Sierra menu bar + web README tab (thanks Matt)

- 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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-05 11:14:48 +10:00
parent fb7524e847
commit 584424c939
2 changed files with 217 additions and 4 deletions

View File

@ -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<Rect> {
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<usize>, 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<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.
#[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<usize> = 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<Event> = 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<MenuAct> = 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)> {

View File

@ -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;
}
</style>
</head>
<body>
<div id="boot"><b>MRPGI</b><span>booting the party&hellip;</span></div>
<canvas id="glcanvas" tabindex="1"></canvas>
<button id="readme-tab">README</button>
<div id="readme">
<h1>MRPGI</h1>
<p>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 <i>The DJ's Lost Fuse</i> included.</p>
<h2>Playing</h2>
<p><kbd>&larr;&uarr;&darr;&rarr;</kbd> or click to walk &middot; type verbs +
<kbd>Enter</kbd> (<i>look, take fuse, use amp, talk bouncer&hellip;</i>) &middot;
menus along the top, like the classics &middot; <kbd>1</kbd>&ndash;<kbd>6</kbd>
pick a dialogue reply &middot; <kbd>Esc</kbd> leaves a chat.</p>
<h2>Painting (press <kbd>Tab</kbd>)</h2>
<p>The in-engine room editor. <kbd>1</kbd>&ndash;<kbd>0</kbd> tools &middot;
<kbd>Z</kbd> undo (just Z &mdash; no Ctrl!) &middot; <kbd>L</kbd> cycles the
look/walkability view &middot; <kbd>[</kbd> <kbd>]</kbd> pick sprites &middot;
<kbd>,</kbd> <kbd>.</kbd> brush size. You paint two layers at once: a colour
<i>and</i> a meaning (floor / wall / water). Making a lake? Paint with the
<b>water</b> tag &mdash; today it's scenery with plans (the engine reserves it
for swimming, like the original).</p>
<p>Saving needs a disk, so the web build is play + paint only &mdash; the
native app saves worlds as tiny JSON files.</p>
<h2>Etc</h2>
<p><kbd>F1</kbd> CRT scanlines &middot; <kbd>F2</kbd> sound &middot; whole
thing is ~1&nbsp;MB &middot; also drivable headlessly by terminals, Python,
and AIs (it's an MCP server). Made by Monster Robot Party.</p>
</div>
<script>
const tab = document.getElementById('readme-tab');
const panel = document.getElementById('readme');
tab.addEventListener('click', () => {
panel.classList.toggle('open');
tab.textContent = panel.classList.contains('open') ? 'CLOSE' : 'README';
if (!panel.classList.contains('open')) document.getElementById('glcanvas').focus();
});
</script>
<script src="mq_js_bundle.js"></script>
<script>
load("mrpgi.wasm");