15 Commits

Author SHA1 Message Date
Tom Boullay e073fc375b fix(world): warm up map shadows from environment
🔍 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:00:40 +02:00
Tom Boullay bff8a16290 feat(intro): add ebike onboarding sequence
🔍 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 10:42:46 +02:00
Tom Boullay a3f611e227 fix(webgl): auto-restore context after loss
🔍 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 Canvas onCreated callback used to log Context Lost but never asked
the GPU to restore it, which left the page on a frozen black canvas
until the user reloaded. We now grab the WEBGL_lose_context extension
on mount and call restoreContext() 500ms after a loss, giving the GPU
time to free memory before we ask for a new context. The existing
webglcontextrestored handler reinstates the shadow map settings, so
recovery is transparent to the user.

This does not prevent context loss itself — frequent losses still
indicate VRAM pressure or HMR-driven context churn — but it removes
the need to reload manually when the GPU recycles us.
2026-05-30 20:58:58 +02:00
Tom Boullay b578e68c2e Update SiteTransitionOverlay.tsx 2026-05-30 20:55:51 +02:00
Tom Boullay 7c691a8044 fix: show dialogue subtitles on black screen
🔍 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-30 20:25:21 +02:00
Tom Boullay f24704091a chore(logging): downgrade 'lite map skipped' to debug
This log fires every time the lite map loader skips heavy nodes, which
is the expected fast-path. It does not need to show up in a normal
console session — moving it to logger.debug keeps it accessible under
?debug for diagnostics while removing the noise from default runs.
2026-05-30 20:20:15 +02:00
Tom Boullay e6bfcbe960 feat(intro): polish loading transition 2026-05-30 20:11:40 +02:00
Tom Boullay 0fa7a82175 fix(perf): prevent Canvas double-mount on /site redirect
HomePage used to mount the Canvas before its effect fired the redirect
to /site, then unmount it as soon as the route changed. That left the
WebGL context torn down mid-load with GLTF requests still in flight,
which on slow GPUs ended in a 'Context Lost' and a stuck 1 FPS render
once the user came back from /site. The fix is a synchronous cookie
check after all hooks: if the user has not visited /site today we
return null and let the redirect happen without ever creating a GL
context.

Also drops the GameMap 'lite map skipped' log from warn to info: it
is an expected lite-loading path, not a problem worth a yellow warning.
2026-05-30 19:51:57 +02:00
Tom Boullay 82dc47a296 fix(assets): correct texture URIs in gant_r and pylone GLTFs
- gant_r/model.gltf: align casing with the actual files on disk
  (gant_basecolor / gant_occlusionroughnessmetallic) so the GLTFLoader
  stops logging 'Couldn't load texture' on case-sensitive filesystems
- pylone/model.gltf: nine missing Image_N.png references replaced with
  the existing color.png / normal.png so the pylone renders without
  console errors. Material slots are mapped by role: normalTexture ->
  normal.png, baseColorTexture and metallicRoughnessTexture -> color.png
2026-05-30 19:51:51 +02:00
Tom Boullay 970adf4853 feat(a11y): WCAG AA polish on the site onboarding flow
🔍 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
- index.css: add visible :focus-visible rings for .site-card-button
  and .site-button so keyboard users can see where focus lives
- SiteCard: drop outline:none, add aria-pressed and aria-label so
  screen readers announce selection state
- SiteButton: add the .site-button class for the shared focus ring
- SiteDisclaimerScreen: keyboard skip via Enter / Space / Escape, a
  role="region" + aria-label wrapper and aria-live="polite" on the
  message; honour prefers-reduced-motion on the fade
- IntroVideoPlayer: role="region" with a skip hint in aria-label,
  preload="auto", and aria-hidden on the decorative caption span
2026-05-30 18:44:03 +02:00
Tom Boullay 07b09c22af fix(site): repair onboarding audio cleanup, redirect, and manifest fetches
- loadDialogueManifest: cache the resolved manifest at module level and
  dedupe concurrent fetches so each screen no longer re-downloads it
- useGameStore: completeIntroState now also advances intro.currentStep
  to "playing" so callers do not need a separate setIntroStep call
- SiteNamingScreen and SiteTransitionOverlay: replace ref-based guards
  with an isCancelled flag captured per effect. The previous guards
  persisted across StrictMode remounts, leaving mount 2 unable to
  re-run the effect after mount 1's chain was cancelled, which broke
  the fade animations, the second narrator dialogue and the redirect.
  Both screens now also call stopCurrentDialogue on unmount so audio
  cannot bleed across routes, and the transition gets a safety timeout
  in case the dialogue audio fails to fire its "ended" event
- SiteTransitionOverlay: keep the <Subtitles /> mount inside the
  overlay so it renders inside the z-index 1000 stacking context
  (above the black screen); the one in SiteLayout sits behind it
- IntroDialogueOverlay: route through playDialogueById instead of
  AudioManager.playSoundWithCallback so the narrator subtitles play
  in sync, and add the same isCancelled cleanup pattern
- IntroRevealOverlay: rely on completeIntro alone now that it advances
  intro.currentStep, and skip the fade when reduced motion is requested
- SiteMobileBlocker: correct logo path from public/... to /...
2026-05-30 18:43:53 +02:00
Tom Boullay 0f6860f1ae refactor(site): extract shared utilities and centralise dialogue IDs
- new src/hooks/ui/useIsMobile.ts (matchMedia + useSyncExternalStore)
  replacing the resize-handler hook inlined inside pages/site/page.tsx
- new src/hooks/ui/usePrefersReducedMotion.ts
- new src/data/site/dialogueIds.ts so site and intro components stop
  carrying hard-coded narrator IDs
- siteConfig: add SITE_BACKGROUND_STYLE shared by SiteLayout and
  SiteMobileBlocker, rename forcedName to presetPlayerName, fix the
  swapped id/label pairing on situation cards
- useSiteStore: rename selectedExperience/Situation to *Index so the
  stored value (an array index) is obvious in callers
- audioConfig: drop dead AUDIO_PATHS placeholders
- propagate the renames and SITE_BACKGROUND_STYLE through SiteLayout,
  SiteWelcomeScreen, SiteSituationScreen and pages/site/page.tsx
2026-05-30 18:43:35 +02:00
Tom Boullay 6ae21a2427 fix(site): unified card styles, import Nersans One font, native naming input
🔍 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-30 17:56:42 +02:00
Tom Boullay 29342d796c fix(site): reduce situation card font size 2026-05-30 17:21:44 +02:00
Tom Boullay 60e3c92511 fix(site): update situation cards 2026-05-30 17:06:29 +02:00
59 changed files with 1411 additions and 586 deletions
Binary file not shown.
+11 -2
View File
@@ -72,14 +72,23 @@ 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
- `gameplayReady`: true when map, stage, and octree are all ready
- `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
The final game-scene readiness condition is:
The base game-scene readiness condition before the shadow warmup is:
```ts
showGameStage && gameStageLoaded && octree !== null;
```
After that condition is met, `SceneShadowWarmup` runs one final loading step:
```txt
Activation des ombres -> Ombres prêtes -> Gameplay prêt
```
This keeps the loading overlay visible until the renderer shadow map, shadow-casting light, and mounted scene graph have all been explicitly refreshed.
The debug physics scene is ready when:
```ts
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -227
View File
@@ -584,22 +584,6 @@
}
]
},
{
"name": "arbre",
"type": "Object3D",
"position": [50.072, 2.2583, 78.7082],
"rotation": [0, 0, 0],
"scale": [1, 1, 1],
"children": [
{
"name": "arbre",
"type": "Mesh",
"position": [50.072, 2.2583, 78.7082],
"rotation": [0, 0, 0],
"scale": [1, 1, 1]
}
]
},
{
"name": "arbre",
"type": "Object3D",
@@ -888,22 +872,6 @@
}
]
},
{
"name": "arbre",
"type": "Object3D",
"position": [59.1794, 2.2557, 73.349],
"rotation": [0, 0, 0],
"scale": [1, 1, 1],
"children": [
{
"name": "arbre",
"type": "Mesh",
"position": [59.1794, 2.2557, 73.349],
"rotation": [0, 0, 0],
"scale": [1, 1, 1]
}
]
},
{
"name": "arbre",
"type": "Object3D",
@@ -1112,22 +1080,6 @@
}
]
},
{
"name": "arbre",
"type": "Object3D",
"position": [74.0452, 2.309, 59.2374],
"rotation": [0, 0, 0],
"scale": [1, 1, 1],
"children": [
{
"name": "arbre",
"type": "Mesh",
"position": [74.0452, 2.309, 59.2374],
"rotation": [0, 0, 0],
"scale": [1, 1, 1]
}
]
},
{
"name": "arbre",
"type": "Object3D",
@@ -2754,22 +2706,6 @@
}
]
},
{
"name": "buisson",
"type": "Object3D",
"position": [73.7334, 1.1132, 54.1382],
"rotation": [0, 0, 0],
"scale": [1, 1, 1],
"children": [
{
"name": "buisson",
"type": "Mesh",
"position": [73.7334, 1.1132, 54.1382],
"rotation": [0, 0, 0],
"scale": [1, 1, 1]
}
]
},
{
"name": "buisson",
"type": "Object3D",
@@ -3330,22 +3266,6 @@
}
]
},
{
"name": "buisson",
"type": "Object3D",
"position": [67.9046, 0.5562, 74.8395],
"rotation": [0, 0, 0],
"scale": [1, 1, 1],
"children": [
{
"name": "buisson",
"type": "Mesh",
"position": [67.9046, 0.5562, 74.8395],
"rotation": [0, 0, 0],
"scale": [1, 1, 1]
}
]
},
{
"name": "buisson",
"type": "Object3D",
@@ -3714,22 +3634,6 @@
}
]
},
{
"name": "buisson",
"type": "Object3D",
"position": [73.5205, 0.3748, 75.9136],
"rotation": [0, 0, 0],
"scale": [1, 1, 1],
"children": [
{
"name": "buisson",
"type": "Mesh",
"position": [73.5205, 0.3748, 75.9136],
"rotation": [0, 0, 0],
"scale": [1, 1, 1]
}
]
},
{
"name": "buisson",
"type": "Object3D",
@@ -3858,22 +3762,6 @@
}
]
},
{
"name": "buisson",
"type": "Object3D",
"position": [66.999, 1.7223, 48.3983],
"rotation": [0, 0, 0],
"scale": [1, 1, 1],
"children": [
{
"name": "buisson",
"type": "Mesh",
"position": [66.999, 1.7223, 48.3983],
"rotation": [0, 0, 0],
"scale": [1, 1, 1]
}
]
},
{
"name": "buisson",
"type": "Object3D",
@@ -4914,22 +4802,6 @@
}
]
},
{
"name": "buisson",
"type": "Object3D",
"position": [61.3924, 0.4621, 82.2195],
"rotation": [0, 0, 0],
"scale": [1, 1, 1],
"children": [
{
"name": "buisson",
"type": "Mesh",
"position": [61.3924, 0.4621, 82.2195],
"rotation": [0, 0, 0],
"scale": [1, 1, 1]
}
]
},
{
"name": "buisson",
"type": "Object3D",
@@ -5122,22 +4994,6 @@
}
]
},
{
"name": "buisson",
"type": "Object3D",
"position": [61.1082, 0.6236, 77.7642],
"rotation": [0, 0, 0],
"scale": [1, 1, 1],
"children": [
{
"name": "buisson",
"type": "Mesh",
"position": [61.1082, 0.6236, 77.7642],
"rotation": [0, 0, 0],
"scale": [1, 1, 1]
}
]
},
{
"name": "buisson",
"type": "Object3D",
@@ -5170,22 +5026,6 @@
}
]
},
{
"name": "buisson",
"type": "Object3D",
"position": [53.1033, 1.6054, 63.3842],
"rotation": [0, 0, 0],
"scale": [1, 1, 1],
"children": [
{
"name": "buisson",
"type": "Mesh",
"position": [53.1033, 1.6054, 63.3842],
"rotation": [0, 0, 0],
"scale": [1, 1, 1]
}
]
},
{
"name": "buisson",
"type": "Object3D",
@@ -5266,22 +5106,6 @@
}
]
},
{
"name": "buisson",
"type": "Object3D",
"position": [59.647, 1.5484, 59.429],
"rotation": [0, 0, 0],
"scale": [1, 1, 1],
"children": [
{
"name": "buisson",
"type": "Mesh",
"position": [59.647, 1.5484, 59.429],
"rotation": [0, 0, 0],
"scale": [1, 1, 1]
}
]
},
{
"name": "buisson",
"type": "Object3D",
@@ -5410,22 +5234,6 @@
}
]
},
{
"name": "buisson",
"type": "Object3D",
"position": [69.2496, 0.6286, 71.5478],
"rotation": [0, 0, 0],
"scale": [1, 1, 1],
"children": [
{
"name": "buisson",
"type": "Mesh",
"position": [69.2496, 0.6286, 71.5478],
"rotation": [0, 0, 0],
"scale": [1, 1, 1]
}
]
},
{
"name": "buisson",
"type": "Object3D",
@@ -6226,22 +6034,6 @@
}
]
},
{
"name": "buisson",
"type": "Object3D",
"position": [58.3126, 0.686, 77.9828],
"rotation": [0, 0, 0],
"scale": [1, 1, 1],
"children": [
{
"name": "buisson",
"type": "Mesh",
"position": [58.3126, 0.686, 77.9828],
"rotation": [0, 0, 0],
"scale": [1, 1, 1]
}
]
},
{
"name": "buisson",
"type": "Object3D",
@@ -37602,23 +37394,6 @@
"rotation": [0, 0, 0],
"scale": [1, 1, 1],
"children": [
{
"name": "ebike",
"type": "Object3D",
"role": "group",
"position": [0, 0, 0],
"rotation": [0, 0, 0],
"scale": [1, 1, 1],
"children": [
{
"name": "ebike",
"type": "Object3D",
"position": [42.2399, 4.5484, 34.6468],
"rotation": [0, 0, 0],
"scale": [1, 1, 1]
}
]
},
{
"name": "zone1_residence",
"type": "Object3D",
@@ -40477,14 +40252,14 @@
"name": "lafabrik",
"type": "Object3D",
"position": [59.4973, 6.2746, 64.6354],
"rotation": [-3.1416, -0.7309, -3.1416],
"rotation": [-3.1416, 2.4107, -3.1416],
"scale": [1, 2, 1],
"children": [
{
"name": "lafabrik",
"type": "Mesh",
"position": [59.4973, 6.2746, 64.6354],
"rotation": [-3.1416, -0.7309, -3.1416],
"rotation": [-3.1416, 2.4107, -3.1416],
"scale": [1, 2, 1]
}
]
Binary file not shown.
Binary file not shown.
+1 -2
View File
@@ -31,8 +31,7 @@
"id": "narrateur_bienvenueaaltera",
"voice": "narrateur",
"audio": "/sounds/dialogue/narrateur_bienvenueaaltera.mp3",
"subtitleCueIndex": 1,
"timecode": 0
"subtitleCueIndex": 1
},
{
"id": "narrateur_intro_prenom",
+31 -4
View File
@@ -6,12 +6,14 @@ import { InteractableObject } from "@/components/three/interaction/InteractableO
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 { 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_ROTATION_Y,
} from "@/data/ebike/ebikeConfig";
import type { Vector3Tuple } from "@/types/three/three";
import "@/types/ebike/ebikeWindow";
@@ -31,7 +33,10 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
const model = useClonedObject(scene);
const movementMode = useGameStore((state) => state.player.movementMode);
const mainState = useGameStore((state) => state.mainState);
const ebikeStep = useGameStore((state) => state.ebike.currentStep);
const setMissionStep = useGameStore((state) => state.setMissionStep);
const camera = useThree((state) => state.camera);
const updateEbikeSounds = useEbikeSounds();
// Map active mainState to target repair zone coordinate
const destPos = useMemo(() => {
@@ -67,7 +72,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
position[1] - PLAYER_EYE_HEIGHT,
position[2],
]);
const restingRotationRef = useRef<number>(0);
const restingRotationRef = useRef<number>(EBIKE_WORLD_ROTATION_Y);
const forkRef = useRef<THREE.Object3D | null>(null);
// State for debug visualization (synced from refs during useFrame)
@@ -102,6 +107,12 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
useFrame((_, delta) => {
if (groupRef.current) {
if (movementMode === "ebike") {
updateEbikeSounds({
mounted: true,
driving: window.ebikeDriveInputActive === true,
breakdown: window.ebikeBreakdownActive === true,
});
restingPositionRef.current = [
groupRef.current.position.x,
groupRef.current.position.y,
@@ -133,6 +144,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
setDebugRestingPosition([...restingPositionRef.current]);
}
} else {
updateEbikeSounds({ mounted: false, driving: false, breakdown: false });
groupRef.current.position.set(...restingPositionRef.current);
groupRef.current.rotation.set(0, restingRotationRef.current, 0);
@@ -159,7 +171,14 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
];
const handleInteract = useCallback((): void => {
if (window.ebikeBreakdownActive === true) return;
if (movementMode === "walk") {
if (mainState === "ebike" && ebikeStep === "waiting") {
setMissionStep("ebike", "inspected");
return;
}
const cameraOffset = new THREE.Vector3(
...EBIKE_CAMERA_TRANSFORM.position,
);
@@ -213,7 +232,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
useGameStore.getState().setPlayerMovementMode("walk");
});
}
}, [movementMode, camera, position]);
}, [movementMode, mainState, ebikeStep, setMissionStep, camera, position]);
// Store handleInteract in a ref for use in debug folder callback
const handleInteractRef = useRef(handleInteract);
@@ -239,12 +258,20 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
return (
<>
<group ref={groupRef} position={position}>
<group
ref={groupRef}
position={position}
rotation={[0, EBIKE_WORLD_ROTATION_Y, 0]}
>
<primitive object={model} />
<InteractableObject
kind="trigger"
label={
movementMode === "walk" ? "Monter sur le bike" : "Descendre du bike"
mainState === "ebike" && ebikeStep === "waiting"
? "Inspecter l'e-bike"
: movementMode === "walk"
? "Monter sur le bike"
: "Descendre du bike"
}
position={position}
radius={15}
+113
View File
@@ -0,0 +1,113 @@
import { useEffect, useRef, useState } from "react";
import { MissionNotification } from "@/components/ui/MissionNotification";
import {
EBIKE_BREAKDOWN_DIALOGUE_DELAY_MS,
EBIKE_BREAKDOWN_DIALOGUE_ID,
EBIKE_INTRO_RIDE_DURATION_MS,
EBIKE_SOUNDS,
} from "@/data/ebike/ebikeConfig";
import { AudioManager } from "@/managers/AudioManager";
import { useGameStore } from "@/managers/stores/useGameStore";
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
import { playDialogueById } from "@/utils/dialogues/playDialogue";
export function EbikeIntroSequence(): React.JSX.Element | null {
const introStep = useGameStore((state) => state.intro.currentStep);
const movementMode = useGameStore((state) => state.player.movementMode);
const setIntroStep = useGameStore((state) => state.setIntroStep);
const completeIntro = useGameStore((state) => state.completeIntro);
const [breakdownDialogueDone, setBreakdownDialogueDone] = useState(false);
const hasStartedBreakdown = useRef(false);
useEffect(() => {
if (introStep !== "await-ebike-mount" || movementMode !== "ebike") return;
setIntroStep("ebike-intro-ride");
}, [introStep, movementMode, setIntroStep]);
useEffect(() => {
if (introStep !== "ebike-intro-ride") return undefined;
const timeoutId = window.setTimeout(() => {
setIntroStep("ebike-breakdown");
}, EBIKE_INTRO_RIDE_DURATION_MS);
return () => {
window.clearTimeout(timeoutId);
};
}, [introStep, setIntroStep]);
useEffect(() => {
if (introStep !== "ebike-breakdown" || hasStartedBreakdown.current) {
return undefined;
}
hasStartedBreakdown.current = true;
setBreakdownDialogueDone(false);
window.ebikeBreakdownActive = true;
AudioManager.getInstance().playSound(EBIKE_SOUNDS.panne, 0.95, {
category: "sfx",
});
let isCancelled = false;
const dialogueTimeoutId = window.setTimeout(() => {
void (async () => {
const manifest = await loadDialogueManifest();
if (isCancelled || !manifest) {
setBreakdownDialogueDone(true);
return;
}
const audio = await playDialogueById(
manifest,
EBIKE_BREAKDOWN_DIALOGUE_ID,
);
if (isCancelled || !audio) {
setBreakdownDialogueDone(true);
return;
}
audio.addEventListener(
"ended",
() => {
setBreakdownDialogueDone(true);
},
{ once: true },
);
})();
}, EBIKE_BREAKDOWN_DIALOGUE_DELAY_MS);
return () => {
isCancelled = true;
window.clearTimeout(dialogueTimeoutId);
};
}, [introStep]);
useEffect(() => {
if (introStep !== "ebike-breakdown") return;
if (!breakdownDialogueDone || movementMode !== "walk") return;
window.ebikeBreakdownActive = false;
completeIntro();
}, [breakdownDialogueDone, completeIntro, introStep, movementMode]);
useEffect(() => {
if (introStep === "ebike-breakdown") return;
window.ebikeBreakdownActive = false;
if (introStep !== "completed") {
hasStartedBreakdown.current = false;
}
}, [introStep]);
if (introStep !== "await-ebike-mount" && introStep !== "ebike-intro-ride") {
return null;
}
return (
<MissionNotification
mission="ebike"
visible={introStep === "await-ebike-mount"}
/>
);
}
+1
View File
@@ -21,6 +21,7 @@ export function SiteButton({
onMouseDown={() => setIsPressed(true)}
onMouseUp={() => setIsPressed(false)}
onMouseLeave={() => setIsPressed(false)}
className="site-button"
style={{
display: "inline-flex",
padding: "12px 20px",
+23 -19
View File
@@ -4,60 +4,64 @@ interface SiteCardProps {
config: SiteCardConfig;
selected: boolean;
onSelect: () => void;
variant?: "default" | "situation";
}
export function SiteCard({
config,
selected,
onSelect,
variant = "default",
}: SiteCardProps): React.JSX.Element {
const { label, imagePath, disabled } = config;
const isSituation = variant === "situation";
const getBackground = (): string => {
if (imagePath) return `url(${imagePath}) center/cover`;
if (disabled) return "#b8b8b8";
if (selected) return "#d9d9d9";
return "#e8e8e8";
if (disabled) return "rgba(255, 255, 255, 0.42)";
return "#b8b8b8";
};
const getBorder = (): string => {
if (selected) return "3px solid #a8d5a2";
if (disabled) return "none";
return "2px solid #ffffff";
};
const borderColor = selected ? "#a8d5a2" : "rgba(255, 255, 255, 0.55)";
const getTextColor = (): string => {
if (disabled) return "#888888";
return "#666666";
};
const textColor = disabled ? "rgba(77, 77, 77, 0.72)" : "#4d4d4d";
return (
<button
type="button"
onClick={onSelect}
disabled={disabled}
aria-pressed={selected}
aria-label={label}
className="site-card-button"
style={{
width: "clamp(120px, 15vw, 160px)",
height: "clamp(140px, 18vw, 180px)",
border: getBorder(),
width: isSituation
? "clamp(220px, 24vw, 300px)"
: "clamp(120px, 15vw, 160px)",
height: isSituation
? "clamp(48px, 6vw, 60px)"
: "clamp(140px, 18vw, 180px)",
border: `3px solid ${borderColor}`,
background: getBackground(),
cursor: disabled ? "not-allowed" : "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "all 0.15s ease",
outline: "none",
flexShrink: 0,
}}
>
{!imagePath && (
<span
style={{
color: getTextColor(),
fontSize: "clamp(10px, 1.5vw, 14px)",
fontWeight: 500,
color: textColor,
fontSize: isSituation
? "clamp(14px, 1.8vw, 22px)"
: "clamp(10px, 1.5vw, 14px)",
fontWeight: isSituation ? 700 : 500,
textAlign: "center",
padding: 8,
lineHeight: 1,
}}
>
{label}
+21 -8
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useSiteStore } from "@/managers/stores/useSiteStore";
import { usePrefersReducedMotion } from "@/hooks/ui/usePrefersReducedMotion";
const DISCLAIMER_TEXT =
"Ce site a été conçu pour être utilisé sur ordinateur.\nPour une meilleure expérience, assurez-vous d'avoir une bonne connexion internet et une machine performante.";
@@ -7,13 +8,15 @@ const DISCLAIMER_TEXT =
const TEXT_DISPLAY_DURATION = 5000;
const FADE_OUT_DURATION = 1000;
const TRANSITION_DELAY = 250;
const SKIP_KEYS = new Set(["Enter", " ", "Escape"]);
/**
* Screen 0: Disclaimer
*/
export function SiteDisclaimerScreen(): React.JSX.Element {
const setStep = useSiteStore((state) => state.setStep);
const [textOpacity, setTextOpacity] = useState(0);
const prefersReducedMotion = usePrefersReducedMotion();
const [textOpacity, setTextOpacity] = useState(prefersReducedMotion ? 1 : 0);
const hasSkipped = useRef(false);
const handleSkip = useCallback(() => {
@@ -23,33 +26,40 @@ export function SiteDisclaimerScreen(): React.JSX.Element {
}, [setStep]);
useEffect(() => {
// Fade in text
const fadeInTimeout = window.setTimeout(() => {
setTextOpacity(1);
}, 100);
// Start fade out after display duration
const fadeOutTimeout = window.setTimeout(() => {
setTextOpacity(0);
}, TEXT_DISPLAY_DURATION);
// Transition to welcome after fade out + delay
const transitionTimeout = window.setTimeout(
() => {
handleSkip();
},
handleSkip,
TEXT_DISPLAY_DURATION + FADE_OUT_DURATION + TRANSITION_DELAY,
);
const handleKeyDown = (event: KeyboardEvent): void => {
if (SKIP_KEYS.has(event.key)) {
event.preventDefault();
handleSkip();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.clearTimeout(fadeInTimeout);
window.clearTimeout(fadeOutTimeout);
window.clearTimeout(transitionTimeout);
window.removeEventListener("keydown", handleKeyDown);
};
}, [handleSkip]);
return (
<div
role="region"
aria-label="Avertissement"
onClick={handleSkip}
style={{
position: "fixed",
@@ -63,6 +73,7 @@ export function SiteDisclaimerScreen(): React.JSX.Element {
}}
>
<p
aria-live="polite"
style={{
color: "#F2F2F2",
textAlign: "center",
@@ -72,7 +83,9 @@ export function SiteDisclaimerScreen(): React.JSX.Element {
lineHeight: 1.6,
maxWidth: 800,
opacity: textOpacity,
transition: `opacity ${FADE_OUT_DURATION}ms ease-in-out`,
transition: prefersReducedMotion
? "none"
: `opacity ${FADE_OUT_DURATION}ms ease-in-out`,
whiteSpace: "pre-wrap",
}}
>
+2 -6
View File
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { SITE_CONFIG } from "@/data/site/siteConfig";
import { SITE_BACKGROUND_STYLE } from "@/data/site/siteConfig";
import { Subtitles } from "@/components/ui/Subtitles";
interface SiteLayoutProps {
@@ -16,11 +16,7 @@ export function SiteLayout({ children }: SiteLayoutProps): React.JSX.Element {
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#87CEEB",
backgroundImage: `url(${SITE_CONFIG.backgroundImage})`,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
...SITE_BACKGROUND_STYLE,
fontFamily: "system-ui, -apple-system, sans-serif",
color: "#fff",
overflow: "hidden",
+6 -15
View File
@@ -1,37 +1,28 @@
import { SITE_CONFIG } from "@/data/site/siteConfig";
import { SITE_BACKGROUND_STYLE } from "@/data/site/siteConfig";
const MOBILE_TEXT =
"Ce site a été conçu pour être utilisé sur ordinateur. Veuillez réessayer sur votre ordinateur pour une expérience optimale.";
/**
* Mobile blocker screen
*/
export function SiteMobileBlocker(): React.JSX.Element {
return (
<div
role="alert"
style={{
position: "fixed",
inset: 0,
backgroundColor: "#87CEEB",
backgroundImage: `url(${SITE_CONFIG.backgroundImage})`,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: 32,
gap: 48,
...SITE_BACKGROUND_STYLE,
}}
>
<img
src="public/assets/logo/logo.jpg"
alt="Logo"
style={{
width: 120,
height: "auto",
}}
src="/assets/logo/logo.jpg"
alt="Logo Altera"
style={{ width: 120, height: "auto" }}
/>
<p
style={{
+50 -25
View File
@@ -3,56 +3,61 @@ import { useGameStore } from "@/managers/stores/useGameStore";
import { useSiteStore } from "@/managers/stores/useSiteStore";
import { SiteButton } from "@/components/site/SiteButton";
import { SITE_CONFIG } from "@/data/site/siteConfig";
import { SITE_DIALOGUE_IDS } from "@/data/site/dialogueIds";
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
import { playDialogueById } from "@/utils/dialogues/playDialogue";
import {
playDialogueById,
stopCurrentDialogue,
} from "@/utils/dialogues/playDialogue";
/**
* Screen 3: Name input
* The displayed name is forced to SITE_CONFIG.presetPlayerName — the
* field reveals one letter per keystroke until the preset name is complete.
*/
export function SiteNamingScreen(): React.JSX.Element {
const setStep = useSiteStore((state) => state.setStep);
const setPlayerName = useGameStore((state) => state.setPlayerName);
const [charIndex, setCharIndex] = useState(0);
const dialogueStarted = useRef(false);
const inputRef = useRef<HTMLInputElement>(null);
const forcedName = SITE_CONFIG.forcedName;
const displayValue = forcedName.slice(0, charIndex);
const isComplete = charIndex >= forcedName.length;
const presetPlayerName = SITE_CONFIG.presetPlayerName;
const displayValue = presetPlayerName.slice(0, charIndex);
const isComplete = charIndex >= presetPlayerName.length;
// Play dialogue when screen appears (with subtitles)
useEffect(() => {
if (dialogueStarted.current) return;
dialogueStarted.current = true;
let cancelled = false;
void (async () => {
const manifest = await loadDialogueManifest();
if (manifest) {
await playDialogueById(manifest, "narrateur_intro_prenom");
}
if (cancelled || !manifest) return;
await playDialogueById(manifest, SITE_DIALOGUE_IDS.naming);
})();
return () => {
cancelled = true;
stopCurrentDialogue();
};
}, []);
// Focus input on mount
useEffect(() => {
inputRef.current?.focus();
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>): void => {
e.preventDefault();
// Only process if not complete and it's a letter key
if (!isComplete && e.key.length === 1 && /[a-zA-Z]/.test(e.key)) {
setCharIndex((prev) => Math.min(prev + 1, forcedName.length));
}
const handleNameChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>): void => {
const nextLength = Math.min(
event.target.value.length,
presetPlayerName.length,
);
setCharIndex(nextLength);
},
[isComplete, forcedName.length],
[presetPlayerName.length],
);
const handleConfirm = (): void => {
if (isComplete) {
setPlayerName(forcedName);
setPlayerName(presetPlayerName);
setStep("transition");
}
};
@@ -79,6 +84,7 @@ export function SiteNamingScreen(): React.JSX.Element {
}}
>
<h2
id="player-name-label"
style={{
color: "#F2F2F2",
textAlign: "center",
@@ -99,9 +105,11 @@ export function SiteNamingScreen(): React.JSX.Element {
ref={inputRef}
type="text"
value={displayValue}
onKeyDown={handleKeyDown}
readOnly
onChange={handleNameChange}
placeholder="Écrivez votre prénom ici"
aria-labelledby="player-name-label"
aria-describedby="player-name-hint"
autoComplete="off"
style={{
display: "flex",
padding: "clamp(8px, 1.5vw, 10px)",
@@ -114,13 +122,30 @@ export function SiteNamingScreen(): React.JSX.Element {
background: "#D9D9D9",
outline: "none",
color: "#333",
caretColor: "transparent",
caretColor: "#333",
fontFamily: "Inter, system-ui, sans-serif",
fontSize: "clamp(16px, 2.5vw, 20px)",
textAlign: "left",
boxSizing: "border-box",
}}
/>
<span
id="player-name-hint"
style={{
position: "absolute",
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: "hidden",
clip: "rect(0, 0, 0, 0)",
whiteSpace: "nowrap",
border: 0,
}}
>
Votre personnage s&apos;appelle {presetPlayerName}. Tapez{" "}
{presetPlayerName.length} caractères pour révéler son nom.
</span>
</div>
<SiteButton
+13 -9
View File
@@ -7,13 +7,15 @@ import { SITUATION_CARDS } from "@/data/site/siteConfig";
* Screen 2: Situation selection
*/
export function SiteSituationScreen(): React.JSX.Element {
const selectedSituation = useSiteStore((state) => state.selectedSituation);
const setSelectedSituation = useSiteStore(
(state) => state.setSelectedSituation,
const selectedSituationIndex = useSiteStore(
(state) => state.selectedSituationIndex,
);
const setSelectedSituationIndex = useSiteStore(
(state) => state.setSelectedSituationIndex,
);
const setStep = useSiteStore((state) => state.setStep);
const canProceed = selectedSituation !== null;
const canProceed = selectedSituationIndex !== null;
const handleConfirm = (): void => {
if (canProceed) {
@@ -52,20 +54,22 @@ export function SiteSituationScreen(): React.JSX.Element {
<div
style={{
display: "flex",
gap: 16,
flexWrap: "wrap",
display: "grid",
gridTemplateColumns: "repeat(2, minmax(220px, 300px))",
gap: "24px 28px",
justifyContent: "center",
width: "100%",
}}
>
{SITUATION_CARDS.map((card, index) => (
<SiteCard
key={card.id}
config={card}
selected={selectedSituation === index}
selected={selectedSituationIndex === index}
variant="situation"
onSelect={() => {
if (!card.disabled) {
setSelectedSituation(index);
setSelectedSituationIndex(index);
}
}}
/>
+74 -58
View File
@@ -1,72 +1,99 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { useNavigate } from "@tanstack/react-router";
import { useSiteStore } from "@/managers/stores/useSiteStore";
import { Subtitles } from "@/components/ui/Subtitles";
import { setSiteVisited } from "@/utils/cookies/siteVisitCookie";
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
import { playDialogueById } from "@/utils/dialogues/playDialogue";
import {
playDialogueById,
stopCurrentDialogue,
} from "@/utils/dialogues/playDialogue";
import { SITE_DIALOGUE_IDS } from "@/data/site/dialogueIds";
import { usePrefersReducedMotion } from "@/hooks/ui/usePrefersReducedMotion";
const FADE_DURATION_MS = 1000;
const DIALOGUE_FALLBACK_TIMEOUT_MS = 12000;
const NO_DIALOGUE_FALLBACK_MS = 3000;
/**
* Transition overlay: black screen (fade in) + logo (fade in/out) + dialogue with subtitles + redirect to /
* Transition overlay: black screen with transition dialogue and subtitles,
* then redirect to /. A safety timeout guarantees the redirect happens even if
* the dialogue audio fails to fire `ended`.
*/
export function SiteTransitionOverlay(): React.JSX.Element {
const navigate = useNavigate();
const reset = useSiteStore((state) => state.reset);
const prefersReducedMotion = usePrefersReducedMotion();
const [screenOpacity, setScreenOpacity] = useState(0);
const [logoOpacity, setLogoOpacity] = useState(0);
const transitionStarted = useRef(false);
useEffect(() => {
if (transitionStarted.current) return;
transitionStarted.current = true;
// Fade in black screen
setScreenOpacity(1);
// Set cookie
setSiteVisited();
// Fade in logo after the black screen transition delay.
setLogoOpacity(1);
let isCancelled = false;
const timeoutIds: number[] = [];
// Defer the opacity flip one tick so the CSS transition has an
// initial frame at opacity 0 before flipping to 1.
const fadeInId = window.setTimeout(() => {
setScreenOpacity(1);
}, 0);
timeoutIds.push(fadeInId);
const redirectToGame = (): void => {
if (isCancelled) return;
const id = window.setTimeout(() => {
if (isCancelled) return;
reset();
navigate({ to: "/" });
}, FADE_DURATION_MS);
timeoutIds.push(id);
};
// Play transition dialogue (with subtitles) then fade out logo and redirect
void (async () => {
const manifest = await loadDialogueManifest();
if (manifest) {
const dialogueAudio = await playDialogueById(
manifest,
"narrateur_intro_apresprenom",
if (isCancelled) return;
const dialogueAudio = manifest
? await playDialogueById(manifest, SITE_DIALOGUE_IDS.transition)
: null;
if (isCancelled) return;
if (dialogueAudio) {
const safetyId = window.setTimeout(
redirectToGame,
DIALOGUE_FALLBACK_TIMEOUT_MS,
);
if (dialogueAudio) {
dialogueAudio.addEventListener(
"ended",
() => {
// Fade out logo
setLogoOpacity(0);
// Redirect after logo fade out
setTimeout(() => {
reset();
navigate({ to: "/" });
}, FADE_DURATION_MS);
},
{ once: true },
);
return;
}
timeoutIds.push(safetyId);
dialogueAudio.addEventListener(
"ended",
() => {
window.clearTimeout(safetyId);
redirectToGame();
},
{ once: true },
);
return;
}
// Fallback: redirect after 3s if dialogue fails
setTimeout(() => {
setLogoOpacity(0);
setTimeout(() => {
reset();
navigate({ to: "/" });
}, FADE_DURATION_MS);
}, 3000);
const fallbackId = window.setTimeout(
redirectToGame,
NO_DIALOGUE_FALLBACK_MS,
);
timeoutIds.push(fallbackId);
})();
return () => {
isCancelled = true;
timeoutIds.forEach(window.clearTimeout);
stopCurrentDialogue();
};
}, [navigate, reset]);
const fadeTransition = prefersReducedMotion
? "none"
: `opacity ${FADE_DURATION_MS}ms ease-in-out`;
return (
<div
style={{
@@ -86,23 +113,12 @@ export function SiteTransitionOverlay(): React.JSX.Element {
background: "#000",
zIndex: 0,
opacity: screenOpacity,
transition: `opacity ${FADE_DURATION_MS}ms ease-in-out`,
}}
/>
<img
src="/assets/logo/logo.jpg"
alt="Logo"
style={{
position: "relative",
zIndex: 1,
width: "min(300px, 45vw)",
height: "auto",
objectFit: "contain",
opacity: logoOpacity,
transition: `opacity ${FADE_DURATION_MS}ms ease-in-out`,
transitionDelay: logoOpacity === 1 ? `${FADE_DURATION_MS}ms` : "0ms",
transition: fadeTransition,
}}
/>
{/* Subtitles must live inside this overlay's stacking context
(z-index 1000) so they render above the black screen. The
<Subtitles /> in SiteLayout sits behind this overlay. */}
<Subtitles />
</div>
);
+8 -6
View File
@@ -7,13 +7,15 @@ import { EXPERIENCE_CARDS } from "@/data/site/siteConfig";
* Screen 1: Welcome
*/
export function SiteWelcomeScreen(): React.JSX.Element {
const selectedExperience = useSiteStore((state) => state.selectedExperience);
const setSelectedExperience = useSiteStore(
(state) => state.setSelectedExperience,
const selectedExperienceIndex = useSiteStore(
(state) => state.selectedExperienceIndex,
);
const setSelectedExperienceIndex = useSiteStore(
(state) => state.setSelectedExperienceIndex,
);
const setStep = useSiteStore((state) => state.setStep);
const canProceed = selectedExperience !== null;
const canProceed = selectedExperienceIndex !== null;
const handleNext = (): void => {
if (canProceed) {
@@ -104,10 +106,10 @@ export function SiteWelcomeScreen(): React.JSX.Element {
<SiteCard
key={card.id}
config={card}
selected={selectedExperience === index}
selected={selectedExperienceIndex === index}
onSelect={() => {
if (!card.disabled) {
setSelectedExperience(index);
setSelectedExperienceIndex(index);
}
}}
/>
+1 -1
View File
@@ -112,7 +112,7 @@ export function RepairGame({
<RepairMissionAssetPreloader config={config} />
</Suspense>
<Suspense fallback={null}>
{step === "waiting" ? (
{step === "waiting" && mission !== "ebike" ? (
<RepairInspectionObject
config={config}
worldPosition={snappedPosition}
+28
View File
@@ -0,0 +1,28 @@
import { MISSION_NOTIFICATION_IMAGE_PATHS } from "@/data/gameplay/missionNotifications";
import type { RepairMissionId } from "@/types/gameplay/repairMission";
interface MissionNotificationProps {
mission: RepairMissionId;
visible?: boolean;
}
export function MissionNotification({
mission,
visible = true,
}: MissionNotificationProps): React.JSX.Element {
return (
<div
className={`mission-notification${visible ? "" : " mission-notification--hidden"}`}
aria-live="polite"
>
<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"
/>
</span>
</div>
);
}
+47 -3
View File
@@ -1,5 +1,13 @@
import type { SceneLoadingState } from "@/types/world/sceneLoading";
const LOADING_BACKGROUND_PATH = "/assets/bg-site.png";
const LOADING_LOGO_PATH = "/assets/logo/logo.jpg";
for (const path of [LOADING_BACKGROUND_PATH, LOADING_LOGO_PATH]) {
const image = new Image();
image.src = path;
}
interface SceneLoadingOverlayProps {
state: SceneLoadingState;
}
@@ -15,11 +23,47 @@ export function SceneLoadingOverlay({
className={`scene-loading-overlay${isReady ? " scene-loading-overlay--ready" : ""}`}
aria-live="polite"
>
<div className="scene-loading-overlay__content">
<strong>{state.currentStep}</strong>
<img
alt=""
className="scene-loading-overlay__background"
src={LOADING_BACKGROUND_PATH}
/>
<div className="scene-loading-overlay__shade" />
<img
alt="La Fabrik Durable"
className="scene-loading-overlay__logo"
src={LOADING_LOGO_PATH}
/>
<div className="scene-loading-overlay__footer">
<div className="scene-loading-overlay__meta">
<div className="scene-loading-overlay__label">
<span>Loading...</span>
<svg
className="scene-loading-overlay__spinner"
viewBox="0 0 32 32"
aria-hidden="true"
>
<path
d="M16 3a13 13 0 1 1-9.2 3.8"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="3.5"
/>
<path
d="M6.8 6.8V2.8H2.8"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="3.5"
/>
</svg>
</div>
<strong>{progress}%</strong>
</div>
<div className="scene-loading-overlay__track">
<span style={{ width: `${progress}%` }} />
<em>{progress}%</em>
</div>
</div>
</div>
@@ -0,0 +1,14 @@
export function FadeToVideoOverlay(): React.JSX.Element {
return (
<div
aria-hidden="true"
style={{
position: "fixed",
inset: 0,
zIndex: 29,
background: "#000",
pointerEvents: "none",
}}
/>
);
}
@@ -1,50 +1,72 @@
import { useEffect, useRef } from "react";
import { AudioManager } from "@/managers/AudioManager";
import { useEffect } from "react";
import { Subtitles } from "@/components/ui/Subtitles";
import { useGameStore } from "@/managers/stores/useGameStore";
import { SITE_DIALOGUE_IDS } from "@/data/site/dialogueIds";
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
import {
playDialogueById,
stopCurrentDialogue,
} from "@/utils/dialogues/playDialogue";
const INTRO_DIALOGUE_PATH = "/sounds/dialogue/narrateur_ordreebike.mp3";
const DIALOGUE_FALLBACK_TIMEOUT_MS = 12000;
/**
* Black screen overlay with dialogue audio
* - Plays narrateur_ordreebike.mp3
* - Transitions to reveal step when dialogue ends
* Black screen overlay that plays the intro dialogue (with synced subtitles)
* via the dialogue manifest, then transitions to the reveal step.
*/
export function IntroDialogueOverlay(): React.JSX.Element {
const setIntroStep = useGameStore((state) => state.setIntroStep);
const dialogueStarted = useRef(false);
useEffect(() => {
if (dialogueStarted.current) return;
dialogueStarted.current = true;
let cancelled = false;
let safetyTimeoutId: number | null = null;
// Play dialogue then transition to reveal
const audio = AudioManager.getInstance();
audio.playSoundWithCallback(INTRO_DIALOGUE_PATH, 0.8, () => {
const advance = (): void => {
if (cancelled) return;
if (safetyTimeoutId !== null) window.clearTimeout(safetyTimeoutId);
setIntroStep("reveal");
});
};
void (async () => {
const manifest = await loadDialogueManifest();
if (cancelled) return;
const audio = manifest
? await playDialogueById(manifest, SITE_DIALOGUE_IDS.introOrder)
: null;
if (cancelled) return;
if (!audio) {
advance();
return;
}
safetyTimeoutId = window.setTimeout(
advance,
DIALOGUE_FALLBACK_TIMEOUT_MS,
);
audio.addEventListener("ended", advance, { once: true });
})();
return () => {
cancelled = true;
if (safetyTimeoutId !== null) window.clearTimeout(safetyTimeoutId);
stopCurrentDialogue();
};
}, [setIntroStep]);
return (
<div
role="region"
aria-label="Dialogue d'introduction"
style={{
position: "fixed",
inset: 0,
background: "#000",
zIndex: 999,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<span
style={{
color: "rgba(255, 255, 255, 0.5)",
fontSize: 16,
fontFamily: "system-ui, sans-serif",
}}
>
...
</span>
<Subtitles />
</div>
);
}
+13 -11
View File
@@ -1,45 +1,47 @@
import { useEffect, useState } from "react";
import { useGameStore } from "@/managers/stores/useGameStore";
import { usePrefersReducedMotion } from "@/hooks/ui/usePrefersReducedMotion";
const REVEAL_DURATION_MS = 2000;
/**
* Fade-out overlay for reveal transition
* - Starts fully black
* - Fades out to reveal the game world
* - Transitions to playing step when done
* Fade-out overlay revealing the game world.
* Moves to the ebike onboarding step when the fade is done. The intro only
* completes after the player rides the ebike and triggers the breakdown.
*/
export function IntroRevealOverlay(): React.JSX.Element {
const setIntroStep = useGameStore((state) => state.setIntroStep);
const completeIntro = useGameStore((state) => state.completeIntro);
const setCanMove = useGameStore((state) => state.setCanMove);
const prefersReducedMotion = usePrefersReducedMotion();
const [opacity, setOpacity] = useState(1);
useEffect(() => {
// Start fade out
const fadeTimeout = window.setTimeout(() => {
setOpacity(0);
}, 100);
// Complete intro after fade
const completeTimeout = window.setTimeout(() => {
setIntroStep("playing");
completeIntro();
setCanMove(true);
setIntroStep("await-ebike-mount");
}, REVEAL_DURATION_MS);
return () => {
window.clearTimeout(fadeTimeout);
window.clearTimeout(completeTimeout);
};
}, [setIntroStep, completeIntro]);
}, [setCanMove, setIntroStep]);
return (
<div
aria-hidden="true"
style={{
position: "fixed",
inset: 0,
background: "#000",
opacity,
transition: `opacity ${REVEAL_DURATION_MS}ms ease-out`,
transition: prefersReducedMotion
? "none"
: `opacity ${REVEAL_DURATION_MS}ms ease-out`,
zIndex: 998,
pointerEvents: "none",
}}
+38 -11
View File
@@ -1,16 +1,18 @@
import { useCallback, useRef, useEffect } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useGameStore } from "@/managers/stores/useGameStore";
const INTRO_VIDEO_PATH = "/cinematics/intro.mp4";
const SKIP_KEYS = new Set(["Enter", " "]);
const SKIP_HINT_HIDE_DELAY_MS = 1000;
/**
* Full-screen video player for intro cinematic
* - Plays intro.mp4 in fullscreen
* - Automatically advances to dialogue-intro step when video ends
* - Allows skipping with Enter/Space/Click
* Full-screen video player for the intro cinematic.
* Advances to the dialogue-intro step when the video ends or the user skips.
*/
export function IntroVideoPlayer(): React.JSX.Element {
const videoRef = useRef<HTMLVideoElement>(null);
const hideHintTimeoutRef = useRef<number | null>(null);
const [showSkipHint, setShowSkipHint] = useState(false);
const setIntroStep = useGameStore((state) => state.setIntroStep);
const handleVideoEnd = useCallback(() => {
@@ -18,16 +20,13 @@ export function IntroVideoPlayer(): React.JSX.Element {
}, [setIntroStep]);
const handleSkip = useCallback(() => {
if (videoRef.current) {
videoRef.current.pause();
}
videoRef.current?.pause();
setIntroStep("dialogue-intro");
}, [setIntroStep]);
// Handle keyboard skip (Enter/Space)
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
const handleKeyDown = (event: KeyboardEvent): void => {
if (SKIP_KEYS.has(event.key)) {
event.preventDefault();
handleSkip();
}
@@ -37,9 +36,33 @@ export function IntroVideoPlayer(): React.JSX.Element {
return () => window.removeEventListener("keydown", handleKeyDown);
}, [handleSkip]);
useEffect(() => {
return () => {
if (hideHintTimeoutRef.current !== null) {
window.clearTimeout(hideHintTimeoutRef.current);
}
};
}, []);
const handleMouseMove = useCallback(() => {
setShowSkipHint(true);
if (hideHintTimeoutRef.current !== null) {
window.clearTimeout(hideHintTimeoutRef.current);
}
hideHintTimeoutRef.current = window.setTimeout(() => {
setShowSkipHint(false);
hideHintTimeoutRef.current = null;
}, SKIP_HINT_HIDE_DELAY_MS);
}, []);
return (
<div
role="region"
aria-label="Vidéo d'introduction. Appuyez sur Entrée pour passer."
onClick={handleSkip}
onMouseMove={handleMouseMove}
style={{
position: "fixed",
inset: 0,
@@ -56,6 +79,7 @@ export function IntroVideoPlayer(): React.JSX.Element {
src={INTRO_VIDEO_PATH}
autoPlay
playsInline
preload="auto"
onEnded={handleVideoEnd}
style={{
width: "100%",
@@ -64,6 +88,7 @@ export function IntroVideoPlayer(): React.JSX.Element {
}}
/>
<span
aria-hidden="true"
style={{
position: "absolute",
bottom: 32,
@@ -71,6 +96,8 @@ export function IntroVideoPlayer(): React.JSX.Element {
color: "rgba(255, 255, 255, 0.6)",
fontSize: 14,
fontFamily: "system-ui, sans-serif",
opacity: showSkipHint ? 1 : 0,
transition: "opacity 240ms ease",
}}
>
Appuyez pour passer
+1
View File
@@ -1,3 +1,4 @@
export { FadeToVideoOverlay } from "./FadeToVideoOverlay";
export { IntroVideoPlayer } from "./IntroVideoPlayer";
export { IntroDialogueOverlay } from "./IntroDialogueOverlay";
export { IntroRevealOverlay } from "./IntroRevealOverlay";
-8
View File
@@ -1,11 +1,3 @@
export const AUDIO_PATHS = {
intro: "/sounds/effect/fa.mp3",
bienvenue: "/sounds/effect/fa.mp3",
alertCentral: "/sounds/effect/fa.mp3",
searching: "/sounds/effect/fa.mp3",
helped: "/sounds/effect/fa.mp3",
} as const;
export type AudioCategory = "music" | "sfx" | "dialogue";
export const DEFAULT_CATEGORY_VOLUMES: Record<AudioCategory, number> = {
+19
View File
@@ -14,3 +14,22 @@ export const EBIKE_DROP_PLAYER_TRANSFORM: CameraTransform = {
position: [0, 1.5, -3],
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_INTRO_RIDE_DURATION_MS = 5000;
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;
export const EBIKE_SOUNDS = {
depart: "/sounds/effect/ebike-depart.mp3",
roule: "/sounds/effect/ebike-roule.mp3",
ralenti: "/sounds/effect/ebike-ralenti.mp3",
panne: "/sounds/effect/ebike-panne.mp3",
} as const;
export const EBIKE_BREAKDOWN_DIALOGUE_ID = "narrateur_ebikecasse";
+5 -1
View File
@@ -15,10 +15,14 @@ export const SITE_STEPS: readonly SiteStep[] = [
*/
export const GAME_STEPS: readonly GameStep[] = [
"loading-map",
"fade-to-video",
"video",
"dialogue-intro",
"reveal",
"playing",
"await-ebike-mount",
"ebike-intro-ride",
"ebike-breakdown",
"completed",
];
export const MAIN_GAME_STATES: readonly MainGameState[] = [
@@ -0,0 +1,8 @@
import type { RepairMissionId } from "@/types/gameplay/repairMission";
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",
};
+2 -3
View File
@@ -3,6 +3,7 @@ import type {
RepairMissionId,
RepairMissionTriggerConfig,
} from "@/types/gameplay/repairMission";
import { EBIKE_WORLD_POSITION } from "@/data/ebike/ebikeConfig";
export const REPAIR_MISSION_ANCHOR_IDS: Partial<
Record<RepairMissionId, string>
@@ -10,9 +11,7 @@ export const REPAIR_MISSION_ANCHOR_IDS: Partial<
pylon: "repair:pylon",
};
const EBIKE_REPAIR_POSITION = [
42.2399, 4.5484, 34.6468,
] as const satisfies Vector3Tuple;
const EBIKE_REPAIR_POSITION = EBIKE_WORLD_POSITION satisfies Vector3Tuple;
const REPAIR_MISSION_POSITIONS = {
ebike: EBIKE_REPAIR_POSITION,
+3 -3
View File
@@ -3,8 +3,8 @@ import type { Vector3Tuple } from "@/types/three/three";
export const PLAYER_EYE_HEIGHT = 1.75;
export const PLAYER_CAPSULE_RADIUS = 0.35;
export const PLAYER_WALK_SPEED = 11;
export const PLAYER_EBIKE_SPEED = 25;
export const PLAYER_WALK_SPEED = 5;
export const PLAYER_EBIKE_SPEED = 20;
export const PLAYER_AIR_CONTROL_FACTOR = 0.35;
export const PLAYER_JUMP_SPEED = 9;
export const PLAYER_GRAVITY = 30;
@@ -14,5 +14,5 @@ export const PLAYER_XZ_DAMPING_FACTOR = 8;
export const PLAYER_FALL_RESPAWN_Y = -20;
export const PLAYER_FALL_RESPAWN_DELAY = 3;
export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [0, 50, 0];
export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [59.5, 10, 64.64];
export const PLAYER_SPAWN_POSITION_PHYSICS: Vector3Tuple = [0, 3, 0];
+9
View File
@@ -0,0 +1,9 @@
/**
* Dialogue manifest IDs used by the /site flow and the intro sequence.
* Defined once here so components don't hold magic strings.
*/
export const SITE_DIALOGUE_IDS = {
naming: "narrateur_intro_prenom",
transition: "narrateur_intro_apresprenom",
introOrder: "narrateur_ordreebike",
} as const;
+22 -7
View File
@@ -1,8 +1,23 @@
import type { CSSProperties } from "react";
const BACKGROUND_IMAGE = "/assets/bg-site.png";
export const SITE_CONFIG = {
backgroundImage: "/assets/bg-site.png",
forcedName: "Danyl",
backgroundImage: BACKGROUND_IMAGE,
presetPlayerName: "Danyl",
} as const;
/**
* Shared background style used by SiteLayout and SiteMobileBlocker.
*/
export const SITE_BACKGROUND_STYLE: CSSProperties = {
backgroundColor: "#87CEEB",
backgroundImage: `url(${BACKGROUND_IMAGE})`,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
};
export interface SiteCardConfig {
id: string;
label: string;
@@ -24,12 +39,12 @@ export const EXPERIENCE_CARDS: readonly SiteCardConfig[] = [
* Cards for screen 2: "Quelle est votre situation ?"
*/
export const SITUATION_CARDS: readonly SiteCardConfig[] = [
{ id: "sit-habitants", label: "Habitants d'Altera", disabled: true },
{ id: "sit-apprentis", label: "Apprentis-Citoyens", disabled: true },
{ id: "sit-sans-domicile", label: "Sans domicile fixe", disabled: true },
{ id: "sit-refugie-guerre", label: "Réfugié.e de guerre", disabled: true },
{
id: "sit-refugies",
label: "Réfugiés Climatiques arrivants",
id: "sit-refugie-climat",
label: "Réfugié.e climatique",
disabled: false,
},
{ id: "sit-seniors", label: "Seniors Hyper-Connectés", disabled: true },
{ id: "sit-autre", label: "Autre", disabled: true },
];
+2 -2
View File
@@ -28,8 +28,8 @@ export const CHARACTER_CONFIGS = {
id: "gerant",
label: "Gerant",
modelPath: "/models/gerant-animated/model.gltf",
position: [45.2, 0, 45.5],
rotation: [0, -1.55, 0],
position: [59.5, 0, 64.64],
rotation: [0, 2.41, 0],
scale: [1, 1, 1],
animations: ["idle", "walk"],
defaultAnimation: "idle",
+110
View File
@@ -0,0 +1,110 @@
import { useCallback, useEffect, useRef } from "react";
import { EBIKE_SOUNDS } from "@/data/ebike/ebikeConfig";
import { AudioManager } from "@/managers/AudioManager";
type EbikeSoundState = "idle" | "depart" | "roule" | "ralenti";
interface UpdateEbikeSoundsOptions {
mounted: boolean;
driving: boolean;
breakdown: boolean;
}
function stopAudio(audio: HTMLAudioElement | null): void {
if (!audio) return;
audio.pause();
audio.currentTime = 0;
audio.loop = false;
}
export function useEbikeSounds(): (options: UpdateEbikeSoundsOptions) => void {
const stateRef = useRef<EbikeSoundState>("idle");
const audioRef = useRef<HTMLAudioElement | null>(null);
const stopCurrent = useCallback(() => {
stopAudio(audioRef.current);
audioRef.current = null;
stateRef.current = "idle";
}, []);
const playDepart = useCallback(() => {
stopCurrent();
const audio = AudioManager.getInstance().playSound(
EBIKE_SOUNDS.depart,
0.8,
{
category: "sfx",
},
);
audioRef.current = audio;
stateRef.current = "depart";
audio.addEventListener(
"ended",
() => {
if (stateRef.current !== "depart") return;
if (window.ebikeDriveInputActive !== true) {
stateRef.current = "idle";
audioRef.current = null;
return;
}
const rollingAudio = AudioManager.getInstance().playSound(
EBIKE_SOUNDS.roule,
0.72,
{ category: "sfx" },
);
rollingAudio.loop = true;
audioRef.current = rollingAudio;
stateRef.current = "roule";
},
{ once: true },
);
}, [stopCurrent]);
const playRalenti = useCallback(() => {
stopCurrent();
const audio = AudioManager.getInstance().playSound(
EBIKE_SOUNDS.ralenti,
0.72,
{
category: "sfx",
},
);
audioRef.current = audio;
stateRef.current = "ralenti";
audio.addEventListener(
"ended",
() => {
if (stateRef.current !== "ralenti") return;
audioRef.current = null;
stateRef.current = "idle";
},
{ once: true },
);
}, [stopCurrent]);
const update = useCallback(
({ mounted, driving, breakdown }: UpdateEbikeSoundsOptions) => {
if (!mounted || breakdown) {
stopCurrent();
return;
}
if (driving) {
if (stateRef.current === "idle" || stateRef.current === "ralenti") {
playDepart();
}
return;
}
if (stateRef.current === "depart" || stateRef.current === "roule") {
playRalenti();
}
},
[playDepart, playRalenti, stopCurrent],
);
useEffect(() => stopCurrent, [stopCurrent]);
return update;
}
+31
View File
@@ -0,0 +1,31 @@
import { useSyncExternalStore } from "react";
const MOBILE_MEDIA_QUERY =
"(max-width: 767px), (pointer: coarse) and (hover: none)";
function subscribeToMobileQuery(callback: () => void): () => void {
const query = window.matchMedia(MOBILE_MEDIA_QUERY);
query.addEventListener("change", callback);
return () => query.removeEventListener("change", callback);
}
function getMobileSnapshot(): boolean {
return window.matchMedia(MOBILE_MEDIA_QUERY).matches;
}
function getServerMobileSnapshot(): boolean {
return false;
}
/**
* True when the device is a phone or a touch-only tablet.
* Uses matchMedia so layout decisions follow CSS conventions
* and avoid resize-handler churn.
*/
export function useIsMobile(): boolean {
return useSyncExternalStore(
subscribeToMobileQuery,
getMobileSnapshot,
getServerMobileSnapshot,
);
}
+29
View File
@@ -0,0 +1,29 @@
import { useSyncExternalStore } from "react";
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
function subscribeToReducedMotion(callback: () => void): () => void {
const query = window.matchMedia(REDUCED_MOTION_QUERY);
query.addEventListener("change", callback);
return () => query.removeEventListener("change", callback);
}
function getReducedMotionSnapshot(): boolean {
return window.matchMedia(REDUCED_MOTION_QUERY).matches;
}
function getServerReducedMotionSnapshot(): boolean {
return false;
}
/**
* True when the user has requested reduced motion at the OS level.
* UI fades and transitions should collapse to 0ms when this is true.
*/
export function usePrefersReducedMotion(): boolean {
return useSyncExternalStore(
subscribeToReducedMotion,
getReducedMotionSnapshot,
getServerReducedMotionSnapshot,
);
}
+31 -1
View File
@@ -11,10 +11,13 @@ interface UseWorldSceneLoadingOptions {
interface UseWorldSceneLoadingResult {
octree: Octree | null;
gameplayReady: boolean;
shouldWarmUpShadows: boolean;
showGameStage: boolean;
handleGameStageLoaded: () => void;
handleGameMapLoaded: () => void;
handleOctreeReady: (octree: Octree) => void;
handleShadowWarmupReady: () => void;
handleShadowWarmupStarted: () => void;
}
export function useWorldSceneLoading({
@@ -24,13 +27,19 @@ export function useWorldSceneLoading({
const [octree, setOctree] = useState<Octree | null>(null);
const [gameMapLoaded, setGameMapLoaded] = useState(false);
const [gameStageLoaded, setGameStageLoaded] = useState(false);
const [shadowsReady, setShadowsReady] = useState(false);
const showGameStage = sceneMode === "game" && gameMapLoaded;
const gameplayReady = showGameStage && gameStageLoaded && octree !== null;
const gameSceneReadyForShadows =
showGameStage && gameStageLoaded && octree !== null;
const shadowWarmupReady = sceneMode === "game" && gameSceneReadyForShadows;
const shouldWarmUpShadows = shadowWarmupReady && !shadowsReady;
const gameplayReady = gameSceneReadyForShadows && shadowsReady;
const sceneReady =
(sceneMode === "game" && gameplayReady) ||
(sceneMode === "physics" && octree !== null);
const handleGameMapLoaded = useCallback(() => {
setShadowsReady(false);
setGameMapLoaded(true);
}, []);
@@ -45,6 +54,7 @@ export function useWorldSceneLoading({
const handleOctreeReady = useCallback(
(nextOctree: Octree) => {
setShadowsReady(false);
setOctree(nextOctree);
onLoadingStateChange?.({
currentStep: "Collision prête",
@@ -55,6 +65,23 @@ export function useWorldSceneLoading({
[onLoadingStateChange],
);
const handleShadowWarmupStarted = useCallback(() => {
onLoadingStateChange?.({
currentStep: "Activation des ombres",
progress: 0.97,
status: "loading",
});
}, [onLoadingStateChange]);
const handleShadowWarmupReady = useCallback(() => {
setShadowsReady(true);
onLoadingStateChange?.({
currentStep: "Ombres prêtes",
progress: 0.99,
status: "loading",
});
}, [onLoadingStateChange]);
useEffect(() => {
onLoadingStateChange?.({
currentStep: "Initialisation du jeu",
@@ -88,9 +115,12 @@ export function useWorldSceneLoading({
return {
octree,
gameplayReady,
shouldWarmUpShadows,
showGameStage,
handleGameStageLoaded,
handleGameMapLoaded,
handleOctreeReady,
handleShadowWarmupReady,
handleShadowWarmupStarted,
};
}
+288 -37
View File
@@ -1,5 +1,15 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap");
@font-face {
font-family: "Nersans One";
src:
url("/fonts/NersansOne.woff2") format("woff2"),
url("/fonts/NersansOne.woff") format("woff");
font-weight: 400;
font-style: normal;
font-display: swap;
}
/* Base document reset */
:root {
color-scheme: dark;
@@ -34,6 +44,18 @@ select {
font: inherit;
}
/* Site onboarding — accessible focus rings (WCAG 2.4.7) */
.site-card-button:focus-visible,
.site-button:focus-visible {
outline: 3px solid #ffffff;
outline-offset: 3px;
box-shadow: 0 0 0 5px rgba(0, 0, 0, 0.55);
}
.site-card-button[aria-pressed="true"]:focus-visible {
outline-color: #a8d5a2;
}
canvas {
display: block;
}
@@ -851,72 +873,301 @@ canvas {
position: fixed;
inset: 0;
z-index: 30;
display: grid;
place-items: center;
display: flex;
align-items: center;
justify-content: center;
width: 100vw;
height: 100vh;
background: #ffffff;
overflow: hidden;
background: #04070d;
pointer-events: none;
opacity: 1;
transition: opacity 640ms ease;
transition: opacity 500ms ease;
}
.scene-loading-overlay--ready {
opacity: 0;
}
.scene-loading-overlay__content {
display: grid;
justify-items: center;
gap: 18px;
width: min(360px, calc(100vw - 48px));
padding: 28px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 28px;
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.12);
.scene-loading-overlay__background,
.scene-loading-overlay__shade {
position: absolute;
inset: 0;
}
.scene-loading-overlay strong {
color: #1e293b;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.02em;
line-height: 1.45;
text-align: center;
.scene-loading-overlay__background {
width: 100%;
height: 100%;
object-fit: cover;
}
.scene-loading-overlay__shade {
background: rgba(4, 7, 13, 0.12);
}
.scene-loading-overlay__logo {
position: relative;
z-index: 1;
width: clamp(180px, 28vw, 320px);
max-height: min(38vh, 320px);
border-radius: 16px;
object-fit: cover;
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.28);
}
.scene-loading-overlay__footer {
position: absolute;
right: 0;
bottom: 0;
left: 0;
z-index: 1;
display: grid;
gap: 12px;
padding: 0 clamp(18px, 4vw, 56px) clamp(22px, 5vh, 48px);
}
.scene-loading-overlay__meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
color: #ffffff;
font-family: "Nersans One", "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: clamp(16px, 2.3vw, 30px);
line-height: 1;
letter-spacing: 0.12em;
text-shadow: 0 2px 14px rgba(0, 0, 0, 0.45);
text-transform: uppercase;
}
.scene-loading-overlay__label {
display: flex;
align-items: center;
gap: clamp(8px, 1.2vw, 14px);
min-width: 0;
}
.scene-loading-overlay__spinner {
flex: 0 0 auto;
width: clamp(18px, 2.2vw, 30px);
height: clamp(18px, 2.2vw, 30px);
color: #ffffff;
animation: scene-loading-spin 900ms linear infinite;
}
.scene-loading-overlay__meta strong {
color: inherit;
font: inherit;
}
.scene-loading-overlay__track {
position: relative;
overflow: hidden;
width: 100%;
height: 18px;
background: #e2e8f0;
border-radius: 999px;
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.04);
height: clamp(7px, 1vw, 12px);
background: rgba(255, 255, 255, 0.22);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.22);
}
.scene-loading-overlay__track span {
display: block;
height: 100%;
background: linear-gradient(90deg, #2563eb, #38bdf8);
border-radius: inherit;
background: #3b82f6;
transition: width 180ms ease;
}
.scene-loading-overlay__track em {
@keyframes scene-loading-spin {
to {
transform: rotate(360deg);
}
}
/* Mission notification */
.mission-notification {
position: fixed;
top: clamp(18px, 4vh, 42px);
left: clamp(18px, 4vw, 48px);
z-index: 20;
width: min(280px, calc(100vw - 36px));
pointer-events: none;
opacity: 1;
filter: drop-shadow(0 0 12px rgba(96, 165, 250, 0.36));
transform: translate3d(0, 0, 0) scale(1);
transition:
opacity 420ms ease,
filter 420ms ease,
transform 420ms ease;
animation: mission-notification-enter 900ms ease-out both;
}
.mission-notification--hidden {
opacity: 0;
filter: drop-shadow(0 0 4px rgba(96, 165, 250, 0.12));
transform: translate3d(-8px, -2px, 0) scale(0.985);
}
.mission-notification::after {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: #ffffff;
font-size: 11px;
font-style: normal;
font-weight: 700;
letter-spacing: 0.04em;
line-height: 1;
text-shadow: 0 1px 4px rgba(15, 23, 42, 0.35);
content: "";
pointer-events: none;
}
.mission-notification::after {
background: linear-gradient(
180deg,
transparent 0%,
rgba(96, 165, 250, 0.16) 48%,
transparent 52%,
transparent 100%
);
background-size: 100% 10px;
opacity: 0.22;
clip-path: polygon(0 0, 100% 0, 100% 69%, 88% 100%, 0 100%);
mix-blend-mode: screen;
}
.mission-notification__glow {
position: absolute;
inset: -14px;
background: radial-gradient(
circle at 22% 22%,
rgba(96, 165, 250, 0.36),
transparent 58%
);
opacity: 0.7;
filter: blur(12px);
animation: mission-notification-glow 10s ease-in-out 1s infinite;
}
.mission-notification__image-wrap {
position: relative;
display: block;
overflow: hidden;
clip-path: polygon(0 0, 100% 0, 100% 69%, 88% 100%, 0 100%);
}
.mission-notification__image-wrap::before {
position: absolute;
top: 0;
bottom: 0;
left: -35%;
z-index: 2;
width: 28%;
background: linear-gradient(
90deg,
transparent 0%,
rgba(191, 219, 254, 0.08) 18%,
rgba(125, 211, 252, 0.52) 50%,
rgba(191, 219, 254, 0.08) 82%,
transparent 100%
);
content: "";
mix-blend-mode: screen;
opacity: 0;
pointer-events: none;
transform: skewX(-16deg);
animation: mission-notification-scan 3.8s ease-in-out 1.2s infinite;
}
.mission-notification__image {
position: relative;
display: block;
width: 100%;
height: auto;
opacity: 0.92;
filter: sepia(0.08) saturate(1.18) hue-rotate(155deg) contrast(1.04)
brightness(1.03) blur(0.18px);
animation: mission-notification-flicker 10s ease-in-out 1s infinite;
}
@keyframes mission-notification-enter {
0% {
opacity: 0;
transform: translate3d(-12px, -4px, 0) scale(0.985);
}
12% {
opacity: 0.85;
}
18% {
opacity: 0.22;
}
26% {
opacity: 0.95;
}
34% {
opacity: 0.5;
}
48%,
100% {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
}
}
@keyframes mission-notification-flicker {
0%,
7%,
100% {
opacity: 0.92;
filter: saturate(1) brightness(1);
}
1.5% {
opacity: 0.58;
filter: saturate(1.25) brightness(1.18);
}
3% {
opacity: 1;
}
4.5% {
opacity: 0.74;
}
}
@keyframes mission-notification-scan {
0%,
22% {
left: -35%;
opacity: 0;
}
32% {
opacity: 0.78;
}
52% {
left: 108%;
opacity: 0;
}
100% {
left: 108%;
opacity: 0;
}
}
@keyframes mission-notification-glow {
0%,
7%,
100% {
opacity: 0.55;
}
2.5% {
opacity: 0.95;
}
4.5% {
opacity: 0.35;
}
}
/* Subtitles */
+1
View File
@@ -121,6 +121,7 @@ function completeIntroState(state: GameState): GameStateUpdate {
mainState: "ebike",
intro: {
...state.intro,
currentStep: "completed",
hasCompleted: true,
isEbikeUnlocked: true,
},
+9 -8
View File
@@ -3,14 +3,14 @@ import type { SiteStep } from "@/types/game";
interface SiteState {
currentStep: SiteStep;
selectedExperience: number | null;
selectedSituation: number | null;
selectedExperienceIndex: number | null;
selectedSituationIndex: number | null;
}
interface SiteActions {
setStep: (step: SiteStep) => void;
setSelectedExperience: (index: number) => void;
setSelectedSituation: (index: number) => void;
setSelectedExperienceIndex: (index: number) => void;
setSelectedSituationIndex: (index: number) => void;
reset: () => void;
}
@@ -18,14 +18,15 @@ type SiteStore = SiteState & SiteActions;
const initialState: SiteState = {
currentStep: "disclaimer",
selectedExperience: null,
selectedSituation: null,
selectedExperienceIndex: null,
selectedSituationIndex: null,
};
export const useSiteStore = create<SiteStore>()((set) => ({
...initialState,
setStep: (step) => set({ currentStep: step }),
setSelectedExperience: (index) => set({ selectedExperience: index }),
setSelectedSituation: (index) => set({ selectedSituation: index }),
setSelectedExperienceIndex: (index) =>
set({ selectedExperienceIndex: index }),
setSelectedSituationIndex: (index) => set({ selectedSituationIndex: index }),
reset: () => set(initialState),
}));
+49 -11
View File
@@ -3,15 +3,18 @@ import { useNavigate } from "@tanstack/react-router";
import { Canvas } from "@react-three/fiber";
import * as THREE from "three";
import { DebugPerf } from "@/components/debug/DebugPerf";
import { EbikeIntroSequence } from "@/components/game/EbikeIntroSequence";
import { DialogMessage } from "@/components/ui/DialogMessage";
import { GameUI } from "@/components/ui/GameUI";
import {
FadeToVideoOverlay,
IntroDialogueOverlay,
IntroRevealOverlay,
IntroVideoPlayer,
} from "@/components/ui/intro";
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
import { INITIAL_SCENE_LOADING_STATE } from "@/data/world/sceneLoadingConfig";
import { AudioManager } from "@/managers/AudioManager";
import { useGameStore } from "@/managers/stores/useGameStore";
import { HandTrackingProvider } from "@/providers/gameplay/HandTrackingProvider";
import type { SceneLoadingState } from "@/types/world/sceneLoading";
@@ -19,17 +22,12 @@ import { hasSiteBeenVisitedToday } from "@/utils/cookies/siteVisitCookie";
import { logger } from "@/utils/core/Logger";
import { World } from "@/world/World";
export function HomePage(): React.JSX.Element {
const LOADING_TO_VIDEO_FADE_MS = 500;
export function HomePage(): React.JSX.Element | null {
const navigate = useNavigate();
const introStep = useGameStore((state) => state.intro.currentStep);
const setIntroStep = useGameStore((state) => state.setIntroStep);
useEffect(() => {
if (!hasSiteBeenVisitedToday()) {
navigate({ to: "/site", replace: true });
}
}, [navigate]);
const dialogMessage = useGameStore(
(state) => state.missionFlow.dialogMessage,
);
@@ -38,6 +36,12 @@ export function HomePage(): React.JSX.Element {
INITIAL_SCENE_LOADING_STATE,
);
useEffect(() => {
if (!hasSiteBeenVisitedToday()) {
navigate({ to: "/site", replace: true });
}
}, [navigate]);
useEffect(() => {
if (!dialogMessage) return undefined;
@@ -68,10 +72,23 @@ export function HomePage(): React.JSX.Element {
useEffect(() => {
if (introStep === "loading-map" && sceneLoadingState.status === "ready") {
setIntroStep("video");
AudioManager.getInstance().stopMusic();
setIntroStep("fade-to-video");
}
}, [introStep, sceneLoadingState.status, setIntroStep]);
useEffect(() => {
if (introStep !== "fade-to-video") return undefined;
const timeoutId = window.setTimeout(() => {
setIntroStep("video");
}, LOADING_TO_VIDEO_FADE_MS);
return () => {
window.clearTimeout(timeoutId);
};
}, [introStep, setIntroStep]);
const handleCanvasCreated = useCallback(
({ gl }: { gl: THREE.WebGLRenderer }) => {
const canvas = gl.domElement;
@@ -80,9 +97,16 @@ export function HomePage(): React.JSX.Element {
gl.shadowMap.type = THREE.PCFShadowMap;
gl.shadowMap.autoUpdate = true;
// The browser hands us a WEBGL_lose_context extension we can use to
// ask the GPU to restore the context after a loss. Without this the
// page stays frozen on a black canvas until the user reloads.
const loseContextExt = gl.getContext().getExtension("WEBGL_lose_context");
const handleContextLost = (event: Event) => {
event.preventDefault();
logger.error("WebGL", "Context lost - GPU resources exhausted");
logger.error("WebGL", "Context lost - attempting auto-restore");
// Give the GPU a moment to free resources before asking it back.
window.setTimeout(() => loseContextExt?.restoreContext(), 500);
};
const handleContextRestored = () => {
@@ -98,7 +122,20 @@ export function HomePage(): React.JSX.Element {
[],
);
// Don't mount the Canvas until we know we will not redirect to /site.
// Without this guard the Canvas would mount, the effect above would fire
// navigate, and the Canvas would unmount mid-load — leaking GLTF requests
// and a WebGL context. The synchronous cookie check happens here AFTER
// all hooks (rules of hooks) but BEFORE any expensive render.
if (!hasSiteBeenVisitedToday()) return null;
const showFadeToVideoOverlay =
introStep === "fade-to-video" ||
(introStep === "loading-map" && sceneLoadingState.status === "ready");
const renderIntroOverlay = () => {
if (showFadeToVideoOverlay) return <FadeToVideoOverlay />;
switch (introStep) {
case "video":
return <IntroVideoPlayer />;
@@ -136,10 +173,11 @@ export function HomePage(): React.JSX.Element {
onClose={hideDialog}
/>
) : null}
{introStep === "loading-map" && (
{(introStep === "loading-map" || introStep === "fade-to-video") && (
<SceneLoadingOverlay state={sceneLoadingState} />
)}
{renderIntroOverlay()}
<EbikeIntroSequence />
</HandTrackingProvider>
);
}
+1 -34
View File
@@ -1,4 +1,3 @@
import { useEffect, useState } from "react";
import { useSiteStore } from "@/managers/stores/useSiteStore";
import { SiteDisclaimerScreen } from "@/components/site/SiteDisclaimerScreen";
import { SiteWelcomeScreen } from "@/components/site/SiteWelcomeScreen";
@@ -7,39 +6,7 @@ import { SiteNamingScreen } from "@/components/site/SiteNamingScreen";
import { SiteTransitionOverlay } from "@/components/site/SiteTransitionOverlay";
import { SiteMobileBlocker } from "@/components/site/SiteMobileBlocker";
import { SiteLayout } from "@/components/site/SiteLayout";
/**
* Check if user is on mobile device
*/
function useIsMobile(): boolean {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const checkMobile = (): void => {
const userAgent = navigator.userAgent.toLowerCase();
const mobileKeywords = [
"android",
"webos",
"iphone",
"ipad",
"ipod",
"blackberry",
"windows phone",
];
const isMobileDevice = mobileKeywords.some((keyword) =>
userAgent.includes(keyword),
);
const isSmallScreen = window.innerWidth < 768;
setIsMobile(isMobileDevice || isSmallScreen);
};
checkMobile();
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
}, []);
return isMobile;
}
import { useIsMobile } from "@/hooks/ui/useIsMobile";
export function SitePage(): React.JSX.Element {
const currentStep = useSiteStore((state) => state.currentStep);
+3
View File
@@ -7,5 +7,8 @@ declare global {
ebikeParkedPosition: Vector3Tuple | null;
ebikeParkedRotation: number | null;
ebikeSteerFactor: number | undefined;
ebikeBreakdownActive: boolean | undefined;
ebikeDriveInputActive: boolean | undefined;
ebikeSpeedFactor: number | undefined;
}
}
+5 -1
View File
@@ -15,9 +15,13 @@ export type SiteStep =
*/
export type GameStep =
| "loading-map" // Chargement des assets
| "fade-to-video" // Fondu noir entre chargement et vidéo
| "video" // Vidéo intro.mp4
| "dialogue-intro" // Dialogues post-vidéo (écran noir)
| "reveal" // Fondu noir → jeu visible
| "playing"; // Intro terminée, jeu actif
| "await-ebike-mount" // Attente interaction pour monter sur l'e-bike
| "ebike-intro-ride" // Courte conduite avant la panne
| "ebike-breakdown" // Panne + dialogue avant mission réparation
| "completed"; // Intro terminée
export type MainGameState = "intro" | RepairMissionId | "outro";
+16 -5
View File
@@ -12,6 +12,9 @@ import type { SubtitleCue } from "@/utils/subtitles/parseSrt";
const DIALOGUE_MANIFEST_PATH = "/sounds/dialogue/dialogues.json";
const DEFAULT_SUBTITLE_LANGUAGE: SubtitleLanguage = "fr";
let manifestCache: DialogueManifest | null = null;
let manifestPromise: Promise<DialogueManifest | null> | null = null;
export interface DialogueSubtitleCue {
voice: DialogueVoice;
cue: SubtitleCue;
@@ -28,13 +31,21 @@ export interface DialogueSubtitleCues {
}
export async function loadDialogueManifest(): Promise<DialogueManifest | null> {
const response = await fetch(DIALOGUE_MANIFEST_PATH);
if (manifestCache) return manifestCache;
if (manifestPromise) return manifestPromise;
if (!response.ok) {
return null;
}
manifestPromise = (async () => {
const response = await fetch(DIALOGUE_MANIFEST_PATH);
if (!response.ok) {
manifestPromise = null;
return null;
}
const manifest = parseDialogueManifest(await response.json());
manifestCache = manifest;
return manifest;
})();
return parseDialogueManifest(await response.json());
return manifestPromise;
}
function getDialogueVoice(
+29 -18
View File
@@ -20,6 +20,7 @@ type ModelEntry = [modelName: string, modelUrl: string];
let cachedSceneData: SceneData | null = null;
let loadingPromise: Promise<SceneData | null> | null = null;
const modelEntryCache = new Map<string, ModelEntry | null>();
export async function loadMapSceneData(): Promise<SceneData | null> {
if (cachedSceneData) {
@@ -223,24 +224,34 @@ async function loadMapModelUrls(
}
async function loadModelEntry(modelName: string): Promise<ModelEntry | null> {
for (const fileName of [...MODEL_FILE_NAMES, `${modelName}.gltf`]) {
const modelUrl = `/models/${modelName}/${fileName}`;
try {
const response = await fetch(modelUrl, { method: "HEAD" });
const contentType = response.headers.get("content-type") ?? "";
if (response.ok && !contentType.includes(HTML_CONTENT_TYPE)) {
return [modelName, modelUrl];
}
} catch (error) {
logger.warn("MapSceneData", "Failed to probe map model URL", {
modelName,
modelUrl,
error: error instanceof Error ? error : String(error),
});
continue;
}
if (modelEntryCache.has(modelName)) {
return modelEntryCache.get(modelName) ?? null;
}
return null;
const modelUrls = [...MODEL_FILE_NAMES, `${modelName}.gltf`].map(
(fileName) => `/models/${modelName}/${fileName}`,
);
const results = await Promise.all(
modelUrls.map(async (modelUrl) => {
try {
const response = await fetch(modelUrl, { method: "HEAD" });
const contentType = response.headers.get("content-type") ?? "";
return response.ok && !contentType.includes(HTML_CONTENT_TYPE);
} catch (error) {
logger.warn("MapSceneData", "Failed to probe map model URL", {
modelName,
modelUrl,
error: error instanceof Error ? error : String(error),
});
return false;
}
}),
);
const modelUrl = modelUrls[results.findIndex(Boolean)] ?? null;
const entry = modelUrl ? ([modelName, modelUrl] satisfies ModelEntry) : null;
modelEntryCache.set(modelName, entry);
return entry;
}
+21 -1
View File
@@ -15,11 +15,24 @@ import { SkyModel } from "@/components/three/world/SkyModel";
import { CloudSystem } from "@/world/clouds/CloudSystem";
import { FogSystem } from "@/world/fog/FogSystem";
import { GrassSystem } from "@/world/grass/GrassSystem";
import { SceneShadowWarmup } from "@/world/SceneShadowWarmup";
import { VegetationSystem } from "@/world/vegetation/VegetationSystem";
import { WaterSystem } from "@/world/water/WaterSystem";
import { WorldPlane } from "@/world/WorldPlane";
export function Environment(): React.JSX.Element {
interface ShadowWarmupConfig {
active: boolean;
onReady: () => void;
onStarted: () => void;
}
interface EnvironmentProps {
shadowWarmup?: ShadowWarmupConfig;
}
export function Environment({
shadowWarmup,
}: EnvironmentProps): React.JSX.Element {
const sceneMode = useSceneMode();
const groups = useMapPerformanceStore((state) => state.groups);
const models = useMapPerformanceStore((state) => state.models);
@@ -34,6 +47,13 @@ export function Environment(): React.JSX.Element {
return (
<>
<FogSystem />
{shadowWarmup ? (
<SceneShadowWarmup
active={shadowWarmup.active}
onReady={shadowWarmup.onReady}
onStarted={shadowWarmup.onStarted}
/>
) : null}
{showSky ? (
<SkyModel
fallbackColor={GAME_SCENE_FALLBACK_BACKGROUND_COLOR}
+1 -1
View File
@@ -175,7 +175,7 @@ export function GameMap({
sceneData.mapNodes.length - visibleMapNodes.length;
if (skippedMapNodeCount > 0) {
logger.warn("GameMap", "Lite map skipped heavy map nodes", {
logger.debug("GameMap", "Lite map skipped heavy map nodes", {
skippedMapNodeCount,
});
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { useEffect } from "react";
import { AudioManager } from "@/managers/AudioManager";
const GAME_MUSIC_PATH = "/sounds/musique/test.mp3";
const GAME_MUSIC_PATH = "/sounds/musique/musique-jeu.mp3";
const GAME_MUSIC_VOLUME = 0.33;
export function GameMusic(): null {
+2 -1
View File
@@ -14,6 +14,7 @@ import { useRepairMissionAnchorStore } from "@/managers/stores/useRepairMissionA
import type { RepairMissionTriggerConfig } from "@/types/gameplay/repairMission";
import type { Vector3Tuple } from "@/types/three/three";
import { getRepairMissionPosition } from "@/utils/gameplay/repairMissionPosition";
import { EBIKE_WORLD_POSITION } from "@/data/ebike/ebikeConfig";
interface StageAnchorProps {
color: string;
@@ -81,7 +82,7 @@ export function GameStageContent(): React.JSX.Element {
return (
<>
{mainState === "intro" ? <StageAnchor {...INTRO_STAGE_ANCHOR} /> : null}
<Ebike position={[0, 10, 0]} />
<Ebike position={EBIKE_WORLD_POSITION} />
{REPAIR_MISSION_POSITION_ENTRIES.map(({ mission }) => {
const position = getRepairMissionPosition(mission, anchors);
if (!position) return null;
+92
View File
@@ -0,0 +1,92 @@
import { useEffect, useRef } from "react";
import { useThree } from "@react-three/fiber";
import * as THREE from "three";
interface SceneShadowWarmupProps {
active: boolean;
onReady: () => void;
onStarted: () => void;
}
function markShadowLightForUpdate(object: THREE.Object3D): void {
if (
!(
object instanceof THREE.DirectionalLight ||
object instanceof THREE.PointLight ||
object instanceof THREE.SpotLight
)
) {
return;
}
if (!object.castShadow) return;
object.updateMatrixWorld(true);
object.shadow.camera.updateProjectionMatrix();
object.shadow.needsUpdate = true;
}
function forceSceneShadowPass(
gl: THREE.WebGLRenderer,
scene: THREE.Scene,
): void {
gl.shadowMap.enabled = true;
gl.shadowMap.type = THREE.PCFShadowMap;
gl.shadowMap.autoUpdate = true;
gl.shadowMap.needsUpdate = true;
scene.updateMatrixWorld(true);
scene.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.updateMatrixWorld(true);
}
markShadowLightForUpdate(object);
});
}
export function SceneShadowWarmup({
active,
onReady,
onStarted,
}: SceneShadowWarmupProps): null {
const gl = useThree((state) => state.gl);
const scene = useThree((state) => state.scene);
const invalidate = useThree((state) => state.invalidate);
const isRunningRef = useRef(false);
useEffect(() => {
if (!active) {
isRunningRef.current = false;
return undefined;
}
if (isRunningRef.current) return undefined;
isRunningRef.current = true;
onStarted();
forceSceneShadowPass(gl, scene);
invalidate();
let firstFrame = 0;
let secondFrame = 0;
firstFrame = window.requestAnimationFrame(() => {
forceSceneShadowPass(gl, scene);
invalidate();
secondFrame = window.requestAnimationFrame(() => {
forceSceneShadowPass(gl, scene);
invalidate();
onReady();
});
});
return () => {
window.cancelAnimationFrame(firstFrame);
window.cancelAnimationFrame(secondFrame);
};
}, [active, gl, invalidate, onReady, onStarted, scene]);
return null;
}
+11 -2
View File
@@ -47,6 +47,9 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
handleGameStageLoaded,
handleGameMapLoaded,
handleOctreeReady,
handleShadowWarmupReady,
handleShadowWarmupStarted,
shouldWarmUpShadows,
} = useWorldSceneLoading({ sceneMode, onLoadingStateChange });
const playerSpawnPosition =
sceneMode === "game"
@@ -61,7 +64,13 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
return (
<>
<Environment />
<Environment
shadowWarmup={{
active: shouldWarmUpShadows,
onReady: handleShadowWarmupReady,
onStarted: handleShadowWarmupStarted,
}}
/>
<Lighting />
<DebugHelpers />
{showHandTrackingGloves ? (
@@ -89,7 +98,7 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
<>
<GameMusic />
{mainState === "outro" ? <GameCinematics /> : null}
<GameDialogues />
{mainState !== "intro" ? <GameDialogues /> : null}
<Player octree={octree} spawnPosition={playerSpawnPosition} />
</>
) : null}
+54 -6
View File
@@ -29,7 +29,12 @@ import { InteractionManager } from "@/managers/InteractionManager";
import { useGameStore } from "@/managers/stores/useGameStore";
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
import type { Vector3Tuple } from "@/types/three/three";
import { EBIKE_CAMERA_TRANSFORM } from "@/data/ebike/ebikeConfig";
import {
EBIKE_ACCELERATION_DURATION_MS,
EBIKE_CAMERA_TRANSFORM,
EBIKE_DECELERATION_DURATION_MS,
EBIKE_MAX_SPEED,
} from "@/data/ebike/ebikeConfig";
/** Global window properties used for ebike communication */
interface EbikeGlobalState {
@@ -39,6 +44,9 @@ interface EbikeGlobalState {
ebikeVisualGroup?: React.RefObject<THREE.Group>;
playerPos?: Vector3Tuple;
ebikeAngle?: number;
ebikeBreakdownActive?: boolean;
ebikeDriveInputActive?: boolean;
ebikeSpeedFactor?: number;
}
declare global {
@@ -156,6 +164,7 @@ export function PlayerController({
const movementModeRef = useRef(movementMode);
const prevMovementModeRef = useRef(movementMode);
const ebikeAngle = useRef(0);
const ebikeSpeedFactor = useRef(0);
const capsule = useRef(createSpawnCapsule(spawnPosition));
useEffect(() => {
@@ -175,6 +184,7 @@ export function PlayerController({
velocity.current.set(0, 0, 0);
onFloor.current = false;
wantsJump.current = false;
ebikeSpeedFactor.current = 0;
ebikeAngle.current = targetRot;
@@ -215,6 +225,7 @@ export function PlayerController({
const shift = rightDir.multiplyScalar(3);
capsule.current.translate(shift);
camera.position.copy(capsule.current.end);
ebikeSpeedFactor.current = 0;
}
prevMovementModeRef.current = movementMode;
}, [movementMode, camera]);
@@ -347,7 +358,10 @@ export function PlayerController({
return;
}
if (movementModeRef.current === "ebike") {
const isEbikeMounted = movementModeRef.current === "ebike";
const isEbikeBreakdown = window.ebikeBreakdownActive === true;
if (isEbikeMounted && !isEbikeBreakdown) {
const turnSpeed = 1.8;
if (keys.current.left) {
ebikeAngle.current += turnSpeed * dt;
@@ -365,19 +379,41 @@ export function PlayerController({
}
_wishDir.set(0, 0, 0);
if (!movementLocked) {
if (!movementLocked && !isEbikeBreakdown) {
if (keys.current.forward) _wishDir.add(_forward);
if (keys.current.backward) _wishDir.sub(_forward);
if (movementModeRef.current !== "ebike") {
if (!isEbikeMounted) {
if (keys.current.left) _wishDir.sub(_right);
if (keys.current.right) _wishDir.add(_right);
}
}
if (_wishDir.lengthSq() > 0) _wishDir.normalize();
if (isEbikeMounted) {
const isDriveInputActive = _wishDir.lengthSq() > 0 && !isEbikeBreakdown;
const durationMs = isDriveInputActive
? EBIKE_ACCELERATION_DURATION_MS
: EBIKE_DECELERATION_DURATION_MS;
const factorDelta = durationMs > 0 ? (dt * 1000) / durationMs : 1;
ebikeSpeedFactor.current = THREE.MathUtils.clamp(
ebikeSpeedFactor.current +
(isDriveInputActive ? factorDelta : -factorDelta),
0,
1,
);
window.ebikeDriveInputActive = isDriveInputActive;
window.ebikeSpeedFactor = ebikeSpeedFactor.current;
} else {
window.ebikeDriveInputActive = false;
window.ebikeSpeedFactor = 0;
}
const movementSpeed = isEbikeMounted
? EBIKE_MAX_SPEED * ebikeSpeedFactor.current
: currentSpeed;
const accel = onFloor.current
? currentSpeed
: currentSpeed * PLAYER_AIR_CONTROL_FACTOR;
? movementSpeed
: movementSpeed * PLAYER_AIR_CONTROL_FACTOR;
velocity.current.x +=
_wishDir.x * accel * dt * PLAYER_ACCELERATION_MULTIPLIER;
velocity.current.z +=
@@ -387,6 +423,18 @@ export function PlayerController({
velocity.current.x *= damping;
velocity.current.z *= damping;
if (
isEbikeMounted &&
isEbikeBreakdown &&
ebikeSpeedFactor.current <= 0.001 &&
Math.hypot(velocity.current.x, velocity.current.z) <= 0.05
) {
velocity.current.setX(0);
velocity.current.setZ(0);
useGameStore.getState().setPlayerMovementMode("walk");
return;
}
if (onFloor.current) {
velocity.current.y = Math.max(0, velocity.current.y);
if (wantsJump.current) {