Skip to main content

dotzuki_runner/
game.rs

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