You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
AdLike2048/assets/script/battle/view/YingCampAnim.ts

252 lines
8.3 KiB

3 weeks ago
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<Vec3>, 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<Vec3>): 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 });
};
}