Adds level prefabs, theme assets, audio, extensions, and deployment scripts for the Unity WebGL migration. Co-authored-by: Cursor <cursoragent@cursor.com>
916 lines
34 KiB
TypeScript
916 lines
34 KiB
TypeScript
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);
|
||
}
|
||
}
|