33 Commits

Author SHA1 Message Date
099926d368 Hotfix for broken install target in Makefile
Some checks failed
Basic checks / Basic build-and-test supertask (push) Failing after 48s
I didn't check that it also *installs* all of it's components! That's
what I get for not having a list of package contents... although I'd
probably just end up missing something there instead.
2025-12-19 16:11:13 -06:00
76426094d3 Mark v0.6.0 release
Some checks failed
Basic checks / Basic build-and-test supertask (push) Failing after 59s
2025-12-19 13:47:49 -06:00
4b101633e7 Better colors for the "get ready" screen
The card itself is transparent, but it still has a size of 30% of the
viewport because the bar is based on the size of it's container.

The start bar and text can still be green and red, respectively, but the
light blue was completely out of place.
2025-12-19 13:45:22 -06:00
a5a6f32037 Add a sound warning and mute guide
Page visitors may be surprised by the sounds and not know how to turn
them off. The answer is "you don't", but practically the browser tab can
be muted instead.
2025-12-19 13:36:48 -06:00
4d899d3c97 Improved "load game" button, now with elem swap
Some checks failed
Basic checks / Basic build-and-test supertask (push) Failing after 1m3s
The canvas element no longer exists in the page source. Instead, there
is a div styled to look similar and contain a button. The button is
styled to match the ones in the Bevy app. When pressed, the button
creates the canvas element, replaces the fake canvas div, and loads the
WASM to do it's thing.
2025-12-19 13:27:42 -06:00
6d5afc2445 Add a basic "load game" button
This will ensure the user has interacted with the page before the game
starts, thus allowing the sound to play correctly.

It doesn't re-load the game if the user quits. I'd like to figure out
how to do that.

The need to push the button isn't immediately obvious. It should be over
top (or in place of) the canvas to best convey that the user must start
the game.
2025-12-19 11:46:18 -06:00
d34d0a31f2 Fix: Folder deps need to be order-only deps
I did the thing againnnnn. The Makefile thinks the sound assets are
constantly out-of-date because the folder that contains them technically
changes any time a file is added or removed from the folder.

1. "out/assets" is created, it is up-to-date
2. "out/assets/example.ogg" is created, it is up-to-date
3. Parent folder "out/assets" has it's mtime updated because it's
   contents have changed. It is newer than it was at step 1, and newer
   than the .ogg file in step 2.
4. run `make` again
5. "out/assets" is up-to-date, nothing changes
6. "out/assets/example.ogg" is OLDER than one of it's dependencies
   ("out/assets/"), and is rebuilt.
7. "out/assets" got new contents, so it's mtime was updated again.

The cycle isn't infinite, but it will always try to rebuild the sound
files. The fix is to consider the containing folder to only be an
ordering dependency rather than a substantive dependency. The former
only needs the dependency to be made first, where the latter considers
the dependency to be part of the target file. The containing folder is
not part of the sound files, so "rebuilding" the sound files when the
folder changes is complete nonsense.
2025-12-19 11:26:18 -06:00
72f062ea10 Teach the Makefile how to "build" the audio assets
Some checks failed
Basic checks / Basic build-and-test supertask (push) Failing after 55s
Makefiles are pretty strange things.

There is a new wildcard target which will copy any .ogg file from
"assets/" into "out/assets". On it's own, it does nothing -- it only
knows how to create the output file by copying the input one.

To drive this, a list of output assets must be created and depended upon
by the main build target(s). I don't want to manually maintain an asset
manifest in a Makefile, so I've achived this by wildcard matching
anything in the "assets/" folder, then rewriting the prefix to be
"out/assets/". This list is in a variable, which is now part of the
dependency list for the main build target(s). All files will be
installed, but only when they are out-of-date. Excellent.
2025-12-19 10:55:57 -06:00
eecfbf5d7c Dev version bump to 0.6.0-dev3
Some checks failed
Basic checks / Basic build-and-test supertask (push) Failing after 1m2s
I'm moving on from asset stuff, so I'm considering this a "new version."

Next up: Fixing the Makefile to understand the sound files.
2025-12-19 10:41:08 -06:00
1e09e3ce3a Fix: Include the sound asset for the thruster!
I forgot to check-in the file the other day. Big brain here in this
head.
2025-12-19 10:40:28 -06:00
19ac032792 Add proper meshes & materials for game objects
The asteroid circles have been replaced with polylines. I was going to
do a regular triangle mesh, but I don't want to figure out GLTF
loading or manually chunking the points into convex hulls.

Asteroid materials are now all populated and their GameAsset getters are
indexed correctly. I'm not sold on the size based color selection.
Later, I think I'll either remove the extra colors or let each of them
randomly apply to any asteroid.

The bullet is now a short line segment. Apparently I already wired in
the rotation logic, so pointing it correctly out of the ship already
works.
2025-12-19 10:29:48 -06:00
de75e25ca6 Add ship thruster sound
The player's Ship now has an AudioPlayer component constantly looping a
thruster sound effect. It starts paused and is only resumed when the
player fires the thruster.

As noted in the TODO comment at the top of the input_ship_thruster(...)
system, I need to figure out if I want to start using the `Single<>`
query parameter instead of a `Query<>` and then doing my own
null-ability checks (like what it does now).
2025-12-17 17:18:18 -06:00
d7802bdbed Add asteroid destruction sound 2025-12-17 16:18:31 -06:00
ae093d2c9c Add bullet/laser sound
Firing the weapon now makes a sound. I've implemented this by spawning
the playback component on the bullet rather than the gun. This seemed
easier than figuring out how to reset a playback component that lives on
the ship entity -- although thats probably better for memory access
patterns.
2025-12-17 14:29:57 -06:00
3963b548b9 Fix: Make shipwreck sound entity despawn itself
I started to work on weapon fire sounds and realized I don't know if the
entity and/or component get removed after playback ends. It turns out
that they DO NOT!... unless told to do so, like this.
2025-12-17 14:23:57 -06:00
b1fd2e5f73 Add a readme
Some checks failed
Basic checks / Basic build-and-test supertask (push) Failing after 1m54s
There hasn't been a README this whole time... I guess there hasn't been
anything I need to immediately communicate to someone looking at the
source repository.

There is now a README so I have somewhere to record the extra licensing
information (for the Kenney asset).
2025-12-17 11:01:23 -06:00
09ff4dc6ca First sound! Added ship explosion sound effect
It's a simple one-shot sound clip that gets dispatched as one more part
of the ship impact routine.

The GameAssets struct has been updated to have an array of handles for
audio assets (just the one, for now).

This sound file, and the next several, are all from Kenney
(www.kenney.nl).
2025-12-17 10:46:51 -06:00
a48dfc1d65 Finish the engine upgrade
Some checks failed
Basic checks / Basic build-and-test supertask (push) Failing after 2m1s
Events have been replaced with Messages, import paths have been updated
for new engine module layout, and minor API changes have been matched.
2025-12-17 10:15:33 -06:00
6e425e8eb9 WIP on engine upgrade 2025-12-17 09:44:48 -06:00
7bd88c702f Replace event derives with message derives 2025-12-17 09:44:48 -06:00
cfe35c61ed Rename crate::events to crate::messages
Bevy 0.17 introduces this idea of "Messages" which mostly replace the
previous usage of "Events." The old events still exist, but are meant
more for targetted actions -- something happening and immediately
triggering a function call on an observer.
2025-12-17 09:44:48 -06:00
13643f73fb Upgrade to Bevy 0.17... minus fixes for API change
Bevy 0.17 is out and I'm going to get started on an upgrade. Upgrading
dependencies will be it's own commit, as will many of the fixes. This
way I can cherry-pick anything, if need be.
2025-12-17 09:41:44 -06:00
8fc9e682cc Release v0.5.0
Some checks failed
Basic checks / Basic build-and-test supertask (push) Failing after 28s
2025-11-15 15:18:54 -06:00
be83be1a7b Compress the WASM file for even more space savings 2025-11-15 15:16:37 -06:00
3639122e54 Fix: install "asteroids.html" not "boids.html"
Yay for copying code around!
2025-11-15 15:16:02 -06:00
97e0313c23 Impl a basic scoring system
Games have scores, so I need a score counter... I guess.
2025-11-09 11:14:27 -06:00
de79ca0258 Apply clippy lint fix 2025-11-09 10:57:36 -06:00
1edbd3e78c Name oldest viable dependency versions
Semver compatible resolution means I still get the newest, so I want to
name the oldest that still works to let Cargo do it's thing.
2025-11-08 22:17:05 -06:00
5af59863a1 Release v0.4.0, now includes lockfile
All checks were successful
Basic checks / Basic build-and-test supertask (push) Successful in 6m23s
The only real change was the addition of some CSS. I'm recording the
lockfile so that consumers can more reliably build the same binary each
time.
2025-11-08 12:31:29 -06:00
84d93d496a Remove Linux-specific deps from web build
All checks were successful
Basic checks / Basic build-and-test supertask (push) Successful in 6m24s
The Alsa and Udev system dependencies are only required on Linux. The
WASM/WASI build doesn't use them, so they don't need to exist in the
build container.
2025-11-08 12:10:42 -06:00
4a9d252691 Update Dockerfile, copy everything, use makefile
I have figured out the `.dockerignore` file so I don't have to do manual
context size management like before.

The Dockerfile now uses the Makefile to ensure image builds contain the
same thing non-image builds would.
2025-11-08 12:08:45 -06:00
3c9a9a7d9d Add some CSS 2025-11-08 12:07:30 -06:00
010cbd6d4b Add an install target 2025-11-06 14:18:13 -06:00
19 changed files with 6977 additions and 112 deletions

6640
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,14 @@
[package]
name = "asteroids"
version = "0.3.0"
version = "0.6.1"
edition = "2024"
license = "AGPL-3.0-only"
[dependencies]
bevy = "0.16"
bevy-inspector-egui = "0.32.0"
bevy_rapier2d = "0.31.0"
rand = "0.9.2"
bevy = "0.17"
bevy-inspector-egui = "0.34"
bevy_rapier2d = "0.32"
rand = "0.9"
[features]
default = ["dynamic_linking"]
@@ -16,7 +16,7 @@ dynamic_linking = ["bevy/dynamic_linking"]
debug_ui = ["bevy_rapier2d/debug-render-2d"]
[target.'cfg(target_arch = "wasm32")'.dependencies]
getrandom = { version = "0.3.3", features = ["wasm_js"] }
getrandom = { version = "0.3", features = ["wasm_js"] }
[profile.speedy]
inherits = "release"

View File

@@ -1,22 +1,11 @@
FROM rust:1.89 AS builder
RUN apt-get update
RUN apt-get install -y --no-install-recommends libasound2-dev libudev-dev
RUN rustup target add wasm32-unknown-unknown
RUN cargo install --locked wasm-bindgen-cli
# Copy in only the parts we care about. This is to prevent Docker from re-
# running some steps because unimportant files changed (e.g.: the .git/ folder)
COPY src/ ./src
COPY Cargo.toml ./Cargo.toml
# WARN: The lockfile doesn't exist in the repo. You will have to create it
# before building the Docker image (i.e.: run `cargo update` first)
COPY Cargo.lock ./Cargo.lock
COPY www/ ./www
COPY Makefile ./Makefile
COPY . .
# Oops. There's no text output in the Docker build command line (it still works, though)
RUN make
RUN make -j
FROM busybox:musl
RUN mkdir -p /var/www

View File

@@ -12,23 +12,26 @@ CARGO_PROFILE := tiny
SRC_DIR = ./src
SRCS := $(wildcard $(SRC_DIR)/**)
ASSET_SOURCE := $(wildcard assets/**)
ASSETS := $(patsubst assets/%.ogg, out/assets/%.ogg, $(ASSET_SOURCE))
.PHONY: clean full-clean tarball tarball-standalone web web-standalone
# "Standalone" version. It includes an index.html to serve as-is
web-standalone: out/asteroids.js out/asteroids_bg.wasm out/index.html
web-standalone: out/asteroids.js out/asteroids_bg.wasm.gz out/index.html $(ASSETS)
# "Bundle-able" version. It has a page, but no index.html. Consumers are
# expected to provide their own index.html and link to this page.
web: out/asteroids.js out/asteroids_bg.wasm out/asteroids.html
web: out/asteroids.js out/asteroids_bg.wasm.gz out/asteroids.html $(ASSETS)
tarball: asteroids_web_root.tar
tarball_standalone: asteroids_web_root_standalone.tar
asteroids_web_root.tar: out/asteroids.js out/asteroids_bg.wasm out/asteroids.html
asteroids_web_root.tar: out/asteroids.js out/asteroids_bg.wasm.gz out/asteroids.html $(ASSETS)
tar -caf $@ $^
asteroids_web_root_standalone.tar: out/asteroids.js out/asteroids_bg.wasm out/index.html
asteroids_web_root_standalone.tar: out/asteroids.js out/asteroids_bg.wasm.gz out/index.html $(ASSETS)
tar -caf $@ $^
target/$(CARGO_TARGET)/$(CARGO_PROFILE)/asteroids.wasm: $(SRCS) Cargo.lock Cargo.toml
@@ -37,15 +40,22 @@ target/$(CARGO_TARGET)/$(CARGO_PROFILE)/asteroids.wasm: $(SRCS) Cargo.lock Cargo
out:
mkdir $@
out/assets: | out
mkdir $@
out/assets/%.ogg: assets/%.ogg | out/assets
cp -ar assets/$*.ogg $@
# Both the JS and WASM files are generated by the wasm-bindgen call, so both
# get to be on the target half of this recipe.
out/asteroids.js out/asteroids_bg.wasm &: target/$(CARGO_TARGET)/$(CARGO_PROFILE)/asteroids.wasm | out
out/asteroids.js out/asteroids_bg.wasm.gz &: target/$(CARGO_TARGET)/$(CARGO_PROFILE)/asteroids.wasm | out
wasm-bindgen --no-typescript --target web --out-dir ./out/ --out-name asteroids target/$(CARGO_TARGET)/$(CARGO_PROFILE)/asteroids.wasm
gzip -9 -f out/asteroids_bg.wasm
# Copies the index page to the output dir.
out/index.html: www/index.html
cp -a $< $@
rm -f out/boids.html
rm -f out/asteroids.html
# Like `out/index.html`, but renames the page for use in a larger site.
out/asteroids.html: www/index.html
@@ -61,3 +71,13 @@ clean:
# this, I guess.
full-clean: clean
cargo clean
# Installation goal. It's meant to be a helper utility for moving the built
# output into the web root. Only supports the "bundle-able" mode.
install: web
install -dm0755 $(DESTDIR)
install -dm0755 $(DESTDIR)/assets
install -m0644 out/asteroids.js $(DESTDIR)/
install -m0644 out/asteroids_bg.wasm.gz $(DESTDIR)/
install -m0644 out/asteroids.html $(DESTDIR)/
install -m0644 $(ASSETS) $(DESTDIR)/assets/

14
README.md Normal file
View File

@@ -0,0 +1,14 @@
# Asteroids
*Another* Asteroids game I'm making. This time in Rust with the Bevy game engine.
## License
| File(s) | License |
|-|-|
| * | AGPLv3 |
| assets/* | CC0 |
(the most-specific match is the applicable license)
The sound files are from KenneyNL's "Sci-Fi Sounds (1.0)" pack. Find their work at [www.kenney.nl].

Binary file not shown.

Binary file not shown.

BIN
assets/laserSmall_001.ogg Normal file

Binary file not shown.

BIN
assets/thrusterFire_004.ogg Normal file

Binary file not shown.

View File

@@ -3,25 +3,27 @@
use bevy::color::Color;
pub const WINDOW_SIZE: bevy::prelude::Vec2 = bevy::prelude::Vec2::new(800.0, 600.0);
pub const WINDOW_SIZE: (u32, u32) = (800, 600);
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.1, 0.1, 0.1);
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_INACTIVE: Color = Color::srgb(0.5, 0.5, 0.5);
pub(crate) const SHIP_FIRE_RATE: f32 = 3.0; // in bullets-per-second
pub(crate) const ASTEROID_SMALL_COLOR: Color = Color::srgb(1.0, 0., 0.);
pub(crate) const BULLET_COLOR: Color = Color::srgb(0.0, 0.1, 0.9);
pub(crate) const ASTEROID_SMALL_COLOR: Color = Color::srgb(0.9, 0.9, 0.9);
pub(crate) const ASTEROID_MEDIUM_COLOR: Color = Color::srgb(0.8, 0.8, 0.8);
pub(crate) const ASTEROID_LARGE_COLOR: Color = Color::srgb(0.6, 0.6, 0.6);
pub(crate) const BULLET_COLOR: Color = Color::srgb(0.9, 0.9, 0.9);
// TODO: asteroid medium & large
pub(crate) const SHIP_THRUST: f32 = 1.0;
pub(crate) const SHIP_THRUST: f32 = 4.0;
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_SPEED: f32 = 500.0;
pub(crate) const BULLET_LIFETIME: f32 = 2.0;
pub(crate) const ASTEROID_LIFETIME: f32 = 40.0;

View File

@@ -3,17 +3,17 @@
//! Compile-time configurables can be found in the [`config`] module.
pub mod config;
mod events;
mod machinery;
mod messages;
mod objects;
mod physics;
mod resources;
mod widgets;
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,
ASTEROID_LARGE_COLOR, ASTEROID_MEDIUM_COLOR, 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, Weapon};
@@ -65,6 +65,7 @@ impl Plugin for AsteroidPlugin {
objects::ship_impact_listener,
physics::collision_listener,
machinery::tick_lifetimes,
machinery::update_scoreboard,
)
.run_if(in_state(GameState::Playing)),
)
@@ -76,10 +77,10 @@ impl Plugin for AsteroidPlugin {
)
.run_if(in_state(GameState::Playing)),
)
.add_event::<events::SpawnAsteroid>()
.add_event::<events::AsteroidDestroy>()
.add_event::<events::ShipDestroy>()
.add_event::<events::BulletDestroy>();
.add_message::<messages::SpawnAsteroid>()
.add_message::<messages::AsteroidDestroy>()
.add_message::<messages::ShipDestroy>()
.add_message::<messages::BulletDestroy>();
app.insert_state(GameState::TitleScreen);
}
}
@@ -110,14 +111,28 @@ fn spawn_camera(mut commands: Commands) {
/// Checks if "W" is pressed and increases velocity accordingly.
fn input_ship_thruster(
keyboard_input: Res<ButtonInput<KeyCode>>,
mut query: Query<(&mut physics::Velocity, &Transform, &mut Children), With<Ship>>,
mut query: Query<
(
&mut physics::Velocity,
&Transform,
Option<&mut AudioSink>,
&mut Children,
),
With<Ship>,
>,
mut commands: Commands,
game_assets: Res<GameAssets>,
) {
// 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 {
//
// The AudioSink component doesn't exist for just one frame, forcing it to
// be an optional system parameter. I'm not sure if I want to guard it with
// a check like it does now, or finally switch to using a Single<...> query
// parameter. I would lose ship control if the sound sink didn't spawn, but
// that should be fine -- any time that fails, more has likely also failed.
let Ok((mut velocity, transform, audio, children)) = query.single_mut() else {
let count = query.iter().count();
panic!("There should be exactly one player ship! Instead, there seems to be {count}.");
};
@@ -131,10 +146,16 @@ fn input_ship_thruster(
commands
.entity(*thrusters)
.insert(MeshMaterial2d(game_assets.thruster_mat_active()));
if let Some(audio) = audio {
audio.play();
}
} else {
commands
.entity(*thrusters)
.insert(MeshMaterial2d(game_assets.thruster_mat_inactive()));
if let Some(audio) = audio {
audio.pause();
}
}
}
@@ -181,7 +202,7 @@ fn input_ship_shoot(
// If the weapon is ready and the player presses the trigger,
// spawn a bullet & reset the timer.
if weapon.finished() && keyboard_input.pressed(KeyCode::Space) {
if weapon.is_finished() && keyboard_input.pressed(KeyCode::Space) {
weapon.reset();
// Derive bullet velocity, add to the ship's velocity
let bullet_vel = (ship_pos.rotation * Vec3::X).xy() * BULLET_SPEED;
@@ -198,6 +219,8 @@ fn input_ship_shoot(
MeshMaterial2d(game_assets.bullet().1),
ship_pos.clone(), // clone ship transform
Lifetime(Timer::from_seconds(BULLET_LIFETIME, TimerMode::Once)),
AudioPlayer::new(game_assets.laser_sound()),
PlaybackSettings::ONCE, // `Lifetime` already despawns the entity, so this doesn't need to
));
}
}

View File

@@ -8,7 +8,12 @@ use std::time::Duration;
use bevy::prelude::*;
use crate::{WorldSize, events::SpawnAsteroid, objects::AsteroidSize};
use crate::{
WorldSize,
messages::{AsteroidDestroy, SpawnAsteroid},
objects::AsteroidSize,
resources::Score,
};
/// Asteroid spawning parameters and state.
///
@@ -38,7 +43,7 @@ impl AsteroidSpawner {
/// 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 events: MessageWriter<SpawnAsteroid>,
mut spawner: ResMut<AsteroidSpawner>,
time: Res<Time>,
play_area: Res<WorldSize>,
@@ -135,3 +140,15 @@ pub fn operate_sparklers(sparklers: Query<(&mut Visibility, &mut Sparkler)>, tim
}
}
}
/// Event listener for adding score after an asteroid was destroyed
///
/// Refreshing the HUD element is done by [crate::widgets::operate_ui] (a private function)
pub fn update_scoreboard(
mut destroy_events: MessageReader<AsteroidDestroy>,
mut scoreboard: ResMut<Score>,
) {
for _event in destroy_events.read() {
scoreboard.0 += 100;
}
}

View File

@@ -8,7 +8,7 @@ fn main() {
.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
canvas: Some("#game-canvas".to_owned()),
resolution: WindowResolution::new(WINDOW_SIZE.x, WINDOW_SIZE.y),
resolution: WindowResolution::new(WINDOW_SIZE.0, WINDOW_SIZE.1),
..default()
}),
..default()

View File

@@ -6,15 +6,15 @@ use crate::objects::AsteroidSize;
///
/// 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)]
#[derive(Message)]
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)]
/// system, which re-emits [spawn messages](`SpawnAsteroid`).
#[derive(Message)]
pub(crate) struct AsteroidDestroy(pub Entity);
/// Signals that an asteroid needs to be spawned and provides the parameters
@@ -22,7 +22,7 @@ pub(crate) struct AsteroidDestroy(pub Entity);
///
/// 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)]
#[derive(Message)]
pub struct SpawnAsteroid {
pub pos: Vec2,
pub vel: Vec2,
@@ -32,5 +32,5 @@ pub struct SpawnAsteroid {
/// 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)]
#[derive(Message)]
pub(crate) struct BulletDestroy(pub Entity);

View File

@@ -3,18 +3,19 @@
//! Asteroids, the player's ship, and such.
use bevy::{
audio::{AudioPlayer, PlaybackSettings},
camera::visibility::Visibility,
ecs::{
bundle::Bundle,
component::Component,
entity::Entity,
event::{EventReader, EventWriter},
message::{MessageReader, MessageWriter},
query::With,
system::{Commands, Query, Res, ResMut, Single},
},
math::{Vec2, Vec3, Vec3Swizzles},
mesh::Mesh2d,
prelude::{Deref, DerefMut},
render::{mesh::Mesh2d, view::Visibility},
sprite::MeshMaterial2d,
sprite_render::MeshMaterial2d,
state::state::NextState,
time::{Timer, TimerMode},
transform::components::Transform,
@@ -24,8 +25,8 @@ use bevy_rapier2d::prelude::{ActiveCollisionTypes, ActiveEvents, Collider, Senso
use crate::{
AngularVelocity, GameAssets, GameState, Lives,
config::{ASTEROID_LIFETIME, DEBRIS_LIFETIME, SHIP_FIRE_RATE},
events::{AsteroidDestroy, BulletDestroy, ShipDestroy, SpawnAsteroid},
machinery::{Lifetime, Sparkler},
messages::{AsteroidDestroy, BulletDestroy, ShipDestroy, SpawnAsteroid},
physics::{Velocity, Wrapping},
};
@@ -70,7 +71,7 @@ pub struct Debris;
/// Responds to [`SpawnAsteroid`] events, spawning as specified
pub fn spawn_asteroid(
mut events: EventReader<SpawnAsteroid>,
mut events: MessageReader<SpawnAsteroid>,
mut commands: Commands,
game_assets: Res<GameAssets>,
) {
@@ -82,9 +83,9 @@ pub fn spawn_asteroid(
};
let collider_radius = match spawn.size {
AsteroidSize::Small => 10.0,
AsteroidSize::Medium => 20.0,
AsteroidSize::Large => 40.0,
AsteroidSize::Small => 5.0,
AsteroidSize::Medium => 10.0,
AsteroidSize::Large => 20.0,
};
commands.spawn((
@@ -110,10 +111,11 @@ pub fn spawn_asteroid(
/// 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 destroy_events: MessageReader<AsteroidDestroy>,
mut respawn_events: MessageWriter<SpawnAsteroid>,
mut commands: Commands,
query: Query<(&Transform, &Asteroid, &Velocity)>,
game_assets: Res<GameAssets>,
) {
for event in destroy_events.read() {
if let Ok((transform, rock, velocity)) = query.get(event.0) {
@@ -136,6 +138,12 @@ pub fn split_asteroids(
// Always despawn the asteroid. New ones (may) be spawned in it's
// place, but this one is gone.
commands.entity(event.0).despawn();
// Play a sound for the asteroid exploding
commands.spawn((
AudioPlayer::new(game_assets.asteroid_crack_sound()),
PlaybackSettings::DESPAWN,
));
}
}
}
@@ -160,6 +168,12 @@ pub fn spawn_player(mut commands: Commands, game_assets: Res<GameAssets>) {
Mesh2d(game_assets.ship().0),
MeshMaterial2d(game_assets.ship().1),
Transform::default().with_scale(Vec3::new(20.0, 20.0, 20.0)),
AudioPlayer::new(game_assets.ship_thruster_sound()),
PlaybackSettings {
mode: bevy::audio::PlaybackMode::Loop,
paused: true,
..Default::default()
},
))
.with_child((
Mesh2d(game_assets.thruster_mesh()),
@@ -172,7 +186,7 @@ pub fn spawn_player(mut commands: Commands, game_assets: Res<GameAssets>) {
/// Watch for [`BulletDestroy`] events and despawn
/// the associated bullet.
pub fn bullet_impact_listener(mut commands: Commands, mut events: EventReader<BulletDestroy>) {
pub fn bullet_impact_listener(mut commands: Commands, mut events: MessageReader<BulletDestroy>) {
for event in events.read() {
commands.entity(event.0).despawn();
}
@@ -188,7 +202,7 @@ pub fn bullet_impact_listener(mut commands: Commands, mut events: EventReader<Bu
/// - Clear all asteroids
/// - Respawn player
pub fn ship_impact_listener(
mut events: EventReader<ShipDestroy>,
mut events: MessageReader<ShipDestroy>,
mut commands: Commands,
mut lives: ResMut<Lives>,
rocks: Query<Entity, With<Asteroid>>,
@@ -232,5 +246,11 @@ pub fn ship_impact_listener(
// STEP 4: Respawn player (teleport them to the origin)
player.0.translation = Vec3::ZERO;
player.1.0 = Vec2::ZERO;
// STEP 5: Play crash sound
commands.spawn((
AudioPlayer::new(game_assets.wreck_sound()),
PlaybackSettings::DESPAWN, // despawn this entity when playback ends.
));
}
}

View File

@@ -16,7 +16,7 @@
//! to detect them for me (so that I don't have to write clipping code).
use crate::{
WorldSize, events,
WorldSize, messages,
objects::{Asteroid, Bullet, Ship},
};
@@ -89,10 +89,10 @@ pub(crate) fn wrap_entities(
/// | 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>,
mut collisions: MessageReader<CollisionEvent>,
mut ship_writer: MessageWriter<messages::ShipDestroy>,
mut asteroid_writer: MessageWriter<messages::AsteroidDestroy>,
mut bullet_writer: MessageWriter<messages::BulletDestroy>,
player: Single<Entity, With<Ship>>,
bullets: Query<&Bullet>,
rocks: Query<&Asteroid>,
@@ -112,12 +112,12 @@ pub fn collision_listener(
if rocks.contains(*two) {
// player-asteroid collision
dbg!("Writing ShipDestroy event");
ship_writer.write(events::ShipDestroy);
ship_writer.write(messages::ShipDestroy);
} // else, we don't care
} else if *two == *player {
if rocks.contains(*one) {
dbg!("Writing ShipDestroy event");
ship_writer.write(events::ShipDestroy);
ship_writer.write(messages::ShipDestroy);
}
}
@@ -125,14 +125,14 @@ pub fn collision_listener(
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));
asteroid_writer.write(messages::AsteroidDestroy(*two));
bullet_writer.write(messages::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));
asteroid_writer.write(messages::AsteroidDestroy(*one));
bullet_writer.write(messages::BulletDestroy(*two));
}
}
}

View File

@@ -1,25 +1,27 @@
//! All the resources for the game
use bevy::{
asset::{Assets, Handle},
asset::{AssetServer, Assets, Handle},
audio::AudioSource,
ecs::{
resource::Resource,
world::{FromWorld, World},
},
math::{
Vec2,
primitives::{Circle, Triangle2d},
primitives::{Polyline2d, Segment2d, Triangle2d},
},
mesh::Mesh,
prelude::{Deref, DerefMut, Reflect, ReflectResource},
render::mesh::Mesh,
sprite::ColorMaterial,
sprite_render::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,
ASTEROID_LARGE_COLOR, ASTEROID_MEDIUM_COLOR, ASTEROID_SMALL_COLOR, BULLET_COLOR,
PLAYER_SHIP_COLOR, SHIP_THRUSTER_COLOR_ACTIVE, SHIP_THRUSTER_COLOR_INACTIVE,
config::WINDOW_SIZE,
};
#[derive(InspectorOptions, Reflect, Resource, Debug, Deref, Clone, Copy)]
@@ -42,12 +44,14 @@ impl From<Lives> for String {
}
}
// TODO: consider switching this to use a u32 pair like the Window settings
// thing now does.
#[derive(Deref, DerefMut, Resource)]
pub struct WorldSize(Vec2);
impl Default for WorldSize {
fn default() -> Self {
WorldSize(Vec2::new(WINDOW_SIZE.x, WINDOW_SIZE.y))
WorldSize(Vec2::new(WINDOW_SIZE.0 as f32, WINDOW_SIZE.1 as f32))
}
}
@@ -55,6 +59,7 @@ impl Default for WorldSize {
pub struct GameAssets {
meshes: [Handle<Mesh>; 5],
materials: [Handle<ColorMaterial>; 7],
sounds: [Handle<AudioSource>; 4],
}
impl GameAssets {
@@ -79,20 +84,36 @@ impl GameAssets {
}
pub fn asteroid_small(&self) -> (Handle<Mesh>, Handle<ColorMaterial>) {
(self.meshes[1].clone(), self.materials[1].clone())
(self.meshes[1].clone(), self.materials[3].clone())
}
pub fn asteroid_medium(&self) -> (Handle<Mesh>, Handle<ColorMaterial>) {
(self.meshes[2].clone(), self.materials[2].clone())
(self.meshes[2].clone(), self.materials[4].clone())
}
pub fn asteroid_large(&self) -> (Handle<Mesh>, Handle<ColorMaterial>) {
(self.meshes[3].clone(), self.materials[3].clone())
(self.meshes[3].clone(), self.materials[5].clone())
}
pub fn bullet(&self) -> (Handle<Mesh>, Handle<ColorMaterial>) {
(self.meshes[4].clone(), self.materials[6].clone())
}
pub fn wreck_sound(&self) -> Handle<AudioSource> {
self.sounds[0].clone()
}
pub fn laser_sound(&self) -> Handle<AudioSource> {
self.sounds[1].clone()
}
pub fn asteroid_crack_sound(&self) -> Handle<AudioSource> {
self.sounds[2].clone()
}
pub fn ship_thruster_sound(&self) -> Handle<AudioSource> {
self.sounds[3].clone()
}
}
impl FromWorld for GameAssets {
@@ -104,10 +125,63 @@ impl FromWorld for GameAssets {
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)),
world_meshes.add(Polyline2d::new(
[
Vec2::new(0.1, 0.0),
Vec2::new(0.8, 0.2),
Vec2::new(0.8, 0.3),
Vec2::new(0.1, 1.0),
Vec2::new(-0.5, 1.0),
Vec2::new(-0.3, 0.3),
Vec2::new(-1.0, 0.3),
Vec2::new(-1.0, -0.2),
Vec2::new(-0.5, -1.0),
Vec2::new(0.1, -0.8),
Vec2::new(0.5, -0.9),
Vec2::new(1.0, -0.4),
Vec2::new(0.1, 0.0),
]
.into_iter()
.map(|vert| vert * 5.0),
)),
world_meshes.add(Polyline2d::new(
[
Vec2::new(0.6, 0.3),
Vec2::new(1.0, 0.6),
Vec2::new(0.6, 1.0),
Vec2::new(0.1, 0.8),
Vec2::new(-0.4, 1.0),
Vec2::new(-1.0, 0.6),
Vec2::new(-0.8, -0.1),
Vec2::new(-1.0, -0.5),
Vec2::new(-0.4, -1.0),
Vec2::new(-0.3, -0.7),
Vec2::new(0.6, -1.0),
Vec2::new(1.0, -0.3),
Vec2::new(0.6, 0.3),
]
.into_iter()
.map(|vert| vert * 10.0),
)),
world_meshes.add(Polyline2d::new(
[
Vec2::new(1.0, -0.1),
Vec2::new(1.0, 0.3),
Vec2::new(0.4, 1.0),
Vec2::new(-0.2, 1.0),
Vec2::new(-0.9, 0.3),
Vec2::new(-0.5, 0.1),
Vec2::new(-0.9, -0.1),
Vec2::new(-0.5, -1.0),
Vec2::new(0.0, 0.0),
Vec2::new(0.0, -1.0),
Vec2::new(0.5, -1.0),
Vec2::new(1.0, -0.1),
]
.into_iter()
.map(|vert| vert * 20.0),
)),
world_meshes.add(Segment2d::new(Vec2::new(-0.1, 0.0), Vec2::new(0.1, 0.0))),
];
let mut world_materials = world.resource_mut::<Assets<ColorMaterial>>();
let materials = [
@@ -116,10 +190,21 @@ impl FromWorld for GameAssets {
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(ASTEROID_MEDIUM_COLOR),
world_materials.add(ASTEROID_LARGE_COLOR),
world_materials.add(BULLET_COLOR),
];
GameAssets { meshes, materials }
let loader = world.resource_mut::<AssetServer>();
let sounds = [
loader.load("explosionCrunch_004.ogg"),
loader.load("laserSmall_001.ogg"),
loader.load("explosionCrunch_000.ogg"),
loader.load("thrusterFire_004.ogg"),
];
GameAssets {
meshes,
materials,
sounds,
}
}
}

View File

@@ -124,7 +124,7 @@ fn button_bundle(text: &str) -> impl Bundle {
margin: UiRect::all(Val::Px(5.0)),
..default()
},
BorderColor(Color::BLACK),
BorderColor::all(Color::BLACK),
BorderRadius::MAX,
BackgroundColor(UI_BUTTON_NORMAL),
children![(
@@ -152,7 +152,7 @@ fn spawn_menu(mut commands: Commands) {
cmds.spawn((
Text::new("Robert's Bad Asteroids Game"),
TextFont::from_font_size(50.0),
TextLayout::new_with_justify(JustifyText::Center),
TextLayout::new_with_justify(Justify::Center),
TextShadow::default(),
));
cmds.spawn((
@@ -180,9 +180,9 @@ fn spawn_get_ready(mut commands: Commands, mut timer: ResMut<ReadySetGoTimer>) {
height: Val::Percent(30.),
..default()
},
BackgroundColor(LIGHT_BLUE.into()),
BackgroundColor(Color::NONE),
children![
(Text::new("Get Ready!"), TextColor(BLACK.into())),
(Text::new("Get Ready!"), TextColor(WHITE.into())),
(
CountdownBar,
Node {
@@ -247,7 +247,7 @@ fn animate_get_ready_widget(
bar_segment.width = Val::Percent(100.0 * (1.0 - timer.fraction()));
// If the timer has expired, change state to playing.
if timer.finished() {
if timer.is_finished() {
game_state.set(GameState::Playing);
}
}
@@ -274,14 +274,14 @@ fn operate_buttons(
(Changed<Interaction>, With<Button>),
>,
mut game_state: ResMut<NextState<GameState>>,
mut app_exit_events: EventWriter<AppExit>,
mut app_exit_events: MessageWriter<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();
border_color.set_all(DARK_GRAY);
match menu_action {
ButtonMenuAction::ToMainMenu => {
game_state.set(GameState::TitleScreen);
@@ -296,11 +296,11 @@ fn operate_buttons(
}
Interaction::Hovered => {
*color = UI_BUTTON_HOVERED.into();
border_color.0 = WHITE.into();
border_color.set_all(WHITE);
}
Interaction::None => {
*color = UI_BUTTON_NORMAL.into();
border_color.0 = BLACK.into();
border_color.set_all(BLACK);
}
}
}

View File

@@ -2,15 +2,46 @@
<html lang="en">
<head>
<style>
h1 {
text-align: center;
body {
background-color: hsl(200, 3%, 65%);
}
h1 {
color: hsl(200, 3%, 90%);
background-color: hsl(195, 5%, 17%);
text-align: center;
margin: auto;
padding: 0.5em;
}
#prestart-controls,
canvas {
padding-left: 0;
padding-right: 0;
margin-top: 1em;
margin-left: auto;
margin-right: auto;
display: block;
outline-color: hsl(200, 7%, 50%);
outline-style: outset;
border-radius: 8px;
background-color: rgb(40%, 40%, 40%);
}
#prestart-controls {
width: 800px;
height: 600px;
text-align: center;
align-content: center;
}
button {
font-size: 20px;
text-shadow: 0.2em 0.2em 0px rgba(0, 0, 0, 75%);
padding: 1em;
border-radius: 2em;
border-style: solid;
border-color: black;
color: rgb(90%, 90%, 90%);
background-color: rgb(15%, 15%, 15%);
}
button:hover {
background-color: rgb(25%, 25%, 25%);
border-color: rgb(90%, 90%, 90%);
}
main {
margin-left: auto;
@@ -18,11 +49,12 @@
width: 70%;
}
table {
margin-bottom: 10px;
border-collapse: collapse;
}
th, td {
border: 1px solid;
padding: 2px 4px;
padding: 0.1em 0.3em;
}
</style>
</head>
@@ -30,13 +62,21 @@
<h1>
Robert's Bad Asteroids Game
</h1>
<canvas id="game-canvas" width="1280" height="720"></canvas>
<!-- <canvas id="game-canvas" width="800" height="600"></canvas> -->
<div id="prestart-controls">
<button id="gameload-button">Load Game</button>
</div>
<main>
<article>
<h2>Description</h2>
<p>
A (work in progress) version of the Asteroids arcade game.
</p>
<p>
<em>Sound Warning!</em> The game now has sound effects, but there are no controls on the page for changing the
volume (including to mute them). You can mute the browser tab by pressing <code>ctrl</code> + <code>m</code>.
Proper volume controls are coming soon.
</p>
</article>
<article>
<h3>Controls</h3>
@@ -79,19 +119,34 @@
<tr>
<td>Program Version</td>
<!-- This version text is completely unchecked. I'll need to do something about that. -->
<td><code>v0.3.0</code></td>
<td><code>v0.6.1</code></td>
</tr>
</table>
</article>
</main>
<script type="module">
import init from './asteroids.js'
let button = document.getElementById("gameload-button");
button.onclick = async function loadGame() {
console.log("Game Load button was pressed!");
let canvas = document.createElement("canvas");
// <canvas id="game-canvas" width="800" height="600"></canvas>
canvas.setAttribute("id", "game-canvas");
canvas.setAttribute("width", "800");
canvas.setAttribute("height", "600");
button.parentElement.replaceWith(canvas);
init().catch((error) => {
if (!error.message.startsWith("Using exceptions for control flow, don't mind me. This isn't actually an error!")) {
throw error;
}
});
let compressed = await fetch("./asteroids_bg.wasm.gz")
let wasm_stream = compressed.body.pipeThrough(new DecompressionStream("gzip"))
let blob = await new Response(wasm_stream).blob();
init(await blob.arrayBuffer()).catch((error) => {
if (!error.message.startsWith("Using exceptions for control flow, don't mind me. This isn't actually an error!")) {
throw error;
}
});
}
</script>
</body>