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:
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user