25 Commits

Author SHA1 Message Date
9d9b25d1df Name the profiles to not suggest they're WASM-only
All checks were successful
Basic checks / Basic build-and-test supertask (push) Successful in 7m13s
2025-08-14 11:04:20 -05:00
89ee328807 A couple extra build profiles 2025-08-14 11:04:20 -05:00
90d8142855 Use wasm-server-runner as the WASM runner
This way I can `cargo run --target ...` and Cargo will spawn a dummy
webserver instead of attempting to execute the WASM file on my x86/arm
CPU.
2025-08-14 11:03:37 -05:00
10366b642c Add wasm-specific feature for RNG child dependency
The `rand` crate eventually depends on `getrandom` which requires a
different feature set when running in the browser. WASM has no OS, and
so no RNG provider... but the browser, specifically, has one that the
JavaScript runtime can touch. Enabling the "wasm_js" feature on
`getrandom` allowes it to use this backend.

An extra config option must *also* be passed to `rustc`. THis can be set
through the environment, or the config.toml. I've chosen the latter so I
don't need to think about it very often.
2025-08-14 11:03:37 -05:00
94e0cd0999 Add my own crate features to pass through to deps
All checks were successful
Basic checks / Basic build-and-test supertask (push) Successful in 7m25s
Closes #20

Now this crate can be told to build with or without certain behavior,
rather than needing to patch the Cargo.toml file.
2025-08-14 10:34:03 -05:00
18d026637b Bump the crate version
I marked 0.2 at some point, so I guess I should keep incrementing this
during development. The menus are finished, which is as good a
checkpoint as any.
2025-08-14 08:58:02 -05:00
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
33828d6a85 Rewrite WorldSize as a newtype around Vec2
All checks were successful
Basic checks / Basic build-and-test supertask (push) Successful in 7m7s
Closes #16

Bevy provides Deref and DerefMut derives, so that's nice. I'm not sure
it makes any sense to keep the field private since those derefs expose
the Vec2 anyway. I added an `impl Default` anyway, though.
2025-08-12 23:39:19 -05:00
5e2018d3e4 Docs for physics module 2025-08-12 23:26:37 -05:00
7c877264a2 autoformat 2025-08-12 23:18:46 -05:00
16842c13f7 Docs for objects.rs 2025-08-12 23:18:33 -05:00
e199db16eb Docs for machinery.rs 2025-08-12 23:01:32 -05:00
f17910daef Docs for lib.rs 2025-08-12 22:41:37 -05:00
70f2313766 Improve documentation for the events
Some checks failed
Basic checks / Basic build-and-test supertask (push) Has been cancelled
2025-08-12 11:33:00 -05:00
10 changed files with 329 additions and 108 deletions

4
.cargo/config.toml Normal file
View File

@@ -0,0 +1,4 @@
[target.wasm32-unknown-unknown]
runner = "wasm-server-runner"
rustflags = ["--cfg", "getrandom_backend=\"wasm_js\""]

View File

@@ -1,10 +1,33 @@
[package] [package]
name = "asteroids" name = "asteroids"
version = "0.2.0" version = "0.3.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
bevy = { version = "0.16", features = ["dynamic_linking"] } bevy = "0.16"
bevy-inspector-egui = "0.32.0" bevy-inspector-egui = "0.32.0"
bevy_rapier2d = { version = "0.31.0", features = ["debug-render-2d"] } bevy_rapier2d = "0.31.0"
rand = "0.9.2" rand = "0.9.2"
[features]
dynamic_linking = ["bevy/dynamic_linking"]
debug_ui = ["bevy_rapier2d/debug-render-2d"]
[target.'cfg(target_arch = "wasm32")'.dependencies]
getrandom = { version = "0.3.3", features = ["wasm_js"] }
[profile.speedy]
inherits = "release"
codegen-units = 1
lto = "fat"
opt-level = 3
strip = "symbols"
panic = "abort"
[profile.tiny]
inherits = "release"
codegen-units = 1
lto = "fat"
opt-level = "z"
strip = "symbols"
panic = "abort"

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

@@ -3,15 +3,25 @@ use bevy::prelude::*;
use crate::objects::AsteroidSize; use crate::objects::AsteroidSize;
/// Signals that the player's ship has been destroyed. /// Signals that the player's ship has been destroyed.
/// Used when the player collides with an asteroid. ///
/// Produced by the [`fn collision_listener(...)`](`crate::physics::collision_listener`)
/// system when the player collides with an asteroid. They are consumed by [`fn ship_impact_listener(...)`](`crate::objects::ship_impact_listener`).
#[derive(Event)] #[derive(Event)]
pub(crate) struct ShipDestroy; pub(crate) struct ShipDestroy;
/// Signals that a particular asteroid has been destroyed. /// Signals that a particular asteroid has been destroyed.
/// Used to split (or vanish) an asteroid when a bullet strikes it. ///
/// Produced by the [`fn collision_listener(...)`](`crate::physics::collision_listener`)
/// system when bullets contact asteroids. It is consumed by [`fn split_asteroid(...)`](`crate::objects::spawn_asteroid`)
/// system, which re-emits [spawn events](`SpawnAsteroid`).
#[derive(Event)] #[derive(Event)]
pub(crate) struct AsteroidDestroy(pub Entity); pub(crate) struct AsteroidDestroy(pub Entity);
/// Signals that an asteroid needs to be spawned and provides the parameters
/// for that action.
///
/// Produced by the [`tick_asteroid_manager(...)`](`crate::machinery::tick_asteroid_manager`)
/// system and consumed by the [`spawn_asteroid(...)`](`crate::objects::spawn_asteroid`) system.
#[derive(Event)] #[derive(Event)]
pub struct SpawnAsteroid { pub struct SpawnAsteroid {
pub pos: Vec2, pub pos: Vec2,
@@ -19,11 +29,7 @@ pub struct SpawnAsteroid {
pub size: AsteroidSize, pub size: AsteroidSize,
} }
// TODO: BulletDestroy /// Signals that a particular bullet has been destroyed (after it strikes an Asteroid).
// Which depends on the still-pending Bullet component creation.
/// Signals that a particular bullet has been destroyed.
/// Used to despawn the bullet after it strikes an Asteroid.
/// ///
/// TODO: Maybe use it for lifetime expiration (which is also a TODO item). /// TODO: Maybe use it for lifetime expiration (which is also a TODO item).
#[derive(Event)] #[derive(Event)]

View File

@@ -1,3 +1,7 @@
//! Asteroids, implemented as a Bevy plugin.
//!
//! Compile-time configurables can be found in the [`config`] module.
pub mod config; pub mod config;
mod events; mod events;
mod machinery; mod machinery;
@@ -9,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};
@@ -24,21 +28,20 @@ use bevy_rapier2d::{
use machinery::Lifetime; use machinery::Lifetime;
use resources::{GameAssets, Lives, Score, WorldSize}; use resources::{GameAssets, Lives, Score, WorldSize};
/// The main game plugin.
pub struct AsteroidPlugin; 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(),
)) ))
.insert_resource(ClearColor(BACKGROUND_COLOR)) .insert_resource(ClearColor(BACKGROUND_COLOR))
.insert_resource(WorldSize { .insert_resource(WorldSize::default())
width: WINDOW_SIZE.x,
height: WINDOW_SIZE.y,
})
.insert_resource(Lives(3)) .insert_resource(Lives(3))
.register_type::<Lives>() .register_type::<Lives>()
.insert_resource(Score(0)) .insert_resource(Score(0))
@@ -62,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)),
@@ -80,16 +81,11 @@ 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);
} }
} }
/// The game's main state tracking mechanism, registered with Bevy as a [`State`](`bevy::prelude::State`).
#[derive(Clone, Debug, Eq, Hash, PartialEq, States)] #[derive(Clone, Debug, Eq, Hash, PartialEq, States)]
pub enum GameState { pub enum GameState {
TitleScreen, // Program is started. Present title screen and await user start TitleScreen, // Program is started. Present title screen and await user start
@@ -102,9 +98,9 @@ fn spawn_camera(mut commands: Commands) {
commands.spawn(Camera2d); commands.spawn(Camera2d);
} }
/* /// Player's thruster control system.
Checks if "W" is pressed and increases velocity accordingly. ///
*/ /// Checks if "W" is pressed and increases velocity accordingly.
fn input_ship_thruster( fn input_ship_thruster(
keyboard_input: Res<ButtonInput<KeyCode>>, keyboard_input: Res<ButtonInput<KeyCode>>,
mut query: Query<(&mut physics::Velocity, &Transform, &mut Children), With<Ship>>, mut query: Query<(&mut physics::Velocity, &Transform, &mut Children), With<Ship>>,
@@ -135,10 +131,10 @@ fn input_ship_thruster(
} }
} }
/* /// Player's rotation control system.
Checks if "A" or "D" is pressed and updates the player's Rotation component accordingly ///
Does *not* rotate the graphical widget! (that's done by the `apply_rotation_to_mesh` system) /// Checks if "A" or "D" is pressed and updates the player's [`AngularVelocity`]
*/ /// component accordingly.
fn input_ship_rotation( fn input_ship_rotation(
keyboard_input: Res<ButtonInput<KeyCode>>, keyboard_input: Res<ButtonInput<KeyCode>>,
mut query: Query<&mut AngularVelocity, With<Ship>>, mut query: Query<&mut AngularVelocity, With<Ship>>,
@@ -157,6 +153,12 @@ fn input_ship_rotation(
} }
} }
/// Player's gun trigger.
///
/// Checks if the spacebar has just been pressed, spawning a bullet if so.
///
/// TODO: Hook up a timer to control weapon fire-rate. Something will have to
/// tick those timers. Maybe this system?
fn input_ship_shoot( fn input_ship_shoot(
keyboard_input: Res<ButtonInput<KeyCode>>, keyboard_input: Res<ButtonInput<KeyCode>>,
ship: Single<(&Transform, &physics::Velocity), With<Ship>>, ship: Single<(&Transform, &physics::Velocity), With<Ship>>,

View File

@@ -1,5 +1,8 @@
//! These are Systems that power the main game mechanics (and some misc items to support them) //! Systems, Components, and any other items for powering the game logic.
//!
//! Where the objects (ship, asteroid, etc) carry their own behavioral systems,
//! the *game* keeps its main logic here. Its for ambient behaviors, like
//! asteroid spawning, or eventually the flying saucer spawns.
use rand::{Rng, SeedableRng}; use rand::{Rng, SeedableRng};
use std::time::Duration; use std::time::Duration;
@@ -7,6 +10,12 @@ use bevy::prelude::*;
use crate::{WorldSize, events::SpawnAsteroid, objects::AsteroidSize}; use crate::{WorldSize, events::SpawnAsteroid, objects::AsteroidSize};
/// Asteroid spawning parameters and state.
///
/// This struct keeps track of the rng and timer for spawning asteroids. In the
/// future it may contain additional fields to allow for more control.
///
/// It's values are operated by the [`tick_asteroid_manager`] system.
#[derive(Resource)] #[derive(Resource)]
pub struct AsteroidSpawner { pub struct AsteroidSpawner {
rng: std::sync::Mutex<rand::rngs::StdRng>, rng: std::sync::Mutex<rand::rngs::StdRng>,
@@ -26,8 +35,8 @@ impl AsteroidSpawner {
} }
} }
/// Update the asteroid spawn timer and spawn any asteroids /// Update the asteroid spawn timer in the [`AsteroidSpawner`] resource, and
/// that are due this frame. /// spawns any asteroids that are due this frame.
pub fn tick_asteroid_manager( pub fn tick_asteroid_manager(
mut events: EventWriter<SpawnAsteroid>, mut events: EventWriter<SpawnAsteroid>,
mut spawner: ResMut<AsteroidSpawner>, mut spawner: ResMut<AsteroidSpawner>,
@@ -46,7 +55,7 @@ pub fn tick_asteroid_manager(
let spawn_angle = rng.random_range(0.0..(std::f32::consts::PI * 2.0)); let spawn_angle = rng.random_range(0.0..(std::f32::consts::PI * 2.0));
// Rho will be the radius of a circle bordering the viewport, multiplied by 1.2 // Rho will be the radius of a circle bordering the viewport, multiplied by 1.2
// TODO: Use view diagonal to get a minimally sized circle around the play area // TODO: Use view diagonal to get a minimally sized circle around the play area
let spawn_distance = play_area.width.max(play_area.height) / 2.0; let spawn_distance = play_area.x.max(play_area.y) / 2.0;
// Convert polar to Cartesian, use as position // Convert polar to Cartesian, use as position
let pos = Vec2::new( let pos = Vec2::new(
@@ -77,9 +86,11 @@ pub fn tick_asteroid_manager(
} }
} }
/// Sets a lifetime on an entity to automatically delete it after expiration.
#[derive(Component)] #[derive(Component)]
pub struct Lifetime(pub Timer); pub struct Lifetime(pub Timer);
/// System to tick the [`Lifetime`] timers and despawn expired entities.
pub fn tick_lifetimes( pub fn tick_lifetimes(
mut commands: Commands, mut commands: Commands,
time: Res<Time>, time: Res<Time>,

View File

@@ -28,6 +28,7 @@ use crate::{
physics::{Velocity, Wrapping}, physics::{Velocity, Wrapping},
}; };
/// The asteroid, defined entirely by [it's size](`AsteroidSize`).
#[derive(Component, Deref, DerefMut)] #[derive(Component, Deref, DerefMut)]
pub struct Asteroid(pub AsteroidSize); pub struct Asteroid(pub AsteroidSize);
@@ -39,6 +40,8 @@ pub enum AsteroidSize {
} }
impl AsteroidSize { impl AsteroidSize {
/// Convenience util to get the "next smallest" size. Useful for splitting
/// after collision.
pub fn next(&self) -> Option<Self> { pub fn next(&self) -> Option<Self> {
match self { match self {
AsteroidSize::Small => None, AsteroidSize::Small => None,
@@ -48,9 +51,11 @@ impl AsteroidSize {
} }
} }
/// Marker component for the player's ship.
#[derive(Component)] #[derive(Component)]
pub struct Ship; pub struct Ship;
/// Marker component for bullets.
#[derive(Component)] #[derive(Component)]
pub struct Bullet; pub struct Bullet;
@@ -126,6 +131,11 @@ pub fn split_asteroids(
} }
} }
/// Spawns the player at the world origin. Used during the state change to
/// [`GameState::Playing`] to spawn the player.
///
/// This only spawns the player. For player **re**-spawn activity, see the
/// [`ship_impact_listener()`] system.
pub fn spawn_player(mut commands: Commands, game_assets: Res<GameAssets>) { pub fn spawn_player(mut commands: Commands, game_assets: Res<GameAssets>) {
commands commands
.spawn(( .spawn((
@@ -160,6 +170,9 @@ pub fn bullet_impact_listener(mut commands: Commands, mut events: EventReader<Bu
/// Watch for [`ShipDestroy`] events and update game state accordingly. /// Watch for [`ShipDestroy`] events and update game state accordingly.
/// ///
/// One life is taken from the counter, asteroids are cleared, and the player
/// is placed back at the origin. If lives reach 0, this system will change
/// states to [`GameState::GameOver`].
/// - Subtract a life /// - Subtract a life
/// - Check life count. If 0, go to game-over state /// - Check life count. If 0, go to game-over state
/// - Clear all asteroids /// - Clear all asteroids

View File

@@ -1,5 +1,19 @@
//! Custom physics items //! Custom physics items
//!
//! TODO: Refactor in terms of Rapier2D, *or* implement colliders and remove it. //! TODO: Refactor in terms of Rapier2D, *or* implement colliders and remove it.
//!
//! Position, both translation and rotation, are tracked by the built-in
//! Bevy [`Transform`] component. This module adds a (Linear) [`Velocity`] and
//! [`AngularVelocity`] component to the mix.
//! These two components are operated by simple integrator systems to
//! accumulate translation and rotation from the velocities.
//!
//! The [`Wrapping`] component marks things that should wrap around the world
//! boundaries. It's a kind of physical property of this world, so I'm putting
//! it here.
//!
//! Collisions are also considered physics. After all, I got *a physics engine*
//! to detect them for me (so that I don't have to write clipping code).
use crate::{ use crate::{
WorldSize, events, WorldSize, events,
@@ -42,9 +56,9 @@ pub(crate) fn wrap_entities(
mut query: Query<&mut Transform, With<Wrapping>>, mut query: Query<&mut Transform, With<Wrapping>>,
world_size: Res<WorldSize>, world_size: Res<WorldSize>,
) { ) {
let right = world_size.width / 2.0; let right = world_size.x / 2.0;
let left = -right; let left = -right;
let top = world_size.height / 2.0; let top = world_size.y / 2.0;
let bottom = -top; let bottom = -top;
for mut pos in query.iter_mut() { for mut pos in query.iter_mut() {

View File

@@ -10,7 +10,7 @@ use bevy::{
Vec2, Vec2,
primitives::{Circle, Triangle2d}, primitives::{Circle, Triangle2d},
}, },
prelude::{Deref, Reflect, ReflectResource}, prelude::{Deref, DerefMut, Reflect, ReflectResource},
render::mesh::Mesh, render::mesh::Mesh,
sprite::ColorMaterial, sprite::ColorMaterial,
}; };
@@ -19,7 +19,7 @@ use bevy_inspector_egui::inspector_options::ReflectInspectorOptions;
use crate::{ use crate::{
ASTEROID_SMALL_COLOR, BULLET_COLOR, PLAYER_SHIP_COLOR, SHIP_THRUSTER_COLOR_ACTIVE, ASTEROID_SMALL_COLOR, BULLET_COLOR, PLAYER_SHIP_COLOR, SHIP_THRUSTER_COLOR_ACTIVE,
SHIP_THRUSTER_COLOR_INACTIVE, SHIP_THRUSTER_COLOR_INACTIVE, config::WINDOW_SIZE,
}; };
#[derive(Resource, Debug, Deref, Clone, Copy)] #[derive(Resource, Debug, Deref, Clone, Copy)]
@@ -41,10 +41,13 @@ impl From<Lives> for String {
} }
} }
#[derive(Resource)] #[derive(Deref, DerefMut, Resource)]
pub struct WorldSize { pub struct WorldSize(Vec2);
pub width: f32,
pub height: f32, impl Default for WorldSize {
fn default() -> Self {
WorldSize(Vec2::new(WINDOW_SIZE.x, WINDOW_SIZE.y))
}
} }
#[derive(Resource)] #[derive(Resource)]

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.
pub struct PluginGetReady;
impl Plugin for PluginGetReady {
fn build(&self, app: &mut App) {
app.add_systems(OnEnter(GameState::GetReady), spawn_get_ready) app.add_systems(OnEnter(GameState::GetReady), spawn_get_ready)
.add_systems(OnExit(GameState::GetReady), despawn_get_ready) .add_systems(OnExit(GameState::GetReady), despawn::<OnReadySetGo>)
.add_systems( .add_systems(
Update, Update,
(animate_get_ready_widget).run_if(in_state(GameState::GetReady)), (animate_get_ready_widget).run_if(in_state(GameState::GetReady)),
) )
.insert_resource(ReadySetGoTimer(Timer::from_seconds(3.0, TimerMode::Once))); .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),
));
}