aether-shards/src/core/InputManager.js

285 lines
8.3 KiB
JavaScript
Raw Normal View History

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