1use std::collections::{HashMap, VecDeque};
73#[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
74use std::collections::HashSet;
75use std::path::{Path, PathBuf};
76
77use anyhow::{Context, Result};
78use dotzuki_engine::camera::{Camera, Rect, Vec2};
79use dotzuki_engine::menu::MenuConfig;
80use dotzuki_engine::overworld::actor::{frame_col, OverworldActor, OverworldCollision};
81use dotzuki_engine::overworld::types::Direction;
82use dotzuki_engine::render::{FrameBuffer, Rgba, TileRect, Ui};
83use dotzuki_engine_script::command::{CommandResult, ScriptCommand};
84use dotzuki_engine_script::engine::ScriptEngine;
85use dotzuki_renderer::embedded_font;
86use dotzuki_renderer::input::{GbButton, InputState};
87use dotzuki_renderer::walk_sprite::WalkSprite;
88use dotzuki_ui::widgets::dialog::{draw_dialog, wrap_lines};
89use dotzuki_ui::widgets::flex_menu::{draw_flex_menu, FlexMenuState};
90use dotzuki_ui::FrameBufferPainter;
91
92use crate::audio::RunnerAudio;
93use crate::battle::{
94 Battle, BattleOutcome, BattleRng, BattleSetup, PartyMemberState, ScriptedRng, XorshiftRng,
95};
96use crate::manifest::DEFAULT_START_MONEY;
97use crate::map::RuntimeMap;
98use crate::project::LoadedProject;
99use crate::save::{GameSave, PartyMemberSave, PlayerSave, DEFAULT_SAVE_FILE, SAVE_VERSION};
100use crate::vfs::join_path;
101#[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
102use crate::watch::ProjectWatcher;
103
104mod menu;
105use menu::{MenuState, ShopState, WhiteoutState};
106
107pub const SCREEN_W: i32 = 320;
109pub const SCREEN_H: i32 = 240;
111
112const FADE_FRAMES: u32 = 10;
114const FLASH_FRAMES: u32 = 10;
116#[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
119const WATCH_DEBOUNCE_FRAMES: u32 = 10;
120
121pub(crate) const DIALOG_AREA: TileRect = TileRect::new(0, 24, 40, 6);
123const DIALOG_LINES_PER_PAGE: usize = 2;
125const DIALOG_WIDTH_PX: usize = (DIALOG_AREA.tw as usize - 2) * 8;
129
130#[derive(Debug, Clone, Default)]
132pub struct RunnerOptions {
133 pub map: Option<String>,
135 pub lang: String,
137 pub watch: bool,
140 pub headless: bool,
143 pub pcm_audio: bool,
148 pub fresh: bool,
150 pub save_file: Option<PathBuf>,
153 pub rng_script: Option<Vec<u8>>,
156 pub write_saves: bool,
161}
162
163struct TextState {
167 engine: Option<ScriptEngine>,
168 pages: VecDeque<String>,
169}
170
171struct ChoiceState {
174 engine: ScriptEngine,
175 options: Vec<String>,
176 cursor: usize,
177 context_text: String,
179}
180
181struct DelayState {
183 engine: ScriptEngine,
184 frames_left: u16,
185}
186
187struct BattleState {
194 engine: Option<ScriptEngine>,
195 battle: Battle,
196}
197
198enum Mode {
200 Overworld,
202 Text(TextState),
204 Choice(ChoiceState),
206 Delay(DelayState),
208 Battle(Box<BattleState>),
211 Menu(MenuState),
214 Shop(Box<ShopState>),
216 Whiteout(WhiteoutState),
219 Idle,
221}
222
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225enum FadePhase {
226 Out,
227 In,
228}
229
230struct WarpTransition {
232 dest_map: String,
233 dest_x: i32,
234 dest_y: i32,
235 phase: FadePhase,
236 frames: u32,
237}
238
239impl WarpTransition {
240 fn new(dest_map: String, dest_x: i32, dest_y: i32) -> Self {
241 Self {
242 dest_map,
243 dest_x,
244 dest_y,
245 phase: FadePhase::Out,
246 frames: FADE_FRAMES,
247 }
248 }
249}
250
251struct CollisionView<'a> {
254 map: &'a RuntimeMap,
255}
256
257impl CollisionView<'_> {
258 fn npc_blocks(&self, x: i32, y: i32) -> bool {
260 self.map.objects().npcs.iter().any(|n| (n.x, n.y) == (x, y))
261 }
262}
263
264impl OverworldCollision for CollisionView<'_> {
265 fn is_blocked(&self, x: i32, y: i32) -> bool {
266 self.map.is_blocked(x, y) || self.npc_blocks(x, y)
267 }
268
269 fn is_blocked_at(&self, level: u8, x: i32, y: i32) -> bool {
270 self.map.is_blocked_at(level, x, y) || self.npc_blocks(x, y)
273 }
274}
275
276pub struct RunnerGame {
284 project: LoadedProject,
285 map: Option<RuntimeMap>,
287 camera: Camera,
288 actor: OverworldActor,
289 player_sprite: Option<WalkSprite>,
291 flags: HashMap<String, bool>,
293 lang: String,
294 mode: Mode,
295 transition: Option<WarpTransition>,
297 pending_scenes: VecDeque<(String, String)>,
299 opening_dispatch_pending: bool,
302 active_scene: Option<String>,
304 last_text: String,
306 flash: u32,
308 frame_count: u64,
310 #[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
313 watcher: Option<ProjectWatcher>,
314 #[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
316 watch_pending: HashSet<PathBuf>,
317 #[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
319 watch_cooldown: u32,
320 audio: RunnerAudio,
323 save_path: PathBuf,
325 write_saves: bool,
327 rng_script: Option<Vec<u8>>,
329 party_state: Option<Vec<PartyMemberState>>,
334 inventory: Option<HashMap<String, u32>>,
337 money: u32,
340 pending_whiteout: bool,
344 pending_weather: Option<String>,
348 overworld_rng: Option<Box<dyn BattleRng>>,
353 steps_taken: u64,
356}
357
358impl RunnerGame {
359 pub fn new(project: LoadedProject, opts: RunnerOptions) -> Result<Self> {
369 let lang = if opts.lang.is_empty() {
370 "en".to_string()
371 } else {
372 opts.lang
373 };
374 let gfx_rel = project.gfx_root_rel();
375 let player_sprite = {
376 let rel = join_path(&gfx_rel, "overworld/player/sheet.png");
377 match project.files().read(&rel) {
378 Ok(bytes) => decode_walk_sheet(&bytes, &rel, 24, 32)
379 .map_err(|e| log::warn!("player sprite {rel}: {e}"))
380 .ok(),
381 Err(_) => None,
382 }
383 };
384
385 let mut camera = Camera::new(SCREEN_W as f32, SCREEN_H as f32);
386 camera.smooth_factor = 0.0; #[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
389 let watcher = if opts.watch {
390 ProjectWatcher::new(&watch_dirs(&project))
391 .map_err(|e| log::warn!("hot-reload disabled: {e}"))
392 .ok()
393 } else {
394 None
395 };
396 let mut audio = RunnerAudio::from_files(project.files().as_ref(), project.data_root_rel(), !opts.headless);
397 if opts.pcm_audio {
398 audio.set_pcm_render(true);
399 }
400 let save_path = opts
401 .save_file
402 .clone()
403 .unwrap_or_else(|| project.root().join(DEFAULT_SAVE_FILE));
404
405 let start_money = project
406 .manifest()
407 .shop
408 .as_ref()
409 .map(|s| s.start_money)
410 .unwrap_or(DEFAULT_START_MONEY);
411
412 let mut game = Self {
413 project,
414 map: None,
415 camera,
416 actor: OverworldActor::new(0, 0, 16),
417 player_sprite,
418 flags: HashMap::new(),
419 lang,
420 mode: Mode::Idle,
421 transition: None,
422 pending_scenes: VecDeque::new(),
423 opening_dispatch_pending: false,
424 active_scene: None,
425 last_text: String::new(),
426 flash: 0,
427 frame_count: 0,
428 #[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
429 watcher,
430 #[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
431 watch_pending: HashSet::new(),
432 #[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
433 watch_cooldown: 0,
434 audio,
435 save_path,
436 write_saves: opts.write_saves,
437 rng_script: opts.rng_script.clone(),
438 party_state: None,
439 inventory: None,
440 money: start_money,
441 pending_whiteout: false,
442 pending_weather: None,
443 overworld_rng: None,
444 steps_taken: 0,
445 };
446
447 #[cfg(not(target_arch = "wasm32"))]
452 if !opts.fresh && opts.map.is_none() {
453 if let Some(save) = GameSave::load(&game.save_path) {
454 if game.resume_from(save) {
455 return Ok(game);
456 }
457 }
458 }
459
460 let map_ids = game.project.map_ids();
461 if map_ids.is_empty() {
462 let scene = game.project.entry_scene_name()?.to_string();
464 log::info!("dotzuki-runner: no maps; booting dialogue-only scene '{scene}'");
465 if !game.activate(&scene, "main") {
466 log::warn!("entry scene '{scene}' has no playable 'main' storyline");
467 }
468 return Ok(game);
469 }
470
471 let map_id = match &opts.map {
472 Some(id) => id.clone(),
473 None => game.project.entry_map()?,
474 };
475 game.boot_map(&map_id)
476 .with_context(|| format!("failed to load entry map '{map_id}'"))?;
477 game.dispatch_opening();
478 Ok(game)
479 }
480
481 fn boot_map(&mut self, map_id: &str) -> Result<()> {
483 let map = self.project.load_map(map_id)?;
484 let spawn = find_spawn(&map);
485 let tile = map.tile_size().0 as i32;
486 self.camera.clamp_to_bounds(Rect::new(
487 0.0,
488 0.0,
489 map.pixel_width() as f32,
490 map.pixel_height() as f32,
491 ));
492 self.actor = OverworldActor::new(spawn.0, spawn.1, tile);
493 self.map = Some(map);
494 self.center_camera();
495 self.camera.update(0.0);
496 log::info!("loaded map {map_id} @ ({},{})", spawn.0, spawn.1);
497 Ok(())
498 }
499
500 #[must_use]
504 fn enter_map(&mut self, map_id: &str, spawn: (i32, i32), facing: Direction) -> bool {
505 let map = match self.project.load_map(map_id) {
506 Ok(m) => m,
507 Err(e) => {
508 log::error!("warp to {map_id} aborted: {e:#}");
509 return false;
510 }
511 };
512 let spawn = if map.is_blocked(spawn.0, spawn.1)
513 || map.objects().npcs.iter().any(|n| (n.x, n.y) == spawn)
514 {
515 let fallback = find_spawn(&map);
516 log::warn!(
517 "spawn ({},{}) on {map_id} is occupied; using ({},{})",
518 spawn.0,
519 spawn.1,
520 fallback.0,
521 fallback.1
522 );
523 fallback
524 } else {
525 spawn
526 };
527 self.camera.clamp_to_bounds(Rect::new(
528 0.0,
529 0.0,
530 map.pixel_width() as f32,
531 map.pixel_height() as f32,
532 ));
533 self.map = Some(map);
534 self.actor.place(spawn.0, spawn.1, facing);
535 self.actor.set_elevation(0);
537 self.center_camera();
538 self.camera.update(0.0);
539 log::info!("loaded map {map_id} @ ({},{})", spawn.0, spawn.1);
540 true
541 }
542
543 fn resume_from(&mut self, save: GameSave) -> bool {
555 self.flags.extend(save.flags);
556 if let Some(party) = save.party {
559 self.party_state = Some(
560 party
561 .into_iter()
562 .map(|m| PartyMemberState {
563 id: m.id,
564 hp: m.hp,
565 mp: m.mp,
566 status: m.status,
567 level: m.level,
568 exp: m.exp,
569 })
570 .collect(),
571 );
572 }
573 if save.inventory.is_some() {
574 self.inventory = save.inventory;
575 }
576 if let Some(money) = save.money {
577 self.money = money;
578 }
579 let Some(map_id) = &save.map else {
580 log::info!("save: restored flags (dialogue-only project)");
581 return false;
582 };
583 let map = match self.project.load_map(map_id) {
584 Ok(m) => m,
585 Err(e) => {
586 log::warn!("save: saved map '{map_id}' failed to load ({e:#}) — starting fresh");
587 return false;
588 }
589 };
590 let (x, y) = (save.player.x, save.player.y);
591 let facing = parse_facing(&save.player.facing);
592 let spawn = if map.is_blocked(x, y) || map.objects().npcs.iter().any(|n| (n.x, n.y) == (x, y))
593 {
594 let fallback = find_spawn(&map);
595 log::warn!(
596 "save: position ({x},{y}) on {map_id} is occupied; using ({},{})",
597 fallback.0,
598 fallback.1
599 );
600 fallback
601 } else {
602 (x, y)
603 };
604 self.camera.clamp_to_bounds(Rect::new(
605 0.0,
606 0.0,
607 map.pixel_width() as f32,
608 map.pixel_height() as f32,
609 ));
610 self.map = Some(map);
611 self.actor.place(spawn.0, spawn.1, facing);
612 let max_level = self
614 .map
615 .as_ref()
616 .map(|m| m.level_count() - 1)
617 .unwrap_or(0) as u8;
618 self.actor.set_elevation(save.player.level.min(max_level));
619 self.center_camera();
620 self.camera.update(0.0);
621 self.mode = Mode::Overworld;
622 log::info!("save: resumed {map_id} @ ({},{})", spawn.0, spawn.1);
623 true
624 }
625
626 fn write_save(&self) {
633 if !self.write_saves {
634 return;
635 }
636 self.write_save_now();
637 }
638
639 pub(crate) fn write_save_now(&self) {
646 #[cfg(not(target_arch = "wasm32"))]
647 {
648 let save = self.current_save();
649 match save.write(&self.save_path) {
650 Ok(()) => log::info!("save: wrote {}", self.save_path.display()),
651 Err(e) => {
652 log::warn!("save: write to {} failed: {e:#}", self.save_path.display())
653 }
654 }
655 }
656 #[cfg(target_arch = "wasm32")]
657 {
658 let _ = &self.save_path; }
660 }
661
662 fn current_save(&self) -> GameSave {
665 let (x, y) = self.actor.tile();
666 GameSave {
667 version: SAVE_VERSION,
668 map: self.current_map_id().map(str::to_string),
669 player: PlayerSave {
670 x,
671 y,
672 facing: facing_name(self.actor.facing()).to_string(),
673 level: self.actor.elevation(),
674 },
675 flags: self.flags.clone(),
676 lang: Some(self.lang.clone()),
677 party: self.party_state.as_ref().map(|party| {
678 party
679 .iter()
680 .map(|m| PartyMemberSave {
681 id: m.id.clone(),
682 hp: m.hp,
683 mp: m.mp,
684 status: m.status.clone(),
685 level: m.level,
686 exp: m.exp,
687 })
688 .collect()
689 }),
690 inventory: self.inventory.clone(),
691 money: Some(self.money),
692 }
693 }
694
695 pub fn export_save(&self) -> Option<String> {
701 if self.transition.is_some()
702 || matches!(
703 self.mode,
704 Mode::Text(_) | Mode::Choice(_) | Mode::Delay(_) | Mode::Battle(_) | Mode::Shop(_)
705 )
706 {
707 return None;
708 }
709 Some(self.current_save().to_json())
710 }
711
712 pub fn import_save(&mut self, json: &str) -> bool {
717 let save = match GameSave::from_json(json) {
718 Ok(save) => save,
719 Err(e) => {
720 log::warn!("save: import failed ({e:#}) — keeping current state");
721 return false;
722 }
723 };
724 if save.version > SAVE_VERSION {
725 log::warn!(
726 "save: imported save is version {} (newer than {SAVE_VERSION}) — keeping current state",
727 save.version
728 );
729 return false;
730 }
731 self.resume_from(save)
732 }
733
734 fn dispatch_opening(&mut self) {
739 let Some(map) = &self.map else {
740 return;
741 };
742 let map_id = map.id().to_string();
743
744 let on_enters: Vec<(String, String)> = self
745 .project
746 .routes()
747 .iter()
748 .filter(|r| r.map == map_id && r.on_enter)
749 .filter_map(|r| {
750 self.scene_with_storyline(&map_id, &r.storyline)
751 .map(|scene| (scene, r.storyline.clone()))
752 })
753 .collect();
754 if !on_enters.is_empty() {
755 self.pending_scenes.extend(on_enters);
756 self.pop_pending_scene();
757 return;
758 }
759
760 let Some(scene) = self.scene_for_map(&map_id) else {
761 return;
762 };
763 let onload = format!("{scene}OnLoad");
764 if scene_has_fn(&self.project, &scene, &onload) {
765 self.activate(&scene, &onload);
766 return;
767 }
768 let played_flag = format!("__played_main_{map_id}");
769 if !self.flags.get(&played_flag).copied().unwrap_or(false)
770 && scene_has_fn(&self.project, &scene, "main")
771 {
772 self.flags.insert(played_flag, true);
774 self.activate(&scene, "main");
775 }
776 }
777
778 fn scene_for_map(&self, map_id: &str) -> Option<String> {
781 for (name, _js, source_path) in &self.project.report().scenes {
782 let path = Path::new(source_path);
783 if path.file_stem().is_some_and(|s| s == "script")
784 && path
785 .parent()
786 .and_then(Path::file_name)
787 .is_some_and(|d| d == map_id)
788 {
789 return Some(name.clone());
790 }
791 }
792 if self.project.scripts().has_script(map_id) {
793 return Some(map_id.to_string());
794 }
795 None
796 }
797
798 fn scene_with_storyline(&self, map_id: &str, storyline: &str) -> Option<String> {
801 if let Some(scene) = self.scene_for_map(map_id) {
802 if scene_has_fn(&self.project, &scene, storyline) {
803 return Some(scene);
804 }
805 }
806 self.project
807 .report()
808 .scenes
809 .iter()
810 .map(|(name, _, _)| name)
811 .find(|name| scene_has_fn(&self.project, name, storyline))
812 .cloned()
813 }
814
815 fn activate(&mut self, scene: &str, storyline: &str) -> bool {
819 let Some(js) = self.project.scripts().get_script(scene) else {
820 log::warn!("scene '{scene}' not registered");
821 return false;
822 };
823 let js = js.to_string();
824 let mut engine = ScriptEngine::new();
825 engine.register_async_fn("startBattle", |args, ctx| {
829 let trainer_id = match args.first() {
830 Some(v) => v.to_string(ctx)?.to_std_string_lossy(),
831 None => String::new(),
832 };
833 Ok(ScriptCommand::StartBattle { trainer_id })
834 });
835 engine.register_async_fn("startWildBattle", |args, ctx| {
836 let species = match args.first() {
837 Some(v) => v.to_string(ctx)?.to_std_string_lossy(),
838 None => String::new(),
839 };
840 let level = match args.get(1) {
841 Some(v) => v.to_number(ctx)? as u8,
842 None => 1,
843 };
844 Ok(ScriptCommand::StartWildBattle { species, level })
845 });
846 engine.register_async_fn("setWeather", |args, ctx| {
850 let weather = match args.first() {
851 Some(v) => v.to_string(ctx)?.to_std_string_lossy(),
852 None => String::new(),
853 };
854 Ok(ScriptCommand::SetWeather {
855 weather: Some(weather),
856 })
857 });
858 engine.register_async_fn("clearWeather", |_args, _ctx| {
859 Ok(ScriptCommand::SetWeather { weather: None })
860 });
861 engine.seed_flags(&self.flags);
862 engine.set_lang(&self.lang);
863 let (px, py) = self.actor.tile();
864 engine.set_player_position(px.clamp(0, 255) as u8, py.clamp(0, 255) as u8);
865 if let Err(e) = engine.load_script(&js) {
866 log::warn!("load scene '{scene}': {e}");
867 return false;
868 }
869 if !engine.has_function(storyline) {
870 log::warn!("scene '{scene}' has no function '{storyline}'");
871 return false;
872 }
873 log::info!("activate {scene}::{storyline}");
874 match engine.call_function_no_args(storyline) {
875 Ok(cmd) => {
876 self.active_scene = Some(scene.to_string());
877 self.pump(engine, cmd, true);
878 true
879 }
880 Err(e) => {
881 log::warn!("run {scene}::{storyline}: {e}");
882 false
883 }
884 }
885 }
886
887 fn pop_pending_scene(&mut self) -> bool {
890 while let Some((scene, storyline)) = self.pending_scenes.pop_front() {
891 if self.activate(&scene, &storyline) {
892 return true;
893 }
894 }
895 false
896 }
897
898 fn pump(&mut self, mut engine: ScriptEngine, mut cmd: Option<ScriptCommand>, first: bool) {
905 let scene = self.active_scene.clone().unwrap_or_default();
906 let mut first = first;
907 loop {
908 match cmd {
909 Some(ScriptCommand::ShowText { text }) => {
910 self.last_text = text.clone();
911 self.mode = Mode::Text(TextState {
912 engine: Some(engine),
913 pages: paginate(&text),
914 });
915 return;
916 }
917 Some(ScriptCommand::ShowChoice { options }) => {
918 self.mode = Mode::Choice(ChoiceState {
919 engine,
920 options,
921 cursor: 0,
922 context_text: self.last_text.clone(),
923 });
924 return;
925 }
926 Some(ScriptCommand::Delay { frames }) => {
927 self.mode = Mode::Delay(DelayState {
928 engine,
929 frames_left: frames,
930 });
931 return;
932 }
933 Some(ScriptCommand::WarpTo { map, x, y }) => {
934 log::info!("scene warp → {map} ({x},{y})");
935 if self.enter_map(&map, (x as i32, y as i32), Direction::Down) {
938 self.opening_dispatch_pending = true;
939 }
940 cmd = self.signal(&mut engine, CommandResult::Void);
941 }
942 Some(ScriptCommand::FadeScreen { fade_type }) => {
943 log::info!("fadeScreen({fade_type}) (cosmetic in dotzuki run v1)");
944 self.flash = FLASH_FRAMES;
945 cmd = self.signal(&mut engine, CommandResult::Void);
946 }
947 Some(ScriptCommand::SetFlag { flag }) => {
948 engine.set_flag(&flag, true);
949 self.flags.insert(flag, true);
950 cmd = self.signal(&mut engine, CommandResult::Void);
951 }
952 Some(ScriptCommand::ResetFlag { flag }) => {
953 engine.set_flag(&flag, false);
954 self.flags.insert(flag, false);
955 cmd = self.signal(&mut engine, CommandResult::Void);
956 }
957 Some(ScriptCommand::CheckFlag { flag }) => {
958 let value = self.flags.get(&flag).copied().unwrap_or(false);
959 cmd = self.signal(&mut engine, CommandResult::Bool(value));
960 }
961 Some(ScriptCommand::PlayMusic { music_id }) => {
962 self.audio.play_music(&music_id);
963 cmd = self.signal(&mut engine, CommandResult::Void);
964 }
965 Some(ScriptCommand::PlaySound { sound_id }) => {
966 self.audio.play_sound(&sound_id);
967 cmd = self.signal(&mut engine, CommandResult::Void);
968 }
969 Some(ScriptCommand::StopMusic) => {
970 self.audio.stop_music();
971 cmd = self.signal(&mut engine, CommandResult::Void);
972 }
973 Some(ScriptCommand::FadeOutMusic) => {
974 self.audio.fade_out_music();
975 cmd = self.signal(&mut engine, CommandResult::Void);
976 }
977 Some(ScriptCommand::StartBattle { trainer_id }) => {
978 log::info!("scene → battle: startBattle({trainer_id})");
979 match self.build_battle(&trainer_id) {
980 Some(battle) => {
981 self.mode = Mode::Battle(Box::new(BattleState { engine: Some(engine), battle }));
984 return;
985 }
986 None => {
987 cmd = self.signal(&mut engine, CommandResult::Text("win".to_string()));
988 }
989 }
990 }
991 Some(ScriptCommand::StartWildBattle { species, level }) => {
992 log::info!("scene → battle: startWildBattle({species}, lv{level}) — level ignored in v1");
993 match self.build_battle(&species) {
994 Some(battle) => {
995 self.mode = Mode::Battle(Box::new(BattleState { engine: Some(engine), battle }));
996 return;
997 }
998 None => {
999 cmd = self.signal(&mut engine, CommandResult::Text("win".to_string()));
1000 }
1001 }
1002 }
1003 Some(ScriptCommand::SetWeather { weather }) => {
1004 log::info!("scene → weather: {weather:?} (applies to the next battle)");
1005 self.pending_weather = weather;
1006 cmd = self.signal(&mut engine, CommandResult::Void);
1007 }
1008 Some(ScriptCommand::OpenShop { items }) => {
1009 log::info!("scene → shop: openShop({items:?})");
1010 let items = self.build_shop_items(&items);
1013 self.mode = Mode::Shop(Box::new(ShopState::new(engine, items)));
1014 return;
1015 }
1016 Some(other) => {
1017 log::warn!(
1019 "unhandled scene command {other:?} in scene '{scene}' — \
1020 auto-completing with Void"
1021 );
1022 cmd = self.signal(&mut engine, CommandResult::Void);
1023 }
1024 None => {
1025 if first {
1026 log::warn!(
1027 "scene '{scene}' finished without producing any command — \
1028 did it call an unregistered game.* function?"
1029 );
1030 }
1031 self.finish_scene(engine);
1032 return;
1033 }
1034 }
1035 first = false;
1036 }
1037 }
1038
1039 fn signal(&mut self, engine: &mut ScriptEngine, result: CommandResult) -> Option<ScriptCommand> {
1041 match engine.signal_done(result) {
1042 Ok(cmd) => cmd,
1043 Err(e) => {
1044 let scene = self.active_scene.clone().unwrap_or_default();
1045 log::warn!("scene '{scene}' signal failed: {e}");
1046 None
1047 }
1048 }
1049 }
1050
1051 fn build_battle(&mut self, enemy_id: &str) -> Option<Battle> {
1057 if self.project.manifest().battle.is_none() {
1058 log::warn!(
1059 "startBattle({enemy_id}): project has no battle section — \
1060 auto-completing with \"win\""
1061 );
1062 return None;
1063 }
1064 let rng: Box<dyn BattleRng> = match &self.rng_script {
1065 Some(bytes) => Box::new(ScriptedRng::new(bytes.clone())),
1066 None => {
1067 #[cfg(not(target_arch = "wasm32"))]
1070 let seed = {
1071 let nanos = std::time::SystemTime::now()
1072 .duration_since(std::time::UNIX_EPOCH)
1073 .map(|d| d.subsec_nanos() as u64)
1074 .unwrap_or(0);
1075 nanos ^ self.frame_count
1076 };
1077 #[cfg(target_arch = "wasm32")]
1078 let seed = self
1079 .frame_count
1080 .wrapping_mul(0x9E37_79B9_7F4A_7C15)
1081 ^ 0xA076_1D64_78BD_642F;
1082 Box::new(XorshiftRng::seed(seed))
1083 }
1084 };
1085 match BattleSetup::from_project(&self.project).and_then(|setup| {
1086 setup.start_with(
1087 enemy_id,
1088 rng,
1089 self.party_state.as_deref(),
1090 self.inventory.as_ref(),
1091 )
1092 }) {
1093 Ok(mut battle) => {
1094 battle.set_lang(&self.lang);
1095 battle.set_currency(self.currency());
1096 battle.set_weather(self.pending_weather.clone());
1101 battle.begin();
1102 Some(battle)
1103 }
1104 Err(e) => {
1105 log::error!("startBattle({enemy_id}): battle setup failed: {e:#}");
1106 None
1107 }
1108 }
1109 }
1110
1111 fn finish_scene(&mut self, engine: ScriptEngine) {
1117 self.flags.extend(engine.get_all_flags());
1118 drop(engine);
1119 self.active_scene = None;
1120 self.mode = if self.map.is_some() {
1121 Mode::Overworld
1122 } else {
1123 Mode::Idle
1124 };
1125 if self.pending_whiteout {
1126 self.pending_whiteout = false;
1127 self.pending_scenes.clear();
1128 self.opening_dispatch_pending = false;
1129 self.start_whiteout();
1130 return;
1131 }
1132 if self.pop_pending_scene() {
1133 return;
1134 }
1135 if self.opening_dispatch_pending {
1136 self.opening_dispatch_pending = false;
1137 self.dispatch_opening();
1138 }
1139 if matches!(self.mode, Mode::Overworld | Mode::Idle) {
1142 self.write_save();
1143 }
1144 }
1145
1146 #[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
1154 pub fn poll_watch(&mut self) {
1155 let Some(watcher) = &mut self.watcher else {
1156 return;
1157 };
1158 self.watch_pending.extend(watcher.poll_events());
1159 if self.watch_cooldown > 0 {
1160 self.watch_cooldown -= 1;
1161 return;
1162 }
1163 if self.watch_pending.is_empty() {
1164 return;
1165 }
1166 let paths: Vec<PathBuf> = self.watch_pending.drain().collect();
1167 self.watch_cooldown = WATCH_DEBOUNCE_FRAMES;
1168 self.apply_watch_batch(paths);
1169 }
1170
1171 #[cfg(any(not(feature = "watch"), target_arch = "wasm32"))]
1174 pub fn poll_watch(&mut self) {}
1175
1176 #[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
1184 fn apply_watch_batch(&mut self, paths: Vec<PathBuf>) {
1185 let map_dir = self
1186 .map
1187 .as_ref()
1188 .map(|m| crate::map::map_dir(&self.project.maps_dir(), m.id()));
1189 let mut scenes_changed = false;
1190 let mut map_changed = false;
1191 for path in &paths {
1192 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
1193 if ext == "scene" {
1194 scenes_changed = true;
1195 } else if map_dir
1196 .as_ref()
1197 .is_some_and(|d| path.parent() == Some(d.as_path()))
1198 {
1199 map_changed = true;
1200 }
1201 }
1202 if scenes_changed {
1203 self.reload_scenes();
1204 }
1205 if map_changed {
1206 self.reload_current_map();
1207 }
1208 }
1209
1210 pub fn reload_scenes(&mut self) -> bool {
1215 match self.project.recompile_scripts() {
1216 Ok(()) => {
1217 log::info!("hot-reload: scenes recompiled");
1218 true
1219 }
1220 Err(e) => {
1221 log::warn!("hot-reload: scene reload failed, keeping old scenes: {e:#}");
1222 false
1223 }
1224 }
1225 }
1226
1227 pub fn reload_current_map(&mut self) -> bool {
1231 let Some(map) = &self.map else {
1232 return false;
1233 };
1234 let map_id = map.id().to_string();
1235 match self.project.load_map(&map_id) {
1236 Ok(new_map) => {
1237 self.camera.clamp_to_bounds(Rect::new(
1238 0.0,
1239 0.0,
1240 new_map.pixel_width() as f32,
1241 new_map.pixel_height() as f32,
1242 ));
1243 self.map = Some(new_map);
1244 self.center_camera();
1245 self.camera.update(0.0);
1246 log::info!("hot-reload: reloaded map '{map_id}'");
1247 true
1248 }
1249 Err(e) => {
1250 log::warn!("hot-reload: map '{map_id}' reload failed, keeping old map: {e:#}");
1251 false
1252 }
1253 }
1254 }
1255
1256 pub fn update(&mut self, input: &InputState) {
1261 self.frame_count += 1;
1262 self.poll_watch();
1263 self.audio.update_frame();
1264 if self.flash > 0 {
1265 self.flash -= 1;
1266 }
1267
1268 if self.transition.is_some() {
1270 self.update_transition();
1271 return;
1272 }
1273
1274 match std::mem::replace(&mut self.mode, Mode::Overworld) {
1275 Mode::Text(mut state) => {
1276 if input.is_just_pressed(GbButton::A) {
1277 state.pages.pop_front();
1278 if state.pages.is_empty() {
1279 match state.engine {
1280 Some(mut engine) => {
1281 let cmd = self.signal(&mut engine, CommandResult::Void);
1282 self.pump(engine, cmd, false);
1283 }
1284 None => {
1285 self.mode = if self.map.is_some() {
1287 Mode::Overworld
1288 } else {
1289 Mode::Idle
1290 };
1291 }
1292 }
1293 } else {
1294 self.mode = Mode::Text(state);
1295 }
1296 } else {
1297 self.mode = Mode::Text(state);
1298 }
1299 }
1300 Mode::Choice(mut state) => {
1301 let n = state.options.len().max(1);
1302 if input.is_just_pressed(GbButton::Up) {
1303 state.cursor = (state.cursor + n - 1) % n;
1304 } else if input.is_just_pressed(GbButton::Down) {
1305 state.cursor = (state.cursor + 1) % n;
1306 }
1307 if input.is_just_pressed(GbButton::A) {
1308 let cursor = state.cursor;
1309 let mut engine = state.engine;
1310 log::info!("choice picked: {cursor}");
1311 let cmd = self.signal(&mut engine, CommandResult::Number(cursor as f64));
1312 self.pump(engine, cmd, false);
1313 } else {
1314 self.mode = Mode::Choice(state);
1315 }
1316 }
1317 Mode::Delay(mut state) => {
1318 if state.frames_left > 0 {
1319 state.frames_left -= 1;
1320 }
1321 if state.frames_left == 0 {
1322 let mut engine = state.engine;
1323 let cmd = self.signal(&mut engine, CommandResult::Void);
1324 self.pump(engine, cmd, false);
1325 } else {
1326 self.mode = Mode::Delay(state);
1327 }
1328 }
1329 Mode::Battle(mut state) => {
1330 state.battle.update(input);
1331 if let Some(outcome) = state.battle.outcome() {
1332 self.party_state = Some(state.battle.party_state());
1336 self.inventory = Some(state.battle.inventory().clone());
1337 let result = match outcome {
1338 BattleOutcome::Win => "win",
1339 BattleOutcome::Lose => "lose",
1340 BattleOutcome::Run => "run",
1341 };
1342 if outcome == BattleOutcome::Win {
1344 self.money = self.money.saturating_add(state.battle.trainer_money());
1345 }
1346 self.pending_whiteout = outcome == BattleOutcome::Lose;
1351 self.pending_weather = None;
1354 log::info!("battle ended: {result}");
1355 match state.engine {
1356 Some(mut engine) => {
1360 let cmd =
1361 self.signal(&mut engine, CommandResult::Text(result.to_string()));
1362 self.pump(engine, cmd, false);
1363 }
1364 None => {
1369 if outcome == BattleOutcome::Lose {
1370 self.pending_whiteout = false;
1371 self.start_whiteout();
1372 } else {
1373 self.mode = Mode::Overworld;
1374 }
1375 }
1376 }
1377 } else {
1378 self.mode = Mode::Battle(state);
1379 }
1380 }
1381 Mode::Overworld => self.update_overworld(input),
1382 Mode::Menu(state) => self.update_menu(state, input),
1383 Mode::Shop(state) => self.update_shop(*state, input),
1384 Mode::Whiteout(state) => self.update_whiteout(state, input),
1385 Mode::Idle => {
1389 self.mode = Mode::Idle;
1390 }
1391 }
1392
1393 self.center_camera();
1394 self.camera.update(0.0);
1395 }
1396
1397 fn update_transition(&mut self) {
1399 let Some(t) = &mut self.transition else {
1400 return;
1401 };
1402 if t.frames > 0 {
1403 t.frames -= 1;
1404 }
1405 if t.frames > 0 {
1406 return;
1407 }
1408 match t.phase {
1409 FadePhase::Out => {
1410 let (dest_map, dest) = (t.dest_map.clone(), (t.dest_x, t.dest_y));
1411 let facing = self.actor.facing();
1412 if self.enter_map(&dest_map, dest, facing) {
1413 if let Some(t) = &mut self.transition {
1414 t.phase = FadePhase::In;
1415 t.frames = FADE_FRAMES;
1416 }
1417 } else {
1418 self.transition = None;
1420 }
1421 }
1422 FadePhase::In => {
1423 self.transition = None;
1424 self.write_save();
1427 self.dispatch_opening();
1428 }
1429 }
1430 }
1431
1432 fn update_overworld(&mut self, input: &InputState) {
1435 if self.map.is_none() {
1436 return;
1437 }
1438 if input.is_just_pressed(GbButton::Start) {
1439 self.mode = Mode::Menu(MenuState::new());
1440 return;
1441 }
1442 if input.is_just_pressed(GbButton::A) {
1443 if let Some(npc_index) = self.faced_npc_index() {
1444 self.talk_to(npc_index);
1445 return;
1446 }
1447 if let Some(sign_index) = self.faced_sign_index() {
1448 self.read_sign(sign_index);
1449 return;
1450 }
1451 }
1452
1453 let held = held_direction(input);
1454 self.actor.set_running(input.is_held(GbButton::B));
1455 let step = {
1456 let map = self.map.as_ref().expect("checked above");
1457 let view = CollisionView { map };
1458 self.actor.update(held, &view)
1459 };
1460 if let Some((tx, ty)) = step {
1461 self.steps_taken += 1;
1462 self.apply_stairs(tx, ty);
1464 let warp = {
1466 let map = self.map.as_ref().expect("checked above");
1467 map.objects()
1468 .warps
1469 .iter()
1470 .find(|w| (w.x, w.y) == (tx, ty) && !w.dest_map.is_empty())
1471 .map(|w| (w.dest_map.clone(), w.dest_x, w.dest_y))
1472 };
1473 if let Some((dest_map, dest_x, dest_y)) = warp {
1474 self.transition = Some(WarpTransition::new(dest_map, dest_x, dest_y));
1475 } else {
1476 self.roll_encounter(tx, ty);
1477 }
1478 }
1479 }
1480
1481 fn roll_encounter(&mut self, tx: i32, ty: i32) {
1488 let (rate, table) = {
1489 let map = self.map.as_ref().expect("roll_encounter requires a map");
1490 let Some(encounters) = &map.objects().encounters else {
1491 return;
1492 };
1493 match encounters.zones.iter().find(|z| z.contains(tx, ty)) {
1494 Some(zone) => (encounters.rate, zone.table.clone()),
1495 None => return,
1496 }
1497 };
1498 if rate == 0 || table.is_empty() {
1499 return;
1500 }
1501 if self.overworld_rng_byte() >= rate {
1502 return;
1503 }
1504 let total: u32 = table.iter().map(|e| e.weight).sum();
1505 if total == 0 {
1506 return;
1507 }
1508 let mut pick = u32::from(self.overworld_rng_byte()) % total;
1509 let id = table
1510 .iter()
1511 .find(|e| {
1512 if pick < e.weight {
1513 true
1514 } else {
1515 pick -= e.weight;
1516 false
1517 }
1518 })
1519 .map(|e| e.id.clone());
1520 if let Some(id) = id {
1521 self.start_overworld_battle(&id);
1522 }
1523 }
1524
1525 fn overworld_rng_byte(&mut self) -> u8 {
1532 if self.overworld_rng.is_none() {
1533 let rng: Box<dyn BattleRng> = match &self.rng_script {
1534 Some(bytes) => Box::new(ScriptedRng::new(bytes.clone())),
1535 None => {
1536 #[cfg(not(target_arch = "wasm32"))]
1537 let seed = {
1538 let nanos = std::time::SystemTime::now()
1539 .duration_since(std::time::UNIX_EPOCH)
1540 .map(|d| d.subsec_nanos() as u64)
1541 .unwrap_or(0);
1542 nanos ^ self.frame_count
1543 };
1544 #[cfg(target_arch = "wasm32")]
1545 let seed = self
1546 .frame_count
1547 .wrapping_add(self.steps_taken)
1548 .wrapping_mul(0x9E37_79B9_7F4A_7C15)
1549 ^ 0xA076_1D64_78BD_642F;
1550 Box::new(XorshiftRng::seed(seed))
1551 }
1552 };
1553 self.overworld_rng = Some(rng);
1554 }
1555 self.overworld_rng
1556 .as_deref_mut()
1557 .expect("seeded above")
1558 .byte()
1559 }
1560
1561 fn start_overworld_battle(&mut self, id: &str) {
1568 log::info!("random encounter: {id}");
1569 if let Some(battle) = self.build_battle(id) {
1570 self.mode = Mode::Battle(Box::new(BattleState { engine: None, battle }));
1571 }
1572 }
1573
1574 fn apply_stairs(&mut self, x: i32, y: i32) {
1577 let Some(map) = &self.map else {
1578 return;
1579 };
1580 let level = self.actor.elevation();
1581 let next = match map.stair_at(x, y) {
1582 Some(1) => (level as usize + 1).min(map.level_count() - 1) as u8,
1583 Some(2) => level.saturating_sub(1),
1584 _ => return,
1585 };
1586 if next != level {
1587 self.actor.set_elevation(next);
1588 }
1589 }
1590
1591 fn faced_npc_index(&self) -> Option<usize> {
1593 let map = self.map.as_ref()?;
1594 let (dx, dy) = direction_delta(self.actor.facing());
1595 let (tx, ty) = self.actor.tile();
1596 let faced = (tx + dx, ty + dy);
1597 map.objects()
1598 .npcs
1599 .iter()
1600 .position(|n| (n.x, n.y) == faced)
1601 }
1602
1603 fn faced_sign_index(&self) -> Option<usize> {
1605 let map = self.map.as_ref()?;
1606 let (dx, dy) = direction_delta(self.actor.facing());
1607 let (tx, ty) = self.actor.tile();
1608 let faced = (tx + dx, ty + dy);
1609 map.objects()
1610 .signs
1611 .iter()
1612 .position(|s| (s.x, s.y) == faced)
1613 }
1614
1615 fn read_sign(&mut self, index: usize) {
1618 let map = self.map.as_ref().expect("read_sign requires a map");
1619 let text = map.objects().signs[index].text.clone();
1620 if text.is_empty() {
1621 return;
1622 }
1623 self.last_text = text.clone();
1624 self.mode = Mode::Text(TextState {
1625 engine: None,
1626 pages: paginate(&text),
1627 });
1628 }
1629
1630 fn talk_to(&mut self, index: usize) {
1633 let (map_id, npc_name, npc_id, talk) = {
1634 let map = self.map.as_ref().expect("talk requires a map");
1635 let npc = &map.objects().npcs[index];
1636 (
1637 map.id().to_string(),
1638 npc.name.clone(),
1639 npc.id,
1640 npc.talk.clone(),
1641 )
1642 };
1643
1644 if !talk.is_empty() {
1646 if let Some(scene) = self.scene_with_storyline(&map_id, &talk) {
1647 if self.activate(&scene, &talk) {
1648 return;
1649 }
1650 }
1651 }
1652
1653 let route = self
1655 .project
1656 .routes()
1657 .iter()
1658 .filter(|r| r.map == map_id && !r.on_enter)
1659 .find(|r| {
1660 r.npc.as_deref().is_some_and(|n| {
1661 (!npc_name.is_empty() && n == npc_name) || n == npc_id.to_string()
1662 })
1663 })
1664 .map(|r| r.storyline.clone());
1665 if let Some(storyline) = route {
1666 if let Some(scene) = self.scene_with_storyline(&map_id, &storyline) {
1667 if self.activate(&scene, &storyline) {
1668 return;
1669 }
1670 }
1671 }
1672
1673 if let Some(scene) = self.scene_for_map(&map_id) {
1675 if scene_has_fn(&self.project, &scene, "main") && self.activate(&scene, "main") {
1676 return;
1677 }
1678 }
1679
1680 if !talk.is_empty() {
1682 self.last_text = talk.clone();
1683 self.mode = Mode::Text(TextState {
1684 engine: None,
1685 pages: paginate(&talk),
1686 });
1687 }
1688 }
1689
1690 pub fn draw(&mut self, fb: &mut FrameBuffer) {
1695 if let Some(map) = &self.map {
1696 fb.clear(Rgba::BLACK);
1697 let cam_x = self.camera.position.x.round() as i32;
1698 let cam_y = self.camera.position.y.round() as i32;
1699 let level = self.actor.elevation() as i32;
1700 if let Err(e) = map.render_below(fb, cam_x, cam_y, SCREEN_W as u32, SCREEN_H as u32, level)
1703 {
1704 log::warn!("map render: {e:#}");
1705 }
1706 self.draw_npcs(fb, cam_x, cam_y);
1707 self.draw_player(fb, cam_x, cam_y);
1708 if let Err(e) = map.render_above(fb, cam_x, cam_y, SCREEN_W as u32, SCREEN_H as u32, level)
1709 {
1710 log::warn!("map render: {e:#}");
1711 }
1712 } else {
1713 fb.clear(Rgba::rgb(0x10, 0x10, 0x18));
1715 }
1716
1717 match &self.mode {
1718 Mode::Text(state) => {
1719 if let Some(page) = state.pages.front() {
1720 draw_textbox(fb, page);
1721 }
1722 }
1723 Mode::Choice(state) => {
1724 if !state.context_text.is_empty() {
1725 draw_textbox(fb, &state.context_text);
1726 }
1727 draw_choice_menu(fb, &state.options, state.cursor);
1728 }
1729 Mode::Battle(state) => state.battle.draw(fb),
1730 Mode::Menu(state) => self.draw_menu(fb, state),
1731 Mode::Shop(state) => self.draw_shop(fb, state),
1732 Mode::Whiteout(state) => {
1733 if let Some(page) = state.pages.front() {
1734 draw_textbox(fb, page);
1735 }
1736 }
1737 Mode::Idle if self.map.is_none() => {
1738 draw_end_card(fb, &self.project.manifest().name, &self.lang);
1741 }
1742 _ => {}
1743 }
1744
1745 let darkness = match &self.transition {
1747 Some(t) => match t.phase {
1748 FadePhase::Out => 1.0 - t.frames as f32 / FADE_FRAMES as f32,
1749 FadePhase::In => t.frames as f32 / FADE_FRAMES as f32,
1750 },
1751 None if self.flash > 0 => 0.5 * self.flash as f32 / FLASH_FRAMES as f32,
1752 None => 0.0,
1753 };
1754 if darkness > 0.0 {
1755 darken(fb, 1.0 - darkness.clamp(0.0, 1.0));
1756 }
1757
1758 if let Mode::Whiteout(state) = &self.mode {
1760 if state.blackout > 0 {
1761 fb.fill_rect(0, 0, SCREEN_W as u32, SCREEN_H as u32, Rgba::BLACK);
1762 }
1763 }
1764 }
1765
1766 fn draw_npcs(&self, fb: &mut FrameBuffer, cam_x: i32, cam_y: i32) {
1769 let Some(map) = &self.map else {
1770 return;
1771 };
1772 let tile = map.tile_size().0 as i32;
1773 for npc in &map.objects().npcs {
1774 let facing = parse_facing(&npc.facing);
1775 let colors = npc_palette(npc.id);
1776 draw_person(
1777 fb,
1778 npc.x * tile - cam_x,
1779 npc.y * tile - cam_y,
1780 tile,
1781 facing,
1782 &colors,
1783 );
1784 }
1785 }
1786
1787 fn draw_player(&self, fb: &mut FrameBuffer, cam_x: i32, cam_y: i32) {
1788 let tile = self.map.as_ref().map(|m| m.tile_size().0 as i32).unwrap_or(16);
1789 let foot_x = self.actor.px().round() as i32 - cam_x;
1790 let foot_y = self.actor.py().round() as i32 - cam_y;
1791 if let Some(sprite) = &self.player_sprite {
1792 let col = frame_col(
1793 self.actor.locomotion(),
1794 self.actor.step_phase(),
1795 sprite.cols,
1796 );
1797 sprite.draw_on_tile(fb, self.actor.facing_row(), col, foot_x, foot_y, tile);
1798 return;
1799 }
1800 draw_person(fb, foot_x, foot_y, tile, self.actor.facing(), &PLAYER_COLORS);
1801 }
1802
1803 fn center_camera(&mut self) {
1804 let tile = self.map.as_ref().map(|m| m.tile_size().0 as i32).unwrap_or(16);
1805 let cx = self.actor.px() + (tile / 2) as f32;
1806 let cy = self.actor.py() + (tile / 2) as f32;
1807 self.camera.follow_target(Vec2::new(
1808 cx - SCREEN_W as f32 / 2.0,
1809 cy - SCREEN_H as f32 / 2.0,
1810 ));
1811 }
1812
1813 pub fn current_map_id(&self) -> Option<&str> {
1817 self.map.as_ref().map(RuntimeMap::id)
1818 }
1819
1820 pub fn flag(&self, name: &str) -> bool {
1822 self.flags.get(name).copied().unwrap_or(false)
1823 }
1824
1825 pub fn dialogue_text(&self) -> Option<&str> {
1828 match &self.mode {
1829 Mode::Text(state) => state.pages.front().map(String::as_str),
1830 Mode::Whiteout(state) if state.blackout == 0 => {
1831 state.pages.front().map(String::as_str)
1832 }
1833 _ => None,
1834 }
1835 }
1836
1837 pub fn choice_options(&self) -> Option<&[String]> {
1839 match &self.mode {
1840 Mode::Choice(state) => Some(&state.options),
1841 _ => None,
1842 }
1843 }
1844
1845 pub fn battle(&self) -> Option<&Battle> {
1847 match &self.mode {
1848 Mode::Battle(state) => Some(&state.battle),
1849 _ => None,
1850 }
1851 }
1852
1853 pub fn party_state(&self) -> Option<&[PartyMemberState]> {
1856 self.party_state.as_deref()
1857 }
1858
1859 pub fn inventory(&self) -> Option<&HashMap<String, u32>> {
1862 self.inventory.as_ref()
1863 }
1864
1865 pub fn money(&self) -> u32 {
1867 self.money
1868 }
1869
1870 pub fn menu_lines(&self) -> Option<Vec<String>> {
1874 match &self.mode {
1875 Mode::Menu(state) => Some(self.menu_lines_for(state)),
1876 _ => None,
1877 }
1878 }
1879
1880 pub fn shop_lines(&self) -> Option<Vec<String>> {
1884 match &self.mode {
1885 Mode::Shop(state) => Some(self.shop_lines_for(state)),
1886 _ => None,
1887 }
1888 }
1889
1890 pub fn whiteout_active(&self) -> bool {
1893 matches!(self.mode, Mode::Whiteout(_))
1894 }
1895
1896 pub fn player_tile(&self) -> (i32, i32) {
1898 self.actor.tile()
1899 }
1900
1901 pub fn player_elevation(&self) -> u8 {
1904 self.actor.elevation()
1905 }
1906
1907 pub fn is_blocked(&self, x: i32, y: i32) -> bool {
1910 match &self.map {
1911 Some(map) => map.is_blocked(x, y),
1912 None => true,
1913 }
1914 }
1915
1916 pub fn audio(&self) -> &RunnerAudio {
1918 &self.audio
1919 }
1920
1921 pub fn render_audio(&mut self, frames: usize) -> Vec<f32> {
1927 self.audio.render_samples(frames)
1928 }
1929
1930 pub fn debug_place(&mut self, x: i32, y: i32, facing: Direction) {
1932 self.actor.place(x, y, facing);
1933 self.center_camera();
1934 self.camera.update(0.0);
1935 }
1936
1937 pub fn frame_count(&self) -> u64 {
1939 self.frame_count
1940 }
1941}
1942
1943#[cfg(not(target_arch = "wasm32"))]
1944impl dotzuki_app::GameLoop for RunnerGame {
1945 type Fb = FrameBuffer;
1946
1947 fn update(&mut self, input: &InputState) {
1948 RunnerGame::update(self, input);
1949 }
1950
1951 fn draw(&mut self, frame_buffer: &mut FrameBuffer) {
1952 RunnerGame::draw(self, frame_buffer);
1953 }
1954}
1955
1956fn decode_walk_sheet(
1961 bytes: &[u8],
1962 path: &str,
1963 frame_w: u32,
1964 frame_h: u32,
1965) -> Result<WalkSprite, String> {
1966 let img = image::load_from_memory(bytes)
1967 .map_err(|e| format!("decode {path}: {e}"))?
1968 .to_rgba8();
1969 let (w, h) = img.dimensions();
1970 let pixels = img
1971 .pixels()
1972 .map(|p| Rgba::new(p.0[0], p.0[1], p.0[2], p.0[3]))
1973 .collect();
1974 WalkSprite::from_rgba(pixels, w, h, frame_w, frame_h)
1975}
1976
1977fn scene_has_fn(project: &LoadedProject, scene: &str, fn_name: &str) -> bool {
1982 let Some(js) = project.scripts().get_script(scene) else {
1983 return false;
1984 };
1985 js.contains(&format!("storyline_{fn_name}")) || js.contains(&format!("function {fn_name}"))
1986}
1987
1988#[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
1991fn watch_dirs(project: &LoadedProject) -> Vec<PathBuf> {
1992 let mut dirs = project.manifest().dsl_dirs(project.root());
1993 dirs.push(project.data_root().to_path_buf());
1994 if let Some(gfx) = project.gfx_root() {
1995 dirs.push(gfx.to_path_buf());
1996 }
1997 let mut seen = HashSet::new();
1998 dirs.retain(|d| seen.insert(d.clone()));
1999 dirs
2000}
2001
2002fn find_spawn(map: &RuntimeMap) -> (i32, i32) {
2005 let (cx, cy) = (map.width() as i32 / 2, map.height() as i32 / 2);
2006 let free = |x: i32, y: i32| {
2007 !map.is_blocked(x, y) && !map.objects().npcs.iter().any(|n| (n.x, n.y) == (x, y))
2008 };
2009 let max_r = (map.width().max(map.height()) as i32) + 1;
2010 for r in 0..max_r {
2011 for dy in -r..=r {
2012 for dx in -r..=r {
2013 if dx.abs().max(dy.abs()) != r {
2014 continue; }
2016 let (x, y) = (cx + dx, cy + dy);
2017 if free(x, y) {
2018 return (x, y);
2019 }
2020 }
2021 }
2022 }
2023 (cx, cy)
2024}
2025
2026fn held_direction(input: &InputState) -> Option<Direction> {
2028 if input.is_held(GbButton::Up) {
2029 Some(Direction::Up)
2030 } else if input.is_held(GbButton::Down) {
2031 Some(Direction::Down)
2032 } else if input.is_held(GbButton::Left) {
2033 Some(Direction::Left)
2034 } else if input.is_held(GbButton::Right) {
2035 Some(Direction::Right)
2036 } else {
2037 None
2038 }
2039}
2040
2041fn direction_delta(dir: Direction) -> (i32, i32) {
2043 match dir {
2044 Direction::Down => (0, 1),
2045 Direction::Up => (0, -1),
2046 Direction::Left => (-1, 0),
2047 Direction::Right => (1, 0),
2048 }
2049}
2050
2051fn parse_facing(facing: &str) -> Direction {
2053 match facing {
2054 "up" => Direction::Up,
2055 "left" => Direction::Left,
2056 "right" => Direction::Right,
2057 _ => Direction::Down,
2058 }
2059}
2060
2061fn facing_name(dir: Direction) -> &'static str {
2063 match dir {
2064 Direction::Down => "down",
2065 Direction::Up => "up",
2066 Direction::Left => "left",
2067 Direction::Right => "right",
2068 }
2069}
2070
2071fn paginate(text: &str) -> VecDeque<String> {
2074 let lines = wrap_lines(text, DIALOG_WIDTH_PX, 4096);
2075 let mut pages: VecDeque<String> = lines
2076 .chunks(DIALOG_LINES_PER_PAGE)
2077 .map(|chunk| chunk.join("\n"))
2078 .collect();
2079 if pages.is_empty() {
2080 pages.push_back(String::new());
2081 }
2082 pages
2083}
2084
2085fn dialog_config() -> MenuConfig {
2087 MenuConfig::new(
2088 DIALOG_AREA,
2089 None,
2090 TileRect::new(
2091 DIALOG_AREA.tx + 1,
2092 DIALOG_AREA.ty + 1,
2093 DIALOG_AREA.tw - 2,
2094 DIALOG_AREA.th - 2,
2095 ),
2096 Default::default(),
2097 )
2098}
2099
2100pub(crate) fn draw_textbox(fb: &mut FrameBuffer, text: &str) {
2102 let mut painter = FrameBufferPainter::new(fb);
2103 draw_dialog(text, &[dialog_config()], &mut painter);
2104}
2105
2106fn draw_end_card(fb: &mut FrameBuffer, game_name: &str, lang: &str) {
2109 let fin = if lang == "zh" { "完" } else { "fin." };
2110 let cx = SCREEN_W as u32 / 2;
2111 let name_w = embedded_font::measure_text(game_name);
2112 embedded_font::draw_text(
2113 game_name,
2114 cx.saturating_sub(name_w / 2),
2115 100,
2116 Rgba::rgb(0xf0, 0xf0, 0xf0),
2117 fb,
2118 );
2119 let fin_w = embedded_font::measure_text(fin);
2120 embedded_font::draw_text(fin, cx.saturating_sub(fin_w / 2), 120, Rgba::rgb(0x90, 0x90, 0xa8), fb);
2121}
2122
2123fn draw_choice_menu(fb: &mut FrameBuffer, options: &[String], cursor: usize) {
2126 let n = options.len() as u32;
2127 if n == 0 {
2128 return;
2129 }
2130 let max_len = options
2131 .iter()
2132 .map(|o| o.chars().count())
2133 .max()
2134 .unwrap_or(1) as u32;
2135 let w = (max_len + 4).clamp(8, 20);
2137 let h = n + 2;
2138 let tx = (40 - w) as i32;
2139 let ty = DIALOG_AREA.ty as i32 - h as i32;
2140 let config = MenuConfig::new(
2141 TileRect::new(tx.max(0) as u32, ty.max(0) as u32, w, h),
2142 None,
2143 TileRect::new(tx.max(0) as u32 + 1, ty.max(0) as u32 + 1, w - 2, n),
2144 Default::default(),
2145 );
2146 let state = FlexMenuState {
2147 cursor,
2148 scroll_offset: 0,
2149 };
2150 let mut painter = FrameBufferPainter::new(fb);
2151 let mut ui = Ui::new(&mut painter);
2152 draw_flex_menu(options, &[config], &state, options.len(), &mut ui);
2153}
2154
2155fn darken(fb: &mut FrameBuffer, factor: f32) {
2157 for px in fb.data.chunks_exact_mut(4) {
2158 px[0] = (px[0] as f32 * factor) as u8;
2159 px[1] = (px[1] as f32 * factor) as u8;
2160 px[2] = (px[2] as f32 * factor) as u8;
2161 }
2162}
2163
2164struct PersonColors {
2168 outline: Rgba,
2169 skin: Rgba,
2170 body: Rgba,
2171}
2172
2173const PLAYER_COLORS: PersonColors = PersonColors {
2175 outline: Rgba::rgb(0x30, 0x18, 0x18),
2176 skin: Rgba::rgb(0xF0, 0xC8, 0xA0),
2177 body: Rgba::rgb(0xC8, 0x30, 0x30),
2178};
2179
2180const NPC_BODIES: [(u8, u8, u8); 6] = [
2182 (0x30, 0x58, 0xC8), (0x38, 0x90, 0x40), (0x88, 0x48, 0xA8), (0xC8, 0x78, 0x28), (0x28, 0x98, 0x98), (0x80, 0x58, 0x38), ];
2189
2190fn npc_palette(id: u32) -> PersonColors {
2192 let (r, g, b) = NPC_BODIES[(id.wrapping_mul(2_654_435_761) >> 16) as usize % NPC_BODIES.len()];
2193 PersonColors {
2194 outline: Rgba::rgb(r / 3, g / 3, b / 3),
2195 skin: Rgba::rgb(0xF0, 0xC8, 0xA0),
2196 body: Rgba::rgb(r, g, b),
2197 }
2198}
2199
2200fn draw_person(
2205 fb: &mut FrameBuffer,
2206 sx: i32,
2207 sy: i32,
2208 tile: i32,
2209 facing: Direction,
2210 colors: &PersonColors,
2211) {
2212 const W: i32 = 12;
2213 const H: i32 = 14;
2214 let ox = sx + (tile - W) / 2;
2215 let oy = sy + tile - H;
2216 let (w, h) = (fb.width() as i32, fb.height() as i32);
2217 let mut put = |x: i32, y: i32, c: Rgba| {
2218 let (px, py) = (ox + x, oy + y);
2219 if px >= 0 && py >= 0 && px < w && py < h {
2220 fb.set_pixel(px as u32, py as u32, c);
2221 }
2222 };
2223 let fill = |x: i32, y: i32, fw: i32, fh: i32, c: Rgba, put: &mut dyn FnMut(i32, i32, Rgba)| {
2224 for dy in 0..fh {
2225 for dx in 0..fw {
2226 put(x + dx, y + dy, c);
2227 }
2228 }
2229 };
2230
2231 fill(3, 0, 6, 5, colors.outline, &mut put);
2233 fill(4, 1, 4, 3, colors.skin, &mut put);
2234 fill(2, 5, 8, 7, colors.outline, &mut put);
2235 fill(3, 6, 6, 5, colors.body, &mut put);
2236 fill(3, 12, 2, 2, colors.outline, &mut put);
2238 fill(7, 12, 2, 2, colors.outline, &mut put);
2239
2240 match facing {
2242 Direction::Down => {
2243 put(4, 2, colors.outline);
2244 put(7, 2, colors.outline);
2245 }
2246 Direction::Left => put(4, 2, colors.outline),
2247 Direction::Right => put(7, 2, colors.outline),
2248 Direction::Up => fill(4, 1, 4, 3, colors.outline, &mut put), }
2250}