1#![warn(clippy::exhaustive_structs, clippy::exhaustive_enums)]
127
128mod audio;
129mod backend;
130#[cfg(feature = "capture")]
131pub mod capture;
132pub mod crash;
133mod facts;
134mod focus;
135#[cfg(not(target_arch = "wasm32"))]
136mod folder;
137pub mod keys;
138#[cfg(all(feature = "link", not(target_arch = "wasm32")))]
139mod link;
140mod pacing;
141pub mod pads;
142pub mod page;
143mod painted;
144#[cfg(not(target_arch = "wasm32"))]
145mod perf_file;
146mod pictures;
147#[cfg(any(
148 all(feature = "link", not(target_arch = "wasm32")),
149 feature = "capture"
150))]
151mod readback;
152pub mod render;
153mod sessions;
154pub mod storage;
155#[cfg(not(target_arch = "wasm32"))]
156mod watch;
157
158use core::fmt::Debug;
159
160use macroquad::prelude::*;
161use shuyi_assets::audio::{Bytes, Files};
162use shuyi_assets::keymap::ActionSet;
163use shuyi_platform::{App, BasicGame, FrameOutcome, Millis, Platform, Start, TextInput};
164use shuyi_ui::GameData;
165use shuyi_ui::basic::FONT_PNG;
166use shuyi_ui::log::{LogLevel, LogLine};
167
168#[cfg(feature = "sound-probe")]
169pub use crate::audio::Chosen as SoundProbeBackend;
170pub use crate::audio::voice::read_manifest as read_voice_manifest;
171use crate::backend::Macroquad;
172pub use crate::crash::CrashReports;
173#[cfg(not(target_arch = "wasm32"))]
174pub(crate) use crate::folder::watched_file;
175#[cfg(not(target_arch = "wasm32"))]
176pub use crate::folder::{FolderError, read_folder, watched_layer};
177use crate::pacing::{Pacer, WebFrame};
178
179#[cfg(target_arch = "wasm32")]
181pub fn watched_layer() -> Option<shuyi_assets::bundle::Layer> {
182 None
183}
184
185#[cfg(target_arch = "wasm32")]
187pub(crate) fn watched_file(_: &str) -> Option<&'static [u8]> {
188 None
189}
190
191#[derive(Debug, Clone, Copy)]
205#[non_exhaustive]
206pub struct Window {
207 pub title: &'static str,
209 pub width: i32,
211 pub height: i32,
213 pub icon: Option<Icon>,
215 pub vsync: bool,
231}
232
233impl Window {
234 #[must_use]
238 pub const fn new(title: &'static str, width: i32, height: i32) -> Self {
239 Self {
240 title,
241 width,
242 height,
243 icon: None,
244 vsync: true,
245 }
246 }
247
248 #[must_use]
251 pub const fn with_icon(self, icon: Option<Icon>) -> Self {
252 Self { icon, ..self }
253 }
254
255 #[must_use]
258 pub const fn with_vsync(self, vsync: bool) -> Self {
259 Self { vsync, ..self }
260 }
261}
262
263pub use shuyi_platform::WindowIcon as Icon;
266
267#[derive(Debug, Clone, Copy)]
271#[non_exhaustive]
272pub struct VoiceFiles {
273 pub folder: &'static str,
275 pub lang: &'static str,
277 pub manifest: &'static str,
279}
280
281impl VoiceFiles {
282 #[must_use]
285 pub const fn new(folder: &'static str, lang: &'static str, manifest: &'static str) -> Self {
286 Self {
287 folder,
288 lang,
289 manifest,
290 }
291 }
292}
293
294#[non_exhaustive]
297pub struct AppConfig {
298 pub window: Window,
300 pub storage_name: &'static str,
304 pub files: fn(&str) -> Option<&'static [u8]>,
309 pub font_png: &'static str,
311 pub sounds: Files<'static>,
314 pub voices: Option<VoiceFiles>,
316 pub text_input: Option<Box<dyn TextInput>>,
320 pub crash: CrashReports,
324}
325
326impl AppConfig {
327 #[must_use]
331 pub const fn new(
332 window: Window,
333 storage_name: &'static str,
334 files: fn(&str) -> Option<&'static [u8]>,
335 font_png: &'static str,
336 sounds: Files<'static>,
337 ) -> Self {
338 Self {
339 window,
340 storage_name,
341 files,
342 font_png,
343 sounds,
344 voices: None,
345 text_input: None,
346 crash: CrashReports::new(""),
347 }
348 }
349}
350
351pub fn window_conf(window: &Window) -> Conf {
354 Conf {
355 window_title: window.title.to_owned(),
356 window_width: window.width,
357 window_height: window.height,
358 window_resizable: true,
359 high_dpi: true,
362 icon: window.icon.and_then(|icon| window_icon(&icon)),
363 platform: miniquad::conf::Platform {
364 swap_interval: Some(i32::from(window.vsync)),
366 ..Default::default()
367 },
368 ..Default::default()
369 }
370}
371
372fn window_icon(icon: &Icon) -> Option<miniquad::conf::Icon> {
375 fn pixels<const N: usize>(png: &[u8]) -> Option<[u8; N]> {
376 let image = Image::from_file_with_format(png, None).ok()?;
377 image.bytes.try_into().ok()
378 }
379 Some(miniquad::conf::Icon {
380 small: pixels(icon.small)?,
381 medium: pixels(icon.medium)?,
382 big: pixels(icon.big)?,
383 })
384}
385
386pub async fn run<G: GameData>(mut config: AppConfig, start: Start<G>) {
412 let (start, facts) = facts::start_as(facts::this_target(), start);
414 #[cfg(feature = "capture")]
415 if let Some(plan) = capture::requested() {
416 capture::run(config, start, plan, facts).await;
417 return;
418 }
419 let voice_dir = config
421 .voices
422 .map(|v| audio::voice::platform_voice_dir(v.folder, v.lang));
423 let mut voice_manifest = None;
424 if let (Some(voices), Some(dir)) = (&config.voices, &voice_dir) {
425 match read_voice_manifest(dir, voices.manifest).await {
426 Ok(manifest) => voice_manifest = manifest,
427 Err(e) => {
428 warn!("no voices: {}", e);
429 }
430 }
431 }
432 let recorded_manifest = voice_manifest.clone();
433 let start = start.before_load(move |setup| setup.voice_manifest = voice_manifest);
434 backend::tag_decodes();
436 let storage_name = config.storage_name;
437 crash::install(config.crash, move || storage::platform(storage_name));
438 let panic_at = crash::panic_at_frame();
439 let mut platform = Macroquad::new(&mut config, facts);
440 let mut app = App::new(start);
441 let kept = sessions::Kept::start(&mut app, config.storage_name, recorded_manifest);
442 let mut pacer = Pacer::new(config.window.vsync);
443 #[cfg(all(feature = "link", not(target_arch = "wasm32")))]
444 let mut link = link::start(config.window.title);
445 #[cfg(not(target_arch = "wasm32"))]
446 let mut perf_file = perf_file::start();
447 loop {
448 let start = pacing::seconds();
449 if cfg!(target_arch = "wasm32") {
450 if pacer.web_frame(start) == WebFrame::Skip {
451 platform.hold_input();
452 app.present_again(&mut platform);
453 next_frame().await;
454 continue;
455 }
456 app.set_pacing(pacer.pacing());
457 } else {
458 pacer.start(start);
459 }
460 let now = Millis::from_secs_f64(start);
462 #[cfg(all(feature = "link", not(target_arch = "wasm32")))]
463 if let Some(link) = &mut link {
464 link.before_frame(&app, platform.link_input());
465 }
466 let outcome = frame_or_crash(&mut app, &mut platform, now, panic_at);
467 if app.take_session_save() {
468 kept.save(&app);
469 }
470 #[cfg(all(feature = "link", not(target_arch = "wasm32")))]
471 if let Some(link) = &mut link {
472 answer_link(link, &app, &mut platform);
473 }
474 for line in app.take_new_log() {
476 print_line(&line);
477 let (level, category) = (line.level.name(), line.category.name());
478 crash::note_log(format!("f{} {level} {category}: {}", line.frame, line.text));
479 }
480 #[cfg(not(target_arch = "wasm32"))]
481 if let Some(file) = perf_file.take() {
482 let frame = app.frame_number();
483 perf_file = file.write(&app.take_new_perf(), frame, || {
484 app.heap_snapshot(&mut platform)
485 });
486 }
487 if !platform.started()
488 && let Some(game) = app.game()
489 {
490 let voice_dir = voice_dir.clone().unwrap_or_default();
491 platform.start(game, &config.sounds, voice_dir).await;
492 }
493 if outcome == FrameOutcome::Quit && !cfg!(target_arch = "wasm32") {
496 kept.save_at_quit(&app);
497 break;
498 }
499 pace(&mut pacer, &platform, &mut app);
500 next_frame().await;
501 }
502}
503
504fn frame_or_crash<G: GameData>(
509 app: &mut App<G>,
510 platform: &mut Macroquad,
511 now: Millis,
512 panic_at: Option<u32>,
513) -> FrameOutcome {
514 let frame = app.frame_number().saturating_add(1);
515 crash::set_frame(frame, app.game().and_then(shuyi_ui::Game::top_screen));
516 let run = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
517 assert!(panic_at != Some(frame), "{}", crash::PANIC_ON_PURPOSE);
518 app.frame(platform, now)
519 }));
520 if let Ok(outcome) = run {
521 return outcome;
522 }
523 platform.audio().stop_all();
524 let record = crash::quiet(|| {
525 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| app.crash_record()))
526 });
527 crash::add_record(record.ok().flatten());
528 std::process::exit(101);
529}
530
531fn pace<G: GameData>(pacer: &mut Pacer, platform: &Macroquad, app: &mut App<G>) {
535 let (cap, serial) = platform.frame_cap();
536 pacer.set_cap(cap, serial);
537 if cfg!(target_arch = "wasm32") {
538 return;
539 }
540 let wait = pacer.wait(pacing::seconds());
541 pacing::sleep(wait, pacing::seconds);
542 pacer.waited(pacing::seconds());
543 app.set_pacing(pacer.pacing());
544}
545
546#[cfg(all(feature = "link", not(target_arch = "wasm32")))]
550fn answer_link<G: GameData>(link: &mut link::Listener, app: &App<G>, platform: &mut Macroquad) {
551 link.after_frame(app);
552 if link.wants_screenshot() {
553 match readback::window_png() {
554 Ok((png, width, height)) => link.screenshot(&png, width, height),
555 Err(e) => link.no_screenshot(e),
556 }
557 }
558 if link.wants_snapshot() {
559 let snapshot = app.heap_snapshot(platform);
560 link.snapshot(app, snapshot);
561 }
562}
563
564pub fn run_game<A: ActionSet, T: Debug + 'static>(game: BasicGame<A, T>) {
571 let game = match watched_layer() {
572 Some(layer) => game.over(layer),
573 None => game,
574 };
575 let _ = shuyi_assets::bundle::install(game.bundle());
579 let window = basic_window(&game);
580 macroquad::Window::from_config(window_conf(&window), run(basic_config(&game), game.start()));
582}
583
584const BASIC_MARGIN: i32 = 16;
586
587fn basic_window<A: ActionSet, T: Debug + 'static>(game: &BasicGame<A, T>) -> Window {
591 let console = game.console_spec();
592 let side = |cells: u16, cell: u16| i32::from(cells) * i32::from(cell) * 2 + BASIC_MARGIN;
593 Window::new(
594 game.name(),
595 side(console.cols, console.cell_w),
596 side(console.rows, console.cell_h),
597 )
598 .with_icon(game.app().window_icon)
599}
600
601fn installed_file(path: &str) -> Option<&'static [u8]> {
603 shuyi_assets::bundle::installed()?.bytes(path)
604}
605
606fn no_sound(_: &str) -> Bytes {
608 None
609}
610
611fn sound_shown(name: &str) -> String {
613 name.to_owned()
614}
615
616fn sound_left_out(_: &str) -> bool {
618 false
619}
620
621fn basic_config<A: ActionSet, T: Debug + 'static>(game: &BasicGame<A, T>) -> AppConfig {
626 let sounds = Files::new(&no_sound, &sound_shown, &sound_left_out, "music");
627 let mut config = AppConfig::new(
628 basic_window(game),
629 game.storage_name(),
630 installed_file,
631 FONT_PNG,
632 sounds,
633 );
634 config.crash = CrashReports::new(game.app().version);
635 config
636}
637
638fn print_line(line: &LogLine) {
640 let (category, text) = (line.category.name(), &line.text);
641 match line.level {
642 LogLevel::Error => {
643 error!("{}: {}", category, text);
644 }
645 LogLevel::Warn => {
646 warn!("{}: {}", category, text);
647 }
648 LogLevel::Info => {
649 info!("{}: {}", category, text);
650 }
651 _ => {
652 debug!("{}: {}", category, text);
653 }
654 }
655}
656
657const ERROR_WIDTH: usize = 150;
659
660fn wrap(text: &str, width: usize) -> String {
663 let mut out = Vec::new();
664 for line in text.lines() {
665 let mut current = String::new();
666 for word in line.split(' ') {
667 let fits = current.chars().count() + 1 + word.chars().count() <= width;
668 if !current.is_empty() && !fits {
669 out.push(std::mem::take(&mut current));
670 }
671 if !current.is_empty() {
672 current.push(' ');
673 }
674 current.push_str(word);
675 }
676 out.push(current);
677 }
678 out.join("\n")
679}
680
681#[cfg(test)]
682mod tests {
683 use shuyi_platform::AppInfo;
684
685 use super::*;
686
687 #[test]
688 fn long_lines_are_broken_at_spaces() {
689 assert_eq!(wrap("aa bb cc", 5), "aa bb\ncc");
690 assert_eq!(wrap("aa bb cc", 4), "aa\nbb\ncc");
691 assert_eq!(wrap("aa bb cc", 8), "aa bb cc");
692 assert_eq!(wrap("aa bb cc", 7), "aa bb\ncc");
693 assert_eq!(wrap("abcdefgh ij", 3), "abcdefgh\nij");
694 assert_eq!(wrap("one\n\ntwo three", 5), "one\n\ntwo\nthree");
695 assert_eq!(wrap("", 5), "");
696 }
697
698 #[test]
699 fn an_icon_that_does_not_decode_is_none() {
700 let broken = Icon::new(b"not a png", b"not a png", b"not a png");
701 assert!(window_icon(&broken).is_none());
702 let window = Window::new("A game", 640, 480).with_icon(Some(broken));
703 let conf = window_conf(&window);
704 assert!(conf.icon.is_none());
705 assert_eq!(
706 (
707 conf.window_title.as_str(),
708 conf.window_width,
709 conf.window_height
710 ),
711 ("A game", 640, 480)
712 );
713 }
714
715 const ICONED: Window =
717 Window::new("A game", 640, 480).with_icon(Some(Icon::new(b"small", b"medium", b"big")));
718
719 const NO_VSYNC: Window = Window::new("A game", 640, 480).with_vsync(false);
721
722 #[test]
723 fn a_window_asks_for_vsync_unless_told_not_to() {
724 let window = Window::new("A game", 640, 480);
725 assert!(window.vsync);
726 assert_eq!(window_conf(&window).platform.swap_interval, Some(1));
727 let off = NO_VSYNC;
728 assert!(!off.vsync);
729 assert_eq!(window_conf(&off).platform.swap_interval, Some(0));
730 assert!(off.with_vsync(true).vsync);
731 assert!(basic_window(&basic()).vsync);
732 }
733
734 #[test]
735 fn a_new_window_has_no_icon_and_with_icon_sets_one() {
736 let window = Window::new("A game", 640, 480);
737 assert_eq!(
738 (window.title, window.width, window.height),
739 ("A game", 640, 480)
740 );
741 assert!(window.icon.is_none());
742 let icon = ICONED.icon.unwrap();
743 assert_eq!(
744 (icon.small, icon.medium, icon.big),
745 (&b"small"[..], &b"medium"[..], &b"big"[..])
746 );
747 assert!(ICONED.with_icon(None).icon.is_none());
748 }
749
750 #[test]
751 fn a_new_config_has_no_voices_and_no_system_keyboard() {
752 let mut config = basic_config(&basic());
753 config = AppConfig::new(
754 config.window,
755 "a-game",
756 installed_file,
757 FONT_PNG,
758 config.sounds,
759 );
760 assert!(config.voices.is_none() && config.text_input.is_none());
761 config.voices = Some(VoiceFiles::new("voice", "en", "voice.ron"));
762 let voices = config.voices.unwrap();
763 assert_eq!(
764 (voices.folder, voices.lang, voices.manifest),
765 ("voice", "en", "voice.ron")
766 );
767 }
768
769 fn basic() -> BasicGame<shuyi_assets::keymap::UiAction> {
771 let files = shuyi_assets::bundle::Layer::table("files", &[]);
772 let app = AppInfo::new("A game", "com.example.a-game", "0.1.0").with_storage("a-game");
773 BasicGame::new(app, files, |_| unreachable!("never started"))
774 }
775
776 #[test]
777 fn a_short_path_games_window_is_its_console_twice_and_a_margin() {
778 let window = basic_window(&basic());
779 assert_eq!(
780 (window.title, window.width, window.height),
781 ("A game", 784, 784)
782 );
783 assert!(window.icon.is_none());
784 let small = basic().console(shuyi_ui::frame::ConsoleSpec::text(20, 10));
785 let window = basic_window(&small);
786 assert_eq!((window.width, window.height), (336, 336));
787 }
788
789 #[test]
790 fn a_short_path_games_crash_reports_name_its_version() {
791 assert_eq!(basic_config(&basic()).crash, CrashReports::new("0.1.0"));
792 assert_eq!(basic_config(&basic()).crash.game_version, "0.1.0");
793 }
794
795 const fn const_config(sounds: Files<'static>) -> AppConfig {
797 AppConfig::new(
798 Window::new("A game", 640, 480),
799 "a-game",
800 installed_file,
801 FONT_PNG,
802 sounds,
803 )
804 }
805
806 #[test]
807 fn a_new_config_writes_reports_with_no_version_and_off_keeps_none() {
808 let sounds = Files::new(&no_sound, &sound_shown, &sound_left_out, "music");
809 let config = const_config(sounds);
810 assert_eq!(config.crash, CrashReports::default());
811 assert_eq!((config.crash.game_version, config.crash.keep), ("", 5));
812 assert_eq!(CrashReports::off().keep, 0);
813 }
814
815 #[test]
816 fn a_short_path_games_storage_is_its_ids_unless_it_says() {
817 let files = shuyi_assets::bundle::Layer::table("files", &[]);
818 let app = AppInfo::new("A game", "com.example.a-game", "0.1.0");
819 let game: BasicGame<shuyi_assets::keymap::UiAction> =
820 BasicGame::new(app, files, |_| unreachable!("never started"));
821 assert_eq!(basic_config(&game).storage_name, "com.example.a-game");
822 assert_eq!(basic_window(&game).title, "A game");
823 }
824
825 #[test]
826 fn a_short_path_games_window_has_its_apps_icon() {
827 let files = shuyi_assets::bundle::Layer::table("files", &[]);
828 let icon = Icon::new(b"small", b"medium", b"big");
829 let app =
830 AppInfo::new("A game", "com.example.a-game", "0.1.0").with_window_icon(Some(icon));
831 let game: BasicGame<shuyi_assets::keymap::UiAction> =
832 BasicGame::new(app, files, |_| unreachable!("never started"));
833 assert_eq!(basic_window(&game).icon, Some(icon));
834 assert_eq!(basic_config(&game).window.icon, Some(icon));
835 }
836
837 #[test]
838 fn a_short_path_games_config_is_silent_and_reads_its_font() {
839 let config = basic_config(&basic());
840 assert_eq!(config.storage_name, "a-game");
841 assert_eq!(config.font_png, "font/atlas.png");
842 assert_eq!(config.window.title, "A game");
843 assert_eq!((config.sounds.sound)("sfx/move.wav"), None);
844 assert_eq!((config.sounds.sound_shown)("sfx/move.wav"), "sfx/move.wav");
845 assert!(!(config.sounds.sound_left_out)("sfx/move.wav"));
846 assert!(config.sounds.music.is_none());
847 assert!(config.voices.is_none() && config.text_input.is_none());
848 assert_eq!((config.files)("font/atlas.png"), None);
850 }
851
852 #[test]
856 fn only_the_link_feature_reads_shuyi_link() {
857 let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
858 let mut files = vec![src.clone()];
859 let mut naming = Vec::new();
860 while let Some(path) = files.pop() {
861 if path.is_dir() {
862 files.extend(std::fs::read_dir(&path).unwrap().map(|e| e.unwrap().path()));
863 } else if path.extension().is_some_and(|e| e == "rs") {
864 let text = std::fs::read_to_string(&path).unwrap();
865 if text.contains("SHUYI_LINK") {
866 naming.push(path.strip_prefix(&src).unwrap().to_owned());
867 }
868 }
869 }
870 naming.sort();
871 let link = std::path::PathBuf::from("link.rs");
872 let lib = std::path::PathBuf::from("lib.rs");
873 assert_eq!(naming, [lib, link]);
874 let here = include_str!("lib.rs");
875 let gate = "#[cfg(all(feature = \"link\", not(target_arch = \"wasm32\")))]
876mod link;";
877 assert!(here.contains(gate));
878 }
879
880 fn table<'a>(manifest: &'a str, header: &str) -> Vec<&'a str> {
883 let lines = manifest.lines().skip_while(|l| l.trim() != header).skip(1);
884 lines
885 .take_while(|l| !l.starts_with('['))
886 .filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
887 .collect()
888 }
889
890 #[test]
894 fn the_lints_are_the_workspace_lints_except_unsafe_code() {
895 let here = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
896 let read = |path: &std::path::Path| std::fs::read_to_string(path).unwrap();
897 let root = here.parent().unwrap().parent().unwrap();
898 let workspace = read(&root.join("Cargo.toml"));
899 let clippy = table(&workspace, "[workspace.lints.clippy]");
900 assert!(clippy.len() > 5, "{clippy:?}");
901 let rust: Vec<String> = table(&workspace, "[workspace.lints.rust]")
902 .iter()
903 .map(|l| l.replace("unsafe_code = \"forbid\"", "unsafe_code = \"deny\""))
904 .collect();
905 assert!(
906 rust.contains(&"unsafe_code = \"deny\"".to_owned()),
907 "{rust:?}"
908 );
909 let manifest = read(&here.join("Cargo.toml"));
910 assert_eq!(table(&manifest, "[lints.clippy]"), clippy);
911 assert_eq!(table(&manifest, "[lints.rust]"), rust);
912 for other in [
913 "crates/assets",
914 "crates/cli",
915 "crates/core",
916 "crates/platform",
917 "crates/ui",
918 "examples/hello-grid",
919 ] {
920 let manifest = read(&root.join(other).join("Cargo.toml"));
921 assert_eq!(table(&manifest, "[lints]"), ["workspace = true"], "{other}");
922 }
923 }
924}