Merge branch 'develop' into fix/repair-game

This commit is contained in:
math-pixel
2026-06-02 09:26:40 +02:00
75 changed files with 1783 additions and 424 deletions
@@ -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>
);
}
+58
View File
@@ -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
View File
@@ -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]} />
+5 -1
View File
@@ -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}
>
+90
View File
@@ -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>
);
}
+58 -11
View File
@@ -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"
}
/>
);
}
+1 -1
View File
@@ -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" }}
/>
+2
View File
@@ -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 />
</>
);
+7 -2
View File
@@ -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>
+29 -4
View File
@@ -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>
);
}
+82
View File
@@ -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>
);
}