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.
1255 lines
48 KiB
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);
|
|
}
|
|
}
|
|
|