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..20848ab 100644 --- a/docs/technical/scene-runtime.md +++ b/docs/technical/scene-runtime.md @@ -74,22 +74,32 @@ It tracks: - `gameMapLoaded`: map data and visible map nodes settled - `gameStageLoaded`: Rapier gameplay stage mounted - `showGameStage`: true when the map is ready enough to mount gameplay content -- `shadowsReady`: renderer, shadow lights, and scene matrices have been forced once after the scene is mounted -- `gameplayReady`: true when map, stage, octree, and the shadow warmup are all ready +- `gameplayReady`: true when map, stage, and octree are all ready -The base game-scene readiness condition before the shadow warmup is: +The game-scene readiness condition is: ```ts showGameStage && gameStageLoaded && octree !== null; ``` -After that condition is met, `SceneShadowWarmup` runs one final loading step: +Shadows are configured once when `Lighting` mounts (renderer `shadowMap.enabled`, sun +`shadow.autoUpdate = true`, bias and frustum from `SHADOW_CONFIG` in +`src/data/world/lightingConfig.ts`). The shadow map then refreshes every frame and +follows the player camera through the sun's `target`. The earlier `SceneShadowWarmup` +step has been removed — the visible loading overlay no longer waits for a forced +shadow refresh because `autoUpdate` covers steady-state rendering. -```txt -Activation des ombres -> Ombres prêtes -> Gameplay prêt -``` +### Avoiding global scene remounts -This keeps the loading overlay visible until the renderer shadow map, shadow-casting light, and mounted scene graph have all been explicitly refreshed. +Heavy stage components (`GameStageContent`, `Player`, dialogues) load assets via +`useGLTF`/`useTexture` without preload (e.g. `EbikeSpeedometer` calls `useTexture` +when the bike mounts). To prevent any late suspension from bubbling up to the +root `` boundary in `src/pages/page.tsx` and unmounting the entire +world (which would trigger a redundant octree rebuild and shadow re-config), the +game stage block and the spawn-player block are wrapped in their own +`` boundaries inside `src/world/World.tsx`. Any new +sibling that suspends late should be added inside one of these boundaries or get +its own. The debug physics scene is ready when: diff --git a/docs/technical/three-debugging.md b/docs/technical/three-debugging.md index 1e7c8aa..467239c 100644 --- a/docs/technical/three-debugging.md +++ b/docs/technical/three-debugging.md @@ -20,3 +20,63 @@ If DevTools still opens a bundled file, stop the dev server, clear Vite's cached rm -rf node_modules/.vite npm run dev:three-debug ``` + +## Visual debug toggles + +The `Debug` folder of the runtime debug GUI exposes inspection toggles backed by +`src/managers/stores/useDebugVisualsStore.ts`: + +- **Show Player Model** — renders the main character GLTF in front of the + current camera (`src/components/debug/DebugPlayerModel.tsx`). The model is + positioned in camera-local space so it stays visible regardless of pitch. +- **Show Octree** — overlays the collision octree as colored line segments, + one wireframe per spatial cell (`src/components/debug/DebugOctreeVisualization.tsx`). + Cells are colored by depth. Use it to inspect collision precision around + doorways or passages. +- **Octree Max Depth** — caps how deep the octree visualization recurses + (default 6). Increase to see leaf-level subdivisions; decrease to keep the + scene readable when the tree is large. + +The octree visualization reads the live `Octree` instance from `World`. The +mesh uses `depthTest: false` and a high `renderOrder`, so cells stay visible +through opaque geometry. + +## Shadow rendering intermittence + +Shadows occasionally failed to render on initial load and could disappear +mid-session even though the `Lighting` configuration ran to completion. The +fix has two layers: + +### Per-frame refresh (steady state) + +The sun follows the camera, so its world matrix is dirty every frame. With +`shadow.autoUpdate` alone, three.js can skip the shadow map re-render on a +frame where the matrix update has happened but the renderer's internal dirty +tracking does not pick it up. To prevent that, `Lighting.useFrame` sets +`sun.shadow.needsUpdate = true` after the per-frame matrix updates. Shadow +config is centralized in `src/data/world/lightingConfig.ts` (`bias=0`, +`normalBias=0`, `cameraSize=95`). + +### Mount-time shadow map reallocation (`useShadowMapWarmup`) + +The merged static map and other GLTFs mount imperatively after `Lighting`, +so the shadow render target ends up linked to a renderer state that pre-dates +the final scene. Materials compiled at that point bake a "no shadow map" +permutation into their shader program and silently fail to render shadows +until a WebGL context-restore cycle (the kind triggered by Chrome DevTools +in `?debug` runs) reallocates everything. + +`src/hooks/three/useShadowMapWarmup.ts` replays that cycle programmatically +without the cost of a full context loss. It runs a `useFrame` watchdog that +samples the scene mesh count every 6 frames; once the count has been stable +for ~1 s (or after a 5 s safety cap), it: + +1. Disposes the directional light shadow map and nulls it. three.js + reallocates the render target on the next render at the configured + `mapSize`. +2. Marks every material's `needsUpdate = true`, forcing a shader recompile + that rebinds every program to the freshly created shadow sampler. +3. Forces a single shadow pass and invalidates the renderer. + +The watchdog runs once per mount and adds a single traversal every 6 frames +during the warmup window, after which it self-terminates. diff --git a/public/assets/bg-site.png b/public/assets/bg-site.png deleted file mode 100644 index d3a1e36..0000000 --- a/public/assets/bg-site.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:9e32d667a6e17ca75437f7fde9bad637bfd691543f14e48d7bca82f95f993414 -size 1469658 diff --git a/public/assets/bg-site.webp b/public/assets/bg-site.webp new file mode 100644 index 0000000..a27f461 --- /dev/null +++ b/public/assets/bg-site.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2912bc92d3b01717b1a93858bd954cb444ecd093c64b1bd1bafe839171b45fa6 +size 1191438 diff --git a/public/assets/loader/Loader-1.png b/public/assets/loader/Loader-1.png new file mode 100644 index 0000000..92bdd46 --- /dev/null +++ b/public/assets/loader/Loader-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:122ad8e04cbad8cd27a6e77990576b7ece0d8a2db1a4aeffd15d658ba04e79f1 +size 52441 diff --git a/public/assets/loader/Loader-2.png b/public/assets/loader/Loader-2.png new file mode 100644 index 0000000..380494d --- /dev/null +++ b/public/assets/loader/Loader-2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7df0a60a64d393125d018ad292dfeece4162d7939dfc664680f0239601bb1ed2 +size 49899 diff --git a/public/assets/loader/Loader-3.png b/public/assets/loader/Loader-3.png new file mode 100644 index 0000000..601c255 --- /dev/null +++ b/public/assets/loader/Loader-3.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:382decf495b43eafe7bc8b5d65e22003f2efb9b5df8b92143c980945afe0720d +size 48379 diff --git a/public/assets/loader/Loader-4.png b/public/assets/loader/Loader-4.png new file mode 100644 index 0000000..b428ccc --- /dev/null +++ b/public/assets/loader/Loader-4.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aed2d260d03e4f4d68dbfcc018cc70973d0806f8c40e6c209b33a33b0be4ac26 +size 50374 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/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/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/public/favicon.ico b/public/favicon.ico index 23e6e2b..e13f6d9 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ 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 diff --git a/public/models/lafabrik/model.glb b/public/models/lafabrik/model.glb index f540ba6..cdcbbca 100644 --- a/public/models/lafabrik/model.glb +++ b/public/models/lafabrik/model.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:828356d45f504ce2bfcb3007267e6c7c31aea58c5f0b417f38c0e67475fad0e6 -size 88587392 +oid sha256:c9e4e66d77ee691dc442e91adf0116dfc63a60031aeffab9c601ae34bfc06e1b +size 88587196 diff --git a/public/models/persoprincipal/model.gltf b/public/models/persoprincipal/model.gltf index faa8ff2..c9ec264 100644 --- a/public/models/persoprincipal/model.gltf +++ b/public/models/persoprincipal/model.gltf @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:955c5c954519afef152989a1fb4d187e115fd5893a6a8f7119e4818bf87dc3ac +oid sha256:7e3ed9f4faf333db1ea120580ff4ac6f9c072ac072093ff64d0ade2a1cdd7c2f size 3136 diff --git a/public/sounds/dialogue/electricienne_aprèsmontage.mp3 b/public/sounds/dialogue/electricienne_aprèsmontage.mp3 new file mode 100644 index 0000000..b7a5af9 --- /dev/null +++ b/public/sounds/dialogue/electricienne_aprèsmontage.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0cdec23a1d25330d9db9a91384b51776996a87da52d59ba264022a01aa794903 +size 46191 diff --git a/public/sounds/dialogue/electricienne_aurevoir.mp3 b/public/sounds/dialogue/electricienne_aurevoir.mp3 new file mode 100644 index 0000000..190200c --- /dev/null +++ b/public/sounds/dialogue/electricienne_aurevoir.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1e6d2a2c28499e2ed6806335e5037f515c6ba790c7be46b56dab4e3b717d854e +size 31216 diff --git a/public/sounds/dialogue/electricienne_welcome.mp3 b/public/sounds/dialogue/electricienne_welcome.mp3 new file mode 100644 index 0000000..1da1fcf --- /dev/null +++ b/public/sounds/dialogue/electricienne_welcome.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6d0a24a7d2a68fdb30bd4776161cd9d9c17383a1d2a5f0190d0667b7f51f4be9 +size 72661 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/components/debug/DebugOctreeVisualization.tsx b/src/components/debug/DebugOctreeVisualization.tsx new file mode 100644 index 0000000..8ff8ae7 --- /dev/null +++ b/src/components/debug/DebugOctreeVisualization.tsx @@ -0,0 +1,173 @@ +import { useMemo } from "react"; +import { Box3, BufferAttribute, BufferGeometry } from "three"; +import type { Octree } from "three-stdlib"; +import { + LA_FABRIK_CENTER, + isInsideLaFabrikFootprint, +} from "@/data/world/laFabrikConfig"; +import { useDebugVisualsStore } from "@/managers/stores/useDebugVisualsStore"; + +interface DebugOctreeVisualizationProps { + octree: Octree | null; +} + +interface OctreeNodeBox { + box: Box3; + depth: number; + triangleCount: number; + isLeaf: boolean; +} + +interface CollectOptions { + minDepth: number; + maxDepth: number; + leavesOnly: boolean; + fabrikOnly: boolean; +} + +const FABRIK_FILTER_PADDING = 1.5; +const FABRIK_FILTER_VERTICAL = 8; + +const BOX_VERTEX_INDEX_PAIRS: ReadonlyArray = [ + [0, 1], + [1, 3], + [3, 2], + [2, 0], + [4, 5], + [5, 7], + [7, 6], + [6, 4], + [0, 4], + [1, 5], + [2, 6], + [3, 7], +]; + +function boxIntersectsFabrik(box: Box3): boolean { + if (box.max.y < LA_FABRIK_CENTER[1] - FABRIK_FILTER_VERTICAL) return false; + if (box.min.y > LA_FABRIK_CENTER[1] + FABRIK_FILTER_VERTICAL) return false; + + // Sample box corners + center on XZ plane against the rotated fabrik footprint. + const samples: ReadonlyArray = [ + [box.min.x, box.min.z], + [box.min.x, box.max.z], + [box.max.x, box.min.z], + [box.max.x, box.max.z], + [(box.min.x + box.max.x) * 0.5, (box.min.z + box.max.z) * 0.5], + ]; + for (const [x, z] of samples) { + if (isInsideLaFabrikFootprint(x, z, FABRIK_FILTER_PADDING)) return true; + } + return false; +} + +function collectOctreeBoxes( + node: Octree, + options: CollectOptions, + depth = 0, + acc: OctreeNodeBox[] = [], +): OctreeNodeBox[] { + if (depth > options.maxDepth) return acc; + + const isLeaf = node.subTrees.length === 0; + const passesDepth = depth >= options.minDepth; + const passesLeafFilter = !options.leavesOnly || isLeaf; + const hasTriangles = node.triangles.length > 0; + const passesFabrikFilter = + !options.fabrikOnly || boxIntersectsFabrik(node.box); + + if (passesDepth && passesLeafFilter && hasTriangles && passesFabrikFilter) { + acc.push({ + box: node.box, + depth, + triangleCount: node.triangles.length, + isLeaf, + }); + } + + for (const sub of node.subTrees) { + collectOctreeBoxes(sub, options, depth + 1, acc); + } + + return acc; +} + +function buildOctreeLineGeometry( + nodes: readonly OctreeNodeBox[], +): BufferGeometry { + const positionsBuffer = new Float32Array( + nodes.length * BOX_VERTEX_INDEX_PAIRS.length * 2 * 3, + ); + + const corners: [number, number, number][] = Array.from({ length: 8 }, () => [ + 0, 0, 0, + ]); + + let positionsOffset = 0; + + for (const node of nodes) { + const { min, max } = node.box; + + corners[0] = [min.x, min.y, min.z]; + corners[1] = [max.x, min.y, min.z]; + corners[2] = [min.x, max.y, min.z]; + corners[3] = [max.x, max.y, min.z]; + corners[4] = [min.x, min.y, max.z]; + corners[5] = [max.x, min.y, max.z]; + corners[6] = [min.x, max.y, max.z]; + corners[7] = [max.x, max.y, max.z]; + + for (const [a, b] of BOX_VERTEX_INDEX_PAIRS) { + const ca = corners[a]!; + const cb = corners[b]!; + positionsBuffer[positionsOffset++] = ca[0]; + positionsBuffer[positionsOffset++] = ca[1]; + positionsBuffer[positionsOffset++] = ca[2]; + positionsBuffer[positionsOffset++] = cb[0]; + positionsBuffer[positionsOffset++] = cb[1]; + positionsBuffer[positionsOffset++] = cb[2]; + } + } + + const geometry = new BufferGeometry(); + geometry.setAttribute("position", new BufferAttribute(positionsBuffer, 3)); + return geometry; +} + +export function DebugOctreeVisualization({ + octree, +}: DebugOctreeVisualizationProps): React.JSX.Element | null { + const showOctree = useDebugVisualsStore((state) => state.showOctree); + const minDepth = useDebugVisualsStore((state) => state.octreeMinDepth); + const maxDepth = useDebugVisualsStore((state) => state.octreeMaxDepth); + const leavesOnly = useDebugVisualsStore((state) => state.octreeLeavesOnly); + const opacity = useDebugVisualsStore((state) => state.octreeOpacity); + const fabrikOnly = useDebugVisualsStore((state) => state.octreeFabrikOnly); + + const geometry = useMemo(() => { + if (!octree || !showOctree) return null; + const boxes = collectOctreeBoxes(octree, { + minDepth, + maxDepth, + leavesOnly, + fabrikOnly, + }); + if (boxes.length === 0) return null; + return buildOctreeLineGeometry(boxes); + }, [fabrikOnly, leavesOnly, maxDepth, minDepth, octree, showOctree]); + + if (!geometry) return null; + + return ( + + + + + ); +} diff --git a/src/components/debug/DebugPlayerModel.tsx b/src/components/debug/DebugPlayerModel.tsx new file mode 100644 index 0000000..fd4d6a3 --- /dev/null +++ b/src/components/debug/DebugPlayerModel.tsx @@ -0,0 +1,58 @@ +import { useEffect, useMemo, useRef } from "react"; +import * as THREE from "three"; +import { useFrame } from "@react-three/fiber"; +import { useGLTF } from "@react-three/drei"; + +const MODEL_PATH = "/models/persoprincipal/model.gltf"; +// Offset expressed in the camera's local space: +// - x: horizontal (0 = centered) +// - y: vertical relative to camera eye (negative = below) +// - z: forward (negative = in front of the camera) +const LOCAL_OFFSET = new THREE.Vector3(0, -1, -2.5); + +const eulerHelper = new THREE.Euler(); + +export function DebugPlayerModel(): React.JSX.Element { + const groupRef = useRef(null); + const { scene } = useGLTF(MODEL_PATH); + + const model = useMemo(() => { + const cloned = scene.clone(true); + cloned.traverse((child) => { + if (child instanceof THREE.Mesh) { + child.castShadow = true; + child.receiveShadow = true; + child.frustumCulled = false; + } + }); + return cloned; + }, [scene]); + + useEffect( + () => () => { + model.clear(); + }, + [model], + ); + + useFrame(({ camera }) => { + const group = groupRef.current; + if (!group) return; + + // Place the model in front of the camera using its local space so it stays + // visible regardless of the camera pitch (top-down ebike view, etc.). + group.position.copy(LOCAL_OFFSET).applyMatrix4(camera.matrixWorld); + + // Keep the model upright and aligned with the camera yaw only. + eulerHelper.setFromQuaternion(camera.quaternion, "YXZ"); + group.rotation.set(0, eulerHelper.y, 0); + }); + + return ( + + + + ); +} + +useGLTF.preload(MODEL_PATH); diff --git a/src/components/ebike/Ebike.tsx b/src/components/ebike/Ebike.tsx index b49e3c4..6eb0455 100644 --- a/src/components/ebike/Ebike.tsx +++ b/src/components/ebike/Ebike.tsx @@ -2,17 +2,22 @@ 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"; 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 { 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"; @@ -31,12 +36,29 @@ 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); 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(() => { @@ -58,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] - PLAYER_EYE_HEIGHT, - position[2], + parkedPosition[0], + parkedPosition[1], + parkedPosition[2], ]); const restingRotationRef = useRef(EBIKE_WORLD_ROTATION_Y); const forkRef = useRef(null); @@ -79,11 +101,27 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element { const [showCameraPoints, setShowCameraPoints] = useState(true); const [debugRestingPosition, setDebugRestingPosition] = useState([ - position[0], - position[1] - PLAYER_EYE_HEIGHT, - position[2], + parkedPosition[0], + parkedPosition[1], + parkedPosition[2], ]); + useEffect(() => { + if (movementMode === "ebike") return; + + restingPositionRef.current = parkedPosition; + restingRotationRef.current = EBIKE_WORLD_ROTATION_Y; + lastGpsUpdatePos.current.set(...parkedPosition); + + if (groupRef.current) { + groupRef.current.position.set(...parkedPosition); + groupRef.current.rotation.set(0, EBIKE_WORLD_ROTATION_Y, 0); + } + + window.ebikeParkedPosition = parkedPosition; + window.ebikeParkedRotation = EBIKE_WORLD_ROTATION_Y; + }, [movementMode, parkedPosition]); + useEffect(() => { if (model) { const fork = model.getObjectByName("fourche"); @@ -93,6 +131,17 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element { } }, [model]); + useEffect(() => { + if (!model) return; + + model.traverse((child) => { + if (child instanceof THREE.Mesh) { + child.castShadow = true; + child.receiveShadow = true; + } + }); + }, [model]); + useEffect(() => { window.ebikeVisualGroup = groupRef; window.ebikeParkedPosition = restingPositionRef.current; @@ -169,16 +218,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 +321,51 @@ 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 ( + + + + + + + + + + + + + + ); +} 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/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/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/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/components/ui/SceneLoadingOverlay.tsx b/src/components/ui/SceneLoadingOverlay.tsx index 831e646..c219cce 100644 --- a/src/components/ui/SceneLoadingOverlay.tsx +++ b/src/components/ui/SceneLoadingOverlay.tsx @@ -1,10 +1,18 @@ +import { useEffect, useState } from "react"; import { AppLoadingIndicator } from "@/components/ui/AppLoadingIndicator"; 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_BACKGROUND_PATH = "/assets/bg-site.webp"; +const LOADING_FRAME_RATE = 12; +const LOADING_FRAME_INTERVAL_MS = 1000 / LOADING_FRAME_RATE; +const LOADING_LOGO_FRAMES = [ + "/assets/loader/Loader-1.png", + "/assets/loader/Loader-2.png", + "/assets/loader/Loader-3.png", + "/assets/loader/Loader-4.png", +] as const; -for (const path of [LOADING_BACKGROUND_PATH, LOADING_LOGO_PATH]) { +for (const path of [LOADING_BACKGROUND_PATH, ...LOADING_LOGO_FRAMES]) { const image = new Image(); image.src = path; } @@ -16,8 +24,25 @@ interface SceneLoadingOverlayProps { export function SceneLoadingOverlay({ state, }: SceneLoadingOverlayProps): React.JSX.Element | null { + const [logoFrameIndex, setLogoFrameIndex] = useState(0); const isReady = state.status === "ready"; const progress = Math.round(Math.max(0, Math.min(1, state.progress)) * 100); + const logoFramePath = + LOADING_LOGO_FRAMES[logoFrameIndex] ?? LOADING_LOGO_FRAMES[0]; + + useEffect(() => { + if (isReady) return undefined; + + const intervalId = window.setInterval(() => { + setLogoFrameIndex( + (currentIndex) => (currentIndex + 1) % LOADING_LOGO_FRAMES.length, + ); + }, LOADING_FRAME_INTERVAL_MS); + + return () => { + window.clearInterval(intervalId); + }; + }, [isReady]); return (
diff --git a/src/components/ui/TalkieDialogueOverlay.tsx b/src/components/ui/TalkieDialogueOverlay.tsx new file mode 100644 index 0000000..21a7f4b --- /dev/null +++ b/src/components/ui/TalkieDialogueOverlay.tsx @@ -0,0 +1,35 @@ +import { Suspense } from "react"; +import { Canvas } from "@react-three/fiber"; +import { TalkieModel } from "@/components/ui/talkie/TalkieModel"; +import { TalkieSignalLines } from "@/components/ui/talkie/TalkieSignalLines"; +import { useTalkieDialogueOverlayState } from "@/hooks/ui/useTalkieDialogueOverlayState"; + +export function TalkieDialogueOverlay(): React.JSX.Element | null { + const { isNarratorDialogue, isVisible } = useTalkieDialogueOverlayState(); + + if (!isVisible) return null; + + return ( + + ); +} diff --git a/src/components/ui/talkie/TalkieModel.tsx b/src/components/ui/talkie/TalkieModel.tsx new file mode 100644 index 0000000..050833b --- /dev/null +++ b/src/components/ui/talkie/TalkieModel.tsx @@ -0,0 +1,82 @@ +import { useEffect, useMemo, useRef } from "react"; +import { useFrame } from "@react-three/fiber"; +import { useGLTF } from "@react-three/drei"; +import * as THREE from "three"; +import gsap from "gsap"; +import type { Vector3Tuple } from "@/types/three/three"; + +const TALKIE_MODEL_PATH = "/models/talkie/model.gltf"; +const TALKIE_REST_Y = -1.55; +const TALKIE_ACTIVE_Y = -0.38; +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; + +interface TalkieModelProps { + active: boolean; +} + +export 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 floatRef = useRef(null); + + useEffect(() => { + model.traverse((child) => { + if (child instanceof THREE.Mesh) { + child.castShadow = false; + child.receiveShadow = false; + child.frustumCulled = false; + } + }); + }, [model]); + + useEffect(() => { + const group = groupRef.current; + if (!group) return; + + gsap.killTweensOf(group.position); + gsap.to(group.position, { + y: active ? TALKIE_ACTIVE_Y : TALKIE_REST_Y, + duration: active ? 0.72 : 0.5, + ease: active ? "power3.out" : "power2.out", + }); + + return () => { + gsap.killTweensOf(group.position); + }; + }, [active]); + + useFrame(({ clock }) => { + if (!floatRef.current) return; + + const t = clock.getElapsedTime(); + floatRef.current.position.y = Math.sin(t * 1.2) * TALKIE_FLOAT_Y_AMPLITUDE; + + floatRef.current.rotation.x = + TALKIE_BASE_ROTATION[0] + + Math.sin(t * 0.7) * TALKIE_FLOAT_ROTATION_AMPLITUDE; + floatRef.current.rotation.y = + TALKIE_BASE_ROTATION[1] + + Math.sin(t * 0.55) * TALKIE_FLOAT_ROTATION_AMPLITUDE; + floatRef.current.rotation.z = + TALKIE_BASE_ROTATION[2] + + Math.sin(t * 0.8) * TALKIE_FLOAT_ROTATION_AMPLITUDE; + }); + + return ( + + + + + + ); +} + +useGLTF.preload(TALKIE_MODEL_PATH); diff --git a/src/components/ui/talkie/TalkieSignalLines.tsx b/src/components/ui/talkie/TalkieSignalLines.tsx new file mode 100644 index 0000000..0f99179 --- /dev/null +++ b/src/components/ui/talkie/TalkieSignalLines.tsx @@ -0,0 +1,19 @@ +interface TalkieSignalLinesProps { + side: "left" | "right"; +} + +export function TalkieSignalLines({ + side, +}: TalkieSignalLinesProps): React.JSX.Element { + return ( + + ); +} diff --git a/src/data/ebike/ebikeConfig.ts b/src/data/ebike/ebikeConfig.ts index 583afc1..9c235e1 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, 10, 62.4]; -export const EBIKE_WORLD_ROTATION_Y = 2.4107; +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; -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/player/playerConfig.ts b/src/data/player/playerConfig.ts index 7ce0ca2..e573afd 100644 --- a/src/data/player/playerConfig.ts +++ b/src/data/player/playerConfig.ts @@ -1,10 +1,11 @@ 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; 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; @@ -14,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 = [59.5, 10, 64.64]; +export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [ + LA_FABRIK_PLAYER_SPAWN[0] + 1, + LA_FABRIK_PLAYER_SPAWN[1], + LA_FABRIK_PLAYER_SPAWN[2] - 1, +]; export const PLAYER_SPAWN_POSITION_PHYSICS: Vector3Tuple = [0, 3, 0]; diff --git a/src/data/site/siteConfig.ts b/src/data/site/siteConfig.ts index b5c42d0..5b90e4b 100644 --- a/src/data/site/siteConfig.ts +++ b/src/data/site/siteConfig.ts @@ -1,6 +1,6 @@ import type { CSSProperties } from "react"; -const BACKGROUND_IMAGE = "/assets/bg-site.png"; +const BACKGROUND_IMAGE = "/assets/bg-site.webp"; export const SITE_CONFIG = { backgroundImage: BACKGROUND_IMAGE, diff --git a/src/data/world/cloudConfig.ts b/src/data/world/cloudConfig.ts index 07615c3..c86aa13 100644 --- a/src/data/world/cloudConfig.ts +++ b/src/data/world/cloudConfig.ts @@ -19,7 +19,7 @@ export const CLOUD_DEFAULTS = { maxRotation: Math.PI * 2, minSpeedMultiplier: 0.4, maxSpeedMultiplier: 1, - castShadow: false, + castShadow: true, receiveShadow: false, }; 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/data/world/laFabrikConfig.ts b/src/data/world/laFabrikConfig.ts new file mode 100644 index 0000000..356116d --- /dev/null +++ b/src/data/world/laFabrikConfig.ts @@ -0,0 +1,29 @@ +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_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( + 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 + ); +} diff --git a/src/data/world/lightingConfig.ts b/src/data/world/lightingConfig.ts index a61c06a..bf91955 100644 --- a/src/data/world/lightingConfig.ts +++ b/src/data/world/lightingConfig.ts @@ -30,3 +30,12 @@ export const SUN_Y_STEP = 1; export const SUN_Z_MIN = -100; export const SUN_Z_MAX = 100; export const SUN_Z_STEP = 1; + +export const SHADOW_CONFIG = { + mapSize: 2048, + cameraSize: 95, + cameraNear: 0.5, + cameraFar: 300, + bias: 0, + normalBias: 0, +} as const; diff --git a/src/data/world/octreeCollisionConfig.ts b/src/data/world/octreeCollisionConfig.ts new file mode 100644 index 0000000..3af672a --- /dev/null +++ b/src/data/world/octreeCollisionConfig.ts @@ -0,0 +1,61 @@ +import type { Vector3Tuple } from "@/types/three/three"; + +export interface OctreeCollisionBox { + center: Vector3Tuple; + size: Vector3Tuple; +} + +export interface MapOctreeCollisionBox extends OctreeCollisionBox { + bottomY: number; +} + +export const MAP_OCTREE_COLLISION_BOXES = { + immeuble1: { + center: [-0.0308, 5.8389, 0], + size: [17.2522, 11.6098, 9.2668], + bottomY: 0.034, + }, + maison1: { + center: [0, 1.3638, 0.0536], + size: [2.7813, 3.022, 2.8609], + bottomY: -0.1472, + }, +} as const satisfies Record; + +export const LA_FABRIK_INTERIOR_COLLISION_BOXES = [ + // NOTE: removed — this thin wall (size [0.2, 1.94, 3.71]) sat at x≈-6.93 and + // sealed the doorway despite the geometry having a hole there. The fabrik + // mesh octree already provides the surrounding wall collision, so this + // proxy was both redundant and bug-causing. + // { + // center: [-6.9351, 2.278, -0.0001], + // size: [0.2, 1.94, 3.711], + // }, + { + center: [0.8026, 0.719, -3.639], + size: [4.346, 1.108, 1.181], + }, + { + center: [-5.8519, 0.9362, 2.5742], + size: [1.67, 1.551, 2.566], + }, + { + center: [-2.0627, 1.4875, -1.2243], + size: [0.691, 0.723, 0.687], + }, + { + center: [-3.5502, 1.4378, -1.2485], + size: [1.055, 0.657, 0.563], + }, +] as const satisfies readonly OctreeCollisionBox[]; + +export const CHARACTER_OCTREE_COLLISION_BOX = { + center: [0, 0.875, 0], + size: [0.62, 1.75, 0.62], +} as const satisfies OctreeCollisionBox; + +export function hasMapOctreeCollisionBox( + name: string, +): name is keyof typeof MAP_OCTREE_COLLISION_BOXES { + return name in MAP_OCTREE_COLLISION_BOXES; +} diff --git a/src/hooks/debug/useDebugVisualsDebug.ts b/src/hooks/debug/useDebugVisualsDebug.ts new file mode 100644 index 0000000..be1538c --- /dev/null +++ b/src/hooks/debug/useDebugVisualsDebug.ts @@ -0,0 +1,66 @@ +import { useDebugFolder } from "@/hooks/debug/useDebugFolder"; +import { useDebugVisualsStore } from "@/managers/stores/useDebugVisualsStore"; + +export function useDebugVisualsDebug(): void { + useDebugFolder("Debug", (folder) => { + const state = useDebugVisualsStore.getState(); + const controls = { + showPlayerModel: state.showPlayerModel, + showOctree: state.showOctree, + octreeMinDepth: state.octreeMinDepth, + octreeMaxDepth: state.octreeMaxDepth, + octreeLeavesOnly: state.octreeLeavesOnly, + octreeOpacity: state.octreeOpacity, + octreeFabrikOnly: state.octreeFabrikOnly, + }; + + folder + .add(controls, "showPlayerModel") + .name("Show Player Model") + .onChange((value: boolean) => { + useDebugVisualsStore.getState().setShowPlayerModel(value); + }); + + folder + .add(controls, "showOctree") + .name("Show Octree") + .onChange((value: boolean) => { + useDebugVisualsStore.getState().setShowOctree(value); + }); + + folder + .add(controls, "octreeLeavesOnly") + .name("Octree Leaves Only") + .onChange((value: boolean) => { + useDebugVisualsStore.getState().setOctreeLeavesOnly(value); + }); + + folder + .add(controls, "octreeMinDepth", 0, 10, 1) + .name("Octree Min Depth") + .onChange((value: number) => { + useDebugVisualsStore.getState().setOctreeMinDepth(value); + }); + + folder + .add(controls, "octreeMaxDepth", 0, 10, 1) + .name("Octree Max Depth") + .onChange((value: number) => { + useDebugVisualsStore.getState().setOctreeMaxDepth(value); + }); + + folder + .add(controls, "octreeOpacity", 0.05, 1, 0.05) + .name("Octree Opacity") + .onChange((value: number) => { + useDebugVisualsStore.getState().setOctreeOpacity(value); + }); + + folder + .add(controls, "octreeFabrikOnly") + .name("Octree Fabrik Only") + .onChange((value: boolean) => { + useDebugVisualsStore.getState().setOctreeFabrikOnly(value); + }); + }); +} diff --git a/src/hooks/three/useOctreeGraphNode.ts b/src/hooks/three/useOctreeGraphNode.ts index f6a0d0a..994ba0b 100644 --- a/src/hooks/three/useOctreeGraphNode.ts +++ b/src/hooks/three/useOctreeGraphNode.ts @@ -1,6 +1,6 @@ import { useEffect, useRef } from "react"; import type { RefObject } from "react"; -import type { Object3D } from "three"; +import { type Object3D } from "three"; import { Octree } from "three-stdlib"; import type { OctreeReadyHandler } from "@/types/three/three"; @@ -27,6 +27,7 @@ export function useOctreeGraphNode( const octree = new Octree(); octree.fromGraphNode(graphNode); + onOctreeReady(octree); }, [enabled, graphNodeRef, onOctreeReady, rebuildKey]); } diff --git a/src/hooks/three/useShadowMapWarmup.ts b/src/hooks/three/useShadowMapWarmup.ts new file mode 100644 index 0000000..5d9b16e --- /dev/null +++ b/src/hooks/three/useShadowMapWarmup.ts @@ -0,0 +1,105 @@ +import { useRef } from "react"; +import { useFrame } from "@react-three/fiber"; +import { + Material, + Mesh, + type DirectionalLight, + type Scene, + type WebGLRenderer, +} from "three"; + +interface UseShadowMapWarmupOptions { + /** Light whose shadow map should be reallocated once the scene stabilizes. */ + light: React.RefObject; + scene: Scene; + gl: WebGLRenderer; + invalidate: () => void; + /** Frames the mesh count must remain unchanged to consider the scene stable. */ + stableFramesThreshold?: number; + /** Hard cap on how long we keep watching, in frames (~5s @60fps). */ + safetyCapFrames?: number; + /** Sample mesh count every N frames to keep the traversal cost negligible. */ + sampleEveryFrames?: number; +} + +export function useShadowMapWarmup({ + light, + scene, + gl, + invalidate, + stableFramesThreshold = 60, + safetyCapFrames = 300, + sampleEveryFrames = 6, +}: UseShadowMapWarmupOptions): void { + const meshCountRef = useRef(0); + const stableFramesRef = useRef(0); + const watchFramesRef = useRef(0); + const doneRef = useRef(false); + + useFrame(() => { + if (doneRef.current || !light.current) return; + + watchFramesRef.current += 1; + + if (watchFramesRef.current % sampleEveryFrames === 0) { + let meshCount = 0; + scene.traverse((object) => { + if (object instanceof Mesh) meshCount += 1; + }); + + if (meshCount !== meshCountRef.current) { + meshCountRef.current = meshCount; + stableFramesRef.current = 0; + } else { + stableFramesRef.current += sampleEveryFrames; + } + } + + const stableEnough = stableFramesRef.current >= stableFramesThreshold; + const safetyCapReached = watchFramesRef.current >= safetyCapFrames; + if (!stableEnough && !safetyCapReached) return; + + doneRef.current = true; + reallocateShadowMap(light.current); + invalidateAllMaterials(scene); + forceShadowPass(gl, scene, light.current); + invalidate(); + }); +} + +function reallocateShadowMap(light: DirectionalLight): void { + const shadowMap = light.shadow.map; + if (!shadowMap) return; + + shadowMap.dispose(); + light.shadow.map = null; +} + +function invalidateAllMaterials(scene: Scene): void { + const seen = new Set(); + scene.traverse((object) => { + if (!(object instanceof Mesh)) return; + const materials = Array.isArray(object.material) + ? object.material + : [object.material]; + for (const material of materials) { + if (!material || seen.has(material)) continue; + seen.add(material); + material.needsUpdate = true; + } + }); +} + +function forceShadowPass( + gl: WebGLRenderer, + scene: Scene, + light: DirectionalLight, +): void { + scene.updateMatrixWorld(true); + light.target.updateMatrixWorld(true); + light.updateMatrixWorld(true); + light.shadow.camera.updateMatrixWorld(true); + light.shadow.camera.updateProjectionMatrix(); + light.shadow.needsUpdate = true; + gl.shadowMap.needsUpdate = true; +} diff --git a/src/hooks/ui/useTalkieDialogueOverlayState.ts b/src/hooks/ui/useTalkieDialogueOverlayState.ts new file mode 100644 index 0000000..b1eb3c4 --- /dev/null +++ b/src/hooks/ui/useTalkieDialogueOverlayState.ts @@ -0,0 +1,27 @@ +import { GAME_STEPS } from "@/data/game/gameStateConfig"; +import { useGameStore } from "@/managers/stores/useGameStore"; +import { useSubtitleStore } from "@/managers/stores/useSubtitleStore"; + +const TALKIE_FIRST_VISIBLE_STEP = "reveal"; +const TALKIE_FIRST_VISIBLE_STEP_INDEX = GAME_STEPS.indexOf( + TALKIE_FIRST_VISIBLE_STEP, +); + +interface TalkieDialogueOverlayState { + isNarratorDialogue: boolean; + isVisible: boolean; +} + +export function useTalkieDialogueOverlayState(): TalkieDialogueOverlayState { + const activeSubtitle = useSubtitleStore((state) => state.activeSubtitle); + const mainState = useGameStore((state) => state.mainState); + const introStep = useGameStore((state) => state.intro.currentStep); + const introStepIndex = GAME_STEPS.indexOf(introStep); + + return { + isNarratorDialogue: activeSubtitle?.speaker === "Narrateur", + isVisible: + mainState !== "intro" || + introStepIndex >= TALKIE_FIRST_VISIBLE_STEP_INDEX, + }; +} diff --git a/src/hooks/world/useWorldSceneLoading.ts b/src/hooks/world/useWorldSceneLoading.ts index c629fa7..e82e92b 100644 --- a/src/hooks/world/useWorldSceneLoading.ts +++ b/src/hooks/world/useWorldSceneLoading.ts @@ -11,13 +11,10 @@ interface UseWorldSceneLoadingOptions { interface UseWorldSceneLoadingResult { octree: Octree | null; gameplayReady: boolean; - shouldWarmUpShadows: boolean; showGameStage: boolean; handleGameStageLoaded: () => void; handleGameMapLoaded: () => void; handleOctreeReady: (octree: Octree) => void; - handleShadowWarmupReady: () => void; - handleShadowWarmupStarted: () => void; } export function useWorldSceneLoading({ @@ -27,19 +24,13 @@ export function useWorldSceneLoading({ const [octree, setOctree] = useState(null); const [gameMapLoaded, setGameMapLoaded] = useState(false); const [gameStageLoaded, setGameStageLoaded] = useState(false); - const [shadowsReady, setShadowsReady] = useState(false); const showGameStage = sceneMode === "game" && gameMapLoaded; - const gameSceneReadyForShadows = - showGameStage && gameStageLoaded && octree !== null; - const shadowWarmupReady = sceneMode === "game" && gameSceneReadyForShadows; - const shouldWarmUpShadows = shadowWarmupReady && !shadowsReady; - const gameplayReady = gameSceneReadyForShadows && shadowsReady; + const gameplayReady = showGameStage && gameStageLoaded && octree !== null; const sceneReady = (sceneMode === "game" && gameplayReady) || (sceneMode === "physics" && octree !== null); const handleGameMapLoaded = useCallback(() => { - setShadowsReady(false); setGameMapLoaded(true); }, []); @@ -54,7 +45,6 @@ export function useWorldSceneLoading({ const handleOctreeReady = useCallback( (nextOctree: Octree) => { - setShadowsReady(false); setOctree(nextOctree); onLoadingStateChange?.({ currentStep: "Collision prête", @@ -65,23 +55,6 @@ export function useWorldSceneLoading({ [onLoadingStateChange], ); - const handleShadowWarmupStarted = useCallback(() => { - onLoadingStateChange?.({ - currentStep: "Activation des ombres", - progress: 0.97, - status: "loading", - }); - }, [onLoadingStateChange]); - - const handleShadowWarmupReady = useCallback(() => { - setShadowsReady(true); - onLoadingStateChange?.({ - currentStep: "Ombres prêtes", - progress: 0.99, - status: "loading", - }); - }, [onLoadingStateChange]); - useEffect(() => { onLoadingStateChange?.({ currentStep: "Initialisation du jeu", @@ -115,12 +88,9 @@ export function useWorldSceneLoading({ return { octree, gameplayReady, - shouldWarmUpShadows, showGameStage, handleGameStageLoaded, handleGameMapLoaded, handleOctreeReady, - handleShadowWarmupReady, - handleShadowWarmupStarted, }; } diff --git a/src/index.css b/src/index.css index 0aed3d3..0eb57e5 100644 --- a/src/index.css +++ b/src/index.css @@ -942,11 +942,11 @@ canvas { .scene-loading-overlay__logo { position: relative; z-index: 1; + display: block; width: clamp(180px, 28vw, 320px); max-height: min(38vh, 320px); - border-radius: 16px; - object-fit: cover; - box-shadow: 0 28px 80px rgba(0, 0, 0, 0.28); + height: auto; + object-fit: contain; } .scene-loading-overlay__footer { @@ -1237,6 +1237,114 @@ canvas { color: #f9a8d4; } +/* Dialogue talkie */ +.talkie-dialogue-overlay { + position: fixed; + left: 0; + bottom: 0; + z-index: 16; + width: clamp(190px, 18vw, 300px); + aspect-ratio: 1; + pointer-events: none; +} + +.talkie-dialogue-overlay__model-frame { + position: absolute; + inset: 0; + filter: drop-shadow(0 16px 22px rgba(0, 0, 0, 0.55)); +} + +.talkie-dialogue-overlay--active .talkie-dialogue-overlay__model-frame { + animation: talkie-radio-shake 1s ease-in-out infinite; +} + +.talkie-dialogue-overlay__model-frame canvas { + width: 100% !important; + height: 100% !important; +} + +.talkie-dialogue-overlay__signals { + position: absolute; + top: 52%; + z-index: 2; + width: 38%; + height: 52%; + overflow: visible; + opacity: 0.8; + animation: talkie-signal-pulse 1s ease-in-out infinite; +} + +.talkie-dialogue-overlay__signals--left { + right: 62%; + transform: translateY(-50%) scaleX(-1); +} + +.talkie-dialogue-overlay__signals--right { + left: 62%; + transform: translateY(-50%); +} + +.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; + } + + 18%, + 38% { + opacity: 0.95; + } + + 60% { + opacity: 0.45; + } +} + /* In-game settings menu */ .game-settings-menu { position: fixed; diff --git a/src/managers/stores/useDebugVisualsStore.ts b/src/managers/stores/useDebugVisualsStore.ts new file mode 100644 index 0000000..030e5c2 --- /dev/null +++ b/src/managers/stores/useDebugVisualsStore.ts @@ -0,0 +1,35 @@ +import { create } from "zustand"; + +interface DebugVisualsStore { + showPlayerModel: boolean; + setShowPlayerModel: (value: boolean) => void; + showOctree: boolean; + setShowOctree: (value: boolean) => void; + octreeMaxDepth: number; + setOctreeMaxDepth: (value: number) => void; + octreeMinDepth: number; + setOctreeMinDepth: (value: number) => void; + octreeLeavesOnly: boolean; + setOctreeLeavesOnly: (value: boolean) => void; + octreeOpacity: number; + setOctreeOpacity: (value: number) => void; + octreeFabrikOnly: boolean; + setOctreeFabrikOnly: (value: boolean) => void; +} + +export const useDebugVisualsStore = create((set) => ({ + showPlayerModel: false, + setShowPlayerModel: (showPlayerModel) => set({ showPlayerModel }), + showOctree: false, + setShowOctree: (showOctree) => set({ showOctree }), + octreeMaxDepth: 8, + setOctreeMaxDepth: (octreeMaxDepth) => set({ octreeMaxDepth }), + octreeMinDepth: 4, + setOctreeMinDepth: (octreeMinDepth) => set({ octreeMinDepth }), + octreeLeavesOnly: true, + setOctreeLeavesOnly: (octreeLeavesOnly) => set({ octreeLeavesOnly }), + octreeOpacity: 0.35, + setOctreeOpacity: (octreeOpacity) => set({ octreeOpacity }), + octreeFabrikOnly: false, + setOctreeFabrikOnly: (octreeFabrikOnly) => set({ octreeFabrikOnly }), +})); 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/pages/page.tsx b/src/pages/page.tsx index 6d9d752..996a285 100644 --- a/src/pages/page.tsx +++ b/src/pages/page.tsx @@ -131,6 +131,7 @@ export function HomePage(): React.JSX.Element | null { gl.shadowMap.enabled = true; gl.shadowMap.type = THREE.PCFShadowMap; gl.shadowMap.autoUpdate = true; + 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 @@ -148,6 +149,7 @@ export function HomePage(): React.JSX.Element | null { gl.shadowMap.enabled = true; gl.shadowMap.type = THREE.PCFShadowMap; gl.shadowMap.autoUpdate = true; + gl.shadowMap.needsUpdate = true; logger.info("WebGL", "Context restored"); }; 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 ; diff --git a/src/utils/debug/Debug.ts b/src/utils/debug/Debug.ts index 026b9d1..7c9606b 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; } @@ -25,6 +26,7 @@ const DEBUG_FOLDER_ORDER = [ "Hand Tracking", "Map", "Personnages", + "Debug", ] as const; function isRecord(value: unknown): value is Record { @@ -39,6 +41,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 +57,9 @@ function getStoredDebugControls(): Partial { ...(isCameraMode(parsedValue.cameraMode) ? { cameraMode: parsedValue.cameraMode } : {}), + ...(isHandTrackingSource(parsedValue.handTrackingSource) + ? { handTrackingSource: parsedValue.handTrackingSource } + : {}), ...(isSceneMode(parsedValue.sceneMode) ? { sceneMode: parsedValue.sceneMode } : {}), @@ -94,7 +103,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 +168,7 @@ export class Debug { .name("Source") .onChange((value: HandTrackingSource) => { this.controls.handTrackingSource = value; - this.emit(); + this.saveAndEmit(); }); } } @@ -246,7 +255,7 @@ export class Debug { setHandTrackingSource(value: HandTrackingSource): void { this.controls.handTrackingSource = value; - this.emit(); + this.saveAndEmit(); } getFogEnabled(): boolean { @@ -285,6 +294,7 @@ export class Debug { DEBUG_CONTROLS_STORAGE_KEY, JSON.stringify({ cameraMode: this.controls.cameraMode, + handTrackingSource: this.controls.handTrackingSource, sceneMode: this.controls.sceneMode, }), ); diff --git a/src/world/Environment.tsx b/src/world/Environment.tsx index 7333d0f..430bc66 100644 --- a/src/world/Environment.tsx +++ b/src/world/Environment.tsx @@ -15,24 +15,11 @@ import { SkyModel } from "@/components/three/world/SkyModel"; import { CloudSystem } from "@/world/clouds/CloudSystem"; import { FogSystem } from "@/world/fog/FogSystem"; import { GrassSystem } from "@/world/grass/GrassSystem"; -import { SceneShadowWarmup } from "@/world/SceneShadowWarmup"; import { VegetationSystem } from "@/world/vegetation/VegetationSystem"; import { WaterSystem } from "@/world/water/WaterSystem"; import { WorldPlane } from "@/world/WorldPlane"; -interface ShadowWarmupConfig { - active: boolean; - onReady: () => void; - onStarted: () => void; -} - -interface EnvironmentProps { - shadowWarmup?: ShadowWarmupConfig; -} - -export function Environment({ - shadowWarmup, -}: EnvironmentProps): React.JSX.Element { +export function Environment(): React.JSX.Element { const sceneMode = useSceneMode(); const groups = useMapPerformanceStore((state) => state.groups); const models = useMapPerformanceStore((state) => state.models); @@ -47,13 +34,6 @@ export function Environment({ return ( <> - {shadowWarmup ? ( - - ) : null} {showSky ? ( ( [], ); + const [proxyCollisionMapNodes, setProxyCollisionMapNodes] = useState< + MapNode[] + >([]); const [terrainNode, setTerrainNode] = useState(null); const [mapLoaded, setMapLoaded] = useState(false); const [settledMapNodeCount, setSettledMapNodeCount] = useState(0); @@ -134,6 +138,7 @@ export function GameMap({ (currentStep: string) => { setRenderMapNodes([]); setCollisionMapNodes([]); + setProxyCollisionMapNodes([]); setTerrainNode(null); setMapLoaded(true); settledMapNodesRef.current.clear(); @@ -191,6 +196,10 @@ export function GameMap({ const modelUrl = sceneData.models.get(node.name); return { node, modelUrl: modelUrl ?? null }; }); + const loadedProxyCollisionNodes = sceneData.mapNodes.filter( + (node) => + node.type === "Object3D" && hasMapOctreeCollisionBox(node.name), + ); const loadedTerrainNode = getTerrainMapNode(sceneData.mapNodes); const repairMissionAnchors = getRepairMissionMapAnchors( sceneData.mapNodes, @@ -211,6 +220,7 @@ export function GameMap({ setRenderMapNodes(loadedMapNodes); setCollisionMapNodes(loadedCollisionNodes); + setProxyCollisionMapNodes(loadedProxyCollisionNodes); setTerrainNode(loadedTerrainNode); setRepairMissionAnchors(repairMissionAnchors); setMapLoaded(true); @@ -285,6 +295,7 @@ export function GameMap({ buildOctree={buildOctree} mapReady={mapReady} nodes={collisionMapNodes} + proxyNodes={proxyCollisionMapNodes} onLoaded={onLoaded} onLoadingStateChange={onLoadingStateChange} onOctreeReady={onOctreeReady} diff --git a/src/world/GameMapCollision.tsx b/src/world/GameMapCollision.tsx index 9d038d7..d2eb246 100644 --- a/src/world/GameMapCollision.tsx +++ b/src/world/GameMapCollision.tsx @@ -17,9 +17,24 @@ import { normalizeMapScale, useTerrainHeightSampler, } from "@/hooks/three/useTerrainHeight"; +import { + CHARACTER_CONFIGS, + CHARACTER_IDS, + type CharacterId, +} from "@/data/world/characters/characterConfig"; +import { + CHARACTER_OCTREE_COLLISION_BOX, + LA_FABRIK_INTERIOR_COLLISION_BOXES, + MAP_OCTREE_COLLISION_BOXES, + hasMapOctreeCollisionBox, + type OctreeCollisionBox, +} from "@/data/world/octreeCollisionConfig"; +import { getMapModelScaleMultiplier } from "@/data/world/mapInstancingConfig"; +import { useCharacterDebugStore } from "@/managers/stores/useCharacterDebugStore"; +import { useGameStore } from "@/managers/stores/useGameStore"; import { WorldBoundsCollision } from "@/world/collision/WorldBoundsCollision"; import type { MapNode } from "@/types/map/mapScene"; -import type { OctreeReadyHandler } from "@/types/three/three"; +import type { OctreeReadyHandler, Vector3Tuple } from "@/types/three/three"; import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading"; import { logModelLoadError } from "@/utils/three/modelLoadLogger"; @@ -39,6 +54,7 @@ interface GameMapCollisionProps { buildOctree?: boolean; mapReady: boolean; nodes: readonly GameMapCollisionNode[]; + proxyNodes: readonly MapNode[]; onLoaded?: (() => void) | undefined; onLoadingStateChange?: SceneLoadingChangeHandler | undefined; onOctreeReady: OctreeReadyHandler; @@ -101,6 +117,7 @@ export function GameMapCollision({ buildOctree = true, mapReady, nodes, + proxyNodes, onLoaded, onLoadingStateChange, onOctreeReady, @@ -109,10 +126,28 @@ export function GameMapCollision({ const settledCollisionNodesRef = useRef(new Set()); const loadedNotifiedRef = useRef(false); const [settledCollisionNodeCount, setSettledCollisionNodeCount] = useState(0); + const mainState = useGameStore((state) => state.mainState); const terrainHeight = useTerrainHeightSampler(); const collisionNodes = nodes.filter(isCollisionNode); + const includeCharacterCollisions = mainState !== "ebike"; + const characterCollisionCount = includeCharacterCollisions + ? CHARACTER_IDS.length + : 0; + const collisionSourceCount = + collisionNodes.length + proxyNodes.length + characterCollisionCount; const collisionReady = mapReady && settledCollisionNodeCount >= collisionNodes.length; + const characterCollisionSignature = useCharacterDebugStore((state) => + includeCharacterCollisions + ? CHARACTER_IDS.map((id) => { + const character = state.characters[id]; + return [...character.position, ...character.rotation].join(","); + }).join("|") + : "characters-hidden", + ); + const collisionRebuildKey = collisionReady + ? `${collisionNodes.length}:${collisionSourceCount}:${characterCollisionSignature}` + : "pending"; const notifyLoaded = useCallback(() => { if (loadedNotifiedRef.current) return; @@ -144,14 +179,14 @@ export function GameMapCollision({ useOctreeGraphNode( groupRef, handleOctreeReady, - collisionReady ? collisionNodes.length : 0, - buildOctree && collisionReady && collisionNodes.length > 0, + collisionRebuildKey, + buildOctree && collisionReady && collisionSourceCount > 0, ); useEffect(() => { if (!mapReady) return; - if (collisionNodes.length === 0) { + if (collisionSourceCount === 0) { notifyLoaded(); return; } @@ -171,6 +206,7 @@ export function GameMapCollision({ }, [ buildOctree, collisionNodes.length, + collisionSourceCount, collisionReady, mapReady, notifyLoaded, @@ -180,6 +216,18 @@ export function GameMapCollision({ return ( {mapReady ? : null} + {mapReady + ? proxyNodes.map((node, index) => ( + + )) + : null} + {mapReady && includeCharacterCollisions ? ( + + ) : null} {mapReady ? collisionNodes.map((mapNode, index) => ( { + if (node.name !== "lafabrik") return; + + const isDoorSlab = (name: string): boolean => + name === "porte" || /^porte[._]\d+$/i.test(name); + const isDoorFrameThickenChild = (child: THREE.Object3D): boolean => + child.parent?.name === "Thicken"; + + const doorMeshes: THREE.Object3D[] = []; + sceneInstance.traverse((child) => { + if (isDoorSlab(child.name) || isDoorFrameThickenChild(child)) { + doorMeshes.push(child); + } + }); + for (const child of doorMeshes) { + child.removeFromParent(); + } + }, [node.name, sceneInstance]); const collisionPosition = useMemo(() => { if (node.name === "terrain") return position; @@ -237,11 +303,131 @@ function CollisionModelInstance({ }, [onLoaded]); return ( - + <> + + {node.name === "lafabrik" ? ( + + {LA_FABRIK_INTERIOR_COLLISION_BOXES.map((box, index) => ( + + ))} + + ) : null} + + ); +} + +function CollisionBox({ box }: { box: OctreeCollisionBox }): React.JSX.Element { + return ( + + + + + + + + + + + ); +} + +function createScaledMapNodeScale(node: MapNode): Vector3Tuple { + const baseScale = normalizeMapScale(node.scale); + const scaleMultiplier = getMapModelScaleMultiplier(node.name); + + return [ + baseScale[0] * scaleMultiplier, + baseScale[1] * scaleMultiplier, + baseScale[2] * scaleMultiplier, + ]; +} + +function MapCollisionBoxProxy({ + node, + terrainHeight, +}: { + node: MapNode; + terrainHeight: TerrainHeightSampler; +}): React.JSX.Element | null { + const collisionBox = hasMapOctreeCollisionBox(node.name) + ? MAP_OCTREE_COLLISION_BOXES[node.name] + : null; + const normalizedScale = useMemo(() => createScaledMapNodeScale(node), [node]); + const position = useMemo(() => { + const [x, y, z] = node.position; + if (!collisionBox) return [x, y, z] satisfies Vector3Tuple; + + const height = terrainHeight.getHeight(x, z); + const bottomOffset = -collisionBox.bottomY * normalizedScale[1]; + + return [x, (height ?? y) + bottomOffset, z] satisfies Vector3Tuple; + }, [collisionBox, node.position, normalizedScale, terrainHeight]); + + if (!collisionBox) return null; + + return ( + + + + ); +} + +function CharacterCollisionProxies({ + terrainHeight, +}: { + terrainHeight: TerrainHeightSampler; +}): React.JSX.Element { + return ( + <> + {CHARACTER_IDS.map((id) => ( + + ))} + + ); +} + +function CharacterCollisionProxy({ + id, + terrainHeight, +}: { + id: CharacterId; + terrainHeight: TerrainHeightSampler; +}): React.JSX.Element { + const config = CHARACTER_CONFIGS[id]; + const state = useCharacterDebugStore((store) => store.characters[id]); + const position = useMemo(() => { + const [x, y, z] = state.position; + const height = terrainHeight.getHeight(x, z); + + return [x, height ?? y, z] satisfies Vector3Tuple; + }, [state.position, terrainHeight]); + + return ( + + + ); } 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; diff --git a/src/world/Lighting.tsx b/src/world/Lighting.tsx index 87908b3..714f44c 100644 --- a/src/world/Lighting.tsx +++ b/src/world/Lighting.tsx @@ -1,10 +1,17 @@ import { useEffect, useRef } from "react"; import { useFrame, useThree } from "@react-three/fiber"; -import type { AmbientLight, DirectionalLight, Object3D } from "three"; +import { + PCFShadowMap, + type AmbientLight, + type DirectionalLight, + type Object3D, + type WebGLRenderer, +} from "three"; import { AMBIENT_INTENSITY_MAX, AMBIENT_INTENSITY_MIN, AMBIENT_INTENSITY_STEP, + SHADOW_CONFIG, SUN_INTENSITY_MAX, SUN_INTENSITY_MIN, SUN_INTENSITY_STEP, @@ -18,16 +25,51 @@ import { SUN_Z_MIN, SUN_Z_STEP, } from "@/data/world/lightingConfig"; +import { LA_FABRIK_INTERIOR_LIGHT_POSITION } from "@/data/world/laFabrikConfig"; import { useDebugFolder } from "@/hooks/debug/useDebugFolder"; +import { useShadowMapWarmup } from "@/hooks/three/useShadowMapWarmup"; import { LIGHTING_STATE } from "@/world/lightingState"; -const SHADOW_MAP_SIZE = 2048; -const SHADOW_CAMERA_SIZE = 95; -const SHADOW_CAMERA_NEAR = 0.5; -const SHADOW_CAMERA_FAR = 300; +function configureRendererShadows(gl: WebGLRenderer): void { + gl.shadowMap.enabled = true; + gl.shadowMap.type = PCFShadowMap; + gl.shadowMap.autoUpdate = true; +} + +function configureSunShadow(sun: DirectionalLight, sunTarget: Object3D): void { + sun.target = sunTarget; + sun.shadow.autoUpdate = true; + sun.shadow.bias = SHADOW_CONFIG.bias; + sun.shadow.normalBias = SHADOW_CONFIG.normalBias; + sun.shadow.mapSize.width = SHADOW_CONFIG.mapSize; + sun.shadow.mapSize.height = SHADOW_CONFIG.mapSize; + sun.shadow.camera.left = -SHADOW_CONFIG.cameraSize; + sun.shadow.camera.right = SHADOW_CONFIG.cameraSize; + sun.shadow.camera.top = SHADOW_CONFIG.cameraSize; + sun.shadow.camera.bottom = -SHADOW_CONFIG.cameraSize; + sun.shadow.camera.near = SHADOW_CONFIG.cameraNear; + sun.shadow.camera.far = SHADOW_CONFIG.cameraFar; + sun.shadow.camera.updateProjectionMatrix(); +} + +function placeSunRelativeToCamera( + sun: DirectionalLight, + sunTarget: Object3D, + cameraPosition: { x: number; z: number }, +): void { + sunTarget.position.set(cameraPosition.x, 0, cameraPosition.z); + sun.position.set( + cameraPosition.x + LIGHTING_STATE.sunX, + LIGHTING_STATE.sunY, + cameraPosition.z + LIGHTING_STATE.sunZ, + ); +} export function Lighting(): React.JSX.Element { const camera = useThree((state) => state.camera); + const gl = useThree((state) => state.gl); + const scene = useThree((state) => state.scene); + const invalidate = useThree((state) => state.invalidate); const ambient = useRef(null); const sun = useRef(null); const sunTarget = useRef(null); @@ -35,19 +77,16 @@ export function Lighting(): React.JSX.Element { 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(); - }, []); + configureRendererShadows(gl); + configureSunShadow(sun.current, sunTarget.current); + // Prime the sun + target onto the camera before the first shadow pass so + // the initial shadow frustum already covers the visible scene; without + // this, the first frame is rendered with the default (origin-centered) + // frustum and shadows can appear absent until the player moves. + placeSunRelativeToCamera(sun.current, sunTarget.current, camera.position); + }, [camera, gl]); + + useShadowMapWarmup({ light: sun, scene, gl, invalidate }); useDebugFolder("Lighting", (folder) => { folder.addColor(LIGHTING_STATE, "ambientColor").name("Ambient Color"); @@ -87,19 +126,14 @@ export function Lighting(): React.JSX.Element { ambient.current.intensity = LIGHTING_STATE.ambientIntensity; } - if (sun.current && sunTarget.current) { - sunTarget.current.position.set(camera.position.x, 0, camera.position.z); - sunTarget.current.updateMatrixWorld(); - sun.current.position.set( - camera.position.x + LIGHTING_STATE.sunX, - LIGHTING_STATE.sunY, - camera.position.z + LIGHTING_STATE.sunZ, - ); - sun.current.color.set(LIGHTING_STATE.sunColor); - sun.current.intensity = LIGHTING_STATE.sunIntensity; - sun.current.updateMatrixWorld(); - sun.current.shadow.needsUpdate = true; - } + if (!sun.current || !sunTarget.current) return; + + placeSunRelativeToCamera(sun.current, sunTarget.current, camera.position); + sunTarget.current.updateMatrixWorld(); + sun.current.color.set(LIGHTING_STATE.sunColor); + sun.current.intensity = LIGHTING_STATE.sunIntensity; + sun.current.updateMatrixWorld(); + sun.current.shadow.needsUpdate = true; }); return ( @@ -121,6 +155,13 @@ export function Lighting(): React.JSX.Element { castShadow /> + ); } diff --git a/src/world/SceneShadowWarmup.tsx b/src/world/SceneShadowWarmup.tsx deleted file mode 100644 index 4f994bd..0000000 --- a/src/world/SceneShadowWarmup.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { useEffect, useRef } from "react"; -import { useThree } from "@react-three/fiber"; -import * as THREE from "three"; - -interface SceneShadowWarmupProps { - active: boolean; - onReady: () => void; - onStarted: () => void; -} - -function markShadowLightForUpdate(object: THREE.Object3D): void { - if ( - !( - object instanceof THREE.DirectionalLight || - object instanceof THREE.PointLight || - object instanceof THREE.SpotLight - ) - ) { - return; - } - - if (!object.castShadow) return; - - object.updateMatrixWorld(true); - object.shadow.camera.updateProjectionMatrix(); - object.shadow.needsUpdate = true; -} - -function forceSceneShadowPass( - gl: THREE.WebGLRenderer, - scene: THREE.Scene, -): void { - gl.shadowMap.enabled = true; - gl.shadowMap.type = THREE.PCFShadowMap; - gl.shadowMap.autoUpdate = true; - gl.shadowMap.needsUpdate = true; - - scene.updateMatrixWorld(true); - scene.traverse((object) => { - if (object instanceof THREE.Mesh) { - object.updateMatrixWorld(true); - } - - markShadowLightForUpdate(object); - }); -} - -export function SceneShadowWarmup({ - active, - onReady, - onStarted, -}: SceneShadowWarmupProps): null { - const gl = useThree((state) => state.gl); - const scene = useThree((state) => state.scene); - const invalidate = useThree((state) => state.invalidate); - const isRunningRef = useRef(false); - - useEffect(() => { - if (!active) { - isRunningRef.current = false; - return undefined; - } - - if (isRunningRef.current) return undefined; - - isRunningRef.current = true; - onStarted(); - forceSceneShadowPass(gl, scene); - invalidate(); - - let firstFrame = 0; - let secondFrame = 0; - - firstFrame = window.requestAnimationFrame(() => { - forceSceneShadowPass(gl, scene); - invalidate(); - - secondFrame = window.requestAnimationFrame(() => { - forceSceneShadowPass(gl, scene); - invalidate(); - onReady(); - }); - }); - - return () => { - window.cancelAnimationFrame(firstFrame); - window.cancelAnimationFrame(secondFrame); - }; - }, [active, gl, invalidate, onReady, onStarted, scene]); - - return null; -} diff --git a/src/world/World.tsx b/src/world/World.tsx index f8eadb5..0b45210 100644 --- a/src/world/World.tsx +++ b/src/world/World.tsx @@ -4,16 +4,21 @@ import { PLAYER_SPAWN_POSITION_GAME, PLAYER_SPAWN_POSITION_PHYSICS, } from "@/data/player/playerConfig"; +import { LA_FABRIK_INITIAL_LOOK_AT } from "@/data/world/laFabrikConfig"; import { useCameraMode } from "@/hooks/debug/useCameraMode"; import { useEnvironmentDebug } from "@/hooks/debug/useEnvironmentDebug"; import { useMapPerformanceDebug } from "@/hooks/debug/useMapPerformanceDebug"; import { useCharacterDebug } from "@/hooks/debug/useCharacterDebug"; +import { useDebugVisualsDebug } from "@/hooks/debug/useDebugVisualsDebug"; import { useSceneMode } from "@/hooks/debug/useSceneMode"; import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot"; import { useWorldSceneLoading } from "@/hooks/world/useWorldSceneLoading"; import { useGameStore } from "@/managers/stores/useGameStore"; +import { useDebugVisualsStore } from "@/managers/stores/useDebugVisualsStore"; import { DebugCameraControls } from "@/components/debug/scene/DebugCameraControls"; import { DebugHelpers } from "@/components/debug/scene/DebugHelpers"; +import { DebugOctreeVisualization } from "@/components/debug/DebugOctreeVisualization"; +import { DebugPlayerModel } from "@/components/debug/DebugPlayerModel"; import { HandTrackingGlove } from "@/components/three/handTracking/HandTrackingGlove"; import { Environment } from "@/world/Environment"; import { GameCinematics } from "@/world/GameCinematics"; @@ -35,10 +40,15 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element { useEnvironmentDebug(); useMapPerformanceDebug(); useCharacterDebug(); + useDebugVisualsDebug(); const cameraMode = useCameraMode(); const sceneMode = useSceneMode(); const mainState = useGameStore((state) => state.mainState); + const showDebugPlayerModel = useDebugVisualsStore( + (state) => state.showPlayerModel, + ); + const showDebugOctree = useDebugVisualsStore((state) => state.showOctree); const { status, usageStatus } = useHandTrackingSnapshot(); const { octree, @@ -47,9 +57,6 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element { handleGameStageLoaded, handleGameMapLoaded, handleOctreeReady, - handleShadowWarmupReady, - handleShadowWarmupStarted, - shouldWarmUpShadows, } = useWorldSceneLoading({ sceneMode, onLoadingStateChange }); const playerSpawnPosition = sceneMode === "game" @@ -64,15 +71,15 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element { return ( <> - + + {showDebugOctree ? : null} + {showDebugPlayerModel ? ( + + + + ) : null} {showHandTrackingGloves ? ( @@ -91,16 +98,22 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element { {showGameStage ? ( - + + + ) : null} {spawnPlayer ? ( - <> + {mainState === "outro" ? : null} {mainState !== "intro" ? : null} - - + + ) : null} ) : ( diff --git a/src/world/grass/GrassPatch.tsx b/src/world/grass/GrassPatch.tsx index 1f1daba..877d18d 100644 --- a/src/world/grass/GrassPatch.tsx +++ b/src/world/grass/GrassPatch.tsx @@ -8,6 +8,11 @@ import { GRASS_COLORS, GRASS_CONFIG, } from "@/data/world/grassConfig"; +import { + LA_FABRIK_CENTER, + LA_FABRIK_HALF_EXTENTS, + LA_FABRIK_ROTATION_Y, +} from "@/data/world/laFabrikConfig"; import { grassFragmentShader, grassVertexShader, @@ -169,6 +174,17 @@ function createGrassMaterial( uMaxBladeHeight: { value: GRASS_CONFIG.maxBladeHeight }, uRandomHeightAmount: { value: GRASS_CONFIG.randomHeightAmount }, uSurfaceOffset: { value: GRASS_CONFIG.surfaceOffset }, + uLaFabrikCenter: { + value: new THREE.Vector2(LA_FABRIK_CENTER[0], LA_FABRIK_CENTER[2]), + }, + uLaFabrikHalfExtents: { + value: new THREE.Vector2( + LA_FABRIK_HALF_EXTENTS.x, + LA_FABRIK_HALF_EXTENTS.z, + ), + }, + uLaFabrikRotation: { value: LA_FABRIK_ROTATION_Y }, + uLaFabrikNoGrassFeather: { value: 1.4 }, }, }); } diff --git a/src/world/grass/grassShaders.ts b/src/world/grass/grassShaders.ts index 3f8b461..15bffbb 100644 --- a/src/world/grass/grassShaders.ts +++ b/src/world/grass/grassShaders.ts @@ -43,6 +43,10 @@ export const grassVertexShader = /* glsl */ ` uniform float uMaxBladeHeight; uniform float uRandomHeightAmount; uniform float uSurfaceOffset; + uniform vec2 uLaFabrikCenter; + uniform vec2 uLaFabrikHalfExtents; + uniform float uLaFabrikRotation; + uniform float uLaFabrikNoGrassFeather; float random(vec2 st) { return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123); @@ -132,6 +136,18 @@ export const grassVertexShader = /* glsl */ ` smoothstep(uBoundingBoxMax.z, uBoundingBoxMax.z - 2.0, worldPos.z); heightModifier *= edgeFade * mix(0.45, 1.0, clumpMask); + vec2 laFabrikDelta = worldPos.xz - uLaFabrikCenter; + float laFabrikCos = cos(-uLaFabrikRotation); + float laFabrikSin = sin(-uLaFabrikRotation); + vec2 laFabrikLocal = vec2( + laFabrikDelta.x * laFabrikCos - laFabrikDelta.y * laFabrikSin, + laFabrikDelta.x * laFabrikSin + laFabrikDelta.y * laFabrikCos + ); + vec2 laFabrikDistance = abs(laFabrikLocal) - uLaFabrikHalfExtents; + float laFabrikOutsideDistance = max(laFabrikDistance.x, laFabrikDistance.y); + float laFabrikGrassMask = smoothstep(0.0, uLaFabrikNoGrassFeather, laFabrikOutsideDistance); + heightModifier *= laFabrikGrassMask; + float sideFactor = (color.r == 0.1) ? 1.0 : (color.b == 0.1) ? -1.0 : 0.0; float tipFactor = color.g; float width = smoothstep(0.02, uMaxBladeHeight * 0.85, heightModifier) * uBladeWidth * bladeVisibility; diff --git a/src/world/player/Player.tsx b/src/world/player/Player.tsx index 230560d..cbd93a4 100644 --- a/src/world/player/Player.tsx +++ b/src/world/player/Player.tsx @@ -7,10 +7,12 @@ import { PlayerController } from "@/world/player/PlayerController"; interface PlayerProps { octree: Octree | null; + initialLookAt?: Vector3Tuple | undefined; spawnPosition: Vector3Tuple; } export function Player({ + initialLookAt, spawnPosition, octree, }: PlayerProps): React.JSX.Element { @@ -18,12 +20,17 @@ export function Player({ useLayoutEffect(() => { 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]);