diff --git a/docs/technical/mission-flow.md b/docs/technical/mission-flow.md index 4c2f766..0c0a95b 100644 --- a/docs/technical/mission-flow.md +++ b/docs/technical/mission-flow.md @@ -37,7 +37,7 @@ Mission progression is not owned by a manager. Components update the store throu - `src/components/game/GameFlow.tsx` reacts to `missionFlow.step` and triggers one-off side effects such as intro audio and movement unlocks. - `src/components/zone/ZoneDetection.tsx` reads the camera position and moves the flow to a target step when the player enters a configured zone. -- `src/components/three/interaction/CentralObject.tsx` and `VillageoisHelperObject.tsx` expose temporary interactive mission objects. +- `src/world/GameStageContent.tsx` mounts repair games and their mission-start triggers. - `src/pages/page.tsx` mounts mission HTML overlays: `IntroUI`, `BienvenueDisplay`, and `DialogMessage`. - `src/world/player/PlayerController.tsx` reads `missionFlow.canMove` as an additional movement lock. diff --git a/docs/technical/repair-game.md b/docs/technical/repair-game.md index e84a5cf..04afb88 100644 --- a/docs/technical/repair-game.md +++ b/docs/technical/repair-game.md @@ -159,7 +159,7 @@ The repair case appears near the mission object. The player can: Both paths move to `fragmented`. -Important current detail: `useRepairMovementLocked()` currently returns `false`, so the movement-lock rule and indicator are present but disabled in the current branch. +`useRepairMovementLocked()` locks player movement during focused repair steps and drives the repair movement indicator. ### Fragmented diff --git a/src/data/gameplay/repairMissionAnchors.ts b/src/data/gameplay/repairMissionAnchors.ts index 65520e9..768c9f6 100644 --- a/src/data/gameplay/repairMissionAnchors.ts +++ b/src/data/gameplay/repairMissionAnchors.ts @@ -1,11 +1,8 @@ import type { Vector3Tuple } from "@/types/three/three"; -import type { RepairMissionId } from "@/types/gameplay/repairMission"; - -export interface RepairMissionTriggerConfig { - mission: RepairMissionId; - label: string; - radius: number; -} +import type { + RepairMissionId, + RepairMissionTriggerConfig, +} from "@/types/gameplay/repairMission"; export const EBIKE_REPAIR_POSITION = [ 42.2399, 4.5484, 34.6468, diff --git a/src/data/gameplay/repairMissions.ts b/src/data/gameplay/repairMissions.ts index 6fe8e5e..8fa0ceb 100644 --- a/src/data/gameplay/repairMissions.ts +++ b/src/data/gameplay/repairMissions.ts @@ -20,7 +20,7 @@ export const REPAIR_MISSIONS: Record = { description: "Repair the damaged cooling module before relaunching the bike", modelPath: "/models/ebike/model.gltf", - modelScale: 0.5, + modelScale: 0.3, stageUiPath: "/assets/UI/ebike.webm", interactUiPath: REPAIR_INTERACT_UI_PATH, brokenUiPath: REPAIR_BROKEN_UI_PATH, diff --git a/src/data/world/mapInstancingConfig.ts b/src/data/world/mapInstancingConfig.ts index e08a919..c446207 100644 --- a/src/data/world/mapInstancingConfig.ts +++ b/src/data/world/mapInstancingConfig.ts @@ -2,6 +2,7 @@ export const MAP_INSTANCING_ASSETS = { boiteauxlettres: { mapName: "boiteauxlettres", modelPath: "/models/boiteauxlettres/model.gltf", + scaleMultiplier: 2, castShadow: true, receiveShadow: true, enabled: true, @@ -9,6 +10,7 @@ export const MAP_INSTANCING_ASSETS = { pylone: { mapName: "pylone", modelPath: "/models/pylone/model.gltf", + scaleMultiplier: 1, castShadow: true, receiveShadow: true, enabled: true, @@ -16,6 +18,7 @@ export const MAP_INSTANCING_ASSETS = { immeuble1: { mapName: "immeuble1", modelPath: "/models/immeuble1/model.gltf", + scaleMultiplier: 1, castShadow: true, receiveShadow: true, enabled: true, @@ -23,6 +26,7 @@ export const MAP_INSTANCING_ASSETS = { maison1: { mapName: "maison1", modelPath: "/models/maison1/model.gltf", + scaleMultiplier: 3, castShadow: true, receiveShadow: true, enabled: true, @@ -30,6 +34,7 @@ export const MAP_INSTANCING_ASSETS = { eolienne: { mapName: "eolienne", modelPath: "/models/eolienne/model.gltf", + scaleMultiplier: 0.85, castShadow: true, receiveShadow: true, enabled: true, @@ -37,6 +42,7 @@ export const MAP_INSTANCING_ASSETS = { parcebike: { mapName: "parcebike", modelPath: "/models/parcebike/model.gltf", + scaleMultiplier: 2, castShadow: true, receiveShadow: true, enabled: true, @@ -44,6 +50,7 @@ export const MAP_INSTANCING_ASSETS = { panneauaffichage: { mapName: "panneauaffichage", modelPath: "/models/panneauaffichage/model.gltf", + scaleMultiplier: 1, castShadow: true, receiveShadow: true, enabled: true, @@ -51,6 +58,7 @@ export const MAP_INSTANCING_ASSETS = { panneauclassique: { mapName: "panneauclassique", modelPath: "/models/panneauclassique/model.gltf", + scaleMultiplier: 1, castShadow: true, receiveShadow: true, enabled: true, @@ -58,6 +66,7 @@ export const MAP_INSTANCING_ASSETS = { panneaufleche: { mapName: "panneaufleche", modelPath: "/models/panneaufleche/model.gltf", + scaleMultiplier: 1, castShadow: true, receiveShadow: true, enabled: true, @@ -65,12 +74,40 @@ export const MAP_INSTANCING_ASSETS = { panneausolaire: { mapName: "panneausolaire", modelPath: "/models/panneausolaire/model.gltf", + scaleMultiplier: 0.85, castShadow: true, receiveShadow: true, enabled: true, }, } as const; +export const MAP_SINGLE_MODEL_SCALE_MULTIPLIERS = { + ebike: 0.3, +} as const satisfies Record; + +export function getMapSingleModelScaleMultiplier(name: string): number { + return ( + MAP_SINGLE_MODEL_SCALE_MULTIPLIERS[ + name as keyof typeof MAP_SINGLE_MODEL_SCALE_MULTIPLIERS + ] ?? 1 + ); +} + +export function getMapInstancedModelScaleMultiplier(name: string): number { + return ( + Object.values(MAP_INSTANCING_ASSETS).find( + (config) => config.mapName === name, + )?.scaleMultiplier ?? 1 + ); +} + +export function getMapModelScaleMultiplier(name: string): number { + return ( + getMapSingleModelScaleMultiplier(name) * + getMapInstancedModelScaleMultiplier(name) + ); +} + export const MAP_INSTANCING_ASSET_TYPES = [ "boiteauxlettres", "pylone", diff --git a/src/managers/stores/useRepairMissionAnchorStore.ts b/src/managers/stores/useRepairMissionAnchorStore.ts new file mode 100644 index 0000000..a9fb8c7 --- /dev/null +++ b/src/managers/stores/useRepairMissionAnchorStore.ts @@ -0,0 +1,15 @@ +import { create } from "zustand"; +import type { RepairMissionId } from "@/types/gameplay/repairMission"; +import type { Vector3Tuple } from "@/types/three/three"; + +interface RepairMissionAnchorStore { + anchors: Partial>; + setAnchors: (anchors: Partial>) => void; +} + +export const useRepairMissionAnchorStore = create( + (set) => ({ + anchors: {}, + setAnchors: (anchors) => set({ anchors }), + }), +); diff --git a/src/types/gameplay/repairMission.ts b/src/types/gameplay/repairMission.ts index 72ce052..bef3cbe 100644 --- a/src/types/gameplay/repairMission.ts +++ b/src/types/gameplay/repairMission.ts @@ -6,6 +6,12 @@ import type { export type RepairMissionId = "ebike" | "pylon" | "farm"; +export interface RepairMissionTriggerConfig { + mission: RepairMissionId; + label: string; + radius: number; +} + export interface RepairMissionCaseConfig { position: Vector3Tuple; rotation: Vector3Tuple; diff --git a/src/utils/map/repairMissionMapAnchors.ts b/src/utils/map/repairMissionMapAnchors.ts new file mode 100644 index 0000000..b16ae38 --- /dev/null +++ b/src/utils/map/repairMissionMapAnchors.ts @@ -0,0 +1,36 @@ +import type { RepairMissionId } from "@/types/gameplay/repairMission"; +import type { MapNode } from "@/types/map/mapScene"; +import type { Vector3Tuple } from "@/types/three/three"; + +const REPAIR_MISSION_MAP_NODE_NAMES = { + ebike: "ebike", + pylon: "pylone", + farm: "fermeverticale", +} as const satisfies Record; + +function isOriginPosition(position: Vector3Tuple): boolean { + return position.every((value) => Math.abs(value) < 0.0001); +} + +export function getRepairMissionMapAnchors( + mapNodes: readonly MapNode[], +): Partial> { + const anchors: Partial> = {}; + + for (const [mission, mapName] of Object.entries( + REPAIR_MISSION_MAP_NODE_NAMES, + ) as [RepairMissionId, string][]) { + const node = mapNodes.find( + (candidate) => + candidate.name === mapName && + candidate.type === "Object3D" && + !isOriginPosition(candidate.position), + ); + + if (node) { + anchors[mission] = node.position; + } + } + + return anchors; +} diff --git a/src/world/GameMap.tsx b/src/world/GameMap.tsx index 542e239..08a78d9 100644 --- a/src/world/GameMap.tsx +++ b/src/world/GameMap.tsx @@ -22,9 +22,11 @@ import { useMapPerformanceStore, } from "@/managers/stores/useMapPerformanceStore"; import { useGameStore } from "@/managers/stores/useGameStore"; +import { useRepairMissionAnchorStore } from "@/managers/stores/useRepairMissionAnchorStore"; import { GameMapCollision } from "@/world/GameMapCollision"; import { GeneratedMapNodeInstance } from "@/world/map-generated/GeneratedMapNodeInstance"; import { isGeneratedMapModelName } from "@/data/world/generatedMapModelConfig"; +import { getMapSingleModelScaleMultiplier } from "@/data/world/mapInstancingConfig"; import { MapInstancingSystem } from "@/world/map-instancing/MapInstancingSystem"; import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading"; import { logger } from "@/utils/core/Logger"; @@ -35,6 +37,7 @@ import { isRuntimeSingleMapNode, } from "@/utils/map/mapRuntimeClassification"; import { logModelLoadError } from "@/utils/three/modelLoadLogger"; +import { getRepairMissionMapAnchors } from "@/utils/map/repairMissionMapAnchors"; import type { MapNode } from "@/types/map/mapScene"; import type { OctreeReadyHandler } from "@/types/three/three"; @@ -114,6 +117,9 @@ export function GameMap({ const [terrainNode, setTerrainNode] = useState(null); const [mapLoaded, setMapLoaded] = useState(false); const [settledMapNodeCount, setSettledMapNodeCount] = useState(0); + const setRepairMissionAnchors = useRepairMissionAnchorStore( + (state) => state.setAnchors, + ); const mapReady = mapLoaded; const handleMapNodeSettled = useCallback((index: number) => { @@ -185,6 +191,9 @@ export function GameMap({ return { node, modelUrl: modelUrl ?? null }; }); const loadedTerrainNode = getTerrainMapNode(sceneData.mapNodes); + const repairMissionAnchors = getRepairMissionMapAnchors( + sceneData.mapNodes, + ); const missingModelCount = loadedMapNodes.filter( (mapNode) => mapNode.modelUrl === null, ).length; @@ -202,6 +211,7 @@ export function GameMap({ setRenderMapNodes(loadedMapNodes); setCollisionMapNodes(loadedCollisionNodes); setTerrainNode(loadedTerrainNode); + setRepairMissionAnchors(repairMissionAnchors); setMapLoaded(true); settledMapNodesRef.current.clear(); setSettledMapNodeCount(0); @@ -219,7 +229,7 @@ export function GameMap({ }; loadMap(); - }, [onLoadingStateChange, showEmptyMap]); + }, [onLoadingStateChange, setRepairMissionAnchors, showEmptyMap]); useEffect(() => { if (renderMapNodes.length === 0) return; @@ -350,7 +360,17 @@ function ModelInstance({ onLoaded: () => void; }): React.JSX.Element { const { position, rotation, scale } = node; - const normalizedScale = normalizeMapScale(scale); + const scaleMultiplier = getMapSingleModelScaleMultiplier(node.name); + const baseScale = normalizeMapScale(scale); + const normalizedScale = useMemo( + () => + [ + baseScale[0] * scaleMultiplier, + baseScale[1] * scaleMultiplier, + baseScale[2] * scaleMultiplier, + ] satisfies [number, number, number], + [baseScale, scaleMultiplier], + ); const terrainHeight = useTerrainHeightSampler(); const { scene } = useLoggedGLTF(modelUrl, { scope: "GameMap.ModelInstance", diff --git a/src/world/GameStageContent.tsx b/src/world/GameStageContent.tsx index 4409126..fa248e2 100644 --- a/src/world/GameStageContent.tsx +++ b/src/world/GameStageContent.tsx @@ -3,11 +3,27 @@ import { RepairGame } from "@/components/three/gameplay/RepairGame"; import { REPAIR_MISSION_POSITION_ENTRIES, REPAIR_MISSION_TRIGGERS, - type RepairMissionTriggerConfig, } from "@/data/gameplay/repairMissionAnchors"; import { useGameStore } from "@/managers/stores/useGameStore"; +import { useRepairMissionAnchorStore } from "@/managers/stores/useRepairMissionAnchorStore"; +import type { RepairMissionId } from "@/types/gameplay/repairMission"; +import type { RepairMissionTriggerConfig } from "@/types/gameplay/repairMission"; import type { Vector3Tuple } from "@/types/three/three"; +const FALLBACK_REPAIR_MISSION_POSITIONS = new Map( + REPAIR_MISSION_POSITION_ENTRIES.map(({ mission, position }) => [ + mission, + position, + ]), +); + +function getRepairMissionPosition( + mission: RepairMissionId, + anchors: Partial>, +): Vector3Tuple | undefined { + return anchors[mission] ?? FALLBACK_REPAIR_MISSION_POSITIONS.get(mission); +} + interface StageAnchorProps { color: string; position: Vector3Tuple; @@ -42,10 +58,9 @@ function RepairMissionTrigger({ const missionStep = useGameStore( (state) => state[config.mission].currentStep, ); + const anchors = useRepairMissionAnchorStore((state) => state.anchors); const setMissionStep = useGameStore((state) => state.setMissionStep); - const position = REPAIR_MISSION_POSITION_ENTRIES.find( - (entry) => entry.mission === config.mission, - )?.position; + const position = getRepairMissionPosition(config.mission, anchors); if (!position) return null; if (mainState !== config.mission || missionStep !== "locked") return null; @@ -70,15 +85,20 @@ function RepairMissionTrigger({ export function GameStageContent(): React.JSX.Element { const mainState = useGameStore((state) => state.mainState); + const anchors = useRepairMissionAnchorStore((state) => state.anchors); return ( <> {mainState === "intro" ? ( ) : null} - {REPAIR_MISSION_POSITION_ENTRIES.map(({ mission, position }) => ( - - ))} + {REPAIR_MISSION_POSITION_ENTRIES.map(({ mission }) => { + const position = getRepairMissionPosition(mission, anchors); + if (!position) return null; + return ( + + ); + })} {REPAIR_MISSION_TRIGGERS.map((config) => ( ))}