You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
324 lines
12 KiB
324 lines
12 KiB
|
3 weeks ago
|
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;
|
||
|
|
}
|