Let a game supply its own ego sprite and death text

Two general-purpose additions, both needed by an external game folder and
useful to any other:

- World::ego_view() — a game folder with sprites/ego.png uses it as the
  player avatar instead of the hardcoded party robot (optional ego1.png
  gives the walk bob a second cel). Refreshed on LoadGame and ReloadSprites.
- GameManifest.death_text — the death banner was hardcoded to morp2's
  "Morp is not cruel; Morp is accurate", which leaked that game's flavour
  into every other one. Now a manifest field; the default keeps the old
  wording minus the morp2 reference, so existing games are unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-25 22:11:16 +10:00
parent 23865f1483
commit 0b8fe85ccd
2 changed files with 33 additions and 3 deletions

View File

@ -12,7 +12,6 @@ use crate::framebuffer::{CTRL_BLOCK, PIC_H, PIC_W};
use crate::parser::{self, Dlg, VerbDef};
use crate::render;
use crate::sprite::{Prop, ScreenObj};
use crate::view::default_robot_view;
use crate::world::{find_spawn, ObjDef, RoomDoc, Stroke, World};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@ -147,7 +146,7 @@ pub struct GameState {
impl GameState {
pub fn new(world: World, ai: ai::AiConfig) -> Self {
let mut ego = ScreenObj::new(default_robot_view(), 80, 150);
let mut ego = ScreenObj::new(world.ego_view(), 80, 150);
ego.min_x = 4;
ego.max_x = PIC_W as i32 - 4;
ego.min_y = 16;
@ -350,12 +349,14 @@ impl GameState {
}
Command::LoadGame { dir } => {
self.world = World::load_game(dir);
self.ego.view = self.world.ego_view();
self.verbs = parser::build_verbs(&self.world.manifest.verbs);
ev.push(Event::GameLoaded { name: self.world.manifest.name.clone(), room: self.world.current });
ev.extend(self.new_game(None));
}
Command::ReloadSprites => {
self.world.reload_sprites();
self.ego.view = self.world.ego_view();
ev.push(self.edited());
}
}
@ -459,7 +460,8 @@ impl GameState {
for l in &cause_lines {
ev.push(self.line(l));
}
ev.push(self.line("\u{2020} You have died. Morp is not cruel; Morp is accurate. The day restarts. \u{2020}"));
let death = self.world.manifest.death_text.clone();
ev.push(self.line(&death));
ev
}

View File

@ -203,11 +203,18 @@ pub struct GameManifest {
/// Total points on offer; 0 = scoring off (no HUD line, no score events).
#[serde(default)]
pub max_score: u32,
/// The banner shown when the player dies and the day restarts.
#[serde(default = "default_death")]
pub death_text: String,
}
fn default_name() -> String {
"untitled".into()
}
fn default_death() -> String {
"\u{2020} You have died. The day restarts. \u{2020}".into()
}
fn default_intro() -> String {
"You blink awake. Type 'help'.".into()
}
@ -221,6 +228,7 @@ impl Default for GameManifest {
verbs: Vec::new(),
flags: HashMap::new(),
max_score: 0,
death_text: default_death(),
}
}
}
@ -486,6 +494,26 @@ impl World {
self.bake();
}
/// The ego's view. A game folder overrides the default party robot by
/// 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") {
Some(c0) => {
let cels = match cel("ego1.png") {
Some(c1) => vec![c0, c1],
None => vec![c0],
};
view::View { loops: vec![view::ViewLoop { cels }] }
}
None => view::default_robot_view(),
}
}
/// Sanity-check a room before committing it (the lore-ingestion gate):
/// exits must point at rooms that exist (on disk, in the overlay, or being
/// upserted alongside), and the room must have somewhere to stand.