add: settings menu + menu store

This commit is contained in:
Tom Boullay
2026-05-09 23:45:05 +01:00
parent ce67d07107
commit 974f1e33fb
5 changed files with 453 additions and 0 deletions
+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>
);
}
+2
View File
@@ -1,5 +1,6 @@
import { Crosshair } from "@/components/ui/Crosshair"; import { Crosshair } from "@/components/ui/Crosshair";
import { DebugOverlayLayout } from "@/components/ui/debug/DebugOverlayLayout"; import { DebugOverlayLayout } from "@/components/ui/debug/DebugOverlayLayout";
import { GameSettingsMenu } from "@/components/ui/GameSettingsMenu";
import { HandTrackingVisualizer } from "@/components/ui/HandTrackingVisualizer"; import { HandTrackingVisualizer } from "@/components/ui/HandTrackingVisualizer";
import { InteractPrompt } from "@/components/ui/InteractPrompt"; import { InteractPrompt } from "@/components/ui/InteractPrompt";
@@ -10,6 +11,7 @@ export function GameUI(): React.JSX.Element {
<Crosshair /> <Crosshair />
<InteractPrompt /> <InteractPrompt />
<HandTrackingVisualizer /> <HandTrackingVisualizer />
<GameSettingsMenu />
</> </>
); );
} }
+147
View File
@@ -397,6 +397,153 @@ canvas {
letter-spacing: 0.03em; letter-spacing: 0.03em;
} }
/* In-game settings menu */
.game-settings-menu {
position: fixed;
inset: 0;
z-index: 40;
display: grid;
place-items: center;
padding: 20px;
background: rgba(0, 0, 0, 0.6);
color: #ffffff;
pointer-events: auto;
backdrop-filter: blur(10px);
}
.game-settings-menu__panel {
width: min(460px, 100%);
max-height: calc(100vh - 40px);
overflow-y: auto;
padding: 18px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 24px;
background: rgba(8, 8, 8, 0.94);
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.55);
}
.game-settings-menu__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 4px 4px 16px;
}
.game-settings-menu__header span {
color: #8f8f8f;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.game-settings-menu__header h2 {
margin: 0.25rem 0 0;
font-size: 1.8rem;
letter-spacing: -0.06em;
}
.game-settings-menu__close {
display: grid;
place-items: center;
width: 40px;
height: 40px;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 999px;
background: #111111;
color: #ffffff;
cursor: pointer;
}
.game-settings-menu__section {
display: grid;
gap: 12px;
padding: 16px 4px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.game-settings-menu__section h3 {
margin: 0;
color: #d7d7d7;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.game-settings-menu__slider {
display: grid;
gap: 8px;
}
.game-settings-menu__slider span,
.game-settings-menu__checkbox {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: #f2f2f2;
font-size: 0.9rem;
font-weight: 650;
}
.game-settings-menu__slider strong {
color: #8f8f8f;
font-size: 0.78rem;
}
.game-settings-menu__slider input[type="range"] {
width: 100%;
accent-color: #ffffff;
}
.game-settings-menu__checkbox {
justify-content: flex-start;
cursor: pointer;
}
.game-settings-menu__checkbox input {
width: 18px;
height: 18px;
accent-color: #ffffff;
}
.game-settings-menu__choice-group {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.game-settings-menu__choice-group--stacked {
grid-template-columns: 1fr;
}
.game-settings-menu__choice-group button,
.game-settings-menu__quit {
width: 100%;
padding: 11px 12px;
border: 1px solid #242424;
border-radius: 14px;
background: #101010;
color: #f2f2f2;
cursor: pointer;
font-size: 0.88rem;
font-weight: 680;
}
.game-settings-menu__choice-group button.active {
border-color: #ffffff;
background: #ffffff;
color: #050505;
}
.game-settings-menu__quit {
margin-top: 8px;
border-color: rgba(248, 113, 113, 0.35);
color: #fecaca;
}
/* Debug overlay panels */ /* Debug overlay panels */
.debug-overlay-layout { .debug-overlay-layout {
position: fixed; position: fixed;
+87
View File
@@ -0,0 +1,87 @@
import { create } from "zustand";
import { AudioManager } from "@/managers/AudioManager";
import type { AudioCategory } from "@/managers/AudioManager";
export type SubtitleLanguage = "fr" | "en";
export type RepairRuntime = "js" | "python";
interface SettingsState {
isSettingsMenuOpen: boolean;
musicVolume: number;
sfxVolume: number;
dialogueVolume: number;
subtitlesEnabled: boolean;
subtitleLanguage: SubtitleLanguage;
repairRuntime: RepairRuntime;
}
interface SettingsActions {
setSettingsMenuOpen: (open: boolean) => void;
setMusicVolume: (volume: number) => void;
setSfxVolume: (volume: number) => void;
setDialogueVolume: (volume: number) => void;
setSubtitlesEnabled: (enabled: boolean) => void;
setSubtitleLanguage: (language: SubtitleLanguage) => void;
setRepairRuntime: (runtime: RepairRuntime) => void;
resetSettings: () => void;
}
type SettingsStore = SettingsState & SettingsActions;
const DEFAULT_SETTINGS: SettingsState = {
isSettingsMenuOpen: false,
musicVolume: 1,
sfxVolume: 1,
dialogueVolume: 1,
subtitlesEnabled: true,
subtitleLanguage: "fr",
repairRuntime: "js",
};
function clampVolume(volume: number): number {
return Math.max(0, Math.min(1, volume));
}
function setAudioCategoryVolume(
category: AudioCategory,
volume: number,
): number {
const nextVolume = clampVolume(volume);
AudioManager.getInstance().setCategoryVolume(category, nextVolume);
return nextVolume;
}
function applyDefaultAudioSettings(): void {
AudioManager.getInstance().setCategoryVolume(
"music",
DEFAULT_SETTINGS.musicVolume,
);
AudioManager.getInstance().setCategoryVolume(
"sfx",
DEFAULT_SETTINGS.sfxVolume,
);
AudioManager.getInstance().setCategoryVolume(
"dialogue",
DEFAULT_SETTINGS.dialogueVolume,
);
}
applyDefaultAudioSettings();
export const useSettingsStore = create<SettingsStore>()((set) => ({
...DEFAULT_SETTINGS,
setSettingsMenuOpen: (isSettingsMenuOpen) => set({ isSettingsMenuOpen }),
setMusicVolume: (volume) =>
set({ musicVolume: setAudioCategoryVolume("music", volume) }),
setSfxVolume: (volume) =>
set({ sfxVolume: setAudioCategoryVolume("sfx", volume) }),
setDialogueVolume: (volume) =>
set({ dialogueVolume: setAudioCategoryVolume("dialogue", volume) }),
setSubtitlesEnabled: (subtitlesEnabled) => set({ subtitlesEnabled }),
setSubtitleLanguage: (subtitleLanguage) => set({ subtitleLanguage }),
setRepairRuntime: (repairRuntime) => set({ repairRuntime }),
resetSettings: () => {
applyDefaultAudioSettings();
set(DEFAULT_SETTINGS);
},
}));
+14
View File
@@ -24,6 +24,7 @@ import {
PLAYER_XZ_DAMPING_FACTOR, PLAYER_XZ_DAMPING_FACTOR,
} from "@/data/player/playerConfig"; } from "@/data/player/playerConfig";
import { InteractionManager } from "@/managers/InteractionManager"; import { InteractionManager } from "@/managers/InteractionManager";
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
import type { Vector3Tuple } from "@/types/three/three"; import type { Vector3Tuple } from "@/types/three/three";
type Keys = { type Keys = {
@@ -108,6 +109,8 @@ export function PlayerController({
const interaction = InteractionManager.getInstance(); const interaction = InteractionManager.getInstance();
const handleKeyDown = (event: KeyboardEvent): void => { const handleKeyDown = (event: KeyboardEvent): void => {
if (useSettingsStore.getState().isSettingsMenuOpen) return;
if (setMovementKey(keys.current, event.key, true)) { if (setMovementKey(keys.current, event.key, true)) {
event.preventDefault(); event.preventDefault();
return; return;
@@ -128,12 +131,15 @@ export function PlayerController({
}; };
const handleKeyUp = (event: KeyboardEvent): void => { const handleKeyUp = (event: KeyboardEvent): void => {
if (useSettingsStore.getState().isSettingsMenuOpen) return;
if (setMovementKey(keys.current, event.key, false)) { if (setMovementKey(keys.current, event.key, false)) {
event.preventDefault(); event.preventDefault();
} }
}; };
const handleMouseDown = (event: MouseEvent): void => { const handleMouseDown = (event: MouseEvent): void => {
if (useSettingsStore.getState().isSettingsMenuOpen) return;
if (event.button !== PRIMARY_INTERACT_MOUSE_BUTTON) return; if (event.button !== PRIMARY_INTERACT_MOUSE_BUTTON) return;
if (interaction.getState().focused?.kind === "grab") { if (interaction.getState().focused?.kind === "grab") {
interaction.pressInteract(); interaction.pressInteract();
@@ -141,6 +147,7 @@ export function PlayerController({
}; };
const handleMouseUp = (event: MouseEvent): void => { const handleMouseUp = (event: MouseEvent): void => {
if (useSettingsStore.getState().isSettingsMenuOpen) return;
if (event.button !== PRIMARY_INTERACT_MOUSE_BUTTON) return; if (event.button !== PRIMARY_INTERACT_MOUSE_BUTTON) return;
if (interaction.getState().holding) { if (interaction.getState().holding) {
interaction.releaseInteract(); interaction.releaseInteract();
@@ -162,6 +169,13 @@ export function PlayerController({
}, []); }, []);
useFrame((_, delta) => { useFrame((_, delta) => {
if (useSettingsStore.getState().isSettingsMenuOpen) {
keys.current = { ...DEFAULT_KEYS };
velocity.current.set(0, 0, 0);
wantsJump.current = false;
return;
}
const dt = Math.min(delta, PLAYER_MAX_DELTA); const dt = Math.min(delta, PLAYER_MAX_DELTA);
camera.getWorldDirection(_forward); camera.getWorldDirection(_forward);