diff --git a/MANUAL.md b/MANUAL.md index 51baaf2..7346b18 100644 --- a/MANUAL.md +++ b/MANUAL.md @@ -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 ` 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`). | diff --git a/mrpgi/src/main.rs b/mrpgi/src/main.rs index 30fe55a..3a7a84b 100644 --- a/mrpgi/src/main.rs +++ b/mrpgi/src/main.rs @@ -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::(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::(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, diff --git a/web/build.sh b/web/build.sh index 4840c28..89c151d 100755 --- a/web/build.sh +++ b/web/build.sh @@ -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/