import type { EntityId } from 'db://assets/mx/module/ecs'; import { BattlePhase, BattleTeamId, BattleEventFactory, HealthComp, MovementComp, TeamComp, TransformComp, UnitMetaComp, isBajiaoBlowRetreating, isYanSoulClaimed, type BattleSession, type Cell, } from 'db://assets/mx/module/battle'; import { BattleBuffCarryStore } from '../../manager/battleBuff/BattleBuffCarryStore'; import { MonsterType } from '../../manager/MonsterDataManager'; import { resolveCarriedBaseThunderConfig, type BattleBuffBaseThunderAdjust, } from '../../manager/battleBuff/BattleBuffPassiveSkillUtil'; import { schedulePresentation } from '../skill/BattlePresentationClock'; import { isMechanismUnit } from '../mechanism/MechanismServices'; import type { ProjectBattleHandle } from '../runtime/BattleRuntime'; import type { BattleGameBaseThunderPresenter } from '../../ui/BattleGame/BattleGameBaseThunderPresenter'; import { AI_PLAYER_ID } from '../ai/runtime/AiCampArea'; /** 御雷珠伤害类型 */ const BASE_THUNDER_DAMAGE_TYPE = 'base_thunder'; /** 无施法者占位 */ const BASE_THUNDER_CASTER = 0 as EntityId; /** 飘字展示后再击杀(逻辑秒) */ const BASE_THUNDER_KILL_DELAY_SEC = 0.4; /** 与 Pathfinder 正交一步代价一致,用于距离场→格数 */ const PATH_ORTH_STEP = 1000; /** 沿进路排序的目标条目 */ type ThunderTargetEntry = { /** 受击实体 */ entityId: EntityId; /** 在生效格序列中的索引(越小越靠前) */ cellOrder: number; }; /** 单玩家御雷珠状态 */ type PlayerThunderState = { /** 防守方 playerId */ playerId: number; /** 汇总配置 */ config: BattleBuffBaseThunderAdjust; /** 本局已触发次数 */ usedTriggers: number; }; /** * 被动 functiontype=20:进攻各方军营的敌军接近终点时降雷。 * 本机/AI 各读 CarryStore,次数独立。 */ export class BattleBuffBaseThunderHost { private _battle: ProjectBattleHandle | null = null; private _presenter: BattleGameBaseThunderPresenter | null = null; private readonly _states: PlayerThunderState[] = []; /** 当前一轮落雷进行中 */ private _striking = false; /** 延迟击杀调度世代(unbind 时递增以作废) */ private _killScheduleGen = 0; /** 绑定本局与表现层 */ bind(battle: ProjectBattleHandle, presenter: BattleGameBaseThunderPresenter | null): void { const sameBattle = this._battle === battle; if (!sameBattle) { this.unbind(); } this._battle = battle; this._presenter = presenter; this._rebuildStates(sameBattle); } /** * AI 挂载后按 CarryStore 重扫(保留本局已触发次数)。 * @param battle 可选;开战时 Host 可能尚未 bind,传入则一并绑定 */ refreshCarry(battle?: ProjectBattleHandle): void { const b = battle ?? this._battle; if (!b) { console.log('[BaseThunder] refreshCarry 时尚未 bind'); return; } this._battle = b; this._rebuildStates(true); } /** * 该玩家御雷珠单局次数是否已用尽。 * @param playerId 防守方 */ isTriggersExhausted(playerId: number): boolean { for (let i = 0; i < this._states.length; i++) { const state = this._states[i]; if (state.playerId !== playerId) continue; return state.usedTriggers >= state.config.maxTriggers; } return false; } /** 解除绑定 */ unbind(): void { this._battle = null; this._presenter = null; this._states.length = 0; this._striking = false; this._killScheduleGen++; } /** * 重建各方神雷状态。 * @param keepUsed 是否保留已触发次数 */ private _rebuildStates(keepUsed = false): void { const prev: PlayerThunderState[] = []; if (keepUsed) { for (let i = 0; i < this._states.length; i++) { prev.push(this._states[i]); } } this._states.length = 0; this._tryPush(0, prev); this._tryPush(AI_PLAYER_ID, prev); } /** * 若该玩家携带有效御雷珠则记入。 * @param playerId 槽位 * @param prev 重建前状态(用于保留次数) */ private _tryPush(playerId: number, prev: readonly PlayerThunderState[] = []): void { const passives = BattleBuffCarryStore.getPassives(playerId); const adjust = resolveCarriedBaseThunderConfig(passives); if (!adjust.active || adjust.maxTriggers <= 0 || adjust.triggerCells <= 0 || adjust.effectCells <= 0) { if (adjust.active) { console.log('[BaseThunder] 御雷珠配置无效,未启用', playerId, adjust); } return; } if (adjust.smallDmgWan <= 0 || adjust.bossDmgWan <= 0) { console.log('[BaseThunder] 御雷珠伤害比例无效', playerId, adjust); return; } let usedTriggers = 0; for (let i = 0; i < prev.length; i++) { if (prev[i].playerId === playerId) { usedTriggers = prev[i].usedTriggers; break; } } this._states.push({ playerId, config: adjust, usedTriggers }); } /** 帧更新:扫描触发条件 */ tick(_dt: number): void { if (this._striking || !this._battle || this._states.length <= 0) return; if (this._battle.session.phase !== BattlePhase.COMBAT) return; for (let i = 0; i < this._states.length; i++) { const state = this._states[i]; if (state.usedTriggers >= state.config.maxTriggers) continue; const anchor = this._findTriggerAnchor(state.playerId, state.config.triggerCells); if (anchor == null) continue; const effectCells = this._collectEffectCells(anchor, state.config.effectCells); if (effectCells.length <= 0) continue; const targets = this._collectTargets(state.playerId, effectCells); if (targets.length <= 0) continue; state.usedTriggers++; this._striking = true; const config = state.config; const presenter = this._presenter; if (presenter) { presenter.playStrike( targets, (entityId) => this._applyDamage(state.playerId, config, entityId), () => { this._striking = false; }, ); return; } for (let t = 0; t < targets.length; t++) { this._applyDamage(state.playerId, config, targets[t]); } this._striking = false; return; } } /** * 找最靠近终点、且处于触发距离内的进攻指定玩家的小怪/Boss/召唤怪。 * @param ownerPlayerId 防守方 * @param triggerCells 触发距离 */ private _findTriggerAnchor(ownerPlayerId: number, triggerCells: number): EntityId | null { const battle = this._battle; if (!battle) return null; const session = battle.session; const thresholdCells = Math.max(1, Math.floor(triggerCells)); let bestId: EntityId | null = null; let bestRemaining = Number.POSITIVE_INFINITY; session.world.forEachAlive((entityId) => { if (!this._isThunderCandidate(battle, ownerPlayerId, entityId)) return; const move = session.world.getComponent(entityId, MovementComp); if (!move || move.spawnProtected || move.presentLocked || isYanSoulClaimed(move) || isBajiaoBlowRetreating(move)) return; const remainingCells = this._getRemainingCellsToAttackTarget(session, entityId); if (!Number.isFinite(remainingCells) || remainingCells > thresholdCells) return; if (remainingCells < bestRemaining) { bestRemaining = remainingCells; bestId = entityId; } }); return bestId; } /** * 实体沿进路至攻击终点的剩余格数。 * 取「未走完路径步数」与「距离场格数」的较小值,避免 path 过长或重寻路导致漏触发。 */ private _getRemainingCellsToAttackTarget(session: BattleSession, entityId: EntityId): number { const world = session.world; const move = world.getComponent(entityId, MovementComp); const tf = world.getComponent(entityId, TransformComp); if (!move || !tf) return Number.POSITIVE_INFINITY; if (move.reachedGoal) return 0; let best = Number.POSITIVE_INFINITY; const pathSteps = move.path.length - move.pathIndex; if (pathSteps > 0) best = pathSteps; const campAreaId = this._resolveEntityCampAreaId(session, entityId); const cost = session.pathfinder.getPathCost({ row: tf.row, col: tf.col }, campAreaId); if (Number.isFinite(cost) && cost >= 0) { const costCells = Math.max(0, Math.ceil(cost / PATH_ORTH_STEP)); if (costCells < best) best = costCells; } if (Number.isFinite(best)) return best; const forward = this._buildPathToAttackTarget(session, entityId); if (forward.length <= 0) return 0; return forward.length; } /** 从实体当前格起,沿进路至攻击目标的路径格(不含起点) */ private _buildPathToAttackTarget(session: BattleSession, entityId: EntityId): Cell[] { const world = session.world; const tf = world.getComponent(entityId, TransformComp); if (!tf) return []; const move = world.getComponent(entityId, MovementComp); const out: Cell[] = []; if (move && move.pathIndex < move.path.length) { for (let i = move.pathIndex; i < move.path.length; i++) { const c = move.path[i]; const last = out.length > 0 ? out[out.length - 1] : tf; if (last.row === c.row && last.col === c.col) continue; out.push({ row: c.row, col: c.col }); } } if (out.length > 0) return out; const campAreaId = this._resolveEntityCampAreaId(session, entityId); const from = { row: tf.row, col: tf.col }; const target = session.pathfinder.shortestTargetCell(from, campAreaId); if (!target) return []; const fullPath = session.pathfinder.buildPathToTarget(from, target, campAreaId); if (fullPath.length <= 1) return []; for (let i = 1; i < fullPath.length; i++) { const c = fullPath[i]; const last = out.length > 0 ? out[out.length - 1] : from; if (last.row === c.row && last.col === c.col) continue; out.push({ row: c.row, col: c.col }); } return out; } /** 实体出怪口所属阵营区域(与 MovementSystem.assignPath 一致) */ private _resolveEntityCampAreaId(session: BattleSession, entityId: EntityId): number | undefined { const meta = session.world.getComponent(entityId, UnitMetaComp); const port = meta?.spawnPoint ?? 0; if (port < 1) { console.log('[BaseThunder] 实体缺少有效 spawnPoint', entityId); return undefined; } const area = session.topology.getSpawnPortCampArea(port); if (area === undefined) { console.log('[BaseThunder] 出怪口缺少阵营区域', port, entityId); } return area; } /** 实体出怪口格(与 MovementSystem 一致) */ private _resolveEntitySpawnCell(session: BattleSession, entityId: EntityId): Cell | null { const meta = session.world.getComponent(entityId, UnitMetaComp); const port = meta?.spawnPoint ?? 0; if (port < 1) { console.log('[BaseThunder] 实体缺少有效 spawnPoint', entityId); return null; } const spawn = session.topology.getSpawnCell(port); if (!spawn) { console.log('[BaseThunder] 出怪口格缺失', port, entityId); } return spawn ?? null; } /** * 从锚点格起沿进路向出怪口后退收集生效格(含锚点格,共 effectCells 格)。 * @param anchorId 锚点 * @param effectCells 生效格数 */ private _collectEffectCells(anchorId: EntityId, effectCells: number): Cell[] { const battle = this._battle; const count = Math.max(1, Math.floor(effectCells)); if (!battle) return []; const session = battle.session; const world = session.world; const tf = world.getComponent(anchorId, TransformComp); if (!tf) return []; const from = { row: tf.row, col: tf.col }; const meta = world.getComponent(anchorId, UnitMetaComp); const port = meta?.spawnPoint ?? 0; const spawn = this._resolveEntitySpawnCell(session, anchorId); if (!spawn || port < 1) return [{ ...from }]; const retreatSteps = count - 1; const path = session.pathfinder.buildRetreatPath(from, retreatSteps, spawn, port); if (path.length <= 0) return [{ ...from }]; if (path.length <= count) return path; return path.slice(0, count); } /** * 收集生效格上的小怪/Boss;排序为锚点格(近终点)优先,再沿后退方向。 * @param ownerPlayerId 防守方 * @param effectCells 生效格 */ private _collectTargets(ownerPlayerId: number, effectCells: Cell[]): EntityId[] { const battle = this._battle; if (!battle) return []; const session = battle.session; const entries: ThunderTargetEntry[] = []; session.world.forEachAlive((entityId) => { if (!this._isThunderCandidate(battle, ownerPlayerId, entityId)) return; const tf = session.world.getComponent(entityId, TransformComp); const move = session.world.getComponent(entityId, MovementComp); let cellOrder = -1; if (move) { const cell = session.grid.worldToCell(move.worldX, move.worldY); cellOrder = this._indexOfCell(effectCells, cell.row, cell.col); } if (cellOrder < 0 && tf) { cellOrder = this._indexOfCell(effectCells, tf.row, tf.col); } if (cellOrder < 0) return; entries.push({ entityId, cellOrder }); }); entries.sort((a, b) => { if (a.cellOrder !== b.cellOrder) return a.cellOrder - b.cellOrder; return a.entityId - b.entityId; }); const out: EntityId[] = []; for (let i = 0; i < entries.length; i++) { out.push(entries[i].entityId); } return out; } /** 格在序列中的索引;-1 表示不在序列内 */ private _indexOfCell(cells: Cell[], row: number, col: number): number { for (let i = 0; i < cells.length; i++) { if (cells[i].row === row && cells[i].col === col) return i; } return -1; } /** * 是否可被御雷珠选中的进攻指定玩家的小怪/Boss/召唤怪。 * @param battle 战斗 * @param ownerPlayerId 防守方 * @param entityId 实体 */ private _isThunderCandidate(battle: ProjectBattleHandle, ownerPlayerId: number, entityId: EntityId): boolean { const session = battle.session; const world = session.world; if (!world.isAlive(entityId)) return false; if (isMechanismUnit(session, entityId)) return false; const team = world.getComponent(entityId, TeamComp); if (!team || !BattleTeamId.isEnemyTeam(team.team)) return false; if (Math.max(0, Math.floor(Number(team.ownerId) || 0)) !== ownerPlayerId) return false; const row = battle.resolveMonsterRow(entityId); if (!row) return false; const type = Math.floor(Number(row.type)); return type === MonsterType.SmallMonster || type === MonsterType.Boss || type === MonsterType.SummonMonster; } /** * 对单目标结算伤害(走正常掉血/击杀流程)。 * @param ownerPlayerId 防守方 * @param config 配置 * @param entityId 目标 */ private _applyDamage(ownerPlayerId: number, config: BattleBuffBaseThunderAdjust, entityId: EntityId): void { const battle = this._battle; if (!battle || !config) return; const session = battle.session; const world = session.world; if (!world.isAlive(entityId)) return; if (!this._isThunderCandidate(battle, ownerPlayerId, entityId)) return; const hp = world.getComponent(entityId, HealthComp); if (!hp || hp.maxHp <= 0) return; const move = world.getComponent(entityId, MovementComp); if (move?.spawnProtected || move?.presentLocked || isYanSoulClaimed(move) || isBajiaoBlowRetreating(move)) return; const monsterRow = battle.resolveMonsterRow(entityId); const isBoss = monsterRow != null && Math.floor(Number(monsterRow.type)) === MonsterType.Boss; const wan = isBoss ? config.bossDmgWan : config.smallDmgWan; const amount = Math.max(1, Math.floor(hp.maxHp * wan / 10000)); const hpBefore = hp.hp; session.damage.apply(BASE_THUNDER_CASTER, entityId, amount, BASE_THUNDER_DAMAGE_TYPE, undefined, false); session.events.flush(); session.events.emit( BattleEventFactory.hitAfter(BASE_THUNDER_CASTER, entityId, amount, false, undefined, BASE_THUNDER_DAMAGE_TYPE), true, ); if (hp.hp <= 0) { this._scheduleKill(entityId, hpBefore); } } /** 飘字后再击杀,避免产桃/死亡特效抢在伤害数字前 */ private _scheduleKill(entityId: EntityId, hpBefore: number): void { const battle = this._battle; if (!battle) return; const scheduleGen = this._killScheduleGen; schedulePresentation(BASE_THUNDER_KILL_DELAY_SEC, () => { if (scheduleGen !== this._killScheduleGen || !this._battle) return; const session = this._battle.session; const world = session.world; if (!world.isAlive(entityId)) return; const hpNow = world.getComponent(entityId, HealthComp); if (!hpNow || hpNow.hp > 0) return; session.lifecycle.kill(entityId, BASE_THUNDER_CASTER, hpBefore); }); } } let _host: BattleBuffBaseThunderHost | null = null; /** 全局御雷珠被动 Host(单局复用) */ export function getBattleBuffBaseThunderHost(): BattleBuffBaseThunderHost { if (!_host) _host = new BattleBuffBaseThunderHost(); return _host; }