4 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
31 changed files with 802 additions and 287 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.
+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]
}
]
+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"}
/>
);
}
+3 -23
View File
@@ -16,16 +16,15 @@ const DIALOGUE_FALLBACK_TIMEOUT_MS = 12000;
const NO_DIALOGUE_FALLBACK_MS = 3000;
/**
* Transition overlay: black screen, logo fade-in, transition dialogue
* with subtitles, then redirect to /. A safety timeout guarantees the
* redirect happens even if the dialogue audio fails to fire `ended`.
* 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);
useEffect(() => {
setSiteVisited();
@@ -37,13 +36,11 @@ export function SiteTransitionOverlay(): React.JSX.Element {
// initial frame at opacity 0 before flipping to 1.
const fadeInId = window.setTimeout(() => {
setScreenOpacity(1);
setLogoOpacity(1);
}, 0);
timeoutIds.push(fadeInId);
const redirectToGame = (): void => {
if (isCancelled) return;
setLogoOpacity(0);
const id = window.setTimeout(() => {
if (isCancelled) return;
reset();
@@ -119,23 +116,6 @@ export function SiteTransitionOverlay(): React.JSX.Element {
transition: fadeTransition,
}}
/>
<img
src="/assets/logo/logo.jpg"
alt="Logo Altera"
style={{
position: "relative",
zIndex: 1,
width: "min(300px, 45vw)",
height: "auto",
objectFit: "contain",
opacity: logoOpacity,
transition: fadeTransition,
transitionDelay:
!prefersReducedMotion && logoOpacity === 1
? `${FADE_DURATION_MS}ms`
: "0ms",
}}
/>
{/* 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. */}
+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>
);
}
@@ -6,11 +6,12 @@ const REVEAL_DURATION_MS = 2000;
/**
* Fade-out overlay revealing the game world.
* Calls completeIntro() when the fade is done — completeIntro also marks
* intro.currentStep as "completed" so no separate setIntroStep call is needed.
* 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 completeIntro = useGameStore((state) => state.completeIntro);
const setIntroStep = useGameStore((state) => state.setIntroStep);
const setCanMove = useGameStore((state) => state.setCanMove);
const prefersReducedMotion = usePrefersReducedMotion();
const [opacity, setOpacity] = useState(1);
@@ -20,14 +21,15 @@ export function IntroRevealOverlay(): React.JSX.Element {
}, 100);
const completeTimeout = window.setTimeout(() => {
completeIntro();
setCanMove(true);
setIntroStep("await-ebike-mount");
}, REVEAL_DURATION_MS);
return () => {
window.clearTimeout(fadeTimeout);
window.clearTimeout(completeTimeout);
};
}, [completeIntro]);
}, [setCanMove, setIntroStep]);
return (
<div
+28 -1
View File
@@ -1,8 +1,9 @@
import { useCallback, useEffect, useRef } 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 the intro cinematic.
@@ -10,6 +11,8 @@ const SKIP_KEYS = new Set(["Enter", " "]);
*/
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(() => {
@@ -33,11 +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,
@@ -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
+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";
+3
View File
@@ -19,6 +19,9 @@ export const GAME_STEPS: readonly GameStep[] = [
"video",
"dialogue-intro",
"reveal",
"await-ebike-mount",
"ebike-intro-ride",
"ebike-breakdown",
"completed",
];
@@ -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];
+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 -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,
};
}
+188
View File
@@ -982,6 +982,194 @@ canvas {
}
}
/* 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;
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 */
.subtitles {
position: fixed;
+16 -3
View File
@@ -3,6 +3,7 @@ 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 {
@@ -96,9 +97,16 @@ export function HomePage(): React.JSX.Element | null {
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 = () => {
@@ -121,10 +129,14 @@ export function HomePage(): React.JSX.Element | null {
// 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 "fade-to-video":
return <FadeToVideoOverlay />;
case "video":
return <IntroVideoPlayer />;
case "dialogue-intro":
@@ -165,6 +177,7 @@ export function HomePage(): React.JSX.Element | null {
<SceneLoadingOverlay state={sceneLoadingState} />
)}
{renderIntroOverlay()}
<EbikeIntroSequence />
</HandTrackingProvider>
);
}
+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;
}
}
+3
View File
@@ -19,6 +19,9 @@ export type GameStep =
| "video" // Vidéo intro.mp4
| "dialogue-intro" // Dialogues post-vidéo (écran noir)
| "reveal" // Fondu noir → jeu visible
| "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";
+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}
+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) {