Files
cocos/assets/scripts/visual/PlayerActionAnimator.ts
刘宇飞 d393302388 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>
2026-06-16 15:30:58 +08:00

336 lines
12 KiB
TypeScript
Raw Blame History

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