Fix ego sprite and text wrap on the web build

ego_view() decided whether a game overrode the default party robot by
stat'ing sprites/ego.png on disk. A wasm build has no disk — the game is
baked into the binary with include_dir — so the check always failed and
every web player was a party robot regardless of what the game shipped.
Read the already-loaded sprite table instead, which both loaders populate
by file stem, so native and web now agree and native does one less read.

The transcript was drawn one draw_text per line with no wrapping, so any
line wider than the window ran off the right edge and was simply lost.
Room descriptions here are whole paragraphs, so this ate the end of most
of them. Greedy word-wrap on measured width, and the visible row count is
derived from the panel geometry rather than hardcoded at 5. Panel grows
150 -> 186 so a wrapped paragraph fits without pushing its own opening
line off the top.

Also: Game > Manual opened monsterrobot.games/engine/manual.html, which
has not served the manual since that domain moved hosts — it silently
returns the arcade index (200, so nothing looked wrong). Point native at
the repo's own web/manual.html, which always matches the build.

Verified: all 16 policesquadquest win paths still replay to max score,
and both fixes confirmed rendering in the native --shot and on the live
deploy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-27 21:30:14 +10:00
parent 827797ad1f
commit 2bf19d983e
2 changed files with 43 additions and 16 deletions

View File

@ -498,16 +498,15 @@ impl World {
/// dropping `sprites/ego.png` (feet on the bottom edge, like any prop);
/// `ego1.png` is an optional second cel for the walk bob.
pub fn ego_view(&self) -> view::View {
let cel = |f: &str| {
let p = self.base.join("sprites").join(f);
p.exists().then(|| assets::load_cel(&p.to_string_lossy(), false))
};
match cel("ego.png") {
// Read the already-loaded sprite table rather than stat'ing the folder:
// a wasm build is baked into the binary and has no `sprites/` to look at,
// so the filesystem version silently fell back to the robot on the web.
match self.sprite_cel("ego") {
Some(c0) => {
let cels = match cel("ego1.png") {
Some(c1) => vec![c0, c1],
None => vec![c0],
};
let mut cels = vec![c0.clone()];
if let Some(c1) = self.sprite_cel("ego1") {
cels.push(c1.clone());
}
view::View { loops: vec![view::ViewLoop { cels }] }
}
None => view::default_robot_view(),

View File

@ -26,7 +26,8 @@ fn window_conf() -> Conf {
}
}
const STATUS_H: f32 = 150.0;
// 7 text rows: wrapped room descriptions routinely run to three or four.
const STATUS_H: f32 = 186.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;
@ -49,17 +50,18 @@ enum MenuAct {
About,
}
const MANUAL_URL: &str = "https://monsterrobot.games/engine/manual.html";
/// Open the Book: a new tab on the web, the default browser natively.
fn open_manual() {
#[cfg(target_arch = "wasm32")]
unsafe {
web_bridge::mrpgi_open_manual();
}
// The repo's own copy, so it always matches this build. The old
// monsterrobot.games/engine/ URL now falls through to the arcade index.
#[cfg(not(target_arch = "wasm32"))]
{
let _ = std::process::Command::new("open").arg(MANUAL_URL).spawn();
let p = concat!(env!("CARGO_MANIFEST_DIR"), "/../web/manual.html");
let _ = std::process::Command::new("open").arg(p).spawn();
}
}
@ -758,6 +760,24 @@ fn draw_scanlines(l: &Layout) {
}
}
/// Greedy word-wrap to a pixel width. A word longer than the line is left to
/// overhang rather than hyphenated — that only happens to URLs.
fn wrap(s: &str, size: f32, max_w: f32) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut cur = String::new();
for w in s.split_whitespace() {
let trial = if cur.is_empty() { w.to_string() } else { format!("{} {}", cur, w) };
if !cur.is_empty() && measure_text(&trial, None, size as u16, 1.0).width > max_w {
out.push(std::mem::take(&mut cur));
cur = w.to_string();
} else {
cur = trial;
}
}
out.push(cur);
out
}
fn draw_play_panel(gs: &GameState, command: &str, crt: bool, ai: &str, sound_on: bool) {
let sw = screen_width();
let sh = screen_height();
@ -765,15 +785,23 @@ fn draw_play_panel(gs: &GameState, command: &str, crt: bool, ai: &str, sound_on:
draw_rectangle(0.0, top, sw, STATUS_H, color_u8!(12, 14, 18, 255));
draw_line(0.0, top, sw, top, 1.0, color_u8!(60, 70, 85, 255));
// Room descriptions are whole paragraphs, so wrap to the panel before
// drawing — draw_text will otherwise run a long line off the right edge.
let rows = (((sh - 38.0) - (top + 20.0)) / 18.0).floor().max(1.0) as usize;
let mut yy = top + 20.0;
let start = gs.transcript.len().saturating_sub(5);
let start = gs.transcript.len().saturating_sub(rows);
let mut vis: Vec<(String, bool)> = Vec::new();
for line in &gs.transcript[start..] {
let col = if line.starts_with('>') {
let cmd = line.starts_with('>');
vis.extend(wrap(&ascii_fold(line), 18.0, sw - 24.0).into_iter().map(|l| (l, cmd)));
}
for (line, cmd) in vis.iter().skip(vis.len().saturating_sub(rows)) {
let col = if *cmd {
color_u8!(255, 135, 215, 255)
} else {
color_u8!(205, 213, 225, 255)
};
draw_text(&ascii_fold(line), 12.0, yy, 18.0, col);
draw_text(line, 12.0, yy, 18.0, col);
yy += 18.0;
}