pixel8-console 0.2.0

Pixel8: a PICO-8-like fantasy console for Rust games
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
//! Web export: turn a cart into a single self-contained HTML file.
//!
//! The file embeds two things, base64-encoded: the browser player (the
//! console runtime compiled to wasm, from the `pixel8-web` crate) and
//! the cart PNG itself. No server, no sidecar files — double-click the
//! HTML and the cart boots, PICO-8-web style: cartridge art first,
//! click to play. See docs/WEB_EXPORT.md for the details and limits.

use anyhow::{anyhow, Context, Result};
use pixel8_runtime::cart::{self, Cart};
use std::{
    path::{Path, PathBuf},
    process::Command,
};

/// Export `cart` as a playable single-file HTML page.
///
/// `controls` decides whether the page carries player controls: the
/// on-screen d-pad and O/X buttons touch devices get, and the keyboard
/// hint beside them. Carts that take no input — animations, demos,
/// visualizations — are better off without either.
pub fn export_html(cart: &Cart, out: &Path, web_crate_dir: &Path, controls: bool) -> Result<()> {
    let player_wasm = build_player(web_crate_dir)?;
    let cart_png = cart::encode(cart)?;
    let title = if cart.assets.meta.name.is_empty() {
        "pixel8 cart".to_string()
    } else {
        cart.assets.meta.name.clone()
    };
    std::fs::write(out, render_page(&title, &player_wasm, &cart_png, controls))?;
    Ok(())
}

/// Where the `pixel8-web` player crate lives. Defaults to this source
/// tree; override with PIXEL8_WEB for installed binaries.
pub fn web_crate_dir(sdk_path: &Path) -> PathBuf {
    if let Ok(p) = std::env::var("PIXEL8_WEB") {
        return PathBuf::from(p);
    }
    sdk_path.join("../pixel8-web")
}

/// Compile the browser player to wasm (a fast no-op after the first
/// time) and return its bytes.
fn build_player(web_crate_dir: &Path) -> Result<Vec<u8>> {
    let output = Command::new("cargo")
        .args([
            "build",
            "--profile",
            "web-release",
            "--target",
            "wasm32-unknown-unknown",
        ])
        .current_dir(web_crate_dir)
        .env("CARGO_TERM_COLOR", "never")
        .output()
        .context("running cargo for the web player")?;
    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        let tail: Vec<&str> = stderr.lines().rev().take(8).collect();
        return Err(anyhow!(
            "building the web player failed:\n{}",
            tail.into_iter().rev().collect::<Vec<_>>().join("\n")
        ));
    }
    let target_dir = std::env::var("CARGO_TARGET_DIR")
        .map(PathBuf::from)
        .unwrap_or_else(|_| web_crate_dir.join("../target"));
    let artifact = target_dir.join("wasm32-unknown-unknown/web-release/pixel8_web.wasm");
    std::fs::read(&artifact)
        .with_context(|| format!("reading web player at {}", artifact.display()))
}

/// Fill the wrapper page in: the two base64 payloads and, when
/// `controls` is on, the control CSS, markup, script and keyboard hint
/// (each an empty string otherwise, so the page ships none of it).
/// The title goes in last — it is the one piece that comes from the
/// cart, and so the one piece that must not name a placeholder.
fn render_page(title: &str, player_wasm: &[u8], cart_png: &[u8], controls: bool) -> String {
    TEMPLATE
        .replace("{{PLAYER_B64}}", &base64(player_wasm))
        .replace("{{CART_B64}}", &base64(cart_png))
        .replace("{{TOUCH_CSS}}", if controls { TOUCH_CSS } else { "" })
        .replace("{{TOUCH_HTML}}", if controls { TOUCH_HTML } else { "" })
        .replace("{{TOUCH_JS}}", if controls { TOUCH_JS } else { "" })
        .replace("{{HINT_KEYS}}", if controls { HINT_KEYS } else { "" })
        .replace("{{TITLE}}", &escape_html(title))
}

fn escape_html(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
}

/// Plain standard base64; small enough to not warrant a dependency.
fn base64(data: &[u8]) -> String {
    const CHARS: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
    for chunk in data.chunks(3) {
        let b = [
            chunk[0],
            *chunk.get(1).unwrap_or(&0),
            *chunk.get(2).unwrap_or(&0),
        ];
        let n = u32::from_be_bytes([0, b[0], b[1], b[2]]);
        out.push(CHARS[(n >> 18 & 63) as usize] as char);
        out.push(CHARS[(n >> 12 & 63) as usize] as char);
        out.push(if chunk.len() > 1 {
            CHARS[(n >> 6 & 63) as usize] as char
        } else {
            '='
        });
        out.push(if chunk.len() > 2 {
            CHARS[(n & 63) as usize] as char
        } else {
            '='
        });
    }
    out
}

/// The wrapper page. Deliberately spartan: black page, cartridge art,
/// click to boot, pixel-perfect canvas, pause/stop controls underneath.
/// No frameworks, no fetches. The `{{TOUCH_*}}` and `{{HINT_KEYS}}`
/// holes take the player controls, which an input-less cart drops.
const TEMPLATE: &str = r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>{{TITLE}} - pixel8</title>
<style>
  html, body { margin: 0; height: 100%; background: #000; }
  body { display: flex; flex-direction: column; align-items: center;
         justify-content: center; gap: 12px;
         font-family: monospace; color: #5f574f; }
  #stage { position: relative; width: min(85vmin, 512px);
           aspect-ratio: 1; }
  canvas, #boot { position: absolute; inset: 0; width: 100%; height: 100%; }
  canvas { image-rendering: pixelated; image-rendering: crisp-edges;
           display: none; }
  #boot { display: flex; flex-direction: column; align-items: center;
          justify-content: center; gap: 16px; cursor: pointer;
          border: 0; background: none; padding: 0; }
  #boot img { height: 80%; image-rendering: pixelated; }
  #boot span { color: #fff1e8; font-size: 16px; }
  #boot:hover span { color: #ffec27; }
  #paused { position: absolute; inset: 0; display: none; align-items: center;
            justify-content: center; background: rgba(0, 0, 0, 0.65);
            color: #fff1e8; font-size: 16px; cursor: pointer; }
  #controls { display: none; gap: 22px; }
  #controls button { background: none; border: 0; padding: 0; cursor: pointer;
                     font-family: monospace; font-size: 12px; color: #5f574f; }
  #controls button:hover { color: #ffec27; }
  #title { color: #c2c3c7; font-size: 14px; }
  #hint { font-size: 11px; }
  a { color: #5f574f; }

  /* Touch devices have no keyboard to hint at, no page to rubber-band, and
     nothing but the screen to spend their width on (the touch controls, if
     the page has them, claim some of it back below). */
  @media (pointer: coarse) {
    body { overscroll-behavior: none; }
    #stage { width: min(96vmin, 90vh); }
    #hint { display: none; }
  }
{{TOUCH_CSS}}</style>
</head>
<body>
<div id="stage">
  <canvas id="screen" width="128" height="128"></canvas>
  <button id="boot"><img alt="cartridge" id="cartimg"><span>click to play</span></button>
  <div id="paused"><span>paused &mdash; click to resume</span></div>
</div>
{{TOUCH_HTML}}<div id="controls">
  <button id="btn-pause">pause</button>
  <button id="btn-stop">stop</button>
</div>
<div id="title">{{TITLE}}</div>
<div id="hint">{{HINT_KEYS}}esc pauses &middot; made with <a href="https://github.com/zeenix/pixel8">pixel8</a></div>
<script>
"use strict";
const PLAYER_B64 = "{{PLAYER_B64}}";
const CART_B64 = "{{CART_B64}}";
const SCREEN = 128, SAMPLE_RATE = 44100;
let fps = 30; // logical frame rate; the cart may ask for 60 at load time

function b64bytes(b64) {
  const s = atob(b64);
  const a = new Uint8Array(s.length);
  for (let i = 0; i < s.length; i++) a[i] = s.charCodeAt(i);
  return a;
}

document.getElementById("cartimg").src = "data:image/png;base64," + CART_B64;

const canvas = document.getElementById("screen");
const ctx2d = canvas.getContext("2d");
const image = new ImageData(SCREEN, SCREEN);

// Same physical keys as the desktop console.
const KEYMAP = {
  ArrowLeft: 0, ArrowRight: 1, ArrowUp: 2, ArrowDown: 3,
  KeyZ: 4, KeyC: 4, KeyN: 4, KeyX: 5, KeyV: 5, KeyM: 5,
};

let wasm = null;
// Replaced by the touch-control block, when the page carries one.
let resetTouch = () => {};
let audioCtx = null;
let audioTime = 0;
let last = 0, acc = 0;
let rafId = 0, paused = false;

async function boot() {
  document.getElementById("boot").style.display = "none";
  canvas.style.display = "block";
  document.getElementById("controls").style.display = "flex";

  const { instance } =
    await WebAssembly.instantiate(b64bytes(PLAYER_B64), {});
  wasm = instance.exports;

  const cart = b64bytes(CART_B64);
  const ptr = wasm.pixel8_web_upload_begin(cart.length);
  new Uint8Array(wasm.memory.buffer, ptr, cart.length).set(cart);
  if (wasm.pixel8_web_load() !== 0) {
    const msg = new TextDecoder().decode(new Uint8Array(
      wasm.memory.buffer, wasm.pixel8_web_error_ptr(), wasm.pixel8_web_error_len()));
    document.getElementById("title").textContent = "cart error: " + msg;
    return;
  }
  fps = wasm.pixel8_web_fps();

  audioCtx = new (window.AudioContext || window.webkitAudioContext)();
  audioTime = 0;

  last = performance.now();
  rafId = requestAnimationFrame(frame);
}

// Pause: freeze the logic clock and suspend audio, resumable in place.
function togglePause() {
  if (!wasm) return;
  paused = !paused;
  document.getElementById("paused").style.display = paused ? "flex" : "none";
  document.getElementById("btn-pause").textContent = paused ? "resume" : "pause";
  if (paused) {
    cancelAnimationFrame(rafId);
    if (audioCtx) audioCtx.suspend();
  } else {
    if (audioCtx) audioCtx.resume();
    last = performance.now();
    acc = 0;
    rafId = requestAnimationFrame(frame);
  }
}

// Stop: tear the player down and return to the click-to-play screen.
// The next boot re-instantiates the module, so the cart starts fresh.
function stop() {
  if (!wasm) return;
  cancelAnimationFrame(rafId);
  paused = false;
  wasm = null;
  if (audioCtx) audioCtx.close();
  audioCtx = null;
  audioTime = 0;
  resetTouch();
  document.getElementById("paused").style.display = "none";
  document.getElementById("btn-pause").textContent = "pause";
  document.getElementById("controls").style.display = "none";
  canvas.style.display = "none";
  document.getElementById("boot").style.display = "";
}

function key(e, down) {
  if (!wasm) return;
  if (e.code === "Escape") {
    e.preventDefault();
    if (down) togglePause();
    return;
  }
  const b = KEYMAP[e.code];
  if (b === undefined) return;
  e.preventDefault();
  if (!paused) wasm.pixel8_web_set_button(b, down);
}

addEventListener("keydown", (e) => key(e, 1));
addEventListener("keyup", (e) => key(e, 0));

// A hidden tab keeps its audio running otherwise; pause instead.
document.addEventListener("visibilitychange", () => {
  if (document.hidden && wasm && !paused) togglePause();
});

{{TOUCH_JS}}
// Keep a short queue of scheduled audio buffers ahead of the clock.
function pumpAudio() {
  if (!audioCtx) return;
  const now = audioCtx.currentTime;
  if (audioTime < now) audioTime = now + 0.05;
  while (audioTime < now + 0.15) {
    const n = wasm.pixel8_web_audio_render(2048);
    if (n === 0) return;
    const samples = new Float32Array(
      wasm.memory.buffer, wasm.pixel8_web_audio_ptr(), n);
    const buf = audioCtx.createBuffer(1, n, SAMPLE_RATE);
    buf.getChannelData(0).set(samples);
    const src = audioCtx.createBufferSource();
    src.buffer = buf;
    src.connect(audioCtx.destination);
    src.start(audioTime);
    audioTime += n / SAMPLE_RATE;
  }
}

function frame(now) {
  // Fixed-rate logic (30 or 60) under a variable display rate.
  acc = Math.min(acc + (now - last), 200);
  last = now;
  const step = 1000 / fps;
  while (acc >= step) {
    wasm.pixel8_web_tick();
    acc -= step;
  }
  const ptr = wasm.pixel8_web_fb_ptr();
  if (ptr !== 0) {
    image.data.set(new Uint8Array(wasm.memory.buffer, ptr, SCREEN * SCREEN * 4));
    ctx2d.putImageData(image, 0, 0);
  }
  pumpAudio();
  // Keep rafId on the *pending* frame: pause and stop cancel by id, and a
  // stale one leaves the cart ticking (and, after stop, ticking on nothing).
  rafId = requestAnimationFrame(frame);
}

document.getElementById("boot").addEventListener("click", () => {
  boot().catch((e) => {
    document.getElementById("title").textContent = "boot failed: " + e;
  });
});
document.getElementById("paused").addEventListener("click", togglePause);
// Blur after click so Space/Enter go back to the game, not the button.
document.getElementById("btn-pause").addEventListener("click", (e) => {
  e.currentTarget.blur();
  togglePause();
});
document.getElementById("btn-stop").addEventListener("click", (e) => {
  e.currentTarget.blur();
  stop();
});
</script>
</body>
</html>
"#;

/// The touch controls' styling. Hidden unless the device has a coarse
/// pointer, and on such a device the canvas gives up the room the pad
/// and buttons need.
const TOUCH_CSS: &str = r#"
  #touch { display: none; width: 100%; max-width: 560px;
           justify-content: space-between; align-items: center;
           padding: 8px 18px; box-sizing: border-box;
           user-select: none; -webkit-user-select: none; }
  @media (pointer: coarse) {
    body { justify-content: flex-start; padding-top: 10px; }
    #stage { width: min(92vmin, 56vh); }
    #touch { display: flex; touch-action: none; }
  }
  #dpad { position: relative; width: 34vmin; height: 34vmin;
          max-width: 180px; max-height: 180px; }
  #dpad::before, #dpad::after { content: ""; position: absolute;
          background: #1d2b53; border: 2px solid #5f574f;
          box-sizing: border-box; border-radius: 6px; }
  #dpad::before { left: 33%; top: 0; width: 34%; height: 100%; }
  #dpad::after { left: 0; top: 33%; width: 100%; height: 34%; }
  #dpad .dir { position: absolute; color: #5f574f; font-size: 18px;
          z-index: 1; transform: translate(-50%, -50%); }
  #dpad .dir.on { color: #ffec27; }
  #d-l { left: 16%; top: 50%; } #d-r { left: 84%; top: 50%; }
  #d-u { left: 50%; top: 16%; } #d-d { left: 50%; top: 84%; }
  #abtns { display: flex; gap: 14px; align-items: flex-end; }
  .ab { width: 17vmin; height: 17vmin; max-width: 90px; max-height: 90px;
        border-radius: 50%; border: 2px solid #5f574f;
        background: #1d2b53; color: #c2c3c7; font-family: monospace;
        font-size: 24px; padding: 0; }
  #btn-o { margin-bottom: 26px; }
  .ab.on { background: #7e2553; color: #fff1e8; border-color: #ff77a8; }
"#;

/// The touch controls' markup: d-pad on the left, O/X on the right.
const TOUCH_HTML: &str = r#"<div id="touch">
  <div id="dpad">
    <span class="dir" id="d-l">&#9664;</span><span class="dir" id="d-r">&#9654;</span>
    <span class="dir" id="d-u">&#9650;</span><span class="dir" id="d-d">&#9660;</span>
  </div>
  <div id="abtns">
    <button class="ab" id="btn-o">o</button>
    <button class="ab" id="btn-x">x</button>
  </div>
</div>
"#;

/// The touch controls' script: multi-touch, 8-way diagonals.
const TOUCH_JS: &str = r#"
const touchState = [0, 0, 0, 0, 0, 0];
const TOUCH_VIS = ["d-l", "d-r", "d-u", "d-d", "btn-o", "btn-x"];
const el = (id) => document.getElementById(id);

resetTouch = () => {
  for (let b = 0; b < 6; b++) {
    touchState[b] = 0;
    el(TOUCH_VIS[b]).classList.remove("on");
  }
};

function inRect(r, t, slop) {
  return t.clientX >= r.left - slop && t.clientX <= r.right + slop &&
         t.clientY >= r.top - slop && t.clientY <= r.bottom + slop;
}

function readTouches(e) {
  e.preventDefault();
  if (!wasm) return;
  const next = [0, 0, 0, 0, 0, 0];
  const pad = el("dpad").getBoundingClientRect();
  const ro = el("btn-o").getBoundingClientRect();
  const rx = el("btn-x").getBoundingClientRect();
  for (const t of e.touches) {
    if (inRect(ro, t, 12)) { next[4] = 1; continue; }
    if (inRect(rx, t, 12)) { next[5] = 1; continue; }
    if (!inRect(pad, t, pad.width * 0.3)) continue;
    const dx = t.clientX - (pad.left + pad.width / 2);
    const dy = t.clientY - (pad.top + pad.height / 2);
    if (Math.hypot(dx, dy) < pad.width * 0.1) continue; // dead zone
    // 8-way: overlapping 135-degree sectors make 45-degree diagonals.
    const a = Math.atan2(dy, dx) * 180 / Math.PI;
    if (Math.abs(a) < 67.5) next[1] = 1;          // right
    if (Math.abs(a) > 112.5) next[0] = 1;         // left
    if (a < -22.5 && a > -157.5) next[2] = 1;     // up
    if (a > 22.5 && a < 157.5) next[3] = 1;       // down
  }
  for (let b = 0; b < 6; b++) {
    if (next[b] !== touchState[b]) {
      touchState[b] = next[b];
      wasm.pixel8_web_set_button(b, next[b]);
      el(TOUCH_VIS[b]).classList.toggle("on", next[b] === 1);
    }
  }
}

for (const ev of ["touchstart", "touchmove", "touchend", "touchcancel"]) {
  el("touch").addEventListener(ev, readTouches, { passive: false });
}
"#;

/// The keys half of the hint line; the rest of it holds either way.
const HINT_KEYS: &str = "arrows + z/x &middot; ";

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn base64_matches_reference() {
        assert_eq!(base64(b""), "");
        assert_eq!(base64(b"f"), "Zg==");
        assert_eq!(base64(b"fo"), "Zm8=");
        assert_eq!(base64(b"foo"), "Zm9v");
        assert_eq!(base64(b"foobar"), "Zm9vYmFy");
        assert_eq!(base64(&[0xff, 0xef, 0xbe]), "/+++");
    }

    #[test]
    fn html_is_escaped() {
        assert_eq!(escape_html("a<b>&c"), "a&lt;b&gt;&amp;c");
    }

    #[test]
    fn page_leaves_no_holes_unfilled() {
        for controls in [true, false] {
            let page = render_page("t", b"player", b"cart", controls);
            assert!(
                !page.contains("{{"),
                "unfilled hole with controls={controls}"
            );
        }
    }

    #[test]
    fn controls_are_all_or_nothing() {
        let with = render_page("t", b"player", b"cart", true);
        assert!(with.contains(r#"id="dpad""#));
        assert!(with.contains("readTouches"));
        assert!(with.contains("arrows + z/x"));

        let without = render_page("t", b"player", b"cart", false);
        assert!(!without.contains("dpad"));
        assert!(!without.contains(r#"id="touch""#));
        assert!(!without.contains("touchstart"));
        assert!(!without.contains("arrows"));
        // Pause and stop are the page's own controls, not the cart's.
        assert!(without.contains(r#"id="btn-pause""#));
        assert!(without.contains("esc pauses"));
    }
}