From d975aac01873dc5c952492441f72c7f2c7996c18 Mon Sep 17 00:00:00 2001 From: math-pixel <59537610+math-pixel@users.noreply.github.com> Date: Mon, 1 Jun 2026 22:26:58 +0200 Subject: [PATCH] o think is not that --- .claude/launch.json | 11 ++++ .../gameplay/pylon/PylonDownedPylon.tsx | 56 +++++++++++++------ .../gameplay/pylon/PylonNarrativeFlow.tsx | 11 ++++ .../three/gameplay/RepairCaseModel.tsx | 6 +- .../three/gameplay/RepairGamePreloader.tsx | 37 ++++++++++++ src/world/GameStageContent.tsx | 15 ++++- 6 files changed, 117 insertions(+), 19 deletions(-) create mode 100644 .claude/launch.json create mode 100644 src/components/three/gameplay/RepairGamePreloader.tsx diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..8b30f3b --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 5173 + } + ] +} diff --git a/src/components/gameplay/pylon/PylonDownedPylon.tsx b/src/components/gameplay/pylon/PylonDownedPylon.tsx index 11458b4..03a073e 100644 --- a/src/components/gameplay/pylon/PylonDownedPylon.tsx +++ b/src/components/gameplay/pylon/PylonDownedPylon.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useFrame } from "@react-three/fiber"; import { useGLTF } from "@react-three/drei"; import * as THREE from "three"; @@ -33,13 +33,27 @@ export function PylonDownedPylon(): React.JSX.Element | null { }, [step]); const { scene } = useGLTF(PYLON_MODEL_PATH); + const clonedScene = useMemo(() => scene.clone(true), [scene]); + + const showUpright = + mainState !== "pylon" || + step === "waiting" || + step === "inspected" || + step === "fragmented" || + step === "scanning" || + step === "repairing" || + step === "reassembling" || + step === "done" || + step === "narrator-outro"; useFrame(() => { const group = groupRef.current; if (!group) return; if (!isStraightening || straightenStartRef.current === null) { - group.rotation.set(...(showUpright ? PYLON_UPRIGHT_ROTATION : PYLON_DOWNED_ROTATION)); + group.rotation.set( + ...(showUpright ? PYLON_UPRIGHT_ROTATION : PYLON_DOWNED_ROTATION), + ); return; } @@ -55,19 +69,19 @@ export function PylonDownedPylon(): React.JSX.Element | null { ); }); - const showUpright = - 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"; + // During these steps the RepairGame renders its own pylon model + // (exploded / reassembling / completion). Rendering the solid world + // pylon on top would double the heaviest model's GPU cost at the same + // spot — a prime cause of WebGL context loss. Let RepairGame own it. + const repairGameOwnsModel = + mainState === "pylon" && + (step === "fragmented" || + step === "scanning" || + step === "reassembling" || + step === "done"); + const beginStraighten = (): void => { setIsStraightening(true); pylonStraighteningSignal.started = true; @@ -80,17 +94,19 @@ export function PylonDownedPylon(): React.JSX.Element | null { setIsStraightening(false); pylonStraighteningSignal.started = false; setCanMove(true); - setMissionStep("pylon", "inspected"); + setMissionStep("pylon", "waiting"); }, PYLON_STRAIGHTEN_ANIMATION_DURATION_MS); }; + if (repairGameOwnsModel) return null; + return ( - + {isPylonInteractive ? ( { const m = await loadDialogueManifest(); if (!m) return; - await playDialogueById(m, PYLON_NARRATIVE_DIALOGUES.demandeAide); + await playDialogueById( + m, + PYLON_NARRATIVE_DIALOGUES.demandeAide, + ); })(); }, { once: true }, @@ -127,7 +146,10 @@ export function PylonDownedPylon(): React.JSX.Element | null { void (async () => { const manifest = await loadDialogueManifest(); if (!manifest) return; - await playDialogueById(manifest, PYLON_NARRATIVE_DIALOGUES.demandeAide); + await playDialogueById( + manifest, + PYLON_NARRATIVE_DIALOGUES.demandeAide, + ); })(); } } else if (step === "npc-return" && !isStraightening) { diff --git a/src/components/gameplay/pylon/PylonNarrativeFlow.tsx b/src/components/gameplay/pylon/PylonNarrativeFlow.tsx index 218a47a..6a33ee0 100644 --- a/src/components/gameplay/pylon/PylonNarrativeFlow.tsx +++ b/src/components/gameplay/pylon/PylonNarrativeFlow.tsx @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import { useGameStore } from "@/managers/stores/useGameStore"; import { useDialoguePlayback } from "@/hooks/gameplay/useDialoguePlayback"; import { ZoneDetection } from "@/components/zone/ZoneDetection"; @@ -28,6 +29,16 @@ export function PylonNarrativeFlow(): React.JSX.Element | null { onComplete: () => completeMission("pylon"), }); + // Advance waiting → inspected in a separate macrotask so React and Rapier + // finish their current commit before RigidBody colliders are created. + useEffect(() => { + if (mainState !== "pylon" || step !== "waiting") return undefined; + const id = window.setTimeout(() => { + setMissionStep("pylon", "inspected"); + }, 0); + return () => window.clearTimeout(id); + }, [mainState, step, setMissionStep]); + if (mainState !== "pylon") return null; if (step === "locked") { diff --git a/src/components/three/gameplay/RepairCaseModel.tsx b/src/components/three/gameplay/RepairCaseModel.tsx index 3df6132..9973ac6 100644 --- a/src/components/three/gameplay/RepairCaseModel.tsx +++ b/src/components/three/gameplay/RepairCaseModel.tsx @@ -219,7 +219,11 @@ export function RepairCaseModel({ parsedScale[2] * pop.current.scale, ); - if (placeholderNodes.current.length > 0) { + // Placeholders are only consumed when the case is open (repairing). While + // floating (inspected/scanning) the case bobs every frame, so emitting here + // would fire a React setState on every frame, re-rendering the whole + // RepairGame subtree continuously. Only compute when not floating. + if (!floating && placeholderNodes.current.length > 0) { const placeholders: RepairCasePlaceholder[] = []; placeholderNodes.current.forEach((child) => { child.getWorldPosition(placeholderPosition.current); diff --git a/src/components/three/gameplay/RepairGamePreloader.tsx b/src/components/three/gameplay/RepairGamePreloader.tsx new file mode 100644 index 0000000..d45ebdc --- /dev/null +++ b/src/components/three/gameplay/RepairGamePreloader.tsx @@ -0,0 +1,37 @@ +import { useEffect } from "react"; +import { useGLTF } from "@react-three/drei"; +import { REPAIR_CASE_MODEL_PATH } from "@/data/gameplay/repairCaseConfig"; +import { REPAIR_MISSIONS } from "@/data/gameplay/repairMissions"; +import type { RepairMissionId } from "@/types/gameplay/repairMission"; + +function getPreloadPaths(mission: RepairMissionId): string[] { + const config = REPAIR_MISSIONS[mission]; + return [ + ...new Set([ + REPAIR_CASE_MODEL_PATH, + config.modelPath, + ...config.brokenParts.flatMap((p) => p.modelPath ?? []), + ...config.replacementParts.flatMap((p) => p.modelPath ?? []), + ]), + ]; +} + +interface RepairGamePreloaderProps { + mission: RepairMissionId; +} + +/** + * Fires useGLTF.preload() for every asset used by a repair mission. + * Renders nothing — pure background loading. + */ +export function RepairGamePreloader({ + mission, +}: RepairGamePreloaderProps): null { + useEffect(() => { + for (const path of getPreloadPaths(mission)) { + useGLTF.preload(path); + } + }, [mission]); + + return null; +} diff --git a/src/world/GameStageContent.tsx b/src/world/GameStageContent.tsx index 3e755c1..841f9a5 100644 --- a/src/world/GameStageContent.tsx +++ b/src/world/GameStageContent.tsx @@ -1,6 +1,7 @@ import { Ebike } from "@/components/ebike/Ebike"; import { InteractableObject } from "@/components/three/interaction/InteractableObject"; import { RepairGame } from "@/components/three/gameplay/RepairGame"; +import { RepairGamePreloader } from "@/components/three/gameplay/RepairGamePreloader"; import { PylonDownedPylon } from "@/components/gameplay/pylon/PylonDownedPylon"; import { PylonNarrativeFlow } from "@/components/gameplay/pylon/PylonNarrativeFlow"; import { ZoneDebugVisual } from "@/components/zone/ZoneDetection"; @@ -91,6 +92,14 @@ export function GameStageContent(): React.JSX.Element { return ( <> + {/* Preload ONLY the next upcoming mission's assets so we never keep a + finished mission's heavy textures resident (ebike alone is ~40MB). + Holding every mission at once is what saturated GPU memory. */} + {mainState === "intro" || mainState === "ebike" ? ( + + ) : null} + {mainState === "pylon" ? : null} + {mainState === "intro" ? : null} @@ -104,7 +113,11 @@ export function GameStageContent(): React.JSX.Element { {REPAIR_MISSION_POSITION_ENTRIES.map(({ mission }) => { const position = getRepairMissionPosition(mission, anchors); if (!position) return null; - if (mission === "pylon" && pylonInNarrative) return null; + if ( + mission === "pylon" && + (pylonInNarrative || pylonStep === "waiting") + ) + return null; return ( );