From fa7fa107d0360ae3f73f37c4d47d8ee1ef4563eb Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 26 Jul 2026 21:50:13 +1000 Subject: [PATCH] Title screen / world picker for game collections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Point --game at a directory that is not itself a game but contains game folders, and the GUI boots into a title screen: the collection's name in gold (optional collection.json {"name": ...}, else the folder name), a shelf of games sorted by folder with display names from each game.json, a dimmed live preview of the selected game's start room baked by the headless renderer, arrow-key selection, Enter to play, and Esc from inside a game returning to the shelf instead of the desktop. Shelf names strip a shared "Series — " prefix when every game has one — the collection title already names the series, and it also sidesteps the em-dash glyph missing from macroquad's default font. Core grows world::is_game_dir / scan_collection / collection_name with a unit test; the GUI grows Mode::Title. --shot pointed at a collection captures the title screen (that is how the screenshots in this commit's review were taken). Sample/kit scaffolding is skipped for collection dirs so a shelf never gets a stray sprites/ folder written into it. Closes the "title screen / world picker" roadmap item in MANUAL §20. Co-Authored-By: Claude Fable 5 --- MANUAL.md | 4 +- mrpgi-core/src/world.rs | 76 +++++++++++++++++++++++ mrpgi/src/main.rs | 132 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 204 insertions(+), 8 deletions(-) diff --git a/MANUAL.md b/MANUAL.md index 6a1d097..51baaf2 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). | +| `--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. | | `--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`). | @@ -636,7 +636,7 @@ rate — which is why scripted replays are bit-identical. JSON-authored for now; the `dialogue`/`requires_flag`/`sets_flag` fields work — the inspector just doesn't show them yet). - The **kit-browser villager** has no built-in dialogue yet (the live inn one does). -- More theme packs; **title screen / world picker**; **save/load a playthrough**. +- More theme packs; **save/load a playthrough**. - Water/trigger control lines are reserved but not yet wired to behaviour. - True-colour ("HD") background layer and polygon fill regions. - **Strudel** live-coded music as a browser sidecar over the HTTP surface. diff --git a/mrpgi-core/src/world.rs b/mrpgi-core/src/world.rs index 6fc45fc..57335d4 100644 --- a/mrpgi-core/src/world.rs +++ b/mrpgi-core/src/world.rs @@ -546,3 +546,79 @@ impl World { // Keep constants nearby for consumers that reason about room geometry. pub const ROOM_W: usize = PIC_W; pub const ROOM_H: usize = PIC_H; + +// --- collections: a folder of game folders ------------------------------ + +/// Does this directory hold a playable game? (Manifest, a start room, or a +/// legacy single-room project all count — the same tests `load_game` uses.) +pub fn is_game_dir(dir: &std::path::Path) -> bool { + dir.join("game.json").exists() + || dir.join("rooms/room0.json").exists() + || dir.join("rooms/room.json").exists() +} + +/// Scan a directory for game folders: every immediate subdirectory that +/// `is_game_dir` accepts, as `(path, display name)`, sorted by folder name. +/// The display name comes from the game's manifest; a folder with no +/// manifest shows under its own name. +pub fn scan_collection(dir: &std::path::Path) -> Vec<(PathBuf, String)> { + let mut out = Vec::new(); + let Ok(rd) = std::fs::read_dir(dir) else { return out }; + for entry in rd.flatten() { + let p = entry.path(); + if p.is_dir() && is_game_dir(&p) { + let name = std::fs::read_to_string(p.join("game.json")) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .map(|m| m.name) + .unwrap_or_else(|| entry.file_name().to_string_lossy().to_string()); + out.push((p, name)); + } + } + out.sort_by(|a, b| a.0.file_name().cmp(&b.0.file_name())); + out +} + +/// The collection's own title, for the picker screen: an optional +/// `collection.json` `{"name": "..."}` in the directory, else the folder +/// name with separators spaced out, uppercased. +pub fn collection_name(dir: &std::path::Path) -> String { + #[derive(Deserialize)] + struct C { + name: String, + } + if let Ok(s) = std::fs::read_to_string(dir.join("collection.json")) { + if let Ok(c) = serde_json::from_str::(&s) { + return c.name; + } + } + dir.file_name() + .map(|n| n.to_string_lossy().replace(['-', '_'], " ").to_uppercase()) + .unwrap_or_else(|| "MRPGI".to_string()) +} + +#[cfg(test)] +mod collection_tests { + use super::*; + + #[test] + fn scans_names_and_sorts_a_collection() { + let base = std::env::temp_dir().join(format!("mrpgi-coll-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(base.join("b-two/rooms")).unwrap(); + std::fs::write(base.join("b-two/rooms/room0.json"), "{}").unwrap(); + std::fs::create_dir_all(base.join("a-one")).unwrap(); + std::fs::write(base.join("a-one/game.json"), r#"{"name":"Alpha"}"#).unwrap(); + std::fs::create_dir_all(base.join("not-a-game")).unwrap(); + std::fs::write(base.join("collection.json"), r#"{"name":"My Shelf"}"#).unwrap(); + + assert!(!is_game_dir(&base)); + let got = scan_collection(&base); + assert_eq!(got.len(), 2); + assert_eq!(got[0].1, "Alpha"); + assert_eq!(got[1].1, "b-two"); + assert_eq!(collection_name(&base), "My Shelf"); + + let _ = std::fs::remove_dir_all(&base); + } +} diff --git a/mrpgi/src/main.rs b/mrpgi/src/main.rs index 752a18f..30fe55a 100644 --- a/mrpgi/src/main.rs +++ b/mrpgi/src/main.rs @@ -33,6 +33,7 @@ const MENU_ITEM_H: f32 = 20.0; #[derive(PartialEq)] enum Mode { + Title, Edit, Play, } @@ -221,8 +222,43 @@ async fn main() { .skip_while(|a| a != "--game") .nth(1) .unwrap_or_else(|| ".".to_string()); + // 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 p = std::path::Path::new(&game_dir); + if mrpgi_core::world::is_game_dir(p) { Vec::new() } else { mrpgi_core::world::scan_collection(p) } + }; + #[cfg(target_arch = "wasm32")] + let picker: Vec<(std::path::PathBuf, String)> = Vec::new(); + 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 + // collection title already names the series. Falls back to the full + // name, with the em-dash swapped for a glyph the default font has. + let shelf: Vec = { + let sep = " \u{2014} "; + let common = picker + .first() + .and_then(|(_, n)| n.split(sep).next()) + .filter(|p0| picker.len() > 1 && picker.iter().all(|(_, n)| n.starts_with(p0) && n.contains(sep))) + .map(|p0| format!("{}{}", p0, sep)); + picker + .iter() + .map(|(_, 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 { + String::new() + }; + + #[cfg(not(target_arch = "wasm32"))] + if !in_collection { if std::env::args().any(|a| a == "--sample") { mrpgi_core::kit::write_sample_world(&game_dir); } @@ -248,15 +284,22 @@ async fn main() { let shot: Option = std::env::args().skip_while(|a| a != "--shot").nth(1); let mut shot_frames = 0u32; - // Native boots into the editor; the web build boots straight into the game. + // Native boots into the editor (or the picker, for a collection); the + // web build boots straight into the game. #[cfg(not(target_arch = "wasm32"))] - let mut mode = if shot.is_some() { Mode::Play } else { Mode::Edit }; + let mut mode = if in_collection { + Mode::Title + } else if shot.is_some() { + Mode::Play + } else { + Mode::Edit + }; #[cfg(target_arch = "wasm32")] let mut mode = Mode::Play; #[cfg(target_arch = "wasm32")] let _ = gs.apply(Command::NewGame { room: None }); #[cfg(not(target_arch = "wasm32"))] - if shot.is_some() { + if shot.is_some() && !in_collection { let _ = gs.apply(Command::NewGame { room: None }); } @@ -265,6 +308,8 @@ async fn main() { play_tex.set_filter(FilterMode::Nearest); let mut crt = false; + let mut sel = 0usize; + let mut preview_idx: Option = None; let mut command = String::new(); let mut menu_open: Option = None; let mut audio = sound::LazyAudio::start(&game_dir); @@ -300,11 +345,17 @@ async fn main() { for e in gs.apply(Command::EndDialogue) { handle(&e, &mut audio); } + } else if in_collection && mode != Mode::Title { + // Back out to the world picker rather than the desktop. + audio.set_music(0); + menu_open = None; + command.clear(); + mode = Mode::Title; } else { break; } } - if is_key_pressed(KeyCode::Tab) { + if is_key_pressed(KeyCode::Tab) && mode != Mode::Title { mode = match mode { Mode::Edit => { // Entering Play: fresh run from the room on the canvas, @@ -320,6 +371,7 @@ async fn main() { audio.set_music(0); Mode::Edit } + Mode::Title => Mode::Title, }; } if is_key_pressed(KeyCode::F2) { @@ -328,6 +380,74 @@ async fn main() { } match mode { + Mode::Title => { + // --- selection ------------------------------------------------ + if is_key_pressed(KeyCode::Down) && !picker.is_empty() { + sel = (sel + 1) % picker.len(); + } + if is_key_pressed(KeyCode::Up) && !picker.is_empty() { + sel = (sel + picker.len() - 1) % picker.len(); + } + // --- 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 peek = GameState::new(w, AiConfig::from_env()); + play_img.bytes.copy_from_slice(&peek.render_visual(false)); + play_tex.update(&play_img); + preview_idx = Some(sel); + } + // --- 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()); + ed = Editor::new(&dir); + audio = sound::LazyAudio::start(&dir); + command.clear(); + menu_open = None; + for e in gs.apply(Command::NewGame { room: None }) { + handle(&e, &mut audio); + } + mode = Mode::Play; + } + // --- draw: dimmed room preview behind the shelf --------------- + let layout = Layout::compute(); + clear_background(BLACK); + draw_texture_ex( + &play_tex, + layout.x, + layout.y, + color_u8!(105, 105, 115, 255), + DrawTextureParams { dest_size: Some(vec2(layout.w, layout.h)), ..Default::default() }, + ); + 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 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)); + // the shelf: a windowed slice of the list around the selection + 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 y = sh * 0.30 + row as f32 * 26.0; + let active = i == sel; + let dim = measure_text(name, None, 20, 1.0); + let x = (sw - dim.width) * 0.5; + if active { + draw_rectangle(x - 26.0, y - 18.0, dim.width + 52.0, 24.0, color_u8!(28, 34, 46, 235)); + draw_text(">", x - 18.0, y, 20.0, color_u8!(255, 210, 80, 255)); + } + let col = if active { color_u8!(255, 255, 255, 255) } else { color_u8!(150, 158, 172, 255) }; + draw_text(name, x, y, 20.0, col); + } + let hint = "up/down choose - Enter play - Esc quit"; + let hdim = measure_text(hint, None, 16, 1.0); + draw_text(hint, (sw - hdim.width) * 0.5, sh - 24.0, 16.0, color_u8!(120, 130, 145, 255)); + } + Mode::Edit => ed.frame(&mut gs), Mode::Play => { @@ -467,7 +587,7 @@ async fn main() { } if let Some(path) = &shot { shot_frames += 1; - if shot_frames == 3 { + if shot_frames == 3 && mode == Mode::Play { menu_open = Some(0); // capture with the Game menu open } if shot_frames >= 6 {