12 Commits

Author SHA1 Message Date
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
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
7 changed files with 131 additions and 30 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

@@ -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

View File

@@ -16,7 +16,7 @@ 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::*;
@@ -37,6 +37,7 @@ 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(), RapierDebugRenderPlugin::default(),
)) ))
@@ -45,13 +46,12 @@ impl Plugin for AsteroidPlugin {
.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,
( (
@@ -85,6 +85,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 +169,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

@@ -22,7 +22,7 @@ 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, SHIP_FIRE_RATE},
events::{AsteroidDestroy, BulletDestroy, ShipDestroy, SpawnAsteroid}, events::{AsteroidDestroy, BulletDestroy, ShipDestroy, SpawnAsteroid},
machinery::Lifetime, machinery::Lifetime,
physics::{Velocity, Wrapping}, physics::{Velocity, Wrapping},
@@ -55,6 +55,10 @@ 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;
@@ -144,6 +148,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),

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,6 +79,10 @@ struct OnReadySetGo;
#[derive(Component)] #[derive(Component)]
struct MarkerGameOver; struct MarkerGameOver;
/// Marker for things on the HUD (the in-game UI elements)
#[derive(Component)]
struct MarkerHUD;
/// Action specifier for the game-over menu's buttons. /// Action specifier for the game-over menu's buttons.
/// ///
/// Attach this component to a button and [`PluginGameOver`] will use it to /// Attach this component to a button and [`PluginGameOver`] will use it to
@@ -88,14 +106,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 +162,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 +278,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 +305,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}");
}