2025-12-22 20:55:41 +00:00
|
|
|
/**
|
|
|
|
|
* @typedef {import("./types.js").RunData} RunData
|
|
|
|
|
* @typedef {import("../grid/types.js").Position} Position
|
|
|
|
|
* @typedef {import("../units/Unit.js").Unit} Unit
|
2025-12-22 22:34:43 +00:00
|
|
|
* @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
|
2025-12-22 20:55:41 +00:00
|
|
|
*/
|
|
|
|
|
|
2025-12-19 23:07:36 +00:00
|
|
|
import * as THREE from "three";
|
2025-12-19 23:08:54 +00:00
|
|
|
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
2025-12-19 23:07:36 +00:00
|
|
|
import { VoxelGrid } from "../grid/VoxelGrid.js";
|
|
|
|
|
import { VoxelManager } from "../grid/VoxelManager.js";
|
2025-12-19 23:35:29 +00:00
|
|
|
import { UnitManager } from "../managers/UnitManager.js";
|
2025-12-19 23:07:36 +00:00
|
|
|
import { CaveGenerator } from "../generation/CaveGenerator.js";
|
|
|
|
|
import { RuinGenerator } from "../generation/RuinGenerator.js";
|
2025-12-20 04:58:16 +00:00
|
|
|
import { InputManager } from "./InputManager.js";
|
2025-12-22 04:40:48 +00:00
|
|
|
import { MissionManager } from "../managers/MissionManager.js";
|
2025-12-24 00:22:32 +00:00
|
|
|
import { TurnSystem } from "../systems/TurnSystem.js";
|
|
|
|
|
import { MovementSystem } from "../systems/MovementSystem.js";
|
2025-12-24 05:01:54 +00:00
|
|
|
import { SkillTargetingSystem } from "../systems/SkillTargetingSystem.js";
|
2025-12-31 04:50:11 +00:00
|
|
|
import { EffectProcessor } from "../systems/EffectProcessor.js";
|
|
|
|
|
import { SeededRandom } from "../utils/SeededRandom.js";
|
2025-12-24 05:01:54 +00:00
|
|
|
import { skillRegistry } from "../managers/SkillRegistry.js";
|
2025-12-28 00:54:03 +00:00
|
|
|
import { InventoryManager } from "../managers/InventoryManager.js";
|
|
|
|
|
import { InventoryContainer } from "../models/InventoryContainer.js";
|
|
|
|
|
import { itemRegistry } from "../managers/ItemRegistry.js";
|
2026-01-01 04:48:12 +00:00
|
|
|
import { narrativeManager } from "../managers/NarrativeManager.js";
|
2025-12-24 05:01:54 +00:00
|
|
|
|
|
|
|
|
// 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" };
|
2025-12-19 23:07:36 +00:00
|
|
|
|
2025-12-22 20:55:41 +00:00
|
|
|
/**
|
|
|
|
|
* Main game loop managing rendering, input, and game state.
|
|
|
|
|
* @class
|
|
|
|
|
*/
|
2025-12-19 23:07:36 +00:00
|
|
|
export class GameLoop {
|
|
|
|
|
constructor() {
|
2025-12-22 20:55:41 +00:00
|
|
|
/** @type {boolean} */
|
2025-12-19 23:07:36 +00:00
|
|
|
this.isRunning = false;
|
2025-12-28 00:54:03 +00:00
|
|
|
|
|
|
|
|
/** @type {Object|null} Cached skill tree template */
|
|
|
|
|
this._skillTreeTemplate = null;
|
|
|
|
|
/** @type {number | null} */
|
|
|
|
|
this.animationFrameId = null;
|
|
|
|
|
/** @type {boolean} */
|
|
|
|
|
this.isPaused = false;
|
2025-12-19 23:07:36 +00:00
|
|
|
|
|
|
|
|
// 1. Core Systems
|
2025-12-22 20:55:41 +00:00
|
|
|
/** @type {THREE.Scene} */
|
2025-12-19 23:07:36 +00:00
|
|
|
this.scene = new THREE.Scene();
|
2025-12-22 20:55:41 +00:00
|
|
|
/** @type {THREE.PerspectiveCamera | null} */
|
2025-12-19 23:07:36 +00:00
|
|
|
this.camera = null;
|
2025-12-22 20:55:41 +00:00
|
|
|
/** @type {THREE.WebGLRenderer | null} */
|
2025-12-19 23:07:36 +00:00
|
|
|
this.renderer = null;
|
2025-12-22 20:55:41 +00:00
|
|
|
/** @type {OrbitControls | null} */
|
2025-12-19 23:08:54 +00:00
|
|
|
this.controls = null;
|
2025-12-22 20:55:41 +00:00
|
|
|
/** @type {InputManager | null} */
|
2025-12-20 04:58:16 +00:00
|
|
|
this.inputManager = null;
|
2025-12-19 23:07:36 +00:00
|
|
|
|
2025-12-22 20:55:41 +00:00
|
|
|
/** @type {VoxelGrid | null} */
|
2025-12-19 23:07:36 +00:00
|
|
|
this.grid = null;
|
2025-12-22 20:55:41 +00:00
|
|
|
/** @type {VoxelManager | null} */
|
2025-12-19 23:07:36 +00:00
|
|
|
this.voxelManager = null;
|
2025-12-22 20:55:41 +00:00
|
|
|
/** @type {UnitManager | null} */
|
2025-12-19 23:07:36 +00:00
|
|
|
this.unitManager = null;
|
|
|
|
|
|
2025-12-24 00:22:32 +00:00
|
|
|
// Combat Logic Systems
|
|
|
|
|
/** @type {TurnSystem | null} */
|
|
|
|
|
this.turnSystem = null;
|
|
|
|
|
/** @type {MovementSystem | null} */
|
|
|
|
|
this.movementSystem = null;
|
2025-12-24 05:01:54 +00:00
|
|
|
/** @type {SkillTargetingSystem | null} */
|
|
|
|
|
this.skillTargetingSystem = null;
|
2025-12-31 04:50:11 +00:00
|
|
|
/** @type {EffectProcessor | null} */
|
|
|
|
|
this.effectProcessor = null;
|
2025-12-28 00:54:03 +00:00
|
|
|
|
|
|
|
|
// Inventory System
|
|
|
|
|
/** @type {InventoryManager | null} */
|
|
|
|
|
this.inventoryManager = null;
|
|
|
|
|
|
|
|
|
|
// AbortController for cleaning up event listeners
|
|
|
|
|
/** @type {AbortController | null} */
|
|
|
|
|
this.turnSystemAbortController = null;
|
2025-12-24 00:22:32 +00:00
|
|
|
|
2025-12-22 20:55:41 +00:00
|
|
|
/** @type {Map<string, THREE.Mesh>} */
|
2025-12-20 00:02:42 +00:00
|
|
|
this.unitMeshes = new Map();
|
2025-12-24 00:22:32 +00:00
|
|
|
/** @type {Set<THREE.Mesh>} */
|
|
|
|
|
this.movementHighlights = new Set();
|
|
|
|
|
/** @type {Set<THREE.Mesh>} */
|
|
|
|
|
this.spawnZoneHighlights = new Set();
|
2025-12-24 05:01:54 +00:00
|
|
|
/** @type {Set<THREE.Mesh>} */
|
|
|
|
|
this.rangeHighlights = new Set();
|
|
|
|
|
/** @type {Set<THREE.Mesh>} */
|
|
|
|
|
this.aoeReticle = new Set();
|
2025-12-22 20:55:41 +00:00
|
|
|
/** @type {RunData | null} */
|
2025-12-19 23:07:36 +00:00
|
|
|
this.runData = null;
|
2025-12-22 20:55:41 +00:00
|
|
|
/** @type {Position[]} */
|
2025-12-20 00:02:42 +00:00
|
|
|
this.playerSpawnZone = [];
|
2025-12-22 20:55:41 +00:00
|
|
|
/** @type {Position[]} */
|
2025-12-20 00:02:42 +00:00
|
|
|
this.enemySpawnZone = [];
|
2025-12-20 04:58:16 +00:00
|
|
|
|
|
|
|
|
// Input Logic State
|
2025-12-22 20:55:41 +00:00
|
|
|
/** @type {number} */
|
2025-12-20 04:58:16 +00:00
|
|
|
this.lastMoveTime = 0;
|
2025-12-22 20:55:41 +00:00
|
|
|
/** @type {number} */
|
2025-12-20 04:58:16 +00:00
|
|
|
this.moveCooldown = 120; // ms between cursor moves
|
2025-12-22 20:55:41 +00:00
|
|
|
/** @type {"MOVEMENT" | "TARGETING"} */
|
2025-12-20 04:58:16 +00:00
|
|
|
this.selectionMode = "MOVEMENT"; // MOVEMENT, TARGETING
|
2025-12-22 20:55:41 +00:00
|
|
|
/** @type {MissionManager} */
|
2025-12-21 05:04:44 +00:00
|
|
|
this.missionManager = new MissionManager(this); // Init Mission Manager
|
|
|
|
|
|
|
|
|
|
// Deployment State
|
2025-12-22 20:55:41 +00:00
|
|
|
/** @type {{ selectedUnitIndex: number; deployedUnits: Map<number, Unit> }} */
|
2025-12-21 05:04:44 +00:00
|
|
|
this.deploymentState = {
|
|
|
|
|
selectedUnitIndex: -1,
|
|
|
|
|
deployedUnits: new Map(), // Map<Index, UnitInstance>
|
|
|
|
|
};
|
2025-12-22 20:55:41 +00:00
|
|
|
|
|
|
|
|
/** @type {import("./GameStateManager.js").GameStateManagerClass | null} */
|
|
|
|
|
this.gameStateManager = null;
|
2025-12-24 05:01:54 +00:00
|
|
|
|
|
|
|
|
// Skill Targeting State
|
|
|
|
|
/** @type {"IDLE" | "SELECTING_MOVE" | "TARGETING_SKILL" | "EXECUTING_SKILL"} */
|
|
|
|
|
this.combatState = "IDLE";
|
|
|
|
|
/** @type {string | null} */
|
|
|
|
|
this.activeSkillId = null;
|
2025-12-19 23:07:36 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-22 20:55:41 +00:00
|
|
|
/**
|
|
|
|
|
* Initializes the game loop with Three.js setup.
|
|
|
|
|
* @param {HTMLElement} container - DOM element to attach the renderer to
|
|
|
|
|
*/
|
2025-12-19 23:07:36 +00:00
|
|
|
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);
|
2025-12-20 04:58:16 +00:00
|
|
|
this.renderer.setClearColor(0x111111);
|
2025-12-19 23:07:36 +00:00
|
|
|
container.appendChild(this.renderer.domElement);
|
|
|
|
|
|
2025-12-19 23:08:54 +00:00
|
|
|
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
2025-12-20 00:02:42 +00:00
|
|
|
this.controls.enableDamping = true;
|
2025-12-19 23:08:54 +00:00
|
|
|
this.controls.dampingFactor = 0.05;
|
|
|
|
|
|
2025-12-24 00:22:32 +00:00
|
|
|
// --- INSTANTIATE COMBAT SYSTEMS ---
|
|
|
|
|
this.turnSystem = new TurnSystem();
|
|
|
|
|
this.movementSystem = new MovementSystem();
|
2025-12-24 05:01:54 +00:00
|
|
|
// SkillTargetingSystem will be initialized in startLevel when grid/unitManager are ready
|
2025-12-24 00:22:32 +00:00
|
|
|
|
2025-12-28 00:54:03 +00:00
|
|
|
// --- 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);
|
|
|
|
|
|
2025-12-20 04:58:16 +00:00
|
|
|
// --- 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)
|
|
|
|
|
);
|
2025-12-24 05:01:54 +00:00
|
|
|
this.inputManager.addEventListener("hover", (e) =>
|
|
|
|
|
this.onCursorHover(e.detail.voxelPosition)
|
|
|
|
|
);
|
2025-12-20 04:58:16 +00:00
|
|
|
|
|
|
|
|
// Default Validator: Movement Logic (Will be overridden in startLevel)
|
|
|
|
|
this.inputManager.setValidator(this.validateCursorMove.bind(this));
|
|
|
|
|
|
2025-12-19 23:07:36 +00:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-12-20 04:58:16 +00:00
|
|
|
* Validation Logic for Standard Movement.
|
2025-12-22 20:55:41 +00:00
|
|
|
* @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
|
2025-12-19 23:07:36 +00:00
|
|
|
*/
|
2025-12-20 04:58:16 +00:00
|
|
|
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 };
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-21 05:04:44 +00:00
|
|
|
return false;
|
2025-12-20 04:58:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Validation Logic for Deployment Phase.
|
2025-12-22 20:55:41 +00:00
|
|
|
* @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
|
2025-12-20 04:58:16 +00:00
|
|
|
*/
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-22 20:55:41 +00:00
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
*/
|
2025-12-20 04:58:16 +00:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-22 20:55:41 +00:00
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
*/
|
2025-12-20 04:58:16 +00:00
|
|
|
validateInteractionTarget(x, y, z) {
|
|
|
|
|
if (!this.grid) return true;
|
|
|
|
|
return this.grid.isValidBounds({ x, y, z });
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-22 20:55:41 +00:00
|
|
|
/**
|
|
|
|
|
* Handles gamepad button input.
|
|
|
|
|
* @param {{ buttonIndex: number; gamepadIndex: number }} detail - Button input detail
|
|
|
|
|
*/
|
2025-12-20 04:58:16 +00:00
|
|
|
handleButtonInput(detail) {
|
|
|
|
|
if (detail.buttonIndex === 0) {
|
|
|
|
|
// A / Cross
|
|
|
|
|
this.triggerSelection();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-22 20:55:41 +00:00
|
|
|
/**
|
|
|
|
|
* Handles keyboard input.
|
|
|
|
|
* @param {string} code - Key code
|
|
|
|
|
*/
|
2025-12-20 04:58:16 +00:00
|
|
|
handleKeyInput(code) {
|
|
|
|
|
if (code === "Space" || code === "Enter") {
|
|
|
|
|
this.triggerSelection();
|
|
|
|
|
}
|
2025-12-24 05:01:54 +00:00
|
|
|
if (code === "Escape" || code === "KeyB") {
|
|
|
|
|
// Cancel skill targeting
|
|
|
|
|
if (this.combatState === "TARGETING_SKILL") {
|
|
|
|
|
this.cancelSkillTargeting();
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-20 04:58:16 +00:00
|
|
|
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);
|
|
|
|
|
}
|
2025-12-28 00:54:03 +00:00
|
|
|
if (code === "KeyC") {
|
|
|
|
|
// Open character sheet for active unit
|
|
|
|
|
this.openCharacterSheet();
|
|
|
|
|
}
|
2025-12-28 01:21:31 +00:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-28 00:54:03 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
);
|
2025-12-20 04:58:16 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-21 05:04:44 +00:00
|
|
|
/**
|
|
|
|
|
* Called by UI when a unit is clicked in the Roster.
|
2025-12-22 04:40:48 +00:00
|
|
|
* @param {number} index - The index of the unit in the squad to select.
|
2025-12-21 05:04:44 +00:00
|
|
|
*/
|
|
|
|
|
selectDeploymentUnit(index) {
|
|
|
|
|
this.deploymentState.selectedUnitIndex = index;
|
|
|
|
|
console.log(`Deployment: Selected Unit Index ${index}`);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-22 20:55:41 +00:00
|
|
|
/**
|
|
|
|
|
* Triggers selection action at cursor position.
|
|
|
|
|
*/
|
2025-12-20 04:58:16 +00:00
|
|
|
triggerSelection() {
|
|
|
|
|
const cursor = this.inputManager.getCursorPosition();
|
|
|
|
|
console.log("Action at:", cursor);
|
|
|
|
|
|
2025-12-22 05:20:33 +00:00
|
|
|
if (
|
|
|
|
|
this.gameStateManager &&
|
|
|
|
|
this.gameStateManager.currentState === "STATE_DEPLOYMENT"
|
|
|
|
|
) {
|
2025-12-21 05:04:44 +00:00
|
|
|
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.");
|
|
|
|
|
}
|
2025-12-24 00:22:32 +00:00
|
|
|
} else if (
|
|
|
|
|
this.gameStateManager &&
|
|
|
|
|
this.gameStateManager.currentState === "STATE_COMBAT"
|
|
|
|
|
) {
|
2025-12-24 05:01:54 +00:00
|
|
|
// Handle combat actions based on state
|
|
|
|
|
if (this.combatState === "TARGETING_SKILL") {
|
|
|
|
|
this.handleSkillTargeting(cursor);
|
|
|
|
|
} else {
|
|
|
|
|
// Default to movement
|
|
|
|
|
this.handleCombatMovement(cursor);
|
|
|
|
|
}
|
2025-12-24 00:22:32 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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) {
|
2025-12-24 04:28:12 +00:00
|
|
|
// Floor surface is at pos.y - 0.5, unit should be 0.6 above: pos.y + 0.1
|
2025-12-24 00:22:32 +00:00
|
|
|
mesh.position.set(
|
|
|
|
|
activeUnit.position.x,
|
2025-12-24 04:28:12 +00:00
|
|
|
activeUnit.position.y + 0.1,
|
2025-12-24 00:22:32 +00:00
|
|
|
activeUnit.position.z
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(
|
|
|
|
|
`Moved ${activeUnit.name} to ${activeUnit.position.x},${activeUnit.position.y},${activeUnit.position.z}`
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Update combat state and movement highlights
|
2025-12-28 00:54:03 +00:00
|
|
|
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.
|
2025-12-20 04:58:16 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 05:01:54 +00:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
|
2025-12-28 01:21:31 +00:00
|
|
|
// If clicking the same skill that's already active, cancel targeting
|
|
|
|
|
if (this.combatState === "TARGETING_SKILL" && this.activeSkillId === skillId) {
|
|
|
|
|
this.cancelSkillTargeting();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 05:01:54 +00:00
|
|
|
// 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;
|
|
|
|
|
|
2025-12-31 04:50:11 +00:00
|
|
|
// Clear movement highlights and show skill range (only valid targets)
|
2025-12-24 05:01:54 +00:00
|
|
|
this.clearMovementHighlights();
|
|
|
|
|
const skillDef = this.skillTargetingSystem.getSkillDef(skillId);
|
2025-12-31 04:50:11 +00:00
|
|
|
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");
|
2025-12-24 05:01:54 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-28 01:21:31 +00:00
|
|
|
// Update combat state to refresh UI (show cancel button)
|
|
|
|
|
this.updateCombatState().catch(console.error);
|
|
|
|
|
|
2025-12-24 05:01:54 +00:00
|
|
|
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)
|
2025-12-31 04:50:11 +00:00
|
|
|
let targets = this.skillTargetingSystem.getUnitsInAoE(
|
2025-12-24 05:01:54 +00:00
|
|
|
activeUnit.position,
|
|
|
|
|
targetPos,
|
|
|
|
|
skillId
|
|
|
|
|
);
|
2025-12-31 04:50:11 +00:00
|
|
|
|
|
|
|
|
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}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-24 05:01:54 +00:00
|
|
|
|
2025-12-31 04:50:11 +00:00
|
|
|
// 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,
|
|
|
|
|
});
|
2025-12-31 04:56:41 +00:00
|
|
|
// Handle unit death
|
|
|
|
|
this.handleUnitDeath(target);
|
2025-12-31 04:50:11 +00:00
|
|
|
}
|
|
|
|
|
// 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}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-24 05:01:54 +00:00
|
|
|
|
|
|
|
|
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
|
2025-12-28 00:54:03 +00:00
|
|
|
this.updateCombatState().catch(console.error);
|
2025-12-24 05:01:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-28 01:21:31 +00:00
|
|
|
// 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);
|
|
|
|
|
}
|
2025-12-24 05:01:54 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-22 20:55:41 +00:00
|
|
|
/**
|
|
|
|
|
* Starts a mission by ID.
|
|
|
|
|
* @param {string} missionId - Mission identifier
|
|
|
|
|
* @returns {Promise<void>}
|
|
|
|
|
*/
|
2025-12-21 05:04:44 +00:00
|
|
|
async startMission(missionId) {
|
|
|
|
|
const mission = await fetch(
|
|
|
|
|
`assets/data/missions/${missionId.toLowerCase()}.json`
|
|
|
|
|
);
|
|
|
|
|
const missionData = await mission.json();
|
|
|
|
|
this.missionManager.startMission(missionData);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-22 20:55:41 +00:00
|
|
|
/**
|
|
|
|
|
* Starts a level with the given run data.
|
|
|
|
|
* @param {RunData} runData - Run data containing mission and squad info
|
2025-12-28 00:54:03 +00:00
|
|
|
* @param {Object} [options] - Optional configuration
|
|
|
|
|
* @param {boolean} [options.startAnimation=true] - Whether to start the animation loop
|
2025-12-22 20:55:41 +00:00
|
|
|
* @returns {Promise<void>}
|
|
|
|
|
*/
|
2025-12-28 00:54:03 +00:00
|
|
|
async startLevel(runData, options = {}) {
|
2025-12-20 00:02:42 +00:00
|
|
|
console.log("GameLoop: Generating Level...");
|
2025-12-19 23:07:36 +00:00
|
|
|
this.runData = runData;
|
|
|
|
|
this.isRunning = true;
|
2025-12-20 00:02:42 +00:00
|
|
|
this.clearUnitMeshes();
|
2025-12-24 00:22:32 +00:00
|
|
|
this.clearMovementHighlights();
|
|
|
|
|
this.clearSpawnZoneHighlights();
|
2025-12-19 23:07:36 +00:00
|
|
|
|
2025-12-21 05:04:44 +00:00
|
|
|
// Reset Deployment State
|
|
|
|
|
this.deploymentState = {
|
|
|
|
|
selectedUnitIndex: -1,
|
|
|
|
|
deployedUnits: new Map(), // Map<Index, UnitInstance>
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-19 23:07:36 +00:00
|
|
|
this.grid = new VoxelGrid(20, 10, 20);
|
|
|
|
|
const generator = new RuinGenerator(this.grid, runData.seed);
|
|
|
|
|
generator.generate();
|
|
|
|
|
|
2025-12-20 00:02:42 +00:00
|
|
|
if (generator.generatedAssets.spawnZones) {
|
|
|
|
|
this.playerSpawnZone = generator.generatedAssets.spawnZones.player || [];
|
|
|
|
|
this.enemySpawnZone = generator.generatedAssets.spawnZones.enemy || [];
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-20 04:58:16 +00:00
|
|
|
if (this.playerSpawnZone.length === 0)
|
2025-12-20 00:02:42 +00:00
|
|
|
this.playerSpawnZone.push({ x: 2, y: 1, z: 2 });
|
2025-12-20 04:58:16 +00:00
|
|
|
if (this.enemySpawnZone.length === 0)
|
2025-12-20 00:02:42 +00:00
|
|
|
this.enemySpawnZone.push({ x: 18, y: 1, z: 18 });
|
2025-12-19 23:07:36 +00:00
|
|
|
|
2025-12-20 00:02:42 +00:00
|
|
|
this.voxelManager = new VoxelManager(this.grid, this.scene);
|
2025-12-19 23:07:36 +00:00
|
|
|
this.voxelManager.updateMaterials(generator.generatedAssets);
|
|
|
|
|
this.voxelManager.update();
|
2025-12-19 23:08:54 +00:00
|
|
|
|
2025-12-24 05:01:54 +00:00
|
|
|
// Set up highlight tracking sets
|
|
|
|
|
this.voxelManager.setHighlightSets(this.rangeHighlights, this.aoeReticle);
|
|
|
|
|
|
2025-12-20 04:58:16 +00:00
|
|
|
if (this.controls) this.voxelManager.focusCamera(this.controls);
|
2025-12-19 23:07:36 +00:00
|
|
|
|
2025-12-24 05:01:54 +00:00
|
|
|
// 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 = {
|
2025-12-19 23:35:29 +00:00
|
|
|
get: (id) => {
|
2025-12-24 05:01:54 +00:00
|
|
|
// Try to get from class registry first
|
|
|
|
|
if (classRegistry.has(id)) {
|
|
|
|
|
return classRegistry.get(id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fallback for enemy units
|
|
|
|
|
if (id.startsWith("ENEMY_")) {
|
2025-12-24 00:22:32 +00:00
|
|
|
return {
|
2025-12-24 05:01:54 +00:00
|
|
|
type: "ENEMY",
|
|
|
|
|
name: "Enemy",
|
|
|
|
|
stats: { health: 50, attack: 8, defense: 3, speed: 8 },
|
|
|
|
|
ai_archetype: "BRUISER",
|
2025-12-24 00:22:32 +00:00
|
|
|
};
|
2025-12-24 05:01:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.warn(`Unit definition not found: ${id}`);
|
|
|
|
|
return null;
|
2025-12-19 23:35:29 +00:00
|
|
|
},
|
|
|
|
|
};
|
2025-12-24 05:01:54 +00:00
|
|
|
|
|
|
|
|
this.unitManager = new UnitManager(unitRegistry);
|
2025-12-28 00:54:03 +00:00
|
|
|
// Store classRegistry reference for accessing class definitions later
|
|
|
|
|
this.classRegistry = classRegistry;
|
2025-12-24 00:22:32 +00:00
|
|
|
|
|
|
|
|
// WIRING: Connect Systems to Data
|
|
|
|
|
this.movementSystem.setContext(this.grid, this.unitManager);
|
|
|
|
|
this.turnSystem.setContext(this.unitManager);
|
2025-12-31 04:50:11 +00:00
|
|
|
// 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);
|
2025-12-24 00:22:32 +00:00
|
|
|
|
2025-12-24 05:01:54 +00:00
|
|
|
// Load skills and initialize SkillTargetingSystem
|
2025-12-28 00:54:03 +00:00
|
|
|
// Skip skill loading in test mode (when startAnimation is false) to avoid fetch timeouts
|
|
|
|
|
if (options.startAnimation !== false && skillRegistry.skills.size === 0) {
|
2025-12-24 05:01:54 +00:00
|
|
|
await skillRegistry.loadAll();
|
|
|
|
|
}
|
|
|
|
|
this.skillTargetingSystem = new SkillTargetingSystem(
|
|
|
|
|
this.grid,
|
|
|
|
|
this.unitManager,
|
|
|
|
|
skillRegistry
|
|
|
|
|
);
|
|
|
|
|
|
2025-12-28 00:54:03 +00:00
|
|
|
// Load items for InventoryManager
|
|
|
|
|
if (options.startAnimation !== false && itemRegistry.items.size === 0) {
|
|
|
|
|
await itemRegistry.loadAll();
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 00:22:32 +00:00
|
|
|
// WIRING: Listen for Turn Changes (to update UI/Input state)
|
2025-12-28 00:54:03 +00:00
|
|
|
// 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 });
|
2025-12-24 00:22:32 +00:00
|
|
|
|
2025-12-20 00:02:42 +00:00
|
|
|
this.highlightZones();
|
|
|
|
|
|
2025-12-20 04:58:16 +00:00
|
|
|
if (this.playerSpawnZone.length > 0) {
|
2025-12-21 05:04:44 +00:00
|
|
|
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;
|
|
|
|
|
|
2025-12-20 04:58:16 +00:00
|
|
|
const start = this.playerSpawnZone[0];
|
|
|
|
|
this.inputManager.setCursor(start.x, start.y, start.z);
|
2025-12-21 05:04:44 +00:00
|
|
|
|
|
|
|
|
if (this.controls) {
|
|
|
|
|
this.controls.target.set(centerX, centerY, centerZ);
|
|
|
|
|
this.controls.update();
|
|
|
|
|
}
|
2025-12-20 04:58:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.inputManager.setValidator(this.validateDeploymentCursor.bind(this));
|
|
|
|
|
|
2025-12-28 00:54:03 +00:00
|
|
|
// Only start animation loop if explicitly requested (default true for normal usage)
|
|
|
|
|
if (options.startAnimation !== false) {
|
|
|
|
|
this.animate();
|
|
|
|
|
}
|
2025-12-19 23:07:36 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-22 20:55:41 +00:00
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
*/
|
2025-12-21 05:04:44 +00:00
|
|
|
deployUnit(unitDef, targetTile, existingUnit = null) {
|
2025-12-22 05:20:33 +00:00
|
|
|
if (
|
|
|
|
|
!this.gameStateManager ||
|
|
|
|
|
this.gameStateManager.currentState !== "STATE_DEPLOYMENT"
|
|
|
|
|
)
|
|
|
|
|
return null;
|
2025-12-20 00:02:42 +00:00
|
|
|
|
2025-12-20 04:58:16 +00:00
|
|
|
const isValid = this.validateDeploymentCursor(
|
|
|
|
|
targetTile.x,
|
|
|
|
|
targetTile.y,
|
|
|
|
|
targetTile.z
|
2025-12-20 00:02:42 +00:00
|
|
|
);
|
|
|
|
|
|
2025-12-21 05:04:44 +00:00
|
|
|
// 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) {
|
2025-12-24 04:28:12 +00:00
|
|
|
// 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);
|
2025-12-21 05:04:44 +00:00
|
|
|
}
|
|
|
|
|
console.log(
|
|
|
|
|
`Moved ${existingUnit.name} to ${targetTile.x},${targetTile.y},${targetTile.z}`
|
|
|
|
|
);
|
|
|
|
|
return existingUnit;
|
|
|
|
|
} else {
|
|
|
|
|
// CREATE logic
|
2025-12-28 00:54:03 +00:00
|
|
|
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
|
2025-12-21 05:04:44 +00:00
|
|
|
if (unitDef.name) unit.name = unitDef.name;
|
2025-12-28 00:54:03 +00:00
|
|
|
if (unitDef.className) unit.className = unitDef.className;
|
2025-12-21 05:04:44 +00:00
|
|
|
|
2025-12-31 21:52:59 +00:00
|
|
|
// Restore progression data from roster for Explorers
|
|
|
|
|
if (unit.type === "EXPLORER" && unitDef.id && this.gameStateManager) {
|
|
|
|
|
const rosterUnit = this.gameStateManager.rosterManager.roster.find(
|
|
|
|
|
(r) => r.id === unitDef.id
|
|
|
|
|
);
|
|
|
|
|
if (rosterUnit) {
|
|
|
|
|
// Store roster ID on unit for later saving
|
|
|
|
|
unit.rosterId = unitDef.id;
|
|
|
|
|
|
|
|
|
|
// Restore activeClassId first (needed for stat recalculation)
|
|
|
|
|
if (rosterUnit.activeClassId) {
|
|
|
|
|
unit.activeClassId = rosterUnit.activeClassId;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Restore classMastery progression
|
|
|
|
|
if (rosterUnit.classMastery) {
|
|
|
|
|
unit.classMastery = JSON.parse(JSON.stringify(rosterUnit.classMastery));
|
|
|
|
|
// Recalculate stats based on restored mastery and activeClassId
|
|
|
|
|
if (unit.recalculateBaseStats && unit.activeClassId) {
|
|
|
|
|
const classDef = typeof this.unitManager.registry.get === "function"
|
|
|
|
|
? this.unitManager.registry.get(unit.activeClassId)
|
|
|
|
|
: this.unitManager.registry[unit.activeClassId];
|
|
|
|
|
if (classDef) {
|
|
|
|
|
unit.recalculateBaseStats(classDef);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-01 04:48:12 +00:00
|
|
|
|
|
|
|
|
// Restore currentHealth from roster (preserve HP that was paid for)
|
|
|
|
|
if (rosterUnit.currentHealth !== undefined && rosterUnit.currentHealth !== null) {
|
|
|
|
|
// Ensure currentHealth doesn't exceed maxHealth (in case maxHealth increased)
|
|
|
|
|
unit.currentHealth = Math.min(rosterUnit.currentHealth, unit.maxHealth || 100);
|
|
|
|
|
console.log(`Restored HP for ${unit.name}: ${unit.currentHealth}/${unit.maxHealth} (from roster)`);
|
|
|
|
|
}
|
2025-12-31 21:52:59 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 05:01:54 +00:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-28 00:54:03 +00:00
|
|
|
// 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
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-01 04:48:12 +00:00
|
|
|
// Ensure unit has valid health values
|
|
|
|
|
// Only set to full health if currentHealth is invalid (0 or negative) and wasn't restored from roster
|
|
|
|
|
// This preserves HP that was paid for in the barracks
|
|
|
|
|
if (unit.currentHealth <= 0 && (!unit.rosterId || !this.gameStateManager?.rosterManager?.roster.find(r => r.id === unit.rosterId)?.currentHealth)) {
|
|
|
|
|
// Only set to full if we didn't restore from roster (new unit or roster had no saved HP)
|
2025-12-24 00:22:32 +00:00
|
|
|
unit.currentHealth = unit.maxHealth || unit.baseStats?.health || 100;
|
2026-01-01 04:48:12 +00:00
|
|
|
}
|
|
|
|
|
// Ensure maxHealth is set
|
|
|
|
|
if (!unit.maxHealth) {
|
|
|
|
|
unit.maxHealth = unit.baseStats?.health || 100;
|
2025-12-24 00:22:32 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-21 05:04:44 +00:00
|
|
|
this.grid.placeUnit(unit, targetTile);
|
|
|
|
|
this.createUnitMesh(unit, targetTile);
|
|
|
|
|
|
|
|
|
|
console.log(
|
|
|
|
|
`Deployed ${unit.name} at ${targetTile.x},${targetTile.y},${targetTile.z}`
|
|
|
|
|
);
|
|
|
|
|
return unit;
|
|
|
|
|
}
|
2025-12-20 00:02:42 +00:00
|
|
|
}
|
2025-12-19 23:35:29 +00:00
|
|
|
|
2025-12-22 20:55:41 +00:00
|
|
|
/**
|
|
|
|
|
* Finalizes deployment phase and starts combat.
|
|
|
|
|
*/
|
2025-12-20 00:02:42 +00:00
|
|
|
finalizeDeployment() {
|
2025-12-22 05:20:33 +00:00
|
|
|
if (
|
|
|
|
|
!this.gameStateManager ||
|
|
|
|
|
this.gameStateManager.currentState !== "STATE_DEPLOYMENT"
|
|
|
|
|
)
|
|
|
|
|
return;
|
2025-12-24 04:28:12 +00:00
|
|
|
|
2025-12-28 00:54:03 +00:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-19 23:35:29 +00:00
|
|
|
}
|
2025-12-28 00:54:03 +00:00
|
|
|
|
|
|
|
|
console.log(`Spawned ${totalSpawned} enemies from mission definition`);
|
2025-12-20 00:02:42 +00:00
|
|
|
}
|
2025-12-20 04:58:16 +00:00
|
|
|
|
|
|
|
|
// Switch to standard movement validator for the game
|
|
|
|
|
this.inputManager.setValidator(this.validateCursorMove.bind(this));
|
2025-12-21 05:04:44 +00:00
|
|
|
|
2025-12-24 00:22:32 +00:00
|
|
|
// Clear spawn zone highlights now that deployment is finished
|
|
|
|
|
this.clearSpawnZoneHighlights();
|
|
|
|
|
|
2025-12-22 05:20:33 +00:00
|
|
|
// Notify GameStateManager about state change
|
|
|
|
|
if (this.gameStateManager) {
|
|
|
|
|
this.gameStateManager.transitionTo("STATE_COMBAT");
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 00:22:32 +00:00
|
|
|
// 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);
|
|
|
|
|
|
2025-12-31 04:56:41 +00:00
|
|
|
// 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();
|
|
|
|
|
|
2025-12-24 00:22:32 +00:00
|
|
|
// Update combat state immediately so UI shows combat HUD
|
2025-12-28 00:54:03 +00:00
|
|
|
this.updateCombatState().catch(console.error);
|
2025-12-22 22:34:43 +00:00
|
|
|
|
2025-12-21 05:04:44 +00:00
|
|
|
console.log("Combat Started!");
|
2025-12-20 00:02:42 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-24 00:22:32 +00:00
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-22 20:55:41 +00:00
|
|
|
/**
|
|
|
|
|
* Clears all unit meshes from the scene.
|
|
|
|
|
*/
|
2025-12-20 00:02:42 +00:00
|
|
|
clearUnitMeshes() {
|
|
|
|
|
this.unitMeshes.forEach((mesh) => this.scene.remove(mesh));
|
|
|
|
|
this.unitMeshes.clear();
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 00:22:32 +00:00
|
|
|
/**
|
|
|
|
|
* Clears all movement highlight meshes from the scene.
|
|
|
|
|
*/
|
|
|
|
|
clearMovementHighlights() {
|
2025-12-28 00:54:03 +00:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
2025-12-24 00:22:32 +00:00
|
|
|
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);
|
|
|
|
|
|
2025-12-24 04:28:12 +00:00
|
|
|
// 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,
|
2025-12-24 00:22:32 +00:00
|
|
|
transparent: true,
|
2025-12-24 04:28:12 +00:00
|
|
|
opacity: 0.3,
|
2025-12-24 00:22:32 +00:00
|
|
|
});
|
|
|
|
|
|
2025-12-24 04:28:12 +00:00
|
|
|
const midGlowMaterial = new THREE.LineBasicMaterial({
|
|
|
|
|
color: 0x0088ff,
|
|
|
|
|
transparent: true,
|
|
|
|
|
opacity: 0.5,
|
|
|
|
|
});
|
2025-12-24 00:22:32 +00:00
|
|
|
|
2025-12-24 04:28:12 +00:00
|
|
|
// 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
|
2025-12-24 00:22:32 +00:00
|
|
|
reachablePositions.forEach((pos) => {
|
2025-12-24 04:28:12 +00:00
|
|
|
// 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);
|
2025-12-24 00:22:32 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-22 20:55:41 +00:00
|
|
|
/**
|
|
|
|
|
* Creates a visual mesh for a unit.
|
|
|
|
|
* @param {Unit} unit - The unit instance
|
|
|
|
|
* @param {Position} pos - Position to place the mesh
|
|
|
|
|
*/
|
2025-12-20 00:02:42 +00:00
|
|
|
createUnitMesh(unit, pos) {
|
|
|
|
|
const geometry = new THREE.BoxGeometry(0.6, 1.2, 0.6);
|
2025-12-28 00:54:03 +00:00
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-20 00:02:42 +00:00
|
|
|
const material = new THREE.MeshStandardMaterial({ color: color });
|
|
|
|
|
const mesh = new THREE.Mesh(geometry, material);
|
2025-12-24 04:28:12 +00:00
|
|
|
// 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);
|
2025-12-20 00:02:42 +00:00
|
|
|
this.scene.add(mesh);
|
|
|
|
|
this.unitMeshes.set(unit.id, mesh);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-22 20:55:41 +00:00
|
|
|
/**
|
|
|
|
|
* Highlights spawn zones with visual indicators.
|
2025-12-28 00:54:03 +00:00
|
|
|
* Uses multi-layer glow outline style similar to movement highlights.
|
2025-12-22 20:55:41 +00:00
|
|
|
*/
|
2025-12-20 00:02:42 +00:00
|
|
|
highlightZones() {
|
2025-12-24 00:22:32 +00:00
|
|
|
// Clear any existing spawn zone highlights
|
|
|
|
|
this.clearSpawnZoneHighlights();
|
|
|
|
|
|
2025-12-28 00:54:03 +00:00
|
|
|
// Player zone colors (green) - multi-layer glow
|
|
|
|
|
const playerOuterGlowMaterial = new THREE.LineBasicMaterial({
|
|
|
|
|
color: 0x006600,
|
2025-12-20 00:02:42 +00:00
|
|
|
transparent: true,
|
|
|
|
|
opacity: 0.3,
|
|
|
|
|
});
|
2025-12-28 00:54:03 +00:00
|
|
|
|
|
|
|
|
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,
|
2025-12-20 00:02:42 +00:00
|
|
|
transparent: true,
|
|
|
|
|
opacity: 0.3,
|
|
|
|
|
});
|
2025-12-28 00:54:03 +00:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
};
|
2025-12-20 00:02:42 +00:00
|
|
|
this.playerSpawnZone.forEach((pos) => {
|
2025-12-28 00:54:03 +00:00
|
|
|
createHighlights(pos, playerMaterials);
|
2025-12-20 00:02:42 +00:00
|
|
|
});
|
2025-12-28 00:54:03 +00:00
|
|
|
|
|
|
|
|
// Create highlights for enemy spawn zone (red)
|
|
|
|
|
const enemyMaterials = {
|
|
|
|
|
outerGlow: enemyOuterGlowMaterial,
|
|
|
|
|
midGlow: enemyMidGlowMaterial,
|
|
|
|
|
highlight: enemyHighlightMaterial,
|
|
|
|
|
thick: enemyThickMaterial,
|
|
|
|
|
};
|
2025-12-20 00:02:42 +00:00
|
|
|
this.enemySpawnZone.forEach((pos) => {
|
2025-12-28 00:54:03 +00:00
|
|
|
createHighlights(pos, enemyMaterials);
|
2025-12-19 23:35:29 +00:00
|
|
|
});
|
2025-12-19 23:07:36 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-24 00:22:32 +00:00
|
|
|
/**
|
|
|
|
|
* Clears all spawn zone highlight meshes from the scene.
|
|
|
|
|
*/
|
|
|
|
|
clearSpawnZoneHighlights() {
|
2025-12-28 00:54:03 +00:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
2025-12-24 00:22:32 +00:00
|
|
|
this.spawnZoneHighlights.clear();
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-22 20:55:41 +00:00
|
|
|
/**
|
|
|
|
|
* Main animation loop.
|
|
|
|
|
*/
|
2025-12-19 23:07:36 +00:00
|
|
|
animate() {
|
|
|
|
|
if (!this.isRunning) return;
|
|
|
|
|
requestAnimationFrame(this.animate);
|
|
|
|
|
|
2025-12-20 04:58:16 +00:00
|
|
|
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;
|
|
|
|
|
}
|
2025-12-19 23:08:54 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-20 00:02:42 +00:00
|
|
|
const time = Date.now() * 0.002;
|
|
|
|
|
this.unitMeshes.forEach((mesh) => {
|
|
|
|
|
mesh.position.y += Math.sin(time) * 0.002;
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-19 23:07:36 +00:00
|
|
|
this.renderer.render(this.scene, this.camera);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-28 00:54:03 +00:00
|
|
|
/**
|
|
|
|
|
* 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();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-22 20:55:41 +00:00
|
|
|
/**
|
|
|
|
|
* Stops the game loop and cleans up resources.
|
|
|
|
|
*/
|
2025-12-19 23:07:36 +00:00
|
|
|
stop() {
|
|
|
|
|
this.isRunning = false;
|
2025-12-28 00:54:03 +00:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 00:22:32 +00:00
|
|
|
if (this.inputManager && typeof this.inputManager.detach === "function") {
|
|
|
|
|
this.inputManager.detach();
|
|
|
|
|
}
|
2025-12-19 23:08:54 +00:00
|
|
|
if (this.controls) this.controls.dispose();
|
2025-12-19 23:07:36 +00:00
|
|
|
}
|
2025-12-22 22:34:43 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Updates the combat state in GameStateManager.
|
|
|
|
|
* Called when combat starts or when combat state changes (turn changes, etc.)
|
2025-12-24 00:22:32 +00:00
|
|
|
* Uses TurnSystem to get the spec-compliant CombatState, then enriches it for UI.
|
2025-12-22 22:34:43 +00:00
|
|
|
*/
|
2025-12-28 00:54:03 +00:00
|
|
|
async updateCombatState() {
|
2025-12-24 00:22:32 +00:00
|
|
|
if (!this.gameStateManager || !this.turnSystem) {
|
2025-12-22 22:34:43 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 00:22:32 +00:00
|
|
|
// Get spec-compliant combat state from TurnSystem
|
|
|
|
|
const turnSystemState = this.turnSystem.getCombatState();
|
2025-12-22 22:34:43 +00:00
|
|
|
|
2025-12-24 00:22:32 +00:00
|
|
|
if (!turnSystemState.isActive) {
|
|
|
|
|
// Combat not active, clear state
|
2025-12-22 22:34:43 +00:00
|
|
|
this.gameStateManager.setCombatState(null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 00:22:32 +00:00
|
|
|
// Get active unit for UI enrichment
|
|
|
|
|
const activeUnit = this.turnSystem.getActiveUnit();
|
2025-12-22 22:34:43 +00:00
|
|
|
|
2025-12-24 00:22:32 +00:00
|
|
|
// Build active unit status if we have an active unit (for UI)
|
2025-12-22 22:34:43 +00:00
|
|
|
let unitStatus = null;
|
|
|
|
|
if (activeUnit) {
|
2025-12-28 01:21:31 +00:00
|
|
|
// 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);
|
2025-12-22 22:34:43 +00:00
|
|
|
|
|
|
|
|
// 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",
|
|
|
|
|
}));
|
|
|
|
|
|
2025-12-28 00:54:03 +00:00
|
|
|
// Build skills from unit's actions
|
2025-12-22 22:34:43 +00:00
|
|
|
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,
|
|
|
|
|
}));
|
|
|
|
|
|
2025-12-28 00:54:03 +00:00
|
|
|
// 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);
|
|
|
|
|
|
2025-12-28 01:21:31 +00:00
|
|
|
// 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);
|
|
|
|
|
|
2025-12-28 00:54:03 +00:00
|
|
|
// 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)) {
|
2025-12-28 01:21:31 +00:00
|
|
|
// Get costAP from full skill definition
|
2025-12-28 00:54:03 +00:00
|
|
|
const costAP = fullSkill?.costs?.ap || skillData.costAP || 3;
|
2025-12-28 01:21:31 +00:00
|
|
|
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;
|
2025-12-28 00:54:03 +00:00
|
|
|
|
|
|
|
|
skills.push({
|
|
|
|
|
id: skillId,
|
2025-12-28 01:21:31 +00:00
|
|
|
name: existingAction.name,
|
|
|
|
|
icon: existingAction.icon,
|
2025-12-28 00:54:03 +00:00
|
|
|
costAP: costAP,
|
2025-12-28 01:21:31 +00:00
|
|
|
cooldown: currentCooldown,
|
2025-12-28 00:54:03 +00:00
|
|
|
isAvailable:
|
2025-12-28 01:21:31 +00:00
|
|
|
activeUnit.currentAP >= costAP && currentCooldown === 0,
|
2025-12-28 00:54:03 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} 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
|
2025-12-22 22:34:43 +00:00
|
|
|
if (skills.length === 0) {
|
|
|
|
|
skills.push({
|
|
|
|
|
id: "attack",
|
|
|
|
|
name: "Attack",
|
|
|
|
|
icon: "⚔",
|
|
|
|
|
costAP: 3,
|
|
|
|
|
cooldown: 0,
|
|
|
|
|
isAvailable: activeUnit.currentAP >= 3,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 05:01:54 +00:00
|
|
|
// 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";
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-22 22:34:43 +00:00
|
|
|
unitStatus = {
|
|
|
|
|
id: activeUnit.id,
|
|
|
|
|
name: activeUnit.name,
|
2025-12-24 05:01:54 +00:00
|
|
|
portrait: activePortrait,
|
2025-12-22 22:34:43 +00:00
|
|
|
hp: {
|
|
|
|
|
current: activeUnit.currentHealth,
|
|
|
|
|
max: activeUnit.maxHealth,
|
|
|
|
|
},
|
|
|
|
|
ap: {
|
|
|
|
|
current: activeUnit.currentAP,
|
|
|
|
|
max: maxAP,
|
|
|
|
|
},
|
|
|
|
|
charge: activeUnit.chargeMeter || 0,
|
|
|
|
|
statuses: statuses,
|
|
|
|
|
skills: skills,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 00:22:32 +00:00
|
|
|
// 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;
|
|
|
|
|
|
2025-12-24 05:01:54 +00:00
|
|
|
// 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";
|
|
|
|
|
}
|
2025-12-24 00:22:32 +00:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
unitId: unit.id,
|
2025-12-24 05:01:54 +00:00
|
|
|
portrait: portrait,
|
2025-12-24 00:22:32 +00:00
|
|
|
team: unit.team || "ENEMY",
|
|
|
|
|
initiative: unit.chargeMeter || 0,
|
|
|
|
|
};
|
|
|
|
|
})
|
|
|
|
|
.filter((entry) => entry !== null);
|
|
|
|
|
|
|
|
|
|
// Build combat state (enriched for UI, but includes spec fields)
|
2025-12-22 22:34:43 +00:00
|
|
|
const combatState = {
|
2025-12-24 00:22:32 +00:00
|
|
|
// 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
|
2025-12-28 01:21:31 +00:00
|
|
|
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)
|
2025-12-24 00:22:32 +00:00
|
|
|
roundNumber: turnSystemState.round, // Alias for UI
|
2025-12-22 22:34:43 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Update GameStateManager
|
|
|
|
|
this.gameStateManager.setCombatState(combatState);
|
|
|
|
|
}
|
2025-12-24 00:22:32 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-28 01:21:31 +00:00
|
|
|
// 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();
|
|
|
|
|
|
2025-12-24 00:22:32 +00:00
|
|
|
// DELEGATE to TurnSystem
|
|
|
|
|
this.turnSystem.endTurn(activeUnit);
|
|
|
|
|
|
|
|
|
|
// Update combat state (TurnSystem will have advanced to next unit)
|
2025-12-28 00:54:03 +00:00
|
|
|
this.updateCombatState().catch(console.error);
|
2025-12-24 00:22:32 +00:00
|
|
|
|
|
|
|
|
// 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();
|
2025-12-31 04:56:41 +00:00
|
|
|
|
|
|
|
|
// 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 });
|
|
|
|
|
}
|
2025-12-24 00:22:32 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Event handler for combat-start event from TurnSystem.
|
|
|
|
|
* @private
|
|
|
|
|
*/
|
|
|
|
|
_onCombatStart() {
|
|
|
|
|
// Combat has started
|
|
|
|
|
console.log("TurnSystem: Combat started");
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-31 04:50:11 +00:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-31 04:56:41 +00:00
|
|
|
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);
|
|
|
|
|
|
2025-12-31 21:52:59 +00:00
|
|
|
// Save Explorer progression back to roster
|
|
|
|
|
this._saveExplorerProgression();
|
|
|
|
|
|
2025-12-31 04:56:41 +00:00
|
|
|
// Pause the game
|
|
|
|
|
this.isPaused = true;
|
|
|
|
|
|
|
|
|
|
// Stop the game loop
|
|
|
|
|
this.stop();
|
|
|
|
|
|
2026-01-01 04:48:12 +00:00
|
|
|
// Clear the active run from persistence since mission is complete
|
|
|
|
|
if (this.gameStateManager) {
|
|
|
|
|
this.gameStateManager.clearActiveRun();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Wait for the outro narrative to complete before transitioning
|
|
|
|
|
// The outro is played in MissionManager.completeActiveMission()
|
|
|
|
|
// We'll listen for the narrative-end event to know when it's done
|
|
|
|
|
const hasOutro = this.gameStateManager?.missionManager?.currentMissionDef?.narrative?.outro_success;
|
|
|
|
|
|
|
|
|
|
if (hasOutro) {
|
|
|
|
|
console.log('GameLoop: Waiting for outro narrative to complete...');
|
|
|
|
|
const handleNarrativeEnd = () => {
|
|
|
|
|
console.log('GameLoop: Narrative end event received, transitioning to hub');
|
|
|
|
|
narrativeManager.removeEventListener('narrative-end', handleNarrativeEnd);
|
|
|
|
|
|
|
|
|
|
// Small delay after narrative ends to let user see the final message
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
if (this.gameStateManager) {
|
|
|
|
|
this.gameStateManager.transitionTo('STATE_MAIN_MENU');
|
|
|
|
|
}
|
|
|
|
|
}, 500);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
narrativeManager.addEventListener('narrative-end', handleNarrativeEnd);
|
|
|
|
|
|
|
|
|
|
// Fallback timeout: if narrative doesn't end within 30 seconds, transition anyway
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
console.warn('GameLoop: Narrative end timeout - transitioning to hub anyway');
|
|
|
|
|
narrativeManager.removeEventListener('narrative-end', handleNarrativeEnd);
|
|
|
|
|
if (this.gameStateManager) {
|
|
|
|
|
this.gameStateManager.transitionTo('STATE_MAIN_MENU');
|
|
|
|
|
}
|
|
|
|
|
}, 30000);
|
|
|
|
|
} else {
|
|
|
|
|
// No outro, transition immediately after a short delay
|
|
|
|
|
console.log('GameLoop: No outro narrative, transitioning to hub');
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
if (this.gameStateManager) {
|
|
|
|
|
this.gameStateManager.transitionTo('STATE_MAIN_MENU');
|
|
|
|
|
}
|
|
|
|
|
}, 1000);
|
|
|
|
|
}
|
2025-12-31 04:56:41 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-31 21:52:59 +00:00
|
|
|
/**
|
|
|
|
|
* Saves Explorer progression (classMastery, activeClassId) back to roster.
|
|
|
|
|
* @private
|
|
|
|
|
*/
|
|
|
|
|
_saveExplorerProgression() {
|
|
|
|
|
if (!this.unitManager || !this.gameStateManager) return;
|
|
|
|
|
|
|
|
|
|
const playerUnits = Array.from(this.unitManager.activeUnits.values())
|
|
|
|
|
.filter(u => u.team === 'PLAYER' && u.type === 'EXPLORER');
|
|
|
|
|
|
|
|
|
|
for (const unit of playerUnits) {
|
|
|
|
|
// Use rosterId if available, otherwise fall back to unit.id
|
|
|
|
|
const rosterId = unit.rosterId || unit.id;
|
|
|
|
|
if (!rosterId) continue;
|
|
|
|
|
|
|
|
|
|
const rosterUnit = this.gameStateManager.rosterManager.roster.find(
|
|
|
|
|
r => r.id === rosterId
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (rosterUnit) {
|
|
|
|
|
// Save classMastery progression
|
|
|
|
|
if (unit.classMastery) {
|
|
|
|
|
rosterUnit.classMastery = JSON.parse(JSON.stringify(unit.classMastery));
|
|
|
|
|
}
|
|
|
|
|
// Save activeClassId
|
|
|
|
|
if (unit.activeClassId) {
|
|
|
|
|
rosterUnit.activeClassId = unit.activeClassId;
|
|
|
|
|
}
|
2026-01-01 04:11:00 +00:00
|
|
|
// Save equipment/loadout
|
|
|
|
|
if (unit.loadout) {
|
|
|
|
|
rosterUnit.loadout = JSON.parse(JSON.stringify(unit.loadout));
|
|
|
|
|
}
|
|
|
|
|
if (unit.equipment) {
|
|
|
|
|
rosterUnit.equipment = JSON.parse(JSON.stringify(unit.equipment));
|
|
|
|
|
}
|
|
|
|
|
// Save current health
|
|
|
|
|
if (unit.currentHealth !== undefined) {
|
|
|
|
|
rosterUnit.currentHealth = unit.currentHealth;
|
|
|
|
|
}
|
2025-12-31 21:52:59 +00:00
|
|
|
console.log(`Saved progression for ${unit.name} (roster ID: ${rosterId})`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Save roster to persistence
|
|
|
|
|
if (this.gameStateManager.rosterManager) {
|
|
|
|
|
this.gameStateManager._saveRoster();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-31 04:56:41 +00:00
|
|
|
/**
|
|
|
|
|
* Handles mission failure.
|
|
|
|
|
* @param {Object} detail - Failure event detail
|
|
|
|
|
* @private
|
|
|
|
|
*/
|
|
|
|
|
_handleMissionFailure(detail) {
|
|
|
|
|
console.log('Mission Failed!', detail);
|
|
|
|
|
|
2025-12-31 21:52:59 +00:00
|
|
|
// Save Explorer progression back to roster (even on failure, progression should persist)
|
|
|
|
|
this._saveExplorerProgression();
|
|
|
|
|
|
2025-12-31 04:56:41 +00:00
|
|
|
// Pause the game
|
|
|
|
|
this.isPaused = true;
|
|
|
|
|
|
|
|
|
|
// Stop the game loop
|
|
|
|
|
this.stop();
|
|
|
|
|
|
2026-01-01 04:48:12 +00:00
|
|
|
// Clear the active run from persistence since mission is failed
|
|
|
|
|
if (this.gameStateManager) {
|
|
|
|
|
this.gameStateManager.clearActiveRun();
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-31 04:56:41 +00:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-31 04:50:11 +00:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 00:22:32 +00:00
|
|
|
/**
|
|
|
|
|
* Event handler for combat-end event from TurnSystem.
|
|
|
|
|
* @private
|
|
|
|
|
*/
|
|
|
|
|
_onCombatEnd() {
|
|
|
|
|
// Combat has ended
|
|
|
|
|
console.log("TurnSystem: Combat ended");
|
|
|
|
|
this.clearMovementHighlights();
|
|
|
|
|
}
|
2025-12-19 23:07:36 +00:00
|
|
|
}
|