10 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
42 changed files with 806 additions and 279 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ Current behavior:
| -------- | ------------------: | --- | ------------------------------------- | | -------- | ------------------: | --- | ------------------------------------- |
| `low` | 10m | On | Always use `*-LOD` models | | `low` | 10m | On | Always use `*-LOD` models |
| `medium` | 20m | 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` | | `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. 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 ```txt
chunkSize: 35 chunkSize: 35
loadRadius: 45 low load/unload radius: 10m / 18m
unloadRadius: 45 medium load/unload radius: 20m / 30m
updateInterval: 350ms high load/unload radius: 35m / 45m
ultra load/unload radius: 50m / 65m
updateInterval: 250ms
fog near: 30 fog near: 30
fog far: 45 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. 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: The debug physics scene is ready when:
```ts ```ts
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 { RouterProvider } from "@tanstack/react-router";
import { SiteMobileBlocker } from "@/components/site/SiteMobileBlocker";
import { useIsMobile } from "@/hooks/ui/useIsMobile";
import { router } from "@/router"; import { router } from "@/router";
function App(): React.JSX.Element { function App(): React.JSX.Element {
const isMobile = useIsMobile();
if (isMobile) {
return <SiteMobileBlocker />;
}
return <RouterProvider router={router} />; return <RouterProvider router={router} />;
} }
+48 -15
View File
@@ -8,12 +8,16 @@ import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import { useClonedObject } from "@/hooks/three/useClonedObject"; import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useDebugFolder } from "@/hooks/debug/useDebugFolder"; import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
import { useEbikeSounds } from "@/hooks/ebike/useEbikeSounds"; import { useEbikeSounds } from "@/hooks/ebike/useEbikeSounds";
import {
getObjectBottomOffset,
useTerrainHeightSampler,
} from "@/hooks/three/useTerrainHeight";
import { animateCameraTransformTransition } from "@/world/GameCinematics"; import { animateCameraTransformTransition } from "@/world/GameCinematics";
import { useGameStore } from "@/managers/stores/useGameStore"; import { useGameStore } from "@/managers/stores/useGameStore";
import { PLAYER_EYE_HEIGHT } from "@/data/player/playerConfig";
import { import {
EBIKE_CAMERA_TRANSFORM, EBIKE_CAMERA_TRANSFORM,
EBIKE_DROP_PLAYER_TRANSFORM, EBIKE_DROP_PLAYER_TRANSFORM,
EBIKE_WORLD_SCALE,
EBIKE_WORLD_ROTATION_Y, EBIKE_WORLD_ROTATION_Y,
} from "@/data/ebike/ebikeConfig"; } from "@/data/ebike/ebikeConfig";
import type { Vector3Tuple } from "@/types/three/three"; import type { Vector3Tuple } from "@/types/three/three";
@@ -32,6 +36,18 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
position: position, position: position,
}); });
const model = useClonedObject(scene); 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 movementMode = useGameStore((state) => state.player.movementMode);
const mainState = useGameStore((state) => state.mainState); const mainState = useGameStore((state) => state.mainState);
const ebikeStep = useGameStore((state) => state.ebike.currentStep); const ebikeStep = useGameStore((state) => state.ebike.currentStep);
@@ -64,19 +80,19 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
y: number; y: number;
z: number; z: number;
}>({ }>({
x: position[0], x: parkedPosition[0],
y: position[1], y: parkedPosition[1],
z: position[2], z: parkedPosition[2],
}); });
const lastGpsUpdatePos = useRef<THREE.Vector3>( 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) // Use ref for internal state, and state for debug visualization (to avoid ref access during render)
const restingPositionRef = useRef<Vector3Tuple>([ const restingPositionRef = useRef<Vector3Tuple>([
position[0], parkedPosition[0],
position[1] - PLAYER_EYE_HEIGHT, parkedPosition[1],
position[2], parkedPosition[2],
]); ]);
const restingRotationRef = useRef<number>(EBIKE_WORLD_ROTATION_Y); const restingRotationRef = useRef<number>(EBIKE_WORLD_ROTATION_Y);
const forkRef = useRef<THREE.Object3D | null>(null); const forkRef = useRef<THREE.Object3D | null>(null);
@@ -85,11 +101,27 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
const [showCameraPoints, setShowCameraPoints] = useState(true); const [showCameraPoints, setShowCameraPoints] = useState(true);
const [debugRestingPosition, setDebugRestingPosition] = const [debugRestingPosition, setDebugRestingPosition] =
useState<Vector3Tuple>([ useState<Vector3Tuple>([
position[0], parkedPosition[0],
position[1] - PLAYER_EYE_HEIGHT, parkedPosition[1],
position[2], 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(() => { useEffect(() => {
if (model) { if (model) {
const fork = model.getObjectByName("fourche"); const fork = model.getObjectByName("fourche");
@@ -281,19 +313,20 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
{!repairGameOwnsEbikeModel ? ( {!repairGameOwnsEbikeModel ? (
<group <group
ref={groupRef} ref={groupRef}
position={position} position={parkedPosition}
rotation={[0, EBIKE_WORLD_ROTATION_Y, 0]} rotation={[0, EBIKE_WORLD_ROTATION_Y, 0]}
scale={EBIKE_WORLD_SCALE}
> >
<primitive object={model} /> <primitive object={model} />
<InteractableObject <InteractableObject
kind="trigger" kind="trigger"
label={interactionLabel} label={interactionLabel}
position={position} position={parkedPosition}
radius={15} radius={5}
onPress={handleInteract} onPress={handleInteract}
> >
<mesh> <mesh>
<boxGeometry args={[10, 13, 2]} /> <boxGeometry args={[8, 9, 2]} />
<meshBasicMaterial colorWrite={false} depthWrite={false} /> <meshBasicMaterial colorWrite={false} depthWrite={false} />
</mesh> </mesh>
</InteractableObject> </InteractableObject>
+58 -11
View File
@@ -1,11 +1,13 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import * as THREE from "three";
import { MissionNotification } from "@/components/ui/MissionNotification"; import { MissionNotification } from "@/components/ui/MissionNotification";
import { import {
EBIKE_BREAKDOWN_DIALOGUE_DELAY_MS, EBIKE_BREAKDOWN_DIALOGUE_DELAY_MS,
EBIKE_BREAKDOWN_DIALOGUE_ID, EBIKE_BREAKDOWN_DIALOGUE_ID,
EBIKE_INTRO_RIDE_DURATION_MS, EBIKE_INTRO_BREAKDOWN_DISTANCE,
EBIKE_SOUNDS, EBIKE_SOUNDS,
} from "@/data/ebike/ebikeConfig"; } from "@/data/ebike/ebikeConfig";
import { INTRO_MISSION_NOTIFICATION_IMAGE_PATH } from "@/data/gameplay/missionNotifications";
import { AudioManager } from "@/managers/AudioManager"; import { AudioManager } from "@/managers/AudioManager";
import { useGameStore } from "@/managers/stores/useGameStore"; import { useGameStore } from "@/managers/stores/useGameStore";
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest"; import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
@@ -18,6 +20,9 @@ export function EbikeIntroSequence(): React.JSX.Element | null {
const completeIntro = useGameStore((state) => state.completeIntro); const completeIntro = useGameStore((state) => state.completeIntro);
const [breakdownDialogueDone, setBreakdownDialogueDone] = useState(false); const [breakdownDialogueDone, setBreakdownDialogueDone] = useState(false);
const hasStartedBreakdown = useRef(false); const hasStartedBreakdown = useRef(false);
const rideDistance = useRef(0);
const lastRidePosition = useRef<THREE.Vector3 | null>(null);
const currentRidePosition = useRef(new THREE.Vector3());
useEffect(() => { useEffect(() => {
if (introStep !== "await-ebike-mount" || movementMode !== "ebike") return; if (introStep !== "await-ebike-mount" || movementMode !== "ebike") return;
@@ -26,16 +31,45 @@ export function EbikeIntroSequence(): React.JSX.Element | null {
}, [introStep, movementMode, setIntroStep]); }, [introStep, movementMode, setIntroStep]);
useEffect(() => { useEffect(() => {
if (introStep !== "ebike-intro-ride") return undefined; if (introStep !== "ebike-intro-ride") return;
const timeoutId = window.setTimeout(() => { rideDistance.current = 0;
setIntroStep("ebike-breakdown"); lastRidePosition.current = null;
}, EBIKE_INTRO_RIDE_DURATION_MS); }, [introStep]);
return () => { useEffect(() => {
window.clearTimeout(timeoutId); 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(() => { useEffect(() => {
if (introStep !== "ebike-breakdown" || hasStartedBreakdown.current) { if (introStep !== "ebike-breakdown" || hasStartedBreakdown.current) {
@@ -100,14 +134,27 @@ export function EbikeIntroSequence(): React.JSX.Element | null {
} }
}, [introStep]); }, [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; return null;
} }
if (introStep === "ebike-breakdown") {
return <MissionNotification mission="ebike" />;
}
return ( return (
<MissionNotification <MissionNotification
mission="ebike" imagePath={INTRO_MISSION_NOTIFICATION_IMAGE_PATH}
visible={introStep === "await-ebike-mount"} visible={
introStep === "reveal" ||
introStep === "await-ebike-mount" ||
introStep === "ebike-intro-ride"
}
/> />
); );
} }
+1 -3
View File
@@ -3,7 +3,6 @@ import * as THREE from "three";
import { useGLTF } from "@react-three/drei"; import { useGLTF } from "@react-three/drei";
import { useThree } from "@react-three/fiber"; import { useThree } from "@react-three/fiber";
import { TERRAIN_MODEL_PATH } from "@/data/world/terrainConfig"; import { TERRAIN_MODEL_PATH } from "@/data/world/terrainConfig";
import { flattenLaFabrikTerrainFootprint } from "@/data/world/laFabrikConfig";
import type { Vector3Tuple } from "@/types/three/three"; import type { Vector3Tuple } from "@/types/three/three";
import { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene"; import { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene";
@@ -66,10 +65,9 @@ export function TerrainModel({
const terrainModel = useMemo(() => { const terrainModel = useMemo(() => {
optimizeGLTFSceneTextures(scene, maxAnisotropy); optimizeGLTFSceneTextures(scene, maxAnisotropy);
const model = scene.clone(true); const model = scene.clone(true);
flattenLaFabrikTerrainFootprint(model, position, rotation, scale);
applyTerrainMaterialSettings(model, receiveShadow); applyTerrainMaterialSettings(model, receiveShadow);
return model; return model;
}, [maxAnisotropy, position, receiveShadow, rotation, scale, scene]); }, [maxAnisotropy, scene, receiveShadow]);
useEffect(() => { useEffect(() => {
onLoaded?.(); onLoaded?.();
+2
View File
@@ -5,6 +5,7 @@ import { HandTrackingVisualizer } from "@/components/ui/HandTrackingVisualizer";
import { InteractPrompt } from "@/components/ui/InteractPrompt"; import { InteractPrompt } from "@/components/ui/InteractPrompt";
import { RepairMovementLockIndicator } from "@/components/ui/RepairMovementLockIndicator"; import { RepairMovementLockIndicator } from "@/components/ui/RepairMovementLockIndicator";
import { Subtitles } from "@/components/ui/Subtitles"; import { Subtitles } from "@/components/ui/Subtitles";
import { TalkieDialogueOverlay } from "@/components/ui/TalkieDialogueOverlay";
export function GameUI(): React.JSX.Element { export function GameUI(): React.JSX.Element {
return ( return (
@@ -15,6 +16,7 @@ export function GameUI(): React.JSX.Element {
<InteractPrompt /> <InteractPrompt />
<HandTrackingVisualizer /> <HandTrackingVisualizer />
<Subtitles /> <Subtitles />
<TalkieDialogueOverlay />
<GameSettingsMenu /> <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"; import type { RepairMissionId } from "@/types/gameplay/repairMission";
interface MissionNotificationProps { interface MissionNotificationProps {
mission: RepairMissionId; mission?: RepairMissionId;
imagePath?: string;
visible?: boolean; visible?: boolean;
} }
export function MissionNotification({ export function MissionNotification({
mission, mission,
imagePath,
visible = true, visible = true,
}: MissionNotificationProps): React.JSX.Element { }: MissionNotificationProps): React.JSX.Element {
const src =
imagePath ?? (mission ? MISSION_NOTIFICATION_IMAGE_PATHS[mission] : "");
return ( return (
<div <div
className={`mission-notification${visible ? "" : " mission-notification--hidden"}`} className={`mission-notification${visible ? "" : " mission-notification--hidden"}`}
@@ -19,7 +24,7 @@ export function MissionNotification({
<span className="mission-notification__image-wrap"> <span className="mission-notification__image-wrap">
<img <img
className="mission-notification__image" className="mission-notification__image"
src={MISSION_NOTIFICATION_IMAGE_PATHS[mission]} src={src}
alt="Nouvel objectif de mission" alt="Nouvel objectif de mission"
/> />
</span> </span>
+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 = { export const EBIKE_CAMERA_TRANSFORM: CameraTransform = {
position: [-3.5, 6, 0], position: [-2.6, 4.5, 0],
rotation: [-10, -90, 0], rotation: [-10, -90, 0],
}; };
export const EBIKE_DROP_PLAYER_TRANSFORM: CameraTransform = { export const EBIKE_DROP_PLAYER_TRANSFORM: CameraTransform = {
position: [0, 1.5, -3], position: [0, 1.3, -2.25],
rotation: [0, 0, 0], rotation: [0, 0, 0],
}; };
export const EBIKE_WORLD_POSITION: Vector3Tuple = [61.5, 8.4, 62.4]; export const EBIKE_WORLD_POSITION: Vector3Tuple = [65, 0.8, 72];
export const EBIKE_WORLD_ROTATION_Y = 2.4107; 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_BREAKDOWN_DIALOGUE_DELAY_MS = 250;
export const EBIKE_MAX_SPEED = 3; export const EBIKE_MAX_SPEED = 3;
@@ -1,5 +1,8 @@
import type { RepairMissionId } from "@/types/gameplay/repairMission"; 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> = export const MISSION_NOTIFICATION_IMAGE_PATHS: Record<RepairMissionId, string> =
{ {
ebike: "/assets/world/UI/ebike-mission-notification.png", ebike: "/assets/world/UI/ebike-mission-notification.png",
+6 -2
View File
@@ -5,7 +5,7 @@ export const PLAYER_EYE_HEIGHT = 1.75;
export const PLAYER_CAPSULE_RADIUS = 0.35; export const PLAYER_CAPSULE_RADIUS = 0.35;
export const PLAYER_WALK_SPEED = 5; 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_AIR_CONTROL_FACTOR = 0.35;
export const PLAYER_JUMP_SPEED = 9; export const PLAYER_JUMP_SPEED = 9;
export const PLAYER_GRAVITY = 30; export const PLAYER_GRAVITY = 30;
@@ -15,5 +15,9 @@ export const PLAYER_XZ_DAMPING_FACTOR = 8;
export const PLAYER_FALL_RESPAWN_Y = -20; export const PLAYER_FALL_RESPAWN_Y = -20;
export const PLAYER_FALL_RESPAWN_DELAY = 3; export const PLAYER_FALL_RESPAWN_DELAY = 3;
export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = LA_FABRIK_PLAYER_SPAWN; 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]; export const PLAYER_SPAWN_POSITION_PHYSICS: Vector3Tuple = [0, 3, 0];
@@ -20,7 +20,6 @@ export interface CharacterConfig {
scale: Vector3Tuple; scale: Vector3Tuple;
animations: readonly string[]; animations: readonly string[];
defaultAnimation: string; defaultAnimation: string;
snapToTerrain?: boolean;
} }
export const CHARACTER_CONFIGS = { export const CHARACTER_CONFIGS = {
@@ -43,7 +42,6 @@ export const CHARACTER_CONFIGS = {
scale: [1.55, 1.55, 1.55], scale: [1.55, 1.55, 1.55],
animations: ["idle", "walk"], animations: ["idle", "walk"],
defaultAnimation: "idle", defaultAnimation: "idle",
snapToTerrain: false,
}, },
fermier: { fermier: {
id: "fermier", id: "fermier",
+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 const GRAPHICS_PRESET_KEYS = ["low", "medium", "high", "ultra"] as const;
export type GraphicsPreset = (typeof GRAPHICS_PRESET_KEYS)[number]; export type GraphicsPreset = (typeof GRAPHICS_PRESET_KEYS)[number];
@@ -32,8 +30,8 @@ export const GRAPHICS_PRESETS = {
}, },
high: { high: {
label: "High", label: "High",
chunkLoadRadius: CHUNK_CONFIG.loadRadius, chunkLoadRadius: 35,
chunkUnloadRadius: CHUNK_CONFIG.unloadRadius, chunkUnloadRadius: 45,
fogEnabled: false, fogEnabled: false,
forceLodModels: false, forceLodModels: false,
lodHighDetailDistance: 10, lodHighDetailDistance: 10,
+2 -56
View File
@@ -1,4 +1,3 @@
import * as THREE from "three";
import type { Vector3Tuple } from "@/types/three/three"; import type { Vector3Tuple } from "@/types/three/three";
export const LA_FABRIK_CENTER: Vector3Tuple = [59.4973, 6.2746, 64.6354]; export const LA_FABRIK_CENTER: Vector3Tuple = [59.4973, 6.2746, 64.6354];
@@ -7,15 +6,10 @@ export const LA_FABRIK_HALF_EXTENTS = {
x: 8.5, x: 8.5,
z: 7.5, z: 7.5,
} as const; } as const;
export const LA_FABRIK_FLOOR_Y = 6.3; export const LA_FABRIK_PLAYER_SPAWN: Vector3Tuple = [59.5, 6.3, 64.64];
export const LA_FABRIK_PLAYER_SPAWN: Vector3Tuple = [59.5, 8.05, 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 const LA_FABRIK_INTERIOR_LIGHT_POSITION: Vector3Tuple = [59.5, 9, 64.64];
const _terrainMatrix = new THREE.Matrix4();
const _meshWorldMatrix = new THREE.Matrix4();
const _inverseMeshWorldMatrix = new THREE.Matrix4();
const _worldPosition = new THREE.Vector3();
export function isInsideLaFabrikFootprint( export function isInsideLaFabrikFootprint(
x: number, x: number,
z: number, z: number,
@@ -33,51 +27,3 @@ export function isInsideLaFabrikFootprint(
Math.abs(localZ) <= LA_FABRIK_HALF_EXTENTS.z + padding Math.abs(localZ) <= LA_FABRIK_HALF_EXTENTS.z + padding
); );
} }
export function flattenLaFabrikTerrainFootprint(
object: THREE.Object3D,
position: Vector3Tuple,
rotation: Vector3Tuple,
scale: Vector3Tuple,
): void {
_terrainMatrix.compose(
new THREE.Vector3(...position),
new THREE.Quaternion().setFromEuler(new THREE.Euler(...rotation)),
new THREE.Vector3(...scale),
);
object.updateMatrixWorld(true);
object.traverse((child) => {
if (!(child instanceof THREE.Mesh)) return;
const geometry = child.geometry;
const positions = geometry.getAttribute("position");
if (!positions) return;
_meshWorldMatrix.multiplyMatrices(_terrainMatrix, child.matrixWorld);
_inverseMeshWorldMatrix.copy(_meshWorldMatrix).invert();
for (let index = 0; index < positions.count; index++) {
_worldPosition
.fromBufferAttribute(positions, index)
.applyMatrix4(_meshWorldMatrix);
if (!isInsideLaFabrikFootprint(_worldPosition.x, _worldPosition.z, 0.8)) {
continue;
}
_worldPosition.y = Math.min(_worldPosition.y, LA_FABRIK_FLOOR_Y - 0.35);
_worldPosition.applyMatrix4(_inverseMeshWorldMatrix);
positions.setXYZ(
index,
_worldPosition.x,
_worldPosition.y,
_worldPosition.z,
);
}
positions.needsUpdate = true;
geometry.computeVertexNormals();
geometry.computeBoundingBox();
geometry.computeBoundingSphere();
});
}
-8
View File
@@ -2,10 +2,6 @@ import { useMemo } from "react";
import { useGLTF } from "@react-three/drei"; import { useGLTF } from "@react-three/drei";
import * as THREE from "three"; import * as THREE from "three";
import { TERRAIN_MODEL_PATH } from "@/data/world/terrainConfig"; import { TERRAIN_MODEL_PATH } from "@/data/world/terrainConfig";
import {
isInsideLaFabrikFootprint,
LA_FABRIK_FLOOR_Y,
} from "@/data/world/laFabrikConfig";
import type { Vector3Tuple } from "@/types/three/three"; import type { Vector3Tuple } from "@/types/three/three";
import { getMapNodesByName } from "@/utils/map/loadMapSceneData"; import { getMapNodesByName } from "@/utils/map/loadMapSceneData";
@@ -70,10 +66,6 @@ function createTerrainHeightSampler(
return { return {
getHeight: (x, z) => { getHeight: (x, z) => {
if (isInsideLaFabrikFootprint(x, z, 0.6)) {
return LA_FABRIK_FLOOR_Y;
}
localOrigin.set(x, RAYCAST_Y, z).applyMatrix4(inverseTerrainMatrix); localOrigin.set(x, RAYCAST_Y, z).applyMatrix4(inverseTerrainMatrix);
raycaster.set(localOrigin, localDirection); raycaster.set(localOrigin, localDirection);
hits.length = 0; hits.length = 0;
+113
View File
@@ -1237,6 +1237,119 @@ canvas {
color: #f9a8d4; 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 */ /* In-game settings menu */
.game-settings-menu { .game-settings-menu {
position: fixed; position: fixed;
+44 -27
View File
@@ -1,4 +1,5 @@
import { create } from "zustand"; import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { AudioManager } from "@/managers/AudioManager"; import { AudioManager } from "@/managers/AudioManager";
import type { AudioCategory } from "@/managers/AudioManager"; import type { AudioCategory } from "@/managers/AudioManager";
import type { SubtitleLanguage } from "@/types/settings/settings"; import type { SubtitleLanguage } from "@/types/settings/settings";
@@ -33,6 +34,8 @@ const DEFAULT_SETTINGS: SettingsState = {
subtitleLanguage: "fr", subtitleLanguage: "fr",
}; };
const SETTINGS_STORAGE_KEY = "la-fabrik-settings";
function clampVolume(volume: number): number { function clampVolume(volume: number): number {
return Math.max(0, Math.min(1, volume)); return Math.max(0, Math.min(1, volume));
} }
@@ -46,36 +49,50 @@ function setAudioCategoryVolume(
return nextVolume; return nextVolume;
} }
function applyDefaultAudioSettings(): void { function applyAudioSettings(
AudioManager.getInstance().setCategoryVolume( settings: Pick<SettingsState, "musicVolume" | "sfxVolume" | "dialogueVolume">,
"music", ): void {
DEFAULT_SETTINGS.musicVolume, AudioManager.getInstance().setCategoryVolume("music", settings.musicVolume);
); AudioManager.getInstance().setCategoryVolume("sfx", settings.sfxVolume);
AudioManager.getInstance().setCategoryVolume(
"sfx",
DEFAULT_SETTINGS.sfxVolume,
);
AudioManager.getInstance().setCategoryVolume( AudioManager.getInstance().setCategoryVolume(
"dialogue", "dialogue",
DEFAULT_SETTINGS.dialogueVolume, settings.dialogueVolume,
); );
} }
applyDefaultAudioSettings(); applyAudioSettings(DEFAULT_SETTINGS);
export const useSettingsStore = create<SettingsStore>()((set) => ({ export const useSettingsStore = create<SettingsStore>()(
...DEFAULT_SETTINGS, persist(
setSettingsMenuOpen: (isSettingsMenuOpen) => set({ isSettingsMenuOpen }), (set) => ({
setMusicVolume: (volume) => ...DEFAULT_SETTINGS,
set({ musicVolume: setAudioCategoryVolume("music", volume) }), setSettingsMenuOpen: (isSettingsMenuOpen) => set({ isSettingsMenuOpen }),
setSfxVolume: (volume) => setMusicVolume: (volume) =>
set({ sfxVolume: setAudioCategoryVolume("sfx", volume) }), set({ musicVolume: setAudioCategoryVolume("music", volume) }),
setDialogueVolume: (volume) => setSfxVolume: (volume) =>
set({ dialogueVolume: setAudioCategoryVolume("dialogue", volume) }), set({ sfxVolume: setAudioCategoryVolume("sfx", volume) }),
setSubtitlesEnabled: (subtitlesEnabled) => set({ subtitlesEnabled }), setDialogueVolume: (volume) =>
setSubtitleLanguage: (subtitleLanguage) => set({ subtitleLanguage }), set({ dialogueVolume: setAudioCategoryVolume("dialogue", volume) }),
resetSettings: () => { setSubtitlesEnabled: (subtitlesEnabled) => set({ subtitlesEnabled }),
applyDefaultAudioSettings(); setSubtitleLanguage: (subtitleLanguage) => set({ subtitleLanguage }),
set(DEFAULT_SETTINGS); 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 { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { CLOUD_DEFAULTS, type CloudState } from "@/data/world/cloudConfig"; import { CLOUD_DEFAULTS, type CloudState } from "@/data/world/cloudConfig";
import { FOG_CONFIG, type FogState } from "@/data/world/fogConfig"; import { FOG_CONFIG, type FogState } from "@/data/world/fogConfig";
import { WIND_DEFAULTS, type WindState } from "@/data/world/windConfig"; import { WIND_DEFAULTS, type WindState } from "@/data/world/windConfig";
@@ -46,73 +47,89 @@ const DEFAULT_STATE: WorldSettingsState = {
graphics: { ...GRAPHICS_DEFAULTS }, graphics: { ...GRAPHICS_DEFAULTS },
}; };
export const useWorldSettingsStore = create<WorldSettingsStore>()((set) => ({ const WORLD_SETTINGS_STORAGE_KEY = "la-fabrik-world-settings";
...DEFAULT_STATE,
setClouds: (cloudsUpdate) => export const useWorldSettingsStore = create<WorldSettingsStore>()(
set((state) => ({ persist(
clouds: { ...state.clouds, ...cloudsUpdate }, (set) => ({
})), ...DEFAULT_STATE,
setFog: (fogUpdate) => setClouds: (cloudsUpdate) =>
set((state) => ({ set((state) => ({
fog: { ...state.fog, ...fogUpdate }, clouds: { ...state.clouds, ...cloudsUpdate },
})), })),
setWind: (windUpdate) => setFog: (fogUpdate) =>
set((state) => ({ set((state) => ({
wind: { ...state.wind, ...windUpdate }, fog: { ...state.fog, ...fogUpdate },
})), })),
setWindSpeed: (speed) => setWind: (windUpdate) =>
set((state) => ({ set((state) => ({
wind: { ...state.wind, speed }, wind: { ...state.wind, ...windUpdate },
})), })),
setWindDirection: (direction) => setWindSpeed: (speed) =>
set((state) => ({ set((state) => ({
wind: { ...state.wind, direction }, wind: { ...state.wind, speed },
})), })),
setWindStrength: (strength) => setWindDirection: (direction) =>
set((state) => ({ set((state) => ({
wind: { ...state.wind, strength }, wind: { ...state.wind, direction },
})), })),
setGraphics: (graphicsUpdate) => setWindStrength: (strength) =>
set((state) => ({ set((state) => ({
graphics: { ...state.graphics, ...graphicsUpdate }, wind: { ...state.wind, strength },
})), })),
setGraphicsPreset: (preset) => setGraphics: (graphicsUpdate) =>
set((state) => ({ set((state) => ({
graphics: { ...state.graphics, preset }, graphics: { ...state.graphics, ...graphicsUpdate },
})), })),
setDynamicGrass: (dynamicGrass) => setGraphicsPreset: (preset) =>
set((state) => ({ set((state) => ({
graphics: { ...state.graphics, dynamicGrass }, graphics: { ...state.graphics, preset },
})), })),
setDynamicTrees: (dynamicTrees) => setDynamicGrass: (dynamicGrass) =>
set((state) => ({ set((state) => ({
graphics: { ...state.graphics, dynamicTrees }, graphics: { ...state.graphics, dynamicGrass },
})), })),
setDynamicClouds: (dynamicClouds) => setDynamicTrees: (dynamicTrees) =>
set((state) => ({ set((state) => ({
graphics: { ...state.graphics, dynamicClouds }, graphics: { ...state.graphics, dynamicTrees },
})), })),
setShadowsEnabled: (shadowsEnabled) => setDynamicClouds: (dynamicClouds) =>
set((state) => ({ set((state) => ({
graphics: { ...state.graphics, shadowsEnabled }, graphics: { ...state.graphics, dynamicClouds },
})), })),
setGrassDensity: (grassDensity) => setShadowsEnabled: (shadowsEnabled) =>
set((state) => ({ set((state) => ({
graphics: { ...state.graphics, grassDensity }, 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.enabled = true;
gl.shadowMap.type = THREE.PCFShadowMap; 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 // 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 // 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 = () => { const handleContextRestored = () => {
gl.shadowMap.enabled = true; gl.shadowMap.enabled = true;
gl.shadowMap.type = THREE.PCFShadowMap; gl.shadowMap.type = THREE.PCFShadowMap;
gl.shadowMap.autoUpdate = true; gl.shadowMap.autoUpdate = false;
gl.shadowMap.needsUpdate = true;
logger.info("WebGL", "Context restored"); 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 { SiteSituationScreen } from "@/components/site/SiteSituationScreen";
import { SiteNamingScreen } from "@/components/site/SiteNamingScreen"; import { SiteNamingScreen } from "@/components/site/SiteNamingScreen";
import { SiteTransitionOverlay } from "@/components/site/SiteTransitionOverlay"; import { SiteTransitionOverlay } from "@/components/site/SiteTransitionOverlay";
import { SiteMobileBlocker } from "@/components/site/SiteMobileBlocker";
import { SiteLayout } from "@/components/site/SiteLayout"; import { SiteLayout } from "@/components/site/SiteLayout";
import { useIsMobile } from "@/hooks/ui/useIsMobile";
export function SitePage(): React.JSX.Element { export function SitePage(): React.JSX.Element {
const currentStep = useSiteStore((state) => state.currentStep); const currentStep = useSiteStore((state) => state.currentStep);
const isMobile = useIsMobile();
if (isMobile) {
return <SiteMobileBlocker />;
}
if (currentStep === "disclaimer") { if (currentStep === "disclaimer") {
return <SiteDisclaimerScreen />; return <SiteDisclaimerScreen />;
+12 -3
View File
@@ -9,6 +9,7 @@ const DEBUG_CONTROLS_STORAGE_KEY = "la-fabrik-debug-controls";
interface StoredDebugControls { interface StoredDebugControls {
cameraMode: CameraMode; cameraMode: CameraMode;
handTrackingSource: HandTrackingSource;
sceneMode: SceneMode; sceneMode: SceneMode;
} }
@@ -39,6 +40,10 @@ function isSceneMode(value: unknown): value is SceneMode {
return value === "game" || value === "physics"; return value === "game" || value === "physics";
} }
function isHandTrackingSource(value: unknown): value is HandTrackingSource {
return value === "browser" || value === "backend";
}
function getStoredDebugControls(): Partial<StoredDebugControls> { function getStoredDebugControls(): Partial<StoredDebugControls> {
try { try {
const rawValue = window.localStorage.getItem(DEBUG_CONTROLS_STORAGE_KEY); const rawValue = window.localStorage.getItem(DEBUG_CONTROLS_STORAGE_KEY);
@@ -51,6 +56,9 @@ function getStoredDebugControls(): Partial<StoredDebugControls> {
...(isCameraMode(parsedValue.cameraMode) ...(isCameraMode(parsedValue.cameraMode)
? { cameraMode: parsedValue.cameraMode } ? { cameraMode: parsedValue.cameraMode }
: {}), : {}),
...(isHandTrackingSource(parsedValue.handTrackingSource)
? { handTrackingSource: parsedValue.handTrackingSource }
: {}),
...(isSceneMode(parsedValue.sceneMode) ...(isSceneMode(parsedValue.sceneMode)
? { sceneMode: parsedValue.sceneMode } ? { sceneMode: parsedValue.sceneMode }
: {}), : {}),
@@ -94,7 +102,7 @@ export class Debug {
this.controls = { this.controls = {
cameraMode: storedControls.cameraMode ?? "player", cameraMode: storedControls.cameraMode ?? "player",
fogEnabled: FOG_CONFIG.enabled, fogEnabled: FOG_CONFIG.enabled,
handTrackingSource: "browser", handTrackingSource: storedControls.handTrackingSource ?? "browser",
showDebugOverlay: true, showDebugOverlay: true,
showHandTrackingSvg: false, showHandTrackingSvg: false,
showInteractionSpheres: false, showInteractionSpheres: false,
@@ -159,7 +167,7 @@ export class Debug {
.name("Source") .name("Source")
.onChange((value: HandTrackingSource) => { .onChange((value: HandTrackingSource) => {
this.controls.handTrackingSource = value; this.controls.handTrackingSource = value;
this.emit(); this.saveAndEmit();
}); });
} }
} }
@@ -246,7 +254,7 @@ export class Debug {
setHandTrackingSource(value: HandTrackingSource): void { setHandTrackingSource(value: HandTrackingSource): void {
this.controls.handTrackingSource = value; this.controls.handTrackingSource = value;
this.emit(); this.saveAndEmit();
} }
getFogEnabled(): boolean { getFogEnabled(): boolean {
@@ -285,6 +293,7 @@ export class Debug {
DEBUG_CONTROLS_STORAGE_KEY, DEBUG_CONTROLS_STORAGE_KEY,
JSON.stringify({ JSON.stringify({
cameraMode: this.controls.cameraMode, cameraMode: this.controls.cameraMode,
handTrackingSource: this.controls.handTrackingSource,
sceneMode: this.controls.sceneMode, sceneMode: this.controls.sceneMode,
}), }),
); );
+4 -29
View File
@@ -18,7 +18,6 @@ import {
useTerrainHeightSampler, useTerrainHeightSampler,
} from "@/hooks/three/useTerrainHeight"; } from "@/hooks/three/useTerrainHeight";
import { WorldBoundsCollision } from "@/world/collision/WorldBoundsCollision"; import { WorldBoundsCollision } from "@/world/collision/WorldBoundsCollision";
import { flattenLaFabrikTerrainFootprint } from "@/data/world/laFabrikConfig";
import type { MapNode } from "@/types/map/mapScene"; import type { MapNode } from "@/types/map/mapScene";
import type { OctreeReadyHandler } from "@/types/three/three"; import type { OctreeReadyHandler } from "@/types/three/three";
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading"; import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
@@ -214,7 +213,7 @@ function CollisionModelInstance({
modelUrl: string; modelUrl: string;
onLoaded: () => void; onLoaded: () => void;
terrainHeight: TerrainHeightSampler; terrainHeight: TerrainHeightSampler;
}): React.JSX.Element | null { }): React.JSX.Element {
const { position, rotation, scale } = node; const { position, rotation, scale } = node;
const normalizedScale = normalizeMapScale(scale); const normalizedScale = normalizeMapScale(scale);
const { scene } = useLoggedGLTF(modelUrl, { const { scene } = useLoggedGLTF(modelUrl, {
@@ -224,46 +223,22 @@ function CollisionModelInstance({
scale: normalizedScale, scale: normalizedScale,
}); });
const sceneInstance = useClonedObject(scene); const sceneInstance = useClonedObject(scene);
const collisionSceneInstance = useMemo(() => {
if (node.name === "terrain") {
flattenLaFabrikTerrainFootprint(
sceneInstance,
position,
rotation,
normalizedScale,
);
}
return sceneInstance;
}, [node.name, normalizedScale, position, rotation, sceneInstance]);
const collisionPosition = useMemo(() => { const collisionPosition = useMemo(() => {
if (node.name === "terrain") return position; if (node.name === "terrain") return position;
const [x, y, z] = position; const [x, y, z] = position;
const height = terrainHeight.getHeight(x, z); const height = terrainHeight.getHeight(x, z);
const bottomOffset = getObjectBottomOffset( const bottomOffset = getObjectBottomOffset(sceneInstance, normalizedScale);
collisionSceneInstance,
normalizedScale,
);
return [x, height !== null ? height + bottomOffset : y, z] as const; return [x, height !== null ? height + bottomOffset : y, z] as const;
}, [ }, [node.name, normalizedScale, position, sceneInstance, terrainHeight]);
node.name,
normalizedScale,
position,
collisionSceneInstance,
terrainHeight,
]);
useEffect(() => { useEffect(() => {
onLoaded(); onLoaded();
}, [onLoaded]); }, [onLoaded]);
if (node.name === "lafabrik") {
return null;
}
return ( return (
<primitive <primitive
object={collisionSceneInstance} object={sceneInstance}
position={collisionPosition} position={collisionPosition}
rotation={rotation} rotation={rotation}
scale={normalizedScale} scale={normalizedScale}
+8 -2
View File
@@ -14,7 +14,13 @@ import { useRepairMissionAnchorStore } from "@/managers/stores/useRepairMissionA
import type { RepairMissionTriggerConfig } from "@/types/gameplay/repairMission"; import type { RepairMissionTriggerConfig } from "@/types/gameplay/repairMission";
import type { Vector3Tuple } from "@/types/three/three"; import type { Vector3Tuple } from "@/types/three/three";
import { getRepairMissionPosition } from "@/utils/gameplay/repairMissionPosition"; 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 { interface StageAnchorProps {
color: string; color: string;
@@ -82,7 +88,7 @@ export function GameStageContent(): React.JSX.Element {
return ( return (
<> <>
{mainState === "intro" ? <StageAnchor {...INTRO_STAGE_ANCHOR} /> : null} {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 }) => { {REPAIR_MISSION_POSITION_ENTRIES.map(({ mission }) => {
const position = getRepairMissionPosition(mission, anchors); const position = getRepairMissionPosition(mission, anchors);
if (!position) return null; if (!position) return null;
+88 -16
View File
@@ -1,6 +1,15 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import type { MutableRefObject } from "react";
import { useFrame, useThree } from "@react-three/fiber"; 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 { import {
AMBIENT_INTENSITY_MAX, AMBIENT_INTENSITY_MAX,
AMBIENT_INTENSITY_MIN, AMBIENT_INTENSITY_MIN,
@@ -26,29 +35,84 @@ const SHADOW_MAP_SIZE = 2048;
const SHADOW_CAMERA_SIZE = 95; const SHADOW_CAMERA_SIZE = 95;
const SHADOW_CAMERA_NEAR = 0.5; const SHADOW_CAMERA_NEAR = 0.5;
const SHADOW_CAMERA_FAR = 300; 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 { export function Lighting(): React.JSX.Element {
const camera = useThree((state) => state.camera); const camera = useThree((state) => state.camera);
const gl = useThree((state) => state.gl);
const ambient = useRef<AmbientLight>(null); const ambient = useRef<AmbientLight>(null);
const sun = useRef<DirectionalLight>(null); const sun = useRef<DirectionalLight>(null);
const sunTarget = useRef<Object3D>(null); const sunTarget = useRef<Object3D>(null);
const lastShadowRefreshMs = useRef(-SHADOW_REFRESH_INTERVAL_MS);
const lastShadowCameraPosition = useRef(new Vector3());
const shadowHasInitialPosition = useRef(false);
useEffect(() => { useEffect(() => {
if (!sun.current || !sunTarget.current) return; if (!sun.current || !sunTarget.current) return;
sun.current.target = sunTarget.current; configureSunShadow(sun.current, sunTarget.current);
sun.current.shadow.autoUpdate = true; configureManualRendererShadows(gl);
sun.current.shadow.needsUpdate = true; }, [gl]);
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();
}, []);
useDebugFolder("Lighting", (folder) => { useDebugFolder("Lighting", (folder) => {
folder.addColor(LIGHTING_STATE, "ambientColor").name("Ambient Color"); folder.addColor(LIGHTING_STATE, "ambientColor").name("Ambient Color");
@@ -82,7 +146,7 @@ export function Lighting(): React.JSX.Element {
.name("Sun Z"); .name("Sun Z");
}); });
useFrame(() => { useFrame(({ clock }) => {
if (ambient.current) { if (ambient.current) {
ambient.current.color.set(LIGHTING_STATE.ambientColor); ambient.current.color.set(LIGHTING_STATE.ambientColor);
ambient.current.intensity = LIGHTING_STATE.ambientIntensity; ambient.current.intensity = LIGHTING_STATE.ambientIntensity;
@@ -99,7 +163,15 @@ export function Lighting(): React.JSX.Element {
sun.current.color.set(LIGHTING_STATE.sunColor); sun.current.color.set(LIGHTING_STATE.sunColor);
sun.current.intensity = LIGHTING_STATE.sunIntensity; sun.current.intensity = LIGHTING_STATE.sunIntensity;
sun.current.updateMatrixWorld(); sun.current.updateMatrixWorld();
sun.current.shadow.needsUpdate = true; requestSunShadowRefresh({
camera,
elapsedMs: clock.elapsedTime * 1000,
gl,
lastCameraPosition: lastShadowCameraPosition.current,
lastRefreshMs: lastShadowRefreshMs,
shadowHasInitialPosition,
sun: sun.current,
});
} }
}); });
+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({ export function SceneShadowWarmup({
active, active,
onReady, onReady,
@@ -77,6 +82,7 @@ export function SceneShadowWarmup({
secondFrame = window.requestAnimationFrame(() => { secondFrame = window.requestAnimationFrame(() => {
forceSceneShadowPass(gl, scene); forceSceneShadowPass(gl, scene);
restoreManualShadowUpdates(gl);
invalidate(); invalidate();
onReady(); onReady();
}); });
+6 -1
View File
@@ -4,6 +4,7 @@ import {
PLAYER_SPAWN_POSITION_GAME, PLAYER_SPAWN_POSITION_GAME,
PLAYER_SPAWN_POSITION_PHYSICS, PLAYER_SPAWN_POSITION_PHYSICS,
} from "@/data/player/playerConfig"; } from "@/data/player/playerConfig";
import { LA_FABRIK_INITIAL_LOOK_AT } from "@/data/world/laFabrikConfig";
import { useCameraMode } from "@/hooks/debug/useCameraMode"; import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useEnvironmentDebug } from "@/hooks/debug/useEnvironmentDebug"; import { useEnvironmentDebug } from "@/hooks/debug/useEnvironmentDebug";
import { useMapPerformanceDebug } from "@/hooks/debug/useMapPerformanceDebug"; import { useMapPerformanceDebug } from "@/hooks/debug/useMapPerformanceDebug";
@@ -99,7 +100,11 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
<GameMusic /> <GameMusic />
{mainState === "outro" ? <GameCinematics /> : null} {mainState === "outro" ? <GameCinematics /> : null}
{mainState !== "intro" ? <GameDialogues /> : null} {mainState !== "intro" ? <GameDialogues /> : null}
<Player octree={octree} spawnPosition={playerSpawnPosition} /> <Player
initialLookAt={LA_FABRIK_INITIAL_LOOK_AT}
octree={octree}
spawnPosition={playerSpawnPosition}
/>
</> </>
) : null} ) : null}
</> </>
+2 -5
View File
@@ -3,18 +3,15 @@ import { AnimatedModel } from "@/components/three/models/AnimatedModel";
import { import {
CHARACTER_CONFIGS, CHARACTER_CONFIGS,
CHARACTER_IDS, CHARACTER_IDS,
type CharacterConfig,
type CharacterId, type CharacterId,
} from "@/data/world/characters/characterConfig"; } from "@/data/world/characters/characterConfig";
import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight"; import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight";
import { useCharacterDebugStore } from "@/managers/stores/useCharacterDebugStore"; import { useCharacterDebugStore } from "@/managers/stores/useCharacterDebugStore";
function CharacterModel({ id }: { id: CharacterId }): React.JSX.Element { function CharacterModel({ id }: { id: CharacterId }): React.JSX.Element {
const config: CharacterConfig = CHARACTER_CONFIGS[id]; const config = CHARACTER_CONFIGS[id];
const state = useCharacterDebugStore((store) => store.characters[id]); const state = useCharacterDebugStore((store) => store.characters[id]);
const snappedPosition = useTerrainSnappedPosition(state.position); const position = useTerrainSnappedPosition(state.position);
const position =
config.snapToTerrain === false ? state.position : snappedPosition;
return ( return (
<AnimatedModel <AnimatedModel
@@ -17,8 +17,7 @@ export function GeneratedMapNodeInstance({
node, node,
onLoaded, onLoaded,
}: GeneratedMapNodeInstanceProps): React.JSX.Element | null { }: GeneratedMapNodeInstanceProps): React.JSX.Element | null {
const snappedPosition = useTerrainSnappedPosition(node.position); const position = useTerrainSnappedPosition(node.position);
const position = node.name === "lafabrik" ? node.position : snappedPosition;
const scale = normalizeMapScale(node.scale); const scale = normalizeMapScale(node.scale);
if (node.name === "ecole") { if (node.name === "ecole") {
+9 -2
View File
@@ -7,10 +7,12 @@ import { PlayerController } from "@/world/player/PlayerController";
interface PlayerProps { interface PlayerProps {
octree: Octree | null; octree: Octree | null;
initialLookAt?: Vector3Tuple | undefined;
spawnPosition: Vector3Tuple; spawnPosition: Vector3Tuple;
} }
export function Player({ export function Player({
initialLookAt,
spawnPosition, spawnPosition,
octree, octree,
}: PlayerProps): React.JSX.Element { }: PlayerProps): React.JSX.Element {
@@ -18,12 +20,17 @@ export function Player({
useLayoutEffect(() => { useLayoutEffect(() => {
camera.position.set(...spawnPosition); camera.position.set(...spawnPosition);
}, [camera, spawnPosition]); if (initialLookAt) camera.lookAt(...initialLookAt);
}, [camera, initialLookAt, spawnPosition]);
return ( return (
<> <>
<PlayerCamera /> <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; const PLAYER_GROUND_SNAP_DISTANCE = 0.22;
interface PlayerControllerProps { interface PlayerControllerProps {
initialLookAt?: Vector3Tuple | undefined;
octree: Octree | null; octree: Octree | null;
spawnPosition: Vector3Tuple; spawnPosition: Vector3Tuple;
} }
@@ -89,6 +90,7 @@ const _collisionCorrection = new THREE.Vector3();
function resetPlayerCapsule( function resetPlayerCapsule(
capsule: Capsule, capsule: Capsule,
spawnPosition: Vector3Tuple, spawnPosition: Vector3Tuple,
initialLookAt: Vector3Tuple | undefined,
camera: THREE.Camera, camera: THREE.Camera,
velocity: THREE.Vector3, velocity: THREE.Vector3,
): void { ): void {
@@ -100,6 +102,7 @@ function resetPlayerCapsule(
capsule.end.set(...spawnPosition); capsule.end.set(...spawnPosition);
velocity.set(0, 0, 0); velocity.set(0, 0, 0);
camera.position.copy(capsule.end); camera.position.copy(capsule.end);
if (initialLookAt) camera.lookAt(...initialLookAt);
} }
function createSpawnCapsule(spawnPosition: Vector3Tuple): Capsule { function createSpawnCapsule(spawnPosition: Vector3Tuple): Capsule {
@@ -145,6 +148,7 @@ function getCapsuleFootY(capsule: Capsule): number {
} }
export function PlayerController({ export function PlayerController({
initialLookAt,
octree, octree,
spawnPosition, spawnPosition,
}: PlayerControllerProps): null { }: PlayerControllerProps): null {
@@ -234,6 +238,7 @@ export function PlayerController({
resetPlayerCapsule( resetPlayerCapsule(
capsule.current, capsule.current,
spawnPosition, spawnPosition,
initialLookAt,
camera, camera,
velocity.current, velocity.current,
); );
@@ -241,7 +246,7 @@ export function PlayerController({
onFloor.current = false; onFloor.current = false;
wantsJump.current = false; wantsJump.current = false;
initializedRef.current = true; initializedRef.current = true;
}, [camera, spawnPosition]); }, [camera, initialLookAt, spawnPosition]);
useEffect(() => { useEffect(() => {
movementLockedRef.current = movementLocked; movementLockedRef.current = movementLocked;
@@ -339,6 +344,7 @@ export function PlayerController({
resetPlayerCapsule( resetPlayerCapsule(
capsule.current, capsule.current,
spawnPosition, spawnPosition,
initialLookAt,
camera, camera,
velocity.current, velocity.current,
); );
+14 -1
View File
@@ -16,6 +16,7 @@ import {
VEGETATION_TYPES, VEGETATION_TYPES,
type VegetationType, type VegetationType,
} from "@/data/world/vegetationConfig"; } from "@/data/world/vegetationConfig";
import { isInsideLaFabrikFootprint } from "@/data/world/laFabrikConfig";
import { createWorldInstanceChunks } from "@/utils/world/chunkInstances"; import { createWorldInstanceChunks } from "@/utils/world/chunkInstances";
interface VegetationSystemProps { 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({ export function VegetationSystem({
onlyMapName = null, onlyMapName = null,
streaming = true, streaming = true,
@@ -90,7 +100,10 @@ export function VegetationSystem({
const entry = data.get(config.mapName); const entry = data.get(config.mapName);
if (!entry || entry.instances.length === 0) return []; 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]); }, [data, groups, models, onlyMapName]);