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/mx/module/battle/combat/MovementSystem.ts

678 lines
26 KiB

3 weeks ago
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 },
];
}