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:
parent
b6c9c5eb23
commit
fa7fa107d0
@ -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.
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user