Add nautical startup loading screen
This commit is contained in:
parent
07c805ea7e
commit
8b4bc846a5
239
index.html
239
index.html
@ -11,6 +11,178 @@
|
||||
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 {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@ -121,7 +293,72 @@
|
||||
}
|
||||
</style>
|
||||
</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="game"></div>
|
||||
</div>
|
||||
|
||||
@ -160,6 +160,23 @@ class GameScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
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, {
|
||||
frameWidth: 64,
|
||||
frameHeight: 64,
|
||||
@ -176,6 +193,9 @@ class GameScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
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.drawWorld();
|
||||
this.createRollingClouds();
|
||||
@ -295,6 +315,10 @@ class GameScene extends Phaser.Scene {
|
||||
|
||||
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.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
|
||||
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.spawnWave();
|
||||
window.dispatchEvent(new CustomEvent('airship:loading-progress', {
|
||||
detail: { progress: 1, phase: 'Ready to sail' },
|
||||
}));
|
||||
window.dispatchEvent(new CustomEvent('airship:game-ready'));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@ const KEYCLOAK_CONFIG = {
|
||||
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
||||
|
||||
const keycloak = new Keycloak(KEYCLOAK_CONFIG);
|
||||
window.airshipLoading?.setNetwork?.('Checking captain credentials…');
|
||||
let socket = null;
|
||||
let selfId = null;
|
||||
let reconnectTimer = null;
|
||||
@ -54,6 +55,17 @@ document.head.appendChild(style);
|
||||
const setStatus = (state, label) => {
|
||||
statusButton.dataset.state = state;
|
||||
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 = {}) => {
|
||||
|
||||
@ -4,6 +4,25 @@ import { readFile } from 'node:fs/promises';
|
||||
|
||||
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 () => {
|
||||
const source = await read('src/mobile-hud.js');
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user