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
72use std::collections::{HashMap, VecDeque};
73#[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
74use std::collections::HashSet;
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(project.files().as_ref(), project.data_root_rel(), !opts.headless);
397        if opts.pcm_audio {
398            audio.set_pcm_render(true);
399        }
400        let save_path = opts
401            .save_file
402            .clone()
403            .unwrap_or_else(|| project.root().join(DEFAULT_SAVE_FILE));
404
405        let start_money = project
406            .manifest()
407            .shop
408            .as_ref()
409            .map(|s| s.start_money)
410            .unwrap_or(DEFAULT_START_MONEY);
411
412        let mut game = Self {
413            project,
414            map: None,
415            camera,
416            actor: OverworldActor::new(0, 0, 16),
417            player_sprite,
418            flags: HashMap::new(),
419            lang,
420            mode: Mode::Idle,
421            transition: None,
422            pending_scenes: VecDeque::new(),
423            opening_dispatch_pending: false,
424            active_scene: None,
425            last_text: String::new(),
426            flash: 0,
427            frame_count: 0,
428            #[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
429            watcher,
430            #[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
431            watch_pending: HashSet::new(),
432            #[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
433            watch_cooldown: 0,
434            audio,
435            save_path,
436            write_saves: opts.write_saves,
437            rng_script: opts.rng_script.clone(),
438            party_state: None,
439            inventory: None,
440            money: start_money,
441            pending_whiteout: false,
442            pending_weather: None,
443            overworld_rng: None,
444            steps_taken: 0,
445        };
446
447        // Resume from a valid save unless `--fresh` or `--map` says
448        // otherwise. A corrupt/incompatible save logs and falls through to
449        // the normal boot. Disk saves are native-only; the WASM shell
450        // restores its localStorage save via `import_save` after boot.
451        #[cfg(not(target_arch = "wasm32"))]
452        if !opts.fresh && opts.map.is_none() {
453            if let Some(save) = GameSave::load(&game.save_path) {
454                if game.resume_from(save) {
455                    return Ok(game);
456                }
457            }
458        }
459
460        let map_ids = game.project.map_ids();
461        if map_ids.is_empty() {
462            // Dialogue-only boot: run the entry scene's `main` to completion.
463            let scene = game.project.entry_scene_name()?.to_string();
464            log::info!("dotzuki-runner: no maps; booting dialogue-only scene '{scene}'");
465            if !game.activate(&scene, "main") {
466                log::warn!("entry scene '{scene}' has no playable 'main' storyline");
467            }
468            return Ok(game);
469        }
470
471        let map_id = match &opts.map {
472            Some(id) => id.clone(),
473            None => game.project.entry_map()?,
474        };
475        game.boot_map(&map_id)
476            .with_context(|| format!("failed to load entry map '{map_id}'"))?;
477        game.dispatch_opening();
478        Ok(game)
479    }
480
481    /// Load `map_id` as the first map, spawning at its centre.
482    fn boot_map(&mut self, map_id: &str) -> Result<()> {
483        let map = self.project.load_map(map_id)?;
484        let spawn = find_spawn(&map);
485        let tile = map.tile_size().0 as i32;
486        self.camera.clamp_to_bounds(Rect::new(
487            0.0,
488            0.0,
489            map.pixel_width() as f32,
490            map.pixel_height() as f32,
491        ));
492        self.actor = OverworldActor::new(spawn.0, spawn.1, tile);
493        self.map = Some(map);
494        self.center_camera();
495        self.camera.update(0.0);
496        log::info!("loaded map {map_id} @ ({},{})", spawn.0, spawn.1);
497        Ok(())
498    }
499
500    /// Switch to another map, placing the player at `spawn` facing `facing`.
501    /// Returns `false` (leaving the current map untouched) when the
502    /// destination can't be loaded, so a bad warp aborts gracefully.
503    #[must_use]
504    fn enter_map(&mut self, map_id: &str, spawn: (i32, i32), facing: Direction) -> bool {
505        let map = match self.project.load_map(map_id) {
506            Ok(m) => m,
507            Err(e) => {
508                log::error!("warp to {map_id} aborted: {e:#}");
509                return false;
510            }
511        };
512        let spawn = if map.is_blocked(spawn.0, spawn.1)
513            || map.objects().npcs.iter().any(|n| (n.x, n.y) == spawn)
514        {
515            let fallback = find_spawn(&map);
516            log::warn!(
517                "spawn ({},{}) on {map_id} is occupied; using ({},{})",
518                spawn.0,
519                spawn.1,
520                fallback.0,
521                fallback.1
522            );
523            fallback
524        } else {
525            spawn
526        };
527        self.camera.clamp_to_bounds(Rect::new(
528            0.0,
529            0.0,
530            map.pixel_width() as f32,
531            map.pixel_height() as f32,
532        ));
533        self.map = Some(map);
534        self.actor.place(spawn.0, spawn.1, facing);
535        // Warps land on the ground level (stairs are the only way up).
536        self.actor.set_elevation(0);
537        self.center_camera();
538        self.camera.update(0.0);
539        log::info!("loaded map {map_id} @ ({},{})", spawn.0, spawn.1);
540        true
541    }
542
543    // ── save/load ───────────────────────────────────────────────────────────
544
545    /// Resume a save: seed the persistent flags, load its map and place the
546    /// player at the saved tile (falling back to the spawn scan when that
547    /// tile has become occupied). The opening dispatch is **skipped** on
548    /// resume — the player is continuing, not entering; the restored
549    /// `__played_main_*` flags keep `main` from replaying on later entries.
550    ///
551    /// Flags are restored from any valid save (they are the only resumable
552    /// state of a dialogue-only project). Returns `false` — fresh boot —
553    /// when the saved map can't be loaded.
554    fn resume_from(&mut self, save: GameSave) -> bool {
555        self.flags.extend(save.flags);
556        // v2 fields: a v1 save (or a v2 save that never battled) has neither —
557        // the first battle then starts from the records / starting counts.
558        if let Some(party) = save.party {
559            self.party_state = Some(
560                party
561                    .into_iter()
562                    .map(|m| PartyMemberState {
563                        id: m.id,
564                        hp: m.hp,
565                        mp: m.mp,
566                        status: m.status,
567                        level: m.level,
568                        exp: m.exp,
569                    })
570                    .collect(),
571            );
572        }
573        if save.inventory.is_some() {
574            self.inventory = save.inventory;
575        }
576        if let Some(money) = save.money {
577            self.money = money;
578        }
579        let Some(map_id) = &save.map else {
580            log::info!("save: restored flags (dialogue-only project)");
581            return false;
582        };
583        let map = match self.project.load_map(map_id) {
584            Ok(m) => m,
585            Err(e) => {
586                log::warn!("save: saved map '{map_id}' failed to load ({e:#}) — starting fresh");
587                return false;
588            }
589        };
590        let (x, y) = (save.player.x, save.player.y);
591        let facing = parse_facing(&save.player.facing);
592        let spawn = if map.is_blocked(x, y) || map.objects().npcs.iter().any(|n| (n.x, n.y) == (x, y))
593        {
594            let fallback = find_spawn(&map);
595            log::warn!(
596                "save: position ({x},{y}) on {map_id} is occupied; using ({},{})",
597                fallback.0,
598                fallback.1
599            );
600            fallback
601        } else {
602            (x, y)
603        };
604        self.camera.clamp_to_bounds(Rect::new(
605            0.0,
606            0.0,
607            map.pixel_width() as f32,
608            map.pixel_height() as f32,
609        ));
610        self.map = Some(map);
611        self.actor.place(spawn.0, spawn.1, facing);
612        // Multi-level maps: restore the saved elevation, clamped to the map.
613        let max_level = self
614            .map
615            .as_ref()
616            .map(|m| m.level_count() - 1)
617            .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 { engine: Some(engine), battle }));
984                            return;
985                        }
986                        None => {
987                            cmd = self.signal(&mut engine, CommandResult::Text("win".to_string()));
988                        }
989                    }
990                }
991                Some(ScriptCommand::StartWildBattle { species, level }) => {
992                    log::info!("scene → battle: startWildBattle({species}, lv{level}) — level ignored in v1");
993                    match self.build_battle(&species) {
994                        Some(battle) => {
995                            self.mode = Mode::Battle(Box::new(BattleState { engine: Some(engine), battle }));
996                            return;
997                        }
998                        None => {
999                            cmd = self.signal(&mut engine, CommandResult::Text("win".to_string()));
1000                        }
1001                    }
1002                }
1003                Some(ScriptCommand::SetWeather { weather }) => {
1004                    log::info!("scene → weather: {weather:?} (applies to the next battle)");
1005                    self.pending_weather = weather;
1006                    cmd = self.signal(&mut engine, CommandResult::Void);
1007                }
1008                Some(ScriptCommand::OpenShop { items }) => {
1009                    log::info!("scene → shop: openShop({items:?})");
1010                    // Suspend the scene; Mode::Shop resumes it with Void on
1011                    // exit. Buy + Sell (see game::menu).
1012                    let items = self.build_shop_items(&items);
1013                    self.mode = Mode::Shop(Box::new(ShopState::new(engine, items)));
1014                    return;
1015                }
1016                Some(other) => {
1017                    // An unimplemented command must never deadlock the VM.
1018                    log::warn!(
1019                        "unhandled scene command {other:?} in scene '{scene}' — \
1020                         auto-completing with Void"
1021                    );
1022                    cmd = self.signal(&mut engine, CommandResult::Void);
1023                }
1024                None => {
1025                    if first {
1026                        log::warn!(
1027                            "scene '{scene}' finished without producing any command — \
1028                             did it call an unregistered game.* function?"
1029                        );
1030                    }
1031                    self.finish_scene(engine);
1032                    return;
1033                }
1034            }
1035            first = false;
1036        }
1037    }
1038
1039    /// `signal_done` wrapper translating errors into a stream end.
1040    fn signal(&mut self, engine: &mut ScriptEngine, result: CommandResult) -> Option<ScriptCommand> {
1041        match engine.signal_done(result) {
1042            Ok(cmd) => cmd,
1043            Err(e) => {
1044                let scene = self.active_scene.clone().unwrap_or_default();
1045                log::warn!("scene '{scene}' signal failed: {e}");
1046                None
1047            }
1048        }
1049    }
1050
1051    /// Build a battle against enemy record `enemy_id`. Returns `None` — the
1052    /// scene continues with `"win"` (undefeated-continue) — when the project
1053    /// has no `battle` section; a broken section (unknown table ids, bad
1054    /// records, unparseable rules) logs a clear error and also yields `None`,
1055    /// so a misconfigured battle can never deadlock the scene VM.
1056    fn build_battle(&mut self, enemy_id: &str) -> Option<Battle> {
1057        if self.project.manifest().battle.is_none() {
1058            log::warn!(
1059                "startBattle({enemy_id}): project has no battle section — \
1060                 auto-completing with \"win\""
1061            );
1062            return None;
1063        }
1064        let rng: Box<dyn BattleRng> = match &self.rng_script {
1065            Some(bytes) => Box::new(ScriptedRng::new(bytes.clone())),
1066            None => {
1067                // SystemTime::now() panics on wasm32-unknown-unknown; there
1068                // the seed is pure-Rust entropy from the frame counter.
1069                #[cfg(not(target_arch = "wasm32"))]
1070                let seed = {
1071                    let nanos = std::time::SystemTime::now()
1072                        .duration_since(std::time::UNIX_EPOCH)
1073                        .map(|d| d.subsec_nanos() as u64)
1074                        .unwrap_or(0);
1075                    nanos ^ self.frame_count
1076                };
1077                #[cfg(target_arch = "wasm32")]
1078                let seed = self
1079                    .frame_count
1080                    .wrapping_mul(0x9E37_79B9_7F4A_7C15)
1081                    ^ 0xA076_1D64_78BD_642F;
1082                Box::new(XorshiftRng::seed(seed))
1083            }
1084        };
1085        match BattleSetup::from_project(&self.project).and_then(|setup| {
1086            setup.start_with(
1087                enemy_id,
1088                rng,
1089                self.party_state.as_deref(),
1090                self.inventory.as_ref(),
1091            )
1092        }) {
1093            Ok(mut battle) => {
1094                battle.set_lang(&self.lang);
1095                battle.set_currency(self.currency());
1096                // v2-e: hand the scene-armed weather to the battle (it stays
1097                // pending until the battle ends — a failed build keeps it for
1098                // the next startBattle), then run the battle-start hook pass
1099                // (weather intro + both actives' ability SwitchIn hooks).
1100                battle.set_weather(self.pending_weather.clone());
1101                battle.begin();
1102                Some(battle)
1103            }
1104            Err(e) => {
1105                log::error!("startBattle({enemy_id}): battle setup failed: {e:#}");
1106                None
1107            }
1108        }
1109    }
1110
1111    /// End the active scene: harvest flags into the persistent store, return
1112    /// to the overworld (or idle), then continue with any queued `on_enter`
1113    /// storyline or a deferred opening dispatch (scene `WarpTo`). A lost
1114    /// battle's whiteout takes precedence over both — the queued scenes
1115    /// belong to a map the player is about to leave.
1116    fn finish_scene(&mut self, engine: ScriptEngine) {
1117        self.flags.extend(engine.get_all_flags());
1118        drop(engine);
1119        self.active_scene = None;
1120        self.mode = if self.map.is_some() {
1121            Mode::Overworld
1122        } else {
1123            Mode::Idle
1124        };
1125        if self.pending_whiteout {
1126            self.pending_whiteout = false;
1127            self.pending_scenes.clear();
1128            self.opening_dispatch_pending = false;
1129            self.start_whiteout();
1130            return;
1131        }
1132        if self.pop_pending_scene() {
1133            return;
1134        }
1135        if self.opening_dispatch_pending {
1136            self.opening_dispatch_pending = false;
1137            self.dispatch_opening();
1138        }
1139        // Scene over and settled (no queued/deferred scene took over): the
1140        // flags it set are safe to persist.
1141        if matches!(self.mode, Mode::Overworld | Mode::Idle) {
1142            self.write_save();
1143        }
1144    }
1145
1146    // ── hot reload (`--watch`) ─────────────────────────────────────────────
1147
1148    /// Poll the file watcher and apply pending changes as one batch.
1149    ///
1150    /// Called at the top of every [`update`](Self::update); a no-op when
1151    /// watching is off. Batches are debounced by [`WATCH_DEBOUNCE_FRAMES`]
1152    /// so an editor save burst applies once.
1153    #[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
1154    pub fn poll_watch(&mut self) {
1155        let Some(watcher) = &mut self.watcher else {
1156            return;
1157        };
1158        self.watch_pending.extend(watcher.poll_events());
1159        if self.watch_cooldown > 0 {
1160            self.watch_cooldown -= 1;
1161            return;
1162        }
1163        if self.watch_pending.is_empty() {
1164            return;
1165        }
1166        let paths: Vec<PathBuf> = self.watch_pending.drain().collect();
1167        self.watch_cooldown = WATCH_DEBOUNCE_FRAMES;
1168        self.apply_watch_batch(paths);
1169    }
1170
1171    /// No-op without the `watch` feature (and on WASM, where hot reload is
1172    /// unsupported).
1173    #[cfg(any(not(feature = "watch"), target_arch = "wasm32"))]
1174    pub fn poll_watch(&mut self) {}
1175
1176    /// Classify a batch of changed paths and reload accordingly:
1177    ///
1178    /// - any `.scene` → recompile every DSL dir and swap scenes in place;
1179    /// - a content file under the **current** map dir (`map.tmx.json`,
1180    ///   `tileset.png`, objects sidecar) → reload that [`RuntimeMap`];
1181    /// - anything else (other maps, data tables, gfx) is ignored — it is
1182    ///   picked up on next map enter / next boot.
1183    #[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
1184    fn apply_watch_batch(&mut self, paths: Vec<PathBuf>) {
1185        let map_dir = self
1186            .map
1187            .as_ref()
1188            .map(|m| crate::map::map_dir(&self.project.maps_dir(), m.id()));
1189        let mut scenes_changed = false;
1190        let mut map_changed = false;
1191        for path in &paths {
1192            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
1193            if ext == "scene" {
1194                scenes_changed = true;
1195            } else if map_dir
1196                .as_ref()
1197                .is_some_and(|d| path.parent() == Some(d.as_path()))
1198            {
1199                map_changed = true;
1200            }
1201        }
1202        if scenes_changed {
1203            self.reload_scenes();
1204        }
1205        if map_changed {
1206            self.reload_current_map();
1207        }
1208    }
1209
1210    /// Recompile the project's DSL and swap the compiled scenes in place.
1211    /// A scene mid-activation keeps running its old engine; the next
1212    /// activation (talk/enter) picks up the new source. On a compiler
1213    /// diagnostic the old scenes keep running (`false`).
1214    pub fn reload_scenes(&mut self) -> bool {
1215        match self.project.recompile_scripts() {
1216            Ok(()) => {
1217                log::info!("hot-reload: scenes recompiled");
1218                true
1219            }
1220            Err(e) => {
1221                log::warn!("hot-reload: scene reload failed, keeping old scenes: {e:#}");
1222                false
1223            }
1224        }
1225    }
1226
1227    /// Reload the current map from disk in place, preserving the player's
1228    /// pixel position and the story flags. On a load error the old map is
1229    /// kept (`false`); `false` also when there is no current map.
1230    pub fn reload_current_map(&mut self) -> bool {
1231        let Some(map) = &self.map else {
1232            return false;
1233        };
1234        let map_id = map.id().to_string();
1235        match self.project.load_map(&map_id) {
1236            Ok(new_map) => {
1237                self.camera.clamp_to_bounds(Rect::new(
1238                    0.0,
1239                    0.0,
1240                    new_map.pixel_width() as f32,
1241                    new_map.pixel_height() as f32,
1242                ));
1243                self.map = Some(new_map);
1244                self.center_camera();
1245                self.camera.update(0.0);
1246                log::info!("hot-reload: reloaded map '{map_id}'");
1247                true
1248            }
1249            Err(e) => {
1250                log::warn!("hot-reload: map '{map_id}' reload failed, keeping old map: {e:#}");
1251                false
1252            }
1253        }
1254    }
1255
1256    // ── per-frame update ────────────────────────────────────────────────────
1257
1258    /// Advance the game one frame. Callable without any window (headless
1259    /// tests, the `run_headless` driver); the `GameLoop` impl forwards here.
1260    pub fn update(&mut self, input: &InputState) {
1261        self.frame_count += 1;
1262        self.poll_watch();
1263        self.audio.update_frame();
1264        if self.flash > 0 {
1265            self.flash -= 1;
1266        }
1267
1268        // A warp fade owns the frame: input is frozen while the map switches.
1269        if self.transition.is_some() {
1270            self.update_transition();
1271            return;
1272        }
1273
1274        match std::mem::replace(&mut self.mode, Mode::Overworld) {
1275            Mode::Text(mut state) => {
1276                if input.is_just_pressed(GbButton::A) {
1277                    state.pages.pop_front();
1278                    if state.pages.is_empty() {
1279                        match state.engine {
1280                            Some(mut engine) => {
1281                                let cmd = self.signal(&mut engine, CommandResult::Void);
1282                                self.pump(engine, cmd, false);
1283                            }
1284                            None => {
1285                                // One-off line: just close the box.
1286                                self.mode = if self.map.is_some() {
1287                                    Mode::Overworld
1288                                } else {
1289                                    Mode::Idle
1290                                };
1291                            }
1292                        }
1293                    } else {
1294                        self.mode = Mode::Text(state);
1295                    }
1296                } else {
1297                    self.mode = Mode::Text(state);
1298                }
1299            }
1300            Mode::Choice(mut state) => {
1301                let n = state.options.len().max(1);
1302                if input.is_just_pressed(GbButton::Up) {
1303                    state.cursor = (state.cursor + n - 1) % n;
1304                } else if input.is_just_pressed(GbButton::Down) {
1305                    state.cursor = (state.cursor + 1) % n;
1306                }
1307                if input.is_just_pressed(GbButton::A) {
1308                    let cursor = state.cursor;
1309                    let mut engine = state.engine;
1310                    log::info!("choice picked: {cursor}");
1311                    let cmd = self.signal(&mut engine, CommandResult::Number(cursor as f64));
1312                    self.pump(engine, cmd, false);
1313                } else {
1314                    self.mode = Mode::Choice(state);
1315                }
1316            }
1317            Mode::Delay(mut state) => {
1318                if state.frames_left > 0 {
1319                    state.frames_left -= 1;
1320                }
1321                if state.frames_left == 0 {
1322                    let mut engine = state.engine;
1323                    let cmd = self.signal(&mut engine, CommandResult::Void);
1324                    self.pump(engine, cmd, false);
1325                } else {
1326                    self.mode = Mode::Delay(state);
1327                }
1328            }
1329            Mode::Battle(mut state) => {
1330                state.battle.update(input);
1331                if let Some(outcome) = state.battle.outcome() {
1332                    // Battle over: harvest the persistent party state and
1333                    // inventory (win, lose AND run), then resume the
1334                    // suspended scene with the result.
1335                    self.party_state = Some(state.battle.party_state());
1336                    self.inventory = Some(state.battle.inventory().clone());
1337                    let result = match outcome {
1338                        BattleOutcome::Win => "win",
1339                        BattleOutcome::Lose => "lose",
1340                        BattleOutcome::Run => "run",
1341                    };
1342                    // A trainer win pays the encounter's money reward (v2-d).
1343                    if outcome == BattleOutcome::Win {
1344                        self.money = self.money.saturating_add(state.battle.trainer_money());
1345                    }
1346                    // A loss arms the whiteout, which fires when the scene
1347                    // that receives "lose" finishes (its post-lose text
1348                    // plays first); any other outcome cancels a previously
1349                    // armed one.
1350                    self.pending_whiteout = outcome == BattleOutcome::Lose;
1351                    // The armed weather was battle-local (v2-e): it dies with
1352                    // the battle and is never saved.
1353                    self.pending_weather = None;
1354                    log::info!("battle ended: {result}");
1355                    match state.engine {
1356                        // Scene battle: resume the suspended scene with the
1357                        // outcome (its post-battle text/branches play out;
1358                        // finish_scene fires an armed whiteout).
1359                        Some(mut engine) => {
1360                            let cmd =
1361                                self.signal(&mut engine, CommandResult::Text(result.to_string()));
1362                            self.pump(engine, cmd, false);
1363                        }
1364                        // Sceneless (a random encounter armed by walking):
1365                        // win/run returns to the overworld in place; a loss
1366                        // goes straight to the whiteout — there is no scene
1367                        // whose post-lose text would play first.
1368                        None => {
1369                            if outcome == BattleOutcome::Lose {
1370                                self.pending_whiteout = false;
1371                                self.start_whiteout();
1372                            } else {
1373                                self.mode = Mode::Overworld;
1374                            }
1375                        }
1376                    }
1377                } else {
1378                    self.mode = Mode::Battle(state);
1379                }
1380            }
1381            Mode::Overworld => self.update_overworld(input),
1382            Mode::Menu(state) => self.update_menu(state, input),
1383            Mode::Shop(state) => self.update_shop(*state, input),
1384            Mode::Whiteout(state) => self.update_whiteout(state, input),
1385            // Restore Idle: the replace above defaults the mode to Overworld,
1386            // which would silently erase the resting state of map-less
1387            // projects (and with it the end card).
1388            Mode::Idle => {
1389                self.mode = Mode::Idle;
1390            }
1391        }
1392
1393        self.center_camera();
1394        self.camera.update(0.0);
1395    }
1396
1397    /// Fade out → switch map → fade in → opening dispatch.
1398    fn update_transition(&mut self) {
1399        let Some(t) = &mut self.transition else {
1400            return;
1401        };
1402        if t.frames > 0 {
1403            t.frames -= 1;
1404        }
1405        if t.frames > 0 {
1406            return;
1407        }
1408        match t.phase {
1409            FadePhase::Out => {
1410                let (dest_map, dest) = (t.dest_map.clone(), (t.dest_x, t.dest_y));
1411                let facing = self.actor.facing();
1412                if self.enter_map(&dest_map, dest, facing) {
1413                    if let Some(t) = &mut self.transition {
1414                        t.phase = FadePhase::In;
1415                        t.frames = FADE_FRAMES;
1416                    }
1417                } else {
1418                    // Broken warp dest: stay put (logged by enter_map).
1419                    self.transition = None;
1420                }
1421            }
1422            FadePhase::In => {
1423                self.transition = None;
1424                // The warp is complete and the mode is a stable Overworld —
1425                // save before the opening dispatch can start a scene.
1426                self.write_save();
1427                self.dispatch_opening();
1428            }
1429        }
1430    }
1431
1432    /// Free-roam input: talk on A, walk with the D-pad, warp on arrival.
1433    /// Start opens the pause menu (party / bag / save).
1434    fn update_overworld(&mut self, input: &InputState) {
1435        if self.map.is_none() {
1436            return;
1437        }
1438        if input.is_just_pressed(GbButton::Start) {
1439            self.mode = Mode::Menu(MenuState::new());
1440            return;
1441        }
1442        if input.is_just_pressed(GbButton::A) {
1443            if let Some(npc_index) = self.faced_npc_index() {
1444                self.talk_to(npc_index);
1445                return;
1446            }
1447            if let Some(sign_index) = self.faced_sign_index() {
1448                self.read_sign(sign_index);
1449                return;
1450            }
1451        }
1452
1453        let held = held_direction(input);
1454        self.actor.set_running(input.is_held(GbButton::B));
1455        let step = {
1456            let map = self.map.as_ref().expect("checked above");
1457            let view = CollisionView { map };
1458            self.actor.update(held, &view)
1459        };
1460        if let Some((tx, ty)) = step {
1461            self.steps_taken += 1;
1462            // Stairs: a tile is either a stair or a warp in practice.
1463            self.apply_stairs(tx, ty);
1464            // Warp takes priority over an encounter roll on the same tile.
1465            let warp = {
1466                let map = self.map.as_ref().expect("checked above");
1467                map.objects()
1468                    .warps
1469                    .iter()
1470                    .find(|w| (w.x, w.y) == (tx, ty) && !w.dest_map.is_empty())
1471                    .map(|w| (w.dest_map.clone(), w.dest_x, w.dest_y))
1472            };
1473            if let Some((dest_map, dest_x, dest_y)) = warp {
1474                self.transition = Some(WarpTransition::new(dest_map, dest_x, dest_y));
1475            } else {
1476                self.roll_encounter(tx, ty);
1477            }
1478        }
1479    }
1480
1481    /// Roll a random encounter after a completed step onto `(tx, ty)`: when
1482    /// the tile lies inside one of the map's encounter zones, draw one byte
1483    /// — a hit (`< rate`, the config's /256 per-step chance) picks a
1484    /// weighted id from the zone's table and arms a sceneless battle.
1485    /// Turning in place never completes a step, and battles/scenes don't run
1486    /// `update_overworld`, so a tile is rolled exactly once per walk onto it.
1487    fn roll_encounter(&mut self, tx: i32, ty: i32) {
1488        let (rate, table) = {
1489            let map = self.map.as_ref().expect("roll_encounter requires a map");
1490            let Some(encounters) = &map.objects().encounters else {
1491                return;
1492            };
1493            match encounters.zones.iter().find(|z| z.contains(tx, ty)) {
1494                Some(zone) => (encounters.rate, zone.table.clone()),
1495                None => return,
1496            }
1497        };
1498        if rate == 0 || table.is_empty() {
1499            return;
1500        }
1501        if self.overworld_rng_byte() >= rate {
1502            return;
1503        }
1504        let total: u32 = table.iter().map(|e| e.weight).sum();
1505        if total == 0 {
1506            return;
1507        }
1508        let mut pick = u32::from(self.overworld_rng_byte()) % total;
1509        let id = table
1510            .iter()
1511            .find(|e| {
1512                if pick < e.weight {
1513                    true
1514                } else {
1515                    pick -= e.weight;
1516                    false
1517                }
1518            })
1519            .map(|e| e.id.clone());
1520        if let Some(id) = id {
1521            self.start_overworld_battle(&id);
1522        }
1523    }
1524
1525    /// The next overworld rng byte, seeding the generator on first use: a
1526    /// scripted stream under [`RunnerOptions::rng_script`] (deterministic
1527    /// test hook), else a xorshift seeded like the per-battle rng in
1528    /// [`build_battle`](Self::build_battle) — SystemTime entropy on native;
1529    /// on wasm32 the frame counter mixed with the step counter (the frame
1530    /// counter alone is 0 at boot there, and SystemTime panics).
1531    fn overworld_rng_byte(&mut self) -> u8 {
1532        if self.overworld_rng.is_none() {
1533            let rng: Box<dyn BattleRng> = match &self.rng_script {
1534                Some(bytes) => Box::new(ScriptedRng::new(bytes.clone())),
1535                None => {
1536                    #[cfg(not(target_arch = "wasm32"))]
1537                    let seed = {
1538                        let nanos = std::time::SystemTime::now()
1539                            .duration_since(std::time::UNIX_EPOCH)
1540                            .map(|d| d.subsec_nanos() as u64)
1541                            .unwrap_or(0);
1542                        nanos ^ self.frame_count
1543                    };
1544                    #[cfg(target_arch = "wasm32")]
1545                    let seed = self
1546                        .frame_count
1547                        .wrapping_add(self.steps_taken)
1548                        .wrapping_mul(0x9E37_79B9_7F4A_7C15)
1549                        ^ 0xA076_1D64_78BD_642F;
1550                    Box::new(XorshiftRng::seed(seed))
1551                }
1552            };
1553            self.overworld_rng = Some(rng);
1554        }
1555        self.overworld_rng
1556            .as_deref_mut()
1557            .expect("seeded above")
1558            .byte()
1559    }
1560
1561    /// Arm a sceneless battle from a random encounter: same construction as
1562    /// a scene's `startBattle` ([`build_battle`](Self::build_battle) — an
1563    /// encounter record first, a single enemy record as the fallback), but
1564    /// no scene engine is suspended, so the outcome flows straight back to
1565    /// the overworld (win/run) or the whiteout (lose). A failed build logs
1566    /// (inside `build_battle`) and simply keeps the player walking.
1567    fn start_overworld_battle(&mut self, id: &str) {
1568        log::info!("random encounter: {id}");
1569        if let Some(battle) = self.build_battle(id) {
1570            self.mode = Mode::Battle(Box::new(BattleState { engine: None, battle }));
1571        }
1572    }
1573
1574    /// Elevation transition on arrival: stair GID 1 ascends one level, GID 2
1575    /// descends one (both clamped to the map's level count).
1576    fn apply_stairs(&mut self, x: i32, y: i32) {
1577        let Some(map) = &self.map else {
1578            return;
1579        };
1580        let level = self.actor.elevation();
1581        let next = match map.stair_at(x, y) {
1582            Some(1) => (level as usize + 1).min(map.level_count() - 1) as u8,
1583            Some(2) => level.saturating_sub(1),
1584            _ => return,
1585        };
1586        if next != level {
1587            self.actor.set_elevation(next);
1588        }
1589    }
1590
1591    /// The index of the NPC on the tile the player faces, if any.
1592    fn faced_npc_index(&self) -> Option<usize> {
1593        let map = self.map.as_ref()?;
1594        let (dx, dy) = direction_delta(self.actor.facing());
1595        let (tx, ty) = self.actor.tile();
1596        let faced = (tx + dx, ty + dy);
1597        map.objects()
1598            .npcs
1599            .iter()
1600            .position(|n| (n.x, n.y) == faced)
1601    }
1602
1603    /// The index of the sign on the tile the player faces, if any.
1604    fn faced_sign_index(&self) -> Option<usize> {
1605        let map = self.map.as_ref()?;
1606        let (dx, dy) = direction_delta(self.actor.facing());
1607        let (tx, ty) = self.actor.tile();
1608        let faced = (tx + dx, ty + dy);
1609        map.objects()
1610            .signs
1611            .iter()
1612            .position(|s| (s.x, s.y) == faced)
1613    }
1614
1615    /// Read the sign at `index`: its `text` is plain text, shown as one-off
1616    /// pages (same shape as an NPC's raw `talk` fallback).
1617    fn read_sign(&mut self, index: usize) {
1618        let map = self.map.as_ref().expect("read_sign requires a map");
1619        let text = map.objects().signs[index].text.clone();
1620        if text.is_empty() {
1621            return;
1622        }
1623        self.last_text = text.clone();
1624        self.mode = Mode::Text(TextState {
1625            engine: None,
1626            pages: paginate(&text),
1627        });
1628    }
1629
1630    /// Talk dispatch for NPC `index`: `talk` as storyline name → matching
1631    /// route → map scene `main` → raw `talk` text as a one-off line.
1632    fn talk_to(&mut self, index: usize) {
1633        let (map_id, npc_name, npc_id, talk) = {
1634            let map = self.map.as_ref().expect("talk requires a map");
1635            let npc = &map.objects().npcs[index];
1636            (
1637                map.id().to_string(),
1638                npc.name.clone(),
1639                npc.id,
1640                npc.talk.clone(),
1641            )
1642        };
1643
1644        // 1. The talk field names a storyline.
1645        if !talk.is_empty() {
1646            if let Some(scene) = self.scene_with_storyline(&map_id, &talk) {
1647                if self.activate(&scene, &talk) {
1648                    return;
1649                }
1650            }
1651        }
1652
1653        // 2. A route whose npc matches this NPC (name, or id as a string).
1654        let route = self
1655            .project
1656            .routes()
1657            .iter()
1658            .filter(|r| r.map == map_id && !r.on_enter)
1659            .find(|r| {
1660                r.npc.as_deref().is_some_and(|n| {
1661                    (!npc_name.is_empty() && n == npc_name) || n == npc_id.to_string()
1662                })
1663            })
1664            .map(|r| r.storyline.clone());
1665        if let Some(storyline) = route {
1666            if let Some(scene) = self.scene_with_storyline(&map_id, &storyline) {
1667                if self.activate(&scene, &storyline) {
1668                    return;
1669                }
1670            }
1671        }
1672
1673        // 3. The map scene's main storyline.
1674        if let Some(scene) = self.scene_for_map(&map_id) {
1675            if scene_has_fn(&self.project, &scene, "main") && self.activate(&scene, "main") {
1676                return;
1677            }
1678        }
1679
1680        // 4. The talk field is plain text — show it as a one-off line.
1681        if !talk.is_empty() {
1682            self.last_text = talk.clone();
1683            self.mode = Mode::Text(TextState {
1684                engine: None,
1685                pages: paginate(&talk),
1686            });
1687        }
1688    }
1689
1690    // ── rendering ───────────────────────────────────────────────────────────
1691
1692    /// Render the current frame. Callable without any window; the `GameLoop`
1693    /// impl forwards here.
1694    pub fn draw(&mut self, fb: &mut FrameBuffer) {
1695        if let Some(map) = &self.map {
1696            fb.clear(Rgba::BLACK);
1697            let cam_x = self.camera.position.x.round() as i32;
1698            let cam_y = self.camera.position.y.round() as i32;
1699            let level = self.actor.elevation() as i32;
1700            // Layers at/below the player's elevation draw under the sprites;
1701            // higher layers (e.g. wall tops seen from the ground) over them.
1702            if let Err(e) = map.render_below(fb, cam_x, cam_y, SCREEN_W as u32, SCREEN_H as u32, level)
1703            {
1704                log::warn!("map render: {e:#}");
1705            }
1706            self.draw_npcs(fb, cam_x, cam_y);
1707            self.draw_player(fb, cam_x, cam_y);
1708            if let Err(e) = map.render_above(fb, cam_x, cam_y, SCREEN_W as u32, SCREEN_H as u32, level)
1709            {
1710                log::warn!("map render: {e:#}");
1711            }
1712        } else {
1713            // Dialogue-only backdrop.
1714            fb.clear(Rgba::rgb(0x10, 0x10, 0x18));
1715        }
1716
1717        match &self.mode {
1718            Mode::Text(state) => {
1719                if let Some(page) = state.pages.front() {
1720                    draw_textbox(fb, page);
1721                }
1722            }
1723            Mode::Choice(state) => {
1724                if !state.context_text.is_empty() {
1725                    draw_textbox(fb, &state.context_text);
1726                }
1727                draw_choice_menu(fb, &state.options, state.cursor);
1728            }
1729            Mode::Battle(state) => state.battle.draw(fb),
1730            Mode::Menu(state) => self.draw_menu(fb, state),
1731            Mode::Shop(state) => self.draw_shop(fb, state),
1732            Mode::Whiteout(state) => {
1733                if let Some(page) = state.pages.front() {
1734                    draw_textbox(fb, page);
1735                }
1736            }
1737            Mode::Idle if self.map.is_none() => {
1738                // Dialogue-only projects end here: show a small end card
1739                // instead of leaving a void on screen.
1740                draw_end_card(fb, &self.project.manifest().name, &self.lang);
1741            }
1742            _ => {}
1743        }
1744
1745        // Fade overlays (warp transition / cosmetic flash).
1746        let darkness = match &self.transition {
1747            Some(t) => match t.phase {
1748                FadePhase::Out => 1.0 - t.frames as f32 / FADE_FRAMES as f32,
1749                FadePhase::In => t.frames as f32 / FADE_FRAMES as f32,
1750            },
1751            None if self.flash > 0 => 0.5 * self.flash as f32 / FLASH_FRAMES as f32,
1752            None => 0.0,
1753        };
1754        if darkness > 0.0 {
1755            darken(fb, 1.0 - darkness.clamp(0.0, 1.0));
1756        }
1757
1758        // The whiteout's blackout phase covers everything.
1759        if let Mode::Whiteout(state) = &self.mode {
1760            if state.blackout > 0 {
1761                fb.fill_rect(0, 0, SCREEN_W as u32, SCREEN_H as u32, Rgba::BLACK);
1762            }
1763        }
1764    }
1765
1766    /// NPC placeholders: a two-tone person blob per NPC, palette derived from
1767    /// the NPC id so distinct NPCs read as distinct people.
1768    fn draw_npcs(&self, fb: &mut FrameBuffer, cam_x: i32, cam_y: i32) {
1769        let Some(map) = &self.map else {
1770            return;
1771        };
1772        let tile = map.tile_size().0 as i32;
1773        for npc in &map.objects().npcs {
1774            let facing = parse_facing(&npc.facing);
1775            let colors = npc_palette(npc.id);
1776            draw_person(
1777                fb,
1778                npc.x * tile - cam_x,
1779                npc.y * tile - cam_y,
1780                tile,
1781                facing,
1782                &colors,
1783            );
1784        }
1785    }
1786
1787    fn draw_player(&self, fb: &mut FrameBuffer, cam_x: i32, cam_y: i32) {
1788        let tile = self.map.as_ref().map(|m| m.tile_size().0 as i32).unwrap_or(16);
1789        let foot_x = self.actor.px().round() as i32 - cam_x;
1790        let foot_y = self.actor.py().round() as i32 - cam_y;
1791        if let Some(sprite) = &self.player_sprite {
1792            let col = frame_col(
1793                self.actor.locomotion(),
1794                self.actor.step_phase(),
1795                sprite.cols,
1796            );
1797            sprite.draw_on_tile(fb, self.actor.facing_row(), col, foot_x, foot_y, tile);
1798            return;
1799        }
1800        draw_person(fb, foot_x, foot_y, tile, self.actor.facing(), &PLAYER_COLORS);
1801    }
1802
1803    fn center_camera(&mut self) {
1804        let tile = self.map.as_ref().map(|m| m.tile_size().0 as i32).unwrap_or(16);
1805        let cx = self.actor.px() + (tile / 2) as f32;
1806        let cy = self.actor.py() + (tile / 2) as f32;
1807        self.camera.follow_target(Vec2::new(
1808            cx - SCREEN_W as f32 / 2.0,
1809            cy - SCREEN_H as f32 / 2.0,
1810        ));
1811    }
1812
1813    // ── introspection (headless driver, tests) ──────────────────────────────
1814
1815    /// The currently loaded map id (`None` in dialogue-only mode).
1816    pub fn current_map_id(&self) -> Option<&str> {
1817        self.map.as_ref().map(RuntimeMap::id)
1818    }
1819
1820    /// A persistent story flag's value (defaults to `false`).
1821    pub fn flag(&self, name: &str) -> bool {
1822        self.flags.get(name).copied().unwrap_or(false)
1823    }
1824
1825    /// The text page currently on screen, if a textbox is open (including
1826    /// the game-over whiteout message).
1827    pub fn dialogue_text(&self) -> Option<&str> {
1828        match &self.mode {
1829            Mode::Text(state) => state.pages.front().map(String::as_str),
1830            Mode::Whiteout(state) if state.blackout == 0 => {
1831                state.pages.front().map(String::as_str)
1832            }
1833            _ => None,
1834        }
1835    }
1836
1837    /// The choice options currently on screen, if a choice menu is open.
1838    pub fn choice_options(&self) -> Option<&[String]> {
1839        match &self.mode {
1840            Mode::Choice(state) => Some(&state.options),
1841            _ => None,
1842        }
1843    }
1844
1845    /// The live battle, if one is running (test/debug introspection).
1846    pub fn battle(&self) -> Option<&Battle> {
1847        match &self.mode {
1848            Mode::Battle(state) => Some(&state.battle),
1849            _ => None,
1850        }
1851    }
1852
1853    /// The persistent party state, once a battle has completed or a save
1854    /// restored one (test/debug introspection).
1855    pub fn party_state(&self) -> Option<&[PartyMemberState]> {
1856        self.party_state.as_deref()
1857    }
1858
1859    /// The persistent battle inventory, once a battle has completed or a
1860    /// save restored one (test/debug introspection).
1861    pub fn inventory(&self) -> Option<&HashMap<String, u32>> {
1862        self.inventory.as_ref()
1863    }
1864
1865    /// The player's current money (test/debug introspection).
1866    pub fn money(&self) -> u32 {
1867        self.money
1868    }
1869
1870    /// The rows the open Start menu currently displays (root labels, party
1871    /// detail lines, bag rows, target rows, or the note text); `None` when
1872    /// the menu is closed (test/debug introspection).
1873    pub fn menu_lines(&self) -> Option<Vec<String>> {
1874        match &self.mode {
1875            Mode::Menu(state) => Some(self.menu_lines_for(state)),
1876            _ => None,
1877        }
1878    }
1879
1880    /// The item rows the open shop displays (`×`-prefixed when
1881    /// unaffordable), plus the transient note as a final row when one is
1882    /// showing; `None` when no shop is open (test/debug introspection).
1883    pub fn shop_lines(&self) -> Option<Vec<String>> {
1884        match &self.mode {
1885            Mode::Shop(state) => Some(self.shop_lines_for(state)),
1886            _ => None,
1887        }
1888    }
1889
1890    /// `true` while the game-over whiteout owns the screen (blackout or
1891    /// message phase; test/debug introspection).
1892    pub fn whiteout_active(&self) -> bool {
1893        matches!(self.mode, Mode::Whiteout(_))
1894    }
1895
1896    /// The player's current tile.
1897    pub fn player_tile(&self) -> (i32, i32) {
1898        self.actor.tile()
1899    }
1900
1901    /// The player's current elevation level (multi-level maps; test/debug
1902    /// introspection).
1903    pub fn player_elevation(&self) -> u8 {
1904        self.actor.elevation()
1905    }
1906
1907    /// `true` when tile `(x, y)` is map-solid on the current map (solid when
1908    /// there is no map). NPC occupancy is not included; test/debug helper.
1909    pub fn is_blocked(&self, x: i32, y: i32) -> bool {
1910        match &self.map {
1911            Some(map) => map.is_blocked(x, y),
1912            None => true,
1913        }
1914    }
1915
1916    /// The audio subsystem (test/debug introspection).
1917    pub fn audio(&self) -> &RunnerAudio {
1918        &self.audio
1919    }
1920
1921    /// Pull `frames` stereo PCM frames (44100 Hz, interleaved L/R `f32`,
1922    /// length `2 * frames`) from the audio engine — the render path for
1923    /// hosts without an audio callback thread (the WASM shell feeding
1924    /// WebAudio). Empty unless [`RunnerOptions::pcm_audio`] is on and a play
1925    /// command has arrived.
1926    pub fn render_audio(&mut self, frames: usize) -> Vec<f32> {
1927        self.audio.render_samples(frames)
1928    }
1929
1930    /// Teleport the player (test/debug helper; no scene side effects).
1931    pub fn debug_place(&mut self, x: i32, y: i32, facing: Direction) {
1932        self.actor.place(x, y, facing);
1933        self.center_camera();
1934        self.camera.update(0.0);
1935    }
1936
1937    /// Frames updated so far.
1938    pub fn frame_count(&self) -> u64 {
1939        self.frame_count
1940    }
1941}
1942
1943#[cfg(not(target_arch = "wasm32"))]
1944impl dotzuki_app::GameLoop for RunnerGame {
1945    type Fb = FrameBuffer;
1946
1947    fn update(&mut self, input: &InputState) {
1948        RunnerGame::update(self, input);
1949    }
1950
1951    fn draw(&mut self, frame_buffer: &mut FrameBuffer) {
1952        RunnerGame::draw(self, frame_buffer);
1953    }
1954}
1955
1956// ── helpers ─────────────────────────────────────────────────────────────────
1957
1958/// Decode a PNG walk sheet from in-memory bytes (the VFS counterpart of
1959/// `WalkSprite::load`, which is disk-only).
1960fn decode_walk_sheet(
1961    bytes: &[u8],
1962    path: &str,
1963    frame_w: u32,
1964    frame_h: u32,
1965) -> Result<WalkSprite, String> {
1966    let img = image::load_from_memory(bytes)
1967        .map_err(|e| format!("decode {path}: {e}"))?
1968        .to_rgba8();
1969    let (w, h) = img.dimensions();
1970    let pixels = img
1971        .pixels()
1972        .map(|p| Rgba::new(p.0[0], p.0[1], p.0[2], p.0[3]))
1973        .collect();
1974    WalkSprite::from_rgba(pixels, w, h, frame_w, frame_h)
1975}
1976
1977/// Cheap probe for "does this scene export this storyline/function name"
1978/// without instantiating a [`ScriptEngine`] — matched on the DSL's generated
1979/// export names (`storyline_<name>`, `<Scene>OnLoad`). [`RunnerGame::activate`]
1980/// re-validates authoritatively with `has_function` before running.
1981fn scene_has_fn(project: &LoadedProject, scene: &str, fn_name: &str) -> bool {
1982    let Some(js) = project.scripts().get_script(scene) else {
1983        return false;
1984    };
1985    js.contains(&format!("storyline_{fn_name}")) || js.contains(&format!("function {fn_name}"))
1986}
1987
1988/// Directories `--watch` monitors: every DSL dir, the data root and the
1989/// gfx root (deduplicated; missing dirs are skipped by the watcher).
1990#[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
1991fn watch_dirs(project: &LoadedProject) -> Vec<PathBuf> {
1992    let mut dirs = project.manifest().dsl_dirs(project.root());
1993    dirs.push(project.data_root().to_path_buf());
1994    if let Some(gfx) = project.gfx_root() {
1995        dirs.push(gfx.to_path_buf());
1996    }
1997    let mut seen = HashSet::new();
1998    dirs.retain(|d| seen.insert(d.clone()));
1999    dirs
2000}
2001
2002/// First free tile scanning outward (Chebyshev rings) from the map centre.
2003/// "Free" = not map-solid and not NPC-occupied.
2004fn find_spawn(map: &RuntimeMap) -> (i32, i32) {
2005    let (cx, cy) = (map.width() as i32 / 2, map.height() as i32 / 2);
2006    let free = |x: i32, y: i32| {
2007        !map.is_blocked(x, y) && !map.objects().npcs.iter().any(|n| (n.x, n.y) == (x, y))
2008    };
2009    let max_r = (map.width().max(map.height()) as i32) + 1;
2010    for r in 0..max_r {
2011        for dy in -r..=r {
2012            for dx in -r..=r {
2013                if dx.abs().max(dy.abs()) != r {
2014                    continue; // ring perimeter only
2015                }
2016                let (x, y) = (cx + dx, cy + dy);
2017                if free(x, y) {
2018                    return (x, y);
2019                }
2020            }
2021        }
2022    }
2023    (cx, cy)
2024}
2025
2026/// Held D-pad direction (Up > Down > Left > Right priority, as wuxia).
2027fn held_direction(input: &InputState) -> Option<Direction> {
2028    if input.is_held(GbButton::Up) {
2029        Some(Direction::Up)
2030    } else if input.is_held(GbButton::Down) {
2031        Some(Direction::Down)
2032    } else if input.is_held(GbButton::Left) {
2033        Some(Direction::Left)
2034    } else if input.is_held(GbButton::Right) {
2035        Some(Direction::Right)
2036    } else {
2037        None
2038    }
2039}
2040
2041/// Unit step delta for a cardinal direction.
2042fn direction_delta(dir: Direction) -> (i32, i32) {
2043    match dir {
2044        Direction::Down => (0, 1),
2045        Direction::Up => (0, -1),
2046        Direction::Left => (-1, 0),
2047        Direction::Right => (1, 0),
2048    }
2049}
2050
2051/// `"down"`/`"up"`/`"left"`/`"right"` sidecar string → [`Direction`].
2052fn parse_facing(facing: &str) -> Direction {
2053    match facing {
2054        "up" => Direction::Up,
2055        "left" => Direction::Left,
2056        "right" => Direction::Right,
2057        _ => Direction::Down,
2058    }
2059}
2060
2061/// [`Direction`] → the sidecar/save string form (inverse of [`parse_facing`]).
2062fn facing_name(dir: Direction) -> &'static str {
2063    match dir {
2064        Direction::Down => "down",
2065        Direction::Up => "up",
2066        Direction::Left => "left",
2067        Direction::Right => "right",
2068    }
2069}
2070
2071/// Wrap `text` into pages of at most [`DIALOG_LINES_PER_PAGE`] lines each
2072/// (the join of the page's lines with `\n`). Always at least one page.
2073fn paginate(text: &str) -> VecDeque<String> {
2074    let lines = wrap_lines(text, DIALOG_WIDTH_PX, 4096);
2075    let mut pages: VecDeque<String> = lines
2076        .chunks(DIALOG_LINES_PER_PAGE)
2077        .map(|chunk| chunk.join("\n"))
2078        .collect();
2079    if pages.is_empty() {
2080        pages.push_back(String::new());
2081    }
2082    pages
2083}
2084
2085/// The shared dialogue [`MenuConfig`] (bottom box on the 40×30 tile grid).
2086fn dialog_config() -> MenuConfig {
2087    MenuConfig::new(
2088        DIALOG_AREA,
2089        None,
2090        TileRect::new(
2091            DIALOG_AREA.tx + 1,
2092            DIALOG_AREA.ty + 1,
2093            DIALOG_AREA.tw - 2,
2094            DIALOG_AREA.th - 2,
2095        ),
2096        Default::default(),
2097    )
2098}
2099
2100/// Draw the bottom dialogue textbox with one page of text.
2101pub(crate) fn draw_textbox(fb: &mut FrameBuffer, text: &str) {
2102    let mut painter = FrameBufferPainter::new(fb);
2103    draw_dialog(text, &[dialog_config()], &mut painter);
2104}
2105
2106/// Centered end card for dialogue-only projects whose entry scene finished:
2107/// the game name plus a localized "fin." so the screen isn't a void.
2108fn draw_end_card(fb: &mut FrameBuffer, game_name: &str, lang: &str) {
2109    let fin = if lang == "zh" { "完" } else { "fin." };
2110    let cx = SCREEN_W as u32 / 2;
2111    let name_w = embedded_font::measure_text(game_name);
2112    embedded_font::draw_text(
2113        game_name,
2114        cx.saturating_sub(name_w / 2),
2115        100,
2116        Rgba::rgb(0xf0, 0xf0, 0xf0),
2117        fb,
2118    );
2119    let fin_w = embedded_font::measure_text(fin);
2120    embedded_font::draw_text(fin, cx.saturating_sub(fin_w / 2), 120, Rgba::rgb(0x90, 0x90, 0xa8), fb);
2121}
2122
2123/// Draw the choice menu as a flex box just above the dialogue area,
2124/// right-aligned, sized to the options.
2125fn draw_choice_menu(fb: &mut FrameBuffer, options: &[String], cursor: usize) {
2126    let n = options.len() as u32;
2127    if n == 0 {
2128        return;
2129    }
2130    let max_len = options
2131        .iter()
2132        .map(|o| o.chars().count())
2133        .max()
2134        .unwrap_or(1) as u32;
2135    // +4: left/right border, cursor column, one padding column.
2136    let w = (max_len + 4).clamp(8, 20);
2137    let h = n + 2;
2138    let tx = (40 - w) as i32;
2139    let ty = DIALOG_AREA.ty as i32 - h as i32;
2140    let config = MenuConfig::new(
2141        TileRect::new(tx.max(0) as u32, ty.max(0) as u32, w, h),
2142        None,
2143        TileRect::new(tx.max(0) as u32 + 1, ty.max(0) as u32 + 1, w - 2, n),
2144        Default::default(),
2145    );
2146    let state = FlexMenuState {
2147        cursor,
2148        scroll_offset: 0,
2149    };
2150    let mut painter = FrameBufferPainter::new(fb);
2151    let mut ui = Ui::new(&mut painter);
2152    draw_flex_menu(options, &[config], &state, options.len(), &mut ui);
2153}
2154
2155/// Multiply every framebuffer pixel by `factor` (1.0 = unchanged, 0.0 = black).
2156fn darken(fb: &mut FrameBuffer, factor: f32) {
2157    for px in fb.data.chunks_exact_mut(4) {
2158        px[0] = (px[0] as f32 * factor) as u8;
2159        px[1] = (px[1] as f32 * factor) as u8;
2160        px[2] = (px[2] as f32 * factor) as u8;
2161    }
2162}
2163
2164// ── placeholder people ──────────────────────────────────────────────────────
2165
2166/// Palette of a procedurally drawn placeholder person.
2167struct PersonColors {
2168    outline: Rgba,
2169    skin: Rgba,
2170    body: Rgba,
2171}
2172
2173/// The player's palette (red jacket, the classic protagonist read).
2174const PLAYER_COLORS: PersonColors = PersonColors {
2175    outline: Rgba::rgb(0x30, 0x18, 0x18),
2176    skin: Rgba::rgb(0xF0, 0xC8, 0xA0),
2177    body: Rgba::rgb(0xC8, 0x30, 0x30),
2178};
2179
2180/// NPC body colours; the NPC id hashes into this palette.
2181const NPC_BODIES: [(u8, u8, u8); 6] = [
2182    (0x30, 0x58, 0xC8), // blue
2183    (0x38, 0x90, 0x40), // green
2184    (0x88, 0x48, 0xA8), // purple
2185    (0xC8, 0x78, 0x28), // orange
2186    (0x28, 0x98, 0x98), // teal
2187    (0x80, 0x58, 0x38), // brown
2188];
2189
2190/// Per-NPC palette: body colour derived from the id hash.
2191fn npc_palette(id: u32) -> PersonColors {
2192    let (r, g, b) = NPC_BODIES[(id.wrapping_mul(2_654_435_761) >> 16) as usize % NPC_BODIES.len()];
2193    PersonColors {
2194        outline: Rgba::rgb(r / 3, g / 3, b / 3),
2195        skin: Rgba::rgb(0xF0, 0xC8, 0xA0),
2196        body: Rgba::rgb(r, g, b),
2197    }
2198}
2199
2200/// Draw a ~12×14 two-tone placeholder person, centred on and bottom-aligned
2201/// to the `tile`-px tile whose top-left is `(sx, sy)` in screen pixels. Pure
2202/// code — no embedded assets. Eyes (or the back of the head when facing up)
2203/// give a 1-px facing indicator.
2204fn draw_person(
2205    fb: &mut FrameBuffer,
2206    sx: i32,
2207    sy: i32,
2208    tile: i32,
2209    facing: Direction,
2210    colors: &PersonColors,
2211) {
2212    const W: i32 = 12;
2213    const H: i32 = 14;
2214    let ox = sx + (tile - W) / 2;
2215    let oy = sy + tile - H;
2216    let (w, h) = (fb.width() as i32, fb.height() as i32);
2217    let mut put = |x: i32, y: i32, c: Rgba| {
2218        let (px, py) = (ox + x, oy + y);
2219        if px >= 0 && py >= 0 && px < w && py < h {
2220            fb.set_pixel(px as u32, py as u32, c);
2221        }
2222    };
2223    let fill = |x: i32, y: i32, fw: i32, fh: i32, c: Rgba, put: &mut dyn FnMut(i32, i32, Rgba)| {
2224        for dy in 0..fh {
2225            for dx in 0..fw {
2226                put(x + dx, y + dy, c);
2227            }
2228        }
2229    };
2230
2231    // Head (6×5) and torso (8×7), 1-px outline.
2232    fill(3, 0, 6, 5, colors.outline, &mut put);
2233    fill(4, 1, 4, 3, colors.skin, &mut put);
2234    fill(2, 5, 8, 7, colors.outline, &mut put);
2235    fill(3, 6, 6, 5, colors.body, &mut put);
2236    // Legs.
2237    fill(3, 12, 2, 2, colors.outline, &mut put);
2238    fill(7, 12, 2, 2, colors.outline, &mut put);
2239
2240    // Facing indicator on the face rows (skin spans x 4..8, y 1..4).
2241    match facing {
2242        Direction::Down => {
2243            put(4, 2, colors.outline);
2244            put(7, 2, colors.outline);
2245        }
2246        Direction::Left => put(4, 2, colors.outline),
2247        Direction::Right => put(7, 2, colors.outline),
2248        Direction::Up => fill(4, 1, 4, 3, colors.outline, &mut put), // back of the head
2249    }
2250}