Merge remote-tracking branch 'origin/develop' into feat/mission-2

# Conflicts:
#	package-lock.json
#	package.json
#	src/App.tsx
#	src/components/three/interaction/CentralObject.tsx
#	src/components/three/interaction/VillageoisHelperObject.tsx
#	src/managers/GameStepManager.ts
#	src/stateManager/AudioManager.ts
#	src/world/World.tsx
#	src/world/player/PlayerController.tsx
This commit is contained in:
Tom Boullay
2026-05-11 17:46:42 +02:00
945 changed files with 26164 additions and 1569 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useInteraction } from "@/hooks/useInteraction";
import { useInteraction } from "@/hooks/interaction/useInteraction";
export function Crosshair(): React.JSX.Element | null {
const cameraMode = useCameraMode();
+203
View File
@@ -0,0 +1,203 @@
import { useEffect } from "react";
import { X } from "lucide-react";
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
import type {
RepairRuntime,
SubtitleLanguage,
} from "@/managers/stores/useSettingsStore";
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;
value: number;
onChange: (value: number) => void;
}
function VolumeSlider({
id,
label,
value,
onChange,
}: VolumeSliderProps): React.JSX.Element {
return (
<label className="game-settings-menu__slider" htmlFor={id}>
<span>
{label}
<strong>{formatPercent(value)}</strong>
</span>
<input
id={id}
type="range"
min="0"
max="1"
step="0.01"
value={value}
onChange={(event) => onChange(Number(event.target.value))}
/>
</label>
);
}
export function GameSettingsMenu(): React.JSX.Element | null {
const {
isSettingsMenuOpen,
musicVolume,
sfxVolume,
dialogueVolume,
subtitlesEnabled,
subtitleLanguage,
repairRuntime,
setMusicVolume,
setSfxVolume,
setDialogueVolume,
setSettingsMenuOpen,
setSubtitlesEnabled,
setSubtitleLanguage,
setRepairRuntime,
} = useSettingsStore();
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent): void => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
if (!isSettingsMenuOpen) document.exitPointerLock();
setSettingsMenuOpen(!isSettingsMenuOpen);
return;
}
};
window.addEventListener("keydown", handleKeyDown, { capture: true });
return () => {
window.removeEventListener("keydown", handleKeyDown, { capture: true });
};
}, [isSettingsMenuOpen, setSettingsMenuOpen]);
if (!isSettingsMenuOpen) return null;
const handleQuit = (): void => {
clearCookies();
window.location.assign("/");
};
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>
</div>
<button
className="game-settings-menu__close"
type="button"
onClick={() => setSettingsMenuOpen(false)}
aria-label="Fermer le menu"
>
<X size={20} aria-hidden="true" />
</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>
<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)}
/>
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}
>
{language === "fr" ? "Francais" : "English"}
</button>
))}
</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>
<button
className="game-settings-menu__quit"
type="button"
onClick={handleQuit}
>
Quitter
</button>
</div>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { Crosshair } from "@/components/ui/Crosshair";
import { DebugOverlayLayout } from "@/components/ui/debug/DebugOverlayLayout";
import { GameSettingsMenu } from "@/components/ui/GameSettingsMenu";
import { HandTrackingVisualizer } from "@/components/ui/HandTrackingVisualizer";
import { InteractPrompt } from "@/components/ui/InteractPrompt";
import { RepairMovementLockIndicator } from "@/components/ui/RepairMovementLockIndicator";
import { Subtitles } from "@/components/ui/Subtitles";
export function GameUI(): React.JSX.Element {
return (
<>
<DebugOverlayLayout />
<Crosshair />
<RepairMovementLockIndicator />
<InteractPrompt />
<HandTrackingVisualizer />
<Subtitles />
<GameSettingsMenu />
</>
);
}
@@ -0,0 +1,90 @@
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
import { useHandTrackingGloveStatus } from "@/hooks/handTracking/useHandTrackingGloveStatus";
import { useDebugStore } from "@/hooks/debug/useDebugStore";
const HAND_CONNECTIONS: Array<[number, number]> = [
[0, 1],
[1, 2],
[2, 3],
[3, 4],
[0, 5],
[5, 6],
[6, 7],
[7, 8],
[5, 9],
[9, 10],
[10, 11],
[11, 12],
[9, 13],
[13, 14],
[14, 15],
[15, 16],
[13, 17],
[17, 18],
[18, 19],
[19, 20],
[0, 17],
];
export function HandTrackingVisualizer(): React.JSX.Element | null {
const { hands, status } = useHandTrackingSnapshot();
const showHandTrackingSvg = useDebugStore((debug) =>
debug.getShowHandTrackingSvg(),
);
const gloves = useHandTrackingGloveStatus((state) => state.gloves);
const hasLoadedGlove = Object.values(gloves).some(
(gloveStatus) => gloveStatus === "loaded",
);
if (
status === "idle" ||
hands.length === 0 ||
(hasLoadedGlove && !showHandTrackingSvg)
) {
return null;
}
return (
<svg className="hand-tracking-visualizer" aria-hidden="true">
{hands.map((hand, handIndex) => {
const landmarks = hand.landmarks;
if (landmarks.length === 0) return null;
const color = hand.isFist ? "#facc15" : "#38bdf8";
return (
<g key={`${hand.handedness}-${handIndex}`}>
{HAND_CONNECTIONS.map(([from, to]) => {
const fromPoint = landmarks[from];
const toPoint = landmarks[to];
if (!fromPoint || !toPoint) return null;
return (
<line
key={`${from}-${to}`}
x1={`${(1 - fromPoint.x) * 100}%`}
y1={`${fromPoint.y * 100}%`}
x2={`${(1 - toPoint.x) * 100}%`}
y2={`${toPoint.y * 100}%`}
stroke={color}
strokeWidth="2"
strokeLinecap="round"
/>
);
})}
{landmarks.map((landmark, landmarkIndex) => (
<circle
key={landmarkIndex}
cx={`${(1 - landmark.x) * 100}%`}
cy={`${landmark.y * 100}%`}
r={landmarkIndex === 8 ? 5 : 3}
fill={landmarkIndex === 8 ? "#ffffff" : color}
/>
))}
</g>
);
})}
</svg>
);
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { INTERACT_KEY } from "@/data/keybindings";
import { INTERACT_KEY } from "@/data/input/keybindings";
import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useInteraction } from "@/hooks/useInteraction";
import { useInteraction } from "@/hooks/interaction/useInteraction";
export function InteractPrompt(): React.JSX.Element | null {
const cameraMode = useCameraMode();
+6 -6
View File
@@ -1,10 +1,10 @@
import { useState } from "react";
import { useGameStore } from "@/stores/gameStore";
import { useMissionFlowStore } from "@/managers/stores/useMissionFlowStore";
export function IntroUI(): React.JSX.Element | null {
const step = useGameStore((state) => state.step);
const setPlayerName = useGameStore((state) => state.setPlayerName);
const setStep = useGameStore((state) => state.setStep);
const step = useMissionFlowStore((state) => state.step);
const setPlayerName = useMissionFlowStore((state) => state.setPlayerName);
const setStep = useMissionFlowStore((state) => state.setStep);
const [inputValue, setInputValue] = useState("");
if (step !== "naming") return null;
@@ -100,8 +100,8 @@ export function IntroUI(): React.JSX.Element | null {
}
export function BienvenueDisplay(): React.JSX.Element | null {
const step = useGameStore((state) => state.step);
const playerName = useGameStore((state) => state.playerName);
const step = useMissionFlowStore((state) => state.step);
const playerName = useMissionFlowStore((state) => state.playerName);
if (step !== "bienvenue") return null;
@@ -0,0 +1,20 @@
import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useRepairMovementLocked } from "@/hooks/gameplay/useRepairMovementLocked";
export function RepairMovementLockIndicator(): React.JSX.Element | null {
const cameraMode = useCameraMode();
const movementLocked = useRepairMovementLocked();
if (cameraMode !== "player") return null;
if (!movementLocked) return null;
return (
<div className="repair-movement-lock-indicator" aria-live="polite">
<span
className="repair-movement-lock-indicator__dot"
aria-hidden="true"
/>
<span>Déplacement verrouillé pendant la réparation</span>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import type { SceneLoadingState } from "@/types/world/sceneLoading";
interface SceneLoadingOverlayProps {
state: SceneLoadingState;
}
export function SceneLoadingOverlay({
state,
}: SceneLoadingOverlayProps): React.JSX.Element | null {
const isReady = state.status === "ready";
const progress = Math.round(Math.max(0, Math.min(1, state.progress)) * 100);
return (
<div
className={`scene-loading-overlay${isReady ? " scene-loading-overlay--ready" : ""}`}
aria-live="polite"
>
<div className="scene-loading-overlay__content">
<strong>{state.currentStep}</strong>
<div className="scene-loading-overlay__track">
<span style={{ width: `${progress}%` }} />
<em>{progress}%</em>
</div>
</div>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
import type { DialogueSpeaker } from "@/types/dialogues/dialogues";
export type SubtitleSpeaker = DialogueSpeaker;
interface SubtitlesProps {
speaker?: SubtitleSpeaker | null;
text?: string | null;
}
export function Subtitles({
speaker = null,
text = null,
}: SubtitlesProps): React.JSX.Element | null {
const subtitlesEnabled = useSettingsStore((state) => state.subtitlesEnabled);
const activeSubtitle = useSubtitleStore((state) => state.activeSubtitle);
const subtitleSpeaker = speaker ?? activeSubtitle?.speaker ?? null;
const content = (text ?? activeSubtitle?.text)?.trim();
if (!subtitlesEnabled || !content) return null;
return (
<div className="subtitles" aria-live="polite">
<p>
{subtitleSpeaker ? (
<span
className={`subtitles__speaker subtitles__speaker--${subtitleSpeaker.toLowerCase()}`}
>
{subtitleSpeaker}:
</span>
) : null}
{content}
</p>
</div>
);
}
@@ -0,0 +1,22 @@
import { GameStateDebugPanel } from "@/components/ui/debug/GameStateDebugPanel";
import { HandTrackingDebugPanel } from "@/components/ui/debug/HandTrackingDebugPanel";
import { useShowDebugOverlay } from "@/hooks/debug/useShowDebugOverlay";
export function DebugOverlayLayout(): React.JSX.Element | null {
const showDebugOverlay = useShowDebugOverlay();
if (!showDebugOverlay) return null;
return (
<aside className="debug-overlay-layout" aria-label="Debug overlay panels">
<header className="debug-overlay-layout__header">
<span className="debug-overlay-layout__kicker">Debug overlay</span>
</header>
<div className="debug-overlay-layout__sections">
<HandTrackingDebugPanel />
<GameStateDebugPanel />
</div>
</aside>
);
}
@@ -0,0 +1,180 @@
import { RotateCcw, StepBack, StepForward } from "lucide-react";
import {
type MainGameState,
useGameStore,
} from "@/managers/stores/useGameStore";
import { isMissionStep, MISSION_STEPS } from "@/types/gameplay/repairMission";
const MAIN_STATES: MainGameState[] = [
"intro",
"bike",
"pylone",
"ferme",
"outro",
];
function toPascalCase(value: string): string {
return value
.split(/[-_\s]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join("");
}
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 detail = useGameStore((state) => {
switch (state.mainState) {
case "intro":
return state.intro.hasCompleted ? "completed" : "waiting";
case "bike":
return state.bike.currentStep;
case "pylone":
return state.pylone.currentStep;
case "ferme":
return state.ferme.currentStep;
case "outro":
return state.outro.hasStarted ? "started" : "waiting";
}
});
const setMainState = useGameStore((state) => state.setMainState);
const setIntroState = useGameStore((state) => state.setIntroState);
const setBikeState = useGameStore((state) => state.setBikeState);
const setPyloneState = useGameStore((state) => state.setPyloneState);
const setFermeState = useGameStore((state) => state.setFermeState);
const setOutroState = useGameStore((state) => state.setOutroState);
const advanceGameState = useGameStore((state) => state.advanceGameState);
const rewindGameState = useGameStore((state) => state.rewindGameState);
const resetGame = useGameStore((state) => state.resetGame);
const subStateOptions =
mainState === "intro"
? ["waiting", "completed"]
: mainState === "outro"
? ["waiting", "started"]
: MISSION_STEPS;
function setSubState(nextSubState: string): void {
if (mainState === "intro") {
setIntroState({ hasCompleted: nextSubState === "completed" });
return;
}
if (mainState === "outro") {
setOutroState({ hasStarted: nextSubState === "started" });
return;
}
if (!isMissionStep(nextSubState)) return;
if (mainState === "bike") {
setBikeState({ currentStep: nextSubState });
return;
}
if (mainState === "pylone") {
setPyloneState({ currentStep: nextSubState });
return;
}
if (mainState === "ferme") {
setFermeState({ currentStep: nextSubState });
return;
}
}
function setDebugMainState(nextMainState: MainGameState): void {
setMainState(nextMainState);
if (nextMainState === "bike" && bikeStep === "locked") {
setBikeState({ currentStep: "waiting" });
return;
}
if (nextMainState === "pylone" && pyloneStep === "locked") {
setPyloneState({ currentStep: "waiting" });
return;
}
if (nextMainState === "ferme" && fermeStep === "locked") {
setFermeState({ currentStep: "waiting" });
}
}
return (
<section
className="game-state-debug-panel debug-overlay-section"
aria-label="Game state debug panel"
>
<div className="game-state-debug-panel__header">
<h3>Game State</h3>
</div>
<div className="game-state-debug-panel__switch-group">
<div className="game-state-debug-panel__switch-heading">
<span>Main state</span>
<strong>{toPascalCase(mainState)}</strong>
</div>
<div
className="game-state-debug-panel__states"
aria-label="Main states"
role="group"
>
{MAIN_STATES.map((state) => (
<button
key={state}
aria-pressed={state === mainState}
className={state === mainState ? "is-active" : undefined}
type="button"
onClick={() => setDebugMainState(state)}
>
{toPascalCase(state)}
</button>
))}
</div>
</div>
<div className="game-state-debug-panel__switch-group">
<div className="game-state-debug-panel__switch-heading">
<span>Sub state</span>
<strong>{toPascalCase(detail)}</strong>
</div>
<div
className="game-state-debug-panel__states"
aria-label="Sub states"
role="group"
>
{subStateOptions.map((subState) => (
<button
key={subState}
aria-pressed={subState === detail}
className={subState === detail ? "is-active" : undefined}
type="button"
onClick={() => setSubState(subState)}
>
{toPascalCase(subState)}
</button>
))}
</div>
</div>
<div className="game-state-debug-panel__actions">
<button type="button" onClick={rewindGameState}>
<StepBack aria-hidden="true" size={14} />
Previous step
</button>
<button type="button" onClick={advanceGameState}>
<StepForward aria-hidden="true" size={14} />
Next step
</button>
<button type="button" onClick={resetGame}>
<RotateCcw aria-hidden="true" size={14} />
Reset
</button>
</div>
</section>
);
}
@@ -0,0 +1,81 @@
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
import { useHandTrackingGloveStatus } from "@/hooks/handTracking/useHandTrackingGloveStatus";
import type { HandTrackingStatus } from "@/types/handTracking/handTracking";
const STATUS_LABELS: Record<HandTrackingStatus, string> = {
idle: "Idle",
requesting_camera: "Requesting camera",
starting_camera: "Starting camera",
connecting_server: "Connecting server",
connecting: "Connecting",
connected: "Connected",
disconnected: "Disconnected",
error: "Error",
};
export function HandTrackingDebugPanel(): React.JSX.Element | null {
const { hands, status, usageStatus, serverStatus, error } =
useHandTrackingSnapshot();
const gloves = useHandTrackingGloveStatus((state) => state.gloves);
if (status === "idle") {
return null;
}
const fist = hands.some((hand) => hand.isFist);
const modelLoaded =
[
gloves.left === "loaded" ? "gant_l" : null,
gloves.right === "loaded" ? "gant_r" : null,
]
.filter(Boolean)
.join(", ") || "none";
const modelFallback = !Object.values(gloves).some(
(gloveStatus) => gloveStatus === "loaded",
);
return (
<section
className="hand-tracking-debug-panel debug-overlay-section"
aria-label="Hand tracking status"
>
<div className="debug-overlay-section__heading">
<h3>Hand tracking</h3>
<span>{STATUS_LABELS[status]}</span>
</div>
<dl className="debug-overlay-metrics">
<div>
<dt>Usage</dt>
<dd>{usageStatus}</dd>
</div>
<div>
<dt>Model loaded</dt>
<dd>{modelLoaded}</dd>
</div>
<div>
<dt>SVG fallback</dt>
<dd>{modelFallback ? "yes" : "no"}</dd>
</div>
{serverStatus ? (
<div>
<dt>Server</dt>
<dd>{serverStatus}</dd>
</div>
) : null}
<div>
<dt>Hands</dt>
<dd>{hands.length}</dd>
</div>
<div>
<dt>Fist</dt>
<dd>{fist ? "yes" : "no"}</dd>
</div>
</dl>
{error ? (
<span className="hand-tracking-debug-panel__error">{error}</span>
) : null}
</section>
);
}