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
🔍 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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user