import { Node, UITransform, Vec3, sp } from 'cc'; import type { EntityId } from 'db://assets/mx/module/ecs'; import { BattleSpinePlayback } from '../presentation/BattleSpinePlayback'; /** 营预制体出怪动画(草字头开合) */ export const YING_ATTACK_ANIM = '攻击'; /** 营待机 */ export const YING_IDLE_ANIM = '待机'; /** 草字头骨骼 / 槽(攻击 0.5s 翻开,1.17s 起合上) */ export const YING_GRASS_BONE = '营2'; /** 草字头插槽(附件 营1a,相对骨有偏移) */ export const YING_GRASS_SLOT = '营2'; /** 草字头完全打开(与 营.json 攻击轨一致) */ export const YING_ATTACK_OPEN_SEC = 0.5; /** 草字头内出生缩放(相对落地尺寸) */ export const YING_SPAWN_SCALE = 0.5; /** 营召唤落地相对 Monster.size */ export const YING_SUMMON_SIZE_MUL = 1.3; /** 出生后从小放到默认大小 */ export const YING_SPAWN_GROW_SEC = 0.3; /** 放到默认大小后停留再跳出 */ export const YING_HOLD_BEFORE_JUMP_SEC = 1; /** 营字跳出速度相对默认(放慢 40%) */ export const YING_JUMP_SPEED = 0.6; /** 出生点:营格心向上(光的位置) */ export const YING_SPAWN_LIFT_PX = 20; /** Spine 骨世界矩阵(读附件偏移) */ interface YingBoneXform { /** 世界 X */ worldX: number; /** 世界 Y */ worldY: number; /** 矩阵 a */ a?: number; /** 矩阵 b */ b?: number; /** 矩阵 c */ c?: number; /** 矩阵 d */ d?: number; } /** Spine 槽(取附件相对骨的 x/y) */ interface YingSlotXform { /** 所属骨 */ bone?: YingBoneXform; /** 当前附件 */ attachment?: { x?: number; y?: number }; /** 取附件 */ getAttachment?: () => { x?: number; y?: number } | null; } /** 跳出起点(草字头世界坐标) */ interface YingJumpStart { /** 怪物实体 */ entity: EntityId; /** 世界 X */ x: number; /** 世界 Y */ y: number; /** 世界 Z */ z: number; } /** 召唤跳出缩放(相对怪物默认) */ export interface SummonJumpScaleLook { /** 出现时相对落地尺寸 */ scaleFrom: number; /** 落地相对 Monster.size;空则 1 */ scaleMul?: number; /** 第一段放到此相对落地尺寸;空则 grow 直接到落地 */ scaleHold?: number; /** 第一段放大秒 */ growSec: number; /** 起跳前停顿秒 */ holdSec: number; /** 跳出时挂「怪跳出拖尾」 */ jumpTrail?: boolean; /** 跳跃速度相对默认;空则 1 */ jumpSpeed?: number; } /** 本批出怪共用起点(须在 unitSpawned 派发前写入) */ interface YingPendingStart { /** 世界 X */ x: number; /** 世界 Y */ y: number; /** 世界 Z */ z: number; /** 非空时覆盖营默认缩放 */ look?: SummonJumpScaleLook; } /** * 营字「攻击」出怪:打开草字头 → 怪出生 → 合上。 * 时间点取自 营.json,不另猜。 */ export class YingCampAnim { private static readonly _idlePend: sp.Skeleton[] = []; private static readonly _starts: YingJumpStart[] = []; private static readonly _boneLocal = new Vec3(); private static _pending: YingPendingStart | null = null; /** * 播攻击;结束后回待机。 * @param skeleton 营 Spine */ static playAttack(skeleton: sp.Skeleton | null): void { if (!skeleton?.isValid) return; YingCampAnim._ensureRealtime(skeleton); YingCampAnim._idlePend.push(skeleton); BattleSpinePlayback.play(skeleton, YING_ATTACK_ANIM, { onComplete: YingCampAnim._onAttackDone }); } /** * 出生点:营格世界坐标向上偏到光。 * @param cellNode 营所在格节点 * @param out 输出 */ static readSpawnWorld(cellNode: Node | null, out: Vec3): boolean { if (!cellNode?.isValid) return false; const p = cellNode.worldPosition; out.set(p.x, p.y + YING_SPAWN_LIFT_PX, p.z); return true; } /** * 读取当前草字头世界坐标(槽附件中心,不是骨轴心)。 * @param skeleton 营 Spine * @param out 输出 */ static readGrassWorld(skeleton: sp.Skeleton | null, out: Vec3): boolean { if (!skeleton?.isValid) return false; const ui = skeleton.node?.getComponent(UITransform); if (!ui) return false; const slot = typeof skeleton.findSlot === 'function' ? skeleton.findSlot(YING_GRASS_SLOT) as YingSlotXform | null : null; const bone = (slot?.bone ?? (typeof skeleton.findBone === 'function' ? skeleton.findBone(YING_GRASS_BONE) : null)) as YingBoneXform | null; if (!bone) return false; const att = slot?.getAttachment?.() ?? slot?.attachment; const lx = Number(att?.x); const ly = Number(att?.y); if (Number.isFinite(lx) && Number.isFinite(ly)) { YingCampAnim._boneToLocal(bone, lx, ly, YingCampAnim._boneLocal); } else { YingCampAnim._boneLocal.set(bone.worldX, bone.worldY, 0); } ui.convertToWorldSpaceAR(YingCampAnim._boneLocal, out); return true; } /** * 停留期间钉在出生点(格心上偏),不跟草字头骨。 * @param out 输出 */ static followGrass(out: Vec3): boolean { const pend = YingCampAnim._pending; if (!pend) return false; out.set(pend.x, pend.y, pend.z); return true; } /** * 格节点读不到 pending 时:格心上偏。 * @param node 营格节点 * @param out 输出 */ static readGrassFromNode(node: Node | null, out: Vec3): boolean { return YingCampAnim.readSpawnWorld(node, out); } /** * 出怪前写入本批起点。unitSpawned 同步派发时实体标记还来不及,必须先挂这里。 * @param world 出生世界坐标 * @param look 非空时覆盖营默认缩放 */ static setPendingStart(world: Readonly, look?: SummonJumpScaleLook): void { YingCampAnim._pending = { x: world.x, y: world.y, z: world.z, look }; } /** 本批缩放;无则走营默认 */ static peekPendingLook(): SummonJumpScaleLook | null { return YingCampAnim._pending?.look ?? null; } /** 本批出怪结束 */ static clearPendingStart(): void { YingCampAnim._pending = null; } /** * 记录怪物跳出起点(草字头)。 * @param entity 怪物 * @param world 世界坐标 */ static markJumpStart(entity: EntityId, world: Readonly): void { YingCampAnim.takeJumpStart(entity); YingCampAnim._starts.push({ entity, x: world.x, y: world.y, z: world.z }); } /** * 取出跳出起点:先实体标记,再本批 pending(不消耗,供同批多只)。 * @param entity 怪物 * @param out 输出 */ static takeJumpStart(entity: EntityId, out?: Vec3): boolean { const list = YingCampAnim._starts; for (let i = list.length - 1; i >= 0; i--) { if (list[i].entity !== entity) continue; if (out) out.set(list[i].x, list[i].y, list[i].z); list.splice(i, 1); return true; } const pend = YingCampAnim._pending; if (!pend) return false; if (out) out.set(pend.x, pend.y, pend.z); return true; } /** 离场清理 */ static clear(): void { YingCampAnim._idlePend.length = 0; YingCampAnim._starts.length = 0; YingCampAnim._pending = null; } /** 读骨须 REALTIME,缓存模式 findBone 会空/旧 */ private static _ensureRealtime(skeleton: sp.Skeleton): void { if (typeof skeleton.setAnimationCacheMode !== 'function') return; skeleton.setAnimationCacheMode(sp.SpineAnimationCacheMode.REALTIME); } /** 骨世界矩阵 × 附件局部 */ private static _boneToLocal(bone: YingBoneXform, lx: number, ly: number, out: Vec3): void { const a = bone.a ?? 1; const b = bone.b ?? 0; const c = bone.c ?? 0; const d = bone.d ?? 1; out.set(lx * a + ly * b + bone.worldX, lx * c + ly * d + bone.worldY, 0); } private static readonly _onAttackDone = (): void => { const sk = YingCampAnim._idlePend.shift(); if (!sk?.isValid) return; BattleSpinePlayback.play(sk, YING_IDLE_ANIM, { loop: true, optional: true }); }; }