Introduce the EffectProcessor class to manage game state changes through various effects, including damage, healing, and status application. Define type specifications for effects, conditions, and passive abilities in Effects.d.ts. Add a comprehensive JSON registry for passive skills and item effects, enhancing gameplay dynamics. Update the GameLoop and TurnSystem to integrate the EffectProcessor, ensuring proper handling of environmental hazards and passive effects during combat. Enhance testing coverage for the EffectProcessor and environmental interactions to validate functionality and performance.
556 lines
15 KiB
JavaScript
556 lines
15 KiB
JavaScript
import { expect } from "@esm-bundle/chai";
|
|
import sinon from "sinon";
|
|
import * as THREE from "three";
|
|
import { GameLoop } from "../../../src/core/GameLoop.js";
|
|
import { skillRegistry } from "../../../src/managers/SkillRegistry.js";
|
|
import {
|
|
createGameLoopSetup,
|
|
cleanupGameLoop,
|
|
createRunData,
|
|
createMockGameStateManagerForCombat,
|
|
setupCombatUnits,
|
|
cleanupTurnSystem,
|
|
} from "./helpers.js";
|
|
|
|
describe("Core: GameLoop - Combat Skill Targeting and Execution", function () {
|
|
this.timeout(30000);
|
|
|
|
let gameLoop;
|
|
let container;
|
|
let mockGameStateManager;
|
|
let playerUnit;
|
|
let enemyUnit;
|
|
|
|
beforeEach(async () => {
|
|
const setup = createGameLoopSetup();
|
|
gameLoop = setup.gameLoop;
|
|
container = setup.container;
|
|
|
|
gameLoop.stop();
|
|
if (
|
|
gameLoop.turnSystem &&
|
|
typeof gameLoop.turnSystem.reset === "function"
|
|
) {
|
|
gameLoop.turnSystem.reset();
|
|
}
|
|
|
|
gameLoop.init(container);
|
|
mockGameStateManager = createMockGameStateManagerForCombat();
|
|
gameLoop.gameStateManager = mockGameStateManager;
|
|
|
|
const runData = createRunData({
|
|
squad: [{ id: "u1", classId: "CLASS_VANGUARD" }],
|
|
});
|
|
await gameLoop.startLevel(runData, { startAnimation: false });
|
|
|
|
const units = setupCombatUnits(gameLoop);
|
|
playerUnit = units.playerUnit;
|
|
enemyUnit = units.enemyUnit;
|
|
|
|
// Start combat and set player unit as active
|
|
if (gameLoop.turnSystem) {
|
|
gameLoop.turnSystem.startCombat([playerUnit, enemyUnit]);
|
|
// Manually set player unit as active for testing
|
|
gameLoop.turnSystem.activeUnitId = playerUnit.id;
|
|
}
|
|
});
|
|
|
|
afterEach(() => {
|
|
gameLoop.clearMovementHighlights();
|
|
gameLoop.clearSpawnZoneHighlights();
|
|
cleanupTurnSystem(gameLoop);
|
|
cleanupGameLoop(gameLoop, container);
|
|
});
|
|
|
|
describe("Skill Targeting Validation", () => {
|
|
it("should only highlight valid targets when entering targeting mode", async () => {
|
|
// Add a skill to the player unit
|
|
const skillId = "SKILL_TELEPORT";
|
|
const skillDef = {
|
|
id: skillId,
|
|
name: "Phase Shift",
|
|
costs: { ap: 2 },
|
|
targeting: {
|
|
range: 5,
|
|
type: "EMPTY",
|
|
line_of_sight: false,
|
|
},
|
|
effects: [{ type: "TELEPORT" }],
|
|
};
|
|
|
|
// Register the skill in the skill registry
|
|
skillRegistry.skills.set(skillId, skillDef);
|
|
|
|
// Add skill to unit actions
|
|
if (!playerUnit.actions) {
|
|
playerUnit.actions = [];
|
|
}
|
|
playerUnit.actions.push({
|
|
id: skillId,
|
|
name: "Phase Shift",
|
|
costAP: 2,
|
|
cooldown: 0,
|
|
});
|
|
|
|
// Ensure unit has enough AP
|
|
playerUnit.currentAP = 10;
|
|
|
|
// Enter targeting mode
|
|
gameLoop.onSkillClicked(skillId);
|
|
|
|
// Verify we're in targeting mode
|
|
expect(gameLoop.combatState).to.equal("TARGETING_SKILL");
|
|
expect(gameLoop.activeSkillId).to.equal(skillId);
|
|
|
|
// Check that highlights were created (valid targets only)
|
|
if (gameLoop.voxelManager && gameLoop.voxelManager.rangeHighlights) {
|
|
const highlightCount = gameLoop.voxelManager.rangeHighlights.size;
|
|
// Should have highlights for valid empty tiles within range
|
|
expect(highlightCount).to.be.greaterThan(0);
|
|
}
|
|
});
|
|
|
|
it("should not highlight invalid targets (out of range)", async () => {
|
|
const skillId = "SKILL_SHORT_RANGE";
|
|
const skillDef = {
|
|
id: skillId,
|
|
name: "Short Range",
|
|
costs: { ap: 1 },
|
|
targeting: {
|
|
range: 2, // Very short range
|
|
type: "ENEMY",
|
|
line_of_sight: true,
|
|
},
|
|
effects: [],
|
|
};
|
|
|
|
// Register the skill in the skill registry
|
|
skillRegistry.skills.set(skillId, skillDef);
|
|
|
|
// Add skill to unit actions
|
|
if (!playerUnit.actions) {
|
|
playerUnit.actions = [];
|
|
}
|
|
playerUnit.actions.push({
|
|
id: skillId,
|
|
name: "Short Range",
|
|
costAP: 1,
|
|
cooldown: 0,
|
|
});
|
|
|
|
// Place enemy far away
|
|
const farAwayPos = {
|
|
x: playerUnit.position.x + 10,
|
|
y: playerUnit.position.y,
|
|
z: playerUnit.position.z + 10,
|
|
};
|
|
gameLoop.grid.moveUnit(enemyUnit, farAwayPos, { force: true });
|
|
|
|
// Enter targeting mode
|
|
gameLoop.onSkillClicked(skillId);
|
|
|
|
// Verify we're in targeting mode
|
|
expect(gameLoop.combatState).to.equal("TARGETING_SKILL");
|
|
|
|
// The far away enemy should not be highlighted
|
|
// (validation happens when checking individual tiles)
|
|
expect(gameLoop.activeSkillId).to.equal(skillId);
|
|
});
|
|
});
|
|
|
|
describe("Movement Button Toggle", () => {
|
|
it("should return to movement mode when movement button is clicked", () => {
|
|
// First enter skill targeting mode
|
|
const skillId = "SKILL_TELEPORT";
|
|
const skillDef = {
|
|
id: skillId,
|
|
name: "Phase Shift",
|
|
costs: { ap: 2 },
|
|
targeting: {
|
|
range: 5,
|
|
type: "EMPTY",
|
|
line_of_sight: false,
|
|
},
|
|
effects: [{ type: "TELEPORT" }],
|
|
};
|
|
|
|
if (gameLoop.skillTargetingSystem) {
|
|
const registry = gameLoop.skillTargetingSystem.skillRegistry;
|
|
if (registry instanceof Map) {
|
|
registry.set(skillId, skillDef);
|
|
} else if (registry.set) {
|
|
registry.set(skillId, skillDef);
|
|
} else {
|
|
registry[skillId] = skillDef;
|
|
}
|
|
}
|
|
|
|
if (!playerUnit.actions) {
|
|
playerUnit.actions = [];
|
|
}
|
|
playerUnit.actions.push({
|
|
id: skillId,
|
|
name: "Phase Shift",
|
|
costAP: 2,
|
|
cooldown: 0,
|
|
});
|
|
|
|
// Ensure unit has enough AP
|
|
playerUnit.currentAP = 10;
|
|
|
|
gameLoop.onSkillClicked(skillId);
|
|
expect(gameLoop.combatState).to.equal("TARGETING_SKILL");
|
|
|
|
// Click movement button
|
|
gameLoop.onMovementClicked();
|
|
|
|
// Should return to IDLE/movement mode
|
|
expect(gameLoop.combatState).to.equal("IDLE");
|
|
expect(gameLoop.activeSkillId).to.be.null;
|
|
});
|
|
|
|
it("should toggle skill off when clicking the same skill again", () => {
|
|
const skillId = "SKILL_TELEPORT";
|
|
const skillDef = {
|
|
id: skillId,
|
|
name: "Phase Shift",
|
|
costs: { ap: 2 },
|
|
targeting: {
|
|
range: 5,
|
|
type: "EMPTY",
|
|
line_of_sight: false,
|
|
},
|
|
effects: [{ type: "TELEPORT" }],
|
|
};
|
|
|
|
if (gameLoop.skillTargetingSystem) {
|
|
const registry = gameLoop.skillTargetingSystem.skillRegistry;
|
|
if (registry instanceof Map) {
|
|
registry.set(skillId, skillDef);
|
|
} else if (registry.set) {
|
|
registry.set(skillId, skillDef);
|
|
} else {
|
|
registry[skillId] = skillDef;
|
|
}
|
|
}
|
|
|
|
if (!playerUnit.actions) {
|
|
playerUnit.actions = [];
|
|
}
|
|
playerUnit.actions.push({
|
|
id: skillId,
|
|
name: "Phase Shift",
|
|
costAP: 2,
|
|
cooldown: 0,
|
|
});
|
|
|
|
// Ensure unit has enough AP
|
|
playerUnit.currentAP = 10;
|
|
|
|
// First click - enter targeting mode
|
|
gameLoop.onSkillClicked(skillId);
|
|
expect(gameLoop.combatState).to.equal("TARGETING_SKILL");
|
|
|
|
// Second click - should cancel targeting
|
|
gameLoop.onSkillClicked(skillId);
|
|
expect(gameLoop.combatState).to.equal("IDLE");
|
|
expect(gameLoop.activeSkillId).to.be.null;
|
|
});
|
|
});
|
|
|
|
describe("Hotkey Support", () => {
|
|
it("should trigger skill when number key is pressed", () => {
|
|
const skillId = "SKILL_TEST";
|
|
const skillDef = {
|
|
id: skillId,
|
|
name: "Test Skill",
|
|
costs: { ap: 2 },
|
|
targeting: {
|
|
range: 5,
|
|
type: "ENEMY",
|
|
line_of_sight: true,
|
|
},
|
|
effects: [],
|
|
};
|
|
|
|
// Register the skill in the skill registry
|
|
skillRegistry.skills.set(skillId, skillDef);
|
|
|
|
if (!playerUnit.actions) {
|
|
playerUnit.actions = [];
|
|
}
|
|
playerUnit.actions.push({
|
|
id: skillId,
|
|
name: "Test Skill",
|
|
costAP: 2,
|
|
cooldown: 0,
|
|
});
|
|
|
|
// Ensure unit has enough AP
|
|
playerUnit.currentAP = 10;
|
|
|
|
// Press number key 1 (first skill)
|
|
gameLoop.handleKeyInput("Digit1");
|
|
|
|
// Should enter targeting mode
|
|
expect(gameLoop.combatState).to.equal("TARGETING_SKILL");
|
|
expect(gameLoop.activeSkillId).to.equal(skillId);
|
|
});
|
|
|
|
it("should trigger movement mode when M key is pressed", () => {
|
|
// First enter skill targeting mode
|
|
const skillId = "SKILL_TEST";
|
|
const skillDef = {
|
|
id: skillId,
|
|
name: "Test Skill",
|
|
costs: { ap: 2 },
|
|
targeting: {
|
|
range: 5,
|
|
type: "ENEMY",
|
|
line_of_sight: true,
|
|
},
|
|
effects: [],
|
|
};
|
|
|
|
// Register the skill in the skill registry
|
|
skillRegistry.skills.set(skillId, skillDef);
|
|
|
|
if (!playerUnit.actions) {
|
|
playerUnit.actions = [];
|
|
}
|
|
playerUnit.actions.push({
|
|
id: skillId,
|
|
name: "Test Skill",
|
|
costAP: 2,
|
|
cooldown: 0,
|
|
});
|
|
|
|
// Ensure unit has enough AP
|
|
playerUnit.currentAP = 10;
|
|
|
|
gameLoop.onSkillClicked(skillId);
|
|
expect(gameLoop.combatState).to.equal("TARGETING_SKILL");
|
|
|
|
// Press M key
|
|
gameLoop.handleKeyInput("KeyM");
|
|
|
|
// Should return to movement mode
|
|
expect(gameLoop.combatState).to.equal("IDLE");
|
|
});
|
|
});
|
|
|
|
describe("TELEPORT Effect Execution", () => {
|
|
it("should teleport unit to target position when TELEPORT effect is executed", async () => {
|
|
const skillId = "SKILL_TELEPORT";
|
|
const skillDef = {
|
|
id: skillId,
|
|
name: "Phase Shift",
|
|
costs: { ap: 2 },
|
|
targeting: {
|
|
range: 5,
|
|
type: "EMPTY",
|
|
line_of_sight: false,
|
|
},
|
|
effects: [{ type: "TELEPORT" }],
|
|
};
|
|
|
|
// Register the skill in the skill registry
|
|
skillRegistry.skills.set(skillId, skillDef);
|
|
|
|
// Add skill to unit actions
|
|
if (!playerUnit.actions) {
|
|
playerUnit.actions = [];
|
|
}
|
|
playerUnit.actions.push({
|
|
id: skillId,
|
|
name: "Phase Shift",
|
|
costAP: 2,
|
|
cooldown: 0,
|
|
});
|
|
|
|
const originalPos = { ...playerUnit.position };
|
|
const targetPos = {
|
|
x: originalPos.x + 3,
|
|
y: originalPos.y,
|
|
z: originalPos.z + 3,
|
|
};
|
|
|
|
// Ensure target position is valid and empty
|
|
if (gameLoop.grid.isOccupied(targetPos)) {
|
|
// Clear it if occupied
|
|
const unitAtPos = gameLoop.grid.getUnitAt(targetPos);
|
|
if (unitAtPos) {
|
|
gameLoop.grid.removeUnit(unitAtPos);
|
|
}
|
|
}
|
|
|
|
// Ensure target position is walkable (floor at y=0, air at y=1 and y=2)
|
|
gameLoop.grid.setCell(targetPos.x, 0, targetPos.z, 1); // Floor
|
|
gameLoop.grid.setCell(targetPos.x, targetPos.y, targetPos.z, 0); // Air at y=1
|
|
gameLoop.grid.setCell(targetPos.x, targetPos.y + 1, targetPos.z, 0); // Air at y=2 (headroom)
|
|
|
|
// Execute the skill
|
|
await gameLoop.executeSkill(skillId, targetPos);
|
|
|
|
// Unit should be at target position
|
|
expect(playerUnit.position.x).to.equal(targetPos.x);
|
|
expect(playerUnit.position.z).to.equal(targetPos.z);
|
|
// Y might be adjusted to walkable level
|
|
expect(playerUnit.position.y).to.be.a("number");
|
|
|
|
// Unit mesh should be updated
|
|
const mesh = gameLoop.unitMeshes.get(playerUnit.id);
|
|
if (mesh) {
|
|
expect(mesh.position.x).to.equal(playerUnit.position.x);
|
|
expect(mesh.position.z).to.equal(playerUnit.position.z);
|
|
}
|
|
});
|
|
|
|
it("should deduct AP when executing TELEPORT skill", async () => {
|
|
const skillId = "SKILL_TELEPORT";
|
|
const skillDef = {
|
|
id: skillId,
|
|
name: "Phase Shift",
|
|
costs: { ap: 2 },
|
|
targeting: {
|
|
range: 5,
|
|
type: "EMPTY",
|
|
line_of_sight: false,
|
|
},
|
|
effects: [{ type: "TELEPORT" }],
|
|
};
|
|
|
|
// Register the skill in the skill registry
|
|
skillRegistry.skills.set(skillId, skillDef);
|
|
|
|
if (!playerUnit.actions) {
|
|
playerUnit.actions = [];
|
|
}
|
|
playerUnit.actions.push({
|
|
id: skillId,
|
|
name: "Phase Shift",
|
|
costAP: 2,
|
|
cooldown: 0,
|
|
});
|
|
|
|
// Ensure unit has enough AP
|
|
playerUnit.currentAP = 10;
|
|
const initialAP = playerUnit.currentAP;
|
|
const targetPos = {
|
|
x: playerUnit.position.x + 2,
|
|
y: playerUnit.position.y,
|
|
z: playerUnit.position.z + 2,
|
|
};
|
|
|
|
// Ensure target is empty
|
|
if (gameLoop.grid.isOccupied(targetPos)) {
|
|
const unitAtPos = gameLoop.grid.getUnitAt(targetPos);
|
|
if (unitAtPos) {
|
|
gameLoop.grid.removeUnit(unitAtPos);
|
|
}
|
|
}
|
|
|
|
await gameLoop.executeSkill(skillId, targetPos);
|
|
|
|
// AP should be deducted
|
|
expect(playerUnit.currentAP).to.equal(initialAP - 2);
|
|
});
|
|
|
|
it("should set cooldown when executing skill", async () => {
|
|
const skillId = "SKILL_TELEPORT";
|
|
const skillDef = {
|
|
id: skillId,
|
|
name: "Phase Shift",
|
|
costs: { ap: 2 },
|
|
cooldown_turns: 4,
|
|
targeting: {
|
|
range: 5,
|
|
type: "EMPTY",
|
|
line_of_sight: false,
|
|
},
|
|
effects: [{ type: "TELEPORT" }],
|
|
};
|
|
|
|
// Register the skill in the skill registry
|
|
skillRegistry.skills.set(skillId, skillDef);
|
|
|
|
if (!playerUnit.actions) {
|
|
playerUnit.actions = [];
|
|
}
|
|
const skillAction = {
|
|
id: skillId,
|
|
name: "Phase Shift",
|
|
costAP: 2,
|
|
cooldown: 0,
|
|
};
|
|
playerUnit.actions.push(skillAction);
|
|
|
|
// Ensure unit has enough AP
|
|
playerUnit.currentAP = 10;
|
|
|
|
const targetPos = {
|
|
x: playerUnit.position.x + 2,
|
|
y: playerUnit.position.y,
|
|
z: playerUnit.position.z + 2,
|
|
};
|
|
|
|
// Ensure target is empty
|
|
if (gameLoop.grid.isOccupied(targetPos)) {
|
|
const unitAtPos = gameLoop.grid.getUnitAt(targetPos);
|
|
if (unitAtPos) {
|
|
gameLoop.grid.removeUnit(unitAtPos);
|
|
}
|
|
}
|
|
|
|
await gameLoop.executeSkill(skillId, targetPos);
|
|
|
|
// Cooldown should be set
|
|
expect(skillAction.cooldown).to.equal(1);
|
|
});
|
|
});
|
|
|
|
describe("Skill Targeting State Management", () => {
|
|
it("should clear highlights when ending turn with skill active", () => {
|
|
const skillId = "SKILL_TELEPORT";
|
|
const skillDef = {
|
|
id: skillId,
|
|
name: "Phase Shift",
|
|
costs: { ap: 2 },
|
|
targeting: {
|
|
range: 5,
|
|
type: "EMPTY",
|
|
line_of_sight: false,
|
|
},
|
|
effects: [{ type: "TELEPORT" }],
|
|
};
|
|
|
|
// Register the skill in the skill registry
|
|
skillRegistry.skills.set(skillId, skillDef);
|
|
|
|
if (!playerUnit.actions) {
|
|
playerUnit.actions = [];
|
|
}
|
|
playerUnit.actions.push({
|
|
id: skillId,
|
|
name: "Phase Shift",
|
|
costAP: 2,
|
|
cooldown: 0,
|
|
});
|
|
|
|
// Ensure unit has enough AP
|
|
playerUnit.currentAP = 10;
|
|
|
|
// Enter targeting mode
|
|
gameLoop.onSkillClicked(skillId);
|
|
expect(gameLoop.combatState).to.equal("TARGETING_SKILL");
|
|
|
|
// End turn
|
|
gameLoop.endTurn();
|
|
|
|
// Should clear targeting state
|
|
expect(gameLoop.combatState).to.equal("IDLE");
|
|
expect(gameLoop.activeSkillId).to.be.null;
|
|
});
|
|
});
|
|
});
|
|
|