Merge branch 'develop' into feat/polish-mission-2
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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]} />
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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;
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -19,7 +19,7 @@ export const CLOUD_DEFAULTS = {
|
||||
maxRotation: Math.PI * 2,
|
||||
minSpeedMultiplier: 0.4,
|
||||
maxSpeedMultiplier: 1,
|
||||
castShadow: false,
|
||||
castShadow: true,
|
||||
receiveShadow: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user