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