Add comprehensive tests for the InventoryManager and InventoryContainer to validate item management functionalities. Implement integration tests for the CharacterSheet component, ensuring proper interaction with the inventory system. Update the Explorer class to support new inventory features and maintain backward compatibility. Refactor related components for improved clarity and performance.
272 lines
8.5 KiB
JavaScript
272 lines
8.5 KiB
JavaScript
/**
|
|
* @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: [] };
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|