merge develop into feat/galerie - resolve model and code conflicts
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
import { useEffect, useRef, useState, useMemo } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { EbikeGPSMap } from "@/components/ebike/EbikeGPSMap";
|
||||
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||
import { animateCameraTransformTransition } from "@/world/GameCinematics";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { PLAYER_EYE_HEIGHT } from "@/data/player/playerConfig";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
const EBIKE_MODEL_PATH = "/models/ebike/model.gltf";
|
||||
|
||||
export interface CameraTransform {
|
||||
position: Vector3Tuple;
|
||||
rotation: Vector3Tuple;
|
||||
}
|
||||
|
||||
export const EBIKE_CAMERA_TRANSFORM: CameraTransform = {
|
||||
position: [-3.5, 6, 0],
|
||||
rotation: [-10, -90, 0],
|
||||
};
|
||||
|
||||
const EBIKE_DROP_PLAYER_TRANSFORM: CameraTransform = {
|
||||
position: [0, 1.5, -3],
|
||||
rotation: [0, 0, 0],
|
||||
};
|
||||
|
||||
interface EbikeProps {
|
||||
position: Vector3Tuple;
|
||||
}
|
||||
|
||||
export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const { scene } = useLoggedGLTF(EBIKE_MODEL_PATH, {
|
||||
scope: "Ebike",
|
||||
position: position,
|
||||
});
|
||||
const model = useClonedObject(scene);
|
||||
const movementMode = useGameStore((state) => state.player.movementMode);
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const camera = useThree((state) => state.camera);
|
||||
|
||||
// Map active mainState to target repair zone coordinate
|
||||
const destPos = useMemo(() => {
|
||||
switch (mainState) {
|
||||
case "ebike":
|
||||
return { x: 8, y: 0, z: -6 };
|
||||
case "pylon":
|
||||
return { x: 64, y: 0, z: -66 };
|
||||
case "farm":
|
||||
return { x: -24, y: 0, z: 42 };
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}, [mainState]);
|
||||
|
||||
// Throttled GPS start position to optimize pathfinding A* algorithm execution
|
||||
const [gpsStartPos, setGpsStartPos] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}>({
|
||||
x: position[0],
|
||||
y: position[1],
|
||||
z: position[2],
|
||||
});
|
||||
const lastGpsUpdatePos = useRef<THREE.Vector3>(
|
||||
new THREE.Vector3(...position),
|
||||
);
|
||||
|
||||
const restingPosition = useRef<Vector3Tuple>([
|
||||
position[0],
|
||||
position[1] - PLAYER_EYE_HEIGHT,
|
||||
position[2],
|
||||
]);
|
||||
const restingRotation = useRef<number>(0);
|
||||
const forkRef = useRef<THREE.Object3D | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (model) {
|
||||
const fork = model.getObjectByName("fourche");
|
||||
if (fork) {
|
||||
forkRef.current = fork;
|
||||
}
|
||||
}
|
||||
}, [model]);
|
||||
|
||||
useEffect(() => {
|
||||
(window as any).ebikeVisualGroup = groupRef;
|
||||
(window as any).ebikeParkedPosition = restingPosition.current;
|
||||
(window as any).ebikeParkedRotation = restingRotation.current;
|
||||
return () => {
|
||||
(window as any).ebikeVisualGroup = null;
|
||||
(window as any).ebikeParkedPosition = null;
|
||||
(window as any).ebikeParkedRotation = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
if (groupRef.current) {
|
||||
if (movementMode === "ebike") {
|
||||
restingPosition.current = [
|
||||
groupRef.current.position.x,
|
||||
groupRef.current.position.y,
|
||||
groupRef.current.position.z,
|
||||
];
|
||||
restingRotation.current = groupRef.current.rotation.y;
|
||||
|
||||
// Smoothly rotate the front fork ("fourche") up to 15 degrees in its own Z axis
|
||||
const steerFactor = (window as any).ebikeSteerFactor || 0;
|
||||
if (forkRef.current) {
|
||||
// 15 degrees is 0.26 radians
|
||||
const targetForkRotation = steerFactor * 0.26;
|
||||
forkRef.current.rotation.z = THREE.MathUtils.lerp(
|
||||
forkRef.current.rotation.z,
|
||||
targetForkRotation,
|
||||
12 * delta,
|
||||
);
|
||||
}
|
||||
|
||||
// Throttled GPS start position update to prevent performance loss
|
||||
const currentPos = groupRef.current.position;
|
||||
if (currentPos.distanceTo(lastGpsUpdatePos.current) > 2.0) {
|
||||
lastGpsUpdatePos.current.copy(currentPos);
|
||||
setGpsStartPos({ x: currentPos.x, y: currentPos.y, z: currentPos.z });
|
||||
}
|
||||
} else {
|
||||
groupRef.current.position.set(...restingPosition.current);
|
||||
groupRef.current.rotation.set(0, restingRotation.current, 0);
|
||||
|
||||
// Reset fork rotation when parked
|
||||
if (forkRef.current) {
|
||||
forkRef.current.rotation.z = 0;
|
||||
}
|
||||
}
|
||||
(window as any).ebikeParkedPosition = restingPosition.current;
|
||||
(window as any).ebikeParkedRotation = restingRotation.current;
|
||||
}
|
||||
});
|
||||
|
||||
const camPointPos: Vector3Tuple = [
|
||||
restingPosition.current[0] + EBIKE_CAMERA_TRANSFORM.position[0],
|
||||
restingPosition.current[1] + EBIKE_CAMERA_TRANSFORM.position[1],
|
||||
restingPosition.current[2] + EBIKE_CAMERA_TRANSFORM.position[2],
|
||||
];
|
||||
const dropPointPos: Vector3Tuple = [
|
||||
restingPosition.current[0] + EBIKE_DROP_PLAYER_TRANSFORM.position[0],
|
||||
restingPosition.current[1] + EBIKE_DROP_PLAYER_TRANSFORM.position[1],
|
||||
restingPosition.current[2] + EBIKE_DROP_PLAYER_TRANSFORM.position[2],
|
||||
];
|
||||
|
||||
const handleInteract = (): void => {
|
||||
if (movementMode === "walk") {
|
||||
const cameraOffset = new THREE.Vector3(
|
||||
...EBIKE_CAMERA_TRANSFORM.position,
|
||||
);
|
||||
cameraOffset.applyAxisAngle(
|
||||
new THREE.Vector3(0, 1, 0),
|
||||
restingRotation.current,
|
||||
);
|
||||
|
||||
const targetCamPos: Vector3Tuple = [
|
||||
restingPosition.current[0] + cameraOffset.x,
|
||||
restingPosition.current[1] + cameraOffset.y,
|
||||
restingPosition.current[2] + cameraOffset.z,
|
||||
];
|
||||
|
||||
const targetRotation: Vector3Tuple = [
|
||||
EBIKE_CAMERA_TRANSFORM.rotation[0],
|
||||
EBIKE_CAMERA_TRANSFORM.rotation[1] +
|
||||
THREE.MathUtils.radToDeg(restingRotation.current),
|
||||
EBIKE_CAMERA_TRANSFORM.rotation[2],
|
||||
];
|
||||
|
||||
animateCameraTransformTransition(targetCamPos, targetRotation, 1, () => {
|
||||
useGameStore.getState().setPlayerMovementMode("ebike");
|
||||
});
|
||||
} else {
|
||||
const currentPos = new THREE.Vector3();
|
||||
if (groupRef.current) {
|
||||
groupRef.current.getWorldPosition(currentPos);
|
||||
} else {
|
||||
currentPos.set(...position);
|
||||
}
|
||||
|
||||
const targetCamPos: Vector3Tuple = [
|
||||
currentPos.x + EBIKE_DROP_PLAYER_TRANSFORM.position[0],
|
||||
currentPos.y + EBIKE_DROP_PLAYER_TRANSFORM.position[1],
|
||||
currentPos.z + EBIKE_DROP_PLAYER_TRANSFORM.position[2],
|
||||
];
|
||||
|
||||
// Get camera's current rotation in degrees so we keep the exact orientation during dismount
|
||||
const currentEuler = new THREE.Euler().setFromQuaternion(
|
||||
camera.quaternion,
|
||||
"YXZ",
|
||||
);
|
||||
const targetRotation: Vector3Tuple = [
|
||||
THREE.MathUtils.radToDeg(currentEuler.x),
|
||||
THREE.MathUtils.radToDeg(currentEuler.y),
|
||||
THREE.MathUtils.radToDeg(currentEuler.z),
|
||||
];
|
||||
|
||||
animateCameraTransformTransition(targetCamPos, targetRotation, 1, () => {
|
||||
useGameStore.getState().setPlayerMovementMode("walk");
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleInteractRef = useRef(handleInteract);
|
||||
handleInteractRef.current = handleInteract;
|
||||
|
||||
const debugRef = useRef({ showCameraPoints: true });
|
||||
const debugActions = useRef({
|
||||
toggleRide: () => {
|
||||
handleInteractRef.current();
|
||||
},
|
||||
});
|
||||
|
||||
useDebugFolder("Ebike", (folder) => {
|
||||
folder
|
||||
.add(debugRef.current, "showCameraPoints")
|
||||
.name("Show Camera Points")
|
||||
.onChange((value: boolean) => {
|
||||
debugRef.current.showCameraPoints = value;
|
||||
});
|
||||
|
||||
folder.add(debugActions.current, "toggleRide").name("Monter / Descendre");
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<group ref={groupRef} position={position}>
|
||||
<primitive object={model} />
|
||||
<InteractableObject
|
||||
kind="trigger"
|
||||
label={
|
||||
movementMode === "walk" ? "Monter sur le bike" : "Descendre du bike"
|
||||
}
|
||||
position={position}
|
||||
radius={15}
|
||||
onPress={handleInteract}
|
||||
>
|
||||
<mesh>
|
||||
<boxGeometry args={[10, 13, 2]} />
|
||||
<meshBasicMaterial colorWrite={false} depthWrite={false} />
|
||||
</mesh>
|
||||
</InteractableObject>
|
||||
|
||||
{/* Dynamic 3D GPS Dashboard Screen */}
|
||||
<group position={[0, 7, 0]} rotation={[0, 90, 0]}>
|
||||
<EbikeGPSMap
|
||||
width={0.8}
|
||||
height={0.8}
|
||||
startPos={gpsStartPos}
|
||||
destPos={destPos}
|
||||
mapImageUrl="/assets/gps/map_background.png"
|
||||
worldBounds={{
|
||||
minX: -166,
|
||||
maxX: 163,
|
||||
minZ: -142,
|
||||
maxZ: 138,
|
||||
}}
|
||||
zoom={4}
|
||||
/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
{debugRef.current.showCameraPoints && (
|
||||
<>
|
||||
<mesh position={camPointPos}>
|
||||
<sphereGeometry args={[0.3, 16, 16]} />
|
||||
<meshStandardMaterial
|
||||
color="yellow"
|
||||
emissive="yellow"
|
||||
emissiveIntensity={0.5}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh position={dropPointPos}>
|
||||
<sphereGeometry args={[0.3, 16, 16]} />
|
||||
<meshStandardMaterial
|
||||
color="cyan"
|
||||
emissive="cyan"
|
||||
emissiveIntensity={0.5}
|
||||
/>
|
||||
</mesh>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
import React, { useRef, useEffect, useState, useMemo } from "react";
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
findClosestWaypoint,
|
||||
findWaypointPath,
|
||||
} from "@/pathfinding/WaypointAStar";
|
||||
import type { Waypoint } from "@/pathfinding/types";
|
||||
function computeImageSource(
|
||||
img: HTMLImageElement | HTMLCanvasElement,
|
||||
baseBounds: { minX: number; maxX: number; minZ: number; maxZ: number },
|
||||
bounds: { minX: number; maxX: number; minZ: number; maxZ: number },
|
||||
) {
|
||||
const imgW = img.width;
|
||||
const imgH = img.height;
|
||||
|
||||
const baseW = baseBounds.maxX - baseBounds.minX;
|
||||
const baseH = baseBounds.maxZ - baseBounds.minZ;
|
||||
|
||||
if (baseW === 0 || baseH === 0) {
|
||||
return { sx: 0, sy: 0, sW: imgW, sH: imgH };
|
||||
}
|
||||
|
||||
const sx = ((bounds.minX - baseBounds.minX) / baseW) * imgW;
|
||||
const sy = ((bounds.minZ - baseBounds.minZ) / baseH) * imgH;
|
||||
const sW = ((bounds.maxX - bounds.minX) / baseW) * imgW;
|
||||
const sH = ((bounds.maxZ - bounds.minZ) / baseH) * imgH;
|
||||
|
||||
return { sx, sy, sW, sH };
|
||||
}
|
||||
|
||||
export interface EbikeGPSMapProps {
|
||||
/**
|
||||
* 3D world position of the player/bike (GPS start point)
|
||||
* If omitted, snaps to [0,0,0]
|
||||
*/
|
||||
startPos?: { x: number; y: number; z: number } | undefined;
|
||||
destPos?: { x: number; y: number; z: number } | undefined;
|
||||
|
||||
/**
|
||||
* Optional custom URL to the map background texture.
|
||||
* If not provided, renders a high-tech minimalist neon blueprint map dynamically.
|
||||
*/
|
||||
mapImageUrl?: string;
|
||||
|
||||
/**
|
||||
* Optional explicit bounds for mapping coordinates.
|
||||
* If omitted, bounds are calculated automatically to perfectly fit the road network!
|
||||
*/
|
||||
worldBounds?: {
|
||||
minX: number;
|
||||
maxX: number;
|
||||
minZ: number;
|
||||
maxZ: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Width of the 3D plane mesh (default: 1)
|
||||
*/
|
||||
width?: number;
|
||||
|
||||
/**
|
||||
* Height of the 3D plane mesh (default: 1)
|
||||
*/
|
||||
height?: number;
|
||||
|
||||
/**
|
||||
* Optional world position for the GPS screen (defaults to origin)
|
||||
*/
|
||||
position?: [number, number, number];
|
||||
|
||||
/**
|
||||
* Resolution of the offscreen canvas used for the map texture.
|
||||
* Higher values yield sharper rendering at the cost of GPU memory.
|
||||
* Default: 1024 (1024×1024 px)
|
||||
*/
|
||||
canvasSize?: number;
|
||||
|
||||
/**
|
||||
* Zoom level applied to the map view.
|
||||
* 1 = full world bounds, 2 = 2× zoom-in centred on the player, etc.
|
||||
* Values < 1 zoom out beyond the calculated world bounds.
|
||||
* Default: 1
|
||||
*/
|
||||
zoom?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* EbikeGPSMap
|
||||
* A premium, state-of-the-art 3D GPS navigation screen for the Ebike.
|
||||
* Loads the road network, runs A* pathfinding, and renders a glowing, animated
|
||||
* orange path over a sleek high-tech map background.
|
||||
*/
|
||||
export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
|
||||
startPos = { x: 0, y: 0, z: 0 },
|
||||
destPos,
|
||||
mapImageUrl,
|
||||
worldBounds,
|
||||
width = 1,
|
||||
height = 1,
|
||||
position = [0, 0, 0],
|
||||
canvasSize = 1024,
|
||||
zoom = 1,
|
||||
}) => {
|
||||
const [waypoints, setWaypoints] = useState<Waypoint[]>([]);
|
||||
const [mapImage, setMapImage] = useState<
|
||||
HTMLImageElement | HTMLCanvasElement | null
|
||||
>(null);
|
||||
|
||||
// Offscreen high-res canvas for crystal clear rendering
|
||||
const [offscreenCanvas] = useState(() => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = canvasSize;
|
||||
canvas.height = canvasSize;
|
||||
return canvas;
|
||||
});
|
||||
|
||||
// Resize the canvas whenever canvasSize changes
|
||||
useEffect(() => {
|
||||
offscreenCanvas.width = canvasSize;
|
||||
offscreenCanvas.height = canvasSize;
|
||||
if (textureRef.current) {
|
||||
textureRef.current.needsUpdate = true;
|
||||
}
|
||||
}, [canvasSize, offscreenCanvas]);
|
||||
|
||||
const textureRef = useRef<THREE.CanvasTexture | null>(null);
|
||||
const animTimeRef = useRef<number>(0);
|
||||
|
||||
// Load waypoints (localStorage with /roadNetwork.json fallback)
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("la-fabrik-waypoints");
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
setWaypoints(parsed);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"[GPS Component] Error loading local storage waypoints",
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to static roadNetwork.json
|
||||
fetch("/roadNetwork.json")
|
||||
.then((res) => {
|
||||
if (res.ok) return res.json();
|
||||
throw new Error("Not found");
|
||||
})
|
||||
.then((data) => {
|
||||
if (Array.isArray(data)) {
|
||||
setWaypoints(data);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log("[GPS Component] No default road network found.", err);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Pre-load background map image (standard HTML5 Image loader)
|
||||
// Since the user's PNG is already transparent, we don't need fetch or pixel manipulation!
|
||||
useEffect(() => {
|
||||
if (!mapImageUrl) {
|
||||
setMapImage(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
setMapImage(img);
|
||||
};
|
||||
img.onerror = () => {
|
||||
console.warn(
|
||||
`[GPS Component] Failed to load map background image from ${mapImageUrl}. Falling back to dynamic vector map.`,
|
||||
);
|
||||
setMapImage(null);
|
||||
};
|
||||
img.src = mapImageUrl;
|
||||
}, [mapImageUrl]);
|
||||
|
||||
// Determine grid boundaries (before zoom)
|
||||
const baseBounds = useMemo(() => {
|
||||
if (worldBounds) return worldBounds;
|
||||
|
||||
if (waypoints.length === 0) {
|
||||
return { minX: -200, maxX: 200, minZ: -200, maxZ: 200 };
|
||||
}
|
||||
|
||||
const xs = waypoints.map((w) => w.x);
|
||||
const zs = waypoints.map((w) => w.z);
|
||||
const minX = Math.min(...xs);
|
||||
const maxX = Math.max(...xs);
|
||||
const minZ = Math.min(...zs);
|
||||
const maxZ = Math.max(...zs);
|
||||
|
||||
// Padding (15% to ensure full view breathing room)
|
||||
const padX = (maxX - minX) * 0.15 || 40;
|
||||
const padZ = (maxZ - minZ) * 0.15 || 40;
|
||||
|
||||
return {
|
||||
minX: minX - padX,
|
||||
maxX: maxX + padX,
|
||||
minZ: minZ - padZ,
|
||||
maxZ: maxZ + padZ,
|
||||
};
|
||||
}, [waypoints, worldBounds]);
|
||||
|
||||
// Apply zoom: shrink the view window around the player position
|
||||
const bounds = useMemo(() => {
|
||||
const clampedZoom = Math.max(0.1, zoom);
|
||||
if (clampedZoom === 1) return baseBounds;
|
||||
|
||||
const centerX = startPos.x;
|
||||
const centerZ = startPos.z;
|
||||
const halfW = (baseBounds.maxX - baseBounds.minX) / 2 / clampedZoom;
|
||||
const halfH = (baseBounds.maxZ - baseBounds.minZ) / 2 / clampedZoom;
|
||||
|
||||
return {
|
||||
minX: centerX - halfW,
|
||||
maxX: centerX + halfW,
|
||||
minZ: centerZ - halfH,
|
||||
maxZ: centerZ + halfH,
|
||||
};
|
||||
}, [baseBounds, zoom, startPos]);
|
||||
|
||||
// Snapped positions
|
||||
const startPosSnapped = useMemo(() => {
|
||||
if (waypoints.length === 0) return null;
|
||||
return findClosestWaypoint(waypoints, startPos);
|
||||
}, [waypoints, startPos]);
|
||||
|
||||
const destPosSnapped = useMemo(() => {
|
||||
if (!destPos || waypoints.length === 0) return null;
|
||||
return findClosestWaypoint(waypoints, destPos);
|
||||
}, [waypoints, destPos]);
|
||||
|
||||
// Calculated active A* route
|
||||
const activePath = useMemo(() => {
|
||||
if (!startPosSnapped || !destPosSnapped || waypoints.length === 0)
|
||||
return [];
|
||||
return findWaypointPath(waypoints, startPosSnapped, destPosSnapped);
|
||||
}, [waypoints, startPosSnapped, destPosSnapped]);
|
||||
|
||||
// Translation helper: 3D world to Canvas pixels
|
||||
const worldToCanvas = (wx: number, wz: number, canvasSize: number) => {
|
||||
const { minX, maxX, minZ, maxZ } = bounds;
|
||||
const px = ((wx - minX) / (maxX - minX)) * canvasSize;
|
||||
const py = ((wz - minZ) / (maxZ - minZ)) * canvasSize;
|
||||
return { x: px, y: py };
|
||||
};
|
||||
|
||||
// Draw loop
|
||||
const draw = () => {
|
||||
const canvas = offscreenCanvas;
|
||||
const ctx = canvas.getContext("2d", {
|
||||
willReadFrequently: true,
|
||||
alpha: true,
|
||||
});
|
||||
if (!ctx) return;
|
||||
|
||||
const size = canvas.width;
|
||||
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
|
||||
// 1. Draw Map Background (Image or premium blueprint vectors)
|
||||
if (mapImage) {
|
||||
const src = computeImageSource(mapImage, baseBounds, bounds);
|
||||
const sx = Math.max(0, Math.min(mapImage.width, src.sx));
|
||||
const sy = Math.max(0, Math.min(mapImage.height, src.sy));
|
||||
const sW = Math.max(1, Math.min(mapImage.width - sx, src.sW));
|
||||
const sH = Math.max(1, Math.min(mapImage.height - sy, src.sH));
|
||||
|
||||
ctx.drawImage(mapImage, sx, sy, sW, sH, 0, 0, size, size);
|
||||
ctx.globalAlpha = 1.0;
|
||||
} else {
|
||||
// Dynamic Sci-fi background grid (Background is transparent!)
|
||||
|
||||
// Sci-fi subgrid
|
||||
ctx.strokeStyle = "rgba(30, 41, 59, 0.4)";
|
||||
ctx.lineWidth = 1;
|
||||
const step = size / 32;
|
||||
for (let x = 0; x < size; x += step) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, size);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let y = 0; y < size; y += step) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(size, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Aesthetic concentric radar topo-rings
|
||||
ctx.strokeStyle = "rgba(71, 85, 105, 0.06)";
|
||||
ctx.lineWidth = 2;
|
||||
for (let r = size / 6; r < size; r += size / 6) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(size / 2, size / 2, r, 0, 2 * Math.PI);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Faint diagonal technical accents
|
||||
ctx.strokeStyle = "rgba(56, 189, 248, 0.03)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, 0);
|
||||
ctx.lineTo(size, size);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(size, 0);
|
||||
ctx.lineTo(0, size);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 2. Draw Active Orange Glowing Path (Neon Highway effect)
|
||||
if (activePath.length > 1) {
|
||||
// Pass 1: Wide transparent orange bloom
|
||||
ctx.beginPath();
|
||||
let pt = worldToCanvas(activePath[0]!.x, activePath[0]!.z, size);
|
||||
ctx.moveTo(pt.x, pt.y);
|
||||
for (let i = 1; i < activePath.length; i++) {
|
||||
pt = worldToCanvas(activePath[i]!.x, activePath[i]!.z, size);
|
||||
ctx.lineTo(pt.x, pt.y);
|
||||
}
|
||||
ctx.strokeStyle = "rgba(249, 115, 22, 0.2)"; // Faint bright orange
|
||||
ctx.lineWidth = 20;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
ctx.shadowBlur = 30;
|
||||
ctx.shadowColor = "#f97316"; // Neon Orange
|
||||
ctx.stroke();
|
||||
|
||||
// Pass 2: Saturated glow core
|
||||
ctx.beginPath();
|
||||
pt = worldToCanvas(activePath[0]!.x, activePath[0]!.z, size);
|
||||
ctx.moveTo(pt.x, pt.y);
|
||||
for (let i = 1; i < activePath.length; i++) {
|
||||
pt = worldToCanvas(activePath[i]!.x, activePath[i]!.z, size);
|
||||
ctx.lineTo(pt.x, pt.y);
|
||||
}
|
||||
ctx.strokeStyle = "#f97316"; // Vibrant orange
|
||||
ctx.lineWidth = 8;
|
||||
ctx.shadowBlur = 12;
|
||||
ctx.shadowColor = "#ea580c";
|
||||
ctx.stroke();
|
||||
|
||||
// Pass 3: High-intensity white core
|
||||
ctx.beginPath();
|
||||
pt = worldToCanvas(activePath[0]!.x, activePath[0]!.z, size);
|
||||
ctx.moveTo(pt.x, pt.y);
|
||||
for (let i = 1; i < activePath.length; i++) {
|
||||
pt = worldToCanvas(activePath[i]!.x, activePath[i]!.z, size);
|
||||
ctx.lineTo(pt.x, pt.y);
|
||||
}
|
||||
ctx.strokeStyle = "#fff7ed"; // Cream white
|
||||
ctx.lineWidth = 3;
|
||||
ctx.shadowBlur = 0; // Turn off shadows for the core
|
||||
ctx.stroke();
|
||||
|
||||
// 3. Energy Particle Pulse animation tracing the road
|
||||
const segments: {
|
||||
start: { x: number; y: number };
|
||||
end: { x: number; y: number };
|
||||
len: number;
|
||||
}[] = [];
|
||||
let totalLen = 0;
|
||||
for (let i = 0; i < activePath.length - 1; i++) {
|
||||
const p1 = worldToCanvas(activePath[i]!.x, activePath[i]!.z, size);
|
||||
const p2 = worldToCanvas(
|
||||
activePath[i + 1]!.x,
|
||||
activePath[i + 1]!.z,
|
||||
size,
|
||||
);
|
||||
const len = Math.sqrt(
|
||||
Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2),
|
||||
);
|
||||
segments.push({ start: p1, end: p2, len });
|
||||
totalLen += len;
|
||||
}
|
||||
|
||||
if (totalLen > 0) {
|
||||
const targetLen = totalLen * animTimeRef.current;
|
||||
let currentLen = 0;
|
||||
let dotPt = segments[0]!.start;
|
||||
|
||||
for (const seg of segments) {
|
||||
if (currentLen + seg.len >= targetLen) {
|
||||
const ratio = (targetLen - currentLen) / seg.len;
|
||||
dotPt = {
|
||||
x: seg.start.x + (seg.end.x - seg.start.x) * ratio,
|
||||
y: seg.start.y + (seg.end.y - seg.start.y) * ratio,
|
||||
};
|
||||
break;
|
||||
}
|
||||
currentLen += seg.len;
|
||||
}
|
||||
|
||||
// Draw multiple glowing pulses along the path
|
||||
ctx.beginPath();
|
||||
ctx.arc(dotPt.x, dotPt.y, 8, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.shadowBlur = 15;
|
||||
ctx.shadowColor = "#f97316";
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Draw Snap Markers (Start and End)
|
||||
if (destPosSnapped) {
|
||||
const pt = worldToCanvas(destPosSnapped.x, destPosSnapped.z, size);
|
||||
const pulseSize = 12 + Math.sin(Date.now() * 0.007) * 4;
|
||||
|
||||
// Pulse ring
|
||||
ctx.beginPath();
|
||||
ctx.arc(pt.x, pt.y, pulseSize, 0, 2 * Math.PI);
|
||||
ctx.strokeStyle = "rgba(249, 115, 22, 0.4)";
|
||||
ctx.lineWidth = 3;
|
||||
ctx.stroke();
|
||||
|
||||
// Solid target core
|
||||
ctx.beginPath();
|
||||
ctx.arc(pt.x, pt.y, 6, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = "#ea580c"; // Deep target orange
|
||||
ctx.strokeStyle = "#ffffff";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
if (startPosSnapped) {
|
||||
const pt = worldToCanvas(startPosSnapped.x, startPosSnapped.z, size);
|
||||
|
||||
// Start Marker (Player Arrow/Dot)
|
||||
ctx.beginPath();
|
||||
ctx.arc(pt.x, pt.y, 8, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = "#0ea5e9"; // Cool cyberpunk sky blue
|
||||
ctx.strokeStyle = "#ffffff";
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
// Tech details
|
||||
ctx.beginPath();
|
||||
ctx.arc(pt.x, pt.y, 3, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// 5. Update WebGL Texture
|
||||
if (textureRef.current) {
|
||||
textureRef.current.needsUpdate = true;
|
||||
}
|
||||
};
|
||||
|
||||
// 60 FPS animation ticker
|
||||
useEffect(() => {
|
||||
let animId: number;
|
||||
const tick = () => {
|
||||
animTimeRef.current += 0.004; // Slow, premium sweep speed
|
||||
if (animTimeRef.current > 1) animTimeRef.current = 0;
|
||||
|
||||
draw();
|
||||
|
||||
animId = requestAnimationFrame(tick);
|
||||
};
|
||||
animId = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(animId);
|
||||
}, [waypoints, startPos, destPos, bounds, mapImage]);
|
||||
|
||||
return (
|
||||
<mesh castShadow receiveShadow position={position as any}>
|
||||
<planeGeometry args={[width, height]} />
|
||||
<meshBasicMaterial
|
||||
toneMapped={false}
|
||||
transparent={true}
|
||||
opacity={1}
|
||||
depthWrite={false}
|
||||
side={THREE.DoubleSide}
|
||||
>
|
||||
<canvasTexture
|
||||
ref={textureRef}
|
||||
attach="map"
|
||||
image={offscreenCanvas}
|
||||
format={THREE.RGBAFormat}
|
||||
minFilter={THREE.LinearFilter}
|
||||
magFilter={THREE.LinearFilter}
|
||||
/>
|
||||
</meshBasicMaterial>
|
||||
</mesh>
|
||||
);
|
||||
};
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
export interface AnimatedModelContextValue {
|
||||
play: (name: string, fade?: number) => void;
|
||||
stop: (fade?: number) => void;
|
||||
fadeTo: (name: string, fade?: number) => void;
|
||||
currentAnimation: string;
|
||||
isReady: boolean;
|
||||
setSpeed: (speed: number) => void;
|
||||
names: string[];
|
||||
}
|
||||
|
||||
export const AnimatedModelContext =
|
||||
createContext<AnimatedModelContextValue | null>(null);
|
||||
@@ -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);
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user