diff --git a/src/core/GameLoop.js b/src/core/GameLoop.js index b34c45f..9413b6c 100644 --- a/src/core/GameLoop.js +++ b/src/core/GameLoop.js @@ -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(); } } diff --git a/src/core/InputManager.js b/src/core/InputManager.js new file mode 100644 index 0000000..f82ed78 --- /dev/null +++ b/src/core/InputManager.js @@ -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; + } +} diff --git a/test/core/InputManager.test.js b/test/core/InputManager.test.js new file mode 100644 index 0000000..ceec99d --- /dev/null +++ b/test/core/InputManager.test.js @@ -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); + }); +});