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:
Matthew Mone 2025-12-19 20:58:16 -08:00
parent 2d72fb9170
commit 0faef9d178
3 changed files with 691 additions and 94 deletions

View file

@ -5,29 +5,33 @@ import { VoxelManager } from "../grid/VoxelManager.js";
import { UnitManager } from "../managers/UnitManager.js";
import { CaveGenerator } from "../generation/CaveGenerator.js";
import { RuinGenerator } from "../generation/RuinGenerator.js";
import { InputManager } from "./InputManager.js";
export class GameLoop {
constructor() {
this.isRunning = false;
this.phase = "INIT"; // INIT, DEPLOYMENT, ACTIVE, RESOLUTION
this.phase = "INIT";
// 1. Core Systems
this.scene = new THREE.Scene();
this.camera = null;
this.renderer = null;
this.controls = null;
this.inputManager = null;
this.grid = null;
this.voxelManager = null;
this.unitManager = null;
// Store visual meshes for units [unitId -> THREE.Mesh]
this.unitMeshes = new Map();
// 2. State
this.runData = null;
this.playerSpawnZone = [];
this.enemySpawnZone = [];
// Input Logic State
this.lastMoveTime = 0;
this.moveCooldown = 120; // ms between cursor moves
this.selectionMode = "MOVEMENT"; // MOVEMENT, TARGETING
}
init(container) {
@ -43,26 +47,37 @@ export class GameLoop {
this.renderer = new THREE.WebGLRenderer({ antialias: true });
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.setClearColor(0x111111); // Dark background
this.renderer.setClearColor(0x111111);
container.appendChild(this.renderer.domElement);
// Setup OrbitControls
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
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 dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
dirLight.position.set(10, 20, 10);
this.scene.add(ambient);
this.scene.add(dirLight);
// Handle Resize
window.addEventListener("resize", () => {
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
@ -73,52 +88,138 @@ export class GameLoop {
}
/**
* Starts a Level based on Run Data (New or Loaded).
* Generates the map but does NOT spawn units immediately.
* Validation Logic for Standard Movement.
* 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) {
console.log("GameLoop: Generating Level...");
this.runData = runData;
this.isRunning = true;
this.phase = "DEPLOYMENT";
// Cleanup previous level
this.clearUnitMeshes();
// 1. Initialize Grid (20x10x20 for prototype)
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);
generator.generate();
// 3. Extract Spawn Zones
if (generator.generatedAssets.spawnZones) {
this.playerSpawnZone = generator.generatedAssets.spawnZones.player || [];
this.enemySpawnZone = generator.generatedAssets.spawnZones.enemy || [];
}
// Safety Fallback if generator provided no zones
if (this.playerSpawnZone.length === 0) {
console.warn("No Player Spawn Zone generated. Using default.");
if (this.playerSpawnZone.length === 0)
this.playerSpawnZone.push({ x: 2, y: 1, z: 2 });
}
if (this.enemySpawnZone.length === 0) {
console.warn("No Enemy Spawn Zone generated. Using default.");
if (this.enemySpawnZone.length === 0)
this.enemySpawnZone.push({ x: 18, y: 1, z: 18 });
}
// 4. Initialize Visuals
this.voxelManager = new VoxelManager(this.grid, this.scene);
this.voxelManager.updateMaterials(generator.generatedAssets);
this.voxelManager.update();
if (this.controls) {
this.voxelManager.focusCamera(this.controls);
}
if (this.controls) this.voxelManager.focusCamera(this.controls);
// 5. Initialize Unit Manager (Empty)
const mockRegistry = {
get: (id) => {
if (id.startsWith("CLASS_"))
@ -132,83 +233,59 @@ export class GameLoop {
},
};
this.unitManager = new UnitManager(mockRegistry);
// 6. Highlight Spawn Zones (Visual Debug)
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();
}
/**
* Called by UI to place a unit during Deployment Phase.
*/
deployUnit(unitDef, targetTile) {
if (this.phase !== "DEPLOYMENT") {
console.warn("Cannot deploy unit outside Deployment phase.");
return null;
}
if (this.phase !== "DEPLOYMENT") return null;
// Validate Tile
const isValid = this.playerSpawnZone.some(
(t) => t.x === targetTile.x && t.z === targetTile.z
// Re-validate using the zone logic (Double check)
const isValid = this.validateDeploymentCursor(
targetTile.x,
targetTile.y,
targetTile.z
);
if (!isValid) {
console.warn("Invalid spawn location.");
return null;
}
if (this.grid.isOccupied(targetTile)) {
console.warn("Tile occupied.");
return null;
}
// Create and Place
if (!isValid || this.grid.isOccupied(targetTile)) return null;
const unit = this.unitManager.createUnit(
unitDef.classId || unitDef.id,
"PLAYER"
);
if (unitDef.name) unit.name = unitDef.name;
this.grid.placeUnit(unit, targetTile);
this.createUnitMesh(unit, targetTile);
console.log(
`Deployed ${unit.name} at ${targetTile.x},${targetTile.y},${targetTile.z}`
);
return unit;
}
/**
* Called when player clicks "Start Battle".
* Spawns enemies and switches phase.
*/
finalizeDeployment() {
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;
for (let i = 0; i < enemyCount; i++) {
// Pick a random spot in enemy zone
const spotIndex = Math.floor(Math.random() * this.enemySpawnZone.length);
const spot = this.enemySpawnZone[spotIndex];
// Ensure spot is valid/empty
if (spot && !this.grid.isOccupied(spot)) {
const enemy = this.unitManager.createUnit("ENEMY_DEFAULT", "ENEMY");
this.grid.placeUnit(enemy, spot);
this.createUnitMesh(enemy, spot);
// Remove spot from pool to avoid double spawn
this.enemySpawnZone.splice(spotIndex, 1);
}
}
this.phase = "ACTIVE";
// TODO: Start Turn System here
// Switch to standard movement validator for the game
this.inputManager.setValidator(this.validateCursorMove.bind(this));
}
clearUnitMeshes() {
@ -219,26 +296,16 @@ export class GameLoop {
createUnitMesh(unit, pos) {
const geometry = new THREE.BoxGeometry(0.6, 1.2, 0.6);
let color = 0xcccccc;
if (unit.id.includes("VANGUARD")) color = 0xff3333;
else if (unit.id.includes("WEAVER")) color = 0x3333ff;
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
else if (unit.team === "ENEMY") color = 0x550000;
const material = new THREE.MeshStandardMaterial({ color: color });
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(pos.x, pos.y + 0.6, pos.z);
this.scene.add(mesh);
this.unitMeshes.set(unit.id, mesh);
}
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({
color: 0x00ff00,
transparent: true,
@ -251,14 +318,11 @@ export class GameLoop {
});
const geo = new THREE.PlaneGeometry(1, 1);
geo.rotateX(-Math.PI / 2);
this.playerSpawnZone.forEach((pos) => {
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);
// Note: Should track these to clean up later
});
this.enemySpawnZone.forEach((pos) => {
const mesh = new THREE.Mesh(geo, highlightMatEnemy);
mesh.position.set(pos.x, pos.y + 0.05, pos.z);
@ -270,10 +334,50 @@ export class GameLoop {
if (!this.isRunning) return;
requestAnimationFrame(this.animate);
if (this.controls) {
this.controls.update();
// 1. Update Managers
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;
this.unitMeshes.forEach((mesh) => {
mesh.position.y += Math.sin(time) * 0.002;
@ -284,6 +388,7 @@ export class GameLoop {
stop() {
this.isRunning = false;
if (this.inputManager) this.inputManager.detach();
if (this.controls) this.controls.dispose();
}
}

284
src/core/InputManager.js Normal file
View 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;
}
}

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