refactor: clean map gameplay architecture
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}[];
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
};
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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([
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
GRASS_BASE_COLOR,
|
||||
GRASS_COLORS,
|
||||
GRASS_CONFIG,
|
||||
} from "@/world/grass/grassConfig";
|
||||
} from "@/data/world/grassConfig";
|
||||
import {
|
||||
grassFragmentShader,
|
||||
grassVertexShader,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
VEGETATION_TYPE_KEYS,
|
||||
VEGETATION_TYPES,
|
||||
type VegetationType,
|
||||
} from "@/world/vegetation/vegetationConfig";
|
||||
} from "@/data/world/vegetationConfig";
|
||||
|
||||
interface VegetationChunk {
|
||||
key: string;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user