Skip to main content

pixel8_console/
webexport.rs

1//! Web export: turn a cart into a single self-contained HTML file.
2//!
3//! The file embeds two things, base64-encoded: the browser player (the
4//! console runtime compiled to wasm, from the `pixel8-web` crate) and
5//! the cart PNG itself. No server, no sidecar files — double-click the
6//! HTML and the cart boots, PICO-8-web style: cartridge art first,
7//! click to play. See docs/WEB_EXPORT.md for the details and limits.
8
9use anyhow::{anyhow, Context, Result};
10use pixel8_runtime::cart::{self, Cart};
11use std::{
12    path::{Path, PathBuf},
13    process::Command,
14};
15
16/// Export `cart` as a playable single-file HTML page.
17///
18/// `controls` decides whether the page carries player controls: the
19/// on-screen d-pad and O/X buttons touch devices get, and the keyboard
20/// hint beside them. Carts that take no input — animations, demos,
21/// visualizations — are better off without either.
22pub fn export_html(cart: &Cart, out: &Path, web_crate_dir: &Path, controls: bool) -> Result<()> {
23    let player_wasm = build_player(web_crate_dir)?;
24    let cart_png = cart::encode(cart)?;
25    let title = if cart.assets.meta.name.is_empty() {
26        "pixel8 cart".to_string()
27    } else {
28        cart.assets.meta.name.clone()
29    };
30    std::fs::write(out, render_page(&title, &player_wasm, &cart_png, controls))?;
31    Ok(())
32}
33
34/// Where the `pixel8-web` player crate lives. Defaults to this source
35/// tree; override with PIXEL8_WEB for installed binaries.
36pub fn web_crate_dir(sdk_path: &Path) -> PathBuf {
37    if let Ok(p) = std::env::var("PIXEL8_WEB") {
38        return PathBuf::from(p);
39    }
40    sdk_path.join("../pixel8-web")
41}
42
43/// Compile the browser player to wasm (a fast no-op after the first
44/// time) and return its bytes.
45fn build_player(web_crate_dir: &Path) -> Result<Vec<u8>> {
46    let output = Command::new("cargo")
47        .args([
48            "build",
49            "--profile",
50            "web-release",
51            "--target",
52            "wasm32-unknown-unknown",
53        ])
54        .current_dir(web_crate_dir)
55        .env("CARGO_TERM_COLOR", "never")
56        .output()
57        .context("running cargo for the web player")?;
58    if !output.status.success() {
59        let stderr = String::from_utf8_lossy(&output.stderr);
60        let tail: Vec<&str> = stderr.lines().rev().take(8).collect();
61        return Err(anyhow!(
62            "building the web player failed:\n{}",
63            tail.into_iter().rev().collect::<Vec<_>>().join("\n")
64        ));
65    }
66    let target_dir = std::env::var("CARGO_TARGET_DIR")
67        .map(PathBuf::from)
68        .unwrap_or_else(|_| web_crate_dir.join("../target"));
69    let artifact = target_dir.join("wasm32-unknown-unknown/web-release/pixel8_web.wasm");
70    std::fs::read(&artifact)
71        .with_context(|| format!("reading web player at {}", artifact.display()))
72}
73
74/// Fill the wrapper page in: the two base64 payloads and, when
75/// `controls` is on, the control CSS, markup, script and keyboard hint
76/// (each an empty string otherwise, so the page ships none of it).
77/// The title goes in last — it is the one piece that comes from the
78/// cart, and so the one piece that must not name a placeholder.
79fn render_page(title: &str, player_wasm: &[u8], cart_png: &[u8], controls: bool) -> String {
80    TEMPLATE
81        .replace("{{PLAYER_B64}}", &base64(player_wasm))
82        .replace("{{CART_B64}}", &base64(cart_png))
83        .replace("{{TOUCH_CSS}}", if controls { TOUCH_CSS } else { "" })
84        .replace("{{TOUCH_HTML}}", if controls { TOUCH_HTML } else { "" })
85        .replace("{{TOUCH_JS}}", if controls { TOUCH_JS } else { "" })
86        .replace("{{HINT_KEYS}}", if controls { HINT_KEYS } else { "" })
87        .replace("{{TITLE}}", &escape_html(title))
88}
89
90fn escape_html(s: &str) -> String {
91    s.replace('&', "&amp;")
92        .replace('<', "&lt;")
93        .replace('>', "&gt;")
94}
95
96/// Plain standard base64; small enough to not warrant a dependency.
97fn base64(data: &[u8]) -> String {
98    const CHARS: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
99    let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
100    for chunk in data.chunks(3) {
101        let b = [
102            chunk[0],
103            *chunk.get(1).unwrap_or(&0),
104            *chunk.get(2).unwrap_or(&0),
105        ];
106        let n = u32::from_be_bytes([0, b[0], b[1], b[2]]);
107        out.push(CHARS[(n >> 18 & 63) as usize] as char);
108        out.push(CHARS[(n >> 12 & 63) as usize] as char);
109        out.push(if chunk.len() > 1 {
110            CHARS[(n >> 6 & 63) as usize] as char
111        } else {
112            '='
113        });
114        out.push(if chunk.len() > 2 {
115            CHARS[(n & 63) as usize] as char
116        } else {
117            '='
118        });
119    }
120    out
121}
122
123/// The wrapper page. Deliberately spartan: black page, cartridge art,
124/// click to boot, pixel-perfect canvas, pause/stop controls underneath.
125/// No frameworks, no fetches. The `{{TOUCH_*}}` and `{{HINT_KEYS}}`
126/// holes take the player controls, which an input-less cart drops.
127const TEMPLATE: &str = r#"<!doctype html>
128<html lang="en">
129<head>
130<meta charset="utf-8">
131<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
132<title>{{TITLE}} - pixel8</title>
133<style>
134  html, body { margin: 0; height: 100%; background: #000; }
135  body { display: flex; flex-direction: column; align-items: center;
136         justify-content: center; gap: 12px;
137         font-family: monospace; color: #5f574f; }
138  #stage { position: relative; width: min(85vmin, 512px);
139           aspect-ratio: 1; }
140  canvas, #boot { position: absolute; inset: 0; width: 100%; height: 100%; }
141  canvas { image-rendering: pixelated; image-rendering: crisp-edges;
142           display: none; }
143  #boot { display: flex; flex-direction: column; align-items: center;
144          justify-content: center; gap: 16px; cursor: pointer;
145          border: 0; background: none; padding: 0; }
146  #boot img { height: 80%; image-rendering: pixelated; }
147  #boot span { color: #fff1e8; font-size: 16px; }
148  #boot:hover span { color: #ffec27; }
149  #paused { position: absolute; inset: 0; display: none; align-items: center;
150            justify-content: center; background: rgba(0, 0, 0, 0.65);
151            color: #fff1e8; font-size: 16px; cursor: pointer; }
152  #controls { display: none; gap: 22px; }
153  #controls button { background: none; border: 0; padding: 0; cursor: pointer;
154                     font-family: monospace; font-size: 12px; color: #5f574f; }
155  #controls button:hover { color: #ffec27; }
156  #title { color: #c2c3c7; font-size: 14px; }
157  #hint { font-size: 11px; }
158  a { color: #5f574f; }
159
160  /* Touch devices have no keyboard to hint at, no page to rubber-band, and
161     nothing but the screen to spend their width on (the touch controls, if
162     the page has them, claim some of it back below). */
163  @media (pointer: coarse) {
164    body { overscroll-behavior: none; }
165    #stage { width: min(96vmin, 90vh); }
166    #hint { display: none; }
167  }
168{{TOUCH_CSS}}</style>
169</head>
170<body>
171<div id="stage">
172  <canvas id="screen" width="128" height="128"></canvas>
173  <button id="boot"><img alt="cartridge" id="cartimg"><span>click to play</span></button>
174  <div id="paused"><span>paused &mdash; click to resume</span></div>
175</div>
176{{TOUCH_HTML}}<div id="controls">
177  <button id="btn-pause">pause</button>
178  <button id="btn-stop">stop</button>
179</div>
180<div id="title">{{TITLE}}</div>
181<div id="hint">{{HINT_KEYS}}esc pauses &middot; made with <a href="https://github.com/zeenix/pixel8">pixel8</a></div>
182<script>
183"use strict";
184const PLAYER_B64 = "{{PLAYER_B64}}";
185const CART_B64 = "{{CART_B64}}";
186const SCREEN = 128, SAMPLE_RATE = 44100;
187let fps = 30; // logical frame rate; the cart may ask for 60 at load time
188
189function b64bytes(b64) {
190  const s = atob(b64);
191  const a = new Uint8Array(s.length);
192  for (let i = 0; i < s.length; i++) a[i] = s.charCodeAt(i);
193  return a;
194}
195
196document.getElementById("cartimg").src = "data:image/png;base64," + CART_B64;
197
198const canvas = document.getElementById("screen");
199const ctx2d = canvas.getContext("2d");
200const image = new ImageData(SCREEN, SCREEN);
201
202// Same physical keys as the desktop console.
203const KEYMAP = {
204  ArrowLeft: 0, ArrowRight: 1, ArrowUp: 2, ArrowDown: 3,
205  KeyZ: 4, KeyC: 4, KeyN: 4, KeyX: 5, KeyV: 5, KeyM: 5,
206};
207
208let wasm = null;
209// Replaced by the touch-control block, when the page carries one.
210let resetTouch = () => {};
211let audioCtx = null;
212let audioTime = 0;
213let last = 0, acc = 0;
214let rafId = 0, paused = false;
215
216async function boot() {
217  document.getElementById("boot").style.display = "none";
218  canvas.style.display = "block";
219  document.getElementById("controls").style.display = "flex";
220
221  const { instance } =
222    await WebAssembly.instantiate(b64bytes(PLAYER_B64), {});
223  wasm = instance.exports;
224
225  const cart = b64bytes(CART_B64);
226  const ptr = wasm.pixel8_web_upload_begin(cart.length);
227  new Uint8Array(wasm.memory.buffer, ptr, cart.length).set(cart);
228  if (wasm.pixel8_web_load() !== 0) {
229    const msg = new TextDecoder().decode(new Uint8Array(
230      wasm.memory.buffer, wasm.pixel8_web_error_ptr(), wasm.pixel8_web_error_len()));
231    document.getElementById("title").textContent = "cart error: " + msg;
232    return;
233  }
234  fps = wasm.pixel8_web_fps();
235
236  audioCtx = new (window.AudioContext || window.webkitAudioContext)();
237  audioTime = 0;
238
239  last = performance.now();
240  rafId = requestAnimationFrame(frame);
241}
242
243// Pause: freeze the logic clock and suspend audio, resumable in place.
244function togglePause() {
245  if (!wasm) return;
246  paused = !paused;
247  document.getElementById("paused").style.display = paused ? "flex" : "none";
248  document.getElementById("btn-pause").textContent = paused ? "resume" : "pause";
249  if (paused) {
250    cancelAnimationFrame(rafId);
251    if (audioCtx) audioCtx.suspend();
252  } else {
253    if (audioCtx) audioCtx.resume();
254    last = performance.now();
255    acc = 0;
256    rafId = requestAnimationFrame(frame);
257  }
258}
259
260// Stop: tear the player down and return to the click-to-play screen.
261// The next boot re-instantiates the module, so the cart starts fresh.
262function stop() {
263  if (!wasm) return;
264  cancelAnimationFrame(rafId);
265  paused = false;
266  wasm = null;
267  if (audioCtx) audioCtx.close();
268  audioCtx = null;
269  audioTime = 0;
270  resetTouch();
271  document.getElementById("paused").style.display = "none";
272  document.getElementById("btn-pause").textContent = "pause";
273  document.getElementById("controls").style.display = "none";
274  canvas.style.display = "none";
275  document.getElementById("boot").style.display = "";
276}
277
278function key(e, down) {
279  if (!wasm) return;
280  if (e.code === "Escape") {
281    e.preventDefault();
282    if (down) togglePause();
283    return;
284  }
285  const b = KEYMAP[e.code];
286  if (b === undefined) return;
287  e.preventDefault();
288  if (!paused) wasm.pixel8_web_set_button(b, down);
289}
290
291addEventListener("keydown", (e) => key(e, 1));
292addEventListener("keyup", (e) => key(e, 0));
293
294// A hidden tab keeps its audio running otherwise; pause instead.
295document.addEventListener("visibilitychange", () => {
296  if (document.hidden && wasm && !paused) togglePause();
297});
298
299{{TOUCH_JS}}
300// Keep a short queue of scheduled audio buffers ahead of the clock.
301function pumpAudio() {
302  if (!audioCtx) return;
303  const now = audioCtx.currentTime;
304  if (audioTime < now) audioTime = now + 0.05;
305  while (audioTime < now + 0.15) {
306    const n = wasm.pixel8_web_audio_render(2048);
307    if (n === 0) return;
308    const samples = new Float32Array(
309      wasm.memory.buffer, wasm.pixel8_web_audio_ptr(), n);
310    const buf = audioCtx.createBuffer(1, n, SAMPLE_RATE);
311    buf.getChannelData(0).set(samples);
312    const src = audioCtx.createBufferSource();
313    src.buffer = buf;
314    src.connect(audioCtx.destination);
315    src.start(audioTime);
316    audioTime += n / SAMPLE_RATE;
317  }
318}
319
320function frame(now) {
321  // Fixed-rate logic (30 or 60) under a variable display rate.
322  acc = Math.min(acc + (now - last), 200);
323  last = now;
324  const step = 1000 / fps;
325  while (acc >= step) {
326    wasm.pixel8_web_tick();
327    acc -= step;
328  }
329  const ptr = wasm.pixel8_web_fb_ptr();
330  if (ptr !== 0) {
331    image.data.set(new Uint8Array(wasm.memory.buffer, ptr, SCREEN * SCREEN * 4));
332    ctx2d.putImageData(image, 0, 0);
333  }
334  pumpAudio();
335  // Keep rafId on the *pending* frame: pause and stop cancel by id, and a
336  // stale one leaves the cart ticking (and, after stop, ticking on nothing).
337  rafId = requestAnimationFrame(frame);
338}
339
340document.getElementById("boot").addEventListener("click", () => {
341  boot().catch((e) => {
342    document.getElementById("title").textContent = "boot failed: " + e;
343  });
344});
345document.getElementById("paused").addEventListener("click", togglePause);
346// Blur after click so Space/Enter go back to the game, not the button.
347document.getElementById("btn-pause").addEventListener("click", (e) => {
348  e.currentTarget.blur();
349  togglePause();
350});
351document.getElementById("btn-stop").addEventListener("click", (e) => {
352  e.currentTarget.blur();
353  stop();
354});
355</script>
356</body>
357</html>
358"#;
359
360/// The touch controls' styling. Hidden unless the device has a coarse
361/// pointer, and on such a device the canvas gives up the room the pad
362/// and buttons need.
363const TOUCH_CSS: &str = r#"
364  #touch { display: none; width: 100%; max-width: 560px;
365           justify-content: space-between; align-items: center;
366           padding: 8px 18px; box-sizing: border-box;
367           user-select: none; -webkit-user-select: none; }
368  @media (pointer: coarse) {
369    body { justify-content: flex-start; padding-top: 10px; }
370    #stage { width: min(92vmin, 56vh); }
371    #touch { display: flex; touch-action: none; }
372  }
373  #dpad { position: relative; width: 34vmin; height: 34vmin;
374          max-width: 180px; max-height: 180px; }
375  #dpad::before, #dpad::after { content: ""; position: absolute;
376          background: #1d2b53; border: 2px solid #5f574f;
377          box-sizing: border-box; border-radius: 6px; }
378  #dpad::before { left: 33%; top: 0; width: 34%; height: 100%; }
379  #dpad::after { left: 0; top: 33%; width: 100%; height: 34%; }
380  #dpad .dir { position: absolute; color: #5f574f; font-size: 18px;
381          z-index: 1; transform: translate(-50%, -50%); }
382  #dpad .dir.on { color: #ffec27; }
383  #d-l { left: 16%; top: 50%; } #d-r { left: 84%; top: 50%; }
384  #d-u { left: 50%; top: 16%; } #d-d { left: 50%; top: 84%; }
385  #abtns { display: flex; gap: 14px; align-items: flex-end; }
386  .ab { width: 17vmin; height: 17vmin; max-width: 90px; max-height: 90px;
387        border-radius: 50%; border: 2px solid #5f574f;
388        background: #1d2b53; color: #c2c3c7; font-family: monospace;
389        font-size: 24px; padding: 0; }
390  #btn-o { margin-bottom: 26px; }
391  .ab.on { background: #7e2553; color: #fff1e8; border-color: #ff77a8; }
392"#;
393
394/// The touch controls' markup: d-pad on the left, O/X on the right.
395const TOUCH_HTML: &str = r#"<div id="touch">
396  <div id="dpad">
397    <span class="dir" id="d-l">&#9664;</span><span class="dir" id="d-r">&#9654;</span>
398    <span class="dir" id="d-u">&#9650;</span><span class="dir" id="d-d">&#9660;</span>
399  </div>
400  <div id="abtns">
401    <button class="ab" id="btn-o">o</button>
402    <button class="ab" id="btn-x">x</button>
403  </div>
404</div>
405"#;
406
407/// The touch controls' script: multi-touch, 8-way diagonals.
408const TOUCH_JS: &str = r#"
409const touchState = [0, 0, 0, 0, 0, 0];
410const TOUCH_VIS = ["d-l", "d-r", "d-u", "d-d", "btn-o", "btn-x"];
411const el = (id) => document.getElementById(id);
412
413resetTouch = () => {
414  for (let b = 0; b < 6; b++) {
415    touchState[b] = 0;
416    el(TOUCH_VIS[b]).classList.remove("on");
417  }
418};
419
420function inRect(r, t, slop) {
421  return t.clientX >= r.left - slop && t.clientX <= r.right + slop &&
422         t.clientY >= r.top - slop && t.clientY <= r.bottom + slop;
423}
424
425function readTouches(e) {
426  e.preventDefault();
427  if (!wasm) return;
428  const next = [0, 0, 0, 0, 0, 0];
429  const pad = el("dpad").getBoundingClientRect();
430  const ro = el("btn-o").getBoundingClientRect();
431  const rx = el("btn-x").getBoundingClientRect();
432  for (const t of e.touches) {
433    if (inRect(ro, t, 12)) { next[4] = 1; continue; }
434    if (inRect(rx, t, 12)) { next[5] = 1; continue; }
435    if (!inRect(pad, t, pad.width * 0.3)) continue;
436    const dx = t.clientX - (pad.left + pad.width / 2);
437    const dy = t.clientY - (pad.top + pad.height / 2);
438    if (Math.hypot(dx, dy) < pad.width * 0.1) continue; // dead zone
439    // 8-way: overlapping 135-degree sectors make 45-degree diagonals.
440    const a = Math.atan2(dy, dx) * 180 / Math.PI;
441    if (Math.abs(a) < 67.5) next[1] = 1;          // right
442    if (Math.abs(a) > 112.5) next[0] = 1;         // left
443    if (a < -22.5 && a > -157.5) next[2] = 1;     // up
444    if (a > 22.5 && a < 157.5) next[3] = 1;       // down
445  }
446  for (let b = 0; b < 6; b++) {
447    if (next[b] !== touchState[b]) {
448      touchState[b] = next[b];
449      wasm.pixel8_web_set_button(b, next[b]);
450      el(TOUCH_VIS[b]).classList.toggle("on", next[b] === 1);
451    }
452  }
453}
454
455for (const ev of ["touchstart", "touchmove", "touchend", "touchcancel"]) {
456  el("touch").addEventListener(ev, readTouches, { passive: false });
457}
458"#;
459
460/// The keys half of the hint line; the rest of it holds either way.
461const HINT_KEYS: &str = "arrows + z/x &middot; ";
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    #[test]
468    fn base64_matches_reference() {
469        assert_eq!(base64(b""), "");
470        assert_eq!(base64(b"f"), "Zg==");
471        assert_eq!(base64(b"fo"), "Zm8=");
472        assert_eq!(base64(b"foo"), "Zm9v");
473        assert_eq!(base64(b"foobar"), "Zm9vYmFy");
474        assert_eq!(base64(&[0xff, 0xef, 0xbe]), "/+++");
475    }
476
477    #[test]
478    fn html_is_escaped() {
479        assert_eq!(escape_html("a<b>&c"), "a&lt;b&gt;&amp;c");
480    }
481
482    #[test]
483    fn page_leaves_no_holes_unfilled() {
484        for controls in [true, false] {
485            let page = render_page("t", b"player", b"cart", controls);
486            assert!(
487                !page.contains("{{"),
488                "unfilled hole with controls={controls}"
489            );
490        }
491    }
492
493    #[test]
494    fn controls_are_all_or_nothing() {
495        let with = render_page("t", b"player", b"cart", true);
496        assert!(with.contains(r#"id="dpad""#));
497        assert!(with.contains("readTouches"));
498        assert!(with.contains("arrows + z/x"));
499
500        let without = render_page("t", b"player", b"cart", false);
501        assert!(!without.contains("dpad"));
502        assert!(!without.contains(r#"id="touch""#));
503        assert!(!without.contains("touchstart"));
504        assert!(!without.contains("arrows"));
505        // Pause and stop are the page's own controls, not the cart's.
506        assert!(without.contains(r#"id="btn-pause""#));
507        assert!(without.contains("esc pauses"));
508    }
509}