Introduce the EffectProcessor class to manage game state changes through various effects, including damage, healing, and status application. Define type specifications for effects, conditions, and passive abilities in Effects.d.ts. Add a comprehensive JSON registry for passive skills and item effects, enhancing gameplay dynamics. Update the GameLoop and TurnSystem to integrate the EffectProcessor, ensuring proper handling of environmental hazards and passive effects during combat. Enhance testing coverage for the EffectProcessor and environmental interactions to validate functionality and performance.
283 lines
9.1 KiB
JavaScript
283 lines
9.1 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: [] };
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|