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/CocosBattleCombatPresenter.ts

1255 lines
48 KiB

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<typeof SkillPresentationEnv.getCasterGridCenter> {
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<Vec3>, 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<Vec3>, 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<Vec3>, to: Readonly<Vec3>): 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<Vec3>): 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<Vec3>, 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);
}
}