Web: bake any game into the wasm; never block first paint on audio

The title screen / world picker now works in the browser, not just natively.
`./web/build.sh <folder-of-game-folders>` stages every game inside it plus
collection.json and bakes the lot into one wasm; the engine boots into the
shelf exactly as it does on the desktop. Sixteen cases of Police Squad Quest
come to 2.1 MB total.

- embedded_world() becomes embedded_world_at(prefix), so one bundle can hold
  many games; embedded_games() / embedded_collection_name() are the browser
  twins of world::scan_collection / collection_name.
- The picker is now keyed by String on both platforms and resolved through one
  load_by_key() — a filesystem path natively, an embedded subdirectory in the
  browser — so Mode::Title has a single implementation.
- Esc is a no-op on the web when there is nowhere to back out to. Breaking the
  main loop in a browser just leaves a dead black canvas; found by pressing it.
- ascii_fold() on every game-text draw. macroquad's built-in font has no em
  dash, curly quote, star or dagger, so room names like "— Squad Room —" and
  every death card were drawing tofu boxes in both builds. Folding at the draw
  layer fixes the whole engine without making game authors type worse prose.
- web/build.sh uses the rustup toolchain explicitly: cargo/rustc on PATH may be
  Homebrew's, which ships no wasm32 std even though `rustup target list` shows
  the target installed, and the resulting "can't find crate for core" is a
  thoroughly misleading error.

Verified in a real browser: shelf renders, arrows move the selection and swap
the live room preview, Enter loads and plays the case, Esc returns to the shelf
with the selection preserved. Native --shot and all 13 core tests still pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-27 18:39:48 +10:00
parent fa7fa107d0
commit 827797ad1f
3 changed files with 164 additions and 35 deletions

View File

@ -87,7 +87,7 @@ cargo run -p mrpgi --release # optimized build (smoother), slower first compile
| Flag | Effect |
|------|--------|
| *(none)* | Loads the game in the current directory (`game.json` + `rooms/room0.json`) and opens in the **editor**. |
| `--game DIR` | Load a game folder instead of the current directory (see §16). Point it at a **folder of game folders** and you get a **title screen / world picker**: arrow keys choose, Enter plays, Esc backs out of a game to the shelf. An optional `collection.json` `{"name": "..."}` in the folder sets the big title. |
| `--game DIR` | Load a game folder instead of the current directory (see §16). Point it at a **folder of game folders** and you get a **title screen / world picker**: arrow keys choose, Enter plays, Esc backs out of a game to the shelf. An optional `collection.json` `{"name": "..."}` in the folder sets the big title. Works natively *and* in the browser — `./web/build.sh <collection>` bakes the whole shelf into one wasm. |
| `--sample` | Writes a 3-room "escape the party" demo (+ `game.json`) then runs it (existing rooms backed up to `*.json.bak`). |
| `--kit` | Generates the fantasy starter kit + placeholder sprites, and installs a 4-room fantasy world (existing → `.bak`). |

View File

@ -191,18 +191,26 @@ mod web_bridge {
/// filesystem on the web) and handed to the core fully parsed. Which game is
/// the MRPGI_WEB_GAME env var at compile time (default set in .cargo/config.toml).
#[cfg(target_arch = "wasm32")]
fn embedded_world() -> World {
static GAME: include_dir::Dir = include_dir::include_dir!("$MRPGI_WEB_GAME");
static GAME: include_dir::Dir = include_dir::include_dir!("$MRPGI_WEB_GAME");
/// Build a World from one subtree of the embedded bundle. `prefix` is "" for a
/// single baked game, or the subdirectory name when a whole collection was
/// baked in.
#[cfg(target_arch = "wasm32")]
fn embedded_world_at(prefix: &str) -> World {
let at = |p: &str| -> String {
if prefix.is_empty() { p.to_string() } else { format!("{}/{}", prefix, p) }
};
let manifest = GAME
.get_file("game.json")
.get_file(at("game.json"))
.and_then(|f| serde_json::from_slice(f.contents()).ok())
.unwrap_or_default();
let rooms = (0u32..64).filter_map(|n| {
let f = GAME.get_file(format!("rooms/room{}.json", n))?;
let f = GAME.get_file(at(&format!("rooms/room{}.json", n)))?;
Some((n, serde_json::from_slice(f.contents()).ok()?))
});
let mut sprites: Vec<(String, mrpgi_core::view::Cel)> = GAME
.get_dir("sprites")
.get_dir(at("sprites"))
.map(|d| {
d.files()
.filter_map(|f| {
@ -216,6 +224,51 @@ fn embedded_world() -> World {
World::from_memory(manifest, rooms, sprites)
}
/// The embedded bundle's games, as (key, display name) — the browser twin of
/// `world::scan_collection`. Empty when a single game was baked at the root,
/// which is the classic one-game web build.
#[cfg(target_arch = "wasm32")]
fn embedded_games() -> Vec<(String, String)> {
if GAME.get_file("game.json").is_some() || GAME.get_dir("rooms").is_some() {
return Vec::new(); // a single game, not a shelf
}
let mut out: Vec<(String, String)> = GAME
.dirs()
.filter(|d| d.get_file(format!("{}/game.json", d.path().display())).is_some()
|| d.get_dir(format!("{}/rooms", d.path().display())).is_some())
.map(|d| {
let key = d.path().display().to_string();
let name = d
.get_file(format!("{}/game.json", key))
.and_then(|f| serde_json::from_slice::<mrpgi_core::world::GameManifest>(f.contents()).ok())
.map(|m| m.name)
.unwrap_or_else(|| key.clone());
(key, name)
})
.collect();
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
/// The collection's title on the web: `collection.json` at the bundle root.
#[cfg(target_arch = "wasm32")]
fn embedded_collection_name() -> String {
// Parsed generically: the GUI crate carries serde_json but not serde derive.
GAME.get_file("collection.json")
.and_then(|f| serde_json::from_slice::<serde_json::Value>(f.contents()).ok())
.and_then(|v| v.get("name")?.as_str().map(str::to_string))
.unwrap_or_else(|| "MRPGI".to_string())
}
/// Load a game by picker key: a filesystem path natively, an embedded
/// subdirectory in the browser. One call site, two worlds.
fn load_by_key(key: &str) -> World {
#[cfg(not(target_arch = "wasm32"))]
{ World::load_game(key) }
#[cfg(target_arch = "wasm32")]
{ embedded_world_at(key) }
}
#[macroquad::main(window_conf)]
async fn main() {
let game_dir = std::env::args()
@ -225,12 +278,19 @@ async fn main() {
// A directory that isn't a game itself but contains game folders is a
// COLLECTION: boot into the title screen / world picker instead.
#[cfg(not(target_arch = "wasm32"))]
let picker: Vec<(std::path::PathBuf, String)> = {
let picker: Vec<(String, String)> = {
let p = std::path::Path::new(&game_dir);
if mrpgi_core::world::is_game_dir(p) { Vec::new() } else { mrpgi_core::world::scan_collection(p) }
if mrpgi_core::world::is_game_dir(p) {
Vec::new()
} else {
mrpgi_core::world::scan_collection(p)
.into_iter()
.map(|(path, name)| (path.to_string_lossy().to_string(), name))
.collect()
}
};
#[cfg(target_arch = "wasm32")]
let picker: Vec<(std::path::PathBuf, String)> = Vec::new();
let picker: Vec<(String, String)> = embedded_games();
let in_collection = !picker.is_empty();
// Shelf display names: when every game shares one "Series — " prefix
// (the usual shape for a campaign), show only the part after it — the
@ -245,16 +305,19 @@ async fn main() {
.map(|p0| format!("{}{}", p0, sep));
picker
.iter()
.map(|(_, n)| match &common {
.map(|(_k, n)| match &common {
Some(c) => n[c.len()..].to_string(),
None => n.replace('\u{2014}', "-"),
})
.collect()
};
let coll_title = if in_collection {
mrpgi_core::world::collection_name(std::path::Path::new(&game_dir))
} else {
let coll_title = if !in_collection {
String::new()
} else {
#[cfg(not(target_arch = "wasm32"))]
{ mrpgi_core::world::collection_name(std::path::Path::new(&game_dir)) }
#[cfg(target_arch = "wasm32")]
{ embedded_collection_name() }
};
#[cfg(not(target_arch = "wasm32"))]
@ -272,10 +335,14 @@ async fn main() {
let ai_cfg = AiConfig::from_env();
let ai_label = ai_cfg.label();
#[cfg(not(target_arch = "wasm32"))]
let world = World::load_game(&game_dir);
#[cfg(target_arch = "wasm32")]
let world = embedded_world();
// In a collection nothing is chosen yet; boot the first entry so the
// preview texture and editor have a valid world to sit on.
let world = if in_collection { load_by_key(&picker[0].0) } else {
#[cfg(not(target_arch = "wasm32"))]
{ World::load_game(&game_dir) }
#[cfg(target_arch = "wasm32")]
{ embedded_world_at("") }
};
let mut gs = GameState::new(world, ai_cfg);
let mut ed = Editor::new(&game_dir);
@ -295,9 +362,11 @@ async fn main() {
Mode::Edit
};
#[cfg(target_arch = "wasm32")]
let mut mode = Mode::Play;
let mut mode = if in_collection { Mode::Title } else { Mode::Play };
#[cfg(target_arch = "wasm32")]
let _ = gs.apply(Command::NewGame { room: None });
if !in_collection {
let _ = gs.apply(Command::NewGame { room: None });
}
#[cfg(not(target_arch = "wasm32"))]
if shot.is_some() && !in_collection {
let _ = gs.apply(Command::NewGame { room: None });
@ -352,6 +421,9 @@ async fn main() {
command.clear();
mode = Mode::Title;
} else {
// On the web there is nowhere to quit to — breaking the loop
// just leaves a dead black canvas, so Esc is a no-op there.
#[cfg(not(target_arch = "wasm32"))]
break;
}
}
@ -390,7 +462,7 @@ async fn main() {
}
// --- lazy preview: bake the selected game's start room -------
if preview_idx != Some(sel) && !picker.is_empty() {
let w = World::load_game(&picker[sel].0);
let w = load_by_key(&picker[sel].0);
let peek = GameState::new(w, AiConfig::from_env());
play_img.bytes.copy_from_slice(&peek.render_visual(false));
play_tex.update(&play_img);
@ -398,8 +470,8 @@ async fn main() {
}
// --- enter: load the world and play ---------------------------
if is_key_pressed(KeyCode::Enter) && !picker.is_empty() {
let dir = picker[sel].0.to_string_lossy().to_string();
gs = GameState::new(World::load_game(&dir), AiConfig::from_env());
let dir = picker[sel].0.clone();
gs = GameState::new(load_by_key(&dir), AiConfig::from_env());
ed = Editor::new(&dir);
audio = sound::LazyAudio::start(&dir);
command.clear();
@ -422,8 +494,9 @@ async fn main() {
let sw = screen_width();
let sh = screen_height();
draw_rectangle(0.0, 0.0, sw, sh, color_u8!(0, 0, 0, 120));
let tdim = measure_text(&coll_title, None, 52, 1.0);
draw_text(&coll_title, (sw - tdim.width) * 0.5, sh * 0.16, 52.0, color_u8!(255, 210, 80, 255));
let ct = ascii_fold(&coll_title);
let tdim = measure_text(&ct, None, 52, 1.0);
draw_text(&ct, (sw - tdim.width) * 0.5, sh * 0.16, 52.0, color_u8!(255, 210, 80, 255));
let sub = "an MRPGI world shelf";
let sdim = measure_text(sub, None, 16, 1.0);
draw_text(sub, (sw - sdim.width) * 0.5, sh * 0.16 + 24.0, 16.0, color_u8!(140, 150, 165, 255));
@ -431,7 +504,7 @@ async fn main() {
let rows = 9usize.min(picker.len());
let first = sel.saturating_sub(rows / 2).min(picker.len().saturating_sub(rows));
for (row, i) in (first..(first + rows)).enumerate() {
let name = &shelf[i];
let name = &ascii_fold(&shelf[i]);
let y = sh * 0.30 + row as f32 * 26.0;
let active = i == sel;
let dim = measure_text(name, None, 20, 1.0);
@ -608,6 +681,28 @@ fn handle(e: &Event, audio: &mut sound::LazyAudio) {
}
}
/// macroquad's built-in font is ASCII-ish: anything outside it draws as a
/// tofu box. Game text is typeset properly (em dashes, curly quotes, stars),
/// so fold those down to the nearest ASCII on the way to the screen rather
/// than making every game author type worse prose.
fn ascii_fold(s: &str) -> String {
s.chars()
.map(|c| match c {
'\u{2014}' | '\u{2013}' => '-', // em / en dash
'\u{2018}' | '\u{2019}' => '\'', // curly single quotes
'\u{201C}' | '\u{201D}' => '"', // curly double quotes
'\u{2026}' => '~', // ellipsis (one cell)
'\u{00B7}' | '\u{2022}' => '.', // middle dot / bullet
'\u{2605}' | '\u{2606}' => '*', // stars
'\u{2020}' => '+', // dagger (death card)
'\u{2191}' => '^',
'\u{2193}' => 'v',
c if (c as u32) < 128 => c,
_ => '?',
})
.collect()
}
fn read_keyboard_dir() -> u8 {
let up = is_key_down(KeyCode::Up);
let down = is_key_down(KeyCode::Down);
@ -678,25 +773,25 @@ fn draw_play_panel(gs: &GameState, command: &str, crt: bool, ai: &str, sound_on:
} else {
color_u8!(205, 213, 225, 255)
};
draw_text(line, 12.0, yy, 18.0, col);
draw_text(&ascii_fold(line), 12.0, yy, 18.0, col);
yy += 18.0;
}
if gs.dlg.is_some() {
draw_text("(type a number to choose · Esc to leave)", 12.0, sh - 30.0, 18.0, color_u8!(95, 255, 215, 255));
} else {
draw_text(&format!("> {}_", command), 12.0, sh - 30.0, 20.0, color_u8!(95, 255, 215, 255));
draw_text(&ascii_fold(&format!("> {}_", command)), 12.0, sh - 30.0, 20.0, color_u8!(95, 255, 215, 255));
}
let inv = if gs.inventory.is_empty() { "".to_string() } else { gs.inventory.join(", ") };
draw_text(
&format!(
&ascii_fold(&format!(
"room {} · inv: {} · ai: {} · Tab: edit · F1: CRT[{}] · F2: snd[{}] · Esc: quit",
gs.world.current,
inv,
ai,
if crt { "on" } else { "off" },
if sound_on { "on" } else { "off" }
),
)),
12.0,
sh - 8.0,
14.0,

View File

@ -5,6 +5,8 @@
# ./web/build.sh # the default game (games/lost-fuse)
# ./web/build.sh /path/to/game # bake any game folder into the wasm;
# # its music/ and web/index.html ride along
# ./web/build.sh /path/to/cases # a folder of game folders bakes the whole
# # shelf and boots the title screen picker
#
# Deploy (engine live at https://monsterrobot.games/engine/):
# scp web/dist/* root@100.94.195.115:/opt/dealgod/images/engine/
@ -15,23 +17,55 @@
set -e
cd "$(dirname "$0")/.."
# rustc/cargo on PATH may be Homebrew's, which ships no wasm32 std even when
# `rustup target list` shows it installed. Use the rustup toolchain outright.
if command -v rustup >/dev/null 2>&1; then
TC_BIN="$(dirname "$(rustup which cargo)")"
CARGO="$TC_BIN/cargo"
RUSTC="$TC_BIN/rustc"
export RUSTC
PATH="$TC_BIN:$PATH"
export PATH
else
CARGO=cargo
fi
# Stage only what the engine reads — include_dir! embeds the whole tree, and a
# game repo also carries raw art, tools, tests, .git.
stage_game() { # $1 = source game dir, $2 = destination
mkdir -p "$2"
cp "$1/game.json" "$2/" 2>/dev/null || true
cp -R "$1/rooms" "$1/sprites" "$2/" 2>/dev/null || true
}
GAME="${1:-}"
if [ -n "$GAME" ]; then
SRC="$(cd "$GAME" && pwd)"
# Stage only what the engine reads — include_dir! embeds the whole tree,
# and a game repo also carries raw art, tools, .git, etc.
STAGE="$(mktemp -d)/game"
mkdir -p "$STAGE"
cp "$SRC/game.json" "$STAGE/" 2>/dev/null || true
cp -R "$SRC/rooms" "$SRC/sprites" "$STAGE/"
if [ -f "$SRC/game.json" ] || [ -d "$SRC/rooms" ]; then
stage_game "$SRC" "$STAGE"
echo "baking game: $SRC"
else
# A COLLECTION: a folder of game folders. Bakes the whole shelf, and
# the engine boots into its title screen / world picker.
n=0
for d in "$SRC"/*/; do
[ -f "$d/game.json" ] || [ -d "$d/rooms" ] || continue
stage_game "$d" "$STAGE/$(basename "$d")"
n=$((n + 1))
done
[ -f "$SRC/collection.json" ] && cp "$SRC/collection.json" "$STAGE/"
[ "$n" -gt 0 ] || { echo "no games found in $SRC"; exit 1; }
echo "baking collection: $SRC ($n games)"
fi
MRPGI_WEB_GAME="$STAGE"
export MRPGI_WEB_GAME
echo "baking game: $SRC (staged at $STAGE)"
fi
# include_dir! reads the env var at compile time; make sure the crate recompiles.
touch mrpgi/src/main.rs
cargo build -p mrpgi --release --target wasm32-unknown-unknown
"$CARGO" build -p mrpgi --release --target wasm32-unknown-unknown
rm -rf web/dist
mkdir -p web/dist
cp target/wasm32-unknown-unknown/release/mrpgi.wasm web/dist/