11 Commits

Author SHA1 Message Date
0967795d51 Remove unnecessary mut on system parameter
All checks were successful
Basic checks / Basic build-and-test supertask (push) Successful in 7m4s
2025-08-14 22:33:53 -05:00
20c71658c3 Implement a basic ship debris effect
I'm stealing the thruster mesh, which itself is actually the ship's
mesh, to draw the debris particles. I'll come back and make the scatter
a bit better at some point, but for now it's passable.
2025-08-14 22:33:42 -05:00
960861af79 Add a sparkler component to flash entities
I'll be using this to make a sparkling effect on the debris field left
behind from a destroyed ship.

It can also be used to do the temporary invincibility effect when
(re)spawning the player.
2025-08-14 21:57:47 -05:00
4b70be7048 Fix and improve ButtonMenuAction doc comment 2025-08-14 20:54:29 -05:00
8bf21e74f0 Remove Rapier debug plugin 2025-08-14 18:25:33 -05:00
c0cf6d7f30 Fix: reset "get-ready" timer upon showing widget
All checks were successful
Basic checks / Basic build-and-test supertask (push) Successful in 7m21s
Closes #25: Reset "get ready" timer upon entering the get-ready state.
2025-08-14 18:20:50 -05:00
cc23e9e08e Despawn the player when exiting the Playing state
Closes #21: Despawn player ship when exiting `GameState::Playing`

I've also moved the `fn despawn<T>` utility to the lib.rs module.
2025-08-14 14:50:12 -05:00
cf9825fcc3 Fix: Starting game from GUI runs "get ready" timer
All checks were successful
Basic checks / Basic build-and-test supertask (push) Successful in 7m14s
Closes #23: Main menu's start button doesn't run the get-ready timer

I made the start button go directly to `GameState::Playing`, which is
not actually the right state for beginning a new game. Swap the states
and everything works again.
2025-08-14 14:39:24 -05:00
708f514582 Implement a proper gun fire-rate mechanism
There is now a `Weapon` component which is just a timer for the gun's
fire rate. It is ticked every frame, clamping at the "ready" state (0
time remaining).

The ship spawns with this thing, and the `input_ship_shoot` system has
been updated to use it.
2025-08-14 14:34:22 -05:00
0a7ffcfa0a Add reflection & debug inspector stuff to Score 2025-08-14 13:14:32 -05:00
40ee042b53 Implement the HUD for real
Now it works!... but it runs every single frame, probably causing a
bunch of unnecessary text rendering and UI layout operations. I'll have
to come back and make it event-based at some point.
2025-08-14 13:07:25 -05:00
6 changed files with 167 additions and 34 deletions

View File

@@ -13,6 +13,7 @@ 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);
pub(crate) const SHIP_THRUSTER_COLOR_INACTIVE: Color = Color::srgb(0.5, 0.5, 0.5); pub(crate) const SHIP_THRUSTER_COLOR_INACTIVE: Color = Color::srgb(0.5, 0.5, 0.5);
pub(crate) const SHIP_FIRE_RATE: f32 = 3.0; // in bullets-per-second
pub(crate) const ASTEROID_SMALL_COLOR: Color = Color::srgb(1.0, 0., 0.); pub(crate) const ASTEROID_SMALL_COLOR: Color = Color::srgb(1.0, 0., 0.);
pub(crate) const BULLET_COLOR: Color = Color::srgb(0.0, 0.1, 0.9); pub(crate) const BULLET_COLOR: Color = Color::srgb(0.0, 0.1, 0.9);
// TODO: asteroid medium & large // TODO: asteroid medium & large
@@ -24,5 +25,6 @@ pub(crate) const BULLET_SPEED: f32 = 150.0;
pub(crate) const BULLET_LIFETIME: f32 = 2.0; pub(crate) const BULLET_LIFETIME: f32 = 2.0;
pub(crate) const ASTEROID_LIFETIME: f32 = 40.0; pub(crate) const ASTEROID_LIFETIME: f32 = 40.0;
pub(crate) const DEBRIS_LIFETIME: f32 = 3.0; // lifetime, in seconds
pub const RNG_SEED: [u8; 32] = *b"12345678909876543210123456789098"; pub const RNG_SEED: [u8; 32] = *b"12345678909876543210123456789098";

View File

@@ -16,14 +16,13 @@ use crate::config::{
SHIP_THRUSTER_COLOR_INACTIVE, SHIP_THRUSTER_COLOR_INACTIVE,
}; };
use crate::machinery::AsteroidSpawner; use crate::machinery::AsteroidSpawner;
use crate::objects::{Bullet, Ship}; use crate::objects::{Bullet, Ship, Weapon};
use crate::physics::AngularVelocity; use crate::physics::AngularVelocity;
use bevy::prelude::*; use bevy::prelude::*;
use bevy_rapier2d::{ use bevy_rapier2d::{
plugin::{NoUserData, RapierPhysicsPlugin}, plugin::{NoUserData, RapierPhysicsPlugin},
prelude::*, prelude::*,
render::RapierDebugRenderPlugin,
}; };
use machinery::Lifetime; use machinery::Lifetime;
use resources::{GameAssets, Lives, Score, WorldSize}; use resources::{GameAssets, Lives, Score, WorldSize};
@@ -37,21 +36,20 @@ impl Plugin for AsteroidPlugin {
widgets::PluginGameMenu, widgets::PluginGameMenu,
widgets::PluginGameOver, widgets::PluginGameOver,
widgets::PluginGetReady, widgets::PluginGetReady,
widgets::PluginGameHud,
RapierPhysicsPlugin::<NoUserData>::pixels_per_meter(10.0), RapierPhysicsPlugin::<NoUserData>::pixels_per_meter(10.0),
RapierDebugRenderPlugin::default(),
)) ))
.insert_resource(ClearColor(BACKGROUND_COLOR)) .insert_resource(ClearColor(BACKGROUND_COLOR))
.insert_resource(WorldSize::default()) .insert_resource(WorldSize::default())
.insert_resource(Lives(3)) .insert_resource(Lives(3))
.register_type::<Lives>() .register_type::<Lives>()
.insert_resource(Score(0)) .insert_resource(Score(0))
.register_type::<Score>()
.insert_resource(AsteroidSpawner::new()) .insert_resource(AsteroidSpawner::new())
.init_resource::<GameAssets>() .init_resource::<GameAssets>()
.add_systems(Startup, spawn_camera) .add_systems(Startup, spawn_camera)
.add_systems( .add_systems(OnEnter(GameState::Playing), objects::spawn_player)
OnEnter(GameState::Playing), .add_systems(OnExit(GameState::Playing), despawn::<Ship>)
(objects::spawn_player, widgets::spawn_ui),
)
.add_systems( .add_systems(
FixedUpdate, FixedUpdate,
( (
@@ -60,6 +58,7 @@ impl Plugin for AsteroidPlugin {
input_ship_shoot, input_ship_shoot,
physics::wrap_entities, physics::wrap_entities,
machinery::tick_asteroid_manager, machinery::tick_asteroid_manager,
machinery::operate_sparklers,
objects::spawn_asteroid.after(machinery::tick_asteroid_manager), objects::spawn_asteroid.after(machinery::tick_asteroid_manager),
objects::split_asteroids, objects::split_asteroids,
objects::bullet_impact_listener, objects::bullet_impact_listener,
@@ -85,6 +84,14 @@ impl Plugin for AsteroidPlugin {
} }
} }
/// Despawns entities matching the generic argument. Intended to remove UI
/// elements.
pub(crate) fn despawn<T: Component>(mut commands: Commands, to_despawn: Query<Entity, With<T>>) {
for entity in to_despawn {
commands.entity(entity).despawn();
}
}
/// The game's main state tracking mechanism, registered with Bevy as a [`State`](`bevy::prelude::State`). /// 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 {
@@ -161,19 +168,25 @@ fn input_ship_rotation(
/// tick those timers. Maybe this system? /// 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, &mut Weapon), With<Ship>>,
game_assets: Res<GameAssets>, game_assets: Res<GameAssets>,
mut commands: Commands, mut commands: Commands,
time: Res<Time>,
) { ) {
let (ship_pos, ship_vel) = *ship; let (ship_pos, ship_vel, mut weapon) = ship.into_inner();
// Derive bullet velocity, add to the ship's velocity // Tick the timer so the cooldown eventually finishes. Once it does, the
let bullet_vel = (ship_pos.rotation * Vec3::X).xy() * BULLET_SPEED; // value will clamp at 0. The weapon is now ready.
let bullet_vel = bullet_vel + ship_vel.0; weapon.tick(time.delta());
// If the weapon is ready and the player presses the trigger,
// spawn a bullet & reset the timer.
if weapon.finished() && keyboard_input.pressed(KeyCode::Space) {
weapon.reset();
// Derive bullet velocity, add to the ship's velocity
let bullet_vel = (ship_pos.rotation * Vec3::X).xy() * BULLET_SPEED;
let bullet_vel = bullet_vel + ship_vel.0;
// TODO: create a timer for the gun fire rate.
// For now, spawn one for each press of the spacebar.
if keyboard_input.just_pressed(KeyCode::Space) {
commands.spawn(( commands.spawn((
Bullet, Bullet,
Collider::ball(0.2), Collider::ball(0.2),

View File

@@ -103,3 +103,35 @@ pub fn tick_lifetimes(
} }
} }
} }
/// Entities marked with this will flash. Used to make the debris field sparkle.
#[derive(Component, Deref, DerefMut)]
pub struct Sparkler(Timer);
impl Sparkler {
pub fn at_interval(period: f32) -> Self {
Self(Timer::from_seconds(period, TimerMode::Repeating))
}
}
/// Advances the timer in a sparkler, swapping between visible and invisible
/// each time the timer expires.
pub fn operate_sparklers(sparklers: Query<(&mut Visibility, &mut Sparkler)>, time: Res<Time>) {
for (mut vis, mut timer) in sparklers {
if timer.tick(time.delta()).just_finished() {
// Cycle between visible and in-visible modes (and print warning for "Inherited")
*vis = match *vis {
Visibility::Inherited => {
// I don't know when entities have this mode, so I'm going
// print a warning for a while.
eprintln!(
"->> WARN: `machinery::operate_sparklers` found an entity with Visibility::Inherited"
);
Visibility::Inherited
}
Visibility::Hidden => Visibility::Visible,
Visibility::Visible => Visibility::Hidden,
};
}
}
}

View File

@@ -4,6 +4,7 @@
use bevy::{ use bevy::{
ecs::{ ecs::{
bundle::Bundle,
component::Component, component::Component,
entity::Entity, entity::Entity,
event::{EventReader, EventWriter}, event::{EventReader, EventWriter},
@@ -12,7 +13,7 @@ use bevy::{
}, },
math::{Vec2, Vec3, Vec3Swizzles}, math::{Vec2, Vec3, Vec3Swizzles},
prelude::{Deref, DerefMut}, prelude::{Deref, DerefMut},
render::mesh::Mesh2d, render::{mesh::Mesh2d, view::Visibility},
sprite::MeshMaterial2d, sprite::MeshMaterial2d,
state::state::NextState, state::state::NextState,
time::{Timer, TimerMode}, time::{Timer, TimerMode},
@@ -22,9 +23,9 @@ use bevy_rapier2d::prelude::{ActiveCollisionTypes, ActiveEvents, Collider, Senso
use crate::{ use crate::{
AngularVelocity, GameAssets, GameState, Lives, AngularVelocity, GameAssets, GameState, Lives,
config::ASTEROID_LIFETIME, config::{ASTEROID_LIFETIME, DEBRIS_LIFETIME, SHIP_FIRE_RATE},
events::{AsteroidDestroy, BulletDestroy, ShipDestroy, SpawnAsteroid}, events::{AsteroidDestroy, BulletDestroy, ShipDestroy, SpawnAsteroid},
machinery::Lifetime, machinery::{Lifetime, Sparkler},
physics::{Velocity, Wrapping}, physics::{Velocity, Wrapping},
}; };
@@ -55,10 +56,18 @@ impl AsteroidSize {
#[derive(Component)] #[derive(Component)]
pub struct Ship; pub struct Ship;
/// The ship's gun (is just a timer)
#[derive(Component, Deref, DerefMut)]
pub struct Weapon(Timer);
/// Marker component for bullets. /// Marker component for bullets.
#[derive(Component)] #[derive(Component)]
pub struct Bullet; pub struct Bullet;
/// Debris left behind after the ship is destroyed.
#[derive(Component)]
pub struct Debris;
/// Responds to [`SpawnAsteroid`] events, spawning as specified /// Responds to [`SpawnAsteroid`] events, spawning as specified
pub fn spawn_asteroid( pub fn spawn_asteroid(
mut events: EventReader<SpawnAsteroid>, mut events: EventReader<SpawnAsteroid>,
@@ -144,6 +153,7 @@ pub fn spawn_player(mut commands: Commands, game_assets: Res<GameAssets>) {
ActiveEvents::COLLISION_EVENTS, ActiveEvents::COLLISION_EVENTS,
ActiveCollisionTypes::STATIC_STATIC, ActiveCollisionTypes::STATIC_STATIC,
Ship, Ship,
Weapon(Timer::from_seconds(1.0 / SHIP_FIRE_RATE, TimerMode::Once)),
Wrapping, Wrapping,
Velocity(Vec2::ZERO), Velocity(Vec2::ZERO),
AngularVelocity(0.0), AngularVelocity(0.0),
@@ -184,6 +194,7 @@ pub fn ship_impact_listener(
rocks: Query<Entity, With<Asteroid>>, rocks: Query<Entity, With<Asteroid>>,
mut player: Single<(&mut Transform, &mut Velocity), With<Ship>>, mut player: Single<(&mut Transform, &mut Velocity), With<Ship>>,
mut next_state: ResMut<NextState<GameState>>, mut next_state: ResMut<NextState<GameState>>,
game_assets: Res<GameAssets>,
) { ) {
for _ in events.read() { for _ in events.read() {
// STEP 1: Decrement lives (and maybe go to game over) // STEP 1: Decrement lives (and maybe go to game over)
@@ -200,6 +211,23 @@ pub fn ship_impact_listener(
commands.entity(rock).despawn(); commands.entity(rock).despawn();
} }
// STEP 3: spawn the debris field where the player used to be.
for i in 0..10 {
let angle_rads = (i as f32) / 10.0 * std::f32::consts::TAU;
let dir = Vec2::from_angle(angle_rads);
let vel = player.1.0 + dir * 100.0;
commands.spawn((
Debris,
Visibility::Visible, // make sure it's "visible" not "Inherited" so the cycle works right
Lifetime(Timer::from_seconds(DEBRIS_LIFETIME, TimerMode::Once)),
Sparkler::at_interval(0.15),
Mesh2d(game_assets.thruster_mesh()), // borrow the thruster mesh for now
MeshMaterial2d(game_assets.thruster_mat_active()), // ... and the active thruster material
player.0.clone(), // clone the player transform
Velocity(vel),
));
}
// STEP 3: Respawn player (teleport them to the origin) // STEP 3: Respawn player (teleport them to the origin)
player.0.translation = Vec3::ZERO; player.0.translation = Vec3::ZERO;
player.1.0 = Vec2::ZERO; player.1.0 = Vec2::ZERO;

View File

@@ -22,7 +22,8 @@ use crate::{
SHIP_THRUSTER_COLOR_INACTIVE, config::WINDOW_SIZE, SHIP_THRUSTER_COLOR_INACTIVE, config::WINDOW_SIZE,
}; };
#[derive(Resource, Debug, Deref, Clone, Copy)] #[derive(InspectorOptions, Reflect, Resource, Debug, Deref, Clone, Copy)]
#[reflect(Resource, InspectorOptions)]
pub struct Score(pub i32); pub struct Score(pub i32);
impl From<Score> for String { impl From<Score> for String {

View File

@@ -1,6 +1,9 @@
use std::ops::DerefMut;
use crate::{ use crate::{
GameState, GameState,
config::{UI_BUTTON_HOVERED, UI_BUTTON_NORMAL, UI_BUTTON_PRESSED}, config::{UI_BUTTON_HOVERED, UI_BUTTON_NORMAL, UI_BUTTON_PRESSED},
despawn,
resources::{Lives, Score}, resources::{Lives, Score},
}; };
@@ -38,6 +41,17 @@ impl Plugin for PluginGetReady {
} }
} }
/// Plugin for the in-game HUD
pub struct PluginGameHud;
impl Plugin for PluginGameHud {
fn build(&self, app: &mut App) {
app.add_systems(OnEnter(GameState::Playing), spawn_ui)
.add_systems(OnExit(GameState::Playing), despawn::<MarkerHUD>)
.add_systems(Update, (operate_ui).run_if(in_state(GameState::Playing)));
}
}
/// Plugin for the game-over screen /// Plugin for the game-over screen
pub struct PluginGameOver; pub struct PluginGameOver;
@@ -65,10 +79,18 @@ struct OnReadySetGo;
#[derive(Component)] #[derive(Component)]
struct MarkerGameOver; struct MarkerGameOver;
/// Action specifier for the game-over menu's buttons. /// Marker for things on the HUD (the in-game UI elements)
#[derive(Component)]
struct MarkerHUD;
/// Action specifier for the buttons on the menus.
/// ///
/// Attach this component to a button and [`PluginGameOver`] will use it to /// Instead of holding function pointers for use as callbacks, I'm doing enum-
/// decide what to do when that button is pressed. /// dispatch. Add a variant according to what action the button press should
/// trigger.
///
/// Only [`PluginGameMenu`] and [`PluginGameOver`] will use it to this
/// component. There is no always-on, global system.
#[derive(Component)] #[derive(Component)]
enum ButtonMenuAction { enum ButtonMenuAction {
ToMainMenu, ToMainMenu,
@@ -88,14 +110,6 @@ 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. /// Utility function for creating a standard button.
fn button_bundle(text: &str) -> impl Bundle { fn button_bundle(text: &str) -> impl Bundle {
( (
@@ -152,7 +166,8 @@ fn spawn_menu(mut commands: Commands) {
}); });
} }
fn spawn_get_ready(mut commands: Commands) { fn spawn_get_ready(mut commands: Commands, mut timer: ResMut<ReadySetGoTimer>) {
timer.reset();
commands.spawn(( commands.spawn((
OnReadySetGo, // marker, so this can be de-spawned properly OnReadySetGo, // marker, so this can be de-spawned properly
Node { Node {
@@ -267,7 +282,7 @@ fn operate_buttons(
game_state.set(GameState::TitleScreen); game_state.set(GameState::TitleScreen);
} }
ButtonMenuAction::StartGame => { ButtonMenuAction::StartGame => {
game_state.set(GameState::Playing); game_state.set(GameState::GetReady);
} }
ButtonMenuAction::Quit => { ButtonMenuAction::Quit => {
app_exit_events.write(AppExit::Success); app_exit_events.write(AppExit::Success);
@@ -294,8 +309,50 @@ fn handle_spacebar(input: Res<ButtonInput<KeyCode>>, mut game_state: ResMut<Next
} }
pub fn spawn_ui(mut commands: Commands, score: Res<Score>, lives: Res<Lives>) { pub fn spawn_ui(mut commands: Commands, score: Res<Score>, lives: Res<Lives>) {
let score = score.0;
let lives = lives.0;
commands.spawn(( commands.spawn((
Text::new(format!("Score: {score:?} | Lives: {lives:?}")), MarkerHUD,
TextFont::from_font_size(25.0), Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
align_items: AlignItems::Start,
justify_content: JustifyContent::SpaceBetween,
padding: UiRect::all(Val::Px(5.0)),
..default()
},
children![
(
Text::new(format!("Score: {score}")),
TextFont::from_font_size(25.0),
TextShadow::default(),
),
(
Text::new(format!("Lives: {lives}")),
TextFont::from_font_size(25.0),
TextShadow::default(),
)
],
)); ));
} }
/// Updates the HUD with the current score & life count
///
/// TODO: some kind of event-based thing. Touching the text nodes every frame
/// seems expensive.
fn operate_ui(
mut query: Single<(&Node, &Children), With<MarkerHUD>>,
mut text_query: Query<&mut Text>,
lives: Res<Lives>,
score: Res<Score>,
) {
let (_node, children) = query.deref_mut();
let score = score.0;
let lives = lives.0;
// TODO: Something smarter than `unwrap()`
let mut score_text = text_query.get_mut(children[0]).unwrap();
**score_text = format!("Score: {score}");
let mut lives_text = text_query.get_mut(children[1]).unwrap();
**lives_text = format!("Lives: {lives}");
}