feat(ui): add transient loading indicator
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
This commit is contained in:
@@ -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 (
|
||||
<div className={classes} role="status" aria-live="polite">
|
||||
<span>Loading...</span>
|
||||
<svg
|
||||
className="app-loading-indicator__spinner"
|
||||
viewBox="0 0 32 32"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M16 3a13 13 0 1 1-9.2 3.8"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeWidth="3.5"
|
||||
/>
|
||||
<path
|
||||
d="M6.8 6.8V2.8H2.8"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="3.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
/>
|
||||
<div className="scene-loading-overlay__footer">
|
||||
<div className="scene-loading-overlay__meta">
|
||||
<div className="scene-loading-overlay__label">
|
||||
<span>Loading...</span>
|
||||
<svg
|
||||
className="scene-loading-overlay__spinner"
|
||||
viewBox="0 0 32 32"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M16 3a13 13 0 1 1-9.2 3.8"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeWidth="3.5"
|
||||
/>
|
||||
<path
|
||||
d="M6.8 6.8V2.8H2.8"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="3.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<AppLoadingIndicator className="scene-loading-overlay__label" />
|
||||
<strong>{progress}%</strong>
|
||||
</div>
|
||||
<div className="scene-loading-overlay__track">
|
||||
|
||||
@@ -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<number | null>(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 };
|
||||
}
|
||||
+35
-9
@@ -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);
|
||||
}
|
||||
|
||||
+44
-4
@@ -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<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()) {
|
||||
@@ -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 <FadeToVideoOverlay />;
|
||||
@@ -173,9 +210,12 @@ export function HomePage(): React.JSX.Element | null {
|
||||
onClose={hideDialog}
|
||||
/>
|
||||
) : null}
|
||||
{(introStep === "loading-map" || introStep === "fade-to-video") && (
|
||||
{showSceneLoadingOverlay ? (
|
||||
<SceneLoadingOverlay state={sceneLoadingState} />
|
||||
)}
|
||||
) : null}
|
||||
{showTransientLoading && !showSceneLoadingOverlay ? (
|
||||
<AppLoadingIndicator floating />
|
||||
) : null}
|
||||
{renderIntroOverlay()}
|
||||
<EbikeIntroSequence />
|
||||
</HandTrackingProvider>
|
||||
|
||||
Reference in New Issue
Block a user