Merge branch 'develop' into feat/polish-mission1
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled

This commit is contained in:
Tom Boullay
2026-06-01 00:15:46 +02:00
1075 changed files with 1242 additions and 1722 deletions
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useAnimations } from "@react-three/drei";
import { SkeletonUtils } from "three-stdlib";
import type { AnimationAction } from "three";
import {
AnimatedModelContext,
@@ -43,7 +44,8 @@ export function AnimatedModel({
rotation,
scale,
});
const { actions, names, mixer } = useAnimations(animations, scene);
const model = useMemo(() => SkeletonUtils.clone(scene), [scene]);
const { actions, names, mixer } = useAnimations(animations, model);
const [currentAnim, setCurrentAnim] = useState(defaultAnimation);
const isReady = names.length > 0;
@@ -154,21 +156,21 @@ export function AnimatedModel({
};
useEffect(() => {
scene.position.set(...position);
scene.rotation.set(rotation[0], rotation[1], rotation[2]);
model.position.set(...position);
model.rotation.set(rotation[0], rotation[1], rotation[2]);
const parsedScale =
typeof scale === "number" ? [scale, scale, scale] : (scale ?? [1, 1, 1]);
scene.scale.set(
model.scale.set(
parsedScale[0] ?? 1,
parsedScale[1] ?? 1,
parsedScale[2] ?? 1,
);
}, [scene, position, rotation, scale]);
}, [model, position, rotation, scale]);
return (
<AnimatedModelContext.Provider value={contextValue}>
<primitive object={scene} />
<primitive object={model} />
{children}
</AnimatedModelContext.Provider>
);
+13 -1
View File
@@ -3,13 +3,25 @@ import {
MergedStaticMapModel,
type MergedStaticMapModelProps,
} 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_LOD_MODEL_PATH = getMapLodModelPath("ecole");
type EcoleModelProps = Omit<MergedStaticMapModelProps, "modelPath">;
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);
if (ECOLE_LOD_MODEL_PATH) {
useGLTF.preload(ECOLE_LOD_MODEL_PATH);
}
@@ -3,15 +3,27 @@ import {
MergedStaticMapModel,
type MergedStaticMapModelProps,
} from "@/components/three/world/MergedStaticMapModel";
import { getMapLodModelPath } from "@/data/world/mapLodConfig";
import { useMapLodModelPath } from "@/hooks/world/useMapLodModelPath";
const LA_FABRIK_MODEL_PATH = "/models/lafabrik/model.gltf";
const LA_FABRIK_MODEL_PATH = "/models/lafabrik/model.glb";
const LA_FABRIK_LOD_MODEL_PATH = getMapLodModelPath("lafabrik");
type LaFabrikMapModelProps = Omit<MergedStaticMapModelProps, "modelPath">;
export function LaFabrikMapModel(
props: LaFabrikMapModelProps,
): React.JSX.Element {
return <MergedStaticMapModel modelPath={LA_FABRIK_MODEL_PATH} {...props} />;
const modelPath = useMapLodModelPath({
modelName: "lafabrik",
modelPath: LA_FABRIK_MODEL_PATH,
position: props.position,
});
return <MergedStaticMapModel modelPath={modelPath} {...props} />;
}
useGLTF.preload(LA_FABRIK_MODEL_PATH);
if (LA_FABRIK_LOD_MODEL_PATH) {
useGLTF.preload(LA_FABRIK_LOD_MODEL_PATH);
}
+44
View File
@@ -0,0 +1,44 @@
interface AppLoadingIndicatorProps {
className?: string | undefined;
floating?: boolean;
}
export function AppLoadingIndicator({
className,
floating = false,
}: AppLoadingIndicatorProps): React.JSX.Element {
const classes = [
"app-loading-indicator",
floating ? "app-loading-indicator--floating" : null,
className,
]
.filter(Boolean)
.join(" ");
return (
<div className={classes} role="status" aria-live="polite">
<span>Loading...</span>
<svg
className="app-loading-indicator__spinner"
viewBox="0 0 32 32"
aria-hidden="true"
>
<path
d="M16 3a13 13 0 1 1-9.2 3.8"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="3.5"
/>
<path
d="M6.8 6.8V2.8H2.8"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="3.5"
/>
</svg>
</div>
);
}
+222 -86
View File
@@ -1,26 +1,38 @@
import { useEffect } from "react";
import { RotateCcw, X } from "lucide-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 { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
import { hasSiteBeenVisitedToday } from "@/utils/cookies/siteVisitCookie";
import { Debug } from "@/utils/debug/Debug";
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;
icon: ReactNode;
value: number;
onChange: (value: number) => void;
}
@@ -28,13 +40,17 @@ interface VolumeSliderProps {
function VolumeSlider({
id,
label,
icon,
value,
onChange,
}: VolumeSliderProps): React.JSX.Element {
return (
<label className="game-settings-menu__slider" htmlFor={id}>
<span>
{label}
<em>
{icon}
{label}
</em>
<strong>{formatPercent(value)}</strong>
</span>
<input
@@ -50,8 +66,73 @@ function VolumeSlider({
);
}
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,
@@ -86,25 +167,23 @@ export function GameSettingsMenu(): React.JSX.Element | null {
if (!isSettingsMenuOpen) return null;
const handleQuit = (): void => {
clearCookies();
window.location.assign("/");
};
const handleRestart = (): void => {
resetGame();
window.location.reload();
setSettingsMenuOpen(false);
window.location.assign(hasSiteBeenVisitedToday() ? "/" : "/site");
};
const showDebugRestart = isDebugEnabled();
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>Pause</span>
<h2>Options</h2>
<span>La Fabrik</span>
<h2>Pause</h2>
</div>
<button
className="game-settings-menu__close"
@@ -116,80 +195,137 @@ export function GameSettingsMenu(): React.JSX.Element | null {
</button>
</header>
<section
className="game-settings-menu__section"
aria-labelledby="audio-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>
<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="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)}
<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}
/>
Afficher sous-titres
</label>
<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>
<div
className="game-settings-menu__choice-group"
aria-label="Langue des sous-titres"
<section
className="game-settings-menu__section"
aria-labelledby="subtitle-settings-heading"
>
{(["fr", "en"] satisfies SubtitleLanguage[]).map((language) => (
<button
key={language}
type="button"
className={subtitleLanguage === language ? "active" : undefined}
onClick={() => setSubtitleLanguage(language)}
aria-pressed={subtitleLanguage === language}
<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"
>
{language === "fr" ? "Francais" : "English"}
</button>
))}
</div>
</section>
{showDebugRestart ? (
<button
className="game-settings-menu__restart"
type="button"
onClick={handleRestart}
>
<RotateCcw size={14} aria-hidden="true" />
Recommencer
</button>
) : null}
{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__quit"
className="game-settings-menu__restart"
type="button"
onClick={handleQuit}
onClick={handleRestart}
>
Quitter
<RotateCcw size={14} aria-hidden="true" />
Recommencer
</button>
</div>
</div>
+2 -24
View File
@@ -1,3 +1,4 @@
import { AppLoadingIndicator } from "@/components/ui/AppLoadingIndicator";
import type { SceneLoadingState } from "@/types/world/sceneLoading";
const LOADING_BACKGROUND_PATH = "/assets/bg-site.png";
@@ -36,30 +37,7 @@ export function SceneLoadingOverlay({
/>
<div className="scene-loading-overlay__footer">
<div className="scene-loading-overlay__meta">
<div className="scene-loading-overlay__label">
<span>Loading...</span>
<svg
className="scene-loading-overlay__spinner"
viewBox="0 0 32 32"
aria-hidden="true"
>
<path
d="M16 3a13 13 0 1 1-9.2 3.8"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="3.5"
/>
<path
d="M6.8 6.8V2.8H2.8"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="3.5"
/>
</svg>
</div>
<AppLoadingIndicator className="scene-loading-overlay__label" />
<strong>{progress}%</strong>
</div>
<div className="scene-loading-overlay__track">
+12 -6
View File
@@ -92,6 +92,12 @@ export const docGroups: DocGroup[] = [
subtitle: "Draw calls, triangles, and streaming",
meta: "13",
},
{
path: "/docs/map-lod",
title: "Map LOD System",
subtitle: "Presets, paths, and model workflow",
meta: "14",
},
],
},
{
@@ -101,31 +107,31 @@ export const docGroups: DocGroup[] = [
path: "/docs/features",
title: "Features",
subtitle: "Implemented scope",
meta: "14",
meta: "15",
},
{
path: "/docs/main-feature",
title: "Main Feature",
subtitle: "Repair-game prototype",
meta: "15",
meta: "16",
},
{
path: "/docs/editor",
title: "Editor User Guide",
subtitle: "Editing workflow",
meta: "16",
meta: "17",
},
{
path: "/docs/animation",
title: "Animation & 3D Model System",
subtitle: "Components and usage",
meta: "17",
meta: "18",
},
{
path: "/docs/gallery",
title: "Model Gallery",
subtitle: "Browsing 3D assets",
meta: "18",
meta: "19",
},
],
},
@@ -136,7 +142,7 @@ export const docGroups: DocGroup[] = [
path: "/docs/code-review",
title: "Code Review Prep",
subtitle: "Presentation support",
meta: "19",
meta: "20",
},
],
},
+1 -1
View File
@@ -101,7 +101,7 @@ export const galleryModels: GalleryModel[] = [
path: "/models/habitant2-animated/model.gltf",
},
{ id: "immeuble1", name: "Immeuble", path: "/models/immeuble1/model.gltf" },
{ id: "lafabrik", name: "La Fabrik", path: "/models/lafabrik/model.gltf" },
{ id: "lafabrik", name: "La Fabrik", path: "/models/lafabrik/model.glb" },
{ id: "maison1", name: "Maison", path: "/models/maison1/model.gltf" },
{
id: "packderelance",
+81 -6
View File
@@ -1,6 +1,15 @@
import type { Vector3Tuple } from "@/types/three/three";
export type CharacterId = "electricienne" | "gerant" | "fermier";
export type CharacterId =
| "electricienne"
| "gerant"
| "fermier"
| "zone1_habitant1"
| "zone1_habitant2"
| "zone2_habitant1"
| "zone2_habitant2"
| "zone3_habitant1"
| "zone3_habitant2";
export interface CharacterConfig {
id: CharacterId;
@@ -21,7 +30,7 @@ export const CHARACTER_CONFIGS = {
modelPath: "/models/electricienne-animated/model.gltf",
position: [-40.5, 0, 45.5],
rotation: [0, -0.35, 0],
scale: [1, 1, 1],
scale: [1.55, 1.55, 1.55],
animations: ["Dance"],
defaultAnimation: "Dance",
},
@@ -29,9 +38,9 @@ export const CHARACTER_CONFIGS = {
id: "gerant",
label: "Gerant",
modelPath: "/models/gerant-animated/model.gltf",
position: [59.5, 6.3, 64.64],
rotation: [0, 2.41, 0],
scale: [1, 1, 1],
position: [58, 0, 62.5],
rotation: [0, 1.83, 0],
scale: [1.55, 1.55, 1.55],
animations: ["idle", "walk"],
defaultAnimation: "idle",
snapToTerrain: false,
@@ -42,7 +51,67 @@ export const CHARACTER_CONFIGS = {
modelPath: "/models/fermier-animated/model.gltf",
position: [-6.5, 0, -69.5],
rotation: [0, -1.18, 0],
scale: [1, 1, 1],
scale: [1.55, 1.55, 1.55],
animations: ["idle", "walk"],
defaultAnimation: "idle",
},
zone1_habitant1: {
id: "zone1_habitant1",
label: "Zone 1 - Habitant 1",
modelPath: "/models/habitant1-animated/model.gltf",
position: [-43.64, 0, -16.72],
rotation: [0, -1.23, 0],
scale: [1.55, 1.55, 1.55],
animations: ["idle", "walk"],
defaultAnimation: "idle",
},
zone1_habitant2: {
id: "zone1_habitant2",
label: "Zone 1 - Habitant 2",
modelPath: "/models/habitant2-animated/model.gltf",
position: [-43.46, 0, -4.93],
rotation: [0, -2.42, 0],
scale: [1.55, 1.55, 1.55],
animations: ["idle", "walk"],
defaultAnimation: "idle",
},
zone2_habitant1: {
id: "zone2_habitant1",
label: "Zone 2 - Habitant 1",
modelPath: "/models/habitant1-animated/model.gltf",
position: [-3.41, 0, 73.01],
rotation: [0, 1.97, 0],
scale: [1.55, 1.55, 1.55],
animations: ["idle", "walk"],
defaultAnimation: "idle",
},
zone2_habitant2: {
id: "zone2_habitant2",
label: "Zone 2 - Habitant 2",
modelPath: "/models/habitant2-animated/model.gltf",
position: [-2.22, 0, 60.59],
rotation: [0, 0.86, 0],
scale: [1.55, 1.55, 1.55],
animations: ["idle", "walk"],
defaultAnimation: "idle",
},
zone3_habitant1: {
id: "zone3_habitant1",
label: "Zone 3 - Habitant 1",
modelPath: "/models/habitant1-animated/model.gltf",
position: [82.52, 0, -29.01],
rotation: [0, -0.89, 0],
scale: [1.55, 1.55, 1.55],
animations: ["idle", "walk"],
defaultAnimation: "idle",
},
zone3_habitant2: {
id: "zone3_habitant2",
label: "Zone 3 - Habitant 2",
modelPath: "/models/habitant2-animated/model.gltf",
position: [92.95, 0, -18.1],
rotation: [0, -1.59, 0],
scale: [1.55, 1.55, 1.55],
animations: ["idle", "walk"],
defaultAnimation: "idle",
},
@@ -52,4 +121,10 @@ export const CHARACTER_IDS = [
"electricienne",
"gerant",
"fermier",
"zone1_habitant1",
"zone1_habitant2",
"zone2_habitant1",
"zone2_habitant2",
"zone3_habitant1",
"zone3_habitant2",
] as const satisfies readonly CharacterId[];
+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 = {
preset: "high" as GraphicsPreset,
dynamicGrass: true,
dynamicTrees: true,
dynamicClouds: true,
+45
View File
@@ -0,0 +1,45 @@
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",
lafabrik: "/models/lafabrik-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;
}
@@ -0,0 +1,36 @@
import { useCallback, useEffect, useRef, useState } from "react";
const DEFAULT_LOADING_DURATION_MS = 900;
export function useTransientLoadingIndicator(): {
showLoading: (durationMs?: number) => void;
visible: boolean;
} {
const [visible, setVisible] = useState(false);
const timeoutRef = useRef<number | null>(null);
const showLoading = useCallback(
(durationMs = DEFAULT_LOADING_DURATION_MS) => {
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current);
}
setVisible(true);
timeoutRef.current = window.setTimeout(() => {
setVisible(false);
timeoutRef.current = null;
}, durationMs);
},
[],
);
useEffect(() => {
return () => {
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current);
}
};
}, []);
return { showLoading, visible };
}
+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";
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 {
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 { CHUNK_CONFIG } from "@/data/world/chunkStreamingConfig";
@@ -8,6 +8,11 @@ export interface WorldChunkLike {
key: string;
}
interface WorldChunkVisibilityConfig {
loadRadius: number;
unloadRadius: number;
}
function areSetsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
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>(
chunks: readonly TChunk[],
streamingEnabled: boolean,
visibilityConfig: WorldChunkVisibilityConfig = CHUNK_CONFIG,
): readonly TChunk[] {
const camera = useThree((state) => state.camera);
const lastUpdateRef = useRef(-CHUNK_CONFIG.updateInterval);
@@ -35,8 +41,8 @@ export function useVisibleWorldChunks<TChunk extends WorldChunkLike>(
);
const wasActive = activeChunkKeysRef.current.has(chunk.key);
const radius = wasActive
? CHUNK_CONFIG.unloadRadius
: CHUNK_CONFIG.loadRadius;
? visibilityConfig.unloadRadius
: visibilityConfig.loadRadius;
if (distance <= radius) {
nextKeys.add(chunk.key);
@@ -47,7 +53,18 @@ export function useVisibleWorldChunks<TChunk extends WorldChunkLike>(
activeChunkKeysRef.current = nextKeys;
setActiveChunkKeys(nextKeys);
}, [camera, chunks]);
}, [
camera,
chunks,
visibilityConfig.loadRadius,
visibilityConfig.unloadRadius,
]);
useEffect(() => {
if (!streamingEnabled) return;
updateActiveChunks();
}, [streamingEnabled, updateActiveChunks]);
useFrame(({ clock }) => {
if (!streamingEnabled) return;
@@ -71,7 +88,7 @@ export function useVisibleWorldChunks<TChunk extends WorldChunkLike>(
Math.hypot(
chunk.centerX - camera.position.x,
chunk.centerZ - camera.position.z,
) <= CHUNK_CONFIG.loadRadius
) <= visibilityConfig.loadRadius
);
});
}, [
@@ -80,5 +97,6 @@ export function useVisibleWorldChunks<TChunk extends WorldChunkLike>(
camera.position.z,
chunks,
streamingEnabled,
visibilityConfig.loadRadius,
]);
}
+243 -65
View File
@@ -869,6 +869,40 @@ canvas {
box-shadow: 0 0 14px rgba(56, 189, 248, 0.86);
}
.app-loading-indicator {
display: inline-flex;
align-items: center;
gap: clamp(8px, 1.2vw, 14px);
min-width: 0;
color: inherit;
font: inherit;
letter-spacing: inherit;
line-height: 1;
text-transform: inherit;
}
.app-loading-indicator--floating {
position: fixed;
bottom: clamp(22px, 5vh, 48px);
left: clamp(18px, 4vw, 56px);
z-index: 45;
color: #ffffff;
font-family: "Nersans One", "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: clamp(16px, 2.3vw, 30px);
letter-spacing: 0.12em;
pointer-events: none;
text-shadow: 0 2px 14px rgba(0, 0, 0, 0.45);
text-transform: uppercase;
}
.app-loading-indicator__spinner {
flex: 0 0 auto;
width: clamp(18px, 2.2vw, 30px);
height: clamp(18px, 2.2vw, 30px);
color: currentColor;
animation: app-loading-spin 900ms linear infinite;
}
.scene-loading-overlay {
position: fixed;
inset: 0;
@@ -947,14 +981,6 @@ canvas {
min-width: 0;
}
.scene-loading-overlay__spinner {
flex: 0 0 auto;
width: clamp(18px, 2.2vw, 30px);
height: clamp(18px, 2.2vw, 30px);
color: #ffffff;
animation: scene-loading-spin 900ms linear infinite;
}
.scene-loading-overlay__meta strong {
color: inherit;
font: inherit;
@@ -976,7 +1002,7 @@ canvas {
transition: width 180ms ease;
}
@keyframes scene-loading-spin {
@keyframes app-loading-spin {
to {
transform: rotate(360deg);
}
@@ -1218,22 +1244,48 @@ canvas {
z-index: 40;
display: grid;
place-items: center;
padding: 20px;
background: rgba(0, 0, 0, 0.6);
padding: clamp(18px, 4vw, 42px);
background: rgba(2, 8, 14, 0.28);
color: #ffffff;
pointer-events: auto;
backdrop-filter: blur(10px);
backdrop-filter: blur(8px) saturate(1.05);
}
.game-settings-menu__panel {
width: min(460px, 100%);
max-height: calc(100vh - 40px);
position: relative;
width: min(760px, 100%);
max-height: calc(100vh - clamp(36px, 8vw, 84px));
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);
padding: clamp(16px, 2.4vw, 22px);
border: 1px solid rgba(125, 211, 252, 0.38);
border-radius: 8px;
background:
linear-gradient(180deg, rgba(8, 20, 34, 0.84), rgba(3, 8, 14, 0.78)),
rgba(4, 8, 12, 0.74);
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);
backdrop-filter: blur(18px) saturate(1.12);
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 {
@@ -1241,21 +1293,26 @@ canvas {
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 4px 4px 16px;
padding: 4px 2px 18px;
}
.game-settings-menu__header span {
color: #8f8f8f;
font-size: 0.7rem;
color: #7dd3fc;
font-family: var(--font-body);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.16em;
letter-spacing: 0;
text-transform: uppercase;
}
.game-settings-menu__header h2 {
margin: 0.25rem 0 0;
font-size: 1.8rem;
letter-spacing: -0.06em;
font-family: "Nersans One", var(--font-primary);
font-size: clamp(2rem, 4vw, 3.2rem);
font-weight: 400;
letter-spacing: 0;
line-height: 0.92;
text-transform: uppercase;
}
.game-settings-menu__close {
@@ -1263,26 +1320,66 @@ canvas {
place-items: center;
width: 40px;
height: 40px;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 999px;
background: #111111;
color: #ffffff;
border: 1px solid rgba(125, 211, 252, 0.35);
border-radius: 8px;
background: rgba(10, 22, 34, 0.84);
color: #dff7ff;
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 {
display: grid;
gap: 12px;
padding: 16px 4px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
align-content: start;
padding: 14px;
border: 1px solid rgba(125, 211, 252, 0.2);
border-radius: 8px;
background: rgba(5, 14, 24, 0.58);
}
.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__subsection {
display: grid;
gap: 10px;
padding-top: 10px;
border-top: 1px solid rgba(125, 211, 252, 0.16);
}
.game-settings-menu__section h3 {
margin: 0;
color: #d7d7d7;
color: #dff7ff;
font-family: var(--font-body);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.12em;
letter-spacing: 0;
text-transform: uppercase;
}
@@ -1297,19 +1394,28 @@ canvas {
align-items: center;
justify-content: space-between;
gap: 12px;
color: #f2f2f2;
color: #edfaff;
font-size: 0.9rem;
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 {
color: #8f8f8f;
color: #7dd3fc;
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
}
.game-settings-menu__slider input[type="range"] {
width: 100%;
accent-color: #ffffff;
accent-color: #7dd3fc;
}
.game-settings-menu__checkbox {
@@ -1320,7 +1426,7 @@ canvas {
.game-settings-menu__checkbox input {
width: 18px;
height: 18px;
accent-color: #ffffff;
accent-color: #7dd3fc;
}
.game-settings-menu__choice-group {
@@ -1329,44 +1435,116 @@ canvas {
gap: 8px;
}
.game-settings-menu__choice-group--stacked {
grid-template-columns: 1fr;
.game-settings-menu__choice-group--presets {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.game-settings-menu__choice-group button,
.game-settings-menu__restart,
.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;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 8px;
border-color: rgba(96, 165, 250, 0.35);
color: #bfdbfe;
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.7);
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__quit {
margin-top: 8px;
border-color: rgba(248, 113, 113, 0.35);
color: #fecaca;
.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--hand-tracking button {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
justify-content: start;
text-align: left;
}
.game-settings-menu__choice-group--hand-tracking button small {
grid-column: 2;
}
.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 {
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 {
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(125, 211, 252, 0.35);
color: #dff7ff;
}
@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 */
@@ -4,6 +4,7 @@ import { FOG_CONFIG, type FogState } from "@/data/world/fogConfig";
import { WIND_DEFAULTS, type WindState } from "@/data/world/windConfig";
import {
GRAPHICS_DEFAULTS,
type GraphicsPreset,
type GraphicsState,
} from "@/data/world/graphicsConfig";
@@ -21,6 +22,7 @@ interface WorldSettingsActions {
setWindSpeed: (speed: number) => void;
setWindDirection: (direction: number) => void;
setWindStrength: (strength: number) => void;
setGraphicsPreset: (preset: GraphicsPreset) => void;
setGraphics: (graphics: Partial<GraphicsState>) => void;
setDynamicGrass: (enabled: boolean) => void;
setDynamicTrees: (enabled: boolean) => void;
@@ -82,6 +84,11 @@ export const useWorldSettingsStore = create<WorldSettingsStore>()((set) => ({
graphics: { ...state.graphics, ...graphicsUpdate },
})),
setGraphicsPreset: (preset) =>
set((state) => ({
graphics: { ...state.graphics, preset },
})),
setDynamicGrass: (dynamicGrass) =>
set((state) => ({
graphics: { ...state.graphics, dynamicGrass },
+6
View File
@@ -0,0 +1,6 @@
import mapLod from "../../../../docs/technical/map-lod.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsMapLodPage(): React.JSX.Element {
return <DocsDocument content={mapLod} meta="14" title="Map LOD System" />;
}
+44 -4
View File
@@ -1,9 +1,10 @@
import { Suspense, useCallback, useEffect, useState } from "react";
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useNavigate } from "@tanstack/react-router";
import { Canvas } from "@react-three/fiber";
import * as THREE from "three";
import { DebugPerf } from "@/components/debug/DebugPerf";
import { EbikeIntroSequence } from "@/components/game/EbikeIntroSequence";
import { AppLoadingIndicator } from "@/components/ui/AppLoadingIndicator";
import { DialogMessage } from "@/components/ui/DialogMessage";
import { GameUI } from "@/components/ui/GameUI";
import {
@@ -14,8 +15,10 @@ import {
} from "@/components/ui/intro";
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
import { INITIAL_SCENE_LOADING_STATE } from "@/data/world/sceneLoadingConfig";
import { useTransientLoadingIndicator } from "@/hooks/ui/useTransientLoadingIndicator";
import { AudioManager } from "@/managers/AudioManager";
import { useGameStore } from "@/managers/stores/useGameStore";
import { useWorldSettingsStore } from "@/managers/stores/useWorldSettingsStore";
import { HandTrackingProvider } from "@/providers/gameplay/HandTrackingProvider";
import type { SceneLoadingState } from "@/types/world/sceneLoading";
import { hasSiteBeenVisitedToday } from "@/utils/cookies/siteVisitCookie";
@@ -26,15 +29,31 @@ const LOADING_TO_VIDEO_FADE_MS = 500;
export function HomePage(): React.JSX.Element | null {
const navigate = useNavigate();
const mainState = useGameStore((state) => state.mainState);
const introStep = useGameStore((state) => state.intro.currentStep);
const ebikeStep = useGameStore((state) => state.ebike.currentStep);
const pylonStep = useGameStore((state) => state.pylon.currentStep);
const farmStep = useGameStore((state) => state.farm.currentStep);
const setIntroStep = useGameStore((state) => state.setIntroStep);
const graphicsPreset = useWorldSettingsStore(
(state) => state.graphics.preset,
);
const dialogMessage = useGameStore(
(state) => state.missionFlow.dialogMessage,
);
const hideDialog = useGameStore((state) => state.hideDialog);
const { showLoading, visible: showTransientLoading } =
useTransientLoadingIndicator();
const [sceneLoadingState, setSceneLoadingState] = useState<SceneLoadingState>(
INITIAL_SCENE_LOADING_STATE,
);
const sceneReadyRef = useRef(false);
const runtimeLoadingSignal = `${graphicsPreset}:${mainState}:${ebikeStep}:${pylonStep}:${farmStep}`;
const previousRuntimeLoadingSignalRef = useRef(runtimeLoadingSignal);
useEffect(() => {
sceneReadyRef.current = sceneLoadingState.status === "ready";
}, [sceneLoadingState.status]);
useEffect(() => {
if (!hasSiteBeenVisitedToday()) {
@@ -56,6 +75,11 @@ export function HomePage(): React.JSX.Element | null {
const handleSceneLoadingStateChange = useCallback(
(nextState: SceneLoadingState) => {
if (sceneReadyRef.current && nextState.status === "loading") {
showLoading();
return;
}
setSceneLoadingState((currentState) => {
if (currentState.status === "ready" && nextState.status === "loading") {
return currentState;
@@ -67,9 +91,20 @@ export function HomePage(): React.JSX.Element | null {
};
});
},
[],
[showLoading],
);
useEffect(() => {
if (previousRuntimeLoadingSignalRef.current === runtimeLoadingSignal) {
return;
}
previousRuntimeLoadingSignalRef.current = runtimeLoadingSignal;
if (sceneLoadingState.status !== "ready") return;
showLoading();
}, [runtimeLoadingSignal, sceneLoadingState.status, showLoading]);
useEffect(() => {
if (introStep === "loading-map" && sceneLoadingState.status === "ready") {
AudioManager.getInstance().stopMusic();
@@ -132,6 +167,8 @@ export function HomePage(): React.JSX.Element | null {
const showFadeToVideoOverlay =
introStep === "fade-to-video" ||
(introStep === "loading-map" && sceneLoadingState.status === "ready");
const showSceneLoadingOverlay =
introStep === "loading-map" || introStep === "fade-to-video";
const renderIntroOverlay = () => {
if (showFadeToVideoOverlay) return <FadeToVideoOverlay />;
@@ -173,9 +210,12 @@ export function HomePage(): React.JSX.Element | null {
onClose={hideDialog}
/>
) : null}
{(introStep === "loading-map" || introStep === "fade-to-video") && (
{showSceneLoadingOverlay ? (
<SceneLoadingOverlay state={sceneLoadingState} />
)}
) : null}
{showTransientLoading && !showSceneLoadingOverlay ? (
<AppLoadingIndicator floating />
) : null}
{renderIntroOverlay()}
<EbikeIntroSequence />
</HandTrackingProvider>
+2
View File
@@ -22,6 +22,7 @@ import {
DocsInteractionRoute,
DocsLayoutRoute,
DocsMainFeatureRoute,
DocsMapLodRoute,
DocsMapPerformanceRoute,
DocsMissionFlowRoute,
DocsReadmeRoute,
@@ -93,6 +94,7 @@ const docsChildRoutes = [
{ path: "zustand", component: DocsZustandRoute },
{ path: "three-debugging", component: DocsThreeDebuggingRoute },
{ path: "map-performance", component: DocsMapPerformanceRoute },
{ path: "map-lod", component: DocsMapLodRoute },
{ path: "features", component: DocsFeaturesRoute },
{ path: "main-feature", component: DocsMainFeatureRoute },
{ path: "editor", component: DocsEditorRoute },
+5
View File
@@ -107,6 +107,10 @@ const LazyDocsMapPerformancePage = lazyNamed(
() => import("@/pages/docs/map-performance/page"),
"DocsMapPerformancePage",
);
const LazyDocsMapLodPage = lazyNamed(
() => import("@/pages/docs/map-lod/page"),
"DocsMapLodPage",
);
export const DocsLayoutRoute = createDocsRoute(LazyDocsLayout);
export const DocsReadmeRoute = createDocsRoute(LazyDocsReadmePage);
@@ -136,3 +140,4 @@ export const DocsThreeDebuggingRoute = createDocsRoute(
export const DocsMapPerformanceRoute = createDocsRoute(
LazyDocsMapPerformancePage,
);
export const DocsMapLodRoute = createDocsRoute(LazyDocsMapLodPage);
+5
View File
@@ -244,6 +244,11 @@ export class Debug {
return this.controls.handTrackingSource;
}
setHandTrackingSource(value: HandTrackingSource): void {
this.controls.handTrackingSource = value;
this.emit();
}
getFogEnabled(): boolean {
return this.controls.fogEnabled;
}
+8 -2
View File
@@ -2,11 +2,11 @@ import type { ReactNode } from "react";
import {
Component,
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState,
useCallback,
} from "react";
import * as THREE from "three";
import { useClonedObject } from "@/hooks/three/useClonedObject";
@@ -23,6 +23,7 @@ import {
} from "@/managers/stores/useMapPerformanceStore";
import { useGameStore } from "@/managers/stores/useGameStore";
import { useRepairMissionAnchorStore } from "@/managers/stores/useRepairMissionAnchorStore";
import { useMapLodModelPath } from "@/hooks/world/useMapLodModelPath";
import { GameMapCollision } from "@/world/GameMapCollision";
import { GeneratedMapNodeInstance } from "@/world/map-generated/GeneratedMapNodeInstance";
import { isGeneratedMapModelName } from "@/data/world/generatedMapModelConfig";
@@ -362,6 +363,11 @@ function ModelInstance({
const { position, rotation, scale } = node;
const scaleMultiplier = getMapSingleModelScaleMultiplier(node.name);
const baseScale = normalizeMapScale(scale);
const activeModelUrl = useMapLodModelPath({
modelName: node.name,
modelPath: modelUrl,
position: node.position,
});
const normalizedScale = useMemo(
() =>
[
@@ -372,7 +378,7 @@ function ModelInstance({
[baseScale, scaleMultiplier],
);
const terrainHeight = useTerrainHeightSampler();
const { scene } = useLoggedGLTF(modelUrl, {
const { scene } = useLoggedGLTF(activeModelUrl, {
scope: "GameMap.ModelInstance",
position,
rotation,
+5 -1
View File
@@ -6,6 +6,7 @@ import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useDebugStore } from "@/hooks/debug/useDebugStore";
import { useSceneMode } from "@/hooks/debug/useSceneMode";
import { useFogSettings } from "@/hooks/world/useFogSettings";
import { useGraphicsPresetConfig } from "@/hooks/world/useGraphicsSettings";
import { LIGHTING_STATE } from "@/world/lightingState";
const tempSunFogColor = new THREE.Color();
@@ -23,11 +24,14 @@ export function FogSystem(): React.JSX.Element | null {
const cameraMode = useCameraMode();
const sceneMode = useSceneMode();
const fog = useFogSettings();
const graphicsPreset = useGraphicsPresetConfig();
const fogEnabled = useDebugStore((debug) => debug.getFogEnabled());
const scene = useThree((state) => state.scene);
const fogColor = useMemo(() => getLightingFogColor(new THREE.Color()), []);
const shouldShowFog =
fogEnabled && sceneMode === "game" && cameraMode === "player";
(fogEnabled || graphicsPreset.fogEnabled) &&
sceneMode === "game" &&
cameraMode === "player";
useFrame(() => {
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 { selectMapModelPathByDistance } from "@/data/world/mapLodConfig";
import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useSceneMode } from "@/hooks/debug/useSceneMode";
import {
useGraphicsPreset,
useGraphicsPresetConfig,
} from "@/hooks/world/useGraphicsSettings";
import { useVisibleWorldChunks } from "@/hooks/world/useVisibleWorldChunks";
import {
isMapModelVisible,
@@ -16,6 +29,7 @@ import {
} from "@/data/world/mapInstancingConfig";
import { useMapInstancingData } from "@/hooks/world/useMapInstancingData";
import type { MapAssetInstance } from "@/types/map/mapScene";
import type { GraphicsPreset } from "@/data/world/graphicsConfig";
import { createWorldInstanceChunks } from "@/utils/world/chunkInstances";
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({
onlyMapName = null,
streaming = true,
}: MapInstancingSystemProps): React.JSX.Element | null {
const camera = useThree((state) => state.camera);
const cameraMode = useCameraMode();
const sceneMode = useSceneMode();
const graphicsPreset = useGraphicsPreset();
const graphicsPresetConfig = useGraphicsPresetConfig();
const groups = useMapPerformanceStore((state) => state.groups);
const models = useMapPerformanceStore((state) => state.models);
const { data, isLoading } = useMapInstancingData();
@@ -84,7 +174,29 @@ export function MapInstancingSystem({
});
}, [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) {
return null;
@@ -92,17 +204,21 @@ export function MapInstancingSystem({
return (
<group name="map-instancing-system">
{visibleChunks.map((chunk) => (
<Suspense key={chunk.key} fallback={null}>
<InstancedMapAsset
modelPath={chunk.config.modelPath}
instances={chunk.instances}
scaleMultiplier={chunk.config.scaleMultiplier}
castShadow={chunk.config.castShadow}
receiveShadow={chunk.config.receiveShadow}
/>
</Suspense>
))}
{visibleChunks.map((chunk) => {
const modelPath = getChunkModelPath(chunk);
return (
<Suspense key={`${chunk.key}:${modelPath}`} fallback={null}>
<InstancedMapAsset
modelPath={modelPath}
instances={chunk.instances}
scaleMultiplier={chunk.config.scaleMultiplier}
castShadow={chunk.config.castShadow}
receiveShadow={chunk.config.receiveShadow}
/>
</Suspense>
);
})}
</group>
);
}
+6 -1
View File
@@ -2,6 +2,7 @@ import { Suspense, useMemo } from "react";
import { CHUNK_CONFIG } from "@/data/world/chunkStreamingConfig";
import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useSceneMode } from "@/hooks/debug/useSceneMode";
import { useGraphicsPresetConfig } from "@/hooks/world/useGraphicsSettings";
import { useVisibleWorldChunks } from "@/hooks/world/useVisibleWorldChunks";
import {
isMapModelVisible,
@@ -65,6 +66,7 @@ export function VegetationSystem({
}: VegetationSystemProps): React.JSX.Element | null {
const cameraMode = useCameraMode();
const sceneMode = useSceneMode();
const graphicsPreset = useGraphicsPresetConfig();
const groups = useMapPerformanceStore((state) => state.groups);
const models = useMapPerformanceStore((state) => state.models);
const { data, isLoading } = useVegetationData();
@@ -92,7 +94,10 @@ export function VegetationSystem({
});
}, [data, groups, models, onlyMapName]);
const visibleChunks = useVisibleWorldChunks(chunks, streamingEnabled);
const visibleChunks = useVisibleWorldChunks(chunks, streamingEnabled, {
loadRadius: graphicsPreset.chunkLoadRadius,
unloadRadius: graphicsPreset.chunkUnloadRadius,
});
if (isLoading || !data) {
return null;