Add Combat Skill Usage and Targeting System Specifications

Introduce detailed specifications for combat skill usage, including interaction flow, state machine updates, and the skill targeting system. Implement the SkillTargetingSystem to handle targeting validation and area of effect calculations. Enhance the CombatHUD specification to define the UI overlay for combat phases. Integrate these systems into the GameLoop for improved combat mechanics and user experience.
This commit is contained in:
Matthew Mone 2025-12-23 21:01:54 -08:00
parent 525a92a2eb
commit 56aa6d79df
34 changed files with 2102 additions and 25 deletions

View file

@ -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();
}
```

View file

@ -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."

View file

@ -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 }
}
]
}

View file

@ -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"
}
]
}

View file

@ -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 }
]
}

View file

@ -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
}
}
]
}

View file

@ -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
}
]
}

View file

@ -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" }
]
}

View file

@ -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 }]
}

View file

@ -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" }
]
}

View file

@ -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 }
]
}

View file

@ -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
}
]
}

View file

@ -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" }]
}

View file

@ -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" }
]
}

View file

@ -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" }]
}

View file

@ -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
}
]
}

View file

@ -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"
}
]
}

View file

@ -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 }
]
}

View file

@ -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 }
]
}

View file

@ -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
}
]
}

View file

@ -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" }]
}

View file

@ -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 }
]
}

View file

@ -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"
}
]
}

View file

@ -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<string, THREE.Mesh>} */
this.unitMeshes = new Map();
@ -59,6 +70,10 @@ export class GameLoop {
this.movementHighlights = new Set();
/** @type {Set<THREE.Mesh>} */
this.spawnZoneHighlights = new Set();
/** @type {Set<THREE.Mesh>} */
this.rangeHighlights = new Set();
/** @type {Set<THREE.Mesh>} */
this.aoeReticle = new Set();
/** @type {RunData | null} */
this.runData = null;
/** @type {Position[]} */
@ -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,
};

View file

@ -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<THREE.Mesh> passed from GameLoop
/** @type {Set<THREE.Mesh> | null} */
this.rangeHighlights = null;
/** @type {Set<THREE.Mesh> | 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<THREE.Mesh>} rangeHighlights - Set to track range highlight meshes
* @param {Set<THREE.Mesh>} 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();
}
}

View file

@ -0,0 +1,82 @@
/**
* SkillRegistry.js
* Loads and manages skill definitions from JSON files.
* @class
*/
export class SkillRegistry {
constructor() {
/** @type {Map<string, Object>} */
this.skills = new Map();
}
/**
* Loads all skill definitions from the assets/skills directory.
* @returns {Promise<void>}
*/
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<string, Object>} - Map of skill ID to skill definition
*/
getAll() {
return this.skills;
}
}
// Singleton instance
export const skillRegistry = new SkillRegistry();

View file

@ -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<string, Object> | Object} skillRegistry - Skill definitions registry
*/
constructor(grid, unitManager, skillRegistry) {
/** @type {VoxelGrid} */
this.grid = grid;
/** @type {UnitManager} */
this.unitManager = unitManager;
/** @type {Map<string, Object> | 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;
}
}

View file

@ -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 {
<combat-hud
.combatState=${this.combatState}
@end-turn=${this.#handleEndTurn}
@skill-click=${this.#handleSkillClick}
></combat-hud>
<dialogue-overlay></dialogue-overlay>`;
}

View file

@ -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;
});
});
});