From a3e8e732f184fe0479485a1cb21d5e53534c05b3 Mon Sep 17 00:00:00 2001 From: math-pixel <59537610+math-pixel@users.noreply.github.com> Date: Tue, 2 Jun 2026 00:23:43 +0200 Subject: [PATCH] its functionning --- src/components/three/gameplay/RepairGame.tsx | 39 ++++- .../three/gameplay/RepairScanSequence.tsx | 18 +-- src/hooks/gameplay/useRepairGameStatus.ts | 43 +++++ .../stores/useRepairTransitionStore.ts | 33 ++++ src/managers/stores/useWorldSettingsStore.ts | 15 +- src/pages/page.tsx | 149 +++++++++++++++--- src/world/GameStageContent.tsx | 38 ++--- src/world/RepairGameScene.tsx | 108 +++++++++++++ src/world/World.tsx | 32 +++- 9 files changed, 407 insertions(+), 68 deletions(-) create mode 100644 src/hooks/gameplay/useRepairGameStatus.ts create mode 100644 src/managers/stores/useRepairTransitionStore.ts create mode 100644 src/world/RepairGameScene.tsx diff --git a/src/components/three/gameplay/RepairGame.tsx b/src/components/three/gameplay/RepairGame.tsx index c6e0a8b..511cf01 100644 --- a/src/components/three/gameplay/RepairGame.tsx +++ b/src/components/three/gameplay/RepairGame.tsx @@ -2,7 +2,6 @@ import { Suspense, useEffect, useMemo, useState } from "react"; import { useGLTF } from "@react-three/drei"; import { ExplodableModel } from "@/components/three/models/ExplodableModel"; import type { RepairCasePlaceholder } from "@/components/three/gameplay/RepairCaseModel"; -import { RepairCompletionStep } from "@/components/three/gameplay/RepairCompletionStep"; import { RepairInspectionObject } from "@/components/three/gameplay/RepairInspectionObject"; import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase"; import { RepairRepairingStep } from "@/components/three/gameplay/RepairRepairingStep"; @@ -10,7 +9,9 @@ import { RepairReassemblyStep } from "@/components/three/gameplay/RepairReassemb import { RepairScanSequence } from "@/components/three/gameplay/RepairScanSequence"; import { REPAIR_CASE_MODEL_PATH } from "@/data/gameplay/repairCaseConfig"; import { REPAIR_FRAGMENTATION_SEQUENCE_SECONDS } from "@/data/gameplay/repairGameConfig"; +import { getNextMissionStep } from "@/data/gameplay/repairMissionState"; import { REPAIR_MISSIONS } from "@/data/gameplay/repairMissions"; +import { useRepairTransitionStore } from "@/managers/stores/useRepairTransitionStore"; import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput"; import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep"; import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight"; @@ -30,6 +31,8 @@ interface RepairGameProps extends Required< mission: RepairMissionId; rotation?: Vector3Tuple; scale?: ModelTransformProps["scale"]; + /** Set to false in isolated scenes with no terrain (e.g. RepairGameScene). */ + snapToTerrain?: boolean; } interface RepairMissionAssetPreloaderProps { @@ -54,6 +57,7 @@ export function RepairGame({ position, rotation = [0, 0, 0], scale = 1, + snapToTerrain = true, }: RepairGameProps): React.JSX.Element | null { const config = REPAIR_MISSIONS[mission]; const mainState = useGameStore((state) => state.mainState); @@ -67,7 +71,11 @@ export function RepairGame({ readonly RepairScannedBrokenPart[] >([]); const parsedScale = toVector3Scale(scale); - const snappedPosition = useTerrainSnappedPosition(position); + // useTerrainSnappedPosition must always be called (rules of hooks) but we + // only use its result when snapToTerrain is true — in the isolated repair + // scene there is no terrain, so we use the raw position directly. + const snappedByTerrain = useTerrainSnappedPosition(position); + const snappedPosition = snapToTerrain ? snappedByTerrain : position; const readyForFragmentation = step === "inspected"; useRepairFragmentationInput({ @@ -103,6 +111,24 @@ export function RepairGame({ }; }, [mainState, mission, setMissionStep, step]); + // When "done" is reached: set pendingCompletion in the transition store. + // useRepairGameStatus detects this and triggers the fade back to world. + // page.tsx waits for the world to fully load, THEN executes the completion. + // This ensures the player sees a loading screen rather than a black flash. + useEffect(() => { + if (mainState !== mission || step !== "done") return undefined; + + const timeoutId = window.setTimeout(() => { + const nextStep = getNextMissionStep("done", mission); + useRepairTransitionStore.getState().setPendingCompletion({ + mission, + nextStep, + }); + }, 200); + + return () => window.clearTimeout(timeoutId); + }, [mainState, mission, step]); + if (mainState !== mission) return null; if (step === "locked") return null; @@ -149,12 +175,9 @@ export function RepairGame({ onComplete={() => setMissionStep(mission, "done")} /> ) : null} - {step === "done" ? ( - completeMission(mission)} - /> - ) : null} + {/* done step: auto-advance is handled by useEffect above — no manual + case-closing interaction needed. Scene is intentionally empty + for the 200ms before completeMission/setMissionStep fires. */} {step !== "waiting" && step !== "done" && step !== "reassembling" ? ( { if (parts.length === 0) return undefined; + // Do NOT call onComplete inside a setState updater — updaters run during + // React's render phase, which would trigger a setState on RepairGame and + // cause a "setState during render" error. Call it directly in the timeout. const timeoutId = window.setTimeout(() => { - setActivePartIndex((currentIndex) => { - const nextIndex = currentIndex + 1; - if (nextIndex >= parts.length) { - onComplete(getScannedBrokenParts(parts, config)); - return currentIndex; - } - - return nextIndex; - }); + const nextIndex = activePartIndex + 1; + if (nextIndex >= parts.length) { + onComplete(getScannedBrokenParts(parts, config)); + } else { + setActivePartIndex(nextIndex); + } }, scanPartSeconds * 1000); return () => { diff --git a/src/hooks/gameplay/useRepairGameStatus.ts b/src/hooks/gameplay/useRepairGameStatus.ts new file mode 100644 index 0000000..375004c --- /dev/null +++ b/src/hooks/gameplay/useRepairGameStatus.ts @@ -0,0 +1,43 @@ +import { useGameStore } from "@/managers/stores/useGameStore"; +import { useRepairTransitionStore } from "@/managers/stores/useRepairTransitionStore"; +import { isRepairGameStep } from "@/types/gameplay/repairMission"; +import type { RepairMissionId } from "@/types/gameplay/repairMission"; + +export interface RepairGameStatus { + active: boolean; + mission: RepairMissionId | null; +} + +/** + * Returns whether a repair game is currently active and for which mission. + * Drives the scene swap in page.tsx: when active, the heavy 3D world is + * unmounted and a lightweight isolated repair scene is shown instead. + */ +export function useRepairGameStatus(): RepairGameStatus { + const mainState = useGameStore((state) => state.mainState); + const ebikeStep = useGameStore((state) => state.ebike.currentStep); + const pylonStep = useGameStore((state) => state.pylon.currentStep); + const farmStep = useGameStore((state) => state.farm.currentStep); + // When pendingCompletion is set the repair game is "done" but we want the + // world to finish loading before executing the completion. Returning + // active=false here triggers the fade back to the world scene. + const pendingCompletion = useRepairTransitionStore( + (s) => s.pendingCompletion, + ); + + if (pendingCompletion !== null) { + return { active: false, mission: null }; + } + + if (mainState === "ebike" && isRepairGameStep(ebikeStep)) { + return { active: true, mission: "ebike" }; + } + if (mainState === "pylon" && isRepairGameStep(pylonStep)) { + return { active: true, mission: "pylon" }; + } + if (mainState === "farm" && isRepairGameStep(farmStep)) { + return { active: true, mission: "farm" }; + } + + return { active: false, mission: null }; +} diff --git a/src/managers/stores/useRepairTransitionStore.ts b/src/managers/stores/useRepairTransitionStore.ts new file mode 100644 index 0000000..109ca68 --- /dev/null +++ b/src/managers/stores/useRepairTransitionStore.ts @@ -0,0 +1,33 @@ +import { create } from "zustand"; +import type { MissionStep, RepairMissionId } from "@/types/gameplay/repairMission"; +import type { Vector3Tuple } from "@/types/three/three"; + +export interface RepairPendingCompletion { + mission: RepairMissionId; + /** Next step to set. When it equals "done", completeMission() is called + * instead (ebike / farm have no further narrative sub-step). */ + nextStep: MissionStep; +} + +interface RepairTransitionState { + /** Set when the repair game reaches "done". page.tsx reads this and + * executes the completion only after the world has fully re-loaded. */ + pendingCompletion: RepairPendingCompletion | null; + /** Player 3D position captured just before entering the repair scene, + * used to re-spawn the player at the correct location on return. */ + savedPlayerPosition: Vector3Tuple | null; +} + +interface RepairTransitionActions { + setPendingCompletion: (data: RepairPendingCompletion | null) => void; + setSavedPlayerPosition: (pos: Vector3Tuple | null) => void; +} + +export const useRepairTransitionStore = create< + RepairTransitionState & RepairTransitionActions +>()((set) => ({ + pendingCompletion: null, + savedPlayerPosition: null, + setPendingCompletion: (data) => set({ pendingCompletion: data }), + setSavedPlayerPosition: (pos) => set({ savedPlayerPosition: pos }), +})); diff --git a/src/managers/stores/useWorldSettingsStore.ts b/src/managers/stores/useWorldSettingsStore.ts index db1d71a..5fcdb9c 100644 --- a/src/managers/stores/useWorldSettingsStore.ts +++ b/src/managers/stores/useWorldSettingsStore.ts @@ -1,4 +1,5 @@ import { create } from "zustand"; +import { persist } from "zustand/middleware"; import { CLOUD_DEFAULTS, type CloudState } from "@/data/world/cloudConfig"; import { FOG_CONFIG, type FogState } from "@/data/world/fogConfig"; import { WIND_DEFAULTS, type WindState } from "@/data/world/windConfig"; @@ -46,7 +47,9 @@ const DEFAULT_STATE: WorldSettingsState = { graphics: { ...GRAPHICS_DEFAULTS }, }; -export const useWorldSettingsStore = create()((set) => ({ +export const useWorldSettingsStore = create()( + persist( + (set) => ({ ...DEFAULT_STATE, setClouds: (cloudsUpdate) => @@ -115,4 +118,12 @@ export const useWorldSettingsStore = create()((set) => ({ })), resetToDefaults: () => set(DEFAULT_STATE), -})); + }), + { + name: "la-fabrik-world-settings", + // Persist only graphics settings (preset + options) — fog/wind/clouds + // reset to defaults on each session. + partialize: (state) => ({ graphics: state.graphics }), + }, + ), +); diff --git a/src/pages/page.tsx b/src/pages/page.tsx index 6d9d752..435211b 100644 --- a/src/pages/page.tsx +++ b/src/pages/page.tsx @@ -15,25 +15,28 @@ import { } from "@/components/ui/intro"; import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay"; import { INITIAL_SCENE_LOADING_STATE } from "@/data/world/sceneLoadingConfig"; +import { useRepairGameStatus } from "@/hooks/gameplay/useRepairGameStatus"; import { useTransientLoadingIndicator } from "@/hooks/ui/useTransientLoadingIndicator"; import { AudioManager } from "@/managers/AudioManager"; import { useGameStore } from "@/managers/stores/useGameStore"; +import { useRepairTransitionStore } from "@/managers/stores/useRepairTransitionStore"; import { useWorldSettingsStore } from "@/managers/stores/useWorldSettingsStore"; import { HandTrackingProvider } from "@/providers/gameplay/HandTrackingProvider"; +import type { RepairMissionId } from "@/types/gameplay/repairMission"; import type { SceneLoadingState } from "@/types/world/sceneLoading"; import { hasSiteBeenVisitedToday } from "@/utils/cookies/siteVisitCookie"; import { logger } from "@/utils/core/Logger"; +import { RepairGameScene } from "@/world/RepairGameScene"; import { World } from "@/world/World"; const LOADING_TO_VIDEO_FADE_MS = 500; +// Duration (ms) of each half of the repair scene cross-fade +const REPAIR_FADE_MS = 250; export function HomePage(): React.JSX.Element | null { const navigate = useNavigate(); const mainState = useGameStore((state) => state.mainState); const introStep = useGameStore((state) => state.intro.currentStep); - const ebikeStep = useGameStore((state) => state.ebike.currentStep); - const pylonStep = useGameStore((state) => state.pylon.currentStep); - const farmStep = useGameStore((state) => state.farm.currentStep); const setIntroStep = useGameStore((state) => state.setIntroStep); const graphicsPreset = useWorldSettingsStore( (state) => state.graphics.preset, @@ -48,9 +51,92 @@ export function HomePage(): React.JSX.Element | null { INITIAL_SCENE_LOADING_STATE, ); const sceneReadyRef = useRef(false); - const runtimeLoadingSignal = `${graphicsPreset}:${mainState}:${ebikeStep}:${pylonStep}:${farmStep}`; + // Only trigger the transient loading indicator on mission-level transitions + // (mainState) or graphics changes — not on every repair game sub-step. + const runtimeLoadingSignal = `${graphicsPreset}:${mainState}`; const previousRuntimeLoadingSignalRef = useRef(runtimeLoadingSignal); + // --- Repair scene swap --------------------------------------------------- + const repairStatus = useRepairGameStatus(); + const pendingCompletion = useRepairTransitionStore( + (s) => s.pendingCompletion, + ); + const setPendingCompletion = useRepairTransitionStore( + (s) => s.setPendingCompletion, + ); + const setSavedPlayerPosition = useRepairTransitionStore( + (s) => s.setSavedPlayerPosition, + ); + + const [showRepairScene, setShowRepairScene] = useState(repairStatus.active); + const [renderedMission, setRenderedMission] = + useState( + repairStatus.active ? repairStatus.mission : null, + ); + const [isFading, setIsFading] = useState(false); + // True while the world is reloading after a repair scene (shows loading overlay). + const [isPostRepairLoading, setIsPostRepairLoading] = useState(false); + const lastRepairActiveRef = useRef(repairStatus.active); + + useEffect(() => { + if (repairStatus.active === lastRepairActiveRef.current) return; + lastRepairActiveRef.current = repairStatus.active; + + if (repairStatus.active) { + // Entering repair scene — capture the player's current world position + // so we can restore it when returning. + const pos = (window as Window & { playerPos?: [number, number, number] }) + .playerPos; + if (pos) setSavedPlayerPosition([pos[0], pos[1], pos[2]]); + } + + setIsFading(true); + + const swapTimer = window.setTimeout(() => { + setShowRepairScene(repairStatus.active); + setRenderedMission(repairStatus.active ? repairStatus.mission : null); + + if (!repairStatus.active) { + // Returning from repair scene — reset loading state so the overlay + // shows while the world reloads from scratch (new WebGL context). + sceneReadyRef.current = false; + setSceneLoadingState(INITIAL_SCENE_LOADING_STATE); + setIsPostRepairLoading(true); + } + + window.setTimeout(() => { + setIsFading(false); + }, 50); + }, REPAIR_FADE_MS); + + return () => window.clearTimeout(swapTimer); + }, [repairStatus.active, repairStatus.mission, setSavedPlayerPosition]); + + // Execute the pending repair completion once the world is fully loaded. + useEffect(() => { + if (!isPostRepairLoading) return; + if (sceneLoadingState.status !== "ready") return; + if (!pendingCompletion) return; + + const { mission, nextStep } = pendingCompletion; + const store = useGameStore.getState(); + + if (nextStep === "done") { + store.completeMission(mission); + } else { + store.setMissionStep(mission, nextStep); + } + + setPendingCompletion(null); + setIsPostRepairLoading(false); + }, [ + isPostRepairLoading, + sceneLoadingState.status, + pendingCompletion, + setPendingCompletion, + ]); + // ------------------------------------------------------------------------- + useEffect(() => { sceneReadyRef.current = sceneLoadingState.status === "ready"; }, [sceneLoadingState.status]); @@ -168,7 +254,9 @@ export function HomePage(): React.JSX.Element | null { introStep === "fade-to-video" || (introStep === "loading-map" && sceneLoadingState.status === "ready"); const showSceneLoadingOverlay = - introStep === "loading-map" || introStep === "fade-to-video"; + introStep === "loading-map" || + introStep === "fade-to-video" || + isPostRepairLoading; const renderIntroOverlay = () => { if (showFadeToVideoOverlay) return ; @@ -187,21 +275,46 @@ export function HomePage(): React.JSX.Element | null { return ( - + ) : ( + + + + + + + )} + + {/* Black fade overlay — covers the WebGL context swap. + The AppLoadingIndicator lives INSIDE this div so it inherits the + stacking context (z-index 60) and always paints above the black. */} + + {dialogMessage ? ( state.mainState); - const pylonStep = useGameStore((state) => state.pylon.currentStep); - const anchors = useRepairMissionAnchorStore((state) => state.anchors); - - const pylonInNarrative = - mainState === "pylon" && isPylonNarrativeStep(pylonStep); 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. */} + {/* Pre-load the next mission's repair assets while the player is still + in the world, so the isolated repair scene mounts instantly. + Only load pylon during ebike (not before) to avoid holding a + finished mission's textures in VRAM. */} {mainState === "intro" || mainState === "ebike" ? ( ) : null} @@ -110,18 +101,13 @@ export function GameStageContent(): React.JSX.Element { ) : null} {mainState === "pylon" ? : null} - {REPAIR_MISSION_POSITION_ENTRIES.map(({ mission }) => { - const position = getRepairMissionPosition(mission, anchors); - if (!position) return null; - if ( - mission === "pylon" && - (pylonInNarrative || pylonStep === "waiting") - ) - return null; - return ( - - ); - })} + + {/* RepairGame is NO LONGER rendered here. When a repair step becomes + active, page.tsx unmounts this whole world and mounts the isolated + RepairGameScene instead, freeing all map/character VRAM. */} + + {/* Trigger sphere that starts the ebike repair (locked → waiting). + The repair scene swap is then handled by useRepairGameStatus. */} {REPAIR_MISSION_TRIGGERS.map((config) => ( ))} diff --git a/src/world/RepairGameScene.tsx b/src/world/RepairGameScene.tsx new file mode 100644 index 0000000..a940d91 --- /dev/null +++ b/src/world/RepairGameScene.tsx @@ -0,0 +1,108 @@ +import { Suspense, useCallback, useEffect } from "react"; +import { Canvas, useThree } from "@react-three/fiber"; +import { Physics } from "@react-three/rapier"; +import * as THREE from "three"; +import { DebugPerf } from "@/components/debug/DebugPerf"; +import { RepairGame } from "@/components/three/gameplay/RepairGame"; +import { logger } from "@/utils/core/Logger"; +import type { RepairMissionId } from "@/types/gameplay/repairMission"; +import type { Vector3Tuple } from "@/types/three/three"; + +// Isolated scene — no world offset, no terrain. The repair game runs +// fully centred in its own context so the heavy map never loads here. +const REPAIR_SCENE_POSITION: Vector3Tuple = [0, 0, 0]; + +// Background: very dark blue-grey to match Altera's night-time mood +const REPAIR_SCENE_BG = "#0b0d14"; + +// Lighting tuned to match the main world defaults from lightingConfig.ts +const AMBIENT_COLOR = "#dfe7d8"; +const AMBIENT_INTENSITY = 0.9; +const SUN_COLOR = "#ffe2bf"; +const SUN_INTENSITY = 2.2; +const SUN_POSITION: Vector3Tuple = [5, 8, 4]; + +// Mimic the first-person view from the main world: +// - PLAYER_EYE_HEIGHT = 1.75 → camera Y +// - Case floats at [0, 0.4, 1.8] (inspected) → [0, 1.05, 2.05] (repairing) +// - Look-at target averaged between those two states +const CAMERA_POSITION: Vector3Tuple = [5, 2, 2]; +const CAMERA_LOOK_AT: Vector3Tuple = [0, 0.7, 1.9]; + +function RepairSceneCamera(): null { + const { camera } = useThree(); + + useEffect(() => { + camera.lookAt(...CAMERA_LOOK_AT); + }, [camera]); + + return null; +} + +interface RepairGameSceneProps { + mission: RepairMissionId; +} + +export function RepairGameScene({ + mission, +}: RepairGameSceneProps): React.JSX.Element { + const handleCreated = useCallback(({ gl }: { gl: THREE.WebGLRenderer }) => { + const canvas = gl.domElement; + const loseContextExt = gl.getContext().getExtension("WEBGL_lose_context"); + + const handleContextLost = (event: Event) => { + event.preventDefault(); + logger.error("WebGL", "Repair scene context lost — attempting restore"); + window.setTimeout(() => loseContextExt?.restoreContext(), 500); + }; + + const handleContextRestored = () => { + logger.info("WebGL", "Repair scene context restored"); + }; + + canvas.addEventListener("webglcontextlost", handleContextLost); + canvas.addEventListener("webglcontextrestored", handleContextRestored); + }, []); + + return ( + + + + + + {/* Lighting — mirrors the game world defaults */} + + + + + {/* Physics is required: TriggerObject and GrabbableObject both use + RigidBody. The world is minimal — no octree, no character bodies. */} + + + + + + + + ); +} diff --git a/src/world/World.tsx b/src/world/World.tsx index 8a3b526..8727f26 100644 --- a/src/world/World.tsx +++ b/src/world/World.tsx @@ -1,9 +1,10 @@ -import { Suspense, useEffect } from "react"; +import { Suspense, useEffect, useRef } from "react"; import { Physics } from "@react-three/rapier"; import { PLAYER_SPAWN_POSITION_GAME, PLAYER_SPAWN_POSITION_PHYSICS, } from "@/data/player/playerConfig"; +import { useRepairTransitionStore } from "@/managers/stores/useRepairTransitionStore"; import { useCameraMode } from "@/hooks/debug/useCameraMode"; import { useEnvironmentDebug } from "@/hooks/debug/useEnvironmentDebug"; import { useMapPerformanceDebug } from "@/hooks/debug/useMapPerformanceDebug"; @@ -53,10 +54,31 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element { handleShadowWarmupStarted, shouldWarmUpShadows, } = useWorldSceneLoading({ sceneMode, onLoadingStateChange }); - const playerSpawnPosition = - sceneMode === "game" - ? PLAYER_SPAWN_POSITION_GAME - : PLAYER_SPAWN_POSITION_PHYSICS; + // Capture the spawn position once on mount via a ref so it never changes + // mid-session (spawnPosition is reactive in Player and would re-spawn the + // character on every prop change). If the player returns from a repair + // scene, savedPlayerPosition holds their world position; otherwise fall + // back to the default spawn from playerConfig. + const savedPlayerPosition = useRepairTransitionStore( + (s) => s.savedPlayerPosition, + ); + const playerSpawnPositionRef = useRef( + savedPlayerPosition ?? + (sceneMode === "game" + ? PLAYER_SPAWN_POSITION_GAME + : PLAYER_SPAWN_POSITION_PHYSICS), + ); + const playerSpawnPosition = playerSpawnPositionRef.current; + + // Clear the saved position right after capturing it so the next world + // mount uses the default spawn instead of the stale repair-exit position. + useEffect(() => { + if (savedPlayerPosition !== null) { + useRepairTransitionStore.getState().setSavedPlayerPosition(null); + } + // Only on mount — intentionally no deps + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const showHandTrackingGloves = sceneMode === "physics" || (status !== "idle" && usageStatus !== "inactive");