Browser build: the engine compiles to WASM and runs at a URL

- mrpgi-core: ureq/tiny_http/threads are native-only deps; the AI lane
  compiles to an always-off stub on wasm32 (deterministic parser stays).
- World::from_memory + assets::cel_from_bytes — boot a game with no
  filesystem.
- mrpgi GUI on wasm: embeds games/lost-fuse via include_dir, boots
  straight into Play (Tab still opens the editor; saving needs a disk,
  so web is play + paint-only for now).
- web/: index.html shell + macroquad JS loader + build.sh producing a
  self-contained static web/dist (1.1 MB total, game included).
- .cargo/config.toml: --allow-undefined for miniquad's JS imports.

Verified in-browser: renders, typed commands reach the parser,
click-to-walk works, zero console errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-05 09:35:45 +10:00
parent 2fdd730cde
commit 11d25f5c52
12 changed files with 191 additions and 9 deletions

4
.cargo/config.toml Normal file
View File

@ -0,0 +1,4 @@
# Web builds: miniquad's JS imports (console_log, init_webgl, ...) are
# resolved at runtime by web/mq_js_bundle.js — tell the linker to allow them.
[target.wasm32-unknown-unknown]
rustflags = ["-C", "link-arg=--allow-undefined"]

1
.gitignore vendored
View File

@ -13,3 +13,4 @@
001.jpeg
002.png
003.png
web/dist/

21
Cargo.lock generated
View File

@ -304,6 +304,25 @@ dependencies = [
"png",
]
[[package]]
name = "include_dir"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
dependencies = [
"include_dir_macros",
]
[[package]]
name = "include_dir_macros"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
dependencies = [
"proc-macro2",
"quote",
]
[[package]]
name = "itoa"
version = "1.0.18"
@ -389,8 +408,10 @@ dependencies = [
name = "mrpgi"
version = "0.1.0"
dependencies = [
"include_dir",
"macroquad",
"mrpgi-core",
"serde_json",
]
[[package]]

View File

@ -8,6 +8,10 @@ description = "MRPGI headless engine core — the sim, parser, renderer and cont
serde = { version = "1", features = ["derive"] }
serde_json = "1"
image = { version = "0.24", default-features = false, features = ["png"] } # PNG-only: lean builds
# Native-only: the AI lane and the HTTP surface don't exist in browser builds
# (wasm gets the deterministic parser and a baked-in game).
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
ureq = { version = "2", features = ["json", "tls"] } # AI parser lane: Ollama / OpenRouter HTTP
tiny_http = "0.12" # the --serve control surface; ponytail: swap for axum+WS if streaming push is ever needed

View File

@ -13,9 +13,12 @@
//! OPENROUTER_API_KEY (or MRPGI_AI_KEY) for openrouter
use std::sync::mpsc::{self, Receiver};
#[cfg(not(target_arch = "wasm32"))]
use std::thread;
#[cfg(not(target_arch = "wasm32"))]
use std::time::Duration;
#[cfg_attr(target_arch = "wasm32", allow(dead_code))] // browser builds only ever construct Off
#[derive(Clone, PartialEq)]
enum Provider {
Off,
@ -23,6 +26,7 @@ enum Provider {
OpenRouter,
}
#[cfg_attr(target_arch = "wasm32", allow(dead_code))] // url/key unused when the lane is compiled out
#[derive(Clone)]
pub struct AiConfig {
provider: Provider,
@ -33,6 +37,15 @@ pub struct AiConfig {
impl AiConfig {
pub fn from_env() -> Self {
// No HTTP client, no worker threads in the browser build.
#[cfg(target_arch = "wasm32")]
return Self::off();
#[cfg(not(target_arch = "wasm32"))]
Self::from_env_native()
}
#[cfg(not(target_arch = "wasm32"))]
fn from_env_native() -> Self {
let which = std::env::var("MRPGI_AI").unwrap_or_else(|_| "ollama".into()).to_lowercase();
let provider = match which.as_str() {
"off" | "none" | "0" => Provider::Off,
@ -84,6 +97,7 @@ Command:"
/// Fire the translation on a worker thread; poll the returned receiver with
/// `try_recv()`. The result is a sanitized command (or "" = no idea).
#[cfg(not(target_arch = "wasm32"))]
pub fn translate_async(cfg: AiConfig, prompt: String) -> Receiver<String> {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
@ -93,6 +107,16 @@ pub fn translate_async(cfg: AiConfig, prompt: String) -> Receiver<String> {
rx
}
/// Browser stub — never reached (the wasm AiConfig is always off), but keeps
/// the call sites compiling without a cfg forest.
#[cfg(target_arch = "wasm32")]
pub fn translate_async(_cfg: AiConfig, _prompt: String) -> Receiver<String> {
let (tx, rx) = mpsc::channel();
let _ = tx.send(String::new());
rx
}
#[cfg(not(target_arch = "wasm32"))]
fn sanitize(s: &str) -> String {
s.lines()
.map(|l| l.trim())
@ -103,6 +127,7 @@ fn sanitize(s: &str) -> String {
.to_lowercase()
}
#[cfg(not(target_arch = "wasm32"))]
fn call(cfg: &AiConfig, prompt: &str) -> Result<String, Box<dyn std::error::Error>> {
let agent = ureq::AgentBuilder::new()
.timeout_connect(Duration::from_secs(5))

View File

@ -67,6 +67,11 @@ pub fn quantize(img: &image::RgbaImage, dither: bool) -> Cel {
Cel { w, h, pixels }
}
/// Quantize a PNG already in memory (the browser build's sprite path).
pub fn cel_from_bytes(bytes: &[u8], dither: bool) -> Option<Cel> {
image::load_from_memory(bytes).ok().map(|img| quantize(&img.to_rgba8(), dither))
}
/// Load a PNG and quantize it. Returns a small magenta placeholder on failure
/// so missing art is obvious rather than a crash.
pub fn load_cel(path: &str, dither: bool) -> Cel {

View File

@ -278,6 +278,28 @@ impl World {
w
}
/// Build a World entirely from memory — no filesystem. This is how the
/// browser build boots: the game folder is baked into the binary and
/// handed over here as parsed rooms + quantized sprites.
pub fn from_memory(
manifest: GameManifest,
rooms: impl IntoIterator<Item = (u32, RoomDoc)>,
sprites: Vec<(String, Cel)>,
) -> Self {
let mut w = World {
base: PathBuf::from("."),
current: manifest.start_room,
manifest,
doc: RoomDoc::default(),
fb: FrameBuffer::new(),
sprites,
overlay: rooms.into_iter().collect(),
};
let start = w.current;
w.goto_room(start);
w
}
pub fn room_path(&self, n: u32) -> PathBuf {
self.base.join(format!("rooms/room{}.json", n))
}

View File

@ -7,3 +7,8 @@ description = "Monster Robot Party Game Interpreter — macroquad GUI front-end
[dependencies]
mrpgi-core = { path = "../mrpgi-core" }
macroquad = "0.4"
# Browser builds bake a game folder into the binary (no filesystem on the web).
[target.'cfg(target_arch = "wasm32")'.dependencies]
include_dir = "0.7"
serde_json = "1"

View File

@ -34,28 +34,70 @@ enum Mode {
Play,
}
/// Browser boot: the game folder is compiled into the binary (there is no
/// filesystem on the web) and handed to the core fully parsed.
#[cfg(target_arch = "wasm32")]
fn embedded_world() -> World {
static GAME: include_dir::Dir = include_dir::include_dir!("$CARGO_MANIFEST_DIR/../games/lost-fuse");
let manifest = GAME
.get_file("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))?;
Some((n, serde_json::from_slice(f.contents()).ok()?))
});
let mut sprites: Vec<(String, mrpgi_core::view::Cel)> = GAME
.get_dir("sprites")
.map(|d| {
d.files()
.filter_map(|f| {
let name = f.path().file_stem()?.to_str()?.to_string();
Some((name, mrpgi_core::assets::cel_from_bytes(f.contents(), false)?))
})
.collect()
})
.unwrap_or_default();
sprites.sort_by(|a, b| a.0.cmp(&b.0));
World::from_memory(manifest, rooms, sprites)
}
#[macroquad::main(window_conf)]
async fn main() {
let game_dir = std::env::args()
.skip_while(|a| a != "--game")
.nth(1)
.unwrap_or_else(|| ".".to_string());
if std::env::args().any(|a| a == "--sample") {
mrpgi_core::kit::write_sample_world(&game_dir);
#[cfg(not(target_arch = "wasm32"))]
{
if std::env::args().any(|a| a == "--sample") {
mrpgi_core::kit::write_sample_world(&game_dir);
}
if std::env::args().any(|a| a == "--kit") {
mrpgi_core::kit::write_kit(&game_dir);
}
mrpgi_core::assets::ensure_sample_sprites(
&std::path::Path::new(&game_dir).join("sprites").to_string_lossy(),
);
}
if std::env::args().any(|a| a == "--kit") {
mrpgi_core::kit::write_kit(&game_dir);
}
mrpgi_core::assets::ensure_sample_sprites(
&std::path::Path::new(&game_dir).join("sprites").to_string_lossy(),
);
let ai_cfg = AiConfig::from_env();
let ai_label = ai_cfg.label();
let mut gs = GameState::new(World::load_game(&game_dir), ai_cfg);
#[cfg(not(target_arch = "wasm32"))]
let world = World::load_game(&game_dir);
#[cfg(target_arch = "wasm32")]
let world = embedded_world();
let mut gs = GameState::new(world, ai_cfg);
let mut ed = Editor::new(&game_dir);
// Native boots into the editor; the web build boots straight into the game.
#[cfg(not(target_arch = "wasm32"))]
let mut mode = Mode::Edit;
#[cfg(target_arch = "wasm32")]
let mut mode = Mode::Play;
#[cfg(target_arch = "wasm32")]
let _ = gs.apply(Command::NewGame { room: None });
let mut play_img = Image::gen_image_color(PIC_W as u16, PIC_H as u16, BLACK);
let play_tex = Texture2D::from_image(&play_img);
play_tex.set_filter(FilterMode::Nearest);

10
web/build.sh Executable file
View File

@ -0,0 +1,10 @@
#!/bin/sh
# Build the browser version of MRPGI into web/dist/ (self-contained, static).
# Requires the wasm target: rustup target add wasm32-unknown-unknown
set -e
cd "$(dirname "$0")/.."
cargo build -p mrpgi --release --target wasm32-unknown-unknown
mkdir -p web/dist
cp target/wasm32-unknown-unknown/release/mrpgi.wasm web/dist/
cp web/index.html web/mq_js_bundle.js web/dist/
echo "web build ready: $(du -sh web/dist | cut -f1) in web/dist/"

40
web/index.html Normal file
View File

@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MRPGI — Monster Robot Party Game Interpreter</title>
<meta name="description" content="A new-school reimagining of Sierra's AGI adventure engine. Play The DJ's Lost Fuse in your browser.">
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #0b0d11; overflow: hidden; }
canvas {
position: absolute; inset: 0;
width: 100%; height: 100%;
image-rendering: pixelated;
background: #0b0d11;
}
#boot {
position: absolute; inset: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 12px;
color: #5fffd7; font-family: ui-monospace, Menlo, monospace; font-size: 14px;
pointer-events: none; transition: opacity .4s;
}
#boot b { font-size: 22px; letter-spacing: 2px; color: #ff87d7; }
</style>
</head>
<body>
<div id="boot"><b>MRPGI</b><span>booting the party&hellip;</span></div>
<canvas id="glcanvas" tabindex="1"></canvas>
<script src="mq_js_bundle.js"></script>
<script>
load("mrpgi.wasm");
// hide the boot splash once the engine is drawing frames
const t = setInterval(() => {
if (typeof wasm_exports !== "undefined" && wasm_exports) {
document.getElementById("boot").style.opacity = "0";
clearInterval(t);
}
}, 250);
</script>
</body>
</html>

3
web/mq_js_bundle.js Normal file

File diff suppressed because one or more lines are too long