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.
 
 
 
 
AdLike2048/assets/script/ui/BattleGame/BattleGameMenShenTrapContro...

911 lines
36 KiB

import { Node, sp, Vec3 } 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 { createMembershipSet, membershipAdd, membershipHas, membershipToArray } from '../../../mx/tools/MembershipSet';
import { GBundle } from '../../game/ConfigRes';
import { BATTLE_HERO_BUFF_PATH } from '../../battle/res/BattleHeroResCollector';
import { getShieldPushCells } from '../../battle/bag/BagPrepConfig';
import { BattleSpinePlayback } from '../../battle/presentation/BattleSpinePlayback';
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 } from '../../battle/map/MapPathQueries';
import { resolveAttackStandGridForCamp } from '../../battle/view/tangSengMap';
import { logicalWorldToCocos } from '../../battle/bridge/CocosBattleMovementBridge';
import type { MembershipSet } from '../../../mx/tools/MembershipSet';
/** 门神陷阱预制体(battleHero/prefab/buff/门神) */
const MENSHEN_PREFAB_NAME = '门神';
/** 怪物到达陷阱前一格时触发门神 */
const TRAP_TRIGGER_BEFORE_CELLS = 1;
/** 门神扫掠移动速度:格/秒 */
const MENSHEN_SWEEP_CELLS_PER_SEC = 1;
/** 门神与怪物世界距离 ≤ 该像素值时开始击退 */
const MENSHEN_CATCH_PX = 40;
/** 与 Pathfinder 正交步长一致 */
const PATH_ORTH_STEP = 1000;
/** 待机 Spine 动画名 */
const IDLE_ANIM = {
left: '向左待机',
right: '向右待机',
up: '向上待机',
down: '向下待机',
} as const;
/** 击退 Spine 动画名 */
const KNOCK_ANIM = {
left: '向左击退',
right: '向右击退',
up: '向上击退',
down: '向下击退',
} as const;
/** 四向动画键 */
type MenShenDir = keyof typeof IDLE_ANIM;
/** 单条进路门神陷阱 */
type MenShenTrapEntry = {
/** 出怪口 */
port: number;
/** 陷阱格索引 */
gridIndex: number;
/** 行 */
row: number;
/** 列 */
col: number;
/** 出怪口→攻击站立格 完整进路(含陷阱与终点) */
routePath: Cell[];
/** 陷阱在 routePath 中的下标 */
trapPathIndex: number;
/** 表现节点 */
node: Node;
/** Spine 组件 */
skeleton: sp.Skeleton | null;
/** 待机朝向(上一格相对陷阱的位置) */
idleDir: MenShenDir;
/** 是否已触发扫掠 */
triggered: boolean;
/** 是否正在播击退 */
knockActive: boolean;
/** 当前击退朝向 */
currentKnockDir: MenShenDir | null;
/** 扫掠路径(陷阱格→进路前方,含起点) */
moveCells: Cell[];
/** 当前扫掠段起点下标(moveCells) */
moveSegIndex: number;
/** 扫掠期间门神逻辑坐标 X */
sweepLogicalX: number;
/** 扫掠期间门神逻辑坐标 Y */
sweepLogicalY: number;
/** 陷阱格心世界坐标 */
homeWorldPos: Vec3;
/** 是否正在沿进路扫掠 */
sweeping: boolean;
/** 本局 Const 推怪格数 */
pushCellsConfig: number;
/** 被门神顶住、帧同步随门神后移的怪 */
riderIds: MembershipSet<EntityId> | null;
};
/**
* 门神盾牌:广告后在进路终点格布置;怪到陷阱前一格触发扫掠,碰到怪后帧同步后移 Const24 格,门神消失后怪再寻路。
*/
export class BattleGameMenShenTrapController {
private _battle: ProjectBattleHandle | null = null;
private _map: MapManager | null = null;
private _defenderPlayerId = 0;
private readonly _traps: MenShenTrapEntry[] = [];
private _retreatPathfinderBuilt = false;
private readonly _tmpWorldA = new Vec3();
/**
* @param battle 战斗句柄
* @param map 地图
* @param defenderPlayerId 防守方 playerId
*/
bind(battle: ProjectBattleHandle | null, map: MapManager | null, defenderPlayerId: number = 0): void {
this.unbind();
this._battle = battle;
this._map = map;
this._defenderPlayerId = Math.max(0, Math.floor(Number(defenderPlayerId) || 0));
}
/** 释放陷阱 */
unbind(): void {
for (let i = 0; i < this._traps.length; i++) {
this._releaseTrapNode(this._traps[i].node);
}
this._traps.length = 0;
this._battle = null;
this._map = null;
this._retreatPathfinderBuilt = false;
}
/** 是否已布置陷阱 */
hasTrap(): boolean {
return this._traps.length > 0;
}
/**
* 观看广告成功后:在进路终点格布置门神。
* @returns 是否至少成功放置一处
*/
tryPlaceTraps(): boolean {
const battle = this._battle;
const map = this._map;
if (!battle || !map?.gridsInitialized) return false;
if (this._traps.length > 0) return true;
const session = battle.session;
const enemyCampAreaId = resolveEnemyCampAreaIdForDefender(session, this._defenderPlayerId);
if (enemyCampAreaId == null) {
console.log('[MenShenTrap] 进攻方阵营区域缺失', this._defenderPlayerId);
return false;
}
const stand = resolveAttackStandGridForCamp(map, session, enemyCampAreaId);
if (!stand) {
console.log('[MenShenTrap] 敌方进路攻击站立格缺失', enemyCampAreaId);
return false;
}
session.pathfinder.rebuild(session.topology);
const ports = this._collectSpawnPorts(session, enemyCampAreaId);
let placed = false;
for (let i = 0; i < ports.length; i++) {
const port = ports[i];
const spawnGrid = this._resolveSpawnGridByPort(map, session, port);
if (!spawnGrid) continue;
const trapGrid = stand;
if (!trapGrid?.node?.isValid) continue;
if (this._hasTrapAt(trapGrid.Index)) continue;
const spawnCell = { row: spawnGrid.Row, col: spawnGrid.Col };
const standCell = { row: stand.Row, col: stand.Col };
const routePath = this._buildAdvancePath(
session,
spawnCell,
standCell,
enemyCampAreaId,
);
if (routePath.length < 2) continue;
let trapPathIndex = -1;
for (let j = 0; j < routePath.length; j++) {
const c = routePath[j];
if (c.row === trapGrid.Row && c.col === trapGrid.Col) {
trapPathIndex = j;
break;
}
}
if (trapPathIndex < 1) {
console.log('[MenShenTrap] 陷阱不在进路上', port, trapGrid.Index);
continue;
}
const prev = routePath[trapPathIndex - 1];
const curr = routePath[trapPathIndex];
const spawned = this._spawnTrapNode(trapGrid, prev, curr);
if (!spawned) continue;
this._traps.push({
port,
gridIndex: trapGrid.Index,
row: trapGrid.Row,
col: trapGrid.Col,
routePath,
trapPathIndex,
node: spawned.node,
skeleton: spawned.skeleton,
idleDir: spawned.idleDir,
triggered: false,
knockActive: false,
currentKnockDir: null,
moveCells: [],
moveSegIndex: 0,
sweepLogicalX: 0,
sweepLogicalY: 0,
homeWorldPos: new Vec3(),
sweeping: false,
pushCellsConfig: 0,
riderIds: null,
});
placed = true;
}
if (!placed) {
console.log('[MenShenTrap] 门神陷阱放置失败');
}
return placed;
}
/** 帧更新:怪到陷阱前一格时启动门神扫掠 */
tick(_dt: number): void {
if (this._traps.length === 0) return;
const battle = this._battle;
const session = battle?.session;
if (!session || session.ended) return;
for (let i = this._traps.length - 1; i >= 0; i--) {
const trap = this._traps[i];
if (!trap.node?.isValid) continue;
if (!trap.triggered) {
const triggerId = this._findTriggerMonster(session, trap);
if (triggerId == null) continue;
if (!this._triggerMenShenSweep(trap)) continue;
trap.triggered = true;
}
if (trap.triggered) {
this._tickMenShenMove(trap, _dt);
}
}
}
/**
* 扫描敌方是否首次进入盾牌广告触发距离(离终点 Const23 格内)。
* @param triggerCells 触发距离
* @returns 是否应展示广告
*/
shouldOfferShieldAd(triggerCells: number): boolean {
const battle = this._battle;
if (!battle || !(triggerCells > 0)) return false;
return this._findClosestThreatRemaining(battle.session, triggerCells) != null;
}
/** 找最靠近终点、且处于触发距离内的进攻怪 */
private _findClosestThreatRemaining(session: BattleSession, thresholdCells: number): EntityId | null {
const battle = this._battle;
if (!battle) return null;
let bestId: EntityId | null = null;
let bestRemaining = Number.POSITIVE_INFINITY;
session.world.forEachAlive((entityId) => {
if (!this._isPushCandidate(battle, entityId)) return;
const remaining = this._getRemainingCellsToAttackTarget(session, entityId);
if (!Number.isFinite(remaining) || remaining > thresholdCells) return;
if (remaining < bestRemaining) {
bestRemaining = remaining;
bestId = entityId;
}
});
return bestId;
}
/** 找已到达陷阱前一格、最靠近陷阱的怪 */
private _findTriggerMonster(session: BattleSession, trap: MenShenTrapEntry): EntityId | null {
const battle = this._battle;
if (!battle) return null;
let bestId: EntityId | null = null;
let bestDist = Number.POSITIVE_INFINITY;
session.world.forEachAlive((entityId) => {
if (!this._isPushCandidate(battle, entityId)) return;
const meta = session.world.getComponent(entityId, UnitMetaComp);
if ((meta?.spawnPoint ?? 0) !== trap.port) return;
const tf = session.world.getComponent(entityId, TransformComp);
if (!tf) return;
if (!this._isCellOnPath(trap.routePath, tf.row, tf.col)) return;
const dist = this._distAlongPathToTrap(trap.routePath, trap.trapPathIndex, tf.row, tf.col);
if (dist !== TRAP_TRIGGER_BEFORE_CELLS) return;
if (dist < bestDist) {
bestDist = dist;
bestId = entityId;
}
});
return bestId;
}
/** 触发门神:自陷阱格沿进路前进,碰到怪才推退;失败时不标记 triggered */
private _triggerMenShenSweep(trap: MenShenTrapEntry): boolean {
const session = this._battle?.session;
if (!session) return false;
if (!this._retreatPathfinderBuilt) {
session.pathfinder.rebuild(session.topology);
this._retreatPathfinderBuilt = true;
}
const pushCells = getShieldPushCells();
if (!(pushCells > 0)) {
console.log('[MenShenTrap] Const 推怪格数无效');
return false;
}
trap.pushCellsConfig = pushCells;
trap.riderIds = createMembershipSet();
return this._beginMenShenSweep(trap, pushCells);
}
/** 门神沿进路向出怪口扫掠(Spine 击退为原地步态,须同步位移节点) */
private _beginMenShenSweep(trap: MenShenTrapEntry, pushCells: number): boolean {
const map = this._map;
const session = this._battle?.session;
const node = trap.node;
if (!map || !session || !node?.isValid) return false;
trap.moveCells = this._buildMenShenSweepPath(trap, pushCells);
trap.moveSegIndex = 0;
trap.sweeping = trap.moveCells.length >= 2;
const homeLogical = session.grid.cellToWorld({ row: trap.row, col: trap.col });
trap.sweepLogicalX = homeLogical.x;
trap.sweepLogicalY = homeLogical.y;
if (node.parent !== map.node) {
node.parent = map.node;
}
this._syncDoorNodeFromLogical(trap, session, map);
node.getWorldPosition(trap.homeWorldPos);
if (!trap.sweeping) {
console.log('[MenShenTrap] 扫掠路径过短', trap.moveCells.length, trap.trapPathIndex);
this._finishMenShenSweep(trap);
return false;
}
const dir = this._resolveMoveDirBetween(trap.moveCells[0], trap.moveCells[1]);
this._playMenShenKnockDir(trap, dir);
this._tryAttachRidersOnContact(trap);
return true;
}
/** 构建门神扫掠格序列:陷阱格沿进路向出怪口反推 pushCells 格 */
private _buildMenShenSweepPath(trap: MenShenTrapEntry, pushCells: number): Cell[] {
const out: Cell[] = [];
const start = trap.trapPathIndex;
const end = Math.max(0, start - Math.max(1, Math.floor(Number(pushCells) || 0)));
for (let i = start; i >= end; i--) {
out.push({ row: trap.routePath[i].row, col: trap.routePath[i].col });
}
return out;
}
/** 帧更新:逻辑坐标驱动门神与怪同速后移 → 扫掠 Const 格数后消失 */
private _tickMenShenMove(trap: MenShenTrapEntry, dt: number): void {
const map = this._map;
const session = this._battle?.session;
if (!trap.node?.isValid || !map || !session) return;
if (!trap.sweeping || trap.moveCells.length < 2) return;
const logicalSpeed = Math.max(1, session.grid.cellSize) * MENSHEN_SWEEP_CELLS_PER_SEC * Math.max(0, dt);
if (logicalSpeed <= 0) return;
const prevSeg = trap.moveSegIndex;
const logicalStep = this._advanceDoorAlongSweep(trap, session, logicalSpeed);
this._syncDoorNodeFromLogical(trap, session, map);
this._tryAttachRidersOnContact(trap);
if (logicalStep > 0) this._advanceRidersWithShield(trap, logicalStep);
if (trap.moveSegIndex >= trap.moveCells.length - 1) {
this._finishMenShenSweep(trap);
return;
}
if (trap.moveSegIndex > prevSeg) {
const dir = this._resolveMoveDirBetween(
trap.moveCells[trap.moveSegIndex],
trap.moveCells[trap.moveSegIndex + 1],
);
this._playMenShenKnockDir(trap, dir);
}
}
/** 门神沿扫掠路径推进 stepPx(逻辑坐标),返回本帧实际位移 */
private _advanceDoorAlongSweep(trap: MenShenTrapEntry, session: BattleSession, stepPx: number): number {
if (!(stepPx > 0)) return 0;
let remaining = stepPx;
let moved = 0;
while (remaining > 1e-4 && trap.moveSegIndex + 1 < trap.moveCells.length) {
const toCell = trap.moveCells[trap.moveSegIndex + 1];
const targetWorld = session.grid.cellToWorld(toCell);
const dx = targetWorld.x - trap.sweepLogicalX;
const dy = targetWorld.y - trap.sweepLogicalY;
const distSq = dx * dx + dy * dy;
if (distSq <= remaining * remaining || distSq < 1e-8) {
const dist = Math.sqrt(distSq);
trap.sweepLogicalX = targetWorld.x;
trap.sweepLogicalY = targetWorld.y;
trap.moveSegIndex++;
moved += dist;
remaining -= dist;
continue;
}
const dist = Math.sqrt(distSq);
trap.sweepLogicalX += (dx / dist) * remaining;
trap.sweepLogicalY += (dy / dist) * remaining;
moved += remaining;
remaining = 0;
}
return moved;
}
/** 门神逻辑坐标 → Cocos 节点位置 */
private _syncDoorNodeFromLogical(trap: MenShenTrapEntry, session: BattleSession, map: MapManager): void {
const node = trap.node;
if (!node?.isValid) return;
const anchor = trap.moveCells[trap.moveSegIndex];
if (!anchor) return;
const cocos = logicalWorldToCocos(
session, map, anchor.row, anchor.col, trap.sweepLogicalX, trap.sweepLogicalY,
);
if (!cocos) {
console.error('[MenShenTrap] 门神坐标解析失败', anchor.row, anchor.col);
return;
}
node.setWorldPosition(cocos);
}
/** 门神移动中碰到进路怪:停步并加入随门神后移列表 */
private _tryAttachRidersOnContact(trap: MenShenTrapEntry): void {
const battle = this._battle;
const session = battle?.session;
const map = this._map;
const node = trap.node;
const riderIds = trap.riderIds;
if (!battle || !session || !map || !node?.isValid || !riderIds) return;
node.getWorldPosition(this._tmpWorldA);
const catchSq = MENSHEN_CATCH_PX * MENSHEN_CATCH_PX;
const minPathIdx = this._resolveSweepEndPathIndex(trap);
const doorPathIdx = this._resolveDoorPathIndex(trap);
session.world.forEachAlive((entityId) => {
if (membershipHas(riderIds, entityId)) return;
if (!this._isMenShenPathMonster(battle, session, entityId, trap)) return;
const tf = session.world.getComponent(entityId, TransformComp);
const move = session.world.getComponent(entityId, MovementComp);
if (!tf || !move) return;
const monsterIdx = this._resolveRoutePathIndex(trap.routePath, tf.row, tf.col);
if (monsterIdx < minPathIdx || monsterIdx > trap.trapPathIndex) return;
if (monsterIdx > doorPathIdx + 1 || monsterIdx < doorPathIdx - 1) return;
const pathTouch = monsterIdx >= doorPathIdx - 1 && monsterIdx <= doorPathIdx;
if (!pathTouch) {
const monsterWorld = logicalWorldToCocos(session, map, tf.row, tf.col, move.worldX, move.worldY);
if (!monsterWorld) return;
const dx = monsterWorld.x - this._tmpWorldA.x;
const dy = monsterWorld.y - this._tmpWorldA.y;
if (dx * dx + dy * dy > catchSq) return;
}
if (this._attachMonsterToShield(session, entityId)) {
membershipAdd(riderIds, entityId);
}
});
}
/** 碰到门神:停寻路,保留当前坐标,沿当前格→前一格方向后移 */
private _attachMonsterToShield(session: BattleSession, entityId: EntityId): boolean {
const world = session.world;
if (!world.isAlive(entityId)) return false;
const move = world.getComponent(entityId, MovementComp);
if (!move) return false;
if (move.spawnProtected) move.spawnProtected = false;
move.presentLocked = false;
move.forcePathSpeed = 0;
move.forcePathHoldAtEnd = false;
move.path = [];
move.pathIndex = 0;
move.reachedGoal = false;
move.hasAttackGoal = false;
move.bajiaoBlowRetreating = true;
move.forcePresentSync = true;
return true;
}
/** 被顶住的怪与门神同帧后移(沿进路格心,步长与门神本帧逻辑位移一致) */
private _advanceRidersWithShield(trap: MenShenTrapEntry, stepLogicalPx: number): void {
if (!(stepLogicalPx > 0) || !trap.riderIds || trap.riderIds.size <= 0) return;
const session = this._battle?.session;
if (!session) return;
const ids = membershipToArray(trap.riderIds);
for (let i = 0; i < ids.length; i++) {
this._advanceRiderAlongRoute(session, ids[i], trap, stepLogicalPx);
}
}
/**
* 沿进路后移 stepPx:在下一格侧则先回当前格格心,再走向前一格格心;不吸附、不平行漂移。
*/
private _advanceRiderAlongRoute(
session: BattleSession,
entityId: EntityId,
trap: MenShenTrapEntry,
stepPx: number,
): void {
const world = session.world;
if (!world.isAlive(entityId)) return;
const move = world.getComponent(entityId, MovementComp);
const tf = world.getComponent(entityId, TransformComp);
if (!move || !tf) return;
const routePath = trap.routePath;
const minPathIdx = this._resolveSweepEndPathIndex(trap);
const maxPathIdx = trap.trapPathIndex;
let pathIdx = this._resolveRoutePathIndex(routePath, tf.row, tf.col);
if (pathIdx < 0 || pathIdx <= minPathIdx) return;
let remaining = stepPx;
while (remaining > 1e-4 && pathIdx > minPathIdx) {
const target = this._resolveRiderRetreatTarget(
session, routePath, pathIdx, maxPathIdx, move.worldX, move.worldY,
);
const dx = target.x - move.worldX;
const dy = target.y - move.worldY;
const dist = Math.hypot(dx, dy);
if (dist < 1e-4) {
if (target.isPrevCell) {
const prevCell = routePath[pathIdx - 1];
tf.row = prevCell.row;
tf.col = prevCell.col;
session.spatial.updateEntity(entityId, { row: tf.row, col: tf.col });
pathIdx--;
}
continue;
}
if (dist <= remaining) {
move.worldX = target.x;
move.worldY = target.y;
remaining -= dist;
if (target.isPrevCell) {
const prevCell = routePath[pathIdx - 1];
tf.row = prevCell.row;
tf.col = prevCell.col;
session.spatial.updateEntity(entityId, { row: tf.row, col: tf.col });
pathIdx--;
}
continue;
}
move.worldX += (dx / dist) * remaining;
move.worldY += (dy / dist) * remaining;
remaining = 0;
}
move.presentLocked = false;
move.forcePresentSync = true;
}
/** 本帧后移目标:在进路下一格侧则先回当前格格心,否则走向前一格格心 */
private _resolveRiderRetreatTarget(
session: BattleSession,
routePath: readonly Cell[],
pathIdx: number,
maxPathIdx: number,
worldX: number,
worldY: number,
): { x: number; y: number; isPrevCell: boolean } {
const currCenter = session.grid.cellToWorld(routePath[pathIdx]);
if (this._isRiderAheadOnRoute(session, routePath, pathIdx, maxPathIdx, worldX, worldY)) {
return { x: currCenter.x, y: currCenter.y, isPrevCell: false };
}
const prevCenter = session.grid.cellToWorld(routePath[pathIdx - 1]);
return { x: prevCenter.x, y: prevCenter.y, isPrevCell: true };
}
/** 逻辑坐标是否在当前格沿进路朝陷阱方向一侧(两格之间) */
private _isRiderAheadOnRoute(
session: BattleSession,
routePath: readonly Cell[],
pathIdx: number,
maxPathIdx: number,
worldX: number,
worldY: number,
): boolean {
if (pathIdx >= maxPathIdx) return false;
const currCenter = session.grid.cellToWorld(routePath[pathIdx]);
const nextCenter = session.grid.cellToWorld(routePath[pathIdx + 1]);
const fx = nextCenter.x - currCenter.x;
const fy = nextCenter.y - currCenter.y;
const lenSq = fx * fx + fy * fy;
if (lenSq < 1e-8) return false;
const px = worldX - currCenter.x;
const py = worldY - currCenter.y;
return (px * fx + py * fy) / lenSq > 1e-4;
}
/** 门神消失:被顶怪落格心并恢复寻路 */
private _releaseRiders(trap: MenShenTrapEntry): void {
const session = this._battle?.session;
const riderIds = trap.riderIds;
if (!session || !riderIds || riderIds.size <= 0) return;
const ids = membershipToArray(riderIds);
for (let i = 0; i < ids.length; i++) {
const entityId = ids[i];
if (!session.world.isAlive(entityId)) continue;
const tf = session.world.getComponent(entityId, TransformComp);
const move = session.world.getComponent(entityId, MovementComp);
if (!tf || !move) continue;
const center = session.grid.cellToWorld({ row: tf.row, col: tf.col });
move.worldX = center.x;
move.worldY = center.y;
move.bajiaoBlowRetreating = false;
move.presentLocked = false;
move.forcePathSpeed = 0;
move.forcePresentSync = true;
move.reachedGoal = false;
session.movement.assignPath(entityId);
}
}
/** 门神当前在进路中的下标(随 moveSegIndex 后移) */
private _resolveDoorPathIndex(trap: MenShenTrapEntry): number {
return trap.trapPathIndex - trap.moveSegIndex;
}
/** 门神扫掠终点在进路中的下标 */
private _resolveSweepEndPathIndex(trap: MenShenTrapEntry): number {
if (trap.moveCells.length <= 0) return trap.trapPathIndex;
return trap.trapPathIndex - (trap.moveCells.length - 1);
}
/** 扫掠进路上的可推怪(同出怪口、在 routePath 上) */
private _isMenShenPathMonster(
battle: ProjectBattleHandle,
session: BattleSession,
entityId: EntityId,
trap: MenShenTrapEntry,
): boolean {
if (!this._isPushCandidate(battle, entityId)) return false;
const meta = session.world.getComponent(entityId, UnitMetaComp);
if ((meta?.spawnPoint ?? 0) !== trap.port) return false;
const tf = session.world.getComponent(entityId, TransformComp);
if (!tf) return false;
return this._isCellOnPath(trap.routePath, tf.row, tf.col);
}
/** 格在进路中的下标;-1 表示不在路径上 */
private _resolveRoutePathIndex(path: readonly Cell[], row: number, col: number): number {
for (let i = 0; i < path.length; i++) {
if (path[i].row === row && path[i].col === col) return i;
}
return -1;
}
/** 扫掠结束:回收门神节点并从陷阱列表移除 */
private _finishMenShenSweep(trap: MenShenTrapEntry): void {
trap.sweeping = false;
trap.knockActive = false;
trap.currentKnockDir = null;
this._releaseRiders(trap);
trap.riderIds = null;
this._releaseTrapNode(trap.node);
trap.node = null;
trap.skeleton = null;
const idx = this._traps.indexOf(trap);
if (idx >= 0) this._traps.splice(idx, 1);
}
/** 播放指定方向击退并记录朝向 */
private _playMenShenKnockDir(trap: MenShenTrapEntry, dir: MenShenDir): void {
const skel = trap.skeleton;
if (!skel?.isValid) return;
if (trap.knockActive && trap.currentKnockDir === dir) return;
trap.knockActive = true;
trap.currentKnockDir = dir;
BattleSpinePlayback.play(skel, KNOCK_ANIM[dir], { loop: true });
}
/** 实体沿进路至攻击终点的剩余格数 */
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);
return forward.length <= 0 ? 0 : 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;
}
/** 加载陷阱并按上一格相对陷阱的位置播待机 */
private _spawnTrapNode(parent: MapGrid, prev: Cell, curr: Cell): { node: Node; skeleton: sp.Skeleton; idleDir: MenShenDir } | null {
if (!parent?.node?.isValid) return null;
let node: Node | null = null;
try {
node = gg.res.getNode(BATTLE_HERO_BUFF_PATH, MENSHEN_PREFAB_NAME, GBundle.BattleHero);
} catch (e) {
console.log('[MenShenTrap] 门神预制体加载失败', String(e));
return null;
}
if (!node?.isValid) {
console.log('[MenShenTrap] 门神预制体缺失', MENSHEN_PREFAB_NAME);
return null;
}
node.parent = parent.node;
node.setPosition(0, 0, 0);
node.active = true;
const skel = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton);
if (!skel?.isValid) {
console.log('[MenShenTrap] 门神 Spine 缺失');
gg.res.putNode(node);
return null;
}
const idleDir = this._resolveIdleDirFromPrev(prev, curr);
skel.setAnimationCacheMode(sp.AnimationCacheMode.REALTIME);
BattleSpinePlayback.play(skel, IDLE_ANIM[idleDir], { loop: true });
return { node, skeleton: skel, idleDir };
}
private _releaseTrapNode(node: Node | null): void {
if (!node?.isValid) return;
const skel = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton);
if (skel?.isValid) BattleSpinePlayback.clearPendingComplete(skel);
gg.res.putNode(node);
}
private _hasTrapAt(gridIndex: number): boolean {
const idx = Number(gridIndex) || 0;
for (let i = 0; i < this._traps.length; i++) {
if (this._traps[i].gridIndex === idx) return true;
}
return false;
}
/** 上一格相对陷阱格的位置 → 待机动画(陷阱朝向进路来向) */
private _resolveIdleDirFromPrev(prev: Cell, curr: Cell): MenShenDir {
return this._resolveMoveDirBetween(curr, prev);
}
/** 从 from 格走向 to 格的四向(col 增=右,row 增=下) */
private _resolveMoveDirBetween(from: Cell, to: Cell): MenShenDir {
const dr = to.row - from.row;
const dc = to.col - from.col;
if (Math.abs(dc) >= Math.abs(dr)) {
return dc > 0 ? 'right' : 'left';
}
return dr > 0 ? 'down' : 'up';
}
private _buildAdvancePath(
session: BattleSession,
spawn: Cell,
trap: Cell,
campAreaId: number,
): Cell[] {
let path = session.pathfinder.buildPathToTarget(spawn, trap, campAreaId);
if (path.length < 2) {
path = session.pathfinder.findPath(spawn, trap, true, campAreaId);
}
return path;
}
private _distAlongPathToTrap(
path: readonly Cell[],
trapPathIndex: number,
row: number,
col: number,
): number {
let idx = -1;
for (let i = 0; i < path.length; i++) {
if (path[i].row === row && path[i].col === col) {
idx = i;
break;
}
}
if (idx < 0) return Number.POSITIVE_INFINITY;
return Math.abs(trapPathIndex - idx);
}
private _isCellOnPath(path: readonly Cell[], row: number, col: number): boolean {
for (let i = 0; i < path.length; i++) {
if (path[i].row === row && path[i].col === col) return true;
}
return false;
}
private _resolveEntityCampAreaId(session: BattleSession, entityId: EntityId): number | undefined {
const meta = session.world.getComponent(entityId, UnitMetaComp);
const port = meta?.spawnPoint ?? 0;
if (port < 1) return undefined;
return session.topology.getSpawnPortCampArea(port);
}
private _collectSpawnPorts(session: BattleSession, enemyCampAreaId: number): number[] {
const ports = createMembershipSet<number>();
for (const port of session.topology.spawnPorts.keys()) {
if (port <= 0) continue;
if (session.topology.getSpawnPortCampArea(port) !== enemyCampAreaId) continue;
membershipAdd(ports, port);
}
if (ports.size === 0) membershipAdd(ports, 1);
return membershipToArray(ports).sort((a, b) => a - b);
}
private _resolveSpawnGridByPort(map: MapManager, session: BattleSession, port: number): MapGrid | null {
const cell = session.topology.getSpawnCell(port);
if (cell) {
const grid = map.getMapGrid(cell.row, cell.col);
if (grid?.isCanWalk) return grid;
}
const spawnNode = map.spawnPointParent?.getChildByName(`spawnPoint_${port}`);
if (!spawnNode) return null;
const spawnGrid = map.getMapGridByWorldPos(spawnNode.worldPosition);
if (!spawnGrid?.isCanWalk) return null;
return spawnGrid;
}
private _isPushCandidate(battle: ProjectBattleHandle, 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)) !== this._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;
}
}