12 Commits

Author SHA1 Message Date
51e6989ef4 Swap button colors to be less upsetting
Making everything gray is still boring as dirt, but at least it doesn't
look like debug info.
2025-08-14 08:55:13 -05:00
2ffc0e8861 Finish main-menu button handling
All checks were successful
Basic checks / Basic build-and-test supertask (push) Successful in 7m8s
2025-08-13 17:25:26 -05:00
d15b96ef48 Add "start" and "quit" buttons to title screen
I'm stealing the game-over plugin's button handling for use in the main
menu. I guess it turns out that my widgets are more generic than
intended.
2025-08-13 17:00:29 -05:00
0aefc96f7a Center the text, add a drop shadow 2025-08-13 16:03:40 -05:00
4102446e90 Rename main menu marker to "MarkerMainMenu" 2025-08-13 15:38:53 -05:00
f4df2ae33a Rearrange the main menu components & systems
I'm trying to keep things somewhat in order. Plugins, then components,
then systems. Within those, they're roughly ordered by game state.
2025-08-13 15:36:14 -05:00
fc43be0777 Hook up the action selectors to the buttons
All checks were successful
Basic checks / Basic build-and-test supertask (push) Successful in 7m8s
2025-08-13 14:22:00 -05:00
a74b99deb4 Visual button interaction is working, needs act
All checks were successful
Basic checks / Basic build-and-test supertask (push) Successful in 7m22s
2025-08-13 12:59:38 -05:00
2f9401e93f Begin GameOver scene, widget spawner works
I've taken a lot directly from the Bevy UI button example.
(https://bevy.org/examples/ui-user-interface/button/)

I'll make it look better later. For now, it just needs to exist. Onward
to the UI operation system!
2025-08-13 11:58:11 -05:00
54ef257ab4 Make generic UI despawning function 2025-08-13 11:56:35 -05:00
69bef24913 Collect GUI plugins at the top of the module
I want the different "scenes" to be their own plugins for ease of setup
and reading.

The main menu plugin has been renamed to have "Plugin" first. This is so
the lexical sort in the docs places all the plugins next to each other.

The "get-ready" plugin has been given an empty struct and an
`impl Plugin` to match the main menu plugin. I've started the game over
scene, but left it unimplemented.
2025-08-13 11:09:48 -05:00
df4479bf49 Remove collision debug print system 2025-08-13 10:33:59 -05:00
3 changed files with 212 additions and 74 deletions

View File

@@ -5,6 +5,10 @@ use bevy::color::Color;
pub const WINDOW_SIZE: bevy::prelude::Vec2 = bevy::prelude::Vec2::new(800.0, 600.0); pub const WINDOW_SIZE: bevy::prelude::Vec2 = bevy::prelude::Vec2::new(800.0, 600.0);
pub const UI_BUTTON_NORMAL: Color = Color::srgb(0.15, 0.15, 0.15); // Button color when it's just hanging out
pub const UI_BUTTON_HOVERED: Color = Color::srgb(0.25, 0.25, 0.25); // ... when it's hovered
pub const UI_BUTTON_PRESSED: Color = Color::srgb(0.55, 0.55, 0.55); // ... when it's pressed
pub(crate) const BACKGROUND_COLOR: Color = Color::srgb(0.3, 0.3, 0.3); pub(crate) const BACKGROUND_COLOR: Color = Color::srgb(0.3, 0.3, 0.3);
pub(crate) const PLAYER_SHIP_COLOR: Color = Color::srgb(1.0, 1.0, 1.0); pub(crate) const PLAYER_SHIP_COLOR: Color = Color::srgb(1.0, 1.0, 1.0);
pub(crate) const SHIP_THRUSTER_COLOR_ACTIVE: Color = Color::srgb(1.0, 0.2, 0.2); pub(crate) const SHIP_THRUSTER_COLOR_ACTIVE: Color = Color::srgb(1.0, 0.2, 0.2);

View File

@@ -13,7 +13,7 @@ mod widgets;
use crate::config::{ use crate::config::{
ASTEROID_SMALL_COLOR, BACKGROUND_COLOR, BULLET_COLOR, BULLET_LIFETIME, BULLET_SPEED, ASTEROID_SMALL_COLOR, BACKGROUND_COLOR, BULLET_COLOR, BULLET_LIFETIME, BULLET_SPEED,
PLAYER_SHIP_COLOR, SHIP_ROTATION, SHIP_THRUST, SHIP_THRUSTER_COLOR_ACTIVE, PLAYER_SHIP_COLOR, SHIP_ROTATION, SHIP_THRUST, SHIP_THRUSTER_COLOR_ACTIVE,
SHIP_THRUSTER_COLOR_INACTIVE, WINDOW_SIZE, SHIP_THRUSTER_COLOR_INACTIVE,
}; };
use crate::machinery::AsteroidSpawner; use crate::machinery::AsteroidSpawner;
use crate::objects::{Bullet, Ship}; use crate::objects::{Bullet, Ship};
@@ -34,8 +34,9 @@ pub struct AsteroidPlugin;
impl Plugin for AsteroidPlugin { impl Plugin for AsteroidPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.add_plugins(( app.add_plugins((
widgets::GameMenuPlugin, widgets::PluginGameMenu,
widgets::preparation_widget_plugin, widgets::PluginGameOver,
widgets::PluginGetReady,
RapierPhysicsPlugin::<NoUserData>::pixels_per_meter(10.0), RapierPhysicsPlugin::<NoUserData>::pixels_per_meter(10.0),
RapierDebugRenderPlugin::default(), RapierDebugRenderPlugin::default(),
)) ))
@@ -64,8 +65,6 @@ impl Plugin for AsteroidPlugin {
objects::bullet_impact_listener, objects::bullet_impact_listener,
objects::ship_impact_listener, objects::ship_impact_listener,
physics::collision_listener, physics::collision_listener,
// TODO: Remove debug printing
debug_collision_event_printer,
machinery::tick_lifetimes, machinery::tick_lifetimes,
) )
.run_if(in_state(GameState::Playing)), .run_if(in_state(GameState::Playing)),
@@ -82,13 +81,7 @@ impl Plugin for AsteroidPlugin {
.add_event::<events::AsteroidDestroy>() .add_event::<events::AsteroidDestroy>()
.add_event::<events::ShipDestroy>() .add_event::<events::ShipDestroy>()
.add_event::<events::BulletDestroy>(); .add_event::<events::BulletDestroy>();
app.insert_state(GameState::Playing); app.insert_state(GameState::TitleScreen);
}
}
fn debug_collision_event_printer(mut collision_events: EventReader<CollisionEvent>) {
for event in collision_events.read() {
dbg!(event);
} }
} }

View File

@@ -1,34 +1,81 @@
use crate::{ use crate::{
GameState, GameState,
config::{UI_BUTTON_HOVERED, UI_BUTTON_NORMAL, UI_BUTTON_PRESSED},
resources::{Lives, Score}, resources::{Lives, Score},
}; };
use bevy::{ use bevy::{
color::palettes::css::{BLACK, GREEN, LIGHT_BLUE, RED}, color::palettes::css::{BLACK, DARK_GRAY, GREEN, LIGHT_BLUE, RED, WHITE},
prelude::*, prelude::*,
}; };
pub fn spawn_ui(mut commands: Commands, score: Res<Score>, lives: Res<Lives>) { /// Plugin for the main menu
commands.spawn(( pub struct PluginGameMenu;
Text::new(format!("Score: {score:?} | Lives: {lives:?}")),
TextFont::from_font_size(25.0), impl Plugin for PluginGameMenu {
)); fn build(&self, app: &mut App) {
app.add_systems(OnEnter(GameState::TitleScreen), spawn_menu)
.add_systems(OnExit(GameState::TitleScreen), despawn::<MarkerMainMenu>)
.add_systems(
Update,
(handle_spacebar, operate_buttons).run_if(in_state(GameState::TitleScreen)),
);
}
} }
pub fn preparation_widget_plugin(app: &mut App) { /// Plugin for the "get ready" period as gameplay starts.
app.add_systems(OnEnter(GameState::GetReady), spawn_get_ready) pub struct PluginGetReady;
.add_systems(OnExit(GameState::GetReady), despawn_get_ready)
.add_systems( impl Plugin for PluginGetReady {
Update, fn build(&self, app: &mut App) {
(animate_get_ready_widget).run_if(in_state(GameState::GetReady)), app.add_systems(OnEnter(GameState::GetReady), spawn_get_ready)
) .add_systems(OnExit(GameState::GetReady), despawn::<OnReadySetGo>)
.insert_resource(ReadySetGoTimer(Timer::from_seconds(3.0, TimerMode::Once))); .add_systems(
Update,
(animate_get_ready_widget).run_if(in_state(GameState::GetReady)),
)
.insert_resource(ReadySetGoTimer(Timer::from_seconds(3.0, TimerMode::Once)));
}
} }
/// Plugin for the game-over screen
pub struct PluginGameOver;
impl Plugin for PluginGameOver {
fn build(&self, app: &mut App) {
app.add_systems(OnEnter(GameState::GameOver), spawn_gameover_ui)
.add_systems(OnExit(GameState::GameOver), despawn::<MarkerGameOver>)
.add_systems(
Update,
operate_buttons.run_if(in_state(GameState::GameOver)),
);
}
}
// Marker component for the title screen UI entity.
// This way, a query for the TitleUI can be used to despawn the title screen
#[derive(Component)]
struct MarkerMainMenu;
/// Marker component for things on the get-ready indicator /// Marker component for things on the get-ready indicator
#[derive(Component)] #[derive(Component)]
struct OnReadySetGo; struct OnReadySetGo;
/// Marker for things on the game-over screen
#[derive(Component)]
struct MarkerGameOver;
/// Action specifier for the game-over menu's buttons.
///
/// Attach this component to a button and [`PluginGameOver`] will use it to
/// decide what to do when that button is pressed.
#[derive(Component)]
enum ButtonMenuAction {
ToMainMenu,
StartGame,
Quit,
}
/// Newtype wrapper for `Timer`. Used to count down during the "get ready" phase. /// Newtype wrapper for `Timer`. Used to count down during the "get ready" phase.
#[derive(Deref, DerefMut, Resource)] #[derive(Deref, DerefMut, Resource)]
struct ReadySetGoTimer(Timer); struct ReadySetGoTimer(Timer);
@@ -41,6 +88,70 @@ struct CountdownText;
#[derive(Component)] #[derive(Component)]
struct CountdownBar; struct CountdownBar;
/// Despawns entities matching the generic argument. Intended to remove UI
/// elements.
fn despawn<T: Component>(mut commands: Commands, to_despawn: Query<Entity, With<T>>) {
for entity in to_despawn {
commands.entity(entity).despawn();
}
}
/// Utility function for creating a standard button.
fn button_bundle(text: &str) -> impl Bundle {
(
Button,
// TODO: Generic action
Node {
width: Val::Px(150.0),
height: Val::Px(65.0),
border: UiRect::all(Val::Px(2.0)),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
margin: UiRect::all(Val::Px(5.0)),
..default()
},
BorderColor(Color::BLACK),
BorderRadius::MAX,
BackgroundColor(UI_BUTTON_NORMAL),
children![(
Text::new(text),
TextColor(Color::srgb(0.9, 0.9, 0.9)),
TextShadow::default(),
)],
)
}
fn spawn_menu(mut commands: Commands) {
commands
.spawn((
MarkerMainMenu,
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
flex_direction: FlexDirection::Column,
..Default::default()
},
))
.with_children(|cmds| {
cmds.spawn((
Text::new("Robert's Bad Asteroids Game"),
TextFont::from_font_size(50.0),
TextLayout::new_with_justify(JustifyText::Center),
TextShadow::default(),
));
cmds.spawn((
Text::new("Press space to begin"),
TextFont::from_font_size(20.0),
TextColor(Color::srgb(0.7, 0.7, 0.7)),
TextShadow::default(),
));
cmds.spawn((button_bundle("Start Game"), ButtonMenuAction::StartGame));
cmds.spawn((button_bundle("Quit"), ButtonMenuAction::Quit));
});
}
fn spawn_get_ready(mut commands: Commands) { fn spawn_get_ready(mut commands: Commands) {
commands.spawn(( commands.spawn((
OnReadySetGo, // marker, so this can be de-spawned properly OnReadySetGo, // marker, so this can be de-spawned properly
@@ -75,12 +186,25 @@ fn spawn_get_ready(mut commands: Commands) {
)); ));
} }
// TODO: Replace this with a generic somewhere else in the crate /// Spawns the game over screen.
// want: `despawn_screen::<OnReadySetGo>>()` ///
fn despawn_get_ready(mut commands: Commands, to_despawn: Query<Entity, With<OnReadySetGo>>) { /// Used by [`PluginGameOver`] when entering the [`GameState::GameOver`] state.
for entity in to_despawn { fn spawn_gameover_ui(mut commands: Commands) {
commands.entity(entity).despawn(); commands.spawn((
} MarkerGameOver, // Marker, so `despawn<T>` can remove this on state exit.
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
flex_direction: FlexDirection::Column,
..default()
},
children![
(button_bundle("Main Menu"), ButtonMenuAction::ToMainMenu,),
(button_bundle("Quit"), ButtonMenuAction::Quit),
],
));
} }
fn animate_get_ready_widget( fn animate_get_ready_widget(
@@ -108,53 +232,70 @@ fn animate_get_ready_widget(
} }
} }
pub struct GameMenuPlugin; /// Handles interactions with the menu buttons.
///
impl Plugin for GameMenuPlugin { /// The buttons are used by the main menu and the game-over menu to change
fn build(&self, app: &mut App) { /// between game states.
app.add_systems(OnEnter(GameState::TitleScreen), spawn_menu) ///
.add_systems(OnExit(GameState::TitleScreen), despawn_menu) /// Button animation and action handling is done entirely within this system.
.add_systems( ///
Update, /// There are no checks for current state. If a "quit" button was put somewhere
handle_spacebar.run_if(in_state(GameState::TitleScreen)), /// on the HUD, this system would quit the game. The same will happen for
); /// returning to the title screen. This should be useful for making a pause
} /// menu, too.
} fn operate_buttons(
mut interactions: Query<
// Marker component for the title screen UI entity. (
// This way, a query for the TitleUI can be used to despawn the title screen &Interaction,
#[derive(Component)] &mut BackgroundColor,
struct TitleUI; &mut BorderColor,
&ButtonMenuAction,
fn spawn_menu(mut commands: Commands) { ),
commands (Changed<Interaction>, With<Button>),
.spawn(( >,
TitleUI, mut game_state: ResMut<NextState<GameState>>,
Node { mut app_exit_events: EventWriter<AppExit>,
flex_direction: FlexDirection::Column, ) {
..Default::default() // TODO: Better colors. These are taken from the example and they're ugly.
}, for (interaction, mut color, mut border_color, menu_action) in &mut interactions {
)) match *interaction {
.with_children(|cmds| { Interaction::Pressed => {
cmds.spawn(( *color = UI_BUTTON_PRESSED.into();
Text::new("Robert's Bad Asteroids Game"), border_color.0 = DARK_GRAY.into();
TextFont::from_font_size(50.0), match menu_action {
)); ButtonMenuAction::ToMainMenu => {
cmds.spawn(( game_state.set(GameState::TitleScreen);
Text::new("Press space to begin"), }
TextFont::from_font_size(40.0), ButtonMenuAction::StartGame => {
)); game_state.set(GameState::Playing);
}); }
} ButtonMenuAction::Quit => {
app_exit_events.write(AppExit::Success);
fn despawn_menu(mut commands: Commands, to_despawn: Query<Entity, With<TitleUI>>) { }
for entity in &to_despawn { }
commands.entity(entity).despawn(); }
Interaction::Hovered => {
*color = UI_BUTTON_HOVERED.into();
border_color.0 = WHITE.into();
}
Interaction::None => {
*color = UI_BUTTON_NORMAL.into();
border_color.0 = BLACK.into();
}
}
} }
} }
/// Main menu input listener. Starts game when the spacebar is pressed.
fn handle_spacebar(input: Res<ButtonInput<KeyCode>>, mut game_state: ResMut<NextState<GameState>>) { fn handle_spacebar(input: Res<ButtonInput<KeyCode>>, mut game_state: ResMut<NextState<GameState>>) {
if input.just_pressed(KeyCode::Space) { if input.just_pressed(KeyCode::Space) {
game_state.set(GameState::GetReady); game_state.set(GameState::GetReady);
} }
} }
pub fn spawn_ui(mut commands: Commands, score: Res<Score>, lives: Res<Lives>) {
commands.spawn((
Text::new(format!("Score: {score:?} | Lives: {lives:?}")),
TextFont::from_font_size(25.0),
));
}