bones_bevy_renderer/
lib.rs

1//! Bevy plugin for rendering Bones framework games.
2
3#![warn(missing_docs)]
4// This cfg_attr is needed because `rustdoc::all` includes lints not supported on stable
5#![cfg_attr(doc, allow(unknown_lints))]
6#![deny(rustdoc::all)]
7
8pub use bevy;
9
10/// The prelude
11pub mod prelude {
12    pub use crate::*;
13}
14
15pub mod debug;
16/// Contains the filesystem storage backend types.
17pub mod storage;
18
19/// Traits and implementations for converting between bevy and bones.
20pub mod convert;
21use convert::*;
22
23/// Input syncing and getting.
24pub mod input;
25use input::*;
26
27/// Contains the systems for extracting, syncing, and loading bones renderables.
28pub mod render;
29use render::*;
30
31/// Systems for syncing and modifying egui. Contains the default game loading ui system.
32pub mod ui;
33use ui::*;
34
35/// Systems for syncing bones rumble controls.
36pub mod rumble;
37use bevy::{log::LogPlugin, prelude::*};
38use bones::GamepadsRumble;
39use bones_framework::prelude as bones;
40use rumble::*;
41
42use bevy::{
43    input::InputSystem,
44    render::RenderApp,
45    sprite::{extract_sprites, SpriteSystem},
46    tasks::IoTaskPool,
47    utils::Instant,
48};
49use std::path::{Path, PathBuf};
50
51/// Renderer for [`bones_framework`] [`Game`][bones::Game]s using Bevy.
52pub struct BonesBevyRenderer {
53    /// Whether or not to load all assets on startup with a loading screen,
54    /// or skip straight to running the bones game immedietally.
55    pub preload: bool,
56    /// Optional field to implement your own loading screen. Does nothing if [`Self::preload`] = false
57    pub custom_load_progress: Option<LoadingFunction>,
58    /// Whether or not to use nearest-neighbor sampling for textures.
59    pub pixel_art: bool,
60    /// The bones game to run.
61    pub game: bones::Game,
62    /// The version of the game, used for the asset loader.
63    pub game_version: bones::Version,
64    /// The (qualifier, organization, application) that will be used to pick a persistent storage
65    /// location for the game.
66    ///
67    /// For example: `("org", "fishfolk", "jumpy")`
68    pub app_namespace: (String, String, String),
69    /// The path to load assets from.
70    pub asset_dir: PathBuf,
71    /// The path to load asset packs from.
72    pub packs_dir: PathBuf,
73}
74
75/// Bevy resource containing the [`bones::Game`]
76#[derive(Resource, Deref, DerefMut)]
77pub struct BonesGame(pub bones::Game);
78impl BonesGame {
79    /// Shorthand for [`bones::AssetServer`] typed access to the shared resource
80    pub fn asset_server(&self) -> Option<bones::Ref<'_, bones::AssetServer>> {
81        self.0.get_shared_resource()
82    }
83}
84
85#[derive(Resource, Deref, DerefMut)]
86struct LoadingContext(pub Option<LoadingFunction>);
87type LoadingFunction =
88    Box<dyn FnMut(&bones::AssetServer, &bevy_egui::egui::Context) + Sync + Send + 'static>;
89
90impl BonesBevyRenderer {
91    // TODO: Create a better builder pattern struct for `BonesBevyRenderer`.
92    // We want to use a nice builder-pattern struct for `BonesBevyRenderer` so that it is easier
93    // to set options like the `pixel_art` flag or the `game_version`.
94    /// Create a new [`BonesBevyRenderer`] for the provided game.
95    pub fn new(game: bones::Game) -> Self {
96        BonesBevyRenderer {
97            preload: true,
98            pixel_art: true,
99            custom_load_progress: None,
100            game,
101            game_version: bones::Version::new(0, 1, 0),
102            app_namespace: ("local".into(), "developer".into(), "bones_demo_game".into()),
103            asset_dir: PathBuf::from("assets"),
104            packs_dir: PathBuf::from("packs"),
105        }
106    }
107    /// Whether or not to load all assets on startup with a loading screen,
108    /// or skip straight to running the bones game immedietally.
109    pub fn preload(self, preload: bool) -> Self {
110        Self { preload, ..self }
111    }
112    /// Insert a custom loading screen function that will be used in place of the default
113    pub fn loading_screen(mut self, function: LoadingFunction) -> Self {
114        self.custom_load_progress = Some(function);
115        self
116    }
117    /// Whether or not to use nearest-neighbor sampling for textures.
118    pub fn pixel_art(self, pixel_art: bool) -> Self {
119        Self { pixel_art, ..self }
120    }
121    /// The (qualifier, organization, application) that will be used to pick a persistent storage
122    /// location for the game.
123    ///
124    /// For example: `("org", "fishfolk", "jumpy")`
125    pub fn namespace(mut self, (qualifier, organization, application): (&str, &str, &str)) -> Self {
126        self.app_namespace = (qualifier.into(), organization.into(), application.into());
127        self
128    }
129    /// The path to load assets from.
130    pub fn asset_dir(self, asset_dir: PathBuf) -> Self {
131        Self { asset_dir, ..self }
132    }
133    /// The path to load asset packs from.
134    pub fn packs_dir(self, packs_dir: PathBuf) -> Self {
135        Self { packs_dir, ..self }
136    }
137    /// Set the version of the game, used for the asset loader.
138    pub fn version(self, game_version: bones::Version) -> Self {
139        Self {
140            game_version,
141            ..self
142        }
143    }
144
145    /// Return a bevy [`App`] configured to run the bones game.
146    pub fn app(mut self) -> App {
147        let mut app = App::new();
148
149        // Initialize Bevy plugins we use
150        let mut plugins = DefaultPlugins
151            .set(WindowPlugin {
152                primary_window: Some(Window {
153                    fit_canvas_to_parent: true,
154                    ..default()
155                }),
156                ..default()
157            })
158            .disable::<LogPlugin>()
159            .build();
160        if self.pixel_art {
161            plugins = plugins.set(ImagePlugin::default_nearest());
162            // app.insert_resource(Msaa::Off);
163        }
164
165        app.add_plugins(plugins).add_plugins((
166            bevy_egui::EguiPlugin,
167            bevy_prototype_lyon::plugin::ShapePlugin,
168            debug::BevyDebugPlugin,
169        ));
170        if self.pixel_art {
171            app.insert_resource({
172                let mut egui_settings = bevy_egui::EguiSettings::default();
173                egui_settings.use_nearest_descriptor();
174                egui_settings
175            });
176        }
177        app.init_resource::<BonesImageIds>();
178
179        if let Some(mut asset_server) = self.game.get_shared_resource_mut::<bones::AssetServer>() {
180            asset_server.set_game_version(self.game_version);
181            asset_server.set_io(asset_io(&self.asset_dir, &self.packs_dir));
182
183            if self.preload {
184                // Spawn the task to load game assets
185                let s = asset_server.clone();
186                IoTaskPool::get()
187                    .spawn(async move {
188                        s.load_assets().await.unwrap();
189                    })
190                    .detach();
191            }
192
193            // Enable asset hot reload.
194            asset_server.watch_for_changes();
195        }
196
197        // Configure and load the persitent storage
198        let mut storage = bones::Storage::with_backend(Box::new(storage::StorageBackend::new(
199            &self.app_namespace.0,
200            &self.app_namespace.1,
201            &self.app_namespace.2,
202        )));
203        storage.load();
204        self.game.insert_shared_resource(storage);
205        self.game
206            .insert_shared_resource(bones::EguiTextures::default());
207
208        // Insert rumble resource and add system
209        self.game.init_shared_resource::<GamepadsRumble>();
210        app.add_systems(
211            Update,
212            handle_bones_rumble.run_if(assets_are_loaded.or_else(move || !self.preload)),
213        );
214
215        // Insert empty inputs that will be updated by the `insert_bones_input` system later.
216        self.game.init_shared_resource::<bones::KeyboardInputs>();
217        self.game.init_shared_resource::<bones::MouseInputs>();
218        self.game.init_shared_resource::<bones::GamepadInputs>();
219
220        #[cfg(not(target_arch = "wasm32"))]
221        {
222            self.game.init_shared_resource::<bones::ExitBones>();
223            app.add_systems(Update, handle_exits);
224        }
225
226        // Insert the bones data
227        app.insert_resource(BonesGame(self.game))
228            .insert_resource(LoadingContext(self.custom_load_progress))
229            .init_resource::<BonesGameEntity>();
230
231        // Add the world sync systems
232        app.add_systems(
233            PreUpdate,
234            (
235                setup_egui,
236                get_bones_input.pipe(insert_bones_input).after(InputSystem),
237                get_mouse_position
238                    .pipe(insert_mouse_position)
239                    .after(InputSystem),
240                egui_input_hook,
241            )
242                .chain()
243                .run_if(assets_are_loaded.or_else(move || !self.preload))
244                .after(bevy_egui::EguiSet::ProcessInput)
245                .before(bevy_egui::EguiSet::BeginFrame),
246        );
247
248        if self.preload {
249            app.add_systems(Update, asset_load_status.run_if(assets_not_loaded));
250        }
251        app.add_systems(
252            Update,
253            (
254                load_egui_textures,
255                sync_bones_window,
256                handle_asset_changes,
257                // Run world simulation
258                step_bones_game,
259                // Synchronize bones render components with the Bevy world.
260                (
261                    sync_egui_settings,
262                    sync_clear_color,
263                    sync_cameras,
264                    sync_bones_path2ds,
265                ),
266            )
267                .chain()
268                .run_if(assets_are_loaded.or_else(move || !self.preload))
269                .run_if(egui_ctx_initialized),
270        );
271
272        if let Ok(render_app) = app.get_sub_app_mut(RenderApp) {
273            render_app.add_systems(
274                ExtractSchedule,
275                (extract_bones_sprites, extract_bones_tilemaps)
276                    .in_set(SpriteSystem::ExtractSprites)
277                    .after(extract_sprites),
278            );
279        }
280
281        app
282    }
283}
284
285fn egui_ctx_initialized(game: Res<BonesGame>) -> bool {
286    game.get_shared_resource::<bones::EguiCtx>().is_some()
287}
288
289fn assets_are_loaded(game: Res<BonesGame>) -> bool {
290    // Game is not required to have AssetServer, so default to true.
291    game.asset_server()
292        .as_ref()
293        .map(|x| x.load_progress.is_finished())
294        .unwrap_or(true)
295}
296
297fn assets_not_loaded(game: Res<BonesGame>) -> bool {
298    game.asset_server()
299        .as_ref()
300        .map(|x| !x.load_progress.is_finished())
301        .unwrap_or(true)
302}
303
304/// A [`bones::AssetIo`] configured for web and local file access
305pub fn asset_io(asset_dir: &Path, packs_dir: &Path) -> impl bones::AssetIo + 'static {
306    #[cfg(not(target_arch = "wasm32"))]
307    {
308        bones::FileAssetIo::new(asset_dir, packs_dir)
309    }
310    #[cfg(target_arch = "wasm32")]
311    {
312        let _ = asset_dir;
313        let _ = packs_dir;
314        let window = web_sys::window().unwrap();
315        let path = window.location().pathname().unwrap();
316        let base = path.rsplit_once('/').map(|x| x.0).unwrap_or(&path);
317        bones::WebAssetIo::new(&format!("{base}/assets"))
318    }
319}
320
321fn asset_load_status(
322    game: Res<BonesGame>,
323    mut custom_load_context: ResMut<LoadingContext>,
324    mut egui_query: Query<&mut bevy_egui::EguiContext, With<Window>>,
325) {
326    let Some(asset_server) = &game.asset_server() else {
327        return;
328    };
329
330    let mut ctx = egui_query.single_mut();
331    if let Some(function) = &mut **custom_load_context {
332        (function)(asset_server, ctx.get_mut());
333    } else {
334        default_load_progress(asset_server, ctx.get_mut());
335    }
336}
337
338fn load_egui_textures(
339    mut has_initialized: Local<bool>,
340    game: ResMut<BonesGame>,
341    mut bones_image_ids: ResMut<BonesImageIds>,
342    mut bevy_images: ResMut<Assets<Image>>,
343    mut bevy_egui_textures: ResMut<bevy_egui::EguiUserTextures>,
344) {
345    if !*has_initialized {
346        *has_initialized = true;
347    } else {
348        return;
349    }
350    if let Some(asset_server) = &game.asset_server() {
351        let bones_egui_textures_cell = game.shared_resource_cell::<bones::EguiTextures>().unwrap();
352        // TODO: Avoid doing this every frame when there have been no assets loaded.
353        // We should should be able to use the asset load progress event listener to detect newly
354        // loaded assets that will need to be handled.
355        let mut bones_egui_textures = bones_egui_textures_cell.borrow_mut().unwrap();
356        // Take all loaded image assets and conver them to external images that reference bevy handles
357        bones_image_ids.load_bones_images(
358            asset_server,
359            &mut bones_egui_textures,
360            &mut bevy_images,
361            &mut bevy_egui_textures,
362        );
363    }
364}
365
366/// System to step the bones simulation.
367fn step_bones_game(world: &mut World) {
368    world.resource_scope(|world: &mut World, mut game: Mut<BonesGame>| {
369        let time = world.get_resource::<Time>().unwrap();
370        game.step(time.last_update().unwrap_or_else(Instant::now));
371    });
372}
373
374/// System for handling asset changes in the bones asset server
375pub fn handle_asset_changes(
376    game: ResMut<BonesGame>,
377    mut bevy_images: ResMut<Assets<Image>>,
378    mut bevy_egui_textures: ResMut<bevy_egui::EguiUserTextures>,
379    mut bones_image_ids: ResMut<BonesImageIds>,
380) {
381    if let Some(mut asset_server) = game.get_shared_resource_mut::<bones::AssetServer>() {
382        asset_server.handle_asset_changes(|asset_server, handle| {
383            let mut bones_egui_textures = game.shared_resource_mut::<bones::EguiTextures>();
384            let Some(mut asset) = asset_server.get_asset_untyped_mut(handle) else {
385                // There was an issue loading the asset. The error will have been logged.
386                return;
387            };
388
389            // TODO: hot reload changed fonts.
390
391            if let Ok(image) = asset.data.try_cast_mut::<bones::Image>() {
392                bones_image_ids.load_bones_image(
393                    handle.typed(),
394                    image,
395                    &mut bones_egui_textures,
396                    &mut bevy_images,
397                    &mut bevy_egui_textures,
398                );
399            }
400        })
401    }
402}
403
404#[cfg(not(target_arch = "wasm32"))]
405fn handle_exits(game: Res<BonesGame>, mut exits: EventWriter<bevy::app::AppExit>) {
406    if **game.shared_resource::<bones::ExitBones>() {
407        exits.send(bevy::app::AppExit);
408    }
409}