113 Commits

Author SHA1 Message Date
51e6989ef4 Swap button colors to be less upsetting
Making everything gray is still boring as dirt, but at least it doesn't
look like debug info.
2025-08-14 08:55:13 -05:00
2ffc0e8861 Finish main-menu button handling
All checks were successful
Basic checks / Basic build-and-test supertask (push) Successful in 7m8s
2025-08-13 17:25:26 -05:00
d15b96ef48 Add "start" and "quit" buttons to title screen
I'm stealing the game-over plugin's button handling for use in the main
menu. I guess it turns out that my widgets are more generic than
intended.
2025-08-13 17:00:29 -05:00
0aefc96f7a Center the text, add a drop shadow 2025-08-13 16:03:40 -05:00
4102446e90 Rename main menu marker to "MarkerMainMenu" 2025-08-13 15:38:53 -05:00
f4df2ae33a Rearrange the main menu components & systems
I'm trying to keep things somewhat in order. Plugins, then components,
then systems. Within those, they're roughly ordered by game state.
2025-08-13 15:36:14 -05:00
fc43be0777 Hook up the action selectors to the buttons
All checks were successful
Basic checks / Basic build-and-test supertask (push) Successful in 7m8s
2025-08-13 14:22:00 -05:00
a74b99deb4 Visual button interaction is working, needs act
All checks were successful
Basic checks / Basic build-and-test supertask (push) Successful in 7m22s
2025-08-13 12:59:38 -05:00
2f9401e93f Begin GameOver scene, widget spawner works
I've taken a lot directly from the Bevy UI button example.
(https://bevy.org/examples/ui-user-interface/button/)

I'll make it look better later. For now, it just needs to exist. Onward
to the UI operation system!
2025-08-13 11:58:11 -05:00
54ef257ab4 Make generic UI despawning function 2025-08-13 11:56:35 -05:00
69bef24913 Collect GUI plugins at the top of the module
I want the different "scenes" to be their own plugins for ease of setup
and reading.

The main menu plugin has been renamed to have "Plugin" first. This is so
the lexical sort in the docs places all the plugins next to each other.

The "get-ready" plugin has been given an empty struct and an
`impl Plugin` to match the main menu plugin. I've started the game over
scene, but left it unimplemented.
2025-08-13 11:09:48 -05:00
df4479bf49 Remove collision debug print system 2025-08-13 10:33:59 -05:00
33828d6a85 Rewrite WorldSize as a newtype around Vec2
All checks were successful
Basic checks / Basic build-and-test supertask (push) Successful in 7m7s
Closes #16

Bevy provides Deref and DerefMut derives, so that's nice. I'm not sure
it makes any sense to keep the field private since those derefs expose
the Vec2 anyway. I added an `impl Default` anyway, though.
2025-08-12 23:39:19 -05:00
5e2018d3e4 Docs for physics module 2025-08-12 23:26:37 -05:00
7c877264a2 autoformat 2025-08-12 23:18:46 -05:00
16842c13f7 Docs for objects.rs 2025-08-12 23:18:33 -05:00
e199db16eb Docs for machinery.rs 2025-08-12 23:01:32 -05:00
f17910daef Docs for lib.rs 2025-08-12 22:41:37 -05:00
70f2313766 Improve documentation for the events
Some checks failed
Basic checks / Basic build-and-test supertask (push) Has been cancelled
2025-08-12 11:33:00 -05:00
58bbc1e614 Fix documentation links 2025-08-11 23:50:43 -05:00
79679759c5 Move HUD spawning system into widgets
It actually needs to be completely replaced. When that finally happens,
the new bits will live here.
2025-08-11 23:42:06 -05:00
0e517de419 Move the title screen stuff into widgets.rs 2025-08-11 23:40:52 -05:00
cb2b57449a Create a "widgets" module (by moving title scene)
The non-gameplay scenes are really just a bunch of widgets. I'm going to
put them all together and then bundle the functionality with some
exported plugin builders.
2025-08-11 23:39:42 -05:00
9a262fcffc Move Lifetime component to machinery module 2025-08-11 23:34:12 -05:00
34ee2fcc7d Create a "machinery" module for game systems
I'm not sold on the name, but anyway.

This module will hold the systems that power the main game mechanics, as
well as a few extra items to support that (in particular, the Lifetime
component).
2025-08-11 23:28:39 -05:00
93da225636 Move resources to a resources.rs submodule 2025-08-11 23:19:06 -05:00
cd194e2dbf Move collision system to physics module 2025-08-11 23:07:18 -05:00
1369a3092f Move the ship & bullet systems into the object mod 2025-08-11 23:01:14 -05:00
619037dab0 Move asteroid spawning & splitting systems
They deal with events, but are directly related to how the Asteroid
object interacts with the events, so I'm putting them here.
2025-08-11 22:59:20 -05:00
c1b69c412a autoformat 2025-08-11 22:48:08 -05:00
d8a83b77c2 Move ship & bullet to the objects module 2025-08-11 22:47:48 -05:00
f5ff7c8779 Move Asteroid components to the objects.rs mod 2025-08-11 22:44:05 -05:00
f4484f759f Move SpawnAsteroid event into events.rs 2025-08-11 19:16:34 -05:00
571b910945 Rename "event" module to be plural "events.rs" 2025-08-11 19:14:22 -05:00
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 1082 additions and 150 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"

View File

@@ -1,16 +1,28 @@
/* //! 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;
pub const WINDOW_SIZE: bevy::prelude::Vec2 = bevy::prelude::Vec2::new(800.0, 600.0); pub const WINDOW_SIZE: bevy::prelude::Vec2 = bevy::prelude::Vec2::new(800.0, 600.0);
pub const UI_BUTTON_NORMAL: Color = Color::srgb(0.15, 0.15, 0.15); // Button color when it's just hanging out
pub const UI_BUTTON_HOVERED: Color = Color::srgb(0.25, 0.25, 0.25); // ... when it's hovered
pub const UI_BUTTON_PRESSED: Color = Color::srgb(0.55, 0.55, 0.55); // ... when it's pressed
pub(crate) const BACKGROUND_COLOR: Color = Color::srgb(0.3, 0.3, 0.3); pub(crate) const 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";

36
src/events.rs Normal file
View File

@@ -0,0 +1,36 @@
use bevy::prelude::*;
use crate::objects::AsteroidSize;
/// Signals that the player's ship has been destroyed.
///
/// Produced by the [`fn collision_listener(...)`](`crate::physics::collision_listener`)
/// system when the player collides with an asteroid. They are consumed by [`fn ship_impact_listener(...)`](`crate::objects::ship_impact_listener`).
#[derive(Event)]
pub(crate) struct ShipDestroy;
/// Signals that a particular asteroid has been destroyed.
///
/// Produced by the [`fn collision_listener(...)`](`crate::physics::collision_listener`)
/// system when bullets contact asteroids. It is consumed by [`fn split_asteroid(...)`](`crate::objects::spawn_asteroid`)
/// system, which re-emits [spawn events](`SpawnAsteroid`).
#[derive(Event)]
pub(crate) struct AsteroidDestroy(pub Entity);
/// Signals that an asteroid needs to be spawned and provides the parameters
/// for that action.
///
/// Produced by the [`tick_asteroid_manager(...)`](`crate::machinery::tick_asteroid_manager`)
/// system and consumed by the [`spawn_asteroid(...)`](`crate::objects::spawn_asteroid`) system.
#[derive(Event)]
pub struct SpawnAsteroid {
pub pos: Vec2,
pub vel: Vec2,
pub size: AsteroidSize,
}
/// Signals that a particular bullet has been destroyed (after it strikes an Asteroid).
///
/// TODO: Maybe use it for lifetime expiration (which is also a TODO item).
#[derive(Event)]
pub(crate) struct BulletDestroy(pub Entity);

View File

@@ -1,111 +1,116 @@
//! Asteroids, implemented as a Bevy plugin.
//!
//! Compile-time configurables can be found in the [`config`] module.
pub mod config; pub mod config;
mod events;
mod machinery;
mod objects;
mod physics;
mod resources;
mod widgets;
use crate::config::{BACKGROUND_COLOR, PLAYER_SHIP_COLOR, SHIP_ROTATION, SHIP_THRUST, WINDOW_SIZE}; 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,
};
use crate::machinery::AsteroidSpawner;
use crate::objects::{Bullet, Ship};
use crate::physics::AngularVelocity;
use bevy::{prelude::*, sprite::MaterialMesh2dBundle}; use bevy::prelude::*;
use config::{SHIP_THRUSTER_COLOR_ACTIVE, SHIP_THRUSTER_COLOR_INACTIVE}; use bevy_rapier2d::{
plugin::{NoUserData, RapierPhysicsPlugin},
prelude::*,
render::RapierDebugRenderPlugin,
};
use machinery::Lifetime;
use resources::{GameAssets, Lives, Score, WorldSize};
/// The main game plugin.
pub struct AsteroidPlugin; 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)) widgets::PluginGameMenu,
.insert_resource(WorldSize { widgets::PluginGameOver,
width: WINDOW_SIZE.x, widgets::PluginGetReady,
height: WINDOW_SIZE.y, RapierPhysicsPlugin::<NoUserData>::pixels_per_meter(10.0),
}) RapierDebugRenderPlugin::default(),
.add_systems( ))
FixedUpdate, .insert_resource(ClearColor(BACKGROUND_COLOR))
(input_ship_thruster, input_ship_rotation, wrap_entities), .insert_resource(WorldSize::default())
.insert_resource(Lives(3))
.register_type::<Lives>()
.insert_resource(Score(0))
.insert_resource(AsteroidSpawner::new())
.init_resource::<GameAssets>()
.add_systems(Startup, spawn_camera)
.add_systems(
OnEnter(GameState::Playing),
(objects::spawn_player, widgets::spawn_ui),
)
.add_systems(
FixedUpdate,
(
input_ship_thruster,
input_ship_rotation,
input_ship_shoot,
physics::wrap_entities,
machinery::tick_asteroid_manager,
objects::spawn_asteroid.after(machinery::tick_asteroid_manager),
objects::split_asteroids,
objects::bullet_impact_listener,
objects::ship_impact_listener,
physics::collision_listener,
machinery::tick_lifetimes,
) )
.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::<events::SpawnAsteroid>()
.add_event::<events::AsteroidDestroy>()
.add_event::<events::ShipDestroy>()
.add_event::<events::BulletDestroy>();
app.insert_state(GameState::TitleScreen);
} }
} }
#[derive(Component)] /// The game's main state tracking mechanism, registered with Bevy as a [`State`](`bevy::prelude::State`).
struct Position(bevy::math::Vec2); #[derive(Clone, Debug, Eq, Hash, PartialEq, States)]
pub enum GameState {
#[derive(Component)] TitleScreen, // Program is started. Present title screen and await user start
struct Velocity(bevy::math::Vec2); GetReady, // Short timer to let the player get ready after pressing start
Playing, // Player has started the game. Run the main loop
#[derive(Component)] GameOver, // Game has ended. Present game over dialogue and await user restart
struct Rotation(f32);
#[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)]
struct WorldSize {
width: f32,
height: f32,
} }
fn spawn_camera(mut commands: Commands) { fn spawn_camera(mut commands: Commands) {
commands.spawn(Camera2dBundle::default()); commands.spawn(Camera2d);
} }
fn spawn_player( /// Player's thruster control system.
mut commands: Commands, ///
mut meshes: ResMut<Assets<Mesh>>, /// Checks if "W" is pressed and increases velocity accordingly.
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 {
mesh: meshes.add(triangle).into(),
material: materials.add(PLAYER_SHIP_COLOR),
transform: Transform::default().with_scale(Vec3::new(20.0, 20.0, 20.0)),
..default()
};
let thruster_mesh = MaterialMesh2dBundle {
mesh: meshes.add(triangle).into(),
material: materials.add(PLAYER_SHIP_COLOR),
transform: Transform::default()
.with_scale(Vec3::splat(0.5))
.with_translation(Vec3::new(-0.5, 0.0, -0.1)),
..default()
};
let thruster = commands.spawn(thruster_mesh).id();
let mut ship_id = commands.spawn((
Ship,
Position(Vec2::default()),
Velocity(Vec2::ZERO),
Rotation(0.0),
ship_mesh,
ThrusterColors(thruster_firing_id, thruster_stopped_id),
));
ship_id.add_child(thruster);
}
/*
Checks if "W" is pressed and increases velocity accordingly.
*/
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,75 +120,71 @@ 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()));
} }
} }
/* /// Player's rotation control system.
Checks if "A" or "D" is pressed and updates the player's Rotation component accordingly ///
Does *not* rotate the graphical widget! (that's done by the `apply_rotation_to_mesh` system) /// Checks if "A" or "D" is pressed and updates the player's [`AngularVelocity`]
*/ /// component accordingly.
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;
} }
} }
/* /// Player's gun trigger.
Add velocity to position ///
*/ /// Checks if the spacebar has just been pressed, spawning a bullet if so.
fn integrate_velocity(mut query: Query<(&mut Position, &Velocity)>, time: Res<Time>) { ///
for (mut position, velocity) in &mut query { /// TODO: Hook up a timer to control weapon fire-rate. Something will have to
position.0 += velocity.0 * time.delta_seconds(); /// tick those timers. Maybe this system?
} fn input_ship_shoot(
} keyboard_input: Res<ButtonInput<KeyCode>>,
ship: Single<(&Transform, &physics::Velocity), With<Ship>>,
fn update_positions(mut query: Query<(&mut Transform, &Position)>) { game_assets: Res<GameAssets>,
for (mut transform, position) in &mut query { mut commands: Commands,
transform.translation.x = position.0.x; ) {
transform.translation.y = position.0.y; 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;
Assigns the rotation to the transform by copying it from the Rotation component.
*/ // TODO: create a timer for the gun fire rate.
fn apply_rotation_to_mesh(mut query: Query<(&mut Transform, &Rotation)>) { // For now, spawn one for each press of the spacebar.
for (mut transform, rotation) in &mut query { if keyboard_input.just_pressed(KeyCode::Space) {
transform.rotation = Quat::from_rotation_z(rotation.0); commands.spawn((
} Bullet,
} Collider::ball(0.2),
Sensor,
fn wrap_entities(mut query: Query<&mut Position>, world_size: Res<WorldSize>) { ActiveEvents::COLLISION_EVENTS,
let right = world_size.width / 2.0; ActiveCollisionTypes::STATIC_STATIC,
let left = -right; physics::Velocity(bullet_vel),
let top = world_size.height / 2.0; Mesh2d(game_assets.bullet().0),
let bottom = -top; MeshMaterial2d(game_assets.bullet().1),
ship_pos.clone(), // clone ship transform
for mut pos in query.iter_mut() { Lifetime(Timer::from_seconds(BULLET_LIFETIME, TimerMode::Once)),
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;
}
} }
} }

105
src/machinery.rs Normal file
View File

@@ -0,0 +1,105 @@
//! Systems, Components, and any other items for powering the game logic.
//!
//! Where the objects (ship, asteroid, etc) carry their own behavioral systems,
//! the *game* keeps its main logic here. Its for ambient behaviors, like
//! asteroid spawning, or eventually the flying saucer spawns.
use rand::{Rng, SeedableRng};
use std::time::Duration;
use bevy::prelude::*;
use crate::{WorldSize, events::SpawnAsteroid, objects::AsteroidSize};
/// Asteroid spawning parameters and state.
///
/// This struct keeps track of the rng and timer for spawning asteroids. In the
/// future it may contain additional fields to allow for more control.
///
/// It's values are operated by the [`tick_asteroid_manager`] system.
#[derive(Resource)]
pub struct AsteroidSpawner {
rng: std::sync::Mutex<rand::rngs::StdRng>,
timer: Timer,
// TODO: Configurables?
// - interval
// - density
// - size distribution
}
impl AsteroidSpawner {
pub fn new() -> Self {
Self {
rng: std::sync::Mutex::new(rand::rngs::StdRng::from_seed(crate::config::RNG_SEED)),
timer: Timer::new(Duration::from_secs(3), TimerMode::Repeating),
}
}
}
/// Update the asteroid spawn timer in the [`AsteroidSpawner`] resource, and
/// spawns any asteroids that are due this frame.
pub fn tick_asteroid_manager(
mut events: EventWriter<SpawnAsteroid>,
mut spawner: ResMut<AsteroidSpawner>,
time: Res<Time>,
play_area: Res<WorldSize>,
) {
spawner.timer.tick(time.delta());
if spawner.timer.just_finished() {
let mut rng = spawner
.rng
.lock()
.expect("Expected to acquire lock on the AsteroidSpawner's RNG field.");
// Use polar coordinate to decide where the asteroid will spawn
// Theta will be random between 0 to 2pi
let spawn_angle = rng.random_range(0.0..(std::f32::consts::PI * 2.0));
// Rho will be the radius of a circle bordering the viewport, multiplied by 1.2
// TODO: Use view diagonal to get a minimally sized circle around the play area
let spawn_distance = play_area.x.max(play_area.y) / 2.0;
// Convert polar to Cartesian, use as position
let pos = Vec2::new(
spawn_distance * spawn_angle.cos(),
spawn_distance * spawn_angle.sin(),
);
// Right now, I'm thinking I can use the opposite signs attached to the position Vec components.
// pos.x == -100, then vel.x = + <random>
// pos.x == 100, then vel.x = - <random>
// etc,
let mut vel = Vec2::new(rng.random_range(0.0..100.0), rng.random_range(0.0..100.0));
if pos.x > 0.0 {
vel.x *= -1.0;
}
if pos.y > 0.0 {
vel.y *= -1.0;
}
let size = match rng.random_range(0..=2) {
0 => AsteroidSize::Small,
1 => AsteroidSize::Medium,
2 => AsteroidSize::Large,
_ => unreachable!(),
};
events.write(SpawnAsteroid { pos, vel, size });
}
}
/// Sets a lifetime on an entity to automatically delete it after expiration.
#[derive(Component)]
pub struct Lifetime(pub Timer);
/// System to tick the [`Lifetime`] timers and despawn expired entities.
pub fn tick_lifetimes(
mut commands: Commands,
time: Res<Time>,
query: Query<(Entity, &mut Lifetime)>,
) {
for (e, mut life) in query {
life.0.tick(time.delta());
if life.0.just_finished() {
commands.entity(e).despawn();
}
}
}

View File

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

207
src/objects.rs Normal file
View File

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

140
src/physics.rs Normal file
View File

@@ -0,0 +1,140 @@
//! Custom physics items
//!
//! TODO: Refactor in terms of Rapier2D, *or* implement colliders and remove it.
//!
//! Position, both translation and rotation, are tracked by the built-in
//! Bevy [`Transform`] component. This module adds a (Linear) [`Velocity`] and
//! [`AngularVelocity`] component to the mix.
//! These two components are operated by simple integrator systems to
//! accumulate translation and rotation from the velocities.
//!
//! The [`Wrapping`] component marks things that should wrap around the world
//! boundaries. It's a kind of physical property of this world, so I'm putting
//! it here.
//!
//! Collisions are also considered physics. After all, I got *a physics engine*
//! to detect them for me (so that I don't have to write clipping code).
use crate::{
WorldSize, events,
objects::{Asteroid, Bullet, Ship},
};
use bevy::prelude::*;
use bevy_rapier2d::pipeline::CollisionEvent;
#[derive(Clone, Component)]
pub(crate) struct Velocity(pub(crate) bevy::math::Vec2);
#[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.x / 2.0;
let left = -right;
let top = world_size.y / 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;
}
}
}
/// The collision event routing system.
///
/// When a `CollisionEvent` occurrs, this system checks which things collided
/// and emits secondary events accordignly.
///
/// | Objects | Response |
/// |-|-|
/// | Ship & Asteroid | emits event [`ShipDestroy`](`crate::events::ShipDestroy`) |
/// | Asteroid & Bullet | emits event [`AsteroidDestroy`](`crate::events::AsteroidDestroy`) |
/// | Asteroid & Asteroid | Nothing. Asteroids won't collide with each other |
/// | Bullet & Bullet | Nothing. Bullets won't collide with each other (and probably can't under normal gameplay conditions) |
/// | Bullet & Ship | Nothing. The player shouldn't be able to shoot themselves (and the Flying Saucer hasn't been impl.'d, so it's bullets don't count) |
pub fn collision_listener(
mut collisions: EventReader<CollisionEvent>,
mut ship_writer: EventWriter<events::ShipDestroy>,
mut asteroid_writer: EventWriter<events::AsteroidDestroy>,
mut bullet_writer: EventWriter<events::BulletDestroy>,
player: Single<Entity, With<Ship>>,
bullets: Query<&Bullet>,
rocks: Query<&Asteroid>,
) {
for event in collisions.read() {
if let CollisionEvent::Started(one, two, _flags) = event {
// Valid collisions are:
//
// - Ship & Asteroid
// - Bullet & Asteroid
//
// Asteroids don't collide with each other, bullets don't collide
// with each other, and bullets don't collide with the player ship.
// Option 1: Ship & Asteroid
if *one == *player {
if rocks.contains(*two) {
// player-asteroid collision
dbg!("Writing ShipDestroy event");
ship_writer.write(events::ShipDestroy);
} // else, we don't care
} else if *two == *player {
if rocks.contains(*one) {
dbg!("Writing ShipDestroy event");
ship_writer.write(events::ShipDestroy);
}
}
// Option 2: Bullet & Asteroid
if bullets.contains(*one) {
if rocks.contains(*two) {
dbg!("Writing AsteroidDestroy & BulletDestroy events");
asteroid_writer.write(events::AsteroidDestroy(*two));
bullet_writer.write(events::BulletDestroy(*one));
}
} else if rocks.contains(*one) {
if bullets.contains(*two) {
dbg!("Writing AsteroidDestroy & BulletDestroy events");
asteroid_writer.write(events::AsteroidDestroy(*one));
bullet_writer.write(events::BulletDestroy(*two));
}
}
}
}
}

124
src/resources.rs Normal file
View File

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

301
src/widgets.rs Normal file
View File

@@ -0,0 +1,301 @@
use crate::{
GameState,
config::{UI_BUTTON_HOVERED, UI_BUTTON_NORMAL, UI_BUTTON_PRESSED},
resources::{Lives, Score},
};
use bevy::{
color::palettes::css::{BLACK, DARK_GRAY, GREEN, LIGHT_BLUE, RED, WHITE},
prelude::*,
};
/// Plugin for the main menu
pub struct PluginGameMenu;
impl Plugin for PluginGameMenu {
fn build(&self, app: &mut App) {
app.add_systems(OnEnter(GameState::TitleScreen), spawn_menu)
.add_systems(OnExit(GameState::TitleScreen), despawn::<MarkerMainMenu>)
.add_systems(
Update,
(handle_spacebar, operate_buttons).run_if(in_state(GameState::TitleScreen)),
);
}
}
/// Plugin for the "get ready" period as gameplay starts.
pub struct PluginGetReady;
impl Plugin for PluginGetReady {
fn build(&self, app: &mut App) {
app.add_systems(OnEnter(GameState::GetReady), spawn_get_ready)
.add_systems(OnExit(GameState::GetReady), despawn::<OnReadySetGo>)
.add_systems(
Update,
(animate_get_ready_widget).run_if(in_state(GameState::GetReady)),
)
.insert_resource(ReadySetGoTimer(Timer::from_seconds(3.0, TimerMode::Once)));
}
}
/// Plugin for the game-over screen
pub struct PluginGameOver;
impl Plugin for PluginGameOver {
fn build(&self, app: &mut App) {
app.add_systems(OnEnter(GameState::GameOver), spawn_gameover_ui)
.add_systems(OnExit(GameState::GameOver), despawn::<MarkerGameOver>)
.add_systems(
Update,
operate_buttons.run_if(in_state(GameState::GameOver)),
);
}
}
// Marker component for the title screen UI entity.
// This way, a query for the TitleUI can be used to despawn the title screen
#[derive(Component)]
struct MarkerMainMenu;
/// Marker component for things on the get-ready indicator
#[derive(Component)]
struct OnReadySetGo;
/// Marker for things on the game-over screen
#[derive(Component)]
struct MarkerGameOver;
/// Action specifier for the game-over menu's buttons.
///
/// Attach this component to a button and [`PluginGameOver`] will use it to
/// decide what to do when that button is pressed.
#[derive(Component)]
enum ButtonMenuAction {
ToMainMenu,
StartGame,
Quit,
}
/// Newtype wrapper for `Timer`. Used to count down during the "get ready" phase.
#[derive(Deref, DerefMut, Resource)]
struct ReadySetGoTimer(Timer);
/// Marker for the counter text segment
#[derive(Component)]
struct CountdownText;
/// Marker for the counter bar segment
#[derive(Component)]
struct CountdownBar;
/// Despawns entities matching the generic argument. Intended to remove UI
/// elements.
fn despawn<T: Component>(mut commands: Commands, to_despawn: Query<Entity, With<T>>) {
for entity in to_despawn {
commands.entity(entity).despawn();
}
}
/// Utility function for creating a standard button.
fn button_bundle(text: &str) -> impl Bundle {
(
Button,
// TODO: Generic action
Node {
width: Val::Px(150.0),
height: Val::Px(65.0),
border: UiRect::all(Val::Px(2.0)),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
margin: UiRect::all(Val::Px(5.0)),
..default()
},
BorderColor(Color::BLACK),
BorderRadius::MAX,
BackgroundColor(UI_BUTTON_NORMAL),
children![(
Text::new(text),
TextColor(Color::srgb(0.9, 0.9, 0.9)),
TextShadow::default(),
)],
)
}
fn spawn_menu(mut commands: Commands) {
commands
.spawn((
MarkerMainMenu,
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
flex_direction: FlexDirection::Column,
..Default::default()
},
))
.with_children(|cmds| {
cmds.spawn((
Text::new("Robert's Bad Asteroids Game"),
TextFont::from_font_size(50.0),
TextLayout::new_with_justify(JustifyText::Center),
TextShadow::default(),
));
cmds.spawn((
Text::new("Press space to begin"),
TextFont::from_font_size(20.0),
TextColor(Color::srgb(0.7, 0.7, 0.7)),
TextShadow::default(),
));
cmds.spawn((button_bundle("Start Game"), ButtonMenuAction::StartGame));
cmds.spawn((button_bundle("Quit"), ButtonMenuAction::Quit));
});
}
fn spawn_get_ready(mut commands: Commands) {
commands.spawn((
OnReadySetGo, // marker, so this can be de-spawned properly
Node {
align_self: AlignSelf::Center,
justify_self: JustifySelf::Center,
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
flex_direction: FlexDirection::Column,
width: Val::Percent(30.),
height: Val::Percent(30.),
..default()
},
BackgroundColor(LIGHT_BLUE.into()),
children![
(Text::new("Get Ready!"), TextColor(BLACK.into())),
(
CountdownBar,
Node {
width: Val::Percent(90.0),
height: Val::Percent(10.),
..default()
},
BackgroundColor(GREEN.into()),
),
(
CountdownText,
Text::new("<uninit timer>"),
TextColor(RED.into()),
)
],
));
}
/// Spawns the game over screen.
///
/// Used by [`PluginGameOver`] when entering the [`GameState::GameOver`] state.
fn spawn_gameover_ui(mut commands: Commands) {
commands.spawn((
MarkerGameOver, // Marker, so `despawn<T>` can remove this on state exit.
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
flex_direction: FlexDirection::Column,
..default()
},
children![
(button_bundle("Main Menu"), ButtonMenuAction::ToMainMenu,),
(button_bundle("Quit"), ButtonMenuAction::Quit),
],
));
}
fn animate_get_ready_widget(
mut text_segment: Single<&mut Text, With<CountdownText>>,
mut bar_segment: Single<&mut Node, With<CountdownBar>>,
time: Res<Time>,
mut timer: ResMut<ReadySetGoTimer>,
mut game_state: ResMut<NextState<GameState>>,
) {
// Advance the timer, read the remaining time and write it onto the label.
timer.tick(time.delta());
// Add one to the visual value so the countdown starts at 3 and stops at 1.
// Otherwise it starts at 2 and disappears after showing 0.
// That feels wrong even though it's functionally identical.
let tval = timer.0.remaining().as_secs() + 1;
**text_segment = format!("{tval}").into();
// Shrink the progress bar Node
bar_segment.width = Val::Percent(100.0 * (1.0 - timer.fraction()));
// If the timer has expired, change state to playing.
if timer.finished() {
game_state.set(GameState::Playing);
}
}
/// Handles interactions with the menu buttons.
///
/// The buttons are used by the main menu and the game-over menu to change
/// between game states.
///
/// Button animation and action handling is done entirely within this system.
///
/// There are no checks for current state. If a "quit" button was put somewhere
/// on the HUD, this system would quit the game. The same will happen for
/// returning to the title screen. This should be useful for making a pause
/// menu, too.
fn operate_buttons(
mut interactions: Query<
(
&Interaction,
&mut BackgroundColor,
&mut BorderColor,
&ButtonMenuAction,
),
(Changed<Interaction>, With<Button>),
>,
mut game_state: ResMut<NextState<GameState>>,
mut app_exit_events: EventWriter<AppExit>,
) {
// TODO: Better colors. These are taken from the example and they're ugly.
for (interaction, mut color, mut border_color, menu_action) in &mut interactions {
match *interaction {
Interaction::Pressed => {
*color = UI_BUTTON_PRESSED.into();
border_color.0 = DARK_GRAY.into();
match menu_action {
ButtonMenuAction::ToMainMenu => {
game_state.set(GameState::TitleScreen);
}
ButtonMenuAction::StartGame => {
game_state.set(GameState::Playing);
}
ButtonMenuAction::Quit => {
app_exit_events.write(AppExit::Success);
}
}
}
Interaction::Hovered => {
*color = UI_BUTTON_HOVERED.into();
border_color.0 = WHITE.into();
}
Interaction::None => {
*color = UI_BUTTON_NORMAL.into();
border_color.0 = BLACK.into();
}
}
}
}
/// Main menu input listener. Starts game when the spacebar is pressed.
fn handle_spacebar(input: Res<ButtonInput<KeyCode>>, mut game_state: ResMut<NextState<GameState>>) {
if input.just_pressed(KeyCode::Space) {
game_state.set(GameState::GetReady);
}
}
pub fn spawn_ui(mut commands: Commands, score: Res<Score>, lives: Res<Lives>) {
commands.spawn((
Text::new(format!("Score: {score:?} | Lives: {lives:?}")),
TextFont::from_font_size(25.0),
));
}