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.
17pub fn export_html(cart: &Cart, out: &Path, web_crate_dir: &Path) -> Result<()> {
18    let player_wasm = build_player(web_crate_dir)?;
19    let cart_png = cart::encode(cart)?;
20    let title = if cart.assets.meta.name.is_empty() {
21        "pixel8 cart".to_string()
22    } else {
23        cart.assets.meta.name.clone()
24    };
25    let html = TEMPLATE
26        .replace("{{TITLE}}", &escape_html(&title))
27        .replace("{{PLAYER_B64}}", &base64(&player_wasm))
28        .replace("{{CART_B64}}", &base64(&cart_png));
29    std::fs::write(out, html)?;
30    Ok(())
31}
32
33/// Where the `pixel8-web` player crate lives. Defaults to this source
34/// tree; override with PIXEL8_WEB for installed binaries.
35pub fn web_crate_dir(sdk_path: &Path) -> PathBuf {
36    if let Ok(p) = std::env::var("PIXEL8_WEB") {
37        return PathBuf::from(p);
38    }
39    sdk_path.join("../pixel8-web")
40}
41
42/// Compile the browser player to wasm (a fast no-op after the first
43/// time) and return its bytes.
44fn build_player(web_crate_dir: &Path) -> Result<Vec<u8>> {
45    let output = Command::new("cargo")
46        .args([
47            "build",
48            "--profile",
49            "web-release",
50            "--target",
51            "wasm32-unknown-unknown",
52        ])
53        .current_dir(web_crate_dir)
54        .env("CARGO_TERM_COLOR", "never")
55        .output()
56        .context("running cargo for the web player")?;
57    if !output.status.success() {
58        let stderr = String::from_utf8_lossy(&output.stderr);
59        let tail: Vec<&str> = stderr.lines().rev().take(8).collect();
60        return Err(anyhow!(
61            "building the web player failed:\n{}",
62            tail.into_iter().rev().collect::<Vec<_>>().join("\n")
63        ));
64    }
65    let target_dir = std::env::var("CARGO_TARGET_DIR")
66        .map(PathBuf::from)
67        .unwrap_or_else(|_| web_crate_dir.join("../target"));
68    let artifact = target_dir.join("wasm32-unknown-unknown/web-release/pixel8_web.wasm");
69    std::fs::read(&artifact)
70        .with_context(|| format!("reading web player at {}", artifact.display()))
71}
72
73fn escape_html(s: &str) -> String {
74    s.replace('&', "&amp;")
75        .replace('<', "&lt;")
76        .replace('>', "&gt;")
77}
78
79/// Plain standard base64; small enough to not warrant a dependency.
80fn base64(data: &[u8]) -> String {
81    const CHARS: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
82    let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
83    for chunk in data.chunks(3) {
84        let b = [
85            chunk[0],
86            *chunk.get(1).unwrap_or(&0),
87            *chunk.get(2).unwrap_or(&0),
88        ];
89        let n = u32::from_be_bytes([0, b[0], b[1], b[2]]);
90        out.push(CHARS[(n >> 18 & 63) as usize] as char);
91        out.push(CHARS[(n >> 12 & 63) as usize] as char);
92        out.push(if chunk.len() > 1 {
93            CHARS[(n >> 6 & 63) as usize] as char
94        } else {
95            '='
96        });
97        out.push(if chunk.len() > 2 {
98            CHARS[(n & 63) as usize] as char
99        } else {
100            '='
101        });
102    }
103    out
104}
105
106/// The wrapper page. Deliberately spartan: black page, cartridge art,
107/// click to boot, pixel-perfect canvas. No frameworks, no fetches.
108const TEMPLATE: &str = r#"<!doctype html>
109<html lang="en">
110<head>
111<meta charset="utf-8">
112<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
113<title>{{TITLE}} - pixel8</title>
114<style>
115  html, body { margin: 0; height: 100%; background: #000; }
116  body { display: flex; flex-direction: column; align-items: center;
117         justify-content: center; gap: 12px;
118         font-family: monospace; color: #5f574f; }
119  #stage { position: relative; width: min(85vmin, 512px);
120           aspect-ratio: 1; }
121  canvas, #boot { position: absolute; inset: 0; width: 100%; height: 100%; }
122  canvas { image-rendering: pixelated; image-rendering: crisp-edges;
123           display: none; }
124  #boot { display: flex; flex-direction: column; align-items: center;
125          justify-content: center; gap: 16px; cursor: pointer;
126          border: 0; background: none; padding: 0; }
127  #boot img { height: 80%; image-rendering: pixelated; }
128  #boot span { color: #fff1e8; font-size: 16px; }
129  #boot:hover span { color: #ffec27; }
130  #title { color: #c2c3c7; font-size: 14px; }
131  #hint { font-size: 11px; }
132  a { color: #5f574f; }
133
134  /* Touch controls: hidden unless the device has a coarse pointer. */
135  #touch { display: none; width: 100%; max-width: 560px;
136           justify-content: space-between; align-items: center;
137           padding: 8px 18px; box-sizing: border-box;
138           user-select: none; -webkit-user-select: none; }
139  @media (pointer: coarse) {
140    body { justify-content: flex-start; padding-top: 10px;
141           overscroll-behavior: none; }
142    #stage { width: min(92vmin, 56vh); }
143    #touch { display: flex; touch-action: none; }
144    #hint { display: none; }
145  }
146  #dpad { position: relative; width: 34vmin; height: 34vmin;
147          max-width: 180px; max-height: 180px; }
148  #dpad::before, #dpad::after { content: ""; position: absolute;
149          background: #1d2b53; border: 2px solid #5f574f;
150          box-sizing: border-box; border-radius: 6px; }
151  #dpad::before { left: 33%; top: 0; width: 34%; height: 100%; }
152  #dpad::after { left: 0; top: 33%; width: 100%; height: 34%; }
153  #dpad .dir { position: absolute; color: #5f574f; font-size: 18px;
154          z-index: 1; transform: translate(-50%, -50%); }
155  #dpad .dir.on { color: #ffec27; }
156  #d-l { left: 16%; top: 50%; } #d-r { left: 84%; top: 50%; }
157  #d-u { left: 50%; top: 16%; } #d-d { left: 50%; top: 84%; }
158  #abtns { display: flex; gap: 14px; align-items: flex-end; }
159  .ab { width: 17vmin; height: 17vmin; max-width: 90px; max-height: 90px;
160        border-radius: 50%; border: 2px solid #5f574f;
161        background: #1d2b53; color: #c2c3c7; font-family: monospace;
162        font-size: 24px; padding: 0; }
163  #btn-o { margin-bottom: 26px; }
164  .ab.on { background: #7e2553; color: #fff1e8; border-color: #ff77a8; }
165</style>
166</head>
167<body>
168<div id="stage">
169  <canvas id="screen" width="128" height="128"></canvas>
170  <button id="boot"><img alt="cartridge" id="cartimg"><span>click to play</span></button>
171</div>
172<div id="touch">
173  <div id="dpad">
174    <span class="dir" id="d-l">&#9664;</span><span class="dir" id="d-r">&#9654;</span>
175    <span class="dir" id="d-u">&#9650;</span><span class="dir" id="d-d">&#9660;</span>
176  </div>
177  <div id="abtns">
178    <button class="ab" id="btn-o">o</button>
179    <button class="ab" id="btn-x">x</button>
180  </div>
181</div>
182<div id="title">{{TITLE}}</div>
183<div id="hint">arrows + z/x &middot; made with <a href="https://github.com/zeenix/pixel8">pixel8</a></div>
184<script>
185"use strict";
186const PLAYER_B64 = "{{PLAYER_B64}}";
187const CART_B64 = "{{CART_B64}}";
188const SCREEN = 128, SAMPLE_RATE = 44100;
189let fps = 30; // logical frame rate; the cart may ask for 60 at load time
190
191function b64bytes(b64) {
192  const s = atob(b64);
193  const a = new Uint8Array(s.length);
194  for (let i = 0; i < s.length; i++) a[i] = s.charCodeAt(i);
195  return a;
196}
197
198document.getElementById("cartimg").src = "data:image/png;base64," + CART_B64;
199
200const canvas = document.getElementById("screen");
201const ctx2d = canvas.getContext("2d");
202const image = new ImageData(SCREEN, SCREEN);
203
204// Same physical keys as the desktop console.
205const KEYMAP = {
206  ArrowLeft: 0, ArrowRight: 1, ArrowUp: 2, ArrowDown: 3,
207  KeyZ: 4, KeyC: 4, KeyN: 4, KeyX: 5, KeyV: 5, KeyM: 5,
208};
209
210let wasm = null;
211let audioCtx = null;
212let audioTime = 0;
213let last = 0, acc = 0;
214
215async function boot() {
216  document.getElementById("boot").style.display = "none";
217  canvas.style.display = "block";
218
219  const { instance } =
220    await WebAssembly.instantiate(b64bytes(PLAYER_B64), {});
221  wasm = instance.exports;
222
223  const cart = b64bytes(CART_B64);
224  const ptr = wasm.pixel8_web_upload_begin(cart.length);
225  new Uint8Array(wasm.memory.buffer, ptr, cart.length).set(cart);
226  if (wasm.pixel8_web_load() !== 0) {
227    const msg = new TextDecoder().decode(new Uint8Array(
228      wasm.memory.buffer, wasm.pixel8_web_error_ptr(), wasm.pixel8_web_error_len()));
229    document.getElementById("title").textContent = "cart error: " + msg;
230    return;
231  }
232  fps = wasm.pixel8_web_fps();
233
234  addEventListener("keydown", (e) => key(e, 1));
235  addEventListener("keyup", (e) => key(e, 0));
236
237  audioCtx = new (window.AudioContext || window.webkitAudioContext)();
238  audioTime = 0;
239
240  last = performance.now();
241  requestAnimationFrame(frame);
242}
243
244function key(e, down) {
245  const b = KEYMAP[e.code];
246  if (b === undefined) return;
247  e.preventDefault();
248  wasm.pixel8_web_set_button(b, down);
249}
250
251// --- Touch controls: d-pad + O/X, multi-touch, 8-way diagonals. ---
252const touchState = [0, 0, 0, 0, 0, 0];
253const el = (id) => document.getElementById(id);
254
255function inRect(r, t, slop) {
256  return t.clientX >= r.left - slop && t.clientX <= r.right + slop &&
257         t.clientY >= r.top - slop && t.clientY <= r.bottom + slop;
258}
259
260function readTouches(e) {
261  e.preventDefault();
262  if (!wasm) return;
263  const next = [0, 0, 0, 0, 0, 0];
264  const pad = el("dpad").getBoundingClientRect();
265  const ro = el("btn-o").getBoundingClientRect();
266  const rx = el("btn-x").getBoundingClientRect();
267  for (const t of e.touches) {
268    if (inRect(ro, t, 12)) { next[4] = 1; continue; }
269    if (inRect(rx, t, 12)) { next[5] = 1; continue; }
270    if (!inRect(pad, t, pad.width * 0.3)) continue;
271    const dx = t.clientX - (pad.left + pad.width / 2);
272    const dy = t.clientY - (pad.top + pad.height / 2);
273    if (Math.hypot(dx, dy) < pad.width * 0.1) continue; // dead zone
274    // 8-way: overlapping 135-degree sectors make 45-degree diagonals.
275    const a = Math.atan2(dy, dx) * 180 / Math.PI;
276    if (Math.abs(a) < 67.5) next[1] = 1;          // right
277    if (Math.abs(a) > 112.5) next[0] = 1;         // left
278    if (a < -22.5 && a > -157.5) next[2] = 1;     // up
279    if (a > 22.5 && a < 157.5) next[3] = 1;       // down
280  }
281  const vis = ["d-l", "d-r", "d-u", "d-d", "btn-o", "btn-x"];
282  for (let b = 0; b < 6; b++) {
283    if (next[b] !== touchState[b]) {
284      touchState[b] = next[b];
285      wasm.pixel8_web_set_button(b, next[b]);
286      el(vis[b]).classList.toggle("on", next[b] === 1);
287    }
288  }
289}
290
291for (const ev of ["touchstart", "touchmove", "touchend", "touchcancel"]) {
292  el("touch").addEventListener(ev, readTouches, { passive: false });
293}
294
295// Keep a short queue of scheduled audio buffers ahead of the clock.
296function pumpAudio() {
297  if (!audioCtx) return;
298  const now = audioCtx.currentTime;
299  if (audioTime < now) audioTime = now + 0.05;
300  while (audioTime < now + 0.15) {
301    const n = wasm.pixel8_web_audio_render(2048);
302    if (n === 0) return;
303    const samples = new Float32Array(
304      wasm.memory.buffer, wasm.pixel8_web_audio_ptr(), n);
305    const buf = audioCtx.createBuffer(1, n, SAMPLE_RATE);
306    buf.getChannelData(0).set(samples);
307    const src = audioCtx.createBufferSource();
308    src.buffer = buf;
309    src.connect(audioCtx.destination);
310    src.start(audioTime);
311    audioTime += n / SAMPLE_RATE;
312  }
313}
314
315function frame(now) {
316  // Fixed-rate logic (30 or 60) under a variable display rate.
317  acc = Math.min(acc + (now - last), 200);
318  last = now;
319  const step = 1000 / fps;
320  while (acc >= step) {
321    wasm.pixel8_web_tick();
322    acc -= step;
323  }
324  const ptr = wasm.pixel8_web_fb_ptr();
325  if (ptr !== 0) {
326    image.data.set(new Uint8Array(wasm.memory.buffer, ptr, SCREEN * SCREEN * 4));
327    ctx2d.putImageData(image, 0, 0);
328  }
329  pumpAudio();
330  requestAnimationFrame(frame);
331}
332
333document.getElementById("boot").addEventListener("click", () => {
334  boot().catch((e) => {
335    document.getElementById("title").textContent = "boot failed: " + e;
336  });
337});
338</script>
339</body>
340</html>
341"#;
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn base64_matches_reference() {
349        assert_eq!(base64(b""), "");
350        assert_eq!(base64(b"f"), "Zg==");
351        assert_eq!(base64(b"fo"), "Zm8=");
352        assert_eq!(base64(b"foo"), "Zm9v");
353        assert_eq!(base64(b"foobar"), "Zm9vYmFy");
354        assert_eq!(base64(&[0xff, 0xef, 0xbe]), "/+++");
355    }
356
357    #[test]
358    fn html_is_escaped() {
359        assert_eq!(escape_html("a<b>&c"), "a&lt;b&gt;&amp;c");
360    }
361}