48 Commits

Author SHA1 Message Date
math-pixel a73f9fb951 fixed ebike 2026-06-02 19:21:52 +02:00
math-pixel 193fc8b4b6 update 2026-06-02 16:31:36 +02:00
Tom Boullay 2c194cdd2e fix: use player ebike speed
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled
2026-06-02 09:48:44 +02:00
Tom Boullay feaf502e44 feat: support webm mission notifications 2026-06-02 09:40:18 +02:00
math-pixel 489499f5d2 Merge pull request 'Feat/polish-mission1' (#12) from feat/polish-mission1 into develop
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled
Reviewed-on: #12
2026-06-01 21:51:09 +00:00
Tom Boullay 39b996eb31 Update GameMapCollision.tsx
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
2026-06-01 23:38:19 +02:00
Tom Boullay 134c0aecb7 fix(world): reallocate shadow map after Suspense + clear LaFabrik doorway
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
Shadows occasionally failed to render on initial load and the Fabrik
doorway sometimes blocked the player. Both issues are tracked down to
geometry that mounts after Lighting:

- Shadows: GLTFs and the merged static map mount imperatively after
  Lighting, so materials get compiled against a renderer state that
  pre-dates the final scene and bake a 'no shadow map' permutation,
  silently dropping shadows. A WebGL context-restore cycle fixes it,
  but is too invasive. New 'useShadowMapWarmup' hook replays it
  cheaply: once the scene mesh count has been stable for ~1s, it
  disposes the directional shadow map (three.js reallocates it on
  the next render) and marks every material 'needsUpdate' so shaders
  rebind to the freshly created shadow sampler.
- Doorway: the door slab + its Solidify-modifier frame (children of
  the 'Thicken' parent in the LaFabrik GLTF) sat inside the doorway
  AABB and prevented the player from walking through. Stripped from
  the collision octree alongside the existing 'porte' slab; visual
  rendering is unaffected.

Also: extract sun-relative-to-camera placement into a small helper,
remove the temporary diagnostic logs, and document the shadow warmup
in three-debugging.md.
2026-06-01 23:37:57 +02:00
Tom Boullay b144dc1c18 Update model.gltf
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
2026-06-01 22:42:21 +02:00
Tom Boullay 69c720b86b fix(world): restore multi-frame shadow warmup and unblock fabrik doorway
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
- Lighting: replace single-frame needsUpdate with a 3-rAF warmup that forces
  scene.updateMatrixWorld + sun.shadow.needsUpdate + gl.shadowMap.needsUpdate.
  This restores the SceneShadowWarmup behaviour (deleted in 777e51e) inline,
  so shadows survive Physics Suspense remounts and webglcontextrestored.
- octreeCollisionConfig: remove (comment out) the thin LA_FABRIK interior box
  at x=-6.93 that was sealing the doorway despite the mesh hole; fabrik mesh
  octree already provides surrounding wall collision.
- DebugOctreeVisualization: add Fabrik-only filter to inspect interior
  collisions/non-collisions in isolation.
2026-06-01 22:41:45 +02:00
Tom Boullay 1b57a25e5f fix(world): strip blender-suffixed porte variants from la fabrik collision
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
2026-06-01 22:19:58 +02:00
Tom Boullay f6db7d74e2 chore(world): add temporary diagnostics for porte strip, octree, ctx loss 2026-06-01 22:19:27 +02:00
Tom Boullay a1798aecb3 refactor(ui): split talkie dialogue overlay
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
2026-06-01 21:43:58 +02:00
Tom Boullay 3b07f40f2d fix(ui): restore talkie idle vs active animation
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
The previous talkie-overlay refactor lost the rest/active behaviour and
left the radio-shake CSS animation running constantly. Restore the
intended polish:

- Position lerp between TALKIE_REST_Y (idle) and TALKIE_ACTIVE_Y
  (raised when narrator is speaking) with a subtle floating bob.
- Subtle z-axis float at idle, faster shake when active.
- Gate the CSS radio-shake on the new --active modifier so the talkie
  is calm when no narrator dialogue is playing.
- Keep the face-camera rotation [0.18, PI, -0.08] from the original
  overlay version.

Visibility still kicks in at the reveal step (no regression on the
recent fix).
2026-06-01 17:01:08 +02:00
Tom Boullay 27416143e3 update: add dialogue 2026-06-01 16:57:22 +02:00
Tom Boullay a2a491bd5c chore(world): add temporary shadow pipeline diagnostics
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
Shadows still go missing intermittently despite the per-frame
needsUpdate fix. Add temporary console logs to narrow down the cause:

- [shadow:mount]: one-shot snapshot of renderer shadow flags and a
  scene.traverse count of meshes with castShadow / receiveShadow at
  Lighting mount time.
- [shadow:tick]: every 2s during useFrame, log shadow map enabled flag,
  autoUpdate, sun.castShadow, sun intensity, shadow map texture
  presence, sun and target world positions, and renderer draw calls.

To be removed once the root cause is identified.
2026-06-01 16:50:21 +02:00
Tom Boullay da7d66e1fd feat(debug): add filters to octree visualization
Default visualization was unreadable because every node from depth 0 to
maxDepth was rendered with rainbow-coloured edges. Add three filters
exposed in the Debug folder:

- Octree Leaves Only (default true): skip internal nodes
- Octree Min Depth (default 4): hide the largest enclosing boxes
- Octree Opacity (default 0.35): tone down line density

Also skip nodes without triangles, drop the per-depth HSL palette in
favour of a uniform cyan, and bump default Octree Max Depth to 8.
2026-06-01 16:50:08 +02:00
Tom Boullay 5faf4b4197 fix(ui): keep talkie overlay visible after reveal step
Regression introduced by the narrator-video revert (1ad0c4d): the talkie
overlay was hidden whenever no narrator subtitle was active. Restore the
prior behaviour where the talkie stays visible from the reveal step
onward and only the --raised modifier and signal lines depend on the
active narrator dialogue.
2026-06-01 16:49:58 +02:00
Tom Boullay bee0c7f223 fix(world): make octree collision proxies solid
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
2026-06-01 15:15:55 +02:00
Tom Boullay 216d29ae59 docs(three): document sun shadow needsUpdate fix
Update three-debugging.md to reflect that the shadow intermittence
is resolved by explicit sun.shadow.needsUpdate = true at mount and
in useFrame after updateMatrixWorld.
2026-06-01 14:47:29 +02:00
Tom Boullay e13cf1e4c7 fix(world): force per-frame sun shadow refresh
Restore sun.shadow.needsUpdate = true at mount and in useFrame
after updateMatrixWorld. Lost during SHADOW_CONFIG centralization.
Matches develop's belt-and-suspenders pattern; autoUpdate alone
is insufficient because the sun follows the camera (matrix dirty
every frame) and three.js can skip shadow map re-render.
2026-06-01 14:46:57 +02:00
Tom Boullay d20bdc4934 fix(ui): render loading loader as raw image
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
2026-06-01 14:39:55 +02:00
Tom Boullay 7c35090dbd fix(ui): animate scene loading logo
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
2026-06-01 14:28:48 +02:00
Tom Boullay a766784ce8 docs: update scene runtime and debug toggles
- Drop SceneShadowWarmup section, document centralized shadow config.
- Document the localized Suspense boundaries in World.tsx.
- Document the new player model and octree debug visualizations.
- Open note about intermittent first-load shadow rendering.
2026-06-01 14:16:01 +02:00
Tom Boullay 63952912b5 fix(world): wrap stage and player in suspense to prevent scene remount
Late asset loads inside GameStageContent (e.g. EbikeSpeedometer's
useTexture) and the spawn-player block were bubbling Suspense up to the
root boundary in pages/page.tsx, which unmounted World mid-load and
triggered a redundant octree rebuild + shadow re-config. Localize the
suspension by wrapping each block in its own Suspense fallback.

Also mount DebugOctreeVisualization conditionally on the new debug
toggle.
2026-06-01 14:14:27 +02:00
Tom Boullay fd0b9e2749 feat(debug): add player model and octree visualization toggles
- Show Player Model: render the main character GLTF in camera-local
  space so it stays visible at any pitch.
- Show Octree: overlay the collision octree as colored line segments,
  one wireframe per spatial cell, colored by depth.
- Octree Max Depth: cap recursion to keep the scene readable.
2026-06-01 14:14:20 +02:00
Tom Boullay 777e51efeb fix(world): centralize shadow config and remove warmup
- Extract SHADOW_CONFIG into lightingConfig.ts (bias=0, normalBias=0,
  cameraSize=95) matching the historically working values from develop.
- Drop SceneShadowWarmup; rely on sun.shadow.autoUpdate=true for
  steady-state refresh.
- Enable cloud castShadow and traverse Ebike meshes for cast/receive.
2026-06-01 14:14:14 +02:00
Tom Boullay 1ad0c4de37 revert(ui): remove narrator video on talkie 2026-06-01 14:14:03 +02:00
Tom Boullay 7a378afad3 feat(world): add octree collision proxies 2026-06-01 14:11:23 +02:00
Tom Boullay d52ec7e5a9 fix(model): clean lafabrik props and materials 2026-06-01 13:50:16 +02:00
Tom Boullay 153833deec Update favicon.ico
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
2026-06-01 11:32:31 +02:00
Tom Boullay b617885aa2 chore(ui): clean loading overlay logo styling
Show the loading logo as a raw, contained image without rounded corners
or drop shadow, slightly larger to balance the empty space.
2026-06-01 11:28:15 +02:00
Tom Boullay 5d2e7e2aab fix(world): allow walking through la fabrik door
Strip the 'porte' mesh from the cloned scene used to build the la fabrik
collision octree. The wall geometry already has a doorway cutout, so
removing the door slab leaves the opening passable. The visual model is
rendered separately by MergedStaticMapModel and is unaffected.

Drops the stop-gap LA_FABRIK_COLLISION_Y_OFFSET added during debugging.
2026-06-01 11:28:15 +02:00
Tom Boullay de77f76d48 fix(world): restore shadow auto-update
Reverts the manual shadow refresh throttle introduced in 6d58b90 which
prevented shadows from rendering. Renderer and sun shadow now use
autoUpdate=true and a per-frame needsUpdate=true pulse, matching the
behaviour that produced visible shadows before that commit.
2026-06-01 11:28:07 +02:00
Tom Boullay bdc704fe8e feat(ui): show narrator video on talkie
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
2026-06-01 10:52:28 +02:00
Tom Boullay bce7d11b66 fix(ebike): snap parked model to terrain 2026-06-01 10:52:17 +02:00
Tom Boullay 8aa755da7a fix(model): replace electricienne animated asset 2026-06-01 10:52:08 +02:00
Tom Boullay 6d58b90856 fix(world): throttle shadows and tune high preset 2026-06-01 10:45:07 +02:00
Tom Boullay bafca5a936 fix(ui): apply mobile blocker globally 2026-06-01 09:45:45 +02:00
Tom Boullay dcf3a8564c feat(ui): add narrator talkie overlay 2026-06-01 01:32:46 +02:00
Tom Boullay bc862960a7 fix(settings): persist pause menu preferences 2026-06-01 01:32:36 +02:00
Tom Boullay 597ebcfbd4 fix(ebike): sync parked position from config 2026-06-01 01:32:29 +02:00
Tom Boullay aa2d411b0c fix(world): stabilize lafabrik spawn and vegetation 2026-06-01 01:32:21 +02:00
Tom Boullay 061e0dc677 feat: update ui and intro sequence 2026-06-01 00:54:59 +02:00
Tom Boullay 9ef94af488 Merge branch 'develop' into feat/polish-mission1
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
2026-06-01 00:15:46 +02:00
Tom Boullay 27b4a2c392 upatde(fabrik): zone + herbe
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
2026-06-01 00:14:39 +02:00
math-pixel d5feb07ff0 Merge pull request 'Feat/polish-perf' (#13) from feat/polish-perf into develop
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled
Reviewed-on: #13
2026-05-31 22:11:58 +00:00
Tom Boullay c33d973f12 fix(ui): update logo asset path 2026-05-31 11:51:33 +02:00
Tom Boullay 396e7e4ff0 feat(ebike): add speedometer
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
2026-05-31 11:36:19 +02:00
122 changed files with 3079 additions and 747 deletions
+1 -1
View File
@@ -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.
+5 -3
View File
@@ -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
```
+18 -8
View File
@@ -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 `<Suspense>` 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
`<Suspense fallback={null}>` 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:
+60
View File
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+744 -189
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
+8
View File
@@ -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 <SiteMobileBlocker />;
}
return <RouterProvider router={router} />;
}
@@ -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<readonly [number, number]> = [
[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<readonly [number, number]> = [
[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 (
<lineSegments frustumCulled={false} renderOrder={999}>
<primitive object={geometry} attach="geometry" />
<lineBasicMaterial
color="#22d3ee"
depthTest={false}
depthWrite={false}
transparent
opacity={opacity}
/>
</lineSegments>
);
}
+58
View File
@@ -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<THREE.Group>(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 (
<group ref={groupRef} frustumCulled={false}>
<primitive object={model} />
</group>
);
}
useGLTF.preload(MODEL_PATH);
+275 -70
View File
@@ -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 { EbikeSpeedmeter } from "@/components/ebike/EbikeSpeedmeter";
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";
@@ -20,6 +25,12 @@ import "@/types/ebike/ebikeWindow";
const EBIKE_MODEL_PATH = "/models/ebike/model.gltf";
// Reusable vectors — allocated once to avoid per-frame GC pressure
const _phareWorldPos = new THREE.Vector3();
const _bikeForward = new THREE.Vector3();
const _aimDir = new THREE.Vector3();
const _up = new THREE.Vector3(0, 1, 0);
interface EbikeProps {
position: Vector3Tuple;
}
@@ -31,12 +42,30 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
position: position,
});
const model = useClonedObject(scene);
const terrainHeight = useTerrainHeightSampler();
const parkedPosition = useMemo<Vector3Tuple>(() => {
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 threeScene = useThree((state) => state.scene);
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,39 +87,125 @@ 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<THREE.Vector3>(
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<Vector3Tuple>([
position[0],
position[1] - PLAYER_EYE_HEIGHT,
position[2],
parkedPosition[0],
parkedPosition[1],
parkedPosition[2],
]);
const restingRotationRef = useRef<number>(EBIKE_WORLD_ROTATION_Y);
const forkRef = useRef<THREE.Object3D | null>(null);
const phareRef = useRef<THREE.Object3D | null>(null);
const headlightRef = useRef<THREE.SpotLight | null>(null);
// SpotLight target — must live in the scene to define the cone direction.
const headlightTarget = useMemo(() => new THREE.Object3D(), []);
// Original quaternion of the Fourche node — rotation is applied on top of this.
const forkInitialQuatRef = useRef(new THREE.Quaternion());
// Smoothed steer angle for the fork (avoids direct Euler manipulation).
const forkAngleRef = useRef(0);
// Ref copy of movementMode — useFrame closures can capture stale React state.
const movementModeRef = useRef(movementMode);
// Becomes true the first time the player mounts. After that, dismounting
// must NOT reset position back to the original spawn point.
const hasRiddenRef = useRef(false);
// State for debug visualization (synced from refs during useFrame)
const [showCameraPoints, setShowCameraPoints] = useState(true);
const [debugRestingPosition, setDebugRestingPosition] =
useState<Vector3Tuple>([
position[0],
position[1] - PLAYER_EYE_HEIGHT,
position[2],
parkedPosition[0],
parkedPosition[1],
parkedPosition[2],
]);
// Keep movementModeRef in sync — useFrame closures capture React state at
// render time and can become stale between renders.
useEffect(() => {
if (model) {
const fork = model.getObjectByName("fourche");
if (fork) {
forkRef.current = fork;
}
movementModeRef.current = movementMode;
}, [movementMode]);
// SpotLight target must be in the scene to define the cone direction.
useEffect(() => {
threeScene.add(headlightTarget);
return () => { threeScene.remove(headlightTarget); };
}, [threeScene, headlightTarget]);
// Link the target to the SpotLight once it mounts.
useEffect(() => {
if (headlightRef.current) {
headlightRef.current.target = headlightTarget;
}
}, [headlightTarget]);
useEffect(() => {
if (movementMode === "ebike") {
// Player just mounted — mark as ridden so we never reset position again.
hasRiddenRef.current = true;
return;
}
if (hasRiddenRef.current) {
// Player dismounted: keep the position the bike was left at.
// Just make sure the window vars are up to date for the next mount.
window.ebikeParkedPosition = restingPositionRef.current;
window.ebikeParkedRotation = restingRotationRef.current;
return;
}
// Bike has never been ridden yet — safe to (re)place it at the spawn point.
// This also fires when parkedPosition recalculates (e.g. terrain loads late).
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) return;
let forkNode: THREE.Object3D | null = null;
model.traverse((child) => {
if (child.name.toLowerCase() === "fourche") forkNode = child;
if (child.name === "Phare") phareRef.current = child;
});
if (forkNode) {
forkRef.current = forkNode;
// Snapshot the rest-pose quaternion — steering is applied on top of this.
forkInitialQuatRef.current.copy((forkNode as THREE.Object3D).quaternion);
forkAngleRef.current = 0;
console.log("[Ebike] Fork found:", (forkNode as THREE.Object3D).name);
} else {
const names: string[] = [];
model.traverse((c) => { if (c.name) names.push(c.name); });
console.warn("[Ebike] Fork not found. All nodes:", names);
}
}, [model]);
useEffect(() => {
if (!model) return;
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.castShadow = true;
child.receiveShadow = true;
}
});
}, [model]);
useEffect(() => {
@@ -105,11 +220,48 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
}, []);
useFrame((_, delta) => {
// ── SpotLight headlight — tune the constants below ────────────────────────
// ── SpotLight headlight — tune these four constants ───────────────────────
const LIGHT_OFFSET_X = -0.7; // position : left(-) / right(+)
const LIGHT_OFFSET_Y = 1.5; // position : down(-) / up(+)
const LIGHT_OFFSET_Z = 0; // position : backward(-) / forward(+)
const LIGHT_AIM_DEG = 90; // aim rotation around Y : 0=forward, -90=left, +90=right
const LIGHT_TARGET_DIST = 20; // metres devant la position de la lumière
// ─────────────────────────────────────────────────────────────────────────
if (headlightRef.current && phareRef.current && groupRef.current) {
phareRef.current.getWorldPosition(_phareWorldPos);
groupRef.current.getWorldDirection(_bikeForward);
// Position offset in bike-local space (no GC — reusing module-level vectors)
const right = _bikeForward.clone().cross(_up).normalize();
_phareWorldPos
.addScaledVector(right, LIGHT_OFFSET_X)
.addScaledVector(_up, LIGHT_OFFSET_Y)
.addScaledVector(_bikeForward, LIGHT_OFFSET_Z);
headlightRef.current.position.copy(_phareWorldPos);
// Aim direction: rotate forward around Y by LIGHT_AIM_DEG
_aimDir
.copy(_bikeForward)
.applyAxisAngle(_up, THREE.MathUtils.degToRad(LIGHT_AIM_DEG));
headlightTarget.position
.copy(_phareWorldPos)
.addScaledVector(_aimDir, LIGHT_TARGET_DIST);
headlightTarget.updateMatrixWorld();
}
// ──────────────────────────────────────────────────────────────────────────
if (groupRef.current) {
if (movementMode === "ebike") {
// Use the ref — not the React state — to avoid stale closure bugs in
// R3F's frame loop (the state value may not update until the next render).
if (movementModeRef.current === "ebike") {
// Sound plays whenever the bike is actually moving (speedFactor > 5 %),
// not only while the input key is held.
updateEbikeSounds({
mounted: true,
driving: window.ebikeDriveInputActive === true,
driving: (window.ebikeSpeedFactor ?? 0) > 0.05,
breakdown: window.ebikeBreakdownActive === true,
});
@@ -120,16 +272,31 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
];
restingRotationRef.current = groupRef.current.rotation.y;
// Smoothly rotate the front fork ("fourche") up to 15 degrees in its own Z axis
// ── Fork steering via quaternion ──────────────────────────────────────
// We rotate around the fork's LOCAL Y axis (steering tube) by composing
// a fresh quaternion on top of the rest-pose snapshot taken at load time.
// This is axis-agnostic: correct regardless of how Blender exported the node.
// Tune FORK_ANGLE (radians) or negate it if the visual direction is wrong.
const FORK_ANGLE = 0.12; // 10°
const steerFactor = window.ebikeSteerFactor ?? 0;
if (forkRef.current) {
// 15 degrees is 0.26 radians
const targetForkRotation = steerFactor * 0.26;
forkRef.current.rotation.z = THREE.MathUtils.lerp(
forkRef.current.rotation.z,
targetForkRotation,
// Smooth the angle separately so we can apply it cleanly each frame.
forkAngleRef.current = THREE.MathUtils.lerp(
forkAngleRef.current,
steerFactor * FORK_ANGLE,
12 * delta,
);
// Build steer quat around LOCAL Y of the fork node.
const steerQuat = new THREE.Quaternion().setFromAxisAngle(
new THREE.Vector3(0, 1, 0),
forkAngleRef.current,
);
// Apply on top of rest-pose: Q_final = Q_rest × Q_steer
forkRef.current.quaternion.multiplyQuaternions(
forkInitialQuatRef.current,
steerQuat,
);
}
// Throttled GPS start position update to prevent performance loss
@@ -148,9 +315,10 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
groupRef.current.position.set(...restingPositionRef.current);
groupRef.current.rotation.set(0, restingRotationRef.current, 0);
// Reset fork rotation when parked
// Reset fork to rest-pose when parked
if (forkRef.current) {
forkRef.current.rotation.z = 0;
forkRef.current.quaternion.copy(forkInitialQuatRef.current);
forkAngleRef.current = 0;
}
}
window.ebikeParkedPosition = restingPositionRef.current;
@@ -169,16 +337,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,53 +440,76 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
return (
<>
<group
ref={groupRef}
position={position}
rotation={[0, EBIKE_WORLD_ROTATION_Y, 0]}
>
<primitive object={model} />
<InteractableObject
kind="trigger"
label={
mainState === "ebike" && ebikeStep === "waiting"
? "Inspecter l'e-bike"
: movementMode === "walk"
? "Monter sur le bike"
: "Descendre du bike"
}
position={position}
radius={15}
onPress={handleInteract}
{!repairGameOwnsEbikeModel ? (
<group
ref={groupRef}
position={parkedPosition}
rotation={[0, EBIKE_WORLD_ROTATION_Y, 0]}
scale={EBIKE_WORLD_SCALE}
>
<mesh>
<boxGeometry args={[10, 13, 2]} />
<meshBasicMaterial colorWrite={false} depthWrite={false} />
</mesh>
</InteractableObject>
<primitive object={model} />
{/* radius 20 → ~7 unités monde (scale 0.35).
Sphère omnidirectionnelle pour que le raycast fonctionne
quelle que soit l'orientation de la caméra (montée ou à pied). */}
<InteractableObject
kind="trigger"
label={interactionLabel}
position={parkedPosition}
radius={5}
onPress={handleInteract}
>
<mesh>
<sphereGeometry args={[8, 15, 12]} />
<meshBasicMaterial colorWrite={false} color={"red"} depthWrite={false} />
</mesh>
</InteractableObject>
{/* Dynamic 3D GPS Dashboard Screen */}
<group position={[0, 7, 0]} rotation={[0, 90, 0]}>
<EbikeGPSMap
width={0.8}
height={0.8}
startPos={gpsStartPos}
destPos={destPos}
mapImageUrl="/assets/world/gps/map_background.png"
worldBounds={{
minX: -166,
maxX: 163,
minZ: -142,
maxZ: 138,
}}
zoom={4}
/>
{/* GPS + Speedmeter same group so they are perfectly co-localised.
GPS: full circle (Fresnel mask), renderOrder 10 000
Speedmeter: upper-half arc overlay, renderOrder 10 001
rotation: Math.PI/2 radians = 90° (NOT the number 90 which = ~116.6°) */}
<group position={[2, 6, 0]} rotation={[0, -80, 0]}>
<EbikeSpeedmeter width={3} height={1.5} position={[0, 0.4, 0]} gaugeInnerR={0.33} gaugeOuterR={0.445}
gaugeWidth={2.5}
gaugeHeight={2.1}
gaugeOffsetX={0}
gaugeOffsetY={-0.19}
/>
<EbikeGPSMap
width={1.3}
height={1}
startPos={gpsStartPos}
destPos={destPos}
mapImageUrl="/assets/world/gps/map_background.png"
worldBounds={{
minX: -166,
maxX: 163,
minZ: -142,
maxZ: 138,
}}
zoom={4}
/>
</group>
</group>
</group>
) : null}
{showCameraPoints && (
{/* SpotLight headlight — cone aimed forward, position & target via useFrame */}
{!repairGameOwnsEbikeModel && (
<spotLight
ref={headlightRef}
intensity={100}
color="#ffca60"
angle={Math.PI / 5} // 22.5° demi-angle — cone étroit comme une torche
penumbra={0.5} // bord doux (0 = dur, 1 = très doux)
distance={50}
decay={2.5}
castShadow={false}
/>
)}
{showCameraPoints && !repairGameOwnsEbikeModel && (
<>
<mesh position={camPointPos}>
{/* <mesh position={camPointPos}>
<sphereGeometry args={[0.3, 16, 16]} />
<meshStandardMaterial
color="yellow"
@@ -319,7 +524,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
emissive="cyan"
emissiveIntensity={0.5}
/>
</mesh>
</mesh> */}
</>
)}
</>
+79 -37
View File
@@ -12,6 +12,28 @@ import {
} from "@/pathfinding/WaypointAStar";
import type { Waypoint } from "@/pathfinding/types";
import type { Vector3Tuple } from "@/types/three/three";
const VERT_SHADER = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
// Circular Fresnel mask: fully visible inside innerRadius, fades out to outerRadius
const FRAG_SHADER = /* glsl */ `
uniform sampler2D map;
uniform float innerRadius;
uniform float outerRadius;
varying vec2 vUv;
void main() {
vec4 color = texture2D(map, vUv);
float dist = length(vUv - vec2(0.5));
float mask = 1.0 - smoothstep(innerRadius, outerRadius, dist);
gl_FragColor = vec4(color.rgb, color.a * mask);
}
`;
function computeImageSource(
img: HTMLImageElement | HTMLCanvasElement,
baseBounds: { minX: number; maxX: number; minZ: number; maxZ: number },
@@ -89,6 +111,8 @@ export interface EbikeGPSMapProps {
* Default: 1
*/
zoom?: number;
renderOrder?: number;
}
/**
@@ -107,6 +131,7 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
position = [0, 0, 0],
canvasSize = 1024,
zoom = 1,
renderOrder = 10_000,
}) => {
const [waypoints, setWaypoints] = useState<Waypoint[]>([]);
const [mapImage, setMapImage] = useState<
@@ -123,19 +148,57 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps -- Canvas should only be created once
}, []);
// Resize the canvas whenever canvasSize changes
// Note: Modifying canvas dimensions is intentional and necessary for rendering
useEffect(() => {
// Use Object.assign to resize canvas - this is a necessary mutation for canvas rendering
Object.assign(offscreenCanvas, { width: canvasSize, height: canvasSize });
if (textureRef.current) {
textureRef.current.needsUpdate = true;
}
}, [canvasSize, offscreenCanvas]);
const textureRef = useRef<THREE.CanvasTexture | null>(null);
const animTimeRef = useRef<number>(0);
// Imperative CanvasTexture — must be declared before the resize effect below
const texture = useMemo(() => {
const tex = new THREE.CanvasTexture(offscreenCanvas);
tex.format = THREE.RGBAFormat;
tex.minFilter = THREE.LinearFilter;
tex.magFilter = THREE.LinearFilter;
return tex;
}, [offscreenCanvas]);
// ShaderMaterial with circular Fresnel mask (created once)
const shaderMat = useMemo(
() =>
new THREE.ShaderMaterial({
uniforms: {
map: { value: null },
innerRadius: { value: 0.45 },
outerRadius: { value: 0.5 },
},
vertexShader: VERT_SHADER,
fragmentShader: FRAG_SHADER,
transparent: true,
depthTest: false,
depthWrite: false,
side: THREE.DoubleSide,
toneMapped: false,
}),
[],
);
// Sync texture into uniform when it changes (canvas resize)
useEffect(() => {
shaderMat.uniforms.map.value = texture;
}, [shaderMat, texture]);
// Cleanup on unmount
useEffect(
() => () => {
shaderMat.dispose();
texture.dispose();
},
[shaderMat, texture],
);
// Resize the canvas whenever canvasSize changes (texture declared above)
useEffect(() => {
Object.assign(offscreenCanvas, { width: canvasSize, height: canvasSize });
texture.needsUpdate = true;
}, [canvasSize, offscreenCanvas, texture]);
// Load waypoints (localStorage with /roadNetwork.json fallback)
useEffect(() => {
let cancelled = false;
@@ -489,41 +552,20 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
useEffect(() => {
let animId: number;
const tick = () => {
animTimeRef.current += 0.004; // Slow, premium sweep speed
animTimeRef.current += 0.004;
if (animTimeRef.current > 1) animTimeRef.current = 0;
draw();
// Update texture after draw
if (textureRef.current) {
textureRef.current.needsUpdate = true;
}
texture.needsUpdate = true;
animId = requestAnimationFrame(tick);
};
animId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(animId);
}, [draw]);
}, [draw, texture]);
return (
<mesh castShadow receiveShadow position={position}>
<mesh position={position} renderOrder={renderOrder}>
<planeGeometry args={[width, height]} />
<meshBasicMaterial
toneMapped={false}
transparent={true}
opacity={1}
depthWrite={false}
side={THREE.DoubleSide}
>
<canvasTexture
ref={textureRef}
attach="map"
image={offscreenCanvas}
format={THREE.RGBAFormat}
minFilter={THREE.LinearFilter}
magFilter={THREE.LinearFilter}
/>
</meshBasicMaterial>
<primitive object={shaderMat} attach="material" />
</mesh>
);
};
+233
View File
@@ -0,0 +1,233 @@
import { useEffect, useRef, useMemo } from "react";
import { useFrame } from "@react-three/fiber";
import { useTexture } from "@react-three/drei";
import * as THREE from "three";
import type { Vector3Tuple } from "@/types/three/three";
import "@/types/ebike/ebikeWindow";
const SPEEDOMETER_DIAL_TEXTURE = "/assets/world/gps/cadran.png";
const SPEEDOMETER_NEEDLE_TEXTURE = "/assets/world/gps/fleche.png";
export interface EbikeSpeedmeterProps {
width?: number;
height?: number;
/** Local position offset within the parent group. Default: [0, 0, 0] */
position?: Vector3Tuple;
/**
* Needle rotation.z when speedFactor = 0.
* Default: Math.PI / 2 (pointing left — 9 o'clock)
*/
minAngle?: number;
/**
* Needle rotation.z when speedFactor = 1.
* Default: -Math.PI / 2 (pointing right — 3 o'clock)
*/
maxAngle?: number;
renderOrder?: number;
/**
* Inner radius of the gauge-fill arc, as a fraction of the canvas half-width.
* Tune this to align the fill with the cadran.png track. Default: 0.33
*/
gaugeInnerR?: number;
/**
* Outer radius of the gauge-fill arc, as a fraction of the canvas half-width.
* Tune this to align the fill with the cadran.png track. Default: 0.445
*/
gaugeOuterR?: number;
/**
* Width of the gauge-fill plane. Defaults to `width` when omitted.
* Lets you resize the fill independently of the cadran/needle.
*/
gaugeWidth?: number;
/**
* Height of the gauge-fill plane. Defaults to `height` when omitted.
* Lets you resize the fill independently of the cadran/needle.
*/
gaugeHeight?: number;
/**
* Horizontal offset of the arc pivot from the canvas centre.
* Expressed as a fraction of the canvas size: -0.1 = shift 10 % to the left,
* +0.1 = shift 10 % to the right. Default: 0
*/
gaugeOffsetX?: number;
/**
* Vertical offset of the arc pivot from its default position.
* Expressed as a fraction of the canvas size: -0.1 = shift upward (toward top
* of the plane), +0.1 = shift downward. Default: 0
*/
gaugeOffsetY?: number;
}
// The needle pivot is always at -height*0.38 in local space,
// which is always 12 % from the bottom of the plane (UV y = 0.12).
// With Three.js flipY texture convention, canvas y = (1 - 0.12) * size = 0.88 * size.
const NEEDLE_PIVOT_UV_Y = 0.12; // fraction from bottom
export function EbikeSpeedmeter({
width = 0.8,
height = 0.8,
position = [0, 0, 0],
minAngle = Math.PI / 2,
maxAngle = -Math.PI / 2,
renderOrder = 1000,
gaugeInnerR = 0.33,
gaugeOuterR = 0.445,
gaugeWidth,
gaugeHeight,
gaugeOffsetX = 0,
gaugeOffsetY = 0,
}: EbikeSpeedmeterProps): React.JSX.Element {
// Fall back to the main dimensions when gauge-specific ones aren't provided
const fillW = gaugeWidth ?? width;
const fillH = gaugeHeight ?? height;
const needleGroupRef = useRef<THREE.Group>(null);
const speedFactorRef = useRef(0);
// ── Dial & needle textures ──────────────────────────────────────────────────
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((tex) => {
tex.colorSpace = THREE.SRGBColorSpace;
tex.needsUpdate = true;
});
}, [dialTexture, needleTexture]);
// ── Gauge-fill canvas ───────────────────────────────────────────────────────
const fillCanvas = useMemo(() => {
const c = document.createElement("canvas");
c.width = 256;
c.height = 256;
return c;
}, []);
const fillTexture = useMemo(() => {
const tex = new THREE.CanvasTexture(fillCanvas);
tex.format = THREE.RGBAFormat;
tex.minFilter = THREE.LinearFilter;
tex.magFilter = THREE.LinearFilter;
return tex;
}, [fillCanvas]);
useEffect(
() => () => {
fillTexture.dispose();
},
[fillTexture],
);
// ── Frame loop ──────────────────────────────────────────────────────────────
useFrame((_, delta) => {
// 1. Smooth speed factor
const target = THREE.MathUtils.clamp(window.ebikeSpeedFactor ?? 0, 0, 1);
speedFactorRef.current = THREE.MathUtils.lerp(
speedFactorRef.current,
target,
Math.min(1, delta * 10),
);
// 2. Needle rotation
if (needleGroupRef.current) {
needleGroupRef.current.rotation.z = THREE.MathUtils.lerp(
minAngle,
maxAngle,
speedFactorRef.current,
);
}
// 3. Draw gauge fill -------------------------------------------------------
const ctx = fillCanvas.getContext("2d", { alpha: true });
if (!ctx) return;
const size = fillCanvas.width;
ctx.clearRect(0, 0, size, size);
// Default centre: horizontal middle + needle-pivot height.
// gaugeOffsetX/Y shift the pivot so the arc aligns with cadran.png.
const cx = size * (0.5 + gaugeOffsetX);
const cy = size * ((1 - NEEDLE_PIVOT_UV_Y) + gaugeOffsetY); // default ≈ 0.88 × size
const outerR = size * gaugeOuterR;
const innerR = size * gaugeInnerR;
// Arc sweeps clockwise from π (left) to current needle angle
const arcStart = Math.PI;
const arcEnd = Math.PI + speedFactorRef.current * Math.PI;
if (speedFactorRef.current > 0.005) {
// Radial gradient using #3F67DD — slightly transparent at inner edge,
// fully solid at outer edge for a depth effect.
const radial = ctx.createRadialGradient(cx, cy, innerR, cx, cy, outerR);
radial.addColorStop(0, "rgba(191, 234, 255, 0)"); // inner edge
radial.addColorStop(0.7, "rgba(118, 152, 255, 0.95)"); // outer edge
// Annular sector shape (outer arc + inner arc reversed)
ctx.beginPath();
ctx.arc(cx, cy, outerR, arcStart, arcEnd, false);
ctx.arc(cx, cy, innerR, arcEnd, arcStart, true);
ctx.closePath();
ctx.fillStyle = radial;
ctx.shadowBlur = 16;
ctx.shadowColor = "#3F67DD";
ctx.fill();
ctx.shadowBlur = 0;
}
fillTexture.needsUpdate = true;
});
return (
<group renderOrder={renderOrder} position={position}>
{/* Gauge fill — behind the cadran frame (size controlled by gaugeWidth/gaugeHeight) */}
<mesh renderOrder={renderOrder - 1} position={[0, 0, -0.001]}>
<planeGeometry args={[fillW, fillH]} />
<meshBasicMaterial
map={fillTexture}
transparent
depthTest={false}
depthWrite={false}
toneMapped={false}
side={THREE.DoubleSide}
/>
</mesh>
{/* Dial frame (cadran.png) */}
<mesh renderOrder={renderOrder}>
<planeGeometry args={[width, height]} />
<meshBasicMaterial
map={dialTexture}
transparent
depthTest={false}
depthWrite={false}
toneMapped={false}
side={THREE.DoubleSide}
/>
</mesh>
{/* Needle — pivot at bottom-centre of the arc */}
<group ref={needleGroupRef} position={[0, -height * 0.38, 0.002]} rotation={[0, 0, 0]}>
<mesh
position={[0, needleHeight / 2, 0]}
renderOrder={renderOrder + 1}
>
<planeGeometry args={[needleWidth, needleHeight]} />
<meshBasicMaterial
map={needleTexture}
transparent
depthTest={false}
depthWrite={false}
toneMapped={false}
side={THREE.DoubleSide}
/>
</mesh>
</group>
</group>
);
}
+90
View File
@@ -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<THREE.Group>(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 (
<group renderOrder={SPEEDOMETER_RENDER_ORDER}>
<mesh renderOrder={SPEEDOMETER_RENDER_ORDER}>
<planeGeometry args={[width, height]} />
<meshBasicMaterial
map={dialTexture}
transparent
depthTest={false}
depthWrite={false}
toneMapped={false}
side={THREE.DoubleSide}
/>
</mesh>
<group ref={needleGroupRef} position={[0, -height * 0.38, 0.002]}>
<mesh
position={[0, needleHeight / 2, 0]}
renderOrder={SPEEDOMETER_RENDER_ORDER + 1}
>
<planeGeometry args={[needleWidth, needleHeight]} />
<meshBasicMaterial
map={needleTexture}
transparent
depthTest={false}
depthWrite={false}
toneMapped={false}
side={THREE.DoubleSide}
/>
</mesh>
</group>
</group>
);
}
+58 -11
View File
@@ -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<THREE.Vector3 | null>(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 <MissionNotification mission="ebike" />;
}
return (
<MissionNotification
mission="ebike"
visible={introStep === "await-ebike-mount"}
imagePath={INTRO_MISSION_NOTIFICATION_IMAGE_PATH}
visible={
introStep === "reveal" ||
introStep === "await-ebike-mount" ||
introStep === "ebike-intro-ride"
}
/>
);
}
+1 -1
View File
@@ -20,7 +20,7 @@ export function SiteMobileBlocker(): React.JSX.Element {
}}
>
<img
src="/assets/logo/logo.jpg"
src="/assets/logo.png"
alt="Logo Altera"
style={{ width: 120, height: "auto" }}
/>
+2
View File
@@ -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 {
<InteractPrompt />
<HandTrackingVisualizer />
<Subtitles />
<TalkieDialogueOverlay />
<GameSettingsMenu />
</>
);
+25 -6
View File
@@ -2,14 +2,20 @@ 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] : "");
const isVideo = src.toLowerCase().endsWith(".webm");
return (
<div
className={`mission-notification${visible ? "" : " mission-notification--hidden"}`}
@@ -17,11 +23,24 @@ export function MissionNotification({
>
<div className="mission-notification__glow" />
<span className="mission-notification__image-wrap">
<img
className="mission-notification__image"
src={MISSION_NOTIFICATION_IMAGE_PATHS[mission]}
alt="Nouvel objectif de mission"
/>
{isVideo ? (
<video
className="mission-notification__image"
src={src}
aria-label="Nouvel objectif de mission"
autoPlay
loop
muted
playsInline
preload="auto"
/>
) : (
<img
className="mission-notification__image"
src={src}
alt="Nouvel objectif de mission"
/>
)}
</span>
</div>
);
+29 -4
View File
@@ -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 (
<div
@@ -33,7 +58,7 @@ export function SceneLoadingOverlay({
<img
alt="La Fabrik Durable"
className="scene-loading-overlay__logo"
src={LOADING_LOGO_PATH}
src={logoFramePath}
/>
<div className="scene-loading-overlay__footer">
<div className="scene-loading-overlay__meta">
@@ -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 (
<aside
className={`talkie-dialogue-overlay${isNarratorDialogue ? " talkie-dialogue-overlay--active" : ""}`}
aria-hidden="true"
>
{isNarratorDialogue ? <TalkieSignalLines side="left" /> : null}
{isNarratorDialogue ? <TalkieSignalLines side="right" /> : null}
<div className="talkie-dialogue-overlay__model-frame">
<Canvas
camera={{ position: [0, 0, 4.2], zoom: 56 }}
dpr={[1, 1.5]}
gl={{ alpha: true, antialias: true }}
orthographic
>
<ambientLight intensity={2.5} />
<directionalLight position={[2, 3, 4]} intensity={2.8} />
<Suspense fallback={null}>
<TalkieModel active={isNarratorDialogue} />
</Suspense>
</Canvas>
</div>
</aside>
);
}
+82
View File
@@ -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<THREE.Group>(null);
const floatRef = useRef<THREE.Group>(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 (
<group ref={groupRef} position={[0, TALKIE_REST_Y, 0]}>
<group ref={floatRef} rotation={TALKIE_BASE_ROTATION}>
<primitive
object={model}
position={[0, -2.45, 0]}
rotation={[0, -1, 0]}
scale={1.2}
/>
</group>
</group>
);
}
useGLTF.preload(TALKIE_MODEL_PATH);
@@ -0,0 +1,19 @@
interface TalkieSignalLinesProps {
side: "left" | "right";
}
export function TalkieSignalLines({
side,
}: TalkieSignalLinesProps): React.JSX.Element {
return (
<svg
className={`talkie-dialogue-overlay__signals talkie-dialogue-overlay__signals--${side}`}
viewBox="0 0 90 120"
aria-hidden="true"
>
<path d="M18 48 C30 58 30 72 18 82" />
<path d="M34 34 C56 52 56 78 34 96" />
<path d="M52 20 C84 46 84 84 52 110" />
</svg>
);
}
+6 -6
View File
@@ -6,22 +6,22 @@ export interface CameraTransform {
}
export const EBIKE_CAMERA_TRANSFORM: CameraTransform = {
position: [-3.5, 6, 0],
position: [-1, 1, 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;
export const EBIKE_ACCELERATION_DURATION_MS = 2000;
export const EBIKE_DECELERATION_DURATION_MS = 2000;
+6 -3
View File
@@ -1,8 +1,11 @@
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<RepairMissionId, string> =
{
ebike: "/assets/world/UI/ebike-mission-notification.png",
pylon: "/assets/world/UI/pylon-mission-notification.png",
farm: "/assets/world/UI/farm-mission-notification.png",
ebike: "/assets/world/UI/ebike-mission-notification.webm",
pylon: "/assets/world/UI/pylon-mission-notification.webm",
farm: "/assets/world/UI/farm-mission-notification.webm",
};
+3 -3
View File
@@ -21,7 +21,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
"Repair the damaged cooling module before relaunching the bike",
modelPath: "/models/ebike/model.gltf",
modelScale: 0.3,
stageUiPath: "/assets/world/UI/ebike.webm",
stageUiPath: "/assets/world/UI/ebike-mission-notification.webm",
interactUiPath: REPAIR_INTERACT_UI_PATH,
brokenUiPath: REPAIR_BROKEN_UI_PATH,
case: DEFAULT_REPAIR_CASE,
@@ -59,7 +59,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
description:
"Restore the pylon lamp relay and damaged panel before reconnecting the grid",
modelPath: "/models/pylone/model.gltf",
stageUiPath: "/assets/world/UI/centrale.webm",
stageUiPath: "/assets/world/UI/pylon-mission-notification.webm",
interactUiPath: REPAIR_INTERACT_UI_PATH,
brokenUiPath: REPAIR_BROKEN_UI_PATH,
case: DEFAULT_REPAIR_CASE,
@@ -104,7 +104,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
description:
"Stabilize the irrigation loop and humidity sensor before restarting the farm",
modelPath: "/models/fermeverticale/model.gltf",
stageUiPath: "/assets/world/UI/laferme.webm",
stageUiPath: "/assets/world/UI/farm-mission-notification.webm",
interactUiPath: REPAIR_INTERACT_UI_PATH,
brokenUiPath: REPAIR_BROKEN_UI_PATH,
case: DEFAULT_REPAIR_CASE,
+6 -1
View File
@@ -1,4 +1,5 @@
import type { Vector3Tuple } from "@/types/three/three";
import { LA_FABRIK_PLAYER_SPAWN } from "@/data/world/laFabrikConfig";
export const PLAYER_EYE_HEIGHT = 1.75;
export const PLAYER_CAPSULE_RADIUS = 0.35;
@@ -14,5 +15,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];
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -19,7 +19,7 @@ export const CLOUD_DEFAULTS = {
maxRotation: Math.PI * 2,
minSpeedMultiplier: 0.4,
maxSpeedMultiplier: 1,
castShadow: false,
castShadow: true,
receiveShadow: false,
};
+2 -4
View File
@@ -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,
+29
View File
@@ -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
);
}
+9
View File
@@ -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;
+61
View File
@@ -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<string, MapOctreeCollisionBox>;
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;
}
+66
View File
@@ -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);
});
});
}
+2 -1
View File
@@ -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]);
}

Some files were not shown because too many files have changed in this diff Show More