Merge branch 'develop' into fix/repair-game
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
import { useMemo } from "react";
|
||||
import { Box3, BufferAttribute, BufferGeometry } from "three";
|
||||
import type { Octree } from "three-stdlib";
|
||||
import {
|
||||
LA_FABRIK_CENTER,
|
||||
isInsideLaFabrikFootprint,
|
||||
} from "@/data/world/laFabrikConfig";
|
||||
import { useDebugVisualsStore } from "@/managers/stores/useDebugVisualsStore";
|
||||
|
||||
interface DebugOctreeVisualizationProps {
|
||||
octree: Octree | null;
|
||||
}
|
||||
|
||||
interface OctreeNodeBox {
|
||||
box: Box3;
|
||||
depth: number;
|
||||
triangleCount: number;
|
||||
isLeaf: boolean;
|
||||
}
|
||||
|
||||
interface CollectOptions {
|
||||
minDepth: number;
|
||||
maxDepth: number;
|
||||
leavesOnly: boolean;
|
||||
fabrikOnly: boolean;
|
||||
}
|
||||
|
||||
const FABRIK_FILTER_PADDING = 1.5;
|
||||
const FABRIK_FILTER_VERTICAL = 8;
|
||||
|
||||
const BOX_VERTEX_INDEX_PAIRS: ReadonlyArray<readonly [number, number]> = [
|
||||
[0, 1],
|
||||
[1, 3],
|
||||
[3, 2],
|
||||
[2, 0],
|
||||
[4, 5],
|
||||
[5, 7],
|
||||
[7, 6],
|
||||
[6, 4],
|
||||
[0, 4],
|
||||
[1, 5],
|
||||
[2, 6],
|
||||
[3, 7],
|
||||
];
|
||||
|
||||
function boxIntersectsFabrik(box: Box3): boolean {
|
||||
if (box.max.y < LA_FABRIK_CENTER[1] - FABRIK_FILTER_VERTICAL) return false;
|
||||
if (box.min.y > LA_FABRIK_CENTER[1] + FABRIK_FILTER_VERTICAL) return false;
|
||||
|
||||
// Sample box corners + center on XZ plane against the rotated fabrik footprint.
|
||||
const samples: ReadonlyArray<readonly [number, number]> = [
|
||||
[box.min.x, box.min.z],
|
||||
[box.min.x, box.max.z],
|
||||
[box.max.x, box.min.z],
|
||||
[box.max.x, box.max.z],
|
||||
[(box.min.x + box.max.x) * 0.5, (box.min.z + box.max.z) * 0.5],
|
||||
];
|
||||
for (const [x, z] of samples) {
|
||||
if (isInsideLaFabrikFootprint(x, z, FABRIK_FILTER_PADDING)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function collectOctreeBoxes(
|
||||
node: Octree,
|
||||
options: CollectOptions,
|
||||
depth = 0,
|
||||
acc: OctreeNodeBox[] = [],
|
||||
): OctreeNodeBox[] {
|
||||
if (depth > options.maxDepth) return acc;
|
||||
|
||||
const isLeaf = node.subTrees.length === 0;
|
||||
const passesDepth = depth >= options.minDepth;
|
||||
const passesLeafFilter = !options.leavesOnly || isLeaf;
|
||||
const hasTriangles = node.triangles.length > 0;
|
||||
const passesFabrikFilter =
|
||||
!options.fabrikOnly || boxIntersectsFabrik(node.box);
|
||||
|
||||
if (passesDepth && passesLeafFilter && hasTriangles && passesFabrikFilter) {
|
||||
acc.push({
|
||||
box: node.box,
|
||||
depth,
|
||||
triangleCount: node.triangles.length,
|
||||
isLeaf,
|
||||
});
|
||||
}
|
||||
|
||||
for (const sub of node.subTrees) {
|
||||
collectOctreeBoxes(sub, options, depth + 1, acc);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}
|
||||
|
||||
function buildOctreeLineGeometry(
|
||||
nodes: readonly OctreeNodeBox[],
|
||||
): BufferGeometry {
|
||||
const positionsBuffer = new Float32Array(
|
||||
nodes.length * BOX_VERTEX_INDEX_PAIRS.length * 2 * 3,
|
||||
);
|
||||
|
||||
const corners: [number, number, number][] = Array.from({ length: 8 }, () => [
|
||||
0, 0, 0,
|
||||
]);
|
||||
|
||||
let positionsOffset = 0;
|
||||
|
||||
for (const node of nodes) {
|
||||
const { min, max } = node.box;
|
||||
|
||||
corners[0] = [min.x, min.y, min.z];
|
||||
corners[1] = [max.x, min.y, min.z];
|
||||
corners[2] = [min.x, max.y, min.z];
|
||||
corners[3] = [max.x, max.y, min.z];
|
||||
corners[4] = [min.x, min.y, max.z];
|
||||
corners[5] = [max.x, min.y, max.z];
|
||||
corners[6] = [min.x, max.y, max.z];
|
||||
corners[7] = [max.x, max.y, max.z];
|
||||
|
||||
for (const [a, b] of BOX_VERTEX_INDEX_PAIRS) {
|
||||
const ca = corners[a]!;
|
||||
const cb = corners[b]!;
|
||||
positionsBuffer[positionsOffset++] = ca[0];
|
||||
positionsBuffer[positionsOffset++] = ca[1];
|
||||
positionsBuffer[positionsOffset++] = ca[2];
|
||||
positionsBuffer[positionsOffset++] = cb[0];
|
||||
positionsBuffer[positionsOffset++] = cb[1];
|
||||
positionsBuffer[positionsOffset++] = cb[2];
|
||||
}
|
||||
}
|
||||
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new BufferAttribute(positionsBuffer, 3));
|
||||
return geometry;
|
||||
}
|
||||
|
||||
export function DebugOctreeVisualization({
|
||||
octree,
|
||||
}: DebugOctreeVisualizationProps): React.JSX.Element | null {
|
||||
const showOctree = useDebugVisualsStore((state) => state.showOctree);
|
||||
const minDepth = useDebugVisualsStore((state) => state.octreeMinDepth);
|
||||
const maxDepth = useDebugVisualsStore((state) => state.octreeMaxDepth);
|
||||
const leavesOnly = useDebugVisualsStore((state) => state.octreeLeavesOnly);
|
||||
const opacity = useDebugVisualsStore((state) => state.octreeOpacity);
|
||||
const fabrikOnly = useDebugVisualsStore((state) => state.octreeFabrikOnly);
|
||||
|
||||
const geometry = useMemo(() => {
|
||||
if (!octree || !showOctree) return null;
|
||||
const boxes = collectOctreeBoxes(octree, {
|
||||
minDepth,
|
||||
maxDepth,
|
||||
leavesOnly,
|
||||
fabrikOnly,
|
||||
});
|
||||
if (boxes.length === 0) return null;
|
||||
return buildOctreeLineGeometry(boxes);
|
||||
}, [fabrikOnly, leavesOnly, maxDepth, minDepth, octree, showOctree]);
|
||||
|
||||
if (!geometry) return null;
|
||||
|
||||
return (
|
||||
<lineSegments frustumCulled={false} renderOrder={999}>
|
||||
<primitive object={geometry} attach="geometry" />
|
||||
<lineBasicMaterial
|
||||
color="#22d3ee"
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
transparent
|
||||
opacity={opacity}
|
||||
/>
|
||||
</lineSegments>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
|
||||
const MODEL_PATH = "/models/persoprincipal/model.gltf";
|
||||
// Offset expressed in the camera's local space:
|
||||
// - x: horizontal (0 = centered)
|
||||
// - y: vertical relative to camera eye (negative = below)
|
||||
// - z: forward (negative = in front of the camera)
|
||||
const LOCAL_OFFSET = new THREE.Vector3(0, -1, -2.5);
|
||||
|
||||
const eulerHelper = new THREE.Euler();
|
||||
|
||||
export function DebugPlayerModel(): React.JSX.Element {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const { scene } = useGLTF(MODEL_PATH);
|
||||
|
||||
const model = useMemo(() => {
|
||||
const cloned = scene.clone(true);
|
||||
cloned.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.castShadow = true;
|
||||
child.receiveShadow = true;
|
||||
child.frustumCulled = false;
|
||||
}
|
||||
});
|
||||
return cloned;
|
||||
}, [scene]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
model.clear();
|
||||
},
|
||||
[model],
|
||||
);
|
||||
|
||||
useFrame(({ camera }) => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
// Place the model in front of the camera using its local space so it stays
|
||||
// visible regardless of the camera pitch (top-down ebike view, etc.).
|
||||
group.position.copy(LOCAL_OFFSET).applyMatrix4(camera.matrixWorld);
|
||||
|
||||
// Keep the model upright and aligned with the camera yaw only.
|
||||
eulerHelper.setFromQuaternion(camera.quaternion, "YXZ");
|
||||
group.rotation.set(0, eulerHelper.y, 0);
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={groupRef} frustumCulled={false}>
|
||||
<primitive object={model} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
useGLTF.preload(MODEL_PATH);
|
||||
+116
-53
@@ -2,17 +2,22 @@ import { useEffect, useRef, useState, useMemo, useCallback } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { EbikeGPSMap } from "@/components/ebike/EbikeGPSMap";
|
||||
import { EbikeSpeedometer } from "@/components/ebike/EbikeSpeedometer";
|
||||
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||
import { useEbikeSounds } from "@/hooks/ebike/useEbikeSounds";
|
||||
import {
|
||||
getObjectBottomOffset,
|
||||
useTerrainHeightSampler,
|
||||
} from "@/hooks/three/useTerrainHeight";
|
||||
import { animateCameraTransformTransition } from "@/world/GameCinematics";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { PLAYER_EYE_HEIGHT } from "@/data/player/playerConfig";
|
||||
import {
|
||||
EBIKE_CAMERA_TRANSFORM,
|
||||
EBIKE_DROP_PLAYER_TRANSFORM,
|
||||
EBIKE_WORLD_SCALE,
|
||||
EBIKE_WORLD_ROTATION_Y,
|
||||
} from "@/data/ebike/ebikeConfig";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
@@ -31,12 +36,29 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
position: position,
|
||||
});
|
||||
const model = useClonedObject(scene);
|
||||
const terrainHeight = useTerrainHeightSampler();
|
||||
const parkedPosition = useMemo<Vector3Tuple>(() => {
|
||||
const [x, y, z] = position;
|
||||
const height = terrainHeight.getHeight(x, z) ?? y;
|
||||
const bottomOffset = getObjectBottomOffset(model, [
|
||||
EBIKE_WORLD_SCALE,
|
||||
EBIKE_WORLD_SCALE,
|
||||
EBIKE_WORLD_SCALE,
|
||||
]);
|
||||
|
||||
return [x, height + bottomOffset, z];
|
||||
}, [model, position, terrainHeight]);
|
||||
const movementMode = useGameStore((state) => state.player.movementMode);
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const ebikeStep = useGameStore((state) => state.ebike.currentStep);
|
||||
const setMissionStep = useGameStore((state) => state.setMissionStep);
|
||||
const camera = useThree((state) => state.camera);
|
||||
const updateEbikeSounds = useEbikeSounds();
|
||||
const repairGameOwnsEbikeModel =
|
||||
mainState === "ebike" &&
|
||||
ebikeStep !== "locked" &&
|
||||
ebikeStep !== "waiting" &&
|
||||
ebikeStep !== "inspected";
|
||||
|
||||
// Map active mainState to target repair zone coordinate
|
||||
const destPos = useMemo(() => {
|
||||
@@ -58,19 +80,19 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
y: number;
|
||||
z: number;
|
||||
}>({
|
||||
x: position[0],
|
||||
y: position[1],
|
||||
z: position[2],
|
||||
x: parkedPosition[0],
|
||||
y: parkedPosition[1],
|
||||
z: parkedPosition[2],
|
||||
});
|
||||
const lastGpsUpdatePos = useRef<THREE.Vector3>(
|
||||
new THREE.Vector3(...position),
|
||||
new THREE.Vector3(...parkedPosition),
|
||||
);
|
||||
|
||||
// Use ref for internal state, and state for debug visualization (to avoid ref access during render)
|
||||
const restingPositionRef = useRef<Vector3Tuple>([
|
||||
position[0],
|
||||
position[1] - PLAYER_EYE_HEIGHT,
|
||||
position[2],
|
||||
parkedPosition[0],
|
||||
parkedPosition[1],
|
||||
parkedPosition[2],
|
||||
]);
|
||||
const restingRotationRef = useRef<number>(EBIKE_WORLD_ROTATION_Y);
|
||||
const forkRef = useRef<THREE.Object3D | null>(null);
|
||||
@@ -79,11 +101,27 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
const [showCameraPoints, setShowCameraPoints] = useState(true);
|
||||
const [debugRestingPosition, setDebugRestingPosition] =
|
||||
useState<Vector3Tuple>([
|
||||
position[0],
|
||||
position[1] - PLAYER_EYE_HEIGHT,
|
||||
position[2],
|
||||
parkedPosition[0],
|
||||
parkedPosition[1],
|
||||
parkedPosition[2],
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (movementMode === "ebike") return;
|
||||
|
||||
restingPositionRef.current = parkedPosition;
|
||||
restingRotationRef.current = EBIKE_WORLD_ROTATION_Y;
|
||||
lastGpsUpdatePos.current.set(...parkedPosition);
|
||||
|
||||
if (groupRef.current) {
|
||||
groupRef.current.position.set(...parkedPosition);
|
||||
groupRef.current.rotation.set(0, EBIKE_WORLD_ROTATION_Y, 0);
|
||||
}
|
||||
|
||||
window.ebikeParkedPosition = parkedPosition;
|
||||
window.ebikeParkedRotation = EBIKE_WORLD_ROTATION_Y;
|
||||
}, [movementMode, parkedPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (model) {
|
||||
const fork = model.getObjectByName("fourche");
|
||||
@@ -93,6 +131,17 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
}
|
||||
}, [model]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!model) return;
|
||||
|
||||
model.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.castShadow = true;
|
||||
child.receiveShadow = true;
|
||||
}
|
||||
});
|
||||
}, [model]);
|
||||
|
||||
useEffect(() => {
|
||||
window.ebikeVisualGroup = groupRef;
|
||||
window.ebikeParkedPosition = restingPositionRef.current;
|
||||
@@ -169,16 +218,30 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
debugRestingPosition[1] + EBIKE_DROP_PLAYER_TRANSFORM.position[1],
|
||||
debugRestingPosition[2] + EBIKE_DROP_PLAYER_TRANSFORM.position[2],
|
||||
];
|
||||
const interactionLabel =
|
||||
mainState === "ebike"
|
||||
? "Réparer l'e-bike"
|
||||
: movementMode === "walk"
|
||||
? "Monter sur le bike"
|
||||
: "Descendre du bike";
|
||||
|
||||
const handleInteract = useCallback((): void => {
|
||||
if (window.ebikeBreakdownActive === true) return;
|
||||
|
||||
if (movementMode === "walk") {
|
||||
if (mainState === "ebike" && ebikeStep === "waiting") {
|
||||
if (
|
||||
mainState === "ebike" &&
|
||||
(ebikeStep === "locked" || ebikeStep === "waiting")
|
||||
) {
|
||||
setMissionStep("ebike", "inspected");
|
||||
return;
|
||||
}
|
||||
|
||||
if (mainState === "ebike" && ebikeStep === "inspected") {
|
||||
setMissionStep("ebike", "fragmented");
|
||||
return;
|
||||
}
|
||||
|
||||
const cameraOffset = new THREE.Vector3(
|
||||
...EBIKE_CAMERA_TRANSFORM.position,
|
||||
);
|
||||
@@ -258,51 +321,51 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element {
|
||||
|
||||
return (
|
||||
<>
|
||||
<group
|
||||
ref={groupRef}
|
||||
position={position}
|
||||
rotation={[0, EBIKE_WORLD_ROTATION_Y, 0]}
|
||||
>
|
||||
<primitive object={model} />
|
||||
<InteractableObject
|
||||
kind="trigger"
|
||||
label={
|
||||
mainState === "ebike" && ebikeStep === "waiting"
|
||||
? "Inspecter l'e-bike"
|
||||
: movementMode === "walk"
|
||||
? "Monter sur le bike"
|
||||
: "Descendre du bike"
|
||||
}
|
||||
position={position}
|
||||
radius={15}
|
||||
onPress={handleInteract}
|
||||
{!repairGameOwnsEbikeModel ? (
|
||||
<group
|
||||
ref={groupRef}
|
||||
position={parkedPosition}
|
||||
rotation={[0, EBIKE_WORLD_ROTATION_Y, 0]}
|
||||
scale={EBIKE_WORLD_SCALE}
|
||||
>
|
||||
<mesh>
|
||||
<boxGeometry args={[10, 13, 2]} />
|
||||
<meshBasicMaterial colorWrite={false} depthWrite={false} />
|
||||
</mesh>
|
||||
</InteractableObject>
|
||||
<primitive object={model} />
|
||||
<InteractableObject
|
||||
kind="trigger"
|
||||
label={interactionLabel}
|
||||
position={parkedPosition}
|
||||
radius={5}
|
||||
onPress={handleInteract}
|
||||
>
|
||||
<mesh>
|
||||
<boxGeometry args={[8, 9, 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/world/gps/map_background.png"
|
||||
worldBounds={{
|
||||
minX: -166,
|
||||
maxX: 163,
|
||||
minZ: -142,
|
||||
maxZ: 138,
|
||||
}}
|
||||
zoom={4}
|
||||
/>
|
||||
{/* Dynamic 3D GPS Dashboard Screen */}
|
||||
<group position={[0, 7, 0]} rotation={[0, 90, 0]}>
|
||||
<EbikeGPSMap
|
||||
width={0.8}
|
||||
height={0.8}
|
||||
startPos={gpsStartPos}
|
||||
destPos={destPos}
|
||||
mapImageUrl="/assets/world/gps/map_background.png"
|
||||
worldBounds={{
|
||||
minX: -166,
|
||||
maxX: 163,
|
||||
minZ: -142,
|
||||
maxZ: 138,
|
||||
}}
|
||||
zoom={4}
|
||||
/>
|
||||
</group>
|
||||
<group position={[0, 6.35, 0]} rotation={[0, 90, 0]}>
|
||||
<EbikeSpeedometer />
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
) : null}
|
||||
|
||||
{showCameraPoints && (
|
||||
{showCameraPoints && !repairGameOwnsEbikeModel && (
|
||||
<>
|
||||
<mesh position={camPointPos}>
|
||||
<sphereGeometry args={[0.3, 16, 16]} />
|
||||
|
||||
@@ -89,6 +89,8 @@ export interface EbikeGPSMapProps {
|
||||
* Default: 1
|
||||
*/
|
||||
zoom?: number;
|
||||
|
||||
renderOrder?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,6 +109,7 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
|
||||
position = [0, 0, 0],
|
||||
canvasSize = 1024,
|
||||
zoom = 1,
|
||||
renderOrder = 10_000,
|
||||
}) => {
|
||||
const [waypoints, setWaypoints] = useState<Waypoint[]>([]);
|
||||
const [mapImage, setMapImage] = useState<
|
||||
@@ -506,12 +509,13 @@ export const EbikeGPSMap: React.FC<EbikeGPSMapProps> = ({
|
||||
}, [draw]);
|
||||
|
||||
return (
|
||||
<mesh castShadow receiveShadow position={position}>
|
||||
<mesh position={position} renderOrder={renderOrder}>
|
||||
<planeGeometry args={[width, height]} />
|
||||
<meshBasicMaterial
|
||||
toneMapped={false}
|
||||
transparent={true}
|
||||
opacity={1}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
side={THREE.DoubleSide}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { useTexture } from "@react-three/drei";
|
||||
import * as THREE from "three";
|
||||
|
||||
const SPEEDOMETER_DIAL_TEXTURE = "/assets/world/gps/cadran.png";
|
||||
const SPEEDOMETER_NEEDLE_TEXTURE = "/assets/world/gps/fleche.png";
|
||||
const SPEEDOMETER_MIN_ANGLE = Math.PI / 2;
|
||||
const SPEEDOMETER_MAX_ANGLE = -Math.PI / 2;
|
||||
const SPEEDOMETER_RENDER_ORDER = 10_000;
|
||||
|
||||
interface EbikeSpeedometerProps {
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export function EbikeSpeedometer({
|
||||
width = 0.9,
|
||||
height = 0.5,
|
||||
}: EbikeSpeedometerProps): React.JSX.Element {
|
||||
const needleGroupRef = useRef<THREE.Group>(null);
|
||||
const speedFactorRef = useRef(0);
|
||||
const [dialTexture, needleTexture] = useTexture([
|
||||
SPEEDOMETER_DIAL_TEXTURE,
|
||||
SPEEDOMETER_NEEDLE_TEXTURE,
|
||||
]) as [THREE.Texture, THREE.Texture];
|
||||
const needleWidth = width * 0.68;
|
||||
const needleHeight = needleWidth / 2;
|
||||
|
||||
useEffect(() => {
|
||||
[dialTexture, needleTexture].forEach((texture) => {
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
texture.needsUpdate = true;
|
||||
});
|
||||
}, [dialTexture, needleTexture]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
const targetSpeedFactor = THREE.MathUtils.clamp(
|
||||
window.ebikeSpeedFactor ?? 0,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
speedFactorRef.current = THREE.MathUtils.lerp(
|
||||
speedFactorRef.current,
|
||||
targetSpeedFactor,
|
||||
Math.min(1, delta * 10),
|
||||
);
|
||||
|
||||
if (needleGroupRef.current) {
|
||||
needleGroupRef.current.rotation.z = THREE.MathUtils.lerp(
|
||||
SPEEDOMETER_MIN_ANGLE,
|
||||
SPEEDOMETER_MAX_ANGLE,
|
||||
speedFactorRef.current,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<group renderOrder={SPEEDOMETER_RENDER_ORDER}>
|
||||
<mesh renderOrder={SPEEDOMETER_RENDER_ORDER}>
|
||||
<planeGeometry args={[width, height]} />
|
||||
<meshBasicMaterial
|
||||
map={dialTexture}
|
||||
transparent
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
toneMapped={false}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
<group ref={needleGroupRef} position={[0, -height * 0.38, 0.002]}>
|
||||
<mesh
|
||||
position={[0, needleHeight / 2, 0]}
|
||||
renderOrder={SPEEDOMETER_RENDER_ORDER + 1}
|
||||
>
|
||||
<planeGeometry args={[needleWidth, needleHeight]} />
|
||||
<meshBasicMaterial
|
||||
map={needleTexture}
|
||||
transparent
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
toneMapped={false}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import * as THREE from "three";
|
||||
import { MissionNotification } from "@/components/ui/MissionNotification";
|
||||
import {
|
||||
EBIKE_BREAKDOWN_DIALOGUE_DELAY_MS,
|
||||
EBIKE_BREAKDOWN_DIALOGUE_ID,
|
||||
EBIKE_INTRO_RIDE_DURATION_MS,
|
||||
EBIKE_INTRO_BREAKDOWN_DISTANCE,
|
||||
EBIKE_SOUNDS,
|
||||
} from "@/data/ebike/ebikeConfig";
|
||||
import { INTRO_MISSION_NOTIFICATION_IMAGE_PATH } from "@/data/gameplay/missionNotifications";
|
||||
import { AudioManager } from "@/managers/AudioManager";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||
@@ -20,6 +22,9 @@ export function EbikeIntroSequence(): React.JSX.Element | null {
|
||||
const completeIntro = useGameStore((state) => state.completeIntro);
|
||||
const [breakdownDialogueDone, setBreakdownDialogueDone] = useState(false);
|
||||
const hasStartedBreakdown = useRef(false);
|
||||
const rideDistance = useRef(0);
|
||||
const lastRidePosition = useRef<THREE.Vector3 | null>(null);
|
||||
const currentRidePosition = useRef(new THREE.Vector3());
|
||||
|
||||
useEffect(() => {
|
||||
if (introStep !== "await-ebike-mount" || movementMode !== "ebike") return;
|
||||
@@ -28,16 +33,45 @@ export function EbikeIntroSequence(): React.JSX.Element | null {
|
||||
}, [introStep, movementMode, setIntroStep]);
|
||||
|
||||
useEffect(() => {
|
||||
if (introStep !== "ebike-intro-ride") return undefined;
|
||||
if (introStep !== "ebike-intro-ride") return;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setIntroStep("ebike-breakdown");
|
||||
}, EBIKE_INTRO_RIDE_DURATION_MS);
|
||||
rideDistance.current = 0;
|
||||
lastRidePosition.current = null;
|
||||
}, [introStep]);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
useEffect(() => {
|
||||
if (introStep !== "ebike-intro-ride" || movementMode !== "ebike") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let animationFrameId = 0;
|
||||
const tick = () => {
|
||||
const parkedPosition = window.ebikeParkedPosition;
|
||||
if (parkedPosition) {
|
||||
currentRidePosition.current.set(...parkedPosition);
|
||||
if (!lastRidePosition.current) {
|
||||
lastRidePosition.current = currentRidePosition.current.clone();
|
||||
} else {
|
||||
rideDistance.current += currentRidePosition.current.distanceTo(
|
||||
lastRidePosition.current,
|
||||
);
|
||||
lastRidePosition.current.copy(currentRidePosition.current);
|
||||
}
|
||||
|
||||
if (rideDistance.current >= EBIKE_INTRO_BREAKDOWN_DISTANCE) {
|
||||
setIntroStep("ebike-breakdown");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
animationFrameId = window.requestAnimationFrame(tick);
|
||||
};
|
||||
}, [introStep, setIntroStep]);
|
||||
|
||||
animationFrameId = window.requestAnimationFrame(tick);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [introStep, movementMode, setIntroStep]);
|
||||
|
||||
useEffect(() => {
|
||||
if (introStep !== "ebike-breakdown" || hasStartedBreakdown.current) {
|
||||
@@ -112,14 +146,27 @@ export function EbikeIntroSequence(): React.JSX.Element | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (introStep !== "await-ebike-mount" && introStep !== "ebike-intro-ride") {
|
||||
if (
|
||||
introStep !== "reveal" &&
|
||||
introStep !== "await-ebike-mount" &&
|
||||
introStep !== "ebike-intro-ride" &&
|
||||
introStep !== "ebike-breakdown"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (introStep === "ebike-breakdown") {
|
||||
return <MissionNotification mission="ebike" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<MissionNotification
|
||||
mission="ebike"
|
||||
visible={introStep === "await-ebike-mount"}
|
||||
imagePath={INTRO_MISSION_NOTIFICATION_IMAGE_PATH}
|
||||
visible={
|
||||
introStep === "reveal" ||
|
||||
introStep === "await-ebike-mount" ||
|
||||
introStep === "ebike-intro-ride"
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export function SiteMobileBlocker(): React.JSX.Element {
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/assets/logo/logo.jpg"
|
||||
src="/assets/logo.png"
|
||||
alt="Logo Altera"
|
||||
style={{ width: 120, height: "auto" }}
|
||||
/>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { HandTrackingVisualizer } from "@/components/ui/HandTrackingVisualizer";
|
||||
import { InteractPrompt } from "@/components/ui/InteractPrompt";
|
||||
import { RepairMovementLockIndicator } from "@/components/ui/RepairMovementLockIndicator";
|
||||
import { Subtitles } from "@/components/ui/Subtitles";
|
||||
import { TalkieDialogueOverlay } from "@/components/ui/TalkieDialogueOverlay";
|
||||
|
||||
export function GameUI(): React.JSX.Element {
|
||||
return (
|
||||
@@ -15,6 +16,7 @@ export function GameUI(): React.JSX.Element {
|
||||
<InteractPrompt />
|
||||
<HandTrackingVisualizer />
|
||||
<Subtitles />
|
||||
<TalkieDialogueOverlay />
|
||||
<GameSettingsMenu />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,14 +2,19 @@ import { MISSION_NOTIFICATION_IMAGE_PATHS } from "@/data/gameplay/missionNotific
|
||||
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||
|
||||
interface MissionNotificationProps {
|
||||
mission: RepairMissionId;
|
||||
mission?: RepairMissionId;
|
||||
imagePath?: string;
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
export function MissionNotification({
|
||||
mission,
|
||||
imagePath,
|
||||
visible = true,
|
||||
}: MissionNotificationProps): React.JSX.Element {
|
||||
const src =
|
||||
imagePath ?? (mission ? MISSION_NOTIFICATION_IMAGE_PATHS[mission] : "");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mission-notification${visible ? "" : " mission-notification--hidden"}`}
|
||||
@@ -19,7 +24,7 @@ export function MissionNotification({
|
||||
<span className="mission-notification__image-wrap">
|
||||
<img
|
||||
className="mission-notification__image"
|
||||
src={MISSION_NOTIFICATION_IMAGE_PATHS[mission]}
|
||||
src={src}
|
||||
alt="Nouvel objectif de mission"
|
||||
/>
|
||||
</span>
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppLoadingIndicator } from "@/components/ui/AppLoadingIndicator";
|
||||
import type { SceneLoadingState } from "@/types/world/sceneLoading";
|
||||
|
||||
const LOADING_BACKGROUND_PATH = "/assets/bg-site.png";
|
||||
const LOADING_LOGO_PATH = "/assets/logo/logo.jpg";
|
||||
const LOADING_BACKGROUND_PATH = "/assets/bg-site.webp";
|
||||
const LOADING_FRAME_RATE = 12;
|
||||
const LOADING_FRAME_INTERVAL_MS = 1000 / LOADING_FRAME_RATE;
|
||||
const LOADING_LOGO_FRAMES = [
|
||||
"/assets/loader/Loader-1.png",
|
||||
"/assets/loader/Loader-2.png",
|
||||
"/assets/loader/Loader-3.png",
|
||||
"/assets/loader/Loader-4.png",
|
||||
] as const;
|
||||
|
||||
for (const path of [LOADING_BACKGROUND_PATH, LOADING_LOGO_PATH]) {
|
||||
for (const path of [LOADING_BACKGROUND_PATH, ...LOADING_LOGO_FRAMES]) {
|
||||
const image = new Image();
|
||||
image.src = path;
|
||||
}
|
||||
@@ -16,8 +24,25 @@ interface SceneLoadingOverlayProps {
|
||||
export function SceneLoadingOverlay({
|
||||
state,
|
||||
}: SceneLoadingOverlayProps): React.JSX.Element | null {
|
||||
const [logoFrameIndex, setLogoFrameIndex] = useState(0);
|
||||
const isReady = state.status === "ready";
|
||||
const progress = Math.round(Math.max(0, Math.min(1, state.progress)) * 100);
|
||||
const logoFramePath =
|
||||
LOADING_LOGO_FRAMES[logoFrameIndex] ?? LOADING_LOGO_FRAMES[0];
|
||||
|
||||
useEffect(() => {
|
||||
if (isReady) return undefined;
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
setLogoFrameIndex(
|
||||
(currentIndex) => (currentIndex + 1) % LOADING_LOGO_FRAMES.length,
|
||||
);
|
||||
}, LOADING_FRAME_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [isReady]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -33,7 +58,7 @@ export function SceneLoadingOverlay({
|
||||
<img
|
||||
alt="La Fabrik Durable"
|
||||
className="scene-loading-overlay__logo"
|
||||
src={LOADING_LOGO_PATH}
|
||||
src={logoFramePath}
|
||||
/>
|
||||
<div className="scene-loading-overlay__footer">
|
||||
<div className="scene-loading-overlay__meta">
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Suspense } from "react";
|
||||
import { Canvas } from "@react-three/fiber";
|
||||
import { TalkieModel } from "@/components/ui/talkie/TalkieModel";
|
||||
import { TalkieSignalLines } from "@/components/ui/talkie/TalkieSignalLines";
|
||||
import { useTalkieDialogueOverlayState } from "@/hooks/ui/useTalkieDialogueOverlayState";
|
||||
|
||||
export function TalkieDialogueOverlay(): React.JSX.Element | null {
|
||||
const { isNarratorDialogue, isVisible } = useTalkieDialogueOverlayState();
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`talkie-dialogue-overlay${isNarratorDialogue ? " talkie-dialogue-overlay--active" : ""}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{isNarratorDialogue ? <TalkieSignalLines side="left" /> : null}
|
||||
{isNarratorDialogue ? <TalkieSignalLines side="right" /> : null}
|
||||
<div className="talkie-dialogue-overlay__model-frame">
|
||||
<Canvas
|
||||
camera={{ position: [0, 0, 4.2], zoom: 56 }}
|
||||
dpr={[1, 1.5]}
|
||||
gl={{ alpha: true, antialias: true }}
|
||||
orthographic
|
||||
>
|
||||
<ambientLight intensity={2.5} />
|
||||
<directionalLight position={[2, 3, 4]} intensity={2.8} />
|
||||
<Suspense fallback={null}>
|
||||
<TalkieModel active={isNarratorDialogue} />
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import * as THREE from "three";
|
||||
import gsap from "gsap";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
const TALKIE_MODEL_PATH = "/models/talkie/model.gltf";
|
||||
const TALKIE_REST_Y = -1.55;
|
||||
const TALKIE_ACTIVE_Y = -0.38;
|
||||
const TALKIE_BASE_ROTATION: Vector3Tuple = [0.08, -0.52, -0.04];
|
||||
const TALKIE_FLOAT_ROTATION_AMPLITUDE = THREE.MathUtils.degToRad(2.2);
|
||||
const TALKIE_FLOAT_Y_AMPLITUDE = 0.055;
|
||||
|
||||
interface TalkieModelProps {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export function TalkieModel({ active }: TalkieModelProps): React.JSX.Element {
|
||||
const { scene } = useGLTF(TALKIE_MODEL_PATH);
|
||||
const model = useMemo(() => scene.clone(true), [scene]);
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const floatRef = useRef<THREE.Group>(null);
|
||||
|
||||
useEffect(() => {
|
||||
model.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.castShadow = false;
|
||||
child.receiveShadow = false;
|
||||
child.frustumCulled = false;
|
||||
}
|
||||
});
|
||||
}, [model]);
|
||||
|
||||
useEffect(() => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
gsap.killTweensOf(group.position);
|
||||
gsap.to(group.position, {
|
||||
y: active ? TALKIE_ACTIVE_Y : TALKIE_REST_Y,
|
||||
duration: active ? 0.72 : 0.5,
|
||||
ease: active ? "power3.out" : "power2.out",
|
||||
});
|
||||
|
||||
return () => {
|
||||
gsap.killTweensOf(group.position);
|
||||
};
|
||||
}, [active]);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (!floatRef.current) return;
|
||||
|
||||
const t = clock.getElapsedTime();
|
||||
floatRef.current.position.y = Math.sin(t * 1.2) * TALKIE_FLOAT_Y_AMPLITUDE;
|
||||
|
||||
floatRef.current.rotation.x =
|
||||
TALKIE_BASE_ROTATION[0] +
|
||||
Math.sin(t * 0.7) * TALKIE_FLOAT_ROTATION_AMPLITUDE;
|
||||
floatRef.current.rotation.y =
|
||||
TALKIE_BASE_ROTATION[1] +
|
||||
Math.sin(t * 0.55) * TALKIE_FLOAT_ROTATION_AMPLITUDE;
|
||||
floatRef.current.rotation.z =
|
||||
TALKIE_BASE_ROTATION[2] +
|
||||
Math.sin(t * 0.8) * TALKIE_FLOAT_ROTATION_AMPLITUDE;
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={groupRef} position={[0, TALKIE_REST_Y, 0]}>
|
||||
<group ref={floatRef} rotation={TALKIE_BASE_ROTATION}>
|
||||
<primitive
|
||||
object={model}
|
||||
position={[0, -2.45, 0]}
|
||||
rotation={[0, -1, 0]}
|
||||
scale={1.2}
|
||||
/>
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
useGLTF.preload(TALKIE_MODEL_PATH);
|
||||
@@ -0,0 +1,19 @@
|
||||
interface TalkieSignalLinesProps {
|
||||
side: "left" | "right";
|
||||
}
|
||||
|
||||
export function TalkieSignalLines({
|
||||
side,
|
||||
}: TalkieSignalLinesProps): React.JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
className={`talkie-dialogue-overlay__signals talkie-dialogue-overlay__signals--${side}`}
|
||||
viewBox="0 0 90 120"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M18 48 C30 58 30 72 18 82" />
|
||||
<path d="M34 34 C56 52 56 78 34 96" />
|
||||
<path d="M52 20 C84 46 84 84 52 110" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user