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 f66609178b
commit 01c583ba96
26 changed files with 152 additions and 53 deletions
@@ -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>
);
}
+5 -15
View File
@@ -1,11 +1,9 @@
import { useState } from "react";
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 { RepairCaseModel } from "@/components/three/RepairCaseModel";
const ZONE_ORIGIN = [10, 0.4, -8] as const;
const CASE_MODEL_PATH = "/models/packderelance/model.gltf";
const ZONE_RADIUS = 4.2;
export function MainFeatureZone(): React.JSX.Element {
@@ -44,19 +42,11 @@ export function MainFeatureZone(): React.JSX.Element {
Pack de Relance Feature
</Text>
<TriggerObject
<MainFeatureObject
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}
position={[0, -0.45, 0]}
scale={0.35}
/>
</TriggerObject>
open={caseOpen}
onToggle={() => setCaseOpen((value) => !value)}
/>
<ModelSelectorPlaceholder
label="Module A"
+25 -14
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef } from "react";
import { useFrame } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import gsap from "gsap";
import * as THREE from "three";
import type { Vector3Tuple } from "@/types/three";
@@ -13,8 +13,9 @@ interface RepairCaseModelProps {
}
const CASE_LID_NODE_NAME = "partiesup";
const CASE_OPEN_ANGLE = THREE.MathUtils.degToRad(115);
const CASE_OPEN_SPEED = 7;
const CASE_OPEN_ROTATION_OFFSET_Z = 0;
const CASE_CLOSED_ROTATION_OFFSET_Z = THREE.MathUtils.degToRad(-115);
const CASE_ANIMATION_DURATION = 1.2;
export function RepairCaseModel({
modelPath,
@@ -26,29 +27,39 @@ export function RepairCaseModel({
const { scene } = useGLTF(modelPath);
const model = useMemo(() => scene.clone(true), [scene]);
const lidRef = useRef<THREE.Object3D | null>(null);
const closedRotationX = useRef(0);
const initialOpen = useRef(open);
const openedRotationZ = useRef(0);
const parsedScale =
typeof scale === "number" ? ([scale, scale, scale] as Vector3Tuple) : scale;
useEffect(() => {
const lid = model.getObjectByName(CASE_LID_NODE_NAME);
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]);
useFrame((_, delta) => {
useEffect(() => {
const lid = lidRef.current;
if (!lid) return;
const targetRotation =
closedRotationX.current - (open ? CASE_OPEN_ANGLE : 0);
lid.rotation.x = THREE.MathUtils.damp(
lid.rotation.x,
targetRotation,
CASE_OPEN_SPEED,
delta,
);
});
openedRotationZ.current +
(open ? CASE_OPEN_ROTATION_OFFSET_Z : CASE_CLOSED_ROTATION_OFFSET_Z);
gsap.to(lid.rotation, {
z: targetRotation,
duration: CASE_ANIMATION_DURATION,
ease: "power2.inOut",
overwrite: true,
});
return () => {
gsap.killTweensOf(lid.rotation);
};
}, [open]);
return (
<group position={position} rotation={rotation} scale={parsedScale}>
+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 { MainFeatureZone } from "./MainFeatureZone";
export { MainFeatureObject } from "./MainFeatureObject";
export { ModelSelectorPlaceholder } from "./ModelSelectorPlaceholder";
export { RepairCaseModel } from "./RepairCaseModel";
+1 -5
View File
@@ -6,7 +6,6 @@ import {
HandTrackingContext,
} from "@/hooks/useHandTrackingSnapshot";
import { useRemoteHandTracking } from "@/hooks/useRemoteHandTracking";
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
export function HandTrackingProvider({
children,
@@ -15,10 +14,7 @@ export function HandTrackingProvider({
}): React.JSX.Element {
const sceneMode = useSceneMode();
const { nearby, holding, handHolding } = useInteraction();
const enabled =
isDebugEnabled() &&
sceneMode === "physics" &&
(nearby || holding || handHolding);
const enabled = sceneMode === "physics" && (nearby || holding || handHolding);
const snapshot = useRemoteHandTracking({ enabled });
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_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_SEGMENTS = 32;
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
- 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/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/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
- 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\`.
- 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.
+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";
+6 -1
View File
@@ -1,5 +1,9 @@
import { logger } from "@/utils/logger";
interface PlaySoundOptions {
playbackRate?: number;
}
export class AudioManager {
private static _instance: AudioManager | null = null;
private readonly _audioPools = new Map<string, HTMLAudioElement[]>();
@@ -20,9 +24,10 @@ export class AudioManager {
private constructor() {}
playSound(path: string, volume = 1): void {
playSound(path: string, volume = 1, options: PlaySoundOptions = {}): void {
const audio = this._acquireAudio(path);
audio.volume = Math.max(0, Math.min(1, volume));
audio.playbackRate = options.playbackRate ?? 1;
audio.currentTime = 0;
void audio.play().catch((error: unknown) => {
+3 -3
View File
@@ -1,9 +1,9 @@
import { Environment as DreiEnvironment } from "@react-three/drei";
import {
GAME_SCENE_SKYBOX_PATH,
GAME_SCENE_SKY_MODEL_PATH,
PHYSICS_SCENE_BACKGROUND_COLOR,
} from "@/data/world/environmentConfig";
import { useSceneMode } from "@/hooks/debug/useSceneMode";
import { SkyModel } from "@/components/three/SkyModel";
export function Environment(): React.JSX.Element {
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 { GameMap } from "@/world/GameMap";
import { Player } from "@/world/player/Player";
import { TestScene } from "@/world/debug/TestScene";
import { TestMap } from "@/world/debug/TestMap";
export function World(): React.JSX.Element {
const cameraMode = useCameraMode();
@@ -33,7 +33,7 @@ export function World(): React.JSX.Element {
{sceneMode === "game" ? (
<GameMap onOctreeReady={setOctree} />
) : (
<TestScene onOctreeReady={setOctree} />
<TestMap onOctreeReady={setOctree} />
)}
{cameraMode !== "debug" ? (
@@ -24,13 +24,11 @@ import {
import { useOctreeGraphNode } from "@/hooks/useOctreeGraphNode";
import type { OctreeReadyHandler } from "@/types/three";
interface TestSceneProps {
interface TestMapProps {
onOctreeReady: OctreeReadyHandler;
}
export function TestScene({
onOctreeReady,
}: TestSceneProps): React.JSX.Element {
export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
const floorRef = useRef<THREE.Group>(null);
useOctreeGraphNode(floorRef, onOctreeReady);