Stage the ACannons browser build at /cannons
A third instance of the pattern notebooks-export/ and the reading payloads already use: an artifact built somewhere with a toolchain this repo's node:24-slim container does not have, dropped into a gitignored directory, staged into public/ by a script, and picked up by auto-build.sh's signature on the next tick. Nothing here needs a JDK, Go, or an opinion about the game -- cannons_reloaded's deploy/export-cannons.sh hands over a directory that is already ready to serve. Three things are load-bearing and easy to get wrong later: The payload is at /cn/, not /cannons/. With build.format:'file' the page builds to cannons.html, and try_files would then have a file and a directory competing for the same URL. That is exactly why the notebook exports live at /nb/ and not under /notebooks/. nginx gets gzip_static for /cn/. nginx:alpine has `gzip on` commented out in its base config, so without it the page ships 9MB instead of 3.3MB and nothing anywhere reports that. It gets no-cache rather than an expiry, because classes.js and cannons.wasm are stable filenames whose contents change on every rebuild -- a long expiry would pair a stale client with a fresh server. The page loads on click, not `loading="lazy"`. Lazy only defers until the frame nears the viewport and this frame is above the fold, so a few MB would download for anyone who opened the page to read the paragraph. A missing export is a normal state throughout: copy-cannons.mjs stages nothing, the page says so, and the sidebar link does not appear. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S4wdbGSVzvUDHBWRjSVxfr
This commit is contained in:
@@ -8,6 +8,7 @@ import Toc from '../components/Toc.astro';
|
||||
import ReadingNow from '../components/ReadingNow.astro';
|
||||
import FinishedBooks from '../components/FinishedBooks.astro';
|
||||
import { loadNotebooks, notebookUrl } from '../lib/notebooks.mjs';
|
||||
import { loadCannons } from '../lib/cannons.mjs';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
@@ -34,6 +35,10 @@ const noteCount = entries.length;
|
||||
// Notebooks are a separate section, NOT injected into the garden collection:
|
||||
// they aren't vault notes, so they must not move noteCount or the sitemap.
|
||||
const notebooks = loadNotebooks();
|
||||
// Only link the game when a build is actually staged: the export is a
|
||||
// gitignored artifact, so a fresh clone or a never-run workflow has none, and
|
||||
// a nav link to a page that says "not staged yet" is worse than no link.
|
||||
const cannons = loadCannons();
|
||||
const navPath = decodeURIComponent(Astro.url.pathname)
|
||||
.replace(/index\.html$/, '')
|
||||
.replace(/\.html$/, '');
|
||||
@@ -197,6 +202,16 @@ const pageUrl = new URL(Astro.url.pathname, Astro.site);
|
||||
Knoebels park map
|
||||
</a>
|
||||
</li>
|
||||
{cannons && (
|
||||
<li>
|
||||
<a
|
||||
href="/cannons"
|
||||
aria-current={navPath === '/cannons' ? 'page' : undefined}
|
||||
>
|
||||
Cannons
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// The browser build of ACannons, as staged by cannons_reloaded's
|
||||
// deploy/export-cannons.sh.
|
||||
//
|
||||
// That script is the only thing that knows how to build the game -- two
|
||||
// containers, a JDK and a Go toolchain, neither of which exists on cyrion or
|
||||
// in this repo's node:24-slim build container. It hands the build a manifest
|
||||
// and a directory of files. So a missing or malformed export can only ever
|
||||
// mean "the game isn't staged", never a broken garden build.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const MANIFEST = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'..',
|
||||
'cannons-export',
|
||||
'manifest.json',
|
||||
);
|
||||
|
||||
export function loadCannons() {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(MANIFEST, 'utf8'));
|
||||
return parsed && parsed.commit ? parsed : null;
|
||||
} catch {
|
||||
return null; // never exported: the page says so and the nav link is hidden
|
||||
}
|
||||
}
|
||||
|
||||
// The payload lives under /cn/, NOT /cannons/. With build.format:'file' the
|
||||
// page builds to cannons.html, and nginx's `try_files $uri $uri.html
|
||||
// $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';
|
||||
@@ -0,0 +1,134 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import { loadCannons, cannonsFrameUrl } from '../lib/cannons.mjs';
|
||||
|
||||
const build = loadCannons();
|
||||
const mb = (n) => (n / 1048576).toFixed(1);
|
||||
const kb = (n) => Math.round(n / 1024);
|
||||
const builtAt = build?.builtAt
|
||||
? new Date(build.builtAt).toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'America/New_York',
|
||||
})
|
||||
: null;
|
||||
// What a visitor actually downloads: the export script sums the .gz for
|
||||
// everything that has one and the file itself for the images, which are
|
||||
// already-compressed formats gzip would only make bigger.
|
||||
const overTheWire = build ? mb(build.wireBytes) : null;
|
||||
---
|
||||
|
||||
<Base
|
||||
title="Cannons"
|
||||
description="A 2003 Playforia artillery applet, decompiled, reimplemented, and running in your browser with its server compiled to WebAssembly."
|
||||
wide
|
||||
>
|
||||
<h1>Cannons</h1>
|
||||
|
||||
<p>
|
||||
<em>Cannons</em> was a turn-based artillery game on playray.com — a Java
|
||||
applet, shipped as obfuscated bytecode, whose servers are long gone. This is
|
||||
that client, decompiled and given its names back, playing against a server I
|
||||
wrote from scratch by watching what the client expected.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Nothing here talks to a server of mine. The Java client is compiled to
|
||||
JavaScript, the Go server is compiled to WebAssembly, and both run in this
|
||||
tab — so when you press play, your browser is hosting the game and a
|
||||
computer opponent takes the other seat.
|
||||
</p>
|
||||
|
||||
{
|
||||
build ? (
|
||||
<div class="cn-wrap">
|
||||
<button class="cn-start" type="button" data-src={cannonsFrameUrl}>
|
||||
<span class="cn-start-label">Play</span>
|
||||
<span class="cn-start-sub">
|
||||
downloads about {overTheWire} MB — {kb(build.bytes['classes.js.gz'])} KB
|
||||
of client and {mb(build.bytes['cannons.wasm.gz'])} MB of server
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p class="cn-missing">
|
||||
The build isn’t staged yet — <code>deploy/export-cannons.sh</code> in
|
||||
<code>cannons_reloaded</code> hasn’t run against this checkout.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
<p class="cn-note">
|
||||
Aim with the mouse, adjust power, and mind the wind. The terrain is
|
||||
destructible and generated from the round seed, which is why the server
|
||||
sends four bytes instead of a map.
|
||||
{builtAt && <> Built from <code>{build.commit}</code> on {builtAt}.</>}
|
||||
</p>
|
||||
</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.
|
||||
const button = document.querySelector('.cn-start');
|
||||
button?.addEventListener('click', () => {
|
||||
const frame = document.createElement('iframe');
|
||||
frame.className = 'cn-frame';
|
||||
frame.src = button.dataset.src;
|
||||
frame.title = 'Cannons';
|
||||
// The client takes the keyboard for chat and aiming, and plays sound.
|
||||
frame.allow = 'autoplay';
|
||||
button.replaceWith(frame);
|
||||
// The game only sees keystrokes once the frame has focus, and a visitor
|
||||
// who just clicked Play should not have to click a second time to type.
|
||||
frame.addEventListener('load', () => frame.focus(), { once: true });
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.cn-wrap {
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
/* 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. */
|
||||
.cn-start,
|
||||
.cn-frame {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 770px;
|
||||
height: 540px;
|
||||
border: 1px solid var(--rule, #2a2f3d);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.cn-start {
|
||||
background: #0b0d12;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: center;
|
||||
}
|
||||
.cn-start:hover {
|
||||
border-color: var(--link, #7aa2f7);
|
||||
}
|
||||
.cn-start-label {
|
||||
display: block;
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.cn-start-sub {
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.cn-frame {
|
||||
background: #0b0d12;
|
||||
}
|
||||
.cn-note,
|
||||
.cn-missing {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user