Files
cocos/assets/scripts/visual/VisualAssets.ts
刘宇飞 d393302388 Complete Cocos Creator port with level bundles, themes, and tooling.
Adds level prefabs, theme assets, audio, extensions, and deployment scripts for the Unity WebGL migration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 15:30:58 +08:00

916 lines
34 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
Node, Sprite, SpriteFrame, UITransform, resources, Color, Graphics,
ImageAsset, Texture2D, Vec3,
} from 'cc';
import { Direction } from '../core/Define';
import { cellToWorld } from '../core/GridCoords';
import { LevelConfig, SpawnConfig, SpawnKind } from '../level/LevelTypes';
import { resolvePropPlacement } from '../level/EntitySpawnPlacement';
import { PropController } from '../controller/PropController';
import { PlayerController } from '../controller/PlayerController';
import { VehicleController } from '../controller/VehicleController';
import { PlayerActionAnimator } from './PlayerActionAnimator';
import { PlayerAction, resolvePlayerAnimPaths } from './PlayerAnimPaths';
import {
resolveEntityScaleMul,
} from '../level/EntitySpawnDefaults';
import { getTilePivot } from './TilePivots';
import { getTileDrawSize, resolveTilePixelSize } from './TileSizes';
import { alignTileNode, findLevelChildByName, forEachLevelEntityNode } from '../level/TileLayout';
import { ensureResourcesBundle } from '../core/ResourcesBundle';
import {
canonicalThemeKey,
entityTextureCandidates,
resolveThemeFolder,
} from './ThemeEntityTextures';
import { getThemeTilePath } from '../theme/ThemeRegistry';
import {
EntityVisualOptions,
collectLevelEntityTexturePaths,
entityFlipX,
normalizeTexturePath,
resolveEntityTexturePaths,
} from './EntityTextureResolver';
import {
EntityDisplayKind,
ensureSpriteFrameSize,
fitEntityDisplaySize,
} from './EntityDisplayRefs';
type SpriteKey = 'player_F' | 'player_B' | 'ship_F' | 'ship_B' | 'coin' | 'tile' | 'jump' | 'wall';
/** 与 Unity / GameController UIStyleNames 一致(仅用于 UI 主题别名) */
const UI_STYLE_TO_FOLDER: Record<string, string> = {
default: 'silu',
chinese: 'chinese',
redArmy: 'redArmy',
redarmy: 'redArmy',
numMan: 'numMan',
snow: 'snow',
sanxing: 'sanxing',
silu: 'silu',
};
export function normalizeTheme(uiStyle: string | undefined): string {
if (!uiStyle) return 'silu';
return UI_STYLE_TO_FOLDER[uiStyle] ?? canonicalThemeKey(uiStyle);
}
const FILE_BY_KEY: Record<SpriteKey, string> = {
player_F: 'player_F',
player_B: 'player_B',
ship_F: 'ship_F',
ship_B: 'ship_B',
coin: 'coin',
tile: 'Baseblock',
jump: 'JumpBlock',
wall: 'WallBlock',
};
export class VisualAssets {
private static frames = new Map<string, SpriteFrame>();
/** 载具异步预载代次,避免 spawn 与 refresh 并发时旧任务覆盖贴图 */
private static vehicleVisualGeneration = new WeakMap<Node, number>();
/** 按 resources 路径缓存的贴图 */
private static pathFrames = new Map<string, SpriteFrame>();
/** 同路径并发加载去重,避免刷新时部分贴图加载失败 */
private static pathLoading = new Map<string, Promise<SpriteFrame | null>>();
/** 瓦片贴图:按「主题:瓦片名」缓存,多关卡多主题并存 */
private static namedFrames = new Map<string, SpriteFrame>();
/** 切关 / 贴图 meta 更新后清缓存,避免仍用旧 sprite-frame 尺寸 */
static clearNamedTileCache() {
this.namedFrames.clear();
}
/** 切关时清实体贴图路径缓存,避免 ImageAsset 直载帧尺寸陈旧 */
static clearPathFrameCache() {
this.pathFrames.clear();
this.pathLoading.clear();
}
private static loading: Promise<void> | null = null;
/** 角色 / UI 用主题(与关卡砖块主题独立) */
private static uiTheme = 'silu';
/** 设置角色/UI 主题;不清空已加载的关卡砖块贴图 */
static setTheme(uiStyle: string, force = false) {
const next = normalizeTheme(uiStyle);
if (!force && this.uiTheme === next) return;
this.uiTheme = next;
this.loading = null;
}
static getUiTheme(): string {
return this.uiTheme;
}
private static frameKey(key: SpriteKey, theme?: string): string {
const t = theme ? resolveThemeFolder(theme) : this.uiTheme;
return `${t}:${key}`;
}
private static resourcePath(key: SpriteKey, theme?: string): string {
const folder = resolveThemeFolder(theme ?? this.uiTheme);
if (key === 'coin') return `textures/${folder}/Prop_kuai1`;
const file = FILE_BY_KEY[key];
return `textures/${folder}/${file}`;
}
private static pathCandidates(key: SpriteKey, theme?: string): string[] {
const t = theme ?? this.uiTheme;
if (key === 'player_F') return entityTextureCandidates(t, 'playerFront');
if (key === 'player_B') return entityTextureCandidates(t, 'playerBack');
if (key === 'ship_F') return entityTextureCandidates(t, 'shipFront');
if (key === 'ship_B') return entityTextureCandidates(t, 'shipBack');
const folder = resolveThemeFolder(t);
if (key === 'coin') {
return [
`textures/${folder}/Prop_kuai1`,
`textures/${folder}/Prop_kuai2`,
`textures/${folder}/Prop_kuai`,
`textures/${folder}/Prop`,
`textures/${folder}/coin`,
'textures/ui/coin',
'textures/silu/Prop_kuai1',
];
}
const base = `textures/${folder}/${FILE_BY_KEY[key]}`;
const paths = [base];
if (folder !== 'silu') paths.push(`textures/silu/${FILE_BY_KEY[key]}`);
return paths;
}
static async preload(uiStyle?: string): Promise<void> {
if (uiStyle) this.setTheme(uiStyle);
if (this.loading) return this.loading;
this.loading = (async () => {
const keys = Object.keys(FILE_BY_KEY) as SpriteKey[];
const results = await Promise.all(keys.map((k) => this.loadOne(k)));
await Promise.all([
this.loadOne('player_F'),
this.loadOne('player_B'),
this.loadOne('ship_F'),
this.loadOne('ship_B'),
]);
const ok = results.filter(Boolean).length;
console.log(`[VisualAssets] UI 主题=${this.uiTheme} 贴图 ${ok}/${keys.length}`);
if (ok === 0) {
console.warn('[VisualAssets] 未加载到贴图,请运行 tools/import_unity_textures.py 并刷新资源');
}
})().catch((e) => {
console.error('[VisualAssets] preload failed', e);
this.loading = null;
});
return this.loading;
}
private static loadOne(key: SpriteKey, theme?: string): Promise<boolean> {
const fk = this.frameKey(key, theme);
if (this.frames.has(fk)) return Promise.resolve(true);
const tryPaths = this.pathCandidates(key, theme);
const attempt = (path: string): Promise<SpriteFrame | null> => new Promise((resolve) => {
resources.load(`${path}/spriteFrame`, SpriteFrame, (err, sf) => {
if (!err && sf) {
resolve(sf);
return;
}
resources.load(path, SpriteFrame, (err2, sf2) => {
if (!err2 && sf2) {
resolve(sf2);
return;
}
resources.load(path, ImageAsset, (err3, img) => {
if (!err3 && img) {
const tex = new Texture2D();
tex.image = img;
const frame = new SpriteFrame();
frame.texture = tex;
ensureSpriteFrameSize(frame, img.width, img.height);
resolve(frame);
} else {
resolve(null);
}
});
});
});
});
return (async () => {
await ensureResourcesBundle();
for (const p of tryPaths) {
const sf = await attempt(p);
if (sf) {
this.frames.set(fk, sf);
return true;
}
}
console.warn(`[VisualAssets] 加载失败: ${tryPaths[0]}`);
return false;
})();
}
static getFrame(key: SpriteKey, theme?: string): SpriteFrame | null {
return this.frames.get(this.frameKey(key, theme)) ?? null;
}
/** 按关卡配置预加载实体贴图(自定义路径 + theme 回退) */
static async preloadLevelEntities(config: LevelConfig | null | undefined): Promise<void> {
if (!config) return;
const paths = collectLevelEntityTexturePaths(
config.theme,
config.entityTextures,
config.spawns,
config,
);
await Promise.all(paths.map((p) => this.loadTexturePath(p)));
}
/** 切关 / 软重置前预加载本关瓦片 + 实体贴图,避免生成占位块 */
static async ensureLevelAssetsReady(
config: LevelConfig | null | undefined,
tileNames: string[],
): Promise<void> {
if (!config) return;
const theme = normalizeTheme(config.theme || 'silu');
await Promise.all([
this.preloadLevelTiles(theme, tileNames),
this.preloadLevelEntities(config),
]);
}
/** 按路径顺序解析贴图(先缓存后异步加载) */
private static async resolveFirstTexture(paths: string[]): Promise<SpriteFrame | null> {
const validPaths = paths
.map((p) => normalizeTexturePath(p))
.filter((p): p is string => !!p);
for (const p of validPaths) {
const cached = this.getPathFrame(p);
if (cached) return cached;
}
for (const p of validPaths) {
const loaded = await this.loadTexturePath(p);
if (loaded) return loaded;
}
return null;
}
/** @deprecated 使用 preloadLevelEntities */
static async preloadEntityTheme(mapTheme: string): Promise<void> {
const keys: SpriteKey[] = ['player_F', 'player_B', 'ship_F', 'ship_B', 'coin'];
await Promise.all(keys.map((k) => this.loadOne(k, mapTheme)));
}
static getPathFrame(texPath: string): SpriteFrame | null {
const key = normalizeTexturePath(texPath);
if (!key) return null;
return this.pathFrames.get(key) ?? null;
}
private static pathCacheKey(texPath: unknown): string {
return normalizeTexturePath(texPath) ?? '';
}
static loadTexturePath(texPath: string): Promise<SpriteFrame | null> {
if (typeof texPath !== 'string') {
return Promise.resolve(null);
}
const key = normalizeTexturePath(texPath);
if (!key || key === '[object Set]' || key === '[object Object]') {
console.warn('[VisualAssets] 贴图路径无效:', texPath);
return Promise.resolve(null);
}
const cached = this.pathFrames.get(key);
if (cached) return Promise.resolve(cached);
const inflight = this.pathLoading.get(key);
if (inflight) return inflight;
const attempt = (path: string): Promise<SpriteFrame | null> => new Promise((resolve) => {
resources.load(`${path}/spriteFrame`, SpriteFrame, (err, sf) => {
if (!err && sf) {
resolve(sf);
return;
}
resources.load(path, SpriteFrame, (err2, sf2) => {
if (!err2 && sf2) {
resolve(sf2);
return;
}
resources.load(path, ImageAsset, (err3, img) => {
if (!err3 && img) {
const tex = new Texture2D();
tex.image = img;
const frame = new SpriteFrame();
frame.texture = tex;
ensureSpriteFrameSize(frame, img.width, img.height);
resolve(frame);
} else {
resolve(null);
}
});
});
});
});
const job = (async () => {
await ensureResourcesBundle();
const sf = await attempt(key);
if (sf) {
this.pathFrames.set(key, sf);
return sf;
}
console.warn(`[VisualAssets] 贴图路径加载失败: ${key}`);
return null;
})().finally(() => {
this.pathLoading.delete(key);
});
this.pathLoading.set(key, job);
return job;
}
private static namedFrameKey(theme: string, tileName: string): string {
return `${resolveThemeFolder(theme)}:${tileName}`;
}
/** 按关卡主题目录加载瓦片theme 来自 levels-database.json与 UI 主题无关) */
static loadNamedFrame(tileName: string, theme: string): Promise<SpriteFrame | null> {
if (typeof tileName !== 'string') {
return Promise.resolve(null);
}
const tile = tileName.trim();
if (!tile) return Promise.resolve(null);
const folder = resolveThemeFolder(theme);
const cacheKey = this.namedFrameKey(folder, tile);
const cached = this.namedFrames.get(cacheKey);
if (cached) return Promise.resolve(cached);
const base = `textures/${folder}/${tile}`;
const tryPaths: string[] = [];
const themed = getThemeTilePath(theme, tile);
if (themed) tryPaths.push(themed);
tryPaths.push(base);
if (folder !== 'silu') tryPaths.push(`textures/silu/${tile}`);
const attempt = (path: string): Promise<SpriteFrame | null> => new Promise((resolve) => {
resources.load(`${path}/spriteFrame`, SpriteFrame, (err, sf) => {
if (!err && sf) {
resolve(sf);
return;
}
resources.load(path, SpriteFrame, (err2, sf2) => {
if (!err2 && sf2) {
resolve(sf2);
return;
}
resources.load(path, ImageAsset, (err3, img) => {
if (!err3 && img) {
const tex = new Texture2D();
tex.image = img;
const frame = new SpriteFrame();
frame.texture = tex;
ensureSpriteFrameSize(frame, img.width, img.height);
resolve(frame);
} else {
resolve(null);
}
});
});
});
});
return (async () => {
for (const p of tryPaths) {
const sf = await attempt(p);
if (sf) {
this.namedFrames.set(cacheKey, sf);
return sf;
}
}
console.warn(`[VisualAssets] 瓦片贴图缺失: ${base}`);
return null;
})();
}
static applyNamedTile(
node: Node,
tileName: string,
alpha = 255,
cellX?: number,
cellY?: number,
theme?: string,
) {
const folder = resolveThemeFolder(theme);
const cacheKey = this.namedFrameKey(folder, tileName);
const sf = this.namedFrames.get(cacheKey);
if (sf) {
this.applyTileFrame(node, sf, tileName, alpha, cellX, cellY, theme ?? folder);
return Promise.resolve();
}
return this.loadNamedFrame(tileName, theme ?? folder).then((loaded) => {
if (loaded && node?.isValid) {
this.applyTileFrame(node, loaded, tileName, alpha, cellX, cellY, theme ?? folder);
}
});
}
/** 预加载某关卡所需的全部瓦片贴图(可与其他主题缓存共存) */
static preloadLevelTiles(theme: string, tileNames: string[]): Promise<void> {
return Promise.all(tileNames.map((n) => this.loadNamedFrame(n, theme))).then(() => undefined);
}
/** 等距砖块:格子锚点 + 各瓦片 Unity pivot与 Tilemap 一致,不做 keep-position 补偿) */
private static applyTileFrame(
node: Node,
sf: SpriteFrame,
tileName: string,
alpha = 255,
cellX?: number,
cellY?: number,
theme?: string,
) {
let ui = node.getComponent(UITransform);
if (!ui) ui = node.addComponent(UITransform);
const spr = node.getComponent(Sprite) || node.addComponent(Sprite);
spr.enabled = true;
spr.spriteFrame = sf;
spr.sizeMode = Sprite.SizeMode.CUSTOM;
const source = resolveTilePixelSize(tileName, sf, theme);
const draw = getTileDrawSize(tileName, source.width, source.height, theme);
ui.setContentSize(draw.width, draw.height);
const pivot = getTilePivot(tileName, theme);
ui.setAnchorPoint(pivot.x, pivot.y);
if (cellX !== undefined && cellY !== undefined && !Number.isNaN(cellX) && !Number.isNaN(cellY)) {
alignTileNode(node, cellX, cellY, tileName, theme);
}
spr.color = new Color(255, 255, 255, alpha);
node.setScale(1, 1, 1);
}
private static applySpriteFrame(node: Node, sf: SpriteFrame, tileName: string, alpha = 255, theme?: string) {
this.applyTileFrame(node, sf, tileName, alpha, undefined, undefined, theme);
}
static applyPlayerSprite(
node: Node,
direction: Direction,
options: EntityVisualOptions = {},
scaleMul = 1,
) {
const flipX = entityFlipX(direction);
const paths = resolveEntityTexturePaths('player', direction, options);
this.applyTexturePaths(node, paths, {
flipX,
displayKind: 'player',
displayMul: scaleMul,
alpha: 255,
theme: options.theme,
});
}
/** 载具四向贴图:按 direction 直接选图,不做 flipX */
static applyVehicleIconForDirection(
node: Node,
direction: Direction,
options: EntityVisualOptions = {},
scaleMul = 1,
) {
const paths = resolveEntityTexturePaths('vehicle', direction, options);
this.applyTexturePaths(node, paths, {
flipX: false,
displayKind: 'vehicle',
displayMul: scaleMul,
alpha: 255,
theme: options.theme,
});
}
/** @deprecated 使用 applyVehicleIconForDirection */
static applyVehicleIcon(
node: Node,
isFront: boolean,
flipX: boolean,
options: EntityVisualOptions = {},
scaleMul = 1,
) {
const direction = isFront ? Direction.South : Direction.North;
this.applyVehicleIconForDirection(node, direction, options, scaleMul);
}
static applyVehicleSprite(
node: Node,
direction: Direction,
options: EntityVisualOptions = {},
scaleMul = 1,
) {
this.applyVehicleIconForDirection(node, direction, options, scaleMul);
}
/** 预载载具四向贴图,显示由 VehicleController.setIcon 负责 */
private static async preloadVehicleTextures(
node: Node,
_direction: Direction,
options: EntityVisualOptions,
): Promise<void> {
const gen = (this.vehicleVisualGeneration.get(node) ?? 0) + 1;
this.vehicleVisualGeneration.set(node, gen);
const paths = new Set<string>();
for (let d = Direction.North; d <= Direction.West; d++) {
for (const p of resolveEntityTexturePaths('vehicle', d, options)) {
paths.add(p);
}
}
await Promise.all([...paths].map((p) => this.loadTexturePath(p)));
if (gen !== this.vehicleVisualGeneration.get(node)) return;
}
static setupEntityVisual(
node: Node,
kind: SpawnKind,
direction: Direction | undefined,
options: EntityVisualOptions,
scaleMul = 1,
) {
void this.setupEntityVisualAsync(node, kind, direction, options, scaleMul);
}
/** 等待贴图就绪后再挂载精灵,避免可拾取物显示为色块占位 */
static async setupEntityVisualAsync(
node: Node,
kind: SpawnKind,
direction: Direction | undefined,
options: EntityVisualOptions,
scaleMul = 1,
): Promise<void> {
const mul = resolveEntityScaleMul(kind, scaleMul);
const visualOpts: EntityVisualOptions = {
theme: options.theme,
entityTextures: options.entityTextures,
spawnTexture: options.spawnTexture,
propPlacement: options.propPlacement,
};
const mapTheme = options.theme;
if (kind === 'player') {
const animPaths = resolvePlayerAnimPaths(mapTheme);
const animator = node.getComponent(PlayerActionAnimator);
if (animPaths && animator) {
animator.configure(mapTheme, direction ?? Direction.South, mul);
animator.setAction(PlayerAction.Idle, true);
return;
}
const paths = resolveEntityTexturePaths('player', direction, visualOpts);
const flipX = entityFlipX(direction ?? Direction.South);
const ok = await this.applyTexturePathsAsync(node, paths, {
flipX,
displayKind: 'player',
displayMul: mul,
alpha: 255,
theme: mapTheme,
});
if (!ok) this.applyPlayerSprite(node, direction ?? Direction.South, visualOpts, mul);
return;
}
if (kind === 'vehicle') {
const dir = direction ?? Direction.North;
await this.preloadVehicleTextures(node, dir, visualOpts);
if (!node?.isValid) return;
node.getComponent(VehicleController)?.refreshIcon()
?? this.applyVehicleIconForDirection(node, dir, visualOpts, mul);
return;
}
if (kind === 'prop') {
const paths = resolveEntityTexturePaths('prop', direction, visualOpts);
const displayKind: EntityDisplayKind = visualOpts.propPlacement === 'ground' ? 'propGround' : 'prop';
const ok = await this.applyTexturePathsAsync(node, paths, {
flipX: false,
displayKind,
displayMul: mul,
alpha: 255,
theme: mapTheme,
});
if (!ok) {
await Promise.all(paths.map((p) => this.loadTexturePath(p)));
await this.applyTexturePathsAsync(node, paths, {
flipX: false,
displayKind,
displayMul: mul,
alpha: 255,
theme: mapTheme,
});
}
return;
}
if (kind === 'prop_decor') {
const paths = resolveEntityTexturePaths('prop_decor', direction, visualOpts);
await this.applyTexturePathsAsync(node, paths, {
flipX: false,
displayKind: 'prop_decor',
displayMul: mul,
alpha: 120,
theme: mapTheme,
});
}
}
/** 软重置 / 同关刷新:优先用实体当前逻辑朝向,避免贴图回退到 spawn 方向 */
static async refreshLevelEntityVisuals(levelRoot: Node, config: LevelConfig): Promise<void> {
if (!levelRoot?.isValid || !config) return;
const theme = config.theme || 'silu';
const playerNode = findLevelChildByName(levelRoot, 'Player')
?? (() => {
let found: Node | null = null;
forEachLevelEntityNode(levelRoot, (c) => {
if (!found && c.getComponent(PlayerController)) found = c;
});
return found;
})();
const playerCtrl = playerNode?.getComponent(PlayerController) ?? null;
const jobs: Promise<void>[] = [];
for (const s of config.spawns ?? []) {
if (s.kind === 'player' && resolvePlayerAnimPaths(theme)) {
continue;
}
const node = this.findSpawnNode(levelRoot, s);
if (!node?.isValid) continue;
const dir = this.resolveRuntimeEntityDirection(s, node, playerCtrl);
let propPlacement = s.propPlacement;
if (s.kind === 'prop' && !propPlacement) {
propPlacement = resolvePropPlacement(s, config);
}
jobs.push(this.setupEntityVisualAsync(node, s.kind, dir, {
theme,
entityTextures: config.entityTextures,
spawnTexture: s.texture,
propPlacement,
}, s.scale));
}
await Promise.all(jobs);
for (const s of config.spawns ?? []) {
if (s.kind !== 'vehicle') continue;
const node = this.findSpawnNode(levelRoot, s);
node?.getComponent(VehicleController)?.refreshIcon();
}
}
private static resolveRuntimeEntityDirection(
s: SpawnConfig,
node: Node,
playerCtrl: PlayerController | null,
): Direction | undefined {
if (s.kind === 'player') {
return node.getComponent(PlayerController)?.direction
?? this.readSpawnDirection(s.playerDirection);
}
if (s.kind === 'vehicle') {
const vc = node.getComponent(VehicleController);
if (vc && playerCtrl) {
if (playerCtrl.getRideVehicle() === vc) {
return playerCtrl.direction;
}
const pCell = playerCtrl.getCommittedCell() ?? playerCtrl.getSpawnCell();
const vCell = vc.getCommittedCell() ?? vc.getSpawnCell()
?? new Vec3(s.x, s.y, 0);
if (pCell && pCell.x === vCell.x && pCell.y === vCell.y) {
return playerCtrl.direction;
}
}
return vc?.direction ?? this.readSpawnDirection(s.vehicleDirection);
}
return undefined;
}
private static readSpawnDirection(raw?: Direction | string): Direction | undefined {
if (raw === undefined || raw === null) return undefined;
if (typeof raw === 'number') return raw as Direction;
const name = String(raw).replace(/^Direction\./, '');
const key = name as keyof typeof Direction;
if (Object.prototype.hasOwnProperty.call(Direction, key)) {
const v = Direction[key];
if (typeof v === 'number') return v as Direction;
}
return undefined;
}
private static findSpawnNode(levelRoot: Node, s: SpawnConfig): Node | null {
if (s.kind === 'player') {
let found = findLevelChildByName(levelRoot, 'Player');
if (found) return found;
forEachLevelEntityNode(levelRoot, (c) => {
if (!found && c.getComponent(PlayerController)) found = c;
});
return found;
}
if (s.kind === 'vehicle') {
let found: Node | null = null;
forEachLevelEntityNode(levelRoot, (c) => {
if (!found && c.getComponent(VehicleController)) found = c;
});
return found;
}
if (s.kind === 'prop') {
let found = findLevelChildByName(levelRoot, `Prop_${s.x}_${s.y}`);
if (found) return found;
forEachLevelEntityNode(levelRoot, (c) => {
if (found) return;
const prop = c.getComponent(PropController);
const cell = prop?.getSpawnCell();
if (cell && cell.x === s.x && cell.y === s.y) found = c;
});
return found;
}
if (s.kind === 'prop_decor') {
return findLevelChildByName(levelRoot, 'PropDecor')
?? (() => {
let found: Node | null = null;
forEachLevelEntityNode(levelRoot, (c) => {
if (!found && c.name === 'PropDecor') found = c;
});
return found;
})();
}
return null;
}
private static async applyTexturePathsAsync(
node: Node,
paths: string[],
opts: {
flipX: boolean;
displayKind: EntityDisplayKind;
displayMul: number;
alpha: number;
theme?: string;
},
): Promise<boolean> {
const sf = await this.resolveFirstTexture(paths);
if (!sf || !node?.isValid) return false;
this.commitTexturePaths(node, sf, opts);
return true;
}
private static applyTexturePaths(
node: Node,
paths: string[],
opts: {
flipX: boolean;
displayKind: EntityDisplayKind;
displayMul: number;
alpha: number;
theme?: string;
},
) {
const validPaths = paths
.map((p) => normalizeTexturePath(p))
.filter((p): p is string => !!p);
let sf: SpriteFrame | null = null;
for (const p of validPaths) {
sf = this.getPathFrame(p);
if (sf) break;
}
if (sf) this.commitTexturePaths(node, sf, opts);
}
/** 贴图在根节点;四向贴图已含朝向,根节点 scale 恒为 1 */
private static prepareVehicleSpriteRoot(root: Node) {
const legacy = root.getChildByName('VehicleVisual');
if (legacy?.isValid) legacy.destroy();
root.setScale(1, 1, 1);
}
private static commitTexturePaths(
node: Node,
sf: SpriteFrame,
opts: {
flipX: boolean;
displayKind: EntityDisplayKind;
displayMul: number;
alpha: number;
theme?: string;
},
) {
const { flipX, displayKind, displayMul, alpha, theme } = opts;
const isVehicle = displayKind === 'vehicle';
if (isVehicle) this.prepareVehicleSpriteRoot(node);
const target = node;
let ui = target.getComponent(UITransform);
if (!ui) {
ui = target.addComponent(UITransform);
ui.setContentSize(48, 48);
}
const g = target.getComponent(Graphics);
if (g) g.destroy();
const sprite = target.getComponent(Sprite) || target.addComponent(Sprite);
sprite.spriteFrame = sf;
sprite.sizeMode = Sprite.SizeMode.CUSTOM;
const fit = fitEntityDisplaySize(displayKind, sf, displayMul, theme);
ui.setAnchorPoint(fit.anchorX, fit.anchorY);
ui.setContentSize(fit.width, fit.height);
sprite.color = new Color(255, 255, 255, alpha);
if (isVehicle) {
target.setScale(1, 1, 1);
} else {
target.setScale(flipX ? -1 : 1, 1, 1);
}
}
/** 旧接口:按 SpriteKey + theme 缓存UI 等仍可用) */
static applyPlayerSpriteLegacy(node: Node, direction: Direction, mapTheme?: string, scaleMul = 1) {
const isFront = direction === Direction.South || direction === Direction.East;
const flipX = direction === Direction.West || direction === Direction.East;
const sk: SpriteKey = isFront ? 'player_F' : 'player_B';
this.applySprite(node, sk, flipX, 1, 255, mapTheme, scaleMul, 'player');
}
static applyVehicleSpriteLegacy(node: Node, direction: Direction, mapTheme?: string, scaleMul = 1) {
this.applyVehicleSprite(node, direction, { theme: mapTheme }, scaleMul);
}
static setupEntityVisualLegacy(
node: Node,
kind: SpawnKind,
direction?: Direction,
mapTheme?: string,
scaleMul = 1,
) {
const mul = resolveEntityScaleMul(kind, scaleMul);
if (kind === 'player') {
this.applyPlayerSpriteLegacy(node, direction ?? Direction.South, mapTheme, mul);
return;
}
if (kind === 'vehicle') {
this.applyVehicleSpriteLegacy(node, direction ?? Direction.North, mapTheme, mul);
return;
}
if (kind === 'prop') {
this.applySprite(node, 'coin', false, mul, 255, mapTheme);
return;
}
if (kind === 'prop_decor') {
this.applySprite(node, 'coin', false, mul, 120, mapTheme);
}
}
static applySprite(
node: Node,
key: SpriteKey,
flipX: boolean,
scale = 1,
alpha = 255,
mapTheme?: string,
heightMul = 1,
heightKind?: 'player' | 'vehicle',
) {
let ui = node.getComponent(UITransform);
if (!ui) {
ui = node.addComponent(UITransform);
ui.setContentSize(48, 48);
}
const sf = this.getFrame(key, mapTheme);
if (!sf && mapTheme) {
void this.loadOne(key, mapTheme).then((ok) => {
if (ok && node?.isValid) {
this.applySprite(node, key, flipX, scale, alpha, mapTheme, heightMul, heightKind);
}
});
}
const spr = node.getComponent(Sprite);
const g = node.getComponent(Graphics);
if (sf) {
if (g) g.destroy();
const sprite = spr || node.addComponent(Sprite);
sprite.spriteFrame = sf;
sprite.sizeMode = Sprite.SizeMode.CUSTOM;
const isPlayer = key === 'player_F' || key === 'player_B';
const isShip = key === 'ship_F' || key === 'ship_B';
if (isPlayer || isShip) {
const kind: EntityDisplayKind = isPlayer ? 'player' : 'vehicle';
const fit = fitEntityDisplaySize(kind, sf, heightMul, mapTheme);
ui.setAnchorPoint(fit.anchorX, fit.anchorY);
ui.setContentSize(fit.width, fit.height);
} else {
const fit = fitEntityDisplaySize('prop', sf, scale, mapTheme);
ui.setAnchorPoint(fit.anchorX, fit.anchorY);
ui.setContentSize(fit.width, fit.height);
}
sprite.color = new Color(255, 255, 255, alpha);
} else if (!spr) {
const graphics = g || node.addComponent(Graphics);
graphics.fillColor = key === 'coin'
? new Color(255, 220, 0, alpha)
: new Color(80, 160, 255, alpha);
const w = ui.contentSize.width * 0.45 * scale;
graphics.clear();
graphics.rect(-w, -w, w * 2, w * 2);
graphics.fill();
} else if (g) {
g.destroy();
}
const sx = flipX ? -1 : 1;
node.setScale(sx, 1, 1);
}
}