import { Node, sp } from 'cc'; import type { EntityId } from 'db://assets/mx/module/ecs'; import { BattleTeamId, MovementComp, TeamComp, TransformComp, UnitMetaComp, isBajiaoBlowRetreating, isYanSoulClaimed, type BattleSession, type Cell, } from 'db://assets/mx/module/battle'; import { GBundle } from '../../game/ConfigRes'; import { BATTLE_HERO_BUFF_PATH } from '../../battle/res/BattleHeroResCollector'; import { isMechanismUnit } from '../../battle/mechanism/MechanismServices'; import { MonsterType } from '../../manager/MonsterDataManager'; import type { ProjectBattleHandle } from '../../battle/runtime/BattleRuntime'; import type { MapManager } from '../../battle/map/legacy/MapManager'; import type { MapGrid } from '../../battle/map/legacy/MapGrid'; import { resolveEnemyCampAreaIdForDefender, resolveEnemySpawnPortForDefender, resolveSpawnGridByPort } from '../../battle/map/MapPathQueries'; import { resolveAttackStandGridForCamp } from '../../battle/view/tangSengMap'; import { BattleAudio } from '../../battle/audio/BattleAudio'; import { BattleSpinePlayback } from '../../battle/presentation/BattleSpinePlayback'; import { schedulePresentation } from '../../battle/skill/BattlePresentationClock'; /** 怪物危机提示预制体(battleHero/prefab/buff/) */ const CRISIS_HINT_PREFAB_NAME = '怪物危机提示'; /** 危机提示 Spine 循环动画 */ const CRISIS_HINT_ANIM = 'animation'; /** 终点前监测格数 */ const WATCH_CELLS_BEFORE_GOAL = 3; /** 本机玩家 id */ const LOCAL_PLAYER_ID = 0; /** AI 玩家 id */ const AI_PLAYER_ID = 1; /** 单侧危机提示运行时 */ type CrisisHintSide = { /** 被进攻防守方 playerId */ defenderPlayerId: number; /** 进路(含终点) */ routePath: Cell[]; /** 终点前一格 */ anchorGrid: MapGrid; /** 终点前 WATCH 格(最多 3 格) */ watchCells: Cell[]; /** 表现节点 */ fxNode: Node | null; /** Spine 组件 */ skeleton: sp.Skeleton | null; /** 循环音效调度世代(递增取消) */ loopSoundGen: number; }; /** * 怪物危机提示:终点前一格挂载预制体,终点前 3 格有进攻怪则显示,否则隐藏。 * 我方提示在敌方终点前一格;敌方提示在我方终点前一格。 */ export class BattleGameMonsterCrisisHintPresenter { private _battle: ProjectBattleHandle | null = null; private _map: MapManager | null = null; private readonly _sides: CrisisHintSide[] = []; /** 全场扫格降频 */ private _scanAcc = 0; /** 绑定地图并在双方终点前一格挂载提示(初始隐藏) */ bind(battle: ProjectBattleHandle, map: MapManager): void { this.unbind(); if (!battle || !map?.gridsInitialized) { console.log('[MonsterCrisisHint] 地图未就绪,跳过绑定'); return; } this._battle = battle; this._map = map; const session = battle.session; session.pathfinder.rebuild(session.topology); this._trySetupSide(session, map, AI_PLAYER_ID); this._trySetupSide(session, map, LOCAL_PLAYER_ID); if (this._sides.length <= 0) { console.log('[MonsterCrisisHint] 无有效进路,跳过'); this.unbind(); } } /** 帧刷新可见性(战斗进行中) */ tick(): void { const battle = this._battle; if (!battle || battle.session.ended || this._sides.length <= 0) return; this._scanAcc++; if (this._scanAcc % 8 !== 1) return; for (let i = 0; i < this._sides.length; i++) { this._refreshSideVisible(battle, this._sides[i]); } } /** 解除绑定并回收节点 */ unbind(): void { for (let i = 0; i < this._sides.length; i++) { this._releaseSideFx(this._sides[i]); } this._sides.length = 0; this._scanAcc = 0; this._battle = null; this._map = null; } /** 初始化单侧:我方→敌方终点前一格;敌方→我方终点前一格 */ private _trySetupSide(session: BattleSession, map: MapManager, defenderPlayerId: number): void { const enemyCamp = resolveEnemyCampAreaIdForDefender(session, defenderPlayerId); if (enemyCamp == null) { console.log('[MonsterCrisisHint] 进攻阵营缺失', defenderPlayerId); return; } const stand = resolveAttackStandGridForCamp(map, session, enemyCamp); if (!stand?.node?.isValid) { console.log('[MonsterCrisisHint] 攻击终点缺失', defenderPlayerId); return; } const port = resolveEnemySpawnPortForDefender(defenderPlayerId); const spawnGrid = resolveSpawnGridByPort(map, session, port); if (!spawnGrid) { console.log('[MonsterCrisisHint] 出怪口格缺失', port, defenderPlayerId); return; } const routePath = this._buildMarchRoute( session, { row: spawnGrid.Row, col: spawnGrid.Col }, { row: stand.Row, col: stand.Col }, enemyCamp, ); if (routePath.length < 2) { console.log('[MonsterCrisisHint] 进路不足', defenderPlayerId, routePath.length); return; } const anchorCell = routePath[routePath.length - 2]; const anchorGrid = map.getMapGrid(anchorCell.row, anchorCell.col); if (!anchorGrid?.node?.isValid) { console.log('[MonsterCrisisHint] 锚点格缺失', defenderPlayerId, anchorCell); return; } const watchCells = this._collectWatchCellsBeforeGoal(routePath); if (watchCells.length <= 0) { console.log('[MonsterCrisisHint] 监测格为空', defenderPlayerId); return; } const fxNode = this._spawnHintNode(anchorGrid); if (!fxNode) return; this._sides.push({ defenderPlayerId, routePath, anchorGrid, watchCells, fxNode, skeleton: null, loopSoundGen: 0, }); } /** 出怪口 → 攻击终点完整进路 */ private _buildMarchRoute(session: BattleSession, spawn: Cell, goal: Cell, campAreaId: number): Cell[] { let path = session.pathfinder.buildPathToTarget(spawn, goal, campAreaId); if (path.length < 2) { path = session.pathfinder.findPath(spawn, goal, true, campAreaId); } return path; } /** 终点前最多 3 格(不含终点) */ private _collectWatchCellsBeforeGoal(path: readonly Cell[]): Cell[] { const out: Cell[] = []; const endIdx = path.length - 1; for (let i = 1; i <= WATCH_CELLS_BEFORE_GOAL && endIdx - i >= 0; i++) { out.push(path[endIdx - i]); } return out; } /** 在锚点格挂载危机提示 */ private _spawnHintNode(anchorGrid: MapGrid): Node | null { if (!anchorGrid?.node?.isValid) return null; let node: Node | null = null; try { node = gg.res.getNode(BATTLE_HERO_BUFF_PATH, CRISIS_HINT_PREFAB_NAME, GBundle.BattleHero); } catch (err) { console.log('[MonsterCrisisHint] 预制体加载失败', String(err)); return null; } if (!node?.isValid) { console.log('[MonsterCrisisHint] 预制体缺失', CRISIS_HINT_PREFAB_NAME); return null; } node.parent = anchorGrid.node; node.setPosition(0, 0, 0); node.active = false; return node; } /** 按终点前 3 格是否有进攻怪刷新显隐 */ private _refreshSideVisible(battle: ProjectBattleHandle, side: CrisisHintSide): void { const fxNode = side.fxNode; if (!fxNode?.isValid) return; const shouldShow = this._hasMonsterOnWatchCells(battle, side); const wasShowing = fxNode.active; if (shouldShow === wasShowing) return; if (shouldShow) { fxNode.active = true; this._startSidePresentation(side); return; } this._stopSidePresentation(side); fxNode.active = false; } /** 显示时:循环播动画,并按每轮时长播危险警报 */ private _startSidePresentation(side: CrisisHintSide): void { const fxNode = side.fxNode; if (!fxNode?.isValid) return; const skel = fxNode.getComponent(sp.Skeleton) ?? fxNode.getComponentInChildren(sp.Skeleton); if (!skel?.isValid) { console.log('[MonsterCrisisHint] Spine 缺失'); return; } side.skeleton = skel; BattleSpinePlayback.resetTracks(skel); BattleSpinePlayback.play(skel, CRISIS_HINT_ANIM, { loop: true, optional: true }); side.loopSoundGen++; this._scheduleLoopAlert(side, side.loopSoundGen); } /** 隐藏时:停止循环音效调度 */ private _stopSidePresentation(side: CrisisHintSide): void { side.loopSoundGen++; const skel = side.skeleton; if (skel?.isValid) BattleSpinePlayback.clearPendingComplete(skel); side.skeleton = null; } /** 每播完一轮 animation 播一次危险警报(随战斗时钟) */ private _scheduleLoopAlert(side: CrisisHintSide, gen: number): void { if (gen !== side.loopSoundGen) return; if (!side.fxNode?.active) return; const skel = side.skeleton; if (!skel?.isValid) return; const delay = BattleSpinePlayback.resolveLogicalCompleteDelay(skel, CRISIS_HINT_ANIM); if (!delay || delay <= 0) { console.log('[MonsterCrisisHint] 动画时长缺失', CRISIS_HINT_ANIM); BattleAudio.playMonsterCrisisAlert(); return; } BattleAudio.playMonsterCrisisAlert(); schedulePresentation(delay, () => { if (gen !== side.loopSoundGen) return; if (!side.fxNode?.active) return; this._scheduleLoopAlert(side, gen); }); } /** 终点前监测格上是否存在进攻该防守方的存活怪 */ private _hasMonsterOnWatchCells(battle: ProjectBattleHandle, side: CrisisHintSide): boolean { const session = battle.session; let found = false; session.world.forEachAlive((entityId) => { if (found) return; if (!this._isMarchMonsterCandidate(battle, side.defenderPlayerId, entityId)) return; const tf = session.world.getComponent(entityId, TransformComp); if (!tf) return; if (!this._isWatchCell(side.watchCells, tf.row, tf.col)) return; found = true; }); return found; } /** 是否为进攻指定防守方的行进怪 */ private _isMarchMonsterCandidate(battle: ProjectBattleHandle, defenderPlayerId: 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)) !== defenderPlayerId) return false; const row = battle.resolveMonsterRow(entityId); if (!row) return false; const type = Math.floor(Number(row.type)); if (type !== MonsterType.SmallMonster && type !== MonsterType.Boss && type !== MonsterType.SummonMonster) { return false; } const move = world.getComponent(entityId, MovementComp); if (move?.spawnProtected || isYanSoulClaimed(move) || isBajiaoBlowRetreating(move)) { return false; } return true; } /** 格是否在监测列表中 */ private _isWatchCell(watchCells: readonly Cell[], row: number, col: number): boolean { for (let i = 0; i < watchCells.length; i++) { const c = watchCells[i]; if (c.row === row && c.col === col) return true; } return false; } /** 回收单侧表现节点 */ private _releaseSideFx(side: CrisisHintSide): void { this._stopSidePresentation(side); const node = side.fxNode; side.fxNode = null; if (!node?.isValid) return; node.removeFromParent(); gg.res.putNode(node); } } let _inst: BattleGameMonsterCrisisHintPresenter | null = null; /** 怪物危机提示单例 */ export function getBattleGameMonsterCrisisHintPresenter(): BattleGameMonsterCrisisHintPresenter { if (!_inst) _inst = new BattleGameMonsterCrisisHintPresenter(); return _inst; }