Title screen / world picker for game collections

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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-26 21:50:13 +10:00
parent b6c9c5eb23
commit fa7fa107d0
3 changed files with 204 additions and 8 deletions

View File

@ -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.

View File

@ -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::<GameManifest>(&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::<C>(&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);
}
}

View File

@ -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<String> = {
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<String> = 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<usize> = None;
let mut command = String::new();
let mut menu_open: Option<usize> = 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 {