cannons: run the demo behind the Play button

The page opened on a dark rectangle. It now opens on a battle already in
progress, with Play over it -- attract mode, as cannons_reloaded 76b32ca
added it.

One iframe, not two: it starts on the demo and the click swaps it for the
game. The demo is the client alone -- no socket, no WebAssembly server --
so the click still buys the payload the old placeholder was there to
defer; the bar was never the client at ~360KB, it was the 3MB server.

Two frame heights, because the demo and the game are not the same shape:
attract mode shows GameAreaPanel alone at 760x400 and the game shows the
whole applet at 770x540. Sized to the demo until Play is pressed, or the
battle sits letterboxed between two black bars.
This commit is contained in:
wes
2026-08-30 07:59:26 -04:00
parent 68da1f716b
commit a63e2913b3
2 changed files with 55 additions and 23 deletions
+6
View File
@@ -32,3 +32,9 @@ export function loadCannons() {
// $uri/index.html` would then have a file and a directory competing for the
// same URL. Exactly why the notebook exports are at /nb/ and not /notebooks/.
export const cannonsFrameUrl = '/cn/embedded.html';
// Attract mode: the same page, told to play scripted scenes instead of
// connecting. It costs the client (classes.js) and nothing else -- no
// WebSocket, and crucially not the WebAssembly server, which is an order of
// magnitude larger and still waits for the click.
export const cannonsDemoUrl = '/cn/embedded.html?demo=1';
+49 -23
View File
@@ -1,6 +1,6 @@
---
import Base from '../layouts/Base.astro';
import { loadCannons, cannonsFrameUrl } from '../lib/cannons.mjs';
import { loadCannons, cannonsFrameUrl, cannonsDemoUrl } from '../lib/cannons.mjs';
const build = loadCannons();
const mb = (n) => (n / 1048576).toFixed(1);
@@ -46,9 +46,27 @@ const overTheWire = build ? mb(build.wireBytes) : null;
computer opponent takes the other seat.
</p>
<p>
The battle below the button is a demo, and it has no server at all: the
clients packet handlers are ordinary methods, so a scene is just a script
of the packets a real match would have sent, fed straight in. Which is why
it can show you eight cannons opening with ICBMs at once — a thing no
actual match can produce, because the computer opponents cannot aim a
guided weapon.
</p>
{
build ? (
<div class="cn-wrap" data-src={cannonsFrameUrl}>
{/* Rendered here rather than created in the script, so Astro's scoped
styles actually reach it -- see the note on .cn-frame below. It
starts on the demo, which needs the client but no server. */}
<iframe
class="cn-frame"
title="Cannons"
allow="autoplay"
src={cannonsDemoUrl}
/>
<button class="cn-start" type="button">
<span class="cn-start-label">Play</span>
<span class="cn-start-sub">
@@ -56,10 +74,6 @@ const overTheWire = build ? mb(build.wireBytes) : null;
the game's own art, compressed
</span>
</button>
{/* Rendered here rather than created in the script, so Astro's scoped
styles actually reach it -- see the note on .cn-frame below. With
no src it loads nothing, so the payload still waits for the click. */}
<iframe class="cn-frame" title="Cannons" allow="autoplay"></iframe>
</div>
) : (
<p class="cn-missing">
@@ -78,10 +92,10 @@ const overTheWire = build ? mb(build.wireBytes) : null;
</Base>
<script>
// Click-to-load rather than <iframe loading="lazy">: lazy only defers until
// the frame nears the viewport, and this frame is above the fold, so the
// whole payload would download for anyone who opened the page. Several MB
// is a lot to spend on a visitor who came here to read the paragraph.
// The frame is already showing the demo, which is the client alone. The
// click swaps it for the real game, and THAT is what fetches the
// WebAssembly server -- most of the payload by a wide margin, and still not
// spent on a visitor who came here to read the paragraph.
const wrap = document.querySelector('.cn-wrap');
const button = wrap?.querySelector('.cn-start');
const frame = wrap?.querySelector('.cn-frame');
@@ -97,6 +111,9 @@ const overTheWire = build ? mb(build.wireBytes) : null;
<style>
.cn-wrap {
margin: 1.5rem 0;
position: relative;
width: 100%;
max-width: 770px;
}
/* 770x540 is the applet's own size; the frame is not responsive because the
game's layout is fixed pixels and scaling it would blur the terrain.
@@ -108,17 +125,35 @@ const overTheWire = build ? mb(build.wireBytes) : null;
the HTML default of 300x150. That is exactly what shipped on 2026-08-28.
Hence the iframe living in the template with the script only setting its
src. */
.cn-start,
/* Two heights, because the demo and the game are not the same shape: attract
mode shows GameAreaPanel alone (760x400) and the game shows the whole
applet (770x540). Sized to the demo until Play is pressed, or the battle
sits in a letterbox with a black bar above and below it. */
.cn-frame {
display: block;
width: 100%;
max-width: 770px;
height: 540px;
height: 402px;
border: 1px solid var(--rule, #2a2f3d);
border-radius: 2px;
}
.cn-start {
background: #0b0d12;
}
.cn-wrap.is-playing .cn-frame {
height: 540px;
}
/* Over the demo rather than instead of it: the battle is the backdrop and
this is the thing you press to take a seat in it. Translucent so the
scene reads through, and covering the whole frame so a click anywhere
starts the game rather than landing in a demo that ignores it. */
.cn-start {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(11, 13, 18, 0.55);
border: 1px solid transparent;
border-radius: 2px;
color: inherit;
cursor: pointer;
font: inherit;
@@ -138,18 +173,9 @@ const overTheWire = build ? mb(build.wireBytes) : null;
font-size: 0.8rem;
color: var(--muted);
}
/* Not the `hidden` attribute: `display: block` above outranks the user
agent's `[hidden] { display: none }`, so the frame would show anyway. */
.cn-frame {
background: #0b0d12;
display: none;
}
.cn-wrap.is-playing .cn-start {
display: none;
}
.cn-wrap.is-playing .cn-frame {
display: block;
}
.cn-note,
.cn-missing {
font-size: 0.8rem;