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:
parent
fb7524e847
commit
584424c939
@ -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)> {
|
||||
|
||||
@ -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…</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>←↑↓→</kbd> or click to walk · type verbs +
|
||||
<kbd>Enter</kbd> (<i>look, take fuse, use amp, talk bouncer…</i>) ·
|
||||
menus along the top, like the classics · <kbd>1</kbd>–<kbd>6</kbd>
|
||||
pick a dialogue reply · <kbd>Esc</kbd> leaves a chat.</p>
|
||||
<h2>Painting (press <kbd>Tab</kbd>)</h2>
|
||||
<p>The in-engine room editor. <kbd>1</kbd>–<kbd>0</kbd> tools ·
|
||||
<kbd>Z</kbd> undo (just Z — no Ctrl!) · <kbd>L</kbd> cycles the
|
||||
look/walkability view · <kbd>[</kbd> <kbd>]</kbd> pick sprites ·
|
||||
<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 — 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 — the
|
||||
native app saves worlds as tiny JSON files.</p>
|
||||
<h2>Etc</h2>
|
||||
<p><kbd>F1</kbd> CRT scanlines · <kbd>F2</kbd> sound · whole
|
||||
thing is ~1 MB · 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");
|
||||
|
||||
Loading…
Reference in New Issue
Block a user