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.
2610 lines
88 KiB
JavaScript
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();
|
|
}
|
|
}
|