482 lines
15 KiB
HTML
482 lines
15 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
<meta name="theme-color" content="#0f1720" />
|
|
<title>Arrowstack Airship</title>
|
|
<style>
|
|
body {
|
|
touch-action: 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 {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 100;
|
|
pointer-events: none;
|
|
user-select: none;
|
|
-webkit-user-select: none;
|
|
}
|
|
|
|
#drag-stick {
|
|
position: absolute;
|
|
left: max(18px, env(safe-area-inset-left));
|
|
bottom: max(22px, env(safe-area-inset-bottom));
|
|
width: 132px;
|
|
height: 132px;
|
|
border: 1px solid rgba(255, 255, 255, 0.28);
|
|
border-radius: 50%;
|
|
background: rgba(7, 16, 24, 0.42);
|
|
box-shadow: inset 0 0 0 1px rgba(140, 211, 255, 0.08);
|
|
backdrop-filter: blur(5px);
|
|
-webkit-backdrop-filter: blur(5px);
|
|
pointer-events: auto;
|
|
touch-action: none;
|
|
}
|
|
|
|
#drag-stick::before,
|
|
#drag-stick::after {
|
|
content: '';
|
|
position: absolute;
|
|
background: rgba(232, 242, 247, 0.18);
|
|
pointer-events: none;
|
|
}
|
|
|
|
#drag-stick::before {
|
|
left: 50%;
|
|
top: 12px;
|
|
bottom: 12px;
|
|
width: 1px;
|
|
}
|
|
|
|
#drag-stick::after {
|
|
top: 50%;
|
|
left: 12px;
|
|
right: 12px;
|
|
height: 1px;
|
|
}
|
|
|
|
#drag-thumb {
|
|
position: absolute;
|
|
left: 50%;
|
|
top: 50%;
|
|
width: 58px;
|
|
height: 58px;
|
|
margin: -29px 0 0 -29px;
|
|
border-radius: 50%;
|
|
border: 1px solid rgba(255, 255, 255, 0.5);
|
|
background: rgba(140, 211, 255, 0.28);
|
|
box-shadow: 0 3px 18px rgba(0, 0, 0, 0.28);
|
|
pointer-events: none;
|
|
will-change: transform;
|
|
}
|
|
|
|
.mobile-control {
|
|
position: absolute;
|
|
min-width: 72px;
|
|
height: 64px;
|
|
padding: 0 14px;
|
|
border: 1px solid rgba(255, 255, 255, 0.35);
|
|
border-radius: 18px;
|
|
background: rgba(7, 16, 24, 0.68);
|
|
color: #e8f2f7;
|
|
font: 700 13px/1 system-ui, sans-serif;
|
|
backdrop-filter: blur(6px);
|
|
-webkit-backdrop-filter: blur(6px);
|
|
pointer-events: auto;
|
|
touch-action: none;
|
|
}
|
|
|
|
.mobile-control:active,
|
|
.mobile-control.active {
|
|
background: rgba(140, 211, 255, 0.34);
|
|
transform: scale(0.96);
|
|
}
|
|
|
|
.mobile-control.port {
|
|
right: max(18px, env(safe-area-inset-right));
|
|
bottom: max(100px, calc(env(safe-area-inset-bottom) + 82px));
|
|
}
|
|
|
|
.mobile-control.starboard {
|
|
right: max(18px, env(safe-area-inset-right));
|
|
bottom: max(26px, env(safe-area-inset-bottom));
|
|
}
|
|
|
|
.mobile-control.restart {
|
|
top: max(14px, env(safe-area-inset-top));
|
|
right: max(14px, env(safe-area-inset-right));
|
|
width: 48px;
|
|
min-width: 48px;
|
|
height: 48px;
|
|
padding: 0;
|
|
border-radius: 50%;
|
|
font-size: 20px;
|
|
}
|
|
|
|
@media (hover: hover) and (pointer: fine) {
|
|
#mobile-controls { display: none; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<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>
|
|
|
|
<div id="mobile-controls" aria-label="Touch controls">
|
|
<div id="drag-stick" aria-label="Drag to steer and throttle">
|
|
<div id="drag-thumb"></div>
|
|
</div>
|
|
<button class="mobile-control port" data-code="KeyQ" data-key="q" data-keycode="81" aria-label="Fire port broadside">PORT</button>
|
|
<button class="mobile-control starboard" data-code="KeyE" data-key="e" data-keycode="69" aria-label="Fire starboard broadside">STARBOARD</button>
|
|
<button class="mobile-control ship-action buy" data-action="open-dock" aria-label="Dock at port">DOCK</button>
|
|
<button class="mobile-control restart" data-code="KeyR" data-key="r" data-keycode="82" aria-label="Restart">↻</button>
|
|
</div>
|
|
|
|
<script type="module" src="/src/game-entry.js"></script>
|
|
<script type="module">
|
|
const dispatchKey = (type, key, code, keyCode) => {
|
|
const event = new KeyboardEvent(type, {
|
|
key,
|
|
code,
|
|
bubbles: true,
|
|
cancelable: true,
|
|
});
|
|
Object.defineProperty(event, 'keyCode', { get: () => keyCode });
|
|
Object.defineProperty(event, 'which', { get: () => keyCode });
|
|
window.dispatchEvent(event);
|
|
};
|
|
|
|
const emitHelmVector = (x, y) => {
|
|
window.dispatchEvent(new CustomEvent('airship:helm-vector', { detail: { x, y } }));
|
|
};
|
|
|
|
const releaseStick = () => {
|
|
document.querySelector('#drag-thumb').style.transform = 'translate(0px, 0px)';
|
|
emitHelmVector(0, 0);
|
|
};
|
|
|
|
const stick = document.querySelector('#drag-stick');
|
|
const thumb = document.querySelector('#drag-thumb');
|
|
const radius = 45;
|
|
let stickPointer = null;
|
|
|
|
const updateStick = (event) => {
|
|
const rect = stick.getBoundingClientRect();
|
|
let dx = event.clientX - (rect.left + rect.width / 2);
|
|
let dy = event.clientY - (rect.top + rect.height / 2);
|
|
const length = Math.hypot(dx, dy);
|
|
if (length > radius) {
|
|
dx = dx / length * radius;
|
|
dy = dy / length * radius;
|
|
}
|
|
|
|
thumb.style.transform = `translate(${dx}px, ${dy}px)`;
|
|
emitHelmVector(dx / radius, dy / radius);
|
|
};
|
|
|
|
stick.addEventListener('pointerdown', (event) => {
|
|
event.preventDefault();
|
|
stickPointer = event.pointerId;
|
|
stick.setPointerCapture?.(event.pointerId);
|
|
updateStick(event);
|
|
});
|
|
|
|
stick.addEventListener('pointermove', (event) => {
|
|
if (event.pointerId !== stickPointer) return;
|
|
event.preventDefault();
|
|
updateStick(event);
|
|
});
|
|
|
|
const endStick = (event) => {
|
|
if (stickPointer !== null && event.pointerId !== undefined && event.pointerId !== stickPointer) return;
|
|
event.preventDefault?.();
|
|
stickPointer = null;
|
|
releaseStick();
|
|
};
|
|
|
|
stick.addEventListener('pointerup', endStick);
|
|
stick.addEventListener('pointercancel', endStick);
|
|
stick.addEventListener('lostpointercapture', endStick);
|
|
stick.addEventListener('contextmenu', (event) => event.preventDefault());
|
|
|
|
for (const button of document.querySelectorAll('.mobile-control')) {
|
|
const isAction = Boolean(button.dataset.action);
|
|
const fire = (type) => {
|
|
if (isAction) {
|
|
if (type === 'keydown') {
|
|
window.dispatchEvent(new CustomEvent('airship:action', {
|
|
detail: { action: button.dataset.action },
|
|
}));
|
|
}
|
|
return;
|
|
}
|
|
dispatchKey(type, button.dataset.key, button.dataset.code, Number(button.dataset.keycode));
|
|
};
|
|
|
|
const release = (event) => {
|
|
event.preventDefault();
|
|
fire('keyup');
|
|
button.classList.remove('active');
|
|
};
|
|
|
|
button.addEventListener('pointerdown', (event) => {
|
|
event.preventDefault();
|
|
button.setPointerCapture?.(event.pointerId);
|
|
fire('keydown');
|
|
button.classList.add('active');
|
|
});
|
|
button.addEventListener('pointerup', release);
|
|
button.addEventListener('pointercancel', release);
|
|
button.addEventListener('lostpointercapture', () => {
|
|
fire('keyup');
|
|
button.classList.remove('active');
|
|
});
|
|
button.addEventListener('contextmenu', (event) => event.preventDefault());
|
|
}
|
|
|
|
window.addEventListener('blur', releaseStick);
|
|
</script>
|
|
</body>
|
|
</html>
|