Complete Cocos Creator port with level bundles, themes, and tooling.

Adds level prefabs, theme assets, audio, extensions, and deployment scripts for the Unity WebGL migration.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-16 15:30:58 +08:00
parent cba5105908
commit d393302388
6248 changed files with 17322729 additions and 11036 deletions

View File

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