import { Node, sp, UITransform, Vec3, view } from 'cc'; import type { BattleEvent, EntityId } from 'db://assets/mx/module/battle'; import { BATTLE_EVENT_ALL, BattleEventType } from 'db://assets/mx/module/battle'; import { AttributeComp, HealthComp } from 'db://assets/mx/module/battle/ecs/Components'; import { ensureSpineSkeletonDataRuntime } from 'db://assets/mx/module/lang/spineRuntimePatch'; import type { ProjectBattleHandle } from '../runtime/BattleRuntime'; import type { CocosBattleMonsterPresenter } from './CocosBattleMonsterPresenter'; import { SkillPresentationEnv } from '../skill/SkillPresentationEnv'; import { BATTLE_MONSTER_CCCLASS, type IBattleMonsterView } from './IBattleMonsterView'; import { DamageTextSpawner } from './DamageTextSpawner'; import { getMonsterStatusText } from './MonsterStatusText'; import { BattleAudio } from '../audio/BattleAudio'; import { MonsterType } from '../../manager/MonsterDataManager'; import { getTowerLoadoutService } from '../combat/TowerLoadoutService'; import { isActiveAbilityId } from '../config/weapon1/Weapon1BattleIds'; import { DEFAULT_HIT_EFFECT } from './HitEffectResolver'; import { TableNames } from '../../game/ConfigTableData'; import { GBundle } from '../../game/ConfigRes'; import { BattleSpinePlayback } from '../presentation/BattleSpinePlayback'; import { SkillTransformDriver, computeAimAngleDeg } from '../skill/SkillTransformDriver'; import { HOMING_DRAGON_EVENT, LINE_DRAGON_EVENT, SWORD_ARRAY_EVENT, SWORD_WAVE_EVENT, LIGHTNING_CHAIN_EVENT, type HomingDragonCue, type LineDragonCue, type SwordArrayCue, type SwordWaveCue, type LightningChainCue, } from '../combat/PassiveSkillRuntime'; import { traceCombatDamage } from 'db://assets/mx/tools/CombatDamageTrace'; type MonsterView = IBattleMonsterView & { entityId?: EntityId | null; hp?: number; upEff?: Node | null; bloodPos?: Node | null; Data?: { type?: number } | null; showHitEffect?(hitEffectCfg?: string, faceSourceX?: number, faceSourceY?: number): void; showStunFx?(durationSec: number): void; clearStunFx?(): void; showSlowFx?(durationSec: number): void; clearSlowFx?(): void; showBurnFx?(durationSec: number): void; clearBurnFx?(): void; showBleedFx?(durationSec: number): void; playBleedHitFx?(): void; clearBleedFx?(): void; showGhostBiteFx?(durationSec: number): void; clearGhostBiteFx?(): void; showElecFx?(durationSec: number): void; clearElecFx?(): void; }; /** 阎王主动「生死簿」Skill.id 区间 */ const YAN_SKILL_LO = 10206; const YAN_SKILL_HI = 10208; const STUN_BUFF_ID = 'debuff_4'; const SLOW_BUFF_ID = 'debuff_3'; const BURN_BUFF_ID = 'debuff_1'; const BLEED_BUFF_ID = 'debuff_2'; const GHOST_BITE_BUFF_ID = 'debuff_6'; const ELEC_BUFF_ID = 'debuff_7'; const BIND_NET_BUFF_ID = 'debuff_bind_net'; const FIRE_TRAP_BUFF_ID = 'debuff_fire_trap'; const KNOCKBACK_APPLIED_EVENT = 'KnockbackApplied'; const SWORD_ARRAY_PREFAB = '剑阵'; const SWORD_ARRAY_PREFAB_PATH = 'prefab/skill/'; /** 剑阵根节点参考边长(预制体 UITransform 200,直径=边长) */ const SWORD_ARRAY_REF_SIDE = 200; const HOMING_DRAGON_PREFAB = '追踪火龙'; const LINE_DRAGON_PREFAB = '直线火龙'; /** 轩辕剑剑气预制体 */ const SWORD_WAVE_PREFAB = '玉帝刀光'; /** 剑气飞出可视区的额外路程(相对屏幕对角线) */ const SWORD_WAVE_SCREEN_PAD = 0.2; /** 闪电链电弧 */ const LIGHTNING_BOLT_PREFAB = '电母-大招'; /** 闪电链受击 */ const LIGHTNING_HIT_PREFAB = '电母-大招受击'; /** 主链电弧动画 */ const LIGHTNING_BOLT_ANIM = '普通雷电'; /** 主链受击动画 */ const LIGHTNING_HIT_ANIM = '普通受击'; /** 电弧根节点宽(锚点 x=0,scaleX=距离/此值) */ const LIGHTNING_BOLT_WIDTH = 150; /** 相对资源原速 */ const LIGHTNING_ANIM_SPEED = 2; /** 「普通雷电」资源时长 / 播放倍率 */ const LIGHTNING_BOLT_SEC = 0.5333 / LIGHTNING_ANIM_SPEED; /** 「普通受击」资源时长 / 播放倍率 */ const LIGHTNING_HIT_SEC = 0.6667 / LIGHTNING_ANIM_SPEED; /** 战斗表现:怪物受击反馈、中心受击特效、伤害飘字 */ export class CocosBattleCombatPresenter { private _battle: ProjectBattleHandle | null = null; private _monsterPresenter: CocosBattleMonsterPresenter | null = null; private _displayRoot: Node | null = null; private _scoreLayer: Node | null = null; private _listener: ((e: BattleEvent) => void) | null = null; private readonly _dmgText = new DamageTextSpawner(); private readonly _swordFx: { node: Node; caster: EntityId; endAt: number }[] = []; private readonly _dragonFx: Node[] = []; private readonly _lineDragonFx: Node[] = []; private readonly _swordWaveFx: Node[] = []; private readonly _lightningFx: Node[] = []; private readonly _dragonFrom = new Vec3(); private readonly _dragonTo = new Vec3(); bind(battle: ProjectBattleHandle, monsterPresenter: CocosBattleMonsterPresenter, displayRoot: Node): void { this.unbind(); this._battle = battle; this._monsterPresenter = monsterPresenter; this._displayRoot = displayRoot; this._scoreLayer = this._ensureScoreLayer(displayRoot); getMonsterStatusText().bind(this._scoreLayer); this._listener = (e) => this.onBattleEvent(e); battle.on(BATTLE_EVENT_ALL, this._listener); } unbind(): void { this._recycleAllSwordArrays(); this._recycleAllHomingDragons(); this._recycleAllLineDragons(); this._recycleAllSwordWaves(); this._recycleAllLightning(); if (this._battle && this._listener) { this._battle.off(BATTLE_EVENT_ALL, this._listener); } this._listener = null; getMonsterStatusText().unbind(); this._battle = null; this._monsterPresenter = null; this._displayRoot = null; this._scoreLayer = null; } private _ensureScoreLayer(root: Node): Node { let layer = root.getChildByName('scoreLayer'); if (!layer) { layer = new Node('scoreLayer'); layer.parent = root; } this._bringScoreLayerToFront(root, layer); return layer; } /** 飘字层保持在怪物层之上(避免被 monsterLayer 遮挡) */ private _bringScoreLayerToFront(root: Node, layer: Node): void { if (!layer.isValid) return; layer.setSiblingIndex(root.children.length - 1); } private onBattleEvent(event: BattleEvent): void { switch (event.type) { case BattleEventType.HitAfter: this._onHitAfter( event.caster, event.target, event.amount, event.isCrit, event.damageType, event.abilityId, ); break; case BattleEventType.DamageDealt: this._syncHpOnly(event.target); break; case BattleEventType.BuffApplied: if (event.buffId === STUN_BUFF_ID) { this._onStunApplied(event.target, event.duration); } else if (event.buffId === SLOW_BUFF_ID) { this._onSlowApplied(event.target, event.duration); } else if (event.buffId === BURN_BUFF_ID) { this._onBurnApplied(event.target, event.duration); } else if (event.buffId === BLEED_BUFF_ID) { this._onBleedApplied(event.target, event.duration); } else if (event.buffId === GHOST_BITE_BUFF_ID) { this._onGhostBiteApplied(event.target, event.duration); } else if (event.buffId === ELEC_BUFF_ID) { this._onElecApplied(event.target, event.duration); } else if (event.buffId === BIND_NET_BUFF_ID) { this._playStatusText(event.target, '束缚'); } else if (event.buffId === FIRE_TRAP_BUFF_ID) { this._playStatusText(event.target, '定身'); } break; case BattleEventType.BuffRemoved: if (event.buffId === STUN_BUFF_ID) { this._onStunRemoved(event.target); } else if (event.buffId === SLOW_BUFF_ID) { this._onSlowRemoved(event.target); } else if (event.buffId === BURN_BUFF_ID) { this._onBurnRemoved(event.target); } else if (event.buffId === BLEED_BUFF_ID) { this._onBleedRemoved(event.target); } else if (event.buffId === GHOST_BITE_BUFF_ID) { this._onGhostBiteRemoved(event.target); } else if (event.buffId === ELEC_BUFF_ID) { this._onElecRemoved(event.target); } break; case BattleEventType.EntityKilled: this._onStunRemoved(event.victim); this._onSlowRemoved(event.victim); this._onBurnRemoved(event.victim); this._onBleedRemoved(event.victim); this._onGhostBiteRemoved(event.victim); this._onElecRemoved(event.victim); break; case BattleEventType.UnitDespawned: this._onStunRemoved(event.entity); this._onSlowRemoved(event.entity); this._onBurnRemoved(event.entity); this._onBleedRemoved(event.entity); this._onGhostBiteRemoved(event.entity); this._onElecRemoved(event.entity); break; case BattleEventType.Custom: if (event.name === KNOCKBACK_APPLIED_EVENT) { const payload = event.payload as { target?: EntityId } | undefined; if (payload?.target != null) { this._onKnockbackApplied(payload.target); } } else if (event.name === SWORD_ARRAY_EVENT) { this._playSwordArray(event.payload as SwordArrayCue | undefined); } else if (event.name === HOMING_DRAGON_EVENT) { this._playHomingDragons(event.payload as HomingDragonCue | undefined); } else if (event.name === LINE_DRAGON_EVENT) { this._playLineDragon(event.payload as LineDragonCue | undefined); } else if (event.name === SWORD_WAVE_EVENT) { this._playSwordWave(event.payload as SwordWaveCue | undefined); } else if (event.name === LIGHTNING_CHAIN_EVENT) { this._playLightningChain(event.payload as LightningChainCue | undefined); } break; } } private _onStunApplied(target: EntityId, duration: number): void { const monster = this._getMonsterView(target); if (!monster || !Number.isFinite(duration) || duration <= 0) return; monster.showStunFx?.(Math.max(0.05, duration)); this._playStatusText(target, '晕眩'); } private _onStunRemoved(target: EntityId): void { this._getMonsterView(target)?.clearStunFx?.(); } private _onSlowApplied(target: EntityId, duration: number): void { const monster = this._getMonsterView(target); if (!monster || !Number.isFinite(duration) || duration <= 0) return; monster.showSlowFx?.(Math.max(0.05, duration)); this._playStatusText(target, '减速'); } private _onSlowRemoved(target: EntityId): void { this._getMonsterView(target)?.clearSlowFx?.(); } private _onBurnApplied(target: EntityId, duration: number): void { const monster = this._getMonsterView(target); if (!monster || !Number.isFinite(duration) || duration <= 0) return; monster.showBurnFx?.(Math.max(0.05, duration)); this._playStatusText(target, '燃烧'); } private _onBurnRemoved(target: EntityId): void { this._getMonsterView(target)?.clearBurnFx?.(); } private _onBleedApplied(target: EntityId, duration: number): void { const monster = this._getMonsterView(target); if (!monster || !Number.isFinite(duration) || duration <= 0) return; monster.showBleedFx?.(Math.max(0.05, duration)); this._playStatusText(target, '流血'); } private _onBleedRemoved(target: EntityId): void { this._getMonsterView(target)?.clearBleedFx?.(); } private _onGhostBiteApplied(target: EntityId, duration: number): void { const monster = this._getMonsterView(target); if (!monster || !Number.isFinite(duration) || duration <= 0) return; monster.showGhostBiteFx?.(Math.max(0.05, duration)); this._playStatusText(target, '啃食'); } private _onGhostBiteRemoved(target: EntityId): void { this._getMonsterView(target)?.clearGhostBiteFx?.(); } private _onElecApplied(target: EntityId, duration: number): void { const monster = this._getMonsterView(target); if (!monster || !Number.isFinite(duration) || duration <= 0) return; monster.showElecFx?.(Math.max(0.05, duration)); this._playStatusText(target, '感电'); } private _onElecRemoved(target: EntityId): void { this._getMonsterView(target)?.clearElecFx?.(); } /** 怪物头顶状态飘字 */ private _playStatusText(target: EntityId, text: string): void { getMonsterStatusText().play(target, text); } private _getMonsterView(target: EntityId): MonsterView | null { const node = this._monsterPresenter?.getEntityNodeMap().get(target); if (!node?.isValid) return null; return node.getComponent(BATTLE_MONSTER_CCCLASS) as MonsterView | null; } private _onKnockbackApplied(target: EntityId): void { this._playStatusText(target, '击退'); } /** * 在落点加载剑阵预制体,播预设默认动画,持续结束后回池。 * @param cue 逻辑层 cue */ private _playSwordArray(cue: SwordArrayCue | undefined): void { const parent = this._displayRoot; const battle = this._battle; if (!cue || !parent?.isValid || !battle) return; const duration = cue.duration; if (!(duration > 0)) { console.log('[CombatPresenter] 剑阵时长无效', duration); return; } if (!(cue.radius > 0)) { console.log('[CombatPresenter] 剑阵半径无效', cue.radius); return; } const wp = this._swordArrayWorldPos(cue); if (!wp) { console.log('[CombatPresenter] 剑阵无落点'); return; } const endAt = battle.session.clock.time + duration; const existed = this._findSwordFx(cue.caster); if (existed) { this._fitSwordArrayScale(existed.node, cue.radius); existed.node.setWorldPosition(wp); existed.endAt = endAt; battle.session.clock.schedule(duration, this._onSwordArrayExpire); return; } let node: Node | null = null; try { node = gg.res.getNode(SWORD_ARRAY_PREFAB_PATH, SWORD_ARRAY_PREFAB, GBundle.BattleHero); } catch (err) { console.log('[CombatPresenter] 剑阵 getNode 失败', String(err)); return; } if (!node?.isValid) { console.log('[CombatPresenter] 剑阵预制体未加载', SWORD_ARRAY_PREFAB); return; } node.active = true; node.layer = parent.layer; node.angle = 0; this._fitSwordArrayScale(node, cue.radius); node.setParent(parent); node.setWorldPosition(wp); node.setSiblingIndex(parent.children.length - 1); this._playPrefabDefaultAnim(node); this._swordFx.push({ node, caster: cue.caster, endAt }); battle.session.clock.schedule(duration, this._onSwordArrayExpire); } /** * 同施法者已在播的剑阵。 * @param caster 施法者 */ private _findSwordFx(caster: EntityId): { node: Node; caster: EntityId; endAt: number } | null { for (let i = 0; i < this._swordFx.length; i++) { if (this._swordFx[i].caster === caster) return this._swordFx[i]; } return null; } /** * 剑阵世界坐标:优先受击怪,否则格心,再否则塔格。 * @param cue 逻辑层 cue */ private _swordArrayWorldPos(cue: SwordArrayCue): ReturnType { const monster = SkillPresentationEnv.getMonsterNode(cue.target); if (monster?.isValid) return monster.worldPosition.clone(); return SkillPresentationEnv.getTargetGridWorldCenter(cue.target) ?? SkillPresentationEnv.getCasterGridCenter(cue.caster); } /** * 根节点按配表半径缩放:scale = (半径×2) / 参考边长。 * @param node 剑阵根节点 * @param radius 配表半径像素 */ private _fitSwordArrayScale(node: Node, radius: number): void { if (!(radius > 0)) { console.log('[CombatPresenter] 剑阵半径无效', radius); return; } const s = (radius * 2) / SWORD_ARRAY_REF_SIDE; node.setScale(s, s, 1); } /** * 播放预制体当前预设默认动画(循环)。 * @param node 剑阵节点 */ private _playPrefabDefaultAnim(node: Node): void { const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton); if (!sk?.isValid) { console.log('[CombatPresenter] 预制体缺少 Skeleton'); return; } if (sk.skeletonData) ensureSpineSkeletonDataRuntime(sk.skeletonData); const typed = sk as sp.Skeleton & { defaultAnimation?: string }; const name = String(typed.defaultAnimation || sk.animation || '').trim(); if (!name) { console.log('[CombatPresenter] 预制体无默认动画'); return; } BattleSpinePlayback.play(sk, name, { loop: true }); } /** 时钟到期后回收已结束的剑阵 */ private readonly _onSwordArrayExpire = (): void => { this._recycleExpiredSwordArrays(); }; /** 回收到期剑阵节点 */ private _recycleExpiredSwordArrays(): void { const now = this._battle?.session.clock.time ?? Number.POSITIVE_INFINITY; for (let i = this._swordFx.length - 1; i >= 0; i--) { if (this._swordFx[i].endAt <= now + 0.001) { this._recycleSwordNode(this._swordFx[i].node); this._swordFx.splice(i, 1); } } } /** 回收全部剑阵 */ private _recycleAllSwordArrays(): void { for (let i = 0; i < this._swordFx.length; i++) { this._recycleSwordNode(this._swordFx[i].node); } this._swordFx.length = 0; } /** * 停动画并回池。 * @param node 剑阵节点 */ private _recycleSwordNode(node: Node): void { if (!node?.isValid) return; const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton); BattleSpinePlayback.clearPendingComplete(sk); BattleSpinePlayback.resetTracks(sk); node.setScale(1, 1, 1); gg.res.putNode(node); } /** * 击杀点按配置数量均分方向直线飞出,到终点回收。 * @param cue 逻辑层 cue */ private _playHomingDragons(cue: HomingDragonCue | undefined): void { if (!cue || !this._battle) return; const list = cue.missiles; if (!list || list.length === 0) return; const parent = SkillPresentationEnv.flyLayer ?? this._displayRoot; if (!parent?.isValid) { console.log('[CombatPresenter] 爆炸火球无父节点'); return; } const from = this._homingDragonOrigin(cue); if (!from) { console.log('[CombatPresenter] 爆炸火球无起点'); return; } this._dragonFrom.set(from); parent.active = true; const flyUt = parent.getComponent(UITransform); for (let i = 0; i < list.length; i++) { this._spawnHomingDragon(parent, flyUt, cue.originX, cue.originY, list[i]); } } /** * 生成点:优先触发实体世界坐标,否则逻辑坐标。 * @param cue 逻辑层 cue */ private _homingDragonOrigin(cue: HomingDragonCue): Vec3 | null { const live = SkillPresentationEnv.getTargetWorldPos(cue.originTarget); if (live) return live; const monster = SkillPresentationEnv.getMonsterNode(cue.originTarget); if (monster?.isValid) return monster.worldPosition; if (Number.isFinite(cue.originX) && Number.isFinite(cue.originY)) { this._dragonTo.set(cue.originX, cue.originY, 0); return this._dragonTo; } return SkillPresentationEnv.getCasterGridCenter(cue.caster); } /** * 取一枚火球并直线飞向终点。 * @param parent 飞行层 * @param flyUt 飞行层 UITransform * @param originX 逻辑起点 X * @param originY 逻辑起点 Y * @param missile 单枚火球 */ private _spawnHomingDragon(parent: Node, flyUt: UITransform | null, originX: number, originY: number, missile: HomingDragonCue['missiles'][number]): void { if (!(missile.duration > 0)) { console.log('[CombatPresenter] 爆炸火球时长无效', missile.duration); return; } const travel = Math.sqrt( (missile.destX - originX) * (missile.destX - originX) + (missile.destY - originY) * (missile.destY - originY), ); if (!(travel > 1e-4)) { console.log('[CombatPresenter] 爆炸火球路程为零'); return; } const uxLen = Math.sqrt(missile.ux * missile.ux + missile.uy * missile.uy); if (!(uxLen > 1e-4)) { console.log('[CombatPresenter] 爆炸火球方向为零'); return; } this._dragonTo.set( this._dragonFrom.x + missile.ux * travel, this._dragonFrom.y + missile.uy * travel, this._dragonFrom.z, ); const angleDeg = computeAimAngleDeg(this._dragonFrom, this._dragonTo); if (angleDeg == null) { console.log('[CombatPresenter] 爆炸火球朝向为零'); return; } let node: Node | null = null; try { node = gg.res.getNode(SWORD_ARRAY_PREFAB_PATH, HOMING_DRAGON_PREFAB, GBundle.BattleHero); } catch (err) { console.log('[CombatPresenter] 爆炸火球 getNode 失败', String(err)); return; } if (!node?.isValid) { console.log('[CombatPresenter] 爆炸火球预制体未加载', HOMING_DRAGON_PREFAB); return; } const startLocal = flyUt ? flyUt.convertToNodeSpaceAR(this._dragonFrom) : this._dragonFrom.clone(); const endLocal = flyUt ? flyUt.convertToNodeSpaceAR(this._dragonTo) : this._dragonTo.clone(); node.active = true; node.layer = parent.layer; node.setScale(1, 1, 1); node.parent = parent; node.setPosition(startLocal); node.angle = angleDeg; node.setSiblingIndex(parent.children.length - 1); this._playPrefabDefaultAnim(node); this._dragonFx.push(node); SkillTransformDriver.cancelNode(node); SkillTransformDriver.animate({ node, duration: missile.duration, fromPos: startLocal, toPos: endLocal, fromAngle: angleDeg, toAngle: angleDeg, easing: SkillTransformDriver.easeLinear, onComplete: this._onHomingDragonArrived, }); } /** * 飞行结束回池。 * @param node 火球节点 */ private readonly _onHomingDragonArrived = (node?: Node): void => { if (node) this._recycleHomingDragon(node); }; /** 回收全部爆炸火球 */ private _recycleAllHomingDragons(): void { for (let i = this._dragonFx.length - 1; i >= 0; i--) { this._recycleHomingDragon(this._dragonFx[i]); } } /** * 停飞并回池。 * @param node 火球节点 */ private _recycleHomingDragon(node: Node): void { for (let i = this._dragonFx.length - 1; i >= 0; i--) { if (this._dragonFx[i] === node) this._dragonFx.splice(i, 1); } if (!node?.isValid) return; SkillTransformDriver.cancelNode(node); const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton); BattleSpinePlayback.clearPendingComplete(sk); BattleSpinePlayback.resetTracks(sk); node.angle = 0; node.setScale(1, 1, 1); gg.res.putNode(node); } /** * 从施法者直线飞向终点,朝向 +X 对齐飞行方向。 * @param cue 逻辑层 cue */ private _playLineDragon(cue: LineDragonCue | undefined): void { if (!cue || !this._battle) return; if (!(cue.duration > 0)) { console.log('[CombatPresenter] 直线火龙时长无效', cue.duration); return; } const parent = SkillPresentationEnv.flyLayer ?? this._displayRoot; if (!parent?.isValid) { console.log('[CombatPresenter] 直线火龙无父节点'); return; } const from = this._lineDragonOrigin(cue); if (!from) { console.log('[CombatPresenter] 直线火龙无起点'); return; } this._dragonFrom.set(from.x, from.y, from.z); if (!this._lineDragonDest(cue, this._dragonFrom, this._dragonTo)) { console.log('[CombatPresenter] 直线火龙无朝向'); return; } const angleDeg = computeAimAngleDeg(this._dragonFrom, this._dragonTo); if (angleDeg == null) { console.log('[CombatPresenter] 直线火龙朝向为零'); return; } let node: Node | null = null; try { node = gg.res.getNode(SWORD_ARRAY_PREFAB_PATH, LINE_DRAGON_PREFAB, GBundle.BattleHero); } catch (err) { console.log('[CombatPresenter] 直线火龙 getNode 失败', String(err)); return; } if (!node?.isValid) { console.log('[CombatPresenter] 直线火龙预制体未加载', LINE_DRAGON_PREFAB); return; } parent.active = true; const flyUt = parent.getComponent(UITransform); node.active = true; node.layer = parent.layer; node.setScale(1, 1, 1); node.parent = parent; const startLocal = flyUt ? flyUt.convertToNodeSpaceAR(this._dragonFrom) : this._dragonFrom.clone(); const endLocal = flyUt ? flyUt.convertToNodeSpaceAR(this._dragonTo) : this._dragonTo.clone(); node.setPosition(startLocal); node.angle = angleDeg; node.setSiblingIndex(parent.children.length - 1); this._playPrefabDefaultAnim(node); this._lineDragonFx.push(node); SkillTransformDriver.cancelNode(node); SkillTransformDriver.animate({ node, duration: cue.duration, fromPos: startLocal, toPos: endLocal, fromAngle: angleDeg, toAngle: angleDeg, easing: SkillTransformDriver.easeLinear, onComplete: this._onLineDragonArrived, }); } /** * 起点:优先塔格心,否则逻辑坐标。 * @param cue 逻辑层 cue */ private _lineDragonOrigin(cue: LineDragonCue): Vec3 | null { const grid = SkillPresentationEnv.getCasterGridCenter(cue.caster); if (grid) return grid; if (Number.isFinite(cue.originX) && Number.isFinite(cue.originY)) { this._dragonFrom.set(cue.originX, cue.originY, 0); return this._dragonFrom; } return null; } /** * 终点:与逻辑弹道同向飞满配表路程(Cocos Y 与逻辑 Y 相反)。 * 不跟腰点重瞄,避免画面穿怪、逻辑打脚导致看起来不穿透。 * @param cue 逻辑层 cue * @param from 起点世界坐标 * @param out 写出终点 */ private _lineDragonDest(cue: LineDragonCue, from: Readonly, out: Vec3): boolean { const ldx = cue.destX - cue.originX; const ldy = cue.destY - cue.originY; const travel = Math.sqrt(ldx * ldx + ldy * ldy); if (!(travel > 1e-4)) return false; out.set(from.x + ldx, from.y - ldy, from.z); return true; } /** 飞行结束回池 */ private readonly _onLineDragonArrived = (node?: Node): void => { if (node) this._recycleLineDragon(node); }; /** 回收全部直线火龙 */ private _recycleAllLineDragons(): void { for (let i = this._lineDragonFx.length - 1; i >= 0; i--) { this._recycleLineDragon(this._lineDragonFx[i]); } } /** * 停飞并回池。 * @param node 火龙节点 */ private _recycleLineDragon(node: Node): void { for (let i = this._lineDragonFx.length - 1; i >= 0; i--) { if (this._lineDragonFx[i] === node) this._lineDragonFx.splice(i, 1); } if (!node?.isValid) return; SkillTransformDriver.cancelNode(node); const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton); BattleSpinePlayback.clearPendingComplete(sk); BattleSpinePlayback.resetTracks(sk); node.angle = 0; node.setScale(1, 1, 1); gg.res.putNode(node); } /** * 玉帝刀光沿触发方向直线飞出屏幕后回池。 * @param cue 逻辑层 cue */ private _playSwordWave(cue: SwordWaveCue | undefined): void { if (!cue || !this._battle) return; if (!(cue.speed > 0)) { console.log('[CombatPresenter] 剑气速度无效', cue.speed); return; } const parent = SkillPresentationEnv.flyLayer ?? this._displayRoot; if (!parent?.isValid) { console.log('[CombatPresenter] 剑气无父节点'); return; } const from = this._swordWaveOrigin(cue); if (!from) { console.log('[CombatPresenter] 剑气无起点'); return; } this._dragonFrom.set(from.x, from.y, from.z); if (!this._swordWaveDest(cue, this._dragonFrom, this._dragonTo)) { console.log('[CombatPresenter] 剑气无朝向'); return; } const angleDeg = computeAimAngleDeg(this._dragonFrom, this._dragonTo); if (angleDeg == null) { console.log('[CombatPresenter] 剑气朝向为零'); return; } const dx = this._dragonTo.x - this._dragonFrom.x; const dy = this._dragonTo.y - this._dragonFrom.y; const travel = Math.sqrt(dx * dx + dy * dy); if (!(travel > 1e-4)) { console.log('[CombatPresenter] 剑气飞行距离无效'); return; } let node: Node | null = null; try { node = gg.res.getNode(SWORD_ARRAY_PREFAB_PATH, SWORD_WAVE_PREFAB, GBundle.BattleHero); } catch (err) { console.log('[CombatPresenter] 剑气 getNode 失败', String(err)); return; } if (!node?.isValid) { console.log('[CombatPresenter] 剑气预制体未加载', SWORD_WAVE_PREFAB); return; } parent.active = true; const flyUt = parent.getComponent(UITransform); node.active = true; node.layer = parent.layer; node.setScale(1, 1, 1); node.parent = parent; const startLocal = flyUt ? flyUt.convertToNodeSpaceAR(this._dragonFrom) : this._dragonFrom.clone(); const endLocal = flyUt ? flyUt.convertToNodeSpaceAR(this._dragonTo) : this._dragonTo.clone(); node.setPosition(startLocal); node.angle = angleDeg; node.setSiblingIndex(parent.children.length - 1); this._playSwordWaveAnim(node, cue.animation); this._swordWaveFx.push(node); const duration = travel / cue.speed; SkillTransformDriver.cancelNode(node); SkillTransformDriver.animate({ node, duration, fromPos: startLocal, toPos: endLocal, fromAngle: angleDeg, toAngle: angleDeg, easing: SkillTransformDriver.easeLinear, onComplete: this._onSwordWaveArrived, }); } /** * 剑气起点:优先塔格心,否则逻辑坐标。 * @param cue 逻辑层 cue */ private _swordWaveOrigin(cue: SwordWaveCue): Vec3 | null { const grid = SkillPresentationEnv.getCasterGridCenter(cue.caster); if (grid) return grid; if (Number.isFinite(cue.originX) && Number.isFinite(cue.originY)) { this._dragonFrom.set(cue.originX, cue.originY, 0); return this._dragonFrom; } return null; } /** * 终点:沿触发时锁定方向飞出可视区(与逻辑层一致,不跟活目标漂移)。 * @param cue 逻辑层 cue * @param from 起点世界坐标 * @param out 写出终点 */ private _swordWaveDest(cue: SwordWaveCue, from: Readonly, out: Vec3): boolean { const len = Math.sqrt(cue.ux * cue.ux + cue.uy * cue.uy); if (!(len > 1e-4)) return false; // 逻辑 Y 与 Cocos 世界 Y 相反 const dx = cue.ux / len; const dy = -cue.uy / len; const visible = view.getVisibleSize(); const travel = Math.sqrt(visible.width * visible.width + visible.height * visible.height) * (0.5 + SWORD_WAVE_SCREEN_PAD); if (!(travel > 1e-4)) return false; out.set(from.x + dx * travel, from.y + dy * travel, from.z); return true; } /** 飞行结束回池 */ private readonly _onSwordWaveArrived = (node?: Node): void => { if (node) this._recycleSwordWave(node); }; /** 回收全部剑气 */ private _recycleAllSwordWaves(): void { for (let i = this._swordWaveFx.length - 1; i >= 0; i--) { this._recycleSwordWave(this._swordWaveFx[i]); } } /** * 停飞并回池。 * @param node 刀光节点 */ private _recycleSwordWave(node: Node): void { for (let i = this._swordWaveFx.length - 1; i >= 0; i--) { if (this._swordWaveFx[i] === node) this._swordWaveFx.splice(i, 1); } if (!node?.isValid) return; SkillTransformDriver.cancelNode(node); const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton); BattleSpinePlayback.clearPendingComplete(sk); BattleSpinePlayback.resetTracks(sk); node.angle = 0; node.setScale(1, 1, 1); gg.res.putNode(node); } /** * 播玉帝刀光 Spine(剑气蓝 / 剑气红)。 * @param node 刀光节点 * @param animName 动画名 */ private _playSwordWaveAnim(node: Node, animName: string): void { const name = String(animName ?? '').trim(); const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton); if (!sk?.isValid) { console.log('[CombatPresenter] 剑气缺少 Skeleton'); return; } if (sk.skeletonData) ensureSpineSkeletonDataRuntime(sk.skeletonData); if (!name) { console.log('[CombatPresenter] 剑气动画名为空'); return; } BattleSpinePlayback.play(sk, name, { loop: true }); } /** * 一跳:可选电弧 from→to,受击挂在落点。 * @param cue 逻辑层 cue */ private _playLightningChain(cue: LightningChainCue | undefined): void { if (!cue || !this._battle) return; const parent = SkillPresentationEnv.flyLayer ?? this._displayRoot; if (!parent?.isValid) { console.log('[CombatPresenter] 闪电链无父节点'); return; } if (!this._resolveChainWorld(cue.from, cue.fromX, cue.fromY, cue.to, cue.toX, cue.toY, this._dragonFrom, this._dragonTo)) { console.log('[CombatPresenter] 闪电链无落点'); return; } parent.active = true; if (cue.showBolt) this._spawnLightningBolt(parent, this._dragonFrom, this._dragonTo); this._spawnLightningHit(parent, this._dragonTo); } /** * 解析 hop 两端世界坐标:优先活体腰点,否则用另一端 + 逻辑 Y 取反。 * @param fromId 起点实体 * @param fromX 起点逻辑 X * @param fromY 起点逻辑 Y * @param toId 落点实体 * @param toX 落点逻辑 X * @param toY 落点逻辑 Y * @param fromOut 写出起点世界 * @param toOut 写出落点世界 */ private _resolveChainWorld(fromId: EntityId, fromX: number, fromY: number, toId: EntityId, toX: number, toY: number, fromOut: Vec3, toOut: Vec3): boolean { const toOk = this._entityWorld(toId, toOut); const fromOk = this._entityWorld(fromId, fromOut); if (toOk && fromOk) return true; if (toOk && !fromOk) { fromOut.set(toOut.x + (fromX - toX), toOut.y - (fromY - toY), toOut.z); return true; } if (fromOk && !toOk) { toOut.set(fromOut.x + (toX - fromX), fromOut.y - (toY - fromY), fromOut.z); return true; } return false; } /** * 实体当前世界坐标(腰点或节点)。 * @param id 实体 * @param out 写出 */ private _entityWorld(id: EntityId, out: Vec3): boolean { const live = SkillPresentationEnv.getTargetWorldPos(id); if (live) { out.set(live.x, live.y, live.z); return true; } const node = SkillPresentationEnv.getMonsterNode(id); if (node?.isValid) { const wp = node.worldPosition; out.set(wp.x, wp.y, wp.z); return true; } return false; } /** * 拉一根电弧并按距离拉伸。 * @param parent 飞行层 * @param from 起点世界 * @param to 终点世界 */ private _spawnLightningBolt(parent: Node, from: Readonly, to: Readonly): void { const angleDeg = computeAimAngleDeg(from, to); if (angleDeg == null) return; const dist = Math.sqrt((to.x - from.x) * (to.x - from.x) + (to.y - from.y) * (to.y - from.y)); if (!(dist > 1)) return; const node = this._takeSkillPrefab(LIGHTNING_BOLT_PREFAB); if (!node) return; const flyUt = parent.getComponent(UITransform); const startLocal = flyUt ? flyUt.convertToNodeSpaceAR(from) : from.clone(); node.active = true; node.layer = parent.layer; node.setScale(1, 1, 1); node.parent = parent; node.setPosition(startLocal); node.angle = angleDeg; const ut = node.getComponent(UITransform); const width = ut?.contentSize.width || LIGHTNING_BOLT_WIDTH; node.setScale(dist / Math.max(1, width), 1, 1); node.setSiblingIndex(parent.children.length - 1); this._playLightningAnim(node, LIGHTNING_BOLT_ANIM); this._lightningFx.push(node); this._holdThenRecycle(node, startLocal, LIGHTNING_BOLT_SEC); } /** * 落点受击。 * @param parent 飞行层 * @param at 世界坐标 */ private _spawnLightningHit(parent: Node, at: Readonly): void { const node = this._takeSkillPrefab(LIGHTNING_HIT_PREFAB); if (!node) return; const flyUt = parent.getComponent(UITransform); const local = flyUt ? flyUt.convertToNodeSpaceAR(at) : at.clone(); node.active = true; node.layer = parent.layer; node.setScale(1, 1, 1); node.parent = parent; node.setPosition(local); node.angle = 0; node.setSiblingIndex(parent.children.length - 1); this._playLightningAnim(node, LIGHTNING_HIT_ANIM); this._lightningFx.push(node); this._holdThenRecycle(node, local, LIGHTNING_HIT_SEC); } /** * 取技能预制体。 * @param name 预制体名 */ private _takeSkillPrefab(name: string): Node | null { let node: Node | null = null; try { node = gg.res.getNode(SWORD_ARRAY_PREFAB_PATH, name, GBundle.BattleHero); } catch (err) { console.log('[CombatPresenter] 闪电链 getNode 失败', name, String(err)); return null; } if (!node?.isValid) { console.log('[CombatPresenter] 闪电链预制体未加载', name); return null; } return node; } /** * 播电弧/受击动画。 * @param node 特效节点 * @param animName 动画名 */ private _playLightningAnim(node: Node, animName: string): void { const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton); if (!sk?.isValid) { console.log('[CombatPresenter] 闪电链缺少 Skeleton', animName); return; } if (sk.skeletonData) ensureSpineSkeletonDataRuntime(sk.skeletonData); BattleSpinePlayback.play(sk, animName, { loop: false, timeScale: LIGHTNING_ANIM_SPEED }); } /** * 停在原地直到 hop 时长结束再回池(onComplete 带 node)。 * @param node 特效节点 * @param local 当前位置 * @param duration 秒 */ private _holdThenRecycle(node: Node, local: Readonly, duration: number): void { SkillTransformDriver.cancelNode(node); SkillTransformDriver.animate({ node, duration: Math.max(0.05, duration), fromPos: local, toPos: local, fromAngle: node.angle, toAngle: node.angle, easing: SkillTransformDriver.easeLinear, onComplete: this._onLightningFxDone, }); } /** 闪电链特效结束回池 */ private readonly _onLightningFxDone = (node?: Node): void => { if (node) this._recycleLightningFx(node); }; /** 回收全部闪电链特效 */ private _recycleAllLightning(): void { for (let i = this._lightningFx.length - 1; i >= 0; i--) { this._recycleLightningFx(this._lightningFx[i]); } } /** * 停动画并回池。 * @param node 特效节点 */ private _recycleLightningFx(node: Node): void { for (let i = this._lightningFx.length - 1; i >= 0; i--) { if (this._lightningFx[i] === node) this._lightningFx.splice(i, 1); } if (!node?.isValid) return; SkillTransformDriver.cancelNode(node); const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton); BattleSpinePlayback.clearPendingComplete(sk); BattleSpinePlayback.resetTracks(sk); node.angle = 0; node.setScale(1, 1, 1); gg.res.putNode(node); } private _onHitAfter( caster: EntityId, target: EntityId, amount: number, isCrit: boolean, damageType?: string, abilityId?: string, ): void { if (!this._battle || !this._monsterPresenter) { traceCombatDamage('hitAfterNoBattle', { caster, target, amount }); return; } const node = this._monsterPresenter.getEntityNodeMap().get(target); if (!node?.isValid) { traceCombatDamage('hitAfterNoNode', { caster, target, amount, spawning: this._monsterPresenter.isSpawning(target), inMap: this._monsterPresenter.getEntityNodeMap().has(target), }); return; } const monster = node.getComponent(BATTLE_MONSTER_CCCLASS) as MonsterView | null; if (!monster) { traceCombatDamage('hitAfterNoMonsterComp', { caster, target, amount }); return; } const hp = this._battle.session.world.getComponent(target, HealthComp); traceCombatDamage('hitAfter', { caster, target, amount, ecsHp: hp?.hp, ecsMaxHp: hp?.maxHp, hasHealthComp: !!hp, viewHp: monster.hp, hasSyncMxHp: !!monster.syncMxHp, hasBlood: !!(monster as IBattleMonsterView).bloodPos, }); if (hp) { monster.syncMxHp?.(hp.hp, hp.maxHp); } const casterWp = SkillPresentationEnv.getCasterGridCenter(caster); const mwp = node.worldPosition; const hitDirX = casterWp ? mwp.x - casterWp.x : 0; const hitDirY = casterWp ? mwp.y - casterWp.y : 0; const faceSourceX = -hitDirX; const faceSourceY = -hitDirY; monster.playHit?.(damageType, hitDirX, hitDirY); monster.showHitEffect?.( this._resolveWeaponHitEffect(caster, abilityId), faceSourceX, faceSourceY, ); if (amount > 0 && this._isYanWangSkill(abilityId)) { monster.playBleedHitFx?.(); } if (monster.Data?.type === MonsterType.TreasureBox) { BattleAudio.playChestHit(); } else { BattleAudio.playWeaponHit(caster, target); } const extraMul = damageType === 'extra_mul' || this._hasExtraDamageMul(caster); const floatPos = (monster.bloodPos ?? node).worldPosition.clone(); floatPos.y += 20; const scoreRoot = this._scoreLayer; if (scoreRoot?.isValid && scoreRoot.parent?.isValid) { this._bringScoreLayerToFront(scoreRoot.parent, scoreRoot); } const floatParent = scoreRoot ?? node.parent!; const killKind = DamageTextSpawner.resolveKillFloaterKind(damageType); if (killKind) { this._dmgText.spawnKillEffect(floatPos, floatParent, killKind); } else { this._dmgText.spawn(amount, floatPos, floatParent, isCrit, extraMul); } } private _syncHpOnly(target: EntityId): void { if (!this._battle || !this._monsterPresenter) { traceCombatDamage('syncHpNoBattle', { target }); return; } const node = this._monsterPresenter.getEntityNodeMap().get(target); if (!node?.isValid) { traceCombatDamage('syncHpNoNode', { target, spawning: this._monsterPresenter.isSpawning(target), }); return; } const monster = node.getComponent(BATTLE_MONSTER_CCCLASS) as MonsterView | null; const hp = this._battle.session.world.getComponent(target, HealthComp); if (hp && monster?.syncMxHp) { traceCombatDamage('syncHp', { target, ecsHp: hp.hp, ecsMaxHp: hp.maxHp, viewHp: monster.hp }); monster.syncMxHp(hp.hp, hp.maxHp); } else { traceCombatDamage('syncHpSkip', { target, hasHp: !!hp, hasMonster: !!monster, hasSync: !!monster?.syncMxHp, }); } } /** * 阎王主动生死簿(10206–10208)。 * @param abilityId 命中 Ability id */ private _isYanWangSkill(abilityId?: string): boolean { if (!abilityId) return false; const m = /^ability_(?:ult|hero|weapon)_(\d+)(?:_e\d+)?$/.exec(abilityId); if (!m) return false; const id = Number(m[1]); return id >= YAN_SKILL_LO && id <= YAN_SKILL_HI; } /** 普攻按 Weapon1.hitEffect;大招仍用通用受击特效 */ private _resolveWeaponHitEffect(caster: EntityId, abilityId?: string): string { if (abilityId && isActiveAbilityId(abilityId)) { return DEFAULT_HIT_EFFECT; } const weapon1Id = getTowerLoadoutService().getLoadout(caster)?.weapon1Id; if (!weapon1Id) return DEFAULT_HIT_EFFECT; return DEFAULT_HIT_EFFECT; } /** caster 是否带有 base_attr=6 的额外伤害倍数 modifier */ private _hasExtraDamageMul(caster: EntityId): boolean { const attr = this._battle?.session.world.getComponent(caster, AttributeComp); if (!attr) return false; return attr.modifiers.some(m => m.attr === 'dmg_mul' && m.op === 'Add' && m.value > 0); } }