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}
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(
397 project.files().as_ref(),
398 project.data_root_rel(),
399 !opts.headless,
400 );
401 if opts.pcm_audio {
402 audio.set_pcm_render(true);
403 }
404 let save_path = opts
405 .save_file
406 .clone()
407 .unwrap_or_else(|| project.root().join(DEFAULT_SAVE_FILE));
408
409 let start_money = project
410 .manifest()
411 .shop
412 .as_ref()
413 .map(|s| s.start_money)
414 .unwrap_or(DEFAULT_START_MONEY);
415
416 let mut game = Self {
417 project,
418 map: None,
419 camera,
420 actor: OverworldActor::new(0, 0, 16),
421 player_sprite,
422 flags: HashMap::new(),
423 lang,
424 mode: Mode::Idle,
425 transition: None,
426 pending_scenes: VecDeque::new(),
427 opening_dispatch_pending: false,
428 active_scene: None,
429 last_text: String::new(),
430 flash: 0,
431 frame_count: 0,
432 #[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
433 watcher,
434 #[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
435 watch_pending: HashSet::new(),
436 #[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
437 watch_cooldown: 0,
438 audio,
439 save_path,
440 write_saves: opts.write_saves,
441 rng_script: opts.rng_script.clone(),
442 party_state: None,
443 inventory: None,
444 money: start_money,
445 pending_whiteout: false,
446 pending_weather: None,
447 overworld_rng: None,
448 steps_taken: 0,
449 };
450
451 #[cfg(not(target_arch = "wasm32"))]
456 if !opts.fresh && opts.map.is_none() {
457 if let Some(save) = GameSave::load(&game.save_path) {
458 if game.resume_from(save) {
459 return Ok(game);
460 }
461 }
462 }
463
464 let map_ids = game.project.map_ids();
465 if map_ids.is_empty() {
466 let scene = game.project.entry_scene_name()?.to_string();
468 log::info!("dotzuki-runner: no maps; booting dialogue-only scene '{scene}'");
469 if !game.activate(&scene, "main") {
470 log::warn!("entry scene '{scene}' has no playable 'main' storyline");
471 }
472 return Ok(game);
473 }
474
475 let map_id = match &opts.map {
476 Some(id) => id.clone(),
477 None => game.project.entry_map()?,
478 };
479 game.boot_map(&map_id)
480 .with_context(|| format!("failed to load entry map '{map_id}'"))?;
481 game.dispatch_opening();
482 Ok(game)
483 }
484
485 fn boot_map(&mut self, map_id: &str) -> Result<()> {
487 let map = self.project.load_map(map_id)?;
488 let spawn = find_spawn(&map);
489 let tile = map.tile_size().0 as i32;
490 self.camera.clamp_to_bounds(Rect::new(
491 0.0,
492 0.0,
493 map.pixel_width() as f32,
494 map.pixel_height() as f32,
495 ));
496 self.actor = OverworldActor::new(spawn.0, spawn.1, tile);
497 self.map = Some(map);
498 self.center_camera();
499 self.camera.update(0.0);
500 log::info!("loaded map {map_id} @ ({},{})", spawn.0, spawn.1);
501 Ok(())
502 }
503
504 #[must_use]
508 fn enter_map(&mut self, map_id: &str, spawn: (i32, i32), facing: Direction) -> bool {
509 let map = match self.project.load_map(map_id) {
510 Ok(m) => m,
511 Err(e) => {
512 log::error!("warp to {map_id} aborted: {e:#}");
513 return false;
514 }
515 };
516 let spawn = if map.is_blocked(spawn.0, spawn.1)
517 || map.objects().npcs.iter().any(|n| (n.x, n.y) == spawn)
518 {
519 let fallback = find_spawn(&map);
520 log::warn!(
521 "spawn ({},{}) on {map_id} is occupied; using ({},{})",
522 spawn.0,
523 spawn.1,
524 fallback.0,
525 fallback.1
526 );
527 fallback
528 } else {
529 spawn
530 };
531 self.camera.clamp_to_bounds(Rect::new(
532 0.0,
533 0.0,
534 map.pixel_width() as f32,
535 map.pixel_height() as f32,
536 ));
537 self.map = Some(map);
538 self.actor.place(spawn.0, spawn.1, facing);
539 self.actor.set_elevation(0);
541 self.center_camera();
542 self.camera.update(0.0);
543 log::info!("loaded map {map_id} @ ({},{})", spawn.0, spawn.1);
544 true
545 }
546
547 fn resume_from(&mut self, save: GameSave) -> bool {
559 self.flags.extend(save.flags);
560 if let Some(party) = save.party {
563 self.party_state = Some(
564 party
565 .into_iter()
566 .map(|m| PartyMemberState {
567 id: m.id,
568 hp: m.hp,
569 mp: m.mp,
570 status: m.status,
571 level: m.level,
572 exp: m.exp,
573 })
574 .collect(),
575 );
576 }
577 if save.inventory.is_some() {
578 self.inventory = save.inventory;
579 }
580 if let Some(money) = save.money {
581 self.money = money;
582 }
583 let Some(map_id) = &save.map else {
584 log::info!("save: restored flags (dialogue-only project)");
585 return false;
586 };
587 let map = match self.project.load_map(map_id) {
588 Ok(m) => m,
589 Err(e) => {
590 log::warn!("save: saved map '{map_id}' failed to load ({e:#}) — starting fresh");
591 return false;
592 }
593 };
594 let (x, y) = (save.player.x, save.player.y);
595 let facing = parse_facing(&save.player.facing);
596 let spawn =
597 if map.is_blocked(x, y) || map.objects().npcs.iter().any(|n| (n.x, n.y) == (x, y)) {
598 let fallback = find_spawn(&map);
599 log::warn!(
600 "save: position ({x},{y}) on {map_id} is occupied; using ({},{})",
601 fallback.0,
602 fallback.1
603 );
604 fallback
605 } else {
606 (x, y)
607 };
608 self.camera.clamp_to_bounds(Rect::new(
609 0.0,
610 0.0,
611 map.pixel_width() as f32,
612 map.pixel_height() as f32,
613 ));
614 self.map = Some(map);
615 self.actor.place(spawn.0, spawn.1, facing);
616 let max_level = self.map.as_ref().map(|m| m.level_count() - 1).unwrap_or(0) as u8;
618 self.actor.set_elevation(save.player.level.min(max_level));
619 self.center_camera();
620 self.camera.update(0.0);
621 self.mode = Mode::Overworld;
622 log::info!("save: resumed {map_id} @ ({},{})", spawn.0, spawn.1);
623 true
624 }
625
626 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 {
984 engine: Some(engine),
985 battle,
986 }));
987 return;
988 }
989 None => {
990 cmd = self.signal(&mut engine, CommandResult::Text("win".to_string()));
991 }
992 }
993 }
994 Some(ScriptCommand::StartWildBattle { species, level }) => {
995 log::info!("scene → battle: startWildBattle({species}, lv{level}) — level ignored in v1");
996 match self.build_battle(&species) {
997 Some(battle) => {
998 self.mode = Mode::Battle(Box::new(BattleState {
999 engine: Some(engine),
1000 battle,
1001 }));
1002 return;
1003 }
1004 None => {
1005 cmd = self.signal(&mut engine, CommandResult::Text("win".to_string()));
1006 }
1007 }
1008 }
1009 Some(ScriptCommand::SetWeather { weather }) => {
1010 log::info!("scene → weather: {weather:?} (applies to the next battle)");
1011 self.pending_weather = weather;
1012 cmd = self.signal(&mut engine, CommandResult::Void);
1013 }
1014 Some(ScriptCommand::OpenShop { items }) => {
1015 log::info!("scene → shop: openShop({items:?})");
1016 let items = self.build_shop_items(&items);
1019 self.mode = Mode::Shop(Box::new(ShopState::new(engine, items)));
1020 return;
1021 }
1022 Some(other) => {
1023 log::warn!(
1025 "unhandled scene command {other:?} in scene '{scene}' — \
1026 auto-completing with Void"
1027 );
1028 cmd = self.signal(&mut engine, CommandResult::Void);
1029 }
1030 None => {
1031 if first {
1032 log::warn!(
1033 "scene '{scene}' finished without producing any command — \
1034 did it call an unregistered game.* function?"
1035 );
1036 }
1037 self.finish_scene(engine);
1038 return;
1039 }
1040 }
1041 first = false;
1042 }
1043 }
1044
1045 fn signal(
1047 &mut self,
1048 engine: &mut ScriptEngine,
1049 result: CommandResult,
1050 ) -> Option<ScriptCommand> {
1051 match engine.signal_done(result) {
1052 Ok(cmd) => cmd,
1053 Err(e) => {
1054 let scene = self.active_scene.clone().unwrap_or_default();
1055 log::warn!("scene '{scene}' signal failed: {e}");
1056 None
1057 }
1058 }
1059 }
1060
1061 fn build_battle(&mut self, enemy_id: &str) -> Option<Battle> {
1067 if self.project.manifest().battle.is_none() {
1068 log::warn!(
1069 "startBattle({enemy_id}): project has no battle section — \
1070 auto-completing with \"win\""
1071 );
1072 return None;
1073 }
1074 let rng: Box<dyn BattleRng> = match &self.rng_script {
1075 Some(bytes) => Box::new(ScriptedRng::new(bytes.clone())),
1076 None => {
1077 #[cfg(not(target_arch = "wasm32"))]
1080 let seed = {
1081 let nanos = std::time::SystemTime::now()
1082 .duration_since(std::time::UNIX_EPOCH)
1083 .map(|d| d.subsec_nanos() as u64)
1084 .unwrap_or(0);
1085 nanos ^ self.frame_count
1086 };
1087 #[cfg(target_arch = "wasm32")]
1088 let seed =
1089 self.frame_count.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ 0xA076_1D64_78BD_642F;
1090 Box::new(XorshiftRng::seed(seed))
1091 }
1092 };
1093 match BattleSetup::from_project(&self.project).and_then(|setup| {
1094 setup.start_with(
1095 enemy_id,
1096 rng,
1097 self.party_state.as_deref(),
1098 self.inventory.as_ref(),
1099 )
1100 }) {
1101 Ok(mut battle) => {
1102 battle.set_lang(&self.lang);
1103 battle.set_currency(self.currency());
1104 battle.set_weather(self.pending_weather.clone());
1109 battle.begin();
1110 Some(battle)
1111 }
1112 Err(e) => {
1113 log::error!("startBattle({enemy_id}): battle setup failed: {e:#}");
1114 None
1115 }
1116 }
1117 }
1118
1119 fn finish_scene(&mut self, engine: ScriptEngine) {
1125 self.flags.extend(engine.get_all_flags());
1126 drop(engine);
1127 self.active_scene = None;
1128 self.mode = if self.map.is_some() {
1129 Mode::Overworld
1130 } else {
1131 Mode::Idle
1132 };
1133 if self.pending_whiteout {
1134 self.pending_whiteout = false;
1135 self.pending_scenes.clear();
1136 self.opening_dispatch_pending = false;
1137 self.start_whiteout();
1138 return;
1139 }
1140 if self.pop_pending_scene() {
1141 return;
1142 }
1143 if self.opening_dispatch_pending {
1144 self.opening_dispatch_pending = false;
1145 self.dispatch_opening();
1146 }
1147 if matches!(self.mode, Mode::Overworld | Mode::Idle) {
1150 self.write_save();
1151 }
1152 }
1153
1154 #[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
1162 pub fn poll_watch(&mut self) {
1163 let Some(watcher) = &mut self.watcher else {
1164 return;
1165 };
1166 self.watch_pending.extend(watcher.poll_events());
1167 if self.watch_cooldown > 0 {
1168 self.watch_cooldown -= 1;
1169 return;
1170 }
1171 if self.watch_pending.is_empty() {
1172 return;
1173 }
1174 let paths: Vec<PathBuf> = self.watch_pending.drain().collect();
1175 self.watch_cooldown = WATCH_DEBOUNCE_FRAMES;
1176 self.apply_watch_batch(paths);
1177 }
1178
1179 #[cfg(any(not(feature = "watch"), target_arch = "wasm32"))]
1182 pub fn poll_watch(&mut self) {}
1183
1184 #[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
1192 fn apply_watch_batch(&mut self, paths: Vec<PathBuf>) {
1193 let map_dir = self
1194 .map
1195 .as_ref()
1196 .map(|m| crate::map::map_dir(&self.project.maps_dir(), m.id()));
1197 let mut scenes_changed = false;
1198 let mut map_changed = false;
1199 for path in &paths {
1200 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
1201 if ext == "scene" {
1202 scenes_changed = true;
1203 } else if map_dir
1204 .as_ref()
1205 .is_some_and(|d| path.parent() == Some(d.as_path()))
1206 {
1207 map_changed = true;
1208 }
1209 }
1210 if scenes_changed {
1211 self.reload_scenes();
1212 }
1213 if map_changed {
1214 self.reload_current_map();
1215 }
1216 }
1217
1218 pub fn reload_scenes(&mut self) -> bool {
1223 match self.project.recompile_scripts() {
1224 Ok(()) => {
1225 log::info!("hot-reload: scenes recompiled");
1226 true
1227 }
1228 Err(e) => {
1229 log::warn!("hot-reload: scene reload failed, keeping old scenes: {e:#}");
1230 false
1231 }
1232 }
1233 }
1234
1235 pub fn reload_current_map(&mut self) -> bool {
1239 let Some(map) = &self.map else {
1240 return false;
1241 };
1242 let map_id = map.id().to_string();
1243 match self.project.load_map(&map_id) {
1244 Ok(new_map) => {
1245 self.camera.clamp_to_bounds(Rect::new(
1246 0.0,
1247 0.0,
1248 new_map.pixel_width() as f32,
1249 new_map.pixel_height() as f32,
1250 ));
1251 self.map = Some(new_map);
1252 self.center_camera();
1253 self.camera.update(0.0);
1254 log::info!("hot-reload: reloaded map '{map_id}'");
1255 true
1256 }
1257 Err(e) => {
1258 log::warn!("hot-reload: map '{map_id}' reload failed, keeping old map: {e:#}");
1259 false
1260 }
1261 }
1262 }
1263
1264 pub fn update(&mut self, input: &InputState) {
1269 self.frame_count += 1;
1270 self.poll_watch();
1271 self.audio.update_frame();
1272 if self.flash > 0 {
1273 self.flash -= 1;
1274 }
1275
1276 if self.transition.is_some() {
1278 self.update_transition();
1279 return;
1280 }
1281
1282 match std::mem::replace(&mut self.mode, Mode::Overworld) {
1283 Mode::Text(mut state) => {
1284 if input.is_just_pressed(GbButton::A) {
1285 state.pages.pop_front();
1286 if state.pages.is_empty() {
1287 match state.engine {
1288 Some(mut engine) => {
1289 let cmd = self.signal(&mut engine, CommandResult::Void);
1290 self.pump(engine, cmd, false);
1291 }
1292 None => {
1293 self.mode = if self.map.is_some() {
1295 Mode::Overworld
1296 } else {
1297 Mode::Idle
1298 };
1299 }
1300 }
1301 } else {
1302 self.mode = Mode::Text(state);
1303 }
1304 } else {
1305 self.mode = Mode::Text(state);
1306 }
1307 }
1308 Mode::Choice(mut state) => {
1309 let n = state.options.len().max(1);
1310 if input.is_just_pressed(GbButton::Up) {
1311 state.cursor = (state.cursor + n - 1) % n;
1312 } else if input.is_just_pressed(GbButton::Down) {
1313 state.cursor = (state.cursor + 1) % n;
1314 }
1315 if input.is_just_pressed(GbButton::A) {
1316 let cursor = state.cursor;
1317 let mut engine = state.engine;
1318 log::info!("choice picked: {cursor}");
1319 let cmd = self.signal(&mut engine, CommandResult::Number(cursor as f64));
1320 self.pump(engine, cmd, false);
1321 } else {
1322 self.mode = Mode::Choice(state);
1323 }
1324 }
1325 Mode::Delay(mut state) => {
1326 if state.frames_left > 0 {
1327 state.frames_left -= 1;
1328 }
1329 if state.frames_left == 0 {
1330 let mut engine = state.engine;
1331 let cmd = self.signal(&mut engine, CommandResult::Void);
1332 self.pump(engine, cmd, false);
1333 } else {
1334 self.mode = Mode::Delay(state);
1335 }
1336 }
1337 Mode::Battle(mut state) => {
1338 state.battle.update(input);
1339 if let Some(outcome) = state.battle.outcome() {
1340 self.party_state = Some(state.battle.party_state());
1344 self.inventory = Some(state.battle.inventory().clone());
1345 let result = match outcome {
1346 BattleOutcome::Win => "win",
1347 BattleOutcome::Lose => "lose",
1348 BattleOutcome::Run => "run",
1349 };
1350 if outcome == BattleOutcome::Win {
1352 self.money = self.money.saturating_add(state.battle.trainer_money());
1353 }
1354 self.pending_whiteout = outcome == BattleOutcome::Lose;
1359 self.pending_weather = None;
1362 log::info!("battle ended: {result}");
1363 match state.engine {
1364 Some(mut engine) => {
1368 let cmd =
1369 self.signal(&mut engine, CommandResult::Text(result.to_string()));
1370 self.pump(engine, cmd, false);
1371 }
1372 None => {
1377 if outcome == BattleOutcome::Lose {
1378 self.pending_whiteout = false;
1379 self.start_whiteout();
1380 } else {
1381 self.mode = Mode::Overworld;
1382 }
1383 }
1384 }
1385 } else {
1386 self.mode = Mode::Battle(state);
1387 }
1388 }
1389 Mode::Overworld => self.update_overworld(input),
1390 Mode::Menu(state) => self.update_menu(state, input),
1391 Mode::Shop(state) => self.update_shop(*state, input),
1392 Mode::Whiteout(state) => self.update_whiteout(state, input),
1393 Mode::Idle => {
1397 self.mode = Mode::Idle;
1398 }
1399 }
1400
1401 self.center_camera();
1402 self.camera.update(0.0);
1403 }
1404
1405 fn update_transition(&mut self) {
1407 let Some(t) = &mut self.transition else {
1408 return;
1409 };
1410 if t.frames > 0 {
1411 t.frames -= 1;
1412 }
1413 if t.frames > 0 {
1414 return;
1415 }
1416 match t.phase {
1417 FadePhase::Out => {
1418 let (dest_map, dest) = (t.dest_map.clone(), (t.dest_x, t.dest_y));
1419 let facing = self.actor.facing();
1420 if self.enter_map(&dest_map, dest, facing) {
1421 if let Some(t) = &mut self.transition {
1422 t.phase = FadePhase::In;
1423 t.frames = FADE_FRAMES;
1424 }
1425 } else {
1426 self.transition = None;
1428 }
1429 }
1430 FadePhase::In => {
1431 self.transition = None;
1432 self.write_save();
1435 self.dispatch_opening();
1436 }
1437 }
1438 }
1439
1440 fn update_overworld(&mut self, input: &InputState) {
1443 if self.map.is_none() {
1444 return;
1445 }
1446 if input.is_just_pressed(GbButton::Start) {
1447 self.mode = Mode::Menu(MenuState::new());
1448 return;
1449 }
1450 if input.is_just_pressed(GbButton::A) {
1451 if let Some(npc_index) = self.faced_npc_index() {
1452 self.talk_to(npc_index);
1453 return;
1454 }
1455 if let Some(sign_index) = self.faced_sign_index() {
1456 self.read_sign(sign_index);
1457 return;
1458 }
1459 }
1460
1461 let held = held_direction(input);
1462 self.actor.set_running(input.is_held(GbButton::B));
1463 let step = {
1464 let map = self.map.as_ref().expect("checked above");
1465 let view = CollisionView { map };
1466 self.actor.update(held, &view)
1467 };
1468 if let Some((tx, ty)) = step {
1469 self.steps_taken += 1;
1470 self.apply_stairs(tx, ty);
1472 let warp = {
1474 let map = self.map.as_ref().expect("checked above");
1475 map.objects()
1476 .warps
1477 .iter()
1478 .find(|w| (w.x, w.y) == (tx, ty) && !w.dest_map.is_empty())
1479 .map(|w| (w.dest_map.clone(), w.dest_x, w.dest_y))
1480 };
1481 if let Some((dest_map, dest_x, dest_y)) = warp {
1482 self.transition = Some(WarpTransition::new(dest_map, dest_x, dest_y));
1483 } else {
1484 self.roll_encounter(tx, ty);
1485 }
1486 }
1487 }
1488
1489 fn roll_encounter(&mut self, tx: i32, ty: i32) {
1496 let (rate, table) = {
1497 let map = self.map.as_ref().expect("roll_encounter requires a map");
1498 let Some(encounters) = &map.objects().encounters else {
1499 return;
1500 };
1501 match encounters.zones.iter().find(|z| z.contains(tx, ty)) {
1502 Some(zone) => (encounters.rate, zone.table.clone()),
1503 None => return,
1504 }
1505 };
1506 if rate == 0 || table.is_empty() {
1507 return;
1508 }
1509 if self.overworld_rng_byte() >= rate {
1510 return;
1511 }
1512 let total: u32 = table.iter().map(|e| e.weight).sum();
1513 if total == 0 {
1514 return;
1515 }
1516 let mut pick = u32::from(self.overworld_rng_byte()) % total;
1517 let id = table
1518 .iter()
1519 .find(|e| {
1520 if pick < e.weight {
1521 true
1522 } else {
1523 pick -= e.weight;
1524 false
1525 }
1526 })
1527 .map(|e| e.id.clone());
1528 if let Some(id) = id {
1529 self.start_overworld_battle(&id);
1530 }
1531 }
1532
1533 fn overworld_rng_byte(&mut self) -> u8 {
1540 if self.overworld_rng.is_none() {
1541 let rng: Box<dyn BattleRng> = match &self.rng_script {
1542 Some(bytes) => Box::new(ScriptedRng::new(bytes.clone())),
1543 None => {
1544 #[cfg(not(target_arch = "wasm32"))]
1545 let seed = {
1546 let nanos = std::time::SystemTime::now()
1547 .duration_since(std::time::UNIX_EPOCH)
1548 .map(|d| d.subsec_nanos() as u64)
1549 .unwrap_or(0);
1550 nanos ^ self.frame_count
1551 };
1552 #[cfg(target_arch = "wasm32")]
1553 let seed = self
1554 .frame_count
1555 .wrapping_add(self.steps_taken)
1556 .wrapping_mul(0x9E37_79B9_7F4A_7C15)
1557 ^ 0xA076_1D64_78BD_642F;
1558 Box::new(XorshiftRng::seed(seed))
1559 }
1560 };
1561 self.overworld_rng = Some(rng);
1562 }
1563 self.overworld_rng
1564 .as_deref_mut()
1565 .expect("seeded above")
1566 .byte()
1567 }
1568
1569 fn start_overworld_battle(&mut self, id: &str) {
1576 log::info!("random encounter: {id}");
1577 if let Some(battle) = self.build_battle(id) {
1578 self.mode = Mode::Battle(Box::new(BattleState {
1579 engine: None,
1580 battle,
1581 }));
1582 }
1583 }
1584
1585 fn apply_stairs(&mut self, x: i32, y: i32) {
1588 let Some(map) = &self.map else {
1589 return;
1590 };
1591 let level = self.actor.elevation();
1592 let next = match map.stair_at(x, y) {
1593 Some(1) => (level as usize + 1).min(map.level_count() - 1) as u8,
1594 Some(2) => level.saturating_sub(1),
1595 _ => return,
1596 };
1597 if next != level {
1598 self.actor.set_elevation(next);
1599 }
1600 }
1601
1602 fn faced_npc_index(&self) -> Option<usize> {
1604 let map = self.map.as_ref()?;
1605 let (dx, dy) = direction_delta(self.actor.facing());
1606 let (tx, ty) = self.actor.tile();
1607 let faced = (tx + dx, ty + dy);
1608 map.objects().npcs.iter().position(|n| (n.x, n.y) == faced)
1609 }
1610
1611 fn faced_sign_index(&self) -> Option<usize> {
1613 let map = self.map.as_ref()?;
1614 let (dx, dy) = direction_delta(self.actor.facing());
1615 let (tx, ty) = self.actor.tile();
1616 let faced = (tx + dx, ty + dy);
1617 map.objects().signs.iter().position(|s| (s.x, s.y) == faced)
1618 }
1619
1620 fn read_sign(&mut self, index: usize) {
1623 let map = self.map.as_ref().expect("read_sign requires a map");
1624 let text = map.objects().signs[index].text.clone();
1625 if text.is_empty() {
1626 return;
1627 }
1628 self.last_text = text.clone();
1629 self.mode = Mode::Text(TextState {
1630 engine: None,
1631 pages: paginate(&text),
1632 });
1633 }
1634
1635 fn talk_to(&mut self, index: usize) {
1638 let (map_id, npc_name, npc_id, talk) = {
1639 let map = self.map.as_ref().expect("talk requires a map");
1640 let npc = &map.objects().npcs[index];
1641 (
1642 map.id().to_string(),
1643 npc.name.clone(),
1644 npc.id,
1645 npc.talk.clone(),
1646 )
1647 };
1648
1649 if !talk.is_empty() {
1651 if let Some(scene) = self.scene_with_storyline(&map_id, &talk) {
1652 if self.activate(&scene, &talk) {
1653 return;
1654 }
1655 }
1656 }
1657
1658 let route = self
1660 .project
1661 .routes()
1662 .iter()
1663 .filter(|r| r.map == map_id && !r.on_enter)
1664 .find(|r| {
1665 r.npc.as_deref().is_some_and(|n| {
1666 (!npc_name.is_empty() && n == npc_name) || n == npc_id.to_string()
1667 })
1668 })
1669 .map(|r| r.storyline.clone());
1670 if let Some(storyline) = route {
1671 if let Some(scene) = self.scene_with_storyline(&map_id, &storyline) {
1672 if self.activate(&scene, &storyline) {
1673 return;
1674 }
1675 }
1676 }
1677
1678 if let Some(scene) = self.scene_for_map(&map_id) {
1680 if scene_has_fn(&self.project, &scene, "main") && self.activate(&scene, "main") {
1681 return;
1682 }
1683 }
1684
1685 if !talk.is_empty() {
1687 self.last_text = talk.clone();
1688 self.mode = Mode::Text(TextState {
1689 engine: None,
1690 pages: paginate(&talk),
1691 });
1692 }
1693 }
1694
1695 pub fn draw(&mut self, fb: &mut FrameBuffer) {
1700 if let Some(map) = &self.map {
1701 fb.clear(Rgba::BLACK);
1702 let cam_x = self.camera.position.x.round() as i32;
1703 let cam_y = self.camera.position.y.round() as i32;
1704 let level = self.actor.elevation() as i32;
1705 if let Err(e) =
1708 map.render_below(fb, cam_x, cam_y, SCREEN_W as u32, SCREEN_H as u32, level)
1709 {
1710 log::warn!("map render: {e:#}");
1711 }
1712 self.draw_npcs(fb, cam_x, cam_y);
1713 self.draw_player(fb, cam_x, cam_y);
1714 if let Err(e) =
1715 map.render_above(fb, cam_x, cam_y, SCREEN_W as u32, SCREEN_H as u32, level)
1716 {
1717 log::warn!("map render: {e:#}");
1718 }
1719 } else {
1720 fb.clear(Rgba::rgb(0x10, 0x10, 0x18));
1722 }
1723
1724 match &self.mode {
1725 Mode::Text(state) => {
1726 if let Some(page) = state.pages.front() {
1727 draw_textbox(fb, page);
1728 }
1729 }
1730 Mode::Choice(state) => {
1731 if !state.context_text.is_empty() {
1732 draw_textbox(fb, &state.context_text);
1733 }
1734 draw_choice_menu(fb, &state.options, state.cursor);
1735 }
1736 Mode::Battle(state) => state.battle.draw(fb),
1737 Mode::Menu(state) => self.draw_menu(fb, state),
1738 Mode::Shop(state) => self.draw_shop(fb, state),
1739 Mode::Whiteout(state) => {
1740 if let Some(page) = state.pages.front() {
1741 draw_textbox(fb, page);
1742 }
1743 }
1744 Mode::Idle if self.map.is_none() => {
1745 draw_end_card(fb, &self.project.manifest().name, &self.lang);
1748 }
1749 _ => {}
1750 }
1751
1752 let darkness = match &self.transition {
1754 Some(t) => match t.phase {
1755 FadePhase::Out => 1.0 - t.frames as f32 / FADE_FRAMES as f32,
1756 FadePhase::In => t.frames as f32 / FADE_FRAMES as f32,
1757 },
1758 None if self.flash > 0 => 0.5 * self.flash as f32 / FLASH_FRAMES as f32,
1759 None => 0.0,
1760 };
1761 if darkness > 0.0 {
1762 darken(fb, 1.0 - darkness.clamp(0.0, 1.0));
1763 }
1764
1765 if let Mode::Whiteout(state) = &self.mode {
1767 if state.blackout > 0 {
1768 fb.fill_rect(0, 0, SCREEN_W as u32, SCREEN_H as u32, Rgba::BLACK);
1769 }
1770 }
1771 }
1772
1773 fn draw_npcs(&self, fb: &mut FrameBuffer, cam_x: i32, cam_y: i32) {
1776 let Some(map) = &self.map else {
1777 return;
1778 };
1779 let tile = map.tile_size().0 as i32;
1780 for npc in &map.objects().npcs {
1781 let facing = parse_facing(&npc.facing);
1782 let colors = npc_palette(npc.id);
1783 draw_person(
1784 fb,
1785 npc.x * tile - cam_x,
1786 npc.y * tile - cam_y,
1787 tile,
1788 facing,
1789 &colors,
1790 );
1791 }
1792 }
1793
1794 fn draw_player(&self, fb: &mut FrameBuffer, cam_x: i32, cam_y: i32) {
1795 let tile = self
1796 .map
1797 .as_ref()
1798 .map(|m| m.tile_size().0 as i32)
1799 .unwrap_or(16);
1800 let foot_x = self.actor.px().round() as i32 - cam_x;
1801 let foot_y = self.actor.py().round() as i32 - cam_y;
1802 if let Some(sprite) = &self.player_sprite {
1803 let col = frame_col(
1804 self.actor.locomotion(),
1805 self.actor.step_phase(),
1806 sprite.cols,
1807 );
1808 sprite.draw_on_tile(fb, self.actor.facing_row(), col, foot_x, foot_y, tile);
1809 return;
1810 }
1811 draw_person(
1812 fb,
1813 foot_x,
1814 foot_y,
1815 tile,
1816 self.actor.facing(),
1817 &PLAYER_COLORS,
1818 );
1819 }
1820
1821 fn center_camera(&mut self) {
1822 let tile = self
1823 .map
1824 .as_ref()
1825 .map(|m| m.tile_size().0 as i32)
1826 .unwrap_or(16);
1827 let cx = self.actor.px() + (tile / 2) as f32;
1828 let cy = self.actor.py() + (tile / 2) as f32;
1829 self.camera.follow_target(Vec2::new(
1830 cx - SCREEN_W as f32 / 2.0,
1831 cy - SCREEN_H as f32 / 2.0,
1832 ));
1833 }
1834
1835 pub fn current_map_id(&self) -> Option<&str> {
1839 self.map.as_ref().map(RuntimeMap::id)
1840 }
1841
1842 pub fn flag(&self, name: &str) -> bool {
1844 self.flags.get(name).copied().unwrap_or(false)
1845 }
1846
1847 pub fn dialogue_text(&self) -> Option<&str> {
1850 match &self.mode {
1851 Mode::Text(state) => state.pages.front().map(String::as_str),
1852 Mode::Whiteout(state) if state.blackout == 0 => state.pages.front().map(String::as_str),
1853 _ => None,
1854 }
1855 }
1856
1857 pub fn choice_options(&self) -> Option<&[String]> {
1859 match &self.mode {
1860 Mode::Choice(state) => Some(&state.options),
1861 _ => None,
1862 }
1863 }
1864
1865 pub fn battle(&self) -> Option<&Battle> {
1867 match &self.mode {
1868 Mode::Battle(state) => Some(&state.battle),
1869 _ => None,
1870 }
1871 }
1872
1873 pub fn party_state(&self) -> Option<&[PartyMemberState]> {
1876 self.party_state.as_deref()
1877 }
1878
1879 pub fn inventory(&self) -> Option<&HashMap<String, u32>> {
1882 self.inventory.as_ref()
1883 }
1884
1885 pub fn money(&self) -> u32 {
1887 self.money
1888 }
1889
1890 pub fn menu_lines(&self) -> Option<Vec<String>> {
1894 match &self.mode {
1895 Mode::Menu(state) => Some(self.menu_lines_for(state)),
1896 _ => None,
1897 }
1898 }
1899
1900 pub fn shop_lines(&self) -> Option<Vec<String>> {
1904 match &self.mode {
1905 Mode::Shop(state) => Some(self.shop_lines_for(state)),
1906 _ => None,
1907 }
1908 }
1909
1910 pub fn whiteout_active(&self) -> bool {
1913 matches!(self.mode, Mode::Whiteout(_))
1914 }
1915
1916 pub fn player_tile(&self) -> (i32, i32) {
1918 self.actor.tile()
1919 }
1920
1921 pub fn player_elevation(&self) -> u8 {
1924 self.actor.elevation()
1925 }
1926
1927 pub fn is_blocked(&self, x: i32, y: i32) -> bool {
1930 match &self.map {
1931 Some(map) => map.is_blocked(x, y),
1932 None => true,
1933 }
1934 }
1935
1936 pub fn audio(&self) -> &RunnerAudio {
1938 &self.audio
1939 }
1940
1941 pub fn render_audio(&mut self, frames: usize) -> Vec<f32> {
1947 self.audio.render_samples(frames)
1948 }
1949
1950 pub fn debug_place(&mut self, x: i32, y: i32, facing: Direction) {
1952 self.actor.place(x, y, facing);
1953 self.center_camera();
1954 self.camera.update(0.0);
1955 }
1956
1957 pub fn frame_count(&self) -> u64 {
1959 self.frame_count
1960 }
1961}
1962
1963#[cfg(not(target_arch = "wasm32"))]
1964impl dotzuki_app::GameLoop for RunnerGame {
1965 type Fb = FrameBuffer;
1966
1967 fn update(&mut self, input: &InputState) {
1968 RunnerGame::update(self, input);
1969 }
1970
1971 fn draw(&mut self, frame_buffer: &mut FrameBuffer) {
1972 RunnerGame::draw(self, frame_buffer);
1973 }
1974}
1975
1976fn decode_walk_sheet(
1981 bytes: &[u8],
1982 path: &str,
1983 frame_w: u32,
1984 frame_h: u32,
1985) -> Result<WalkSprite, String> {
1986 let img = image::load_from_memory(bytes)
1987 .map_err(|e| format!("decode {path}: {e}"))?
1988 .to_rgba8();
1989 let (w, h) = img.dimensions();
1990 let pixels = img
1991 .pixels()
1992 .map(|p| Rgba::new(p.0[0], p.0[1], p.0[2], p.0[3]))
1993 .collect();
1994 WalkSprite::from_rgba(pixels, w, h, frame_w, frame_h)
1995}
1996
1997fn scene_has_fn(project: &LoadedProject, scene: &str, fn_name: &str) -> bool {
2002 let Some(js) = project.scripts().get_script(scene) else {
2003 return false;
2004 };
2005 js.contains(&format!("storyline_{fn_name}")) || js.contains(&format!("function {fn_name}"))
2006}
2007
2008#[cfg(all(feature = "watch", not(target_arch = "wasm32")))]
2011fn watch_dirs(project: &LoadedProject) -> Vec<PathBuf> {
2012 let mut dirs = project.manifest().dsl_dirs(project.root());
2013 dirs.push(project.data_root().to_path_buf());
2014 if let Some(gfx) = project.gfx_root() {
2015 dirs.push(gfx.to_path_buf());
2016 }
2017 let mut seen = HashSet::new();
2018 dirs.retain(|d| seen.insert(d.clone()));
2019 dirs
2020}
2021
2022fn find_spawn(map: &RuntimeMap) -> (i32, i32) {
2025 let (cx, cy) = (map.width() as i32 / 2, map.height() as i32 / 2);
2026 let free = |x: i32, y: i32| {
2027 !map.is_blocked(x, y) && !map.objects().npcs.iter().any(|n| (n.x, n.y) == (x, y))
2028 };
2029 let max_r = (map.width().max(map.height()) as i32) + 1;
2030 for r in 0..max_r {
2031 for dy in -r..=r {
2032 for dx in -r..=r {
2033 if dx.abs().max(dy.abs()) != r {
2034 continue; }
2036 let (x, y) = (cx + dx, cy + dy);
2037 if free(x, y) {
2038 return (x, y);
2039 }
2040 }
2041 }
2042 }
2043 (cx, cy)
2044}
2045
2046fn held_direction(input: &InputState) -> Option<Direction> {
2048 if input.is_held(GbButton::Up) {
2049 Some(Direction::Up)
2050 } else if input.is_held(GbButton::Down) {
2051 Some(Direction::Down)
2052 } else if input.is_held(GbButton::Left) {
2053 Some(Direction::Left)
2054 } else if input.is_held(GbButton::Right) {
2055 Some(Direction::Right)
2056 } else {
2057 None
2058 }
2059}
2060
2061fn direction_delta(dir: Direction) -> (i32, i32) {
2063 match dir {
2064 Direction::Down => (0, 1),
2065 Direction::Up => (0, -1),
2066 Direction::Left => (-1, 0),
2067 Direction::Right => (1, 0),
2068 }
2069}
2070
2071fn parse_facing(facing: &str) -> Direction {
2073 match facing {
2074 "up" => Direction::Up,
2075 "left" => Direction::Left,
2076 "right" => Direction::Right,
2077 _ => Direction::Down,
2078 }
2079}
2080
2081fn facing_name(dir: Direction) -> &'static str {
2083 match dir {
2084 Direction::Down => "down",
2085 Direction::Up => "up",
2086 Direction::Left => "left",
2087 Direction::Right => "right",
2088 }
2089}
2090
2091fn paginate(text: &str) -> VecDeque<String> {
2094 let lines = wrap_lines(text, DIALOG_WIDTH_PX, 4096);
2095 let mut pages: VecDeque<String> = lines
2096 .chunks(DIALOG_LINES_PER_PAGE)
2097 .map(|chunk| chunk.join("\n"))
2098 .collect();
2099 if pages.is_empty() {
2100 pages.push_back(String::new());
2101 }
2102 pages
2103}
2104
2105fn dialog_config() -> MenuConfig {
2107 MenuConfig::new(
2108 DIALOG_AREA,
2109 None,
2110 TileRect::new(
2111 DIALOG_AREA.tx + 1,
2112 DIALOG_AREA.ty + 1,
2113 DIALOG_AREA.tw - 2,
2114 DIALOG_AREA.th - 2,
2115 ),
2116 Default::default(),
2117 )
2118}
2119
2120pub(crate) fn draw_textbox(fb: &mut FrameBuffer, text: &str) {
2122 let mut painter = FrameBufferPainter::new(fb);
2123 draw_dialog(text, &[dialog_config()], &mut painter);
2124}
2125
2126fn draw_end_card(fb: &mut FrameBuffer, game_name: &str, lang: &str) {
2129 let fin = if lang == "zh" { "完" } else { "fin." };
2130 let cx = SCREEN_W as u32 / 2;
2131 let name_w = embedded_font::measure_text(game_name);
2132 embedded_font::draw_text(
2133 game_name,
2134 cx.saturating_sub(name_w / 2),
2135 100,
2136 Rgba::rgb(0xf0, 0xf0, 0xf0),
2137 fb,
2138 );
2139 let fin_w = embedded_font::measure_text(fin);
2140 embedded_font::draw_text(
2141 fin,
2142 cx.saturating_sub(fin_w / 2),
2143 120,
2144 Rgba::rgb(0x90, 0x90, 0xa8),
2145 fb,
2146 );
2147}
2148
2149fn draw_choice_menu(fb: &mut FrameBuffer, options: &[String], cursor: usize) {
2152 let n = options.len() as u32;
2153 if n == 0 {
2154 return;
2155 }
2156 let max_len = options.iter().map(|o| o.chars().count()).max().unwrap_or(1) as u32;
2157 let w = (max_len + 4).clamp(8, 20);
2159 let h = n + 2;
2160 let tx = (40 - w) as i32;
2161 let ty = DIALOG_AREA.ty as i32 - h as i32;
2162 let config = MenuConfig::new(
2163 TileRect::new(tx.max(0) as u32, ty.max(0) as u32, w, h),
2164 None,
2165 TileRect::new(tx.max(0) as u32 + 1, ty.max(0) as u32 + 1, w - 2, n),
2166 Default::default(),
2167 );
2168 let state = FlexMenuState {
2169 cursor,
2170 scroll_offset: 0,
2171 };
2172 let mut painter = FrameBufferPainter::new(fb);
2173 let mut ui = Ui::new(&mut painter);
2174 draw_flex_menu(options, &[config], &state, options.len(), &mut ui);
2175}
2176
2177fn darken(fb: &mut FrameBuffer, factor: f32) {
2179 for px in fb.data.chunks_exact_mut(4) {
2180 px[0] = (px[0] as f32 * factor) as u8;
2181 px[1] = (px[1] as f32 * factor) as u8;
2182 px[2] = (px[2] as f32 * factor) as u8;
2183 }
2184}
2185
2186struct PersonColors {
2190 outline: Rgba,
2191 skin: Rgba,
2192 body: Rgba,
2193}
2194
2195const PLAYER_COLORS: PersonColors = PersonColors {
2197 outline: Rgba::rgb(0x30, 0x18, 0x18),
2198 skin: Rgba::rgb(0xF0, 0xC8, 0xA0),
2199 body: Rgba::rgb(0xC8, 0x30, 0x30),
2200};
2201
2202const NPC_BODIES: [(u8, u8, u8); 6] = [
2204 (0x30, 0x58, 0xC8), (0x38, 0x90, 0x40), (0x88, 0x48, 0xA8), (0xC8, 0x78, 0x28), (0x28, 0x98, 0x98), (0x80, 0x58, 0x38), ];
2211
2212fn npc_palette(id: u32) -> PersonColors {
2214 let (r, g, b) = NPC_BODIES[(id.wrapping_mul(2_654_435_761) >> 16) as usize % NPC_BODIES.len()];
2215 PersonColors {
2216 outline: Rgba::rgb(r / 3, g / 3, b / 3),
2217 skin: Rgba::rgb(0xF0, 0xC8, 0xA0),
2218 body: Rgba::rgb(r, g, b),
2219 }
2220}
2221
2222fn draw_person(
2227 fb: &mut FrameBuffer,
2228 sx: i32,
2229 sy: i32,
2230 tile: i32,
2231 facing: Direction,
2232 colors: &PersonColors,
2233) {
2234 const W: i32 = 12;
2235 const H: i32 = 14;
2236 let ox = sx + (tile - W) / 2;
2237 let oy = sy + tile - H;
2238 let (w, h) = (fb.width() as i32, fb.height() as i32);
2239 let mut put = |x: i32, y: i32, c: Rgba| {
2240 let (px, py) = (ox + x, oy + y);
2241 if px >= 0 && py >= 0 && px < w && py < h {
2242 fb.set_pixel(px as u32, py as u32, c);
2243 }
2244 };
2245 let fill = |x: i32, y: i32, fw: i32, fh: i32, c: Rgba, put: &mut dyn FnMut(i32, i32, Rgba)| {
2246 for dy in 0..fh {
2247 for dx in 0..fw {
2248 put(x + dx, y + dy, c);
2249 }
2250 }
2251 };
2252
2253 fill(3, 0, 6, 5, colors.outline, &mut put);
2255 fill(4, 1, 4, 3, colors.skin, &mut put);
2256 fill(2, 5, 8, 7, colors.outline, &mut put);
2257 fill(3, 6, 6, 5, colors.body, &mut put);
2258 fill(3, 12, 2, 2, colors.outline, &mut put);
2260 fill(7, 12, 2, 2, colors.outline, &mut put);
2261
2262 match facing {
2264 Direction::Down => {
2265 put(4, 2, colors.outline);
2266 put(7, 2, colors.outline);
2267 }
2268 Direction::Left => put(4, 2, colors.outline),
2269 Direction::Right => put(7, 2, colors.outline),
2270 Direction::Up => fill(4, 1, 4, 3, colors.outline, &mut put), }
2272}