Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a73f9fb951 | |||
| 193fc8b4b6 | |||
| 2c194cdd2e | |||
| feaf502e44 | |||
| 489499f5d2 | |||
| 39b996eb31 | |||
| 134c0aecb7 | |||
| b144dc1c18 | |||
| 69c720b86b | |||
| 1b57a25e5f | |||
| f6db7d74e2 | |||
| a1798aecb3 | |||
| 3b07f40f2d | |||
| 27416143e3 | |||
| a2a491bd5c | |||
| da7d66e1fd | |||
| 5faf4b4197 | |||
| bee0c7f223 | |||
| 216d29ae59 | |||
| e13cf1e4c7 | |||
| d20bdc4934 | |||
| 7c35090dbd | |||
| a766784ce8 | |||
| 63952912b5 | |||
| fd0b9e2749 | |||
| 777e51efeb | |||
| 1ad0c4de37 | |||
| 7a378afad3 | |||
| d52ec7e5a9 | |||
| 153833deec | |||
| b617885aa2 | |||
| 5d2e7e2aab | |||
| de77f76d48 | |||
| bdc704fe8e | |||
| bce7d11b66 | |||
| 8aa755da7a | |||
| 6d58b90856 | |||
| bafca5a936 | |||
| dcf3a8564c | |||
| bc862960a7 | |||
| 597ebcfbd4 | |||
| aa2d411b0c | |||
| 061e0dc677 | |||
| 9ef94af488 | |||
| 27b4a2c392 | |||
| d5feb07ff0 | |||
| c33d973f12 | |||
| 396e7e4ff0 |
@@ -25,7 +25,7 @@ Current behavior:
|
||||
| -------- | ------------------: | --- | ------------------------------------- |
|
||||
| `low` | 10m | On | Always use `*-LOD` models |
|
||||
| `medium` | 20m | On | Always use `*-LOD` models |
|
||||
| `high` | Current default 50m | Off | Regular model up to 10m, then `*-LOD` |
|
||||
| `high` | 35m | Off | Regular model up to 10m, then `*-LOD` |
|
||||
| `ultra` | 50m | Off | Regular model up to 20m, then `*-LOD` |
|
||||
|
||||
The unload distance stays slightly larger than the load distance to avoid rapid mount/unmount flickering when the player stands near a boundary.
|
||||
|
||||
@@ -158,9 +158,11 @@ Current runtime values:
|
||||
|
||||
```txt
|
||||
chunkSize: 35
|
||||
loadRadius: 45
|
||||
unloadRadius: 45
|
||||
updateInterval: 350ms
|
||||
low load/unload radius: 10m / 18m
|
||||
medium load/unload radius: 20m / 30m
|
||||
high load/unload radius: 35m / 45m
|
||||
ultra load/unload radius: 50m / 65m
|
||||
updateInterval: 250ms
|
||||
fog near: 30
|
||||
fog far: 45
|
||||
```
|
||||
|
||||
@@ -74,22 +74,32 @@ It tracks:
|
||||
- `gameMapLoaded`: map data and visible map nodes settled
|
||||
- `gameStageLoaded`: Rapier gameplay stage mounted
|
||||
- `showGameStage`: true when the map is ready enough to mount gameplay content
|
||||
- `shadowsReady`: renderer, shadow lights, and scene matrices have been forced once after the scene is mounted
|
||||
- `gameplayReady`: true when map, stage, octree, and the shadow warmup are all ready
|
||||
- `gameplayReady`: true when map, stage, and octree are all ready
|
||||
|
||||
The base game-scene readiness condition before the shadow warmup is:
|
||||
The game-scene readiness condition is:
|
||||
|
||||
```ts
|
||||
showGameStage && gameStageLoaded && octree !== null;
|
||||
```
|
||||
|
||||
After that condition is met, `SceneShadowWarmup` runs one final loading step:
|
||||
Shadows are configured once when `Lighting` mounts (renderer `shadowMap.enabled`, sun
|
||||
`shadow.autoUpdate = true`, bias and frustum from `SHADOW_CONFIG` in
|
||||
`src/data/world/lightingConfig.ts`). The shadow map then refreshes every frame and
|
||||
follows the player camera through the sun's `target`. The earlier `SceneShadowWarmup`
|
||||
step has been removed — the visible loading overlay no longer waits for a forced
|
||||
shadow refresh because `autoUpdate` covers steady-state rendering.
|
||||
|
||||
```txt
|
||||
Activation des ombres -> Ombres prêtes -> Gameplay prêt
|
||||
```
|
||||
### Avoiding global scene remounts
|
||||
|
||||
This keeps the loading overlay visible until the renderer shadow map, shadow-casting light, and mounted scene graph have all been explicitly refreshed.
|
||||
Heavy stage components (`GameStageContent`, `Player`, dialogues) load assets via
|
||||
`useGLTF`/`useTexture` without preload (e.g. `EbikeSpeedometer` calls `useTexture`
|
||||
when the bike mounts). To prevent any late suspension from bubbling up to the
|
||||
root `<Suspense>` boundary in `src/pages/page.tsx` and unmounting the entire
|
||||
world (which would trigger a redundant octree rebuild and shadow re-config), the
|
||||
game stage block and the spawn-player block are wrapped in their own
|
||||
`<Suspense fallback={null}>` boundaries inside `src/world/World.tsx`. Any new
|
||||
sibling that suspends late should be added inside one of these boundaries or get
|
||||
its own.
|
||||
|
||||
The debug physics scene is ready when:
|
||||
|
||||
|
||||
@@ -20,3 +20,63 @@ If DevTools still opens a bundled file, stop the dev server, clear Vite's cached
|
||||
rm -rf node_modules/.vite
|
||||
npm run dev:three-debug
|
||||
```
|
||||
|
||||
## Visual debug toggles
|
||||
|
||||
The `Debug` folder of the runtime debug GUI exposes inspection toggles backed by
|
||||
`src/managers/stores/useDebugVisualsStore.ts`:
|
||||
|
||||
- **Show Player Model** — renders the main character GLTF in front of the
|
||||
current camera (`src/components/debug/DebugPlayerModel.tsx`). The model is
|
||||
positioned in camera-local space so it stays visible regardless of pitch.
|
||||
- **Show Octree** — overlays the collision octree as colored line segments,
|
||||
one wireframe per spatial cell (`src/components/debug/DebugOctreeVisualization.tsx`).
|
||||
Cells are colored by depth. Use it to inspect collision precision around
|
||||
doorways or passages.
|
||||
- **Octree Max Depth** — caps how deep the octree visualization recurses
|
||||
(default 6). Increase to see leaf-level subdivisions; decrease to keep the
|
||||
scene readable when the tree is large.
|
||||
|
||||
The octree visualization reads the live `Octree` instance from `World`. The
|
||||
mesh uses `depthTest: false` and a high `renderOrder`, so cells stay visible
|
||||
through opaque geometry.
|
||||
|
||||
## Shadow rendering intermittence
|
||||
|
||||
Shadows occasionally failed to render on initial load and could disappear
|
||||
mid-session even though the `Lighting` configuration ran to completion. The
|
||||
fix has two layers:
|
||||
|
||||
### Per-frame refresh (steady state)
|
||||
|
||||
The sun follows the camera, so its world matrix is dirty every frame. With
|
||||
`shadow.autoUpdate` alone, three.js can skip the shadow map re-render on a
|
||||
frame where the matrix update has happened but the renderer's internal dirty
|
||||
tracking does not pick it up. To prevent that, `Lighting.useFrame` sets
|
||||
`sun.shadow.needsUpdate = true` after the per-frame matrix updates. Shadow
|
||||
config is centralized in `src/data/world/lightingConfig.ts` (`bias=0`,
|
||||
`normalBias=0`, `cameraSize=95`).
|
||||
|
||||
### Mount-time shadow map reallocation (`useShadowMapWarmup`)
|
||||
|
||||
The merged static map and other GLTFs mount imperatively after `Lighting`,
|
||||
so the shadow render target ends up linked to a renderer state that pre-dates
|
||||
the final scene. Materials compiled at that point bake a "no shadow map"
|
||||
permutation into their shader program and silently fail to render shadows
|
||||
until a WebGL context-restore cycle (the kind triggered by Chrome DevTools
|
||||
in `?debug` runs) reallocates everything.
|
||||
|
||||
`src/hooks/three/useShadowMapWarmup.ts` replays that cycle programmatically
|
||||
without the cost of a full context loss. It runs a `useFrame` watchdog that
|
||||
samples the scene mesh count every 6 frames; once the count has been stable
|
||||
for ~1 s (or after a 5 s safety cap), it:
|
||||
|
||||
1. Disposes the directional light shadow map and nulls it. three.js
|
||||
reallocates the render target on the next render at the configured
|
||||
`mapSize`.
|
||||
2. Marks every material's `needsUpdate = true`, forcing a shader recompile
|
||||
that rebinds every program to the freshly created shadow sampler.
|
||||
3. Forces a single shadow pass and invalidates the renderer.
|
||||
|
||||
The watchdog runs once per mount and adds a single traversal every 6 frames
|
||||
during the warmup window, after which it self-terminates.
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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.
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.
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.
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.
Binary file not shown.
Binary file not shown.
+744
-189
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,7 +1,15 @@
|
||||
import { RouterProvider } from "@tanstack/react-router";
|
||||
import { SiteMobileBlocker } from "@/components/site/SiteMobileBlocker";
|
||||
import { useIsMobile } from "@/hooks/ui/useIsMobile";
|
||||
import { router } from "@/router";
|
||||
|
||||
function App(): React.JSX.Element {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
if (isMobile) {
|
||||
return <SiteMobileBlocker />;
|
||||
}
|
||||
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useMemo } from "react";
|
||||
import { Box3, BufferAttribute, BufferGeometry } from "three";
|
||||
import type { Octree } from "three-stdlib";
|
||||
import {
|
||||
LA_FABRIK_CENTER,
|
||||
isInsideLaFabrikFootprint,
|
||||
} from "@/data/world/laFabrikConfig";
|
||||
import { useDebugVisualsStore } from "@/managers/stores/useDebugVisualsStore";
|
||||
|
||||
interface DebugOctreeVisualizationProps {
|
||||
octree: Octree | null;
|
||||
}
|
||||
|
||||
interface OctreeNodeBox {
|
||||
box: Box3;
|
||||
depth: number;
|
||||
triangleCount: number;
|
||||
isLeaf: boolean;
|
||||
}
|
||||
|
||||
interface CollectOptions {
|
||||
minDepth: number;
|
||||
maxDepth: number;
|
||||
leavesOnly: boolean;
|
||||
fabrikOnly: boolean;
|
||||
}
|
||||
|
||||
const FABRIK_FILTER_PADDING = 1.5;
|
||||
const FABRIK_FILTER_VERTICAL = 8;
|
||||
|
||||
const BOX_VERTEX_INDEX_PAIRS: ReadonlyArray<readonly [number, number]> = [
|
||||
[0, 1],
|
||||
[1, 3],
|
||||
[3, 2],
|
||||
[2, 0],
|
||||
[4, 5],
|
||||
[5, 7],
|
||||
[7, 6],
|
||||
[6, 4],
|
||||
[0, 4],
|
||||
[1, 5],
|
||||
[2, 6],
|
||||
[3, 7],
|
||||
];
|
||||
|
||||
function boxIntersectsFabrik(box: Box3): boolean {
|
||||
if (box.max.y < LA_FABRIK_CENTER[1] - FABRIK_FILTER_VERTICAL) return false;
|
||||
if (box.min.y > LA_FABRIK_CENTER[1] + FABRIK_FILTER_VERTICAL) return false;
|
||||
|
||||
// Sample box corners + center on XZ plane against the rotated fabrik footprint.
|
||||
const samples: ReadonlyArray<readonly [number, number]> = [
|
||||
[box.min.x, box.min.z],
|
||||
[box.min.x, box.max.z],
|
||||
[box.max.x, box.min.z],
|
||||
[box.max.x, box.max.z],
|
||||
[(box.min.x + box.max.x) * 0.5, (box.min.z + box.max.z) * 0.5],
|
||||
];
|
||||
for (const [x, z] of samples) {
|
||||
if (isInsideLaFabrikFootprint(x, z, FABRIK_FILTER_PADDING)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function collectOctreeBoxes(
|
||||
node: Octree,
|
||||
options: CollectOptions,
|
||||
depth = 0,
|
||||
acc: OctreeNodeBox[] = [],
|
||||
): OctreeNodeBox[] {
|
||||
if (depth > options.maxDepth) return acc;
|
||||
|
||||
const isLeaf = node.subTrees.length === 0;
|
||||
const passesDepth = depth >= options.minDepth;
|
||||
const passesLeafFilter = !options.leavesOnly || isLeaf;
|
||||
const hasTriangles = node.triangles.length > 0;
|
||||
const passesFabrikFilter =
|
||||
!options.fabrikOnly || boxIntersectsFabrik(node.box);
|
||||
|
||||
if (passesDepth && passesLeafFilter && hasTriangles && passesFabrikFilter) {
|
||||
acc.push({
|
||||
box: node.box,
|
||||
depth,
|
||||
triangleCount: node.triangles.length,
|
||||
isLeaf,
|
||||
});
|
||||
}
|
||||
|
||||
for (const sub of node.subTrees) {
|
||||
collectOctreeBoxes(sub, options, depth + 1, acc);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}
|
||||
|
||||
function buildOctreeLineGeometry(
|
||||
nodes: readonly OctreeNodeBox[],
|
||||
): BufferGeometry {
|
||||
const positionsBuffer = new Float32Array(
|
||||
nodes.length * BOX_VERTEX_INDEX_PAIRS.length * 2 * 3,
|
||||
);
|
||||
|
||||
const corners: [number, number, number][] = Array.from({ length: 8 }, () => [
|
||||
0, 0, 0,
|
||||
]);
|
||||
|
||||
let positionsOffset = 0;
|
||||
|
||||
for (const node of nodes) {
|
||||
const { min, max } = node.box;
|
||||
|
||||
corners[0] = [min.x, min.y, min.z];
|
||||
corners[1] = [max.x, min.y, min.z];
|
||||
corners[2] = [min.x, max.y, min.z];
|
||||
corners[3] = [max.x, max.y, min.z];
|
||||
corners[4] = [min.x, min.y, max.z];
|
||||
corners[5] = [max.x, min.y, max.z];
|
||||
corners[6] = [min.x, max.y, max.z];
|
||||
corners[7] = [max.x, max.y, max.z];
|
||||
|
||||
for (const [a, b] of BOX_VERTEX_INDEX_PAIRS) {
|
||||
const ca = corners[a]!;
|
||||
const cb = corners[b]!;
|
||||
positionsBuffer[positionsOffset++] = ca[0];
|
||||
positionsBuffer[positionsOffset++] = ca[1];
|
||||
positionsBuffer[positionsOffset++] = ca[2];
|
||||
positionsBuffer[positionsOffset++] = cb[0];
|
||||
positionsBuffer[positionsOffset++] = cb[1];
|
||||
positionsBuffer[positionsOffset++] = cb[2];
|
||||
}
|
||||
}
|
||||
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new BufferAttribute(positionsBuffer, 3));
|
||||
return geometry;
|
||||
}
|
||||
|
||||
export function DebugOctreeVisualization({
|
||||
octree,
|
||||
}: DebugOctreeVisualizationProps): React.JSX.Element | null {
|
||||
const showOctree = useDebugVisualsStore((state) => state.showOctree);
|
||||
const minDepth = useDebugVisualsStore((state) => state.octreeMinDepth);
|
||||
const maxDepth = useDebugVisualsStore((state) => state.octreeMaxDepth);
|
||||
const leavesOnly = useDebugVisualsStore((state) => state.octreeLeavesOnly);
|
||||
const opacity = useDebugVisualsStore((state) => state.octreeOpacity);
|
||||
const fabrikOnly = useDebugVisualsStore((state) => state.octreeFabrikOnly);
|
||||
|
||||
const geometry = useMemo(() => {
|
||||
if (!octree || !showOctree) return null;
|
||||
const boxes = collectOctreeBoxes(octree, {
|
||||
minDepth,
|
||||
maxDepth,
|
||||
leavesOnly,
|
||||
fabrikOnly,
|
||||
});
|
||||
if (boxes.length === 0) return null;
|
||||
return buildOctreeLineGeometry(boxes);
|
||||
}, [fabrikOnly, leavesOnly, maxDepth, minDepth, octree, showOctree]);
|
||||
|
||||
if (!geometry) return null;
|
||||
|
||||
return (
|
||||
<lineSegments frustumCulled={false} renderOrder={999}>
|
||||
<primitive object={geometry} attach="geometry" />
|
||||
<lineBasicMaterial
|
||||
color="#22d3ee"
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
transparent
|
||||
opacity={opacity}
|
||||
/>
|
||||
</lineSegments>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
|
||||
const MODEL_PATH = "/models/persoprincipal/model.gltf";
|
||||
// Offset expressed in the camera's local space:
|
||||
// - x: horizontal (0 = centered)
|
||||
// - y: vertical relative to camera eye (negative = below)
|
||||
// - z: forward (negative = in front of the camera)
|
||||
const LOCAL_OFFSET = new THREE.Vector3(0, -1, -2.5);
|
||||
|
||||
const eulerHelper = new THREE.Euler();
|
||||
|
||||
export function DebugPlayerModel(): React.JSX.Element {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const { scene } = useGLTF(MODEL_PATH);
|
||||
|
||||
const model = useMemo(() => {
|
||||
const cloned = scene.clone(true);
|
||||
cloned.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.castShadow = true;
|
||||
child.receiveShadow = true;
|
||||
child.frustumCulled = false;
|
||||
}
|
||||
});
|
||||
return cloned;
|
||||
}, [scene]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
model.clear();
|
||||
},
|
||||
[model],
|
||||
);
|
||||
|
||||
useFrame(({ camera }) => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
// Place the model in front of the camera using its local space so it stays
|
||||
// visible regardless of the camera pitch (top-down ebike view, etc.).
|
||||
group.position.copy(LOCAL_OFFSET).applyMatrix4(camera.matrixWorld);
|
||||
|
||||
// Keep the model upright and aligned with the camera yaw only.
|
||||
eulerHelper.setFromQuaternion(camera.quaternion, "YXZ");
|
||||
group.rotation.set(0, eulerHelper.y, 0);
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={groupRef} frustumCulled={false}>
|
||||
<primitive object={model} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
useGLTF.preload(MODEL_PATH);
|
||||
+275
-70
@@ -2,17 +2,22 @@ import { useEffect, useRef, useState, useMemo, useCallback } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { EbikeGPSMap } from "@/components/ebike/EbikeGPSMap";
|
||||
import { EbikeSpeedmeter } from "@/components/ebike/EbikeSpeedmeter";
|
||||
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||
import { useEbikeSounds } from "@/hooks/ebike/useEbikeSounds";
|
||||
import {
|
||||
getObjectBottomOffset,
|
||||
useTerrainHeightSampler,
|
||||
} from "@/hooks/three/useTerrainHeight";
|
||||
import { animateCameraTransformTransition } from "@/world/GameCinematics";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { PLAYER_EYE_HEIGHT } from "@/data/player/playerConfig";
|
||||
import {
|
||||
EBIKE_CAMERA_TRANSFORM,
|
||||
EBIKE_DROP_PLAYER_TRANSFORM,
|
||||
EBIKE_WORLD_SCALE,
|
||||
EBIKE_WORLD_ROTATION_Y,
|
||||
} from "@/data/ebike/ebikeConfig";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
@@ -20,6 +25,12 @@ import "@/types/ebike/ebikeWindow";
|
||||
|
||||
const EBIKE_MODEL_PATH = "/models/ebike/model.gltf";
|
||||
|
||||
// Reusable vectors — allocated once to avoid per-frame GC pressure
|
||||
const _phareWorldPos = new THREE.Vector3();
|
||||
const _bikeForward = new THREE.Vector3();
|
||||
const _aimDir = new THREE.Vector3();
|
||||
const _up = new THREE.Vector3(0, 1, 0);
|
||||
|
||||
interface EbikeProps {
|
||||
position: Vector3Tuple;
|
||||
}
|
||||
@@ -31,12 +42,30 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
position: position,
|
||||
});
|
||||
const model = useClonedObject(scene);
|
||||
const terrainHeight = useTerrainHeightSampler();
|
||||
const parkedPosition = useMemo<Vector3Tuple>(() => {
|
||||
const [x, y, z] = position;
|
||||
const height = terrainHeight.getHeight(x, z) ?? y;
|
||||
const bottomOffset = getObjectBottomOffset(model, [
|
||||
EBIKE_WORLD_SCALE,
|
||||
EBIKE_WORLD_SCALE,
|
||||
EBIKE_WORLD_SCALE,
|
||||
]);
|
||||
|
||||
return [x, height + bottomOffset, z];
|
||||
}, [model, position, terrainHeight]);
|
||||
const movementMode = useGameStore((state) => state.player.movementMode);
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const ebikeStep = useGameStore((state) => state.ebike.currentStep);
|
||||
const setMissionStep = useGameStore((state) => state.setMissionStep);
|
||||
const camera = useThree((state) => state.camera);
|
||||
const threeScene = useThree((state) => state.scene);
|
||||
const updateEbikeSounds = useEbikeSounds();
|
||||
const repairGameOwnsEbikeModel =
|
||||
mainState === "ebike" &&
|
||||
ebikeStep !== "locked" &&
|
||||
ebikeStep !== "waiting" &&
|
||||
ebikeStep !== "inspected";
|
||||
|
||||
// Map active mainState to target repair zone coordinate
|
||||
const destPos = useMemo(() => {
|
||||
@@ -58,39 +87,125 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
y: number;
|
||||
z: number;
|
||||
}>({
|
||||
x: position[0],
|
||||
y: position[1],
|
||||
z: position[2],
|
||||
x: parkedPosition[0],
|
||||
y: parkedPosition[1],
|
||||
z: parkedPosition[2],
|
||||
});
|
||||
const lastGpsUpdatePos = useRef<THREE.Vector3>(
|
||||
new THREE.Vector3(...position),
|
||||
new THREE.Vector3(...parkedPosition),
|
||||
);
|
||||
|
||||
// Use ref for internal state, and state for debug visualization (to avoid ref access during render)
|
||||
const restingPositionRef = useRef<Vector3Tuple>([
|
||||
position[0],
|
||||
position[1] - PLAYER_EYE_HEIGHT,
|
||||
position[2],
|
||||
parkedPosition[0],
|
||||
parkedPosition[1],
|
||||
parkedPosition[2],
|
||||
]);
|
||||
const restingRotationRef = useRef<number>(EBIKE_WORLD_ROTATION_Y);
|
||||
const forkRef = useRef<THREE.Object3D | null>(null);
|
||||
const phareRef = useRef<THREE.Object3D | null>(null);
|
||||
const headlightRef = useRef<THREE.SpotLight | null>(null);
|
||||
// SpotLight target — must live in the scene to define the cone direction.
|
||||
const headlightTarget = useMemo(() => new THREE.Object3D(), []);
|
||||
// Original quaternion of the Fourche node — rotation is applied on top of this.
|
||||
const forkInitialQuatRef = useRef(new THREE.Quaternion());
|
||||
// Smoothed steer angle for the fork (avoids direct Euler manipulation).
|
||||
const forkAngleRef = useRef(0);
|
||||
// Ref copy of movementMode — useFrame closures can capture stale React state.
|
||||
const movementModeRef = useRef(movementMode);
|
||||
// Becomes true the first time the player mounts. After that, dismounting
|
||||
// must NOT reset position back to the original spawn point.
|
||||
const hasRiddenRef = useRef(false);
|
||||
|
||||
// State for debug visualization (synced from refs during useFrame)
|
||||
const [showCameraPoints, setShowCameraPoints] = useState(true);
|
||||
const [debugRestingPosition, setDebugRestingPosition] =
|
||||
useState<Vector3Tuple>([
|
||||
position[0],
|
||||
position[1] - PLAYER_EYE_HEIGHT,
|
||||
position[2],
|
||||
parkedPosition[0],
|
||||
parkedPosition[1],
|
||||
parkedPosition[2],
|
||||
]);
|
||||
|
||||
// Keep movementModeRef in sync — useFrame closures capture React state at
|
||||
// render time and can become stale between renders.
|
||||
useEffect(() => {
|
||||
if (model) {
|
||||
const fork = model.getObjectByName("fourche");
|
||||
if (fork) {
|
||||
forkRef.current = fork;
|
||||
}
|
||||
movementModeRef.current = movementMode;
|
||||
}, [movementMode]);
|
||||
|
||||
// SpotLight target must be in the scene to define the cone direction.
|
||||
useEffect(() => {
|
||||
threeScene.add(headlightTarget);
|
||||
return () => { threeScene.remove(headlightTarget); };
|
||||
}, [threeScene, headlightTarget]);
|
||||
|
||||
// Link the target to the SpotLight once it mounts.
|
||||
useEffect(() => {
|
||||
if (headlightRef.current) {
|
||||
headlightRef.current.target = headlightTarget;
|
||||
}
|
||||
}, [headlightTarget]);
|
||||
|
||||
useEffect(() => {
|
||||
if (movementMode === "ebike") {
|
||||
// Player just mounted — mark as ridden so we never reset position again.
|
||||
hasRiddenRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasRiddenRef.current) {
|
||||
// Player dismounted: keep the position the bike was left at.
|
||||
// Just make sure the window vars are up to date for the next mount.
|
||||
window.ebikeParkedPosition = restingPositionRef.current;
|
||||
window.ebikeParkedRotation = restingRotationRef.current;
|
||||
return;
|
||||
}
|
||||
|
||||
// Bike has never been ridden yet — safe to (re)place it at the spawn point.
|
||||
// This also fires when parkedPosition recalculates (e.g. terrain loads late).
|
||||
restingPositionRef.current = parkedPosition;
|
||||
restingRotationRef.current = EBIKE_WORLD_ROTATION_Y;
|
||||
lastGpsUpdatePos.current.set(...parkedPosition);
|
||||
|
||||
if (groupRef.current) {
|
||||
groupRef.current.position.set(...parkedPosition);
|
||||
groupRef.current.rotation.set(0, EBIKE_WORLD_ROTATION_Y, 0);
|
||||
}
|
||||
|
||||
window.ebikeParkedPosition = parkedPosition;
|
||||
window.ebikeParkedRotation = EBIKE_WORLD_ROTATION_Y;
|
||||
}, [movementMode, parkedPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!model) return;
|
||||
|
||||
let forkNode: THREE.Object3D | null = null;
|
||||
model.traverse((child) => {
|
||||
if (child.name.toLowerCase() === "fourche") forkNode = child;
|
||||
if (child.name === "Phare") phareRef.current = child;
|
||||
});
|
||||
|
||||
if (forkNode) {
|
||||
forkRef.current = forkNode;
|
||||
// Snapshot the rest-pose quaternion — steering is applied on top of this.
|
||||
forkInitialQuatRef.current.copy((forkNode as THREE.Object3D).quaternion);
|
||||
forkAngleRef.current = 0;
|
||||
console.log("[Ebike] Fork found:", (forkNode as THREE.Object3D).name);
|
||||
} else {
|
||||
const names: string[] = [];
|
||||
model.traverse((c) => { if (c.name) names.push(c.name); });
|
||||
console.warn("[Ebike] Fork not found. All nodes:", names);
|
||||
}
|
||||
}, [model]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!model) return;
|
||||
|
||||
model.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.castShadow = true;
|
||||
child.receiveShadow = true;
|
||||
}
|
||||
});
|
||||
}, [model]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -105,11 +220,48 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
}, []);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
// ── SpotLight headlight — tune the constants below ────────────────────────
|
||||
// ── SpotLight headlight — tune these four constants ───────────────────────
|
||||
const LIGHT_OFFSET_X = -0.7; // position : left(-) / right(+)
|
||||
const LIGHT_OFFSET_Y = 1.5; // position : down(-) / up(+)
|
||||
const LIGHT_OFFSET_Z = 0; // position : backward(-) / forward(+)
|
||||
const LIGHT_AIM_DEG = 90; // aim rotation around Y : 0=forward, -90=left, +90=right
|
||||
const LIGHT_TARGET_DIST = 20; // metres devant la position de la lumière
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
if (headlightRef.current && phareRef.current && groupRef.current) {
|
||||
phareRef.current.getWorldPosition(_phareWorldPos);
|
||||
groupRef.current.getWorldDirection(_bikeForward);
|
||||
|
||||
// Position offset in bike-local space (no GC — reusing module-level vectors)
|
||||
const right = _bikeForward.clone().cross(_up).normalize();
|
||||
_phareWorldPos
|
||||
.addScaledVector(right, LIGHT_OFFSET_X)
|
||||
.addScaledVector(_up, LIGHT_OFFSET_Y)
|
||||
.addScaledVector(_bikeForward, LIGHT_OFFSET_Z);
|
||||
|
||||
headlightRef.current.position.copy(_phareWorldPos);
|
||||
|
||||
// Aim direction: rotate forward around Y by LIGHT_AIM_DEG
|
||||
_aimDir
|
||||
.copy(_bikeForward)
|
||||
.applyAxisAngle(_up, THREE.MathUtils.degToRad(LIGHT_AIM_DEG));
|
||||
|
||||
headlightTarget.position
|
||||
.copy(_phareWorldPos)
|
||||
.addScaledVector(_aimDir, LIGHT_TARGET_DIST);
|
||||
headlightTarget.updateMatrixWorld();
|
||||
}
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
if (groupRef.current) {
|
||||
if (movementMode === "ebike") {
|
||||
// Use the ref — not the React state — to avoid stale closure bugs in
|
||||
// R3F's frame loop (the state value may not update until the next render).
|
||||
if (movementModeRef.current === "ebike") {
|
||||
// Sound plays whenever the bike is actually moving (speedFactor > 5 %),
|
||||
// not only while the input key is held.
|
||||
updateEbikeSounds({
|
||||
mounted: true,
|
||||
driving: window.ebikeDriveInputActive === true,
|
||||
driving: (window.ebikeSpeedFactor ?? 0) > 0.05,
|
||||
breakdown: window.ebikeBreakdownActive === true,
|
||||
});
|
||||
|
||||
@@ -120,16 +272,31 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
];
|
||||
restingRotationRef.current = groupRef.current.rotation.y;
|
||||
|
||||
// Smoothly rotate the front fork ("fourche") up to 15 degrees in its own Z axis
|
||||
// ── Fork steering via quaternion ──────────────────────────────────────
|
||||
// We rotate around the fork's LOCAL Y axis (steering tube) by composing
|
||||
// a fresh quaternion on top of the rest-pose snapshot taken at load time.
|
||||
// This is axis-agnostic: correct regardless of how Blender exported the node.
|
||||
// Tune FORK_ANGLE (radians) or negate it if the visual direction is wrong.
|
||||
const FORK_ANGLE = 0.12; // 10°
|
||||
const steerFactor = window.ebikeSteerFactor ?? 0;
|
||||
|
||||
if (forkRef.current) {
|
||||
// 15 degrees is 0.26 radians
|
||||
const targetForkRotation = steerFactor * 0.26;
|
||||
forkRef.current.rotation.z = THREE.MathUtils.lerp(
|
||||
forkRef.current.rotation.z,
|
||||
targetForkRotation,
|
||||
// Smooth the angle separately so we can apply it cleanly each frame.
|
||||
forkAngleRef.current = THREE.MathUtils.lerp(
|
||||
forkAngleRef.current,
|
||||
steerFactor * FORK_ANGLE,
|
||||
12 * delta,
|
||||
);
|
||||
// Build steer quat around LOCAL Y of the fork node.
|
||||
const steerQuat = new THREE.Quaternion().setFromAxisAngle(
|
||||
new THREE.Vector3(0, 1, 0),
|
||||
forkAngleRef.current,
|
||||
);
|
||||
// Apply on top of rest-pose: Q_final = Q_rest × Q_steer
|
||||
forkRef.current.quaternion.multiplyQuaternions(
|
||||
forkInitialQuatRef.current,
|
||||
steerQuat,
|
||||
);
|
||||
}
|
||||
|
||||
// Throttled GPS start position update to prevent performance loss
|
||||
@@ -148,9 +315,10 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
groupRef.current.position.set(...restingPositionRef.current);
|
||||
groupRef.current.rotation.set(0, restingRotationRef.current, 0);
|
||||
|
||||
// Reset fork rotation when parked
|
||||
// Reset fork to rest-pose when parked
|
||||
if (forkRef.current) {
|
||||
forkRef.current.rotation.z = 0;
|
||||
forkRef.current.quaternion.copy(forkInitialQuatRef.current);
|
||||
forkAngleRef.current = 0;
|
||||
}
|
||||
}
|
||||
window.ebikeParkedPosition = restingPositionRef.current;
|
||||
@@ -169,16 +337,30 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
debugRestingPosition[1] + EBIKE_DROP_PLAYER_TRANSFORM.position[1],
|
||||
debugRestingPosition[2] + EBIKE_DROP_PLAYER_TRANSFORM.position[2],
|
||||
];
|
||||
const interactionLabel =
|
||||
mainState === "ebike"
|
||||
? "Réparer l'e-bike"
|
||||
: movementMode === "walk"
|
||||
? "Monter sur le bike"
|
||||
: "Descendre du bike";
|
||||
|
||||
const handleInteract = useCallback((): void => {
|
||||
if (window.ebikeBreakdownActive === true) return;
|
||||
|
||||
if (movementMode === "walk") {
|
||||
if (mainState === "ebike" && ebikeStep === "waiting") {
|
||||
if (
|
||||
mainState === "ebike" &&
|
||||
(ebikeStep === "locked" || ebikeStep === "waiting")
|
||||
) {
|
||||
setMissionStep("ebike", "inspected");
|
||||
return;
|
||||
}
|
||||
|
||||
if (mainState === "ebike" && ebikeStep === "inspected") {
|
||||
setMissionStep("ebike", "fragmented");
|
||||
return;
|
||||
}
|
||||
|
||||
const cameraOffset = new THREE.Vector3(
|
||||
...EBIKE_CAMERA_TRANSFORM.position,
|
||||
);
|
||||
@@ -258,53 +440,76 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
|
||||
return (
|
||||
<>
|
||||
<group
|
||||
ref={groupRef}
|
||||
position={position}
|
||||
rotation={[0, EBIKE_WORLD_ROTATION_Y, 0]}
|
||||
>
|
||||
<primitive object={model} />
|
||||
<InteractableObject
|
||||
kind="trigger"
|
||||
label={
|
||||
mainState === "ebike" && ebikeStep === "waiting"
|
||||
? "Inspecter l'e-bike"
|
||||
: movementMode === "walk"
|
||||
? "Monter sur le bike"
|
||||
: "Descendre du bike"
|
||||
}
|
||||
position={position}
|
||||
radius={15}
|
||||
onPress={handleInteract}
|
||||
{!repairGameOwnsEbikeModel ? (
|
||||
<group
|
||||
ref={groupRef}
|
||||
position={parkedPosition}
|
||||
rotation={[0, EBIKE_WORLD_ROTATION_Y, 0]}
|
||||
scale={EBIKE_WORLD_SCALE}
|
||||
>
|
||||
<mesh>
|
||||
<boxGeometry args={[10, 13, 2]} />
|
||||
<meshBasicMaterial colorWrite={false} depthWrite={false} />
|
||||
</mesh>
|
||||
</InteractableObject>
|
||||
<primitive object={model} />
|
||||
{/* radius 20 → ~7 unités monde (scale 0.35).
|
||||
Sphère omnidirectionnelle pour que le raycast fonctionne
|
||||
quelle que soit l'orientation de la caméra (montée ou à pied). */}
|
||||
<InteractableObject
|
||||
kind="trigger"
|
||||
label={interactionLabel}
|
||||
position={parkedPosition}
|
||||
radius={5}
|
||||
onPress={handleInteract}
|
||||
>
|
||||
<mesh>
|
||||
<sphereGeometry args={[8, 15, 12]} />
|
||||
<meshBasicMaterial colorWrite={false} color={"red"} depthWrite={false} />
|
||||
</mesh>
|
||||
</InteractableObject>
|
||||
|
||||
{/* Dynamic 3D GPS Dashboard Screen */}
|
||||
<group position={[0, 7, 0]} rotation={[0, 90, 0]}>
|
||||
<EbikeGPSMap
|
||||
width={0.8}
|
||||
height={0.8}
|
||||
startPos={gpsStartPos}
|
||||
destPos={destPos}
|
||||
mapImageUrl="/assets/world/gps/map_background.png"
|
||||
worldBounds={{
|
||||
minX: -166,
|
||||
maxX: 163,
|
||||
minZ: -142,
|
||||
maxZ: 138,
|
||||
}}
|
||||
zoom={4}
|
||||
/>
|
||||
{/* GPS + Speedmeter – same group so they are perfectly co-localised.
|
||||
GPS: full circle (Fresnel mask), renderOrder 10 000
|
||||
Speedmeter: upper-half arc overlay, renderOrder 10 001
|
||||
rotation: Math.PI/2 radians = 90° (NOT the number 90 which = ~116.6°) */}
|
||||
<group position={[2, 6, 0]} rotation={[0, -80, 0]}>
|
||||
<EbikeSpeedmeter width={3} height={1.5} position={[0, 0.4, 0]} gaugeInnerR={0.33} gaugeOuterR={0.445}
|
||||
gaugeWidth={2.5}
|
||||
gaugeHeight={2.1}
|
||||
gaugeOffsetX={0}
|
||||
gaugeOffsetY={-0.19}
|
||||
/>
|
||||
<EbikeGPSMap
|
||||
width={1.3}
|
||||
height={1}
|
||||
startPos={gpsStartPos}
|
||||
destPos={destPos}
|
||||
mapImageUrl="/assets/world/gps/map_background.png"
|
||||
worldBounds={{
|
||||
minX: -166,
|
||||
maxX: 163,
|
||||
minZ: -142,
|
||||
maxZ: 138,
|
||||
}}
|
||||
zoom={4}
|
||||
/>
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
) : null}
|
||||
|
||||
{showCameraPoints && (
|
||||
{/* SpotLight headlight — cone aimed forward, position & target via useFrame */}
|
||||
{!repairGameOwnsEbikeModel && (
|
||||
<spotLight
|
||||
ref={headlightRef}
|
||||
intensity={100}
|
||||
color="#ffca60"
|
||||
angle={Math.PI / 5} // 22.5° demi-angle — cone étroit comme une torche
|
||||
penumbra={0.5} // bord doux (0 = dur, 1 = très doux)
|
||||
distance={50}
|
||||
decay={2.5}
|
||||
castShadow={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showCameraPoints && !repairGameOwnsEbikeModel && (
|
||||
<>
|
||||
<mesh position={camPointPos}>
|
||||
{/* <mesh position={camPointPos}>
|
||||
<sphereGeometry args={[0.3, 16, 16]} />
|
||||
<meshStandardMaterial
|
||||
color="yellow"
|
||||
@@ -319,7 +524,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
emissive="cyan"
|
||||
emissiveIntensity={0.5}
|
||||
/>
|
||||
</mesh>
|
||||
</mesh> */}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -12,6 +12,28 @@ import {
|
||||
} from "@/pathfinding/WaypointAStar";
|
||||
import type { Waypoint } from "@/pathfinding/types";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
const VERT_SHADER = /* glsl */ `
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
// Circular Fresnel mask: fully visible inside innerRadius, fades out to outerRadius
|
||||
const FRAG_SHADER = /* glsl */ `
|
||||
uniform sampler2D map;
|
||||
uniform float innerRadius;
|
||||
uniform float outerRadius;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vec4 color = texture2D(map, vUv);
|
||||
float dist = length(vUv - vec2(0.5));
|
||||
float mask = 1.0 - smoothstep(innerRadius, outerRadius, dist);
|
||||
gl_FragColor = vec4(color.rgb, color.a * mask);
|
||||
}
|
||||
`;
|
||||
function computeImageSource(
|
||||
img: HTMLImageElement | HTMLCanvasElement,
|
||||
baseBounds: { minX: number; maxX: number; minZ: number; maxZ: number },
|
||||
@@ -89,6 +111,8 @@ export interface EbikeGPSMapProps {
|
||||
* Default: 1
|
||||
*/
|
||||
zoom?: number;
|
||||
|
||||
renderOrder?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,6 +131,7 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
|
||||
position = [0, 0, 0],
|
||||
canvasSize = 1024,
|
||||
zoom = 1,
|
||||
renderOrder = 10_000,
|
||||
}) => {
|
||||
const [waypoints, setWaypoints] = useState<Waypoint[]>([]);
|
||||
const [mapImage, setMapImage] = useState<
|
||||
@@ -123,19 +148,57 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Canvas should only be created once
|
||||
}, []);
|
||||
|
||||
// Resize the canvas whenever canvasSize changes
|
||||
// Note: Modifying canvas dimensions is intentional and necessary for rendering
|
||||
useEffect(() => {
|
||||
// Use Object.assign to resize canvas - this is a necessary mutation for canvas rendering
|
||||
Object.assign(offscreenCanvas, { width: canvasSize, height: canvasSize });
|
||||
if (textureRef.current) {
|
||||
textureRef.current.needsUpdate = true;
|
||||
}
|
||||
}, [canvasSize, offscreenCanvas]);
|
||||
|
||||
const textureRef = useRef<THREE.CanvasTexture | null>(null);
|
||||
const animTimeRef = useRef<number>(0);
|
||||
|
||||
// Imperative CanvasTexture — must be declared before the resize effect below
|
||||
const texture = useMemo(() => {
|
||||
const tex = new THREE.CanvasTexture(offscreenCanvas);
|
||||
tex.format = THREE.RGBAFormat;
|
||||
tex.minFilter = THREE.LinearFilter;
|
||||
tex.magFilter = THREE.LinearFilter;
|
||||
return tex;
|
||||
}, [offscreenCanvas]);
|
||||
|
||||
// ShaderMaterial with circular Fresnel mask (created once)
|
||||
const shaderMat = useMemo(
|
||||
() =>
|
||||
new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
map: { value: null },
|
||||
innerRadius: { value: 0.45 },
|
||||
outerRadius: { value: 0.5 },
|
||||
},
|
||||
vertexShader: VERT_SHADER,
|
||||
fragmentShader: FRAG_SHADER,
|
||||
transparent: true,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
side: THREE.DoubleSide,
|
||||
toneMapped: false,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// Sync texture into uniform when it changes (canvas resize)
|
||||
useEffect(() => {
|
||||
shaderMat.uniforms.map.value = texture;
|
||||
}, [shaderMat, texture]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
shaderMat.dispose();
|
||||
texture.dispose();
|
||||
},
|
||||
[shaderMat, texture],
|
||||
);
|
||||
|
||||
// Resize the canvas whenever canvasSize changes (texture declared above)
|
||||
useEffect(() => {
|
||||
Object.assign(offscreenCanvas, { width: canvasSize, height: canvasSize });
|
||||
texture.needsUpdate = true;
|
||||
}, [canvasSize, offscreenCanvas, texture]);
|
||||
|
||||
// Load waypoints (localStorage with /roadNetwork.json fallback)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -489,41 +552,20 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
|
||||
useEffect(() => {
|
||||
let animId: number;
|
||||
const tick = () => {
|
||||
animTimeRef.current += 0.004; // Slow, premium sweep speed
|
||||
animTimeRef.current += 0.004;
|
||||
if (animTimeRef.current > 1) animTimeRef.current = 0;
|
||||
|
||||
draw();
|
||||
|
||||
// Update texture after draw
|
||||
if (textureRef.current) {
|
||||
textureRef.current.needsUpdate = true;
|
||||
}
|
||||
|
||||
texture.needsUpdate = true;
|
||||
animId = requestAnimationFrame(tick);
|
||||
};
|
||||
animId = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(animId);
|
||||
}, [draw]);
|
||||
}, [draw, texture]);
|
||||
|
||||
return (
|
||||
<mesh castShadow receiveShadow position={position}>
|
||||
<mesh position={position} renderOrder={renderOrder}>
|
||||
<planeGeometry args={[width, height]} />
|
||||
<meshBasicMaterial
|
||||
toneMapped={false}
|
||||
transparent={true}
|
||||
opacity={1}
|
||||
depthWrite={false}
|
||||
side={THREE.DoubleSide}
|
||||
>
|
||||
<canvasTexture
|
||||
ref={textureRef}
|
||||
attach="map"
|
||||
image={offscreenCanvas}
|
||||
format={THREE.RGBAFormat}
|
||||
minFilter={THREE.LinearFilter}
|
||||
magFilter={THREE.LinearFilter}
|
||||
/>
|
||||
</meshBasicMaterial>
|
||||
<primitive object={shaderMat} attach="material" />
|
||||
</mesh>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useEffect, useRef, useMemo } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { useTexture } from "@react-three/drei";
|
||||
import * as THREE from "three";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
import "@/types/ebike/ebikeWindow";
|
||||
|
||||
const SPEEDOMETER_DIAL_TEXTURE = "/assets/world/gps/cadran.png";
|
||||
const SPEEDOMETER_NEEDLE_TEXTURE = "/assets/world/gps/fleche.png";
|
||||
|
||||
export interface EbikeSpeedmeterProps {
|
||||
width?: number;
|
||||
height?: number;
|
||||
/** Local position offset within the parent group. Default: [0, 0, 0] */
|
||||
position?: Vector3Tuple;
|
||||
/**
|
||||
* Needle rotation.z when speedFactor = 0.
|
||||
* Default: Math.PI / 2 (pointing left — 9 o'clock)
|
||||
*/
|
||||
minAngle?: number;
|
||||
/**
|
||||
* Needle rotation.z when speedFactor = 1.
|
||||
* Default: -Math.PI / 2 (pointing right — 3 o'clock)
|
||||
*/
|
||||
maxAngle?: number;
|
||||
renderOrder?: number;
|
||||
/**
|
||||
* Inner radius of the gauge-fill arc, as a fraction of the canvas half-width.
|
||||
* Tune this to align the fill with the cadran.png track. Default: 0.33
|
||||
*/
|
||||
gaugeInnerR?: number;
|
||||
/**
|
||||
* Outer radius of the gauge-fill arc, as a fraction of the canvas half-width.
|
||||
* Tune this to align the fill with the cadran.png track. Default: 0.445
|
||||
*/
|
||||
gaugeOuterR?: number;
|
||||
/**
|
||||
* Width of the gauge-fill plane. Defaults to `width` when omitted.
|
||||
* Lets you resize the fill independently of the cadran/needle.
|
||||
*/
|
||||
gaugeWidth?: number;
|
||||
/**
|
||||
* Height of the gauge-fill plane. Defaults to `height` when omitted.
|
||||
* Lets you resize the fill independently of the cadran/needle.
|
||||
*/
|
||||
gaugeHeight?: number;
|
||||
/**
|
||||
* Horizontal offset of the arc pivot from the canvas centre.
|
||||
* Expressed as a fraction of the canvas size: -0.1 = shift 10 % to the left,
|
||||
* +0.1 = shift 10 % to the right. Default: 0
|
||||
*/
|
||||
gaugeOffsetX?: number;
|
||||
/**
|
||||
* Vertical offset of the arc pivot from its default position.
|
||||
* Expressed as a fraction of the canvas size: -0.1 = shift upward (toward top
|
||||
* of the plane), +0.1 = shift downward. Default: 0
|
||||
*/
|
||||
gaugeOffsetY?: number;
|
||||
}
|
||||
|
||||
// The needle pivot is always at -height*0.38 in local space,
|
||||
// which is always 12 % from the bottom of the plane (UV y = 0.12).
|
||||
// With Three.js flipY texture convention, canvas y = (1 - 0.12) * size = 0.88 * size.
|
||||
const NEEDLE_PIVOT_UV_Y = 0.12; // fraction from bottom
|
||||
|
||||
export function EbikeSpeedmeter({
|
||||
width = 0.8,
|
||||
height = 0.8,
|
||||
position = [0, 0, 0],
|
||||
minAngle = Math.PI / 2,
|
||||
maxAngle = -Math.PI / 2,
|
||||
renderOrder = 1000,
|
||||
gaugeInnerR = 0.33,
|
||||
gaugeOuterR = 0.445,
|
||||
gaugeWidth,
|
||||
gaugeHeight,
|
||||
gaugeOffsetX = 0,
|
||||
gaugeOffsetY = 0,
|
||||
}: EbikeSpeedmeterProps): React.JSX.Element {
|
||||
// Fall back to the main dimensions when gauge-specific ones aren't provided
|
||||
const fillW = gaugeWidth ?? width;
|
||||
const fillH = gaugeHeight ?? height;
|
||||
const needleGroupRef = useRef<THREE.Group>(null);
|
||||
const speedFactorRef = useRef(0);
|
||||
|
||||
// ── Dial & needle textures ──────────────────────────────────────────────────
|
||||
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((tex) => {
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
tex.needsUpdate = true;
|
||||
});
|
||||
}, [dialTexture, needleTexture]);
|
||||
|
||||
// ── Gauge-fill canvas ───────────────────────────────────────────────────────
|
||||
const fillCanvas = useMemo(() => {
|
||||
const c = document.createElement("canvas");
|
||||
c.width = 256;
|
||||
c.height = 256;
|
||||
return c;
|
||||
}, []);
|
||||
|
||||
const fillTexture = useMemo(() => {
|
||||
const tex = new THREE.CanvasTexture(fillCanvas);
|
||||
tex.format = THREE.RGBAFormat;
|
||||
tex.minFilter = THREE.LinearFilter;
|
||||
tex.magFilter = THREE.LinearFilter;
|
||||
return tex;
|
||||
}, [fillCanvas]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
fillTexture.dispose();
|
||||
},
|
||||
[fillTexture],
|
||||
);
|
||||
|
||||
// ── Frame loop ──────────────────────────────────────────────────────────────
|
||||
useFrame((_, delta) => {
|
||||
// 1. Smooth speed factor
|
||||
const target = THREE.MathUtils.clamp(window.ebikeSpeedFactor ?? 0, 0, 1);
|
||||
speedFactorRef.current = THREE.MathUtils.lerp(
|
||||
speedFactorRef.current,
|
||||
target,
|
||||
Math.min(1, delta * 10),
|
||||
);
|
||||
|
||||
// 2. Needle rotation
|
||||
if (needleGroupRef.current) {
|
||||
needleGroupRef.current.rotation.z = THREE.MathUtils.lerp(
|
||||
minAngle,
|
||||
maxAngle,
|
||||
speedFactorRef.current,
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Draw gauge fill -------------------------------------------------------
|
||||
const ctx = fillCanvas.getContext("2d", { alpha: true });
|
||||
if (!ctx) return;
|
||||
|
||||
const size = fillCanvas.width;
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
|
||||
// Default centre: horizontal middle + needle-pivot height.
|
||||
// gaugeOffsetX/Y shift the pivot so the arc aligns with cadran.png.
|
||||
const cx = size * (0.5 + gaugeOffsetX);
|
||||
const cy = size * ((1 - NEEDLE_PIVOT_UV_Y) + gaugeOffsetY); // default ≈ 0.88 × size
|
||||
|
||||
const outerR = size * gaugeOuterR;
|
||||
const innerR = size * gaugeInnerR;
|
||||
|
||||
// Arc sweeps clockwise from π (left) to current needle angle
|
||||
const arcStart = Math.PI;
|
||||
const arcEnd = Math.PI + speedFactorRef.current * Math.PI;
|
||||
|
||||
if (speedFactorRef.current > 0.005) {
|
||||
// Radial gradient using #3F67DD — slightly transparent at inner edge,
|
||||
// fully solid at outer edge for a depth effect.
|
||||
const radial = ctx.createRadialGradient(cx, cy, innerR, cx, cy, outerR);
|
||||
radial.addColorStop(0, "rgba(191, 234, 255, 0)"); // inner edge
|
||||
radial.addColorStop(0.7, "rgba(118, 152, 255, 0.95)"); // outer edge
|
||||
|
||||
// Annular sector shape (outer arc + inner arc reversed)
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, outerR, arcStart, arcEnd, false);
|
||||
ctx.arc(cx, cy, innerR, arcEnd, arcStart, true);
|
||||
ctx.closePath();
|
||||
|
||||
ctx.fillStyle = radial;
|
||||
ctx.shadowBlur = 16;
|
||||
ctx.shadowColor = "#3F67DD";
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
fillTexture.needsUpdate = true;
|
||||
});
|
||||
|
||||
return (
|
||||
<group renderOrder={renderOrder} position={position}>
|
||||
{/* Gauge fill — behind the cadran frame (size controlled by gaugeWidth/gaugeHeight) */}
|
||||
<mesh renderOrder={renderOrder - 1} position={[0, 0, -0.001]}>
|
||||
<planeGeometry args={[fillW, fillH]} />
|
||||
<meshBasicMaterial
|
||||
map={fillTexture}
|
||||
transparent
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
toneMapped={false}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Dial frame (cadran.png) */}
|
||||
<mesh renderOrder={renderOrder}>
|
||||
<planeGeometry args={[width, height]} />
|
||||
<meshBasicMaterial
|
||||
map={dialTexture}
|
||||
transparent
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
toneMapped={false}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Needle — pivot at bottom-centre of the arc */}
|
||||
<group ref={needleGroupRef} position={[0, -height * 0.38, 0.002]} rotation={[0, 0, 0]}>
|
||||
<mesh
|
||||
position={[0, needleHeight / 2, 0]}
|
||||
renderOrder={renderOrder + 1}
|
||||
>
|
||||
<planeGeometry args={[needleWidth, needleHeight]} />
|
||||
<meshBasicMaterial
|
||||
map={needleTexture}
|
||||
transparent
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
toneMapped={false}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { useTexture } from "@react-three/drei";
|
||||
import * as THREE from "three";
|
||||
|
||||
const SPEEDOMETER_DIAL_TEXTURE = "/assets/world/gps/cadran.png";
|
||||
const SPEEDOMETER_NEEDLE_TEXTURE = "/assets/world/gps/fleche.png";
|
||||
const SPEEDOMETER_MIN_ANGLE = Math.PI / 2;
|
||||
const SPEEDOMETER_MAX_ANGLE = -Math.PI / 2;
|
||||
const SPEEDOMETER_RENDER_ORDER = 10_000;
|
||||
|
||||
interface EbikeSpeedometerProps {
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export function EbikeSpeedometer({
|
||||
width = 0.9,
|
||||
height = 0.5,
|
||||
}: EbikeSpeedometerProps): React.JSX.Element {
|
||||
const needleGroupRef = useRef<THREE.Group>(null);
|
||||
const speedFactorRef = useRef(0);
|
||||
const [dialTexture, needleTexture] = useTexture([
|
||||
SPEEDOMETER_DIAL_TEXTURE,
|
||||
SPEEDOMETER_NEEDLE_TEXTURE,
|
||||
]) as [THREE.Texture, THREE.Texture];
|
||||
const needleWidth = width * 0.68;
|
||||
const needleHeight = needleWidth / 2;
|
||||
|
||||
useEffect(() => {
|
||||
[dialTexture, needleTexture].forEach((texture) => {
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
texture.needsUpdate = true;
|
||||
});
|
||||
}, [dialTexture, needleTexture]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
const targetSpeedFactor = THREE.MathUtils.clamp(
|
||||
window.ebikeSpeedFactor ?? 0,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
speedFactorRef.current = THREE.MathUtils.lerp(
|
||||
speedFactorRef.current,
|
||||
targetSpeedFactor,
|
||||
Math.min(1, delta * 10),
|
||||
);
|
||||
|
||||
if (needleGroupRef.current) {
|
||||
needleGroupRef.current.rotation.z = THREE.MathUtils.lerp(
|
||||
SPEEDOMETER_MIN_ANGLE,
|
||||
SPEEDOMETER_MAX_ANGLE,
|
||||
speedFactorRef.current,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<group renderOrder={SPEEDOMETER_RENDER_ORDER}>
|
||||
<mesh renderOrder={SPEEDOMETER_RENDER_ORDER}>
|
||||
<planeGeometry args={[width, height]} />
|
||||
<meshBasicMaterial
|
||||
map={dialTexture}
|
||||
transparent
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
toneMapped={false}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
<group ref={needleGroupRef} position={[0, -height * 0.38, 0.002]}>
|
||||
<mesh
|
||||
position={[0, needleHeight / 2, 0]}
|
||||
renderOrder={SPEEDOMETER_RENDER_ORDER + 1}
|
||||
>
|
||||
<planeGeometry args={[needleWidth, needleHeight]} />
|
||||
<meshBasicMaterial
|
||||
map={needleTexture}
|
||||
transparent
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
toneMapped={false}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import * as THREE from "three";
|
||||
import { MissionNotification } from "@/components/ui/MissionNotification";
|
||||
import {
|
||||
EBIKE_BREAKDOWN_DIALOGUE_DELAY_MS,
|
||||
EBIKE_BREAKDOWN_DIALOGUE_ID,
|
||||
EBIKE_INTRO_RIDE_DURATION_MS,
|
||||
EBIKE_INTRO_BREAKDOWN_DISTANCE,
|
||||
EBIKE_SOUNDS,
|
||||
} from "@/data/ebike/ebikeConfig";
|
||||
import { INTRO_MISSION_NOTIFICATION_IMAGE_PATH } from "@/data/gameplay/missionNotifications";
|
||||
import { AudioManager } from "@/managers/AudioManager";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||
@@ -18,6 +20,9 @@ export function EbikeIntroSequence(): React.JSX.Element | null {
|
||||
const completeIntro = useGameStore((state) => state.completeIntro);
|
||||
const [breakdownDialogueDone, setBreakdownDialogueDone] = useState(false);
|
||||
const hasStartedBreakdown = useRef(false);
|
||||
const rideDistance = useRef(0);
|
||||
const lastRidePosition = useRef<THREE.Vector3 | null>(null);
|
||||
const currentRidePosition = useRef(new THREE.Vector3());
|
||||
|
||||
useEffect(() => {
|
||||
if (introStep !== "await-ebike-mount" || movementMode !== "ebike") return;
|
||||
@@ -26,16 +31,45 @@ export function EbikeIntroSequence(): React.JSX.Element | null {
|
||||
}, [introStep, movementMode, setIntroStep]);
|
||||
|
||||
useEffect(() => {
|
||||
if (introStep !== "ebike-intro-ride") return undefined;
|
||||
if (introStep !== "ebike-intro-ride") return;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setIntroStep("ebike-breakdown");
|
||||
}, EBIKE_INTRO_RIDE_DURATION_MS);
|
||||
rideDistance.current = 0;
|
||||
lastRidePosition.current = null;
|
||||
}, [introStep]);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
useEffect(() => {
|
||||
if (introStep !== "ebike-intro-ride" || movementMode !== "ebike") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let animationFrameId = 0;
|
||||
const tick = () => {
|
||||
const parkedPosition = window.ebikeParkedPosition;
|
||||
if (parkedPosition) {
|
||||
currentRidePosition.current.set(...parkedPosition);
|
||||
if (!lastRidePosition.current) {
|
||||
lastRidePosition.current = currentRidePosition.current.clone();
|
||||
} else {
|
||||
rideDistance.current += currentRidePosition.current.distanceTo(
|
||||
lastRidePosition.current,
|
||||
);
|
||||
lastRidePosition.current.copy(currentRidePosition.current);
|
||||
}
|
||||
|
||||
if (rideDistance.current >= EBIKE_INTRO_BREAKDOWN_DISTANCE) {
|
||||
setIntroStep("ebike-breakdown");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
animationFrameId = window.requestAnimationFrame(tick);
|
||||
};
|
||||
}, [introStep, setIntroStep]);
|
||||
|
||||
animationFrameId = window.requestAnimationFrame(tick);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [introStep, movementMode, setIntroStep]);
|
||||
|
||||
useEffect(() => {
|
||||
if (introStep !== "ebike-breakdown" || hasStartedBreakdown.current) {
|
||||
@@ -100,14 +134,27 @@ export function EbikeIntroSequence(): React.JSX.Element | null {
|
||||
}
|
||||
}, [introStep]);
|
||||
|
||||
if (introStep !== "await-ebike-mount" && introStep !== "ebike-intro-ride") {
|
||||
if (
|
||||
introStep !== "reveal" &&
|
||||
introStep !== "await-ebike-mount" &&
|
||||
introStep !== "ebike-intro-ride" &&
|
||||
introStep !== "ebike-breakdown"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (introStep === "ebike-breakdown") {
|
||||
return <MissionNotification mission="ebike" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<MissionNotification
|
||||
mission="ebike"
|
||||
visible={introStep === "await-ebike-mount"}
|
||||
imagePath={INTRO_MISSION_NOTIFICATION_IMAGE_PATH}
|
||||
visible={
|
||||
introStep === "reveal" ||
|
||||
introStep === "await-ebike-mount" ||
|
||||
introStep === "ebike-intro-ride"
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export function SiteMobileBlocker(): React.JSX.Element {
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/assets/logo/logo.jpg"
|
||||
src="/assets/logo.png"
|
||||
alt="Logo Altera"
|
||||
style={{ width: 120, height: "auto" }}
|
||||
/>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { HandTrackingVisualizer } from "@/components/ui/HandTrackingVisualizer";
|
||||
import { InteractPrompt } from "@/components/ui/InteractPrompt";
|
||||
import { RepairMovementLockIndicator } from "@/components/ui/RepairMovementLockIndicator";
|
||||
import { Subtitles } from "@/components/ui/Subtitles";
|
||||
import { TalkieDialogueOverlay } from "@/components/ui/TalkieDialogueOverlay";
|
||||
|
||||
export function GameUI(): React.JSX.Element {
|
||||
return (
|
||||
@@ -15,6 +16,7 @@ export function GameUI(): React.JSX.Element {
|
||||
<InteractPrompt />
|
||||
<HandTrackingVisualizer />
|
||||
<Subtitles />
|
||||
<TalkieDialogueOverlay />
|
||||
<GameSettingsMenu />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,14 +2,20 @@ import { MISSION_NOTIFICATION_IMAGE_PATHS } from "@/data/gameplay/missionNotific
|
||||
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||
|
||||
interface MissionNotificationProps {
|
||||
mission: RepairMissionId;
|
||||
mission?: RepairMissionId;
|
||||
imagePath?: string;
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
export function MissionNotification({
|
||||
mission,
|
||||
imagePath,
|
||||
visible = true,
|
||||
}: MissionNotificationProps): React.JSX.Element {
|
||||
const src =
|
||||
imagePath ?? (mission ? MISSION_NOTIFICATION_IMAGE_PATHS[mission] : "");
|
||||
const isVideo = src.toLowerCase().endsWith(".webm");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mission-notification${visible ? "" : " mission-notification--hidden"}`}
|
||||
@@ -17,11 +23,24 @@ export function MissionNotification({
|
||||
>
|
||||
<div className="mission-notification__glow" />
|
||||
<span className="mission-notification__image-wrap">
|
||||
<img
|
||||
className="mission-notification__image"
|
||||
src={MISSION_NOTIFICATION_IMAGE_PATHS[mission]}
|
||||
alt="Nouvel objectif de mission"
|
||||
/>
|
||||
{isVideo ? (
|
||||
<video
|
||||
className="mission-notification__image"
|
||||
src={src}
|
||||
aria-label="Nouvel objectif de mission"
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
preload="auto"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
className="mission-notification__image"
|
||||
src={src}
|
||||
alt="Nouvel objectif de mission"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppLoadingIndicator } from "@/components/ui/AppLoadingIndicator";
|
||||
import type { SceneLoadingState } from "@/types/world/sceneLoading";
|
||||
|
||||
const LOADING_BACKGROUND_PATH = "/assets/bg-site.png";
|
||||
const LOADING_LOGO_PATH = "/assets/logo/logo.jpg";
|
||||
const LOADING_BACKGROUND_PATH = "/assets/bg-site.webp";
|
||||
const LOADING_FRAME_RATE = 12;
|
||||
const LOADING_FRAME_INTERVAL_MS = 1000 / LOADING_FRAME_RATE;
|
||||
const LOADING_LOGO_FRAMES = [
|
||||
"/assets/loader/Loader-1.png",
|
||||
"/assets/loader/Loader-2.png",
|
||||
"/assets/loader/Loader-3.png",
|
||||
"/assets/loader/Loader-4.png",
|
||||
] as const;
|
||||
|
||||
for (const path of [LOADING_BACKGROUND_PATH, LOADING_LOGO_PATH]) {
|
||||
for (const path of [LOADING_BACKGROUND_PATH, ...LOADING_LOGO_FRAMES]) {
|
||||
const image = new Image();
|
||||
image.src = path;
|
||||
}
|
||||
@@ -16,8 +24,25 @@ interface SceneLoadingOverlayProps {
|
||||
export function SceneLoadingOverlay({
|
||||
state,
|
||||
}: SceneLoadingOverlayProps): React.JSX.Element | null {
|
||||
const [logoFrameIndex, setLogoFrameIndex] = useState(0);
|
||||
const isReady = state.status === "ready";
|
||||
const progress = Math.round(Math.max(0, Math.min(1, state.progress)) * 100);
|
||||
const logoFramePath =
|
||||
LOADING_LOGO_FRAMES[logoFrameIndex] ?? LOADING_LOGO_FRAMES[0];
|
||||
|
||||
useEffect(() => {
|
||||
if (isReady) return undefined;
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
setLogoFrameIndex(
|
||||
(currentIndex) => (currentIndex + 1) % LOADING_LOGO_FRAMES.length,
|
||||
);
|
||||
}, LOADING_FRAME_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [isReady]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -33,7 +58,7 @@ export function SceneLoadingOverlay({
|
||||
<img
|
||||
alt="La Fabrik Durable"
|
||||
className="scene-loading-overlay__logo"
|
||||
src={LOADING_LOGO_PATH}
|
||||
src={logoFramePath}
|
||||
/>
|
||||
<div className="scene-loading-overlay__footer">
|
||||
<div className="scene-loading-overlay__meta">
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Suspense } from "react";
|
||||
import { Canvas } from "@react-three/fiber";
|
||||
import { TalkieModel } from "@/components/ui/talkie/TalkieModel";
|
||||
import { TalkieSignalLines } from "@/components/ui/talkie/TalkieSignalLines";
|
||||
import { useTalkieDialogueOverlayState } from "@/hooks/ui/useTalkieDialogueOverlayState";
|
||||
|
||||
export function TalkieDialogueOverlay(): React.JSX.Element | null {
|
||||
const { isNarratorDialogue, isVisible } = useTalkieDialogueOverlayState();
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`talkie-dialogue-overlay${isNarratorDialogue ? " talkie-dialogue-overlay--active" : ""}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{isNarratorDialogue ? <TalkieSignalLines side="left" /> : null}
|
||||
{isNarratorDialogue ? <TalkieSignalLines side="right" /> : null}
|
||||
<div className="talkie-dialogue-overlay__model-frame">
|
||||
<Canvas
|
||||
camera={{ position: [0, 0, 4.2], zoom: 56 }}
|
||||
dpr={[1, 1.5]}
|
||||
gl={{ alpha: true, antialias: true }}
|
||||
orthographic
|
||||
>
|
||||
<ambientLight intensity={2.5} />
|
||||
<directionalLight position={[2, 3, 4]} intensity={2.8} />
|
||||
<Suspense fallback={null}>
|
||||
<TalkieModel active={isNarratorDialogue} />
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import * as THREE from "three";
|
||||
import gsap from "gsap";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
const TALKIE_MODEL_PATH = "/models/talkie/model.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>
|
||||
);
|
||||
}
|
||||
@@ -6,22 +6,22 @@ export interface CameraTransform {
|
||||
}
|
||||
|
||||
export const EBIKE_CAMERA_TRANSFORM: CameraTransform = {
|
||||
position: [-3.5, 6, 0],
|
||||
position: [-1, 1, 0],
|
||||
rotation: [-10, -90, 0],
|
||||
};
|
||||
|
||||
export const EBIKE_DROP_PLAYER_TRANSFORM: CameraTransform = {
|
||||
position: [0, 1.5, -3],
|
||||
position: [0, 1.3, -2.25],
|
||||
rotation: [0, 0, 0],
|
||||
};
|
||||
|
||||
export const EBIKE_WORLD_POSITION: Vector3Tuple = [61.5, 10, 62.4];
|
||||
export const EBIKE_WORLD_ROTATION_Y = 2.4107;
|
||||
export const EBIKE_WORLD_POSITION: Vector3Tuple = [65, 0.8, 72];
|
||||
export const EBIKE_WORLD_ROTATION_Y = -2.5;
|
||||
export const EBIKE_WORLD_SCALE = 0.35;
|
||||
|
||||
export const EBIKE_INTRO_RIDE_DURATION_MS = 5000;
|
||||
export const EBIKE_INTRO_BREAKDOWN_DISTANCE = 15;
|
||||
export const EBIKE_BREAKDOWN_DIALOGUE_DELAY_MS = 250;
|
||||
|
||||
export const EBIKE_MAX_SPEED = 3;
|
||||
export const EBIKE_ACCELERATION_DURATION_MS = 2000;
|
||||
export const EBIKE_DECELERATION_DURATION_MS = 2000;
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||
|
||||
export const INTRO_MISSION_NOTIFICATION_IMAGE_PATH =
|
||||
"/assets/world/UI/intro-mission-notification.png";
|
||||
|
||||
export const MISSION_NOTIFICATION_IMAGE_PATHS: Record<RepairMissionId, string> =
|
||||
{
|
||||
ebike: "/assets/world/UI/ebike-mission-notification.png",
|
||||
pylon: "/assets/world/UI/pylon-mission-notification.png",
|
||||
farm: "/assets/world/UI/farm-mission-notification.png",
|
||||
ebike: "/assets/world/UI/ebike-mission-notification.webm",
|
||||
pylon: "/assets/world/UI/pylon-mission-notification.webm",
|
||||
farm: "/assets/world/UI/farm-mission-notification.webm",
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
||||
"Repair the damaged cooling module before relaunching the bike",
|
||||
modelPath: "/models/ebike/model.gltf",
|
||||
modelScale: 0.3,
|
||||
stageUiPath: "/assets/world/UI/ebike.webm",
|
||||
stageUiPath: "/assets/world/UI/ebike-mission-notification.webm",
|
||||
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
||||
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||
case: DEFAULT_REPAIR_CASE,
|
||||
@@ -59,7 +59,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
||||
description:
|
||||
"Restore the pylon lamp relay and damaged panel before reconnecting the grid",
|
||||
modelPath: "/models/pylone/model.gltf",
|
||||
stageUiPath: "/assets/world/UI/centrale.webm",
|
||||
stageUiPath: "/assets/world/UI/pylon-mission-notification.webm",
|
||||
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
||||
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||
case: DEFAULT_REPAIR_CASE,
|
||||
@@ -104,7 +104,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
||||
description:
|
||||
"Stabilize the irrigation loop and humidity sensor before restarting the farm",
|
||||
modelPath: "/models/fermeverticale/model.gltf",
|
||||
stageUiPath: "/assets/world/UI/laferme.webm",
|
||||
stageUiPath: "/assets/world/UI/farm-mission-notification.webm",
|
||||
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
||||
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||
case: DEFAULT_REPAIR_CASE,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
import { LA_FABRIK_PLAYER_SPAWN } from "@/data/world/laFabrikConfig";
|
||||
|
||||
export const PLAYER_EYE_HEIGHT = 1.75;
|
||||
export const PLAYER_CAPSULE_RADIUS = 0.35;
|
||||
@@ -14,5 +15,9 @@ export const PLAYER_XZ_DAMPING_FACTOR = 8;
|
||||
export const PLAYER_FALL_RESPAWN_Y = -20;
|
||||
export const PLAYER_FALL_RESPAWN_DELAY = 3;
|
||||
|
||||
export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [59.5, 10, 64.64];
|
||||
export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [
|
||||
LA_FABRIK_PLAYER_SPAWN[0] + 1,
|
||||
LA_FABRIK_PLAYER_SPAWN[1],
|
||||
LA_FABRIK_PLAYER_SPAWN[2] - 1,
|
||||
];
|
||||
export const PLAYER_SPAWN_POSITION_PHYSICS: Vector3Tuple = [0, 3, 0];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
const BACKGROUND_IMAGE = "/assets/bg-site.png";
|
||||
const BACKGROUND_IMAGE = "/assets/bg-site.webp";
|
||||
|
||||
export const SITE_CONFIG = {
|
||||
backgroundImage: BACKGROUND_IMAGE,
|
||||
|
||||
@@ -19,7 +19,7 @@ export const CLOUD_DEFAULTS = {
|
||||
maxRotation: Math.PI * 2,
|
||||
minSpeedMultiplier: 0.4,
|
||||
maxSpeedMultiplier: 1,
|
||||
castShadow: false,
|
||||
castShadow: true,
|
||||
receiveShadow: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { CHUNK_CONFIG } from "@/data/world/chunkStreamingConfig";
|
||||
|
||||
export const GRAPHICS_PRESET_KEYS = ["low", "medium", "high", "ultra"] as const;
|
||||
|
||||
export type GraphicsPreset = (typeof GRAPHICS_PRESET_KEYS)[number];
|
||||
@@ -32,8 +30,8 @@ export const GRAPHICS_PRESETS = {
|
||||
},
|
||||
high: {
|
||||
label: "High",
|
||||
chunkLoadRadius: CHUNK_CONFIG.loadRadius,
|
||||
chunkUnloadRadius: CHUNK_CONFIG.unloadRadius,
|
||||
chunkLoadRadius: 35,
|
||||
chunkUnloadRadius: 45,
|
||||
fogEnabled: false,
|
||||
forceLodModels: false,
|
||||
lodHighDetailDistance: 10,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
export const LA_FABRIK_CENTER: Vector3Tuple = [59.4973, 6.2746, 64.6354];
|
||||
export const LA_FABRIK_ROTATION_Y = 2.4107;
|
||||
export const LA_FABRIK_HALF_EXTENTS = {
|
||||
x: 8.5,
|
||||
z: 7.5,
|
||||
} as const;
|
||||
export const LA_FABRIK_PLAYER_SPAWN: Vector3Tuple = [59.5, 6.3, 64.64];
|
||||
export const LA_FABRIK_INITIAL_LOOK_AT: Vector3Tuple = [58, 7.3, 62.5];
|
||||
export const LA_FABRIK_INTERIOR_LIGHT_POSITION: Vector3Tuple = [59.5, 9, 64.64];
|
||||
|
||||
export function isInsideLaFabrikFootprint(
|
||||
x: number,
|
||||
z: number,
|
||||
padding = 0,
|
||||
): boolean {
|
||||
const dx = x - LA_FABRIK_CENTER[0];
|
||||
const dz = z - LA_FABRIK_CENTER[2];
|
||||
const cos = Math.cos(-LA_FABRIK_ROTATION_Y);
|
||||
const sin = Math.sin(-LA_FABRIK_ROTATION_Y);
|
||||
const localX = dx * cos - dz * sin;
|
||||
const localZ = dx * sin + dz * cos;
|
||||
|
||||
return (
|
||||
Math.abs(localX) <= LA_FABRIK_HALF_EXTENTS.x + padding &&
|
||||
Math.abs(localZ) <= LA_FABRIK_HALF_EXTENTS.z + padding
|
||||
);
|
||||
}
|
||||
@@ -30,3 +30,12 @@ export const SUN_Y_STEP = 1;
|
||||
export const SUN_Z_MIN = -100;
|
||||
export const SUN_Z_MAX = 100;
|
||||
export const SUN_Z_STEP = 1;
|
||||
|
||||
export const SHADOW_CONFIG = {
|
||||
mapSize: 2048,
|
||||
cameraSize: 95,
|
||||
cameraNear: 0.5,
|
||||
cameraFar: 300,
|
||||
bias: 0,
|
||||
normalBias: 0,
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import type { Object3D } from "three";
|
||||
import { type Object3D } from "three";
|
||||
import { Octree } from "three-stdlib";
|
||||
import type { OctreeReadyHandler } from "@/types/three/three";
|
||||
|
||||
@@ -27,6 +27,7 @@ export function useOctreeGraphNode(
|
||||
|
||||
const octree = new Octree();
|
||||
octree.fromGraphNode(graphNode);
|
||||
|
||||
onOctreeReady(octree);
|
||||
}, [enabled, graphNodeRef, onOctreeReady, rebuildKey]);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user