53 Commits

Author SHA1 Message Date
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
7 changed files with 479 additions and 68 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"

128
src/asteroids.rs Normal file
View File

@@ -0,0 +1,128 @@
use bevy_rapier2d::prelude::Collider;
use rand::{Rng, SeedableRng};
use std::time::Duration;
/// This is the module containing all the rock-related things
/// not... not the whole game.
use bevy::prelude::*;
use crate::{GameAssets, Position, Rotation, Velocity, WorldSize};
#[derive(Component, Deref, DerefMut)]
pub struct Asteroid(AsteroidSize);
pub enum AsteroidSize {
Small,
Medium,
Large,
}
#[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(AsteroidSize::Small),
Collider::ball(collider_radius),
Position(spawn.pos),
Velocity(spawn.vel),
Rotation(0.0),
Mesh2d(mesh),
MeshMaterial2d(material),
));
}
}

View File

@@ -11,6 +11,10 @@ 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.);
// 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 = 0.1; // +/- rotation speed in... radians per frame
pub const RNG_SEED: [u8; 32] = *b"12345678909876543210123456789098";

View File

@@ -1,31 +1,69 @@
mod asteroids;
pub mod config; pub mod config;
mod preparation_widget;
mod title_screen;
use crate::config::{BACKGROUND_COLOR, PLAYER_SHIP_COLOR, SHIP_ROTATION, SHIP_THRUST, WINDOW_SIZE}; use crate::config::{BACKGROUND_COLOR, PLAYER_SHIP_COLOR, SHIP_ROTATION, SHIP_THRUST, WINDOW_SIZE};
use bevy::{prelude::*, sprite::MaterialMesh2dBundle}; use asteroids::AsteroidSpawner;
use config::{SHIP_THRUSTER_COLOR_ACTIVE, SHIP_THRUSTER_COLOR_INACTIVE}; use bevy::prelude::*;
use bevy_inspector_egui::InspectorOptions;
use bevy_inspector_egui::prelude::ReflectInspectorOptions;
use bevy_rapier2d::{plugin::{NoUserData, RapierPhysicsPlugin}, prelude::Collider, render::RapierDebugRenderPlugin};
use config::{ASTEROID_SMALL_COLOR, SHIP_THRUSTER_COLOR_ACTIVE, SHIP_THRUSTER_COLOR_INACTIVE};
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), (spawn_player, spawn_ui))
.add_systems(
FixedUpdate,
(
input_ship_thruster,
input_ship_rotation,
wrap_entities,
asteroids::tick_asteroid_manager,
asteroids::spawn_asteroid.after(asteroids::tick_asteroid_manager),
) )
.add_systems( .run_if(in_state(GameState::Playing)),
FixedPostUpdate, )
(integrate_velocity, update_positions, apply_rotation_to_mesh), .add_systems(
); FixedPostUpdate,
(integrate_velocity, update_positions, apply_rotation_to_mesh)
.run_if(in_state(GameState::Playing)),
)
.add_event::<asteroids::SpawnAsteroid>();
app.insert_state(GameState::TitleScreen);
} }
} }
#[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(Component)] #[derive(Component)]
struct Position(bevy::math::Vec2); struct Position(bevy::math::Vec2);
@@ -38,11 +76,28 @@ struct Rotation(f32);
#[derive(Component)] #[derive(Component)]
struct Ship; struct Ship;
// Data component to store color properties attached to an entity /// Marker for any entity that should wrap on screen edges
// This was easier (and imo better) than holding global consts with
// UUID assets.
#[derive(Component)] #[derive(Component)]
struct ThrusterColors(Handle<ColorMaterial>, Handle<ColorMaterial>); struct Wrapping;
#[derive(Resource, Debug, Deref, Clone, Copy)]
struct Score(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)]
struct Lives(i32);
impl From<Lives> for String {
fn from(value: Lives) -> Self {
value.to_string()
}
}
#[derive(Resource)] #[derive(Resource)]
struct WorldSize { struct WorldSize {
@@ -50,51 +105,97 @@ struct WorldSize {
height: f32, height: f32,
} }
fn spawn_camera(mut commands: Commands) { #[derive(Resource)]
commands.spawn(Camera2dBundle::default()); struct GameAssets {
meshes: [Handle<Mesh>; 4],
materials: [Handle<ColorMaterial>; 6],
} }
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())
}
}
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)),
];
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),
];
GameAssets { meshes, materials }
}
}
fn spawn_camera(mut commands: Commands) {
commands.spawn(Camera2d);
}
fn spawn_player(mut commands: Commands, game_assets: Res<GameAssets>) {
commands
.spawn((
Collider::ball(0.7),
Ship,
Wrapping,
Position(Vec2::default()),
Velocity(Vec2::ZERO),
Rotation(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)),
));
} }
/* /*
@@ -102,10 +203,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 Velocity, &Rotation, &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, rotation, 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}.");
}; };
@@ -116,9 +221,13 @@ fn input_ship_thruster(
if keyboard_input.pressed(KeyCode::KeyW) { if keyboard_input.pressed(KeyCode::KeyW) {
velocity.0 += Vec2::from_angle(rotation.0) * SHIP_THRUST; velocity.0 += Vec2::from_angle(rotation.0) * 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()));
} }
} }
@@ -130,7 +239,7 @@ 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 Rotation, With<Ship>>,
) { ) {
let Ok(mut rotation) = query.get_single_mut() else { let Ok(mut rotation) = 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}.");
}; };
@@ -142,12 +251,16 @@ fn input_ship_rotation(
} }
} }
// TODO: Combine movement integration steps into one function
// They need to be ordered so the physics is deterministic. Bevy can enforce
// order, but it makes more sense to cut out the extra machinery and have one
// single function. Probably better for cache locality or whatever, too.
/* /*
Add velocity to position Add velocity to position
*/ */
fn integrate_velocity(mut query: Query<(&mut Position, &Velocity)>, time: Res<Time>) { fn integrate_velocity(mut query: Query<(&mut Position, &Velocity)>, time: Res<Time>) {
for (mut position, velocity) in &mut query { for (mut position, velocity) in &mut query {
position.0 += velocity.0 * time.delta_seconds(); position.0 += velocity.0 * time.delta_secs();
} }
} }
@@ -167,7 +280,7 @@ fn apply_rotation_to_mesh(mut query: Query<(&mut Transform, &Rotation)>) {
} }
} }
fn wrap_entities(mut query: Query<&mut Position>, world_size: Res<WorldSize>) { fn wrap_entities(mut query: Query<&mut Position, With<Wrapping>>, world_size: Res<WorldSize>) {
let right = world_size.width / 2.0; let right = world_size.width / 2.0;
let left = -right; let left = -right;
let top = world_size.height / 2.0; let top = world_size.height / 2.0;
@@ -187,3 +300,10 @@ fn wrap_entities(mut query: Query<&mut Position>, world_size: Res<WorldSize>) {
} }
} }
} }
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),
));
}

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

99
src/preparation_widget.rs Normal file
View File

@@ -0,0 +1,99 @@
use bevy::{
color::palettes::css::{BLACK, GREEN, LIGHT_BLUE, RED},
prelude::*,
};
use crate::GameState;
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);
}
}

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