35 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
58bbc1e614 Fix documentation links 2025-08-11 23:50:43 -05:00
79679759c5 Move HUD spawning system into widgets
It actually needs to be completely replaced. When that finally happens,
the new bits will live here.
2025-08-11 23:42:06 -05:00
0e517de419 Move the title screen stuff into widgets.rs 2025-08-11 23:40:52 -05:00
cb2b57449a Create a "widgets" module (by moving title scene)
The non-gameplay scenes are really just a bunch of widgets. I'm going to
put them all together and then bundle the functionality with some
exported plugin builders.
2025-08-11 23:39:42 -05:00
9a262fcffc Move Lifetime component to machinery module 2025-08-11 23:34:12 -05:00
34ee2fcc7d Create a "machinery" module for game systems
I'm not sold on the name, but anyway.

This module will hold the systems that power the main game mechanics, as
well as a few extra items to support that (in particular, the Lifetime
component).
2025-08-11 23:28:39 -05:00
93da225636 Move resources to a resources.rs submodule 2025-08-11 23:19:06 -05:00
cd194e2dbf Move collision system to physics module 2025-08-11 23:07:18 -05:00
1369a3092f Move the ship & bullet systems into the object mod 2025-08-11 23:01:14 -05:00
619037dab0 Move asteroid spawning & splitting systems
They deal with events, but are directly related to how the Asteroid
object interacts with the events, so I'm putting them here.
2025-08-11 22:59:20 -05:00
14 changed files with 878 additions and 622 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]
name = "asteroids"
version = "0.2.0"
version = "0.3.0"
edition = "2024"
[dependencies]
bevy = { version = "0.16", features = ["dynamic_linking"] }
bevy = "0.16"
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"
[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

@@ -1,160 +0,0 @@
//! This is the module containing all the rock-related things.
//! Not... not the whole game.
use bevy_rapier2d::prelude::*;
use rand::{Rng, SeedableRng};
use std::time::Duration;
use bevy::prelude::*;
use crate::{
GameAssets, Lifetime, WorldSize,
config::ASTEROID_LIFETIME,
events::{AsteroidDestroy, SpawnAsteroid},
objects::{Asteroid, AsteroidSize},
physics::Velocity,
};
#[derive(Resource)]
pub struct AsteroidSpawner {
rng: std::sync::Mutex<rand::rngs::StdRng>,
timer: Timer,
// TODO: Configurables?
// - interval
// - density
// - size distribution
}
impl AsteroidSpawner {
pub fn new() -> Self {
Self {
rng: std::sync::Mutex::new(rand::rngs::StdRng::from_seed(crate::config::RNG_SEED)),
timer: Timer::new(Duration::from_secs(3), TimerMode::Repeating),
}
}
}
/// Update the asteroid spawn timer and spawn any asteroids
/// that are due this frame.
pub fn tick_asteroid_manager(
mut events: EventWriter<SpawnAsteroid>,
mut spawner: ResMut<AsteroidSpawner>,
time: Res<Time>,
play_area: Res<WorldSize>,
) {
spawner.timer.tick(time.delta());
if spawner.timer.just_finished() {
let mut rng = spawner
.rng
.lock()
.expect("Expected to acquire lock on the AsteroidSpawner's RNG field.");
// Use polar coordinate to decide where the asteroid will spawn
// Theta will be random between 0 to 2pi
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
// 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;
// Convert polar to Cartesian, use as position
let pos = Vec2::new(
spawn_distance * spawn_angle.cos(),
spawn_distance * spawn_angle.sin(),
);
// Right now, I'm thinking I can use the opposite signs attached to the position Vec components.
// pos.x == -100, then vel.x = + <random>
// pos.x == 100, then vel.x = - <random>
// etc,
let mut vel = Vec2::new(rng.random_range(0.0..100.0), rng.random_range(0.0..100.0));
if pos.x > 0.0 {
vel.x *= -1.0;
}
if pos.y > 0.0 {
vel.y *= -1.0;
}
let size = match rng.random_range(0..=2) {
0 => AsteroidSize::Small,
1 => AsteroidSize::Medium,
2 => AsteroidSize::Large,
_ => unreachable!(),
};
events.write(SpawnAsteroid { pos, vel, size });
}
}
/// Utility function to spawn a single asteroid of a given type
/// TODO: convert to an event listener monitoring for "spawn asteroid" events
/// from the `fn tick_asteroid_manager(...)` system.
pub fn spawn_asteroid(
mut events: EventReader<SpawnAsteroid>,
mut commands: Commands,
game_assets: Res<GameAssets>,
) {
for spawn in events.read() {
let (mesh, material) = match spawn.size {
AsteroidSize::Small => game_assets.asteroid_small(),
AsteroidSize::Medium => game_assets.asteroid_medium(),
AsteroidSize::Large => game_assets.asteroid_large(),
};
let collider_radius = match spawn.size {
AsteroidSize::Small => 10.0,
AsteroidSize::Medium => 20.0,
AsteroidSize::Large => 40.0,
};
commands.spawn((
Asteroid(spawn.size),
Collider::ball(collider_radius),
Sensor,
Transform::from_translation(spawn.pos.extend(0.0)),
Velocity(spawn.vel),
Mesh2d(mesh),
MeshMaterial2d(material),
Lifetime(Timer::from_seconds(ASTEROID_LIFETIME, TimerMode::Once)),
));
}
}
/// Event listener for asteroid destruction events. Shrinks and multiplies
/// asteroids until they vanish.
///
/// - Large -> 2x Medium
/// - Medium -> 2x Small
/// - Small -> (despawned)
///
/// The velocity of the child asteroids is scattered somewhat, as if they were
/// explosively pushed apart.
pub fn split_asteroids(
mut destroy_events: EventReader<AsteroidDestroy>,
mut respawn_events: EventWriter<SpawnAsteroid>,
mut commands: Commands,
query: Query<(&Transform, &Asteroid, &Velocity)>,
) {
for event in destroy_events.read() {
if let Ok((transform, rock, velocity)) = query.get(event.0) {
let next_size = rock.0.next();
if let Some(size) = next_size {
let pos = transform.translation.xy();
let left_offset = Vec2::from_angle(0.4);
let right_offset = Vec2::from_angle(-0.4);
respawn_events.write(SpawnAsteroid {
pos,
vel: left_offset.rotate(velocity.0),
size,
});
respawn_events.write(SpawnAsteroid {
pos,
vel: right_offset.rotate(velocity.0),
size,
});
}
// Always despawn the asteroid. New ones (may) be spawned in it's
// place, but this one is gone.
commands.entity(event.0).despawn();
}
}
}

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 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 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);

View File

@@ -3,15 +3,25 @@ use bevy::prelude::*;
use crate::objects::AsteroidSize;
/// 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)]
pub(crate) struct ShipDestroy;
/// 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)]
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)]
pub struct SpawnAsteroid {
pub pos: Vec2,
@@ -19,11 +29,7 @@ pub struct SpawnAsteroid {
pub size: AsteroidSize,
}
// TODO: BulletDestroy
// 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.
/// Signals that a particular bullet has been destroyed (after it strikes an Asteroid).
///
/// TODO: Maybe use it for lifetime expiration (which is also a TODO item).
#[derive(Event)]

View File

@@ -1,52 +1,57 @@
mod asteroids;
//! Asteroids, implemented as a Bevy plugin.
//!
//! Compile-time configurables can be found in the [`config`] module.
pub mod config;
mod events;
mod machinery;
mod objects;
mod physics;
mod preparation_widget;
mod ship;
mod title_screen;
mod resources;
mod widgets;
use crate::asteroids::AsteroidSpawner;
use crate::config::{
ASTEROID_SMALL_COLOR, BACKGROUND_COLOR, BULLET_COLOR, BULLET_LIFETIME, BULLET_SPEED,
PLAYER_SHIP_COLOR, SHIP_ROTATION, SHIP_THRUST, SHIP_THRUSTER_COLOR_ACTIVE,
SHIP_THRUSTER_COLOR_INACTIVE, WINDOW_SIZE,
SHIP_THRUSTER_COLOR_INACTIVE,
};
use crate::objects::{Asteroid, Bullet, Ship};
use crate::machinery::AsteroidSpawner;
use crate::objects::{Bullet, Ship};
use crate::physics::AngularVelocity;
use bevy::prelude::*;
use bevy_inspector_egui::InspectorOptions;
use bevy_inspector_egui::prelude::ReflectInspectorOptions;
use bevy_rapier2d::{
plugin::{NoUserData, RapierPhysicsPlugin},
prelude::*,
render::RapierDebugRenderPlugin,
};
use machinery::Lifetime;
use resources::{GameAssets, Lives, Score, WorldSize};
/// The main game plugin.
pub struct AsteroidPlugin;
impl Plugin for AsteroidPlugin {
fn build(&self, app: &mut App) {
app.add_plugins((
title_screen::GameMenuPlugin,
preparation_widget::preparation_widget_plugin,
widgets::PluginGameMenu,
widgets::PluginGameOver,
widgets::PluginGetReady,
RapierPhysicsPlugin::<NoUserData>::pixels_per_meter(10.0),
RapierDebugRenderPlugin::default(),
))
.insert_resource(ClearColor(BACKGROUND_COLOR))
.insert_resource(WorldSize {
width: WINDOW_SIZE.x,
height: WINDOW_SIZE.y,
})
.insert_resource(WorldSize::default())
.insert_resource(Lives(3))
.register_type::<Lives>()
.insert_resource(Score(0))
.insert_resource(AsteroidSpawner::new())
.init_resource::<GameAssets>()
.add_systems(Startup, spawn_camera)
.add_systems(OnEnter(GameState::Playing), (ship::spawn_player, spawn_ui))
.add_systems(
OnEnter(GameState::Playing),
(objects::spawn_player, widgets::spawn_ui),
)
.add_systems(
FixedUpdate,
(
@@ -54,15 +59,13 @@ impl Plugin for AsteroidPlugin {
input_ship_rotation,
input_ship_shoot,
physics::wrap_entities,
asteroids::tick_asteroid_manager,
asteroids::spawn_asteroid.after(asteroids::tick_asteroid_manager),
asteroids::split_asteroids,
ship::bullet_impact_listener,
ship::ship_impact_listener,
collision_listener,
// TODO: Remove debug printing
debug_collision_event_printer,
tick_lifetimes,
machinery::tick_asteroid_manager,
objects::spawn_asteroid.after(machinery::tick_asteroid_manager),
objects::split_asteroids,
objects::bullet_impact_listener,
objects::ship_impact_listener,
physics::collision_listener,
machinery::tick_lifetimes,
)
.run_if(in_state(GameState::Playing)),
)
@@ -78,79 +81,11 @@ impl Plugin for AsteroidPlugin {
.add_event::<events::AsteroidDestroy>()
.add_event::<events::ShipDestroy>()
.add_event::<events::BulletDestroy>();
app.insert_state(GameState::Playing);
}
}
fn debug_collision_event_printer(mut collision_events: EventReader<CollisionEvent>) {
for event in collision_events.read() {
dbg!(event);
}
}
/// The collision event routing system.
///
/// When a `CollisionEvent` occurrs, this system checks which things collided
/// and emits secondary events accordignly.
///
/// | Objects | Response |
/// |-|-|
/// | Ship & Asteroid | emits event [`ShipDestroy`](`crate::event::ShipDestroy`) |
/// | Asteroid & Bullet | emits event [`AsteroidDestroy`](`crate::event::AsteroidDestroy`) |
/// | Asteroid & Asteroid | Nothing. Asteroids won't collide with each other |
/// | Bullet & Bullet | Nothing. Bullets won't collide with each other (and probably can't under normal gameplay conditions) |
/// | Bullet & Ship | Nothing. The player shouldn't be able to shoot themselves (and the Flying Saucer hasn't been impl.'d, so it's bullets don't count) |
fn collision_listener(
mut collisions: EventReader<CollisionEvent>,
mut ship_writer: EventWriter<events::ShipDestroy>,
mut asteroid_writer: EventWriter<events::AsteroidDestroy>,
mut bullet_writer: EventWriter<events::BulletDestroy>,
player: Single<Entity, With<Ship>>,
bullets: Query<&Bullet>,
rocks: Query<&Asteroid>,
) {
for event in collisions.read() {
if let CollisionEvent::Started(one, two, _flags) = event {
// Valid collisions are:
//
// - Ship & Asteroid
// - Bullet & Asteroid
//
// Asteroids don't collide with each other, bullets don't collide
// with each other, and bullets don't collide with the player ship.
// Option 1: Ship & Asteroid
if *one == *player {
if rocks.contains(*two) {
// player-asteroid collision
dbg!("Writing ShipDestroy event");
ship_writer.write(events::ShipDestroy);
} // else, we don't care
} else if *two == *player {
if rocks.contains(*one) {
dbg!("Writing ShipDestroy event");
ship_writer.write(events::ShipDestroy);
}
}
// Option 2: Bullet & Asteroid
if bullets.contains(*one) {
if rocks.contains(*two) {
dbg!("Writing AsteroidDestroy & BulletDestroy events");
asteroid_writer.write(events::AsteroidDestroy(*two));
bullet_writer.write(events::BulletDestroy(*one));
}
} else if rocks.contains(*one) {
if bullets.contains(*two) {
dbg!("Writing AsteroidDestroy & BulletDestroy events");
asteroid_writer.write(events::AsteroidDestroy(*one));
bullet_writer.write(events::BulletDestroy(*two));
}
}
}
app.insert_state(GameState::TitleScreen);
}
}
/// The game's main state tracking mechanism, registered with Bevy as a [`State`](`bevy::prelude::State`).
#[derive(Clone, Debug, Eq, Hash, PartialEq, States)]
pub enum GameState {
TitleScreen, // Program is started. Present title screen and await user start
@@ -159,114 +94,13 @@ pub enum GameState {
GameOver, // Game has ended. Present game over dialogue and await user restart
}
#[derive(Resource, Debug, Deref, Clone, Copy)]
struct Score(i32);
impl From<Score> for String {
fn from(value: Score) -> Self {
value.to_string()
}
}
#[derive(Component)]
struct Lifetime(Timer);
#[derive(InspectorOptions, Reflect, Resource, Debug, Deref, Clone, Copy)]
#[reflect(Resource, InspectorOptions)]
struct Lives(i32);
impl From<Lives> for String {
fn from(value: Lives) -> Self {
value.to_string()
}
}
#[derive(Resource)]
struct WorldSize {
width: f32,
height: f32,
}
#[derive(Resource)]
struct GameAssets {
meshes: [Handle<Mesh>; 5],
materials: [Handle<ColorMaterial>; 7],
}
impl GameAssets {
fn ship(&self) -> (Handle<Mesh>, Handle<ColorMaterial>) {
(self.meshes[0].clone(), self.materials[0].clone())
}
// The thruster mesh is actually just the ship mesh
fn thruster_mesh(&self) -> Handle<Mesh> {
self.meshes[0].clone()
}
// TODO: Look into parameterizing the material
// A shader uniform should be able to do this, but I don't know how to
// load those in Bevy.
fn thruster_mat_inactive(&self) -> Handle<ColorMaterial> {
self.materials[1].clone()
}
fn thruster_mat_active(&self) -> Handle<ColorMaterial> {
self.materials[2].clone()
}
fn asteroid_small(&self) -> (Handle<Mesh>, Handle<ColorMaterial>) {
(self.meshes[1].clone(), self.materials[1].clone())
}
fn asteroid_medium(&self) -> (Handle<Mesh>, Handle<ColorMaterial>) {
(self.meshes[2].clone(), self.materials[2].clone())
}
fn asteroid_large(&self) -> (Handle<Mesh>, Handle<ColorMaterial>) {
(self.meshes[3].clone(), self.materials[3].clone())
}
fn bullet(&self) -> (Handle<Mesh>, Handle<ColorMaterial>) {
(self.meshes[4].clone(), self.materials[6].clone())
}
}
impl FromWorld for GameAssets {
fn from_world(world: &mut World) -> Self {
let mut world_meshes = world.resource_mut::<Assets<Mesh>>();
let meshes = [
world_meshes.add(Triangle2d::new(
Vec2::new(0.5, 0.0),
Vec2::new(-0.5, 0.45),
Vec2::new(-0.5, -0.45),
)),
world_meshes.add(Circle::new(10.0)),
world_meshes.add(Circle::new(20.0)),
world_meshes.add(Circle::new(40.0)),
world_meshes.add(Circle::new(0.2)),
];
let mut world_materials = world.resource_mut::<Assets<ColorMaterial>>();
let materials = [
world_materials.add(PLAYER_SHIP_COLOR),
world_materials.add(SHIP_THRUSTER_COLOR_INACTIVE),
world_materials.add(SHIP_THRUSTER_COLOR_ACTIVE),
world_materials.add(ASTEROID_SMALL_COLOR),
// TODO: asteroid medium and large colors
world_materials.add(ASTEROID_SMALL_COLOR),
world_materials.add(ASTEROID_SMALL_COLOR),
world_materials.add(BULLET_COLOR),
];
GameAssets { meshes, materials }
}
}
fn spawn_camera(mut commands: Commands) {
commands.spawn(Camera2d);
}
/*
Checks if "W" is pressed and increases velocity accordingly.
*/
/// Player's thruster control system.
///
/// Checks if "W" is pressed and increases velocity accordingly.
fn input_ship_thruster(
keyboard_input: Res<ButtonInput<KeyCode>>,
mut query: Query<(&mut physics::Velocity, &Transform, &mut Children), With<Ship>>,
@@ -297,10 +131,10 @@ fn input_ship_thruster(
}
}
/*
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)
*/
/// Player's rotation control system.
///
/// Checks if "A" or "D" is pressed and updates the player's [`AngularVelocity`]
/// component accordingly.
fn input_ship_rotation(
keyboard_input: Res<ButtonInput<KeyCode>>,
mut query: Query<&mut AngularVelocity, With<Ship>>,
@@ -319,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(
keyboard_input: Res<ButtonInput<KeyCode>>,
ship: Single<(&Transform, &physics::Velocity), With<Ship>>,
@@ -348,19 +188,3 @@ fn input_ship_shoot(
));
}
}
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),
));
}
fn tick_lifetimes(mut commands: Commands, time: Res<Time>, query: Query<(Entity, &mut Lifetime)>) {
for (e, mut life) in query {
life.0.tick(time.delta());
if life.0.just_finished() {
commands.entity(e).despawn();
}
}
}

105
src/machinery.rs Normal file
View File

@@ -0,0 +1,105 @@
//! 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 std::time::Duration;
use bevy::prelude::*;
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)]
pub struct AsteroidSpawner {
rng: std::sync::Mutex<rand::rngs::StdRng>,
timer: Timer,
// TODO: Configurables?
// - interval
// - density
// - size distribution
}
impl AsteroidSpawner {
pub fn new() -> Self {
Self {
rng: std::sync::Mutex::new(rand::rngs::StdRng::from_seed(crate::config::RNG_SEED)),
timer: Timer::new(Duration::from_secs(3), TimerMode::Repeating),
}
}
}
/// Update the asteroid spawn timer in the [`AsteroidSpawner`] resource, and
/// spawns any asteroids that are due this frame.
pub fn tick_asteroid_manager(
mut events: EventWriter<SpawnAsteroid>,
mut spawner: ResMut<AsteroidSpawner>,
time: Res<Time>,
play_area: Res<WorldSize>,
) {
spawner.timer.tick(time.delta());
if spawner.timer.just_finished() {
let mut rng = spawner
.rng
.lock()
.expect("Expected to acquire lock on the AsteroidSpawner's RNG field.");
// Use polar coordinate to decide where the asteroid will spawn
// Theta will be random between 0 to 2pi
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
// TODO: Use view diagonal to get a minimally sized circle around the play area
let spawn_distance = play_area.x.max(play_area.y) / 2.0;
// Convert polar to Cartesian, use as position
let pos = Vec2::new(
spawn_distance * spawn_angle.cos(),
spawn_distance * spawn_angle.sin(),
);
// Right now, I'm thinking I can use the opposite signs attached to the position Vec components.
// pos.x == -100, then vel.x = + <random>
// pos.x == 100, then vel.x = - <random>
// etc,
let mut vel = Vec2::new(rng.random_range(0.0..100.0), rng.random_range(0.0..100.0));
if pos.x > 0.0 {
vel.x *= -1.0;
}
if pos.y > 0.0 {
vel.y *= -1.0;
}
let size = match rng.random_range(0..=2) {
0 => AsteroidSize::Small,
1 => AsteroidSize::Medium,
2 => AsteroidSize::Large,
_ => unreachable!(),
};
events.write(SpawnAsteroid { pos, vel, size });
}
}
/// Sets a lifetime on an entity to automatically delete it after expiration.
#[derive(Component)]
pub struct Lifetime(pub Timer);
/// System to tick the [`Lifetime`] timers and despawn expired entities.
pub fn tick_lifetimes(
mut commands: Commands,
time: Res<Time>,
query: Query<(Entity, &mut Lifetime)>,
) {
for (e, mut life) in query {
life.0.tick(time.delta());
if life.0.just_finished() {
commands.entity(e).despawn();
}
}
}

View File

@@ -3,10 +3,32 @@
//! Asteroids, the player's ship, and such.
use bevy::{
ecs::component::Component,
ecs::{
component::Component,
entity::Entity,
event::{EventReader, EventWriter},
query::With,
system::{Commands, Query, Res, ResMut, Single},
},
math::{Vec2, Vec3, Vec3Swizzles},
prelude::{Deref, DerefMut},
render::mesh::Mesh2d,
sprite::MeshMaterial2d,
state::state::NextState,
time::{Timer, TimerMode},
transform::components::Transform,
};
use bevy_rapier2d::prelude::{ActiveCollisionTypes, ActiveEvents, Collider, Sensor};
use crate::{
AngularVelocity, GameAssets, GameState, Lives,
config::ASTEROID_LIFETIME,
events::{AsteroidDestroy, BulletDestroy, ShipDestroy, SpawnAsteroid},
machinery::Lifetime,
physics::{Velocity, Wrapping},
};
/// The asteroid, defined entirely by [it's size](`AsteroidSize`).
#[derive(Component, Deref, DerefMut)]
pub struct Asteroid(pub AsteroidSize);
@@ -18,6 +40,8 @@ pub enum AsteroidSize {
}
impl AsteroidSize {
/// Convenience util to get the "next smallest" size. Useful for splitting
/// after collision.
pub fn next(&self) -> Option<Self> {
match self {
AsteroidSize::Small => None,
@@ -27,8 +51,157 @@ impl AsteroidSize {
}
}
/// Marker component for the player's ship.
#[derive(Component)]
pub struct Ship;
/// Marker component for bullets.
#[derive(Component)]
pub struct Bullet;
/// Responds to [`SpawnAsteroid`] events, spawning as specified
pub fn spawn_asteroid(
mut events: EventReader<SpawnAsteroid>,
mut commands: Commands,
game_assets: Res<GameAssets>,
) {
for spawn in events.read() {
let (mesh, material) = match spawn.size {
AsteroidSize::Small => game_assets.asteroid_small(),
AsteroidSize::Medium => game_assets.asteroid_medium(),
AsteroidSize::Large => game_assets.asteroid_large(),
};
let collider_radius = match spawn.size {
AsteroidSize::Small => 10.0,
AsteroidSize::Medium => 20.0,
AsteroidSize::Large => 40.0,
};
commands.spawn((
Asteroid(spawn.size),
Collider::ball(collider_radius),
Sensor,
Transform::from_translation(spawn.pos.extend(0.0)),
Velocity(spawn.vel),
Mesh2d(mesh),
MeshMaterial2d(material),
Lifetime(Timer::from_seconds(ASTEROID_LIFETIME, TimerMode::Once)),
));
}
}
/// Event listener for asteroid destruction events. Shrinks and multiplies
/// asteroids until they vanish.
///
/// - Large -> 2x Medium
/// - Medium -> 2x Small
/// - Small -> (despawned)
///
/// The velocity of the child asteroids is scattered somewhat, as if they were
/// explosively pushed apart.
pub fn split_asteroids(
mut destroy_events: EventReader<AsteroidDestroy>,
mut respawn_events: EventWriter<SpawnAsteroid>,
mut commands: Commands,
query: Query<(&Transform, &Asteroid, &Velocity)>,
) {
for event in destroy_events.read() {
if let Ok((transform, rock, velocity)) = query.get(event.0) {
let next_size = rock.0.next();
if let Some(size) = next_size {
let pos = transform.translation.xy();
let left_offset = Vec2::from_angle(0.4);
let right_offset = Vec2::from_angle(-0.4);
respawn_events.write(SpawnAsteroid {
pos,
vel: left_offset.rotate(velocity.0),
size,
});
respawn_events.write(SpawnAsteroid {
pos,
vel: right_offset.rotate(velocity.0),
size,
});
}
// Always despawn the asteroid. New ones (may) be spawned in it's
// place, but this one is gone.
commands.entity(event.0).despawn();
}
}
}
/// 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>) {
commands
.spawn((
Collider::ball(0.7),
Sensor,
ActiveEvents::COLLISION_EVENTS,
ActiveCollisionTypes::STATIC_STATIC,
Ship,
Wrapping,
Velocity(Vec2::ZERO),
AngularVelocity(0.0),
Mesh2d(game_assets.ship().0),
MeshMaterial2d(game_assets.ship().1),
Transform::default().with_scale(Vec3::new(20.0, 20.0, 20.0)),
))
.with_child((
Mesh2d(game_assets.thruster_mesh()),
MeshMaterial2d(game_assets.thruster_mat_inactive()),
Transform::default()
.with_scale(Vec3::splat(0.5))
.with_translation(Vec3::new(-0.5, 0.0, -0.1)),
));
}
/// Watch for [`BulletDestroy`] events and despawn
/// the associated bullet.
pub fn bullet_impact_listener(mut commands: Commands, mut events: EventReader<BulletDestroy>) {
for event in events.read() {
commands.entity(event.0).despawn();
}
}
/// 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
/// - Check life count. If 0, go to game-over state
/// - Clear all asteroids
/// - Respawn player
pub fn ship_impact_listener(
mut events: EventReader<ShipDestroy>,
mut commands: Commands,
mut lives: ResMut<Lives>,
rocks: Query<Entity, With<Asteroid>>,
mut player: Single<(&mut Transform, &mut Velocity), With<Ship>>,
mut next_state: ResMut<NextState<GameState>>,
) {
for _ in events.read() {
// STEP 1: Decrement lives (and maybe go to game over)
if lives.0 == 0 {
// If already at 0, game is over.
next_state.set(GameState::GameOver);
} else {
// Decrease life count.
lives.0 -= 1;
}
// STEP 2: Clear asteroids
for rock in rocks {
commands.entity(rock).despawn();
}
// STEP 3: Respawn player (teleport them to the origin)
player.0.translation = Vec3::ZERO;
player.1.0 = Vec2::ZERO;
}
}

View File

@@ -1,9 +1,27 @@
//! Custom physics items
//!
//! 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::WorldSize;
use crate::{
WorldSize, events,
objects::{Asteroid, Bullet, Ship},
};
use bevy::prelude::*;
use bevy_rapier2d::pipeline::CollisionEvent;
#[derive(Clone, Component)]
pub(crate) struct Velocity(pub(crate) bevy::math::Vec2);
@@ -38,9 +56,9 @@ pub(crate) fn wrap_entities(
mut query: Query<&mut Transform, With<Wrapping>>,
world_size: Res<WorldSize>,
) {
let right = world_size.width / 2.0;
let right = world_size.x / 2.0;
let left = -right;
let top = world_size.height / 2.0;
let top = world_size.y / 2.0;
let bottom = -top;
for mut pos in query.iter_mut() {
@@ -57,3 +75,66 @@ pub(crate) fn wrap_entities(
}
}
}
/// The collision event routing system.
///
/// When a `CollisionEvent` occurrs, this system checks which things collided
/// and emits secondary events accordignly.
///
/// | Objects | Response |
/// |-|-|
/// | Ship & Asteroid | emits event [`ShipDestroy`](`crate::events::ShipDestroy`) |
/// | Asteroid & Bullet | emits event [`AsteroidDestroy`](`crate::events::AsteroidDestroy`) |
/// | Asteroid & Asteroid | Nothing. Asteroids won't collide with each other |
/// | Bullet & Bullet | Nothing. Bullets won't collide with each other (and probably can't under normal gameplay conditions) |
/// | Bullet & Ship | Nothing. The player shouldn't be able to shoot themselves (and the Flying Saucer hasn't been impl.'d, so it's bullets don't count) |
pub fn collision_listener(
mut collisions: EventReader<CollisionEvent>,
mut ship_writer: EventWriter<events::ShipDestroy>,
mut asteroid_writer: EventWriter<events::AsteroidDestroy>,
mut bullet_writer: EventWriter<events::BulletDestroy>,
player: Single<Entity, With<Ship>>,
bullets: Query<&Bullet>,
rocks: Query<&Asteroid>,
) {
for event in collisions.read() {
if let CollisionEvent::Started(one, two, _flags) = event {
// Valid collisions are:
//
// - Ship & Asteroid
// - Bullet & Asteroid
//
// Asteroids don't collide with each other, bullets don't collide
// with each other, and bullets don't collide with the player ship.
// Option 1: Ship & Asteroid
if *one == *player {
if rocks.contains(*two) {
// player-asteroid collision
dbg!("Writing ShipDestroy event");
ship_writer.write(events::ShipDestroy);
} // else, we don't care
} else if *two == *player {
if rocks.contains(*one) {
dbg!("Writing ShipDestroy event");
ship_writer.write(events::ShipDestroy);
}
}
// Option 2: Bullet & Asteroid
if bullets.contains(*one) {
if rocks.contains(*two) {
dbg!("Writing AsteroidDestroy & BulletDestroy events");
asteroid_writer.write(events::AsteroidDestroy(*two));
bullet_writer.write(events::BulletDestroy(*one));
}
} else if rocks.contains(*one) {
if bullets.contains(*two) {
dbg!("Writing AsteroidDestroy & BulletDestroy events");
asteroid_writer.write(events::AsteroidDestroy(*one));
bullet_writer.write(events::BulletDestroy(*two));
}
}
}
}
}

View File

@@ -1,99 +0,0 @@
use crate::GameState;
use bevy::{
color::palettes::css::{BLACK, GREEN, LIGHT_BLUE, RED},
prelude::*,
};
pub fn preparation_widget_plugin(app: &mut App) {
app.add_systems(OnEnter(GameState::GetReady), spawn_get_ready)
.add_systems(OnExit(GameState::GetReady), despawn_get_ready)
.add_systems(
Update,
(animate_get_ready_widget).run_if(in_state(GameState::GetReady)),
)
.insert_resource(ReadySetGoTimer(Timer::from_seconds(3.0, TimerMode::Once)));
}
/// Marker component for things on the get-ready indicator
#[derive(Component)]
struct OnReadySetGo;
/// Newtype wrapper for `Timer`. Used to count down during the "get ready" phase.
#[derive(Deref, DerefMut, Resource)]
struct ReadySetGoTimer(Timer);
/// Marker for the counter text segment
#[derive(Component)]
struct CountdownText;
/// Marker for the counter bar segment
#[derive(Component)]
struct CountdownBar;
fn spawn_get_ready(mut commands: Commands) {
commands.spawn((
OnReadySetGo, // marker, so this can be de-spawned properly
Node {
align_self: AlignSelf::Center,
justify_self: JustifySelf::Center,
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
flex_direction: FlexDirection::Column,
width: Val::Percent(30.),
height: Val::Percent(30.),
..default()
},
BackgroundColor(LIGHT_BLUE.into()),
children![
(Text::new("Get Ready!"), TextColor(BLACK.into())),
(
CountdownBar,
Node {
width: Val::Percent(90.0),
height: Val::Percent(10.),
..default()
},
BackgroundColor(GREEN.into()),
),
(
CountdownText,
Text::new("<uninit timer>"),
TextColor(RED.into()),
)
],
));
}
// TODO: Replace this with a generic somewhere else in the crate
// want: `despawn_screen::<OnReadySetGo>>()`
fn despawn_get_ready(mut commands: Commands, to_despawn: Query<Entity, With<OnReadySetGo>>) {
for entity in to_despawn {
commands.entity(entity).despawn();
}
}
fn animate_get_ready_widget(
mut text_segment: Single<&mut Text, With<CountdownText>>,
mut bar_segment: Single<&mut Node, With<CountdownBar>>,
time: Res<Time>,
mut timer: ResMut<ReadySetGoTimer>,
mut game_state: ResMut<NextState<GameState>>,
) {
// Advance the timer, read the remaining time and write it onto the label.
timer.tick(time.delta());
// Add one to the visual value so the countdown starts at 3 and stops at 1.
// Otherwise it starts at 2 and disappears after showing 0.
// That feels wrong even though it's functionally identical.
let tval = timer.0.remaining().as_secs() + 1;
**text_segment = format!("{tval}").into();
// Shrink the progress bar Node
bar_segment.width = Val::Percent(100.0 * (1.0 - timer.fraction()));
// If the timer has expired, change state to playing.
if timer.finished() {
game_state.set(GameState::Playing);
}
}

124
src/resources.rs Normal file
View File

@@ -0,0 +1,124 @@
//! All the resources for the game
use bevy::{
asset::{Assets, Handle},
ecs::{
resource::Resource,
world::{FromWorld, World},
},
math::{
Vec2,
primitives::{Circle, Triangle2d},
},
prelude::{Deref, DerefMut, Reflect, ReflectResource},
render::mesh::Mesh,
sprite::ColorMaterial,
};
use bevy_inspector_egui::InspectorOptions;
use bevy_inspector_egui::inspector_options::ReflectInspectorOptions;
use crate::{
ASTEROID_SMALL_COLOR, BULLET_COLOR, PLAYER_SHIP_COLOR, SHIP_THRUSTER_COLOR_ACTIVE,
SHIP_THRUSTER_COLOR_INACTIVE, config::WINDOW_SIZE,
};
#[derive(Resource, Debug, Deref, Clone, Copy)]
pub struct Score(pub i32);
impl From<Score> for String {
fn from(value: Score) -> Self {
value.to_string()
}
}
#[derive(InspectorOptions, Reflect, Resource, Debug, Deref, Clone, Copy)]
#[reflect(Resource, InspectorOptions)]
pub struct Lives(pub i32);
impl From<Lives> for String {
fn from(value: Lives) -> Self {
value.to_string()
}
}
#[derive(Deref, DerefMut, Resource)]
pub struct WorldSize(Vec2);
impl Default for WorldSize {
fn default() -> Self {
WorldSize(Vec2::new(WINDOW_SIZE.x, WINDOW_SIZE.y))
}
}
#[derive(Resource)]
pub struct GameAssets {
meshes: [Handle<Mesh>; 5],
materials: [Handle<ColorMaterial>; 7],
}
impl GameAssets {
pub fn ship(&self) -> (Handle<Mesh>, Handle<ColorMaterial>) {
(self.meshes[0].clone(), self.materials[0].clone())
}
// The thruster mesh is actually just the ship mesh
pub fn thruster_mesh(&self) -> Handle<Mesh> {
self.meshes[0].clone()
}
// TODO: Look into parameterizing the material
// A shader uniform should be able to do this, but I don't know how to
// load those in Bevy.
pub fn thruster_mat_inactive(&self) -> Handle<ColorMaterial> {
self.materials[1].clone()
}
pub fn thruster_mat_active(&self) -> Handle<ColorMaterial> {
self.materials[2].clone()
}
pub fn asteroid_small(&self) -> (Handle<Mesh>, Handle<ColorMaterial>) {
(self.meshes[1].clone(), self.materials[1].clone())
}
pub fn asteroid_medium(&self) -> (Handle<Mesh>, Handle<ColorMaterial>) {
(self.meshes[2].clone(), self.materials[2].clone())
}
pub fn asteroid_large(&self) -> (Handle<Mesh>, Handle<ColorMaterial>) {
(self.meshes[3].clone(), self.materials[3].clone())
}
pub fn bullet(&self) -> (Handle<Mesh>, Handle<ColorMaterial>) {
(self.meshes[4].clone(), self.materials[6].clone())
}
}
impl FromWorld for GameAssets {
fn from_world(world: &mut World) -> Self {
let mut world_meshes = world.resource_mut::<Assets<Mesh>>();
let meshes = [
world_meshes.add(Triangle2d::new(
Vec2::new(0.5, 0.0),
Vec2::new(-0.5, 0.45),
Vec2::new(-0.5, -0.45),
)),
world_meshes.add(Circle::new(10.0)),
world_meshes.add(Circle::new(20.0)),
world_meshes.add(Circle::new(40.0)),
world_meshes.add(Circle::new(0.2)),
];
let mut world_materials = world.resource_mut::<Assets<ColorMaterial>>();
let materials = [
world_materials.add(PLAYER_SHIP_COLOR),
world_materials.add(SHIP_THRUSTER_COLOR_INACTIVE),
world_materials.add(SHIP_THRUSTER_COLOR_ACTIVE),
world_materials.add(ASTEROID_SMALL_COLOR),
// TODO: asteroid medium and large colors
world_materials.add(ASTEROID_SMALL_COLOR),
world_materials.add(ASTEROID_SMALL_COLOR),
world_materials.add(BULLET_COLOR),
];
GameAssets { meshes, materials }
}
}

View File

@@ -1,76 +0,0 @@
use crate::{
AngularVelocity, GameAssets, GameState, Lives,
events::{BulletDestroy, ShipDestroy},
objects::{Asteroid, Ship},
physics::{Velocity, Wrapping},
};
use bevy::prelude::*;
use bevy_rapier2d::prelude::*;
pub fn spawn_player(mut commands: Commands, game_assets: Res<GameAssets>) {
commands
.spawn((
Collider::ball(0.7),
Sensor,
ActiveEvents::COLLISION_EVENTS,
ActiveCollisionTypes::STATIC_STATIC,
Ship,
Wrapping,
Velocity(Vec2::ZERO),
AngularVelocity(0.0),
Mesh2d(game_assets.ship().0),
MeshMaterial2d(game_assets.ship().1),
Transform::default().with_scale(Vec3::new(20.0, 20.0, 20.0)),
))
.with_child((
Mesh2d(game_assets.thruster_mesh()),
MeshMaterial2d(game_assets.thruster_mat_inactive()),
Transform::default()
.with_scale(Vec3::splat(0.5))
.with_translation(Vec3::new(-0.5, 0.0, -0.1)),
));
}
/// Watch for [`BulletDestroy`] events and despawn
/// the associated bullet.
pub fn bullet_impact_listener(mut commands: Commands, mut events: EventReader<BulletDestroy>) {
for event in events.read() {
commands.entity(event.0).despawn();
}
}
/// Watch for [`ShipDestroy`] events and update game state accordingly.
///
/// - Subtract a life
/// - Check life count. If 0, go to game-over state
/// - Clear all asteroids
/// - Respawn player
pub fn ship_impact_listener(
mut events: EventReader<ShipDestroy>,
mut commands: Commands,
mut lives: ResMut<Lives>,
rocks: Query<Entity, With<Asteroid>>,
mut player: Single<(&mut Transform, &mut Velocity), With<Ship>>,
mut next_state: ResMut<NextState<GameState>>,
) {
for _ in events.read() {
// STEP 1: Decrement lives (and maybe go to game over)
if lives.0 == 0 {
// If already at 0, game is over.
next_state.set(GameState::GameOver);
} else {
// Decrease life count.
lives.0 -= 1;
}
// STEP 2: Clear asteroids
for rock in rocks {
commands.entity(rock).despawn();
}
// STEP 3: Respawn player (teleport them to the origin)
player.0.translation = Vec3::ZERO;
player.1.0 = Vec2::ZERO;
}
}

View File

@@ -1,54 +0,0 @@
use crate::GameState;
use bevy::prelude::*;
pub struct GameMenuPlugin;
impl Plugin for GameMenuPlugin {
fn build(&self, app: &mut App) {
app.add_systems(OnEnter(GameState::TitleScreen), spawn_menu)
.add_systems(OnExit(GameState::TitleScreen), despawn_menu)
.add_systems(
Update,
handle_spacebar.run_if(in_state(GameState::TitleScreen)),
);
}
}
// 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 TitleUI;
fn spawn_menu(mut commands: Commands) {
commands
.spawn((
TitleUI,
Node {
flex_direction: FlexDirection::Column,
..Default::default()
},
))
.with_children(|cmds| {
cmds.spawn((
Text::new("Robert's Bad Asteroids Game"),
TextFont::from_font_size(50.0),
));
cmds.spawn((
Text::new("Press space to begin"),
TextFont::from_font_size(40.0),
));
});
}
fn despawn_menu(mut commands: Commands, to_despawn: Query<Entity, With<TitleUI>>) {
for entity in &to_despawn {
commands.entity(entity).despawn();
}
}
fn handle_spacebar(input: Res<ButtonInput<KeyCode>>, mut game_state: ResMut<NextState<GameState>>) {
if input.just_pressed(KeyCode::Space) {
game_state.set(GameState::GetReady);
}
}

301
src/widgets.rs Normal file
View File

@@ -0,0 +1,301 @@
use crate::{
GameState,
config::{UI_BUTTON_HOVERED, UI_BUTTON_NORMAL, UI_BUTTON_PRESSED},
resources::{Lives, Score},
};
use bevy::{
color::palettes::css::{BLACK, DARK_GRAY, GREEN, LIGHT_BLUE, RED, WHITE},
prelude::*,
};
/// Plugin for the main menu
pub struct PluginGameMenu;
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)),
);
}
}
/// 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)
.add_systems(OnExit(GameState::GetReady), despawn::<OnReadySetGo>)
.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
#[derive(Component)]
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.
#[derive(Deref, DerefMut, Resource)]
struct ReadySetGoTimer(Timer);
/// Marker for the counter text segment
#[derive(Component)]
struct CountdownText;
/// Marker for the counter bar segment
#[derive(Component)]
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) {
commands.spawn((
OnReadySetGo, // marker, so this can be de-spawned properly
Node {
align_self: AlignSelf::Center,
justify_self: JustifySelf::Center,
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
flex_direction: FlexDirection::Column,
width: Val::Percent(30.),
height: Val::Percent(30.),
..default()
},
BackgroundColor(LIGHT_BLUE.into()),
children![
(Text::new("Get Ready!"), TextColor(BLACK.into())),
(
CountdownBar,
Node {
width: Val::Percent(90.0),
height: Val::Percent(10.),
..default()
},
BackgroundColor(GREEN.into()),
),
(
CountdownText,
Text::new("<uninit timer>"),
TextColor(RED.into()),
)
],
));
}
/// Spawns the game over screen.
///
/// Used by [`PluginGameOver`] when entering the [`GameState::GameOver`] state.
fn spawn_gameover_ui(mut commands: Commands) {
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(
mut text_segment: Single<&mut Text, With<CountdownText>>,
mut bar_segment: Single<&mut Node, With<CountdownBar>>,
time: Res<Time>,
mut timer: ResMut<ReadySetGoTimer>,
mut game_state: ResMut<NextState<GameState>>,
) {
// Advance the timer, read the remaining time and write it onto the label.
timer.tick(time.delta());
// Add one to the visual value so the countdown starts at 3 and stops at 1.
// Otherwise it starts at 2 and disappears after showing 0.
// That feels wrong even though it's functionally identical.
let tval = timer.0.remaining().as_secs() + 1;
**text_segment = format!("{tval}").into();
// Shrink the progress bar Node
bar_segment.width = Val::Percent(100.0 * (1.0 - timer.fraction()));
// If the timer has expired, change state to playing.
if timer.finished() {
game_state.set(GameState::Playing);
}
}
/// Handles interactions with the menu buttons.
///
/// The buttons are used by the main menu and the game-over menu to change
/// between game states.
///
/// Button animation and action handling is done entirely within this system.
///
/// There are no checks for current state. If a "quit" button was put somewhere
/// 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<
(
&Interaction,
&mut BackgroundColor,
&mut BorderColor,
&ButtonMenuAction,
),
(Changed<Interaction>, With<Button>),
>,
mut game_state: ResMut<NextState<GameState>>,
mut app_exit_events: EventWriter<AppExit>,
) {
// 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 {
Interaction::Pressed => {
*color = UI_BUTTON_PRESSED.into();
border_color.0 = DARK_GRAY.into();
match menu_action {
ButtonMenuAction::ToMainMenu => {
game_state.set(GameState::TitleScreen);
}
ButtonMenuAction::StartGame => {
game_state.set(GameState::Playing);
}
ButtonMenuAction::Quit => {
app_exit_events.write(AppExit::Success);
}
}
}
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>>) {
if input.just_pressed(KeyCode::Space) {
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),
));
}