merge develop into feat/galerie - resolve model and code conflicts

This commit is contained in:
Tom Boullay
2026-05-29 02:25:46 +02:00
940 changed files with 92419 additions and 37567 deletions
+3 -1
View File
@@ -6,12 +6,14 @@ import {
DEBUG_GRID_SIZE,
DEBUG_GRID_Y,
} from "@/data/debug/debugConfig";
import { useSceneMode } from "@/hooks/debug/useSceneMode";
import { Debug } from "@/utils/debug/Debug";
export function DebugHelpers(): React.JSX.Element | null {
const debug = Debug.getInstance();
const sceneMode = useSceneMode();
if (!debug.active) {
if (!debug.active || sceneMode === "game") {
return null;
}
+2 -2
View File
@@ -6,14 +6,14 @@ interface DocsDocumentProps {
title: string;
meta: string;
content: string;
frContent: string;
frContent?: string;
}
export function DocsDocument({
title,
meta,
content,
frContent,
frContent = content,
}: DocsDocumentProps): React.JSX.Element {
const { language, toggleLanguage } = useDocsLanguage();
const hasAlternateContent = frContent !== content;
+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>
);
};
+223 -5
View File
@@ -8,33 +8,52 @@ import {
Lock,
MousePointer2,
Move3D,
Plus,
Redo2,
RotateCw,
Save,
Trash2,
ScanSearch,
Undo2,
Unlock,
X,
} from "lucide-react";
import { useState } from "react";
import { EditorCinematicManifestPanel } from "@/components/editor/EditorCinematicManifestPanel";
import { EditorDialogueManifestPanel } from "@/components/editor/EditorDialogueManifestPanel";
import { EditorSrtPanel } from "@/components/editor/EditorSrtPanel";
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
import type { MapNode, TransformMode } from "@/types/editor/editor";
import type { Vector3Tuple } from "@/types/three/three";
interface EditorControlsProps {
transformMode: TransformMode;
onTransformModeChange: (mode: TransformMode) => void;
selectedNodeIndex: number | null;
selectedNodeIndexes: number[];
mapNodes: MapNode[];
nodesCount: number;
selectedNodeName: string | null;
selectedNodeScale: Vector3Tuple | null;
lockTerrainSelection: boolean;
onLockTerrainSelectionChange: (locked: boolean) => void;
isSelectionLocked: boolean;
onSelectionLockToggle: () => void;
onClearSelection: () => void;
snapToTerrain: boolean;
onSnapToTerrainToggle: () => void;
onSnapAllToTerrain: () => void;
newNodeName: string;
onNewNodeNameChange: (value: string) => void;
onAddNode: () => void;
onDeleteSelectedNode: () => void;
onSelectedScaleChange: (axis: 0 | 1 | 2, value: number) => void;
undoCount: number;
redoCount: number;
onUndo: () => void;
onRedo: () => void;
cameraActionLabel: string;
onCameraAction: () => void;
onExportJson: () => void;
onSaveToServer?: (() => void | Promise<void>) | undefined;
onPlayerMode?: (() => void) | undefined;
@@ -50,9 +69,10 @@ const TRANSFORM_OPTIONS = [
const EDITOR_SHORTCUTS = [
["Click", "Select object"],
["Shift + Right click", "Toggle multi-selection"],
["T / R / S", "Transform mode"],
["Ctrl Z / Y", "Undo / redo"],
["Esc", "Deselect"],
["Esc / X button", "Clear selection"],
["WASD", "Move when locked"],
] as const;
@@ -83,20 +103,80 @@ 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({
transformMode,
onTransformModeChange,
selectedNodeIndex,
selectedNodeIndexes,
mapNodes,
nodesCount,
selectedNodeName,
selectedNodeScale,
lockTerrainSelection,
onLockTerrainSelectionChange,
isSelectionLocked,
onSelectionLockToggle,
onClearSelection,
snapToTerrain,
onSnapToTerrainToggle,
onSnapAllToTerrain,
newNodeName,
onNewNodeNameChange,
onAddNode,
onDeleteSelectedNode,
onSelectedScaleChange,
undoCount,
redoCount,
onUndo,
onRedo,
cameraActionLabel,
onCameraAction,
onExportJson,
onSaveToServer,
onPlayerMode,
@@ -105,6 +185,10 @@ export function EditorControls({
}: EditorControlsProps): React.JSX.Element {
const viewModeLabel = isPlayerMode ? "View locked" : "Lock view";
const jsonPreview = getJsonPreview(mapNodes, selectedNodeIndex);
const selectedNode =
selectedNodeIndex !== null ? mapNodes[selectedNodeIndex] : null;
const selectionCount = selectedNodeIndexes.length;
const transformValues = getTransformValues(selectedNode ?? null);
return (
<>
@@ -155,7 +239,10 @@ export function EditorControls({
aria-pressed={transformMode === mode}
>
<Icon size={16} aria-hidden="true" />
<span>{label}</span>
<span className="editor-transform-label">
<span>{label}</span>
<small>{transformValues[mode]}</small>
</span>
<kbd>{shortcut}</kbd>
</button>
))}
@@ -181,6 +268,24 @@ export function EditorControls({
<span>{redoCount}</span>
</button>
</div>
<label className="editor-checkbox-row">
<input
type="checkbox"
checked={snapToTerrain}
onChange={onSnapToTerrainToggle}
/>
<span>Snap terrain on move</span>
</label>
<button
type="button"
className="editor-history-button"
onClick={onSnapAllToTerrain}
>
<ScanSearch size={15} aria-hidden="true" />
Snap all to terrain
</button>
</section>
<section
@@ -197,13 +302,25 @@ export function EditorControls({
<Box size={17} aria-hidden="true" />
<div>
<strong>
{selectedNodeName || `Node ${selectedNodeIndex + 1}`}
{selectionCount > 1
? `${selectionCount} selected nodes`
: selectedNodeName || `Node ${selectedNodeIndex + 1}`}
</strong>
<span>
Index {selectedNodeIndex + 1} of {nodesCount}
{selectionCount > 1
? `Primary index ${selectedNodeIndex + 1} of ${nodesCount}`
: `Index ${selectedNodeIndex + 1} of ${nodesCount}`}
</span>
</div>
<div className="editor-selected-actions">
<button
type="button"
onClick={onDeleteSelectedNode}
aria-label="Delete selected node"
title="Delete selected node"
>
<Trash2 size={14} aria-hidden="true" />
</button>
<button
type="button"
onClick={onSelectionLockToggle}
@@ -230,6 +347,19 @@ export function EditorControls({
<X size={14} aria-hidden="true" />
</button>
</div>
{selectedNodeScale ? (
<div className="editor-scale-fields">
{selectedNodeScale.map((value, axis) => (
<EditorScaleField
key={`${axis}:${value}`}
axis={axis as 0 | 1 | 2}
label={["X", "Y", "Z"][axis] ?? "?"}
value={value}
onCommit={onSelectedScaleChange}
/>
))}
</div>
) : null}
</div>
) : (
<div className="editor-no-selection">
@@ -239,6 +369,32 @@ export function EditorControls({
)}
</section>
<section
className="editor-control-section"
aria-labelledby="add-node-heading"
>
<div className="editor-section-heading">
<h3 id="add-node-heading">Add Node</h3>
</div>
<div className="editor-add-node-row">
<input
type="text"
value={newNodeName}
onChange={(event) => onNewNodeNameChange(event.target.value)}
placeholder="model-folder-name"
/>
<button
type="button"
className="editor-action-button"
onClick={onAddNode}
>
<Plus size={16} aria-hidden="true" />
Add cube
</button>
</div>
</section>
<section
className="editor-control-section"
aria-labelledby="view-heading"
@@ -257,6 +413,25 @@ export function EditorControls({
{viewModeLabel}
</button>
)}
<button className="editor-action-button" onClick={onCameraAction}>
<ScanSearch size={16} aria-hidden="true" />
{cameraActionLabel}
</button>
<label className="editor-checkbox-row">
<input
type="checkbox"
checked={lockTerrainSelection}
onChange={(event) =>
onLockTerrainSelectionChange(event.currentTarget.checked)
}
/>
<span>
<strong>Lock terrain</strong>
<small>Keep terrain visible but ignore terrain clicks</small>
</span>
</label>
</section>
<section
@@ -329,6 +504,42 @@ export function EditorControls({
);
}
function formatNumber(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(2);
}
function formatVector(values: readonly [number, number, number]): string {
return `X ${formatNumber(values[0])} · Y ${formatNumber(values[1])} · Z ${formatNumber(values[2])}`;
}
function formatRotation(values: readonly [number, number, number]): string {
const degrees = values.map((value) => (value * 180) / Math.PI) as [
number,
number,
number,
];
return `X ${formatNumber(degrees[0])}° · Y ${formatNumber(degrees[1])}° · Z ${formatNumber(degrees[2])}°`;
}
function getTransformValues(
node: MapNode | null,
): Record<TransformMode, string> {
if (!node) {
return {
translate: "No selection",
rotate: "No selection",
scale: "No selection",
};
}
return {
translate: formatVector(node.position),
rotate: formatRotation(node.rotation),
scale: formatVector(node.scale),
};
}
interface JsonPreviewLine {
number: number;
content: string;
@@ -378,7 +589,14 @@ function formatMapNodesWithRanges(mapNodes: MapNode[]): {
const ranges: Array<{ start: number; end: number }> = [];
mapNodes.forEach((node, index) => {
const objectLines = JSON.stringify(node, null, 2)
const serializableNode = {
name: node.name,
position: node.position,
rotation: node.rotation,
scale: node.scale,
type: node.type,
};
const objectLines = JSON.stringify(serializableNode, null, 2)
.split("\n")
.map((line) => ` ${line}`);
@@ -445,11 +445,14 @@ export function EditorDialogueManifestPanel(): React.JSX.Element {
Voix
<select
value={selectedDialogue.voice}
onChange={(event) =>
updateSelectedDialogue({
voice: event.target.value as DialogueVoiceId,
})
}
onChange={(event) => {
const selectedVoice = voices.find(
(voice) => voice.id === event.target.value,
);
if (!selectedVoice) return;
updateSelectedDialogue({ voice: selectedVoice.id });
}}
>
{voices.map((voice) => (
<option key={voice.id} value={voice.id}>
+44 -28
View File
@@ -1,14 +1,19 @@
import { useEffect, useRef, useState } from "react";
import { Download, RefreshCw, Save } from "lucide-react";
import type { SubtitleLanguage } from "@/managers/stores/useSettingsStore";
import type { SubtitleLanguage } from "@/types/settings/settings";
import type {
DialogueDefinition,
DialogueManifest,
DialogueSpeaker,
DialogueVoiceId,
} from "@/types/dialogues/dialogues";
import { logger } from "@/utils/core/Logger";
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
import { parseSrt } from "@/utils/subtitles/parseSrt";
import {
parseSrt,
parseSrtTime,
parseSrtWithDiagnostics,
} from "@/utils/subtitles/parseSrt";
interface SrtVoiceOption {
id: DialogueVoiceId;
@@ -88,21 +93,6 @@ function formatPreviewTime(totalSeconds: number): string {
return `${Math.max(0, totalSeconds).toFixed(1)}s`;
}
function parseSrtTime(value: string): number | null {
const match = value.match(/^(\d{2}):(\d{2}):(\d{2}),(\d{3})$/);
if (!match) return null;
const [, hours, minutes, seconds, milliseconds] = match;
if (!hours || !minutes || !seconds || !milliseconds) return null;
return (
Number(hours) * 3600 +
Number(minutes) * 60 +
Number(seconds) +
Number(milliseconds) / 1000
);
}
function padTime(value: number): string {
return value.toString().padStart(2, "0");
}
@@ -120,7 +110,7 @@ function getSrtDiagnostic(
.trim()
.split(/\n{2,}/)
.filter(Boolean);
const cues = parseSrt(content);
const { cues, diagnostics } = parseSrtWithDiagnostics(content);
const errors: string[] = [];
const indexes = new Set<number>();
@@ -164,6 +154,10 @@ function getSrtDiagnostic(
);
}
for (const diagnostic of diagnostics) {
errors.push(`Bloc ${diagnostic.blockIndex + 1}: ${diagnostic.reason}.`);
}
const cueIndexes = new Set(cues.map((cue) => cue.index));
const missingCueIndexes = expectedCueIndexes.filter(
(cueIndex) => !cueIndexes.has(cueIndex),
@@ -470,8 +464,14 @@ export function EditorSrtPanel(): React.JSX.Element {
.then((loadedManifest) => {
if (mounted) setManifest(loadedManifest);
})
.catch(() => {
if (mounted) setManifest(null);
.catch((error) => {
if (!mounted) return;
setManifest(null);
setStatus("Erreur de chargement du manifeste dialogues");
logger.error("EditorSrt", "Failed to load dialogue manifest", {
error: error instanceof Error ? error : String(error),
});
});
return () => {
@@ -496,10 +496,16 @@ export function EditorSrtPanel(): React.JSX.Element {
setContent(await response.text());
setStatus(`Charge depuis ${srtPath}`);
})
.catch(() => {
.catch((error: unknown) => {
if (!mounted) return;
setContent(srtTemplate);
setStatus("Erreur de chargement, template local cree");
setStatus(
`Erreur de chargement, template local cree: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
);
logger.warn("EditorSrt", "Falling back to local SRT template", {
srtPath,
error: error instanceof Error ? error : String(error),
});
});
return () => {
@@ -519,9 +525,14 @@ export function EditorSrtPanel(): React.JSX.Element {
Voix
<select
value={voice}
onChange={(event) =>
setVoice(event.target.value as DialogueVoiceId)
}
onChange={(event) => {
const selectedVoice = SRT_VOICES.find(
(item) => item.id === event.target.value,
);
if (selectedVoice) {
setVoice(selectedVoice.id);
}
}}
>
{SRT_VOICES.map((item) => (
<option key={item.id} value={item.id}>
@@ -535,9 +546,14 @@ export function EditorSrtPanel(): React.JSX.Element {
Langue
<select
value={language}
onChange={(event) =>
setLanguage(event.target.value as SubtitleLanguage)
}
onChange={(event) => {
const selectedLanguage = SRT_LANGUAGES.find(
(item) => item === event.target.value,
);
if (selectedLanguage) {
setLanguage(selectedLanguage);
}
}}
>
{SRT_LANGUAGES.map((item) => (
<option key={item} value={item}>
+386 -53
View File
@@ -1,23 +1,47 @@
import { useRef, useEffect, useState } from "react";
import { Grid, TransformControls } from "@react-three/drei";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
Suspense,
} from "react";
import { TransformControls } from "@react-three/drei";
import type { ThreeEvent } from "@react-three/fiber";
import * as THREE from "three";
import { TerrainModel } from "@/components/three/world/TerrainModel";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import {
getObjectBottomOffset,
useTerrainHeightSampler,
} from "@/hooks/three/useTerrainHeight";
import type { SceneData, MapNode, TransformMode } from "@/types/editor/editor";
import {
isEditorVisibleMapNode,
getTerrainMapNode,
} from "@/utils/map/mapRuntimeClassification";
import { getMapModelScaleMultiplier } from "@/data/world/mapInstancingConfig";
import { getVegetationModelScaleMultiplier } from "@/data/world/vegetationConfig";
interface EditorMapProps {
sceneData: SceneData;
selectedNodeIndex: number | null;
selectedNodeIndexes: number[];
onSelectNode: (index: number | null) => void;
onToggleNodeSelection: (index: number) => void;
isSelectionLocked: boolean;
hoveredNodeIndex: number | null;
onHoverNode: (index: number | null) => void;
transformMode: TransformMode;
snapToTerrain: boolean;
lockTerrainSelection: boolean;
onTransformStart: () => void;
onTransformEnd: () => void;
onNodeTransform: (nodeIndex: number, transform: MapNode) => void;
snapAllToTerrainRequest: number;
onSnapAllToTerrain: (mapNodes: MapNode[]) => void;
}
type EditorNodeObjectRef = React.RefObject<Map<number, THREE.Object3D>>;
@@ -29,16 +53,81 @@ interface EditorNodeCommonProps {
isHovered: boolean;
objectsMapRef: EditorNodeObjectRef;
onSelectNode: (index: number | null) => void;
onToggleNodeSelection: (index: number) => void;
isSelectionLocked: boolean;
onHoverNode: (index: number | null) => void;
}
interface EditorNodePointerHandlers {
onClick: (event: ThreeEvent<MouseEvent>) => void;
onContextMenu: (event: ThreeEvent<MouseEvent>) => void;
onPointerEnter: (event: ThreeEvent<PointerEvent>) => void;
onPointerLeave: (event: ThreeEvent<PointerEvent>) => void;
}
interface TransformSnapshot {
groupMatrix: THREE.Matrix4;
objects: Map<number, THREE.Matrix4>;
}
const TEMP_BOX = new THREE.Box3();
const TEMP_CENTER = new THREE.Vector3();
const TEMP_DELTA_MATRIX = new THREE.Matrix4();
const TEMP_INVERSE_GROUP_MATRIX = new THREE.Matrix4();
const TEMP_POSITION = new THREE.Vector3();
const TEMP_QUATERNION = new THREE.Quaternion();
const TEMP_SCALE = new THREE.Vector3();
function isOriginPosition(position: MapNode["position"]): boolean {
return position.every((value) => Math.abs(value) < 0.0001);
}
function isSnapAllCandidate(node: MapNode): boolean {
return (
isEditorVisibleMapNode(node) &&
node.name !== "terrain" &&
!isOriginPosition(node.position)
);
}
function shouldRenderEditorNode(
node: MapNode,
selectedNodeName: string | null,
): boolean {
if (!isEditorVisibleMapNode(node)) return false;
return selectedNodeName === null || node.name === selectedNodeName;
}
function getEditorModelVisualScaleMultiplier(name: string): number {
return (
getMapModelScaleMultiplier(name) * getVegetationModelScaleMultiplier(name)
);
}
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 {
object.position.set(...node.position);
object.rotation.set(...node.rotation);
@@ -110,6 +199,7 @@ function getNodeHighlightColor(
function createEditorNodePointerHandlers(
index: number,
onSelectNode: (index: number | null) => void,
onToggleNodeSelection: (index: number) => void,
isSelectionLocked: boolean,
onHoverNode: (index: number | null) => void,
): EditorNodePointerHandlers {
@@ -119,6 +209,12 @@ function createEditorNodePointerHandlers(
if (isSelectionLocked) return;
onSelectNode(index);
},
onContextMenu: (event) => {
event.stopPropagation();
event.nativeEvent.preventDefault();
if (!event.nativeEvent.shiftKey || isSelectionLocked) return;
onToggleNodeSelection(index);
},
onPointerEnter: (event) => {
event.stopPropagation();
onHoverNode(index);
@@ -133,93 +229,272 @@ function createEditorNodePointerHandlers(
export function EditorMap({
sceneData,
selectedNodeIndex,
selectedNodeIndexes,
onSelectNode,
onToggleNodeSelection,
isSelectionLocked,
hoveredNodeIndex,
onHoverNode,
transformMode,
snapToTerrain,
lockTerrainSelection,
onTransformStart,
onTransformEnd,
onNodeTransform,
snapAllToTerrainRequest,
onSnapAllToTerrain,
}: EditorMapProps): React.JSX.Element {
const objectsMapRef = useRef<Map<number, THREE.Object3D>>(new Map());
const transformGroupRef = useRef<THREE.Group>(null);
const transformSnapshotRef = useRef<TransformSnapshot | null>(null);
const terrainHeight = useTerrainHeightSampler();
const lastSnapAllToTerrainRequestRef = useRef(0);
const handleTransformMouseDown = () => {
onTransformStart();
};
const selectedIndexSet = new Set(selectedNodeIndexes);
const isMultiSelection = selectedNodeIndexes.length > 1;
const selectedNodeName =
selectedNodeIndex !== null
? (sceneData.mapNodes[selectedNodeIndex]?.name ?? null)
: null;
const getTransformObject = useCallback(() => {
if (isMultiSelection) {
return transformGroupRef.current;
}
if (selectedNodeIndex !== null) {
return objectsMapRef.current.get(selectedNodeIndex) ?? null;
}
return null;
}, [isMultiSelection, selectedNodeIndex]);
const prepareTransformGroup = useCallback(() => {
if (!isMultiSelection || !transformGroupRef.current) return;
const selectedObjects = selectedNodeIndexes
.map((index) => objectsMapRef.current.get(index))
.filter((object): object is THREE.Object3D => Boolean(object));
if (selectedObjects.length === 0) return;
TEMP_BOX.makeEmpty();
for (const object of selectedObjects) {
object.updateWorldMatrix(true, false);
TEMP_BOX.expandByPoint(object.getWorldPosition(TEMP_CENTER));
}
TEMP_BOX.getCenter(TEMP_CENTER);
transformGroupRef.current.position.copy(TEMP_CENTER);
transformGroupRef.current.rotation.set(0, 0, 0);
transformGroupRef.current.scale.set(1, 1, 1);
transformGroupRef.current.updateMatrixWorld(true);
}, [isMultiSelection, selectedNodeIndexes]);
const createTransformSnapshot = useCallback((): TransformSnapshot | null => {
const transformGroup = transformGroupRef.current;
if (!isMultiSelection || !transformGroup) return null;
const objects = new Map<number, THREE.Matrix4>();
for (const index of selectedNodeIndexes) {
const object = objectsMapRef.current.get(index);
if (!object) continue;
object.updateMatrixWorld(true);
objects.set(index, object.matrix.clone());
}
transformGroup.updateMatrixWorld(true);
return {
groupMatrix: transformGroup.matrix.clone(),
objects,
};
}, [isMultiSelection, selectedNodeIndexes]);
const syncSelectedObjectTransform = () => {
if (isMultiSelection) {
const transformGroup = transformGroupRef.current;
const snapshot = transformSnapshotRef.current;
if (!transformGroup || !snapshot) return;
transformGroup.updateMatrix();
TEMP_INVERSE_GROUP_MATRIX.copy(snapshot.groupMatrix).invert();
TEMP_DELTA_MATRIX.multiplyMatrices(
transformGroup.matrix,
TEMP_INVERSE_GROUP_MATRIX,
);
for (const [index, startMatrix] of snapshot.objects) {
const obj = objectsMapRef.current.get(index);
const node = sceneData.mapNodes[index];
if (!obj || !node) continue;
const nextMatrix = TEMP_DELTA_MATRIX.clone().multiply(startMatrix);
nextMatrix.decompose(TEMP_POSITION, TEMP_QUATERNION, TEMP_SCALE);
obj.position.copy(TEMP_POSITION);
obj.quaternion.copy(TEMP_QUATERNION);
obj.scale.copy(TEMP_SCALE);
const terrainY = snapToTerrain
? terrainHeight.getHeight(obj.position.x, obj.position.z)
: null;
if (terrainY !== null && transformMode === "translate") {
obj.position.y = terrainY;
}
onNodeTransform(index, {
...node,
position: [obj.position.x, obj.position.y, obj.position.z],
rotation: [obj.rotation.x, obj.rotation.y, obj.rotation.z],
scale: [obj.scale.x, obj.scale.y, obj.scale.z],
});
}
return;
}
const handleTransformMouseUp = () => {
if (selectedNodeIndex !== null) {
const obj = objectsMapRef.current.get(selectedNodeIndex);
if (!obj) return;
const node = sceneData.mapNodes[selectedNodeIndex];
if (node) {
const terrainY = snapToTerrain
? terrainHeight.getHeight(obj.position.x, obj.position.z)
: null;
if (terrainY !== null && transformMode === "translate") {
obj.position.y = terrainY;
}
const updatedNode: MapNode = {
...node,
position: [obj.position.x, obj.position.y, obj.position.z],
position: [
obj.position.x,
terrainY !== null && transformMode === "translate"
? terrainY
: obj.position.y,
obj.position.z,
],
rotation: [obj.rotation.x, obj.rotation.y, obj.rotation.z],
scale: [obj.scale.x, obj.scale.y, obj.scale.z],
};
onNodeTransform(selectedNodeIndex, updatedNode);
}
}
};
const handleTransformMouseDown = () => {
prepareTransformGroup();
transformSnapshotRef.current = createTransformSnapshot();
onTransformStart();
};
const handleTransformMouseUp = () => {
syncSelectedObjectTransform();
transformSnapshotRef.current = null;
prepareTransformGroup();
onTransformEnd();
};
const [selectedObject, setSelectedObject] = useState<THREE.Object3D | null>(
null,
);
const terrainNode = getTerrainMapNode(sceneData.mapNodes);
const terrainNodeIndex = terrainNode
? sceneData.mapNodes.indexOf(terrainNode)
: -1;
useLayoutEffect(() => {
prepareTransformGroup();
}, [prepareTransformGroup]);
useEffect(() => {
if (selectedNodeIndex !== null) {
const obj = objectsMapRef.current.get(selectedNodeIndex);
setSelectedObject(obj || null);
} else {
setSelectedObject(null);
if (
snapAllToTerrainRequest === 0 ||
snapAllToTerrainRequest === lastSnapAllToTerrainRequestRef.current
) {
return;
}
}, [selectedNodeIndex]);
lastSnapAllToTerrainRequestRef.current = snapAllToTerrainRequest;
const snappedNodes = sceneData.mapNodes.map((node) => {
if (!isSnapAllCandidate(node)) return node;
const [x, y, z] = node.position;
const terrainY = terrainHeight.getHeight(x, z);
if (terrainY === null || Math.abs(terrainY - y) < 0.0001) return node;
return {
...node,
position: [x, terrainY, z] satisfies [number, number, number],
};
});
onSnapAllToTerrain(snappedNodes);
}, [
onSnapAllToTerrain,
sceneData.mapNodes,
snapAllToTerrainRequest,
terrainHeight,
]);
// TransformControls needs the current Three object; editor refs are managed outside React rendering.
// eslint-disable-next-line react-hooks/refs
const selectedObject = getTransformObject();
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
onClick={(event: ThreeEvent<MouseEvent>) => {
event.stopPropagation();
if (isSelectionLocked) return;
onSelectNode(null);
}}
>
<group>
{terrainNode ? (
<Suspense fallback={null}>
<EditorTerrainNode
index={terrainNodeIndex}
node={terrainNode}
isSelected={selectedIndexSet.has(terrainNodeIndex)}
isHovered={hoveredNodeIndex === terrainNodeIndex}
lockTerrainSelection={lockTerrainSelection}
objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode}
onToggleNodeSelection={onToggleNodeSelection}
isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode}
/>
</Suspense>
) : null}
{sceneData.mapNodes.map((node, index) => {
if (!shouldRenderEditorNode(node, selectedNodeName)) {
return null;
}
const modelUrl = sceneData.models.get(node.name);
if (modelUrl) {
return (
<EditorModelNode
<Suspense
key={index}
index={index}
node={node}
modelUrl={modelUrl}
isSelected={selectedNodeIndex === index}
isHovered={hoveredNodeIndex === index}
objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode}
isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode}
/>
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}
node={node}
modelUrl={modelUrl}
isSelected={selectedIndexSet.has(index)}
isHovered={hoveredNodeIndex === index}
objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode}
onToggleNodeSelection={onToggleNodeSelection}
isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode}
/>
</Suspense>
);
} else {
return (
@@ -227,10 +502,11 @@ export function EditorMap({
key={index}
index={index}
node={node}
isSelected={selectedNodeIndex === index}
isSelected={selectedIndexSet.has(index)}
isHovered={hoveredNodeIndex === index}
objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode}
onToggleNodeSelection={onToggleNodeSelection}
isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode}
/>
@@ -239,12 +515,15 @@ export function EditorMap({
})}
</group>
<group ref={transformGroupRef} />
{selectedObject && (
<TransformControls
object={selectedObject}
mode={transformMode}
onMouseDown={handleTransformMouseDown}
onMouseUp={handleTransformMouseUp}
onObjectChange={syncSelectedObjectTransform}
/>
)}
</>
@@ -259,6 +538,7 @@ function EditorModelNode({
isHovered,
objectsMapRef,
onSelectNode,
onToggleNodeSelection,
isSelectionLocked,
onHoverNode,
}: EditorNodeCommonProps & {
@@ -275,9 +555,22 @@ function EditorModelNode({
scale: node.scale,
});
const sceneInstance = useClonedObject(scene);
const terrainHeight = useTerrainHeightSampler();
const visualScaleMultiplier = getEditorModelVisualScaleMultiplier(node.name);
const visualYOffset = useMemo(
() =>
getEditorModelVisualYOffset(
sceneInstance,
node,
terrainHeight,
visualScaleMultiplier,
),
[node, sceneInstance, terrainHeight, visualScaleMultiplier],
);
const pointerHandlers = createEditorNodePointerHandlers(
index,
onSelectNode,
onToggleNodeSelection,
isSelectionLocked,
onHoverNode,
);
@@ -335,14 +628,52 @@ function EditorModelNode({
}, []);
return (
<primitive
<group
ref={groupRef}
object={sceneInstance}
position={node.position}
rotation={node.rotation}
scale={node.scale}
{...pointerHandlers}
/>
>
<primitive
object={sceneInstance}
position={[0, visualYOffset, 0]}
scale={visualScaleMultiplier}
/>
</group>
);
}
function EditorTerrainNode({
index,
node,
lockTerrainSelection,
objectsMapRef,
onSelectNode,
onToggleNodeSelection,
isSelectionLocked,
onHoverNode,
}: EditorNodeCommonProps & { lockTerrainSelection: boolean }) {
const groupRef = useRef<THREE.Group>(null);
const pointerHandlers = createEditorNodePointerHandlers(
index,
onSelectNode,
onToggleNodeSelection,
isSelectionLocked,
onHoverNode,
);
useRegisteredEditorNode(groupRef, index, node, objectsMapRef);
return (
<group
ref={groupRef}
position={node.position}
rotation={node.rotation}
scale={node.scale}
{...(lockTerrainSelection ? {} : pointerHandlers)}
>
<TerrainModel receiveShadow visible />
</group>
);
}
@@ -353,6 +684,7 @@ function EditorFallbackNode({
isHovered,
objectsMapRef,
onSelectNode,
onToggleNodeSelection,
isSelectionLocked,
onHoverNode,
}: EditorNodeCommonProps) {
@@ -360,6 +692,7 @@ function EditorFallbackNode({
const pointerHandlers = createEditorNodePointerHandlers(
index,
onSelectNode,
onToggleNodeSelection,
isSelectionLocked,
onHoverNode,
);
+148 -18
View File
@@ -1,32 +1,54 @@
import { useEffect, useRef } from "react";
import { OrbitControls } from "@react-three/drei";
import { Suspense, useCallback, useEffect, useRef } from "react";
import { Grid, OrbitControls } from "@react-three/drei";
import { useThree } from "@react-three/fiber";
import gsap from "gsap";
import * as THREE from "three";
import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
import { EditorMap } from "@/components/editor/scene/EditorMap";
import { FlyController } from "@/controls/editor/FlyController";
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
import type { MapNode, TransformMode, SceneData } from "@/types/editor/editor";
import type {
EditorCinematicPreviewRequest,
MapNode,
TransformMode,
SceneData,
} from "@/types/editor/editor";
export interface EditorCinematicPreviewRequest {
id: string;
cinematic: CinematicDefinition;
const EDITOR_CAMERA_HOME_POSITION = new THREE.Vector3(0, 50, 100);
const EDITOR_CAMERA_HOME_TARGET = new THREE.Vector3(0, 0, 0);
function isEditableShortcutTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
return (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
target.isContentEditable
);
}
interface EditorSceneProps {
sceneData: SceneData;
selectedNodeIndex: number | null;
selectedNodeIndexes: number[];
onSelectNode: (index: number | null) => void;
onToggleNodeSelection: (index: number) => void;
isSelectionLocked: boolean;
hoveredNodeIndex: number | null;
onHoverNode: (index: number | null) => void;
transformMode: TransformMode;
snapToTerrain: boolean;
lockTerrainSelection: boolean;
onTransformModeChange: (mode: TransformMode) => void;
onTransformStart: () => void;
onTransformEnd: () => void;
onNodeTransform: (nodeIndex: number, transform: MapNode) => void;
snapAllToTerrainRequest: number;
onSnapAllToTerrain: (mapNodes: MapNode[]) => void;
onUndo: () => void;
onRedo: () => void;
resetCameraRequest: number;
focusSelectedCameraRequest: number;
isPlayerMode?: boolean;
cinematicPreviewRequest?: EditorCinematicPreviewRequest | null;
onCinematicPreviewComplete?: (() => void) | undefined;
@@ -35,25 +57,109 @@ interface EditorSceneProps {
export function EditorScene({
sceneData,
selectedNodeIndex,
selectedNodeIndexes,
onSelectNode,
onToggleNodeSelection,
isSelectionLocked,
hoveredNodeIndex,
onHoverNode,
transformMode,
snapToTerrain,
lockTerrainSelection,
onTransformModeChange,
onTransformStart,
onTransformEnd,
onNodeTransform,
snapAllToTerrainRequest,
onSnapAllToTerrain,
onUndo,
onRedo,
resetCameraRequest,
focusSelectedCameraRequest,
isPlayerMode = false,
cinematicPreviewRequest = null,
onCinematicPreviewComplete,
}: EditorSceneProps): React.JSX.Element {
const isCinematicPreviewing = cinematicPreviewRequest !== null;
const camera = useThree((state) => state.camera);
const orbitControlsRef = useRef<OrbitControlsImpl | null>(null);
const previousSelectedNodeIndexRef = useRef<number | null>(null);
const focusCameraOnNode = useCallback(
(node: MapNode): void => {
const controls = orbitControlsRef.current;
const target = new THREE.Vector3(...node.position);
const currentTarget = controls?.target ?? EDITOR_CAMERA_HOME_TARGET;
const cameraOffset = camera.position.clone().sub(currentTarget);
camera.position.copy(target).add(cameraOffset);
camera.lookAt(target);
controls?.target.copy(target);
controls?.update();
},
[camera],
);
useEffect(() => {
if (selectedNodeIndex === previousSelectedNodeIndexRef.current) return;
previousSelectedNodeIndexRef.current = selectedNodeIndex;
if (selectedNodeIndex === null || isPlayerMode || isCinematicPreviewing) {
return;
}
const selectedNode = sceneData.mapNodes[selectedNodeIndex];
if (!selectedNode) return;
focusCameraOnNode(selectedNode);
}, [
camera,
isCinematicPreviewing,
isPlayerMode,
focusCameraOnNode,
sceneData,
selectedNodeIndex,
]);
useEffect(() => {
if (
focusSelectedCameraRequest === 0 ||
selectedNodeIndex === null ||
isPlayerMode ||
isCinematicPreviewing
) {
return;
}
const selectedNode = sceneData.mapNodes[selectedNodeIndex];
if (!selectedNode) return;
focusCameraOnNode(selectedNode);
}, [
focusSelectedCameraRequest,
focusCameraOnNode,
isCinematicPreviewing,
isPlayerMode,
sceneData,
selectedNodeIndex,
]);
useEffect(() => {
if (resetCameraRequest === 0 || isPlayerMode || isCinematicPreviewing) {
return;
}
const controls = orbitControlsRef.current;
camera.position.copy(EDITOR_CAMERA_HOME_POSITION);
camera.lookAt(EDITOR_CAMERA_HOME_TARGET);
controls?.target.copy(EDITOR_CAMERA_HOME_TARGET);
controls?.update();
}, [camera, isCinematicPreviewing, isPlayerMode, resetCameraRequest]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (isEditableShortcutTarget(e.target)) return;
if (e.ctrlKey || e.metaKey) {
if (e.key === "z" || e.key === "Z") {
e.preventDefault();
@@ -107,6 +213,7 @@ export function EditorScene({
<FlyController disabled={isCinematicPreviewing} />
) : (
<OrbitControls
ref={orbitControlsRef}
enabled={!isCinematicPreviewing}
enableDamping
dampingFactor={0.05}
@@ -118,18 +225,41 @@ export function EditorScene({
/>
)}
<EditorMap
sceneData={sceneData}
selectedNodeIndex={selectedNodeIndex}
onSelectNode={onSelectNode}
isSelectionLocked={isSelectionLocked}
hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={onHoverNode}
transformMode={transformMode}
onTransformStart={onTransformStart}
onTransformEnd={onTransformEnd}
onNodeTransform={onNodeTransform}
<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
sceneData={sceneData}
selectedNodeIndex={selectedNodeIndex}
selectedNodeIndexes={selectedNodeIndexes}
onSelectNode={onSelectNode}
onToggleNodeSelection={onToggleNodeSelection}
isSelectionLocked={isSelectionLocked}
hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={onHoverNode}
transformMode={transformMode}
snapToTerrain={snapToTerrain}
lockTerrainSelection={lockTerrainSelection}
onTransformStart={onTransformStart}
onTransformEnd={onTransformEnd}
onNodeTransform={onNodeTransform}
snapAllToTerrainRequest={snapAllToTerrainRequest}
onSnapAllToTerrain={onSnapAllToTerrain}
/>
</Suspense>
<ambientLight intensity={0.6} />
<directionalLight position={[10, 20, 10]} intensity={1.5} castShadow />
+9 -1
View File
@@ -8,6 +8,7 @@ export function GameFlow(): null {
const setStep = useGameStore((state) => state.setIntroStep);
const setActivityCity = useGameStore((state) => state.setActivityCity);
const setCanMove = useGameStore((state) => state.setCanMove);
const completeIntro = useGameStore((state) => state.completeIntro);
const hasInitialized = useRef(false);
useEffect(() => {
@@ -55,10 +56,17 @@ export function GameFlow(): null {
if (step === "manipulation") {
setCanMove(false);
const timeoutId = window.setTimeout(() => {
completeIntro();
}, 1000);
return () => {
window.clearTimeout(timeoutId);
};
}
return undefined;
}, [step, setStep, setActivityCity, setCanMove]);
}, [completeIntro, step, setStep, setActivityCity, setCanMove]);
return null;
}
+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>
);
}
@@ -5,7 +5,7 @@ import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
import { REPAIR_CASE_ANIMATION_DURATION } from "@/data/gameplay/repairCaseConfig";
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
import type { RepairMissionConfig } from "@/types/gameplay/repairMission";
interface RepairCompletionStepProps {
config: RepairMissionConfig;
+8 -10
View File
@@ -7,21 +7,18 @@ import { RepairInspectionObject } from "@/components/three/gameplay/RepairInspec
import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase";
import { RepairRepairingStep } from "@/components/three/gameplay/RepairRepairingStep";
import { RepairReassemblyStep } from "@/components/three/gameplay/RepairReassemblyStep";
import {
RepairScanSequence,
type RepairScannedBrokenPart,
} from "@/components/three/gameplay/RepairScanSequence";
import { RepairScanSequence } from "@/components/three/gameplay/RepairScanSequence";
import { REPAIR_CASE_MODEL_PATH } from "@/data/gameplay/repairCaseConfig";
import { REPAIR_FRAGMENTATION_SEQUENCE_SECONDS } from "@/data/gameplay/repairGameConfig";
import {
REPAIR_MISSIONS,
type RepairMissionConfig,
} from "@/data/gameplay/repairMissions";
import { REPAIR_MISSIONS } from "@/data/gameplay/repairMissions";
import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput";
import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep";
import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight";
import type {
MissionStep,
RepairMissionConfig,
RepairMissionId,
RepairScannedBrokenPart,
} from "@/types/gameplay/repairMission";
import { useGameStore } from "@/managers/stores/useGameStore";
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
@@ -70,6 +67,7 @@ export function RepairGame({
readonly RepairScannedBrokenPart[]
>([]);
const parsedScale = toVector3Scale(scale);
const snappedPosition = useTerrainSnappedPosition(position);
const readyForFragmentation = step === "inspected";
useRepairFragmentationInput({
@@ -109,7 +107,7 @@ export function RepairGame({
if (step === "locked") return null;
return (
<group position={position} rotation={rotation} scale={parsedScale}>
<group position={snappedPosition} rotation={rotation} scale={parsedScale}>
<Suspense fallback={null}>
<RepairMissionAssetPreloader config={config} />
</Suspense>
@@ -117,7 +115,7 @@ export function RepairGame({
{step === "waiting" ? (
<RepairInspectionObject
config={config}
worldPosition={position}
worldPosition={snappedPosition}
onInspect={() => setMissionStep(mission, "inspected")}
/>
) : null}
@@ -2,7 +2,7 @@ import { InteractableObject } from "@/components/three/interaction/InteractableO
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
import type { RepairMissionConfig } from "@/types/gameplay/repairMission";
import type { Vector3Tuple } from "@/types/three/three";
interface RepairInspectionObjectProps {
@@ -10,7 +10,7 @@ import {
REPAIR_CASE_MODEL_PATH,
} from "@/data/gameplay/repairCaseConfig";
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
import type { RepairMissionConfig } from "@/types/gameplay/repairMission";
import type { Vector3Tuple } from "@/types/three/three";
interface RepairMissionCaseProps {
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { RepairCompletionParticles } from "@/components/three/gameplay/RepairCompletionParticles";
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
import { REPAIR_REASSEMBLY_SECONDS } from "@/data/gameplay/repairGameConfig";
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
import type { RepairMissionConfig } from "@/types/gameplay/repairMission";
interface RepairReassemblyStepProps {
config: RepairMissionConfig;
@@ -3,7 +3,6 @@ import * as THREE from "three";
import type { RepairCasePlaceholder } from "@/components/three/gameplay/RepairCaseModel";
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
import type { RepairScannedBrokenPart } from "@/components/three/gameplay/RepairScanSequence";
import { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
import {
@@ -15,7 +14,9 @@ import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
import type {
RepairMissionConfig,
RepairMissionPartConfig,
} from "@/data/gameplay/repairMissions";
RepairScannedBrokenPart,
} from "@/types/gameplay/repairMission";
import { logger } from "@/utils/core/Logger";
import type { Vector3Tuple } from "@/types/three/three";
const INSTALL_TARGET_POSITION: Vector3Tuple = [0, 0.8, 0];
@@ -34,6 +35,7 @@ const REPAIR_INSTALL_RADIUS = 1.1;
const VALID_PART_COLOR = "#22c55e";
const INVALID_PART_COLOR = "#ef4444";
const STORED_BROKEN_PART_COLOR = "#38bdf8";
let hasWarnedMissingPlaceholders = false;
interface RepairRepairingStepProps {
brokenParts: readonly RepairScannedBrokenPart[];
@@ -400,6 +402,14 @@ function getPlaceholderTargets(
return placeholders;
}
if (!hasWarnedMissingPlaceholders) {
hasWarnedMissingPlaceholders = true;
logger.warn(
"RepairGame",
"Repair case placeholders missing, using fallback slots",
);
}
return FALLBACK_PLACEHOLDER_OFFSETS.map(
(offset, index): RepairCasePlaceholder => ({
name: `placeholder_${index + 1}`,
@@ -416,12 +426,12 @@ function getBrokenPartTargetPositions(
part: RepairScannedBrokenPart,
placeholderTargets: readonly RepairCasePlaceholder[],
): readonly Vector3Tuple[] {
if (!part.placeholderName) {
if (!part.caseSlotName) {
return placeholderTargets.map((placeholder) => placeholder.position);
}
const matchingPlaceholder = placeholderTargets.find(
(placeholder) => placeholder.name === part.placeholderName,
(placeholder) => placeholder.name === part.caseSlotName,
);
return matchingPlaceholder
@@ -475,6 +485,6 @@ function getBrokenPartsToDeposit(
id: part.id,
label: part.label,
modelPath: part.modelPath ?? config.modelPath,
...(part.placeholderName ? { placeholderName: part.placeholderName } : {}),
...(part.caseSlotName ? { caseSlotName: part.caseSlotName } : {}),
}));
}
@@ -8,7 +8,9 @@ import { REPAIR_SCAN_PART_SECONDS } from "@/data/gameplay/repairGameConfig";
import type {
RepairMissionConfig,
RepairMissionPartConfig,
} from "@/data/gameplay/repairMissions";
RepairScannedBrokenPart,
} from "@/types/gameplay/repairMission";
import { logger } from "@/utils/core/Logger";
import type { ExplodedPart } from "@/utils/three/ExplodedModel";
interface RepairScanSequenceProps {
@@ -16,13 +18,13 @@ interface RepairScanSequenceProps {
onComplete: (brokenParts: readonly RepairScannedBrokenPart[]) => void;
}
export interface RepairScannedBrokenPart {
id: string;
label: string;
modelPath: string;
placeholderName?: string;
interface RepairBrokenPartMatch {
config: RepairMissionPartConfig;
partIndex: number;
}
const warnedMissingScanParts = new Set<string>();
export function RepairScanSequence({
config,
onComplete,
@@ -31,9 +33,9 @@ export function RepairScanSequence({
const [activePartIndex, setActivePartIndex] = useState(0);
const activePart = parts[activePartIndex];
const scanPartSeconds = config.scanPartSeconds ?? REPAIR_SCAN_PART_SECONDS;
const brokenPartIndexes = getBrokenPartIndexes(parts, config.brokenParts);
const visibleBrokenPartIndexes = brokenPartIndexes.filter(
(partIndex) => partIndex <= activePartIndex,
const brokenPartMatches = getBrokenPartMatches(parts, config);
const visibleBrokenPartMatches = brokenPartMatches.filter(
(match) => match.partIndex <= activePartIndex,
);
useEffect(() => {
@@ -65,8 +67,8 @@ export function RepairScanSequence({
onPartsReady={setParts}
/>
<RepairScanVisual target={activePart?.object} />
{visibleBrokenPartIndexes.map((partIndex) => {
const part = parts[partIndex];
{visibleBrokenPartMatches.map((match) => {
const part = parts[match.partIndex];
if (!part) return null;
return (
@@ -87,29 +89,25 @@ function getScannedBrokenParts(
parts: readonly ExplodedPart[],
config: RepairMissionConfig,
): readonly RepairScannedBrokenPart[] {
const brokenPartIndexes = getBrokenPartIndexes(parts, config.brokenParts);
return brokenPartIndexes.map((_, index) => {
const configuredPart = config.brokenParts[index] ?? config.brokenParts[0];
return getBrokenPartMatches(parts, config).map((match) => {
return {
id: configuredPart?.id ?? `${config.id}-broken-part-${index}`,
label: configuredPart?.label ?? `${config.label} broken part`,
modelPath: configuredPart?.modelPath ?? config.modelPath,
...(configuredPart?.placeholderName
? { placeholderName: configuredPart.placeholderName }
id: match.config.id,
label: match.config.label,
modelPath: match.config.modelPath ?? config.modelPath,
...(match.config.caseSlotName
? { caseSlotName: match.config.caseSlotName }
: {}),
};
});
}
function getBrokenPartIndexes(
function getBrokenPartMatches(
parts: readonly ExplodedPart[],
brokenParts: readonly RepairMissionPartConfig[],
): number[] {
if (parts.length === 0 || brokenParts.length === 0) return [];
config: RepairMissionConfig,
): RepairBrokenPartMatch[] {
if (parts.length === 0 || config.brokenParts.length === 0) return [];
const matchedIndexes = brokenParts.flatMap((brokenPart) => {
const matches = config.brokenParts.flatMap((brokenPart) => {
const { nodeName } = brokenPart;
if (!nodeName) return [];
@@ -117,12 +115,30 @@ function getBrokenPartIndexes(
objectContainsNodeName(part.object, nodeName),
);
return index >= 0 ? [index] : [];
return index >= 0 ? [{ config: brokenPart, partIndex: index }] : [];
});
if (matchedIndexes.length > 0) return [...new Set(matchedIndexes)];
if (matches.length !== config.brokenParts.length) {
const matchedIds = new Set(matches.map((match) => match.config.id));
const missingIds = config.brokenParts
.filter((brokenPart) => !matchedIds.has(brokenPart.id))
.map((brokenPart) => brokenPart.id);
return parts.slice(0, brokenParts.length).map((_, index) => index);
const warningKey = `${config.id}:${missingIds.join(",")}`;
if (!warnedMissingScanParts.has(warningKey)) {
warnedMissingScanParts.add(warningKey);
logger.warn("RepairScan", "Broken parts missing from exploded model", {
missionId: config.id,
missingIds,
});
}
}
return matches.filter(
(match, index, allMatches) =>
allMatches.findIndex((item) => item.partIndex === match.partIndex) ===
index,
);
}
function objectContainsNodeName(
@@ -3,7 +3,7 @@ import { Component, useEffect, useMemo, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
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 {
useHandTrackingGloveStatus,
@@ -255,7 +255,7 @@ function HandTrackingGloveModel({
throw new Error(`Missing glove root node ${config.rootNodeName}`);
}
const clonedRootNode = clone(rootNode);
const clonedRootNode = SkeletonUtils.clone(rootNode);
clonedRootNode.visible = false;
return clonedRootNode;
@@ -41,8 +41,27 @@ type InteractableObjectProps =
const _cameraPos = new THREE.Vector3();
const _cameraDir = new THREE.Vector3();
const _objectPos = new THREE.Vector3();
const _objectBounds = new THREE.Box3();
const _raycaster = new THREE.Raycaster();
function getInteractableWorldPosition(
group: THREE.Group,
debugSphere: THREE.Mesh | null,
): THREE.Vector3 {
_objectBounds.makeEmpty();
for (const child of group.children) {
if (child === debugSphere) continue;
_objectBounds.expandByObject(child);
}
if (!_objectBounds.isEmpty()) {
return _objectBounds.getCenter(_objectPos);
}
return group.getWorldPosition(_objectPos);
}
function createInteractableHandle(
props: InteractableObjectProps,
): InteractableHandle {
@@ -158,7 +177,7 @@ export function InteractableObject(
const t = bodyRef.current.translation();
_objectPos.set(t.x, t.y, t.z);
} else if (group) {
group.getWorldPosition(_objectPos);
getInteractableWorldPosition(group, debugSphereRef.current);
} else {
_objectPos.set(...position);
}
+43 -33
View File
@@ -4,11 +4,12 @@ import type { AnimationAction } from "three";
import {
AnimatedModelContext,
type AnimatedModelContextValue,
} from "@/components/three/models/useAnimatedModel";
} from "@/hooks/animation/useAnimatedModel";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import type { ModelTransformProps } from "@/types/three/three";
import { logger } from "@/utils/core/Logger";
export interface AnimatedModelConfig extends ModelTransformProps {
interface AnimatedModelConfig extends ModelTransformProps {
modelPath: string;
animations?: string[];
defaultAnimation?: string;
@@ -67,32 +68,6 @@ export function AnimatedModel({
}
}, [mixer, onAnimationEnd]);
const play = useCallback(
(name: string, fade = fadeDuration) => {
const action = actions[name];
if (action) {
Object.values(actions).forEach((a) => {
if (a && a !== action) a.fadeOut(fade);
});
action.reset().fadeIn(fade).play();
setCurrentAnim(name);
}
},
[actions, fadeDuration],
);
const stop = useCallback(
(fade = fadeDuration) => {
Object.values(actions).forEach((a) => a?.fadeOut(fade));
const defaultAction = actions[defaultAnimation];
if (defaultAction) {
defaultAction.reset().fadeIn(fade).play();
setCurrentAnim(defaultAnimation);
}
},
[actions, defaultAnimation, fadeDuration],
);
const fadeTo = useCallback(
(name: string, fade = fadeDuration) => {
const action = actions[name];
@@ -106,6 +81,19 @@ export function AnimatedModel({
},
[actions, fadeDuration],
);
const play = fadeTo;
const stop = useCallback(
(fade = fadeDuration) => {
Object.values(actions).forEach((a) => a?.fadeOut(fade));
const defaultAction = actions[defaultAnimation];
if (defaultAction) {
defaultAction.reset().fadeIn(fade).play();
setCurrentAnim(defaultAnimation);
}
},
[actions, defaultAnimation, fadeDuration],
);
const setSpeed = useCallback(
(newSpeed: number) => {
@@ -121,17 +109,39 @@ export function AnimatedModel({
return;
}
let defaultAction = actions[defaultAnimation as string];
let defaultAction = actions[defaultAnimation];
if (!defaultAction && names.length > 0) {
defaultAction = actions[names[0] as string];
const fallbackAnimation = names[0];
if (!defaultAction && fallbackAnimation) {
logger.warn(
"AnimatedModel",
"Default animation missing, using fallback",
{
modelPath,
defaultAnimation,
fallbackAnimation,
availableAnimations: names,
},
);
defaultAction = actions[fallbackAnimation];
}
if (defaultAction) {
defaultAction.play();
Object.values(actions).forEach((action) => {
if (action && action !== defaultAction) action.fadeOut(fadeDuration);
});
defaultAction.reset().fadeIn(fadeDuration).play();
onLoaded?.();
}
}, [actions, defaultAnimation, names, autoPlay, onLoaded]);
}, [
actions,
defaultAnimation,
fadeDuration,
modelPath,
names,
autoPlay,
onLoaded,
]);
const contextValue: AnimatedModelContextValue = {
play,
+23 -10
View File
@@ -1,8 +1,23 @@
import { useMemo } from "react";
import { useEffect } from "react";
import * as THREE from "three";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
export interface SimpleModelConfig extends ModelTransformProps {
function applyShadowSettings(
object: THREE.Object3D,
castShadow: boolean,
receiveShadow: boolean,
): void {
object.traverse((child) => {
if (!(child instanceof THREE.Mesh)) return;
child.castShadow = castShadow;
child.receiveShadow = receiveShadow;
});
}
interface SimpleModelConfig extends ModelTransformProps {
modelPath: string;
castShadow?: boolean;
receiveShadow?: boolean;
@@ -27,20 +42,18 @@ export function SimpleModel({
rotation,
scale,
});
const model = useMemo(() => scene.clone(true), [scene]);
const model = useClonedObject(scene, { cloneResources: true });
useEffect(() => {
applyShadowSettings(model, castShadow, receiveShadow);
}, [castShadow, model, receiveShadow]);
const parsedScale =
typeof scale === "number" ? ([scale, scale, scale] as Vector3Tuple) : scale;
return (
<group position={position} rotation={rotation} scale={parsedScale}>
{children ?? (
<primitive
object={model}
castShadow={castShadow}
receiveShadow={receiveShadow}
/>
)}
{children ?? <primitive object={model} />}
</group>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { useMemo } from "react";
import * as THREE from "three";
import { useGLTF } from "@react-three/drei";
import { useThree } from "@react-three/fiber";
import { CLOUD_CONFIG } from "@/data/world/cloudConfig";
import { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene";
interface CloudModelProps {
castShadow?: boolean;
receiveShadow?: boolean;
}
function applyCloudSettings(
scene: THREE.Object3D,
castShadow: boolean,
receiveShadow: boolean,
): void {
scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.castShadow = castShadow;
child.receiveShadow = receiveShadow;
const materials = Array.isArray(child.material)
? child.material
: [child.material];
for (const material of materials) {
material.fog = false;
}
}
});
}
export function CloudModel({
castShadow = false,
receiveShadow = false,
}: CloudModelProps): React.JSX.Element {
const { scene } = useGLTF(CLOUD_CONFIG.modelPath);
const maxAnisotropy = useThree((state) =>
state.gl.capabilities.getMaxAnisotropy(),
);
const cloud = useMemo(() => {
optimizeGLTFSceneTextures(scene, maxAnisotropy);
const model = scene.clone(true);
applyCloudSettings(model, castShadow, receiveShadow);
return model;
}, [castShadow, maxAnisotropy, receiveShadow, scene]);
return <primitive object={cloud} />;
}
useGLTF.preload(CLOUD_CONFIG.modelPath);
+15
View File
@@ -0,0 +1,15 @@
import { useGLTF } from "@react-three/drei";
import {
MergedStaticMapModel,
type MergedStaticMapModelProps,
} from "@/components/three/world/MergedStaticMapModel";
const ECOLE_MODEL_PATH = "/models/ecole/model.gltf";
type EcoleModelProps = Omit<MergedStaticMapModelProps, "modelPath">;
export function EcoleModel(props: EcoleModelProps): React.JSX.Element {
return <MergedStaticMapModel modelPath={ECOLE_MODEL_PATH} {...props} />;
}
useGLTF.preload(ECOLE_MODEL_PATH);
@@ -0,0 +1,19 @@
import { useGLTF } from "@react-three/drei";
import {
MergedStaticMapModel,
type MergedStaticMapModelProps,
} from "@/components/three/world/MergedStaticMapModel";
const FERME_VERTICALE_MODEL_PATH = "/models/fermeverticale/model.gltf";
type FermeVerticaleModelProps = Omit<MergedStaticMapModelProps, "modelPath">;
export function FermeVerticaleModel(
props: FermeVerticaleModelProps,
): React.JSX.Element {
return (
<MergedStaticMapModel modelPath={FERME_VERTICALE_MODEL_PATH} {...props} />
);
}
useGLTF.preload(FERME_VERTICALE_MODEL_PATH);
@@ -0,0 +1,17 @@
import { useGLTF } from "@react-three/drei";
import {
MergedStaticMapModel,
type MergedStaticMapModelProps,
} from "@/components/three/world/MergedStaticMapModel";
const GENERATEUR_MODEL_PATH = "/models/generateur/model.gltf";
type GenerateurModelProps = Omit<MergedStaticMapModelProps, "modelPath">;
export function GenerateurModel(
props: GenerateurModelProps,
): React.JSX.Element {
return <MergedStaticMapModel modelPath={GENERATEUR_MODEL_PATH} {...props} />;
}
useGLTF.preload(GENERATEUR_MODEL_PATH);
@@ -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);
@@ -0,0 +1,175 @@
import { useEffect, useRef } from "react";
import { useGLTF } from "@react-three/drei";
import { useThree } from "@react-three/fiber";
import * as THREE from "three";
import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
import type { Vector3Tuple } from "@/types/three/three";
import { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene";
export interface MergedStaticMapModelProps {
modelPath: string;
position: Vector3Tuple;
rotation: Vector3Tuple;
scale: Vector3Tuple;
castShadow?: boolean;
receiveShadow?: boolean;
onLoaded?: () => void;
}
interface MergedMeshData {
geometry: THREE.BufferGeometry;
material: THREE.Material | THREE.Material[];
}
interface GeometryGroup {
geometries: THREE.BufferGeometry[];
material: THREE.Material | THREE.Material[];
}
function cloneMaterial(
material: THREE.Material | THREE.Material[],
): THREE.Material | THREE.Material[] {
return Array.isArray(material)
? material.map((item) => item.clone())
: material.clone();
}
function disposeMaterial(material: THREE.Material | THREE.Material[]): void {
if (Array.isArray(material)) {
for (const item of material) {
item.dispose();
}
return;
}
material.dispose();
}
function createGeometrySignature(geometry: THREE.BufferGeometry): string {
const attributes = Object.entries(geometry.attributes)
.map(([name, attribute]) => {
return `${name}:${attribute.itemSize}:${attribute.normalized}`;
})
.sort()
.join("|");
return `${geometry.index ? "indexed" : "non-indexed"}:${attributes}`;
}
function createMaterialKey(
material: THREE.Material | THREE.Material[],
): string {
if (Array.isArray(material)) {
return material.map((item) => item.uuid).join("|");
}
return material.uuid;
}
function createMergedMeshes(scene: THREE.Group): MergedMeshData[] {
const groups = new Map<string, GeometryGroup>();
scene.updateMatrixWorld(true);
scene.traverse((child) => {
if (!(child instanceof THREE.Mesh)) return;
const geometry = child.geometry.clone();
geometry.applyMatrix4(child.matrixWorld);
const material = child.material;
const key = `${createMaterialKey(material)}:${createGeometrySignature(geometry)}`;
const group = groups.get(key);
if (group) {
group.geometries.push(geometry);
return;
}
groups.set(key, {
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,
material: group.material,
};
}
const geometry = mergeGeometries(group.geometries, false);
for (const sourceGeometry of group.geometries) {
sourceGeometry.dispose();
}
if (!geometry) {
disposeMaterial(group.material);
return null;
}
return {
geometry,
material: group.material,
};
})
.filter((meshData): meshData is MergedMeshData => meshData !== null);
}
export function MergedStaticMapModel({
modelPath,
position,
rotation,
scale,
castShadow = true,
receiveShadow = true,
onLoaded,
}: MergedStaticMapModelProps): React.JSX.Element {
const { scene } = useGLTF(modelPath);
const maxAnisotropy = useThree((state) =>
state.gl.capabilities.getMaxAnisotropy(),
);
const groupRef = useRef<THREE.Group>(null);
useEffect(() => {
const group = groupRef.current;
if (!group) return;
optimizeGLTFSceneTextures(scene, maxAnisotropy);
const mergedMeshes = createMergedMeshes(scene);
const meshes = mergedMeshes.map((meshData) => {
const mesh = new THREE.Mesh(meshData.geometry, meshData.material);
mesh.castShadow = castShadow;
mesh.receiveShadow = receiveShadow;
return mesh;
});
for (const mesh of meshes) {
group.add(mesh);
}
onLoaded?.();
return () => {
for (const mesh of meshes) {
group.remove(mesh);
mesh.geometry.dispose();
disposeMaterial(mesh.material);
}
};
}, [castShadow, maxAnisotropy, modelPath, onLoaded, receiveShadow, scene]);
return (
<group
ref={groupRef}
position={position}
rotation={rotation}
scale={scale}
/>
);
}
+47 -12
View File
@@ -3,12 +3,15 @@ import { useGLTF } from "@react-three/drei";
import { Component, useEffect, useMemo, useRef, type ReactNode } from "react";
import * as THREE from "three";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import { logger } from "@/utils/core/Logger";
interface SkyModelProps {
modelPath: string;
fallbackModelScale?: number | undefined;
fallbackModelPath?: string | undefined;
fallbackScale?: number | undefined;
fallbackColor?: string | undefined;
materialSide?: THREE.Side | undefined;
modelPath: string;
scale?: number | undefined;
unlit?: boolean | undefined;
}
@@ -23,6 +26,8 @@ interface SkyModelContentProps {
interface SkyModelErrorBoundaryProps {
children: ReactNode;
fallback: ReactNode;
label: string;
modelPath: string;
}
interface SkyModelErrorBoundaryState {
@@ -31,7 +36,7 @@ interface SkyModelErrorBoundaryState {
const SKY_MODEL_SCALE = 1;
const SKY_MODEL_RENDER_ORDER = -1000;
const LEGACY_SKY_MODEL_PATH = "/models/sky/model.glb";
const SKYBOX_MODEL_PATH = "/models/skybox/model.gltf";
class SkyModelErrorBoundary extends Component<
SkyModelErrorBoundaryProps,
@@ -46,6 +51,17 @@ class SkyModelErrorBoundary extends Component<
return { hasError: true };
}
componentDidCatch(error: Error): void {
logger.warn(
"SkyModel",
`${this.props.label} model failed; using fallback`,
{
error,
modelPath: this.props.modelPath,
},
);
}
render(): ReactNode {
if (this.state.hasError) {
return this.props.fallback;
@@ -56,6 +72,8 @@ class SkyModelErrorBoundary extends Component<
}
export function SkyModel({
fallbackColor,
fallbackModelScale = SKY_MODEL_SCALE,
fallbackModelPath,
fallbackScale = SKY_MODEL_SCALE,
materialSide = THREE.BackSide,
@@ -63,17 +81,35 @@ export function SkyModel({
scale = SKY_MODEL_SCALE,
unlit = false,
}: SkyModelProps): React.JSX.Element {
const fallback = fallbackModelPath ? (
<SkyModelContent
materialSide={materialSide}
modelPath={fallbackModelPath}
scale={fallbackScale}
unlit={unlit}
/>
const colorFallback = fallbackColor ? (
<color attach="background" args={[fallbackColor]} />
) : null;
const fallback = fallbackModelPath ? (
<SkyModelErrorBoundary
key={fallbackModelPath}
fallback={colorFallback}
label="Fallback sky"
modelPath={fallbackModelPath}
>
<SkyModelContent
materialSide={materialSide}
modelPath={fallbackModelPath}
scale={fallbackScale ?? fallbackModelScale}
unlit={unlit}
/>
</SkyModelErrorBoundary>
) : (
colorFallback
);
return (
<SkyModelErrorBoundary key={modelPath} fallback={fallback}>
<SkyModelErrorBoundary
key={modelPath}
fallback={fallback}
label="Primary sky"
modelPath={modelPath}
>
<SkyModelContent
materialSide={materialSide}
modelPath={modelPath}
@@ -190,5 +226,4 @@ function disposeSkyModelMaterials(model: THREE.Object3D): void {
});
}
useGLTF.preload("/models/skybox/skybox.gltf");
useGLTF.preload(LEGACY_SKY_MODEL_PATH);
useGLTF.preload(SKYBOX_MODEL_PATH);
@@ -0,0 +1,89 @@
import { useEffect, useMemo, useRef } from "react";
import * as THREE from "three";
import { useGLTF } from "@react-three/drei";
import { useThree } from "@react-three/fiber";
import { TERRAIN_MODEL_PATH } from "@/data/world/terrainConfig";
import type { Vector3Tuple } from "@/types/three/three";
import { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene";
const TERRAIN_DEFAULT_POSITION: Vector3Tuple = [0, 0, 0];
interface TerrainModelProps {
position?: Vector3Tuple;
rotation?: Vector3Tuple;
scale?: Vector3Tuple;
receiveShadow?: boolean;
visible?: boolean;
groupRef?: React.RefObject<THREE.Group>;
onLoaded?: () => void;
}
function applyTerrainMaterialSettings(
scene: THREE.Object3D,
receiveShadow: boolean,
): void {
scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.receiveShadow = receiveShadow;
const materials = Array.isArray(child.material)
? child.material
: [child.material];
for (const material of materials) {
const materialWithAlphaMap = material as THREE.Material & {
alphaMap?: THREE.Texture | null;
};
material.depthTest = true;
material.depthWrite = true;
if (material.opacity >= 1 && !materialWithAlphaMap.alphaMap) {
material.transparent = false;
}
}
}
});
}
export function TerrainModel({
position = TERRAIN_DEFAULT_POSITION,
rotation = [0, 0, 0],
scale = [1, 1, 1],
receiveShadow = true,
visible = true,
groupRef,
onLoaded,
}: TerrainModelProps): React.JSX.Element {
const internalRef = useRef<THREE.Group>(null);
const ref = groupRef ?? internalRef;
const { scene } = useGLTF(TERRAIN_MODEL_PATH);
const maxAnisotropy = useThree((state) =>
state.gl.capabilities.getMaxAnisotropy(),
);
const terrainModel = useMemo(() => {
optimizeGLTFSceneTextures(scene, maxAnisotropy);
const model = scene.clone(true);
applyTerrainMaterialSettings(model, receiveShadow);
return model;
}, [maxAnisotropy, scene, receiveShadow]);
useEffect(() => {
onLoaded?.();
}, [onLoaded]);
return (
<group
ref={ref}
position={position}
rotation={rotation}
scale={scale}
visible={visible}
>
<primitive object={terrainModel} />
</group>
);
}
useGLTF.preload(TERRAIN_MODEL_PATH);
+22 -28
View File
@@ -1,10 +1,9 @@
import { useEffect } from "react";
import { X } from "lucide-react";
import { RotateCcw, X } from "lucide-react";
import { useGameStore } from "@/managers/stores/useGameStore";
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
import type {
RepairRuntime,
SubtitleLanguage,
} from "@/managers/stores/useSettingsStore";
import type { SubtitleLanguage } from "@/types/settings/settings";
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
function formatPercent(value: number): string {
return `${Math.round(value * 100)}%`;
@@ -52,6 +51,7 @@ function VolumeSlider({
}
export function GameSettingsMenu(): React.JSX.Element | null {
const resetGame = useGameStore((state) => state.resetGame);
const {
isSettingsMenuOpen,
musicVolume,
@@ -59,14 +59,12 @@ export function GameSettingsMenu(): React.JSX.Element | null {
dialogueVolume,
subtitlesEnabled,
subtitleLanguage,
repairRuntime,
setMusicVolume,
setSfxVolume,
setDialogueVolume,
setSettingsMenuOpen,
setSubtitlesEnabled,
setSubtitleLanguage,
setRepairRuntime,
} = useSettingsStore();
useEffect(() => {
@@ -93,6 +91,13 @@ export function GameSettingsMenu(): React.JSX.Element | null {
window.location.assign("/");
};
const handleRestart = (): void => {
resetGame();
window.location.reload();
};
const showDebugRestart = isDebugEnabled();
return (
<div className="game-settings-menu" role="dialog" aria-modal="true">
<div className="game-settings-menu__panel">
@@ -168,27 +173,16 @@ export function GameSettingsMenu(): React.JSX.Element | null {
</div>
</section>
<section
className="game-settings-menu__section"
aria-labelledby="repair-settings-heading"
>
<h3 id="repair-settings-heading">Repair game</h3>
<div className="game-settings-menu__choice-group game-settings-menu__choice-group--stacked">
{(["js", "python"] satisfies RepairRuntime[]).map((runtime) => (
<button
key={runtime}
type="button"
className={repairRuntime === runtime ? "active" : undefined}
onClick={() => setRepairRuntime(runtime)}
aria-pressed={repairRuntime === runtime}
>
{runtime === "js"
? "Repair game en JS (local)"
: "Repair game en Python (server)"}
</button>
))}
</div>
</section>
{showDebugRestart ? (
<button
className="game-settings-menu__restart"
type="button"
onClick={handleRestart}
>
<RotateCcw size={14} aria-hidden="true" />
Recommencer
</button>
) : null}
<button
className="game-settings-menu__quit"
+1 -1
View File
@@ -2,7 +2,7 @@ import { useSettingsStore } from "@/managers/stores/useSettingsStore";
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
import type { DialogueSpeaker } from "@/types/dialogues/dialogues";
export type SubtitleSpeaker = DialogueSpeaker;
type SubtitleSpeaker = DialogueSpeaker;
interface SubtitlesProps {
speaker?: SubtitleSpeaker | null;
+54 -39
View File
@@ -1,18 +1,15 @@
import { RotateCcw, StepBack, StepForward } from "lucide-react";
import {
type MainGameState,
useGameStore,
} from "@/managers/stores/useGameStore";
import { isMissionStep, MISSION_STEPS } from "@/types/gameplay/repairMission";
import { GAME_STEPS, type GameStep } from "@/types/game";
const MAIN_STATES: MainGameState[] = [
"intro",
"bike",
"pylone",
"ferme",
"outro",
];
GAME_STEPS,
isGameStep,
MAIN_GAME_STATES,
} from "@/data/game/gameStateConfig";
import {
isMissionStep,
MISSION_STEPS,
} from "@/data/gameplay/repairMissionState";
import { useGameStore } from "@/managers/stores/useGameStore";
import type { MainGameState } from "@/types/game";
function toPascalCase(value: string): string {
return value
@@ -24,28 +21,28 @@ function toPascalCase(value: string): string {
export function GameStateDebugPanel(): React.JSX.Element {
const mainState = useGameStore((state) => state.mainState);
const bikeStep = useGameStore((state) => state.bike.currentStep);
const pyloneStep = useGameStore((state) => state.pylone.currentStep);
const fermeStep = useGameStore((state) => state.ferme.currentStep);
const ebikeStep = useGameStore((state) => state.ebike.currentStep);
const pylonStep = useGameStore((state) => state.pylon.currentStep);
const farmStep = useGameStore((state) => state.farm.currentStep);
const detail = useGameStore((state) => {
switch (state.mainState) {
case "intro":
return state.intro.currentStep;
case "bike":
return state.bike.currentStep;
case "pylone":
return state.pylone.currentStep;
case "ferme":
return state.ferme.currentStep;
case "ebike":
return state.ebike.currentStep;
case "pylon":
return state.pylon.currentStep;
case "farm":
return state.farm.currentStep;
case "outro":
return state.outro.hasStarted ? "started" : "waiting";
}
});
const setMainState = useGameStore((state) => state.setMainState);
const setIntroStep = useGameStore((state) => state.setIntroStep);
const setBikeState = useGameStore((state) => state.setBikeState);
const setPyloneState = useGameStore((state) => state.setPyloneState);
const setFermeState = useGameStore((state) => state.setFermeState);
const setEbikeState = useGameStore((state) => state.setEbikeState);
const setPylonState = useGameStore((state) => state.setPylonState);
const setFarmState = useGameStore((state) => state.setFarmState);
const setOutroState = useGameStore((state) => state.setOutroState);
const advanceGameState = useGameStore((state) => state.advanceGameState);
const rewindGameState = useGameStore((state) => state.rewindGameState);
@@ -60,7 +57,9 @@ export function GameStateDebugPanel(): React.JSX.Element {
function setSubState(nextSubState: string): void {
if (mainState === "intro") {
setIntroStep(nextSubState as GameStep);
if (isGameStep(nextSubState)) {
setIntroStep(nextSubState);
}
return;
}
@@ -71,18 +70,18 @@ export function GameStateDebugPanel(): React.JSX.Element {
if (!isMissionStep(nextSubState)) return;
if (mainState === "bike") {
setBikeState({ currentStep: nextSubState });
if (mainState === "ebike") {
setEbikeState({ currentStep: nextSubState });
return;
}
if (mainState === "pylone") {
setPyloneState({ currentStep: nextSubState });
if (mainState === "pylon") {
setPylonState({ currentStep: nextSubState });
return;
}
if (mainState === "ferme") {
setFermeState({ currentStep: nextSubState });
if (mainState === "farm") {
setFarmState({ currentStep: nextSubState });
return;
}
}
@@ -90,18 +89,34 @@ export function GameStateDebugPanel(): React.JSX.Element {
function setDebugMainState(nextMainState: MainGameState): void {
setMainState(nextMainState);
if (nextMainState === "bike" && bikeStep === "locked") {
setBikeState({ currentStep: "waiting" });
if (
nextMainState === "pylon" ||
nextMainState === "farm" ||
nextMainState === "outro"
) {
setEbikeState({ currentStep: "done", isRepaired: true });
}
if (nextMainState === "farm" || nextMainState === "outro") {
setPylonState({ currentStep: "done", isPowered: true });
}
if (nextMainState === "outro") {
setFarmState({ currentStep: "done", irrigationFixed: true });
}
if (nextMainState === "ebike" && ebikeStep === "locked") {
setEbikeState({ currentStep: "waiting" });
return;
}
if (nextMainState === "pylone" && pyloneStep === "locked") {
setPyloneState({ currentStep: "waiting" });
if (nextMainState === "pylon" && pylonStep === "locked") {
setPylonState({ currentStep: "waiting" });
return;
}
if (nextMainState === "ferme" && fermeStep === "locked") {
setFermeState({ currentStep: "waiting" });
if (nextMainState === "farm" && farmStep === "locked") {
setFarmState({ currentStep: "waiting" });
}
}
@@ -124,7 +139,7 @@ export function GameStateDebugPanel(): React.JSX.Element {
aria-label="Main states"
role="group"
>
{MAIN_STATES.map((state) => (
{MAIN_GAME_STATES.map((state) => (
<button
key={state}
aria-pressed={state === mainState}
+1 -1
View File
@@ -4,7 +4,7 @@ import * as THREE from "three";
import { ZONES } from "@/data/zones";
import { useGameStore } from "@/managers/stores/useGameStore";
import { Debug } from "@/utils/debug/Debug";
import { GAME_STEPS } from "@/types/game";
import { GAME_STEPS } from "@/data/game/gameStateConfig";
const _playerPos = new THREE.Vector3();
const _zonePos = new THREE.Vector3();
+1 -1
View File
@@ -30,7 +30,7 @@ export const FlyController = forwardRef<FlyControllerRef, FlyControllerProps>(
) => {
const { camera: rawCamera } = useThree();
const cameraRef = useRef(rawCamera);
const keys = useRef<{ [key: string]: boolean }>({});
const keys = useRef<Partial<Record<string, boolean>>>({});
const controlsRef = useRef<OrbitControlsRef | null>(null);
const lastPosition = useRef(new THREE.Vector3());
+8
View File
@@ -5,3 +5,11 @@ export const AUDIO_PATHS = {
searching: "/sounds/effect/fa.mp3",
helped: "/sounds/effect/fa.mp3",
} as const;
export type AudioCategory = "music" | "sfx" | "dialogue";
export const DEFAULT_CATEGORY_VOLUMES: Record<AudioCategory, number> = {
music: 1,
sfx: 1,
dialogue: 1,
};
+10 -7
View File
@@ -1,4 +1,5 @@
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_SIZE: Vector3Tuple = [200, 1, 200];
@@ -23,28 +24,30 @@ 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_TUBE_RADIUS = 0.045;
export const TEST_SCENE_REPAIR_ZONES = [
export const GAME_REPAIR_ZONES = [
{
mission: "bike",
label: "Bike",
mission: "ebike",
label: "E-bike",
color: "#38bdf8",
position: [-12, 0, -12],
},
{
mission: "pylone",
label: "Pylone",
mission: "pylon",
label: "Pylon",
color: "#facc15",
position: [0, 0, -12],
},
{
mission: "ferme",
mission: "farm",
label: "Farm",
color: "#86efac",
position: [12, 0, -12],
},
] as const satisfies readonly {
mission: "bike" | "pylone" | "ferme";
mission: RepairMissionId;
label: string;
color: string;
position: Vector3Tuple;
}[];
export const TEST_SCENE_REPAIR_ZONES = GAME_REPAIR_ZONES;
+25 -13
View File
@@ -38,47 +38,59 @@ export const docGroups: DocGroup[] = [
subtitle: "Gameplay implementation",
meta: "04",
},
{
path: "/docs/mission-flow",
title: "Mission Flow",
subtitle: "Intro and mission progression",
meta: "05",
},
{
path: "/docs/interaction",
title: "Interaction System",
subtitle: "Trigger, grab, hand input",
meta: "05",
meta: "06",
},
{
path: "/docs/target-architecture",
title: "Target Architecture",
subtitle: "Next direction",
meta: "06",
meta: "07",
},
{
path: "/docs/technical-editor",
title: "Editor Technical Notes",
subtitle: "Implementation details",
meta: "07",
meta: "08",
},
{
path: "/docs/audio",
title: "Audio Technical Notes",
subtitle: "Music, dialogue, SRT, and SFX",
meta: "08",
meta: "09",
},
{
path: "/docs/hand-tracking",
title: "Hand Tracking Technical Notes",
subtitle: "Webcam interaction pipeline",
meta: "09",
meta: "10",
},
{
path: "/docs/zustand",
title: "Zustand Stores",
subtitle: "Game, settings, subtitles",
meta: "10",
meta: "11",
},
{
path: "/docs/three-debugging",
title: "Three Debugging",
subtitle: "Step into Three.js internals",
meta: "11",
meta: "12",
},
{
path: "/docs/map-performance",
title: "Map Performance",
subtitle: "Draw calls, triangles, and streaming",
meta: "13",
},
],
},
@@ -89,31 +101,31 @@ export const docGroups: DocGroup[] = [
path: "/docs/features",
title: "Features",
subtitle: "Implemented scope",
meta: "12",
meta: "14",
},
{
path: "/docs/main-feature",
title: "Main Feature",
subtitle: "Repair-game prototype",
meta: "13",
meta: "15",
},
{
path: "/docs/editor",
title: "Editor User Guide",
subtitle: "Editing workflow",
meta: "14",
meta: "16",
},
{
path: "/docs/animation",
title: "Animation & 3D Model System",
subtitle: "Components and usage",
meta: "15",
meta: "17",
},
{
path: "/docs/gallery",
title: "Model Gallery",
subtitle: "Browsing 3D assets",
meta: "16",
meta: "18",
},
],
},
@@ -124,7 +136,7 @@ export const docGroups: DocGroup[] = [
path: "/docs/code-review",
title: "Code Review Prep",
subtitle: "Presentation support",
meta: "17",
meta: "19",
},
],
},
+3 -709
View File
@@ -1,459 +1,3 @@
export const readmeFr = `# La-Fabrik
Une expérience web 3D interactive pour La Fabrik Durable, un service low-tech de réparation et de transformation situé à Altera, une ville post-capitaliste reconstruite en 2039. Les joueurs incarnent un technicien fraîchement intégré et vivent une journée de service : réparer un vélo électrique, remettre en état un réseau d'énergie et améliorer le système d'irrigation d'une ferme verticale.
Construit avec React, Three.js et Vite. Fonctionne dans le navigateur, sans installation côté utilisateur.
## Stack technique
### Build et langage
| Package |
| -------------------------------------------------- |
| [TypeScript](https://www.typescriptlang.org/docs/) |
| [React](https://react.dev/learn) |
| [Vite](https://vite.dev/guide/) |
| [ESLint](https://eslint.org/docs/latest/) |
| [Prettier](https://prettier.io/docs/) |
### Moteur 3D
| Package |
| ----------------------------------------------------------------------------------------- |
| [Three.js](https://threejs.org/docs/) |
| [@react-three/fiber](https://docs.pmnd.rs/react-three-fiber/getting-started/introduction) |
| [@react-three/drei](https://pmndrs.github.io/drei) |
| [@react-three/rapier](https://rapier.rs/docs/) |
| [GSAP](https://gsap.com/docs/v3/Installation/) |
### Performance et effets
| Package |
| --------------------------------------------------------------------------- |
| [r3f-perf](https://github.com/utsuboco/r3f-perf) |
| [AnimationMixer](https://threejs.org/docs/#api/en/animation/AnimationMixer) |
## Structure du projet
\`\`\`
la-fabrik/
├── public/
│ ├── models/
│ │ ├── map/ # Carte de base, chargée au démarrage
│ │ ├── workshop/
│ │ ├── powerGrid/
│ │ └── farm/
│ ├── textures/
│ └── sounds/
└── src/
├── world/ # Composition du monde 3D persistant
│ ├── World.tsx # Composition de la scène active
│ ├── GameMap.tsx # Chargement de carte et collision octree
│ ├── Lighting.tsx # Lumières ambiante, directionnelle et ponctuelles
│ ├── Environment.tsx # Arrière-plan et modèle de ciel
│ ├── GameMusic.tsx # Cycle de vie de la musique de jeu
│ ├── debug/ # Scène de test debug
│ └── player/ # Contrôleur joueur et caméra
├── components/
│ ├── three/ # Composants R3F par domaine
│ └── ui/ # Overlays HTML hors Canvas
├── managers/ # Logique, état et orchestration
├── hooks/ # Hooks React autour des managers
├── data/ # Configuration statique
├── shaders/ # Shaders GLSL
└── utils/ # Utilitaires partagés et debug
\`\`\`
## Démarrage
\`\`\`bash
git clone https://github.com/La-Fabrik-Durable/La-Fabrik.git
cd La-Fabrik
npm install
npm run dev
\`\`\`
- application : \`http://localhost:5173\`
- mode debug : \`http://localhost:5173?debug\`
## Licence
Voir le fichier [LICENSE](./LICENSE).
`;
export const architectureFr = `# Architecture actuelle
Ce document décrit le code réellement présent aujourd'hui dans le dépôt.
## Structure runtime
- \`src/App.tsx\` monte le \`RouterProvider\`, qui pilote l'affichage des vues de l'application.
- \`src/pages/page.tsx\` monte le \`Canvas\`, le \`World\` 3D, l'overlay de performance debug et les overlays HTML.
- \`src/world/World.tsx\` compose la scène active avec :
- l'environnement et l'éclairage
- les helpers debug et le mode caméra debug
- soit la carte principale, soit la scène de test physique debug
- le rig joueur quand le mode caméra actif est \`player\`
- \`src/world/GameMap.tsx\` charge les modèles de carte disponibles et construit l'octree de collision.
- \`src/world/GameStageContent.tsx\` est enveloppé dans le contexte Rapier \`Physics\` dans la scène de jeu de production afin que les objets gameplay de stage puissent utiliser la physique sans migrer la carte ou le joueur vers Rapier. Il monte maintenant des instances réutilisables de \`RepairGame\` pour les états de mission \`bike\`, \`pylone\` et \`ferme\`.
- \`src/world/debug/TestMap.tsx\` fournit une carte orientée debug pour les interactions et la physique, avec les objets existants de grab, trigger et preview de modèle, plus des zones playground de réparation séparées \`Bike\`, \`Pylone\` et \`Farm\`.
- \`src/world/player/Player.tsx\` monte la caméra et le contrôleur.
- \`src/world/player/PlayerController.tsx\` gère le mouvement pointer lock, le saut, le verrouillage de déplacement pendant les étapes repair et les inputs d'interaction.
## Frontières physiques
Le projet utilise actuellement deux couches de collision avec des responsabilités séparées :
- \`GameMap\` construit une octree utilisée par le contrôleur joueur pour les collisions avec la carte.
- \`GameStageContent\` est enveloppé dans Rapier \`Physics\` pour les objets gameplay comme les triggers de réparation, les mallettes, les objets saisissables et les futurs objets spécifiques aux missions.
- \`TestMap\` possède son propre playground Rapier \`Physics\` afin de peaufiner le gameplay de réparation par state de mission sans dépendre du placement de la carte de production.
Le joueur et l'octree de carte doivent rester hors du provider Rapier tant qu'il n'existe pas de plan de migration volontaire. Cela évite de mélanger les règles de déplacement joueur avec la physique d'objets avant que les systèmes gameplay en aient besoin.
## Modèle d'interaction
- \`src/managers/InteractionManager.ts\` est la source d'état actuelle des interactions.
- \`src/components/three/interaction/InteractableObject.tsx\` gère la détection de focus par distance et raycasting.
- \`src/components/three/interaction/TriggerObject.tsx\` implémente les interactions de type trigger.
- \`src/components/three/interaction/GrabbableObject.tsx\` implémente les interactions saisir / relâcher.
- \`src/hooks/interaction/useInteraction.ts\` expose un snapshot d'interaction à l'UI React.
- \`src/components/ui/InteractPrompt.tsx\` affiche le prompt \`E\` pour les interactions trigger.
## Audio
- \`src/managers/AudioManager.ts\` fournit la lecture de sons one-shot avec pool, la musique en boucle, les volumes par catégorie et un pan stéréo optionnel pour les sons one-shot.
- Les catégories audio supportées sont \`music\`, \`sfx\` et \`dialogue\`.
- Les interactions trigger peuvent lancer directement des SFX via \`AudioManager\`.
## Menu options
- \`src/managers/stores/useSettingsStore.ts\` stocke les réglages de volume musique, volume SFX, volume dialogue, sous-titres, langue des sous-titres, runtime de réparation et visibilité du menu.
- \`src/components/ui/GameSettingsMenu.tsx\` rend le menu options en jeu.
- \`src/components/ui/GameUI.tsx\` monte le menu comme overlay HTML hors canvas.
- \`Esc\` ouvre et ferme le menu, et \`src/world/player/PlayerController.tsx\` ignore les inputs joueur pendant son ouverture.
- Les changements de volume sont transmis à \`AudioManager\` par catégorie.
## Dialogues et sous-titres
- \`public/sounds/dialogue/dialogues.json\` est le manifeste runtime des dialogues.
- Les fichiers audio de dialogue vivent dans \`public/sounds/dialogue/\`.
- Les fichiers de sous-titres vivent dans \`public/sounds/dialogue/subtitles/{fr|en}/\`.
- Le modèle actuel utilise un fichier SRT par voix et par langue.
- \`src/types/dialogues/dialogues.ts\` contient les types du manifeste.
- \`src/utils/dialogues/dialogueManifestValidation.ts\` valide la forme du manifeste au runtime.
- \`src/utils/dialogues/loadDialogueManifest.ts\` charge le manifeste et les cues SRT, avec fallback français si la langue sélectionnée manque.
- \`src/utils/subtitles/parseSrt.ts\` parse les blocs et timecodes SRT.
- \`src/utils/dialogues/playDialogue.ts\` joue l'audio de dialogue et synchronise le sous-titre actif avec le temps de l'élément audio.
- \`src/managers/stores/useSubtitleStore.ts\` stocke la cue de sous-titre affichée.
- \`src/components/ui/Subtitles.tsx\` rend l'overlay de sous-titres.
- \`src/world/GameDialogues.tsx\` déclenche actuellement les dialogues qui définissent un \`timecode\`.
- La lecture de dialogue est mise en file pour éviter les chevauchements.
## Cinématiques
- \`public/cinematics.json\` est le manifeste runtime des cinématiques.
- \`src/types/cinematics/cinematics.ts\` contient les types du manifeste.
- \`src/utils/cinematics/cinematicManifestValidation.ts\` valide la forme du manifeste.
- \`src/utils/cinematics/loadCinematicManifest.ts\` charge \`/cinematics.json\`.
- \`src/world/GameCinematics.tsx\` déclenche les cinématiques qui définissent un \`timecode\` global.
- Les cinématiques utilisent GSAP pour animer la position caméra et sa cible de regard.
- Les \`dialogueCues\` d'une cinématique déclenchent des dialogues à des temps relatifs au début de la cinématique.
- \`useGameStore.isCinematicPlaying\` sert à bloquer les inputs joueur pendant une cinématique.
## Système debug
- Le mode debug est activé avec \`?debug\`.
- \`src/utils/debug/Debug.ts\` possède l'instance \`lil-gui\` et les contrôles debug.
- \`src/hooks/debug/useCameraMode.ts\` et \`src/hooks/debug/useSceneMode.ts\` s'abonnent à l'état debug.
- \`src/components/debug/DebugPerf.tsx\` monte \`r3f-perf\` en lazy uniquement en mode debug.
- \`src/components/ui/debug/DebugOverlayLayout.tsx\` monte l'overlay HTML debug compact quand il est activé depuis \`lil-gui\`.
- \`src/components/ui/debug/GameStateDebugPanel.tsx\` expose l'état de jeu courant, le changement de main/sub-state, les contrôles previous/next step et le reset.
- \`src/components/ui/debug/HandTrackingDebugPanel.tsx\` affiche le statut hand tracking, l'usage, le modèle de gant chargé, le nombre de mains et l'état fist pendant l'activation du hand tracking.
- \`src/components/three/handTracking/HandTrackingGlove.tsx\` place les modèles riggés \`gant_l\` et \`gant_r\` sur les mains détectées dans la scène physics debug.
- \`src/components/debug/scene/DebugHelpers.tsx\` monte les helpers debug.
- \`src/components/debug/scene/DebugCameraControls.tsx\` monte la caméra libre debug.
- Les contrôles globaux \`lil-gui\` incluent camera mode, scene mode, \`R3F Perf\` et \`Debug Overlay\`; les contrôles d'interaction vivent dans le dossier \`Interaction\`.
## Domaines de composants 3D
- \`src/components/three/models/\` contient les helpers de modèles réutilisables comme \`ExplodableModel\`.
- \`src/components/three/interaction/\` contient les wrappers d'interaction réutilisables comme \`InteractableObject\`, \`TriggerObject\` et \`GrabbableObject\`.
- \`src/components/three/handTracking/\` contient les modèles debug R3F liés au hand tracking, comme les gants.
- \`src/components/three/gameplay/\` contient les composants de gameplay de réparation : le flow de production réutilisable \`RepairGame\`, la mallette, les étapes de réparation et les prompts.
- \`src/components/three/world/\` contient les objets world/environnement réutilisables comme \`SkyModel\`.
## Limites actuelles
- Le dépôt est encore un prototype, pas le runtime complet du jeu.
- \`src/world/debug/TestMap.tsx\` fait encore partie de la composition active.
- Il n'existe pas encore d'orchestrateur gameplay central comme \`GameManager\`.
- L'état de mission existe dans Zustand et le flow de réparation est implémenté comme prototype pour les missions de réparation actuelles.
- Les cinématiques et dialogues existent comme systèmes prototype pilotés par timecode; les branches de dialogue et l'orchestration gameplay globale restent limitées.
- Le joueur utilise une collision octree et des règles simples, pas une pile physique gameplay complète.
`;
export const targetArchitectureFr = `# Architecture cible
Ce document décrit l'architecture visée à moyen terme pour le projet.
## Relation avec le code actuel
- \`docs/technical/architecture.md\` reste la source de vérité de ce qui existe maintenant.
- Ce document décrit une direction d'architecture, pas un comportement implémenté.
- Si ce document contredit l'implémentation actuelle, l'implémentation actuelle gagne.
## Objectifs
- Garder \`App.tsx\` petit et centré sur l'orchestration.
- Séparer le code de production du monde des chemins runtime uniquement debug.
- Garder une source de vérité claire par responsabilité.
- Faire grandir les systèmes gameplay progressivement, sans préconstruire une architecture vide.
## Couches prévues
### Couche App
- \`App.tsx\` monte la scène canvas et les overlays HTML de premier niveau.
- Il doit rester fin et éviter la logique gameplay.
### Couche World
- \`src/world/\` doit contenir la composition de scène de production et les objets de scène de production.
- Responsabilités attendues :
- composition du monde
- carte, environnement, éclairage
- contrôleur joueur
- ancres d'interaction de production
- post-processing de production si nécessaire
### Couche Debug
- Les scènes et outils uniquement debug doivent être isolés du chemin de production.
- Responsabilités attendues :
- \`lil-gui\`
- overlay de performance
- helpers de scène
- caméra libre et contrôles de calibration
- scènes temporaires de test utilisées pendant le développement
### Couche UI
- \`src/components/ui/\` doit contenir les overlays HTML visibles par le joueur.
- Exemples futurs :
- crosshair
- flow de chargement
- HUD de mission
- overlays narratifs
### Couche Gameplay
- À mesure que le projet grandit, l'état gameplay peut évoluer vers une couche d'orchestration plus claire.
- Sujets probables :
- missions
- zones
- cinématiques
- dialogues
- audio
- interactions
## Règles
- Préférer du code direct et fonctionnel plutôt qu'un échafaudage spéculatif.
- Les types partagés doivent rester proches de leur domaine jusqu'à avoir plusieurs vrais consommateurs.
- Éviter de créer de nouveaux managers ou services sans besoin runtime actif.
- Les chemins runtime uniquement debug doivent être clairement marqués et faciles à retirer plus tard.
`;
export const zustandFr = `# État de jeu Zustand
Ce document explique comment Zustand est utilisé dans le projet actuel.
## Pourquoi Zustand existe ici
Le projet a besoin d'une source de vérité partagée pour suivre la progression du joueur dans l'expérience.
La progression actuelle est découpée en main states :
| Main state | Rôle |
| --- | --- |
| \`intro\` | Onboarding et séquence d'ouverture |
| \`bike\` | Séquence de réparation du vélo électrique |
| \`pylone\` | Séquence du réseau électrique |
| \`ferme\` | Séquence de la ferme verticale |
| \`outro\` | Séquence de fin |
Chaque main state peut aussi posséder un sous-état plus fin, comme l'étape de mission courante, l'audio de dialogue ou des flags de complétion.
Zustand est utile parce que les composants React et React Three Fiber peuvent s'abonner uniquement à la partie de state dont ils ont besoin. Quand cette partie change, seuls les composants abonnés se mettent à jour.
## Emplacement du store
Le store de progression du jeu vit ici :
\`\`\`txt
src/managers/stores/useGameStore.ts
\`\`\`
Le store est placé dans \`src/managers/stores/\` parce qu'il appartient à la couche d'orchestration gameplay, pas à un composant visuel précis.
## Managers vs Store
Les managers sont responsables des objets runtime locaux et des comportements impératifs.
Exemples :
- \`AudioManager\` possède les éléments audio et les pools de sons.
- \`InteractionManager\` possède les handles d'interaction transitoires et la logique orientée input.
Un manager peut lire ou mettre à jour le store Zustand quand son comportement local doit impacter la progression globale du jeu.
Le store Zustand est responsable de l'état global durable :
- main state courant
- sous-état de mission
- flags de progression
- références de dialogue/audio
- transitions de state
Règle simple :
- manager = objets runtime, effets de bord et logique impérative locale
- store = état gameplay global auquel l'UI ou le world peuvent s'abonner
## Forme actuelle
Le store expose :
- \`mainState\` : phase active du jeu
- \`missionFlow\` : état prototype de l'intro et de la mission 2
- \`intro\` : état spécifique à l'intro
- \`bike\` : état de la mission vélo
- \`pylone\` : état de la mission réseau électrique
- \`ferme\` : état de la mission ferme
- \`outro\` : état de fin
- des actions de mise à jour directe et des actions de progression
Le slice \`missionFlow\` contient l'étape prototype, le prénom joueur, le lock de déplacement, le flag d'activité de la ville et le message de dialogue temporaire. Il vit dans le store principal parce qu'il s'agit d'un état gameplay global utilisé par l'UI, le world et le controller joueur.
Les étapes de mission utilisent actuellement cette séquence :
\`\`\`ts
"locked" | "waiting" | "inspected" | "fragmented" | "scanning" | "repairing" | "reassembling" | "done"
\`\`\`
## Lire le state dans un composant
Utilise des selectors pour lire uniquement ce dont le composant a besoin.
\`\`\`tsx
import { useGameStore } from "@/managers/stores/useGameStore";
export function Example(): React.JSX.Element {
const mainState = useGameStore((state) => state.mainState);
return <p>State courant : {mainState}</p>;
}
\`\`\`
C'est mieux que de lire tout le store, car le composant se re-render uniquement quand \`mainState\` change.
## Mettre à jour le state
Préfère les actions explicites du store.
\`\`\`ts
const advanceGameState = useGameStore((state) => state.advanceGameState);
advanceGameState();
\`\`\`
Pour le développement et le debug, des setters directs existent aussi :
\`\`\`ts
const setMainState = useGameStore((state) => state.setMainState);
setMainState("bike");
\`\`\`
Les setters directs sont pratiques pour les panneaux debug, mais le gameplay de production devrait préférer les actions métier comme \`advanceGameState\`, \`completeBike\` ou \`completePylone\`.
Le gameplay de mission qui peut cibler \`bike\`, \`pylone\` ou \`ferme\` doit préférer les actions génériques de mission :
\`\`\`ts
const setMissionStep = useGameStore((state) => state.setMissionStep);
const completeMission = useGameStore((state) => state.completeMission);
setMissionStep("bike", "inspected");
completeMission("bike");
\`\`\`
Cela évite aux composants gameplay réutilisables, comme les flows de réparation, de dupliquer des branches spécifiques à chaque mission avec \`setBikeState\`, \`setPyloneState\` et \`setFermeState\`.
## Intégration avec le World
\`src/world/GameStageContent.tsx\` s'abonne à \`mainState\` et monte le contenu spécifique au state courant.
Pour les missions de réparation, il monte le composant réutilisable \`RepairGame\` avec un id de mission :
\`\`\`tsx
<RepairGame mission="bike" position={[8, 0, -6]} />
\`\`\`
\`RepairGame\` lit l'étape de mission active depuis le store et écrit les transitions via des actions génériques comme \`setMissionStep\` et \`completeMission\`. Les ids de mission, étapes de mission et guards partagés vivent dans \`src/types/gameplay/repairMission.ts\`, ce qui évite à la configuration statique des missions de dépendre du store Zustand. Le flow de réparation de production supporte actuellement les transitions \`waiting -> inspected -> fragmented -> scanning -> repairing -> reassembling -> done -> next mission\`.
Le flow prototype intro et mission 2 est documenté séparément dans \`docs/technical/mission-flow.md\`. Il utilise volontairement la même source de vérité \`useGameStore\`, sans \`GameStepManager\` dédié ni second store Zustand.
La scène peut donc évoluer progressivement vers ce pattern :
\`\`\`tsx
switch (mainState) {
case "intro":
return <IntroContent />;
case "bike":
return <BikeContent />;
case "pylone":
return <PyloneContent />;
case "ferme":
return <FarmContent />;
case "outro":
return <OutroContent />;
}
\`\`\`
Dans React Three Fiber, monter ou démonter du JSX contrôle ce qui apparaît dans la scène Three.js. Quand un composant lié à un state disparaît du JSX, React le retire de la scène.
## Intégration UI
\`src/components/ui/GameUI.tsx\` regroupe les overlays HTML utilisés par la route jouable.
Overlays actuels :
- \`DebugOverlayLayout\` : layout compact des panels debug HTML visible avec \`?debug\`
- \`GameStateDebugPanel\` : panneau de progression debug pour consulter/changer le main state, le sub state, avancer/reculer et reset le store
- \`Crosshair\` : aide de visée joueur
- \`InteractPrompt\` : prompt d'interaction
- \`RepairMovementLockIndicator\` : indicateur joueur affiché quand les étapes repair désactivent temporairement le déplacement
- Les overlays du flow mission comme \`IntroUI\`, \`BienvenueDisplay\` et \`DialogMessage\` sont montés par \`src/pages/page.tsx\`, car ce sont des overlays HTML de route plutôt qu'un HUD de jeu persistant.
\`src/pages/page.tsx\` doit rester fin et monter le canvas, le \`GameUI\` persistant et les overlays de route.
## Règles anti-régression
- Ne pas stocker les valeurs mises à jour à chaque frame dans Zustand.
- Utiliser \`useRef\` pour les valeurs mutables fréquentes comme la vélocité joueur, les vecteurs temporaires ou les données de boucle d'animation.
- Utiliser des selectors au lieu de lire tout le store dans les composants.
- Garder les transitions gameplay dans les actions du store quand possible.
- Garder les contrôles debug derrière \`?debug\`.
- Ajouter du state uniquement quand une vraie fonctionnalité runtime en a besoin.
## Prochaines étapes
Déplacer la validation de réparation dans les données de mission lorsque chaque mission aura ses propres nodes de modules cassés, assets de remplacement et événements de complétion.
`;
export const missionFlowFr = `# Flow de mission
Ce document décrit le flow prototype d'intro et de mission 2 après son intégration dans l'architecture actuelle.
@@ -470,7 +14,6 @@ Le store possède le slice \`missionFlow\` :
\`\`\`ts
missionFlow: {
step: GameStep;
activityCity: boolean;
playerName: string;
canMove: boolean;
@@ -487,14 +30,13 @@ Les managers restent responsables de services runtime locaux :
- \`AudioManager\` possède les éléments audio, les pools audio, la musique, le volume par catégorie et le pan stéréo.
- \`InteractionManager\` possède les handles d'interaction transitoires, focus, nearby et held.
La progression de mission n'est pas possédée par un manager. Les composants mettent à jour le store via des actions explicites comme \`setFlowStep\`, \`setCanMove\`, \`showDialog\` et \`hideDialog\`.
La progression de mission n'est pas possédée par un manager. Les composants mettent à jour le store via des actions explicites comme \`setCanMove\`, \`showDialog\` et \`hideDialog\`.
## Composants runtime
- \`src/components/game/GameFlow.tsx\` réagit à \`missionFlow.step\` et déclenche les effets ponctuels comme l'audio d'intro et le déblocage du mouvement.
- \`src/components/game/GameFlow.tsx\` réagit au store et déclenche les effets ponctuels comme l'audio d'intro et le déblocage du mouvement.
- \`src/components/zone/ZoneDetection.tsx\` lit la position caméra et fait passer le flow à une étape cible quand le joueur entre dans une zone configurée.
- \`src/components/three/interaction/CentralObject.tsx\` et \`VillageoisHelperObject.tsx\` exposent les objets interactifs temporaires de mission.
- \`src/pages/page.tsx\` monte les overlays HTML de mission : \`IntroUI\`, \`BienvenueDisplay\` et \`DialogMessage\`.
- \`src/pages/page.tsx\` monte les overlays HTML de mission : \`IntroUI\`, \`DialogMessage\` et \`Subtitles\`.
- \`src/world/player/PlayerController.tsx\` lit \`missionFlow.canMove\` comme lock de déplacement supplémentaire.
## Séquence d'étapes
@@ -525,251 +67,3 @@ Chaque zone possède un id, une position, un rayon, une hauteur et un \`targetSt
- Garder les effets de bord comme l'audio dans les composants ou les managers de service, mais garder la transition d'état dans le store.
- Ne pas mettre les valeurs par-frame comme la position caméra ou les distances de zones dans Zustand.
`;
export const featuresFr = `# Fonctionnalités implémentées
Ce document liste les fonctionnalités présentes dans le code actuel.
## Scène
- Scène React Three Fiber plein écran
- Carte principale chargée depuis \`public/models/{name}/model.glb\`, avec fallback vers \`model.gltf\`
- Scène de test physique debug sélectionnable depuis le panneau debug, avec tests grab/trigger, preview de modèle animé et zones playground de réparation séparées pour \`bike\`, \`pylone\` et \`ferme\`
- Contexte physique Rapier disponible pour les objets gameplay de stage en production
- Éclairage ambiant et directionnel
- Configuration de l'environnement de fond
## Joueur
- Mode caméra joueur
- Orientation souris avec pointer lock
- Déplacement avec \`ZQSD\`
- Saut
- Verrouillage du déplacement pendant les étapes repair actives, avec indicateur à l'écran tout en gardant les interactions trigger disponibles
- Collision basée sur une octree contre la carte chargée
## Interactions
- Détection de focus par distance et raycast
- Interactions trigger activées avec \`E\`
- Interactions grab activées avec le bouton principal de la souris
- Les objets gameplay avec physique peuvent être montés dans le contenu de stage sans remplacer la collision octree du joueur
- Prompt d'interaction affiché pour les interactions trigger
## Gameplay de réparation
- \`RepairGame\` de production réutilisable monté pour les états de mission \`bike\`, \`pylone\` et \`ferme\`
- Le playground physics debug monte le même \`RepairGame\` réutilisable dans des zones \`Bike\`, \`Pylone\` et \`Farm\`, afin de peaufiner chaque state avec un placement isolé avant déplacement vers la carte de production
- Configuration de mission partagée via \`src/data/gameplay/repairMissions.ts\`, avec nodes cassés, placeholders cibles, timing de scan et timing de réassemblage propres à chaque mission
- Flow repair-game avec \`waiting -> inspected -> fragmented -> scanning -> repairing -> reassembling -> done -> next mission\`, prompts \`.webm\`, apparition/ouverture/sortie de la mallette, vue focalisée de la mallette, indicateur de verrouillage de déplacement pendant la réparation active, interaction trigger sur la mallette, traverse des placeholders de mallette, placement avec snap vers placeholder, feedback de dépôt des pièces cassées, touche \`E\`, hold deux poings, transition de modèle explosé, réassemblage inverse avec particules, scan visuel par pièce, marqueur rouge persistant et vidéo UI centrée sur les pièces cassées, plusieurs choix de pièces grabbables, feedback de validation de la bonne pièce et complétion de mission
## Audio
- Volumes par catégorie pour la musique, les SFX et les dialogues
- Lecture de musique en boucle via \`AudioManager\`
- Lecture de sons one-shot pour les SFX et les dialogues, avec pool simple par son
- Pan stéréo optionnel pour les sons one-shot
## Dialogues et sous-titres
- Manifeste de dialogues dans \`public/sounds/dialogue/dialogues.json\`
- Audios de dialogue chargés depuis \`public/sounds/dialogue/\`
- Un fichier SRT par voix et par langue
- Fallback vers les sous-titres français quand le fichier de langue sélectionné manque
- Overlay de sous-titres runtime avec couleurs par speaker
- Déclenchement timecodé pour les dialogues qui définissent \`timecode\`
- File d'attente pour éviter les dialogues superposés
## Cinématiques
- Manifeste de cinématiques dans \`public/cinematics.json\`
- Déclenchement timecodé des cinématiques
- Lecture de keyframes caméra via GSAP
- Dialogue cues optionnelles synchronisées avec les timelines de cinématique
- Blocage des inputs joueur pendant une cinématique
## Menu options
- \`Esc\` ouvre et ferme le menu options en jeu
- Sliders de volume musique, SFX et dialogue
- Toggle d'affichage des sous-titres
- Choix de langue des sous-titres entre français et anglais
- Choix du runtime de réparation entre JavaScript local et serveur Python
- Action quitter qui nettoie les cookies accessibles au navigateur et retourne vers \`/\`
## Outils debug
- Le paramètre \`?debug\` active le panneau debug
- Contrôles \`lil-gui\` pour le mode caméra, le mode scène, \`R3F Perf\`, \`Debug Overlay\` et le tuning d'interaction
- Overlay debug compact pour les contrôles de game state et le statut hand tracking
- Le changement de mission dans le panneau game-state debug déverrouille les missions repair encore \`locked\` à \`waiting\` pour accélérer les tests
- Helpers de scène debug
- Caméra libre debug
- Overlay \`r3f-perf\`
## Éditeur de carte
- Route \`/editor\` pour inspecter et éditer \`public/map.json\`
- Chargement automatique de \`public/map.json\` quand il existe
- Rendu des modèles disponibles depuis \`public/models/{name}/model.glb\` ou \`model.gltf\`
- Cubes de fallback pour les nodes dont le modèle manque
- Sélection d'objet au clic
- Modes de transformation translation, rotation et scale
- Export JSON pour télécharger la carte modifiée
- Endpoint de sauvegarde dev-server pour écrire \`public/map.json\`
- Éditeur SRT pour les sous-titres de dialogue
- Preview audio et outils de timing pour les cues SRT
- Endpoint de sauvegarde dev-server pour les fichiers SRT
- Validation du manifeste de dialogues depuis l'UI de l'éditeur
- Éditeur de manifeste dialogues avec preview et création assistée de cue SRT FR
- Éditeur de manifeste cinématiques avec keyframes caméra, dialogue cues et preview canvas
## Pas encore implémenté
- système de missions complet
- système de zones
- branches de dialogues gameplay au-delà des déclencheurs prototype actuels
- flow de chargement
- minimap et HUD de mission
- séparation complète production / debug pour les scènes gameplay
`;
export const editorFr = `# Éditeur de carte
L'éditeur de carte est disponible sur "/editor". Il permet d'inspecter et d'ajuster les objets déclarés dans "/public/map.json" directement depuis le navigateur.
## Ce qui est édité
L'éditeur travaille sur la liste de nodes stockée dans "/public/map.json".
Chaque node décrit un objet de la scène :
- "name" : nom du dossier modèle dans "/public/models/{name}/model.glb", avec fallback vers "model.gltf"
- "type" : catégorie de l'objet
- "position" : "[x, y, z]"
- "rotation" : "[x, y, z]"
- "scale" : "[x, y, z]"
Les modèles sont chargés depuis "/public/models". Si un modèle manque, l'éditeur affiche un cube gris de remplacement pour que le node reste sélectionnable et déplaçable.
## Workflow de base
1. Ouvrir "/editor".
2. Sélectionner un objet dans la vue 3D.
3. Choisir un mode de transformation : translation, rotation ou scale.
4. Déplacer la gizmo de transformation.
5. Utiliser undo ou redo si nécessaire.
6. Exporter le JSON mis à jour ou le sauvegarder sur le serveur de dev.
## Contrôles
| Action | Input |
| --- | --- |
| Sélectionner un objet | Clic sur l'objet |
| Désélectionner | "Esc" ou clic dans le vide |
| Mode translation | "T" |
| Mode rotation | "R" |
| Mode scale | "S" |
| Undo | "Ctrl+Z" |
| Redo | "Ctrl+Y" |
| Déplacement en vue verrouillée | "WASD", "ZQSD", flèches |
| Monter / descendre | "Space", "Shift" |
## Actions fichier
### Export JSON
"Export JSON" télécharge la liste actuelle des nodes sous le nom "map.json". À utiliser pour remplacer manuellement "/public/map.json".
### Save to server
"Save to server" est disponible uniquement en développement local. L'action écrit la carte modifiée dans "/public/map.json" via l'endpoint du serveur de dev Vite.
Cette action est masquée dans les builds de production car il n'existe pas encore d'API de persistance production.
## Éditer les dialogues et sous-titres
Le panneau latéral contient aussi des outils pour les dialogues et les sous-titres.
### Manifeste dialogues
Le panneau \`Dialogues\` permet d'éditer \`public/sounds/dialogue/dialogues.json\` sans ouvrir le JSON à la main.
- \`Reload\` recharge le manifeste depuis le disque.
- \`Add\` crée un dialogue local pour la voix courante et assigne le prochain index SRT disponible.
- \`Save\` écrit le manifeste via le serveur Vite local.
- \`Preview dialogue\` joue le dialogue sélectionné avec les sous-titres dans l'éditeur.
- \`Create FR SRT cue\` crée la cue française si elle manque.
- \`Delete dialogue\` supprime localement l'entrée sélectionnée.
Après \`Add\`, il faut cliquer \`Save\` pour conserver le dialogue dans le manifeste. La cue SRT FR est écrite directement, mais le manifeste reste local tant qu'il n'est pas sauvegardé.
Les nouveaux dialogues utilisent un chemin audio placeholder comme \`/sounds/dialogue/new_dialogue_24.mp3\`. Remplace-le par un vrai MP3 avant validation finale.
### Éditeur SRT
1. Choisir une voix : \`narrateur\`, \`fermier\` ou \`electricienne\`.
2. Choisir une langue : \`FR\` ou \`EN\`.
3. Modifier le texte SRT directement dans la textarea.
4. Utiliser la preview audio pour vérifier le dialogue sélectionné.
5. Utiliser \`Set start\`, \`Set end\`, \`-100ms\` et \`+100ms\` pour ajuster le timing de la cue sélectionnée avec l'audio.
6. Utiliser \`Save SRT\` en développement local, ou \`Export SRT\` pour télécharger le fichier manuellement.
Chaque fichier SRT appartient à une voix, pas à un dialogue. Les indexes de cue doivent correspondre aux valeurs \`subtitleCueIndex\` référencées par le manifeste de dialogues.
## Valider les assets de dialogue
Utilise \`Validate\` dans le panneau SRT pour vérifier le manifeste et les assets liés.
La validation vérifie :
- \`public/sounds/dialogue/dialogues.json\`
- les fichiers audio de dialogue référencés
- les fichiers SRT français
- les indexes de cue référencés par le manifeste
Les fichiers SRT anglais manquants sont des warnings parce que le runtime retombe sur les sous-titres français.
## Éditer les cinématiques
Le panneau \`Cinematics\` permet d'éditer \`public/cinematics.json\`.
Chaque cinématique contient :
- un \`id\`
- un \`timecode\` global optionnel
- au moins deux keyframes caméra
- des dialogue cues optionnelles synchronisées avec la timeline
Les keyframes caméra définissent un temps relatif, une position caméra et une cible de regard. Les dialogue cues définissent un temps relatif et un \`dialogueId\` issu de \`dialogues.json\`.
Actions disponibles :
- \`Reload\` recharge le manifeste.
- \`Add\` crée une cinématique locale avec deux keyframes.
- \`Save\` écrit \`public/cinematics.json\` via le serveur Vite local.
- \`Preview cinematic\` joue l'animation caméra dans le canvas éditeur.
- \`Add keyframe\` et \`Remove\` modifient le chemin caméra.
- \`Add dialogue\` et \`Remove\` modifient les dialogues synchronisés.
- \`Delete cinematic\` supprime localement la cinématique sélectionnée.
Les dialogue cues sont la manière recommandée de synchroniser un dialogue avec une cinématique. Évite de donner aussi un \`timecode\` global au même dialogue dans \`dialogues.json\`, sinon il peut être lancé deux fois.
## Inspecteur JSON
Le panneau latéral affiche le JSON brut de la carte :
- sans sélection, il affiche toute la liste des nodes
- avec un objet sélectionné, il met en évidence les lignes du node sélectionné
Utilise-le pour vérifier les valeurs numériques exactes avant export ou sauvegarde.
## Limites actuelles
- L'éditeur modifie uniquement les nodes existants.
- Il n'y a pas encore d'interface pour créer ou supprimer des objets.
- La sauvegarde production n'est pas implémentée.
- Les modèles manquants s'affichent comme cubes de fallback au lieu de bloquer tout l'éditeur.
- La sauvegarde SRT est un helper local du serveur Vite, pas une API backend de production.
- Les sauvegardes dialogues et cinématiques sont aussi des helpers locaux du serveur Vite.
`;
+33
View File
@@ -0,0 +1,33 @@
import type { GameStep, MainGameState } from "@/types/game";
export const GAME_STEPS: readonly GameStep[] = [
"intro",
"start-intro",
"naming",
"bienvenue",
"star-move",
"mission2",
"searching",
"helped",
"manipulation",
"outOfFabrik",
];
export const MAIN_GAME_STATES: readonly MainGameState[] = [
"intro",
"ebike",
"pylon",
"farm",
"outro",
] as const;
const GAME_STEP_VALUES: ReadonlySet<string> = new Set(GAME_STEPS);
const MAIN_GAME_STATE_VALUES: ReadonlySet<string> = new Set(MAIN_GAME_STATES);
export function isGameStep(value: unknown): value is GameStep {
return typeof value === "string" && GAME_STEP_VALUES.has(value);
}
export function isMainGameState(value: unknown): value is MainGameState {
return typeof value === "string" && MAIN_GAME_STATE_VALUES.has(value);
}
+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,
};
+39
View File
@@ -0,0 +1,39 @@
import type { Vector3Tuple } from "@/types/three/three";
import type {
RepairMissionId,
RepairMissionTriggerConfig,
} from "@/types/gameplay/repairMission";
export const REPAIR_MISSION_ANCHOR_IDS: Partial<
Record<RepairMissionId, string>
> = {
pylon: "repair:pylon",
};
const EBIKE_REPAIR_POSITION = [
42.2399, 4.5484, 34.6468,
] as const satisfies Vector3Tuple;
const REPAIR_MISSION_POSITIONS = {
ebike: EBIKE_REPAIR_POSITION,
pylon: [64, 0, -66],
farm: [-24, 0, 42],
} as const satisfies Record<RepairMissionId, Vector3Tuple>;
export const REPAIR_MISSION_TRIGGERS = [
{
mission: "ebike",
label: "Réparer l'e-bike",
radius: 4,
},
] as const satisfies readonly RepairMissionTriggerConfig[];
export const REPAIR_MISSION_POSITION_ENTRIES = Object.entries(
REPAIR_MISSION_POSITIONS,
).map(([mission, position]) => ({
mission: mission as RepairMissionId,
position,
})) satisfies readonly {
mission: RepairMissionId;
position: Vector3Tuple;
}[];
+69
View File
@@ -0,0 +1,69 @@
import type {
MissionStep,
RepairMissionId,
} from "@/types/gameplay/repairMission";
import { REPAIR_MISSION_IDS } from "@/types/gameplay/repairMission";
const REPAIR_MISSION_ID_VALUES: ReadonlySet<string> = new Set(
REPAIR_MISSION_IDS,
);
export const MISSION_STEPS = [
"locked",
"waiting",
"inspected",
"fragmented",
"scanning",
"repairing",
"reassembling",
"done",
] as const satisfies readonly MissionStep[];
const MISSION_STEP_VALUES: ReadonlySet<string> = new Set(MISSION_STEPS);
export function isRepairMissionId(value: string): value is RepairMissionId {
return REPAIR_MISSION_ID_VALUES.has(value);
}
export function isMissionStep(value: string): value is MissionStep {
return MISSION_STEP_VALUES.has(value);
}
export function getNextMissionStep(step: MissionStep): MissionStep {
switch (step) {
case "locked":
return "waiting";
case "waiting":
return "inspected";
case "inspected":
return "fragmented";
case "fragmented":
return "scanning";
case "scanning":
return "repairing";
case "repairing":
return "reassembling";
case "reassembling":
case "done":
return "done";
}
}
export function getPreviousMissionStep(step: MissionStep): MissionStep {
switch (step) {
case "locked":
case "waiting":
return "locked";
case "inspected":
return "waiting";
case "fragmented":
return "inspected";
case "scanning":
return "fragmented";
case "repairing":
return "scanning";
case "reassembling":
return "repairing";
case "done":
return "reassembling";
}
}
+33 -65
View File
@@ -1,40 +1,8 @@
import type { RepairMissionId } from "@/types/gameplay/repairMission";
import type {
ModelTransformProps,
Vector3Scale,
Vector3Tuple,
} from "@/types/three/three";
export interface RepairMissionCaseConfig {
position: Vector3Tuple;
rotation: Vector3Tuple;
scale: Vector3Scale;
}
export interface RepairMissionPartConfig {
id: string;
label: string;
nodeName?: string;
placeholderName?: string;
modelPath?: string;
}
export interface RepairMissionConfig {
id: RepairMissionId;
label: string;
description: string;
modelPath: string;
modelScale?: ModelTransformProps["scale"];
stageUiPath: string;
interactUiPath: string;
brokenUiPath: string;
case: RepairMissionCaseConfig;
reassemblySeconds?: number;
requiredReplacementPartId: string;
scanPartSeconds?: number;
brokenParts: readonly RepairMissionPartConfig[];
replacementParts: readonly RepairMissionPartConfig[];
}
RepairMissionCaseConfig,
RepairMissionConfig,
RepairMissionId,
} from "@/types/gameplay/repairMission";
const REPAIR_INTERACT_UI_PATH = "/assets/UI/interagir.webm";
const REPAIR_BROKEN_UI_PATH = "/assets/UI/cassé.webm";
@@ -46,47 +14,47 @@ const DEFAULT_REPAIR_CASE = {
} satisfies RepairMissionCaseConfig;
export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
bike: {
id: "bike",
ebike: {
id: "ebike",
label: "E-bike",
description:
"Repair the damaged cooling module before relaunching the bike",
modelPath: "/models/ebike/model.gltf",
modelScale: 0.5,
modelScale: 0.3,
stageUiPath: "/assets/UI/ebike.webm",
interactUiPath: REPAIR_INTERACT_UI_PATH,
brokenUiPath: REPAIR_BROKEN_UI_PATH,
case: DEFAULT_REPAIR_CASE,
requiredReplacementPartId: "bike-cooling-core-replacement",
requiredReplacementPartId: "ebike-cooling-core-replacement",
brokenParts: [
{
id: "bike-cooling-core",
id: "ebike-cooling-core",
label: "Cooling core",
modelPath: "/models/refroidisseur/model.gltf",
nodeName: "refroidisseur",
placeholderName: "placeholder_1",
caseSlotName: "placeholder_1",
},
],
replacementParts: [
{
id: "bike-cooling-core-replacement",
id: "ebike-cooling-core-replacement",
label: "Replacement cooling core",
modelPath: "/models/refroidisseur/model.gltf",
},
{
id: "bike-radio-decoy",
id: "ebike-radio-distractor",
label: "Radio module",
modelPath: "/models/talkie/model.gltf",
},
{
id: "bike-glove-decoy",
id: "ebike-glove-distractor",
label: "Insulation glove",
modelPath: "/models/gant_l/model.gltf",
},
],
},
pylone: {
id: "pylone",
pylon: {
id: "pylon",
label: "Power pylon",
description:
"Restore the pylon lamp relay and damaged panel before reconnecting the grid",
@@ -96,42 +64,42 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
brokenUiPath: REPAIR_BROKEN_UI_PATH,
case: DEFAULT_REPAIR_CASE,
reassemblySeconds: 1.8,
requiredReplacementPartId: "pylone-grid-relay-replacement",
requiredReplacementPartId: "pylon-grid-relay-replacement",
scanPartSeconds: 1.4,
brokenParts: [
{
id: "pylone-grid-relay",
id: "pylon-grid-relay",
label: "Grid relay",
nodeName: "lampe",
placeholderName: "placeholder_1",
caseSlotName: "placeholder_1",
},
{
id: "pylone-damaged-panel",
id: "pylon-damaged-panel",
label: "Damaged solar panel",
nodeName: "panneau2",
placeholderName: "placeholder_2",
caseSlotName: "placeholder_2",
},
],
replacementParts: [
{
id: "pylone-grid-relay-replacement",
id: "pylon-grid-relay-replacement",
label: "Replacement grid relay",
modelPath: "/models/pylone/model.gltf",
},
{
id: "pylone-stone-decoy",
id: "pylon-stone-distractor",
label: "Stone counterweight",
modelPath: "/models/galet/model.gltf",
},
{
id: "pylone-cooling-decoy",
id: "pylon-cooling-distractor",
label: "Cooling core",
modelPath: "/models/refroidisseur/model.gltf",
},
],
},
ferme: {
id: "ferme",
farm: {
id: "farm",
label: "Vertical farm",
description:
"Stabilize the irrigation loop and humidity sensor before restarting the farm",
@@ -141,33 +109,33 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
brokenUiPath: REPAIR_BROKEN_UI_PATH,
case: DEFAULT_REPAIR_CASE,
reassemblySeconds: 1.2,
requiredReplacementPartId: "ferme-irrigation-pump-replacement",
requiredReplacementPartId: "farm-irrigation-pump-replacement",
scanPartSeconds: 0.9,
brokenParts: [
{
id: "ferme-irrigation-pump",
id: "farm-irrigation-pump",
label: "Irrigation pump",
placeholderName: "placeholder_1",
caseSlotName: "placeholder_1",
},
{
id: "ferme-humidity-sensor",
id: "farm-humidity-sensor",
label: "Humidity sensor",
placeholderName: "placeholder_2",
caseSlotName: "placeholder_2",
},
],
replacementParts: [
{
id: "ferme-irrigation-pump-replacement",
id: "farm-irrigation-pump-replacement",
label: "Replacement irrigation pump",
modelPath: "/models/fermeverticale/model.gltf",
},
{
id: "ferme-tree-decoy",
id: "farm-tree-distractor",
label: "Tree sensor",
modelPath: "/models/sapin/model.gltf",
},
{
id: "ferme-radio-decoy",
id: "farm-radio-distractor",
label: "Radio module",
modelPath: "/models/talkie/model.gltf",
},
-15
View File
@@ -1,6 +1,3 @@
const HAND_TRACKING_LOCAL_WS_URL = "ws://localhost:8000/ws";
const HAND_TRACKING_PROD_WS_URL = "wss://handtracking.la-fabrik.fr/ws";
export const HAND_TRACKING_FRAME_WIDTH = 320;
export const HAND_TRACKING_FRAME_HEIGHT = 240;
export const HAND_TRACKING_TARGET_FPS = 10;
@@ -11,15 +8,3 @@ export const HAND_TRACKING_BROWSER_WASM_URL =
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.35/wasm";
export const HAND_TRACKING_BROWSER_MODEL_URL =
"https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task";
export function getHandTrackingWsUrl(): string {
const configuredUrl = import.meta.env.VITE_HAND_TRACKING_WS_URL;
if (configuredUrl) {
return configuredUrl;
}
return import.meta.env.DEV
? HAND_TRACKING_LOCAL_WS_URL
: HAND_TRACKING_PROD_WS_URL;
}
+3
View File
@@ -4,12 +4,15 @@ export const PLAYER_EYE_HEIGHT = 1.75;
export const PLAYER_CAPSULE_RADIUS = 0.35;
export const PLAYER_WALK_SPEED = 11;
export const PLAYER_EBIKE_SPEED = 25;
export const PLAYER_AIR_CONTROL_FACTOR = 0.35;
export const PLAYER_JUMP_SPEED = 9;
export const PLAYER_GRAVITY = 30;
export const PLAYER_MAX_DELTA = 0.05;
export const PLAYER_ACCELERATION_MULTIPLIER = 9;
export const PLAYER_XZ_DAMPING_FACTOR = 8;
export const PLAYER_FALL_RESPAWN_Y = -20;
export const PLAYER_FALL_RESPAWN_DELAY = 3;
export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [0, 50, 0];
export const PLAYER_SPAWN_POSITION_PHYSICS: Vector3Tuple = [0, 3, 0];
@@ -0,0 +1,53 @@
import type { Vector3Tuple } from "@/types/three/three";
export type CharacterId = "electricienne" | "gerant" | "fermier";
export interface CharacterConfig {
id: CharacterId;
label: string;
modelPath: string;
position: Vector3Tuple;
rotation: Vector3Tuple;
scale: Vector3Tuple;
animations: readonly string[];
defaultAnimation: string;
}
export const CHARACTER_CONFIGS = {
electricienne: {
id: "electricienne",
label: "Electricienne",
modelPath: "/models/electricienne-animated/model.gltf",
position: [-40.5, 0, 45.5],
rotation: [0, -0.35, 0],
scale: [1, 1, 1],
animations: ["Dance"],
defaultAnimation: "Dance",
},
gerant: {
id: "gerant",
label: "Gerant",
modelPath: "/models/gerant-animated/model.gltf",
position: [45.2, 0, 45.5],
rotation: [0, -1.55, 0],
scale: [1, 1, 1],
animations: ["idle", "walk"],
defaultAnimation: "idle",
},
fermier: {
id: "fermier",
label: "Fermier",
modelPath: "/models/fermier-animated/model.gltf",
position: [-6.5, 0, -69.5],
rotation: [0, -1.18, 0],
scale: [1, 1, 1],
animations: ["idle", "walk"],
defaultAnimation: "idle",
},
} satisfies Record<CharacterId, CharacterConfig>;
export const CHARACTER_IDS = [
"electricienne",
"gerant",
"fermier",
] as const satisfies readonly CharacterId[];
+7
View File
@@ -0,0 +1,7 @@
export const CHUNK_CONFIG = {
enabled: true,
chunkSize: 35,
loadRadius: 50,
unloadRadius: 65,
updateInterval: 250,
};
+34
View File
@@ -0,0 +1,34 @@
import type { Vector3Tuple } from "@/types/three/three";
export const CLOUD_CONFIG = {
enabled: true,
modelPath: "/models/cloud/model.glb",
center: [0, 40, 0] as Vector3Tuple,
areaSize: [240, 180] as const,
minDriftSpeed: 0.05,
wrapPadding: 30,
};
export const CLOUD_DEFAULTS = {
count: 10,
minHeight: 25,
maxHeight: 55,
minScale: 5,
maxScale: 13,
minRotation: 0,
maxRotation: Math.PI * 2,
minSpeedMultiplier: 0.4,
maxSpeedMultiplier: 1,
castShadow: false,
receiveShadow: false,
};
export const CLOUD_BOUNDS = {
count: { min: 0, max: 30, step: 1 },
height: { min: 10, max: 100, step: 1 },
scale: { min: 1, max: 30, step: 0.5 },
rotation: { min: -Math.PI * 2, max: Math.PI * 2, step: 0.1 },
speedMultiplier: { min: 0, max: 3, step: 0.1 },
};
export type CloudState = typeof CLOUD_DEFAULTS;
+5 -4
View File
@@ -1,5 +1,6 @@
export const GAME_SCENE_SKY_MODEL_PATH = "/models/skybox/skybox.gltf";
export const GAME_SCENE_FALLBACK_SKY_MODEL_PATH = "/models/sky/model.glb";
export const GAME_SCENE_SKY_MODEL_SCALE = 300;
export const GAME_SCENE_FALLBACK_SKY_MODEL_SCALE = 1;
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_FALLBACK_MODEL_SCALE = 1;
export const GAME_SCENE_FALLBACK_BACKGROUND_COLOR = "#0b1018";
export const PHYSICS_SCENE_BACKGROUND_COLOR = "#0b1018";
+28
View File
@@ -0,0 +1,28 @@
export type FogMode = "linear" | "exp2";
export const FOG_CONFIG = {
enabled: false,
mode: "exp2" as FogMode,
color: "#dfe7d8",
near: 32,
far: 48,
density: 0.032,
};
export const FOG_LIGHTING_COLOR_MIX = {
ambient: 0.3,
sun: 0.7,
};
export const FOG_BOUNDS = {
near: { min: 0, max: 100, step: 1 },
far: { min: 1, max: 160, step: 1 },
density: { min: 0.001, max: 0.05, step: 0.001 },
};
export interface FogState {
density: number;
far: number;
mode: FogMode;
near: number;
}
+10
View File
@@ -0,0 +1,10 @@
const GENERATED_MAP_MODEL_NAMES = new Set([
"ecole",
"fermeverticale",
"generateur",
"lafabrik",
]);
export function isGeneratedMapModelName(name: string): boolean {
return GENERATED_MAP_MODEL_NAMES.has(name);
}
+9
View File
@@ -0,0 +1,9 @@
export const GRAPHICS_DEFAULTS = {
dynamicGrass: true,
dynamicTrees: true,
dynamicClouds: true,
shadowsEnabled: true,
grassDensity: 1.0,
};
export type GraphicsState = typeof GRAPHICS_DEFAULTS;
+36
View File
@@ -0,0 +1,36 @@
export const GRASS_CONFIG = {
enabled: true,
patchSize: 30,
bladeCount: 32000,
bladeWidth: 0.08,
maxBladeHeight: 0.67,
randomHeightAmount: 0.25,
surfaceOffset: 0.025,
heightTextureSize: 128,
windNoiseScale: 0.9,
windStrength: 0.35,
baldPatchModifier: 1.1,
falloffSharpness: 0.35,
heightNoiseFrequency: 9,
heightNoiseAmplitude: 1,
clumpFrequency: 2.6,
clumpThreshold: 0.18,
clumpSoftness: 0.45,
zoneFrequency: 0.035,
noGrassZoneThreshold: 0.2,
sparseZoneThreshold: 0.4,
mediumZoneThreshold: 0.65,
zoneSoftness: 0.08,
noGrassZoneHeight: 0,
sparseZoneHeight: 0.08,
mediumZoneHeight: 0.45,
tallZoneHeight: 1,
noGrassZoneDensity: 0,
sparseZoneDensity: 0.08,
mediumZoneDensity: 0.72,
tallZoneDensity: 1,
maxBendAngle: 14,
} as const;
export const GRASS_COLORS = ["#84C66B", "#67B058", "#A3CA5B"] as const;
export const GRASS_BASE_COLOR = "#1A3A1A" as const;
+9 -7
View File
@@ -1,12 +1,14 @@
export const AMBIENT_LIGHT_COLOR = "#dbeafe";
export const SUN_LIGHT_COLOR = "#fff7ed";
const AMBIENT_LIGHT_COLOR = "#dfe7d8";
const SUN_LIGHT_COLOR = "#ffe2bf";
export const LIGHTING_DEFAULTS = {
ambientIntensity: 1.8,
sunIntensity: 2.8,
sunX: 60,
sunY: 80,
sunZ: 30,
ambientColor: AMBIENT_LIGHT_COLOR,
ambientIntensity: 0.9,
sunColor: SUN_LIGHT_COLOR,
sunIntensity: 2.2,
sunX: 70,
sunY: 45,
sunZ: 35,
};
export const AMBIENT_INTENSITY_MIN = 0;
+128
View File
@@ -0,0 +1,128 @@
export const MAP_INSTANCING_ASSETS = {
boiteauxlettres: {
mapName: "boiteauxlettres",
modelPath: "/models/boiteauxlettres/model.gltf",
scaleMultiplier: 2,
castShadow: true,
receiveShadow: true,
enabled: true,
},
pylone: {
mapName: "pylone",
modelPath: "/models/pylone/model.gltf",
scaleMultiplier: 1,
castShadow: true,
receiveShadow: true,
enabled: true,
},
immeuble1: {
mapName: "immeuble1",
modelPath: "/models/immeuble1/model.gltf",
scaleMultiplier: 1,
castShadow: true,
receiveShadow: true,
enabled: true,
},
maison1: {
mapName: "maison1",
modelPath: "/models/maison1/model.gltf",
scaleMultiplier: 3,
castShadow: true,
receiveShadow: true,
enabled: true,
},
eolienne: {
mapName: "eolienne",
modelPath: "/models/eolienne/model.gltf",
scaleMultiplier: 0.85,
castShadow: true,
receiveShadow: true,
enabled: true,
},
parcebike: {
mapName: "parcebike",
modelPath: "/models/parcebike/model.gltf",
scaleMultiplier: 2,
castShadow: true,
receiveShadow: true,
enabled: true,
},
panneauaffichage: {
mapName: "panneauaffichage",
modelPath: "/models/panneauaffichage/model.gltf",
scaleMultiplier: 1,
castShadow: true,
receiveShadow: true,
enabled: true,
},
panneauclassique: {
mapName: "panneauclassique",
modelPath: "/models/panneauclassique/model.gltf",
scaleMultiplier: 1,
castShadow: true,
receiveShadow: true,
enabled: true,
},
panneaufleche: {
mapName: "panneaufleche",
modelPath: "/models/panneaufleche/model.gltf",
scaleMultiplier: 1,
castShadow: true,
receiveShadow: true,
enabled: true,
},
panneausolaire: {
mapName: "panneausolaire",
modelPath: "/models/panneausolaire/model.gltf",
scaleMultiplier: 0.85,
castShadow: true,
receiveShadow: true,
enabled: true,
},
} as const;
const MAP_SINGLE_MODEL_SCALE_MULTIPLIERS = {
ebike: 0.3,
} as const satisfies Record<string, number>;
export function getMapSingleModelScaleMultiplier(name: string): number {
return (
MAP_SINGLE_MODEL_SCALE_MULTIPLIERS[
name as keyof typeof MAP_SINGLE_MODEL_SCALE_MULTIPLIERS
] ?? 1
);
}
function getMapInstancedModelScaleMultiplier(name: string): number {
return (
Object.values(MAP_INSTANCING_ASSETS).find(
(config) => config.mapName === name,
)?.scaleMultiplier ?? 1
);
}
export function getMapModelScaleMultiplier(name: string): number {
return (
getMapSingleModelScaleMultiplier(name) *
getMapInstancedModelScaleMultiplier(name)
);
}
export const MAP_INSTANCING_ASSET_TYPES = [
"boiteauxlettres",
"pylone",
"immeuble1",
"maison1",
"eolienne",
"parcebike",
"panneauaffichage",
"panneauclassique",
"panneaufleche",
"panneausolaire",
] as const satisfies readonly (keyof typeof MAP_INSTANCING_ASSETS)[];
export type MapInstancingAssetType =
(typeof MAP_INSTANCING_ASSET_TYPES)[number];
export type MapInstancingAssetConfig =
(typeof MAP_INSTANCING_ASSETS)[MapInstancingAssetType];
+100
View File
@@ -0,0 +1,100 @@
export type MapPerformanceGroupName =
| "vegetation"
| "crops"
| "trees"
| "buildings"
| "landmarks"
| "props"
| "terrain"
| "sky";
export type MapPerformanceModelName =
| "buisson"
| "arbre"
| "sapin"
| "champdeble"
| "champdesoja"
| "champsdetournesol"
| "potager"
| "ecole"
| "generateur"
| "fermeverticale"
| "lafabrik"
| "immeuble1"
| "eolienne"
| "pylone"
| "boiteauxlettres"
| "maison1"
| "panneauaffichage"
| "panneauclassique"
| "panneaufleche"
| "panneausolaire"
| "parcebike"
| "terrain"
| "sky";
export const MAP_PERFORMANCE_GROUP_NAMES: readonly MapPerformanceGroupName[] = [
"vegetation",
"crops",
"trees",
"buildings",
"landmarks",
"props",
"terrain",
"sky",
];
export const MAP_PERFORMANCE_MODEL_NAMES: readonly MapPerformanceModelName[] = [
"buisson",
"arbre",
"sapin",
"champdeble",
"champdesoja",
"champsdetournesol",
"potager",
"ecole",
"generateur",
"fermeverticale",
"lafabrik",
"immeuble1",
"eolienne",
"pylone",
"boiteauxlettres",
"maison1",
"panneauaffichage",
"panneauclassique",
"panneaufleche",
"panneausolaire",
"parcebike",
"terrain",
"sky",
];
export const MAP_PERFORMANCE_MODEL_GROUPS: Record<
MapPerformanceModelName,
readonly MapPerformanceGroupName[]
> = {
buisson: ["vegetation"],
arbre: ["vegetation", "trees"],
sapin: ["vegetation", "trees"],
champdeble: ["vegetation", "crops"],
champdesoja: ["vegetation", "crops"],
champsdetournesol: ["vegetation", "crops"],
potager: ["vegetation", "crops"],
ecole: ["buildings", "landmarks"],
generateur: ["landmarks"],
fermeverticale: ["buildings", "landmarks"],
lafabrik: ["buildings", "landmarks"],
immeuble1: ["buildings"],
eolienne: ["props"],
pylone: ["props"],
boiteauxlettres: ["props"],
maison1: ["buildings"],
panneauaffichage: ["props"],
panneauclassique: ["props"],
panneaufleche: ["props"],
panneausolaire: ["props"],
parcebike: ["props"],
terrain: ["terrain"],
sky: ["sky"],
};
+7
View File
@@ -0,0 +1,7 @@
import type { SceneLoadingState } from "@/types/world/sceneLoading";
export const INITIAL_SCENE_LOADING_STATE: SceneLoadingState = {
currentStep: "Initialisation du jeu",
progress: 0,
status: "loading",
};
+56
View File
@@ -0,0 +1,56 @@
import type { TerrainSurfaceColorConfig } from "@/types/world/terrainSurface";
export const TERRAIN_MODEL_PATH = "/models/terrain/model.gltf";
export const TERRAIN_WATER_HEIGHT = 0.8;
const TERRAIN_TILE_SIZE = 1;
export const TERRAIN_COLORS = {
grass1: {
hex: "#84C66B",
rgb: [132, 198, 107] as const,
kind: "grass",
grassTipColor: "#84C66B",
},
grass2: {
hex: "#67B058",
rgb: [103, 176, 88] as const,
kind: "grass",
grassTipColor: "#67B058",
},
grass3: {
hex: "#A3CA5B",
rgb: [163, 202, 91] as const,
kind: "grass",
grassTipColor: "#A3CA5B",
},
potager: {
hex: "#342420",
rgb: [52, 36, 32] as const,
kind: "garden",
modelPath: "/models/potager/potager.gltf",
tileSize: TERRAIN_TILE_SIZE,
},
terre: {
hex: "#513E2C",
rgb: [81, 62, 44] as const,
kind: "dirt",
},
chemin: {
hex: "#F5D896",
rgb: [245, 216, 150] as const,
kind: "path",
modelPath: "/models/chemins/model.gltf",
tileSize: TERRAIN_TILE_SIZE,
},
eau: {
hex: "#91DAF5",
rgb: [145, 218, 245] as const,
kind: "water",
},
cailloux: {
hex: "#B6D3DE",
rgb: [182, 211, 222] as const,
kind: "rock",
},
} satisfies Record<string, TerrainSurfaceColorConfig>;
+103
View File
@@ -0,0 +1,103 @@
export const VEGETATION_TYPES = {
buissons: {
mapName: "buisson",
modelPath: "/models/buisson/model.gltf",
scaleMultiplier: 1.5,
castShadow: true,
receiveShadow: true,
windStrength: 0.06,
rotationOffset: [0, 0, 0],
enabled: true,
},
sapin: {
mapName: "sapin",
modelPath: "/models/sapin/model.gltf",
scaleMultiplier: 4,
castShadow: true,
receiveShadow: true,
windStrength: 0.12,
rotationOffset: [0, 0, 0],
enabled: true,
},
arbre: {
mapName: "arbre",
modelPath: "/models/arbre/model.gltf",
scaleMultiplier: 1,
castShadow: true,
receiveShadow: true,
windStrength: 0.15,
rotationOffset: [0, 0, 0],
enabled: true,
},
champdeble: {
mapName: "champdeble",
modelPath: "/models/champdeble/model.gltf",
scaleMultiplier: 1,
castShadow: true,
receiveShadow: true,
windStrength: 0.15,
rotationOffset: [0, 0, 0],
enabled: true,
},
champdesoja: {
mapName: "champdesoja",
modelPath: "/models/champdesoja/model.gltf",
scaleMultiplier: 1,
castShadow: true,
receiveShadow: true,
windStrength: 0.15,
rotationOffset: [0, 0, 0],
enabled: true,
},
champsdetournesol: {
mapName: "champsdetournesol",
modelPath: "/models/champsdetournesol/model.gltf",
scaleMultiplier: 1,
castShadow: true,
receiveShadow: true,
windStrength: 0.15,
rotationOffset: [0, 0, 0],
enabled: true,
},
potager: {
mapName: "potager",
modelPath: "/models/potager/potager.gltf",
scaleMultiplier: 1,
castShadow: true,
receiveShadow: true,
windStrength: 0,
rotationOffset: [0, 0, 0],
enabled: true,
},
} as const;
export const VEGETATION_TYPE_KEYS = [
"buissons",
"sapin",
"arbre",
"champdeble",
"champdesoja",
"champsdetournesol",
"potager",
] as const satisfies readonly (keyof typeof VEGETATION_TYPES)[];
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 {
return (
Object.values(VEGETATION_TYPES).find((config) => config.mapName === name)
?.scaleMultiplier ?? 1
);
}
export const VEGETATION_INSTANCE_EXCLUDED_NODE_NAMES = new Set([
"Scene",
"blocking",
"terrain",
]);
+49
View File
@@ -0,0 +1,49 @@
import { TERRAIN_WATER_HEIGHT } from "@/data/world/terrainConfig";
import type { Vector3Tuple } from "@/types/three/three";
export interface WaterSurfaceConfig {
position: Vector3Tuple;
rotation: Vector3Tuple;
size: [number, number];
renderOrder: number;
}
export const WATER_SHADER_CONFIG = {
enabled: true,
height: TERRAIN_WATER_HEIGHT,
depthOffset: -0.04,
borderRadius: 0.18,
borderSoftness: 0.035,
scale: 0.4,
smoothness: 0.55,
edgeThreshold: 0.067,
edgeSoftness: 0.01,
flowX: 0,
flowZ: 0.05,
cellSpeed: 0.3,
noiseScale: 1.52,
noiseFlowSpeed: 0.2,
distortAmount: 0.3,
deepColor: "#1a3a5c",
midColor: "#59c0e8",
midPos: 0.084,
highlightColor: "#ffffff",
opacity: 0.88,
deepOpacity: 0.45,
};
export const WATER_STREAMING_CONFIG = {
enabled: true,
loadDistance: 40,
unloadDistance: 48,
updateInterval: 350,
};
export const WATER_SURFACES: WaterSurfaceConfig[] = [
{
position: [40, TERRAIN_WATER_HEIGHT, -102],
rotation: [0, 0, 0],
size: [75, 45],
renderOrder: 0,
},
];
+15
View File
@@ -0,0 +1,15 @@
export const WIND_DEFAULTS = {
speed: 0.8,
direction: 0.5584,
strength: 1.5,
noiseScale: 0.8,
};
export const WIND_BOUNDS = {
speed: { min: 0, max: 2, step: 0.1 },
direction: { min: -Math.PI, max: Math.PI, step: 0.1 },
strength: { min: 0, max: 3, step: 0.1 },
noiseScale: { min: 0.1, max: 5, step: 0.1 },
};
export type WindState = typeof WIND_DEFAULTS;
+13
View File
@@ -0,0 +1,13 @@
import { TERRAIN_COLORS } from "@/data/world/terrainConfig";
import type { Vector3Tuple } from "@/types/three/three";
export const WORLD_BOUNDS_CONFIG = {
enabled: true,
center: [0, 0, 0] as Vector3Tuple,
planeColor: TERRAIN_COLORS.grass1.hex,
planeY: -0.04,
planeCollisionThickness: 1,
size: [270, 260] as const,
wallHeight: 28,
wallThickness: 4,
};
@@ -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,
};
}
+108
View File
@@ -0,0 +1,108 @@
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
import {
CHARACTER_CONFIGS,
CHARACTER_IDS,
} from "@/data/world/characters/characterConfig";
import { useCharacterDebugStore } from "@/managers/stores/useCharacterDebugStore";
function createAnimationOptions(
animations: readonly string[],
): Record<string, string> {
if (animations.length === 0) {
return { None: "" };
}
return Object.fromEntries(
animations.map((animation) => [animation || "None", animation]),
);
}
export function useCharacterDebug(): void {
useDebugFolder("Personnages", (folder) => {
const store = useCharacterDebugStore.getState();
for (const id of CHARACTER_IDS) {
const config = CHARACTER_CONFIGS[id];
const state = store.characters[id];
const characterFolder = folder.addFolder(config.label);
const controls = {
animation: state.animation,
positionX: state.position[0],
positionY: state.position[1],
positionZ: state.position[2],
rotationX: state.rotation[0],
rotationY: state.rotation[1],
rotationZ: state.rotation[2],
scaleX: state.scale[0],
scaleY: state.scale[1],
scaleZ: state.scale[2],
};
characterFolder
.add(controls, "animation", createAnimationOptions(config.animations))
.name("Animation")
.onChange((animation: string) => {
useCharacterDebugStore.getState().setAnimation(id, animation);
});
characterFolder
.add(controls, "positionX", -120, 120, 0.1)
.name("Position X")
.onChange((value: number) => {
useCharacterDebugStore.getState().setPosition(id, 0, value);
});
characterFolder
.add(controls, "positionY", -20, 40, 0.1)
.name("Position Y")
.onChange((value: number) => {
useCharacterDebugStore.getState().setPosition(id, 1, value);
});
characterFolder
.add(controls, "positionZ", -120, 120, 0.1)
.name("Position Z")
.onChange((value: number) => {
useCharacterDebugStore.getState().setPosition(id, 2, value);
});
characterFolder
.add(controls, "rotationX", -Math.PI, Math.PI, 0.01)
.name("Rotation X")
.onChange((value: number) => {
useCharacterDebugStore.getState().setRotation(id, 0, value);
});
characterFolder
.add(controls, "rotationY", -Math.PI, Math.PI, 0.01)
.name("Rotation Y")
.onChange((value: number) => {
useCharacterDebugStore.getState().setRotation(id, 1, value);
});
characterFolder
.add(controls, "rotationZ", -Math.PI, Math.PI, 0.01)
.name("Rotation Z")
.onChange((value: number) => {
useCharacterDebugStore.getState().setRotation(id, 2, value);
});
characterFolder
.add(controls, "scaleX", 0.1, 5, 0.05)
.name("Scale X")
.onChange((value: number) => {
useCharacterDebugStore.getState().setScale(id, 0, value);
});
characterFolder
.add(controls, "scaleY", 0.1, 5, 0.05)
.name("Scale Y")
.onChange((value: number) => {
useCharacterDebugStore.getState().setScale(id, 1, value);
});
characterFolder
.add(controls, "scaleZ", 0.1, 5, 0.05)
.name("Scale Z")
.onChange((value: number) => {
useCharacterDebugStore.getState().setScale(id, 2, value);
});
characterFolder.close();
}
});
}
+190
View File
@@ -0,0 +1,190 @@
import { CLOUD_BOUNDS } from "@/data/world/cloudConfig";
import { FOG_BOUNDS, type FogMode } from "@/data/world/fogConfig";
import { WIND_BOUNDS } from "@/data/world/windConfig";
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
import { useWorldSettingsStore } from "@/managers/stores/useWorldSettingsStore";
import { Debug } from "@/utils/debug/Debug";
export function useEnvironmentDebug(): void {
useDebugFolder("Dynamic Wind", (folder) => {
const { setWind, wind } = useWorldSettingsStore.getState();
const controls = { ...wind };
folder
.add(controls, "speed", WIND_BOUNDS.speed.min, WIND_BOUNDS.speed.max)
.step(WIND_BOUNDS.speed.step)
.name("Wind speed")
.onChange((speed: number) => setWind({ speed }));
folder
.add(
controls,
"direction",
WIND_BOUNDS.direction.min,
WIND_BOUNDS.direction.max,
)
.step(WIND_BOUNDS.direction.step)
.name("Wind direction")
.onChange((direction: number) => setWind({ direction }));
folder
.add(
controls,
"strength",
WIND_BOUNDS.strength.min,
WIND_BOUNDS.strength.max,
)
.step(WIND_BOUNDS.strength.step)
.name("Wind strength")
.onChange((strength: number) => setWind({ strength }));
folder
.add(
controls,
"noiseScale",
WIND_BOUNDS.noiseScale.min,
WIND_BOUNDS.noiseScale.max,
)
.step(WIND_BOUNDS.noiseScale.step)
.name("Wind noise scale")
.onChange((noiseScale: number) => setWind({ noiseScale }));
});
useDebugFolder("Environment", (folder) => {
Debug.getInstance().addFogControl(folder);
const { clouds, fog, graphics, setClouds, setDynamicClouds, setFog } =
useWorldSettingsStore.getState();
const controls = {
...clouds,
...fog,
dynamicClouds: graphics.dynamicClouds,
};
folder
.add(controls, "mode", { Linear: "linear", Exp2: "exp2" })
.name("Fog mode")
.onChange((mode: FogMode) => setFog({ mode }));
folder
.add(controls, "near", FOG_BOUNDS.near.min, FOG_BOUNDS.near.max)
.step(FOG_BOUNDS.near.step)
.name("Fog near")
.onChange((near: number) => setFog({ near }));
folder
.add(controls, "far", FOG_BOUNDS.far.min, FOG_BOUNDS.far.max)
.step(FOG_BOUNDS.far.step)
.name("Fog far")
.onChange((far: number) => setFog({ far }));
folder
.add(controls, "density", FOG_BOUNDS.density.min, FOG_BOUNDS.density.max)
.step(FOG_BOUNDS.density.step)
.name("Fog density")
.onChange((density: number) => setFog({ density }));
folder
.add(controls, "dynamicClouds")
.name("Clouds")
.onChange((dynamicClouds: boolean) => setDynamicClouds(dynamicClouds));
folder
.add(controls, "count", CLOUD_BOUNDS.count.min, CLOUD_BOUNDS.count.max)
.step(CLOUD_BOUNDS.count.step)
.name("Cloud count")
.onChange((count: number) => setClouds({ count }));
folder
.add(controls, "minScale", CLOUD_BOUNDS.scale.min, CLOUD_BOUNDS.scale.max)
.step(CLOUD_BOUNDS.scale.step)
.name("Cloud min scale")
.onChange((minScale: number) => setClouds({ minScale }));
folder
.add(controls, "maxScale", CLOUD_BOUNDS.scale.min, CLOUD_BOUNDS.scale.max)
.step(CLOUD_BOUNDS.scale.step)
.name("Cloud max scale")
.onChange((maxScale: number) => setClouds({ maxScale }));
folder
.add(
controls,
"minRotation",
CLOUD_BOUNDS.rotation.min,
CLOUD_BOUNDS.rotation.max,
)
.step(CLOUD_BOUNDS.rotation.step)
.name("Cloud min rotation")
.onChange((minRotation: number) => setClouds({ minRotation }));
folder
.add(
controls,
"maxRotation",
CLOUD_BOUNDS.rotation.min,
CLOUD_BOUNDS.rotation.max,
)
.step(CLOUD_BOUNDS.rotation.step)
.name("Cloud max rotation")
.onChange((maxRotation: number) => setClouds({ maxRotation }));
folder
.add(
controls,
"minHeight",
CLOUD_BOUNDS.height.min,
CLOUD_BOUNDS.height.max,
)
.step(CLOUD_BOUNDS.height.step)
.name("Cloud min height")
.onChange((minHeight: number) => setClouds({ minHeight }));
folder
.add(
controls,
"maxHeight",
CLOUD_BOUNDS.height.min,
CLOUD_BOUNDS.height.max,
)
.step(CLOUD_BOUNDS.height.step)
.name("Cloud max height")
.onChange((maxHeight: number) => setClouds({ maxHeight }));
folder
.add(
controls,
"minSpeedMultiplier",
CLOUD_BOUNDS.speedMultiplier.min,
CLOUD_BOUNDS.speedMultiplier.max,
)
.step(CLOUD_BOUNDS.speedMultiplier.step)
.name("Cloud min speed")
.onChange((minSpeedMultiplier: number) =>
setClouds({ minSpeedMultiplier }),
);
folder
.add(
controls,
"maxSpeedMultiplier",
CLOUD_BOUNDS.speedMultiplier.min,
CLOUD_BOUNDS.speedMultiplier.max,
)
.step(CLOUD_BOUNDS.speedMultiplier.step)
.name("Cloud max speed")
.onChange((maxSpeedMultiplier: number) =>
setClouds({ maxSpeedMultiplier }),
);
folder
.add(controls, "castShadow")
.name("Cloud cast shadow")
.onChange((castShadow: boolean) => setClouds({ castShadow }));
folder
.add(controls, "receiveShadow")
.name("Cloud receive shadow")
.onChange((receiveShadow: boolean) => setClouds({ receiveShadow }));
});
}
+58
View File
@@ -0,0 +1,58 @@
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
import {
MAP_PERFORMANCE_GROUP_NAMES,
MAP_PERFORMANCE_MODEL_NAMES,
useMapPerformanceStore,
} from "@/managers/stores/useMapPerformanceStore";
function toLabel(value: string): string {
return value
.split(/[-_\s]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
export function useMapPerformanceDebug(): void {
useDebugFolder("Map", (folder) => {
const {
groups,
models,
setGroupVisible,
setModelVisible,
resetVisibility,
} = useMapPerformanceStore.getState();
const controls = {
...groups,
...models,
reset: () => {
resetVisibility();
for (const key of [
...MAP_PERFORMANCE_GROUP_NAMES,
...MAP_PERFORMANCE_MODEL_NAMES,
]) {
controls[key] = true;
}
folder.controllersRecursive().forEach((controller) => {
controller.updateDisplay();
});
},
};
for (const group of MAP_PERFORMANCE_GROUP_NAMES) {
folder
.add(controls, group)
.name(toLabel(group))
.onChange((visible: boolean) => setGroupVisible(group, visible));
}
for (const model of MAP_PERFORMANCE_MODEL_NAMES) {
folder
.add(controls, model)
.name(toLabel(model))
.onChange((visible: boolean) => setModelVisible(model, visible));
}
folder.add(controls, "reset").name("Reset visibility");
});
}
+74 -3
View File
@@ -1,13 +1,76 @@
import { useCallback, useRef, useState } from "react";
import type { MapNode, SceneData } from "@/types/editor/editor";
import type {
HierarchicalMapNode,
MapNode,
SceneData,
} from "@/types/editor/editor";
interface ObjectTransform {
uuid: string;
sourcePath?: number[];
position: { x: number; y: number; z: number };
rotation: { x: number; y: number; z: number };
scale: { x: number; y: number; z: number };
}
function cloneMapTree(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
): HierarchicalMapNode | HierarchicalMapNode[] {
return JSON.parse(JSON.stringify(mapTree)) as
| HierarchicalMapNode
| HierarchicalMapNode[];
}
function updateTreeNodeAtPath(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
path: number[],
transform: ObjectTransform,
): 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;
const updateNode = (node: HierarchicalMapNode): HierarchicalMapNode => ({
...node,
position: [
transform.position.x,
transform.position.y,
transform.position.z,
],
rotation: [
transform.rotation.x,
transform.rotation.y,
transform.rotation.z,
],
scale: [transform.scale.x, transform.scale.y, transform.scale.z],
});
if (isRootTarget) {
rootNodes[targetIndex] = updateNode(
rootNodes[targetIndex] as HierarchicalMapNode,
);
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]) {
parent.children[targetIndex] = updateNode(parent.children[targetIndex]);
}
return nextTree;
}
class HistoryManager {
private history: ObjectTransform[][] = [];
private currentIndex = -1;
@@ -81,13 +144,14 @@ export function useEditorHistory(
setSceneData((prev) => {
if (!prev) return null;
let mapTree = prev.mapTree;
const mapNodes = prev.mapNodes.map((node, index) => {
const transform = snapshot.find(
(item) => item.uuid === `node-${index}`,
);
if (!transform) return node;
return {
const nextNode = {
...node,
position: [
transform.position.x,
@@ -101,9 +165,15 @@ export function useEditorHistory(
],
scale: [transform.scale.x, transform.scale.y, transform.scale.z],
} satisfies MapNode;
if (mapTree && node.sourcePath) {
mapTree = updateTreeNodeAtPath(mapTree, node.sourcePath, transform);
}
return nextNode;
});
return { ...prev, mapNodes };
return mapTree ? { ...prev, mapNodes, mapTree } : { ...prev, mapNodes };
});
},
[setSceneData],
@@ -149,6 +219,7 @@ export function useEditorHistory(
function createSnapshot(sceneData: SceneData): ObjectTransform[] {
return sceneData.mapNodes.map((node, index) => ({
uuid: `node-${index}`,
...(node.sourcePath ? { sourcePath: node.sourcePath } : {}),
position: {
x: node.position[0],
y: node.position[1],
+1 -1
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { createSceneDataFromFiles } from "@/utils/editor/loadEditorScene";
import { loadMapSceneData } from "@/utils/map/loadMapSceneData";
import type { SceneData } from "@/types/editor/editor";
import type { SceneData } from "@/types/map/mapScene";
interface UseEditorSceneDataResult {
hasMapJson: boolean;
@@ -2,16 +2,14 @@ import { useGameStore } from "@/managers/stores/useGameStore";
import type { MissionStep } from "@/types/gameplay/repairMission";
export function useRepairMovementLocked(): boolean {
return false;
return useGameStore((state) => {
switch (state.mainState) {
case "bike":
return isRepairMovementLocked(state.bike.currentStep);
case "pylone":
return isRepairMovementLocked(state.pylone.currentStep);
case "ferme":
return isRepairMovementLocked(state.ferme.currentStep);
case "ebike":
return isRepairMovementLocked(state.ebike.currentStep);
case "pylon":
return isRepairMovementLocked(state.pylon.currentStep);
case "farm":
return isRepairMovementLocked(state.farm.currentStep);
case "intro":
case "outro":
return false;
@@ -25,6 +23,7 @@ function isRepairMovementLocked(step: MissionStep): boolean {
step === "fragmented" ||
step === "scanning" ||
step === "repairing" ||
step === "reassembling"
step === "reassembling" ||
step === "done"
);
}
@@ -5,8 +5,8 @@ import {
HAND_TRACKING_JPEG_QUALITY,
HAND_TRACKING_RESPONSE_TIMEOUT_MS,
HAND_TRACKING_TARGET_FPS,
getHandTrackingWsUrl,
} from "@/data/handTrackingConfig";
import { getHandTrackingWsUrl } from "@/utils/handTracking/handTrackingEndpoint";
import {
INITIAL_HAND_TRACKING_SNAPSHOT,
getCameraStreamWithTimeout,
+52 -4
View File
@@ -1,6 +1,54 @@
import { useMemo } from "react";
import type * as THREE from "three";
import { useEffect, useMemo } from "react";
import * as THREE from "three";
import { disposeObject3D } from "@/utils/three/dispose";
export function useClonedObject<T extends THREE.Object3D>(object: T): T {
return useMemo(() => object.clone(true) as T, [object]);
interface UseClonedObjectOptions {
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;
}
+9
View File
@@ -1,18 +1,27 @@
import { useEffect, useRef } from "react";
import { useGLTF } from "@react-three/drei";
import { useThree } from "@react-three/fiber";
import {
logModelLoadSuccess,
type ModelLoadLogContext,
} from "@/utils/three/modelLoadLogger";
import { optimizeGLTFSceneTextures } from "@/utils/three/optimizeGLTFScene";
export function useLoggedGLTF(
modelPath: string,
context: Omit<ModelLoadLogContext, "modelPath">,
) {
const gltf = useGLTF(modelPath);
const maxAnisotropy = useThree((state) =>
state.gl.capabilities.getMaxAnisotropy(),
);
const hasLoggedRef = useRef(false);
const { position, rotation, scale, scope } = context;
useEffect(() => {
optimizeGLTFSceneTextures(gltf.scene, maxAnisotropy);
}, [gltf.scene, maxAnisotropy]);
useEffect(() => {
if (hasLoggedRef.current) return;
+1 -1
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef } from "react";
import type { RefObject } from "react";
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";
export function useOctreeGraphNode(
+131
View File
@@ -0,0 +1,131 @@
import { useMemo } from "react";
import { useGLTF } from "@react-three/drei";
import * as THREE from "three";
import { TERRAIN_MODEL_PATH } from "@/data/world/terrainConfig";
import type { Vector3Tuple } from "@/types/three/three";
import { getMapNodesByName } from "@/utils/map/loadMapSceneData";
const RAYCAST_Y = 500;
const RAYCAST_FAR = 1000;
const DOWN = new THREE.Vector3(0, -1, 0);
const DEFAULT_TERRAIN_POSITION: Vector3Tuple = [0, 0, 0];
const DEFAULT_TERRAIN_ROTATION: Vector3Tuple = [0, 0, 0];
const DEFAULT_TERRAIN_SCALE: Vector3Tuple = [1, 1, 1];
interface TerrainHeightSampler {
getHeight: (x: number, z: number) => number | null;
}
interface CachedTerrainHeightSampler {
key: string;
sampler: TerrainHeightSampler;
}
const terrainSamplerCache = new WeakMap<
THREE.Object3D,
CachedTerrainHeightSampler
>();
function createTerrainSamplerCacheKey(
position: Vector3Tuple,
rotation: Vector3Tuple,
scale: Vector3Tuple,
): string {
return `${position.join(",")}|${rotation.join(",")}|${scale.join(",")}`;
}
function createTerrainHeightSampler(
scene: THREE.Object3D,
position: Vector3Tuple,
rotation: Vector3Tuple,
scale: Vector3Tuple,
): TerrainHeightSampler {
const meshes: THREE.Mesh[] = [];
const terrainMatrix = new THREE.Matrix4().compose(
new THREE.Vector3(...position),
new THREE.Quaternion().setFromEuler(new THREE.Euler(...rotation)),
new THREE.Vector3(...scale),
);
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(
new THREE.Vector3(),
DOWN,
0,
RAYCAST_FAR,
);
scene.updateMatrixWorld(true);
scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
meshes.push(child);
}
});
return {
getHeight: (x, z) => {
localOrigin.set(x, RAYCAST_Y, z).applyMatrix4(inverseTerrainMatrix);
raycaster.set(localOrigin, localDirection);
hits.length = 0;
raycaster.intersectObjects(meshes, false, hits);
const hit = hits[0];
return hit?.point.applyMatrix4(terrainMatrix).y ?? null;
},
};
}
export function useTerrainHeightSampler(): TerrainHeightSampler {
const { scene } = useGLTF(TERRAIN_MODEL_PATH);
const terrainNode = getMapNodesByName("terrain")[0];
const position = terrainNode?.position ?? DEFAULT_TERRAIN_POSITION;
const rotation = terrainNode?.rotation ?? DEFAULT_TERRAIN_ROTATION;
const scale = terrainNode?.scale ?? DEFAULT_TERRAIN_SCALE;
return useMemo(() => {
const key = createTerrainSamplerCacheKey(position, rotation, scale);
const cached = terrainSamplerCache.get(scene);
if (cached?.key === key) {
return cached.sampler;
}
const sampler = createTerrainHeightSampler(
scene,
position,
rotation,
scale,
);
terrainSamplerCache.set(scene, { key, sampler });
return sampler;
}, [position, rotation, scale, scene]);
}
export function useTerrainSnappedPosition(
position: Vector3Tuple,
): Vector3Tuple {
const terrainHeight = useTerrainHeightSampler();
return useMemo(() => {
const [x, y, z] = position;
const height = terrainHeight.getHeight(x, z);
return [x, height ?? y, z];
}, [position, terrainHeight]);
}
export function getObjectBottomOffset(
object: THREE.Object3D,
scale: Vector3Tuple = [1, 1, 1],
): number {
const bounds = new THREE.Box3().setFromObject(object);
if (bounds.isEmpty()) return 0;
return -bounds.min.y * scale[1];
}
export function normalizeMapScale(scale: Vector3Tuple): Vector3Tuple {
const [x, y, z] = scale;
const isUniform = Math.abs(x - y) < 0.001 && Math.abs(x - z) < 0.001;
return isUniform ? scale : [x, x, x];
}
-5
View File
@@ -1,5 +0,0 @@
import { useGameStore } from "@/managers/stores/useGameStore";
export function useActivityCity(): boolean {
return useGameStore((state) => state.missionFlow.activityCity);
}
+6
View File
@@ -0,0 +1,6 @@
import { useWorldSettingsStore } from "@/managers/stores/useWorldSettingsStore";
import type { CloudState } from "@/data/world/cloudConfig";
export function useCloudSettings(): CloudState {
return useWorldSettingsStore((state) => state.clouds);
}
+6
View File
@@ -0,0 +1,6 @@
import { useWorldSettingsStore } from "@/managers/stores/useWorldSettingsStore";
import type { FogState } from "@/data/world/fogConfig";
export function useFogSettings(): FogState {
return useWorldSettingsStore((state) => state.fog);
}
+13
View File
@@ -0,0 +1,13 @@
import { useWorldSettingsStore } from "@/managers/stores/useWorldSettingsStore";
export function useDynamicGrass(): boolean {
return useWorldSettingsStore((state) => state.graphics.dynamicGrass);
}
export function useDynamicClouds(): boolean {
return useWorldSettingsStore((state) => state.graphics.dynamicClouds);
}
export function useGrassDensity(): number {
return useWorldSettingsStore((state) => state.graphics.grassDensity);
}
+86
View File
@@ -0,0 +1,86 @@
import { useEffect, useState } from "react";
import {
MAP_INSTANCING_ASSETS,
MAP_INSTANCING_ASSET_TYPES,
type MapInstancingAssetType,
} from "@/data/world/mapInstancingConfig";
import type { MapAssetInstance, MapNode } from "@/types/map/mapScene";
import { mapNodeToInstanceTransform } from "@/utils/map/mapInstanceTransform";
import { logger } from "@/utils/core/Logger";
import { getMapNodes, loadMapSceneData } from "@/utils/map/loadMapSceneData";
export type MapInstancingData = Map<MapInstancingAssetType, MapAssetInstance[]>;
function extractMapInstancingData(mapNodes: MapNode[]): MapInstancingData {
const data: MapInstancingData = new Map();
for (const type of MAP_INSTANCING_ASSET_TYPES) {
const config = MAP_INSTANCING_ASSETS[type];
if (!config.enabled) continue;
const instances = mapNodes
.filter(
(node) => node.name === config.mapName && node.type === "Object3D",
)
.map(mapNodeToInstanceTransform);
if (instances.length > 0) {
data.set(type, instances);
}
}
return data;
}
export function useMapInstancingData(): {
data: MapInstancingData | null;
isLoading: boolean;
} {
const [data, setData] = useState<MapInstancingData | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let cancelled = false;
async function load() {
const cachedNodes = getMapNodes();
if (cachedNodes) {
if (!cancelled) {
setData(extractMapInstancingData(cachedNodes));
setIsLoading(false);
}
return;
}
try {
await loadMapSceneData();
} catch (error) {
logger.error("MapInstancing", "Failed to load map instancing data", {
error: error instanceof Error ? error : String(error),
});
if (!cancelled) {
setData(null);
setIsLoading(false);
}
return;
}
const nodes = getMapNodes();
if (!cancelled) {
setData(nodes ? extractMapInstancingData(nodes) : new Map());
setIsLoading(false);
}
}
void load();
return () => {
cancelled = true;
};
}, []);
return { data, isLoading };
}
+97
View File
@@ -0,0 +1,97 @@
import { useEffect, useState } from "react";
import { VEGETATION_INSTANCE_EXCLUDED_NODE_NAMES } from "@/data/world/vegetationConfig";
import type { MapNode, VegetationInstance } from "@/types/map/mapScene";
import { mapNodeToInstanceTransform } from "@/utils/map/mapInstanceTransform";
import { logger } from "@/utils/core/Logger";
import { loadMapSceneData } from "@/utils/map/loadMapSceneData";
interface InstancedMapEntry {
modelPath: string;
instances: VegetationInstance[];
}
export type VegetationData = Map<string, InstancedMapEntry>;
function extractVegetationData(
mapNodes: MapNode[],
models: Map<string, string>,
): VegetationData {
const data: VegetationData = new Map();
function addInstance(
mapName: string,
modelPath: string,
node: MapNode,
): void {
const entry = data.get(mapName);
const instance = mapNodeToInstanceTransform(node);
if (entry) {
entry.instances.push(instance);
return;
}
data.set(mapName, {
modelPath,
instances: [instance],
});
}
for (const node of mapNodes) {
if (node.type !== "Object3D") continue;
if (VEGETATION_INSTANCE_EXCLUDED_NODE_NAMES.has(node.name)) continue;
const modelPath = models.get(node.name);
if (!modelPath) continue;
addInstance(node.name, modelPath, node);
}
return data;
}
export function useVegetationData(): {
data: VegetationData | null;
isLoading: boolean;
} {
const [data, setData] = useState<VegetationData | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let cancelled = false;
async function load() {
let sceneData: Awaited<ReturnType<typeof loadMapSceneData>> | null = null;
try {
sceneData = await loadMapSceneData();
} catch (error) {
logger.error("Vegetation", "Failed to load vegetation data", {
error: error instanceof Error ? error : String(error),
});
if (!cancelled) {
setData(null);
setIsLoading(false);
}
return;
}
if (!cancelled) {
setData(
sceneData
? extractVegetationData(sceneData.mapNodes, sceneData.models)
: new Map(),
);
setIsLoading(false);
}
}
void load();
return () => {
cancelled = true;
};
}, []);
return { data, isLoading };
}
+84
View File
@@ -0,0 +1,84 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { CHUNK_CONFIG } from "@/data/world/chunkStreamingConfig";
export interface WorldChunkLike {
centerX: number;
centerZ: number;
key: string;
}
function areSetsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
return a.size === b.size && [...a].every((key) => b.has(key));
}
export function useVisibleWorldChunks<TChunk extends WorldChunkLike>(
chunks: readonly TChunk[],
streamingEnabled: boolean,
): readonly TChunk[] {
const camera = useThree((state) => state.camera);
const lastUpdateRef = useRef(-CHUNK_CONFIG.updateInterval);
const activeChunkKeysRef = useRef<Set<string>>(new Set());
const [activeChunkKeys, setActiveChunkKeys] = useState<Set<string>>(
() => new Set(),
);
const updateActiveChunks = useCallback(() => {
const nextKeys = new Set<string>();
const cameraX = camera.position.x;
const cameraZ = camera.position.z;
for (const chunk of chunks) {
const distance = Math.hypot(
chunk.centerX - cameraX,
chunk.centerZ - cameraZ,
);
const wasActive = activeChunkKeysRef.current.has(chunk.key);
const radius = wasActive
? CHUNK_CONFIG.unloadRadius
: CHUNK_CONFIG.loadRadius;
if (distance <= radius) {
nextKeys.add(chunk.key);
}
}
if (areSetsEqual(nextKeys, activeChunkKeysRef.current)) return;
activeChunkKeysRef.current = nextKeys;
setActiveChunkKeys(nextKeys);
}, [camera, chunks]);
useFrame(({ clock }) => {
if (!streamingEnabled) return;
const now = clock.elapsedTime * 1000;
if (now - lastUpdateRef.current < CHUNK_CONFIG.updateInterval) return;
lastUpdateRef.current = now;
updateActiveChunks();
});
return useMemo(() => {
if (!streamingEnabled) return chunks;
return chunks.filter((chunk) => {
if (activeChunkKeys.size > 0) {
return activeChunkKeys.has(chunk.key);
}
return (
Math.hypot(
chunk.centerX - camera.position.x,
chunk.centerZ - camera.position.z,
) <= CHUNK_CONFIG.loadRadius
);
});
}, [
activeChunkKeys,
camera.position.x,
camera.position.z,
chunks,
streamingEnabled,
]);
}
+6
View File
@@ -0,0 +1,6 @@
import { useWorldSettingsStore } from "@/managers/stores/useWorldSettingsStore";
import type { WindState } from "@/data/world/windConfig";
export function useWind(): WindState {
return useWorldSettingsStore((state) => state.wind);
}
+1 -1
View File
@@ -1,5 +1,5 @@
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 { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
+120 -2
View File
@@ -1144,6 +1144,7 @@ canvas {
}
.game-settings-menu__choice-group button,
.game-settings-menu__restart,
.game-settings-menu__quit {
width: 100%;
padding: 11px 12px;
@@ -1162,6 +1163,16 @@ canvas {
color: #050505;
}
.game-settings-menu__restart {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 8px;
border-color: rgba(96, 165, 250, 0.35);
color: #bfdbfe;
}
.game-settings-menu__quit {
margin-top: 8px;
border-color: rgba(248, 113, 113, 0.35);
@@ -1715,7 +1726,7 @@ canvas {
.editor-transform-button {
display: grid;
grid-template-columns: 18px 1fr auto;
grid-template-columns: 18px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
width: 100%;
@@ -1735,6 +1746,30 @@ canvas {
transform 160ms ease;
}
.editor-transform-label {
display: grid;
min-width: 0;
gap: 2px;
}
.editor-transform-label span,
.editor-transform-label small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.editor-transform-label small {
color: #8f8f8f;
font-size: 0.64rem;
font-weight: 620;
letter-spacing: 0;
}
.editor-transform-button.active .editor-transform-label small {
color: #555555;
}
.editor-transform-button.active {
background: #ffffff;
color: #050505;
@@ -1818,7 +1853,8 @@ canvas {
transform 160ms ease;
}
.editor-action-button + .editor-action-button {
.editor-action-button + .editor-action-button,
.editor-player-button + .editor-action-button {
margin-top: 8px;
}
@@ -1849,6 +1885,42 @@ canvas {
color: #050505;
}
.editor-checkbox-row {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 10px;
margin-top: 9px;
padding: 10px 11px;
background: #101010;
border: 1px solid #242424;
border-radius: 14px;
color: #f2f2f2;
cursor: pointer;
}
.editor-checkbox-row input {
width: 16px;
height: 16px;
accent-color: #ffffff;
}
.editor-checkbox-row span {
display: grid;
gap: 2px;
}
.editor-checkbox-row strong {
font-size: 0.82rem;
line-height: 1.1;
}
.editor-checkbox-row small {
color: #8f8f8f;
font-size: 0.68rem;
line-height: 1.2;
}
.editor-selected-info {
display: grid;
grid-template-columns: 17px 1fr auto;
@@ -1861,6 +1933,52 @@ canvas {
color: #050505;
}
.editor-scale-fields {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-top: 2px;
}
.editor-scale-fields label,
.editor-checkbox-row,
.editor-add-node-row {
display: flex;
align-items: center;
gap: 8px;
}
.editor-scale-fields label {
flex-direction: column;
align-items: stretch;
}
.editor-scale-fields input,
.editor-add-node-row input {
width: 100%;
min-width: 0;
color: #f4f4f4;
background: #101010;
border: 1px solid #2a2a2a;
border-radius: 10px;
padding: 8px 9px;
font: inherit;
}
.editor-checkbox-row {
color: #d5d5d5;
font-size: 0.82rem;
}
.editor-add-node-row {
align-items: stretch;
}
.editor-add-node-row .editor-action-button {
white-space: nowrap;
}
.editor-selected-actions {
display: inline-flex;
gap: 6px;
+5 -7
View File
@@ -1,18 +1,16 @@
import {
DEFAULT_CATEGORY_VOLUMES,
type AudioCategory,
} from "@/data/audioConfig";
import { logger } from "@/utils/core/Logger";
export type AudioCategory = "music" | "sfx" | "dialogue";
export type { AudioCategory } from "@/data/audioConfig";
export type OneShotAudioCategory = Exclude<AudioCategory, "music">;
interface AudioContextWindow extends Window {
webkitAudioContext?: typeof AudioContext;
}
const DEFAULT_CATEGORY_VOLUMES: Record<AudioCategory, number> = {
music: 1,
sfx: 1,
dialogue: 1,
};
interface PlaySoundOptions {
category?: OneShotAudioCategory;
pan?: number;
@@ -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),
},
},
})),
}));
+264 -55
View File
@@ -1,21 +1,35 @@
import { create } from "zustand";
import type { GameStep } from "@/types/game";
import { isGameStep, isMainGameState } from "@/data/game/gameStateConfig";
import {
isRepairMissionId,
getNextMissionStep,
getPreviousMissionStep,
isMissionStep,
isRepairMissionId,
} from "@/data/gameplay/repairMissionState";
import {
PLAYER_EBIKE_SPEED,
PLAYER_WALK_SPEED,
} from "@/data/player/playerConfig";
import type { GameStep, MainGameState } from "@/types/game";
import {
type MissionStep,
type RepairMissionId,
} from "@/types/gameplay/repairMission";
import {
clearDebugGameStateCookie,
readDebugGameStateCookie,
writeDebugGameStateCookie,
} from "@/utils/debug/debugGameStateCookie";
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
export type MainGameState = "intro" | "bike" | "pylone" | "ferme" | "outro";
export type PlayerMovementMode = "walk" | "ebike";
export type { MissionStep, RepairMissionId };
interface IntroState {
currentStep: GameStep;
dialogueAudio: string | null;
hasCompleted: boolean;
isBikeUnlocked: boolean;
isEbikeUnlocked: boolean;
}
interface MissionState {
@@ -30,18 +44,19 @@ interface MissionFlowState {
playerName: string;
}
interface GameState {
export interface GameState {
mainState: MainGameState;
isCinematicPlaying: boolean;
missionFlow: MissionFlowState;
player: PlayerState;
intro: IntroState;
bike: MissionState & {
ebike: MissionState & {
isRepaired: boolean;
};
pylone: MissionState & {
pylon: MissionState & {
isPowered: boolean;
};
ferme: MissionState & {
farm: MissionState & {
irrigationFixed: boolean;
};
outro: {
@@ -50,24 +65,30 @@ interface GameState {
};
}
interface PlayerState {
movementMode: PlayerMovementMode;
currentSpeed: number;
}
interface GameActions {
setMainState: (mainState: MainGameState) => void;
setCinematicPlaying: (isCinematicPlaying: boolean) => void;
hideDialog: () => void;
setActivityCity: (activityCity: boolean) => void;
setCanMove: (canMove: boolean) => void;
setPlayerMovementMode: (mode: PlayerMovementMode) => void;
setIntroStep: (step: GameStep) => void;
setIntroState: (intro: Partial<IntroState>) => void;
setPlayerName: (playerName: string) => void;
setBikeState: (bike: Partial<GameState["bike"]>) => void;
setPyloneState: (pylone: Partial<GameState["pylone"]>) => void;
setFermeState: (ferme: Partial<GameState["ferme"]>) => void;
setEbikeState: (ebike: Partial<GameState["ebike"]>) => void;
setPylonState: (pylon: Partial<GameState["pylon"]>) => void;
setFarmState: (farm: Partial<GameState["farm"]>) => void;
setOutroState: (outro: Partial<GameState["outro"]>) => void;
setMissionStep: (mission: RepairMissionId, step: MissionStep) => void;
completeIntro: () => void;
completeBike: () => void;
completePylone: () => void;
completeFerme: () => void;
completeEbike: () => void;
completePylon: () => void;
completeFarm: () => void;
completeMission: (mission: RepairMissionId) => void;
startOutro: () => void;
advanceGameState: () => void;
@@ -79,56 +100,76 @@ interface GameActions {
type GameStore = GameState & GameActions;
type GameStateUpdate = Partial<GameState>;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isStringOrNull(value: unknown): value is string | null {
return typeof value === "string" || value === null;
}
function isBoolean(value: unknown): value is boolean {
return typeof value === "boolean";
}
function isPlayerMovementMode(value: unknown): value is PlayerMovementMode {
return value === "walk" || value === "ebike";
}
function completeIntroState(state: GameState): GameStateUpdate {
return {
mainState: "bike",
mainState: "ebike",
intro: {
...state.intro,
hasCompleted: true,
isBikeUnlocked: true,
isEbikeUnlocked: true,
},
bike: {
...state.bike,
currentStep: "waiting",
missionFlow: {
...state.missionFlow,
canMove: true,
},
ebike: {
...state.ebike,
currentStep: "locked",
},
};
}
function completeBikeState(state: GameState): GameStateUpdate {
function completeEbikeState(state: GameState): GameStateUpdate {
return {
mainState: "pylone",
bike: {
...state.bike,
mainState: "pylon",
ebike: {
...state.ebike,
currentStep: "done",
isRepaired: true,
},
pylone: {
...state.pylone,
pylon: {
...state.pylon,
currentStep: "waiting",
},
};
}
function completePyloneState(state: GameState): GameStateUpdate {
function completePylonState(state: GameState): GameStateUpdate {
return {
mainState: "ferme",
pylone: {
...state.pylone,
mainState: "farm",
pylon: {
...state.pylon,
currentStep: "done",
isPowered: true,
},
ferme: {
...state.ferme,
farm: {
...state.farm,
currentStep: "waiting",
},
};
}
function completeFermeState(state: GameState): GameStateUpdate {
function completeFarmState(state: GameState): GameStateUpdate {
return {
mainState: "outro",
ferme: {
...state.ferme,
farm: {
...state.farm,
currentStep: "done",
irrigationFixed: true,
},
@@ -157,12 +198,12 @@ function completeMissionState(
mission: RepairMissionId,
): GameStateUpdate {
switch (mission) {
case "bike":
return completeBikeState(state);
case "pylone":
return completePyloneState(state);
case "ferme":
return completeFermeState(state);
case "ebike":
return completeEbikeState(state);
case "pylon":
return completePylonState(state);
case "farm":
return completeFarmState(state);
}
}
@@ -209,23 +250,27 @@ function createInitialGameState(): GameState {
dialogMessage: null,
playerName: "",
},
player: {
movementMode: "walk",
currentSpeed: PLAYER_WALK_SPEED,
},
intro: {
currentStep: "intro",
dialogueAudio: null,
hasCompleted: false,
isBikeUnlocked: false,
isEbikeUnlocked: false,
},
bike: {
ebike: {
currentStep: "locked",
dialogueAudio: null,
isRepaired: false,
},
pylone: {
pylon: {
currentStep: "locked",
dialogueAudio: null,
isPowered: false,
},
ferme: {
farm: {
currentStep: "locked",
dialogueAudio: null,
irrigationFixed: false,
@@ -237,8 +282,155 @@ function createInitialGameState(): GameState {
};
}
function hydrateIntroState(initial: IntroState, value: unknown): IntroState {
if (!isRecord(value)) return initial;
return {
currentStep: isGameStep(value.currentStep)
? value.currentStep
: initial.currentStep,
dialogueAudio: isStringOrNull(value.dialogueAudio)
? value.dialogueAudio
: initial.dialogueAudio,
hasCompleted: isBoolean(value.hasCompleted)
? value.hasCompleted
: initial.hasCompleted,
isEbikeUnlocked: isBoolean(value.isEbikeUnlocked)
? value.isEbikeUnlocked
: initial.isEbikeUnlocked,
};
}
function hydrateMissionState(
initial: MissionState,
value: unknown,
): MissionState {
if (!isRecord(value)) return initial;
return {
currentStep:
typeof value.currentStep === "string" && isMissionStep(value.currentStep)
? value.currentStep
: initial.currentStep,
dialogueAudio: isStringOrNull(value.dialogueAudio)
? value.dialogueAudio
: initial.dialogueAudio,
};
}
function hydrateMissionFlowState(
initial: MissionFlowState,
value: unknown,
): MissionFlowState {
if (!isRecord(value)) return initial;
return {
activityCity: isBoolean(value.activityCity)
? value.activityCity
: initial.activityCity,
canMove: isBoolean(value.canMove) ? value.canMove : initial.canMove,
dialogMessage: isStringOrNull(value.dialogMessage)
? value.dialogMessage
: initial.dialogMessage,
playerName:
typeof value.playerName === "string"
? value.playerName
: initial.playerName,
};
}
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 {
if (!isRecord(value)) return initial;
const ebike = hydrateMissionState(initial.ebike, value.ebike);
const pylon = hydrateMissionState(initial.pylon, value.pylon);
const farm = hydrateMissionState(initial.farm, value.farm);
const outro = isRecord(value.outro) ? value.outro : null;
return {
mainState: isMainGameState(value.mainState)
? value.mainState
: initial.mainState,
isCinematicPlaying: isBoolean(value.isCinematicPlaying)
? value.isCinematicPlaying
: initial.isCinematicPlaying,
missionFlow: hydrateMissionFlowState(
initial.missionFlow,
value.missionFlow,
),
player: hydratePlayerState(initial.player, value.player),
intro: hydrateIntroState(initial.intro, value.intro),
ebike: {
...ebike,
isRepaired:
isRecord(value.ebike) && isBoolean(value.ebike.isRepaired)
? value.ebike.isRepaired
: initial.ebike.isRepaired,
},
pylon: {
...pylon,
isPowered:
isRecord(value.pylon) && isBoolean(value.pylon.isPowered)
? value.pylon.isPowered
: initial.pylon.isPowered,
},
farm: {
...farm,
irrigationFixed:
isRecord(value.farm) && isBoolean(value.farm.irrigationFixed)
? value.farm.irrigationFixed
: initial.farm.irrigationFixed,
},
outro: {
dialogueAudio:
outro && isStringOrNull(outro.dialogueAudio)
? outro.dialogueAudio
: initial.outro.dialogueAudio,
hasStarted:
outro && isBoolean(outro.hasStarted)
? outro.hasStarted
: initial.outro.hasStarted,
},
};
}
function createInitialDebugGameState(): GameState {
const initialState = createInitialGameState();
if (!isDebugEnabled()) return initialState;
return hydrateDebugGameState(initialState, readDebugGameStateCookie());
}
function pickGameState(state: GameStore): GameState {
return {
mainState: state.mainState,
isCinematicPlaying: state.isCinematicPlaying,
missionFlow: state.missionFlow,
player: state.player,
intro: state.intro,
ebike: state.ebike,
pylon: state.pylon,
farm: state.farm,
outro: state.outro,
};
}
export const useGameStore = create<GameStore>()((set) => ({
...createInitialGameState(),
...createInitialDebugGameState(),
setMainState: (mainState) => set({ mainState }),
setCinematicPlaying: (isCinematicPlaying) => set({ isCinematicPlaying }),
hideDialog: () =>
@@ -249,6 +441,14 @@ export const useGameStore = create<GameStore>()((set) => ({
set((state) => ({
missionFlow: { ...state.missionFlow, activityCity },
})),
setPlayerMovementMode: (mode) =>
set((state) => ({
player: {
...state.player,
movementMode: mode,
currentSpeed: mode === "ebike" ? PLAYER_EBIKE_SPEED : PLAYER_WALK_SPEED,
},
})),
setCanMove: (canMove) =>
set((state) => ({
missionFlow: { ...state.missionFlow, canMove },
@@ -261,20 +461,20 @@ export const useGameStore = create<GameStore>()((set) => ({
set((state) => ({
missionFlow: { ...state.missionFlow, playerName },
})),
setBikeState: (bike) =>
set((state) => ({ bike: { ...state.bike, ...bike } })),
setPyloneState: (pylone) =>
set((state) => ({ pylone: { ...state.pylone, ...pylone } })),
setFermeState: (ferme) =>
set((state) => ({ ferme: { ...state.ferme, ...ferme } })),
setEbikeState: (ebike) =>
set((state) => ({ ebike: { ...state.ebike, ...ebike } })),
setPylonState: (pylon) =>
set((state) => ({ pylon: { ...state.pylon, ...pylon } })),
setFarmState: (farm) =>
set((state) => ({ farm: { ...state.farm, ...farm } })),
setOutroState: (outro) =>
set((state) => ({ outro: { ...state.outro, ...outro } })),
setMissionStep: (mission, step) =>
set((state) => setMissionStepState(state, mission, step)),
completeIntro: () => set(completeIntroState),
completeBike: () => set((state) => completeMissionState(state, "bike")),
completePylone: () => set((state) => completeMissionState(state, "pylone")),
completeFerme: () => set((state) => completeMissionState(state, "ferme")),
completeEbike: () => set((state) => completeMissionState(state, "ebike")),
completePylon: () => set((state) => completeMissionState(state, "pylon")),
completeFarm: () => set((state) => completeMissionState(state, "farm")),
completeMission: (mission) =>
set((state) => completeMissionState(state, mission)),
startOutro: () => set(startOutroState),
@@ -302,9 +502,18 @@ export const useGameStore = create<GameStore>()((set) => ({
return { outro: { ...state.outro, hasStarted: false } };
}),
resetGame: () => set(createInitialGameState()),
resetGame: () => {
set(createInitialGameState());
clearDebugGameStateCookie();
},
showDialog: (dialogMessage) =>
set((state) => ({
missionFlow: { ...state.missionFlow, dialogMessage },
})),
}));
if (isDebugEnabled()) {
useGameStore.subscribe((state) => {
writeDebugGameStateCookie(pickGameState(state));
});
}
@@ -0,0 +1,70 @@
import { create } from "zustand";
import {
MAP_PERFORMANCE_GROUP_NAMES,
MAP_PERFORMANCE_MODEL_GROUPS,
MAP_PERFORMANCE_MODEL_NAMES,
type MapPerformanceGroupName,
type MapPerformanceModelName,
} from "@/data/world/mapPerformanceConfig";
export { MAP_PERFORMANCE_GROUP_NAMES, MAP_PERFORMANCE_MODEL_NAMES };
export interface MapPerformanceVisibility {
groups: Record<MapPerformanceGroupName, boolean>;
models: Record<MapPerformanceModelName, boolean>;
}
interface MapPerformanceActions {
setGroupVisible: (group: MapPerformanceGroupName, visible: boolean) => void;
setModelVisible: (model: MapPerformanceModelName, visible: boolean) => void;
resetVisibility: () => void;
}
type MapPerformanceStore = MapPerformanceVisibility & MapPerformanceActions;
function createVisibleRecord<T extends string>(
keys: readonly T[],
): Record<T, boolean> {
return Object.fromEntries(keys.map((key) => [key, true])) as Record<
T,
boolean
>;
}
function createDefaultVisibility(): MapPerformanceVisibility {
return {
groups: createVisibleRecord(MAP_PERFORMANCE_GROUP_NAMES),
models: createVisibleRecord(MAP_PERFORMANCE_MODEL_NAMES),
};
}
function isMapPerformanceModelName(
name: string,
): name is MapPerformanceModelName {
return (MAP_PERFORMANCE_MODEL_NAMES as readonly string[]).includes(name);
}
export function isMapModelVisible(
name: string,
visibility: MapPerformanceVisibility,
): boolean {
if (!isMapPerformanceModelName(name)) return true;
if (!visibility.models[name]) return false;
return MAP_PERFORMANCE_MODEL_GROUPS[name].every(
(group) => visibility.groups[group],
);
}
export const useMapPerformanceStore = create<MapPerformanceStore>()((set) => ({
...createDefaultVisibility(),
setGroupVisible: (group, visible) =>
set((state) => ({
groups: { ...state.groups, [group]: visible },
})),
setModelVisible: (model, visible) =>
set((state) => ({
models: { ...state.models, [model]: visible },
})),
resetVisibility: () => set(createDefaultVisibility()),
}));
@@ -0,0 +1,15 @@
import { create } from "zustand";
import type { RepairMissionId } from "@/types/gameplay/repairMission";
import type { Vector3Tuple } from "@/types/three/three";
interface RepairMissionAnchorStore {
anchors: Partial<Record<RepairMissionId, Vector3Tuple>>;
setAnchors: (anchors: Partial<Record<RepairMissionId, Vector3Tuple>>) => void;
}
export const useRepairMissionAnchorStore = create<RepairMissionAnchorStore>(
(set) => ({
anchors: {},
setAnchors: (anchors) => set({ anchors }),
}),
);
+1 -7
View File
@@ -1,9 +1,7 @@
import { create } from "zustand";
import { AudioManager } from "@/managers/AudioManager";
import type { AudioCategory } from "@/managers/AudioManager";
export type SubtitleLanguage = "fr" | "en";
export type RepairRuntime = "js" | "python";
import type { SubtitleLanguage } from "@/types/settings/settings";
interface SettingsState {
isSettingsMenuOpen: boolean;
@@ -12,7 +10,6 @@ interface SettingsState {
dialogueVolume: number;
subtitlesEnabled: boolean;
subtitleLanguage: SubtitleLanguage;
repairRuntime: RepairRuntime;
}
interface SettingsActions {
@@ -22,7 +19,6 @@ interface SettingsActions {
setDialogueVolume: (volume: number) => void;
setSubtitlesEnabled: (enabled: boolean) => void;
setSubtitleLanguage: (language: SubtitleLanguage) => void;
setRepairRuntime: (runtime: RepairRuntime) => void;
resetSettings: () => void;
}
@@ -35,7 +31,6 @@ const DEFAULT_SETTINGS: SettingsState = {
dialogueVolume: 1,
subtitlesEnabled: true,
subtitleLanguage: "fr",
repairRuntime: "js",
};
function clampVolume(volume: number): number {
@@ -79,7 +74,6 @@ export const useSettingsStore = create<SettingsStore>()((set) => ({
set({ dialogueVolume: setAudioCategoryVolume("dialogue", volume) }),
setSubtitlesEnabled: (subtitlesEnabled) => set({ subtitlesEnabled }),
setSubtitleLanguage: (subtitleLanguage) => set({ subtitleLanguage }),
setRepairRuntime: (repairRuntime) => set({ repairRuntime }),
resetSettings: () => {
applyDefaultAudioSettings();
set(DEFAULT_SETTINGS);
@@ -0,0 +1,111 @@
import { create } from "zustand";
import { CLOUD_DEFAULTS, type CloudState } from "@/data/world/cloudConfig";
import { FOG_CONFIG, type FogState } from "@/data/world/fogConfig";
import { WIND_DEFAULTS, type WindState } from "@/data/world/windConfig";
import {
GRAPHICS_DEFAULTS,
type GraphicsState,
} from "@/data/world/graphicsConfig";
interface WorldSettingsState {
clouds: CloudState;
fog: FogState;
wind: WindState;
graphics: GraphicsState;
}
interface WorldSettingsActions {
setClouds: (clouds: Partial<CloudState>) => void;
setFog: (fog: Partial<FogState>) => void;
setWind: (wind: Partial<WindState>) => void;
setWindSpeed: (speed: number) => void;
setWindDirection: (direction: number) => void;
setWindStrength: (strength: number) => void;
setGraphics: (graphics: Partial<GraphicsState>) => void;
setDynamicGrass: (enabled: boolean) => void;
setDynamicTrees: (enabled: boolean) => void;
setDynamicClouds: (enabled: boolean) => void;
setShadowsEnabled: (enabled: boolean) => void;
setGrassDensity: (density: number) => void;
resetToDefaults: () => void;
}
type WorldSettingsStore = WorldSettingsState & WorldSettingsActions;
const DEFAULT_STATE: WorldSettingsState = {
clouds: { ...CLOUD_DEFAULTS },
fog: {
density: FOG_CONFIG.density,
far: FOG_CONFIG.far,
mode: FOG_CONFIG.mode,
near: FOG_CONFIG.near,
},
wind: { ...WIND_DEFAULTS },
graphics: { ...GRAPHICS_DEFAULTS },
};
export const useWorldSettingsStore = create<WorldSettingsStore>()((set) => ({
...DEFAULT_STATE,
setClouds: (cloudsUpdate) =>
set((state) => ({
clouds: { ...state.clouds, ...cloudsUpdate },
})),
setFog: (fogUpdate) =>
set((state) => ({
fog: { ...state.fog, ...fogUpdate },
})),
setWind: (windUpdate) =>
set((state) => ({
wind: { ...state.wind, ...windUpdate },
})),
setWindSpeed: (speed) =>
set((state) => ({
wind: { ...state.wind, speed },
})),
setWindDirection: (direction) =>
set((state) => ({
wind: { ...state.wind, direction },
})),
setWindStrength: (strength) =>
set((state) => ({
wind: { ...state.wind, strength },
})),
setGraphics: (graphicsUpdate) =>
set((state) => ({
graphics: { ...state.graphics, ...graphicsUpdate },
})),
setDynamicGrass: (dynamicGrass) =>
set((state) => ({
graphics: { ...state.graphics, dynamicGrass },
})),
setDynamicTrees: (dynamicTrees) =>
set((state) => ({
graphics: { ...state.graphics, dynamicTrees },
})),
setDynamicClouds: (dynamicClouds) =>
set((state) => ({
graphics: { ...state.graphics, dynamicClouds },
})),
setShadowsEnabled: (shadowsEnabled) =>
set((state) => ({
graphics: { ...state.graphics, shadowsEnabled },
})),
setGrassDensity: (grassDensity) =>
set((state) => ({
graphics: { ...state.graphics, grassDensity },
})),
resetToDefaults: () => set(DEFAULT_STATE),
}));
+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>
);
}
-1
View File
@@ -5,7 +5,6 @@ export function DocsAnimationPage(): React.JSX.Element {
return (
<DocsDocument
content={animation}
frContent={animation}
meta="15"
title="Animation & 3D Model System"
/>
-1
View File
@@ -5,7 +5,6 @@ export function DocsArchitecturePage(): React.JSX.Element {
return (
<DocsDocument
content={architecture}
frContent={architecture}
meta="02"
title="Current Architecture"
/>
+1 -6
View File
@@ -3,11 +3,6 @@ import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsAudioPage(): React.JSX.Element {
return (
<DocsDocument
content={audio}
frContent={audio}
meta="08"
title="Audio Technical Notes"
/>
<DocsDocument content={audio} meta="08" title="Audio Technical Notes" />
);
}
+1 -6
View File
@@ -3,11 +3,6 @@ import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsCodeReviewPage(): React.JSX.Element {
return (
<DocsDocument
content={codeReview}
frContent={codeReview}
meta="16"
title="Code Review Prep"
/>
<DocsDocument content={codeReview} meta="16" title="Code Review Prep" />
);
}
+1 -8
View File
@@ -2,12 +2,5 @@ import editor from "../../../../docs/user/editor.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsEditorPage(): React.JSX.Element {
return (
<DocsDocument
content={editor}
frContent={editor}
meta="14"
title="Editor User Guide"
/>
);
return <DocsDocument content={editor} meta="14" title="Editor User Guide" />;
}
+1 -8
View File
@@ -2,12 +2,5 @@ import features from "../../../../docs/user/features.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsFeaturesPage(): React.JSX.Element {
return (
<DocsDocument
content={features}
frContent={features}
meta="12"
title="Features"
/>
);
return <DocsDocument content={features} meta="12" title="Features" />;
}

Some files were not shown because too many files have changed in this diff Show More