Merge branch 'develop' into feat/polish-mission1
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled

This commit is contained in:
Tom Boullay
2026-06-01 00:15:46 +02:00
1075 changed files with 1242 additions and 1722 deletions
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useAnimations } from "@react-three/drei";
import { SkeletonUtils } from "three-stdlib";
import type { AnimationAction } from "three";
import {
AnimatedModelContext,
@@ -43,7 +44,8 @@ export function AnimatedModel({
rotation,
scale,
});
const { actions, names, mixer } = useAnimations(animations, scene);
const model = useMemo(() => SkeletonUtils.clone(scene), [scene]);
const { actions, names, mixer } = useAnimations(animations, model);
const [currentAnim, setCurrentAnim] = useState(defaultAnimation);
const isReady = names.length > 0;
@@ -154,21 +156,21 @@ export function AnimatedModel({
};
useEffect(() => {
scene.position.set(...position);
scene.rotation.set(rotation[0], rotation[1], rotation[2]);
model.position.set(...position);
model.rotation.set(rotation[0], rotation[1], rotation[2]);
const parsedScale =
typeof scale === "number" ? [scale, scale, scale] : (scale ?? [1, 1, 1]);
scene.scale.set(
model.scale.set(
parsedScale[0] ?? 1,
parsedScale[1] ?? 1,
parsedScale[2] ?? 1,
);
}, [scene, position, rotation, scale]);
}, [model, position, rotation, scale]);
return (
<AnimatedModelContext.Provider value={contextValue}>
<primitive object={scene} />
<primitive object={model} />
{children}
</AnimatedModelContext.Provider>
);
+13 -1
View File
@@ -3,13 +3,25 @@ import {
MergedStaticMapModel,
type MergedStaticMapModelProps,
} from "@/components/three/world/MergedStaticMapModel";
import { getMapLodModelPath } from "@/data/world/mapLodConfig";
import { useMapLodModelPath } from "@/hooks/world/useMapLodModelPath";
const ECOLE_MODEL_PATH = "/models/ecole/model.gltf";
const ECOLE_LOD_MODEL_PATH = getMapLodModelPath("ecole");
type EcoleModelProps = Omit<MergedStaticMapModelProps, "modelPath">;
export function EcoleModel(props: EcoleModelProps): React.JSX.Element {
return <MergedStaticMapModel modelPath={ECOLE_MODEL_PATH} {...props} />;
const modelPath = useMapLodModelPath({
modelName: "ecole",
modelPath: ECOLE_MODEL_PATH,
position: props.position,
});
return <MergedStaticMapModel modelPath={modelPath} {...props} />;
}
useGLTF.preload(ECOLE_MODEL_PATH);
if (ECOLE_LOD_MODEL_PATH) {
useGLTF.preload(ECOLE_LOD_MODEL_PATH);
}
@@ -3,15 +3,27 @@ import {
MergedStaticMapModel,
type MergedStaticMapModelProps,
} from "@/components/three/world/MergedStaticMapModel";
import { getMapLodModelPath } from "@/data/world/mapLodConfig";
import { useMapLodModelPath } from "@/hooks/world/useMapLodModelPath";
const LA_FABRIK_MODEL_PATH = "/models/lafabrik/model.gltf";
const LA_FABRIK_MODEL_PATH = "/models/lafabrik/model.glb";
const LA_FABRIK_LOD_MODEL_PATH = getMapLodModelPath("lafabrik");
type LaFabrikMapModelProps = Omit<MergedStaticMapModelProps, "modelPath">;
export function LaFabrikMapModel(
props: LaFabrikMapModelProps,
): React.JSX.Element {
return <MergedStaticMapModel modelPath={LA_FABRIK_MODEL_PATH} {...props} />;
const modelPath = useMapLodModelPath({
modelName: "lafabrik",
modelPath: LA_FABRIK_MODEL_PATH,
position: props.position,
});
return <MergedStaticMapModel modelPath={modelPath} {...props} />;
}
useGLTF.preload(LA_FABRIK_MODEL_PATH);
if (LA_FABRIK_LOD_MODEL_PATH) {
useGLTF.preload(LA_FABRIK_LOD_MODEL_PATH);
}
+44
View File
@@ -0,0 +1,44 @@
interface AppLoadingIndicatorProps {
className?: string | undefined;
floating?: boolean;
}
export function AppLoadingIndicator({
className,
floating = false,
}: AppLoadingIndicatorProps): React.JSX.Element {
const classes = [
"app-loading-indicator",
floating ? "app-loading-indicator--floating" : null,
className,
]
.filter(Boolean)
.join(" ");
return (
<div className={classes} role="status" aria-live="polite">
<span>Loading...</span>
<svg
className="app-loading-indicator__spinner"
viewBox="0 0 32 32"
aria-hidden="true"
>
<path
d="M16 3a13 13 0 1 1-9.2 3.8"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="3.5"
/>
<path
d="M6.8 6.8V2.8H2.8"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="3.5"
/>
</svg>
</div>
);
}
+222 -86
View File
@@ -1,26 +1,38 @@
import { useEffect } from "react";
import { RotateCcw, X } from "lucide-react";
import type { ReactNode } from "react";
import {
Captions,
Gauge,
Hand,
Laptop,
Music2,
RotateCcw,
Server,
Volume2,
X,
} from "lucide-react";
import {
GRAPHICS_PRESET_KEYS,
GRAPHICS_PRESETS,
type GraphicsPreset,
} from "@/data/world/graphicsConfig";
import { useDebugStore } from "@/hooks/debug/useDebugStore";
import { useGameStore } from "@/managers/stores/useGameStore";
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
import { useWorldSettingsStore } from "@/managers/stores/useWorldSettingsStore";
import type { HandTrackingSource } from "@/types/handTracking/handTracking";
import type { SubtitleLanguage } from "@/types/settings/settings";
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
import { hasSiteBeenVisitedToday } from "@/utils/cookies/siteVisitCookie";
import { Debug } from "@/utils/debug/Debug";
function formatPercent(value: number): string {
return `${Math.round(value * 100)}%`;
}
function clearCookies(): void {
document.cookie.split(";").forEach((cookie) => {
const cookieName = cookie.split("=")[0]?.trim();
if (!cookieName) return;
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
});
}
interface VolumeSliderProps {
id: string;
label: string;
icon: ReactNode;
value: number;
onChange: (value: number) => void;
}
@@ -28,13 +40,17 @@ interface VolumeSliderProps {
function VolumeSlider({
id,
label,
icon,
value,
onChange,
}: VolumeSliderProps): React.JSX.Element {
return (
<label className="game-settings-menu__slider" htmlFor={id}>
<span>
{label}
<em>
{icon}
{label}
</em>
<strong>{formatPercent(value)}</strong>
</span>
<input
@@ -50,8 +66,73 @@ function VolumeSlider({
);
}
function formatChunkDistance(distance: number): string {
return `${distance}m`;
}
const HAND_TRACKING_OPTIONS = [
{
description: "Calcul local",
icon: <Laptop size={14} aria-hidden="true" />,
label: "Sur cet ordi",
source: "browser",
},
{
description: "Soulage l'ordi",
icon: <Server size={14} aria-hidden="true" />,
label: "Mode assisté",
source: "backend",
},
] as const satisfies readonly {
description: string;
icon: ReactNode;
label: string;
source: HandTrackingSource;
}[];
interface GraphicsPresetButtonProps {
active: boolean;
preset: GraphicsPreset;
onSelect: (preset: GraphicsPreset) => void;
}
function GraphicsPresetButton({
active,
preset,
onSelect,
}: GraphicsPresetButtonProps): React.JSX.Element {
const config = GRAPHICS_PRESETS[preset];
const lodLabel = config.forceLodModels
? "LOD forcé"
: `HD ${config.lodHighDetailDistance}m`;
return (
<button
type="button"
className={active ? "active" : undefined}
onClick={() => onSelect(preset)}
aria-pressed={active}
>
<span>{config.label}</span>
<small>
{formatChunkDistance(config.chunkLoadRadius)} · {lodLabel} ·{" "}
{config.fogEnabled ? "Fog" : "Clear"}
</small>
</button>
);
}
export function GameSettingsMenu(): React.JSX.Element | null {
const resetGame = useGameStore((state) => state.resetGame);
const handTrackingSource = useDebugStore((debug) =>
debug.getHandTrackingSource(),
);
const graphicsPreset = useWorldSettingsStore(
(state) => state.graphics.preset,
);
const setGraphicsPreset = useWorldSettingsStore(
(state) => state.setGraphicsPreset,
);
const {
isSettingsMenuOpen,
musicVolume,
@@ -86,25 +167,23 @@ export function GameSettingsMenu(): React.JSX.Element | null {
if (!isSettingsMenuOpen) return null;
const handleQuit = (): void => {
clearCookies();
window.location.assign("/");
};
const handleRestart = (): void => {
resetGame();
window.location.reload();
setSettingsMenuOpen(false);
window.location.assign(hasSiteBeenVisitedToday() ? "/" : "/site");
};
const showDebugRestart = isDebugEnabled();
const handleHandTrackingSourceChange = (source: HandTrackingSource): void => {
Debug.getInstance().setHandTrackingSource(source);
};
return (
<div className="game-settings-menu" role="dialog" aria-modal="true">
<div className="game-settings-menu__panel">
<header className="game-settings-menu__header">
<div>
<span>Pause</span>
<h2>Options</h2>
<span>La Fabrik</span>
<h2>Pause</h2>
</div>
<button
className="game-settings-menu__close"
@@ -116,80 +195,137 @@ export function GameSettingsMenu(): React.JSX.Element | null {
</button>
</header>
<section
className="game-settings-menu__section"
aria-labelledby="audio-settings-heading"
>
<h3 id="audio-settings-heading">Audio</h3>
<VolumeSlider
id="music-volume"
label="Musique"
value={musicVolume}
onChange={setMusicVolume}
/>
<VolumeSlider
id="sfx-volume"
label="Sound effects"
value={sfxVolume}
onChange={setSfxVolume}
/>
<VolumeSlider
id="dialogue-volume"
label="Dialogue"
value={dialogueVolume}
onChange={setDialogueVolume}
/>
</section>
<div className="game-settings-menu__grid">
<section
className="game-settings-menu__section game-settings-menu__section--wide"
aria-labelledby="graphics-settings-heading"
>
<div className="game-settings-menu__section-title">
<Gauge size={16} aria-hidden="true" />
<h3 id="graphics-settings-heading">Graphisme</h3>
</div>
<div
className="game-settings-menu__choice-group game-settings-menu__choice-group--presets"
aria-label="Preset graphique"
>
{GRAPHICS_PRESET_KEYS.map((preset) => (
<GraphicsPresetButton
key={preset}
preset={preset}
active={graphicsPreset === preset}
onSelect={setGraphicsPreset}
/>
))}
</div>
</section>
<section
className="game-settings-menu__section"
aria-labelledby="subtitle-settings-heading"
>
<h3 id="subtitle-settings-heading">Sous-titres</h3>
<label className="game-settings-menu__checkbox">
<input
type="checkbox"
checked={subtitlesEnabled}
onChange={(event) => setSubtitlesEnabled(event.target.checked)}
<section
className="game-settings-menu__section"
aria-labelledby="audio-settings-heading"
>
<div className="game-settings-menu__section-title">
<Volume2 size={16} aria-hidden="true" />
<h3 id="audio-settings-heading">Audio</h3>
</div>
<VolumeSlider
id="music-volume"
icon={<Music2 size={14} aria-hidden="true" />}
label="Musique"
value={musicVolume}
onChange={setMusicVolume}
/>
Afficher sous-titres
</label>
<VolumeSlider
id="sfx-volume"
icon={<Volume2 size={14} aria-hidden="true" />}
label="Effets"
value={sfxVolume}
onChange={setSfxVolume}
/>
<VolumeSlider
id="dialogue-volume"
icon={<Captions size={14} aria-hidden="true" />}
label="Dialogue"
value={dialogueVolume}
onChange={setDialogueVolume}
/>
</section>
<div
className="game-settings-menu__choice-group"
aria-label="Langue des sous-titres"
<section
className="game-settings-menu__section"
aria-labelledby="subtitle-settings-heading"
>
{(["fr", "en"] satisfies SubtitleLanguage[]).map((language) => (
<button
key={language}
type="button"
className={subtitleLanguage === language ? "active" : undefined}
onClick={() => setSubtitleLanguage(language)}
aria-pressed={subtitleLanguage === language}
<div className="game-settings-menu__section-title">
<Captions size={16} aria-hidden="true" />
<h3 id="subtitle-settings-heading">Sous-titres</h3>
</div>
<label className="game-settings-menu__checkbox">
<input
type="checkbox"
checked={subtitlesEnabled}
onChange={(event) => setSubtitlesEnabled(event.target.checked)}
/>
Afficher sous-titres
</label>
<div
className="game-settings-menu__choice-group"
aria-label="Langue des sous-titres"
>
{(["fr", "en"] satisfies SubtitleLanguage[]).map((language) => (
<button
key={language}
type="button"
className={
subtitleLanguage === language ? "active" : undefined
}
onClick={() => setSubtitleLanguage(language)}
aria-pressed={subtitleLanguage === language}
>
<span>{language === "fr" ? "Français" : "English"}</span>
</button>
))}
</div>
<div className="game-settings-menu__subsection">
<div className="game-settings-menu__section-title">
<Hand size={16} aria-hidden="true" />
<h3 id="hand-tracking-settings-heading">Détection des mains</h3>
</div>
<div
className="game-settings-menu__choice-group game-settings-menu__choice-group--hand-tracking"
aria-labelledby="hand-tracking-settings-heading"
>
{language === "fr" ? "Francais" : "English"}
</button>
))}
</div>
</section>
{showDebugRestart ? (
<button
className="game-settings-menu__restart"
type="button"
onClick={handleRestart}
>
<RotateCcw size={14} aria-hidden="true" />
Recommencer
</button>
) : null}
{HAND_TRACKING_OPTIONS.map((option) => (
<button
key={option.source}
type="button"
className={
handTrackingSource === option.source
? "active"
: undefined
}
onClick={() =>
handleHandTrackingSourceChange(option.source)
}
aria-pressed={handTrackingSource === option.source}
>
{option.icon}
<span>{option.label}</span>
<small>{option.description}</small>
</button>
))}
</div>
</div>
</section>
</div>
<button
className="game-settings-menu__quit"
className="game-settings-menu__restart"
type="button"
onClick={handleQuit}
onClick={handleRestart}
>
Quitter
<RotateCcw size={14} aria-hidden="true" />
Recommencer
</button>
</div>
</div>
+2 -24
View File
@@ -1,3 +1,4 @@
import { AppLoadingIndicator } from "@/components/ui/AppLoadingIndicator";
import type { SceneLoadingState } from "@/types/world/sceneLoading";
const LOADING_BACKGROUND_PATH = "/assets/bg-site.png";
@@ -36,30 +37,7 @@ export function SceneLoadingOverlay({
/>
<div className="scene-loading-overlay__footer">
<div className="scene-loading-overlay__meta">
<div className="scene-loading-overlay__label">
<span>Loading...</span>
<svg
className="scene-loading-overlay__spinner"
viewBox="0 0 32 32"
aria-hidden="true"
>
<path
d="M16 3a13 13 0 1 1-9.2 3.8"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="3.5"
/>
<path
d="M6.8 6.8V2.8H2.8"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="3.5"
/>
</svg>
</div>
<AppLoadingIndicator className="scene-loading-overlay__label" />
<strong>{progress}%</strong>
</div>
<div className="scene-loading-overlay__track">