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:
parent
2fdd730cde
commit
11d25f5c52
4
.cargo/config.toml
Normal file
4
.cargo/config.toml
Normal 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
1
.gitignore
vendored
@ -13,3 +13,4 @@
|
||||
001.jpeg
|
||||
002.png
|
||||
003.png
|
||||
web/dist/
|
||||
|
||||
21
Cargo.lock
generated
21
Cargo.lock
generated
@ -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]]
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -34,12 +34,42 @@ 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());
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if std::env::args().any(|a| a == "--sample") {
|
||||
mrpgi_core::kit::write_sample_world(&game_dir);
|
||||
}
|
||||
@ -49,13 +79,25 @@ async fn main() {
|
||||
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
10
web/build.sh
Executable 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
40
web/index.html
Normal 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…</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
3
web/mq_js_bundle.js
Normal file
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user