Compare commits
49 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 | |||
| 9ef94af488 | |||
| 27b4a2c392 | |||
| d5feb07ff0 | |||
| c33d973f12 | |||
| 396e7e4ff0 |
@@ -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 |
|
| `low` | 10m | On | Always use `*-LOD` models |
|
||||||
| `medium` | 20m | On | Always use `*-LOD` models |
|
| `medium` | 20m | On | Always use `*-LOD` models |
|
||||||
| `high` | Current default 50m | Off | Regular model up to 10m, then `*-LOD` |
|
| `high` | 35m | Off | Regular model up to 10m, then `*-LOD` |
|
||||||
| `ultra` | 50m | Off | Regular model up to 20m, then `*-LOD` |
|
| `ultra` | 50m | Off | Regular model up to 20m, then `*-LOD` |
|
||||||
|
|
||||||
The unload distance stays slightly larger than the load distance to avoid rapid mount/unmount flickering when the player stands near a boundary.
|
The unload distance stays slightly larger than the load distance to avoid rapid mount/unmount flickering when the player stands near a boundary.
|
||||||
|
|||||||
@@ -158,9 +158,11 @@ Current runtime values:
|
|||||||
|
|
||||||
```txt
|
```txt
|
||||||
chunkSize: 35
|
chunkSize: 35
|
||||||
loadRadius: 45
|
low load/unload radius: 10m / 18m
|
||||||
unloadRadius: 45
|
medium load/unload radius: 20m / 30m
|
||||||
updateInterval: 350ms
|
high load/unload radius: 35m / 45m
|
||||||
|
ultra load/unload radius: 50m / 65m
|
||||||
|
updateInterval: 250ms
|
||||||
fog near: 30
|
fog near: 30
|
||||||
fog far: 45
|
fog far: 45
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -74,22 +74,32 @@ It tracks:
|
|||||||
- `gameMapLoaded`: map data and visible map nodes settled
|
- `gameMapLoaded`: map data and visible map nodes settled
|
||||||
- `gameStageLoaded`: Rapier gameplay stage mounted
|
- `gameStageLoaded`: Rapier gameplay stage mounted
|
||||||
- `showGameStage`: true when the map is ready enough to mount gameplay content
|
- `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, and octree are all ready
|
||||||
- `gameplayReady`: true when map, stage, octree, and the shadow warmup are all ready
|
|
||||||
|
|
||||||
The base game-scene readiness condition before the shadow warmup is:
|
The game-scene readiness condition is:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
showGameStage && gameStageLoaded && octree !== null;
|
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
|
### Avoiding global scene remounts
|
||||||
Activation des ombres -> Ombres prêtes -> Gameplay prêt
|
|
||||||
```
|
|
||||||
|
|
||||||
This keeps the loading overlay visible until the renderer shadow map, shadow-casting light, and mounted scene graph have all been explicitly refreshed.
|
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:
|
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
|
rm -rf node_modules/.vite
|
||||||
npm run dev:three-debug
|
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.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -78,19 +78,19 @@
|
|||||||
{
|
{
|
||||||
"id": "narrateur_coupureelec",
|
"id": "narrateur_coupureelec",
|
||||||
"voice": "narrateur",
|
"voice": "narrateur",
|
||||||
"audio": "/sounds/dialogue/narrateur_coupureélec.mp3",
|
"audio": "/sounds/dialogue/narrateur_coupure_elec.mp3",
|
||||||
"subtitleCueIndex": 9
|
"subtitleCueIndex": 9
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "narrateur_poteaueleccasse",
|
"id": "narrateur_poteaueleccasse",
|
||||||
"voice": "narrateur",
|
"voice": "narrateur",
|
||||||
"audio": "/sounds/dialogue/narrateur_poteauéleccassé.mp3",
|
"audio": "/sounds/dialogue/narrateur_poteau_elec_casse.mp3",
|
||||||
"subtitleCueIndex": 10
|
"subtitleCueIndex": 10
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "narrateur_courantrepare",
|
"id": "narrateur_courantrepare",
|
||||||
"voice": "narrateur",
|
"voice": "narrateur",
|
||||||
"audio": "/sounds/dialogue/narrateur_courantréparé.mp3",
|
"audio": "/sounds/dialogue/narrateur_courant_repare.mp3",
|
||||||
"subtitleCueIndex": 11
|
"subtitleCueIndex": 11
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -165,6 +165,12 @@
|
|||||||
"audio": "/sounds/dialogue/narrateur_histoireelectricienne.mp3",
|
"audio": "/sounds/dialogue/narrateur_histoireelectricienne.mp3",
|
||||||
"subtitleCueIndex": 23
|
"subtitleCueIndex": 23
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "narrateur_demande_aide",
|
||||||
|
"voice": "narrateur",
|
||||||
|
"audio": "/sounds/dialogue/narrateur_demande_aide.mp3",
|
||||||
|
"subtitleCueIndex": 24
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "fermier_coupdemain",
|
"id": "fermier_coupdemain",
|
||||||
"voice": "fermier",
|
"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 { RouterProvider } from "@tanstack/react-router";
|
||||||
|
import { SiteMobileBlocker } from "@/components/site/SiteMobileBlocker";
|
||||||
|
import { useIsMobile } from "@/hooks/ui/useIsMobile";
|
||||||
import { router } from "@/router";
|
import { router } from "@/router";
|
||||||
|
|
||||||
function App(): React.JSX.Element {
|
function App(): React.JSX.Element {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return <SiteMobileBlocker />;
|
||||||
|
}
|
||||||
|
|
||||||
return <RouterProvider router={router} />;
|
return <RouterProvider router={router} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { Box3, BufferAttribute, BufferGeometry } from "three";
|
||||||
|
import type { Octree } from "three-stdlib";
|
||||||
|
import {
|
||||||
|
LA_FABRIK_CENTER,
|
||||||
|
isInsideLaFabrikFootprint,
|
||||||
|
} from "@/data/world/laFabrikConfig";
|
||||||
|
import { useDebugVisualsStore } from "@/managers/stores/useDebugVisualsStore";
|
||||||
|
|
||||||
|
interface DebugOctreeVisualizationProps {
|
||||||
|
octree: Octree | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OctreeNodeBox {
|
||||||
|
box: Box3;
|
||||||
|
depth: number;
|
||||||
|
triangleCount: number;
|
||||||
|
isLeaf: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CollectOptions {
|
||||||
|
minDepth: number;
|
||||||
|
maxDepth: number;
|
||||||
|
leavesOnly: boolean;
|
||||||
|
fabrikOnly: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FABRIK_FILTER_PADDING = 1.5;
|
||||||
|
const FABRIK_FILTER_VERTICAL = 8;
|
||||||
|
|
||||||
|
const BOX_VERTEX_INDEX_PAIRS: ReadonlyArray<readonly [number, number]> = [
|
||||||
|
[0, 1],
|
||||||
|
[1, 3],
|
||||||
|
[3, 2],
|
||||||
|
[2, 0],
|
||||||
|
[4, 5],
|
||||||
|
[5, 7],
|
||||||
|
[7, 6],
|
||||||
|
[6, 4],
|
||||||
|
[0, 4],
|
||||||
|
[1, 5],
|
||||||
|
[2, 6],
|
||||||
|
[3, 7],
|
||||||
|
];
|
||||||
|
|
||||||
|
function boxIntersectsFabrik(box: Box3): boolean {
|
||||||
|
if (box.max.y < LA_FABRIK_CENTER[1] - FABRIK_FILTER_VERTICAL) return false;
|
||||||
|
if (box.min.y > LA_FABRIK_CENTER[1] + FABRIK_FILTER_VERTICAL) return false;
|
||||||
|
|
||||||
|
// Sample box corners + center on XZ plane against the rotated fabrik footprint.
|
||||||
|
const samples: ReadonlyArray<readonly [number, number]> = [
|
||||||
|
[box.min.x, box.min.z],
|
||||||
|
[box.min.x, box.max.z],
|
||||||
|
[box.max.x, box.min.z],
|
||||||
|
[box.max.x, box.max.z],
|
||||||
|
[(box.min.x + box.max.x) * 0.5, (box.min.z + box.max.z) * 0.5],
|
||||||
|
];
|
||||||
|
for (const [x, z] of samples) {
|
||||||
|
if (isInsideLaFabrikFootprint(x, z, FABRIK_FILTER_PADDING)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectOctreeBoxes(
|
||||||
|
node: Octree,
|
||||||
|
options: CollectOptions,
|
||||||
|
depth = 0,
|
||||||
|
acc: OctreeNodeBox[] = [],
|
||||||
|
): OctreeNodeBox[] {
|
||||||
|
if (depth > options.maxDepth) return acc;
|
||||||
|
|
||||||
|
const isLeaf = node.subTrees.length === 0;
|
||||||
|
const passesDepth = depth >= options.minDepth;
|
||||||
|
const passesLeafFilter = !options.leavesOnly || isLeaf;
|
||||||
|
const hasTriangles = node.triangles.length > 0;
|
||||||
|
const passesFabrikFilter =
|
||||||
|
!options.fabrikOnly || boxIntersectsFabrik(node.box);
|
||||||
|
|
||||||
|
if (passesDepth && passesLeafFilter && hasTriangles && passesFabrikFilter) {
|
||||||
|
acc.push({
|
||||||
|
box: node.box,
|
||||||
|
depth,
|
||||||
|
triangleCount: node.triangles.length,
|
||||||
|
isLeaf,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sub of node.subTrees) {
|
||||||
|
collectOctreeBoxes(sub, options, depth + 1, acc);
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOctreeLineGeometry(
|
||||||
|
nodes: readonly OctreeNodeBox[],
|
||||||
|
): BufferGeometry {
|
||||||
|
const positionsBuffer = new Float32Array(
|
||||||
|
nodes.length * BOX_VERTEX_INDEX_PAIRS.length * 2 * 3,
|
||||||
|
);
|
||||||
|
|
||||||
|
const corners: [number, number, number][] = Array.from({ length: 8 }, () => [
|
||||||
|
0, 0, 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
let positionsOffset = 0;
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
const { min, max } = node.box;
|
||||||
|
|
||||||
|
corners[0] = [min.x, min.y, min.z];
|
||||||
|
corners[1] = [max.x, min.y, min.z];
|
||||||
|
corners[2] = [min.x, max.y, min.z];
|
||||||
|
corners[3] = [max.x, max.y, min.z];
|
||||||
|
corners[4] = [min.x, min.y, max.z];
|
||||||
|
corners[5] = [max.x, min.y, max.z];
|
||||||
|
corners[6] = [min.x, max.y, max.z];
|
||||||
|
corners[7] = [max.x, max.y, max.z];
|
||||||
|
|
||||||
|
for (const [a, b] of BOX_VERTEX_INDEX_PAIRS) {
|
||||||
|
const ca = corners[a]!;
|
||||||
|
const cb = corners[b]!;
|
||||||
|
positionsBuffer[positionsOffset++] = ca[0];
|
||||||
|
positionsBuffer[positionsOffset++] = ca[1];
|
||||||
|
positionsBuffer[positionsOffset++] = ca[2];
|
||||||
|
positionsBuffer[positionsOffset++] = cb[0];
|
||||||
|
positionsBuffer[positionsOffset++] = cb[1];
|
||||||
|
positionsBuffer[positionsOffset++] = cb[2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const geometry = new BufferGeometry();
|
||||||
|
geometry.setAttribute("position", new BufferAttribute(positionsBuffer, 3));
|
||||||
|
return geometry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DebugOctreeVisualization({
|
||||||
|
octree,
|
||||||
|
}: DebugOctreeVisualizationProps): React.JSX.Element | null {
|
||||||
|
const showOctree = useDebugVisualsStore((state) => state.showOctree);
|
||||||
|
const minDepth = useDebugVisualsStore((state) => state.octreeMinDepth);
|
||||||
|
const maxDepth = useDebugVisualsStore((state) => state.octreeMaxDepth);
|
||||||
|
const leavesOnly = useDebugVisualsStore((state) => state.octreeLeavesOnly);
|
||||||
|
const opacity = useDebugVisualsStore((state) => state.octreeOpacity);
|
||||||
|
const fabrikOnly = useDebugVisualsStore((state) => state.octreeFabrikOnly);
|
||||||
|
|
||||||
|
const geometry = useMemo(() => {
|
||||||
|
if (!octree || !showOctree) return null;
|
||||||
|
const boxes = collectOctreeBoxes(octree, {
|
||||||
|
minDepth,
|
||||||
|
maxDepth,
|
||||||
|
leavesOnly,
|
||||||
|
fabrikOnly,
|
||||||
|
});
|
||||||
|
if (boxes.length === 0) return null;
|
||||||
|
return buildOctreeLineGeometry(boxes);
|
||||||
|
}, [fabrikOnly, leavesOnly, maxDepth, minDepth, octree, showOctree]);
|
||||||
|
|
||||||
|
if (!geometry) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<lineSegments frustumCulled={false} renderOrder={999}>
|
||||||
|
<primitive object={geometry} attach="geometry" />
|
||||||
|
<lineBasicMaterial
|
||||||
|
color="#22d3ee"
|
||||||
|
depthTest={false}
|
||||||
|
depthWrite={false}
|
||||||
|
transparent
|
||||||
|
opacity={opacity}
|
||||||
|
/>
|
||||||
|
</lineSegments>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { useFrame } from "@react-three/fiber";
|
||||||
|
import { useGLTF } from "@react-three/drei";
|
||||||
|
|
||||||
|
const MODEL_PATH = "/models/persoprincipal/model.gltf";
|
||||||
|
// Offset expressed in the camera's local space:
|
||||||
|
// - x: horizontal (0 = centered)
|
||||||
|
// - y: vertical relative to camera eye (negative = below)
|
||||||
|
// - z: forward (negative = in front of the camera)
|
||||||
|
const LOCAL_OFFSET = new THREE.Vector3(0, -1, -2.5);
|
||||||
|
|
||||||
|
const eulerHelper = new THREE.Euler();
|
||||||
|
|
||||||
|
export function DebugPlayerModel(): React.JSX.Element {
|
||||||
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
|
const { scene } = useGLTF(MODEL_PATH);
|
||||||
|
|
||||||
|
const model = useMemo(() => {
|
||||||
|
const cloned = scene.clone(true);
|
||||||
|
cloned.traverse((child) => {
|
||||||
|
if (child instanceof THREE.Mesh) {
|
||||||
|
child.castShadow = true;
|
||||||
|
child.receiveShadow = true;
|
||||||
|
child.frustumCulled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return cloned;
|
||||||
|
}, [scene]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
model.clear();
|
||||||
|
},
|
||||||
|
[model],
|
||||||
|
);
|
||||||
|
|
||||||
|
useFrame(({ camera }) => {
|
||||||
|
const group = groupRef.current;
|
||||||
|
if (!group) return;
|
||||||
|
|
||||||
|
// Place the model in front of the camera using its local space so it stays
|
||||||
|
// visible regardless of the camera pitch (top-down ebike view, etc.).
|
||||||
|
group.position.copy(LOCAL_OFFSET).applyMatrix4(camera.matrixWorld);
|
||||||
|
|
||||||
|
// Keep the model upright and aligned with the camera yaw only.
|
||||||
|
eulerHelper.setFromQuaternion(camera.quaternion, "YXZ");
|
||||||
|
group.rotation.set(0, eulerHelper.y, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={groupRef} frustumCulled={false}>
|
||||||
|
<primitive object={model} />
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
useGLTF.preload(MODEL_PATH);
|
||||||
+116
-53
@@ -2,17 +2,22 @@ import { useEffect, useRef, useState, useMemo, useCallback } from "react";
|
|||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { useFrame, useThree } from "@react-three/fiber";
|
import { useFrame, useThree } from "@react-three/fiber";
|
||||||
import { EbikeGPSMap } from "@/components/ebike/EbikeGPSMap";
|
import { EbikeGPSMap } from "@/components/ebike/EbikeGPSMap";
|
||||||
|
import { EbikeSpeedometer } from "@/components/ebike/EbikeSpeedometer";
|
||||||
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||||
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||||
import { useEbikeSounds } from "@/hooks/ebike/useEbikeSounds";
|
import { useEbikeSounds } from "@/hooks/ebike/useEbikeSounds";
|
||||||
|
import {
|
||||||
|
getObjectBottomOffset,
|
||||||
|
useTerrainHeightSampler,
|
||||||
|
} from "@/hooks/three/useTerrainHeight";
|
||||||
import { animateCameraTransformTransition } from "@/world/GameCinematics";
|
import { animateCameraTransformTransition } from "@/world/GameCinematics";
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
import { PLAYER_EYE_HEIGHT } from "@/data/player/playerConfig";
|
|
||||||
import {
|
import {
|
||||||
EBIKE_CAMERA_TRANSFORM,
|
EBIKE_CAMERA_TRANSFORM,
|
||||||
EBIKE_DROP_PLAYER_TRANSFORM,
|
EBIKE_DROP_PLAYER_TRANSFORM,
|
||||||
|
EBIKE_WORLD_SCALE,
|
||||||
EBIKE_WORLD_ROTATION_Y,
|
EBIKE_WORLD_ROTATION_Y,
|
||||||
} from "@/data/ebike/ebikeConfig";
|
} from "@/data/ebike/ebikeConfig";
|
||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
@@ -31,12 +36,29 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
|||||||
position: position,
|
position: position,
|
||||||
});
|
});
|
||||||
const model = useClonedObject(scene);
|
const model = useClonedObject(scene);
|
||||||
|
const terrainHeight = useTerrainHeightSampler();
|
||||||
|
const parkedPosition = useMemo<Vector3Tuple>(() => {
|
||||||
|
const [x, y, z] = position;
|
||||||
|
const height = terrainHeight.getHeight(x, z) ?? y;
|
||||||
|
const bottomOffset = getObjectBottomOffset(model, [
|
||||||
|
EBIKE_WORLD_SCALE,
|
||||||
|
EBIKE_WORLD_SCALE,
|
||||||
|
EBIKE_WORLD_SCALE,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [x, height + bottomOffset, z];
|
||||||
|
}, [model, position, terrainHeight]);
|
||||||
const movementMode = useGameStore((state) => state.player.movementMode);
|
const movementMode = useGameStore((state) => state.player.movementMode);
|
||||||
const mainState = useGameStore((state) => state.mainState);
|
const mainState = useGameStore((state) => state.mainState);
|
||||||
const ebikeStep = useGameStore((state) => state.ebike.currentStep);
|
const ebikeStep = useGameStore((state) => state.ebike.currentStep);
|
||||||
const setMissionStep = useGameStore((state) => state.setMissionStep);
|
const setMissionStep = useGameStore((state) => state.setMissionStep);
|
||||||
const camera = useThree((state) => state.camera);
|
const camera = useThree((state) => state.camera);
|
||||||
const updateEbikeSounds = useEbikeSounds();
|
const updateEbikeSounds = useEbikeSounds();
|
||||||
|
const repairGameOwnsEbikeModel =
|
||||||
|
mainState === "ebike" &&
|
||||||
|
ebikeStep !== "locked" &&
|
||||||
|
ebikeStep !== "waiting" &&
|
||||||
|
ebikeStep !== "inspected";
|
||||||
|
|
||||||
// Map active mainState to target repair zone coordinate
|
// Map active mainState to target repair zone coordinate
|
||||||
const destPos = useMemo(() => {
|
const destPos = useMemo(() => {
|
||||||
@@ -58,19 +80,19 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
|||||||
y: number;
|
y: number;
|
||||||
z: number;
|
z: number;
|
||||||
}>({
|
}>({
|
||||||
x: position[0],
|
x: parkedPosition[0],
|
||||||
y: position[1],
|
y: parkedPosition[1],
|
||||||
z: position[2],
|
z: parkedPosition[2],
|
||||||
});
|
});
|
||||||
const lastGpsUpdatePos = useRef<THREE.Vector3>(
|
const lastGpsUpdatePos = useRef<THREE.Vector3>(
|
||||||
new THREE.Vector3(...position),
|
new THREE.Vector3(...parkedPosition),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Use ref for internal state, and state for debug visualization (to avoid ref access during render)
|
// Use ref for internal state, and state for debug visualization (to avoid ref access during render)
|
||||||
const restingPositionRef = useRef<Vector3Tuple>([
|
const restingPositionRef = useRef<Vector3Tuple>([
|
||||||
position[0],
|
parkedPosition[0],
|
||||||
position[1] - PLAYER_EYE_HEIGHT,
|
parkedPosition[1],
|
||||||
position[2],
|
parkedPosition[2],
|
||||||
]);
|
]);
|
||||||
const restingRotationRef = useRef<number>(EBIKE_WORLD_ROTATION_Y);
|
const restingRotationRef = useRef<number>(EBIKE_WORLD_ROTATION_Y);
|
||||||
const forkRef = useRef<THREE.Object3D | null>(null);
|
const forkRef = useRef<THREE.Object3D | null>(null);
|
||||||
@@ -79,11 +101,27 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
|||||||
const [showCameraPoints, setShowCameraPoints] = useState(true);
|
const [showCameraPoints, setShowCameraPoints] = useState(true);
|
||||||
const [debugRestingPosition, setDebugRestingPosition] =
|
const [debugRestingPosition, setDebugRestingPosition] =
|
||||||
useState<Vector3Tuple>([
|
useState<Vector3Tuple>([
|
||||||
position[0],
|
parkedPosition[0],
|
||||||
position[1] - PLAYER_EYE_HEIGHT,
|
parkedPosition[1],
|
||||||
position[2],
|
parkedPosition[2],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (movementMode === "ebike") return;
|
||||||
|
|
||||||
|
restingPositionRef.current = parkedPosition;
|
||||||
|
restingRotationRef.current = EBIKE_WORLD_ROTATION_Y;
|
||||||
|
lastGpsUpdatePos.current.set(...parkedPosition);
|
||||||
|
|
||||||
|
if (groupRef.current) {
|
||||||
|
groupRef.current.position.set(...parkedPosition);
|
||||||
|
groupRef.current.rotation.set(0, EBIKE_WORLD_ROTATION_Y, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.ebikeParkedPosition = parkedPosition;
|
||||||
|
window.ebikeParkedRotation = EBIKE_WORLD_ROTATION_Y;
|
||||||
|
}, [movementMode, parkedPosition]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (model) {
|
if (model) {
|
||||||
const fork = model.getObjectByName("fourche");
|
const fork = model.getObjectByName("fourche");
|
||||||
@@ -93,6 +131,17 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
}, [model]);
|
}, [model]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!model) return;
|
||||||
|
|
||||||
|
model.traverse((child) => {
|
||||||
|
if (child instanceof THREE.Mesh) {
|
||||||
|
child.castShadow = true;
|
||||||
|
child.receiveShadow = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [model]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.ebikeVisualGroup = groupRef;
|
window.ebikeVisualGroup = groupRef;
|
||||||
window.ebikeParkedPosition = restingPositionRef.current;
|
window.ebikeParkedPosition = restingPositionRef.current;
|
||||||
@@ -169,16 +218,30 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
|||||||
debugRestingPosition[1] + EBIKE_DROP_PLAYER_TRANSFORM.position[1],
|
debugRestingPosition[1] + EBIKE_DROP_PLAYER_TRANSFORM.position[1],
|
||||||
debugRestingPosition[2] + EBIKE_DROP_PLAYER_TRANSFORM.position[2],
|
debugRestingPosition[2] + EBIKE_DROP_PLAYER_TRANSFORM.position[2],
|
||||||
];
|
];
|
||||||
|
const interactionLabel =
|
||||||
|
mainState === "ebike"
|
||||||
|
? "Réparer l'e-bike"
|
||||||
|
: movementMode === "walk"
|
||||||
|
? "Monter sur le bike"
|
||||||
|
: "Descendre du bike";
|
||||||
|
|
||||||
const handleInteract = useCallback((): void => {
|
const handleInteract = useCallback((): void => {
|
||||||
if (window.ebikeBreakdownActive === true) return;
|
if (window.ebikeBreakdownActive === true) return;
|
||||||
|
|
||||||
if (movementMode === "walk") {
|
if (movementMode === "walk") {
|
||||||
if (mainState === "ebike" && ebikeStep === "waiting") {
|
if (
|
||||||
|
mainState === "ebike" &&
|
||||||
|
(ebikeStep === "locked" || ebikeStep === "waiting")
|
||||||
|
) {
|
||||||
setMissionStep("ebike", "inspected");
|
setMissionStep("ebike", "inspected");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mainState === "ebike" && ebikeStep === "inspected") {
|
||||||
|
setMissionStep("ebike", "fragmented");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const cameraOffset = new THREE.Vector3(
|
const cameraOffset = new THREE.Vector3(
|
||||||
...EBIKE_CAMERA_TRANSFORM.position,
|
...EBIKE_CAMERA_TRANSFORM.position,
|
||||||
);
|
);
|
||||||
@@ -258,51 +321,51 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<group
|
{!repairGameOwnsEbikeModel ? (
|
||||||
ref={groupRef}
|
<group
|
||||||
position={position}
|
ref={groupRef}
|
||||||
rotation={[0, EBIKE_WORLD_ROTATION_Y, 0]}
|
position={parkedPosition}
|
||||||
>
|
rotation={[0, EBIKE_WORLD_ROTATION_Y, 0]}
|
||||||
<primitive object={model} />
|
scale={EBIKE_WORLD_SCALE}
|
||||||
<InteractableObject
|
|
||||||
kind="trigger"
|
|
||||||
label={
|
|
||||||
mainState === "ebike" && ebikeStep === "waiting"
|
|
||||||
? "Inspecter l'e-bike"
|
|
||||||
: movementMode === "walk"
|
|
||||||
? "Monter sur le bike"
|
|
||||||
: "Descendre du bike"
|
|
||||||
}
|
|
||||||
position={position}
|
|
||||||
radius={15}
|
|
||||||
onPress={handleInteract}
|
|
||||||
>
|
>
|
||||||
<mesh>
|
<primitive object={model} />
|
||||||
<boxGeometry args={[10, 13, 2]} />
|
<InteractableObject
|
||||||
<meshBasicMaterial colorWrite={false} depthWrite={false} />
|
kind="trigger"
|
||||||
</mesh>
|
label={interactionLabel}
|
||||||
</InteractableObject>
|
position={parkedPosition}
|
||||||
|
radius={5}
|
||||||
|
onPress={handleInteract}
|
||||||
|
>
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[8, 9, 2]} />
|
||||||
|
<meshBasicMaterial colorWrite={false} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
</InteractableObject>
|
||||||
|
|
||||||
{/* Dynamic 3D GPS Dashboard Screen */}
|
{/* Dynamic 3D GPS Dashboard Screen */}
|
||||||
<group position={[0, 7, 0]} rotation={[0, 90, 0]}>
|
<group position={[0, 7, 0]} rotation={[0, 90, 0]}>
|
||||||
<EbikeGPSMap
|
<EbikeGPSMap
|
||||||
width={0.8}
|
width={0.8}
|
||||||
height={0.8}
|
height={0.8}
|
||||||
startPos={gpsStartPos}
|
startPos={gpsStartPos}
|
||||||
destPos={destPos}
|
destPos={destPos}
|
||||||
mapImageUrl="/assets/world/gps/map_background.png"
|
mapImageUrl="/assets/world/gps/map_background.png"
|
||||||
worldBounds={{
|
worldBounds={{
|
||||||
minX: -166,
|
minX: -166,
|
||||||
maxX: 163,
|
maxX: 163,
|
||||||
minZ: -142,
|
minZ: -142,
|
||||||
maxZ: 138,
|
maxZ: 138,
|
||||||
}}
|
}}
|
||||||
zoom={4}
|
zoom={4}
|
||||||
/>
|
/>
|
||||||
|
</group>
|
||||||
|
<group position={[0, 6.35, 0]} rotation={[0, 90, 0]}>
|
||||||
|
<EbikeSpeedometer />
|
||||||
|
</group>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
) : null}
|
||||||
|
|
||||||
{showCameraPoints && (
|
{showCameraPoints && !repairGameOwnsEbikeModel && (
|
||||||
<>
|
<>
|
||||||
<mesh position={camPointPos}>
|
<mesh position={camPointPos}>
|
||||||
<sphereGeometry args={[0.3, 16, 16]} />
|
<sphereGeometry args={[0.3, 16, 16]} />
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ export interface EbikeGPSMapProps {
|
|||||||
* Default: 1
|
* Default: 1
|
||||||
*/
|
*/
|
||||||
zoom?: number;
|
zoom?: number;
|
||||||
|
|
||||||
|
renderOrder?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -107,6 +109,7 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
|
|||||||
position = [0, 0, 0],
|
position = [0, 0, 0],
|
||||||
canvasSize = 1024,
|
canvasSize = 1024,
|
||||||
zoom = 1,
|
zoom = 1,
|
||||||
|
renderOrder = 10_000,
|
||||||
}) => {
|
}) => {
|
||||||
const [waypoints, setWaypoints] = useState<Waypoint[]>([]);
|
const [waypoints, setWaypoints] = useState<Waypoint[]>([]);
|
||||||
const [mapImage, setMapImage] = useState<
|
const [mapImage, setMapImage] = useState<
|
||||||
@@ -506,12 +509,13 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
|
|||||||
}, [draw]);
|
}, [draw]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh castShadow receiveShadow position={position}>
|
<mesh position={position} renderOrder={renderOrder}>
|
||||||
<planeGeometry args={[width, height]} />
|
<planeGeometry args={[width, height]} />
|
||||||
<meshBasicMaterial
|
<meshBasicMaterial
|
||||||
toneMapped={false}
|
toneMapped={false}
|
||||||
transparent={true}
|
transparent={true}
|
||||||
opacity={1}
|
opacity={1}
|
||||||
|
depthTest={false}
|
||||||
depthWrite={false}
|
depthWrite={false}
|
||||||
side={THREE.DoubleSide}
|
side={THREE.DoubleSide}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useFrame } from "@react-three/fiber";
|
||||||
|
import { useTexture } from "@react-three/drei";
|
||||||
|
import * as THREE from "three";
|
||||||
|
|
||||||
|
const SPEEDOMETER_DIAL_TEXTURE = "/assets/world/gps/cadran.png";
|
||||||
|
const SPEEDOMETER_NEEDLE_TEXTURE = "/assets/world/gps/fleche.png";
|
||||||
|
const SPEEDOMETER_MIN_ANGLE = Math.PI / 2;
|
||||||
|
const SPEEDOMETER_MAX_ANGLE = -Math.PI / 2;
|
||||||
|
const SPEEDOMETER_RENDER_ORDER = 10_000;
|
||||||
|
|
||||||
|
interface EbikeSpeedometerProps {
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EbikeSpeedometer({
|
||||||
|
width = 0.9,
|
||||||
|
height = 0.5,
|
||||||
|
}: EbikeSpeedometerProps): React.JSX.Element {
|
||||||
|
const needleGroupRef = useRef<THREE.Group>(null);
|
||||||
|
const speedFactorRef = useRef(0);
|
||||||
|
const [dialTexture, needleTexture] = useTexture([
|
||||||
|
SPEEDOMETER_DIAL_TEXTURE,
|
||||||
|
SPEEDOMETER_NEEDLE_TEXTURE,
|
||||||
|
]) as [THREE.Texture, THREE.Texture];
|
||||||
|
const needleWidth = width * 0.68;
|
||||||
|
const needleHeight = needleWidth / 2;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
[dialTexture, needleTexture].forEach((texture) => {
|
||||||
|
texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
texture.needsUpdate = true;
|
||||||
|
});
|
||||||
|
}, [dialTexture, needleTexture]);
|
||||||
|
|
||||||
|
useFrame((_, delta) => {
|
||||||
|
const targetSpeedFactor = THREE.MathUtils.clamp(
|
||||||
|
window.ebikeSpeedFactor ?? 0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
speedFactorRef.current = THREE.MathUtils.lerp(
|
||||||
|
speedFactorRef.current,
|
||||||
|
targetSpeedFactor,
|
||||||
|
Math.min(1, delta * 10),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (needleGroupRef.current) {
|
||||||
|
needleGroupRef.current.rotation.z = THREE.MathUtils.lerp(
|
||||||
|
SPEEDOMETER_MIN_ANGLE,
|
||||||
|
SPEEDOMETER_MAX_ANGLE,
|
||||||
|
speedFactorRef.current,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group renderOrder={SPEEDOMETER_RENDER_ORDER}>
|
||||||
|
<mesh renderOrder={SPEEDOMETER_RENDER_ORDER}>
|
||||||
|
<planeGeometry args={[width, height]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
map={dialTexture}
|
||||||
|
transparent
|
||||||
|
depthTest={false}
|
||||||
|
depthWrite={false}
|
||||||
|
toneMapped={false}
|
||||||
|
side={THREE.DoubleSide}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
<group ref={needleGroupRef} position={[0, -height * 0.38, 0.002]}>
|
||||||
|
<mesh
|
||||||
|
position={[0, needleHeight / 2, 0]}
|
||||||
|
renderOrder={SPEEDOMETER_RENDER_ORDER + 1}
|
||||||
|
>
|
||||||
|
<planeGeometry args={[needleWidth, needleHeight]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
map={needleTexture}
|
||||||
|
transparent
|
||||||
|
depthTest={false}
|
||||||
|
depthWrite={false}
|
||||||
|
toneMapped={false}
|
||||||
|
side={THREE.DoubleSide}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,23 +1,30 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import * as THREE from "three";
|
||||||
import { MissionNotification } from "@/components/ui/MissionNotification";
|
import { MissionNotification } from "@/components/ui/MissionNotification";
|
||||||
import {
|
import {
|
||||||
EBIKE_BREAKDOWN_DIALOGUE_DELAY_MS,
|
EBIKE_BREAKDOWN_DIALOGUE_DELAY_MS,
|
||||||
EBIKE_BREAKDOWN_DIALOGUE_ID,
|
EBIKE_BREAKDOWN_DIALOGUE_ID,
|
||||||
EBIKE_INTRO_RIDE_DURATION_MS,
|
EBIKE_INTRO_BREAKDOWN_DISTANCE,
|
||||||
EBIKE_SOUNDS,
|
EBIKE_SOUNDS,
|
||||||
} from "@/data/ebike/ebikeConfig";
|
} from "@/data/ebike/ebikeConfig";
|
||||||
|
import { INTRO_MISSION_NOTIFICATION_IMAGE_PATH } from "@/data/gameplay/missionNotifications";
|
||||||
import { AudioManager } from "@/managers/AudioManager";
|
import { AudioManager } from "@/managers/AudioManager";
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||||
import { playDialogueById } from "@/utils/dialogues/playDialogue";
|
import { playDialogueById } from "@/utils/dialogues/playDialogue";
|
||||||
|
|
||||||
export function EbikeIntroSequence(): React.JSX.Element | null {
|
export function EbikeIntroSequence(): React.JSX.Element | null {
|
||||||
|
const mainState = useGameStore((state) => state.mainState);
|
||||||
const introStep = useGameStore((state) => state.intro.currentStep);
|
const introStep = useGameStore((state) => state.intro.currentStep);
|
||||||
const movementMode = useGameStore((state) => state.player.movementMode);
|
const movementMode = useGameStore((state) => state.player.movementMode);
|
||||||
|
const pylonStep = useGameStore((state) => state.pylon.currentStep);
|
||||||
const setIntroStep = useGameStore((state) => state.setIntroStep);
|
const setIntroStep = useGameStore((state) => state.setIntroStep);
|
||||||
const completeIntro = useGameStore((state) => state.completeIntro);
|
const completeIntro = useGameStore((state) => state.completeIntro);
|
||||||
const [breakdownDialogueDone, setBreakdownDialogueDone] = useState(false);
|
const [breakdownDialogueDone, setBreakdownDialogueDone] = useState(false);
|
||||||
const hasStartedBreakdown = useRef(false);
|
const hasStartedBreakdown = useRef(false);
|
||||||
|
const rideDistance = useRef(0);
|
||||||
|
const lastRidePosition = useRef<THREE.Vector3 | null>(null);
|
||||||
|
const currentRidePosition = useRef(new THREE.Vector3());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (introStep !== "await-ebike-mount" || movementMode !== "ebike") return;
|
if (introStep !== "await-ebike-mount" || movementMode !== "ebike") return;
|
||||||
@@ -26,16 +33,45 @@ export function EbikeIntroSequence(): React.JSX.Element | null {
|
|||||||
}, [introStep, movementMode, setIntroStep]);
|
}, [introStep, movementMode, setIntroStep]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (introStep !== "ebike-intro-ride") return undefined;
|
if (introStep !== "ebike-intro-ride") return;
|
||||||
|
|
||||||
const timeoutId = window.setTimeout(() => {
|
rideDistance.current = 0;
|
||||||
setIntroStep("ebike-breakdown");
|
lastRidePosition.current = null;
|
||||||
}, EBIKE_INTRO_RIDE_DURATION_MS);
|
}, [introStep]);
|
||||||
|
|
||||||
return () => {
|
useEffect(() => {
|
||||||
window.clearTimeout(timeoutId);
|
if (introStep !== "ebike-intro-ride" || movementMode !== "ebike") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let animationFrameId = 0;
|
||||||
|
const tick = () => {
|
||||||
|
const parkedPosition = window.ebikeParkedPosition;
|
||||||
|
if (parkedPosition) {
|
||||||
|
currentRidePosition.current.set(...parkedPosition);
|
||||||
|
if (!lastRidePosition.current) {
|
||||||
|
lastRidePosition.current = currentRidePosition.current.clone();
|
||||||
|
} else {
|
||||||
|
rideDistance.current += currentRidePosition.current.distanceTo(
|
||||||
|
lastRidePosition.current,
|
||||||
|
);
|
||||||
|
lastRidePosition.current.copy(currentRidePosition.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rideDistance.current >= EBIKE_INTRO_BREAKDOWN_DISTANCE) {
|
||||||
|
setIntroStep("ebike-breakdown");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
animationFrameId = window.requestAnimationFrame(tick);
|
||||||
};
|
};
|
||||||
}, [introStep, setIntroStep]);
|
|
||||||
|
animationFrameId = window.requestAnimationFrame(tick);
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(animationFrameId);
|
||||||
|
};
|
||||||
|
}, [introStep, movementMode, setIntroStep]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (introStep !== "ebike-breakdown" || hasStartedBreakdown.current) {
|
if (introStep !== "ebike-breakdown" || hasStartedBreakdown.current) {
|
||||||
@@ -100,14 +136,37 @@ export function EbikeIntroSequence(): React.JSX.Element | null {
|
|||||||
}
|
}
|
||||||
}, [introStep]);
|
}, [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;
|
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 (
|
return (
|
||||||
<MissionNotification
|
<MissionNotification
|
||||||
mission="ebike"
|
imagePath={INTRO_MISSION_NOTIFICATION_IMAGE_PATH}
|
||||||
visible={introStep === "await-ebike-mount"}
|
visible={
|
||||||
|
introStep === "reveal" ||
|
||||||
|
introStep === "await-ebike-mount" ||
|
||||||
|
introStep === "ebike-intro-ride"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -20,7 +20,7 @@ export function SiteMobileBlocker(): React.JSX.Element {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/assets/logo/logo.jpg"
|
src="/assets/logo.png"
|
||||||
alt="Logo Altera"
|
alt="Logo Altera"
|
||||||
style={{ width: 120, height: "auto" }}
|
style={{ width: 120, height: "auto" }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -219,7 +219,11 @@ export function RepairCaseModel({
|
|||||||
parsedScale[2] * pop.current.scale,
|
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[] = [];
|
const placeholders: RepairCasePlaceholder[] = [];
|
||||||
placeholderNodes.current.forEach((child) => {
|
placeholderNodes.current.forEach((child) => {
|
||||||
child.getWorldPosition(placeholderPosition.current);
|
child.getWorldPosition(placeholderPosition.current);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Suspense, useEffect, useMemo, useState } from "react";
|
|||||||
import { useGLTF } from "@react-three/drei";
|
import { useGLTF } from "@react-three/drei";
|
||||||
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
|
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
|
||||||
import type { RepairCasePlaceholder } from "@/components/three/gameplay/RepairCaseModel";
|
import type { RepairCasePlaceholder } from "@/components/three/gameplay/RepairCaseModel";
|
||||||
import { RepairCompletionStep } from "@/components/three/gameplay/RepairCompletionStep";
|
|
||||||
import { RepairInspectionObject } from "@/components/three/gameplay/RepairInspectionObject";
|
import { RepairInspectionObject } from "@/components/three/gameplay/RepairInspectionObject";
|
||||||
import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase";
|
import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase";
|
||||||
import { RepairRepairingStep } from "@/components/three/gameplay/RepairRepairingStep";
|
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 { RepairScanSequence } from "@/components/three/gameplay/RepairScanSequence";
|
||||||
import { REPAIR_CASE_MODEL_PATH } from "@/data/gameplay/repairCaseConfig";
|
import { REPAIR_CASE_MODEL_PATH } from "@/data/gameplay/repairCaseConfig";
|
||||||
import { REPAIR_FRAGMENTATION_SEQUENCE_SECONDS } from "@/data/gameplay/repairGameConfig";
|
import { REPAIR_FRAGMENTATION_SEQUENCE_SECONDS } from "@/data/gameplay/repairGameConfig";
|
||||||
|
import { getNextMissionStep } from "@/data/gameplay/repairMissionState";
|
||||||
import { REPAIR_MISSIONS } from "@/data/gameplay/repairMissions";
|
import { REPAIR_MISSIONS } from "@/data/gameplay/repairMissions";
|
||||||
|
import { useRepairTransitionStore } from "@/managers/stores/useRepairTransitionStore";
|
||||||
import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput";
|
import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput";
|
||||||
import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep";
|
import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep";
|
||||||
import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight";
|
import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight";
|
||||||
@@ -30,6 +31,8 @@ interface RepairGameProps extends Required<
|
|||||||
mission: RepairMissionId;
|
mission: RepairMissionId;
|
||||||
rotation?: Vector3Tuple;
|
rotation?: Vector3Tuple;
|
||||||
scale?: ModelTransformProps["scale"];
|
scale?: ModelTransformProps["scale"];
|
||||||
|
/** Set to false in isolated scenes with no terrain (e.g. RepairGameScene). */
|
||||||
|
snapToTerrain?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RepairMissionAssetPreloaderProps {
|
interface RepairMissionAssetPreloaderProps {
|
||||||
@@ -54,6 +57,7 @@ export function RepairGame({
|
|||||||
position,
|
position,
|
||||||
rotation = [0, 0, 0],
|
rotation = [0, 0, 0],
|
||||||
scale = 1,
|
scale = 1,
|
||||||
|
snapToTerrain = true,
|
||||||
}: RepairGameProps): React.JSX.Element | null {
|
}: RepairGameProps): React.JSX.Element | null {
|
||||||
const config = REPAIR_MISSIONS[mission];
|
const config = REPAIR_MISSIONS[mission];
|
||||||
const mainState = useGameStore((state) => state.mainState);
|
const mainState = useGameStore((state) => state.mainState);
|
||||||
@@ -67,7 +71,11 @@ export function RepairGame({
|
|||||||
readonly RepairScannedBrokenPart[]
|
readonly RepairScannedBrokenPart[]
|
||||||
>([]);
|
>([]);
|
||||||
const parsedScale = toVector3Scale(scale);
|
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";
|
const readyForFragmentation = step === "inspected";
|
||||||
|
|
||||||
useRepairFragmentationInput({
|
useRepairFragmentationInput({
|
||||||
@@ -103,6 +111,24 @@ export function RepairGame({
|
|||||||
};
|
};
|
||||||
}, [mainState, mission, setMissionStep, step]);
|
}, [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 (mainState !== mission) return null;
|
||||||
if (step === "locked") return null;
|
if (step === "locked") return null;
|
||||||
|
|
||||||
@@ -149,12 +175,9 @@ export function RepairGame({
|
|||||||
onComplete={() => setMissionStep(mission, "done")}
|
onComplete={() => setMissionStep(mission, "done")}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{step === "done" ? (
|
{/* done step: auto-advance is handled by useEffect above — no manual
|
||||||
<RepairCompletionStep
|
case-closing interaction needed. Scene is intentionally empty
|
||||||
config={config}
|
for the 200ms before completeMission/setMissionStep fires. */}
|
||||||
onComplete={() => completeMission(mission)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{step !== "waiting" && step !== "done" && step !== "reassembling" ? (
|
{step !== "waiting" && step !== "done" && step !== "reassembling" ? (
|
||||||
<RepairMissionCase
|
<RepairMissionCase
|
||||||
config={config}
|
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(() => {
|
useEffect(() => {
|
||||||
if (parts.length === 0) return undefined;
|
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(() => {
|
const timeoutId = window.setTimeout(() => {
|
||||||
setActivePartIndex((currentIndex) => {
|
const nextIndex = activePartIndex + 1;
|
||||||
const nextIndex = currentIndex + 1;
|
if (nextIndex >= parts.length) {
|
||||||
if (nextIndex >= parts.length) {
|
onComplete(getScannedBrokenParts(parts, config));
|
||||||
onComplete(getScannedBrokenParts(parts, config));
|
} else {
|
||||||
return currentIndex;
|
setActivePartIndex(nextIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
return nextIndex;
|
|
||||||
});
|
|
||||||
}, scanPartSeconds * 1000);
|
}, scanPartSeconds * 1000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { HandTrackingVisualizer } from "@/components/ui/HandTrackingVisualizer";
|
|||||||
import { InteractPrompt } from "@/components/ui/InteractPrompt";
|
import { InteractPrompt } from "@/components/ui/InteractPrompt";
|
||||||
import { RepairMovementLockIndicator } from "@/components/ui/RepairMovementLockIndicator";
|
import { RepairMovementLockIndicator } from "@/components/ui/RepairMovementLockIndicator";
|
||||||
import { Subtitles } from "@/components/ui/Subtitles";
|
import { Subtitles } from "@/components/ui/Subtitles";
|
||||||
|
import { TalkieDialogueOverlay } from "@/components/ui/TalkieDialogueOverlay";
|
||||||
|
|
||||||
export function GameUI(): React.JSX.Element {
|
export function GameUI(): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
@@ -15,6 +16,7 @@ export function GameUI(): React.JSX.Element {
|
|||||||
<InteractPrompt />
|
<InteractPrompt />
|
||||||
<HandTrackingVisualizer />
|
<HandTrackingVisualizer />
|
||||||
<Subtitles />
|
<Subtitles />
|
||||||
|
<TalkieDialogueOverlay />
|
||||||
<GameSettingsMenu />
|
<GameSettingsMenu />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,14 +2,19 @@ import { MISSION_NOTIFICATION_IMAGE_PATHS } from "@/data/gameplay/missionNotific
|
|||||||
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||||
|
|
||||||
interface MissionNotificationProps {
|
interface MissionNotificationProps {
|
||||||
mission: RepairMissionId;
|
mission?: RepairMissionId;
|
||||||
|
imagePath?: string;
|
||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MissionNotification({
|
export function MissionNotification({
|
||||||
mission,
|
mission,
|
||||||
|
imagePath,
|
||||||
visible = true,
|
visible = true,
|
||||||
}: MissionNotificationProps): React.JSX.Element {
|
}: MissionNotificationProps): React.JSX.Element {
|
||||||
|
const src =
|
||||||
|
imagePath ?? (mission ? MISSION_NOTIFICATION_IMAGE_PATHS[mission] : "");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`mission-notification${visible ? "" : " mission-notification--hidden"}`}
|
className={`mission-notification${visible ? "" : " mission-notification--hidden"}`}
|
||||||
@@ -19,7 +24,7 @@ export function MissionNotification({
|
|||||||
<span className="mission-notification__image-wrap">
|
<span className="mission-notification__image-wrap">
|
||||||
<img
|
<img
|
||||||
className="mission-notification__image"
|
className="mission-notification__image"
|
||||||
src={MISSION_NOTIFICATION_IMAGE_PATHS[mission]}
|
src={src}
|
||||||
alt="Nouvel objectif de mission"
|
alt="Nouvel objectif de mission"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
import { AppLoadingIndicator } from "@/components/ui/AppLoadingIndicator";
|
import { AppLoadingIndicator } from "@/components/ui/AppLoadingIndicator";
|
||||||
import type { SceneLoadingState } from "@/types/world/sceneLoading";
|
import type { SceneLoadingState } from "@/types/world/sceneLoading";
|
||||||
|
|
||||||
const LOADING_BACKGROUND_PATH = "/assets/bg-site.png";
|
const LOADING_BACKGROUND_PATH = "/assets/bg-site.webp";
|
||||||
const LOADING_LOGO_PATH = "/assets/logo/logo.jpg";
|
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();
|
const image = new Image();
|
||||||
image.src = path;
|
image.src = path;
|
||||||
}
|
}
|
||||||
@@ -16,8 +24,25 @@ interface SceneLoadingOverlayProps {
|
|||||||
export function SceneLoadingOverlay({
|
export function SceneLoadingOverlay({
|
||||||
state,
|
state,
|
||||||
}: SceneLoadingOverlayProps): React.JSX.Element | null {
|
}: SceneLoadingOverlayProps): React.JSX.Element | null {
|
||||||
|
const [logoFrameIndex, setLogoFrameIndex] = useState(0);
|
||||||
const isReady = state.status === "ready";
|
const isReady = state.status === "ready";
|
||||||
const progress = Math.round(Math.max(0, Math.min(1, state.progress)) * 100);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -33,7 +58,7 @@ export function SceneLoadingOverlay({
|
|||||||
<img
|
<img
|
||||||
alt="La Fabrik Durable"
|
alt="La Fabrik Durable"
|
||||||
className="scene-loading-overlay__logo"
|
className="scene-loading-overlay__logo"
|
||||||
src={LOADING_LOGO_PATH}
|
src={logoFramePath}
|
||||||
/>
|
/>
|
||||||
<div className="scene-loading-overlay__footer">
|
<div className="scene-loading-overlay__footer">
|
||||||
<div className="scene-loading-overlay__meta">
|
<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 = {
|
export const EBIKE_CAMERA_TRANSFORM: CameraTransform = {
|
||||||
position: [-3.5, 6, 0],
|
position: [-2.6, 4.5, 0],
|
||||||
rotation: [-10, -90, 0],
|
rotation: [-10, -90, 0],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EBIKE_DROP_PLAYER_TRANSFORM: CameraTransform = {
|
export const EBIKE_DROP_PLAYER_TRANSFORM: CameraTransform = {
|
||||||
position: [0, 1.5, -3],
|
position: [0, 1.3, -2.25],
|
||||||
rotation: [0, 0, 0],
|
rotation: [0, 0, 0],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EBIKE_WORLD_POSITION: Vector3Tuple = [61.5, 10, 62.4];
|
export const EBIKE_WORLD_POSITION: Vector3Tuple = [65, 0.8, 72];
|
||||||
export const EBIKE_WORLD_ROTATION_Y = 2.4107;
|
export const EBIKE_WORLD_ROTATION_Y = -2.5;
|
||||||
|
export const EBIKE_WORLD_SCALE = 0.35;
|
||||||
|
|
||||||
export const EBIKE_INTRO_RIDE_DURATION_MS = 5000;
|
export const EBIKE_INTRO_BREAKDOWN_DISTANCE = 15;
|
||||||
export const EBIKE_BREAKDOWN_DIALOGUE_DELAY_MS = 250;
|
export const EBIKE_BREAKDOWN_DIALOGUE_DELAY_MS = 250;
|
||||||
|
|
||||||
export const EBIKE_MAX_SPEED = 3;
|
export const EBIKE_MAX_SPEED = 3;
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||||
|
|
||||||
|
export const INTRO_MISSION_NOTIFICATION_IMAGE_PATH =
|
||||||
|
"/assets/world/UI/intro-mission-notification.png";
|
||||||
|
|
||||||
export const MISSION_NOTIFICATION_IMAGE_PATHS: Record<RepairMissionId, string> =
|
export const MISSION_NOTIFICATION_IMAGE_PATHS: Record<RepairMissionId, string> =
|
||||||
{
|
{
|
||||||
ebike: "/assets/world/UI/ebike-mission-notification.png",
|
ebike: "/assets/world/UI/ebike-mission-notification.png",
|
||||||
|
|||||||
@@ -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,
|
RepairMissionTriggerConfig,
|
||||||
} from "@/types/gameplay/repairMission";
|
} from "@/types/gameplay/repairMission";
|
||||||
import { EBIKE_WORLD_POSITION } from "@/data/ebike/ebikeConfig";
|
import { EBIKE_WORLD_POSITION } from "@/data/ebike/ebikeConfig";
|
||||||
|
import { PYLON_WORLD_POSITION } from "@/data/gameplay/pylonConfig";
|
||||||
|
|
||||||
export const REPAIR_MISSION_ANCHOR_IDS: Partial<
|
export const REPAIR_MISSION_ANCHOR_IDS: Partial<
|
||||||
Record<RepairMissionId, string>
|
Record<RepairMissionId, string>
|
||||||
@@ -15,7 +16,7 @@ const EBIKE_REPAIR_POSITION = EBIKE_WORLD_POSITION satisfies Vector3Tuple;
|
|||||||
|
|
||||||
const REPAIR_MISSION_POSITIONS = {
|
const REPAIR_MISSION_POSITIONS = {
|
||||||
ebike: EBIKE_REPAIR_POSITION,
|
ebike: EBIKE_REPAIR_POSITION,
|
||||||
pylon: [64, 0, -66],
|
pylon: PYLON_WORLD_POSITION,
|
||||||
farm: [-24, 0, 42],
|
farm: [-24, 0, 42],
|
||||||
} as const satisfies Record<RepairMissionId, Vector3Tuple>;
|
} as const satisfies Record<RepairMissionId, Vector3Tuple>;
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ const REPAIR_MISSION_ID_VALUES: ReadonlySet<string> = new Set(
|
|||||||
|
|
||||||
export const MISSION_STEPS = [
|
export const MISSION_STEPS = [
|
||||||
"locked",
|
"locked",
|
||||||
|
"approaching",
|
||||||
|
"arrived",
|
||||||
|
"npc-return",
|
||||||
"waiting",
|
"waiting",
|
||||||
"inspected",
|
"inspected",
|
||||||
"fragmented",
|
"fragmented",
|
||||||
@@ -17,6 +20,7 @@ export const MISSION_STEPS = [
|
|||||||
"repairing",
|
"repairing",
|
||||||
"reassembling",
|
"reassembling",
|
||||||
"done",
|
"done",
|
||||||
|
"narrator-outro",
|
||||||
] as const satisfies readonly MissionStep[];
|
] as const satisfies readonly MissionStep[];
|
||||||
const MISSION_STEP_VALUES: ReadonlySet<string> = new Set(MISSION_STEPS);
|
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);
|
return MISSION_STEP_VALUES.has(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNextMissionStep(step: MissionStep): MissionStep {
|
export function getNextMissionStep(
|
||||||
|
step: MissionStep,
|
||||||
|
mission?: RepairMissionId,
|
||||||
|
): MissionStep {
|
||||||
switch (step) {
|
switch (step) {
|
||||||
case "locked":
|
case "locked":
|
||||||
|
return mission === "pylon" ? "approaching" : "waiting";
|
||||||
|
case "approaching":
|
||||||
|
return "arrived";
|
||||||
|
case "arrived":
|
||||||
|
return "npc-return";
|
||||||
|
case "npc-return":
|
||||||
return "waiting";
|
return "waiting";
|
||||||
case "waiting":
|
case "waiting":
|
||||||
return "inspected";
|
return "inspected";
|
||||||
@@ -43,16 +56,29 @@ export function getNextMissionStep(step: MissionStep): MissionStep {
|
|||||||
case "repairing":
|
case "repairing":
|
||||||
return "reassembling";
|
return "reassembling";
|
||||||
case "reassembling":
|
case "reassembling":
|
||||||
case "done":
|
|
||||||
return "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) {
|
switch (step) {
|
||||||
case "locked":
|
case "locked":
|
||||||
case "waiting":
|
|
||||||
return "locked";
|
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":
|
case "inspected":
|
||||||
return "waiting";
|
return "waiting";
|
||||||
case "fragmented":
|
case "fragmented":
|
||||||
@@ -65,5 +91,7 @@ export function getPreviousMissionStep(step: MissionStep): MissionStep {
|
|||||||
return "repairing";
|
return "repairing";
|
||||||
case "done":
|
case "done":
|
||||||
return "reassembling";
|
return "reassembling";
|
||||||
|
case "narrator-outro":
|
||||||
|
return "done";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
|||||||
{
|
{
|
||||||
id: "pylon-damaged-panel",
|
id: "pylon-damaged-panel",
|
||||||
label: "Damaged solar panel",
|
label: "Damaged solar panel",
|
||||||
nodeName: "panneau2",
|
nodeName: "pylone",
|
||||||
caseSlotName: "placeholder_2",
|
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,
|
||||||
|
};
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
import { LA_FABRIK_PLAYER_SPAWN } from "@/data/world/laFabrikConfig";
|
||||||
|
|
||||||
export const PLAYER_EYE_HEIGHT = 1.75;
|
export const PLAYER_EYE_HEIGHT = 1.75;
|
||||||
export const PLAYER_CAPSULE_RADIUS = 0.35;
|
export const PLAYER_CAPSULE_RADIUS = 0.35;
|
||||||
|
|
||||||
export const PLAYER_WALK_SPEED = 5;
|
export const PLAYER_WALK_SPEED = 5;
|
||||||
export const PLAYER_EBIKE_SPEED = 20;
|
export const PLAYER_EBIKE_SPEED = 30;
|
||||||
export const PLAYER_AIR_CONTROL_FACTOR = 0.35;
|
export const PLAYER_AIR_CONTROL_FACTOR = 0.35;
|
||||||
export const PLAYER_JUMP_SPEED = 9;
|
export const PLAYER_JUMP_SPEED = 9;
|
||||||
export const PLAYER_GRAVITY = 30;
|
export const PLAYER_GRAVITY = 30;
|
||||||
@@ -14,5 +15,9 @@ export const PLAYER_XZ_DAMPING_FACTOR = 8;
|
|||||||
export const PLAYER_FALL_RESPAWN_Y = -20;
|
export const PLAYER_FALL_RESPAWN_Y = -20;
|
||||||
export const PLAYER_FALL_RESPAWN_DELAY = 3;
|
export const PLAYER_FALL_RESPAWN_DELAY = 3;
|
||||||
|
|
||||||
export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [59.5, 10, 64.64];
|
export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [
|
||||||
|
LA_FABRIK_PLAYER_SPAWN[0] + 1,
|
||||||
|
LA_FABRIK_PLAYER_SPAWN[1],
|
||||||
|
LA_FABRIK_PLAYER_SPAWN[2] - 1,
|
||||||
|
];
|
||||||
export const PLAYER_SPAWN_POSITION_PHYSICS: Vector3Tuple = [0, 3, 0];
|
export const PLAYER_SPAWN_POSITION_PHYSICS: Vector3Tuple = [0, 3, 0];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
const BACKGROUND_IMAGE = "/assets/bg-site.png";
|
const BACKGROUND_IMAGE = "/assets/bg-site.webp";
|
||||||
|
|
||||||
export const SITE_CONFIG = {
|
export const SITE_CONFIG = {
|
||||||
backgroundImage: BACKGROUND_IMAGE,
|
backgroundImage: BACKGROUND_IMAGE,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const CLOUD_DEFAULTS = {
|
|||||||
maxRotation: Math.PI * 2,
|
maxRotation: Math.PI * 2,
|
||||||
minSpeedMultiplier: 0.4,
|
minSpeedMultiplier: 0.4,
|
||||||
maxSpeedMultiplier: 1,
|
maxSpeedMultiplier: 1,
|
||||||
castShadow: false,
|
castShadow: true,
|
||||||
receiveShadow: false,
|
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 const GRAPHICS_PRESET_KEYS = ["low", "medium", "high", "ultra"] as const;
|
||||||
|
|
||||||
export type GraphicsPreset = (typeof GRAPHICS_PRESET_KEYS)[number];
|
export type GraphicsPreset = (typeof GRAPHICS_PRESET_KEYS)[number];
|
||||||
@@ -32,8 +30,8 @@ export const GRAPHICS_PRESETS = {
|
|||||||
},
|
},
|
||||||
high: {
|
high: {
|
||||||
label: "High",
|
label: "High",
|
||||||
chunkLoadRadius: CHUNK_CONFIG.loadRadius,
|
chunkLoadRadius: 35,
|
||||||
chunkUnloadRadius: CHUNK_CONFIG.unloadRadius,
|
chunkUnloadRadius: 45,
|
||||||
fogEnabled: false,
|
fogEnabled: false,
|
||||||
forceLodModels: false,
|
forceLodModels: false,
|
||||||
lodHighDetailDistance: 10,
|
lodHighDetailDistance: 10,
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
|
||||||
|
export const LA_FABRIK_CENTER: Vector3Tuple = [59.4973, 6.2746, 64.6354];
|
||||||
|
export const LA_FABRIK_ROTATION_Y = 2.4107;
|
||||||
|
export const LA_FABRIK_HALF_EXTENTS = {
|
||||||
|
x: 8.5,
|
||||||
|
z: 7.5,
|
||||||
|
} as const;
|
||||||
|
export const LA_FABRIK_PLAYER_SPAWN: Vector3Tuple = [59.5, 6.3, 64.64];
|
||||||
|
export const LA_FABRIK_INITIAL_LOOK_AT: Vector3Tuple = [58, 7.3, 62.5];
|
||||||
|
export const LA_FABRIK_INTERIOR_LIGHT_POSITION: Vector3Tuple = [59.5, 9, 64.64];
|
||||||
|
|
||||||
|
export function isInsideLaFabrikFootprint(
|
||||||
|
x: number,
|
||||||
|
z: number,
|
||||||
|
padding = 0,
|
||||||
|
): boolean {
|
||||||
|
const dx = x - LA_FABRIK_CENTER[0];
|
||||||
|
const dz = z - LA_FABRIK_CENTER[2];
|
||||||
|
const cos = Math.cos(-LA_FABRIK_ROTATION_Y);
|
||||||
|
const sin = Math.sin(-LA_FABRIK_ROTATION_Y);
|
||||||
|
const localX = dx * cos - dz * sin;
|
||||||
|
const localZ = dx * sin + dz * cos;
|
||||||
|
|
||||||
|
return (
|
||||||
|
Math.abs(localX) <= LA_FABRIK_HALF_EXTENTS.x + padding &&
|
||||||
|
Math.abs(localZ) <= LA_FABRIK_HALF_EXTENTS.z + padding
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -30,3 +30,12 @@ export const SUN_Y_STEP = 1;
|
|||||||
export const SUN_Z_MIN = -100;
|
export const SUN_Z_MIN = -100;
|
||||||
export const SUN_Z_MAX = 100;
|
export const SUN_Z_MAX = 100;
|
||||||
export const SUN_Z_STEP = 1;
|
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 { useEffect, useRef } from "react";
|
||||||
import type { RefObject } from "react";
|
import type { RefObject } from "react";
|
||||||
import type { Object3D } from "three";
|
import { type Object3D } from "three";
|
||||||
import { Octree } from "three-stdlib";
|
import { Octree } from "three-stdlib";
|
||||||
import type { OctreeReadyHandler } from "@/types/three/three";
|
import type { OctreeReadyHandler } from "@/types/three/three";
|
||||||
|
|
||||||
@@ -27,6 +27,7 @@ export function useOctreeGraphNode(
|
|||||||
|
|
||||||
const octree = new Octree();
|
const octree = new Octree();
|
||||||
octree.fromGraphNode(graphNode);
|
octree.fromGraphNode(graphNode);
|
||||||
|
|
||||||
onOctreeReady(octree);
|
onOctreeReady(octree);
|
||||||
}, [enabled, graphNodeRef, onOctreeReady, rebuildKey]);
|
}, [enabled, graphNodeRef, onOctreeReady, rebuildKey]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
import { useFrame } from "@react-three/fiber";
|
||||||
|
import {
|
||||||
|
Material,
|
||||||
|
Mesh,
|
||||||
|
type DirectionalLight,
|
||||||
|
type Scene,
|
||||||
|
type WebGLRenderer,
|
||||||
|
} from "three";
|
||||||
|
|
||||||
|
interface UseShadowMapWarmupOptions {
|
||||||
|
/** Light whose shadow map should be reallocated once the scene stabilizes. */
|
||||||
|
light: React.RefObject<DirectionalLight | null>;
|
||||||
|
scene: Scene;
|
||||||
|
gl: WebGLRenderer;
|
||||||
|
invalidate: () => void;
|
||||||
|
/** Frames the mesh count must remain unchanged to consider the scene stable. */
|
||||||
|
stableFramesThreshold?: number;
|
||||||
|
/** Hard cap on how long we keep watching, in frames (~5s @60fps). */
|
||||||
|
safetyCapFrames?: number;
|
||||||
|
/** Sample mesh count every N frames to keep the traversal cost negligible. */
|
||||||
|
sampleEveryFrames?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useShadowMapWarmup({
|
||||||
|
light,
|
||||||
|
scene,
|
||||||
|
gl,
|
||||||
|
invalidate,
|
||||||
|
stableFramesThreshold = 60,
|
||||||
|
safetyCapFrames = 300,
|
||||||
|
sampleEveryFrames = 6,
|
||||||
|
}: UseShadowMapWarmupOptions): void {
|
||||||
|
const meshCountRef = useRef(0);
|
||||||
|
const stableFramesRef = useRef(0);
|
||||||
|
const watchFramesRef = useRef(0);
|
||||||
|
const doneRef = useRef(false);
|
||||||
|
|
||||||
|
useFrame(() => {
|
||||||
|
if (doneRef.current || !light.current) return;
|
||||||
|
|
||||||
|
watchFramesRef.current += 1;
|
||||||
|
|
||||||
|
if (watchFramesRef.current % sampleEveryFrames === 0) {
|
||||||
|
let meshCount = 0;
|
||||||
|
scene.traverse((object) => {
|
||||||
|
if (object instanceof Mesh) meshCount += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (meshCount !== meshCountRef.current) {
|
||||||
|
meshCountRef.current = meshCount;
|
||||||
|
stableFramesRef.current = 0;
|
||||||
|
} else {
|
||||||
|
stableFramesRef.current += sampleEveryFrames;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stableEnough = stableFramesRef.current >= stableFramesThreshold;
|
||||||
|
const safetyCapReached = watchFramesRef.current >= safetyCapFrames;
|
||||||
|
if (!stableEnough && !safetyCapReached) return;
|
||||||
|
|
||||||
|
doneRef.current = true;
|
||||||
|
reallocateShadowMap(light.current);
|
||||||
|
invalidateAllMaterials(scene);
|
||||||
|
forceShadowPass(gl, scene, light.current);
|
||||||
|
invalidate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function reallocateShadowMap(light: DirectionalLight): void {
|
||||||
|
const shadowMap = light.shadow.map;
|
||||||
|
if (!shadowMap) return;
|
||||||
|
|
||||||
|
shadowMap.dispose();
|
||||||
|
light.shadow.map = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateAllMaterials(scene: Scene): void {
|
||||||
|
const seen = new Set<Material>();
|
||||||
|
scene.traverse((object) => {
|
||||||
|
if (!(object instanceof Mesh)) return;
|
||||||
|
const materials = Array.isArray(object.material)
|
||||||
|
? object.material
|
||||||
|
: [object.material];
|
||||||
|
for (const material of materials) {
|
||||||
|
if (!material || seen.has(material)) continue;
|
||||||
|
seen.add(material);
|
||||||
|
material.needsUpdate = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function forceShadowPass(
|
||||||
|
gl: WebGLRenderer,
|
||||||
|
scene: Scene,
|
||||||
|
light: DirectionalLight,
|
||||||
|
): void {
|
||||||
|
scene.updateMatrixWorld(true);
|
||||||
|
light.target.updateMatrixWorld(true);
|
||||||
|
light.updateMatrixWorld(true);
|
||||||
|
light.shadow.camera.updateMatrixWorld(true);
|
||||||
|
light.shadow.camera.updateProjectionMatrix();
|
||||||
|
light.shadow.needsUpdate = true;
|
||||||
|
gl.shadowMap.needsUpdate = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { GAME_STEPS } from "@/data/game/gameStateConfig";
|
||||||
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
|
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
|
||||||
|
|
||||||
|
const TALKIE_FIRST_VISIBLE_STEP = "reveal";
|
||||||
|
const TALKIE_FIRST_VISIBLE_STEP_INDEX = GAME_STEPS.indexOf(
|
||||||
|
TALKIE_FIRST_VISIBLE_STEP,
|
||||||
|
);
|
||||||
|
|
||||||
|
interface TalkieDialogueOverlayState {
|
||||||
|
isNarratorDialogue: boolean;
|
||||||
|
isVisible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTalkieDialogueOverlayState(): TalkieDialogueOverlayState {
|
||||||
|
const activeSubtitle = useSubtitleStore((state) => state.activeSubtitle);
|
||||||
|
const mainState = useGameStore((state) => state.mainState);
|
||||||
|
const introStep = useGameStore((state) => state.intro.currentStep);
|
||||||
|
const introStepIndex = GAME_STEPS.indexOf(introStep);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isNarratorDialogue: activeSubtitle?.speaker === "Narrateur",
|
||||||
|
isVisible:
|
||||||
|
mainState !== "intro" ||
|
||||||
|
introStepIndex >= TALKIE_FIRST_VISIBLE_STEP_INDEX,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,13 +11,10 @@ interface UseWorldSceneLoadingOptions {
|
|||||||
interface UseWorldSceneLoadingResult {
|
interface UseWorldSceneLoadingResult {
|
||||||
octree: Octree | null;
|
octree: Octree | null;
|
||||||
gameplayReady: boolean;
|
gameplayReady: boolean;
|
||||||
shouldWarmUpShadows: boolean;
|
|
||||||
showGameStage: boolean;
|
showGameStage: boolean;
|
||||||
handleGameStageLoaded: () => void;
|
handleGameStageLoaded: () => void;
|
||||||
handleGameMapLoaded: () => void;
|
handleGameMapLoaded: () => void;
|
||||||
handleOctreeReady: (octree: Octree) => void;
|
handleOctreeReady: (octree: Octree) => void;
|
||||||
handleShadowWarmupReady: () => void;
|
|
||||||
handleShadowWarmupStarted: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWorldSceneLoading({
|
export function useWorldSceneLoading({
|
||||||
@@ -27,19 +24,13 @@ export function useWorldSceneLoading({
|
|||||||
const [octree, setOctree] = useState<Octree | null>(null);
|
const [octree, setOctree] = useState<Octree | null>(null);
|
||||||
const [gameMapLoaded, setGameMapLoaded] = useState(false);
|
const [gameMapLoaded, setGameMapLoaded] = useState(false);
|
||||||
const [gameStageLoaded, setGameStageLoaded] = useState(false);
|
const [gameStageLoaded, setGameStageLoaded] = useState(false);
|
||||||
const [shadowsReady, setShadowsReady] = useState(false);
|
|
||||||
const showGameStage = sceneMode === "game" && gameMapLoaded;
|
const showGameStage = sceneMode === "game" && gameMapLoaded;
|
||||||
const gameSceneReadyForShadows =
|
const gameplayReady = showGameStage && gameStageLoaded && octree !== null;
|
||||||
showGameStage && gameStageLoaded && octree !== null;
|
|
||||||
const shadowWarmupReady = sceneMode === "game" && gameSceneReadyForShadows;
|
|
||||||
const shouldWarmUpShadows = shadowWarmupReady && !shadowsReady;
|
|
||||||
const gameplayReady = gameSceneReadyForShadows && shadowsReady;
|
|
||||||
const sceneReady =
|
const sceneReady =
|
||||||
(sceneMode === "game" && gameplayReady) ||
|
(sceneMode === "game" && gameplayReady) ||
|
||||||
(sceneMode === "physics" && octree !== null);
|
(sceneMode === "physics" && octree !== null);
|
||||||
|
|
||||||
const handleGameMapLoaded = useCallback(() => {
|
const handleGameMapLoaded = useCallback(() => {
|
||||||
setShadowsReady(false);
|
|
||||||
setGameMapLoaded(true);
|
setGameMapLoaded(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -54,7 +45,6 @@ export function useWorldSceneLoading({
|
|||||||
|
|
||||||
const handleOctreeReady = useCallback(
|
const handleOctreeReady = useCallback(
|
||||||
(nextOctree: Octree) => {
|
(nextOctree: Octree) => {
|
||||||
setShadowsReady(false);
|
|
||||||
setOctree(nextOctree);
|
setOctree(nextOctree);
|
||||||
onLoadingStateChange?.({
|
onLoadingStateChange?.({
|
||||||
currentStep: "Collision prête",
|
currentStep: "Collision prête",
|
||||||
@@ -65,23 +55,6 @@ export function useWorldSceneLoading({
|
|||||||
[onLoadingStateChange],
|
[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(() => {
|
useEffect(() => {
|
||||||
onLoadingStateChange?.({
|
onLoadingStateChange?.({
|
||||||
currentStep: "Initialisation du jeu",
|
currentStep: "Initialisation du jeu",
|
||||||
@@ -115,12 +88,9 @@ export function useWorldSceneLoading({
|
|||||||
return {
|
return {
|
||||||
octree,
|
octree,
|
||||||
gameplayReady,
|
gameplayReady,
|
||||||
shouldWarmUpShadows,
|
|
||||||
showGameStage,
|
showGameStage,
|
||||||
handleGameStageLoaded,
|
handleGameStageLoaded,
|
||||||
handleGameMapLoaded,
|
handleGameMapLoaded,
|
||||||
handleOctreeReady,
|
handleOctreeReady,
|
||||||
handleShadowWarmupReady,
|
|
||||||
handleShadowWarmupStarted,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+111
-3
@@ -942,11 +942,11 @@ canvas {
|
|||||||
.scene-loading-overlay__logo {
|
.scene-loading-overlay__logo {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
display: block;
|
||||||
width: clamp(180px, 28vw, 320px);
|
width: clamp(180px, 28vw, 320px);
|
||||||
max-height: min(38vh, 320px);
|
max-height: min(38vh, 320px);
|
||||||
border-radius: 16px;
|
height: auto;
|
||||||
object-fit: cover;
|
object-fit: contain;
|
||||||
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.28);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-loading-overlay__footer {
|
.scene-loading-overlay__footer {
|
||||||
@@ -1237,6 +1237,114 @@ canvas {
|
|||||||
color: #f9a8d4;
|
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 */
|
/* In-game settings menu */
|
||||||
.game-settings-menu {
|
.game-settings-menu {
|
||||||
position: fixed;
|
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: {
|
pylon: {
|
||||||
...state.pylon,
|
...state.pylon,
|
||||||
currentStep: "waiting",
|
currentStep: "approaching",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -212,7 +212,7 @@ function advanceRepairMissionState(
|
|||||||
state: GameState,
|
state: GameState,
|
||||||
mission: RepairMissionId,
|
mission: RepairMissionId,
|
||||||
): GameStateUpdate {
|
): GameStateUpdate {
|
||||||
const nextStep = getNextMissionStep(state[mission].currentStep);
|
const nextStep = getNextMissionStep(state[mission].currentStep, mission);
|
||||||
if (nextStep === "done") {
|
if (nextStep === "done") {
|
||||||
return completeMissionState(state, mission);
|
return completeMissionState(state, mission);
|
||||||
}
|
}
|
||||||
@@ -227,7 +227,7 @@ function rewindRepairMissionState(
|
|||||||
return setMissionStepState(
|
return setMissionStepState(
|
||||||
state,
|
state,
|
||||||
mission,
|
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 { create } from "zustand";
|
||||||
|
import { createJSONStorage, persist } from "zustand/middleware";
|
||||||
import { AudioManager } from "@/managers/AudioManager";
|
import { AudioManager } from "@/managers/AudioManager";
|
||||||
import type { AudioCategory } from "@/managers/AudioManager";
|
import type { AudioCategory } from "@/managers/AudioManager";
|
||||||
import type { SubtitleLanguage } from "@/types/settings/settings";
|
import type { SubtitleLanguage } from "@/types/settings/settings";
|
||||||
@@ -33,6 +34,8 @@ const DEFAULT_SETTINGS: SettingsState = {
|
|||||||
subtitleLanguage: "fr",
|
subtitleLanguage: "fr",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SETTINGS_STORAGE_KEY = "la-fabrik-settings";
|
||||||
|
|
||||||
function clampVolume(volume: number): number {
|
function clampVolume(volume: number): number {
|
||||||
return Math.max(0, Math.min(1, volume));
|
return Math.max(0, Math.min(1, volume));
|
||||||
}
|
}
|
||||||
@@ -46,36 +49,50 @@ function setAudioCategoryVolume(
|
|||||||
return nextVolume;
|
return nextVolume;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyDefaultAudioSettings(): void {
|
function applyAudioSettings(
|
||||||
AudioManager.getInstance().setCategoryVolume(
|
settings: Pick<SettingsState, "musicVolume" | "sfxVolume" | "dialogueVolume">,
|
||||||
"music",
|
): void {
|
||||||
DEFAULT_SETTINGS.musicVolume,
|
AudioManager.getInstance().setCategoryVolume("music", settings.musicVolume);
|
||||||
);
|
AudioManager.getInstance().setCategoryVolume("sfx", settings.sfxVolume);
|
||||||
AudioManager.getInstance().setCategoryVolume(
|
|
||||||
"sfx",
|
|
||||||
DEFAULT_SETTINGS.sfxVolume,
|
|
||||||
);
|
|
||||||
AudioManager.getInstance().setCategoryVolume(
|
AudioManager.getInstance().setCategoryVolume(
|
||||||
"dialogue",
|
"dialogue",
|
||||||
DEFAULT_SETTINGS.dialogueVolume,
|
settings.dialogueVolume,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
applyDefaultAudioSettings();
|
applyAudioSettings(DEFAULT_SETTINGS);
|
||||||
|
|
||||||
export const useSettingsStore = create<SettingsStore>()((set) => ({
|
export const useSettingsStore = create<SettingsStore>()(
|
||||||
...DEFAULT_SETTINGS,
|
persist(
|
||||||
setSettingsMenuOpen: (isSettingsMenuOpen) => set({ isSettingsMenuOpen }),
|
(set) => ({
|
||||||
setMusicVolume: (volume) =>
|
...DEFAULT_SETTINGS,
|
||||||
set({ musicVolume: setAudioCategoryVolume("music", volume) }),
|
setSettingsMenuOpen: (isSettingsMenuOpen) => set({ isSettingsMenuOpen }),
|
||||||
setSfxVolume: (volume) =>
|
setMusicVolume: (volume) =>
|
||||||
set({ sfxVolume: setAudioCategoryVolume("sfx", volume) }),
|
set({ musicVolume: setAudioCategoryVolume("music", volume) }),
|
||||||
setDialogueVolume: (volume) =>
|
setSfxVolume: (volume) =>
|
||||||
set({ dialogueVolume: setAudioCategoryVolume("dialogue", volume) }),
|
set({ sfxVolume: setAudioCategoryVolume("sfx", volume) }),
|
||||||
setSubtitlesEnabled: (subtitlesEnabled) => set({ subtitlesEnabled }),
|
setDialogueVolume: (volume) =>
|
||||||
setSubtitleLanguage: (subtitleLanguage) => set({ subtitleLanguage }),
|
set({ dialogueVolume: setAudioCategoryVolume("dialogue", volume) }),
|
||||||
resetSettings: () => {
|
setSubtitlesEnabled: (subtitlesEnabled) => set({ subtitlesEnabled }),
|
||||||
applyDefaultAudioSettings();
|
setSubtitleLanguage: (subtitleLanguage) => set({ subtitleLanguage }),
|
||||||
set(DEFAULT_SETTINGS);
|
resetSettings: () => {
|
||||||
},
|
applyAudioSettings(DEFAULT_SETTINGS);
|
||||||
}));
|
set(DEFAULT_SETTINGS);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: SETTINGS_STORAGE_KEY,
|
||||||
|
storage: createJSONStorage(() => window.localStorage),
|
||||||
|
partialize: (state) => ({
|
||||||
|
dialogueVolume: state.dialogueVolume,
|
||||||
|
musicVolume: state.musicVolume,
|
||||||
|
sfxVolume: state.sfxVolume,
|
||||||
|
subtitleLanguage: state.subtitleLanguage,
|
||||||
|
subtitlesEnabled: state.subtitlesEnabled,
|
||||||
|
}),
|
||||||
|
onRehydrateStorage: () => (state) => {
|
||||||
|
if (state) applyAudioSettings(state);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
import { createJSONStorage, persist } from "zustand/middleware";
|
||||||
import { CLOUD_DEFAULTS, type CloudState } from "@/data/world/cloudConfig";
|
import { CLOUD_DEFAULTS, type CloudState } from "@/data/world/cloudConfig";
|
||||||
import { FOG_CONFIG, type FogState } from "@/data/world/fogConfig";
|
import { FOG_CONFIG, type FogState } from "@/data/world/fogConfig";
|
||||||
import { WIND_DEFAULTS, type WindState } from "@/data/world/windConfig";
|
import { WIND_DEFAULTS, type WindState } from "@/data/world/windConfig";
|
||||||
@@ -46,73 +47,89 @@ const DEFAULT_STATE: WorldSettingsState = {
|
|||||||
graphics: { ...GRAPHICS_DEFAULTS },
|
graphics: { ...GRAPHICS_DEFAULTS },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useWorldSettingsStore = create<WorldSettingsStore>()((set) => ({
|
const WORLD_SETTINGS_STORAGE_KEY = "la-fabrik-world-settings";
|
||||||
...DEFAULT_STATE,
|
|
||||||
|
|
||||||
setClouds: (cloudsUpdate) =>
|
export const useWorldSettingsStore = create<WorldSettingsStore>()(
|
||||||
set((state) => ({
|
persist(
|
||||||
clouds: { ...state.clouds, ...cloudsUpdate },
|
(set) => ({
|
||||||
})),
|
...DEFAULT_STATE,
|
||||||
|
|
||||||
setFog: (fogUpdate) =>
|
setClouds: (cloudsUpdate) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
fog: { ...state.fog, ...fogUpdate },
|
clouds: { ...state.clouds, ...cloudsUpdate },
|
||||||
})),
|
})),
|
||||||
|
|
||||||
setWind: (windUpdate) =>
|
setFog: (fogUpdate) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
wind: { ...state.wind, ...windUpdate },
|
fog: { ...state.fog, ...fogUpdate },
|
||||||
})),
|
})),
|
||||||
|
|
||||||
setWindSpeed: (speed) =>
|
setWind: (windUpdate) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
wind: { ...state.wind, speed },
|
wind: { ...state.wind, ...windUpdate },
|
||||||
})),
|
})),
|
||||||
|
|
||||||
setWindDirection: (direction) =>
|
setWindSpeed: (speed) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
wind: { ...state.wind, direction },
|
wind: { ...state.wind, speed },
|
||||||
})),
|
})),
|
||||||
|
|
||||||
setWindStrength: (strength) =>
|
setWindDirection: (direction) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
wind: { ...state.wind, strength },
|
wind: { ...state.wind, direction },
|
||||||
})),
|
})),
|
||||||
|
|
||||||
setGraphics: (graphicsUpdate) =>
|
setWindStrength: (strength) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
graphics: { ...state.graphics, ...graphicsUpdate },
|
wind: { ...state.wind, strength },
|
||||||
})),
|
})),
|
||||||
|
|
||||||
setGraphicsPreset: (preset) =>
|
setGraphics: (graphicsUpdate) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
graphics: { ...state.graphics, preset },
|
graphics: { ...state.graphics, ...graphicsUpdate },
|
||||||
})),
|
})),
|
||||||
|
|
||||||
setDynamicGrass: (dynamicGrass) =>
|
setGraphicsPreset: (preset) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
graphics: { ...state.graphics, dynamicGrass },
|
graphics: { ...state.graphics, preset },
|
||||||
})),
|
})),
|
||||||
|
|
||||||
setDynamicTrees: (dynamicTrees) =>
|
setDynamicGrass: (dynamicGrass) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
graphics: { ...state.graphics, dynamicTrees },
|
graphics: { ...state.graphics, dynamicGrass },
|
||||||
})),
|
})),
|
||||||
|
|
||||||
setDynamicClouds: (dynamicClouds) =>
|
setDynamicTrees: (dynamicTrees) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
graphics: { ...state.graphics, dynamicClouds },
|
graphics: { ...state.graphics, dynamicTrees },
|
||||||
})),
|
})),
|
||||||
|
|
||||||
setShadowsEnabled: (shadowsEnabled) =>
|
setDynamicClouds: (dynamicClouds) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
graphics: { ...state.graphics, shadowsEnabled },
|
graphics: { ...state.graphics, dynamicClouds },
|
||||||
})),
|
})),
|
||||||
|
|
||||||
setGrassDensity: (grassDensity) =>
|
setShadowsEnabled: (shadowsEnabled) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
graphics: { ...state.graphics, grassDensity },
|
graphics: { ...state.graphics, shadowsEnabled },
|
||||||
})),
|
})),
|
||||||
|
|
||||||
resetToDefaults: () => set(DEFAULT_STATE),
|
setGrassDensity: (grassDensity) =>
|
||||||
}));
|
set((state) => ({
|
||||||
|
graphics: { ...state.graphics, grassDensity },
|
||||||
|
})),
|
||||||
|
|
||||||
|
resetToDefaults: () => set(DEFAULT_STATE),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: WORLD_SETTINGS_STORAGE_KEY,
|
||||||
|
storage: createJSONStorage(() => window.localStorage),
|
||||||
|
partialize: (state) => ({
|
||||||
|
clouds: state.clouds,
|
||||||
|
fog: state.fog,
|
||||||
|
graphics: state.graphics,
|
||||||
|
wind: state.wind,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|||||||
+133
-18
@@ -15,25 +15,28 @@ import {
|
|||||||
} from "@/components/ui/intro";
|
} from "@/components/ui/intro";
|
||||||
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
|
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
|
||||||
import { INITIAL_SCENE_LOADING_STATE } from "@/data/world/sceneLoadingConfig";
|
import { INITIAL_SCENE_LOADING_STATE } from "@/data/world/sceneLoadingConfig";
|
||||||
|
import { useRepairGameStatus } from "@/hooks/gameplay/useRepairGameStatus";
|
||||||
import { useTransientLoadingIndicator } from "@/hooks/ui/useTransientLoadingIndicator";
|
import { useTransientLoadingIndicator } from "@/hooks/ui/useTransientLoadingIndicator";
|
||||||
import { AudioManager } from "@/managers/AudioManager";
|
import { AudioManager } from "@/managers/AudioManager";
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
|
import { useRepairTransitionStore } from "@/managers/stores/useRepairTransitionStore";
|
||||||
import { useWorldSettingsStore } from "@/managers/stores/useWorldSettingsStore";
|
import { useWorldSettingsStore } from "@/managers/stores/useWorldSettingsStore";
|
||||||
import { HandTrackingProvider } from "@/providers/gameplay/HandTrackingProvider";
|
import { HandTrackingProvider } from "@/providers/gameplay/HandTrackingProvider";
|
||||||
|
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||||
import type { SceneLoadingState } from "@/types/world/sceneLoading";
|
import type { SceneLoadingState } from "@/types/world/sceneLoading";
|
||||||
import { hasSiteBeenVisitedToday } from "@/utils/cookies/siteVisitCookie";
|
import { hasSiteBeenVisitedToday } from "@/utils/cookies/siteVisitCookie";
|
||||||
import { logger } from "@/utils/core/Logger";
|
import { logger } from "@/utils/core/Logger";
|
||||||
|
import { RepairGameScene } from "@/world/RepairGameScene";
|
||||||
import { World } from "@/world/World";
|
import { World } from "@/world/World";
|
||||||
|
|
||||||
const LOADING_TO_VIDEO_FADE_MS = 500;
|
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 {
|
export function HomePage(): React.JSX.Element | null {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const mainState = useGameStore((state) => state.mainState);
|
const mainState = useGameStore((state) => state.mainState);
|
||||||
const introStep = useGameStore((state) => state.intro.currentStep);
|
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 setIntroStep = useGameStore((state) => state.setIntroStep);
|
||||||
const graphicsPreset = useWorldSettingsStore(
|
const graphicsPreset = useWorldSettingsStore(
|
||||||
(state) => state.graphics.preset,
|
(state) => state.graphics.preset,
|
||||||
@@ -48,9 +51,92 @@ export function HomePage(): React.JSX.Element | null {
|
|||||||
INITIAL_SCENE_LOADING_STATE,
|
INITIAL_SCENE_LOADING_STATE,
|
||||||
);
|
);
|
||||||
const sceneReadyRef = useRef(false);
|
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);
|
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(() => {
|
useEffect(() => {
|
||||||
sceneReadyRef.current = sceneLoadingState.status === "ready";
|
sceneReadyRef.current = sceneLoadingState.status === "ready";
|
||||||
}, [sceneLoadingState.status]);
|
}, [sceneLoadingState.status]);
|
||||||
@@ -131,6 +217,7 @@ export function HomePage(): React.JSX.Element | null {
|
|||||||
gl.shadowMap.enabled = true;
|
gl.shadowMap.enabled = true;
|
||||||
gl.shadowMap.type = THREE.PCFShadowMap;
|
gl.shadowMap.type = THREE.PCFShadowMap;
|
||||||
gl.shadowMap.autoUpdate = true;
|
gl.shadowMap.autoUpdate = true;
|
||||||
|
gl.shadowMap.needsUpdate = true;
|
||||||
|
|
||||||
// The browser hands us a WEBGL_lose_context extension we can use to
|
// The browser hands us a WEBGL_lose_context extension we can use to
|
||||||
// ask the GPU to restore the context after a loss. Without this the
|
// ask the GPU to restore the context after a loss. Without this the
|
||||||
@@ -148,6 +235,7 @@ export function HomePage(): React.JSX.Element | null {
|
|||||||
gl.shadowMap.enabled = true;
|
gl.shadowMap.enabled = true;
|
||||||
gl.shadowMap.type = THREE.PCFShadowMap;
|
gl.shadowMap.type = THREE.PCFShadowMap;
|
||||||
gl.shadowMap.autoUpdate = true;
|
gl.shadowMap.autoUpdate = true;
|
||||||
|
gl.shadowMap.needsUpdate = true;
|
||||||
logger.info("WebGL", "Context restored");
|
logger.info("WebGL", "Context restored");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -168,7 +256,9 @@ export function HomePage(): React.JSX.Element | null {
|
|||||||
introStep === "fade-to-video" ||
|
introStep === "fade-to-video" ||
|
||||||
(introStep === "loading-map" && sceneLoadingState.status === "ready");
|
(introStep === "loading-map" && sceneLoadingState.status === "ready");
|
||||||
const showSceneLoadingOverlay =
|
const showSceneLoadingOverlay =
|
||||||
introStep === "loading-map" || introStep === "fade-to-video";
|
introStep === "loading-map" ||
|
||||||
|
introStep === "fade-to-video" ||
|
||||||
|
isPostRepairLoading;
|
||||||
|
|
||||||
const renderIntroOverlay = () => {
|
const renderIntroOverlay = () => {
|
||||||
if (showFadeToVideoOverlay) return <FadeToVideoOverlay />;
|
if (showFadeToVideoOverlay) return <FadeToVideoOverlay />;
|
||||||
@@ -187,21 +277,46 @@ export function HomePage(): React.JSX.Element | null {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<HandTrackingProvider>
|
<HandTrackingProvider>
|
||||||
<Canvas
|
{showRepairScene && renderedMission !== null ? (
|
||||||
camera={{ position: [85, 60, 85], fov: 42 }}
|
/* Isolated repair scene — no map, no player, no physics world.
|
||||||
shadows={{ type: THREE.PCFShadowMap }}
|
Unmounting the main Canvas here frees the full GPU budget. */
|
||||||
gl={{
|
<RepairGameScene mission={renderedMission} />
|
||||||
powerPreference: "high-performance",
|
) : (
|
||||||
antialias: true,
|
<Canvas
|
||||||
stencil: false,
|
camera={{ position: [85, 60, 85], fov: 42 }}
|
||||||
|
shadows={{ type: THREE.PCFShadowMap }}
|
||||||
|
gl={{
|
||||||
|
powerPreference: "high-performance",
|
||||||
|
antialias: true,
|
||||||
|
stencil: false,
|
||||||
|
}}
|
||||||
|
onCreated={handleCanvasCreated}
|
||||||
|
>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<World onLoadingStateChange={handleSceneLoadingStateChange} />
|
||||||
|
<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",
|
||||||
}}
|
}}
|
||||||
onCreated={handleCanvasCreated}
|
|
||||||
>
|
>
|
||||||
<Suspense fallback={null}>
|
{isFading ? <AppLoadingIndicator floating /> : null}
|
||||||
<World onLoadingStateChange={handleSceneLoadingStateChange} />
|
</div>
|
||||||
<DebugPerf />
|
|
||||||
</Suspense>
|
|
||||||
</Canvas>
|
|
||||||
<GameUI />
|
<GameUI />
|
||||||
{dialogMessage ? (
|
{dialogMessage ? (
|
||||||
<DialogMessage
|
<DialogMessage
|
||||||
|
|||||||
@@ -4,17 +4,10 @@ import { SiteWelcomeScreen } from "@/components/site/SiteWelcomeScreen";
|
|||||||
import { SiteSituationScreen } from "@/components/site/SiteSituationScreen";
|
import { SiteSituationScreen } from "@/components/site/SiteSituationScreen";
|
||||||
import { SiteNamingScreen } from "@/components/site/SiteNamingScreen";
|
import { SiteNamingScreen } from "@/components/site/SiteNamingScreen";
|
||||||
import { SiteTransitionOverlay } from "@/components/site/SiteTransitionOverlay";
|
import { SiteTransitionOverlay } from "@/components/site/SiteTransitionOverlay";
|
||||||
import { SiteMobileBlocker } from "@/components/site/SiteMobileBlocker";
|
|
||||||
import { SiteLayout } from "@/components/site/SiteLayout";
|
import { SiteLayout } from "@/components/site/SiteLayout";
|
||||||
import { useIsMobile } from "@/hooks/ui/useIsMobile";
|
|
||||||
|
|
||||||
export function SitePage(): React.JSX.Element {
|
export function SitePage(): React.JSX.Element {
|
||||||
const currentStep = useSiteStore((state) => state.currentStep);
|
const currentStep = useSiteStore((state) => state.currentStep);
|
||||||
const isMobile = useIsMobile();
|
|
||||||
|
|
||||||
if (isMobile) {
|
|
||||||
return <SiteMobileBlocker />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentStep === "disclaimer") {
|
if (currentStep === "disclaimer") {
|
||||||
return <SiteDisclaimerScreen />;
|
return <SiteDisclaimerScreen />;
|
||||||
|
|||||||
@@ -54,10 +54,39 @@ export interface RepairMissionConfig {
|
|||||||
|
|
||||||
export type MissionStep =
|
export type MissionStep =
|
||||||
| "locked"
|
| "locked"
|
||||||
|
| "approaching"
|
||||||
|
| "arrived"
|
||||||
|
| "npc-return"
|
||||||
| "waiting"
|
| "waiting"
|
||||||
| "inspected"
|
| "inspected"
|
||||||
| "fragmented"
|
| "fragmented"
|
||||||
| "scanning"
|
| "scanning"
|
||||||
| "repairing"
|
| "repairing"
|
||||||
| "reassembling"
|
| "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 {
|
interface StoredDebugControls {
|
||||||
cameraMode: CameraMode;
|
cameraMode: CameraMode;
|
||||||
|
handTrackingSource: HandTrackingSource;
|
||||||
sceneMode: SceneMode;
|
sceneMode: SceneMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ const DEBUG_FOLDER_ORDER = [
|
|||||||
"Hand Tracking",
|
"Hand Tracking",
|
||||||
"Map",
|
"Map",
|
||||||
"Personnages",
|
"Personnages",
|
||||||
|
"Debug",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
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";
|
return value === "game" || value === "physics";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isHandTrackingSource(value: unknown): value is HandTrackingSource {
|
||||||
|
return value === "browser" || value === "backend";
|
||||||
|
}
|
||||||
|
|
||||||
function getStoredDebugControls(): Partial<StoredDebugControls> {
|
function getStoredDebugControls(): Partial<StoredDebugControls> {
|
||||||
try {
|
try {
|
||||||
const rawValue = window.localStorage.getItem(DEBUG_CONTROLS_STORAGE_KEY);
|
const rawValue = window.localStorage.getItem(DEBUG_CONTROLS_STORAGE_KEY);
|
||||||
@@ -51,6 +57,9 @@ function getStoredDebugControls(): Partial<StoredDebugControls> {
|
|||||||
...(isCameraMode(parsedValue.cameraMode)
|
...(isCameraMode(parsedValue.cameraMode)
|
||||||
? { cameraMode: parsedValue.cameraMode }
|
? { cameraMode: parsedValue.cameraMode }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(isHandTrackingSource(parsedValue.handTrackingSource)
|
||||||
|
? { handTrackingSource: parsedValue.handTrackingSource }
|
||||||
|
: {}),
|
||||||
...(isSceneMode(parsedValue.sceneMode)
|
...(isSceneMode(parsedValue.sceneMode)
|
||||||
? { sceneMode: parsedValue.sceneMode }
|
? { sceneMode: parsedValue.sceneMode }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -94,7 +103,7 @@ export class Debug {
|
|||||||
this.controls = {
|
this.controls = {
|
||||||
cameraMode: storedControls.cameraMode ?? "player",
|
cameraMode: storedControls.cameraMode ?? "player",
|
||||||
fogEnabled: FOG_CONFIG.enabled,
|
fogEnabled: FOG_CONFIG.enabled,
|
||||||
handTrackingSource: "browser",
|
handTrackingSource: storedControls.handTrackingSource ?? "browser",
|
||||||
showDebugOverlay: true,
|
showDebugOverlay: true,
|
||||||
showHandTrackingSvg: false,
|
showHandTrackingSvg: false,
|
||||||
showInteractionSpheres: false,
|
showInteractionSpheres: false,
|
||||||
@@ -159,7 +168,7 @@ export class Debug {
|
|||||||
.name("Source")
|
.name("Source")
|
||||||
.onChange((value: HandTrackingSource) => {
|
.onChange((value: HandTrackingSource) => {
|
||||||
this.controls.handTrackingSource = value;
|
this.controls.handTrackingSource = value;
|
||||||
this.emit();
|
this.saveAndEmit();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -246,7 +255,7 @@ export class Debug {
|
|||||||
|
|
||||||
setHandTrackingSource(value: HandTrackingSource): void {
|
setHandTrackingSource(value: HandTrackingSource): void {
|
||||||
this.controls.handTrackingSource = value;
|
this.controls.handTrackingSource = value;
|
||||||
this.emit();
|
this.saveAndEmit();
|
||||||
}
|
}
|
||||||
|
|
||||||
getFogEnabled(): boolean {
|
getFogEnabled(): boolean {
|
||||||
@@ -285,6 +294,7 @@ export class Debug {
|
|||||||
DEBUG_CONTROLS_STORAGE_KEY,
|
DEBUG_CONTROLS_STORAGE_KEY,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
cameraMode: this.controls.cameraMode,
|
cameraMode: this.controls.cameraMode,
|
||||||
|
handTrackingSource: this.controls.handTrackingSource,
|
||||||
sceneMode: this.controls.sceneMode,
|
sceneMode: this.controls.sceneMode,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,24 +15,11 @@ import { SkyModel } from "@/components/three/world/SkyModel";
|
|||||||
import { CloudSystem } from "@/world/clouds/CloudSystem";
|
import { CloudSystem } from "@/world/clouds/CloudSystem";
|
||||||
import { FogSystem } from "@/world/fog/FogSystem";
|
import { FogSystem } from "@/world/fog/FogSystem";
|
||||||
import { GrassSystem } from "@/world/grass/GrassSystem";
|
import { GrassSystem } from "@/world/grass/GrassSystem";
|
||||||
import { SceneShadowWarmup } from "@/world/SceneShadowWarmup";
|
|
||||||
import { VegetationSystem } from "@/world/vegetation/VegetationSystem";
|
import { VegetationSystem } from "@/world/vegetation/VegetationSystem";
|
||||||
import { WaterSystem } from "@/world/water/WaterSystem";
|
import { WaterSystem } from "@/world/water/WaterSystem";
|
||||||
import { WorldPlane } from "@/world/WorldPlane";
|
import { WorldPlane } from "@/world/WorldPlane";
|
||||||
|
|
||||||
interface ShadowWarmupConfig {
|
export function Environment(): React.JSX.Element {
|
||||||
active: boolean;
|
|
||||||
onReady: () => void;
|
|
||||||
onStarted: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EnvironmentProps {
|
|
||||||
shadowWarmup?: ShadowWarmupConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Environment({
|
|
||||||
shadowWarmup,
|
|
||||||
}: EnvironmentProps): React.JSX.Element {
|
|
||||||
const sceneMode = useSceneMode();
|
const sceneMode = useSceneMode();
|
||||||
const groups = useMapPerformanceStore((state) => state.groups);
|
const groups = useMapPerformanceStore((state) => state.groups);
|
||||||
const models = useMapPerformanceStore((state) => state.models);
|
const models = useMapPerformanceStore((state) => state.models);
|
||||||
@@ -47,13 +34,6 @@ export function Environment({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FogSystem />
|
<FogSystem />
|
||||||
{shadowWarmup ? (
|
|
||||||
<SceneShadowWarmup
|
|
||||||
active={shadowWarmup.active}
|
|
||||||
onReady={shadowWarmup.onReady}
|
|
||||||
onStarted={shadowWarmup.onStarted}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{showSky ? (
|
{showSky ? (
|
||||||
<SkyModel
|
<SkyModel
|
||||||
fallbackColor={GAME_SCENE_FALLBACK_BACKGROUND_COLOR}
|
fallbackColor={GAME_SCENE_FALLBACK_BACKGROUND_COLOR}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { useMapLodModelPath } from "@/hooks/world/useMapLodModelPath";
|
|||||||
import { GameMapCollision } from "@/world/GameMapCollision";
|
import { GameMapCollision } from "@/world/GameMapCollision";
|
||||||
import { GeneratedMapNodeInstance } from "@/world/map-generated/GeneratedMapNodeInstance";
|
import { GeneratedMapNodeInstance } from "@/world/map-generated/GeneratedMapNodeInstance";
|
||||||
import { isGeneratedMapModelName } from "@/data/world/generatedMapModelConfig";
|
import { isGeneratedMapModelName } from "@/data/world/generatedMapModelConfig";
|
||||||
|
import { hasMapOctreeCollisionBox } from "@/data/world/octreeCollisionConfig";
|
||||||
import { getMapSingleModelScaleMultiplier } from "@/data/world/mapInstancingConfig";
|
import { getMapSingleModelScaleMultiplier } from "@/data/world/mapInstancingConfig";
|
||||||
import { MapInstancingSystem } from "@/world/map-instancing/MapInstancingSystem";
|
import { MapInstancingSystem } from "@/world/map-instancing/MapInstancingSystem";
|
||||||
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
|
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
|
||||||
@@ -115,6 +116,9 @@ export function GameMap({
|
|||||||
const [collisionMapNodes, setCollisionMapNodes] = useState<LoadedMapNode[]>(
|
const [collisionMapNodes, setCollisionMapNodes] = useState<LoadedMapNode[]>(
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
const [proxyCollisionMapNodes, setProxyCollisionMapNodes] = useState<
|
||||||
|
MapNode[]
|
||||||
|
>([]);
|
||||||
const [terrainNode, setTerrainNode] = useState<MapNode | null>(null);
|
const [terrainNode, setTerrainNode] = useState<MapNode | null>(null);
|
||||||
const [mapLoaded, setMapLoaded] = useState(false);
|
const [mapLoaded, setMapLoaded] = useState(false);
|
||||||
const [settledMapNodeCount, setSettledMapNodeCount] = useState(0);
|
const [settledMapNodeCount, setSettledMapNodeCount] = useState(0);
|
||||||
@@ -134,6 +138,7 @@ export function GameMap({
|
|||||||
(currentStep: string) => {
|
(currentStep: string) => {
|
||||||
setRenderMapNodes([]);
|
setRenderMapNodes([]);
|
||||||
setCollisionMapNodes([]);
|
setCollisionMapNodes([]);
|
||||||
|
setProxyCollisionMapNodes([]);
|
||||||
setTerrainNode(null);
|
setTerrainNode(null);
|
||||||
setMapLoaded(true);
|
setMapLoaded(true);
|
||||||
settledMapNodesRef.current.clear();
|
settledMapNodesRef.current.clear();
|
||||||
@@ -191,6 +196,10 @@ export function GameMap({
|
|||||||
const modelUrl = sceneData.models.get(node.name);
|
const modelUrl = sceneData.models.get(node.name);
|
||||||
return { node, modelUrl: modelUrl ?? null };
|
return { node, modelUrl: modelUrl ?? null };
|
||||||
});
|
});
|
||||||
|
const loadedProxyCollisionNodes = sceneData.mapNodes.filter(
|
||||||
|
(node) =>
|
||||||
|
node.type === "Object3D" && hasMapOctreeCollisionBox(node.name),
|
||||||
|
);
|
||||||
const loadedTerrainNode = getTerrainMapNode(sceneData.mapNodes);
|
const loadedTerrainNode = getTerrainMapNode(sceneData.mapNodes);
|
||||||
const repairMissionAnchors = getRepairMissionMapAnchors(
|
const repairMissionAnchors = getRepairMissionMapAnchors(
|
||||||
sceneData.mapNodes,
|
sceneData.mapNodes,
|
||||||
@@ -211,6 +220,7 @@ export function GameMap({
|
|||||||
|
|
||||||
setRenderMapNodes(loadedMapNodes);
|
setRenderMapNodes(loadedMapNodes);
|
||||||
setCollisionMapNodes(loadedCollisionNodes);
|
setCollisionMapNodes(loadedCollisionNodes);
|
||||||
|
setProxyCollisionMapNodes(loadedProxyCollisionNodes);
|
||||||
setTerrainNode(loadedTerrainNode);
|
setTerrainNode(loadedTerrainNode);
|
||||||
setRepairMissionAnchors(repairMissionAnchors);
|
setRepairMissionAnchors(repairMissionAnchors);
|
||||||
setMapLoaded(true);
|
setMapLoaded(true);
|
||||||
@@ -285,6 +295,7 @@ export function GameMap({
|
|||||||
buildOctree={buildOctree}
|
buildOctree={buildOctree}
|
||||||
mapReady={mapReady}
|
mapReady={mapReady}
|
||||||
nodes={collisionMapNodes}
|
nodes={collisionMapNodes}
|
||||||
|
proxyNodes={proxyCollisionMapNodes}
|
||||||
onLoaded={onLoaded}
|
onLoaded={onLoaded}
|
||||||
onLoadingStateChange={onLoadingStateChange}
|
onLoadingStateChange={onLoadingStateChange}
|
||||||
onOctreeReady={onOctreeReady}
|
onOctreeReady={onOctreeReady}
|
||||||
|
|||||||
+196
-10
@@ -17,9 +17,24 @@ import {
|
|||||||
normalizeMapScale,
|
normalizeMapScale,
|
||||||
useTerrainHeightSampler,
|
useTerrainHeightSampler,
|
||||||
} from "@/hooks/three/useTerrainHeight";
|
} 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 { WorldBoundsCollision } from "@/world/collision/WorldBoundsCollision";
|
||||||
import type { MapNode } from "@/types/map/mapScene";
|
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 type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
|
||||||
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
|
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
|
||||||
|
|
||||||
@@ -39,6 +54,7 @@ interface GameMapCollisionProps {
|
|||||||
buildOctree?: boolean;
|
buildOctree?: boolean;
|
||||||
mapReady: boolean;
|
mapReady: boolean;
|
||||||
nodes: readonly GameMapCollisionNode[];
|
nodes: readonly GameMapCollisionNode[];
|
||||||
|
proxyNodes: readonly MapNode[];
|
||||||
onLoaded?: (() => void) | undefined;
|
onLoaded?: (() => void) | undefined;
|
||||||
onLoadingStateChange?: SceneLoadingChangeHandler | undefined;
|
onLoadingStateChange?: SceneLoadingChangeHandler | undefined;
|
||||||
onOctreeReady: OctreeReadyHandler;
|
onOctreeReady: OctreeReadyHandler;
|
||||||
@@ -101,6 +117,7 @@ export function GameMapCollision({
|
|||||||
buildOctree = true,
|
buildOctree = true,
|
||||||
mapReady,
|
mapReady,
|
||||||
nodes,
|
nodes,
|
||||||
|
proxyNodes,
|
||||||
onLoaded,
|
onLoaded,
|
||||||
onLoadingStateChange,
|
onLoadingStateChange,
|
||||||
onOctreeReady,
|
onOctreeReady,
|
||||||
@@ -109,10 +126,28 @@ export function GameMapCollision({
|
|||||||
const settledCollisionNodesRef = useRef(new Set<number>());
|
const settledCollisionNodesRef = useRef(new Set<number>());
|
||||||
const loadedNotifiedRef = useRef(false);
|
const loadedNotifiedRef = useRef(false);
|
||||||
const [settledCollisionNodeCount, setSettledCollisionNodeCount] = useState(0);
|
const [settledCollisionNodeCount, setSettledCollisionNodeCount] = useState(0);
|
||||||
|
const mainState = useGameStore((state) => state.mainState);
|
||||||
const terrainHeight = useTerrainHeightSampler();
|
const terrainHeight = useTerrainHeightSampler();
|
||||||
const collisionNodes = nodes.filter(isCollisionNode);
|
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 =
|
const collisionReady =
|
||||||
mapReady && settledCollisionNodeCount >= collisionNodes.length;
|
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(() => {
|
const notifyLoaded = useCallback(() => {
|
||||||
if (loadedNotifiedRef.current) return;
|
if (loadedNotifiedRef.current) return;
|
||||||
@@ -144,14 +179,14 @@ export function GameMapCollision({
|
|||||||
useOctreeGraphNode(
|
useOctreeGraphNode(
|
||||||
groupRef,
|
groupRef,
|
||||||
handleOctreeReady,
|
handleOctreeReady,
|
||||||
collisionReady ? collisionNodes.length : 0,
|
collisionRebuildKey,
|
||||||
buildOctree && collisionReady && collisionNodes.length > 0,
|
buildOctree && collisionReady && collisionSourceCount > 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mapReady) return;
|
if (!mapReady) return;
|
||||||
|
|
||||||
if (collisionNodes.length === 0) {
|
if (collisionSourceCount === 0) {
|
||||||
notifyLoaded();
|
notifyLoaded();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -171,6 +206,7 @@ export function GameMapCollision({
|
|||||||
}, [
|
}, [
|
||||||
buildOctree,
|
buildOctree,
|
||||||
collisionNodes.length,
|
collisionNodes.length,
|
||||||
|
collisionSourceCount,
|
||||||
collisionReady,
|
collisionReady,
|
||||||
mapReady,
|
mapReady,
|
||||||
notifyLoaded,
|
notifyLoaded,
|
||||||
@@ -180,6 +216,18 @@ export function GameMapCollision({
|
|||||||
return (
|
return (
|
||||||
<group ref={groupRef} visible={false}>
|
<group ref={groupRef} visible={false}>
|
||||||
{mapReady ? <WorldBoundsCollision /> : null}
|
{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
|
{mapReady
|
||||||
? collisionNodes.map((mapNode, index) => (
|
? collisionNodes.map((mapNode, index) => (
|
||||||
<CollisionErrorBoundary
|
<CollisionErrorBoundary
|
||||||
@@ -223,6 +271,24 @@ function CollisionModelInstance({
|
|||||||
scale: normalizedScale,
|
scale: normalizedScale,
|
||||||
});
|
});
|
||||||
const sceneInstance = useClonedObject(scene);
|
const sceneInstance = useClonedObject(scene);
|
||||||
|
useEffect(() => {
|
||||||
|
if (node.name !== "lafabrik") return;
|
||||||
|
|
||||||
|
const isDoorSlab = (name: string): boolean =>
|
||||||
|
name === "porte" || /^porte[._]\d+$/i.test(name);
|
||||||
|
const isDoorFrameThickenChild = (child: THREE.Object3D): boolean =>
|
||||||
|
child.parent?.name === "Thicken";
|
||||||
|
|
||||||
|
const doorMeshes: THREE.Object3D[] = [];
|
||||||
|
sceneInstance.traverse((child) => {
|
||||||
|
if (isDoorSlab(child.name) || isDoorFrameThickenChild(child)) {
|
||||||
|
doorMeshes.push(child);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for (const child of doorMeshes) {
|
||||||
|
child.removeFromParent();
|
||||||
|
}
|
||||||
|
}, [node.name, sceneInstance]);
|
||||||
const collisionPosition = useMemo(() => {
|
const collisionPosition = useMemo(() => {
|
||||||
if (node.name === "terrain") return position;
|
if (node.name === "terrain") return position;
|
||||||
|
|
||||||
@@ -237,11 +303,131 @@ function CollisionModelInstance({
|
|||||||
}, [onLoaded]);
|
}, [onLoaded]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<primitive
|
<>
|
||||||
object={sceneInstance}
|
<primitive
|
||||||
position={collisionPosition}
|
object={sceneInstance}
|
||||||
rotation={rotation}
|
position={collisionPosition}
|
||||||
scale={normalizedScale}
|
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 { Ebike } from "@/components/ebike/Ebike";
|
||||||
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||||
import { RepairGame } from "@/components/three/gameplay/RepairGame";
|
import { RepairGamePreloader } from "@/components/three/gameplay/RepairGamePreloader";
|
||||||
import {
|
import { PylonDownedPylon } from "@/components/gameplay/pylon/PylonDownedPylon";
|
||||||
REPAIR_MISSION_POSITION_ENTRIES,
|
import { PylonNarrativeFlow } from "@/components/gameplay/pylon/PylonNarrativeFlow";
|
||||||
REPAIR_MISSION_TRIGGERS,
|
import { ZoneDebugVisual } from "@/components/zone/ZoneDetection";
|
||||||
} from "@/data/gameplay/repairMissionAnchors";
|
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 {
|
import {
|
||||||
INTRO_STAGE_ANCHOR,
|
INTRO_STAGE_ANCHOR,
|
||||||
OUTRO_STAGE_ANCHOR,
|
OUTRO_STAGE_ANCHOR,
|
||||||
@@ -14,7 +16,13 @@ import { useRepairMissionAnchorStore } from "@/managers/stores/useRepairMissionA
|
|||||||
import type { RepairMissionTriggerConfig } from "@/types/gameplay/repairMission";
|
import type { RepairMissionTriggerConfig } from "@/types/gameplay/repairMission";
|
||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
import { getRepairMissionPosition } from "@/utils/gameplay/repairMissionPosition";
|
import { getRepairMissionPosition } from "@/utils/gameplay/repairMissionPosition";
|
||||||
import { EBIKE_WORLD_POSITION } from "@/data/ebike/ebikeConfig";
|
import {
|
||||||
|
EBIKE_WORLD_POSITION,
|
||||||
|
EBIKE_WORLD_ROTATION_Y,
|
||||||
|
EBIKE_WORLD_SCALE,
|
||||||
|
} from "@/data/ebike/ebikeConfig";
|
||||||
|
|
||||||
|
const EBIKE_CONFIG_KEY = `${EBIKE_WORLD_POSITION.join(",")}:${EBIKE_WORLD_ROTATION_Y}:${EBIKE_WORLD_SCALE}`;
|
||||||
|
|
||||||
interface StageAnchorProps {
|
interface StageAnchorProps {
|
||||||
color: string;
|
color: string;
|
||||||
@@ -77,19 +85,35 @@ function RepairMissionTrigger({
|
|||||||
|
|
||||||
export function GameStageContent(): React.JSX.Element {
|
export function GameStageContent(): React.JSX.Element {
|
||||||
const mainState = useGameStore((state) => state.mainState);
|
const mainState = useGameStore((state) => state.mainState);
|
||||||
const anchors = useRepairMissionAnchorStore((state) => state.anchors);
|
|
||||||
|
|
||||||
return (
|
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}
|
{mainState === "intro" ? <StageAnchor {...INTRO_STAGE_ANCHOR} /> : null}
|
||||||
<Ebike position={EBIKE_WORLD_POSITION} />
|
<Ebike key={EBIKE_CONFIG_KEY} position={EBIKE_WORLD_POSITION} />
|
||||||
{REPAIR_MISSION_POSITION_ENTRIES.map(({ mission }) => {
|
<PylonDownedPylon />
|
||||||
const position = getRepairMissionPosition(mission, anchors);
|
{isDebugEnabled() ? (
|
||||||
if (!position) return null;
|
<>
|
||||||
return (
|
<ZoneDebugVisual zone={PYLON_APPROACH_ZONE} active={false} />
|
||||||
<RepairGame key={mission} mission={mission} position={position} />
|
<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) => (
|
{REPAIR_MISSION_TRIGGERS.map((config) => (
|
||||||
<RepairMissionTrigger key={config.mission} config={config} />
|
<RepairMissionTrigger key={config.mission} config={config} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
+72
-31
@@ -1,10 +1,17 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useFrame, useThree } from "@react-three/fiber";
|
import { useFrame, useThree } from "@react-three/fiber";
|
||||||
import type { AmbientLight, DirectionalLight, Object3D } from "three";
|
import {
|
||||||
|
PCFShadowMap,
|
||||||
|
type AmbientLight,
|
||||||
|
type DirectionalLight,
|
||||||
|
type Object3D,
|
||||||
|
type WebGLRenderer,
|
||||||
|
} from "three";
|
||||||
import {
|
import {
|
||||||
AMBIENT_INTENSITY_MAX,
|
AMBIENT_INTENSITY_MAX,
|
||||||
AMBIENT_INTENSITY_MIN,
|
AMBIENT_INTENSITY_MIN,
|
||||||
AMBIENT_INTENSITY_STEP,
|
AMBIENT_INTENSITY_STEP,
|
||||||
|
SHADOW_CONFIG,
|
||||||
SUN_INTENSITY_MAX,
|
SUN_INTENSITY_MAX,
|
||||||
SUN_INTENSITY_MIN,
|
SUN_INTENSITY_MIN,
|
||||||
SUN_INTENSITY_STEP,
|
SUN_INTENSITY_STEP,
|
||||||
@@ -18,16 +25,51 @@ import {
|
|||||||
SUN_Z_MIN,
|
SUN_Z_MIN,
|
||||||
SUN_Z_STEP,
|
SUN_Z_STEP,
|
||||||
} from "@/data/world/lightingConfig";
|
} from "@/data/world/lightingConfig";
|
||||||
|
import { LA_FABRIK_INTERIOR_LIGHT_POSITION } from "@/data/world/laFabrikConfig";
|
||||||
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||||
|
import { useShadowMapWarmup } from "@/hooks/three/useShadowMapWarmup";
|
||||||
import { LIGHTING_STATE } from "@/world/lightingState";
|
import { LIGHTING_STATE } from "@/world/lightingState";
|
||||||
|
|
||||||
const SHADOW_MAP_SIZE = 2048;
|
function configureRendererShadows(gl: WebGLRenderer): void {
|
||||||
const SHADOW_CAMERA_SIZE = 95;
|
gl.shadowMap.enabled = true;
|
||||||
const SHADOW_CAMERA_NEAR = 0.5;
|
gl.shadowMap.type = PCFShadowMap;
|
||||||
const SHADOW_CAMERA_FAR = 300;
|
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 {
|
export function Lighting(): React.JSX.Element {
|
||||||
const camera = useThree((state) => state.camera);
|
const camera = useThree((state) => state.camera);
|
||||||
|
const gl = useThree((state) => state.gl);
|
||||||
|
const scene = useThree((state) => state.scene);
|
||||||
|
const invalidate = useThree((state) => state.invalidate);
|
||||||
const ambient = useRef<AmbientLight>(null);
|
const ambient = useRef<AmbientLight>(null);
|
||||||
const sun = useRef<DirectionalLight>(null);
|
const sun = useRef<DirectionalLight>(null);
|
||||||
const sunTarget = useRef<Object3D>(null);
|
const sunTarget = useRef<Object3D>(null);
|
||||||
@@ -35,19 +77,16 @@ export function Lighting(): React.JSX.Element {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sun.current || !sunTarget.current) return;
|
if (!sun.current || !sunTarget.current) return;
|
||||||
|
|
||||||
sun.current.target = sunTarget.current;
|
configureRendererShadows(gl);
|
||||||
sun.current.shadow.autoUpdate = true;
|
configureSunShadow(sun.current, sunTarget.current);
|
||||||
sun.current.shadow.needsUpdate = true;
|
// Prime the sun + target onto the camera before the first shadow pass so
|
||||||
sun.current.shadow.mapSize.width = SHADOW_MAP_SIZE;
|
// the initial shadow frustum already covers the visible scene; without
|
||||||
sun.current.shadow.mapSize.height = SHADOW_MAP_SIZE;
|
// this, the first frame is rendered with the default (origin-centered)
|
||||||
sun.current.shadow.camera.left = -SHADOW_CAMERA_SIZE;
|
// frustum and shadows can appear absent until the player moves.
|
||||||
sun.current.shadow.camera.right = SHADOW_CAMERA_SIZE;
|
placeSunRelativeToCamera(sun.current, sunTarget.current, camera.position);
|
||||||
sun.current.shadow.camera.top = SHADOW_CAMERA_SIZE;
|
}, [camera, gl]);
|
||||||
sun.current.shadow.camera.bottom = -SHADOW_CAMERA_SIZE;
|
|
||||||
sun.current.shadow.camera.near = SHADOW_CAMERA_NEAR;
|
useShadowMapWarmup({ light: sun, scene, gl, invalidate });
|
||||||
sun.current.shadow.camera.far = SHADOW_CAMERA_FAR;
|
|
||||||
sun.current.shadow.camera.updateProjectionMatrix();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useDebugFolder("Lighting", (folder) => {
|
useDebugFolder("Lighting", (folder) => {
|
||||||
folder.addColor(LIGHTING_STATE, "ambientColor").name("Ambient Color");
|
folder.addColor(LIGHTING_STATE, "ambientColor").name("Ambient Color");
|
||||||
@@ -87,19 +126,14 @@ export function Lighting(): React.JSX.Element {
|
|||||||
ambient.current.intensity = LIGHTING_STATE.ambientIntensity;
|
ambient.current.intensity = LIGHTING_STATE.ambientIntensity;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sun.current && sunTarget.current) {
|
if (!sun.current || !sunTarget.current) return;
|
||||||
sunTarget.current.position.set(camera.position.x, 0, camera.position.z);
|
|
||||||
sunTarget.current.updateMatrixWorld();
|
placeSunRelativeToCamera(sun.current, sunTarget.current, camera.position);
|
||||||
sun.current.position.set(
|
sunTarget.current.updateMatrixWorld();
|
||||||
camera.position.x + LIGHTING_STATE.sunX,
|
sun.current.color.set(LIGHTING_STATE.sunColor);
|
||||||
LIGHTING_STATE.sunY,
|
sun.current.intensity = LIGHTING_STATE.sunIntensity;
|
||||||
camera.position.z + LIGHTING_STATE.sunZ,
|
sun.current.updateMatrixWorld();
|
||||||
);
|
sun.current.shadow.needsUpdate = true;
|
||||||
sun.current.color.set(LIGHTING_STATE.sunColor);
|
|
||||||
sun.current.intensity = LIGHTING_STATE.sunIntensity;
|
|
||||||
sun.current.updateMatrixWorld();
|
|
||||||
sun.current.shadow.needsUpdate = true;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -121,6 +155,13 @@ export function Lighting(): React.JSX.Element {
|
|||||||
castShadow
|
castShadow
|
||||||
/>
|
/>
|
||||||
<object3D ref={sunTarget} />
|
<object3D ref={sunTarget} />
|
||||||
|
<pointLight
|
||||||
|
position={LA_FABRIK_INTERIOR_LIGHT_POSITION}
|
||||||
|
color="#dbeafe"
|
||||||
|
intensity={1.2}
|
||||||
|
distance={14}
|
||||||
|
decay={1.6}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
+56
-19
@@ -1,19 +1,26 @@
|
|||||||
import { Suspense, useEffect } from "react";
|
import { Suspense, useEffect, useRef } from "react";
|
||||||
import { Physics } from "@react-three/rapier";
|
import { Physics } from "@react-three/rapier";
|
||||||
import {
|
import {
|
||||||
PLAYER_SPAWN_POSITION_GAME,
|
PLAYER_SPAWN_POSITION_GAME,
|
||||||
PLAYER_SPAWN_POSITION_PHYSICS,
|
PLAYER_SPAWN_POSITION_PHYSICS,
|
||||||
} from "@/data/player/playerConfig";
|
} from "@/data/player/playerConfig";
|
||||||
|
import { useRepairTransitionStore } from "@/managers/stores/useRepairTransitionStore";
|
||||||
|
import { LA_FABRIK_INITIAL_LOOK_AT } from "@/data/world/laFabrikConfig";
|
||||||
import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
||||||
import { useEnvironmentDebug } from "@/hooks/debug/useEnvironmentDebug";
|
import { useEnvironmentDebug } from "@/hooks/debug/useEnvironmentDebug";
|
||||||
import { useMapPerformanceDebug } from "@/hooks/debug/useMapPerformanceDebug";
|
import { useMapPerformanceDebug } from "@/hooks/debug/useMapPerformanceDebug";
|
||||||
import { useCharacterDebug } from "@/hooks/debug/useCharacterDebug";
|
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 { useSceneMode } from "@/hooks/debug/useSceneMode";
|
||||||
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
|
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
|
||||||
import { useWorldSceneLoading } from "@/hooks/world/useWorldSceneLoading";
|
import { useWorldSceneLoading } from "@/hooks/world/useWorldSceneLoading";
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
|
import { useDebugVisualsStore } from "@/managers/stores/useDebugVisualsStore";
|
||||||
import { DebugCameraControls } from "@/components/debug/scene/DebugCameraControls";
|
import { DebugCameraControls } from "@/components/debug/scene/DebugCameraControls";
|
||||||
import { DebugHelpers } from "@/components/debug/scene/DebugHelpers";
|
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 { HandTrackingGlove } from "@/components/three/handTracking/HandTrackingGlove";
|
||||||
import { Environment } from "@/world/Environment";
|
import { Environment } from "@/world/Environment";
|
||||||
import { GameCinematics } from "@/world/GameCinematics";
|
import { GameCinematics } from "@/world/GameCinematics";
|
||||||
@@ -35,10 +42,16 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
|||||||
useEnvironmentDebug();
|
useEnvironmentDebug();
|
||||||
useMapPerformanceDebug();
|
useMapPerformanceDebug();
|
||||||
useCharacterDebug();
|
useCharacterDebug();
|
||||||
|
usePlayerPositionDebug();
|
||||||
|
useDebugVisualsDebug();
|
||||||
|
|
||||||
const cameraMode = useCameraMode();
|
const cameraMode = useCameraMode();
|
||||||
const sceneMode = useSceneMode();
|
const sceneMode = useSceneMode();
|
||||||
const mainState = useGameStore((state) => state.mainState);
|
const mainState = useGameStore((state) => state.mainState);
|
||||||
|
const showDebugPlayerModel = useDebugVisualsStore(
|
||||||
|
(state) => state.showPlayerModel,
|
||||||
|
);
|
||||||
|
const showDebugOctree = useDebugVisualsStore((state) => state.showOctree);
|
||||||
const { status, usageStatus } = useHandTrackingSnapshot();
|
const { status, usageStatus } = useHandTrackingSnapshot();
|
||||||
const {
|
const {
|
||||||
octree,
|
octree,
|
||||||
@@ -47,14 +60,32 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
|||||||
handleGameStageLoaded,
|
handleGameStageLoaded,
|
||||||
handleGameMapLoaded,
|
handleGameMapLoaded,
|
||||||
handleOctreeReady,
|
handleOctreeReady,
|
||||||
handleShadowWarmupReady,
|
|
||||||
handleShadowWarmupStarted,
|
|
||||||
shouldWarmUpShadows,
|
|
||||||
} = useWorldSceneLoading({ sceneMode, onLoadingStateChange });
|
} = useWorldSceneLoading({ sceneMode, onLoadingStateChange });
|
||||||
const playerSpawnPosition =
|
// Capture the spawn position once on mount via a ref so it never changes
|
||||||
sceneMode === "game"
|
// mid-session (spawnPosition is reactive in Player and would re-spawn the
|
||||||
? PLAYER_SPAWN_POSITION_GAME
|
// character on every prop change). If the player returns from a repair
|
||||||
: PLAYER_SPAWN_POSITION_PHYSICS;
|
// 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),
|
||||||
|
);
|
||||||
|
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 =
|
const showHandTrackingGloves =
|
||||||
sceneMode === "physics" ||
|
sceneMode === "physics" ||
|
||||||
(status !== "idle" && usageStatus !== "inactive");
|
(status !== "idle" && usageStatus !== "inactive");
|
||||||
@@ -64,15 +95,15 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Environment
|
<Environment />
|
||||||
shadowWarmup={{
|
|
||||||
active: shouldWarmUpShadows,
|
|
||||||
onReady: handleShadowWarmupReady,
|
|
||||||
onStarted: handleShadowWarmupStarted,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Lighting />
|
<Lighting />
|
||||||
<DebugHelpers />
|
<DebugHelpers />
|
||||||
|
{showDebugOctree ? <DebugOctreeVisualization octree={octree} /> : null}
|
||||||
|
{showDebugPlayerModel ? (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<DebugPlayerModel />
|
||||||
|
</Suspense>
|
||||||
|
) : null}
|
||||||
{showHandTrackingGloves ? (
|
{showHandTrackingGloves ? (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<HandTrackingGlove handedness="left" />
|
<HandTrackingGlove handedness="left" />
|
||||||
@@ -91,16 +122,22 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
|||||||
{showGameStage ? (
|
{showGameStage ? (
|
||||||
<Physics>
|
<Physics>
|
||||||
<GameStageLoaded onLoaded={handleGameStageLoaded} />
|
<GameStageLoaded onLoaded={handleGameStageLoaded} />
|
||||||
<GameStageContent />
|
<Suspense fallback={null}>
|
||||||
|
<GameStageContent />
|
||||||
|
</Suspense>
|
||||||
</Physics>
|
</Physics>
|
||||||
) : null}
|
) : null}
|
||||||
{spawnPlayer ? (
|
{spawnPlayer ? (
|
||||||
<>
|
<Suspense fallback={null}>
|
||||||
<GameMusic />
|
<GameMusic />
|
||||||
{mainState === "outro" ? <GameCinematics /> : null}
|
{mainState === "outro" ? <GameCinematics /> : null}
|
||||||
{mainState !== "intro" ? <GameDialogues /> : null}
|
{mainState !== "intro" ? <GameDialogues /> : null}
|
||||||
<Player octree={octree} spawnPosition={playerSpawnPosition} />
|
<Player
|
||||||
</>
|
initialLookAt={LA_FABRIK_INITIAL_LOOK_AT}
|
||||||
|
octree={octree}
|
||||||
|
spawnPosition={playerSpawnPosition}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import {
|
|||||||
GRASS_COLORS,
|
GRASS_COLORS,
|
||||||
GRASS_CONFIG,
|
GRASS_CONFIG,
|
||||||
} from "@/data/world/grassConfig";
|
} from "@/data/world/grassConfig";
|
||||||
|
import {
|
||||||
|
LA_FABRIK_CENTER,
|
||||||
|
LA_FABRIK_HALF_EXTENTS,
|
||||||
|
LA_FABRIK_ROTATION_Y,
|
||||||
|
} from "@/data/world/laFabrikConfig";
|
||||||
import {
|
import {
|
||||||
grassFragmentShader,
|
grassFragmentShader,
|
||||||
grassVertexShader,
|
grassVertexShader,
|
||||||
@@ -169,6 +174,17 @@ function createGrassMaterial(
|
|||||||
uMaxBladeHeight: { value: GRASS_CONFIG.maxBladeHeight },
|
uMaxBladeHeight: { value: GRASS_CONFIG.maxBladeHeight },
|
||||||
uRandomHeightAmount: { value: GRASS_CONFIG.randomHeightAmount },
|
uRandomHeightAmount: { value: GRASS_CONFIG.randomHeightAmount },
|
||||||
uSurfaceOffset: { value: GRASS_CONFIG.surfaceOffset },
|
uSurfaceOffset: { value: GRASS_CONFIG.surfaceOffset },
|
||||||
|
uLaFabrikCenter: {
|
||||||
|
value: new THREE.Vector2(LA_FABRIK_CENTER[0], LA_FABRIK_CENTER[2]),
|
||||||
|
},
|
||||||
|
uLaFabrikHalfExtents: {
|
||||||
|
value: new THREE.Vector2(
|
||||||
|
LA_FABRIK_HALF_EXTENTS.x,
|
||||||
|
LA_FABRIK_HALF_EXTENTS.z,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
uLaFabrikRotation: { value: LA_FABRIK_ROTATION_Y },
|
||||||
|
uLaFabrikNoGrassFeather: { value: 1.4 },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user