diff --git a/src/hooks/debug/useSceneMode.ts b/src/hooks/debug/useSceneMode.ts new file mode 100644 index 0000000..629c1cd --- /dev/null +++ b/src/hooks/debug/useSceneMode.ts @@ -0,0 +1,13 @@ +import { useSyncExternalStore } from "react"; +import type { SceneMode } from "@/types/debug"; +import { Debug } from "@/utils/debug/Debug"; + +export function useSceneMode(): SceneMode { + const debug = Debug.getInstance(); + + return useSyncExternalStore( + (listener) => debug.subscribe(listener), + () => debug.getSceneMode(), + () => debug.getSceneMode(), + ); +} diff --git a/src/types/debug.ts b/src/types/debug.ts index 2528812..64e14bf 100644 --- a/src/types/debug.ts +++ b/src/types/debug.ts @@ -1 +1,2 @@ export type CameraMode = "player" | "debug"; +export type SceneMode = "game" | "physics"; diff --git a/src/types/three-addons.d.ts b/src/types/three-addons.d.ts new file mode 100644 index 0000000..ac0eec5 --- /dev/null +++ b/src/types/three-addons.d.ts @@ -0,0 +1,33 @@ +declare module "three/addons/math/Capsule.js" { + import { Vector3 } from "three"; + + export class Capsule { + start: Vector3; + end: Vector3; + radius: number; + + constructor(start?: Vector3, end?: Vector3, radius?: number); + + set(start: Vector3, end: Vector3, radius: number): this; + clone(): Capsule; + copy(capsule: Capsule): this; + getCenter(target: Vector3): Vector3; + translate(v: Vector3): this; + } +} + +declare module "three/addons/math/Octree.js" { + import { Object3D } from "three"; + import { Capsule } from "three/addons/math/Capsule.js"; + + export interface CapsuleIntersectResult { + normal: import("three").Vector3; + depth: number; + } + + export class Octree { + constructor(); + fromGraphNode(group: Object3D): this; + capsuleIntersect(capsule: Capsule): CapsuleIntersectResult | false; + } +} diff --git a/src/utils/debug/Debug.ts b/src/utils/debug/Debug.ts index e172394..ba98a2a 100644 --- a/src/utils/debug/Debug.ts +++ b/src/utils/debug/Debug.ts @@ -1,5 +1,5 @@ import GUI from "lil-gui"; -import type { CameraMode } from "@/types/debug"; +import type { CameraMode, SceneMode } from "@/types/debug"; export class Debug { private static instance: Debug | null = null; @@ -12,9 +12,11 @@ export class Debug { private readonly controls: { cameraMode: CameraMode; showInteractionSpheres: boolean; + sceneMode: SceneMode; } = { cameraMode: "player", showInteractionSpheres: false, + sceneMode: "game", }; static getInstance(): Debug { @@ -42,6 +44,14 @@ export class Debug { this.emit(); }); + folder + .add(this.controls, "sceneMode", { Game: "game", Physics: "physics" }) + .name("Scene") + .onChange((value: SceneMode) => { + this.controls.sceneMode = value; + this.emit(); + }); + folder .add(this.controls, "showInteractionSpheres") .name("Interaction Spheres") @@ -86,6 +96,10 @@ export class Debug { return this.controls.cameraMode; } + getSceneMode(): SceneMode { + return this.controls.sceneMode; + } + getShowInteractionSpheres(): boolean { return this.controls.showInteractionSpheres; } diff --git a/src/world/Map.tsx b/src/world/Map.tsx new file mode 100644 index 0000000..c811b62 --- /dev/null +++ b/src/world/Map.tsx @@ -0,0 +1,64 @@ +import { useEffect, useRef } from "react"; +import { useThree } from "@react-three/fiber"; +import { useGLTF } from "@react-three/drei"; +import * as THREE from "three"; +import { Octree } from "three/addons/math/Octree.js"; +import { Debug } from "@/utils/debug/Debug"; + +const MAP_PATH = "/models/map/model.gltf"; + +interface MapProps { + onOctreeReady: (octree: Octree) => void; +} + +export function Map({ onOctreeReady }: MapProps): React.JSX.Element { + const { scene: gltfScene } = useGLTF(MAP_PATH); + const groupRef = useRef(null); + const octreeBuilt = useRef(false); + const boxHelpersRef = useRef([]); + const { scene } = useThree(); + + useEffect(() => { + if (octreeBuilt.current || !groupRef.current) return; + octreeBuilt.current = true; + + groupRef.current.updateMatrixWorld(true); + + const octree = new Octree(); + octree.fromGraphNode(groupRef.current); + onOctreeReady(octree); + }, [onOctreeReady]); + + // BoxHelper wireframes in debug mode — one per mesh in the model + useEffect(() => { + const debug = Debug.getInstance(); + if (!debug.active || !groupRef.current) return; + + const helpers: THREE.BoxHelper[] = []; + + groupRef.current.traverse((child) => { + if (!(child instanceof THREE.Mesh)) return; + const helper = new THREE.BoxHelper(child, 0x00ff88); + scene.add(helper); + helpers.push(helper); + }); + + boxHelpersRef.current = helpers; + + return () => { + helpers.forEach((h) => { + scene.remove(h); + h.dispose(); + }); + boxHelpersRef.current = []; + }; + }, [scene]); + + return ( + + + + ); +} + +useGLTF.preload(MAP_PATH); diff --git a/src/world/World.tsx b/src/world/World.tsx index 1776aa6..566ffb1 100644 --- a/src/world/World.tsx +++ b/src/world/World.tsx @@ -1,15 +1,20 @@ -import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier"; +import { useState, useCallback } from "react"; +import type { Octree } from "three/addons/math/Octree.js"; import { useCameraMode } from "@/hooks/debug/useCameraMode"; +import { useSceneMode } from "@/hooks/debug/useSceneMode"; import { DebugCameraControls } from "@/utils/debug/scene/DebugCameraControls"; import { DebugHelpers } from "@/utils/debug/scene/DebugHelpers"; import { Environment } from "@/world/Environment"; import { Lighting } from "@/world/Lighting"; -import { GrabCube } from "@/world/objects/GrabCube"; -import { TriggerSphere } from "@/world/objects/TriggerSphere"; +import { Map } from "@/world/Map"; import { PlayerComponent } from "@/world/player/PlayerComponent"; +import { TestScene } from "@/world/debug/TestScene"; export function World(): React.JSX.Element { const cameraMode = useCameraMode(); + const sceneMode = useSceneMode(); + const [octree, setOctree] = useState(null); + const onOctreeReady = useCallback((o: Octree) => setOctree(o), []); return ( <> @@ -17,14 +22,19 @@ export function World(): React.JSX.Element { {cameraMode === "debug" ? : null} - - - - - - - {cameraMode === "debug" ? null : } - + + {sceneMode === "game" ? ( + + ) : ( + + )} + + {cameraMode !== "debug" ? ( + + ) : null} ); } diff --git a/src/world/debug/TestScene.tsx b/src/world/debug/TestScene.tsx new file mode 100644 index 0000000..79de99f --- /dev/null +++ b/src/world/debug/TestScene.tsx @@ -0,0 +1,49 @@ +import { useEffect, useRef } from "react"; +import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier"; +import * as THREE from "three"; +import { Octree } from "three/addons/math/Octree.js"; +import { GrabCube } from "@/world/objects/GrabCube"; +import { TriggerSphere } from "@/world/objects/TriggerSphere"; + +interface TestSceneProps { + onOctreeReady: (octree: Octree) => void; +} + +export function TestScene({ + onOctreeReady, +}: TestSceneProps): React.JSX.Element { + const floorRef = useRef(null); + const octreeBuilt = useRef(false); + + useEffect(() => { + if (octreeBuilt.current || !floorRef.current) return; + octreeBuilt.current = true; + + floorRef.current.updateMatrixWorld(true); + + const octree = new Octree(); + octree.fromGraphNode(floorRef.current); + onOctreeReady(octree); + }, [onOctreeReady]); + + return ( + <> + {/* Invisible floor mesh for Octree player collision */} + + + + + + + + {/* Rapier physics for interactable objects */} + + + + + + + + + ); +} diff --git a/src/world/player/PlayerCamera.tsx b/src/world/player/PlayerCamera.tsx index 324e2da..e22d43c 100644 --- a/src/world/player/PlayerCamera.tsx +++ b/src/world/player/PlayerCamera.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { PointerLockControls } from "@react-three/drei"; export const PLAYER_EYE_HEIGHT = 1.75; +export const PLAYER_CAPSULE_RADIUS = 0.35; export function PlayerCamera(): React.JSX.Element { useEffect(() => { diff --git a/src/world/player/PlayerComponent.tsx b/src/world/player/PlayerComponent.tsx index 9c8440e..28b914b 100644 --- a/src/world/player/PlayerComponent.tsx +++ b/src/world/player/PlayerComponent.tsx @@ -1,19 +1,28 @@ import { useEffect } from "react"; import { useThree } from "@react-three/fiber"; -import { PlayerCamera, PLAYER_EYE_HEIGHT } from "@/world/player/PlayerCamera"; +import type { Octree } from "three/addons/math/Octree.js"; +import { PlayerCamera } from "@/world/player/PlayerCamera"; import { PlayerController } from "@/world/player/PlayerController"; -export function PlayerComponent(): React.JSX.Element { +interface PlayerComponentProps { + octree?: Octree | null; + spawnY?: number; +} + +export function PlayerComponent({ + octree = null, + spawnY = 100, +}: PlayerComponentProps): React.JSX.Element { const camera = useThree((state) => state.camera); useEffect(() => { - camera.position.set(0, PLAYER_EYE_HEIGHT, 0); - }, [camera]); + camera.position.set(0, spawnY, 0); + }, [camera, spawnY]); return ( <> - + ); } diff --git a/src/world/player/PlayerController.tsx b/src/world/player/PlayerController.tsx index 99b1748..e21a797 100644 --- a/src/world/player/PlayerController.tsx +++ b/src/world/player/PlayerController.tsx @@ -1,13 +1,18 @@ import { useEffect, useRef } from "react"; import { useFrame, useThree } from "@react-three/fiber"; import * as THREE from "three"; +import { Capsule } from "three/addons/math/Capsule.js"; +import type { Octree } from "three/addons/math/Octree.js"; import { InteractionManager } from "@/stateManager/InteractionManager"; -import { PLAYER_EYE_HEIGHT } from "@/world/player/PlayerCamera"; +import { + PLAYER_EYE_HEIGHT, + PLAYER_CAPSULE_RADIUS, +} from "@/world/player/PlayerCamera"; -const MOVE_SPEED = 5; -const GRAVITY = -20; -const JUMP_VELOCITY = 7; -const FLOOR_Y = 0; +const WALK_SPEED = 11; +const AIR_CONTROL = 0.35; +const JUMP_SPEED = 9; +const GRAVITY = 30; type Keys = { forward: boolean; @@ -25,15 +30,43 @@ const DEFAULT_KEYS: Keys = { jump: false, }; -export function PlayerController(): null { +interface PlayerControllerProps { + octree: Octree | null; +} + +const _forward = new THREE.Vector3(); +const _right = new THREE.Vector3(); +const _wishDir = new THREE.Vector3(); +const _up = new THREE.Vector3(0, 1, 0); +const _translateVec = new THREE.Vector3(); + +export function PlayerController({ octree }: PlayerControllerProps): null { const camera = useThree((state) => state.camera); const keys = useRef({ ...DEFAULT_KEYS }); - const velocityY = useRef(0); - const isGrounded = useRef(false); - const forward = useRef(new THREE.Vector3()); - const right = useRef(new THREE.Vector3()); - const movement = useRef(new THREE.Vector3()); - const up = useRef(new THREE.Vector3(0, 1, 0)); + const velocity = useRef(new THREE.Vector3()); + const onFloor = useRef(false); + const wantsJump = useRef(false); + + // Capsule: start = feet, end = eyes + const capsule = useRef( + new Capsule( + new THREE.Vector3(0, PLAYER_CAPSULE_RADIUS, 0), + new THREE.Vector3(0, PLAYER_EYE_HEIGHT - PLAYER_CAPSULE_RADIUS, 0), + PLAYER_CAPSULE_RADIUS, + ), + ); + + // Sync capsule to camera spawn position on mount + useEffect(() => { + const spawnY = camera.position.y; + capsule.current.start.set( + 0, + spawnY - PLAYER_EYE_HEIGHT + PLAYER_CAPSULE_RADIUS, + 0, + ); + capsule.current.end.set(0, spawnY, 0); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); useEffect(() => { const interaction = InteractionManager.getInstance(); @@ -53,7 +86,7 @@ export function PlayerController(): null { keys.current.right = true; break; case " ": - keys.current.jump = true; + wantsJump.current = true; break; case "e": if (interaction.getState().focused?.kind === "trigger") { @@ -63,7 +96,6 @@ export function PlayerController(): null { default: return; } - event.preventDefault(); }; @@ -89,13 +121,11 @@ export function PlayerController(): null { default: return; } - event.preventDefault(); }; const handleMouseDown = (event: MouseEvent): void => { if (event.button !== 0) return; - if (interaction.getState().focused?.kind === "grab") { interaction.pressInteract(); } @@ -103,7 +133,6 @@ export function PlayerController(): null { const handleMouseUp = (event: MouseEvent): void => { if (event.button !== 0) return; - if (interaction.getState().holding) { interaction.releaseInteract(); } @@ -124,47 +153,75 @@ export function PlayerController(): null { }, []); useFrame((_, delta) => { - const currentForward = forward.current; - const currentRight = right.current; - const currentMovement = movement.current; + // Clamp delta so physics don't explode on tab focus regain + const dt = Math.min(delta, 0.05); - camera.getWorldDirection(currentForward); - currentForward.setY(0); - - if (currentForward.lengthSq() > 0) { - currentForward.normalize(); - currentRight.crossVectors(currentForward, up.current).normalize(); + // Compute wish direction from camera yaw (XZ only) + camera.getWorldDirection(_forward); + _forward.setY(0); + if (_forward.lengthSq() > 0) { + _forward.normalize(); + _right.crossVectors(_forward, _up).normalize(); } - currentMovement.set(0, 0, 0); + _wishDir.set(0, 0, 0); + if (keys.current.forward) _wishDir.add(_forward); + if (keys.current.backward) _wishDir.sub(_forward); + if (keys.current.left) _wishDir.sub(_right); + if (keys.current.right) _wishDir.add(_right); + if (_wishDir.lengthSq() > 0) _wishDir.normalize(); - if (keys.current.forward) currentMovement.add(currentForward); - if (keys.current.backward) currentMovement.sub(currentForward); - if (keys.current.left) currentMovement.sub(currentRight); - if (keys.current.right) currentMovement.add(currentRight); + // Accelerate horizontally + const accel = onFloor.current ? WALK_SPEED : WALK_SPEED * AIR_CONTROL; + velocity.current.x += _wishDir.x * accel * dt * 9; + velocity.current.z += _wishDir.z * accel * dt * 9; - if (currentMovement.lengthSq() > 0) { - currentMovement.normalize().multiplyScalar(MOVE_SPEED * delta); - camera.position.add(currentMovement); + // Exponential damping on XZ + const damping = Math.exp(-8 * dt); + velocity.current.x *= damping; + velocity.current.z *= damping; + + // Gravity + jump + if (onFloor.current) { + velocity.current.y = Math.max(0, velocity.current.y); + if (wantsJump.current) { + velocity.current.y = JUMP_SPEED; + onFloor.current = false; + } + } else { + velocity.current.y -= GRAVITY * dt; + } + wantsJump.current = false; + + // Move capsule + _translateVec.copy(velocity.current).multiplyScalar(dt); + capsule.current.translate(_translateVec); + + // Resolve collisions against octree + if (octree) { + const result = octree.capsuleIntersect(capsule.current); + onFloor.current = false; + + if (result) { + onFloor.current = result.normal.y > 0; + + if (!onFloor.current) { + // Cancel velocity component going into the wall + const vn = result.normal.dot(velocity.current); + velocity.current.addScaledVector(result.normal, -vn); + } else { + velocity.current.y = Math.max(0, velocity.current.y); + } + + // Push capsule out of geometry + capsule.current.translate( + result.normal.clone().multiplyScalar(result.depth), + ); + } } - const groundY = FLOOR_Y + PLAYER_EYE_HEIGHT; - isGrounded.current = camera.position.y <= groundY + 0.01; - - if (keys.current.jump && isGrounded.current) { - velocityY.current = JUMP_VELOCITY; - keys.current.jump = false; - } - - if (!isGrounded.current) { - velocityY.current += GRAVITY * delta; - } else if (velocityY.current < 0) { - velocityY.current = 0; - } - - camera.position.setY( - Math.max(groundY, camera.position.y + velocityY.current * delta), - ); + // Sync camera to capsule top (eye position) + camera.position.copy(capsule.current.end); }); return null;