update: add a physic scenne

This commit is contained in:
2026-04-17 10:48:18 +02:00
parent b26da614f0
commit ed7681a293
23 changed files with 2052 additions and 218 deletions
+1437 -133
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -37,6 +37,7 @@
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0", "globals": "^17.4.0",
"lil-gui": "^0.21.0", "lil-gui": "^0.21.0",
"madge": "^8.0.0",
"prettier": "^3.8.2", "prettier": "^3.8.2",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"typescript-eslint": "^8.58.0", "typescript-eslint": "^8.58.0",
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:7f11632d7daa81f186cf7e9a79631c0aac929c7a1e68ee10676e83837436652f oid sha256:b3535a67501bb43ccf233a25e98b20b3804e29f1fe7ef8ba821bbdd00b98f140
size 3220979 size 3279070
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5f714331f0f9ad760ae59d1c7cd4a6eb10b853c018f9764564e306b2f2444e56
size 149972
+2
View File
@@ -1,5 +1,6 @@
import { Canvas } from "@react-three/fiber"; import { Canvas } from "@react-three/fiber";
import { Crosshair } from "@/components/ui/Crosshair"; import { Crosshair } from "@/components/ui/Crosshair";
import { InteractPrompt } from "@/components/ui/InteractPrompt";
import { DebugPerf } from "@/utils/debug/DebugPerf"; import { DebugPerf } from "@/utils/debug/DebugPerf";
import { World } from "@/world/World"; import { World } from "@/world/World";
@@ -11,6 +12,7 @@ function App(): React.JSX.Element {
<DebugPerf /> <DebugPerf />
</Canvas> </Canvas>
<Crosshair /> <Crosshair />
<InteractPrompt />
</> </>
); );
} }
+133
View File
@@ -0,0 +1,133 @@
import { useEffect, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { RigidBody } from "@react-three/rapier";
import type { RapierRigidBody } from "@react-three/rapier";
import * as THREE from "three";
import type { RefObject } from "react";
import { Debug } from "@/utils/debug/Debug";
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
import {
InteractionManager,
type InteractableHandle,
type InteractableKind,
} from "@/stateManager/InteractionManager";
import { INTERACTION_RADIUS } from "@/data/interactionConfig";
interface InteractableObjectProps {
kind: InteractableKind;
label: string;
position: [number, number, number];
rigidBodyType?: "dynamic" | "fixed";
colliders?: "cuboid" | "ball" | "hull";
rbRef?: RefObject<RapierRigidBody | null>;
onPress: () => void;
onRelease?: () => void;
children: React.ReactNode;
}
const _cameraPos = new THREE.Vector3();
const _cameraDir = new THREE.Vector3();
const _objectPos = new THREE.Vector3();
const _raycaster = new THREE.Raycaster();
export function InteractableObject({
kind,
label,
position,
rigidBodyType = "dynamic",
colliders = "cuboid",
rbRef,
onPress,
onRelease = () => {},
children,
}: InteractableObjectProps): React.JSX.Element {
const camera = useThree((state) => state.camera);
const internalRef = useRef<RapierRigidBody>(null);
const bodyRef = rbRef ?? internalRef;
const groupRef = useRef<THREE.Group>(null);
const debugSphereRef = useRef<THREE.Mesh>(null);
const handle = useRef<InteractableHandle>({
kind,
label,
onPress,
onRelease,
});
useEffect(() => {
handle.current.onPress = onPress;
handle.current.onRelease = onRelease;
});
useDebugFolder("Interaction", (folder) => {
folder
.add({ radius: INTERACTION_RADIUS }, "radius")
.name("Interaction radius")
.disable();
});
useFrame(() => {
const body = bodyRef.current;
const group = groupRef.current;
const debug = Debug.getInstance();
const manager = InteractionManager.getInstance();
if (debugSphereRef.current) {
debugSphereRef.current.visible =
debug.active && debug.getShowInteractionSpheres();
}
if (body) {
const t = body.translation();
_objectPos.set(t.x, t.y, t.z);
} else {
_objectPos.set(...position);
}
camera.getWorldPosition(_cameraPos);
const dist = _cameraPos.distanceTo(_objectPos);
if (dist > INTERACTION_RADIUS) {
if (manager.getState().focused === handle.current) {
manager.setFocused(null);
}
return;
}
camera.getWorldDirection(_cameraDir);
_raycaster.set(_cameraPos, _cameraDir);
_raycaster.far = INTERACTION_RADIUS;
const hits = group ? _raycaster.intersectObject(group, true) : [];
const validHit = hits.find((h) => h.object !== debugSphereRef.current);
if (validHit) {
manager.setFocused(handle.current);
} else if (manager.getState().focused === handle.current) {
manager.setFocused(null);
}
});
return (
<RigidBody
ref={bodyRef}
type={rigidBodyType}
colliders={colliders}
position={position}
>
<group ref={groupRef}>
{children}
<mesh ref={debugSphereRef} visible={false}>
<sphereGeometry args={[INTERACTION_RADIUS, 16, 16]} />
<meshBasicMaterial
color="#facc15"
wireframe
transparent
opacity={0.25}
/>
</mesh>
</group>
</RigidBody>
);
}
+9 -4
View File
@@ -1,11 +1,16 @@
import { useCameraMode } from "@/hooks/debug/useCameraMode"; import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useInteraction } from "@/hooks/useInteraction";
export function Crosshair(): React.JSX.Element | null { export function Crosshair(): React.JSX.Element | null {
const cameraMode = useCameraMode(); const cameraMode = useCameraMode();
const { focused } = useInteraction();
if (cameraMode !== "player") { if (cameraMode !== "player") return null;
return null;
}
return <div className="crosshair" aria-hidden="true" />; return (
<div
className={focused ? "crosshair crosshair--interact" : "crosshair"}
aria-hidden="true"
/>
);
} }
+17
View File
@@ -0,0 +1,17 @@
import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useInteraction } from "@/hooks/useInteraction";
export function InteractPrompt(): React.JSX.Element | null {
const cameraMode = useCameraMode();
const { focused, holding } = useInteraction();
if (cameraMode !== "player") return null;
if (!focused || holding || focused.kind !== "trigger") return null;
return (
<div className="interact-prompt" aria-live="polite">
<kbd className="interact-prompt__key">E</kbd>
<span className="interact-prompt__label">{focused.label}</span>
</div>
);
}
+1
View File
@@ -0,0 +1 @@
export const INTERACTION_RADIUS = 3;
+17
View File
@@ -0,0 +1,17 @@
import { useEffect } from "react";
import type GUI from "lil-gui";
import { Debug } from "@/utils/debug/Debug";
export function useDebugFolder(
name: string,
setup: (folder: GUI) => void,
): void {
useEffect(() => {
const debug = Debug.getInstance();
if (!debug.active) return;
const folder = debug.createFolder(name);
if (!folder) return;
setup(folder);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}
+18
View File
@@ -0,0 +1,18 @@
import { useEffect, useState } from "react";
import {
InteractionManager,
type InteractionSnapshot,
} from "@/stateManager/InteractionManager";
export function useInteraction(): InteractionSnapshot {
const manager = InteractionManager.getInstance();
const [state, setState] = useState<InteractionSnapshot>(manager.getState());
useEffect(() => {
return manager.subscribe(() => {
setState({ ...manager.getState() });
});
}, [manager]);
return state;
}
+43 -4
View File
@@ -31,12 +31,51 @@ canvas {
position: fixed; position: fixed;
top: 50%; top: 50%;
left: 50%; left: 50%;
width: 12px; width: 6px;
height: 12px; height: 6px;
border: 2px solid rgba(255, 255, 255, 0.92); background: rgba(255, 255, 255, 0.92);
border-radius: 999px; border-radius: 999px;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
box-sizing: border-box;
pointer-events: none; pointer-events: none;
z-index: 10; z-index: 10;
} }
.crosshair--interact {
width: 12px;
height: 12px;
background: transparent;
border: 2px solid rgba(255, 255, 255, 0.92);
}
.interact-prompt {
position: fixed;
bottom: 30%;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 8px;
pointer-events: none;
z-index: 10;
}
.interact-prompt__key {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: 4px;
font-size: 13px;
font-weight: 600;
color: white;
font-style: normal;
}
.interact-prompt__label {
font-size: 13px;
color: rgba(255, 255, 255, 0.85);
letter-spacing: 0.03em;
}
+23
View File
@@ -0,0 +1,23 @@
export class AudioManager {
private static _instance: AudioManager | null = null;
static getInstance(): AudioManager {
if (!AudioManager._instance) {
AudioManager._instance = new AudioManager();
}
return AudioManager._instance;
}
private constructor() {}
playSound(path: string, volume = 1): void {
const audio = new Audio(path);
audio.volume = Math.max(0, Math.min(1, volume));
void audio.play();
}
destroy(): void {
AudioManager._instance = null;
}
}
+87
View File
@@ -0,0 +1,87 @@
export type InteractableKind = "grab" | "trigger";
export interface InteractableHandle {
kind: InteractableKind;
label: string;
onPress: () => void;
onRelease: () => void;
}
export interface InteractionSnapshot {
focused: InteractableHandle | null;
holding: boolean;
}
export class InteractionManager {
private static _instance: InteractionManager | null = null;
private _focused: InteractableHandle | null = null;
private _holding = false;
private _holdingHandle: InteractableHandle | null = null;
private readonly _listeners = new Set<() => void>();
static getInstance(): InteractionManager {
if (!InteractionManager._instance) {
InteractionManager._instance = new InteractionManager();
}
return InteractionManager._instance;
}
private constructor() {}
getState(): InteractionSnapshot {
return {
focused: this._focused,
holding: this._holding,
};
}
setFocused(handle: InteractableHandle | null): void {
if (this._focused === handle) return;
// Never interrupt an active grab via focus change
if (this._holding) {
this._focused = handle;
this._emit();
return;
}
this._focused = handle;
this._emit();
}
pressInteract(): void {
if (!this._focused) return;
this._holding = this._focused.kind === "grab";
if (this._holding) this._holdingHandle = this._focused;
this._focused.onPress();
this._emit();
}
releaseInteract(): void {
const handle = this._holdingHandle ?? this._focused;
if (!handle) return;
handle.onRelease();
this._holding = false;
this._holdingHandle = null;
this._emit();
}
subscribe(listener: () => void): () => void {
this._listeners.add(listener);
return () => {
this._listeners.delete(listener);
};
}
destroy(): void {
this._listeners.clear();
InteractionManager._instance = null;
}
private _emit(): void {
this._listeners.forEach((cb) => cb());
}
}
+33 -13
View File
@@ -1,18 +1,36 @@
type Listener<TPayload> = (payload: TPayload) => void; type Listener<TPayload> = (payload: TPayload) => void;
// TypeScript cannot narrow mapped-type indexed access by a generic key TKey
// (microsoft/TypeScript#30581). The helper below encapsulates the one necessary
// cast so the rest of the class stays cast-free.
type ListenerMap<TEvents extends Record<string, unknown>> = {
[TKey in keyof TEvents]?: Set<Listener<TEvents[TKey]>>;
};
function getListeners<
TEvents extends Record<string, unknown>,
TKey extends keyof TEvents,
>(
map: ListenerMap<TEvents>,
key: TKey,
): Set<Listener<TEvents[TKey]>> | undefined {
return map[key] as Set<Listener<TEvents[TKey]>> | undefined;
}
export class EventEmitter<TEvents extends Record<string, unknown>> { export class EventEmitter<TEvents extends Record<string, unknown>> {
private readonly listeners = new Map< private readonly listeners: ListenerMap<TEvents> = {};
keyof TEvents,
Set<Listener<TEvents[keyof TEvents]>>
>();
on<TKey extends keyof TEvents>( on<TKey extends keyof TEvents>(
event: TKey, event: TKey,
listener: Listener<TEvents[TKey]>, listener: Listener<TEvents[TKey]>,
): () => void { ): () => void {
const currentListeners = this.listeners.get(event) ?? new Set(); const existing = getListeners(this.listeners, event);
currentListeners.add(listener as Listener<TEvents[keyof TEvents]>);
this.listeners.set(event, currentListeners); if (existing) {
existing.add(listener);
} else {
this.listeners[event] = new Set([listener]) as ListenerMap<TEvents>[TKey];
}
return () => { return () => {
this.off(event, listener); this.off(event, listener);
@@ -23,32 +41,34 @@ export class EventEmitter<TEvents extends Record<string, unknown>> {
event: TKey, event: TKey,
listener: Listener<TEvents[TKey]>, listener: Listener<TEvents[TKey]>,
): void { ): void {
const currentListeners = this.listeners.get(event); const currentListeners = getListeners(this.listeners, event);
if (!currentListeners) { if (!currentListeners) {
return; return;
} }
currentListeners.delete(listener as Listener<TEvents[keyof TEvents]>); currentListeners.delete(listener);
if (currentListeners.size === 0) { if (currentListeners.size === 0) {
this.listeners.delete(event); delete this.listeners[event];
} }
} }
emit<TKey extends keyof TEvents>(event: TKey, payload: TEvents[TKey]): void { emit<TKey extends keyof TEvents>(event: TKey, payload: TEvents[TKey]): void {
const currentListeners = this.listeners.get(event); const currentListeners = getListeners(this.listeners, event);
if (!currentListeners) { if (!currentListeners) {
return; return;
} }
currentListeners.forEach((listener) => { currentListeners.forEach((listener) => {
listener(payload as TEvents[keyof TEvents]); listener(payload);
}); });
} }
clear(): void { clear(): void {
this.listeners.clear(); for (const key of Object.keys(this.listeners) as (keyof TEvents)[]) {
delete this.listeners[key];
}
} }
} }
+22 -14
View File
@@ -9,8 +9,12 @@ export class Debug {
private readonly folders = new Map<string, GUI>(); private readonly folders = new Map<string, GUI>();
private readonly registeredFolders = new Set<string>(); private readonly registeredFolders = new Set<string>();
private readonly listeners = new Set<() => void>(); private readonly listeners = new Set<() => void>();
private readonly controls: { cameraMode: CameraMode } = { private readonly controls: {
cameraMode: CameraMode;
showInteractionSpheres: boolean;
} = {
cameraMode: "player", cameraMode: "player",
showInteractionSpheres: false,
}; };
static getInstance(): Debug { static getInstance(): Debug {
@@ -28,9 +32,7 @@ export class Debug {
if (this.gui) { if (this.gui) {
const folder = this.createFolder("Debug"); const folder = this.createFolder("Debug");
if (!folder) { if (!folder) return;
return;
}
folder folder
.add(this.controls, "cameraMode", { Player: "player", Debug: "debug" }) .add(this.controls, "cameraMode", { Player: "player", Debug: "debug" })
@@ -39,6 +41,14 @@ export class Debug {
this.controls.cameraMode = value; this.controls.cameraMode = value;
this.emit(); this.emit();
}); });
folder
.add(this.controls, "showInteractionSpheres")
.name("Interaction Spheres")
.onChange((value: boolean) => {
this.controls.showInteractionSpheres = value;
this.emit();
});
} }
} }
@@ -48,21 +58,15 @@ export class Debug {
* null is returned to avoid duplicating controls under StrictMode double-mount. * null is returned to avoid duplicating controls under StrictMode double-mount.
*/ */
createFolder(name: string): GUI | null { createFolder(name: string): GUI | null {
if (!this.gui) { if (!this.gui) return null;
return null;
}
if (this.registeredFolders.has(name)) { if (this.registeredFolders.has(name)) return null;
return null;
}
this.registeredFolders.add(name); this.registeredFolders.add(name);
const existingFolder = this.folders.get(name); const existing = this.folders.get(name);
if (existingFolder) { if (existing) return existing;
return existingFolder;
}
const folder = this.gui.addFolder(name); const folder = this.gui.addFolder(name);
this.folders.set(name, folder); this.folders.set(name, folder);
@@ -82,6 +86,10 @@ export class Debug {
return this.controls.cameraMode; return this.controls.cameraMode;
} }
getShowInteractionSpheres(): boolean {
return this.controls.showInteractionSpheres;
}
private emit(): void { private emit(): void {
this.listeners.forEach((listener) => listener()); this.listeners.forEach((listener) => listener());
} }
+4 -17
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef } from "react"; import { useRef } from "react";
import { useFrame } from "@react-three/fiber"; import { useFrame } from "@react-three/fiber";
import type { AmbientLight, DirectionalLight } from "three"; import type { AmbientLight, DirectionalLight } from "three";
import { Debug } from "@/utils/debug/Debug"; import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
type LightingState = { type LightingState = {
ambientIntensity: number; ambientIntensity: number;
@@ -23,26 +23,13 @@ export function Lighting(): React.JSX.Element {
const ambient = useRef<AmbientLight>(null); const ambient = useRef<AmbientLight>(null);
const sun = useRef<DirectionalLight>(null); const sun = useRef<DirectionalLight>(null);
useEffect(() => { useDebugFolder("Lighting", (folder) => {
const debug = Debug.getInstance();
if (!debug.active) {
return;
}
const folder = debug.createFolder("Lighting");
// null = already registered (StrictMode double-mount), skip adding controls
if (!folder) {
return;
}
folder.add(LIGHTING_STATE, "ambientIntensity", 0, 5, 0.1).name("Ambient"); folder.add(LIGHTING_STATE, "ambientIntensity", 0, 5, 0.1).name("Ambient");
folder.add(LIGHTING_STATE, "sunIntensity", 0, 8, 0.1).name("Sun Intensity"); folder.add(LIGHTING_STATE, "sunIntensity", 0, 8, 0.1).name("Sun Intensity");
folder.add(LIGHTING_STATE, "sunX", -100, 100, 1).name("Sun X"); folder.add(LIGHTING_STATE, "sunX", -100, 100, 1).name("Sun X");
folder.add(LIGHTING_STATE, "sunY", 0, 150, 1).name("Sun Y"); folder.add(LIGHTING_STATE, "sunY", 0, 150, 1).name("Sun Y");
folder.add(LIGHTING_STATE, "sunZ", -100, 100, 1).name("Sun Z"); folder.add(LIGHTING_STATE, "sunZ", -100, 100, 1).name("Sun Z");
}, []); });
useFrame(() => { useFrame(() => {
if (ambient.current) { if (ambient.current) {
+10
View File
@@ -1,8 +1,11 @@
import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier";
import { useCameraMode } from "@/hooks/debug/useCameraMode"; import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { DebugCameraControls } from "@/utils/debug/scene/DebugCameraControls"; import { DebugCameraControls } from "@/utils/debug/scene/DebugCameraControls";
import { DebugHelpers } from "@/utils/debug/scene/DebugHelpers"; import { DebugHelpers } from "@/utils/debug/scene/DebugHelpers";
import { Environment } from "@/world/Environment"; import { Environment } from "@/world/Environment";
import { Lighting } from "@/world/Lighting"; import { Lighting } from "@/world/Lighting";
import { GrabCube } from "@/world/objects/GrabCube";
import { TriggerSphere } from "@/world/objects/TriggerSphere";
import { PlayerComponent } from "@/world/player/PlayerComponent"; import { PlayerComponent } from "@/world/player/PlayerComponent";
export function World(): React.JSX.Element { export function World(): React.JSX.Element {
@@ -14,7 +17,14 @@ export function World(): React.JSX.Element {
<Lighting /> <Lighting />
<DebugHelpers /> <DebugHelpers />
{cameraMode === "debug" ? <DebugCameraControls /> : null} {cameraMode === "debug" ? <DebugCameraControls /> : null}
<Physics>
<RigidBody type="fixed">
<CuboidCollider args={[50, 0.1, 50]} position={[0, -0.1, 0]} />
</RigidBody>
<GrabCube />
<TriggerSphere />
{cameraMode === "debug" ? null : <PlayerComponent />} {cameraMode === "debug" ? null : <PlayerComponent />}
</Physics>
</> </>
); );
} }
+80
View File
@@ -0,0 +1,80 @@
import { useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import type { RapierRigidBody } from "@react-three/rapier";
import * as THREE from "three";
import { InteractableObject } from "@/components/3d/InteractableObject";
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
const CUBE_SIZE = 0.5;
const HOLD_DISTANCE = 2;
const SPAWN_POSITION: [number, number, number] = [0, 1, -3];
const params = { stiffness: 15, throwBoost: 1.0 };
const _holdTarget = new THREE.Vector3();
const _currentPos = new THREE.Vector3();
const _velocity = new THREE.Vector3();
export function GrabCube(): React.JSX.Element {
const camera = useThree((state) => state.camera);
const rbRef = useRef<RapierRigidBody>(null);
const isHolding = useRef(false);
useDebugFolder("GrabCube", (folder) => {
folder.add(params, "stiffness", 1, 50, 1).name("Hold stiffness");
folder.add(params, "throwBoost", 0.5, 3.0, 0.1).name("Throw boost");
});
useFrame(() => {
if (!isHolding.current || !rbRef.current) return;
camera.getWorldDirection(_holdTarget);
_holdTarget.multiplyScalar(HOLD_DISTANCE).add(camera.position);
const t = rbRef.current.translation();
_currentPos.set(t.x, t.y, t.z);
_velocity
.subVectors(_holdTarget, _currentPos)
.multiplyScalar(params.stiffness);
rbRef.current.setLinvel(
{ x: _velocity.x, y: _velocity.y, z: _velocity.z },
true,
);
rbRef.current.setAngvel({ x: 0, y: 0, z: 0 }, true);
});
return (
<InteractableObject
kind="grab"
label="Prendre"
position={SPAWN_POSITION}
rigidBodyType="dynamic"
colliders="cuboid"
rbRef={rbRef}
onPress={() => {
isHolding.current = true;
}}
onRelease={() => {
isHolding.current = false;
if (rbRef.current && params.throwBoost !== 1.0) {
const v = rbRef.current.linvel();
rbRef.current.setLinvel(
{
x: v.x * params.throwBoost,
y: v.y * params.throwBoost,
z: v.z * params.throwBoost,
},
true,
);
}
}}
>
<mesh castShadow receiveShadow>
<boxGeometry args={[CUBE_SIZE, CUBE_SIZE, CUBE_SIZE]} />
<meshStandardMaterial color="#e07b39" roughness={0.6} metalness={0.1} />
</mesh>
</InteractableObject>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { AudioManager } from "@/stateManager/AudioManager";
import { InteractableObject } from "@/components/3d/InteractableObject";
const SPHERE_RADIUS = 0.4;
const SPAWN_POSITION: [number, number, number] = [3, 2, -3];
const SOUND_PATH = "/sounds/fa.mp3";
interface TriggerSphereProps {
soundPath?: string;
}
export function TriggerSphere({
soundPath = SOUND_PATH,
}: TriggerSphereProps): React.JSX.Element {
return (
<InteractableObject
kind="trigger"
label="Interagir"
position={SPAWN_POSITION}
rigidBodyType="fixed"
colliders="ball"
onPress={() => {
AudioManager.getInstance().playSound(soundPath);
}}
>
<mesh castShadow receiveShadow>
<sphereGeometry args={[SPHERE_RADIUS, 32, 32]} />
<meshStandardMaterial color="#3b82f6" roughness={0.3} metalness={0.5} />
</mesh>
</InteractableObject>
);
}
+1 -1
View File
@@ -6,7 +6,7 @@ export const PLAYER_EYE_HEIGHT = 1.75;
export function PlayerCamera(): React.JSX.Element { export function PlayerCamera(): React.JSX.Element {
useEffect(() => { useEffect(() => {
return () => { return () => {
document.exitPointerLock?.(); document.exitPointerLock();
}; };
}, []); }, []);
+1 -3
View File
@@ -3,13 +3,11 @@ import { useThree } from "@react-three/fiber";
import { PlayerCamera, PLAYER_EYE_HEIGHT } from "@/world/player/PlayerCamera"; import { PlayerCamera, PLAYER_EYE_HEIGHT } from "@/world/player/PlayerCamera";
import { PlayerController } from "@/world/player/PlayerController"; import { PlayerController } from "@/world/player/PlayerController";
const SPAWN_POSITION = { x: 0, y: PLAYER_EYE_HEIGHT, z: 0 };
export function PlayerComponent(): React.JSX.Element { export function PlayerComponent(): React.JSX.Element {
const camera = useThree((state) => state.camera); const camera = useThree((state) => state.camera);
useEffect(() => { useEffect(() => {
camera.position.set(SPAWN_POSITION.x, SPAWN_POSITION.y, SPAWN_POSITION.z); camera.position.set(0, PLAYER_EYE_HEIGHT, 0);
}, [camera]); }, [camera]);
return ( return (
+59 -10
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber"; import { useFrame, useThree } from "@react-three/fiber";
import * as THREE from "three"; import * as THREE from "three";
import { InteractionManager } from "@/stateManager/InteractionManager";
import { PLAYER_EYE_HEIGHT } from "@/world/player/PlayerCamera"; import { PLAYER_EYE_HEIGHT } from "@/world/player/PlayerCamera";
const MOVE_SPEED = 5; const MOVE_SPEED = 5;
@@ -35,24 +36,29 @@ export function PlayerController(): null {
const up = useRef(new THREE.Vector3(0, 1, 0)); const up = useRef(new THREE.Vector3(0, 1, 0));
useEffect(() => { useEffect(() => {
const handleKeyChange = const interaction = InteractionManager.getInstance();
(pressed: boolean) =>
(event: KeyboardEvent): void => { const handleKeyDown = (event: KeyboardEvent): void => {
switch (event.key.toLowerCase()) { switch (event.key.toLowerCase()) {
case "z": case "z":
keys.current.forward = pressed; keys.current.forward = true;
break; break;
case "s": case "s":
keys.current.backward = pressed; keys.current.backward = true;
break; break;
case "q": case "q":
keys.current.left = pressed; keys.current.left = true;
break; break;
case "d": case "d":
keys.current.right = pressed; keys.current.right = true;
break; break;
case " ": case " ":
if (pressed) keys.current.jump = true; keys.current.jump = true;
break;
case "e":
if (interaction.getState().focused?.kind === "trigger") {
interaction.pressInteract();
}
break; break;
default: default:
return; return;
@@ -61,15 +67,58 @@ export function PlayerController(): null {
event.preventDefault(); event.preventDefault();
}; };
const handleKeyDown = handleKeyChange(true); const handleKeyUp = (event: KeyboardEvent): void => {
const handleKeyUp = handleKeyChange(false); switch (event.key.toLowerCase()) {
case "z":
keys.current.forward = false;
break;
case "s":
keys.current.backward = false;
break;
case "q":
keys.current.left = false;
break;
case "d":
keys.current.right = false;
break;
case "e":
if (interaction.getState().focused?.kind === "trigger") {
interaction.releaseInteract();
}
break;
default:
return;
}
event.preventDefault();
};
const handleMouseDown = (event: MouseEvent): void => {
if (event.button !== 0) return;
if (interaction.getState().focused?.kind === "grab") {
interaction.pressInteract();
}
};
const handleMouseUp = (event: MouseEvent): void => {
if (event.button !== 0) return;
if (interaction.getState().holding) {
interaction.releaseInteract();
}
};
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp); window.addEventListener("keyup", handleKeyUp);
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("mouseup", handleMouseUp);
return () => { return () => {
window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp); window.removeEventListener("keyup", handleKeyUp);
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("mouseup", handleMouseUp);
keys.current = { ...DEFAULT_KEYS }; keys.current = { ...DEFAULT_KEYS };
}; };
}, []); }, []);