From 51569af7b80edef2d0f5392916a9a81f98f1294f Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Sun, 31 May 2026 22:43:48 +0200 Subject: [PATCH] feat(ui): add transient loading indicator --- docs/technical/scene-runtime.md | 2 + public/models/lafabrik/model.glb | 4 +- src/components/ui/AppLoadingIndicator.tsx | 44 ++++++++++++++++++ src/components/ui/SceneLoadingOverlay.tsx | 26 +---------- src/hooks/ui/useTransientLoadingIndicator.ts | 36 +++++++++++++++ src/index.css | 44 ++++++++++++++---- src/pages/page.tsx | 48 ++++++++++++++++++-- 7 files changed, 165 insertions(+), 39 deletions(-) create mode 100644 src/components/ui/AppLoadingIndicator.tsx create mode 100644 src/hooks/ui/useTransientLoadingIndicator.ts diff --git a/docs/technical/scene-runtime.md b/docs/technical/scene-runtime.md index fcbe033..08f38e2 100644 --- a/docs/technical/scene-runtime.md +++ b/docs/technical/scene-runtime.md @@ -32,6 +32,8 @@ The loading progress in `HomePage` is monotonic: This prevents the overlay from jumping backward when nested loaders finish in a slightly different order. +After the initial map boot is complete, late loading signals no longer reopen the full-screen loading overlay. Instead, `HomePage` shows the compact `AppLoadingIndicator` while the game remains visible. This is reserved for explicit runtime reload signals such as graphics preset changes, repair-state transitions, or late world loading events; chunk streaming intentionally does not drive this indicator. + ## World Composition `src/world/World.tsx` is the main scene composer. diff --git a/public/models/lafabrik/model.glb b/public/models/lafabrik/model.glb index 45e16e1..bf3a072 100644 --- a/public/models/lafabrik/model.glb +++ b/public/models/lafabrik/model.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ba5c4ef79a0087c66799f8a8f82c92966b24f9bad618af495815c79dbccd9948 -size 5755772 +oid sha256:483551409e1f08a420ef7d4410ef7a9a8deb2efc8ea1e79ae6e1bd207d487a1b +size 88598988 diff --git a/src/components/ui/AppLoadingIndicator.tsx b/src/components/ui/AppLoadingIndicator.tsx new file mode 100644 index 0000000..eeec7fd --- /dev/null +++ b/src/components/ui/AppLoadingIndicator.tsx @@ -0,0 +1,44 @@ +interface AppLoadingIndicatorProps { + className?: string | undefined; + floating?: boolean; +} + +export function AppLoadingIndicator({ + className, + floating = false, +}: AppLoadingIndicatorProps): React.JSX.Element { + const classes = [ + "app-loading-indicator", + floating ? "app-loading-indicator--floating" : null, + className, + ] + .filter(Boolean) + .join(" "); + + return ( +
+ Loading... + +
+ ); +} diff --git a/src/components/ui/SceneLoadingOverlay.tsx b/src/components/ui/SceneLoadingOverlay.tsx index b58dbd8..831e646 100644 --- a/src/components/ui/SceneLoadingOverlay.tsx +++ b/src/components/ui/SceneLoadingOverlay.tsx @@ -1,3 +1,4 @@ +import { AppLoadingIndicator } from "@/components/ui/AppLoadingIndicator"; import type { SceneLoadingState } from "@/types/world/sceneLoading"; const LOADING_BACKGROUND_PATH = "/assets/bg-site.png"; @@ -36,30 +37,7 @@ export function SceneLoadingOverlay({ />
-
- Loading... - -
+ {progress}%
diff --git a/src/hooks/ui/useTransientLoadingIndicator.ts b/src/hooks/ui/useTransientLoadingIndicator.ts new file mode 100644 index 0000000..59e8e40 --- /dev/null +++ b/src/hooks/ui/useTransientLoadingIndicator.ts @@ -0,0 +1,36 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +const DEFAULT_LOADING_DURATION_MS = 900; + +export function useTransientLoadingIndicator(): { + showLoading: (durationMs?: number) => void; + visible: boolean; +} { + const [visible, setVisible] = useState(false); + const timeoutRef = useRef(null); + + const showLoading = useCallback( + (durationMs = DEFAULT_LOADING_DURATION_MS) => { + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + } + + setVisible(true); + timeoutRef.current = window.setTimeout(() => { + setVisible(false); + timeoutRef.current = null; + }, durationMs); + }, + [], + ); + + useEffect(() => { + return () => { + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + } + }; + }, []); + + return { showLoading, visible }; +} diff --git a/src/index.css b/src/index.css index 296de99..0aed3d3 100644 --- a/src/index.css +++ b/src/index.css @@ -869,6 +869,40 @@ canvas { box-shadow: 0 0 14px rgba(56, 189, 248, 0.86); } +.app-loading-indicator { + display: inline-flex; + align-items: center; + gap: clamp(8px, 1.2vw, 14px); + min-width: 0; + color: inherit; + font: inherit; + letter-spacing: inherit; + line-height: 1; + text-transform: inherit; +} + +.app-loading-indicator--floating { + position: fixed; + bottom: clamp(22px, 5vh, 48px); + left: clamp(18px, 4vw, 56px); + z-index: 45; + color: #ffffff; + font-family: "Nersans One", "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: clamp(16px, 2.3vw, 30px); + letter-spacing: 0.12em; + pointer-events: none; + text-shadow: 0 2px 14px rgba(0, 0, 0, 0.45); + text-transform: uppercase; +} + +.app-loading-indicator__spinner { + flex: 0 0 auto; + width: clamp(18px, 2.2vw, 30px); + height: clamp(18px, 2.2vw, 30px); + color: currentColor; + animation: app-loading-spin 900ms linear infinite; +} + .scene-loading-overlay { position: fixed; inset: 0; @@ -947,14 +981,6 @@ canvas { min-width: 0; } -.scene-loading-overlay__spinner { - flex: 0 0 auto; - width: clamp(18px, 2.2vw, 30px); - height: clamp(18px, 2.2vw, 30px); - color: #ffffff; - animation: scene-loading-spin 900ms linear infinite; -} - .scene-loading-overlay__meta strong { color: inherit; font: inherit; @@ -976,7 +1002,7 @@ canvas { transition: width 180ms ease; } -@keyframes scene-loading-spin { +@keyframes app-loading-spin { to { transform: rotate(360deg); } diff --git a/src/pages/page.tsx b/src/pages/page.tsx index d8b2a84..6d9d752 100644 --- a/src/pages/page.tsx +++ b/src/pages/page.tsx @@ -1,9 +1,10 @@ -import { Suspense, useCallback, useEffect, useState } from "react"; +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 { @@ -14,8 +15,10 @@ import { } 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"; @@ -26,15 +29,31 @@ 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( 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()) { @@ -56,6 +75,11 @@ export function HomePage(): React.JSX.Element | null { const handleSceneLoadingStateChange = useCallback( (nextState: SceneLoadingState) => { + if (sceneReadyRef.current && nextState.status === "loading") { + showLoading(); + return; + } + setSceneLoadingState((currentState) => { if (currentState.status === "ready" && nextState.status === "loading") { return currentState; @@ -67,9 +91,20 @@ export function HomePage(): React.JSX.Element | null { }; }); }, - [], + [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(); @@ -132,6 +167,8 @@ export function HomePage(): React.JSX.Element | 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 ; @@ -173,9 +210,12 @@ export function HomePage(): React.JSX.Element | null { onClose={hideDialog} /> ) : null} - {(introStep === "loading-map" || introStep === "fade-to-video") && ( + {showSceneLoadingOverlay ? ( - )} + ) : null} + {showTransientLoading && !showSceneLoadingOverlay ? ( + + ) : null} {renderIntroOverlay()}