2025-12-24 00:22:32 +00:00
|
|
|
/**
|
|
|
|
|
* @typedef {import("../units/Unit.js").Unit} Unit
|
|
|
|
|
* @typedef {import("../grid/VoxelGrid.js").VoxelGrid} VoxelGrid
|
|
|
|
|
* @typedef {import("../managers/UnitManager.js").UnitManager} UnitManager
|
|
|
|
|
* @typedef {import("../grid/types.js").Position} Position
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* MovementSystem.js
|
|
|
|
|
* Manages unit movement, pathfinding, and validation.
|
|
|
|
|
* Implements the specifications from CombatState.spec.md
|
|
|
|
|
* @class
|
|
|
|
|
*/
|
|
|
|
|
export class MovementSystem {
|
|
|
|
|
/**
|
|
|
|
|
* @param {VoxelGrid} [grid] - Voxel grid instance
|
|
|
|
|
* @param {UnitManager} [unitManager] - Unit manager instance
|
|
|
|
|
*/
|
|
|
|
|
constructor(grid = null, unitManager = null) {
|
|
|
|
|
/** @type {VoxelGrid | null} */
|
|
|
|
|
this.grid = grid;
|
|
|
|
|
/** @type {UnitManager | null} */
|
|
|
|
|
this.unitManager = unitManager;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Sets the context (grid and unit manager).
|
|
|
|
|
* @param {VoxelGrid} grid - Voxel grid instance
|
|
|
|
|
* @param {UnitManager} unitManager - Unit manager instance
|
|
|
|
|
*/
|
|
|
|
|
setContext(grid, unitManager) {
|
|
|
|
|
this.grid = grid;
|
|
|
|
|
this.unitManager = unitManager;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Finds the walkable Y level for a given X,Z position.
|
|
|
|
|
* @param {number} x - X coordinate
|
|
|
|
|
* @param {number} z - Z coordinate
|
|
|
|
|
* @param {number} referenceY - Reference Y level to check around
|
|
|
|
|
* @returns {number | null} - Walkable Y level or null if not walkable
|
|
|
|
|
* @private
|
|
|
|
|
*/
|
|
|
|
|
findWalkableY(x, z, referenceY) {
|
|
|
|
|
if (!this.grid) return null;
|
|
|
|
|
|
|
|
|
|
// Check same level, up 1, down 1, down 2 (matching GameLoop logic)
|
2025-12-28 00:54:03 +00:00
|
|
|
const yLevels = [
|
|
|
|
|
referenceY,
|
|
|
|
|
referenceY + 1,
|
|
|
|
|
referenceY - 1,
|
|
|
|
|
referenceY - 2,
|
|
|
|
|
];
|
2025-12-24 00:22:32 +00:00
|
|
|
for (const y of yLevels) {
|
|
|
|
|
if (this.isWalkable(x, y, z)) {
|
|
|
|
|
return y;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Checks if a position is walkable.
|
|
|
|
|
* @param {number} x - X coordinate
|
|
|
|
|
* @param {number} y - Y coordinate
|
|
|
|
|
* @param {number} z - Z coordinate
|
|
|
|
|
* @returns {boolean} - True if walkable
|
|
|
|
|
* @private
|
|
|
|
|
*/
|
|
|
|
|
isWalkable(x, y, z) {
|
|
|
|
|
if (!this.grid) return false;
|
|
|
|
|
|
|
|
|
|
// Check if cell is air
|
|
|
|
|
if (this.grid.getCell(x, y, z) !== 0) return false;
|
|
|
|
|
// Check if there's a floor below
|
|
|
|
|
if (this.grid.getCell(x, y - 1, z) === 0) return false;
|
|
|
|
|
// Check if there's air above
|
|
|
|
|
if (this.grid.getCell(x, y + 1, z) !== 0) return false;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Calculates all reachable positions for a unit using BFS.
|
2025-12-28 00:54:03 +00:00
|
|
|
* Takes into account the unit's current AP, not just base movement stat.
|
2025-12-24 00:22:32 +00:00
|
|
|
* @param {Unit} unit - The unit to calculate movement for
|
2025-12-28 00:54:03 +00:00
|
|
|
* @param {number} maxRange - Maximum movement range (overrides AP calculation if provided)
|
2025-12-24 00:22:32 +00:00
|
|
|
* @returns {Position[]} - Array of reachable positions
|
|
|
|
|
*/
|
|
|
|
|
getReachableTiles(unit, maxRange = null) {
|
|
|
|
|
if (!this.grid || !unit.position) return [];
|
|
|
|
|
|
|
|
|
|
const start = unit.position;
|
2025-12-28 00:54:03 +00:00
|
|
|
const baseMovement = unit.baseStats?.movement || 4;
|
|
|
|
|
const currentAP = unit.currentAP || 0;
|
|
|
|
|
|
|
|
|
|
// Use the minimum of base movement and current AP as the effective range
|
|
|
|
|
// This ensures we only show tiles the unit can actually reach with their current AP
|
|
|
|
|
const effectiveMaxRange =
|
|
|
|
|
maxRange !== null ? maxRange : Math.min(baseMovement, currentAP);
|
|
|
|
|
|
2025-12-24 00:22:32 +00:00
|
|
|
const reachable = new Set();
|
2025-12-28 00:54:03 +00:00
|
|
|
const queue = [{ x: start.x, z: start.z, y: start.y }];
|
2025-12-24 00:22:32 +00:00
|
|
|
const visited = new Set();
|
|
|
|
|
visited.add(`${start.x},${start.z}`); // Track by X,Z only for horizontal movement
|
|
|
|
|
|
|
|
|
|
// Horizontal movement directions (4-connected)
|
|
|
|
|
const directions = [
|
|
|
|
|
{ x: 1, z: 0 },
|
|
|
|
|
{ x: -1, z: 0 },
|
|
|
|
|
{ x: 0, z: 1 },
|
|
|
|
|
{ x: 0, z: -1 },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
while (queue.length > 0) {
|
2025-12-28 00:54:03 +00:00
|
|
|
const { x, z, y } = queue.shift();
|
2025-12-24 00:22:32 +00:00
|
|
|
|
|
|
|
|
// Find the walkable Y level for this X,Z position
|
|
|
|
|
const walkableY = this.findWalkableY(x, z, y);
|
|
|
|
|
if (walkableY === null) continue;
|
|
|
|
|
|
|
|
|
|
const pos = { x, y: walkableY, z };
|
2025-12-24 04:28:12 +00:00
|
|
|
// Use walkableY in the key, not the reference y
|
|
|
|
|
const posKey = `${x},${walkableY},${z}`;
|
2025-12-24 00:22:32 +00:00
|
|
|
|
2025-12-28 00:54:03 +00:00
|
|
|
// Calculate actual AP cost (Manhattan distance from start)
|
|
|
|
|
// This matches the cost calculation in validateMove()
|
|
|
|
|
const horizontalDistance = Math.abs(x - start.x) + Math.abs(z - start.z);
|
|
|
|
|
const movementCost = Math.max(1, horizontalDistance);
|
|
|
|
|
|
|
|
|
|
// Only include positions that:
|
|
|
|
|
// 1. Are not occupied (or are the starting position)
|
|
|
|
|
// 2. Cost no more AP than the unit currently has (or is the starting position)
|
|
|
|
|
// 3. Are within the effective movement range
|
|
|
|
|
const isStartPos =
|
|
|
|
|
x === start.x && z === start.z && walkableY === start.y;
|
|
|
|
|
const canAfford = movementCost <= currentAP || isStartPos;
|
|
|
|
|
const inRange = horizontalDistance <= effectiveMaxRange;
|
|
|
|
|
|
|
|
|
|
if ((!this.grid.isOccupied(pos) || isStartPos) && canAfford && inRange) {
|
2025-12-24 00:22:32 +00:00
|
|
|
reachable.add(posKey);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-28 00:54:03 +00:00
|
|
|
// Explore neighbors if we haven't exceeded max range
|
|
|
|
|
// Continue exploring even if current position wasn't reachable (might be blocked but neighbors aren't)
|
|
|
|
|
if (horizontalDistance < effectiveMaxRange) {
|
2025-12-24 00:22:32 +00:00
|
|
|
for (const dir of directions) {
|
|
|
|
|
const newX = x + dir.x;
|
|
|
|
|
const newZ = z + dir.z;
|
|
|
|
|
const key = `${newX},${newZ}`;
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
!visited.has(key) &&
|
|
|
|
|
this.grid.isValidBounds({ x: newX, y: 0, z: newZ })
|
|
|
|
|
) {
|
|
|
|
|
visited.add(key);
|
|
|
|
|
// Use the walkable Y we found as reference for next position
|
|
|
|
|
queue.push({
|
|
|
|
|
x: newX,
|
|
|
|
|
z: newZ,
|
|
|
|
|
y: walkableY,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Convert Set to array of Position objects
|
|
|
|
|
return Array.from(reachable).map((key) => {
|
|
|
|
|
const [x, y, z] = key.split(",").map(Number);
|
|
|
|
|
return { x, y, z };
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Validates if a move is possible.
|
|
|
|
|
* CoA 1: Validation - checks blocked/occupied, path exists, sufficient AP
|
|
|
|
|
* @param {Unit} unit - The unit attempting to move
|
|
|
|
|
* @param {Position} targetPos - Target position
|
|
|
|
|
* @returns {{ valid: boolean; cost: number; path: Position[] }} - Validation result
|
|
|
|
|
*/
|
|
|
|
|
validateMove(unit, targetPos) {
|
|
|
|
|
if (!this.grid || !unit.position) {
|
|
|
|
|
return { valid: false, cost: 0, path: [] };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Find walkable Y level
|
2025-12-28 00:54:03 +00:00
|
|
|
const walkableY = this.findWalkableY(targetPos.x, targetPos.z, targetPos.y);
|
2025-12-24 00:22:32 +00:00
|
|
|
if (walkableY === null) {
|
|
|
|
|
return { valid: false, cost: 0, path: [] };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const finalTargetPos = { x: targetPos.x, y: walkableY, z: targetPos.z };
|
|
|
|
|
|
|
|
|
|
// Check if target is blocked/occupied
|
|
|
|
|
if (this.grid.isOccupied(finalTargetPos)) {
|
|
|
|
|
return { valid: false, cost: 0, path: [] };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if target is reachable (path exists)
|
|
|
|
|
const reachableTiles = this.getReachableTiles(unit);
|
|
|
|
|
const isReachable = reachableTiles.some(
|
|
|
|
|
(pos) =>
|
|
|
|
|
pos.x === finalTargetPos.x &&
|
|
|
|
|
pos.y === finalTargetPos.y &&
|
|
|
|
|
pos.z === finalTargetPos.z
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (!isReachable) {
|
|
|
|
|
return { valid: false, cost: 0, path: [] };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Calculate movement cost (horizontal Manhattan distance)
|
|
|
|
|
const horizontalDistance =
|
|
|
|
|
Math.abs(finalTargetPos.x - unit.position.x) +
|
|
|
|
|
Math.abs(finalTargetPos.z - unit.position.z);
|
|
|
|
|
const movementCost = Math.max(1, horizontalDistance);
|
|
|
|
|
|
|
|
|
|
// Check if unit has sufficient AP
|
|
|
|
|
if (unit.currentAP < movementCost) {
|
|
|
|
|
return { valid: false, cost: movementCost, path: [] };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build simple path (straight line for now, could use A* later)
|
|
|
|
|
const path = [unit.position, finalTargetPos];
|
|
|
|
|
|
|
|
|
|
return { valid: true, cost: movementCost, path };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Executes a move for a unit.
|
|
|
|
|
* CoA 2: Execution - updates position, grid, deducts AP
|
|
|
|
|
* @param {Unit} unit - The unit to move
|
|
|
|
|
* @param {Position} targetPos - Target position
|
|
|
|
|
* @returns {Promise<boolean>} - True if move was successful
|
|
|
|
|
*/
|
|
|
|
|
async executeMove(unit, targetPos) {
|
|
|
|
|
// Validate first
|
|
|
|
|
const validation = this.validateMove(unit, targetPos);
|
|
|
|
|
if (!validation.valid) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Find walkable Y level
|
2025-12-28 00:54:03 +00:00
|
|
|
const walkableY = this.findWalkableY(targetPos.x, targetPos.z, targetPos.y);
|
2025-12-24 00:22:32 +00:00
|
|
|
if (walkableY === null) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const finalTargetPos = { x: targetPos.x, y: walkableY, z: targetPos.z };
|
|
|
|
|
|
|
|
|
|
// Update grid occupancy
|
|
|
|
|
if (!this.grid.moveUnit(unit, finalTargetPos)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Deduct AP
|
|
|
|
|
unit.currentAP -= validation.cost;
|
|
|
|
|
|
|
|
|
|
// Return immediately (no animation for now)
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Checks if a move is valid (convenience method).
|
|
|
|
|
* @param {Unit} unit - The unit attempting to move
|
|
|
|
|
* @param {Position} targetPos - Target position
|
|
|
|
|
* @returns {boolean} - True if move is valid
|
|
|
|
|
*/
|
|
|
|
|
isValidMove(unit, targetPos) {
|
|
|
|
|
return this.validateMove(unit, targetPos).valid;
|
|
|
|
|
}
|
|
|
|
|
}
|