aether-shards/src/core/GameLoop.js
Matthew Mone 06389903c5 Enhance GameLoop and MissionManager integration for unit death handling and mission events
Refactor the GameLoop to implement unit death handling through the new handleUnitDeath method, which removes units from the grid and dispatches relevant events to the MissionManager. Set up MissionManager references and event listeners for mission victory and failure. Update the MissionManager to manage secondary objectives and failure conditions, ensuring proper tracking of game events like unit deaths and turn ends. This integration improves gameplay dynamics and mission management.
2025-12-30 20:56:41 -08:00

2610 lines
88 KiB
JavaScript

/**
* @typedef {import("./types.js").RunData} RunData
* @typedef {import("../grid/types.js").Position} Position
* @typedef {import("../units/Unit.js").Unit} Unit
* @typedef {import("../ui/combat-hud.d.ts").CombatState} CombatState
* @typedef {import("../ui/combat-hud.d.ts").UnitStatus} UnitStatus
* @typedef {import("../ui/combat-hud.d.ts").QueueEntry} QueueEntry
*/
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { VoxelGrid } from "../grid/VoxelGrid.js";
import { VoxelManager } from "../grid/VoxelManager.js";
import { UnitManager } from "../managers/UnitManager.js";
import { CaveGenerator } from "../generation/CaveGenerator.js";
import { RuinGenerator } from "../generation/RuinGenerator.js";
import { InputManager } from "./InputManager.js";
import { MissionManager } from "../managers/MissionManager.js";
import { TurnSystem } from "../systems/TurnSystem.js";
import { MovementSystem } from "../systems/MovementSystem.js";
import { SkillTargetingSystem } from "../systems/SkillTargetingSystem.js";
import { EffectProcessor } from "../systems/EffectProcessor.js";
import { SeededRandom } from "../utils/SeededRandom.js";
import { skillRegistry } from "../managers/SkillRegistry.js";
import { InventoryManager } from "../managers/InventoryManager.js";
import { InventoryContainer } from "../models/InventoryContainer.js";
import { itemRegistry } from "../managers/ItemRegistry.js";
// Import class definitions
import vanguardDef from "../assets/data/classes/vanguard.json" with { type: "json" };
import weaverDef from "../assets/data/classes/aether_weaver.json" with { type: "json" };
import scavengerDef from "../assets/data/classes/scavenger.json" with { type: "json" };
import tinkerDef from "../assets/data/classes/tinker.json" with { type: "json" };
import custodianDef from "../assets/data/classes/custodian.json" with { type: "json" };
/**
* Main game loop managing rendering, input, and game state.
* @class
*/
export class GameLoop {
constructor() {
/** @type {boolean} */
this.isRunning = false;
/** @type {Object|null} Cached skill tree template */
this._skillTreeTemplate = null;
/** @type {number | null} */
this.animationFrameId = null;
/** @type {boolean} */
this.isPaused = false;
// 1. Core Systems
/** @type {THREE.Scene} */
this.scene = new THREE.Scene();
/** @type {THREE.PerspectiveCamera | null} */
this.camera = null;
/** @type {THREE.WebGLRenderer | null} */
this.renderer = null;
/** @type {OrbitControls | null} */
this.controls = null;
/** @type {InputManager | null} */
this.inputManager = null;
/** @type {VoxelGrid | null} */
this.grid = null;
/** @type {VoxelManager | null} */
this.voxelManager = null;
/** @type {UnitManager | null} */
this.unitManager = null;
// Combat Logic Systems
/** @type {TurnSystem | null} */
this.turnSystem = null;
/** @type {MovementSystem | null} */
this.movementSystem = null;
/** @type {SkillTargetingSystem | null} */
this.skillTargetingSystem = null;
/** @type {EffectProcessor | null} */
this.effectProcessor = null;
// Inventory System
/** @type {InventoryManager | null} */
this.inventoryManager = null;
// AbortController for cleaning up event listeners
/** @type {AbortController | null} */
this.turnSystemAbortController = null;
/** @type {Map<string, THREE.Mesh>} */
this.unitMeshes = new Map();
/** @type {Set<THREE.Mesh>} */
this.movementHighlights = new Set();
/** @type {Set<THREE.Mesh>} */
this.spawnZoneHighlights = new Set();
/** @type {Set<THREE.Mesh>} */
this.rangeHighlights = new Set();
/** @type {Set<THREE.Mesh>} */
this.aoeReticle = new Set();
/** @type {RunData | null} */
this.runData = null;
/** @type {Position[]} */
this.playerSpawnZone = [];
/** @type {Position[]} */
this.enemySpawnZone = [];
// Input Logic State
/** @type {number} */
this.lastMoveTime = 0;
/** @type {number} */
this.moveCooldown = 120; // ms between cursor moves
/** @type {"MOVEMENT" | "TARGETING"} */
this.selectionMode = "MOVEMENT"; // MOVEMENT, TARGETING
/** @type {MissionManager} */
this.missionManager = new MissionManager(this); // Init Mission Manager
// Deployment State
/** @type {{ selectedUnitIndex: number; deployedUnits: Map<number, Unit> }} */
this.deploymentState = {
selectedUnitIndex: -1,
deployedUnits: new Map(), // Map<Index, UnitInstance>
};
/** @type {import("./GameStateManager.js").GameStateManagerClass | null} */
this.gameStateManager = null;
// Skill Targeting State
/** @type {"IDLE" | "SELECTING_MOVE" | "TARGETING_SKILL" | "EXECUTING_SKILL"} */
this.combatState = "IDLE";
/** @type {string | null} */
this.activeSkillId = null;
}
/**
* Initializes the game loop with Three.js setup.
* @param {HTMLElement} container - DOM element to attach the renderer to
*/
init(container) {
// Setup Three.js
this.camera = new THREE.PerspectiveCamera(
45,
window.innerWidth / window.innerHeight,
0.1,
1000
);
this.camera.position.set(20, 20, 20);
this.camera.lookAt(0, 0, 0);
this.renderer = new THREE.WebGLRenderer({ antialias: true });
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.setClearColor(0x111111);
container.appendChild(this.renderer.domElement);
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.05;
// --- INSTANTIATE COMBAT SYSTEMS ---
this.turnSystem = new TurnSystem();
this.movementSystem = new MovementSystem();
// SkillTargetingSystem will be initialized in startLevel when grid/unitManager are ready
// --- INITIALIZE INVENTORY SYSTEM ---
// Create stashes (InventoryManager will be initialized in startLevel after itemRegistry loads)
const runStash = new InventoryContainer("RUN_LOOT");
const hubStash = new InventoryContainer("HUB_VAULT");
// Initialize InventoryManager with itemRegistry (will load items in startLevel)
this.inventoryManager = new InventoryManager(itemRegistry, runStash, hubStash);
// --- SETUP INPUT MANAGER ---
this.inputManager = new InputManager(
this.camera,
this.scene,
this.renderer.domElement
);
// Bind Buttons (Events)
this.inputManager.addEventListener("gamepadbuttondown", (e) =>
this.handleButtonInput(e.detail)
);
this.inputManager.addEventListener("keydown", (e) =>
this.handleKeyInput(e.detail)
);
this.inputManager.addEventListener("hover", (e) =>
this.onCursorHover(e.detail.voxelPosition)
);
// Default Validator: Movement Logic (Will be overridden in startLevel)
this.inputManager.setValidator(this.validateCursorMove.bind(this));
const ambient = new THREE.AmbientLight(0xffffff, 0.6);
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
dirLight.position.set(10, 20, 10);
this.scene.add(ambient);
this.scene.add(dirLight);
window.addEventListener("resize", () => {
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
});
this.animate = this.animate.bind(this);
}
/**
* Validation Logic for Standard Movement.
* @param {number} x - X coordinate
* @param {number} y - Y coordinate
* @param {number} z - Z coordinate
* @returns {false | Position} - False if invalid, or adjusted position object
*/
validateCursorMove(x, y, z) {
if (!this.grid) return true; // Allow if grid not ready
// 1. Basic Bounds Check
if (!this.grid.isValidBounds({ x, y: 0, z })) return false;
// 2. Scan Column for Surface (Climb/Drop Logic)
let bestY = null;
if (this.isWalkable(x, y, z)) bestY = y;
else if (this.isWalkable(x, y + 1, z)) bestY = y + 1;
else if (this.isWalkable(x, y - 1, z)) bestY = y - 1;
else if (this.isWalkable(x, y - 2, z)) bestY = y - 2;
if (bestY !== null) {
return { x, y: bestY, z };
}
return false;
}
/**
* Validation Logic for Deployment Phase.
* @param {number} x - X coordinate
* @param {number} y - Y coordinate
* @param {number} z - Z coordinate
* @returns {false | Position} - False if invalid, or valid spawn position
*/
validateDeploymentCursor(x, y, z) {
if (!this.grid || this.playerSpawnZone.length === 0) return false;
// Check if the target X,Z is inside the spawn zone list
const validSpot = this.playerSpawnZone.find((t) => t.x === x && t.z === z);
if (validSpot) {
// Snap Y to the valid floor height defined in the zone
return { x: validSpot.x, y: validSpot.y, z: validSpot.z };
}
return false; // Cursor cannot leave the spawn zone
}
/**
* 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
*/
isWalkable(x, y, z) {
if (this.grid.getCell(x, y, z) !== 0) return false;
if (this.grid.getCell(x, y - 1, z) === 0) return false;
if (this.grid.getCell(x, y + 1, z) !== 0) return false;
return true;
}
/**
* Validates an interaction target position.
* @param {number} x - X coordinate
* @param {number} y - Y coordinate
* @param {number} z - Z coordinate
* @returns {boolean} - True if valid
*/
validateInteractionTarget(x, y, z) {
if (!this.grid) return true;
return this.grid.isValidBounds({ x, y, z });
}
/**
* Handles gamepad button input.
* @param {{ buttonIndex: number; gamepadIndex: number }} detail - Button input detail
*/
handleButtonInput(detail) {
if (detail.buttonIndex === 0) {
// A / Cross
this.triggerSelection();
}
}
/**
* Handles keyboard input.
* @param {string} code - Key code
*/
handleKeyInput(code) {
if (code === "Space" || code === "Enter") {
this.triggerSelection();
}
if (code === "Escape" || code === "KeyB") {
// Cancel skill targeting
if (this.combatState === "TARGETING_SKILL") {
this.cancelSkillTargeting();
}
}
if (code === "Tab") {
this.selectionMode =
this.selectionMode === "MOVEMENT" ? "TARGETING" : "MOVEMENT";
const validator =
this.selectionMode === "MOVEMENT"
? this.validateCursorMove.bind(this)
: this.validateInteractionTarget.bind(this);
this.inputManager.setValidator(validator);
}
if (code === "KeyC") {
// Open character sheet for active unit
this.openCharacterSheet();
}
if (code === "KeyM") {
// Movement mode hotkey
if (this.gameStateManager?.currentState === "STATE_COMBAT") {
this.onMovementClicked();
}
}
// Number key hotkeys for skills (1-5)
if (
this.gameStateManager?.currentState === "STATE_COMBAT" &&
this.turnSystem
) {
const activeUnit = this.turnSystem.getActiveUnit();
if (activeUnit && activeUnit.team === "PLAYER") {
const skills = activeUnit.actions || [];
let skillIndex = -1;
// Map key codes to skill indices (1-5)
if (code === "Digit1" || code === "Numpad1") {
skillIndex = 0;
} else if (code === "Digit2" || code === "Numpad2") {
skillIndex = 1;
} else if (code === "Digit3" || code === "Numpad3") {
skillIndex = 2;
} else if (code === "Digit4" || code === "Numpad4") {
skillIndex = 3;
} else if (code === "Digit5" || code === "Numpad5") {
skillIndex = 4;
}
if (skillIndex >= 0 && skillIndex < skills.length) {
const skill = skills[skillIndex];
if (skill && skill.id) {
this.onSkillClicked(skill.id);
}
}
}
}
}
/**
* Opens the character sheet for the currently active unit.
*/
openCharacterSheet() {
if (!this.turnSystem) return;
const activeUnit = this.turnSystem.getActiveUnit();
if (!activeUnit || activeUnit.team !== "PLAYER") {
// If no active unit or not player unit, try to get first player unit
if (this.unitManager) {
const playerUnits = this.unitManager.getAllUnits().filter((u) => u.team === "PLAYER");
if (playerUnits.length > 0) {
this._dispatchOpenCharacterSheet(playerUnits[0]);
}
}
return;
}
this._dispatchOpenCharacterSheet(activeUnit);
}
/**
* Dispatches open-character-sheet event for a unit.
* @param {Unit|string} unitOrId - Unit object or unit ID
* @private
*/
_dispatchOpenCharacterSheet(unitOrId) {
// Get full unit object if ID was provided
let unit = unitOrId;
if (typeof unitOrId === "string" && this.unitManager) {
unit = this.unitManager.getUnitById(unitOrId);
}
if (!unit) {
console.warn("Cannot open character sheet: unit not found");
return;
}
// Get inventory from runData or empty array
const inventory = this.runData?.inventory || [];
// Determine if read-only (enemy turn or restricted)
const activeUnit = this.turnSystem?.getActiveUnit();
const isReadOnly = this.combatState === "TARGETING_SKILL" ||
(activeUnit && activeUnit.team !== "PLAYER");
window.dispatchEvent(
new CustomEvent("open-character-sheet", {
detail: {
unit: unit,
readOnly: isReadOnly,
inventory: inventory,
},
})
);
}
/**
* Called by UI when a unit is clicked in the Roster.
* @param {number} index - The index of the unit in the squad to select.
*/
selectDeploymentUnit(index) {
this.deploymentState.selectedUnitIndex = index;
console.log(`Deployment: Selected Unit Index ${index}`);
}
/**
* Triggers selection action at cursor position.
*/
triggerSelection() {
const cursor = this.inputManager.getCursorPosition();
console.log("Action at:", cursor);
if (
this.gameStateManager &&
this.gameStateManager.currentState === "STATE_DEPLOYMENT"
) {
const selIndex = this.deploymentState.selectedUnitIndex;
if (selIndex !== -1) {
// Attempt to deploy OR move the selected unit
const unitDef = this.runData.squad[selIndex];
const existingUnit = this.deploymentState.deployedUnits.get(selIndex);
const resultUnit = this.deployUnit(unitDef, cursor, existingUnit);
if (resultUnit) {
// Track it
this.deploymentState.deployedUnits.set(selIndex, resultUnit);
// Notify UI
window.dispatchEvent(
new CustomEvent("deployment-update", {
detail: {
deployedIndices: Array.from(
this.deploymentState.deployedUnits.keys()
),
},
})
);
}
} else {
console.log("No unit selected.");
}
} else if (
this.gameStateManager &&
this.gameStateManager.currentState === "STATE_COMBAT"
) {
// Handle combat actions based on state
if (this.combatState === "TARGETING_SKILL") {
this.handleSkillTargeting(cursor);
} else {
// Default to movement
this.handleCombatMovement(cursor);
}
}
}
/**
* Handles movement in combat state.
* Delegates to MovementSystem.
* @param {Position} targetPos - Target position to move to
*/
async handleCombatMovement(targetPos) {
if (!this.movementSystem || !this.turnSystem) return;
const activeUnit = this.turnSystem.getActiveUnit();
if (!activeUnit || activeUnit.team !== "PLAYER") {
console.log("Not a player's turn or unit not found");
return;
}
// DELEGATE to MovementSystem
const success = await this.movementSystem.executeMove(
activeUnit,
targetPos
);
if (success) {
// Update unit mesh position
const mesh = this.unitMeshes.get(activeUnit.id);
if (mesh) {
// Floor surface is at pos.y - 0.5, unit should be 0.6 above: pos.y + 0.1
mesh.position.set(
activeUnit.position.x,
activeUnit.position.y + 0.1,
activeUnit.position.z
);
}
console.log(
`Moved ${activeUnit.name} to ${activeUnit.position.x},${activeUnit.position.y},${activeUnit.position.z}`
);
// Update combat state and movement highlights
this.updateCombatState().catch(console.error);
// NOTE: Do NOT auto-end turn when AP reaches 0 after movement.
// The player should explicitly click "End Turn" to end their turn.
// Even if the unit has no AP left, they may want to use skills or wait.
}
}
/**
* Handles skill click from CombatHUD.
* Enters TARGETING_SKILL state and shows skill range.
* @param {string} skillId - Skill ID
*/
onSkillClicked(skillId) {
if (!this.turnSystem || !this.skillTargetingSystem) return;
const activeUnit = this.turnSystem.getActiveUnit();
if (!activeUnit || activeUnit.team !== "PLAYER") return;
// If clicking the same skill that's already active, cancel targeting
if (this.combatState === "TARGETING_SKILL" && this.activeSkillId === skillId) {
this.cancelSkillTargeting();
return;
}
// Find skill in unit's actions
const skill = (activeUnit.actions || []).find((a) => a.id === skillId);
if (!skill) {
console.warn(`Skill ${skillId} not found in unit actions`);
return;
}
// Validate unit has AP
if (activeUnit.currentAP < (skill.costAP || 0)) {
console.log("Insufficient AP");
return;
}
// Enter targeting mode
this.combatState = "TARGETING_SKILL";
this.activeSkillId = skillId;
// Clear movement highlights and show skill range (only valid targets)
this.clearMovementHighlights();
const skillDef = this.skillTargetingSystem.getSkillDef(skillId);
if (skillDef && this.voxelManager && this.skillTargetingSystem) {
// Check if this is a teleport skill with unlimited range (range = -1)
const isTeleportSkill = skillDef.effects && skillDef.effects.some((e) => e.type === "TELEPORT");
const hasUnlimitedRange = skillDef.range === -1 || skillDef.range === Infinity;
let allTilesInRange = [];
if (isTeleportSkill && hasUnlimitedRange) {
// For teleport with unlimited range, scan all valid tiles in the grid
// Range is limited only by line of sight
if (this.grid && this.grid.size) {
for (let x = 0; x < this.grid.size.x; x++) {
for (let y = 0; y < this.grid.size.y; y++) {
for (let z = 0; z < this.grid.size.z; z++) {
if (this.grid.isValidBounds({ x, y, z })) {
allTilesInRange.push({ x, y, z });
}
}
}
}
}
} else {
// For normal skills, get tiles within range
for (let x = activeUnit.position.x - skillDef.range; x <= activeUnit.position.x + skillDef.range; x++) {
for (let y = activeUnit.position.y - skillDef.range; y <= activeUnit.position.y + skillDef.range; y++) {
for (let z = activeUnit.position.z - skillDef.range; z <= activeUnit.position.z + skillDef.range; z++) {
const dist =
Math.abs(x - activeUnit.position.x) +
Math.abs(y - activeUnit.position.y) +
Math.abs(z - activeUnit.position.z);
if (dist <= skillDef.range) {
// Check if position is valid bounds
if (this.grid && this.grid.isValidBounds({ x, y, z })) {
allTilesInRange.push({ x, y, z });
}
}
}
}
}
}
// Filter to only valid targets using validation and collect obstruction data
const validTilesWithObstruction = [];
allTilesInRange.forEach((tilePos) => {
const validation = this.skillTargetingSystem.validateTarget(
activeUnit,
tilePos,
skillId
);
if (validation.valid) {
validTilesWithObstruction.push({
pos: tilePos,
obstruction: validation.obstruction || 0
});
}
});
// Highlight only valid targets with obstruction-based dimming
this.voxelManager.highlightTilesWithObstruction(validTilesWithObstruction, "RED_OUTLINE");
}
// Update combat state to refresh UI (show cancel button)
this.updateCombatState().catch(console.error);
console.log(`Entering targeting mode for skill: ${skillId}`);
}
/**
* Handles skill targeting when in TARGETING_SKILL state.
* Validates target and executes skill if valid.
* @param {Position} targetPos - Target position
*/
handleSkillTargeting(targetPos) {
if (!this.turnSystem || !this.skillTargetingSystem || !this.activeSkillId) {
return;
}
const activeUnit = this.turnSystem.getActiveUnit();
if (!activeUnit || activeUnit.team !== "PLAYER") {
return;
}
// Validate target
const validation = this.skillTargetingSystem.validateTarget(
activeUnit,
targetPos,
this.activeSkillId
);
if (validation.valid) {
this.executeSkill(this.activeSkillId, targetPos);
} else {
// Audio: Error Buzz
console.log(`Invalid target: ${validation.reason}`);
}
}
/**
* Executes a skill at the target position.
* Deducts costs, processes effects, and cleans up.
* @param {string} skillId - Skill ID
* @param {Position} targetPos - Target position
*/
async executeSkill(skillId, targetPos) {
if (!this.turnSystem || !this.skillTargetingSystem) return;
const activeUnit = this.turnSystem.getActiveUnit();
if (!activeUnit) return;
this.combatState = "EXECUTING_SKILL";
// 1. Deduct Costs (AP, Cooldown)
const skill = (activeUnit.actions || []).find((a) => a.id === skillId);
if (skill) {
activeUnit.currentAP -= skill.costAP || 0;
if (skill.cooldown !== undefined) {
skill.cooldown = (skill.cooldown || 0) + 1; // Set cooldown
}
}
// 2. Get Targets (Units in AoE)
let targets = this.skillTargetingSystem.getUnitsInAoE(
activeUnit.position,
targetPos,
skillId
);
console.log(`AoE found ${targets.length} targets at ${targetPos.x},${targetPos.y},${targetPos.z}`);
if (targets.length > 0) {
targets.forEach((t) => {
console.log(` - Target: ${t.name} at ${t.position.x},${t.position.y},${t.position.z}`);
});
}
// Fallback: If no targets found but there's a unit at the target position, include it
// This handles cases where the AoE calculation might miss the exact target
if (targets.length === 0 && this.grid) {
const unitAtTarget = this.grid.getUnitAt(targetPos);
if (unitAtTarget) {
targets = [unitAtTarget];
console.log(`Fallback: Added unit at target position: ${unitAtTarget.name}`);
}
}
// 3. Process Effects using EffectProcessor
const skillDef = this.skillTargetingSystem.getSkillDef(skillId);
// Process ON_SKILL_CAST passive effects
if (skillDef) {
this.processPassiveItemEffects(activeUnit, "ON_SKILL_CAST", {
skillId: skillId,
skillDef: skillDef,
});
}
if (skillDef && skillDef.effects && this.effectProcessor) {
for (const effect of skillDef.effects) {
// Special handling for TELEPORT - teleports the source unit, not targets
if (effect.type === "TELEPORT") {
// Check line of sight - if obstructed, teleport has a chance to fail
const losResult = this.skillTargetingSystem.hasLineOfSight(
activeUnit.position,
targetPos,
skillDef.ignore_cover || false
);
// Calculate failure chance based on obstruction level
// Obstruction of 0 = 0% failure, obstruction of 1.0 = 100% failure
const failureChance = losResult.obstruction || 0;
if (Math.random() < failureChance) {
console.warn(
`${activeUnit.name}'s teleport failed due to obstructed line of sight! (${Math.round(failureChance * 100)}% obstruction)`
);
// Teleport failed - unit stays in place, but AP was already deducted
// Could optionally refund AP here, but for now we'll just log the failure
continue; // Skip teleport execution
}
// Find walkable Y level for target position
let walkableY = targetPos.y;
if (this.movementSystem) {
const foundY = this.movementSystem.findWalkableY(
targetPos.x,
targetPos.z,
targetPos.y
);
if (foundY !== null) {
walkableY = foundY;
} else {
// No walkable position found - teleport fails
console.warn(
`${activeUnit.name}'s teleport failed: target position is not walkable`
);
continue; // Skip teleport execution
}
}
const teleportDestination = { x: targetPos.x, y: walkableY, z: targetPos.z };
// Process teleport effect - source unit is teleported to destination
const result = this.effectProcessor.process(effect, activeUnit, teleportDestination);
if (result.success && result.data) {
// Update unit mesh position after teleport
const mesh = this.unitMeshes.get(activeUnit.id);
if (mesh) {
// Floor surface is at pos.y - 0.5, unit should be 0.6 above: pos.y + 0.1
mesh.position.set(
activeUnit.position.x,
activeUnit.position.y + 0.1,
activeUnit.position.z
);
}
console.log(
`Teleported ${activeUnit.name} to ${activeUnit.position.x},${activeUnit.position.y},${activeUnit.position.z}`
);
} else {
console.warn(`Teleport failed: ${result.error || "Unknown error"}`);
}
continue; // Skip normal target processing for TELEPORT
}
// Process effect for all targets (for non-TELEPORT effects)
for (const target of targets) {
if (!target) continue;
// Check if unit is alive
if (typeof target.isAlive === "function" && !target.isAlive()) continue;
if (target.currentHealth <= 0) continue;
// Process ON_SKILL_HIT passive effects (before processing effect)
this.processPassiveItemEffects(activeUnit, "ON_SKILL_HIT", {
skillId: skillId,
skillDef: skillDef,
target: target,
effect: effect,
});
// Process effect through EffectProcessor
const result = this.effectProcessor.process(effect, activeUnit, target);
if (result.success) {
// Log success messages based on effect type
if (result.data) {
if (result.data.type === "DAMAGE") {
console.log(
`${activeUnit.name} dealt ${result.data.amount} damage to ${target.name} (${result.data.currentHP}/${target.maxHealth} HP)`
);
if (result.data.currentHP <= 0) {
console.log(`${target.name} has been defeated!`);
// Process ON_KILL passive effects (on source)
this.processPassiveItemEffects(activeUnit, "ON_KILL", {
target: target,
killedUnit: target,
});
// Handle unit death
this.handleUnitDeath(target);
}
// Process passive item effects for ON_DAMAGED trigger (on target)
this.processPassiveItemEffects(target, "ON_DAMAGED", {
source: activeUnit,
damageAmount: result.data.amount,
});
// Process passive item effects for ON_DAMAGE_DEALT trigger (on source)
this.processPassiveItemEffects(activeUnit, "ON_DAMAGE_DEALT", {
target: target,
damageAmount: result.data.amount,
});
} else if (result.data.type === "HEAL") {
if (result.data.amount > 0) {
console.log(
`${activeUnit.name} healed ${target.name} for ${result.data.amount} HP (${result.data.currentHP}/${target.maxHealth} HP)`
);
}
// Process passive item effects for ON_HEAL_DEALT trigger (on source)
this.processPassiveItemEffects(activeUnit, "ON_HEAL_DEALT", {
target: target,
healAmount: result.data.amount,
});
} else if (result.data.type === "APPLY_STATUS") {
console.log(
`${activeUnit.name} applied ${result.data.statusId} to ${target.name} for ${result.data.duration} turns`
);
} else if (result.data.type === "CHAIN_DAMAGE") {
// Log chain damage results
if (result.data.results && result.data.results.length > 0) {
const primaryResult = result.data.results[0];
console.log(
`${activeUnit.name} dealt ${primaryResult.amount} damage to ${target.name} (chain lightning)`
);
if (result.data.chainTargets && result.data.chainTargets.length > 0) {
console.log(
`Chain lightning bounced to ${result.data.chainTargets.length} additional targets`
);
}
}
}
}
} else {
// Log warnings for failed effects (but don't block other effects)
if (result.error && result.error !== "Conditions not met") {
console.warn(`Effect ${effect.type} failed: ${result.error}`);
}
}
}
}
}
console.log(
`Executed skill ${skillId} at ${targetPos.x},${targetPos.y},${targetPos.z}, hit ${targets.length} targets`
);
// 4. Cleanup
this.combatState = "IDLE";
this.activeSkillId = null;
// Clear skill highlights
if (this.voxelManager) {
this.voxelManager.clearHighlights();
}
// Restore movement highlights if we have an active unit
if (this.turnSystem) {
const activeUnit = this.turnSystem.getActiveUnit();
if (activeUnit && activeUnit.team === "PLAYER") {
this.updateMovementHighlights(activeUnit);
}
}
// Update combat state
this.updateCombatState().catch(console.error);
}
/**
* Handles cursor hover to update AoE preview when targeting skills.
* @param {THREE.Vector3} pos - Cursor position
*/
onCursorHover(pos) {
if (
this.combatState === "TARGETING_SKILL" &&
this.activeSkillId &&
this.turnSystem &&
this.skillTargetingSystem &&
this.voxelManager
) {
const activeUnit = this.turnSystem.getActiveUnit();
if (activeUnit) {
const cursorPos = { x: pos.x, y: pos.y, z: pos.z };
const aoeTiles = this.skillTargetingSystem.getAoETiles(
activeUnit.position,
cursorPos,
this.activeSkillId
);
// Show AoE reticle
this.voxelManager.showReticle(aoeTiles);
}
}
}
/**
* Cancels skill targeting and returns to IDLE state.
*/
cancelSkillTargeting() {
this.combatState = "IDLE";
this.activeSkillId = null;
// Clear skill highlights
if (this.voxelManager) {
this.voxelManager.clearHighlights();
}
// Restore movement highlights if we have an active unit
if (this.turnSystem) {
const activeUnit = this.turnSystem.getActiveUnit();
if (activeUnit && activeUnit.team === "PLAYER") {
this.updateMovementHighlights(activeUnit);
}
}
// Update combat state to refresh UI
this.updateCombatState().catch(console.error);
}
/**
* Handles movement button click from CombatHUD.
* Returns to movement mode from skill targeting.
*/
onMovementClicked() {
if (!this.turnSystem) return;
const activeUnit = this.turnSystem.getActiveUnit();
if (!activeUnit || activeUnit.team !== "PLAYER") return;
// If we're in skill targeting mode, cancel it and return to movement
if (this.combatState === "TARGETING_SKILL") {
this.cancelSkillTargeting();
} else {
// If already in movement mode, ensure movement highlights are shown
this.updateMovementHighlights(activeUnit);
// Update combat state to refresh UI
this.updateCombatState().catch(console.error);
}
}
/**
* Starts a mission by ID.
* @param {string} missionId - Mission identifier
* @returns {Promise<void>}
*/
async startMission(missionId) {
const mission = await fetch(
`assets/data/missions/${missionId.toLowerCase()}.json`
);
const missionData = await mission.json();
this.missionManager.startMission(missionData);
}
/**
* Starts a level with the given run data.
* @param {RunData} runData - Run data containing mission and squad info
* @param {Object} [options] - Optional configuration
* @param {boolean} [options.startAnimation=true] - Whether to start the animation loop
* @returns {Promise<void>}
*/
async startLevel(runData, options = {}) {
console.log("GameLoop: Generating Level...");
this.runData = runData;
this.isRunning = true;
this.clearUnitMeshes();
this.clearMovementHighlights();
this.clearSpawnZoneHighlights();
// Reset Deployment State
this.deploymentState = {
selectedUnitIndex: -1,
deployedUnits: new Map(), // Map<Index, UnitInstance>
};
this.grid = new VoxelGrid(20, 10, 20);
const generator = new RuinGenerator(this.grid, runData.seed);
generator.generate();
if (generator.generatedAssets.spawnZones) {
this.playerSpawnZone = generator.generatedAssets.spawnZones.player || [];
this.enemySpawnZone = generator.generatedAssets.spawnZones.enemy || [];
}
if (this.playerSpawnZone.length === 0)
this.playerSpawnZone.push({ x: 2, y: 1, z: 2 });
if (this.enemySpawnZone.length === 0)
this.enemySpawnZone.push({ x: 18, y: 1, z: 18 });
this.voxelManager = new VoxelManager(this.grid, this.scene);
this.voxelManager.updateMaterials(generator.generatedAssets);
this.voxelManager.update();
// Set up highlight tracking sets
this.voxelManager.setHighlightSets(this.rangeHighlights, this.aoeReticle);
if (this.controls) this.voxelManager.focusCamera(this.controls);
// Create a proper registry with actual class definitions
const classRegistry = new Map();
// Register all class definitions
const classDefs = [
vanguardDef,
weaverDef,
scavengerDef,
tinkerDef,
custodianDef,
];
for (const classDef of classDefs) {
if (classDef && classDef.id) {
// Add type field for compatibility
classRegistry.set(classDef.id, {
...classDef,
type: "EXPLORER",
});
}
}
// Create registry object with get method for UnitManager
const unitRegistry = {
get: (id) => {
// Try to get from class registry first
if (classRegistry.has(id)) {
return classRegistry.get(id);
}
// Fallback for enemy units
if (id.startsWith("ENEMY_")) {
return {
type: "ENEMY",
name: "Enemy",
stats: { health: 50, attack: 8, defense: 3, speed: 8 },
ai_archetype: "BRUISER",
};
}
console.warn(`Unit definition not found: ${id}`);
return null;
},
};
this.unitManager = new UnitManager(unitRegistry);
// Store classRegistry reference for accessing class definitions later
this.classRegistry = classRegistry;
// WIRING: Connect Systems to Data
this.movementSystem.setContext(this.grid, this.unitManager);
this.turnSystem.setContext(this.unitManager);
// Initialize EffectProcessor with grid, unitManager, and optional RNG (using seed from runData)
this.effectProcessor = new EffectProcessor(
this.grid,
this.unitManager,
runData.seed ? new SeededRandom(runData.seed) : null
);
// Set hazard context for TurnSystem (for environmental hazard processing)
this.turnSystem.setHazardContext(this.grid, this.effectProcessor);
// Load skills and initialize SkillTargetingSystem
// Skip skill loading in test mode (when startAnimation is false) to avoid fetch timeouts
if (options.startAnimation !== false && skillRegistry.skills.size === 0) {
await skillRegistry.loadAll();
}
this.skillTargetingSystem = new SkillTargetingSystem(
this.grid,
this.unitManager,
skillRegistry
);
// Load items for InventoryManager
if (options.startAnimation !== false && itemRegistry.items.size === 0) {
await itemRegistry.loadAll();
}
// WIRING: Listen for Turn Changes (to update UI/Input state)
// Create new AbortController for this level - when aborted, listeners are automatically removed
this.turnSystemAbortController = new AbortController();
const signal = this.turnSystemAbortController.signal;
this.turnSystem.addEventListener("turn-start", (e) => this._onTurnStart(e.detail), { signal });
this.turnSystem.addEventListener("turn-end", (e) => this._onTurnEnd(e.detail), { signal });
this.turnSystem.addEventListener("combat-start", () => this._onCombatStart(), { signal });
this.turnSystem.addEventListener("combat-end", () => this._onCombatEnd(), { signal });
this.highlightZones();
if (this.playerSpawnZone.length > 0) {
let sumX = 0,
sumY = 0,
sumZ = 0;
for (const spot of this.playerSpawnZone) {
sumX += spot.x;
sumY += spot.y;
sumZ += spot.z;
}
const centerX = sumX / this.playerSpawnZone.length;
const centerY = sumY / this.playerSpawnZone.length;
const centerZ = sumZ / this.playerSpawnZone.length;
const start = this.playerSpawnZone[0];
this.inputManager.setCursor(start.x, start.y, start.z);
if (this.controls) {
this.controls.target.set(centerX, centerY, centerZ);
this.controls.update();
}
}
this.inputManager.setValidator(this.validateDeploymentCursor.bind(this));
// Only start animation loop if explicitly requested (default true for normal usage)
if (options.startAnimation !== false) {
this.animate();
}
}
/**
* Deploys or moves a unit to a target tile.
* @param {import("./types.js").SquadMember} unitDef - Unit definition
* @param {Position} targetTile - Target position
* @param {Unit | null} [existingUnit] - Existing unit to move, or null to create new
* @returns {Unit | null} - The deployed/moved unit, or null if failed
*/
deployUnit(unitDef, targetTile, existingUnit = null) {
if (
!this.gameStateManager ||
this.gameStateManager.currentState !== "STATE_DEPLOYMENT"
)
return null;
const isValid = this.validateDeploymentCursor(
targetTile.x,
targetTile.y,
targetTile.z
);
// Check collision
if (!isValid) {
console.warn("Invalid spawn zone");
return null;
}
// If tile occupied...
if (this.grid.isOccupied(targetTile)) {
// If occupied by SELF (clicking same spot), that's valid, just do nothing
if (
existingUnit &&
existingUnit.position.x === targetTile.x &&
existingUnit.position.z === targetTile.z
) {
return existingUnit;
}
console.warn("Tile occupied");
return null;
}
if (existingUnit) {
// MOVE logic
this.grid.moveUnit(existingUnit, targetTile, { force: true }); // Force to bypass standard move checks if any
// Update Mesh
const mesh = this.unitMeshes.get(existingUnit.id);
if (mesh) {
// Floor surface is at pos.y - 0.5, unit should be 0.6 above: pos.y + 0.1
mesh.position.set(targetTile.x, targetTile.y + 0.1, targetTile.z);
}
console.log(
`Moved ${existingUnit.name} to ${targetTile.x},${targetTile.y},${targetTile.z}`
);
return existingUnit;
} else {
// CREATE logic
const classId = unitDef.classId || unitDef.id;
const unit = this.unitManager.createUnit(classId, "PLAYER");
if (!unit) {
console.error(`Failed to create unit for class: ${classId}`);
return null;
}
// Set character name and class name from unitDef
if (unitDef.name) unit.name = unitDef.name;
if (unitDef.className) unit.className = unitDef.className;
// Preserve portrait/image from unitDef for UI display
if (unitDef.image) {
// Normalize path: ensure it starts with / if it doesn't already
unit.portrait = unitDef.image.startsWith("/")
? unitDef.image
: "/" + unitDef.image;
} else if (unitDef.portrait) {
unit.portrait = unitDef.portrait.startsWith("/")
? unitDef.portrait
: "/" + unitDef.portrait;
}
// Initialize starting equipment for Explorers
if (unit.type === "EXPLORER" && this.inventoryManager) {
// Get class definition from the registry
let classDef = null;
if (this.unitManager.registry) {
classDef = typeof this.unitManager.registry.get === "function"
? this.unitManager.registry.get(classId)
: this.unitManager.registry[classId];
}
if (classDef && typeof unit.initializeStartingEquipment === "function") {
unit.initializeStartingEquipment(
this.inventoryManager.itemRegistry,
classDef
);
}
}
// Ensure unit starts with full health
// Explorer constructor might set health to 0 if classDef is missing base_stats
if (unit.currentHealth <= 0) {
unit.currentHealth = unit.maxHealth || unit.baseStats?.health || 100;
unit.maxHealth = unit.maxHealth || unit.baseStats?.health || 100;
}
this.grid.placeUnit(unit, targetTile);
this.createUnitMesh(unit, targetTile);
console.log(
`Deployed ${unit.name} at ${targetTile.x},${targetTile.y},${targetTile.z}`
);
return unit;
}
}
/**
* Finalizes deployment phase and starts combat.
*/
finalizeDeployment() {
if (
!this.gameStateManager ||
this.gameStateManager.currentState !== "STATE_DEPLOYMENT"
)
return;
// Get enemy spawns from mission definition
const missionDef = this.missionManager?.getActiveMission();
const enemySpawns = missionDef?.enemy_spawns || [];
// If no enemy_spawns defined, fall back to default behavior
if (enemySpawns.length === 0) {
console.warn("No enemy_spawns defined in mission, using default");
const enemy = this.unitManager.createUnit("ENEMY_DEFAULT", "ENEMY");
if (enemy && this.enemySpawnZone.length > 0) {
const spot = this.enemySpawnZone[0];
const walkableY = this.movementSystem?.findWalkableY(
spot.x,
spot.z,
spot.y
);
if (walkableY !== null) {
const walkablePos = { x: spot.x, y: walkableY, z: spot.z };
if (!this.grid.isOccupied(walkablePos) && !this.grid.isSolid(walkablePos)) {
this.grid.placeUnit(enemy, walkablePos);
this.createUnitMesh(enemy, walkablePos);
}
}
}
} else {
// Spawn enemies according to mission definition
let totalSpawned = 0;
const availableSpots = [...this.enemySpawnZone]; // Copy to avoid mutating original
for (const spawnDef of enemySpawns) {
const { enemy_def_id, count } = spawnDef;
let attempts = 0;
const maxAttempts = availableSpots.length * 2;
for (let i = 0; i < count && attempts < maxAttempts && availableSpots.length > 0; attempts++) {
const spotIndex = Math.floor(Math.random() * availableSpots.length);
const spot = availableSpots[spotIndex];
if (!spot) continue;
// Check if position is walkable (not just unoccupied)
const walkableY = this.movementSystem?.findWalkableY(
spot.x,
spot.z,
spot.y
);
if (walkableY === null) continue;
const walkablePos = { x: spot.x, y: walkableY, z: spot.z };
// Check if position is not occupied and is walkable (not solid)
if (
!this.grid.isOccupied(walkablePos) &&
!this.grid.isSolid(walkablePos)
) {
const enemy = this.unitManager.createUnit(enemy_def_id, "ENEMY");
if (enemy) {
this.grid.placeUnit(enemy, walkablePos);
this.createUnitMesh(enemy, walkablePos);
availableSpots.splice(spotIndex, 1);
totalSpawned++;
i++; // Only increment if we successfully placed an enemy
}
}
}
}
console.log(`Spawned ${totalSpawned} enemies from mission definition`);
}
// Switch to standard movement validator for the game
this.inputManager.setValidator(this.validateCursorMove.bind(this));
// Clear spawn zone highlights now that deployment is finished
this.clearSpawnZoneHighlights();
// Notify GameStateManager about state change
if (this.gameStateManager) {
this.gameStateManager.transitionTo("STATE_COMBAT");
}
// WIRING: Hand control to TurnSystem
// Get units from UnitManager (which tracks all units including enemies just spawned)
const allUnits = this.unitManager.getAllUnits();
this.turnSystem.startCombat(allUnits);
// WIRING: Set up MissionManager references
if (this.missionManager) {
this.missionManager.setUnitManager(this.unitManager);
this.missionManager.setTurnSystem(this.turnSystem);
this.missionManager.setupActiveMission();
}
// WIRING: Listen for mission events
this._setupMissionEventListeners();
// Update combat state immediately so UI shows combat HUD
this.updateCombatState().catch(console.error);
console.log("Combat Started!");
}
/**
* Initializes all units for combat with starting AP and charge.
*/
initializeCombatUnits() {
if (!this.grid) return;
const allUnits = Array.from(this.grid.unitMap.values());
allUnits.forEach((unit) => {
// Set starting AP (default to 10, can be derived from stats later)
const maxAP = 10; // TODO: Derive from unit stats
// All units start with full AP when combat begins
unit.currentAP = maxAP;
// Initialize charge meter based on speed stat (faster units start with more charge)
// Charge meter ranges from 0-100, speed-based units get a head start
const speed = unit.baseStats?.speed || 10;
// Scale speed (typically 5-20) to charge (0-100)
// Faster units start closer to 100, slower units start lower
unit.chargeMeter = Math.min(100, Math.max(0, speed * 5)); // Rough scaling: 10 speed = 50 charge
});
}
/**
* Clears all unit meshes from the scene.
*/
clearUnitMeshes() {
this.unitMeshes.forEach((mesh) => this.scene.remove(mesh));
this.unitMeshes.clear();
}
/**
* Clears all movement highlight meshes from the scene.
*/
clearMovementHighlights() {
this.movementHighlights.forEach((mesh) => {
this.scene.remove(mesh);
// Dispose geometry and material to free memory
if (mesh.geometry) {
// For LineSegments, geometry might be EdgesGeometry which wraps another geometry
// Dispose the geometry itself
mesh.geometry.dispose();
}
if (mesh.material) {
if (Array.isArray(mesh.material)) {
mesh.material.forEach((mat) => {
if (mat.map) mat.map.dispose();
mat.dispose();
});
} else {
if (mesh.material.map) mesh.material.map.dispose();
mesh.material.dispose();
}
}
});
this.movementHighlights.clear();
}
/**
* Updates movement highlights for the active player unit.
* Uses MovementSystem to get reachable tiles.
* @param {Unit | null} activeUnit - The active unit, or null to clear highlights
*/
updateMovementHighlights(activeUnit) {
// Clear existing highlights
this.clearMovementHighlights();
// Only show highlights for player units in combat
if (
!activeUnit ||
activeUnit.team !== "PLAYER" ||
!this.gameStateManager ||
this.gameStateManager.currentState !== "STATE_COMBAT" ||
!this.movementSystem
) {
return;
}
// DELEGATE to MovementSystem
const reachablePositions =
this.movementSystem.getReachableTiles(activeUnit);
// Create glowing blue outline materials with multiple layers for enhanced glow
// Outer glow layers (fade outward, decreasing opacity)
const outerGlowMaterial = new THREE.LineBasicMaterial({
color: 0x0066ff,
transparent: true,
opacity: 0.3,
});
const midGlowMaterial = new THREE.LineBasicMaterial({
color: 0x0088ff,
transparent: true,
opacity: 0.5,
});
// Inner bright outline (main glow - brightest)
const highlightMaterial = new THREE.LineBasicMaterial({
color: 0x00ccff, // Very bright cyan-blue for maximum visibility
transparent: true,
opacity: 1.0,
});
// Thick inner outline (for thickness simulation)
const thickMaterial = new THREE.LineBasicMaterial({
color: 0x00aaff,
transparent: true,
opacity: 0.8,
});
// Create base plane geometry for the tile
const baseGeometry = new THREE.PlaneGeometry(1, 1);
baseGeometry.rotateX(-Math.PI / 2);
// Create highlight outlines for each reachable position
reachablePositions.forEach((pos) => {
// Get the correct floor surface height for this position
const walkableY = this.movementSystem.findWalkableY(pos.x, pos.z, pos.y);
if (walkableY === null) return; // Skip if no valid floor found
// Floor surface is at the walkable Y coordinate (top of the floor block)
// Adjust by -0.5 to account for voxel centering
const floorSurfaceY = walkableY - 0.5;
// Create multiple glow layers for enhanced visibility and fade effect
// Outer glow (largest, most transparent)
const outerGlowGeometry = new THREE.PlaneGeometry(1.15, 1.15);
outerGlowGeometry.rotateX(-Math.PI / 2);
const outerGlowEdges = new THREE.EdgesGeometry(outerGlowGeometry);
const outerGlowLines = new THREE.LineSegments(
outerGlowEdges,
outerGlowMaterial
);
outerGlowLines.position.set(pos.x, floorSurfaceY + 0.003, pos.z);
this.scene.add(outerGlowLines);
this.movementHighlights.add(outerGlowLines);
// Mid glow (medium size)
const midGlowGeometry = new THREE.PlaneGeometry(1.08, 1.08);
midGlowGeometry.rotateX(-Math.PI / 2);
const midGlowEdges = new THREE.EdgesGeometry(midGlowGeometry);
const midGlowLines = new THREE.LineSegments(
midGlowEdges,
midGlowMaterial
);
midGlowLines.position.set(pos.x, floorSurfaceY + 0.002, pos.z);
this.scene.add(midGlowLines);
this.movementHighlights.add(midGlowLines);
// Thick inner outline (slightly larger than base for thickness)
const thickGeometry = new THREE.PlaneGeometry(1.02, 1.02);
thickGeometry.rotateX(-Math.PI / 2);
const thickEdges = new THREE.EdgesGeometry(thickGeometry);
const thickLines = new THREE.LineSegments(thickEdges, thickMaterial);
thickLines.position.set(pos.x, floorSurfaceY + 0.001, pos.z);
this.scene.add(thickLines);
this.movementHighlights.add(thickLines);
// Main bright outline (exact size, brightest)
const edgesGeometry = new THREE.EdgesGeometry(baseGeometry);
const lineSegments = new THREE.LineSegments(
edgesGeometry,
highlightMaterial
);
// Position exactly on floor surface
lineSegments.position.set(pos.x, floorSurfaceY, pos.z);
this.scene.add(lineSegments);
this.movementHighlights.add(lineSegments);
});
}
/**
* Creates a visual mesh for a unit.
* @param {Unit} unit - The unit instance
* @param {Position} pos - Position to place the mesh
*/
createUnitMesh(unit, pos) {
const geometry = new THREE.BoxGeometry(0.6, 1.2, 0.6);
// Class-based color mapping for player units
const CLASS_COLORS = {
CLASS_VANGUARD: 0xff3333, // Red - Tank
CLASS_TINKER: 0xffaa00, // Orange - Tech/Mechanical
CLASS_SCAVENGER: 0xaa33ff, // Purple - Rogue/Stealth
CLASS_CUSTODIAN: 0x33ffaa, // Teal - Healer/Support
CLASS_BATTLE_MAGE: 0x3333ff, // Blue - Magic Fighter
CLASS_SAPPER: 0xff6600, // Dark Orange - Explosive
CLASS_FIELD_ENGINEER: 0x00ffff, // Cyan - Tech Support
CLASS_WEAVER: 0xff33ff, // Magenta - Magic (Aether Weaver)
CLASS_AETHER_SENTINEL: 0x33aaff, // Light Blue - Defensive Magic
CLASS_ARCANE_SCOURGE: 0x6600ff, // Dark Purple - Dark Magic
};
let color = 0xcccccc; // Default gray
if (unit.team === "ENEMY") {
color = 0x550000; // Dark red for enemies
} else if (unit.team === "PLAYER") {
// Get class ID from activeClassId (Explorer units) or extract from unit.id
let classId = unit.activeClassId;
// If no activeClassId, try to extract from unit.id (format: "CLASS_VANGUARD_0")
if (!classId && unit.id.includes("CLASS_")) {
const parts = unit.id.split("_");
if (parts.length >= 2) {
classId = parts[0] + "_" + parts[1];
}
}
// Look up color by class ID
if (classId && CLASS_COLORS[classId]) {
color = CLASS_COLORS[classId];
} else {
// Fallback: check if unit.id contains any class name
for (const className of Object.keys(CLASS_COLORS)) {
const classShortName = className.replace("CLASS_", "");
if (unit.id.includes(classShortName)) {
color = CLASS_COLORS[className];
break;
}
}
}
}
const material = new THREE.MeshStandardMaterial({ color: color });
const mesh = new THREE.Mesh(geometry, material);
// Floor surface is at pos.y - 0.5 (floor block at pos.y-1, top at pos.y-0.5)
// Unit should be 0.6 units above floor surface: (pos.y - 0.5) + 0.6 = pos.y + 0.1
mesh.position.set(pos.x, pos.y + 0.1, pos.z);
this.scene.add(mesh);
this.unitMeshes.set(unit.id, mesh);
}
/**
* Highlights spawn zones with visual indicators.
* Uses multi-layer glow outline style similar to movement highlights.
*/
highlightZones() {
// Clear any existing spawn zone highlights
this.clearSpawnZoneHighlights();
// Player zone colors (green) - multi-layer glow
const playerOuterGlowMaterial = new THREE.LineBasicMaterial({
color: 0x006600,
transparent: true,
opacity: 0.3,
});
const playerMidGlowMaterial = new THREE.LineBasicMaterial({
color: 0x008800,
transparent: true,
opacity: 0.5,
});
const playerHighlightMaterial = new THREE.LineBasicMaterial({
color: 0x00ff00, // Bright green
transparent: true,
opacity: 1.0,
});
const playerThickMaterial = new THREE.LineBasicMaterial({
color: 0x00cc00,
transparent: true,
opacity: 0.8,
});
// Enemy zone colors (red) - multi-layer glow
const enemyOuterGlowMaterial = new THREE.LineBasicMaterial({
color: 0x660000,
transparent: true,
opacity: 0.3,
});
const enemyMidGlowMaterial = new THREE.LineBasicMaterial({
color: 0x880000,
transparent: true,
opacity: 0.5,
});
const enemyHighlightMaterial = new THREE.LineBasicMaterial({
color: 0xff0000, // Bright red
transparent: true,
opacity: 1.0,
});
const enemyThickMaterial = new THREE.LineBasicMaterial({
color: 0xcc0000,
transparent: true,
opacity: 0.8,
});
// Create base plane geometry for the tile
const baseGeometry = new THREE.PlaneGeometry(1, 1);
baseGeometry.rotateX(-Math.PI / 2);
// Helper function to create multi-layer highlights for a position
const createHighlights = (pos, materials) => {
const { outerGlow, midGlow, highlight, thick } = materials;
// Find walkable Y level (similar to movement highlights)
let walkableY = pos.y;
if (this.grid && this.grid.getCell(pos.x, pos.y - 1, pos.z) === 0) {
for (let checkY = pos.y; checkY >= 0; checkY--) {
if (this.grid.getCell(pos.x, checkY - 1, pos.z) !== 0) {
walkableY = checkY;
break;
}
}
}
const floorSurfaceY = walkableY - 0.5;
// Outer glow (largest, most transparent)
const outerGlowGeometry = new THREE.PlaneGeometry(1.15, 1.15);
outerGlowGeometry.rotateX(-Math.PI / 2);
const outerGlowEdges = new THREE.EdgesGeometry(outerGlowGeometry);
const outerGlowLines = new THREE.LineSegments(
outerGlowEdges,
outerGlow
);
outerGlowLines.position.set(pos.x, floorSurfaceY + 0.003, pos.z);
this.scene.add(outerGlowLines);
this.spawnZoneHighlights.add(outerGlowLines);
// Mid glow (medium size)
const midGlowGeometry = new THREE.PlaneGeometry(1.08, 1.08);
midGlowGeometry.rotateX(-Math.PI / 2);
const midGlowEdges = new THREE.EdgesGeometry(midGlowGeometry);
const midGlowLines = new THREE.LineSegments(
midGlowEdges,
midGlow
);
midGlowLines.position.set(pos.x, floorSurfaceY + 0.002, pos.z);
this.scene.add(midGlowLines);
this.spawnZoneHighlights.add(midGlowLines);
// Thick inner outline (slightly larger than base for thickness)
const thickGeometry = new THREE.PlaneGeometry(1.02, 1.02);
thickGeometry.rotateX(-Math.PI / 2);
const thickEdges = new THREE.EdgesGeometry(thickGeometry);
const thickLines = new THREE.LineSegments(thickEdges, thick);
thickLines.position.set(pos.x, floorSurfaceY + 0.001, pos.z);
this.scene.add(thickLines);
this.spawnZoneHighlights.add(thickLines);
// Main bright outline (exact size, brightest)
const edgesGeometry = new THREE.EdgesGeometry(baseGeometry);
const lineSegments = new THREE.LineSegments(
edgesGeometry,
highlight
);
lineSegments.position.set(pos.x, floorSurfaceY, pos.z);
this.scene.add(lineSegments);
this.spawnZoneHighlights.add(lineSegments);
};
// Create highlights for player spawn zone (green)
const playerMaterials = {
outerGlow: playerOuterGlowMaterial,
midGlow: playerMidGlowMaterial,
highlight: playerHighlightMaterial,
thick: playerThickMaterial,
};
this.playerSpawnZone.forEach((pos) => {
createHighlights(pos, playerMaterials);
});
// Create highlights for enemy spawn zone (red)
const enemyMaterials = {
outerGlow: enemyOuterGlowMaterial,
midGlow: enemyMidGlowMaterial,
highlight: enemyHighlightMaterial,
thick: enemyThickMaterial,
};
this.enemySpawnZone.forEach((pos) => {
createHighlights(pos, enemyMaterials);
});
}
/**
* Clears all spawn zone highlight meshes from the scene.
*/
clearSpawnZoneHighlights() {
this.spawnZoneHighlights.forEach((mesh) => {
this.scene.remove(mesh);
// Dispose geometry and material to free memory
if (mesh.geometry) {
mesh.geometry.dispose();
}
if (mesh.material) {
if (Array.isArray(mesh.material)) {
mesh.material.forEach((mat) => mat.dispose());
} else {
mesh.material.dispose();
}
}
});
this.spawnZoneHighlights.clear();
}
/**
* Main animation loop.
*/
animate() {
if (!this.isRunning) return;
requestAnimationFrame(this.animate);
if (this.inputManager) this.inputManager.update();
if (this.controls) this.controls.update();
const now = Date.now();
if (now - this.lastMoveTime > this.moveCooldown) {
let dx = 0;
let dz = 0;
if (
this.inputManager.isKeyPressed("KeyW") ||
this.inputManager.isKeyPressed("ArrowUp")
)
dz = -1;
if (
this.inputManager.isKeyPressed("KeyS") ||
this.inputManager.isKeyPressed("ArrowDown")
)
dz = 1;
if (
this.inputManager.isKeyPressed("KeyA") ||
this.inputManager.isKeyPressed("ArrowLeft")
)
dx = -1;
if (
this.inputManager.isKeyPressed("KeyD") ||
this.inputManager.isKeyPressed("ArrowRight")
)
dx = 1;
if (dx !== 0 || dz !== 0) {
const currentPos = this.inputManager.getCursorPosition();
const newX = currentPos.x + dx;
const newZ = currentPos.z + dz;
this.inputManager.setCursor(newX, currentPos.y, newZ);
this.lastMoveTime = now;
}
}
const time = Date.now() * 0.002;
this.unitMeshes.forEach((mesh) => {
mesh.position.y += Math.sin(time) * 0.002;
});
this.renderer.render(this.scene, this.camera);
}
/**
* Pauses the game loop (temporarily stops animation).
* Can be resumed with resume().
*/
pause() {
this.isPaused = true;
this.isRunning = false;
}
/**
* Resumes the game loop after being paused.
*/
resume() {
if (this.isPaused) {
this.isPaused = false;
this.isRunning = true;
this.animate();
}
}
/**
* Stops the game loop and cleans up resources.
*/
stop() {
this.isRunning = false;
this.isPaused = false;
// Abort turn system event listeners (automatically removes them via signal)
if (this.turnSystemAbortController) {
this.turnSystemAbortController.abort();
this.turnSystemAbortController = null;
}
// Reset turn system state BEFORE ending combat to prevent event cascades
if (this.turnSystem) {
// End combat first to stop any ongoing turn advancement
if (this.turnSystem.phase !== "INIT" && this.turnSystem.phase !== "COMBAT_END") {
try {
this.turnSystem.endCombat();
} catch (e) {
// Ignore errors
}
}
// Then reset
if (typeof this.turnSystem.reset === "function") {
this.turnSystem.reset();
}
}
if (this.inputManager && typeof this.inputManager.detach === "function") {
this.inputManager.detach();
}
if (this.controls) this.controls.dispose();
}
/**
* Updates the combat state in GameStateManager.
* Called when combat starts or when combat state changes (turn changes, etc.)
* Uses TurnSystem to get the spec-compliant CombatState, then enriches it for UI.
*/
async updateCombatState() {
if (!this.gameStateManager || !this.turnSystem) {
return;
}
// Get spec-compliant combat state from TurnSystem
const turnSystemState = this.turnSystem.getCombatState();
if (!turnSystemState.isActive) {
// Combat not active, clear state
this.gameStateManager.setCombatState(null);
return;
}
// Get active unit for UI enrichment
const activeUnit = this.turnSystem.getActiveUnit();
// Build active unit status if we have an active unit (for UI)
let unitStatus = null;
if (activeUnit) {
// Calculate effective speed (including equipment and skill tree bonuses)
let effectiveSpeed = activeUnit.baseStats?.speed || 10;
// Add equipment bonuses if available
if (activeUnit.loadout && this.inventoryManager) {
const loadoutSlots = ["mainHand", "offHand", "body", "accessory"];
for (const slot of loadoutSlots) {
const itemInstance = activeUnit.loadout[slot];
if (itemInstance) {
const itemDef = this.inventoryManager.itemRegistry?.get(
itemInstance.defId
);
if (itemDef && itemDef.stats && itemDef.stats.speed) {
effectiveSpeed += itemDef.stats.speed;
}
}
}
}
// Calculate max AP using formula: 3 + floor(effectiveSpeed/5)
// We'll add skill tree bonuses to speed below when we generate the skill tree
let maxAP = 3 + Math.floor(effectiveSpeed / 5);
// Convert status effects to status icons
const statuses = (activeUnit.statusEffects || []).map((effect) => ({
id: effect.id || "unknown",
icon: effect.icon || "❓",
turnsRemaining: effect.duration || 0,
description: effect.description || effect.name || "Status Effect",
}));
// Build skills from unit's actions
const skills = (activeUnit.actions || []).map((action, index) => ({
id: action.id || `skill_${index}`,
name: action.name || "Unknown Skill",
icon: action.icon || "⚔",
costAP: action.costAP || 0,
cooldown: action.cooldown || 0,
isAvailable:
activeUnit.currentAP >= (action.costAP || 0) &&
(action.cooldown || 0) === 0,
}));
// Add unlocked skill tree skills for Explorer units
if (
(activeUnit.type === "EXPLORER" || activeUnit.constructor?.name === "Explorer") &&
activeUnit.activeClassId &&
activeUnit.classMastery &&
this.classRegistry
) {
const mastery = activeUnit.classMastery[activeUnit.activeClassId];
if (mastery && mastery.unlockedNodes && mastery.unlockedNodes.length > 0) {
try {
// Get class definition
const classDef = this.classRegistry.get(activeUnit.activeClassId);
if (classDef && classDef.skillTreeData) {
// Generate skill tree (similar to index.js)
// We'll need to import SkillTreeFactory dynamically or store it
// For now, let's try to get the skill tree from the skill registry
const { SkillTreeFactory } = await import(
"../factories/SkillTreeFactory.js"
);
// Load skill tree template (use cache if available)
let template = this._skillTreeTemplate;
if (!template) {
const templateResponse = await fetch(
"assets/data/skill_trees/template_standard_30.json"
);
if (templateResponse.ok) {
template = await templateResponse.json();
this._skillTreeTemplate = template; // Cache it
}
}
if (template) {
const templateRegistry = { [template.id]: template };
// Convert skillRegistry Map to object for SkillTreeFactory
const skillMap = Object.fromEntries(skillRegistry.skills);
// Create factory and generate tree
const factory = new SkillTreeFactory(templateRegistry, skillMap);
const skillTree = factory.createTree(classDef);
// Add speed boosts from unlocked nodes to effective speed
for (const nodeId of mastery.unlockedNodes) {
const nodeDef = skillTree.nodes?.[nodeId];
if (
nodeDef &&
nodeDef.type === "STAT_BOOST" &&
nodeDef.data &&
nodeDef.data.stat === "speed"
) {
effectiveSpeed += nodeDef.data.value || 0;
}
}
// Recalculate maxAP with skill tree bonuses
maxAP = 3 + Math.floor(effectiveSpeed / 5);
// Add unlocked ACTIVE_SKILL nodes to skills array
for (const nodeId of mastery.unlockedNodes) {
const nodeDef = skillTree.nodes?.[nodeId];
if (nodeDef && nodeDef.type === "ACTIVE_SKILL" && nodeDef.data) {
const skillData = nodeDef.data;
const skillId = skillData.id || nodeId;
// Get full skill definition from registry if available
const fullSkill = skillRegistry.skills.get(skillId);
// Add skill to skills array (avoid duplicates)
if (!skills.find((s) => s.id === skillId)) {
// Get costAP from full skill definition
const costAP = fullSkill?.costs?.ap || skillData.costAP || 3;
const baseCooldown = fullSkill?.cooldown_turns || skillData.cooldown || 0;
// Ensure skill exists in unit.actions for cooldown tracking
if (!activeUnit.actions) {
activeUnit.actions = [];
}
let existingAction = activeUnit.actions.find(
(a) => a.id === skillId
);
// If action doesn't exist, create it with cooldown 0 (ready to use immediately)
if (!existingAction) {
existingAction = {
id: skillId,
name: skillData.name || fullSkill?.name || "Unknown Skill",
icon: skillData.icon || fullSkill?.icon || "⚔",
costAP: costAP,
cooldown: 0, // Newly unlocked skills start ready to use
};
activeUnit.actions.push(existingAction);
}
// Use current cooldown from the action (which gets decremented by TurnSystem)
const currentCooldown = existingAction.cooldown || 0;
skills.push({
id: skillId,
name: existingAction.name,
icon: existingAction.icon,
costAP: costAP,
cooldown: currentCooldown,
isAvailable:
activeUnit.currentAP >= costAP && currentCooldown === 0,
});
}
}
}
}
}
} catch (error) {
console.warn("Failed to load skill tree for combat HUD:", error);
}
}
}
// If no skills from actions or skill tree, provide a default attack skill
if (skills.length === 0) {
skills.push({
id: "attack",
name: "Attack",
icon: "⚔",
costAP: 3,
cooldown: 0,
isAvailable: activeUnit.currentAP >= 3,
});
}
// Get portrait for active unit (same logic as enrichedQueue)
let activePortrait = activeUnit.portrait || activeUnit.image;
// If no portrait and it's a player unit, try to look up by classId
if (
!activePortrait &&
activeUnit.team === "PLAYER" &&
activeUnit.activeClassId
) {
const CLASS_PORTRAITS = {
CLASS_VANGUARD: "/assets/images/portraits/vanguard.png",
CLASS_WEAVER: "/assets/images/portraits/weaver.png",
CLASS_SCAVENGER: "/assets/images/portraits/scavenger.png",
CLASS_TINKER: "/assets/images/portraits/tinker.png",
CLASS_CUSTODIAN: "/assets/images/portraits/custodian.png",
};
activePortrait = CLASS_PORTRAITS[activeUnit.activeClassId];
}
// Normalize path: ensure it starts with / if it doesn't already
if (activePortrait && !activePortrait.startsWith("/")) {
activePortrait = "/" + activePortrait;
}
// Fallback to default portraits
if (!activePortrait) {
activePortrait =
activeUnit.team === "PLAYER"
? "/assets/images/portraits/default.png"
: "/assets/images/portraits/enemy.png";
}
unitStatus = {
id: activeUnit.id,
name: activeUnit.name,
portrait: activePortrait,
hp: {
current: activeUnit.currentHealth,
max: activeUnit.maxHealth,
},
ap: {
current: activeUnit.currentAP,
max: maxAP,
},
charge: activeUnit.chargeMeter || 0,
statuses: statuses,
skills: skills,
};
}
// Build enriched turn queue for UI (with portraits, etc.)
const enrichedQueue = turnSystemState.turnQueue
.map((unitId) => {
const unit = this.unitManager?.activeUnits.get(unitId);
if (!unit) return null;
// Try to get portrait from unit property (portrait or image)
let portrait = unit.portrait || unit.image;
// If no portrait and it's a player unit, try to look up by classId
if (!portrait && unit.team === "PLAYER" && unit.activeClassId) {
// Map of class IDs to portrait paths (matching team-builder CLASS_METADATA)
const CLASS_PORTRAITS = {
CLASS_VANGUARD: "/assets/images/portraits/vanguard.png",
CLASS_WEAVER: "/assets/images/portraits/weaver.png",
CLASS_SCAVENGER: "/assets/images/portraits/scavenger.png",
CLASS_TINKER: "/assets/images/portraits/tinker.png",
CLASS_CUSTODIAN: "/assets/images/portraits/custodian.png",
};
portrait = CLASS_PORTRAITS[unit.activeClassId];
}
// Normalize path: ensure it starts with / if it doesn't already
if (portrait && !portrait.startsWith("/")) {
portrait = "/" + portrait;
}
// Fallback to default portraits
if (!portrait) {
portrait =
unit.team === "PLAYER"
? "/assets/images/portraits/default.png"
: "/assets/images/portraits/enemy.png";
}
return {
unitId: unit.id,
portrait: portrait,
team: unit.team || "ENEMY",
initiative: unit.chargeMeter || 0,
};
})
.filter((entry) => entry !== null);
// Build combat state (enriched for UI, but includes spec fields)
const combatState = {
// Spec-compliant fields
isActive: turnSystemState.isActive,
round: turnSystemState.round,
turnQueue: turnSystemState.turnQueue, // string[] as per spec
activeUnitId: turnSystemState.activeUnitId, // string as per spec
phase: turnSystemState.phase,
// UI-enriched fields (for backward compatibility)
activeUnit: unitStatus, // Object for UI
enrichedQueue: enrichedQueue, // Objects for UI display
targetingMode: this.combatState === "TARGETING_SKILL", // True when player is targeting a skill
activeSkillId: this.activeSkillId || null, // ID of the skill being targeted (for UI toggle state)
roundNumber: turnSystemState.round, // Alias for UI
};
// Update GameStateManager
this.gameStateManager.setCombatState(combatState);
}
/**
* Ends the current unit's turn and advances the turn queue.
* Delegates to TurnSystem.
*/
endTurn() {
if (!this.turnSystem) {
return;
}
const activeUnit = this.turnSystem.getActiveUnit();
if (!activeUnit) {
return;
}
// Clear any active skill targeting state and highlights
if (this.combatState === "TARGETING_SKILL" || this.activeSkillId) {
this.combatState = "IDLE";
this.activeSkillId = null;
// Clear skill targeting highlights (range and AoE reticle)
if (this.voxelManager) {
this.voxelManager.clearHighlights();
}
}
// Clear movement highlights
this.clearMovementHighlights();
// DELEGATE to TurnSystem
this.turnSystem.endTurn(activeUnit);
// Update combat state (TurnSystem will have advanced to next unit)
this.updateCombatState().catch(console.error);
// If the next unit is an enemy, trigger AI turn
const nextUnit = this.turnSystem.getActiveUnit();
if (nextUnit && nextUnit.team === "ENEMY") {
// TODO: Trigger AI turn
console.log(`Enemy ${nextUnit.name}'s turn`);
// For now, auto-end enemy turns after a delay
setTimeout(() => {
this.endTurn();
}, 1000);
}
}
/**
* Event handler for turn-start event from TurnSystem.
* @param {{ unitId: string; unit: Unit }} detail - Turn start event detail
* @private
*/
_onTurnStart(detail) {
const { unit } = detail;
// Update movement highlights if it's a player's turn
if (unit.team === "PLAYER") {
this.updateMovementHighlights(unit);
} else {
this.clearMovementHighlights();
}
}
/**
* Event handler for turn-end event from TurnSystem.
* @param {{ unitId: string; unit: Unit }} detail - Turn end event detail
* @private
*/
_onTurnEnd(detail) {
// Clear movement highlights when turn ends
this.clearMovementHighlights();
// Dispatch TURN_END event to MissionManager
if (this.missionManager && this.turnSystem) {
const currentTurn = this.turnSystem.round || 0;
this.missionManager.updateTurn(currentTurn);
this.missionManager.onGameEvent('TURN_END', { turn: currentTurn });
}
}
/**
* Event handler for combat-start event from TurnSystem.
* @private
*/
_onCombatStart() {
// Combat has started
console.log("TurnSystem: Combat started");
}
/**
* Processes passive item effects for a unit when a trigger event occurs.
* Integration Point 2: EventSystem for Passive Items
* @param {Unit} unit - Unit whose items should be checked
* @param {string} trigger - Trigger event type (e.g., "ON_DAMAGED", "ON_DAMAGE_DEALT", "ON_HEAL_DEALT")
* @param {Object} context - Event context (source, target, damageAmount, etc.)
* @private
*/
processPassiveItemEffects(unit, trigger, context = {}) {
if (!unit || !unit.loadout || !this.effectProcessor || !this.inventoryManager) {
return;
}
// Get all equipped items from loadout
const equippedItems = [
unit.loadout.mainHand,
unit.loadout.offHand,
unit.loadout.body,
unit.loadout.accessory,
...(unit.loadout.belt || []),
].filter(Boolean); // Remove nulls
// Process each equipped item's passive effects
for (const itemInstance of equippedItems) {
if (!itemInstance || !itemInstance.defId) continue;
const itemDef = this.inventoryManager.itemRegistry.get(itemInstance.defId);
if (!itemDef || !itemDef.passives) continue;
// Check each passive effect
for (const passive of itemDef.passives) {
if (passive.trigger !== trigger) continue;
// Check conditions before processing
if (!this._checkPassiveConditions(passive, context)) continue;
// Check chance if present
if (passive.chance !== undefined) {
if (!this.rng || Math.random() > passive.chance) continue;
}
// Convert passive effect to EffectDefinition format
const effectDef = this._convertPassiveToEffectDef(passive, context);
if (!effectDef) continue;
// Determine target based on passive action
let target = context.target || context.source || unit;
if (passive.params && passive.params.target === "SOURCE" && context.source) {
target = context.source;
} else if (passive.params && passive.params.target === "SELF") {
target = unit;
}
// Process the effect through EffectProcessor
const result = this.effectProcessor.process(effectDef, unit, target);
if (result.success && result.data) {
console.log(
`Passive effect ${passive.id || "unknown"} triggered on ${unit.name} (${trigger})`
);
}
}
}
}
/**
* Converts a passive effect definition to an EffectDefinition format.
* @param {Object} passive - Passive effect definition
* @param {Object} context - Event context
* @returns {Object | null} - EffectDefinition or null if conversion not supported
* @private
*/
_convertPassiveToEffectDef(passive, context) {
if (!passive.action || !passive.params) return null;
// Map passive actions to EffectProcessor effect types
const actionMap = {
DAMAGE: "DAMAGE",
APPLY_STATUS: "APPLY_STATUS",
HEAL: "HEAL",
CHAIN_DAMAGE: "CHAIN_DAMAGE",
GIVE_AP: "GIVE_AP",
HEAL_SELF: "HEAL_SELF",
APPLY_BUFF: "APPLY_BUFF",
};
const effectType = actionMap[passive.action];
if (!effectType) return null; // Unsupported action
const effectDef = {
type: effectType,
};
// Copy relevant params
if (passive.params.power !== undefined) {
effectDef.power = passive.params.power;
}
if (passive.params.element) {
effectDef.element = passive.params.element;
}
if (passive.params.status_id) {
effectDef.status_id = passive.params.status_id;
}
if (passive.params.duration !== undefined) {
effectDef.duration = passive.params.duration;
}
if (passive.params.chance !== undefined) {
effectDef.chance = passive.params.chance;
}
// CHAIN_DAMAGE specific params
if (passive.params.bounces !== undefined) {
effectDef.bounces = passive.params.bounces;
}
if (passive.params.chainRange !== undefined) {
effectDef.chainRange = passive.params.chainRange;
}
if (passive.params.decay !== undefined) {
effectDef.decay = passive.params.decay;
}
if (passive.params.synergy_trigger) {
effectDef.synergy_trigger = passive.params.synergy_trigger;
}
if (passive.condition) {
effectDef.condition = passive.condition;
}
// GIVE_AP specific params
if (passive.params.amount !== undefined) {
effectDef.amount = passive.params.amount;
}
// HEAL_SELF specific params
if (passive.params.percentage !== undefined) {
effectDef.percentage = passive.params.percentage;
}
if (context.healAmount !== undefined) {
effectDef.healAmount = context.healAmount;
}
// APPLY_BUFF specific params
if (passive.params.stat !== undefined) {
effectDef.stat = passive.params.stat;
}
if (passive.params.value !== undefined) {
effectDef.value = passive.params.value;
}
return effectDef;
}
/**
* Checks if passive effect conditions are met.
* @param {Object} passive - Passive effect definition
* @param {Object} context - Event context
* @returns {boolean} - True if conditions are met
* @private
*/
_checkPassiveConditions(passive, context) {
if (!passive.condition) return true; // No conditions means always execute
const condition = passive.condition;
// Check SKILL_TAG condition
if (condition.type === "SKILL_TAG" && condition.value) {
const skillDef = context.skillDef;
if (!skillDef || !skillDef.tags) return false;
if (!skillDef.tags.includes(condition.value)) return false;
}
// Check DAMAGE_TYPE condition
if (condition.type === "DAMAGE_TYPE" && condition.value) {
const effect = context.effect;
if (!effect || effect.element !== condition.value) return false;
}
// Check TARGET_TAG condition
if (condition.type === "TARGET_TAG" && condition.value) {
const target = context.target;
if (!target || !target.tags) return false;
if (!target.tags.includes(condition.value)) return false;
}
// Check SOURCE_IS_ADJACENT condition
if (condition.type === "SOURCE_IS_ADJACENT") {
const source = context.source;
const target = context.target || context.unit;
if (!source || !target || !source.position || !target.position) return false;
const dist = Math.abs(source.position.x - target.position.x) +
Math.abs(source.position.y - target.position.y) +
Math.abs(source.position.z - target.position.z);
if (dist > 1) return false; // Not adjacent (Manhattan distance > 1)
}
// Check IS_ADJACENT condition
if (condition.type === "IS_ADJACENT") {
const source = context.source || context.unit;
const target = context.target;
if (!source || !target || !source.position || !target.position) return false;
const dist = Math.abs(source.position.x - target.position.x) +
Math.abs(source.position.y - target.position.y) +
Math.abs(source.position.z - target.position.z);
if (dist > 1) return false; // Not adjacent
}
// Check DID_NOT_ATTACK condition (for ON_TURN_END)
if (condition.type === "DID_NOT_ATTACK") {
// This would need to track if the unit attacked this turn
// For now, we'll assume it's tracked in context
if (context.didAttack === true) return false;
}
return true; // All conditions passed
}
/**
* Handles unit death: removes from grid, dispatches events, and updates MissionManager.
* @param {Unit} unit - The unit that died
*/
handleUnitDeath(unit) {
if (!unit || !this.grid || !this.unitManager) return;
// Remove unit from grid
if (unit.position) {
this.grid.removeUnit(unit.position);
}
// Remove unit from UnitManager
this.unitManager.removeUnit(unit.id);
// Remove unit mesh from scene
const mesh = this.unitMeshes.get(unit.id);
if (mesh) {
this.scene.remove(mesh);
this.unitMeshes.delete(unit.id);
// Dispose geometry and material
if (mesh.geometry) mesh.geometry.dispose();
if (mesh.material) {
if (Array.isArray(mesh.material)) {
mesh.material.forEach(mat => {
if (mat.map) mat.map.dispose();
mat.dispose();
});
} else {
if (mesh.material.map) mesh.material.map.dispose();
mesh.material.dispose();
}
}
}
// Dispatch death event to MissionManager
if (this.missionManager) {
const eventType = unit.team === 'ENEMY' ? 'ENEMY_DEATH' : 'PLAYER_DEATH';
const unitDefId = unit.defId || unit.id;
this.missionManager.onGameEvent(eventType, {
unitId: unit.id,
defId: unitDefId,
team: unit.team
});
}
console.log(`${unit.name} (${unit.team}) has been removed from combat.`);
}
/**
* Sets up event listeners for mission victory and failure.
* @private
*/
_setupMissionEventListeners() {
// Listen for mission victory
window.addEventListener('mission-victory', (event) => {
this._handleMissionVictory(event.detail);
});
// Listen for mission failure
window.addEventListener('mission-failure', (event) => {
this._handleMissionFailure(event.detail);
});
}
/**
* Handles mission victory.
* @param {Object} detail - Victory event detail
* @private
*/
_handleMissionVictory(detail) {
console.log('Mission Victory!', detail);
// Pause the game
this.isPaused = true;
// Stop the game loop
this.stop();
// TODO: Show victory screen UI
// For now, just log and transition back to main menu after a delay
setTimeout(() => {
if (this.gameStateManager) {
this.gameStateManager.transitionTo('STATE_MAIN_MENU');
}
}, 3000);
}
/**
* Handles mission failure.
* @param {Object} detail - Failure event detail
* @private
*/
_handleMissionFailure(detail) {
console.log('Mission Failed!', detail);
// Pause the game
this.isPaused = true;
// Stop the game loop
this.stop();
// TODO: Show failure screen UI
// For now, just log and transition back to main menu after a delay
setTimeout(() => {
if (this.gameStateManager) {
this.gameStateManager.transitionTo('STATE_MAIN_MENU');
}
}, 3000);
}
/**
* Checks DID_NOT_ATTACK condition (for ON_TURN_END)
if (condition.type === "DID_NOT_ATTACK") {
// This would need to track if the unit attacked this turn
// For now, we'll assume it's tracked in context
if (context.didAttack === true) return false;
}
return true;
}
/**
* Event handler for combat-end event from TurnSystem.
* @private
*/
_onCombatEnd() {
// Combat has ended
console.log("TurnSystem: Combat ended");
this.clearMovementHighlights();
}
}