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