334 lines
9.8 KiB
TypeScript
334 lines
9.8 KiB
TypeScript
import { useEffect } from "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 { hasSiteBeenVisitedToday } from "@/utils/cookies/siteVisitCookie";
|
|
import { Debug } from "@/utils/debug/Debug";
|
|
|
|
function formatPercent(value: number): string {
|
|
return `${Math.round(value * 100)}%`;
|
|
}
|
|
|
|
interface VolumeSliderProps {
|
|
id: string;
|
|
label: string;
|
|
icon: ReactNode;
|
|
value: number;
|
|
onChange: (value: number) => void;
|
|
}
|
|
|
|
function VolumeSlider({
|
|
id,
|
|
label,
|
|
icon,
|
|
value,
|
|
onChange,
|
|
}: VolumeSliderProps): React.JSX.Element {
|
|
return (
|
|
<label className="game-settings-menu__slider" htmlFor={id}>
|
|
<span>
|
|
<em>
|
|
{icon}
|
|
{label}
|
|
</em>
|
|
<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>
|
|
);
|
|
}
|
|
|
|
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,
|
|
sfxVolume,
|
|
dialogueVolume,
|
|
subtitlesEnabled,
|
|
subtitleLanguage,
|
|
setMusicVolume,
|
|
setSfxVolume,
|
|
setDialogueVolume,
|
|
setSettingsMenuOpen,
|
|
setSubtitlesEnabled,
|
|
setSubtitleLanguage,
|
|
} = 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 handleRestart = (): void => {
|
|
resetGame();
|
|
setSettingsMenuOpen(false);
|
|
window.location.assign(hasSiteBeenVisitedToday() ? "/" : "/site");
|
|
};
|
|
|
|
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>La Fabrik</span>
|
|
<h2>Pause</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>
|
|
|
|
<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="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}
|
|
/>
|
|
<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>
|
|
|
|
<section
|
|
className="game-settings-menu__section"
|
|
aria-labelledby="subtitle-settings-heading"
|
|
>
|
|
<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"
|
|
>
|
|
{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__restart"
|
|
type="button"
|
|
onClick={handleRestart}
|
|
>
|
|
<RotateCcw size={14} aria-hidden="true" />
|
|
Recommencer
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|