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;
|
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)]
|
#[derive(PartialEq)]
|
||||||
enum Mode {
|
enum Mode {
|
||||||
@ -34,6 +37,90 @@ enum Mode {
|
|||||||
Play,
|
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
|
/// Browser boot: the game folder is compiled into the binary (there is no
|
||||||
/// filesystem on the web) and handed to the core fully parsed.
|
/// filesystem on the web) and handed to the core fully parsed.
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
@ -90,13 +177,22 @@ async fn main() {
|
|||||||
let mut gs = GameState::new(world, ai_cfg);
|
let mut gs = GameState::new(world, ai_cfg);
|
||||||
let mut ed = Editor::new(&game_dir);
|
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.
|
// Native boots into the editor; the web build boots straight into the game.
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[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")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
let mut mode = Mode::Play;
|
let mut mode = Mode::Play;
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
let _ = gs.apply(Command::NewGame { room: None });
|
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 mut play_img = Image::gen_image_color(PIC_W as u16, PIC_H as u16, BLACK);
|
||||||
let play_tex = Texture2D::from_image(&play_img);
|
let play_tex = Texture2D::from_image(&play_img);
|
||||||
@ -104,9 +200,11 @@ async fn main() {
|
|||||||
|
|
||||||
let mut crt = false;
|
let mut crt = false;
|
||||||
let mut command = String::new();
|
let mut command = String::new();
|
||||||
|
let mut menu_open: Option<usize> = None;
|
||||||
let mut audio = sound::AudioOut::load(&game_dir).await;
|
let mut audio = sound::AudioOut::load(&game_dir).await;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
let mut want_edit = false;
|
||||||
if is_key_pressed(KeyCode::Escape) {
|
if is_key_pressed(KeyCode::Escape) {
|
||||||
if mode == Mode::Edit && ed.is_editing() {
|
if mode == Mode::Edit && ed.is_editing() {
|
||||||
ed.stop_editing();
|
ed.stop_editing();
|
||||||
@ -153,10 +251,52 @@ async fn main() {
|
|||||||
let in_dlg = gs.dlg.is_some();
|
let in_dlg = gs.dlg.is_some();
|
||||||
let mut events: Vec<Event> = Vec::new();
|
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) ---------
|
// --- movement (arrows / click; paused while talking) ---------
|
||||||
if !in_dlg {
|
if !in_dlg {
|
||||||
events.extend(gs.apply(Command::Walk { dir: read_keyboard_dir() }));
|
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();
|
let (mx, my) = mouse_position();
|
||||||
if let Some((px, py)) = layout.to_picture(mx, my) {
|
if let Some((px, py)) = layout.to_picture(mx, my) {
|
||||||
events.extend(gs.apply(Command::MoveTo { x: px, y: py }));
|
events.extend(gs.apply(Command::MoveTo { x: px, y: py }));
|
||||||
@ -209,6 +349,7 @@ async fn main() {
|
|||||||
if crt {
|
if crt {
|
||||||
draw_scanlines(&layout);
|
draw_scanlines(&layout);
|
||||||
}
|
}
|
||||||
|
draw_menus(&menus, menu_open, mmx, mmy);
|
||||||
draw_play_panel(&gs, &command, crt, &ai_label, !audio.muted);
|
draw_play_panel(&gs, &command, crt, &ai_label, !audio.muted);
|
||||||
if gs.won {
|
if gs.won {
|
||||||
let sw2 = screen_width();
|
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;
|
next_frame().await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -262,11 +419,11 @@ impl Layout {
|
|||||||
fn compute() -> Self {
|
fn compute() -> Self {
|
||||||
let sw = screen_width();
|
let sw = screen_width();
|
||||||
let sh = screen_height();
|
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 scale = (sw / 320.0).min(avail_h / PIC_H as f32);
|
||||||
let w = 320.0 * scale;
|
let w = 320.0 * scale;
|
||||||
let h = PIC_H as f32 * 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)> {
|
fn to_picture(&self, mx: f32, my: f32) -> Option<(i32, i32)> {
|
||||||
|
|||||||
@ -20,11 +20,67 @@
|
|||||||
pointer-events: none; transition: opacity .4s;
|
pointer-events: none; transition: opacity .4s;
|
||||||
}
|
}
|
||||||
#boot b { font-size: 22px; letter-spacing: 2px; color: #ff87d7; }
|
#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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="boot"><b>MRPGI</b><span>booting the party…</span></div>
|
<div id="boot"><b>MRPGI</b><span>booting the party…</span></div>
|
||||||
<canvas id="glcanvas" tabindex="1"></canvas>
|
<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 src="mq_js_bundle.js"></script>
|
||||||
<script>
|
<script>
|
||||||
load("mrpgi.wasm");
|
load("mrpgi.wasm");
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user