import { Node, UITransform, Vec3, sp } from 'cc'; import type { EntityId } from 'db://assets/mx/module/ecs'; import { GBundle } from '../../game/ConfigRes'; import type { Skill } from '../skill/Skill'; import { scaleSkillParentReach } from '../skill/SkillParentAim'; import { SkillPresentationEnv } from '../skill/SkillPresentationEnv'; import { computeAimAngleDeg } from '../skill/SkillTransformDriver'; import { applyWeaponSpineSkin } from '../view/GlyphSpineSkin'; import { WEAPON_REST_ANGLE } from '../view/HeroWeaponMount'; import { BattleSpinePlayback } from './BattleSpinePlayback'; /** Weapon.type → 预制体(与 HeroWeaponMount 一致) */ const WEAPON_TYPE_PREFAB: readonly string[] = [ '', 'weaponType1', 'weaponType2', 'weaponType3', 'weaponType4', ]; const WEAPON_PREFAB_PATH = 'prefab/weapon/'; const AIM_NODE = 'weapon'; const SKIN_NODE = 'skin'; /** 「金箍棒技能」砸出帧:金箍棒1–4 / 击中光出现 */ const SMASH_HIT_AT = 0.8667; /** 砸击闪光骨(拉伸落点) */ const HIT_BONE = '击中光'; /** 一次独立砸击实例 */ interface StaffSmashFx { /** 武器克隆 */ node: Node; /** 施法者 */ caster: EntityId; /** 播控序号 */ seq: number; /** 回池剩余秒 */ wait: number; } /** 一轮连砸任务 */ interface StaffSmashJob { /** 施法者 */ caster: EntityId; /** 播控序号 */ seq: number; /** Skill 宿主 */ skill: Skill; /** 开战锁定目标 */ pool: EntityId[]; /** 下一砸下标 */ nextSmash: number; /** 剩余砸次数 */ left: number; /** 距下次生成秒 */ spawnWait: number; /** 砸间隔秒 */ interval: number; /** Skill.animation */ animation: string; /** Weapon.skin */ skin: string; /** Weapon.type */ weaponType: number; /** 生成点世界坐标 */ ox: number; /** 生成点世界 Y */ oy: number; /** 生成点世界 Z */ oz: number; /** 世界缩放 X */ sx: number; /** 世界缩放 Y */ sy: number; /** 世界缩放 Z */ sz: number; } /** 开战参数 */ export interface StaffSmashBegin { /** 施法者 */ caster: EntityId; /** 播控序号 */ seq: number; /** Skill 宿主 */ skill: Skill; /** 开战锁定目标 */ pool: readonly EntityId[]; /** 砸次数 */ hitCount: number; /** 间隔秒 */ intervalSec: number; /** Skill.animation */ animation: string; /** Weapon.skin */ skin: string; /** Weapon.type */ weaponType: number; /** 原武器根(取世界坐标与缩放) */ fromNode: Node; } let _host: StaffSmashHost | null = null; /** 金箍棒等连砸:每次独立武器实例 */ export function getStaffSmashHost(): StaffSmashHost { if (!_host) _host = new StaffSmashHost(); return _host; } /** * atk_type=10 连砸实例:每次 getNode 一根武器,播完回池,互不打断。 */ export class StaffSmashHost { private readonly _fx: StaffSmashFx[] = []; private readonly _jobs: StaffSmashJob[] = []; private readonly _from = new Vec3(); private readonly _scale = new Vec3(); private readonly _to = new Vec3(); /** 击中光世界点 */ private readonly _hit = new Vec3(); /** 骨本地 scratch */ private readonly _boneLocal = new Vec3(); /** 朝向节点未拉伸的本地缩放 */ private readonly _aimInit = new Vec3(1, 1, 1); /** * 开始一轮连砸。同施法者未结束的上一轮先清掉。 * @param spec 开战参数 */ begin(spec: StaffSmashBegin): void { this.abortCaster(spec.caster); const hitCount = Math.max(0, Math.floor(spec.hitCount)); if (!(hitCount > 0)) { console.log('[StaffSmash] 砸次数无效', spec.hitCount); spec.skill.markStaffSmashAnimsComplete(spec.seq); return; } const from = spec.fromNode; if (!from?.isValid) { console.log('[StaffSmash] 无武器根节点'); spec.skill.markStaffSmashAnimsComplete(spec.seq); return; } const wp = from.worldPosition; const ws = from.worldScale; const pool: EntityId[] = []; for (let i = 0; i < spec.pool.length; i++) pool.push(spec.pool[i]); const job: StaffSmashJob = { caster: spec.caster, seq: spec.seq, skill: spec.skill, pool, nextSmash: 0, left: hitCount, spawnWait: 0, interval: Math.max(0, spec.intervalSec), animation: String(spec.animation ?? '').trim(), skin: String(spec.skin ?? '').trim(), weaponType: spec.weaponType, ox: wp.x, oy: wp.y, oz: wp.z, sx: ws.x, sy: ws.y, sz: ws.z, }; this._jobs.push(job); this._spawnOne(job); } /** * 推进生成与回池。 * @param dt 逻辑秒 */ tick(dt: number): void { if (!(dt > 0)) return; for (let i = this._jobs.length - 1; i >= 0; i--) { const job = this._jobs[i]; if (job.left <= 0) continue; job.spawnWait -= dt; if (job.spawnWait > 0) continue; this._spawnOne(job); } for (let i = this._fx.length - 1; i >= 0; i--) { const fx = this._fx[i]; if (!fx.node?.isValid) { this._fx.splice(i, 1); continue; } fx.wait -= dt; if (fx.wait <= 0) this._endFx(i); } this._notifyIdleJobs(); } /** * 打断某施法者全部连砸实例。 * @param caster 施法者 */ abortCaster(caster: EntityId): void { for (let i = this._jobs.length - 1; i >= 0; i--) { if (this._jobs[i].caster === caster) this._jobs.splice(i, 1); } for (let i = this._fx.length - 1; i >= 0; i--) { if (this._fx[i].caster === caster) this._endFx(i); } } /** 取消全部连砸并回池 */ abortAll(): void { this._jobs.length = 0; for (let i = this._fx.length - 1; i >= 0; i--) this._endFx(i); } /** * 目标已尽:停后续生成,已出实例播完再通知收招。 * @param caster 施法者 */ stopSpawning(caster: EntityId): void { for (let i = 0; i < this._jobs.length; i++) { if (this._jobs[i].caster === caster) this._jobs[i].left = 0; } this._notifyIdleJobs(); } /** * 收集该施法者仍在播的克隆(暂停冻 Spine)。 * @param out 输出 * @param caster 施法者 */ collectNodes(out: Node[], caster: EntityId): void { for (let i = 0; i < this._fx.length; i++) { const fx = this._fx[i]; if (fx.caster !== caster) continue; if (fx.node?.isValid) out.push(fx.node); } } /** * 生成一次独立砸击。 * @param job 任务 */ private _spawnOne(job: StaffSmashJob): void { if (job.left <= 0) return; job.left -= 1; job.spawnWait = job.interval; const smashIndex = job.nextSmash; job.nextSmash += 1; const target = this._pickTarget(job.pool, smashIndex); const node = this._spawnNode(job); if (!node) { console.log('[StaffSmash] 实例未生成', job.weaponType, smashIndex); return; } const aim = node.getChildByName(AIM_NODE); const sk = aim?.getChildByName(SKIN_NODE)?.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton); if (!aim?.isValid || !sk?.isValid) { console.log('[StaffSmash] 克隆缺朝向/Spine'); this._recycle(node); return; } if (job.skin) applyWeaponSpineSkin(sk, job.skin, '[StaffSmash]'); const anim = job.animation || '攻击'; this._aimAt(aim, node, target); this._stretchToTarget(aim, sk, anim, target); const wait = BattleSpinePlayback.resolveLogicalCompleteDelay(sk, anim); if (wait == null) { console.log('[StaffSmash] 动画不存在', anim); this._recycle(node); return; } if (!BattleSpinePlayback.play(sk, anim, { loop: false })) { this._recycle(node); return; } this._fx.push({ node, caster: job.caster, seq: job.seq, wait }); } /** * 从池取存活目标,不足循环。 * @param pool 开战快照 * @param smashIndex 第几次 */ private _pickTarget(pool: readonly EntityId[], smashIndex: number): EntityId { const n = pool.length; if (n <= 0) return 0; const start = smashIndex % n; for (let k = 0; k < n; k++) { const id = pool[(start + k) % n]; if (SkillPresentationEnv.isPresentTargetValid(id) && !SkillPresentationEnv.isSpawnProtected(id) && !SkillPresentationEnv.isBajiaoBlowRetreating(id) && !SkillPresentationEnv.isYanCaught(id)) { return id; } } return 0; } /** * 取武器克隆放到飞行层,对齐原武器世界坐标。 * @param job 任务 */ private _spawnNode(job: StaffSmashJob): Node | null { const prefab = WEAPON_TYPE_PREFAB[job.weaponType] ?? ''; if (!prefab) { console.log('[StaffSmash] 武器类型无预制体', job.weaponType); return null; } const parent = SkillPresentationEnv.flyLayer ?? SkillPresentationEnv.getMap()?.ensureHeroParent(); if (!parent?.isValid) { console.log('[StaffSmash] 无父节点'); return null; } let node: Node | null = null; try { node = gg.res.getNode(WEAPON_PREFAB_PATH, prefab, GBundle.BattleHero); } catch (e) { console.log('[StaffSmash] 加载异常', String(e)); return null; } if (!node?.isValid) { console.log('[StaffSmash] 预制体未加载', prefab); return null; } if (!parent.active) parent.active = true; node.parent = parent; node.active = true; node.layer = parent.layer; this._from.set(job.ox, job.oy, job.oz); this._scale.set(job.sx, job.sy, job.sz); node.setWorldScale(this._scale); node.setWorldPosition(this._from); return node; } /** * 克隆朝向目标。 * @param aim 朝向节点 * @param root 根 * @param target 目标 */ private _aimAt(aim: Node, root: Node, target: EntityId): void { const world = target > 0 ? SkillPresentationEnv.getTargetWorldPos(target) : null; if (!world) { aim.angle = WEAPON_REST_ANGLE; return; } this._to.set(world.x, world.y, world.z); const deg = computeAimAngleDeg(aim.worldPosition, this._to) ?? computeAimAngleDeg(root.worldPosition, this._to); if (deg == null) { console.log('[StaffSmash] 朝向为零', target); return; } aim.angle = deg; } /** * 按砸出帧击中光到目标的距离,沿朝向 X 拉伸,避免远处伸不到。 * @param aim 朝向节点 * @param sk 武器 Spine * @param anim Skill.animation * @param target 目标 */ private _stretchToTarget(aim: Node, sk: sp.Skeleton, anim: string, target: EntityId): void { if (!(target > 0)) return; const world = SkillPresentationEnv.getTargetWorldPos(target); if (!world) return; this._to.set(world.x, world.y, world.z); if (!this._poseAtHit(sk, anim)) return; if (!this._readBoneWorld(sk, HIT_BONE, this._hit)) { console.log('[StaffSmash] 击中光骨骼缺失,无法按距离拉伸'); return; } const pivot = aim.worldPosition; const baseReach = Math.hypot(this._hit.x - pivot.x, this._hit.y - pivot.y); if (baseReach <= 1e-3) { console.log('[StaffSmash] 砸出长度为零'); return; } this._aimInit.set(1, 1, 1); scaleSkillParentReach(aim, pivot, this._to, baseReach, this._aimInit); } /** * 把动画采样到砸出帧,刷新骨世界坐标。 * @param sk 武器 Spine * @param anim 动画名 */ private _poseAtHit(sk: sp.Skeleton, anim: string): boolean { if (!sk?.isValid) return false; if (typeof sk.setAnimationCacheMode === 'function') { sk.setAnimationCacheMode(sp.SpineAnimationCacheMode.REALTIME); } BattleSpinePlayback.clearPendingComplete(sk); sk.timeScale = 1; if (!sk.setAnimation(0, anim, false)) { console.log('[StaffSmash] 无法采样砸出帧', anim); return false; } sk.updateAnimation(SMASH_HIT_AT); return true; } /** * 读骨世界坐标。 * @param sk 武器 Spine * @param boneName 骨名 * @param out 输出 */ private _readBoneWorld(sk: sp.Skeleton, boneName: string, out: Vec3): boolean { const bone = typeof sk.findBone === 'function' ? sk.findBone(boneName) : null; const ui = sk.node?.getComponent(UITransform); if (!bone || !ui?.isValid) return false; this._boneLocal.set(bone.worldX, bone.worldY, 0); ui.convertToWorldSpaceAR(this._boneLocal, out); return true; } /** * 任务砸完且实例播完:通知 Skill 可收招。 */ private _notifyIdleJobs(): void { for (let i = this._jobs.length - 1; i >= 0; i--) { const job = this._jobs[i]; if (job.left > 0) continue; if (this._hasFx(job.caster, job.seq)) continue; this._jobs.splice(i, 1); job.skill.markStaffSmashAnimsComplete(job.seq); } } /** * 是否还有该轮实例。 * @param caster 施法者 * @param seq 播控序号 */ private _hasFx(caster: EntityId, seq: number): boolean { for (let i = 0; i < this._fx.length; i++) { if (this._fx[i].caster === caster && this._fx[i].seq === seq) return true; } return false; } /** * 结束并回池。 * @param index 下标 */ private _endFx(index: number): void { const fx = this._fx[index]; this._fx.splice(index, 1); this._recycle(fx.node); } /** * 回池武器克隆。 * @param node 节点 */ private _recycle(node: Node | null): void { if (!node?.isValid) return; const sk = node.getComponentInChildren(sp.Skeleton); BattleSpinePlayback.clearPendingComplete(sk); BattleSpinePlayback.resetTracks(sk); const aim = node.getChildByName(AIM_NODE); if (aim?.isValid) { aim.angle = WEAPON_REST_ANGLE; aim.setScale(1, 1, 1); } node.setScale(1, 1, 1); node.setPosition(0, 0, 0); gg.res.putNode(node); const flyLayer = SkillPresentationEnv.flyLayer; if (flyLayer?.isValid && flyLayer.children.length === 0) flyLayer.active = false; } }