diff --git a/public/configuration/asset-loader.js b/public/configuration/asset-loader.js
index 7483733..8a4a916 100644
--- a/public/configuration/asset-loader.js
+++ b/public/configuration/asset-loader.js
@@ -57,7 +57,10 @@
const renderShell = () => {
const root = document.getElementById("root");
if(!root || root.firstChild) return;
- root.innerHTML = '
';
+ // Match the React LoadingView background so the pre-React shell paints
+ // the same gradient — no light-blue login-skeleton flash before the
+ // loader takes over.
+ root.innerHTML = '';
};
const decodeAsset = (bytes) => {
diff --git a/public/configuration/renderer-config.example b/public/configuration/renderer-config.example
index cff14e5..4d8fd1f 100644
--- a/public/configuration/renderer-config.example
+++ b/public/configuration/renderer-config.example
@@ -48,6 +48,26 @@
"timezone.settings": "Europe/Amsterdam",
"youtube.publish.disabled": false,
"user.badges.group.slot.enabled": true,
+
+ "_comment_loading_screen": "Schermata di caricamento — sostituibili per traduzione/branding. Logo, sfondo e colore della barra usano lo standard CSS (URL, gradient, colore esadecimale). Le label compaiono sotto la barra di progresso man mano che ogni fase del boot completa.",
+ "loading.logo.url": "",
+ "loading.background": "",
+ "loading.progress.color": "linear-gradient(90deg,#4f8cff,#2563eb)",
+ "loading.task.boot": "Avvio in corso...",
+ "loading.task.session": "Verifica sessione",
+ "loading.task.renderer": "Inizializzazione renderer",
+ "loading.task.warmup": "Caricamento contenuti...",
+ "loading.task.assets": "Sto caricando gli asset di gioco",
+ "loading.task.localization": "Sto caricando le traduzioni",
+ "loading.task.avatar": "Sto caricando il guardaroba",
+ "loading.task.sounds": "Sto caricando i suoni",
+ "loading.task.startsession": "Avvio sessione",
+ "loading.task.userdata": "Caricamento dati utente",
+ "loading.task.rooms": "Caricamento stanze",
+ "loading.task.engine": "Caricamento engine grafico",
+ "loading.task.connect": "Connessione al server",
+ "loading.task.ready": "Pronto!",
+
"login.screen.enabled": true,
"login.endpoint": "${api.url}/api/auth/login",
"login.register.endpoint": "${api.url}/api/auth/register",
diff --git a/scripts/write-asset-loader.mjs b/scripts/write-asset-loader.mjs
index 4b082fc..44976c6 100644
--- a/scripts/write-asset-loader.mjs
+++ b/scripts/write-asset-loader.mjs
@@ -228,7 +228,10 @@ const ASSET_LOADER_JS = `(() => {
const renderShell = () => {
const root = document.getElementById("root");
if(!root || root.firstChild) return;
- root.innerHTML = '';
+ // Match the React LoadingView background so the pre-React shell paints
+ // the same gradient — no light-blue login-skeleton flash before the
+ // loader takes over.
+ root.innerHTML = '';
};
const decodeAsset = (bytes) => {
diff --git a/src/App.tsx b/src/App.tsx
index 6c3f00f..6bf4d4b 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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(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>(null);
const rendererPromiseRef = useRef>(null);
const gameInitPromiseRef = useRef | 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('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('system.fps.max', 24);
NitroLogger.LOG_DEBUG = GetConfiguration().getValue('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; 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(() =>
{
@@ -434,12 +484,18 @@ export const App: FC<{}> = props =>
urlSso: 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!');
let ssoTicket = window.NitroConfig['sso.ticket'];
if(ssoTicket) GetConfiguration().setValue('sso.ticket', ssoTicket);
+ bumpProgress(10, taskLabel('loading.task.session', 'Verifica sessione'));
if(!ssoTicket || ssoTicket === '')
{
@@ -506,17 +562,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 +607,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 +644,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 (
{ !isReady && !showLogin &&
- 0 } message={ errorMessage } homeUrl={ homeUrl } /> }
+ 0 } message={ errorMessage } homeUrl={ homeUrl } progress={ loadingProgress } currentTask={ loadingTask } /> }
{ !isReady && showLogin && }
{ isReady && }
{ /* Reconnect overlay must NOT render before we've actually entered
diff --git a/src/components/avatar-effects/AvatarEffectsView.tsx b/src/components/avatar-effects/AvatarEffectsView.tsx
index 887b9fb..eed77d8 100644
--- a/src/components/avatar-effects/AvatarEffectsView.tsx
+++ b/src/components/avatar-effects/AvatarEffectsView.tsx
@@ -1,4 +1,5 @@
import { AddLinkEventTracker, AvatarDirectionAngle, AvatarEffectActivatedComposer, GetConfiguration, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
+import { loadGamedata } from '@nitrots/utils';
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 +66,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)
diff --git a/src/components/loading/LoadingView.tsx b/src/components/loading/LoadingView.tsx
index 30e3c0c..f0542dd 100644
--- a/src/components/loading/LoadingView.tsx
+++ b/src/components/loading/LoadingView.tsx
@@ -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(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(key, '');
+ if(!raw) return fallback;
+ return raw;
+ }
+ catch
+ {
+ return fallback;
+ }
+};
+
export const LoadingView: FC = 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 (
-
+
+
-
{ isError && (message && message.length) ?
@@ -32,15 +87,59 @@ export const LoadingView: FC = props =>
}
:
-
-
- { message && message.length ?
-
- { message }
-
- : null
+ <>
+
+
+ { message && message.length ?
+
+ { message }
+
+ : null
+ }
+
+ { clampedProgress !== null &&
+
+
+
+
+ { clampedProgress }%
+
+
+
+ { currentTask }
+
+
}
-
+ >
}