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.
677 lines
26 KiB
677 lines
26 KiB
import type { World, EntityId } from 'db://assets/mx/module/ecs';
|
|
import type { ISystem } from 'db://assets/mx/module/ecs';
|
|
import type { Cell } from '../battlefield/Cell';
|
|
import { BattlePhase } from '../core/BattlePhase';
|
|
import { EntityTag } from '../ecs/EntityTag';
|
|
import { BattleTeamId } from '../ecs/BattleTeamId';
|
|
import { MovementComp, TransformComp, TagComp, TeamComp, AttributeComp, BuffContainerComp, UnitMetaComp } from '../ecs/Components';
|
|
import type { GridMap } from '../battlefield/GridMap';
|
|
import type { SpatialGrid } from '../battlefield/SpatialGrid';
|
|
import type { Pathfinder } from '../battlefield/Pathfinder';
|
|
import type { MapTopology } from '../battlefield/MapTopology';
|
|
import { createMembershipSet, membershipAdd } from 'db://assets/mx/tools/MembershipSet';
|
|
|
|
/** Stop & attack when within this many logical pixels of attack-target world pos */
|
|
export const ATTACK_STOP_DIST_PX = 90;
|
|
|
|
/** Entity navigation: path plan + per-frame MovementComp advance */
|
|
export class MovementSystem implements ISystem {
|
|
readonly priority = 50;
|
|
enabled = true;
|
|
|
|
constructor(
|
|
private readonly _world: World,
|
|
private readonly _grid: GridMap,
|
|
private readonly _pathfinder: Pathfinder,
|
|
private readonly _spatial: SpatialGrid,
|
|
private readonly _topology: MapTopology,
|
|
) {}
|
|
|
|
/** 格子中心世界坐标 scratch */
|
|
private readonly _worldScratch = { x: 0, y: 0 };
|
|
/** 空间索引格子 scratch */
|
|
private readonly _cellScratch: Cell = { row: 0, col: 0 };
|
|
/** estimatePathLength 起点 scratch */
|
|
private readonly _pathOrigin = { x: 0, y: 0 };
|
|
|
|
/** Plan path toward nearest attack target;无目标时沿 patrolLoop 环走 */
|
|
assignPath(entityId: EntityId): void {
|
|
const world = this._world;
|
|
const move = world.getComponent(entityId, MovementComp);
|
|
const tf = world.getComponent(entityId, TransformComp);
|
|
if (!move || !tf) return;
|
|
|
|
const from = this._resolvePathStart(tf, move);
|
|
const campAreaId = this._resolveCampAreaForEntity(entityId);
|
|
const target = this._pathfinder.shortestTargetCell(from, campAreaId);
|
|
if (target) {
|
|
this._bindAttackGoal(move, target, campAreaId);
|
|
const path = this._pathfinder.buildPathToTarget(from, target, campAreaId);
|
|
if (path.length <= 1) {
|
|
move.path = [];
|
|
move.pathIndex = 0;
|
|
move.reachedGoal = from.row === target.row && from.col === target.col;
|
|
this._syncRemainPath(move);
|
|
return;
|
|
}
|
|
|
|
move.path = path.slice(1);
|
|
move.pathIndex = 0;
|
|
move.reachedGoal = false;
|
|
if (move.worldX === 0 && move.worldY === 0) {
|
|
this._grid.cellToWorldInto(from, this._worldScratch);
|
|
move.worldX = this._worldScratch.x;
|
|
move.worldY = this._worldScratch.y;
|
|
}
|
|
this._syncRemainPath(move);
|
|
return;
|
|
}
|
|
|
|
if (this._assignPatrolPath(move, from)) {
|
|
return;
|
|
}
|
|
|
|
// 非循环圈:与旧逻辑一致,无目标则停住
|
|
if (this._topology.enablePatrolLoop) {
|
|
console.error('[MovementSystem] assignPath: 循环圈模式无 patrolLoop', from);
|
|
}
|
|
move.path = [];
|
|
move.pathIndex = 0;
|
|
move.reachedGoal = true;
|
|
move.hasAttackGoal = false;
|
|
this._syncRemainPath(move);
|
|
}
|
|
|
|
/**
|
|
* 从 via 格规划进攻路径;当前格可与 via 不同,将先走到 via。
|
|
* @param entityId 实体
|
|
* @param via 可走汇入格
|
|
*/
|
|
assignPathVia(entityId: EntityId, via: Cell): void {
|
|
const world = this._world;
|
|
const move = world.getComponent(entityId, MovementComp);
|
|
const tf = world.getComponent(entityId, TransformComp);
|
|
if (!move || !tf) return;
|
|
|
|
const campAreaId = this._resolveCampAreaForEntity(entityId);
|
|
const target = this._pathfinder.shortestTargetCell(via, campAreaId);
|
|
if (!target) {
|
|
console.error(`[MovementSystem] assignPathVia: via 不可达攻击点 row=${via.row} col=${via.col}`);
|
|
move.path = [];
|
|
move.pathIndex = 0;
|
|
move.reachedGoal = true;
|
|
move.hasAttackGoal = false;
|
|
this._syncRemainPath(move);
|
|
return;
|
|
}
|
|
|
|
this._bindAttackGoal(move, target, campAreaId);
|
|
const viaPath = this._pathfinder.buildPathToTarget(via, target, campAreaId);
|
|
if (viaPath.length < 1) {
|
|
console.error(`[MovementSystem] assignPathVia: 路径为空 via=${via.row},${via.col} target=${target.row},${target.col}`);
|
|
move.path = [];
|
|
move.pathIndex = 0;
|
|
move.reachedGoal = true;
|
|
this._syncRemainPath(move);
|
|
return;
|
|
}
|
|
|
|
const atVia = tf.row === via.row && tf.col === via.col;
|
|
if (atVia) {
|
|
if (viaPath.length <= 1) {
|
|
move.path = [];
|
|
move.pathIndex = 0;
|
|
move.reachedGoal = tf.row === target.row && tf.col === target.col;
|
|
this._syncRemainPath(move);
|
|
return;
|
|
}
|
|
move.path = viaPath.slice(1);
|
|
} else {
|
|
move.path = viaPath.map((c) => ({ row: c.row, col: c.col }));
|
|
}
|
|
move.pathIndex = 0;
|
|
move.reachedGoal = false;
|
|
if (move.worldX === 0 && move.worldY === 0) {
|
|
this._cellScratch.row = tf.row;
|
|
this._cellScratch.col = tf.col;
|
|
this._grid.cellToWorldInto(this._cellScratch, this._worldScratch);
|
|
move.worldX = this._worldScratch.x;
|
|
move.worldY = this._worldScratch.y;
|
|
}
|
|
this._syncRemainPath(move);
|
|
}
|
|
|
|
/** Plan path to a specific cell (generic; not attack-target stop) */
|
|
assignPathTo(entityId: EntityId, to: Cell, use8Dir = true): void {
|
|
const world = this._world;
|
|
const move = world.getComponent(entityId, MovementComp);
|
|
const tf = world.getComponent(entityId, TransformComp);
|
|
if (!move || !tf) return;
|
|
|
|
const from = this._resolvePathStart(tf, move);
|
|
move.hasAttackGoal = false;
|
|
const campAreaId = this._resolveCampAreaForEntity(entityId);
|
|
const fullPath = this._pathfinder.findPath(from, to, use8Dir, campAreaId);
|
|
if (fullPath.length <= 1) {
|
|
move.path = [];
|
|
move.pathIndex = 0;
|
|
move.reachedGoal = fullPath.length === 1
|
|
&& from.row === to.row && from.col === to.col;
|
|
this._syncRemainPath(move);
|
|
return;
|
|
}
|
|
|
|
move.path = fullPath.slice(1);
|
|
move.pathIndex = 0;
|
|
move.reachedGoal = false;
|
|
if (move.worldX === 0 && move.worldY === 0) {
|
|
this._grid.cellToWorldInto(from, this._worldScratch);
|
|
move.worldX = this._worldScratch.x;
|
|
move.worldY = this._worldScratch.y;
|
|
}
|
|
this._syncRemainPath(move);
|
|
}
|
|
|
|
/** 沿当前寻路路径到攻击目标的剩余逻辑像素距离;不可达/未规划返回 Infinity */
|
|
getRemainingPathLength(entityId: EntityId): number {
|
|
const move = this._world.getComponent(entityId, MovementComp);
|
|
if (!move) return Number.POSITIVE_INFINITY;
|
|
return move.remainPathPx;
|
|
}
|
|
|
|
/**
|
|
* 强制沿给定格路径移动(含起点);忽略攻击停步与移速同步。
|
|
* 若实体已在起点格,保留当前 worldX/Y(格间插值),避免吸附格心造成瞬移。
|
|
* @returns 实际路径格数(不含起点);无法移动返回 0
|
|
*/
|
|
setForcedPath(entityId: EntityId, fullPath: readonly Cell[], speedPx: number): number {
|
|
if (speedPx <= 0 || fullPath.length <= 1) return 0;
|
|
const move = this._world.getComponent(entityId, MovementComp);
|
|
const tf = this._world.getComponent(entityId, TransformComp);
|
|
if (!move || !tf) return 0;
|
|
|
|
const start = fullPath[0];
|
|
const onStartCell = tf.row === start.row && tf.col === start.col;
|
|
if (!onStartCell) {
|
|
tf.row = start.row;
|
|
tf.col = start.col;
|
|
this._grid.cellToWorldInto(start, this._worldScratch);
|
|
move.worldX = this._worldScratch.x;
|
|
move.worldY = this._worldScratch.y;
|
|
this._spatial.updateEntity(entityId, start);
|
|
}
|
|
|
|
move.path = fullPath.slice(1).map((c) => ({ ...c }));
|
|
move.pathIndex = 0;
|
|
move.reachedGoal = false;
|
|
move.hasAttackGoal = false;
|
|
move.forcePathSpeed = speedPx;
|
|
move.forcePathHoldAtEnd = false;
|
|
move.presentLocked = false;
|
|
move.forcePresentSync = true;
|
|
this._syncRemainPath(move);
|
|
return move.path.length;
|
|
}
|
|
|
|
/** 是否仍在强制路径移动中 */
|
|
isOnForcedPath(entityId: EntityId): boolean {
|
|
const move = this._world.getComponent(entityId, MovementComp);
|
|
return !!move && move.forcePathSpeed > 0;
|
|
}
|
|
|
|
/**
|
|
* 沿自身出怪口前进路线后退 maxSteps 格并强制移动。
|
|
* @returns 实际后退格数(不含起点)
|
|
*/
|
|
retreatAlongPath(entityId: EntityId, maxSteps: number, speedPx: number): number {
|
|
const tf = this._world.getComponent(entityId, TransformComp);
|
|
if (!tf || maxSteps <= 0) return 0;
|
|
const spawn = this._resolveSpawnCell(entityId);
|
|
if (!spawn) return 0;
|
|
const port = this._resolveSpawnPort(entityId);
|
|
const fullPath = this._pathfinder.buildRetreatPath(
|
|
{ row: tf.row, col: tf.col },
|
|
maxSteps,
|
|
spawn,
|
|
port,
|
|
);
|
|
return this.setForcedPath(entityId, fullPath, speedPx);
|
|
}
|
|
|
|
private _resolveSpawnPort(entityId: EntityId): number | undefined {
|
|
const meta = this._world.getComponent(entityId, UnitMetaComp);
|
|
const port = meta?.spawnPoint ?? 0;
|
|
if (port < 1) {
|
|
console.error('[MovementSystem] entity missing valid spawnPoint', entityId);
|
|
return undefined;
|
|
}
|
|
return port;
|
|
}
|
|
|
|
private _resolveCampAreaForEntity(entityId: EntityId): number | undefined {
|
|
const port = this._resolveSpawnPort(entityId);
|
|
if (port === undefined) return undefined;
|
|
const area = this._topology.getSpawnPortCampArea(port);
|
|
if (area === undefined) {
|
|
console.error('[MovementSystem] spawn port missing camp area mapping', port);
|
|
return undefined;
|
|
}
|
|
return area;
|
|
}
|
|
|
|
private _resolveSpawnCell(entityId: EntityId): Cell | null {
|
|
const port = this._resolveSpawnPort(entityId);
|
|
if (port === undefined) return null;
|
|
const cell = this._topology.getSpawnCell(port);
|
|
if (!cell) {
|
|
console.error('[MovementSystem] spawn port not in topology', port);
|
|
return null;
|
|
}
|
|
return cell;
|
|
}
|
|
|
|
/** Temp path-follower entity (skill bullets etc.) */
|
|
createPathFollower(from: Cell, to: Cell, speed: number): EntityId {
|
|
const world = this._world;
|
|
const id = world.spawn();
|
|
world.addComponent(id, new TransformComp(from.row, from.col));
|
|
this._spatial.updateEntity(id, from);
|
|
this._grid.cellToWorldInto(from, this._worldScratch);
|
|
world.addComponent(id, new MovementComp(speed, [], 0, this._worldScratch.x, this._worldScratch.y, false));
|
|
this.assignPathTo(id, to);
|
|
return id;
|
|
}
|
|
|
|
/** 沿指定格序列强制移动的临时实体(旋风等表现跟随) */
|
|
createForcedPathFollower(fullPath: readonly Cell[], speed: number): EntityId {
|
|
if (fullPath.length < 1) {
|
|
throw new Error('[MovementSystem] createForcedPathFollower empty path');
|
|
}
|
|
const from = fullPath[0];
|
|
const world = this._world;
|
|
const id = world.spawn();
|
|
world.addComponent(id, new TransformComp(from.row, from.col));
|
|
this._spatial.updateEntity(id, from);
|
|
this._grid.cellToWorldInto(from, this._worldScratch);
|
|
world.addComponent(id, new MovementComp(speed, [], 0, this._worldScratch.x, this._worldScratch.y, false));
|
|
if (fullPath.length > 1) {
|
|
this.setForcedPath(id, fullPath, speed);
|
|
} else {
|
|
const move = world.getComponent(id, MovementComp)!;
|
|
move.reachedGoal = true;
|
|
this._syncRemainPath(move);
|
|
}
|
|
return id;
|
|
}
|
|
|
|
/** @param dt frame delta seconds */
|
|
tick(dt: number): void {
|
|
const phase = this._phase();
|
|
// PAUSE:仅推进强制路径(芭蕉扇推回),出怪/普攻停步等仍停表
|
|
const forcedOnly = phase === BattlePhase.PAUSE;
|
|
if (!forcedOnly && phase !== BattlePhase.COMBAT) return;
|
|
|
|
const world = this._world;
|
|
world.view2(MovementComp, TransformComp).forEach((id, move, tf) => {
|
|
if (move.imprisoned || move.presentLocked) return;
|
|
/** 门神顶推等外部改写坐标:无强制路径时不走本系统 */
|
|
if (move.bajiaoBlowRetreating && move.forcePathSpeed <= 0) return;
|
|
|
|
const forced = move.forcePathSpeed > 0;
|
|
if (forcedOnly && !forced) return;
|
|
if (!forced && move.reachedGoal) return;
|
|
|
|
if (!forced && this._withinAttackStopRange(move) && !this._hasRemainingPathSteps(move)) {
|
|
this._stopForAttack(move);
|
|
return;
|
|
}
|
|
|
|
if (!forced) this._syncMoveSpeed(id, move);
|
|
const speed = (forced ? move.forcePathSpeed : move.speed) * dt;
|
|
if (speed <= 0) return;
|
|
|
|
let targetCell: Cell | null = null;
|
|
if (move.pathIndex < move.path.length) {
|
|
targetCell = move.path[move.pathIndex];
|
|
} else if (forced) {
|
|
this._finishForcedPath(id, move);
|
|
return;
|
|
} else if (this._continuePatrolIfNeeded(id, move)) {
|
|
return;
|
|
} else {
|
|
move.reachedGoal = true;
|
|
return;
|
|
}
|
|
|
|
const targetWorld = this._worldScratch;
|
|
this._grid.cellToWorldInto(targetCell, targetWorld);
|
|
const dx = targetWorld.x - move.worldX;
|
|
const dy = targetWorld.y - move.worldY;
|
|
const distSq = dx * dx + dy * dy;
|
|
const reachSq = speed * speed;
|
|
|
|
if (distSq <= reachSq || distSq < 1e-8) {
|
|
move.worldX = targetWorld.x;
|
|
move.worldY = targetWorld.y;
|
|
tf.row = targetCell.row;
|
|
tf.col = targetCell.col;
|
|
this._cellScratch.row = tf.row;
|
|
this._cellScratch.col = tf.col;
|
|
this._spatial.updateEntity(id, this._cellScratch);
|
|
move.pathIndex++;
|
|
if (!forced && this._withinAttackStopRange(move) && !this._hasRemainingPathSteps(move)) {
|
|
this._stopForAttack(move);
|
|
return;
|
|
}
|
|
if (move.pathIndex >= move.path.length) {
|
|
if (forced) this._finishForcedPath(id, move);
|
|
else if (!this._continuePatrolIfNeeded(id, move)) {
|
|
move.reachedGoal = true;
|
|
this._syncRemainPath(move);
|
|
}
|
|
} else {
|
|
this._syncRemainPath(move);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const dist = Math.sqrt(distSq);
|
|
const stepX = (dx / dist) * speed;
|
|
const stepY = (dy / dist) * speed;
|
|
const nextX = move.worldX + stepX;
|
|
const nextY = move.worldY + stepY;
|
|
if (!forced && move.hasAttackGoal && !this._hasRemainingPathSteps(move)) {
|
|
const gdx = move.goalWorldX - nextX;
|
|
const gdy = move.goalWorldY - nextY;
|
|
if (gdx * gdx + gdy * gdy <= ATTACK_STOP_DIST_PX * ATTACK_STOP_DIST_PX) {
|
|
move.worldX = nextX;
|
|
move.worldY = nextY;
|
|
this._stopForAttack(move);
|
|
return;
|
|
}
|
|
}
|
|
|
|
move.worldX = nextX;
|
|
move.worldY = nextY;
|
|
this._syncRemainPath(move);
|
|
});
|
|
}
|
|
|
|
private _finishForcedPath(entityId: EntityId, move: MovementComp): void {
|
|
move.forcePathSpeed = 0;
|
|
move.bajiaoBlowRetreating = false;
|
|
move.path = [];
|
|
move.pathIndex = 0;
|
|
move.reachedGoal = false;
|
|
move.forcePresentSync = true;
|
|
if (move.forcePathHoldAtEnd) {
|
|
move.forcePathHoldAtEnd = false;
|
|
move.presentLocked = true;
|
|
this._syncRemainPath(move);
|
|
return;
|
|
}
|
|
if (this._isEnemy(entityId)) {
|
|
this.assignPath(entityId);
|
|
} else {
|
|
this._syncRemainPath(move);
|
|
}
|
|
}
|
|
|
|
private _syncMoveSpeed(id: EntityId, move: MovementComp): void {
|
|
const world = this._world;
|
|
const buffs = world.getComponent(id, BuffContainerComp);
|
|
if (buffs?.hasDebuff4) {
|
|
move.speed = 0;
|
|
return;
|
|
}
|
|
const tags = world.getComponent(id, TagComp);
|
|
let hasStunTag = false;
|
|
let hasSlowTag = false;
|
|
if (tags) {
|
|
const tagList = tags.tags;
|
|
for (let i = 0; i < tagList.length; i++) {
|
|
const t = tagList[i];
|
|
if (t === 'stun') hasStunTag = true;
|
|
else if (t === 'slow') hasSlowTag = true;
|
|
}
|
|
}
|
|
if (hasStunTag) {
|
|
move.speed = 0;
|
|
return;
|
|
}
|
|
const attr = world.getComponent(id, AttributeComp);
|
|
let mul = 1;
|
|
if (attr) {
|
|
const mods = attr.modifiers;
|
|
for (let i = 0; i < mods.length; i++) {
|
|
const m = mods[i];
|
|
if (m.attr !== 'move_speed_mul') continue;
|
|
if (m.op === 'Mul') mul *= m.value;
|
|
else if (m.op === 'Override') mul = m.value;
|
|
}
|
|
}
|
|
if (hasSlowTag && mul >= 1) mul = 0.5;
|
|
move.speed = Math.max(0, (move.baseSpeed || move.speed) * mul);
|
|
}
|
|
|
|
/** Topology changed: repath enemies without moving them */
|
|
refreshEnemyPaths(): void {
|
|
const world = this._world;
|
|
world.forEachAlive(id => {
|
|
if (!this._isEnemy(id)) return;
|
|
this.assignPath(id);
|
|
});
|
|
}
|
|
|
|
/** Evict enemies standing on a blocked cell */
|
|
evictEnemiesAtCell(row: number, col: number): void {
|
|
const world = this._world;
|
|
world.forEachAlive(id => {
|
|
if (!this._isEnemy(id)) return;
|
|
const tf = world.getComponent(id, TransformComp);
|
|
if (!tf || tf.row !== row || tf.col !== col) return;
|
|
this._evictEntityToNearestWalkable(id);
|
|
});
|
|
}
|
|
|
|
/** Evict enemies from all currently blocked cells */
|
|
evictEnemiesFromBlockedCells(): void {
|
|
const world = this._world;
|
|
world.forEachAlive(id => {
|
|
if (!this._isEnemy(id)) return;
|
|
const tf = world.getComponent(id, TransformComp);
|
|
if (!tf) return;
|
|
if (this._grid.isWalkable({ row: tf.row, col: tf.col })) return;
|
|
this._evictEntityToNearestWalkable(id);
|
|
});
|
|
}
|
|
|
|
private _phaseFn: () => BattlePhase = () => BattlePhase.COMBAT;
|
|
|
|
setPhaseQuery(fn: () => BattlePhase): void {
|
|
this._phaseFn = fn;
|
|
}
|
|
|
|
private _phase(): BattlePhase {
|
|
return this._phaseFn();
|
|
}
|
|
|
|
private _resolvePathStart(tf: TransformComp, _move: MovementComp): Cell {
|
|
return { row: tf.row, col: tf.col };
|
|
}
|
|
|
|
private _bindAttackGoal(move: MovementComp, targetCell: Cell, campAreaId?: number): void {
|
|
const w = this._topology.getAttackTargetWorld(targetCell, campAreaId);
|
|
if (w) {
|
|
move.goalWorldX = w.x;
|
|
move.goalWorldY = w.y;
|
|
} else {
|
|
this._grid.cellToWorldInto(targetCell, this._worldScratch);
|
|
move.goalWorldX = this._worldScratch.x;
|
|
move.goalWorldY = this._worldScratch.y;
|
|
}
|
|
move.hasAttackGoal = true;
|
|
}
|
|
|
|
/**
|
|
* 无攻击目标:沿 topology.patrolLoop 顺时针走一整圈(从下一格起)。
|
|
* 若不在环上,先寻路到最近环格再接入环。
|
|
*/
|
|
private _assignPatrolPath(move: MovementComp, from: Cell): boolean {
|
|
const loop = this._topology.patrolLoop;
|
|
if (!loop.length) return false;
|
|
|
|
move.hasAttackGoal = false;
|
|
const idx = this._nearestPatrolIndex(from, loop);
|
|
const onLoop = loop[idx].row === from.row && loop[idx].col === from.col;
|
|
const path: Cell[] = [];
|
|
|
|
if (!onLoop) {
|
|
const toNearest = this._pathfinder.findPath(from, loop[idx], true);
|
|
for (let i = 1; i < toNearest.length; i++) {
|
|
path.push({ ...toNearest[i] });
|
|
}
|
|
}
|
|
|
|
const n = loop.length;
|
|
for (let step = 1; step <= n; step++) {
|
|
const c = loop[(idx + step) % n];
|
|
path.push({ ...c });
|
|
}
|
|
|
|
if (!path.length) {
|
|
move.path = [];
|
|
move.pathIndex = 0;
|
|
move.reachedGoal = false;
|
|
this._syncRemainPath(move);
|
|
return true;
|
|
}
|
|
|
|
move.path = path;
|
|
move.pathIndex = 0;
|
|
move.reachedGoal = false;
|
|
if (move.worldX === 0 && move.worldY === 0) {
|
|
this._grid.cellToWorldInto(from, this._worldScratch);
|
|
move.worldX = this._worldScratch.x;
|
|
move.worldY = this._worldScratch.y;
|
|
}
|
|
this._syncRemainPath(move);
|
|
return true;
|
|
}
|
|
|
|
private _nearestPatrolIndex(from: Cell, loop: readonly Cell[]): number {
|
|
let best = 0;
|
|
let bestD = Number.POSITIVE_INFINITY;
|
|
for (let i = 0; i < loop.length; i++) {
|
|
const d = Math.abs(loop[i].row - from.row) + Math.abs(loop[i].col - from.col);
|
|
if (d < bestD) {
|
|
bestD = d;
|
|
best = i;
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
/** 环走路径走完后续下一圈;有攻击目标时不续 */
|
|
private _continuePatrolIfNeeded(entityId: EntityId, move: MovementComp): boolean {
|
|
if (move.hasAttackGoal) return false;
|
|
if (!this._topology.patrolLoop.length) return false;
|
|
this.assignPath(entityId);
|
|
return !move.reachedGoal && move.path.length > 0;
|
|
}
|
|
|
|
/** 是否仍有未走完的寻路格(有剩余步数时不应因近距判定直接攻墙) */
|
|
private _hasRemainingPathSteps(move: MovementComp): boolean {
|
|
return move.pathIndex < move.path.length;
|
|
}
|
|
|
|
private _withinAttackStopRange(move: MovementComp): boolean {
|
|
if (!move.hasAttackGoal) return false;
|
|
const dx = move.goalWorldX - move.worldX;
|
|
const dy = move.goalWorldY - move.worldY;
|
|
return dx * dx + dy * dy <= ATTACK_STOP_DIST_PX * ATTACK_STOP_DIST_PX;
|
|
}
|
|
|
|
private _stopForAttack(move: MovementComp): void {
|
|
move.reachedGoal = true;
|
|
move.path = [];
|
|
move.pathIndex = 0;
|
|
this._syncRemainPath(move);
|
|
}
|
|
|
|
/**
|
|
* 刷新 remainPathPx:到达为 0,无路径为 Infinity,否则估算剩余像素。
|
|
* @param move 移动组件
|
|
*/
|
|
private _syncRemainPath(move: MovementComp): void {
|
|
if (move.reachedGoal) {
|
|
move.remainPathPx = 0;
|
|
return;
|
|
}
|
|
if (!move.path.length || move.pathIndex >= move.path.length) {
|
|
move.remainPathPx = Number.POSITIVE_INFINITY;
|
|
return;
|
|
}
|
|
this._pathOrigin.x = move.worldX;
|
|
this._pathOrigin.y = move.worldY;
|
|
move.remainPathPx = this._pathfinder.estimatePathLength(this._pathOrigin, move.path, move.pathIndex);
|
|
}
|
|
|
|
private _evictEntityToNearestWalkable(entityId: EntityId): void {
|
|
const world = this._world;
|
|
const move = world.getComponent(entityId, MovementComp);
|
|
const tf = world.getComponent(entityId, TransformComp);
|
|
if (!move || !tf) return;
|
|
|
|
const escape = this._findNearestWalkable({ row: tf.row, col: tf.col });
|
|
if (!escape) return;
|
|
if (escape.row === tf.row && escape.col === tf.col) return;
|
|
|
|
this._grid.cellToWorldInto(escape, this._worldScratch);
|
|
move.worldX = this._worldScratch.x;
|
|
move.worldY = this._worldScratch.y;
|
|
tf.row = escape.row;
|
|
tf.col = escape.col;
|
|
this._spatial.updateEntity(entityId, escape);
|
|
move.reachedGoal = false;
|
|
this.assignPath(entityId);
|
|
}
|
|
|
|
private _findNearestWalkable(from: Cell): Cell | null {
|
|
if (this._grid.isWalkable(from)) return from;
|
|
|
|
const queue: Cell[] = [from];
|
|
const visited = createMembershipSet<number>();
|
|
membershipAdd(visited, this._grid.indexOf(from));
|
|
|
|
let head = 0;
|
|
while (head < queue.length) {
|
|
const cur = queue[head++];
|
|
for (const { dr, dc } of MovementSystem.EVICT_DIRS) {
|
|
const nb = { row: cur.row + dr, col: cur.col + dc };
|
|
if (!this._grid.inBounds(nb)) continue;
|
|
const ni = this._grid.indexOf(nb);
|
|
if (visited.has(ni)) continue;
|
|
membershipAdd(visited, ni);
|
|
if (this._grid.isWalkable(nb)) return nb;
|
|
queue.push(nb);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private _isEnemy(id: EntityId): boolean {
|
|
const world = this._world;
|
|
const tags = world.getComponent(id, TagComp);
|
|
if (tags?.tags.includes(EntityTag.Enemy)) return true;
|
|
const team = world.getComponent(id, TeamComp);
|
|
return !!(team && BattleTeamId.isEnemyTeam(team.team));
|
|
}
|
|
|
|
private static readonly EVICT_DIRS: readonly { dr: number; dc: number }[] = [
|
|
{ dr: -1, dc: 0 },
|
|
{ dr: 1, dc: 0 },
|
|
{ dr: 0, dc: -1 },
|
|
{ dr: 0, dc: 1 },
|
|
];
|
|
}
|
|
|