8 Commits

Author SHA1 Message Date
Tom Boullay 4f1b3b4ff3 fix(graphics): tune presets, single-line ui, vegetation LOD by nearest instance
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled
- Bump high to 30m chunk / HD<20m and ultra to HD<30m so HD models
  persist further before swapping.
- Render the 5 graphics preset cards on a single row.
- Vegetation LOD selection now uses the distance to the nearest instance
  in each chunk instead of the chunk centre, matching MapInstancingSystem
  and avoiding premature LOD swaps when the camera enters a chunk.
2026-06-02 14:33:16 +02:00
Tom Boullay 627c8d4eb9 feat(vegetation): wire arbre/sapin/buisson into LOD system
Register the three new vegetation LOD models in MAP_LOD_MODEL_PATHS and
extend VegetationSystem with per-chunk distance-based LOD selection
(mirroring MapInstancingSystem). Chunk model paths are re-evaluated on
the existing CHUNK_CONFIG.updateInterval cadence and Suspense keys
include the resolved path so a swap unmounts the previous instanced mesh
cleanly.
2026-06-02 14:17:19 +02:00
Tom Boullay 0b801888f0 update: fix name assets + add LOD 2026-06-02 14:13:55 +02:00
Tom Boullay a180b89ee6 fix(pylone): convert lampe BLEND to MASK in GLTFs
Switching alphaMode from BLEND to MASK with a 0.5 cutoff lets the lampe
material participate in instanced shadow rendering correctly. BLEND is
known to interact badly with InstancedMesh shadow casting, producing
incorrect or missing shadows on the pylone lampe.
2026-06-02 13:52:08 +02:00
Tom Boullay 3e66e31117 feat(graphics): add max preset (no chunk streaming, LOD@50m)
Restore ultra to its original behaviour (50m chunk streaming, HD within
20m, no fog) and introduce a new max preset that disables chunk streaming
entirely (loads all chunks unconditionally) and pushes the HD/LOD swap
distance to 50m. Add chunkStreamingEnabled flag to GraphicsPresetConfig
so the streaming gate honours the preset. The settings card label shows
'All' when streaming is off.
2026-06-02 13:51:33 +02:00
Tom Boullay 2c194cdd2e fix: use player ebike speed
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled
2026-06-02 09:48:44 +02:00
Tom Boullay feaf502e44 feat: support webm mission notifications 2026-06-02 09:40:18 +02:00
math-pixel 489499f5d2 Merge pull request 'Feat/polish-mission1' (#12) from feat/polish-mission1 into develop
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled
Reviewed-on: #12
2026-06-01 21:51:09 +00:00
17 changed files with 177 additions and 42 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+4 -2
View File
@@ -105,6 +105,9 @@ function GraphicsPresetButton({
const lodLabel = config.forceLodModels
? "LOD forcé"
: `HD ${config.lodHighDetailDistance}m`;
const chunkLabel = config.chunkStreamingEnabled
? formatChunkDistance(config.chunkLoadRadius)
: "All";
return (
<button
@@ -115,8 +118,7 @@ function GraphicsPresetButton({
>
<span>{config.label}</span>
<small>
{formatChunkDistance(config.chunkLoadRadius)} · {lodLabel} ·{" "}
{config.fogEnabled ? "Fog" : "Clear"}
{chunkLabel} · {lodLabel} · {config.fogEnabled ? "Fog" : "Clear"}
</small>
</button>
);
+19 -5
View File
@@ -14,6 +14,7 @@ export function MissionNotification({
}: MissionNotificationProps): React.JSX.Element {
const src =
imagePath ?? (mission ? MISSION_NOTIFICATION_IMAGE_PATHS[mission] : "");
const isVideo = src.toLowerCase().endsWith(".webm");
return (
<div
@@ -22,11 +23,24 @@ export function MissionNotification({
>
<div className="mission-notification__glow" />
<span className="mission-notification__image-wrap">
<img
className="mission-notification__image"
src={src}
alt="Nouvel objectif de mission"
/>
{isVideo ? (
<video
className="mission-notification__image"
src={src}
aria-label="Nouvel objectif de mission"
autoPlay
loop
muted
playsInline
preload="auto"
/>
) : (
<img
className="mission-notification__image"
src={src}
alt="Nouvel objectif de mission"
/>
)}
</span>
</div>
);
-1
View File
@@ -22,7 +22,6 @@ export const EBIKE_WORLD_SCALE = 0.35;
export const EBIKE_INTRO_BREAKDOWN_DISTANCE = 15;
export const EBIKE_BREAKDOWN_DIALOGUE_DELAY_MS = 250;
export const EBIKE_MAX_SPEED = 3;
export const EBIKE_ACCELERATION_DURATION_MS = 2000;
export const EBIKE_DECELERATION_DURATION_MS = 2000;
+3 -3
View File
@@ -5,7 +5,7 @@ export const INTRO_MISSION_NOTIFICATION_IMAGE_PATH =
export const MISSION_NOTIFICATION_IMAGE_PATHS: Record<RepairMissionId, string> =
{
ebike: "/assets/world/UI/ebike-mission-notification.png",
pylon: "/assets/world/UI/pylon-mission-notification.png",
farm: "/assets/world/UI/farm-mission-notification.png",
ebike: "/assets/world/UI/ebike-mission-notification.webm",
pylon: "/assets/world/UI/pylon-mission-notification.webm",
farm: "/assets/world/UI/farm-mission-notification.webm",
};
+3 -3
View File
@@ -21,7 +21,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
"Repair the damaged cooling module before relaunching the bike",
modelPath: "/models/ebike/model.gltf",
modelScale: 0.3,
stageUiPath: "/assets/world/UI/ebike.webm",
stageUiPath: "/assets/world/UI/ebike-mission-notification.webm",
interactUiPath: REPAIR_INTERACT_UI_PATH,
brokenUiPath: REPAIR_BROKEN_UI_PATH,
case: DEFAULT_REPAIR_CASE,
@@ -59,7 +59,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
description:
"Restore the pylon lamp relay and damaged panel before reconnecting the grid",
modelPath: "/models/pylone/model.gltf",
stageUiPath: "/assets/world/UI/centrale.webm",
stageUiPath: "/assets/world/UI/pylon-mission-notification.webm",
interactUiPath: REPAIR_INTERACT_UI_PATH,
brokenUiPath: REPAIR_BROKEN_UI_PATH,
case: DEFAULT_REPAIR_CASE,
@@ -104,7 +104,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
description:
"Stabilize the irrigation loop and humidity sensor before restarting the farm",
modelPath: "/models/fermeverticale/model.gltf",
stageUiPath: "/assets/world/UI/laferme.webm",
stageUiPath: "/assets/world/UI/farm-mission-notification.webm",
interactUiPath: REPAIR_INTERACT_UI_PATH,
brokenUiPath: REPAIR_BROKEN_UI_PATH,
case: DEFAULT_REPAIR_CASE,
+1 -1
View File
@@ -5,7 +5,7 @@ export const PLAYER_EYE_HEIGHT = 1.75;
export const PLAYER_CAPSULE_RADIUS = 0.35;
export const PLAYER_WALK_SPEED = 5;
export const PLAYER_EBIKE_SPEED = 30;
export const PLAYER_EBIKE_SPEED = 20;
export const PLAYER_AIR_CONTROL_FACTOR = 0.35;
export const PLAYER_JUMP_SPEED = 9;
export const PLAYER_GRAVITY = 30;
+25 -5
View File
@@ -1,9 +1,16 @@
export const GRAPHICS_PRESET_KEYS = ["low", "medium", "high", "ultra"] as const;
export const GRAPHICS_PRESET_KEYS = [
"low",
"medium",
"high",
"ultra",
"max",
] as const;
export type GraphicsPreset = (typeof GRAPHICS_PRESET_KEYS)[number];
export interface GraphicsPresetConfig {
chunkLoadRadius: number;
chunkStreamingEnabled: boolean;
chunkUnloadRadius: number;
fogEnabled: boolean;
forceLodModels: boolean;
@@ -16,6 +23,7 @@ export const GRAPHICS_PRESETS = {
label: "Basse",
chunkLoadRadius: 10,
chunkUnloadRadius: 18,
chunkStreamingEnabled: true,
fogEnabled: true,
forceLodModels: true,
lodHighDetailDistance: 0,
@@ -24,25 +32,37 @@ export const GRAPHICS_PRESETS = {
label: "Moyenne",
chunkLoadRadius: 20,
chunkUnloadRadius: 30,
chunkStreamingEnabled: true,
fogEnabled: true,
forceLodModels: true,
lodHighDetailDistance: 0,
},
high: {
label: "High",
chunkLoadRadius: 35,
chunkUnloadRadius: 45,
chunkLoadRadius: 30,
chunkUnloadRadius: 40,
chunkStreamingEnabled: true,
fogEnabled: false,
forceLodModels: false,
lodHighDetailDistance: 10,
lodHighDetailDistance: 20,
},
ultra: {
label: "Ultra",
chunkLoadRadius: 50,
chunkUnloadRadius: 65,
chunkStreamingEnabled: true,
fogEnabled: false,
forceLodModels: false,
lodHighDetailDistance: 20,
lodHighDetailDistance: 30,
},
max: {
label: "Max",
chunkLoadRadius: 50,
chunkUnloadRadius: 65,
chunkStreamingEnabled: false,
fogEnabled: false,
forceLodModels: false,
lodHighDetailDistance: 50,
},
} as const satisfies Record<GraphicsPreset, GraphicsPresetConfig>;
+3
View File
@@ -14,6 +14,9 @@ export const MAP_LOD_MODEL_PATHS = {
maison1: "/models/maison1-LOD/model.gltf",
panneauaffichage: "/models/panneauaffichage-LOD/model.gltf",
talkie: "/models/talkie-LOD/model.gltf",
arbre: "/models/arbre-LOD/model.glb",
buisson: "/models/buisson-LOD/model.glb",
sapin: "/models/sapin-LOD/model.glb",
} as const satisfies Record<string, string>;
export function getMapLodModelPath(modelName: string): string | null {
+1 -1
View File
@@ -1544,7 +1544,7 @@ canvas {
}
.game-settings-menu__choice-group--presets {
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.game-settings-menu__choice-group button,
@@ -149,6 +149,7 @@ export function MapInstancingSystem({
const streamingEnabled =
streaming &&
CHUNK_CONFIG.enabled &&
graphicsPresetConfig.chunkStreamingEnabled &&
sceneMode === "game" &&
cameraMode === "player";
+1 -2
View File
@@ -33,7 +33,6 @@ import {
EBIKE_ACCELERATION_DURATION_MS,
EBIKE_CAMERA_TRANSFORM,
EBIKE_DECELERATION_DURATION_MS,
EBIKE_MAX_SPEED,
} from "@/data/ebike/ebikeConfig";
/** Global window properties used for ebike communication */
@@ -415,7 +414,7 @@ export function PlayerController({
}
const movementSpeed = isEbikeMounted
? EBIKE_MAX_SPEED * ebikeSpeedFactor.current
? currentSpeed * ebikeSpeedFactor.current
: currentSpeed;
const accel = onFloor.current
? movementSpeed
+103 -15
View File
@@ -1,8 +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 { useGraphicsPresetConfig } from "@/hooks/world/useGraphicsSettings";
import {
useGraphicsPreset,
useGraphicsPresetConfig,
} from "@/hooks/world/useGraphicsSettings";
import { useVisibleWorldChunks } from "@/hooks/world/useVisibleWorldChunks";
import {
isMapModelVisible,
@@ -18,6 +30,7 @@ import {
} from "@/data/world/vegetationConfig";
import { isInsideLaFabrikFootprint } from "@/data/world/laFabrikConfig";
import { createWorldInstanceChunks } from "@/utils/world/chunkInstances";
import type { GraphicsPreset } from "@/data/world/graphicsConfig";
interface VegetationSystemProps {
onlyMapName?: string | null;
@@ -70,12 +83,78 @@ function removeLaFabrikVegetation(
});
}
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 useVegetationChunkModelPaths(
chunks: readonly VegetationChunk[],
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 next = new Map<string, string>();
for (const chunk of chunks) {
let nearestDistance = Number.POSITIVE_INFINITY;
for (const instance of chunk.instances) {
const distance = Math.hypot(
instance.position[0] - cameraX,
instance.position[2] - cameraZ,
);
if (distance < nearestDistance) nearestDistance = distance;
}
const modelPath = selectMapModelPathByDistance({
distance: nearestDistance,
modelName: VEGETATION_TYPES[chunk.type].mapName,
modelPath: chunk.modelPath,
preset,
});
next.set(chunk.key, modelPath);
}
if (areChunkModelPathsEqual(next, modelPathsRef.current)) return;
modelPathsRef.current = next;
setModelPaths(next);
}, [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 VegetationSystem({
onlyMapName = null,
streaming = true,
}: VegetationSystemProps): React.JSX.Element | null {
const cameraMode = useCameraMode();
const sceneMode = useSceneMode();
const graphicsPresetKey = useGraphicsPreset();
const graphicsPreset = useGraphicsPresetConfig();
const groups = useMapPerformanceStore((state) => state.groups);
const models = useMapPerformanceStore((state) => state.models);
@@ -83,6 +162,7 @@ export function VegetationSystem({
const streamingEnabled =
streaming &&
CHUNK_CONFIG.enabled &&
graphicsPreset.chunkStreamingEnabled &&
sceneMode === "game" &&
cameraMode === "player";
@@ -112,25 +192,33 @@ export function VegetationSystem({
unloadRadius: graphicsPreset.chunkUnloadRadius,
});
const chunkModelPaths = useVegetationChunkModelPaths(
visibleChunks,
graphicsPresetKey,
);
if (isLoading || !data) {
return null;
}
return (
<group name="vegetation-system">
{visibleChunks.map((chunk) => (
<Suspense key={chunk.key} fallback={null}>
<InstancedVegetation
modelPath={chunk.modelPath}
instances={chunk.instances}
scaleMultiplier={chunk.scaleMultiplier}
castShadow={chunk.castShadow}
receiveShadow={chunk.receiveShadow}
windStrength={chunk.windStrength}
rotationOffset={chunk.rotationOffset}
/>
</Suspense>
))}
{visibleChunks.map((chunk) => {
const modelPath = chunkModelPaths.get(chunk.key) ?? chunk.modelPath;
return (
<Suspense key={`${chunk.key}:${modelPath}`} fallback={null}>
<InstancedVegetation
modelPath={modelPath}
instances={chunk.instances}
scaleMultiplier={chunk.scaleMultiplier}
castShadow={chunk.castShadow}
receiveShadow={chunk.receiveShadow}
windStrength={chunk.windStrength}
rotationOffset={chunk.rotationOffset}
/>
</Suspense>
);
})}
</group>
);
}