its functionning

This commit is contained in:
math-pixel
2026-06-02 00:23:43 +02:00
parent d975aac018
commit a3e8e732f1
9 changed files with 407 additions and 68 deletions
+31 -8
View File
@@ -2,7 +2,6 @@ import { Suspense, useEffect, useMemo, useState } from "react";
import { useGLTF } from "@react-three/drei"; import { useGLTF } from "@react-three/drei";
import { ExplodableModel } from "@/components/three/models/ExplodableModel"; import { ExplodableModel } from "@/components/three/models/ExplodableModel";
import type { RepairCasePlaceholder } from "@/components/three/gameplay/RepairCaseModel"; import type { RepairCasePlaceholder } from "@/components/three/gameplay/RepairCaseModel";
import { RepairCompletionStep } from "@/components/three/gameplay/RepairCompletionStep";
import { RepairInspectionObject } from "@/components/three/gameplay/RepairInspectionObject"; import { RepairInspectionObject } from "@/components/three/gameplay/RepairInspectionObject";
import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase"; import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase";
import { RepairRepairingStep } from "@/components/three/gameplay/RepairRepairingStep"; 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 { RepairScanSequence } from "@/components/three/gameplay/RepairScanSequence";
import { REPAIR_CASE_MODEL_PATH } from "@/data/gameplay/repairCaseConfig"; import { REPAIR_CASE_MODEL_PATH } from "@/data/gameplay/repairCaseConfig";
import { REPAIR_FRAGMENTATION_SEQUENCE_SECONDS } from "@/data/gameplay/repairGameConfig"; import { REPAIR_FRAGMENTATION_SEQUENCE_SECONDS } from "@/data/gameplay/repairGameConfig";
import { getNextMissionStep } from "@/data/gameplay/repairMissionState";
import { REPAIR_MISSIONS } from "@/data/gameplay/repairMissions"; import { REPAIR_MISSIONS } from "@/data/gameplay/repairMissions";
import { useRepairTransitionStore } from "@/managers/stores/useRepairTransitionStore";
import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput"; import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput";
import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep"; import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep";
import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight"; import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight";
@@ -30,6 +31,8 @@ interface RepairGameProps extends Required<
mission: RepairMissionId; mission: RepairMissionId;
rotation?: Vector3Tuple; rotation?: Vector3Tuple;
scale?: ModelTransformProps["scale"]; scale?: ModelTransformProps["scale"];
/** Set to false in isolated scenes with no terrain (e.g. RepairGameScene). */
snapToTerrain?: boolean;
} }
interface RepairMissionAssetPreloaderProps { interface RepairMissionAssetPreloaderProps {
@@ -54,6 +57,7 @@ export function RepairGame({
position, position,
rotation = [0, 0, 0], rotation = [0, 0, 0],
scale = 1, scale = 1,
snapToTerrain = true,
}: RepairGameProps): React.JSX.Element | null { }: RepairGameProps): React.JSX.Element | null {
const config = REPAIR_MISSIONS[mission]; const config = REPAIR_MISSIONS[mission];
const mainState = useGameStore((state) => state.mainState); const mainState = useGameStore((state) => state.mainState);
@@ -67,7 +71,11 @@ export function RepairGame({
readonly RepairScannedBrokenPart[] readonly RepairScannedBrokenPart[]
>([]); >([]);
const parsedScale = toVector3Scale(scale); 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"; const readyForFragmentation = step === "inspected";
useRepairFragmentationInput({ useRepairFragmentationInput({
@@ -103,6 +111,24 @@ export function RepairGame({
}; };
}, [mainState, mission, setMissionStep, step]); }, [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 (mainState !== mission) return null;
if (step === "locked") return null; if (step === "locked") return null;
@@ -149,12 +175,9 @@ export function RepairGame({
onComplete={() => setMissionStep(mission, "done")} onComplete={() => setMissionStep(mission, "done")}
/> />
) : null} ) : null}
{step === "done" ? ( {/* done step: auto-advance is handled by useEffect above — no manual
<RepairCompletionStep case-closing interaction needed. Scene is intentionally empty
config={config} for the 200ms before completeMission/setMissionStep fires. */}
onComplete={() => completeMission(mission)}
/>
) : null}
{step !== "waiting" && step !== "done" && step !== "reassembling" ? ( {step !== "waiting" && step !== "done" && step !== "reassembling" ? (
<RepairMissionCase <RepairMissionCase
config={config} config={config}
@@ -41,16 +41,16 @@ export function RepairScanSequence({
useEffect(() => { useEffect(() => {
if (parts.length === 0) return undefined; 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(() => { const timeoutId = window.setTimeout(() => {
setActivePartIndex((currentIndex) => { const nextIndex = activePartIndex + 1;
const nextIndex = currentIndex + 1;
if (nextIndex >= parts.length) { if (nextIndex >= parts.length) {
onComplete(getScannedBrokenParts(parts, config)); onComplete(getScannedBrokenParts(parts, config));
return currentIndex; } else {
setActivePartIndex(nextIndex);
} }
return nextIndex;
});
}, scanPartSeconds * 1000); }, scanPartSeconds * 1000);
return () => { return () => {
+43
View File
@@ -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 };
}
@@ -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 }),
}));
+13 -2
View File
@@ -1,4 +1,5 @@
import { create } from "zustand"; import { create } from "zustand";
import { persist } from "zustand/middleware";
import { CLOUD_DEFAULTS, type CloudState } from "@/data/world/cloudConfig"; import { CLOUD_DEFAULTS, type CloudState } from "@/data/world/cloudConfig";
import { FOG_CONFIG, type FogState } from "@/data/world/fogConfig"; import { FOG_CONFIG, type FogState } from "@/data/world/fogConfig";
import { WIND_DEFAULTS, type WindState } from "@/data/world/windConfig"; import { WIND_DEFAULTS, type WindState } from "@/data/world/windConfig";
@@ -46,7 +47,9 @@ const DEFAULT_STATE: WorldSettingsState = {
graphics: { ...GRAPHICS_DEFAULTS }, graphics: { ...GRAPHICS_DEFAULTS },
}; };
export const useWorldSettingsStore = create<WorldSettingsStore>()((set) => ({ export const useWorldSettingsStore = create<WorldSettingsStore>()(
persist(
(set) => ({
...DEFAULT_STATE, ...DEFAULT_STATE,
setClouds: (cloudsUpdate) => setClouds: (cloudsUpdate) =>
@@ -115,4 +118,12 @@ export const useWorldSettingsStore = create<WorldSettingsStore>()((set) => ({
})), })),
resetToDefaults: () => set(DEFAULT_STATE), 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 }),
},
),
);
+118 -5
View File
@@ -15,25 +15,28 @@ import {
} from "@/components/ui/intro"; } from "@/components/ui/intro";
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay"; import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
import { INITIAL_SCENE_LOADING_STATE } from "@/data/world/sceneLoadingConfig"; import { INITIAL_SCENE_LOADING_STATE } from "@/data/world/sceneLoadingConfig";
import { useRepairGameStatus } from "@/hooks/gameplay/useRepairGameStatus";
import { useTransientLoadingIndicator } from "@/hooks/ui/useTransientLoadingIndicator"; import { useTransientLoadingIndicator } from "@/hooks/ui/useTransientLoadingIndicator";
import { AudioManager } from "@/managers/AudioManager"; import { AudioManager } from "@/managers/AudioManager";
import { useGameStore } from "@/managers/stores/useGameStore"; import { useGameStore } from "@/managers/stores/useGameStore";
import { useRepairTransitionStore } from "@/managers/stores/useRepairTransitionStore";
import { useWorldSettingsStore } from "@/managers/stores/useWorldSettingsStore"; import { useWorldSettingsStore } from "@/managers/stores/useWorldSettingsStore";
import { HandTrackingProvider } from "@/providers/gameplay/HandTrackingProvider"; import { HandTrackingProvider } from "@/providers/gameplay/HandTrackingProvider";
import type { RepairMissionId } from "@/types/gameplay/repairMission";
import type { SceneLoadingState } from "@/types/world/sceneLoading"; import type { SceneLoadingState } from "@/types/world/sceneLoading";
import { hasSiteBeenVisitedToday } from "@/utils/cookies/siteVisitCookie"; import { hasSiteBeenVisitedToday } from "@/utils/cookies/siteVisitCookie";
import { logger } from "@/utils/core/Logger"; import { logger } from "@/utils/core/Logger";
import { RepairGameScene } from "@/world/RepairGameScene";
import { World } from "@/world/World"; import { World } from "@/world/World";
const LOADING_TO_VIDEO_FADE_MS = 500; 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 { export function HomePage(): React.JSX.Element | null {
const navigate = useNavigate(); const navigate = useNavigate();
const mainState = useGameStore((state) => state.mainState); const mainState = useGameStore((state) => state.mainState);
const introStep = useGameStore((state) => state.intro.currentStep); 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 setIntroStep = useGameStore((state) => state.setIntroStep);
const graphicsPreset = useWorldSettingsStore( const graphicsPreset = useWorldSettingsStore(
(state) => state.graphics.preset, (state) => state.graphics.preset,
@@ -48,9 +51,92 @@ export function HomePage(): React.JSX.Element | null {
INITIAL_SCENE_LOADING_STATE, INITIAL_SCENE_LOADING_STATE,
); );
const sceneReadyRef = useRef(false); 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); 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<RepairMissionId | null>(
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(() => { useEffect(() => {
sceneReadyRef.current = sceneLoadingState.status === "ready"; sceneReadyRef.current = sceneLoadingState.status === "ready";
}, [sceneLoadingState.status]); }, [sceneLoadingState.status]);
@@ -168,7 +254,9 @@ export function HomePage(): React.JSX.Element | null {
introStep === "fade-to-video" || introStep === "fade-to-video" ||
(introStep === "loading-map" && sceneLoadingState.status === "ready"); (introStep === "loading-map" && sceneLoadingState.status === "ready");
const showSceneLoadingOverlay = const showSceneLoadingOverlay =
introStep === "loading-map" || introStep === "fade-to-video"; introStep === "loading-map" ||
introStep === "fade-to-video" ||
isPostRepairLoading;
const renderIntroOverlay = () => { const renderIntroOverlay = () => {
if (showFadeToVideoOverlay) return <FadeToVideoOverlay />; if (showFadeToVideoOverlay) return <FadeToVideoOverlay />;
@@ -187,6 +275,11 @@ export function HomePage(): React.JSX.Element | null {
return ( return (
<HandTrackingProvider> <HandTrackingProvider>
{showRepairScene && renderedMission !== null ? (
/* Isolated repair scene — no map, no player, no physics world.
Unmounting the main Canvas here frees the full GPU budget. */
<RepairGameScene mission={renderedMission} />
) : (
<Canvas <Canvas
camera={{ position: [85, 60, 85], fov: 42 }} camera={{ position: [85, 60, 85], fov: 42 }}
shadows={{ type: THREE.PCFShadowMap }} shadows={{ type: THREE.PCFShadowMap }}
@@ -202,6 +295,26 @@ export function HomePage(): React.JSX.Element | null {
<DebugPerf /> <DebugPerf />
</Suspense> </Suspense>
</Canvas> </Canvas>
)}
{/* 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. */}
<div
aria-hidden="true"
style={{
position: "fixed",
inset: 0,
background: "#000",
zIndex: 60,
opacity: isFading ? 1 : 0,
transition: `opacity ${REPAIR_FADE_MS}ms ease-in-out`,
pointerEvents: isFading ? "all" : "none",
}}
>
{isFading ? <AppLoadingIndicator floating /> : null}
</div>
<GameUI /> <GameUI />
{dialogMessage ? ( {dialogMessage ? (
<DialogMessage <DialogMessage
+12 -26
View File
@@ -1,23 +1,18 @@
import { Ebike } from "@/components/ebike/Ebike"; import { Ebike } from "@/components/ebike/Ebike";
import { InteractableObject } from "@/components/three/interaction/InteractableObject"; import { InteractableObject } from "@/components/three/interaction/InteractableObject";
import { RepairGame } from "@/components/three/gameplay/RepairGame";
import { RepairGamePreloader } from "@/components/three/gameplay/RepairGamePreloader"; import { RepairGamePreloader } from "@/components/three/gameplay/RepairGamePreloader";
import { PylonDownedPylon } from "@/components/gameplay/pylon/PylonDownedPylon"; import { PylonDownedPylon } from "@/components/gameplay/pylon/PylonDownedPylon";
import { PylonNarrativeFlow } from "@/components/gameplay/pylon/PylonNarrativeFlow"; import { PylonNarrativeFlow } from "@/components/gameplay/pylon/PylonNarrativeFlow";
import { ZoneDebugVisual } from "@/components/zone/ZoneDetection"; import { ZoneDebugVisual } from "@/components/zone/ZoneDetection";
import { PYLON_APPROACH_ZONE, PYLON_ARRIVED_ZONE } from "@/data/gameplay/zones"; import { PYLON_APPROACH_ZONE, PYLON_ARRIVED_ZONE } from "@/data/gameplay/zones";
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled"; import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
import { import { REPAIR_MISSION_TRIGGERS } from "@/data/gameplay/repairMissionAnchors";
REPAIR_MISSION_POSITION_ENTRIES,
REPAIR_MISSION_TRIGGERS,
} from "@/data/gameplay/repairMissionAnchors";
import { import {
INTRO_STAGE_ANCHOR, INTRO_STAGE_ANCHOR,
OUTRO_STAGE_ANCHOR, OUTRO_STAGE_ANCHOR,
} from "@/data/gameplay/gameStageAnchors"; } from "@/data/gameplay/gameStageAnchors";
import { useGameStore } from "@/managers/stores/useGameStore"; import { useGameStore } from "@/managers/stores/useGameStore";
import { useRepairMissionAnchorStore } from "@/managers/stores/useRepairMissionAnchorStore"; import { useRepairMissionAnchorStore } from "@/managers/stores/useRepairMissionAnchorStore";
import { isPylonNarrativeStep } from "@/types/gameplay/repairMission";
import type { RepairMissionTriggerConfig } from "@/types/gameplay/repairMission"; import type { RepairMissionTriggerConfig } from "@/types/gameplay/repairMission";
import type { Vector3Tuple } from "@/types/three/three"; import type { Vector3Tuple } from "@/types/three/three";
import { getRepairMissionPosition } from "@/utils/gameplay/repairMissionPosition"; import { getRepairMissionPosition } from "@/utils/gameplay/repairMissionPosition";
@@ -84,17 +79,13 @@ function RepairMissionTrigger({
export function GameStageContent(): React.JSX.Element { export function GameStageContent(): React.JSX.Element {
const mainState = useGameStore((state) => state.mainState); const mainState = useGameStore((state) => state.mainState);
const pylonStep = useGameStore((state) => state.pylon.currentStep);
const anchors = useRepairMissionAnchorStore((state) => state.anchors);
const pylonInNarrative =
mainState === "pylon" && isPylonNarrativeStep(pylonStep);
return ( return (
<> <>
{/* Preload ONLY the next upcoming mission's assets so we never keep a {/* Pre-load the next mission's repair assets while the player is still
finished mission's heavy textures resident (ebike alone is ~40MB). in the world, so the isolated repair scene mounts instantly.
Holding every mission at once is what saturated GPU memory. */} Only load pylon during ebike (not before) to avoid holding a
finished mission's textures in VRAM. */}
{mainState === "intro" || mainState === "ebike" ? ( {mainState === "intro" || mainState === "ebike" ? (
<RepairGamePreloader mission="pylon" /> <RepairGamePreloader mission="pylon" />
) : null} ) : null}
@@ -110,18 +101,13 @@ export function GameStageContent(): React.JSX.Element {
</> </>
) : null} ) : null}
{mainState === "pylon" ? <PylonNarrativeFlow /> : null} {mainState === "pylon" ? <PylonNarrativeFlow /> : null}
{REPAIR_MISSION_POSITION_ENTRIES.map(({ mission }) => {
const position = getRepairMissionPosition(mission, anchors); {/* RepairGame is NO LONGER rendered here. When a repair step becomes
if (!position) return null; active, page.tsx unmounts this whole world and mounts the isolated
if ( RepairGameScene instead, freeing all map/character VRAM. */}
mission === "pylon" &&
(pylonInNarrative || pylonStep === "waiting") {/* Trigger sphere that starts the ebike repair (locked → waiting).
) The repair scene swap is then handled by useRepairGameStatus. */}
return null;
return (
<RepairGame key={mission} mission={mission} position={position} />
);
})}
{REPAIR_MISSION_TRIGGERS.map((config) => ( {REPAIR_MISSION_TRIGGERS.map((config) => (
<RepairMissionTrigger key={config.mission} config={config} /> <RepairMissionTrigger key={config.mission} config={config} />
))} ))}
+108
View File
@@ -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 (
<Canvas
camera={{ position: CAMERA_POSITION, fov: 42 }}
shadows={{ type: THREE.PCFShadowMap }}
gl={{
powerPreference: "high-performance",
antialias: true,
stencil: false,
}}
onCreated={handleCreated}
>
<color attach="background" args={[REPAIR_SCENE_BG]} />
<RepairSceneCamera />
{/* Lighting — mirrors the game world defaults */}
<ambientLight intensity={AMBIENT_INTENSITY} color={AMBIENT_COLOR} />
<directionalLight
position={SUN_POSITION}
intensity={SUN_INTENSITY}
color={SUN_COLOR}
castShadow
shadow-mapSize-width={1024}
shadow-mapSize-height={1024}
/>
<Suspense fallback={null}>
{/* Physics is required: TriggerObject and GrabbableObject both use
RigidBody. The world is minimal — no octree, no character bodies. */}
<Physics>
<RepairGame
mission={mission}
position={REPAIR_SCENE_POSITION}
snapToTerrain={false}
/>
</Physics>
</Suspense>
<DebugPerf />
</Canvas>
);
}
+26 -4
View File
@@ -1,9 +1,10 @@
import { Suspense, useEffect } from "react"; import { Suspense, useEffect, useRef } from "react";
import { Physics } from "@react-three/rapier"; import { Physics } from "@react-three/rapier";
import { import {
PLAYER_SPAWN_POSITION_GAME, PLAYER_SPAWN_POSITION_GAME,
PLAYER_SPAWN_POSITION_PHYSICS, PLAYER_SPAWN_POSITION_PHYSICS,
} from "@/data/player/playerConfig"; } from "@/data/player/playerConfig";
import { useRepairTransitionStore } from "@/managers/stores/useRepairTransitionStore";
import { useCameraMode } from "@/hooks/debug/useCameraMode"; import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useEnvironmentDebug } from "@/hooks/debug/useEnvironmentDebug"; import { useEnvironmentDebug } from "@/hooks/debug/useEnvironmentDebug";
import { useMapPerformanceDebug } from "@/hooks/debug/useMapPerformanceDebug"; import { useMapPerformanceDebug } from "@/hooks/debug/useMapPerformanceDebug";
@@ -53,10 +54,31 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
handleShadowWarmupStarted, handleShadowWarmupStarted,
shouldWarmUpShadows, shouldWarmUpShadows,
} = useWorldSceneLoading({ sceneMode, onLoadingStateChange }); } = useWorldSceneLoading({ sceneMode, onLoadingStateChange });
const playerSpawnPosition = // Capture the spawn position once on mount via a ref so it never changes
sceneMode === "game" // 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_GAME
: PLAYER_SPAWN_POSITION_PHYSICS; : 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 = const showHandTrackingGloves =
sceneMode === "physics" || sceneMode === "physics" ||
(status !== "idle" && usageStatus !== "inactive"); (status !== "idle" && usageStatus !== "inactive");