diff --git a/src/core/CombatIntegration.spec.md b/specs/CombatIntegration.spec.md similarity index 100% rename from src/core/CombatIntegration.spec.md rename to specs/CombatIntegration.spec.md diff --git a/src/core/CombatState.spec.md b/specs/CombatState.spec.md similarity index 100% rename from src/core/CombatState.spec.md rename to specs/CombatState.spec.md diff --git a/specs/Combat_Skill_Usage.spec.md b/specs/Combat_Skill_Usage.spec.md new file mode 100644 index 0000000..9f6733f --- /dev/null +++ b/specs/Combat_Skill_Usage.spec.md @@ -0,0 +1,130 @@ +# **Combat Skill Usage Specification** + +This document defines the workflow for selecting, targeting, and executing Active Skills during the Combat Phase. + +## **1. The Interaction Flow** + +The process follows a strict 3-step sequence: + +1. **Selection (UI):** Player clicks a skill button in the HUD. +2. **Targeting (Grid):** Game enters TARGETING_MODE. Player moves cursor to select a target. Valid targets are highlighted. +3. **Execution (Engine):** Player confirms selection. Costs are paid, and effects are applied. + +## **2. State Machine Updates** + +We need to expand the CombatState in GameLoop to handle targeting. + +| State | Description | Input Behavior | +| :------------------------ | :---------------------------------------------------------- | :------------------------------------------------------------------------------------------- | +| **IDLE / SELECTING_MOVE** | Standard state. Cursor highlights movement range. | Click Unit = Select. Click Empty = Move. | +| **TARGETING_SKILL** | Player has selected a skill. Cursor highlights Skill Range. | **Hover:** Update AoE Reticle. **Click:** Execute Skill. **Cancel (B/Esc):** Return to IDLE. | +| **EXECUTING_SKILL** | Animation playing. Input locked. | None. | + +## **3. The Skill Targeting System** + +We need a helper system (src/systems/SkillTargetingSystem.js) to handle the complex math of "Can I hit this?" without cluttering the GameLoop. + +### **Core Logic: isValidTarget(source, targetTile, skillDef)** + +1. **Range Check:** Manhattan distance between Source and Target <= skill.range. +2. **Line of Sight (LOS):** Raycast from Source head height to Target center. Must not hit isSolid voxels (unless skill has ignore_cover). +3. **Content Check:** + - If target_type is **ENEMY**: Tile must contain a unit AND unit.team != source.team. + - If target_type is **ALLY**: Tile must contain a unit AND unit.team == source.team. + - If target_type is **EMPTY**: Tile must be empty. + +### **Visual Logic: getAffectedTiles(targetTile, skillDef)** + +Calculates the Area of Effect (AoE) to highlight in **Red**. + +- **SINGLE:** Just the target tile. +- **CIRCLE (Radius R):** All tiles within distance R of target. +- **LINE (Length L):** Raycast L tiles in the cardinal direction from Source to Target. +- **CONE:** A triangle pattern originating from Source. + +## **4. Integration Steps (How to Code It)** + +### **Step 1: Create SkillTargetingSystem** + +This class encapsulates the math. + +```js +class SkillTargetingSystem { + constructor(grid, unitManager) { ... } + + /** Returns { valid: boolean, reason: string } */ + validateTarget(sourceUnit, targetPos, skillId) { ... } + + /** Returns array of {x,y,z} for highlighting */ + getAoETiles(sourcePos, cursorPos, skillId) { ... } +} +``` + +### **Step 2: Update GameLoop State** + +Add activeSkillId to track which skill is pending. + +```js +// In GameLoop.js + +// 1. Handle UI Event +onSkillClicked(skillId) { + // Validate unit has AP + if (this.unit.currentAP < getSkillCost(skillId)) return; + + this.combatState = 'TARGETING_SKILL'; + this.activeSkillId = skillId; + + // VISUALS: Clear Movement Blue Grid -> Show Attack Red Grid (Range) + const skill = this.unit.skills.get(skillId); + this.voxelManager.highlightRange(this.unit.pos, skill.range, 'RED_OUTLINE'); +} + +// 2. Handle Cursor Hover (InputManager event) +onCursorHover(pos) { + if (this.combatState === 'TARGETING_SKILL') { + const aoeTiles = this.targetingSystem.getAoETiles(this.unit.pos, pos, this.activeSkillId); + this.voxelManager.showReticle(aoeTiles); // Solid Red Highlight + } +} +``` + +### **Step 3: Execution Logic** + +When the player confirms the click. + +```js +// In GameLoop.js -> triggerSelection() + +if (this.combatState === 'TARGETING_SKILL') { + const valid = this.targetingSystem.validateTarget(this.unit, cursor, this.activeSkillId); + + if (valid) { + this.executeSkill(this.activeSkillId, cursor); + } else { + // Audio: Error Buzz + console.log("Invalid Target"); + } +} + +executeSkill(skillId, targetPos) { + this.combatState = 'EXECUTING_SKILL'; + + // 1. Deduct Costs (AP, Cooldown) via SkillManager + this.unit.skillManager.payCosts(skillId); + + // 2. Get Targets (Units in AoE) + const targets = this.targetingSystem.getUnitsInAoE(targetPos, skillId); + + // 3. Process Effects (Damage, Status) via EffectProcessor + const skillDef = this.registry.get(skillId); + skillDef.effects.forEach(eff => { + targets.forEach(t => this.effectProcessor.process(eff, this.unit, t)); + }); + + // 4. Cleanup + this.combatState = 'IDLE'; + this.activeSkillId = null; + this.voxelManager.clearHighlights(); +} +``` diff --git a/specs/EffectProcessor.spec.md b/specs/EffectProcessor.spec.md new file mode 100644 index 0000000..a39ad90 --- /dev/null +++ b/specs/EffectProcessor.spec.md @@ -0,0 +1,132 @@ +# **Effect Processor Specification: The Game Logic Engine** + +This document defines the architecture for the **Effect Processor**, the central system responsible for executing all changes to the game state (Damage, Healing, Movement, Spawning). + +## **1. System Overview** + +The EffectProcessor is a stateless logic engine. It takes a **Definition** (What to do) and a **Context** (Who is doing it to whom), and applies the necessary mutations to the UnitManager or VoxelGrid. + +### **Architectural Role** + +- **Input:** EffectDefinition (JSON), Source (Unit), Target (Unit/Tile). +- **Output:** State Mutation (HP changed, Unit moved) + EffectResult (Log data). +- **Pattern:** Strategy Pattern. Each effect_type maps to a specific Handler Function. + +## **2. Integration Points** + +### **A. Calling the Processor** + +The Processor is never called directly by the UI. It is invoked by: + +1. **SkillManager:** When an Active Skill is executed. +2. **EventSystem:** When a Passive Item triggers (e.g., "On Hit -> Apply Burn"). +3. **Environmental Hazard:** When a unit starts their turn on Fire/Acid. + +### **B. Dependencies** + +The Processor requires injection of: + +- VoxelGrid: To check collision, modify terrain, or move units. +- UnitManager: To find neighbors (Chain Lightning) or spawn tokens (Turrets). +- RNG: A seeded random number generator for damage variance and status chances. + +## **3. Data Structure (JSON Schema)** + +Every effect in the game must adhere to this structure. + +```typescript +interface EffectDefinition { + type: EffectType; + + // -- Magnitude -- + power?: number; // Base amount (Damage/Heal) + attribute?: string; // Stat to scale off (e.g., "strength", "magic") + scaling?: number; // Multiplier for attribute (Default: 1.0) + + // -- Flavour -- + element?: "PHYSICAL" | "FIRE" | "ICE" | "SHOCK" | "VOID" | "TECH"; + + // -- Status/Buffs -- + status_id?: string; // For APPLY_STATUS + duration?: number; // Turns + chance?: number; // 0.0 to 1.0 + + // -- Movement/Physics -- + force?: number; // Distance for Push/Pull + destination?: "TARGET" | "BEHIND_TARGET"; // For Teleport + + // -- Conditionals -- + condition?: { + target_tag?: string; // e.g. "MECHANICAL" + target_status?: string; // e.g. "WET" + hp_threshold?: number; // e.g. 0.3 (30%) + }; +} + +type EffectType = + | "DAMAGE" + | "HEAL" + | "APPLY_STATUS" + | "REMOVE_STATUS" + | "TELEPORT" + | "PUSH" + | "PULL" + | "SPAWN_UNIT" + | "MODIFY_TERRAIN" // Destroy walls, create hazards + | "CHAIN_DAMAGE"; // Bouncing projectiles +``` + +## 4. Handler Specifications + +### Handler: `DAMAGE` + +- **Logic:** `FinalDamage = (BasePower + (Source[Attribute] * Scaling)) - Target.Defense`. +- **Element Check:** If Target has Resistance/Weakness to `element`, modify FinalDamage. +- **Result:** `Target.currentHP -= FinalDamage`. + +### Handler: `CHAIN_DAMAGE` + +- **Logic:** Apply `DAMAGE` to primary target. Then, scan for N nearest enemies within Range R. Apply `DAMAGE * Decay` to them. +- **Synergy:** If `condition.target_status` is present on a target, the chain may branch or deal double damage. + +### Handler: `TELEPORT` + +- **Logic:** Validate destination tile (must be Air and Unoccupied). Update `Unit.position` and `VoxelGrid.unitMap`. +- **Visuals:** Trigger "Vanish" VFX at old pos, "Appear" VFX at new pos. + +### Handler: `MODIFY_TERRAIN` + +- **Logic:** Update `VoxelGrid` ID at Target coordinates. +- **Use Case:** Sapper's "Breach Charge" turns `ID_WALL` into `ID_AIR`. +- **Safety:** Check `VoxelGrid.isDestructible()`. Do not destroy bedrock. + +--- + +## 5. Conditions of Acceptance (CoA) + +**CoA 1: Attribute Scaling** + +- Given a Damage Effect with `power: 10` and `attribute: "magic"`, if Source has `magic: 5`, the output damage must be 15. + +**CoA 2: Conditional Logic** + +- Given an Effect with `condition: { target_status: "WET" }`, if the target does _not_ have the "WET" status, the effect must **not** execute (return early). + +**CoA 3: State Mutation** + +- When `APPLY_STATUS` is executed, the Target unit's `statusEffects` array must contain the new ID with the correct duration. + +**CoA 4: Physics Safety** + +- When `PUSH` is executed, the system must check `VoxelGrid.isSolid()` behind the target. If a wall exists, the unit must **not** move into the wall (optionally take "Smash" damage instead). + +--- + +## 6. Prompt for Coding Agent + +> "Create `src/systems/EffectProcessor.js`. +> +> 1. **Constructor:** Accept `VoxelGrid` and `UnitManager`. Initialize a map of `handlers`. +> 2. **Process Method:** `process(effectDef, source, target)`. Look up handler by `effectDef.type`. Verify `checkConditions()`. Execute handler. Return `ResultObject`. +> 3. **Handlers:** Implement `handleDamage`, `handleHeal`, `handleStatus`, `handleMove`. +> 4. **Helper:** Implement `calculatePower(def, source)` to handle attribute scaling logic centrally." diff --git a/src/core/Turn-System.spec.md b/specs/Turn-System.spec.md similarity index 100% rename from src/core/Turn-System.spec.md rename to specs/Turn-System.spec.md diff --git a/src/core/TurnLifecycle.spec.md b/specs/TurnLifecycle.spec.md similarity index 100% rename from src/core/TurnLifecycle.spec.md rename to specs/TurnLifecycle.spec.md diff --git a/src/ui/combat-hud.spec.js b/specs/combat-hud.spec.md similarity index 100% rename from src/ui/combat-hud.spec.js rename to specs/combat-hud.spec.md diff --git a/src/assets/skills/skill_breach_move.json b/src/assets/skills/skill_breach_move.json new file mode 100644 index 0000000..11201a0 --- /dev/null +++ b/src/assets/skills/skill_breach_move.json @@ -0,0 +1,24 @@ +{ + "id": "SKILL_BREACH_MOVE", + "name": "Tunnel Vision", + "class": "Sapper", + "description": "Charge through a wall, destroying it and dealing damage to anything on the other side.", + "type": "ACTIVE", + "costs": { "ap": 3 }, + "cooldown_turns": 5, + "targeting": { + "range": 1, + "type": "OBSTACLE" + }, + "effects": [ + { "type": "DESTROY_VOXEL" }, + { "type": "MOVE_TO_TARGET" }, + { + "type": "DAMAGE", + "power": 20, + "attribute": "attack", + "area_of_effect": { "shape": "CONE", "size": 2 } + } + ] +} + diff --git a/src/assets/skills/skill_chain_lightning.json b/src/assets/skills/skill_chain_lightning.json new file mode 100644 index 0000000..62497e0 --- /dev/null +++ b/src/assets/skills/skill_chain_lightning.json @@ -0,0 +1,25 @@ +{ + "id": "SKILL_CHAIN_LIGHTNING", + "name": "Chain Lightning", + "description": "Deals damage that jumps to nearby enemies. Deals double damage to Wet targets.", + "type": "ACTIVE", + "costs": { "ap": 3 }, + "cooldown_turns": 3, + "targeting": { + "range": 6, + "type": "ENEMY", + "line_of_sight": true + }, + "effects": [ + { + "type": "CHAIN_DAMAGE", + "power": 8, + "attribute": "magic", + "element": "SHOCK", + "bounces": 2, + "synergy_trigger": "STATUS_WET" + } + ] +} + + diff --git a/src/assets/skills/skill_deploy_turret.json b/src/assets/skills/skill_deploy_turret.json new file mode 100644 index 0000000..7e1ad30 --- /dev/null +++ b/src/assets/skills/skill_deploy_turret.json @@ -0,0 +1,17 @@ +{ + "id": "SKILL_DEPLOY_TURRET", + "name": "Deploy Auto-Turret", + "description": "Builds a stationary turret that shoots the nearest enemy at the end of the turn.", + "type": "ACTIVE", + "costs": { "ap": 3 }, + "cooldown_turns": 4, + "targeting": { + "range": 1, + "type": "EMPTY" + }, + "effects": [ + { "type": "SPAWN_UNIT", "unit_id": "UNIT_TURRET_MK1", "duration": 3 } + ] +} + + diff --git a/src/assets/skills/skill_execute.json b/src/assets/skills/skill_execute.json new file mode 100644 index 0000000..562fbc1 --- /dev/null +++ b/src/assets/skills/skill_execute.json @@ -0,0 +1,26 @@ +{ + "id": "SKILL_EXECUTE", + "name": "Execute", + "description": "A heavy strike that deals double damage if the target is below 30% Health.", + "type": "ACTIVE", + "costs": { "ap": 3 }, + "cooldown_turns": 3, + "targeting": { + "range": 1, + "type": "ENEMY" + }, + "effects": [ + { + "type": "DAMAGE", + "power": 15, + "attribute": "attack", + "element": "PHYSICAL", + "conditional_multiplier": { + "condition": "TARGET_HP_LOW", + "value": 2.0 + } + } + ] +} + + diff --git a/src/assets/skills/skill_fireball.json b/src/assets/skills/skill_fireball.json new file mode 100644 index 0000000..40bdae7 --- /dev/null +++ b/src/assets/skills/skill_fireball.json @@ -0,0 +1,30 @@ +{ + "id": "SKILL_FIREBALL", + "name": "Fireball", + "description": "Hurls a ball of fire that explodes on impact.", + "type": "ACTIVE", + "costs": { "ap": 2 }, + "cooldown_turns": 2, + "targeting": { + "range": 5, + "type": "ENEMY", + "line_of_sight": true, + "area_of_effect": { "shape": "CIRCLE", "size": 1 } + }, + "effects": [ + { + "type": "DAMAGE", + "power": 10, + "attribute": "magic", + "element": "FIRE" + }, + { + "type": "APPLY_STATUS", + "status_id": "STATUS_SCORCH", + "chance": 1.0, + "duration": 2 + } + ] +} + + diff --git a/src/assets/skills/skill_flashbang.json b/src/assets/skills/skill_flashbang.json new file mode 100644 index 0000000..5d0f322 --- /dev/null +++ b/src/assets/skills/skill_flashbang.json @@ -0,0 +1,19 @@ +{ + "id": "SKILL_FLASHBANG", + "name": "Flashbang", + "description": "Blinds enemies in an area, removing Overwatch and reducing Accuracy.", + "type": "ACTIVE", + "costs": { "ap": 2 }, + "cooldown_turns": 3, + "targeting": { + "range": 4, + "type": "ENEMY", + "area_of_effect": { "shape": "CIRCLE", "size": 2 } + }, + "effects": [ + { "type": "APPLY_STATUS", "status_id": "STATUS_BLIND", "duration": 2 }, + { "type": "REMOVE_STATUS", "status_id": "STATUS_OVERWATCH" } + ] +} + + diff --git a/src/assets/skills/skill_grapple_hook.json b/src/assets/skills/skill_grapple_hook.json new file mode 100644 index 0000000..130d768 --- /dev/null +++ b/src/assets/skills/skill_grapple_hook.json @@ -0,0 +1,16 @@ +{ + "id": "SKILL_GRAPPLE_HOOK", + "name": "Grapple Hook", + "description": "If target is an Enemy, pull them to you. If target is a Wall, pull yourself to it.", + "type": "ACTIVE", + "costs": { "ap": 2 }, + "cooldown_turns": 2, + "targeting": { + "range": 5, + "type": "ANY", + "line_of_sight": true + }, + "effects": [{ "type": "PHYSICS_PULL", "force": 5 }] +} + + diff --git a/src/assets/skills/skill_grenade.json b/src/assets/skills/skill_grenade.json new file mode 100644 index 0000000..896faa5 --- /dev/null +++ b/src/assets/skills/skill_grenade.json @@ -0,0 +1,20 @@ +{ + "id": "SKILL_GRENADE", + "name": "Scrap Bomb", + "class": "Field Engineer", + "description": "Thrown explosive that deals damage and destroys Cover objects.", + "type": "ACTIVE", + "costs": { "ap": 2 }, + "cooldown_turns": 2, + "targeting": { + "range": 5, + "type": "EMPTY", + "area_of_effect": { "shape": "CIRCLE", "size": 2 } + }, + "effects": [ + { "type": "DAMAGE", "power": 12, "element": "PHYSICAL" }, + { "type": "DESTROY_OBJECTS", "tag": "COVER" } + ] +} + + diff --git a/src/assets/skills/skill_ice_wall.json b/src/assets/skills/skill_ice_wall.json new file mode 100644 index 0000000..8479cd7 --- /dev/null +++ b/src/assets/skills/skill_ice_wall.json @@ -0,0 +1,18 @@ +{ + "id": "SKILL_ICE_WALL", + "name": "Glacial Barrier", + "description": "Summons 3 destructible Ice Voxels to block movement and sight.", + "type": "ACTIVE", + "costs": { "ap": 3 }, + "cooldown_turns": 5, + "targeting": { + "range": 4, + "type": "EMPTY", + "area_of_effect": { "shape": "LINE", "size": 3 } + }, + "effects": [ + { "type": "SPAWN_OBJECT", "object_id": "OBJ_ICE_WALL", "duration": 3 } + ] +} + + diff --git a/src/assets/skills/skill_intercept.json b/src/assets/skills/skill_intercept.json new file mode 100644 index 0000000..3b84b21 --- /dev/null +++ b/src/assets/skills/skill_intercept.json @@ -0,0 +1,25 @@ +{ + "id": "SKILL_INTERCEPT", + "name": "Intercept", + "description": "Dash to an ally's position and swap places, taking any incoming attacks for them.", + "type": "ACTIVE", + "costs": { "ap": 3 }, + "cooldown_turns": 4, + "targeting": { + "range": 4, + "type": "ALLY", + "line_of_sight": true + }, + "effects": [ + { "type": "MOVE_TO_TARGET" }, + { "type": "SWAP_POSITIONS" }, + { + "type": "APPLY_STATUS", + "status_id": "STATUS_GUARD", + "target": "SELF", + "duration": 1 + } + ] +} + + diff --git a/src/assets/skills/skill_mend.json b/src/assets/skills/skill_mend.json new file mode 100644 index 0000000..7023a86 --- /dev/null +++ b/src/assets/skills/skill_mend.json @@ -0,0 +1,16 @@ +{ + "id": "SKILL_MEND", + "name": "Mend Flesh", + "description": "Restores HP to a single biological target.", + "type": "ACTIVE", + "costs": { "ap": 2 }, + "cooldown_turns": 1, + "targeting": { + "range": 3, + "type": "ALLY", + "line_of_sight": true + }, + "effects": [{ "type": "HEAL", "power": 15, "attribute": "willpower" }] +} + + diff --git a/src/assets/skills/skill_purify.json b/src/assets/skills/skill_purify.json new file mode 100644 index 0000000..26656b8 --- /dev/null +++ b/src/assets/skills/skill_purify.json @@ -0,0 +1,18 @@ +{ + "id": "SKILL_PURIFY", + "name": "Cleansing Light", + "description": "Removes all negative Status Effects and clears Corruption from the ground tile.", + "type": "ACTIVE", + "costs": { "ap": 1 }, + "cooldown_turns": 2, + "targeting": { + "range": 4, + "type": "ALLY" + }, + "effects": [ + { "type": "REMOVE_ALL_DEBUFFS" }, + { "type": "MODIFY_TERRAIN", "action": "CLEANSE_HAZARD" } + ] +} + + diff --git a/src/assets/skills/skill_repair_bot.json b/src/assets/skills/skill_repair_bot.json new file mode 100644 index 0000000..0a7bbc9 --- /dev/null +++ b/src/assets/skills/skill_repair_bot.json @@ -0,0 +1,15 @@ +{ + "id": "SKILL_REPAIR_BOT", + "name": "Percussive Maintenance", + "description": "Heals a Mechanical ally or Structure.", + "type": "ACTIVE", + "costs": { "ap": 1 }, + "cooldown_turns": 1, + "targeting": { + "range": 1, + "type": "ALLY" + }, + "effects": [{ "type": "HEAL", "power": 20, "condition": "IS_MECHANICAL" }] +} + + diff --git a/src/assets/skills/skill_shield_bash.json b/src/assets/skills/skill_shield_bash.json new file mode 100644 index 0000000..1932e87 --- /dev/null +++ b/src/assets/skills/skill_shield_bash.json @@ -0,0 +1,28 @@ +{ + "id": "SKILL_SHIELD_BASH", + "name": "Shield Bash", + "description": "Strike an enemy with your shield, dealing damage and Stunning them.", + "type": "ACTIVE", + "costs": { "ap": 2 }, + "cooldown_turns": 2, + "targeting": { + "range": 1, + "type": "ENEMY", + "line_of_sight": true + }, + "effects": [ + { + "type": "DAMAGE", + "power": 5, + "attribute": "attack", + "element": "PHYSICAL" + }, + { + "type": "APPLY_STATUS", + "status_id": "STATUS_STUN", + "chance": 1.0, + "duration": 1 + } + ] +} + diff --git a/src/assets/skills/skill_shock_grenade.json b/src/assets/skills/skill_shock_grenade.json new file mode 100644 index 0000000..6880d61 --- /dev/null +++ b/src/assets/skills/skill_shock_grenade.json @@ -0,0 +1,28 @@ +{ + "id": "SKILL_SHOCK_GRENADE", + "name": "Shock Grenade", + "description": "Deals Tech damage and stuns mechanical enemies.", + "type": "ACTIVE", + "costs": { "ap": 2 }, + "cooldown_turns": 3, + "targeting": { + "range": 4, + "type": "ENEMY", + "area_of_effect": { "shape": "CIRCLE", "size": 2 } + }, + "effects": [ + { + "type": "DAMAGE", + "power": 8, + "attribute": "tech", + "element": "SHOCK" + }, + { + "type": "APPLY_STATUS", + "status_id": "STATUS_STUN", + "conditional": "IS_MECHANICAL" + } + ] +} + + diff --git a/src/assets/skills/skill_sprint.json b/src/assets/skills/skill_sprint.json new file mode 100644 index 0000000..2bbf107 --- /dev/null +++ b/src/assets/skills/skill_sprint.json @@ -0,0 +1,14 @@ +{ + "id": "SKILL_SPRINT", + "name": "Sprint", + "description": "Doubles Movement Allowance for this turn.", + "type": "ACTIVE", + "costs": { "ap": 1 }, + "cooldown_turns": 2, + "targeting": { "type": "SELF" }, + "effects": [ + { "type": "APPLY_BUFF", "stat": "movement", "value": 5, "duration": 1 } + ] +} + + diff --git a/src/assets/skills/skill_stealth.json b/src/assets/skills/skill_stealth.json new file mode 100644 index 0000000..f38baa4 --- /dev/null +++ b/src/assets/skills/skill_stealth.json @@ -0,0 +1,14 @@ +{ + "id": "SKILL_STEALTH", + "name": "Shadow Cloak", + "description": "Become invisible to enemies. Attacking breaks stealth but deals critical damage.", + "type": "ACTIVE", + "costs": { "ap": 2 }, + "cooldown_turns": 4, + "targeting": { "type": "SELF" }, + "effects": [ + { "type": "APPLY_STATUS", "status_id": "STATUS_STEALTH", "duration": 2 } + ] +} + + diff --git a/src/assets/skills/skill_taunt.json b/src/assets/skills/skill_taunt.json new file mode 100644 index 0000000..620da95 --- /dev/null +++ b/src/assets/skills/skill_taunt.json @@ -0,0 +1,24 @@ +{ + "id": "SKILL_TAUNT", + "name": "Challenger's Shout", + "description": "Force all enemies in range to target this unit next turn.", + "type": "ACTIVE", + "costs": { "ap": 1 }, + "cooldown_turns": 3, + "targeting": { + "range": 3, + "type": "ENEMY", + "line_of_sight": true, + "area_of_effect": { "shape": "CIRCLE", "size": 3 } + }, + "effects": [ + { + "type": "APPLY_STATUS", + "status_id": "STATUS_TAUNTED", + "chance": 1.0, + "duration": 1 + } + ] +} + + diff --git a/src/assets/skills/skill_teleport.json b/src/assets/skills/skill_teleport.json new file mode 100644 index 0000000..d50550b --- /dev/null +++ b/src/assets/skills/skill_teleport.json @@ -0,0 +1,16 @@ +{ + "id": "SKILL_TELEPORT", + "name": "Phase Shift", + "description": "Instantly move to any target tile within range, ignoring obstacles.", + "type": "ACTIVE", + "costs": { "ap": 2 }, + "cooldown_turns": 4, + "targeting": { + "range": 5, + "type": "EMPTY", + "line_of_sight": false + }, + "effects": [{ "type": "TELEPORT" }] +} + + diff --git a/src/assets/skills/skill_ward_corruption.json b/src/assets/skills/skill_ward_corruption.json new file mode 100644 index 0000000..bea77bf --- /dev/null +++ b/src/assets/skills/skill_ward_corruption.json @@ -0,0 +1,17 @@ +{ + "id": "SKILL_WARD_CORRUPTION", + "name": "Aether Ward", + "description": "Grants an ally immunity to Status Effects and reduces Magic Damage taken.", + "type": "ACTIVE", + "costs": { "ap": 2 }, + "cooldown_turns": 3, + "targeting": { + "range": 4, + "type": "ALLY" + }, + "effects": [ + { "type": "APPLY_STATUS", "status_id": "STATUS_WARDED", "duration": 2 } + ] +} + + diff --git a/src/assets/skills/skill_warp_strike.json b/src/assets/skills/skill_warp_strike.json new file mode 100644 index 0000000..f33a595 --- /dev/null +++ b/src/assets/skills/skill_warp_strike.json @@ -0,0 +1,23 @@ +{ + "id": "SKILL_WARP_STRIKE", + "name": "Warp Strike", + "class": "Battle Mage", + "description": "Teleport to an enemy and immediately attack with melee weapon.", + "type": "ACTIVE", + "costs": { "ap": 3 }, + "cooldown_turns": 3, + "targeting": { + "range": 5, + "type": "ENEMY" + }, + "effects": [ + { "type": "TELEPORT", "location": "ADJACENT_TO_TARGET" }, + { + "type": "DAMAGE", + "power": 10, + "attribute": "attack", + "element": "MAGIC" + } + ] +} + diff --git a/src/core/GameLoop.js b/src/core/GameLoop.js index 0c86028..35a2f48 100644 --- a/src/core/GameLoop.js +++ b/src/core/GameLoop.js @@ -18,6 +18,15 @@ import { InputManager } from "./InputManager.js"; import { MissionManager } from "../managers/MissionManager.js"; import { TurnSystem } from "../systems/TurnSystem.js"; import { MovementSystem } from "../systems/MovementSystem.js"; +import { SkillTargetingSystem } from "../systems/SkillTargetingSystem.js"; +import { skillRegistry } from "../managers/SkillRegistry.js"; + +// Import class definitions +import vanguardDef from "../assets/data/classes/vanguard.json" with { type: "json" }; +import weaverDef from "../assets/data/classes/aether_weaver.json" with { type: "json" }; +import scavengerDef from "../assets/data/classes/scavenger.json" with { type: "json" }; +import tinkerDef from "../assets/data/classes/tinker.json" with { type: "json" }; +import custodianDef from "../assets/data/classes/custodian.json" with { type: "json" }; /** * Main game loop managing rendering, input, and game state. @@ -52,6 +61,8 @@ export class GameLoop { this.turnSystem = null; /** @type {MovementSystem | null} */ this.movementSystem = null; + /** @type {SkillTargetingSystem | null} */ + this.skillTargetingSystem = null; /** @type {Map} */ this.unitMeshes = new Map(); @@ -59,6 +70,10 @@ export class GameLoop { this.movementHighlights = new Set(); /** @type {Set} */ this.spawnZoneHighlights = new Set(); + /** @type {Set} */ + this.rangeHighlights = new Set(); + /** @type {Set} */ + this.aoeReticle = new Set(); /** @type {RunData | null} */ this.runData = null; /** @type {Position[]} */ @@ -85,6 +100,12 @@ export class GameLoop { /** @type {import("./GameStateManager.js").GameStateManagerClass | null} */ this.gameStateManager = null; + + // Skill Targeting State + /** @type {"IDLE" | "SELECTING_MOVE" | "TARGETING_SKILL" | "EXECUTING_SKILL"} */ + this.combatState = "IDLE"; + /** @type {string | null} */ + this.activeSkillId = null; } /** @@ -114,6 +135,7 @@ export class GameLoop { // --- INSTANTIATE COMBAT SYSTEMS --- this.turnSystem = new TurnSystem(); this.movementSystem = new MovementSystem(); + // SkillTargetingSystem will be initialized in startLevel when grid/unitManager are ready // --- SETUP INPUT MANAGER --- this.inputManager = new InputManager( @@ -129,6 +151,9 @@ export class GameLoop { this.inputManager.addEventListener("keydown", (e) => this.handleKeyInput(e.detail) ); + this.inputManager.addEventListener("hover", (e) => + this.onCursorHover(e.detail.voxelPosition) + ); // Default Validator: Movement Logic (Will be overridden in startLevel) this.inputManager.setValidator(this.validateCursorMove.bind(this)); @@ -242,6 +267,12 @@ export class GameLoop { if (code === "Space" || code === "Enter") { this.triggerSelection(); } + if (code === "Escape" || code === "KeyB") { + // Cancel skill targeting + if (this.combatState === "TARGETING_SKILL") { + this.cancelSkillTargeting(); + } + } if (code === "Tab") { this.selectionMode = this.selectionMode === "MOVEMENT" ? "TARGETING" : "MOVEMENT"; @@ -305,8 +336,13 @@ export class GameLoop { this.gameStateManager && this.gameStateManager.currentState === "STATE_COMBAT" ) { - // Handle combat movement - this.handleCombatMovement(cursor); + // Handle combat actions based on state + if (this.combatState === "TARGETING_SKILL") { + this.handleSkillTargeting(cursor); + } else { + // Default to movement + this.handleCombatMovement(cursor); + } } } @@ -351,6 +387,186 @@ export class GameLoop { } } + /** + * 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; + + // Find skill in unit's actions + const skill = (activeUnit.actions || []).find((a) => a.id === skillId); + if (!skill) { + console.warn(`Skill ${skillId} not found in unit actions`); + return; + } + + // Validate unit has AP + if (activeUnit.currentAP < (skill.costAP || 0)) { + console.log("Insufficient AP"); + return; + } + + // Enter targeting mode + this.combatState = "TARGETING_SKILL"; + this.activeSkillId = skillId; + + // Clear movement highlights and show skill range + this.clearMovementHighlights(); + const skillDef = this.skillTargetingSystem.getSkillDef(skillId); + if (skillDef && this.voxelManager) { + this.voxelManager.highlightRange( + activeUnit.position, + skillDef.range, + "RED_OUTLINE" + ); + } + + 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) + const targets = this.skillTargetingSystem.getUnitsInAoE( + activeUnit.position, + targetPos, + skillId + ); + + // 3. Process Effects (Damage, Status) via EffectProcessor + // TODO: Implement EffectProcessor + // const skillDef = this.skillTargetingSystem.getSkillDef(skillId); + // if (skillDef && skillDef.effects) { + // skillDef.effects.forEach(eff => { + // targets.forEach(t => this.effectProcessor.process(eff, activeUnit, t)); + // }); + // } + + console.log( + `Executed skill ${skillId} at ${targetPos.x},${targetPos.y},${targetPos.z}, hit ${targets.length} targets` + ); + + // 4. Cleanup + this.combatState = "IDLE"; + this.activeSkillId = null; + // Clear skill highlights + if (this.voxelManager) { + this.voxelManager.clearHighlights(); + } + // Restore movement highlights if we have an active unit + if (this.turnSystem) { + const activeUnit = this.turnSystem.getActiveUnit(); + if (activeUnit && activeUnit.team === "PLAYER") { + this.updateMovementHighlights(activeUnit); + } + } + + // Update combat state + this.updateCombatState(); + } + + /** + * 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); + } + } + } + /** * Starts a mission by ID. * @param {string} missionId - Mission identifier @@ -401,32 +617,72 @@ export class GameLoop { this.voxelManager.updateMaterials(generator.generatedAssets); this.voxelManager.update(); + // Set up highlight tracking sets + this.voxelManager.setHighlightSets(this.rangeHighlights, this.aoeReticle); + if (this.controls) this.voxelManager.focusCamera(this.controls); - const mockRegistry = { + // Create a proper registry with actual class definitions + const classRegistry = new Map(); + + // Register all class definitions + const classDefs = [ + vanguardDef, + weaverDef, + scavengerDef, + tinkerDef, + custodianDef, + ]; + + for (const classDef of classDefs) { + if (classDef && classDef.id) { + // Add type field for compatibility + classRegistry.set(classDef.id, { + ...classDef, + type: "EXPLORER", + }); + } + } + + // Create registry object with get method for UnitManager + const unitRegistry = { get: (id) => { - if (id.startsWith("CLASS_")) + // Try to get from class registry first + if (classRegistry.has(id)) { + return classRegistry.get(id); + } + + // Fallback for enemy units + if (id.startsWith("ENEMY_")) { return { - type: "EXPLORER", - name: id, - id: id, - base_stats: { health: 100, attack: 10, defense: 5, speed: 10 }, - growth_rates: {}, + type: "ENEMY", + name: "Enemy", + stats: { health: 50, attack: 8, defense: 3, speed: 8 }, + ai_archetype: "BRUISER", }; - return { - type: "ENEMY", - name: "Enemy", - stats: { health: 50, attack: 8, defense: 3, speed: 8 }, - ai_archetype: "BRUISER", - }; + } + + console.warn(`Unit definition not found: ${id}`); + return null; }, }; - this.unitManager = new UnitManager(mockRegistry); + + this.unitManager = new UnitManager(unitRegistry); // WIRING: Connect Systems to Data this.movementSystem.setContext(this.grid, this.unitManager); this.turnSystem.setContext(this.unitManager); + // Load skills and initialize SkillTargetingSystem + if (skillRegistry.skills.size === 0) { + await skillRegistry.loadAll(); + } + this.skillTargetingSystem = new SkillTargetingSystem( + this.grid, + this.unitManager, + skillRegistry + ); + // WIRING: Listen for Turn Changes (to update UI/Input state) this.turnSystem.addEventListener("turn-start", (e) => this._onTurnStart(e.detail) @@ -529,6 +785,18 @@ export class GameLoop { ); if (unitDef.name) unit.name = unitDef.name; + // 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; + } + // Ensure unit starts with full health // Explorer constructor might set health to 0 if classDef is missing base_stats if (unit.currentHealth <= 0) { @@ -950,13 +1218,42 @@ export class GameLoop { }); } + // Get portrait for active unit (same logic as enrichedQueue) + let activePortrait = activeUnit.portrait || activeUnit.image; + + // If no portrait and it's a player unit, try to look up by classId + if ( + !activePortrait && + activeUnit.team === "PLAYER" && + activeUnit.activeClassId + ) { + const CLASS_PORTRAITS = { + CLASS_VANGUARD: "/assets/images/portraits/vanguard.png", + CLASS_WEAVER: "/assets/images/portraits/weaver.png", + CLASS_SCAVENGER: "/assets/images/portraits/scavenger.png", + CLASS_TINKER: "/assets/images/portraits/tinker.png", + CLASS_CUSTODIAN: "/assets/images/portraits/custodian.png", + }; + activePortrait = CLASS_PORTRAITS[activeUnit.activeClassId]; + } + + // Normalize path: ensure it starts with / if it doesn't already + if (activePortrait && !activePortrait.startsWith("/")) { + activePortrait = "/" + activePortrait; + } + + // Fallback to default portraits + if (!activePortrait) { + activePortrait = + activeUnit.team === "PLAYER" + ? "/assets/images/portraits/default.png" + : "/assets/images/portraits/enemy.png"; + } + unitStatus = { id: activeUnit.id, name: activeUnit.name, - portrait: - activeUnit.team === "PLAYER" - ? "/assets/images/portraits/default.png" - : "/assets/images/portraits/enemy.png", + portrait: activePortrait, hp: { current: activeUnit.currentHealth, max: activeUnit.maxHealth, @@ -977,14 +1274,38 @@ export class GameLoop { const unit = this.unitManager?.activeUnits.get(unitId); if (!unit) return null; - const portrait = - unit.team === "PLAYER" - ? "/assets/images/portraits/default.png" - : "/assets/images/portraits/enemy.png"; + // Try to get portrait from unit property (portrait or image) + let portrait = unit.portrait || unit.image; + + // If no portrait and it's a player unit, try to look up by classId + if (!portrait && unit.team === "PLAYER" && unit.activeClassId) { + // Map of class IDs to portrait paths (matching team-builder CLASS_METADATA) + const CLASS_PORTRAITS = { + CLASS_VANGUARD: "/assets/images/portraits/vanguard.png", + CLASS_WEAVER: "/assets/images/portraits/weaver.png", + CLASS_SCAVENGER: "/assets/images/portraits/scavenger.png", + CLASS_TINKER: "/assets/images/portraits/tinker.png", + CLASS_CUSTODIAN: "/assets/images/portraits/custodian.png", + }; + portrait = CLASS_PORTRAITS[unit.activeClassId]; + } + + // Normalize path: ensure it starts with / if it doesn't already + if (portrait && !portrait.startsWith("/")) { + portrait = "/" + portrait; + } + + // Fallback to default portraits + if (!portrait) { + portrait = + unit.team === "PLAYER" + ? "/assets/images/portraits/default.png" + : "/assets/images/portraits/enemy.png"; + } return { unitId: unit.id, - portrait: unit.portrait || portrait, + portrait: portrait, team: unit.team || "ENEMY", initiative: unit.chargeMeter || 0, }; diff --git a/src/grid/VoxelManager.js b/src/grid/VoxelManager.js index 5ed3aaa..7c8ef3f 100644 --- a/src/grid/VoxelManager.js +++ b/src/grid/VoxelManager.js @@ -48,6 +48,13 @@ export class VoxelManager { this.focusTarget = new THREE.Object3D(); this.focusTarget.name = "CameraFocusTarget"; this.scene.add(this.focusTarget); + + // Highlight tracking (managed externally, but VoxelManager provides helper methods) + // These will be Set passed from GameLoop + /** @type {Set | null} */ + this.rangeHighlights = null; + /** @type {Set | null} */ + this.aoeReticle = null; } /** @@ -313,4 +320,230 @@ export class VoxelManager { updateVoxel(x, y, z) { this.update(); } + + /** + * Sets the highlight tracking sets (called from GameLoop). + * @param {Set} rangeHighlights - Set to track range highlight meshes + * @param {Set} aoeReticle - Set to track AoE reticle meshes + */ + setHighlightSets(rangeHighlights, aoeReticle) { + this.rangeHighlights = rangeHighlights; + this.aoeReticle = aoeReticle; + } + + /** + * Highlights tiles within skill range (red outline). + * @param {import("./types.js").Position} sourcePos - Source position + * @param {number} range - Skill range in tiles + * @param {string} style - Highlight style (e.g., 'RED_OUTLINE') + */ + highlightRange(sourcePos, range, style = "RED_OUTLINE") { + if (!this.rangeHighlights) return; + + // Clear existing range highlights + this.clearRangeHighlights(); + + // Generate all tiles within Manhattan distance range + const tiles = []; + for (let x = sourcePos.x - range; x <= sourcePos.x + range; x++) { + for (let y = sourcePos.y - range; y <= sourcePos.y + range; y++) { + for (let z = sourcePos.z - range; z <= sourcePos.z + range; z++) { + const dist = + Math.abs(x - sourcePos.x) + + Math.abs(y - sourcePos.y) + + Math.abs(z - sourcePos.z); + if (dist <= range) { + // Check if position is valid and walkable + if (this.grid.isValidBounds({ x, y, z })) { + tiles.push({ x, y, z }); + } + } + } + } + } + + // Create red outline materials (similar to movement highlights but red) + const outerGlowMaterial = new THREE.LineBasicMaterial({ + color: 0x660000, + transparent: true, + opacity: 0.3, + }); + + const midGlowMaterial = new THREE.LineBasicMaterial({ + color: 0x880000, + transparent: true, + opacity: 0.5, + }); + + const highlightMaterial = new THREE.LineBasicMaterial({ + color: 0xff0000, // Bright red + transparent: true, + opacity: 1.0, + }); + + const thickMaterial = new THREE.LineBasicMaterial({ + color: 0xcc0000, + transparent: true, + opacity: 0.8, + }); + + // Create base plane geometry + const baseGeometry = new THREE.PlaneGeometry(1, 1); + baseGeometry.rotateX(-Math.PI / 2); + + // Create highlights for each tile + tiles.forEach((pos) => { + // Find walkable Y level (similar to movement highlights) + let walkableY = pos.y; + // Check if there's a floor at this position + if (this.grid.getCell(pos.x, pos.y - 1, pos.z) === 0) { + // No floor, try to find walkable level + 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 + 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.rangeHighlights.add(outerGlowLines); + + // Mid glow + 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.rangeHighlights.add(midGlowLines); + + // Thick outline + 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.rangeHighlights.add(thickLines); + + // Main bright outline + const edgesGeometry = new THREE.EdgesGeometry(baseGeometry); + const lineSegments = new THREE.LineSegments( + edgesGeometry, + highlightMaterial + ); + lineSegments.position.set(pos.x, floorSurfaceY, pos.z); + this.scene.add(lineSegments); + this.rangeHighlights.add(lineSegments); + }); + } + + /** + * Shows AoE reticle (solid red highlight) for affected tiles. + * @param {import("./types.js").Position[]} tiles - Array of tile positions in AoE + */ + showReticle(tiles) { + if (!this.aoeReticle) return; + + // Clear existing reticle + this.clearReticle(); + + // Create solid red material (more opaque than outline) + const reticleMaterial = new THREE.MeshStandardMaterial({ + color: 0xff0000, + transparent: true, + opacity: 0.4, + emissive: 0x330000, + emissiveIntensity: 0.5, + }); + + // Create plane geometry for solid highlight + const planeGeometry = new THREE.PlaneGeometry(1, 1); + planeGeometry.rotateX(-Math.PI / 2); + + // Create solid highlights for each tile + tiles.forEach((pos) => { + // Find walkable Y level + let walkableY = pos.y; + if (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; + + // Create solid plane mesh + const plane = new THREE.Mesh(planeGeometry, reticleMaterial); + plane.position.set(pos.x, floorSurfaceY + 0.01, pos.z); + this.scene.add(plane); + this.aoeReticle.add(plane); + }); + } + + /** + * Clears all range highlights. + */ + clearRangeHighlights() { + if (!this.rangeHighlights) return; + this.rangeHighlights.forEach((mesh) => { + this.scene.remove(mesh); + if (mesh.geometry) mesh.geometry.dispose(); + if (mesh.material) { + if (Array.isArray(mesh.material)) { + mesh.material.forEach((m) => m.dispose()); + } else { + mesh.material.dispose(); + } + } + mesh.dispose(); + }); + this.rangeHighlights.clear(); + } + + /** + * Clears AoE reticle. + */ + clearReticle() { + if (!this.aoeReticle) return; + this.aoeReticle.forEach((mesh) => { + this.scene.remove(mesh); + if (mesh.geometry) mesh.geometry.dispose(); + if (mesh.material) { + if (Array.isArray(mesh.material)) { + mesh.material.forEach((m) => m.dispose()); + } else { + mesh.material.dispose(); + } + } + mesh.dispose(); + }); + this.aoeReticle.clear(); + } + + /** + * Clears all highlights (range and AoE). + */ + clearHighlights() { + this.clearRangeHighlights(); + this.clearReticle(); + } } diff --git a/src/managers/SkillRegistry.js b/src/managers/SkillRegistry.js new file mode 100644 index 0000000..971aced --- /dev/null +++ b/src/managers/SkillRegistry.js @@ -0,0 +1,82 @@ +/** + * SkillRegistry.js + * Loads and manages skill definitions from JSON files. + * @class + */ +export class SkillRegistry { + constructor() { + /** @type {Map} */ + this.skills = new Map(); + } + + /** + * Loads all skill definitions from the assets/skills directory. + * @returns {Promise} + */ + async loadAll() { + // List of all skill files (could be auto-generated in the future) + const skillFiles = [ + "skill_breach_move", + "skill_chain_lightning", + "skill_deploy_turret", + "skill_execute", + "skill_fireball", + "skill_flashbang", + "skill_grapple_hook", + "skill_grenade", + "skill_ice_wall", + "skill_intercept", + "skill_mend", + "skill_purify", + "skill_repair_bot", + "skill_shield_bash", + "skill_shock_grenade", + "skill_sprint", + "skill_stealth", + "skill_taunt", + "skill_teleport", + "skill_ward_corruption", + "skill_warp_strike", + ]; + + const loadPromises = skillFiles.map(async (filename) => { + try { + const response = await fetch(`assets/skills/${filename}.json`); + if (!response.ok) { + console.warn(`Failed to load skill: ${filename}`); + return null; + } + const skillData = await response.json(); + this.skills.set(skillData.id, skillData); + return skillData; + } catch (error) { + console.error(`Error loading skill ${filename}:`, error); + return null; + } + }); + + await Promise.all(loadPromises); + console.log(`Loaded ${this.skills.size} skills`); + } + + /** + * Gets a skill definition by ID. + * @param {string} skillId - Skill ID + * @returns {Object | undefined} - Skill definition + */ + get(skillId) { + return this.skills.get(skillId); + } + + /** + * Gets all registered skills. + * @returns {Map} - Map of skill ID to skill definition + */ + getAll() { + return this.skills; + } +} + +// Singleton instance +export const skillRegistry = new SkillRegistry(); + diff --git a/src/systems/SkillTargetingSystem.js b/src/systems/SkillTargetingSystem.js new file mode 100644 index 0000000..1f13465 --- /dev/null +++ b/src/systems/SkillTargetingSystem.js @@ -0,0 +1,302 @@ +/** + * @typedef {import("../grid/VoxelGrid.js").VoxelGrid} VoxelGrid + * @typedef {import("../managers/UnitManager.js").UnitManager} UnitManager + * @typedef {import("../units/Unit.js").Unit} Unit + * @typedef {import("../grid/types.js").Position} Position + */ + +/** + * SkillTargetingSystem.js + * Handles skill targeting validation, AoE calculation, and unit selection. + * Implements the specifications from Combat_Skill_Usage.spec.md + * @class + */ +export class SkillTargetingSystem { + /** + * @param {VoxelGrid} grid - Voxel grid instance + * @param {UnitManager} unitManager - Unit manager instance + * @param {Map | Object} skillRegistry - Skill definitions registry + */ + constructor(grid, unitManager, skillRegistry) { + /** @type {VoxelGrid} */ + this.grid = grid; + /** @type {UnitManager} */ + this.unitManager = unitManager; + /** @type {Map | Object} */ + this.skillRegistry = skillRegistry; + } + + /** + * Gets a skill definition from the registry and normalizes it. + * Maps the actual skill data structure to the format expected by the system. + * @param {string} skillId - Skill ID + * @returns {Object | null} - Normalized skill definition or null + * @private + */ + getSkillDef(skillId) { + let skillDef; + if (this.skillRegistry instanceof Map) { + skillDef = this.skillRegistry.get(skillId); + } else if (this.skillRegistry.get) { + // SkillRegistry instance + skillDef = this.skillRegistry.get(skillId); + } else { + skillDef = this.skillRegistry[skillId]; + } + + if (!skillDef) { + return null; + } + + // Normalize the skill definition to match expected structure + const targeting = skillDef.targeting || {}; + const aoe = targeting.area_of_effect || {}; + + // Handle SELF target type (no range needed, targets the caster) + const targetType = targeting.type || "ENEMY"; + const range = targetType === "SELF" ? 0 : (targeting.range || 0); + + return { + id: skillDef.id, + name: skillDef.name, + range: range, + target_type: targetType, + ignore_cover: targeting.line_of_sight === false, // If line_of_sight is false, ignore cover + aoe_type: aoe.shape || "SINGLE", + aoe_radius: aoe.shape === "CIRCLE" ? (aoe.size || 1) : undefined, + aoe_length: aoe.shape === "LINE" ? (aoe.size || 1) : undefined, + costAP: skillDef.costs?.ap || 0, + cooldown: skillDef.cooldown_turns || 0, + effects: skillDef.effects || [], + }; + } + + /** + * Calculates Manhattan distance between two positions. + * @param {Position} pos1 - First position + * @param {Position} pos2 - Second position + * @returns {number} - Manhattan distance + * @private + */ + manhattanDistance(pos1, pos2) { + return ( + Math.abs(pos1.x - pos2.x) + + Math.abs(pos1.y - pos2.y) + + Math.abs(pos1.z - pos2.z) + ); + } + + /** + * Checks line of sight from source to target. + * Raycasts from source head height to target center. + * @param {Position} sourcePos - Source position + * @param {Position} targetPos - Target position + * @param {boolean} ignoreCover - Whether to ignore cover + * @returns {boolean} - True if line of sight is clear + * @private + */ + hasLineOfSight(sourcePos, targetPos, ignoreCover = false) { + if (ignoreCover) { + return true; + } + + // Source head height (assuming unit is 1.5 voxels tall, head at y + 1.5) + const sourceHeadY = sourcePos.y + 1.5; + // Target center (assuming target is at y + 0.5) + const targetCenterY = targetPos.y + 0.5; + + // Raycast from source to target + const dx = targetPos.x - sourcePos.x; + const dy = targetCenterY - sourceHeadY; + const dz = targetPos.z - sourcePos.z; + + // Number of steps (use the maximum dimension) + const steps = Math.max(Math.abs(dx), Math.abs(dy), Math.abs(dz)); + if (steps === 0) return true; + + const stepX = dx / steps; + const stepY = dy / steps; + const stepZ = dz / steps; + + // Check each step along the ray (skip first and last to avoid edge cases) + for (let i = 1; i < steps; i++) { + const x = Math.round(sourcePos.x + stepX * i); + const y = Math.round(sourceHeadY + stepY * i); + const z = Math.round(sourcePos.z + stepZ * i); + + // Check if this voxel is solid + if (this.grid.isSolid({ x, y, z })) { + return false; + } + } + + return true; + } + + /** + * Validates if a target is valid for a skill. + * Checks range, line of sight, and target type. + * @param {Unit} sourceUnit - Source unit casting the skill + * @param {Position} targetPos - Target position + * @param {string} skillId - Skill ID + * @returns {{ valid: boolean; reason: string }} - Validation result + */ + validateTarget(sourceUnit, targetPos, skillId) { + const skillDef = this.getSkillDef(skillId); + if (!skillDef) { + return { valid: false, reason: "Skill not found" }; + } + + // 1. Range Check + const distance = this.manhattanDistance(sourceUnit.position, targetPos); + if (distance > skillDef.range) { + return { valid: false, reason: "Target out of range" }; + } + + // 2. Line of Sight Check + const ignoreCover = skillDef.ignore_cover || false; + if (!this.hasLineOfSight(sourceUnit.position, targetPos, ignoreCover)) { + return { valid: false, reason: "No line of sight" }; + } + + // 3. Content Check (Target Type) + const targetUnit = this.grid.getUnitAt(targetPos); + const targetType = skillDef.target_type; + + if (targetType === "SELF") { + // SELF skills target the caster's position + if ( + targetPos.x !== sourceUnit.position.x || + targetPos.y !== sourceUnit.position.y || + targetPos.z !== sourceUnit.position.z + ) { + return { valid: false, reason: "Invalid target type: must target self" }; + } + } else if (targetType === "ENEMY") { + if (!targetUnit || targetUnit.team === sourceUnit.team) { + return { valid: false, reason: "Invalid target type: must be enemy" }; + } + } else if (targetType === "ALLY") { + if (!targetUnit || targetUnit.team !== sourceUnit.team) { + return { valid: false, reason: "Invalid target type: must be ally" }; + } + } else if (targetType === "EMPTY") { + if (targetUnit) { + return { valid: false, reason: "Invalid target type: must be empty" }; + } + } + + return { valid: true, reason: "" }; + } + + /** + * Calculates the Area of Effect tiles for a skill. + * @param {Position} sourcePos - Source position + * @param {Position} cursorPos - Cursor/target position + * @param {string} skillId - Skill ID + * @returns {Position[]} - Array of affected tile positions + */ + getAoETiles(sourcePos, cursorPos, skillId) { + const skillDef = this.getSkillDef(skillId); + if (!skillDef) { + return []; + } + + const aoeType = skillDef.aoe_type || "SINGLE"; + + switch (aoeType) { + case "SINGLE": + return [cursorPos]; + + case "CIRCLE": { + const radius = skillDef.aoe_radius || 1; + const tiles = []; + + // Generate all tiles within Manhattan distance radius + for (let x = cursorPos.x - radius; x <= cursorPos.x + radius; x++) { + for (let y = cursorPos.y - radius; y <= cursorPos.y + radius; y++) { + for ( + let z = cursorPos.z - radius; + z <= cursorPos.z + radius; + z++ + ) { + const pos = { x, y, z }; + const dist = this.manhattanDistance(cursorPos, pos); + if (dist <= radius) { + tiles.push(pos); + } + } + } + } + + return tiles; + } + + case "LINE": { + const length = skillDef.aoe_length || 1; + const tiles = []; + + // Calculate direction from source to cursor + const dx = cursorPos.x - sourcePos.x; + const dz = cursorPos.z - sourcePos.z; + + // Determine cardinal direction + let dirX = 0; + let dirZ = 0; + if (Math.abs(dx) > Math.abs(dz)) { + dirX = dx > 0 ? 1 : -1; + } else { + dirZ = dz > 0 ? 1 : -1; + } + + // Generate line tiles + for (let i = 0; i < length; i++) { + const x = sourcePos.x + dirX * i; + const z = sourcePos.z + dirZ * i; + tiles.push({ x, y: cursorPos.y, z }); + } + + return tiles; + } + + case "CONE": { + // TODO: Implement cone pattern + // For now, return single tile + return [cursorPos]; + } + + default: + return [cursorPos]; + } + } + + /** + * Gets all units within the AoE of a skill. + * @param {Position} sourcePos - Source position (for LINE AoE calculation) + * @param {Position} targetPos - Target position (center of AoE) + * @param {string} skillId - Skill ID + * @returns {Unit[]} - Array of units in AoE + */ + getUnitsInAoE(sourcePos, targetPos, skillId) { + const skillDef = this.getSkillDef(skillId); + if (!skillDef) { + return []; + } + + const aoeTiles = this.getAoETiles(sourcePos, targetPos, skillId); + const units = []; + + for (const tile of aoeTiles) { + const unit = this.grid.getUnitAt(tile); + if (unit) { + // Avoid duplicates + if (!units.some((u) => u.id === unit.id)) { + units.push(unit); + } + } + } + + return units; + } +} + diff --git a/src/ui/game-viewport.js b/src/ui/game-viewport.js index bffa226..158dee4 100644 --- a/src/ui/game-viewport.js +++ b/src/ui/game-viewport.js @@ -53,6 +53,13 @@ export class GameViewport extends LitElement { } } + #handleSkillClick(event) { + const { skillId } = event.detail; + if (gameStateManager.gameLoop) { + gameStateManager.gameLoop.onSkillClicked(skillId); + } + } + async firstUpdated() { const container = this.shadowRoot.getElementById("canvas-container"); const loop = new GameLoop(); @@ -95,6 +102,7 @@ export class GameViewport extends LitElement { `; } diff --git a/test/systems/SkillTargetingSystem.test.js b/test/systems/SkillTargetingSystem.test.js new file mode 100644 index 0000000..45f46e3 --- /dev/null +++ b/test/systems/SkillTargetingSystem.test.js @@ -0,0 +1,436 @@ +import { expect } from "@esm-bundle/chai"; +import { SkillTargetingSystem } from "../../src/systems/SkillTargetingSystem.js"; +import { VoxelGrid } from "../../src/grid/VoxelGrid.js"; +import { UnitManager } from "../../src/managers/UnitManager.js"; +import { Explorer } from "../../src/units/Explorer.js"; +import { Enemy } from "../../src/units/Enemy.js"; + +describe("Systems: SkillTargetingSystem", function () { + let targetingSystem; + let grid; + let unitManager; + let mockRegistry; + let skillRegistry; + + beforeEach(() => { + // Create a 20x10x20 grid + grid = new VoxelGrid(20, 10, 20); + + // Create floor at y=0 + for (let x = 0; x < 20; x++) { + for (let z = 0; z < 20; z++) { + grid.setCell(x, 0, z, 1); // Floor + grid.setCell(x, 1, z, 0); // Air at y=1 (walkable) + } + } + + // Create mock unit registry + mockRegistry = new Map(); + mockRegistry.set("CLASS_VANGUARD", { + id: "CLASS_VANGUARD", + name: "Vanguard", + base_stats: { + health: 100, + attack: 10, + defense: 5, + speed: 10, + movement: 4, + }, + }); + mockRegistry.set("ENEMY_DEFAULT", { + id: "ENEMY_DEFAULT", + name: "Enemy", + type: "ENEMY", + base_stats: { + health: 50, + attack: 5, + defense: 2, + speed: 8, + movement: 3, + }, + }); + + unitManager = new UnitManager(mockRegistry); + + // Create skill registry with various skill types + skillRegistry = new Map(); + skillRegistry.set("SKILL_SINGLE_TARGET", { + id: "SKILL_SINGLE_TARGET", + name: "Single Target", + range: 5, + target_type: "ENEMY", + aoe_type: "SINGLE", + costAP: 2, + effects: [], + }); + skillRegistry.set("SKILL_CIRCLE_AOE", { + id: "SKILL_CIRCLE_AOE", + name: "Circle AoE", + range: 4, + target_type: "ENEMY", + aoe_type: "CIRCLE", + aoe_radius: 2, + costAP: 3, + effects: [], + }); + skillRegistry.set("SKILL_LINE_AOE", { + id: "SKILL_LINE_AOE", + name: "Line AoE", + range: 6, + target_type: "ENEMY", + aoe_type: "LINE", + aoe_length: 4, + costAP: 2, + effects: [], + }); + skillRegistry.set("SKILL_ALLY_HEAL", { + id: "SKILL_ALLY_HEAL", + name: "Heal", + range: 3, + target_type: "ALLY", + aoe_type: "SINGLE", + costAP: 2, + effects: [], + }); + skillRegistry.set("SKILL_EMPTY_TARGET", { + id: "SKILL_EMPTY_TARGET", + name: "Place Trap", + range: 3, + target_type: "EMPTY", + aoe_type: "SINGLE", + costAP: 1, + effects: [], + }); + skillRegistry.set("SKILL_IGNORE_COVER", { + id: "SKILL_IGNORE_COVER", + name: "Piercing Shot", + range: 8, + target_type: "ENEMY", + aoe_type: "SINGLE", + ignore_cover: true, + costAP: 3, + effects: [], + }); + + targetingSystem = new SkillTargetingSystem( + grid, + unitManager, + skillRegistry + ); + }); + + describe("Range Validation", () => { + it("should validate target within range", () => { + const source = unitManager.createUnit("CLASS_VANGUARD", "PLAYER"); + source.position = { x: 5, y: 1, z: 5 }; + grid.placeUnit(source, source.position); + + const targetPos = { x: 7, y: 1, z: 5 }; // 2 tiles away (within range 5) + + const result = targetingSystem.validateTarget( + source, + targetPos, + "SKILL_SINGLE_TARGET" + ); + + expect(result.valid).to.be.true; + }); + + it("should reject target out of range", () => { + const source = unitManager.createUnit("CLASS_VANGUARD", "PLAYER"); + source.position = { x: 5, y: 1, z: 5 }; + grid.placeUnit(source, source.position); + + const targetPos = { x: 12, y: 1, z: 5 }; // 7 tiles away (out of range 5) + + const result = targetingSystem.validateTarget( + source, + targetPos, + "SKILL_SINGLE_TARGET" + ); + + expect(result.valid).to.be.false; + expect(result.reason).to.include("range"); + }); + }); + + describe("Line of Sight (LOS)", () => { + it("should validate target with clear line of sight", () => { + const source = unitManager.createUnit("CLASS_VANGUARD", "PLAYER"); + source.position = { x: 5, y: 1, z: 5 }; + grid.placeUnit(source, source.position); + + const targetPos = { x: 8, y: 1, z: 5 }; // Clear path + + const result = targetingSystem.validateTarget( + source, + targetPos, + "SKILL_SINGLE_TARGET" + ); + + expect(result.valid).to.be.true; + }); + + it("should reject target blocked by solid voxel", () => { + const source = unitManager.createUnit("CLASS_VANGUARD", "PLAYER"); + source.position = { x: 5, y: 1, z: 5 }; + grid.placeUnit(source, source.position); + + // Place a wall between source and target + grid.setCell(6, 1, 5, 1); // Solid block + grid.setCell(6, 2, 5, 1); // Solid block + + const targetPos = { x: 8, y: 1, z: 5 }; + + const result = targetingSystem.validateTarget( + source, + targetPos, + "SKILL_SINGLE_TARGET" + ); + + expect(result.valid).to.be.false; + expect(result.reason).to.include("line of sight"); + }); + + it("should allow target blocked by cover if skill ignores cover", () => { + const source = unitManager.createUnit("CLASS_VANGUARD", "PLAYER"); + source.position = { x: 5, y: 1, z: 5 }; + grid.placeUnit(source, source.position); + + // Place a wall between source and target + grid.setCell(6, 1, 5, 1); + grid.setCell(6, 2, 5, 1); + + const targetPos = { x: 8, y: 1, z: 5 }; + + const result = targetingSystem.validateTarget( + source, + targetPos, + "SKILL_IGNORE_COVER" + ); + + expect(result.valid).to.be.true; // Should pass despite cover + }); + }); + + describe("Target Type Validation", () => { + it("should validate ENEMY target type", () => { + const source = unitManager.createUnit("CLASS_VANGUARD", "PLAYER"); + source.position = { x: 5, y: 1, z: 5 }; + grid.placeUnit(source, source.position); + + const enemy = unitManager.createUnit("ENEMY_DEFAULT", "ENEMY"); + enemy.position = { x: 7, y: 1, z: 5 }; + grid.placeUnit(enemy, enemy.position); + + const result = targetingSystem.validateTarget( + source, + enemy.position, + "SKILL_SINGLE_TARGET" + ); + + expect(result.valid).to.be.true; + }); + + it("should reject ENEMY target type when targeting ally", () => { + const source = unitManager.createUnit("CLASS_VANGUARD", "PLAYER"); + source.position = { x: 5, y: 1, z: 5 }; + grid.placeUnit(source, source.position); + + const ally = unitManager.createUnit("CLASS_VANGUARD", "PLAYER"); + ally.position = { x: 7, y: 1, z: 5 }; + grid.placeUnit(ally, ally.position); + + const result = targetingSystem.validateTarget( + source, + ally.position, + "SKILL_SINGLE_TARGET" + ); + + expect(result.valid).to.be.false; + expect(result.reason).to.include("target type"); + }); + + it("should validate ALLY target type", () => { + const source = unitManager.createUnit("CLASS_VANGUARD", "PLAYER"); + source.position = { x: 5, y: 1, z: 5 }; + grid.placeUnit(source, source.position); + + const ally = unitManager.createUnit("CLASS_VANGUARD", "PLAYER"); + ally.position = { x: 7, y: 1, z: 5 }; + grid.placeUnit(ally, ally.position); + + const result = targetingSystem.validateTarget( + source, + ally.position, + "SKILL_ALLY_HEAL" + ); + + expect(result.valid).to.be.true; + }); + + it("should validate EMPTY target type", () => { + const source = unitManager.createUnit("CLASS_VANGUARD", "PLAYER"); + source.position = { x: 5, y: 1, z: 5 }; + grid.placeUnit(source, source.position); + + const emptyPos = { x: 7, y: 1, z: 5 }; // No unit here + + const result = targetingSystem.validateTarget( + source, + emptyPos, + "SKILL_EMPTY_TARGET" + ); + + expect(result.valid).to.be.true; + }); + + it("should reject EMPTY target type when tile is occupied", () => { + const source = unitManager.createUnit("CLASS_VANGUARD", "PLAYER"); + source.position = { x: 5, y: 1, z: 5 }; + grid.placeUnit(source, source.position); + + const enemy = unitManager.createUnit("ENEMY_DEFAULT", "ENEMY"); + enemy.position = { x: 7, y: 1, z: 5 }; + grid.placeUnit(enemy, enemy.position); + + const result = targetingSystem.validateTarget( + source, + enemy.position, + "SKILL_EMPTY_TARGET" + ); + + expect(result.valid).to.be.false; + expect(result.reason).to.include("empty"); + }); + }); + + describe("AoE Calculation: SINGLE", () => { + it("should return single tile for SINGLE AoE", () => { + const sourcePos = { x: 5, y: 1, z: 5 }; + const cursorPos = { x: 7, y: 1, z: 5 }; + + const tiles = targetingSystem.getAoETiles( + sourcePos, + cursorPos, + "SKILL_SINGLE_TARGET" + ); + + expect(tiles).to.have.length(1); + expect(tiles[0]).to.deep.equal(cursorPos); + }); + }); + + describe("AoE Calculation: CIRCLE", () => { + it("should return all tiles within radius for CIRCLE AoE", () => { + const sourcePos = { x: 5, y: 1, z: 5 }; + const cursorPos = { x: 7, y: 1, z: 5 }; // Center of circle + + const tiles = targetingSystem.getAoETiles( + sourcePos, + cursorPos, + "SKILL_CIRCLE_AOE" + ); + + // Circle with radius 2 should include center + surrounding tiles + // Should have at least the center tile + expect(tiles.length).to.be.greaterThan(0); + expect(tiles.some((t) => t.x === 7 && t.z === 5)).to.be.true; + }); + + it("should include tiles at exact radius distance", () => { + const sourcePos = { x: 5, y: 1, z: 5 }; + const cursorPos = { x: 7, y: 1, z: 5 }; + + const tiles = targetingSystem.getAoETiles( + sourcePos, + cursorPos, + "SKILL_CIRCLE_AOE" + ); + + // Check that tiles at radius 2 are included (Manhattan distance) + const hasRadius2Tile = tiles.some((t) => { + const dist = + Math.abs(t.x - cursorPos.x) + + Math.abs(t.y - cursorPos.y) + + Math.abs(t.z - cursorPos.z); + return dist === 2; + }); + expect(hasRadius2Tile).to.be.true; + }); + }); + + describe("AoE Calculation: LINE", () => { + it("should return line of tiles from source to target for LINE AoE", () => { + const sourcePos = { x: 5, y: 1, z: 5 }; + const cursorPos = { x: 9, y: 1, z: 5 }; // 4 tiles east + + const tiles = targetingSystem.getAoETiles( + sourcePos, + cursorPos, + "SKILL_LINE_AOE" + ); + + // Line should include tiles along the path + expect(tiles.length).to.be.greaterThan(1); + // Should include source position or nearby tiles in the line + const hasSourceDirection = tiles.some( + (t) => t.x >= sourcePos.x && t.x <= cursorPos.x && t.z === sourcePos.z + ); + expect(hasSourceDirection).to.be.true; + }); + }); + + describe("getUnitsInAoE", () => { + it("should return all units within AoE", () => { + const source = unitManager.createUnit("CLASS_VANGUARD", "PLAYER"); + source.position = { x: 5, y: 1, z: 5 }; + grid.placeUnit(source, source.position); + + // Place enemies in AoE range + const enemy1 = unitManager.createUnit("ENEMY_DEFAULT", "ENEMY"); + enemy1.position = { x: 7, y: 1, z: 5 }; + grid.placeUnit(enemy1, enemy1.position); + + const enemy2 = unitManager.createUnit("ENEMY_DEFAULT", "ENEMY"); + enemy2.position = { x: 8, y: 1, z: 5 }; + grid.placeUnit(enemy2, enemy2.position); + + const sourcePos = source.position; + const targetPos = { x: 7, y: 1, z: 5 }; + const units = targetingSystem.getUnitsInAoE( + sourcePos, + targetPos, + "SKILL_CIRCLE_AOE" + ); + + expect(units.length).to.be.greaterThan(0); + expect(units.some((u) => u.id === enemy1.id)).to.be.true; + }); + + it("should only return units within AoE radius", () => { + const source = unitManager.createUnit("CLASS_VANGUARD", "PLAYER"); + source.position = { x: 5, y: 1, z: 5 }; + grid.placeUnit(source, source.position); + + const enemy1 = unitManager.createUnit("ENEMY_DEFAULT", "ENEMY"); + enemy1.position = { x: 7, y: 1, z: 5 }; // Within radius 2 + grid.placeUnit(enemy1, enemy1.position); + + const enemy2 = unitManager.createUnit("ENEMY_DEFAULT", "ENEMY"); + enemy2.position = { x: 12, y: 1, z: 5 }; // Outside radius 2 + grid.placeUnit(enemy2, enemy2.position); + + const sourcePos = { x: 5, y: 1, z: 5 }; + const targetPos = { x: 7, y: 1, z: 5 }; + const units = targetingSystem.getUnitsInAoE( + sourcePos, + targetPos, + "SKILL_CIRCLE_AOE" + ); + + expect(units.some((u) => u.id === enemy1.id)).to.be.true; + expect(units.some((u) => u.id === enemy2.id)).to.be.false; + }); + }); +});