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(); 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 }, ]; }