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 = { 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 = { 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(); /** 载具异步预载代次,避免 spawn 与 refresh 并发时旧任务覆盖贴图 */ private static vehicleVisualGeneration = new WeakMap(); /** 按 resources 路径缓存的贴图 */ private static pathFrames = new Map(); /** 同路径并发加载去重,避免刷新时部分贴图加载失败 */ private static pathLoading = new Map>(); /** 瓦片贴图:按「主题:瓦片名」缓存,多关卡多主题并存 */ private static namedFrames = new Map(); /** 切关 / 贴图 meta 更新后清缓存,避免仍用旧 sprite-frame 尺寸 */ static clearNamedTileCache() { this.namedFrames.clear(); } /** 切关时清实体贴图路径缓存,避免 ImageAsset 直载帧尺寸陈旧 */ static clearPathFrameCache() { this.pathFrames.clear(); this.pathLoading.clear(); } private static loading: Promise | 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 { 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 { 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 => 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 { 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 { 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 { 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 { 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 { 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 => 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 { 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 => 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 { 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 { const gen = (this.vehicleVisualGeneration.get(node) ?? 0) + 1; this.vehicleVisualGeneration.set(node, gen); const paths = new Set(); 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 { 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 { 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[] = []; 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 { 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); } }