Skip to main content

dotzuki_runner/
game.rs

1//! The `dotzuki run` game runtime: [`RunnerGame`].
2//!
3//! [`RunnerGame`] boots a [`LoadedProject`] into a playable game with **zero
4//! game-specific code**: an overworld driven by the generic
5//! [`OverworldActor`], storyline dispatch from the DSL routing table
6//! (`@trigger` declarations), a scene-pumping VM on top of
7//! [`ScriptEngine`] (one short-lived engine per scene activation, the wuxia
8//! pattern), textbox/choice UI from `dotzuki-ui`, and placeholder sprites drawn
9//! procedurally (no embedded assets).
10//!
11//! # State machine
12//!
13//! ```text
14//! Overworld ──A on NPC──▶ Text ──last page + A──▶ (pump) ──▶ Overworld
15//!    │                     ▲   │ ShowChoice        │ next command
16//!    │step onto warp       │   ▼                   ▼
17//!    │                     └── Choice ──A──▶ signal_done(Number) ──▶ (pump)
18//!    │Start
19//!    ▼
20//! Menu (party / bag / save) ──B/Start──▶ Overworld
21//! WarpTransition (fade out → load map → fade in → opening dispatch)
22//! ```
23//!
24//! A [`Mode::Delay`] suspends the scene for N frames. `Mode::Idle` is the
25//! resting state of a map-less (dialogue-only) project once its entry scene
26//! has finished.
27//!
28//! # Scene dispatch rules
29//!
30//! On entering a map (boot or warp), after any fade-in:
31//!
32//! 1. every `on_enter` route for the map fires, sequentially;
33//! 2. else the map scene's `<SceneName>OnLoad` (from `@load`) runs;
34//! 3. else the map scene's storyline `main` plays once, guarded by the
35//!    `__played_main_<map>` flag.
36//!
37//! Talking to an NPC tries, in order: the NPC's `talk` field as a storyline
38//! name, a route whose `npc` matches the NPC's name/id, the map scene's
39//! `main`, and finally the `talk` field shown as a raw one-off line.
40//!
41//! # Command handling
42//!
43//! v1 handles `ShowText`, `ShowChoice`, `Delay`, `WarpTo`, `FadeScreen`,
44//! `SetFlag`/`ResetFlag`/`CheckFlag` and the audio commands (played through
45//! [`RunnerAudio`] when the project ships `data/audio/` tracks and a device
46//! is available; silent no-ops otherwise). `StartBattle`/`StartWildBattle`
47//! suspend the scene and arm the generic battle system ([`crate::battle`])
48//! when the manifest has a `battle` section — otherwise they auto-complete
49//! with `"win"` like any unimplemented command; a lost battle arms the
50//! game-over whiteout (see [`menu`]). `OpenShop` suspends the scene and
51//! opens the buy-only shop UI. Any other command logs a loud warning and is
52//! auto-completed with `Void` — an unimplemented command must never deadlock
53//! the scene VM.
54//!
55//! # Menus, shops, game-over
56//!
57//! Start in the overworld opens the pause menu (party view / bag / save);
58//! the scene `openShop` command opens a buy-only shop; losing a battle
59//! triggers the whiteout (heal + return to the entry spawn). All three live
60//! in the [`menu`] child module; money is runner-owned, seeded from the
61//! manifest's `shop` section and carried in the v3 save.
62//!
63//! # Random encounters
64//!
65//! A map's objects sidecar may carry an `encounters` block (`{rate, zones}`
66//! — pokered `wild_data`-shaped, `rate` in /256 per-step units). A completed
67//! walk step onto a zoned tile rolls once; a hit picks a weighted id from
68//! the zone's table and arms a **sceneless** battle (no scene suspended —
69//! win/run returns to the overworld in place, a loss goes straight to the
70//! whiteout). Warp tiles take priority over the roll on the same tile.
71
72#[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
73use std::collections::HashSet;
74use std::collections::{HashMap, VecDeque};
75use std::path::{Path, PathBuf};
76
77use anyhow::{Context, Result};
78use dotzuki_engine::camera::{Camera, Rect, Vec2};
79use dotzuki_engine::menu::MenuConfig;
80use dotzuki_engine::overworld::actor::{frame_col, OverworldActor, OverworldCollision};
81use dotzuki_engine::overworld::types::Direction;
82use dotzuki_engine::render::{FrameBuffer, Rgba, TileRect, Ui};
83use dotzuki_engine_script::command::{CommandResult, ScriptCommand};
84use dotzuki_engine_script::engine::ScriptEngine;
85use dotzuki_renderer::embedded_font;
86use dotzuki_renderer::input::{GbButton, InputState};
87use dotzuki_renderer::walk_sprite::WalkSprite;
88use dotzuki_ui::widgets::dialog::{draw_dialog, wrap_lines};
89use dotzuki_ui::widgets::flex_menu::{draw_flex_menu, FlexMenuState};
90use dotzuki_ui::FrameBufferPainter;
91
92use crate::audio::RunnerAudio;
93use crate::battle::{
94    Battle, BattleOutcome, BattleRng, BattleSetup, PartyMemberState, ScriptedRng, XorshiftRng,
95};
96use crate::manifest::DEFAULT_START_MONEY;
97use crate::map::RuntimeMap;
98use crate::project::LoadedProject;
99use crate::save::{GameSave, PartyMemberSave, PlayerSave, DEFAULT_SAVE_FILE, SAVE_VERSION};
100use crate::vfs::join_path;
101#[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
102use crate::watch::ProjectWatcher;
103
104mod menu;
105use menu::{MenuState, ShopState, WhiteoutState};
106
107/// Logical framebuffer width (window mode and headless rendering).
108pub const SCREEN_W: i32 = 320;
109/// Logical framebuffer height.
110pub const SCREEN_H: i32 = 240;
111
112/// Fade frames per warp-transition phase (out / in).
113const FADE_FRAMES: u32 = 10;
114/// Cosmetic blackout frames for a `FadeScreen` command.
115const FLASH_FRAMES: u32 = 10;
116/// Frames to wait after applying a hot-reload batch before the next one —
117/// coalesces editor save bursts into a single reload.
118#[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
119const WATCH_DEBOUNCE_FRAMES: u32 = 10;
120
121/// Tile-grid geometry of the bottom dialogue box (8px tiles, 40×30 grid).
122pub(crate) const DIALOG_AREA: TileRect = TileRect::new(0, 24, 40, 6);
123/// Text lines per dialogue page (`content.th / line_height`, as in draw_dialog).
124const DIALOG_LINES_PER_PAGE: usize = 2;
125/// Wrap budget for a dialogue line: the box interior width in pixels
126/// (Fusion Pixel font: Latin 5px, CJK 10px advance — see
127/// `dotzuki_renderer::embedded_font::char_advance`).
128const DIALOG_WIDTH_PX: usize = (DIALOG_AREA.tw as usize - 2) * 8;
129
130/// Options for booting a [`RunnerGame`].
131#[derive(Debug, Clone, Default)]
132pub struct RunnerOptions {
133    /// Map to spawn on, overriding the manifest's `game.entryMap`.
134    pub map: Option<String>,
135    /// UI/script language (`"en"` / `"zh"`); drives `@t` bilingual text.
136    pub lang: String,
137    /// Watch the project's data/gfx/scene dirs and hot-reload changed
138    /// content (windowed mode; the CLI ignores this for headless runs).
139    pub watch: bool,
140    /// Headless run: never opens an audio device — audio commands resolve
141    /// silently (CI/smoke tests).
142    pub headless: bool,
143    /// PCM pull-render audio (WASM shell): play commands create the audio
144    /// engine without an output device, and the host pulls samples via
145    /// [`RunnerGame::render_audio`]. Independent of `headless` — cpal is
146    /// never touched either way.
147    pub pcm_audio: bool,
148    /// Ignore an existing save file on boot (`--fresh`).
149    pub fresh: bool,
150    /// Save file location override (`--save-file`); the default is
151    /// `<project>/.dotzuki-save.json`.
152    pub save_file: Option<PathBuf>,
153    /// Deterministic battle rng: when set, every battle draws from this byte
154    /// script (cycling) instead of a seeded PRNG. Test/CI hook.
155    pub rng_script: Option<Vec<u8>>,
156    /// Write saves at stable points (warp/scene completion). The CLI sets
157    /// this for windowed runs; headless runs only with an explicit opt-in
158    /// (`--save`), keeping CI side-effect-free. Loading is independent — a
159    /// valid save always resumes unless `fresh`/`map` say otherwise.
160    pub write_saves: bool,
161}
162
163/// Live textbox state: pages waiting on A presses. `engine` is `Some` for a
164/// scene-driven text (A on the last page resolves the `showText` promise);
165/// `None` for a one-off line (raw NPC `talk` text), which just closes.
166struct TextState {
167    engine: Option<ScriptEngine>,
168    pages: VecDeque<String>,
169}
170
171/// Live choice state: the scene is paused on `await game.showChoice([...])`;
172/// A resumes it with `signal_done(Number(cursor))`.
173struct ChoiceState {
174    engine: ScriptEngine,
175    options: Vec<String>,
176    cursor: usize,
177    /// The text page that preceded the choice (redrawn beneath the menu).
178    context_text: String,
179}
180
181/// Live delay state: the scene resumes after `frames_left` frames.
182struct DelayState {
183    engine: ScriptEngine,
184    frames_left: u16,
185}
186
187/// Live battle state: the battle itself plus the scene engine suspended on
188/// `await startBattle(...)` — `None` for a sceneless battle (a random
189/// encounter armed by walking; nothing to resume). When a scene battle
190/// resolves, the engine resumes with `signal_done(Text("win"|"lose"|"run"))`
191/// so the scene's own JS branches on the result (the wuxia pattern); a
192/// sceneless battle returns straight to the overworld (or the whiteout).
193struct BattleState {
194    engine: Option<ScriptEngine>,
195    battle: Battle,
196}
197
198/// Runtime mode — what currently owns input.
199enum Mode {
200    /// Free overworld movement.
201    Overworld,
202    /// A textbox is on screen.
203    Text(TextState),
204    /// A choice menu is on screen.
205    Choice(ChoiceState),
206    /// A scene-imposed delay is counting down.
207    Delay(DelayState),
208    /// A battle is running (the scene is suspended). Boxed: the battle is
209    /// by far the largest mode payload.
210    Battle(Box<BattleState>),
211    /// The overworld Start menu (party view / bag / save) is open; the
212    /// overworld is frozen underneath.
213    Menu(MenuState),
214    /// A scene-opened shop (`openShop`) is open; the scene is suspended.
215    Shop(Box<ShopState>),
216    /// The game-over whiteout after a lost battle: blackout + message, then
217    /// the party heals and the player returns to the entry map's spawn.
218    Whiteout(WhiteoutState),
219    /// Map-less project whose entry scene has finished; nothing to do.
220    Idle,
221}
222
223/// Fade phase of an overworld warp transition.
224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225enum FadePhase {
226    Out,
227    In,
228}
229
230/// An in-progress overworld warp: fade out, switch maps, fade in.
231struct WarpTransition {
232    dest_map: String,
233    dest_x: i32,
234    dest_y: i32,
235    phase: FadePhase,
236    frames: u32,
237}
238
239impl WarpTransition {
240    fn new(dest_map: String, dest_x: i32, dest_y: i32) -> Self {
241        Self {
242            dest_map,
243            dest_x,
244            dest_y,
245            phase: FadePhase::Out,
246            frames: FADE_FRAMES,
247        }
248    }
249}
250
251/// Walkability view the [`OverworldActor`] queries: map collision unioned
252/// with the current NPC tiles (the player stops in front of NPCs).
253struct CollisionView<'a> {
254    map: &'a RuntimeMap,
255}
256
257impl CollisionView<'_> {
258    /// Any NPC standing on `(x, y)`?
259    fn npc_blocks(&self, x: i32, y: i32) -> bool {
260        self.map.objects().npcs.iter().any(|n| (n.x, n.y) == (x, y))
261    }
262}
263
264impl OverworldCollision for CollisionView<'_> {
265    fn is_blocked(&self, x: i32, y: i32) -> bool {
266        self.map.is_blocked(x, y) || self.npc_blocks(x, y)
267    }
268
269    fn is_blocked_at(&self, level: u8, x: i32, y: i32) -> bool {
270        // NPCs block their tile at every elevation level (simplest rule: a
271        // person is in the way regardless of the player's height).
272        self.map.is_blocked_at(level, x, y) || self.npc_blocks(x, y)
273    }
274}
275
276/// A booted zero-Rust game: overworld + scene VM + dialogue/choice UI.
277///
278/// Owns the [`LoadedProject`], the current [`RuntimeMap`], the player
279/// [`OverworldActor`], the persistent flag store (seeded into / harvested
280/// from each short-lived scene engine) and the mode state machine. Drive it
281/// with [`update`](Self::update) + [`draw`](Self::draw) — directly (headless)
282/// or via the `dotzuki_app::GameLoop` impl (windowed).
283pub struct RunnerGame {
284    project: LoadedProject,
285    /// The current map; `None` in dialogue-only mode (project without maps).
286    map: Option<RuntimeMap>,
287    camera: Camera,
288    actor: OverworldActor,
289    /// `gfx/overworld/player/sheet.png` when the project ships one.
290    player_sprite: Option<WalkSprite>,
291    /// Persistent story flags (cross-scene truth).
292    flags: HashMap<String, bool>,
293    lang: String,
294    mode: Mode,
295    /// In-progress overworld warp fade (owns input while active).
296    transition: Option<WarpTransition>,
297    /// `on_enter` storylines queued to fire one after another.
298    pending_scenes: VecDeque<(String, String)>,
299    /// A scene-triggered `WarpTo` defers the destination's opening dispatch
300    /// until the scene completes (post-warp text must play out first).
301    opening_dispatch_pending: bool,
302    /// Name of the scene currently being pumped (for diagnostics).
303    active_scene: Option<String>,
304    /// The text page most recently shown (context under a choice menu).
305    last_text: String,
306    /// Cosmetic blackout counter for `FadeScreen` commands.
307    flash: u32,
308    /// Total frames updated (animation pacing / diagnostics).
309    frame_count: u64,
310    /// File watcher for `--watch` (`None` when watching is off or the
311    /// watcher failed to start — the game runs fine either way).
312    #[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
313    watcher: Option<ProjectWatcher>,
314    /// Changed paths accumulated since the last applied reload batch.
315    #[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
316    watch_pending: HashSet<PathBuf>,
317    /// Frames until the next reload batch may apply (burst coalescing).
318    #[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
319    watch_cooldown: u32,
320    /// Music/SFX playback for scene audio commands; silent when the project
321    /// ships no `data/audio/` tracks or no output device is available.
322    audio: RunnerAudio,
323    /// Where the save file lives (`<project>/.dotzuki-save.json` by default).
324    save_path: PathBuf,
325    /// Whether stable-state saves are written (see [`RunnerOptions::write_saves`]).
326    write_saves: bool,
327    /// Deterministic battle rng byte script (see [`RunnerOptions::rng_script`]).
328    rng_script: Option<Vec<u8>>,
329    /// The persistent party state (v2-b): every party member's current
330    /// HP/MP/status, harvested at the end of each battle (win AND lose) and
331    /// restored from a save. `None` until the first battle (or a save
332    /// carrying it) — the next battle then starts from the records.
333    party_state: Option<Vec<PartyMemberState>>,
334    /// The persistent battle inventory (v2-b), same lifecycle as
335    /// `party_state`; `None` ⇒ the manifest's `items.starting` counts.
336    inventory: Option<HashMap<String, u32>>,
337    /// The player's money (v3): initialized from the manifest's
338    /// `shop.startMoney` (default 100) on a fresh boot, carried in the save.
339    money: u32,
340    /// A lost battle arms the game-over whiteout, triggered when the scene
341    /// that received `"lose"` finishes into the overworld/idle (its post-lose
342    /// text plays first). Cleared when a battle is won instead.
343    pending_whiteout: bool,
344    /// The weather a scene armed with `setWeather` (v2-e): a `kind: Weather`
345    /// RON record id handed to the NEXT battle (battle-local — cleared when
346    /// that battle ends, never saved). `clearWeather` resets it to `None`.
347    pending_weather: Option<String>,
348    /// The overworld's own entropy source for random-encounter rolls,
349    /// seeded lazily on the first roll (a scripted stream when
350    /// [`RunnerOptions::rng_script`] is set). Separate from the per-battle
351    /// rng so step rolls can't perturb battle determinism.
352    overworld_rng: Option<Box<dyn BattleRng>>,
353    /// Completed walk steps since boot; mixed into the wasm32 rng seed (the
354    /// frame counter alone is 0 at boot there).
355    steps_taken: u64,
356}
357
358impl RunnerGame {
359    /// Boot the project: load the entry map (`opts.map` override or
360    /// `game.entryMap`), spawn the player, and run the map's opening
361    /// dispatch. A project with **no maps** boots dialogue-only: the entry
362    /// scene's `main` storyline runs to completion, then the game idles.
363    ///
364    /// # Errors
365    ///
366    /// Fails when the entry map (or `--map` override) cannot be loaded, or
367    /// when a map-less project has no entry scene.
368    pub fn new(project: LoadedProject, opts: RunnerOptions) -> Result<Self> {
369        let lang = if opts.lang.is_empty() {
370            "en".to_string()
371        } else {
372            opts.lang
373        };
374        let gfx_rel = project.gfx_root_rel();
375        let player_sprite = {
376            let rel = join_path(&gfx_rel, "overworld/player/sheet.png");
377            match project.files().read(&rel) {
378                Ok(bytes) => decode_walk_sheet(&bytes, &rel, 24, 32)
379                    .map_err(|e| log::warn!("player sprite {rel}: {e}"))
380                    .ok(),
381                Err(_) => None,
382            }
383        };
384
385        let mut camera = Camera::new(SCREEN_W as f32, SCREEN_H as f32);
386        camera.smooth_factor = 0.0; // locked to the player
387
388        #[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
389        let watcher = if opts.watch {
390            ProjectWatcher::new(&watch_dirs(&project))
391                .map_err(|e| log::warn!("hot-reload disabled: {e}"))
392                .ok()
393        } else {
394            None
395        };
396        let mut audio = RunnerAudio::from_files(
397            project.files().as_ref(),
398            project.data_root_rel(),
399            !opts.headless,
400        );
401        if opts.pcm_audio {
402            audio.set_pcm_render(true);
403        }
404        let save_path = opts
405            .save_file
406            .clone()
407            .unwrap_or_else(|| project.root().join(DEFAULT_SAVE_FILE));
408
409        let start_money = project
410            .manifest()
411            .shop
412            .as_ref()
413            .map(|s| s.start_money)
414            .unwrap_or(DEFAULT_START_MONEY);
415
416        let mut game = Self {
417            project,
418            map: None,
419            camera,
420            actor: OverworldActor::new(0, 0, 16),
421            player_sprite,
422            flags: HashMap::new(),
423            lang,
424            mode: Mode::Idle,
425            transition: None,
426            pending_scenes: VecDeque::new(),
427            opening_dispatch_pending: false,
428            active_scene: None,
429            last_text: String::new(),
430            flash: 0,
431            frame_count: 0,
432            #[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
433            watcher,
434            #[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
435            watch_pending: HashSet::new(),
436            #[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
437            watch_cooldown: 0,
438            audio,
439            save_path,
440            write_saves: opts.write_saves,
441            rng_script: opts.rng_script.clone(),
442            party_state: None,
443            inventory: None,
444            money: start_money,
445            pending_whiteout: false,
446            pending_weather: None,
447            overworld_rng: None,
448            steps_taken: 0,
449        };
450
451        // Resume from a valid save unless `--fresh` or `--map` says
452        // otherwise. A corrupt/incompatible save logs and falls through to
453        // the normal boot. Disk saves are native-only; the WASM shell
454        // restores its localStorage save via `import_save` after boot.
455        #[cfg(not(target_arch = "wasm32"))]
456        if !opts.fresh && opts.map.is_none() {
457            if let Some(save) = GameSave::load(&game.save_path) {
458                if game.resume_from(save) {
459                    return Ok(game);
460                }
461            }
462        }
463
464        let map_ids = game.project.map_ids();
465        if map_ids.is_empty() {
466            // Dialogue-only boot: run the entry scene's `main` to completion.
467            let scene = game.project.entry_scene_name()?.to_string();
468            log::info!("dotzuki-runner: no maps; booting dialogue-only scene '{scene}'");
469            if !game.activate(&scene, "main") {
470                log::warn!("entry scene '{scene}' has no playable 'main' storyline");
471            }
472            return Ok(game);
473        }
474
475        let map_id = match &opts.map {
476            Some(id) => id.clone(),
477            None => game.project.entry_map()?,
478        };
479        game.boot_map(&map_id)
480            .with_context(|| format!("failed to load entry map '{map_id}'"))?;
481        game.dispatch_opening();
482        Ok(game)
483    }
484
485    /// Load `map_id` as the first map, spawning at its centre.
486    fn boot_map(&mut self, map_id: &str) -> Result<()> {
487        let map = self.project.load_map(map_id)?;
488        let spawn = find_spawn(&map);
489        let tile = map.tile_size().0 as i32;
490        self.camera.clamp_to_bounds(Rect::new(
491            0.0,
492            0.0,
493            map.pixel_width() as f32,
494            map.pixel_height() as f32,
495        ));
496        self.actor = OverworldActor::new(spawn.0, spawn.1, tile);
497        self.map = Some(map);
498        self.center_camera();
499        self.camera.update(0.0);
500        log::info!("loaded map {map_id} @ ({},{})", spawn.0, spawn.1);
501        Ok(())
502    }
503
504    /// Switch to another map, placing the player at `spawn` facing `facing`.
505    /// Returns `false` (leaving the current map untouched) when the
506    /// destination can't be loaded, so a bad warp aborts gracefully.
507    #[must_use]
508    fn enter_map(&mut self, map_id: &str, spawn: (i32, i32), facing: Direction) -> bool {
509        let map = match self.project.load_map(map_id) {
510            Ok(m) => m,
511            Err(e) => {
512                log::error!("warp to {map_id} aborted: {e:#}");
513                return false;
514            }
515        };
516        let spawn = if map.is_blocked(spawn.0, spawn.1)
517            || map.objects().npcs.iter().any(|n| (n.x, n.y) == spawn)
518        {
519            let fallback = find_spawn(&map);
520            log::warn!(
521                "spawn ({},{}) on {map_id} is occupied; using ({},{})",
522                spawn.0,
523                spawn.1,
524                fallback.0,
525                fallback.1
526            );
527            fallback
528        } else {
529            spawn
530        };
531        self.camera.clamp_to_bounds(Rect::new(
532            0.0,
533            0.0,
534            map.pixel_width() as f32,
535            map.pixel_height() as f32,
536        ));
537        self.map = Some(map);
538        self.actor.place(spawn.0, spawn.1, facing);
539        // Warps land on the ground level (stairs are the only way up).
540        self.actor.set_elevation(0);
541        self.center_camera();
542        self.camera.update(0.0);
543        log::info!("loaded map {map_id} @ ({},{})", spawn.0, spawn.1);
544        true
545    }
546
547    // ── save/load ───────────────────────────────────────────────────────────
548
549    /// Resume a save: seed the persistent flags, load its map and place the
550    /// player at the saved tile (falling back to the spawn scan when that
551    /// tile has become occupied). The opening dispatch is **skipped** on
552    /// resume — the player is continuing, not entering; the restored
553    /// `__played_main_*` flags keep `main` from replaying on later entries.
554    ///
555    /// Flags are restored from any valid save (they are the only resumable
556    /// state of a dialogue-only project). Returns `false` — fresh boot —
557    /// when the saved map can't be loaded.
558    fn resume_from(&mut self, save: GameSave) -> bool {
559        self.flags.extend(save.flags);
560        // v2 fields: a v1 save (or a v2 save that never battled) has neither —
561        // the first battle then starts from the records / starting counts.
562        if let Some(party) = save.party {
563            self.party_state = Some(
564                party
565                    .into_iter()
566                    .map(|m| PartyMemberState {
567                        id: m.id,
568                        hp: m.hp,
569                        mp: m.mp,
570                        status: m.status,
571                        level: m.level,
572                        exp: m.exp,
573                    })
574                    .collect(),
575            );
576        }
577        if save.inventory.is_some() {
578            self.inventory = save.inventory;
579        }
580        if let Some(money) = save.money {
581            self.money = money;
582        }
583        let Some(map_id) = &save.map else {
584            log::info!("save: restored flags (dialogue-only project)");
585            return false;
586        };
587        let map = match self.project.load_map(map_id) {
588            Ok(m) => m,
589            Err(e) => {
590                log::warn!("save: saved map '{map_id}' failed to load ({e:#}) — starting fresh");
591                return false;
592            }
593        };
594        let (x, y) = (save.player.x, save.player.y);
595        let facing = parse_facing(&save.player.facing);
596        let spawn =
597            if map.is_blocked(x, y) || map.objects().npcs.iter().any(|n| (n.x, n.y) == (x, y)) {
598                let fallback = find_spawn(&map);
599                log::warn!(
600                    "save: position ({x},{y}) on {map_id} is occupied; using ({},{})",
601                    fallback.0,
602                    fallback.1
603                );
604                fallback
605            } else {
606                (x, y)
607            };
608        self.camera.clamp_to_bounds(Rect::new(
609            0.0,
610            0.0,
611            map.pixel_width() as f32,
612            map.pixel_height() as f32,
613        ));
614        self.map = Some(map);
615        self.actor.place(spawn.0, spawn.1, facing);
616        // Multi-level maps: restore the saved elevation, clamped to the map.
617        let max_level = self.map.as_ref().map(|m| m.level_count() - 1).unwrap_or(0) as u8;
618        self.actor.set_elevation(save.player.level.min(max_level));
619        self.center_camera();
620        self.camera.update(0.0);
621        self.mode = Mode::Overworld;
622        log::info!("save: resumed {map_id} @ ({},{})", spawn.0, spawn.1);
623        true
624    }
625
626    /// Write the current state to the save file. Called only from **stable**
627    /// states — after a completed warp transition and when a scene finishes
628    /// into the overworld/idle — never mid-scene or mid-warp (a suspended
629    /// scene engine can't be resumed). Closing the window mid-dialogue
630    /// therefore keeps the save from the last stable point. No-op when
631    /// [`RunnerOptions::write_saves`] is off.
632    fn write_save(&self) {
633        if !self.write_saves {
634            return;
635        }
636        self.write_save_now();
637    }
638
639    /// Write the current state to the save file, unconditionally. The Start
640    /// menu's Save entry uses this — saving from the menu is always allowed,
641    /// even where automatic stable-state saves are off (headless runs).
642    /// No-op on WASM (no disk; the shell persists [`export_save`] output).
643    ///
644    /// [`export_save`]: Self::export_save
645    pub(crate) fn write_save_now(&self) {
646        #[cfg(not(target_arch = "wasm32"))]
647        {
648            let save = self.current_save();
649            match save.write(&self.save_path) {
650                Ok(()) => log::info!("save: wrote {}", self.save_path.display()),
651                Err(e) => {
652                    log::warn!("save: write to {} failed: {e:#}", self.save_path.display())
653                }
654            }
655        }
656        #[cfg(target_arch = "wasm32")]
657        {
658            let _ = &self.save_path; // disk saves are native-only
659        }
660    }
661
662    /// The current state as a [`GameSave`] (map, player tile/facing, flags,
663    /// language, party, inventory, money).
664    fn current_save(&self) -> GameSave {
665        let (x, y) = self.actor.tile();
666        GameSave {
667            version: SAVE_VERSION,
668            map: self.current_map_id().map(str::to_string),
669            player: PlayerSave {
670                x,
671                y,
672                facing: facing_name(self.actor.facing()).to_string(),
673                level: self.actor.elevation(),
674            },
675            flags: self.flags.clone(),
676            lang: Some(self.lang.clone()),
677            party: self.party_state.as_ref().map(|party| {
678                party
679                    .iter()
680                    .map(|m| PartyMemberSave {
681                        id: m.id.clone(),
682                        hp: m.hp,
683                        mp: m.mp,
684                        status: m.status.clone(),
685                        level: m.level,
686                        exp: m.exp,
687                    })
688                    .collect()
689            }),
690            inventory: self.inventory.clone(),
691            money: Some(self.money),
692        }
693    }
694
695    /// Serialize the current state as save JSON — the persistence bridge for
696    /// the WASM shell (localStorage). Returns `None` while the game is in a
697    /// transient state that cannot round-trip (a scene engine suspended on
698    /// text/choice/delay/battle/shop, or a warp transition mid-flight);
699    /// stable states (overworld, menu, whiteout, idle) always export.
700    pub fn export_save(&self) -> Option<String> {
701        if self.transition.is_some()
702            || matches!(
703                self.mode,
704                Mode::Text(_) | Mode::Choice(_) | Mode::Delay(_) | Mode::Battle(_) | Mode::Shop(_)
705            )
706        {
707            return None;
708        }
709        Some(self.current_save().to_json())
710    }
711
712    /// Restore a save produced by [`export_save`](Self::export_save) (the
713    /// WASM shell's localStorage bridge). Returns `false` — the game keeps
714    /// its current state — on unparseable JSON, a NEWER save version, or a
715    /// saved map that no longer loads.
716    pub fn import_save(&mut self, json: &str) -> bool {
717        let save = match GameSave::from_json(json) {
718            Ok(save) => save,
719            Err(e) => {
720                log::warn!("save: import failed ({e:#}) — keeping current state");
721                return false;
722            }
723        };
724        if save.version > SAVE_VERSION {
725            log::warn!(
726                "save: imported save is version {} (newer than {SAVE_VERSION}) — keeping current state",
727                save.version
728            );
729            return false;
730        }
731        self.resume_from(save)
732    }
733
734    // ── scene dispatch ──────────────────────────────────────────────────────
735
736    /// Fire the current map's opening scene(s): `on_enter` routes first (all,
737    /// sequentially), then `<SceneName>OnLoad`, then a once-only `main`.
738    fn dispatch_opening(&mut self) {
739        let Some(map) = &self.map else {
740            return;
741        };
742        let map_id = map.id().to_string();
743
744        let on_enters: Vec<(String, String)> = self
745            .project
746            .routes()
747            .iter()
748            .filter(|r| r.map == map_id && r.on_enter)
749            .filter_map(|r| {
750                self.scene_with_storyline(&map_id, &r.storyline)
751                    .map(|scene| (scene, r.storyline.clone()))
752            })
753            .collect();
754        if !on_enters.is_empty() {
755            self.pending_scenes.extend(on_enters);
756            self.pop_pending_scene();
757            return;
758        }
759
760        let Some(scene) = self.scene_for_map(&map_id) else {
761            return;
762        };
763        let onload = format!("{scene}OnLoad");
764        if scene_has_fn(&self.project, &scene, &onload) {
765            self.activate(&scene, &onload);
766            return;
767        }
768        let played_flag = format!("__played_main_{map_id}");
769        if !self.flags.get(&played_flag).copied().unwrap_or(false)
770            && scene_has_fn(&self.project, &scene, "main")
771        {
772            // Set before playing so a failing scene can't retrigger every entry.
773            self.flags.insert(played_flag, true);
774            self.activate(&scene, "main");
775        }
776    }
777
778    /// The compiled scene belonging to a map: the scene whose source file is
779    /// `<maps_dir>/<map>/script.scene`, else a scene named like the map.
780    fn scene_for_map(&self, map_id: &str) -> Option<String> {
781        for (name, _js, source_path) in &self.project.report().scenes {
782            let path = Path::new(source_path);
783            if path.file_stem().is_some_and(|s| s == "script")
784                && path
785                    .parent()
786                    .and_then(Path::file_name)
787                    .is_some_and(|d| d == map_id)
788            {
789                return Some(name.clone());
790            }
791        }
792        if self.project.scripts().has_script(map_id) {
793            return Some(map_id.to_string());
794        }
795        None
796    }
797
798    /// The scene exporting a storyline: the current map's scene first, then
799    /// any compiled scene (matched on the generated export names).
800    fn scene_with_storyline(&self, map_id: &str, storyline: &str) -> Option<String> {
801        if let Some(scene) = self.scene_for_map(map_id) {
802            if scene_has_fn(&self.project, &scene, storyline) {
803                return Some(scene);
804            }
805        }
806        self.project
807            .report()
808            .scenes
809            .iter()
810            .map(|(name, _, _)| name)
811            .find(|name| scene_has_fn(&self.project, name, storyline))
812            .cloned()
813    }
814
815    /// Activate `storyline` from `scene` on a fresh [`ScriptEngine`] seeded
816    /// with the persistent flags, language and player position. Returns
817    /// `false` when the scene/function is missing or fails to start.
818    fn activate(&mut self, scene: &str, storyline: &str) -> bool {
819        let Some(js) = self.project.scripts().get_script(scene) else {
820            log::warn!("scene '{scene}' not registered");
821            return false;
822        };
823        let js = js.to_string();
824        let mut engine = ScriptEngine::new();
825        // Battle commands are not part of the engine's core `game.*` set —
826        // register them locally (the wuxia pattern) so `@command("startBattle", …)`
827        // / `result = startBattle(…)` yield ScriptCommands the pump can arm.
828        engine.register_async_fn("startBattle", |args, ctx| {
829            let trainer_id = match args.first() {
830                Some(v) => v.to_string(ctx)?.to_std_string_lossy(),
831                None => String::new(),
832            };
833            Ok(ScriptCommand::StartBattle { trainer_id })
834        });
835        engine.register_async_fn("startWildBattle", |args, ctx| {
836            let species = match args.first() {
837                Some(v) => v.to_string(ctx)?.to_std_string_lossy(),
838                None => String::new(),
839            };
840            let level = match args.get(1) {
841                Some(v) => v.to_number(ctx)? as u8,
842                None => 1,
843            };
844            Ok(ScriptCommand::StartWildBattle { species, level })
845        });
846        // Weather is runner-local too (the wuxia pattern): `setWeather(id)`
847        // arms a `kind: Weather` rules.ron record for the NEXT battle,
848        // `clearWeather()` cancels a previously armed one.
849        engine.register_async_fn("setWeather", |args, ctx| {
850            let weather = match args.first() {
851                Some(v) => v.to_string(ctx)?.to_std_string_lossy(),
852                None => String::new(),
853            };
854            Ok(ScriptCommand::SetWeather {
855                weather: Some(weather),
856            })
857        });
858        engine.register_async_fn("clearWeather", |_args, _ctx| {
859            Ok(ScriptCommand::SetWeather { weather: None })
860        });
861        engine.seed_flags(&self.flags);
862        engine.set_lang(&self.lang);
863        let (px, py) = self.actor.tile();
864        engine.set_player_position(px.clamp(0, 255) as u8, py.clamp(0, 255) as u8);
865        if let Err(e) = engine.load_script(&js) {
866            log::warn!("load scene '{scene}': {e}");
867            return false;
868        }
869        if !engine.has_function(storyline) {
870            log::warn!("scene '{scene}' has no function '{storyline}'");
871            return false;
872        }
873        log::info!("activate {scene}::{storyline}");
874        match engine.call_function_no_args(storyline) {
875            Ok(cmd) => {
876                self.active_scene = Some(scene.to_string());
877                self.pump(engine, cmd, true);
878                true
879            }
880            Err(e) => {
881                log::warn!("run {scene}::{storyline}: {e}");
882                false
883            }
884        }
885    }
886
887    /// Pop the next queued `on_enter` storyline and activate it. Returns
888    /// `false` when the queue is drained (or every activation failed).
889    fn pop_pending_scene(&mut self) -> bool {
890        while let Some((scene, storyline)) = self.pending_scenes.pop_front() {
891            if self.activate(&scene, &storyline) {
892                return true;
893            }
894        }
895        false
896    }
897
898    /// The scene VM: drive the command stream from `cmd` onward, owning
899    /// `engine`. Suspends (storing the engine in [`Mode`]) on commands that
900    /// need UI or time; transparently resolves side-effect commands; ends the
901    /// scene on stream end or error. `first` marks the initial command of a
902    /// fresh activation — an immediate `None` there means the scene produced
903    /// nothing at all (typically an unregistered `game.*` call killed it).
904    fn pump(&mut self, mut engine: ScriptEngine, mut cmd: Option<ScriptCommand>, first: bool) {
905        let scene = self.active_scene.clone().unwrap_or_default();
906        let mut first = first;
907        loop {
908            match cmd {
909                Some(ScriptCommand::ShowText { text }) => {
910                    self.last_text = text.clone();
911                    self.mode = Mode::Text(TextState {
912                        engine: Some(engine),
913                        pages: paginate(&text),
914                    });
915                    return;
916                }
917                Some(ScriptCommand::ShowChoice { options }) => {
918                    self.mode = Mode::Choice(ChoiceState {
919                        engine,
920                        options,
921                        cursor: 0,
922                        context_text: self.last_text.clone(),
923                    });
924                    return;
925                }
926                Some(ScriptCommand::Delay { frames }) => {
927                    self.mode = Mode::Delay(DelayState {
928                        engine,
929                        frames_left: frames,
930                    });
931                    return;
932                }
933                Some(ScriptCommand::WarpTo { map, x, y }) => {
934                    log::info!("scene warp → {map} ({x},{y})");
935                    // On a bad dest, enter_map logs and leaves us put; resume
936                    // the scene regardless so it can't deadlock on the warp.
937                    if self.enter_map(&map, (x as i32, y as i32), Direction::Down) {
938                        self.opening_dispatch_pending = true;
939                    }
940                    cmd = self.signal(&mut engine, CommandResult::Void);
941                }
942                Some(ScriptCommand::FadeScreen { fade_type }) => {
943                    log::info!("fadeScreen({fade_type}) (cosmetic in dotzuki run v1)");
944                    self.flash = FLASH_FRAMES;
945                    cmd = self.signal(&mut engine, CommandResult::Void);
946                }
947                Some(ScriptCommand::SetFlag { flag }) => {
948                    engine.set_flag(&flag, true);
949                    self.flags.insert(flag, true);
950                    cmd = self.signal(&mut engine, CommandResult::Void);
951                }
952                Some(ScriptCommand::ResetFlag { flag }) => {
953                    engine.set_flag(&flag, false);
954                    self.flags.insert(flag, false);
955                    cmd = self.signal(&mut engine, CommandResult::Void);
956                }
957                Some(ScriptCommand::CheckFlag { flag }) => {
958                    let value = self.flags.get(&flag).copied().unwrap_or(false);
959                    cmd = self.signal(&mut engine, CommandResult::Bool(value));
960                }
961                Some(ScriptCommand::PlayMusic { music_id }) => {
962                    self.audio.play_music(&music_id);
963                    cmd = self.signal(&mut engine, CommandResult::Void);
964                }
965                Some(ScriptCommand::PlaySound { sound_id }) => {
966                    self.audio.play_sound(&sound_id);
967                    cmd = self.signal(&mut engine, CommandResult::Void);
968                }
969                Some(ScriptCommand::StopMusic) => {
970                    self.audio.stop_music();
971                    cmd = self.signal(&mut engine, CommandResult::Void);
972                }
973                Some(ScriptCommand::FadeOutMusic) => {
974                    self.audio.fade_out_music();
975                    cmd = self.signal(&mut engine, CommandResult::Void);
976                }
977                Some(ScriptCommand::StartBattle { trainer_id }) => {
978                    log::info!("scene → battle: startBattle({trainer_id})");
979                    match self.build_battle(&trainer_id) {
980                        Some(battle) => {
981                            // Suspend the scene; Mode::Battle resumes it with
982                            // the outcome when the battle ends.
983                            self.mode = Mode::Battle(Box::new(BattleState {
984                                engine: Some(engine),
985                                battle,
986                            }));
987                            return;
988                        }
989                        None => {
990                            cmd = self.signal(&mut engine, CommandResult::Text("win".to_string()));
991                        }
992                    }
993                }
994                Some(ScriptCommand::StartWildBattle { species, level }) => {
995                    log::info!("scene → battle: startWildBattle({species}, lv{level}) — level ignored in v1");
996                    match self.build_battle(&species) {
997                        Some(battle) => {
998                            self.mode = Mode::Battle(Box::new(BattleState {
999                                engine: Some(engine),
1000                                battle,
1001                            }));
1002                            return;
1003                        }
1004                        None => {
1005                            cmd = self.signal(&mut engine, CommandResult::Text("win".to_string()));
1006                        }
1007                    }
1008                }
1009                Some(ScriptCommand::SetWeather { weather }) => {
1010                    log::info!("scene → weather: {weather:?} (applies to the next battle)");
1011                    self.pending_weather = weather;
1012                    cmd = self.signal(&mut engine, CommandResult::Void);
1013                }
1014                Some(ScriptCommand::OpenShop { items }) => {
1015                    log::info!("scene → shop: openShop({items:?})");
1016                    // Suspend the scene; Mode::Shop resumes it with Void on
1017                    // exit. Buy + Sell (see game::menu).
1018                    let items = self.build_shop_items(&items);
1019                    self.mode = Mode::Shop(Box::new(ShopState::new(engine, items)));
1020                    return;
1021                }
1022                Some(other) => {
1023                    // An unimplemented command must never deadlock the VM.
1024                    log::warn!(
1025                        "unhandled scene command {other:?} in scene '{scene}' — \
1026                         auto-completing with Void"
1027                    );
1028                    cmd = self.signal(&mut engine, CommandResult::Void);
1029                }
1030                None => {
1031                    if first {
1032                        log::warn!(
1033                            "scene '{scene}' finished without producing any command — \
1034                             did it call an unregistered game.* function?"
1035                        );
1036                    }
1037                    self.finish_scene(engine);
1038                    return;
1039                }
1040            }
1041            first = false;
1042        }
1043    }
1044
1045    /// `signal_done` wrapper translating errors into a stream end.
1046    fn signal(
1047        &mut self,
1048        engine: &mut ScriptEngine,
1049        result: CommandResult,
1050    ) -> Option<ScriptCommand> {
1051        match engine.signal_done(result) {
1052            Ok(cmd) => cmd,
1053            Err(e) => {
1054                let scene = self.active_scene.clone().unwrap_or_default();
1055                log::warn!("scene '{scene}' signal failed: {e}");
1056                None
1057            }
1058        }
1059    }
1060
1061    /// Build a battle against enemy record `enemy_id`. Returns `None` — the
1062    /// scene continues with `"win"` (undefeated-continue) — when the project
1063    /// has no `battle` section; a broken section (unknown table ids, bad
1064    /// records, unparseable rules) logs a clear error and also yields `None`,
1065    /// so a misconfigured battle can never deadlock the scene VM.
1066    fn build_battle(&mut self, enemy_id: &str) -> Option<Battle> {
1067        if self.project.manifest().battle.is_none() {
1068            log::warn!(
1069                "startBattle({enemy_id}): project has no battle section — \
1070                 auto-completing with \"win\""
1071            );
1072            return None;
1073        }
1074        let rng: Box<dyn BattleRng> = match &self.rng_script {
1075            Some(bytes) => Box::new(ScriptedRng::new(bytes.clone())),
1076            None => {
1077                // SystemTime::now() panics on wasm32-unknown-unknown; there
1078                // the seed is pure-Rust entropy from the frame counter.
1079                #[cfg(not(target_arch = "wasm32"))]
1080                let seed = {
1081                    let nanos = std::time::SystemTime::now()
1082                        .duration_since(std::time::UNIX_EPOCH)
1083                        .map(|d| d.subsec_nanos() as u64)
1084                        .unwrap_or(0);
1085                    nanos ^ self.frame_count
1086                };
1087                #[cfg(target_arch = "wasm32")]
1088                let seed =
1089                    self.frame_count.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ 0xA076_1D64_78BD_642F;
1090                Box::new(XorshiftRng::seed(seed))
1091            }
1092        };
1093        match BattleSetup::from_project(&self.project).and_then(|setup| {
1094            setup.start_with(
1095                enemy_id,
1096                rng,
1097                self.party_state.as_deref(),
1098                self.inventory.as_ref(),
1099            )
1100        }) {
1101            Ok(mut battle) => {
1102                battle.set_lang(&self.lang);
1103                battle.set_currency(self.currency());
1104                // v2-e: hand the scene-armed weather to the battle (it stays
1105                // pending until the battle ends — a failed build keeps it for
1106                // the next startBattle), then run the battle-start hook pass
1107                // (weather intro + both actives' ability SwitchIn hooks).
1108                battle.set_weather(self.pending_weather.clone());
1109                battle.begin();
1110                Some(battle)
1111            }
1112            Err(e) => {
1113                log::error!("startBattle({enemy_id}): battle setup failed: {e:#}");
1114                None
1115            }
1116        }
1117    }
1118
1119    /// End the active scene: harvest flags into the persistent store, return
1120    /// to the overworld (or idle), then continue with any queued `on_enter`
1121    /// storyline or a deferred opening dispatch (scene `WarpTo`). A lost
1122    /// battle's whiteout takes precedence over both — the queued scenes
1123    /// belong to a map the player is about to leave.
1124    fn finish_scene(&mut self, engine: ScriptEngine) {
1125        self.flags.extend(engine.get_all_flags());
1126        drop(engine);
1127        self.active_scene = None;
1128        self.mode = if self.map.is_some() {
1129            Mode::Overworld
1130        } else {
1131            Mode::Idle
1132        };
1133        if self.pending_whiteout {
1134            self.pending_whiteout = false;
1135            self.pending_scenes.clear();
1136            self.opening_dispatch_pending = false;
1137            self.start_whiteout();
1138            return;
1139        }
1140        if self.pop_pending_scene() {
1141            return;
1142        }
1143        if self.opening_dispatch_pending {
1144            self.opening_dispatch_pending = false;
1145            self.dispatch_opening();
1146        }
1147        // Scene over and settled (no queued/deferred scene took over): the
1148        // flags it set are safe to persist.
1149        if matches!(self.mode, Mode::Overworld | Mode::Idle) {
1150            self.write_save();
1151        }
1152    }
1153
1154    // ── hot reload (`--watch`) ─────────────────────────────────────────────
1155
1156    /// Poll the file watcher and apply pending changes as one batch.
1157    ///
1158    /// Called at the top of every [`update`](Self::update); a no-op when
1159    /// watching is off. Batches are debounced by [`WATCH_DEBOUNCE_FRAMES`]
1160    /// so an editor save burst applies once.
1161    #[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
1162    pub fn poll_watch(&mut self) {
1163        let Some(watcher) = &mut self.watcher else {
1164            return;
1165        };
1166        self.watch_pending.extend(watcher.poll_events());
1167        if self.watch_cooldown > 0 {
1168            self.watch_cooldown -= 1;
1169            return;
1170        }
1171        if self.watch_pending.is_empty() {
1172            return;
1173        }
1174        let paths: Vec<PathBuf> = self.watch_pending.drain().collect();
1175        self.watch_cooldown = WATCH_DEBOUNCE_FRAMES;
1176        self.apply_watch_batch(paths);
1177    }
1178
1179    /// No-op without the `watch` feature (and on WASM, where hot reload is
1180    /// unsupported).
1181    #[cfg(any(not(feature = "watch"), target_arch = "wasm32"))]
1182    pub fn poll_watch(&mut self) {}
1183
1184    /// Classify a batch of changed paths and reload accordingly:
1185    ///
1186    /// - any `.scene` → recompile every DSL dir and swap scenes in place;
1187    /// - a content file under the **current** map dir (`map.tmx.json`,
1188    ///   `tileset.png`, objects sidecar) → reload that [`RuntimeMap`];
1189    /// - anything else (other maps, data tables, gfx) is ignored — it is
1190    ///   picked up on next map enter / next boot.
1191    #[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
1192    fn apply_watch_batch(&mut self, paths: Vec<PathBuf>) {
1193        let map_dir = self
1194            .map
1195            .as_ref()
1196            .map(|m| crate::map::map_dir(&self.project.maps_dir(), m.id()));
1197        let mut scenes_changed = false;
1198        let mut map_changed = false;
1199        for path in &paths {
1200            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
1201            if ext == "scene" {
1202                scenes_changed = true;
1203            } else if map_dir
1204                .as_ref()
1205                .is_some_and(|d| path.parent() == Some(d.as_path()))
1206            {
1207                map_changed = true;
1208            }
1209        }
1210        if scenes_changed {
1211            self.reload_scenes();
1212        }
1213        if map_changed {
1214            self.reload_current_map();
1215        }
1216    }
1217
1218    /// Recompile the project's DSL and swap the compiled scenes in place.
1219    /// A scene mid-activation keeps running its old engine; the next
1220    /// activation (talk/enter) picks up the new source. On a compiler
1221    /// diagnostic the old scenes keep running (`false`).
1222    pub fn reload_scenes(&mut self) -> bool {
1223        match self.project.recompile_scripts() {
1224            Ok(()) => {
1225                log::info!("hot-reload: scenes recompiled");
1226                true
1227            }
1228            Err(e) => {
1229                log::warn!("hot-reload: scene reload failed, keeping old scenes: {e:#}");
1230                false
1231            }
1232        }
1233    }
1234
1235    /// Reload the current map from disk in place, preserving the player's
1236    /// pixel position and the story flags. On a load error the old map is
1237    /// kept (`false`); `false` also when there is no current map.
1238    pub fn reload_current_map(&mut self) -> bool {
1239        let Some(map) = &self.map else {
1240            return false;
1241        };
1242        let map_id = map.id().to_string();
1243        match self.project.load_map(&map_id) {
1244            Ok(new_map) => {
1245                self.camera.clamp_to_bounds(Rect::new(
1246                    0.0,
1247                    0.0,
1248                    new_map.pixel_width() as f32,
1249                    new_map.pixel_height() as f32,
1250                ));
1251                self.map = Some(new_map);
1252                self.center_camera();
1253                self.camera.update(0.0);
1254                log::info!("hot-reload: reloaded map '{map_id}'");
1255                true
1256            }
1257            Err(e) => {
1258                log::warn!("hot-reload: map '{map_id}' reload failed, keeping old map: {e:#}");
1259                false
1260            }
1261        }
1262    }
1263
1264    // ── per-frame update ────────────────────────────────────────────────────
1265
1266    /// Advance the game one frame. Callable without any window (headless
1267    /// tests, the `run_headless` driver); the `GameLoop` impl forwards here.
1268    pub fn update(&mut self, input: &InputState) {
1269        self.frame_count += 1;
1270        self.poll_watch();
1271        self.audio.update_frame();
1272        if self.flash > 0 {
1273            self.flash -= 1;
1274        }
1275
1276        // A warp fade owns the frame: input is frozen while the map switches.
1277        if self.transition.is_some() {
1278            self.update_transition();
1279            return;
1280        }
1281
1282        match std::mem::replace(&mut self.mode, Mode::Overworld) {
1283            Mode::Text(mut state) => {
1284                if input.is_just_pressed(GbButton::A) {
1285                    state.pages.pop_front();
1286                    if state.pages.is_empty() {
1287                        match state.engine {
1288                            Some(mut engine) => {
1289                                let cmd = self.signal(&mut engine, CommandResult::Void);
1290                                self.pump(engine, cmd, false);
1291                            }
1292                            None => {
1293                                // One-off line: just close the box.
1294                                self.mode = if self.map.is_some() {
1295                                    Mode::Overworld
1296                                } else {
1297                                    Mode::Idle
1298                                };
1299                            }
1300                        }
1301                    } else {
1302                        self.mode = Mode::Text(state);
1303                    }
1304                } else {
1305                    self.mode = Mode::Text(state);
1306                }
1307            }
1308            Mode::Choice(mut state) => {
1309                let n = state.options.len().max(1);
1310                if input.is_just_pressed(GbButton::Up) {
1311                    state.cursor = (state.cursor + n - 1) % n;
1312                } else if input.is_just_pressed(GbButton::Down) {
1313                    state.cursor = (state.cursor + 1) % n;
1314                }
1315                if input.is_just_pressed(GbButton::A) {
1316                    let cursor = state.cursor;
1317                    let mut engine = state.engine;
1318                    log::info!("choice picked: {cursor}");
1319                    let cmd = self.signal(&mut engine, CommandResult::Number(cursor as f64));
1320                    self.pump(engine, cmd, false);
1321                } else {
1322                    self.mode = Mode::Choice(state);
1323                }
1324            }
1325            Mode::Delay(mut state) => {
1326                if state.frames_left > 0 {
1327                    state.frames_left -= 1;
1328                }
1329                if state.frames_left == 0 {
1330                    let mut engine = state.engine;
1331                    let cmd = self.signal(&mut engine, CommandResult::Void);
1332                    self.pump(engine, cmd, false);
1333                } else {
1334                    self.mode = Mode::Delay(state);
1335                }
1336            }
1337            Mode::Battle(mut state) => {
1338                state.battle.update(input);
1339                if let Some(outcome) = state.battle.outcome() {
1340                    // Battle over: harvest the persistent party state and
1341                    // inventory (win, lose AND run), then resume the
1342                    // suspended scene with the result.
1343                    self.party_state = Some(state.battle.party_state());
1344                    self.inventory = Some(state.battle.inventory().clone());
1345                    let result = match outcome {
1346                        BattleOutcome::Win => "win",
1347                        BattleOutcome::Lose => "lose",
1348                        BattleOutcome::Run => "run",
1349                    };
1350                    // A trainer win pays the encounter's money reward (v2-d).
1351                    if outcome == BattleOutcome::Win {
1352                        self.money = self.money.saturating_add(state.battle.trainer_money());
1353                    }
1354                    // A loss arms the whiteout, which fires when the scene
1355                    // that receives "lose" finishes (its post-lose text
1356                    // plays first); any other outcome cancels a previously
1357                    // armed one.
1358                    self.pending_whiteout = outcome == BattleOutcome::Lose;
1359                    // The armed weather was battle-local (v2-e): it dies with
1360                    // the battle and is never saved.
1361                    self.pending_weather = None;
1362                    log::info!("battle ended: {result}");
1363                    match state.engine {
1364                        // Scene battle: resume the suspended scene with the
1365                        // outcome (its post-battle text/branches play out;
1366                        // finish_scene fires an armed whiteout).
1367                        Some(mut engine) => {
1368                            let cmd =
1369                                self.signal(&mut engine, CommandResult::Text(result.to_string()));
1370                            self.pump(engine, cmd, false);
1371                        }
1372                        // Sceneless (a random encounter armed by walking):
1373                        // win/run returns to the overworld in place; a loss
1374                        // goes straight to the whiteout — there is no scene
1375                        // whose post-lose text would play first.
1376                        None => {
1377                            if outcome == BattleOutcome::Lose {
1378                                self.pending_whiteout = false;
1379                                self.start_whiteout();
1380                            } else {
1381                                self.mode = Mode::Overworld;
1382                            }
1383                        }
1384                    }
1385                } else {
1386                    self.mode = Mode::Battle(state);
1387                }
1388            }
1389            Mode::Overworld => self.update_overworld(input),
1390            Mode::Menu(state) => self.update_menu(state, input),
1391            Mode::Shop(state) => self.update_shop(*state, input),
1392            Mode::Whiteout(state) => self.update_whiteout(state, input),
1393            // Restore Idle: the replace above defaults the mode to Overworld,
1394            // which would silently erase the resting state of map-less
1395            // projects (and with it the end card).
1396            Mode::Idle => {
1397                self.mode = Mode::Idle;
1398            }
1399        }
1400
1401        self.center_camera();
1402        self.camera.update(0.0);
1403    }
1404
1405    /// Fade out → switch map → fade in → opening dispatch.
1406    fn update_transition(&mut self) {
1407        let Some(t) = &mut self.transition else {
1408            return;
1409        };
1410        if t.frames > 0 {
1411            t.frames -= 1;
1412        }
1413        if t.frames > 0 {
1414            return;
1415        }
1416        match t.phase {
1417            FadePhase::Out => {
1418                let (dest_map, dest) = (t.dest_map.clone(), (t.dest_x, t.dest_y));
1419                let facing = self.actor.facing();
1420                if self.enter_map(&dest_map, dest, facing) {
1421                    if let Some(t) = &mut self.transition {
1422                        t.phase = FadePhase::In;
1423                        t.frames = FADE_FRAMES;
1424                    }
1425                } else {
1426                    // Broken warp dest: stay put (logged by enter_map).
1427                    self.transition = None;
1428                }
1429            }
1430            FadePhase::In => {
1431                self.transition = None;
1432                // The warp is complete and the mode is a stable Overworld —
1433                // save before the opening dispatch can start a scene.
1434                self.write_save();
1435                self.dispatch_opening();
1436            }
1437        }
1438    }
1439
1440    /// Free-roam input: talk on A, walk with the D-pad, warp on arrival.
1441    /// Start opens the pause menu (party / bag / save).
1442    fn update_overworld(&mut self, input: &InputState) {
1443        if self.map.is_none() {
1444            return;
1445        }
1446        if input.is_just_pressed(GbButton::Start) {
1447            self.mode = Mode::Menu(MenuState::new());
1448            return;
1449        }
1450        if input.is_just_pressed(GbButton::A) {
1451            if let Some(npc_index) = self.faced_npc_index() {
1452                self.talk_to(npc_index);
1453                return;
1454            }
1455            if let Some(sign_index) = self.faced_sign_index() {
1456                self.read_sign(sign_index);
1457                return;
1458            }
1459        }
1460
1461        let held = held_direction(input);
1462        self.actor.set_running(input.is_held(GbButton::B));
1463        let step = {
1464            let map = self.map.as_ref().expect("checked above");
1465            let view = CollisionView { map };
1466            self.actor.update(held, &view)
1467        };
1468        if let Some((tx, ty)) = step {
1469            self.steps_taken += 1;
1470            // Stairs: a tile is either a stair or a warp in practice.
1471            self.apply_stairs(tx, ty);
1472            // Warp takes priority over an encounter roll on the same tile.
1473            let warp = {
1474                let map = self.map.as_ref().expect("checked above");
1475                map.objects()
1476                    .warps
1477                    .iter()
1478                    .find(|w| (w.x, w.y) == (tx, ty) && !w.dest_map.is_empty())
1479                    .map(|w| (w.dest_map.clone(), w.dest_x, w.dest_y))
1480            };
1481            if let Some((dest_map, dest_x, dest_y)) = warp {
1482                self.transition = Some(WarpTransition::new(dest_map, dest_x, dest_y));
1483            } else {
1484                self.roll_encounter(tx, ty);
1485            }
1486        }
1487    }
1488
1489    /// Roll a random encounter after a completed step onto `(tx, ty)`: when
1490    /// the tile lies inside one of the map's encounter zones, draw one byte
1491    /// — a hit (`< rate`, the config's /256 per-step chance) picks a
1492    /// weighted id from the zone's table and arms a sceneless battle.
1493    /// Turning in place never completes a step, and battles/scenes don't run
1494    /// `update_overworld`, so a tile is rolled exactly once per walk onto it.
1495    fn roll_encounter(&mut self, tx: i32, ty: i32) {
1496        let (rate, table) = {
1497            let map = self.map.as_ref().expect("roll_encounter requires a map");
1498            let Some(encounters) = &map.objects().encounters else {
1499                return;
1500            };
1501            match encounters.zones.iter().find(|z| z.contains(tx, ty)) {
1502                Some(zone) => (encounters.rate, zone.table.clone()),
1503                None => return,
1504            }
1505        };
1506        if rate == 0 || table.is_empty() {
1507            return;
1508        }
1509        if self.overworld_rng_byte() >= rate {
1510            return;
1511        }
1512        let total: u32 = table.iter().map(|e| e.weight).sum();
1513        if total == 0 {
1514            return;
1515        }
1516        let mut pick = u32::from(self.overworld_rng_byte()) % total;
1517        let id = table
1518            .iter()
1519            .find(|e| {
1520                if pick < e.weight {
1521                    true
1522                } else {
1523                    pick -= e.weight;
1524                    false
1525                }
1526            })
1527            .map(|e| e.id.clone());
1528        if let Some(id) = id {
1529            self.start_overworld_battle(&id);
1530        }
1531    }
1532
1533    /// The next overworld rng byte, seeding the generator on first use: a
1534    /// scripted stream under [`RunnerOptions::rng_script`] (deterministic
1535    /// test hook), else a xorshift seeded like the per-battle rng in
1536    /// [`build_battle`](Self::build_battle) — SystemTime entropy on native;
1537    /// on wasm32 the frame counter mixed with the step counter (the frame
1538    /// counter alone is 0 at boot there, and SystemTime panics).
1539    fn overworld_rng_byte(&mut self) -> u8 {
1540        if self.overworld_rng.is_none() {
1541            let rng: Box<dyn BattleRng> = match &self.rng_script {
1542                Some(bytes) => Box::new(ScriptedRng::new(bytes.clone())),
1543                None => {
1544                    #[cfg(not(target_arch = "wasm32"))]
1545                    let seed = {
1546                        let nanos = std::time::SystemTime::now()
1547                            .duration_since(std::time::UNIX_EPOCH)
1548                            .map(|d| d.subsec_nanos() as u64)
1549                            .unwrap_or(0);
1550                        nanos ^ self.frame_count
1551                    };
1552                    #[cfg(target_arch = "wasm32")]
1553                    let seed = self
1554                        .frame_count
1555                        .wrapping_add(self.steps_taken)
1556                        .wrapping_mul(0x9E37_79B9_7F4A_7C15)
1557                        ^ 0xA076_1D64_78BD_642F;
1558                    Box::new(XorshiftRng::seed(seed))
1559                }
1560            };
1561            self.overworld_rng = Some(rng);
1562        }
1563        self.overworld_rng
1564            .as_deref_mut()
1565            .expect("seeded above")
1566            .byte()
1567    }
1568
1569    /// Arm a sceneless battle from a random encounter: same construction as
1570    /// a scene's `startBattle` ([`build_battle`](Self::build_battle) — an
1571    /// encounter record first, a single enemy record as the fallback), but
1572    /// no scene engine is suspended, so the outcome flows straight back to
1573    /// the overworld (win/run) or the whiteout (lose). A failed build logs
1574    /// (inside `build_battle`) and simply keeps the player walking.
1575    fn start_overworld_battle(&mut self, id: &str) {
1576        log::info!("random encounter: {id}");
1577        if let Some(battle) = self.build_battle(id) {
1578            self.mode = Mode::Battle(Box::new(BattleState {
1579                engine: None,
1580                battle,
1581            }));
1582        }
1583    }
1584
1585    /// Elevation transition on arrival: stair GID 1 ascends one level, GID 2
1586    /// descends one (both clamped to the map's level count).
1587    fn apply_stairs(&mut self, x: i32, y: i32) {
1588        let Some(map) = &self.map else {
1589            return;
1590        };
1591        let level = self.actor.elevation();
1592        let next = match map.stair_at(x, y) {
1593            Some(1) => (level as usize + 1).min(map.level_count() - 1) as u8,
1594            Some(2) => level.saturating_sub(1),
1595            _ => return,
1596        };
1597        if next != level {
1598            self.actor.set_elevation(next);
1599        }
1600    }
1601
1602    /// The index of the NPC on the tile the player faces, if any.
1603    fn faced_npc_index(&self) -> Option<usize> {
1604        let map = self.map.as_ref()?;
1605        let (dx, dy) = direction_delta(self.actor.facing());
1606        let (tx, ty) = self.actor.tile();
1607        let faced = (tx + dx, ty + dy);
1608        map.objects().npcs.iter().position(|n| (n.x, n.y) == faced)
1609    }
1610
1611    /// The index of the sign on the tile the player faces, if any.
1612    fn faced_sign_index(&self) -> Option<usize> {
1613        let map = self.map.as_ref()?;
1614        let (dx, dy) = direction_delta(self.actor.facing());
1615        let (tx, ty) = self.actor.tile();
1616        let faced = (tx + dx, ty + dy);
1617        map.objects().signs.iter().position(|s| (s.x, s.y) == faced)
1618    }
1619
1620    /// Read the sign at `index`: its `text` is plain text, shown as one-off
1621    /// pages (same shape as an NPC's raw `talk` fallback).
1622    fn read_sign(&mut self, index: usize) {
1623        let map = self.map.as_ref().expect("read_sign requires a map");
1624        let text = map.objects().signs[index].text.clone();
1625        if text.is_empty() {
1626            return;
1627        }
1628        self.last_text = text.clone();
1629        self.mode = Mode::Text(TextState {
1630            engine: None,
1631            pages: paginate(&text),
1632        });
1633    }
1634
1635    /// Talk dispatch for NPC `index`: `talk` as storyline name → matching
1636    /// route → map scene `main` → raw `talk` text as a one-off line.
1637    fn talk_to(&mut self, index: usize) {
1638        let (map_id, npc_name, npc_id, talk) = {
1639            let map = self.map.as_ref().expect("talk requires a map");
1640            let npc = &map.objects().npcs[index];
1641            (
1642                map.id().to_string(),
1643                npc.name.clone(),
1644                npc.id,
1645                npc.talk.clone(),
1646            )
1647        };
1648
1649        // 1. The talk field names a storyline.
1650        if !talk.is_empty() {
1651            if let Some(scene) = self.scene_with_storyline(&map_id, &talk) {
1652                if self.activate(&scene, &talk) {
1653                    return;
1654                }
1655            }
1656        }
1657
1658        // 2. A route whose npc matches this NPC (name, or id as a string).
1659        let route = self
1660            .project
1661            .routes()
1662            .iter()
1663            .filter(|r| r.map == map_id && !r.on_enter)
1664            .find(|r| {
1665                r.npc.as_deref().is_some_and(|n| {
1666                    (!npc_name.is_empty() && n == npc_name) || n == npc_id.to_string()
1667                })
1668            })
1669            .map(|r| r.storyline.clone());
1670        if let Some(storyline) = route {
1671            if let Some(scene) = self.scene_with_storyline(&map_id, &storyline) {
1672                if self.activate(&scene, &storyline) {
1673                    return;
1674                }
1675            }
1676        }
1677
1678        // 3. The map scene's main storyline.
1679        if let Some(scene) = self.scene_for_map(&map_id) {
1680            if scene_has_fn(&self.project, &scene, "main") && self.activate(&scene, "main") {
1681                return;
1682            }
1683        }
1684
1685        // 4. The talk field is plain text — show it as a one-off line.
1686        if !talk.is_empty() {
1687            self.last_text = talk.clone();
1688            self.mode = Mode::Text(TextState {
1689                engine: None,
1690                pages: paginate(&talk),
1691            });
1692        }
1693    }
1694
1695    // ── rendering ───────────────────────────────────────────────────────────
1696
1697    /// Render the current frame. Callable without any window; the `GameLoop`
1698    /// impl forwards here.
1699    pub fn draw(&mut self, fb: &mut FrameBuffer) {
1700        if let Some(map) = &self.map {
1701            fb.clear(Rgba::BLACK);
1702            let cam_x = self.camera.position.x.round() as i32;
1703            let cam_y = self.camera.position.y.round() as i32;
1704            let level = self.actor.elevation() as i32;
1705            // Layers at/below the player's elevation draw under the sprites;
1706            // higher layers (e.g. wall tops seen from the ground) over them.
1707            if let Err(e) =
1708                map.render_below(fb, cam_x, cam_y, SCREEN_W as u32, SCREEN_H as u32, level)
1709            {
1710                log::warn!("map render: {e:#}");
1711            }
1712            self.draw_npcs(fb, cam_x, cam_y);
1713            self.draw_player(fb, cam_x, cam_y);
1714            if let Err(e) =
1715                map.render_above(fb, cam_x, cam_y, SCREEN_W as u32, SCREEN_H as u32, level)
1716            {
1717                log::warn!("map render: {e:#}");
1718            }
1719        } else {
1720            // Dialogue-only backdrop.
1721            fb.clear(Rgba::rgb(0x10, 0x10, 0x18));
1722        }
1723
1724        match &self.mode {
1725            Mode::Text(state) => {
1726                if let Some(page) = state.pages.front() {
1727                    draw_textbox(fb, page);
1728                }
1729            }
1730            Mode::Choice(state) => {
1731                if !state.context_text.is_empty() {
1732                    draw_textbox(fb, &state.context_text);
1733                }
1734                draw_choice_menu(fb, &state.options, state.cursor);
1735            }
1736            Mode::Battle(state) => state.battle.draw(fb),
1737            Mode::Menu(state) => self.draw_menu(fb, state),
1738            Mode::Shop(state) => self.draw_shop(fb, state),
1739            Mode::Whiteout(state) => {
1740                if let Some(page) = state.pages.front() {
1741                    draw_textbox(fb, page);
1742                }
1743            }
1744            Mode::Idle if self.map.is_none() => {
1745                // Dialogue-only projects end here: show a small end card
1746                // instead of leaving a void on screen.
1747                draw_end_card(fb, &self.project.manifest().name, &self.lang);
1748            }
1749            _ => {}
1750        }
1751
1752        // Fade overlays (warp transition / cosmetic flash).
1753        let darkness = match &self.transition {
1754            Some(t) => match t.phase {
1755                FadePhase::Out => 1.0 - t.frames as f32 / FADE_FRAMES as f32,
1756                FadePhase::In => t.frames as f32 / FADE_FRAMES as f32,
1757            },
1758            None if self.flash > 0 => 0.5 * self.flash as f32 / FLASH_FRAMES as f32,
1759            None => 0.0,
1760        };
1761        if darkness > 0.0 {
1762            darken(fb, 1.0 - darkness.clamp(0.0, 1.0));
1763        }
1764
1765        // The whiteout's blackout phase covers everything.
1766        if let Mode::Whiteout(state) = &self.mode {
1767            if state.blackout > 0 {
1768                fb.fill_rect(0, 0, SCREEN_W as u32, SCREEN_H as u32, Rgba::BLACK);
1769            }
1770        }
1771    }
1772
1773    /// NPC placeholders: a two-tone person blob per NPC, palette derived from
1774    /// the NPC id so distinct NPCs read as distinct people.
1775    fn draw_npcs(&self, fb: &mut FrameBuffer, cam_x: i32, cam_y: i32) {
1776        let Some(map) = &self.map else {
1777            return;
1778        };
1779        let tile = map.tile_size().0 as i32;
1780        for npc in &map.objects().npcs {
1781            let facing = parse_facing(&npc.facing);
1782            let colors = npc_palette(npc.id);
1783            draw_person(
1784                fb,
1785                npc.x * tile - cam_x,
1786                npc.y * tile - cam_y,
1787                tile,
1788                facing,
1789                &colors,
1790            );
1791        }
1792    }
1793
1794    fn draw_player(&self, fb: &mut FrameBuffer, cam_x: i32, cam_y: i32) {
1795        let tile = self
1796            .map
1797            .as_ref()
1798            .map(|m| m.tile_size().0 as i32)
1799            .unwrap_or(16);
1800        let foot_x = self.actor.px().round() as i32 - cam_x;
1801        let foot_y = self.actor.py().round() as i32 - cam_y;
1802        if let Some(sprite) = &self.player_sprite {
1803            let col = frame_col(
1804                self.actor.locomotion(),
1805                self.actor.step_phase(),
1806                sprite.cols,
1807            );
1808            sprite.draw_on_tile(fb, self.actor.facing_row(), col, foot_x, foot_y, tile);
1809            return;
1810        }
1811        draw_person(
1812            fb,
1813            foot_x,
1814            foot_y,
1815            tile,
1816            self.actor.facing(),
1817            &PLAYER_COLORS,
1818        );
1819    }
1820
1821    fn center_camera(&mut self) {
1822        let tile = self
1823            .map
1824            .as_ref()
1825            .map(|m| m.tile_size().0 as i32)
1826            .unwrap_or(16);
1827        let cx = self.actor.px() + (tile / 2) as f32;
1828        let cy = self.actor.py() + (tile / 2) as f32;
1829        self.camera.follow_target(Vec2::new(
1830            cx - SCREEN_W as f32 / 2.0,
1831            cy - SCREEN_H as f32 / 2.0,
1832        ));
1833    }
1834
1835    // ── introspection (headless driver, tests) ──────────────────────────────
1836
1837    /// The currently loaded map id (`None` in dialogue-only mode).
1838    pub fn current_map_id(&self) -> Option<&str> {
1839        self.map.as_ref().map(RuntimeMap::id)
1840    }
1841
1842    /// A persistent story flag's value (defaults to `false`).
1843    pub fn flag(&self, name: &str) -> bool {
1844        self.flags.get(name).copied().unwrap_or(false)
1845    }
1846
1847    /// The text page currently on screen, if a textbox is open (including
1848    /// the game-over whiteout message).
1849    pub fn dialogue_text(&self) -> Option<&str> {
1850        match &self.mode {
1851            Mode::Text(state) => state.pages.front().map(String::as_str),
1852            Mode::Whiteout(state) if state.blackout == 0 => state.pages.front().map(String::as_str),
1853            _ => None,
1854        }
1855    }
1856
1857    /// The choice options currently on screen, if a choice menu is open.
1858    pub fn choice_options(&self) -> Option<&[String]> {
1859        match &self.mode {
1860            Mode::Choice(state) => Some(&state.options),
1861            _ => None,
1862        }
1863    }
1864
1865    /// The live battle, if one is running (test/debug introspection).
1866    pub fn battle(&self) -> Option<&Battle> {
1867        match &self.mode {
1868            Mode::Battle(state) => Some(&state.battle),
1869            _ => None,
1870        }
1871    }
1872
1873    /// The persistent party state, once a battle has completed or a save
1874    /// restored one (test/debug introspection).
1875    pub fn party_state(&self) -> Option<&[PartyMemberState]> {
1876        self.party_state.as_deref()
1877    }
1878
1879    /// The persistent battle inventory, once a battle has completed or a
1880    /// save restored one (test/debug introspection).
1881    pub fn inventory(&self) -> Option<&HashMap<String, u32>> {
1882        self.inventory.as_ref()
1883    }
1884
1885    /// The player's current money (test/debug introspection).
1886    pub fn money(&self) -> u32 {
1887        self.money
1888    }
1889
1890    /// The rows the open Start menu currently displays (root labels, party
1891    /// detail lines, bag rows, target rows, or the note text); `None` when
1892    /// the menu is closed (test/debug introspection).
1893    pub fn menu_lines(&self) -> Option<Vec<String>> {
1894        match &self.mode {
1895            Mode::Menu(state) => Some(self.menu_lines_for(state)),
1896            _ => None,
1897        }
1898    }
1899
1900    /// The item rows the open shop displays (`×`-prefixed when
1901    /// unaffordable), plus the transient note as a final row when one is
1902    /// showing; `None` when no shop is open (test/debug introspection).
1903    pub fn shop_lines(&self) -> Option<Vec<String>> {
1904        match &self.mode {
1905            Mode::Shop(state) => Some(self.shop_lines_for(state)),
1906            _ => None,
1907        }
1908    }
1909
1910    /// `true` while the game-over whiteout owns the screen (blackout or
1911    /// message phase; test/debug introspection).
1912    pub fn whiteout_active(&self) -> bool {
1913        matches!(self.mode, Mode::Whiteout(_))
1914    }
1915
1916    /// The player's current tile.
1917    pub fn player_tile(&self) -> (i32, i32) {
1918        self.actor.tile()
1919    }
1920
1921    /// The player's current elevation level (multi-level maps; test/debug
1922    /// introspection).
1923    pub fn player_elevation(&self) -> u8 {
1924        self.actor.elevation()
1925    }
1926
1927    /// `true` when tile `(x, y)` is map-solid on the current map (solid when
1928    /// there is no map). NPC occupancy is not included; test/debug helper.
1929    pub fn is_blocked(&self, x: i32, y: i32) -> bool {
1930        match &self.map {
1931            Some(map) => map.is_blocked(x, y),
1932            None => true,
1933        }
1934    }
1935
1936    /// The audio subsystem (test/debug introspection).
1937    pub fn audio(&self) -> &RunnerAudio {
1938        &self.audio
1939    }
1940
1941    /// Pull `frames` stereo PCM frames (44100 Hz, interleaved L/R `f32`,
1942    /// length `2 * frames`) from the audio engine — the render path for
1943    /// hosts without an audio callback thread (the WASM shell feeding
1944    /// WebAudio). Empty unless [`RunnerOptions::pcm_audio`] is on and a play
1945    /// command has arrived.
1946    pub fn render_audio(&mut self, frames: usize) -> Vec<f32> {
1947        self.audio.render_samples(frames)
1948    }
1949
1950    /// Teleport the player (test/debug helper; no scene side effects).
1951    pub fn debug_place(&mut self, x: i32, y: i32, facing: Direction) {
1952        self.actor.place(x, y, facing);
1953        self.center_camera();
1954        self.camera.update(0.0);
1955    }
1956
1957    /// Frames updated so far.
1958    pub fn frame_count(&self) -> u64 {
1959        self.frame_count
1960    }
1961}
1962
1963#[cfg(not(target_arch = "wasm32"))]
1964impl dotzuki_app::GameLoop for RunnerGame {
1965    type Fb = FrameBuffer;
1966
1967    fn update(&mut self, input: &InputState) {
1968        RunnerGame::update(self, input);
1969    }
1970
1971    fn draw(&mut self, frame_buffer: &mut FrameBuffer) {
1972        RunnerGame::draw(self, frame_buffer);
1973    }
1974}
1975
1976// ── helpers ─────────────────────────────────────────────────────────────────
1977
1978/// Decode a PNG walk sheet from in-memory bytes (the VFS counterpart of
1979/// `WalkSprite::load`, which is disk-only).
1980fn decode_walk_sheet(
1981    bytes: &[u8],
1982    path: &str,
1983    frame_w: u32,
1984    frame_h: u32,
1985) -> Result<WalkSprite, String> {
1986    let img = image::load_from_memory(bytes)
1987        .map_err(|e| format!("decode {path}: {e}"))?
1988        .to_rgba8();
1989    let (w, h) = img.dimensions();
1990    let pixels = img
1991        .pixels()
1992        .map(|p| Rgba::new(p.0[0], p.0[1], p.0[2], p.0[3]))
1993        .collect();
1994    WalkSprite::from_rgba(pixels, w, h, frame_w, frame_h)
1995}
1996
1997/// Cheap probe for "does this scene export this storyline/function name"
1998/// without instantiating a [`ScriptEngine`] — matched on the DSL's generated
1999/// export names (`storyline_<name>`, `<Scene>OnLoad`). [`RunnerGame::activate`]
2000/// re-validates authoritatively with `has_function` before running.
2001fn scene_has_fn(project: &LoadedProject, scene: &str, fn_name: &str) -> bool {
2002    let Some(js) = project.scripts().get_script(scene) else {
2003        return false;
2004    };
2005    js.contains(&format!("storyline_{fn_name}")) || js.contains(&format!("function {fn_name}"))
2006}
2007
2008/// Directories `--watch` monitors: every DSL dir, the data root and the
2009/// gfx root (deduplicated; missing dirs are skipped by the watcher).
2010#[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
2011fn watch_dirs(project: &LoadedProject) -> Vec<PathBuf> {
2012    let mut dirs = project.manifest().dsl_dirs(project.root());
2013    dirs.push(project.data_root().to_path_buf());
2014    if let Some(gfx) = project.gfx_root() {
2015        dirs.push(gfx.to_path_buf());
2016    }
2017    let mut seen = HashSet::new();
2018    dirs.retain(|d| seen.insert(d.clone()));
2019    dirs
2020}
2021
2022/// First free tile scanning outward (Chebyshev rings) from the map centre.
2023/// "Free" = not map-solid and not NPC-occupied.
2024fn find_spawn(map: &RuntimeMap) -> (i32, i32) {
2025    let (cx, cy) = (map.width() as i32 / 2, map.height() as i32 / 2);
2026    let free = |x: i32, y: i32| {
2027        !map.is_blocked(x, y) && !map.objects().npcs.iter().any(|n| (n.x, n.y) == (x, y))
2028    };
2029    let max_r = (map.width().max(map.height()) as i32) + 1;
2030    for r in 0..max_r {
2031        for dy in -r..=r {
2032            for dx in -r..=r {
2033                if dx.abs().max(dy.abs()) != r {
2034                    continue; // ring perimeter only
2035                }
2036                let (x, y) = (cx + dx, cy + dy);
2037                if free(x, y) {
2038                    return (x, y);
2039                }
2040            }
2041        }
2042    }
2043    (cx, cy)
2044}
2045
2046/// Held D-pad direction (Up > Down > Left > Right priority, as wuxia).
2047fn held_direction(input: &InputState) -> Option<Direction> {
2048    if input.is_held(GbButton::Up) {
2049        Some(Direction::Up)
2050    } else if input.is_held(GbButton::Down) {
2051        Some(Direction::Down)
2052    } else if input.is_held(GbButton::Left) {
2053        Some(Direction::Left)
2054    } else if input.is_held(GbButton::Right) {
2055        Some(Direction::Right)
2056    } else {
2057        None
2058    }
2059}
2060
2061/// Unit step delta for a cardinal direction.
2062fn direction_delta(dir: Direction) -> (i32, i32) {
2063    match dir {
2064        Direction::Down => (0, 1),
2065        Direction::Up => (0, -1),
2066        Direction::Left => (-1, 0),
2067        Direction::Right => (1, 0),
2068    }
2069}
2070
2071/// `"down"`/`"up"`/`"left"`/`"right"` sidecar string → [`Direction`].
2072fn parse_facing(facing: &str) -> Direction {
2073    match facing {
2074        "up" => Direction::Up,
2075        "left" => Direction::Left,
2076        "right" => Direction::Right,
2077        _ => Direction::Down,
2078    }
2079}
2080
2081/// [`Direction`] → the sidecar/save string form (inverse of [`parse_facing`]).
2082fn facing_name(dir: Direction) -> &'static str {
2083    match dir {
2084        Direction::Down => "down",
2085        Direction::Up => "up",
2086        Direction::Left => "left",
2087        Direction::Right => "right",
2088    }
2089}
2090
2091/// Wrap `text` into pages of at most [`DIALOG_LINES_PER_PAGE`] lines each
2092/// (the join of the page's lines with `\n`). Always at least one page.
2093fn paginate(text: &str) -> VecDeque<String> {
2094    let lines = wrap_lines(text, DIALOG_WIDTH_PX, 4096);
2095    let mut pages: VecDeque<String> = lines
2096        .chunks(DIALOG_LINES_PER_PAGE)
2097        .map(|chunk| chunk.join("\n"))
2098        .collect();
2099    if pages.is_empty() {
2100        pages.push_back(String::new());
2101    }
2102    pages
2103}
2104
2105/// The shared dialogue [`MenuConfig`] (bottom box on the 40×30 tile grid).
2106fn dialog_config() -> MenuConfig {
2107    MenuConfig::new(
2108        DIALOG_AREA,
2109        None,
2110        TileRect::new(
2111            DIALOG_AREA.tx + 1,
2112            DIALOG_AREA.ty + 1,
2113            DIALOG_AREA.tw - 2,
2114            DIALOG_AREA.th - 2,
2115        ),
2116        Default::default(),
2117    )
2118}
2119
2120/// Draw the bottom dialogue textbox with one page of text.
2121pub(crate) fn draw_textbox(fb: &mut FrameBuffer, text: &str) {
2122    let mut painter = FrameBufferPainter::new(fb);
2123    draw_dialog(text, &[dialog_config()], &mut painter);
2124}
2125
2126/// Centered end card for dialogue-only projects whose entry scene finished:
2127/// the game name plus a localized "fin." so the screen isn't a void.
2128fn draw_end_card(fb: &mut FrameBuffer, game_name: &str, lang: &str) {
2129    let fin = if lang == "zh" { "完" } else { "fin." };
2130    let cx = SCREEN_W as u32 / 2;
2131    let name_w = embedded_font::measure_text(game_name);
2132    embedded_font::draw_text(
2133        game_name,
2134        cx.saturating_sub(name_w / 2),
2135        100,
2136        Rgba::rgb(0xf0, 0xf0, 0xf0),
2137        fb,
2138    );
2139    let fin_w = embedded_font::measure_text(fin);
2140    embedded_font::draw_text(
2141        fin,
2142        cx.saturating_sub(fin_w / 2),
2143        120,
2144        Rgba::rgb(0x90, 0x90, 0xa8),
2145        fb,
2146    );
2147}
2148
2149/// Draw the choice menu as a flex box just above the dialogue area,
2150/// right-aligned, sized to the options.
2151fn draw_choice_menu(fb: &mut FrameBuffer, options: &[String], cursor: usize) {
2152    let n = options.len() as u32;
2153    if n == 0 {
2154        return;
2155    }
2156    let max_len = options.iter().map(|o| o.chars().count()).max().unwrap_or(1) as u32;
2157    // +4: left/right border, cursor column, one padding column.
2158    let w = (max_len + 4).clamp(8, 20);
2159    let h = n + 2;
2160    let tx = (40 - w) as i32;
2161    let ty = DIALOG_AREA.ty as i32 - h as i32;
2162    let config = MenuConfig::new(
2163        TileRect::new(tx.max(0) as u32, ty.max(0) as u32, w, h),
2164        None,
2165        TileRect::new(tx.max(0) as u32 + 1, ty.max(0) as u32 + 1, w - 2, n),
2166        Default::default(),
2167    );
2168    let state = FlexMenuState {
2169        cursor,
2170        scroll_offset: 0,
2171    };
2172    let mut painter = FrameBufferPainter::new(fb);
2173    let mut ui = Ui::new(&mut painter);
2174    draw_flex_menu(options, &[config], &state, options.len(), &mut ui);
2175}
2176
2177/// Multiply every framebuffer pixel by `factor` (1.0 = unchanged, 0.0 = black).
2178fn darken(fb: &mut FrameBuffer, factor: f32) {
2179    for px in fb.data.chunks_exact_mut(4) {
2180        px[0] = (px[0] as f32 * factor) as u8;
2181        px[1] = (px[1] as f32 * factor) as u8;
2182        px[2] = (px[2] as f32 * factor) as u8;
2183    }
2184}
2185
2186// ── placeholder people ──────────────────────────────────────────────────────
2187
2188/// Palette of a procedurally drawn placeholder person.
2189struct PersonColors {
2190    outline: Rgba,
2191    skin: Rgba,
2192    body: Rgba,
2193}
2194
2195/// The player's palette (red jacket, the classic protagonist read).
2196const PLAYER_COLORS: PersonColors = PersonColors {
2197    outline: Rgba::rgb(0x30, 0x18, 0x18),
2198    skin: Rgba::rgb(0xF0, 0xC8, 0xA0),
2199    body: Rgba::rgb(0xC8, 0x30, 0x30),
2200};
2201
2202/// NPC body colours; the NPC id hashes into this palette.
2203const NPC_BODIES: [(u8, u8, u8); 6] = [
2204    (0x30, 0x58, 0xC8), // blue
2205    (0x38, 0x90, 0x40), // green
2206    (0x88, 0x48, 0xA8), // purple
2207    (0xC8, 0x78, 0x28), // orange
2208    (0x28, 0x98, 0x98), // teal
2209    (0x80, 0x58, 0x38), // brown
2210];
2211
2212/// Per-NPC palette: body colour derived from the id hash.
2213fn npc_palette(id: u32) -> PersonColors {
2214    let (r, g, b) = NPC_BODIES[(id.wrapping_mul(2_654_435_761) >> 16) as usize % NPC_BODIES.len()];
2215    PersonColors {
2216        outline: Rgba::rgb(r / 3, g / 3, b / 3),
2217        skin: Rgba::rgb(0xF0, 0xC8, 0xA0),
2218        body: Rgba::rgb(r, g, b),
2219    }
2220}
2221
2222/// Draw a ~12×14 two-tone placeholder person, centred on and bottom-aligned
2223/// to the `tile`-px tile whose top-left is `(sx, sy)` in screen pixels. Pure
2224/// code — no embedded assets. Eyes (or the back of the head when facing up)
2225/// give a 1-px facing indicator.
2226fn draw_person(
2227    fb: &mut FrameBuffer,
2228    sx: i32,
2229    sy: i32,
2230    tile: i32,
2231    facing: Direction,
2232    colors: &PersonColors,
2233) {
2234    const W: i32 = 12;
2235    const H: i32 = 14;
2236    let ox = sx + (tile - W) / 2;
2237    let oy = sy + tile - H;
2238    let (w, h) = (fb.width() as i32, fb.height() as i32);
2239    let mut put = |x: i32, y: i32, c: Rgba| {
2240        let (px, py) = (ox + x, oy + y);
2241        if px >= 0 && py >= 0 && px < w && py < h {
2242            fb.set_pixel(px as u32, py as u32, c);
2243        }
2244    };
2245    let fill = |x: i32, y: i32, fw: i32, fh: i32, c: Rgba, put: &mut dyn FnMut(i32, i32, Rgba)| {
2246        for dy in 0..fh {
2247            for dx in 0..fw {
2248                put(x + dx, y + dy, c);
2249            }
2250        }
2251    };
2252
2253    // Head (6×5) and torso (8×7), 1-px outline.
2254    fill(3, 0, 6, 5, colors.outline, &mut put);
2255    fill(4, 1, 4, 3, colors.skin, &mut put);
2256    fill(2, 5, 8, 7, colors.outline, &mut put);
2257    fill(3, 6, 6, 5, colors.body, &mut put);
2258    // Legs.
2259    fill(3, 12, 2, 2, colors.outline, &mut put);
2260    fill(7, 12, 2, 2, colors.outline, &mut put);
2261
2262    // Facing indicator on the face rows (skin spans x 4..8, y 1..4).
2263    match facing {
2264        Direction::Down => {
2265            put(4, 2, colors.outline);
2266            put(7, 2, colors.outline);
2267        }
2268        Direction::Left => put(4, 2, colors.outline),
2269        Direction::Right => put(7, 2, colors.outline),
2270        Direction::Up => fill(4, 1, 4, 3, colors.outline, &mut put), // back of the head
2271    }
2272}