Merge branch 'develop' into feat/polish-mission-2

This commit is contained in:
math-pixel
2026-06-02 20:27:48 +02:00
309 changed files with 2650 additions and 1127 deletions
+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} />;
}
@@ -0,0 +1,173 @@
import { useMemo } from "react";
import { Box3, BufferAttribute, BufferGeometry } from "three";
import type { Octree } from "three-stdlib";
import {
LA_FABRIK_CENTER,
isInsideLaFabrikFootprint,
} from "@/data/world/laFabrikConfig";
import { useDebugVisualsStore } from "@/managers/stores/useDebugVisualsStore";
interface DebugOctreeVisualizationProps {
octree: Octree | null;
}
interface OctreeNodeBox {
box: Box3;
depth: number;
triangleCount: number;
isLeaf: boolean;
}
interface CollectOptions {
minDepth: number;
maxDepth: number;
leavesOnly: boolean;
fabrikOnly: boolean;
}
const FABRIK_FILTER_PADDING = 1.5;
const FABRIK_FILTER_VERTICAL = 8;
const BOX_VERTEX_INDEX_PAIRS: ReadonlyArray<readonly [number, number]> = [
[0, 1],
[1, 3],
[3, 2],
[2, 0],
[4, 5],
[5, 7],
[7, 6],
[6, 4],
[0, 4],
[1, 5],
[2, 6],
[3, 7],
];
function boxIntersectsFabrik(box: Box3): boolean {
if (box.max.y < LA_FABRIK_CENTER[1] - FABRIK_FILTER_VERTICAL) return false;
if (box.min.y > LA_FABRIK_CENTER[1] + FABRIK_FILTER_VERTICAL) return false;
// Sample box corners + center on XZ plane against the rotated fabrik footprint.
const samples: ReadonlyArray<readonly [number, number]> = [
[box.min.x, box.min.z],
[box.min.x, box.max.z],
[box.max.x, box.min.z],
[box.max.x, box.max.z],
[(box.min.x + box.max.x) * 0.5, (box.min.z + box.max.z) * 0.5],
];
for (const [x, z] of samples) {
if (isInsideLaFabrikFootprint(x, z, FABRIK_FILTER_PADDING)) return true;
}
return false;
}
function collectOctreeBoxes(
node: Octree,
options: CollectOptions,
depth = 0,
acc: OctreeNodeBox[] = [],
): OctreeNodeBox[] {
if (depth > options.maxDepth) return acc;
const isLeaf = node.subTrees.length === 0;
const passesDepth = depth >= options.minDepth;
const passesLeafFilter = !options.leavesOnly || isLeaf;
const hasTriangles = node.triangles.length > 0;
const passesFabrikFilter =
!options.fabrikOnly || boxIntersectsFabrik(node.box);
if (passesDepth && passesLeafFilter && hasTriangles && passesFabrikFilter) {
acc.push({
box: node.box,
depth,
triangleCount: node.triangles.length,
isLeaf,
});
}
for (const sub of node.subTrees) {
collectOctreeBoxes(sub, options, depth + 1, acc);
}
return acc;
}
function buildOctreeLineGeometry(
nodes: readonly OctreeNodeBox[],
): BufferGeometry {
const positionsBuffer = new Float32Array(
nodes.length * BOX_VERTEX_INDEX_PAIRS.length * 2 * 3,
);
const corners: [number, number, number][] = Array.from({ length: 8 }, () => [
0, 0, 0,
]);
let positionsOffset = 0;
for (const node of nodes) {
const { min, max } = node.box;
corners[0] = [min.x, min.y, min.z];
corners[1] = [max.x, min.y, min.z];
corners[2] = [min.x, max.y, min.z];
corners[3] = [max.x, max.y, min.z];
corners[4] = [min.x, min.y, max.z];
corners[5] = [max.x, min.y, max.z];
corners[6] = [min.x, max.y, max.z];
corners[7] = [max.x, max.y, max.z];
for (const [a, b] of BOX_VERTEX_INDEX_PAIRS) {
const ca = corners[a]!;
const cb = corners[b]!;
positionsBuffer[positionsOffset++] = ca[0];
positionsBuffer[positionsOffset++] = ca[1];
positionsBuffer[positionsOffset++] = ca[2];
positionsBuffer[positionsOffset++] = cb[0];
positionsBuffer[positionsOffset++] = cb[1];
positionsBuffer[positionsOffset++] = cb[2];
}
}
const geometry = new BufferGeometry();
geometry.setAttribute("position", new BufferAttribute(positionsBuffer, 3));
return geometry;
}
export function DebugOctreeVisualization({
octree,
}: DebugOctreeVisualizationProps): React.JSX.Element | null {
const showOctree = useDebugVisualsStore((state) => state.showOctree);
const minDepth = useDebugVisualsStore((state) => state.octreeMinDepth);
const maxDepth = useDebugVisualsStore((state) => state.octreeMaxDepth);
const leavesOnly = useDebugVisualsStore((state) => state.octreeLeavesOnly);
const opacity = useDebugVisualsStore((state) => state.octreeOpacity);
const fabrikOnly = useDebugVisualsStore((state) => state.octreeFabrikOnly);
const geometry = useMemo(() => {
if (!octree || !showOctree) return null;
const boxes = collectOctreeBoxes(octree, {
minDepth,
maxDepth,
leavesOnly,
fabrikOnly,
});
if (boxes.length === 0) return null;
return buildOctreeLineGeometry(boxes);
}, [fabrikOnly, leavesOnly, maxDepth, minDepth, octree, showOctree]);
if (!geometry) return null;
return (
<lineSegments frustumCulled={false} renderOrder={999}>
<primitive object={geometry} attach="geometry" />
<lineBasicMaterial
color="#22d3ee"
depthTest={false}
depthWrite={false}
transparent
opacity={opacity}
/>
</lineSegments>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { useEffect, useMemo, useRef } from "react";
import * as THREE from "three";
import { useFrame } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
const MODEL_PATH = "/models/persoprincipal/model.gltf";
// Offset expressed in the camera's local space:
// - x: horizontal (0 = centered)
// - y: vertical relative to camera eye (negative = below)
// - z: forward (negative = in front of the camera)
const LOCAL_OFFSET = new THREE.Vector3(0, -1, -2.5);
const eulerHelper = new THREE.Euler();
export function DebugPlayerModel(): React.JSX.Element {
const groupRef = useRef<THREE.Group>(null);
const { scene } = useGLTF(MODEL_PATH);
const model = useMemo(() => {
const cloned = scene.clone(true);
cloned.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.castShadow = true;
child.receiveShadow = true;
child.frustumCulled = false;
}
});
return cloned;
}, [scene]);
useEffect(
() => () => {
model.clear();
},
[model],
);
useFrame(({ camera }) => {
const group = groupRef.current;
if (!group) return;
// Place the model in front of the camera using its local space so it stays
// visible regardless of the camera pitch (top-down ebike view, etc.).
group.position.copy(LOCAL_OFFSET).applyMatrix4(camera.matrixWorld);
// Keep the model upright and aligned with the camera yaw only.
eulerHelper.setFromQuaternion(camera.quaternion, "YXZ");
group.rotation.set(0, eulerHelper.y, 0);
});
return (
<group ref={groupRef} frustumCulled={false}>
<primitive object={model} />
</group>
);
}
useGLTF.preload(MODEL_PATH);
+116 -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");
@@ -93,6 +131,17 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
}
}, [model]);
useEffect(() => {
if (!model) return;
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.castShadow = true;
child.receiveShadow = true;
}
});
}, [model]);
useEffect(() => {
window.ebikeVisualGroup = groupRef;
window.ebikeParkedPosition = restingPositionRef.current;
@@ -169,16 +218,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 +321,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>
);
}
+68 -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";
@@ -20,6 +22,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;
@@ -28,16 +33,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) {
@@ -112,14 +146,37 @@ export function EbikeIntroSequence(): React.JSX.Element | null {
return null;
}
if (introStep !== "await-ebike-mount" && introStep !== "ebike-intro-ride") {
if (mainState == "pylon") {
if (pylonStep === "approaching") {
return <MissionNotification mission="pylon" visible />;
}
if (pylonStep === "narrator-outro") {
return <MissionNotification mission="farm" visible />;
}
return null;
}
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" }}
/>
@@ -1,7 +1,6 @@
import type { ReactNode } from "react";
import { Component, useEffect, useMemo, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import * as THREE from "three";
import { SkeletonUtils } from "three-stdlib";
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
@@ -362,6 +361,3 @@ export function HandTrackingGlove({
</HandTrackingGloveErrorBoundary>
);
}
useGLTF.preload(GLOVE_CONFIGS.left.modelPath);
useGLTF.preload(GLOVE_CONFIGS.right.modelPath);
+4 -2
View File
@@ -105,6 +105,9 @@ function GraphicsPresetButton({
const lodLabel = config.forceLodModels
? "LOD forcé"
: `HD ${config.lodHighDetailDistance}m`;
const chunkLabel = config.chunkStreamingEnabled
? formatChunkDistance(config.chunkLoadRadius)
: "All";
return (
<button
@@ -115,8 +118,7 @@ function GraphicsPresetButton({
>
<span>{config.label}</span>
<small>
{formatChunkDistance(config.chunkLoadRadius)} · {lodLabel} ·{" "}
{config.fogEnabled ? "Fog" : "Clear"}
{chunkLabel} · {lodLabel} · {config.fogEnabled ? "Fog" : "Clear"}
</small>
</button>
);
+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 />
</>
);
+25 -6
View File
@@ -2,14 +2,20 @@ 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] : "");
const isVideo = src.toLowerCase().endsWith(".webm");
return (
<div
className={`mission-notification${visible ? "" : " mission-notification--hidden"}`}
@@ -17,11 +23,24 @@ export function MissionNotification({
>
<div className="mission-notification__glow" />
<span className="mission-notification__image-wrap">
<img
className="mission-notification__image"
src={MISSION_NOTIFICATION_IMAGE_PATHS[mission]}
alt="Nouvel objectif de mission"
/>
{isVideo ? (
<video
className="mission-notification__image"
src={src}
aria-label="Nouvel objectif de mission"
autoPlay
loop
muted
playsInline
preload="auto"
/>
) : (
<img
className="mission-notification__image"
src={src}
alt="Nouvel objectif de mission"
/>
)}
</span>
</div>
);
+29 -4
View File
@@ -1,10 +1,18 @@
import { useEffect, useState } from "react";
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_BACKGROUND_PATH = "/assets/bg-site.webp";
const LOADING_FRAME_RATE = 12;
const LOADING_FRAME_INTERVAL_MS = 1000 / LOADING_FRAME_RATE;
const LOADING_LOGO_FRAMES = [
"/assets/loader/Loader-1.png",
"/assets/loader/Loader-2.png",
"/assets/loader/Loader-3.png",
"/assets/loader/Loader-4.png",
] as const;
for (const path of [LOADING_BACKGROUND_PATH, LOADING_LOGO_PATH]) {
for (const path of [LOADING_BACKGROUND_PATH, ...LOADING_LOGO_FRAMES]) {
const image = new Image();
image.src = path;
}
@@ -16,8 +24,25 @@ interface SceneLoadingOverlayProps {
export function SceneLoadingOverlay({
state,
}: SceneLoadingOverlayProps): React.JSX.Element | null {
const [logoFrameIndex, setLogoFrameIndex] = useState(0);
const isReady = state.status === "ready";
const progress = Math.round(Math.max(0, Math.min(1, state.progress)) * 100);
const logoFramePath =
LOADING_LOGO_FRAMES[logoFrameIndex] ?? LOADING_LOGO_FRAMES[0];
useEffect(() => {
if (isReady) return undefined;
const intervalId = window.setInterval(() => {
setLogoFrameIndex(
(currentIndex) => (currentIndex + 1) % LOADING_LOGO_FRAMES.length,
);
}, LOADING_FRAME_INTERVAL_MS);
return () => {
window.clearInterval(intervalId);
};
}, [isReady]);
return (
<div
@@ -33,7 +58,7 @@ export function SceneLoadingOverlay({
<img
alt="La Fabrik Durable"
className="scene-loading-overlay__logo"
src={LOADING_LOGO_PATH}
src={logoFramePath}
/>
<div className="scene-loading-overlay__footer">
<div className="scene-loading-overlay__meta">
@@ -0,0 +1,35 @@
import { Suspense } from "react";
import { Canvas } from "@react-three/fiber";
import { TalkieModel } from "@/components/ui/talkie/TalkieModel";
import { TalkieSignalLines } from "@/components/ui/talkie/TalkieSignalLines";
import { useTalkieDialogueOverlayState } from "@/hooks/ui/useTalkieDialogueOverlayState";
export function TalkieDialogueOverlay(): React.JSX.Element | null {
const { isNarratorDialogue, isVisible } = useTalkieDialogueOverlayState();
if (!isVisible) return null;
return (
<aside
className={`talkie-dialogue-overlay${isNarratorDialogue ? " talkie-dialogue-overlay--active" : ""}`}
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: 56 }}
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>
);
}
+82
View File
@@ -0,0 +1,82 @@
import { useEffect, useMemo, useRef } from "react";
import { useFrame } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import * as THREE from "three";
import gsap from "gsap";
import type { Vector3Tuple } from "@/types/three/three";
const TALKIE_MODEL_PATH = "/models/talkie/model.glb";
const TALKIE_REST_Y = -1.55;
const TALKIE_ACTIVE_Y = -0.38;
const TALKIE_BASE_ROTATION: Vector3Tuple = [0.08, -0.52, -0.04];
const TALKIE_FLOAT_ROTATION_AMPLITUDE = THREE.MathUtils.degToRad(2.2);
const TALKIE_FLOAT_Y_AMPLITUDE = 0.055;
interface TalkieModelProps {
active: boolean;
}
export function TalkieModel({ active }: TalkieModelProps): React.JSX.Element {
const { scene } = useGLTF(TALKIE_MODEL_PATH);
const model = useMemo(() => scene.clone(true), [scene]);
const groupRef = useRef<THREE.Group>(null);
const floatRef = useRef<THREE.Group>(null);
useEffect(() => {
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.castShadow = false;
child.receiveShadow = false;
child.frustumCulled = false;
}
});
}, [model]);
useEffect(() => {
const group = groupRef.current;
if (!group) return;
gsap.killTweensOf(group.position);
gsap.to(group.position, {
y: active ? TALKIE_ACTIVE_Y : TALKIE_REST_Y,
duration: active ? 0.72 : 0.5,
ease: active ? "power3.out" : "power2.out",
});
return () => {
gsap.killTweensOf(group.position);
};
}, [active]);
useFrame(({ clock }) => {
if (!floatRef.current) return;
const t = clock.getElapsedTime();
floatRef.current.position.y = Math.sin(t * 1.2) * TALKIE_FLOAT_Y_AMPLITUDE;
floatRef.current.rotation.x =
TALKIE_BASE_ROTATION[0] +
Math.sin(t * 0.7) * TALKIE_FLOAT_ROTATION_AMPLITUDE;
floatRef.current.rotation.y =
TALKIE_BASE_ROTATION[1] +
Math.sin(t * 0.55) * TALKIE_FLOAT_ROTATION_AMPLITUDE;
floatRef.current.rotation.z =
TALKIE_BASE_ROTATION[2] +
Math.sin(t * 0.8) * TALKIE_FLOAT_ROTATION_AMPLITUDE;
});
return (
<group ref={groupRef} position={[0, TALKIE_REST_Y, 0]}>
<group ref={floatRef} rotation={TALKIE_BASE_ROTATION}>
<primitive
object={model}
position={[0, -2.45, 0]}
rotation={[0, -1, 0]}
scale={1.2}
/>
</group>
</group>
);
}
useGLTF.preload(TALKIE_MODEL_PATH);
@@ -0,0 +1,19 @@
interface TalkieSignalLinesProps {
side: "left" | "right";
}
export function TalkieSignalLines({
side,
}: TalkieSignalLinesProps): React.JSX.Element {
return (
<svg
className={`talkie-dialogue-overlay__signals talkie-dialogue-overlay__signals--${side}`}
viewBox="0 0 90 120"
aria-hidden="true"
>
<path d="M18 48 C30 58 30 72 18 82" />
<path d="M34 34 C56 52 56 78 34 96" />
<path d="M52 20 C84 46 84 84 52 110" />
</svg>
);
}
+9 -1
View File
@@ -1,13 +1,15 @@
import type { Vector3Tuple } from "@/types/three/three";
import type { RepairMissionId } from "@/types/gameplay/repairMission";
const DEG_TO_RAD = Math.PI / 180;
export const TEST_SCENE_FLOOR_POSITION: Vector3Tuple = [0, -0.5, 0];
export const TEST_SCENE_FLOOR_SIZE: Vector3Tuple = [200, 1, 200];
export const TEST_SCENE_FLOOR_COLLIDER_HALF_EXTENTS: Vector3Tuple = [
100, 0.5, 100,
];
export const TEST_SCENE_GRABBABLE_POSITION: Vector3Tuple = [0, 1, -3];
export const TEST_SCENE_GRABBABLE_POSITION: Vector3Tuple = [0, 0.25, -3];
export const TEST_SCENE_GRABBABLE_BOX_SIZE: Vector3Tuple = [0.5, 0.5, 0.5];
export const TEST_SCENE_GRABBABLE_COLOR = "#e07b39";
export const TEST_SCENE_GRABBABLE_ROUGHNESS = 0.6;
@@ -23,6 +25,12 @@ export const TEST_SCENE_TRIGGER_METALNESS = 0.5;
export const TEST_SCENE_REPAIR_ZONE_MARKER_RADIUS = 1.65;
export const TEST_SCENE_REPAIR_ZONE_MARKER_TUBE_RADIUS = 0.045;
export const TEST_SCENE_GPS_PREVIEW_POSITION: Vector3Tuple = [0, 5, -4.8];
export const TEST_SCENE_GPS_PREVIEW_ROTATION: Vector3Tuple = [
-33 * DEG_TO_RAD,
0,
0,
];
export const GAME_REPAIR_ZONES = [
{
+6 -6
View File
@@ -6,22 +6,22 @@ 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;
export const EBIKE_ACCELERATION_DURATION_MS = 2000;
export const EBIKE_DECELERATION_DURATION_MS = 2000;
+1 -1
View File
@@ -143,7 +143,7 @@ export const galleryModels: GalleryModel[] = [
},
{ id: "sapin", name: "Sapin", path: "/models/sapin/model.gltf" },
{ id: "skybox", name: "Skybox", path: "/models/skybox/skybox.gltf" },
{ id: "talkie", name: "Talkie", path: "/models/talkie/model.gltf" },
{ id: "talkie", name: "Talkie", path: "/models/talkie/model.glb" },
{ id: "terrain", name: "Terrain", path: "/models/terrain/model.gltf" },
{
id: "tuyauxlac",
+6 -3
View File
@@ -1,8 +1,11 @@
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",
pylon: "/assets/world/UI/pylon-mission-notification.png",
farm: "/assets/world/UI/farm-mission-notification.png",
ebike: "/assets/world/UI/ebike-mission-notification.webm",
pylon: "/assets/world/UI/pylon-mission-notification.webm",
farm: "/assets/world/UI/farm-mission-notification.webm",
};
+3 -13
View File
@@ -21,7 +21,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
"Repair the damaged cooling module before relaunching the bike",
modelPath: "/models/ebike/model.gltf",
modelScale: 0.3,
stageUiPath: "/assets/world/UI/ebike.webm",
stageUiPath: "/assets/world/UI/ebike-mission-notification.webm",
interactUiPath: REPAIR_INTERACT_UI_PATH,
brokenUiPath: REPAIR_BROKEN_UI_PATH,
case: DEFAULT_REPAIR_CASE,
@@ -41,11 +41,6 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
label: "Replacement cooling core",
modelPath: "/models/refroidisseur/model.gltf",
},
{
id: "ebike-radio-distractor",
label: "Radio module",
modelPath: "/models/talkie/model.gltf",
},
{
id: "ebike-glove-distractor",
label: "Insulation glove",
@@ -59,7 +54,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
description:
"Restore the pylon lamp relay and damaged panel before reconnecting the grid",
modelPath: "/models/pylone/model.gltf",
stageUiPath: "/assets/world/UI/centrale.webm",
stageUiPath: "/assets/world/UI/pylon-mission-notification.webm",
interactUiPath: REPAIR_INTERACT_UI_PATH,
brokenUiPath: REPAIR_BROKEN_UI_PATH,
case: DEFAULT_REPAIR_CASE,
@@ -104,7 +99,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
description:
"Stabilize the irrigation loop and humidity sensor before restarting the farm",
modelPath: "/models/fermeverticale/model.gltf",
stageUiPath: "/assets/world/UI/laferme.webm",
stageUiPath: "/assets/world/UI/farm-mission-notification.webm",
interactUiPath: REPAIR_INTERACT_UI_PATH,
brokenUiPath: REPAIR_BROKEN_UI_PATH,
case: DEFAULT_REPAIR_CASE,
@@ -134,11 +129,6 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
label: "Tree sensor",
modelPath: "/models/sapin/model.gltf",
},
{
id: "farm-radio-distractor",
label: "Radio module",
modelPath: "/models/talkie/model.gltf",
},
],
},
};
+6
View File
@@ -8,3 +8,9 @@ export const HAND_TRACKING_BROWSER_WASM_URL =
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.35/wasm";
export const HAND_TRACKING_BROWSER_MODEL_URL =
"https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task";
export const HAND_TRACKING_BROWSER_DELEGATE: "CPU" | "GPU" = "CPU";
// Delay before the runtime actually starts after `enabled` flips to true.
// Absorbs React StrictMode's mount/unmount/mount cycle in dev and rapid
// `nearby` toggles at trigger borders. Invisible to the user (~5 frames).
export const HAND_TRACKING_RUNTIME_START_DELAY_MS = 80;
+11 -2
View File
@@ -1,4 +1,5 @@
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;
@@ -14,5 +15,13 @@ 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_PHYSICS: Vector3Tuple = [0, 3, 0];
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,
PLAYER_EYE_HEIGHT,
0,
];
+1 -1
View File
@@ -1,6 +1,6 @@
import type { CSSProperties } from "react";
const BACKGROUND_IMAGE = "/assets/bg-site.png";
const BACKGROUND_IMAGE = "/assets/bg-site.webp";
export const SITE_CONFIG = {
backgroundImage: BACKGROUND_IMAGE,
+1 -1
View File
@@ -19,7 +19,7 @@ export const CLOUD_DEFAULTS = {
maxRotation: Math.PI * 2,
minSpeedMultiplier: 0.4,
maxSpeedMultiplier: 1,
castShadow: false,
castShadow: true,
receiveShadow: false,
};
+25 -7
View File
@@ -1,11 +1,16 @@
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",
"max",
] as const;
export type GraphicsPreset = (typeof GRAPHICS_PRESET_KEYS)[number];
export interface GraphicsPresetConfig {
chunkLoadRadius: number;
chunkStreamingEnabled: boolean;
chunkUnloadRadius: number;
fogEnabled: boolean;
forceLodModels: boolean;
@@ -18,6 +23,7 @@ export const GRAPHICS_PRESETS = {
label: "Basse",
chunkLoadRadius: 10,
chunkUnloadRadius: 18,
chunkStreamingEnabled: true,
fogEnabled: true,
forceLodModels: true,
lodHighDetailDistance: 0,
@@ -26,25 +32,37 @@ export const GRAPHICS_PRESETS = {
label: "Moyenne",
chunkLoadRadius: 20,
chunkUnloadRadius: 30,
chunkStreamingEnabled: true,
fogEnabled: true,
forceLodModels: true,
lodHighDetailDistance: 0,
},
high: {
label: "High",
chunkLoadRadius: CHUNK_CONFIG.loadRadius,
chunkUnloadRadius: CHUNK_CONFIG.unloadRadius,
chunkLoadRadius: 30,
chunkUnloadRadius: 40,
chunkStreamingEnabled: true,
fogEnabled: false,
forceLodModels: false,
lodHighDetailDistance: 10,
lodHighDetailDistance: 20,
},
ultra: {
label: "Ultra",
chunkLoadRadius: 50,
chunkUnloadRadius: 65,
chunkStreamingEnabled: true,
fogEnabled: false,
forceLodModels: false,
lodHighDetailDistance: 20,
lodHighDetailDistance: 30,
},
max: {
label: "Max",
chunkLoadRadius: 50,
chunkUnloadRadius: 65,
chunkStreamingEnabled: false,
fogEnabled: false,
forceLodModels: false,
lodHighDetailDistance: 50,
},
} as const satisfies Record<GraphicsPreset, GraphicsPresetConfig>;
+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
);
}
+11 -2
View File
@@ -3,9 +3,9 @@ const SUN_LIGHT_COLOR = "#ffe2bf";
export const LIGHTING_DEFAULTS = {
ambientColor: AMBIENT_LIGHT_COLOR,
ambientIntensity: 0.9,
ambientIntensity: 0.7,
sunColor: SUN_LIGHT_COLOR,
sunIntensity: 2.2,
sunIntensity: 1.9,
sunX: 70,
sunY: 45,
sunZ: 35,
@@ -30,3 +30,12 @@ export const SUN_Y_STEP = 1;
export const SUN_Z_MIN = -100;
export const SUN_Z_MAX = 100;
export const SUN_Z_STEP = 1;
export const SHADOW_CONFIG = {
mapSize: 2048,
cameraSize: 95,
cameraNear: 0.5,
cameraFar: 300,
bias: 0,
normalBias: 0,
} as const;
+16 -1
View File
@@ -13,7 +13,9 @@ export const MAP_LOD_MODEL_PATHS = {
lafabrik: "/models/lafabrik-LOD/model.gltf",
maison1: "/models/maison1-LOD/model.gltf",
panneauaffichage: "/models/panneauaffichage-LOD/model.gltf",
talkie: "/models/talkie-LOD/model.gltf",
arbre: "/models/arbre-LOD/model.glb",
buisson: "/models/buisson-LOD/model.glb",
sapin: "/models/sapin-LOD/model.glb",
} as const satisfies Record<string, string>;
export function getMapLodModelPath(modelName: string): string | null {
@@ -22,6 +24,19 @@ export function getMapLodModelPath(modelName: string): string | null {
);
}
export const MAP_LOD_SCALE_MULTIPLIERS = {
sapin: 0.35,
buisson: 0.7,
} as const satisfies Partial<Record<keyof typeof MAP_LOD_MODEL_PATHS, number>>;
export function getMapLodScaleMultiplier(modelName: string): number {
return (
MAP_LOD_SCALE_MULTIPLIERS[
modelName as keyof typeof MAP_LOD_SCALE_MULTIPLIERS
] ?? 1
);
}
export function selectMapModelPathByDistance({
distance,
modelName,
+61
View File
@@ -0,0 +1,61 @@
import type { Vector3Tuple } from "@/types/three/three";
export interface OctreeCollisionBox {
center: Vector3Tuple;
size: Vector3Tuple;
}
export interface MapOctreeCollisionBox extends OctreeCollisionBox {
bottomY: number;
}
export const MAP_OCTREE_COLLISION_BOXES = {
immeuble1: {
center: [-0.0308, 5.8389, 0],
size: [17.2522, 11.6098, 9.2668],
bottomY: 0.034,
},
maison1: {
center: [0, 1.3638, 0.0536],
size: [2.7813, 3.022, 2.8609],
bottomY: -0.1472,
},
} as const satisfies Record<string, MapOctreeCollisionBox>;
export const LA_FABRIK_INTERIOR_COLLISION_BOXES = [
// NOTE: removed — this thin wall (size [0.2, 1.94, 3.71]) sat at x≈-6.93 and
// sealed the doorway despite the geometry having a hole there. The fabrik
// mesh octree already provides the surrounding wall collision, so this
// proxy was both redundant and bug-causing.
// {
// center: [-6.9351, 2.278, -0.0001],
// size: [0.2, 1.94, 3.711],
// },
{
center: [0.8026, 0.719, -3.639],
size: [4.346, 1.108, 1.181],
},
{
center: [-5.8519, 0.9362, 2.5742],
size: [1.67, 1.551, 2.566],
},
{
center: [-2.0627, 1.4875, -1.2243],
size: [0.691, 0.723, 0.687],
},
{
center: [-3.5502, 1.4378, -1.2485],
size: [1.055, 0.657, 0.563],
},
] as const satisfies readonly OctreeCollisionBox[];
export const CHARACTER_OCTREE_COLLISION_BOX = {
center: [0, 0.875, 0],
size: [0.62, 1.75, 0.62],
} as const satisfies OctreeCollisionBox;
export function hasMapOctreeCollisionBox(
name: string,
): name is keyof typeof MAP_OCTREE_COLLISION_BOXES {
return name in MAP_OCTREE_COLLISION_BOXES;
}
+66
View File
@@ -0,0 +1,66 @@
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
import { useDebugVisualsStore } from "@/managers/stores/useDebugVisualsStore";
export function useDebugVisualsDebug(): void {
useDebugFolder("Debug", (folder) => {
const state = useDebugVisualsStore.getState();
const controls = {
showPlayerModel: state.showPlayerModel,
showOctree: state.showOctree,
octreeMinDepth: state.octreeMinDepth,
octreeMaxDepth: state.octreeMaxDepth,
octreeLeavesOnly: state.octreeLeavesOnly,
octreeOpacity: state.octreeOpacity,
octreeFabrikOnly: state.octreeFabrikOnly,
};
folder
.add(controls, "showPlayerModel")
.name("Show Player Model")
.onChange((value: boolean) => {
useDebugVisualsStore.getState().setShowPlayerModel(value);
});
folder
.add(controls, "showOctree")
.name("Show Octree")
.onChange((value: boolean) => {
useDebugVisualsStore.getState().setShowOctree(value);
});
folder
.add(controls, "octreeLeavesOnly")
.name("Octree Leaves Only")
.onChange((value: boolean) => {
useDebugVisualsStore.getState().setOctreeLeavesOnly(value);
});
folder
.add(controls, "octreeMinDepth", 0, 10, 1)
.name("Octree Min Depth")
.onChange((value: number) => {
useDebugVisualsStore.getState().setOctreeMinDepth(value);
});
folder
.add(controls, "octreeMaxDepth", 0, 10, 1)
.name("Octree Max Depth")
.onChange((value: number) => {
useDebugVisualsStore.getState().setOctreeMaxDepth(value);
});
folder
.add(controls, "octreeOpacity", 0.05, 1, 0.05)
.name("Octree Opacity")
.onChange((value: number) => {
useDebugVisualsStore.getState().setOctreeOpacity(value);
});
folder
.add(controls, "octreeFabrikOnly")
.name("Octree Fabrik Only")
.onChange((value: boolean) => {
useDebugVisualsStore.getState().setOctreeFabrikOnly(value);
});
});
}
@@ -2,17 +2,20 @@ import { useEffect, useRef, useState } from "react";
import {
HAND_TRACKING_FRAME_HEIGHT,
HAND_TRACKING_FRAME_WIDTH,
HAND_TRACKING_RUNTIME_START_DELAY_MS,
HAND_TRACKING_TARGET_FPS,
} from "@/data/handTrackingConfig";
import {
convertBrowserHandResult,
getBrowserHandLandmarker,
releaseBrowserHandLandmarker,
} from "@/lib/handTracking/browserHandTracking";
import {
INITIAL_HAND_TRACKING_SNAPSHOT,
getCameraStreamWithTimeout,
} from "@/lib/handTracking/handTrackingSession";
import type { HandTrackingSnapshot } from "@/types/handTracking/handTracking";
import { logger } from "@/utils/core/Logger";
interface UseBrowserHandTrackingOptions {
enabled: boolean;
@@ -34,8 +37,12 @@ export function useBrowserHandTracking({
}
let cancelled = false;
let cleanedUp = false;
const cleanup = (): void => {
if (cleanedUp) return;
cleanedUp = true;
if (intervalRef.current !== null) {
window.clearInterval(intervalRef.current);
intervalRef.current = null;
@@ -44,6 +51,7 @@ export function useBrowserHandTracking({
streamRef.current?.getTracks().forEach((track) => track.stop());
streamRef.current = null;
videoRef.current = null;
releaseBrowserHandLandmarker();
};
const start = async (): Promise<void> => {
@@ -111,24 +119,44 @@ export function useBrowserHandTracking({
intervalRef.current = window.setInterval(() => {
if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) return;
const result = handLandmarker.detectForVideo(
video,
performance.now(),
);
const hands = convertBrowserHandResult(result);
try {
const result = handLandmarker.detectForVideo(
video,
performance.now(),
);
const hands = convertBrowserHandResult(result);
setSnapshot((current) => ({
...current,
hands,
usageStatus: hands.some((hand) => hand.isFist)
? "active"
: "available",
error: null,
}));
setSnapshot((current) => ({
...current,
hands,
usageStatus: hands.some((hand) => hand.isFist)
? "active"
: "available",
error: null,
}));
} catch (error) {
logger.error("HandTracking", "Browser JS runtime error", {
error: error instanceof Error ? error.message : String(error),
});
cleanup();
setSnapshot({
hands: [],
status: "error",
usageStatus: "inactive",
serverStatus: "Browser JS",
error:
error instanceof Error
? error.message
: "Browser hand tracking failed",
});
}
}, 1_000 / HAND_TRACKING_TARGET_FPS);
} catch (error) {
if (cancelled) return;
logger.error("HandTracking", "Browser JS runtime failed", {
error: error instanceof Error ? error.message : String(error),
});
setSnapshot({
hands: [],
status: "error",
@@ -142,10 +170,17 @@ export function useBrowserHandTracking({
}
};
void start();
// Delay the actual start so that a StrictMode mount/unmount/mount
// cycle, or a rapid `enabled` toggle at a trigger border, does not
// spin up the camera + MediaPipe twice in a few milliseconds.
const startTimer = window.setTimeout(() => {
if (cancelled) return;
void start();
}, HAND_TRACKING_RUNTIME_START_DELAY_MS);
return () => {
cancelled = true;
window.clearTimeout(startTimer);
cleanup();
};
}, [enabled]);
@@ -4,6 +4,7 @@ import {
HAND_TRACKING_FRAME_WIDTH,
HAND_TRACKING_JPEG_QUALITY,
HAND_TRACKING_RESPONSE_TIMEOUT_MS,
HAND_TRACKING_RUNTIME_START_DELAY_MS,
HAND_TRACKING_TARGET_FPS,
} from "@/data/handTrackingConfig";
import { getHandTrackingWsUrl } from "@/utils/handTracking/handTrackingEndpoint";
@@ -17,6 +18,7 @@ import type {
HandTrackingServerMessage,
HandTrackingSnapshot,
} from "@/types/handTracking/handTracking";
import { logger } from "@/utils/core/Logger";
interface UseRemoteHandTrackingOptions {
enabled: boolean;
@@ -100,6 +102,7 @@ export function useRemoteHandTracking({
}
let cancelled = false;
let cleanedUp = false;
const clearResponseTimeout = (): void => {
if (responseTimeoutRef.current === null) return;
@@ -108,6 +111,9 @@ export function useRemoteHandTracking({
};
const cleanup = (): void => {
if (cleanedUp) return;
cleanedUp = true;
if (sendIntervalRef.current !== null) {
window.clearInterval(sendIntervalRef.current);
sendIntervalRef.current = null;
@@ -283,6 +289,9 @@ export function useRemoteHandTracking({
};
ws.onerror = () => {
markResponseReceived();
logger.error("HandTracking", "Backend WebSocket error", {
websocketUrl,
});
setSnapshot((current) => ({
...current,
status: "error",
@@ -307,6 +316,10 @@ export function useRemoteHandTracking({
);
} catch (error) {
if (cancelled) return;
logger.error("HandTracking", "Backend runtime failed", {
error: error instanceof Error ? error.message : String(error),
websocketUrl,
});
setSnapshot({
hands: [],
status: "error",
@@ -318,10 +331,17 @@ export function useRemoteHandTracking({
}
};
void start();
// Delay the actual start so that a StrictMode mount/unmount/mount
// cycle, or a rapid `enabled` toggle at a trigger border, does not
// open the camera + WebSocket twice in a few milliseconds.
const startTimer = window.setTimeout(() => {
if (cancelled) return;
void start();
}, HAND_TRACKING_RUNTIME_START_DELAY_MS);
return () => {
cancelled = true;
window.clearTimeout(startTimer);
cleanup();
};
}, [enabled, websocketUrl]);
+2 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef } from "react";
import type { RefObject } from "react";
import type { Object3D } from "three";
import { type Object3D } from "three";
import { Octree } from "three-stdlib";
import type { OctreeReadyHandler } from "@/types/three/three";
@@ -27,6 +27,7 @@ export function useOctreeGraphNode(
const octree = new Octree();
octree.fromGraphNode(graphNode);
onOctreeReady(octree);
}, [enabled, graphNodeRef, onOctreeReady, rebuildKey]);
}
+105
View File
@@ -0,0 +1,105 @@
import { useRef } from "react";
import { useFrame } from "@react-three/fiber";
import {
Material,
Mesh,
type DirectionalLight,
type Scene,
type WebGLRenderer,
} from "three";
interface UseShadowMapWarmupOptions {
/** Light whose shadow map should be reallocated once the scene stabilizes. */
light: React.RefObject<DirectionalLight | null>;
scene: Scene;
gl: WebGLRenderer;
invalidate: () => void;
/** Frames the mesh count must remain unchanged to consider the scene stable. */
stableFramesThreshold?: number;
/** Hard cap on how long we keep watching, in frames (~5s @60fps). */
safetyCapFrames?: number;
/** Sample mesh count every N frames to keep the traversal cost negligible. */
sampleEveryFrames?: number;
}
export function useShadowMapWarmup({
light,
scene,
gl,
invalidate,
stableFramesThreshold = 60,
safetyCapFrames = 300,
sampleEveryFrames = 6,
}: UseShadowMapWarmupOptions): void {
const meshCountRef = useRef(0);
const stableFramesRef = useRef(0);
const watchFramesRef = useRef(0);
const doneRef = useRef(false);
useFrame(() => {
if (doneRef.current || !light.current) return;
watchFramesRef.current += 1;
if (watchFramesRef.current % sampleEveryFrames === 0) {
let meshCount = 0;
scene.traverse((object) => {
if (object instanceof Mesh) meshCount += 1;
});
if (meshCount !== meshCountRef.current) {
meshCountRef.current = meshCount;
stableFramesRef.current = 0;
} else {
stableFramesRef.current += sampleEveryFrames;
}
}
const stableEnough = stableFramesRef.current >= stableFramesThreshold;
const safetyCapReached = watchFramesRef.current >= safetyCapFrames;
if (!stableEnough && !safetyCapReached) return;
doneRef.current = true;
reallocateShadowMap(light.current);
invalidateAllMaterials(scene);
forceShadowPass(gl, scene, light.current);
invalidate();
});
}
function reallocateShadowMap(light: DirectionalLight): void {
const shadowMap = light.shadow.map;
if (!shadowMap) return;
shadowMap.dispose();
light.shadow.map = null;
}
function invalidateAllMaterials(scene: Scene): void {
const seen = new Set<Material>();
scene.traverse((object) => {
if (!(object instanceof Mesh)) return;
const materials = Array.isArray(object.material)
? object.material
: [object.material];
for (const material of materials) {
if (!material || seen.has(material)) continue;
seen.add(material);
material.needsUpdate = true;
}
});
}
function forceShadowPass(
gl: WebGLRenderer,
scene: Scene,
light: DirectionalLight,
): void {
scene.updateMatrixWorld(true);
light.target.updateMatrixWorld(true);
light.updateMatrixWorld(true);
light.shadow.camera.updateMatrixWorld(true);
light.shadow.camera.updateProjectionMatrix();
light.shadow.needsUpdate = true;
gl.shadowMap.needsUpdate = true;
}
@@ -0,0 +1,27 @@
import { GAME_STEPS } from "@/data/game/gameStateConfig";
import { useGameStore } from "@/managers/stores/useGameStore";
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
const TALKIE_FIRST_VISIBLE_STEP = "reveal";
const TALKIE_FIRST_VISIBLE_STEP_INDEX = GAME_STEPS.indexOf(
TALKIE_FIRST_VISIBLE_STEP,
);
interface TalkieDialogueOverlayState {
isNarratorDialogue: boolean;
isVisible: boolean;
}
export function useTalkieDialogueOverlayState(): TalkieDialogueOverlayState {
const activeSubtitle = useSubtitleStore((state) => state.activeSubtitle);
const mainState = useGameStore((state) => state.mainState);
const introStep = useGameStore((state) => state.intro.currentStep);
const introStepIndex = GAME_STEPS.indexOf(introStep);
return {
isNarratorDialogue: activeSubtitle?.speaker === "Narrateur",
isVisible:
mainState !== "intro" ||
introStepIndex >= TALKIE_FIRST_VISIBLE_STEP_INDEX,
};
}
+1 -31
View File
@@ -11,13 +11,10 @@ interface UseWorldSceneLoadingOptions {
interface UseWorldSceneLoadingResult {
octree: Octree | null;
gameplayReady: boolean;
shouldWarmUpShadows: boolean;
showGameStage: boolean;
handleGameStageLoaded: () => void;
handleGameMapLoaded: () => void;
handleOctreeReady: (octree: Octree) => void;
handleShadowWarmupReady: () => void;
handleShadowWarmupStarted: () => void;
}
export function useWorldSceneLoading({
@@ -27,19 +24,13 @@ export function useWorldSceneLoading({
const [octree, setOctree] = useState<Octree | null>(null);
const [gameMapLoaded, setGameMapLoaded] = useState(false);
const [gameStageLoaded, setGameStageLoaded] = useState(false);
const [shadowsReady, setShadowsReady] = useState(false);
const showGameStage = sceneMode === "game" && gameMapLoaded;
const gameSceneReadyForShadows =
showGameStage && gameStageLoaded && octree !== null;
const shadowWarmupReady = sceneMode === "game" && gameSceneReadyForShadows;
const shouldWarmUpShadows = shadowWarmupReady && !shadowsReady;
const gameplayReady = gameSceneReadyForShadows && shadowsReady;
const gameplayReady = showGameStage && gameStageLoaded && octree !== null;
const sceneReady =
(sceneMode === "game" && gameplayReady) ||
(sceneMode === "physics" && octree !== null);
const handleGameMapLoaded = useCallback(() => {
setShadowsReady(false);
setGameMapLoaded(true);
}, []);
@@ -54,7 +45,6 @@ export function useWorldSceneLoading({
const handleOctreeReady = useCallback(
(nextOctree: Octree) => {
setShadowsReady(false);
setOctree(nextOctree);
onLoadingStateChange?.({
currentStep: "Collision prête",
@@ -65,23 +55,6 @@ export function useWorldSceneLoading({
[onLoadingStateChange],
);
const handleShadowWarmupStarted = useCallback(() => {
onLoadingStateChange?.({
currentStep: "Activation des ombres",
progress: 0.97,
status: "loading",
});
}, [onLoadingStateChange]);
const handleShadowWarmupReady = useCallback(() => {
setShadowsReady(true);
onLoadingStateChange?.({
currentStep: "Ombres prêtes",
progress: 0.99,
status: "loading",
});
}, [onLoadingStateChange]);
useEffect(() => {
onLoadingStateChange?.({
currentStep: "Initialisation du jeu",
@@ -115,12 +88,9 @@ export function useWorldSceneLoading({
return {
octree,
gameplayReady,
shouldWarmUpShadows,
showGameStage,
handleGameStageLoaded,
handleGameMapLoaded,
handleOctreeReady,
handleShadowWarmupReady,
handleShadowWarmupStarted,
};
}
+112 -4
View File
@@ -942,11 +942,11 @@ canvas {
.scene-loading-overlay__logo {
position: relative;
z-index: 1;
display: block;
width: clamp(180px, 28vw, 320px);
max-height: min(38vh, 320px);
border-radius: 16px;
object-fit: cover;
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.28);
height: auto;
object-fit: contain;
}
.scene-loading-overlay__footer {
@@ -1237,6 +1237,114 @@ canvas {
color: #f9a8d4;
}
/* Dialogue talkie */
.talkie-dialogue-overlay {
position: fixed;
left: 0;
bottom: 0;
z-index: 16;
width: clamp(190px, 18vw, 300px);
aspect-ratio: 1;
pointer-events: none;
}
.talkie-dialogue-overlay__model-frame {
position: absolute;
inset: 0;
filter: drop-shadow(0 16px 22px rgba(0, 0, 0, 0.55));
}
.talkie-dialogue-overlay--active .talkie-dialogue-overlay__model-frame {
animation: talkie-radio-shake 1s ease-in-out infinite;
}
.talkie-dialogue-overlay__model-frame canvas {
width: 100% !important;
height: 100% !important;
}
.talkie-dialogue-overlay__signals {
position: absolute;
top: 52%;
z-index: 2;
width: 38%;
height: 52%;
overflow: visible;
opacity: 0.8;
animation: talkie-signal-pulse 1s ease-in-out infinite;
}
.talkie-dialogue-overlay__signals--left {
right: 62%;
transform: translateY(-50%) scaleX(-1);
}
.talkie-dialogue-overlay__signals--right {
left: 62%;
transform: translateY(-50%);
}
.talkie-dialogue-overlay__signals path {
fill: none;
stroke: rgba(235, 244, 255, 0.9);
stroke-linecap: round;
stroke-width: 5;
filter: drop-shadow(0 0 7px rgba(125, 211, 252, 0.72));
}
.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.55;
}
@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;
}
18%,
38% {
opacity: 0.95;
}
60% {
opacity: 0.45;
}
}
/* In-game settings menu */
.game-settings-menu {
position: fixed;
@@ -1436,7 +1544,7 @@ canvas {
}
.game-settings-menu__choice-group--presets {
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.game-settings-menu__choice-group button,
+31 -2
View File
@@ -1,4 +1,5 @@
import {
HAND_TRACKING_BROWSER_DELEGATE,
HAND_TRACKING_BROWSER_MODEL_URL,
HAND_TRACKING_BROWSER_WASM_URL,
} from "@/data/handTrackingConfig";
@@ -6,6 +7,7 @@ import type {
HandTrackingHand,
HandTrackingLandmark,
} from "@/types/handTracking/handTracking";
import { logger } from "@/utils/core/Logger";
type HandLandmarkerModule = typeof import("@mediapipe/tasks-vision");
type HandLandmarker = Awaited<
@@ -14,6 +16,7 @@ type HandLandmarker = Awaited<
type HandLandmarkerResult = ReturnType<HandLandmarker["detectForVideo"]>;
let handLandmarkerPromise: Promise<HandLandmarker> | null = null;
let handLandmarkerInstance: HandLandmarker | null = null;
function averageLandmarks(
landmarks: HandTrackingLandmark[],
@@ -78,20 +81,46 @@ export async function getBrowserHandLandmarker(): Promise<HandLandmarker> {
HAND_TRACKING_BROWSER_WASM_URL,
);
return HandLandmarker.createFromOptions(vision, {
const handLandmarker = await HandLandmarker.createFromOptions(vision, {
baseOptions: {
modelAssetPath: HAND_TRACKING_BROWSER_MODEL_URL,
delegate: "GPU",
delegate: HAND_TRACKING_BROWSER_DELEGATE,
},
numHands: 2,
runningMode: "VIDEO",
});
handLandmarkerInstance = handLandmarker;
return handLandmarker;
},
);
return handLandmarkerPromise;
}
export function releaseBrowserHandLandmarker(): void {
const activeLandmarker = handLandmarkerInstance;
const pendingLandmarker = handLandmarkerPromise;
handLandmarkerInstance = null;
handLandmarkerPromise = null;
if (activeLandmarker) {
activeLandmarker.close();
return;
}
void pendingLandmarker
?.then((landmarker) => {
landmarker.close();
})
.catch((error: unknown) => {
logger.warn("HandTracking", "Browser JS landmarker release failed", {
error: error instanceof Error ? error.message : String(error),
});
});
}
export function convertBrowserHandResult(
result: HandLandmarkerResult,
): HandTrackingHand[] {
@@ -0,0 +1,35 @@
import { create } from "zustand";
interface DebugVisualsStore {
showPlayerModel: boolean;
setShowPlayerModel: (value: boolean) => void;
showOctree: boolean;
setShowOctree: (value: boolean) => void;
octreeMaxDepth: number;
setOctreeMaxDepth: (value: number) => void;
octreeMinDepth: number;
setOctreeMinDepth: (value: number) => void;
octreeLeavesOnly: boolean;
setOctreeLeavesOnly: (value: boolean) => void;
octreeOpacity: number;
setOctreeOpacity: (value: number) => void;
octreeFabrikOnly: boolean;
setOctreeFabrikOnly: (value: boolean) => void;
}
export const useDebugVisualsStore = create<DebugVisualsStore>((set) => ({
showPlayerModel: false,
setShowPlayerModel: (showPlayerModel) => set({ showPlayerModel }),
showOctree: false,
setShowOctree: (showOctree) => set({ showOctree }),
octreeMaxDepth: 8,
setOctreeMaxDepth: (octreeMaxDepth) => set({ octreeMaxDepth }),
octreeMinDepth: 4,
setOctreeMinDepth: (octreeMinDepth) => set({ octreeMinDepth }),
octreeLeavesOnly: true,
setOctreeLeavesOnly: (octreeLeavesOnly) => set({ octreeLeavesOnly }),
octreeOpacity: 0.35,
setOctreeOpacity: (octreeOpacity) => set({ octreeOpacity }),
octreeFabrikOnly: false,
setOctreeFabrikOnly: (octreeFabrikOnly) => set({ octreeFabrikOnly }),
}));
+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,
}),
},
),
);
+45 -4
View File
@@ -15,7 +15,9 @@ import {
} from "@/components/ui/intro";
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
import { INITIAL_SCENE_LOADING_STATE } from "@/data/world/sceneLoadingConfig";
import { useDebugStore } from "@/hooks/debug/useDebugStore";
import { useTransientLoadingIndicator } from "@/hooks/ui/useTransientLoadingIndicator";
import { releaseBrowserHandLandmarker } from "@/lib/handTracking/browserHandTracking";
import { AudioManager } from "@/managers/AudioManager";
import { useGameStore } from "@/managers/stores/useGameStore";
import { useWorldSettingsStore } from "@/managers/stores/useWorldSettingsStore";
@@ -26,6 +28,9 @@ import { logger } from "@/utils/core/Logger";
import { World } from "@/world/World";
const LOADING_TO_VIDEO_FADE_MS = 500;
const WEBGL_CONTEXT_RESTORE_DELAY_MS = 500;
const CANVAS_DPR: [number, number] = [1, 1];
const registeredWebglContextCanvases = new WeakSet<HTMLCanvasElement>();
export function HomePage(): React.JSX.Element | null {
const navigate = useNavigate();
@@ -38,6 +43,11 @@ export function HomePage(): React.JSX.Element | null {
const graphicsPreset = useWorldSettingsStore(
(state) => state.graphics.preset,
);
const cameraMode = useDebugStore((debug) => debug.getCameraMode());
const handTrackingSource = useDebugStore((debug) =>
debug.getHandTrackingSource(),
);
const sceneMode = useDebugStore((debug) => debug.getSceneMode());
const dialogMessage = useGameStore(
(state) => state.missionFlow.dialogMessage,
);
@@ -48,9 +58,18 @@ export function HomePage(): React.JSX.Element | null {
INITIAL_SCENE_LOADING_STATE,
);
const sceneReadyRef = useRef(false);
const cameraModeRef = useRef(cameraMode);
const handTrackingSourceRef = useRef(handTrackingSource);
const sceneModeRef = useRef(sceneMode);
const runtimeLoadingSignal = `${graphicsPreset}:${mainState}:${ebikeStep}:${pylonStep}:${farmStep}`;
const previousRuntimeLoadingSignalRef = useRef(runtimeLoadingSignal);
useEffect(() => {
cameraModeRef.current = cameraMode;
handTrackingSourceRef.current = handTrackingSource;
sceneModeRef.current = sceneMode;
}, [cameraMode, handTrackingSource, sceneMode]);
useEffect(() => {
sceneReadyRef.current = sceneLoadingState.status === "ready";
}, [sceneLoadingState.status]);
@@ -131,24 +150,44 @@ export function HomePage(): React.JSX.Element | null {
gl.shadowMap.enabled = true;
gl.shadowMap.type = THREE.PCFShadowMap;
gl.shadowMap.autoUpdate = true;
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
// page stays frozen on a black canvas until the user reloads.
const loseContextExt = gl.getContext().getExtension("WEBGL_lose_context");
if (registeredWebglContextCanvases.has(canvas)) return;
registeredWebglContextCanvases.add(canvas);
const handleContextLost = (event: Event) => {
event.preventDefault();
logger.error("WebGL", "Context lost - attempting auto-restore");
releaseBrowserHandLandmarker();
logger.error("WebGL", "Context lost - attempting auto-restore", {
cameraMode: cameraModeRef.current,
geometries: gl.info.memory.geometries,
handTrackingSource: handTrackingSourceRef.current,
sceneMode: sceneModeRef.current,
textures: gl.info.memory.textures,
});
// Give the GPU a moment to free resources before asking it back.
window.setTimeout(() => loseContextExt?.restoreContext(), 500);
window.setTimeout(
() => loseContextExt?.restoreContext(),
WEBGL_CONTEXT_RESTORE_DELAY_MS,
);
};
const handleContextRestored = () => {
gl.shadowMap.enabled = true;
gl.shadowMap.type = THREE.PCFShadowMap;
gl.shadowMap.autoUpdate = true;
logger.info("WebGL", "Context restored");
gl.shadowMap.needsUpdate = true;
logger.info("WebGL", "Context restored", {
cameraMode: cameraModeRef.current,
handTrackingSource: handTrackingSourceRef.current,
sceneMode: sceneModeRef.current,
});
};
canvas.addEventListener("webglcontextlost", handleContextLost);
@@ -189,10 +228,12 @@ export function HomePage(): React.JSX.Element | null {
<HandTrackingProvider>
<Canvas
camera={{ position: [85, 60, 85], fov: 42 }}
dpr={CANVAS_DPR}
id="game-canvas"
shadows={{ type: THREE.PCFShadowMap }}
gl={{
powerPreference: "high-performance",
antialias: true,
antialias: false,
stencil: false,
}}
onCreated={handleCanvasCreated}
-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 />;
+37 -12
View File
@@ -1,14 +1,17 @@
import GUI from "lil-gui";
import type { Controller } from "lil-gui";
import type { CameraMode, SceneMode } from "@/types/debug/debug";
import type { HandTrackingSource } from "@/types/handTracking/handTracking";
import { FOG_CONFIG } from "@/data/world/fogConfig";
import { EventEmitter } from "@/utils/core/EventEmitter";
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
import { logger } from "@/utils/core/Logger";
const DEBUG_CONTROLS_STORAGE_KEY = "la-fabrik-debug-controls";
interface StoredDebugControls {
cameraMode: CameraMode;
handTrackingSource: HandTrackingSource;
sceneMode: SceneMode;
}
@@ -25,6 +28,7 @@ const DEBUG_FOLDER_ORDER = [
"Hand Tracking",
"Map",
"Personnages",
"Debug",
] as const;
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -39,6 +43,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 +59,9 @@ function getStoredDebugControls(): Partial<StoredDebugControls> {
...(isCameraMode(parsedValue.cameraMode)
? { cameraMode: parsedValue.cameraMode }
: {}),
...(isHandTrackingSource(parsedValue.handTrackingSource)
? { handTrackingSource: parsedValue.handTrackingSource }
: {}),
...(isSceneMode(parsedValue.sceneMode)
? { sceneMode: parsedValue.sceneMode }
: {}),
@@ -68,6 +79,7 @@ export class Debug {
private readonly events = new EventEmitter<DebugEvents>();
private readonly folders = new Map<string, GUI>();
private readonly folderRefCounts = new Map<string, number>();
private handTrackingSourceController: Controller | null = null;
private readonly controls: {
cameraMode: CameraMode;
fogEnabled: boolean;
@@ -94,7 +106,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,
@@ -151,16 +163,22 @@ export class Debug {
this.emit();
});
handTrackingFolder
?.add(this.controls, "handTrackingSource", {
"Browser JS": "browser",
Backend: "backend",
})
.name("Source")
.onChange((value: HandTrackingSource) => {
this.controls.handTrackingSource = value;
this.emit();
});
this.handTrackingSourceController =
handTrackingFolder
?.add(this.controls, "handTrackingSource", {
"Browser JS": "browser",
Backend: "backend",
})
.name("Source")
.onChange((value: HandTrackingSource) => {
const previousSource = this.controls.handTrackingSource;
this.controls.handTrackingSource = value;
logger.info("HandTracking", "Debug source changed", {
from: previousSource,
to: value,
});
this.saveAndEmit();
}) ?? null;
}
}
@@ -245,8 +263,14 @@ export class Debug {
}
setHandTrackingSource(value: HandTrackingSource): void {
const previousSource = this.controls.handTrackingSource;
this.controls.handTrackingSource = value;
this.emit();
this.handTrackingSourceController?.updateDisplay();
logger.info("HandTracking", "Settings source changed", {
from: previousSource,
to: value,
});
this.saveAndEmit();
}
getFogEnabled(): boolean {
@@ -285,6 +309,7 @@ export class Debug {
DEBUG_CONTROLS_STORAGE_KEY,
JSON.stringify({
cameraMode: this.controls.cameraMode,
handTrackingSource: this.controls.handTrackingSource,
sceneMode: this.controls.sceneMode,
}),
);
+8 -7
View File
@@ -19,20 +19,21 @@ type TexturedMaterial = THREE.Material &
Partial<Record<TextureKey, THREE.Texture>>;
const optimizedTextures = new WeakSet<THREE.Texture>();
const MAX_GLTF_TEXTURE_ANISOTROPY = 2;
function optimizeTexture(texture: THREE.Texture, maxAnisotropy: number): void {
if (optimizedTextures.has(texture)) return;
optimizedTextures.add(texture);
texture.anisotropy = Math.min(4, Math.max(1, maxAnisotropy));
const nextAnisotropy = Math.min(
MAX_GLTF_TEXTURE_ANISOTROPY,
Math.max(1, maxAnisotropy),
);
if (!(texture instanceof THREE.CompressedTexture)) {
texture.generateMipmaps = true;
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.magFilter = THREE.LinearFilter;
if (texture.anisotropy > nextAnisotropy) {
texture.anisotropy = nextAnisotropy;
texture.needsUpdate = true;
}
texture.needsUpdate = true;
}
function optimizeMaterialTextures(
+1 -21
View File
@@ -15,24 +15,11 @@ import { SkyModel } from "@/components/three/world/SkyModel";
import { CloudSystem } from "@/world/clouds/CloudSystem";
import { FogSystem } from "@/world/fog/FogSystem";
import { GrassSystem } from "@/world/grass/GrassSystem";
import { SceneShadowWarmup } from "@/world/SceneShadowWarmup";
import { VegetationSystem } from "@/world/vegetation/VegetationSystem";
import { WaterSystem } from "@/world/water/WaterSystem";
import { WorldPlane } from "@/world/WorldPlane";
interface ShadowWarmupConfig {
active: boolean;
onReady: () => void;
onStarted: () => void;
}
interface EnvironmentProps {
shadowWarmup?: ShadowWarmupConfig;
}
export function Environment({
shadowWarmup,
}: EnvironmentProps): React.JSX.Element {
export function Environment(): React.JSX.Element {
const sceneMode = useSceneMode();
const groups = useMapPerformanceStore((state) => state.groups);
const models = useMapPerformanceStore((state) => state.models);
@@ -47,13 +34,6 @@ export function Environment({
return (
<>
<FogSystem />
{shadowWarmup ? (
<SceneShadowWarmup
active={shadowWarmup.active}
onReady={shadowWarmup.onReady}
onStarted={shadowWarmup.onStarted}
/>
) : null}
{showSky ? (
<SkyModel
fallbackColor={GAME_SCENE_FALLBACK_BACKGROUND_COLOR}
+11
View File
@@ -27,6 +27,7 @@ import { useMapLodModelPath } from "@/hooks/world/useMapLodModelPath";
import { GameMapCollision } from "@/world/GameMapCollision";
import { GeneratedMapNodeInstance } from "@/world/map-generated/GeneratedMapNodeInstance";
import { isGeneratedMapModelName } from "@/data/world/generatedMapModelConfig";
import { hasMapOctreeCollisionBox } from "@/data/world/octreeCollisionConfig";
import { getMapSingleModelScaleMultiplier } from "@/data/world/mapInstancingConfig";
import { MapInstancingSystem } from "@/world/map-instancing/MapInstancingSystem";
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
@@ -115,6 +116,9 @@ export function GameMap({
const [collisionMapNodes, setCollisionMapNodes] = useState<LoadedMapNode[]>(
[],
);
const [proxyCollisionMapNodes, setProxyCollisionMapNodes] = useState<
MapNode[]
>([]);
const [terrainNode, setTerrainNode] = useState<MapNode | null>(null);
const [mapLoaded, setMapLoaded] = useState(false);
const [settledMapNodeCount, setSettledMapNodeCount] = useState(0);
@@ -134,6 +138,7 @@ export function GameMap({
(currentStep: string) => {
setRenderMapNodes([]);
setCollisionMapNodes([]);
setProxyCollisionMapNodes([]);
setTerrainNode(null);
setMapLoaded(true);
settledMapNodesRef.current.clear();
@@ -191,6 +196,10 @@ export function GameMap({
const modelUrl = sceneData.models.get(node.name);
return { node, modelUrl: modelUrl ?? null };
});
const loadedProxyCollisionNodes = sceneData.mapNodes.filter(
(node) =>
node.type === "Object3D" && hasMapOctreeCollisionBox(node.name),
);
const loadedTerrainNode = getTerrainMapNode(sceneData.mapNodes);
const repairMissionAnchors = getRepairMissionMapAnchors(
sceneData.mapNodes,
@@ -211,6 +220,7 @@ export function GameMap({
setRenderMapNodes(loadedMapNodes);
setCollisionMapNodes(loadedCollisionNodes);
setProxyCollisionMapNodes(loadedProxyCollisionNodes);
setTerrainNode(loadedTerrainNode);
setRepairMissionAnchors(repairMissionAnchors);
setMapLoaded(true);
@@ -285,6 +295,7 @@ export function GameMap({
buildOctree={buildOctree}
mapReady={mapReady}
nodes={collisionMapNodes}
proxyNodes={proxyCollisionMapNodes}
onLoaded={onLoaded}
onLoadingStateChange={onLoadingStateChange}
onOctreeReady={onOctreeReady}
+196 -10
View File
@@ -17,9 +17,24 @@ import {
normalizeMapScale,
useTerrainHeightSampler,
} from "@/hooks/three/useTerrainHeight";
import {
CHARACTER_CONFIGS,
CHARACTER_IDS,
type CharacterId,
} from "@/data/world/characters/characterConfig";
import {
CHARACTER_OCTREE_COLLISION_BOX,
LA_FABRIK_INTERIOR_COLLISION_BOXES,
MAP_OCTREE_COLLISION_BOXES,
hasMapOctreeCollisionBox,
type OctreeCollisionBox,
} from "@/data/world/octreeCollisionConfig";
import { getMapModelScaleMultiplier } from "@/data/world/mapInstancingConfig";
import { useCharacterDebugStore } from "@/managers/stores/useCharacterDebugStore";
import { useGameStore } from "@/managers/stores/useGameStore";
import { WorldBoundsCollision } from "@/world/collision/WorldBoundsCollision";
import type { MapNode } from "@/types/map/mapScene";
import type { OctreeReadyHandler } from "@/types/three/three";
import type { OctreeReadyHandler, Vector3Tuple } from "@/types/three/three";
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
@@ -39,6 +54,7 @@ interface GameMapCollisionProps {
buildOctree?: boolean;
mapReady: boolean;
nodes: readonly GameMapCollisionNode[];
proxyNodes: readonly MapNode[];
onLoaded?: (() => void) | undefined;
onLoadingStateChange?: SceneLoadingChangeHandler | undefined;
onOctreeReady: OctreeReadyHandler;
@@ -101,6 +117,7 @@ export function GameMapCollision({
buildOctree = true,
mapReady,
nodes,
proxyNodes,
onLoaded,
onLoadingStateChange,
onOctreeReady,
@@ -109,10 +126,28 @@ export function GameMapCollision({
const settledCollisionNodesRef = useRef(new Set<number>());
const loadedNotifiedRef = useRef(false);
const [settledCollisionNodeCount, setSettledCollisionNodeCount] = useState(0);
const mainState = useGameStore((state) => state.mainState);
const terrainHeight = useTerrainHeightSampler();
const collisionNodes = nodes.filter(isCollisionNode);
const includeCharacterCollisions = mainState !== "ebike";
const characterCollisionCount = includeCharacterCollisions
? CHARACTER_IDS.length
: 0;
const collisionSourceCount =
collisionNodes.length + proxyNodes.length + characterCollisionCount;
const collisionReady =
mapReady && settledCollisionNodeCount >= collisionNodes.length;
const characterCollisionSignature = useCharacterDebugStore((state) =>
includeCharacterCollisions
? CHARACTER_IDS.map((id) => {
const character = state.characters[id];
return [...character.position, ...character.rotation].join(",");
}).join("|")
: "characters-hidden",
);
const collisionRebuildKey = collisionReady
? `${collisionNodes.length}:${collisionSourceCount}:${characterCollisionSignature}`
: "pending";
const notifyLoaded = useCallback(() => {
if (loadedNotifiedRef.current) return;
@@ -144,14 +179,14 @@ export function GameMapCollision({
useOctreeGraphNode(
groupRef,
handleOctreeReady,
collisionReady ? collisionNodes.length : 0,
buildOctree && collisionReady && collisionNodes.length > 0,
collisionRebuildKey,
buildOctree && collisionReady && collisionSourceCount > 0,
);
useEffect(() => {
if (!mapReady) return;
if (collisionNodes.length === 0) {
if (collisionSourceCount === 0) {
notifyLoaded();
return;
}
@@ -171,6 +206,7 @@ export function GameMapCollision({
}, [
buildOctree,
collisionNodes.length,
collisionSourceCount,
collisionReady,
mapReady,
notifyLoaded,
@@ -180,6 +216,18 @@ export function GameMapCollision({
return (
<group ref={groupRef} visible={false}>
{mapReady ? <WorldBoundsCollision /> : null}
{mapReady
? proxyNodes.map((node, index) => (
<MapCollisionBoxProxy
key={`proxy-collision-${index}`}
node={node}
terrainHeight={terrainHeight}
/>
))
: null}
{mapReady && includeCharacterCollisions ? (
<CharacterCollisionProxies terrainHeight={terrainHeight} />
) : null}
{mapReady
? collisionNodes.map((mapNode, index) => (
<CollisionErrorBoundary
@@ -223,6 +271,24 @@ function CollisionModelInstance({
scale: normalizedScale,
});
const sceneInstance = useClonedObject(scene);
useEffect(() => {
if (node.name !== "lafabrik") return;
const isDoorSlab = (name: string): boolean =>
name === "porte" || /^porte[._]\d+$/i.test(name);
const isDoorFrameThickenChild = (child: THREE.Object3D): boolean =>
child.parent?.name === "Thicken";
const doorMeshes: THREE.Object3D[] = [];
sceneInstance.traverse((child) => {
if (isDoorSlab(child.name) || isDoorFrameThickenChild(child)) {
doorMeshes.push(child);
}
});
for (const child of doorMeshes) {
child.removeFromParent();
}
}, [node.name, sceneInstance]);
const collisionPosition = useMemo(() => {
if (node.name === "terrain") return position;
@@ -237,11 +303,131 @@ function CollisionModelInstance({
}, [onLoaded]);
return (
<primitive
object={sceneInstance}
position={collisionPosition}
rotation={rotation}
scale={normalizedScale}
/>
<>
<primitive
object={sceneInstance}
position={collisionPosition}
rotation={rotation}
scale={normalizedScale}
/>
{node.name === "lafabrik" ? (
<group
name="lafabrik-interior-collision-proxies"
position={collisionPosition}
rotation={rotation}
scale={normalizedScale}
>
{LA_FABRIK_INTERIOR_COLLISION_BOXES.map((box, index) => (
<CollisionBox key={`lafabrik-interior-${index}`} box={box} />
))}
</group>
) : null}
</>
);
}
function CollisionBox({ box }: { box: OctreeCollisionBox }): React.JSX.Element {
return (
<group position={box.center}>
<mesh>
<boxGeometry args={box.size} />
<meshBasicMaterial />
</mesh>
<mesh rotation={[0, Math.PI, 0]}>
<boxGeometry args={box.size} />
<meshBasicMaterial />
</mesh>
</group>
);
}
function createScaledMapNodeScale(node: MapNode): Vector3Tuple {
const baseScale = normalizeMapScale(node.scale);
const scaleMultiplier = getMapModelScaleMultiplier(node.name);
return [
baseScale[0] * scaleMultiplier,
baseScale[1] * scaleMultiplier,
baseScale[2] * scaleMultiplier,
];
}
function MapCollisionBoxProxy({
node,
terrainHeight,
}: {
node: MapNode;
terrainHeight: TerrainHeightSampler;
}): React.JSX.Element | null {
const collisionBox = hasMapOctreeCollisionBox(node.name)
? MAP_OCTREE_COLLISION_BOXES[node.name]
: null;
const normalizedScale = useMemo(() => createScaledMapNodeScale(node), [node]);
const position = useMemo(() => {
const [x, y, z] = node.position;
if (!collisionBox) return [x, y, z] satisfies Vector3Tuple;
const height = terrainHeight.getHeight(x, z);
const bottomOffset = -collisionBox.bottomY * normalizedScale[1];
return [x, (height ?? y) + bottomOffset, z] satisfies Vector3Tuple;
}, [collisionBox, node.position, normalizedScale, terrainHeight]);
if (!collisionBox) return null;
return (
<group
name={`${node.name}-octree-collision-proxy`}
position={position}
rotation={node.rotation}
scale={normalizedScale}
>
<CollisionBox box={collisionBox} />
</group>
);
}
function CharacterCollisionProxies({
terrainHeight,
}: {
terrainHeight: TerrainHeightSampler;
}): React.JSX.Element {
return (
<>
{CHARACTER_IDS.map((id) => (
<CharacterCollisionProxy
key={`character-collision-${id}`}
id={id}
terrainHeight={terrainHeight}
/>
))}
</>
);
}
function CharacterCollisionProxy({
id,
terrainHeight,
}: {
id: CharacterId;
terrainHeight: TerrainHeightSampler;
}): React.JSX.Element {
const config = CHARACTER_CONFIGS[id];
const state = useCharacterDebugStore((store) => store.characters[id]);
const position = useMemo(() => {
const [x, y, z] = state.position;
const height = terrainHeight.getHeight(x, z);
return [x, height ?? y, z] satisfies Vector3Tuple;
}, [state.position, terrainHeight]);
return (
<group
name={`${config.id}-octree-collision-proxy`}
position={position}
rotation={state.rotation}
>
<CollisionBox box={CHARACTER_OCTREE_COLLISION_BOX} />
</group>
);
}
+7 -1
View File
@@ -20,7 +20,13 @@ import { isPylonNarrativeStep } from "@/types/gameplay/repairMission";
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;
+72 -31
View File
@@ -1,10 +1,17 @@
import { useEffect, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import type { AmbientLight, DirectionalLight, Object3D } from "three";
import {
PCFShadowMap,
type AmbientLight,
type DirectionalLight,
type Object3D,
type WebGLRenderer,
} from "three";
import {
AMBIENT_INTENSITY_MAX,
AMBIENT_INTENSITY_MIN,
AMBIENT_INTENSITY_STEP,
SHADOW_CONFIG,
SUN_INTENSITY_MAX,
SUN_INTENSITY_MIN,
SUN_INTENSITY_STEP,
@@ -18,16 +25,51 @@ 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 { useShadowMapWarmup } from "@/hooks/three/useShadowMapWarmup";
import { LIGHTING_STATE } from "@/world/lightingState";
const SHADOW_MAP_SIZE = 2048;
const SHADOW_CAMERA_SIZE = 95;
const SHADOW_CAMERA_NEAR = 0.5;
const SHADOW_CAMERA_FAR = 300;
function configureRendererShadows(gl: WebGLRenderer): void {
gl.shadowMap.enabled = true;
gl.shadowMap.type = PCFShadowMap;
gl.shadowMap.autoUpdate = true;
}
function configureSunShadow(sun: DirectionalLight, sunTarget: Object3D): void {
sun.target = sunTarget;
sun.shadow.autoUpdate = true;
sun.shadow.bias = SHADOW_CONFIG.bias;
sun.shadow.normalBias = SHADOW_CONFIG.normalBias;
sun.shadow.mapSize.width = SHADOW_CONFIG.mapSize;
sun.shadow.mapSize.height = SHADOW_CONFIG.mapSize;
sun.shadow.camera.left = -SHADOW_CONFIG.cameraSize;
sun.shadow.camera.right = SHADOW_CONFIG.cameraSize;
sun.shadow.camera.top = SHADOW_CONFIG.cameraSize;
sun.shadow.camera.bottom = -SHADOW_CONFIG.cameraSize;
sun.shadow.camera.near = SHADOW_CONFIG.cameraNear;
sun.shadow.camera.far = SHADOW_CONFIG.cameraFar;
sun.shadow.camera.updateProjectionMatrix();
}
function placeSunRelativeToCamera(
sun: DirectionalLight,
sunTarget: Object3D,
cameraPosition: { x: number; z: number },
): void {
sunTarget.position.set(cameraPosition.x, 0, cameraPosition.z);
sun.position.set(
cameraPosition.x + LIGHTING_STATE.sunX,
LIGHTING_STATE.sunY,
cameraPosition.z + LIGHTING_STATE.sunZ,
);
}
export function Lighting(): React.JSX.Element {
const camera = useThree((state) => state.camera);
const gl = useThree((state) => state.gl);
const scene = useThree((state) => state.scene);
const invalidate = useThree((state) => state.invalidate);
const ambient = useRef<AmbientLight>(null);
const sun = useRef<DirectionalLight>(null);
const sunTarget = useRef<Object3D>(null);
@@ -35,19 +77,16 @@ export function Lighting(): React.JSX.Element {
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();
}, []);
configureRendererShadows(gl);
configureSunShadow(sun.current, sunTarget.current);
// Prime the sun + target onto the camera before the first shadow pass so
// the initial shadow frustum already covers the visible scene; without
// this, the first frame is rendered with the default (origin-centered)
// frustum and shadows can appear absent until the player moves.
placeSunRelativeToCamera(sun.current, sunTarget.current, camera.position);
}, [camera, gl]);
useShadowMapWarmup({ light: sun, scene, gl, invalidate });
useDebugFolder("Lighting", (folder) => {
folder.addColor(LIGHTING_STATE, "ambientColor").name("Ambient Color");
@@ -87,19 +126,14 @@ export function Lighting(): React.JSX.Element {
ambient.current.intensity = LIGHTING_STATE.ambientIntensity;
}
if (sun.current && sunTarget.current) {
sunTarget.current.position.set(camera.position.x, 0, camera.position.z);
sunTarget.current.updateMatrixWorld();
sun.current.position.set(
camera.position.x + LIGHTING_STATE.sunX,
LIGHTING_STATE.sunY,
camera.position.z + LIGHTING_STATE.sunZ,
);
sun.current.color.set(LIGHTING_STATE.sunColor);
sun.current.intensity = LIGHTING_STATE.sunIntensity;
sun.current.updateMatrixWorld();
sun.current.shadow.needsUpdate = true;
}
if (!sun.current || !sunTarget.current) return;
placeSunRelativeToCamera(sun.current, sunTarget.current, camera.position);
sunTarget.current.updateMatrixWorld();
sun.current.color.set(LIGHTING_STATE.sunColor);
sun.current.intensity = LIGHTING_STATE.sunIntensity;
sun.current.updateMatrixWorld();
sun.current.shadow.needsUpdate = true;
});
return (
@@ -121,6 +155,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}
/>
</>
);
}
-92
View File
@@ -1,92 +0,0 @@
import { useEffect, useRef } from "react";
import { useThree } from "@react-three/fiber";
import * as THREE from "three";
interface SceneShadowWarmupProps {
active: boolean;
onReady: () => void;
onStarted: () => void;
}
function markShadowLightForUpdate(object: THREE.Object3D): void {
if (
!(
object instanceof THREE.DirectionalLight ||
object instanceof THREE.PointLight ||
object instanceof THREE.SpotLight
)
) {
return;
}
if (!object.castShadow) return;
object.updateMatrixWorld(true);
object.shadow.camera.updateProjectionMatrix();
object.shadow.needsUpdate = true;
}
function forceSceneShadowPass(
gl: THREE.WebGLRenderer,
scene: THREE.Scene,
): void {
gl.shadowMap.enabled = true;
gl.shadowMap.type = THREE.PCFShadowMap;
gl.shadowMap.autoUpdate = true;
gl.shadowMap.needsUpdate = true;
scene.updateMatrixWorld(true);
scene.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.updateMatrixWorld(true);
}
markShadowLightForUpdate(object);
});
}
export function SceneShadowWarmup({
active,
onReady,
onStarted,
}: SceneShadowWarmupProps): null {
const gl = useThree((state) => state.gl);
const scene = useThree((state) => state.scene);
const invalidate = useThree((state) => state.invalidate);
const isRunningRef = useRef(false);
useEffect(() => {
if (!active) {
isRunningRef.current = false;
return undefined;
}
if (isRunningRef.current) return undefined;
isRunningRef.current = true;
onStarted();
forceSceneShadowPass(gl, scene);
invalidate();
let firstFrame = 0;
let secondFrame = 0;
firstFrame = window.requestAnimationFrame(() => {
forceSceneShadowPass(gl, scene);
invalidate();
secondFrame = window.requestAnimationFrame(() => {
forceSceneShadowPass(gl, scene);
invalidate();
onReady();
});
});
return () => {
window.cancelAnimationFrame(firstFrame);
window.cancelAnimationFrame(secondFrame);
};
}, [active, gl, invalidate, onReady, onStarted, scene]);
return null;
}
+48 -19
View File
@@ -4,17 +4,22 @@ 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";
import { useCharacterDebug } from "@/hooks/debug/useCharacterDebug";
import { usePlayerPositionDebug } from "@/hooks/debug/usePlayerPositionDebug";
import { useDebugVisualsDebug } from "@/hooks/debug/useDebugVisualsDebug";
import { useSceneMode } from "@/hooks/debug/useSceneMode";
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
import { useWorldSceneLoading } from "@/hooks/world/useWorldSceneLoading";
import { useGameStore } from "@/managers/stores/useGameStore";
import { useDebugVisualsStore } from "@/managers/stores/useDebugVisualsStore";
import { DebugCameraControls } from "@/components/debug/scene/DebugCameraControls";
import { DebugHelpers } from "@/components/debug/scene/DebugHelpers";
import { DebugOctreeVisualization } from "@/components/debug/DebugOctreeVisualization";
import { DebugPlayerModel } from "@/components/debug/DebugPlayerModel";
import { HandTrackingGlove } from "@/components/three/handTracking/HandTrackingGlove";
import { Environment } from "@/world/Environment";
import { GameCinematics } from "@/world/GameCinematics";
@@ -27,21 +32,35 @@ import { CharacterSystem } from "@/world/characters/CharacterSystem";
import { Player } from "@/world/player/Player";
import { TestMap } from "@/world/debug/TestMap";
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
import type { HandTrackingGloveHandedness } from "@/hooks/handTracking/useHandTrackingGloveStatus";
import type { HandTrackingHand } from "@/types/handTracking/handTracking";
interface WorldProps {
onLoadingStateChange?: SceneLoadingChangeHandler | undefined;
}
function hasTrackedHand(
hands: HandTrackingHand[],
handedness: HandTrackingGloveHandedness,
): boolean {
return hands.some((hand) => hand.handedness.toLowerCase() === handedness);
}
export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
useEnvironmentDebug();
useMapPerformanceDebug();
useCharacterDebug();
usePlayerPositionDebug();
useDebugVisualsDebug();
const cameraMode = useCameraMode();
const sceneMode = useSceneMode();
const mainState = useGameStore((state) => state.mainState);
const { status, usageStatus } = useHandTrackingSnapshot();
const showDebugPlayerModel = useDebugVisualsStore(
(state) => state.showPlayerModel,
);
const showDebugOctree = useDebugVisualsStore((state) => state.showOctree);
const { hands, status, usageStatus } = useHandTrackingSnapshot();
const {
octree,
gameplayReady,
@@ -49,36 +68,40 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
handleGameStageLoaded,
handleGameMapLoaded,
handleOctreeReady,
handleShadowWarmupReady,
handleShadowWarmupStarted,
shouldWarmUpShadows,
} = useWorldSceneLoading({ sceneMode, onLoadingStateChange });
const playerSpawnPosition =
sceneMode === "game"
? PLAYER_SPAWN_POSITION_GAME
: PLAYER_SPAWN_POSITION_PHYSICS;
const showHandTrackingGloves =
sceneMode === "physics" ||
(status !== "idle" && usageStatus !== "inactive");
status === "connected" && usageStatus !== "inactive" && hands.length > 0;
const showLeftHandTrackingGlove =
showHandTrackingGloves && hasTrackedHand(hands, "left");
const showRightHandTrackingGlove =
showHandTrackingGloves && hasTrackedHand(hands, "right");
const spawnPlayer =
cameraMode !== "debug" &&
(sceneMode === "game" ? gameplayReady : octree !== null);
return (
<>
<Environment
shadowWarmup={{
active: shouldWarmUpShadows,
onReady: handleShadowWarmupReady,
onStarted: handleShadowWarmupStarted,
}}
/>
<Environment />
<Lighting />
<DebugHelpers />
{showDebugOctree ? <DebugOctreeVisualization octree={octree} /> : null}
{showDebugPlayerModel ? (
<Suspense fallback={null}>
<DebugPlayerModel />
</Suspense>
) : null}
{showHandTrackingGloves ? (
<Suspense fallback={null}>
<HandTrackingGlove handedness="left" />
<HandTrackingGlove handedness="right" />
{showLeftHandTrackingGlove ? (
<HandTrackingGlove handedness="left" />
) : null}
{showRightHandTrackingGlove ? (
<HandTrackingGlove handedness="right" />
) : null}
</Suspense>
) : null}
{cameraMode === "debug" ? <DebugCameraControls /> : null}
@@ -93,16 +116,22 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
{showGameStage ? (
<Physics>
<GameStageLoaded onLoaded={handleGameStageLoaded} />
<GameStageContent />
<Suspense fallback={null}>
<GameStageContent />
</Suspense>
</Physics>
) : null}
{spawnPlayer ? (
<>
<Suspense fallback={null}>
<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}
/>
</Suspense>
) : null}
</>
) : (
+10 -15
View File
@@ -17,6 +17,8 @@ import {
TEST_SCENE_GRABBABLE_METALNESS,
TEST_SCENE_GRABBABLE_POSITION,
TEST_SCENE_GRABBABLE_ROUGHNESS,
TEST_SCENE_GPS_PREVIEW_POSITION,
TEST_SCENE_GPS_PREVIEW_ROTATION,
GAME_REPAIR_ZONES,
TEST_SCENE_REPAIR_ZONE_MARKER_RADIUS,
TEST_SCENE_REPAIR_ZONE_MARKER_TUBE_RADIUS,
@@ -110,24 +112,17 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
try {
const parsed = JSON.parse(saved);
if (Array.isArray(parsed) && parsed.length > 0) {
console.log(
`[TestMap] ${parsed.length} waypoints chargés depuis localStorage.`,
);
// Schedule state update to avoid synchronous setState in effect
queueMicrotask(() => {
if (!cancelled) setWaypoints(parsed);
});
return;
}
} catch (e) {
console.error("Failed to parse local storage waypoints", e);
} catch {
// Ignore parse errors — fall through to fetch fallback
}
}
// 2. Try public/roadNetwork.json
console.log(
"[TestMap] Tentative de chargement depuis /roadNetwork.json...",
);
fetch("/roadNetwork.json")
.then((res) => {
if (res.ok) return res.json();
@@ -136,14 +131,11 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
.then((data) => {
if (cancelled) return;
if (Array.isArray(data)) {
console.log(
`[TestMap] ${data.length} waypoints chargés depuis /roadNetwork.json.`,
);
setWaypoints(data);
}
})
.catch((err) => {
console.log("[TestMap] Aucun point d'A* trouvé par défaut.", err);
.catch(() => {
// No A* waypoints available — silent fallback
});
return () => {
@@ -253,7 +245,10 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
</Physics>
{/* Dynamic Futuristic 3D GPS Dashboard Preview */}
<group position={[0, 2.8, -4.8]} rotation={[0, 0, 0]}>
<group
position={TEST_SCENE_GPS_PREVIEW_POSITION}
rotation={TEST_SCENE_GPS_PREVIEW_ROTATION}
>
{/* Futuristic glowing screen frame (commented out to show true 3D transparency!) */}
{/*
<mesh>
+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;
@@ -149,6 +149,7 @@ export function MapInstancingSystem({
const streamingEnabled =
streaming &&
CHUNK_CONFIG.enabled &&
graphicsPresetConfig.chunkStreamingEnabled &&
sceneMode === "game" &&
cameraMode === "player";
+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}
/>
</>
);
}
+13 -2
View File
@@ -1,18 +1,29 @@
import { useEffect } from "react";
import { useThree } from "@react-three/fiber";
import { PointerLockControls } from "@react-three/drei";
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
import { setGlobalCamera } from "@/world/GameCinematics";
export function PlayerCamera(): React.JSX.Element {
const camera = useThree((state) => state.camera);
const isSettingsMenuOpen = useSettingsStore(
(state) => state.isSettingsMenuOpen,
);
useEffect(() => {
setGlobalCamera(camera);
return () => {
setGlobalCamera(null);
document.exitPointerLock();
if (document.pointerLockElement) {
document.exitPointerLock();
}
};
}, [camera]);
return <PointerLockControls />;
return (
<PointerLockControls
enabled={!isSettingsMenuOpen}
selector="#game-canvas"
/>
);
}
+24 -15
View File
@@ -33,8 +33,8 @@ import {
EBIKE_ACCELERATION_DURATION_MS,
EBIKE_CAMERA_TRANSFORM,
EBIKE_DECELERATION_DURATION_MS,
EBIKE_MAX_SPEED,
} from "@/data/ebike/ebikeConfig";
import { useSceneMode } from "@/hooks/debug/useSceneMode";
/** Global window properties used for ebike communication */
interface EbikeGlobalState {
@@ -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,10 +148,12 @@ function getCapsuleFootY(capsule: Capsule): number {
}
export function PlayerController({
initialLookAt,
octree,
spawnPosition,
}: PlayerControllerProps): null {
const camera = useThree((state) => state.camera);
const sceneMode = useSceneMode();
const movementLocked = useRepairMovementLocked();
const terrainHeight = useTerrainHeightSampler();
const movementLockedRef = useRef(movementLocked);
@@ -234,6 +239,7 @@ export function PlayerController({
resetPlayerCapsule(
capsule.current,
spawnPosition,
initialLookAt,
camera,
velocity.current,
);
@@ -241,7 +247,7 @@ export function PlayerController({
onFloor.current = false;
wantsJump.current = false;
initializedRef.current = true;
}, [camera, spawnPosition]);
}, [camera, initialLookAt, spawnPosition]);
useEffect(() => {
movementLockedRef.current = movementLocked;
@@ -339,6 +345,7 @@ export function PlayerController({
resetPlayerCapsule(
capsule.current,
spawnPosition,
initialLookAt,
camera,
velocity.current,
);
@@ -409,7 +416,7 @@ export function PlayerController({
}
const movementSpeed = isEbikeMounted
? EBIKE_MAX_SPEED * ebikeSpeedFactor.current
? currentSpeed * ebikeSpeedFactor.current
: currentSpeed;
const accel = onFloor.current
? movementSpeed
@@ -478,19 +485,21 @@ export function PlayerController({
}
}
const groundHeight = terrainHeight.getHeight(
capsule.current.end.x,
capsule.current.end.z,
);
if (groundHeight !== null && velocity.current.y <= 0) {
const groundOffset = getCapsuleFootY(capsule.current) - groundHeight;
if (sceneMode === "game") {
const groundHeight = terrainHeight.getHeight(
capsule.current.end.x,
capsule.current.end.z,
);
if (groundHeight !== null && velocity.current.y <= 0) {
const groundOffset = getCapsuleFootY(capsule.current) - groundHeight;
if (groundOffset <= PLAYER_GROUND_SNAP_DISTANCE) {
capsule.current.translate(
_collisionCorrection.set(0, -groundOffset, 0),
);
velocity.current.y = 0;
onFloor.current = true;
if (groundOffset <= PLAYER_GROUND_SNAP_DISTANCE) {
capsule.current.translate(
_collisionCorrection.set(0, -groundOffset, 0),
);
velocity.current.y = 0;
onFloor.current = true;
}
}
}
+126 -16
View File
@@ -1,8 +1,24 @@
import { Suspense, useMemo } from "react";
import {
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { CHUNK_CONFIG } from "@/data/world/chunkStreamingConfig";
import {
getMapLodModelPath,
getMapLodScaleMultiplier,
selectMapModelPathByDistance,
} from "@/data/world/mapLodConfig";
import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useSceneMode } from "@/hooks/debug/useSceneMode";
import { useGraphicsPresetConfig } from "@/hooks/world/useGraphicsSettings";
import {
useGraphicsPreset,
useGraphicsPresetConfig,
} from "@/hooks/world/useGraphicsSettings";
import { useVisibleWorldChunks } from "@/hooks/world/useVisibleWorldChunks";
import {
isMapModelVisible,
@@ -16,7 +32,9 @@ import {
VEGETATION_TYPES,
type VegetationType,
} from "@/data/world/vegetationConfig";
import { isInsideLaFabrikFootprint } from "@/data/world/laFabrikConfig";
import { createWorldInstanceChunks } from "@/utils/world/chunkInstances";
import type { GraphicsPreset } from "@/data/world/graphicsConfig";
interface VegetationSystemProps {
onlyMapName?: string | null;
@@ -60,12 +78,87 @@ function createVegetationChunks(
});
}
function removeLaFabrikVegetation(
instances: VegetationInstance[],
): VegetationInstance[] {
return instances.filter((instance) => {
const [x, , z] = instance.position;
return !isInsideLaFabrikFootprint(x, z, 1.2);
});
}
function areChunkModelPathsEqual(
a: ReadonlyMap<string, string>,
b: ReadonlyMap<string, string>,
): boolean {
return (
a.size === b.size && [...a].every(([key, value]) => b.get(key) === value)
);
}
function useVegetationChunkModelPaths(
chunks: readonly VegetationChunk[],
preset: GraphicsPreset,
): ReadonlyMap<string, string> {
const camera = useThree((state) => state.camera);
const lastUpdateRef = useRef(-CHUNK_CONFIG.updateInterval);
const modelPathsRef = useRef<Map<string, string>>(new Map());
const [modelPaths, setModelPaths] = useState<ReadonlyMap<string, string>>(
() => new Map(),
);
const updateModelPaths = useCallback(() => {
const cameraX = camera.position.x;
const cameraZ = camera.position.z;
const next = new Map<string, string>();
for (const chunk of chunks) {
let nearestDistance = Number.POSITIVE_INFINITY;
for (const instance of chunk.instances) {
const distance = Math.hypot(
instance.position[0] - cameraX,
instance.position[2] - cameraZ,
);
if (distance < nearestDistance) nearestDistance = distance;
}
const modelPath = selectMapModelPathByDistance({
distance: nearestDistance,
modelName: VEGETATION_TYPES[chunk.type].mapName,
modelPath: chunk.modelPath,
preset,
});
next.set(chunk.key, modelPath);
}
if (areChunkModelPathsEqual(next, modelPathsRef.current)) return;
modelPathsRef.current = next;
setModelPaths(next);
}, [camera, chunks, preset]);
useEffect(() => {
updateModelPaths();
}, [updateModelPaths]);
useFrame(({ clock }) => {
const now = clock.elapsedTime * 1000;
if (now - lastUpdateRef.current < CHUNK_CONFIG.updateInterval) return;
lastUpdateRef.current = now;
updateModelPaths();
});
return modelPaths;
}
export function VegetationSystem({
onlyMapName = null,
streaming = true,
}: VegetationSystemProps): React.JSX.Element | null {
const cameraMode = useCameraMode();
const sceneMode = useSceneMode();
const graphicsPresetKey = useGraphicsPreset();
const graphicsPreset = useGraphicsPresetConfig();
const groups = useMapPerformanceStore((state) => state.groups);
const models = useMapPerformanceStore((state) => state.models);
@@ -73,6 +166,7 @@ export function VegetationSystem({
const streamingEnabled =
streaming &&
CHUNK_CONFIG.enabled &&
graphicsPreset.chunkStreamingEnabled &&
sceneMode === "game" &&
cameraMode === "player";
@@ -90,7 +184,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]);
@@ -99,25 +196,38 @@ export function VegetationSystem({
unloadRadius: graphicsPreset.chunkUnloadRadius,
});
const chunkModelPaths = useVegetationChunkModelPaths(
visibleChunks,
graphicsPresetKey,
);
if (isLoading || !data) {
return null;
}
return (
<group name="vegetation-system">
{visibleChunks.map((chunk) => (
<Suspense key={chunk.key} fallback={null}>
<InstancedVegetation
modelPath={chunk.modelPath}
instances={chunk.instances}
scaleMultiplier={chunk.scaleMultiplier}
castShadow={chunk.castShadow}
receiveShadow={chunk.receiveShadow}
windStrength={chunk.windStrength}
rotationOffset={chunk.rotationOffset}
/>
</Suspense>
))}
{visibleChunks.map((chunk) => {
const modelPath = chunkModelPaths.get(chunk.key) ?? chunk.modelPath;
const mapName = VEGETATION_TYPES[chunk.type].mapName;
const isLod = modelPath === getMapLodModelPath(mapName);
const scaleMultiplier =
chunk.scaleMultiplier *
(isLod ? getMapLodScaleMultiplier(mapName) : 1);
return (
<Suspense key={`${chunk.key}:${modelPath}`} fallback={null}>
<InstancedVegetation
modelPath={modelPath}
instances={chunk.instances}
scaleMultiplier={scaleMultiplier}
castShadow={chunk.castShadow}
receiveShadow={chunk.receiveShadow}
windStrength={chunk.windStrength}
rotationOffset={chunk.rotationOffset}
/>
</Suspense>
);
})}
</group>
);
}