Implement InputManager for comprehensive input handling, including keyboard, mouse, and gamepad support. Integrate InputManager into GameLoop for cursor management and validation during gameplay. Enhance GameLoop with input event handling for movement and selection. Add unit tests for InputManager to ensure functionality and reliability.
This commit is contained in:
parent
2d72fb9170
commit
0faef9d178
3 changed files with 691 additions and 94 deletions
|
|
@ -5,29 +5,33 @@ import { VoxelManager } from "../grid/VoxelManager.js";
|
||||||
import { UnitManager } from "../managers/UnitManager.js";
|
import { UnitManager } from "../managers/UnitManager.js";
|
||||||
import { CaveGenerator } from "../generation/CaveGenerator.js";
|
import { CaveGenerator } from "../generation/CaveGenerator.js";
|
||||||
import { RuinGenerator } from "../generation/RuinGenerator.js";
|
import { RuinGenerator } from "../generation/RuinGenerator.js";
|
||||||
|
import { InputManager } from "./InputManager.js";
|
||||||
|
|
||||||
export class GameLoop {
|
export class GameLoop {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.isRunning = false;
|
this.isRunning = false;
|
||||||
this.phase = "INIT"; // INIT, DEPLOYMENT, ACTIVE, RESOLUTION
|
this.phase = "INIT";
|
||||||
|
|
||||||
// 1. Core Systems
|
// 1. Core Systems
|
||||||
this.scene = new THREE.Scene();
|
this.scene = new THREE.Scene();
|
||||||
this.camera = null;
|
this.camera = null;
|
||||||
this.renderer = null;
|
this.renderer = null;
|
||||||
this.controls = null;
|
this.controls = null;
|
||||||
|
this.inputManager = null;
|
||||||
|
|
||||||
this.grid = null;
|
this.grid = null;
|
||||||
this.voxelManager = null;
|
this.voxelManager = null;
|
||||||
this.unitManager = null;
|
this.unitManager = null;
|
||||||
|
|
||||||
// Store visual meshes for units [unitId -> THREE.Mesh]
|
|
||||||
this.unitMeshes = new Map();
|
this.unitMeshes = new Map();
|
||||||
|
|
||||||
// 2. State
|
|
||||||
this.runData = null;
|
this.runData = null;
|
||||||
this.playerSpawnZone = [];
|
this.playerSpawnZone = [];
|
||||||
this.enemySpawnZone = [];
|
this.enemySpawnZone = [];
|
||||||
|
|
||||||
|
// Input Logic State
|
||||||
|
this.lastMoveTime = 0;
|
||||||
|
this.moveCooldown = 120; // ms between cursor moves
|
||||||
|
this.selectionMode = "MOVEMENT"; // MOVEMENT, TARGETING
|
||||||
}
|
}
|
||||||
|
|
||||||
init(container) {
|
init(container) {
|
||||||
|
|
@ -43,26 +47,37 @@ export class GameLoop {
|
||||||
|
|
||||||
this.renderer = new THREE.WebGLRenderer({ antialias: true });
|
this.renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||||
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
||||||
this.renderer.setClearColor(0x111111); // Dark background
|
this.renderer.setClearColor(0x111111);
|
||||||
container.appendChild(this.renderer.domElement);
|
container.appendChild(this.renderer.domElement);
|
||||||
|
|
||||||
// Setup OrbitControls
|
|
||||||
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
||||||
this.controls.enableDamping = true;
|
this.controls.enableDamping = true;
|
||||||
this.controls.dampingFactor = 0.05;
|
this.controls.dampingFactor = 0.05;
|
||||||
this.controls.screenSpacePanning = false;
|
|
||||||
this.controls.minDistance = 5;
|
|
||||||
this.controls.maxDistance = 100;
|
|
||||||
this.controls.maxPolarAngle = Math.PI / 2;
|
|
||||||
|
|
||||||
// Lighting
|
// --- SETUP INPUT MANAGER ---
|
||||||
|
this.inputManager = new InputManager(
|
||||||
|
this.camera,
|
||||||
|
this.scene,
|
||||||
|
this.renderer.domElement
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bind Buttons (Events)
|
||||||
|
this.inputManager.addEventListener("gamepadbuttondown", (e) =>
|
||||||
|
this.handleButtonInput(e.detail)
|
||||||
|
);
|
||||||
|
this.inputManager.addEventListener("keydown", (e) =>
|
||||||
|
this.handleKeyInput(e.detail)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Default Validator: Movement Logic (Will be overridden in startLevel)
|
||||||
|
this.inputManager.setValidator(this.validateCursorMove.bind(this));
|
||||||
|
|
||||||
const ambient = new THREE.AmbientLight(0xffffff, 0.6);
|
const ambient = new THREE.AmbientLight(0xffffff, 0.6);
|
||||||
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||||
dirLight.position.set(10, 20, 10);
|
dirLight.position.set(10, 20, 10);
|
||||||
this.scene.add(ambient);
|
this.scene.add(ambient);
|
||||||
this.scene.add(dirLight);
|
this.scene.add(dirLight);
|
||||||
|
|
||||||
// Handle Resize
|
|
||||||
window.addEventListener("resize", () => {
|
window.addEventListener("resize", () => {
|
||||||
this.camera.aspect = window.innerWidth / window.innerHeight;
|
this.camera.aspect = window.innerWidth / window.innerHeight;
|
||||||
this.camera.updateProjectionMatrix();
|
this.camera.updateProjectionMatrix();
|
||||||
|
|
@ -73,52 +88,138 @@ export class GameLoop {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts a Level based on Run Data (New or Loaded).
|
* Validation Logic for Standard Movement.
|
||||||
* Generates the map but does NOT spawn units immediately.
|
* Checks for valid ground, headroom, and bounds.
|
||||||
|
* Returns modified position (climbing/dropping) or false (invalid).
|
||||||
*/
|
*/
|
||||||
|
validateCursorMove(x, y, z) {
|
||||||
|
if (!this.grid) return true; // Allow if grid not ready
|
||||||
|
|
||||||
|
// 1. Basic Bounds Check
|
||||||
|
if (!this.grid.isValidBounds({ x, y: 0, z })) return false;
|
||||||
|
|
||||||
|
// 2. Scan Column for Surface (Climb/Drop Logic)
|
||||||
|
// Look 2 units up and 2 units down from current Y
|
||||||
|
let bestY = null;
|
||||||
|
|
||||||
|
// Check Current Level
|
||||||
|
if (this.isWalkable(x, y, z)) bestY = y;
|
||||||
|
// Check Climb (y+1)
|
||||||
|
else if (this.isWalkable(x, y + 1, z)) bestY = y + 1;
|
||||||
|
// Check Drop (y-1, y-2)
|
||||||
|
else if (this.isWalkable(x, y - 1, z)) bestY = y - 1;
|
||||||
|
else if (this.isWalkable(x, y - 2, z)) bestY = y - 2;
|
||||||
|
|
||||||
|
if (bestY !== null) {
|
||||||
|
return { x, y: bestY, z };
|
||||||
|
}
|
||||||
|
|
||||||
|
return false; // No valid footing found
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation Logic for Deployment Phase.
|
||||||
|
* Restricts cursor to the Player Spawn Zone.
|
||||||
|
*/
|
||||||
|
validateDeploymentCursor(x, y, z) {
|
||||||
|
if (!this.grid || this.playerSpawnZone.length === 0) return false;
|
||||||
|
|
||||||
|
// Check if the target X,Z is inside the spawn zone list
|
||||||
|
const validSpot = this.playerSpawnZone.find((t) => t.x === x && t.z === z);
|
||||||
|
|
||||||
|
if (validSpot) {
|
||||||
|
// Snap Y to the valid floor height defined in the zone
|
||||||
|
return { x: validSpot.x, y: validSpot.y, z: validSpot.z };
|
||||||
|
}
|
||||||
|
|
||||||
|
return false; // Cursor cannot leave the spawn zone
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Checks if a specific tile is valid to stand on.
|
||||||
|
*/
|
||||||
|
isWalkable(x, y, z) {
|
||||||
|
// Must be Air
|
||||||
|
if (this.grid.getCell(x, y, z) !== 0) return false;
|
||||||
|
// Must have Solid Floor below
|
||||||
|
if (this.grid.getCell(x, y - 1, z) === 0) return false;
|
||||||
|
// Must have Headroom (Air above)
|
||||||
|
if (this.grid.getCell(x, y + 1, z) !== 0) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation Logic for Interaction / Targeting.
|
||||||
|
* Allows selecting Walls, Enemies, or Empty Space (within bounds).
|
||||||
|
*/
|
||||||
|
validateInteractionTarget(x, y, z) {
|
||||||
|
if (!this.grid) return true;
|
||||||
|
return this.grid.isValidBounds({ x, y, z });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleButtonInput(detail) {
|
||||||
|
if (detail.buttonIndex === 0) {
|
||||||
|
// A / Cross
|
||||||
|
this.triggerSelection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKeyInput(code) {
|
||||||
|
if (code === "Space" || code === "Enter") {
|
||||||
|
this.triggerSelection();
|
||||||
|
}
|
||||||
|
// Toggle Mode for Debug (e.g. Tab)
|
||||||
|
if (code === "Tab") {
|
||||||
|
this.selectionMode =
|
||||||
|
this.selectionMode === "MOVEMENT" ? "TARGETING" : "MOVEMENT";
|
||||||
|
const validator =
|
||||||
|
this.selectionMode === "MOVEMENT"
|
||||||
|
? this.validateCursorMove.bind(this)
|
||||||
|
: this.validateInteractionTarget.bind(this);
|
||||||
|
|
||||||
|
this.inputManager.setValidator(validator);
|
||||||
|
console.log(`Switched to ${this.selectionMode} mode`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerSelection() {
|
||||||
|
const cursor = this.inputManager.getCursorPosition();
|
||||||
|
console.log("Action at:", cursor);
|
||||||
|
|
||||||
|
if (this.phase === "DEPLOYMENT") {
|
||||||
|
// TODO: Check if selecting a deployed unit to move it, or a tile to deploy to
|
||||||
|
// This requires state from the UI (which unit is selected in roster)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async startLevel(runData) {
|
async startLevel(runData) {
|
||||||
console.log("GameLoop: Generating Level...");
|
console.log("GameLoop: Generating Level...");
|
||||||
this.runData = runData;
|
this.runData = runData;
|
||||||
this.isRunning = true;
|
this.isRunning = true;
|
||||||
this.phase = "DEPLOYMENT";
|
this.phase = "DEPLOYMENT";
|
||||||
|
|
||||||
// Cleanup previous level
|
|
||||||
this.clearUnitMeshes();
|
this.clearUnitMeshes();
|
||||||
|
|
||||||
// 1. Initialize Grid (20x10x20 for prototype)
|
|
||||||
this.grid = new VoxelGrid(20, 10, 20);
|
this.grid = new VoxelGrid(20, 10, 20);
|
||||||
|
|
||||||
// 2. Generate World
|
|
||||||
// TODO: Switch generator based on runData.biome_id
|
|
||||||
const generator = new RuinGenerator(this.grid, runData.seed);
|
const generator = new RuinGenerator(this.grid, runData.seed);
|
||||||
generator.generate();
|
generator.generate();
|
||||||
|
|
||||||
// 3. Extract Spawn Zones
|
|
||||||
if (generator.generatedAssets.spawnZones) {
|
if (generator.generatedAssets.spawnZones) {
|
||||||
this.playerSpawnZone = generator.generatedAssets.spawnZones.player || [];
|
this.playerSpawnZone = generator.generatedAssets.spawnZones.player || [];
|
||||||
this.enemySpawnZone = generator.generatedAssets.spawnZones.enemy || [];
|
this.enemySpawnZone = generator.generatedAssets.spawnZones.enemy || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Safety Fallback if generator provided no zones
|
if (this.playerSpawnZone.length === 0)
|
||||||
if (this.playerSpawnZone.length === 0) {
|
|
||||||
console.warn("No Player Spawn Zone generated. Using default.");
|
|
||||||
this.playerSpawnZone.push({ x: 2, y: 1, z: 2 });
|
this.playerSpawnZone.push({ x: 2, y: 1, z: 2 });
|
||||||
}
|
if (this.enemySpawnZone.length === 0)
|
||||||
if (this.enemySpawnZone.length === 0) {
|
|
||||||
console.warn("No Enemy Spawn Zone generated. Using default.");
|
|
||||||
this.enemySpawnZone.push({ x: 18, y: 1, z: 18 });
|
this.enemySpawnZone.push({ x: 18, y: 1, z: 18 });
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Initialize Visuals
|
|
||||||
this.voxelManager = new VoxelManager(this.grid, this.scene);
|
this.voxelManager = new VoxelManager(this.grid, this.scene);
|
||||||
this.voxelManager.updateMaterials(generator.generatedAssets);
|
this.voxelManager.updateMaterials(generator.generatedAssets);
|
||||||
this.voxelManager.update();
|
this.voxelManager.update();
|
||||||
|
|
||||||
if (this.controls) {
|
if (this.controls) this.voxelManager.focusCamera(this.controls);
|
||||||
this.voxelManager.focusCamera(this.controls);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Initialize Unit Manager (Empty)
|
|
||||||
const mockRegistry = {
|
const mockRegistry = {
|
||||||
get: (id) => {
|
get: (id) => {
|
||||||
if (id.startsWith("CLASS_"))
|
if (id.startsWith("CLASS_"))
|
||||||
|
|
@ -132,83 +233,59 @@ export class GameLoop {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
this.unitManager = new UnitManager(mockRegistry);
|
this.unitManager = new UnitManager(mockRegistry);
|
||||||
|
|
||||||
// 6. Highlight Spawn Zones (Visual Debug)
|
|
||||||
this.highlightZones();
|
this.highlightZones();
|
||||||
|
|
||||||
// Start Render Loop (Waiting for player input)
|
// Snap Cursor to Player Start
|
||||||
|
if (this.playerSpawnZone.length > 0) {
|
||||||
|
const start = this.playerSpawnZone[0];
|
||||||
|
// Ensure y is correct (on top of floor)
|
||||||
|
this.inputManager.setCursor(start.x, start.y, start.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set Strict Validator for Deployment
|
||||||
|
this.inputManager.setValidator(this.validateDeploymentCursor.bind(this));
|
||||||
|
|
||||||
this.animate();
|
this.animate();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Called by UI to place a unit during Deployment Phase.
|
|
||||||
*/
|
|
||||||
deployUnit(unitDef, targetTile) {
|
deployUnit(unitDef, targetTile) {
|
||||||
if (this.phase !== "DEPLOYMENT") {
|
if (this.phase !== "DEPLOYMENT") return null;
|
||||||
console.warn("Cannot deploy unit outside Deployment phase.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate Tile
|
// Re-validate using the zone logic (Double check)
|
||||||
const isValid = this.playerSpawnZone.some(
|
const isValid = this.validateDeploymentCursor(
|
||||||
(t) => t.x === targetTile.x && t.z === targetTile.z
|
targetTile.x,
|
||||||
|
targetTile.y,
|
||||||
|
targetTile.z
|
||||||
);
|
);
|
||||||
if (!isValid) {
|
|
||||||
console.warn("Invalid spawn location.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.grid.isOccupied(targetTile)) {
|
if (!isValid || this.grid.isOccupied(targetTile)) return null;
|
||||||
console.warn("Tile occupied.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create and Place
|
|
||||||
const unit = this.unitManager.createUnit(
|
const unit = this.unitManager.createUnit(
|
||||||
unitDef.classId || unitDef.id,
|
unitDef.classId || unitDef.id,
|
||||||
"PLAYER"
|
"PLAYER"
|
||||||
);
|
);
|
||||||
if (unitDef.name) unit.name = unitDef.name;
|
if (unitDef.name) unit.name = unitDef.name;
|
||||||
|
|
||||||
this.grid.placeUnit(unit, targetTile);
|
this.grid.placeUnit(unit, targetTile);
|
||||||
this.createUnitMesh(unit, targetTile);
|
this.createUnitMesh(unit, targetTile);
|
||||||
|
|
||||||
console.log(
|
|
||||||
`Deployed ${unit.name} at ${targetTile.x},${targetTile.y},${targetTile.z}`
|
|
||||||
);
|
|
||||||
return unit;
|
return unit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when player clicks "Start Battle".
|
|
||||||
* Spawns enemies and switches phase.
|
|
||||||
*/
|
|
||||||
finalizeDeployment() {
|
finalizeDeployment() {
|
||||||
if (this.phase !== "DEPLOYMENT") return;
|
if (this.phase !== "DEPLOYMENT") return;
|
||||||
|
|
||||||
console.log("Finalizing Deployment. Spawning Enemies...");
|
|
||||||
|
|
||||||
// Simple Enemy Spawning Logic
|
|
||||||
// In a real game, this would read from a Level Design configuration
|
|
||||||
const enemyCount = 2;
|
const enemyCount = 2;
|
||||||
|
|
||||||
for (let i = 0; i < enemyCount; i++) {
|
for (let i = 0; i < enemyCount; i++) {
|
||||||
// Pick a random spot in enemy zone
|
|
||||||
const spotIndex = Math.floor(Math.random() * this.enemySpawnZone.length);
|
const spotIndex = Math.floor(Math.random() * this.enemySpawnZone.length);
|
||||||
const spot = this.enemySpawnZone[spotIndex];
|
const spot = this.enemySpawnZone[spotIndex];
|
||||||
|
|
||||||
// Ensure spot is valid/empty
|
|
||||||
if (spot && !this.grid.isOccupied(spot)) {
|
if (spot && !this.grid.isOccupied(spot)) {
|
||||||
const enemy = this.unitManager.createUnit("ENEMY_DEFAULT", "ENEMY");
|
const enemy = this.unitManager.createUnit("ENEMY_DEFAULT", "ENEMY");
|
||||||
this.grid.placeUnit(enemy, spot);
|
this.grid.placeUnit(enemy, spot);
|
||||||
this.createUnitMesh(enemy, spot);
|
this.createUnitMesh(enemy, spot);
|
||||||
// Remove spot from pool to avoid double spawn
|
|
||||||
this.enemySpawnZone.splice(spotIndex, 1);
|
this.enemySpawnZone.splice(spotIndex, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.phase = "ACTIVE";
|
this.phase = "ACTIVE";
|
||||||
// TODO: Start Turn System here
|
|
||||||
|
// Switch to standard movement validator for the game
|
||||||
|
this.inputManager.setValidator(this.validateCursorMove.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
clearUnitMeshes() {
|
clearUnitMeshes() {
|
||||||
|
|
@ -219,26 +296,16 @@ export class GameLoop {
|
||||||
createUnitMesh(unit, pos) {
|
createUnitMesh(unit, pos) {
|
||||||
const geometry = new THREE.BoxGeometry(0.6, 1.2, 0.6);
|
const geometry = new THREE.BoxGeometry(0.6, 1.2, 0.6);
|
||||||
let color = 0xcccccc;
|
let color = 0xcccccc;
|
||||||
|
|
||||||
if (unit.id.includes("VANGUARD")) color = 0xff3333;
|
if (unit.id.includes("VANGUARD")) color = 0xff3333;
|
||||||
else if (unit.id.includes("WEAVER")) color = 0x3333ff;
|
else if (unit.team === "ENEMY") color = 0x550000;
|
||||||
else if (unit.id.includes("SCAVENGER")) color = 0xffff33;
|
|
||||||
else if (unit.id.includes("TINKER")) color = 0xff9933;
|
|
||||||
else if (unit.id.includes("CUSTODIAN")) color = 0x33ff33;
|
|
||||||
else if (unit.team === "ENEMY") color = 0x550000; // Dark Red for enemies
|
|
||||||
|
|
||||||
const material = new THREE.MeshStandardMaterial({ color: color });
|
const material = new THREE.MeshStandardMaterial({ color: color });
|
||||||
const mesh = new THREE.Mesh(geometry, material);
|
const mesh = new THREE.Mesh(geometry, material);
|
||||||
|
|
||||||
mesh.position.set(pos.x, pos.y + 0.6, pos.z);
|
mesh.position.set(pos.x, pos.y + 0.6, pos.z);
|
||||||
|
|
||||||
this.scene.add(mesh);
|
this.scene.add(mesh);
|
||||||
this.unitMeshes.set(unit.id, mesh);
|
this.unitMeshes.set(unit.id, mesh);
|
||||||
}
|
}
|
||||||
|
|
||||||
highlightZones() {
|
highlightZones() {
|
||||||
// Visual debug for spawn zones (Green for Player, Red for Enemy)
|
|
||||||
// In a full implementation, this would use the VoxelManager's highlight system
|
|
||||||
const highlightMatPlayer = new THREE.MeshBasicMaterial({
|
const highlightMatPlayer = new THREE.MeshBasicMaterial({
|
||||||
color: 0x00ff00,
|
color: 0x00ff00,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
|
|
@ -251,14 +318,11 @@ export class GameLoop {
|
||||||
});
|
});
|
||||||
const geo = new THREE.PlaneGeometry(1, 1);
|
const geo = new THREE.PlaneGeometry(1, 1);
|
||||||
geo.rotateX(-Math.PI / 2);
|
geo.rotateX(-Math.PI / 2);
|
||||||
|
|
||||||
this.playerSpawnZone.forEach((pos) => {
|
this.playerSpawnZone.forEach((pos) => {
|
||||||
const mesh = new THREE.Mesh(geo, highlightMatPlayer);
|
const mesh = new THREE.Mesh(geo, highlightMatPlayer);
|
||||||
mesh.position.set(pos.x, pos.y + 0.05, pos.z); // Slightly above floor
|
mesh.position.set(pos.x, pos.y + 0.05, pos.z);
|
||||||
this.scene.add(mesh);
|
this.scene.add(mesh);
|
||||||
// Note: Should track these to clean up later
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.enemySpawnZone.forEach((pos) => {
|
this.enemySpawnZone.forEach((pos) => {
|
||||||
const mesh = new THREE.Mesh(geo, highlightMatEnemy);
|
const mesh = new THREE.Mesh(geo, highlightMatEnemy);
|
||||||
mesh.position.set(pos.x, pos.y + 0.05, pos.z);
|
mesh.position.set(pos.x, pos.y + 0.05, pos.z);
|
||||||
|
|
@ -270,10 +334,50 @@ export class GameLoop {
|
||||||
if (!this.isRunning) return;
|
if (!this.isRunning) return;
|
||||||
requestAnimationFrame(this.animate);
|
requestAnimationFrame(this.animate);
|
||||||
|
|
||||||
if (this.controls) {
|
// 1. Update Managers
|
||||||
this.controls.update();
|
if (this.inputManager) this.inputManager.update();
|
||||||
|
if (this.controls) this.controls.update();
|
||||||
|
|
||||||
|
// 2. Handle Continuous Input (Keyboard polling)
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - this.lastMoveTime > this.moveCooldown) {
|
||||||
|
let dx = 0;
|
||||||
|
let dz = 0;
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.inputManager.isKeyPressed("KeyW") ||
|
||||||
|
this.inputManager.isKeyPressed("ArrowUp")
|
||||||
|
)
|
||||||
|
dz = -1;
|
||||||
|
if (
|
||||||
|
this.inputManager.isKeyPressed("KeyS") ||
|
||||||
|
this.inputManager.isKeyPressed("ArrowDown")
|
||||||
|
)
|
||||||
|
dz = 1;
|
||||||
|
if (
|
||||||
|
this.inputManager.isKeyPressed("KeyA") ||
|
||||||
|
this.inputManager.isKeyPressed("ArrowLeft")
|
||||||
|
)
|
||||||
|
dx = -1;
|
||||||
|
if (
|
||||||
|
this.inputManager.isKeyPressed("KeyD") ||
|
||||||
|
this.inputManager.isKeyPressed("ArrowRight")
|
||||||
|
)
|
||||||
|
dx = 1;
|
||||||
|
|
||||||
|
if (dx !== 0 || dz !== 0) {
|
||||||
|
const currentPos = this.inputManager.getCursorPosition();
|
||||||
|
const newX = currentPos.x + dx;
|
||||||
|
const newZ = currentPos.z + dz;
|
||||||
|
|
||||||
|
// Pass desired coordinates to InputManager
|
||||||
|
// InputManager will call our validator (validateCursorMove/Deployment) to check logic
|
||||||
|
this.inputManager.setCursor(newX, currentPos.y, newZ);
|
||||||
|
this.lastMoveTime = now;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Render
|
||||||
const time = Date.now() * 0.002;
|
const time = Date.now() * 0.002;
|
||||||
this.unitMeshes.forEach((mesh) => {
|
this.unitMeshes.forEach((mesh) => {
|
||||||
mesh.position.y += Math.sin(time) * 0.002;
|
mesh.position.y += Math.sin(time) * 0.002;
|
||||||
|
|
@ -284,6 +388,7 @@ export class GameLoop {
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
this.isRunning = false;
|
this.isRunning = false;
|
||||||
|
if (this.inputManager) this.inputManager.detach();
|
||||||
if (this.controls) this.controls.dispose();
|
if (this.controls) this.controls.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
284
src/core/InputManager.js
Normal file
284
src/core/InputManager.js
Normal file
|
|
@ -0,0 +1,284 @@
|
||||||
|
import * as THREE from "three";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* InputManager.js
|
||||||
|
* Handles mouse interaction, raycasting, grid selection, keyboard, and Gamepad input.
|
||||||
|
* Extends EventTarget for standard event handling.
|
||||||
|
*/
|
||||||
|
export class InputManager extends EventTarget {
|
||||||
|
constructor(camera, scene, domElement) {
|
||||||
|
super();
|
||||||
|
this.camera = camera;
|
||||||
|
this.scene = scene;
|
||||||
|
this.domElement = domElement;
|
||||||
|
|
||||||
|
this.raycaster = new THREE.Raycaster();
|
||||||
|
this.mouse = new THREE.Vector2();
|
||||||
|
|
||||||
|
// Input State
|
||||||
|
this.keys = new Set();
|
||||||
|
this.gamepads = new Map();
|
||||||
|
this.axisDeadzone = 0.2; // Increased slightly for stability
|
||||||
|
|
||||||
|
// Cursor State
|
||||||
|
this.cursorPos = new THREE.Vector3(0, 0, 0);
|
||||||
|
this.cursorValidator = null; // Function to check if a position is valid
|
||||||
|
|
||||||
|
// Visual Cursor (Wireframe Box)
|
||||||
|
const geometry = new THREE.BoxGeometry(1.05, 1.05, 1.05);
|
||||||
|
const material = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0xffff00,
|
||||||
|
wireframe: true,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.8,
|
||||||
|
});
|
||||||
|
this.cursor = new THREE.Mesh(geometry, material);
|
||||||
|
this.cursor.visible = false;
|
||||||
|
// Ensure cursor doesn't interfere with raycast
|
||||||
|
this.cursor.raycast = () => {};
|
||||||
|
this.scene.add(this.cursor);
|
||||||
|
|
||||||
|
// Bindings
|
||||||
|
this.onMouseMove = this.onMouseMove.bind(this);
|
||||||
|
this.onMouseClick = this.onMouseClick.bind(this);
|
||||||
|
this.onKeyDown = this.onKeyDown.bind(this);
|
||||||
|
this.onKeyUp = this.onKeyUp.bind(this);
|
||||||
|
this.onGamepadConnected = this.onGamepadConnected.bind(this);
|
||||||
|
this.onGamepadDisconnected = this.onGamepadDisconnected.bind(this);
|
||||||
|
|
||||||
|
this.attach();
|
||||||
|
}
|
||||||
|
|
||||||
|
attach() {
|
||||||
|
this.domElement.addEventListener("mousemove", this.onMouseMove);
|
||||||
|
this.domElement.addEventListener("click", this.onMouseClick);
|
||||||
|
window.addEventListener("keydown", this.onKeyDown);
|
||||||
|
window.addEventListener("keyup", this.onKeyUp);
|
||||||
|
window.addEventListener("gamepadconnected", this.onGamepadConnected);
|
||||||
|
window.addEventListener("gamepaddisconnected", this.onGamepadDisconnected);
|
||||||
|
}
|
||||||
|
|
||||||
|
detach() {
|
||||||
|
this.domElement.removeEventListener("mousemove", this.onMouseMove);
|
||||||
|
this.domElement.removeEventListener("click", this.onMouseClick);
|
||||||
|
window.removeEventListener("keydown", this.onKeyDown);
|
||||||
|
window.removeEventListener("keyup", this.onKeyUp);
|
||||||
|
window.removeEventListener("gamepadconnected", this.onGamepadConnected);
|
||||||
|
window.removeEventListener(
|
||||||
|
"gamepaddisconnected",
|
||||||
|
this.onGamepadDisconnected
|
||||||
|
);
|
||||||
|
|
||||||
|
if (this.cursor.parent) {
|
||||||
|
this.cursor.parent.remove(this.cursor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CURSOR MANIPULATION ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a validation function to restrict cursor movement.
|
||||||
|
* @param {Function} validatorFn - Takes (x, y, z). Returns true/false OR a modified {x,y,z} object.
|
||||||
|
*/
|
||||||
|
setValidator(validatorFn) {
|
||||||
|
this.cursorValidator = validatorFn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Programmatically move the cursor (e.g. via Gamepad or Keyboard).
|
||||||
|
* This now simulates a raycast-like resolution to ensure consistent behavior.
|
||||||
|
*/
|
||||||
|
setCursor(x, y, z) {
|
||||||
|
// Instead of raw rounding, we try to "snap" using the same logic as raycast
|
||||||
|
// if possible, or just default to standard validation.
|
||||||
|
|
||||||
|
// Since keyboard input gives us integer coordinates directly (usually),
|
||||||
|
// we can treat them as the "Target Voxel".
|
||||||
|
|
||||||
|
let targetX = Math.round(x);
|
||||||
|
let targetY = Math.round(y);
|
||||||
|
let targetZ = Math.round(z);
|
||||||
|
|
||||||
|
// Validate or Adjust the target position if a validator is set
|
||||||
|
if (this.cursorValidator) {
|
||||||
|
const result = this.cursorValidator(targetX, targetY, targetZ);
|
||||||
|
|
||||||
|
if (result === false) {
|
||||||
|
return; // Invalid, abort
|
||||||
|
} else if (typeof result === "object") {
|
||||||
|
// Validator returned a corrected position (e.g. climbed a wall)
|
||||||
|
targetX = result.x;
|
||||||
|
targetY = result.y;
|
||||||
|
targetZ = result.z;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cursor.visible = true;
|
||||||
|
this.cursorPos.set(targetX, targetY, targetZ);
|
||||||
|
this.cursor.position.copy(this.cursorPos);
|
||||||
|
|
||||||
|
// Emit hover event for UI/GameLoop to react
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent("hover", {
|
||||||
|
detail: { voxelPosition: this.cursorPos },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCursorPosition() {
|
||||||
|
return this.cursorPos;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- GAMEPAD LOGIC ---
|
||||||
|
|
||||||
|
onGamepadConnected(event) {
|
||||||
|
console.log(`Gamepad connected: ${event.gamepad.id}`);
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent("gamepadconnected", { detail: event.gamepad })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onGamepadDisconnected(event) {
|
||||||
|
this.gamepads.delete(event.gamepad.index);
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent("gamepaddisconnected", { detail: event.gamepad })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
const activeGamepads = navigator.getGamepads ? navigator.getGamepads() : [];
|
||||||
|
|
||||||
|
for (const gamepad of activeGamepads) {
|
||||||
|
if (!gamepad) continue;
|
||||||
|
|
||||||
|
if (!this.gamepads.has(gamepad.index)) {
|
||||||
|
this.gamepads.set(gamepad.index, {
|
||||||
|
buttons: new Array(gamepad.buttons.length).fill(false),
|
||||||
|
axes: new Array(gamepad.axes.length).fill(0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevState = this.gamepads.get(gamepad.index);
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
gamepad.buttons.forEach((btn, i) => {
|
||||||
|
const pressed = btn.pressed;
|
||||||
|
if (pressed !== prevState.buttons[i]) {
|
||||||
|
prevState.buttons[i] = pressed;
|
||||||
|
const eventType = pressed ? "gamepadbuttondown" : "gamepadbuttonup";
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent(eventType, {
|
||||||
|
detail: { gamepadIndex: gamepad.index, buttonIndex: i },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Axes
|
||||||
|
gamepad.axes.forEach((val, i) => {
|
||||||
|
const cleanVal = Math.abs(val) > this.axisDeadzone ? val : 0;
|
||||||
|
// Always emit axis state if non-zero (for continuous movement)
|
||||||
|
if (Math.abs(cleanVal) > 0 || prevState.axes[i] !== 0) {
|
||||||
|
prevState.axes[i] = cleanVal;
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent("gamepadaxis", {
|
||||||
|
detail: {
|
||||||
|
gamepadIndex: gamepad.index,
|
||||||
|
axisIndex: i,
|
||||||
|
value: cleanVal,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- KEYBOARD LOGIC ---
|
||||||
|
|
||||||
|
onKeyDown(event) {
|
||||||
|
this.keys.add(event.code);
|
||||||
|
this.dispatchEvent(new CustomEvent("keydown", { detail: event.code }));
|
||||||
|
}
|
||||||
|
|
||||||
|
onKeyUp(event) {
|
||||||
|
this.keys.delete(event.code);
|
||||||
|
this.dispatchEvent(new CustomEvent("keyup", { detail: event.code }));
|
||||||
|
}
|
||||||
|
|
||||||
|
isKeyPressed(code) {
|
||||||
|
return this.keys.has(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MOUSE LOGIC ---
|
||||||
|
|
||||||
|
onMouseMove(event) {
|
||||||
|
this.updateMouseCoords(event);
|
||||||
|
const hit = this.raycast();
|
||||||
|
|
||||||
|
if (hit) {
|
||||||
|
// Direct set, because raycast already calculated the "voxelPosition" (target)
|
||||||
|
this.setCursor(
|
||||||
|
hit.voxelPosition.x,
|
||||||
|
hit.voxelPosition.y,
|
||||||
|
hit.voxelPosition.z
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Optional: Hide cursor if mouse leaves grid?
|
||||||
|
// this.cursor.visible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMouseClick(event) {
|
||||||
|
if (this.cursor.visible) {
|
||||||
|
this.dispatchEvent(new CustomEvent("click", { detail: this.cursorPos }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMouseCoords(event) {
|
||||||
|
const rect = this.domElement.getBoundingClientRect();
|
||||||
|
this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||||
|
this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a world-space point and normal into voxel coordinates.
|
||||||
|
* Shared logic for Raycasting and potentially other inputs.
|
||||||
|
*/
|
||||||
|
resolveCursorFromWorldPosition(point, normal) {
|
||||||
|
const p = point.clone();
|
||||||
|
|
||||||
|
// Move slightly inside the block to handle edge cases
|
||||||
|
// (p - n * 0.5) puts us inside the block we hit
|
||||||
|
p.addScaledVector(normal, -0.5);
|
||||||
|
|
||||||
|
const x = Math.round(p.x);
|
||||||
|
const y = Math.round(p.y);
|
||||||
|
const z = Math.round(p.z);
|
||||||
|
|
||||||
|
// Calculate the "Target" tile (e.g. the space *adjacent* to the face)
|
||||||
|
const targetX = x + Math.round(normal.x);
|
||||||
|
const targetY = y + Math.round(normal.y);
|
||||||
|
const targetZ = z + Math.round(normal.z);
|
||||||
|
|
||||||
|
return {
|
||||||
|
hitPosition: { x, y, z }, // The solid block
|
||||||
|
voxelPosition: { x: targetX, y: targetY, z: targetZ }, // The empty space adjacent
|
||||||
|
normal: normal,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
raycast() {
|
||||||
|
this.raycaster.setFromCamera(this.mouse, this.camera);
|
||||||
|
const intersects = this.raycaster.intersectObjects(
|
||||||
|
this.scene.children,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
const hit = intersects.find((i) => i.object.isInstancedMesh);
|
||||||
|
|
||||||
|
if (hit) {
|
||||||
|
return this.resolveCursorFromWorldPosition(hit.point, hit.face.normal);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
208
test/core/InputManager.test.js
Normal file
208
test/core/InputManager.test.js
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
import { expect } from "@esm-bundle/chai";
|
||||||
|
import sinon from "sinon";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { InputManager } from "../../src/core/InputManager.js";
|
||||||
|
|
||||||
|
describe("Core: InputManager", () => {
|
||||||
|
let inputManager;
|
||||||
|
let camera;
|
||||||
|
let scene;
|
||||||
|
let domElement;
|
||||||
|
let originalGetGamepads;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
camera = new THREE.PerspectiveCamera();
|
||||||
|
scene = new THREE.Scene();
|
||||||
|
|
||||||
|
// Mock DOM Element
|
||||||
|
domElement = {
|
||||||
|
getBoundingClientRect: () => ({
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
}),
|
||||||
|
addEventListener: sinon.spy(),
|
||||||
|
removeEventListener: sinon.spy(),
|
||||||
|
};
|
||||||
|
|
||||||
|
inputManager = new InputManager(camera, scene, domElement);
|
||||||
|
|
||||||
|
// Save original navigator.getGamepads to restore later
|
||||||
|
originalGetGamepads = navigator.getGamepads;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
inputManager.detach();
|
||||||
|
// Restore original navigator function
|
||||||
|
navigator.getGamepads = originalGetGamepads;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 1: Should attach event listeners on creation", () => {
|
||||||
|
expect(domElement.addEventListener.calledWith("mousemove")).to.be.true;
|
||||||
|
expect(domElement.addEventListener.calledWith("click")).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 2: Should calculate normalized mouse coordinates", () => {
|
||||||
|
// Simulate mouse event at center (50, 50)
|
||||||
|
const event = { clientX: 50, clientY: 50 };
|
||||||
|
inputManager.updateMouseCoords(event);
|
||||||
|
|
||||||
|
// Center should be (0, 0) in NDC
|
||||||
|
expect(inputManager.mouse.x).to.equal(0);
|
||||||
|
expect(inputManager.mouse.y).to.equal(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 3: Raycast should identify voxel coordinates based on intersection", () => {
|
||||||
|
// Mock Raycaster intersection result
|
||||||
|
// Use (0, 0.5, 0) which is the exact center of the top face of a block at (0,0,0)
|
||||||
|
// 1x1x1 box at 0,0,0 extends from -0.5 to 0.5
|
||||||
|
const mockIntersection = {
|
||||||
|
object: { isInstancedMesh: true },
|
||||||
|
point: new THREE.Vector3(0, 0.5, 0),
|
||||||
|
face: { normal: new THREE.Vector3(0, 1, 0) }, // Up normal
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stub the internal raycaster call
|
||||||
|
sinon
|
||||||
|
.stub(inputManager.raycaster, "intersectObjects")
|
||||||
|
.returns([mockIntersection]);
|
||||||
|
|
||||||
|
const result = inputManager.raycast();
|
||||||
|
|
||||||
|
expect(result).to.exist;
|
||||||
|
|
||||||
|
// Hit Logic: 0.5 + (-0.5 * 1) = 0.
|
||||||
|
// Block hit is at 0,0,0
|
||||||
|
expect(result.hitPosition.x).to.equal(0);
|
||||||
|
expect(result.hitPosition.y).to.equal(0);
|
||||||
|
expect(result.hitPosition.z).to.equal(0);
|
||||||
|
|
||||||
|
// Target Logic: 0 + 1 = 1
|
||||||
|
// Voxel Position (Target) should be 0,1,0 (Above the floor)
|
||||||
|
expect(result.voxelPosition.x).to.equal(0);
|
||||||
|
expect(result.voxelPosition.y).to.equal(1);
|
||||||
|
expect(result.voxelPosition.z).to.equal(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 4: Mouse move should update cursor and emit hover", () => {
|
||||||
|
const hoverSpy = sinon.spy();
|
||||||
|
inputManager.addEventListener("hover", hoverSpy);
|
||||||
|
|
||||||
|
// Stub raycast to return a hit
|
||||||
|
sinon.stub(inputManager, "raycast").returns({
|
||||||
|
voxelPosition: new THREE.Vector3(1, 1, 1),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate Move
|
||||||
|
inputManager.onMouseMove({ clientX: 0, clientY: 0 });
|
||||||
|
|
||||||
|
expect(inputManager.cursor.visible).to.be.true;
|
||||||
|
expect(inputManager.cursor.position.x).to.equal(1);
|
||||||
|
expect(hoverSpy.called).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 5: Mouse click should emit click event with coords", () => {
|
||||||
|
const clickSpy = sinon.spy();
|
||||||
|
inputManager.addEventListener("click", clickSpy);
|
||||||
|
|
||||||
|
// Use the API method to ensure internal state (cursorPos) is synced with Mesh
|
||||||
|
// Directly setting mesh.position might desync the internal tracker used for the event payload
|
||||||
|
inputManager.setCursor(2, 2, 2);
|
||||||
|
|
||||||
|
// Simulate Click
|
||||||
|
inputManager.onMouseClick({});
|
||||||
|
|
||||||
|
expect(clickSpy.calledOnce).to.be.true;
|
||||||
|
const payload = clickSpy.firstCall.args[0].detail; // Access detail
|
||||||
|
expect(payload.x).to.equal(2);
|
||||||
|
expect(payload.y).to.equal(2);
|
||||||
|
expect(payload.z).to.equal(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 6: Keyboard events should be tracked and emitted", () => {
|
||||||
|
const keySpy = sinon.spy();
|
||||||
|
inputManager.addEventListener("keydown", keySpy);
|
||||||
|
|
||||||
|
// Dispatch window event
|
||||||
|
const event = new KeyboardEvent("keydown", { code: "KeyW" });
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(keySpy.called).to.be.true;
|
||||||
|
expect(keySpy.firstCall.args[0].detail).to.equal("KeyW");
|
||||||
|
expect(inputManager.isKeyPressed("KeyW")).to.be.true;
|
||||||
|
|
||||||
|
// Key Up
|
||||||
|
const upEvent = new KeyboardEvent("keyup", { code: "KeyW" });
|
||||||
|
window.dispatchEvent(upEvent);
|
||||||
|
|
||||||
|
expect(inputManager.isKeyPressed("KeyW")).to.be.false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 7: Gamepad button press should emit events", () => {
|
||||||
|
const buttonSpy = sinon.spy();
|
||||||
|
inputManager.addEventListener("gamepadbuttondown", buttonSpy);
|
||||||
|
|
||||||
|
// Mock Gamepad State
|
||||||
|
const mockGamepad = {
|
||||||
|
index: 0,
|
||||||
|
buttons: [{ pressed: true }], // Button 0 pressed
|
||||||
|
axes: [0, 0],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock Navigator directly on the window object (browser environment)
|
||||||
|
// Note: Some browsers make navigator read-only, but usually this works in test runners.
|
||||||
|
// If strict, we might need Object.defineProperty
|
||||||
|
Object.defineProperty(navigator, "getGamepads", {
|
||||||
|
value: () => [mockGamepad],
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger Poll
|
||||||
|
inputManager.update();
|
||||||
|
|
||||||
|
expect(buttonSpy.calledOnce).to.be.true;
|
||||||
|
expect(buttonSpy.firstCall.args[0].detail).to.deep.equal({
|
||||||
|
gamepadIndex: 0,
|
||||||
|
buttonIndex: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 8: Gamepad axis movement should emit events with deadzone", () => {
|
||||||
|
const axisSpy = sinon.spy();
|
||||||
|
inputManager.addEventListener("gamepadaxis", axisSpy);
|
||||||
|
|
||||||
|
// Mock Gamepad State
|
||||||
|
const mockGamepad = {
|
||||||
|
index: 0,
|
||||||
|
buttons: [],
|
||||||
|
axes: [0.5, 0.05], // Axis 0 moved, Axis 1 inside deadzone (0.15)
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(navigator, "getGamepads", {
|
||||||
|
value: () => [mockGamepad],
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
inputManager.update();
|
||||||
|
|
||||||
|
expect(axisSpy.calledOnce).to.be.true; // Only Axis 0 should trigger
|
||||||
|
expect(axisSpy.firstCall.args[0].detail.value).to.equal(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CoA 9: setCursor should prevent movement to invalid locations if validator provided", () => {
|
||||||
|
// Setup a validator that only allows positive X values
|
||||||
|
inputManager.setValidator((x, y, z) => {
|
||||||
|
return x >= 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attempt valid move
|
||||||
|
inputManager.setCursor(5, 1, 5);
|
||||||
|
expect(inputManager.cursor.position.x).to.equal(5);
|
||||||
|
|
||||||
|
// Attempt invalid move (blocked by validator)
|
||||||
|
inputManager.setCursor(-1, 1, 5);
|
||||||
|
// Should stay at previous valid position
|
||||||
|
expect(inputManager.cursor.position.x).to.equal(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue