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
|
001.jpeg
|
||||||
002.png
|
002.png
|
||||||
003.png
|
003.png
|
||||||
|
web/dist/
|
||||||
|
|||||||
21
Cargo.lock
generated
21
Cargo.lock
generated
@ -304,6 +304,25 @@ dependencies = [
|
|||||||
"png",
|
"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]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.18"
|
version = "1.0.18"
|
||||||
@ -389,8 +408,10 @@ dependencies = [
|
|||||||
name = "mrpgi"
|
name = "mrpgi"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"include_dir",
|
||||||
"macroquad",
|
"macroquad",
|
||||||
"mrpgi-core",
|
"mrpgi-core",
|
||||||
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@ -8,6 +8,10 @@ description = "MRPGI headless engine core — the sim, parser, renderer and cont
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
image = { version = "0.24", default-features = false, features = ["png"] } # PNG-only: lean builds
|
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
|
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
|
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
|
//! OPENROUTER_API_KEY (or MRPGI_AI_KEY) for openrouter
|
||||||
|
|
||||||
use std::sync::mpsc::{self, Receiver};
|
use std::sync::mpsc::{self, Receiver};
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use std::thread;
|
use std::thread;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[cfg_attr(target_arch = "wasm32", allow(dead_code))] // browser builds only ever construct Off
|
||||||
#[derive(Clone, PartialEq)]
|
#[derive(Clone, PartialEq)]
|
||||||
enum Provider {
|
enum Provider {
|
||||||
Off,
|
Off,
|
||||||
@ -23,6 +26,7 @@ enum Provider {
|
|||||||
OpenRouter,
|
OpenRouter,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(target_arch = "wasm32", allow(dead_code))] // url/key unused when the lane is compiled out
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AiConfig {
|
pub struct AiConfig {
|
||||||
provider: Provider,
|
provider: Provider,
|
||||||
@ -33,6 +37,15 @@ pub struct AiConfig {
|
|||||||
|
|
||||||
impl AiConfig {
|
impl AiConfig {
|
||||||
pub fn from_env() -> Self {
|
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 which = std::env::var("MRPGI_AI").unwrap_or_else(|_| "ollama".into()).to_lowercase();
|
||||||
let provider = match which.as_str() {
|
let provider = match which.as_str() {
|
||||||
"off" | "none" | "0" => Provider::Off,
|
"off" | "none" | "0" => Provider::Off,
|
||||||
@ -84,6 +97,7 @@ Command:"
|
|||||||
|
|
||||||
/// Fire the translation on a worker thread; poll the returned receiver with
|
/// Fire the translation on a worker thread; poll the returned receiver with
|
||||||
/// `try_recv()`. The result is a sanitized command (or "" = no idea).
|
/// `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> {
|
pub fn translate_async(cfg: AiConfig, prompt: String) -> Receiver<String> {
|
||||||
let (tx, rx) = mpsc::channel();
|
let (tx, rx) = mpsc::channel();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
@ -93,6 +107,16 @@ pub fn translate_async(cfg: AiConfig, prompt: String) -> Receiver<String> {
|
|||||||
rx
|
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 {
|
fn sanitize(s: &str) -> String {
|
||||||
s.lines()
|
s.lines()
|
||||||
.map(|l| l.trim())
|
.map(|l| l.trim())
|
||||||
@ -103,6 +127,7 @@ fn sanitize(s: &str) -> String {
|
|||||||
.to_lowercase()
|
.to_lowercase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
fn call(cfg: &AiConfig, prompt: &str) -> Result<String, Box<dyn std::error::Error>> {
|
fn call(cfg: &AiConfig, prompt: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||||
let agent = ureq::AgentBuilder::new()
|
let agent = ureq::AgentBuilder::new()
|
||||||
.timeout_connect(Duration::from_secs(5))
|
.timeout_connect(Duration::from_secs(5))
|
||||||
|
|||||||
@ -67,6 +67,11 @@ pub fn quantize(img: &image::RgbaImage, dither: bool) -> Cel {
|
|||||||
Cel { w, h, pixels }
|
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
|
/// Load a PNG and quantize it. Returns a small magenta placeholder on failure
|
||||||
/// so missing art is obvious rather than a crash.
|
/// so missing art is obvious rather than a crash.
|
||||||
pub fn load_cel(path: &str, dither: bool) -> Cel {
|
pub fn load_cel(path: &str, dither: bool) -> Cel {
|
||||||
|
|||||||
@ -278,6 +278,28 @@ impl World {
|
|||||||
w
|
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 {
|
pub fn room_path(&self, n: u32) -> PathBuf {
|
||||||
self.base.join(format!("rooms/room{}.json", n))
|
self.base.join(format!("rooms/room{}.json", n))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,3 +7,8 @@ description = "Monster Robot Party Game Interpreter — macroquad GUI front-end
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
mrpgi-core = { path = "../mrpgi-core" }
|
mrpgi-core = { path = "../mrpgi-core" }
|
||||||
macroquad = "0.4"
|
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,28 +34,70 @@ enum Mode {
|
|||||||
Play,
|
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)]
|
#[macroquad::main(window_conf)]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
let game_dir = std::env::args()
|
let game_dir = std::env::args()
|
||||||
.skip_while(|a| a != "--game")
|
.skip_while(|a| a != "--game")
|
||||||
.nth(1)
|
.nth(1)
|
||||||
.unwrap_or_else(|| ".".to_string());
|
.unwrap_or_else(|| ".".to_string());
|
||||||
if std::env::args().any(|a| a == "--sample") {
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
mrpgi_core::kit::write_sample_world(&game_dir);
|
{
|
||||||
|
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_cfg = AiConfig::from_env();
|
||||||
let ai_label = ai_cfg.label();
|
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);
|
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;
|
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 mut play_img = Image::gen_image_color(PIC_W as u16, PIC_H as u16, BLACK);
|
||||||
let play_tex = Texture2D::from_image(&play_img);
|
let play_tex = Texture2D::from_image(&play_img);
|
||||||
play_tex.set_filter(FilterMode::Nearest);
|
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