From 486aea9647d42b03c3e6ca41d5c27de2cee23ab9 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Sat, 9 May 2026 23:45:05 +0100 Subject: [PATCH] add: settings menu + menu store --- src/components/ui/GameSettingsMenu.tsx | 203 ++++++++++++++++++++++++ src/components/ui/GameUI.tsx | 2 + src/index.css | 147 +++++++++++++++++ src/managers/stores/useSettingsStore.ts | 87 ++++++++++ src/world/player/PlayerController.tsx | 14 ++ 5 files changed, 453 insertions(+) create mode 100644 src/components/ui/GameSettingsMenu.tsx create mode 100644 src/managers/stores/useSettingsStore.ts 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

+
+ +
+ +
+

Audio

+ + + +
+ +
+

Sous-titres

+ + +
+ {(["fr", "en"] satisfies SubtitleLanguage[]).map((language) => ( + + ))} +
+
+ +
+

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);