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:
157
assets/scripts/visual/EntityDisplayRefs.ts
Normal file
157
assets/scripts/visual/EntityDisplayRefs.ts
Normal 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 补全 originalSize(ImageAsset 直载时 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));
|
||||
}
|
||||
9
assets/scripts/visual/EntityDisplayRefs.ts.meta
Normal file
9
assets/scripts/visual/EntityDisplayRefs.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "c5ea903f-83fc-42e9-8721-5540fc38c79f",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
318
assets/scripts/visual/EntityTextureResolver.ts
Normal file
318
assets/scripts/visual/EntityTextureResolver.ts
Normal 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_kuai2;Prop → 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;
|
||||
}
|
||||
9
assets/scripts/visual/EntityTextureResolver.ts.meta
Normal file
9
assets/scripts/visual/EntityTextureResolver.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "f794ac4d-27dd-4b75-ac42-dabe97cdb679",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
335
assets/scripts/visual/PlayerActionAnimator.ts
Normal file
335
assets/scripts/visual/PlayerActionAnimator.ts
Normal 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 0–4)。
|
||||
* 无 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);
|
||||
}
|
||||
}
|
||||
9
assets/scripts/visual/PlayerActionAnimator.ts.meta
Normal file
9
assets/scripts/visual/PlayerActionAnimator.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "bbfaa54a-c152-430e-bc7a-ad567f25bf85",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
65
assets/scripts/visual/PlayerAnimPaths.ts
Normal file
65
assets/scripts/visual/PlayerAnimPaths.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
9
assets/scripts/visual/PlayerAnimPaths.ts.meta
Normal file
9
assets/scripts/visual/PlayerAnimPaths.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "5c42d6d0-2388-470a-a738-9e2640194d51",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
163
assets/scripts/visual/ThemeEntityTextures.ts
Normal file
163
assets/scripts/visual/ThemeEntityTextures.ts
Normal 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;
|
||||
}
|
||||
9
assets/scripts/visual/ThemeEntityTextures.ts.meta
Normal file
9
assets/scripts/visual/ThemeEntityTextures.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "37205258-4b45-4090-bc1b-e8178c3664f2",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
89
assets/scripts/visual/TileDisplayMeta.ts
Normal file
89
assets/scripts/visual/TileDisplayMeta.ts
Normal 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;
|
||||
/** 相对格子宽度的额外缩放(默认 1,snow 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;
|
||||
}
|
||||
9
assets/scripts/visual/TileDisplayMeta.ts.meta
Normal file
9
assets/scripts/visual/TileDisplayMeta.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "922a514b-1242-4a5e-be13-fac729161727",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
23
assets/scripts/visual/TilePivots.ts
Normal file
23
assets/scripts/visual/TilePivots.ts
Normal 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);
|
||||
}
|
||||
9
assets/scripts/visual/TilePivots.ts.meta
Normal file
9
assets/scripts/visual/TilePivots.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "97cf4d35-2a8a-4e57-a6fc-a45061f9988a",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
64
assets/scripts/visual/TileSizes.ts
Normal file
64
assets/scripts/visual/TileSizes.ts
Normal 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 };
|
||||
}
|
||||
9
assets/scripts/visual/TileSizes.ts.meta
Normal file
9
assets/scripts/visual/TileSizes.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "13aecf34-b260-4097-8817-6683b6ed2960",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user