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)
|
|
|
|
|
const yLevels = [referenceY, referenceY + 1, referenceY - 1, referenceY - 2];
|
|
|
|
|
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.
|
|
|
|
|
* @param {Unit} unit - The unit to calculate movement for
|
|
|
|
|
* @param {number} maxRange - Maximum movement range
|
|
|
|
|
* @returns {Position[]} - Array of reachable positions
|
|
|
|
|
*/
|
|
|
|
|
getReachableTiles(unit, maxRange = null) {
|
|
|
|
|
if (!this.grid || !unit.position) return [];
|
|
|
|
|
|
|
|
|
|
const movementRange = maxRange || unit.baseStats?.movement || 4;
|
|
|
|
|
const start = unit.position;
|
|
|
|
|
const reachable = new Set();
|
|
|
|
|
const queue = [{ x: start.x, z: start.z, y: start.y, distance: 0 }];
|
|
|
|
|
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) {
|
|
|
|
|
const { x, z, y, distance } = queue.shift();
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
|
|
// Check if position is not occupied (or is the starting position)
|
|
|
|
|
// Starting position is always reachable (unit is already there)
|
2025-12-24 04:28:12 +00:00
|
|
|
const isStartPos = x === start.x && z === start.z && walkableY === start.y;
|
2025-12-24 00:22:32 +00:00
|
|
|
if (!this.grid.isOccupied(pos) || isStartPos) {
|
|
|
|
|
reachable.add(posKey);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Explore neighbors if we haven't reached max range
|
|
|
|
|
if (distance < movementRange) {
|
|
|
|
|
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,
|
|
|
|
|
distance: distance + 1,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
const walkableY = this.findWalkableY(
|
|
|
|
|
targetPos.x,
|
|
|
|
|
targetPos.z,
|
|
|
|
|
targetPos.y
|
|
|
|
|
);
|
|
|
|
|
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
|
|
|
|
|
const walkableY = this.findWalkableY(
|
|
|
|
|
targetPos.x,
|
|
|
|
|
targetPos.z,
|
|
|
|
|
targetPos.y
|
|
|
|
|
);
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|