diff --git a/Cargo.lock b/Cargo.lock index 6d37ebe8..63a0fb30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2144,7 +2144,9 @@ dependencies = [ "bevy_egui", "bevy_mod_debugdump", "bevy_rapier2d", + "getrandom", "iyes_loopless", + "rand", "serde", "serde_yaml", "structopt", diff --git a/assets/beach/beach.level.yaml b/assets/beach/beach.level.yaml index 69ea7b7e..5117f7a8 100644 --- a/assets/beach/beach.level.yaml +++ b/assets/beach/beach.level.yaml @@ -42,7 +42,7 @@ parallax_background: scale: 0.9 transition_factor: 0.9 -player_spawn: +player: fighter: /fighters/fishy/fishy.fighter.yaml location: [0, 0, 0] diff --git a/assets/default.game.yaml b/assets/default.game.yaml index 476c69ba..27e0d3fb 100644 --- a/assets/default.game.yaml +++ b/assets/default.game.yaml @@ -1 +1,61 @@ start_level: beach/beach.level.yaml +camera_height: 448 + +main_menu: + title: "Fish Fight\nPunchy" + title_size: 45 + title_font: ark + + background_image: + image: ui/main-menu-background.png + size: [1919, 1027] + +ui_theme: + fonts: + ark: ui/ark-pixel-16px-latin.ttf + + panel: + text_color: [51, 40, 40] + padding: + top: 30 + bottom: 30 + left: 30 + right: 30 + border: + image: ui/paper.png + image_size: [38, 34] + border_size: + top: 11 + bottom: 11 + left: 11 + right: 11 + scale: 4.0 + + button: + text_color: [255, 255, 255] + font_size: 30 + font: ark + padding: + top: 12 + left: 12 + right: 12 + bottom: 12 + borders: + default: + image: ui/green-button.png + image_size: [14, 14] + border_size: + top: 5 + bottom: 5 + right: 5 + left: 5 + scale: 3 + clicked: + image: ui/green-button-down.png + image_size: [14, 14] + border_size: + top: 5 + bottom: 5 + right: 5 + left: 5 + scale: 3 diff --git a/assets/ui/ark-pixel-16-px-latin-LICENSE.txt b/assets/ui/ark-pixel-16-px-latin-LICENSE.txt new file mode 100644 index 00000000..f8ab1076 --- /dev/null +++ b/assets/ui/ark-pixel-16-px-latin-LICENSE.txt @@ -0,0 +1,94 @@ +Copyright (c) 2021, TakWolf (https://ark-pixel-font.takwolf.com), +with Reserved Font Name 'Ark Pixel'. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/ui/ark-pixel-16px-latin.ttf b/assets/ui/ark-pixel-16px-latin.ttf new file mode 100644 index 00000000..430e8562 Binary files /dev/null and b/assets/ui/ark-pixel-16px-latin.ttf differ diff --git a/assets/ui/green-button-down.png b/assets/ui/green-button-down.png new file mode 100644 index 00000000..ef7f207f Binary files /dev/null and b/assets/ui/green-button-down.png differ diff --git a/assets/ui/green-button.png b/assets/ui/green-button.png new file mode 100755 index 00000000..41b7f932 Binary files /dev/null and b/assets/ui/green-button.png differ diff --git a/assets/ui/main-menu-background.png b/assets/ui/main-menu-background.png new file mode 100644 index 00000000..a16da45c Binary files /dev/null and b/assets/ui/main-menu-background.png differ diff --git a/assets/ui/paper.png b/assets/ui/paper.png new file mode 100755 index 00000000..e6c4e7f1 Binary files /dev/null and b/assets/ui/paper.png differ diff --git a/src/assets.rs b/src/assets.rs index 586b7980..76a5b1bf 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -1,61 +1,108 @@ use std::path::{Path, PathBuf}; use bevy::{ - asset::{AssetLoader, AssetPath, LoadedAsset}, + asset::{Asset, AssetLoader, AssetPath, LoadedAsset}, prelude::AddAsset, prelude::*, + reflect::TypeUuid, }; +use bevy_egui::egui; use crate::metadata::*; +/// Register game asset and loaders pub fn register(app: &mut bevy::prelude::App) { app.register_type::() - .add_asset::() - .add_asset_loader(GameLoader) - .add_asset::() - .add_asset_loader(LevelLoader) + .add_asset::() + .add_asset_loader(GameMetaLoader) + .add_asset::() + .add_asset_loader(LevelMetaLoader) .add_asset::() - .add_asset_loader(FighterLoader); + .add_asset_loader(FighterLoader) + .add_asset::() + .add_asset_loader(EguiFontLoader); } +// An error that could ocurr during asset processing #[derive(thiserror::Error, Debug)] pub enum AssetLoaderError { #[error("Could not parse YAML asset: {0}")] DeserializationError(#[from] serde_yaml::Error), } -fn relative_asset_path(asset_path: &Path, relative: &str) -> PathBuf { - let is_relative = !relative.starts_with('/'); +/// Calculate an asset's full path relative to another asset +fn relative_asset_path(asset_path: &Path, relative_path: &str) -> PathBuf { + let is_relative = !relative_path.starts_with('/'); if is_relative { let base = asset_path.parent().unwrap_or_else(|| Path::new("")); - base.join(relative) + base.join(relative_path) } else { - Path::new(relative).strip_prefix("/").unwrap().to_owned() + Path::new(relative_path) + .strip_prefix("/") + .unwrap() + .to_owned() } } #[derive(Default)] -pub struct GameLoader; +pub struct GameMetaLoader; -impl AssetLoader for GameLoader { +impl AssetLoader for GameMetaLoader { fn load<'a>( &'a self, bytes: &'a [u8], load_context: &'a mut bevy::asset::LoadContext, ) -> bevy::utils::BoxedFuture<'a, Result<(), anyhow::Error>> { Box::pin(async move { - let meta: GameMeta = serde_yaml::from_slice(bytes)?; + let mut meta: GameMeta = serde_yaml::from_slice(bytes)?; trace!(?meta, "Loaded game asset"); - let self_path = load_context.path(); + let self_path = load_context.path().to_owned(); + + /// Helper to get relative asset paths and handles + fn get_relative_asset( + load_context: &mut bevy::asset::LoadContext, + self_path: &Path, + relative_path: &str, + ) -> (AssetPath<'static>, Handle) { + let asset_path = relative_asset_path(self_path, relative_path); + let asset_path = AssetPath::new(asset_path, None); + let handle = load_context.get_handle(asset_path.clone()); + + (asset_path, handle) + } + + // Load the start level asset + let (start_level_path, start_level_handle) = + get_relative_asset(load_context, &self_path, &meta.start_level); + meta.start_level_handle = start_level_handle; + + // Load the main menu background + let (main_menu_background_path, main_menu_background) = get_relative_asset( + load_context, + &self_path, + &meta.main_menu.background_image.image, + ); + meta.main_menu.background_image.handle = main_menu_background; + + // Load UI fonts + let mut font_paths = Vec::new(); + for (font_name, font_relative_path) in &meta.ui_theme.fonts { + let (font_path, font_handle) = + get_relative_asset(load_context, &self_path, font_relative_path); - let start_level_path = relative_asset_path(self_path, &meta.start_level); - let start_level_path = AssetPath::new(start_level_path, None); - let start_level = load_context.get_handle(start_level_path.clone()); + font_paths.push(font_path); + + meta.ui_theme + .font_handles + .insert(font_name.clone(), font_handle); + } load_context.set_default_asset( - LoadedAsset::new(Game { meta, start_level }).with_dependency(start_level_path), + LoadedAsset::new(meta) + .with_dependencies(vec![start_level_path, main_menu_background_path]) + .with_dependencies(font_paths), ); Ok(()) @@ -67,9 +114,9 @@ impl AssetLoader for GameLoader { } } -pub struct LevelLoader; +pub struct LevelMetaLoader; -impl AssetLoader for LevelLoader { +impl AssetLoader for LevelMetaLoader { fn load<'a>( &'a self, bytes: &'a [u8], @@ -90,31 +137,27 @@ impl AssetLoader for LevelLoader { .to_owned(); } - let player_fighter_file_path = - relative_asset_path(self_path, &meta.player_spawn.fighter); + // Load the player + let player_fighter_file_path = relative_asset_path(self_path, &meta.player.fighter); let player_fighter_path = AssetPath::new(player_fighter_file_path, None); let player_fighter_handle = load_context.get_handle(player_fighter_path.clone()); + meta.player.fighter_handle = player_fighter_handle; - let mut enemy_fighter_handles = Vec::new(); + // Load the enemies let mut enemy_asset_paths = Vec::new(); - - for enemy in &meta.enemies { + for enemy in &mut meta.enemies { let enemy_fighter_file_path = relative_asset_path(self_path, &enemy.fighter); let enemy_fighter_path = AssetPath::new(enemy_fighter_file_path.clone(), None); let enemy_fighter_handle = load_context.get_handle(enemy_fighter_path.clone()); enemy_asset_paths.push(enemy_fighter_path); - enemy_fighter_handles.push(enemy_fighter_handle); + enemy.fighter_handle = enemy_fighter_handle; } load_context.set_default_asset( - LoadedAsset::new(Level { - meta, - player_fighter_handle, - enemy_fighter_handles, - }) - .with_dependency(player_fighter_path) - .with_dependencies(enemy_asset_paths), + LoadedAsset::new(meta) + .with_dependency(player_fighter_path) + .with_dependencies(enemy_asset_paths), ); Ok(()) @@ -163,3 +206,31 @@ impl AssetLoader for FighterLoader { &["fighter.yml", "fighter.yaml"] } } + +#[derive(Debug, Clone, TypeUuid)] +#[uuid = "da277340-574f-4069-907c-7571b8756200"] +pub struct EguiFont(pub egui::FontData); + +pub struct EguiFontLoader; + +impl AssetLoader for EguiFontLoader { + fn load<'a>( + &'a self, + bytes: &'a [u8], + load_context: &'a mut bevy::asset::LoadContext, + ) -> bevy::utils::BoxedFuture<'a, Result<(), anyhow::Error>> { + Box::pin(async move { + let path = load_context.path(); + let data = egui::FontData::from_owned(bytes.to_vec()); + debug!(?path, "Loaded font asset"); + + load_context.set_default_asset(LoadedAsset::new(EguiFont(data))); + + Ok(()) + }) + } + + fn extensions(&self) -> &[&str] { + &["ttf"] + } +} diff --git a/src/main.rs b/src/main.rs index 01d8164f..88b30d5b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ #![allow(clippy::type_complexity)] #![allow(clippy::forget_non_drop)] +#![allow(clippy::too_many_arguments)] use bevy::{ asset::{AssetServerSettings, AssetStage}, @@ -7,6 +8,7 @@ use bevy::{ prelude::*, render::camera::ScalingMode, }; +use bevy_egui::{egui, EguiContext}; use bevy_parallax::{ParallaxCameraComponent, ParallaxPlugin, ParallaxResource}; use bevy_rapier2d::prelude::*; use iyes_loopless::prelude::*; @@ -37,18 +39,18 @@ mod ui; mod y_sort; use animation::*; -use attack::{Attack, AttackPlugin}; +use attack::AttackPlugin; use camera::*; use collisions::*; use item::{spawn_throwable_items, ThrowItemEvent}; -use metadata::{Fighter, Game, Level}; +use metadata::{Fighter, GameMeta, LevelMeta}; use movement::*; use serde::Deserialize; use state::{State, StatePlugin}; use ui::UIPlugin; use y_sort::*; -use crate::config::EngineConfig; +use crate::{config::EngineConfig, metadata::UIBorderImageMeta}; #[derive(Component)] pub struct Player; @@ -159,28 +161,43 @@ impl Default for EnemyBundle { } } +/// Used as a system In parameter to indicate whether the system is being called to +/// host reload an asset. +struct IsHotReload(bool); +/// Helper to create a system chain, i.e.: `not_hot_reload.chain(my_system)` +fn not_hot_reload() -> IsHotReload { + IsHotReload(false) +} +/// Helper to create a system chain, i.e.: `is_hot_reload.chain(my_system)` +fn is_hot_reload() -> IsHotReload { + IsHotReload(true) +} + fn main() { let engine_config = EngineConfig::from_args(); let mut app = App::new(); + // Configure asset server let mut asset_server_settings = AssetServerSettings { watch_for_changes: engine_config.hot_reload, ..default() }; - if let Some(asset_dir) = &engine_config.asset_dir { asset_server_settings.asset_folder = asset_dir.clone(); } + app.insert_resource(asset_server_settings); + // Add default plugins #[cfg(feature = "schedule_graph")] app.add_plugins_with(DefaultPlugins, |plugins| { plugins.disable::() }); #[cfg(not(feature = "schedule_graph"))] app.add_plugins(DefaultPlugins); + + // Add other systems and resources app.insert_resource(engine_config.clone()) - .insert_resource(asset_server_settings) .insert_resource(ClearColor(Color::BLACK)) .insert_resource(WindowDescriptor { title: "Fish Fight Punchy".to_string(), @@ -188,6 +205,7 @@ fn main() { ..Default::default() }) .add_event::() + .add_loopless_state(GameState::LoadingGame) .add_plugin(platform::PlatformPlugin) .add_plugin(RapierPhysicsPlugin::::pixels_per_meter(100.0)) .add_plugin(AttackPlugin) @@ -196,9 +214,11 @@ fn main() { .add_plugin(ParallaxPlugin) .add_plugin(UIPlugin) .insert_resource(ParallaxResource::default()) - .add_startup_system(spawn_camera) - .add_loopless_state(GameState::LoadingGame) - .add_system(load_game.run_in_state(GameState::LoadingGame)) + .add_system( + not_hot_reload + .chain(load_game) + .run_in_state(GameState::LoadingGame), + ) .add_system(load_level.run_in_state(GameState::LoadingLevel)) .add_system_set( ConditionSet::new() @@ -230,22 +250,7 @@ fn main() { ) .add_system_to_stage(CoreStage::Last, despawn_entities); - if engine_config.hot_reload { - app.add_stage_after( - AssetStage::LoadAssets, - GameStage::HotReload, - SystemStage::parallel(), - ) - .add_system_set_to_stage( - GameStage::HotReload, - ConditionSet::new() - .run_in_state(GameState::InGame) - .with_system(hot_reload_level) - .with_system(hot_reload_fighters) - .into(), - ); - } - + // Add debug plugins #[cfg(feature = "debug")] app.add_plugin(RapierDebugRenderPlugin::default()) .add_plugin(InspectableRapierPlugin) @@ -255,59 +260,166 @@ fn main() { .register_inspectable::() .register_inspectable::() .register_inspectable::() - .register_inspectable::() + .register_inspectable::() .register_inspectable::() .register_inspectable::() .register_inspectable::(); + // Register assets and loaders assets::register(&mut app); debug!(?engine_config, "Starting game"); - // Insert the game handle + // Get the game handle let asset_server = app.world.get_resource::().unwrap(); let game_asset = engine_config.game_asset; - let handle: Handle = asset_server.load(&game_asset); - app.world.insert_resource(handle); + let game_handle: Handle = asset_server.load(&game_asset); + // Configure hot reload + if engine_config.hot_reload { + app.add_stage_after( + AssetStage::LoadAssets, + GameStage::HotReload, + SystemStage::parallel(), + ) + .add_system_to_stage(GameStage::HotReload, is_hot_reload.chain(load_game)) + .add_system_set_to_stage( + GameStage::HotReload, + ConditionSet::new() + .run_in_state(GameState::InGame) + .with_system(hot_reload_level) + .with_system(hot_reload_fighters) + .into(), + ); + } + + // Insert game handle resource + app.world.insert_resource(game_handle); + + // Print the graphviz schedule graph #[cfg(feature = "schedule_graph")] bevy_mod_debugdump::print_schedule(&mut app); app.run(); } -fn spawn_camera(mut commands: Commands) { - let mut camera_bundle = OrthographicCameraBundle::new_2d(); - // camera_bundle.orthographic_projection.depth_calculation = DepthCalculation::Distance; - camera_bundle.orthographic_projection.scaling_mode = ScalingMode::FixedVertical; - camera_bundle.orthographic_projection.scale = 16. * 14.; - commands - .spawn_bundle(camera_bundle) - .insert(Panning { - offset: Vec2::new(0., -consts::GROUND_Y), - }) - .insert(ParallaxCameraComponent); -} - +/// Loads the main [`GameMeta`] resource and then transitions to the main menu +/// +/// This is run in a chain as either `not_hot_reload().chain(load_game)` or +/// `is_hot_reload().chain(load_game)` so that logic for hot reloading the game and loading the game +/// can be shared. fn load_game( + is_hot_reload: In, + mut skip_next_asset_update_event: Local, + camera: Query>, mut commands: Commands, - game_handle: Res>, - mut assets: ResMut>, + game_handle: Res>, + mut assets: ResMut>, + mut egui_ctx: ResMut, + asset_server: Res, + mut events: EventReader>, ) { - if let Some(game) = assets.remove(game_handle.clone_weak()) { + let is_hot_reload = is_hot_reload.0 .0; + + // If this is a hot reload run, check for modified asset events + if is_hot_reload { + let mut has_update = false; + for (event, event_id) in events.iter_with_id() { + if let AssetEvent::Modified { .. } = event { + // We may need to skip an asset update event + if *skip_next_asset_update_event { + *skip_next_asset_update_event = false; + } else { + debug!(%event_id, "Game updated"); + has_update = true; + } + } + } + + // If there was no update, skip execution + if !has_update { + return; + } + } + + if let Some(game) = assets.get_mut(game_handle.clone_weak()) { debug!("Loaded game"); + + if is_hot_reload { + // Despawn previous camera + commands.entity(camera.single()).despawn(); + } + + // Spawn the camera + let mut camera_bundle = OrthographicCameraBundle::new_2d(); + // camera_bundle.orthographic_projection.depth_calculation = DepthCalculation::Distance; + camera_bundle.orthographic_projection.scaling_mode = ScalingMode::FixedVertical; + camera_bundle.orthographic_projection.scale = game.camera_height as f32 / 2.0; + commands + .spawn_bundle(camera_bundle) + .insert(Panning { + offset: Vec2::new(0., -consts::GROUND_Y), + }) + .insert(ParallaxCameraComponent); + + // Helper to load border images + let mut load_border_image = |border: &mut UIBorderImageMeta| { + border.handle = asset_server.load(&border.image); + border.egui_texture = egui_ctx.add_image(border.handle.clone_weak()); + }; + + // Load border images + load_border_image(&mut game.ui_theme.panel.border); + load_border_image(&mut game.ui_theme.button.borders.default); + if let Some(border) = &mut game.ui_theme.button.borders.clicked { + load_border_image(border); + } + if let Some(border) = &mut game.ui_theme.button.borders.hovered { + load_border_image(border); + } + + if !is_hot_reload { + // Initialize empty fonts for all game fonts. + // + // This makes sure Egui will not panic if we try to use a font that is still loading. + let mut egui_fonts = egui::FontDefinitions::default(); + for font_name in game.ui_theme.fonts.keys() { + let font_family = egui::FontFamily::Name(font_name.clone().into()); + egui_fonts.families.insert(font_family, vec![]); + } + egui_ctx.ctx_mut().set_fonts(egui_fonts.clone()); + commands.insert_resource(egui_fonts); + + // If this is a hot reload run + } else { + // Since we modified the game asset, which will trigger another asset changed event, we + // need to skip the next update event. + *skip_next_asset_update_event = true; + } + + // Insert the game resource + commands.insert_resource(game.clone()); commands.insert_resource(game.start_level.clone()); - commands.insert_resource(game); - commands.insert_resource(NextState(GameState::LoadingLevel)); + + if !is_hot_reload { + // Transition to the main menu + commands.insert_resource(NextState(GameState::MainMenu)); + } + + // If the game asset isn't loaded yet } else { trace!("Awaiting game load") } } +/// Loads a level and transitions to [`GameState::InGame`] +/// +/// A [`Handle`] resource must be inserted before running this system, to indicate which +/// level to load. fn load_level( - level_handle: Res>, + level_handle: Res>, mut commands: Commands, - assets: Res>, + assets: Res>, mut parallax: ResMut, mut texture_atlases: ResMut>, asset_server: Res, @@ -318,36 +430,31 @@ fn load_level( let window = windows.primary(); // Setup the parallax background - *parallax = level.meta.parallax_background.get_resource(); + *parallax = level.parallax_background.get_resource(); parallax.window_size = Vec2::new(window.width(), window.height()); parallax.create_layers(&mut commands, &asset_server, &mut texture_atlases); // Set the clear color - commands.insert_resource(ClearColor(level.meta.background_color())); + commands.insert_resource(ClearColor(level.background_color())); // Spawn the player let ground_offset = Vec3::new(0.0, consts::GROUND_Y, 0.0); - let player_pos = level.meta.player_spawn.location + ground_offset; + let player_pos = level.player.location + ground_offset; commands .spawn_bundle(TransformBundle::from_transform( Transform::from_translation(player_pos), )) - .insert(level.player_fighter_handle.clone()) + .insert(level.player.fighter_handle.clone()) .insert_bundle(PlayerBundle::default()); // Spawn the enemies - for (enemy, enemy_handle) in level - .meta - .enemies - .iter() - .zip(level.enemy_fighter_handles.iter()) - { + for enemy in level.enemies.iter() { let enemy_pos = enemy.location + ground_offset; commands .spawn_bundle(TransformBundle::from_transform( Transform::from_translation(enemy_pos), )) - .insert(enemy_handle.clone()) + .insert(enemy.fighter_handle.clone()) .insert_bundle(EnemyBundle::default()); } @@ -358,13 +465,14 @@ fn load_level( } } +/// Hot reloads level asset data fn hot_reload_level( mut commands: Commands, mut parallax: ResMut, - mut events: EventReader>, + mut events: EventReader>, mut texture_atlases: ResMut>, - level_handle: Res>, - assets: Res>, + level_handle: Res>, + assets: Res>, asset_server: Res, windows: Res, ) { @@ -375,17 +483,21 @@ fn hot_reload_level( // Update the level background let window = windows.primary(); parallax.despawn_layers(&mut commands); - *parallax = level.meta.parallax_background.get_resource(); + *parallax = level.parallax_background.get_resource(); parallax.window_size = Vec2::new(window.width(), window.height()); parallax.create_layers(&mut commands, &asset_server, &mut texture_atlases); - commands.insert_resource(ClearColor(level.meta.background_color())); + commands.insert_resource(ClearColor(level.background_color())); } } } } -/// Load all fighters that have their handles spawned +/// Load all fighters that have their handles spawned. +/// +/// Fighters are spawned as "stubs" that only contain a transform, a marker component, and a +/// [`Handle`]. This system takes those stubs, populates the rest of their components once +/// the figher asset has been loaded. fn load_fighters( mut commands: Commands, // All fighters that haven't been fully loaded yet @@ -438,6 +550,7 @@ fn load_fighters( } } +/// Hot reload fighter data when fighter assets are updated. fn hot_reload_fighters( mut fighters: Query<( &Handle, @@ -470,17 +583,20 @@ fn hot_reload_fighters( } } +/// Transition game to pause state fn pause(keyboard: Res>, mut commands: Commands) { if keyboard.just_pressed(KeyCode::P) { commands.insert_resource(NextState(GameState::Paused)); } } +// Transition game out of paused state fn unpause(keyboard: Res>, mut commands: Commands) { if keyboard.just_pressed(KeyCode::P) { commands.insert_resource(NextState(GameState::InGame)); } } + fn player_attack( mut query: Query<(&mut State, &mut Transform, &Animation, &Facing), With>, keyboard: Res>, diff --git a/src/metadata.rs b/src/metadata.rs index eeced330..614f993e 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -1,53 +1,61 @@ use bevy::{ math::{UVec2, Vec2, Vec3}, - prelude::{Color, Component, Handle}, + prelude::{Color, Component, Handle, Image}, reflect::TypeUuid, sprite::TextureAtlas, utils::HashMap, }; +use bevy_egui::egui; use bevy_parallax::{LayerData, ParallaxResource}; use serde::Deserialize; -use crate::{animation::Clip, state::State, Stats}; +use crate::{animation::Clip, assets::EguiFont, state::State, Stats}; -#[derive(TypeUuid, Clone, Debug)] +#[derive(TypeUuid, Deserialize, Clone, Debug)] +#[serde(deny_unknown_fields)] #[uuid = "eb28180f-ef68-44a0-8479-a299a3cef66e"] -pub struct Game { - pub meta: GameMeta, - pub start_level: Handle, +pub struct GameMeta { + pub start_level: String, + #[serde(skip)] + pub start_level_handle: Handle, + pub main_menu: MainMenuMeta, + pub ui_theme: UIThemeMeta, + pub camera_height: u32, } #[derive(Deserialize, Clone, Debug)] #[serde(deny_unknown_fields)] -pub struct GameMeta { - pub start_level: String, +pub struct MainMenuMeta { + pub title: String, + pub title_size: f32, + pub title_font: String, + pub background_image: ImageMeta, } -#[derive(TypeUuid, Clone, Debug)] -#[uuid = "32111f6e-bb9a-4ea7-8988-1220b923a059"] -pub struct Level { - pub meta: LevelMeta, - pub player_fighter_handle: Handle, - pub enemy_fighter_handles: Vec>, +#[derive(Deserialize, Clone, Debug)] +#[serde(deny_unknown_fields)] +pub struct ImageMeta { + pub image: String, + pub size: Vec2, + #[serde(skip)] + pub handle: Handle, } -#[derive(Deserialize, Clone, Debug)] +#[derive(TypeUuid, Deserialize, Clone, Debug)] #[serde(deny_unknown_fields)] +#[uuid = "32111f6e-bb9a-4ea7-8988-1220b923a059"] pub struct LevelMeta { pub background_color: [u8; 3], pub parallax_background: ParallaxMeta, - pub player_spawn: FighterSpawnMeta, + pub player: FighterSpawnMeta, #[serde(default)] pub enemies: Vec, } impl LevelMeta { pub fn background_color(&self) -> Color { - Color::rgb_u8( - self.background_color[0], - self.background_color[1], - self.background_color[2], - ) + let [r, g, b] = self.background_color; + Color::rgb_u8(r, g, b) } } @@ -81,6 +89,8 @@ pub struct FighterSpritesheetMeta { #[serde(deny_unknown_fields)] pub struct FighterSpawnMeta { pub fighter: String, + #[serde(skip)] + pub fighter_handle: Handle, pub location: Vec3, } @@ -124,3 +134,101 @@ impl From for LayerData { } } } + +#[derive(Deserialize, Clone, Debug)] +#[serde(deny_unknown_fields)] +pub struct UIThemeMeta { + pub fonts: HashMap, + #[serde(skip)] + pub font_handles: HashMap>, + pub panel: UIPanelThemeMeta, + pub button: UIButtonThemeMeta, +} + +#[derive(Deserialize, Clone, Debug)] +#[serde(deny_unknown_fields)] +pub struct UIPanelThemeMeta { + #[serde(default)] + pub text_color: ColorMeta, + #[serde(default)] + pub padding: MarginMeta, + pub border: UIBorderImageMeta, +} + +#[derive(Deserialize, Clone, Debug)] +#[serde(deny_unknown_fields)] +pub struct UIButtonThemeMeta { + #[serde(default)] + pub text_color: ColorMeta, + pub font_size: f32, + pub font: String, + #[serde(default)] + pub padding: MarginMeta, + pub borders: UIButtonBordersMeta, +} + +#[derive(Deserialize, Clone, Debug)] +#[serde(deny_unknown_fields)] +pub struct UIBorderImageMeta { + pub image: String, + pub image_size: UVec2, + pub border_size: MarginMeta, + #[serde(default = "f32_one")] + pub scale: f32, + #[serde(default)] + pub only_frame: bool, + + #[serde(skip)] + pub handle: Handle, + #[serde(skip)] + pub egui_texture: egui::TextureId, +} + +fn f32_one() -> f32 { + 1.0 +} + +#[derive(Deserialize, Clone, Debug)] +#[serde(deny_unknown_fields)] +pub struct UIButtonBordersMeta { + pub default: UIBorderImageMeta, + #[serde(default)] + pub hovered: Option, + #[serde(default)] + pub clicked: Option, +} + +#[derive(Default, Deserialize, Clone, Copy, Debug)] +#[serde(deny_unknown_fields)] +pub struct ColorMeta([u8; 3]); + +impl From for egui::Color32 { + fn from(c: ColorMeta) -> Self { + let [r, g, b] = c.0; + egui::Color32::from_rgb(r, g, b) + } +} + +#[derive(Default, Deserialize, Clone, Copy, Debug)] +#[serde(deny_unknown_fields)] +pub struct MarginMeta { + #[serde(default)] + pub top: f32, + #[serde(default)] + pub bottom: f32, + #[serde(default)] + pub left: f32, + #[serde(default)] + pub right: f32, +} + +impl From for bevy_egui::egui::style::Margin { + fn from(m: MarginMeta) -> Self { + Self { + left: m.left, + right: m.right, + top: m.top, + bottom: m.bottom, + } + } +} diff --git a/src/ui.rs b/src/ui.rs index 48d02538..c24a083f 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,14 +1,25 @@ -use bevy::prelude::{App, Plugin, ResMut}; -use bevy_egui::{egui, EguiContext, EguiPlugin}; -use iyes_loopless::condition::ConditionSet; +use bevy::prelude::*; +use bevy_egui::{ + egui::{self, style::Margin, RichText}, + EguiContext, EguiPlugin, EguiSettings, +}; +use iyes_loopless::prelude::*; -use crate::GameState; +use crate::{assets::EguiFont, metadata::GameMeta, GameState}; + +use self::widgets::{bordered_button::BorderedButton, bordered_frame::BorderedFrame}; + +pub mod widgets; pub struct UIPlugin; impl Plugin for UIPlugin { fn build(&self, app: &mut App) { app.add_plugin(EguiPlugin) + .add_enter_system(GameState::MainMenu, spawn_main_menu_background) + .add_exit_system(GameState::MainMenu, despawn_main_menu_background) + .add_system(update_egui_fonts.run_if_resource_exists::()) + .add_system(update_ui_scale.run_if_resource_exists::()) .add_system_set( ConditionSet::new() .run_in_state(GameState::Paused) @@ -24,10 +35,182 @@ impl Plugin for UIPlugin { } } +/// Watches for asset events for [`EguiFont`] assets and updates the corresponding fonts from the +/// [`GameMeta`], inserting the font data into the egui context. +fn update_egui_fonts( + mut egui_font_definitions: ResMut, + mut egui_ctx: ResMut, + game: Res, + mut events: EventReader>, + assets: Res>, +) { + for event in events.iter() { + if let AssetEvent::Created { handle } | AssetEvent::Modified { handle } = event { + // Get the game font name associated to this handle + let name = game + .ui_theme + .font_handles + .iter() + .find_map(|(font_name, font_handle)| { + if font_handle == handle { + Some(font_name.clone()) + } else { + None + } + }); + + // If we were able to find the font handle in our game fonts + if let Some(font_name) = name { + // Get the font asset + if let Some(font) = assets.get(handle) { + // And insert it into the Egui font definitions + let ctx = egui_ctx.ctx_mut(); + egui_font_definitions + .font_data + .insert(font_name.clone(), font.0.clone()); + + egui_font_definitions + .families + .get_mut(&egui::FontFamily::Name(font_name.clone().into())) + .unwrap() + .push(font_name); + + ctx.set_fonts(egui_font_definitions.clone()); + } + } + } + } +} + +/// This system makes sure that the UI scale of Egui matches our game scale so that a pixel in egui +/// will be the same size as a pixel in our sprites. +fn update_ui_scale( + mut egui_settings: ResMut, + windows: Res, + projection: Query<&OrthographicProjection, With>, +) { + if let Some(window) = windows.get_primary() { + if let Ok(projection) = projection.get_single() { + match projection.scaling_mode { + bevy::render::camera::ScalingMode::FixedVertical => { + let window_height = window.height(); + let scale = window_height / (projection.scale * 2.0); + egui_settings.scale_factor = scale as f64; + } + bevy::render::camera::ScalingMode::FixedHorizontal => { + let window_width = window.width(); + let scale = window_width / (projection.scale * 2.0); + egui_settings.scale_factor = scale as f64; + } + bevy::render::camera::ScalingMode::None => (), + bevy::render::camera::ScalingMode::WindowSize => (), + } + } + } +} + fn pause_menu(mut egui_context: ResMut) { egui::Window::new("Paused").show(egui_context.ctx_mut(), |_ui| {}); } -fn main_menu(mut egui_context: ResMut) { - egui::Window::new("Main Menu").show(egui_context.ctx_mut(), |_ui| {}); +#[derive(Component)] +struct MainMenuBackground; + +/// Spawns the background image for the main menu +fn spawn_main_menu_background(mut commands: Commands, game: Res, windows: Res) { + let window = windows.primary(); + let bg_handle = game.main_menu.background_image.handle.clone(); + let img_size = game.main_menu.background_image.size; + let ratio = img_size.x / img_size.y; + let height = window.height(); + let width = height * ratio; + commands + .spawn_bundle(SpriteBundle { + texture: bg_handle, + sprite: Sprite { + custom_size: Some(Vec2::new(width, height)), + ..default() + }, + ..default() + }) + .insert(MainMenuBackground); +} + +/// Despawns the background image for the main menu +fn despawn_main_menu_background( + mut commands: Commands, + background: Query>, +) { + let bg = background.single(); + commands.entity(bg).despawn(); +} + +/// Render the main menu UI +fn main_menu(mut commands: Commands, mut egui_context: ResMut, game: Res) { + let ui_theme = &game.ui_theme; + + egui::CentralPanel::default() + .frame(egui::Frame::none()) + .show(egui_context.ctx_mut(), |ui| { + let screen_rect = ui.max_rect(); + + // Calculate a margin of 20% of the screen size + let outer_margin = screen_rect.size() * 0.20; + let outer_margin = Margin { + left: outer_margin.x, + right: outer_margin.x, + // Make top and bottom margins smaller + top: outer_margin.y / 1.5, + bottom: outer_margin.y / 1.5, + }; + + BorderedFrame::new(&ui_theme.panel.border) + .margin(outer_margin) + .padding(ui_theme.panel.padding.into()) + .show(ui, |ui| { + let text_color = ui_theme.panel.text_color; + + // Make sure the frame ocupies the entire rect that we allocated for it. + // + // Without this it would only take up enough size to fit it's content. + ui.set_min_size(ui.available_size()); + + // Create a vertical list of items, centered horizontally + ui.vertical_centered(|ui| { + ui.label( + RichText::new(&game.main_menu.title) + .font(egui::FontId::new( + game.main_menu.title_size, + egui::FontFamily::Name( + game.main_menu.title_font.clone().into(), + ), + )) + .color(text_color), + ); + + // Now switch the layout to bottom_up so that we can start adding widgets + // from the bottom of the frame. + ui.with_layout(egui::Layout::bottom_up(egui::Align::Center), |ui| { + if BorderedButton::new( + RichText::new("Start Game") + .font(egui::FontId::new( + ui_theme.button.font_size, + egui::FontFamily::Name(ui_theme.button.font.clone().into()), + )) + .color(ui_theme.button.text_color), + ) + .padding(ui_theme.button.padding.into()) + .border(&ui_theme.button.borders.default) + .on_click_border(ui_theme.button.borders.clicked.as_ref()) + .on_hover_border(ui_theme.button.borders.hovered.as_ref()) + .show(ui) + .clicked() + { + commands.insert_resource(game.start_level_handle.clone()); + commands.insert_resource(NextState(GameState::LoadingLevel)); + } + }); + }); + }) + }); } diff --git a/src/ui/widgets.rs b/src/ui/widgets.rs new file mode 100644 index 00000000..37bbd16c --- /dev/null +++ b/src/ui/widgets.rs @@ -0,0 +1,2 @@ +pub mod bordered_frame; +pub mod bordered_button; diff --git a/src/ui/widgets/bordered_button.rs b/src/ui/widgets/bordered_button.rs new file mode 100644 index 00000000..5d8a884f --- /dev/null +++ b/src/ui/widgets/bordered_button.rs @@ -0,0 +1,182 @@ +#![allow(dead_code)] // We haven't used all of these methods yet but we still want them there + +/// Bordered button rendering +/// +/// Adapted from +use bevy_egui::egui::{self, *}; + +use crate::metadata::UIBorderImageMeta; + +use super::bordered_frame::BorderedFrame; + +/// A button rendered with a [`BorderImage`] +pub struct BorderedButton<'a> { + text: WidgetText, + wrap: Option, + sense: Sense, + min_size: Vec2, + default_border: Option<&'a UIBorderImageMeta>, + on_hover_border: Option<&'a UIBorderImageMeta>, + on_click_border: Option<&'a UIBorderImageMeta>, + margin: egui::style::Margin, + padding: egui::style::Margin, +} + +impl<'a> BorderedButton<'a> { + // Create a new button + #[must_use = "You must call .show() to render the button"] + pub fn new(text: impl Into) -> Self { + Self { + text: text.into(), + sense: Sense::click(), + min_size: Vec2::ZERO, + wrap: None, + default_border: None, + on_hover_border: None, + on_click_border: None, + margin: Default::default(), + padding: Default::default(), + } + } + + /// If `true`, the text will wrap to stay within the max width of the [`Ui`]. + /// + /// By default [`Self::wrap`] will be true in vertical layouts + /// and horizontal layouts with wrapping, + /// and false on non-wrapping horizontal layouts. + /// + /// Note that any `\n` in the text will always produce a new line. + #[inline] + pub fn wrap(mut self, wrap: bool) -> Self { + self.wrap = Some(wrap); + self + } + + /// Set the margin. This will be applied on the outside of the border. + #[must_use = "You must call .show() to render the button"] + pub fn margin(mut self, margin: bevy::math::Rect) -> Self { + self.margin = egui::style::Margin { + left: margin.left, + right: margin.right, + top: margin.top, + bottom: margin.bottom, + }; + + self + } + + /// Set the padding. This will be applied on the inside of the border. + #[must_use = "You must call .show() to render the button"] + pub fn padding(mut self, padding: egui::style::Margin) -> Self { + self.padding = padding; + + self + } + + /// Set the button border image + #[must_use = "You must call .show() to render the button"] + pub fn border(mut self, border: &'a UIBorderImageMeta) -> Self { + self.default_border = Some(border); + self + } + + /// Set a different border to use when hovering over the button + #[must_use = "You must call .show() to render the button"] + pub fn on_hover_border(mut self, border: Option<&'a UIBorderImageMeta>) -> Self { + self.on_hover_border = border; + self + } + + /// Set a different border to use when the mouse is clicking on the button + #[must_use = "You must call .show() to render the button"] + pub fn on_click_border(mut self, border: Option<&'a UIBorderImageMeta>) -> Self { + self.on_click_border = border; + self + } + + /// By default, buttons senses clicks. + /// Change this to a drag-button with `Sense::drag()`. + #[must_use = "You must call .show() to render the button"] + pub fn sense(mut self, sense: Sense) -> Self { + self.sense = sense; + self + } + + /// Set the minimum size for the button + #[must_use = "You must call .show() to render the button"] + pub fn min_size(mut self, min_size: Vec2) -> Self { + self.min_size = min_size; + self + } + + /// Render the button + #[must_use = "You must call .show() to render the button"] + pub fn show(self, ui: &mut Ui) -> egui::Response { + self.ui(ui) + } +} + +impl<'a> Widget for BorderedButton<'a> { + fn ui(self, ui: &mut Ui) -> Response { + let BorderedButton { + text, + sense, + min_size, + wrap, + default_border, + on_hover_border, + on_click_border, + margin, + padding, + }: BorderedButton = self; + + let total_extra = padding.sum() + margin.sum(); + + let wrap_width = ui.available_width() - total_extra.x; + let text = text.into_galley(ui, wrap, wrap_width, TextStyle::Button); + + let mut desired_size = text.size() + total_extra; + desired_size = desired_size.at_least(min_size); + + let (rect, response) = ui.allocate_at_least(desired_size, sense); + response.widget_info(|| WidgetInfo::labeled(WidgetType::Button, text.text())); + + if ui.is_rect_visible(rect) { + let visuals = ui.style().interact(&response); + + let mut text_rect = rect; + text_rect.min += padding.left_top() + margin.left_top(); + text_rect.max -= padding.right_bottom() + margin.right_bottom(); + text_rect.max.x = text_rect.max.x.max(text_rect.min.x); + text_rect.max.y = text_rect.max.y.max(text_rect.min.y); + + let label_pos = ui + .layout() + .align_size_within_rect(text.size(), text_rect) + .min; + + let border = if response.is_pointer_button_down_on() { + on_click_border.or(default_border) + } else if response.hovered() { + on_hover_border.or(default_border) + } else { + default_border + }; + + let mut border_rect = rect; + border_rect.min += margin.left_top(); + border_rect.max -= margin.right_bottom(); + border_rect.max.x = border_rect.max.x.max(border_rect.min.x); + border_rect.max.y = border_rect.max.y.max(border_rect.min.y); + + if let Some(border) = border { + ui.painter() + .add(BorderedFrame::new(border).paint(border_rect)); + } + + text.paint_with_visuals(ui.painter(), label_pos, visuals); + } + + response + } +} diff --git a/src/ui/widgets/bordered_frame.rs b/src/ui/widgets/bordered_frame.rs new file mode 100644 index 00000000..d1ef448b --- /dev/null +++ b/src/ui/widgets/bordered_frame.rs @@ -0,0 +1,279 @@ +use bevy_egui::egui; + +use crate::metadata::UIBorderImageMeta; + +/// A 9-patch style bordered frame. +/// +/// # See Also +/// +/// - [`UiBorderImage`] +pub struct BorderedFrame { + bg_texture: egui::TextureId, + border_scale: f32, + texture_size: egui::Vec2, + texture_border_size: egui::style::Margin, + padding: egui::style::Margin, + margin: egui::style::Margin, + border_only: bool, +} + +impl BorderedFrame { + /// Create a new frame with the given [`BorderImage`] + #[must_use = "You must call .show() to render the frame"] + pub fn new(border_image: &UIBorderImageMeta) -> Self { + let s = border_image.image_size; + Self { + bg_texture: border_image.egui_texture, + border_scale: border_image.scale, + texture_size: egui::Vec2::new(s.x as f32, s.y as f32), + texture_border_size: border_image.border_size.into(), + padding: Default::default(), + margin: Default::default(), + border_only: false, + } + } + + /// Set the padding. This will be applied on the inside of the border. + #[must_use = "You must call .show() to render the frame"] + pub fn padding(mut self, margin: egui::style::Margin) -> Self { + self.padding = margin; + + self + } + + /// Set the margin. This will be applied on the outside of the border. + #[must_use = "You must call .show() to render the frame"] + pub fn margin(mut self, margin: egui::style::Margin) -> Self { + self.margin = margin; + + self + } + + + #[allow(unused)] // We just haven't needed this method yet + #[must_use = "You must call .show() to render the frame"] + pub fn border_scale(mut self, scale: f32) -> Self { + self.border_scale = scale; + + self + } + + #[allow(unused)] // We just haven't needed this method yet + /// If border_only is set to `true`, then the middle section of the frame will be transparent, + /// only the border will be rendered. + #[must_use = "You must call .show() to render the frame"] + pub fn border_only(mut self, border_only: bool) -> Self { + self.border_only = border_only; + + self + } + + /// Render the frame + pub fn show( + self, + ui: &mut egui::Ui, + add_contents: impl FnOnce(&mut egui::Ui) -> R, + ) -> egui::InnerResponse { + self.show_dyn(ui, Box::new(add_contents)) + } + + fn show_dyn<'c, R>( + self, + ui: &mut egui::Ui, + add_contents: Box R + 'c>, + ) -> egui::InnerResponse { + let mut prepared = self.begin(ui); + let ret = add_contents(&mut prepared.content_ui); + let response = prepared.end(ui); + + egui::InnerResponse { + inner: ret, + response, + } + } + + fn begin(self, ui: &mut egui::Ui) -> BorderedFramePrepared { + let background_shape_idx = ui.painter().add(egui::Shape::Noop); + + let mut content_rect = ui.available_rect_before_wrap(); + content_rect.min += self.padding.left_top() + self.margin.left_top(); + content_rect.max -= self.padding.right_bottom() + self.margin.right_bottom(); + + // Avoid negative size + content_rect.max.x = content_rect.max.x.max(content_rect.min.x); + content_rect.max.y = content_rect.max.y.max(content_rect.min.y); + + let content_ui = ui.child_ui(content_rect, *ui.layout()); + + BorderedFramePrepared { + frame: self, + background_shape_idx, + content_ui, + } + } + + pub fn paint(&self, paint_rect: egui::Rect) -> egui::Shape { + use egui::{Pos2, Rect, Vec2}; + let white = egui::Color32::WHITE; + + let mut mesh = egui::Mesh { + texture_id: self.bg_texture, + ..Default::default() + }; + + let s = self.texture_size; + let b = self.texture_border_size; + let pr = paint_rect; + // UV border + let buv = egui::style::Margin { + left: b.left / s.x, + right: b.right / s.x, + top: b.top / s.y, + bottom: b.bottom / s.y, + }; + let b = egui::style::Margin { + left: b.left * self.border_scale, + right: b.right * self.border_scale, + top: b.top * self.border_scale, + bottom: b.bottom * self.border_scale, + }; + + // Build the 9-patches + + // Top left + mesh.add_rect_with_uv( + Rect::from_min_size(pr.min, Vec2::new(b.left, b.top)), + egui::Rect::from_min_size(Pos2::ZERO, Vec2::new(buv.left, buv.top)), + white, + ); + // Top center + mesh.add_rect_with_uv( + Rect::from_min_size( + pr.min + Vec2::new(b.left, 0.0), + Vec2::new(pr.width() - b.left - b.right, b.top), + ), + egui::Rect::from_min_size( + Pos2::new(buv.left, 0.0), + Vec2::new(1.0 - buv.left - buv.right, buv.top), + ), + white, + ); + // Top right + mesh.add_rect_with_uv( + Rect::from_min_size( + pr.right_top() - Vec2::new(b.right, 0.0), + Vec2::new(b.right, b.top), + ), + egui::Rect::from_min_size( + Pos2::new(1.0 - buv.right, 0.0), + Vec2::new(buv.right, buv.top), + ), + white, + ); + // Middle left + mesh.add_rect_with_uv( + Rect::from_min_size( + pr.min + Vec2::new(0.0, b.top), + Vec2::new(b.left, pr.height() - b.top - b.bottom), + ), + egui::Rect::from_min_size( + Pos2::new(0.0, buv.top), + Vec2::new(buv.left, 1.0 - buv.top - buv.bottom), + ), + white, + ); + // Middle center + if !self.border_only { + mesh.add_rect_with_uv( + Rect::from_min_size( + pr.min + Vec2::new(b.left, b.top), + Vec2::new( + pr.width() - b.left - b.right, + pr.height() - b.top - b.bottom, + ), + ), + egui::Rect::from_min_size( + Pos2::new(buv.left, buv.top), + Vec2::new(1.0 - buv.left - buv.top, 1.0 - buv.top - buv.bottom), + ), + white, + ); + } + // Middle right + mesh.add_rect_with_uv( + Rect::from_min_size( + pr.min + Vec2::new(pr.width() - b.right, b.top), + Vec2::new(b.right, pr.height() - b.top - b.bottom), + ), + egui::Rect::from_min_size( + Pos2::new(1.0 - buv.right, buv.top), + Vec2::new(buv.right, 1.0 - buv.top - buv.bottom), + ), + white, + ); + // Bottom left + mesh.add_rect_with_uv( + Rect::from_min_size( + pr.min + Vec2::new(0.0, pr.height() - b.bottom), + Vec2::new(b.left, b.bottom), + ), + egui::Rect::from_min_size( + Pos2::new(0.0, 1.0 - buv.bottom), + Vec2::new(buv.left, buv.bottom), + ), + white, + ); + // Bottom center + mesh.add_rect_with_uv( + Rect::from_min_size( + pr.min + Vec2::new(b.left, pr.height() - b.bottom), + Vec2::new(pr.width() - b.left - b.right, b.bottom), + ), + egui::Rect::from_min_size( + Pos2::new(buv.left, 1.0 - buv.bottom), + Vec2::new(1.0 - buv.left - buv.right, buv.bottom), + ), + white, + ); + // Bottom right + mesh.add_rect_with_uv( + Rect::from_min_size( + pr.min + Vec2::new(pr.width() - b.right, pr.height() - b.bottom), + Vec2::new(b.right, b.bottom), + ), + egui::Rect::from_min_size( + Pos2::new(1.0 - buv.right, 1.0 - buv.bottom), + Vec2::new(buv.right, buv.bottom), + ), + white, + ); + + egui::Shape::Mesh(mesh) + } +} + +/// Internal helper struct for rendering the [`BorderedFrame`] +struct BorderedFramePrepared { + frame: BorderedFrame, + background_shape_idx: egui::layers::ShapeIdx, + content_ui: egui::Ui, +} + +impl BorderedFramePrepared { + fn end(self, ui: &mut egui::Ui) -> egui::Response { + use egui::Vec2; + + let min_rect = self.content_ui.min_rect(); + let m = self.frame.padding; + let paint_rect = egui::Rect { + min: min_rect.min - Vec2::new(m.left, m.top), + max: min_rect.max + Vec2::new(m.right, m.bottom), + }; + if ui.is_rect_visible(paint_rect) { + let shape = self.frame.paint(paint_rect); + ui.painter().set(self.background_shape_idx, shape); + } + + ui.allocate_rect(paint_rect, egui::Sense::hover()) + } +}