import { Suspense, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { useFrame, useThree } from "@react-three/fiber"; import { CHUNK_CONFIG } from "@/data/world/chunkStreamingConfig"; import { selectMapModelPathByDistance } from "@/data/world/mapLodConfig"; import { useCameraMode } from "@/hooks/debug/useCameraMode"; import { useSceneMode } from "@/hooks/debug/useSceneMode"; import { useGraphicsPreset, useGraphicsPresetConfig, } from "@/hooks/world/useGraphicsSettings"; import { useVisibleWorldChunks } from "@/hooks/world/useVisibleWorldChunks"; import { isMapModelVisible, useMapPerformanceStore, } from "@/managers/stores/useMapPerformanceStore"; import { useGameStore } from "@/managers/stores/useGameStore"; import { InstancedMapAsset } from "@/world/map-instancing/InstancedMapAsset"; import { MAP_INSTANCING_ASSETS, MAP_INSTANCING_ASSET_TYPES, type MapInstancingAssetConfig, type MapInstancingAssetType, } from "@/data/world/mapInstancingConfig"; import { REPAIR_MISSION_ANCHOR_IDS } from "@/data/gameplay/repairMissionAnchors"; import { isRepairGameStep } from "@/types/gameplay/repairMission"; import { useMapInstancingData } from "@/hooks/world/useMapInstancingData"; import type { MapAssetInstance } from "@/types/map/mapScene"; import type { GraphicsPreset } from "@/data/world/graphicsConfig"; import { createWorldInstanceChunks } from "@/utils/world/chunkInstances"; interface MapInstancingSystemProps { onlyMapName?: string | null; streaming?: boolean; } interface MapAssetChunk { key: string; config: MapInstancingAssetConfig; centerX: number; centerZ: number; instances: MapAssetInstance[]; } function createMapAssetChunks( type: MapInstancingAssetType, config: MapInstancingAssetConfig, instances: MapAssetInstance[], ): MapAssetChunk[] { return createWorldInstanceChunks(instances).map((chunk) => { return { key: `${type}:${chunk.chunkKey}`, config, centerX: chunk.centerX, centerZ: chunk.centerZ, instances: chunk.instances, }; }); } function areChunkModelPathsEqual( a: ReadonlyMap, b: ReadonlyMap, ): boolean { return ( a.size === b.size && [...a].every(([key, value]) => b.get(key) === value) ); } function getNearestChunkInstanceDistance( chunk: MapAssetChunk, cameraX: number, cameraZ: number, ): number { return chunk.instances.reduce((nearestDistance, instance) => { const distance = Math.hypot( instance.position[0] - cameraX, instance.position[2] - cameraZ, ); return Math.min(nearestDistance, distance); }, Number.POSITIVE_INFINITY); } function useChunkModelPaths( chunks: readonly MapAssetChunk[], preset: GraphicsPreset, ): ReadonlyMap { const camera = useThree((state) => state.camera); const lastUpdateRef = useRef(-CHUNK_CONFIG.updateInterval); const modelPathsRef = useRef>(new Map()); const [modelPaths, setModelPaths] = useState>( () => new Map(), ); const updateModelPaths = useCallback(() => { const cameraX = camera.position.x; const cameraZ = camera.position.z; const nextModelPaths = new Map(); for (const chunk of chunks) { const distance = getNearestChunkInstanceDistance(chunk, cameraX, cameraZ); const modelPath = selectMapModelPathByDistance({ distance, modelName: chunk.config.mapName, modelPath: chunk.config.modelPath, preset, }); nextModelPaths.set(chunk.key, modelPath); } if (areChunkModelPathsEqual(nextModelPaths, modelPathsRef.current)) return; modelPathsRef.current = nextModelPaths; setModelPaths(nextModelPaths); }, [camera, chunks, preset]); useEffect(() => { updateModelPaths(); }, [updateModelPaths]); useFrame(({ clock }) => { const now = clock.elapsedTime * 1000; if (now - lastUpdateRef.current < CHUNK_CONFIG.updateInterval) return; lastUpdateRef.current = now; updateModelPaths(); }); return modelPaths; } export function MapInstancingSystem({ onlyMapName = null, streaming = true, }: MapInstancingSystemProps): React.JSX.Element | null { const camera = useThree((state) => state.camera); const cameraMode = useCameraMode(); const sceneMode = useSceneMode(); const graphicsPreset = useGraphicsPreset(); const graphicsPresetConfig = useGraphicsPresetConfig(); const groups = useMapPerformanceStore((state) => state.groups); const models = useMapPerformanceStore((state) => state.models); const { data, isLoading } = useMapInstancingData(); const mainState = useGameStore((state) => state.mainState); const pylonStep = useGameStore((state) => state.pylon.currentStep); const streamingEnabled = streaming && CHUNK_CONFIG.enabled && graphicsPresetConfig.chunkStreamingEnabled && sceneMode === "game" && cameraMode === "player"; // During the pylon narrative phase (before the pylon is raised), hide the // repair:pylon instanced mesh so the PylonDownedPylon component takes its place. // Once the pylon is raised (repair-game steps), restore it so the normal model // appears upright in the world while the repair mini-game runs. const hidePylonAnchorId = mainState === "pylon" && !isRepairGameStep(pylonStep) ? REPAIR_MISSION_ANCHOR_IDS.pylon : undefined; const chunks = useMemo(() => { if (!data) return []; return MAP_INSTANCING_ASSET_TYPES.flatMap((type) => { const config = MAP_INSTANCING_ASSETS[type]; if (onlyMapName && config.mapName !== onlyMapName) return []; if ( !config.enabled || !isMapModelVisible(config.mapName, { groups, models }) ) { return []; } let instances = data.get(type); if (!instances || instances.length === 0) return []; // Filter out the repair-mission pylon instance during the narrative phase if (hidePylonAnchorId && config.mapName === "pylone") { instances = instances.filter((inst) => inst.id !== hidePylonAnchorId); if (instances.length === 0) return []; } return createMapAssetChunks(type, config, instances); }); }, [data, groups, models, onlyMapName, hidePylonAnchorId]); const visibleChunks = useVisibleWorldChunks(chunks, streamingEnabled, { loadRadius: graphicsPresetConfig.chunkLoadRadius, unloadRadius: graphicsPresetConfig.chunkUnloadRadius, }); const chunkModelPaths = useChunkModelPaths(visibleChunks, graphicsPreset); const getChunkModelPath = useCallback( (chunk: MapAssetChunk): string => { const cachedModelPath = chunkModelPaths.get(chunk.key); if (cachedModelPath) return cachedModelPath; return selectMapModelPathByDistance({ distance: getNearestChunkInstanceDistance( chunk, camera.position.x, camera.position.z, ), modelName: chunk.config.mapName, modelPath: chunk.config.modelPath, preset: graphicsPreset, }); }, [camera, chunkModelPaths, graphicsPreset], ); if (isLoading || !data) { return null; } return ( {visibleChunks.map((chunk) => { const modelPath = getChunkModelPath(chunk); return ( ); })} ); }