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;
|
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>
|
||||||
|
|||||||
@ -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'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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 = {}) => {
|
||||||
|
|||||||
@ -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');
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user