/** * @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 }; // Use walkableY in the key, not the reference y const posKey = `${x},${walkableY},${z}`; // Check if position is not occupied (or is the starting position) // Starting position is always reachable (unit is already there) const isStartPos = x === start.x && z === start.z && walkableY === start.y; 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} - 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; } }