import { Node, sp } from 'cc'; import { AttributeComp, HealthComp, MovementComp, TeamComp, UnitMetaComp, BattleTeamId, type BattleSession, } from 'db://assets/mx/module/battle'; import type { EntityId } from 'db://assets/mx/module/ecs'; import { GBundle } from '../../game/ConfigRes'; import { BATTLE_HERO_BUFF_PATH } from '../res/BattleHeroResCollector'; import { parseLegacyMonsterId, parseMonsterUnitDefId } from '../config/MonsterHpCalculator'; import { SkillPresentationEnv } from '../skill/SkillPresentationEnv'; import type { SkillPresentationContext } from '../skill/SkillPresentationTypes'; import { BATTLE_MONSTER_CCCLASS, type IBattleMonsterView } from '../view/IBattleMonsterView'; import { getMonsterStatusText } from '../view/MonsterStatusText'; import { BattleSpinePlayback } from './BattleSpinePlayback'; /** 加速特效预制体名 */ const FX_SPEED = '怪_加速'; /** 移速 modifier 分组 */ const SPEED_MOD = 'ally_skill_extra_speed'; /** 特效缺时长时的回收秒(已 warn) */ const FX_WAIT_FALLBACK = 2; /** 挂在友军怪上的特效 */ interface AllyFx { /** 目标实体 */ entity: EntityId; /** 特效节点 */ node: Node; /** 剩余秒 */ wait: number; /** 循环(加速);刷新时先回收 */ loop: boolean; /** 朝向用:上一帧怪物世界 X */ lastX?: number; /** 朝向用:上一帧怪物世界 Y */ lastY?: number; } let _host: AllySkillExtraHost | null = null; /** Skill.skill 类型 3/4:友军加速 / 加血 */ export function getAllySkillExtraHost(): AllySkillExtraHost { if (!_host) _host = new AllySkillExtraHost(); return _host; } /** 主动技额外 3/4:对友军怪物加速或加血并播特效 */ export class AllySkillExtraHost { private readonly _fx: AllyFx[] = []; private readonly _allyIds: EntityId[] = []; private _scanSession: BattleSession | null = null; private _scanCaster: EntityId = 0; /** * 英雄/武器主动释放时应用。普攻不触发。 * @param ctx 表现上下文 */ onSkillCast(ctx: SkillPresentationContext): void { if (!ctx.isAlive) return; const kind = String(ctx.params.skillKind ?? ''); if (kind !== 'hero' && kind !== 'weapon') return; const cue = String(ctx.cueId ?? '').toLowerCase(); if (cue !== 'skill' && cue !== 'weaponskill') return; const raw = String(ctx.params.extraSkill ?? ctx.params.skillRaw ?? ''); const parts = raw.split(',').map((s) => Number(String(s).trim())); const head = parts[0] ?? 0; if (head === 3) { this._applySpeed(ctx, parts); return; } if (head === 4) { this._applyHeal(ctx, parts); } } /** * atk_buff type3 正移速:身上循环加速特效,时长到回收。 * @param entity 目标 * @param duration 秒 */ playSpeedFx(entity: EntityId, duration: number): void { if (!(duration > 0)) return; this._playFx(entity, FX_SPEED, duration, true); } /** * 推进特效计时。 * @param dt 逻辑秒 */ tick(dt: number): void { if (!(dt > 0)) return; const session = SkillPresentationEnv.getBattleSession(); for (let i = this._fx.length - 1; i >= 0; i--) { const fx = this._fx[i]; fx.wait -= dt; const dead = !fx.node.isValid || (session != null && !session.world.isAlive(fx.entity)); if (fx.wait > 0 && !dead) { if (fx.loop) this._orientLoopFxToMove(fx); continue; } this._fx.splice(i, 1); this._recycleFx(fx.node); } } /** 循环加速特效按怪物位移转向行进方向(预制体默认朝 +X) */ private _orientLoopFxToMove(fx: AllyFx): void { if (!fx.node.isValid) return; const monsterNode = SkillPresentationEnv.getMonsterNode(fx.entity); if (!monsterNode?.isValid) return; const wp = monsterNode.worldPosition; const x = wp.x; const y = wp.y; if (fx.lastX == null || fx.lastY == null || !Number.isFinite(fx.lastX) || !Number.isFinite(fx.lastY)) { fx.lastX = x; fx.lastY = y; return; } const dx = x - fx.lastX; const dy = y - fx.lastY; fx.lastX = x; fx.lastY = y; if (dx * dx + dy * dy < 1e-4) return; fx.node.angle = Math.atan2(dy, dx) * 180 / Math.PI; } /** 离场回收 */ abortAll(): void { for (let i = 0; i < this._fx.length; i++) { this._recycleFx(this._fx[i].node); } this._fx.length = 0; } /** * 3,加速系数万分比,加速时间秒。 * @param ctx 表现上下文 * @param parts skill 分段 */ private _applySpeed(ctx: SkillPresentationContext, parts: number[]): void { const wan = parts[1]; const duration = parts[2]; if (!(wan > 0) || !(duration > 0)) { console.log('[AllySkillExtra] skill 类型3参数无效', parts.join(',')); return; } const session = SkillPresentationEnv.getBattleSession(); if (!session) { console.log('[AllySkillExtra] 无 session'); return; } this._fillAllies(session, ctx.caster); const mul = 1 + wan / 10000; for (let i = 0; i < this._allyIds.length; i++) { this._buffSpeed(session, this._allyIds[i], mul, duration); this._playFx(this._allyIds[i], FX_SPEED, duration, true); getMonsterStatusText().play(this._allyIds[i], '加速'); } } /** * 4,加血比例万分比。满血或未实际加血也播 `怪物加血`。 * @param ctx 表现上下文 * @param parts skill 分段 */ private _applyHeal(ctx: SkillPresentationContext, parts: number[]): void { const wan = parts[1]; if (!(wan > 0)) { console.log('[AllySkillExtra] skill 类型4参数无效', parts.join(',')); return; } const session = SkillPresentationEnv.getBattleSession(); if (!session) { console.log('[AllySkillExtra] 无 session'); return; } this._fillAllies(session, ctx.caster); for (let i = 0; i < this._allyIds.length; i++) { const id = this._allyIds[i]; this._healOne(session, id, wan); const view = this._monsterView(id); if (!view?.playHealEffect) { console.log('[AllySkillExtra] 友军怪无法播加血特效', id); continue; } view.playHealEffect(); } } /** * 收集友军怪物。 * @param session 会话 * @param caster 施法者 */ private _fillAllies(session: BattleSession, caster: EntityId): void { this._scanSession = session; this._scanCaster = caster; this._allyIds.length = 0; session.world.forEachAlive(this._collectAlly); this._scanSession = null; } /** 扫描回调:友军怪物入列 */ private readonly _collectAlly = (id: EntityId): void => { const session = this._scanSession; if (!session) return; if (!this._isAllyMonster(session, this._scanCaster, id)) return; this._allyIds.push(id); }; /** * 是否施法者友军怪物:己方出怪口行军怪,或友方召唤。不含攻向自己的敌方来路怪。 * @param session 会话 * @param caster 施法者 * @param id 候选 */ private _isAllyMonster(session: BattleSession, caster: EntityId, id: EntityId): boolean { if (id === caster) return false; const world = session.world; if (!world.isAlive(id)) return false; const hp = world.getComponent(id, HealthComp); if (!hp || !(hp.hp > 0)) return false; const meta = world.getComponent(id, UnitMetaComp); if (!meta?.unitDefId) return false; const parsed = parseMonsterUnitDefId(meta.unitDefId); const monsterId = parsed?.monsterId ?? parseLegacyMonsterId(meta.unitDefId); if (!(monsterId > 0)) return false; const casterTeam = world.getComponent(caster, TeamComp); const team = world.getComponent(id, TeamComp); if (BattleTeamId.isFriendly(casterTeam, team)) return true; const allyPort = (casterTeam?.ownerId ?? 0) === 0 ? 2 : 1; return (meta.spawnPoint ?? 0) === allyPort; } /** * 写入限时移速。MovementSystem 每帧按 modifier 刷新 speed。 * @param session 会话 * @param id 目标 * @param mul 倍率 * @param duration 秒 */ private _buffSpeed(session: BattleSession, id: EntityId, mul: number, duration: number): void { const world = session.world; if (!world.getComponent(id, AttributeComp)) { world.addComponent(id, new AttributeComp()); } session.attributes.addModifier(id, { attr: 'move_speed_mul', op: 'Mul', value: mul, source: SPEED_MOD, group: SPEED_MOD, remain: duration, }); const move = world.getComponent(id, MovementComp); if (move) { move.speed = Math.max(0, (move.baseSpeed || move.speed) * mul); } } /** * 按最大生命万分比加血。 * @param session 会话 * @param id 目标 * @param wan 万分比 */ private _healOne(session: BattleSession, id: EntityId, wan: number): void { const health = session.world.getComponent(id, HealthComp); if (!health || !(health.maxHp > 0)) return; const add = Math.max(1, Math.floor(health.maxHp * wan / 10000)); health.hp = Math.min(health.maxHp, health.hp + add); const view = this._monsterView(id); view?.syncMxHp?.(health.hp, health.maxHp); } /** * 在怪物节点上播 buff 特效(加速等)。 * @param entity 目标 * @param prefab 预制体名 * @param duration 持续秒;≤0 时用动画时长或兜底 * @param loop 是否循环 */ private _playFx(entity: EntityId, prefab: string, duration: number, loop: boolean): void { const parent = this._fxParent(entity); if (!parent?.isValid) { console.log('[AllySkillExtra] 友军怪无节点', entity, prefab); return; } if (loop) this._dropLoopOnEntity(entity); const node = this._loadFxNode(prefab); if (!node) return; parent.addChild(node); node.active = true; node.setPosition(0, 0, 0); const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton); let wait = duration; if (sk?.isValid) { const typed = sk as sp.Skeleton & { defaultAnimation?: string }; const anim = String(typed.defaultAnimation || sk.animation || '').trim(); if (!anim) { console.log('[AllySkillExtra] 特效无默认动画', prefab); } else { BattleSpinePlayback.play(sk, anim, { loop }); if (!loop) { const dur = BattleSpinePlayback.getAnimDuration(sk, anim); wait = dur != null && dur > 0 ? dur : FX_WAIT_FALLBACK; if (dur == null || !(dur > 0)) { console.log('[AllySkillExtra] 特效动画时长无效', prefab, anim); } } } } else { console.log('[AllySkillExtra] 特效缺少 Skeleton', prefab); } if (!(wait > 0)) wait = FX_WAIT_FALLBACK; this._fx.push({ entity, node, wait, loop }); } /** * 取 buff 预制体:优先 battleHero/prefab/buff,否则 battleGameMonster/prefab。 * @param name 预制体名 */ private _loadFxNode(name: string): Node | null { const heroNode = gg.res.getNode(BATTLE_HERO_BUFF_PATH, name, GBundle.BattleHero); if (heroNode?.isValid) return heroNode; const monsterNode = gg.res.getNode('prefab/', name, GBundle.BattleGameMonster); if (monsterNode?.isValid) { console.log('[AllySkillExtra] battleHero/prefab/buff 无预制体,改用 battleGameMonster', name); return monsterNode; } console.log('[AllySkillExtra] 特效预制体缺失', name); return null; } /** * 特效挂点(upEff 优先)。 * @param entity 目标 */ private _fxParent(entity: EntityId): Node | null { const node = SkillPresentationEnv.getMonsterNode(entity); if (!node?.isValid) return null; const view = this._monsterView(entity); const up = view?.upEff; return up?.isValid ? up : node; } /** * 怪物视图。 * @param entity 目标 */ private _monsterView(entity: EntityId): IBattleMonsterView | null { const node = SkillPresentationEnv.getMonsterNode(entity); if (!node?.isValid) return null; return node.getComponent(BATTLE_MONSTER_CCCLASS) as IBattleMonsterView | null; } /** * 去掉该实体已有循环特效(加速刷新)。 * @param entity 目标 */ private _dropLoopOnEntity(entity: EntityId): void { for (let i = this._fx.length - 1; i >= 0; i--) { const fx = this._fx[i]; if (fx.entity !== entity || !fx.loop) continue; this._fx.splice(i, 1); this._recycleFx(fx.node); } } /** * 回池特效。 * @param node 节点 */ private _recycleFx(node: Node): void { if (!node.isValid) return; const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton); BattleSpinePlayback.clearPendingComplete(sk); BattleSpinePlayback.resetTracks(sk); gg.res.putNode(node); } }