Files
La-Fabrik/src/components/ui/GameSettingsMenu.tsx
T
Tom Boullay 3e66e31117 feat(graphics): add max preset (no chunk streaming, LOD@50m)
Restore ultra to its original behaviour (50m chunk streaming, HD within
20m, no fog) and introduce a new max preset that disables chunk streaming
entirely (loads all chunks unconditionally) and pushes the HD/LOD swap
distance to 50m. Add chunkStreamingEnabled flag to GraphicsPresetConfig
so the streaming gate honours the preset. The settings card label shows
'All' when streaming is off.
2026-06-02 13:51:33 +02:00

336 lines
9.9 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`;
const chunkLabel = config.chunkStreamingEnabled
? formatChunkDistance(config.chunkLoadRadius)
: "All";
return (
<button
type="button"
className={active ? "active" : undefined}
onClick={() => onSelect(preset)}
aria-pressed={active}
>
<span>{config.label}</span>
<small>
{chunkLabel} · {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>
);
}