feat(world): add map lod graphics presets

This commit is contained in:
Tom Boullay
2026-05-31 19:03:55 +02:00
parent 564a455520
commit 34c198ebfd
13 changed files with 717 additions and 131 deletions
+13 -1
View File
@@ -3,13 +3,25 @@ import {
MergedStaticMapModel, MergedStaticMapModel,
type MergedStaticMapModelProps, type MergedStaticMapModelProps,
} from "@/components/three/world/MergedStaticMapModel"; } from "@/components/three/world/MergedStaticMapModel";
import { getMapLodModelPath } from "@/data/world/mapLodConfig";
import { useMapLodModelPath } from "@/hooks/world/useMapLodModelPath";
const ECOLE_MODEL_PATH = "/models/ecole/model.gltf"; const ECOLE_MODEL_PATH = "/models/ecole/model.gltf";
const ECOLE_LOD_MODEL_PATH = getMapLodModelPath("ecole");
type EcoleModelProps = Omit<MergedStaticMapModelProps, "modelPath">; type EcoleModelProps = Omit<MergedStaticMapModelProps, "modelPath">;
export function EcoleModel(props: EcoleModelProps): React.JSX.Element { export function EcoleModel(props: EcoleModelProps): React.JSX.Element {
return <MergedStaticMapModel modelPath={ECOLE_MODEL_PATH} {...props} />; const modelPath = useMapLodModelPath({
modelName: "ecole",
modelPath: ECOLE_MODEL_PATH,
position: props.position,
});
return <MergedStaticMapModel modelPath={modelPath} {...props} />;
} }
useGLTF.preload(ECOLE_MODEL_PATH); useGLTF.preload(ECOLE_MODEL_PATH);
if (ECOLE_LOD_MODEL_PATH) {
useGLTF.preload(ECOLE_LOD_MODEL_PATH);
}
+158 -59
View File
@@ -1,7 +1,22 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { RotateCcw, X } from "lucide-react"; import type { ReactNode } from "react";
import {
Captions,
Gauge,
LogOut,
Music2,
RotateCcw,
Volume2,
X,
} from "lucide-react";
import {
GRAPHICS_PRESET_KEYS,
GRAPHICS_PRESETS,
type GraphicsPreset,
} from "@/data/world/graphicsConfig";
import { useGameStore } from "@/managers/stores/useGameStore"; import { useGameStore } from "@/managers/stores/useGameStore";
import { useSettingsStore } from "@/managers/stores/useSettingsStore"; import { useSettingsStore } from "@/managers/stores/useSettingsStore";
import { useWorldSettingsStore } from "@/managers/stores/useWorldSettingsStore";
import type { SubtitleLanguage } from "@/types/settings/settings"; import type { SubtitleLanguage } from "@/types/settings/settings";
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled"; import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
@@ -21,6 +36,7 @@ function clearCookies(): void {
interface VolumeSliderProps { interface VolumeSliderProps {
id: string; id: string;
label: string; label: string;
icon: ReactNode;
value: number; value: number;
onChange: (value: number) => void; onChange: (value: number) => void;
} }
@@ -28,13 +44,17 @@ interface VolumeSliderProps {
function VolumeSlider({ function VolumeSlider({
id, id,
label, label,
icon,
value, value,
onChange, onChange,
}: VolumeSliderProps): React.JSX.Element { }: VolumeSliderProps): React.JSX.Element {
return ( return (
<label className="game-settings-menu__slider" htmlFor={id}> <label className="game-settings-menu__slider" htmlFor={id}>
<span> <span>
{label} <em>
{icon}
{label}
</em>
<strong>{formatPercent(value)}</strong> <strong>{formatPercent(value)}</strong>
</span> </span>
<input <input
@@ -50,8 +70,50 @@ function VolumeSlider({
); );
} }
function formatChunkDistance(distance: number): string {
return `${distance}m`;
}
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 { export function GameSettingsMenu(): React.JSX.Element | null {
const resetGame = useGameStore((state) => state.resetGame); const resetGame = useGameStore((state) => state.resetGame);
const graphicsPreset = useWorldSettingsStore(
(state) => state.graphics.preset,
);
const setGraphicsPreset = useWorldSettingsStore(
(state) => state.setGraphicsPreset,
);
const { const {
isSettingsMenuOpen, isSettingsMenuOpen,
musicVolume, musicVolume,
@@ -103,8 +165,8 @@ export function GameSettingsMenu(): React.JSX.Element | null {
<div className="game-settings-menu__panel"> <div className="game-settings-menu__panel">
<header className="game-settings-menu__header"> <header className="game-settings-menu__header">
<div> <div>
<span>Pause</span> <span>La Fabrik</span>
<h2>Options</h2> <h2>Pause</h2>
</div> </div>
<button <button
className="game-settings-menu__close" className="game-settings-menu__close"
@@ -116,62 +178,98 @@ export function GameSettingsMenu(): React.JSX.Element | null {
</button> </button>
</header> </header>
<section <div className="game-settings-menu__grid">
className="game-settings-menu__section" <section
aria-labelledby="audio-settings-heading" className="game-settings-menu__section game-settings-menu__section--wide"
> aria-labelledby="graphics-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) => ( <div className="game-settings-menu__section-title">
<button <Gauge size={16} aria-hidden="true" />
key={language} <h3 id="graphics-settings-heading">Performance</h3>
type="button" </div>
className={subtitleLanguage === language ? "active" : undefined} <div
onClick={() => setSubtitleLanguage(language)} className="game-settings-menu__choice-group game-settings-menu__choice-group--presets"
aria-pressed={subtitleLanguage === language} aria-label="Preset graphique"
> >
{language === "fr" ? "Francais" : "English"} {GRAPHICS_PRESET_KEYS.map((preset) => (
</button> <GraphicsPresetButton
))} key={preset}
</div> preset={preset}
</section> 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>
</section>
</div>
{showDebugRestart ? ( {showDebugRestart ? (
<button <button
@@ -189,6 +287,7 @@ export function GameSettingsMenu(): React.JSX.Element | null {
type="button" type="button"
onClick={handleQuit} onClick={handleQuit}
> >
<LogOut size={14} aria-hidden="true" />
Quitter Quitter
</button> </button>
</div> </div>
+51
View File
@@ -1,4 +1,55 @@
import { CHUNK_CONFIG } from "@/data/world/chunkStreamingConfig";
export const GRAPHICS_PRESET_KEYS = ["low", "medium", "high", "ultra"] as const;
export type GraphicsPreset = (typeof GRAPHICS_PRESET_KEYS)[number];
export interface GraphicsPresetConfig {
chunkLoadRadius: number;
chunkUnloadRadius: number;
fogEnabled: boolean;
forceLodModels: boolean;
label: string;
lodHighDetailDistance: number;
}
export const GRAPHICS_PRESETS = {
low: {
label: "Basse",
chunkLoadRadius: 10,
chunkUnloadRadius: 18,
fogEnabled: true,
forceLodModels: true,
lodHighDetailDistance: 0,
},
medium: {
label: "Moyenne",
chunkLoadRadius: 20,
chunkUnloadRadius: 30,
fogEnabled: true,
forceLodModels: true,
lodHighDetailDistance: 0,
},
high: {
label: "High",
chunkLoadRadius: CHUNK_CONFIG.loadRadius,
chunkUnloadRadius: CHUNK_CONFIG.unloadRadius,
fogEnabled: false,
forceLodModels: false,
lodHighDetailDistance: 10,
},
ultra: {
label: "Ultra",
chunkLoadRadius: 50,
chunkUnloadRadius: 65,
fogEnabled: false,
forceLodModels: false,
lodHighDetailDistance: 20,
},
} as const satisfies Record<GraphicsPreset, GraphicsPresetConfig>;
export const GRAPHICS_DEFAULTS = { export const GRAPHICS_DEFAULTS = {
preset: "high" as GraphicsPreset,
dynamicGrass: true, dynamicGrass: true,
dynamicTrees: true, dynamicTrees: true,
dynamicClouds: true, dynamicClouds: true,
+44
View File
@@ -0,0 +1,44 @@
import {
GRAPHICS_PRESETS,
type GraphicsPreset,
} from "@/data/world/graphicsConfig";
export const MAP_LOD_MODEL_PATHS = {
ebike: "/models/ebike-LOD/model.gltf",
eolienne: "/models/eolienne-LOD/model.gltf",
pylone: "/models/pylone-LOD/model.gltf",
boiteimmeuble: "/models/boiteimmeuble-LOD/model.gltf",
ecole: "/models/ecole-LOD/model.gltf",
immeuble1: "/models/immeuble1-LOD/model.gltf",
maison1: "/models/maison1-LOD/model.gltf",
panneauaffichage: "/models/panneauaffichage-LOD/model.gltf",
talkie: "/models/talkie-LOD/model.gltf",
} as const satisfies Record<string, string>;
export function getMapLodModelPath(modelName: string): string | null {
return (
MAP_LOD_MODEL_PATHS[modelName as keyof typeof MAP_LOD_MODEL_PATHS] ?? null
);
}
export function selectMapModelPathByDistance({
distance,
modelName,
modelPath,
preset,
}: {
distance: number;
modelName: string;
modelPath: string;
preset: GraphicsPreset;
}): string {
const lodModelPath = getMapLodModelPath(modelName);
if (!lodModelPath) return modelPath;
const presetConfig = GRAPHICS_PRESETS[preset];
if (presetConfig.forceLodModels) return lodModelPath;
return distance <= presetConfig.lodHighDetailDistance
? modelPath
: lodModelPath;
}
+15
View File
@@ -1,5 +1,20 @@
import { GRAPHICS_PRESETS } from "@/data/world/graphicsConfig";
import type {
GraphicsPreset,
GraphicsPresetConfig,
} from "@/data/world/graphicsConfig";
import { useWorldSettingsStore } from "@/managers/stores/useWorldSettingsStore"; import { useWorldSettingsStore } from "@/managers/stores/useWorldSettingsStore";
export function useGraphicsPreset(): GraphicsPreset {
return useWorldSettingsStore((state) => state.graphics.preset);
}
export function useGraphicsPresetConfig(): GraphicsPresetConfig {
return useWorldSettingsStore(
(state) => GRAPHICS_PRESETS[state.graphics.preset],
);
}
export function useDynamicGrass(): boolean { export function useDynamicGrass(): boolean {
return useWorldSettingsStore((state) => state.graphics.dynamicGrass); return useWorldSettingsStore((state) => state.graphics.dynamicGrass);
} }
+64
View File
@@ -0,0 +1,64 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { CHUNK_CONFIG } from "@/data/world/chunkStreamingConfig";
import { selectMapModelPathByDistance } from "@/data/world/mapLodConfig";
import { useGraphicsPreset } from "@/hooks/world/useGraphicsSettings";
interface UseMapLodModelPathArgs {
modelName: string;
modelPath: string;
position: readonly [number, number, number];
}
export function useMapLodModelPath({
modelName,
modelPath,
position,
}: UseMapLodModelPathArgs): string {
const camera = useThree((state) => state.camera);
const graphicsPreset = useGraphicsPreset();
const lastUpdateRef = useRef(-CHUNK_CONFIG.updateInterval);
const initialModelPath = selectMapModelPathByDistance({
distance: Math.hypot(
position[0] - camera.position.x,
position[2] - camera.position.z,
),
modelName,
modelPath,
preset: graphicsPreset,
});
const activeModelPathRef = useRef(initialModelPath);
const [activeModelPath, setActiveModelPath] = useState(initialModelPath);
const updateModelPath = useCallback(() => {
const distance = Math.hypot(
position[0] - camera.position.x,
position[2] - camera.position.z,
);
const nextModelPath = selectMapModelPathByDistance({
distance,
modelName,
modelPath,
preset: graphicsPreset,
});
if (nextModelPath === activeModelPathRef.current) return;
activeModelPathRef.current = nextModelPath;
setActiveModelPath(nextModelPath);
}, [camera, graphicsPreset, modelName, modelPath, position]);
useEffect(() => {
updateModelPath();
}, [updateModelPath]);
useFrame(({ clock }) => {
const now = clock.elapsedTime * 1000;
if (now - lastUpdateRef.current < CHUNK_CONFIG.updateInterval) return;
lastUpdateRef.current = now;
updateModelPath();
});
return activeModelPath;
}
+23 -5
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useFrame, useThree } from "@react-three/fiber"; import { useFrame, useThree } from "@react-three/fiber";
import { CHUNK_CONFIG } from "@/data/world/chunkStreamingConfig"; import { CHUNK_CONFIG } from "@/data/world/chunkStreamingConfig";
@@ -8,6 +8,11 @@ export interface WorldChunkLike {
key: string; key: string;
} }
interface WorldChunkVisibilityConfig {
loadRadius: number;
unloadRadius: number;
}
function areSetsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean { function areSetsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
return a.size === b.size && [...a].every((key) => b.has(key)); return a.size === b.size && [...a].every((key) => b.has(key));
} }
@@ -15,6 +20,7 @@ function areSetsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
export function useVisibleWorldChunks<TChunk extends WorldChunkLike>( export function useVisibleWorldChunks<TChunk extends WorldChunkLike>(
chunks: readonly TChunk[], chunks: readonly TChunk[],
streamingEnabled: boolean, streamingEnabled: boolean,
visibilityConfig: WorldChunkVisibilityConfig = CHUNK_CONFIG,
): readonly TChunk[] { ): readonly TChunk[] {
const camera = useThree((state) => state.camera); const camera = useThree((state) => state.camera);
const lastUpdateRef = useRef(-CHUNK_CONFIG.updateInterval); const lastUpdateRef = useRef(-CHUNK_CONFIG.updateInterval);
@@ -35,8 +41,8 @@ export function useVisibleWorldChunks<TChunk extends WorldChunkLike>(
); );
const wasActive = activeChunkKeysRef.current.has(chunk.key); const wasActive = activeChunkKeysRef.current.has(chunk.key);
const radius = wasActive const radius = wasActive
? CHUNK_CONFIG.unloadRadius ? visibilityConfig.unloadRadius
: CHUNK_CONFIG.loadRadius; : visibilityConfig.loadRadius;
if (distance <= radius) { if (distance <= radius) {
nextKeys.add(chunk.key); nextKeys.add(chunk.key);
@@ -47,7 +53,18 @@ export function useVisibleWorldChunks<TChunk extends WorldChunkLike>(
activeChunkKeysRef.current = nextKeys; activeChunkKeysRef.current = nextKeys;
setActiveChunkKeys(nextKeys); setActiveChunkKeys(nextKeys);
}, [camera, chunks]); }, [
camera,
chunks,
visibilityConfig.loadRadius,
visibilityConfig.unloadRadius,
]);
useEffect(() => {
if (!streamingEnabled) return;
updateActiveChunks();
}, [streamingEnabled, updateActiveChunks]);
useFrame(({ clock }) => { useFrame(({ clock }) => {
if (!streamingEnabled) return; if (!streamingEnabled) return;
@@ -71,7 +88,7 @@ export function useVisibleWorldChunks<TChunk extends WorldChunkLike>(
Math.hypot( Math.hypot(
chunk.centerX - camera.position.x, chunk.centerX - camera.position.x,
chunk.centerZ - camera.position.z, chunk.centerZ - camera.position.z,
) <= CHUNK_CONFIG.loadRadius ) <= visibilityConfig.loadRadius
); );
}); });
}, [ }, [
@@ -80,5 +97,6 @@ export function useVisibleWorldChunks<TChunk extends WorldChunkLike>(
camera.position.z, camera.position.z,
chunks, chunks,
streamingEnabled, streamingEnabled,
visibilityConfig.loadRadius,
]); ]);
} }
+194 -49
View File
@@ -1218,22 +1218,49 @@ canvas {
z-index: 40; z-index: 40;
display: grid; display: grid;
place-items: center; place-items: center;
padding: 20px; padding: clamp(18px, 4vw, 42px);
background: rgba(0, 0, 0, 0.6); background:
linear-gradient(180deg, rgba(4, 10, 18, 0.64), rgba(2, 6, 12, 0.86)),
rgba(0, 0, 0, 0.72);
color: #ffffff; color: #ffffff;
pointer-events: auto; pointer-events: auto;
backdrop-filter: blur(10px); backdrop-filter: blur(14px);
} }
.game-settings-menu__panel { .game-settings-menu__panel {
width: min(460px, 100%); position: relative;
max-height: calc(100vh - 40px); width: min(760px, 100%);
max-height: calc(100vh - clamp(36px, 8vw, 84px));
overflow-y: auto; overflow-y: auto;
padding: 18px; padding: clamp(16px, 2.4vw, 22px);
border: 1px solid rgba(255, 255, 255, 0.12); border: 1px solid rgba(125, 211, 252, 0.38);
border-radius: 24px; border-radius: 8px;
background: rgba(8, 8, 8, 0.94); background:
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.55); linear-gradient(180deg, rgba(8, 20, 34, 0.94), rgba(3, 8, 14, 0.96)),
rgba(4, 8, 12, 0.96);
box-shadow:
0 28px 90px rgba(0, 0, 0, 0.58),
inset 0 0 0 1px rgba(255, 255, 255, 0.05),
0 0 34px rgba(56, 189, 248, 0.16);
scrollbar-width: thin;
scrollbar-color: rgba(125, 211, 252, 0.55) transparent;
}
.game-settings-menu__panel::before {
position: absolute;
inset: 0;
z-index: -1;
border-radius: 8px;
background: repeating-linear-gradient(
180deg,
rgba(125, 211, 252, 0.08) 0,
rgba(125, 211, 252, 0.08) 1px,
transparent 1px,
transparent 8px
);
content: "";
opacity: 0.35;
pointer-events: none;
} }
.game-settings-menu__header { .game-settings-menu__header {
@@ -1241,21 +1268,26 @@ canvas {
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 16px;
padding: 4px 4px 16px; padding: 4px 2px 18px;
} }
.game-settings-menu__header span { .game-settings-menu__header span {
color: #8f8f8f; color: #7dd3fc;
font-size: 0.7rem; font-family: var(--font-body);
font-size: 0.72rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.16em; letter-spacing: 0;
text-transform: uppercase; text-transform: uppercase;
} }
.game-settings-menu__header h2 { .game-settings-menu__header h2 {
margin: 0.25rem 0 0; margin: 0.25rem 0 0;
font-size: 1.8rem; font-family: "Nersans One", var(--font-primary);
letter-spacing: -0.06em; font-size: clamp(2rem, 4vw, 3.2rem);
font-weight: 400;
letter-spacing: 0;
line-height: 0.92;
text-transform: uppercase;
} }
.game-settings-menu__close { .game-settings-menu__close {
@@ -1263,26 +1295,59 @@ canvas {
place-items: center; place-items: center;
width: 40px; width: 40px;
height: 40px; height: 40px;
border: 1px solid rgba(255, 255, 255, 0.14); border: 1px solid rgba(125, 211, 252, 0.35);
border-radius: 999px; border-radius: 8px;
background: #111111; background: rgba(10, 22, 34, 0.84);
color: #ffffff; color: #dff7ff;
cursor: pointer; cursor: pointer;
transition:
background 160ms ease,
border-color 160ms ease,
color 160ms ease;
}
.game-settings-menu__close:hover,
.game-settings-menu__close:focus-visible {
border-color: rgba(255, 255, 255, 0.78);
background: rgba(125, 211, 252, 0.18);
color: #ffffff;
outline: none;
}
.game-settings-menu__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
} }
.game-settings-menu__section { .game-settings-menu__section {
display: grid; display: grid;
gap: 12px; gap: 12px;
padding: 16px 4px; align-content: start;
border-top: 1px solid rgba(255, 255, 255, 0.1); padding: 14px;
border: 1px solid rgba(125, 211, 252, 0.2);
border-radius: 8px;
background: rgba(5, 14, 24, 0.64);
}
.game-settings-menu__section--wide {
grid-column: 1 / -1;
}
.game-settings-menu__section-title {
display: flex;
align-items: center;
gap: 8px;
color: #7dd3fc;
} }
.game-settings-menu__section h3 { .game-settings-menu__section h3 {
margin: 0; margin: 0;
color: #d7d7d7; color: #dff7ff;
font-family: var(--font-body);
font-size: 0.78rem; font-size: 0.78rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.12em; letter-spacing: 0;
text-transform: uppercase; text-transform: uppercase;
} }
@@ -1297,19 +1362,28 @@ canvas {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
color: #f2f2f2; color: #edfaff;
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 650; font-weight: 650;
} }
.game-settings-menu__slider em {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
font-style: normal;
}
.game-settings-menu__slider strong { .game-settings-menu__slider strong {
color: #8f8f8f; color: #7dd3fc;
font-size: 0.78rem; font-size: 0.78rem;
font-variant-numeric: tabular-nums;
} }
.game-settings-menu__slider input[type="range"] { .game-settings-menu__slider input[type="range"] {
width: 100%; width: 100%;
accent-color: #ffffff; accent-color: #7dd3fc;
} }
.game-settings-menu__checkbox { .game-settings-menu__checkbox {
@@ -1320,7 +1394,7 @@ canvas {
.game-settings-menu__checkbox input { .game-settings-menu__checkbox input {
width: 18px; width: 18px;
height: 18px; height: 18px;
accent-color: #ffffff; accent-color: #7dd3fc;
} }
.game-settings-menu__choice-group { .game-settings-menu__choice-group {
@@ -1329,36 +1403,91 @@ canvas {
gap: 8px; gap: 8px;
} }
.game-settings-menu__choice-group--stacked { .game-settings-menu__choice-group--presets {
grid-template-columns: 1fr; grid-template-columns: repeat(4, minmax(0, 1fr));
} }
.game-settings-menu__choice-group button, .game-settings-menu__choice-group button,
.game-settings-menu__restart, .game-settings-menu__restart,
.game-settings-menu__quit { .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__restart {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 8px; gap: 8px;
margin-top: 8px; width: 100%;
min-height: 44px;
padding: 11px 12px;
border: 1px solid rgba(125, 211, 252, 0.2);
border-radius: 8px;
background: rgba(6, 18, 30, 0.72);
color: #edfaff;
cursor: pointer;
font-size: 0.88rem;
font-weight: 680;
transition:
background 160ms ease,
border-color 160ms ease,
color 160ms ease,
transform 120ms ease;
}
.game-settings-menu__choice-group--presets button {
display: grid;
justify-items: start;
gap: 4px;
min-height: 72px;
text-align: left;
}
.game-settings-menu__choice-group button span {
min-width: 0;
overflow-wrap: anywhere;
}
.game-settings-menu__choice-group button small {
color: rgba(223, 247, 255, 0.62);
font-size: 0.68rem;
font-weight: 700;
line-height: 1.35;
}
.game-settings-menu__choice-group button:hover,
.game-settings-menu__choice-group button:focus-visible,
.game-settings-menu__restart:hover,
.game-settings-menu__restart:focus-visible,
.game-settings-menu__quit:hover,
.game-settings-menu__quit:focus-visible {
border-color: rgba(255, 255, 255, 0.64);
background: rgba(125, 211, 252, 0.16);
color: #ffffff;
outline: none;
}
.game-settings-menu__choice-group button:active,
.game-settings-menu__restart:active,
.game-settings-menu__quit:active {
transform: translateY(1px);
}
.game-settings-menu__choice-group button.active {
border-color: rgba(125, 211, 252, 0.92);
background: linear-gradient(
180deg,
rgba(125, 211, 252, 0.28),
rgba(14, 116, 144, 0.22)
);
color: #ffffff;
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.08),
0 0 22px rgba(56, 189, 248, 0.22);
}
.game-settings-menu__choice-group button.active small {
color: rgba(255, 255, 255, 0.78);
}
.game-settings-menu__restart {
margin-top: 12px;
border-color: rgba(96, 165, 250, 0.35); border-color: rgba(96, 165, 250, 0.35);
color: #bfdbfe; color: #bfdbfe;
} }
@@ -1369,6 +1498,22 @@ canvas {
color: #fecaca; color: #fecaca;
} }
@media (max-width: 720px) {
.game-settings-menu {
align-items: stretch;
padding: 14px;
}
.game-settings-menu__panel {
max-height: calc(100vh - 28px);
}
.game-settings-menu__grid,
.game-settings-menu__choice-group--presets {
grid-template-columns: 1fr;
}
}
/* Debug overlay panels */ /* Debug overlay panels */
.debug-overlay-layout { .debug-overlay-layout {
position: fixed; position: fixed;
@@ -4,6 +4,7 @@ import { FOG_CONFIG, type FogState } from "@/data/world/fogConfig";
import { WIND_DEFAULTS, type WindState } from "@/data/world/windConfig"; import { WIND_DEFAULTS, type WindState } from "@/data/world/windConfig";
import { import {
GRAPHICS_DEFAULTS, GRAPHICS_DEFAULTS,
type GraphicsPreset,
type GraphicsState, type GraphicsState,
} from "@/data/world/graphicsConfig"; } from "@/data/world/graphicsConfig";
@@ -21,6 +22,7 @@ interface WorldSettingsActions {
setWindSpeed: (speed: number) => void; setWindSpeed: (speed: number) => void;
setWindDirection: (direction: number) => void; setWindDirection: (direction: number) => void;
setWindStrength: (strength: number) => void; setWindStrength: (strength: number) => void;
setGraphicsPreset: (preset: GraphicsPreset) => void;
setGraphics: (graphics: Partial<GraphicsState>) => void; setGraphics: (graphics: Partial<GraphicsState>) => void;
setDynamicGrass: (enabled: boolean) => void; setDynamicGrass: (enabled: boolean) => void;
setDynamicTrees: (enabled: boolean) => void; setDynamicTrees: (enabled: boolean) => void;
@@ -82,6 +84,11 @@ export const useWorldSettingsStore = create<WorldSettingsStore>()((set) => ({
graphics: { ...state.graphics, ...graphicsUpdate }, graphics: { ...state.graphics, ...graphicsUpdate },
})), })),
setGraphicsPreset: (preset) =>
set((state) => ({
graphics: { ...state.graphics, preset },
})),
setDynamicGrass: (dynamicGrass) => setDynamicGrass: (dynamicGrass) =>
set((state) => ({ set((state) => ({
graphics: { ...state.graphics, dynamicGrass }, graphics: { ...state.graphics, dynamicGrass },
+8 -2
View File
@@ -2,11 +2,11 @@ import type { ReactNode } from "react";
import { import {
Component, Component,
Suspense, Suspense,
useCallback,
useEffect, useEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
useCallback,
} from "react"; } from "react";
import * as THREE from "three"; import * as THREE from "three";
import { useClonedObject } from "@/hooks/three/useClonedObject"; import { useClonedObject } from "@/hooks/three/useClonedObject";
@@ -23,6 +23,7 @@ import {
} from "@/managers/stores/useMapPerformanceStore"; } from "@/managers/stores/useMapPerformanceStore";
import { useGameStore } from "@/managers/stores/useGameStore"; import { useGameStore } from "@/managers/stores/useGameStore";
import { useRepairMissionAnchorStore } from "@/managers/stores/useRepairMissionAnchorStore"; import { useRepairMissionAnchorStore } from "@/managers/stores/useRepairMissionAnchorStore";
import { useMapLodModelPath } from "@/hooks/world/useMapLodModelPath";
import { GameMapCollision } from "@/world/GameMapCollision"; import { GameMapCollision } from "@/world/GameMapCollision";
import { GeneratedMapNodeInstance } from "@/world/map-generated/GeneratedMapNodeInstance"; import { GeneratedMapNodeInstance } from "@/world/map-generated/GeneratedMapNodeInstance";
import { isGeneratedMapModelName } from "@/data/world/generatedMapModelConfig"; import { isGeneratedMapModelName } from "@/data/world/generatedMapModelConfig";
@@ -362,6 +363,11 @@ function ModelInstance({
const { position, rotation, scale } = node; const { position, rotation, scale } = node;
const scaleMultiplier = getMapSingleModelScaleMultiplier(node.name); const scaleMultiplier = getMapSingleModelScaleMultiplier(node.name);
const baseScale = normalizeMapScale(scale); const baseScale = normalizeMapScale(scale);
const activeModelUrl = useMapLodModelPath({
modelName: node.name,
modelPath: modelUrl,
position: node.position,
});
const normalizedScale = useMemo( const normalizedScale = useMemo(
() => () =>
[ [
@@ -372,7 +378,7 @@ function ModelInstance({
[baseScale, scaleMultiplier], [baseScale, scaleMultiplier],
); );
const terrainHeight = useTerrainHeightSampler(); const terrainHeight = useTerrainHeightSampler();
const { scene } = useLoggedGLTF(modelUrl, { const { scene } = useLoggedGLTF(activeModelUrl, {
scope: "GameMap.ModelInstance", scope: "GameMap.ModelInstance",
position, position,
rotation, rotation,
+5 -1
View File
@@ -6,6 +6,7 @@ import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useDebugStore } from "@/hooks/debug/useDebugStore"; import { useDebugStore } from "@/hooks/debug/useDebugStore";
import { useSceneMode } from "@/hooks/debug/useSceneMode"; import { useSceneMode } from "@/hooks/debug/useSceneMode";
import { useFogSettings } from "@/hooks/world/useFogSettings"; import { useFogSettings } from "@/hooks/world/useFogSettings";
import { useGraphicsPresetConfig } from "@/hooks/world/useGraphicsSettings";
import { LIGHTING_STATE } from "@/world/lightingState"; import { LIGHTING_STATE } from "@/world/lightingState";
const tempSunFogColor = new THREE.Color(); const tempSunFogColor = new THREE.Color();
@@ -23,11 +24,14 @@ export function FogSystem(): React.JSX.Element | null {
const cameraMode = useCameraMode(); const cameraMode = useCameraMode();
const sceneMode = useSceneMode(); const sceneMode = useSceneMode();
const fog = useFogSettings(); const fog = useFogSettings();
const graphicsPreset = useGraphicsPresetConfig();
const fogEnabled = useDebugStore((debug) => debug.getFogEnabled()); const fogEnabled = useDebugStore((debug) => debug.getFogEnabled());
const scene = useThree((state) => state.scene); const scene = useThree((state) => state.scene);
const fogColor = useMemo(() => getLightingFogColor(new THREE.Color()), []); const fogColor = useMemo(() => getLightingFogColor(new THREE.Color()), []);
const shouldShowFog = const shouldShowFog =
fogEnabled && sceneMode === "game" && cameraMode === "player"; (fogEnabled || graphicsPreset.fogEnabled) &&
sceneMode === "game" &&
cameraMode === "player";
useFrame(() => { useFrame(() => {
if (!scene.fog) return; if (!scene.fog) return;
+129 -13
View File
@@ -1,7 +1,20 @@
import { Suspense, useMemo } from "react"; import {
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { CHUNK_CONFIG } from "@/data/world/chunkStreamingConfig"; import { CHUNK_CONFIG } from "@/data/world/chunkStreamingConfig";
import { selectMapModelPathByDistance } from "@/data/world/mapLodConfig";
import { useCameraMode } from "@/hooks/debug/useCameraMode"; import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useSceneMode } from "@/hooks/debug/useSceneMode"; import { useSceneMode } from "@/hooks/debug/useSceneMode";
import {
useGraphicsPreset,
useGraphicsPresetConfig,
} from "@/hooks/world/useGraphicsSettings";
import { useVisibleWorldChunks } from "@/hooks/world/useVisibleWorldChunks"; import { useVisibleWorldChunks } from "@/hooks/world/useVisibleWorldChunks";
import { import {
isMapModelVisible, isMapModelVisible,
@@ -16,6 +29,7 @@ import {
} from "@/data/world/mapInstancingConfig"; } from "@/data/world/mapInstancingConfig";
import { useMapInstancingData } from "@/hooks/world/useMapInstancingData"; import { useMapInstancingData } from "@/hooks/world/useMapInstancingData";
import type { MapAssetInstance } from "@/types/map/mapScene"; import type { MapAssetInstance } from "@/types/map/mapScene";
import type { GraphicsPreset } from "@/data/world/graphicsConfig";
import { createWorldInstanceChunks } from "@/utils/world/chunkInstances"; import { createWorldInstanceChunks } from "@/utils/world/chunkInstances";
interface MapInstancingSystemProps { interface MapInstancingSystemProps {
@@ -47,12 +61,88 @@ function createMapAssetChunks(
}); });
} }
function areChunkModelPathsEqual(
a: ReadonlyMap<string, string>,
b: ReadonlyMap<string, string>,
): boolean {
return (
a.size === b.size && [...a].every(([key, value]) => b.get(key) === value)
);
}
function getNearestChunkInstanceDistance(
chunk: MapAssetChunk,
cameraX: number,
cameraZ: number,
): number {
return chunk.instances.reduce((nearestDistance, instance) => {
const distance = Math.hypot(
instance.position[0] - cameraX,
instance.position[2] - cameraZ,
);
return Math.min(nearestDistance, distance);
}, Number.POSITIVE_INFINITY);
}
function useChunkModelPaths(
chunks: readonly MapAssetChunk[],
preset: GraphicsPreset,
): ReadonlyMap<string, string> {
const camera = useThree((state) => state.camera);
const lastUpdateRef = useRef(-CHUNK_CONFIG.updateInterval);
const modelPathsRef = useRef<Map<string, string>>(new Map());
const [modelPaths, setModelPaths] = useState<ReadonlyMap<string, string>>(
() => new Map(),
);
const updateModelPaths = useCallback(() => {
const cameraX = camera.position.x;
const cameraZ = camera.position.z;
const nextModelPaths = new Map<string, string>();
for (const chunk of chunks) {
const distance = getNearestChunkInstanceDistance(chunk, cameraX, cameraZ);
const modelPath = selectMapModelPathByDistance({
distance,
modelName: chunk.config.mapName,
modelPath: chunk.config.modelPath,
preset,
});
nextModelPaths.set(chunk.key, modelPath);
}
if (areChunkModelPathsEqual(nextModelPaths, modelPathsRef.current)) return;
modelPathsRef.current = nextModelPaths;
setModelPaths(nextModelPaths);
}, [camera, chunks, preset]);
useEffect(() => {
updateModelPaths();
}, [updateModelPaths]);
useFrame(({ clock }) => {
const now = clock.elapsedTime * 1000;
if (now - lastUpdateRef.current < CHUNK_CONFIG.updateInterval) return;
lastUpdateRef.current = now;
updateModelPaths();
});
return modelPaths;
}
export function MapInstancingSystem({ export function MapInstancingSystem({
onlyMapName = null, onlyMapName = null,
streaming = true, streaming = true,
}: MapInstancingSystemProps): React.JSX.Element | null { }: MapInstancingSystemProps): React.JSX.Element | null {
const camera = useThree((state) => state.camera);
const cameraMode = useCameraMode(); const cameraMode = useCameraMode();
const sceneMode = useSceneMode(); const sceneMode = useSceneMode();
const graphicsPreset = useGraphicsPreset();
const graphicsPresetConfig = useGraphicsPresetConfig();
const groups = useMapPerformanceStore((state) => state.groups); const groups = useMapPerformanceStore((state) => state.groups);
const models = useMapPerformanceStore((state) => state.models); const models = useMapPerformanceStore((state) => state.models);
const { data, isLoading } = useMapInstancingData(); const { data, isLoading } = useMapInstancingData();
@@ -84,7 +174,29 @@ export function MapInstancingSystem({
}); });
}, [data, groups, models, onlyMapName]); }, [data, groups, models, onlyMapName]);
const visibleChunks = useVisibleWorldChunks(chunks, streamingEnabled); const visibleChunks = useVisibleWorldChunks(chunks, streamingEnabled, {
loadRadius: graphicsPresetConfig.chunkLoadRadius,
unloadRadius: graphicsPresetConfig.chunkUnloadRadius,
});
const chunkModelPaths = useChunkModelPaths(visibleChunks, graphicsPreset);
const getChunkModelPath = useCallback(
(chunk: MapAssetChunk): string => {
const cachedModelPath = chunkModelPaths.get(chunk.key);
if (cachedModelPath) return cachedModelPath;
return selectMapModelPathByDistance({
distance: getNearestChunkInstanceDistance(
chunk,
camera.position.x,
camera.position.z,
),
modelName: chunk.config.mapName,
modelPath: chunk.config.modelPath,
preset: graphicsPreset,
});
},
[camera, chunkModelPaths, graphicsPreset],
);
if (isLoading || !data) { if (isLoading || !data) {
return null; return null;
@@ -92,17 +204,21 @@ export function MapInstancingSystem({
return ( return (
<group name="map-instancing-system"> <group name="map-instancing-system">
{visibleChunks.map((chunk) => ( {visibleChunks.map((chunk) => {
<Suspense key={chunk.key} fallback={null}> const modelPath = getChunkModelPath(chunk);
<InstancedMapAsset
modelPath={chunk.config.modelPath} return (
instances={chunk.instances} <Suspense key={`${chunk.key}:${modelPath}`} fallback={null}>
scaleMultiplier={chunk.config.scaleMultiplier} <InstancedMapAsset
castShadow={chunk.config.castShadow} modelPath={modelPath}
receiveShadow={chunk.config.receiveShadow} instances={chunk.instances}
/> scaleMultiplier={chunk.config.scaleMultiplier}
</Suspense> castShadow={chunk.config.castShadow}
))} receiveShadow={chunk.config.receiveShadow}
/>
</Suspense>
);
})}
</group> </group>
); );
} }
+6 -1
View File
@@ -2,6 +2,7 @@ import { Suspense, useMemo } from "react";
import { CHUNK_CONFIG } from "@/data/world/chunkStreamingConfig"; import { CHUNK_CONFIG } from "@/data/world/chunkStreamingConfig";
import { useCameraMode } from "@/hooks/debug/useCameraMode"; import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useSceneMode } from "@/hooks/debug/useSceneMode"; import { useSceneMode } from "@/hooks/debug/useSceneMode";
import { useGraphicsPresetConfig } from "@/hooks/world/useGraphicsSettings";
import { useVisibleWorldChunks } from "@/hooks/world/useVisibleWorldChunks"; import { useVisibleWorldChunks } from "@/hooks/world/useVisibleWorldChunks";
import { import {
isMapModelVisible, isMapModelVisible,
@@ -65,6 +66,7 @@ export function VegetationSystem({
}: VegetationSystemProps): React.JSX.Element | null { }: VegetationSystemProps): React.JSX.Element | null {
const cameraMode = useCameraMode(); const cameraMode = useCameraMode();
const sceneMode = useSceneMode(); const sceneMode = useSceneMode();
const graphicsPreset = useGraphicsPresetConfig();
const groups = useMapPerformanceStore((state) => state.groups); const groups = useMapPerformanceStore((state) => state.groups);
const models = useMapPerformanceStore((state) => state.models); const models = useMapPerformanceStore((state) => state.models);
const { data, isLoading } = useVegetationData(); const { data, isLoading } = useVegetationData();
@@ -92,7 +94,10 @@ export function VegetationSystem({
}); });
}, [data, groups, models, onlyMapName]); }, [data, groups, models, onlyMapName]);
const visibleChunks = useVisibleWorldChunks(chunks, streamingEnabled); const visibleChunks = useVisibleWorldChunks(chunks, streamingEnabled, {
loadRadius: graphicsPreset.chunkLoadRadius,
unloadRadius: graphicsPreset.chunkUnloadRadius,
});
if (isLoading || !data) { if (isLoading || !data) {
return null; return null;