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