79 Commits

Author SHA1 Message Date
804186ea2f Handle player ship destruction... some of it...
Some checks failed
Basic checks / Basic build-and-test supertask (push) Has been cancelled
The player will be respawned, their lives decreased, and the board
cleared. The UI doesn't update, and the sudden snap to a freshly reset
board is quite jarring. The state transition to GameOver stops the game,
but there isn't anything else running in that state so it just looks
frozen.

Basically, there's a ton left to do, but technically I have handled
player ship destruction!
2025-08-10 21:29:25 -05:00
45b1fe751f Create the asteroid splitter to break down rocks.
Some checks failed
Basic checks / Basic build-and-test supertask (push) Has been cancelled
2025-08-10 20:45:08 -05:00
3d28d489b0 Fix: Pass size through to spawned asteroid
I actually have most of the asteroid's destruction handling system done
and found this bug.

Asteroid *assets* are selected based on the size set in the spawn event,
but the `Asteroid` Component itself was left hard-coded to the smallest
size.
2025-08-10 18:46:49 -05:00
cdd665cc93 Add bullet on-impact despawning system 2025-08-10 18:43:56 -05:00
364fbd7530 Use the lifetime limiters on Bullets & Asteroids
All checks were successful
Basic checks / Basic build-and-test supertask (push) Successful in 7m3s
Closes #14

Despawn bullets to limit range.

Despawn asteroids to limit random garbage floating around the scene.
2025-08-10 17:14:26 -05:00
2f463303a0 Create Lifetime component and system
Closes #13

The lifetime component, and system to operate it, are ready! Now I can
delete things that have lived for too long.
2025-08-10 17:07:20 -05:00
8d689d7842 Wire in the bullet & asteroid destruction events 2025-08-10 16:49:22 -05:00
65f28e832f Implement basic gun controller
All checks were successful
Basic checks / Basic build-and-test supertask (push) Successful in 7m3s
The spacebar fires the gun, bullets are spawned on top of the ship with
a velocity that sends them forward (relative to the ship).

I still need a despawn mechanism, and a fire rate control. To despawn
things, I'm already planning a `Lifetime` component. For rate of fire,
an additional component will be created and attached to the player ship.
2025-08-10 13:30:42 -05:00
dea8a0dc1a Fix: apply thrust input properly
All checks were successful
Basic checks / Basic build-and-test supertask (push) Successful in 7m11s
Yay for funny coordinate spaces. I was, again, using the wrong
operations to get the 2D Cartesian angle and apply force to the ship.
2025-08-09 16:25:19 -05:00
6191fde25a Fix: apply steering input properly
The ship in Asteroids isn't expected to spin up while holding a steering
direction, but that's exactly what I just made it do.

Fix that problem by assigning, not accumulating, the angular velocity
for the ship.
2025-08-09 16:09:54 -05:00
d4f11faf5a Fix: integrate rotation *correctly*
Something something quaternions are hard. I thought I would take the two
"things", add them together, and then put them back into the transform.

That's clearly not how it works :v
2025-08-09 16:04:51 -05:00
e841facf73 Remove Rotation component, update usage sites 2025-08-09 16:04:12 -05:00
939ffc70a1 Add AngularVelocity, begin removal of Rotation
The rotation component is also redundant with Bevy's transform
component.

This new component and system fill the physics role, but the input
handling code needs to be updated.

Player steering still functions because it uses the `Rotation` component
to direct the force vector. The force is correctly applied to the linear
velocity vector. An improvement would be to have a force/impulse
accumulator so I could account for mass, but I'm not going to do that
right now.
2025-08-09 15:50:54 -05:00
877c7f93d7 Drop TODO to combine phys fns, update docstring
Replacing the `Position` component with direct manipulation of the
transform functionally completed the TODO. The functions weren't exactly
combined, but the resulting behavior might as well have been.

I've updated the docstring to (exist, actually. yay syntax) describe the
system accurately.
2025-08-09 15:26:28 -05:00
2b1a0f386e Add an angular velocity component 2025-08-09 15:14:05 -05:00
515ecaac27 autoformat 2025-08-09 15:11:57 -05:00
3922cac3d7 Finish removal of Position
The component has been completely removed, and all call sites are fixed
to use the Transform directly.
2025-08-09 15:10:44 -05:00
e834d94b8a Use Transform not Position in wrapping system 2025-08-09 15:08:15 -05:00
ad5e86a06b Begin removal of Position component
The position component is redundant with the built-in Bevy Transform.
I've updated the velocity integrator to use the transform directly.

Physics still works, but things still set their initial locations
through the Position component. I'll need to fix all those call sites.
2025-08-09 15:05:02 -05:00
3d0da6df2d Fix module-level docstring in asteroids & config
I made a comment to help me remember the purpose, but if I do it this
way `cargo doc` will render it out to the actual documentation.
2025-08-09 14:55:07 -05:00
73b97ad15c Re-order use statements, prefer crate mods first 2025-08-09 14:53:50 -05:00
61c57783f1 Split physics parts into a submodule
The physics sim bits (that aren't Rapier2d) are now in their own
submodule. I've included the `Wrapping` marker component because it
doesn't really have anywhere else to live, and it's kinda sorta related
to the physics. It controls the motion of objects... that's physics. :p

There are name collisions between Rapier2d's `Velocity` and my own, but
that's a problem for later. I've used more specific type paths where
necessary to maintain the previous behavior.

The `physics::Position` component can go away completely. It's just an
extra copy of some of the built-in `Transform` data. I'm pretty sure it
only exists because I didn't realize I could rely on directly
manipulating the transform when I started this project.
2025-08-09 14:46:56 -05:00
809810b8ce First parts of a collision event handler
All checks were successful
Basic checks / Basic build-and-test supertask (push) Successful in 7m15s
I've added a new `event` module to contain all the game's events (which
it does not. I'll collect them all later).

Using these events, the CollisionEvent listening system can watch for
any two colliders intersecting, check on what role they play (asteroid,
ship, bullet, etc), and re-emit an event for that specific thing. Each
component can then handle it's own destruction process.
2025-08-09 12:30:37 -05:00
29735e7426 Move ship parts into it's own submodule 2025-08-08 22:03:34 -05:00
ab5f489450 Enable collision sensing for bodyless entities
Some checks failed
Basic checks / Basic build-and-test supertask (push) Has been cancelled
Rapier expects to have a RigidBody attached to the entity, but I do not.

I'm not going to make one, either, because the objects in a game of
Asteroids don't need collision handling the way most games do. I just
need to know if two objects have started overlapping.

According to this: https://rapier.rs/docs/user_guides/bevy_plugin/colliders#collision-groups-and-solver-groups
only one of the two objects involved needs to have the ActiveEvents and
ActiveCollisionTypes components attached, so I've placed them on the
player ship.
2025-08-06 15:21:12 -05:00
d4ceaa6312 Add Sensor component to colliders 2025-08-06 14:44:38 -05:00
f553574e3e Beginning work with collision detection
Some checks failed
Basic checks / Basic build-and-test supertask (push) Failing after 31s
I'm going to grab the Rapier physics library so that I don't have to do
my own collision detection mechanism. The last time I did this, I
simplified everything into circles. This time I'd like to have convex
hulls, particularly for the player ship.

Also the last time, I ended up rolling my own quadtree impl. I'm not
particularly interested in doing that again, and I'd like to learn more
of the broader Bevy ecosystem.
2025-08-06 13:05:12 -05:00
96e9376330 Autoformat before physics feature
All checks were successful
Basic checks / Basic build-and-test supertask (push) Successful in 6m47s
2025-08-06 12:32:42 -05:00
07105760f5 Bump Rust edition to 2024
Some checks failed
Basic checks / Basic build-and-test supertask (push) Failing after 27s
2025-07-30 22:41:43 -05:00
1555c93bed Set asteroid velocity so they move into play area
All checks were successful
Basic checks / Basic build-and-test supertask (push) Successful in 6m24s
It's functional, if not especially interesting. Forward to the next
thing!
2025-07-29 17:44:33 -05:00
2b93654491 Randomly assign asteroid spawning positions
All checks were successful
Basic checks / Basic build-and-test supertask (push) Successful in 6m23s
I need to close the circle down to touch the corners of the play area,
but this demonstrates the principle.

Next, I need to generate a velocity and "fix" it so that the asteroid
crosses through the viewport. I left a TODO about this, which I think
will work well enough. Although it might allow for some asteroids to
slip past, or bias the density in a funny way.

Oh well, it's just an Asteroids game.
2025-07-29 16:31:35 -05:00
c80ada4aa6 Add "rand" crate, store an RNG in AsteroidSpawner 2025-07-29 13:32:26 -05:00
eee039339e Asteroid spawner now uses info from event message
Some checks failed
Basic checks / Basic build-and-test supertask (push) Has been cancelled
Closes #4: "Convert asteroid spawning system into an event listener"
2025-07-29 13:26:41 -05:00
04f192b62a Drop never-used import
Thanks, VSCode. I really like when you import things I'm not actually
trying to use. SMH my head.
2025-07-29 13:17:01 -05:00
2dd3b2ff61 Fix case on AsteroidSize enum variants
All checks were successful
Basic checks / Basic build-and-test supertask (push) Successful in 6m28s
Closes #9: "Fix case on AsteroidSize enum variants"
2025-07-29 13:15:54 -05:00
2c43bc699e Begin work on event-based asteroid spawning
The events are being emitted by the spawn manager, and consumed by the
spawn_asteroid system. Now to wire in the spawn properties, and then
make a good spawning manager.
2025-07-29 13:12:22 -05:00
911b6c5fe7 Fix visibility & add constructors, program builds
All checks were successful
Basic checks / Basic build-and-test supertask (push) Successful in 6m29s
2025-07-29 12:54:09 -05:00
71ec77f5b1 Move asteroid bits to another module
Now to fix the visibility issues and make the program compile again...
2025-07-29 12:48:54 -05:00
5dfe11d31f Remove unused color import
All checks were successful
Basic checks / Basic build-and-test supertask (push) Successful in 6m30s
2025-07-29 11:59:36 -05:00
40102bf46b Drop ThrusterColors component
All checks were successful
Basic checks / Basic build-and-test supertask (push) Successful in 6m26s
I've already moved these resources to the GameAssets Resource, now I can
delete the Component struct itself.
2025-07-29 11:57:52 -05:00
0eac337c00 Update spawn_player to use new GameAssets
Some checks failed
Basic checks / Basic build-and-test supertask (push) Has been cancelled
2025-07-29 11:54:39 -05:00
3d4e0afc58 Swap asteroid-spawning to use new GameAssets 2025-07-29 11:51:12 -05:00
f62ab2c95d New GameAssets resource to hold all my assets
I'm finally getting around to centralizing all the assets instead of
letting spawners load their own.

I'm missing some assets, which may eventually be filled by textures
instead of solid-colors and simple shapes

I also need to hook up all the functions to use this thing instead.
2025-07-29 11:48:02 -05:00
6681b25728 Functional prototype of WIP asteroid spawning
All checks were successful
Basic checks / Basic build-and-test supertask (push) Successful in 6m36s
It needs a whole lot more work, but hey, look: A rock!

... well a circle, anyway.
2025-07-28 15:35:50 -05:00
68a8de1809 Make player ship wrap by marking it with Wrapping
Some checks failed
Basic checks / Basic build-and-test supertask (push) Failing after 3m2s
2025-07-28 09:15:23 -05:00
a6622f24b5 Add new "Wrapping" marker component
Not everything needs to wrap, so I'll use a marker component for the
ones that do. At the moment, I'm thinking only the player's ship will
wrap around. Asteroids can be de-spawned and re-spawned, and bullets can
simply evaporate.
2025-07-27 19:36:05 -05:00
c37887b0e7 Remove unused system inputs for prep widget 2025-07-27 16:20:40 -05:00
31451732c4 Finish the countdown anim with a progress bar
All checks were successful
Basic checks / Basic build-and-test supertask (push) Successful in 6m40s
2025-07-27 16:18:51 -05:00
4ecbfaa370 Implement (most of) the timer countdown
Some checks failed
Basic checks / Basic build-and-test supertask (push) Failing after 26s
The timer-wrapper thing was marked as a Component rather than a
Resource, which I have fixed.

The update function is about as straight forward as can be:
1. tick the timer
2. read the value out, format it for display,
3. check if the timer is expired, change states if so.
2025-07-27 14:42:50 -05:00
eb50655671 autoformat 2025-07-27 12:57:56 -05:00
a7d54c9192 Impl "get-ready"s despawn function 2025-07-27 12:57:07 -05:00
fccd2e6a8b Impl the "get-ready" widget's spawn function 2025-07-27 12:56:42 -05:00
efabcbf636 Start submodule to impl the "Get ready" spinner 2025-07-27 11:58:58 -05:00
f848de6b2e Replace usage of another deprecated function
Some checks failed
Basic checks / Basic build-and-test supertask (push) Failing after 29s
2025-07-27 11:08:59 -05:00
e605bbf80d Spawn just one camera (it's messing with egui)
Some checks failed
Basic checks / Basic build-and-test supertask (push) Failing after 27s
The egui debug inspector disappears when switching scenes, which seems
to be related to the camera change. I'm not going to dig into why
exactly this is happening. I'll just create one camera and keep it.
2025-07-27 11:05:49 -05:00
39bddf1c9e Fix: only run space-to-start when on title screen 2025-07-27 10:43:28 -05:00
477460ad2f Fix: Mark the main-menu camera so it despawns
The camera spawned for the main menu wasn't given the TitleUI marker
component, so it wouldn't despawn when changing to the game scene.
2025-07-27 10:41:00 -05:00
c11322969c Finally impl the "space to start" feature 2025-07-27 10:40:37 -05:00
7123192271 Implement the main menu despawn function 2025-07-27 10:33:53 -05:00
9a2381249f autoformat 2025-07-27 10:31:24 -05:00
f68d841e52 Switch to non-deprecated .single_mut() method 2025-07-27 10:31:12 -05:00
88db8a868a Make the Lives(i32) resource appear on debug UI 2025-07-27 10:30:11 -05:00
584a30f7f8 Add bevy-inspector-egui so I can fiddle with values easier 2025-07-27 09:45:33 -05:00
430b77be2e Fix: add game entities only when in Playing state 2025-07-27 09:32:49 -05:00
6eb69f476f Split title screen into it's own mod & Plugin
Some checks failed
Basic checks / Basic build-and-test supertask (push) Failing after 28s
2025-07-27 09:31:09 -05:00
a4409cb946 Bump to Bevy 0.16 2025-07-26 19:38:49 -05:00
38fbc85505 Autoformat 2025-07-26 18:59:56 -05:00
08c9625e71 Remove all usage of old Mesh & Material bundles 2025-07-26 18:57:47 -05:00
96aff4ae46 Chain method calls, drop intermediate vars 2025-07-26 18:47:18 -05:00
a52311eac6 Switch to new Camera2d struct over cam bundle 2025-07-26 18:45:02 -05:00
cf678f9f16 Update spawn_screen function 2025-07-26 18:44:10 -05:00
2f9afaeac1 Replace spawn_ui implementation 2025-07-26 18:30:38 -05:00
7f5a166f10 Hack to get ship meshes back in place 2025-07-26 18:19:50 -05:00
5e6440340f Rename time elapsed method usage 2025-07-26 18:01:06 -05:00
290aab45f5 Bump to Bevy 0.15, enable dynamic linkage
The dynamic linkage is for improving testing cycle time, not for any direct impl reason.
2025-07-26 17:55:22 -05:00
406e611e31 Autoformat to make the checker happy
Some checks failed
Basic checks / Basic build-and-test supertask (push) Failing after 44s
2025-02-01 17:28:37 -06:00
c86cd0d642 States, I guess. Now to do the others
Game states are named and used to toggle behavior. Now to rewrite those
things to do the *right* behavior.
2024-11-29 16:45:52 -06:00
f114203665 Title menu, but always present. Time for states!
I've made a quick title menu, but it is always present. I'll need to set
up some game state stuff so I can flip between play modes.
2024-11-29 16:01:23 -06:00
37d7c1db42 Spawning score and lives UI elements, no logic
I have basic UI elements! They can't be updated, yet, and there's still
no game logic to allow the player to affect it on their own.
2024-11-28 11:56:42 -06:00
10 changed files with 812 additions and 123 deletions

View File

@@ -1,7 +1,10 @@
[package] [package]
name = "asteroids" name = "asteroids"
version = "0.1.0" version = "0.2.0"
edition = "2021" edition = "2024"
[dependencies] [dependencies]
bevy = "0.14.2" bevy = { version = "0.16", features = ["dynamic_linking"] }
bevy-inspector-egui = "0.32.0"
bevy_rapier2d = { version = "0.31.0", features = ["debug-render-2d"] }
rand = "0.9.2"

184
src/asteroids.rs Normal file
View File

@@ -0,0 +1,184 @@
//! 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, event::AsteroidDestroy,
physics::Velocity,
};
#[derive(Component, Deref, DerefMut)]
pub struct Asteroid(AsteroidSize);
#[derive(Clone, Copy, Debug)]
pub enum AsteroidSize {
Small,
Medium,
Large,
}
impl AsteroidSize {
fn next(&self) -> Option<Self> {
match self {
AsteroidSize::Small => None,
AsteroidSize::Medium => Some(AsteroidSize::Small),
AsteroidSize::Large => Some(AsteroidSize::Medium),
}
}
}
#[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),
}
}
}
#[derive(Event)]
pub struct SpawnAsteroid {
pos: Vec2,
vel: Vec2,
size: AsteroidSize,
}
/// 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

@@ -1,7 +1,5 @@
/* //! Global constants used all over the program. Rather than leaving them scattered
Global constants used all over the program. Rather than leaving them scattered //! where ever they happen to be needed, I'm concentrating them here.
where ever they happen to be needed, I'm concentrating them here.
*/
use bevy::color::Color; use bevy::color::Color;
@@ -11,6 +9,16 @@ 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 ASTEROID_SMALL_COLOR: Color = Color::srgb(1.0, 0., 0.);
pub(crate) const BULLET_COLOR: Color = Color::srgb(0.0, 0.1, 0.9);
// TODO: asteroid medium & large
pub(crate) const SHIP_THRUST: f32 = 1.0; pub(crate) const SHIP_THRUST: f32 = 1.0;
pub(crate) const SHIP_ROTATION: f32 = 0.1; // +/- rotation speed in... radians per frame pub(crate) const SHIP_ROTATION: f32 = 4.0; // +/- rotation speed in... radians per frame
pub(crate) const BULLET_SPEED: f32 = 150.0;
pub(crate) const BULLET_LIFETIME: f32 = 2.0;
pub(crate) const ASTEROID_LIFETIME: f32 = 40.0;
pub const RNG_SEED: [u8; 32] = *b"12345678909876543210123456789098";

21
src/event.rs Normal file
View File

@@ -0,0 +1,21 @@
use bevy::prelude::*;
/// Signals that the player's ship has been destroyed.
/// Used when the player collides with an asteroid.
#[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.
#[derive(Event)]
pub(crate) struct AsteroidDestroy(pub Entity);
// 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.
///
/// TODO: Maybe use it for lifetime expiration (which is also a TODO item).
#[derive(Event)]
pub(crate) struct BulletDestroy(pub Entity);

View File

@@ -1,48 +1,184 @@
mod asteroids;
pub mod config; pub mod config;
mod event;
mod physics;
mod preparation_widget;
mod ship;
mod title_screen;
use crate::config::{BACKGROUND_COLOR, PLAYER_SHIP_COLOR, SHIP_ROTATION, SHIP_THRUST, WINDOW_SIZE}; use crate::asteroids::{Asteroid, 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,
};
use crate::physics::AngularVelocity;
use crate::ship::{Bullet, Ship};
use bevy::{prelude::*, sprite::MaterialMesh2dBundle}; use bevy::prelude::*;
use config::{SHIP_THRUSTER_COLOR_ACTIVE, SHIP_THRUSTER_COLOR_INACTIVE}; use bevy_inspector_egui::InspectorOptions;
use bevy_inspector_egui::prelude::ReflectInspectorOptions;
use bevy_rapier2d::{
plugin::{NoUserData, RapierPhysicsPlugin},
prelude::*,
render::RapierDebugRenderPlugin,
};
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_systems(Startup, (spawn_camera, spawn_player)) app.add_plugins((
.insert_resource(ClearColor(BACKGROUND_COLOR)) title_screen::GameMenuPlugin,
.insert_resource(WorldSize { preparation_widget::preparation_widget_plugin,
width: WINDOW_SIZE.x, RapierPhysicsPlugin::<NoUserData>::pixels_per_meter(10.0),
height: WINDOW_SIZE.y, RapierDebugRenderPlugin::default(),
}) ))
.add_systems( .insert_resource(ClearColor(BACKGROUND_COLOR))
FixedUpdate, .insert_resource(WorldSize {
(input_ship_thruster, input_ship_rotation, wrap_entities), width: WINDOW_SIZE.x,
height: WINDOW_SIZE.y,
})
.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(
FixedUpdate,
(
input_ship_thruster,
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,
) )
.add_systems( .run_if(in_state(GameState::Playing)),
FixedPostUpdate, )
(integrate_velocity, update_positions, apply_rotation_to_mesh), .add_systems(
); FixedPostUpdate,
(
physics::integrate_velocity,
physics::integrate_angular_velocity,
)
.run_if(in_state(GameState::Playing)),
)
.add_event::<asteroids::SpawnAsteroid>()
.add_event::<event::AsteroidDestroy>()
.add_event::<event::ShipDestroy>()
.add_event::<event::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<event::ShipDestroy>,
mut asteroid_writer: EventWriter<event::AsteroidDestroy>,
mut bullet_writer: EventWriter<event::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(event::ShipDestroy);
} // else, we don't care
} else if *two == *player {
if rocks.contains(*one) {
dbg!("Writing ShipDestroy event");
ship_writer.write(event::ShipDestroy);
}
}
// Option 2: Bullet & Asteroid
if bullets.contains(*one) {
if rocks.contains(*two) {
dbg!("Writing AsteroidDestroy & BulletDestroy events");
asteroid_writer.write(event::AsteroidDestroy(*two));
bullet_writer.write(event::BulletDestroy(*one));
}
} else if rocks.contains(*one) {
if bullets.contains(*two) {
dbg!("Writing AsteroidDestroy & BulletDestroy events");
asteroid_writer.write(event::AsteroidDestroy(*one));
bullet_writer.write(event::BulletDestroy(*two));
}
}
}
}
}
#[derive(Clone, Debug, Eq, Hash, PartialEq, States)]
pub enum GameState {
TitleScreen, // Program is started. Present title screen and await user start
GetReady, // Short timer to let the player get ready after pressing start
Playing, // Player has started the game. Run the main loop
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)] #[derive(Component)]
struct Position(bevy::math::Vec2); struct Lifetime(Timer);
#[derive(Component)] #[derive(InspectorOptions, Reflect, Resource, Debug, Deref, Clone, Copy)]
struct Velocity(bevy::math::Vec2); #[reflect(Resource, InspectorOptions)]
struct Lives(i32);
#[derive(Component)] impl From<Lives> for String {
struct Rotation(f32); fn from(value: Lives) -> Self {
value.to_string()
#[derive(Component)] }
struct Ship; }
// Data component to store color properties attached to an entity
// This was easier (and imo better) than holding global consts with
// UUID assets.
#[derive(Component)]
struct ThrusterColors(Handle<ColorMaterial>, Handle<ColorMaterial>);
#[derive(Resource)] #[derive(Resource)]
struct WorldSize { struct WorldSize {
@@ -50,51 +186,81 @@ struct WorldSize {
height: f32, height: f32,
} }
fn spawn_camera(mut commands: Commands) { #[derive(Resource)]
commands.spawn(Camera2dBundle::default()); struct GameAssets {
meshes: [Handle<Mesh>; 5],
materials: [Handle<ColorMaterial>; 7],
} }
fn spawn_player( impl GameAssets {
mut commands: Commands, fn ship(&self) -> (Handle<Mesh>, Handle<ColorMaterial>) {
mut meshes: ResMut<Assets<Mesh>>, (self.meshes[0].clone(), self.materials[0].clone())
mut materials: ResMut<Assets<ColorMaterial>>, }
) {
let triangle = Triangle2d::new(
Vec2::new(0.5, 0.0),
Vec2::new(-0.5, 0.45),
Vec2::new(-0.5, -0.45),
);
let thruster_firing_id = materials.add(SHIP_THRUSTER_COLOR_ACTIVE);
let thruster_stopped_id = materials.add(SHIP_THRUSTER_COLOR_INACTIVE);
let ship_mesh = MaterialMesh2dBundle { // The thruster mesh is actually just the ship mesh
mesh: meshes.add(triangle).into(), fn thruster_mesh(&self) -> Handle<Mesh> {
material: materials.add(PLAYER_SHIP_COLOR), self.meshes[0].clone()
transform: Transform::default().with_scale(Vec3::new(20.0, 20.0, 20.0)), }
..default()
};
let thruster_mesh = MaterialMesh2dBundle { // TODO: Look into parameterizing the material
mesh: meshes.add(triangle).into(), // A shader uniform should be able to do this, but I don't know how to
material: materials.add(PLAYER_SHIP_COLOR), // load those in Bevy.
transform: Transform::default() fn thruster_mat_inactive(&self) -> Handle<ColorMaterial> {
.with_scale(Vec3::splat(0.5)) self.materials[1].clone()
.with_translation(Vec3::new(-0.5, 0.0, -0.1)), }
..default()
};
let thruster = commands.spawn(thruster_mesh).id(); fn thruster_mat_active(&self) -> Handle<ColorMaterial> {
self.materials[2].clone()
}
let mut ship_id = commands.spawn(( fn asteroid_small(&self) -> (Handle<Mesh>, Handle<ColorMaterial>) {
Ship, (self.meshes[1].clone(), self.materials[1].clone())
Position(Vec2::default()), }
Velocity(Vec2::ZERO),
Rotation(0.0),
ship_mesh,
ThrusterColors(thruster_firing_id, thruster_stopped_id),
));
ship_id.add_child(thruster); 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);
} }
/* /*
@@ -102,10 +268,14 @@ fn spawn_player(
*/ */
fn input_ship_thruster( fn input_ship_thruster(
keyboard_input: Res<ButtonInput<KeyCode>>, keyboard_input: Res<ButtonInput<KeyCode>>,
mut query: Query<(&mut Velocity, &Rotation, &mut Children, &ThrusterColors), With<Ship>>, mut query: Query<(&mut physics::Velocity, &Transform, &mut Children), With<Ship>>,
mut commands: Commands, mut commands: Commands,
game_assets: Res<GameAssets>,
) { ) {
let Ok((mut velocity, rotation, children, colors)) = query.get_single_mut() else { // TODO: Maybe change for a Single<Ship>> so this only runs for the one ship
// buuut... that would silently do nothing if there are 0 or >1 ships, and
// I might want to crash on purpose in that case.
let Ok((mut velocity, transform, children)) = query.single_mut() else {
let count = query.iter().count(); let count = query.iter().count();
panic!("There should be exactly one player ship! Instead, there seems to be {count}."); panic!("There should be exactly one player ship! Instead, there seems to be {count}.");
}; };
@@ -115,10 +285,14 @@ fn input_ship_thruster(
.expect("Couldn't find first child, which should be the thruster"); .expect("Couldn't find first child, which should be the thruster");
if keyboard_input.pressed(KeyCode::KeyW) { if keyboard_input.pressed(KeyCode::KeyW) {
velocity.0 += Vec2::from_angle(rotation.0) * SHIP_THRUST; velocity.0 += (transform.rotation * Vec3::X).xy() * SHIP_THRUST;
commands.entity(*thrusters).insert(colors.0.clone()); commands
.entity(*thrusters)
.insert(MeshMaterial2d(game_assets.thruster_mat_active()));
} else { } else {
commands.entity(*thrusters).insert(colors.1.clone()); commands
.entity(*thrusters)
.insert(MeshMaterial2d(game_assets.thruster_mat_inactive()));
} }
} }
@@ -128,62 +302,64 @@ fn input_ship_thruster(
*/ */
fn input_ship_rotation( fn input_ship_rotation(
keyboard_input: Res<ButtonInput<KeyCode>>, keyboard_input: Res<ButtonInput<KeyCode>>,
mut query: Query<&mut Rotation, With<Ship>>, mut query: Query<&mut AngularVelocity, With<Ship>>,
) { ) {
let Ok(mut rotation) = query.get_single_mut() else { let Ok(mut angular_vel) = query.single_mut() else {
let count = query.iter().count(); let count = query.iter().count();
panic!("There should be exactly one player ship! Instead, there seems to be {count}."); panic!("There should be exactly one player ship! Instead, there seems to be {count}.");
}; };
if keyboard_input.pressed(KeyCode::KeyA) { if keyboard_input.pressed(KeyCode::KeyA) {
rotation.0 += SHIP_ROTATION; angular_vel.0 = SHIP_ROTATION;
} else if keyboard_input.pressed(KeyCode::KeyD) { } else if keyboard_input.pressed(KeyCode::KeyD) {
rotation.0 -= SHIP_ROTATION; angular_vel.0 = -SHIP_ROTATION;
} else {
angular_vel.0 = 0.0;
} }
} }
/* fn input_ship_shoot(
Add velocity to position keyboard_input: Res<ButtonInput<KeyCode>>,
*/ ship: Single<(&Transform, &physics::Velocity), With<Ship>>,
fn integrate_velocity(mut query: Query<(&mut Position, &Velocity)>, time: Res<Time>) { game_assets: Res<GameAssets>,
for (mut position, velocity) in &mut query { mut commands: Commands,
position.0 += velocity.0 * time.delta_seconds(); ) {
let (ship_pos, ship_vel) = *ship;
// 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((
ship::Bullet,
Collider::ball(0.2),
Sensor,
ActiveEvents::COLLISION_EVENTS,
ActiveCollisionTypes::STATIC_STATIC,
physics::Velocity(bullet_vel),
Mesh2d(game_assets.bullet().0),
MeshMaterial2d(game_assets.bullet().1),
ship_pos.clone(), // clone ship transform
Lifetime(Timer::from_seconds(BULLET_LIFETIME, TimerMode::Once)),
));
} }
} }
fn update_positions(mut query: Query<(&mut Transform, &Position)>) { fn spawn_ui(mut commands: Commands, score: Res<Score>, lives: Res<Lives>) {
for (mut transform, position) in &mut query { commands.spawn((
transform.translation.x = position.0.x; Text::new(format!("Score: {score:?} | Lives: {lives:?}")),
transform.translation.y = position.0.y; TextFont::from_font_size(25.0),
} ));
} }
/* fn tick_lifetimes(mut commands: Commands, time: Res<Time>, query: Query<(Entity, &mut Lifetime)>) {
Assigns the rotation to the transform by copying it from the Rotation component. for (e, mut life) in query {
*/ life.0.tick(time.delta());
fn apply_rotation_to_mesh(mut query: Query<(&mut Transform, &Rotation)>) { if life.0.just_finished() {
for (mut transform, rotation) in &mut query { commands.entity(e).despawn();
transform.rotation = Quat::from_rotation_z(rotation.0);
}
}
fn wrap_entities(mut query: Query<&mut Position>, world_size: Res<WorldSize>) {
let right = world_size.width / 2.0;
let left = -right;
let top = world_size.height / 2.0;
let bottom = -top;
for mut pos in query.iter_mut() {
if pos.0.x > right {
pos.0.x = left;
} else if pos.0.x < left {
pos.0.x = right;
}
if pos.0.y > top {
pos.0.y = bottom;
} else if pos.0.y < bottom {
pos.0.y = top;
} }
} }
} }

View File

@@ -1,6 +1,7 @@
use bevy::{prelude::*, window::WindowResolution}; use bevy::{prelude::*, window::WindowResolution};
use asteroids::{config::WINDOW_SIZE, AsteroidPlugin}; use asteroids::{AsteroidPlugin, config::WINDOW_SIZE};
use bevy_inspector_egui::{bevy_egui::EguiPlugin, quick::WorldInspectorPlugin};
fn main() { fn main() {
App::new() App::new()
@@ -12,5 +13,7 @@ fn main() {
..default() ..default()
})) }))
.add_plugins(AsteroidPlugin) .add_plugins(AsteroidPlugin)
.add_plugins(EguiPlugin::default())
.add_plugins(WorldInspectorPlugin::new())
.run(); .run();
} }

59
src/physics.rs Normal file
View File

@@ -0,0 +1,59 @@
//! Custom physics items
//! TODO: Refactor in terms of Rapier2D, *or* implement colliders and remove it.
use crate::WorldSize;
use bevy::prelude::*;
#[derive(Clone, Component)]
pub(crate) struct Velocity(pub(crate) bevy::math::Vec2);
#[derive(Component)]
pub(crate) struct AngularVelocity(pub(crate) f32);
/// Marker for any entity that should wrap on screen edges
#[derive(Component)]
pub(crate) struct Wrapping;
/// Integrate linear velocity and update the entity's transform.
pub(crate) fn integrate_velocity(mut query: Query<(&mut Transform, &Velocity)>, time: Res<Time>) {
for (mut transform, velocity) in &mut query {
let delta = velocity.0 * time.delta_secs();
transform.translation += delta.extend(0.0);
}
}
/// Integrate angular velocity and update the entity's transform.
pub(crate) fn integrate_angular_velocity(
mut objects: Query<(&mut Transform, &AngularVelocity)>,
time: Res<Time>,
) {
for (mut transform, ang_vel) in &mut objects {
let delta = ang_vel.0 * time.delta_secs();
transform.rotate_z(delta);
}
}
pub(crate) fn wrap_entities(
mut query: Query<&mut Transform, With<Wrapping>>,
world_size: Res<WorldSize>,
) {
let right = world_size.width / 2.0;
let left = -right;
let top = world_size.height / 2.0;
let bottom = -top;
for mut pos in query.iter_mut() {
if pos.translation.x > right {
pos.translation.x = left;
} else if pos.translation.x < left {
pos.translation.x = right;
}
if pos.translation.y > top {
pos.translation.y = bottom;
} else if pos.translation.y < bottom {
pos.translation.y = top;
}
}
}

99
src/preparation_widget.rs Normal file
View File

@@ -0,0 +1,99 @@
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);
}
}

82
src/ship.rs Normal file
View File

@@ -0,0 +1,82 @@
use crate::{
AngularVelocity, GameAssets, GameState, Lives,
asteroids::Asteroid,
event::{BulletDestroy, ShipDestroy},
physics::{Velocity, Wrapping},
};
use bevy::prelude::*;
use bevy_rapier2d::prelude::*;
#[derive(Component)]
pub struct Ship;
#[derive(Component)]
pub struct Bullet;
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;
}
}

54
src/title_screen.rs Normal file
View File

@@ -0,0 +1,54 @@
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);
}
}