import { Node, UIOpacity, UITransform, Vec3, sp } from 'cc'; import { SkillTransformDriver } from '../skill/SkillTransformDriver'; import { AbilitySetComp, BattleEventType, BattleTeamId, HealthComp, MovementComp, TeamComp, TransformComp, UnitMetaComp, type BattleEvent, } from 'db://assets/mx/module/battle'; import { createAbilityContext } from 'db://assets/mx/module/battle/combat/AbilityContext'; import type { EntityId } from 'db://assets/mx/module/ecs'; import { GBundle, GPath } from '../../game/ConfigRes'; import { TableNames } from '../../game/ConfigTableData'; import { parseLegacyMonsterId, parseMonsterUnitDefId } from '../config/MonsterHpCalculator'; import { MonsterDataManager } from '../../manager/MonsterDataManager'; import { SkillAbilityBuilder } from '../config/baseAtk/SkillAbilityBuilder'; import { entityMonsterAbilityId, isMonsterAbilityId } from '../config/weapon1/Weapon1BattleIds'; import { BattleMapSpec } from '../map/BattleMapSpec'; import { BattleSpinePlayback } from '../presentation/BattleSpinePlayback'; import { SkillVisualChannel } from '../presentation/SkillVisualChannel'; import type { ProjectBattleHandle } from '../runtime/BattleRuntime'; import { findGridIndexForEntity } from '../runtime/DeployEntityQuery'; import { buildSkillPresentationContext } from '../skill/SkillPresentationBridge'; 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 { pickSealablePutTiles, sealPlaceableTiles } from './SkillAtkBuffBoardBridge'; import { getPassiveSkillHost, PassiveResultKind } from './PassiveSkillRuntime'; import { getTowerRuinHost } from './TowerRuinHost'; import { SkillAtkBuffExecutor } from './SkillAtkBuffExecutor'; /** Skill.skill 类型 9:干扰迷雾 */ const EXTRA_FOG = 9; /** 迷雾预制体(battleHero/prefab/skill) */ const FOG_PREFAB = '干扰迷雾'; /** 迷雾循环动画 */ const FOG_ANIM = '技能效果'; /** 持续结束后渐隐秒 */ const FOG_FADE_SEC = 0.3; /** atk_type=15 隐藏:全屏紫雾遮挡 + 怪身,只留乌云 */ const FOG_LOCAL_HIDE_SLOTS = ['遮挡烟雾', '遮挡烟雾2', '身体', '武器', '六耳/影子']; /** atk_type=15 局部迷雾节点缩放(Spine 视觉不受 UITransform ContentSize 约束) */ const FOG_LOCAL_SCALE_X = 0.2; /** atk_type=15 局部迷雾节点缩放 Y */ const FOG_LOCAL_SCALE_Y = 0.4; /** Skill.atk_buff1 类型 9:封可放置格为可开挖 */ const BUFF_SEAL = 9; /** 千里眼 Boss 技能预制体(battleHero/prefab/skill) */ const EYE_PREFAB = '千里眼技能'; /** 光束子节点 */ const EYE_BEAM_NODE = '光束'; /** 落点特效子节点 */ const EYE_HIT_NODE = '目标点效果'; /** 无技能动画时长时特效最短保持秒(与封格 fill 同量级) */ const EYE_HOLD_SEC = 1.2; /** Skill.atk_buff1 类型 12:损毁基础字 */ const BUFF_DESTROY = 12; /** 损毁预制体(battleHero/prefab/skill) */ const DESTROY_PREFAB = '损毁文字'; /** Skill.atk_buff1 类型 6:降雨降攻速 */ const BUFF_RAIN = 6; /** 降雨预制体(battleHero/prefab/skill);敖广用,奔波儿灞(skill 类型9)只用降攻速不挂此特效 */ const RAIN_PREFAB = '降雨'; /** 奔波儿灞喷毒葫芦(battleHero/prefab/skill) */ const GOURD_PREFAB = '葫芦'; /** 葫芦 Spine 节点 */ const GOURD_ANIM_NODE = '葫芦动画'; /** 葫芦释放动画 */ const GOURD_ANIM = '技能葫芦'; /** 葫芦待机角(预制体 euler.z) */ const GOURD_REST_DEG = 45; /** 葫芦相对皮肤根的待机坐标(预制体 lpos) */ const GOURD_REST_X = 24.573; /** 葫芦相对皮肤根的待机坐标 Y */ const GOURD_REST_Y = 30.123; /** 葫芦转向/回正秒 */ const GOURD_TURN_SEC = 0.12; /** 降雨落点相对格子中心上移(像素) */ const RAIN_ABOVE_PX = BattleMapSpec.CELL_SIZE * 0.5; /** Skill.skill 类型 5:吞噬本方小兵 */ const EXTRA_DEVOUR = 5; /** Skill.atk_buff1 类型 1:眩晕 */ const BUFF_STUN = 1; /** Skill.atk_buff1 类型 3:移速 */ const BUFF_SPEED = 3; /** 施法表现等待 */ interface PendingPresent { /** 施法者 */ entity: EntityId; /** 表现上下文 */ ctx: SkillPresentationContext; /** 剩余逻辑秒 */ wait: number; /** 本次是否锁了施法者位移 */ moveLocked: boolean; } /** 干扰迷雾实例 */ interface FogFx { /** 迷雾节点 */ node: Node; /** 透明度 */ opacity: UIOpacity; /** 渐显剩余秒 */ appear: number; /** 渐显总秒;0 表示已满显 */ appearTotal: number; /** 保持剩余秒 */ hold: number; /** 渐隐剩余秒;<0 表示尚未开始渐隐 */ fade: number; /** atk_type=15:局部盖主目标,需藏全屏紫雾槽 */ localCover: boolean; } /** 损毁文字实例 */ interface DestroyFx { /** 特效节点 */ node: Node; /** 目标实体 */ target: EntityId; /** 剩余逻辑秒 */ wait: number; } /** 降雨实例 */ interface RainFx { /** 特效节点 */ node: Node; /** 保持剩余秒 */ hold: number; } /** 奔波儿灞葫芦 */ interface GourdFx { /** 施法者 */ entity: EntityId; /** 葫芦根 */ root: Node; /** 回正角度 */ restDeg: number; /** 播完剩余秒 */ wait: number; /** 已开始回正 */ returning: boolean; } /** 千里眼技能特效 */ interface EyeFx { /** 施法者 */ caster: EntityId; /** 根节点 */ node: Node; /** 剩余逻辑秒 */ hold: number; } /** 被吞噬小兵 */ interface DevourVictim { /** 实体 */ entity: EntityId; /** 吸入起点 X */ fromX: number; /** 吸入起点 Y */ fromY: number; /** 吸入起点缩放 X */ scaleX: number; /** 吸入起点缩放 Y */ scaleY: number; } /** 一次吞噬 */ interface DevourRun { /** 施法者 */ caster: EntityId; /** 被吸小兵 */ victims: DevourVictim[]; /** 已过逻辑秒 */ elapsed: number; /** 吸入时长(技能动画秒) */ duration: number; /** 每只加成万分比 */ bonusWan: number; } /** 友军加速特效计时 */ interface AllySpeedFx { /** 目标实体 */ entity: EntityId; /** 剩余秒 */ wait: number; } let _host: MonsterSkillHost | null = null; /** 怪物技能 Host(单局复用) */ export function getMonsterSkillHost(): MonsterSkillHost { if (!_host) _host = new MonsterSkillHost(); return _host; } /** * 出怪绑定 Monster.skill → Ability,承接 monsterSkill cue 播释放动画。 * extra 9:起播时挂干扰迷雾并渐显(无 atk_type=半屏;atk_type=15=主目标格);同时降攻速。 * atk_buff1 类型 12:技能动画结束后在锁定基础字格播损毁文字并使其失效(火 Boss 41101 索敌跳过场上最高等级)。 * atk_buff1 类型 1 + atk_type=3:动画结束按**当前位置**重扫圈内数字并眩晕(土 Boss 51101;禁攻+格上怪_晕眩)。 * result=11 且 atk_buff1=12:动画结束后放剑气,命中路径上基础字击倒(金 Boss 11101)。 * atk_buff1 类型 6:技能动画结束后降攻速;无 skill 类型 9 时再挂降雨(敖广)。 * 奔波儿灞:播「技能」同时出池 `葫芦` 挂怪物(预制体默认偏移),播「技能葫芦」并朝向主目标格;同时渐显干扰迷雾,播完回 45° 后回池。 * extra 5:释放同时吸入本方周围小兵,动画结束吞噬并加血上限。 * target_stage=2:技能动画结束后给射程内同侧友军怪上 atk_buff(太白移速+回血、水 Boss 31101 移速,含自身;依赖 Skill.range>0)。 * atk_buff1 类型 9:施法起播时出千里眼技能瞄向目标格并封为可开挖。 */ export class MonsterSkillHost { private _battle: ProjectBattleHandle | null = null; private _bound = false; private readonly _pending: PendingPresent[] = []; private readonly _fogs: FogFx[] = []; /** 损毁文字等待 */ private readonly _destroys: DestroyFx[] = []; /** 降雨等待 */ private readonly _rains: RainFx[] = []; /** 奔波儿灞葫芦 */ private readonly _gourds: GourdFx[] = []; /** 葫芦瞄准 local scratch */ private readonly _gourdLocal = new Vec3(); /** 千里眼技能特效 */ private readonly _eyes: EyeFx[] = []; /** 千里眼瞄准 local scratch */ private readonly _eyeLocal = new Vec3(); /** 吞噬吸入 */ private readonly _devours: DevourRun[] = []; /** 半屏定位 scratch */ private readonly _fogLocal = new Vec3(); /** 半屏世界坐标 scratch */ private readonly _fogWorld = new Vec3(); /** 吞噬扫描施法者 */ private _devourScanCaster: EntityId = 0; /** 吞噬扫描射程像素 */ private _devourScanRangePx = 0; /** 吞噬扫描命中 */ private readonly _devourScanIds: EntityId[] = []; /** 友军加速特效 */ private readonly _allySpeeds: AllySpeedFx[] = []; /** 友军 buff 扫描施法者 */ private _allyScanCaster: EntityId = 0; /** 友军 buff 扫描射程像素 */ private _allyScanRangePx = 0; /** 友军 buff 扫描命中 */ private readonly _allyScanIds: EntityId[] = []; /** 绑定本局:监听出怪与表现 cue */ bind(battle: ProjectBattleHandle): void { this.unbind(); this._battle = battle; battle.session.events.on(BattleEventType.UnitSpawned, this._onBattleEvent); battle.session.events.on(BattleEventType.PresentationCue, this._onBattleEvent); battle.session.events.on(BattleEventType.EntityKilled, this._onBattleEvent); battle.session.events.on(BattleEventType.UnitDespawned, this._onBattleEvent); this._bound = true; battle.session.world.forEachAlive(this._tryBind); } /** 解除绑定并释放施法锁 */ unbind(): void { const battle = this._battle; if (battle && this._bound) { battle.session.events.off(BattleEventType.UnitSpawned, this._onBattleEvent); battle.session.events.off(BattleEventType.PresentationCue, this._onBattleEvent); battle.session.events.off(BattleEventType.EntityKilled, this._onBattleEvent); battle.session.events.off(BattleEventType.UnitDespawned, this._onBattleEvent); } this._bound = false; this._cancelAll('host_unbind'); this._recycleAllFogs(); this._recycleAllDestroys(); this._recycleAllRains(); this._recycleAllGourds(); this._recycleAllEyes(); this._abortAllDevours(); this._clearAllAllySpeeds(); this._battle = null; } /** * 还魂丹复活:取消未结束技能表现并解锁施法锁。 * @param entityId 行军怪实体 */ releasePassiveReviveHold(entityId: EntityId): void { this._cancelEntity(entityId, 'passive_revive'); const battle = this._battle; if (battle) { battle.session.abilities.setPresentationLocked(entityId, false); } } /** * 推进技能动画等待、迷雾、损毁、降雨与吞噬吸入。 * @param dt 战斗缩放秒 */ tick(dt: number): void { if (!(dt > 0)) return; this._tickDevours(dt); if (this._pending.length > 0) { for (let i = this._pending.length - 1; i >= 0; i--) { const item = this._pending[i]; item.wait -= dt; if (item.wait > 0 && item.ctx.isAlive) continue; this._pending.splice(i, 1); this._finishPresent(item); } } this._tickFogs(dt); this._tickDestroys(dt); this._tickRains(dt); this._tickGourds(dt); this._tickEyes(dt); this._tickAllySpeeds(dt); } private _onBattleEvent = (event: BattleEvent): void => { if (event.type === BattleEventType.UnitSpawned) { this._tryBind(event.entity); return; } if (event.type === BattleEventType.PresentationCue) { this._present(event); return; } if (event.type === BattleEventType.EntityKilled) { this._cancelEntity(event.victim, 'caster_dead'); return; } if (event.type === BattleEventType.UnitDespawned) { this._cancelEntity(event.entity, 'caster_despawn'); } }; /** * 按 Monster.skill 注册并绑定 Ability。 * @param entityId 出怪实体 */ private _tryBind = (entityId: EntityId): void => { const battle = this._battle; if (!battle) return; const world = battle.session.world; if (!world.isAlive(entityId)) return; const meta = world.getComponent(entityId, UnitMetaComp); if (!meta?.unitDefId) return; const parsed = parseMonsterUnitDefId(meta.unitDefId); const monsterId = parsed?.monsterId ?? parseLegacyMonsterId(meta.unitDefId); if (!(monsterId > 0)) return; const monster = gg.data.table.getTableData(TableNames.Monster, monsterId); if (!monster) { console.log('[MonsterSkillHost] Monster 缺失', monsterId); return; } const skillId = Math.floor(Number(monster.skill) || 0); if (!(skillId > 0)) return; const row = gg.data.table.getTableData(TableNames.Skill, skillId); if (!row) { console.log('[MonsterSkillHost] Skill 缺失', skillId, 'monster', monsterId); return; } const skillType = Number(row.type); if (skillType === 2) { console.log('[MonsterSkillHost] 被动技能本切片不绑定', monsterId, skillId); return; } if (skillType !== 1) { console.log('[MonsterSkillHost] Skill.type 非主动(1),仍按主动绑定播动画', skillId, skillType); } const abilityId = entityMonsterAbilityId(row.id, entityId); const set = world.getComponent(entityId, AbilitySetComp); if (set && set.abilityIds.indexOf(abilityId) >= 0) return; const config = battle.session.config; if (!config.registerAbility) { console.log('[MonsterSkillHost] config 不支持 registerAbility', abilityId); return; } const startOnCooldown = MonsterDataManager.isBossType(monster.type); config.registerAbility(abilityId, SkillAbilityBuilder.buildMonster(row, entityId, startOnCooldown)); if (!set) { world.addComponent(entityId, new AbilitySetComp([abilityId])); } else { set.abilityIds.push(abilityId); } battle.session.abilities.bindEntity(entityId, { preserveRuntime: !!set }); }; /** * 播 Skill.animation;动画结束后 finish 解锁。 * @param event PresentationCue */ private _present(event: Extract): void { const isMonster = isMonsterAbilityId(event.abilityId) || event.params?.skillKind === 'monster' || event.cueId === 'monsterSkill'; if (!isMonster) return; const battle = this._battle; if (!battle) { console.log('[MonsterSkillHost] 未绑定,丢弃怪物技能 cue', event.abilityId); return; } // 被动打断等已 cancel 同次 cast:无 deferred pending,不播技能动画 if (!battle.session.presentation.getPending(event.castInstanceId)) { return; } const ctx = buildSkillPresentationContext(battle.session, { caster: event.caster, abilityId: event.abilityId, cueId: event.cueId, castInstanceId: event.castInstanceId, targetEntityIds: event.targetEntityIds, params: event.params, }); if (!ctx.isAlive) { ctx.cancel('caster_dead'); return; } const animName = String(event.params?.animation ?? '').trim(); const node = SkillPresentationEnv.getMonsterNode(event.caster); const view = node?.isValid ? node.getComponent(BATTLE_MONSTER_CCCLASS) as IBattleMonsterView | null : null; const sk = view?.anim; if (!node?.isValid || !view || !sk?.isValid) { console.log('[MonsterSkillHost] 怪物节点未就绪', event.caster, event.abilityId); this._finishPresent({ entity: event.caster, ctx, wait: 0, moveLocked: false }); return; } // 无 Data 时不查死亡轨(避免 resolveDeathAnim 无配表刷屏);仅已绑定配表时拦截死亡态 const deathAnim = view.Data ? MonsterDataManager.resolveDeathAnim(view.Data) : ''; if (sk.animation === '死亡' || (deathAnim && sk.animation === deathAnim)) { ctx.finish(); return; } let playAnim = animName; if (!playAnim) { console.log('[MonsterSkillHost] Skill.animation 为空', event.abilityId); } else if (BattleSpinePlayback.getAnimDuration(sk, playAnim) == null) { // 五行 Boss 资源轨多为「攻击」,表若写「攻击1」则告警后改播「攻击」 if (playAnim === '攻击1' && BattleSpinePlayback.getAnimDuration(sk, '攻击') != null) { console.log('[MonsterSkillHost] 骨骼无', playAnim, '改播攻击', node.name, event.abilityId); playAnim = '攻击'; } else { console.log('[MonsterSkillHost] 骨骼无技能动画', playAnim, node.name, event.abilityId); playAnim = ''; } } let wait = 0; if (playAnim) { const baseCd = Math.max(0, Number(ctx.params.cd) || 0); const intervalSec = baseCd > 0 ? SkillPresentationEnv.getFinalCooldown(event.caster, baseCd) : 0; const dur = BattleSpinePlayback.getAnimDuration(sk, playAnim); const animScale = SkillVisualChannel.fitAnimTimeScale( dur, intervalSec > 0 ? intervalSec : Number.POSITIVE_INFINITY, ); const delay = BattleSpinePlayback.resolveLogicalCompleteDelay(sk, playAnim, animScale); // Boss/怪物主动:仅当 CD 短于动画时长时加速,从不减速 view.playSpineAnim?.(playAnim, false, undefined, animScale); wait = delay != null && delay > 0 ? delay : 0; } this._playGourdIfNeeded(event.caster, ctx); this._spawnFogIfNeeded(ctx, wait > 0 ? wait : FOG_FADE_SEC); // 迷雾起播即降攻速,避免动画结束前点面板看不到 if (Math.floor(Number(ctx.params.skillType ?? 0) || 0) === EXTRA_FOG) { this._spawnRainIfNeeded(ctx); } getMonsterStatusText().play(event.caster, '释放技能'); // result 11 剑气与攻击同帧起飞(勿等动画结束,否则观感像没放) this._fireSkillResultIfNeeded(ctx); this._beginDevour(ctx, wait); this._beginEyeIfNeeded(ctx, wait); if (!(wait > 0)) { this._finishPresent({ entity: event.caster, ctx, wait: 0, moveLocked: false }); return; } this._lockCasterMove(event.caster); this._pending.push({ entity: event.caster, ctx, wait, moveLocked: true }); } /** * 动画结束:回走路并解锁施法。 * @param item 等待项 */ private _finishPresent(item: PendingPresent): void { const ctx = item.ctx; if (item.moveLocked) this._unlockCasterMove(item.entity); if (!ctx.isAlive) { this._abortDevour(item.entity); ctx.cancel('caster_dead'); return; } const node = SkillPresentationEnv.getMonsterNode(item.entity); const view = node?.isValid ? node.getComponent(BATTLE_MONSTER_CCCLASS) as IBattleMonsterView | null : null; if (view?.playWalk && view.anim?.isValid) { const deathAnim = view.Data ? MonsterDataManager.resolveDeathAnim(view.Data) : ''; const cur = String(view.anim.animation ?? ''); if (cur !== '死亡' && !(deathAnim && cur === deathAnim)) { view.playWalk(); } } this._consumeDevour(item.entity); if (!this._hasSwordWaveRuin(ctx)) { this._spawnDestroyIfNeeded(ctx); } if (Math.floor(Number(ctx.params.skillType ?? 0) || 0) !== EXTRA_FOG) { this._spawnRainIfNeeded(ctx); } this._buffAlliesIfNeeded(ctx); this._stunAroundIfNeeded(ctx); while (ctx.isAlive && ctx.hit()) { /* 召唤 / atk_buff */ } ctx.finish(); } /** * atk_type=3 + atk_buff1 类型1:按施法者**当前**位置为圆心重扫范围内敌方数字并眩晕。 * @param ctx 本次施法 */ private _stunAroundIfNeeded(ctx: SkillPresentationContext): void { const buff1 = String(ctx.params.buff1 ?? '').trim(); const buff2 = String(ctx.params.buff2 ?? '').trim(); const head = Math.floor(Number(buff1.split(',')[0]) || 0); if (head !== BUFF_STUN) return; const atkHead = Math.floor(Number(ctx.params.atkTypeHead ?? 0) || 0); if (atkHead !== 3) return; const battle = this._battle; if (!battle) { console.log('[MonsterSkillHost] 未绑定,无法圈晕', ctx.abilityId); return; } const def = battle.session.config.getAbility(ctx.abilityId); if (!def?.targeting) { console.log('[MonsterSkillHost] 圈晕缺少 targeting', ctx.abilityId); return; } const abilityCtx = createAbilityContext(battle.session, ctx.caster, ctx.abilityId); abilityCtx.level = ctx.level; const ids = battle.session.targets.resolve(abilityCtx, def.targeting); if (ids.length <= 0) { console.log('[MonsterSkillHost] 当前位置圈内无可晕数字', ctx.abilityId); return; } for (let i = 0; i < ids.length; i++) { SkillAtkBuffExecutor.applyPair(battle.session, abilityCtx, ids[i], buff1, buff2); } } /** * 施法起播按 Skill.result 结算(金 Boss 等:result 11 剑气)。 * @param ctx 本次施法 */ private _fireSkillResultIfNeeded(ctx: SkillPresentationContext): void { const skillId = Math.floor(Number(ctx.params.skillId) || 0); const session = this._battle?.session; if (!session || !(skillId > 0)) return; const row = gg.data.table.getTableData(TableNames.Skill, skillId); if (!String(row?.result ?? '').trim()) return; const target = ctx.targets[0] ?? 0; getPassiveSkillHost().applySkillResult(session, ctx.caster, target, skillId); } /** * result=11 且 atk_buff1=12:损毁改由剑气命中路径结算。 * @param ctx 本次施法 */ private _hasSwordWaveRuin(ctx: SkillPresentationContext): boolean { const skillId = Math.floor(Number(ctx.params.skillId) || 0); if (!(skillId > 0)) return false; const row = gg.data.table.getTableData(TableNames.Skill, skillId); const result = String(row?.result ?? '').trim(); if (!result) return false; const kind = Math.floor(Number(result.split(',')[0]) || 0); if (kind !== PassiveResultKind.SwordWave) return false; const buffHead = Math.floor(Number(String(ctx.params.buff1 ?? '').split(',')[0]) || 0); return buffHead === BUFF_DESTROY; } /** * 取消指定实体未结束的表现。 * @param entityId 施法者 * @param reason 原因 */ private _cancelEntity(entityId: EntityId, reason: string): void { this._abortDevour(entityId); this._recycleGourdOf(entityId); this._recycleEyesOf(entityId); for (let i = this._pending.length - 1; i >= 0; i--) { if (this._pending[i].entity !== entityId) continue; const item = this._pending[i]; this._pending.splice(i, 1); if (item.moveLocked) this._unlockCasterMove(entityId); if (item.ctx.isAlive) item.ctx.cancel(reason); } } /** * 取消全部未结束表现。 * @param reason 原因 */ private _cancelAll(reason: string): void { for (let i = 0; i < this._pending.length; i++) { const item = this._pending[i]; if (item.moveLocked) this._unlockCasterMove(item.entity); if (item.ctx.isAlive) item.ctx.cancel(reason); } this._pending.length = 0; this._abortAllDevours(); this._recycleAllGourds(); this._recycleAllEyes(); } /** * 施法期间停步。门神顶推中不重新上锁,避免 Boss 技能把怪钉死。 * @param entityId 施法者 */ private _lockCasterMove(entityId: EntityId): void { const move = this._battle?.session.world.getComponent(entityId, MovementComp); if (!move || move.bajiaoBlowRetreating) return; move.presentLocked = true; } /** * 施法结束恢复走路。 * @param entityId 施法者 */ private _unlockCasterMove(entityId: EntityId): void { const move = this._battle?.session.world.getComponent(entityId, MovementComp); if (move) move.presentLocked = false; } /** * skill 类型 9:出池葫芦挂怪物,用预制体默认偏移,朝向主目标格并播「技能葫芦」。 * @param entity 施法者 * @param ctx 本次施法 */ private _playGourdIfNeeded(entity: EntityId, ctx: SkillPresentationContext): void { const raw = String(ctx.params.extraSkill ?? ctx.params.skillRaw ?? ''); const head = Math.floor(Number(raw.split(',')[0]) || 0); if (head !== EXTRA_FOG) return; this._recycleGourdOf(entity); const parent = SkillPresentationEnv.getMonsterNode(entity); if (!parent?.isValid) { console.log('[MonsterSkillHost] 葫芦无怪物挂点', entity); return; } let root: Node | null = null; try { root = gg.res.getNode(GPath.HeroSkillPrefab(), GOURD_PREFAB, GBundle.BattleHero); } catch (err) { console.log('[MonsterSkillHost] getNode 失败', GOURD_PREFAB, String(err)); return; } if (!root?.isValid) { console.log('[MonsterSkillHost] 预制体未加载', GOURD_PREFAB); return; } root.layer = parent.layer; root.parent = parent; root.setSiblingIndex(parent.children.length - 1); root.setPosition(GOURD_REST_X, GOURD_REST_Y, 0); root.setScale(1, 1, 1); root.angle = GOURD_REST_DEG; root.active = true; const animNode = root.getChildByName(GOURD_ANIM_NODE); const sk = animNode?.getComponent(sp.Skeleton); if (!animNode?.isValid || !sk?.isValid) { console.log('[MonsterSkillHost] 葫芦缺少动画节点', entity); this._recycleGourdNode(root, GOURD_REST_DEG); return; } if (BattleSpinePlayback.getAnimDuration(sk, GOURD_ANIM) == null) { console.log('[MonsterSkillHost] 葫芦缺少动画', GOURD_ANIM, entity); this._recycleGourdNode(root, GOURD_REST_DEG); return; } const aimDeg = this._gourdAimDeg(root, ctx.targets[0] ?? 0); if (aimDeg == null) { console.log('[MonsterSkillHost] 葫芦无目标朝向', entity); } SkillTransformDriver.cancelNode(root); if (aimDeg != null) { SkillTransformDriver.animate({ node: root, duration: GOURD_TURN_SEC, fromAngle: GOURD_REST_DEG, toAngle: aimDeg, }); } if (!BattleSpinePlayback.play(sk, GOURD_ANIM, { loop: false })) { console.log('[MonsterSkillHost] 葫芦动画起播失败', GOURD_ANIM, entity); this._recycleGourdNode(root, GOURD_REST_DEG); return; } const delay = BattleSpinePlayback.resolveLogicalCompleteDelay(sk, GOURD_ANIM, 1); this._gourds.push({ entity, root, restDeg: GOURD_REST_DEG, wait: delay != null && delay > 0 ? delay : GOURD_TURN_SEC, returning: false, }); } /** * 主目标格世界坐标(与迷雾落点同一套)。 * @param target 主目标 */ private _gourdTargetWorld(target: EntityId): Vec3 | null { if (!(target > 0)) return null; return SkillPresentationEnv.getTargetGridWorldCenter(target) ?? SkillPresentationEnv.getTargetWorldPos(target); } /** * 葫芦根朝向主目标格的本地角。 * @param root 葫芦 * @param target 主目标 */ private _gourdAimDeg(root: Node, target: EntityId): number | null { const to = this._gourdTargetWorld(target); if (!to) return null; const parent = root.parent; const parentUt = parent?.getComponent(UITransform); if (!parentUt) return null; parentUt.convertToNodeSpaceAR(to, this._gourdLocal); const dx = this._gourdLocal.x - root.position.x; const dy = this._gourdLocal.y - root.position.y; if (!(dx * dx + dy * dy > 1e-6)) return null; return Math.atan2(dy, dx) * 180 / Math.PI; } /** 推进葫芦:播完回正后回池 */ private _tickGourds(dt: number): void { for (let i = this._gourds.length - 1; i >= 0; i--) { const fx = this._gourds[i]; if (!fx.root?.isValid) { this._gourds.splice(i, 1); continue; } fx.wait -= dt; if (fx.wait > 0) continue; if (fx.returning) continue; fx.returning = true; SkillTransformDriver.cancelNode(fx.root); SkillTransformDriver.animate({ node: fx.root, duration: GOURD_TURN_SEC, fromAngle: fx.root.angle, toAngle: fx.restDeg, onComplete: this._onGourdRestored, }); } } /** 回正结束:回池葫芦 */ private readonly _onGourdRestored = (node?: Node): void => { if (!node?.isValid) return; for (let i = this._gourds.length - 1; i >= 0; i--) { if (this._gourds[i].root !== node) continue; this._recycleGourdNode(node, this._gourds[i].restDeg); this._gourds.splice(i, 1); return; } this._recycleGourdNode(node, GOURD_REST_DEG); }; /** * 立刻回池一只葫芦。 * @param entity 施法者 */ private _recycleGourdOf(entity: EntityId): void { for (let i = this._gourds.length - 1; i >= 0; i--) { if (this._gourds[i].entity !== entity) continue; const fx = this._gourds[i]; this._gourds.splice(i, 1); this._recycleGourdNode(fx.root, fx.restDeg); } } /** 回池全部葫芦 */ private _recycleAllGourds(): void { for (let i = 0; i < this._gourds.length; i++) { this._recycleGourdNode(this._gourds[i].root, this._gourds[i].restDeg); } this._gourds.length = 0; } /** * 停转向、复位后回池。 * @param root 葫芦 * @param restDeg 默认角 */ private _recycleGourdNode(root: Node, restDeg: number): void { if (!root?.isValid) return; SkillTransformDriver.cancelNode(root); const sk = root.getComponentInChildren(sp.Skeleton); BattleSpinePlayback.clearPendingComplete(sk); BattleSpinePlayback.resetTracks(sk); root.angle = restDeg; root.setPosition(GOURD_REST_X, GOURD_REST_Y, 0); root.setScale(1, 1, 1); gg.res.putNode(root); } /** * Skill.skill 类型 9:与葫芦同时挂迷雾并渐显。 * atk_type=15:盖在主目标格上并按 atk_para 半径缩放;否则对方半屏。 * @param ctx 本次施法 * @param fadeIn 渐显秒 */ private _spawnFogIfNeeded(ctx: SkillPresentationContext, fadeIn: number): void { const raw = String(ctx.params.extraSkill ?? ctx.params.skillRaw ?? ''); const parts = raw.split(','); const head = Math.floor(Number(parts[0]) || 0); if (head !== EXTRA_FOG) return; const duration = Number(parts[1]); if (!(duration > 0)) { console.log('[MonsterSkillHost] skill 类型9缺少持续时间', ctx.abilityId); return; } const battle = this._battle; if (!battle) { console.log('[MonsterSkillHost] 未绑定,无法挂迷雾', ctx.abilityId); return; } const team = battle.session.world.getComponent(ctx.caster, TeamComp); if (!team) { console.log('[MonsterSkillHost] 施法者无 TeamComp', ctx.caster); return; } const flyLayer = SkillPresentationEnv.flyLayer; if (!flyLayer?.isValid) { console.log('[MonsterSkillHost] flyLayer 缺失', FOG_PREFAB); return; } let node: Node | null = null; try { node = gg.res.getNode(GPath.HeroSkillPrefab(), FOG_PREFAB, GBundle.BattleHero); } catch (err) { console.log('[MonsterSkillHost] getNode 失败', FOG_PREFAB, String(err)); return; } if (!node?.isValid) { console.log('[MonsterSkillHost] 预制体未加载', FOG_PREFAB); return; } flyLayer.active = true; node.active = true; node.layer = flyLayer.layer; node.parent = flyLayer; const atkHead = Math.floor(Number(ctx.params.atkTypeHead ?? ctx.params.atkType) || 0); const localCover = atkHead === 15; const placed = localCover ? this._placeFogOnPrimary(node, ctx) : this._placeFogHalfScreen(node, flyLayer, this._fogOnUpperHalf(team)); if (!placed) { node.setScale(1, 1, 1); gg.res.putNode(node); return; } node.setSiblingIndex(flyLayer.children.length - 1); const opacity = node.getComponent(UIOpacity) ?? node.addComponent(UIOpacity); const appear = fadeIn > 0 ? fadeIn : 0; opacity.opacity = appear > 0 ? 0 : 255; const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton); if (!sk?.isValid) { console.log('[MonsterSkillHost] 迷雾缺少 Skeleton', FOG_PREFAB); node.setScale(1, 1, 1); gg.res.putNode(node); return; } if (BattleSpinePlayback.getAnimDuration(sk, FOG_ANIM) == null) { console.log('[MonsterSkillHost] 迷雾无动画', FOG_ANIM, FOG_PREFAB); node.setScale(1, 1, 1); gg.res.putNode(node); return; } BattleSpinePlayback.play(sk, FOG_ANIM, { loop: true }); if (localCover) this._hideFogLocalSlots(sk); this._fogs.push({ node, opacity, appear, appearTotal: appear, hold: duration, fade: -1, localCover, }); } /** * atk_type=15:迷雾挂主目标;节点缩放到 FOG_LOCAL_SCALE(UITransform 改 ContentSize 不影响 Spine 视觉)。 * @param node 迷雾 * @param ctx 施法上下文 */ private _placeFogOnPrimary(node: Node, ctx: SkillPresentationContext): boolean { const primary = ctx.targets[0] ?? 0; if (!(primary > 0)) { console.log('[MonsterSkillHost] atk_type=15 无主目标,无法挂迷雾', ctx.abilityId); return false; } const world = this._gourdTargetWorld(primary); if (!world) { console.log('[MonsterSkillHost] 主目标无格子坐标', primary, ctx.abilityId); return false; } node.setScale(FOG_LOCAL_SCALE_X, FOG_LOCAL_SCALE_Y, 1); node.setWorldPosition(world.x, world.y, world.z); return true; } /** * 藏全屏紫雾与怪身槽;动画可能每帧写回,需配合 tick 重复调用。 * @param sk 迷雾 Skeleton */ private _hideFogLocalSlots(sk: sp.Skeleton): void { if (!sk?.isValid || typeof sk.findSlot !== 'function') return; for (let i = 0; i < FOG_LOCAL_HIDE_SLOTS.length; i++) { const name = FOG_LOCAL_HIDE_SLOTS[i]; const slot = sk.findSlot(name) as { color?: { a: number }; attachment?: unknown; setAttachment?: (a: null) => void } | null; if (!slot) { console.log('[MonsterSkillHost] 迷雾局部缺槽', name); continue; } if (slot.color) slot.color.a = 0; if (typeof slot.setAttachment === 'function') slot.setAttachment(null); else slot.attachment = null; } } /** * 迷雾盖对方半屏:敌方打本机=下半屏,本机侧打对方=上半屏。 * @param team 施法者阵营 */ private _fogOnUpperHalf(team: TeamComp): boolean { const localId = this._battle?.player?.playerId ?? 0; const ownerId = Math.floor(Number(team.ownerId) || 0); const targetPlayerId = BattleTeamId.isEnemyTeam(team.team) ? ownerId : 1 - ownerId; return targetPlayerId !== localId; } /** * 挂到 flyLayer:x=0,y 为对阵半屏中心(不锁格子)。 * @param node 迷雾 * @param flyLayer ui_fly * @param upper 是否上半屏(敌方) */ private _placeFogHalfScreen(node: Node, flyLayer: Node, upper: boolean): boolean { const board = flyLayer.parent; const boardUt = board?.getComponent(UITransform); const flyUt = flyLayer.getComponent(UITransform); if (!boardUt || !flyUt) { console.log('[MonsterSkillHost] 半屏定位缺少 UITransform'); return false; } const h = boardUt.height; if (!(h > 0)) { console.log('[MonsterSkillHost] 战斗界面高度无效', h); return false; } node.setScale(1, 1, 1); this._fogLocal.set(0, upper ? h * 0.25 : -h * 0.25, 0); boardUt.convertToWorldSpaceAR(this._fogLocal, this._fogWorld); const local = flyUt.convertToNodeSpaceAR(this._fogWorld); node.setPosition(0, local.y, 0); return true; } /** * 推进迷雾保持与渐隐。 * @param dt 战斗缩放秒 */ private _tickFogs(dt: number): void { if (this._fogs.length <= 0) return; for (let i = this._fogs.length - 1; i >= 0; i--) { const fog = this._fogs[i]; if (!fog.node.isValid) { this._fogs.splice(i, 1); continue; } if (fog.localCover) { const sk = fog.node.getComponent(sp.Skeleton) ?? fog.node.getComponentInChildren(sp.Skeleton); if (sk?.isValid) this._hideFogLocalSlots(sk); } if (fog.appear > 0 && fog.appearTotal > 0) { fog.appear -= dt; const t = 1 - Math.max(fog.appear, 0) / fog.appearTotal; fog.opacity.opacity = Math.round(255 * t); if (fog.appear > 0) continue; fog.appear = 0; fog.opacity.opacity = 255; } if (fog.hold > 0) { fog.hold -= dt; if (fog.hold > 0) continue; fog.hold = 0; fog.fade = FOG_FADE_SEC; } if (fog.fade < 0) continue; fog.fade -= dt; if (fog.fade > 0) { fog.opacity.opacity = Math.round(255 * (fog.fade / FOG_FADE_SEC)); continue; } this._fogs.splice(i, 1); this._recycleFog(fog); } } /** * 回池单团迷雾。 * @param fog 实例 */ private _recycleFog(fog: FogFx): void { const node = fog.node; if (!node?.isValid) return; const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton); BattleSpinePlayback.clearPendingComplete(sk); BattleSpinePlayback.resetTracks(sk); if (sk?.isValid && typeof sk.setSlotsToSetupPose === 'function') { sk.setSlotsToSetupPose(); } fog.opacity.opacity = 255; node.setScale(1, 1, 1); gg.res.putNode(node); const flyLayer = SkillPresentationEnv.flyLayer; if (flyLayer?.isValid && flyLayer.children.length === 0) flyLayer.active = false; } /** 回收全部迷雾 */ private _recycleAllFogs(): void { for (let i = 0; i < this._fogs.length; i++) { this._recycleFog(this._fogs[i]); } this._fogs.length = 0; } /** * atk_buff1 类型 9:施法起播时出千里眼技能,瞄向目标格并封格。 * @param ctx 本次施法 * @param wait 技能动画秒 */ private _beginEyeIfNeeded(ctx: SkillPresentationContext, wait: number): void { const raw = String(ctx.params.buff1 ?? ''); const head = Math.floor(Number(raw.split(',')[0]) || 0); if (head !== BUFF_SEAL) return; const count = Math.floor(Number(String(ctx.params.atkPara ?? '').split(',')[0]) || 0); if (!(count > 0)) { console.log('[MonsterSkillHost] atk_buff1 类型9缺少封格数量', ctx.abilityId, ctx.params.atkPara); return; } const battle = this._battle; if (!battle) { console.log('[MonsterSkillHost] 未绑定,无法封格', ctx.abilityId); return; } const grids = pickSealablePutTiles(battle, ctx.caster, count); if (grids.length <= 0) return; const hold = wait > 0 ? wait : EYE_HOLD_SEC; for (let i = 0; i < grids.length; i++) { const node = this._spawnEyeFx(ctx.caster, grids[i]); if (node) this._eyes.push({ caster: ctx.caster, node, hold }); } sealPlaceableTiles(battle, ctx.caster, grids); } /** * 在 Boss 处出池千里眼技能并瞄向目标格。 * @param caster 施法者 * @param gridIndex 目标格 */ private _spawnEyeFx(caster: EntityId, gridIndex: number): Node | null { const flyLayer = SkillPresentationEnv.flyLayer; if (!flyLayer?.isValid) { console.log('[MonsterSkillHost] flyLayer 缺失', EYE_PREFAB); return null; } const boss = SkillPresentationEnv.getMonsterNode(caster); if (!boss?.isValid) { console.log('[MonsterSkillHost] 千里眼无 Boss 节点', caster); return null; } let node: Node | null = null; try { node = gg.res.getNode(GPath.HeroSkillPrefab(), EYE_PREFAB, GBundle.BattleHero); } catch (err) { console.log('[MonsterSkillHost] getNode 失败', EYE_PREFAB, String(err)); return null; } if (!node?.isValid) { console.log('[MonsterSkillHost] 预制体未加载', EYE_PREFAB); return null; } const flyUt = flyLayer.getComponent(UITransform); if (!flyUt) { console.log('[MonsterSkillHost] 千里眼 flyLayer 无 UITransform'); gg.res.putNode(node); return null; } flyLayer.active = true; node.active = true; node.layer = flyLayer.layer; node.setScale(1, 1, 1); node.angle = 0; this._resetEyeNode(node); node.parent = flyLayer; node.setSiblingIndex(flyLayer.children.length - 1); node.setPosition(flyUt.convertToNodeSpaceAR(boss.worldPosition)); if (!this._aimEyeFx(node, gridIndex)) { this._resetEyeNode(node); gg.res.putNode(node); return null; } this._playEyeDefaultLoops(node); return node; } /** * 光束朝向目标并按距离拉 X 缩放;落点特效放到目标格。 * @param root 千里眼技能根 * @param gridIndex 目标格 */ private _aimEyeFx(root: Node, gridIndex: number): boolean { const map = this._battle?.Map; const mg = map?.getMapGridByIndex(gridIndex); if (!mg?.node?.isValid) { console.log('[MonsterSkillHost] 千里眼目标格缺失', gridIndex); return false; } const rootUt = root.getComponent(UITransform); if (!rootUt) { console.log('[MonsterSkillHost] 千里眼根节点无 UITransform'); return false; } const beam = root.getChildByName(EYE_BEAM_NODE); const hit = root.getChildByName(EYE_HIT_NODE); if (!beam?.isValid || !hit?.isValid) { console.log('[MonsterSkillHost] 千里眼缺少子节点', EYE_BEAM_NODE, EYE_HIT_NODE); return false; } const local = rootUt.convertToNodeSpaceAR(mg.node.worldPosition, this._eyeLocal); hit.setPosition(local); const dx = local.x - beam.position.x; const dy = local.y - beam.position.y; const dist = Math.sqrt(dx * dx + dy * dy); if (!(dist > 1e-4)) { console.log('[MonsterSkillHost] 千里眼光束距离为零', gridIndex); return false; } const beamUt = beam.getComponent(UITransform); const base = beamUt?.width ?? 0; if (!(base > 1e-4)) { console.log('[MonsterSkillHost] 千里眼光束宽度无效'); return false; } beam.angle = Math.atan2(dy, dx) * 180 / Math.PI; beam.setScale(dist / base, 1, 1); return true; } /** * 光束与落点播预制体默认动画(循环)。 * @param root 千里眼技能根 */ private _playEyeDefaultLoops(root: Node): void { const hit = root.getChildByName(EYE_HIT_NODE); const beam = root.getChildByName(EYE_BEAM_NODE); if (hit?.isValid) this._playNodeDefaultLoop(hit, EYE_HIT_NODE); if (beam?.isValid) this._playNodeDefaultLoop(beam, EYE_BEAM_NODE); } /** * 节点默认动画循环。 * @param node 带 Skeleton 的节点 * @param label 日志名 */ private _playNodeDefaultLoop(node: Node, label: string): void { const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton); if (!sk?.isValid) { console.log('[MonsterSkillHost] 千里眼缺少 Skeleton', label); return; } const name = String((sk as sp.Skeleton & { defaultAnimation?: string }).defaultAnimation ?? '').trim(); if (!name) { console.log('[MonsterSkillHost] 千里眼无默认动画', label); return; } if (!BattleSpinePlayback.play(sk, name, { loop: true })) { console.log('[MonsterSkillHost] 千里眼默认动画起播失败', label, name); } } /** * 推进千里眼特效保持。 * @param dt 战斗缩放秒 */ private _tickEyes(dt: number): void { if (this._eyes.length <= 0) return; for (let i = this._eyes.length - 1; i >= 0; i--) { const fx = this._eyes[i]; if (!fx.node.isValid) { this._eyes.splice(i, 1); continue; } fx.hold -= dt; if (fx.hold > 0) continue; this._eyes.splice(i, 1); this._recycleEye(fx); } } /** * 回收某施法者的千里眼特效。 * @param caster 施法者 */ private _recycleEyesOf(caster: EntityId): void { for (let i = this._eyes.length - 1; i >= 0; i--) { if (this._eyes[i].caster !== caster) continue; const fx = this._eyes[i]; this._eyes.splice(i, 1); this._recycleEye(fx); } } /** 回收全部千里眼特效 */ private _recycleAllEyes(): void { for (let i = 0; i < this._eyes.length; i++) { this._recycleEye(this._eyes[i]); } this._eyes.length = 0; } /** * 回池单条千里眼。 * @param fx 实例 */ private _recycleEye(fx: EyeFx): void { const node = fx.node; if (!node?.isValid) return; this._resetEyeNode(node); gg.res.putNode(node); const flyLayer = SkillPresentationEnv.flyLayer; if (flyLayer?.isValid && flyLayer.children.length === 0) flyLayer.active = false; } /** * 回池前复位缩放朝向与 Spine。 * @param node 根节点 */ private _resetEyeNode(node: Node): void { const beam = node.getChildByName(EYE_BEAM_NODE); if (beam?.isValid) { beam.angle = 0; beam.setScale(1, 1, 1); } const sks = node.getComponentsInChildren(sp.Skeleton); for (let i = 0; i < sks.length; i++) { BattleSpinePlayback.clearPendingComplete(sks[i]); BattleSpinePlayback.resetTracks(sks[i]); } node.angle = 0; node.setScale(1, 1, 1); } /** * target_stage=2:技能动画结束后给射程内同侧友军怪上 atk_buff。 * @param ctx 本次施法 */ private _buffAlliesIfNeeded(ctx: SkillPresentationContext): void { if (Math.floor(Number(ctx.params.skillType) || 0) === EXTRA_DEVOUR) return; if (Math.floor(Number(ctx.params.stageStrategy) || 0) !== 2) return; const buff1 = String(ctx.params.buff1 ?? '').trim(); const buff2 = String(ctx.params.buff2 ?? '').trim(); if (!buff1 && !buff2) return; const battle = this._battle; if (!battle) { console.log('[MonsterSkillHost] 未绑定,无法给友军上 buff', ctx.abilityId); return; } const rangePx = Number(ctx.params.stageRangePx); if (!(rangePx > 0)) { console.log('[MonsterSkillHost] 友军 buff 缺少射程', ctx.abilityId, ctx.params.stageRangePx); return; } const ids = this._collectSameSideMonsters(ctx.caster, rangePx); if (ids.length <= 0) { console.log('[MonsterSkillHost] 射程内无同侧友军(含自身)', ctx.abilityId, rangePx); return; } const abilityCtx = createAbilityContext(battle.session, ctx.caster, ctx.abilityId, ids); const speedParts = buff1.split(','); const speedHead = Math.floor(Number(speedParts[0]) || 0); const speedDur = Number(speedParts[3]); for (let i = 0; i < ids.length; i++) { const id = ids[i]; SkillAtkBuffExecutor.applyPair(battle.session, abilityCtx, id, buff1, buff2); if (speedHead === BUFF_SPEED && speedDur > 0) { this._playAllySpeedFx(id, speedDur); } } } /** * 扫描射程内同侧友军怪(含自身,太白回血/加速对自己生效)。 * @param caster 施法者 * @param rangePx 射程像素 */ private _collectSameSideMonsters(caster: EntityId, rangePx: number): EntityId[] { this._allyScanIds.length = 0; this._allyScanCaster = caster; this._allyScanRangePx = rangePx; this._battle?.session.world.forEachAlive(this._onAllyScan); if (this._isAllyBuffSelf(caster)) this._allyScanIds.push(caster); return this._allyScanIds; } /** * 施法者是否可作为友军 buff 目标。 * @param caster 施法者 */ private _isAllyBuffSelf(caster: EntityId): boolean { const battle = this._battle; if (!battle) return false; const world = battle.session.world; if (!world.isAlive(caster)) return false; const hp = world.getComponent(caster, HealthComp); if (!hp || !(hp.hp > 0)) return false; if (world.getComponent(caster, MovementComp)?.spawnProtected) return false; const meta = world.getComponent(caster, UnitMetaComp); if (!meta?.unitDefId) return false; const parsed = parseMonsterUnitDefId(meta.unitDefId); const monsterId = parsed?.monsterId ?? parseLegacyMonsterId(meta.unitDefId); return monsterId > 0; } /** 友军扫描回调 */ private readonly _onAllyScan = (id: EntityId): void => { if (!this._isSameSideMonster(this._allyScanCaster, id, this._allyScanRangePx)) return; this._allyScanIds.push(id); }; /** * 在友军身上挂加速特效,持续结束回收。 * @param entity 目标 * @param duration 秒 */ private _playAllySpeedFx(entity: EntityId, duration: number): void { const view = this._monsterView(entity); if (!view?.showAccelerateFx) { console.log('[MonsterSkillHost] 友军加速特效不可用', entity); return; } view.showAccelerateFx(); for (let i = 0; i < this._allySpeeds.length; i++) { if (this._allySpeeds[i].entity === entity) { this._allySpeeds[i].wait = duration; return; } } this._allySpeeds.push({ entity, wait: duration }); } /** * 推进友军加速特效。 * @param dt 战斗缩放秒 */ private _tickAllySpeeds(dt: number): void { if (this._allySpeeds.length <= 0) return; const world = this._battle?.session.world; for (let i = this._allySpeeds.length - 1; i >= 0; i--) { const fx = this._allySpeeds[i]; fx.wait -= dt; const dead = !world || !world.isAlive(fx.entity); if (fx.wait > 0 && !dead) continue; this._allySpeeds.splice(i, 1); this._clearAllySpeedFx(fx.entity); } } /** * 回收单个加速特效。 * @param entity 目标 */ private _clearAllySpeedFx(entity: EntityId): void { this._monsterView(entity)?.clearAccelerateFx?.(); } /** 回收全部友军加速特效 */ private _clearAllAllySpeeds(): void { for (let i = 0; i < this._allySpeeds.length; i++) { this._clearAllySpeedFx(this._allySpeeds[i].entity); } this._allySpeeds.length = 0; } /** * 怪物视图。 * @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; } /** * atk_buff1 类型 12:技能动画结束后在锁定基础字格播损毁文字。 * @param ctx 本次施法 */ private _spawnDestroyIfNeeded(ctx: SkillPresentationContext): void { const raw = String(ctx.params.buff1 ?? ''); const parts = raw.split(','); const head = Math.floor(Number(parts[0]) || 0); if (head !== BUFF_DESTROY) return; if (parts.length < 2 || !Number.isFinite(Number(parts[1]))) { console.log('[MonsterSkillHost] atk_buff1 类型12缺少概率', ctx.abilityId); return; } const wan = Number(parts[1]); const battle = this._battle; if (!battle) { console.log('[MonsterSkillHost] 未绑定,无法损毁', ctx.abilityId); return; } if (wan < 10000 && battle.session.rng.next() * 10000 >= wan) return; const target = ctx.targets[0]; if (target == null) { console.log('[MonsterSkillHost] 类型12无锁定目标', ctx.abilityId); return; } if (getTowerRuinHost().isEntityRuined(target)) return; const team = battle.session.world.getComponent(target, TeamComp); if (!team) { console.log('[MonsterSkillHost] 损毁目标无 TeamComp', target); return; } const playerId = Math.floor(Number(team.ownerId) || 0); const player = battle.getPlayer(playerId); const map = battle.Map; if (!player || !map) { console.log('[MonsterSkillHost] 损毁目标玩家或地图缺失', target, playerId); return; } const gridIndex = findGridIndexForEntity(target, player, battle.session, map); if (gridIndex == null) { console.log('[MonsterSkillHost] 损毁目标无格子', target); return; } if (!this._playDestroyFx(gridIndex, target)) { this._applyDestroy(target); } } /** * 在格子中心出池损毁文字并播默认动画。 * @param gridIndex 目标格 * @param target 锁定实体 * @returns 是否已挂起等待动画 */ private _playDestroyFx(gridIndex: number, target: EntityId): boolean { const flyLayer = SkillPresentationEnv.flyLayer; if (!flyLayer?.isValid) { console.log('[MonsterSkillHost] flyLayer 缺失', DESTROY_PREFAB); return false; } let node: Node | null = null; try { node = gg.res.getNode(GPath.HeroSkillPrefab(), DESTROY_PREFAB, GBundle.BattleHero); } catch (err) { console.log('[MonsterSkillHost] getNode 失败', DESTROY_PREFAB, String(err)); return false; } if (!node?.isValid) { console.log('[MonsterSkillHost] 预制体未加载', DESTROY_PREFAB); return false; } const world = SkillPresentationEnv.getGridWorldCenter(gridIndex); const flyUt = flyLayer.getComponent(UITransform); if (!world || !flyUt) { console.log('[MonsterSkillHost] 损毁文字定位失败', gridIndex); gg.res.putNode(node); return false; } flyLayer.active = true; node.active = true; node.layer = flyLayer.layer; node.parent = flyLayer; node.setSiblingIndex(flyLayer.children.length - 1); const local = flyUt.convertToNodeSpaceAR(world); node.setPosition(local); const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton); if (!sk?.isValid) { console.log('[MonsterSkillHost] 损毁文字缺少 Skeleton', DESTROY_PREFAB); gg.res.putNode(node); return false; } const animName = String((sk as sp.Skeleton & { defaultAnimation?: string }).defaultAnimation ?? '').trim(); if (!animName) { console.log('[MonsterSkillHost] 损毁文字无默认动画', DESTROY_PREFAB); gg.res.putNode(node); return false; } const delay = BattleSpinePlayback.resolveLogicalCompleteDelay(sk, animName, 1); if (delay == null) { console.log('[MonsterSkillHost] 损毁文字无动画', animName, DESTROY_PREFAB); gg.res.putNode(node); return false; } if (!BattleSpinePlayback.play(sk, animName, { loop: false })) { gg.res.putNode(node); return false; } this._destroys.push({ node, target, wait: Math.max(0, delay) }); return true; } /** * 推进损毁文字播放,结束后使目标失效。 * @param dt 战斗缩放秒 */ private _tickDestroys(dt: number): void { if (this._destroys.length <= 0) return; for (let i = this._destroys.length - 1; i >= 0; i--) { const fx = this._destroys[i]; fx.wait -= dt; if (fx.wait > 0 && fx.node.isValid) continue; this._destroys.splice(i, 1); this._finishDestroy(fx); } } /** * 回收特效并使目标失效。 * @param fx 实例 */ private _finishDestroy(fx: DestroyFx): void { this._recycleDestroyNode(fx.node); this._applyDestroy(fx.target); } /** * 基础字失效:禁攻,合成升级前保持损毁。 * @param target 锁定实体 */ private _applyDestroy(target: EntityId): void { const battle = this._battle; if (!battle) return; getTowerRuinHost().apply(battle, target); } /** * 回池损毁文字。 * @param node 特效 */ private _recycleDestroyNode(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); const flyLayer = SkillPresentationEnv.flyLayer; if (flyLayer?.isValid && flyLayer.children.length === 0) flyLayer.active = false; } /** 回收全部损毁文字(不解算单位) */ private _recycleAllDestroys(): void { for (let i = 0; i < this._destroys.length; i++) { this._recycleDestroyNode(this._destroys[i].node); } this._destroys.length = 0; } /** * atk_buff1 类型 6:技能动画结束后降攻速;skill 类型 9(奔波儿灞迷雾)不挂降雨特效。 * @param ctx 本次施法 */ private _spawnRainIfNeeded(ctx: SkillPresentationContext): void { const raw = String(ctx.params.buff1 ?? ''); const parts = raw.split(','); const head = Math.floor(Number(parts[0]) || 0); if (head !== BUFF_RAIN) return; if (parts.length < 2 || !Number.isFinite(Number(parts[1]))) { console.log('[MonsterSkillHost] atk_buff1 类型6缺少概率', ctx.abilityId); return; } const duration = Number(parts[3]); if (!(duration > 0)) { console.log('[MonsterSkillHost] atk_buff1 类型6缺少持续时间', ctx.abilityId); } const battle = this._battle; if (!battle) { console.log('[MonsterSkillHost] 未绑定,无法降雨', ctx.abilityId); return; } const targets = ctx.targets; if (targets.length <= 0) { console.log('[MonsterSkillHost] 类型6无锁定目标', ctx.abilityId); return; } const extraHead = Math.floor(Number(ctx.params.skillType ?? 0) || 0); const playRain = extraHead !== EXTRA_FOG; const abilityCtx = createAbilityContext(battle.session, ctx.caster, ctx.abilityId, targets); const buff2 = String(ctx.params.buff2 ?? ''); for (let i = 0; i < targets.length; i++) { const target = targets[i]; SkillAtkBuffExecutor.applyPair(battle.session, abilityCtx, target, raw, buff2); if (!playRain || !(duration > 0)) continue; if (!this._playRainFx(target, duration)) { console.log('[MonsterSkillHost] 降雨特效失败仍已上 buff', target, ctx.abilityId); } } } /** * 在文字格上方出池降雨并循环播默认动画。 * @param target 锁定实体 * @param duration 配表持续秒 * @returns 是否已挂起 */ private _playRainFx(target: EntityId, duration: number): boolean { const battle = this._battle; if (!battle) return false; const team = battle.session.world.getComponent(target, TeamComp); if (!team) { console.log('[MonsterSkillHost] 降雨目标无 TeamComp', target); return false; } const playerId = Math.floor(Number(team.ownerId) || 0); const player = battle.getPlayer(playerId); const map = battle.Map; if (!player || !map) { console.log('[MonsterSkillHost] 降雨目标玩家或地图缺失', target, playerId); return false; } const gridIndex = findGridIndexForEntity(target, player, battle.session, map); if (gridIndex == null) { console.log('[MonsterSkillHost] 降雨目标无格子', target); return false; } const flyLayer = SkillPresentationEnv.flyLayer; if (!flyLayer?.isValid) { console.log('[MonsterSkillHost] flyLayer 缺失', RAIN_PREFAB); return false; } let node: Node | null = null; try { node = gg.res.getNode(GPath.HeroSkillPrefab(), RAIN_PREFAB, GBundle.BattleHero); } catch (err) { console.log('[MonsterSkillHost] getNode 失败', RAIN_PREFAB, String(err)); return false; } if (!node?.isValid) { console.log('[MonsterSkillHost] 预制体未加载', RAIN_PREFAB); return false; } const world = SkillPresentationEnv.getGridWorldCenter(gridIndex); const flyUt = flyLayer.getComponent(UITransform); if (!world || !flyUt) { console.log('[MonsterSkillHost] 降雨定位失败', gridIndex); gg.res.putNode(node); return false; } flyLayer.active = true; node.active = true; node.layer = flyLayer.layer; node.parent = flyLayer; node.setSiblingIndex(flyLayer.children.length - 1); const local = flyUt.convertToNodeSpaceAR(world); local.y += RAIN_ABOVE_PX; node.setPosition(local); const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton); if (!sk?.isValid) { console.log('[MonsterSkillHost] 降雨缺少 Skeleton', RAIN_PREFAB); gg.res.putNode(node); return false; } const animName = String((sk as sp.Skeleton & { defaultAnimation?: string }).defaultAnimation ?? '').trim(); if (!animName) { console.log('[MonsterSkillHost] 降雨无默认动画', RAIN_PREFAB); gg.res.putNode(node); return false; } if (!BattleSpinePlayback.play(sk, animName, { loop: true })) { gg.res.putNode(node); return false; } this._rains.push({ node, hold: duration }); return true; } /** * 推进降雨保持,结束后回池。 * @param dt 战斗缩放秒 */ private _tickRains(dt: number): void { if (this._rains.length <= 0) return; for (let i = this._rains.length - 1; i >= 0; i--) { const fx = this._rains[i]; if (!fx.node.isValid) { this._rains.splice(i, 1); continue; } fx.hold -= dt; if (fx.hold > 0) continue; this._rains.splice(i, 1); this._recycleRain(fx); } } /** * 回池单团降雨。 * @param fx 实例 */ private _recycleRain(fx: RainFx): void { const node = fx.node; if (!node?.isValid) return; const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton); BattleSpinePlayback.clearPendingComplete(sk); BattleSpinePlayback.resetTracks(sk); gg.res.putNode(node); const flyLayer = SkillPresentationEnv.flyLayer; if (flyLayer?.isValid && flyLayer.children.length === 0) flyLayer.active = false; } /** 回收全部降雨 */ private _recycleAllRains(): void { for (let i = 0; i < this._rains.length; i++) { this._recycleRain(this._rains[i]); } this._rains.length = 0; } /** * skill 类型 5:施法同时锁定本方周围小兵并吸入。 * @param ctx 本次施法 * @param duration 技能动画秒 */ private _beginDevour(ctx: SkillPresentationContext, duration: number): void { const spec = this._readDevourSpec(ctx); if (!spec) return; const battle = this._battle; if (!battle) { console.log('[MonsterSkillHost] 未绑定,无法吞噬', ctx.abilityId); return; } this._abortDevour(ctx.caster); const ids = this._pickDevourIds(ctx, spec.maxN); const victims: DevourVictim[] = []; for (let i = 0; i < ids.length; i++) { this._lockDevourVictim(ids[i], victims); } if (victims.length <= 0) { console.log('[MonsterSkillHost] 类型5无周围小兵', ctx.abilityId, ctx.targets.length); return; } this._devours.push({ caster: ctx.caster, victims, elapsed: 0, duration: Math.max(0, duration), bonusWan: spec.bonusWan, }); } /** * 读 skill 类型 5 参数。 * @param ctx 本次施法 */ private _readDevourSpec(ctx: SkillPresentationContext): { maxN: number; bonusWan: number } | null { const parts = this._devourParts(ctx); if ((parts[0] ?? 0) !== EXTRA_DEVOUR) return null; const maxN = Math.floor(Number(parts[1]) || 0); if (!(maxN > 0)) { console.log('[MonsterSkillHost] skill 类型5缺少最大数量', ctx.abilityId, parts); return null; } const bonusWan = Number(parts[2]); if (!Number.isFinite(bonusWan)) { console.log('[MonsterSkillHost] skill 类型5缺少每个额外加成', ctx.abilityId, parts); return { maxN, bonusWan: 0 }; } return { maxN, bonusWan }; } /** * extraSkill / skillParts / skillRaw 解析类型 5。 * @param ctx 本次施法 */ private _devourParts(ctx: SkillPresentationContext): number[] { const extra = String(ctx.params.extraSkill ?? '').trim(); if (extra) { const fromExtra = extra.split(',').map(Number); if ((fromExtra[0] ?? 0) === EXTRA_DEVOUR) return fromExtra; } const listed = ctx.params.skillParts; if (Array.isArray(listed) && listed.length > 0) { const fromList: number[] = []; for (let i = 0; i < listed.length; i++) { fromList.push(Number(listed[i])); } if ((fromList[0] ?? 0) === EXTRA_DEVOUR) return fromList; } const raw = String(ctx.params.skillRaw ?? '').trim(); if (!raw) return []; return raw.split(',').map(Number); } /** * 周围同侧小兵:先扫场,再补 Ability 已锁目标。 * @param ctx 本次施法 * @param maxN 最大数量 */ private _pickDevourIds(ctx: SkillPresentationContext, maxN: number): EntityId[] { const rangePx = Number(ctx.params.stageRangePx); if (!(rangePx > 0)) { console.log('[MonsterSkillHost] 类型5缺少射程', ctx.abilityId, ctx.params.stageRangePx); } this._devourScanIds.length = 0; this._devourScanCaster = ctx.caster; this._devourScanRangePx = rangePx > 0 ? rangePx : 0; const battle = this._battle; if (battle && this._devourScanRangePx > 0) { battle.session.world.forEachAlive(this._onDevourScan); } this._sortDevourScan(ctx.caster); const out: EntityId[] = []; for (let i = 0; i < this._devourScanIds.length && out.length < maxN; i++) { out.push(this._devourScanIds[i]); } const locked = ctx.targets; for (let i = 0; i < locked.length && out.length < maxN; i++) { const id = locked[i]; if (id === ctx.caster) continue; if (this._idInList(out, id)) continue; if (!this._isDevourCandidate(ctx.caster, id, 0)) continue; out.push(id); } return out; } /** 吞噬扫描:同侧射程内小兵 */ private readonly _onDevourScan = (id: EntityId): void => { if (!this._isDevourCandidate(this._devourScanCaster, id, this._devourScanRangePx)) return; this._devourScanIds.push(id); }; /** * 是否可吞:存活、同侧、小兵、射程内。 * @param caster 施法者 * @param id 候选 * @param rangePx 射程;≤0 不限距离 */ private _isDevourCandidate(caster: EntityId, id: EntityId, rangePx: number): boolean { if (!this._isSameSideMonster(caster, id, rangePx)) return false; const battle = this._battle; if (!battle) return false; const meta = battle.session.world.getComponent(id, UnitMetaComp); if (!meta?.unitDefId) return false; const parsed = parseMonsterUnitDefId(meta.unitDefId); const monsterId = parsed?.monsterId ?? parseLegacyMonsterId(meta.unitDefId); return MonsterDataManager.isDevourMinion(monsterId); } /** * 存活同侧友军怪且在射程内(不含自身)。 * @param caster 施法者 * @param id 候选 * @param rangePx 射程;≤0 不限距离 */ private _isSameSideMonster(caster: EntityId, id: EntityId, rangePx: number): boolean { if (id === caster) return false; const battle = this._battle; if (!battle) return false; const world = battle.session.world; if (!world.isAlive(id)) return false; const hp = world.getComponent(id, HealthComp); if (!hp || !(hp.hp > 0)) return false; if (world.getComponent(id, MovementComp)?.spawnProtected) return false; const casterTeam = world.getComponent(caster, TeamComp); const team = world.getComponent(id, TeamComp); if (!BattleTeamId.isSameSide(casterTeam, team)) 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; if (!(rangePx > 0)) return true; return this._devourDistPx(caster, id) <= rangePx; } /** * 施法者与目标格距像素。 * @param caster 施法者 * @param id 目标 */ private _devourDistPx(caster: EntityId, id: EntityId): number { const battle = this._battle; if (!battle) return Number.POSITIVE_INFINITY; const world = battle.session.world; const ma = world.getComponent(caster, MovementComp); const mb = world.getComponent(id, MovementComp); if (ma && mb) return Math.hypot(ma.worldX - mb.worldX, ma.worldY - mb.worldY); const a = world.getComponent(caster, TransformComp); const b = world.getComponent(id, TransformComp); if (!a || !b) return Number.POSITIVE_INFINITY; return Math.hypot(a.row - b.row, a.col - b.col) * BattleMapSpec.CELL_SIZE; } /** * 按距离插入排序。 * @param caster 施法者 */ private _sortDevourScan(caster: EntityId): void { const list = this._devourScanIds; for (let i = 1; i < list.length; i++) { const cur = list[i]; const curD = this._devourDistPx(caster, cur); let j = i; while (j > 0 && this._devourDistPx(caster, list[j - 1]) > curD) { list[j] = list[j - 1]; j -= 1; } list[j] = cur; } } /** * 列表是否已含 id。 * @param list 列表 * @param id 实体 */ private _idInList(list: EntityId[], id: EntityId): boolean { for (let i = 0; i < list.length; i++) { if (list[i] === id) return true; } return false; } /** * 锁移动并占用节点;无节点仍吞噬。 * @param id 小兵 * @param out 输出 * @returns 是否锁定成功 */ private _lockDevourVictim(id: EntityId, out: DevourVictim[]): boolean { const battle = this._battle; if (!battle || !battle.session.world.isAlive(id)) return false; const move = battle.session.world.getComponent(id, MovementComp); if (move) move.presentLocked = true; SkillPresentationEnv.holdMonsterVisual(id); const node = SkillPresentationEnv.getMonsterNode(id); let fromX = move?.worldX ?? 0; let fromY = move?.worldY ?? 0; let scaleX = 1; let scaleY = 1; if (node?.isValid) { fromX = node.worldPosition.x; fromY = node.worldPosition.y; scaleX = node.scale.x; scaleY = node.scale.y; } else { const grid = SkillPresentationEnv.getTargetGridWorldCenter(id); if (grid) { fromX = grid.x; fromY = grid.y; } else { console.log('[MonsterSkillHost] 吞噬目标无节点,仍会移除', id); } } out.push({ entity: id, fromX, fromY, scaleX, scaleY }); return true; } /** * 吸入靠近施法者。 * @param dt 战斗缩放秒 */ private _tickDevours(dt: number): void { if (this._devours.length <= 0) return; for (let i = 0; i < this._devours.length; i++) { const run = this._devours[i]; run.elapsed += dt; const dur = run.duration > 0 ? run.duration : 1; const u = Math.min(1, run.elapsed / dur); const t = u * u; const dest = SkillPresentationEnv.getMonsterNode(run.caster); const dx = dest?.isValid ? dest.worldPosition.x : 0; const dy = dest?.isValid ? dest.worldPosition.y : 0; const hasDest = dest?.isValid === true; for (let v = 0; v < run.victims.length; v++) { this._pullDevourVictim(run.victims[v], t, hasDest, dx, dy); } } } /** * 单只小兵插值到施法者。 * @param victim 被吸 * @param t 0~1 缓入 * @param hasDest 施法者节点有效 * @param dx 目标 X * @param dy 目标 Y */ private _pullDevourVictim(victim: DevourVictim, t: number, hasDest: boolean, dx: number, dy: number): void { const node = SkillPresentationEnv.getMonsterNode(victim.entity); if (!node?.isValid) return; const shrink = 1 - 0.8 * t; node.setScale(victim.scaleX * shrink, victim.scaleY * shrink, 1); if (!hasDest) return; this._fogWorld.set( victim.fromX + (dx - victim.fromX) * t, victim.fromY + (dy - victim.fromY) * t, node.worldPosition.z, ); node.setWorldPosition(this._fogWorld); } /** * 吞噬并按只数加血上限。 * @param caster 施法者 */ private _consumeDevour(caster: EntityId): void { const idx = this._indexDevour(caster); if (idx < 0) return; const run = this._devours[idx]; this._devours.splice(idx, 1); const battle = this._battle; if (!battle) { this._releaseDevourVictims(run, false); return; } let eaten = 0; for (let i = 0; i < run.victims.length; i++) { const id = run.victims[i].entity; SkillPresentationEnv.releaseMonsterVisual(id); if (!battle.session.world.isAlive(id)) continue; battle.session.lifecycle.despawn(id); eaten += 1; } if (eaten <= 0) return; if (run.bonusWan === 0) return; SkillAtkBuffExecutor.applyMaxHpCoeff( battle.session.world, caster, eaten * run.bonusWan, battle, ); } /** * 中断吞噬,小兵恢复走路。 * @param caster 施法者 */ private _abortDevour(caster: EntityId): void { const idx = this._indexDevour(caster); if (idx < 0) return; const run = this._devours[idx]; this._devours.splice(idx, 1); this._releaseDevourVictims(run, true); } /** 中断全部吞噬 */ private _abortAllDevours(): void { for (let i = this._devours.length - 1; i >= 0; i--) { this._releaseDevourVictims(this._devours[i], true); } this._devours.length = 0; } /** * 查找施法者吞噬。 * @param caster 施法者 */ private _indexDevour(caster: EntityId): number { for (let i = 0; i < this._devours.length; i++) { if (this._devours[i].caster === caster) return i; } return -1; } /** * 解除占用;restore 时还原缩放并解锁移动。 * @param run 本次吞噬 * @param restore 是否放回小兵 */ private _releaseDevourVictims(run: DevourRun, restore: boolean): void { const battle = this._battle; for (let i = 0; i < run.victims.length; i++) { const victim = run.victims[i]; const node = SkillPresentationEnv.getMonsterNode(victim.entity); if (restore && node?.isValid) { node.setScale(victim.scaleX, victim.scaleY, 1); node.setWorldPosition(victim.fromX, victim.fromY, node.worldPosition.z); } SkillPresentationEnv.releaseMonsterVisual(victim.entity); const move = battle?.session.world.getComponent(victim.entity, MovementComp); if (move) move.presentLocked = false; } } }