33 Commits

Author SHA1 Message Date
Tom Boullay f7b4a07e41 fix: bug on textute vegetation item
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
2026-05-29 02:00:35 +02:00
Tom Boullay 89044a18ec merge develop into feat/map-environment
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
2026-05-29 01:45:08 +02:00
Tom Boullay 95ca1bbfde hore(review): tighten pre-merge audit cleanup
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
2026-05-29 01:34:10 +02:00
Tom Boullay 093ffd726d fix(review): address audit findings before merge
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
2026-05-29 01:23:08 +02:00
Tom Boullay 4728690a11 Create outro.mp4
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
2026-05-29 00:52:53 +02:00
Tom Boullay 343a122c06 fix(editor): restore stable map editing behavior 2026-05-29 00:52:44 +02:00
math-pixel fb466a63cb Merge pull request 'Merge e_bike + gps into develop' (#7) from feat/gps into develop
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled
Reviewed-on: #7
2026-05-28 05:55:18 +00:00
math-pixel a75c3fd896 uptd : change location of map_backougnround & fix : remove old netshader into world tsx
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
2026-05-28 07:54:47 +02:00
math-pixel 603e521714 Merge branch 'develop' into feat/gps 2026-05-28 07:50:25 +02:00
math-pixel 49ef8f58b4 Merge pull request 'Add Net shader into develop' (#8) from feat/shader-net into develop
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled
Reviewed-on: #8
2026-05-28 05:47:52 +00:00
math-pixel 0a322acf88 Merge branch 'develop' into feat/shader-net
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
2026-05-27 18:08:46 +02:00
math-pixel a397febd52 fix zoom
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
2026-05-27 17:23:06 +02:00
math-pixel c15cad2ab0 fix zoom 2026-05-27 17:22:14 +02:00
math-pixel 011e7815a2 update gps 2026-05-27 17:15:08 +02:00
math-pixel 970253801a add map on bike 2026-05-22 18:28:05 +02:00
math-pixel 246da0019a add transparency gps 2026-05-20 16:56:01 +02:00
math-pixel 09a9471814 feature gps works 2026-05-20 15:29:23 +02:00
math-pixel 6e9318457a gps component 2026-05-20 14:45:40 +02:00
math-pixel 54a353de03 first implementation of pathfinding 2026-05-20 14:34:26 +02:00
math-pixel 8b619bfc28 feat: add NetShader and UnicolorShader with a debug component for visual testing in the world scene 2026-05-19 22:54:29 +02:00
math-pixel 4faa226326 working move kikle 2026-05-19 17:10:34 +02:00
math-pixel dd66966507 working move kikle 2026-05-19 17:04:01 +02:00
math-pixel 5893afe42a working move kikle 2026-05-19 16:34:48 +02:00
math-pixel 1ead7ab3a7 working move kikle 2026-05-19 16:17:02 +02:00
math-pixel 047c58678b working move kikle 2026-05-19 16:12:58 +02:00
math-pixel ed9051b0dc working move kikle 2026-05-19 16:10:57 +02:00
math-pixel 08be6bee48 add good inclinason cam 2026-05-19 15:54:40 +02:00
math-pixel ce0eb90321 inhance move 2026-05-19 15:50:11 +02:00
math-pixel 96d7ec7fc0 move forward cam 2026-05-19 15:36:50 +02:00
math-pixel 9ab4b4a002 first move with bike 2026-05-19 15:32:59 +02:00
math-pixel d13dd0fda0 wip bike movement 2026-05-17 12:30:40 +02:00
math-pixel fbedb90bca working bike 2026-05-17 08:15:16 +02:00
math-pixel cff7744ad9 wip 2026-05-17 07:41:29 +02:00
97 changed files with 6695 additions and 1202 deletions
+2 -2
View File
@@ -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
+6
View File
@@ -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.
+1 -1
View File
@@ -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
+4 -5
View File
@@ -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
+1 -1
View File
@@ -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:
+3 -4
View File
@@ -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
+1
View File
@@ -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": {
+2
View File
@@ -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
View File
@@ -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
+59
View File
@@ -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}`);
} }
+293
View File
@@ -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>
</>
)}
</>
);
}
+497
View File
@@ -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>
);
};
+53 -13
View File
@@ -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}
+72 -21
View File
@@ -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>
); );
} }
+36 -10
View File
@@ -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 />
+24
View File
@@ -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;
+3 -2
View File
@@ -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);
+40 -2
View File
@@ -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>
); );
+5 -2
View File
@@ -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;
+18
View File
@@ -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,
};
+7 -1
View File
@@ -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;
+1 -1
View File
@@ -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,
); );
+1
View File
@@ -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;
@@ -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[];
+2
View File
@@ -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";
+1 -1
View File
@@ -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,
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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: {
+7 -1
View File
@@ -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();
+51 -3
View File
@@ -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 -1
View File
@@ -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(
+7 -6
View File
@@ -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;
}, },
}; };
+2 -40
View File
@@ -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 -1
View File
@@ -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),
},
},
})),
}));
+44
View File
@@ -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),
},
},
})),
}));
+357
View File
@@ -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
View File
@@ -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
+131
View File
@@ -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 [];
}
+234
View File
@@ -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,
};
+104
View File
@@ -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;
}
}
+76
View File
@@ -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);
}
+146
View File
@@ -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 [];
}
+8
View File
@@ -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";
+39
View File
@@ -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;
}
+256
View File
@@ -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,
};
}
+215
View File
@@ -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,
};
}
+16
View File
@@ -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),
]); ]);
+66
View File
@@ -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);
};
+34
View File
@@ -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);
}
`,
});
};
+7
View File
@@ -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
View File
@@ -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;
+3 -1
View File
@@ -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
View File
@@ -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;
-42
View File
@@ -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 -1
View File
@@ -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];
+2 -2
View File
@@ -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;
+7 -1
View File
@@ -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;
} }
} }
+259
View File
@@ -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],
},
],
};
}
+1 -1
View File
@@ -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);
}
+1
View File
@@ -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,
+2
View File
@@ -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,
+2 -10
View File
@@ -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)
); );
} }
+2 -2
View File
@@ -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",
+62 -6
View File
@@ -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;
+76
View File
@@ -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();
}
}
}
+55
View File
@@ -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,
};
});
}
+4
View File
@@ -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}
/> />
+123
View File
@@ -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,
);
}
+2 -2
View File
@@ -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",
}); });
+9 -21
View File
@@ -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
View File
@@ -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} />
@@ -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
View File
@@ -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}
+53 -58
View File
@@ -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();
+9 -8
View File
@@ -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}
+20 -75
View File
@@ -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 -1
View File
@@ -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";
+7 -1
View File
@@ -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 />;
} }
+153 -6
View File
@@ -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;
+37 -36
View File
@@ -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(
+10 -36
View File
@@ -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
View File
@@ -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;