Files
La-Fabrik/src/pages/page.tsx
T
Tom Boullay de77f76d48 fix(world): restore shadow auto-update
Reverts the manual shadow refresh throttle introduced in 6d58b90 which
prevented shadows from rendering. Renderer and sun shadow now use
autoUpdate=true and a per-frame needsUpdate=true pulse, matching the
behaviour that produced visible shadows before that commit.
2026-06-01 11:28:07 +02:00

226 lines
7.7 KiB
TypeScript

import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useNavigate } from "@tanstack/react-router";
import { Canvas } from "@react-three/fiber";
import * as THREE from "three";
import { DebugPerf } from "@/components/debug/DebugPerf";
import { EbikeIntroSequence } from "@/components/game/EbikeIntroSequence";
import { AppLoadingIndicator } from "@/components/ui/AppLoadingIndicator";
import { DialogMessage } from "@/components/ui/DialogMessage";
import { GameUI } from "@/components/ui/GameUI";
import {
FadeToVideoOverlay,
IntroDialogueOverlay,
IntroRevealOverlay,
IntroVideoPlayer,
} from "@/components/ui/intro";
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
import { INITIAL_SCENE_LOADING_STATE } from "@/data/world/sceneLoadingConfig";
import { useTransientLoadingIndicator } from "@/hooks/ui/useTransientLoadingIndicator";
import { AudioManager } from "@/managers/AudioManager";
import { useGameStore } from "@/managers/stores/useGameStore";
import { useWorldSettingsStore } from "@/managers/stores/useWorldSettingsStore";
import { HandTrackingProvider } from "@/providers/gameplay/HandTrackingProvider";
import type { SceneLoadingState } from "@/types/world/sceneLoading";
import { hasSiteBeenVisitedToday } from "@/utils/cookies/siteVisitCookie";
import { logger } from "@/utils/core/Logger";
import { World } from "@/world/World";
const LOADING_TO_VIDEO_FADE_MS = 500;
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,
);
const dialogMessage = useGameStore(
(state) => state.missionFlow.dialogMessage,
);
const hideDialog = useGameStore((state) => state.hideDialog);
const { showLoading, visible: showTransientLoading } =
useTransientLoadingIndicator();
const [sceneLoadingState, setSceneLoadingState] = useState<SceneLoadingState>(
INITIAL_SCENE_LOADING_STATE,
);
const sceneReadyRef = useRef(false);
const runtimeLoadingSignal = `${graphicsPreset}:${mainState}:${ebikeStep}:${pylonStep}:${farmStep}`;
const previousRuntimeLoadingSignalRef = useRef(runtimeLoadingSignal);
useEffect(() => {
sceneReadyRef.current = sceneLoadingState.status === "ready";
}, [sceneLoadingState.status]);
useEffect(() => {
if (!hasSiteBeenVisitedToday()) {
navigate({ to: "/site", replace: true });
}
}, [navigate]);
useEffect(() => {
if (!dialogMessage) return undefined;
const timeoutId = window.setTimeout(() => {
hideDialog();
}, 3000);
return () => {
window.clearTimeout(timeoutId);
};
}, [dialogMessage, hideDialog]);
const handleSceneLoadingStateChange = useCallback(
(nextState: SceneLoadingState) => {
if (sceneReadyRef.current && nextState.status === "loading") {
showLoading();
return;
}
setSceneLoadingState((currentState) => {
if (currentState.status === "ready" && nextState.status === "loading") {
return currentState;
}
return {
...nextState,
progress: Math.max(currentState.progress, nextState.progress),
};
});
},
[showLoading],
);
useEffect(() => {
if (previousRuntimeLoadingSignalRef.current === runtimeLoadingSignal) {
return;
}
previousRuntimeLoadingSignalRef.current = runtimeLoadingSignal;
if (sceneLoadingState.status !== "ready") return;
showLoading();
}, [runtimeLoadingSignal, sceneLoadingState.status, showLoading]);
useEffect(() => {
if (introStep === "loading-map" && sceneLoadingState.status === "ready") {
AudioManager.getInstance().stopMusic();
setIntroStep("fade-to-video");
}
}, [introStep, sceneLoadingState.status, setIntroStep]);
useEffect(() => {
if (introStep !== "fade-to-video") return undefined;
const timeoutId = window.setTimeout(() => {
setIntroStep("video");
}, LOADING_TO_VIDEO_FADE_MS);
return () => {
window.clearTimeout(timeoutId);
};
}, [introStep, setIntroStep]);
const handleCanvasCreated = useCallback(
({ gl }: { gl: THREE.WebGLRenderer }) => {
const canvas = gl.domElement;
gl.shadowMap.enabled = true;
gl.shadowMap.type = THREE.PCFShadowMap;
gl.shadowMap.autoUpdate = true;
gl.shadowMap.needsUpdate = true;
// The browser hands us a WEBGL_lose_context extension we can use to
// ask the GPU to restore the context after a loss. Without this the
// page stays frozen on a black canvas until the user reloads.
const loseContextExt = gl.getContext().getExtension("WEBGL_lose_context");
const handleContextLost = (event: Event) => {
event.preventDefault();
logger.error("WebGL", "Context lost - attempting auto-restore");
// Give the GPU a moment to free resources before asking it back.
window.setTimeout(() => loseContextExt?.restoreContext(), 500);
};
const handleContextRestored = () => {
gl.shadowMap.enabled = true;
gl.shadowMap.type = THREE.PCFShadowMap;
gl.shadowMap.autoUpdate = true;
gl.shadowMap.needsUpdate = true;
logger.info("WebGL", "Context restored");
};
canvas.addEventListener("webglcontextlost", handleContextLost);
canvas.addEventListener("webglcontextrestored", handleContextRestored);
},
[],
);
// Don't mount the Canvas until we know we will not redirect to /site.
// Without this guard the Canvas would mount, the effect above would fire
// navigate, and the Canvas would unmount mid-load — leaking GLTF requests
// and a WebGL context. The synchronous cookie check happens here AFTER
// all hooks (rules of hooks) but BEFORE any expensive render.
if (!hasSiteBeenVisitedToday()) return null;
const showFadeToVideoOverlay =
introStep === "fade-to-video" ||
(introStep === "loading-map" && sceneLoadingState.status === "ready");
const showSceneLoadingOverlay =
introStep === "loading-map" || introStep === "fade-to-video";
const renderIntroOverlay = () => {
if (showFadeToVideoOverlay) return <FadeToVideoOverlay />;
switch (introStep) {
case "video":
return <IntroVideoPlayer />;
case "dialogue-intro":
return <IntroDialogueOverlay />;
case "reveal":
return <IntroRevealOverlay />;
default:
return null;
}
};
return (
<HandTrackingProvider>
<Canvas
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>
<GameUI />
{dialogMessage ? (
<DialogMessage
message={dialogMessage}
duration={3000}
onClose={hideDialog}
/>
) : null}
{showSceneLoadingOverlay ? (
<SceneLoadingOverlay state={sceneLoadingState} />
) : null}
{showTransientLoading && !showSceneLoadingOverlay ? (
<AppLoadingIndicator floating />
) : null}
{renderIntroOverlay()}
<EbikeIntroSequence />
</HandTrackingProvider>
);
}