update: add a physic scenne
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { Canvas } from "@react-three/fiber";
|
||||
import { Crosshair } from "@/components/ui/Crosshair";
|
||||
import { InteractPrompt } from "@/components/ui/InteractPrompt";
|
||||
import { DebugPerf } from "@/utils/debug/DebugPerf";
|
||||
import { World } from "@/world/World";
|
||||
|
||||
@@ -11,6 +12,7 @@ function App(): React.JSX.Element {
|
||||
<DebugPerf />
|
||||
</Canvas>
|
||||
<Crosshair />
|
||||
<InteractPrompt />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
||||
import { useInteraction } from "@/hooks/useInteraction";
|
||||
|
||||
export function Crosshair(): React.JSX.Element | null {
|
||||
const cameraMode = useCameraMode();
|
||||
const { focused } = useInteraction();
|
||||
|
||||
if (cameraMode !== "player") {
|
||||
return null;
|
||||
}
|
||||
if (cameraMode !== "player") return null;
|
||||
|
||||
return <div className="crosshair" aria-hidden="true" />;
|
||||
return (
|
||||
<div
|
||||
className={focused ? "crosshair crosshair--interact" : "crosshair"}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const INTERACTION_RADIUS = 3;
|
||||
@@ -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
|
||||
}, []);
|
||||
}
|
||||
@@ -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
@@ -31,12 +31,51 @@ canvas {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.92);
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-radius: 999px;
|
||||
transform: translate(-50%, -50%);
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1,18 +1,36 @@
|
||||
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>> {
|
||||
private readonly listeners = new Map<
|
||||
keyof TEvents,
|
||||
Set<Listener<TEvents[keyof TEvents]>>
|
||||
>();
|
||||
private readonly listeners: ListenerMap<TEvents> = {};
|
||||
|
||||
on<TKey extends keyof TEvents>(
|
||||
event: TKey,
|
||||
listener: Listener<TEvents[TKey]>,
|
||||
): () => void {
|
||||
const currentListeners = this.listeners.get(event) ?? new Set();
|
||||
currentListeners.add(listener as Listener<TEvents[keyof TEvents]>);
|
||||
this.listeners.set(event, currentListeners);
|
||||
const existing = getListeners(this.listeners, event);
|
||||
|
||||
if (existing) {
|
||||
existing.add(listener);
|
||||
} else {
|
||||
this.listeners[event] = new Set([listener]) as ListenerMap<TEvents>[TKey];
|
||||
}
|
||||
|
||||
return () => {
|
||||
this.off(event, listener);
|
||||
@@ -23,32 +41,34 @@ export class EventEmitter<TEvents extends Record<string, unknown>> {
|
||||
event: TKey,
|
||||
listener: Listener<TEvents[TKey]>,
|
||||
): void {
|
||||
const currentListeners = this.listeners.get(event);
|
||||
const currentListeners = getListeners(this.listeners, event);
|
||||
|
||||
if (!currentListeners) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentListeners.delete(listener as Listener<TEvents[keyof TEvents]>);
|
||||
currentListeners.delete(listener);
|
||||
|
||||
if (currentListeners.size === 0) {
|
||||
this.listeners.delete(event);
|
||||
delete this.listeners[event];
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentListeners.forEach((listener) => {
|
||||
listener(payload as TEvents[keyof TEvents]);
|
||||
listener(payload);
|
||||
});
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.listeners.clear();
|
||||
for (const key of Object.keys(this.listeners) as (keyof TEvents)[]) {
|
||||
delete this.listeners[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-14
@@ -9,8 +9,12 @@ export class Debug {
|
||||
private readonly folders = new Map<string, GUI>();
|
||||
private readonly registeredFolders = new Set<string>();
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private readonly controls: { cameraMode: CameraMode } = {
|
||||
private readonly controls: {
|
||||
cameraMode: CameraMode;
|
||||
showInteractionSpheres: boolean;
|
||||
} = {
|
||||
cameraMode: "player",
|
||||
showInteractionSpheres: false,
|
||||
};
|
||||
|
||||
static getInstance(): Debug {
|
||||
@@ -28,9 +32,7 @@ export class Debug {
|
||||
if (this.gui) {
|
||||
const folder = this.createFolder("Debug");
|
||||
|
||||
if (!folder) {
|
||||
return;
|
||||
}
|
||||
if (!folder) return;
|
||||
|
||||
folder
|
||||
.add(this.controls, "cameraMode", { Player: "player", Debug: "debug" })
|
||||
@@ -39,6 +41,14 @@ export class Debug {
|
||||
this.controls.cameraMode = value;
|
||||
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.
|
||||
*/
|
||||
createFolder(name: string): GUI | null {
|
||||
if (!this.gui) {
|
||||
return null;
|
||||
}
|
||||
if (!this.gui) return null;
|
||||
|
||||
if (this.registeredFolders.has(name)) {
|
||||
return null;
|
||||
}
|
||||
if (this.registeredFolders.has(name)) return null;
|
||||
|
||||
this.registeredFolders.add(name);
|
||||
|
||||
const existingFolder = this.folders.get(name);
|
||||
const existing = this.folders.get(name);
|
||||
|
||||
if (existingFolder) {
|
||||
return existingFolder;
|
||||
}
|
||||
if (existing) return existing;
|
||||
|
||||
const folder = this.gui.addFolder(name);
|
||||
this.folders.set(name, folder);
|
||||
@@ -82,6 +86,10 @@ export class Debug {
|
||||
return this.controls.cameraMode;
|
||||
}
|
||||
|
||||
getShowInteractionSpheres(): boolean {
|
||||
return this.controls.showInteractionSpheres;
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
this.listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
+4
-17
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRef } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import type { AmbientLight, DirectionalLight } from "three";
|
||||
import { Debug } from "@/utils/debug/Debug";
|
||||
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||
|
||||
type LightingState = {
|
||||
ambientIntensity: number;
|
||||
@@ -23,26 +23,13 @@ export function Lighting(): React.JSX.Element {
|
||||
const ambient = useRef<AmbientLight>(null);
|
||||
const sun = useRef<DirectionalLight>(null);
|
||||
|
||||
useEffect(() => {
|
||||
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;
|
||||
}
|
||||
|
||||
useDebugFolder("Lighting", (folder) => {
|
||||
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, "sunX", -100, 100, 1).name("Sun X");
|
||||
folder.add(LIGHTING_STATE, "sunY", 0, 150, 1).name("Sun Y");
|
||||
folder.add(LIGHTING_STATE, "sunZ", -100, 100, 1).name("Sun Z");
|
||||
}, []);
|
||||
});
|
||||
|
||||
useFrame(() => {
|
||||
if (ambient.current) {
|
||||
|
||||
+11
-1
@@ -1,8 +1,11 @@
|
||||
import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier";
|
||||
import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
||||
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 { PlayerComponent } from "@/world/player/PlayerComponent";
|
||||
|
||||
export function World(): React.JSX.Element {
|
||||
@@ -14,7 +17,14 @@ export function World(): React.JSX.Element {
|
||||
<Lighting />
|
||||
<DebugHelpers />
|
||||
{cameraMode === "debug" ? <DebugCameraControls /> : null}
|
||||
{cameraMode === "debug" ? null : <PlayerComponent />}
|
||||
<Physics>
|
||||
<RigidBody type="fixed">
|
||||
<CuboidCollider args={[50, 0.1, 50]} position={[0, -0.1, 0]} />
|
||||
</RigidBody>
|
||||
<GrabCube />
|
||||
<TriggerSphere />
|
||||
{cameraMode === "debug" ? null : <PlayerComponent />}
|
||||
</Physics>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export const PLAYER_EYE_HEIGHT = 1.75;
|
||||
export function PlayerCamera(): React.JSX.Element {
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
document.exitPointerLock?.();
|
||||
document.exitPointerLock();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -3,13 +3,11 @@ import { useThree } from "@react-three/fiber";
|
||||
import { PlayerCamera, PLAYER_EYE_HEIGHT } from "@/world/player/PlayerCamera";
|
||||
import { PlayerController } from "@/world/player/PlayerController";
|
||||
|
||||
const SPAWN_POSITION = { x: 0, y: PLAYER_EYE_HEIGHT, z: 0 };
|
||||
|
||||
export function PlayerComponent(): React.JSX.Element {
|
||||
const camera = useThree((state) => state.camera);
|
||||
|
||||
useEffect(() => {
|
||||
camera.position.set(SPAWN_POSITION.x, SPAWN_POSITION.y, SPAWN_POSITION.z);
|
||||
camera.position.set(0, PLAYER_EYE_HEIGHT, 0);
|
||||
}, [camera]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
import { InteractionManager } from "@/stateManager/InteractionManager";
|
||||
import { PLAYER_EYE_HEIGHT } from "@/world/player/PlayerCamera";
|
||||
|
||||
const MOVE_SPEED = 5;
|
||||
@@ -35,41 +36,89 @@ export function PlayerController(): null {
|
||||
const up = useRef(new THREE.Vector3(0, 1, 0));
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyChange =
|
||||
(pressed: boolean) =>
|
||||
(event: KeyboardEvent): void => {
|
||||
switch (event.key.toLowerCase()) {
|
||||
case "z":
|
||||
keys.current.forward = pressed;
|
||||
break;
|
||||
case "s":
|
||||
keys.current.backward = pressed;
|
||||
break;
|
||||
case "q":
|
||||
keys.current.left = pressed;
|
||||
break;
|
||||
case "d":
|
||||
keys.current.right = pressed;
|
||||
break;
|
||||
case " ":
|
||||
if (pressed) keys.current.jump = true;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
const interaction = InteractionManager.getInstance();
|
||||
|
||||
event.preventDefault();
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||
switch (event.key.toLowerCase()) {
|
||||
case "z":
|
||||
keys.current.forward = true;
|
||||
break;
|
||||
case "s":
|
||||
keys.current.backward = true;
|
||||
break;
|
||||
case "q":
|
||||
keys.current.left = true;
|
||||
break;
|
||||
case "d":
|
||||
keys.current.right = true;
|
||||
break;
|
||||
case " ":
|
||||
keys.current.jump = true;
|
||||
break;
|
||||
case "e":
|
||||
if (interaction.getState().focused?.kind === "trigger") {
|
||||
interaction.pressInteract();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
const handleKeyDown = handleKeyChange(true);
|
||||
const handleKeyUp = handleKeyChange(false);
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent): void => {
|
||||
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("keyup", handleKeyUp);
|
||||
document.addEventListener("mousedown", handleMouseDown);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
document.removeEventListener("mousedown", handleMouseDown);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
keys.current = { ...DEFAULT_KEYS };
|
||||
};
|
||||
}, []);
|
||||
|
||||
Reference in New Issue
Block a user