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:
parent
525a92a2eb
commit
56aa6d79df
34 changed files with 2102 additions and 25 deletions
130
specs/Combat_Skill_Usage.spec.md
Normal file
130
specs/Combat_Skill_Usage.spec.md
Normal 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();
|
||||
}
|
||||
```
|
||||
132
specs/EffectProcessor.spec.md
Normal file
132
specs/EffectProcessor.spec.md
Normal 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."
|
||||
24
src/assets/skills/skill_breach_move.json
Normal file
24
src/assets/skills/skill_breach_move.json
Normal 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 }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
25
src/assets/skills/skill_chain_lightning.json
Normal file
25
src/assets/skills/skill_chain_lightning.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
17
src/assets/skills/skill_deploy_turret.json
Normal file
17
src/assets/skills/skill_deploy_turret.json
Normal 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 }
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
26
src/assets/skills/skill_execute.json
Normal file
26
src/assets/skills/skill_execute.json
Normal 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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
30
src/assets/skills/skill_fireball.json
Normal file
30
src/assets/skills/skill_fireball.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
19
src/assets/skills/skill_flashbang.json
Normal file
19
src/assets/skills/skill_flashbang.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
16
src/assets/skills/skill_grapple_hook.json
Normal file
16
src/assets/skills/skill_grapple_hook.json
Normal 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 }]
|
||||
}
|
||||
|
||||
|
||||
20
src/assets/skills/skill_grenade.json
Normal file
20
src/assets/skills/skill_grenade.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
18
src/assets/skills/skill_ice_wall.json
Normal file
18
src/assets/skills/skill_ice_wall.json
Normal 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 }
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
25
src/assets/skills/skill_intercept.json
Normal file
25
src/assets/skills/skill_intercept.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
16
src/assets/skills/skill_mend.json
Normal file
16
src/assets/skills/skill_mend.json
Normal 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" }]
|
||||
}
|
||||
|
||||
|
||||
18
src/assets/skills/skill_purify.json
Normal file
18
src/assets/skills/skill_purify.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
15
src/assets/skills/skill_repair_bot.json
Normal file
15
src/assets/skills/skill_repair_bot.json
Normal 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" }]
|
||||
}
|
||||
|
||||
|
||||
28
src/assets/skills/skill_shield_bash.json
Normal file
28
src/assets/skills/skill_shield_bash.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
28
src/assets/skills/skill_shock_grenade.json
Normal file
28
src/assets/skills/skill_shock_grenade.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
14
src/assets/skills/skill_sprint.json
Normal file
14
src/assets/skills/skill_sprint.json
Normal 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 }
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
14
src/assets/skills/skill_stealth.json
Normal file
14
src/assets/skills/skill_stealth.json
Normal 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 }
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
24
src/assets/skills/skill_taunt.json
Normal file
24
src/assets/skills/skill_taunt.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
16
src/assets/skills/skill_teleport.json
Normal file
16
src/assets/skills/skill_teleport.json
Normal 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" }]
|
||||
}
|
||||
|
||||
|
||||
17
src/assets/skills/skill_ward_corruption.json
Normal file
17
src/assets/skills/skill_ward_corruption.json
Normal 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 }
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
23
src/assets/skills/skill_warp_strike.json
Normal file
23
src/assets/skills/skill_warp_strike.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -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,10 +336,15 @@ export class GameLoop {
|
|||
this.gameStateManager &&
|
||||
this.gameStateManager.currentState === "STATE_COMBAT"
|
||||
) {
|
||||
// Handle combat movement
|
||||
// Handle combat actions based on state
|
||||
if (this.combatState === "TARGETING_SKILL") {
|
||||
this.handleSkillTargeting(cursor);
|
||||
} else {
|
||||
// Default to movement
|
||||
this.handleCombatMovement(cursor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles movement in combat state.
|
||||
|
|
@ -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 = {
|
||||
get: (id) => {
|
||||
if (id.startsWith("CLASS_"))
|
||||
return {
|
||||
// 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",
|
||||
name: id,
|
||||
id: id,
|
||||
base_stats: { health: 100, attack: 10, defense: 5, speed: 10 },
|
||||
growth_rates: {},
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create registry object with get method for UnitManager
|
||||
const unitRegistry = {
|
||||
get: (id) => {
|
||||
// Try to get from class registry first
|
||||
if (classRegistry.has(id)) {
|
||||
return classRegistry.get(id);
|
||||
}
|
||||
|
||||
// Fallback for enemy units
|
||||
if (id.startsWith("ENEMY_")) {
|
||||
return {
|
||||
type: "ENEMY",
|
||||
name: "Enemy",
|
||||
stats: { health: 50, attack: 8, defense: 3, speed: 8 },
|
||||
ai_archetype: "BRUISER",
|
||||
};
|
||||
}
|
||||
|
||||
console.warn(`Unit definition not found: ${id}`);
|
||||
return null;
|
||||
},
|
||||
};
|
||||
this.unitManager = new UnitManager(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 =
|
||||
// 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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
82
src/managers/SkillRegistry.js
Normal file
82
src/managers/SkillRegistry.js
Normal 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();
|
||||
|
||||
302
src/systems/SkillTargetingSystem.js
Normal file
302
src/systems/SkillTargetingSystem.js
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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>`;
|
||||
}
|
||||
|
|
|
|||
436
test/systems/SkillTargetingSystem.test.js
Normal file
436
test/systems/SkillTargetingSystem.test.js
Normal 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;
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue