|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|