From 584424c9393685c15d6514b97e44cb3bb16c8a09 Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 5 Jul 2026 11:14:48 +1000 Subject: [PATCH] Sierra menu bar + web README tab (thanks Matt) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The classic dropdown menus across the top of the play screen: Game (New Game / Edit Mode / CRT / Sound / About) and Action (Look Around / Inventory / Help). Pure bus consumers — menu items inject the same Commands every other surface uses. Sliding along the bar switches menus, clicks outside close, clicks never leak into click-to-walk. - web: README tab pinned top-right with the short guide — controls, editor shortcuts (undo is just Z, no Ctrl), the two-layer paint model, and why the lake is scenery for now. - --shot out.png: render a few Play frames and save the real backbuffer to PNG — ground-truth GPU screenshots for debugging and CI. (Used to prove the menu colors were right when a preview browser's auto-dark filter was inverting light neutrals on canvas.) Co-Authored-By: Claude Fable 5 --- mrpgi/src/main.rs | 165 ++++++++++++++++++++++++++++++++++++++++++++-- web/index.html | 56 ++++++++++++++++ 2 files changed, 217 insertions(+), 4 deletions(-) diff --git a/mrpgi/src/main.rs b/mrpgi/src/main.rs index 4ae8fb4..02dd813 100644 --- a/mrpgi/src/main.rs +++ b/mrpgi/src/main.rs @@ -27,6 +27,9 @@ fn window_conf() -> Conf { } const STATUS_H: f32 = 150.0; +/// The classic Sierra menu bar across the top of the play screen. +const MENU_H: f32 = 22.0; +const MENU_ITEM_H: f32 = 20.0; #[derive(PartialEq)] enum Mode { @@ -34,6 +37,90 @@ enum Mode { Play, } +#[derive(Clone, Copy)] +enum MenuAct { + NewGame, + EditMode, + ToggleCrt, + ToggleSound, + Say(&'static str), + About, +} + +fn build_menus(crt: bool, sound_on: bool) -> Vec<(&'static str, Vec<(String, MenuAct)>)> { + let onoff = |b: bool| if b { "on" } else { "off" }; + vec![ + ( + "Game", + vec![ + ("New Game".to_string(), MenuAct::NewGame), + ("Edit Mode Tab".to_string(), MenuAct::EditMode), + (format!("CRT: {} F1", onoff(crt)), MenuAct::ToggleCrt), + (format!("Sound: {} F2", onoff(sound_on)), MenuAct::ToggleSound), + ("About MRPGI".to_string(), MenuAct::About), + ], + ), + ( + "Action", + vec![ + ("Look Around".to_string(), MenuAct::Say("look")), + ("Inventory".to_string(), MenuAct::Say("inventory")), + ("Help".to_string(), MenuAct::Say("help")), + ], + ), + ] +} + +fn menu_title_rects(menus: &[(&'static str, Vec<(String, MenuAct)>)]) -> Vec { + let mut x = 10.0; + menus + .iter() + .map(|(t, _)| { + let w = measure_text(t, None, 16, 1.0).width + 26.0; + let r = Rect::new(x, 0.0, w, MENU_H); + x += w; + r + }) + .collect() +} + +fn dropdown_rect(title: &Rect, items: &[(String, MenuAct)]) -> Rect { + let w = items.iter().map(|(l, _)| measure_text(l, None, 16, 1.0).width).fold(130.0, f32::max) + 24.0; + Rect::new(title.x, MENU_H, w, items.len() as f32 * MENU_ITEM_H + 6.0) +} + +/// Draw the bar + any open dropdown, Sierra style: white bar, black text, +/// inverted highlight. +const MENU_LIGHT: Color = Color { r: 0.925, g: 0.925, b: 0.925, a: 1.0 }; +const MENU_DARK: Color = Color { r: 0.06, g: 0.06, b: 0.06, a: 1.0 }; + +fn draw_menus(menus: &[(&'static str, Vec<(String, MenuAct)>)], open: Option, mx: f32, my: f32) { + let sw = screen_width(); + draw_rectangle(0.0, 0.0, sw, MENU_H, MENU_LIGHT); + let titles = menu_title_rects(menus); + for (i, r) in titles.iter().enumerate() { + let active = open == Some(i); + if active { + draw_rectangle(r.x, r.y, r.w, r.h, MENU_DARK); + } + draw_text(menus[i].0, r.x + 13.0, 16.0, 16.0, if active { MENU_LIGHT } else { MENU_DARK }); + } + if let Some(oi) = open { + let items = &menus[oi].1; + let d = dropdown_rect(&titles[oi], items); + draw_rectangle(d.x, d.y, d.w, d.h, MENU_LIGHT); + draw_rectangle_lines(d.x, d.y, d.w, d.h, 2.0, MENU_DARK); + for (j, (label, _)) in items.iter().enumerate() { + let iy = d.y + 3.0 + j as f32 * MENU_ITEM_H; + let hov = mx >= d.x && mx <= d.x + d.w && my >= iy && my < iy + MENU_ITEM_H; + if hov { + draw_rectangle(d.x + 2.0, iy, d.w - 4.0, MENU_ITEM_H, MENU_DARK); + } + draw_text(label, d.x + 12.0, iy + 15.0, 16.0, if hov { MENU_LIGHT } else { MENU_DARK }); + } + } +} + /// 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")] @@ -90,13 +177,22 @@ async fn main() { let mut gs = GameState::new(world, ai_cfg); let mut ed = Editor::new(&game_dir); + // --shot out.png: render a few frames of Play mode, save the real + // backbuffer, exit. Ground truth for how the GPU actually drew the UI. + 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. #[cfg(not(target_arch = "wasm32"))] - let mut mode = Mode::Edit; + let mut mode = 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() { + 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 play_tex = Texture2D::from_image(&play_img); @@ -104,9 +200,11 @@ async fn main() { let mut crt = false; let mut command = String::new(); + let mut menu_open: Option = None; let mut audio = sound::AudioOut::load(&game_dir).await; loop { + let mut want_edit = false; if is_key_pressed(KeyCode::Escape) { if mode == Mode::Edit && ed.is_editing() { ed.stop_editing(); @@ -153,10 +251,52 @@ async fn main() { let in_dlg = gs.dlg.is_some(); let mut events: Vec = Vec::new(); + // --- the Sierra menu bar: input pass -------------------------- + let menus = build_menus(crt, !audio.muted); + let (mmx, mmy) = mouse_position(); + let titles = menu_title_rects(&menus); + if menu_open.is_some() { + // sliding along the bar switches menus, like the classics + if let Some(h) = titles.iter().position(|r| r.contains(vec2(mmx, mmy))) { + menu_open = Some(h); + } + } + let mut menu_consumed_click = false; + let mut menu_act: Option = None; + if is_mouse_button_pressed(MouseButton::Left) { + if let Some(i) = titles.iter().position(|r| r.contains(vec2(mmx, mmy))) { + menu_open = if menu_open == Some(i) { None } else { Some(i) }; + menu_consumed_click = true; + } else if let Some(oi) = menu_open { + let d = dropdown_rect(&titles[oi], &menus[oi].1); + if d.contains(vec2(mmx, mmy)) { + let j = (((mmy - d.y - 3.0) / MENU_ITEM_H) as usize).min(menus[oi].1.len() - 1); + menu_act = Some(menus[oi].1[j].1); + } + menu_open = None; + menu_consumed_click = true; + } + } + match menu_act { + Some(MenuAct::NewGame) => events.extend(gs.apply(Command::NewGame { room: None })), + Some(MenuAct::EditMode) => want_edit = true, + Some(MenuAct::ToggleCrt) => crt = !crt, + Some(MenuAct::ToggleSound) => { + let m = !audio.muted; + audio.set_muted(m); + } + Some(MenuAct::Say(s)) => events.extend(gs.apply(Command::Parse { text: s.to_string() })), + Some(MenuAct::About) => { + gs.transcript.push("MRPGI \u{2014} Monster Robot Party Game Interpreter.".to_string()); + gs.transcript.push("A new-school Sierra AGI engine. Tab opens the room painter.".to_string()); + } + None => {} + } + // --- movement (arrows / click; paused while talking) --------- if !in_dlg { events.extend(gs.apply(Command::Walk { dir: read_keyboard_dir() })); - if is_mouse_button_pressed(MouseButton::Left) { + if is_mouse_button_pressed(MouseButton::Left) && !menu_consumed_click && menu_open.is_none() { let (mx, my) = mouse_position(); if let Some((px, py)) = layout.to_picture(mx, my) { events.extend(gs.apply(Command::MoveTo { x: px, y: py })); @@ -209,6 +349,7 @@ async fn main() { if crt { draw_scanlines(&layout); } + draw_menus(&menus, menu_open, mmx, mmy); draw_play_panel(&gs, &command, crt, &ai_label, !audio.muted); if gs.won { let sw2 = screen_width(); @@ -221,6 +362,22 @@ async fn main() { } } + if want_edit && mode == Mode::Play { + audio.set_music(0); + menu_open = None; + mode = Mode::Edit; + } + if let Some(path) = &shot { + shot_frames += 1; + if shot_frames == 3 { + menu_open = Some(0); // capture with the Game menu open + } + if shot_frames >= 6 { + get_screen_data().export_png(path); + eprintln!("shot saved to {}", path); + break; + } + } next_frame().await; } } @@ -262,11 +419,11 @@ impl Layout { fn compute() -> Self { let sw = screen_width(); let sh = screen_height(); - let avail_h = (sh - STATUS_H).max(1.0); + let avail_h = (sh - STATUS_H - MENU_H).max(1.0); let scale = (sw / 320.0).min(avail_h / PIC_H as f32); let w = 320.0 * scale; let h = PIC_H as f32 * scale; - Layout { x: (sw - w) * 0.5, y: 0.0, w, h } + Layout { x: (sw - w) * 0.5, y: MENU_H, w, h } } fn to_picture(&self, mx: f32, my: f32) -> Option<(i32, i32)> { diff --git a/web/index.html b/web/index.html index 15260df..beff930 100644 --- a/web/index.html +++ b/web/index.html @@ -20,11 +20,67 @@ pointer-events: none; transition: opacity .4s; } #boot b { font-size: 22px; letter-spacing: 2px; color: #ff87d7; } + #readme-tab { + position: fixed; top: 0; right: 24px; z-index: 10; + background: #ff87d7; color: #0b0d11; border: 0; + border-radius: 0 0 8px 8px; padding: 5px 14px 7px; + font: bold 13px ui-monospace, Menlo, monospace; letter-spacing: 1px; + cursor: pointer; + } + #readme { + position: fixed; top: 34px; right: 12px; z-index: 10; display: none; + width: min(340px, calc(100vw - 24px)); max-height: 80vh; overflow-y: auto; + background: #12151c; color: #cdd5e1; border: 1px solid #ff87d7; border-radius: 8px; + padding: 14px 16px; font: 13px/1.5 ui-monospace, Menlo, monospace; + } + #readme.open { display: block; } + #readme h1 { font-size: 15px; color: #ff87d7; margin: 0 0 6px; letter-spacing: 1px; } + #readme h2 { font-size: 13px; color: #5fffd7; margin: 12px 0 2px; } + #readme p { margin: 4px 0; } + #readme kbd { + background: #1e232b; border: 1px solid #48505f; border-radius: 4px; + padding: 0 5px; font: inherit; color: #fff; + }
MRPGIbooting the party…
+ +
+

MRPGI

+

A new-school take on Sierra's 1980s AGI adventure engine (King's Quest, + Space Quest), rebuilt in Rust and running here as one small WASM file — + engine, paint tool, and the game The DJ's Lost Fuse included.

+

Playing

+

←↑↓→ or click to walk · type verbs + + Enter (look, take fuse, use amp, talk bouncer…) · + menus along the top, like the classics · 16 + pick a dialogue reply · Esc leaves a chat.

+

Painting (press Tab)

+

The in-engine room editor. 10 tools · + Z undo (just Z — no Ctrl!) · L cycles the + look/walkability view · [ ] pick sprites · + , . brush size. You paint two layers at once: a colour + and a meaning (floor / wall / water). Making a lake? Paint with the + water tag — today it's scenery with plans (the engine reserves it + for swimming, like the original).

+

Saving needs a disk, so the web build is play + paint only — the + native app saves worlds as tiny JSON files.

+

Etc

+

F1 CRT scanlines · F2 sound · whole + thing is ~1 MB · also drivable headlessly by terminals, Python, + and AIs (it's an MCP server). Made by Monster Robot Party.

+
+