Merge pull request #146 from medievalshell/Dev

feat(loading): redesigned loader with progress bar, task labels, configurable assets + perf(build): granular code-split + preconnect hint for cold-load speed + docs: PERFORMANCE.md — client + server recipe for the 4s cold load
This commit is contained in:
DuckieTM
2026-05-21 07:39:32 +02:00
committed by GitHub
10 changed files with 889 additions and 51 deletions
+120 -27
View File
@@ -1,6 +1,6 @@
import { GetAssetManager, GetAvatarRenderManager, GetCommunication, GetConfiguration, GetLocalizationManager, GetRoomEngine, GetRoomSessionManager, GetSessionDataManager, GetSoundManager, GetStage, GetTexturePool, GetTicker, HabboWebTools, LegacyExternalInterface, LoadGameUrlEvent, NitroEventType, NitroLogger, NitroVersion, PrepareRenderer } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useEffectEvent, useRef, useState } from 'react';
import { ClearRememberLogin, GetRememberLogin, GetUIVersion, StoreRememberLoginFromPayload, persistAccessTokenFromPayload } from './api';
import { ClearRememberLogin, GetRememberLogin, GetUIVersion, SetRememberLogin, StoreRememberLoginFromPayload, persistAccessTokenFromPayload } from './api';
import { Base } from './common';
import { LoadingView } from './components/loading/LoadingView';
import { LoginView } from './components/login/LoginView';
@@ -72,6 +72,29 @@ export const App: FC<{}> = props =>
const [ showLogin, setShowLogin ] = useState(false);
const [ isEnteringHotel, setIsEnteringHotel ] = useState(() => !!window.NitroConfig?.['sso.ticket'] || hasRememberLogin());
const [ prepareTrigger, setPrepareTrigger ] = useState(0);
const [ loadingProgress, setLoadingProgress ] = useState(0);
const [ loadingTask, setLoadingTask ] = useState('');
// Look up a loader-stage label from renderer-config so the strings the user
// sees during the boot ("Sto caricando il guardaroba", "Connessione…") can
// be translated by editing the JSON/JSON5 config — fallback keeps the
// Italian baseline shipped with the client.
const taskLabel = useCallback((key: string, fallback: string): string =>
{
try
{
const raw = GetConfiguration().getValue<string>(key, '');
return (typeof raw === 'string' && raw.length) ? raw : fallback;
}
catch
{
return fallback;
}
}, []);
const bumpProgress = useCallback((value: number, task?: string) =>
{
setLoadingProgress(prev => (value > prev ? value : prev));
if(task !== undefined) setLoadingTask(task);
}, []);
const warmupPromiseRef = useRef<Promise<void>>(null);
const rendererPromiseRef = useRef<Promise<any>>(null);
const gameInitPromiseRef = useRef<Promise<void> | null>(null);
@@ -104,8 +127,36 @@ export const App: FC<{}> = props =>
catch {}
}, []);
const showSessionExpired = useCallback(() =>
{
console.warn('[App] showSessionExpired — diagnostic shown (mid-game close)');
clearStoredCredentials();
const baseUrl = window.location.origin + '/';
setHomeUrl(baseUrl);
setErrorMessage('Your session has expired.\nPlease log in again to enter the hotel.');
setIsReady(false);
setShowLogin(false);
setIsEnteringHotel(false);
}, [ clearStoredCredentials ]);
const fallbackToLogin = useCallback(() =>
{
// When login.screen.enabled is false this hotel uses SSO-only auth
// (CMS issues the ticket and redirects here). Surfacing a login form
// on init failure would just dump an empty/broken placeholder, since
// the form's backgrounds and Turnstile aren't even configured. Send
// the user back to the hotel home page instead.
const rawLoginEnabled = GetConfiguration().getValue<unknown>('login.screen.enabled', false);
const loginScreenEnabled = rawLoginEnabled === true || rawLoginEnabled === 'true' || rawLoginEnabled === 1;
if(!loginScreenEnabled)
{
console.warn('[App] fallbackToLogin — login.screen.enabled=false, redirecting to home instead');
showSessionExpired();
return;
}
// Using console.warn (not NitroLogger.log) on purpose: NitroLogger
// is gated on LOG_DEBUG, which only flips to true once startWarmup's
// GetConfiguration().init() completes. Auth-failure paths fire before
@@ -119,20 +170,7 @@ export const App: FC<{}> = props =>
setIsReady(false);
setShowLogin(true);
setIsEnteringHotel(false);
}, [ clearStoredCredentials ]);
const showSessionExpired = useCallback(() =>
{
console.warn('[App] showSessionExpired — diagnostic shown (mid-game close)');
clearStoredCredentials();
const baseUrl = window.location.origin + '/';
setHomeUrl(baseUrl);
setErrorMessage('Your session has expired.\nPlease log in again to enter the hotel.');
setIsReady(false);
setShowLogin(false);
setIsEnteringHotel(false);
}, [ clearStoredCredentials ]);
}, [ clearStoredCredentials, showSessionExpired ]);
const applySsoTicket = useCallback((ssoTicket: string) =>
{
@@ -352,6 +390,7 @@ export const App: FC<{}> = props =>
warmupPromiseRef.current = (async () =>
{
await GetConfiguration().init();
bumpProgress(25, taskLabel('loading.task.warmup', 'Caricamento contenuti...'));
GetTicker().maxFPS = GetConfiguration().getValue<number>('system.fps.max', 24);
NitroLogger.LOG_DEBUG = GetConfiguration().getValue<boolean>('system.log.debug', true);
@@ -388,18 +427,29 @@ export const App: FC<{}> = props =>
loginImageUrls.forEach(preloadImage);
gamedataUrls.forEach(url => preloadUrl(url));
await Promise.all(
[
GetAssetManager().downloadAssets(assetUrls),
GetLocalizationManager().init(),
GetAvatarRenderManager().init(),
GetSoundManager().init()
]
);
// Wire each warmup task to a progress bump so the bar reflects
// real subsystem-init completion, not a fake timer. Range 25→70.
// Each task carries a friendly label so the user sees what is
// currently being prepared instead of raw file names.
const warmupTasks: { promise: Promise<any>; label: string }[] = [
{ promise: GetAssetManager().downloadAssets(assetUrls), label: taskLabel('loading.task.assets', 'Sto caricando gli asset di gioco') },
{ promise: GetLocalizationManager().init(), label: taskLabel('loading.task.localization', 'Sto caricando le traduzioni') },
{ promise: GetAvatarRenderManager().init(), label: taskLabel('loading.task.avatar', 'Sto caricando il guardaroba') },
{ promise: GetSoundManager().init(), label: taskLabel('loading.task.sounds', 'Sto caricando i suoni') }
];
let warmupDone = 0;
const warmupStart = 25;
const warmupSpan = 45;
await Promise.all(warmupTasks.map(t => t.promise.then(value =>
{
warmupDone++;
bumpProgress(warmupStart + Math.round((warmupSpan * warmupDone) / warmupTasks.length), t.label);
return value;
})));
})();
return warmupPromiseRef.current;
}, [ startRenderer ]);
}, [ startRenderer, bumpProgress, taskLabel ]);
useEffect(() =>
{
@@ -427,13 +477,23 @@ export const App: FC<{}> = props =>
{
const prepare = async (width: number, height: number) =>
{
// Don't dump the actual SSO ticket — it's a one-shot bearer
// credential that grants access to the user's session, so
// logging it in console.warn would leak it via copied logs
// / screen shares / browser extension hooks. Boolean flag is
// enough for the diagnostic.
console.warn('[App] prepare() start', {
hasNitroConfig: !!window.NitroConfig,
ssoTicketInConfig: !!window.NitroConfig?.['sso.ticket'],
hasRememberLocal: !!GetRememberLogin(),
urlSso: new URLSearchParams(window.location.search).get('sso')
hasUrlSso: !!new URLSearchParams(window.location.search).get('sso')
});
const bootLabel = taskLabel('loading.task.boot', 'Avvio in corso...');
setLoadingProgress(0);
setLoadingTask(bootLabel);
bumpProgress(5, bootLabel);
try
{
if(!window.NitroConfig) throw new Error('NitroConfig is not defined!');
@@ -441,6 +501,32 @@ export const App: FC<{}> = props =>
let ssoTicket = window.NitroConfig['sso.ticket'];
if(ssoTicket) GetConfiguration().setValue('sso.ticket', ssoTicket);
// Cattura il remember-token passato via URL (?token=&token_exp=)
// dal CMS Inertia /client e salvalo in localStorage. Serve a
// tryRememberLogin() in reconnect: chiama POST /api/auth/remember
// col token UUID, riceve un nuovo SSO ticket fresco invece di
// riusare quello cleared da Arcturus dopo il primo consume.
try
{
const urlParams = new URLSearchParams(window.location.search);
const tokenParam = urlParams.get('token');
const tokenExpParam = urlParams.get('token_exp');
if(tokenParam && !GetRememberLogin())
{
const parsedExpiry = Number(tokenExpParam || 0);
const expiresAt = (Number.isFinite(parsedExpiry) && parsedExpiry > 0)
? parsedExpiry
: Math.floor(Date.now() / 1000) + (30 * 24 * 60 * 60);
SetRememberLogin({ token: tokenParam, expiresAt });
}
}
catch(e)
{
console.warn('[App] failed to persist remember token from URL', e);
}
bumpProgress(10, taskLabel('loading.task.session', 'Verifica sessione'));
if(!ssoTicket || ssoTicket === '')
{
// Configuration is loaded lazily — fetch it up-front so the login
@@ -506,17 +592,23 @@ export const App: FC<{}> = props =>
}
const renderer = await startRenderer(width, height);
bumpProgress(20, taskLabel('loading.task.renderer', 'Inizializzazione renderer'));
await startWarmup(width, height);
bumpProgress(70, taskLabel('loading.task.startsession', 'Avvio sessione'));
if(!gameInitPromiseRef.current)
{
gameInitPromiseRef.current = (async () =>
{
await GetSessionDataManager().init();
bumpProgress(78, taskLabel('loading.task.userdata', 'Caricamento dati utente'));
await GetRoomSessionManager().init();
bumpProgress(85, taskLabel('loading.task.rooms', 'Caricamento stanze'));
await GetRoomEngine().init();
bumpProgress(92, taskLabel('loading.task.engine', 'Caricamento engine grafico'));
await GetCommunication().init();
bumpProgress(98, taskLabel('loading.task.connect', 'Connessione al server'));
})();
}
@@ -545,6 +637,7 @@ export const App: FC<{}> = props =>
GetTicker().add(ticker => GetTexturePool().run());
}
bumpProgress(100, taskLabel('loading.task.ready', 'Pronto!'));
setIsReady(true);
setShowLogin(false);
setIsEnteringHotel(false);
@@ -581,12 +674,12 @@ export const App: FC<{}> = props =>
if(heartbeatIntervalRef.current !== null) window.clearInterval(heartbeatIntervalRef.current);
if(rememberRotateIntervalRef.current !== null) window.clearInterval(rememberRotateIntervalRef.current);
};
}, [ prepareTrigger, startWarmup, startRenderer, tryRememberLogin, applySsoTicket, rotateRememberLogin ]);
}, [ prepareTrigger, startWarmup, startRenderer, tryRememberLogin, applySsoTicket, rotateRememberLogin, bumpProgress, taskLabel ]);
return (
<Base fit overflow="hidden" className={ `nitro-app-root ${ !(window.devicePixelRatio % 1) ? 'image-rendering-pixelated' : '' }` }>
{ !isReady && !showLogin &&
<LoadingView isError={ errorMessage.length > 0 } message={ errorMessage } homeUrl={ homeUrl } /> }
<LoadingView isError={ errorMessage.length > 0 } message={ errorMessage } homeUrl={ homeUrl } progress={ loadingProgress } currentTask={ loadingTask } /> }
{ !isReady && showLogin && <LoginView onAuthenticated={ handleAuthenticated } isEntering={ isEnteringHotel } /> }
{ isReady && <MainView /> }
{ /* Reconnect overlay must NOT render before we've actually entered
@@ -1,4 +1,4 @@
import { AddLinkEventTracker, AvatarDirectionAngle, AvatarEffectActivatedComposer, GetConfiguration, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { AddLinkEventTracker, AvatarDirectionAngle, AvatarEffectActivatedComposer, GetConfiguration, GetSessionDataManager, ILinkEventTracker, loadGamedata, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { ChangeEvent, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FaChevronLeft, FaChevronRight, FaSearch } from 'react-icons/fa';
import { LocalizeText, SendMessageComposer } from '../../api';
@@ -65,9 +65,11 @@ export const AvatarEffectsView: FC<{}> = () =>
{
try
{
const response = await fetch(url);
if(!response.ok) throw new Error(`HTTP ${ response.status }`);
const json = await response.json();
// The effectmap is served either as a single JSON file or as a
// tiered directory with core/custom/seasonal manifests using
// JSON5 syntax (// comments allowed). loadGamedata picks the
// right mode for us and merges tiers.
const json = await loadGamedata<{ effects?: EffectMapEntry[] }>(url);
if(cancelled) return;
const list: EffectMapEntry[] = Array.isArray(json?.effects)
+111 -12
View File
@@ -1,22 +1,77 @@
import { FC } from 'react';
import { GetConfiguration } from '@nitrots/nitro-renderer';
import { FC, useMemo } from 'react';
import loadingGif from '@/assets/images/loading/loading.gif';
import nitroV3Logo from '@/assets/images/notifications/nitro_v3.png';
import { Base, Column, Text } from '../../common';
interface LoadingViewProps {
isError?: boolean;
message?: string;
homeUrl?: string;
progress?: number;
currentTask?: string;
}
const resolveConfigUrl = (key: string): string =>
{
try
{
const raw = GetConfiguration().getValue<string>(key, '');
if(!raw) return '';
const interpolated = GetConfiguration().interpolate(raw) || raw;
return interpolated;
}
catch
{
return '';
}
};
const resolveConfigString = (key: string, fallback = ''): string =>
{
try
{
const raw = GetConfiguration().getValue<string>(key, '');
if(!raw) return fallback;
return raw;
}
catch
{
return fallback;
}
};
export const LoadingView: FC<LoadingViewProps> = props =>
{
const { isError = false, message = '', homeUrl = '' } = props;
const { isError = false, message = '', homeUrl = '', progress, currentTask = '' } = props;
const customLogoUrl = useMemo(() => resolveConfigUrl('loading.logo.url'), []);
const customBackground = useMemo(() => resolveConfigString('loading.background', ''), []);
const progressBarColor = useMemo(() => resolveConfigString('loading.progress.color', 'linear-gradient(90deg,#4f8cff,#2563eb)'), []);
const clampedProgress = typeof progress === 'number' && Number.isFinite(progress)
? Math.max(0, Math.min(100, Math.round(progress)))
: null;
const backgroundStyle = customBackground
? { background: customBackground }
: undefined;
const backgroundClassName = customBackground
? 'fixed inset-0 z-[2147483000]'
: 'fixed inset-0 z-[2147483000] bg-[radial-gradient(#1d1a24,#003a6b)]';
return (
<Column fullHeight position="fixed" className="fixed inset-0 z-[2147483000] bg-[radial-gradient(#1d1a24,#003a6b)]">
<Column fullHeight position="fixed" className={ backgroundClassName } style={ backgroundStyle }>
<img
src={ nitroV3Logo }
alt="Nitro V3"
draggable={ false }
className="absolute top-5 left-0 z-2 w-37.5 h-auto select-none pointer-events-none"
/>
<Base fullHeight className="container h-100">
<Column fullHeight alignItems="center" justifyContent="center">
<Base className="absolute top-[20px] left-[20px] z-[2] w-[150px] h-[100px] bg-[url('@/assets/images/notifications/nitro_v3.png')] bg-no-repeat bg-left-top" />
{ isError && (message && message.length) ?
<Column alignItems="center" className="absolute bottom-[20px] left-1/2 z-[3] -translate-x-1/2 max-w-[80%]" gap={ 2 }>
<Text fontSizeCustom={ 20 } variant="white" className="text-center [text-shadow:0px_4px_4px_rgba(0,0,0,0.25)]">
@@ -32,15 +87,59 @@ export const LoadingView: FC<LoadingViewProps> = props =>
}
</Column>
:
<Column alignItems="center" justifyContent="center" gap={ 3 } className="z-[3]">
<img src={ loadingGif } alt="" draggable={ false } className="block w-auto h-auto select-none pointer-events-none" />
{ message && message.length ?
<Text fontSizeCustom={ 20 } variant="white" className="text-center [text-shadow:0px_4px_4px_rgba(0,0,0,0.25)]">
{ message }
</Text>
: null
<>
<Column alignItems="center" justifyContent="center" className="z-[3] absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
<img
src={ customLogoUrl || loadingGif }
alt=""
draggable={ false }
className="block w-auto h-auto max-w-[80vw] max-h-[40vh] select-none pointer-events-none"
/>
{ message && message.length ?
<Text fontSizeCustom={ 22 } variant="white" className="text-center mt-4 [text-shadow:0px_4px_4px_rgba(0,0,0,0.4)] tracking-wide">
{ message }
</Text>
: null
}
</Column>
{ clampedProgress !== null &&
<Column
alignItems="center"
gap={ 2 }
className="absolute bottom-[8vh] left-1/2 -translate-x-1/2 z-[4] w-[min(900px,90vw)]"
>
<Base
className="relative w-full h-8 rounded-full overflow-hidden border border-white/30 shadow-[0_8px_24px_rgba(0,0,0,0.45)]"
style={ { background: 'rgba(0,0,0,0.45)', backdropFilter: 'blur(4px)' } }
>
<Base
className="h-full rounded-full transition-[width] duration-300 ease-out"
style={ { width: `${ clampedProgress }%`, background: progressBarColor, boxShadow: '0 0 18px rgba(79,140,255,0.55)' } }
/>
<Base
className="absolute inset-0 flex items-center justify-center pointer-events-none"
style={ { fontFamily: '"Poppins","Segoe UI",system-ui,sans-serif', fontWeight: 700, fontSize: '16px', color: '#fff', letterSpacing: '0.08em', textShadow: '0 2px 4px rgba(0,0,0,0.6)' } }
>
{ clampedProgress }%
</Base>
</Base>
<Base
className="text-center"
style={ {
fontFamily: '"Poppins","Segoe UI",system-ui,sans-serif',
fontWeight: 500,
fontSize: '15px',
color: 'rgba(255,255,255,0.85)',
letterSpacing: '0.04em',
textShadow: '0 2px 4px rgba(0,0,0,0.5)',
minHeight: '22px'
} }
>
{ currentTask }
</Base>
</Column>
}
</Column>
</>
}
</Column>
</Base>
@@ -63,6 +63,7 @@ const { fakeStore } = vi.hoisted(() =>
selectCatalogOffer: vi.fn(),
getNodeById: vi.fn(),
getNodeByName: vi.fn(),
getNodesByOfferId: vi.fn(),
getBuilderFurniPlaceableStatus: vi.fn()
};