15 Commits

Author SHA1 Message Date
Tom Boullay bdc704fe8e feat(ui): show narrator video on talkie
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
2026-06-01 10:52:28 +02:00
Tom Boullay bce7d11b66 fix(ebike): snap parked model to terrain 2026-06-01 10:52:17 +02:00
Tom Boullay 8aa755da7a fix(model): replace electricienne animated asset 2026-06-01 10:52:08 +02:00
Tom Boullay 6d58b90856 fix(world): throttle shadows and tune high preset 2026-06-01 10:45:07 +02:00
Tom Boullay bafca5a936 fix(ui): apply mobile blocker globally 2026-06-01 09:45:45 +02:00
Tom Boullay dcf3a8564c feat(ui): add narrator talkie overlay 2026-06-01 01:32:46 +02:00
Tom Boullay bc862960a7 fix(settings): persist pause menu preferences 2026-06-01 01:32:36 +02:00
Tom Boullay 597ebcfbd4 fix(ebike): sync parked position from config 2026-06-01 01:32:29 +02:00
Tom Boullay aa2d411b0c fix(world): stabilize lafabrik spawn and vegetation 2026-06-01 01:32:21 +02:00
Tom Boullay 061e0dc677 feat: update ui and intro sequence 2026-06-01 00:54:59 +02:00
Tom Boullay 9ef94af488 Merge branch 'develop' into feat/polish-mission1
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
2026-06-01 00:15:46 +02:00
Tom Boullay 27b4a2c392 upatde(fabrik): zone + herbe
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
2026-06-01 00:14:39 +02:00
math-pixel d5feb07ff0 Merge pull request 'Feat/polish-perf' (#13) from feat/polish-perf into develop
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled
Reviewed-on: #13
2026-05-31 22:11:58 +00:00
Tom Boullay c33d973f12 fix(ui): update logo asset path 2026-05-31 11:51:33 +02:00
Tom Boullay 396e7e4ff0 feat(ebike): add speedometer
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
2026-05-31 11:36:19 +02:00
46 changed files with 1029 additions and 218 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ Current behavior:
| -------- | ------------------: | --- | ------------------------------------- |
| `low` | 10m | On | Always use `*-LOD` models |
| `medium` | 20m | On | Always use `*-LOD` models |
| `high` | Current default 50m | Off | Regular model up to 10m, then `*-LOD` |
| `high` | 35m | Off | Regular model up to 10m, then `*-LOD` |
| `ultra` | 50m | Off | Regular model up to 20m, then `*-LOD` |
The unload distance stays slightly larger than the load distance to avoid rapid mount/unmount flickering when the player stands near a boundary.
+5 -3
View File
@@ -158,9 +158,11 @@ Current runtime values:
```txt
chunkSize: 35
loadRadius: 45
unloadRadius: 45
updateInterval: 350ms
low load/unload radius: 10m / 18m
medium load/unload radius: 20m / 30m
high load/unload radius: 35m / 45m
ultra load/unload radius: 50m / 65m
updateInterval: 250ms
fog near: 30
fog far: 45
```
+6
View File
@@ -91,6 +91,12 @@ 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.
After the warmup, shadow maps switch back to manual refreshes driven by `Lighting`.
The sun still follows the player camera, but the shadow map is only marked dirty
when the camera has moved enough and a short refresh interval has elapsed. This
keeps shadows present after loading without paying for a full shadow render every
frame across the dense vegetation chunks.
The debug physics scene is ready when:
```ts
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+8
View File
@@ -1,7 +1,15 @@
import { RouterProvider } from "@tanstack/react-router";
import { SiteMobileBlocker } from "@/components/site/SiteMobileBlocker";
import { useIsMobile } from "@/hooks/ui/useIsMobile";
import { router } from "@/router";
function App(): React.JSX.Element {
const isMobile = useIsMobile();
if (isMobile) {
return <SiteMobileBlocker />;
}
return <RouterProvider router={router} />;
}
+105 -53
View File
@@ -2,17 +2,22 @@ import { useEffect, useRef, useState, useMemo, useCallback } from "react";
import * as THREE from "three";
import { useFrame, useThree } from "@react-three/fiber";
import { EbikeGPSMap } from "@/components/ebike/EbikeGPSMap";
import { EbikeSpeedometer } from "@/components/ebike/EbikeSpeedometer";
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
import { useEbikeSounds } from "@/hooks/ebike/useEbikeSounds";
import {
getObjectBottomOffset,
useTerrainHeightSampler,
} from "@/hooks/three/useTerrainHeight";
import { animateCameraTransformTransition } from "@/world/GameCinematics";
import { useGameStore } from "@/managers/stores/useGameStore";
import { PLAYER_EYE_HEIGHT } from "@/data/player/playerConfig";
import {
EBIKE_CAMERA_TRANSFORM,
EBIKE_DROP_PLAYER_TRANSFORM,
EBIKE_WORLD_SCALE,
EBIKE_WORLD_ROTATION_Y,
} from "@/data/ebike/ebikeConfig";
import type { Vector3Tuple } from "@/types/three/three";
@@ -31,12 +36,29 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
position: position,
});
const model = useClonedObject(scene);
const terrainHeight = useTerrainHeightSampler();
const parkedPosition = useMemo<Vector3Tuple>(() => {
const [x, y, z] = position;
const height = terrainHeight.getHeight(x, z) ?? y;
const bottomOffset = getObjectBottomOffset(model, [
EBIKE_WORLD_SCALE,
EBIKE_WORLD_SCALE,
EBIKE_WORLD_SCALE,
]);
return [x, height + bottomOffset, z];
}, [model, position, terrainHeight]);
const movementMode = useGameStore((state) => state.player.movementMode);
const mainState = useGameStore((state) => state.mainState);
const ebikeStep = useGameStore((state) => state.ebike.currentStep);
const setMissionStep = useGameStore((state) => state.setMissionStep);
const camera = useThree((state) => state.camera);
const updateEbikeSounds = useEbikeSounds();
const repairGameOwnsEbikeModel =
mainState === "ebike" &&
ebikeStep !== "locked" &&
ebikeStep !== "waiting" &&
ebikeStep !== "inspected";
// Map active mainState to target repair zone coordinate
const destPos = useMemo(() => {
@@ -58,19 +80,19 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
y: number;
z: number;
}>({
x: position[0],
y: position[1],
z: position[2],
x: parkedPosition[0],
y: parkedPosition[1],
z: parkedPosition[2],
});
const lastGpsUpdatePos = useRef<THREE.Vector3>(
new THREE.Vector3(...position),
new THREE.Vector3(...parkedPosition),
);
// Use ref for internal state, and state for debug visualization (to avoid ref access during render)
const restingPositionRef = useRef<Vector3Tuple>([
position[0],
position[1] - PLAYER_EYE_HEIGHT,
position[2],
parkedPosition[0],
parkedPosition[1],
parkedPosition[2],
]);
const restingRotationRef = useRef<number>(EBIKE_WORLD_ROTATION_Y);
const forkRef = useRef<THREE.Object3D | null>(null);
@@ -79,11 +101,27 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
const [showCameraPoints, setShowCameraPoints] = useState(true);
const [debugRestingPosition, setDebugRestingPosition] =
useState<Vector3Tuple>([
position[0],
position[1] - PLAYER_EYE_HEIGHT,
position[2],
parkedPosition[0],
parkedPosition[1],
parkedPosition[2],
]);
useEffect(() => {
if (movementMode === "ebike") return;
restingPositionRef.current = parkedPosition;
restingRotationRef.current = EBIKE_WORLD_ROTATION_Y;
lastGpsUpdatePos.current.set(...parkedPosition);
if (groupRef.current) {
groupRef.current.position.set(...parkedPosition);
groupRef.current.rotation.set(0, EBIKE_WORLD_ROTATION_Y, 0);
}
window.ebikeParkedPosition = parkedPosition;
window.ebikeParkedRotation = EBIKE_WORLD_ROTATION_Y;
}, [movementMode, parkedPosition]);
useEffect(() => {
if (model) {
const fork = model.getObjectByName("fourche");
@@ -169,16 +207,30 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
debugRestingPosition[1] + EBIKE_DROP_PLAYER_TRANSFORM.position[1],
debugRestingPosition[2] + EBIKE_DROP_PLAYER_TRANSFORM.position[2],
];
const interactionLabel =
mainState === "ebike"
? "Réparer l'e-bike"
: movementMode === "walk"
? "Monter sur le bike"
: "Descendre du bike";
const handleInteract = useCallback((): void => {
if (window.ebikeBreakdownActive === true) return;
if (movementMode === "walk") {
if (mainState === "ebike" && ebikeStep === "waiting") {
if (
mainState === "ebike" &&
(ebikeStep === "locked" || ebikeStep === "waiting")
) {
setMissionStep("ebike", "inspected");
return;
}
if (mainState === "ebike" && ebikeStep === "inspected") {
setMissionStep("ebike", "fragmented");
return;
}
const cameraOffset = new THREE.Vector3(
...EBIKE_CAMERA_TRANSFORM.position,
);
@@ -258,51 +310,51 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
return (
<>
<group
ref={groupRef}
position={position}
rotation={[0, EBIKE_WORLD_ROTATION_Y, 0]}
>
<primitive object={model} />
<InteractableObject
kind="trigger"
label={
mainState === "ebike" && ebikeStep === "waiting"
? "Inspecter l'e-bike"
: movementMode === "walk"
? "Monter sur le bike"
: "Descendre du bike"
}
position={position}
radius={15}
onPress={handleInteract}
{!repairGameOwnsEbikeModel ? (
<group
ref={groupRef}
position={parkedPosition}
rotation={[0, EBIKE_WORLD_ROTATION_Y, 0]}
scale={EBIKE_WORLD_SCALE}
>
<mesh>
<boxGeometry args={[10, 13, 2]} />
<meshBasicMaterial colorWrite={false} depthWrite={false} />
</mesh>
</InteractableObject>
<primitive object={model} />
<InteractableObject
kind="trigger"
label={interactionLabel}
position={parkedPosition}
radius={5}
onPress={handleInteract}
>
<mesh>
<boxGeometry args={[8, 9, 2]} />
<meshBasicMaterial colorWrite={false} depthWrite={false} />
</mesh>
</InteractableObject>
{/* Dynamic 3D GPS Dashboard Screen */}
<group position={[0, 7, 0]} rotation={[0, 90, 0]}>
<EbikeGPSMap
width={0.8}
height={0.8}
startPos={gpsStartPos}
destPos={destPos}
mapImageUrl="/assets/world/gps/map_background.png"
worldBounds={{
minX: -166,
maxX: 163,
minZ: -142,
maxZ: 138,
}}
zoom={4}
/>
{/* Dynamic 3D GPS Dashboard Screen */}
<group position={[0, 7, 0]} rotation={[0, 90, 0]}>
<EbikeGPSMap
width={0.8}
height={0.8}
startPos={gpsStartPos}
destPos={destPos}
mapImageUrl="/assets/world/gps/map_background.png"
worldBounds={{
minX: -166,
maxX: 163,
minZ: -142,
maxZ: 138,
}}
zoom={4}
/>
</group>
<group position={[0, 6.35, 0]} rotation={[0, 90, 0]}>
<EbikeSpeedometer />
</group>
</group>
</group>
) : null}
{showCameraPoints && (
{showCameraPoints && !repairGameOwnsEbikeModel && (
<>
<mesh position={camPointPos}>
<sphereGeometry args={[0.3, 16, 16]} />
+5 -1
View File
@@ -89,6 +89,8 @@ export interface EbikeGPSMapProps {
* Default: 1
*/
zoom?: number;
renderOrder?: number;
}
/**
@@ -107,6 +109,7 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
position = [0, 0, 0],
canvasSize = 1024,
zoom = 1,
renderOrder = 10_000,
}) => {
const [waypoints, setWaypoints] = useState<Waypoint[]>([]);
const [mapImage, setMapImage] = useState<
@@ -506,12 +509,13 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
}, [draw]);
return (
<mesh castShadow receiveShadow position={position}>
<mesh position={position} renderOrder={renderOrder}>
<planeGeometry args={[width, height]} />
<meshBasicMaterial
toneMapped={false}
transparent={true}
opacity={1}
depthTest={false}
depthWrite={false}
side={THREE.DoubleSide}
>
+90
View File
@@ -0,0 +1,90 @@
import { useEffect, useRef } from "react";
import { useFrame } from "@react-three/fiber";
import { useTexture } from "@react-three/drei";
import * as THREE from "three";
const SPEEDOMETER_DIAL_TEXTURE = "/assets/world/gps/cadran.png";
const SPEEDOMETER_NEEDLE_TEXTURE = "/assets/world/gps/fleche.png";
const SPEEDOMETER_MIN_ANGLE = Math.PI / 2;
const SPEEDOMETER_MAX_ANGLE = -Math.PI / 2;
const SPEEDOMETER_RENDER_ORDER = 10_000;
interface EbikeSpeedometerProps {
width?: number;
height?: number;
}
export function EbikeSpeedometer({
width = 0.9,
height = 0.5,
}: EbikeSpeedometerProps): React.JSX.Element {
const needleGroupRef = useRef<THREE.Group>(null);
const speedFactorRef = useRef(0);
const [dialTexture, needleTexture] = useTexture([
SPEEDOMETER_DIAL_TEXTURE,
SPEEDOMETER_NEEDLE_TEXTURE,
]) as [THREE.Texture, THREE.Texture];
const needleWidth = width * 0.68;
const needleHeight = needleWidth / 2;
useEffect(() => {
[dialTexture, needleTexture].forEach((texture) => {
texture.colorSpace = THREE.SRGBColorSpace;
texture.needsUpdate = true;
});
}, [dialTexture, needleTexture]);
useFrame((_, delta) => {
const targetSpeedFactor = THREE.MathUtils.clamp(
window.ebikeSpeedFactor ?? 0,
0,
1,
);
speedFactorRef.current = THREE.MathUtils.lerp(
speedFactorRef.current,
targetSpeedFactor,
Math.min(1, delta * 10),
);
if (needleGroupRef.current) {
needleGroupRef.current.rotation.z = THREE.MathUtils.lerp(
SPEEDOMETER_MIN_ANGLE,
SPEEDOMETER_MAX_ANGLE,
speedFactorRef.current,
);
}
});
return (
<group renderOrder={SPEEDOMETER_RENDER_ORDER}>
<mesh renderOrder={SPEEDOMETER_RENDER_ORDER}>
<planeGeometry args={[width, height]} />
<meshBasicMaterial
map={dialTexture}
transparent
depthTest={false}
depthWrite={false}
toneMapped={false}
side={THREE.DoubleSide}
/>
</mesh>
<group ref={needleGroupRef} position={[0, -height * 0.38, 0.002]}>
<mesh
position={[0, needleHeight / 2, 0]}
renderOrder={SPEEDOMETER_RENDER_ORDER + 1}
>
<planeGeometry args={[needleWidth, needleHeight]} />
<meshBasicMaterial
map={needleTexture}
transparent
depthTest={false}
depthWrite={false}
toneMapped={false}
side={THREE.DoubleSide}
/>
</mesh>
</group>
</group>
);
}
+58 -11
View File
@@ -1,11 +1,13 @@
import { useEffect, useRef, useState } from "react";
import * as THREE from "three";
import { MissionNotification } from "@/components/ui/MissionNotification";
import {
EBIKE_BREAKDOWN_DIALOGUE_DELAY_MS,
EBIKE_BREAKDOWN_DIALOGUE_ID,
EBIKE_INTRO_RIDE_DURATION_MS,
EBIKE_INTRO_BREAKDOWN_DISTANCE,
EBIKE_SOUNDS,
} from "@/data/ebike/ebikeConfig";
import { INTRO_MISSION_NOTIFICATION_IMAGE_PATH } from "@/data/gameplay/missionNotifications";
import { AudioManager } from "@/managers/AudioManager";
import { useGameStore } from "@/managers/stores/useGameStore";
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
@@ -18,6 +20,9 @@ export function EbikeIntroSequence(): React.JSX.Element | null {
const completeIntro = useGameStore((state) => state.completeIntro);
const [breakdownDialogueDone, setBreakdownDialogueDone] = useState(false);
const hasStartedBreakdown = useRef(false);
const rideDistance = useRef(0);
const lastRidePosition = useRef<THREE.Vector3 | null>(null);
const currentRidePosition = useRef(new THREE.Vector3());
useEffect(() => {
if (introStep !== "await-ebike-mount" || movementMode !== "ebike") return;
@@ -26,16 +31,45 @@ export function EbikeIntroSequence(): React.JSX.Element | null {
}, [introStep, movementMode, setIntroStep]);
useEffect(() => {
if (introStep !== "ebike-intro-ride") return undefined;
if (introStep !== "ebike-intro-ride") return;
const timeoutId = window.setTimeout(() => {
setIntroStep("ebike-breakdown");
}, EBIKE_INTRO_RIDE_DURATION_MS);
rideDistance.current = 0;
lastRidePosition.current = null;
}, [introStep]);
return () => {
window.clearTimeout(timeoutId);
useEffect(() => {
if (introStep !== "ebike-intro-ride" || movementMode !== "ebike") {
return undefined;
}
let animationFrameId = 0;
const tick = () => {
const parkedPosition = window.ebikeParkedPosition;
if (parkedPosition) {
currentRidePosition.current.set(...parkedPosition);
if (!lastRidePosition.current) {
lastRidePosition.current = currentRidePosition.current.clone();
} else {
rideDistance.current += currentRidePosition.current.distanceTo(
lastRidePosition.current,
);
lastRidePosition.current.copy(currentRidePosition.current);
}
if (rideDistance.current >= EBIKE_INTRO_BREAKDOWN_DISTANCE) {
setIntroStep("ebike-breakdown");
return;
}
}
animationFrameId = window.requestAnimationFrame(tick);
};
}, [introStep, setIntroStep]);
animationFrameId = window.requestAnimationFrame(tick);
return () => {
window.cancelAnimationFrame(animationFrameId);
};
}, [introStep, movementMode, setIntroStep]);
useEffect(() => {
if (introStep !== "ebike-breakdown" || hasStartedBreakdown.current) {
@@ -100,14 +134,27 @@ export function EbikeIntroSequence(): React.JSX.Element | null {
}
}, [introStep]);
if (introStep !== "await-ebike-mount" && introStep !== "ebike-intro-ride") {
if (
introStep !== "reveal" &&
introStep !== "await-ebike-mount" &&
introStep !== "ebike-intro-ride" &&
introStep !== "ebike-breakdown"
) {
return null;
}
if (introStep === "ebike-breakdown") {
return <MissionNotification mission="ebike" />;
}
return (
<MissionNotification
mission="ebike"
visible={introStep === "await-ebike-mount"}
imagePath={INTRO_MISSION_NOTIFICATION_IMAGE_PATH}
visible={
introStep === "reveal" ||
introStep === "await-ebike-mount" ||
introStep === "ebike-intro-ride"
}
/>
);
}
+1 -1
View File
@@ -20,7 +20,7 @@ export function SiteMobileBlocker(): React.JSX.Element {
}}
>
<img
src="/assets/logo/logo.jpg"
src="/assets/logo.png"
alt="Logo Altera"
style={{ width: 120, height: "auto" }}
/>
+2
View File
@@ -5,6 +5,7 @@ import { HandTrackingVisualizer } from "@/components/ui/HandTrackingVisualizer";
import { InteractPrompt } from "@/components/ui/InteractPrompt";
import { RepairMovementLockIndicator } from "@/components/ui/RepairMovementLockIndicator";
import { Subtitles } from "@/components/ui/Subtitles";
import { TalkieDialogueOverlay } from "@/components/ui/TalkieDialogueOverlay";
export function GameUI(): React.JSX.Element {
return (
@@ -15,6 +16,7 @@ export function GameUI(): React.JSX.Element {
<InteractPrompt />
<HandTrackingVisualizer />
<Subtitles />
<TalkieDialogueOverlay />
<GameSettingsMenu />
</>
);
+7 -2
View File
@@ -2,14 +2,19 @@ import { MISSION_NOTIFICATION_IMAGE_PATHS } from "@/data/gameplay/missionNotific
import type { RepairMissionId } from "@/types/gameplay/repairMission";
interface MissionNotificationProps {
mission: RepairMissionId;
mission?: RepairMissionId;
imagePath?: string;
visible?: boolean;
}
export function MissionNotification({
mission,
imagePath,
visible = true,
}: MissionNotificationProps): React.JSX.Element {
const src =
imagePath ?? (mission ? MISSION_NOTIFICATION_IMAGE_PATHS[mission] : "");
return (
<div
className={`mission-notification${visible ? "" : " mission-notification--hidden"}`}
@@ -19,7 +24,7 @@ export function MissionNotification({
<span className="mission-notification__image-wrap">
<img
className="mission-notification__image"
src={MISSION_NOTIFICATION_IMAGE_PATHS[mission]}
src={src}
alt="Nouvel objectif de mission"
/>
</span>
+1 -1
View File
@@ -2,7 +2,7 @@ import { AppLoadingIndicator } from "@/components/ui/AppLoadingIndicator";
import type { SceneLoadingState } from "@/types/world/sceneLoading";
const LOADING_BACKGROUND_PATH = "/assets/bg-site.png";
const LOADING_LOGO_PATH = "/assets/logo/logo.jpg";
const LOADING_LOGO_PATH = "/assets/logo.png";
for (const path of [LOADING_BACKGROUND_PATH, LOADING_LOGO_PATH]) {
const image = new Image();
+244
View File
@@ -0,0 +1,244 @@
import { Suspense, useEffect, useMemo, useRef } from "react";
import { Canvas, useFrame } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import * as THREE from "three";
import { useGameStore } from "@/managers/stores/useGameStore";
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
import { GAME_STEPS } from "@/data/game/gameStateConfig";
import type { Vector3Tuple } from "@/types/three/three";
const TALKIE_MODEL_PATH = "/models/talkie/model.gltf";
const TALKIE_VIDEO_PATH = "/assets/world/UI/talkie-video.mp4";
const TALKIE_FIRST_VISIBLE_STEP = "reveal";
const TALKIE_FIRST_VISIBLE_STEP_INDEX = GAME_STEPS.indexOf(
TALKIE_FIRST_VISIBLE_STEP,
);
const TALKIE_REST_Y = -1.55;
const TALKIE_ACTIVE_Y = -0.92;
const TALKIE_BASE_ROTATION: Vector3Tuple = [0.08, -0.52, -0.04];
const TALKIE_FLOAT_ROTATION_AMPLITUDE = THREE.MathUtils.degToRad(2.2);
const TALKIE_FLOAT_Y_AMPLITUDE = 0.055;
const TALKIE_SCREEN_TEXTURE_SIZE = 512;
interface TalkieModelProps {
active: boolean;
}
interface TalkieVideoResources {
canvas: HTMLCanvasElement;
context: CanvasRenderingContext2D | null;
material: THREE.MeshBasicMaterial;
texture: THREE.CanvasTexture;
video: HTMLVideoElement;
}
function createTalkieVideoResources(): TalkieVideoResources {
const video = document.createElement("video");
video.src = TALKIE_VIDEO_PATH;
video.crossOrigin = "anonymous";
video.loop = true;
video.muted = true;
video.playsInline = true;
video.preload = "auto";
const canvas = document.createElement("canvas");
canvas.width = TALKIE_SCREEN_TEXTURE_SIZE;
canvas.height = TALKIE_SCREEN_TEXTURE_SIZE;
const context = canvas.getContext("2d");
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.flipY = false;
texture.needsUpdate = true;
const material = new THREE.MeshBasicMaterial({
map: texture,
toneMapped: false,
});
return { canvas, context, material, texture, video };
}
function TalkieModel({ active }: TalkieModelProps): React.JSX.Element {
const { scene } = useGLTF(TALKIE_MODEL_PATH);
const model = useMemo(() => scene.clone(true), [scene]);
const groupRef = useRef<THREE.Group>(null);
const screenRef = useRef<THREE.Mesh | null>(null);
const originalScreenMaterialRef = useRef<THREE.Material | null>(null);
const videoResourcesRef = useRef<TalkieVideoResources | null>(null);
useEffect(() => {
const videoResources = createTalkieVideoResources();
videoResourcesRef.current = videoResources;
return () => {
videoResources.video.pause();
videoResources.video.removeAttribute("src");
videoResources.video.load();
videoResources.texture.dispose();
videoResources.material.dispose();
videoResourcesRef.current = null;
};
}, []);
useEffect(() => {
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.castShadow = false;
child.receiveShadow = false;
child.frustumCulled = false;
}
});
const screen = model.getObjectByName("écran");
if (screen instanceof THREE.Mesh) {
screenRef.current = screen;
originalScreenMaterialRef.current = Array.isArray(screen.material)
? (screen.material[0] ?? null)
: screen.material;
}
}, [model]);
useEffect(() => {
const screen = screenRef.current;
const originalMaterial = originalScreenMaterialRef.current;
const videoResources = videoResourcesRef.current;
if (!videoResources) return;
if (screen) {
screen.material = active
? videoResources.material
: (originalMaterial ?? videoResources.material);
}
if (active) {
void videoResources.video.play();
return;
}
videoResources.video.pause();
}, [active]);
useFrame(({ clock }) => {
if (!groupRef.current) return;
const t = clock.getElapsedTime();
const floatY = Math.sin(t * 1.2) * TALKIE_FLOAT_Y_AMPLITUDE;
const targetY = (active ? TALKIE_ACTIVE_Y : TALKIE_REST_Y) + floatY;
groupRef.current.position.y = THREE.MathUtils.lerp(
groupRef.current.position.y,
targetY,
0.14,
);
groupRef.current.rotation.x =
TALKIE_BASE_ROTATION[0] +
Math.sin(t * 0.7) * TALKIE_FLOAT_ROTATION_AMPLITUDE;
groupRef.current.rotation.y =
TALKIE_BASE_ROTATION[1] +
Math.sin(t * 0.55) * TALKIE_FLOAT_ROTATION_AMPLITUDE;
groupRef.current.rotation.z =
TALKIE_BASE_ROTATION[2] +
Math.sin(t * 0.8) * TALKIE_FLOAT_ROTATION_AMPLITUDE;
const videoResources = videoResourcesRef.current;
if (active && videoResources?.context) {
const { canvas, context, texture, video } = videoResources;
context.fillStyle = "#02040a";
context.fillRect(0, 0, canvas.width, canvas.height);
if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
const videoAspect = video.videoWidth / video.videoHeight;
const canvasAspect = canvas.width / canvas.height;
const drawWidth =
videoAspect > canvasAspect
? canvas.width
: canvas.height * videoAspect;
const drawHeight =
videoAspect > canvasAspect
? canvas.width / videoAspect
: canvas.height;
const drawX = (canvas.width - drawWidth) / 2;
const drawY = (canvas.height - drawHeight) / 2;
context.drawImage(video, drawX, drawY, drawWidth, drawHeight);
}
texture.needsUpdate = true;
}
});
return (
<group
ref={groupRef}
position={[0, TALKIE_REST_Y, 0]}
rotation={TALKIE_BASE_ROTATION}
>
<primitive
object={model}
position={[0, -3.25, 0]}
rotation={[0, -1, 0]}
scale={1.5}
/>
</group>
);
}
interface TalkieSignalLinesProps {
side: "left" | "right";
}
function TalkieSignalLines({
side,
}: TalkieSignalLinesProps): React.JSX.Element {
return (
<svg
className={`talkie-dialogue-overlay__signals talkie-dialogue-overlay__signals--${side}`}
viewBox="0 0 90 120"
aria-hidden="true"
>
<path d="M18 48 C30 58 30 72 18 82" />
<path d="M34 34 C56 52 56 78 34 96" />
<path d="M52 20 C84 46 84 84 52 110" />
</svg>
);
}
export function TalkieDialogueOverlay(): React.JSX.Element | null {
const activeSubtitle = useSubtitleStore((state) => state.activeSubtitle);
const mainState = useGameStore((state) => state.mainState);
const introStep = useGameStore((state) => state.intro.currentStep);
const introStepIndex = GAME_STEPS.indexOf(introStep);
const hasTalkieBeenRevealed =
mainState !== "intro" || introStepIndex >= TALKIE_FIRST_VISIBLE_STEP_INDEX;
const isNarratorDialogue = activeSubtitle?.speaker === "Narrateur";
if (!hasTalkieBeenRevealed) return null;
return (
<aside
className={`talkie-dialogue-overlay${isNarratorDialogue ? " talkie-dialogue-overlay--active talkie-dialogue-overlay--raised" : ""}`}
aria-hidden="true"
>
{isNarratorDialogue ? <TalkieSignalLines side="left" /> : null}
{isNarratorDialogue ? <TalkieSignalLines side="right" /> : null}
<div className="talkie-dialogue-overlay__model-frame">
<Canvas
camera={{ position: [0, 0, 4.2], zoom: 62 }}
dpr={[1, 1.5]}
gl={{ alpha: true, antialias: true }}
orthographic
>
<ambientLight intensity={2.5} />
<directionalLight position={[2, 3, 4]} intensity={2.8} />
<Suspense fallback={null}>
<TalkieModel active={isNarratorDialogue} />
</Suspense>
</Canvas>
</div>
</aside>
);
}
useGLTF.preload(TALKIE_MODEL_PATH);
+6 -5
View File
@@ -6,19 +6,20 @@ export interface CameraTransform {
}
export const EBIKE_CAMERA_TRANSFORM: CameraTransform = {
position: [-3.5, 6, 0],
position: [-2.6, 4.5, 0],
rotation: [-10, -90, 0],
};
export const EBIKE_DROP_PLAYER_TRANSFORM: CameraTransform = {
position: [0, 1.5, -3],
position: [0, 1.3, -2.25],
rotation: [0, 0, 0],
};
export const EBIKE_WORLD_POSITION: Vector3Tuple = [61.5, 10, 62.4];
export const EBIKE_WORLD_ROTATION_Y = 2.4107;
export const EBIKE_WORLD_POSITION: Vector3Tuple = [65, 0.8, 72];
export const EBIKE_WORLD_ROTATION_Y = -2.5;
export const EBIKE_WORLD_SCALE = 0.35;
export const EBIKE_INTRO_RIDE_DURATION_MS = 5000;
export const EBIKE_INTRO_BREAKDOWN_DISTANCE = 15;
export const EBIKE_BREAKDOWN_DIALOGUE_DELAY_MS = 250;
export const EBIKE_MAX_SPEED = 3;
@@ -1,5 +1,8 @@
import type { RepairMissionId } from "@/types/gameplay/repairMission";
export const INTRO_MISSION_NOTIFICATION_IMAGE_PATH =
"/assets/world/UI/intro-mission-notification.png";
export const MISSION_NOTIFICATION_IMAGE_PATHS: Record<RepairMissionId, string> =
{
ebike: "/assets/world/UI/ebike-mission-notification.png",
+7 -2
View File
@@ -1,10 +1,11 @@
import type { Vector3Tuple } from "@/types/three/three";
import { LA_FABRIK_PLAYER_SPAWN } from "@/data/world/laFabrikConfig";
export const PLAYER_EYE_HEIGHT = 1.75;
export const PLAYER_CAPSULE_RADIUS = 0.35;
export const PLAYER_WALK_SPEED = 5;
export const PLAYER_EBIKE_SPEED = 20;
export const PLAYER_EBIKE_SPEED = 30;
export const PLAYER_AIR_CONTROL_FACTOR = 0.35;
export const PLAYER_JUMP_SPEED = 9;
export const PLAYER_GRAVITY = 30;
@@ -14,5 +15,9 @@ export const PLAYER_XZ_DAMPING_FACTOR = 8;
export const PLAYER_FALL_RESPAWN_Y = -20;
export const PLAYER_FALL_RESPAWN_DELAY = 3;
export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [59.5, 10, 64.64];
export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [
LA_FABRIK_PLAYER_SPAWN[0] + 1,
LA_FABRIK_PLAYER_SPAWN[1],
LA_FABRIK_PLAYER_SPAWN[2] - 1,
];
export const PLAYER_SPAWN_POSITION_PHYSICS: Vector3Tuple = [0, 3, 0];
+2 -4
View File
@@ -1,5 +1,3 @@
import { CHUNK_CONFIG } from "@/data/world/chunkStreamingConfig";
export const GRAPHICS_PRESET_KEYS = ["low", "medium", "high", "ultra"] as const;
export type GraphicsPreset = (typeof GRAPHICS_PRESET_KEYS)[number];
@@ -32,8 +30,8 @@ export const GRAPHICS_PRESETS = {
},
high: {
label: "High",
chunkLoadRadius: CHUNK_CONFIG.loadRadius,
chunkUnloadRadius: CHUNK_CONFIG.unloadRadius,
chunkLoadRadius: 35,
chunkUnloadRadius: 45,
fogEnabled: false,
forceLodModels: false,
lodHighDetailDistance: 10,
+29
View File
@@ -0,0 +1,29 @@
import type { Vector3Tuple } from "@/types/three/three";
export const LA_FABRIK_CENTER: Vector3Tuple = [59.4973, 6.2746, 64.6354];
export const LA_FABRIK_ROTATION_Y = 2.4107;
export const LA_FABRIK_HALF_EXTENTS = {
x: 8.5,
z: 7.5,
} as const;
export const LA_FABRIK_PLAYER_SPAWN: Vector3Tuple = [59.5, 6.3, 64.64];
export const LA_FABRIK_INITIAL_LOOK_AT: Vector3Tuple = [58, 7.3, 62.5];
export const LA_FABRIK_INTERIOR_LIGHT_POSITION: Vector3Tuple = [59.5, 9, 64.64];
export function isInsideLaFabrikFootprint(
x: number,
z: number,
padding = 0,
): boolean {
const dx = x - LA_FABRIK_CENTER[0];
const dz = z - LA_FABRIK_CENTER[2];
const cos = Math.cos(-LA_FABRIK_ROTATION_Y);
const sin = Math.sin(-LA_FABRIK_ROTATION_Y);
const localX = dx * cos - dz * sin;
const localZ = dx * sin + dz * cos;
return (
Math.abs(localX) <= LA_FABRIK_HALF_EXTENTS.x + padding &&
Math.abs(localZ) <= LA_FABRIK_HALF_EXTENTS.z + padding
);
}
+113
View File
@@ -1237,6 +1237,119 @@ canvas {
color: #f9a8d4;
}
/* Dialogue talkie */
.talkie-dialogue-overlay {
position: fixed;
left: 0;
bottom: 0;
z-index: 16;
width: clamp(190px, 18vw, 310px);
aspect-ratio: 1.05;
overflow: visible;
pointer-events: none;
transform: translateY(0);
transition: transform 180ms ease;
}
.talkie-dialogue-overlay--raised {
transform: translateY(-8px);
}
.talkie-dialogue-overlay__model-frame {
position: absolute;
inset: -18% -12% -6% -12%;
filter: drop-shadow(0 16px 22px rgba(0, 0, 0, 0.55));
}
.talkie-dialogue-overlay__model-frame canvas {
width: 100% !important;
height: 100% !important;
}
.talkie-dialogue-overlay__signals {
position: absolute;
bottom: 38%;
z-index: 2;
width: 34%;
height: 50%;
overflow: visible;
opacity: 0.72;
animation: talkie-signal-pulse 1s ease-in-out infinite;
}
.talkie-dialogue-overlay__signals--left {
left: 7%;
scale: -1 1;
}
.talkie-dialogue-overlay__signals--right {
right: 7%;
}
.talkie-dialogue-overlay__signals path {
fill: none;
stroke: rgba(162, 210, 255, 0.92);
stroke-linecap: round;
stroke-width: 4;
filter: drop-shadow(0 0 5px rgba(125, 211, 252, 0.58));
}
.talkie-dialogue-overlay__signals path:nth-child(2) {
animation-delay: 90ms;
opacity: 0.75;
}
.talkie-dialogue-overlay__signals path:nth-child(3) {
animation-delay: 180ms;
opacity: 0.45;
}
@keyframes talkie-radio-shake {
0%,
11%,
23%,
100% {
transform: translate3d(0, 0, 0) rotate(0deg);
}
3%,
15%,
27% {
transform: translate3d(-2px, 1px, 0) rotate(-1.7deg);
}
6%,
18%,
30% {
transform: translate3d(2px, -1px, 0) rotate(1.7deg);
}
9%,
21%,
33% {
transform: translate3d(-1px, 0, 0) rotate(-0.8deg);
}
}
@keyframes talkie-signal-pulse {
0%,
100% {
opacity: 0.28;
transform: translate3d(-4px, 4px, 0) scale(0.92);
}
18%,
38% {
opacity: 0.95;
transform: translate3d(0, 0, 0) scale(1);
}
60% {
opacity: 0.45;
transform: translate3d(4px, -6px, 0) scale(1.05);
}
}
/* In-game settings menu */
.game-settings-menu {
position: fixed;
+44 -27
View File
@@ -1,4 +1,5 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { AudioManager } from "@/managers/AudioManager";
import type { AudioCategory } from "@/managers/AudioManager";
import type { SubtitleLanguage } from "@/types/settings/settings";
@@ -33,6 +34,8 @@ const DEFAULT_SETTINGS: SettingsState = {
subtitleLanguage: "fr",
};
const SETTINGS_STORAGE_KEY = "la-fabrik-settings";
function clampVolume(volume: number): number {
return Math.max(0, Math.min(1, volume));
}
@@ -46,36 +49,50 @@ function setAudioCategoryVolume(
return nextVolume;
}
function applyDefaultAudioSettings(): void {
AudioManager.getInstance().setCategoryVolume(
"music",
DEFAULT_SETTINGS.musicVolume,
);
AudioManager.getInstance().setCategoryVolume(
"sfx",
DEFAULT_SETTINGS.sfxVolume,
);
function applyAudioSettings(
settings: Pick<SettingsState, "musicVolume" | "sfxVolume" | "dialogueVolume">,
): void {
AudioManager.getInstance().setCategoryVolume("music", settings.musicVolume);
AudioManager.getInstance().setCategoryVolume("sfx", settings.sfxVolume);
AudioManager.getInstance().setCategoryVolume(
"dialogue",
DEFAULT_SETTINGS.dialogueVolume,
settings.dialogueVolume,
);
}
applyDefaultAudioSettings();
applyAudioSettings(DEFAULT_SETTINGS);
export const useSettingsStore = create<SettingsStore>()((set) => ({
...DEFAULT_SETTINGS,
setSettingsMenuOpen: (isSettingsMenuOpen) => set({ isSettingsMenuOpen }),
setMusicVolume: (volume) =>
set({ musicVolume: setAudioCategoryVolume("music", volume) }),
setSfxVolume: (volume) =>
set({ sfxVolume: setAudioCategoryVolume("sfx", volume) }),
setDialogueVolume: (volume) =>
set({ dialogueVolume: setAudioCategoryVolume("dialogue", volume) }),
setSubtitlesEnabled: (subtitlesEnabled) => set({ subtitlesEnabled }),
setSubtitleLanguage: (subtitleLanguage) => set({ subtitleLanguage }),
resetSettings: () => {
applyDefaultAudioSettings();
set(DEFAULT_SETTINGS);
},
}));
export const useSettingsStore = create<SettingsStore>()(
persist(
(set) => ({
...DEFAULT_SETTINGS,
setSettingsMenuOpen: (isSettingsMenuOpen) => set({ isSettingsMenuOpen }),
setMusicVolume: (volume) =>
set({ musicVolume: setAudioCategoryVolume("music", volume) }),
setSfxVolume: (volume) =>
set({ sfxVolume: setAudioCategoryVolume("sfx", volume) }),
setDialogueVolume: (volume) =>
set({ dialogueVolume: setAudioCategoryVolume("dialogue", volume) }),
setSubtitlesEnabled: (subtitlesEnabled) => set({ subtitlesEnabled }),
setSubtitleLanguage: (subtitleLanguage) => set({ subtitleLanguage }),
resetSettings: () => {
applyAudioSettings(DEFAULT_SETTINGS);
set(DEFAULT_SETTINGS);
},
}),
{
name: SETTINGS_STORAGE_KEY,
storage: createJSONStorage(() => window.localStorage),
partialize: (state) => ({
dialogueVolume: state.dialogueVolume,
musicVolume: state.musicVolume,
sfxVolume: state.sfxVolume,
subtitleLanguage: state.subtitleLanguage,
subtitlesEnabled: state.subtitlesEnabled,
}),
onRehydrateStorage: () => (state) => {
if (state) applyAudioSettings(state);
},
},
),
);
+73 -56
View File
@@ -1,4 +1,5 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { CLOUD_DEFAULTS, type CloudState } from "@/data/world/cloudConfig";
import { FOG_CONFIG, type FogState } from "@/data/world/fogConfig";
import { WIND_DEFAULTS, type WindState } from "@/data/world/windConfig";
@@ -46,73 +47,89 @@ const DEFAULT_STATE: WorldSettingsState = {
graphics: { ...GRAPHICS_DEFAULTS },
};
export const useWorldSettingsStore = create<WorldSettingsStore>()((set) => ({
...DEFAULT_STATE,
const WORLD_SETTINGS_STORAGE_KEY = "la-fabrik-world-settings";
setClouds: (cloudsUpdate) =>
set((state) => ({
clouds: { ...state.clouds, ...cloudsUpdate },
})),
export const useWorldSettingsStore = create<WorldSettingsStore>()(
persist(
(set) => ({
...DEFAULT_STATE,
setFog: (fogUpdate) =>
set((state) => ({
fog: { ...state.fog, ...fogUpdate },
})),
setClouds: (cloudsUpdate) =>
set((state) => ({
clouds: { ...state.clouds, ...cloudsUpdate },
})),
setWind: (windUpdate) =>
set((state) => ({
wind: { ...state.wind, ...windUpdate },
})),
setFog: (fogUpdate) =>
set((state) => ({
fog: { ...state.fog, ...fogUpdate },
})),
setWindSpeed: (speed) =>
set((state) => ({
wind: { ...state.wind, speed },
})),
setWind: (windUpdate) =>
set((state) => ({
wind: { ...state.wind, ...windUpdate },
})),
setWindDirection: (direction) =>
set((state) => ({
wind: { ...state.wind, direction },
})),
setWindSpeed: (speed) =>
set((state) => ({
wind: { ...state.wind, speed },
})),
setWindStrength: (strength) =>
set((state) => ({
wind: { ...state.wind, strength },
})),
setWindDirection: (direction) =>
set((state) => ({
wind: { ...state.wind, direction },
})),
setGraphics: (graphicsUpdate) =>
set((state) => ({
graphics: { ...state.graphics, ...graphicsUpdate },
})),
setWindStrength: (strength) =>
set((state) => ({
wind: { ...state.wind, strength },
})),
setGraphicsPreset: (preset) =>
set((state) => ({
graphics: { ...state.graphics, preset },
})),
setGraphics: (graphicsUpdate) =>
set((state) => ({
graphics: { ...state.graphics, ...graphicsUpdate },
})),
setDynamicGrass: (dynamicGrass) =>
set((state) => ({
graphics: { ...state.graphics, dynamicGrass },
})),
setGraphicsPreset: (preset) =>
set((state) => ({
graphics: { ...state.graphics, preset },
})),
setDynamicTrees: (dynamicTrees) =>
set((state) => ({
graphics: { ...state.graphics, dynamicTrees },
})),
setDynamicGrass: (dynamicGrass) =>
set((state) => ({
graphics: { ...state.graphics, dynamicGrass },
})),
setDynamicClouds: (dynamicClouds) =>
set((state) => ({
graphics: { ...state.graphics, dynamicClouds },
})),
setDynamicTrees: (dynamicTrees) =>
set((state) => ({
graphics: { ...state.graphics, dynamicTrees },
})),
setShadowsEnabled: (shadowsEnabled) =>
set((state) => ({
graphics: { ...state.graphics, shadowsEnabled },
})),
setDynamicClouds: (dynamicClouds) =>
set((state) => ({
graphics: { ...state.graphics, dynamicClouds },
})),
setGrassDensity: (grassDensity) =>
set((state) => ({
graphics: { ...state.graphics, grassDensity },
})),
setShadowsEnabled: (shadowsEnabled) =>
set((state) => ({
graphics: { ...state.graphics, shadowsEnabled },
})),
resetToDefaults: () => set(DEFAULT_STATE),
}));
setGrassDensity: (grassDensity) =>
set((state) => ({
graphics: { ...state.graphics, grassDensity },
})),
resetToDefaults: () => set(DEFAULT_STATE),
}),
{
name: WORLD_SETTINGS_STORAGE_KEY,
storage: createJSONStorage(() => window.localStorage),
partialize: (state) => ({
clouds: state.clouds,
fog: state.fog,
graphics: state.graphics,
wind: state.wind,
}),
},
),
);
+4 -2
View File
@@ -130,7 +130,8 @@ export function HomePage(): React.JSX.Element | null {
gl.shadowMap.enabled = true;
gl.shadowMap.type = THREE.PCFShadowMap;
gl.shadowMap.autoUpdate = true;
gl.shadowMap.autoUpdate = false;
gl.shadowMap.needsUpdate = true;
// The browser hands us a WEBGL_lose_context extension we can use to
// ask the GPU to restore the context after a loss. Without this the
@@ -147,7 +148,8 @@ export function HomePage(): React.JSX.Element | null {
const handleContextRestored = () => {
gl.shadowMap.enabled = true;
gl.shadowMap.type = THREE.PCFShadowMap;
gl.shadowMap.autoUpdate = true;
gl.shadowMap.autoUpdate = false;
gl.shadowMap.needsUpdate = true;
logger.info("WebGL", "Context restored");
};
-7
View File
@@ -4,17 +4,10 @@ import { SiteWelcomeScreen } from "@/components/site/SiteWelcomeScreen";
import { SiteSituationScreen } from "@/components/site/SiteSituationScreen";
import { SiteNamingScreen } from "@/components/site/SiteNamingScreen";
import { SiteTransitionOverlay } from "@/components/site/SiteTransitionOverlay";
import { SiteMobileBlocker } from "@/components/site/SiteMobileBlocker";
import { SiteLayout } from "@/components/site/SiteLayout";
import { useIsMobile } from "@/hooks/ui/useIsMobile";
export function SitePage(): React.JSX.Element {
const currentStep = useSiteStore((state) => state.currentStep);
const isMobile = useIsMobile();
if (isMobile) {
return <SiteMobileBlocker />;
}
if (currentStep === "disclaimer") {
return <SiteDisclaimerScreen />;
+12 -3
View File
@@ -9,6 +9,7 @@ const DEBUG_CONTROLS_STORAGE_KEY = "la-fabrik-debug-controls";
interface StoredDebugControls {
cameraMode: CameraMode;
handTrackingSource: HandTrackingSource;
sceneMode: SceneMode;
}
@@ -39,6 +40,10 @@ function isSceneMode(value: unknown): value is SceneMode {
return value === "game" || value === "physics";
}
function isHandTrackingSource(value: unknown): value is HandTrackingSource {
return value === "browser" || value === "backend";
}
function getStoredDebugControls(): Partial<StoredDebugControls> {
try {
const rawValue = window.localStorage.getItem(DEBUG_CONTROLS_STORAGE_KEY);
@@ -51,6 +56,9 @@ function getStoredDebugControls(): Partial<StoredDebugControls> {
...(isCameraMode(parsedValue.cameraMode)
? { cameraMode: parsedValue.cameraMode }
: {}),
...(isHandTrackingSource(parsedValue.handTrackingSource)
? { handTrackingSource: parsedValue.handTrackingSource }
: {}),
...(isSceneMode(parsedValue.sceneMode)
? { sceneMode: parsedValue.sceneMode }
: {}),
@@ -94,7 +102,7 @@ export class Debug {
this.controls = {
cameraMode: storedControls.cameraMode ?? "player",
fogEnabled: FOG_CONFIG.enabled,
handTrackingSource: "browser",
handTrackingSource: storedControls.handTrackingSource ?? "browser",
showDebugOverlay: true,
showHandTrackingSvg: false,
showInteractionSpheres: false,
@@ -159,7 +167,7 @@ export class Debug {
.name("Source")
.onChange((value: HandTrackingSource) => {
this.controls.handTrackingSource = value;
this.emit();
this.saveAndEmit();
});
}
}
@@ -246,7 +254,7 @@ export class Debug {
setHandTrackingSource(value: HandTrackingSource): void {
this.controls.handTrackingSource = value;
this.emit();
this.saveAndEmit();
}
getFogEnabled(): boolean {
@@ -285,6 +293,7 @@ export class Debug {
DEBUG_CONTROLS_STORAGE_KEY,
JSON.stringify({
cameraMode: this.controls.cameraMode,
handTrackingSource: this.controls.handTrackingSource,
sceneMode: this.controls.sceneMode,
}),
);
+8 -2
View File
@@ -14,7 +14,13 @@ import { useRepairMissionAnchorStore } from "@/managers/stores/useRepairMissionA
import type { RepairMissionTriggerConfig } from "@/types/gameplay/repairMission";
import type { Vector3Tuple } from "@/types/three/three";
import { getRepairMissionPosition } from "@/utils/gameplay/repairMissionPosition";
import { EBIKE_WORLD_POSITION } from "@/data/ebike/ebikeConfig";
import {
EBIKE_WORLD_POSITION,
EBIKE_WORLD_ROTATION_Y,
EBIKE_WORLD_SCALE,
} from "@/data/ebike/ebikeConfig";
const EBIKE_CONFIG_KEY = `${EBIKE_WORLD_POSITION.join(",")}:${EBIKE_WORLD_ROTATION_Y}:${EBIKE_WORLD_SCALE}`;
interface StageAnchorProps {
color: string;
@@ -82,7 +88,7 @@ export function GameStageContent(): React.JSX.Element {
return (
<>
{mainState === "intro" ? <StageAnchor {...INTRO_STAGE_ANCHOR} /> : null}
<Ebike position={EBIKE_WORLD_POSITION} />
<Ebike key={EBIKE_CONFIG_KEY} position={EBIKE_WORLD_POSITION} />
{REPAIR_MISSION_POSITION_ENTRIES.map(({ mission }) => {
const position = getRepairMissionPosition(mission, anchors);
if (!position) return null;
+96 -16
View File
@@ -1,6 +1,15 @@
import { useEffect, useRef } from "react";
import type { MutableRefObject } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import type { AmbientLight, DirectionalLight, Object3D } from "three";
import {
PCFShadowMap,
Vector3,
type AmbientLight,
type Camera,
type DirectionalLight,
type Object3D,
type WebGLRenderer,
} from "three";
import {
AMBIENT_INTENSITY_MAX,
AMBIENT_INTENSITY_MIN,
@@ -18,6 +27,7 @@ import {
SUN_Z_MIN,
SUN_Z_STEP,
} from "@/data/world/lightingConfig";
import { LA_FABRIK_INTERIOR_LIGHT_POSITION } from "@/data/world/laFabrikConfig";
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
import { LIGHTING_STATE } from "@/world/lightingState";
@@ -25,29 +35,84 @@ const SHADOW_MAP_SIZE = 2048;
const SHADOW_CAMERA_SIZE = 95;
const SHADOW_CAMERA_NEAR = 0.5;
const SHADOW_CAMERA_FAR = 300;
const SHADOW_REFRESH_INTERVAL_MS = 180;
const SHADOW_REFRESH_DISTANCE = 0.75;
const SHADOW_REFRESH_DISTANCE_SQUARED =
SHADOW_REFRESH_DISTANCE * SHADOW_REFRESH_DISTANCE;
function configureManualRendererShadows(gl: WebGLRenderer): void {
gl.shadowMap.enabled = true;
gl.shadowMap.type = PCFShadowMap;
gl.shadowMap.autoUpdate = false;
gl.shadowMap.needsUpdate = true;
}
function configureSunShadow(sun: DirectionalLight, sunTarget: Object3D): void {
sun.target = sunTarget;
sun.shadow.autoUpdate = false;
sun.shadow.needsUpdate = true;
sun.shadow.mapSize.width = SHADOW_MAP_SIZE;
sun.shadow.mapSize.height = SHADOW_MAP_SIZE;
sun.shadow.camera.left = -SHADOW_CAMERA_SIZE;
sun.shadow.camera.right = SHADOW_CAMERA_SIZE;
sun.shadow.camera.top = SHADOW_CAMERA_SIZE;
sun.shadow.camera.bottom = -SHADOW_CAMERA_SIZE;
sun.shadow.camera.near = SHADOW_CAMERA_NEAR;
sun.shadow.camera.far = SHADOW_CAMERA_FAR;
sun.shadow.camera.updateProjectionMatrix();
}
function requestSunShadowRefresh({
camera,
elapsedMs,
gl,
lastCameraPosition,
lastRefreshMs,
shadowHasInitialPosition,
sun,
}: {
camera: Camera;
elapsedMs: number;
gl: WebGLRenderer;
lastCameraPosition: Vector3;
lastRefreshMs: MutableRefObject<number>;
shadowHasInitialPosition: MutableRefObject<boolean>;
sun: DirectionalLight;
}): void {
if (elapsedMs - lastRefreshMs.current < SHADOW_REFRESH_INTERVAL_MS) {
return;
}
const cameraMovedEnough =
!shadowHasInitialPosition.current ||
lastCameraPosition.distanceToSquared(camera.position) >=
SHADOW_REFRESH_DISTANCE_SQUARED;
if (!cameraMovedEnough) return;
configureManualRendererShadows(gl);
sun.shadow.needsUpdate = true;
lastCameraPosition.copy(camera.position);
lastRefreshMs.current = elapsedMs;
shadowHasInitialPosition.current = true;
}
export function Lighting(): React.JSX.Element {
const camera = useThree((state) => state.camera);
const gl = useThree((state) => state.gl);
const ambient = useRef<AmbientLight>(null);
const sun = useRef<DirectionalLight>(null);
const sunTarget = useRef<Object3D>(null);
const lastShadowRefreshMs = useRef(-SHADOW_REFRESH_INTERVAL_MS);
const lastShadowCameraPosition = useRef(new Vector3());
const shadowHasInitialPosition = useRef(false);
useEffect(() => {
if (!sun.current || !sunTarget.current) return;
sun.current.target = sunTarget.current;
sun.current.shadow.autoUpdate = true;
sun.current.shadow.needsUpdate = true;
sun.current.shadow.mapSize.width = SHADOW_MAP_SIZE;
sun.current.shadow.mapSize.height = SHADOW_MAP_SIZE;
sun.current.shadow.camera.left = -SHADOW_CAMERA_SIZE;
sun.current.shadow.camera.right = SHADOW_CAMERA_SIZE;
sun.current.shadow.camera.top = SHADOW_CAMERA_SIZE;
sun.current.shadow.camera.bottom = -SHADOW_CAMERA_SIZE;
sun.current.shadow.camera.near = SHADOW_CAMERA_NEAR;
sun.current.shadow.camera.far = SHADOW_CAMERA_FAR;
sun.current.shadow.camera.updateProjectionMatrix();
}, []);
configureSunShadow(sun.current, sunTarget.current);
configureManualRendererShadows(gl);
}, [gl]);
useDebugFolder("Lighting", (folder) => {
folder.addColor(LIGHTING_STATE, "ambientColor").name("Ambient Color");
@@ -81,7 +146,7 @@ export function Lighting(): React.JSX.Element {
.name("Sun Z");
});
useFrame(() => {
useFrame(({ clock }) => {
if (ambient.current) {
ambient.current.color.set(LIGHTING_STATE.ambientColor);
ambient.current.intensity = LIGHTING_STATE.ambientIntensity;
@@ -98,7 +163,15 @@ export function Lighting(): React.JSX.Element {
sun.current.color.set(LIGHTING_STATE.sunColor);
sun.current.intensity = LIGHTING_STATE.sunIntensity;
sun.current.updateMatrixWorld();
sun.current.shadow.needsUpdate = true;
requestSunShadowRefresh({
camera,
elapsedMs: clock.elapsedTime * 1000,
gl,
lastCameraPosition: lastShadowCameraPosition.current,
lastRefreshMs: lastShadowRefreshMs,
shadowHasInitialPosition,
sun: sun.current,
});
}
});
@@ -121,6 +194,13 @@ export function Lighting(): React.JSX.Element {
castShadow
/>
<object3D ref={sunTarget} />
<pointLight
position={LA_FABRIK_INTERIOR_LIGHT_POSITION}
color="#dbeafe"
intensity={1.2}
distance={14}
decay={1.6}
/>
</>
);
}
+6
View File
@@ -45,6 +45,11 @@ function forceSceneShadowPass(
});
}
function restoreManualShadowUpdates(gl: THREE.WebGLRenderer): void {
gl.shadowMap.autoUpdate = false;
gl.shadowMap.needsUpdate = true;
}
export function SceneShadowWarmup({
active,
onReady,
@@ -77,6 +82,7 @@ export function SceneShadowWarmup({
secondFrame = window.requestAnimationFrame(() => {
forceSceneShadowPass(gl, scene);
restoreManualShadowUpdates(gl);
invalidate();
onReady();
});
+6 -1
View File
@@ -4,6 +4,7 @@ import {
PLAYER_SPAWN_POSITION_GAME,
PLAYER_SPAWN_POSITION_PHYSICS,
} from "@/data/player/playerConfig";
import { LA_FABRIK_INITIAL_LOOK_AT } from "@/data/world/laFabrikConfig";
import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useEnvironmentDebug } from "@/hooks/debug/useEnvironmentDebug";
import { useMapPerformanceDebug } from "@/hooks/debug/useMapPerformanceDebug";
@@ -99,7 +100,11 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
<GameMusic />
{mainState === "outro" ? <GameCinematics /> : null}
{mainState !== "intro" ? <GameDialogues /> : null}
<Player octree={octree} spawnPosition={playerSpawnPosition} />
<Player
initialLookAt={LA_FABRIK_INITIAL_LOOK_AT}
octree={octree}
spawnPosition={playerSpawnPosition}
/>
</>
) : null}
</>
+16
View File
@@ -8,6 +8,11 @@ import {
GRASS_COLORS,
GRASS_CONFIG,
} from "@/data/world/grassConfig";
import {
LA_FABRIK_CENTER,
LA_FABRIK_HALF_EXTENTS,
LA_FABRIK_ROTATION_Y,
} from "@/data/world/laFabrikConfig";
import {
grassFragmentShader,
grassVertexShader,
@@ -169,6 +174,17 @@ function createGrassMaterial(
uMaxBladeHeight: { value: GRASS_CONFIG.maxBladeHeight },
uRandomHeightAmount: { value: GRASS_CONFIG.randomHeightAmount },
uSurfaceOffset: { value: GRASS_CONFIG.surfaceOffset },
uLaFabrikCenter: {
value: new THREE.Vector2(LA_FABRIK_CENTER[0], LA_FABRIK_CENTER[2]),
},
uLaFabrikHalfExtents: {
value: new THREE.Vector2(
LA_FABRIK_HALF_EXTENTS.x,
LA_FABRIK_HALF_EXTENTS.z,
),
},
uLaFabrikRotation: { value: LA_FABRIK_ROTATION_Y },
uLaFabrikNoGrassFeather: { value: 1.4 },
},
});
}
+16
View File
@@ -43,6 +43,10 @@ export const grassVertexShader = /* glsl */ `
uniform float uMaxBladeHeight;
uniform float uRandomHeightAmount;
uniform float uSurfaceOffset;
uniform vec2 uLaFabrikCenter;
uniform vec2 uLaFabrikHalfExtents;
uniform float uLaFabrikRotation;
uniform float uLaFabrikNoGrassFeather;
float random(vec2 st) {
return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123);
@@ -132,6 +136,18 @@ export const grassVertexShader = /* glsl */ `
smoothstep(uBoundingBoxMax.z, uBoundingBoxMax.z - 2.0, worldPos.z);
heightModifier *= edgeFade * mix(0.45, 1.0, clumpMask);
vec2 laFabrikDelta = worldPos.xz - uLaFabrikCenter;
float laFabrikCos = cos(-uLaFabrikRotation);
float laFabrikSin = sin(-uLaFabrikRotation);
vec2 laFabrikLocal = vec2(
laFabrikDelta.x * laFabrikCos - laFabrikDelta.y * laFabrikSin,
laFabrikDelta.x * laFabrikSin + laFabrikDelta.y * laFabrikCos
);
vec2 laFabrikDistance = abs(laFabrikLocal) - uLaFabrikHalfExtents;
float laFabrikOutsideDistance = max(laFabrikDistance.x, laFabrikDistance.y);
float laFabrikGrassMask = smoothstep(0.0, uLaFabrikNoGrassFeather, laFabrikOutsideDistance);
heightModifier *= laFabrikGrassMask;
float sideFactor = (color.r == 0.1) ? 1.0 : (color.b == 0.1) ? -1.0 : 0.0;
float tipFactor = color.g;
float width = smoothstep(0.02, uMaxBladeHeight * 0.85, heightModifier) * uBladeWidth * bladeVisibility;
+9 -2
View File
@@ -7,10 +7,12 @@ import { PlayerController } from "@/world/player/PlayerController";
interface PlayerProps {
octree: Octree | null;
initialLookAt?: Vector3Tuple | undefined;
spawnPosition: Vector3Tuple;
}
export function Player({
initialLookAt,
spawnPosition,
octree,
}: PlayerProps): React.JSX.Element {
@@ -18,12 +20,17 @@ export function Player({
useLayoutEffect(() => {
camera.position.set(...spawnPosition);
}, [camera, spawnPosition]);
if (initialLookAt) camera.lookAt(...initialLookAt);
}, [camera, initialLookAt, spawnPosition]);
return (
<>
<PlayerCamera />
<PlayerController octree={octree} spawnPosition={spawnPosition} />
<PlayerController
initialLookAt={initialLookAt}
octree={octree}
spawnPosition={spawnPosition}
/>
</>
);
}
+7 -1
View File
@@ -75,6 +75,7 @@ const PLAYER_FLOOR_NORMAL_MIN = 0.15;
const PLAYER_GROUND_SNAP_DISTANCE = 0.22;
interface PlayerControllerProps {
initialLookAt?: Vector3Tuple | undefined;
octree: Octree | null;
spawnPosition: Vector3Tuple;
}
@@ -89,6 +90,7 @@ const _collisionCorrection = new THREE.Vector3();
function resetPlayerCapsule(
capsule: Capsule,
spawnPosition: Vector3Tuple,
initialLookAt: Vector3Tuple | undefined,
camera: THREE.Camera,
velocity: THREE.Vector3,
): void {
@@ -100,6 +102,7 @@ function resetPlayerCapsule(
capsule.end.set(...spawnPosition);
velocity.set(0, 0, 0);
camera.position.copy(capsule.end);
if (initialLookAt) camera.lookAt(...initialLookAt);
}
function createSpawnCapsule(spawnPosition: Vector3Tuple): Capsule {
@@ -145,6 +148,7 @@ function getCapsuleFootY(capsule: Capsule): number {
}
export function PlayerController({
initialLookAt,
octree,
spawnPosition,
}: PlayerControllerProps): null {
@@ -234,6 +238,7 @@ export function PlayerController({
resetPlayerCapsule(
capsule.current,
spawnPosition,
initialLookAt,
camera,
velocity.current,
);
@@ -241,7 +246,7 @@ export function PlayerController({
onFloor.current = false;
wantsJump.current = false;
initializedRef.current = true;
}, [camera, spawnPosition]);
}, [camera, initialLookAt, spawnPosition]);
useEffect(() => {
movementLockedRef.current = movementLocked;
@@ -339,6 +344,7 @@ export function PlayerController({
resetPlayerCapsule(
capsule.current,
spawnPosition,
initialLookAt,
camera,
velocity.current,
);
+14 -1
View File
@@ -16,6 +16,7 @@ import {
VEGETATION_TYPES,
type VegetationType,
} from "@/data/world/vegetationConfig";
import { isInsideLaFabrikFootprint } from "@/data/world/laFabrikConfig";
import { createWorldInstanceChunks } from "@/utils/world/chunkInstances";
interface VegetationSystemProps {
@@ -60,6 +61,15 @@ function createVegetationChunks(
});
}
function removeLaFabrikVegetation(
instances: VegetationInstance[],
): VegetationInstance[] {
return instances.filter((instance) => {
const [x, , z] = instance.position;
return !isInsideLaFabrikFootprint(x, z, 1.2);
});
}
export function VegetationSystem({
onlyMapName = null,
streaming = true,
@@ -90,7 +100,10 @@ export function VegetationSystem({
const entry = data.get(config.mapName);
if (!entry || entry.instances.length === 0) return [];
return createVegetationChunks(type, entry.instances);
const instances = removeLaFabrikVegetation(entry.instances);
if (instances.length === 0) return [];
return createVegetationChunks(type, instances);
});
}, [data, groups, models, onlyMapName]);