2 Commits

Author SHA1 Message Date
math-pixel a3e8e732f1 its functionning 2026-06-02 00:23:43 +02:00
math-pixel d975aac018 o think is not that 2026-06-01 22:26:58 +02:00
14 changed files with 516 additions and 79 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 5173
}
]
}
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useFrame } from "@react-three/fiber"; import { useFrame } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei"; import { useGLTF } from "@react-three/drei";
import * as THREE from "three"; import * as THREE from "three";
@@ -33,13 +33,27 @@ export function PylonDownedPylon(): React.JSX.Element | null {
}, [step]); }, [step]);
const { scene } = useGLTF(PYLON_MODEL_PATH); 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(() => { useFrame(() => {
const group = groupRef.current; const group = groupRef.current;
if (!group) return; if (!group) return;
if (!isStraightening || straightenStartRef.current === null) { 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; 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"; 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 => { const beginStraighten = (): void => {
setIsStraightening(true); setIsStraightening(true);
pylonStraighteningSignal.started = true; pylonStraighteningSignal.started = true;
@@ -80,17 +94,19 @@ export function PylonDownedPylon(): React.JSX.Element | null {
setIsStraightening(false); setIsStraightening(false);
pylonStraighteningSignal.started = false; pylonStraighteningSignal.started = false;
setCanMove(true); setCanMove(true);
setMissionStep("pylon", "inspected"); setMissionStep("pylon", "waiting");
}, PYLON_STRAIGHTEN_ANIMATION_DURATION_MS); }, PYLON_STRAIGHTEN_ANIMATION_DURATION_MS);
}; };
if (repairGameOwnsModel) return null;
return ( return (
<group <group
ref={groupRef} ref={groupRef}
position={PYLON_WORLD_POSITION} position={PYLON_WORLD_POSITION}
rotation={PYLON_DOWNED_ROTATION} rotation={PYLON_DOWNED_ROTATION}
> >
<primitive object={scene.clone(true)} /> <primitive object={clonedScene} />
{isPylonInteractive ? ( {isPylonInteractive ? (
<InteractableObject <InteractableObject
kind="trigger" kind="trigger"
@@ -117,7 +133,10 @@ export function PylonDownedPylon(): React.JSX.Element | null {
void (async () => { void (async () => {
const m = await loadDialogueManifest(); const m = await loadDialogueManifest();
if (!m) return; if (!m) return;
await playDialogueById(m, PYLON_NARRATIVE_DIALOGUES.demandeAide); await playDialogueById(
m,
PYLON_NARRATIVE_DIALOGUES.demandeAide,
);
})(); })();
}, },
{ once: true }, { once: true },
@@ -127,7 +146,10 @@ export function PylonDownedPylon(): React.JSX.Element | null {
void (async () => { void (async () => {
const manifest = await loadDialogueManifest(); const manifest = await loadDialogueManifest();
if (!manifest) return; if (!manifest) return;
await playDialogueById(manifest, PYLON_NARRATIVE_DIALOGUES.demandeAide); await playDialogueById(
manifest,
PYLON_NARRATIVE_DIALOGUES.demandeAide,
);
})(); })();
} }
} else if (step === "npc-return" && !isStraightening) { } else if (step === "npc-return" && !isStraightening) {
@@ -1,3 +1,4 @@
import { useEffect } from "react";
import { useGameStore } from "@/managers/stores/useGameStore"; import { useGameStore } from "@/managers/stores/useGameStore";
import { useDialoguePlayback } from "@/hooks/gameplay/useDialoguePlayback"; import { useDialoguePlayback } from "@/hooks/gameplay/useDialoguePlayback";
import { ZoneDetection } from "@/components/zone/ZoneDetection"; import { ZoneDetection } from "@/components/zone/ZoneDetection";
@@ -28,6 +29,16 @@ export function PylonNarrativeFlow(): React.JSX.Element | null {
onComplete: () => completeMission("pylon"), 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 (mainState !== "pylon") return null;
if (step === "locked") { if (step === "locked") {
@@ -219,7 +219,11 @@ export function RepairCaseModel({
parsedScale[2] * pop.current.scale, 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[] = []; const placeholders: RepairCasePlaceholder[] = [];
placeholderNodes.current.forEach((child) => { placeholderNodes.current.forEach((child) => {
child.getWorldPosition(placeholderPosition.current); child.getWorldPosition(placeholderPosition.current);
+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}
@@ -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;
}
@@ -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)); } else {
return currentIndex; 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 }),
},
),
);
+131 -18
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,21 +275,46 @@ export function HomePage(): React.JSX.Element | null {
return ( return (
<HandTrackingProvider> <HandTrackingProvider>
<Canvas {showRepairScene && renderedMission !== null ? (
camera={{ position: [85, 60, 85], fov: 42 }} /* Isolated repair scene — no map, no player, no physics world.
shadows={{ type: THREE.PCFShadowMap }} Unmounting the main Canvas here frees the full GPU budget. */
gl={{ <RepairGameScene mission={renderedMission} />
powerPreference: "high-performance", ) : (
antialias: true, <Canvas
stencil: false, camera={{ position: [85, 60, 85], fov: 42 }}
shadows={{ type: THREE.PCFShadowMap }}
gl={{
powerPreference: "high-performance",
antialias: true,
stencil: false,
}}
onCreated={handleCanvasCreated}
>
<Suspense fallback={null}>
<World onLoadingStateChange={handleSceneLoadingStateChange} />
<DebugPerf />
</Suspense>
</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",
}} }}
onCreated={handleCanvasCreated}
> >
<Suspense fallback={null}> {isFading ? <AppLoadingIndicator floating /> : null}
<World onLoadingStateChange={handleSceneLoadingStateChange} /> </div>
<DebugPerf />
</Suspense>
</Canvas>
<GameUI /> <GameUI />
{dialogMessage ? ( {dialogMessage ? (
<DialogMessage <DialogMessage
+18 -19
View File
@@ -1,22 +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 { 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";
@@ -83,14 +79,18 @@ 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 (
<> <>
{/* 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" ? (
<RepairGamePreloader mission="pylon" />
) : null}
{mainState === "pylon" ? <RepairGamePreloader mission="farm" /> : null}
{mainState === "intro" ? <StageAnchor {...INTRO_STAGE_ANCHOR} /> : null} {mainState === "intro" ? <StageAnchor {...INTRO_STAGE_ANCHOR} /> : null}
<Ebike position={EBIKE_WORLD_POSITION} /> <Ebike position={EBIKE_WORLD_POSITION} />
<PylonDownedPylon /> <PylonDownedPylon />
@@ -101,14 +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 (mission === "pylon" && pylonInNarrative) return null; RepairGameScene instead, freeing all map/character VRAM. */}
return (
<RepairGame key={mission} mission={mission} position={position} /> {/* Trigger sphere that starts the ebike repair (locked → waiting).
); The repair scene swap is then handled by useRepairGameStatus. */}
})}
{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>
);
}
+27 -5
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
? PLAYER_SPAWN_POSITION_GAME // character on every prop change). If the player returns from a repair
: PLAYER_SPAWN_POSITION_PHYSICS; // 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 = const showHandTrackingGloves =
sceneMode === "physics" || sceneMode === "physics" ||
(status !== "idle" && usageStatus !== "inactive"); (status !== "idle" && usageStatus !== "inactive");