15 Commits

Author SHA1 Message Date
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
18 changed files with 488 additions and 179 deletions
+34 -21
View File
@@ -41,29 +41,42 @@ 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 (open investigation)
## Shadow rendering intermittence
Shadows occasionally fail to render on initial load even though the
`Lighting` configuration runs to completion (verified through diagnostic logs).
The issue is not deterministic across runs with identical config. Suspected
contributors:
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:
- WebGL context restoration timing (`webglcontextrestored` rebinds shadow map
state in `src/pages/page.tsx`).
- First-frame shadow map being rendered before any mesh has its
`castShadow`/`receiveShadow` flag set; `autoUpdate=true` should fix it on the
next frame, but a single dropped frame is still visible at very first paint.
- HMR/state interactions in dev mode that do not occur in production builds.
### Per-frame refresh (steady state)
Mitigations already applied:
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`).
- Shadow config centralized in `src/data/world/lightingConfig.ts`
(`bias=0`, `normalBias=0`, `cameraSize=95`, matching the historically working
values from `develop`).
- Late-suspension Suspense boundaries in `World.tsx` to prevent global scene
remounts that would re-run shadow setup mid-load.
### Mount-time shadow map reallocation (`useShadowMapWarmup`)
If the issue reproduces in production, capture a screenshot plus the
`[diag]`-style logs from `useOctreeGraphNode`, `Lighting`, and `GameMapCollision`
to confirm whether the third configuration pass is happening (which would
indicate a remaining suspending hook outside the existing Suspense boundaries).
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.
@@ -1,6 +1,10 @@
import { useMemo } from "react";
import { Box3, BufferAttribute, BufferGeometry, Color } from "three";
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 {
@@ -11,8 +15,19 @@ 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],
@@ -28,22 +43,50 @@ const BOX_VERTEX_INDEX_PAIRS: ReadonlyArray<readonly [number, number]> = [
[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,
maxDepth: number,
options: CollectOptions,
depth = 0,
acc: OctreeNodeBox[] = [],
): OctreeNodeBox[] {
if (depth > maxDepth) return acc;
if (depth > options.maxDepth) return acc;
acc.push({
box: node.box,
depth,
triangleCount: node.triangles.length,
});
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, maxDepth, depth + 1, acc);
collectOctreeBoxes(sub, options, depth + 1, acc);
}
return acc;
@@ -55,17 +98,12 @@ function buildOctreeLineGeometry(
const positionsBuffer = new Float32Array(
nodes.length * BOX_VERTEX_INDEX_PAIRS.length * 2 * 3,
);
const colorsBuffer = 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;
let colorsOffset = 0;
const colorHelper = new Color();
for (const node of nodes) {
const { min, max } = node.box;
@@ -79,9 +117,6 @@ function buildOctreeLineGeometry(
corners[6] = [min.x, max.y, max.z];
corners[7] = [max.x, max.y, max.z];
const hue = (node.depth * 0.13) % 1;
colorHelper.setHSL(hue, 0.85, 0.55);
for (const [a, b] of BOX_VERTEX_INDEX_PAIRS) {
const ca = corners[a]!;
const cb = corners[b]!;
@@ -91,19 +126,11 @@ function buildOctreeLineGeometry(
positionsBuffer[positionsOffset++] = cb[0];
positionsBuffer[positionsOffset++] = cb[1];
positionsBuffer[positionsOffset++] = cb[2];
colorsBuffer[colorsOffset++] = colorHelper.r;
colorsBuffer[colorsOffset++] = colorHelper.g;
colorsBuffer[colorsOffset++] = colorHelper.b;
colorsBuffer[colorsOffset++] = colorHelper.r;
colorsBuffer[colorsOffset++] = colorHelper.g;
colorsBuffer[colorsOffset++] = colorHelper.b;
}
}
const geometry = new BufferGeometry();
geometry.setAttribute("position", new BufferAttribute(positionsBuffer, 3));
geometry.setAttribute("color", new BufferAttribute(colorsBuffer, 3));
return geometry;
}
@@ -111,14 +138,23 @@ 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, maxDepth);
const boxes = collectOctreeBoxes(octree, {
minDepth,
maxDepth,
leavesOnly,
fabrikOnly,
});
if (boxes.length === 0) return null;
return buildOctreeLineGeometry(boxes);
}, [maxDepth, octree, showOctree]);
}, [fabrikOnly, leavesOnly, maxDepth, minDepth, octree, showOctree]);
if (!geometry) return null;
@@ -126,11 +162,11 @@ export function DebugOctreeVisualization({
<lineSegments frustumCulled={false} renderOrder={999}>
<primitive object={geometry} attach="geometry" />
<lineBasicMaterial
vertexColors
color="#22d3ee"
depthTest={false}
depthWrite={false}
transparent
opacity={0.85}
opacity={opacity}
/>
</lineSegments>
);
+12 -77
View File
@@ -1,87 +1,24 @@
import { Suspense, useEffect, useMemo, useRef } from "react";
import { Canvas, useFrame } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import * as THREE from "three";
import { useGameStore } from "@/managers/stores/useGameStore";
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
const TALKIE_MODEL_PATH = "/models/talkie/model.gltf";
const TALKIE_REVEAL_STEPS = new Set([
"reveal",
"await-ebike-mount",
"ebike-intro-ride",
"ebike-breakdown",
"completed",
]);
function TalkieModel(): React.JSX.Element {
const { scene } = useGLTF(TALKIE_MODEL_PATH);
const model = useMemo(() => scene.clone(true), [scene]);
const groupRef = useRef<THREE.Group>(null);
useEffect(() => {
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.castShadow = false;
child.receiveShadow = false;
child.frustumCulled = false;
}
});
}, [model]);
useFrame(({ clock }) => {
if (!groupRef.current) return;
const t = clock.getElapsedTime();
groupRef.current.rotation.z = Math.sin(t * 22) * 0.025;
groupRef.current.position.y = Math.sin(t * 6) * 0.012;
});
return (
<group ref={groupRef}>
<primitive
object={model}
position={[0, -0.18, 0]}
rotation={[0.18, Math.PI, -0.08]}
scale={1.45}
/>
</group>
);
}
function TalkieSignalLines(): React.JSX.Element {
return (
<svg
className="talkie-dialogue-overlay__signals"
viewBox="0 0 120 160"
aria-hidden="true"
>
<path d="M34 20 C52 44 16 66 34 92 C48 112 22 128 30 146" />
<path d="M68 12 C92 44 50 70 70 104 C84 130 48 142 52 154" />
<path d="M100 8 C124 42 82 76 100 112 C112 136 74 150 78 158" />
</svg>
);
}
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 activeSubtitle = useSubtitleStore((state) => state.activeSubtitle);
const mainState = useGameStore((state) => state.mainState);
const introStep = useGameStore((state) => state.intro.currentStep);
const isAfterReveal =
mainState !== "intro" || TALKIE_REVEAL_STEPS.has(introStep);
const isNarratorDialogue = activeSubtitle?.speaker === "Narrateur";
const { isNarratorDialogue, isVisible } = useTalkieDialogueOverlayState();
if (!isAfterReveal || !isNarratorDialogue) return null;
if (!isVisible) return null;
return (
<aside
className="talkie-dialogue-overlay talkie-dialogue-overlay--raised"
className={`talkie-dialogue-overlay${isNarratorDialogue ? " talkie-dialogue-overlay--active" : ""}`}
aria-hidden="true"
>
<TalkieSignalLines />
{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: 78 }}
camera={{ position: [0, 0, 4.2], zoom: 56 }}
dpr={[1, 1.5]}
gl={{ alpha: true, antialias: true }}
orthographic
@@ -89,12 +26,10 @@ export function TalkieDialogueOverlay(): React.JSX.Element | null {
<ambientLight intensity={2.5} />
<directionalLight position={[2, 3, 4]} intensity={2.8} />
<Suspense fallback={null}>
<TalkieModel />
<TalkieModel active={isNarratorDialogue} />
</Suspense>
</Canvas>
</div>
</aside>
);
}
useGLTF.preload(TALKIE_MODEL_PATH);
+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>
);
}
+8 -4
View File
@@ -23,10 +23,14 @@ export const MAP_OCTREE_COLLISION_BOXES = {
} as const satisfies Record<string, MapOctreeCollisionBox>;
export const LA_FABRIK_INTERIOR_COLLISION_BOXES = [
{
center: [-6.9351, 2.278, -0.0001],
size: [0.2, 1.94, 3.711],
},
// 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],
+36 -3
View File
@@ -3,10 +3,15 @@ import { useDebugVisualsStore } from "@/managers/stores/useDebugVisualsStore";
export function useDebugVisualsDebug(): void {
useDebugFolder("Debug", (folder) => {
const state = useDebugVisualsStore.getState();
const controls = {
showPlayerModel: useDebugVisualsStore.getState().showPlayerModel,
showOctree: useDebugVisualsStore.getState().showOctree,
octreeMaxDepth: useDebugVisualsStore.getState().octreeMaxDepth,
showPlayerModel: state.showPlayerModel,
showOctree: state.showOctree,
octreeMinDepth: state.octreeMinDepth,
octreeMaxDepth: state.octreeMaxDepth,
octreeLeavesOnly: state.octreeLeavesOnly,
octreeOpacity: state.octreeOpacity,
octreeFabrikOnly: state.octreeFabrikOnly,
};
folder
@@ -23,11 +28,39 @@ export function useDebugVisualsDebug(): void {
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);
});
});
}
+1 -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";
+105
View File
@@ -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<DirectionalLight | null>;
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<Material>();
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;
}
@@ -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,
};
}
+20 -17
View File
@@ -1240,27 +1240,24 @@ canvas {
/* Dialogue talkie */
.talkie-dialogue-overlay {
position: fixed;
left: clamp(12px, 2.2vw, 28px);
bottom: clamp(24px, 7vh, 76px);
left: 0;
bottom: 0;
z-index: 16;
width: clamp(120px, 13vw, 190px);
width: clamp(190px, 18vw, 300px);
aspect-ratio: 1;
pointer-events: none;
transform: translateY(0);
transition: transform 180ms ease;
}
.talkie-dialogue-overlay--raised {
transform: translateY(-10px);
}
.talkie-dialogue-overlay__model-frame {
position: absolute;
inset: 0;
animation: talkie-radio-shake 1s ease-in-out infinite;
filter: drop-shadow(0 16px 22px rgba(0, 0, 0, 0.55));
}
.talkie-dialogue-overlay--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;
@@ -1268,16 +1265,25 @@ canvas {
.talkie-dialogue-overlay__signals {
position: absolute;
right: -26%;
bottom: 34%;
top: 52%;
z-index: 2;
width: 58%;
height: 78%;
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);
@@ -1327,18 +1333,15 @@ canvas {
0%,
100% {
opacity: 0.28;
transform: translate3d(-4px, 4px, 0) scale(0.92);
}
18%,
38% {
opacity: 0.95;
transform: translate3d(0, 0, 0) scale(1);
}
60% {
opacity: 0.45;
transform: translate3d(4px, -6px, 0) scale(1.05);
}
}
+17 -1
View File
@@ -7,6 +7,14 @@ interface DebugVisualsStore {
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<DebugVisualsStore>((set) => ({
@@ -14,6 +22,14 @@ export const useDebugVisualsStore = create<DebugVisualsStore>((set) => ({
setShowPlayerModel: (showPlayerModel) => set({ showPlayerModel }),
showOctree: false,
setShowOctree: (showOctree) => set({ showOctree }),
octreeMaxDepth: 6,
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 }),
}));
+19 -11
View File
@@ -272,18 +272,20 @@ function CollisionModelInstance({
});
const sceneInstance = useClonedObject(scene);
useEffect(() => {
// Strip the door slab from the la fabrik collision octree so the player
// can walk through the doorway. The visual model is rendered separately
// by MergedStaticMapModel and is unaffected.
if (node.name !== "lafabrik") return;
const removed: THREE.Object3D[] = [];
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 (child.name === "porte") {
removed.push(child);
if (isDoorSlab(child.name) || isDoorFrameThickenChild(child)) {
doorMeshes.push(child);
}
});
for (const child of removed) {
for (const child of doorMeshes) {
child.removeFromParent();
}
}, [node.name, sceneInstance]);
@@ -326,10 +328,16 @@ function CollisionModelInstance({
function CollisionBox({ box }: { box: OctreeCollisionBox }): React.JSX.Element {
return (
<mesh position={box.center}>
<boxGeometry args={box.size} />
<meshBasicMaterial />
</mesh>
<group position={box.center}>
<mesh>
<boxGeometry args={box.size} />
<meshBasicMaterial />
</mesh>
<mesh rotation={[0, Math.PI, 0]}>
<boxGeometry args={box.size} />
<meshBasicMaterial />
</mesh>
</group>
);
}
+33 -14
View File
@@ -27,6 +27,7 @@ import {
} 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";
function configureRendererShadows(gl: WebGLRenderer): void {
@@ -51,9 +52,24 @@ function configureSunShadow(sun: DirectionalLight, sunTarget: Object3D): void {
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<AmbientLight>(null);
const sun = useRef<DirectionalLight>(null);
const sunTarget = useRef<Object3D>(null);
@@ -61,9 +77,16 @@ export function Lighting(): React.JSX.Element {
useEffect(() => {
if (!sun.current || !sunTarget.current) return;
configureSunShadow(sun.current, sunTarget.current);
configureRendererShadows(gl);
}, [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");
@@ -103,18 +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();
}
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 (