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 (
+
+ );
+}
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({
/>
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()}