refactor: prepare main feature gameplay object and use GLB sky model

This commit is contained in:
Tom Boullay
2026-04-30 10:02:00 +02:00
parent d7b77b2f44
commit 475a4c7c5e
26 changed files with 152 additions and 53 deletions
+2 -2
View File
@@ -14,7 +14,7 @@ This document describes the code that exists today in the repository.
- either the map scene or the debug physics test scene - either the map scene or the debug physics test scene
- the player rig when the active camera mode is `player` - the player rig when the active camera mode is `player`
- `src/world/GameMap.tsx` loads map nodes from `public/map.json`, resolves available models, and builds the collision octree. - `src/world/GameMap.tsx` loads map nodes from `public/map.json`, resolves available models, and builds the collision octree.
- `src/world/debug/TestScene.tsx` provides a debug-oriented interaction and physics scene. - `src/world/debug/TestMap.tsx` provides a debug-oriented interaction and physics map.
- `src/world/player/PlayerComponent.tsx` mounts the camera and controller. - `src/world/player/PlayerComponent.tsx` mounts the camera and controller.
- `src/world/player/PlayerController.tsx` owns pointer lock movement, jump handling, and interaction input. - `src/world/player/PlayerController.tsx` owns pointer lock movement, jump handling, and interaction input.
@@ -64,7 +64,7 @@ This document describes the code that exists today in the repository.
## Current Limitations ## Current Limitations
- The repository is a prototype, not the full intended game runtime. - The repository is a prototype, not the full intended game runtime.
- `src/world/debug/TestScene.tsx` is part of the active scene composition. - `src/world/debug/TestMap.tsx` is part of the active scene composition.
- There is no central gameplay orchestrator such as `GameManager`. - There is no central gameplay orchestrator such as `GameManager`.
- Missions, zones, cinematics, and dialogue systems are not implemented. - Missions, zones, cinematics, and dialogue systems are not implemented.
- The player uses octree collision and simple movement rules, not a complete gameplay physics stack. - The player uses octree collision and simple movement rules, not a complete gameplay physics stack.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,42 @@
import { TriggerObject } from "@/components/three/TriggerObject";
import { RepairCaseModel } from "@/components/three/RepairCaseModel";
import { AudioManager } from "@/managers/AudioManager";
import type { Vector3Tuple } from "@/types/three";
interface MainFeatureObjectProps {
position: Vector3Tuple;
open: boolean;
onToggle: () => void;
}
const CASE_MODEL_PATH = "/models/packderelance/model.gltf";
const CASE_SOUND_PATH = "/sounds/effect/fa.mp3";
const CASE_OPEN_SOUND_RATE = 1.08;
const CASE_CLOSE_SOUND_RATE = 0.82;
export function MainFeatureObject({
position,
open,
onToggle,
}: MainFeatureObjectProps): React.JSX.Element {
return (
<TriggerObject
position={position}
colliders="cuboid"
label={open ? "Fermer la mallette" : "Ouvrir la mallette"}
onTrigger={() => {
AudioManager.getInstance().playSound(CASE_SOUND_PATH, 1, {
playbackRate: open ? CASE_CLOSE_SOUND_RATE : CASE_OPEN_SOUND_RATE,
});
onToggle();
}}
>
<RepairCaseModel
modelPath={CASE_MODEL_PATH}
open={open}
position={[0, -0.45, 0]}
scale={1.5}
/>
</TriggerObject>
);
}
+3 -13
View File
@@ -1,11 +1,9 @@
import { useState } from "react"; import { useState } from "react";
import { Text } from "@react-three/drei"; import { Text } from "@react-three/drei";
import { TriggerObject } from "@/components/three/TriggerObject"; import { MainFeatureObject } from "@/components/three/MainFeatureObject";
import { ModelSelectorPlaceholder } from "@/components/three/ModelSelectorPlaceholder"; import { ModelSelectorPlaceholder } from "@/components/three/ModelSelectorPlaceholder";
import { RepairCaseModel } from "@/components/three/RepairCaseModel";
const ZONE_ORIGIN = [10, 0.4, -8] as const; const ZONE_ORIGIN = [10, 0.4, -8] as const;
const CASE_MODEL_PATH = "/models/packderelance/model.gltf";
const ZONE_RADIUS = 4.2; const ZONE_RADIUS = 4.2;
export function MainFeatureZone(): React.JSX.Element { export function MainFeatureZone(): React.JSX.Element {
@@ -44,19 +42,11 @@ export function MainFeatureZone(): React.JSX.Element {
Pack de Relance Feature Pack de Relance Feature
</Text> </Text>
<TriggerObject <MainFeatureObject
position={[ZONE_ORIGIN[0], ZONE_ORIGIN[1], ZONE_ORIGIN[2]]} position={[ZONE_ORIGIN[0], ZONE_ORIGIN[1], ZONE_ORIGIN[2]]}
colliders="cuboid"
label={caseOpen ? "Fermer la mallette" : "Ouvrir la mallette"}
onTrigger={() => setCaseOpen((value) => !value)}
>
<RepairCaseModel
modelPath={CASE_MODEL_PATH}
open={caseOpen} open={caseOpen}
position={[0, -0.45, 0]} onToggle={() => setCaseOpen((value) => !value)}
scale={0.35}
/> />
</TriggerObject>
<ModelSelectorPlaceholder <ModelSelectorPlaceholder
label="Module A" label="Module A"
+24 -13
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import { useFrame } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei"; import { useGLTF } from "@react-three/drei";
import gsap from "gsap";
import * as THREE from "three"; import * as THREE from "three";
import type { Vector3Tuple } from "@/types/three"; import type { Vector3Tuple } from "@/types/three";
@@ -13,8 +13,9 @@ interface RepairCaseModelProps {
} }
const CASE_LID_NODE_NAME = "partiesup"; const CASE_LID_NODE_NAME = "partiesup";
const CASE_OPEN_ANGLE = THREE.MathUtils.degToRad(115); const CASE_OPEN_ROTATION_OFFSET_Z = 0;
const CASE_OPEN_SPEED = 7; const CASE_CLOSED_ROTATION_OFFSET_Z = THREE.MathUtils.degToRad(-115);
const CASE_ANIMATION_DURATION = 1.2;
export function RepairCaseModel({ export function RepairCaseModel({
modelPath, modelPath,
@@ -26,30 +27,40 @@ export function RepairCaseModel({
const { scene } = useGLTF(modelPath); const { scene } = useGLTF(modelPath);
const model = useMemo(() => scene.clone(true), [scene]); const model = useMemo(() => scene.clone(true), [scene]);
const lidRef = useRef<THREE.Object3D | null>(null); const lidRef = useRef<THREE.Object3D | null>(null);
const closedRotationX = useRef(0); const initialOpen = useRef(open);
const openedRotationZ = useRef(0);
const parsedScale = const parsedScale =
typeof scale === "number" ? ([scale, scale, scale] as Vector3Tuple) : scale; typeof scale === "number" ? ([scale, scale, scale] as Vector3Tuple) : scale;
useEffect(() => { useEffect(() => {
const lid = model.getObjectByName(CASE_LID_NODE_NAME); const lid = model.getObjectByName(CASE_LID_NODE_NAME);
lidRef.current = lid ?? null; lidRef.current = lid ?? null;
closedRotationX.current = lid?.rotation.x ?? 0; openedRotationZ.current = lid?.rotation.z ?? 0;
if (lid && !initialOpen.current) {
lid.rotation.z = openedRotationZ.current + CASE_CLOSED_ROTATION_OFFSET_Z;
}
}, [model]); }, [model]);
useFrame((_, delta) => { useEffect(() => {
const lid = lidRef.current; const lid = lidRef.current;
if (!lid) return; if (!lid) return;
const targetRotation = const targetRotation =
closedRotationX.current - (open ? CASE_OPEN_ANGLE : 0); openedRotationZ.current +
lid.rotation.x = THREE.MathUtils.damp( (open ? CASE_OPEN_ROTATION_OFFSET_Z : CASE_CLOSED_ROTATION_OFFSET_Z);
lid.rotation.x, gsap.to(lid.rotation, {
targetRotation, z: targetRotation,
CASE_OPEN_SPEED, duration: CASE_ANIMATION_DURATION,
delta, ease: "power2.inOut",
); overwrite: true,
}); });
return () => {
gsap.killTweensOf(lid.rotation);
};
}, [open]);
return ( return (
<group position={position} rotation={rotation} scale={parsedScale}> <group position={position} rotation={rotation} scale={parsedScale}>
<primitive object={model} /> <primitive object={model} />
+29
View File
@@ -0,0 +1,29 @@
import { useFrame, useThree } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import { useMemo, useRef } from "react";
import * as THREE from "three";
interface SkyModelProps {
modelPath: string;
}
const SKY_MODEL_SCALE = 1;
export function SkyModel({ modelPath }: SkyModelProps): React.JSX.Element {
const camera = useThree((state) => state.camera);
const groupRef = useRef<THREE.Group>(null);
const { scene } = useGLTF(modelPath);
const model = useMemo(() => scene.clone(true), [scene]);
useFrame(() => {
groupRef.current?.position.copy(camera.position);
});
return (
<group ref={groupRef} scale={SKY_MODEL_SCALE} frustumCulled={false}>
<primitive object={model} />
</group>
);
}
useGLTF.preload("/models/sky/model.glb");
+1
View File
@@ -6,6 +6,7 @@ export type { SimpleModelConfig } from "./SimpleModel";
export { ExplodableModel } from "./ExplodableModel"; export { ExplodableModel } from "./ExplodableModel";
export { MainFeatureZone } from "./MainFeatureZone"; export { MainFeatureZone } from "./MainFeatureZone";
export { MainFeatureObject } from "./MainFeatureObject";
export { ModelSelectorPlaceholder } from "./ModelSelectorPlaceholder"; export { ModelSelectorPlaceholder } from "./ModelSelectorPlaceholder";
export { RepairCaseModel } from "./RepairCaseModel"; export { RepairCaseModel } from "./RepairCaseModel";
+1 -5
View File
@@ -6,7 +6,6 @@ import {
HandTrackingContext, HandTrackingContext,
} from "@/hooks/useHandTrackingSnapshot"; } from "@/hooks/useHandTrackingSnapshot";
import { useRemoteHandTracking } from "@/hooks/useRemoteHandTracking"; import { useRemoteHandTracking } from "@/hooks/useRemoteHandTracking";
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
export function HandTrackingProvider({ export function HandTrackingProvider({
children, children,
@@ -15,10 +14,7 @@ export function HandTrackingProvider({
}): React.JSX.Element { }): React.JSX.Element {
const sceneMode = useSceneMode(); const sceneMode = useSceneMode();
const { nearby, holding, handHolding } = useInteraction(); const { nearby, holding, handHolding } = useInteraction();
const enabled = const enabled = sceneMode === "physics" && (nearby || holding || handHolding);
isDebugEnabled() &&
sceneMode === "physics" &&
(nearby || holding || handHolding);
const snapshot = useRemoteHandTracking({ enabled }); const snapshot = useRemoteHandTracking({ enabled });
return ( return (
+1 -1
View File
@@ -13,7 +13,7 @@ export const TEST_SCENE_GRABBABLE_ROUGHNESS = 0.6;
export const TEST_SCENE_GRABBABLE_METALNESS = 0.1; export const TEST_SCENE_GRABBABLE_METALNESS = 0.1;
export const TEST_SCENE_TRIGGER_POSITION: Vector3Tuple = [3, 2, -3]; export const TEST_SCENE_TRIGGER_POSITION: Vector3Tuple = [3, 2, -3];
export const TEST_SCENE_TRIGGER_SOUND_PATH = "/sounds/fa.mp3"; export const TEST_SCENE_TRIGGER_SOUND_PATH = "/sounds/effect/fa.mp3";
export const TEST_SCENE_TRIGGER_RADIUS = 0.4; export const TEST_SCENE_TRIGGER_RADIUS = 0.4;
export const TEST_SCENE_TRIGGER_SEGMENTS = 32; export const TEST_SCENE_TRIGGER_SEGMENTS = 32;
export const TEST_SCENE_TRIGGER_COLOR = "#3b82f6"; export const TEST_SCENE_TRIGGER_COLOR = "#3b82f6";
+2 -2
View File
@@ -99,7 +99,7 @@ Ce document décrit le code réellement présent aujourd'hui dans le dépôt.
- soit la carte principale, soit la scène de test physique debug - soit la carte principale, soit la scène de test physique debug
- le rig joueur quand le mode caméra actif est \`player\` - le rig joueur quand le mode caméra actif est \`player\`
- \`src/world/GameMap.tsx\` charge les modèles de carte disponibles et construit l'octree de collision. - \`src/world/GameMap.tsx\` charge les modèles de carte disponibles et construit l'octree de collision.
- \`src/world/debug/TestScene.tsx\` fournit une scène orientée debug pour les interactions et la physique. - \`src/world/debug/TestMap.tsx\` fournit une carte orientée debug pour les interactions et la physique.
- \`src/world/player/Player.tsx\` monte la caméra et le contrôleur. - \`src/world/player/Player.tsx\` monte la caméra et le contrôleur.
- \`src/world/player/PlayerController.tsx\` gère le mouvement pointer lock, le saut et les inputs d'interaction. - \`src/world/player/PlayerController.tsx\` gère le mouvement pointer lock, le saut et les inputs d'interaction.
@@ -129,7 +129,7 @@ Ce document décrit le code réellement présent aujourd'hui dans le dépôt.
## Limites actuelles ## Limites actuelles
- Le dépôt est encore un prototype, pas le runtime complet du jeu. - Le dépôt est encore un prototype, pas le runtime complet du jeu.
- \`src/world/debug/TestScene.tsx\` fait encore partie de la composition active. - \`src/world/debug/TestMap.tsx\` fait encore partie de la composition active.
- Il n'existe pas encore d'orchestrateur gameplay central comme \`GameManager\`. - Il n'existe pas encore d'orchestrateur gameplay central comme \`GameManager\`.
- Les systèmes de missions, zones, cinématiques et dialogues ne sont pas implémentés. - Les systèmes de missions, zones, cinématiques et dialogues ne sont pas implémentés.
- Le joueur utilise une collision octree et des règles simples, pas une pile physique gameplay complète. - Le joueur utilise une collision octree et des règles simples, pas une pile physique gameplay complète.
+1 -1
View File
@@ -1,2 +1,2 @@
export const GAME_SCENE_SKYBOX_PATH = "/skybox/sky.exr"; export const GAME_SCENE_SKY_MODEL_PATH = "/models/sky/model.glb";
export const PHYSICS_SCENE_BACKGROUND_COLOR = "#0b1018"; export const PHYSICS_SCENE_BACKGROUND_COLOR = "#0b1018";
+6 -1
View File
@@ -1,5 +1,9 @@
import { logger } from "@/utils/logger"; import { logger } from "@/utils/logger";
interface PlaySoundOptions {
playbackRate?: number;
}
export class AudioManager { export class AudioManager {
private static _instance: AudioManager | null = null; private static _instance: AudioManager | null = null;
private readonly _audioPools = new Map<string, HTMLAudioElement[]>(); private readonly _audioPools = new Map<string, HTMLAudioElement[]>();
@@ -20,9 +24,10 @@ export class AudioManager {
private constructor() {} private constructor() {}
playSound(path: string, volume = 1): void { playSound(path: string, volume = 1, options: PlaySoundOptions = {}): void {
const audio = this._acquireAudio(path); const audio = this._acquireAudio(path);
audio.volume = Math.max(0, Math.min(1, volume)); audio.volume = Math.max(0, Math.min(1, volume));
audio.playbackRate = options.playbackRate ?? 1;
audio.currentTime = 0; audio.currentTime = 0;
void audio.play().catch((error: unknown) => { void audio.play().catch((error: unknown) => {
+3 -3
View File
@@ -1,9 +1,9 @@
import { Environment as DreiEnvironment } from "@react-three/drei";
import { import {
GAME_SCENE_SKYBOX_PATH, GAME_SCENE_SKY_MODEL_PATH,
PHYSICS_SCENE_BACKGROUND_COLOR, PHYSICS_SCENE_BACKGROUND_COLOR,
} from "@/data/world/environmentConfig"; } from "@/data/world/environmentConfig";
import { useSceneMode } from "@/hooks/debug/useSceneMode"; import { useSceneMode } from "@/hooks/debug/useSceneMode";
import { SkyModel } from "@/components/three/SkyModel";
export function Environment(): React.JSX.Element { export function Environment(): React.JSX.Element {
const sceneMode = useSceneMode(); const sceneMode = useSceneMode();
@@ -14,5 +14,5 @@ export function Environment(): React.JSX.Element {
); );
} }
return <DreiEnvironment background files={GAME_SCENE_SKYBOX_PATH} />; return <SkyModel modelPath={GAME_SCENE_SKY_MODEL_PATH} />;
} }
+2 -2
View File
@@ -12,7 +12,7 @@ import { Environment } from "@/world/Environment";
import { Lighting } from "@/world/Lighting"; import { Lighting } from "@/world/Lighting";
import { GameMap } from "@/world/GameMap"; import { GameMap } from "@/world/GameMap";
import { Player } from "@/world/player/Player"; import { Player } from "@/world/player/Player";
import { TestScene } from "@/world/debug/TestScene"; import { TestMap } from "@/world/debug/TestMap";
export function World(): React.JSX.Element { export function World(): React.JSX.Element {
const cameraMode = useCameraMode(); const cameraMode = useCameraMode();
@@ -33,7 +33,7 @@ export function World(): React.JSX.Element {
{sceneMode === "game" ? ( {sceneMode === "game" ? (
<GameMap onOctreeReady={setOctree} /> <GameMap onOctreeReady={setOctree} />
) : ( ) : (
<TestScene onOctreeReady={setOctree} /> <TestMap onOctreeReady={setOctree} />
)} )}
{cameraMode !== "debug" ? ( {cameraMode !== "debug" ? (
@@ -24,13 +24,11 @@ import {
import { useOctreeGraphNode } from "@/hooks/useOctreeGraphNode"; import { useOctreeGraphNode } from "@/hooks/useOctreeGraphNode";
import type { OctreeReadyHandler } from "@/types/three"; import type { OctreeReadyHandler } from "@/types/three";
interface TestSceneProps { interface TestMapProps {
onOctreeReady: OctreeReadyHandler; onOctreeReady: OctreeReadyHandler;
} }
export function TestScene({ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
onOctreeReady,
}: TestSceneProps): React.JSX.Element {
const floorRef = useRef<THREE.Group>(null); const floorRef = useRef<THREE.Group>(null);
useOctreeGraphNode(floorRef, onOctreeReady); useOctreeGraphNode(floorRef, onOctreeReady);