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.
436 lines
13 KiB
JavaScript
436 lines
13 KiB
JavaScript
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;
|
|
});
|
|
});
|
|
});
|