From 396e7e4ff064f39e7357d44511c3c9b3642fd5c3 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Sun, 31 May 2026 11:36:19 +0200 Subject: [PATCH 01/61] feat(ebike): add speedometer --- public/assets/world/gps/cadran.png | 3 + public/assets/world/gps/fleche.png | 3 + src/components/ebike/Ebike.tsx | 101 +++++++++++++--------- src/components/ebike/EbikeGPSMap.tsx | 6 +- src/components/ebike/EbikeSpeedometer.tsx | 90 +++++++++++++++++++ 5 files changed, 161 insertions(+), 42 deletions(-) create mode 100644 public/assets/world/gps/cadran.png create mode 100644 public/assets/world/gps/fleche.png create mode 100644 src/components/ebike/EbikeSpeedometer.tsx diff --git a/public/assets/world/gps/cadran.png b/public/assets/world/gps/cadran.png new file mode 100644 index 0000000..652420c --- /dev/null +++ b/public/assets/world/gps/cadran.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:65883c1760293f3a415268a59cae60d0b35de8760de752c8dedb4ab3e19c0e96 +size 531191 diff --git a/public/assets/world/gps/fleche.png b/public/assets/world/gps/fleche.png new file mode 100644 index 0000000..e146cc7 --- /dev/null +++ b/public/assets/world/gps/fleche.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:286164bc5aeb147abb145857cb56832f04a2d17afd50a3d9000ae81059b8201e +size 121079 diff --git a/src/components/ebike/Ebike.tsx b/src/components/ebike/Ebike.tsx index b49e3c4..addef6d 100644 --- a/src/components/ebike/Ebike.tsx +++ b/src/components/ebike/Ebike.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState, useMemo, useCallback } from "react"; import * as THREE from "three"; import { useFrame, useThree } from "@react-three/fiber"; import { EbikeGPSMap } from "@/components/ebike/EbikeGPSMap"; +import { EbikeSpeedometer } from "@/components/ebike/EbikeSpeedometer"; import { InteractableObject } from "@/components/three/interaction/InteractableObject"; import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; import { useClonedObject } from "@/hooks/three/useClonedObject"; @@ -37,6 +38,11 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element { const setMissionStep = useGameStore((state) => state.setMissionStep); const camera = useThree((state) => state.camera); const updateEbikeSounds = useEbikeSounds(); + const repairGameOwnsEbikeModel = + mainState === "ebike" && + ebikeStep !== "locked" && + ebikeStep !== "waiting" && + ebikeStep !== "inspected"; // Map active mainState to target repair zone coordinate const destPos = useMemo(() => { @@ -169,16 +175,30 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element { debugRestingPosition[1] + EBIKE_DROP_PLAYER_TRANSFORM.position[1], debugRestingPosition[2] + EBIKE_DROP_PLAYER_TRANSFORM.position[2], ]; + const interactionLabel = + mainState === "ebike" + ? "Réparer l'e-bike" + : movementMode === "walk" + ? "Monter sur le bike" + : "Descendre du bike"; const handleInteract = useCallback((): void => { if (window.ebikeBreakdownActive === true) return; if (movementMode === "walk") { - if (mainState === "ebike" && ebikeStep === "waiting") { + if ( + mainState === "ebike" && + (ebikeStep === "locked" || ebikeStep === "waiting") + ) { setMissionStep("ebike", "inspected"); return; } + if (mainState === "ebike" && ebikeStep === "inspected") { + setMissionStep("ebike", "fragmented"); + return; + } + const cameraOffset = new THREE.Vector3( ...EBIKE_CAMERA_TRANSFORM.position, ); @@ -258,51 +278,50 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element { return ( <> - - - - - - - - + + + + + + + - {/* Dynamic 3D GPS Dashboard Screen */} - - + {/* Dynamic 3D GPS Dashboard Screen */} + + + + + + - + ) : null} - {showCameraPoints && ( + {showCameraPoints && !repairGameOwnsEbikeModel && ( <> diff --git a/src/components/ebike/EbikeGPSMap.tsx b/src/components/ebike/EbikeGPSMap.tsx index e51108c..08fab3e 100644 --- a/src/components/ebike/EbikeGPSMap.tsx +++ b/src/components/ebike/EbikeGPSMap.tsx @@ -89,6 +89,8 @@ export interface EbikeGPSMapProps { * Default: 1 */ zoom?: number; + + renderOrder?: number; } /** @@ -107,6 +109,7 @@ export const EbikeGPSMap: React.FC = ({ position = [0, 0, 0], canvasSize = 1024, zoom = 1, + renderOrder = 10_000, }) => { const [waypoints, setWaypoints] = useState([]); const [mapImage, setMapImage] = useState< @@ -506,12 +509,13 @@ export const EbikeGPSMap: React.FC = ({ }, [draw]); return ( - + diff --git a/src/components/ebike/EbikeSpeedometer.tsx b/src/components/ebike/EbikeSpeedometer.tsx new file mode 100644 index 0000000..82d7040 --- /dev/null +++ b/src/components/ebike/EbikeSpeedometer.tsx @@ -0,0 +1,90 @@ +import { useEffect, useRef } from "react"; +import { useFrame } from "@react-three/fiber"; +import { useTexture } from "@react-three/drei"; +import * as THREE from "three"; + +const SPEEDOMETER_DIAL_TEXTURE = "/assets/world/gps/cadran.png"; +const SPEEDOMETER_NEEDLE_TEXTURE = "/assets/world/gps/fleche.png"; +const SPEEDOMETER_MIN_ANGLE = Math.PI / 2; +const SPEEDOMETER_MAX_ANGLE = -Math.PI / 2; +const SPEEDOMETER_RENDER_ORDER = 10_000; + +interface EbikeSpeedometerProps { + width?: number; + height?: number; +} + +export function EbikeSpeedometer({ + width = 0.9, + height = 0.5, +}: EbikeSpeedometerProps): React.JSX.Element { + const needleGroupRef = useRef(null); + const speedFactorRef = useRef(0); + const [dialTexture, needleTexture] = useTexture([ + SPEEDOMETER_DIAL_TEXTURE, + SPEEDOMETER_NEEDLE_TEXTURE, + ]) as [THREE.Texture, THREE.Texture]; + const needleWidth = width * 0.68; + const needleHeight = needleWidth / 2; + + useEffect(() => { + [dialTexture, needleTexture].forEach((texture) => { + texture.colorSpace = THREE.SRGBColorSpace; + texture.needsUpdate = true; + }); + }, [dialTexture, needleTexture]); + + useFrame((_, delta) => { + const targetSpeedFactor = THREE.MathUtils.clamp( + window.ebikeSpeedFactor ?? 0, + 0, + 1, + ); + speedFactorRef.current = THREE.MathUtils.lerp( + speedFactorRef.current, + targetSpeedFactor, + Math.min(1, delta * 10), + ); + + if (needleGroupRef.current) { + needleGroupRef.current.rotation.z = THREE.MathUtils.lerp( + SPEEDOMETER_MIN_ANGLE, + SPEEDOMETER_MAX_ANGLE, + speedFactorRef.current, + ); + } + }); + + return ( + + + + + + + + + + + + + + ); +} From c33d973f126b67fe9458a9949cda651184fdfe95 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Sun, 31 May 2026 11:51:33 +0200 Subject: [PATCH 02/61] fix(ui): update logo asset path --- public/assets/logo.png | 3 +++ public/assets/logo/logo.jpg | 3 --- src/components/site/SiteMobileBlocker.tsx | 2 +- src/components/ui/SceneLoadingOverlay.tsx | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) create mode 100644 public/assets/logo.png delete mode 100644 public/assets/logo/logo.jpg diff --git a/public/assets/logo.png b/public/assets/logo.png new file mode 100644 index 0000000..f6dd236 --- /dev/null +++ b/public/assets/logo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b46b6642ccb5f83d9d16f7d9e9810f390107790fd3365ebd02dfba0de9f56289 +size 1309650 diff --git a/public/assets/logo/logo.jpg b/public/assets/logo/logo.jpg deleted file mode 100644 index 3617df6..0000000 --- a/public/assets/logo/logo.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:814db18091a1a822dc2ebdef9f00400c4ff943e9aa1e43151e85b6ea1c4e98cc -size 149572 diff --git a/src/components/site/SiteMobileBlocker.tsx b/src/components/site/SiteMobileBlocker.tsx index acce806..dc84282 100644 --- a/src/components/site/SiteMobileBlocker.tsx +++ b/src/components/site/SiteMobileBlocker.tsx @@ -20,7 +20,7 @@ export function SiteMobileBlocker(): React.JSX.Element { }} > Logo Altera diff --git a/src/components/ui/SceneLoadingOverlay.tsx b/src/components/ui/SceneLoadingOverlay.tsx index b58dbd8..f23bc2c 100644 --- a/src/components/ui/SceneLoadingOverlay.tsx +++ b/src/components/ui/SceneLoadingOverlay.tsx @@ -1,7 +1,7 @@ import type { SceneLoadingState } from "@/types/world/sceneLoading"; const LOADING_BACKGROUND_PATH = "/assets/bg-site.png"; -const LOADING_LOGO_PATH = "/assets/logo/logo.jpg"; +const LOADING_LOGO_PATH = "/assets/logo.png"; for (const path of [LOADING_BACKGROUND_PATH, LOADING_LOGO_PATH]) { const image = new Image(); From 27b4a2c3924d304949e0edc25bf74d2f1ceab552 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Mon, 1 Jun 2026 00:14:39 +0200 Subject: [PATCH 03/61] upatde(fabrik): zone + herbe --- src/components/three/world/TerrainModel.tsx | 4 +- src/data/ebike/ebikeConfig.ts | 2 +- src/data/player/playerConfig.ts | 3 +- src/data/world/characters/characterConfig.ts | 4 +- src/data/world/laFabrikConfig.ts | 83 +++++++++++++++++++ src/hooks/three/useTerrainHeight.ts | 8 ++ src/world/GameMapCollision.tsx | 33 +++++++- src/world/Lighting.tsx | 8 ++ src/world/characters/CharacterSystem.tsx | 7 +- src/world/grass/GrassPatch.tsx | 16 ++++ src/world/grass/grassShaders.ts | 16 ++++ .../GeneratedMapNodeInstance.tsx | 3 +- 12 files changed, 176 insertions(+), 11 deletions(-) create mode 100644 src/data/world/laFabrikConfig.ts diff --git a/src/components/three/world/TerrainModel.tsx b/src/components/three/world/TerrainModel.tsx index 4525ee2..5ba842a 100644 --- a/src/components/three/world/TerrainModel.tsx +++ b/src/components/three/world/TerrainModel.tsx @@ -3,6 +3,7 @@ import * as THREE from "three"; import { useGLTF } from "@react-three/drei"; import { useThree } from "@react-three/fiber"; import { TERRAIN_MODEL_PATH } from "@/data/world/terrainConfig"; +import { flattenLaFabrikTerrainFootprint } from "@/data/world/laFabrikConfig"; import type { Vector3Tuple } from "@/types/three/three"; import { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene"; @@ -65,9 +66,10 @@ export function TerrainModel({ const terrainModel = useMemo(() => { optimizeGLTFSceneTextures(scene, maxAnisotropy); const model = scene.clone(true); + flattenLaFabrikTerrainFootprint(model, position, rotation, scale); applyTerrainMaterialSettings(model, receiveShadow); return model; - }, [maxAnisotropy, scene, receiveShadow]); + }, [maxAnisotropy, position, receiveShadow, rotation, scale, scene]); useEffect(() => { onLoaded?.(); diff --git a/src/data/ebike/ebikeConfig.ts b/src/data/ebike/ebikeConfig.ts index 583afc1..12685d8 100644 --- a/src/data/ebike/ebikeConfig.ts +++ b/src/data/ebike/ebikeConfig.ts @@ -15,7 +15,7 @@ export const EBIKE_DROP_PLAYER_TRANSFORM: CameraTransform = { rotation: [0, 0, 0], }; -export const EBIKE_WORLD_POSITION: Vector3Tuple = [61.5, 10, 62.4]; +export const EBIKE_WORLD_POSITION: Vector3Tuple = [61.5, 8.4, 62.4]; export const EBIKE_WORLD_ROTATION_Y = 2.4107; export const EBIKE_INTRO_RIDE_DURATION_MS = 5000; diff --git a/src/data/player/playerConfig.ts b/src/data/player/playerConfig.ts index 7ce0ca2..a1aa8d3 100644 --- a/src/data/player/playerConfig.ts +++ b/src/data/player/playerConfig.ts @@ -1,4 +1,5 @@ import type { Vector3Tuple } from "@/types/three/three"; +import { LA_FABRIK_PLAYER_SPAWN } from "@/data/world/laFabrikConfig"; export const PLAYER_EYE_HEIGHT = 1.75; export const PLAYER_CAPSULE_RADIUS = 0.35; @@ -14,5 +15,5 @@ export const PLAYER_XZ_DAMPING_FACTOR = 8; export const PLAYER_FALL_RESPAWN_Y = -20; export const PLAYER_FALL_RESPAWN_DELAY = 3; -export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [59.5, 10, 64.64]; +export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = LA_FABRIK_PLAYER_SPAWN; export const PLAYER_SPAWN_POSITION_PHYSICS: Vector3Tuple = [0, 3, 0]; diff --git a/src/data/world/characters/characterConfig.ts b/src/data/world/characters/characterConfig.ts index fce3d29..43d8199 100644 --- a/src/data/world/characters/characterConfig.ts +++ b/src/data/world/characters/characterConfig.ts @@ -11,6 +11,7 @@ export interface CharacterConfig { scale: Vector3Tuple; animations: readonly string[]; defaultAnimation: string; + snapToTerrain?: boolean; } export const CHARACTER_CONFIGS = { @@ -28,11 +29,12 @@ export const CHARACTER_CONFIGS = { id: "gerant", label: "Gerant", modelPath: "/models/gerant-animated/model.gltf", - position: [59.5, 0, 64.64], + position: [59.5, 6.3, 64.64], rotation: [0, 2.41, 0], scale: [1, 1, 1], animations: ["idle", "walk"], defaultAnimation: "idle", + snapToTerrain: false, }, fermier: { id: "fermier", diff --git a/src/data/world/laFabrikConfig.ts b/src/data/world/laFabrikConfig.ts new file mode 100644 index 0000000..1eeb7b0 --- /dev/null +++ b/src/data/world/laFabrikConfig.ts @@ -0,0 +1,83 @@ +import * as THREE from "three"; +import type { Vector3Tuple } from "@/types/three/three"; + +export const LA_FABRIK_CENTER: Vector3Tuple = [59.4973, 6.2746, 64.6354]; +export const LA_FABRIK_ROTATION_Y = 2.4107; +export const LA_FABRIK_HALF_EXTENTS = { + x: 8.5, + z: 7.5, +} as const; +export const LA_FABRIK_FLOOR_Y = 6.3; +export const LA_FABRIK_PLAYER_SPAWN: Vector3Tuple = [59.5, 8.05, 64.64]; +export const LA_FABRIK_INTERIOR_LIGHT_POSITION: Vector3Tuple = [59.5, 9, 64.64]; + +const _terrainMatrix = new THREE.Matrix4(); +const _meshWorldMatrix = new THREE.Matrix4(); +const _inverseMeshWorldMatrix = new THREE.Matrix4(); +const _worldPosition = new THREE.Vector3(); + +export function isInsideLaFabrikFootprint( + x: number, + z: number, + padding = 0, +): boolean { + const dx = x - LA_FABRIK_CENTER[0]; + const dz = z - LA_FABRIK_CENTER[2]; + const cos = Math.cos(-LA_FABRIK_ROTATION_Y); + const sin = Math.sin(-LA_FABRIK_ROTATION_Y); + const localX = dx * cos - dz * sin; + const localZ = dx * sin + dz * cos; + + return ( + Math.abs(localX) <= LA_FABRIK_HALF_EXTENTS.x + padding && + Math.abs(localZ) <= LA_FABRIK_HALF_EXTENTS.z + padding + ); +} + +export function flattenLaFabrikTerrainFootprint( + object: THREE.Object3D, + position: Vector3Tuple, + rotation: Vector3Tuple, + scale: Vector3Tuple, +): void { + _terrainMatrix.compose( + new THREE.Vector3(...position), + new THREE.Quaternion().setFromEuler(new THREE.Euler(...rotation)), + new THREE.Vector3(...scale), + ); + object.updateMatrixWorld(true); + + object.traverse((child) => { + if (!(child instanceof THREE.Mesh)) return; + const geometry = child.geometry; + const positions = geometry.getAttribute("position"); + if (!positions) return; + + _meshWorldMatrix.multiplyMatrices(_terrainMatrix, child.matrixWorld); + _inverseMeshWorldMatrix.copy(_meshWorldMatrix).invert(); + + for (let index = 0; index < positions.count; index++) { + _worldPosition + .fromBufferAttribute(positions, index) + .applyMatrix4(_meshWorldMatrix); + + if (!isInsideLaFabrikFootprint(_worldPosition.x, _worldPosition.z, 0.8)) { + continue; + } + + _worldPosition.y = Math.min(_worldPosition.y, LA_FABRIK_FLOOR_Y - 0.35); + _worldPosition.applyMatrix4(_inverseMeshWorldMatrix); + positions.setXYZ( + index, + _worldPosition.x, + _worldPosition.y, + _worldPosition.z, + ); + } + + positions.needsUpdate = true; + geometry.computeVertexNormals(); + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + }); +} diff --git a/src/hooks/three/useTerrainHeight.ts b/src/hooks/three/useTerrainHeight.ts index b4405f2..7e0a9d3 100644 --- a/src/hooks/three/useTerrainHeight.ts +++ b/src/hooks/three/useTerrainHeight.ts @@ -2,6 +2,10 @@ import { useMemo } from "react"; import { useGLTF } from "@react-three/drei"; import * as THREE from "three"; import { TERRAIN_MODEL_PATH } from "@/data/world/terrainConfig"; +import { + isInsideLaFabrikFootprint, + LA_FABRIK_FLOOR_Y, +} from "@/data/world/laFabrikConfig"; import type { Vector3Tuple } from "@/types/three/three"; import { getMapNodesByName } from "@/utils/map/loadMapSceneData"; @@ -66,6 +70,10 @@ function createTerrainHeightSampler( return { getHeight: (x, z) => { + if (isInsideLaFabrikFootprint(x, z, 0.6)) { + return LA_FABRIK_FLOOR_Y; + } + localOrigin.set(x, RAYCAST_Y, z).applyMatrix4(inverseTerrainMatrix); raycaster.set(localOrigin, localDirection); hits.length = 0; diff --git a/src/world/GameMapCollision.tsx b/src/world/GameMapCollision.tsx index 9d038d7..f27955b 100644 --- a/src/world/GameMapCollision.tsx +++ b/src/world/GameMapCollision.tsx @@ -18,6 +18,7 @@ import { useTerrainHeightSampler, } from "@/hooks/three/useTerrainHeight"; import { WorldBoundsCollision } from "@/world/collision/WorldBoundsCollision"; +import { flattenLaFabrikTerrainFootprint } from "@/data/world/laFabrikConfig"; import type { MapNode } from "@/types/map/mapScene"; import type { OctreeReadyHandler } from "@/types/three/three"; import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading"; @@ -213,7 +214,7 @@ function CollisionModelInstance({ modelUrl: string; onLoaded: () => void; terrainHeight: TerrainHeightSampler; -}): React.JSX.Element { +}): React.JSX.Element | null { const { position, rotation, scale } = node; const normalizedScale = normalizeMapScale(scale); const { scene } = useLoggedGLTF(modelUrl, { @@ -223,22 +224,46 @@ function CollisionModelInstance({ scale: normalizedScale, }); const sceneInstance = useClonedObject(scene); + const collisionSceneInstance = useMemo(() => { + if (node.name === "terrain") { + flattenLaFabrikTerrainFootprint( + sceneInstance, + position, + rotation, + normalizedScale, + ); + } + return sceneInstance; + }, [node.name, normalizedScale, position, rotation, sceneInstance]); const collisionPosition = useMemo(() => { if (node.name === "terrain") return position; const [x, y, z] = position; const height = terrainHeight.getHeight(x, z); - const bottomOffset = getObjectBottomOffset(sceneInstance, normalizedScale); + const bottomOffset = getObjectBottomOffset( + collisionSceneInstance, + normalizedScale, + ); return [x, height !== null ? height + bottomOffset : y, z] as const; - }, [node.name, normalizedScale, position, sceneInstance, terrainHeight]); + }, [ + node.name, + normalizedScale, + position, + collisionSceneInstance, + terrainHeight, + ]); useEffect(() => { onLoaded(); }, [onLoaded]); + if (node.name === "lafabrik") { + return null; + } + return ( + ); } diff --git a/src/world/characters/CharacterSystem.tsx b/src/world/characters/CharacterSystem.tsx index 3de23a4..d2f6672 100644 --- a/src/world/characters/CharacterSystem.tsx +++ b/src/world/characters/CharacterSystem.tsx @@ -3,15 +3,18 @@ import { AnimatedModel } from "@/components/three/models/AnimatedModel"; import { CHARACTER_CONFIGS, CHARACTER_IDS, + type CharacterConfig, type CharacterId, } from "@/data/world/characters/characterConfig"; import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight"; import { useCharacterDebugStore } from "@/managers/stores/useCharacterDebugStore"; function CharacterModel({ id }: { id: CharacterId }): React.JSX.Element { - const config = CHARACTER_CONFIGS[id]; + const config: CharacterConfig = CHARACTER_CONFIGS[id]; const state = useCharacterDebugStore((store) => store.characters[id]); - const position = useTerrainSnappedPosition(state.position); + const snappedPosition = useTerrainSnappedPosition(state.position); + const position = + config.snapToTerrain === false ? state.position : snappedPosition; return ( Date: Mon, 1 Jun 2026 00:54:59 +0200 Subject: [PATCH 04/61] feat: update ui and intro sequence --- .../world/UI/intro-mission-notification.png | 3 + src/components/ebike/Ebike.tsx | 6 +- src/components/game/EbikeIntroSequence.tsx | 69 ++++++++++++++++--- src/components/ui/MissionNotification.tsx | 9 ++- src/data/ebike/ebikeConfig.ts | 9 +-- src/data/gameplay/missionNotifications.ts | 3 + src/data/world/characters/characterConfig.ts | 1 - 7 files changed, 80 insertions(+), 20 deletions(-) create mode 100644 public/assets/world/UI/intro-mission-notification.png diff --git a/public/assets/world/UI/intro-mission-notification.png b/public/assets/world/UI/intro-mission-notification.png new file mode 100644 index 0000000..fe207c3 --- /dev/null +++ b/public/assets/world/UI/intro-mission-notification.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a0c92f57ef14cfa7ec19c9e6a8ed32eaabb3f3db9ea57f1c1bcc6a0ad7c00825 +size 8467 diff --git a/src/components/ebike/Ebike.tsx b/src/components/ebike/Ebike.tsx index addef6d..3c9e3de 100644 --- a/src/components/ebike/Ebike.tsx +++ b/src/components/ebike/Ebike.tsx @@ -14,6 +14,7 @@ import { PLAYER_EYE_HEIGHT } from "@/data/player/playerConfig"; import { EBIKE_CAMERA_TRANSFORM, EBIKE_DROP_PLAYER_TRANSFORM, + EBIKE_WORLD_SCALE, EBIKE_WORLD_ROTATION_Y, } from "@/data/ebike/ebikeConfig"; import type { Vector3Tuple } from "@/types/three/three"; @@ -283,17 +284,18 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element { ref={groupRef} position={position} rotation={[0, EBIKE_WORLD_ROTATION_Y, 0]} + scale={EBIKE_WORLD_SCALE} > - + diff --git a/src/components/game/EbikeIntroSequence.tsx b/src/components/game/EbikeIntroSequence.tsx index b5d0c90..3351952 100644 --- a/src/components/game/EbikeIntroSequence.tsx +++ b/src/components/game/EbikeIntroSequence.tsx @@ -1,11 +1,13 @@ import { useEffect, useRef, useState } from "react"; +import * as THREE from "three"; import { MissionNotification } from "@/components/ui/MissionNotification"; import { EBIKE_BREAKDOWN_DIALOGUE_DELAY_MS, EBIKE_BREAKDOWN_DIALOGUE_ID, - EBIKE_INTRO_RIDE_DURATION_MS, + EBIKE_INTRO_BREAKDOWN_DISTANCE, EBIKE_SOUNDS, } from "@/data/ebike/ebikeConfig"; +import { INTRO_MISSION_NOTIFICATION_IMAGE_PATH } from "@/data/gameplay/missionNotifications"; import { AudioManager } from "@/managers/AudioManager"; import { useGameStore } from "@/managers/stores/useGameStore"; import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest"; @@ -18,6 +20,9 @@ export function EbikeIntroSequence(): React.JSX.Element | null { const completeIntro = useGameStore((state) => state.completeIntro); const [breakdownDialogueDone, setBreakdownDialogueDone] = useState(false); const hasStartedBreakdown = useRef(false); + const rideDistance = useRef(0); + const lastRidePosition = useRef(null); + const currentRidePosition = useRef(new THREE.Vector3()); useEffect(() => { if (introStep !== "await-ebike-mount" || movementMode !== "ebike") return; @@ -26,16 +31,45 @@ export function EbikeIntroSequence(): React.JSX.Element | null { }, [introStep, movementMode, setIntroStep]); useEffect(() => { - if (introStep !== "ebike-intro-ride") return undefined; + if (introStep !== "ebike-intro-ride") return; - const timeoutId = window.setTimeout(() => { - setIntroStep("ebike-breakdown"); - }, EBIKE_INTRO_RIDE_DURATION_MS); + rideDistance.current = 0; + lastRidePosition.current = null; + }, [introStep]); - return () => { - window.clearTimeout(timeoutId); + useEffect(() => { + if (introStep !== "ebike-intro-ride" || movementMode !== "ebike") { + return undefined; + } + + let animationFrameId = 0; + const tick = () => { + const parkedPosition = window.ebikeParkedPosition; + if (parkedPosition) { + currentRidePosition.current.set(...parkedPosition); + if (!lastRidePosition.current) { + lastRidePosition.current = currentRidePosition.current.clone(); + } else { + rideDistance.current += currentRidePosition.current.distanceTo( + lastRidePosition.current, + ); + lastRidePosition.current.copy(currentRidePosition.current); + } + + if (rideDistance.current >= EBIKE_INTRO_BREAKDOWN_DISTANCE) { + setIntroStep("ebike-breakdown"); + return; + } + } + + animationFrameId = window.requestAnimationFrame(tick); }; - }, [introStep, setIntroStep]); + + animationFrameId = window.requestAnimationFrame(tick); + return () => { + window.cancelAnimationFrame(animationFrameId); + }; + }, [introStep, movementMode, setIntroStep]); useEffect(() => { if (introStep !== "ebike-breakdown" || hasStartedBreakdown.current) { @@ -100,14 +134,27 @@ export function EbikeIntroSequence(): React.JSX.Element | null { } }, [introStep]); - if (introStep !== "await-ebike-mount" && introStep !== "ebike-intro-ride") { + if ( + introStep !== "reveal" && + introStep !== "await-ebike-mount" && + introStep !== "ebike-intro-ride" && + introStep !== "ebike-breakdown" + ) { return null; } + if (introStep === "ebike-breakdown") { + return ; + } + return ( ); } diff --git a/src/components/ui/MissionNotification.tsx b/src/components/ui/MissionNotification.tsx index 8b2d968..439ed9f 100644 --- a/src/components/ui/MissionNotification.tsx +++ b/src/components/ui/MissionNotification.tsx @@ -2,14 +2,19 @@ import { MISSION_NOTIFICATION_IMAGE_PATHS } from "@/data/gameplay/missionNotific import type { RepairMissionId } from "@/types/gameplay/repairMission"; interface MissionNotificationProps { - mission: RepairMissionId; + mission?: RepairMissionId; + imagePath?: string; visible?: boolean; } export function MissionNotification({ mission, + imagePath, visible = true, }: MissionNotificationProps): React.JSX.Element { + const src = + imagePath ?? (mission ? MISSION_NOTIFICATION_IMAGE_PATHS[mission] : ""); + return (
Nouvel objectif de mission diff --git a/src/data/ebike/ebikeConfig.ts b/src/data/ebike/ebikeConfig.ts index 12685d8..0930799 100644 --- a/src/data/ebike/ebikeConfig.ts +++ b/src/data/ebike/ebikeConfig.ts @@ -6,19 +6,20 @@ export interface CameraTransform { } export const EBIKE_CAMERA_TRANSFORM: CameraTransform = { - position: [-3.5, 6, 0], + position: [-2.6, 4.5, 0], rotation: [-10, -90, 0], }; export const EBIKE_DROP_PLAYER_TRANSFORM: CameraTransform = { - position: [0, 1.5, -3], + position: [0, 1.3, -2.25], rotation: [0, 0, 0], }; -export const EBIKE_WORLD_POSITION: Vector3Tuple = [61.5, 8.4, 62.4]; +export const EBIKE_WORLD_POSITION: Vector3Tuple = [55.8, 1.75, 60.2]; export const EBIKE_WORLD_ROTATION_Y = 2.4107; +export const EBIKE_WORLD_SCALE = 0.25; -export const EBIKE_INTRO_RIDE_DURATION_MS = 5000; +export const EBIKE_INTRO_BREAKDOWN_DISTANCE = 15; export const EBIKE_BREAKDOWN_DIALOGUE_DELAY_MS = 250; export const EBIKE_MAX_SPEED = 3; diff --git a/src/data/gameplay/missionNotifications.ts b/src/data/gameplay/missionNotifications.ts index 82caf13..167a063 100644 --- a/src/data/gameplay/missionNotifications.ts +++ b/src/data/gameplay/missionNotifications.ts @@ -1,5 +1,8 @@ import type { RepairMissionId } from "@/types/gameplay/repairMission"; +export const INTRO_MISSION_NOTIFICATION_IMAGE_PATH = + "/assets/world/UI/intro-mission-notification.png"; + export const MISSION_NOTIFICATION_IMAGE_PATHS: Record = { ebike: "/assets/world/UI/ebike-mission-notification.png", diff --git a/src/data/world/characters/characterConfig.ts b/src/data/world/characters/characterConfig.ts index 4c413e1..26776cb 100644 --- a/src/data/world/characters/characterConfig.ts +++ b/src/data/world/characters/characterConfig.ts @@ -43,7 +43,6 @@ export const CHARACTER_CONFIGS = { scale: [1.55, 1.55, 1.55], animations: ["idle", "walk"], defaultAnimation: "idle", - snapToTerrain: false, }, fermier: { id: "fermier", From aa2d411b0c0573c3f403c5eebb2345c3679d287e Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Mon, 1 Jun 2026 01:32:21 +0200 Subject: [PATCH 05/61] fix(world): stabilize lafabrik spawn and vegetation --- src/components/three/world/TerrainModel.tsx | 4 +- src/data/player/playerConfig.ts | 6 +- src/data/world/characters/characterConfig.ts | 1 - src/data/world/laFabrikConfig.ts | 58 +------------------ src/hooks/three/useTerrainHeight.ts | 8 --- src/world/GameMapCollision.tsx | 33 ++--------- src/world/World.tsx | 7 ++- src/world/characters/CharacterSystem.tsx | 7 +-- .../GeneratedMapNodeInstance.tsx | 3 +- src/world/player/Player.tsx | 11 +++- src/world/player/PlayerController.tsx | 8 ++- src/world/vegetation/VegetationSystem.tsx | 15 ++++- 12 files changed, 51 insertions(+), 110 deletions(-) diff --git a/src/components/three/world/TerrainModel.tsx b/src/components/three/world/TerrainModel.tsx index 5ba842a..4525ee2 100644 --- a/src/components/three/world/TerrainModel.tsx +++ b/src/components/three/world/TerrainModel.tsx @@ -3,7 +3,6 @@ import * as THREE from "three"; import { useGLTF } from "@react-three/drei"; import { useThree } from "@react-three/fiber"; import { TERRAIN_MODEL_PATH } from "@/data/world/terrainConfig"; -import { flattenLaFabrikTerrainFootprint } from "@/data/world/laFabrikConfig"; import type { Vector3Tuple } from "@/types/three/three"; import { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene"; @@ -66,10 +65,9 @@ export function TerrainModel({ const terrainModel = useMemo(() => { optimizeGLTFSceneTextures(scene, maxAnisotropy); const model = scene.clone(true); - flattenLaFabrikTerrainFootprint(model, position, rotation, scale); applyTerrainMaterialSettings(model, receiveShadow); return model; - }, [maxAnisotropy, position, receiveShadow, rotation, scale, scene]); + }, [maxAnisotropy, scene, receiveShadow]); useEffect(() => { onLoaded?.(); diff --git a/src/data/player/playerConfig.ts b/src/data/player/playerConfig.ts index a1aa8d3..b9a99ce 100644 --- a/src/data/player/playerConfig.ts +++ b/src/data/player/playerConfig.ts @@ -15,5 +15,9 @@ export const PLAYER_XZ_DAMPING_FACTOR = 8; export const PLAYER_FALL_RESPAWN_Y = -20; export const PLAYER_FALL_RESPAWN_DELAY = 3; -export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = LA_FABRIK_PLAYER_SPAWN; +export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [ + LA_FABRIK_PLAYER_SPAWN[0] + 5, + LA_FABRIK_PLAYER_SPAWN[1], + LA_FABRIK_PLAYER_SPAWN[2] + 5, +]; export const PLAYER_SPAWN_POSITION_PHYSICS: Vector3Tuple = [0, 3, 0]; diff --git a/src/data/world/characters/characterConfig.ts b/src/data/world/characters/characterConfig.ts index 26776cb..579cb55 100644 --- a/src/data/world/characters/characterConfig.ts +++ b/src/data/world/characters/characterConfig.ts @@ -20,7 +20,6 @@ export interface CharacterConfig { scale: Vector3Tuple; animations: readonly string[]; defaultAnimation: string; - snapToTerrain?: boolean; } export const CHARACTER_CONFIGS = { diff --git a/src/data/world/laFabrikConfig.ts b/src/data/world/laFabrikConfig.ts index 1eeb7b0..795eb08 100644 --- a/src/data/world/laFabrikConfig.ts +++ b/src/data/world/laFabrikConfig.ts @@ -1,4 +1,3 @@ -import * as THREE from "three"; import type { Vector3Tuple } from "@/types/three/three"; export const LA_FABRIK_CENTER: Vector3Tuple = [59.4973, 6.2746, 64.6354]; @@ -7,15 +6,10 @@ export const LA_FABRIK_HALF_EXTENTS = { x: 8.5, z: 7.5, } as const; -export const LA_FABRIK_FLOOR_Y = 6.3; -export const LA_FABRIK_PLAYER_SPAWN: Vector3Tuple = [59.5, 8.05, 64.64]; +export const LA_FABRIK_PLAYER_SPAWN: Vector3Tuple = [59.5, 7.8, 64.64]; +export const LA_FABRIK_INITIAL_LOOK_AT: Vector3Tuple = [58, 7.8, 62.5]; export const LA_FABRIK_INTERIOR_LIGHT_POSITION: Vector3Tuple = [59.5, 9, 64.64]; -const _terrainMatrix = new THREE.Matrix4(); -const _meshWorldMatrix = new THREE.Matrix4(); -const _inverseMeshWorldMatrix = new THREE.Matrix4(); -const _worldPosition = new THREE.Vector3(); - export function isInsideLaFabrikFootprint( x: number, z: number, @@ -33,51 +27,3 @@ export function isInsideLaFabrikFootprint( Math.abs(localZ) <= LA_FABRIK_HALF_EXTENTS.z + padding ); } - -export function flattenLaFabrikTerrainFootprint( - object: THREE.Object3D, - position: Vector3Tuple, - rotation: Vector3Tuple, - scale: Vector3Tuple, -): void { - _terrainMatrix.compose( - new THREE.Vector3(...position), - new THREE.Quaternion().setFromEuler(new THREE.Euler(...rotation)), - new THREE.Vector3(...scale), - ); - object.updateMatrixWorld(true); - - object.traverse((child) => { - if (!(child instanceof THREE.Mesh)) return; - const geometry = child.geometry; - const positions = geometry.getAttribute("position"); - if (!positions) return; - - _meshWorldMatrix.multiplyMatrices(_terrainMatrix, child.matrixWorld); - _inverseMeshWorldMatrix.copy(_meshWorldMatrix).invert(); - - for (let index = 0; index < positions.count; index++) { - _worldPosition - .fromBufferAttribute(positions, index) - .applyMatrix4(_meshWorldMatrix); - - if (!isInsideLaFabrikFootprint(_worldPosition.x, _worldPosition.z, 0.8)) { - continue; - } - - _worldPosition.y = Math.min(_worldPosition.y, LA_FABRIK_FLOOR_Y - 0.35); - _worldPosition.applyMatrix4(_inverseMeshWorldMatrix); - positions.setXYZ( - index, - _worldPosition.x, - _worldPosition.y, - _worldPosition.z, - ); - } - - positions.needsUpdate = true; - geometry.computeVertexNormals(); - geometry.computeBoundingBox(); - geometry.computeBoundingSphere(); - }); -} diff --git a/src/hooks/three/useTerrainHeight.ts b/src/hooks/three/useTerrainHeight.ts index 7e0a9d3..b4405f2 100644 --- a/src/hooks/three/useTerrainHeight.ts +++ b/src/hooks/three/useTerrainHeight.ts @@ -2,10 +2,6 @@ import { useMemo } from "react"; import { useGLTF } from "@react-three/drei"; import * as THREE from "three"; import { TERRAIN_MODEL_PATH } from "@/data/world/terrainConfig"; -import { - isInsideLaFabrikFootprint, - LA_FABRIK_FLOOR_Y, -} from "@/data/world/laFabrikConfig"; import type { Vector3Tuple } from "@/types/three/three"; import { getMapNodesByName } from "@/utils/map/loadMapSceneData"; @@ -70,10 +66,6 @@ function createTerrainHeightSampler( return { getHeight: (x, z) => { - if (isInsideLaFabrikFootprint(x, z, 0.6)) { - return LA_FABRIK_FLOOR_Y; - } - localOrigin.set(x, RAYCAST_Y, z).applyMatrix4(inverseTerrainMatrix); raycaster.set(localOrigin, localDirection); hits.length = 0; diff --git a/src/world/GameMapCollision.tsx b/src/world/GameMapCollision.tsx index f27955b..9d038d7 100644 --- a/src/world/GameMapCollision.tsx +++ b/src/world/GameMapCollision.tsx @@ -18,7 +18,6 @@ import { useTerrainHeightSampler, } from "@/hooks/three/useTerrainHeight"; import { WorldBoundsCollision } from "@/world/collision/WorldBoundsCollision"; -import { flattenLaFabrikTerrainFootprint } from "@/data/world/laFabrikConfig"; import type { MapNode } from "@/types/map/mapScene"; import type { OctreeReadyHandler } from "@/types/three/three"; import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading"; @@ -214,7 +213,7 @@ function CollisionModelInstance({ modelUrl: string; onLoaded: () => void; terrainHeight: TerrainHeightSampler; -}): React.JSX.Element | null { +}): React.JSX.Element { const { position, rotation, scale } = node; const normalizedScale = normalizeMapScale(scale); const { scene } = useLoggedGLTF(modelUrl, { @@ -224,46 +223,22 @@ function CollisionModelInstance({ scale: normalizedScale, }); const sceneInstance = useClonedObject(scene); - const collisionSceneInstance = useMemo(() => { - if (node.name === "terrain") { - flattenLaFabrikTerrainFootprint( - sceneInstance, - position, - rotation, - normalizedScale, - ); - } - return sceneInstance; - }, [node.name, normalizedScale, position, rotation, sceneInstance]); const collisionPosition = useMemo(() => { if (node.name === "terrain") return position; const [x, y, z] = position; const height = terrainHeight.getHeight(x, z); - const bottomOffset = getObjectBottomOffset( - collisionSceneInstance, - normalizedScale, - ); + const bottomOffset = getObjectBottomOffset(sceneInstance, normalizedScale); return [x, height !== null ? height + bottomOffset : y, z] as const; - }, [ - node.name, - normalizedScale, - position, - collisionSceneInstance, - terrainHeight, - ]); + }, [node.name, normalizedScale, position, sceneInstance, terrainHeight]); useEffect(() => { onLoaded(); }, [onLoaded]); - if (node.name === "lafabrik") { - return null; - } - return ( {mainState === "outro" ? : null} {mainState !== "intro" ? : null} - + ) : null} diff --git a/src/world/characters/CharacterSystem.tsx b/src/world/characters/CharacterSystem.tsx index d2f6672..3de23a4 100644 --- a/src/world/characters/CharacterSystem.tsx +++ b/src/world/characters/CharacterSystem.tsx @@ -3,18 +3,15 @@ import { AnimatedModel } from "@/components/three/models/AnimatedModel"; import { CHARACTER_CONFIGS, CHARACTER_IDS, - type CharacterConfig, type CharacterId, } from "@/data/world/characters/characterConfig"; import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight"; import { useCharacterDebugStore } from "@/managers/stores/useCharacterDebugStore"; function CharacterModel({ id }: { id: CharacterId }): React.JSX.Element { - const config: CharacterConfig = CHARACTER_CONFIGS[id]; + const config = CHARACTER_CONFIGS[id]; const state = useCharacterDebugStore((store) => store.characters[id]); - const snappedPosition = useTerrainSnappedPosition(state.position); - const position = - config.snapToTerrain === false ? state.position : snappedPosition; + const position = useTerrainSnappedPosition(state.position); return ( { camera.position.set(...spawnPosition); - }, [camera, spawnPosition]); + if (initialLookAt) camera.lookAt(...initialLookAt); + }, [camera, initialLookAt, spawnPosition]); return ( <> - + ); } diff --git a/src/world/player/PlayerController.tsx b/src/world/player/PlayerController.tsx index 4c74d52..62fbe14 100644 --- a/src/world/player/PlayerController.tsx +++ b/src/world/player/PlayerController.tsx @@ -75,6 +75,7 @@ const PLAYER_FLOOR_NORMAL_MIN = 0.15; const PLAYER_GROUND_SNAP_DISTANCE = 0.22; interface PlayerControllerProps { + initialLookAt?: Vector3Tuple | undefined; octree: Octree | null; spawnPosition: Vector3Tuple; } @@ -89,6 +90,7 @@ const _collisionCorrection = new THREE.Vector3(); function resetPlayerCapsule( capsule: Capsule, spawnPosition: Vector3Tuple, + initialLookAt: Vector3Tuple | undefined, camera: THREE.Camera, velocity: THREE.Vector3, ): void { @@ -100,6 +102,7 @@ function resetPlayerCapsule( capsule.end.set(...spawnPosition); velocity.set(0, 0, 0); camera.position.copy(capsule.end); + if (initialLookAt) camera.lookAt(...initialLookAt); } function createSpawnCapsule(spawnPosition: Vector3Tuple): Capsule { @@ -145,6 +148,7 @@ function getCapsuleFootY(capsule: Capsule): number { } export function PlayerController({ + initialLookAt, octree, spawnPosition, }: PlayerControllerProps): null { @@ -234,6 +238,7 @@ export function PlayerController({ resetPlayerCapsule( capsule.current, spawnPosition, + initialLookAt, camera, velocity.current, ); @@ -241,7 +246,7 @@ export function PlayerController({ onFloor.current = false; wantsJump.current = false; initializedRef.current = true; - }, [camera, spawnPosition]); + }, [camera, initialLookAt, spawnPosition]); useEffect(() => { movementLockedRef.current = movementLocked; @@ -339,6 +344,7 @@ export function PlayerController({ resetPlayerCapsule( capsule.current, spawnPosition, + initialLookAt, camera, velocity.current, ); diff --git a/src/world/vegetation/VegetationSystem.tsx b/src/world/vegetation/VegetationSystem.tsx index c340633..ef07b55 100644 --- a/src/world/vegetation/VegetationSystem.tsx +++ b/src/world/vegetation/VegetationSystem.tsx @@ -16,6 +16,7 @@ import { VEGETATION_TYPES, type VegetationType, } from "@/data/world/vegetationConfig"; +import { isInsideLaFabrikFootprint } from "@/data/world/laFabrikConfig"; import { createWorldInstanceChunks } from "@/utils/world/chunkInstances"; interface VegetationSystemProps { @@ -60,6 +61,15 @@ function createVegetationChunks( }); } +function removeLaFabrikVegetation( + instances: VegetationInstance[], +): VegetationInstance[] { + return instances.filter((instance) => { + const [x, , z] = instance.position; + return !isInsideLaFabrikFootprint(x, z, 1.2); + }); +} + export function VegetationSystem({ onlyMapName = null, streaming = true, @@ -90,7 +100,10 @@ export function VegetationSystem({ const entry = data.get(config.mapName); if (!entry || entry.instances.length === 0) return []; - return createVegetationChunks(type, entry.instances); + const instances = removeLaFabrikVegetation(entry.instances); + if (instances.length === 0) return []; + + return createVegetationChunks(type, instances); }); }, [data, groups, models, onlyMapName]); From 597ebcfbd4caabdf8a7719f4b490722eb11bb988 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Mon, 1 Jun 2026 01:32:29 +0200 Subject: [PATCH 06/61] fix(ebike): sync parked position from config --- src/components/ebike/Ebike.tsx | 25 ++++++++++++++++++------- src/data/ebike/ebikeConfig.ts | 6 +++--- src/world/GameStageContent.tsx | 10 ++++++++-- 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/src/components/ebike/Ebike.tsx b/src/components/ebike/Ebike.tsx index 3c9e3de..be6488c 100644 --- a/src/components/ebike/Ebike.tsx +++ b/src/components/ebike/Ebike.tsx @@ -10,7 +10,6 @@ import { useDebugFolder } from "@/hooks/debug/useDebugFolder"; import { useEbikeSounds } from "@/hooks/ebike/useEbikeSounds"; import { animateCameraTransformTransition } from "@/world/GameCinematics"; import { useGameStore } from "@/managers/stores/useGameStore"; -import { PLAYER_EYE_HEIGHT } from "@/data/player/playerConfig"; import { EBIKE_CAMERA_TRANSFORM, EBIKE_DROP_PLAYER_TRANSFORM, @@ -76,7 +75,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element { // Use ref for internal state, and state for debug visualization (to avoid ref access during render) const restingPositionRef = useRef([ position[0], - position[1] - PLAYER_EYE_HEIGHT, + position[1], position[2], ]); const restingRotationRef = useRef(EBIKE_WORLD_ROTATION_Y); @@ -85,11 +84,23 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element { // State for debug visualization (synced from refs during useFrame) const [showCameraPoints, setShowCameraPoints] = useState(true); const [debugRestingPosition, setDebugRestingPosition] = - useState([ - position[0], - position[1] - PLAYER_EYE_HEIGHT, - position[2], - ]); + useState([position[0], position[1], position[2]]); + + useEffect(() => { + if (movementMode === "ebike") return; + + restingPositionRef.current = position; + restingRotationRef.current = EBIKE_WORLD_ROTATION_Y; + lastGpsUpdatePos.current.set(...position); + + if (groupRef.current) { + groupRef.current.position.set(...position); + groupRef.current.rotation.set(0, EBIKE_WORLD_ROTATION_Y, 0); + } + + window.ebikeParkedPosition = position; + window.ebikeParkedRotation = EBIKE_WORLD_ROTATION_Y; + }, [movementMode, position]); useEffect(() => { if (model) { diff --git a/src/data/ebike/ebikeConfig.ts b/src/data/ebike/ebikeConfig.ts index 0930799..dbefe5e 100644 --- a/src/data/ebike/ebikeConfig.ts +++ b/src/data/ebike/ebikeConfig.ts @@ -15,9 +15,9 @@ export const EBIKE_DROP_PLAYER_TRANSFORM: CameraTransform = { rotation: [0, 0, 0], }; -export const EBIKE_WORLD_POSITION: Vector3Tuple = [55.8, 1.75, 60.2]; -export const EBIKE_WORLD_ROTATION_Y = 2.4107; -export const EBIKE_WORLD_SCALE = 0.25; +export const EBIKE_WORLD_POSITION: Vector3Tuple = [57.9, 6.3, 58.35]; +export const EBIKE_WORLD_ROTATION_Y = -2.5; +export const EBIKE_WORLD_SCALE = 0.35; export const EBIKE_INTRO_BREAKDOWN_DISTANCE = 15; export const EBIKE_BREAKDOWN_DIALOGUE_DELAY_MS = 250; diff --git a/src/world/GameStageContent.tsx b/src/world/GameStageContent.tsx index 016a864..d2a604b 100644 --- a/src/world/GameStageContent.tsx +++ b/src/world/GameStageContent.tsx @@ -14,7 +14,13 @@ import { useRepairMissionAnchorStore } from "@/managers/stores/useRepairMissionA import type { RepairMissionTriggerConfig } from "@/types/gameplay/repairMission"; import type { Vector3Tuple } from "@/types/three/three"; import { getRepairMissionPosition } from "@/utils/gameplay/repairMissionPosition"; -import { EBIKE_WORLD_POSITION } from "@/data/ebike/ebikeConfig"; +import { + EBIKE_WORLD_POSITION, + EBIKE_WORLD_ROTATION_Y, + EBIKE_WORLD_SCALE, +} from "@/data/ebike/ebikeConfig"; + +const EBIKE_CONFIG_KEY = `${EBIKE_WORLD_POSITION.join(",")}:${EBIKE_WORLD_ROTATION_Y}:${EBIKE_WORLD_SCALE}`; interface StageAnchorProps { color: string; @@ -82,7 +88,7 @@ export function GameStageContent(): React.JSX.Element { return ( <> {mainState === "intro" ? : null} - + {REPAIR_MISSION_POSITION_ENTRIES.map(({ mission }) => { const position = getRepairMissionPosition(mission, anchors); if (!position) return null; From bc862960a77d101c76a1bb713d1c59bcac45c699 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Mon, 1 Jun 2026 01:32:36 +0200 Subject: [PATCH 07/61] fix(settings): persist pause menu preferences --- src/managers/stores/useSettingsStore.ts | 71 ++++++---- src/managers/stores/useWorldSettingsStore.ts | 129 +++++++++++-------- src/utils/debug/Debug.ts | 15 ++- 3 files changed, 129 insertions(+), 86 deletions(-) diff --git a/src/managers/stores/useSettingsStore.ts b/src/managers/stores/useSettingsStore.ts index 5bafec8..c46a147 100644 --- a/src/managers/stores/useSettingsStore.ts +++ b/src/managers/stores/useSettingsStore.ts @@ -1,4 +1,5 @@ import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; import { AudioManager } from "@/managers/AudioManager"; import type { AudioCategory } from "@/managers/AudioManager"; import type { SubtitleLanguage } from "@/types/settings/settings"; @@ -33,6 +34,8 @@ const DEFAULT_SETTINGS: SettingsState = { subtitleLanguage: "fr", }; +const SETTINGS_STORAGE_KEY = "la-fabrik-settings"; + function clampVolume(volume: number): number { return Math.max(0, Math.min(1, volume)); } @@ -46,36 +49,50 @@ function setAudioCategoryVolume( return nextVolume; } -function applyDefaultAudioSettings(): void { - AudioManager.getInstance().setCategoryVolume( - "music", - DEFAULT_SETTINGS.musicVolume, - ); - AudioManager.getInstance().setCategoryVolume( - "sfx", - DEFAULT_SETTINGS.sfxVolume, - ); +function applyAudioSettings( + settings: Pick, +): void { + AudioManager.getInstance().setCategoryVolume("music", settings.musicVolume); + AudioManager.getInstance().setCategoryVolume("sfx", settings.sfxVolume); AudioManager.getInstance().setCategoryVolume( "dialogue", - DEFAULT_SETTINGS.dialogueVolume, + settings.dialogueVolume, ); } -applyDefaultAudioSettings(); +applyAudioSettings(DEFAULT_SETTINGS); -export const useSettingsStore = create()((set) => ({ - ...DEFAULT_SETTINGS, - setSettingsMenuOpen: (isSettingsMenuOpen) => set({ isSettingsMenuOpen }), - setMusicVolume: (volume) => - set({ musicVolume: setAudioCategoryVolume("music", volume) }), - setSfxVolume: (volume) => - set({ sfxVolume: setAudioCategoryVolume("sfx", volume) }), - setDialogueVolume: (volume) => - set({ dialogueVolume: setAudioCategoryVolume("dialogue", volume) }), - setSubtitlesEnabled: (subtitlesEnabled) => set({ subtitlesEnabled }), - setSubtitleLanguage: (subtitleLanguage) => set({ subtitleLanguage }), - resetSettings: () => { - applyDefaultAudioSettings(); - set(DEFAULT_SETTINGS); - }, -})); +export const useSettingsStore = create()( + persist( + (set) => ({ + ...DEFAULT_SETTINGS, + setSettingsMenuOpen: (isSettingsMenuOpen) => set({ isSettingsMenuOpen }), + setMusicVolume: (volume) => + set({ musicVolume: setAudioCategoryVolume("music", volume) }), + setSfxVolume: (volume) => + set({ sfxVolume: setAudioCategoryVolume("sfx", volume) }), + setDialogueVolume: (volume) => + set({ dialogueVolume: setAudioCategoryVolume("dialogue", volume) }), + setSubtitlesEnabled: (subtitlesEnabled) => set({ subtitlesEnabled }), + setSubtitleLanguage: (subtitleLanguage) => set({ subtitleLanguage }), + resetSettings: () => { + applyAudioSettings(DEFAULT_SETTINGS); + set(DEFAULT_SETTINGS); + }, + }), + { + name: SETTINGS_STORAGE_KEY, + storage: createJSONStorage(() => window.localStorage), + partialize: (state) => ({ + dialogueVolume: state.dialogueVolume, + musicVolume: state.musicVolume, + sfxVolume: state.sfxVolume, + subtitleLanguage: state.subtitleLanguage, + subtitlesEnabled: state.subtitlesEnabled, + }), + onRehydrateStorage: () => (state) => { + if (state) applyAudioSettings(state); + }, + }, + ), +); diff --git a/src/managers/stores/useWorldSettingsStore.ts b/src/managers/stores/useWorldSettingsStore.ts index db1d71a..bdf6ad5 100644 --- a/src/managers/stores/useWorldSettingsStore.ts +++ b/src/managers/stores/useWorldSettingsStore.ts @@ -1,4 +1,5 @@ import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; import { CLOUD_DEFAULTS, type CloudState } from "@/data/world/cloudConfig"; import { FOG_CONFIG, type FogState } from "@/data/world/fogConfig"; import { WIND_DEFAULTS, type WindState } from "@/data/world/windConfig"; @@ -46,73 +47,89 @@ const DEFAULT_STATE: WorldSettingsState = { graphics: { ...GRAPHICS_DEFAULTS }, }; -export const useWorldSettingsStore = create()((set) => ({ - ...DEFAULT_STATE, +const WORLD_SETTINGS_STORAGE_KEY = "la-fabrik-world-settings"; - setClouds: (cloudsUpdate) => - set((state) => ({ - clouds: { ...state.clouds, ...cloudsUpdate }, - })), +export const useWorldSettingsStore = create()( + persist( + (set) => ({ + ...DEFAULT_STATE, - setFog: (fogUpdate) => - set((state) => ({ - fog: { ...state.fog, ...fogUpdate }, - })), + setClouds: (cloudsUpdate) => + set((state) => ({ + clouds: { ...state.clouds, ...cloudsUpdate }, + })), - setWind: (windUpdate) => - set((state) => ({ - wind: { ...state.wind, ...windUpdate }, - })), + setFog: (fogUpdate) => + set((state) => ({ + fog: { ...state.fog, ...fogUpdate }, + })), - setWindSpeed: (speed) => - set((state) => ({ - wind: { ...state.wind, speed }, - })), + setWind: (windUpdate) => + set((state) => ({ + wind: { ...state.wind, ...windUpdate }, + })), - setWindDirection: (direction) => - set((state) => ({ - wind: { ...state.wind, direction }, - })), + setWindSpeed: (speed) => + set((state) => ({ + wind: { ...state.wind, speed }, + })), - setWindStrength: (strength) => - set((state) => ({ - wind: { ...state.wind, strength }, - })), + setWindDirection: (direction) => + set((state) => ({ + wind: { ...state.wind, direction }, + })), - setGraphics: (graphicsUpdate) => - set((state) => ({ - graphics: { ...state.graphics, ...graphicsUpdate }, - })), + setWindStrength: (strength) => + set((state) => ({ + wind: { ...state.wind, strength }, + })), - setGraphicsPreset: (preset) => - set((state) => ({ - graphics: { ...state.graphics, preset }, - })), + setGraphics: (graphicsUpdate) => + set((state) => ({ + graphics: { ...state.graphics, ...graphicsUpdate }, + })), - setDynamicGrass: (dynamicGrass) => - set((state) => ({ - graphics: { ...state.graphics, dynamicGrass }, - })), + setGraphicsPreset: (preset) => + set((state) => ({ + graphics: { ...state.graphics, preset }, + })), - setDynamicTrees: (dynamicTrees) => - set((state) => ({ - graphics: { ...state.graphics, dynamicTrees }, - })), + setDynamicGrass: (dynamicGrass) => + set((state) => ({ + graphics: { ...state.graphics, dynamicGrass }, + })), - setDynamicClouds: (dynamicClouds) => - set((state) => ({ - graphics: { ...state.graphics, dynamicClouds }, - })), + setDynamicTrees: (dynamicTrees) => + set((state) => ({ + graphics: { ...state.graphics, dynamicTrees }, + })), - setShadowsEnabled: (shadowsEnabled) => - set((state) => ({ - graphics: { ...state.graphics, shadowsEnabled }, - })), + setDynamicClouds: (dynamicClouds) => + set((state) => ({ + graphics: { ...state.graphics, dynamicClouds }, + })), - setGrassDensity: (grassDensity) => - set((state) => ({ - graphics: { ...state.graphics, grassDensity }, - })), + setShadowsEnabled: (shadowsEnabled) => + set((state) => ({ + graphics: { ...state.graphics, shadowsEnabled }, + })), - resetToDefaults: () => set(DEFAULT_STATE), -})); + setGrassDensity: (grassDensity) => + set((state) => ({ + graphics: { ...state.graphics, grassDensity }, + })), + + resetToDefaults: () => set(DEFAULT_STATE), + }), + { + name: WORLD_SETTINGS_STORAGE_KEY, + storage: createJSONStorage(() => window.localStorage), + partialize: (state) => ({ + clouds: state.clouds, + fog: state.fog, + graphics: state.graphics, + wind: state.wind, + }), + }, + ), +); diff --git a/src/utils/debug/Debug.ts b/src/utils/debug/Debug.ts index 026b9d1..e848f15 100644 --- a/src/utils/debug/Debug.ts +++ b/src/utils/debug/Debug.ts @@ -9,6 +9,7 @@ const DEBUG_CONTROLS_STORAGE_KEY = "la-fabrik-debug-controls"; interface StoredDebugControls { cameraMode: CameraMode; + handTrackingSource: HandTrackingSource; sceneMode: SceneMode; } @@ -39,6 +40,10 @@ function isSceneMode(value: unknown): value is SceneMode { return value === "game" || value === "physics"; } +function isHandTrackingSource(value: unknown): value is HandTrackingSource { + return value === "browser" || value === "backend"; +} + function getStoredDebugControls(): Partial { try { const rawValue = window.localStorage.getItem(DEBUG_CONTROLS_STORAGE_KEY); @@ -51,6 +56,9 @@ function getStoredDebugControls(): Partial { ...(isCameraMode(parsedValue.cameraMode) ? { cameraMode: parsedValue.cameraMode } : {}), + ...(isHandTrackingSource(parsedValue.handTrackingSource) + ? { handTrackingSource: parsedValue.handTrackingSource } + : {}), ...(isSceneMode(parsedValue.sceneMode) ? { sceneMode: parsedValue.sceneMode } : {}), @@ -94,7 +102,7 @@ export class Debug { this.controls = { cameraMode: storedControls.cameraMode ?? "player", fogEnabled: FOG_CONFIG.enabled, - handTrackingSource: "browser", + handTrackingSource: storedControls.handTrackingSource ?? "browser", showDebugOverlay: true, showHandTrackingSvg: false, showInteractionSpheres: false, @@ -159,7 +167,7 @@ export class Debug { .name("Source") .onChange((value: HandTrackingSource) => { this.controls.handTrackingSource = value; - this.emit(); + this.saveAndEmit(); }); } } @@ -246,7 +254,7 @@ export class Debug { setHandTrackingSource(value: HandTrackingSource): void { this.controls.handTrackingSource = value; - this.emit(); + this.saveAndEmit(); } getFogEnabled(): boolean { @@ -285,6 +293,7 @@ export class Debug { DEBUG_CONTROLS_STORAGE_KEY, JSON.stringify({ cameraMode: this.controls.cameraMode, + handTrackingSource: this.controls.handTrackingSource, sceneMode: this.controls.sceneMode, }), ); From dcf3a8564cc28e8d493b8b3a3d8e429237660476 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Mon, 1 Jun 2026 01:32:46 +0200 Subject: [PATCH 08/61] feat(ui): add narrator talkie overlay --- src/components/ui/GameUI.tsx | 2 + src/components/ui/TalkieDialogueOverlay.tsx | 100 +++++++++++++++++++ src/index.css | 105 ++++++++++++++++++++ 3 files changed, 207 insertions(+) create mode 100644 src/components/ui/TalkieDialogueOverlay.tsx diff --git a/src/components/ui/GameUI.tsx b/src/components/ui/GameUI.tsx index 687d71e..526c3b6 100644 --- a/src/components/ui/GameUI.tsx +++ b/src/components/ui/GameUI.tsx @@ -5,6 +5,7 @@ import { HandTrackingVisualizer } from "@/components/ui/HandTrackingVisualizer"; import { InteractPrompt } from "@/components/ui/InteractPrompt"; import { RepairMovementLockIndicator } from "@/components/ui/RepairMovementLockIndicator"; import { Subtitles } from "@/components/ui/Subtitles"; +import { TalkieDialogueOverlay } from "@/components/ui/TalkieDialogueOverlay"; export function GameUI(): React.JSX.Element { return ( @@ -15,6 +16,7 @@ export function GameUI(): React.JSX.Element { + ); diff --git a/src/components/ui/TalkieDialogueOverlay.tsx b/src/components/ui/TalkieDialogueOverlay.tsx new file mode 100644 index 0000000..d28c2cc --- /dev/null +++ b/src/components/ui/TalkieDialogueOverlay.tsx @@ -0,0 +1,100 @@ +import { Suspense, useEffect, useMemo, useRef } from "react"; +import { Canvas, useFrame } from "@react-three/fiber"; +import { useGLTF } from "@react-three/drei"; +import * as THREE from "three"; +import { useGameStore } from "@/managers/stores/useGameStore"; +import { useSubtitleStore } from "@/managers/stores/useSubtitleStore"; + +const TALKIE_MODEL_PATH = "/models/talkie/model.gltf"; +const TALKIE_REVEAL_STEPS = new Set([ + "reveal", + "await-ebike-mount", + "ebike-intro-ride", + "ebike-breakdown", + "completed", +]); + +function TalkieModel(): React.JSX.Element { + const { scene } = useGLTF(TALKIE_MODEL_PATH); + const model = useMemo(() => scene.clone(true), [scene]); + const groupRef = useRef(null); + + useEffect(() => { + model.traverse((child) => { + if (child instanceof THREE.Mesh) { + child.castShadow = false; + child.receiveShadow = false; + child.frustumCulled = false; + } + }); + }, [model]); + + useFrame(({ clock }) => { + if (!groupRef.current) return; + + const t = clock.getElapsedTime(); + groupRef.current.rotation.z = Math.sin(t * 22) * 0.025; + groupRef.current.position.y = Math.sin(t * 6) * 0.012; + }); + + return ( + + + + ); +} + +function TalkieSignalLines(): React.JSX.Element { + return ( + + ); +} + +export function TalkieDialogueOverlay(): React.JSX.Element | null { + const activeSubtitle = useSubtitleStore((state) => state.activeSubtitle); + const mainState = useGameStore((state) => state.mainState); + const introStep = useGameStore((state) => state.intro.currentStep); + const isAfterReveal = + mainState !== "intro" || TALKIE_REVEAL_STEPS.has(introStep); + const isNarratorDialogue = activeSubtitle?.speaker === "Narrateur"; + + if (!isAfterReveal || !isNarratorDialogue) return null; + + return ( + + ); +} + +useGLTF.preload(TALKIE_MODEL_PATH); diff --git a/src/index.css b/src/index.css index 0aed3d3..a716273 100644 --- a/src/index.css +++ b/src/index.css @@ -1237,6 +1237,111 @@ canvas { color: #f9a8d4; } +/* Dialogue talkie */ +.talkie-dialogue-overlay { + position: fixed; + left: clamp(12px, 2.2vw, 28px); + bottom: clamp(24px, 7vh, 76px); + z-index: 16; + width: clamp(120px, 13vw, 190px); + aspect-ratio: 1; + pointer-events: none; + transform: translateY(0); + transition: transform 180ms ease; +} + +.talkie-dialogue-overlay--raised { + transform: translateY(-10px); +} + +.talkie-dialogue-overlay__model-frame { + position: absolute; + inset: 0; + animation: talkie-radio-shake 1s ease-in-out infinite; + filter: drop-shadow(0 16px 22px rgba(0, 0, 0, 0.55)); +} + +.talkie-dialogue-overlay__model-frame canvas { + width: 100% !important; + height: 100% !important; +} + +.talkie-dialogue-overlay__signals { + position: absolute; + right: -26%; + bottom: 34%; + z-index: 2; + width: 58%; + height: 78%; + overflow: visible; + opacity: 0.8; + animation: talkie-signal-pulse 1s ease-in-out infinite; +} + +.talkie-dialogue-overlay__signals path { + fill: none; + stroke: rgba(235, 244, 255, 0.9); + stroke-linecap: round; + stroke-width: 5; + filter: drop-shadow(0 0 7px rgba(125, 211, 252, 0.72)); +} + +.talkie-dialogue-overlay__signals path:nth-child(2) { + animation-delay: 90ms; + opacity: 0.75; +} + +.talkie-dialogue-overlay__signals path:nth-child(3) { + animation-delay: 180ms; + opacity: 0.55; +} + +@keyframes talkie-radio-shake { + 0%, + 11%, + 23%, + 100% { + transform: translate3d(0, 0, 0) rotate(0deg); + } + + 3%, + 15%, + 27% { + transform: translate3d(-2px, 1px, 0) rotate(-1.7deg); + } + + 6%, + 18%, + 30% { + transform: translate3d(2px, -1px, 0) rotate(1.7deg); + } + + 9%, + 21%, + 33% { + transform: translate3d(-1px, 0, 0) rotate(-0.8deg); + } +} + +@keyframes talkie-signal-pulse { + 0%, + 100% { + opacity: 0.28; + transform: translate3d(-4px, 4px, 0) scale(0.92); + } + + 18%, + 38% { + opacity: 0.95; + transform: translate3d(0, 0, 0) scale(1); + } + + 60% { + opacity: 0.45; + transform: translate3d(4px, -6px, 0) scale(1.05); + } +} + /* In-game settings menu */ .game-settings-menu { position: fixed; From bafca5a9361d04d5b6c279dd75a28e303745c9e2 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Mon, 1 Jun 2026 09:45:45 +0200 Subject: [PATCH 09/61] fix(ui): apply mobile blocker globally --- src/App.tsx | 8 ++++++++ src/pages/site/page.tsx | 7 ------- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index c6ccffa..c90a626 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,15 @@ import { RouterProvider } from "@tanstack/react-router"; +import { SiteMobileBlocker } from "@/components/site/SiteMobileBlocker"; +import { useIsMobile } from "@/hooks/ui/useIsMobile"; import { router } from "@/router"; function App(): React.JSX.Element { + const isMobile = useIsMobile(); + + if (isMobile) { + return ; + } + return ; } diff --git a/src/pages/site/page.tsx b/src/pages/site/page.tsx index 722fea6..d591524 100644 --- a/src/pages/site/page.tsx +++ b/src/pages/site/page.tsx @@ -4,17 +4,10 @@ import { SiteWelcomeScreen } from "@/components/site/SiteWelcomeScreen"; import { SiteSituationScreen } from "@/components/site/SiteSituationScreen"; import { SiteNamingScreen } from "@/components/site/SiteNamingScreen"; import { SiteTransitionOverlay } from "@/components/site/SiteTransitionOverlay"; -import { SiteMobileBlocker } from "@/components/site/SiteMobileBlocker"; import { SiteLayout } from "@/components/site/SiteLayout"; -import { useIsMobile } from "@/hooks/ui/useIsMobile"; export function SitePage(): React.JSX.Element { const currentStep = useSiteStore((state) => state.currentStep); - const isMobile = useIsMobile(); - - if (isMobile) { - return ; - } if (currentStep === "disclaimer") { return ; From 6d58b90856af3c2cd02d0f70265fa09d2c4631f8 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Mon, 1 Jun 2026 10:45:07 +0200 Subject: [PATCH 10/61] fix(world): throttle shadows and tune high preset --- docs/technical/map-lod.md | 2 +- docs/technical/map-performance.md | 8 ++- docs/technical/scene-runtime.md | 6 ++ src/data/world/graphicsConfig.ts | 6 +- src/pages/page.tsx | 6 +- src/world/Lighting.tsx | 104 +++++++++++++++++++++++++----- src/world/SceneShadowWarmup.tsx | 6 ++ 7 files changed, 112 insertions(+), 26 deletions(-) diff --git a/docs/technical/map-lod.md b/docs/technical/map-lod.md index 952a09c..b43e6f5 100644 --- a/docs/technical/map-lod.md +++ b/docs/technical/map-lod.md @@ -25,7 +25,7 @@ Current behavior: | -------- | ------------------: | --- | ------------------------------------- | | `low` | 10m | On | Always use `*-LOD` models | | `medium` | 20m | On | Always use `*-LOD` models | -| `high` | Current default 50m | Off | Regular model up to 10m, then `*-LOD` | +| `high` | 35m | Off | Regular model up to 10m, then `*-LOD` | | `ultra` | 50m | Off | Regular model up to 20m, then `*-LOD` | The unload distance stays slightly larger than the load distance to avoid rapid mount/unmount flickering when the player stands near a boundary. diff --git a/docs/technical/map-performance.md b/docs/technical/map-performance.md index 853b890..5b509b4 100644 --- a/docs/technical/map-performance.md +++ b/docs/technical/map-performance.md @@ -158,9 +158,11 @@ Current runtime values: ```txt chunkSize: 35 -loadRadius: 45 -unloadRadius: 45 -updateInterval: 350ms +low load/unload radius: 10m / 18m +medium load/unload radius: 20m / 30m +high load/unload radius: 35m / 45m +ultra load/unload radius: 50m / 65m +updateInterval: 250ms fog near: 30 fog far: 45 ``` diff --git a/docs/technical/scene-runtime.md b/docs/technical/scene-runtime.md index 08f38e2..230497d 100644 --- a/docs/technical/scene-runtime.md +++ b/docs/technical/scene-runtime.md @@ -91,6 +91,12 @@ Activation des ombres -> Ombres prêtes -> Gameplay prêt This keeps the loading overlay visible until the renderer shadow map, shadow-casting light, and mounted scene graph have all been explicitly refreshed. +After the warmup, shadow maps switch back to manual refreshes driven by `Lighting`. +The sun still follows the player camera, but the shadow map is only marked dirty +when the camera has moved enough and a short refresh interval has elapsed. This +keeps shadows present after loading without paying for a full shadow render every +frame across the dense vegetation chunks. + The debug physics scene is ready when: ```ts diff --git a/src/data/world/graphicsConfig.ts b/src/data/world/graphicsConfig.ts index de081fc..be5b1e9 100644 --- a/src/data/world/graphicsConfig.ts +++ b/src/data/world/graphicsConfig.ts @@ -1,5 +1,3 @@ -import { CHUNK_CONFIG } from "@/data/world/chunkStreamingConfig"; - export const GRAPHICS_PRESET_KEYS = ["low", "medium", "high", "ultra"] as const; export type GraphicsPreset = (typeof GRAPHICS_PRESET_KEYS)[number]; @@ -32,8 +30,8 @@ export const GRAPHICS_PRESETS = { }, high: { label: "High", - chunkLoadRadius: CHUNK_CONFIG.loadRadius, - chunkUnloadRadius: CHUNK_CONFIG.unloadRadius, + chunkLoadRadius: 35, + chunkUnloadRadius: 45, fogEnabled: false, forceLodModels: false, lodHighDetailDistance: 10, diff --git a/src/pages/page.tsx b/src/pages/page.tsx index 6d9d752..c3f9b06 100644 --- a/src/pages/page.tsx +++ b/src/pages/page.tsx @@ -130,7 +130,8 @@ export function HomePage(): React.JSX.Element | null { gl.shadowMap.enabled = true; gl.shadowMap.type = THREE.PCFShadowMap; - gl.shadowMap.autoUpdate = true; + gl.shadowMap.autoUpdate = false; + gl.shadowMap.needsUpdate = true; // The browser hands us a WEBGL_lose_context extension we can use to // ask the GPU to restore the context after a loss. Without this the @@ -147,7 +148,8 @@ export function HomePage(): React.JSX.Element | null { const handleContextRestored = () => { gl.shadowMap.enabled = true; gl.shadowMap.type = THREE.PCFShadowMap; - gl.shadowMap.autoUpdate = true; + gl.shadowMap.autoUpdate = false; + gl.shadowMap.needsUpdate = true; logger.info("WebGL", "Context restored"); }; diff --git a/src/world/Lighting.tsx b/src/world/Lighting.tsx index 9c3cd45..acb1cfb 100644 --- a/src/world/Lighting.tsx +++ b/src/world/Lighting.tsx @@ -1,6 +1,15 @@ import { useEffect, useRef } from "react"; +import type { MutableRefObject } from "react"; import { useFrame, useThree } from "@react-three/fiber"; -import type { AmbientLight, DirectionalLight, Object3D } from "three"; +import { + PCFShadowMap, + Vector3, + type AmbientLight, + type Camera, + type DirectionalLight, + type Object3D, + type WebGLRenderer, +} from "three"; import { AMBIENT_INTENSITY_MAX, AMBIENT_INTENSITY_MIN, @@ -26,29 +35,84 @@ const SHADOW_MAP_SIZE = 2048; const SHADOW_CAMERA_SIZE = 95; const SHADOW_CAMERA_NEAR = 0.5; const SHADOW_CAMERA_FAR = 300; +const SHADOW_REFRESH_INTERVAL_MS = 180; +const SHADOW_REFRESH_DISTANCE = 0.75; +const SHADOW_REFRESH_DISTANCE_SQUARED = + SHADOW_REFRESH_DISTANCE * SHADOW_REFRESH_DISTANCE; + +function configureManualRendererShadows(gl: WebGLRenderer): void { + gl.shadowMap.enabled = true; + gl.shadowMap.type = PCFShadowMap; + gl.shadowMap.autoUpdate = false; + gl.shadowMap.needsUpdate = true; +} + +function configureSunShadow(sun: DirectionalLight, sunTarget: Object3D): void { + sun.target = sunTarget; + sun.shadow.autoUpdate = false; + sun.shadow.needsUpdate = true; + sun.shadow.mapSize.width = SHADOW_MAP_SIZE; + sun.shadow.mapSize.height = SHADOW_MAP_SIZE; + sun.shadow.camera.left = -SHADOW_CAMERA_SIZE; + sun.shadow.camera.right = SHADOW_CAMERA_SIZE; + sun.shadow.camera.top = SHADOW_CAMERA_SIZE; + sun.shadow.camera.bottom = -SHADOW_CAMERA_SIZE; + sun.shadow.camera.near = SHADOW_CAMERA_NEAR; + sun.shadow.camera.far = SHADOW_CAMERA_FAR; + sun.shadow.camera.updateProjectionMatrix(); +} + +function requestSunShadowRefresh({ + camera, + elapsedMs, + gl, + lastCameraPosition, + lastRefreshMs, + shadowHasInitialPosition, + sun, +}: { + camera: Camera; + elapsedMs: number; + gl: WebGLRenderer; + lastCameraPosition: Vector3; + lastRefreshMs: MutableRefObject; + shadowHasInitialPosition: MutableRefObject; + sun: DirectionalLight; +}): void { + if (elapsedMs - lastRefreshMs.current < SHADOW_REFRESH_INTERVAL_MS) { + return; + } + + const cameraMovedEnough = + !shadowHasInitialPosition.current || + lastCameraPosition.distanceToSquared(camera.position) >= + SHADOW_REFRESH_DISTANCE_SQUARED; + + if (!cameraMovedEnough) return; + + configureManualRendererShadows(gl); + sun.shadow.needsUpdate = true; + lastCameraPosition.copy(camera.position); + lastRefreshMs.current = elapsedMs; + shadowHasInitialPosition.current = true; +} export function Lighting(): React.JSX.Element { const camera = useThree((state) => state.camera); + const gl = useThree((state) => state.gl); const ambient = useRef(null); const sun = useRef(null); const sunTarget = useRef(null); + const lastShadowRefreshMs = useRef(-SHADOW_REFRESH_INTERVAL_MS); + const lastShadowCameraPosition = useRef(new Vector3()); + const shadowHasInitialPosition = useRef(false); useEffect(() => { if (!sun.current || !sunTarget.current) return; - sun.current.target = sunTarget.current; - sun.current.shadow.autoUpdate = true; - sun.current.shadow.needsUpdate = true; - sun.current.shadow.mapSize.width = SHADOW_MAP_SIZE; - sun.current.shadow.mapSize.height = SHADOW_MAP_SIZE; - sun.current.shadow.camera.left = -SHADOW_CAMERA_SIZE; - sun.current.shadow.camera.right = SHADOW_CAMERA_SIZE; - sun.current.shadow.camera.top = SHADOW_CAMERA_SIZE; - sun.current.shadow.camera.bottom = -SHADOW_CAMERA_SIZE; - sun.current.shadow.camera.near = SHADOW_CAMERA_NEAR; - sun.current.shadow.camera.far = SHADOW_CAMERA_FAR; - sun.current.shadow.camera.updateProjectionMatrix(); - }, []); + configureSunShadow(sun.current, sunTarget.current); + configureManualRendererShadows(gl); + }, [gl]); useDebugFolder("Lighting", (folder) => { folder.addColor(LIGHTING_STATE, "ambientColor").name("Ambient Color"); @@ -82,7 +146,7 @@ export function Lighting(): React.JSX.Element { .name("Sun Z"); }); - useFrame(() => { + useFrame(({ clock }) => { if (ambient.current) { ambient.current.color.set(LIGHTING_STATE.ambientColor); ambient.current.intensity = LIGHTING_STATE.ambientIntensity; @@ -99,7 +163,15 @@ export function Lighting(): React.JSX.Element { sun.current.color.set(LIGHTING_STATE.sunColor); sun.current.intensity = LIGHTING_STATE.sunIntensity; sun.current.updateMatrixWorld(); - sun.current.shadow.needsUpdate = true; + requestSunShadowRefresh({ + camera, + elapsedMs: clock.elapsedTime * 1000, + gl, + lastCameraPosition: lastShadowCameraPosition.current, + lastRefreshMs: lastShadowRefreshMs, + shadowHasInitialPosition, + sun: sun.current, + }); } }); diff --git a/src/world/SceneShadowWarmup.tsx b/src/world/SceneShadowWarmup.tsx index 4f994bd..d3a4897 100644 --- a/src/world/SceneShadowWarmup.tsx +++ b/src/world/SceneShadowWarmup.tsx @@ -45,6 +45,11 @@ function forceSceneShadowPass( }); } +function restoreManualShadowUpdates(gl: THREE.WebGLRenderer): void { + gl.shadowMap.autoUpdate = false; + gl.shadowMap.needsUpdate = true; +} + export function SceneShadowWarmup({ active, onReady, @@ -77,6 +82,7 @@ export function SceneShadowWarmup({ secondFrame = window.requestAnimationFrame(() => { forceSceneShadowPass(gl, scene); + restoreManualShadowUpdates(gl); invalidate(); onReady(); }); From 8aa755da7af425f18c69a016a3986d77ff39fdf1 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Mon, 1 Jun 2026 10:52:08 +0200 Subject: [PATCH 11/61] fix(model): replace electricienne animated asset --- public/models/electricienne-animated/Mat_baseColor.png | 3 --- public/models/electricienne-animated/Mat_diffuse.png | 3 +++ public/models/electricienne-animated/Mat_normal.png | 4 ++-- .../electricienne-animated/Mat_occlusionRoughnessMetallic.png | 3 --- public/models/electricienne-animated/electricienne.bin | 3 +++ public/models/electricienne-animated/model.bin | 3 --- public/models/electricienne-animated/model.gltf | 4 ++-- 7 files changed, 10 insertions(+), 13 deletions(-) delete mode 100644 public/models/electricienne-animated/Mat_baseColor.png create mode 100644 public/models/electricienne-animated/Mat_diffuse.png delete mode 100644 public/models/electricienne-animated/Mat_occlusionRoughnessMetallic.png create mode 100644 public/models/electricienne-animated/electricienne.bin delete mode 100644 public/models/electricienne-animated/model.bin diff --git a/public/models/electricienne-animated/Mat_baseColor.png b/public/models/electricienne-animated/Mat_baseColor.png deleted file mode 100644 index ba0de35..0000000 --- a/public/models/electricienne-animated/Mat_baseColor.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:142be230a66ff6bebe321b373e4785283624c3bb5f3565114a6acca6e2d056f2 -size 691735 diff --git a/public/models/electricienne-animated/Mat_diffuse.png b/public/models/electricienne-animated/Mat_diffuse.png new file mode 100644 index 0000000..ad54907 --- /dev/null +++ b/public/models/electricienne-animated/Mat_diffuse.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2e3b747fcd521e7a24d586345925525f10a33412ffb041a7adbf1fbc49fb2d08 +size 727199 diff --git a/public/models/electricienne-animated/Mat_normal.png b/public/models/electricienne-animated/Mat_normal.png index c72e6e2..66ca144 100644 --- a/public/models/electricienne-animated/Mat_normal.png +++ b/public/models/electricienne-animated/Mat_normal.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4f4580790707fc1fc505b3b725a523eac3e985353bc2e566a73ae2d983e87029 -size 1229760 +oid sha256:de14342c19b038a504840385d616c239851b90a289dac974fb6f93e7f3c03b99 +size 3374459 diff --git a/public/models/electricienne-animated/Mat_occlusionRoughnessMetallic.png b/public/models/electricienne-animated/Mat_occlusionRoughnessMetallic.png deleted file mode 100644 index dc6530c..0000000 --- a/public/models/electricienne-animated/Mat_occlusionRoughnessMetallic.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:2abdb28a5b27842d8958480f97357a3603b2c0ab46db9ff6bf08e474600c5d49 -size 650826 diff --git a/public/models/electricienne-animated/electricienne.bin b/public/models/electricienne-animated/electricienne.bin new file mode 100644 index 0000000..a8a646b --- /dev/null +++ b/public/models/electricienne-animated/electricienne.bin @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:df5e642df78807e9b7f41e240ba7a6aa6c27d0a7c12b5db42293e57a03bd1c2a +size 2893220 diff --git a/public/models/electricienne-animated/model.bin b/public/models/electricienne-animated/model.bin deleted file mode 100644 index 3a75871..0000000 --- a/public/models/electricienne-animated/model.bin +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:247407ee9bdb8fa5730a56df06872a224888cde1a4a0592c62d0157608b83f02 -size 2954520 diff --git a/public/models/electricienne-animated/model.gltf b/public/models/electricienne-animated/model.gltf index a6b86f9..790a4a0 100644 --- a/public/models/electricienne-animated/model.gltf +++ b/public/models/electricienne-animated/model.gltf @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:93c52e55e65710316e38a2c43703afba3e545d7f9a36cc99e766046a2d138691 -size 47280 +oid sha256:91bd4603d2e76e55b0eac402935c1ef8fa80af30528d277691309ac0d539e040 +size 86432 From bce7d11b660474670f6a165a64b5c238719fe811 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Mon, 1 Jun 2026 10:52:17 +0200 Subject: [PATCH 12/61] fix(ebike): snap parked model to terrain --- src/components/ebike/Ebike.tsx | 50 ++++++++++++++++++++++---------- src/data/ebike/ebikeConfig.ts | 2 +- src/data/player/playerConfig.ts | 6 ++-- src/data/world/laFabrikConfig.ts | 4 +-- 4 files changed, 41 insertions(+), 21 deletions(-) diff --git a/src/components/ebike/Ebike.tsx b/src/components/ebike/Ebike.tsx index be6488c..d5a32ac 100644 --- a/src/components/ebike/Ebike.tsx +++ b/src/components/ebike/Ebike.tsx @@ -8,6 +8,10 @@ import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; import { useClonedObject } from "@/hooks/three/useClonedObject"; import { useDebugFolder } from "@/hooks/debug/useDebugFolder"; import { useEbikeSounds } from "@/hooks/ebike/useEbikeSounds"; +import { + getObjectBottomOffset, + useTerrainHeightSampler, +} from "@/hooks/three/useTerrainHeight"; import { animateCameraTransformTransition } from "@/world/GameCinematics"; import { useGameStore } from "@/managers/stores/useGameStore"; import { @@ -32,6 +36,18 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element { position: position, }); const model = useClonedObject(scene); + const terrainHeight = useTerrainHeightSampler(); + const parkedPosition = useMemo(() => { + const [x, y, z] = position; + const height = terrainHeight.getHeight(x, z) ?? y; + const bottomOffset = getObjectBottomOffset(model, [ + EBIKE_WORLD_SCALE, + EBIKE_WORLD_SCALE, + EBIKE_WORLD_SCALE, + ]); + + return [x, height + bottomOffset, z]; + }, [model, position, terrainHeight]); const movementMode = useGameStore((state) => state.player.movementMode); const mainState = useGameStore((state) => state.mainState); const ebikeStep = useGameStore((state) => state.ebike.currentStep); @@ -64,19 +80,19 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element { y: number; z: number; }>({ - x: position[0], - y: position[1], - z: position[2], + x: parkedPosition[0], + y: parkedPosition[1], + z: parkedPosition[2], }); const lastGpsUpdatePos = useRef( - new THREE.Vector3(...position), + new THREE.Vector3(...parkedPosition), ); // Use ref for internal state, and state for debug visualization (to avoid ref access during render) const restingPositionRef = useRef([ - position[0], - position[1], - position[2], + parkedPosition[0], + parkedPosition[1], + parkedPosition[2], ]); const restingRotationRef = useRef(EBIKE_WORLD_ROTATION_Y); const forkRef = useRef(null); @@ -84,23 +100,27 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element { // State for debug visualization (synced from refs during useFrame) const [showCameraPoints, setShowCameraPoints] = useState(true); const [debugRestingPosition, setDebugRestingPosition] = - useState([position[0], position[1], position[2]]); + useState([ + parkedPosition[0], + parkedPosition[1], + parkedPosition[2], + ]); useEffect(() => { if (movementMode === "ebike") return; - restingPositionRef.current = position; + restingPositionRef.current = parkedPosition; restingRotationRef.current = EBIKE_WORLD_ROTATION_Y; - lastGpsUpdatePos.current.set(...position); + lastGpsUpdatePos.current.set(...parkedPosition); if (groupRef.current) { - groupRef.current.position.set(...position); + groupRef.current.position.set(...parkedPosition); groupRef.current.rotation.set(0, EBIKE_WORLD_ROTATION_Y, 0); } - window.ebikeParkedPosition = position; + window.ebikeParkedPosition = parkedPosition; window.ebikeParkedRotation = EBIKE_WORLD_ROTATION_Y; - }, [movementMode, position]); + }, [movementMode, parkedPosition]); useEffect(() => { if (model) { @@ -293,7 +313,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element { {!repairGameOwnsEbikeModel ? ( @@ -301,7 +321,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element { diff --git a/src/data/ebike/ebikeConfig.ts b/src/data/ebike/ebikeConfig.ts index dbefe5e..9c235e1 100644 --- a/src/data/ebike/ebikeConfig.ts +++ b/src/data/ebike/ebikeConfig.ts @@ -15,7 +15,7 @@ export const EBIKE_DROP_PLAYER_TRANSFORM: CameraTransform = { rotation: [0, 0, 0], }; -export const EBIKE_WORLD_POSITION: Vector3Tuple = [57.9, 6.3, 58.35]; +export const EBIKE_WORLD_POSITION: Vector3Tuple = [65, 0.8, 72]; export const EBIKE_WORLD_ROTATION_Y = -2.5; export const EBIKE_WORLD_SCALE = 0.35; diff --git a/src/data/player/playerConfig.ts b/src/data/player/playerConfig.ts index b9a99ce..e573afd 100644 --- a/src/data/player/playerConfig.ts +++ b/src/data/player/playerConfig.ts @@ -5,7 +5,7 @@ export const PLAYER_EYE_HEIGHT = 1.75; export const PLAYER_CAPSULE_RADIUS = 0.35; export const PLAYER_WALK_SPEED = 5; -export const PLAYER_EBIKE_SPEED = 20; +export const PLAYER_EBIKE_SPEED = 30; export const PLAYER_AIR_CONTROL_FACTOR = 0.35; export const PLAYER_JUMP_SPEED = 9; export const PLAYER_GRAVITY = 30; @@ -16,8 +16,8 @@ export const PLAYER_FALL_RESPAWN_Y = -20; export const PLAYER_FALL_RESPAWN_DELAY = 3; export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [ - LA_FABRIK_PLAYER_SPAWN[0] + 5, + LA_FABRIK_PLAYER_SPAWN[0] + 1, LA_FABRIK_PLAYER_SPAWN[1], - LA_FABRIK_PLAYER_SPAWN[2] + 5, + LA_FABRIK_PLAYER_SPAWN[2] - 1, ]; export const PLAYER_SPAWN_POSITION_PHYSICS: Vector3Tuple = [0, 3, 0]; diff --git a/src/data/world/laFabrikConfig.ts b/src/data/world/laFabrikConfig.ts index 795eb08..356116d 100644 --- a/src/data/world/laFabrikConfig.ts +++ b/src/data/world/laFabrikConfig.ts @@ -6,8 +6,8 @@ export const LA_FABRIK_HALF_EXTENTS = { x: 8.5, z: 7.5, } as const; -export const LA_FABRIK_PLAYER_SPAWN: Vector3Tuple = [59.5, 7.8, 64.64]; -export const LA_FABRIK_INITIAL_LOOK_AT: Vector3Tuple = [58, 7.8, 62.5]; +export const LA_FABRIK_PLAYER_SPAWN: Vector3Tuple = [59.5, 6.3, 64.64]; +export const LA_FABRIK_INITIAL_LOOK_AT: Vector3Tuple = [58, 7.3, 62.5]; export const LA_FABRIK_INTERIOR_LIGHT_POSITION: Vector3Tuple = [59.5, 9, 64.64]; export function isInsideLaFabrikFootprint( From bdc704fe8e7829fca64acd6404efcc0c7e7eb1f0 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Mon, 1 Jun 2026 10:52:28 +0200 Subject: [PATCH 13/61] feat(ui): show narrator video on talkie --- public/assets/world/UI/talkie-video.mp4 | 3 + src/components/ui/TalkieDialogueOverlay.tsx | 198 +++++++++++++++++--- src/index.css | 40 ++-- 3 files changed, 198 insertions(+), 43 deletions(-) create mode 100644 public/assets/world/UI/talkie-video.mp4 diff --git a/public/assets/world/UI/talkie-video.mp4 b/public/assets/world/UI/talkie-video.mp4 new file mode 100644 index 0000000..541ed63 --- /dev/null +++ b/public/assets/world/UI/talkie-video.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:15ee3608d9d07029de48373fbafa0cb36effd25a68d2ae9dade8aaf03512d164 +size 288088 diff --git a/src/components/ui/TalkieDialogueOverlay.tsx b/src/components/ui/TalkieDialogueOverlay.tsx index d28c2cc..7d75a4a 100644 --- a/src/components/ui/TalkieDialogueOverlay.tsx +++ b/src/components/ui/TalkieDialogueOverlay.tsx @@ -4,20 +4,81 @@ import { useGLTF } from "@react-three/drei"; import * as THREE from "three"; import { useGameStore } from "@/managers/stores/useGameStore"; import { useSubtitleStore } from "@/managers/stores/useSubtitleStore"; +import { GAME_STEPS } from "@/data/game/gameStateConfig"; +import type { Vector3Tuple } from "@/types/three/three"; const TALKIE_MODEL_PATH = "/models/talkie/model.gltf"; -const TALKIE_REVEAL_STEPS = new Set([ - "reveal", - "await-ebike-mount", - "ebike-intro-ride", - "ebike-breakdown", - "completed", -]); +const TALKIE_VIDEO_PATH = "/assets/world/UI/talkie-video.mp4"; +const TALKIE_FIRST_VISIBLE_STEP = "reveal"; +const TALKIE_FIRST_VISIBLE_STEP_INDEX = GAME_STEPS.indexOf( + TALKIE_FIRST_VISIBLE_STEP, +); -function TalkieModel(): React.JSX.Element { +const TALKIE_REST_Y = -1.55; +const TALKIE_ACTIVE_Y = -0.92; +const TALKIE_BASE_ROTATION: Vector3Tuple = [0.08, -0.52, -0.04]; +const TALKIE_FLOAT_ROTATION_AMPLITUDE = THREE.MathUtils.degToRad(2.2); +const TALKIE_FLOAT_Y_AMPLITUDE = 0.055; +const TALKIE_SCREEN_TEXTURE_SIZE = 512; + +interface TalkieModelProps { + active: boolean; +} + +interface TalkieVideoResources { + canvas: HTMLCanvasElement; + context: CanvasRenderingContext2D | null; + material: THREE.MeshBasicMaterial; + texture: THREE.CanvasTexture; + video: HTMLVideoElement; +} + +function createTalkieVideoResources(): TalkieVideoResources { + const video = document.createElement("video"); + video.src = TALKIE_VIDEO_PATH; + video.crossOrigin = "anonymous"; + video.loop = true; + video.muted = true; + video.playsInline = true; + video.preload = "auto"; + + const canvas = document.createElement("canvas"); + canvas.width = TALKIE_SCREEN_TEXTURE_SIZE; + canvas.height = TALKIE_SCREEN_TEXTURE_SIZE; + const context = canvas.getContext("2d"); + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + texture.flipY = false; + texture.needsUpdate = true; + const material = new THREE.MeshBasicMaterial({ + map: texture, + toneMapped: false, + }); + + return { canvas, context, material, texture, video }; +} + +function TalkieModel({ active }: TalkieModelProps): React.JSX.Element { const { scene } = useGLTF(TALKIE_MODEL_PATH); const model = useMemo(() => scene.clone(true), [scene]); const groupRef = useRef(null); + const screenRef = useRef(null); + const originalScreenMaterialRef = useRef(null); + const videoResourcesRef = useRef(null); + + useEffect(() => { + const videoResources = createTalkieVideoResources(); + videoResourcesRef.current = videoResources; + + return () => { + videoResources.video.pause(); + videoResources.video.removeAttribute("src"); + videoResources.video.load(); + videoResources.texture.dispose(); + videoResources.material.dispose(); + videoResourcesRef.current = null; + }; + }, []); useEffect(() => { model.traverse((child) => { @@ -27,38 +88,119 @@ function TalkieModel(): React.JSX.Element { child.frustumCulled = false; } }); + + const screen = model.getObjectByName("écran"); + if (screen instanceof THREE.Mesh) { + screenRef.current = screen; + originalScreenMaterialRef.current = Array.isArray(screen.material) + ? (screen.material[0] ?? null) + : screen.material; + } }, [model]); + useEffect(() => { + const screen = screenRef.current; + const originalMaterial = originalScreenMaterialRef.current; + const videoResources = videoResourcesRef.current; + + if (!videoResources) return; + + if (screen) { + screen.material = active + ? videoResources.material + : (originalMaterial ?? videoResources.material); + } + + if (active) { + void videoResources.video.play(); + return; + } + + videoResources.video.pause(); + }, [active]); + useFrame(({ clock }) => { if (!groupRef.current) return; const t = clock.getElapsedTime(); - groupRef.current.rotation.z = Math.sin(t * 22) * 0.025; - groupRef.current.position.y = Math.sin(t * 6) * 0.012; + const floatY = Math.sin(t * 1.2) * TALKIE_FLOAT_Y_AMPLITUDE; + const targetY = (active ? TALKIE_ACTIVE_Y : TALKIE_REST_Y) + floatY; + groupRef.current.position.y = THREE.MathUtils.lerp( + groupRef.current.position.y, + targetY, + 0.14, + ); + + groupRef.current.rotation.x = + TALKIE_BASE_ROTATION[0] + + Math.sin(t * 0.7) * TALKIE_FLOAT_ROTATION_AMPLITUDE; + groupRef.current.rotation.y = + TALKIE_BASE_ROTATION[1] + + Math.sin(t * 0.55) * TALKIE_FLOAT_ROTATION_AMPLITUDE; + groupRef.current.rotation.z = + TALKIE_BASE_ROTATION[2] + + Math.sin(t * 0.8) * TALKIE_FLOAT_ROTATION_AMPLITUDE; + + const videoResources = videoResourcesRef.current; + + if (active && videoResources?.context) { + const { canvas, context, texture, video } = videoResources; + context.fillStyle = "#02040a"; + context.fillRect(0, 0, canvas.width, canvas.height); + + if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) { + const videoAspect = video.videoWidth / video.videoHeight; + const canvasAspect = canvas.width / canvas.height; + const drawWidth = + videoAspect > canvasAspect + ? canvas.width + : canvas.height * videoAspect; + const drawHeight = + videoAspect > canvasAspect + ? canvas.width / videoAspect + : canvas.height; + const drawX = (canvas.width - drawWidth) / 2; + const drawY = (canvas.height - drawHeight) / 2; + + context.drawImage(video, drawX, drawY, drawWidth, drawHeight); + } + + texture.needsUpdate = true; + } }); return ( - + ); } -function TalkieSignalLines(): React.JSX.Element { +interface TalkieSignalLinesProps { + side: "left" | "right"; +} + +function TalkieSignalLines({ + side, +}: TalkieSignalLinesProps): React.JSX.Element { return ( ); } @@ -67,21 +209,23 @@ export function TalkieDialogueOverlay(): React.JSX.Element | null { const activeSubtitle = useSubtitleStore((state) => state.activeSubtitle); const mainState = useGameStore((state) => state.mainState); const introStep = useGameStore((state) => state.intro.currentStep); - const isAfterReveal = - mainState !== "intro" || TALKIE_REVEAL_STEPS.has(introStep); + const introStepIndex = GAME_STEPS.indexOf(introStep); + const hasTalkieBeenRevealed = + mainState !== "intro" || introStepIndex >= TALKIE_FIRST_VISIBLE_STEP_INDEX; const isNarratorDialogue = activeSubtitle?.speaker === "Narrateur"; - if (!isAfterReveal || !isNarratorDialogue) return null; + if (!hasTalkieBeenRevealed) return null; return (