diff --git a/public/map.json b/public/map.json index 1b3b11a..fc03525 100644 --- a/public/map.json +++ b/public/map.json @@ -39340,8 +39340,7 @@ "rotation": [0, 0.0027, 0.0819], "scale": [1, 1, 1] } - ], - "id": "repair:pylon" + ] }, { "name": "pylone", @@ -39373,7 +39372,8 @@ "rotation": [0, 0.0027, 0.0819], "scale": [1, 1, 1] } - ] + ], + "id": "repair:pylon" }, { "name": "pylone", diff --git a/src/components/gameplay/pylon/PylonDownedPylon.tsx b/src/components/gameplay/pylon/PylonDownedPylon.tsx index d863314..6111693 100644 --- a/src/components/gameplay/pylon/PylonDownedPylon.tsx +++ b/src/components/gameplay/pylon/PylonDownedPylon.tsx @@ -4,6 +4,8 @@ import { useGLTF } from "@react-three/drei"; import * as THREE from "three"; import { InteractableObject } from "@/components/three/interaction/InteractableObject"; import { useGameStore } from "@/managers/stores/useGameStore"; +import { useRepairMissionAnchorStore } from "@/managers/stores/useRepairMissionAnchorStore"; +import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight"; import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest"; import { playDialogueById } from "@/utils/dialogues/playDialogue"; import { @@ -14,6 +16,7 @@ import { PYLON_UPRIGHT_ROTATION, PYLON_WORLD_POSITION, } from "@/data/gameplay/pylonConfig"; +import { isRepairGameStep } from "@/types/gameplay/repairMission"; import { pylonStraighteningSignal } from "@/components/gameplay/pylon/pylonSignals"; const PYLON_MODEL_PATH = "/models/pylone/model.glb"; @@ -22,6 +25,15 @@ export function PylonDownedPylon(): React.JSX.Element | null { const mainState = useGameStore((state) => state.mainState); const step = useGameStore((state) => state.pylon.currentStep); const setCanMove = useGameStore((state) => state.setCanMove); + // Use the repair:pylon anchor from the store so the downed pylon is always + // co-located with the instanced mesh it replaces. Falls back to the + // hard-coded constant while the map is loading or unavailable. + const pylonAnchor = useRepairMissionAnchorStore( + (state) => state.anchors.pylon, + ); + // Snap to terrain so the downed/upright model sits flush on the ground, + // matching the Y adjustment that InstancedMapAsset applies to the same node. + const position = useTerrainSnappedPosition(pylonAnchor ?? PYLON_WORLD_POSITION); const [isStraightening, setIsStraightening] = useState(false); // Keeps the pylon upright after the animation completes while // PylonFarmerNPC plays the post-raise audio sequence. @@ -30,6 +42,10 @@ export function PylonDownedPylon(): React.JSX.Element | null { const straightenStartRef = useRef(null); const hasPlayedFirstAudioRef = useRef(false); + // Hidden outside the pylon mission and once the pylon has been raised + // (repair-game steps take over from there). + const shouldRender = mainState === "pylon" && !isRepairGameStep(step); + useEffect(() => { if (step === "arrived") { hasPlayedFirstAudioRef.current = false; @@ -44,7 +60,7 @@ export function PylonDownedPylon(): React.JSX.Element | null { if (!group) return; if (!isStraightening || straightenStartRef.current === null) { - group.rotation.set(...(showUpright ? PYLON_UPRIGHT_ROTATION : PYLON_DOWNED_ROTATION)); + group.rotation.set(...(isRaised ? PYLON_UPRIGHT_ROTATION : PYLON_DOWNED_ROTATION)); return; } @@ -60,18 +76,6 @@ export function PylonDownedPylon(): React.JSX.Element | null { ); }); - const showUpright = - isRaised || - mainState !== "pylon" || - step === "waiting" || - step === "inspected" || - step === "fragmented" || - step === "scanning" || - step === "repairing" || - step === "reassembling" || - step === "done" || - step === "narrator-outro"; - const isPylonInteractive = step === "arrived" || step === "npc-return"; const beginStraighten = (): void => { @@ -94,10 +98,12 @@ export function PylonDownedPylon(): React.JSX.Element | null { }, PYLON_STRAIGHTEN_ANIMATION_DURATION_MS); }; + if (!shouldRender) return null; + return ( @@ -107,7 +113,7 @@ export function PylonDownedPylon(): React.JSX.Element | null { label={ step === "arrived" ? "Inspecter le pylône" : "Redresser le pylône" } - position={PYLON_WORLD_POSITION} + position={position} radius={PYLON_NARRATIVE_INTERACT_RADIUS} onPress={() => { if (step === "arrived") { diff --git a/src/components/gameplay/pylon/PylonFarmerNPC.tsx b/src/components/gameplay/pylon/PylonFarmerNPC.tsx index 638248b..1ff02c0 100644 --- a/src/components/gameplay/pylon/PylonFarmerNPC.tsx +++ b/src/components/gameplay/pylon/PylonFarmerNPC.tsx @@ -40,9 +40,32 @@ function faceToward(from: THREE.Vector3, to: readonly [number, number, number]): return Math.atan2(dx, dz); } +/** + * Outer shell — only checks visibility conditions. + * Rendering is delegated to PylonFarmerNPCContent so that the heavy hooks + * (useFrame, useAnimations) are only active while the NPC is actually shown. + */ export function PylonFarmerNPC(): React.JSX.Element | null { const mainState = useGameStore((state) => state.mainState); const step = useGameStore((state) => state.pylon.currentStep); + + if (mainState !== "pylon") return null; + // Visible during narrative + at repair completion (hides during repair steps) + if ( + step !== "arrived" && + step !== "npc-return" && + step !== "inspected" && + step !== "done" + ) { + return null; + } + + return ; +} + +// ─── Inner component — heavy hooks only run when NPC is mounted ────────────── +function PylonFarmerNPCContent(): React.JSX.Element { + const step = useGameStore((state) => state.pylon.currentStep); const setMissionStep = useGameStore((state) => state.setMissionStep); const camera = useThree((state) => state.camera); @@ -69,8 +92,6 @@ export function PylonFarmerNPC(): React.JSX.Element | null { const { actions } = useAnimations(animations, model); // ─── playAnim ───────────────────────────────────────────────────────────── - // NOTE: actions is intentionally in the dep array so this callback is - // recreated when drei's internal state populates the actions map. const playAnim = useCallback( (name: NPCAnimation, fade = ANIM_FADE): void => { if (currentAnimRef.current === name) return; @@ -94,7 +115,6 @@ export function PylonFarmerNPC(): React.JSX.Element | null { const playPostRaiseAudioAndAdvance = useCallback(async () => { const manifest = await loadDialogueManifest(); if (manifest) { - // "N'hésite pas, si tu as besoin d'autre chose !" const audio = await playDialogueById( manifest, PYLON_NARRATIVE_DIALOGUES.electricienneApresMontage, @@ -111,7 +131,6 @@ export function PylonFarmerNPC(): React.JSX.Element | null { }, [setMissionStep]); // ─── Step-driven animation ──────────────────────────────────────────────── - // Fires when step changes OR when playAnim changes (i.e. when actions load). useEffect(() => { currentAnimRef.current = null; if (step === "arrived") { @@ -124,6 +143,15 @@ export function PylonFarmerNPC(): React.JSX.Element | null { playAnim("walk"); } else if (step === "inspected") { playAnim("idle"); + } else if (step === "done") { + // NPC reappears at repair completion — position at the post-raise spot, + // facing the pylon, playing idle. + currentPosRef.current.set(...PYLON_FARMER_NPC_AFTER_POSITION_pylone_straight); + savedRotationYRef.current = faceToward( + currentPosRef.current, + PYLON_WORLD_POSITION, + ); + playAnim("idle"); } }, [step, playAnim]); @@ -171,7 +199,7 @@ export function PylonFarmerNPC(): React.JSX.Element | null { savedRotationYRef.current = faceToward(currentPosRef.current, PYLON_WORLD_POSITION); } group.position.copy(currentPosRef.current); - } else if (step === "inspected") { + } else if (step === "inspected" || step === "done") { group.position.set(...PYLON_FARMER_NPC_AFTER_POSITION_pylone_straight); } else if (isCompleted) { group.position.copy(currentPosRef.current); @@ -190,10 +218,6 @@ export function PylonFarmerNPC(): React.JSX.Element | null { group.scale.setScalar(PYLON_FARMER_NPC_AFTER_SCALE); }); - if (mainState !== "pylon") return null; - if (step !== "arrived" && step !== "npc-return" && step !== "inspected") - return null; - return ( @@ -204,6 +228,13 @@ export function PylonFarmerNPC(): React.JSX.Element | null { position={PYLON_FARMER_NPC_POSITION} radius={PYLON_NARRATIVE_INTERACT_RADIUS} onPress={() => { + // Turn to face the player the moment they engage the NPC + savedRotationYRef.current = faceToward(currentPosRef.current, [ + camera.position.x, + camera.position.y, + camera.position.z, + ]); + void (async () => { const manifest = await loadDialogueManifest(); if (!manifest) { diff --git a/src/components/gameplay/pylon/PylonNarrativeFlow.tsx b/src/components/gameplay/pylon/PylonNarrativeFlow.tsx index 11a9016..cf5bd63 100644 --- a/src/components/gameplay/pylon/PylonNarrativeFlow.tsx +++ b/src/components/gameplay/pylon/PylonNarrativeFlow.tsx @@ -45,7 +45,12 @@ export function PylonNarrativeFlow(): React.JSX.Element | null { ); } - if (step === "arrived" || step === "npc-return" || step === "inspected") { + if ( + step === "arrived" || + step === "npc-return" || + step === "inspected" || + step === "done" + ) { return ; } diff --git a/src/types/map/mapScene.ts b/src/types/map/mapScene.ts index bde04dd..6403ab6 100644 --- a/src/types/map/mapScene.ts +++ b/src/types/map/mapScene.ts @@ -11,6 +11,8 @@ export interface MapNode { } export interface MapNodeInstanceTransform { + /** Node id from map.json — preserved so specific instances can be excluded at runtime. */ + id?: string; position: Vector3Tuple; rotation: Vector3Tuple; scale: Vector3Tuple; diff --git a/src/utils/map/mapInstanceTransform.ts b/src/utils/map/mapInstanceTransform.ts index 0cf39cb..257a10f 100644 --- a/src/utils/map/mapInstanceTransform.ts +++ b/src/utils/map/mapInstanceTransform.ts @@ -4,6 +4,7 @@ export function mapNodeToInstanceTransform( node: MapNode, ): MapNodeInstanceTransform { return { + id: node.id, position: node.position, rotation: node.rotation, scale: node.scale, diff --git a/src/world/debug/TestMap.tsx b/src/world/debug/TestMap.tsx index e1d74b9..ec7f016 100644 --- a/src/world/debug/TestMap.tsx +++ b/src/world/debug/TestMap.tsx @@ -264,7 +264,7 @@ export function TestMap({ onOctreeReady }: TestMapProps): React.JSX.Element { */} {/* GPS Map screen plane */} - + 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 && @@ -153,6 +158,15 @@ export function MapInstancingSystem({ 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 []; @@ -168,12 +182,18 @@ export function MapInstancingSystem({ return []; } - const instances = data.get(type); + 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]); + }, [data, groups, models, onlyMapName, hidePylonAnchorId]); const visibleChunks = useVisibleWorldChunks(chunks, streamingEnabled, { loadRadius: graphicsPresetConfig.chunkLoadRadius,