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.
251 lines
8.3 KiB
251 lines
8.3 KiB
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 });
|
|
};
|
|
}
|
|
|