diff --git a/src/components/ui/GameSettingsMenu.tsx b/src/components/ui/GameSettingsMenu.tsx
new file mode 100644
index 0000000..e737bb6
--- /dev/null
+++ b/src/components/ui/GameSettingsMenu.tsx
@@ -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 (
+
+ );
+}
+
+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 (
+
+
+
+
+ Pause
+
Options
+
+
+
+
+
+
+
+
+
+ Repair game
+
+ {(["js", "python"] satisfies RepairRuntime[]).map((runtime) => (
+
+ ))}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/ui/GameUI.tsx b/src/components/ui/GameUI.tsx
index 6b3482a..0525e48 100644
--- a/src/components/ui/GameUI.tsx
+++ b/src/components/ui/GameUI.tsx
@@ -1,5 +1,6 @@
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";
@@ -10,6 +11,7 @@ export function GameUI(): React.JSX.Element {
+
>
);
}
diff --git a/src/index.css b/src/index.css
index 6ea0915..5efb0c9 100644
--- a/src/index.css
+++ b/src/index.css
@@ -397,6 +397,153 @@ canvas {
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-layout {
position: fixed;
diff --git a/src/managers/stores/useSettingsStore.ts b/src/managers/stores/useSettingsStore.ts
new file mode 100644
index 0000000..414be31
--- /dev/null
+++ b/src/managers/stores/useSettingsStore.ts
@@ -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()((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);
+ },
+}));
diff --git a/src/world/player/PlayerController.tsx b/src/world/player/PlayerController.tsx
index 6467d84..d7c3e32 100644
--- a/src/world/player/PlayerController.tsx
+++ b/src/world/player/PlayerController.tsx
@@ -24,6 +24,7 @@ import {
PLAYER_XZ_DAMPING_FACTOR,
} from "@/data/player/playerConfig";
import { InteractionManager } from "@/managers/InteractionManager";
+import { useSettingsStore } from "@/managers/stores/useSettingsStore";
import type { Vector3Tuple } from "@/types/three/three";
type Keys = {
@@ -108,6 +109,8 @@ export function PlayerController({
const interaction = InteractionManager.getInstance();
const handleKeyDown = (event: KeyboardEvent): void => {
+ if (useSettingsStore.getState().isSettingsMenuOpen) return;
+
if (setMovementKey(keys.current, event.key, true)) {
event.preventDefault();
return;
@@ -128,12 +131,15 @@ export function PlayerController({
};
const handleKeyUp = (event: KeyboardEvent): void => {
+ if (useSettingsStore.getState().isSettingsMenuOpen) return;
+
if (setMovementKey(keys.current, event.key, false)) {
event.preventDefault();
}
};
const handleMouseDown = (event: MouseEvent): void => {
+ if (useSettingsStore.getState().isSettingsMenuOpen) return;
if (event.button !== PRIMARY_INTERACT_MOUSE_BUTTON) return;
if (interaction.getState().focused?.kind === "grab") {
interaction.pressInteract();
@@ -141,6 +147,7 @@ export function PlayerController({
};
const handleMouseUp = (event: MouseEvent): void => {
+ if (useSettingsStore.getState().isSettingsMenuOpen) return;
if (event.button !== PRIMARY_INTERACT_MOUSE_BUTTON) return;
if (interaction.getState().holding) {
interaction.releaseInteract();
@@ -162,6 +169,13 @@ export function PlayerController({
}, []);
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);
camera.getWorldDirection(_forward);