/** * @typedef {import("../grid/types.js").Position} Position */ import * as THREE from "three"; /** * InputManager.js * Handles mouse interaction, raycasting, grid selection, keyboard, and Gamepad input. * Extends EventTarget for standard event handling. * @class */ export class InputManager extends EventTarget { /** * @param {THREE.Camera} camera - Three.js camera * @param {THREE.Scene} scene - Three.js scene * @param {HTMLElement} domElement - DOM element for input */ constructor(camera, scene, domElement) { super(); /** @type {THREE.Camera} */ this.camera = camera; /** @type {THREE.Scene} */ this.scene = scene; /** @type {HTMLElement} */ this.domElement = domElement; /** @type {THREE.Raycaster} */ this.raycaster = new THREE.Raycaster(); /** @type {THREE.Vector2} */ this.mouse = new THREE.Vector2(); // Input State /** @type {Set} */ this.keys = new Set(); /** @type {Map} */ this.gamepads = new Map(); /** @type {number} */ this.axisDeadzone = 0.2; // Increased slightly for stability // Cursor State /** @type {THREE.Vector3} */ this.cursorPos = new THREE.Vector3(0, 0, 0); /** @type {((x: number, y: number, z: number) => false | Position) | null} */ 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); // Re-add cursor to scene if missing and scene is available if (this.cursor && this.scene && !this.cursor.parent) { this.scene.add(this.cursor); } } 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 {(x: number, y: number, z: number) => false | Position} validatorFn - Takes (x, y, z). Returns false if invalid, or a modified position 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. * @param {number} x - X coordinate * @param {number} y - Y coordinate * @param {number} z - Z coordinate */ 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 }, }) ); } /** * Gets the current cursor position. * @returns {THREE.Vector3} - Current cursor position */ 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 }) ); } /** * Updates gamepad state. Should be called every frame. */ 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 })); } /** * Checks if a key is currently pressed. * @param {string} code - Key code * @returns {boolean} - True if key is pressed */ 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. * @param {THREE.Vector3} point - World space point * @param {THREE.Vector3} normal - Surface normal * @returns {{ hitPosition: Position; voxelPosition: Position; normal: THREE.Vector3 }} - Resolved cursor data */ 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, }; } /** * Performs raycast from camera through mouse position. * @returns {null | { hitPosition: Position; voxelPosition: Position; normal: THREE.Vector3 }} - Hit data or null */ 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; } }