Sync nautical startup loading screen

This commit is contained in:
Admin 2026-09-21 02:09:09 +00:00
parent 4299f9b0de
commit 6a02b0e9ae
4 changed files with 297 additions and 1 deletions

View File

@ -11,6 +11,178 @@
overscroll-behavior: none; overscroll-behavior: none;
} }
body.airship-loading #mobile-controls {
visibility: hidden;
}
#airship-loading-screen {
position: fixed;
inset: 0;
z-index: 2000;
display: grid;
place-items: center;
box-sizing: border-box;
padding:
max(24px, env(safe-area-inset-top))
max(22px, env(safe-area-inset-right))
max(28px, env(safe-area-inset-bottom))
max(22px, env(safe-area-inset-left));
background:
radial-gradient(circle at 50% 38%, rgba(15, 70, 77, .38), transparent 33%),
linear-gradient(180deg, #132837 0%, #0a1a25 52%, #07151e 100%);
color: #f7e3ad;
opacity: 1;
visibility: visible;
transition: opacity 360ms ease, visibility 360ms ease;
pointer-events: auto;
}
#airship-loading-screen::before {
content: '';
position: absolute;
inset: 0;
opacity: .22;
background-image:
linear-gradient(rgba(224, 177, 87, .08) 1px, transparent 1px),
linear-gradient(90deg, rgba(224, 177, 87, .06) 1px, transparent 1px);
background-size: 42px 42px;
mask-image: linear-gradient(to bottom, transparent, black 22%, black 78%, transparent);
pointer-events: none;
}
#airship-loading-screen.is-ready {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.airship-loader {
position: relative;
width: min(420px, 88vw);
display: grid;
justify-items: center;
gap: 18px;
text-align: center;
}
.airship-loader-mark {
position: relative;
width: clamp(108px, 27vw, 150px);
aspect-ratio: 1;
border: 4px solid #8e5c22;
border-radius: 50%;
background:
radial-gradient(circle, #0c4b50 0 42%, #082f35 43% 57%, #071c25 58%);
box-shadow:
0 0 0 3px #d1a24e,
0 0 0 7px #4e3217,
inset 0 0 28px rgba(0,0,0,.55),
0 14px 38px rgba(0,0,0,.42);
}
.airship-loader-mark::before,
.airship-loader-mark::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 72%;
height: 8px;
margin: -4px 0 0 -36%;
border-radius: 999px;
background: linear-gradient(90deg, #8d5d26, #f0cf77 46%, #8d5d26);
transform-origin: center;
animation: airship-loader-spin 2.8s linear infinite;
box-shadow: 0 1px 2px rgba(0,0,0,.5);
}
.airship-loader-mark::after {
animation-direction: reverse;
animation-duration: 4.2s;
transform: rotate(90deg);
}
.airship-loader-hub {
position: absolute;
inset: 36%;
z-index: 2;
border: 3px solid #61401d;
border-radius: 50%;
background: radial-gradient(circle at 38% 35%, #f0d179, #a66c29 64%, #573516 100%);
box-shadow: 0 0 0 2px #e0b65d, 0 3px 8px rgba(0,0,0,.45);
}
@keyframes airship-loader-spin {
to { transform: rotate(360deg); }
}
.airship-loader-kicker {
margin: 4px 0 -8px;
color: #a8c6c9;
font: 700 11px/1.2 ui-sans-serif, system-ui, sans-serif;
letter-spacing: .34em;
}
.airship-loader h1 {
margin: 0;
font: 700 clamp(28px, 8vw, 48px)/.95 Georgia, 'Times New Roman', serif;
letter-spacing: .02em;
text-shadow: 0 3px 0 rgba(74, 44, 15, .75);
}
.airship-loader-phase {
min-height: 20px;
margin: 0;
color: #f5dda0;
font: 700 13px/1.3 ui-sans-serif, system-ui, sans-serif;
letter-spacing: .04em;
}
.airship-loader-track {
position: relative;
width: 100%;
height: 12px;
padding: 2px;
box-sizing: border-box;
border: 1px solid #8a5b27;
border-radius: 999px;
background: #06161d;
box-shadow: inset 0 2px 5px rgba(0,0,0,.65), 0 0 0 1px rgba(230,183,91,.16);
overflow: hidden;
}
.airship-loader-fill {
width: 6%;
height: 100%;
border-radius: inherit;
background:
linear-gradient(90deg, #18666b, #3f9a8d 62%, #e3bd62 100%);
box-shadow: 0 0 12px rgba(86, 191, 167, .5);
transition: width 180ms ease-out;
}
.airship-loader-meta {
width: 100%;
display: flex;
justify-content: space-between;
gap: 12px;
color: #85a8ad;
font: 600 10px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace;
text-transform: uppercase;
letter-spacing: .08em;
}
.airship-loader-meta span:last-child {
text-align: right;
}
@media (prefers-reduced-motion: reduce) {
.airship-loader-mark::before,
.airship-loader-mark::after { animation: none; }
#airship-loading-screen,
.airship-loader-fill { transition-duration: 1ms; }
}
#mobile-controls { #mobile-controls {
position: fixed; position: fixed;
inset: 0; inset: 0;
@ -121,7 +293,72 @@
} }
</style> </style>
</head> </head>
<body> <body class="airship-loading">
<div id="airship-loading-screen" role="status" aria-live="polite" aria-label="Loading Arrowstack Airship">
<div class="airship-loader">
<div class="airship-loader-mark" aria-hidden="true">
<div class="airship-loader-hub"></div>
</div>
<p class="airship-loader-kicker">ARROWSTACK AIRSHIP</p>
<h1>Preparing the Sparrow</h1>
<p class="airship-loader-phase" data-loading="phase">Warming the engines…</p>
<div class="airship-loader-track" aria-hidden="true">
<div class="airship-loader-fill" data-loading="fill"></div>
</div>
<div class="airship-loader-meta">
<span data-loading="percent">6%</span>
<span data-loading="network">Checking captain credentials…</span>
</div>
</div>
</div>
<script>
(() => {
const root = document.querySelector('#airship-loading-screen');
const fill = root.querySelector('[data-loading="fill"]');
const phase = root.querySelector('[data-loading="phase"]');
const percent = root.querySelector('[data-loading="percent"]');
const network = root.querySelector('[data-loading="network"]');
let progress = 0.06;
let ready = false;
const setProgress = (value, label) => {
if (ready) return;
progress = Math.max(progress, Math.min(1, Number(value) || 0));
fill.style.width = `${Math.round(progress * 100)}%`;
percent.textContent = `${Math.round(progress * 100)}%`;
if (label) phase.textContent = label;
};
const setNetwork = (label) => {
if (label) network.textContent = label;
};
const finish = () => {
if (ready) return;
ready = true;
setNetwork(network.textContent);
fill.style.width = '100%';
percent.textContent = '100%';
phase.textContent = 'Ready to sail';
window.setTimeout(() => {
root.classList.add('is-ready');
document.body.classList.remove('airship-loading');
}, 140);
};
window.airshipLoading = { setProgress, setNetwork, finish };
window.addEventListener('airship:loading-progress', (event) => {
setProgress(event.detail?.progress, event.detail?.phase);
});
window.addEventListener('airship:game-ready', finish);
window.setTimeout(() => {
if (!ready && progress < 0.12) setProgress(0.12, 'Starting flight systems…');
}, 700);
})();
</script>
<div id="app"> <div id="app">
<div id="game"></div> <div id="game"></div>
</div> </div>

View File

@ -160,6 +160,23 @@ class GameScene extends Phaser.Scene {
} }
preload() { preload() {
window.dispatchEvent(new CustomEvent('airship:loading-progress', {
detail: { progress: 0.16, phase: 'Loading ship systems…' },
}));
this.load.on('progress', (value) => {
window.dispatchEvent(new CustomEvent('airship:loading-progress', {
detail: {
progress: 0.16 + value * 0.54,
phase: 'Loading airship assets…',
},
}));
});
this.load.once('complete', () => {
window.dispatchEvent(new CustomEvent('airship:loading-progress', {
detail: { progress: 0.72, phase: 'Charting the skies…' },
}));
});
this.load.spritesheet('airships', airshipSheetUrl, { this.load.spritesheet('airships', airshipSheetUrl, {
frameWidth: 64, frameWidth: 64,
frameHeight: 64, frameHeight: 64,
@ -176,6 +193,9 @@ class GameScene extends Phaser.Scene {
} }
create() { create() {
window.dispatchEvent(new CustomEvent('airship:loading-progress', {
detail: { progress: 0.78, phase: 'Generating trade routes and skyports…' },
}));
this.physics.world.setBounds(0, 0, this.world.width, this.world.height); this.physics.world.setBounds(0, 0, this.world.width, this.world.height);
this.drawWorld(); this.drawWorld();
this.createRollingClouds(); this.createRollingClouds();
@ -295,6 +315,10 @@ class GameScene extends Phaser.Scene {
this.createHud(); this.createHud();
window.dispatchEvent(new CustomEvent('airship:loading-progress', {
detail: { progress: 0.94, phase: 'Bringing the Sparrow online…' },
}));
this.scale.on('resize', this.layoutHud, this); this.scale.on('resize', this.layoutHud, this);
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => { this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
this.scale.off('resize', this.layoutHud, this); this.scale.off('resize', this.layoutHud, this);
@ -313,6 +337,10 @@ class GameScene extends Phaser.Scene {
}); });
this.layoutHud({ width: this.scale.width, height: this.scale.height }); this.layoutHud({ width: this.scale.width, height: this.scale.height });
this.spawnWave(); this.spawnWave();
window.dispatchEvent(new CustomEvent('airship:loading-progress', {
detail: { progress: 1, phase: 'Ready to sail' },
}));
window.dispatchEvent(new CustomEvent('airship:game-ready'));
} }

View File

@ -11,6 +11,7 @@ const KEYCLOAK_CONFIG = {
const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
const keycloak = new Keycloak(KEYCLOAK_CONFIG); const keycloak = new Keycloak(KEYCLOAK_CONFIG);
window.airshipLoading?.setNetwork?.('Checking captain credentials…');
let socket = null; let socket = null;
let selfId = null; let selfId = null;
let reconnectTimer = null; let reconnectTimer = null;
@ -54,6 +55,17 @@ document.head.appendChild(style);
const setStatus = (state, label) => { const setStatus = (state, label) => {
statusButton.dataset.state = state; statusButton.dataset.state = state;
statusButton.textContent = label; statusButton.textContent = label;
const loadingLabel = state === 'online'
? `Shared skies online${playerCount > 1 ? ` • ${playerCount} captains` : ''}`
: state === 'connecting'
? 'Joining shared skies…'
: state === 'error'
? 'Multiplayer unavailable • solo ready'
: authenticated
? 'Multiplayer ready'
: 'Solo flight ready';
window.airshipLoading?.setNetwork?.(loadingLabel);
}; };
const emitConnection = (state, extra = {}) => { const emitConnection = (state, extra = {}) => {

View File

@ -4,6 +4,25 @@ import { readFile } from 'node:fs/promises';
const read = (path) => readFile(new URL(`../${path}`, import.meta.url), 'utf8'); const read = (path) => readFile(new URL(`../${path}`, import.meta.url), 'utf8');
test('startup shows a real progress loading screen until the Phaser scene is ready', async () => {
const [index, game, multiplayer] = await Promise.all([
read('index.html'),
read('src/game-v4.js'),
read('src/multiplayer.js'),
]);
assert.match(index, /id="airship-loading-screen"/);
assert.match(index, /Preparing the Sparrow/);
assert.match(index, /body class="airship-loading"/);
assert.match(index, /window\.airshipLoading = \{ setProgress, setNetwork, finish \}/);
assert.match(index, /airship:game-ready/);
assert.match(game, /this\.load\.on\('progress'/);
assert.match(game, /airship:loading-progress/);
assert.match(game, /airship:game-ready/);
assert.match(multiplayer, /window\.airshipLoading\?\.setNetwork/);
assert.match(index, /body\.airship-loading #mobile-controls/);
});
test('mobile HUD uses split helm frame and knob assets', async () => { test('mobile HUD uses split helm frame and knob assets', async () => {
const source = await read('src/mobile-hud.js'); const source = await read('src/mobile-hud.js');