331 lines
10 KiB
JavaScript
331 lines
10 KiB
JavaScript
/**
|
|
* @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<string>} */
|
|
this.keys = new Set();
|
|
/** @type {Map<number, { buttons: boolean[]; axes: number[] }>} */
|
|
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;
|
|
}
|
|
}
|