Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7696519452 | |||
| a3e8e732f1 | |||
| 489499f5d2 | |||
| 39b996eb31 | |||
| 134c0aecb7 | |||
| b144dc1c18 | |||
| 69c720b86b | |||
| d975aac018 | |||
| 1b57a25e5f | |||
| f6db7d74e2 | |||
| a1798aecb3 | |||
| 3b07f40f2d | |||
| 27416143e3 | |||
| a2a491bd5c | |||
| da7d66e1fd | |||
| 5faf4b4197 | |||
| bee0c7f223 | |||
| 216d29ae59 | |||
| e13cf1e4c7 | |||
| cd0afcda8c | |||
| d20bdc4934 | |||
| 7c35090dbd | |||
| a766784ce8 | |||
| 63952912b5 | |||
| fd0b9e2749 | |||
| 777e51efeb | |||
| 1ad0c4de37 | |||
| 7a378afad3 | |||
| d52ec7e5a9 | |||
| 813c10f3f7 | |||
| 153833deec | |||
| b617885aa2 | |||
| 5d2e7e2aab | |||
| de77f76d48 | |||
| bdc704fe8e | |||
| bce7d11b66 | |||
| 8aa755da7a | |||
| 6d58b90856 | |||
| bafca5a936 | |||
| dcf3a8564c | |||
| bc862960a7 | |||
| 597ebcfbd4 | |||
| aa2d411b0c | |||
| 061e0dc677 |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 5173
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,63 @@ 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
|
||||
|
||||
Shadows occasionally failed to render on initial load and could disappear
|
||||
mid-session even though the `Lighting` configuration ran to completion. The
|
||||
fix has two layers:
|
||||
|
||||
### Per-frame refresh (steady state)
|
||||
|
||||
The sun follows the camera, so its world matrix is dirty every frame. With
|
||||
`shadow.autoUpdate` alone, three.js can skip the shadow map re-render on a
|
||||
frame where the matrix update has happened but the renderer's internal dirty
|
||||
tracking does not pick it up. To prevent that, `Lighting.useFrame` sets
|
||||
`sun.shadow.needsUpdate = true` after the per-frame matrix updates. Shadow
|
||||
config is centralized in `src/data/world/lightingConfig.ts` (`bias=0`,
|
||||
`normalBias=0`, `cameraSize=95`).
|
||||
|
||||
### Mount-time shadow map reallocation (`useShadowMapWarmup`)
|
||||
|
||||
The merged static map and other GLTFs mount imperatively after `Lighting`,
|
||||
so the shadow render target ends up linked to a renderer state that pre-dates
|
||||
the final scene. Materials compiled at that point bake a "no shadow map"
|
||||
permutation into their shader program and silently fail to render shadows
|
||||
until a WebGL context-restore cycle (the kind triggered by Chrome DevTools
|
||||
in `?debug` runs) reallocates everything.
|
||||
|
||||
`src/hooks/three/useShadowMapWarmup.ts` replays that cycle programmatically
|
||||
without the cost of a full context loss. It runs a `useFrame` watchdog that
|
||||
samples the scene mesh count every 6 frames; once the count has been stable
|
||||
for ~1 s (or after a 5 s safety cap), it:
|
||||
|
||||
1. Disposes the directional light shadow map and nulls it. three.js
|
||||
reallocates the render target on the next render at the configured
|
||||
`mapSize`.
|
||||
2. Marks every material's `needsUpdate = true`, forcing a shader recompile
|
||||
that rebinds every program to the freshly created shadow sampler.
|
||||
3. Forces a single shadow pass and invalidates the renderer.
|
||||
|
||||
The watchdog runs once per mount and adds a single traversal every 6 frames
|
||||
during the warmup window, after which it self-terminates.
|
||||
|
||||
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.
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.
Binary file not shown.
@@ -78,19 +78,19 @@
|
||||
{
|
||||
"id": "narrateur_coupureelec",
|
||||
"voice": "narrateur",
|
||||
"audio": "/sounds/dialogue/narrateur_coupureélec.mp3",
|
||||
"audio": "/sounds/dialogue/narrateur_coupure_elec.mp3",
|
||||
"subtitleCueIndex": 9
|
||||
},
|
||||
{
|
||||
"id": "narrateur_poteaueleccasse",
|
||||
"voice": "narrateur",
|
||||
"audio": "/sounds/dialogue/narrateur_poteauéleccassé.mp3",
|
||||
"audio": "/sounds/dialogue/narrateur_poteau_elec_casse.mp3",
|
||||
"subtitleCueIndex": 10
|
||||
},
|
||||
{
|
||||
"id": "narrateur_courantrepare",
|
||||
"voice": "narrateur",
|
||||
"audio": "/sounds/dialogue/narrateur_courantréparé.mp3",
|
||||
"audio": "/sounds/dialogue/narrateur_courant_repare.mp3",
|
||||
"subtitleCueIndex": 11
|
||||
},
|
||||
{
|
||||
@@ -165,6 +165,12 @@
|
||||
"audio": "/sounds/dialogue/narrateur_histoireelectricienne.mp3",
|
||||
"subtitleCueIndex": 23
|
||||
},
|
||||
{
|
||||
"id": "narrateur_demande_aide",
|
||||
"voice": "narrateur",
|
||||
"audio": "/sounds/dialogue/narrateur_demande_aide.mp3",
|
||||
"subtitleCueIndex": 24
|
||||
},
|
||||
{
|
||||
"id": "fermier_coupdemain",
|
||||
"voice": "fermier",
|
||||
|
||||
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,173 @@
|
||||
import { useMemo } from "react";
|
||||
import { Box3, BufferAttribute, BufferGeometry } from "three";
|
||||
import type { Octree } from "three-stdlib";
|
||||
import {
|
||||
LA_FABRIK_CENTER,
|
||||
isInsideLaFabrikFootprint,
|
||||
} from "@/data/world/laFabrikConfig";
|
||||
import { useDebugVisualsStore } from "@/managers/stores/useDebugVisualsStore";
|
||||
|
||||
interface DebugOctreeVisualizationProps {
|
||||
octree: Octree | null;
|
||||
}
|
||||
|
||||
interface OctreeNodeBox {
|
||||
box: Box3;
|
||||
depth: number;
|
||||
triangleCount: number;
|
||||
isLeaf: boolean;
|
||||
}
|
||||
|
||||
interface CollectOptions {
|
||||
minDepth: number;
|
||||
maxDepth: number;
|
||||
leavesOnly: boolean;
|
||||
fabrikOnly: boolean;
|
||||
}
|
||||
|
||||
const FABRIK_FILTER_PADDING = 1.5;
|
||||
const FABRIK_FILTER_VERTICAL = 8;
|
||||
|
||||
const BOX_VERTEX_INDEX_PAIRS: ReadonlyArray<readonly [number, number]> = [
|
||||
[0, 1],
|
||||
[1, 3],
|
||||
[3, 2],
|
||||
[2, 0],
|
||||
[4, 5],
|
||||
[5, 7],
|
||||
[7, 6],
|
||||
[6, 4],
|
||||
[0, 4],
|
||||
[1, 5],
|
||||
[2, 6],
|
||||
[3, 7],
|
||||
];
|
||||
|
||||
function boxIntersectsFabrik(box: Box3): boolean {
|
||||
if (box.max.y < LA_FABRIK_CENTER[1] - FABRIK_FILTER_VERTICAL) return false;
|
||||
if (box.min.y > LA_FABRIK_CENTER[1] + FABRIK_FILTER_VERTICAL) return false;
|
||||
|
||||
// Sample box corners + center on XZ plane against the rotated fabrik footprint.
|
||||
const samples: ReadonlyArray<readonly [number, number]> = [
|
||||
[box.min.x, box.min.z],
|
||||
[box.min.x, box.max.z],
|
||||
[box.max.x, box.min.z],
|
||||
[box.max.x, box.max.z],
|
||||
[(box.min.x + box.max.x) * 0.5, (box.min.z + box.max.z) * 0.5],
|
||||
];
|
||||
for (const [x, z] of samples) {
|
||||
if (isInsideLaFabrikFootprint(x, z, FABRIK_FILTER_PADDING)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function collectOctreeBoxes(
|
||||
node: Octree,
|
||||
options: CollectOptions,
|
||||
depth = 0,
|
||||
acc: OctreeNodeBox[] = [],
|
||||
): OctreeNodeBox[] {
|
||||
if (depth > options.maxDepth) return acc;
|
||||
|
||||
const isLeaf = node.subTrees.length === 0;
|
||||
const passesDepth = depth >= options.minDepth;
|
||||
const passesLeafFilter = !options.leavesOnly || isLeaf;
|
||||
const hasTriangles = node.triangles.length > 0;
|
||||
const passesFabrikFilter =
|
||||
!options.fabrikOnly || boxIntersectsFabrik(node.box);
|
||||
|
||||
if (passesDepth && passesLeafFilter && hasTriangles && passesFabrikFilter) {
|
||||
acc.push({
|
||||
box: node.box,
|
||||
depth,
|
||||
triangleCount: node.triangles.length,
|
||||
isLeaf,
|
||||
});
|
||||
}
|
||||
|
||||
for (const sub of node.subTrees) {
|
||||
collectOctreeBoxes(sub, options, depth + 1, acc);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}
|
||||
|
||||
function buildOctreeLineGeometry(
|
||||
nodes: readonly OctreeNodeBox[],
|
||||
): BufferGeometry {
|
||||
const positionsBuffer = new Float32Array(
|
||||
nodes.length * BOX_VERTEX_INDEX_PAIRS.length * 2 * 3,
|
||||
);
|
||||
|
||||
const corners: [number, number, number][] = Array.from({ length: 8 }, () => [
|
||||
0, 0, 0,
|
||||
]);
|
||||
|
||||
let positionsOffset = 0;
|
||||
|
||||
for (const node of nodes) {
|
||||
const { min, max } = node.box;
|
||||
|
||||
corners[0] = [min.x, min.y, min.z];
|
||||
corners[1] = [max.x, min.y, min.z];
|
||||
corners[2] = [min.x, max.y, min.z];
|
||||
corners[3] = [max.x, max.y, min.z];
|
||||
corners[4] = [min.x, min.y, max.z];
|
||||
corners[5] = [max.x, min.y, max.z];
|
||||
corners[6] = [min.x, max.y, max.z];
|
||||
corners[7] = [max.x, max.y, max.z];
|
||||
|
||||
for (const [a, b] of BOX_VERTEX_INDEX_PAIRS) {
|
||||
const ca = corners[a]!;
|
||||
const cb = corners[b]!;
|
||||
positionsBuffer[positionsOffset++] = ca[0];
|
||||
positionsBuffer[positionsOffset++] = ca[1];
|
||||
positionsBuffer[positionsOffset++] = ca[2];
|
||||
positionsBuffer[positionsOffset++] = cb[0];
|
||||
positionsBuffer[positionsOffset++] = cb[1];
|
||||
positionsBuffer[positionsOffset++] = cb[2];
|
||||
}
|
||||
}
|
||||
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new BufferAttribute(positionsBuffer, 3));
|
||||
return geometry;
|
||||
}
|
||||
|
||||
export function DebugOctreeVisualization({
|
||||
octree,
|
||||
}: DebugOctreeVisualizationProps): React.JSX.Element | null {
|
||||
const showOctree = useDebugVisualsStore((state) => state.showOctree);
|
||||
const minDepth = useDebugVisualsStore((state) => state.octreeMinDepth);
|
||||
const maxDepth = useDebugVisualsStore((state) => state.octreeMaxDepth);
|
||||
const leavesOnly = useDebugVisualsStore((state) => state.octreeLeavesOnly);
|
||||
const opacity = useDebugVisualsStore((state) => state.octreeOpacity);
|
||||
const fabrikOnly = useDebugVisualsStore((state) => state.octreeFabrikOnly);
|
||||
|
||||
const geometry = useMemo(() => {
|
||||
if (!octree || !showOctree) return null;
|
||||
const boxes = collectOctreeBoxes(octree, {
|
||||
minDepth,
|
||||
maxDepth,
|
||||
leavesOnly,
|
||||
fabrikOnly,
|
||||
});
|
||||
if (boxes.length === 0) return null;
|
||||
return buildOctreeLineGeometry(boxes);
|
||||
}, [fabrikOnly, leavesOnly, maxDepth, minDepth, octree, showOctree]);
|
||||
|
||||
if (!geometry) return null;
|
||||
|
||||
return (
|
||||
<lineSegments frustumCulled={false} renderOrder={999}>
|
||||
<primitive object={geometry} attach="geometry" />
|
||||
<lineBasicMaterial
|
||||
color="#22d3ee"
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
transparent
|
||||
opacity={opacity}
|
||||
/>
|
||||
</lineSegments>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
|
||||
const MODEL_PATH = "/models/persoprincipal/model.gltf";
|
||||
// Offset expressed in the camera's local space:
|
||||
// - x: horizontal (0 = centered)
|
||||
// - y: vertical relative to camera eye (negative = below)
|
||||
// - z: forward (negative = in front of the camera)
|
||||
const LOCAL_OFFSET = new THREE.Vector3(0, -1, -2.5);
|
||||
|
||||
const eulerHelper = new THREE.Euler();
|
||||
|
||||
export function DebugPlayerModel(): React.JSX.Element {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const { scene } = useGLTF(MODEL_PATH);
|
||||
|
||||
const model = useMemo(() => {
|
||||
const cloned = scene.clone(true);
|
||||
cloned.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.castShadow = true;
|
||||
child.receiveShadow = true;
|
||||
child.frustumCulled = false;
|
||||
}
|
||||
});
|
||||
return cloned;
|
||||
}, [scene]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
model.clear();
|
||||
},
|
||||
[model],
|
||||
);
|
||||
|
||||
useFrame(({ camera }) => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
// Place the model in front of the camera using its local space so it stays
|
||||
// visible regardless of the camera pitch (top-down ebike view, etc.).
|
||||
group.position.copy(LOCAL_OFFSET).applyMatrix4(camera.matrixWorld);
|
||||
|
||||
// Keep the model upright and aligned with the camera yaw only.
|
||||
eulerHelper.setFromQuaternion(camera.quaternion, "YXZ");
|
||||
group.rotation.set(0, eulerHelper.y, 0);
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={groupRef} frustumCulled={false}>
|
||||
<primitive object={model} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
useGLTF.preload(MODEL_PATH);
|
||||
@@ -8,12 +8,16 @@ 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";
|
||||
@@ -32,6 +36,18 @@ 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);
|
||||
@@ -64,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);
|
||||
@@ -85,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");
|
||||
@@ -99,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;
|
||||
@@ -281,19 +324,20 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
{!repairGameOwnsEbikeModel ? (
|
||||
<group
|
||||
ref={groupRef}
|
||||
position={position}
|
||||
position={parkedPosition}
|
||||
rotation={[0, EBIKE_WORLD_ROTATION_Y, 0]}
|
||||
scale={EBIKE_WORLD_SCALE}
|
||||
>
|
||||
<primitive object={model} />
|
||||
<InteractableObject
|
||||
kind="trigger"
|
||||
label={interactionLabel}
|
||||
position={position}
|
||||
radius={15}
|
||||
position={parkedPosition}
|
||||
radius={5}
|
||||
onPress={handleInteract}
|
||||
>
|
||||
<mesh>
|
||||
<boxGeometry args={[10, 13, 2]} />
|
||||
<boxGeometry args={[8, 9, 2]} />
|
||||
<meshBasicMaterial colorWrite={false} depthWrite={false} />
|
||||
</mesh>
|
||||
</InteractableObject>
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
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";
|
||||
import { playDialogueById } from "@/utils/dialogues/playDialogue";
|
||||
|
||||
export function EbikeIntroSequence(): React.JSX.Element | null {
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const introStep = useGameStore((state) => state.intro.currentStep);
|
||||
const movementMode = useGameStore((state) => state.player.movementMode);
|
||||
const pylonStep = useGameStore((state) => state.pylon.currentStep);
|
||||
const setIntroStep = useGameStore((state) => state.setIntroStep);
|
||||
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 +33,45 @@ export function EbikeIntroSequence(): React.JSX.Element | null {
|
||||
}, [introStep, movementMode, setIntroStep]);
|
||||
|
||||
useEffect(() => {
|
||||
if (introStep !== "ebike-intro-ride") return undefined;
|
||||
if (introStep !== "ebike-intro-ride") return;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
rideDistance.current = 0;
|
||||
lastRidePosition.current = null;
|
||||
}, [introStep]);
|
||||
|
||||
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");
|
||||
}, EBIKE_INTRO_RIDE_DURATION_MS);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
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 +136,37 @@ export function EbikeIntroSequence(): React.JSX.Element | null {
|
||||
}
|
||||
}, [introStep]);
|
||||
|
||||
if (introStep !== "await-ebike-mount" && introStep !== "ebike-intro-ride") {
|
||||
if (mainState === "pylon") {
|
||||
if (pylonStep === "approaching") {
|
||||
return <MissionNotification mission="pylon" visible />;
|
||||
}
|
||||
if (pylonStep === "narrator-outro") {
|
||||
return <MissionNotification mission="farm" visible />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
introStep !== "reveal" &&
|
||||
introStep !== "await-ebike-mount" &&
|
||||
introStep !== "ebike-intro-ride" &&
|
||||
introStep !== "ebike-breakdown"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (introStep === "ebike-breakdown") {
|
||||
return <MissionNotification mission="ebike" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<MissionNotification
|
||||
mission="ebike"
|
||||
visible={introStep === "await-ebike-mount"}
|
||||
imagePath={INTRO_MISSION_NOTIFICATION_IMAGE_PATH}
|
||||
visible={
|
||||
introStep === "reveal" ||
|
||||
introStep === "await-ebike-mount" ||
|
||||
introStep === "ebike-intro-ride"
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import * as THREE from "three";
|
||||
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||
import { playDialogueById } from "@/utils/dialogues/playDialogue";
|
||||
import {
|
||||
PYLON_DOWNED_ROTATION,
|
||||
PYLON_NARRATIVE_INTERACT_RADIUS,
|
||||
PYLON_NARRATIVE_DIALOGUES,
|
||||
PYLON_STRAIGHTEN_ANIMATION_DURATION_MS,
|
||||
PYLON_UPRIGHT_ROTATION,
|
||||
PYLON_WORLD_POSITION,
|
||||
} from "@/data/gameplay/pylonConfig";
|
||||
import { pylonStraighteningSignal } from "@/components/gameplay/pylon/pylonSignals";
|
||||
|
||||
const PYLON_MODEL_PATH = "/models/pylone/model.gltf";
|
||||
|
||||
export function PylonDownedPylon(): React.JSX.Element | null {
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const step = useGameStore((state) => state.pylon.currentStep);
|
||||
const setMissionStep = useGameStore((state) => state.setMissionStep);
|
||||
const setCanMove = useGameStore((state) => state.setCanMove);
|
||||
const [isStraightening, setIsStraightening] = useState(false);
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const straightenStartRef = useRef<number | null>(null);
|
||||
const hasPlayedFirstAudioRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (step === "arrived") hasPlayedFirstAudioRef.current = false;
|
||||
}, [step]);
|
||||
|
||||
const { scene } = useGLTF(PYLON_MODEL_PATH);
|
||||
const clonedScene = useMemo(() => scene.clone(true), [scene]);
|
||||
|
||||
const showUpright =
|
||||
mainState !== "pylon" ||
|
||||
step === "waiting" ||
|
||||
step === "inspected" ||
|
||||
step === "fragmented" ||
|
||||
step === "scanning" ||
|
||||
step === "repairing" ||
|
||||
step === "reassembling" ||
|
||||
step === "done" ||
|
||||
step === "narrator-outro";
|
||||
|
||||
useFrame(() => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
if (!isStraightening || straightenStartRef.current === null) {
|
||||
group.rotation.set(
|
||||
...(showUpright ? PYLON_UPRIGHT_ROTATION : PYLON_DOWNED_ROTATION),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsed = performance.now() - straightenStartRef.current;
|
||||
const t = Math.min(elapsed / PYLON_STRAIGHTEN_ANIMATION_DURATION_MS, 1);
|
||||
const eased = 1 - Math.pow(1 - t, 3);
|
||||
const startEuler = new THREE.Euler(...PYLON_DOWNED_ROTATION);
|
||||
|
||||
group.rotation.set(
|
||||
THREE.MathUtils.lerp(startEuler.x, 0, eased),
|
||||
startEuler.y,
|
||||
THREE.MathUtils.lerp(startEuler.z, 0, eased),
|
||||
);
|
||||
});
|
||||
|
||||
const isPylonInteractive = step === "arrived" || step === "npc-return";
|
||||
|
||||
// During these steps the RepairGame renders its own pylon model
|
||||
// (exploded / reassembling / completion). Rendering the solid world
|
||||
// pylon on top would double the heaviest model's GPU cost at the same
|
||||
// spot — a prime cause of WebGL context loss. Let RepairGame own it.
|
||||
const repairGameOwnsModel =
|
||||
mainState === "pylon" &&
|
||||
(step === "fragmented" ||
|
||||
step === "scanning" ||
|
||||
step === "reassembling" ||
|
||||
step === "done");
|
||||
|
||||
const beginStraighten = (): void => {
|
||||
setIsStraightening(true);
|
||||
pylonStraighteningSignal.started = true;
|
||||
straightenStartRef.current = performance.now();
|
||||
setCanMove(false);
|
||||
if (groupRef.current) {
|
||||
groupRef.current.rotation.set(...PYLON_DOWNED_ROTATION);
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
setIsStraightening(false);
|
||||
pylonStraighteningSignal.started = false;
|
||||
setCanMove(true);
|
||||
setMissionStep("pylon", "waiting");
|
||||
}, PYLON_STRAIGHTEN_ANIMATION_DURATION_MS);
|
||||
};
|
||||
|
||||
if (repairGameOwnsModel) return null;
|
||||
|
||||
return (
|
||||
<group
|
||||
ref={groupRef}
|
||||
position={PYLON_WORLD_POSITION}
|
||||
rotation={PYLON_DOWNED_ROTATION}
|
||||
>
|
||||
<primitive object={clonedScene} />
|
||||
{isPylonInteractive ? (
|
||||
<InteractableObject
|
||||
kind="trigger"
|
||||
label={
|
||||
step === "arrived" ? "Inspecter le pylône" : "Redresser le pylône"
|
||||
}
|
||||
position={PYLON_WORLD_POSITION}
|
||||
radius={PYLON_NARRATIVE_INTERACT_RADIUS}
|
||||
onPress={() => {
|
||||
if (step === "arrived") {
|
||||
if (!hasPlayedFirstAudioRef.current) {
|
||||
hasPlayedFirstAudioRef.current = true;
|
||||
void (async () => {
|
||||
const manifest = await loadDialogueManifest();
|
||||
if (!manifest) return;
|
||||
const audio = await playDialogueById(
|
||||
manifest,
|
||||
PYLON_NARRATIVE_DIALOGUES.brokenPylon,
|
||||
);
|
||||
if (!audio) return;
|
||||
audio.addEventListener(
|
||||
"ended",
|
||||
() => {
|
||||
void (async () => {
|
||||
const m = await loadDialogueManifest();
|
||||
if (!m) return;
|
||||
await playDialogueById(
|
||||
m,
|
||||
PYLON_NARRATIVE_DIALOGUES.demandeAide,
|
||||
);
|
||||
})();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
})();
|
||||
} else {
|
||||
void (async () => {
|
||||
const manifest = await loadDialogueManifest();
|
||||
if (!manifest) return;
|
||||
await playDialogueById(
|
||||
manifest,
|
||||
PYLON_NARRATIVE_DIALOGUES.demandeAide,
|
||||
);
|
||||
})();
|
||||
}
|
||||
} else if (step === "npc-return" && !isStraightening) {
|
||||
beginStraighten();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<mesh>
|
||||
<sphereGeometry args={[1, 8, 8]} />
|
||||
<meshBasicMaterial transparent opacity={0} depthWrite={false} />
|
||||
</mesh>
|
||||
</InteractableObject>
|
||||
) : null}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
useGLTF.preload(PYLON_MODEL_PATH);
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||
import { playDialogueById } from "@/utils/dialogues/playDialogue";
|
||||
import {
|
||||
PYLON_FARMER_NPC_AFTER_POSITION,
|
||||
PYLON_FARMER_NPC_AFTER_POSITION_pylone_straight,
|
||||
PYLON_FARMER_NPC_AFTER_ROTATION,
|
||||
PYLON_FARMER_NPC_AFTER_SCALE,
|
||||
PYLON_FARMER_NPC_POSITION,
|
||||
PYLON_FARMER_NPC_WALK_SPEED,
|
||||
PYLON_NARRATIVE_DIALOGUES,
|
||||
PYLON_NARRATIVE_INTERACT_RADIUS,
|
||||
} from "@/data/gameplay/pylonConfig";
|
||||
import { pylonStraighteningSignal } from "@/components/gameplay/pylon/pylonSignals";
|
||||
|
||||
const _target = new THREE.Vector3();
|
||||
|
||||
export function PylonFarmerNPC(): React.JSX.Element | null {
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const step = useGameStore((state) => state.pylon.currentStep);
|
||||
const setMissionStep = useGameStore((state) => state.setMissionStep);
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const currentPosRef = useRef(
|
||||
new THREE.Vector3(...PYLON_FARMER_NPC_POSITION),
|
||||
);
|
||||
|
||||
// Reset position when entering arrived, set target when entering npc-return
|
||||
useEffect(() => {
|
||||
if (step === "arrived") {
|
||||
currentPosRef.current.set(...PYLON_FARMER_NPC_POSITION);
|
||||
}
|
||||
}, [step]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
if (step === "npc-return") {
|
||||
const targetPos = pylonStraighteningSignal.started
|
||||
? PYLON_FARMER_NPC_AFTER_POSITION_pylone_straight
|
||||
: PYLON_FARMER_NPC_AFTER_POSITION;
|
||||
_target.set(...targetPos);
|
||||
currentPosRef.current.lerp(_target, Math.min(PYLON_FARMER_NPC_WALK_SPEED * delta, 1));
|
||||
group.position.copy(currentPosRef.current);
|
||||
group.rotation.set(...PYLON_FARMER_NPC_AFTER_ROTATION);
|
||||
group.scale.setScalar(PYLON_FARMER_NPC_AFTER_SCALE);
|
||||
} else if (step === "inspected") {
|
||||
group.position.set(...PYLON_FARMER_NPC_AFTER_POSITION_pylone_straight);
|
||||
group.rotation.set(...PYLON_FARMER_NPC_AFTER_ROTATION);
|
||||
group.scale.setScalar(PYLON_FARMER_NPC_AFTER_SCALE);
|
||||
} else {
|
||||
group.position.set(...PYLON_FARMER_NPC_POSITION);
|
||||
}
|
||||
});
|
||||
|
||||
if (mainState !== "pylon") return null;
|
||||
if (step !== "arrived" && step !== "npc-return" && step !== "inspected") return null;
|
||||
|
||||
return (
|
||||
<group ref={groupRef} position={PYLON_FARMER_NPC_POSITION}>
|
||||
<mesh position={[0, 1, 0]}>
|
||||
<capsuleGeometry args={[0.4, 1.2, 6, 12]} />
|
||||
<meshStandardMaterial color="#a16207" />
|
||||
</mesh>
|
||||
<mesh position={[0, 1.95, 0]}>
|
||||
<sphereGeometry args={[0.28, 12, 12]} />
|
||||
<meshStandardMaterial color="#fde68a" />
|
||||
</mesh>
|
||||
|
||||
{step === "arrived" ? (
|
||||
<InteractableObject
|
||||
kind="trigger"
|
||||
label="Parler au fermier"
|
||||
position={PYLON_FARMER_NPC_POSITION}
|
||||
radius={PYLON_NARRATIVE_INTERACT_RADIUS}
|
||||
onPress={() => {
|
||||
void (async () => {
|
||||
const manifest = await loadDialogueManifest();
|
||||
if (!manifest) {
|
||||
setMissionStep("pylon", "npc-return");
|
||||
return;
|
||||
}
|
||||
const audio = await playDialogueById(
|
||||
manifest,
|
||||
PYLON_NARRATIVE_DIALOGUES.farmerHelp,
|
||||
);
|
||||
if (!audio) {
|
||||
setMissionStep("pylon", "npc-return");
|
||||
return;
|
||||
}
|
||||
audio.addEventListener(
|
||||
"ended",
|
||||
() => setMissionStep("pylon", "npc-return"),
|
||||
{ once: true },
|
||||
);
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<mesh>
|
||||
<sphereGeometry args={[1, 8, 8]} />
|
||||
<meshBasicMaterial transparent opacity={0} depthWrite={false} />
|
||||
</mesh>
|
||||
</InteractableObject>
|
||||
) : null}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect } from "react";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { useDialoguePlayback } from "@/hooks/gameplay/useDialoguePlayback";
|
||||
import { ZoneDetection } from "@/components/zone/ZoneDetection";
|
||||
import { PylonFarmerNPC } from "@/components/gameplay/pylon/PylonFarmerNPC";
|
||||
import { PylonNarratorOutro } from "@/components/gameplay/pylon/PylonNarratorOutro";
|
||||
import { PYLON_APPROACH_ZONE, PYLON_ARRIVED_ZONE } from "@/data/gameplay/zones";
|
||||
import { PYLON_NARRATIVE_DIALOGUES } from "@/data/gameplay/pylonConfig";
|
||||
|
||||
export function PylonNarrativeFlow(): React.JSX.Element | null {
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const step = useGameStore((state) => state.pylon.currentStep);
|
||||
const setMissionStep = useGameStore((state) => state.setMissionStep);
|
||||
const completeMission = useGameStore((state) => state.completeMission);
|
||||
|
||||
useDialoguePlayback({
|
||||
enabled: mainState === "pylon" && step === "approaching",
|
||||
dialogueId: PYLON_NARRATIVE_DIALOGUES.electricOutage,
|
||||
});
|
||||
|
||||
useDialoguePlayback({
|
||||
enabled: mainState === "pylon" && step === "arrived",
|
||||
dialogueId: PYLON_NARRATIVE_DIALOGUES.searchCentral,
|
||||
});
|
||||
|
||||
useDialoguePlayback({
|
||||
enabled: mainState === "pylon" && step === "narrator-outro",
|
||||
dialogueId: PYLON_NARRATIVE_DIALOGUES.powerRestored,
|
||||
onComplete: () => completeMission("pylon"),
|
||||
});
|
||||
|
||||
// Advance waiting → inspected in a separate macrotask so React and Rapier
|
||||
// finish their current commit before RigidBody colliders are created.
|
||||
useEffect(() => {
|
||||
if (mainState !== "pylon" || step !== "waiting") return undefined;
|
||||
const id = window.setTimeout(() => {
|
||||
setMissionStep("pylon", "inspected");
|
||||
}, 0);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [mainState, step, setMissionStep]);
|
||||
|
||||
if (mainState !== "pylon") return null;
|
||||
|
||||
if (step === "locked") {
|
||||
return (
|
||||
<ZoneDetection
|
||||
key="pylon-approach"
|
||||
zone={PYLON_APPROACH_ZONE}
|
||||
onEnter={() => setMissionStep("pylon", "approaching")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "approaching") {
|
||||
return (
|
||||
<ZoneDetection
|
||||
key="pylon-arrived"
|
||||
zone={PYLON_ARRIVED_ZONE}
|
||||
onEnter={() => setMissionStep("pylon", "arrived")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "arrived" || step === "npc-return" || step === "inspected") {
|
||||
return <PylonFarmerNPC />;
|
||||
}
|
||||
|
||||
if (step === "narrator-outro") {
|
||||
return <PylonNarratorOutro />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
|
||||
export function PylonNarratorOutro(): React.JSX.Element | null {
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const step = useGameStore((state) => state.pylon.currentStep);
|
||||
|
||||
if (mainState !== "pylon") return null;
|
||||
if (step !== "narrator-outro") return null;
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Shared runtime signal set by PylonDownedPylon when the straighten
|
||||
* animation starts, so PylonFarmerNPC can switch its lerp target.
|
||||
*/
|
||||
export const pylonStraighteningSignal = { started: false };
|
||||
@@ -219,7 +219,11 @@ export function RepairCaseModel({
|
||||
parsedScale[2] * pop.current.scale,
|
||||
);
|
||||
|
||||
if (placeholderNodes.current.length > 0) {
|
||||
// Placeholders are only consumed when the case is open (repairing). While
|
||||
// floating (inspected/scanning) the case bobs every frame, so emitting here
|
||||
// would fire a React setState on every frame, re-rendering the whole
|
||||
// RepairGame subtree continuously. Only compute when not floating.
|
||||
if (!floating && placeholderNodes.current.length > 0) {
|
||||
const placeholders: RepairCasePlaceholder[] = [];
|
||||
placeholderNodes.current.forEach((child) => {
|
||||
child.getWorldPosition(placeholderPosition.current);
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
|
||||
import type { RepairCasePlaceholder } from "@/components/three/gameplay/RepairCaseModel";
|
||||
import { RepairCompletionStep } from "@/components/three/gameplay/RepairCompletionStep";
|
||||
import { RepairInspectionObject } from "@/components/three/gameplay/RepairInspectionObject";
|
||||
import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase";
|
||||
import { RepairRepairingStep } from "@/components/three/gameplay/RepairRepairingStep";
|
||||
@@ -10,7 +9,9 @@ import { RepairReassemblyStep } from "@/components/three/gameplay/RepairReassemb
|
||||
import { RepairScanSequence } from "@/components/three/gameplay/RepairScanSequence";
|
||||
import { REPAIR_CASE_MODEL_PATH } from "@/data/gameplay/repairCaseConfig";
|
||||
import { REPAIR_FRAGMENTATION_SEQUENCE_SECONDS } from "@/data/gameplay/repairGameConfig";
|
||||
import { getNextMissionStep } from "@/data/gameplay/repairMissionState";
|
||||
import { REPAIR_MISSIONS } from "@/data/gameplay/repairMissions";
|
||||
import { useRepairTransitionStore } from "@/managers/stores/useRepairTransitionStore";
|
||||
import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput";
|
||||
import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep";
|
||||
import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight";
|
||||
@@ -30,6 +31,8 @@ interface RepairGameProps extends Required<
|
||||
mission: RepairMissionId;
|
||||
rotation?: Vector3Tuple;
|
||||
scale?: ModelTransformProps["scale"];
|
||||
/** Set to false in isolated scenes with no terrain (e.g. RepairGameScene). */
|
||||
snapToTerrain?: boolean;
|
||||
}
|
||||
|
||||
interface RepairMissionAssetPreloaderProps {
|
||||
@@ -54,6 +57,7 @@ export function RepairGame({
|
||||
position,
|
||||
rotation = [0, 0, 0],
|
||||
scale = 1,
|
||||
snapToTerrain = true,
|
||||
}: RepairGameProps): React.JSX.Element | null {
|
||||
const config = REPAIR_MISSIONS[mission];
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
@@ -67,7 +71,11 @@ export function RepairGame({
|
||||
readonly RepairScannedBrokenPart[]
|
||||
>([]);
|
||||
const parsedScale = toVector3Scale(scale);
|
||||
const snappedPosition = useTerrainSnappedPosition(position);
|
||||
// useTerrainSnappedPosition must always be called (rules of hooks) but we
|
||||
// only use its result when snapToTerrain is true — in the isolated repair
|
||||
// scene there is no terrain, so we use the raw position directly.
|
||||
const snappedByTerrain = useTerrainSnappedPosition(position);
|
||||
const snappedPosition = snapToTerrain ? snappedByTerrain : position;
|
||||
const readyForFragmentation = step === "inspected";
|
||||
|
||||
useRepairFragmentationInput({
|
||||
@@ -103,6 +111,24 @@ export function RepairGame({
|
||||
};
|
||||
}, [mainState, mission, setMissionStep, step]);
|
||||
|
||||
// When "done" is reached: set pendingCompletion in the transition store.
|
||||
// useRepairGameStatus detects this and triggers the fade back to world.
|
||||
// page.tsx waits for the world to fully load, THEN executes the completion.
|
||||
// This ensures the player sees a loading screen rather than a black flash.
|
||||
useEffect(() => {
|
||||
if (mainState !== mission || step !== "done") return undefined;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
const nextStep = getNextMissionStep("done", mission);
|
||||
useRepairTransitionStore.getState().setPendingCompletion({
|
||||
mission,
|
||||
nextStep,
|
||||
});
|
||||
}, 200);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [mainState, mission, step]);
|
||||
|
||||
if (mainState !== mission) return null;
|
||||
if (step === "locked") return null;
|
||||
|
||||
@@ -149,12 +175,9 @@ export function RepairGame({
|
||||
onComplete={() => setMissionStep(mission, "done")}
|
||||
/>
|
||||
) : null}
|
||||
{step === "done" ? (
|
||||
<RepairCompletionStep
|
||||
config={config}
|
||||
onComplete={() => completeMission(mission)}
|
||||
/>
|
||||
) : null}
|
||||
{/* done step: auto-advance is handled by useEffect above — no manual
|
||||
case-closing interaction needed. Scene is intentionally empty
|
||||
for the 200ms before completeMission/setMissionStep fires. */}
|
||||
{step !== "waiting" && step !== "done" && step !== "reassembling" ? (
|
||||
<RepairMissionCase
|
||||
config={config}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect } from "react";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { REPAIR_CASE_MODEL_PATH } from "@/data/gameplay/repairCaseConfig";
|
||||
import { REPAIR_MISSIONS } from "@/data/gameplay/repairMissions";
|
||||
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||
|
||||
function getPreloadPaths(mission: RepairMissionId): string[] {
|
||||
const config = REPAIR_MISSIONS[mission];
|
||||
return [
|
||||
...new Set([
|
||||
REPAIR_CASE_MODEL_PATH,
|
||||
config.modelPath,
|
||||
...config.brokenParts.flatMap((p) => p.modelPath ?? []),
|
||||
...config.replacementParts.flatMap((p) => p.modelPath ?? []),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
interface RepairGamePreloaderProps {
|
||||
mission: RepairMissionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires useGLTF.preload() for every asset used by a repair mission.
|
||||
* Renders nothing — pure background loading.
|
||||
*/
|
||||
export function RepairGamePreloader({
|
||||
mission,
|
||||
}: RepairGamePreloaderProps): null {
|
||||
useEffect(() => {
|
||||
for (const path of getPreloadPaths(mission)) {
|
||||
useGLTF.preload(path);
|
||||
}
|
||||
}, [mission]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -41,16 +41,16 @@ export function RepairScanSequence({
|
||||
useEffect(() => {
|
||||
if (parts.length === 0) return undefined;
|
||||
|
||||
// Do NOT call onComplete inside a setState updater — updaters run during
|
||||
// React's render phase, which would trigger a setState on RepairGame and
|
||||
// cause a "setState during render" error. Call it directly in the timeout.
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setActivePartIndex((currentIndex) => {
|
||||
const nextIndex = currentIndex + 1;
|
||||
const nextIndex = activePartIndex + 1;
|
||||
if (nextIndex >= parts.length) {
|
||||
onComplete(getScannedBrokenParts(parts, config));
|
||||
return currentIndex;
|
||||
} else {
|
||||
setActivePartIndex(nextIndex);
|
||||
}
|
||||
|
||||
return nextIndex;
|
||||
});
|
||||
}, scanPartSeconds * 1000);
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -3,7 +3,6 @@ import * as THREE from "three";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { useThree } from "@react-three/fiber";
|
||||
import { TERRAIN_MODEL_PATH } from "@/data/world/terrainConfig";
|
||||
import { flattenLaFabrikTerrainFootprint } from "@/data/world/laFabrikConfig";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
import { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene";
|
||||
|
||||
@@ -66,10 +65,9 @@ export function TerrainModel({
|
||||
const terrainModel = useMemo(() => {
|
||||
optimizeGLTFSceneTextures(scene, maxAnisotropy);
|
||||
const model = scene.clone(true);
|
||||
flattenLaFabrikTerrainFootprint(model, position, rotation, scale);
|
||||
applyTerrainMaterialSettings(model, receiveShadow);
|
||||
return model;
|
||||
}, [maxAnisotropy, position, receiveShadow, rotation, scale, scene]);
|
||||
}, [maxAnisotropy, scene, receiveShadow]);
|
||||
|
||||
useEffect(() => {
|
||||
onLoaded?.();
|
||||
|
||||
@@ -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.png";
|
||||
const LOADING_BACKGROUND_PATH = "/assets/bg-site.webp";
|
||||
const LOADING_FRAME_RATE = 12;
|
||||
const LOADING_FRAME_INTERVAL_MS = 1000 / LOADING_FRAME_RATE;
|
||||
const LOADING_LOGO_FRAMES = [
|
||||
"/assets/loader/Loader-1.png",
|
||||
"/assets/loader/Loader-2.png",
|
||||
"/assets/loader/Loader-3.png",
|
||||
"/assets/loader/Loader-4.png",
|
||||
] as const;
|
||||
|
||||
for (const path of [LOADING_BACKGROUND_PATH, LOADING_LOGO_PATH]) {
|
||||
for (const path of [LOADING_BACKGROUND_PATH, ...LOADING_LOGO_FRAMES]) {
|
||||
const image = new Image();
|
||||
image.src = path;
|
||||
}
|
||||
@@ -16,8 +24,25 @@ interface SceneLoadingOverlayProps {
|
||||
export function SceneLoadingOverlay({
|
||||
state,
|
||||
}: SceneLoadingOverlayProps): React.JSX.Element | null {
|
||||
const [logoFrameIndex, setLogoFrameIndex] = useState(0);
|
||||
const isReady = state.status === "ready";
|
||||
const progress = Math.round(Math.max(0, Math.min(1, state.progress)) * 100);
|
||||
const logoFramePath =
|
||||
LOADING_LOGO_FRAMES[logoFrameIndex] ?? LOADING_LOGO_FRAMES[0];
|
||||
|
||||
useEffect(() => {
|
||||
if (isReady) return undefined;
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
setLogoFrameIndex(
|
||||
(currentIndex) => (currentIndex + 1) % LOADING_LOGO_FRAMES.length,
|
||||
);
|
||||
}, LOADING_FRAME_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [isReady]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -33,7 +58,7 @@ export function SceneLoadingOverlay({
|
||||
<img
|
||||
alt="La Fabrik Durable"
|
||||
className="scene-loading-overlay__logo"
|
||||
src={LOADING_LOGO_PATH}
|
||||
src={logoFramePath}
|
||||
/>
|
||||
<div className="scene-loading-overlay__footer">
|
||||
<div className="scene-loading-overlay__meta">
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Suspense } from "react";
|
||||
import { Canvas } from "@react-three/fiber";
|
||||
import { TalkieModel } from "@/components/ui/talkie/TalkieModel";
|
||||
import { TalkieSignalLines } from "@/components/ui/talkie/TalkieSignalLines";
|
||||
import { useTalkieDialogueOverlayState } from "@/hooks/ui/useTalkieDialogueOverlayState";
|
||||
|
||||
export function TalkieDialogueOverlay(): React.JSX.Element | null {
|
||||
const { isNarratorDialogue, isVisible } = useTalkieDialogueOverlayState();
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`talkie-dialogue-overlay${isNarratorDialogue ? " talkie-dialogue-overlay--active" : ""}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{isNarratorDialogue ? <TalkieSignalLines side="left" /> : null}
|
||||
{isNarratorDialogue ? <TalkieSignalLines side="right" /> : null}
|
||||
<div className="talkie-dialogue-overlay__model-frame">
|
||||
<Canvas
|
||||
camera={{ position: [0, 0, 4.2], zoom: 56 }}
|
||||
dpr={[1, 1.5]}
|
||||
gl={{ alpha: true, antialias: true }}
|
||||
orthographic
|
||||
>
|
||||
<ambientLight intensity={2.5} />
|
||||
<directionalLight position={[2, 3, 4]} intensity={2.8} />
|
||||
<Suspense fallback={null}>
|
||||
<TalkieModel active={isNarratorDialogue} />
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import * as THREE from "three";
|
||||
import gsap from "gsap";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
const TALKIE_MODEL_PATH = "/models/talkie/model.gltf";
|
||||
const TALKIE_REST_Y = -1.55;
|
||||
const TALKIE_ACTIVE_Y = -0.38;
|
||||
const TALKIE_BASE_ROTATION: Vector3Tuple = [0.08, -0.52, -0.04];
|
||||
const TALKIE_FLOAT_ROTATION_AMPLITUDE = THREE.MathUtils.degToRad(2.2);
|
||||
const TALKIE_FLOAT_Y_AMPLITUDE = 0.055;
|
||||
|
||||
interface TalkieModelProps {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export function TalkieModel({ active }: TalkieModelProps): React.JSX.Element {
|
||||
const { scene } = useGLTF(TALKIE_MODEL_PATH);
|
||||
const model = useMemo(() => scene.clone(true), [scene]);
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const floatRef = useRef<THREE.Group>(null);
|
||||
|
||||
useEffect(() => {
|
||||
model.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.castShadow = false;
|
||||
child.receiveShadow = false;
|
||||
child.frustumCulled = false;
|
||||
}
|
||||
});
|
||||
}, [model]);
|
||||
|
||||
useEffect(() => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
gsap.killTweensOf(group.position);
|
||||
gsap.to(group.position, {
|
||||
y: active ? TALKIE_ACTIVE_Y : TALKIE_REST_Y,
|
||||
duration: active ? 0.72 : 0.5,
|
||||
ease: active ? "power3.out" : "power2.out",
|
||||
});
|
||||
|
||||
return () => {
|
||||
gsap.killTweensOf(group.position);
|
||||
};
|
||||
}, [active]);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (!floatRef.current) return;
|
||||
|
||||
const t = clock.getElapsedTime();
|
||||
floatRef.current.position.y = Math.sin(t * 1.2) * TALKIE_FLOAT_Y_AMPLITUDE;
|
||||
|
||||
floatRef.current.rotation.x =
|
||||
TALKIE_BASE_ROTATION[0] +
|
||||
Math.sin(t * 0.7) * TALKIE_FLOAT_ROTATION_AMPLITUDE;
|
||||
floatRef.current.rotation.y =
|
||||
TALKIE_BASE_ROTATION[1] +
|
||||
Math.sin(t * 0.55) * TALKIE_FLOAT_ROTATION_AMPLITUDE;
|
||||
floatRef.current.rotation.z =
|
||||
TALKIE_BASE_ROTATION[2] +
|
||||
Math.sin(t * 0.8) * TALKIE_FLOAT_ROTATION_AMPLITUDE;
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={groupRef} position={[0, TALKIE_REST_Y, 0]}>
|
||||
<group ref={floatRef} rotation={TALKIE_BASE_ROTATION}>
|
||||
<primitive
|
||||
object={model}
|
||||
position={[0, -2.45, 0]}
|
||||
rotation={[0, -1, 0]}
|
||||
scale={1.2}
|
||||
/>
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
useGLTF.preload(TALKIE_MODEL_PATH);
|
||||
@@ -0,0 +1,19 @@
|
||||
interface TalkieSignalLinesProps {
|
||||
side: "left" | "right";
|
||||
}
|
||||
|
||||
export function TalkieSignalLines({
|
||||
side,
|
||||
}: TalkieSignalLinesProps): React.JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
className={`talkie-dialogue-overlay__signals talkie-dialogue-overlay__signals--${side}`}
|
||||
viewBox="0 0 90 120"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M18 48 C30 58 30 72 18 82" />
|
||||
<path d="M34 34 C56 52 56 78 34 96" />
|
||||
<path d="M52 20 C84 46 84 84 52 110" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
|
||||
import type { ZoneConfig } from "@/types/gameplay/zone";
|
||||
|
||||
interface ZoneDetectionProps {
|
||||
zone: ZoneConfig;
|
||||
onEnter: () => void;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const _cameraPos = new THREE.Vector3();
|
||||
|
||||
export function ZoneDebugVisual({
|
||||
zone,
|
||||
active,
|
||||
}: {
|
||||
zone: ZoneConfig;
|
||||
active: boolean;
|
||||
}): React.JSX.Element | null {
|
||||
if (!isDebugEnabled()) return null;
|
||||
return (
|
||||
<group position={zone.position}>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[zone.radius - 0.2, zone.radius, 32]} />
|
||||
<meshBasicMaterial
|
||||
color={active ? "#22c55e" : "#fbbf24"}
|
||||
transparent
|
||||
opacity={0.6}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh>
|
||||
<cylinderGeometry
|
||||
args={[zone.radius, zone.radius, zone.height, 16, 1, true]}
|
||||
/>
|
||||
<meshBasicMaterial
|
||||
color={active ? "#22c55e" : "#fbbf24"}
|
||||
transparent
|
||||
opacity={0.08}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export function ZoneDetection({
|
||||
zone,
|
||||
onEnter,
|
||||
height,
|
||||
}: ZoneDetectionProps): React.JSX.Element {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const hasTriggeredRef = useRef(false);
|
||||
const onEnterRef = useRef(onEnter);
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
onEnterRef.current = onEnter;
|
||||
}, [onEnter]);
|
||||
|
||||
useFrame(() => {
|
||||
if (hasTriggeredRef.current) return;
|
||||
|
||||
camera.getWorldPosition(_cameraPos);
|
||||
const dx = _cameraPos.x - zone.position[0];
|
||||
const dz = _cameraPos.z - zone.position[2];
|
||||
const horizontalDist = Math.sqrt(dx * dx + dz * dz);
|
||||
|
||||
if (horizontalDist > zone.radius) return;
|
||||
|
||||
const zoneHeight = height ?? zone.height;
|
||||
if (_cameraPos.y < zone.position[1] - zoneHeight / 2) return;
|
||||
if (_cameraPos.y > zone.position[1] + zoneHeight / 2) return;
|
||||
|
||||
hasTriggeredRef.current = true;
|
||||
setIsActive(true);
|
||||
onEnterRef.current();
|
||||
});
|
||||
|
||||
return <ZoneDebugVisual zone={zone} active={isActive} />;
|
||||
}
|
||||
@@ -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, 8.4, 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",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
export const PYLON_WORLD_POSITION: Vector3Tuple = [43, 5, 45];
|
||||
|
||||
export const PYLON_DOWNED_ROTATION: Vector3Tuple = [0, 0, -0.9];
|
||||
|
||||
export const PYLON_UPRIGHT_ROTATION: Vector3Tuple = [0, 0, 0];
|
||||
|
||||
export const PYLON_FARMER_NPC_POSITION: Vector3Tuple = [
|
||||
PYLON_WORLD_POSITION[0] - 6,
|
||||
PYLON_WORLD_POSITION[1],
|
||||
PYLON_WORLD_POSITION[2] + 4,
|
||||
];
|
||||
|
||||
export const PYLON_FARMER_NPC_AFTER_POSITION: Vector3Tuple = [
|
||||
PYLON_WORLD_POSITION[0] + 3,
|
||||
PYLON_WORLD_POSITION[1],
|
||||
PYLON_WORLD_POSITION[2],
|
||||
];
|
||||
|
||||
/** Position finale du PNJ quand le pylône se redresse */
|
||||
export const PYLON_FARMER_NPC_AFTER_POSITION_pylone_straight: Vector3Tuple = [
|
||||
PYLON_WORLD_POSITION[0] + 1,
|
||||
PYLON_WORLD_POSITION[1],
|
||||
PYLON_WORLD_POSITION[2],
|
||||
];
|
||||
|
||||
/** Rotation (X Y Z radians) du PNJ une fois arrivé sous le pylône */
|
||||
export const PYLON_FARMER_NPC_AFTER_ROTATION: Vector3Tuple = [0, 0, 0];
|
||||
|
||||
/** Scale uniforme du PNJ une fois arrivé sous le pylône */
|
||||
export const PYLON_FARMER_NPC_AFTER_SCALE = 1;
|
||||
|
||||
/** Vitesse du lerp de déplacement du PNJ (unités/s) */
|
||||
export const PYLON_FARMER_NPC_WALK_SPEED = 2;
|
||||
|
||||
export const PYLON_NARRATIVE_INTERACT_RADIUS = 3.5;
|
||||
|
||||
export const PYLON_STRAIGHTEN_ANIMATION_DURATION_MS = 2200;
|
||||
|
||||
export const PYLON_NARRATIVE_DIALOGUES = {
|
||||
electricOutage: "narrateur_coupureelec",
|
||||
searchCentral: "narrateur_fouillelecentre",
|
||||
brokenPylon: "narrateur_poteaueleccasse",
|
||||
demandeAide: "narrateur_demande_aide",
|
||||
farmerHelp: "fermier_coupdemain",
|
||||
powerRestored: "narrateur_courantrepare",
|
||||
} as const;
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
RepairMissionTriggerConfig,
|
||||
} from "@/types/gameplay/repairMission";
|
||||
import { EBIKE_WORLD_POSITION } from "@/data/ebike/ebikeConfig";
|
||||
import { PYLON_WORLD_POSITION } from "@/data/gameplay/pylonConfig";
|
||||
|
||||
export const REPAIR_MISSION_ANCHOR_IDS: Partial<
|
||||
Record<RepairMissionId, string>
|
||||
@@ -15,7 +16,7 @@ const EBIKE_REPAIR_POSITION = EBIKE_WORLD_POSITION satisfies Vector3Tuple;
|
||||
|
||||
const REPAIR_MISSION_POSITIONS = {
|
||||
ebike: EBIKE_REPAIR_POSITION,
|
||||
pylon: [64, 0, -66],
|
||||
pylon: PYLON_WORLD_POSITION,
|
||||
farm: [-24, 0, 42],
|
||||
} as const satisfies Record<RepairMissionId, Vector3Tuple>;
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ const REPAIR_MISSION_ID_VALUES: ReadonlySet<string> = new Set(
|
||||
|
||||
export const MISSION_STEPS = [
|
||||
"locked",
|
||||
"approaching",
|
||||
"arrived",
|
||||
"npc-return",
|
||||
"waiting",
|
||||
"inspected",
|
||||
"fragmented",
|
||||
@@ -17,6 +20,7 @@ export const MISSION_STEPS = [
|
||||
"repairing",
|
||||
"reassembling",
|
||||
"done",
|
||||
"narrator-outro",
|
||||
] as const satisfies readonly MissionStep[];
|
||||
const MISSION_STEP_VALUES: ReadonlySet<string> = new Set(MISSION_STEPS);
|
||||
|
||||
@@ -28,9 +32,18 @@ export function isMissionStep(value: string): value is MissionStep {
|
||||
return MISSION_STEP_VALUES.has(value);
|
||||
}
|
||||
|
||||
export function getNextMissionStep(step: MissionStep): MissionStep {
|
||||
export function getNextMissionStep(
|
||||
step: MissionStep,
|
||||
mission?: RepairMissionId,
|
||||
): MissionStep {
|
||||
switch (step) {
|
||||
case "locked":
|
||||
return mission === "pylon" ? "approaching" : "waiting";
|
||||
case "approaching":
|
||||
return "arrived";
|
||||
case "arrived":
|
||||
return "npc-return";
|
||||
case "npc-return":
|
||||
return "waiting";
|
||||
case "waiting":
|
||||
return "inspected";
|
||||
@@ -43,16 +56,29 @@ export function getNextMissionStep(step: MissionStep): MissionStep {
|
||||
case "repairing":
|
||||
return "reassembling";
|
||||
case "reassembling":
|
||||
case "done":
|
||||
return "done";
|
||||
case "done":
|
||||
return mission === "pylon" ? "narrator-outro" : "done";
|
||||
case "narrator-outro":
|
||||
return "narrator-outro";
|
||||
}
|
||||
}
|
||||
|
||||
export function getPreviousMissionStep(step: MissionStep): MissionStep {
|
||||
export function getPreviousMissionStep(
|
||||
step: MissionStep,
|
||||
mission?: RepairMissionId,
|
||||
): MissionStep {
|
||||
switch (step) {
|
||||
case "locked":
|
||||
case "waiting":
|
||||
return "locked";
|
||||
case "approaching":
|
||||
return "locked";
|
||||
case "arrived":
|
||||
return "approaching";
|
||||
case "npc-return":
|
||||
return "arrived";
|
||||
case "waiting":
|
||||
return mission === "pylon" ? "npc-return" : "locked";
|
||||
case "inspected":
|
||||
return "waiting";
|
||||
case "fragmented":
|
||||
@@ -65,5 +91,7 @@ export function getPreviousMissionStep(step: MissionStep): MissionStep {
|
||||
return "repairing";
|
||||
case "done":
|
||||
return "reassembling";
|
||||
case "narrator-outro":
|
||||
return "done";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
||||
{
|
||||
id: "pylon-damaged-panel",
|
||||
label: "Damaged solar panel",
|
||||
nodeName: "panneau2",
|
||||
nodeName: "pylone",
|
||||
caseSlotName: "placeholder_2",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ZoneConfig } from "@/types/gameplay/zone";
|
||||
import { PYLON_WORLD_POSITION } from "@/data/gameplay/pylonConfig";
|
||||
|
||||
export const PYLON_APPROACH_ZONE: ZoneConfig = {
|
||||
id: "pylon-approach",
|
||||
position: [
|
||||
PYLON_WORLD_POSITION[0],
|
||||
PYLON_WORLD_POSITION[1]- 5,
|
||||
PYLON_WORLD_POSITION[2],
|
||||
],
|
||||
radius: 5,
|
||||
height: 18,
|
||||
oneShot: true,
|
||||
};
|
||||
|
||||
export const PYLON_ARRIVED_ZONE: ZoneConfig = {
|
||||
id: "pylon-arrived",
|
||||
position: [
|
||||
PYLON_WORLD_POSITION[0] + 5,
|
||||
PYLON_WORLD_POSITION[1] - 5,
|
||||
PYLON_WORLD_POSITION[2] + 5,
|
||||
],
|
||||
radius: 5,
|
||||
height: 15,
|
||||
oneShot: true,
|
||||
};
|
||||
@@ -5,7 +5,7 @@ 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;
|
||||
@@ -15,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 = LA_FABRIK_PLAYER_SPAWN;
|
||||
export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [
|
||||
LA_FABRIK_PLAYER_SPAWN[0] + 1,
|
||||
LA_FABRIK_PLAYER_SPAWN[1],
|
||||
LA_FABRIK_PLAYER_SPAWN[2] - 1,
|
||||
];
|
||||
export const PLAYER_SPAWN_POSITION_PHYSICS: Vector3Tuple = [0, 3, 0];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -20,7 +20,6 @@ export interface CharacterConfig {
|
||||
scale: Vector3Tuple;
|
||||
animations: readonly string[];
|
||||
defaultAnimation: string;
|
||||
snapToTerrain?: boolean;
|
||||
}
|
||||
|
||||
export const CHARACTER_CONFIGS = {
|
||||
@@ -43,7 +42,6 @@ export const CHARACTER_CONFIGS = {
|
||||
scale: [1.55, 1.55, 1.55],
|
||||
animations: ["idle", "walk"],
|
||||
defaultAnimation: "idle",
|
||||
snapToTerrain: false,
|
||||
},
|
||||
fermier: {
|
||||
id: "fermier",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as THREE from "three";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
export const LA_FABRIK_CENTER: Vector3Tuple = [59.4973, 6.2746, 64.6354];
|
||||
@@ -7,15 +6,10 @@ export const LA_FABRIK_HALF_EXTENTS = {
|
||||
x: 8.5,
|
||||
z: 7.5,
|
||||
} as const;
|
||||
export const LA_FABRIK_FLOOR_Y = 6.3;
|
||||
export const LA_FABRIK_PLAYER_SPAWN: Vector3Tuple = [59.5, 8.05, 64.64];
|
||||
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];
|
||||
|
||||
const _terrainMatrix = new THREE.Matrix4();
|
||||
const _meshWorldMatrix = new THREE.Matrix4();
|
||||
const _inverseMeshWorldMatrix = new THREE.Matrix4();
|
||||
const _worldPosition = new THREE.Vector3();
|
||||
|
||||
export function isInsideLaFabrikFootprint(
|
||||
x: number,
|
||||
z: number,
|
||||
@@ -33,51 +27,3 @@ export function isInsideLaFabrikFootprint(
|
||||
Math.abs(localZ) <= LA_FABRIK_HALF_EXTENTS.z + padding
|
||||
);
|
||||
}
|
||||
|
||||
export function flattenLaFabrikTerrainFootprint(
|
||||
object: THREE.Object3D,
|
||||
position: Vector3Tuple,
|
||||
rotation: Vector3Tuple,
|
||||
scale: Vector3Tuple,
|
||||
): void {
|
||||
_terrainMatrix.compose(
|
||||
new THREE.Vector3(...position),
|
||||
new THREE.Quaternion().setFromEuler(new THREE.Euler(...rotation)),
|
||||
new THREE.Vector3(...scale),
|
||||
);
|
||||
object.updateMatrixWorld(true);
|
||||
|
||||
object.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh)) return;
|
||||
const geometry = child.geometry;
|
||||
const positions = geometry.getAttribute("position");
|
||||
if (!positions) return;
|
||||
|
||||
_meshWorldMatrix.multiplyMatrices(_terrainMatrix, child.matrixWorld);
|
||||
_inverseMeshWorldMatrix.copy(_meshWorldMatrix).invert();
|
||||
|
||||
for (let index = 0; index < positions.count; index++) {
|
||||
_worldPosition
|
||||
.fromBufferAttribute(positions, index)
|
||||
.applyMatrix4(_meshWorldMatrix);
|
||||
|
||||
if (!isInsideLaFabrikFootprint(_worldPosition.x, _worldPosition.z, 0.8)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
_worldPosition.y = Math.min(_worldPosition.y, LA_FABRIK_FLOOR_Y - 0.35);
|
||||
_worldPosition.applyMatrix4(_inverseMeshWorldMatrix);
|
||||
positions.setXYZ(
|
||||
index,
|
||||
_worldPosition.x,
|
||||
_worldPosition.y,
|
||||
_worldPosition.z,
|
||||
);
|
||||
}
|
||||
|
||||
positions.needsUpdate = true;
|
||||
geometry.computeVertexNormals();
|
||||
geometry.computeBoundingBox();
|
||||
geometry.computeBoundingSphere();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,61 @@
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
export interface OctreeCollisionBox {
|
||||
center: Vector3Tuple;
|
||||
size: Vector3Tuple;
|
||||
}
|
||||
|
||||
export interface MapOctreeCollisionBox extends OctreeCollisionBox {
|
||||
bottomY: number;
|
||||
}
|
||||
|
||||
export const MAP_OCTREE_COLLISION_BOXES = {
|
||||
immeuble1: {
|
||||
center: [-0.0308, 5.8389, 0],
|
||||
size: [17.2522, 11.6098, 9.2668],
|
||||
bottomY: 0.034,
|
||||
},
|
||||
maison1: {
|
||||
center: [0, 1.3638, 0.0536],
|
||||
size: [2.7813, 3.022, 2.8609],
|
||||
bottomY: -0.1472,
|
||||
},
|
||||
} as const satisfies Record<string, MapOctreeCollisionBox>;
|
||||
|
||||
export const LA_FABRIK_INTERIOR_COLLISION_BOXES = [
|
||||
// NOTE: removed — this thin wall (size [0.2, 1.94, 3.71]) sat at x≈-6.93 and
|
||||
// sealed the doorway despite the geometry having a hole there. The fabrik
|
||||
// mesh octree already provides the surrounding wall collision, so this
|
||||
// proxy was both redundant and bug-causing.
|
||||
// {
|
||||
// center: [-6.9351, 2.278, -0.0001],
|
||||
// size: [0.2, 1.94, 3.711],
|
||||
// },
|
||||
{
|
||||
center: [0.8026, 0.719, -3.639],
|
||||
size: [4.346, 1.108, 1.181],
|
||||
},
|
||||
{
|
||||
center: [-5.8519, 0.9362, 2.5742],
|
||||
size: [1.67, 1.551, 2.566],
|
||||
},
|
||||
{
|
||||
center: [-2.0627, 1.4875, -1.2243],
|
||||
size: [0.691, 0.723, 0.687],
|
||||
},
|
||||
{
|
||||
center: [-3.5502, 1.4378, -1.2485],
|
||||
size: [1.055, 0.657, 0.563],
|
||||
},
|
||||
] as const satisfies readonly OctreeCollisionBox[];
|
||||
|
||||
export const CHARACTER_OCTREE_COLLISION_BOX = {
|
||||
center: [0, 0.875, 0],
|
||||
size: [0.62, 1.75, 0.62],
|
||||
} as const satisfies OctreeCollisionBox;
|
||||
|
||||
export function hasMapOctreeCollisionBox(
|
||||
name: string,
|
||||
): name is keyof typeof MAP_OCTREE_COLLISION_BOXES {
|
||||
return name in MAP_OCTREE_COLLISION_BOXES;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||
import { useDebugVisualsStore } from "@/managers/stores/useDebugVisualsStore";
|
||||
|
||||
export function useDebugVisualsDebug(): void {
|
||||
useDebugFolder("Debug", (folder) => {
|
||||
const state = useDebugVisualsStore.getState();
|
||||
const controls = {
|
||||
showPlayerModel: state.showPlayerModel,
|
||||
showOctree: state.showOctree,
|
||||
octreeMinDepth: state.octreeMinDepth,
|
||||
octreeMaxDepth: state.octreeMaxDepth,
|
||||
octreeLeavesOnly: state.octreeLeavesOnly,
|
||||
octreeOpacity: state.octreeOpacity,
|
||||
octreeFabrikOnly: state.octreeFabrikOnly,
|
||||
};
|
||||
|
||||
folder
|
||||
.add(controls, "showPlayerModel")
|
||||
.name("Show Player Model")
|
||||
.onChange((value: boolean) => {
|
||||
useDebugVisualsStore.getState().setShowPlayerModel(value);
|
||||
});
|
||||
|
||||
folder
|
||||
.add(controls, "showOctree")
|
||||
.name("Show Octree")
|
||||
.onChange((value: boolean) => {
|
||||
useDebugVisualsStore.getState().setShowOctree(value);
|
||||
});
|
||||
|
||||
folder
|
||||
.add(controls, "octreeLeavesOnly")
|
||||
.name("Octree Leaves Only")
|
||||
.onChange((value: boolean) => {
|
||||
useDebugVisualsStore.getState().setOctreeLeavesOnly(value);
|
||||
});
|
||||
|
||||
folder
|
||||
.add(controls, "octreeMinDepth", 0, 10, 1)
|
||||
.name("Octree Min Depth")
|
||||
.onChange((value: number) => {
|
||||
useDebugVisualsStore.getState().setOctreeMinDepth(value);
|
||||
});
|
||||
|
||||
folder
|
||||
.add(controls, "octreeMaxDepth", 0, 10, 1)
|
||||
.name("Octree Max Depth")
|
||||
.onChange((value: number) => {
|
||||
useDebugVisualsStore.getState().setOctreeMaxDepth(value);
|
||||
});
|
||||
|
||||
folder
|
||||
.add(controls, "octreeOpacity", 0.05, 1, 0.05)
|
||||
.name("Octree Opacity")
|
||||
.onChange((value: number) => {
|
||||
useDebugVisualsStore.getState().setOctreeOpacity(value);
|
||||
});
|
||||
|
||||
folder
|
||||
.add(controls, "octreeFabrikOnly")
|
||||
.name("Octree Fabrik Only")
|
||||
.onChange((value: boolean) => {
|
||||
useDebugVisualsStore.getState().setOctreeFabrikOnly(value);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useRef } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import type GUI from "lil-gui";
|
||||
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||
|
||||
export function usePlayerPositionDebug(): void {
|
||||
const pos = useRef({ x: 0, y: 0, z: 0 });
|
||||
const controllers = useRef<{ updateDisplay: () => void }[]>([]);
|
||||
|
||||
useDebugFolder("Game", (folder: GUI) => {
|
||||
const sub = folder.addFolder("Player Position");
|
||||
sub.open();
|
||||
|
||||
controllers.current = [
|
||||
sub.add(pos.current, "x").name("X").decimals(2).disable(),
|
||||
sub.add(pos.current, "y").name("Y").decimals(2).disable(),
|
||||
sub.add(pos.current, "z").name("Z").decimals(2).disable(),
|
||||
];
|
||||
});
|
||||
|
||||
useFrame(() => {
|
||||
const p = window.playerPos;
|
||||
if (!p) return;
|
||||
pos.current.x = p[0];
|
||||
pos.current.y = p[1];
|
||||
pos.current.z = p[2];
|
||||
for (const c of controllers.current) c.updateDisplay();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useEffect } from "react";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||
import { playDialogueById } from "@/utils/dialogues/playDialogue";
|
||||
|
||||
interface UseDialoguePlaybackOptions {
|
||||
enabled: boolean;
|
||||
dialogueId: string | null;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
export function useDialoguePlayback({
|
||||
enabled,
|
||||
dialogueId,
|
||||
onComplete,
|
||||
}: UseDialoguePlaybackOptions): void {
|
||||
const setCanMove = useGameStore((state) => state.setCanMove);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !dialogueId) return undefined;
|
||||
|
||||
let isCancelled = false;
|
||||
setCanMove(false);
|
||||
|
||||
void (async () => {
|
||||
const manifest = await loadDialogueManifest();
|
||||
if (isCancelled || !manifest) {
|
||||
setCanMove(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const audio = await playDialogueById(manifest, dialogueId);
|
||||
if (isCancelled || !audio) {
|
||||
setCanMove(true);
|
||||
return;
|
||||
}
|
||||
|
||||
audio.addEventListener(
|
||||
"ended",
|
||||
() => {
|
||||
setCanMove(true);
|
||||
onComplete?.();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
setCanMove(true);
|
||||
};
|
||||
}, [enabled, dialogueId, onComplete, setCanMove]);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { useRepairTransitionStore } from "@/managers/stores/useRepairTransitionStore";
|
||||
import { isRepairGameStep } from "@/types/gameplay/repairMission";
|
||||
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||
|
||||
export interface RepairGameStatus {
|
||||
active: boolean;
|
||||
mission: RepairMissionId | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a repair game is currently active and for which mission.
|
||||
* Drives the scene swap in page.tsx: when active, the heavy 3D world is
|
||||
* unmounted and a lightweight isolated repair scene is shown instead.
|
||||
*/
|
||||
export function useRepairGameStatus(): RepairGameStatus {
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const ebikeStep = useGameStore((state) => state.ebike.currentStep);
|
||||
const pylonStep = useGameStore((state) => state.pylon.currentStep);
|
||||
const farmStep = useGameStore((state) => state.farm.currentStep);
|
||||
// When pendingCompletion is set the repair game is "done" but we want the
|
||||
// world to finish loading before executing the completion. Returning
|
||||
// active=false here triggers the fade back to the world scene.
|
||||
const pendingCompletion = useRepairTransitionStore(
|
||||
(s) => s.pendingCompletion,
|
||||
);
|
||||
|
||||
if (pendingCompletion !== null) {
|
||||
return { active: false, mission: null };
|
||||
}
|
||||
|
||||
if (mainState === "ebike" && isRepairGameStep(ebikeStep)) {
|
||||
return { active: true, mission: "ebike" };
|
||||
}
|
||||
if (mainState === "pylon" && isRepairGameStep(pylonStep)) {
|
||||
return { active: true, mission: "pylon" };
|
||||
}
|
||||
if (mainState === "farm" && isRepairGameStep(farmStep)) {
|
||||
return { active: true, mission: "farm" };
|
||||
}
|
||||
|
||||
return { active: false, mission: null };
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import type { Object3D } from "three";
|
||||
import { type Object3D } from "three";
|
||||
import { Octree } from "three-stdlib";
|
||||
import type { OctreeReadyHandler } from "@/types/three/three";
|
||||
|
||||
@@ -27,6 +27,7 @@ export function useOctreeGraphNode(
|
||||
|
||||
const octree = new Octree();
|
||||
octree.fromGraphNode(graphNode);
|
||||
|
||||
onOctreeReady(octree);
|
||||
}, [enabled, graphNodeRef, onOctreeReady, rebuildKey]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useRef } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import {
|
||||
Material,
|
||||
Mesh,
|
||||
type DirectionalLight,
|
||||
type Scene,
|
||||
type WebGLRenderer,
|
||||
} from "three";
|
||||
|
||||
interface UseShadowMapWarmupOptions {
|
||||
/** Light whose shadow map should be reallocated once the scene stabilizes. */
|
||||
light: React.RefObject<DirectionalLight | null>;
|
||||
scene: Scene;
|
||||
gl: WebGLRenderer;
|
||||
invalidate: () => void;
|
||||
/** Frames the mesh count must remain unchanged to consider the scene stable. */
|
||||
stableFramesThreshold?: number;
|
||||
/** Hard cap on how long we keep watching, in frames (~5s @60fps). */
|
||||
safetyCapFrames?: number;
|
||||
/** Sample mesh count every N frames to keep the traversal cost negligible. */
|
||||
sampleEveryFrames?: number;
|
||||
}
|
||||
|
||||
export function useShadowMapWarmup({
|
||||
light,
|
||||
scene,
|
||||
gl,
|
||||
invalidate,
|
||||
stableFramesThreshold = 60,
|
||||
safetyCapFrames = 300,
|
||||
sampleEveryFrames = 6,
|
||||
}: UseShadowMapWarmupOptions): void {
|
||||
const meshCountRef = useRef(0);
|
||||
const stableFramesRef = useRef(0);
|
||||
const watchFramesRef = useRef(0);
|
||||
const doneRef = useRef(false);
|
||||
|
||||
useFrame(() => {
|
||||
if (doneRef.current || !light.current) return;
|
||||
|
||||
watchFramesRef.current += 1;
|
||||
|
||||
if (watchFramesRef.current % sampleEveryFrames === 0) {
|
||||
let meshCount = 0;
|
||||
scene.traverse((object) => {
|
||||
if (object instanceof Mesh) meshCount += 1;
|
||||
});
|
||||
|
||||
if (meshCount !== meshCountRef.current) {
|
||||
meshCountRef.current = meshCount;
|
||||
stableFramesRef.current = 0;
|
||||
} else {
|
||||
stableFramesRef.current += sampleEveryFrames;
|
||||
}
|
||||
}
|
||||
|
||||
const stableEnough = stableFramesRef.current >= stableFramesThreshold;
|
||||
const safetyCapReached = watchFramesRef.current >= safetyCapFrames;
|
||||
if (!stableEnough && !safetyCapReached) return;
|
||||
|
||||
doneRef.current = true;
|
||||
reallocateShadowMap(light.current);
|
||||
invalidateAllMaterials(scene);
|
||||
forceShadowPass(gl, scene, light.current);
|
||||
invalidate();
|
||||
});
|
||||
}
|
||||
|
||||
function reallocateShadowMap(light: DirectionalLight): void {
|
||||
const shadowMap = light.shadow.map;
|
||||
if (!shadowMap) return;
|
||||
|
||||
shadowMap.dispose();
|
||||
light.shadow.map = null;
|
||||
}
|
||||
|
||||
function invalidateAllMaterials(scene: Scene): void {
|
||||
const seen = new Set<Material>();
|
||||
scene.traverse((object) => {
|
||||
if (!(object instanceof Mesh)) return;
|
||||
const materials = Array.isArray(object.material)
|
||||
? object.material
|
||||
: [object.material];
|
||||
for (const material of materials) {
|
||||
if (!material || seen.has(material)) continue;
|
||||
seen.add(material);
|
||||
material.needsUpdate = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function forceShadowPass(
|
||||
gl: WebGLRenderer,
|
||||
scene: Scene,
|
||||
light: DirectionalLight,
|
||||
): void {
|
||||
scene.updateMatrixWorld(true);
|
||||
light.target.updateMatrixWorld(true);
|
||||
light.updateMatrixWorld(true);
|
||||
light.shadow.camera.updateMatrixWorld(true);
|
||||
light.shadow.camera.updateProjectionMatrix();
|
||||
light.shadow.needsUpdate = true;
|
||||
gl.shadowMap.needsUpdate = true;
|
||||
}
|
||||
@@ -2,10 +2,6 @@ import { useMemo } from "react";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import * as THREE from "three";
|
||||
import { TERRAIN_MODEL_PATH } from "@/data/world/terrainConfig";
|
||||
import {
|
||||
isInsideLaFabrikFootprint,
|
||||
LA_FABRIK_FLOOR_Y,
|
||||
} from "@/data/world/laFabrikConfig";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
import { getMapNodesByName } from "@/utils/map/loadMapSceneData";
|
||||
|
||||
@@ -70,10 +66,6 @@ function createTerrainHeightSampler(
|
||||
|
||||
return {
|
||||
getHeight: (x, z) => {
|
||||
if (isInsideLaFabrikFootprint(x, z, 0.6)) {
|
||||
return LA_FABRIK_FLOOR_Y;
|
||||
}
|
||||
|
||||
localOrigin.set(x, RAYCAST_Y, z).applyMatrix4(inverseTerrainMatrix);
|
||||
raycaster.set(localOrigin, localDirection);
|
||||
hits.length = 0;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { GAME_STEPS } from "@/data/game/gameStateConfig";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
|
||||
|
||||
const TALKIE_FIRST_VISIBLE_STEP = "reveal";
|
||||
const TALKIE_FIRST_VISIBLE_STEP_INDEX = GAME_STEPS.indexOf(
|
||||
TALKIE_FIRST_VISIBLE_STEP,
|
||||
);
|
||||
|
||||
interface TalkieDialogueOverlayState {
|
||||
isNarratorDialogue: boolean;
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
export function useTalkieDialogueOverlayState(): TalkieDialogueOverlayState {
|
||||
const activeSubtitle = useSubtitleStore((state) => state.activeSubtitle);
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const introStep = useGameStore((state) => state.intro.currentStep);
|
||||
const introStepIndex = GAME_STEPS.indexOf(introStep);
|
||||
|
||||
return {
|
||||
isNarratorDialogue: activeSubtitle?.speaker === "Narrateur",
|
||||
isVisible:
|
||||
mainState !== "intro" ||
|
||||
introStepIndex >= TALKIE_FIRST_VISIBLE_STEP_INDEX,
|
||||
};
|
||||
}
|
||||
@@ -11,13 +11,10 @@ interface UseWorldSceneLoadingOptions {
|
||||
interface UseWorldSceneLoadingResult {
|
||||
octree: Octree | null;
|
||||
gameplayReady: boolean;
|
||||
shouldWarmUpShadows: boolean;
|
||||
showGameStage: boolean;
|
||||
handleGameStageLoaded: () => void;
|
||||
handleGameMapLoaded: () => void;
|
||||
handleOctreeReady: (octree: Octree) => void;
|
||||
handleShadowWarmupReady: () => void;
|
||||
handleShadowWarmupStarted: () => void;
|
||||
}
|
||||
|
||||
export function useWorldSceneLoading({
|
||||
@@ -27,19 +24,13 @@ export function useWorldSceneLoading({
|
||||
const [octree, setOctree] = useState<Octree | null>(null);
|
||||
const [gameMapLoaded, setGameMapLoaded] = useState(false);
|
||||
const [gameStageLoaded, setGameStageLoaded] = useState(false);
|
||||
const [shadowsReady, setShadowsReady] = useState(false);
|
||||
const showGameStage = sceneMode === "game" && gameMapLoaded;
|
||||
const gameSceneReadyForShadows =
|
||||
showGameStage && gameStageLoaded && octree !== null;
|
||||
const shadowWarmupReady = sceneMode === "game" && gameSceneReadyForShadows;
|
||||
const shouldWarmUpShadows = shadowWarmupReady && !shadowsReady;
|
||||
const gameplayReady = gameSceneReadyForShadows && shadowsReady;
|
||||
const gameplayReady = showGameStage && gameStageLoaded && octree !== null;
|
||||
const sceneReady =
|
||||
(sceneMode === "game" && gameplayReady) ||
|
||||
(sceneMode === "physics" && octree !== null);
|
||||
|
||||
const handleGameMapLoaded = useCallback(() => {
|
||||
setShadowsReady(false);
|
||||
setGameMapLoaded(true);
|
||||
}, []);
|
||||
|
||||
@@ -54,7 +45,6 @@ export function useWorldSceneLoading({
|
||||
|
||||
const handleOctreeReady = useCallback(
|
||||
(nextOctree: Octree) => {
|
||||
setShadowsReady(false);
|
||||
setOctree(nextOctree);
|
||||
onLoadingStateChange?.({
|
||||
currentStep: "Collision prête",
|
||||
@@ -65,23 +55,6 @@ export function useWorldSceneLoading({
|
||||
[onLoadingStateChange],
|
||||
);
|
||||
|
||||
const handleShadowWarmupStarted = useCallback(() => {
|
||||
onLoadingStateChange?.({
|
||||
currentStep: "Activation des ombres",
|
||||
progress: 0.97,
|
||||
status: "loading",
|
||||
});
|
||||
}, [onLoadingStateChange]);
|
||||
|
||||
const handleShadowWarmupReady = useCallback(() => {
|
||||
setShadowsReady(true);
|
||||
onLoadingStateChange?.({
|
||||
currentStep: "Ombres prêtes",
|
||||
progress: 0.99,
|
||||
status: "loading",
|
||||
});
|
||||
}, [onLoadingStateChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onLoadingStateChange?.({
|
||||
currentStep: "Initialisation du jeu",
|
||||
@@ -115,12 +88,9 @@ export function useWorldSceneLoading({
|
||||
return {
|
||||
octree,
|
||||
gameplayReady,
|
||||
shouldWarmUpShadows,
|
||||
showGameStage,
|
||||
handleGameStageLoaded,
|
||||
handleGameMapLoaded,
|
||||
handleOctreeReady,
|
||||
handleShadowWarmupReady,
|
||||
handleShadowWarmupStarted,
|
||||
};
|
||||
}
|
||||
|
||||
+111
-3
@@ -942,11 +942,11 @@ canvas {
|
||||
.scene-loading-overlay__logo {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
width: clamp(180px, 28vw, 320px);
|
||||
max-height: min(38vh, 320px);
|
||||
border-radius: 16px;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.28);
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.scene-loading-overlay__footer {
|
||||
@@ -1237,6 +1237,114 @@ canvas {
|
||||
color: #f9a8d4;
|
||||
}
|
||||
|
||||
/* Dialogue talkie */
|
||||
.talkie-dialogue-overlay {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 16;
|
||||
width: clamp(190px, 18vw, 300px);
|
||||
aspect-ratio: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.talkie-dialogue-overlay__model-frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
filter: drop-shadow(0 16px 22px rgba(0, 0, 0, 0.55));
|
||||
}
|
||||
|
||||
.talkie-dialogue-overlay--active .talkie-dialogue-overlay__model-frame {
|
||||
animation: talkie-radio-shake 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.talkie-dialogue-overlay__model-frame canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.talkie-dialogue-overlay__signals {
|
||||
position: absolute;
|
||||
top: 52%;
|
||||
z-index: 2;
|
||||
width: 38%;
|
||||
height: 52%;
|
||||
overflow: visible;
|
||||
opacity: 0.8;
|
||||
animation: talkie-signal-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.talkie-dialogue-overlay__signals--left {
|
||||
right: 62%;
|
||||
transform: translateY(-50%) scaleX(-1);
|
||||
}
|
||||
|
||||
.talkie-dialogue-overlay__signals--right {
|
||||
left: 62%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.talkie-dialogue-overlay__signals path {
|
||||
fill: none;
|
||||
stroke: rgba(235, 244, 255, 0.9);
|
||||
stroke-linecap: round;
|
||||
stroke-width: 5;
|
||||
filter: drop-shadow(0 0 7px rgba(125, 211, 252, 0.72));
|
||||
}
|
||||
|
||||
.talkie-dialogue-overlay__signals path:nth-child(2) {
|
||||
animation-delay: 90ms;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.talkie-dialogue-overlay__signals path:nth-child(3) {
|
||||
animation-delay: 180ms;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
@keyframes talkie-radio-shake {
|
||||
0%,
|
||||
11%,
|
||||
23%,
|
||||
100% {
|
||||
transform: translate3d(0, 0, 0) rotate(0deg);
|
||||
}
|
||||
|
||||
3%,
|
||||
15%,
|
||||
27% {
|
||||
transform: translate3d(-2px, 1px, 0) rotate(-1.7deg);
|
||||
}
|
||||
|
||||
6%,
|
||||
18%,
|
||||
30% {
|
||||
transform: translate3d(2px, -1px, 0) rotate(1.7deg);
|
||||
}
|
||||
|
||||
9%,
|
||||
21%,
|
||||
33% {
|
||||
transform: translate3d(-1px, 0, 0) rotate(-0.8deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes talkie-signal-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.28;
|
||||
}
|
||||
|
||||
18%,
|
||||
38% {
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
60% {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
|
||||
/* In-game settings menu */
|
||||
.game-settings-menu {
|
||||
position: fixed;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
interface DebugVisualsStore {
|
||||
showPlayerModel: boolean;
|
||||
setShowPlayerModel: (value: boolean) => void;
|
||||
showOctree: boolean;
|
||||
setShowOctree: (value: boolean) => void;
|
||||
octreeMaxDepth: number;
|
||||
setOctreeMaxDepth: (value: number) => void;
|
||||
octreeMinDepth: number;
|
||||
setOctreeMinDepth: (value: number) => void;
|
||||
octreeLeavesOnly: boolean;
|
||||
setOctreeLeavesOnly: (value: boolean) => void;
|
||||
octreeOpacity: number;
|
||||
setOctreeOpacity: (value: number) => void;
|
||||
octreeFabrikOnly: boolean;
|
||||
setOctreeFabrikOnly: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export const useDebugVisualsStore = create<DebugVisualsStore>((set) => ({
|
||||
showPlayerModel: false,
|
||||
setShowPlayerModel: (showPlayerModel) => set({ showPlayerModel }),
|
||||
showOctree: false,
|
||||
setShowOctree: (showOctree) => set({ showOctree }),
|
||||
octreeMaxDepth: 8,
|
||||
setOctreeMaxDepth: (octreeMaxDepth) => set({ octreeMaxDepth }),
|
||||
octreeMinDepth: 4,
|
||||
setOctreeMinDepth: (octreeMinDepth) => set({ octreeMinDepth }),
|
||||
octreeLeavesOnly: true,
|
||||
setOctreeLeavesOnly: (octreeLeavesOnly) => set({ octreeLeavesOnly }),
|
||||
octreeOpacity: 0.35,
|
||||
setOctreeOpacity: (octreeOpacity) => set({ octreeOpacity }),
|
||||
octreeFabrikOnly: false,
|
||||
setOctreeFabrikOnly: (octreeFabrikOnly) => set({ octreeFabrikOnly }),
|
||||
}));
|
||||
@@ -146,7 +146,7 @@ function completeEbikeState(state: GameState): GameStateUpdate {
|
||||
},
|
||||
pylon: {
|
||||
...state.pylon,
|
||||
currentStep: "waiting",
|
||||
currentStep: "approaching",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -212,7 +212,7 @@ function advanceRepairMissionState(
|
||||
state: GameState,
|
||||
mission: RepairMissionId,
|
||||
): GameStateUpdate {
|
||||
const nextStep = getNextMissionStep(state[mission].currentStep);
|
||||
const nextStep = getNextMissionStep(state[mission].currentStep, mission);
|
||||
if (nextStep === "done") {
|
||||
return completeMissionState(state, mission);
|
||||
}
|
||||
@@ -227,7 +227,7 @@ function rewindRepairMissionState(
|
||||
return setMissionStepState(
|
||||
state,
|
||||
mission,
|
||||
getPreviousMissionStep(state[mission].currentStep),
|
||||
getPreviousMissionStep(state[mission].currentStep, mission),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { create } from "zustand";
|
||||
import type { MissionStep, RepairMissionId } from "@/types/gameplay/repairMission";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
export interface RepairPendingCompletion {
|
||||
mission: RepairMissionId;
|
||||
/** Next step to set. When it equals "done", completeMission() is called
|
||||
* instead (ebike / farm have no further narrative sub-step). */
|
||||
nextStep: MissionStep;
|
||||
}
|
||||
|
||||
interface RepairTransitionState {
|
||||
/** Set when the repair game reaches "done". page.tsx reads this and
|
||||
* executes the completion only after the world has fully re-loaded. */
|
||||
pendingCompletion: RepairPendingCompletion | null;
|
||||
/** Player 3D position captured just before entering the repair scene,
|
||||
* used to re-spawn the player at the correct location on return. */
|
||||
savedPlayerPosition: Vector3Tuple | null;
|
||||
}
|
||||
|
||||
interface RepairTransitionActions {
|
||||
setPendingCompletion: (data: RepairPendingCompletion | null) => void;
|
||||
setSavedPlayerPosition: (pos: Vector3Tuple | null) => void;
|
||||
}
|
||||
|
||||
export const useRepairTransitionStore = create<
|
||||
RepairTransitionState & RepairTransitionActions
|
||||
>()((set) => ({
|
||||
pendingCompletion: null,
|
||||
savedPlayerPosition: null,
|
||||
setPendingCompletion: (data) => set({ pendingCompletion: data }),
|
||||
setSavedPlayerPosition: (pos) => set({ savedPlayerPosition: pos }),
|
||||
}));
|
||||
@@ -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,24 +49,22 @@ 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) => ({
|
||||
export const useSettingsStore = create<SettingsStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
...DEFAULT_SETTINGS,
|
||||
setSettingsMenuOpen: (isSettingsMenuOpen) => set({ isSettingsMenuOpen }),
|
||||
setMusicVolume: (volume) =>
|
||||
@@ -75,7 +76,23 @@ export const useSettingsStore = create<SettingsStore>()((set) => ({
|
||||
setSubtitlesEnabled: (subtitlesEnabled) => set({ subtitlesEnabled }),
|
||||
setSubtitleLanguage: (subtitleLanguage) => set({ subtitleLanguage }),
|
||||
resetSettings: () => {
|
||||
applyDefaultAudioSettings();
|
||||
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,7 +47,11 @@ const DEFAULT_STATE: WorldSettingsState = {
|
||||
graphics: { ...GRAPHICS_DEFAULTS },
|
||||
};
|
||||
|
||||
export const useWorldSettingsStore = create<WorldSettingsStore>()((set) => ({
|
||||
const WORLD_SETTINGS_STORAGE_KEY = "la-fabrik-world-settings";
|
||||
|
||||
export const useWorldSettingsStore = create<WorldSettingsStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
...DEFAULT_STATE,
|
||||
|
||||
setClouds: (cloudsUpdate) =>
|
||||
@@ -115,4 +120,16 @@ export const useWorldSettingsStore = create<WorldSettingsStore>()((set) => ({
|
||||
})),
|
||||
|
||||
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,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
+120
-5
@@ -15,25 +15,28 @@ import {
|
||||
} from "@/components/ui/intro";
|
||||
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
|
||||
import { INITIAL_SCENE_LOADING_STATE } from "@/data/world/sceneLoadingConfig";
|
||||
import { useRepairGameStatus } from "@/hooks/gameplay/useRepairGameStatus";
|
||||
import { useTransientLoadingIndicator } from "@/hooks/ui/useTransientLoadingIndicator";
|
||||
import { AudioManager } from "@/managers/AudioManager";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { useRepairTransitionStore } from "@/managers/stores/useRepairTransitionStore";
|
||||
import { useWorldSettingsStore } from "@/managers/stores/useWorldSettingsStore";
|
||||
import { HandTrackingProvider } from "@/providers/gameplay/HandTrackingProvider";
|
||||
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||
import type { SceneLoadingState } from "@/types/world/sceneLoading";
|
||||
import { hasSiteBeenVisitedToday } from "@/utils/cookies/siteVisitCookie";
|
||||
import { logger } from "@/utils/core/Logger";
|
||||
import { RepairGameScene } from "@/world/RepairGameScene";
|
||||
import { World } from "@/world/World";
|
||||
|
||||
const LOADING_TO_VIDEO_FADE_MS = 500;
|
||||
// Duration (ms) of each half of the repair scene cross-fade
|
||||
const REPAIR_FADE_MS = 250;
|
||||
|
||||
export function HomePage(): React.JSX.Element | null {
|
||||
const navigate = useNavigate();
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const introStep = useGameStore((state) => state.intro.currentStep);
|
||||
const ebikeStep = useGameStore((state) => state.ebike.currentStep);
|
||||
const pylonStep = useGameStore((state) => state.pylon.currentStep);
|
||||
const farmStep = useGameStore((state) => state.farm.currentStep);
|
||||
const setIntroStep = useGameStore((state) => state.setIntroStep);
|
||||
const graphicsPreset = useWorldSettingsStore(
|
||||
(state) => state.graphics.preset,
|
||||
@@ -48,9 +51,92 @@ export function HomePage(): React.JSX.Element | null {
|
||||
INITIAL_SCENE_LOADING_STATE,
|
||||
);
|
||||
const sceneReadyRef = useRef(false);
|
||||
const runtimeLoadingSignal = `${graphicsPreset}:${mainState}:${ebikeStep}:${pylonStep}:${farmStep}`;
|
||||
// Only trigger the transient loading indicator on mission-level transitions
|
||||
// (mainState) or graphics changes — not on every repair game sub-step.
|
||||
const runtimeLoadingSignal = `${graphicsPreset}:${mainState}`;
|
||||
const previousRuntimeLoadingSignalRef = useRef(runtimeLoadingSignal);
|
||||
|
||||
// --- Repair scene swap ---------------------------------------------------
|
||||
const repairStatus = useRepairGameStatus();
|
||||
const pendingCompletion = useRepairTransitionStore(
|
||||
(s) => s.pendingCompletion,
|
||||
);
|
||||
const setPendingCompletion = useRepairTransitionStore(
|
||||
(s) => s.setPendingCompletion,
|
||||
);
|
||||
const setSavedPlayerPosition = useRepairTransitionStore(
|
||||
(s) => s.setSavedPlayerPosition,
|
||||
);
|
||||
|
||||
const [showRepairScene, setShowRepairScene] = useState(repairStatus.active);
|
||||
const [renderedMission, setRenderedMission] =
|
||||
useState<RepairMissionId | null>(
|
||||
repairStatus.active ? repairStatus.mission : null,
|
||||
);
|
||||
const [isFading, setIsFading] = useState(false);
|
||||
// True while the world is reloading after a repair scene (shows loading overlay).
|
||||
const [isPostRepairLoading, setIsPostRepairLoading] = useState(false);
|
||||
const lastRepairActiveRef = useRef(repairStatus.active);
|
||||
|
||||
useEffect(() => {
|
||||
if (repairStatus.active === lastRepairActiveRef.current) return;
|
||||
lastRepairActiveRef.current = repairStatus.active;
|
||||
|
||||
if (repairStatus.active) {
|
||||
// Entering repair scene — capture the player's current world position
|
||||
// so we can restore it when returning.
|
||||
const pos = (window as Window & { playerPos?: [number, number, number] })
|
||||
.playerPos;
|
||||
if (pos) setSavedPlayerPosition([pos[0], pos[1], pos[2]]);
|
||||
}
|
||||
|
||||
setIsFading(true);
|
||||
|
||||
const swapTimer = window.setTimeout(() => {
|
||||
setShowRepairScene(repairStatus.active);
|
||||
setRenderedMission(repairStatus.active ? repairStatus.mission : null);
|
||||
|
||||
if (!repairStatus.active) {
|
||||
// Returning from repair scene — reset loading state so the overlay
|
||||
// shows while the world reloads from scratch (new WebGL context).
|
||||
sceneReadyRef.current = false;
|
||||
setSceneLoadingState(INITIAL_SCENE_LOADING_STATE);
|
||||
setIsPostRepairLoading(true);
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
setIsFading(false);
|
||||
}, 50);
|
||||
}, REPAIR_FADE_MS);
|
||||
|
||||
return () => window.clearTimeout(swapTimer);
|
||||
}, [repairStatus.active, repairStatus.mission, setSavedPlayerPosition]);
|
||||
|
||||
// Execute the pending repair completion once the world is fully loaded.
|
||||
useEffect(() => {
|
||||
if (!isPostRepairLoading) return;
|
||||
if (sceneLoadingState.status !== "ready") return;
|
||||
if (!pendingCompletion) return;
|
||||
|
||||
const { mission, nextStep } = pendingCompletion;
|
||||
const store = useGameStore.getState();
|
||||
|
||||
if (nextStep === "done") {
|
||||
store.completeMission(mission);
|
||||
} else {
|
||||
store.setMissionStep(mission, nextStep);
|
||||
}
|
||||
|
||||
setPendingCompletion(null);
|
||||
setIsPostRepairLoading(false);
|
||||
}, [
|
||||
isPostRepairLoading,
|
||||
sceneLoadingState.status,
|
||||
pendingCompletion,
|
||||
setPendingCompletion,
|
||||
]);
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
sceneReadyRef.current = sceneLoadingState.status === "ready";
|
||||
}, [sceneLoadingState.status]);
|
||||
@@ -131,6 +217,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 +235,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");
|
||||
};
|
||||
|
||||
@@ -168,7 +256,9 @@ export function HomePage(): React.JSX.Element | null {
|
||||
introStep === "fade-to-video" ||
|
||||
(introStep === "loading-map" && sceneLoadingState.status === "ready");
|
||||
const showSceneLoadingOverlay =
|
||||
introStep === "loading-map" || introStep === "fade-to-video";
|
||||
introStep === "loading-map" ||
|
||||
introStep === "fade-to-video" ||
|
||||
isPostRepairLoading;
|
||||
|
||||
const renderIntroOverlay = () => {
|
||||
if (showFadeToVideoOverlay) return <FadeToVideoOverlay />;
|
||||
@@ -187,6 +277,11 @@ export function HomePage(): React.JSX.Element | null {
|
||||
|
||||
return (
|
||||
<HandTrackingProvider>
|
||||
{showRepairScene && renderedMission !== null ? (
|
||||
/* Isolated repair scene — no map, no player, no physics world.
|
||||
Unmounting the main Canvas here frees the full GPU budget. */
|
||||
<RepairGameScene mission={renderedMission} />
|
||||
) : (
|
||||
<Canvas
|
||||
camera={{ position: [85, 60, 85], fov: 42 }}
|
||||
shadows={{ type: THREE.PCFShadowMap }}
|
||||
@@ -202,6 +297,26 @@ export function HomePage(): React.JSX.Element | null {
|
||||
<DebugPerf />
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
)}
|
||||
|
||||
{/* Black fade overlay — covers the WebGL context swap.
|
||||
The AppLoadingIndicator lives INSIDE this div so it inherits the
|
||||
stacking context (z-index 60) and always paints above the black. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "#000",
|
||||
zIndex: 60,
|
||||
opacity: isFading ? 1 : 0,
|
||||
transition: `opacity ${REPAIR_FADE_MS}ms ease-in-out`,
|
||||
pointerEvents: isFading ? "all" : "none",
|
||||
}}
|
||||
>
|
||||
{isFading ? <AppLoadingIndicator floating /> : null}
|
||||
</div>
|
||||
|
||||
<GameUI />
|
||||
{dialogMessage ? (
|
||||
<DialogMessage
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
@@ -54,10 +54,39 @@ export interface RepairMissionConfig {
|
||||
|
||||
export type MissionStep =
|
||||
| "locked"
|
||||
| "approaching"
|
||||
| "arrived"
|
||||
| "npc-return"
|
||||
| "waiting"
|
||||
| "inspected"
|
||||
| "fragmented"
|
||||
| "scanning"
|
||||
| "repairing"
|
||||
| "reassembling"
|
||||
| "done";
|
||||
| "done"
|
||||
| "narrator-outro";
|
||||
|
||||
export const PYLON_NARRATIVE_STEPS = [
|
||||
"approaching",
|
||||
"arrived",
|
||||
"npc-return",
|
||||
"narrator-outro",
|
||||
] as const;
|
||||
|
||||
export const REPAIR_GAME_STEPS = [
|
||||
"waiting",
|
||||
"inspected",
|
||||
"fragmented",
|
||||
"scanning",
|
||||
"repairing",
|
||||
"reassembling",
|
||||
"done",
|
||||
] as const;
|
||||
|
||||
export function isPylonNarrativeStep(step: MissionStep): boolean {
|
||||
return (PYLON_NARRATIVE_STEPS as readonly MissionStep[]).includes(step);
|
||||
}
|
||||
|
||||
export function isRepairGameStep(step: MissionStep): boolean {
|
||||
return (REPAIR_GAME_STEPS as readonly MissionStep[]).includes(step);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
export interface ZoneConfig {
|
||||
id: string;
|
||||
position: Vector3Tuple;
|
||||
radius: number;
|
||||
height: number;
|
||||
oneShot: boolean;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
+193
-32
@@ -17,10 +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 { flattenLaFabrikTerrainFootprint } from "@/data/world/laFabrikConfig";
|
||||
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";
|
||||
|
||||
@@ -40,6 +54,7 @@ interface GameMapCollisionProps {
|
||||
buildOctree?: boolean;
|
||||
mapReady: boolean;
|
||||
nodes: readonly GameMapCollisionNode[];
|
||||
proxyNodes: readonly MapNode[];
|
||||
onLoaded?: (() => void) | undefined;
|
||||
onLoadingStateChange?: SceneLoadingChangeHandler | undefined;
|
||||
onOctreeReady: OctreeReadyHandler;
|
||||
@@ -102,6 +117,7 @@ export function GameMapCollision({
|
||||
buildOctree = true,
|
||||
mapReady,
|
||||
nodes,
|
||||
proxyNodes,
|
||||
onLoaded,
|
||||
onLoadingStateChange,
|
||||
onOctreeReady,
|
||||
@@ -110,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;
|
||||
@@ -145,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;
|
||||
}
|
||||
@@ -172,6 +206,7 @@ export function GameMapCollision({
|
||||
}, [
|
||||
buildOctree,
|
||||
collisionNodes.length,
|
||||
collisionSourceCount,
|
||||
collisionReady,
|
||||
mapReady,
|
||||
notifyLoaded,
|
||||
@@ -181,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
|
||||
@@ -214,7 +261,7 @@ function CollisionModelInstance({
|
||||
modelUrl: string;
|
||||
onLoaded: () => void;
|
||||
terrainHeight: TerrainHeightSampler;
|
||||
}): React.JSX.Element | null {
|
||||
}): React.JSX.Element {
|
||||
const { position, rotation, scale } = node;
|
||||
const normalizedScale = normalizeMapScale(scale);
|
||||
const { scene } = useLoggedGLTF(modelUrl, {
|
||||
@@ -224,49 +271,163 @@ function CollisionModelInstance({
|
||||
scale: normalizedScale,
|
||||
});
|
||||
const sceneInstance = useClonedObject(scene);
|
||||
const collisionSceneInstance = useMemo(() => {
|
||||
if (node.name === "terrain") {
|
||||
flattenLaFabrikTerrainFootprint(
|
||||
sceneInstance,
|
||||
position,
|
||||
rotation,
|
||||
normalizedScale,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (node.name !== "lafabrik") return;
|
||||
|
||||
const isDoorSlab = (name: string): boolean =>
|
||||
name === "porte" || /^porte[._]\d+$/i.test(name);
|
||||
const isDoorFrameThickenChild = (child: THREE.Object3D): boolean =>
|
||||
child.parent?.name === "Thicken";
|
||||
|
||||
const doorMeshes: THREE.Object3D[] = [];
|
||||
sceneInstance.traverse((child) => {
|
||||
if (isDoorSlab(child.name) || isDoorFrameThickenChild(child)) {
|
||||
doorMeshes.push(child);
|
||||
}
|
||||
return sceneInstance;
|
||||
}, [node.name, normalizedScale, position, rotation, sceneInstance]);
|
||||
});
|
||||
for (const child of doorMeshes) {
|
||||
child.removeFromParent();
|
||||
}
|
||||
}, [node.name, sceneInstance]);
|
||||
const collisionPosition = useMemo(() => {
|
||||
if (node.name === "terrain") return position;
|
||||
|
||||
const [x, y, z] = position;
|
||||
const height = terrainHeight.getHeight(x, z);
|
||||
const bottomOffset = getObjectBottomOffset(
|
||||
collisionSceneInstance,
|
||||
normalizedScale,
|
||||
);
|
||||
const bottomOffset = getObjectBottomOffset(sceneInstance, normalizedScale);
|
||||
return [x, height !== null ? height + bottomOffset : y, z] as const;
|
||||
}, [
|
||||
node.name,
|
||||
normalizedScale,
|
||||
position,
|
||||
collisionSceneInstance,
|
||||
terrainHeight,
|
||||
]);
|
||||
}, [node.name, normalizedScale, position, sceneInstance, terrainHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
onLoaded();
|
||||
}, [onLoaded]);
|
||||
|
||||
if (node.name === "lafabrik") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<primitive
|
||||
object={collisionSceneInstance}
|
||||
object={sceneInstance}
|
||||
position={collisionPosition}
|
||||
rotation={rotation}
|
||||
scale={normalizedScale}
|
||||
/>
|
||||
{node.name === "lafabrik" ? (
|
||||
<group
|
||||
name="lafabrik-interior-collision-proxies"
|
||||
position={collisionPosition}
|
||||
rotation={rotation}
|
||||
scale={normalizedScale}
|
||||
>
|
||||
{LA_FABRIK_INTERIOR_COLLISION_BOXES.map((box, index) => (
|
||||
<CollisionBox key={`lafabrik-interior-${index}`} box={box} />
|
||||
))}
|
||||
</group>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CollisionBox({ box }: { box: OctreeCollisionBox }): React.JSX.Element {
|
||||
return (
|
||||
<group position={box.center}>
|
||||
<mesh>
|
||||
<boxGeometry args={box.size} />
|
||||
<meshBasicMaterial />
|
||||
</mesh>
|
||||
<mesh rotation={[0, Math.PI, 0]}>
|
||||
<boxGeometry args={box.size} />
|
||||
<meshBasicMaterial />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function createScaledMapNodeScale(node: MapNode): Vector3Tuple {
|
||||
const baseScale = normalizeMapScale(node.scale);
|
||||
const scaleMultiplier = getMapModelScaleMultiplier(node.name);
|
||||
|
||||
return [
|
||||
baseScale[0] * scaleMultiplier,
|
||||
baseScale[1] * scaleMultiplier,
|
||||
baseScale[2] * scaleMultiplier,
|
||||
];
|
||||
}
|
||||
|
||||
function MapCollisionBoxProxy({
|
||||
node,
|
||||
terrainHeight,
|
||||
}: {
|
||||
node: MapNode;
|
||||
terrainHeight: TerrainHeightSampler;
|
||||
}): React.JSX.Element | null {
|
||||
const collisionBox = hasMapOctreeCollisionBox(node.name)
|
||||
? MAP_OCTREE_COLLISION_BOXES[node.name]
|
||||
: null;
|
||||
const normalizedScale = useMemo(() => createScaledMapNodeScale(node), [node]);
|
||||
const position = useMemo(() => {
|
||||
const [x, y, z] = node.position;
|
||||
if (!collisionBox) return [x, y, z] satisfies Vector3Tuple;
|
||||
|
||||
const height = terrainHeight.getHeight(x, z);
|
||||
const bottomOffset = -collisionBox.bottomY * normalizedScale[1];
|
||||
|
||||
return [x, (height ?? y) + bottomOffset, z] satisfies Vector3Tuple;
|
||||
}, [collisionBox, node.position, normalizedScale, terrainHeight]);
|
||||
|
||||
if (!collisionBox) return null;
|
||||
|
||||
return (
|
||||
<group
|
||||
name={`${node.name}-octree-collision-proxy`}
|
||||
position={position}
|
||||
rotation={node.rotation}
|
||||
scale={normalizedScale}
|
||||
>
|
||||
<CollisionBox box={collisionBox} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function CharacterCollisionProxies({
|
||||
terrainHeight,
|
||||
}: {
|
||||
terrainHeight: TerrainHeightSampler;
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{CHARACTER_IDS.map((id) => (
|
||||
<CharacterCollisionProxy
|
||||
key={`character-collision-${id}`}
|
||||
id={id}
|
||||
terrainHeight={terrainHeight}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CharacterCollisionProxy({
|
||||
id,
|
||||
terrainHeight,
|
||||
}: {
|
||||
id: CharacterId;
|
||||
terrainHeight: TerrainHeightSampler;
|
||||
}): React.JSX.Element {
|
||||
const config = CHARACTER_CONFIGS[id];
|
||||
const state = useCharacterDebugStore((store) => store.characters[id]);
|
||||
const position = useMemo(() => {
|
||||
const [x, y, z] = state.position;
|
||||
const height = terrainHeight.getHeight(x, z);
|
||||
|
||||
return [x, height ?? y, z] satisfies Vector3Tuple;
|
||||
}, [state.position, terrainHeight]);
|
||||
|
||||
return (
|
||||
<group
|
||||
name={`${config.id}-octree-collision-proxy`}
|
||||
position={position}
|
||||
rotation={state.rotation}
|
||||
>
|
||||
<CollisionBox box={CHARACTER_OCTREE_COLLISION_BOX} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Ebike } from "@/components/ebike/Ebike";
|
||||
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||
import { RepairGame } from "@/components/three/gameplay/RepairGame";
|
||||
import {
|
||||
REPAIR_MISSION_POSITION_ENTRIES,
|
||||
REPAIR_MISSION_TRIGGERS,
|
||||
} from "@/data/gameplay/repairMissionAnchors";
|
||||
import { RepairGamePreloader } from "@/components/three/gameplay/RepairGamePreloader";
|
||||
import { PylonDownedPylon } from "@/components/gameplay/pylon/PylonDownedPylon";
|
||||
import { PylonNarrativeFlow } from "@/components/gameplay/pylon/PylonNarrativeFlow";
|
||||
import { ZoneDebugVisual } from "@/components/zone/ZoneDetection";
|
||||
import { PYLON_APPROACH_ZONE, PYLON_ARRIVED_ZONE } from "@/data/gameplay/zones";
|
||||
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
|
||||
import { REPAIR_MISSION_TRIGGERS } from "@/data/gameplay/repairMissionAnchors";
|
||||
import {
|
||||
INTRO_STAGE_ANCHOR,
|
||||
OUTRO_STAGE_ANCHOR,
|
||||
@@ -14,7 +16,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;
|
||||
@@ -77,19 +85,35 @@ function RepairMissionTrigger({
|
||||
|
||||
export function GameStageContent(): React.JSX.Element {
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const anchors = useRepairMissionAnchorStore((state) => state.anchors);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Pre-load the next mission's repair assets while the player is still
|
||||
in the world, so the isolated repair scene mounts instantly.
|
||||
Only load pylon during ebike (not before) to avoid holding a
|
||||
finished mission's textures in VRAM. */}
|
||||
{mainState === "intro" || mainState === "ebike" ? (
|
||||
<RepairGamePreloader mission="pylon" />
|
||||
) : null}
|
||||
{mainState === "pylon" ? <RepairGamePreloader mission="farm" /> : null}
|
||||
|
||||
{mainState === "intro" ? <StageAnchor {...INTRO_STAGE_ANCHOR} /> : null}
|
||||
<Ebike position={EBIKE_WORLD_POSITION} />
|
||||
{REPAIR_MISSION_POSITION_ENTRIES.map(({ mission }) => {
|
||||
const position = getRepairMissionPosition(mission, anchors);
|
||||
if (!position) return null;
|
||||
return (
|
||||
<RepairGame key={mission} mission={mission} position={position} />
|
||||
);
|
||||
})}
|
||||
<Ebike key={EBIKE_CONFIG_KEY} position={EBIKE_WORLD_POSITION} />
|
||||
<PylonDownedPylon />
|
||||
{isDebugEnabled() ? (
|
||||
<>
|
||||
<ZoneDebugVisual zone={PYLON_APPROACH_ZONE} active={false} />
|
||||
<ZoneDebugVisual zone={PYLON_ARRIVED_ZONE} active={false} />
|
||||
</>
|
||||
) : null}
|
||||
{mainState === "pylon" ? <PylonNarrativeFlow /> : null}
|
||||
|
||||
{/* RepairGame is NO LONGER rendered here. When a repair step becomes
|
||||
active, page.tsx unmounts this whole world and mounts the isolated
|
||||
RepairGameScene instead, freeing all map/character VRAM. */}
|
||||
|
||||
{/* Trigger sphere that starts the ebike repair (locked → waiting).
|
||||
The repair scene swap is then handled by useRepairGameStatus. */}
|
||||
{REPAIR_MISSION_TRIGGERS.map((config) => (
|
||||
<RepairMissionTrigger key={config.mission} config={config} />
|
||||
))}
|
||||
|
||||
+59
-26
@@ -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,
|
||||
@@ -20,15 +27,49 @@ import {
|
||||
} from "@/data/world/lightingConfig";
|
||||
import { LA_FABRIK_INTERIOR_LIGHT_POSITION } from "@/data/world/laFabrikConfig";
|
||||
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||
import { useShadowMapWarmup } from "@/hooks/three/useShadowMapWarmup";
|
||||
import { LIGHTING_STATE } from "@/world/lightingState";
|
||||
|
||||
const SHADOW_MAP_SIZE = 2048;
|
||||
const SHADOW_CAMERA_SIZE = 95;
|
||||
const SHADOW_CAMERA_NEAR = 0.5;
|
||||
const SHADOW_CAMERA_FAR = 300;
|
||||
function configureRendererShadows(gl: WebGLRenderer): void {
|
||||
gl.shadowMap.enabled = true;
|
||||
gl.shadowMap.type = PCFShadowMap;
|
||||
gl.shadowMap.autoUpdate = true;
|
||||
}
|
||||
|
||||
function configureSunShadow(sun: DirectionalLight, sunTarget: Object3D): void {
|
||||
sun.target = sunTarget;
|
||||
sun.shadow.autoUpdate = true;
|
||||
sun.shadow.bias = SHADOW_CONFIG.bias;
|
||||
sun.shadow.normalBias = SHADOW_CONFIG.normalBias;
|
||||
sun.shadow.mapSize.width = SHADOW_CONFIG.mapSize;
|
||||
sun.shadow.mapSize.height = SHADOW_CONFIG.mapSize;
|
||||
sun.shadow.camera.left = -SHADOW_CONFIG.cameraSize;
|
||||
sun.shadow.camera.right = SHADOW_CONFIG.cameraSize;
|
||||
sun.shadow.camera.top = SHADOW_CONFIG.cameraSize;
|
||||
sun.shadow.camera.bottom = -SHADOW_CONFIG.cameraSize;
|
||||
sun.shadow.camera.near = SHADOW_CONFIG.cameraNear;
|
||||
sun.shadow.camera.far = SHADOW_CONFIG.cameraFar;
|
||||
sun.shadow.camera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
function placeSunRelativeToCamera(
|
||||
sun: DirectionalLight,
|
||||
sunTarget: Object3D,
|
||||
cameraPosition: { x: number; z: number },
|
||||
): void {
|
||||
sunTarget.position.set(cameraPosition.x, 0, cameraPosition.z);
|
||||
sun.position.set(
|
||||
cameraPosition.x + LIGHTING_STATE.sunX,
|
||||
LIGHTING_STATE.sunY,
|
||||
cameraPosition.z + LIGHTING_STATE.sunZ,
|
||||
);
|
||||
}
|
||||
|
||||
export function Lighting(): React.JSX.Element {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const gl = useThree((state) => state.gl);
|
||||
const scene = useThree((state) => state.scene);
|
||||
const invalidate = useThree((state) => state.invalidate);
|
||||
const ambient = useRef<AmbientLight>(null);
|
||||
const sun = useRef<DirectionalLight>(null);
|
||||
const sunTarget = useRef<Object3D>(null);
|
||||
@@ -36,19 +77,16 @@ export function Lighting(): React.JSX.Element {
|
||||
useEffect(() => {
|
||||
if (!sun.current || !sunTarget.current) return;
|
||||
|
||||
sun.current.target = sunTarget.current;
|
||||
sun.current.shadow.autoUpdate = true;
|
||||
sun.current.shadow.needsUpdate = true;
|
||||
sun.current.shadow.mapSize.width = SHADOW_MAP_SIZE;
|
||||
sun.current.shadow.mapSize.height = SHADOW_MAP_SIZE;
|
||||
sun.current.shadow.camera.left = -SHADOW_CAMERA_SIZE;
|
||||
sun.current.shadow.camera.right = SHADOW_CAMERA_SIZE;
|
||||
sun.current.shadow.camera.top = SHADOW_CAMERA_SIZE;
|
||||
sun.current.shadow.camera.bottom = -SHADOW_CAMERA_SIZE;
|
||||
sun.current.shadow.camera.near = SHADOW_CAMERA_NEAR;
|
||||
sun.current.shadow.camera.far = SHADOW_CAMERA_FAR;
|
||||
sun.current.shadow.camera.updateProjectionMatrix();
|
||||
}, []);
|
||||
configureRendererShadows(gl);
|
||||
configureSunShadow(sun.current, sunTarget.current);
|
||||
// Prime the sun + target onto the camera before the first shadow pass so
|
||||
// the initial shadow frustum already covers the visible scene; without
|
||||
// this, the first frame is rendered with the default (origin-centered)
|
||||
// frustum and shadows can appear absent until the player moves.
|
||||
placeSunRelativeToCamera(sun.current, sunTarget.current, camera.position);
|
||||
}, [camera, gl]);
|
||||
|
||||
useShadowMapWarmup({ light: sun, scene, gl, invalidate });
|
||||
|
||||
useDebugFolder("Lighting", (folder) => {
|
||||
folder.addColor(LIGHTING_STATE, "ambientColor").name("Ambient Color");
|
||||
@@ -88,19 +126,14 @@ export function Lighting(): React.JSX.Element {
|
||||
ambient.current.intensity = LIGHTING_STATE.ambientIntensity;
|
||||
}
|
||||
|
||||
if (sun.current && sunTarget.current) {
|
||||
sunTarget.current.position.set(camera.position.x, 0, camera.position.z);
|
||||
if (!sun.current || !sunTarget.current) return;
|
||||
|
||||
placeSunRelativeToCamera(sun.current, sunTarget.current, camera.position);
|
||||
sunTarget.current.updateMatrixWorld();
|
||||
sun.current.position.set(
|
||||
camera.position.x + LIGHTING_STATE.sunX,
|
||||
LIGHTING_STATE.sunY,
|
||||
camera.position.z + LIGHTING_STATE.sunZ,
|
||||
);
|
||||
sun.current.color.set(LIGHTING_STATE.sunColor);
|
||||
sun.current.intensity = LIGHTING_STATE.sunIntensity;
|
||||
sun.current.updateMatrixWorld();
|
||||
sun.current.shadow.needsUpdate = true;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Suspense, useCallback, useEffect } from "react";
|
||||
import { Canvas, useThree } from "@react-three/fiber";
|
||||
import { Physics } from "@react-three/rapier";
|
||||
import * as THREE from "three";
|
||||
import { DebugPerf } from "@/components/debug/DebugPerf";
|
||||
import { RepairGame } from "@/components/three/gameplay/RepairGame";
|
||||
import { logger } from "@/utils/core/Logger";
|
||||
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
// Isolated scene — no world offset, no terrain. The repair game runs
|
||||
// fully centred in its own context so the heavy map never loads here.
|
||||
const REPAIR_SCENE_POSITION: Vector3Tuple = [0, 0, 0];
|
||||
|
||||
// Background: very dark blue-grey to match Altera's night-time mood
|
||||
const REPAIR_SCENE_BG = "#0b0d14";
|
||||
|
||||
// Lighting tuned to match the main world defaults from lightingConfig.ts
|
||||
const AMBIENT_COLOR = "#dfe7d8";
|
||||
const AMBIENT_INTENSITY = 0.9;
|
||||
const SUN_COLOR = "#ffe2bf";
|
||||
const SUN_INTENSITY = 2.2;
|
||||
const SUN_POSITION: Vector3Tuple = [5, 8, 4];
|
||||
|
||||
// Mimic the first-person view from the main world:
|
||||
// - PLAYER_EYE_HEIGHT = 1.75 → camera Y
|
||||
// - Case floats at [0, 0.4, 1.8] (inspected) → [0, 1.05, 2.05] (repairing)
|
||||
// - Look-at target averaged between those two states
|
||||
const CAMERA_POSITION: Vector3Tuple = [5, 2, 2];
|
||||
const CAMERA_LOOK_AT: Vector3Tuple = [0, 0.7, 1.9];
|
||||
|
||||
function RepairSceneCamera(): null {
|
||||
const { camera } = useThree();
|
||||
|
||||
useEffect(() => {
|
||||
camera.lookAt(...CAMERA_LOOK_AT);
|
||||
}, [camera]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
interface RepairGameSceneProps {
|
||||
mission: RepairMissionId;
|
||||
}
|
||||
|
||||
export function RepairGameScene({
|
||||
mission,
|
||||
}: RepairGameSceneProps): React.JSX.Element {
|
||||
const handleCreated = useCallback(({ gl }: { gl: THREE.WebGLRenderer }) => {
|
||||
const canvas = gl.domElement;
|
||||
const loseContextExt = gl.getContext().getExtension("WEBGL_lose_context");
|
||||
|
||||
const handleContextLost = (event: Event) => {
|
||||
event.preventDefault();
|
||||
logger.error("WebGL", "Repair scene context lost — attempting restore");
|
||||
window.setTimeout(() => loseContextExt?.restoreContext(), 500);
|
||||
};
|
||||
|
||||
const handleContextRestored = () => {
|
||||
logger.info("WebGL", "Repair scene context restored");
|
||||
};
|
||||
|
||||
canvas.addEventListener("webglcontextlost", handleContextLost);
|
||||
canvas.addEventListener("webglcontextrestored", handleContextRestored);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Canvas
|
||||
camera={{ position: CAMERA_POSITION, fov: 42 }}
|
||||
shadows={{ type: THREE.PCFShadowMap }}
|
||||
gl={{
|
||||
powerPreference: "high-performance",
|
||||
antialias: true,
|
||||
stencil: false,
|
||||
}}
|
||||
onCreated={handleCreated}
|
||||
>
|
||||
<color attach="background" args={[REPAIR_SCENE_BG]} />
|
||||
|
||||
<RepairSceneCamera />
|
||||
|
||||
{/* Lighting — mirrors the game world defaults */}
|
||||
<ambientLight intensity={AMBIENT_INTENSITY} color={AMBIENT_COLOR} />
|
||||
<directionalLight
|
||||
position={SUN_POSITION}
|
||||
intensity={SUN_INTENSITY}
|
||||
color={SUN_COLOR}
|
||||
castShadow
|
||||
shadow-mapSize-width={1024}
|
||||
shadow-mapSize-height={1024}
|
||||
/>
|
||||
|
||||
<Suspense fallback={null}>
|
||||
{/* Physics is required: TriggerObject and GrabbableObject both use
|
||||
RigidBody. The world is minimal — no octree, no character bodies. */}
|
||||
<Physics>
|
||||
<RepairGame
|
||||
mission={mission}
|
||||
position={REPAIR_SCENE_POSITION}
|
||||
snapToTerrain={false}
|
||||
/>
|
||||
</Physics>
|
||||
</Suspense>
|
||||
|
||||
<DebugPerf />
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+54
-17
@@ -1,19 +1,26 @@
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { Suspense, useEffect, useRef } from "react";
|
||||
import { Physics } from "@react-three/rapier";
|
||||
import {
|
||||
PLAYER_SPAWN_POSITION_GAME,
|
||||
PLAYER_SPAWN_POSITION_PHYSICS,
|
||||
} from "@/data/player/playerConfig";
|
||||
import { useRepairTransitionStore } from "@/managers/stores/useRepairTransitionStore";
|
||||
import { LA_FABRIK_INITIAL_LOOK_AT } from "@/data/world/laFabrikConfig";
|
||||
import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
||||
import { useEnvironmentDebug } from "@/hooks/debug/useEnvironmentDebug";
|
||||
import { useMapPerformanceDebug } from "@/hooks/debug/useMapPerformanceDebug";
|
||||
import { useCharacterDebug } from "@/hooks/debug/useCharacterDebug";
|
||||
import { usePlayerPositionDebug } from "@/hooks/debug/usePlayerPositionDebug";
|
||||
import { useDebugVisualsDebug } from "@/hooks/debug/useDebugVisualsDebug";
|
||||
import { useSceneMode } from "@/hooks/debug/useSceneMode";
|
||||
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
|
||||
import { useWorldSceneLoading } from "@/hooks/world/useWorldSceneLoading";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { useDebugVisualsStore } from "@/managers/stores/useDebugVisualsStore";
|
||||
import { DebugCameraControls } from "@/components/debug/scene/DebugCameraControls";
|
||||
import { DebugHelpers } from "@/components/debug/scene/DebugHelpers";
|
||||
import { DebugOctreeVisualization } from "@/components/debug/DebugOctreeVisualization";
|
||||
import { DebugPlayerModel } from "@/components/debug/DebugPlayerModel";
|
||||
import { HandTrackingGlove } from "@/components/three/handTracking/HandTrackingGlove";
|
||||
import { Environment } from "@/world/Environment";
|
||||
import { GameCinematics } from "@/world/GameCinematics";
|
||||
@@ -35,10 +42,16 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
||||
useEnvironmentDebug();
|
||||
useMapPerformanceDebug();
|
||||
useCharacterDebug();
|
||||
usePlayerPositionDebug();
|
||||
useDebugVisualsDebug();
|
||||
|
||||
const cameraMode = useCameraMode();
|
||||
const sceneMode = useSceneMode();
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const showDebugPlayerModel = useDebugVisualsStore(
|
||||
(state) => state.showPlayerModel,
|
||||
);
|
||||
const showDebugOctree = useDebugVisualsStore((state) => state.showOctree);
|
||||
const { status, usageStatus } = useHandTrackingSnapshot();
|
||||
const {
|
||||
octree,
|
||||
@@ -47,14 +60,32 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
||||
handleGameStageLoaded,
|
||||
handleGameMapLoaded,
|
||||
handleOctreeReady,
|
||||
handleShadowWarmupReady,
|
||||
handleShadowWarmupStarted,
|
||||
shouldWarmUpShadows,
|
||||
} = useWorldSceneLoading({ sceneMode, onLoadingStateChange });
|
||||
const playerSpawnPosition =
|
||||
sceneMode === "game"
|
||||
// Capture the spawn position once on mount via a ref so it never changes
|
||||
// mid-session (spawnPosition is reactive in Player and would re-spawn the
|
||||
// character on every prop change). If the player returns from a repair
|
||||
// scene, savedPlayerPosition holds their world position; otherwise fall
|
||||
// back to the default spawn from playerConfig.
|
||||
const savedPlayerPosition = useRepairTransitionStore(
|
||||
(s) => s.savedPlayerPosition,
|
||||
);
|
||||
const playerSpawnPositionRef = useRef(
|
||||
savedPlayerPosition ??
|
||||
(sceneMode === "game"
|
||||
? PLAYER_SPAWN_POSITION_GAME
|
||||
: PLAYER_SPAWN_POSITION_PHYSICS;
|
||||
: PLAYER_SPAWN_POSITION_PHYSICS),
|
||||
);
|
||||
const playerSpawnPosition = playerSpawnPositionRef.current;
|
||||
|
||||
// Clear the saved position right after capturing it so the next world
|
||||
// mount uses the default spawn instead of the stale repair-exit position.
|
||||
useEffect(() => {
|
||||
if (savedPlayerPosition !== null) {
|
||||
useRepairTransitionStore.getState().setSavedPlayerPosition(null);
|
||||
}
|
||||
// Only on mount — intentionally no deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
const showHandTrackingGloves =
|
||||
sceneMode === "physics" ||
|
||||
(status !== "idle" && usageStatus !== "inactive");
|
||||
@@ -64,15 +95,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 +122,22 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
||||
{showGameStage ? (
|
||||
<Physics>
|
||||
<GameStageLoaded onLoaded={handleGameStageLoaded} />
|
||||
<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}
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -3,18 +3,15 @@ import { AnimatedModel } from "@/components/three/models/AnimatedModel";
|
||||
import {
|
||||
CHARACTER_CONFIGS,
|
||||
CHARACTER_IDS,
|
||||
type CharacterConfig,
|
||||
type CharacterId,
|
||||
} from "@/data/world/characters/characterConfig";
|
||||
import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight";
|
||||
import { useCharacterDebugStore } from "@/managers/stores/useCharacterDebugStore";
|
||||
|
||||
function CharacterModel({ id }: { id: CharacterId }): React.JSX.Element {
|
||||
const config: CharacterConfig = CHARACTER_CONFIGS[id];
|
||||
const config = CHARACTER_CONFIGS[id];
|
||||
const state = useCharacterDebugStore((store) => store.characters[id]);
|
||||
const snappedPosition = useTerrainSnappedPosition(state.position);
|
||||
const position =
|
||||
config.snapToTerrain === false ? state.position : snappedPosition;
|
||||
const position = useTerrainSnappedPosition(state.position);
|
||||
|
||||
return (
|
||||
<AnimatedModel
|
||||
|
||||
@@ -17,8 +17,7 @@ export function GeneratedMapNodeInstance({
|
||||
node,
|
||||
onLoaded,
|
||||
}: GeneratedMapNodeInstanceProps): React.JSX.Element | null {
|
||||
const snappedPosition = useTerrainSnappedPosition(node.position);
|
||||
const position = node.name === "lafabrik" ? node.position : snappedPosition;
|
||||
const position = useTerrainSnappedPosition(node.position);
|
||||
const scale = normalizeMapScale(node.scale);
|
||||
|
||||
if (node.name === "ecole") {
|
||||
|
||||
@@ -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