Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c35090dbd | |||
| a766784ce8 | |||
| 63952912b5 | |||
| fd0b9e2749 | |||
| 777e51efeb | |||
| 1ad0c4de37 | |||
| 7a378afad3 | |||
| d52ec7e5a9 | |||
| 153833deec | |||
| b617885aa2 | |||
| 5d2e7e2aab | |||
| de77f76d48 | |||
| bdc704fe8e | |||
| bce7d11b66 | |||
| 8aa755da7a | |||
| 6d58b90856 | |||
| bafca5a936 | |||
| dcf3a8564c | |||
| bc862960a7 | |||
| 597ebcfbd4 | |||
| aa2d411b0c | |||
| 061e0dc677 | |||
| 9ef94af488 | |||
| 27b4a2c392 | |||
| d5feb07ff0 | |||
| c33d973f12 | |||
| 396e7e4ff0 |
@@ -25,7 +25,7 @@ Current behavior:
|
||||
| -------- | ------------------: | --- | ------------------------------------- |
|
||||
| `low` | 10m | On | Always use `*-LOD` models |
|
||||
| `medium` | 20m | On | Always use `*-LOD` models |
|
||||
| `high` | Current default 50m | Off | Regular model up to 10m, then `*-LOD` |
|
||||
| `high` | 35m | Off | Regular model up to 10m, then `*-LOD` |
|
||||
| `ultra` | 50m | Off | Regular model up to 20m, then `*-LOD` |
|
||||
|
||||
The unload distance stays slightly larger than the load distance to avoid rapid mount/unmount flickering when the player stands near a boundary.
|
||||
|
||||
@@ -158,9 +158,11 @@ Current runtime values:
|
||||
|
||||
```txt
|
||||
chunkSize: 35
|
||||
loadRadius: 45
|
||||
unloadRadius: 45
|
||||
updateInterval: 350ms
|
||||
low load/unload radius: 10m / 18m
|
||||
medium load/unload radius: 20m / 30m
|
||||
high load/unload radius: 35m / 45m
|
||||
ultra load/unload radius: 50m / 65m
|
||||
updateInterval: 250ms
|
||||
fog near: 30
|
||||
fog far: 45
|
||||
```
|
||||
|
||||
@@ -74,22 +74,32 @@ It tracks:
|
||||
- `gameMapLoaded`: map data and visible map nodes settled
|
||||
- `gameStageLoaded`: Rapier gameplay stage mounted
|
||||
- `showGameStage`: true when the map is ready enough to mount gameplay content
|
||||
- `shadowsReady`: renderer, shadow lights, and scene matrices have been forced once after the scene is mounted
|
||||
- `gameplayReady`: true when map, stage, octree, and the shadow warmup are all ready
|
||||
- `gameplayReady`: true when map, stage, and octree are all ready
|
||||
|
||||
The base game-scene readiness condition before the shadow warmup is:
|
||||
The game-scene readiness condition is:
|
||||
|
||||
```ts
|
||||
showGameStage && gameStageLoaded && octree !== null;
|
||||
```
|
||||
|
||||
After that condition is met, `SceneShadowWarmup` runs one final loading step:
|
||||
Shadows are configured once when `Lighting` mounts (renderer `shadowMap.enabled`, sun
|
||||
`shadow.autoUpdate = true`, bias and frustum from `SHADOW_CONFIG` in
|
||||
`src/data/world/lightingConfig.ts`). The shadow map then refreshes every frame and
|
||||
follows the player camera through the sun's `target`. The earlier `SceneShadowWarmup`
|
||||
step has been removed — the visible loading overlay no longer waits for a forced
|
||||
shadow refresh because `autoUpdate` covers steady-state rendering.
|
||||
|
||||
```txt
|
||||
Activation des ombres -> Ombres prêtes -> Gameplay prêt
|
||||
```
|
||||
### Avoiding global scene remounts
|
||||
|
||||
This keeps the loading overlay visible until the renderer shadow map, shadow-casting light, and mounted scene graph have all been explicitly refreshed.
|
||||
Heavy stage components (`GameStageContent`, `Player`, dialogues) load assets via
|
||||
`useGLTF`/`useTexture` without preload (e.g. `EbikeSpeedometer` calls `useTexture`
|
||||
when the bike mounts). To prevent any late suspension from bubbling up to the
|
||||
root `<Suspense>` boundary in `src/pages/page.tsx` and unmounting the entire
|
||||
world (which would trigger a redundant octree rebuild and shadow re-config), the
|
||||
game stage block and the spawn-player block are wrapped in their own
|
||||
`<Suspense fallback={null}>` boundaries inside `src/world/World.tsx`. Any new
|
||||
sibling that suspends late should be added inside one of these boundaries or get
|
||||
its own.
|
||||
|
||||
The debug physics scene is ready when:
|
||||
|
||||
|
||||
@@ -20,3 +20,50 @@ If DevTools still opens a bundled file, stop the dev server, clear Vite's cached
|
||||
rm -rf node_modules/.vite
|
||||
npm run dev:three-debug
|
||||
```
|
||||
|
||||
## Visual debug toggles
|
||||
|
||||
The `Debug` folder of the runtime debug GUI exposes inspection toggles backed by
|
||||
`src/managers/stores/useDebugVisualsStore.ts`:
|
||||
|
||||
- **Show Player Model** — renders the main character GLTF in front of the
|
||||
current camera (`src/components/debug/DebugPlayerModel.tsx`). The model is
|
||||
positioned in camera-local space so it stays visible regardless of pitch.
|
||||
- **Show Octree** — overlays the collision octree as colored line segments,
|
||||
one wireframe per spatial cell (`src/components/debug/DebugOctreeVisualization.tsx`).
|
||||
Cells are colored by depth. Use it to inspect collision precision around
|
||||
doorways or passages.
|
||||
- **Octree Max Depth** — caps how deep the octree visualization recurses
|
||||
(default 6). Increase to see leaf-level subdivisions; decrease to keep the
|
||||
scene readable when the tree is large.
|
||||
|
||||
The octree visualization reads the live `Octree` instance from `World`. The
|
||||
mesh uses `depthTest: false` and a high `renderOrder`, so cells stay visible
|
||||
through opaque geometry.
|
||||
|
||||
## Shadow rendering intermittence (open investigation)
|
||||
|
||||
Shadows occasionally fail to render on initial load even though the
|
||||
`Lighting` configuration runs to completion (verified through diagnostic logs).
|
||||
The issue is not deterministic across runs with identical config. Suspected
|
||||
contributors:
|
||||
|
||||
- WebGL context restoration timing (`webglcontextrestored` rebinds shadow map
|
||||
state in `src/pages/page.tsx`).
|
||||
- First-frame shadow map being rendered before any mesh has its
|
||||
`castShadow`/`receiveShadow` flag set; `autoUpdate=true` should fix it on the
|
||||
next frame, but a single dropped frame is still visible at very first paint.
|
||||
- HMR/state interactions in dev mode that do not occur in production builds.
|
||||
|
||||
Mitigations already applied:
|
||||
|
||||
- Shadow config centralized in `src/data/world/lightingConfig.ts`
|
||||
(`bias=0`, `normalBias=0`, `cameraSize=95`, matching the historically working
|
||||
values from `develop`).
|
||||
- Late-suspension Suspense boundaries in `World.tsx` to prevent global scene
|
||||
remounts that would re-run shadow setup mid-load.
|
||||
|
||||
If the issue reproduces in production, capture a screenshot plus the
|
||||
`[diag]`-style logs from `useOctreeGraphNode`, `Lighting`, and `GameMapCollision`
|
||||
to confirm whether the third configuration pass is happening (which would
|
||||
indicate a remaining suspending hook outside the existing Suspense boundaries).
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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,137 @@
|
||||
import { useMemo } from "react";
|
||||
import { Box3, BufferAttribute, BufferGeometry, Color } from "three";
|
||||
import type { Octree } from "three-stdlib";
|
||||
import { useDebugVisualsStore } from "@/managers/stores/useDebugVisualsStore";
|
||||
|
||||
interface DebugOctreeVisualizationProps {
|
||||
octree: Octree | null;
|
||||
}
|
||||
|
||||
interface OctreeNodeBox {
|
||||
box: Box3;
|
||||
depth: number;
|
||||
triangleCount: number;
|
||||
}
|
||||
|
||||
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 collectOctreeBoxes(
|
||||
node: Octree,
|
||||
maxDepth: number,
|
||||
depth = 0,
|
||||
acc: OctreeNodeBox[] = [],
|
||||
): OctreeNodeBox[] {
|
||||
if (depth > maxDepth) return acc;
|
||||
|
||||
acc.push({
|
||||
box: node.box,
|
||||
depth,
|
||||
triangleCount: node.triangles.length,
|
||||
});
|
||||
|
||||
for (const sub of node.subTrees) {
|
||||
collectOctreeBoxes(sub, maxDepth, 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 colorsBuffer = 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;
|
||||
let colorsOffset = 0;
|
||||
const colorHelper = new Color();
|
||||
|
||||
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];
|
||||
|
||||
const hue = (node.depth * 0.13) % 1;
|
||||
colorHelper.setHSL(hue, 0.85, 0.55);
|
||||
|
||||
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];
|
||||
|
||||
colorsBuffer[colorsOffset++] = colorHelper.r;
|
||||
colorsBuffer[colorsOffset++] = colorHelper.g;
|
||||
colorsBuffer[colorsOffset++] = colorHelper.b;
|
||||
colorsBuffer[colorsOffset++] = colorHelper.r;
|
||||
colorsBuffer[colorsOffset++] = colorHelper.g;
|
||||
colorsBuffer[colorsOffset++] = colorHelper.b;
|
||||
}
|
||||
}
|
||||
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new BufferAttribute(positionsBuffer, 3));
|
||||
geometry.setAttribute("color", new BufferAttribute(colorsBuffer, 3));
|
||||
return geometry;
|
||||
}
|
||||
|
||||
export function DebugOctreeVisualization({
|
||||
octree,
|
||||
}: DebugOctreeVisualizationProps): React.JSX.Element | null {
|
||||
const showOctree = useDebugVisualsStore((state) => state.showOctree);
|
||||
const maxDepth = useDebugVisualsStore((state) => state.octreeMaxDepth);
|
||||
|
||||
const geometry = useMemo(() => {
|
||||
if (!octree || !showOctree) return null;
|
||||
const boxes = collectOctreeBoxes(octree, maxDepth);
|
||||
if (boxes.length === 0) return null;
|
||||
return buildOctreeLineGeometry(boxes);
|
||||
}, [maxDepth, octree, showOctree]);
|
||||
|
||||
if (!geometry) return null;
|
||||
|
||||
return (
|
||||
<lineSegments frustumCulled={false} renderOrder={999}>
|
||||
<primitive object={geometry} attach="geometry" />
|
||||
<lineBasicMaterial
|
||||
vertexColors
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
transparent
|
||||
opacity={0.85}
|
||||
/>
|
||||
</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";
|
||||
@@ -18,6 +20,9 @@ export function EbikeIntroSequence(): React.JSX.Element | null {
|
||||
const completeIntro = useGameStore((state) => state.completeIntro);
|
||||
const [breakdownDialogueDone, setBreakdownDialogueDone] = useState(false);
|
||||
const hasStartedBreakdown = useRef(false);
|
||||
const rideDistance = useRef(0);
|
||||
const lastRidePosition = useRef<THREE.Vector3 | null>(null);
|
||||
const currentRidePosition = useRef(new THREE.Vector3());
|
||||
|
||||
useEffect(() => {
|
||||
if (introStep !== "await-ebike-mount" || movementMode !== "ebike") return;
|
||||
@@ -26,16 +31,45 @@ export function EbikeIntroSequence(): React.JSX.Element | null {
|
||||
}, [introStep, movementMode, setIntroStep]);
|
||||
|
||||
useEffect(() => {
|
||||
if (introStep !== "ebike-intro-ride") return undefined;
|
||||
if (introStep !== "ebike-intro-ride") return;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setIntroStep("ebike-breakdown");
|
||||
}, EBIKE_INTRO_RIDE_DURATION_MS);
|
||||
rideDistance.current = 0;
|
||||
lastRidePosition.current = null;
|
||||
}, [introStep]);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
useEffect(() => {
|
||||
if (introStep !== "ebike-intro-ride" || movementMode !== "ebike") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let animationFrameId = 0;
|
||||
const tick = () => {
|
||||
const parkedPosition = window.ebikeParkedPosition;
|
||||
if (parkedPosition) {
|
||||
currentRidePosition.current.set(...parkedPosition);
|
||||
if (!lastRidePosition.current) {
|
||||
lastRidePosition.current = currentRidePosition.current.clone();
|
||||
} else {
|
||||
rideDistance.current += currentRidePosition.current.distanceTo(
|
||||
lastRidePosition.current,
|
||||
);
|
||||
lastRidePosition.current.copy(currentRidePosition.current);
|
||||
}
|
||||
|
||||
if (rideDistance.current >= EBIKE_INTRO_BREAKDOWN_DISTANCE) {
|
||||
setIntroStep("ebike-breakdown");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
animationFrameId = window.requestAnimationFrame(tick);
|
||||
};
|
||||
}, [introStep, setIntroStep]);
|
||||
|
||||
animationFrameId = window.requestAnimationFrame(tick);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [introStep, movementMode, setIntroStep]);
|
||||
|
||||
useEffect(() => {
|
||||
if (introStep !== "ebike-breakdown" || hasStartedBreakdown.current) {
|
||||
@@ -100,14 +134,27 @@ export function EbikeIntroSequence(): React.JSX.Element | null {
|
||||
}
|
||||
}, [introStep]);
|
||||
|
||||
if (introStep !== "await-ebike-mount" && introStep !== "ebike-intro-ride") {
|
||||
if (
|
||||
introStep !== "reveal" &&
|
||||
introStep !== "await-ebike-mount" &&
|
||||
introStep !== "ebike-intro-ride" &&
|
||||
introStep !== "ebike-breakdown"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (introStep === "ebike-breakdown") {
|
||||
return <MissionNotification mission="ebike" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<MissionNotification
|
||||
mission="ebike"
|
||||
visible={introStep === "await-ebike-mount"}
|
||||
imagePath={INTRO_MISSION_NOTIFICATION_IMAGE_PATH}
|
||||
visible={
|
||||
introStep === "reveal" ||
|
||||
introStep === "await-ebike-mount" ||
|
||||
introStep === "ebike-intro-ride"
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" }}
|
||||
/>
|
||||
|
||||
@@ -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,19 @@ import { MISSION_NOTIFICATION_IMAGE_PATHS } from "@/data/gameplay/missionNotific
|
||||
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||
|
||||
interface MissionNotificationProps {
|
||||
mission: RepairMissionId;
|
||||
mission?: RepairMissionId;
|
||||
imagePath?: string;
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
export function MissionNotification({
|
||||
mission,
|
||||
imagePath,
|
||||
visible = true,
|
||||
}: MissionNotificationProps): React.JSX.Element {
|
||||
const src =
|
||||
imagePath ?? (mission ? MISSION_NOTIFICATION_IMAGE_PATHS[mission] : "");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mission-notification${visible ? "" : " mission-notification--hidden"}`}
|
||||
@@ -19,7 +24,7 @@ export function MissionNotification({
|
||||
<span className="mission-notification__image-wrap">
|
||||
<img
|
||||
className="mission-notification__image"
|
||||
src={MISSION_NOTIFICATION_IMAGE_PATHS[mission]}
|
||||
src={src}
|
||||
alt="Nouvel objectif de mission"
|
||||
/>
|
||||
</span>
|
||||
|
||||
@@ -1,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,100 @@
|
||||
import { Suspense, useEffect, useMemo, useRef } from "react";
|
||||
import { Canvas, useFrame } from "@react-three/fiber";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import * as THREE from "three";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
|
||||
|
||||
const TALKIE_MODEL_PATH = "/models/talkie/model.gltf";
|
||||
const TALKIE_REVEAL_STEPS = new Set([
|
||||
"reveal",
|
||||
"await-ebike-mount",
|
||||
"ebike-intro-ride",
|
||||
"ebike-breakdown",
|
||||
"completed",
|
||||
]);
|
||||
|
||||
function TalkieModel(): React.JSX.Element {
|
||||
const { scene } = useGLTF(TALKIE_MODEL_PATH);
|
||||
const model = useMemo(() => scene.clone(true), [scene]);
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
|
||||
useEffect(() => {
|
||||
model.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.castShadow = false;
|
||||
child.receiveShadow = false;
|
||||
child.frustumCulled = false;
|
||||
}
|
||||
});
|
||||
}, [model]);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (!groupRef.current) return;
|
||||
|
||||
const t = clock.getElapsedTime();
|
||||
groupRef.current.rotation.z = Math.sin(t * 22) * 0.025;
|
||||
groupRef.current.position.y = Math.sin(t * 6) * 0.012;
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
<primitive
|
||||
object={model}
|
||||
position={[0, -0.18, 0]}
|
||||
rotation={[0.18, Math.PI, -0.08]}
|
||||
scale={1.45}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function TalkieSignalLines(): React.JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
className="talkie-dialogue-overlay__signals"
|
||||
viewBox="0 0 120 160"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M34 20 C52 44 16 66 34 92 C48 112 22 128 30 146" />
|
||||
<path d="M68 12 C92 44 50 70 70 104 C84 130 48 142 52 154" />
|
||||
<path d="M100 8 C124 42 82 76 100 112 C112 136 74 150 78 158" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TalkieDialogueOverlay(): React.JSX.Element | null {
|
||||
const activeSubtitle = useSubtitleStore((state) => state.activeSubtitle);
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const introStep = useGameStore((state) => state.intro.currentStep);
|
||||
const isAfterReveal =
|
||||
mainState !== "intro" || TALKIE_REVEAL_STEPS.has(introStep);
|
||||
const isNarratorDialogue = activeSubtitle?.speaker === "Narrateur";
|
||||
|
||||
if (!isAfterReveal || !isNarratorDialogue) return null;
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="talkie-dialogue-overlay talkie-dialogue-overlay--raised"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<TalkieSignalLines />
|
||||
<div className="talkie-dialogue-overlay__model-frame">
|
||||
<Canvas
|
||||
camera={{ position: [0, 0, 4.2], zoom: 78 }}
|
||||
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 />
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
useGLTF.preload(TALKIE_MODEL_PATH);
|
||||
@@ -6,19 +6,20 @@ export interface CameraTransform {
|
||||
}
|
||||
|
||||
export const EBIKE_CAMERA_TRANSFORM: CameraTransform = {
|
||||
position: [-3.5, 6, 0],
|
||||
position: [-2.6, 4.5, 0],
|
||||
rotation: [-10, -90, 0],
|
||||
};
|
||||
|
||||
export const EBIKE_DROP_PLAYER_TRANSFORM: CameraTransform = {
|
||||
position: [0, 1.5, -3],
|
||||
position: [0, 1.3, -2.25],
|
||||
rotation: [0, 0, 0],
|
||||
};
|
||||
|
||||
export const EBIKE_WORLD_POSITION: Vector3Tuple = [61.5, 10, 62.4];
|
||||
export const EBIKE_WORLD_ROTATION_Y = 2.4107;
|
||||
export const EBIKE_WORLD_POSITION: Vector3Tuple = [65, 0.8, 72];
|
||||
export const EBIKE_WORLD_ROTATION_Y = -2.5;
|
||||
export const EBIKE_WORLD_SCALE = 0.35;
|
||||
|
||||
export const EBIKE_INTRO_RIDE_DURATION_MS = 5000;
|
||||
export const EBIKE_INTRO_BREAKDOWN_DISTANCE = 15;
|
||||
export const EBIKE_BREAKDOWN_DIALOGUE_DELAY_MS = 250;
|
||||
|
||||
export const EBIKE_MAX_SPEED = 3;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||
|
||||
export const INTRO_MISSION_NOTIFICATION_IMAGE_PATH =
|
||||
"/assets/world/UI/intro-mission-notification.png";
|
||||
|
||||
export const MISSION_NOTIFICATION_IMAGE_PATHS: Record<RepairMissionId, string> =
|
||||
{
|
||||
ebike: "/assets/world/UI/ebike-mission-notification.png",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
import { LA_FABRIK_PLAYER_SPAWN } from "@/data/world/laFabrikConfig";
|
||||
|
||||
export const PLAYER_EYE_HEIGHT = 1.75;
|
||||
export const PLAYER_CAPSULE_RADIUS = 0.35;
|
||||
|
||||
export const PLAYER_WALK_SPEED = 5;
|
||||
export const PLAYER_EBIKE_SPEED = 20;
|
||||
export const PLAYER_EBIKE_SPEED = 30;
|
||||
export const PLAYER_AIR_CONTROL_FACTOR = 0.35;
|
||||
export const PLAYER_JUMP_SPEED = 9;
|
||||
export const PLAYER_GRAVITY = 30;
|
||||
@@ -14,5 +15,9 @@ export const PLAYER_XZ_DAMPING_FACTOR = 8;
|
||||
export const PLAYER_FALL_RESPAWN_Y = -20;
|
||||
export const PLAYER_FALL_RESPAWN_DELAY = 3;
|
||||
|
||||
export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [59.5, 10, 64.64];
|
||||
export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [
|
||||
LA_FABRIK_PLAYER_SPAWN[0] + 1,
|
||||
LA_FABRIK_PLAYER_SPAWN[1],
|
||||
LA_FABRIK_PLAYER_SPAWN[2] - 1,
|
||||
];
|
||||
export const PLAYER_SPAWN_POSITION_PHYSICS: Vector3Tuple = [0, 3, 0];
|
||||
|
||||
@@ -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,5 +1,3 @@
|
||||
import { CHUNK_CONFIG } from "@/data/world/chunkStreamingConfig";
|
||||
|
||||
export const GRAPHICS_PRESET_KEYS = ["low", "medium", "high", "ultra"] as const;
|
||||
|
||||
export type GraphicsPreset = (typeof GRAPHICS_PRESET_KEYS)[number];
|
||||
@@ -32,8 +30,8 @@ export const GRAPHICS_PRESETS = {
|
||||
},
|
||||
high: {
|
||||
label: "High",
|
||||
chunkLoadRadius: CHUNK_CONFIG.loadRadius,
|
||||
chunkUnloadRadius: CHUNK_CONFIG.unloadRadius,
|
||||
chunkLoadRadius: 35,
|
||||
chunkUnloadRadius: 45,
|
||||
fogEnabled: false,
|
||||
forceLodModels: false,
|
||||
lodHighDetailDistance: 10,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
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 = [
|
||||
{
|
||||
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,33 @@
|
||||
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||
import { useDebugVisualsStore } from "@/managers/stores/useDebugVisualsStore";
|
||||
|
||||
export function useDebugVisualsDebug(): void {
|
||||
useDebugFolder("Debug", (folder) => {
|
||||
const controls = {
|
||||
showPlayerModel: useDebugVisualsStore.getState().showPlayerModel,
|
||||
showOctree: useDebugVisualsStore.getState().showOctree,
|
||||
octreeMaxDepth: useDebugVisualsStore.getState().octreeMaxDepth,
|
||||
};
|
||||
|
||||
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, "octreeMaxDepth", 0, 10, 1)
|
||||
.name("Octree Max Depth")
|
||||
.onChange((value: number) => {
|
||||
useDebugVisualsStore.getState().setOctreeMaxDepth(value);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -27,6 +27,7 @@ export function useOctreeGraphNode(
|
||||
|
||||
const octree = new Octree();
|
||||
octree.fromGraphNode(graphNode);
|
||||
|
||||
onOctreeReady(octree);
|
||||
}, [enabled, graphNodeRef, onOctreeReady, rebuildKey]);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
+105
@@ -1237,6 +1237,111 @@ canvas {
|
||||
color: #f9a8d4;
|
||||
}
|
||||
|
||||
/* Dialogue talkie */
|
||||
.talkie-dialogue-overlay {
|
||||
position: fixed;
|
||||
left: clamp(12px, 2.2vw, 28px);
|
||||
bottom: clamp(24px, 7vh, 76px);
|
||||
z-index: 16;
|
||||
width: clamp(120px, 13vw, 190px);
|
||||
aspect-ratio: 1;
|
||||
pointer-events: none;
|
||||
transform: translateY(0);
|
||||
transition: transform 180ms ease;
|
||||
}
|
||||
|
||||
.talkie-dialogue-overlay--raised {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
.talkie-dialogue-overlay__model-frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
animation: talkie-radio-shake 1s ease-in-out infinite;
|
||||
filter: drop-shadow(0 16px 22px rgba(0, 0, 0, 0.55));
|
||||
}
|
||||
|
||||
.talkie-dialogue-overlay__model-frame canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.talkie-dialogue-overlay__signals {
|
||||
position: absolute;
|
||||
right: -26%;
|
||||
bottom: 34%;
|
||||
z-index: 2;
|
||||
width: 58%;
|
||||
height: 78%;
|
||||
overflow: visible;
|
||||
opacity: 0.8;
|
||||
animation: talkie-signal-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.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;
|
||||
transform: translate3d(-4px, 4px, 0) scale(0.92);
|
||||
}
|
||||
|
||||
18%,
|
||||
38% {
|
||||
opacity: 0.95;
|
||||
transform: translate3d(0, 0, 0) scale(1);
|
||||
}
|
||||
|
||||
60% {
|
||||
opacity: 0.45;
|
||||
transform: translate3d(4px, -6px, 0) scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* In-game settings menu */
|
||||
.game-settings-menu {
|
||||
position: fixed;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
interface DebugVisualsStore {
|
||||
showPlayerModel: boolean;
|
||||
setShowPlayerModel: (value: boolean) => void;
|
||||
showOctree: boolean;
|
||||
setShowOctree: (value: boolean) => void;
|
||||
octreeMaxDepth: number;
|
||||
setOctreeMaxDepth: (value: number) => void;
|
||||
}
|
||||
|
||||
export const useDebugVisualsStore = create<DebugVisualsStore>((set) => ({
|
||||
showPlayerModel: false,
|
||||
setShowPlayerModel: (showPlayerModel) => set({ showPlayerModel }),
|
||||
showOctree: false,
|
||||
setShowOctree: (showOctree) => set({ showOctree }),
|
||||
octreeMaxDepth: 6,
|
||||
setOctreeMaxDepth: (octreeMaxDepth) => set({ octreeMaxDepth }),
|
||||
}));
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -131,6 +131,7 @@ 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
|
||||
@@ -148,6 +149,7 @@ export function HomePage(): React.JSX.Element | null {
|
||||
gl.shadowMap.enabled = true;
|
||||
gl.shadowMap.type = THREE.PCFShadowMap;
|
||||
gl.shadowMap.autoUpdate = true;
|
||||
gl.shadowMap.needsUpdate = true;
|
||||
logger.info("WebGL", "Context restored");
|
||||
};
|
||||
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
@@ -9,6 +9,7 @@ const DEBUG_CONTROLS_STORAGE_KEY = "la-fabrik-debug-controls";
|
||||
|
||||
interface StoredDebugControls {
|
||||
cameraMode: CameraMode;
|
||||
handTrackingSource: HandTrackingSource;
|
||||
sceneMode: SceneMode;
|
||||
}
|
||||
|
||||
@@ -25,6 +26,7 @@ const DEBUG_FOLDER_ORDER = [
|
||||
"Hand Tracking",
|
||||
"Map",
|
||||
"Personnages",
|
||||
"Debug",
|
||||
] as const;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -39,6 +41,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 +57,9 @@ function getStoredDebugControls(): Partial<StoredDebugControls> {
|
||||
...(isCameraMode(parsedValue.cameraMode)
|
||||
? { cameraMode: parsedValue.cameraMode }
|
||||
: {}),
|
||||
...(isHandTrackingSource(parsedValue.handTrackingSource)
|
||||
? { handTrackingSource: parsedValue.handTrackingSource }
|
||||
: {}),
|
||||
...(isSceneMode(parsedValue.sceneMode)
|
||||
? { sceneMode: parsedValue.sceneMode }
|
||||
: {}),
|
||||
@@ -94,7 +103,7 @@ export class Debug {
|
||||
this.controls = {
|
||||
cameraMode: storedControls.cameraMode ?? "player",
|
||||
fogEnabled: FOG_CONFIG.enabled,
|
||||
handTrackingSource: "browser",
|
||||
handTrackingSource: storedControls.handTrackingSource ?? "browser",
|
||||
showDebugOverlay: true,
|
||||
showHandTrackingSvg: false,
|
||||
showInteractionSpheres: false,
|
||||
@@ -159,7 +168,7 @@ export class Debug {
|
||||
.name("Source")
|
||||
.onChange((value: HandTrackingSource) => {
|
||||
this.controls.handTrackingSource = value;
|
||||
this.emit();
|
||||
this.saveAndEmit();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -246,7 +255,7 @@ export class Debug {
|
||||
|
||||
setHandTrackingSource(value: HandTrackingSource): void {
|
||||
this.controls.handTrackingSource = value;
|
||||
this.emit();
|
||||
this.saveAndEmit();
|
||||
}
|
||||
|
||||
getFogEnabled(): boolean {
|
||||
@@ -285,6 +294,7 @@ export class Debug {
|
||||
DEBUG_CONTROLS_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
cameraMode: this.controls.cameraMode,
|
||||
handTrackingSource: this.controls.handTrackingSource,
|
||||
sceneMode: this.controls.sceneMode,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
+188
-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,22 @@ function CollisionModelInstance({
|
||||
scale: normalizedScale,
|
||||
});
|
||||
const sceneInstance = useClonedObject(scene);
|
||||
useEffect(() => {
|
||||
// Strip the door slab from the la fabrik collision octree so the player
|
||||
// can walk through the doorway. The visual model is rendered separately
|
||||
// by MergedStaticMapModel and is unaffected.
|
||||
if (node.name !== "lafabrik") return;
|
||||
|
||||
const removed: THREE.Object3D[] = [];
|
||||
sceneInstance.traverse((child) => {
|
||||
if (child.name === "porte") {
|
||||
removed.push(child);
|
||||
}
|
||||
});
|
||||
for (const child of removed) {
|
||||
child.removeFromParent();
|
||||
}
|
||||
}, [node.name, sceneInstance]);
|
||||
const collisionPosition = useMemo(() => {
|
||||
if (node.name === "terrain") return position;
|
||||
|
||||
@@ -237,11 +301,125 @@ 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 (
|
||||
<mesh position={box.center}>
|
||||
<boxGeometry args={box.size} />
|
||||
<meshBasicMaterial />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,13 @@ import { useRepairMissionAnchorStore } from "@/managers/stores/useRepairMissionA
|
||||
import type { RepairMissionTriggerConfig } from "@/types/gameplay/repairMission";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
import { getRepairMissionPosition } from "@/utils/gameplay/repairMissionPosition";
|
||||
import { EBIKE_WORLD_POSITION } from "@/data/ebike/ebikeConfig";
|
||||
import {
|
||||
EBIKE_WORLD_POSITION,
|
||||
EBIKE_WORLD_ROTATION_Y,
|
||||
EBIKE_WORLD_SCALE,
|
||||
} from "@/data/ebike/ebikeConfig";
|
||||
|
||||
const EBIKE_CONFIG_KEY = `${EBIKE_WORLD_POSITION.join(",")}:${EBIKE_WORLD_ROTATION_Y}:${EBIKE_WORLD_SCALE}`;
|
||||
|
||||
interface StageAnchorProps {
|
||||
color: string;
|
||||
@@ -82,7 +88,7 @@ export function GameStageContent(): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{mainState === "intro" ? <StageAnchor {...INTRO_STAGE_ANCHOR} /> : null}
|
||||
<Ebike position={EBIKE_WORLD_POSITION} />
|
||||
<Ebike key={EBIKE_CONFIG_KEY} position={EBIKE_WORLD_POSITION} />
|
||||
{REPAIR_MISSION_POSITION_ENTRIES.map(({ mission }) => {
|
||||
const position = getRepairMissionPosition(mission, anchors);
|
||||
if (!position) return null;
|
||||
|
||||
+41
-19
@@ -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,35 @@ import {
|
||||
SUN_Z_MIN,
|
||||
SUN_Z_STEP,
|
||||
} from "@/data/world/lightingConfig";
|
||||
import { LA_FABRIK_INTERIOR_LIGHT_POSITION } from "@/data/world/laFabrikConfig";
|
||||
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||
import { LIGHTING_STATE } from "@/world/lightingState";
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
export function Lighting(): React.JSX.Element {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const gl = useThree((state) => state.gl);
|
||||
const ambient = useRef<AmbientLight>(null);
|
||||
const sun = useRef<DirectionalLight>(null);
|
||||
const sunTarget = useRef<Object3D>(null);
|
||||
@@ -35,19 +61,9 @@ 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();
|
||||
}, []);
|
||||
configureSunShadow(sun.current, sunTarget.current);
|
||||
configureRendererShadows(gl);
|
||||
}, [gl]);
|
||||
|
||||
useDebugFolder("Lighting", (folder) => {
|
||||
folder.addColor(LIGHTING_STATE, "ambientColor").name("Ambient Color");
|
||||
@@ -98,7 +114,6 @@ export function Lighting(): React.JSX.Element {
|
||||
sun.current.color.set(LIGHTING_STATE.sunColor);
|
||||
sun.current.intensity = LIGHTING_STATE.sunIntensity;
|
||||
sun.current.updateMatrixWorld();
|
||||
sun.current.shadow.needsUpdate = true;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -121,6 +136,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;
|
||||
}
|
||||
+27
-14
@@ -4,16 +4,21 @@ 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 { 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";
|
||||
@@ -35,10 +40,15 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
||||
useEnvironmentDebug();
|
||||
useMapPerformanceDebug();
|
||||
useCharacterDebug();
|
||||
useDebugVisualsDebug();
|
||||
|
||||
const cameraMode = useCameraMode();
|
||||
const sceneMode = useSceneMode();
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const showDebugPlayerModel = useDebugVisualsStore(
|
||||
(state) => state.showPlayerModel,
|
||||
);
|
||||
const showDebugOctree = useDebugVisualsStore((state) => state.showOctree);
|
||||
const { status, usageStatus } = useHandTrackingSnapshot();
|
||||
const {
|
||||
octree,
|
||||
@@ -47,9 +57,6 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
||||
handleGameStageLoaded,
|
||||
handleGameMapLoaded,
|
||||
handleOctreeReady,
|
||||
handleShadowWarmupReady,
|
||||
handleShadowWarmupStarted,
|
||||
shouldWarmUpShadows,
|
||||
} = useWorldSceneLoading({ sceneMode, onLoadingStateChange });
|
||||
const playerSpawnPosition =
|
||||
sceneMode === "game"
|
||||
@@ -64,15 +71,15 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
||||
|
||||
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" />
|
||||
@@ -91,16 +98,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}
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ const PLAYER_FLOOR_NORMAL_MIN = 0.15;
|
||||
const PLAYER_GROUND_SNAP_DISTANCE = 0.22;
|
||||
|
||||
interface PlayerControllerProps {
|
||||
initialLookAt?: Vector3Tuple | undefined;
|
||||
octree: Octree | null;
|
||||
spawnPosition: Vector3Tuple;
|
||||
}
|
||||
@@ -89,6 +90,7 @@ const _collisionCorrection = new THREE.Vector3();
|
||||
function resetPlayerCapsule(
|
||||
capsule: Capsule,
|
||||
spawnPosition: Vector3Tuple,
|
||||
initialLookAt: Vector3Tuple | undefined,
|
||||
camera: THREE.Camera,
|
||||
velocity: THREE.Vector3,
|
||||
): void {
|
||||
@@ -100,6 +102,7 @@ function resetPlayerCapsule(
|
||||
capsule.end.set(...spawnPosition);
|
||||
velocity.set(0, 0, 0);
|
||||
camera.position.copy(capsule.end);
|
||||
if (initialLookAt) camera.lookAt(...initialLookAt);
|
||||
}
|
||||
|
||||
function createSpawnCapsule(spawnPosition: Vector3Tuple): Capsule {
|
||||
@@ -145,6 +148,7 @@ function getCapsuleFootY(capsule: Capsule): number {
|
||||
}
|
||||
|
||||
export function PlayerController({
|
||||
initialLookAt,
|
||||
octree,
|
||||
spawnPosition,
|
||||
}: PlayerControllerProps): null {
|
||||
@@ -234,6 +238,7 @@ export function PlayerController({
|
||||
resetPlayerCapsule(
|
||||
capsule.current,
|
||||
spawnPosition,
|
||||
initialLookAt,
|
||||
camera,
|
||||
velocity.current,
|
||||
);
|
||||
@@ -241,7 +246,7 @@ export function PlayerController({
|
||||
onFloor.current = false;
|
||||
wantsJump.current = false;
|
||||
initializedRef.current = true;
|
||||
}, [camera, spawnPosition]);
|
||||
}, [camera, initialLookAt, spawnPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
movementLockedRef.current = movementLocked;
|
||||
@@ -339,6 +344,7 @@ export function PlayerController({
|
||||
resetPlayerCapsule(
|
||||
capsule.current,
|
||||
spawnPosition,
|
||||
initialLookAt,
|
||||
camera,
|
||||
velocity.current,
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
VEGETATION_TYPES,
|
||||
type VegetationType,
|
||||
} from "@/data/world/vegetationConfig";
|
||||
import { isInsideLaFabrikFootprint } from "@/data/world/laFabrikConfig";
|
||||
import { createWorldInstanceChunks } from "@/utils/world/chunkInstances";
|
||||
|
||||
interface VegetationSystemProps {
|
||||
@@ -60,6 +61,15 @@ function createVegetationChunks(
|
||||
});
|
||||
}
|
||||
|
||||
function removeLaFabrikVegetation(
|
||||
instances: VegetationInstance[],
|
||||
): VegetationInstance[] {
|
||||
return instances.filter((instance) => {
|
||||
const [x, , z] = instance.position;
|
||||
return !isInsideLaFabrikFootprint(x, z, 1.2);
|
||||
});
|
||||
}
|
||||
|
||||
export function VegetationSystem({
|
||||
onlyMapName = null,
|
||||
streaming = true,
|
||||
@@ -90,7 +100,10 @@ export function VegetationSystem({
|
||||
const entry = data.get(config.mapName);
|
||||
if (!entry || entry.instances.length === 0) return [];
|
||||
|
||||
return createVegetationChunks(type, entry.instances);
|
||||
const instances = removeLaFabrikVegetation(entry.instances);
|
||||
if (instances.length === 0) return [];
|
||||
|
||||
return createVegetationChunks(type, instances);
|
||||
});
|
||||
}, [data, groups, models, onlyMapName]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user