refactor: clean map gameplay architecture

This commit is contained in:
tom-boullay
2026-05-28 11:15:45 +02:00
parent d9cf87d2d6
commit 1a91b1d7ae
69 changed files with 791 additions and 1112 deletions
+9 -1
View File
@@ -8,6 +8,7 @@ export function GameFlow(): null {
const setStep = useGameStore((state) => state.setIntroStep);
const setActivityCity = useGameStore((state) => state.setActivityCity);
const setCanMove = useGameStore((state) => state.setCanMove);
const completeIntro = useGameStore((state) => state.completeIntro);
const hasInitialized = useRef(false);
useEffect(() => {
@@ -55,10 +56,17 @@ export function GameFlow(): null {
if (step === "manipulation") {
setCanMove(false);
const timeoutId = window.setTimeout(() => {
completeIntro();
}, 1000);
return () => {
window.clearTimeout(timeoutId);
};
}
return undefined;
}, [step, setStep, setActivityCity, setCanMove]);
}, [completeIntro, step, setStep, setActivityCity, setCanMove]);
return null;
}
@@ -41,8 +41,27 @@ type InteractableObjectProps =
const _cameraPos = new THREE.Vector3();
const _cameraDir = new THREE.Vector3();
const _objectPos = new THREE.Vector3();
const _objectBounds = new THREE.Box3();
const _raycaster = new THREE.Raycaster();
function getInteractableWorldPosition(
group: THREE.Group,
debugSphere: THREE.Mesh | null,
): THREE.Vector3 {
_objectBounds.makeEmpty();
for (const child of group.children) {
if (child === debugSphere) continue;
_objectBounds.expandByObject(child);
}
if (!_objectBounds.isEmpty()) {
return _objectBounds.getCenter(_objectPos);
}
return group.getWorldPosition(_objectPos);
}
function createInteractableHandle(
props: InteractableObjectProps,
): InteractableHandle {
@@ -158,7 +177,7 @@ export function InteractableObject(
const t = bodyRef.current.translation();
_objectPos.set(t.x, t.y, t.z);
} else if (group) {
group.getWorldPosition(_objectPos);
getInteractableWorldPosition(group, debugSphereRef.current);
} else {
_objectPos.set(...position);
}
@@ -2,7 +2,6 @@ import { useEffect, useMemo } from "react";
import * as THREE from "three";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
import { disposeObject3D } from "@/utils/three/dispose";
function applyShadowSettings(
object: THREE.Object3D,
@@ -48,12 +47,6 @@ export function SimpleModel({
applyShadowSettings(model, castShadow, receiveShadow);
}, [castShadow, model, receiveShadow]);
useEffect(() => {
return () => {
disposeObject3D(model);
};
}, [model]);
const parsedScale =
typeof scale === "number" ? ([scale, scale, scale] as Vector3Tuple) : scale;
+39 -20
View File
@@ -1,12 +1,15 @@
import { RotateCcw, StepBack, StepForward } from "lucide-react";
import { useGameStore } from "@/managers/stores/useGameStore";
import { isMissionStep, MISSION_STEPS } from "@/types/gameplay/repairMission";
import {
GAME_STEPS,
isGameStep,
MAIN_GAME_STATES,
type MainGameState,
} from "@/types/game";
} from "@/data/game/gameStateConfig";
import {
isMissionStep,
MISSION_STEPS,
} from "@/data/gameplay/repairMissionState";
import { useGameStore } from "@/managers/stores/useGameStore";
import type { MainGameState } from "@/types/game";
function toPascalCase(value: string): string {
return value
@@ -19,18 +22,18 @@ function toPascalCase(value: string): string {
export function GameStateDebugPanel(): React.JSX.Element {
const mainState = useGameStore((state) => state.mainState);
const ebikeStep = useGameStore((state) => state.ebike.currentStep);
const pyloneStep = useGameStore((state) => state.pylone.currentStep);
const fermeStep = useGameStore((state) => state.ferme.currentStep);
const pylonStep = useGameStore((state) => state.pylon.currentStep);
const farmStep = useGameStore((state) => state.farm.currentStep);
const detail = useGameStore((state) => {
switch (state.mainState) {
case "intro":
return state.intro.currentStep;
case "ebike":
return state.ebike.currentStep;
case "pylone":
return state.pylone.currentStep;
case "ferme":
return state.ferme.currentStep;
case "pylon":
return state.pylon.currentStep;
case "farm":
return state.farm.currentStep;
case "outro":
return state.outro.hasStarted ? "started" : "waiting";
}
@@ -38,8 +41,8 @@ export function GameStateDebugPanel(): React.JSX.Element {
const setMainState = useGameStore((state) => state.setMainState);
const setIntroStep = useGameStore((state) => state.setIntroStep);
const setEbikeState = useGameStore((state) => state.setEbikeState);
const setPyloneState = useGameStore((state) => state.setPyloneState);
const setFermeState = useGameStore((state) => state.setFermeState);
const setPylonState = useGameStore((state) => state.setPylonState);
const setFarmState = useGameStore((state) => state.setFarmState);
const setOutroState = useGameStore((state) => state.setOutroState);
const advanceGameState = useGameStore((state) => state.advanceGameState);
const rewindGameState = useGameStore((state) => state.rewindGameState);
@@ -72,13 +75,13 @@ export function GameStateDebugPanel(): React.JSX.Element {
return;
}
if (mainState === "pylone") {
setPyloneState({ currentStep: nextSubState });
if (mainState === "pylon") {
setPylonState({ currentStep: nextSubState });
return;
}
if (mainState === "ferme") {
setFermeState({ currentStep: nextSubState });
if (mainState === "farm") {
setFarmState({ currentStep: nextSubState });
return;
}
}
@@ -86,18 +89,34 @@ export function GameStateDebugPanel(): React.JSX.Element {
function setDebugMainState(nextMainState: MainGameState): void {
setMainState(nextMainState);
if (
nextMainState === "pylon" ||
nextMainState === "farm" ||
nextMainState === "outro"
) {
setEbikeState({ currentStep: "done", isRepaired: true });
}
if (nextMainState === "farm" || nextMainState === "outro") {
setPylonState({ currentStep: "done", isPowered: true });
}
if (nextMainState === "outro") {
setFarmState({ currentStep: "done", irrigationFixed: true });
}
if (nextMainState === "ebike" && ebikeStep === "locked") {
setEbikeState({ currentStep: "waiting" });
return;
}
if (nextMainState === "pylone" && pyloneStep === "locked") {
setPyloneState({ currentStep: "waiting" });
if (nextMainState === "pylon" && pylonStep === "locked") {
setPylonState({ currentStep: "waiting" });
return;
}
if (nextMainState === "ferme" && fermeStep === "locked") {
setFermeState({ currentStep: "waiting" });
if (nextMainState === "farm" && farmStep === "locked") {
setFarmState({ currentStep: "waiting" });
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ import * as THREE from "three";
import { ZONES } from "@/data/zones";
import { useGameStore } from "@/managers/stores/useGameStore";
import { Debug } from "@/utils/debug/Debug";
import { GAME_STEPS } from "@/types/game";
import { GAME_STEPS } from "@/data/game/gameStateConfig";
const _playerPos = new THREE.Vector3();
const _zonePos = new THREE.Vector3();
+8
View File
@@ -1,3 +1,5 @@
import type { AudioCategory } from "@/managers/AudioManager";
export const AUDIO_PATHS = {
intro: "/sounds/effect/fa.mp3",
bienvenue: "/sounds/effect/fa.mp3",
@@ -5,3 +7,9 @@ export const AUDIO_PATHS = {
searching: "/sounds/effect/fa.mp3",
helped: "/sounds/effect/fa.mp3",
} as const;
export const DEFAULT_CATEGORY_VOLUMES: Record<AudioCategory, number> = {
music: 1,
sfx: 1,
dialogue: 1,
};
+4 -4
View File
@@ -31,19 +31,19 @@ export const TEST_SCENE_REPAIR_ZONES = [
position: [-12, 0, -12],
},
{
mission: "pylone",
label: "Pylone",
mission: "pylon",
label: "Pylon",
color: "#facc15",
position: [0, 0, -12],
},
{
mission: "ferme",
mission: "farm",
label: "Farm",
color: "#86efac",
position: [12, 0, -12],
},
] as const satisfies readonly {
mission: "ebike" | "pylone" | "ferme";
mission: "ebike" | "pylon" | "farm";
label: string;
color: string;
position: Vector3Tuple;
+19 -13
View File
@@ -38,53 +38,59 @@ export const docGroups: DocGroup[] = [
subtitle: "Gameplay implementation",
meta: "04",
},
{
path: "/docs/mission-flow",
title: "Mission Flow",
subtitle: "Intro and mission progression",
meta: "05",
},
{
path: "/docs/interaction",
title: "Interaction System",
subtitle: "Trigger, grab, hand input",
meta: "05",
meta: "06",
},
{
path: "/docs/target-architecture",
title: "Target Architecture",
subtitle: "Next direction",
meta: "06",
meta: "07",
},
{
path: "/docs/technical-editor",
title: "Editor Technical Notes",
subtitle: "Implementation details",
meta: "07",
meta: "08",
},
{
path: "/docs/audio",
title: "Audio Technical Notes",
subtitle: "Music, dialogue, SRT, and SFX",
meta: "08",
meta: "09",
},
{
path: "/docs/hand-tracking",
title: "Hand Tracking Technical Notes",
subtitle: "Webcam interaction pipeline",
meta: "09",
meta: "10",
},
{
path: "/docs/zustand",
title: "Zustand Stores",
subtitle: "Game, settings, subtitles",
meta: "10",
meta: "11",
},
{
path: "/docs/three-debugging",
title: "Three Debugging",
subtitle: "Step into Three.js internals",
meta: "11",
meta: "12",
},
{
path: "/docs/map-performance",
title: "Map Performance",
subtitle: "Draw calls, triangles, and streaming",
meta: "12",
meta: "13",
},
],
},
@@ -95,25 +101,25 @@ export const docGroups: DocGroup[] = [
path: "/docs/features",
title: "Features",
subtitle: "Implemented scope",
meta: "13",
meta: "14",
},
{
path: "/docs/main-feature",
title: "Main Feature",
subtitle: "Repair-game prototype",
meta: "14",
meta: "15",
},
{
path: "/docs/editor",
title: "Editor User Guide",
subtitle: "Editing workflow",
meta: "15",
meta: "16",
},
{
path: "/docs/animation",
title: "Animation & 3D Model System",
subtitle: "Components and usage",
meta: "16",
meta: "17",
},
],
},
@@ -124,7 +130,7 @@ export const docGroups: DocGroup[] = [
path: "/docs/code-review",
title: "Code Review Prep",
subtitle: "Presentation support",
meta: "17",
meta: "18",
},
],
},
+33
View File
@@ -0,0 +1,33 @@
import type { GameStep, MainGameState } from "@/types/game";
export const GAME_STEPS: readonly GameStep[] = [
"intro",
"start-intro",
"naming",
"bienvenue",
"star-move",
"mission2",
"searching",
"helped",
"manipulation",
"outOfFabrik",
];
export const MAIN_GAME_STATES: readonly MainGameState[] = [
"intro",
"ebike",
"pylon",
"farm",
"outro",
] as const;
const GAME_STEP_VALUES: ReadonlySet<string> = new Set(GAME_STEPS);
const MAIN_GAME_STATE_VALUES: ReadonlySet<string> = new Set(MAIN_GAME_STATES);
export function isGameStep(value: unknown): value is GameStep {
return typeof value === "string" && GAME_STEP_VALUES.has(value);
}
export function isMainGameState(value: unknown): value is MainGameState {
return typeof value === "string" && MAIN_GAME_STATE_VALUES.has(value);
}
+22 -7
View File
@@ -1,21 +1,36 @@
import type { Vector3Tuple } from "@/types/three/three";
import type { RepairMissionId } from "@/types/gameplay/repairMission";
export interface RepairMissionTriggerConfig {
mission: RepairMissionId;
label: string;
radius: number;
}
export const EBIKE_REPAIR_POSITION = [
42.2399, 4.5484, 34.6468,
] as const satisfies Vector3Tuple;
const REPAIR_MISSION_POSITIONS = {
ebike: EBIKE_REPAIR_POSITION,
pylone: [64, 0, -66],
ferme: [-24, 0, 42],
pylon: [64, 0, -66],
farm: [-24, 0, 42],
} as const satisfies Record<RepairMissionId, Vector3Tuple>;
export const REPAIR_MISSION_POSITION_ENTRIES = [
{ mission: "ebike", position: REPAIR_MISSION_POSITIONS.ebike },
{ mission: "pylone", position: REPAIR_MISSION_POSITIONS.pylone },
{ mission: "ferme", position: REPAIR_MISSION_POSITIONS.ferme },
] as const satisfies readonly {
export const REPAIR_MISSION_TRIGGERS = [
{
mission: "ebike",
label: "Réparer l'e-bike",
radius: 4,
},
] as const satisfies readonly RepairMissionTriggerConfig[];
export const REPAIR_MISSION_POSITION_ENTRIES = Object.entries(
REPAIR_MISSION_POSITIONS,
).map(([mission, position]) => ({
mission: mission as RepairMissionId,
position,
})) satisfies readonly {
mission: RepairMissionId;
position: Vector3Tuple;
}[];
+69
View File
@@ -0,0 +1,69 @@
import type {
MissionStep,
RepairMissionId,
} from "@/types/gameplay/repairMission";
const REPAIR_MISSION_IDS = ["ebike", "pylon", "farm"] as const;
const REPAIR_MISSION_ID_VALUES: ReadonlySet<string> = new Set(
REPAIR_MISSION_IDS,
);
export const MISSION_STEPS = [
"locked",
"waiting",
"inspected",
"fragmented",
"scanning",
"repairing",
"reassembling",
"done",
] as const satisfies readonly MissionStep[];
const MISSION_STEP_VALUES: ReadonlySet<string> = new Set(MISSION_STEPS);
export function isRepairMissionId(value: string): value is RepairMissionId {
return REPAIR_MISSION_ID_VALUES.has(value);
}
export function isMissionStep(value: string): value is MissionStep {
return MISSION_STEP_VALUES.has(value);
}
export function getNextMissionStep(step: MissionStep): MissionStep {
switch (step) {
case "locked":
return "waiting";
case "waiting":
return "inspected";
case "inspected":
return "fragmented";
case "fragmented":
return "scanning";
case "scanning":
return "repairing";
case "repairing":
return "reassembling";
case "reassembling":
case "done":
return "done";
}
}
export function getPreviousMissionStep(step: MissionStep): MissionStep {
switch (step) {
case "locked":
case "waiting":
return "locked";
case "inspected":
return "waiting";
case "fragmented":
return "inspected";
case "scanning":
return "fragmented";
case "repairing":
return "scanning";
case "reassembling":
return "repairing";
case "done":
return "reassembling";
}
}
+16 -16
View File
@@ -53,8 +53,8 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
},
],
},
pylone: {
id: "pylone",
pylon: {
id: "pylon",
label: "Power pylon",
description:
"Restore the pylon lamp relay and damaged panel before reconnecting the grid",
@@ -64,17 +64,17 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
brokenUiPath: REPAIR_BROKEN_UI_PATH,
case: DEFAULT_REPAIR_CASE,
reassemblySeconds: 1.8,
requiredReplacementPartId: "pylone-grid-relay-replacement",
requiredReplacementPartId: "pylon-grid-relay-replacement",
scanPartSeconds: 1.4,
brokenParts: [
{
id: "pylone-grid-relay",
id: "pylon-grid-relay",
label: "Grid relay",
nodeName: "lampe",
caseSlotName: "placeholder_1",
},
{
id: "pylone-damaged-panel",
id: "pylon-damaged-panel",
label: "Damaged solar panel",
nodeName: "panneau2",
caseSlotName: "placeholder_2",
@@ -82,24 +82,24 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
],
replacementParts: [
{
id: "pylone-grid-relay-replacement",
id: "pylon-grid-relay-replacement",
label: "Replacement grid relay",
modelPath: "/models/pylone/model.gltf",
},
{
id: "pylone-stone-distractor",
id: "pylon-stone-distractor",
label: "Stone counterweight",
modelPath: "/models/galet/model.gltf",
},
{
id: "pylone-cooling-distractor",
id: "pylon-cooling-distractor",
label: "Cooling core",
modelPath: "/models/refroidisseur/model.gltf",
},
],
},
ferme: {
id: "ferme",
farm: {
id: "farm",
label: "Vertical farm",
description:
"Stabilize the irrigation loop and humidity sensor before restarting the farm",
@@ -109,33 +109,33 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
brokenUiPath: REPAIR_BROKEN_UI_PATH,
case: DEFAULT_REPAIR_CASE,
reassemblySeconds: 1.2,
requiredReplacementPartId: "ferme-irrigation-pump-replacement",
requiredReplacementPartId: "farm-irrigation-pump-replacement",
scanPartSeconds: 0.9,
brokenParts: [
{
id: "ferme-irrigation-pump",
id: "farm-irrigation-pump",
label: "Irrigation pump",
caseSlotName: "placeholder_1",
},
{
id: "ferme-humidity-sensor",
id: "farm-humidity-sensor",
label: "Humidity sensor",
caseSlotName: "placeholder_2",
},
],
replacementParts: [
{
id: "ferme-irrigation-pump-replacement",
id: "farm-irrigation-pump-replacement",
label: "Replacement irrigation pump",
modelPath: "/models/fermeverticale/model.gltf",
},
{
id: "ferme-tree-distractor",
id: "farm-tree-distractor",
label: "Tree sensor",
modelPath: "/models/sapin/model.gltf",
},
{
id: "ferme-radio-distractor",
id: "farm-radio-distractor",
label: "Radio module",
modelPath: "/models/talkie/model.gltf",
},
-15
View File
@@ -1,6 +1,3 @@
const HAND_TRACKING_LOCAL_WS_URL = "ws://localhost:8000/ws";
const HAND_TRACKING_PROD_WS_URL = "wss://handtracking.la-fabrik.fr/ws";
export const HAND_TRACKING_FRAME_WIDTH = 320;
export const HAND_TRACKING_FRAME_HEIGHT = 240;
export const HAND_TRACKING_TARGET_FPS = 10;
@@ -11,15 +8,3 @@ export const HAND_TRACKING_BROWSER_WASM_URL =
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.35/wasm";
export const HAND_TRACKING_BROWSER_MODEL_URL =
"https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task";
export function getHandTrackingWsUrl(): string {
const configuredUrl = import.meta.env.VITE_HAND_TRACKING_WS_URL;
if (configuredUrl) {
return configuredUrl;
}
return import.meta.env.DEV
? HAND_TRACKING_LOCAL_WS_URL
: HAND_TRACKING_PROD_WS_URL;
}
-10
View File
@@ -89,13 +89,3 @@ export type MapInstancingAssetType =
export type MapInstancingAssetConfig =
(typeof MAP_INSTANCING_ASSETS)[MapInstancingAssetType];
const MAP_INSTANCED_NODE_NAMES: ReadonlySet<string> = new Set(
Object.values(MAP_INSTANCING_ASSETS)
.filter((config) => config.enabled)
.map((config) => config.mapName),
);
export function isInstancedMapNodeName(name: string): boolean {
return MAP_INSTANCED_NODE_NAMES.has(name);
}
+97
View File
@@ -0,0 +1,97 @@
export type MapPerformanceGroupName =
| "vegetation"
| "crops"
| "trees"
| "buildings"
| "landmarks"
| "props"
| "terrain"
| "sky";
export type MapPerformanceModelName =
| "buisson"
| "arbre"
| "sapin"
| "champdeble"
| "champdesoja"
| "champsdetournesol"
| "ecole"
| "generateur"
| "fermeverticale"
| "lafabrik"
| "immeuble1"
| "eolienne"
| "pylone"
| "boiteauxlettres"
| "maison1"
| "panneauaffichage"
| "panneauclassique"
| "panneaufleche"
| "panneausolaire"
| "parcebike"
| "terrain"
| "sky";
export const MAP_PERFORMANCE_GROUP_NAMES: readonly MapPerformanceGroupName[] = [
"vegetation",
"crops",
"trees",
"buildings",
"landmarks",
"props",
"terrain",
"sky",
];
export const MAP_PERFORMANCE_MODEL_NAMES: readonly MapPerformanceModelName[] = [
"buisson",
"arbre",
"sapin",
"champdeble",
"champdesoja",
"champsdetournesol",
"ecole",
"generateur",
"fermeverticale",
"lafabrik",
"immeuble1",
"eolienne",
"pylone",
"boiteauxlettres",
"maison1",
"panneauaffichage",
"panneauclassique",
"panneaufleche",
"panneausolaire",
"parcebike",
"terrain",
"sky",
];
export const MAP_PERFORMANCE_MODEL_GROUPS: Record<
MapPerformanceModelName,
readonly MapPerformanceGroupName[]
> = {
buisson: ["vegetation"],
arbre: ["vegetation", "trees"],
sapin: ["vegetation", "trees"],
champdeble: ["vegetation", "crops"],
champdesoja: ["vegetation", "crops"],
champsdetournesol: ["vegetation", "crops"],
ecole: ["buildings", "landmarks"],
generateur: ["landmarks"],
fermeverticale: ["buildings", "landmarks"],
lafabrik: ["buildings", "landmarks"],
immeuble1: ["buildings"],
eolienne: ["props"],
pylone: ["props"],
boiteauxlettres: ["props"],
maison1: ["buildings"],
panneauaffichage: ["props"],
panneauclassique: ["props"],
panneaufleche: ["props"],
panneausolaire: ["props"],
parcebike: ["props"],
terrain: ["terrain"],
sky: ["sky"],
};
+7
View File
@@ -0,0 +1,7 @@
import type { SceneLoadingState } from "@/types/world/sceneLoading";
export const INITIAL_SCENE_LOADING_STATE: SceneLoadingState = {
currentStep: "Initialisation du jeu",
progress: 0,
status: "loading",
};
@@ -2,7 +2,7 @@ export const VEGETATION_TYPES = {
buissons: {
mapName: "buisson",
modelPath: "/models/buisson/model.gltf",
scaleMultiplier: 2,
scaleMultiplier: 1.5,
castShadow: true,
receiveShadow: true,
windStrength: 0.08,
@@ -11,7 +11,7 @@ export const VEGETATION_TYPES = {
sapin: {
mapName: "sapin",
modelPath: "/models/sapin/model.gltf",
scaleMultiplier: 5,
scaleMultiplier: 4,
castShadow: true,
receiveShadow: true,
windStrength: 0.04,
-9
View File
@@ -13,12 +13,3 @@ export const WIND_BOUNDS = {
};
export type WindState = typeof WIND_DEFAULTS;
export function getWindVector(wind: WindState): { x: number; z: number } {
const intensity = wind.speed * wind.strength;
return {
x: Math.cos(wind.direction) * intensity,
z: Math.sin(wind.direction) * intensity,
};
}
@@ -6,10 +6,10 @@ export function useRepairMovementLocked(): boolean {
switch (state.mainState) {
case "ebike":
return isRepairMovementLocked(state.ebike.currentStep);
case "pylone":
return isRepairMovementLocked(state.pylone.currentStep);
case "ferme":
return isRepairMovementLocked(state.ferme.currentStep);
case "pylon":
return isRepairMovementLocked(state.pylon.currentStep);
case "farm":
return isRepairMovementLocked(state.farm.currentStep);
case "intro":
case "outro":
return false;
@@ -23,6 +23,7 @@ function isRepairMovementLocked(step: MissionStep): boolean {
step === "fragmented" ||
step === "scanning" ||
step === "repairing" ||
step === "reassembling"
step === "reassembling" ||
step === "done"
);
}
@@ -5,8 +5,8 @@ import {
HAND_TRACKING_JPEG_QUALITY,
HAND_TRACKING_RESPONSE_TIMEOUT_MS,
HAND_TRACKING_TARGET_FPS,
getHandTrackingWsUrl,
} from "@/data/handTrackingConfig";
import { getHandTrackingWsUrl } from "@/utils/handTracking/handTrackingEndpoint";
import {
INITIAL_HAND_TRACKING_SNAPSHOT,
getCameraStreamWithTimeout,
+2 -11
View File
@@ -1,15 +1,6 @@
import { useEffect, useMemo } from "react";
import { useMemo } from "react";
import * as THREE from "three";
import { disposeObject3D } from "@/utils/three/dispose";
export function useClonedObject<T extends THREE.Object3D>(object: T): T {
const clone = useMemo(() => object.clone(true) as T, [object]);
useEffect(() => {
return () => {
disposeObject3D(clone);
};
}, [clone]);
return clone;
return useMemo(() => object.clone(true) as T, [object]);
}
+35 -4
View File
@@ -16,6 +16,24 @@ interface TerrainHeightSampler {
getHeight: (x: number, z: number) => number | null;
}
interface CachedTerrainHeightSampler {
key: string;
sampler: TerrainHeightSampler;
}
const terrainSamplerCache = new WeakMap<
THREE.Object3D,
CachedTerrainHeightSampler
>();
function createTerrainSamplerCacheKey(
position: Vector3Tuple,
rotation: Vector3Tuple,
scale: Vector3Tuple,
): string {
return `${position.join(",")}|${rotation.join(",")}|${scale.join(",")}`;
}
function createTerrainHeightSampler(
scene: THREE.Object3D,
position: Vector3Tuple,
@@ -64,10 +82,23 @@ export function useTerrainHeightSampler(): TerrainHeightSampler {
const rotation = terrainNode?.rotation ?? DEFAULT_TERRAIN_ROTATION;
const scale = terrainNode?.scale ?? DEFAULT_TERRAIN_SCALE;
return useMemo(
() => createTerrainHeightSampler(scene, position, rotation, scale),
[position, rotation, scale, scene],
);
return useMemo(() => {
const key = createTerrainSamplerCacheKey(position, rotation, scale);
const cached = terrainSamplerCache.get(scene);
if (cached?.key === key) {
return cached.sampler;
}
const sampler = createTerrainHeightSampler(
scene,
position,
rotation,
scale,
);
terrainSamplerCache.set(scene, { key, sampler });
return sampler;
}, [position, rotation, scale, scene]);
}
export function useTerrainSnappedPosition(
+1 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { INSTANCED_MAP_EXCEPTIONS } from "@/world/vegetation/vegetationConfig";
import { INSTANCED_MAP_EXCEPTIONS } from "@/data/world/vegetationConfig";
import type { MapNode } from "@/types/map/mapScene";
import {
type MapNodeInstanceTransform,
+26 -16
View File
@@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { CHUNK_CONFIG } from "@/data/world/chunkStreamingConfig";
@@ -18,6 +18,7 @@ export function useVisibleWorldChunks<TChunk extends WorldChunkLike>(
): readonly TChunk[] {
const camera = useThree((state) => state.camera);
const lastUpdateRef = useRef(-CHUNK_CONFIG.updateInterval);
const activeChunkKeysRef = useRef<Set<string>>(new Set());
const [activeChunkKeys, setActiveChunkKeys] = useState<Set<string>>(
() => new Set(),
);
@@ -32,7 +33,7 @@ export function useVisibleWorldChunks<TChunk extends WorldChunkLike>(
chunk.centerX - cameraX,
chunk.centerZ - cameraZ,
);
const wasActive = activeChunkKeys.has(chunk.key);
const wasActive = activeChunkKeysRef.current.has(chunk.key);
const radius = wasActive
? CHUNK_CONFIG.unloadRadius
: CHUNK_CONFIG.loadRadius;
@@ -42,10 +43,11 @@ export function useVisibleWorldChunks<TChunk extends WorldChunkLike>(
}
}
if (areSetsEqual(nextKeys, activeChunkKeys)) return;
if (areSetsEqual(nextKeys, activeChunkKeysRef.current)) return;
activeChunkKeysRef.current = nextKeys;
setActiveChunkKeys(nextKeys);
}, [activeChunkKeys, camera, chunks]);
}, [camera, chunks]);
useFrame(({ clock }) => {
if (!streamingEnabled) return;
@@ -57,18 +59,26 @@ export function useVisibleWorldChunks<TChunk extends WorldChunkLike>(
updateActiveChunks();
});
if (!streamingEnabled) return chunks;
return useMemo(() => {
if (!streamingEnabled) return chunks;
return chunks.filter((chunk) => {
if (activeChunkKeys.size > 0) {
return activeChunkKeys.has(chunk.key);
}
return chunks.filter((chunk) => {
if (activeChunkKeys.size > 0) {
return activeChunkKeys.has(chunk.key);
}
return (
Math.hypot(
chunk.centerX - camera.position.x,
chunk.centerZ - camera.position.z,
) <= CHUNK_CONFIG.loadRadius
);
});
return (
Math.hypot(
chunk.centerX - camera.position.x,
chunk.centerZ - camera.position.z,
) <= CHUNK_CONFIG.loadRadius
);
});
}, [
activeChunkKeys,
camera.position.x,
camera.position.z,
chunks,
streamingEnabled,
]);
}
+1 -6
View File
@@ -1,3 +1,4 @@
import { DEFAULT_CATEGORY_VOLUMES } from "@/data/audioConfig";
import { logger } from "@/utils/core/Logger";
export type AudioCategory = "music" | "sfx" | "dialogue";
@@ -7,12 +8,6 @@ interface AudioContextWindow extends Window {
webkitAudioContext?: typeof AudioContext;
}
const DEFAULT_CATEGORY_VOLUMES: Record<AudioCategory, number> = {
music: 1,
sfx: 1,
dialogue: 1,
};
interface PlaySoundOptions {
category?: OneShotAudioCategory;
pan?: number;
+54 -52
View File
@@ -1,15 +1,13 @@
import { create } from "zustand";
import { isGameStep, isMainGameState } from "@/data/game/gameStateConfig";
import {
isGameStep,
isMainGameState,
type GameStep,
type MainGameState,
} from "@/types/game";
import {
isRepairMissionId,
isMissionStep,
getNextMissionStep,
getPreviousMissionStep,
isMissionStep,
isRepairMissionId,
} from "@/data/gameplay/repairMissionState";
import type { GameStep, MainGameState } from "@/types/game";
import {
type MissionStep,
type RepairMissionId,
} from "@/types/gameplay/repairMission";
@@ -49,10 +47,10 @@ export interface GameState {
ebike: MissionState & {
isRepaired: boolean;
};
pylone: MissionState & {
pylon: MissionState & {
isPowered: boolean;
};
ferme: MissionState & {
farm: MissionState & {
irrigationFixed: boolean;
};
outro: {
@@ -71,14 +69,14 @@ interface GameActions {
setIntroState: (intro: Partial<IntroState>) => void;
setPlayerName: (playerName: string) => void;
setEbikeState: (ebike: Partial<GameState["ebike"]>) => void;
setPyloneState: (pylone: Partial<GameState["pylone"]>) => void;
setFermeState: (ferme: Partial<GameState["ferme"]>) => void;
setPylonState: (pylon: Partial<GameState["pylon"]>) => void;
setFarmState: (farm: Partial<GameState["farm"]>) => void;
setOutroState: (outro: Partial<GameState["outro"]>) => void;
setMissionStep: (mission: RepairMissionId, step: MissionStep) => void;
completeIntro: () => void;
completeEbike: () => void;
completePylone: () => void;
completeFerme: () => void;
completePylon: () => void;
completeFarm: () => void;
completeMission: (mission: RepairMissionId) => void;
startOutro: () => void;
advanceGameState: () => void;
@@ -110,6 +108,10 @@ function completeIntroState(state: GameState): GameStateUpdate {
hasCompleted: true,
isEbikeUnlocked: true,
},
missionFlow: {
...state.missionFlow,
canMove: true,
},
ebike: {
...state.ebike,
currentStep: "locked",
@@ -119,39 +121,39 @@ function completeIntroState(state: GameState): GameStateUpdate {
function completeEbikeState(state: GameState): GameStateUpdate {
return {
mainState: "pylone",
mainState: "pylon",
ebike: {
...state.ebike,
currentStep: "done",
isRepaired: true,
},
pylone: {
...state.pylone,
pylon: {
...state.pylon,
currentStep: "waiting",
},
};
}
function completePyloneState(state: GameState): GameStateUpdate {
function completePylonState(state: GameState): GameStateUpdate {
return {
mainState: "ferme",
pylone: {
...state.pylone,
mainState: "farm",
pylon: {
...state.pylon,
currentStep: "done",
isPowered: true,
},
ferme: {
...state.ferme,
farm: {
...state.farm,
currentStep: "waiting",
},
};
}
function completeFermeState(state: GameState): GameStateUpdate {
function completeFarmState(state: GameState): GameStateUpdate {
return {
mainState: "outro",
ferme: {
...state.ferme,
farm: {
...state.farm,
currentStep: "done",
irrigationFixed: true,
},
@@ -182,10 +184,10 @@ function completeMissionState(
switch (mission) {
case "ebike":
return completeEbikeState(state);
case "pylone":
return completePyloneState(state);
case "ferme":
return completeFermeState(state);
case "pylon":
return completePylonState(state);
case "farm":
return completeFarmState(state);
}
}
@@ -243,12 +245,12 @@ function createInitialGameState(): GameState {
dialogueAudio: null,
isRepaired: false,
},
pylone: {
pylon: {
currentStep: "locked",
dialogueAudio: null,
isPowered: false,
},
ferme: {
farm: {
currentStep: "locked",
dialogueAudio: null,
irrigationFixed: false,
@@ -321,8 +323,8 @@ function hydrateDebugGameState(initial: GameState, value: unknown): GameState {
if (!isRecord(value)) return initial;
const ebike = hydrateMissionState(initial.ebike, value.ebike);
const pylone = hydrateMissionState(initial.pylone, value.pylone);
const ferme = hydrateMissionState(initial.ferme, value.ferme);
const pylon = hydrateMissionState(initial.pylon, value.pylon);
const farm = hydrateMissionState(initial.farm, value.farm);
const outro = isRecord(value.outro) ? value.outro : null;
return {
@@ -344,19 +346,19 @@ function hydrateDebugGameState(initial: GameState, value: unknown): GameState {
? value.ebike.isRepaired
: initial.ebike.isRepaired,
},
pylone: {
...pylone,
pylon: {
...pylon,
isPowered:
isRecord(value.pylone) && isBoolean(value.pylone.isPowered)
? value.pylone.isPowered
: initial.pylone.isPowered,
isRecord(value.pylon) && isBoolean(value.pylon.isPowered)
? value.pylon.isPowered
: initial.pylon.isPowered,
},
ferme: {
...ferme,
farm: {
...farm,
irrigationFixed:
isRecord(value.ferme) && isBoolean(value.ferme.irrigationFixed)
? value.ferme.irrigationFixed
: initial.ferme.irrigationFixed,
isRecord(value.farm) && isBoolean(value.farm.irrigationFixed)
? value.farm.irrigationFixed
: initial.farm.irrigationFixed,
},
outro: {
dialogueAudio:
@@ -385,8 +387,8 @@ function pickGameState(state: GameStore): GameState {
missionFlow: state.missionFlow,
intro: state.intro,
ebike: state.ebike,
pylone: state.pylone,
ferme: state.ferme,
pylon: state.pylon,
farm: state.farm,
outro: state.outro,
};
}
@@ -417,18 +419,18 @@ export const useGameStore = create<GameStore>()((set) => ({
})),
setEbikeState: (ebike) =>
set((state) => ({ ebike: { ...state.ebike, ...ebike } })),
setPyloneState: (pylone) =>
set((state) => ({ pylone: { ...state.pylone, ...pylone } })),
setFermeState: (ferme) =>
set((state) => ({ ferme: { ...state.ferme, ...ferme } })),
setPylonState: (pylon) =>
set((state) => ({ pylon: { ...state.pylon, ...pylon } })),
setFarmState: (farm) =>
set((state) => ({ farm: { ...state.farm, ...farm } })),
setOutroState: (outro) =>
set((state) => ({ outro: { ...state.outro, ...outro } })),
setMissionStep: (mission, step) =>
set((state) => setMissionStepState(state, mission, step)),
completeIntro: () => set(completeIntroState),
completeEbike: () => set((state) => completeMissionState(state, "ebike")),
completePylone: () => set((state) => completeMissionState(state, "pylone")),
completeFerme: () => set((state) => completeMissionState(state, "ferme")),
completePylon: () => set((state) => completeMissionState(state, "pylon")),
completeFarm: () => set((state) => completeMissionState(state, "farm")),
completeMission: (mission) =>
set((state) => completeMissionState(state, mission)),
startOutro: () => set(startOutroState),
+16 -98
View File
@@ -1,38 +1,18 @@
import { create } from "zustand";
import {
MAP_PERFORMANCE_GROUP_NAMES,
MAP_PERFORMANCE_MODEL_GROUPS,
MAP_PERFORMANCE_MODEL_NAMES,
type MapPerformanceGroupName,
type MapPerformanceModelName,
} from "@/data/world/mapPerformanceConfig";
export type MapPerformanceGroupName =
| "vegetation"
| "crops"
| "trees"
| "buildings"
| "landmarks"
| "props"
| "terrain"
| "sky";
export type MapPerformanceModelName =
| "buisson"
| "arbre"
| "sapin"
| "champdeble"
| "champdesoja"
| "champsdetournesol"
| "ecole"
| "generateur"
| "fermeverticale"
| "lafabrik"
| "immeuble1"
| "eolienne"
| "pylone"
| "boiteauxlettres"
| "maison1"
| "panneauaffichage"
| "panneauclassique"
| "panneaufleche"
| "panneausolaire"
| "parcebike"
| "terrain"
| "sky";
export {
MAP_PERFORMANCE_GROUP_NAMES,
MAP_PERFORMANCE_MODEL_NAMES,
type MapPerformanceGroupName,
type MapPerformanceModelName,
};
export interface MapPerformanceVisibility {
groups: Record<MapPerformanceGroupName, boolean>;
@@ -47,70 +27,6 @@ interface MapPerformanceActions {
type MapPerformanceStore = MapPerformanceVisibility & MapPerformanceActions;
export const MAP_PERFORMANCE_GROUP_NAMES: readonly MapPerformanceGroupName[] = [
"vegetation",
"crops",
"trees",
"buildings",
"landmarks",
"props",
"terrain",
"sky",
];
export const MAP_PERFORMANCE_MODEL_NAMES: readonly MapPerformanceModelName[] = [
"buisson",
"arbre",
"sapin",
"champdeble",
"champdesoja",
"champsdetournesol",
"ecole",
"generateur",
"fermeverticale",
"lafabrik",
"immeuble1",
"eolienne",
"pylone",
"boiteauxlettres",
"maison1",
"panneauaffichage",
"panneauclassique",
"panneaufleche",
"panneausolaire",
"parcebike",
"terrain",
"sky",
];
const MODEL_GROUPS: Record<
MapPerformanceModelName,
readonly MapPerformanceGroupName[]
> = {
buisson: ["vegetation"],
arbre: ["vegetation", "trees"],
sapin: ["vegetation", "trees"],
champdeble: ["vegetation", "crops"],
champdesoja: ["vegetation", "crops"],
champsdetournesol: ["vegetation", "crops"],
ecole: ["buildings", "landmarks"],
generateur: ["landmarks"],
fermeverticale: ["buildings", "landmarks"],
lafabrik: ["buildings", "landmarks"],
immeuble1: ["buildings"],
eolienne: ["props"],
pylone: ["props"],
boiteauxlettres: ["props"],
maison1: ["buildings"],
panneauaffichage: ["props"],
panneauclassique: ["props"],
panneaufleche: ["props"],
panneausolaire: ["props"],
parcebike: ["props"],
terrain: ["terrain"],
sky: ["sky"],
};
function createVisibleRecord<T extends string>(
keys: readonly T[],
): Record<T, boolean> {
@@ -140,7 +56,9 @@ export function isMapModelVisible(
if (!isMapPerformanceModelName(name)) return true;
if (!visibility.models[name]) return false;
return MODEL_GROUPS[name].every((group) => visibility.groups[group]);
return MAP_PERFORMANCE_MODEL_GROUPS[name].every(
(group) => visibility.groups[group],
);
}
export const useMapPerformanceStore = create<MapPerformanceStore>()((set) => ({
+1 -1
View File
@@ -6,6 +6,7 @@ import { EditorScene } from "@/components/editor/scene/EditorScene";
import type { EditorCinematicPreviewRequest } from "@/components/editor/scene/EditorScene";
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
import { Subtitles } from "@/components/ui/Subtitles";
import { INITIAL_SCENE_LOADING_STATE } from "@/data/world/sceneLoadingConfig";
import { useEditorHistory } from "@/hooks/editor/useEditorHistory";
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
import { useEditorSceneData } from "@/hooks/editor/useEditorSceneData";
@@ -16,7 +17,6 @@ import type {
TransformMode,
} from "@/types/editor/editor";
import {
INITIAL_SCENE_LOADING_STATE,
type SceneLoadingChangeHandler,
type SceneLoadingState,
} from "@/types/world/sceneLoading";
+2 -4
View File
@@ -6,12 +6,10 @@ import { DialogMessage } from "@/components/ui/DialogMessage";
import { GameUI } from "@/components/ui/GameUI";
import { BienvenueDisplay, IntroUI } from "@/components/ui/IntroUI";
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
import { INITIAL_SCENE_LOADING_STATE } from "@/data/world/sceneLoadingConfig";
import { useGameStore } from "@/managers/stores/useGameStore";
import { HandTrackingProvider } from "@/providers/gameplay/HandTrackingProvider";
import {
INITIAL_SCENE_LOADING_STATE,
type SceneLoadingState,
} from "@/types/world/sceneLoading";
import type { SceneLoadingState } from "@/types/world/sceneLoading";
import { logger } from "@/utils/core/Logger";
import { World } from "@/world/World";
@@ -28,10 +28,10 @@ export function HandTrackingProvider({
switch (state.mainState) {
case "ebike":
return REPAIR_HAND_TRACKING_STEPS.has(state.ebike.currentStep);
case "pylone":
return REPAIR_HAND_TRACKING_STEPS.has(state.pylone.currentStep);
case "ferme":
return REPAIR_HAND_TRACKING_STEPS.has(state.ferme.currentStep);
case "pylon":
return REPAIR_HAND_TRACKING_STEPS.has(state.pylon.currentStep);
case "farm":
return REPAIR_HAND_TRACKING_STEPS.has(state.farm.currentStep);
case "intro":
case "outro":
return false;
+1 -34
View File
@@ -12,40 +12,7 @@ export type GameStep =
| "manipulation"
| "outOfFabrik";
export const GAME_STEPS: readonly GameStep[] = [
"intro",
"start-intro",
"naming",
"bienvenue",
"star-move",
"mission2",
"searching",
"helped",
"manipulation",
"outOfFabrik",
] as const;
const GAME_STEP_VALUES: ReadonlySet<string> = new Set(GAME_STEPS);
export type MainGameState = "intro" | "ebike" | "pylone" | "ferme" | "outro";
export const MAIN_GAME_STATES: readonly MainGameState[] = [
"intro",
"ebike",
"pylone",
"ferme",
"outro",
] as const;
const MAIN_GAME_STATE_VALUES: ReadonlySet<string> = new Set(MAIN_GAME_STATES);
export function isGameStep(value: unknown): value is GameStep {
return typeof value === "string" && GAME_STEP_VALUES.has(value);
}
export function isMainGameState(value: unknown): value is MainGameState {
return typeof value === "string" && MAIN_GAME_STATE_VALUES.has(value);
}
export type MainGameState = "intro" | "ebike" | "pylon" | "farm" | "outro";
export interface Zone {
id: string;
+1 -66
View File
@@ -4,7 +4,7 @@ import type {
Vector3Tuple,
} from "@/types/three/three";
export type RepairMissionId = "ebike" | "pylone" | "ferme";
export type RepairMissionId = "ebike" | "pylon" | "farm";
export interface RepairMissionCaseConfig {
position: Vector3Tuple;
@@ -53,68 +53,3 @@ export type MissionStep =
| "repairing"
| "reassembling"
| "done";
const REPAIR_MISSION_IDS = ["ebike", "pylone", "ferme"] as const;
const REPAIR_MISSION_ID_VALUES: ReadonlySet<string> = new Set(
REPAIR_MISSION_IDS,
);
export const MISSION_STEPS = [
"locked",
"waiting",
"inspected",
"fragmented",
"scanning",
"repairing",
"reassembling",
"done",
] as const satisfies readonly MissionStep[];
const MISSION_STEP_VALUES: ReadonlySet<string> = new Set(MISSION_STEPS);
export function isRepairMissionId(value: string): value is RepairMissionId {
return REPAIR_MISSION_ID_VALUES.has(value);
}
export function isMissionStep(value: string): value is MissionStep {
return MISSION_STEP_VALUES.has(value);
}
export function getNextMissionStep(step: MissionStep): MissionStep {
switch (step) {
case "locked":
return "waiting";
case "waiting":
return "inspected";
case "inspected":
return "fragmented";
case "fragmented":
return "scanning";
case "scanning":
return "repairing";
case "repairing":
return "reassembling";
case "reassembling":
case "done":
return "done";
}
}
export function getPreviousMissionStep(step: MissionStep): MissionStep {
switch (step) {
case "locked":
case "waiting":
return "locked";
case "inspected":
return "waiting";
case "fragmented":
return "inspected";
case "scanning":
return "fragmented";
case "repairing":
return "scanning";
case "reassembling":
return "repairing";
case "done":
return "reassembling";
}
}
-6
View File
@@ -7,9 +7,3 @@ export interface SceneLoadingState {
}
export type SceneLoadingChangeHandler = (state: SceneLoadingState) => void;
export const INITIAL_SCENE_LOADING_STATE: SceneLoadingState = {
currentStep: "Initialisation du jeu",
progress: 0,
status: "loading",
};
@@ -0,0 +1,14 @@
const HAND_TRACKING_LOCAL_WS_URL = "ws://localhost:8000/ws";
const HAND_TRACKING_PROD_WS_URL = "wss://handtracking.la-fabrik.fr/ws";
export function getHandTrackingWsUrl(): string {
const configuredUrl = import.meta.env.VITE_HAND_TRACKING_WS_URL;
if (configuredUrl) {
return configuredUrl;
}
return import.meta.env.DEV
? HAND_TRACKING_LOCAL_WS_URL
: HAND_TRACKING_PROD_WS_URL;
}
+11
View File
@@ -0,0 +1,11 @@
import { MAP_INSTANCING_ASSETS } from "@/data/world/mapInstancingConfig";
const MAP_INSTANCED_NODE_NAMES: ReadonlySet<string> = new Set(
Object.values(MAP_INSTANCING_ASSETS)
.filter((config) => config.enabled)
.map((config) => config.mapName),
);
export function isInstancedMapNodeName(name: string): boolean {
return MAP_INSTANCED_NODE_NAMES.has(name);
}
+1 -1
View File
@@ -1,5 +1,5 @@
import type { MapNode } from "@/types/map/mapScene";
import { isInstancedMapNodeName } from "@/data/world/mapInstancingConfig";
import { isInstancedMapNodeName } from "@/utils/map/isInstancedMapNodeName";
const MAP_STRUCTURE_NODE_NAMES = new Set(["Scene", "blocking", "terrain"]);
const RUNTIME_VEGETATION_NODE_NAMES = new Set([
+10
View File
@@ -0,0 +1,10 @@
import type { WindState } from "@/data/world/windConfig";
export function getWindVector(wind: WindState): { x: number; z: number } {
const intensity = wind.speed * wind.strength;
return {
x: Math.cos(wind.direction) * intensity,
z: Math.sin(wind.direction) * intensity,
};
}
+1 -1
View File
@@ -24,7 +24,7 @@ import {
import { useGameStore } from "@/managers/stores/useGameStore";
import { GameMapCollision } from "@/world/GameMapCollision";
import { GeneratedMapNodeInstance } from "@/world/map-generated/GeneratedMapNodeInstance";
import { isGeneratedMapModelName } from "@/world/map-generated/generatedMapModelConfig";
import { isGeneratedMapModelName } from "@/data/world/generatedMapModelConfig";
import { MapInstancingSystem } from "@/world/map-instancing/MapInstancingSystem";
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
import { logger } from "@/utils/core/Logger";
+23 -10
View File
@@ -1,8 +1,9 @@
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
import { RepairGame } from "@/components/three/gameplay/RepairGame";
import {
EBIKE_REPAIR_POSITION,
REPAIR_MISSION_POSITION_ENTRIES,
REPAIR_MISSION_TRIGGERS,
type RepairMissionTriggerConfig,
} from "@/data/gameplay/repairMissionAnchors";
import { useGameStore } from "@/managers/stores/useGameStore";
import type { Vector3Tuple } from "@/types/three/three";
@@ -32,21 +33,31 @@ function StageAnchor({
);
}
function EbikeMissionTrigger(): React.JSX.Element | null {
function RepairMissionTrigger({
config,
}: {
config: RepairMissionTriggerConfig;
}): React.JSX.Element | null {
const mainState = useGameStore((state) => state.mainState);
const ebikeStep = useGameStore((state) => state.ebike.currentStep);
const missionStep = useGameStore(
(state) => state[config.mission].currentStep,
);
const setMissionStep = useGameStore((state) => state.setMissionStep);
const position = REPAIR_MISSION_POSITION_ENTRIES.find(
(entry) => entry.mission === config.mission,
)?.position;
if (mainState !== "ebike" || ebikeStep !== "locked") return null;
if (!position) return null;
if (mainState !== config.mission || missionStep !== "locked") return null;
return (
<group position={EBIKE_REPAIR_POSITION}>
<group position={position}>
<InteractableObject
kind="trigger"
label="Réparer l'e-bike"
position={EBIKE_REPAIR_POSITION}
radius={4}
onPress={() => setMissionStep("ebike", "waiting")}
label={config.label}
position={position}
radius={config.radius}
onPress={() => setMissionStep(config.mission, "waiting")}
>
<mesh>
<sphereGeometry args={[1.3, 16, 16]} />
@@ -68,7 +79,9 @@ export function GameStageContent(): React.JSX.Element {
{REPAIR_MISSION_POSITION_ENTRIES.map(({ mission, position }) => (
<RepairGame key={mission} mission={mission} position={position} />
))}
<EbikeMissionTrigger />
{REPAIR_MISSION_TRIGGERS.map((config) => (
<RepairMissionTrigger key={config.mission} config={config} />
))}
{mainState === "outro" ? (
<StageAnchor color="#fb7185" position={[0, 6, 10]} scale={1.25} />
) : null}
+1 -1
View File
@@ -2,7 +2,7 @@ import { Suspense, useMemo, useRef } from "react";
import * as THREE from "three";
import { useFrame } from "@react-three/fiber";
import { CLOUD_CONFIG } from "@/data/world/cloudConfig";
import { getWindVector } from "@/data/world/windConfig";
import { getWindVector } from "@/utils/world/windVector";
import { useDynamicClouds } from "@/hooks/world/useGraphicsSettings";
import { useCloudSettings } from "@/hooks/world/useCloudSettings";
import { useWind } from "@/hooks/world/useWind";
+1 -1
View File
@@ -7,7 +7,7 @@ import {
GRASS_BASE_COLOR,
GRASS_COLORS,
GRASS_CONFIG,
} from "@/world/grass/grassConfig";
} from "@/data/world/grassConfig";
import {
grassFragmentShader,
grassVertexShader,
+1 -1
View File
@@ -3,7 +3,7 @@ import {
useDynamicGrass,
useGrassDensity,
} from "@/hooks/world/useGraphicsSettings";
import { GRASS_CONFIG } from "@/world/grass/grassConfig";
import { GRASS_CONFIG } from "@/data/world/grassConfig";
import { GrassPatch } from "@/world/grass/GrassPatch";
import { useTerrainGrassSampler } from "@/world/grass/useTerrainGrassSampler";
+1 -1
View File
@@ -6,7 +6,7 @@ import type { TerrainSurfaceBounds } from "@/types/world/terrainSurface";
import type { Vector3Tuple } from "@/types/three/three";
import { logger } from "@/utils/core/Logger";
import { getMapNodesByName } from "@/utils/map/loadMapSceneData";
import { GRASS_CONFIG } from "@/world/grass/grassConfig";
import { GRASS_CONFIG } from "@/data/world/grassConfig";
const RAYCAST_Y = 500;
const RAYCAST_FAR = 1000;
+12 -5
View File
@@ -27,6 +27,8 @@ interface MeshMergeGroup {
material: THREE.Material | THREE.Material[];
}
const meshDataCache = new Map<string, MeshData[]>();
function cloneMaterial(
material: THREE.Material | THREE.Material[],
): THREE.Material | THREE.Material[] {
@@ -49,8 +51,6 @@ function disposeMaterialOnly(
}
function disposeInstancedMapMesh(mesh: THREE.InstancedMesh): void {
mesh.geometry.dispose();
disposeMaterialOnly(mesh.material);
mesh.dispose();
}
@@ -183,6 +183,15 @@ export function InstancedMapAsset({
state.gl.capabilities.getMaxAnisotropy(),
);
const groupRef = useRef<THREE.Group>(null);
const meshDataList = useMemo(() => {
const cached = meshDataCache.get(modelPath);
if (cached) return cached;
optimizeGLTFSceneTextures(scene, maxAnisotropy);
const extracted = extractMeshes(scene);
meshDataCache.set(modelPath, extracted);
return extracted;
}, [maxAnisotropy, modelPath, scene]);
const groundedInstances = useMemo(
() =>
instances.map((instance) => {
@@ -202,8 +211,6 @@ export function InstancedMapAsset({
const group = groupRef.current;
if (!group || groundedInstances.length === 0) return;
optimizeGLTFSceneTextures(scene, maxAnisotropy);
const meshDataList = extractMeshes(scene);
const geometryBottomY = getMeshBottomY(meshDataList);
const instancedMeshes = meshDataList.map((meshData, index) => {
const instancedMesh = new THREE.InstancedMesh(
@@ -232,7 +239,7 @@ export function InstancedMapAsset({
disposeInstancedMapMesh(mesh);
}
};
}, [castShadow, groundedInstances, maxAnisotropy, receiveShadow, scene]);
}, [castShadow, groundedInstances, meshDataList, receiveShadow]);
if (instances.length === 0) {
return null;
@@ -1,87 +0,0 @@
export const MAP_INSTANCING_ASSETS = {
boiteauxlettres: {
mapName: "boiteauxlettres",
modelPath: "/models/boiteauxlettres/model.gltf",
castShadow: true,
receiveShadow: true,
enabled: true,
},
pylone: {
mapName: "pylone",
modelPath: "/models/pylone/model.gltf",
castShadow: true,
receiveShadow: true,
enabled: true,
},
immeuble1: {
mapName: "immeuble1",
modelPath: "/models/immeuble1/model.gltf",
castShadow: true,
receiveShadow: true,
enabled: true,
},
maison1: {
mapName: "maison1",
modelPath: "/models/maison1/model.gltf",
castShadow: true,
receiveShadow: true,
enabled: true,
},
eolienne: {
mapName: "eolienne",
modelPath: "/models/eolienne/model.gltf",
castShadow: true,
receiveShadow: true,
enabled: true,
},
parcebike: {
mapName: "parcebike",
modelPath: "/models/parcebike/model.gltf",
castShadow: true,
receiveShadow: true,
enabled: true,
},
panneauaffichage: {
mapName: "panneauaffichage",
modelPath: "/models/panneauaffichage/model.gltf",
castShadow: true,
receiveShadow: true,
enabled: true,
},
panneauclassique: {
mapName: "panneauclassique",
modelPath: "/models/panneauclassique/model.gltf",
castShadow: true,
receiveShadow: true,
enabled: true,
},
panneaufleche: {
mapName: "panneaufleche",
modelPath: "/models/panneaufleche/model.gltf",
castShadow: true,
receiveShadow: true,
enabled: true,
},
panneausolaire: {
mapName: "panneausolaire",
modelPath: "/models/panneausolaire/model.gltf",
castShadow: true,
receiveShadow: true,
enabled: true,
},
} as const;
export type MapInstancingAssetType = keyof typeof MAP_INSTANCING_ASSETS;
export type MapInstancingAssetConfig =
(typeof MAP_INSTANCING_ASSETS)[MapInstancingAssetType];
export const MAP_INSTANCED_NODE_NAMES: ReadonlySet<string> = new Set(
Object.values(MAP_INSTANCING_ASSETS)
.filter((config) => config.enabled)
.map((config) => config.mapName),
);
export function isInstancedMapNodeName(name: string): boolean {
return MAP_INSTANCED_NODE_NAMES.has(name);
}
@@ -1,84 +0,0 @@
import { useEffect, useState } from "react";
import type { MapNode } from "@/types/editor/editor";
import type { Vector3Tuple } from "@/types/three/three";
import { getMapNodes, loadMapSceneData } from "@/utils/map/loadMapSceneData";
import {
MAP_INSTANCING_ASSETS,
type MapInstancingAssetType,
} from "@/world/map-instancing/mapInstancingConfig";
export interface MapAssetInstance {
position: Vector3Tuple;
rotation: Vector3Tuple;
scale: Vector3Tuple;
}
export type MapInstancingData = Map<MapInstancingAssetType, MapAssetInstance[]>;
function mapNodeToInstance(node: MapNode): MapAssetInstance {
return {
position: node.position,
rotation: node.rotation,
scale: node.scale,
};
}
function extractMapInstancingData(mapNodes: MapNode[]): MapInstancingData {
const data: MapInstancingData = new Map();
for (const [type, config] of Object.entries(MAP_INSTANCING_ASSETS)) {
if (!config.enabled) continue;
const instances = mapNodes
.filter(
(node) => node.name === config.mapName && node.type === "Object3D",
)
.map(mapNodeToInstance);
if (instances.length > 0) {
data.set(type as MapInstancingAssetType, instances);
}
}
return data;
}
export function useMapInstancingData(): {
data: MapInstancingData | null;
isLoading: boolean;
} {
const [data, setData] = useState<MapInstancingData | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let cancelled = false;
async function load() {
const cachedNodes = getMapNodes();
if (cachedNodes) {
if (!cancelled) {
setData(extractMapInstancingData(cachedNodes));
setIsLoading(false);
}
return;
}
await loadMapSceneData();
const nodes = getMapNodes();
if (!cancelled && nodes) {
setData(extractMapInstancingData(nodes));
setIsLoading(false);
}
}
load();
return () => {
cancelled = true;
};
}, []);
return { data, isLoading };
}
+2 -2
View File
@@ -5,9 +5,9 @@ import {
PATH_DEBUG_PREVIEW_ENABLED,
PATH_TILE_RENDER_ENABLED,
PATH_TILE_MODEL_PATH,
} from "@/world/paths/pathConfig";
} from "@/data/world/pathConfig";
import { usePathTileData } from "@/world/paths/usePathTileData";
import type { MapAssetInstance } from "@/world/map-instancing/useMapInstancingData";
import type { MapAssetInstance } from "@/hooks/world/useMapInstancingData";
export function PathSystem(): React.JSX.Element | null {
if (!PATH_DEBUG_PREVIEW_ENABLED && !PATH_TILE_RENDER_ENABLED) {
+2 -2
View File
@@ -4,14 +4,14 @@ import { useTerrainHeightSampler } from "@/hooks/three/useTerrainHeight";
import { useTerrainSurfaceData } from "@/hooks/world/useTerrainSurfaceData";
import type { Vector3Tuple } from "@/types/three/three";
import { sampleTerrainSurfaceAtXZ } from "@/utils/world/terrainSurfaceSampler";
import type { MapAssetInstance } from "@/world/map-instancing/useMapInstancingData";
import type { MapAssetInstance } from "@/hooks/world/useMapInstancingData";
import {
PATH_TILE_MAX_COUNT,
PATH_SURFACE_KEY,
PATH_TILE_ROTATION,
PATH_TILE_SAMPLE_STEP,
PATH_TILE_SCALE,
} from "@/world/paths/pathConfig";
} from "@/data/world/pathConfig";
function createSampleCenters(min: number, max: number, step: number): number[] {
const start = Math.ceil(min / step) * step + step * 0.5;
+9 -14
View File
@@ -36,6 +36,8 @@ interface VegetationWindUniforms {
noiseScale: { value: number };
}
const meshDataCache = new Map<string, MeshData[]>();
function updateVegetationWindUniforms(
uniforms: VegetationWindUniforms,
elapsedTime: number,
@@ -242,9 +244,14 @@ export function InstancedVegetation({
const windUniformsRef = useRef<VegetationWindUniforms[]>([]);
const meshDataList = useMemo(() => {
const cached = meshDataCache.get(modelPath);
if (cached) return cached;
optimizeGLTFSceneTextures(scene, maxAnisotropy);
return extractMeshes(scene);
}, [maxAnisotropy, scene]);
const extracted = extractMeshes(scene);
meshDataCache.set(modelPath, extracted);
return extracted;
}, [maxAnisotropy, modelPath, scene]);
const groundedInstances = useMemo(
() =>
instances.map((instance) => {
@@ -325,20 +332,8 @@ export function InstancedVegetation({
(uniforms): uniforms is VegetationWindUniforms =>
uniforms !== undefined,
);
return () => {
windUniformsRef.current = [];
for (const meshData of meshDataList) {
meshData.geometry.dispose();
if (Array.isArray(meshData.material)) {
for (const mat of meshData.material) {
mat.dispose();
}
} else {
meshData.material.dispose();
}
}
};
}, [meshDataList]);
+1 -1
View File
@@ -16,7 +16,7 @@ import {
VEGETATION_TYPE_KEYS,
VEGETATION_TYPES,
type VegetationType,
} from "@/world/vegetation/vegetationConfig";
} from "@/data/world/vegetationConfig";
interface VegetationChunk {
key: string;
-83
View File
@@ -1,83 +0,0 @@
import { useEffect, useState } from "react";
import type { MapNode } from "@/types/editor/editor";
import type { Vector3Tuple } from "@/types/three/three";
import { loadMapSceneData } from "@/utils/map/loadMapSceneData";
import { INSTANCED_MAP_EXCEPTIONS } from "@/world/vegetation/vegetationConfig";
export interface VegetationInstance {
position: Vector3Tuple;
rotation: Vector3Tuple;
scale: Vector3Tuple;
}
export interface InstancedMapEntry {
modelPath: string;
instances: VegetationInstance[];
}
export type VegetationData = Map<string, InstancedMapEntry>;
function mapNodeToInstance(node: MapNode): VegetationInstance {
return {
position: node.position,
rotation: node.rotation,
scale: node.scale,
};
}
function extractVegetationData(
mapNodes: MapNode[],
models: Map<string, string>,
): VegetationData {
const data: VegetationData = new Map();
for (const node of mapNodes) {
if (node.type !== "Object3D") continue;
if (INSTANCED_MAP_EXCEPTIONS.has(node.name)) continue;
const modelPath = models.get(node.name);
if (!modelPath) continue;
const entry = data.get(node.name);
if (entry) {
entry.instances.push(mapNodeToInstance(node));
} else {
data.set(node.name, {
modelPath,
instances: [mapNodeToInstance(node)],
});
}
}
return data;
}
export function useVegetationData(): {
data: VegetationData | null;
isLoading: boolean;
} {
const [data, setData] = useState<VegetationData | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let cancelled = false;
async function load() {
const sceneData = await loadMapSceneData();
if (!cancelled && sceneData) {
setData(extractVegetationData(sceneData.mapNodes, sceneData.models));
setIsLoading(false);
}
}
load();
return () => {
cancelled = true;
};
}, []);
return { data, isLoading };
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { useMemo, useRef } from "react";
import * as THREE from "three";
import { useFrame, useThree } from "@react-three/fiber";
import { FOG_CONFIG } from "@/data/world/fogConfig";
import { getWindVector } from "@/data/world/windConfig";
import { getWindVector } from "@/utils/world/windVector";
import { WATER_SHADER_CONFIG } from "@/data/world/waterConfig";
import type { WaterSurfaceConfig } from "@/data/world/waterConfig";
import { useWind } from "@/hooks/world/useWind";