aether-shards/src/systems/MovementSystem.js

284 lines
9.1 KiB
JavaScript
Raw Normal View History

/**
* @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.
* Takes into account the unit's current AP, not just base movement stat.
* @param {Unit} unit - The unit to calculate movement for
* @param {number} maxRange - Maximum movement range (overrides AP calculation if provided)
* @returns {Position[]} - Array of reachable positions
*/
getReachableTiles(unit, maxRange = null) {
if (!this.grid || !unit.position) return [];
const start = unit.position;
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);
const reachable = new Set();
const queue = [{ x: start.x, z: start.z, y: start.y }];
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 } = 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}`;
// 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) {
reachable.add(posKey);
}
// 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) {
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
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: [] };
}
// Calculate movement cost first (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 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 not reachable, check if it's due to insufficient AP or out of range
if (!isReachable) {
// Check if it's within movement range but just not affordable
const baseMovement = unit.baseStats?.movement || 4;
const inMovementRange = horizontalDistance <= baseMovement;
// If within movement range but not reachable, it's likely due to insufficient AP
if (inMovementRange && unit.currentAP < movementCost) {
return { valid: false, cost: movementCost, path: [] };
}
// Otherwise, it's out of range
return { valid: false, cost: 0, path: [] };
}
// 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;
}
}