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>
This commit is contained in:
2026-06-16 15:30:58 +08:00
parent cba5105908
commit d393302388
6248 changed files with 17322729 additions and 11036 deletions

View File

@@ -0,0 +1,157 @@
import { Size, SpriteFrame } from 'cc';
import { CELL_PIXEL } from '../core/GridConstants';
import { getThemeEntityDisplayCellBoxes } from '../theme/ThemeDatabase';
/** 内置默认themes-database 未加载时使用) */
export const DEFAULT_ENTITY_CELL_BOX = {
player: { w: 0.68, h: 0.9 },
vehicle: { w: 0.96, h: 0.88 },
prop: { w: 0.52, h: 0.69 },
propGround: { w: 0.52, h: 0.69 },
} as const;
/** 装饰物相对可拾取物的缩放比 */
const PROP_DECOR_RATIO = 0.45 / 0.69;
const cellW = (ratio: number) => CELL_PIXEL * ratio;
const cellH = (ratio: number) => CELL_PIXEL * ratio;
export function getEntityCellBox(theme?: string) {
if (theme) return getThemeEntityDisplayCellBoxes(theme);
return { ...DEFAULT_ENTITY_CELL_BOX };
}
export const DEFAULT_PLAYER_ANCHOR_Y = 0.35;
/** Unity silu ship pivot y = 0.45 */
export const DEFAULT_VEHICLE_ANCHOR_Y = 0.45;
export function getEntityDisplaySizes(theme?: string) {
const box = getEntityCellBox(theme);
return {
player: {
width: cellW(box.player.w),
height: cellH(box.player.h),
},
vehicle: {
width: cellW(box.vehicle.w),
height: cellH(box.vehicle.h),
},
prop: {
width: cellW(box.prop.w),
height: cellH(box.prop.h),
},
propGround: {
width: cellW(box.propGround.w),
height: cellH(box.propGround.h),
},
prop_decor: {
width: cellW(box.prop.w) * PROP_DECOR_RATIO,
height: cellH(box.prop.h) * PROP_DECOR_RATIO,
},
};
}
/** @deprecated 使用 getEntityDisplaySizes(theme) */
export const ENTITY_CELL_BOX = DEFAULT_ENTITY_CELL_BOX;
/** @deprecated 使用 getEntityDisplaySizes(theme) */
export const SANXING_ENTITY_DISPLAY = {
get player() { return getEntityDisplaySizes().player; },
get vehicle() { return getEntityDisplaySizes().vehicle; },
get prop() { return getEntityDisplaySizes().prop; },
get prop_decor() { return getEntityDisplaySizes().prop_decor; },
};
export type EntityDisplayKind = 'player' | 'vehicle' | 'prop' | 'propGround' | 'prop_decor';
type SpawnKindLike = 'player' | 'vehicle' | 'prop' | 'prop_decor';
export function entityPlaceholderSize(kind: SpawnKindLike, theme?: string): { width: number; height: number } {
const sizes = getEntityDisplaySizes(theme);
if (kind === 'prop_decor') return sizes.prop_decor;
if (kind === 'prop') return sizes.prop;
if (kind === 'vehicle') return sizes.vehicle;
return sizes.player;
}
export function spriteOriginalSize(sf: SpriteFrame): { width: number; height: number } {
const rect = sf.rect;
if (rect.width > 0 && rect.height > 0) {
return { width: rect.width, height: rect.height };
}
let w = sf.originalSize?.width ?? 0;
let h = sf.originalSize?.height ?? 0;
if (w <= 0 || h <= 0) {
w = sf.texture?.width ?? 1;
h = sf.texture?.height ?? 1;
}
return { width: Math.max(1, w), height: Math.max(1, h) };
}
/** 等比缩放完整落入主题包围盒contain */
export function fitEntityDisplaySize(
kind: EntityDisplayKind,
sf: SpriteFrame,
scaleMul = 1,
theme?: string,
): { width: number; height: number; anchorX: number; anchorY: number } {
const s = computeEntityUniformScale(kind, sf, scaleMul, theme);
return sizeFromUniformScale(sf, s, kind, theme);
}
/** 单帧 contain 缩放系数(序列动画各帧共用,避免画布尺寸不同导致比例跳变) */
export function computeEntityUniformScale(
kind: EntityDisplayKind,
sf: SpriteFrame,
scaleMul = 1,
theme?: string,
): number {
const mul = Math.max(0.1, Math.min(4, scaleMul));
const { width: ow, height: oh } = spriteOriginalSize(sf);
const ref = getEntityDisplaySizes(theme)[kind];
return Math.min(ref.width * mul / ow, ref.height * mul / oh);
}
export function sizeFromUniformScale(
sf: SpriteFrame,
uniformScale: number,
kind: EntityDisplayKind = 'player',
theme?: string,
): { width: number; height: number; anchorX: number; anchorY: number } {
const { width: ow, height: oh } = spriteOriginalSize(sf);
let anchorY = 0;
if (kind === 'player') {
anchorY = DEFAULT_PLAYER_ANCHOR_Y;
} else if (kind === 'vehicle') {
anchorY = DEFAULT_VEHICLE_ANCHOR_Y;
} else if (kind !== 'prop' && kind !== 'propGround' && kind !== 'prop_decor') {
anchorY = DEFAULT_PLAYER_ANCHOR_Y;
}
return {
width: ow * uniformScale,
height: oh * uniformScale,
anchorX: 0.5,
anchorY,
};
}
/**
* 序列帧脚点对齐:各帧保留原始比例,仅调 anchorY。
* 脚离画布底边像素一致时anchorY = baseAnchorY * refOh / oh
*/
export function playerSeqFrameAnchorY(
sf: SpriteFrame,
refSf: SpriteFrame,
baseAnchorY = DEFAULT_PLAYER_ANCHOR_Y,
): number {
const oh = Math.max(1, spriteOriginalSize(sf).height);
const refOh = Math.max(1, spriteOriginalSize(refSf).height);
if (oh === refOh) return baseAnchorY;
return baseAnchorY * refOh / oh;
}
/** 动态 SpriteFrame 补全 originalSizeImageAsset 直载时 Cocos 默认为 0 */
export function ensureSpriteFrameSize(sf: SpriteFrame, width: number, height: number) {
if (sf.originalSize.width > 0 && sf.originalSize.height > 0) return;
sf.originalSize = new Size(Math.max(1, width), Math.max(1, height));
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "c5ea903f-83fc-42e9-8721-5540fc38c79f",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,318 @@
import { Direction } from '../core/Define';
import { LevelConfig, LevelEntityTextures, SpawnConfig, SpawnKind } from '../level/LevelTypes';
import { resolvePropPlacement } from '../level/EntitySpawnPlacement';
import type { PropPlacement } from '../level/EntitySpawnPlacement';
import { getThemeBackground, getThemeEntities, getThemeTextureFolder, isThemeDatabaseReady } from '../theme/ThemeRegistry';
import {
entityTextureCandidates,
vehicleSpriteRoleForDirection,
vehicleThemeFieldForDirection,
allVehicleDirectionTextureCandidates,
type EntitySpriteRole,
} from './ThemeEntityTextures';
const THEME_TEXTURE_FOLDER: Record<string, string> = {
default: 'default',
silu: 'silu',
SILU: 'silu',
chinese: 'chinese',
redArmy: 'redArmy',
redarmy: 'redArmy',
numMan: 'numMan',
snow: 'snow',
sanxing: 'sanxing',
};
function resolveThemeFolder(theme: string | undefined): string {
if (!theme) return 'silu';
const trimmed = String(theme).trim();
if (isThemeDatabaseReady()) return getThemeTextureFolder(trimmed);
return THEME_TEXTURE_FOLDER[trimmed] ?? THEME_TEXTURE_FOLDER[trimmed.toLowerCase()] ?? trimmed;
}
export interface EntityVisualOptions {
theme?: string;
entityTextures?: LevelEntityTextures;
/** 单个 spawn 的贴图覆盖(可拾取物常用) */
spawnTexture?: string;
/** 可拾取物放置高度Unity Prop / nProp */
propPlacement?: PropPlacement;
}
/** 统一为 resources 相对路径,无 .png 后缀 */
export function normalizeTexturePath(raw: string | undefined): string | undefined {
if (!raw) return undefined;
let p = String(raw).trim().replace(/\\/g, '/');
if (!p) return undefined;
if (p.startsWith('assets/resources/')) p = p.slice('assets/resources/'.length);
if (p.startsWith('resources/')) p = p.slice('resources/'.length);
if (p.startsWith('/')) p = p.slice(1);
if (p.endsWith('.png')) p = p.slice(0, -4);
return p;
}
function themePropCandidates(theme: string | undefined): string[] {
const folder = resolveThemeFolder(theme);
return [
`textures/${folder}/Prop_kuai1`,
'textures/silu/Prop_kuai1',
];
}
function themeGroundPropCandidates(theme: string | undefined): string[] {
const folder = resolveThemeFolder(theme);
return [
`textures/${folder}/nProp_kuai1`,
'textures/silu/nProp_kuai1',
];
}
/** Prop_kuai2 → nProp_kuai2Prop → nProp */
export function toGroundPropTexturePath(blockPath: string | undefined): string | undefined {
const norm = normalizeTexturePath(blockPath);
if (!norm) return undefined;
const slash = norm.lastIndexOf('/');
const dir = slash >= 0 ? norm.slice(0, slash + 1) : '';
const file = slash >= 0 ? norm.slice(slash + 1) : norm;
if (file.startsWith('Prop_')) return `${dir}n${file}`;
if (file === 'Prop') return `${dir}nProp`;
if (file.startsWith('nProp')) return norm;
return norm.replace(/\/Prop([^/]*)$/, '/nProp$1');
}
function themeDbEntityPath(
theme: string | undefined,
field: keyof NonNullable<ReturnType<typeof getThemeEntities>>,
): string | undefined {
const ent = getThemeEntities(theme);
if (!ent) return undefined;
return normalizeTexturePath(ent[field]);
}
function themeDbEntityCandidates(
theme: string | undefined,
field: keyof NonNullable<ReturnType<typeof getThemeEntities>>,
role: EntitySpriteRole,
): string[] {
const db = themeDbEntityPath(theme, field);
const legacy = entityTextureCandidates(theme, role);
return withFallback(db, legacy);
}
function levelVehicleTextureOverride(
direction: Direction,
entityTextures: LevelEntityTextures | undefined,
): string | undefined {
if (!entityTextures) return undefined;
const field = vehicleThemeFieldForDirection(direction);
const direct = normalizeTexturePath(entityTextures[field]);
if (direct) return direct;
const front = isEntityFront(direction);
return normalizeTexturePath(
front ? entityTextures.vehicleFront : entityTextures.vehicleBack,
);
}
function withFallback(primary: string | undefined, fallbacks: string[]): string[] {
const out: string[] = [];
const norm = normalizeTexturePath(primary);
if (norm) out.push(norm);
for (const p of fallbacks) {
if (!out.includes(p)) out.push(p);
}
return out;
}
function themePropCandidatesWithDb(theme: string | undefined): string[] {
const db = themeDbEntityPath(theme, 'prop');
if (db) return [db];
return themePropCandidates(theme);
}
function themeGroundPropCandidatesWithDb(
theme: string | undefined,
blockPropPath?: string,
): string[] {
const dbGround = themeDbEntityPath(theme, 'propGround');
if (dbGround) return [dbGround];
const derived = toGroundPropTexturePath(blockPropPath ?? themeDbEntityPath(theme, 'prop'));
if (derived) return [derived];
return themeGroundPropCandidates(theme);
}
export function isEntityFront(direction: Direction | undefined): boolean {
return direction === Direction.South || direction === Direction.East;
}
export function entityFlipX(direction: Direction | undefined): boolean {
return direction === Direction.West || direction === Direction.East;
}
/** @deprecated 载具已改用四向贴图;玩家仍用 IsFront + flipX */
export function resolveVehicleOrientation(direction: Direction | undefined): {
front: boolean;
flipX: boolean;
} {
const dir = direction ?? Direction.North;
return {
front: isEntityFront(dir),
flipX: entityFlipX(dir),
};
}
export function resolvePlayerTexturePaths(
direction: Direction | undefined,
options: EntityVisualOptions = {},
): string[] {
const front = isEntityFront(direction);
const custom = front
? normalizeTexturePath(options.entityTextures?.playerFront)
: normalizeTexturePath(options.entityTextures?.playerBack);
const spawnCustom = normalizeTexturePath(options.spawnTexture);
const themePaths = themeDbEntityCandidates(
options.theme,
front ? 'playerFront' : 'playerBack',
front ? 'playerFront' : 'playerBack',
);
if (spawnCustom) return withFallback(spawnCustom, themePaths);
if (custom) return withFallback(custom, themePaths);
return themePaths;
}
/** 载具四向贴图路径(北/东/南/西各一张,转向时直接切换,无 flipX */
export function resolveVehicleTexturePaths(
direction: Direction | undefined,
options: EntityVisualOptions = {},
): string[] {
const dir = direction ?? Direction.North;
const field = vehicleThemeFieldForDirection(dir);
const role = vehicleSpriteRoleForDirection(dir);
const custom = levelVehicleTextureOverride(dir, options.entityTextures);
const spawnCustom = normalizeTexturePath(options.spawnTexture);
const themePaths = themeDbEntityCandidates(options.theme, field, role);
if (spawnCustom) return withFallback(spawnCustom, themePaths);
if (custom) return withFallback(custom, themePaths);
return themePaths;
}
/** 预加载主题载具四向贴图 */
export function collectThemeVehicleTexturePaths(
theme: string | undefined,
entityTextures?: LevelEntityTextures,
): string[] {
const set = new Set<string>();
const add = (p: string | undefined) => {
const n = normalizeTexturePath(p);
if (n) set.add(n);
};
for (const p of allVehicleDirectionTextureCandidates(theme)) add(p);
if (entityTextures) {
for (let d = Direction.North; d <= Direction.West; d++) {
add(levelVehicleTextureOverride(d, entityTextures));
}
}
return Array.from(set);
}
export function resolvePropTexturePaths(options: EntityVisualOptions = {}): string[] {
const spawnCustom = normalizeTexturePath(options.spawnTexture);
const levelBlock = normalizeTexturePath(options.entityTextures?.prop);
const levelGround = normalizeTexturePath(options.entityTextures?.propGround);
const onGround = options.propPlacement === 'ground';
if (spawnCustom) {
const custom = onGround ? toGroundPropTexturePath(spawnCustom) ?? spawnCustom : spawnCustom;
const fallback = onGround
? themeGroundPropCandidatesWithDb(options.theme, levelBlock)
: themePropCandidatesWithDb(options.theme);
return withFallback(custom, fallback);
}
if (onGround && levelGround) {
return withFallback(levelGround, themeGroundPropCandidatesWithDb(options.theme, levelBlock));
}
if (levelBlock) {
const custom = onGround ? toGroundPropTexturePath(levelBlock) ?? levelBlock : levelBlock;
return withFallback(custom, onGround
? themeGroundPropCandidatesWithDb(options.theme, levelBlock)
: themePropCandidatesWithDb(options.theme));
}
return onGround
? themeGroundPropCandidatesWithDb(options.theme)
: themePropCandidatesWithDb(options.theme);
}
export function resolveEntityTexturePaths(
kind: SpawnKind,
direction: Direction | undefined,
options: EntityVisualOptions = {},
): string[] {
if (kind === 'player') return resolvePlayerTexturePaths(direction, options);
if (kind === 'vehicle') return resolveVehicleTexturePaths(direction, options);
if (kind === 'prop' || kind === 'prop_decor') return resolvePropTexturePaths(options);
return themePropCandidates(options.theme);
}
/** 预加载关卡用到的实体贴图(仅主路径,不扫全量 fallback */
export function collectLevelEntityTexturePaths(
theme: string | undefined,
entityTextures?: LevelEntityTextures,
spawns?: SpawnConfig[],
levelConfig?: Pick<LevelConfig, 'ground' | 'border'>,
): string[] {
const set = new Set<string>();
const add = (p: string | undefined) => {
const n = normalizeTexturePath(p);
if (n) set.add(n);
};
const addPrimary = (paths: string[]) => {
for (const p of paths) add(p);
};
add(getThemeBackground(theme));
const visualBase: EntityVisualOptions = { theme, entityTextures };
const list = spawns ?? [];
for (const s of list) {
if (s.kind === 'player') {
addPrimary(resolvePlayerTexturePaths(
parseSpawnDirection(s.playerDirection) ?? Direction.South,
{ ...visualBase, spawnTexture: s.texture },
));
} else if (s.kind === 'vehicle') {
for (let d = Direction.North; d <= Direction.West; d++) {
addPrimary(resolveVehicleTexturePaths(d, { ...visualBase, spawnTexture: s.texture }));
}
} else if (s.kind === 'prop') {
const placement: PropPlacement = levelConfig
? resolvePropPlacement(s, levelConfig as LevelConfig)
: (s.propPlacement ?? 'block');
addPrimary(resolvePropTexturePaths({
...visualBase,
spawnTexture: s.texture,
propPlacement: placement,
}));
} else if (s.kind === 'prop_decor') {
addPrimary(resolvePropTexturePaths(visualBase));
}
}
if (!list.length) {
addPrimary(resolvePlayerTexturePaths(Direction.South, visualBase));
for (const p of collectThemeVehicleTexturePaths(theme, entityTextures)) add(p);
addPrimary(resolvePropTexturePaths(visualBase));
}
return Array.from(set);
}
function parseSpawnDirection(raw: unknown): Direction | undefined {
if (typeof raw === 'number') return raw as Direction;
if (typeof raw !== 'string') return undefined;
const name = 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;
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "f794ac4d-27dd-4b75-ac42-dabe97cdb679",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,335 @@
import {
_decorator, Asset, Component, ImageAsset, Node, resources, Sprite, SpriteFrame, Texture2D, UITransform,
} from 'cc';
import { Direction } from '../core/Define';
import { EntityVisualOptions, entityFlipX } from './EntityTextureResolver';
import {
computeEntityUniformScale, ensureSpriteFrameSize, playerSeqFrameAnchorY,
sizeFromUniformScale, spriteOriginalSize,
} from './EntityDisplayRefs';
import { VisualAssets } from './VisualAssets';
import { GameManager } from '../manager/GameManager';
import { Movement } from '../gameplay/Movement';
import { resolveThemeId } from '../theme/ThemeDatabase';
import {
PlayerAction, PlayerAnimPaths, actionFolder, resolvePlayerAnimPaths,
} from './PlayerAnimPaths';
const { ccclass } = _decorator;
const FRAME_INTERVAL: Record<PlayerAction, number> = {
[PlayerAction.Idle]: 0.22,
[PlayerAction.Move]: 0.12,
[PlayerAction.Jump]: 0.1,
[PlayerAction.Win]: 0.14,
[PlayerAction.Fail]: 0,
};
const seqCache = new Map<string, SpriteFrame[]>();
async function loadSpriteFrame(path: string): Promise<SpriteFrame | null> {
const loadSf = (p: string) => new Promise<SpriteFrame | null>((resolve) => {
resources.load(p, SpriteFrame, (err, sf) => resolve(!err && sf ? sf : null));
});
const loadImg = (p: string) => new Promise<SpriteFrame | null>((resolve) => {
resources.load(p, ImageAsset, (err, img) => {
if (err || !img) {
resolve(null);
return;
}
const tex = new Texture2D();
tex.image = img;
const frame = new SpriteFrame();
frame.texture = tex;
ensureSpriteFrameSize(frame, img.width, img.height);
resolve(frame);
});
});
return (await loadSf(`${path}/spriteFrame`))
?? (await loadSf(path))
?? (await loadImg(path));
}
/** 从文件名推断序列帧顺序(兼容 1.png / 走1.png / 小红军走1.png / 机器人-1.png */
function frameOrderFromName(assetPath: string): number {
const stem = assetPath.replace(/\\/g, '/').split('/').pop()?.replace(/\.[^.]+$/, '') ?? assetPath;
if (stem === '机器人') return 0;
const dash = stem.match(/-(\d+)$/);
if (dash) return parseInt(dash[1], 10);
const trail = stem.match(/(\d+)$/);
if (trail) return parseInt(trail[1], 10) - 1;
return 0;
}
function imageToSpriteFrame(img: ImageAsset): SpriteFrame {
const tex = new Texture2D();
tex.image = img;
const frame = new SpriteFrame();
frame.texture = tex;
ensureSpriteFrameSize(frame, img.width, img.height);
return frame;
}
function loadDirAssets<T extends Asset>(
folder: string,
type: new (...args: never[]) => T,
): Promise<T[]> {
return new Promise((resolve) => {
resources.loadDir(folder, type, (err, assets) => {
if (err || !assets?.length) {
resolve([]);
return;
}
resolve(assets);
});
});
}
async function loadFrameSequence(folder: string): Promise<SpriteFrame[]> {
const hit = seqCache.get(folder);
if (hit) return hit;
const spriteAssets = await loadDirAssets(folder, SpriteFrame);
let entries: { order: number; frame: SpriteFrame }[] = spriteAssets.map((sf) => ({
order: frameOrderFromName(sf.name),
frame: sf,
}));
if (entries.length === 0) {
const images = await loadDirAssets(folder, ImageAsset);
entries = images.map((img) => ({
order: frameOrderFromName(img.name),
frame: imageToSpriteFrame(img),
}));
}
if (entries.length === 0) {
const frames: SpriteFrame[] = [];
for (let i = 1; i <= 16; i++) {
const leaf = folder.split('/').pop() ?? '';
const candidates = [
`${folder}/${i}`,
leaf ? `${folder}/${leaf}${i}` : '',
i === 1 ? `${folder}/机器人` : `${folder}/机器人-${i - 1}`,
`${folder}/小红军${leaf}${i}`,
].filter(Boolean);
let sf: SpriteFrame | null = null;
for (const path of candidates) {
sf = await loadSpriteFrame(path);
if (sf) break;
}
if (!sf) break;
frames.push(sf);
}
if (frames.length > 0) seqCache.set(folder, frames);
return frames;
}
entries.sort((a, b) => a.order - b.order || a.frame.name.localeCompare(b.frame.name));
const frames = entries.map((e) => e.frame);
seqCache.set(folder, frames);
return frames;
}
/**
* 序列帧角色动画(对齐 Unity Animator Action 04
* 无 skin 动画目录时回退 VisualAssets 静态贴图。
*/
@ccclass('PlayerActionAnimator')
export class PlayerActionAnimator extends Component {
private action = PlayerAction.Idle;
private direction = Direction.South;
private theme = 'silu';
private scaleMul = 1;
private animPaths: PlayerAnimPaths | null = null;
private frames: SpriteFrame[] = [];
private frameIdx = 0;
private frameTimer = 0;
private loadingGen = 0;
private useSequence = false;
/** 主题级统一缩放(按最高序列帧锁定,避免待机/走路切换时重新算 scale */
private lockedUniformScale: number | null = null;
/** 脚点对齐参考帧(与 lockedUniformScale 同源,取最高帧) */
private refIdleFrame: SpriteFrame | null = null;
private displayLockPromise: Promise<void> | null = null;
private get spriteNode(): Node {
return this.node;
}
configure(theme: string | undefined, direction: Direction, scaleMul = 1) {
const nextTheme = theme ?? 'silu';
const themeChanged = resolveThemeId(this.theme) !== resolveThemeId(nextTheme)
|| this.scaleMul !== scaleMul;
this.theme = nextTheme;
this.direction = direction;
this.scaleMul = scaleMul;
if (themeChanged) {
this.lockedUniformScale = null;
this.refIdleFrame = null;
this.displayLockPromise = null;
}
this.animPaths = resolvePlayerAnimPaths(this.theme);
this.useSequence = this.animPaths !== null;
if (!this.useSequence) {
VisualAssets.applyPlayerSprite(this.spriteNode, direction, { theme: this.theme }, scaleMul);
return;
}
void this.applyActionFrames(this.action);
}
setDirection(direction: Direction, options?: EntityVisualOptions, scaleMul = 1) {
const nextTheme = options?.theme ?? this.theme;
if (
this.direction === direction
&& this.theme === nextTheme
&& this.scaleMul === scaleMul
&& this.useSequence
&& this.frames.length > 0
) {
const flipX = entityFlipX(direction);
this.spriteNode.setScale(flipX ? -1 : 1, 1, 1);
return;
}
this.direction = direction;
if (options?.theme) this.theme = options.theme;
this.scaleMul = scaleMul;
this.animPaths = resolvePlayerAnimPaths(this.theme);
this.useSequence = this.animPaths !== null;
if (!this.useSequence) {
VisualAssets.applyPlayerSprite(this.spriteNode, direction, options ?? { theme: this.theme }, scaleMul);
return;
}
void this.applyActionFrames(this.action);
}
setAction(action: PlayerAction, force = false) {
if (!force && this.action === action && this.useSequence && this.frames.length > 0) return;
this.action = action;
this.frameIdx = 0;
this.frameTimer = 0;
if (!this.useSequence) {
VisualAssets.applyPlayerSprite(
this.spriteNode, this.direction, { theme: this.theme }, this.scaleMul,
);
return;
}
void this.applyActionFrames(action);
}
getAction(): PlayerAction {
return this.action;
}
update(dt: number) {
if (!this.useSequence || this.frames.length <= 1) return;
const interval = FRAME_INTERVAL[this.action];
if (interval <= 0) return;
const speedMul = GameManager.instance?.getGameSpeed() ?? Movement.getSpeedMultiplier();
this.frameTimer += dt * speedMul;
if (this.frameTimer < interval) return;
this.frameTimer = 0;
const loop = this.action === PlayerAction.Move
|| this.action === PlayerAction.Jump
|| this.action === PlayerAction.Win
|| this.action === PlayerAction.Idle;
if (loop) {
this.frameIdx = (this.frameIdx + 1) % this.frames.length;
} else if (this.frameIdx < this.frames.length - 1) {
this.frameIdx++;
}
this.showFrame(this.frames[this.frameIdx]!);
}
private lockDisplayFromFrame(sf: SpriteFrame) {
this.lockedUniformScale = computeEntityUniformScale(
'player', sf, this.scaleMul, this.theme,
);
this.refIdleFrame = sf;
}
/** 用正/背面全部动作序列里的最高帧锁定显示盒,避免走路帧更高导致缩放闪动 */
private ensureThemeDisplayLock(): Promise<void> {
if (this.lockedUniformScale != null) return Promise.resolve();
if (this.displayLockPromise) return this.displayLockPromise;
if (!this.animPaths) return Promise.resolve();
this.displayLockPromise = (async () => {
let tallest: SpriteFrame | null = null;
let maxH = 0;
const sides = [this.animPaths!.front, this.animPaths!.back];
for (const side of sides) {
for (const folder of [side.idle, side.move, side.jump]) {
const frames = await loadFrameSequence(folder);
for (const sf of frames) {
const h = spriteOriginalSize(sf).height;
if (h > maxH) {
maxH = h;
tallest = sf;
}
}
}
}
if (tallest && this.lockedUniformScale == null) {
this.lockDisplayFromFrame(tallest);
}
})();
return this.displayLockPromise;
}
private async applyActionFrames(action: PlayerAction) {
if (!this.animPaths) return;
const gen = ++this.loadingGen;
const isFront = this.direction === Direction.South || this.direction === Direction.East;
const side = isFront ? this.animPaths.front : this.animPaths.back;
await this.ensureThemeDisplayLock();
let folder = actionFolder(side, action);
let frames = await loadFrameSequence(folder);
if (frames.length === 0 && action === PlayerAction.Win) {
folder = side.idle;
frames = await loadFrameSequence(folder);
if (frames.length > 1) frames = frames.slice(0, 2);
}
if (frames.length === 0) {
VisualAssets.applyPlayerSprite(
this.spriteNode, this.direction, { theme: this.theme }, this.scaleMul,
);
return;
}
if (gen !== this.loadingGen || !this.node.isValid) return;
this.frames = frames;
this.frameIdx = 0;
this.frameTimer = 0;
this.showFrame(frames[0]!);
}
private showFrame(sf: SpriteFrame) {
const flipX = entityFlipX(this.direction);
let ui = this.spriteNode.getComponent(UITransform);
if (!ui) {
ui = this.spriteNode.addComponent(UITransform);
ui.setContentSize(48, 48);
}
const sprite = this.spriteNode.getComponent(Sprite) ?? this.spriteNode.addComponent(Sprite);
sprite.spriteFrame = sf;
sprite.sizeMode = Sprite.SizeMode.CUSTOM;
if (this.lockedUniformScale == null) {
this.lockDisplayFromFrame(sf);
}
const uniform = this.lockedUniformScale ?? computeEntityUniformScale(
'player', sf, this.scaleMul, this.theme,
);
const fit = sizeFromUniformScale(sf, uniform, 'player', this.theme);
const ref = this.refIdleFrame ?? sf;
const anchorY = ref === sf
? fit.anchorY
: playerSeqFrameAnchorY(sf, ref, fit.anchorY);
ui.setAnchorPoint(0.5, anchorY);
ui.setContentSize(fit.width, fit.height);
this.spriteNode.setScale(flipX ? -1 : 1, 1, 1);
}
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "bbfaa54a-c152-430e-bc7a-ad567f25bf85",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,65 @@
import { canonicalThemeKey, resolveThemeFolder } from './ThemeEntityTextures';
/** 对齐 Unity Animator Integer「Action」 */
export enum PlayerAction {
Idle = 0,
Move = 1,
Jump = 2,
Win = 3,
Fail = 4,
}
export interface PlayerAnimSidePaths {
idle: string;
move: string;
jump: string;
win?: string;
fail?: string;
}
export interface PlayerAnimPaths {
front: PlayerAnimSidePaths;
back: PlayerAnimSidePaths;
}
/** 各主题 skin 动画目录;无序列帧的主题返回 null仍用静态贴图 */
export function resolvePlayerAnimPaths(theme: string | undefined): PlayerAnimPaths | null {
const key = canonicalThemeKey(theme);
const folder = resolveThemeFolder(theme);
const base = `textures/${folder}`;
switch (key) {
case 'silu':
case 'sanxing':
case 'snow':
case 'chinese':
case 'redArmy':
case 'numMan':
return {
front: {
idle: `${base}/skin/待机正面`,
move: `${base}/skin/走`,
jump: `${base}/skin/跳`,
fail: key === 'silu' ? `${base}/player/失败正` : undefined,
},
back: {
idle: `${base}/skin/待机背面`,
move: `${base}/skin/走背面`,
jump: `${base}/skin/跳背面`,
fail: key === 'silu' ? `${base}/player/失败反` : undefined,
},
};
default:
return null;
}
}
export function actionFolder(side: PlayerAnimSidePaths, action: PlayerAction): string {
switch (action) {
case PlayerAction.Move: return side.move;
case PlayerAction.Jump: return side.jump;
case PlayerAction.Win: return side.win ?? side.idle;
case PlayerAction.Fail: return side.fail ?? side.idle;
default: return side.idle;
}
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "5c42d6d0-2388-470a-a738-9e2640194d51",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,163 @@
/**
* 内置回退贴图themes-database.json 未加载或未配置时使用)
*/
import { Direction } from '../core/Define';
import { getThemeTextureFolder, isThemeDatabaseReady } from '../theme/ThemeRegistry';
export interface ThemeEntityTextureSet {
playerFront: string[];
playerBack: string[];
shipNorth: string[];
shipEast: string[];
shipSouth: string[];
shipWest: string[];
/** @deprecated 等同 shipSouth */
shipFront: string[];
/** @deprecated 等同 shipNorth */
shipBack: string[];
}
const THEME_TEXTURE_FOLDER: Record<string, string> = {
default: 'default',
silu: 'silu',
SILU: 'silu',
chinese: 'chinese',
redArmy: 'redArmy',
redarmy: 'redArmy',
numMan: 'numMan',
snow: 'snow',
sanxing: 'sanxing',
};
const THEME_CANONICAL_KEY: Record<string, string> = {
default: 'default',
silu: 'silu',
SILU: 'silu',
chinese: 'chinese',
redArmy: 'redArmy',
redarmy: 'redArmy',
numMan: 'numMan',
snow: 'snow',
sanxing: 'sanxing',
};
function shipFourWay(folder: string, prefix: string): Pick<
ThemeEntityTextureSet,
'shipNorth' | 'shipEast' | 'shipSouth' | 'shipWest' | 'shipFront' | 'shipBack'
> {
const base = `textures/${folder}/${prefix}`;
return {
shipNorth: [`${base}_N`, `${base}_B`],
shipEast: [`${base}_E`],
shipSouth: [`${base}_S`, `${base}_F`],
shipWest: [`${base}_W`],
shipFront: [`${base}_S`, `${base}_F`],
shipBack: [`${base}_N`, `${base}_B`],
};
}
const SILU_SHIP = shipFourWay('silu', 'siluShip');
const SILU_FALLBACK: ThemeEntityTextureSet = {
playerFront: ['textures/silu/skin/待机正面/1'],
playerBack: ['textures/silu/skin/待机背面/1'],
...SILU_SHIP,
};
export const THEME_ENTITY_TEXTURES: Record<string, ThemeEntityTextureSet> = {
default: {
playerFront: ['textures/default/player_F'],
playerBack: ['textures/default/player_B'],
...shipFourWay('default', 'ship'),
},
silu: { ...SILU_FALLBACK },
chinese: {
playerFront: ['textures/chinese/skin/待机正面/1', 'textures/chinese/chineseShip_F'],
playerBack: ['textures/chinese/skin/待机背面/1', 'textures/chinese/chineseShip_B'],
...shipFourWay('chinese', 'chineseShip'),
},
redArmy: {
playerFront: ['textures/redArmy/skin/待机正面/1'],
playerBack: ['textures/redArmy/skin/待机背面/1'],
...shipFourWay('redArmy', 'redArmyShip'),
},
numMan: {
playerFront: ['textures/numMan/skin/待机正面/1'],
playerBack: ['textures/numMan/skin/待机背面/1'],
...shipFourWay('numMan', 'numManShip'),
},
snow: {
playerFront: ['textures/snow/skin/待机正面/1'],
playerBack: ['textures/snow/skin/待机背面/1'],
...shipFourWay('snow', 'snowShip'),
},
sanxing: {
playerFront: ['textures/sanxing/skin/待机正面/1'],
playerBack: ['textures/sanxing/skin/待机背面/1'],
...shipFourWay('sanxing', 'sanxingShip'),
},
};
export function canonicalThemeKey(theme: string | undefined): string {
if (!theme) return 'silu';
return THEME_CANONICAL_KEY[theme] ?? theme;
}
export function resolveThemeFolder(theme: string | undefined): string {
if (!theme) return 'silu';
if (isThemeDatabaseReady()) return getThemeTextureFolder(theme);
return THEME_TEXTURE_FOLDER[theme] ?? theme;
}
export function getThemeEntityTextures(theme: string | undefined): ThemeEntityTextureSet {
const key = canonicalThemeKey(theme);
return THEME_ENTITY_TEXTURES[key] ?? SILU_FALLBACK;
}
export type VehicleSpriteRole = 'shipNorth' | 'shipEast' | 'shipSouth' | 'shipWest';
export type EntitySpriteRole = 'playerFront' | 'playerBack' | VehicleSpriteRole | 'shipFront' | 'shipBack';
export function vehicleSpriteRoleForDirection(direction: Direction): VehicleSpriteRole {
switch (direction) {
case Direction.North: return 'shipNorth';
case Direction.East: return 'shipEast';
case Direction.South: return 'shipSouth';
case Direction.West: return 'shipWest';
default: return 'shipNorth';
}
}
export function vehicleThemeFieldForDirection(direction: Direction): keyof import('../theme/ThemeTypes').ThemeEntityConfig {
switch (direction) {
case Direction.North: return 'vehicleNorth';
case Direction.East: return 'vehicleEast';
case Direction.South: return 'vehicleSouth';
case Direction.West: return 'vehicleWest';
default: return 'vehicleNorth';
}
}
export function entityTextureCandidates(
theme: string | undefined,
role: EntitySpriteRole,
): string[] {
const set = getThemeEntityTextures(theme);
const primary = [...(set[role] ?? [])];
const fb = SILU_FALLBACK[role as keyof ThemeEntityTextureSet];
if (fb) {
for (const p of fb) {
if (!primary.includes(p)) primary.push(p);
}
}
return primary;
}
/** 预加载某主题载具四向贴图主路径 */
export function allVehicleDirectionTextureCandidates(theme: string | undefined): string[] {
const out: string[] = [];
for (let d = Direction.North; d <= Direction.West; d++) {
for (const p of entityTextureCandidates(theme, vehicleSpriteRoleForDirection(d))) {
if (!out.includes(p)) out.push(p);
}
}
return out;
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "37205258-4b45-4090-bc1b-e8178c3664f2",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,89 @@
import { JsonAsset, resources } from 'cc';
const META_PATH = 'theme/tile-display-meta';
export interface TileDisplayEntry {
width: number;
height: number;
pivotX: number;
pivotY: number;
ppu?: number;
/** 相对格子宽度的额外缩放(默认 1snow kuai11 可略大于 1 以贴满格) */
fitMul?: number;
}
type ThemeTileMap = Record<string, TileDisplayEntry>;
let themeTiles: Record<string, ThemeTileMap> = {};
let loadPromise: Promise<void> | null = null;
function ingest(data: { themes?: Record<string, ThemeTileMap> }) {
themeTiles = { ...(data.themes ?? {}) };
}
export function loadTileDisplayMeta(): Promise<void> {
if (loadPromise) return loadPromise;
loadPromise = new Promise((resolve) => {
resources.load(META_PATH, JsonAsset, (err, asset) => {
if (err || !asset?.json) {
console.warn('[TileDisplayMeta] 未找到 tile-display-meta.json', err);
themeTiles = {};
} else {
ingest(asset.json as { themes?: Record<string, ThemeTileMap> });
}
resolve();
});
});
return loadPromise;
}
export function reloadTileDisplayMeta(): Promise<void> {
themeTiles = {};
loadPromise = null;
return new Promise((resolve) => {
resources.release(META_PATH);
loadTileDisplayMeta().then(resolve);
});
}
function normalizeThemeKey(theme?: string): string | undefined {
if (theme == null) return undefined;
const t = (typeof theme === 'string' ? theme : String(theme)).trim();
if (!t) return undefined;
if (t === 'redArmy') return 'redarmy';
return t;
}
export function getThemeTileDisplayEntry(theme: string | undefined, tileName: string): TileDisplayEntry | null {
const key = normalizeThemeKey(theme);
if (!key) return null;
const map = themeTiles[key];
if (!map) return null;
return map[tileName] ?? null;
}
export function getThemeTileSize(
theme: string | undefined,
tileName: string,
fallback: { width: number; height: number },
): { width: number; height: number } {
const entry = getThemeTileDisplayEntry(theme, tileName);
if (!entry) return fallback;
return { width: entry.width, height: entry.height };
}
export function getThemeTilePivot(
theme: string | undefined,
tileName: string,
fallback: { x: number; y: number },
): { x: number; y: number } {
const entry = getThemeTileDisplayEntry(theme, tileName);
if (!entry) return fallback;
return { x: entry.pivotX, y: entry.pivotY };
}
export function getThemeTileFitMul(theme: string | undefined, tileName: string): number {
const entry = getThemeTileDisplayEntry(theme, tileName);
const mul = entry?.fitMul ?? 1;
return Number.isFinite(mul) && mul > 0 ? mul : 1;
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "922a514b-1242-4a5e-be13-fac729161727",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,23 @@
import { getThemeTilePivot } from './TileDisplayMeta';
/** 与 Unity Texture/*.png.meta 中 spritePivot 一致(无主题数据时的回退) */
export interface TilePivot {
x: number;
y: number;
}
const DEFAULT_PIVOTS: Record<string, TilePivot> = {
Baseblock: { x: 0.5, y: 0.92 },
JumpBlock: { x: 0.5, y: 0.77 },
WallBlock: { x: 0.5, y: 0.67 },
kuai11: { x: 0.5, y: 1.01 },
Decor23: { x: 0.5, y: 0.92 },
'素材切图-23': { x: 0.5, y: 0.92 },
'素材切图2-23': { x: 0.5, y: 0.92 },
'小游戏素材红色_03': { x: 0.5, y: 0.92 },
};
export function getTilePivot(tileName: string, theme?: string): TilePivot {
const fallback = DEFAULT_PIVOTS[tileName] ?? { x: 0.5, y: 0.92 };
return getThemeTilePivot(theme, tileName, fallback);
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "97cf4d35-2a8a-4e57-a6fc-a45061f9988a",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,64 @@
import { getThemeTileDisplayEntry, getThemeTileSize, getThemeTileFitMul } from './TileDisplayMeta';
import { SpriteFrame } from 'cc';
/** 与 Unity / Cocos sprite-frame 原始尺寸一致(无主题数据时的回退) */
export interface TileSize {
width: number;
height: number;
}
const DEFAULT_SIZES: Record<string, TileSize> = {
Baseblock: { width: 101, height: 80 },
JumpBlock: { width: 101, height: 99 },
WallBlock: { width: 101, height: 115 },
kuai11: { width: 101, height: 74 },
};
/** 等距菱形格宽度(与 grid-math CELL_PIXEL 一致) */
const CELL_W = 100;
export function getTileSize(tileName: string, theme?: string): TileSize {
const fallback = DEFAULT_SIZES[tileName] ?? { width: 101, height: 80 };
return getThemeTileSize(theme, tileName, fallback);
}
/** 优先主题 meta裁剪后尺寸避免 sprite originalSize 含透明边导致缩放偏小 */
export function resolveTilePixelSize(
tileName: string,
sf?: SpriteFrame | null,
theme?: string,
): TileSize {
if (theme && getThemeTileDisplayEntry(theme, tileName)) {
return getTileSize(tileName, theme);
}
if (sf) {
const rect = sf.rect;
if (rect.width > 0 && rect.height > 0) {
return { width: rect.width, height: rect.height };
}
const os = sf.originalSize;
if (os.width > 0 && os.height > 0) {
return { width: os.width, height: os.height };
}
}
return getTileSize(tileName, theme);
}
/** 等比缩放宽度贴满格子100px可叠加主题 fitMul */
export function getTileFitScale(tileName: string, width?: number, _height?: number, theme?: string): number {
const size = theme && getThemeTileDisplayEntry(theme, tileName)
? getTileSize(tileName, theme)
: { width: width ?? getTileSize(tileName, theme).width, height: _height ?? getTileSize(tileName, theme).height };
return (CELL_W / size.width) * getThemeTileFitMul(theme, tileName);
}
export function getTileDrawSize(tileName: string, width?: number, height?: number, theme?: string): TileSize {
const source = theme && getThemeTileDisplayEntry(theme, tileName)
? getTileSize(tileName, theme)
: {
width: width ?? getTileSize(tileName, theme).width,
height: height ?? getTileSize(tileName, theme).height,
};
const s = (CELL_W / source.width) * getThemeTileFitMul(theme, tileName);
return { width: source.width * s, height: source.height * s };
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "13aecf34-b260-4097-8817-6683b6ed2960",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -1,34 +1,161 @@
import {
Node, Sprite, SpriteFrame, UITransform, resources, Color, Graphics,
ImageAsset, Texture2D,
ImageAsset, Texture2D, Vec3,
} from 'cc';
import { Direction } from '../core/Define';
import { SpawnKind } from '../level/LevelTypes';
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';
type SpriteKey = 'player_F' | 'player_B' | 'ship_F' | 'ship_B' | 'coin' | 'tile' | 'jump' | 'wall';
const PATHS: Record<SpriteKey, string> = {
player_F: 'textures/silu/player_F',
player_B: 'textures/silu/player_B',
ship_F: 'textures/silu/ship_F',
ship_B: 'textures/silu/ship_B',
coin: 'textures/ui/coin',
tile: 'textures/silu/Baseblock',
/** 与 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<SpriteKey, SpriteFrame>();
private static loading: Promise<void> | null = null;
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>();
static async preload(): Promise<void> {
/** 切关 / 贴图 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(PATHS) as SpriteKey[];
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] 贴图加载 ${ok}/${keys.length}`);
console.log(`[VisualAssets] UI 主题=${this.uiTheme} 贴图 ${ok}/${keys.length}`);
if (ok === 0) {
console.warn('[VisualAssets] 未加载到贴图,将使用色块。请确认 assets/resources/textures 已导入');
console.warn('[VisualAssets] 未加载到贴图,请运行 tools/import_unity_textures.py 并刷新资源');
}
})().catch((e) => {
console.error('[VisualAssets] preload failed', e);
@@ -37,86 +164,717 @@ export class VisualAssets {
return this.loading;
}
private static loadOne(key: SpriteKey): Promise<boolean> {
if (this.frames.has(key)) return Promise.resolve(true);
const base = PATHS[key];
return new Promise((resolve) => {
resources.load(`${base}/spriteFrame`, SpriteFrame, (err, sf) => {
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) {
this.frames.set(key, sf);
resolve(true);
resolve(sf);
return;
}
resources.load(base, SpriteFrame, (err2, sf2) => {
resources.load(path, SpriteFrame, (err2, sf2) => {
if (!err2 && sf2) {
this.frames.set(key, sf2);
resolve(true);
resolve(sf2);
return;
}
resources.load(base, ImageAsset, (err3, img) => {
resources.load(path, ImageAsset, (err3, img) => {
if (!err3 && img) {
const tex = new Texture2D();
tex.image = img;
const frame = new SpriteFrame();
frame.texture = tex;
this.frames.set(key, frame);
resolve(true);
ensureSpriteFrameSize(frame, img.width, img.height);
resolve(frame);
} else {
console.warn(`[VisualAssets] 加载失败: ${base}`, err3 || err2 || err);
resolve(false);
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): SpriteFrame | null {
return this.frames.get(key) ?? null;
static getFrame(key: SpriteKey, theme?: string): SpriteFrame | null {
return this.frames.get(this.frameKey(key, theme)) ?? null;
}
static applyPlayerSprite(node: Node, direction: Direction) {
const isFront = direction === Direction.South || direction === Direction.East;
const flipX = direction === Direction.West || direction === Direction.East;
const key: SpriteKey = isFront ? 'player_F' : 'player_B';
this.applySprite(node, key, flipX);
/** 按关卡配置预加载实体贴图(自定义路径 + 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 applyVehicleSprite(node: Node, direction: Direction, uiStyle = 'default') {
void uiStyle;
const isFront = direction === Direction.South || direction === Direction.East;
const flipX = direction === Direction.West || direction === Direction.East;
const key: SpriteKey = isFront ? 'ship_F' : 'ship_B';
this.applySprite(node, key, flipX);
/** 切关 / 软重置前预加载本关瓦片 + 实体贴图,避免生成占位块 */
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),
]);
}
static setupEntityVisual(node: Node, kind: SpawnKind, direction?: Direction) {
/** 按路径顺序解析贴图(先缓存后异步加载) */
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') {
this.applyPlayerSprite(node, direction ?? Direction.South);
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') {
this.applyVehicleSprite(node, direction ?? Direction.North);
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') {
this.applySprite(node, 'coin', false, 0.85);
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') {
this.applySprite(node, 'coin', false, 0.6, 120);
const paths = resolveEntityTexturePaths('prop_decor', direction, visualOpts);
await this.applyTexturePathsAsync(node, paths, {
flipX: false,
displayKind: 'prop_decor',
displayMul: mul,
alpha: 120,
theme: mapTheme,
});
}
}
/** Sprite 与 Graphics 不能共存,二选一 */
static applySprite(node: Node, key: SpriteKey, flipX: boolean, scale = 1, alpha = 255) {
/** 软重置 / 同关刷新:优先用实体当前逻辑朝向,避免贴图回退到 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);
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);
@@ -125,12 +883,20 @@ export class VisualAssets {
const sprite = spr || node.addComponent(Sprite);
sprite.spriteFrame = sf;
sprite.sizeMode = Sprite.SizeMode.CUSTOM;
const w = ui.contentSize.width * scale;
const h = ui.contentSize.height * scale;
ui.setContentSize(w, h);
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) spr.destroy();
} else if (!spr) {
const graphics = g || node.addComponent(Graphics);
graphics.fillColor = key === 'coin'
? new Color(255, 220, 0, alpha)
@@ -139,9 +905,11 @@ export class VisualAssets {
graphics.clear();
graphics.rect(-w, -w, w * 2, w * 2);
graphics.fill();
} else if (g) {
g.destroy();
}
const sx = flipX ? -Math.abs(scale) : Math.abs(scale);
node.setScale(sx, Math.abs(scale), 1);
const sx = flipX ? -1 : 1;
node.setScale(sx, 1, 1);
}
}