1#[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
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 pub external_saves: bool,
165}
166
167struct TextState {
171 engine: Option<ScriptEngine>,
172 pages: VecDeque<String>,
173}
174
175struct ChoiceState {
178 engine: ScriptEngine,
179 options: Vec<String>,
180 cursor: usize,
181 context_text: String,
183}
184
185struct DelayState {
187 engine: ScriptEngine,
188 frames_left: u16,
189}
190
191struct BattleState {
198 engine: Option<ScriptEngine>,
199 battle: Battle,
200}
201
202enum Mode {
204 Overworld,
206 Text(TextState),
208 Choice(ChoiceState),
210 Delay(DelayState),
212 Battle(Box<BattleState>),
215 Menu(MenuState),
218 Shop(Box<ShopState>),
220 Whiteout(WhiteoutState),
223 Idle,
225}
226
227#[derive(Debug, Clone, Copy, PartialEq, Eq)]
229enum FadePhase {
230 Out,
231 In,
232}
233
234struct 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
255struct CollisionView<'a> {
258 map: &'a RuntimeMap,
259}
260
261impl CollisionView<'_> {
262 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 self.map.is_blocked_at(level, x, y) || self.npc_blocks(x, y)
277 }
278}
279
280pub struct RunnerGame {
288 project: LoadedProject,
289 map: Option<RuntimeMap>,
291 camera: Camera,
292 actor: OverworldActor,
293 player_sprite: Option<WalkSprite>,
295 flags: HashMap<String, bool>,
297 lang: String,
298 mode: Mode,
299 transition: Option<WarpTransition>,
301 pending_scenes: VecDeque<(String, String)>,
303 opening_dispatch_pending: bool,
306 active_scene: Option<String>,
308 last_text: String,
310 flash: u32,
312 frame_count: u64,
314 #[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
317 watcher: Option<ProjectWatcher>,
318 #[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
320 watch_pending: HashSet<PathBuf>,
321 #[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
323 watch_cooldown: u32,
324 audio: RunnerAudio,
327 save_path: PathBuf,
329 write_saves: bool,
331 external_saves: bool,
333 rng_script: Option<Vec<u8>>,
335 party_state: Option<Vec<PartyMemberState>>,
340 inventory: Option<HashMap<String, u32>>,
343 money: u32,
346 pending_whiteout: bool,
350 pending_weather: Option<String>,
354 overworld_rng: Option<Box<dyn BattleRng>>,
359 steps_taken: u64,
362}
363
364impl RunnerGame {
365 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; #[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 #[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 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 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 #[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 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 fn resume_from(&mut self, save: GameSave) -> bool {
566 self.flags.extend(save.flags);
567 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 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 fn write_save(&self) {
640 if !self.write_saves {
641 return;
642 }
643 self.write_save_now();
644 }
645
646 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; }
670 }
671
672 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 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 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 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 self.flags.insert(played_flag, true);
784 self.activate(&scene, "main");
785 }
786 }
787
788 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 if matches!(self.mode, Mode::Overworld | Mode::Idle) {
1160 self.write_save();
1161 }
1162 }
1163
1164 #[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 #[cfg(any(not(feature = "watch"), target_arch = "wasm32"))]
1192 pub fn poll_watch(&mut self) {}
1193
1194 #[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 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 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 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 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 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 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 if outcome == BattleOutcome::Win {
1362 self.money = self.money.saturating_add(state.battle.trainer_money());
1363 }
1364 self.pending_whiteout = outcome == BattleOutcome::Lose;
1369 self.pending_weather = None;
1372 log::info!("battle ended: {result}");
1373 match state.engine {
1374 Some(mut engine) => {
1378 let cmd =
1379 self.signal(&mut engine, CommandResult::Text(result.to_string()));
1380 self.pump(engine, cmd, false);
1381 }
1382 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 Mode::Idle => {
1407 self.mode = Mode::Idle;
1408 }
1409 }
1410
1411 self.center_camera();
1412 self.camera.update(0.0);
1413 }
1414
1415 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 self.transition = None;
1438 }
1439 }
1440 FadePhase::In => {
1441 self.transition = None;
1442 self.write_save();
1445 self.dispatch_opening();
1446 }
1447 }
1448 }
1449
1450 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 self.apply_stairs(tx, ty);
1482 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 draw_end_card(fb, &self.project.manifest().name, &self.lang);
1758 }
1759 _ => {}
1760 }
1761
1762 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 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 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 pub fn current_map_id(&self) -> Option<&str> {
1849 self.map.as_ref().map(RuntimeMap::id)
1850 }
1851
1852 pub fn flag(&self, name: &str) -> bool {
1854 self.flags.get(name).copied().unwrap_or(false)
1855 }
1856
1857 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 pub fn choice_options(&self) -> Option<&[String]> {
1869 match &self.mode {
1870 Mode::Choice(state) => Some(&state.options),
1871 _ => None,
1872 }
1873 }
1874
1875 pub fn battle(&self) -> Option<&Battle> {
1877 match &self.mode {
1878 Mode::Battle(state) => Some(&state.battle),
1879 _ => None,
1880 }
1881 }
1882
1883 pub fn party_state(&self) -> Option<&[PartyMemberState]> {
1886 self.party_state.as_deref()
1887 }
1888
1889 pub fn inventory(&self) -> Option<&HashMap<String, u32>> {
1892 self.inventory.as_ref()
1893 }
1894
1895 pub fn money(&self) -> u32 {
1897 self.money
1898 }
1899
1900 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 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 pub fn whiteout_active(&self) -> bool {
1923 matches!(self.mode, Mode::Whiteout(_))
1924 }
1925
1926 pub fn player_tile(&self) -> (i32, i32) {
1928 self.actor.tile()
1929 }
1930
1931 pub fn player_elevation(&self) -> u8 {
1934 self.actor.elevation()
1935 }
1936
1937 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 pub fn audio(&self) -> &RunnerAudio {
1948 &self.audio
1949 }
1950
1951 pub fn render_audio(&mut self, frames: usize) -> Vec<f32> {
1957 self.audio.render_samples(frames)
1958 }
1959
1960 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 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
1986fn 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
2007fn 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#[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
2032fn 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; }
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
2056fn 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
2071fn 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
2081fn 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
2091fn 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
2101fn 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
2115fn 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
2130pub(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
2136fn 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
2159fn 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 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
2187fn 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
2196struct PersonColors {
2200 outline: Rgba,
2201 skin: Rgba,
2202 body: Rgba,
2203}
2204
2205const 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
2212const NPC_BODIES: [(u8, u8, u8); 6] = [
2214 (0x30, 0x58, 0xC8), (0x38, 0x90, 0x40), (0x88, 0x48, 0xA8), (0xC8, 0x78, 0x28), (0x28, 0x98, 0x98), (0x80, 0x58, 0x38), ];
2221
2222fn 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
2232fn 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 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 fill(3, 12, 2, 2, colors.outline, &mut put);
2270 fill(7, 12, 2, 2, colors.outline, &mut put);
2271
2272 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), }
2282}