Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7b4a07e41 | |||
| 89044a18ec | |||
| 95ca1bbfde | |||
| 093ffd726d | |||
| 4728690a11 | |||
| 343a122c06 | |||
| fb466a63cb | |||
| a75c3fd896 | |||
| 603e521714 | |||
| 49ef8f58b4 | |||
| 0a322acf88 | |||
| a397febd52 | |||
| c15cad2ab0 | |||
| 011e7815a2 | |||
| 970253801a | |||
| 246da0019a | |||
| 09a9471814 | |||
| 6e9318457a | |||
| 54a353de03 | |||
| 8b619bfc28 | |||
| 4faa226326 | |||
| dd66966507 | |||
| 5893afe42a | |||
| 1ead7ab3a7 | |||
| 047c58678b | |||
| ed9051b0dc | |||
| 08be6bee48 | |||
| ce0eb90321 | |||
| 96d7ec7fc0 | |||
| 9ab4b4a002 | |||
| d13dd0fda0 | |||
| fbedb90bca | |||
| cff7744ad9 |
@@ -80,8 +80,8 @@ jobs:
|
|||||||
|
|
||||||
- name: 📏 Check bundle size
|
- name: 📏 Check bundle size
|
||||||
run: |
|
run: |
|
||||||
# Check generated app assets only; public/ model files are runtime assets copied to dist.
|
# Check generated JS/CSS bundles only; public runtime assets are copied to dist/assets too.
|
||||||
SIZE=$(du -k dist/assets | cut -f1)
|
SIZE=$(node -e "const fs=require('fs');const path=require('path');function walk(dir){return fs.readdirSync(dir,{withFileTypes:true}).flatMap((entry)=>{const file=path.join(dir,entry.name);return entry.isDirectory()?walk(file):file;});}const bytes=walk('dist/assets').filter((file)=>/\.(js|css)$/.test(file)).reduce((sum,file)=>sum+fs.statSync(file).size,0);console.log(Math.ceil(bytes/1024));")
|
||||||
echo "Bundle size: ${SIZE}KB"
|
echo "Bundle size: ${SIZE}KB"
|
||||||
|
|
||||||
THRESHOLD=5000
|
THRESHOLD=5000
|
||||||
|
|||||||
@@ -110,6 +110,12 @@ npm run format:check
|
|||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Regenerate runtime map data after editing `public/map_raw.json`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run map:transform
|
||||||
|
```
|
||||||
|
|
||||||
## Optional Hand-Tracking Backend
|
## Optional Hand-Tracking Backend
|
||||||
|
|
||||||
The app can use the local Python backend, but the default debug source is browser-side MediaPipe.
|
The app can use the local Python backend, but the default debug source is browser-side MediaPipe.
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ Phrase à retenir :
|
|||||||
|
|
||||||
Piège à connaître :
|
Piège à connaître :
|
||||||
|
|
||||||
`useRepairMovementLocked()` retourne actuellement `false`. Le lock de mouvement est prévu dans le code et l'UI, mais il est désactivé sur `develop`.
|
`useRepairMovementLocked()` lit maintenant l'étape de mission active et verrouille le déplacement pendant les phases de réparation qui doivent immobiliser le joueur.
|
||||||
|
|
||||||
### Interaction
|
### Interaction
|
||||||
|
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ If `model.glb` and `model.gltf` are both missing, the editor renders a fallback
|
|||||||
2. `useEditorSceneData` calls `loadMapSceneData()`.
|
2. `useEditorSceneData` calls `loadMapSceneData()`.
|
||||||
3. `loadMapSceneData()` loads `/map.json` and available model URLs.
|
3. `loadMapSceneData()` loads `/map.json` and available model URLs.
|
||||||
4. If `/map.json` is missing, the page displays a folder-upload flow.
|
4. If `/map.json` is missing, the page displays a folder-upload flow.
|
||||||
5. `EditorSceneLoadingTracker` uses drei `useProgress()` to update the fullscreen editor loading overlay while models load.
|
5. The route-level loading overlay reports map JSON loading, then hands off to the editor scene once the map payload is ready.
|
||||||
6. `EditorScene` renders the grid, lights, camera controls, and map nodes inside `Suspense`.
|
6. `EditorScene` renders the grid, lights, camera controls, and map nodes inside `Suspense`.
|
||||||
7. `EditorControls` exposes transform mode, terrain snap, terrain-selection lock, add/delete node, precise scale inputs, history actions, camera focus/reset, export, save, JSON preview, selection lock, multi-selection status, and the cinematic/dialogue/SRT editors.
|
7. `EditorControls` exposes transform mode, terrain snap, terrain-selection lock, add/delete node, precise scale inputs, history actions, camera focus/reset, export, save, JSON preview, selection lock, multi-selection status, and the cinematic/dialogue/SRT editors.
|
||||||
|
|
||||||
@@ -150,14 +150,13 @@ The dev-only `/api/save-map` endpoint is implemented by the Vite plugin in `vite
|
|||||||
|
|
||||||
## Editor Loading Overlay
|
## Editor Loading Overlay
|
||||||
|
|
||||||
The editor uses `SceneLoadingOverlay` like the runtime scene. `EditorSceneLoadingTracker` lives in `src/pages/editor/page.tsx` and reads drei `useProgress()` inside the canvas.
|
The editor uses `SceneLoadingOverlay` like the runtime scene for the route-level map JSON loading phase.
|
||||||
|
|
||||||
The route tracks two loading phases:
|
The route tracks the map JSON loading phase:
|
||||||
|
|
||||||
- map JSON loading through `useEditorSceneData()`
|
- map JSON loading through `useEditorSceneData()`
|
||||||
- model loading through `useProgress()`
|
|
||||||
|
|
||||||
The overlay is rendered outside the canvas so it remains visible while the R3F scene mounts. The scene itself is wrapped in `Suspense` with a `null` fallback; the visual feedback is handled by the overlay instead of by the canvas fallback.
|
The overlay is rendered outside the canvas so it remains visible while the editor route mounts. Model loading is left to R3F `Suspense` boundaries to avoid progress updates during model render.
|
||||||
|
|
||||||
## Panel Groups
|
## Panel Groups
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ These vegetation and crop assets account for almost all of the current `~69M` tr
|
|||||||
|
|
||||||
## Debug Performance Controls
|
## Debug Performance Controls
|
||||||
|
|
||||||
The next useful runtime tool is a debug-only performance folder that can isolate model families. This should be mounted only when `?debug` is enabled.
|
The debug-only performance folder can isolate model families when `?debug` is enabled.
|
||||||
|
|
||||||
Proposed controls:
|
Proposed controls:
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ The store owns the `missionFlow` slice:
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
missionFlow: {
|
missionFlow: {
|
||||||
step: GameStep;
|
|
||||||
activityCity: boolean;
|
activityCity: boolean;
|
||||||
playerName: string;
|
playerName: string;
|
||||||
canMove: boolean;
|
canMove: boolean;
|
||||||
@@ -31,14 +30,14 @@ Managers stay responsible for local runtime services:
|
|||||||
- `AudioManager` owns audio elements, audio pools, music playback, category volume, and stereo pan.
|
- `AudioManager` owns audio elements, audio pools, music playback, category volume, and stereo pan.
|
||||||
- `InteractionManager` owns transient focused/nearby/held interaction handles.
|
- `InteractionManager` owns transient focused/nearby/held interaction handles.
|
||||||
|
|
||||||
Mission progression is not owned by a manager. Components update the store through explicit actions such as `setFlowStep`, `setCanMove`, `showDialog`, and `hideDialog`.
|
Mission progression is not owned by a manager. Components update the store through explicit actions such as `setIntroStep`, `setCanMove`, `showDialog`, and `hideDialog`.
|
||||||
|
|
||||||
## Runtime Components
|
## Runtime Components
|
||||||
|
|
||||||
- `src/components/game/GameFlow.tsx` reacts to `missionFlow.step` and triggers one-off side effects such as intro audio and movement unlocks.
|
- `src/components/game/GameFlow.tsx` reacts to intro state and triggers one-off side effects such as intro audio and movement unlocks.
|
||||||
- `src/components/zone/ZoneDetection.tsx` reads the camera position and moves the flow to a target step when the player enters a configured zone.
|
- `src/components/zone/ZoneDetection.tsx` reads the camera position and moves the flow to a target step when the player enters a configured zone.
|
||||||
- `src/world/GameStageContent.tsx` mounts repair games and their mission-start triggers.
|
- `src/world/GameStageContent.tsx` mounts repair games and their mission-start triggers.
|
||||||
- `src/pages/page.tsx` mounts mission HTML overlays: `IntroUI`, `BienvenueDisplay`, and `DialogMessage`.
|
- `src/pages/page.tsx` mounts mission HTML overlays: `IntroUI`, `DialogMessage`, and subtitles.
|
||||||
- `src/world/player/PlayerController.tsx` reads `missionFlow.canMove` as an additional movement lock.
|
- `src/world/player/PlayerController.tsx` reads `missionFlow.canMove` as an additional movement lock.
|
||||||
|
|
||||||
## Step Sequence
|
## Step Sequence
|
||||||
|
|||||||
Generated
+1
@@ -22,6 +22,7 @@
|
|||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"three": "0.182.0",
|
"three": "0.182.0",
|
||||||
|
"three-stdlib": "^2.36.1",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
|
"map:transform": "node scripts/transformMap.cjs",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc -b"
|
"typecheck": "tsc -b"
|
||||||
},
|
},
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"three": "0.182.0",
|
"three": "0.182.0",
|
||||||
|
"three-stdlib": "^2.36.1",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
+2
-1
@@ -39565,7 +39565,8 @@
|
|||||||
"rotation": [0, 0.0027, 0.0819],
|
"rotation": [0, 0.0027, 0.0819],
|
||||||
"scale": [1, 1, 1]
|
"scale": [1, 1, 1]
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"id": "repair:pylon"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "pylone",
|
"name": "pylone",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,8 @@ const IDENTITY_NODE = {
|
|||||||
rotation: [0, 0, 0],
|
rotation: [0, 0, 0],
|
||||||
scale: [1, 1, 1],
|
scale: [1, 1, 1],
|
||||||
};
|
};
|
||||||
|
const REPAIR_PYLON_ANCHOR_ID = "repair:pylon";
|
||||||
|
const REPAIR_PYLON_FALLBACK_POSITION = [64, 0, -66];
|
||||||
const MAX_MESH_Y_PLACEMENT_OFFSET = 2;
|
const MAX_MESH_Y_PLACEMENT_OFFSET = 2;
|
||||||
const RAW_INDEX = {
|
const RAW_INDEX = {
|
||||||
directionGroup: 5,
|
directionGroup: 5,
|
||||||
@@ -55,6 +57,7 @@ const RAW_RANGES = {
|
|||||||
|
|
||||||
function cloneNode(node) {
|
function cloneNode(node) {
|
||||||
return {
|
return {
|
||||||
|
...(node.id ? { id: node.id } : {}),
|
||||||
name: node.name,
|
name: node.name,
|
||||||
type: node.type,
|
type: node.type,
|
||||||
position: node.position,
|
position: node.position,
|
||||||
@@ -63,6 +66,60 @@ function cloneNode(node) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isOriginPosition(position) {
|
||||||
|
return position.every((value) => Math.abs(value) < 0.0001);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasDistinctPylonTransform(node) {
|
||||||
|
return (
|
||||||
|
node.rotation.some((value) => Math.abs(value) > 0.0001) ||
|
||||||
|
node.scale.some((value) => Math.abs(value - 1) > 0.0001)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function distanceToPosition(node, position) {
|
||||||
|
return Math.hypot(
|
||||||
|
node.position[0] - position[0],
|
||||||
|
node.position[2] - position[2],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectMapNodes(root, predicate) {
|
||||||
|
const results = [];
|
||||||
|
const stack = [root];
|
||||||
|
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const node = stack.pop();
|
||||||
|
if (predicate(node)) {
|
||||||
|
results.push(node);
|
||||||
|
}
|
||||||
|
stack.push(...(node.children ?? []));
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignRepairPylonAnchorId(root) {
|
||||||
|
const pylones = collectMapNodes(
|
||||||
|
root,
|
||||||
|
(node) =>
|
||||||
|
node.name === "pylone" &&
|
||||||
|
node.type === "Object3D" &&
|
||||||
|
!isOriginPosition(node.position),
|
||||||
|
);
|
||||||
|
const distinctPylones = pylones.filter(hasDistinctPylonTransform);
|
||||||
|
const candidates = distinctPylones.length > 0 ? distinctPylones : pylones;
|
||||||
|
if (candidates.length === 0) return;
|
||||||
|
|
||||||
|
const anchor = [...candidates].sort(
|
||||||
|
(a, b) =>
|
||||||
|
distanceToPosition(a, REPAIR_PYLON_FALLBACK_POSITION) -
|
||||||
|
distanceToPosition(b, REPAIR_PYLON_FALLBACK_POSITION),
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
anchor.id = REPAIR_PYLON_ANCHOR_ID;
|
||||||
|
}
|
||||||
|
|
||||||
function createGroup(name, sourceNode) {
|
function createGroup(name, sourceNode) {
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
@@ -434,6 +491,8 @@ function transformMap() {
|
|||||||
blocking.children.push(unclassified);
|
blocking.children.push(unclassified);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assignRepairPylonAnchorId(scene);
|
||||||
|
|
||||||
fs.writeFileSync(OUTPUT_PATH, JSON.stringify(scene, null, 2));
|
fs.writeFileSync(OUTPUT_PATH, JSON.stringify(scene, null, 2));
|
||||||
console.log(`Written hierarchical map to ${OUTPUT_PATH}`);
|
console.log(`Written hierarchical map to ${OUTPUT_PATH}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import { useEffect, useRef, useState, useMemo } from "react";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { useFrame, useThree } from "@react-three/fiber";
|
||||||
|
import { EbikeGPSMap } from "@/components/ebike/EbikeGPSMap";
|
||||||
|
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 { animateCameraTransformTransition } from "@/world/GameCinematics";
|
||||||
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
|
import { PLAYER_EYE_HEIGHT } from "@/data/player/playerConfig";
|
||||||
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
|
||||||
|
const EBIKE_MODEL_PATH = "/models/ebike/model.gltf";
|
||||||
|
|
||||||
|
export interface CameraTransform {
|
||||||
|
position: Vector3Tuple;
|
||||||
|
rotation: Vector3Tuple;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EBIKE_CAMERA_TRANSFORM: CameraTransform = {
|
||||||
|
position: [-3.5, 6, 0],
|
||||||
|
rotation: [-10, -90, 0],
|
||||||
|
};
|
||||||
|
|
||||||
|
const EBIKE_DROP_PLAYER_TRANSFORM: CameraTransform = {
|
||||||
|
position: [0, 1.5, -3],
|
||||||
|
rotation: [0, 0, 0],
|
||||||
|
};
|
||||||
|
|
||||||
|
interface EbikeProps {
|
||||||
|
position: Vector3Tuple;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||||
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
|
const { scene } = useLoggedGLTF(EBIKE_MODEL_PATH, {
|
||||||
|
scope: "Ebike",
|
||||||
|
position: position,
|
||||||
|
});
|
||||||
|
const model = useClonedObject(scene);
|
||||||
|
const movementMode = useGameStore((state) => state.player.movementMode);
|
||||||
|
const mainState = useGameStore((state) => state.mainState);
|
||||||
|
const camera = useThree((state) => state.camera);
|
||||||
|
|
||||||
|
// Map active mainState to target repair zone coordinate
|
||||||
|
const destPos = useMemo(() => {
|
||||||
|
switch (mainState) {
|
||||||
|
case "ebike":
|
||||||
|
return { x: 8, y: 0, z: -6 };
|
||||||
|
case "pylon":
|
||||||
|
return { x: 64, y: 0, z: -66 };
|
||||||
|
case "farm":
|
||||||
|
return { x: -24, y: 0, z: 42 };
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}, [mainState]);
|
||||||
|
|
||||||
|
// Throttled GPS start position to optimize pathfinding A* algorithm execution
|
||||||
|
const [gpsStartPos, setGpsStartPos] = useState<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
}>({
|
||||||
|
x: position[0],
|
||||||
|
y: position[1],
|
||||||
|
z: position[2],
|
||||||
|
});
|
||||||
|
const lastGpsUpdatePos = useRef<THREE.Vector3>(
|
||||||
|
new THREE.Vector3(...position),
|
||||||
|
);
|
||||||
|
|
||||||
|
const restingPosition = useRef<Vector3Tuple>([
|
||||||
|
position[0],
|
||||||
|
position[1] - PLAYER_EYE_HEIGHT,
|
||||||
|
position[2],
|
||||||
|
]);
|
||||||
|
const restingRotation = useRef<number>(0);
|
||||||
|
const forkRef = useRef<THREE.Object3D | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (model) {
|
||||||
|
const fork = model.getObjectByName("fourche");
|
||||||
|
if (fork) {
|
||||||
|
forkRef.current = fork;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [model]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(window as any).ebikeVisualGroup = groupRef;
|
||||||
|
(window as any).ebikeParkedPosition = restingPosition.current;
|
||||||
|
(window as any).ebikeParkedRotation = restingRotation.current;
|
||||||
|
return () => {
|
||||||
|
(window as any).ebikeVisualGroup = null;
|
||||||
|
(window as any).ebikeParkedPosition = null;
|
||||||
|
(window as any).ebikeParkedRotation = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useFrame((_, delta) => {
|
||||||
|
if (groupRef.current) {
|
||||||
|
if (movementMode === "ebike") {
|
||||||
|
restingPosition.current = [
|
||||||
|
groupRef.current.position.x,
|
||||||
|
groupRef.current.position.y,
|
||||||
|
groupRef.current.position.z,
|
||||||
|
];
|
||||||
|
restingRotation.current = groupRef.current.rotation.y;
|
||||||
|
|
||||||
|
// Smoothly rotate the front fork ("fourche") up to 15 degrees in its own Z axis
|
||||||
|
const steerFactor = (window as any).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,
|
||||||
|
12 * delta,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throttled GPS start position update to prevent performance loss
|
||||||
|
const currentPos = groupRef.current.position;
|
||||||
|
if (currentPos.distanceTo(lastGpsUpdatePos.current) > 2.0) {
|
||||||
|
lastGpsUpdatePos.current.copy(currentPos);
|
||||||
|
setGpsStartPos({ x: currentPos.x, y: currentPos.y, z: currentPos.z });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
groupRef.current.position.set(...restingPosition.current);
|
||||||
|
groupRef.current.rotation.set(0, restingRotation.current, 0);
|
||||||
|
|
||||||
|
// Reset fork rotation when parked
|
||||||
|
if (forkRef.current) {
|
||||||
|
forkRef.current.rotation.z = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(window as any).ebikeParkedPosition = restingPosition.current;
|
||||||
|
(window as any).ebikeParkedRotation = restingRotation.current;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const camPointPos: Vector3Tuple = [
|
||||||
|
restingPosition.current[0] + EBIKE_CAMERA_TRANSFORM.position[0],
|
||||||
|
restingPosition.current[1] + EBIKE_CAMERA_TRANSFORM.position[1],
|
||||||
|
restingPosition.current[2] + EBIKE_CAMERA_TRANSFORM.position[2],
|
||||||
|
];
|
||||||
|
const dropPointPos: Vector3Tuple = [
|
||||||
|
restingPosition.current[0] + EBIKE_DROP_PLAYER_TRANSFORM.position[0],
|
||||||
|
restingPosition.current[1] + EBIKE_DROP_PLAYER_TRANSFORM.position[1],
|
||||||
|
restingPosition.current[2] + EBIKE_DROP_PLAYER_TRANSFORM.position[2],
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleInteract = (): void => {
|
||||||
|
if (movementMode === "walk") {
|
||||||
|
const cameraOffset = new THREE.Vector3(
|
||||||
|
...EBIKE_CAMERA_TRANSFORM.position,
|
||||||
|
);
|
||||||
|
cameraOffset.applyAxisAngle(
|
||||||
|
new THREE.Vector3(0, 1, 0),
|
||||||
|
restingRotation.current,
|
||||||
|
);
|
||||||
|
|
||||||
|
const targetCamPos: Vector3Tuple = [
|
||||||
|
restingPosition.current[0] + cameraOffset.x,
|
||||||
|
restingPosition.current[1] + cameraOffset.y,
|
||||||
|
restingPosition.current[2] + cameraOffset.z,
|
||||||
|
];
|
||||||
|
|
||||||
|
const targetRotation: Vector3Tuple = [
|
||||||
|
EBIKE_CAMERA_TRANSFORM.rotation[0],
|
||||||
|
EBIKE_CAMERA_TRANSFORM.rotation[1] +
|
||||||
|
THREE.MathUtils.radToDeg(restingRotation.current),
|
||||||
|
EBIKE_CAMERA_TRANSFORM.rotation[2],
|
||||||
|
];
|
||||||
|
|
||||||
|
animateCameraTransformTransition(targetCamPos, targetRotation, 1, () => {
|
||||||
|
useGameStore.getState().setPlayerMovementMode("ebike");
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const currentPos = new THREE.Vector3();
|
||||||
|
if (groupRef.current) {
|
||||||
|
groupRef.current.getWorldPosition(currentPos);
|
||||||
|
} else {
|
||||||
|
currentPos.set(...position);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetCamPos: Vector3Tuple = [
|
||||||
|
currentPos.x + EBIKE_DROP_PLAYER_TRANSFORM.position[0],
|
||||||
|
currentPos.y + EBIKE_DROP_PLAYER_TRANSFORM.position[1],
|
||||||
|
currentPos.z + EBIKE_DROP_PLAYER_TRANSFORM.position[2],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Get camera's current rotation in degrees so we keep the exact orientation during dismount
|
||||||
|
const currentEuler = new THREE.Euler().setFromQuaternion(
|
||||||
|
camera.quaternion,
|
||||||
|
"YXZ",
|
||||||
|
);
|
||||||
|
const targetRotation: Vector3Tuple = [
|
||||||
|
THREE.MathUtils.radToDeg(currentEuler.x),
|
||||||
|
THREE.MathUtils.radToDeg(currentEuler.y),
|
||||||
|
THREE.MathUtils.radToDeg(currentEuler.z),
|
||||||
|
];
|
||||||
|
|
||||||
|
animateCameraTransformTransition(targetCamPos, targetRotation, 1, () => {
|
||||||
|
useGameStore.getState().setPlayerMovementMode("walk");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInteractRef = useRef(handleInteract);
|
||||||
|
handleInteractRef.current = handleInteract;
|
||||||
|
|
||||||
|
const debugRef = useRef({ showCameraPoints: true });
|
||||||
|
const debugActions = useRef({
|
||||||
|
toggleRide: () => {
|
||||||
|
handleInteractRef.current();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useDebugFolder("Ebike", (folder) => {
|
||||||
|
folder
|
||||||
|
.add(debugRef.current, "showCameraPoints")
|
||||||
|
.name("Show Camera Points")
|
||||||
|
.onChange((value: boolean) => {
|
||||||
|
debugRef.current.showCameraPoints = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
folder.add(debugActions.current, "toggleRide").name("Monter / Descendre");
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<group ref={groupRef} position={position}>
|
||||||
|
<primitive object={model} />
|
||||||
|
<InteractableObject
|
||||||
|
kind="trigger"
|
||||||
|
label={
|
||||||
|
movementMode === "walk" ? "Monter sur le bike" : "Descendre du bike"
|
||||||
|
}
|
||||||
|
position={position}
|
||||||
|
radius={15}
|
||||||
|
onPress={handleInteract}
|
||||||
|
>
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[10, 13, 2]} />
|
||||||
|
<meshBasicMaterial colorWrite={false} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
</InteractableObject>
|
||||||
|
|
||||||
|
{/* Dynamic 3D GPS Dashboard Screen */}
|
||||||
|
<group position={[0, 7, 0]} rotation={[0, 90, 0]}>
|
||||||
|
<EbikeGPSMap
|
||||||
|
width={0.8}
|
||||||
|
height={0.8}
|
||||||
|
startPos={gpsStartPos}
|
||||||
|
destPos={destPos}
|
||||||
|
mapImageUrl="/assets/gps/map_background.png"
|
||||||
|
worldBounds={{
|
||||||
|
minX: -166,
|
||||||
|
maxX: 163,
|
||||||
|
minZ: -142,
|
||||||
|
maxZ: 138,
|
||||||
|
}}
|
||||||
|
zoom={4}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
{debugRef.current.showCameraPoints && (
|
||||||
|
<>
|
||||||
|
<mesh position={camPointPos}>
|
||||||
|
<sphereGeometry args={[0.3, 16, 16]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="yellow"
|
||||||
|
emissive="yellow"
|
||||||
|
emissiveIntensity={0.5}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
<mesh position={dropPointPos}>
|
||||||
|
<sphereGeometry args={[0.3, 16, 16]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="cyan"
|
||||||
|
emissive="cyan"
|
||||||
|
emissiveIntensity={0.5}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,497 @@
|
|||||||
|
import React, { useRef, useEffect, useState, useMemo } from "react";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import {
|
||||||
|
findClosestWaypoint,
|
||||||
|
findWaypointPath,
|
||||||
|
} from "@/pathfinding/WaypointAStar";
|
||||||
|
import type { Waypoint } from "@/pathfinding/types";
|
||||||
|
function computeImageSource(
|
||||||
|
img: HTMLImageElement | HTMLCanvasElement,
|
||||||
|
baseBounds: { minX: number; maxX: number; minZ: number; maxZ: number },
|
||||||
|
bounds: { minX: number; maxX: number; minZ: number; maxZ: number },
|
||||||
|
) {
|
||||||
|
const imgW = img.width;
|
||||||
|
const imgH = img.height;
|
||||||
|
|
||||||
|
const baseW = baseBounds.maxX - baseBounds.minX;
|
||||||
|
const baseH = baseBounds.maxZ - baseBounds.minZ;
|
||||||
|
|
||||||
|
if (baseW === 0 || baseH === 0) {
|
||||||
|
return { sx: 0, sy: 0, sW: imgW, sH: imgH };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sx = ((bounds.minX - baseBounds.minX) / baseW) * imgW;
|
||||||
|
const sy = ((bounds.minZ - baseBounds.minZ) / baseH) * imgH;
|
||||||
|
const sW = ((bounds.maxX - bounds.minX) / baseW) * imgW;
|
||||||
|
const sH = ((bounds.maxZ - bounds.minZ) / baseH) * imgH;
|
||||||
|
|
||||||
|
return { sx, sy, sW, sH };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EbikeGPSMapProps {
|
||||||
|
/**
|
||||||
|
* 3D world position of the player/bike (GPS start point)
|
||||||
|
* If omitted, snaps to [0,0,0]
|
||||||
|
*/
|
||||||
|
startPos?: { x: number; y: number; z: number } | undefined;
|
||||||
|
destPos?: { x: number; y: number; z: number } | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional custom URL to the map background texture.
|
||||||
|
* If not provided, renders a high-tech minimalist neon blueprint map dynamically.
|
||||||
|
*/
|
||||||
|
mapImageUrl?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional explicit bounds for mapping coordinates.
|
||||||
|
* If omitted, bounds are calculated automatically to perfectly fit the road network!
|
||||||
|
*/
|
||||||
|
worldBounds?: {
|
||||||
|
minX: number;
|
||||||
|
maxX: number;
|
||||||
|
minZ: number;
|
||||||
|
maxZ: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Width of the 3D plane mesh (default: 1)
|
||||||
|
*/
|
||||||
|
width?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Height of the 3D plane mesh (default: 1)
|
||||||
|
*/
|
||||||
|
height?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional world position for the GPS screen (defaults to origin)
|
||||||
|
*/
|
||||||
|
position?: [number, number, number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolution of the offscreen canvas used for the map texture.
|
||||||
|
* Higher values yield sharper rendering at the cost of GPU memory.
|
||||||
|
* Default: 1024 (1024×1024 px)
|
||||||
|
*/
|
||||||
|
canvasSize?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zoom level applied to the map view.
|
||||||
|
* 1 = full world bounds, 2 = 2× zoom-in centred on the player, etc.
|
||||||
|
* Values < 1 zoom out beyond the calculated world bounds.
|
||||||
|
* Default: 1
|
||||||
|
*/
|
||||||
|
zoom?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EbikeGPSMap
|
||||||
|
* A premium, state-of-the-art 3D GPS navigation screen for the Ebike.
|
||||||
|
* Loads the road network, runs A* pathfinding, and renders a glowing, animated
|
||||||
|
* orange path over a sleek high-tech map background.
|
||||||
|
*/
|
||||||
|
export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
|
||||||
|
startPos = { x: 0, y: 0, z: 0 },
|
||||||
|
destPos,
|
||||||
|
mapImageUrl,
|
||||||
|
worldBounds,
|
||||||
|
width = 1,
|
||||||
|
height = 1,
|
||||||
|
position = [0, 0, 0],
|
||||||
|
canvasSize = 1024,
|
||||||
|
zoom = 1,
|
||||||
|
}) => {
|
||||||
|
const [waypoints, setWaypoints] = useState<Waypoint[]>([]);
|
||||||
|
const [mapImage, setMapImage] = useState<
|
||||||
|
HTMLImageElement | HTMLCanvasElement | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
// Offscreen high-res canvas for crystal clear rendering
|
||||||
|
const [offscreenCanvas] = useState(() => {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = canvasSize;
|
||||||
|
canvas.height = canvasSize;
|
||||||
|
return canvas;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resize the canvas whenever canvasSize changes
|
||||||
|
useEffect(() => {
|
||||||
|
offscreenCanvas.width = canvasSize;
|
||||||
|
offscreenCanvas.height = canvasSize;
|
||||||
|
if (textureRef.current) {
|
||||||
|
textureRef.current.needsUpdate = true;
|
||||||
|
}
|
||||||
|
}, [canvasSize, offscreenCanvas]);
|
||||||
|
|
||||||
|
const textureRef = useRef<THREE.CanvasTexture | null>(null);
|
||||||
|
const animTimeRef = useRef<number>(0);
|
||||||
|
|
||||||
|
// Load waypoints (localStorage with /roadNetwork.json fallback)
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = localStorage.getItem("la-fabrik-waypoints");
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||||
|
setWaypoints(parsed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
"[GPS Component] Error loading local storage waypoints",
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to static roadNetwork.json
|
||||||
|
fetch("/roadNetwork.json")
|
||||||
|
.then((res) => {
|
||||||
|
if (res.ok) return res.json();
|
||||||
|
throw new Error("Not found");
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
setWaypoints(data);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log("[GPS Component] No default road network found.", err);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Pre-load background map image (standard HTML5 Image loader)
|
||||||
|
// Since the user's PNG is already transparent, we don't need fetch or pixel manipulation!
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapImageUrl) {
|
||||||
|
setMapImage(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
setMapImage(img);
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
console.warn(
|
||||||
|
`[GPS Component] Failed to load map background image from ${mapImageUrl}. Falling back to dynamic vector map.`,
|
||||||
|
);
|
||||||
|
setMapImage(null);
|
||||||
|
};
|
||||||
|
img.src = mapImageUrl;
|
||||||
|
}, [mapImageUrl]);
|
||||||
|
|
||||||
|
// Determine grid boundaries (before zoom)
|
||||||
|
const baseBounds = useMemo(() => {
|
||||||
|
if (worldBounds) return worldBounds;
|
||||||
|
|
||||||
|
if (waypoints.length === 0) {
|
||||||
|
return { minX: -200, maxX: 200, minZ: -200, maxZ: 200 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const xs = waypoints.map((w) => w.x);
|
||||||
|
const zs = waypoints.map((w) => w.z);
|
||||||
|
const minX = Math.min(...xs);
|
||||||
|
const maxX = Math.max(...xs);
|
||||||
|
const minZ = Math.min(...zs);
|
||||||
|
const maxZ = Math.max(...zs);
|
||||||
|
|
||||||
|
// Padding (15% to ensure full view breathing room)
|
||||||
|
const padX = (maxX - minX) * 0.15 || 40;
|
||||||
|
const padZ = (maxZ - minZ) * 0.15 || 40;
|
||||||
|
|
||||||
|
return {
|
||||||
|
minX: minX - padX,
|
||||||
|
maxX: maxX + padX,
|
||||||
|
minZ: minZ - padZ,
|
||||||
|
maxZ: maxZ + padZ,
|
||||||
|
};
|
||||||
|
}, [waypoints, worldBounds]);
|
||||||
|
|
||||||
|
// Apply zoom: shrink the view window around the player position
|
||||||
|
const bounds = useMemo(() => {
|
||||||
|
const clampedZoom = Math.max(0.1, zoom);
|
||||||
|
if (clampedZoom === 1) return baseBounds;
|
||||||
|
|
||||||
|
const centerX = startPos.x;
|
||||||
|
const centerZ = startPos.z;
|
||||||
|
const halfW = (baseBounds.maxX - baseBounds.minX) / 2 / clampedZoom;
|
||||||
|
const halfH = (baseBounds.maxZ - baseBounds.minZ) / 2 / clampedZoom;
|
||||||
|
|
||||||
|
return {
|
||||||
|
minX: centerX - halfW,
|
||||||
|
maxX: centerX + halfW,
|
||||||
|
minZ: centerZ - halfH,
|
||||||
|
maxZ: centerZ + halfH,
|
||||||
|
};
|
||||||
|
}, [baseBounds, zoom, startPos]);
|
||||||
|
|
||||||
|
// Snapped positions
|
||||||
|
const startPosSnapped = useMemo(() => {
|
||||||
|
if (waypoints.length === 0) return null;
|
||||||
|
return findClosestWaypoint(waypoints, startPos);
|
||||||
|
}, [waypoints, startPos]);
|
||||||
|
|
||||||
|
const destPosSnapped = useMemo(() => {
|
||||||
|
if (!destPos || waypoints.length === 0) return null;
|
||||||
|
return findClosestWaypoint(waypoints, destPos);
|
||||||
|
}, [waypoints, destPos]);
|
||||||
|
|
||||||
|
// Calculated active A* route
|
||||||
|
const activePath = useMemo(() => {
|
||||||
|
if (!startPosSnapped || !destPosSnapped || waypoints.length === 0)
|
||||||
|
return [];
|
||||||
|
return findWaypointPath(waypoints, startPosSnapped, destPosSnapped);
|
||||||
|
}, [waypoints, startPosSnapped, destPosSnapped]);
|
||||||
|
|
||||||
|
// Translation helper: 3D world to Canvas pixels
|
||||||
|
const worldToCanvas = (wx: number, wz: number, canvasSize: number) => {
|
||||||
|
const { minX, maxX, minZ, maxZ } = bounds;
|
||||||
|
const px = ((wx - minX) / (maxX - minX)) * canvasSize;
|
||||||
|
const py = ((wz - minZ) / (maxZ - minZ)) * canvasSize;
|
||||||
|
return { x: px, y: py };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Draw loop
|
||||||
|
const draw = () => {
|
||||||
|
const canvas = offscreenCanvas;
|
||||||
|
const ctx = canvas.getContext("2d", {
|
||||||
|
willReadFrequently: true,
|
||||||
|
alpha: true,
|
||||||
|
});
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const size = canvas.width;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, size, size);
|
||||||
|
|
||||||
|
// 1. Draw Map Background (Image or premium blueprint vectors)
|
||||||
|
if (mapImage) {
|
||||||
|
const src = computeImageSource(mapImage, baseBounds, bounds);
|
||||||
|
const sx = Math.max(0, Math.min(mapImage.width, src.sx));
|
||||||
|
const sy = Math.max(0, Math.min(mapImage.height, src.sy));
|
||||||
|
const sW = Math.max(1, Math.min(mapImage.width - sx, src.sW));
|
||||||
|
const sH = Math.max(1, Math.min(mapImage.height - sy, src.sH));
|
||||||
|
|
||||||
|
ctx.drawImage(mapImage, sx, sy, sW, sH, 0, 0, size, size);
|
||||||
|
ctx.globalAlpha = 1.0;
|
||||||
|
} else {
|
||||||
|
// Dynamic Sci-fi background grid (Background is transparent!)
|
||||||
|
|
||||||
|
// Sci-fi subgrid
|
||||||
|
ctx.strokeStyle = "rgba(30, 41, 59, 0.4)";
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
const step = size / 32;
|
||||||
|
for (let x = 0; x < size; x += step) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, 0);
|
||||||
|
ctx.lineTo(x, size);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
for (let y = 0; y < size; y += step) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, y);
|
||||||
|
ctx.lineTo(size, y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aesthetic concentric radar topo-rings
|
||||||
|
ctx.strokeStyle = "rgba(71, 85, 105, 0.06)";
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
for (let r = size / 6; r < size; r += size / 6) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(size / 2, size / 2, r, 0, 2 * Math.PI);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Faint diagonal technical accents
|
||||||
|
ctx.strokeStyle = "rgba(56, 189, 248, 0.03)";
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, 0);
|
||||||
|
ctx.lineTo(size, size);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(size, 0);
|
||||||
|
ctx.lineTo(0, size);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Draw Active Orange Glowing Path (Neon Highway effect)
|
||||||
|
if (activePath.length > 1) {
|
||||||
|
// Pass 1: Wide transparent orange bloom
|
||||||
|
ctx.beginPath();
|
||||||
|
let pt = worldToCanvas(activePath[0]!.x, activePath[0]!.z, size);
|
||||||
|
ctx.moveTo(pt.x, pt.y);
|
||||||
|
for (let i = 1; i < activePath.length; i++) {
|
||||||
|
pt = worldToCanvas(activePath[i]!.x, activePath[i]!.z, size);
|
||||||
|
ctx.lineTo(pt.x, pt.y);
|
||||||
|
}
|
||||||
|
ctx.strokeStyle = "rgba(249, 115, 22, 0.2)"; // Faint bright orange
|
||||||
|
ctx.lineWidth = 20;
|
||||||
|
ctx.lineCap = "round";
|
||||||
|
ctx.lineJoin = "round";
|
||||||
|
ctx.shadowBlur = 30;
|
||||||
|
ctx.shadowColor = "#f97316"; // Neon Orange
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Pass 2: Saturated glow core
|
||||||
|
ctx.beginPath();
|
||||||
|
pt = worldToCanvas(activePath[0]!.x, activePath[0]!.z, size);
|
||||||
|
ctx.moveTo(pt.x, pt.y);
|
||||||
|
for (let i = 1; i < activePath.length; i++) {
|
||||||
|
pt = worldToCanvas(activePath[i]!.x, activePath[i]!.z, size);
|
||||||
|
ctx.lineTo(pt.x, pt.y);
|
||||||
|
}
|
||||||
|
ctx.strokeStyle = "#f97316"; // Vibrant orange
|
||||||
|
ctx.lineWidth = 8;
|
||||||
|
ctx.shadowBlur = 12;
|
||||||
|
ctx.shadowColor = "#ea580c";
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Pass 3: High-intensity white core
|
||||||
|
ctx.beginPath();
|
||||||
|
pt = worldToCanvas(activePath[0]!.x, activePath[0]!.z, size);
|
||||||
|
ctx.moveTo(pt.x, pt.y);
|
||||||
|
for (let i = 1; i < activePath.length; i++) {
|
||||||
|
pt = worldToCanvas(activePath[i]!.x, activePath[i]!.z, size);
|
||||||
|
ctx.lineTo(pt.x, pt.y);
|
||||||
|
}
|
||||||
|
ctx.strokeStyle = "#fff7ed"; // Cream white
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.shadowBlur = 0; // Turn off shadows for the core
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// 3. Energy Particle Pulse animation tracing the road
|
||||||
|
const segments: {
|
||||||
|
start: { x: number; y: number };
|
||||||
|
end: { x: number; y: number };
|
||||||
|
len: number;
|
||||||
|
}[] = [];
|
||||||
|
let totalLen = 0;
|
||||||
|
for (let i = 0; i < activePath.length - 1; i++) {
|
||||||
|
const p1 = worldToCanvas(activePath[i]!.x, activePath[i]!.z, size);
|
||||||
|
const p2 = worldToCanvas(
|
||||||
|
activePath[i + 1]!.x,
|
||||||
|
activePath[i + 1]!.z,
|
||||||
|
size,
|
||||||
|
);
|
||||||
|
const len = Math.sqrt(
|
||||||
|
Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2),
|
||||||
|
);
|
||||||
|
segments.push({ start: p1, end: p2, len });
|
||||||
|
totalLen += len;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalLen > 0) {
|
||||||
|
const targetLen = totalLen * animTimeRef.current;
|
||||||
|
let currentLen = 0;
|
||||||
|
let dotPt = segments[0]!.start;
|
||||||
|
|
||||||
|
for (const seg of segments) {
|
||||||
|
if (currentLen + seg.len >= targetLen) {
|
||||||
|
const ratio = (targetLen - currentLen) / seg.len;
|
||||||
|
dotPt = {
|
||||||
|
x: seg.start.x + (seg.end.x - seg.start.x) * ratio,
|
||||||
|
y: seg.start.y + (seg.end.y - seg.start.y) * ratio,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
currentLen += seg.len;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw multiple glowing pulses along the path
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(dotPt.x, dotPt.y, 8, 0, 2 * Math.PI);
|
||||||
|
ctx.fillStyle = "#ffffff";
|
||||||
|
ctx.shadowBlur = 15;
|
||||||
|
ctx.shadowColor = "#f97316";
|
||||||
|
ctx.fill();
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Draw Snap Markers (Start and End)
|
||||||
|
if (destPosSnapped) {
|
||||||
|
const pt = worldToCanvas(destPosSnapped.x, destPosSnapped.z, size);
|
||||||
|
const pulseSize = 12 + Math.sin(Date.now() * 0.007) * 4;
|
||||||
|
|
||||||
|
// Pulse ring
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(pt.x, pt.y, pulseSize, 0, 2 * Math.PI);
|
||||||
|
ctx.strokeStyle = "rgba(249, 115, 22, 0.4)";
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Solid target core
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(pt.x, pt.y, 6, 0, 2 * Math.PI);
|
||||||
|
ctx.fillStyle = "#ea580c"; // Deep target orange
|
||||||
|
ctx.strokeStyle = "#ffffff";
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startPosSnapped) {
|
||||||
|
const pt = worldToCanvas(startPosSnapped.x, startPosSnapped.z, size);
|
||||||
|
|
||||||
|
// Start Marker (Player Arrow/Dot)
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(pt.x, pt.y, 8, 0, 2 * Math.PI);
|
||||||
|
ctx.fillStyle = "#0ea5e9"; // Cool cyberpunk sky blue
|
||||||
|
ctx.strokeStyle = "#ffffff";
|
||||||
|
ctx.lineWidth = 2.5;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Tech details
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(pt.x, pt.y, 3, 0, 2 * Math.PI);
|
||||||
|
ctx.fillStyle = "#ffffff";
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Update WebGL Texture
|
||||||
|
if (textureRef.current) {
|
||||||
|
textureRef.current.needsUpdate = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 60 FPS animation ticker
|
||||||
|
useEffect(() => {
|
||||||
|
let animId: number;
|
||||||
|
const tick = () => {
|
||||||
|
animTimeRef.current += 0.004; // Slow, premium sweep speed
|
||||||
|
if (animTimeRef.current > 1) animTimeRef.current = 0;
|
||||||
|
|
||||||
|
draw();
|
||||||
|
|
||||||
|
animId = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
animId = requestAnimationFrame(tick);
|
||||||
|
return () => cancelAnimationFrame(animId);
|
||||||
|
}, [waypoints, startPos, destPos, bounds, mapImage]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh castShadow receiveShadow position={position as any}>
|
||||||
|
<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>
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Unlock,
|
Unlock,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
import { EditorCinematicManifestPanel } from "@/components/editor/EditorCinematicManifestPanel";
|
import { EditorCinematicManifestPanel } from "@/components/editor/EditorCinematicManifestPanel";
|
||||||
import { EditorDialogueManifestPanel } from "@/components/editor/EditorDialogueManifestPanel";
|
import { EditorDialogueManifestPanel } from "@/components/editor/EditorDialogueManifestPanel";
|
||||||
import { EditorSrtPanel } from "@/components/editor/EditorSrtPanel";
|
import { EditorSrtPanel } from "@/components/editor/EditorSrtPanel";
|
||||||
@@ -102,6 +103,52 @@ function EditorPanelGroup({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface EditorScaleFieldProps {
|
||||||
|
axis: 0 | 1 | 2;
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
onCommit: (axis: 0 | 1 | 2, value: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditorScaleField({
|
||||||
|
axis,
|
||||||
|
label,
|
||||||
|
onCommit,
|
||||||
|
value,
|
||||||
|
}: EditorScaleFieldProps): React.JSX.Element {
|
||||||
|
const [draftValue, setDraftValue] = useState(() =>
|
||||||
|
String(Number(value.toFixed(4))),
|
||||||
|
);
|
||||||
|
|
||||||
|
const commitDraftValue = (): void => {
|
||||||
|
const nextValue = Number(draftValue);
|
||||||
|
if (!draftValue.trim() || Number.isNaN(nextValue)) {
|
||||||
|
setDraftValue(String(Number(value.toFixed(4))));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onCommit(axis, nextValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label>
|
||||||
|
<span>{label}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={draftValue}
|
||||||
|
onBlur={commitDraftValue}
|
||||||
|
onChange={(event) => setDraftValue(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.currentTarget.blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function EditorControls({
|
export function EditorControls({
|
||||||
transformMode,
|
transformMode,
|
||||||
onTransformModeChange,
|
onTransformModeChange,
|
||||||
@@ -303,20 +350,13 @@ export function EditorControls({
|
|||||||
{selectedNodeScale ? (
|
{selectedNodeScale ? (
|
||||||
<div className="editor-scale-fields">
|
<div className="editor-scale-fields">
|
||||||
{selectedNodeScale.map((value, axis) => (
|
{selectedNodeScale.map((value, axis) => (
|
||||||
<label key={axis}>
|
<EditorScaleField
|
||||||
<span>{["X", "Y", "Z"][axis]}</span>
|
key={`${axis}:${value}`}
|
||||||
<input
|
axis={axis as 0 | 1 | 2}
|
||||||
type="number"
|
label={["X", "Y", "Z"][axis] ?? "?"}
|
||||||
step="0.01"
|
value={value}
|
||||||
value={Number(value.toFixed(4))}
|
onCommit={onSelectedScaleChange}
|
||||||
onChange={(event) =>
|
|
||||||
onSelectedScaleChange(
|
|
||||||
axis as 0 | 1 | 2,
|
|
||||||
Number(event.target.value),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
|
import {
|
||||||
import { Grid, TransformControls } from "@react-three/drei";
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
Suspense,
|
||||||
|
} from "react";
|
||||||
|
import { TransformControls } from "@react-three/drei";
|
||||||
import type { ThreeEvent } from "@react-three/fiber";
|
import type { ThreeEvent } from "@react-three/fiber";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
|
|
||||||
import { TerrainModel } from "@/components/three/world/TerrainModel";
|
import { TerrainModel } from "@/components/three/world/TerrainModel";
|
||||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||||
import { useTerrainHeightSampler } from "@/hooks/three/useTerrainHeight";
|
import {
|
||||||
|
getObjectBottomOffset,
|
||||||
|
useTerrainHeightSampler,
|
||||||
|
} from "@/hooks/three/useTerrainHeight";
|
||||||
import type { SceneData, MapNode, TransformMode } from "@/types/editor/editor";
|
import type { SceneData, MapNode, TransformMode } from "@/types/editor/editor";
|
||||||
import {
|
import {
|
||||||
isEditorVisibleMapNode,
|
isEditorVisibleMapNode,
|
||||||
@@ -94,6 +104,30 @@ function getEditorModelVisualScaleMultiplier(name: string): number {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getEditorModelVisualYOffset(
|
||||||
|
object: THREE.Object3D,
|
||||||
|
node: MapNode,
|
||||||
|
terrainHeight: ReturnType<typeof useTerrainHeightSampler>,
|
||||||
|
visualScaleMultiplier: number,
|
||||||
|
): number {
|
||||||
|
const [x, y, z] = node.position;
|
||||||
|
const height = terrainHeight.getHeight(x, z);
|
||||||
|
if (height === null) return 0;
|
||||||
|
|
||||||
|
const finalScale: [number, number, number] = [
|
||||||
|
node.scale[0] * visualScaleMultiplier,
|
||||||
|
node.scale[1] * visualScaleMultiplier,
|
||||||
|
node.scale[2] * visualScaleMultiplier,
|
||||||
|
];
|
||||||
|
const originalPosition = object.position.clone();
|
||||||
|
object.position.set(0, 0, 0);
|
||||||
|
const bottomOffset = getObjectBottomOffset(object, finalScale);
|
||||||
|
object.position.copy(originalPosition);
|
||||||
|
const parentScaleY = Math.abs(node.scale[1]) > 0.0001 ? node.scale[1] : 1;
|
||||||
|
|
||||||
|
return (height + bottomOffset - y) / parentScaleY;
|
||||||
|
}
|
||||||
|
|
||||||
function applyNodeTransform(object: THREE.Object3D, node: MapNode): void {
|
function applyNodeTransform(object: THREE.Object3D, node: MapNode): void {
|
||||||
object.position.set(...node.position);
|
object.position.set(...node.position);
|
||||||
object.rotation.set(...node.rotation);
|
object.rotation.set(...node.rotation);
|
||||||
@@ -222,7 +256,6 @@ export function EditorMap({
|
|||||||
selectedNodeIndex !== null
|
selectedNodeIndex !== null
|
||||||
? (sceneData.mapNodes[selectedNodeIndex]?.name ?? null)
|
? (sceneData.mapNodes[selectedNodeIndex]?.name ?? null)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const getTransformObject = useCallback(() => {
|
const getTransformObject = useCallback(() => {
|
||||||
if (isMultiSelection) {
|
if (isMultiSelection) {
|
||||||
return transformGroupRef.current;
|
return transformGroupRef.current;
|
||||||
@@ -407,23 +440,9 @@ export function EditorMap({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Grid
|
|
||||||
args={[100, 100]}
|
|
||||||
cellSize={1}
|
|
||||||
cellThickness={0.5}
|
|
||||||
cellColor="#242424"
|
|
||||||
sectionSize={5}
|
|
||||||
sectionThickness={1}
|
|
||||||
sectionColor="#3a3a3a"
|
|
||||||
fadeDistance={50}
|
|
||||||
fadeStrength={1}
|
|
||||||
followCamera={false}
|
|
||||||
infiniteGrid={false}
|
|
||||||
/>
|
|
||||||
<axesHelper args={[10]} />
|
|
||||||
|
|
||||||
<group>
|
<group>
|
||||||
{terrainNode ? (
|
{terrainNode ? (
|
||||||
|
<Suspense fallback={null}>
|
||||||
<EditorTerrainNode
|
<EditorTerrainNode
|
||||||
index={terrainNodeIndex}
|
index={terrainNodeIndex}
|
||||||
node={terrainNode}
|
node={terrainNode}
|
||||||
@@ -436,6 +455,7 @@ export function EditorMap({
|
|||||||
isSelectionLocked={isSelectionLocked}
|
isSelectionLocked={isSelectionLocked}
|
||||||
onHoverNode={onHoverNode}
|
onHoverNode={onHoverNode}
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
) : null}
|
) : null}
|
||||||
{sceneData.mapNodes.map((node, index) => {
|
{sceneData.mapNodes.map((node, index) => {
|
||||||
if (!shouldRenderEditorNode(node, selectedNodeName)) {
|
if (!shouldRenderEditorNode(node, selectedNodeName)) {
|
||||||
@@ -446,8 +466,23 @@ export function EditorMap({
|
|||||||
|
|
||||||
if (modelUrl) {
|
if (modelUrl) {
|
||||||
return (
|
return (
|
||||||
<EditorModelNode
|
<Suspense
|
||||||
key={index}
|
key={index}
|
||||||
|
fallback={
|
||||||
|
<EditorFallbackNode
|
||||||
|
index={index}
|
||||||
|
node={node}
|
||||||
|
isSelected={selectedIndexSet.has(index)}
|
||||||
|
isHovered={hoveredNodeIndex === index}
|
||||||
|
objectsMapRef={objectsMapRef}
|
||||||
|
onSelectNode={onSelectNode}
|
||||||
|
onToggleNodeSelection={onToggleNodeSelection}
|
||||||
|
isSelectionLocked={isSelectionLocked}
|
||||||
|
onHoverNode={onHoverNode}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<EditorModelNode
|
||||||
index={index}
|
index={index}
|
||||||
node={node}
|
node={node}
|
||||||
modelUrl={modelUrl}
|
modelUrl={modelUrl}
|
||||||
@@ -459,6 +494,7 @@ export function EditorMap({
|
|||||||
isSelectionLocked={isSelectionLocked}
|
isSelectionLocked={isSelectionLocked}
|
||||||
onHoverNode={onHoverNode}
|
onHoverNode={onHoverNode}
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
@@ -519,7 +555,18 @@ function EditorModelNode({
|
|||||||
scale: node.scale,
|
scale: node.scale,
|
||||||
});
|
});
|
||||||
const sceneInstance = useClonedObject(scene);
|
const sceneInstance = useClonedObject(scene);
|
||||||
|
const terrainHeight = useTerrainHeightSampler();
|
||||||
const visualScaleMultiplier = getEditorModelVisualScaleMultiplier(node.name);
|
const visualScaleMultiplier = getEditorModelVisualScaleMultiplier(node.name);
|
||||||
|
const visualYOffset = useMemo(
|
||||||
|
() =>
|
||||||
|
getEditorModelVisualYOffset(
|
||||||
|
sceneInstance,
|
||||||
|
node,
|
||||||
|
terrainHeight,
|
||||||
|
visualScaleMultiplier,
|
||||||
|
),
|
||||||
|
[node, sceneInstance, terrainHeight, visualScaleMultiplier],
|
||||||
|
);
|
||||||
const pointerHandlers = createEditorNodePointerHandlers(
|
const pointerHandlers = createEditorNodePointerHandlers(
|
||||||
index,
|
index,
|
||||||
onSelectNode,
|
onSelectNode,
|
||||||
@@ -588,7 +635,11 @@ function EditorModelNode({
|
|||||||
scale={node.scale}
|
scale={node.scale}
|
||||||
{...pointerHandlers}
|
{...pointerHandlers}
|
||||||
>
|
>
|
||||||
<primitive object={sceneInstance} scale={visualScaleMultiplier} />
|
<primitive
|
||||||
|
object={sceneInstance}
|
||||||
|
position={[0, visualYOffset, 0]}
|
||||||
|
scale={visualScaleMultiplier}
|
||||||
|
/>
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,30 @@
|
|||||||
import { useCallback, useEffect, useRef } from "react";
|
import { Suspense, useCallback, useEffect, useRef } from "react";
|
||||||
import { OrbitControls } from "@react-three/drei";
|
import { Grid, OrbitControls } from "@react-three/drei";
|
||||||
import { useThree } from "@react-three/fiber";
|
import { useThree } from "@react-three/fiber";
|
||||||
import gsap from "gsap";
|
import gsap from "gsap";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
|
import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
|
||||||
import { EditorMap } from "@/components/editor/scene/EditorMap";
|
import { EditorMap } from "@/components/editor/scene/EditorMap";
|
||||||
import { FlyController } from "@/controls/editor/FlyController";
|
import { FlyController } from "@/controls/editor/FlyController";
|
||||||
import { PersonnageSystem } from "@/world/personnages/PersonnageSystem";
|
import type {
|
||||||
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
|
EditorCinematicPreviewRequest,
|
||||||
import type { MapNode, TransformMode, SceneData } from "@/types/editor/editor";
|
MapNode,
|
||||||
|
TransformMode,
|
||||||
|
SceneData,
|
||||||
|
} from "@/types/editor/editor";
|
||||||
|
|
||||||
const EDITOR_CAMERA_HOME_POSITION = new THREE.Vector3(0, 50, 100);
|
const EDITOR_CAMERA_HOME_POSITION = new THREE.Vector3(0, 50, 100);
|
||||||
const EDITOR_CAMERA_HOME_TARGET = new THREE.Vector3(0, 0, 0);
|
const EDITOR_CAMERA_HOME_TARGET = new THREE.Vector3(0, 0, 0);
|
||||||
|
|
||||||
export interface EditorCinematicPreviewRequest {
|
function isEditableShortcutTarget(target: EventTarget | null): boolean {
|
||||||
id: string;
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
cinematic: CinematicDefinition;
|
|
||||||
|
return (
|
||||||
|
target instanceof HTMLInputElement ||
|
||||||
|
target instanceof HTMLTextAreaElement ||
|
||||||
|
target instanceof HTMLSelectElement ||
|
||||||
|
target.isContentEditable
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EditorSceneProps {
|
interface EditorSceneProps {
|
||||||
@@ -149,6 +158,8 @@ export function EditorScene({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (isEditableShortcutTarget(e.target)) return;
|
||||||
|
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
if (e.key === "z" || e.key === "Z") {
|
if (e.key === "z" || e.key === "Z") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -214,6 +225,22 @@ export function EditorScene({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Grid
|
||||||
|
args={[100, 100]}
|
||||||
|
cellSize={1}
|
||||||
|
cellThickness={0.5}
|
||||||
|
cellColor="#242424"
|
||||||
|
sectionSize={5}
|
||||||
|
sectionThickness={1}
|
||||||
|
sectionColor="#3a3a3a"
|
||||||
|
fadeDistance={50}
|
||||||
|
fadeStrength={1}
|
||||||
|
followCamera={false}
|
||||||
|
infiniteGrid={false}
|
||||||
|
/>
|
||||||
|
<axesHelper args={[10]} />
|
||||||
|
|
||||||
|
<Suspense fallback={null}>
|
||||||
<EditorMap
|
<EditorMap
|
||||||
sceneData={sceneData}
|
sceneData={sceneData}
|
||||||
selectedNodeIndex={selectedNodeIndex}
|
selectedNodeIndex={selectedNodeIndex}
|
||||||
@@ -232,8 +259,7 @@ export function EditorScene({
|
|||||||
snapAllToTerrainRequest={snapAllToTerrainRequest}
|
snapAllToTerrainRequest={snapAllToTerrainRequest}
|
||||||
onSnapAllToTerrain={onSnapAllToTerrain}
|
onSnapAllToTerrain={onSnapAllToTerrain}
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
<PersonnageSystem />
|
|
||||||
|
|
||||||
<ambientLight intensity={0.6} />
|
<ambientLight intensity={0.6} />
|
||||||
<directionalLight position={[10, 20, 10]} intensity={1.5} castShadow />
|
<directionalLight position={[10, 20, 10]} intensity={1.5} castShadow />
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
import { useFrame } from "@react-three/fiber";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { createNetShader } from "@/shaders/NetShader";
|
||||||
|
|
||||||
|
export function NetTest(): React.JSX.Element {
|
||||||
|
const materialRef = useRef<THREE.ShaderMaterial>(null);
|
||||||
|
|
||||||
|
useFrame((_, delta) => {
|
||||||
|
const timeUniform = materialRef.current?.uniforms.uTime;
|
||||||
|
if (timeUniform) timeUniform.value += delta;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh position={[0, 2, -3]} rotation={[0, 0, 0]}>
|
||||||
|
<planeGeometry args={[2, 2, 1, 1]} />
|
||||||
|
<primitive
|
||||||
|
object={createNetShader()}
|
||||||
|
ref={materialRef}
|
||||||
|
attach="material"
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { Component, useEffect, useMemo, useRef } from "react";
|
|||||||
import { useFrame, useThree } from "@react-three/fiber";
|
import { useFrame, useThree } from "@react-three/fiber";
|
||||||
import { useGLTF } from "@react-three/drei";
|
import { useGLTF } from "@react-three/drei";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { clone } from "three/addons/utils/SkeletonUtils.js";
|
import { SkeletonUtils } from "three-stdlib";
|
||||||
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
|
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
|
||||||
import {
|
import {
|
||||||
useHandTrackingGloveStatus,
|
useHandTrackingGloveStatus,
|
||||||
@@ -255,7 +255,7 @@ function HandTrackingGloveModel({
|
|||||||
throw new Error(`Missing glove root node ${config.rootNodeName}`);
|
throw new Error(`Missing glove root node ${config.rootNodeName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const clonedRootNode = clone(rootNode);
|
const clonedRootNode = SkeletonUtils.clone(rootNode);
|
||||||
clonedRootNode.visible = false;
|
clonedRootNode.visible = false;
|
||||||
|
|
||||||
return clonedRootNode;
|
return clonedRootNode;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
import { useEffect } from "react";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
|
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||||
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
|
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ export function SimpleModel({
|
|||||||
rotation,
|
rotation,
|
||||||
scale,
|
scale,
|
||||||
});
|
});
|
||||||
const model = useMemo(() => scene.clone(true), [scene]);
|
const model = useClonedObject(scene, { cloneResources: true });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyShadowSettings(model, castShadow, receiveShadow);
|
applyShadowSettings(model, castShadow, receiveShadow);
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { useGLTF } from "@react-three/drei";
|
||||||
|
import {
|
||||||
|
MergedStaticMapModel,
|
||||||
|
type MergedStaticMapModelProps,
|
||||||
|
} from "@/components/three/world/MergedStaticMapModel";
|
||||||
|
|
||||||
|
const LA_FABRIK_MODEL_PATH = "/models/lafabrik/model.gltf";
|
||||||
|
|
||||||
|
type LaFabrikMapModelProps = Omit<MergedStaticMapModelProps, "modelPath">;
|
||||||
|
|
||||||
|
export function LaFabrikMapModel(
|
||||||
|
props: LaFabrikMapModelProps,
|
||||||
|
): React.JSX.Element {
|
||||||
|
return <MergedStaticMapModel modelPath={LA_FABRIK_MODEL_PATH} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
useGLTF.preload(LA_FABRIK_MODEL_PATH);
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { useGLTF } from "@react-three/drei";
|
|
||||||
import {
|
|
||||||
MergedStaticMapModel,
|
|
||||||
type MergedStaticMapModelProps,
|
|
||||||
} from "@/components/three/world/MergedStaticMapModel";
|
|
||||||
|
|
||||||
const LAFABRIK_MODEL_PATH = "/models/lafabrik/model.gltf";
|
|
||||||
|
|
||||||
type LafabrikModelProps = Omit<MergedStaticMapModelProps, "modelPath">;
|
|
||||||
|
|
||||||
export function LafabrikModel(props: LafabrikModelProps): React.JSX.Element {
|
|
||||||
return <MergedStaticMapModel modelPath={LAFABRIK_MODEL_PATH} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
useGLTF.preload(LAFABRIK_MODEL_PATH);
|
|
||||||
@@ -3,8 +3,11 @@ import { useGLTF } from "@react-three/drei";
|
|||||||
import { Component, useEffect, useMemo, useRef, type ReactNode } from "react";
|
import { Component, useEffect, useMemo, useRef, type ReactNode } from "react";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||||
|
import { logger } from "@/utils/core/Logger";
|
||||||
|
|
||||||
interface SkyModelProps {
|
interface SkyModelProps {
|
||||||
|
fallbackModelScale?: number | undefined;
|
||||||
|
fallbackModelPath?: string | undefined;
|
||||||
modelPath: string;
|
modelPath: string;
|
||||||
fallbackColor?: string | undefined;
|
fallbackColor?: string | undefined;
|
||||||
scale?: number | undefined;
|
scale?: number | undefined;
|
||||||
@@ -18,6 +21,8 @@ interface SkyModelContentProps {
|
|||||||
interface SkyModelErrorBoundaryProps {
|
interface SkyModelErrorBoundaryProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
fallback: ReactNode;
|
fallback: ReactNode;
|
||||||
|
label: string;
|
||||||
|
modelPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SkyModelErrorBoundaryState {
|
interface SkyModelErrorBoundaryState {
|
||||||
@@ -41,6 +46,17 @@ class SkyModelErrorBoundary extends Component<
|
|||||||
return { hasError: true };
|
return { hasError: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error): void {
|
||||||
|
logger.warn(
|
||||||
|
"SkyModel",
|
||||||
|
`${this.props.label} model failed; using fallback`,
|
||||||
|
{
|
||||||
|
error,
|
||||||
|
modelPath: this.props.modelPath,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
render(): ReactNode {
|
render(): ReactNode {
|
||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
return this.props.fallback;
|
return this.props.fallback;
|
||||||
@@ -52,15 +68,37 @@ class SkyModelErrorBoundary extends Component<
|
|||||||
|
|
||||||
export function SkyModel({
|
export function SkyModel({
|
||||||
fallbackColor,
|
fallbackColor,
|
||||||
|
fallbackModelScale = SKY_MODEL_SCALE,
|
||||||
|
fallbackModelPath,
|
||||||
modelPath,
|
modelPath,
|
||||||
scale = SKY_MODEL_SCALE,
|
scale = SKY_MODEL_SCALE,
|
||||||
}: SkyModelProps): React.JSX.Element {
|
}: SkyModelProps): React.JSX.Element {
|
||||||
const fallback = fallbackColor ? (
|
const colorFallback = fallbackColor ? (
|
||||||
<color attach="background" args={[fallbackColor]} />
|
<color attach="background" args={[fallbackColor]} />
|
||||||
) : null;
|
) : null;
|
||||||
|
const fallback = fallbackModelPath ? (
|
||||||
|
<SkyModelErrorBoundary
|
||||||
|
key={fallbackModelPath}
|
||||||
|
fallback={colorFallback}
|
||||||
|
label="Fallback sky"
|
||||||
|
modelPath={fallbackModelPath}
|
||||||
|
>
|
||||||
|
<SkyModelContent
|
||||||
|
modelPath={fallbackModelPath}
|
||||||
|
scale={fallbackModelScale}
|
||||||
|
/>
|
||||||
|
</SkyModelErrorBoundary>
|
||||||
|
) : (
|
||||||
|
colorFallback
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SkyModelErrorBoundary key={modelPath} fallback={fallback}>
|
<SkyModelErrorBoundary
|
||||||
|
key={modelPath}
|
||||||
|
fallback={fallback}
|
||||||
|
label="Primary sky"
|
||||||
|
modelPath={modelPath}
|
||||||
|
>
|
||||||
<SkyModelContent modelPath={modelPath} scale={scale} />
|
<SkyModelContent modelPath={modelPath} scale={scale} />
|
||||||
</SkyModelErrorBoundary>
|
</SkyModelErrorBoundary>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||||
|
|
||||||
export const TEST_SCENE_FLOOR_POSITION: Vector3Tuple = [0, -0.5, 0];
|
export const TEST_SCENE_FLOOR_POSITION: Vector3Tuple = [0, -0.5, 0];
|
||||||
export const TEST_SCENE_FLOOR_SIZE: Vector3Tuple = [200, 1, 200];
|
export const TEST_SCENE_FLOOR_SIZE: Vector3Tuple = [200, 1, 200];
|
||||||
@@ -23,7 +24,7 @@ export const TEST_SCENE_TRIGGER_METALNESS = 0.5;
|
|||||||
export const TEST_SCENE_REPAIR_ZONE_MARKER_RADIUS = 1.65;
|
export const TEST_SCENE_REPAIR_ZONE_MARKER_RADIUS = 1.65;
|
||||||
export const TEST_SCENE_REPAIR_ZONE_MARKER_TUBE_RADIUS = 0.045;
|
export const TEST_SCENE_REPAIR_ZONE_MARKER_TUBE_RADIUS = 0.045;
|
||||||
|
|
||||||
export const TEST_SCENE_REPAIR_ZONES = [
|
export const GAME_REPAIR_ZONES = [
|
||||||
{
|
{
|
||||||
mission: "ebike",
|
mission: "ebike",
|
||||||
label: "E-bike",
|
label: "E-bike",
|
||||||
@@ -43,8 +44,10 @@ export const TEST_SCENE_REPAIR_ZONES = [
|
|||||||
position: [12, 0, -12],
|
position: [12, 0, -12],
|
||||||
},
|
},
|
||||||
] as const satisfies readonly {
|
] as const satisfies readonly {
|
||||||
mission: "ebike" | "pylon" | "farm";
|
mission: RepairMissionId;
|
||||||
label: string;
|
label: string;
|
||||||
color: string;
|
color: string;
|
||||||
position: Vector3Tuple;
|
position: Vector3Tuple;
|
||||||
}[];
|
}[];
|
||||||
|
|
||||||
|
export const TEST_SCENE_REPAIR_ZONES = GAME_REPAIR_ZONES;
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
|
||||||
|
export interface StageAnchorConfig {
|
||||||
|
color: string;
|
||||||
|
position: Vector3Tuple;
|
||||||
|
scale?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const INTRO_STAGE_ANCHOR: StageAnchorConfig = {
|
||||||
|
color: "#7dd3fc",
|
||||||
|
position: [0, 4, 0],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OUTRO_STAGE_ANCHOR: StageAnchorConfig = {
|
||||||
|
color: "#fb7185",
|
||||||
|
position: [0, 6, 10],
|
||||||
|
scale: 1.25,
|
||||||
|
};
|
||||||
@@ -4,7 +4,13 @@ import type {
|
|||||||
RepairMissionTriggerConfig,
|
RepairMissionTriggerConfig,
|
||||||
} from "@/types/gameplay/repairMission";
|
} from "@/types/gameplay/repairMission";
|
||||||
|
|
||||||
export const EBIKE_REPAIR_POSITION = [
|
export const REPAIR_MISSION_ANCHOR_IDS: Partial<
|
||||||
|
Record<RepairMissionId, string>
|
||||||
|
> = {
|
||||||
|
pylon: "repair:pylon",
|
||||||
|
};
|
||||||
|
|
||||||
|
const EBIKE_REPAIR_POSITION = [
|
||||||
42.2399, 4.5484, 34.6468,
|
42.2399, 4.5484, 34.6468,
|
||||||
] as const satisfies Vector3Tuple;
|
] as const satisfies Vector3Tuple;
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import type {
|
|||||||
MissionStep,
|
MissionStep,
|
||||||
RepairMissionId,
|
RepairMissionId,
|
||||||
} from "@/types/gameplay/repairMission";
|
} from "@/types/gameplay/repairMission";
|
||||||
|
import { REPAIR_MISSION_IDS } from "@/types/gameplay/repairMission";
|
||||||
|
|
||||||
const REPAIR_MISSION_IDS = ["ebike", "pylon", "farm"] as const;
|
|
||||||
const REPAIR_MISSION_ID_VALUES: ReadonlySet<string> = new Set(
|
const REPAIR_MISSION_ID_VALUES: ReadonlySet<string> = new Set(
|
||||||
REPAIR_MISSION_IDS,
|
REPAIR_MISSION_IDS,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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 = 11;
|
export const PLAYER_WALK_SPEED = 11;
|
||||||
|
export const PLAYER_EBIKE_SPEED = 25;
|
||||||
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;
|
||||||
|
|||||||
+7
-7
@@ -1,9 +1,9 @@
|
|||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
|
||||||
export type PersonnageId = "electricienne" | "gerant" | "fermier";
|
export type CharacterId = "electricienne" | "gerant" | "fermier";
|
||||||
|
|
||||||
export interface PersonnageConfig {
|
export interface CharacterConfig {
|
||||||
id: PersonnageId;
|
id: CharacterId;
|
||||||
label: string;
|
label: string;
|
||||||
modelPath: string;
|
modelPath: string;
|
||||||
position: Vector3Tuple;
|
position: Vector3Tuple;
|
||||||
@@ -13,7 +13,7 @@ export interface PersonnageConfig {
|
|||||||
defaultAnimation: string;
|
defaultAnimation: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PERSONNAGE_CONFIGS = {
|
export const CHARACTER_CONFIGS = {
|
||||||
electricienne: {
|
electricienne: {
|
||||||
id: "electricienne",
|
id: "electricienne",
|
||||||
label: "Electricienne",
|
label: "Electricienne",
|
||||||
@@ -44,10 +44,10 @@ export const PERSONNAGE_CONFIGS = {
|
|||||||
animations: ["idle", "walk"],
|
animations: ["idle", "walk"],
|
||||||
defaultAnimation: "idle",
|
defaultAnimation: "idle",
|
||||||
},
|
},
|
||||||
} satisfies Record<PersonnageId, PersonnageConfig>;
|
} satisfies Record<CharacterId, CharacterConfig>;
|
||||||
|
|
||||||
export const PERSONNAGE_IDS = [
|
export const CHARACTER_IDS = [
|
||||||
"electricienne",
|
"electricienne",
|
||||||
"gerant",
|
"gerant",
|
||||||
"fermier",
|
"fermier",
|
||||||
] as const satisfies readonly PersonnageId[];
|
] as const satisfies readonly CharacterId[];
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
export const GAME_SCENE_SKY_MODEL_PATH = "/models/skybox/model.gltf";
|
export const GAME_SCENE_SKY_MODEL_PATH = "/models/skybox/model.gltf";
|
||||||
|
export const GAME_SCENE_SKY_FALLBACK_MODEL_PATH = "/models/sky/model.glb";
|
||||||
export const GAME_SCENE_SKY_MODEL_SCALE = 100;
|
export const GAME_SCENE_SKY_MODEL_SCALE = 100;
|
||||||
|
export const GAME_SCENE_SKY_FALLBACK_MODEL_SCALE = 1;
|
||||||
export const GAME_SCENE_FALLBACK_BACKGROUND_COLOR = "#0b1018";
|
export const GAME_SCENE_FALLBACK_BACKGROUND_COLOR = "#0b1018";
|
||||||
export const PHYSICS_SCENE_BACKGROUND_COLOR = "#0b1018";
|
export const PHYSICS_SCENE_BACKGROUND_COLOR = "#0b1018";
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export const GRASS_CONFIG = {
|
|||||||
patchSize: 30,
|
patchSize: 30,
|
||||||
bladeCount: 32000,
|
bladeCount: 32000,
|
||||||
bladeWidth: 0.08,
|
bladeWidth: 0.08,
|
||||||
maxBladeHeight: 0.56,
|
maxBladeHeight: 0.67,
|
||||||
randomHeightAmount: 0.25,
|
randomHeightAmount: 0.25,
|
||||||
surfaceOffset: 0.025,
|
surfaceOffset: 0.025,
|
||||||
heightTextureSize: 128,
|
heightTextureSize: 128,
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export const MAP_INSTANCING_ASSETS = {
|
|||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const MAP_SINGLE_MODEL_SCALE_MULTIPLIERS = {
|
const MAP_SINGLE_MODEL_SCALE_MULTIPLIERS = {
|
||||||
ebike: 0.3,
|
ebike: 0.3,
|
||||||
} as const satisfies Record<string, number>;
|
} as const satisfies Record<string, number>;
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ export function getMapSingleModelScaleMultiplier(name: string): number {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMapInstancedModelScaleMultiplier(name: string): number {
|
function getMapInstancedModelScaleMultiplier(name: string): number {
|
||||||
return (
|
return (
|
||||||
Object.values(MAP_INSTANCING_ASSETS).find(
|
Object.values(MAP_INSTANCING_ASSETS).find(
|
||||||
(config) => config.mapName === name,
|
(config) => config.mapName === name,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { TerrainSurfaceColorConfig } from "@/types/world/terrainSurface";
|
|||||||
export const TERRAIN_MODEL_PATH = "/models/terrain/model.gltf";
|
export const TERRAIN_MODEL_PATH = "/models/terrain/model.gltf";
|
||||||
export const TERRAIN_WATER_HEIGHT = 0.8;
|
export const TERRAIN_WATER_HEIGHT = 0.8;
|
||||||
|
|
||||||
export const TERRAIN_TILE_SIZE = 1;
|
const TERRAIN_TILE_SIZE = 1;
|
||||||
|
|
||||||
export const TERRAIN_COLORS = {
|
export const TERRAIN_COLORS = {
|
||||||
grass1: {
|
grass1: {
|
||||||
|
|||||||
@@ -83,6 +83,12 @@ export const VEGETATION_TYPE_KEYS = [
|
|||||||
|
|
||||||
export type VegetationType = (typeof VEGETATION_TYPE_KEYS)[number];
|
export type VegetationType = (typeof VEGETATION_TYPE_KEYS)[number];
|
||||||
|
|
||||||
|
export const VEGETATION_MAP_NODE_NAMES: ReadonlySet<string> = new Set(
|
||||||
|
Object.values(VEGETATION_TYPES)
|
||||||
|
.filter((config) => config.enabled)
|
||||||
|
.map((config) => config.mapName),
|
||||||
|
);
|
||||||
|
|
||||||
export function getVegetationModelScaleMultiplier(name: string): number {
|
export function getVegetationModelScaleMultiplier(name: string): number {
|
||||||
return (
|
return (
|
||||||
Object.values(VEGETATION_TYPES).find((config) => config.mapName === name)
|
Object.values(VEGETATION_TYPES).find((config) => config.mapName === name)
|
||||||
@@ -90,7 +96,7 @@ export function getVegetationModelScaleMultiplier(name: string): number {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const INSTANCED_MAP_EXCEPTIONS = new Set([
|
export const VEGETATION_INSTANCE_EXCLUDED_NODE_NAMES = new Set([
|
||||||
"Scene",
|
"Scene",
|
||||||
"blocking",
|
"blocking",
|
||||||
"terrain",
|
"terrain",
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
import { useRef, useEffect, useState, useCallback, useMemo } from "react";
|
|
||||||
import { useAnimations } from "@react-three/drei";
|
|
||||||
import type { AnimationAction, AnimationMixer } from "three";
|
|
||||||
import * as THREE from "three";
|
|
||||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
|
||||||
|
|
||||||
export interface CharacterAnimationConfig {
|
|
||||||
modelPath: string;
|
|
||||||
initialAnimation?: string;
|
|
||||||
fadeDuration?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseCharacterAnimationReturn {
|
|
||||||
scene: THREE.Group;
|
|
||||||
actions: { [key: string]: AnimationAction | null };
|
|
||||||
names: string[];
|
|
||||||
mixer: AnimationMixer;
|
|
||||||
groupRef: React.MutableRefObject<THREE.Group | null>;
|
|
||||||
currentAnimation: string;
|
|
||||||
play: (name: string) => void;
|
|
||||||
stop: () => void;
|
|
||||||
fadeTo: (name: string, duration?: number) => void;
|
|
||||||
setAnimationSpeed: (speed: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_FADE_DURATION = 0.3;
|
|
||||||
|
|
||||||
export function useCharacterAnimation(
|
|
||||||
config: CharacterAnimationConfig,
|
|
||||||
): UseCharacterAnimationReturn {
|
|
||||||
const {
|
|
||||||
modelPath,
|
|
||||||
initialAnimation = "Idle",
|
|
||||||
fadeDuration = DEFAULT_FADE_DURATION,
|
|
||||||
} = config;
|
|
||||||
|
|
||||||
const groupRef = useRef<THREE.Group | null>(null);
|
|
||||||
const { scene, animations } = useLoggedGLTF(modelPath, {
|
|
||||||
scope: "useCharacterAnimation",
|
|
||||||
});
|
|
||||||
const model = useMemo(() => scene.clone(true), [scene]);
|
|
||||||
const { actions, names, mixer } = useAnimations(animations, groupRef);
|
|
||||||
const [currentAnimation, setCurrentAnimation] = useState(initialAnimation);
|
|
||||||
|
|
||||||
const play = useCallback(
|
|
||||||
(name: string) => {
|
|
||||||
const action = actions[name];
|
|
||||||
if (action) {
|
|
||||||
Object.values(actions).forEach((a) => {
|
|
||||||
if (a && a !== action) a.fadeOut(fadeDuration);
|
|
||||||
});
|
|
||||||
action.reset().fadeIn(fadeDuration).play();
|
|
||||||
setCurrentAnimation(name);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[actions, fadeDuration],
|
|
||||||
);
|
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
|
||||||
Object.values(actions).forEach((a) => a?.fadeOut(fadeDuration));
|
|
||||||
const defaultAction = actions[initialAnimation as string];
|
|
||||||
if (defaultAction) {
|
|
||||||
defaultAction.reset().fadeIn(fadeDuration).play();
|
|
||||||
setCurrentAnimation(initialAnimation);
|
|
||||||
}
|
|
||||||
}, [actions, initialAnimation, fadeDuration]);
|
|
||||||
|
|
||||||
const fadeTo = useCallback(
|
|
||||||
(name: string, duration = fadeDuration) => {
|
|
||||||
const targetAction = actions[name];
|
|
||||||
if (targetAction) {
|
|
||||||
Object.values(actions).forEach((a) => {
|
|
||||||
if (a && a !== targetAction) a.fadeOut(duration);
|
|
||||||
});
|
|
||||||
targetAction.reset().fadeIn(duration).play();
|
|
||||||
setCurrentAnimation(name);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[actions, fadeDuration],
|
|
||||||
);
|
|
||||||
|
|
||||||
const setAnimationSpeed = useCallback(
|
|
||||||
(speed: number) => {
|
|
||||||
Object.values(actions).forEach((action) => {
|
|
||||||
action?.setEffectiveTimeScale(speed);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[actions],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const defaultAction = actions[initialAnimation as string];
|
|
||||||
if (defaultAction) {
|
|
||||||
defaultAction.play();
|
|
||||||
}
|
|
||||||
}, [actions, initialAnimation]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
scene: model,
|
|
||||||
actions,
|
|
||||||
names,
|
|
||||||
mixer,
|
|
||||||
groupRef,
|
|
||||||
currentAnimation,
|
|
||||||
play,
|
|
||||||
stop,
|
|
||||||
fadeTo,
|
|
||||||
setAnimationSpeed,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||||
import {
|
import {
|
||||||
PERSONNAGE_CONFIGS,
|
CHARACTER_CONFIGS,
|
||||||
PERSONNAGE_IDS,
|
CHARACTER_IDS,
|
||||||
} from "@/data/world/personnages/personnageConfig";
|
} from "@/data/world/characters/characterConfig";
|
||||||
import { usePersonnageDebugStore } from "@/managers/stores/usePersonnageDebugStore";
|
import { useCharacterDebugStore } from "@/managers/stores/useCharacterDebugStore";
|
||||||
|
|
||||||
function createAnimationOptions(
|
function createAnimationOptions(
|
||||||
animations: readonly string[],
|
animations: readonly string[],
|
||||||
@@ -17,13 +17,13 @@ function createAnimationOptions(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePersonnageDebug(): void {
|
export function useCharacterDebug(): void {
|
||||||
useDebugFolder("Personnages", (folder) => {
|
useDebugFolder("Personnages", (folder) => {
|
||||||
const store = usePersonnageDebugStore.getState();
|
const store = useCharacterDebugStore.getState();
|
||||||
|
|
||||||
for (const id of PERSONNAGE_IDS) {
|
for (const id of CHARACTER_IDS) {
|
||||||
const config = PERSONNAGE_CONFIGS[id];
|
const config = CHARACTER_CONFIGS[id];
|
||||||
const state = store.personnages[id];
|
const state = store.characters[id];
|
||||||
const characterFolder = folder.addFolder(config.label);
|
const characterFolder = folder.addFolder(config.label);
|
||||||
const controls = {
|
const controls = {
|
||||||
animation: state.animation,
|
animation: state.animation,
|
||||||
@@ -42,64 +42,64 @@ export function usePersonnageDebug(): void {
|
|||||||
.add(controls, "animation", createAnimationOptions(config.animations))
|
.add(controls, "animation", createAnimationOptions(config.animations))
|
||||||
.name("Animation")
|
.name("Animation")
|
||||||
.onChange((animation: string) => {
|
.onChange((animation: string) => {
|
||||||
usePersonnageDebugStore.getState().setAnimation(id, animation);
|
useCharacterDebugStore.getState().setAnimation(id, animation);
|
||||||
});
|
});
|
||||||
|
|
||||||
characterFolder
|
characterFolder
|
||||||
.add(controls, "positionX", -120, 120, 0.1)
|
.add(controls, "positionX", -120, 120, 0.1)
|
||||||
.name("Position X")
|
.name("Position X")
|
||||||
.onChange((value: number) => {
|
.onChange((value: number) => {
|
||||||
usePersonnageDebugStore.getState().setPosition(id, 0, value);
|
useCharacterDebugStore.getState().setPosition(id, 0, value);
|
||||||
});
|
});
|
||||||
characterFolder
|
characterFolder
|
||||||
.add(controls, "positionY", -20, 40, 0.1)
|
.add(controls, "positionY", -20, 40, 0.1)
|
||||||
.name("Position Y")
|
.name("Position Y")
|
||||||
.onChange((value: number) => {
|
.onChange((value: number) => {
|
||||||
usePersonnageDebugStore.getState().setPosition(id, 1, value);
|
useCharacterDebugStore.getState().setPosition(id, 1, value);
|
||||||
});
|
});
|
||||||
characterFolder
|
characterFolder
|
||||||
.add(controls, "positionZ", -120, 120, 0.1)
|
.add(controls, "positionZ", -120, 120, 0.1)
|
||||||
.name("Position Z")
|
.name("Position Z")
|
||||||
.onChange((value: number) => {
|
.onChange((value: number) => {
|
||||||
usePersonnageDebugStore.getState().setPosition(id, 2, value);
|
useCharacterDebugStore.getState().setPosition(id, 2, value);
|
||||||
});
|
});
|
||||||
|
|
||||||
characterFolder
|
characterFolder
|
||||||
.add(controls, "rotationX", -Math.PI, Math.PI, 0.01)
|
.add(controls, "rotationX", -Math.PI, Math.PI, 0.01)
|
||||||
.name("Rotation X")
|
.name("Rotation X")
|
||||||
.onChange((value: number) => {
|
.onChange((value: number) => {
|
||||||
usePersonnageDebugStore.getState().setRotation(id, 0, value);
|
useCharacterDebugStore.getState().setRotation(id, 0, value);
|
||||||
});
|
});
|
||||||
characterFolder
|
characterFolder
|
||||||
.add(controls, "rotationY", -Math.PI, Math.PI, 0.01)
|
.add(controls, "rotationY", -Math.PI, Math.PI, 0.01)
|
||||||
.name("Rotation Y")
|
.name("Rotation Y")
|
||||||
.onChange((value: number) => {
|
.onChange((value: number) => {
|
||||||
usePersonnageDebugStore.getState().setRotation(id, 1, value);
|
useCharacterDebugStore.getState().setRotation(id, 1, value);
|
||||||
});
|
});
|
||||||
characterFolder
|
characterFolder
|
||||||
.add(controls, "rotationZ", -Math.PI, Math.PI, 0.01)
|
.add(controls, "rotationZ", -Math.PI, Math.PI, 0.01)
|
||||||
.name("Rotation Z")
|
.name("Rotation Z")
|
||||||
.onChange((value: number) => {
|
.onChange((value: number) => {
|
||||||
usePersonnageDebugStore.getState().setRotation(id, 2, value);
|
useCharacterDebugStore.getState().setRotation(id, 2, value);
|
||||||
});
|
});
|
||||||
|
|
||||||
characterFolder
|
characterFolder
|
||||||
.add(controls, "scaleX", 0.1, 5, 0.05)
|
.add(controls, "scaleX", 0.1, 5, 0.05)
|
||||||
.name("Scale X")
|
.name("Scale X")
|
||||||
.onChange((value: number) => {
|
.onChange((value: number) => {
|
||||||
usePersonnageDebugStore.getState().setScale(id, 0, value);
|
useCharacterDebugStore.getState().setScale(id, 0, value);
|
||||||
});
|
});
|
||||||
characterFolder
|
characterFolder
|
||||||
.add(controls, "scaleY", 0.1, 5, 0.05)
|
.add(controls, "scaleY", 0.1, 5, 0.05)
|
||||||
.name("Scale Y")
|
.name("Scale Y")
|
||||||
.onChange((value: number) => {
|
.onChange((value: number) => {
|
||||||
usePersonnageDebugStore.getState().setScale(id, 1, value);
|
useCharacterDebugStore.getState().setScale(id, 1, value);
|
||||||
});
|
});
|
||||||
characterFolder
|
characterFolder
|
||||||
.add(controls, "scaleZ", 0.1, 5, 0.05)
|
.add(controls, "scaleZ", 0.1, 5, 0.05)
|
||||||
.name("Scale Z")
|
.name("Scale Z")
|
||||||
.onChange((value: number) => {
|
.onChange((value: number) => {
|
||||||
usePersonnageDebugStore.getState().setScale(id, 2, value);
|
useCharacterDebugStore.getState().setScale(id, 2, value);
|
||||||
});
|
});
|
||||||
|
|
||||||
characterFolder.close();
|
characterFolder.close();
|
||||||
@@ -1,6 +1,54 @@
|
|||||||
import { useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
|
import { disposeObject3D } from "@/utils/three/dispose";
|
||||||
|
|
||||||
export function useClonedObject<T extends THREE.Object3D>(object: T): T {
|
interface UseClonedObjectOptions {
|
||||||
return useMemo(() => object.clone(true) as T, [object]);
|
cloneResources?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneMaterial(
|
||||||
|
material: THREE.Material | THREE.Material[],
|
||||||
|
): THREE.Material | THREE.Material[] {
|
||||||
|
return Array.isArray(material)
|
||||||
|
? material.map((item) => item.clone())
|
||||||
|
: material.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneObject<T extends THREE.Object3D>(
|
||||||
|
object: T,
|
||||||
|
cloneResources: boolean,
|
||||||
|
): T {
|
||||||
|
const clone = object.clone(true) as T;
|
||||||
|
|
||||||
|
if (!cloneResources) return clone;
|
||||||
|
|
||||||
|
clone.traverse((child) => {
|
||||||
|
if (!(child instanceof THREE.Mesh)) return;
|
||||||
|
|
||||||
|
child.geometry = child.geometry.clone();
|
||||||
|
child.material = cloneMaterial(child.material);
|
||||||
|
});
|
||||||
|
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useClonedObject<T extends THREE.Object3D>(
|
||||||
|
object: T,
|
||||||
|
options: UseClonedObjectOptions = {},
|
||||||
|
): T {
|
||||||
|
const cloneResources = options.cloneResources ?? false;
|
||||||
|
const clone = useMemo(
|
||||||
|
() => cloneObject(object, cloneResources),
|
||||||
|
[cloneResources, object],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cloneResources) return undefined;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposeObject3D(clone);
|
||||||
|
};
|
||||||
|
}, [clone, cloneResources]);
|
||||||
|
|
||||||
|
return clone;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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/addons/math/Octree.js";
|
import { Octree } from "three-stdlib";
|
||||||
import type { OctreeReadyHandler } from "@/types/three/three";
|
import type { OctreeReadyHandler } from "@/types/three/three";
|
||||||
|
|
||||||
export function useOctreeGraphNode(
|
export function useOctreeGraphNode(
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ function createTerrainHeightSampler(
|
|||||||
new THREE.Vector3(...scale),
|
new THREE.Vector3(...scale),
|
||||||
);
|
);
|
||||||
const inverseTerrainMatrix = terrainMatrix.clone().invert();
|
const inverseTerrainMatrix = terrainMatrix.clone().invert();
|
||||||
|
const localOrigin = new THREE.Vector3();
|
||||||
|
const localDirection = DOWN.clone().transformDirection(inverseTerrainMatrix);
|
||||||
|
const hits: THREE.Intersection[] = [];
|
||||||
const raycaster = new THREE.Raycaster(
|
const raycaster = new THREE.Raycaster(
|
||||||
new THREE.Vector3(),
|
new THREE.Vector3(),
|
||||||
DOWN,
|
DOWN,
|
||||||
@@ -63,13 +66,11 @@ function createTerrainHeightSampler(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
getHeight: (x, z) => {
|
getHeight: (x, z) => {
|
||||||
const localOrigin = new THREE.Vector3(x, RAYCAST_Y, z).applyMatrix4(
|
localOrigin.set(x, RAYCAST_Y, z).applyMatrix4(inverseTerrainMatrix);
|
||||||
inverseTerrainMatrix,
|
|
||||||
);
|
|
||||||
const localDirection =
|
|
||||||
DOWN.clone().transformDirection(inverseTerrainMatrix);
|
|
||||||
raycaster.set(localOrigin, localDirection);
|
raycaster.set(localOrigin, localDirection);
|
||||||
const hit = raycaster.intersectObjects(meshes, false)[0];
|
hits.length = 0;
|
||||||
|
raycaster.intersectObjects(meshes, false, hits);
|
||||||
|
const hit = hits[0];
|
||||||
return hit?.point.applyMatrix4(terrainMatrix).y ?? null;
|
return hit?.point.applyMatrix4(terrainMatrix).y ?? null;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { INSTANCED_MAP_EXCEPTIONS } from "@/data/world/vegetationConfig";
|
import { VEGETATION_INSTANCE_EXCLUDED_NODE_NAMES } from "@/data/world/vegetationConfig";
|
||||||
import type { MapNode, VegetationInstance } from "@/types/map/mapScene";
|
import type { MapNode, VegetationInstance } from "@/types/map/mapScene";
|
||||||
import { mapNodeToInstanceTransform } from "@/utils/map/mapInstanceTransform";
|
import { mapNodeToInstanceTransform } from "@/utils/map/mapInstanceTransform";
|
||||||
import { logger } from "@/utils/core/Logger";
|
import { logger } from "@/utils/core/Logger";
|
||||||
import { loadMapSceneData } from "@/utils/map/loadMapSceneData";
|
import { loadMapSceneData } from "@/utils/map/loadMapSceneData";
|
||||||
import {
|
|
||||||
createPotagerMapNode,
|
|
||||||
isPotagerSourceMapNode,
|
|
||||||
POTAGER_MAP_NAME,
|
|
||||||
} from "@/utils/map/potagerMapNodes";
|
|
||||||
|
|
||||||
interface InstancedMapEntry {
|
interface InstancedMapEntry {
|
||||||
modelPath: string;
|
modelPath: string;
|
||||||
@@ -17,10 +12,6 @@ interface InstancedMapEntry {
|
|||||||
|
|
||||||
export type VegetationData = Map<string, InstancedMapEntry>;
|
export type VegetationData = Map<string, InstancedMapEntry>;
|
||||||
|
|
||||||
function createPositionKey(node: MapNode): string {
|
|
||||||
return node.position.map((value) => value.toFixed(3)).join(":");
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractVegetationData(
|
function extractVegetationData(
|
||||||
mapNodes: MapNode[],
|
mapNodes: MapNode[],
|
||||||
models: Map<string, string>,
|
models: Map<string, string>,
|
||||||
@@ -48,7 +39,7 @@ function extractVegetationData(
|
|||||||
|
|
||||||
for (const node of mapNodes) {
|
for (const node of mapNodes) {
|
||||||
if (node.type !== "Object3D") continue;
|
if (node.type !== "Object3D") continue;
|
||||||
if (INSTANCED_MAP_EXCEPTIONS.has(node.name)) continue;
|
if (VEGETATION_INSTANCE_EXCLUDED_NODE_NAMES.has(node.name)) continue;
|
||||||
|
|
||||||
const modelPath = models.get(node.name);
|
const modelPath = models.get(node.name);
|
||||||
if (!modelPath) continue;
|
if (!modelPath) continue;
|
||||||
@@ -56,35 +47,6 @@ function extractVegetationData(
|
|||||||
addInstance(node.name, modelPath, node);
|
addInstance(node.name, modelPath, node);
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingPotagerPositionKeys = new Set(
|
|
||||||
mapNodes
|
|
||||||
.filter((node) => node.name === POTAGER_MAP_NAME)
|
|
||||||
.map(createPositionKey),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const node of mapNodes) {
|
|
||||||
if (!isPotagerSourceMapNode(node)) continue;
|
|
||||||
if (existingPotagerPositionKeys.has(createPositionKey(node))) continue;
|
|
||||||
|
|
||||||
addInstance(
|
|
||||||
POTAGER_MAP_NAME,
|
|
||||||
"/models/potager/potager.gltf",
|
|
||||||
createPotagerMapNode(node),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const potagerEntry = data.get(POTAGER_MAP_NAME);
|
|
||||||
if (potagerEntry) {
|
|
||||||
const uniqueInstances = new Map<string, VegetationInstance>();
|
|
||||||
for (const instance of potagerEntry.instances) {
|
|
||||||
uniqueInstances.set(
|
|
||||||
instance.position.map((value) => value.toFixed(3)).join(":"),
|
|
||||||
instance,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
potagerEntry.instances = [...uniqueInstances.values()];
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import type { Octree } from "three/addons/math/Octree.js";
|
import type { Octree } from "three-stdlib";
|
||||||
import type { SceneMode } from "@/types/debug/debug";
|
import type { SceneMode } from "@/types/debug/debug";
|
||||||
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
|
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import {
|
||||||
|
CHARACTER_CONFIGS,
|
||||||
|
CHARACTER_IDS,
|
||||||
|
type CharacterId,
|
||||||
|
} from "@/data/world/characters/characterConfig";
|
||||||
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
|
||||||
|
interface CharacterDebugState {
|
||||||
|
animation: string;
|
||||||
|
position: Vector3Tuple;
|
||||||
|
rotation: Vector3Tuple;
|
||||||
|
scale: Vector3Tuple;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CharacterDebugStore {
|
||||||
|
characters: Record<CharacterId, CharacterDebugState>;
|
||||||
|
setAnimation: (id: CharacterId, animation: string) => void;
|
||||||
|
setPosition: (id: CharacterId, axis: 0 | 1 | 2, value: number) => void;
|
||||||
|
setRotation: (id: CharacterId, axis: 0 | 1 | 2, value: number) => void;
|
||||||
|
setScale: (id: CharacterId, axis: 0 | 1 | 2, value: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateVector(
|
||||||
|
vector: Vector3Tuple,
|
||||||
|
axis: 0 | 1 | 2,
|
||||||
|
value: number,
|
||||||
|
): Vector3Tuple {
|
||||||
|
const next: Vector3Tuple = [...vector];
|
||||||
|
next[axis] = value;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialCharacters = Object.fromEntries(
|
||||||
|
CHARACTER_IDS.map((id) => {
|
||||||
|
const config = CHARACTER_CONFIGS[id];
|
||||||
|
|
||||||
|
return [
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
animation: config.defaultAnimation,
|
||||||
|
position: [...config.position],
|
||||||
|
rotation: [...config.rotation],
|
||||||
|
scale: [...config.scale],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
) as Record<CharacterId, CharacterDebugState>;
|
||||||
|
|
||||||
|
export const useCharacterDebugStore = create<CharacterDebugStore>((set) => ({
|
||||||
|
characters: initialCharacters,
|
||||||
|
setAnimation: (id, animation) =>
|
||||||
|
set((state) => ({
|
||||||
|
characters: {
|
||||||
|
...state.characters,
|
||||||
|
[id]: { ...state.characters[id], animation },
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
setPosition: (id, axis, value) =>
|
||||||
|
set((state) => ({
|
||||||
|
characters: {
|
||||||
|
...state.characters,
|
||||||
|
[id]: {
|
||||||
|
...state.characters[id],
|
||||||
|
position: updateVector(state.characters[id].position, axis, value),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
setRotation: (id, axis, value) =>
|
||||||
|
set((state) => ({
|
||||||
|
characters: {
|
||||||
|
...state.characters,
|
||||||
|
[id]: {
|
||||||
|
...state.characters[id],
|
||||||
|
rotation: updateVector(state.characters[id].rotation, axis, value),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
setScale: (id, axis, value) =>
|
||||||
|
set((state) => ({
|
||||||
|
characters: {
|
||||||
|
...state.characters,
|
||||||
|
[id]: {
|
||||||
|
...state.characters[id],
|
||||||
|
scale: updateVector(state.characters[id].scale, axis, value),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}));
|
||||||
@@ -6,6 +6,10 @@ import {
|
|||||||
isMissionStep,
|
isMissionStep,
|
||||||
isRepairMissionId,
|
isRepairMissionId,
|
||||||
} from "@/data/gameplay/repairMissionState";
|
} from "@/data/gameplay/repairMissionState";
|
||||||
|
import {
|
||||||
|
PLAYER_EBIKE_SPEED,
|
||||||
|
PLAYER_WALK_SPEED,
|
||||||
|
} from "@/data/player/playerConfig";
|
||||||
import type { GameStep, MainGameState } from "@/types/game";
|
import type { GameStep, MainGameState } from "@/types/game";
|
||||||
import {
|
import {
|
||||||
type MissionStep,
|
type MissionStep,
|
||||||
@@ -18,6 +22,7 @@ import {
|
|||||||
} from "@/utils/debug/debugGameStateCookie";
|
} from "@/utils/debug/debugGameStateCookie";
|
||||||
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
|
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
|
||||||
|
|
||||||
|
export type PlayerMovementMode = "walk" | "ebike";
|
||||||
export type { MissionStep, RepairMissionId };
|
export type { MissionStep, RepairMissionId };
|
||||||
|
|
||||||
interface IntroState {
|
interface IntroState {
|
||||||
@@ -43,6 +48,7 @@ export interface GameState {
|
|||||||
mainState: MainGameState;
|
mainState: MainGameState;
|
||||||
isCinematicPlaying: boolean;
|
isCinematicPlaying: boolean;
|
||||||
missionFlow: MissionFlowState;
|
missionFlow: MissionFlowState;
|
||||||
|
player: PlayerState;
|
||||||
intro: IntroState;
|
intro: IntroState;
|
||||||
ebike: MissionState & {
|
ebike: MissionState & {
|
||||||
isRepaired: boolean;
|
isRepaired: boolean;
|
||||||
@@ -59,12 +65,18 @@ export interface GameState {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PlayerState {
|
||||||
|
movementMode: PlayerMovementMode;
|
||||||
|
currentSpeed: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface GameActions {
|
interface GameActions {
|
||||||
setMainState: (mainState: MainGameState) => void;
|
setMainState: (mainState: MainGameState) => void;
|
||||||
setCinematicPlaying: (isCinematicPlaying: boolean) => void;
|
setCinematicPlaying: (isCinematicPlaying: boolean) => void;
|
||||||
hideDialog: () => void;
|
hideDialog: () => void;
|
||||||
setActivityCity: (activityCity: boolean) => void;
|
setActivityCity: (activityCity: boolean) => void;
|
||||||
setCanMove: (canMove: boolean) => void;
|
setCanMove: (canMove: boolean) => void;
|
||||||
|
setPlayerMovementMode: (mode: PlayerMovementMode) => void;
|
||||||
setIntroStep: (step: GameStep) => void;
|
setIntroStep: (step: GameStep) => void;
|
||||||
setIntroState: (intro: Partial<IntroState>) => void;
|
setIntroState: (intro: Partial<IntroState>) => void;
|
||||||
setPlayerName: (playerName: string) => void;
|
setPlayerName: (playerName: string) => void;
|
||||||
@@ -100,6 +112,10 @@ function isBoolean(value: unknown): value is boolean {
|
|||||||
return typeof value === "boolean";
|
return typeof value === "boolean";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPlayerMovementMode(value: unknown): value is PlayerMovementMode {
|
||||||
|
return value === "walk" || value === "ebike";
|
||||||
|
}
|
||||||
|
|
||||||
function completeIntroState(state: GameState): GameStateUpdate {
|
function completeIntroState(state: GameState): GameStateUpdate {
|
||||||
return {
|
return {
|
||||||
mainState: "ebike",
|
mainState: "ebike",
|
||||||
@@ -234,6 +250,10 @@ function createInitialGameState(): GameState {
|
|||||||
dialogMessage: null,
|
dialogMessage: null,
|
||||||
playerName: "",
|
playerName: "",
|
||||||
},
|
},
|
||||||
|
player: {
|
||||||
|
movementMode: "walk",
|
||||||
|
currentSpeed: PLAYER_WALK_SPEED,
|
||||||
|
},
|
||||||
intro: {
|
intro: {
|
||||||
currentStep: "intro",
|
currentStep: "intro",
|
||||||
dialogueAudio: null,
|
dialogueAudio: null,
|
||||||
@@ -319,6 +339,20 @@ function hydrateMissionFlowState(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hydratePlayerState(initial: PlayerState, value: unknown): PlayerState {
|
||||||
|
if (!isRecord(value)) return initial;
|
||||||
|
|
||||||
|
return {
|
||||||
|
movementMode: isPlayerMovementMode(value.movementMode)
|
||||||
|
? value.movementMode
|
||||||
|
: initial.movementMode,
|
||||||
|
currentSpeed:
|
||||||
|
typeof value.currentSpeed === "number"
|
||||||
|
? value.currentSpeed
|
||||||
|
: initial.currentSpeed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function hydrateDebugGameState(initial: GameState, value: unknown): GameState {
|
function hydrateDebugGameState(initial: GameState, value: unknown): GameState {
|
||||||
if (!isRecord(value)) return initial;
|
if (!isRecord(value)) return initial;
|
||||||
|
|
||||||
@@ -338,6 +372,7 @@ function hydrateDebugGameState(initial: GameState, value: unknown): GameState {
|
|||||||
initial.missionFlow,
|
initial.missionFlow,
|
||||||
value.missionFlow,
|
value.missionFlow,
|
||||||
),
|
),
|
||||||
|
player: hydratePlayerState(initial.player, value.player),
|
||||||
intro: hydrateIntroState(initial.intro, value.intro),
|
intro: hydrateIntroState(initial.intro, value.intro),
|
||||||
ebike: {
|
ebike: {
|
||||||
...ebike,
|
...ebike,
|
||||||
@@ -385,6 +420,7 @@ function pickGameState(state: GameStore): GameState {
|
|||||||
mainState: state.mainState,
|
mainState: state.mainState,
|
||||||
isCinematicPlaying: state.isCinematicPlaying,
|
isCinematicPlaying: state.isCinematicPlaying,
|
||||||
missionFlow: state.missionFlow,
|
missionFlow: state.missionFlow,
|
||||||
|
player: state.player,
|
||||||
intro: state.intro,
|
intro: state.intro,
|
||||||
ebike: state.ebike,
|
ebike: state.ebike,
|
||||||
pylon: state.pylon,
|
pylon: state.pylon,
|
||||||
@@ -405,6 +441,14 @@ export const useGameStore = create<GameStore>()((set) => ({
|
|||||||
set((state) => ({
|
set((state) => ({
|
||||||
missionFlow: { ...state.missionFlow, activityCity },
|
missionFlow: { ...state.missionFlow, activityCity },
|
||||||
})),
|
})),
|
||||||
|
setPlayerMovementMode: (mode) =>
|
||||||
|
set((state) => ({
|
||||||
|
player: {
|
||||||
|
...state.player,
|
||||||
|
movementMode: mode,
|
||||||
|
currentSpeed: mode === "ebike" ? PLAYER_EBIKE_SPEED : PLAYER_WALK_SPEED,
|
||||||
|
},
|
||||||
|
})),
|
||||||
setCanMove: (canMove) =>
|
setCanMove: (canMove) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
missionFlow: { ...state.missionFlow, canMove },
|
missionFlow: { ...state.missionFlow, canMove },
|
||||||
|
|||||||
@@ -7,12 +7,7 @@ import {
|
|||||||
type MapPerformanceModelName,
|
type MapPerformanceModelName,
|
||||||
} from "@/data/world/mapPerformanceConfig";
|
} from "@/data/world/mapPerformanceConfig";
|
||||||
|
|
||||||
export {
|
export { MAP_PERFORMANCE_GROUP_NAMES, MAP_PERFORMANCE_MODEL_NAMES };
|
||||||
MAP_PERFORMANCE_GROUP_NAMES,
|
|
||||||
MAP_PERFORMANCE_MODEL_NAMES,
|
|
||||||
type MapPerformanceGroupName,
|
|
||||||
type MapPerformanceModelName,
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface MapPerformanceVisibility {
|
export interface MapPerformanceVisibility {
|
||||||
groups: Record<MapPerformanceGroupName, boolean>;
|
groups: Record<MapPerformanceGroupName, boolean>;
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
import { create } from "zustand";
|
|
||||||
import {
|
|
||||||
PERSONNAGE_CONFIGS,
|
|
||||||
PERSONNAGE_IDS,
|
|
||||||
type PersonnageId,
|
|
||||||
} from "@/data/world/personnages/personnageConfig";
|
|
||||||
import type { Vector3Tuple } from "@/types/three/three";
|
|
||||||
|
|
||||||
interface PersonnageDebugState {
|
|
||||||
animation: string;
|
|
||||||
position: Vector3Tuple;
|
|
||||||
rotation: Vector3Tuple;
|
|
||||||
scale: Vector3Tuple;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PersonnageDebugStore {
|
|
||||||
personnages: Record<PersonnageId, PersonnageDebugState>;
|
|
||||||
setAnimation: (id: PersonnageId, animation: string) => void;
|
|
||||||
setPosition: (id: PersonnageId, axis: 0 | 1 | 2, value: number) => void;
|
|
||||||
setRotation: (id: PersonnageId, axis: 0 | 1 | 2, value: number) => void;
|
|
||||||
setScale: (id: PersonnageId, axis: 0 | 1 | 2, value: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateVector(
|
|
||||||
vector: Vector3Tuple,
|
|
||||||
axis: 0 | 1 | 2,
|
|
||||||
value: number,
|
|
||||||
): Vector3Tuple {
|
|
||||||
const next: Vector3Tuple = [...vector];
|
|
||||||
next[axis] = value;
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialPersonnages = Object.fromEntries(
|
|
||||||
PERSONNAGE_IDS.map((id) => {
|
|
||||||
const config = PERSONNAGE_CONFIGS[id];
|
|
||||||
|
|
||||||
return [
|
|
||||||
id,
|
|
||||||
{
|
|
||||||
animation: config.defaultAnimation,
|
|
||||||
position: [...config.position],
|
|
||||||
rotation: [...config.rotation],
|
|
||||||
scale: [...config.scale],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}),
|
|
||||||
) as Record<PersonnageId, PersonnageDebugState>;
|
|
||||||
|
|
||||||
export const usePersonnageDebugStore = create<PersonnageDebugStore>((set) => ({
|
|
||||||
personnages: initialPersonnages,
|
|
||||||
setAnimation: (id, animation) =>
|
|
||||||
set((state) => ({
|
|
||||||
personnages: {
|
|
||||||
...state.personnages,
|
|
||||||
[id]: { ...state.personnages[id], animation },
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
setPosition: (id, axis, value) =>
|
|
||||||
set((state) => ({
|
|
||||||
personnages: {
|
|
||||||
...state.personnages,
|
|
||||||
[id]: {
|
|
||||||
...state.personnages[id],
|
|
||||||
position: updateVector(state.personnages[id].position, axis, value),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
setRotation: (id, axis, value) =>
|
|
||||||
set((state) => ({
|
|
||||||
personnages: {
|
|
||||||
...state.personnages,
|
|
||||||
[id]: {
|
|
||||||
...state.personnages[id],
|
|
||||||
rotation: updateVector(state.personnages[id].rotation, axis, value),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
setScale: (id, axis, value) =>
|
|
||||||
set((state) => ({
|
|
||||||
personnages: {
|
|
||||||
...state.personnages,
|
|
||||||
[id]: {
|
|
||||||
...state.personnages[id],
|
|
||||||
scale: updateVector(state.personnages[id].scale, axis, value),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||||
|
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
||||||
|
import { MapControls, OrthographicCamera, useGLTF } from "@react-three/drei";
|
||||||
|
import * as THREE from "three";
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 1. Terrain Scene
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
function TerrainScene() {
|
||||||
|
const { scene } = useGLTF("/models/terrain/terrain.glb");
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
<ambientLight intensity={1.5} />
|
||||||
|
<directionalLight position={[10, 20, 10]} intensity={2} />
|
||||||
|
<primitive object={scene} />
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 2. Waypoint Overlay (Debug visualization)
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
function WaypointOverlay({
|
||||||
|
waypoints,
|
||||||
|
visible,
|
||||||
|
}: {
|
||||||
|
waypoints: any[];
|
||||||
|
visible: boolean;
|
||||||
|
}) {
|
||||||
|
if (!visible) return null;
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{waypoints.map((w) => (
|
||||||
|
<mesh key={w.id} position={[w.x, w.y + 1, w.z]}>
|
||||||
|
<sphereGeometry args={[0.3, 16, 16]} />
|
||||||
|
<meshBasicMaterial color="#10b981" />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 3. Camera Manager (Handles Orthographic Math & Downloads)
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
function CameraManager({
|
||||||
|
autoBounds,
|
||||||
|
boundsTextRef,
|
||||||
|
}: {
|
||||||
|
autoBounds: any;
|
||||||
|
boundsTextRef: React.RefObject<HTMLPreElement | null>;
|
||||||
|
}) {
|
||||||
|
const { camera, gl, scene } = useThree();
|
||||||
|
const controlsRef = useRef<any>(null);
|
||||||
|
|
||||||
|
// Apply Auto-Bounds function
|
||||||
|
useEffect(() => {
|
||||||
|
const applyAutoBounds = () => {
|
||||||
|
if (camera instanceof THREE.OrthographicCamera && autoBounds) {
|
||||||
|
const width = autoBounds.maxX - autoBounds.minX;
|
||||||
|
const height = autoBounds.maxZ - autoBounds.minZ;
|
||||||
|
const centerX = (autoBounds.minX + autoBounds.maxX) / 2;
|
||||||
|
const centerZ = (autoBounds.minZ + autoBounds.maxZ) / 2;
|
||||||
|
|
||||||
|
camera.position.set(centerX, 200, centerZ);
|
||||||
|
camera.left = -width / 2;
|
||||||
|
camera.right = width / 2;
|
||||||
|
camera.top = height / 2;
|
||||||
|
camera.bottom = -height / 2;
|
||||||
|
camera.zoom = 1;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
|
||||||
|
if (controlsRef.current) {
|
||||||
|
controlsRef.current.target.set(centerX, 0, centerZ);
|
||||||
|
controlsRef.current.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
(window as any).applyAutoBounds = applyAutoBounds;
|
||||||
|
// Initial apply
|
||||||
|
applyAutoBounds();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
delete (window as any).applyAutoBounds;
|
||||||
|
};
|
||||||
|
}, [camera, autoBounds]);
|
||||||
|
|
||||||
|
// Track dynamic bounds without triggering React re-renders!
|
||||||
|
useFrame(() => {
|
||||||
|
if (camera instanceof THREE.OrthographicCamera && boundsTextRef.current) {
|
||||||
|
const width = (camera.right - camera.left) / camera.zoom;
|
||||||
|
const height = (camera.top - camera.bottom) / camera.zoom;
|
||||||
|
const minX = Math.round(camera.position.x - width / 2);
|
||||||
|
const maxX = Math.round(camera.position.x + width / 2);
|
||||||
|
const minZ = Math.round(camera.position.z - height / 2);
|
||||||
|
const maxZ = Math.round(camera.position.z + height / 2);
|
||||||
|
|
||||||
|
// Direct DOM mutation for 60fps performance (prevents WebGL Context Lost!)
|
||||||
|
boundsTextRef.current.innerText = JSON.stringify(
|
||||||
|
{ minX, maxX, minZ, maxZ },
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach screenshot capture logic
|
||||||
|
useEffect(() => {
|
||||||
|
(window as any).downloadMapScreenshot = () => {
|
||||||
|
// Force an immediate render frame to ensure no UI overlays are missing
|
||||||
|
gl.render(scene, camera);
|
||||||
|
const dataUrl = gl.domElement.toDataURL("image/png");
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = dataUrl;
|
||||||
|
a.download = "/assets/gps/map_background.png";
|
||||||
|
a.click();
|
||||||
|
};
|
||||||
|
return () => {
|
||||||
|
delete (window as any).downloadMapScreenshot;
|
||||||
|
};
|
||||||
|
}, [gl, camera, scene]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MapControls ref={controlsRef} enableRotate={false} dampingFactor={0.05} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 4. Main Page Route Component
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
export function BackgroundMapPage() {
|
||||||
|
const [waypoints, setWaypoints] = useState<any[]>([]);
|
||||||
|
const [showWaypoints, setShowWaypoints] = useState(true);
|
||||||
|
const boundsTextRef = useRef<HTMLPreElement>(null);
|
||||||
|
|
||||||
|
// Load road network waypoints to compute perfect GPS bounds
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = localStorage.getItem("la-fabrik-waypoints");
|
||||||
|
if (saved) {
|
||||||
|
setWaypoints(JSON.parse(saved));
|
||||||
|
} else {
|
||||||
|
fetch("/roadNetwork.json")
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => setWaypoints(data))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Compute exact bounds that the EbikeGPSMap will use by default
|
||||||
|
const autoBounds = useMemo(() => {
|
||||||
|
if (waypoints.length === 0) return null;
|
||||||
|
const xs = waypoints.map((w) => w.x);
|
||||||
|
const zs = waypoints.map((w) => w.z);
|
||||||
|
const minX = Math.min(...xs);
|
||||||
|
const maxX = Math.max(...xs);
|
||||||
|
const minZ = Math.min(...zs);
|
||||||
|
const maxZ = Math.max(...zs);
|
||||||
|
|
||||||
|
// CRITICAL: We MUST force the camera bounds to be a PERFECT SQUARE.
|
||||||
|
// If the camera is rectangular, the exported PNG will be distorted when drawn
|
||||||
|
// on the EbikeGPSMap's 1024x1024 canvas!
|
||||||
|
const width = maxX - minX;
|
||||||
|
const height = maxZ - minZ;
|
||||||
|
const maxDim = Math.max(width, height);
|
||||||
|
|
||||||
|
const centerX = (minX + maxX) / 2;
|
||||||
|
const centerZ = (minZ + maxZ) / 2;
|
||||||
|
|
||||||
|
const paddedDim = maxDim * 1.15 || 100;
|
||||||
|
|
||||||
|
return {
|
||||||
|
minX: centerX - paddedDim / 2,
|
||||||
|
maxX: centerX + paddedDim / 2,
|
||||||
|
minZ: centerZ - paddedDim / 2,
|
||||||
|
maxZ: centerZ + paddedDim / 2,
|
||||||
|
};
|
||||||
|
}, [waypoints]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "100vw",
|
||||||
|
height: "100vh",
|
||||||
|
background: "#050505",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/*
|
||||||
|
CRITICAL: The DOM element MUST be a perfect square so the resulting PNG
|
||||||
|
is exactly 1:1, preventing stretching in the EbikeGPSMap canvas texture!
|
||||||
|
*/}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "min(100vw, 100vh)",
|
||||||
|
height: "min(100vw, 100vh)",
|
||||||
|
background: "#000",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Canvas
|
||||||
|
gl={{ preserveDrawingBuffer: true, antialias: true, alpha: false }}
|
||||||
|
>
|
||||||
|
<OrthographicCamera
|
||||||
|
makeDefault
|
||||||
|
position={[0, 200, 0]}
|
||||||
|
near={0.1}
|
||||||
|
far={1000}
|
||||||
|
/>
|
||||||
|
<TerrainScene />
|
||||||
|
<WaypointOverlay waypoints={waypoints} visible={showWaypoints} />
|
||||||
|
<CameraManager
|
||||||
|
autoBounds={autoBounds}
|
||||||
|
boundsTextRef={boundsTextRef}
|
||||||
|
/>
|
||||||
|
</Canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Premium Glassmorphic UI Dashboard */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 24,
|
||||||
|
left: 24,
|
||||||
|
background: "rgba(15, 23, 42, 0.85)",
|
||||||
|
padding: 24,
|
||||||
|
borderRadius: 16,
|
||||||
|
border: "1px solid #334155",
|
||||||
|
color: "white",
|
||||||
|
fontFamily: "system-ui, sans-serif",
|
||||||
|
backdropFilter: "blur(12px)",
|
||||||
|
width: 360,
|
||||||
|
boxShadow: "0 20px 25px -5px rgba(0, 0, 0, 0.5)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
style={{ margin: "0 0 16px 0", fontSize: "1.4rem", color: "#38bdf8" }}
|
||||||
|
>
|
||||||
|
GPS Map Generator
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
color: "#94a3b8",
|
||||||
|
marginBottom: 20,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
1. Cadrez votre carte (ou utilisez le <b>Cadrage Automatique</b>).
|
||||||
|
<br />
|
||||||
|
2. Masquez les waypoints (fond visuel seul).
|
||||||
|
<br />
|
||||||
|
3. Cliquez sur <b>Capturer la carte</b>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowWaypoints(!showWaypoints)}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "12px",
|
||||||
|
marginBottom: 12,
|
||||||
|
background: showWaypoints ? "#1e293b" : "#334155",
|
||||||
|
border: "1px solid #475569",
|
||||||
|
color: "white",
|
||||||
|
borderRadius: 8,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: 600,
|
||||||
|
transition: "all 0.2s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showWaypoints ? "👁️ Masquer Waypoints" : "👁️🗨️ Afficher Waypoints"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if ((window as any).applyAutoBounds)
|
||||||
|
(window as any).applyAutoBounds();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "12px",
|
||||||
|
marginBottom: 16,
|
||||||
|
background: "#1e293b",
|
||||||
|
border: "1px solid #475569",
|
||||||
|
color: "#10b981",
|
||||||
|
borderRadius: 8,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: 600,
|
||||||
|
transition: "all 0.2s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🎯 Cadrage Automatique
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if ((window as any).downloadMapScreenshot)
|
||||||
|
(window as any).downloadMapScreenshot();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "14px",
|
||||||
|
background: "#0ea5e9",
|
||||||
|
border: "none",
|
||||||
|
color: "white",
|
||||||
|
borderRadius: 8,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: "bold",
|
||||||
|
fontSize: "1rem",
|
||||||
|
boxShadow: "0 4px 6px -1px rgba(14, 165, 233, 0.4)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📸 Capturer la carte (.png)
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 24,
|
||||||
|
padding: 16,
|
||||||
|
background: "#020617",
|
||||||
|
borderRadius: 10,
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ color: "#64748b", marginBottom: 8, fontWeight: 600 }}>
|
||||||
|
Limites Actuelles (worldBounds):
|
||||||
|
</div>
|
||||||
|
<pre
|
||||||
|
ref={boundsTextRef}
|
||||||
|
style={{ margin: 0, color: "#10b981", fontFamily: "monospace" }}
|
||||||
|
>
|
||||||
|
Calcul...
|
||||||
|
</pre>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: "#ef4444",
|
||||||
|
marginTop: 12,
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
*Si vous décadrez à la souris, vous devrez copier ces valeurs
|
||||||
|
exactes dans la prop <code>worldBounds</code> de votre composant{" "}
|
||||||
|
<b>EbikeGPSMap</b> !
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
Astuce : Utilisez le <b>Cadrage Automatique</b> pour ne rien avoir à
|
||||||
|
configurer.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+96
-385
@@ -1,302 +1,56 @@
|
|||||||
import { Suspense, useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Canvas } from "@react-three/fiber";
|
import { Canvas, useThree } from "@react-three/fiber";
|
||||||
import { useProgress } from "@react-three/drei";
|
|
||||||
import { EditorControls } from "@/components/editor/EditorControls";
|
import { EditorControls } from "@/components/editor/EditorControls";
|
||||||
import { EditorScene } from "@/components/editor/scene/EditorScene";
|
import { EditorScene } from "@/components/editor/scene/EditorScene";
|
||||||
import type { EditorCinematicPreviewRequest } from "@/components/editor/scene/EditorScene";
|
|
||||||
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
|
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
|
||||||
import { Subtitles } from "@/components/ui/Subtitles";
|
import { Subtitles } from "@/components/ui/Subtitles";
|
||||||
import { INITIAL_SCENE_LOADING_STATE } from "@/data/world/sceneLoadingConfig";
|
|
||||||
import { useEditorHistory } from "@/hooks/editor/useEditorHistory";
|
import { useEditorHistory } from "@/hooks/editor/useEditorHistory";
|
||||||
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
|
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
|
||||||
import { useEditorSceneData } from "@/hooks/editor/useEditorSceneData";
|
import { useEditorSceneData } from "@/hooks/editor/useEditorSceneData";
|
||||||
import type {
|
import type {
|
||||||
HierarchicalMapNode,
|
EditorCinematicPreviewRequest,
|
||||||
MapNode,
|
MapNode,
|
||||||
SceneData,
|
|
||||||
TransformMode,
|
TransformMode,
|
||||||
} from "@/types/editor/editor";
|
} from "@/types/editor/editor";
|
||||||
import {
|
import type { SceneLoadingState } from "@/types/world/sceneLoading";
|
||||||
type SceneLoadingChangeHandler,
|
|
||||||
type SceneLoadingState,
|
|
||||||
} from "@/types/world/sceneLoading";
|
|
||||||
import { logger } from "@/utils/core/Logger";
|
import { logger } from "@/utils/core/Logger";
|
||||||
|
import {
|
||||||
|
addTreeNode,
|
||||||
|
createNewMapNode,
|
||||||
|
mergeFlatNodeTransformsIntoTree,
|
||||||
|
removeEditorMetadata,
|
||||||
|
removeTreeNodeAtPath,
|
||||||
|
serializeMapNodes,
|
||||||
|
updateSceneDataTree,
|
||||||
|
updateTreeNodeAtPath,
|
||||||
|
} from "@/utils/editor/editorMapTree";
|
||||||
|
|
||||||
const SAVE_ERROR_MESSAGE = "Erreur lors de l'enregistrement";
|
const SAVE_ERROR_MESSAGE = "Erreur lors de l'enregistrement";
|
||||||
const DEFAULT_NEW_NODE_NAME = "new-model";
|
const DEFAULT_NEW_NODE_NAME = "new-model";
|
||||||
|
|
||||||
interface EditorSceneLoadingTrackerProps {
|
function EditorWebGLContextLogger(): null {
|
||||||
onLoadingStateChange: SceneLoadingChangeHandler;
|
const gl = useThree((state) => state.gl);
|
||||||
}
|
|
||||||
|
|
||||||
function serializeMapNodes(sceneData: SceneData): string {
|
|
||||||
const mapPayload = sceneData.mapTree
|
|
||||||
? mergeFlatNodeTransformsIntoTree(sceneData)
|
|
||||||
: sceneData.mapNodes.map(removeEditorMetadata);
|
|
||||||
|
|
||||||
return JSON.stringify(mapPayload, null, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createSourcePathKey(sourcePath: readonly number[]): string {
|
|
||||||
return sourcePath.join(".");
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeEditorMetadata(node: MapNode): MapNode {
|
|
||||||
return {
|
|
||||||
name: node.name,
|
|
||||||
type: node.type,
|
|
||||||
position: node.position,
|
|
||||||
rotation: node.rotation,
|
|
||||||
scale: node.scale,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeFlatNodeTransformsIntoTree(
|
|
||||||
sceneData: SceneData,
|
|
||||||
): HierarchicalMapNode | HierarchicalMapNode[] {
|
|
||||||
const nodesBySourcePath = new Map<string, MapNode>();
|
|
||||||
|
|
||||||
for (const node of sceneData.mapNodes) {
|
|
||||||
if (!node.sourcePath) continue;
|
|
||||||
nodesBySourcePath.set(createSourcePathKey(node.sourcePath), node);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cloneNode = (
|
|
||||||
node: HierarchicalMapNode,
|
|
||||||
path: number[],
|
|
||||||
): HierarchicalMapNode => {
|
|
||||||
const updatedNode = nodesBySourcePath.get(createSourcePathKey(path));
|
|
||||||
const nextNode: HierarchicalMapNode = {
|
|
||||||
name: node.name,
|
|
||||||
type: node.type,
|
|
||||||
position: updatedNode?.position ?? node.position,
|
|
||||||
rotation: updatedNode?.rotation ?? node.rotation,
|
|
||||||
scale: updatedNode?.scale ?? node.scale,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (node.role) {
|
|
||||||
nextNode.role = node.role;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.children) {
|
|
||||||
nextNode.children = node.children.map((child, index) =>
|
|
||||||
cloneNode(child, [...path, index]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return nextNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapTree = sceneData.mapTree;
|
|
||||||
|
|
||||||
if (!mapTree) {
|
|
||||||
return sceneData.mapNodes.map(removeEditorMetadata);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(mapTree)) {
|
|
||||||
return mapTree.map((node, index) => cloneNode(node, [index]));
|
|
||||||
}
|
|
||||||
|
|
||||||
return cloneNode(mapTree, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
function cloneMapTree(
|
|
||||||
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
|
|
||||||
): HierarchicalMapNode | HierarchicalMapNode[] {
|
|
||||||
return JSON.parse(JSON.stringify(mapTree)) as
|
|
||||||
| HierarchicalMapNode
|
|
||||||
| HierarchicalMapNode[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectEditableMapNodes(
|
|
||||||
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
|
|
||||||
): MapNode[] {
|
|
||||||
const nodes: MapNode[] = [];
|
|
||||||
|
|
||||||
function visit(node: HierarchicalMapNode, path: number[]): void {
|
|
||||||
if (node.role !== "group" && node.type !== "Mesh") {
|
|
||||||
nodes.push({
|
|
||||||
name: node.name,
|
|
||||||
position: node.position,
|
|
||||||
rotation: node.rotation,
|
|
||||||
scale: node.scale,
|
|
||||||
sourcePath: path,
|
|
||||||
type: node.type,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
node.children?.forEach((child, index) => visit(child, [...path, index]));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(mapTree)) {
|
|
||||||
mapTree.forEach((node, index) => visit(node, [index]));
|
|
||||||
} else {
|
|
||||||
visit(mapTree, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
return nodes;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateTreeNodeAtPath(
|
|
||||||
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
|
|
||||||
path: number[],
|
|
||||||
update: (node: HierarchicalMapNode) => HierarchicalMapNode,
|
|
||||||
): HierarchicalMapNode | HierarchicalMapNode[] {
|
|
||||||
const nextTree = cloneMapTree(mapTree);
|
|
||||||
const rootNodes = Array.isArray(nextTree) ? nextTree : [nextTree];
|
|
||||||
const targetIndex = path[path.length - 1] ?? 0;
|
|
||||||
const isRootTarget = Array.isArray(nextTree)
|
|
||||||
? path.length === 1
|
|
||||||
: path.length === 0;
|
|
||||||
|
|
||||||
if (isRootTarget) {
|
|
||||||
const targetNode = rootNodes[targetIndex];
|
|
||||||
if (targetNode) {
|
|
||||||
rootNodes[targetIndex] = update(targetNode);
|
|
||||||
}
|
|
||||||
return nextTree;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parentPath = path.slice(0, -1);
|
|
||||||
let parent = Array.isArray(nextTree)
|
|
||||||
? rootNodes[parentPath[0] ?? 0]
|
|
||||||
: rootNodes[0];
|
|
||||||
const childPath = Array.isArray(nextTree) ? parentPath.slice(1) : parentPath;
|
|
||||||
|
|
||||||
for (const index of childPath) {
|
|
||||||
parent = parent?.children?.[index];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!parent?.children?.[targetIndex]) return nextTree;
|
|
||||||
parent.children[targetIndex] = update(parent.children[targetIndex]);
|
|
||||||
|
|
||||||
return nextTree;
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeTreeNodeAtPath(
|
|
||||||
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
|
|
||||||
path: number[],
|
|
||||||
): HierarchicalMapNode | HierarchicalMapNode[] {
|
|
||||||
const nextTree = cloneMapTree(mapTree);
|
|
||||||
const rootNodes = Array.isArray(nextTree) ? nextTree : [nextTree];
|
|
||||||
const targetIndex = path[path.length - 1];
|
|
||||||
if (targetIndex === undefined) return nextTree;
|
|
||||||
|
|
||||||
if (Array.isArray(nextTree) && path.length === 1) {
|
|
||||||
nextTree.splice(targetIndex, 1);
|
|
||||||
return nextTree;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parentPath = path.slice(0, -1);
|
|
||||||
let parent = Array.isArray(nextTree)
|
|
||||||
? rootNodes[parentPath[0] ?? 0]
|
|
||||||
: rootNodes[0];
|
|
||||||
const childPath = Array.isArray(nextTree) ? parentPath.slice(1) : parentPath;
|
|
||||||
|
|
||||||
for (const index of childPath) {
|
|
||||||
parent = parent?.children?.[index];
|
|
||||||
}
|
|
||||||
|
|
||||||
parent?.children?.splice(targetIndex, 1);
|
|
||||||
return nextTree;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSceneDataTree(
|
|
||||||
sceneData: SceneData,
|
|
||||||
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
|
|
||||||
): SceneData {
|
|
||||||
return {
|
|
||||||
...sceneData,
|
|
||||||
mapNodes: collectEditableMapNodes(mapTree),
|
|
||||||
mapTree,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function findNodePathByName(
|
|
||||||
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
|
|
||||||
name: string,
|
|
||||||
): number[] | null {
|
|
||||||
function visit(node: HierarchicalMapNode, path: number[]): number[] | null {
|
|
||||||
if (node.name === name) return path;
|
|
||||||
|
|
||||||
for (let index = 0; index < (node.children?.length ?? 0); index++) {
|
|
||||||
const child = node.children?.[index];
|
|
||||||
if (!child) continue;
|
|
||||||
const result = visit(child, [...path, index]);
|
|
||||||
if (result) return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(mapTree)) {
|
|
||||||
for (let index = 0; index < mapTree.length; index++) {
|
|
||||||
const node = mapTree[index];
|
|
||||||
if (!node) continue;
|
|
||||||
const result = visit(node, [index]);
|
|
||||||
if (result) return result;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return visit(mapTree, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addTreeNode(
|
|
||||||
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
|
|
||||||
node: HierarchicalMapNode,
|
|
||||||
): HierarchicalMapNode | HierarchicalMapNode[] {
|
|
||||||
const blockingPath = findNodePathByName(mapTree, "blocking");
|
|
||||||
if (!blockingPath) return mapTree;
|
|
||||||
|
|
||||||
return updateTreeNodeAtPath(mapTree, blockingPath, (blockingNode) => ({
|
|
||||||
...blockingNode,
|
|
||||||
children: [...(blockingNode.children ?? []), node],
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function createNewMapNode(name: string): HierarchicalMapNode {
|
|
||||||
const safeName = name.trim() || DEFAULT_NEW_NODE_NAME;
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: safeName,
|
|
||||||
type: "Object3D",
|
|
||||||
position: [0, 0, 0],
|
|
||||||
rotation: [0, 0, 0],
|
|
||||||
scale: [1, 1, 1],
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
name: safeName,
|
|
||||||
type: "Mesh",
|
|
||||||
position: [0, 0, 0],
|
|
||||||
rotation: [0, 0, 0],
|
|
||||||
scale: [1, 1, 1],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function EditorSceneLoadingTracker({
|
|
||||||
onLoadingStateChange,
|
|
||||||
}: EditorSceneLoadingTrackerProps): null {
|
|
||||||
const { active, progress } = useProgress();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (active) {
|
gl.setClearColor("#050505");
|
||||||
onLoadingStateChange({
|
|
||||||
currentStep: "Importation des models",
|
|
||||||
progress: 0.2 + (progress / 100) * 0.7,
|
|
||||||
status: "loading",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onLoadingStateChange({
|
const canvas = gl.domElement;
|
||||||
currentStep: "Gameplay prêt",
|
const handleContextLost = (event: Event) => {
|
||||||
progress: 1,
|
event.preventDefault();
|
||||||
status: "ready",
|
logger.error("WebGL", "Context lost - GPU resources exhausted");
|
||||||
});
|
};
|
||||||
}, [active, onLoadingStateChange, progress]);
|
const handleContextRestored = () => {
|
||||||
|
logger.info("WebGL", "Context restored");
|
||||||
|
};
|
||||||
|
|
||||||
|
canvas.addEventListener("webglcontextlost", handleContextLost);
|
||||||
|
canvas.addEventListener("webglcontextrestored", handleContextRestored);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
canvas.removeEventListener("webglcontextlost", handleContextLost);
|
||||||
|
canvas.removeEventListener("webglcontextrestored", handleContextRestored);
|
||||||
|
};
|
||||||
|
}, [gl]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -329,35 +83,17 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
const [cameraViewMode, setCameraViewMode] = useState<"home" | "object">(
|
const [cameraViewMode, setCameraViewMode] = useState<"home" | "object">(
|
||||||
"home",
|
"home",
|
||||||
);
|
);
|
||||||
const [sceneLoadingState, setSceneLoadingState] = useState<SceneLoadingState>(
|
const editorLoadingState: SceneLoadingState = isMapLoading
|
||||||
{
|
|
||||||
...INITIAL_SCENE_LOADING_STATE,
|
|
||||||
currentStep: "Montage progressif des models",
|
|
||||||
progress: 0.2,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const handleSceneLoadingStateChange = useCallback(
|
|
||||||
(nextState: SceneLoadingState) => {
|
|
||||||
setSceneLoadingState((currentState) => {
|
|
||||||
const shouldRestartProgress = currentState.status === "ready";
|
|
||||||
|
|
||||||
return {
|
|
||||||
...nextState,
|
|
||||||
progress: shouldRestartProgress
|
|
||||||
? nextState.progress
|
|
||||||
: Math.max(currentState.progress, nextState.progress),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const editorLoadingState = isMapLoading
|
|
||||||
? {
|
? {
|
||||||
currentStep: "Récupération blocking",
|
currentStep: "Chargement de la carte",
|
||||||
progress: 0.08,
|
progress: 0.08,
|
||||||
status: "loading" as const,
|
status: "loading" as const,
|
||||||
}
|
}
|
||||||
: sceneLoadingState;
|
: {
|
||||||
|
currentStep: "Gameplay prêt",
|
||||||
|
progress: 1,
|
||||||
|
status: "ready" as const,
|
||||||
|
};
|
||||||
const [cinematicPreviewRequest, setCinematicPreviewRequest] =
|
const [cinematicPreviewRequest, setCinematicPreviewRequest] =
|
||||||
useState<EditorCinematicPreviewRequest | null>(null);
|
useState<EditorCinematicPreviewRequest | null>(null);
|
||||||
|
|
||||||
@@ -383,13 +119,14 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
setResetCameraRequest((request) => request + 1);
|
setResetCameraRequest((request) => request + 1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleToggleNodeSelection = useCallback((index: number) => {
|
const handleToggleNodeSelection = useCallback(
|
||||||
setSelectedNodeIndexes((currentIndexes) => {
|
(index: number) => {
|
||||||
const isSelected = currentIndexes.includes(index);
|
const isSelected = selectedNodeIndexes.includes(index);
|
||||||
const nextIndexes = isSelected
|
const nextIndexes = isSelected
|
||||||
? currentIndexes.filter((item) => item !== index)
|
? selectedNodeIndexes.filter((item) => item !== index)
|
||||||
: [...currentIndexes, index];
|
: [...selectedNodeIndexes, index];
|
||||||
|
|
||||||
|
setSelectedNodeIndexes(nextIndexes);
|
||||||
setSelectedNodeIndex(nextIndexes.at(-1) ?? null);
|
setSelectedNodeIndex(nextIndexes.at(-1) ?? null);
|
||||||
if (nextIndexes.length > 0) {
|
if (nextIndexes.length > 0) {
|
||||||
setCameraViewMode("object");
|
setCameraViewMode("object");
|
||||||
@@ -397,10 +134,9 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
setCameraViewMode("home");
|
setCameraViewMode("home");
|
||||||
setResetCameraRequest((request) => request + 1);
|
setResetCameraRequest((request) => request + 1);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
return nextIndexes;
|
[selectedNodeIndexes],
|
||||||
});
|
);
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleClearSelection = useCallback(() => {
|
const handleClearSelection = useCallback(() => {
|
||||||
setSelectedNodeIndex(null);
|
setSelectedNodeIndex(null);
|
||||||
@@ -446,28 +182,20 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
|
|
||||||
if (!locked) return;
|
if (!locked) return;
|
||||||
|
|
||||||
setSelectedNodeIndex((currentIndex) => {
|
const nextIndexes = selectedNodeIndexes.filter(
|
||||||
if (currentIndex === null) return null;
|
|
||||||
|
|
||||||
const selectedNode = sceneData?.mapNodes[currentIndex];
|
|
||||||
if (selectedNode?.name === "terrain") {
|
|
||||||
setSelectedNodeIndexes((indexes) =>
|
|
||||||
indexes.filter(
|
|
||||||
(index) => sceneData?.mapNodes[index]?.name !== "terrain",
|
(index) => sceneData?.mapNodes[index]?.name !== "terrain",
|
||||||
),
|
|
||||||
);
|
);
|
||||||
return null;
|
const selectedNode =
|
||||||
}
|
selectedNodeIndex !== null
|
||||||
|
? sceneData?.mapNodes[selectedNodeIndex]
|
||||||
|
: null;
|
||||||
|
|
||||||
setSelectedNodeIndexes((indexes) =>
|
setSelectedNodeIndexes(nextIndexes);
|
||||||
indexes.filter(
|
setSelectedNodeIndex(
|
||||||
(index) => sceneData?.mapNodes[index]?.name !== "terrain",
|
selectedNode?.name === "terrain" ? null : selectedNodeIndex,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
return currentIndex;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[sceneData],
|
[sceneData, selectedNodeIndex, selectedNodeIndexes],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleHoverNode = useCallback((index: number | null) => {
|
const handleHoverNode = useCallback((index: number | null) => {
|
||||||
@@ -601,51 +329,55 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleAddNode = useCallback(() => {
|
const handleAddNode = useCallback(() => {
|
||||||
setSceneData((prev) => {
|
if (!sceneData) return;
|
||||||
if (!prev) return null;
|
|
||||||
if (!prev.mapTree) {
|
if (!sceneData.mapTree) {
|
||||||
const newNode = createNewMapNode(newNodeName);
|
const newNode = createNewMapNode(newNodeName);
|
||||||
const mapNodes = [...prev.mapNodes, removeEditorMetadata(newNode)];
|
const mapNodes = [...sceneData.mapNodes, removeEditorMetadata(newNode)];
|
||||||
setSelectedNodeIndex(mapNodes.length - 1);
|
const selectedIndex = mapNodes.length - 1;
|
||||||
setSelectedNodeIndexes([mapNodes.length - 1]);
|
|
||||||
return { ...prev, mapNodes };
|
setSceneData({ ...sceneData, mapNodes });
|
||||||
|
setSelectedNodeIndex(selectedIndex);
|
||||||
|
setSelectedNodeIndexes([selectedIndex]);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapTree = addTreeNode(prev.mapTree, createNewMapNode(newNodeName));
|
const mapTree = addTreeNode(
|
||||||
const nextSceneData = updateSceneDataTree(prev, mapTree);
|
sceneData.mapTree,
|
||||||
setSelectedNodeIndex(nextSceneData.mapNodes.length - 1);
|
createNewMapNode(newNodeName),
|
||||||
setSelectedNodeIndexes([nextSceneData.mapNodes.length - 1]);
|
);
|
||||||
return nextSceneData;
|
const nextSceneData = updateSceneDataTree(sceneData, mapTree);
|
||||||
});
|
const selectedIndex = nextSceneData.mapNodes.length - 1;
|
||||||
}, [newNodeName, setSceneData]);
|
|
||||||
|
setSceneData(nextSceneData);
|
||||||
|
setSelectedNodeIndex(selectedIndex);
|
||||||
|
setSelectedNodeIndexes([selectedIndex]);
|
||||||
|
}, [newNodeName, sceneData, setSceneData]);
|
||||||
|
|
||||||
const handleDeleteSelectedNode = useCallback(() => {
|
const handleDeleteSelectedNode = useCallback(() => {
|
||||||
if (selectedNodeIndex === null) return;
|
if (!sceneData || selectedNodeIndex === null) return;
|
||||||
|
|
||||||
setSceneData((prev) => {
|
const currentNode = sceneData.mapNodes[selectedNodeIndex];
|
||||||
if (!prev) return null;
|
if (!currentNode) return;
|
||||||
const currentNode = prev.mapNodes[selectedNodeIndex];
|
|
||||||
if (!currentNode) return prev;
|
if (!sceneData.mapTree || !currentNode.sourcePath) {
|
||||||
if (!prev.mapTree || !currentNode.sourcePath) {
|
setSceneData({
|
||||||
setSelectedNodeIndex(null);
|
...sceneData,
|
||||||
setSelectedNodeIndexes([]);
|
mapNodes: sceneData.mapNodes.filter(
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
mapNodes: prev.mapNodes.filter(
|
|
||||||
(_node, index) => index !== selectedNodeIndex,
|
(_node, index) => index !== selectedNodeIndex,
|
||||||
),
|
),
|
||||||
};
|
});
|
||||||
}
|
} else {
|
||||||
|
|
||||||
const mapTree = removeTreeNodeAtPath(
|
const mapTree = removeTreeNodeAtPath(
|
||||||
prev.mapTree,
|
sceneData.mapTree,
|
||||||
currentNode.sourcePath,
|
currentNode.sourcePath,
|
||||||
);
|
);
|
||||||
|
setSceneData(updateSceneDataTree(sceneData, mapTree));
|
||||||
|
}
|
||||||
|
|
||||||
setSelectedNodeIndex(null);
|
setSelectedNodeIndex(null);
|
||||||
setSelectedNodeIndexes([]);
|
setSelectedNodeIndexes([]);
|
||||||
return updateSceneDataTree(prev, mapTree);
|
}, [sceneData, selectedNodeIndex, setSceneData]);
|
||||||
});
|
|
||||||
}, [selectedNodeIndex, setSceneData]);
|
|
||||||
|
|
||||||
if (isMapLoading) {
|
if (isMapLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -702,28 +434,8 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
antialias: true,
|
antialias: true,
|
||||||
stencil: false,
|
stencil: false,
|
||||||
}}
|
}}
|
||||||
onCreated={({ gl }) => {
|
|
||||||
gl.setClearColor("#050505");
|
|
||||||
|
|
||||||
const canvas = gl.domElement;
|
|
||||||
const handleContextLost = (event: Event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
logger.error("WebGL", "Context lost - GPU resources exhausted");
|
|
||||||
};
|
|
||||||
const handleContextRestored = () => {
|
|
||||||
logger.info("WebGL", "Context restored");
|
|
||||||
};
|
|
||||||
canvas.addEventListener("webglcontextlost", handleContextLost);
|
|
||||||
canvas.addEventListener(
|
|
||||||
"webglcontextrestored",
|
|
||||||
handleContextRestored,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<EditorSceneLoadingTracker
|
<EditorWebGLContextLogger />
|
||||||
onLoadingStateChange={handleSceneLoadingStateChange}
|
|
||||||
/>
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<EditorScene
|
<EditorScene
|
||||||
sceneData={sceneData!}
|
sceneData={sceneData!}
|
||||||
selectedNodeIndex={selectedNodeIndex}
|
selectedNodeIndex={selectedNodeIndex}
|
||||||
@@ -750,7 +462,6 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
cinematicPreviewRequest={cinematicPreviewRequest}
|
cinematicPreviewRequest={cinematicPreviewRequest}
|
||||||
onCinematicPreviewComplete={handleCinematicPreviewComplete}
|
onCinematicPreviewComplete={handleCinematicPreviewComplete}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
|
||||||
</Canvas>
|
</Canvas>
|
||||||
|
|
||||||
<SceneLoadingOverlay state={editorLoadingState} />
|
<SceneLoadingOverlay state={editorLoadingState} />
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,131 @@
|
|||||||
|
import { Grid } from "./Grid";
|
||||||
|
import type { GridNode, Position } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the octile heuristic distance between two nodes.
|
||||||
|
* Ideal for 8-directional grid movement.
|
||||||
|
*/
|
||||||
|
function getOctileDistance(nodeA: GridNode, nodeB: GridNode): number {
|
||||||
|
const dx = Math.abs(nodeA.x - nodeB.x);
|
||||||
|
const dy = Math.abs(nodeA.y - nodeB.y);
|
||||||
|
|
||||||
|
const D = 1; // Orthogonal movement cost
|
||||||
|
const D2 = 1.414; // Diagonal movement cost (approx Math.sqrt(2))
|
||||||
|
|
||||||
|
return D * (dx + dy) + (D2 - 2 * D) * Math.min(dx, dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the shortest path between start and end positions on the grid.
|
||||||
|
* Returns an array of Positions representing the path, or an empty array if no path is found.
|
||||||
|
*/
|
||||||
|
export function findPath(
|
||||||
|
grid: Grid,
|
||||||
|
startPos: Position,
|
||||||
|
endPos: Position,
|
||||||
|
allowDiagonals: boolean = true,
|
||||||
|
): Position[] {
|
||||||
|
grid.reset();
|
||||||
|
|
||||||
|
const startNode = grid.getNode(
|
||||||
|
Math.floor(startPos.x),
|
||||||
|
Math.floor(startPos.y),
|
||||||
|
);
|
||||||
|
const endNode = grid.getNode(Math.floor(endPos.x), Math.floor(endPos.y));
|
||||||
|
|
||||||
|
if (!startNode || !endNode) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the destination node itself is blocked, we try to find the nearest walkable neighbor
|
||||||
|
if (!endNode.walkable) {
|
||||||
|
const endNeighbors = grid.getNeighbors(endNode, allowDiagonals);
|
||||||
|
if (endNeighbors.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
// Set destination to the closest walkable neighbor
|
||||||
|
let closestNeighbor = endNeighbors[0]!;
|
||||||
|
let minDist = getOctileDistance(startNode, closestNeighbor);
|
||||||
|
for (let i = 1; i < endNeighbors.length; i++) {
|
||||||
|
const neighbor = endNeighbors[i]!;
|
||||||
|
const dist = getOctileDistance(startNode, neighbor);
|
||||||
|
if (dist < minDist) {
|
||||||
|
minDist = dist;
|
||||||
|
closestNeighbor = neighbor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Reroute to that walkable neighbor
|
||||||
|
return findPath(
|
||||||
|
grid,
|
||||||
|
startPos,
|
||||||
|
{ x: closestNeighbor.x, y: closestNeighbor.y },
|
||||||
|
allowDiagonals,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const openSet: GridNode[] = [startNode];
|
||||||
|
const closedSet = new Set<GridNode>();
|
||||||
|
|
||||||
|
startNode.g = 0;
|
||||||
|
startNode.h = getOctileDistance(startNode, endNode);
|
||||||
|
startNode.f = startNode.h;
|
||||||
|
|
||||||
|
while (openSet.length > 0) {
|
||||||
|
// Find the node in openSet with the lowest f value
|
||||||
|
let lowIndex = 0;
|
||||||
|
for (let i = 1; i < openSet.length; i++) {
|
||||||
|
const node = openSet[i]!;
|
||||||
|
const lowNode = openSet[lowIndex]!;
|
||||||
|
if (node.f < lowNode.f) {
|
||||||
|
lowIndex = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentNode = openSet[lowIndex]!;
|
||||||
|
|
||||||
|
// Check if we reached the destination
|
||||||
|
if (currentNode === endNode) {
|
||||||
|
const path: Position[] = [];
|
||||||
|
let temp: GridNode | null = currentNode;
|
||||||
|
while (temp !== null) {
|
||||||
|
path.push({ x: temp.x, y: temp.y });
|
||||||
|
temp = temp.parent;
|
||||||
|
}
|
||||||
|
return path.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove currentNode from openSet and add to closedSet
|
||||||
|
openSet.splice(lowIndex, 1);
|
||||||
|
closedSet.add(currentNode);
|
||||||
|
|
||||||
|
const neighbors = grid.getNeighbors(currentNode, allowDiagonals);
|
||||||
|
|
||||||
|
for (const neighbor of neighbors) {
|
||||||
|
if (closedSet.has(neighbor)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate cost to move to this neighbor (1 for orthogonal, 1.414 for diagonal)
|
||||||
|
const isDiagonal =
|
||||||
|
neighbor.x !== currentNode.x && neighbor.y !== currentNode.y;
|
||||||
|
const moveCost = isDiagonal ? 1.414 : 1;
|
||||||
|
const tentativeG = currentNode.g + moveCost;
|
||||||
|
|
||||||
|
const neighborInOpenSet = openSet.includes(neighbor);
|
||||||
|
|
||||||
|
if (!neighborInOpenSet || tentativeG < neighbor.g) {
|
||||||
|
neighbor.parent = currentNode;
|
||||||
|
neighbor.g = tentativeG;
|
||||||
|
neighbor.h = getOctileDistance(neighbor, endNode);
|
||||||
|
neighbor.f = neighbor.g + neighbor.h;
|
||||||
|
|
||||||
|
if (!neighborInOpenSet) {
|
||||||
|
openSet.push(neighbor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return empty if no path is found
|
||||||
|
return [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import React, { useRef, useEffect, useState, useMemo } from "react";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { useGPS } from "./useGPS";
|
||||||
|
import type { WorldBounds } from "./useGPS";
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 1. Premium 2D HUD GPS Overlay Component
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
export interface GPSMinimapHUDProps {
|
||||||
|
bwMaskUrl: string;
|
||||||
|
colorMapUrl: string;
|
||||||
|
gridWidth: number;
|
||||||
|
gridHeight: number;
|
||||||
|
worldBounds: WorldBounds;
|
||||||
|
playerPos: { x: number; z: number };
|
||||||
|
destPos?: { x: number; z: number };
|
||||||
|
size?: number; // Size of HUD in pixels
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A beautiful, glassmorphic 2D HUD overlay that renders the GPS Minimap
|
||||||
|
* in the corner of the screen.
|
||||||
|
*/
|
||||||
|
export const GPSMinimapHUD: React.FC<GPSMinimapHUDProps> = ({
|
||||||
|
bwMaskUrl,
|
||||||
|
colorMapUrl,
|
||||||
|
gridWidth,
|
||||||
|
gridHeight,
|
||||||
|
worldBounds,
|
||||||
|
playerPos,
|
||||||
|
destPos,
|
||||||
|
size = 200,
|
||||||
|
}) => {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
|
const gpsOptions = useMemo(
|
||||||
|
() => ({
|
||||||
|
bwMaskUrl,
|
||||||
|
colorMapUrl,
|
||||||
|
gridWidth,
|
||||||
|
gridHeight,
|
||||||
|
worldBounds,
|
||||||
|
}),
|
||||||
|
[bwMaskUrl, colorMapUrl, gridWidth, gridHeight, worldBounds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { calculateWorldPath, renderGPSToCanvas, loading, error } =
|
||||||
|
useGPS(gpsOptions);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading || error || !canvasRef.current) return;
|
||||||
|
|
||||||
|
// Calculate A* path in world coordinates
|
||||||
|
const path = destPos ? calculateWorldPath(playerPos, destPos) : [];
|
||||||
|
|
||||||
|
// Render path onto HUD canvas
|
||||||
|
renderGPSToCanvas(canvasRef.current, path, playerPos, destPos, {
|
||||||
|
pathColor: "#3b82f6", // Premium vibrant blue
|
||||||
|
pathWidth: 5,
|
||||||
|
playerColor: "#ef4444", // Hot red for player
|
||||||
|
playerSize: 6,
|
||||||
|
destColor: "#10b981", // Emerald green for destination
|
||||||
|
destSize: 6,
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
playerPos,
|
||||||
|
destPos,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
calculateWorldPath,
|
||||||
|
renderGPSToCanvas,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={hudStyles.container(size)}>
|
||||||
|
{loading && <div style={hudStyles.statusText}>Initializing GPS...</div>}
|
||||||
|
{error && (
|
||||||
|
<div style={{ ...hudStyles.statusText, color: "#ef4444" }}>
|
||||||
|
GPS Error: {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
width={size * 2} // Double size for retina/high-DPI screens
|
||||||
|
height={size * 2}
|
||||||
|
style={hudStyles.canvas(size)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 2. 3D Handlebar Screen Mesh Component (R3F)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
export interface GPSBikeScreenProps {
|
||||||
|
bwMaskUrl: string;
|
||||||
|
colorMapUrl: string;
|
||||||
|
gridWidth: number;
|
||||||
|
gridHeight: number;
|
||||||
|
worldBounds: WorldBounds;
|
||||||
|
playerPos: { x: number; z: number };
|
||||||
|
destPos?: { x: number; z: number };
|
||||||
|
width?: number; // 3D Plane Width
|
||||||
|
height?: number; // 3D Plane Height
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Three.js 3D plane mesh that renders the GPS dynamically as a CanvasTexture.
|
||||||
|
* This can be directly attached to the bike's handlebars in your 3D world.
|
||||||
|
*/
|
||||||
|
export const GPSBikeScreen: React.FC<GPSBikeScreenProps> = ({
|
||||||
|
bwMaskUrl,
|
||||||
|
colorMapUrl,
|
||||||
|
gridWidth,
|
||||||
|
gridHeight,
|
||||||
|
worldBounds,
|
||||||
|
playerPos,
|
||||||
|
destPos,
|
||||||
|
width = 0.4,
|
||||||
|
height = 0.4,
|
||||||
|
}) => {
|
||||||
|
// Offscreen canvas to render the GPS texture onto
|
||||||
|
const [offscreenCanvas] = useState(() => {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = 512;
|
||||||
|
canvas.height = 512;
|
||||||
|
return canvas;
|
||||||
|
});
|
||||||
|
|
||||||
|
const textureRef = useRef<THREE.CanvasTexture | null>(null);
|
||||||
|
|
||||||
|
const gpsOptions = useMemo(
|
||||||
|
() => ({
|
||||||
|
bwMaskUrl,
|
||||||
|
colorMapUrl,
|
||||||
|
gridWidth,
|
||||||
|
gridHeight,
|
||||||
|
worldBounds,
|
||||||
|
}),
|
||||||
|
[bwMaskUrl, colorMapUrl, gridWidth, gridHeight, worldBounds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { calculateWorldPath, renderGPSToCanvas, loading } = useGPS(gpsOptions);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) return;
|
||||||
|
|
||||||
|
// Calculate A* path
|
||||||
|
const path = destPos ? calculateWorldPath(playerPos, destPos) : [];
|
||||||
|
|
||||||
|
// Render path onto our offscreen canvas
|
||||||
|
renderGPSToCanvas(offscreenCanvas, path, playerPos, destPos, {
|
||||||
|
pathColor: "#60a5fa", // Bright neon blue
|
||||||
|
pathWidth: 8,
|
||||||
|
playerColor: "#ff0055", // Neon pink-red for bike
|
||||||
|
playerSize: 10,
|
||||||
|
destColor: "#00ffcc", // Vibrant cyan for target
|
||||||
|
destSize: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify Three.js that the texture needs an update
|
||||||
|
if (textureRef.current) {
|
||||||
|
textureRef.current.needsUpdate = true;
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
playerPos,
|
||||||
|
destPos,
|
||||||
|
loading,
|
||||||
|
calculateWorldPath,
|
||||||
|
renderGPSToCanvas,
|
||||||
|
offscreenCanvas,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh castShadow receiveShadow>
|
||||||
|
<planeGeometry args={[width, height]} />
|
||||||
|
<meshBasicMaterial toneMapped={false}>
|
||||||
|
<canvasTexture
|
||||||
|
ref={textureRef}
|
||||||
|
attach="map"
|
||||||
|
image={offscreenCanvas}
|
||||||
|
minFilter={THREE.LinearFilter}
|
||||||
|
magFilter={THREE.LinearFilter}
|
||||||
|
/>
|
||||||
|
</meshBasicMaterial>
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Styles for HUD (Premium Glassmorphism)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
const hudStyles = {
|
||||||
|
container: (size: number): React.CSSProperties => ({
|
||||||
|
position: "absolute",
|
||||||
|
bottom: "24px",
|
||||||
|
right: "24px",
|
||||||
|
width: `${size}px`,
|
||||||
|
height: `${size}px`,
|
||||||
|
borderRadius: "24px",
|
||||||
|
overflow: "hidden",
|
||||||
|
border: "1px solid rgba(255, 255, 255, 0.15)",
|
||||||
|
boxShadow:
|
||||||
|
"0 8px 32px 0 rgba(0, 0, 0, 0.37), 0 0 15px rgba(59, 130, 246, 0.2)",
|
||||||
|
backdropFilter: "blur(8px)",
|
||||||
|
WebkitBackdropFilter: "blur(8px)",
|
||||||
|
background: "rgba(15, 23, 42, 0.6)", // Sleek dark slate
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 1000,
|
||||||
|
pointerEvents: "none",
|
||||||
|
transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
|
||||||
|
}),
|
||||||
|
canvas: (size: number): React.CSSProperties => ({
|
||||||
|
width: `${size}px`,
|
||||||
|
height: `${size}px`,
|
||||||
|
display: "block",
|
||||||
|
}),
|
||||||
|
statusText: {
|
||||||
|
color: "#94a3b8",
|
||||||
|
fontFamily:
|
||||||
|
'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||||
|
fontSize: "12px",
|
||||||
|
fontWeight: 500,
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
} as React.CSSProperties,
|
||||||
|
};
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import type { GridNode } from "./types";
|
||||||
|
|
||||||
|
export class Grid {
|
||||||
|
public width: number;
|
||||||
|
public height: number;
|
||||||
|
private nodes: GridNode[][];
|
||||||
|
|
||||||
|
constructor(walkableMatrix: boolean[][]) {
|
||||||
|
this.height = walkableMatrix.length;
|
||||||
|
this.width = this.height > 0 ? (walkableMatrix[0]?.length ?? 0) : 0;
|
||||||
|
this.nodes = [];
|
||||||
|
|
||||||
|
for (let y = 0; y < this.height; y++) {
|
||||||
|
const row: GridNode[] = [];
|
||||||
|
const sourceRow = walkableMatrix[y];
|
||||||
|
for (let x = 0; x < this.width; x++) {
|
||||||
|
row.push({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
walkable: sourceRow ? (sourceRow[x] ?? false) : false,
|
||||||
|
g: 0,
|
||||||
|
h: 0,
|
||||||
|
f: 0,
|
||||||
|
parent: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.nodes.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getNode(x: number, y: number): GridNode | null {
|
||||||
|
if (x >= 0 && x < this.width && y >= 0 && y < this.height) {
|
||||||
|
const row = this.nodes[y];
|
||||||
|
return row ? (row[x] ?? null) : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets g, h, f values and parents for all nodes in the grid,
|
||||||
|
* preparing it for a new A* calculation.
|
||||||
|
*/
|
||||||
|
public reset(): void {
|
||||||
|
for (let y = 0; y < this.height; y++) {
|
||||||
|
const row = this.nodes[y];
|
||||||
|
if (!row) continue;
|
||||||
|
for (let x = 0; x < this.width; x++) {
|
||||||
|
const node = row[x];
|
||||||
|
if (!node) continue;
|
||||||
|
node.g = 0;
|
||||||
|
node.h = 0;
|
||||||
|
node.f = 0;
|
||||||
|
node.parent = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves neighboring nodes. Supports 8-directional movement.
|
||||||
|
*/
|
||||||
|
public getNeighbors(
|
||||||
|
node: GridNode,
|
||||||
|
allowDiagonals: boolean = true,
|
||||||
|
): GridNode[] {
|
||||||
|
const neighbors: GridNode[] = [];
|
||||||
|
const { x, y } = node;
|
||||||
|
|
||||||
|
// Relative coordinates of 8 neighbors
|
||||||
|
const directions = [
|
||||||
|
{ dx: 0, dy: -1, isDiagonal: false }, // N
|
||||||
|
{ dx: 1, dy: 0, isDiagonal: false }, // E
|
||||||
|
{ dx: 0, dy: 1, isDiagonal: false }, // S
|
||||||
|
{ dx: -1, dy: 0, isDiagonal: false }, // W
|
||||||
|
];
|
||||||
|
|
||||||
|
if (allowDiagonals) {
|
||||||
|
directions.push(
|
||||||
|
{ dx: 1, dy: -1, isDiagonal: true }, // NE
|
||||||
|
{ dx: 1, dy: 1, isDiagonal: true }, // SE
|
||||||
|
{ dx: -1, dy: 1, isDiagonal: true }, // SW
|
||||||
|
{ dx: -1, dy: -1, isDiagonal: true }, // NW
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dir of directions) {
|
||||||
|
const neighbor = this.getNode(x + dir.dx, y + dir.dy);
|
||||||
|
if (neighbor && neighbor.walkable) {
|
||||||
|
// Prevent corner cutting if both orthogonal neighbors are blocked
|
||||||
|
if (dir.isDiagonal) {
|
||||||
|
const ortho1 = this.getNode(x + dir.dx, y);
|
||||||
|
const ortho2 = this.getNode(x, y + dir.dy);
|
||||||
|
const isBlocked =
|
||||||
|
(!ortho1 || !ortho1.walkable) && (!ortho2 || !ortho2.walkable);
|
||||||
|
if (isBlocked) {
|
||||||
|
continue; // Skip this diagonal neighbor to avoid squeezing through corners
|
||||||
|
}
|
||||||
|
}
|
||||||
|
neighbors.push(neighbor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return neighbors;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { Grid } from "./Grid";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads an image from a URL.
|
||||||
|
*/
|
||||||
|
function loadImage(url: string): Promise<HTMLImageElement> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = "anonymous"; // Enable CORS just in case
|
||||||
|
img.onload = () => resolve(img);
|
||||||
|
img.onerror = (err) =>
|
||||||
|
reject(new Error(`Failed to load image at ${url}: ${err}`));
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a B&W image and scales it to gridWidth x gridHeight.
|
||||||
|
* Higher dimensions = higher accuracy but slower pathfinding.
|
||||||
|
* Lower dimensions = extremely fast pathfinding.
|
||||||
|
*
|
||||||
|
* Walkable roads should be white (or light gray). Non-walkable areas should be black.
|
||||||
|
*
|
||||||
|
* @param imageUrl The path or URL of the B&W navigation mask.
|
||||||
|
* @param gridWidth The target width of our A* pathfinding grid.
|
||||||
|
* @param gridHeight The target height of our A* pathfinding grid.
|
||||||
|
* @param threshold Brightness threshold (0-255) above which a pixel is considered walkable (default: 128).
|
||||||
|
*/
|
||||||
|
export async function createGridFromImage(
|
||||||
|
imageUrl: string,
|
||||||
|
gridWidth: number,
|
||||||
|
gridHeight: number,
|
||||||
|
threshold: number = 128,
|
||||||
|
): Promise<Grid> {
|
||||||
|
const img = await loadImage(imageUrl);
|
||||||
|
|
||||||
|
// Create an offscreen canvas to scale and analyze the image
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = gridWidth;
|
||||||
|
canvas.height = gridHeight;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("Could not get 2D context for offscreen canvas");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw and scale the image onto the canvas
|
||||||
|
ctx.drawImage(img, 0, 0, gridWidth, gridHeight);
|
||||||
|
|
||||||
|
// Retrieve pixel data
|
||||||
|
const imgData = ctx.getImageData(0, 0, gridWidth, gridHeight);
|
||||||
|
const data = imgData.data;
|
||||||
|
|
||||||
|
// Initialize a 2D boolean matrix representing the walkable grid
|
||||||
|
const walkableMatrix: boolean[][] = [];
|
||||||
|
|
||||||
|
for (let y = 0; y < gridHeight; y++) {
|
||||||
|
const row: boolean[] = [];
|
||||||
|
for (let x = 0; x < gridWidth; x++) {
|
||||||
|
// Each pixel has 4 channels: R, G, B, A
|
||||||
|
const index = (y * gridWidth + x) * 4;
|
||||||
|
const r = data[index] ?? 0;
|
||||||
|
const g = data[index + 1] ?? 0;
|
||||||
|
const b = data[index + 2] ?? 0;
|
||||||
|
|
||||||
|
// Calculate brightness (standard grayscale weighting)
|
||||||
|
const brightness = 0.299 * r + 0.587 * g + 0.114 * b;
|
||||||
|
|
||||||
|
// If bright enough, it is a road (walkable)
|
||||||
|
row.push(brightness >= threshold);
|
||||||
|
}
|
||||||
|
walkableMatrix.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Grid(walkableMatrix);
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import type { Waypoint, WaypointNode } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates Euclidean 3D distance between two points.
|
||||||
|
*/
|
||||||
|
function getDistance3D(
|
||||||
|
posA: { x: number; y: number; z: number },
|
||||||
|
posB: { x: number; y: number; z: number },
|
||||||
|
): number {
|
||||||
|
return Math.sqrt(
|
||||||
|
Math.pow(posA.x - posB.x, 2) +
|
||||||
|
Math.pow(posA.y - posB.y, 2) +
|
||||||
|
Math.pow(posA.z - posB.z, 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the closest Waypoint in a list to a target 3D world position.
|
||||||
|
*/
|
||||||
|
export function findClosestWaypoint(
|
||||||
|
waypoints: Waypoint[],
|
||||||
|
pos: { x: number; y: number; z: number },
|
||||||
|
): Waypoint | null {
|
||||||
|
if (waypoints.length === 0) return null;
|
||||||
|
|
||||||
|
let closest = waypoints[0]!;
|
||||||
|
let minDist = getDistance3D(closest, pos);
|
||||||
|
|
||||||
|
for (let i = 1; i < waypoints.length; i++) {
|
||||||
|
const wp = waypoints[i]!;
|
||||||
|
const dist = getDistance3D(wp, pos);
|
||||||
|
if (dist < minDist) {
|
||||||
|
minDist = dist;
|
||||||
|
closest = wp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return closest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs A* pathfinding on a network of 3D Waypoints.
|
||||||
|
*
|
||||||
|
* @param waypoints List of all waypoints in the road network.
|
||||||
|
* @param startWorldPos Player's current 3D world position.
|
||||||
|
* @param endWorldPos Targeted 3D world destination.
|
||||||
|
* @returns Array of Waypoints representing the path from start to end, or empty array if none found.
|
||||||
|
*/
|
||||||
|
export function findWaypointPath(
|
||||||
|
waypoints: Waypoint[],
|
||||||
|
startWorldPos: { x: number; y: number; z: number },
|
||||||
|
endWorldPos: { x: number; y: number; z: number },
|
||||||
|
): Waypoint[] {
|
||||||
|
if (waypoints.length === 0) return [];
|
||||||
|
|
||||||
|
// 1. Find the closest starting and ending waypoints in the network
|
||||||
|
const startWp = findClosestWaypoint(waypoints, startWorldPos);
|
||||||
|
const endWp = findClosestWaypoint(waypoints, endWorldPos);
|
||||||
|
|
||||||
|
if (!startWp || !endWp) return [];
|
||||||
|
if (startWp.id === endWp.id) return [startWp];
|
||||||
|
|
||||||
|
// 2. Map all waypoints to A* search nodes
|
||||||
|
const nodeMap = new Map<number, WaypointNode>();
|
||||||
|
waypoints.forEach((wp) => {
|
||||||
|
nodeMap.set(wp.id, {
|
||||||
|
...wp,
|
||||||
|
g: Infinity,
|
||||||
|
h: Infinity,
|
||||||
|
f: Infinity,
|
||||||
|
parent: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const startNode = nodeMap.get(startWp.id)!;
|
||||||
|
const endNode = nodeMap.get(endWp.id)!;
|
||||||
|
|
||||||
|
// 3. Initialize open and closed sets
|
||||||
|
const openSet: WaypointNode[] = [startNode];
|
||||||
|
const closedSet = new Set<number>(); // Set of waypoint IDs
|
||||||
|
|
||||||
|
startNode.g = 0;
|
||||||
|
startNode.h = getDistance3D(startNode, endNode);
|
||||||
|
startNode.f = startNode.h;
|
||||||
|
|
||||||
|
while (openSet.length > 0) {
|
||||||
|
// Find node with lowest f score
|
||||||
|
let lowIndex = 0;
|
||||||
|
for (let i = 1; i < openSet.length; i++) {
|
||||||
|
const node = openSet[i]!;
|
||||||
|
const lowNode = openSet[lowIndex]!;
|
||||||
|
if (node.f < lowNode.f) {
|
||||||
|
lowIndex = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentNode = openSet[lowIndex]!;
|
||||||
|
|
||||||
|
// Reached destination! Reconstruct the path
|
||||||
|
if (currentNode.id === endNode.id) {
|
||||||
|
const path: Waypoint[] = [];
|
||||||
|
let temp: WaypointNode | null = currentNode;
|
||||||
|
while (temp !== null) {
|
||||||
|
// Find corresponding raw Waypoint
|
||||||
|
const rawWp = waypoints.find((w) => w.id === temp!.id);
|
||||||
|
if (rawWp) {
|
||||||
|
path.push(rawWp);
|
||||||
|
}
|
||||||
|
temp = temp.parent;
|
||||||
|
}
|
||||||
|
return path.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move from open to closed set
|
||||||
|
openSet.splice(lowIndex, 1);
|
||||||
|
closedSet.add(currentNode.id);
|
||||||
|
|
||||||
|
// Process neighbors
|
||||||
|
for (const neighborId of currentNode.connections) {
|
||||||
|
if (closedSet.has(neighborId)) continue;
|
||||||
|
|
||||||
|
const neighborNode = nodeMap.get(neighborId);
|
||||||
|
if (!neighborNode) continue;
|
||||||
|
|
||||||
|
// Distance from currentNode to neighbor is physical 3D distance
|
||||||
|
const tentativeG =
|
||||||
|
currentNode.g + getDistance3D(currentNode, neighborNode);
|
||||||
|
|
||||||
|
const neighborInOpenSet = openSet.some((node) => node.id === neighborId);
|
||||||
|
|
||||||
|
if (!neighborInOpenSet || tentativeG < neighborNode.g) {
|
||||||
|
neighborNode.parent = currentNode;
|
||||||
|
neighborNode.g = tentativeG;
|
||||||
|
neighborNode.h = getDistance3D(neighborNode, endNode);
|
||||||
|
neighborNode.f = neighborNode.g + neighborNode.h;
|
||||||
|
|
||||||
|
if (!neighborInOpenSet) {
|
||||||
|
openSet.push(neighborNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No path found
|
||||||
|
return [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export * from "./types";
|
||||||
|
export * from "./Grid";
|
||||||
|
export * from "./AStar";
|
||||||
|
export * from "./ImageToGrid";
|
||||||
|
export * from "./useGPS";
|
||||||
|
export * from "./GPSMinimap";
|
||||||
|
export * from "./WaypointAStar";
|
||||||
|
export * from "./useWaypointGPS";
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
export interface Position {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GridNode {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
walkable: boolean;
|
||||||
|
g: number;
|
||||||
|
h: number;
|
||||||
|
f: number;
|
||||||
|
parent: GridNode | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GridSize {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Waypoint {
|
||||||
|
id: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
connections: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WaypointNode {
|
||||||
|
id: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
connections: number[];
|
||||||
|
g: number;
|
||||||
|
h: number;
|
||||||
|
f: number;
|
||||||
|
parent: WaypointNode | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { Grid } from "./Grid";
|
||||||
|
import { createGridFromImage } from "./ImageToGrid";
|
||||||
|
import { findPath } from "./AStar";
|
||||||
|
import type { Position } from "./types";
|
||||||
|
|
||||||
|
export interface WorldBounds {
|
||||||
|
minX: number;
|
||||||
|
maxX: number;
|
||||||
|
minZ: number;
|
||||||
|
maxZ: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseGPSOptions {
|
||||||
|
bwMaskUrl: string;
|
||||||
|
colorMapUrl: string;
|
||||||
|
gridWidth: number; // The "width of the array pathfinding" (resolution scaling)
|
||||||
|
gridHeight: number; // The "height of the array pathfinding"
|
||||||
|
worldBounds: WorldBounds;
|
||||||
|
allowDiagonals?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGPS({
|
||||||
|
bwMaskUrl,
|
||||||
|
colorMapUrl,
|
||||||
|
gridWidth,
|
||||||
|
gridHeight,
|
||||||
|
worldBounds,
|
||||||
|
allowDiagonals = true,
|
||||||
|
}: UseGPSOptions) {
|
||||||
|
const [grid, setGrid] = useState<Grid | null>(null);
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Cache the images so they don't reload every frame
|
||||||
|
const colorMapImgRef = useRef<HTMLImageElement | null>(null);
|
||||||
|
|
||||||
|
// Initialize the pathfinding grid
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
async function initGrid() {
|
||||||
|
try {
|
||||||
|
const pathfindingGrid = await createGridFromImage(
|
||||||
|
bwMaskUrl,
|
||||||
|
gridWidth,
|
||||||
|
gridHeight,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pre-load color map image for canvas drawing
|
||||||
|
const colorMapImg = new Image();
|
||||||
|
colorMapImg.crossOrigin = "anonymous";
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
colorMapImg.onload = resolve;
|
||||||
|
colorMapImg.onerror = reject;
|
||||||
|
colorMapImg.src = colorMapUrl;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (active) {
|
||||||
|
setGrid(pathfindingGrid);
|
||||||
|
colorMapImgRef.current = colorMapImg;
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (active) {
|
||||||
|
setError(err.message || "Failed to initialize GPS system");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initGrid();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [bwMaskUrl, colorMapUrl, gridWidth, gridHeight]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translates 3D World coordinates (X, Z) into 2D Grid coordinates (col, row)
|
||||||
|
*/
|
||||||
|
const worldToGrid = useCallback(
|
||||||
|
(worldX: number, worldZ: number): Position => {
|
||||||
|
const { minX, maxX, minZ, maxZ } = worldBounds;
|
||||||
|
|
||||||
|
// Calculate percentages across the bounds
|
||||||
|
const pctX = (worldX - minX) / (maxX - minX);
|
||||||
|
const pctZ = (worldZ - minZ) / (maxZ - minZ);
|
||||||
|
|
||||||
|
// Map to grid dimensions
|
||||||
|
const gridX = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(gridWidth - 1, Math.floor(pctX * gridWidth)),
|
||||||
|
);
|
||||||
|
const gridY = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(gridHeight - 1, Math.floor(pctZ * gridHeight)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { x: gridX, y: gridY };
|
||||||
|
},
|
||||||
|
[worldBounds, gridWidth, gridHeight],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translates 2D Grid coordinates (col, row) back into 3D World coordinates (X, Z)
|
||||||
|
*/
|
||||||
|
const gridToWorld = useCallback(
|
||||||
|
(gridX: number, gridY: number): { x: number; z: number } => {
|
||||||
|
const { minX, maxX, minZ, maxZ } = worldBounds;
|
||||||
|
|
||||||
|
const pctX = gridX / gridWidth;
|
||||||
|
const pctZ = gridY / gridHeight;
|
||||||
|
|
||||||
|
const worldX = minX + pctX * (maxX - minX);
|
||||||
|
const worldZ = minZ + pctZ * (maxZ - minZ);
|
||||||
|
|
||||||
|
return { x: worldX, z: worldZ };
|
||||||
|
},
|
||||||
|
[worldBounds, gridWidth, gridHeight],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the A* calculation using 3D world coordinates.
|
||||||
|
* Returns path in 3D world space.
|
||||||
|
*/
|
||||||
|
const calculateWorldPath = useCallback(
|
||||||
|
(
|
||||||
|
startWorld: { x: number; z: number },
|
||||||
|
endWorld: { x: number; z: number },
|
||||||
|
): { x: number; z: number }[] => {
|
||||||
|
if (!grid) return [];
|
||||||
|
|
||||||
|
const startGrid = worldToGrid(startWorld.x, startWorld.z);
|
||||||
|
const endGrid = worldToGrid(endWorld.x, endWorld.z);
|
||||||
|
|
||||||
|
const gridPath = findPath(grid, startGrid, endGrid, allowDiagonals);
|
||||||
|
|
||||||
|
// Convert path coordinates back to 3D space
|
||||||
|
return gridPath.map((node) => gridToWorld(node.x, node.y));
|
||||||
|
},
|
||||||
|
[grid, worldToGrid, gridToWorld, allowDiagonals],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an HTML5 `<canvas>` element with the background color map,
|
||||||
|
* a path line, and the player/destination indicators.
|
||||||
|
*/
|
||||||
|
const renderGPSToCanvas = useCallback(
|
||||||
|
(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
path: { x: number; z: number }[],
|
||||||
|
playerWorldPos?: { x: number; z: number },
|
||||||
|
destWorldPos?: { x: number; z: number },
|
||||||
|
options: {
|
||||||
|
pathColor?: string;
|
||||||
|
pathWidth?: number;
|
||||||
|
playerColor?: string;
|
||||||
|
playerSize?: number;
|
||||||
|
destColor?: string;
|
||||||
|
destSize?: number;
|
||||||
|
} = {},
|
||||||
|
) => {
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx || !colorMapImgRef.current) return;
|
||||||
|
|
||||||
|
const {
|
||||||
|
pathColor = "#3b82f6", // Premium blue
|
||||||
|
pathWidth = 6,
|
||||||
|
playerColor = "#ef4444", // Red dot for player
|
||||||
|
playerSize = 8,
|
||||||
|
destColor = "#10b981", // Green dot for flag
|
||||||
|
destSize = 8,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const canvasWidth = canvas.width;
|
||||||
|
const canvasHeight = canvas.height;
|
||||||
|
|
||||||
|
// 1. Draw background color map
|
||||||
|
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||||
|
ctx.drawImage(colorMapImgRef.current, 0, 0, canvasWidth, canvasHeight);
|
||||||
|
|
||||||
|
// Helper: translate world coordinates to Canvas pixels
|
||||||
|
const worldToCanvas = (wx: number, wz: number): Position => {
|
||||||
|
const { minX, maxX, minZ, maxZ } = worldBounds;
|
||||||
|
const px = ((wx - minX) / (maxX - minX)) * canvasWidth;
|
||||||
|
const py = ((wz - minZ) / (maxZ - minZ)) * canvasHeight;
|
||||||
|
return { x: px, y: py };
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Draw A* Path Line
|
||||||
|
if (path.length > 1) {
|
||||||
|
ctx.beginPath();
|
||||||
|
const startNode = path[0]!;
|
||||||
|
const startPt = worldToCanvas(startNode.x, startNode.z);
|
||||||
|
ctx.moveTo(startPt.x, startPt.y);
|
||||||
|
|
||||||
|
for (let i = 1; i < path.length; i++) {
|
||||||
|
const node = path[i]!;
|
||||||
|
const pt = worldToCanvas(node.x, node.z);
|
||||||
|
ctx.lineTo(pt.x, pt.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.strokeStyle = pathColor;
|
||||||
|
ctx.lineWidth = pathWidth;
|
||||||
|
ctx.lineCap = "round";
|
||||||
|
ctx.lineJoin = "round";
|
||||||
|
|
||||||
|
// Add a soft glow effect for premium feel
|
||||||
|
ctx.shadowBlur = 8;
|
||||||
|
ctx.shadowColor = pathColor;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Reset shadow for subsequent drawings
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Draw Destination Indicator
|
||||||
|
if (destWorldPos) {
|
||||||
|
const destPt = worldToCanvas(destWorldPos.x, destWorldPos.z);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(destPt.x, destPt.y, destSize, 0, 2 * Math.PI);
|
||||||
|
ctx.fillStyle = destColor;
|
||||||
|
ctx.strokeStyle = "#ffffff";
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Draw Player Indicator
|
||||||
|
if (playerWorldPos) {
|
||||||
|
const playerPt = worldToCanvas(playerWorldPos.x, playerWorldPos.z);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(playerPt.x, playerPt.y, playerSize, 0, 2 * Math.PI);
|
||||||
|
ctx.fillStyle = playerColor;
|
||||||
|
ctx.strokeStyle = "#ffffff";
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[worldBounds],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
grid,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
calculateWorldPath,
|
||||||
|
renderGPSToCanvas,
|
||||||
|
worldToGrid,
|
||||||
|
gridToWorld,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { findWaypointPath } from "./WaypointAStar";
|
||||||
|
import type { Waypoint } from "./types";
|
||||||
|
import type { WorldBounds } from "./useGPS";
|
||||||
|
|
||||||
|
export interface UseWaypointGPSOptions {
|
||||||
|
roadNetworkUrl: string; // URL/Path to roadNetwork.json
|
||||||
|
colorMapUrl: string; // URL/Path to color_map.png
|
||||||
|
worldBounds: WorldBounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWaypointGPS({
|
||||||
|
roadNetworkUrl,
|
||||||
|
colorMapUrl,
|
||||||
|
worldBounds,
|
||||||
|
}: UseWaypointGPSOptions) {
|
||||||
|
const [waypoints, setWaypoints] = useState<Waypoint[]>([]);
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const colorMapImgRef = useRef<HTMLImageElement | null>(null);
|
||||||
|
|
||||||
|
// Load waypoint list and background color map image
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
async function initGPS() {
|
||||||
|
try {
|
||||||
|
// 1. Fetch the road network JSON
|
||||||
|
const response = await fetch(roadNetworkUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load road network from ${roadNetworkUrl}`);
|
||||||
|
}
|
||||||
|
const data: Waypoint[] = await response.json();
|
||||||
|
|
||||||
|
// 2. Pre-load the color map image
|
||||||
|
const colorMapImg = new Image();
|
||||||
|
colorMapImg.crossOrigin = "anonymous";
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
colorMapImg.onload = resolve;
|
||||||
|
colorMapImg.onerror = reject;
|
||||||
|
colorMapImg.src = colorMapUrl;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (active) {
|
||||||
|
setWaypoints(data);
|
||||||
|
colorMapImgRef.current = colorMapImg;
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (active) {
|
||||||
|
setError(err.message || "Failed to initialize Waypoint GPS");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initGPS();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [roadNetworkUrl, colorMapUrl]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the shortest path between start and end world points.
|
||||||
|
*/
|
||||||
|
const calculateRoute = useCallback(
|
||||||
|
(
|
||||||
|
startWorld: { x: number; y: number; z: number },
|
||||||
|
endWorld: { x: number; y: number; z: number },
|
||||||
|
): Waypoint[] => {
|
||||||
|
if (waypoints.length === 0) return [];
|
||||||
|
return findWaypointPath(waypoints, startWorld, endWorld);
|
||||||
|
},
|
||||||
|
[waypoints],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the road network path, player position, and waypoint target onto a canvas.
|
||||||
|
*/
|
||||||
|
const renderGPSToCanvas = useCallback(
|
||||||
|
(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
path: Waypoint[],
|
||||||
|
playerWorldPos?: { x: number; y: number; z: number },
|
||||||
|
destWorldPos?: { x: number; y: number; z: number },
|
||||||
|
options: {
|
||||||
|
pathColor?: string;
|
||||||
|
pathWidth?: number;
|
||||||
|
playerColor?: string;
|
||||||
|
playerSize?: number;
|
||||||
|
destColor?: string;
|
||||||
|
destSize?: number;
|
||||||
|
showAllWaypoints?: boolean; // Debug mode
|
||||||
|
} = {},
|
||||||
|
) => {
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx || !colorMapImgRef.current) return;
|
||||||
|
|
||||||
|
const {
|
||||||
|
pathColor = "#10b981", // Premium emerald green
|
||||||
|
pathWidth = 6,
|
||||||
|
playerColor = "#ff0055", // Neon pink-red for bike
|
||||||
|
playerSize = 8,
|
||||||
|
destColor = "#00ffcc", // Neon cyan for target
|
||||||
|
destSize = 8,
|
||||||
|
showAllWaypoints = false,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const canvasWidth = canvas.width;
|
||||||
|
const canvasHeight = canvas.height;
|
||||||
|
|
||||||
|
// 1. Draw color map background
|
||||||
|
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||||
|
ctx.drawImage(colorMapImgRef.current, 0, 0, canvasWidth, canvasHeight);
|
||||||
|
|
||||||
|
// Helper: translate world coordinates (X, Z) to Canvas pixels (x, y)
|
||||||
|
const worldToCanvas = (wx: number, wz: number) => {
|
||||||
|
const { minX, maxX, minZ, maxZ } = worldBounds;
|
||||||
|
const px = ((wx - minX) / (maxX - minX)) * canvasWidth;
|
||||||
|
const py = ((wz - minZ) / (maxZ - minZ)) * canvasHeight;
|
||||||
|
return { x: px, y: py };
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. [Debug] Draw all network connections
|
||||||
|
if (showAllWaypoints && waypoints.length > 0) {
|
||||||
|
ctx.strokeStyle = "rgba(255, 255, 255, 0.15)";
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
const drawn = new Set<string>();
|
||||||
|
|
||||||
|
waypoints.forEach((wp) => {
|
||||||
|
const startPt = worldToCanvas(wp.x, wp.z);
|
||||||
|
wp.connections.forEach((connId) => {
|
||||||
|
const other = waypoints.find((w) => w.id === connId);
|
||||||
|
if (other) {
|
||||||
|
const key =
|
||||||
|
wp.id < other.id
|
||||||
|
? `${wp.id}-${other.id}`
|
||||||
|
: `${other.id}-${wp.id}`;
|
||||||
|
if (!drawn.has(key)) {
|
||||||
|
drawn.add(key);
|
||||||
|
const endPt = worldToCanvas(other.x, other.z);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(startPt.x, startPt.y);
|
||||||
|
ctx.lineTo(endPt.x, endPt.y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Draw calculated A* path line
|
||||||
|
if (path.length > 1) {
|
||||||
|
ctx.beginPath();
|
||||||
|
const startNode = path[0]!;
|
||||||
|
const startPt = worldToCanvas(startNode.x, startNode.z);
|
||||||
|
ctx.moveTo(startPt.x, startPt.y);
|
||||||
|
|
||||||
|
for (let i = 1; i < path.length; i++) {
|
||||||
|
const node = path[i]!;
|
||||||
|
const pt = worldToCanvas(node.x, node.z);
|
||||||
|
ctx.lineTo(pt.x, pt.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.strokeStyle = pathColor;
|
||||||
|
ctx.lineWidth = pathWidth;
|
||||||
|
ctx.lineCap = "round";
|
||||||
|
ctx.lineJoin = "round";
|
||||||
|
|
||||||
|
// Add soft premium path glow
|
||||||
|
ctx.shadowBlur = 8;
|
||||||
|
ctx.shadowColor = pathColor;
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.shadowBlur = 0; // Reset
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Draw Destination target
|
||||||
|
if (destWorldPos) {
|
||||||
|
const destPt = worldToCanvas(destWorldPos.x, destWorldPos.z);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(destPt.x, destPt.y, destSize, 0, 2 * Math.PI);
|
||||||
|
ctx.fillStyle = destColor;
|
||||||
|
ctx.strokeStyle = "#ffffff";
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Draw Player / Bike
|
||||||
|
if (playerWorldPos) {
|
||||||
|
const playerPt = worldToCanvas(playerWorldPos.x, playerWorldPos.z);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(playerPt.x, playerPt.y, playerSize, 0, 2 * Math.PI);
|
||||||
|
ctx.fillStyle = playerColor;
|
||||||
|
ctx.strokeStyle = "#ffffff";
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[worldBounds, waypoints],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
waypoints,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
calculateRoute,
|
||||||
|
renderGPSToCanvas,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
} from "@tanstack/react-router";
|
} from "@tanstack/react-router";
|
||||||
import { HomePage } from "@/pages/page";
|
import { HomePage } from "@/pages/page";
|
||||||
import { EditorPage } from "@/pages/editor/page";
|
import { EditorPage } from "@/pages/editor/page";
|
||||||
|
import { WaypointEditorPage } from "@/pages/waypoint/page";
|
||||||
|
import { BackgroundMapPage } from "@/pages/backgroundmap/page";
|
||||||
import {
|
import {
|
||||||
DocsAnimationRoute,
|
DocsAnimationRoute,
|
||||||
DocsAudioRoute,
|
DocsAudioRoute,
|
||||||
@@ -44,6 +46,18 @@ const editorRoute = createRoute({
|
|||||||
component: EditorPage,
|
component: EditorPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const waypointRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/waypoint",
|
||||||
|
component: WaypointEditorPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const backgroundMapRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/backgroundmap",
|
||||||
|
component: BackgroundMapPage,
|
||||||
|
});
|
||||||
|
|
||||||
const docsRoute = createRoute({
|
const docsRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: "/docs",
|
path: "/docs",
|
||||||
@@ -80,6 +94,8 @@ const docsChildRoutes = [
|
|||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
indexRoute,
|
indexRoute,
|
||||||
editorRoute,
|
editorRoute,
|
||||||
|
waypointRoute,
|
||||||
|
backgroundMapRoute,
|
||||||
docsRoute.addChildren(docsChildRoutes),
|
docsRoute.addChildren(docsChildRoutes),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { Mesh, PlaneGeometry, ShaderMaterial } from "three";
|
||||||
|
|
||||||
|
export const createNetShader = (): ShaderMaterial => {
|
||||||
|
return new ShaderMaterial({
|
||||||
|
uniforms: {
|
||||||
|
uTime: { value: 0 },
|
||||||
|
uGridScale: { value: 15.0 },
|
||||||
|
uPincushionStrength: { value: 0.4 },
|
||||||
|
uBloomIntensity: { value: 0.3 },
|
||||||
|
uGridThickness: { value: 0.02 },
|
||||||
|
},
|
||||||
|
vertexShader: `
|
||||||
|
varying vec2 vUv;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vUv = uv;
|
||||||
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
fragmentShader: `
|
||||||
|
uniform float uTime;
|
||||||
|
uniform float uGridScale;
|
||||||
|
uniform float uPincushionStrength;
|
||||||
|
uniform float uBloomIntensity;
|
||||||
|
uniform float uGridThickness;
|
||||||
|
|
||||||
|
varying vec2 vUv;
|
||||||
|
|
||||||
|
vec2 applyPincushion(vec2 uv, float strength) {
|
||||||
|
vec2 center = uv - 0.5;
|
||||||
|
float dist = length(center);
|
||||||
|
float distortion = 1.0 + dist * dist * strength;
|
||||||
|
return center * distortion + 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
float grid(vec2 uv, float scale, float thickness) {
|
||||||
|
vec2 gridUV = fract(uv * scale);
|
||||||
|
float lineX = smoothstep(thickness, thickness + 0.01, gridUV.x)
|
||||||
|
* smoothstep(1.0 - thickness, 1.0 - thickness - 0.01, gridUV.x);
|
||||||
|
float lineY = smoothstep(thickness, thickness + 0.01, gridUV.y)
|
||||||
|
* smoothstep(1.0 - thickness, 1.0 - thickness - 0.01, gridUV.y);
|
||||||
|
return lineX + lineY;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec2 uv = applyPincushion(vUv, uPincushionStrength);
|
||||||
|
|
||||||
|
float gridPattern = grid(uv, uGridScale, uGridThickness);
|
||||||
|
|
||||||
|
vec3 gridColor = vec3(1.0, 0.4, 0.7);
|
||||||
|
vec3 bgColor = vec3(0.05, 0.02, 0.05);
|
||||||
|
|
||||||
|
float bloom = gridPattern * uBloomIntensity;
|
||||||
|
vec3 col = mix(bgColor, gridColor + bloom, gridPattern);
|
||||||
|
|
||||||
|
gl_FragColor = vec4(col, 1.0);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createNetMesh = (): Mesh => {
|
||||||
|
const geometry = new PlaneGeometry(2, 2);
|
||||||
|
const material = createNetShader();
|
||||||
|
return new Mesh(geometry, material);
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { ShaderMaterial, Color } from "three";
|
||||||
|
|
||||||
|
export const createUnicolorShader = (
|
||||||
|
color: Color | string | number,
|
||||||
|
): ShaderMaterial => {
|
||||||
|
return new ShaderMaterial({
|
||||||
|
uniforms: {
|
||||||
|
uColor: { value: color instanceof Color ? color : new Color(color) },
|
||||||
|
},
|
||||||
|
vertexShader: `
|
||||||
|
varying vec3 vNormal;
|
||||||
|
varying vec3 vPosition;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vNormal = normalize(normalMatrix * normal);
|
||||||
|
vPosition = (modelViewMatrix * vec4(position, 1.0)).xyz;
|
||||||
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
fragmentShader: `
|
||||||
|
uniform vec3 uColor;
|
||||||
|
varying vec3 vNormal;
|
||||||
|
varying vec3 vPosition;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec3 lightDir = normalize(vec3(1.0, 1.0, 1.0));
|
||||||
|
float diffuse = max(dot(vNormal, lightDir), 0.0);
|
||||||
|
float ambient = 0.3;
|
||||||
|
vec3 finalColor = uColor * (ambient + diffuse * 0.7);
|
||||||
|
gl_FragColor = vec4(finalColor, 1.0);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
HierarchicalMapNode,
|
HierarchicalMapNode,
|
||||||
MapNode,
|
MapNode,
|
||||||
@@ -5,3 +7,8 @@ export type {
|
|||||||
} from "@/types/map/mapScene";
|
} from "@/types/map/mapScene";
|
||||||
|
|
||||||
export type TransformMode = "translate" | "rotate" | "scale";
|
export type TransformMode = "translate" | "rotate" | "scale";
|
||||||
|
|
||||||
|
export interface EditorCinematicPreviewRequest {
|
||||||
|
id: string;
|
||||||
|
cinematic: CinematicDefinition;
|
||||||
|
}
|
||||||
|
|||||||
+2
-1
@@ -1,4 +1,5 @@
|
|||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||||
|
|
||||||
export type GameStep =
|
export type GameStep =
|
||||||
| "intro"
|
| "intro"
|
||||||
@@ -12,7 +13,7 @@ export type GameStep =
|
|||||||
| "manipulation"
|
| "manipulation"
|
||||||
| "outOfFabrik";
|
| "outOfFabrik";
|
||||||
|
|
||||||
export type MainGameState = "intro" | "ebike" | "pylon" | "farm" | "outro";
|
export type MainGameState = "intro" | RepairMissionId | "outro";
|
||||||
|
|
||||||
export interface Zone {
|
export interface Zone {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import type {
|
|||||||
Vector3Tuple,
|
Vector3Tuple,
|
||||||
} from "@/types/three/three";
|
} from "@/types/three/three";
|
||||||
|
|
||||||
export type RepairMissionId = "ebike" | "pylon" | "farm";
|
export const REPAIR_MISSION_IDS = ["ebike", "pylon", "farm"] as const;
|
||||||
|
|
||||||
|
export type RepairMissionId = (typeof REPAIR_MISSION_IDS)[number];
|
||||||
|
|
||||||
export interface RepairMissionTriggerConfig {
|
export interface RepairMissionTriggerConfig {
|
||||||
mission: RepairMissionId;
|
mission: RepairMissionId;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
|
||||||
export interface MapNode {
|
export interface MapNode {
|
||||||
|
id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
position: Vector3Tuple;
|
position: Vector3Tuple;
|
||||||
|
|||||||
Vendored
-42
@@ -1,42 +0,0 @@
|
|||||||
declare module "three/addons/math/Capsule.js" {
|
|
||||||
import { Vector3 } from "three";
|
|
||||||
|
|
||||||
export class Capsule {
|
|
||||||
start: Vector3;
|
|
||||||
end: Vector3;
|
|
||||||
radius: number;
|
|
||||||
|
|
||||||
constructor(start?: Vector3, end?: Vector3, radius?: number);
|
|
||||||
|
|
||||||
set(start: Vector3, end: Vector3, radius: number): this;
|
|
||||||
clone(): Capsule;
|
|
||||||
copy(capsule: Capsule): this;
|
|
||||||
getCenter(target: Vector3): Vector3;
|
|
||||||
translate(v: Vector3): this;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module "three/addons/math/Octree.js" {
|
|
||||||
import { Object3D } from "three";
|
|
||||||
import { Capsule } from "three/addons/math/Capsule.js";
|
|
||||||
|
|
||||||
export interface CapsuleIntersectResult {
|
|
||||||
normal: import("three").Vector3;
|
|
||||||
depth: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Octree {
|
|
||||||
constructor();
|
|
||||||
fromGraphNode(group: Object3D): this;
|
|
||||||
capsuleIntersect(capsule: Capsule): CapsuleIntersectResult | false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module "three/addons/utils/BufferGeometryUtils.js" {
|
|
||||||
import { BufferGeometry } from "three";
|
|
||||||
|
|
||||||
export function mergeGeometries(
|
|
||||||
geometries: BufferGeometry[],
|
|
||||||
useGroups?: boolean,
|
|
||||||
): BufferGeometry | null;
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Octree } from "three/addons/math/Octree.js";
|
import type { Octree } from "three-stdlib";
|
||||||
|
|
||||||
export type Vector3Tuple = [number, number, number];
|
export type Vector3Tuple = [number, number, number];
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type TerrainSurfaceKind =
|
type TerrainSurfaceKind =
|
||||||
| "grass"
|
| "grass"
|
||||||
| "path"
|
| "path"
|
||||||
| "water"
|
| "water"
|
||||||
@@ -6,7 +6,7 @@ export type TerrainSurfaceKind =
|
|||||||
| "dirt"
|
| "dirt"
|
||||||
| "rock";
|
| "rock";
|
||||||
|
|
||||||
export type TerrainSurfaceRgb = readonly [number, number, number];
|
type TerrainSurfaceRgb = readonly [number, number, number];
|
||||||
|
|
||||||
export interface TerrainSurfaceBounds {
|
export interface TerrainSurfaceBounds {
|
||||||
minX: number;
|
minX: number;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { logger } from "@/utils/core/Logger";
|
||||||
|
|
||||||
const DEBUG_GAME_STATE_COOKIE_NAME = "la-fabrik-debug-game-state";
|
const DEBUG_GAME_STATE_COOKIE_NAME = "la-fabrik-debug-game-state";
|
||||||
const DEBUG_GAME_STATE_COOKIE_MAX_AGE = 60 * 60 * 24 * 30;
|
const DEBUG_GAME_STATE_COOKIE_MAX_AGE = 60 * 60 * 24 * 30;
|
||||||
|
|
||||||
@@ -18,7 +20,11 @@ export function readDebugGameStateCookie(): unknown {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
return JSON.parse(decodeURIComponent(value));
|
return JSON.parse(decodeURIComponent(value));
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
logger.warn("DebugGameState", "Invalid debug game state cookie cleared", {
|
||||||
|
error: error instanceof Error ? error : String(error),
|
||||||
|
});
|
||||||
|
clearDebugGameStateCookie();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import type {
|
||||||
|
HierarchicalMapNode,
|
||||||
|
MapNode,
|
||||||
|
SceneData,
|
||||||
|
} from "@/types/editor/editor";
|
||||||
|
|
||||||
|
const DEFAULT_NEW_NODE_NAME = "new-model";
|
||||||
|
|
||||||
|
export function serializeMapNodes(sceneData: SceneData): string {
|
||||||
|
const mapPayload = sceneData.mapTree
|
||||||
|
? mergeFlatNodeTransformsIntoTree(sceneData)
|
||||||
|
: sceneData.mapNodes.map(removeEditorMetadata);
|
||||||
|
|
||||||
|
return JSON.stringify(mapPayload, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSourcePathKey(sourcePath: readonly number[]): string {
|
||||||
|
return sourcePath.join(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeEditorMetadata(node: MapNode): MapNode {
|
||||||
|
return {
|
||||||
|
...(node.id ? { id: node.id } : {}),
|
||||||
|
name: node.name,
|
||||||
|
type: node.type,
|
||||||
|
position: node.position,
|
||||||
|
rotation: node.rotation,
|
||||||
|
scale: node.scale,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeFlatNodeTransformsIntoTree(
|
||||||
|
sceneData: SceneData,
|
||||||
|
): HierarchicalMapNode | HierarchicalMapNode[] {
|
||||||
|
const nodesBySourcePath = new Map<string, MapNode>();
|
||||||
|
|
||||||
|
for (const node of sceneData.mapNodes) {
|
||||||
|
if (!node.sourcePath) continue;
|
||||||
|
nodesBySourcePath.set(createSourcePathKey(node.sourcePath), node);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloneNode = (
|
||||||
|
node: HierarchicalMapNode,
|
||||||
|
path: number[],
|
||||||
|
): HierarchicalMapNode => {
|
||||||
|
const updatedNode = nodesBySourcePath.get(createSourcePathKey(path));
|
||||||
|
const nextNode: HierarchicalMapNode = {
|
||||||
|
...((updatedNode?.id ?? node.id)
|
||||||
|
? { id: updatedNode?.id ?? node.id }
|
||||||
|
: {}),
|
||||||
|
name: node.name,
|
||||||
|
type: node.type,
|
||||||
|
position: updatedNode?.position ?? node.position,
|
||||||
|
rotation: updatedNode?.rotation ?? node.rotation,
|
||||||
|
scale: updatedNode?.scale ?? node.scale,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (node.role) {
|
||||||
|
nextNode.role = node.role;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.children) {
|
||||||
|
nextNode.children = node.children.map((child, index) =>
|
||||||
|
cloneNode(child, [...path, index]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapTree = sceneData.mapTree;
|
||||||
|
|
||||||
|
if (!mapTree) {
|
||||||
|
return sceneData.mapNodes.map(removeEditorMetadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(mapTree)) {
|
||||||
|
return mapTree.map((node, index) => cloneNode(node, [index]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return cloneNode(mapTree, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneMapTree(
|
||||||
|
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
|
||||||
|
): HierarchicalMapNode | HierarchicalMapNode[] {
|
||||||
|
return JSON.parse(JSON.stringify(mapTree)) as
|
||||||
|
| HierarchicalMapNode
|
||||||
|
| HierarchicalMapNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectEditableMapNodes(
|
||||||
|
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
|
||||||
|
): MapNode[] {
|
||||||
|
const nodes: MapNode[] = [];
|
||||||
|
|
||||||
|
function visit(node: HierarchicalMapNode, path: number[]): void {
|
||||||
|
if (node.role !== "group" && node.type !== "Mesh") {
|
||||||
|
nodes.push({
|
||||||
|
...(node.id ? { id: node.id } : {}),
|
||||||
|
name: node.name,
|
||||||
|
position: node.position,
|
||||||
|
rotation: node.rotation,
|
||||||
|
scale: node.scale,
|
||||||
|
sourcePath: path,
|
||||||
|
type: node.type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
node.children?.forEach((child, index) => visit(child, [...path, index]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(mapTree)) {
|
||||||
|
mapTree.forEach((node, index) => visit(node, [index]));
|
||||||
|
} else {
|
||||||
|
visit(mapTree, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTreeNodeAtPath(
|
||||||
|
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
|
||||||
|
path: number[],
|
||||||
|
update: (node: HierarchicalMapNode) => HierarchicalMapNode,
|
||||||
|
): HierarchicalMapNode | HierarchicalMapNode[] {
|
||||||
|
const nextTree = cloneMapTree(mapTree);
|
||||||
|
const rootNodes = Array.isArray(nextTree) ? nextTree : [nextTree];
|
||||||
|
const targetIndex = path[path.length - 1] ?? 0;
|
||||||
|
const isRootTarget = Array.isArray(nextTree)
|
||||||
|
? path.length === 1
|
||||||
|
: path.length === 0;
|
||||||
|
|
||||||
|
if (isRootTarget) {
|
||||||
|
const targetNode = rootNodes[targetIndex];
|
||||||
|
if (targetNode) {
|
||||||
|
rootNodes[targetIndex] = update(targetNode);
|
||||||
|
}
|
||||||
|
return nextTree;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentPath = path.slice(0, -1);
|
||||||
|
let parent = Array.isArray(nextTree)
|
||||||
|
? rootNodes[parentPath[0] ?? 0]
|
||||||
|
: rootNodes[0];
|
||||||
|
const childPath = Array.isArray(nextTree) ? parentPath.slice(1) : parentPath;
|
||||||
|
|
||||||
|
for (const index of childPath) {
|
||||||
|
parent = parent?.children?.[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parent?.children?.[targetIndex]) return nextTree;
|
||||||
|
parent.children[targetIndex] = update(parent.children[targetIndex]);
|
||||||
|
|
||||||
|
return nextTree;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeTreeNodeAtPath(
|
||||||
|
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
|
||||||
|
path: number[],
|
||||||
|
): HierarchicalMapNode | HierarchicalMapNode[] {
|
||||||
|
const nextTree = cloneMapTree(mapTree);
|
||||||
|
const rootNodes = Array.isArray(nextTree) ? nextTree : [nextTree];
|
||||||
|
const targetIndex = path[path.length - 1];
|
||||||
|
if (targetIndex === undefined) return nextTree;
|
||||||
|
|
||||||
|
if (Array.isArray(nextTree) && path.length === 1) {
|
||||||
|
nextTree.splice(targetIndex, 1);
|
||||||
|
return nextTree;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentPath = path.slice(0, -1);
|
||||||
|
let parent = Array.isArray(nextTree)
|
||||||
|
? rootNodes[parentPath[0] ?? 0]
|
||||||
|
: rootNodes[0];
|
||||||
|
const childPath = Array.isArray(nextTree) ? parentPath.slice(1) : parentPath;
|
||||||
|
|
||||||
|
for (const index of childPath) {
|
||||||
|
parent = parent?.children?.[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
parent?.children?.splice(targetIndex, 1);
|
||||||
|
return nextTree;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSceneDataTree(
|
||||||
|
sceneData: SceneData,
|
||||||
|
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
|
||||||
|
): SceneData {
|
||||||
|
return {
|
||||||
|
...sceneData,
|
||||||
|
mapNodes: collectEditableMapNodes(mapTree),
|
||||||
|
mapTree,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNodePathByName(
|
||||||
|
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
|
||||||
|
name: string,
|
||||||
|
): number[] | null {
|
||||||
|
function visit(node: HierarchicalMapNode, path: number[]): number[] | null {
|
||||||
|
if (node.name === name) return path;
|
||||||
|
|
||||||
|
for (let index = 0; index < (node.children?.length ?? 0); index++) {
|
||||||
|
const child = node.children?.[index];
|
||||||
|
if (!child) continue;
|
||||||
|
const result = visit(child, [...path, index]);
|
||||||
|
if (result) return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(mapTree)) {
|
||||||
|
for (let index = 0; index < mapTree.length; index++) {
|
||||||
|
const node = mapTree[index];
|
||||||
|
if (!node) continue;
|
||||||
|
const result = visit(node, [index]);
|
||||||
|
if (result) return result;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return visit(mapTree, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addTreeNode(
|
||||||
|
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
|
||||||
|
node: HierarchicalMapNode,
|
||||||
|
): HierarchicalMapNode | HierarchicalMapNode[] {
|
||||||
|
const blockingPath = findNodePathByName(mapTree, "blocking");
|
||||||
|
if (!blockingPath) return mapTree;
|
||||||
|
|
||||||
|
return updateTreeNodeAtPath(mapTree, blockingPath, (blockingNode) => ({
|
||||||
|
...blockingNode,
|
||||||
|
children: [...(blockingNode.children ?? []), node],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNewMapNode(name: string): HierarchicalMapNode {
|
||||||
|
const safeName = name.trim() || DEFAULT_NEW_NODE_NAME;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: safeName,
|
||||||
|
type: "Object3D",
|
||||||
|
position: [0, 0, 0],
|
||||||
|
rotation: [0, 0, 0],
|
||||||
|
scale: [1, 1, 1],
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: safeName,
|
||||||
|
type: "Mesh",
|
||||||
|
position: [0, 0, 0],
|
||||||
|
rotation: [0, 0, 0],
|
||||||
|
scale: [1, 1, 1],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ export async function createSceneDataFromFiles(
|
|||||||
const models = new Map<string, string>();
|
const models = new Map<string, string>();
|
||||||
|
|
||||||
for (const [path, file] of fileMap.entries()) {
|
for (const [path, file] of fileMap.entries()) {
|
||||||
const modelMatch = path.match(/^\/models\/(.+)\/model\.(glb|gltf)$/);
|
const modelMatch = path.match(/^\/models\/(.+)\/(?:model|\1)\.(glb|gltf)$/);
|
||||||
const modelName = modelMatch?.[1];
|
const modelName = modelMatch?.[1];
|
||||||
const modelExtension = modelMatch?.[2];
|
const modelExtension = modelMatch?.[2];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { REPAIR_MISSION_POSITION_ENTRIES } from "@/data/gameplay/repairMissionAnchors";
|
||||||
|
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||||
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
|
||||||
|
const FALLBACK_REPAIR_MISSION_POSITIONS = new Map(
|
||||||
|
REPAIR_MISSION_POSITION_ENTRIES.map(({ mission, position }) => [
|
||||||
|
mission,
|
||||||
|
position,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function getRepairMissionPosition(
|
||||||
|
mission: RepairMissionId,
|
||||||
|
anchors: Partial<Record<RepairMissionId, Vector3Tuple>>,
|
||||||
|
): Vector3Tuple | undefined {
|
||||||
|
return anchors[mission] ?? FALLBACK_REPAIR_MISSION_POSITIONS.get(mission);
|
||||||
|
}
|
||||||
@@ -99,6 +99,7 @@ function flattenMapNode(node: HierarchicalMapNode, path: number[]): MapNode[] {
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
...(node.id ? { id: node.id } : {}),
|
||||||
name: node.name,
|
name: node.name,
|
||||||
type: node.type,
|
type: node.type,
|
||||||
position: node.position,
|
position: node.position,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ function isMapNode(value: unknown): value is MapNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
(value.id === undefined || typeof value.id === "string") &&
|
||||||
typeof value.name === "string" &&
|
typeof value.name === "string" &&
|
||||||
typeof value.type === "string" &&
|
typeof value.type === "string" &&
|
||||||
isVector3Tuple(value.position) &&
|
isVector3Tuple(value.position) &&
|
||||||
@@ -53,6 +54,7 @@ function isHierarchicalMapNode(value: unknown): value is HierarchicalMapNode {
|
|||||||
|
|
||||||
function flattenMapNode(node: HierarchicalMapNode, path: number[]): MapNode[] {
|
function flattenMapNode(node: HierarchicalMapNode, path: number[]): MapNode[] {
|
||||||
const mapNode: MapNode = {
|
const mapNode: MapNode = {
|
||||||
|
...(node.id ? { id: node.id } : {}),
|
||||||
name: node.name,
|
name: node.name,
|
||||||
type: node.type,
|
type: node.type,
|
||||||
position: node.position,
|
position: node.position,
|
||||||
|
|||||||
@@ -1,16 +1,8 @@
|
|||||||
import type { MapNode } from "@/types/map/mapScene";
|
import type { MapNode } from "@/types/map/mapScene";
|
||||||
|
import { VEGETATION_MAP_NODE_NAMES } from "@/data/world/vegetationConfig";
|
||||||
import { isInstancedMapNodeName } from "@/utils/map/isInstancedMapNodeName";
|
import { isInstancedMapNodeName } from "@/utils/map/isInstancedMapNodeName";
|
||||||
|
|
||||||
const MAP_STRUCTURE_NODE_NAMES = new Set(["Scene", "blocking", "terrain"]);
|
const MAP_STRUCTURE_NODE_NAMES = new Set(["Scene", "blocking", "terrain"]);
|
||||||
const RUNTIME_VEGETATION_NODE_NAMES = new Set([
|
|
||||||
"arbre",
|
|
||||||
"buisson",
|
|
||||||
"champdeble",
|
|
||||||
"champdesoja",
|
|
||||||
"champsdetournesol",
|
|
||||||
"potager",
|
|
||||||
"sapin",
|
|
||||||
]);
|
|
||||||
|
|
||||||
function isRuntimeStructureMapNode(name: string): boolean {
|
function isRuntimeStructureMapNode(name: string): boolean {
|
||||||
return MAP_STRUCTURE_NODE_NAMES.has(name);
|
return MAP_STRUCTURE_NODE_NAMES.has(name);
|
||||||
@@ -26,7 +18,7 @@ export function isRuntimeSingleMapNode(node: MapNode): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
!RUNTIME_VEGETATION_NODE_NAMES.has(node.name) &&
|
!VEGETATION_MAP_NODE_NAMES.has(node.name) &&
|
||||||
!isInstancedMapNodeName(node.name)
|
!isInstancedMapNodeName(node.name)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { MapNode } from "@/types/map/mapScene";
|
import type { MapNode } from "@/types/map/mapScene";
|
||||||
|
|
||||||
export const POTAGER_MAP_NAME = "potager";
|
export const POTAGER_MAP_NAME = "potager";
|
||||||
export const POTAGER_DEFAULT_ROTATION_OFFSET = [0, 0, 0] as const;
|
const POTAGER_DEFAULT_ROTATION_OFFSET = [0, 0, 0] as const;
|
||||||
|
|
||||||
export const POTAGER_SOURCE_MAP_NAMES = new Set([
|
const POTAGER_SOURCE_MAP_NAMES = new Set([
|
||||||
"champdeble",
|
"champdeble",
|
||||||
"champdesoja",
|
"champdesoja",
|
||||||
"champsdetournesol",
|
"champsdetournesol",
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
REPAIR_MISSION_ANCHOR_IDS,
|
||||||
|
REPAIR_MISSION_POSITION_ENTRIES,
|
||||||
|
} from "@/data/gameplay/repairMissionAnchors";
|
||||||
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||||
import type { MapNode } from "@/types/map/mapScene";
|
import type { MapNode } from "@/types/map/mapScene";
|
||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
@@ -8,10 +12,67 @@ const REPAIR_MISSION_MAP_NODE_NAMES = {
|
|||||||
farm: "fermeverticale",
|
farm: "fermeverticale",
|
||||||
} as const satisfies Record<RepairMissionId, string>;
|
} as const satisfies Record<RepairMissionId, string>;
|
||||||
|
|
||||||
|
const REPAIR_MISSION_FALLBACK_POSITIONS = new Map(
|
||||||
|
REPAIR_MISSION_POSITION_ENTRIES.map(({ mission, position }) => [
|
||||||
|
mission,
|
||||||
|
position,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
function isOriginPosition(position: Vector3Tuple): boolean {
|
function isOriginPosition(position: Vector3Tuple): boolean {
|
||||||
return position.every((value) => Math.abs(value) < 0.0001);
|
return position.every((value) => Math.abs(value) < 0.0001);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasDistinctTransform(node: MapNode): boolean {
|
||||||
|
return (
|
||||||
|
node.rotation.some((value) => Math.abs(value) > 0.0001) ||
|
||||||
|
node.scale.some((value) => Math.abs(value - 1) > 0.0001)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function distanceToPosition(node: MapNode, position: Vector3Tuple): number {
|
||||||
|
return Math.hypot(
|
||||||
|
node.position[0] - position[0],
|
||||||
|
node.position[2] - position[2],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAnchorNode(
|
||||||
|
mapNodes: readonly MapNode[],
|
||||||
|
mission: RepairMissionId,
|
||||||
|
mapName: string,
|
||||||
|
): MapNode | null {
|
||||||
|
const anchorId = REPAIR_MISSION_ANCHOR_IDS[mission];
|
||||||
|
if (anchorId) {
|
||||||
|
const nodeById = mapNodes.find((candidate) => candidate.id === anchorId);
|
||||||
|
if (nodeById) return nodeById;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = mapNodes.filter(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.name === mapName &&
|
||||||
|
candidate.type === "Object3D" &&
|
||||||
|
!isOriginPosition(candidate.position),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mission !== "pylon") return candidates[0] ?? null;
|
||||||
|
|
||||||
|
const distinctCandidates = candidates.filter(hasDistinctTransform);
|
||||||
|
const pylonCandidates =
|
||||||
|
distinctCandidates.length > 0 ? distinctCandidates : candidates;
|
||||||
|
const fallbackPosition = REPAIR_MISSION_FALLBACK_POSITIONS.get(mission);
|
||||||
|
|
||||||
|
if (!fallbackPosition) return pylonCandidates[0] ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
[...pylonCandidates].sort(
|
||||||
|
(a, b) =>
|
||||||
|
distanceToPosition(a, fallbackPosition) -
|
||||||
|
distanceToPosition(b, fallbackPosition),
|
||||||
|
)[0] ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function getRepairMissionMapAnchors(
|
export function getRepairMissionMapAnchors(
|
||||||
mapNodes: readonly MapNode[],
|
mapNodes: readonly MapNode[],
|
||||||
): Partial<Record<RepairMissionId, Vector3Tuple>> {
|
): Partial<Record<RepairMissionId, Vector3Tuple>> {
|
||||||
@@ -20,12 +81,7 @@ export function getRepairMissionMapAnchors(
|
|||||||
for (const [mission, mapName] of Object.entries(
|
for (const [mission, mapName] of Object.entries(
|
||||||
REPAIR_MISSION_MAP_NODE_NAMES,
|
REPAIR_MISSION_MAP_NODE_NAMES,
|
||||||
) as [RepairMissionId, string][]) {
|
) as [RepairMissionId, string][]) {
|
||||||
const node = mapNodes.find(
|
const node = getAnchorNode(mapNodes, mission, mapName);
|
||||||
(candidate) =>
|
|
||||||
candidate.name === mapName &&
|
|
||||||
candidate.type === "Object3D" &&
|
|
||||||
!isOriginPosition(candidate.position),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (node) {
|
if (node) {
|
||||||
anchors[mission] = node.position;
|
anchors[mission] = node.position;
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import * as THREE from "three";
|
||||||
|
|
||||||
|
type TextureMaterialKey = Extract<
|
||||||
|
| keyof THREE.MeshBasicMaterial
|
||||||
|
| keyof THREE.MeshStandardMaterial
|
||||||
|
| keyof THREE.MeshPhysicalMaterial
|
||||||
|
| keyof THREE.MeshToonMaterial,
|
||||||
|
string
|
||||||
|
>;
|
||||||
|
|
||||||
|
type MaterialWithTextureSlots = THREE.Material &
|
||||||
|
Partial<Record<TextureMaterialKey, THREE.Texture | null>>;
|
||||||
|
|
||||||
|
interface DisposeObject3DOptions {
|
||||||
|
disposeTextures?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MATERIAL_TEXTURE_KEYS = [
|
||||||
|
"alphaMap",
|
||||||
|
"aoMap",
|
||||||
|
"bumpMap",
|
||||||
|
"clearcoatMap",
|
||||||
|
"clearcoatNormalMap",
|
||||||
|
"clearcoatRoughnessMap",
|
||||||
|
"displacementMap",
|
||||||
|
"emissiveMap",
|
||||||
|
"envMap",
|
||||||
|
"gradientMap",
|
||||||
|
"lightMap",
|
||||||
|
"map",
|
||||||
|
"metalnessMap",
|
||||||
|
"normalMap",
|
||||||
|
"roughnessMap",
|
||||||
|
"sheenColorMap",
|
||||||
|
"sheenRoughnessMap",
|
||||||
|
"specularColorMap",
|
||||||
|
"specularIntensityMap",
|
||||||
|
"specularMap",
|
||||||
|
"thicknessMap",
|
||||||
|
"transmissionMap",
|
||||||
|
] as const satisfies readonly TextureMaterialKey[];
|
||||||
|
|
||||||
|
export function disposeObject3D(
|
||||||
|
object: THREE.Object3D,
|
||||||
|
options: DisposeObject3DOptions = {},
|
||||||
|
): void {
|
||||||
|
object.traverse((child) => {
|
||||||
|
if (!(child instanceof THREE.Mesh)) return;
|
||||||
|
|
||||||
|
child.geometry?.dispose();
|
||||||
|
|
||||||
|
if (Array.isArray(child.material)) {
|
||||||
|
child.material.forEach((material) => disposeMaterial(material, options));
|
||||||
|
} else if (child.material) {
|
||||||
|
disposeMaterial(child.material, options);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function disposeMaterial(
|
||||||
|
material: THREE.Material,
|
||||||
|
options: DisposeObject3DOptions,
|
||||||
|
): void {
|
||||||
|
material.dispose();
|
||||||
|
if (!options.disposeTextures) return;
|
||||||
|
|
||||||
|
const materialWithTextures = material as MaterialWithTextureSlots;
|
||||||
|
|
||||||
|
for (const key of MATERIAL_TEXTURE_KEYS) {
|
||||||
|
const value = materialWithTextures[key];
|
||||||
|
|
||||||
|
if (value instanceof THREE.Texture) {
|
||||||
|
value.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { CHUNK_CONFIG } from "@/data/world/chunkStreamingConfig";
|
||||||
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
|
||||||
|
interface PositionedInstance {
|
||||||
|
position: Vector3Tuple;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorldInstanceChunk<TInstance extends PositionedInstance> {
|
||||||
|
centerX: number;
|
||||||
|
centerZ: number;
|
||||||
|
chunkKey: string;
|
||||||
|
instances: TInstance[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWorldChunkKey(instance: PositionedInstance): string {
|
||||||
|
const [x, , z] = instance.position;
|
||||||
|
const chunkX = Math.floor(x / CHUNK_CONFIG.chunkSize);
|
||||||
|
const chunkZ = Math.floor(z / CHUNK_CONFIG.chunkSize);
|
||||||
|
return `${chunkX}:${chunkZ}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWorldInstanceChunks<TInstance extends PositionedInstance>(
|
||||||
|
instances: TInstance[],
|
||||||
|
): WorldInstanceChunk<TInstance>[] {
|
||||||
|
const chunks = new Map<string, TInstance[]>();
|
||||||
|
|
||||||
|
for (const instance of instances) {
|
||||||
|
const chunkKey = getWorldChunkKey(instance);
|
||||||
|
const chunk = chunks.get(chunkKey);
|
||||||
|
|
||||||
|
if (chunk) {
|
||||||
|
chunk.push(instance);
|
||||||
|
} else {
|
||||||
|
chunks.set(chunkKey, [instance]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...chunks.entries()].map(([chunkKey, chunkInstances]) => {
|
||||||
|
const center = chunkInstances.reduce(
|
||||||
|
(sum, instance) => {
|
||||||
|
sum.x += instance.position[0];
|
||||||
|
sum.z += instance.position[2];
|
||||||
|
return sum;
|
||||||
|
},
|
||||||
|
{ x: 0, z: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
centerX: center.x / chunkInstances.length,
|
||||||
|
centerZ: center.z / chunkInstances.length,
|
||||||
|
chunkKey,
|
||||||
|
instances: chunkInstances,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
GAME_SCENE_FALLBACK_BACKGROUND_COLOR,
|
GAME_SCENE_FALLBACK_BACKGROUND_COLOR,
|
||||||
|
GAME_SCENE_SKY_FALLBACK_MODEL_PATH,
|
||||||
|
GAME_SCENE_SKY_FALLBACK_MODEL_SCALE,
|
||||||
GAME_SCENE_SKY_MODEL_PATH,
|
GAME_SCENE_SKY_MODEL_PATH,
|
||||||
GAME_SCENE_SKY_MODEL_SCALE,
|
GAME_SCENE_SKY_MODEL_SCALE,
|
||||||
PHYSICS_SCENE_BACKGROUND_COLOR,
|
PHYSICS_SCENE_BACKGROUND_COLOR,
|
||||||
@@ -35,6 +37,8 @@ export function Environment(): React.JSX.Element {
|
|||||||
{showSky ? (
|
{showSky ? (
|
||||||
<SkyModel
|
<SkyModel
|
||||||
fallbackColor={GAME_SCENE_FALLBACK_BACKGROUND_COLOR}
|
fallbackColor={GAME_SCENE_FALLBACK_BACKGROUND_COLOR}
|
||||||
|
fallbackModelScale={GAME_SCENE_SKY_FALLBACK_MODEL_SCALE}
|
||||||
|
fallbackModelPath={GAME_SCENE_SKY_FALLBACK_MODEL_PATH}
|
||||||
modelPath={GAME_SCENE_SKY_MODEL_PATH}
|
modelPath={GAME_SCENE_SKY_MODEL_PATH}
|
||||||
scale={GAME_SCENE_SKY_MODEL_SCALE}
|
scale={GAME_SCENE_SKY_MODEL_SCALE}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
CinematicManifest,
|
CinematicManifest,
|
||||||
} from "@/types/cinematics/cinematics";
|
} from "@/types/cinematics/cinematics";
|
||||||
import type { DialogueManifest } from "@/types/dialogues/dialogues";
|
import type { DialogueManifest } from "@/types/dialogues/dialogues";
|
||||||
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
import { logger } from "@/utils/core/Logger";
|
import { logger } from "@/utils/core/Logger";
|
||||||
import { loadCinematicManifest } from "@/utils/cinematics/loadCinematicManifest";
|
import { loadCinematicManifest } from "@/utils/cinematics/loadCinematicManifest";
|
||||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||||
@@ -16,6 +17,11 @@ import { queueDialogueById } from "@/utils/dialogues/playDialogue";
|
|||||||
|
|
||||||
export function GameCinematics(): null {
|
export function GameCinematics(): null {
|
||||||
const camera = useThree((state) => state.camera);
|
const camera = useThree((state) => state.camera);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setGlobalCamera(camera);
|
||||||
|
}, [camera]);
|
||||||
|
|
||||||
const [manifest, setManifest] = useState<CinematicManifest | null>(null);
|
const [manifest, setManifest] = useState<CinematicManifest | null>(null);
|
||||||
const [dialogueManifest, setDialogueManifest] =
|
const [dialogueManifest, setDialogueManifest] =
|
||||||
useState<DialogueManifest | null>(null);
|
useState<DialogueManifest | null>(null);
|
||||||
@@ -171,3 +177,120 @@ function playCinematic(
|
|||||||
|
|
||||||
timelineRef.current = timeline;
|
timelineRef.current = timeline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let cameraTransitionTimeline: gsap.core.Timeline | null = null;
|
||||||
|
let globalCamera: THREE.Camera | null = null;
|
||||||
|
|
||||||
|
export function setGlobalCamera(camera: THREE.Camera | null): void {
|
||||||
|
globalCamera = camera;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function animateCameraTransition(
|
||||||
|
targetPosition: Vector3Tuple,
|
||||||
|
targetLookAt: Vector3Tuple,
|
||||||
|
duration: number = 1,
|
||||||
|
onComplete?: () => void,
|
||||||
|
): void {
|
||||||
|
if (!globalCamera) {
|
||||||
|
logger.warn("GameCinematics", "Camera not found for transition");
|
||||||
|
onComplete?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const camera = globalCamera;
|
||||||
|
|
||||||
|
cameraTransitionTimeline?.kill();
|
||||||
|
useGameStore.getState().setCinematicPlaying(true);
|
||||||
|
|
||||||
|
const target = new THREE.Vector3(...targetLookAt);
|
||||||
|
|
||||||
|
cameraTransitionTimeline = gsap.timeline({
|
||||||
|
onUpdate: () => camera.lookAt(target),
|
||||||
|
onComplete: () => {
|
||||||
|
cameraTransitionTimeline = null;
|
||||||
|
useGameStore.getState().setCinematicPlaying(false);
|
||||||
|
onComplete?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
cameraTransitionTimeline.to(camera.position, {
|
||||||
|
x: targetPosition[0],
|
||||||
|
y: targetPosition[1],
|
||||||
|
z: targetPosition[2],
|
||||||
|
duration,
|
||||||
|
ease: "power2.inOut",
|
||||||
|
});
|
||||||
|
|
||||||
|
cameraTransitionTimeline.to(
|
||||||
|
target,
|
||||||
|
{
|
||||||
|
x: targetLookAt[0],
|
||||||
|
y: targetLookAt[1],
|
||||||
|
z: targetLookAt[2],
|
||||||
|
duration,
|
||||||
|
ease: "power2.inOut",
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function animateCameraTransformTransition(
|
||||||
|
targetPosition: Vector3Tuple,
|
||||||
|
targetRotation: Vector3Tuple,
|
||||||
|
duration: number = 1,
|
||||||
|
onComplete?: () => void,
|
||||||
|
): void {
|
||||||
|
if (!globalCamera) {
|
||||||
|
logger.warn("GameCinematics", "Camera not found for transition");
|
||||||
|
onComplete?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const camera = globalCamera;
|
||||||
|
|
||||||
|
cameraTransitionTimeline?.kill();
|
||||||
|
useGameStore.getState().setCinematicPlaying(true);
|
||||||
|
|
||||||
|
// Convert target rotation in degrees to quaternion
|
||||||
|
const targetEuler = new THREE.Euler(
|
||||||
|
THREE.MathUtils.degToRad(targetRotation[0]),
|
||||||
|
THREE.MathUtils.degToRad(targetRotation[1]),
|
||||||
|
THREE.MathUtils.degToRad(targetRotation[2]),
|
||||||
|
"YXZ",
|
||||||
|
);
|
||||||
|
const startQuaternion = camera.quaternion.clone();
|
||||||
|
const endQuaternion = new THREE.Quaternion().setFromEuler(targetEuler);
|
||||||
|
|
||||||
|
const transitionObj = { progress: 0 };
|
||||||
|
|
||||||
|
cameraTransitionTimeline = gsap.timeline({
|
||||||
|
onUpdate: () => {
|
||||||
|
camera.quaternion
|
||||||
|
.copy(startQuaternion)
|
||||||
|
.slerp(endQuaternion, transitionObj.progress);
|
||||||
|
},
|
||||||
|
onComplete: () => {
|
||||||
|
cameraTransitionTimeline = null;
|
||||||
|
useGameStore.getState().setCinematicPlaying(false);
|
||||||
|
onComplete?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
cameraTransitionTimeline.to(camera.position, {
|
||||||
|
x: targetPosition[0],
|
||||||
|
y: targetPosition[1],
|
||||||
|
z: targetPosition[2],
|
||||||
|
duration,
|
||||||
|
ease: "power2.inOut",
|
||||||
|
});
|
||||||
|
|
||||||
|
cameraTransitionTimeline.to(
|
||||||
|
transitionObj,
|
||||||
|
{
|
||||||
|
progress: 1,
|
||||||
|
duration,
|
||||||
|
ease: "power2.inOut",
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ export function GameMap({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onLoadingStateChange?.({
|
onLoadingStateChange?.({
|
||||||
currentStep: "Récupération blocking",
|
currentStep: "Chargement de la carte",
|
||||||
progress: 0.05,
|
progress: 0.05,
|
||||||
status: "loading",
|
status: "loading",
|
||||||
});
|
});
|
||||||
@@ -163,7 +163,7 @@ export function GameMap({
|
|||||||
}
|
}
|
||||||
|
|
||||||
onLoadingStateChange?.({
|
onLoadingStateChange?.({
|
||||||
currentStep: "Importation des models",
|
currentStep: "Importation des modèles",
|
||||||
progress: 0.18,
|
progress: 0.18,
|
||||||
status: "loading",
|
status: "loading",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,28 +1,19 @@
|
|||||||
|
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 { RepairGame } from "@/components/three/gameplay/RepairGame";
|
||||||
import {
|
import {
|
||||||
REPAIR_MISSION_POSITION_ENTRIES,
|
REPAIR_MISSION_POSITION_ENTRIES,
|
||||||
REPAIR_MISSION_TRIGGERS,
|
REPAIR_MISSION_TRIGGERS,
|
||||||
} from "@/data/gameplay/repairMissionAnchors";
|
} from "@/data/gameplay/repairMissionAnchors";
|
||||||
|
import {
|
||||||
|
INTRO_STAGE_ANCHOR,
|
||||||
|
OUTRO_STAGE_ANCHOR,
|
||||||
|
} from "@/data/gameplay/gameStageAnchors";
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
import { useRepairMissionAnchorStore } from "@/managers/stores/useRepairMissionAnchorStore";
|
import { useRepairMissionAnchorStore } from "@/managers/stores/useRepairMissionAnchorStore";
|
||||||
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
|
||||||
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";
|
||||||
const FALLBACK_REPAIR_MISSION_POSITIONS = new Map(
|
|
||||||
REPAIR_MISSION_POSITION_ENTRIES.map(({ mission, position }) => [
|
|
||||||
mission,
|
|
||||||
position,
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
function getRepairMissionPosition(
|
|
||||||
mission: RepairMissionId,
|
|
||||||
anchors: Partial<Record<RepairMissionId, Vector3Tuple>>,
|
|
||||||
): Vector3Tuple | undefined {
|
|
||||||
return anchors[mission] ?? FALLBACK_REPAIR_MISSION_POSITIONS.get(mission);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StageAnchorProps {
|
interface StageAnchorProps {
|
||||||
color: string;
|
color: string;
|
||||||
@@ -89,9 +80,8 @@ export function GameStageContent(): React.JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{mainState === "intro" ? (
|
{mainState === "intro" ? <StageAnchor {...INTRO_STAGE_ANCHOR} /> : null}
|
||||||
<StageAnchor color="#7dd3fc" position={[0, 4, 0]} />
|
<Ebike position={[0, 10, 0]} />
|
||||||
) : null}
|
|
||||||
{REPAIR_MISSION_POSITION_ENTRIES.map(({ mission }) => {
|
{REPAIR_MISSION_POSITION_ENTRIES.map(({ mission }) => {
|
||||||
const position = getRepairMissionPosition(mission, anchors);
|
const position = getRepairMissionPosition(mission, anchors);
|
||||||
if (!position) return null;
|
if (!position) return null;
|
||||||
@@ -102,9 +92,7 @@ export function GameStageContent(): React.JSX.Element {
|
|||||||
{REPAIR_MISSION_TRIGGERS.map((config) => (
|
{REPAIR_MISSION_TRIGGERS.map((config) => (
|
||||||
<RepairMissionTrigger key={config.mission} config={config} />
|
<RepairMissionTrigger key={config.mission} config={config} />
|
||||||
))}
|
))}
|
||||||
{mainState === "outro" ? (
|
{mainState === "outro" ? <StageAnchor {...OUTRO_STAGE_ANCHOR} /> : null}
|
||||||
<StageAnchor color="#fb7185" position={[0, 6, 10]} scale={1.25} />
|
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -7,7 +7,7 @@ import {
|
|||||||
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 { usePersonnageDebug } from "@/hooks/debug/usePersonnageDebug";
|
import { useCharacterDebug } from "@/hooks/debug/useCharacterDebug";
|
||||||
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";
|
||||||
@@ -29,7 +29,7 @@ import { GameMusic } from "@/world/GameMusic";
|
|||||||
import { Lighting } from "@/world/Lighting";
|
import { Lighting } from "@/world/Lighting";
|
||||||
import { GameMap } from "@/world/GameMap";
|
import { GameMap } from "@/world/GameMap";
|
||||||
import { GameStageContent } from "@/world/GameStageContent";
|
import { GameStageContent } from "@/world/GameStageContent";
|
||||||
import { PersonnageSystem } from "@/world/personnages/PersonnageSystem";
|
import { CharacterSystem } from "@/world/characters/CharacterSystem";
|
||||||
import { Player } from "@/world/player/Player";
|
import { Player } from "@/world/player/Player";
|
||||||
import { TestMap } from "@/world/debug/TestMap";
|
import { TestMap } from "@/world/debug/TestMap";
|
||||||
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
|
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
|
||||||
@@ -41,7 +41,7 @@ interface WorldProps {
|
|||||||
export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
||||||
useEnvironmentDebug();
|
useEnvironmentDebug();
|
||||||
useMapPerformanceDebug();
|
useMapPerformanceDebug();
|
||||||
usePersonnageDebug();
|
useCharacterDebug();
|
||||||
|
|
||||||
const cameraMode = useCameraMode();
|
const cameraMode = useCameraMode();
|
||||||
const sceneMode = useSceneMode();
|
const sceneMode = useSceneMode();
|
||||||
@@ -90,7 +90,7 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
|||||||
onLoadingStateChange={onLoadingStateChange}
|
onLoadingStateChange={onLoadingStateChange}
|
||||||
onOctreeReady={handleOctreeReady}
|
onOctreeReady={handleOctreeReady}
|
||||||
/>
|
/>
|
||||||
<PersonnageSystem />
|
{showGameStage && mainState !== "ebike" ? <CharacterSystem /> : null}
|
||||||
{showGameStage ? (
|
{showGameStage ? (
|
||||||
<Physics>
|
<Physics>
|
||||||
<GameStageLoaded onLoaded={handleGameStageLoaded} />
|
<GameStageLoaded onLoaded={handleGameStageLoaded} />
|
||||||
|
|||||||
+12
-12
@@ -1,16 +1,16 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { AnimatedModel } from "@/components/three/models/AnimatedModel";
|
import { AnimatedModel } from "@/components/three/models/AnimatedModel";
|
||||||
import {
|
import {
|
||||||
PERSONNAGE_CONFIGS,
|
CHARACTER_CONFIGS,
|
||||||
PERSONNAGE_IDS,
|
CHARACTER_IDS,
|
||||||
type PersonnageId,
|
type CharacterId,
|
||||||
} from "@/data/world/personnages/personnageConfig";
|
} from "@/data/world/characters/characterConfig";
|
||||||
import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight";
|
import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight";
|
||||||
import { usePersonnageDebugStore } from "@/managers/stores/usePersonnageDebugStore";
|
import { useCharacterDebugStore } from "@/managers/stores/useCharacterDebugStore";
|
||||||
|
|
||||||
function PersonnageModel({ id }: { id: PersonnageId }): React.JSX.Element {
|
function CharacterModel({ id }: { id: CharacterId }): React.JSX.Element {
|
||||||
const config = PERSONNAGE_CONFIGS[id];
|
const config = CHARACTER_CONFIGS[id];
|
||||||
const state = usePersonnageDebugStore((store) => store.personnages[id]);
|
const state = useCharacterDebugStore((store) => store.characters[id]);
|
||||||
const position = useTerrainSnappedPosition(state.position);
|
const position = useTerrainSnappedPosition(state.position);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -24,12 +24,12 @@ function PersonnageModel({ id }: { id: PersonnageId }): React.JSX.Element {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PersonnageSystem(): React.JSX.Element {
|
export function CharacterSystem(): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
<group name="personnage-system">
|
<group name="character-system">
|
||||||
{PERSONNAGE_IDS.map((id) => (
|
{CHARACTER_IDS.map((id) => (
|
||||||
<Suspense key={id} fallback={null}>
|
<Suspense key={id} fallback={null}>
|
||||||
<PersonnageModel id={id} />
|
<CharacterModel id={id} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
))}
|
))}
|
||||||
</group>
|
</group>
|
||||||
+130
-3
@@ -1,11 +1,13 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Component, useRef } from "react";
|
import { Component, useRef, useState, useEffect } from "react";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier";
|
import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier";
|
||||||
|
import { Line } from "@react-three/drei";
|
||||||
import { RepairGame } from "@/components/three/gameplay/RepairGame";
|
import { RepairGame } from "@/components/three/gameplay/RepairGame";
|
||||||
import { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
|
import { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
|
||||||
import { AnimatedModel } from "@/components/three/models/AnimatedModel";
|
import { AnimatedModel } from "@/components/three/models/AnimatedModel";
|
||||||
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
|
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
|
||||||
|
import { EbikeGPSMap } from "@/components/ebike/EbikeGPSMap";
|
||||||
import {
|
import {
|
||||||
TEST_SCENE_FLOOR_COLLIDER_HALF_EXTENTS,
|
TEST_SCENE_FLOOR_COLLIDER_HALF_EXTENTS,
|
||||||
TEST_SCENE_FLOOR_POSITION,
|
TEST_SCENE_FLOOR_POSITION,
|
||||||
@@ -15,9 +17,9 @@ import {
|
|||||||
TEST_SCENE_GRABBABLE_METALNESS,
|
TEST_SCENE_GRABBABLE_METALNESS,
|
||||||
TEST_SCENE_GRABBABLE_POSITION,
|
TEST_SCENE_GRABBABLE_POSITION,
|
||||||
TEST_SCENE_GRABBABLE_ROUGHNESS,
|
TEST_SCENE_GRABBABLE_ROUGHNESS,
|
||||||
|
GAME_REPAIR_ZONES,
|
||||||
TEST_SCENE_REPAIR_ZONE_MARKER_RADIUS,
|
TEST_SCENE_REPAIR_ZONE_MARKER_RADIUS,
|
||||||
TEST_SCENE_REPAIR_ZONE_MARKER_TUBE_RADIUS,
|
TEST_SCENE_REPAIR_ZONE_MARKER_TUBE_RADIUS,
|
||||||
TEST_SCENE_REPAIR_ZONES,
|
|
||||||
TEST_SCENE_TRIGGER_COLOR,
|
TEST_SCENE_TRIGGER_COLOR,
|
||||||
TEST_SCENE_TRIGGER_METALNESS,
|
TEST_SCENE_TRIGGER_METALNESS,
|
||||||
TEST_SCENE_TRIGGER_POSITION,
|
TEST_SCENE_TRIGGER_POSITION,
|
||||||
@@ -84,11 +86,61 @@ class ModelPreviewErrorBoundary extends Component<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Waypoint {
|
||||||
|
id: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
connections: number[];
|
||||||
|
}
|
||||||
|
|
||||||
export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
|
export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
|
||||||
const floorRef = useRef<THREE.Group>(null);
|
const floorRef = useRef<THREE.Group>(null);
|
||||||
|
const [waypoints, setWaypoints] = useState<Waypoint[]>([]);
|
||||||
|
|
||||||
useOctreeGraphNode(floorRef, onOctreeReady);
|
useOctreeGraphNode(floorRef, onOctreeReady);
|
||||||
|
|
||||||
|
// Load waypoints with double-safe fallback
|
||||||
|
useEffect(() => {
|
||||||
|
// 1. Try localStorage
|
||||||
|
const saved = localStorage.getItem("la-fabrik-waypoints");
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||||
|
console.log(
|
||||||
|
`[TestMap] ${parsed.length} waypoints chargés depuis localStorage.`,
|
||||||
|
);
|
||||||
|
setWaypoints(parsed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to parse local storage waypoints", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try public/roadNetwork.json
|
||||||
|
console.log(
|
||||||
|
"[TestMap] Tentative de chargement depuis /roadNetwork.json...",
|
||||||
|
);
|
||||||
|
fetch("/roadNetwork.json")
|
||||||
|
.then((res) => {
|
||||||
|
if (res.ok) return res.json();
|
||||||
|
throw new Error("Impossible de charger /roadNetwork.json");
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
console.log(
|
||||||
|
`[TestMap] ${data.length} waypoints chargés depuis /roadNetwork.json.`,
|
||||||
|
);
|
||||||
|
setWaypoints(data);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log("[TestMap] Aucun point d'A* trouvé par défaut.", err);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<group ref={floorRef}>
|
<group ref={floorRef}>
|
||||||
@@ -98,6 +150,45 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
|
|||||||
</mesh>
|
</mesh>
|
||||||
</group>
|
</group>
|
||||||
|
|
||||||
|
{/* Render Pathfinder Maps Waypoints & Routes visually */}
|
||||||
|
<group name="pathfinder-maps-visuals">
|
||||||
|
{/* Render Connection Lines */}
|
||||||
|
{waypoints.flatMap((wp) =>
|
||||||
|
wp.connections.map((connId) => {
|
||||||
|
const other = waypoints.find((w) => w.id === connId);
|
||||||
|
// Draw each line only once by enforcing wp.id < other.id
|
||||||
|
if (other && wp.id < other.id) {
|
||||||
|
return (
|
||||||
|
<Line
|
||||||
|
key={`route-${wp.id}-${other.id}`}
|
||||||
|
points={[
|
||||||
|
[wp.x, wp.y + 0.3, wp.z],
|
||||||
|
[other.x, other.y + 0.3, other.z],
|
||||||
|
]}
|
||||||
|
color="#10b981" // Beautiful emerald green
|
||||||
|
lineWidth={2.5}
|
||||||
|
transparent
|
||||||
|
opacity={0.8}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Render Waypoint Spheres */}
|
||||||
|
{waypoints.map((wp) => (
|
||||||
|
<mesh key={`wp-sphere-${wp.id}`} position={[wp.x, wp.y + 0.3, wp.z]}>
|
||||||
|
<sphereGeometry args={[0.35, 16, 16]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
color="#059669" // Deep emerald green
|
||||||
|
transparent
|
||||||
|
opacity={0.8}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
|
||||||
<Physics>
|
<Physics>
|
||||||
<RigidBody type="fixed">
|
<RigidBody type="fixed">
|
||||||
<CuboidCollider
|
<CuboidCollider
|
||||||
@@ -141,7 +232,7 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
|
|||||||
</mesh>
|
</mesh>
|
||||||
</TriggerObject>
|
</TriggerObject>
|
||||||
|
|
||||||
{TEST_SCENE_REPAIR_ZONES.map((zone) => (
|
{GAME_REPAIR_ZONES.map((zone) => (
|
||||||
<group key={zone.mission}>
|
<group key={zone.mission}>
|
||||||
<group position={zone.position}>
|
<group position={zone.position}>
|
||||||
<RepairPlaygroundZoneMarker color={zone.color} />
|
<RepairPlaygroundZoneMarker color={zone.color} />
|
||||||
@@ -151,6 +242,42 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element {
|
|||||||
))}
|
))}
|
||||||
</Physics>
|
</Physics>
|
||||||
|
|
||||||
|
{/* Dynamic Futuristic 3D GPS Dashboard Preview */}
|
||||||
|
<group position={[0, 2.8, -4.8]} rotation={[0, 0, 0]}>
|
||||||
|
{/* Futuristic glowing screen frame (commented out to show true 3D transparency!) */}
|
||||||
|
{/*
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[4.2, 4.2, 0.1]} />
|
||||||
|
<meshStandardMaterial color="#0f172a" roughness={0.2} metalness={0.8} transparent opacity={0.4} />
|
||||||
|
</mesh>
|
||||||
|
*/}
|
||||||
|
{/* Glow accent border (commented out to remove any orange transparency tint!) */}
|
||||||
|
{/*
|
||||||
|
<mesh position={[0, 0, 0.01]}>
|
||||||
|
<boxGeometry args={[4.05, 4.05, 0.02]} />
|
||||||
|
<meshBasicMaterial color="#f97316" transparent opacity={0.1} />
|
||||||
|
</mesh>
|
||||||
|
*/}
|
||||||
|
{/* GPS Map screen plane */}
|
||||||
|
<group position={[0, 0, 0.06]}>
|
||||||
|
<EbikeGPSMap
|
||||||
|
width={4}
|
||||||
|
height={4}
|
||||||
|
startPos={{ x: 10, y: 0, z: -10 }}
|
||||||
|
destPos={{ x: -40, y: 0, z: 30 }}
|
||||||
|
mapImageUrl="/assets/gps/map_background.png"
|
||||||
|
worldBounds={{
|
||||||
|
minX: -166,
|
||||||
|
maxX: 163,
|
||||||
|
minZ: -142,
|
||||||
|
maxZ: 138,
|
||||||
|
}}
|
||||||
|
zoom={1}
|
||||||
|
canvasSize={900}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
<ModelPreviewErrorBoundary modelPath={ELECTRICIENNE_ANIMATED_MODEL_PATH}>
|
<ModelPreviewErrorBoundary modelPath={ELECTRICIENNE_ANIMATED_MODEL_PATH}>
|
||||||
<AnimatedModel
|
<AnimatedModel
|
||||||
modelPath={ELECTRICIENNE_ANIMATED_MODEL_PATH}
|
modelPath={ELECTRICIENNE_ANIMATED_MODEL_PATH}
|
||||||
|
|||||||
@@ -24,89 +24,84 @@ function random01(seed: number): number {
|
|||||||
return value - Math.floor(value);
|
return value - Math.floor(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function pushVector(target: number[], value: THREE.Vector3): void {
|
const GRASS_COLOR_VALUES = GRASS_COLORS.map((color) => new THREE.Color(color));
|
||||||
target.push(value.x, value.y, value.z);
|
const MARKER_COLOR_VALUES = [0.1, 0, 0, 0, 0, 0.1, 1, 1, 1] as const;
|
||||||
}
|
|
||||||
|
|
||||||
function pushColor(target: number[], value: THREE.Color): void {
|
|
||||||
target.push(value.r, value.g, value.b);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createGrassGeometry(density: number): THREE.BufferGeometry {
|
function createGrassGeometry(density: number): THREE.BufferGeometry {
|
||||||
const positions: number[] = [];
|
|
||||||
const colors: number[] = [];
|
|
||||||
const uvs: number[] = [];
|
|
||||||
const bladeOrigins: number[] = [];
|
|
||||||
const yaws: number[] = [];
|
|
||||||
const bladeCount = Math.round(GRASS_CONFIG.bladeCount * density);
|
const bladeCount = Math.round(GRASS_CONFIG.bladeCount * density);
|
||||||
|
const vertexCount = bladeCount * 3;
|
||||||
|
const positions = new Float32Array(vertexCount * 3);
|
||||||
|
const markerColorValues = new Float32Array(vertexCount * 3);
|
||||||
|
const bladeColorValues = new Float32Array(vertexCount * 3);
|
||||||
|
const uvs = new Float32Array(vertexCount * 2);
|
||||||
|
const bladeOrigins = new Float32Array(vertexCount * 3);
|
||||||
|
const yaws = new Float32Array(vertexCount * 3);
|
||||||
const halfPatchSize = GRASS_CONFIG.patchSize * 0.5;
|
const halfPatchSize = GRASS_CONFIG.patchSize * 0.5;
|
||||||
|
|
||||||
for (let index = 0; index < bladeCount; index++) {
|
for (let index = 0; index < bladeCount; index++) {
|
||||||
const seed = index * 997;
|
const seed = index * 997;
|
||||||
const origin = new THREE.Vector3(
|
const originX = random01(seed + 1) * GRASS_CONFIG.patchSize - halfPatchSize;
|
||||||
random01(seed + 1) * GRASS_CONFIG.patchSize - halfPatchSize,
|
const originY = 0;
|
||||||
0,
|
const originZ = random01(seed + 2) * GRASS_CONFIG.patchSize - halfPatchSize;
|
||||||
random01(seed + 2) * GRASS_CONFIG.patchSize - halfPatchSize,
|
|
||||||
);
|
|
||||||
const yawAngle = random01(seed + 3) * Math.PI * 2;
|
const yawAngle = random01(seed + 3) * Math.PI * 2;
|
||||||
const yaw = new THREE.Vector3(Math.sin(yawAngle), 0, -Math.cos(yawAngle));
|
const yawX = Math.sin(yawAngle);
|
||||||
|
const yawY = 0;
|
||||||
|
const yawZ = -Math.cos(yawAngle);
|
||||||
const colorIndex = Math.floor(random01(seed + 4) * GRASS_COLORS.length);
|
const colorIndex = Math.floor(random01(seed + 4) * GRASS_COLORS.length);
|
||||||
const color = new THREE.Color(GRASS_COLORS[colorIndex] ?? GRASS_COLORS[0]);
|
const color = GRASS_COLOR_VALUES[colorIndex] ?? GRASS_COLOR_VALUES[0];
|
||||||
const markerColors = [
|
const uvX = originX / GRASS_CONFIG.patchSize + 0.5;
|
||||||
new THREE.Color(0.1, 0, 0),
|
const uvY = originZ / GRASS_CONFIG.patchSize + 0.5;
|
||||||
new THREE.Color(0, 0, 0.1),
|
|
||||||
new THREE.Color(1, 1, 1),
|
|
||||||
] as const;
|
|
||||||
const uv = new THREE.Vector2(
|
|
||||||
origin.x / GRASS_CONFIG.patchSize + 0.5,
|
|
||||||
origin.z / GRASS_CONFIG.patchSize + 0.5,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (let vertexIndex = 0; vertexIndex < 3; vertexIndex++) {
|
for (let vertexIndex = 0; vertexIndex < 3; vertexIndex++) {
|
||||||
pushVector(positions, origin);
|
const vertexOffset = index * 3 + vertexIndex;
|
||||||
pushColor(colors, markerColors[vertexIndex] ?? markerColors[2]);
|
const vectorOffset = vertexOffset * 3;
|
||||||
pushVector(bladeOrigins, origin);
|
const uvOffset = vertexOffset * 2;
|
||||||
pushVector(yaws, yaw);
|
const markerOffset = vertexIndex * 3;
|
||||||
pushColor(colors, color);
|
|
||||||
uvs.push(uv.x, uv.y);
|
positions[vectorOffset] = originX;
|
||||||
|
positions[vectorOffset + 1] = originY;
|
||||||
|
positions[vectorOffset + 2] = originZ;
|
||||||
|
|
||||||
|
markerColorValues[vectorOffset] = MARKER_COLOR_VALUES[markerOffset] ?? 1;
|
||||||
|
markerColorValues[vectorOffset + 1] =
|
||||||
|
MARKER_COLOR_VALUES[markerOffset + 1] ?? 1;
|
||||||
|
markerColorValues[vectorOffset + 2] =
|
||||||
|
MARKER_COLOR_VALUES[markerOffset + 2] ?? 1;
|
||||||
|
|
||||||
|
bladeColorValues[vectorOffset] = color?.r ?? 0;
|
||||||
|
bladeColorValues[vectorOffset + 1] = color?.g ?? 0;
|
||||||
|
bladeColorValues[vectorOffset + 2] = color?.b ?? 0;
|
||||||
|
|
||||||
|
bladeOrigins[vectorOffset] = originX;
|
||||||
|
bladeOrigins[vectorOffset + 1] = originY;
|
||||||
|
bladeOrigins[vectorOffset + 2] = originZ;
|
||||||
|
|
||||||
|
yaws[vectorOffset] = yawX;
|
||||||
|
yaws[vectorOffset + 1] = yawY;
|
||||||
|
yaws[vectorOffset + 2] = yawZ;
|
||||||
|
|
||||||
|
uvs[uvOffset] = uvX;
|
||||||
|
uvs[uvOffset + 1] = uvY;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const geometry = new THREE.BufferGeometry();
|
const geometry = new THREE.BufferGeometry();
|
||||||
const markerColorValues: number[] = [];
|
|
||||||
const bladeColorValues: number[] = [];
|
|
||||||
|
|
||||||
for (let index = 0; index < colors.length; index += 6) {
|
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||||
markerColorValues.push(
|
|
||||||
colors[index] ?? 0,
|
|
||||||
colors[index + 1] ?? 0,
|
|
||||||
colors[index + 2] ?? 0,
|
|
||||||
);
|
|
||||||
bladeColorValues.push(
|
|
||||||
colors[index + 3] ?? 0,
|
|
||||||
colors[index + 4] ?? 0,
|
|
||||||
colors[index + 5] ?? 0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
geometry.setAttribute(
|
|
||||||
"position",
|
|
||||||
new THREE.Float32BufferAttribute(positions, 3),
|
|
||||||
);
|
|
||||||
geometry.setAttribute(
|
geometry.setAttribute(
|
||||||
"color",
|
"color",
|
||||||
new THREE.Float32BufferAttribute(markerColorValues, 3),
|
new THREE.BufferAttribute(markerColorValues, 3),
|
||||||
);
|
);
|
||||||
geometry.setAttribute(
|
geometry.setAttribute(
|
||||||
"aBladeColor",
|
"aBladeColor",
|
||||||
new THREE.Float32BufferAttribute(bladeColorValues, 3),
|
new THREE.BufferAttribute(bladeColorValues, 3),
|
||||||
);
|
);
|
||||||
geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));
|
geometry.setAttribute("uv", new THREE.BufferAttribute(uvs, 2));
|
||||||
geometry.setAttribute(
|
geometry.setAttribute(
|
||||||
"aBladeOrigin",
|
"aBladeOrigin",
|
||||||
new THREE.Float32BufferAttribute(bladeOrigins, 3),
|
new THREE.BufferAttribute(bladeOrigins, 3),
|
||||||
);
|
);
|
||||||
geometry.setAttribute("aYaw", new THREE.Float32BufferAttribute(yaws, 3));
|
geometry.setAttribute("aYaw", new THREE.BufferAttribute(yaws, 3));
|
||||||
geometry.computeVertexNormals();
|
geometry.computeVertexNormals();
|
||||||
geometry.computeBoundingSphere();
|
geometry.computeBoundingSphere();
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ function createTerrainGrassSampler(
|
|||||||
const terrainMatrix = createTerrainMatrix(position, rotation, scale);
|
const terrainMatrix = createTerrainMatrix(position, rotation, scale);
|
||||||
const inverseTerrainMatrix = terrainMatrix.clone().invert();
|
const inverseTerrainMatrix = terrainMatrix.clone().invert();
|
||||||
const normalMatrix = new THREE.Matrix3().getNormalMatrix(terrainMatrix);
|
const normalMatrix = new THREE.Matrix3().getNormalMatrix(terrainMatrix);
|
||||||
|
const localOrigin = new THREE.Vector3();
|
||||||
|
const localDirection = DOWN.clone().transformDirection(inverseTerrainMatrix);
|
||||||
|
const fallbackNormal = new THREE.Vector3(0, 1, 0);
|
||||||
|
const hits: THREE.Intersection[] = [];
|
||||||
const raycaster = new THREE.Raycaster(
|
const raycaster = new THREE.Raycaster(
|
||||||
new THREE.Vector3(),
|
new THREE.Vector3(),
|
||||||
DOWN,
|
DOWN,
|
||||||
@@ -94,14 +98,11 @@ function createTerrainGrassSampler(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sample = (x: number, z: number): TerrainGrassSample | null => {
|
const sample = (x: number, z: number): TerrainGrassSample | null => {
|
||||||
const localOrigin = new THREE.Vector3(x, RAYCAST_Y, z).applyMatrix4(
|
localOrigin.set(x, RAYCAST_Y, z).applyMatrix4(inverseTerrainMatrix);
|
||||||
inverseTerrainMatrix,
|
|
||||||
);
|
|
||||||
const localDirection =
|
|
||||||
DOWN.clone().transformDirection(inverseTerrainMatrix);
|
|
||||||
|
|
||||||
raycaster.set(localOrigin, localDirection);
|
raycaster.set(localOrigin, localDirection);
|
||||||
const hit = raycaster.intersectObjects(meshes, false)[0];
|
hits.length = 0;
|
||||||
|
raycaster.intersectObjects(meshes, false, hits);
|
||||||
|
const hit = hits[0];
|
||||||
if (!hit) return null;
|
if (!hit) return null;
|
||||||
|
|
||||||
const normal = hit.face?.normal
|
const normal = hit.face?.normal
|
||||||
@@ -112,7 +113,7 @@ function createTerrainGrassSampler(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
position: hit.point.clone().applyMatrix4(terrainMatrix),
|
position: hit.point.clone().applyMatrix4(terrainMatrix),
|
||||||
normal: normal ?? new THREE.Vector3(0, 1, 0),
|
normal: normal ?? fallbackNormal.clone(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { EcoleModel } from "@/components/three/world/EcoleModel";
|
import { EcoleModel } from "@/components/three/world/EcoleModel";
|
||||||
import { FermeVerticaleModel } from "@/components/three/world/FermeVerticaleModel";
|
import { FermeVerticaleModel } from "@/components/three/world/FermeVerticaleModel";
|
||||||
import { GenerateurModel } from "@/components/three/world/GenerateurModel";
|
import { GenerateurModel } from "@/components/three/world/GenerateurModel";
|
||||||
import { LafabrikModel } from "@/components/three/world/LafabrikModel";
|
import { LaFabrikMapModel } from "@/components/three/world/LaFabrikMapModel";
|
||||||
import {
|
import {
|
||||||
normalizeMapScale,
|
normalizeMapScale,
|
||||||
useTerrainSnappedPosition,
|
useTerrainSnappedPosition,
|
||||||
@@ -55,7 +55,7 @@ export function GeneratedMapNodeInstance({
|
|||||||
|
|
||||||
if (node.name === "lafabrik") {
|
if (node.name === "lafabrik") {
|
||||||
return (
|
return (
|
||||||
<LafabrikModel
|
<LaFabrikMapModel
|
||||||
position={position}
|
position={position}
|
||||||
rotation={node.rotation}
|
rotation={node.rotation}
|
||||||
scale={scale}
|
scale={scale}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useRef } from "react";
|
|||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { useGLTF } from "@react-three/drei";
|
import { useGLTF } from "@react-three/drei";
|
||||||
import { useThree } from "@react-three/fiber";
|
import { useThree } from "@react-three/fiber";
|
||||||
import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
|
|
||||||
import {
|
import {
|
||||||
normalizeMapScale,
|
normalizeMapScale,
|
||||||
useTerrainHeightSampler,
|
useTerrainHeightSampler,
|
||||||
@@ -23,11 +22,6 @@ interface MeshData {
|
|||||||
material: THREE.Material | THREE.Material[];
|
material: THREE.Material | THREE.Material[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MeshMergeGroup {
|
|
||||||
geometries: THREE.BufferGeometry[];
|
|
||||||
material: THREE.Material | THREE.Material[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const meshDataCache = new Map<string, MeshData[]>();
|
const meshDataCache = new Map<string, MeshData[]>();
|
||||||
|
|
||||||
function cloneMaterial(
|
function cloneMaterial(
|
||||||
@@ -38,46 +32,29 @@ function cloneMaterial(
|
|||||||
: material.clone();
|
: material.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
function disposeMaterialOnly(
|
|
||||||
material: THREE.Material | THREE.Material[],
|
|
||||||
): void {
|
|
||||||
if (Array.isArray(material)) {
|
|
||||||
for (const item of material) {
|
|
||||||
item.dispose();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
material.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
function disposeInstancedMapMesh(mesh: THREE.InstancedMesh): void {
|
function disposeInstancedMapMesh(mesh: THREE.InstancedMesh): void {
|
||||||
mesh.dispose();
|
mesh.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
function createGeometrySignature(geometry: THREE.BufferGeometry): string {
|
function hasFinitePositions(geometry: THREE.BufferGeometry): boolean {
|
||||||
const attributes = Object.entries(geometry.attributes)
|
const position = geometry.getAttribute("position");
|
||||||
.map(([name, attribute]) => {
|
if (!position) return false;
|
||||||
return `${name}:${attribute.itemSize}:${attribute.normalized}`;
|
|
||||||
})
|
|
||||||
.sort()
|
|
||||||
.join("|");
|
|
||||||
|
|
||||||
return `${geometry.index ? "indexed" : "non-indexed"}:${attributes}`;
|
for (let index = 0; index < position.count; index++) {
|
||||||
|
if (
|
||||||
|
!Number.isFinite(position.getX(index)) ||
|
||||||
|
!Number.isFinite(position.getY(index)) ||
|
||||||
|
!Number.isFinite(position.getZ(index))
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMaterialKey(
|
return true;
|
||||||
material: THREE.Material | THREE.Material[],
|
|
||||||
): string {
|
|
||||||
if (Array.isArray(material)) {
|
|
||||||
return material.map((item) => item.uuid).join("|");
|
|
||||||
}
|
|
||||||
|
|
||||||
return material.uuid;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractMeshes(scene: THREE.Group): MeshData[] {
|
function extractMeshes(scene: THREE.Group): MeshData[] {
|
||||||
const groups = new Map<string, MeshMergeGroup>();
|
const meshes: MeshData[] = [];
|
||||||
|
|
||||||
scene.updateMatrixWorld(true);
|
scene.updateMatrixWorld(true);
|
||||||
scene.traverse((child) => {
|
scene.traverse((child) => {
|
||||||
@@ -85,50 +62,18 @@ function extractMeshes(scene: THREE.Group): MeshData[] {
|
|||||||
|
|
||||||
const geometry = child.geometry.clone();
|
const geometry = child.geometry.clone();
|
||||||
geometry.applyMatrix4(child.matrixWorld);
|
geometry.applyMatrix4(child.matrixWorld);
|
||||||
const material = child.material;
|
if (!hasFinitePositions(geometry)) {
|
||||||
const key = `${createMaterialKey(material)}:${createGeometrySignature(geometry)}`;
|
geometry.dispose();
|
||||||
const group = groups.get(key);
|
|
||||||
|
|
||||||
if (group) {
|
|
||||||
group.geometries.push(geometry);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
groups.set(key, {
|
meshes.push({
|
||||||
geometries: [geometry],
|
|
||||||
material: cloneMaterial(material),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return [...groups.values()]
|
|
||||||
.map((group) => {
|
|
||||||
if (group.geometries.length === 1) {
|
|
||||||
const [geometry] = group.geometries;
|
|
||||||
if (!geometry) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
geometry,
|
geometry,
|
||||||
material: group.material,
|
material: cloneMaterial(child.material),
|
||||||
};
|
});
|
||||||
}
|
});
|
||||||
|
|
||||||
const mergedGeometry = mergeGeometries(group.geometries, false);
|
return meshes;
|
||||||
|
|
||||||
for (const geometry of group.geometries) {
|
|
||||||
geometry.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!mergedGeometry) {
|
|
||||||
disposeMaterialOnly(group.material);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
geometry: mergedGeometry,
|
|
||||||
material: group.material,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((meshData): meshData is MeshData => meshData !== null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setInstanceMatrices(
|
function setInstanceMatrices(
|
||||||
|
|||||||
@@ -16,9 +16,10 @@ import {
|
|||||||
} from "@/data/world/mapInstancingConfig";
|
} from "@/data/world/mapInstancingConfig";
|
||||||
import { useMapInstancingData } from "@/hooks/world/useMapInstancingData";
|
import { useMapInstancingData } from "@/hooks/world/useMapInstancingData";
|
||||||
import type { MapAssetInstance } from "@/types/map/mapScene";
|
import type { MapAssetInstance } from "@/types/map/mapScene";
|
||||||
|
import { createWorldInstanceChunks } from "@/utils/world/chunkInstances";
|
||||||
|
|
||||||
interface MapInstancingSystemProps {
|
interface MapInstancingSystemProps {
|
||||||
onlyModelName?: string | null;
|
onlyMapName?: string | null;
|
||||||
streaming?: boolean;
|
streaming?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,53 +31,24 @@ interface MapAssetChunk {
|
|||||||
instances: MapAssetInstance[];
|
instances: MapAssetInstance[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getChunkKey(instance: MapAssetInstance): string {
|
|
||||||
const [x, , z] = instance.position;
|
|
||||||
const chunkX = Math.floor(x / CHUNK_CONFIG.chunkSize);
|
|
||||||
const chunkZ = Math.floor(z / CHUNK_CONFIG.chunkSize);
|
|
||||||
return `${chunkX}:${chunkZ}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMapAssetChunks(
|
function createMapAssetChunks(
|
||||||
type: MapInstancingAssetType,
|
type: MapInstancingAssetType,
|
||||||
config: MapInstancingAssetConfig,
|
config: MapInstancingAssetConfig,
|
||||||
instances: MapAssetInstance[],
|
instances: MapAssetInstance[],
|
||||||
): MapAssetChunk[] {
|
): MapAssetChunk[] {
|
||||||
const chunks = new Map<string, MapAssetInstance[]>();
|
return createWorldInstanceChunks(instances).map((chunk) => {
|
||||||
|
|
||||||
for (const instance of instances) {
|
|
||||||
const key = getChunkKey(instance);
|
|
||||||
const chunk = chunks.get(key);
|
|
||||||
|
|
||||||
if (chunk) {
|
|
||||||
chunk.push(instance);
|
|
||||||
} else {
|
|
||||||
chunks.set(key, [instance]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...chunks.entries()].map(([chunkKey, chunkInstances]) => {
|
|
||||||
const center = chunkInstances.reduce(
|
|
||||||
(sum, instance) => {
|
|
||||||
sum.x += instance.position[0];
|
|
||||||
sum.z += instance.position[2];
|
|
||||||
return sum;
|
|
||||||
},
|
|
||||||
{ x: 0, z: 0 },
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key: `${type}:${chunkKey}`,
|
key: `${type}:${chunk.chunkKey}`,
|
||||||
config,
|
config,
|
||||||
centerX: center.x / chunkInstances.length,
|
centerX: chunk.centerX,
|
||||||
centerZ: center.z / chunkInstances.length,
|
centerZ: chunk.centerZ,
|
||||||
instances: chunkInstances,
|
instances: chunk.instances,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MapInstancingSystem({
|
export function MapInstancingSystem({
|
||||||
onlyModelName = null,
|
onlyMapName = null,
|
||||||
streaming = true,
|
streaming = true,
|
||||||
}: MapInstancingSystemProps): React.JSX.Element | null {
|
}: MapInstancingSystemProps): React.JSX.Element | null {
|
||||||
const cameraMode = useCameraMode();
|
const cameraMode = useCameraMode();
|
||||||
@@ -96,7 +68,7 @@ export function MapInstancingSystem({
|
|||||||
return MAP_INSTANCING_ASSET_TYPES.flatMap((type) => {
|
return MAP_INSTANCING_ASSET_TYPES.flatMap((type) => {
|
||||||
const config = MAP_INSTANCING_ASSETS[type];
|
const config = MAP_INSTANCING_ASSETS[type];
|
||||||
|
|
||||||
if (onlyModelName && config.mapName !== onlyModelName) return [];
|
if (onlyMapName && config.mapName !== onlyMapName) return [];
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!config.enabled ||
|
!config.enabled ||
|
||||||
@@ -110,7 +82,7 @@ export function MapInstancingSystem({
|
|||||||
|
|
||||||
return createMapAssetChunks(type, config, instances);
|
return createMapAssetChunks(type, config, instances);
|
||||||
});
|
});
|
||||||
}, [data, groups, models, onlyModelName]);
|
}, [data, groups, models, onlyMapName]);
|
||||||
|
|
||||||
const visibleChunks = useVisibleWorldChunks(chunks, streamingEnabled);
|
const visibleChunks = useVisibleWorldChunks(chunks, streamingEnabled);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useLayoutEffect } from "react";
|
import { useLayoutEffect } from "react";
|
||||||
import { useThree } from "@react-three/fiber";
|
import { useThree } from "@react-three/fiber";
|
||||||
import type { Octree } from "three/addons/math/Octree.js";
|
import type { Octree } from "three-stdlib";
|
||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
import { PlayerCamera } from "@/world/player/PlayerCamera";
|
import { PlayerCamera } from "@/world/player/PlayerCamera";
|
||||||
import { PlayerController } from "@/world/player/PlayerController";
|
import { PlayerController } from "@/world/player/PlayerController";
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { useThree } from "@react-three/fiber";
|
||||||
import { PointerLockControls } from "@react-three/drei";
|
import { PointerLockControls } from "@react-three/drei";
|
||||||
|
import { setGlobalCamera } from "@/world/GameCinematics";
|
||||||
|
|
||||||
export function PlayerCamera(): React.JSX.Element {
|
export function PlayerCamera(): React.JSX.Element {
|
||||||
|
const camera = useThree((state) => state.camera);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setGlobalCamera(camera);
|
||||||
return () => {
|
return () => {
|
||||||
|
setGlobalCamera(null);
|
||||||
document.exitPointerLock();
|
document.exitPointerLock();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [camera]);
|
||||||
|
|
||||||
return <PointerLockControls />;
|
return <PointerLockControls />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||||
import { useFrame, useThree } from "@react-three/fiber";
|
import { useFrame, useThree } from "@react-three/fiber";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { Capsule } from "three/addons/math/Capsule.js";
|
import { Capsule, type Octree } from "three-stdlib";
|
||||||
import type { Octree } from "three/addons/math/Octree.js";
|
|
||||||
import {
|
import {
|
||||||
INTERACT_KEY,
|
INTERACT_KEY,
|
||||||
JUMP_KEY,
|
JUMP_KEY,
|
||||||
@@ -22,7 +21,6 @@ import {
|
|||||||
PLAYER_GRAVITY,
|
PLAYER_GRAVITY,
|
||||||
PLAYER_JUMP_SPEED,
|
PLAYER_JUMP_SPEED,
|
||||||
PLAYER_MAX_DELTA,
|
PLAYER_MAX_DELTA,
|
||||||
PLAYER_WALK_SPEED,
|
|
||||||
PLAYER_XZ_DAMPING_FACTOR,
|
PLAYER_XZ_DAMPING_FACTOR,
|
||||||
} from "@/data/player/playerConfig";
|
} from "@/data/player/playerConfig";
|
||||||
import { useRepairMovementLocked } from "@/hooks/gameplay/useRepairMovementLocked";
|
import { useRepairMovementLocked } from "@/hooks/gameplay/useRepairMovementLocked";
|
||||||
@@ -31,6 +29,7 @@ import { InteractionManager } from "@/managers/InteractionManager";
|
|||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
import { EBIKE_CAMERA_TRANSFORM } from "@/components/ebike/Ebike";
|
||||||
|
|
||||||
type Keys = {
|
type Keys = {
|
||||||
forward: boolean;
|
forward: boolean;
|
||||||
@@ -137,9 +136,74 @@ export function PlayerController({
|
|||||||
const wantsJump = useRef(false);
|
const wantsJump = useRef(false);
|
||||||
const initializedRef = useRef(false);
|
const initializedRef = useRef(false);
|
||||||
const canMove = useGameStore((state) => state.missionFlow.canMove);
|
const canMove = useGameStore((state) => state.missionFlow.canMove);
|
||||||
|
const currentSpeed = useGameStore((state) => state.player.currentSpeed);
|
||||||
|
const movementMode = useGameStore((state) => state.player.movementMode);
|
||||||
|
const movementModeRef = useRef(movementMode);
|
||||||
|
const prevMovementModeRef = useRef(movementMode);
|
||||||
|
const ebikeAngle = useRef(0);
|
||||||
const capsule = useRef(createSpawnCapsule(spawnPosition));
|
const capsule = useRef(createSpawnCapsule(spawnPosition));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
movementModeRef.current = movementMode;
|
||||||
|
}, [movementMode]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (movementMode === "ebike") {
|
||||||
|
const targetPos: Vector3Tuple = (window as any).ebikeParkedPosition || [
|
||||||
|
0, 8.2, 0,
|
||||||
|
];
|
||||||
|
const targetRot: number = (window as any).ebikeParkedRotation || 0;
|
||||||
|
|
||||||
|
const headY = targetPos[1] + PLAYER_EYE_HEIGHT;
|
||||||
|
const bottomY = targetPos[1] + PLAYER_CAPSULE_RADIUS;
|
||||||
|
|
||||||
|
capsule.current.start.set(targetPos[0], bottomY, targetPos[2]);
|
||||||
|
capsule.current.end.set(targetPos[0], headY, targetPos[2]);
|
||||||
|
velocity.current.set(0, 0, 0);
|
||||||
|
onFloor.current = false;
|
||||||
|
wantsJump.current = false;
|
||||||
|
|
||||||
|
ebikeAngle.current = targetRot;
|
||||||
|
|
||||||
|
const cameraOffset = new THREE.Vector3(
|
||||||
|
...EBIKE_CAMERA_TRANSFORM.position,
|
||||||
|
);
|
||||||
|
cameraOffset.applyAxisAngle(_up, targetRot);
|
||||||
|
|
||||||
|
const camPos = new THREE.Vector3()
|
||||||
|
.copy(capsule.current.end)
|
||||||
|
.add(cameraOffset);
|
||||||
|
camera.position.copy(camPos);
|
||||||
|
|
||||||
|
const pitchRad = THREE.MathUtils.degToRad(
|
||||||
|
EBIKE_CAMERA_TRANSFORM.rotation[0],
|
||||||
|
);
|
||||||
|
const yawRad =
|
||||||
|
THREE.MathUtils.degToRad(EBIKE_CAMERA_TRANSFORM.rotation[1]) +
|
||||||
|
targetRot;
|
||||||
|
const rollRad = THREE.MathUtils.degToRad(
|
||||||
|
EBIKE_CAMERA_TRANSFORM.rotation[2],
|
||||||
|
);
|
||||||
|
camera.rotation.set(pitchRad, yawRad, rollRad, "YXZ");
|
||||||
|
} else if (
|
||||||
|
movementMode === "walk" &&
|
||||||
|
prevMovementModeRef.current === "ebike"
|
||||||
|
) {
|
||||||
|
const perspectiveCam = camera as THREE.PerspectiveCamera;
|
||||||
|
perspectiveCam.fov = 60;
|
||||||
|
perspectiveCam.updateProjectionMatrix();
|
||||||
|
|
||||||
|
const rightDir = new THREE.Vector3();
|
||||||
|
camera.getWorldDirection(_forward);
|
||||||
|
_forward.setY(0).normalize();
|
||||||
|
rightDir.crossVectors(_forward, _up).normalize();
|
||||||
|
|
||||||
|
const shift = rightDir.multiplyScalar(3);
|
||||||
|
capsule.current.translate(shift);
|
||||||
|
camera.position.copy(capsule.current.end);
|
||||||
|
}
|
||||||
|
prevMovementModeRef.current = movementMode;
|
||||||
|
}, [movementMode, camera]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
resetPlayerCapsule(
|
resetPlayerCapsule(
|
||||||
capsule.current,
|
capsule.current,
|
||||||
@@ -267,6 +331,16 @@ export function PlayerController({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (movementModeRef.current === "ebike") {
|
||||||
|
const turnSpeed = 1.8;
|
||||||
|
if (keys.current.left) {
|
||||||
|
ebikeAngle.current += turnSpeed * dt;
|
||||||
|
}
|
||||||
|
if (keys.current.right) {
|
||||||
|
ebikeAngle.current -= turnSpeed * dt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
camera.getWorldDirection(_forward);
|
camera.getWorldDirection(_forward);
|
||||||
_forward.setY(0);
|
_forward.setY(0);
|
||||||
if (_forward.lengthSq() > 0) {
|
if (_forward.lengthSq() > 0) {
|
||||||
@@ -278,14 +352,16 @@ export function PlayerController({
|
|||||||
if (!movementLocked) {
|
if (!movementLocked) {
|
||||||
if (keys.current.forward) _wishDir.add(_forward);
|
if (keys.current.forward) _wishDir.add(_forward);
|
||||||
if (keys.current.backward) _wishDir.sub(_forward);
|
if (keys.current.backward) _wishDir.sub(_forward);
|
||||||
|
if (movementModeRef.current !== "ebike") {
|
||||||
if (keys.current.left) _wishDir.sub(_right);
|
if (keys.current.left) _wishDir.sub(_right);
|
||||||
if (keys.current.right) _wishDir.add(_right);
|
if (keys.current.right) _wishDir.add(_right);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (_wishDir.lengthSq() > 0) _wishDir.normalize();
|
if (_wishDir.lengthSq() > 0) _wishDir.normalize();
|
||||||
|
|
||||||
const accel = onFloor.current
|
const accel = onFloor.current
|
||||||
? PLAYER_WALK_SPEED
|
? currentSpeed
|
||||||
: PLAYER_WALK_SPEED * PLAYER_AIR_CONTROL_FACTOR;
|
: currentSpeed * PLAYER_AIR_CONTROL_FACTOR;
|
||||||
velocity.current.x +=
|
velocity.current.x +=
|
||||||
_wishDir.x * accel * dt * PLAYER_ACCELERATION_MULTIPLIER;
|
_wishDir.x * accel * dt * PLAYER_ACCELERATION_MULTIPLIER;
|
||||||
velocity.current.z +=
|
velocity.current.z +=
|
||||||
@@ -354,7 +430,78 @@ export function PlayerController({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (movementModeRef.current === "ebike") {
|
||||||
|
let targetSteer = 0;
|
||||||
|
if (keys.current.left) targetSteer = 1;
|
||||||
|
else if (keys.current.right) targetSteer = -1;
|
||||||
|
|
||||||
|
const currentSteer = (window as any).ebikeSteerFactor || 0;
|
||||||
|
const steerFactor = THREE.MathUtils.lerp(
|
||||||
|
currentSteer,
|
||||||
|
targetSteer,
|
||||||
|
8 * dt,
|
||||||
|
);
|
||||||
|
(window as any).ebikeSteerFactor = steerFactor;
|
||||||
|
|
||||||
|
const speed = velocity.current.length();
|
||||||
|
const targetFov = 60 + Math.min(speed * 0.35, 9);
|
||||||
|
const perspectiveCam = camera as THREE.PerspectiveCamera;
|
||||||
|
perspectiveCam.fov = THREE.MathUtils.lerp(
|
||||||
|
perspectiveCam.fov,
|
||||||
|
targetFov,
|
||||||
|
6 * dt,
|
||||||
|
);
|
||||||
|
perspectiveCam.updateProjectionMatrix();
|
||||||
|
|
||||||
|
const cameraOffset = new THREE.Vector3(
|
||||||
|
...EBIKE_CAMERA_TRANSFORM.position,
|
||||||
|
);
|
||||||
|
cameraOffset.applyAxisAngle(_up, ebikeAngle.current);
|
||||||
|
|
||||||
|
const swingX = -Math.abs(steerFactor) * 1.5;
|
||||||
|
const swingZ = steerFactor > 0 ? steerFactor * 2.5 : steerFactor * 1.0;
|
||||||
|
|
||||||
|
const cameraSwing = new THREE.Vector3(swingX, 0, swingZ);
|
||||||
|
cameraSwing.applyAxisAngle(_up, ebikeAngle.current);
|
||||||
|
cameraOffset.add(cameraSwing);
|
||||||
|
|
||||||
|
const targetCamPos = new THREE.Vector3()
|
||||||
|
.copy(capsule.current.end)
|
||||||
|
.add(cameraOffset);
|
||||||
|
|
||||||
|
camera.position.lerp(targetCamPos, 12 * dt);
|
||||||
|
|
||||||
|
const pitchRad = THREE.MathUtils.degToRad(
|
||||||
|
EBIKE_CAMERA_TRANSFORM.rotation[0],
|
||||||
|
);
|
||||||
|
const yawRad =
|
||||||
|
THREE.MathUtils.degToRad(EBIKE_CAMERA_TRANSFORM.rotation[1]) +
|
||||||
|
ebikeAngle.current;
|
||||||
|
const rollRad = THREE.MathUtils.degToRad(
|
||||||
|
EBIKE_CAMERA_TRANSFORM.rotation[2],
|
||||||
|
);
|
||||||
|
camera.rotation.set(pitchRad, yawRad, rollRad, "YXZ");
|
||||||
|
|
||||||
|
const ebikeVisual = (window as any).ebikeVisualGroup?.current;
|
||||||
|
if (ebikeVisual) {
|
||||||
|
ebikeVisual.position.set(
|
||||||
|
capsule.current.end.x,
|
||||||
|
capsule.current.end.y - PLAYER_EYE_HEIGHT,
|
||||||
|
capsule.current.end.z,
|
||||||
|
);
|
||||||
|
const leanAngle = steerFactor * 0.26;
|
||||||
|
ebikeVisual.rotation.set(0, ebikeAngle.current, leanAngle, "YXZ");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
camera.position.copy(capsule.current.end);
|
camera.position.copy(capsule.current.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
(window as any).playerPos = [
|
||||||
|
capsule.current.end.x,
|
||||||
|
capsule.current.end.y,
|
||||||
|
capsule.current.end.z,
|
||||||
|
];
|
||||||
|
(window as any).ebikeAngle = ebikeAngle.current;
|
||||||
});
|
});
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useRef } from "react";
|
|||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { useGLTF } from "@react-three/drei";
|
import { useGLTF } from "@react-three/drei";
|
||||||
import { useFrame, useThree } from "@react-three/fiber";
|
import { useFrame, useThree } from "@react-three/fiber";
|
||||||
import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
|
|
||||||
import { useTerrainHeightSampler } from "@/hooks/three/useTerrainHeight";
|
import { useTerrainHeightSampler } from "@/hooks/three/useTerrainHeight";
|
||||||
import type { VegetationInstance } from "@/types/map/mapScene";
|
import type { VegetationInstance } from "@/types/map/mapScene";
|
||||||
import { useWind } from "@/hooks/world/useWind";
|
import { useWind } from "@/hooks/world/useWind";
|
||||||
@@ -38,6 +37,7 @@ interface VegetationWindUniforms {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const meshDataCache = new Map<string, MeshData[]>();
|
const meshDataCache = new Map<string, MeshData[]>();
|
||||||
|
const VEGETATION_ALPHA_TEST = 0.35;
|
||||||
|
|
||||||
function updateVegetationWindUniforms(
|
function updateVegetationWindUniforms(
|
||||||
uniforms: VegetationWindUniforms,
|
uniforms: VegetationWindUniforms,
|
||||||
@@ -90,6 +90,15 @@ function applyVegetationWindMaterial(
|
|||||||
};
|
};
|
||||||
|
|
||||||
windMaterial.userData.windUniforms = windUniforms;
|
windMaterial.userData.windUniforms = windUniforms;
|
||||||
|
windMaterial.alphaTest = Math.max(
|
||||||
|
windMaterial.alphaTest,
|
||||||
|
VEGETATION_ALPHA_TEST,
|
||||||
|
);
|
||||||
|
windMaterial.transparent = false;
|
||||||
|
windMaterial.depthTest = true;
|
||||||
|
windMaterial.depthWrite = true;
|
||||||
|
windMaterial.side = THREE.DoubleSide;
|
||||||
|
windMaterial.needsUpdate = true;
|
||||||
|
|
||||||
windMaterial.onBeforeCompile = (shader) => {
|
windMaterial.onBeforeCompile = (shader) => {
|
||||||
shader.uniforms.uVegetationWindTime = windUniforms.time;
|
shader.uniforms.uVegetationWindTime = windUniforms.time;
|
||||||
@@ -130,11 +139,25 @@ function applyVegetationWindMaterial(
|
|||||||
return windMaterial;
|
return windMaterial;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasFinitePositions(geometry: THREE.BufferGeometry): boolean {
|
||||||
|
const position = geometry.getAttribute("position");
|
||||||
|
if (!position) return false;
|
||||||
|
|
||||||
|
for (let index = 0; index < position.count; index++) {
|
||||||
|
if (
|
||||||
|
!Number.isFinite(position.getX(index)) ||
|
||||||
|
!Number.isFinite(position.getY(index)) ||
|
||||||
|
!Number.isFinite(position.getZ(index))
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function extractMeshes(scene: THREE.Group): MeshData[] {
|
function extractMeshes(scene: THREE.Group): MeshData[] {
|
||||||
const meshesByMaterial = new Map<
|
const meshes: MeshData[] = [];
|
||||||
string,
|
|
||||||
{ geometries: THREE.BufferGeometry[]; material: THREE.Material }
|
|
||||||
>();
|
|
||||||
scene.updateMatrixWorld(true);
|
scene.updateMatrixWorld(true);
|
||||||
|
|
||||||
scene.traverse((child) => {
|
scene.traverse((child) => {
|
||||||
@@ -147,41 +170,19 @@ function extractMeshes(scene: THREE.Group): MeshData[] {
|
|||||||
|
|
||||||
const geometry = child.geometry.clone();
|
const geometry = child.geometry.clone();
|
||||||
geometry.applyMatrix4(child.matrixWorld);
|
geometry.applyMatrix4(child.matrixWorld);
|
||||||
|
if (!hasFinitePositions(geometry)) {
|
||||||
const existing = meshesByMaterial.get(material.uuid);
|
|
||||||
if (existing) {
|
|
||||||
existing.geometries.push(geometry);
|
|
||||||
} else {
|
|
||||||
meshesByMaterial.set(material.uuid, {
|
|
||||||
geometries: [geometry],
|
|
||||||
material: material.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return [...meshesByMaterial.values()]
|
|
||||||
.map(({ geometries, material }) => {
|
|
||||||
const mergedGeometry = mergeGeometries(geometries, false);
|
|
||||||
|
|
||||||
for (const geometry of geometries) {
|
|
||||||
if (geometry !== mergedGeometry) {
|
|
||||||
geometry.dispose();
|
geometry.dispose();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
addWindWeightAttribute(geometry);
|
||||||
|
|
||||||
if (!mergedGeometry) {
|
meshes.push({
|
||||||
material.dispose();
|
geometry,
|
||||||
return null;
|
material: applyVegetationWindMaterial(material.clone()),
|
||||||
}
|
});
|
||||||
|
});
|
||||||
|
|
||||||
addWindWeightAttribute(mergedGeometry);
|
return meshes;
|
||||||
|
|
||||||
return {
|
|
||||||
geometry: mergedGeometry,
|
|
||||||
material: applyVegetationWindMaterial(material),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((meshData): meshData is MeshData => meshData !== null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createInstanceMatrices(
|
function createInstanceMatrices(
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ import {
|
|||||||
VEGETATION_TYPES,
|
VEGETATION_TYPES,
|
||||||
type VegetationType,
|
type VegetationType,
|
||||||
} from "@/data/world/vegetationConfig";
|
} from "@/data/world/vegetationConfig";
|
||||||
|
import { createWorldInstanceChunks } from "@/utils/world/chunkInstances";
|
||||||
|
|
||||||
interface VegetationSystemProps {
|
interface VegetationSystemProps {
|
||||||
onlyModelName?: string | null;
|
onlyMapName?: string | null;
|
||||||
streaming?: boolean;
|
streaming?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,42 +36,15 @@ interface VegetationChunk {
|
|||||||
instances: VegetationInstance[];
|
instances: VegetationInstance[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getChunkKey(instance: VegetationInstance): string {
|
|
||||||
const [x, , z] = instance.position;
|
|
||||||
const chunkX = Math.floor(x / CHUNK_CONFIG.chunkSize);
|
|
||||||
const chunkZ = Math.floor(z / CHUNK_CONFIG.chunkSize);
|
|
||||||
return `${chunkX}:${chunkZ}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createVegetationChunks(
|
function createVegetationChunks(
|
||||||
type: VegetationType,
|
type: VegetationType,
|
||||||
instances: VegetationInstance[],
|
instances: VegetationInstance[],
|
||||||
): VegetationChunk[] {
|
): VegetationChunk[] {
|
||||||
const config = VEGETATION_TYPES[type];
|
const config = VEGETATION_TYPES[type];
|
||||||
const chunks = new Map<string, VegetationInstance[]>();
|
|
||||||
|
|
||||||
for (const instance of instances) {
|
|
||||||
const key = getChunkKey(instance);
|
|
||||||
const chunk = chunks.get(key);
|
|
||||||
if (chunk) {
|
|
||||||
chunk.push(instance);
|
|
||||||
} else {
|
|
||||||
chunks.set(key, [instance]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...chunks.entries()].map(([chunkKey, chunkInstances]) => {
|
|
||||||
const center = chunkInstances.reduce(
|
|
||||||
(sum, instance) => {
|
|
||||||
sum.x += instance.position[0];
|
|
||||||
sum.z += instance.position[2];
|
|
||||||
return sum;
|
|
||||||
},
|
|
||||||
{ x: 0, z: 0 },
|
|
||||||
);
|
|
||||||
|
|
||||||
|
return createWorldInstanceChunks(instances).map((chunk) => {
|
||||||
return {
|
return {
|
||||||
key: `${type}:${chunkKey}`,
|
key: `${type}:${chunk.chunkKey}`,
|
||||||
type,
|
type,
|
||||||
modelPath: config.modelPath,
|
modelPath: config.modelPath,
|
||||||
scaleMultiplier: config.scaleMultiplier,
|
scaleMultiplier: config.scaleMultiplier,
|
||||||
@@ -78,15 +52,15 @@ function createVegetationChunks(
|
|||||||
receiveShadow: config.receiveShadow,
|
receiveShadow: config.receiveShadow,
|
||||||
windStrength: config.windStrength,
|
windStrength: config.windStrength,
|
||||||
rotationOffset: config.rotationOffset,
|
rotationOffset: config.rotationOffset,
|
||||||
centerX: center.x / chunkInstances.length,
|
centerX: chunk.centerX,
|
||||||
centerZ: center.z / chunkInstances.length,
|
centerZ: chunk.centerZ,
|
||||||
instances: chunkInstances,
|
instances: chunk.instances,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VegetationSystem({
|
export function VegetationSystem({
|
||||||
onlyModelName = null,
|
onlyMapName = null,
|
||||||
streaming = true,
|
streaming = true,
|
||||||
}: VegetationSystemProps): React.JSX.Element | null {
|
}: VegetationSystemProps): React.JSX.Element | null {
|
||||||
const cameraMode = useCameraMode();
|
const cameraMode = useCameraMode();
|
||||||
@@ -106,7 +80,7 @@ export function VegetationSystem({
|
|||||||
return VEGETATION_TYPE_KEYS.flatMap((type) => {
|
return VEGETATION_TYPE_KEYS.flatMap((type) => {
|
||||||
const config = VEGETATION_TYPES[type];
|
const config = VEGETATION_TYPES[type];
|
||||||
|
|
||||||
if (onlyModelName && config.mapName !== onlyModelName) return [];
|
if (onlyMapName && config.mapName !== onlyMapName) return [];
|
||||||
|
|
||||||
if (!config.enabled) return [];
|
if (!config.enabled) return [];
|
||||||
if (!isMapModelVisible(config.mapName, { groups, models })) return [];
|
if (!isMapModelVisible(config.mapName, { groups, models })) return [];
|
||||||
@@ -116,7 +90,7 @@ export function VegetationSystem({
|
|||||||
|
|
||||||
return createVegetationChunks(type, entry.instances);
|
return createVegetationChunks(type, entry.instances);
|
||||||
});
|
});
|
||||||
}, [data, groups, models, onlyModelName]);
|
}, [data, groups, models, onlyMapName]);
|
||||||
|
|
||||||
const visibleChunks = useVisibleWorldChunks(chunks, streamingEnabled);
|
const visibleChunks = useVisibleWorldChunks(chunks, streamingEnabled);
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ const THREE_SOURCE_ENTRY = fileURLToPath(
|
|||||||
new URL("./node_modules/three/src/Three.js", import.meta.url),
|
new URL("./node_modules/three/src/Three.js", import.meta.url),
|
||||||
);
|
);
|
||||||
|
|
||||||
const MAX_MAP_PAYLOAD_BYTES = 1024 * 1024;
|
const MAX_MAP_PAYLOAD_BYTES = 4 * 1024 * 1024;
|
||||||
const MAX_SRT_PAYLOAD_BYTES = 256 * 1024;
|
const MAX_SRT_PAYLOAD_BYTES = 256 * 1024;
|
||||||
const MAX_DIALOGUE_MANIFEST_PAYLOAD_BYTES = 256 * 1024;
|
const MAX_DIALOGUE_MANIFEST_PAYLOAD_BYTES = 256 * 1024;
|
||||||
const MAX_CINEMATIC_MANIFEST_PAYLOAD_BYTES = 256 * 1024;
|
const MAX_CINEMATIC_MANIFEST_PAYLOAD_BYTES = 256 * 1024;
|
||||||
|
|||||||
Reference in New Issue
Block a user