diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c914fad2..81a5e6b31 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,7 @@ jobs: - doccheck - doctest - test + - assets include: - ci-argument: clippy toolchain-components: clippy diff --git a/Cargo.lock b/Cargo.lock index 7f1152d1b..451e1498d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1253,6 +1253,7 @@ name = "ci" version = "0.1.0" dependencies = [ "bevy", + "itertools", "xshell", ] diff --git a/tools/ci/Cargo.toml b/tools/ci/Cargo.toml index 70455dbaf..e4a2b2559 100644 --- a/tools/ci/Cargo.toml +++ b/tools/ci/Cargo.toml @@ -9,3 +9,4 @@ license = "MIT OR Apache-2.0" [dependencies] xshell = "0.2" bevy = "0.10" +itertools = "0.10" diff --git a/tools/ci/src/asset_loading.rs b/tools/ci/src/asset_loading.rs new file mode 100644 index 000000000..298b572ab --- /dev/null +++ b/tools/ci/src/asset_loading.rs @@ -0,0 +1,148 @@ +use bevy::{ + app::AppExit, + asset::LoadState, + audio::AudioPlugin, + core_pipeline::CorePipelinePlugin, + gltf::GltfPlugin, + pbr::PbrPlugin, + prelude::*, + render::RenderPlugin, + scene::ScenePlugin, + utils::{Duration, HashMap, Instant}, + window::{WindowClosed, WindowCreated, WindowResized}, +}; +use itertools::Itertools; + +use std::fmt::{Display, Formatter}; + +/// The path to the asset folder, from the root of the repository. +const ROOT_ASSET_FOLDER: &str = "emergence_game/assets"; + +/// The path to the asset folder from the perspective of the [`AssetPlugin`] is specified relative to the executable. +/// +/// As a result, we need to go up two levels to translate. +const PATH_ADAPTOR: &str = "../../"; + +pub(super) fn verify_assets_load() { + App::new() + .init_resource::() + .insert_resource(TimeOut { + start: Instant::now(), + max: Duration::from_secs(10), + }) + .add_plugins(MinimalPlugins) + // This must come before the asset format plugins for AssetServer to exist + .add_plugin(AssetPlugin { + asset_folder: format!("{PATH_ADAPTOR}{ROOT_ASSET_FOLDER}"), + watch_for_changes: false, + }) + // These plugins are required for the asset loaders to be detected. + // Without this, AssetServer::load_folder will return an empty list + // as file types without an associated loader registered are silently skipped. + .add_plugin(ScenePlugin) + .add_plugin(AudioPlugin) + .init_resource::>() + .init_resource::>() + .init_resource::>() + .add_plugin(RenderPlugin::default()) + .add_plugin(ImagePlugin::default()) + .add_plugin(CorePipelinePlugin) + .add_plugin(PbrPlugin::default()) + .add_plugin(GltfPlugin) + .add_startup_system(load_assets) + .add_system(check_if_assets_loaded) + .run() +} + +#[derive(Default, Resource, Debug, Clone, PartialEq)] +struct AssetHandles { + handles: HashMap, +} + +impl Display for AssetHandles { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let mut string = String::new(); + + // Sort the handles alphabetically by name + for name in self.handles.keys().sorted() { + let handle = self.handles.get(name).unwrap(); + + string += &format!(" {} - {:?}\n", name, handle.load_state); + } + + write!(f, "{string}") + } +} + +impl AssetHandles { + fn all_loaded(&self) -> bool { + self.handles + .values() + .all(|handle| handle.load_state == LoadState::Loaded) + } +} + +#[derive(Debug, Clone, PartialEq)] +struct HandleStatus { + handle: HandleUntyped, + load_state: LoadState, +} + +#[derive(Resource, Debug)] +struct TimeOut { + start: Instant, + max: Duration, +} + +impl TimeOut { + fn timed_out(&self) -> bool { + self.start.elapsed() > self.max + } +} + +fn load_assets(asset_server: Res, mut asset_handles: ResMut) { + // Try to load all assets + let all_handles = asset_server.load_folder(".").unwrap(); + assert!(!all_handles.is_empty()); + for handle in all_handles { + let asset_path = asset_server.get_handle_path(&handle).unwrap(); + asset_handles.handles.insert( + asset_path.path().to_str().unwrap().to_string(), + HandleStatus { + handle, + load_state: LoadState::NotLoaded, + }, + ); + } +} + +fn check_if_assets_loaded( + mut asset_handles: ResMut, + asset_server: Res, + mut app_exit_events: ResMut>, + time_out: Res, + mut previous_asset_handles: Local, +) { + *previous_asset_handles = asset_handles.clone(); + + for mut handle_status in asset_handles.handles.values_mut() { + if handle_status.load_state == LoadState::NotLoaded { + handle_status.load_state = + asset_server.get_load_state(handle_status.handle.clone_weak()); + } + } + + if asset_handles.all_loaded() { + println!("{}", *asset_handles); + println!("All assets loaded successfully, exiting."); + app_exit_events.send(AppExit); + } else { + if *asset_handles != *previous_asset_handles { + println!("{}", *asset_handles); + } + + if time_out.timed_out() { + panic!("Timed out waiting for assets to load."); + } + } +} diff --git a/tools/ci/src/main.rs b/tools/ci/src/main.rs index c41fb4ead..8b137311c 100644 --- a/tools/ci/src/main.rs +++ b/tools/ci/src/main.rs @@ -8,9 +8,12 @@ use std::process; +use bevy::prelude::*; use bevy::utils::HashSet; use xshell::{cmd, Shell}; +mod asset_loading; + /// The checks that can be run in CI. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] enum Check { @@ -20,6 +23,7 @@ enum Check { DocTest, DocCheck, CompileCheck, + ValidateAssets, } impl Check { @@ -32,6 +36,7 @@ impl Check { Check::DocTest, Check::DocCheck, Check::CompileCheck, + Check::ValidateAssets, ] .iter() .copied() @@ -47,6 +52,7 @@ impl Check { Check::DocTest => "doctest", Check::DocCheck => "doccheck", Check::CompileCheck => "compilecheck", + Check::ValidateAssets => "assets", } } @@ -59,6 +65,7 @@ impl Check { "doctest" => Some(Check::DocTest), "doccheck" => Some(Check::DocCheck), "compilecheck" => Some(Check::CompileCheck), + "assets" => Some(Check::ValidateAssets), _ => None, } } @@ -140,6 +147,11 @@ fn main() { .run() .expect("Please fix compiler errors in above output."); } + + if what_to_run.contains(&Check::ValidateAssets) { + info!("Starting Bevy app to check assets..."); + asset_loading::verify_assets_load(); + } } #[cfg(test)]