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.Idle]: 0.22, [PlayerAction.Move]: 0.12, [PlayerAction.Jump]: 0.1, [PlayerAction.Win]: 0.14, [PlayerAction.Fail]: 0, }; const seqCache = new Map(); async function loadSpriteFrame(path: string): Promise { const loadSf = (p: string) => new Promise((resolve) => { resources.load(p, SpriteFrame, (err, sf) => resolve(!err && sf ? sf : null)); }); const loadImg = (p: string) => new Promise((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( folder: string, type: new (...args: never[]) => T, ): Promise { return new Promise((resolve) => { resources.loadDir(folder, type, (err, assets) => { if (err || !assets?.length) { resolve([]); return; } resolve(assets); }); }); } async function loadFrameSequence(folder: string): Promise { 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 | 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 { 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); } }