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 // $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/. // same URL. Exactly why the notebook exports are at /nb/ and not /notebooks/.
export const cannonsFrameUrl = '/cn/embedded.html'; 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 Base from '../layouts/Base.astro';
import { loadCannons, cannonsFrameUrl } from '../lib/cannons.mjs'; import { loadCannons, cannonsFrameUrl, cannonsDemoUrl } from '../lib/cannons.mjs';
const build = loadCannons(); const build = loadCannons();
const mb = (n) => (n / 1048576).toFixed(1); const mb = (n) => (n / 1048576).toFixed(1);
@@ -46,9 +46,27 @@ const overTheWire = build ? mb(build.wireBytes) : null;
computer opponent takes the other seat. computer opponent takes the other seat.
</p> </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 ? ( build ? (
<div class="cn-wrap" data-src={cannonsFrameUrl}> <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"> <button class="cn-start" type="button">
<span class="cn-start-label">Play</span> <span class="cn-start-label">Play</span>
<span class="cn-start-sub"> <span class="cn-start-sub">
@@ -56,10 +74,6 @@ const overTheWire = build ? mb(build.wireBytes) : null;
the game's own art, compressed the game's own art, compressed
</span> </span>
</button> </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> </div>
) : ( ) : (
<p class="cn-missing"> <p class="cn-missing">
@@ -78,10 +92,10 @@ const overTheWire = build ? mb(build.wireBytes) : null;
</Base> </Base>
<script> <script>
// Click-to-load rather than <iframe loading="lazy">: lazy only defers until // The frame is already showing the demo, which is the client alone. The
// the frame nears the viewport, and this frame is above the fold, so the // click swaps it for the real game, and THAT is what fetches the
// whole payload would download for anyone who opened the page. Several MB // WebAssembly server -- most of the payload by a wide margin, and still not
// is a lot to spend on a visitor who came here to read the paragraph. // spent on a visitor who came here to read the paragraph.
const wrap = document.querySelector('.cn-wrap'); const wrap = document.querySelector('.cn-wrap');
const button = wrap?.querySelector('.cn-start'); const button = wrap?.querySelector('.cn-start');
const frame = wrap?.querySelector('.cn-frame'); const frame = wrap?.querySelector('.cn-frame');
@@ -97,6 +111,9 @@ const overTheWire = build ? mb(build.wireBytes) : null;
<style> <style>
.cn-wrap { .cn-wrap {
margin: 1.5rem 0; 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 /* 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. 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. 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 Hence the iframe living in the template with the script only setting its
src. */ 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 { .cn-frame {
display: block; display: block;
width: 100%; width: 100%;
max-width: 770px; height: 402px;
height: 540px;
border: 1px solid var(--rule, #2a2f3d); border: 1px solid var(--rule, #2a2f3d);
border-radius: 2px; border-radius: 2px;
}
.cn-start {
background: #0b0d12; 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; color: inherit;
cursor: pointer; cursor: pointer;
font: inherit; font: inherit;
@@ -138,18 +173,9 @@ const overTheWire = build ? mb(build.wireBytes) : null;
font-size: 0.8rem; font-size: 0.8rem;
color: var(--muted); 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 { .cn-wrap.is-playing .cn-start {
display: none; display: none;
} }
.cn-wrap.is-playing .cn-frame {
display: block;
}
.cn-note, .cn-note,
.cn-missing { .cn-missing {
font-size: 0.8rem; font-size: 0.8rem;