Asteroid Dodger: Sound and Particle Effects
This tutorial picks up where the collisions and physics tutorial left off. You have shooting, elastic bounce physics, and asteroid splitting. The game plays well, but it feels flat without audio or visual feedback. By the end of this tutorial, lasers will chirp, explosions will rumble, and particle bursts will mark every impact — all generated procedurally with no external asset files.
What you’ll add
- Procedural WAV generation for four sound effects (shoot, explode, damage, thrust)
- A
Soundsstruct with async loading and volume control - A particle system with position, velocity, fade, and drag
- Particle bursts on asteroid destruction and player damage
Milestones overview
Prerequisites
- Completed the physics tutorial
- Project compiles and runs with shooting, elastic physics, and asteroid splitting
Milestone 1 — Sound effects
Milestone 1 of 2Step 1 — Enable audio and create src/sound.rs
macroquad’s audio support is behind a feature flag — it’s not included by default. Enable it:
cargo add macroquad --features audio
This updates Cargo.toml to macroquad = { version = "0.4", features = ["audio"] }. Without this, macroquad::audio won’t exist and the next step won’t compile.
Register the new module in main.rs:
mod sound;
The plan: generate WAV audio data entirely in memory. No .wav files on disk, no asset folder. A WAV file is a 44-byte header followed by raw PCM samples — one of the simplest audio formats.
A WAV file starts with a 44-byte RIFF/WAVE header that describes the sample rate,
bit depth, and data length. After the header, raw 16-bit signed integers represent
the waveform. Because the format is so simple, you can build a valid WAV file with
nothing but byte slicing and to_le_bytes().
Create src/sound.rs with three helper functions and a Sounds struct:
// src/sound.rs
use macroquad::audio::{Sound, load_sound_from_bytes, play_sound, PlaySoundParams};
const SAMPLE_RATE: u32 = 44100;
fn generate_wav(samples: &[i16]) -> Vec<u8> {
let data_len = (samples.len() * 2) as u32;
let file_len = 36 + data_len;
let mut buf = Vec::with_capacity(file_len as usize + 8);
// RIFF header
buf.extend_from_slice(b"RIFF");
buf.extend_from_slice(&file_len.to_le_bytes());
buf.extend_from_slice(b"WAVE");
// fmt subchunk: PCM, mono, 44100 Hz, 16-bit
buf.extend_from_slice(b"fmt ");
buf.extend_from_slice(&16u32.to_le_bytes()); // subchunk size
buf.extend_from_slice(&1u16.to_le_bytes()); // PCM format
buf.extend_from_slice(&1u16.to_le_bytes()); // mono
buf.extend_from_slice(&SAMPLE_RATE.to_le_bytes());
buf.extend_from_slice(&(SAMPLE_RATE * 2).to_le_bytes()); // byte rate
buf.extend_from_slice(&2u16.to_le_bytes()); // block align
buf.extend_from_slice(&16u16.to_le_bytes()); // bits per sample
// data subchunk
buf.extend_from_slice(b"data");
buf.extend_from_slice(&data_len.to_le_bytes());
for &s in samples {
buf.extend_from_slice(&s.to_le_bytes());
}
buf
}
fn sine_samples(freq: f32, duration: f32, volume: f32) -> Vec<i16> {
let n = (SAMPLE_RATE as f32 * duration) as usize;
(0..n)
.map(|i| {
let t = i as f32 / SAMPLE_RATE as f32;
let fade = 1.0 - (t / duration);
let sample = (t * freq * std::f32::consts::TAU).sin() * volume * fade;
(sample * i16::MAX as f32) as i16
})
.collect()
}
fn noise_samples(duration: f32, volume: f32) -> Vec<i16> {
let n = (SAMPLE_RATE as f32 * duration) as usize;
(0..n)
.map(|i| {
let t = i as f32 / SAMPLE_RATE as f32;
let fade = 1.0 - (t / duration);
let noise = macroquad::rand::gen_range(-1.0f32, 1.0);
(noise * volume * fade * i16::MAX as f32) as i16
})
.collect()
}
pub struct Sounds {
pub shoot: Sound,
pub explode: Sound,
pub damage: Sound,
pub thrust: Sound,
pub volume: f32,
}
impl Sounds {
pub async fn load() -> Self {
let shoot_wav = generate_wav(&sine_samples(880.0, 0.08, 0.3));
let explode_wav = generate_wav(&noise_samples(0.2, 0.4));
let damage_wav = generate_wav(&sine_samples(220.0, 0.25, 0.5));
let thrust_wav = generate_wav(&noise_samples(0.1, 0.15));
Self {
shoot: load_sound_from_bytes(&shoot_wav).await.unwrap(),
explode: load_sound_from_bytes(&explode_wav).await.unwrap(),
damage: load_sound_from_bytes(&damage_wav).await.unwrap(),
thrust: load_sound_from_bytes(&thrust_wav).await.unwrap(),
volume: 0.5,
}
}
pub fn play(&self, sound: &Sound) {
play_sound(sound, PlaySoundParams { looped: false, volume: self.volume });
}
}
Each sound uses a different generator:
- Shoot — 880 Hz sine wave, 0.08 seconds. A short high-pitched chirp.
- Explode — white noise, 0.2 seconds. Random samples create a burst.
- Damage — 220 Hz sine wave, 0.25 seconds. A low thud for taking a hit.
- Thrust — white noise, 0.1 seconds. A quick hiss each frame.
Both generators apply a linear fade-out (1.0 - t/duration) so sounds don’t clip at the end.
load_sound_from_bytes is async because macroquad’s audio backend
initialises lazily. The .await lets the runtime finish setup before
returning the Sound handle. This is why Sounds::load()
is pub async fn — it must be called from an async context.
Note that play constructs PlaySoundParams with explicit fields rather than using ..Default::default(). In macroquad 0.4, PlaySoundParams does not implement Default, so the spread syntax won’t compile.
Step 2 — Wire sounds into the game
src/main.rs — load sounds before creating the game, and pass them in:
use macroquad::prelude::*;
mod collision;
mod player;
mod asteroid;
mod bullet;
mod game;
mod sound;
use game::Game;
use sound::Sounds;
#[macroquad::main("Asteroid Dodger")]
async fn main() {
let sounds = Sounds::load().await;
let mut game = Game::new(sounds);
loop {
game.update(get_frame_time());
game.draw();
next_frame().await;
}
}
src/game.rs — add the Sounds import and field:
use crate::sound::Sounds;
Add sounds: Sounds to the Game struct and update new to accept it:
pub struct Game {
player: Player,
asteroids: Vec<Asteroid>,
bullets: Vec<Bullet>,
score: f32,
spawn_timer: f32,
spawn_interval: f32,
shoot_cooldown: f32,
state: GameState,
sounds: Sounds,
}
impl Game {
pub fn new(sounds: Sounds) -> Self {
Self {
player: Player::new(),
asteroids: Vec::new(),
bullets: Vec::new(),
score: 0.0,
spawn_timer: 0.0,
spawn_interval: 3.0,
shoot_cooldown: 0.0,
state: GameState::Playing,
sounds,
}
}
The previous tutorial used *self = Game::new() to restart, but Game::new now requires a Sounds argument. Rather than passing sounds through the restart path, add a reset method that clears gameplay state while preserving the sounds field:
pub fn reset(&mut self) {
self.player = Player::new();
self.asteroids.clear();
self.bullets.clear();
self.score = 0.0;
self.spawn_timer = 0.0;
self.spawn_interval = 3.0;
self.shoot_cooldown = 0.0;
self.state = GameState::Playing;
}
Update the game-over guard to use reset:
if self.state == GameState::GameOver {
if is_key_pressed(KeyCode::R) { self.reset(); }
return;
}
Now play sounds at the right moments. In update, after the game-over guard:
// Thrust sound
if is_key_down(KeyCode::W) {
self.sounds.play(&self.sounds.thrust.clone());
}
// Shooting
self.shoot_cooldown -= dt;
if is_key_down(KeyCode::Space) && self.shoot_cooldown <= 0.0 {
self.bullets.push(self.player.spawn_bullet());
self.shoot_cooldown = 0.15;
self.sounds.play(&self.sounds.shoot.clone());
}
In update_bullets, after removing hit asteroids, play the explosion sound:
// At the end of update_bullets, after swap_remove loop:
if !asteroids_hit.is_empty() {
self.sounds.play(&self.sounds.explode.clone());
}
In update_collisions, when damage is taken:
if took_damage {
self.player.hp -= 20.0;
self.player.invincibility = 1.5;
self.sounds.play(&self.sounds.damage.clone());
if self.player.hp <= 0.0 {
self.state = GameState::GameOver;
}
}
Sound in macroquad is a lightweight handle — internally it’s a
reference-counted pointer to the audio backend’s buffer. Cloning it doesn’t
duplicate the audio data; it copies a small handle. We clone here because
self.sounds is borrowed through &self, and
play takes &self too — cloning the handle into a
temporary avoids conflicting borrows on self.sounds.
cargo run — hold W to hear thrust hiss, press
Space for laser chirps, shoot an asteroid for an explosion burst,
and fly into one for a damage thud.
Milestone 2 — Particle effects
Milestone 2 of 2Step 3 — Create src/particle.rs
Register the module in main.rs:
mod particle;
The particle system is a simple pool: each particle has a position, velocity, lifetime, size, and color. Every frame, particles move, slow down via drag, shrink, and fade. Dead particles are removed.
// src/particle.rs
use macroquad::prelude::*;
pub struct Particle {
pos: Vec2,
vel: Vec2,
lifetime: f32,
max_lifetime: f32,
radius: f32,
color: Color,
}
impl Particle {
pub fn new(pos: Vec2, vel: Vec2, lifetime: f32, radius: f32, color: Color) -> Self {
Self { pos, vel, lifetime, max_lifetime: lifetime, radius, color }
}
pub fn is_alive(&self) -> bool {
self.lifetime > 0.0
}
}
pub struct Particles {
pool: Vec<Particle>,
}
impl Particles {
pub fn new() -> Self {
Self { pool: Vec::new() }
}
pub fn update(&mut self, dt: f32) {
for p in &mut self.pool {
p.pos += p.vel * dt;
p.vel *= 1.0 - 3.0 * dt;
p.lifetime -= dt;
}
self.pool.retain(|p| p.is_alive());
}
pub fn draw(&self) {
for p in &self.pool {
let t = p.lifetime / p.max_lifetime;
let r = p.radius * t;
let mut c = p.color;
c.a = t;
draw_circle(p.pos.x, p.pos.y, r, c);
}
}
pub fn burst(&mut self, pos: Vec2, count: usize, speed: f32, color: Color) {
for _ in 0..count {
let angle = rand::gen_range(0.0f32, std::f32::consts::TAU);
let spd = rand::gen_range(speed * 0.3, speed);
let vel = Vec2::from_angle(angle) * spd;
let lifetime = rand::gen_range(0.3f32, 0.8);
let radius = rand::gen_range(1.5f32, 4.0);
self.pool.push(Particle::new(pos, vel, lifetime, radius, color));
}
}
}
The key design choices:
- Drag —
vel *= 1.0 - 3.0 * dtslows particles exponentially. At 60 FPS,3.0 * dtis about 0.05, so each frame reduces speed by 5%. Particles decelerate smoothly rather than stopping abruptly. - Radius shrinks —
radius * twheretgoes from 1.0 to 0.0 over the lifetime. Particles visually dissolve. - Alpha fades —
c.a = tmakes particles transparent as they age, combining with the shrinking radius for a natural fade-out. - Randomised burst —
burst()spawnscountparticles at random angles with varied speeds and lifetimes. This prevents the burst from looking uniform.
Step 4 — Emit particles from game.rs
Add the import and field:
use crate::particle::Particles;
pub struct Game {
// ... existing fields ...
particles: Particles,
}
// In new():
particles: Particles::new(),
Update reset to clear the particle pool:
pub fn reset(&mut self) {
// ... existing resets ...
self.particles = Particles::new();
}
Call particles.update(dt) in the update method, after update_bullets:
self.update_bullets();
self.particles.update(dt);
Emit particles on asteroid destruction. In update_bullets, capture hit positions before removing asteroids. The key change is collecting (pos, radius) pairs from hit asteroids, then iterating them by reference after removal:
fn update_bullets(&mut self) {
for bullet in &mut self.bullets {
bullet.update(get_frame_time());
}
self.bullets.retain(|b| !b.is_off_screen());
let mut asteroids_hit: Vec<usize> = Vec::new();
self.bullets.retain(|bullet| {
for (i, asteroid) in self.asteroids.iter().enumerate() {
if collision::point_in_polygon(bullet.pos, asteroid.pos, &asteroid.vertices) {
if !asteroids_hit.contains(&i) {
asteroids_hit.push(i);
}
return false;
}
}
true
});
// Capture positions and spawn fragments before removal
let mut fragments: Vec<Asteroid> = Vec::new();
let mut hit_positions: Vec<(Vec2, f32)> = Vec::new();
for &i in &asteroids_hit {
let asteroid = &self.asteroids[i];
hit_positions.push((asteroid.pos, asteroid.radius));
if asteroid.generation == 0 {
let spread = Vec2::from_angle(rand::gen_range(0.0f32, std::f32::consts::TAU));
fragments.push(Asteroid::spawn_fragment(
asteroid.pos, asteroid.vel, asteroid.radius, spread,
));
fragments.push(Asteroid::spawn_fragment(
asteroid.pos, asteroid.vel, asteroid.radius, -spread,
));
}
}
asteroids_hit.sort_unstable();
for i in asteroids_hit.into_iter().rev() {
self.asteroids.swap_remove(i);
}
self.asteroids.extend(fragments);
// Burst particles at each hit position
for &(pos, radius) in &hit_positions {
let count = (radius * 0.8) as usize;
self.particles.burst(pos, count, radius * 2.0, LIGHTGRAY);
}
if !hit_positions.is_empty() {
self.sounds.play(&self.sounds.explode.clone());
}
}
We need asteroid positions to spawn particles, but swap_remove destroys
the asteroids. Collecting (pos, radius) into a separate Vec
preserves the data past removal. Note the iteration uses
for &(pos, radius) in &hit_positions — destructuring by reference. Using
for (pos, radius) in hit_positions would move the Vec,
consuming it before the .is_empty() check on the next line.
Emit particles on player damage. In update_collisions, burst orange particles when the player takes a hit:
if took_damage {
self.player.hp -= 20.0;
self.player.invincibility = 1.5;
self.particles.burst(self.player.pos, 12, 80.0, ORANGE);
self.sounds.play(&self.sounds.damage.clone());
if self.player.hp <= 0.0 {
self.state = GameState::GameOver;
}
}
Draw particles. In Game::draw, render particles after clear_background but before asteroids so they appear behind solid objects:
pub fn draw(&self) {
clear_background(BLACK);
self.particles.draw();
for asteroid in &self.asteroids {
asteroid.draw();
}
// ... rest of draw unchanged
}
cargo run — shoot an asteroid and gray particles scatter from the
impact point. Larger asteroids produce more particles. Fly into an asteroid and
orange sparks burst from the ship.
Complete listing
The project now has eight source files:
src/
├── main.rs
├── collision.rs
├── player.rs
├── asteroid.rs
├── bullet.rs
├── game.rs
├── sound.rs (new)
└── particle.rs (new)
collision.rs, player.rs, asteroid.rs, and bullet.rs are unchanged from the collisions and physics tutorial.
src/main.rs
use macroquad::prelude::*;
mod collision;
mod player;
mod asteroid;
mod bullet;
mod game;
mod sound;
mod particle;
use game::Game;
use sound::Sounds;
#[macroquad::main("Asteroid Dodger")]
async fn main() {
let sounds = Sounds::load().await;
let mut game = Game::new(sounds);
loop {
game.update(get_frame_time());
game.draw();
next_frame().await;
}
}
src/sound.rs
use macroquad::audio::{Sound, load_sound_from_bytes, play_sound, PlaySoundParams};
const SAMPLE_RATE: u32 = 44100;
fn generate_wav(samples: &[i16]) -> Vec<u8> {
let data_len = (samples.len() * 2) as u32;
let file_len = 36 + data_len;
let mut buf = Vec::with_capacity(file_len as usize + 8);
buf.extend_from_slice(b"RIFF");
buf.extend_from_slice(&file_len.to_le_bytes());
buf.extend_from_slice(b"WAVE");
buf.extend_from_slice(b"fmt ");
buf.extend_from_slice(&16u32.to_le_bytes());
buf.extend_from_slice(&1u16.to_le_bytes());
buf.extend_from_slice(&1u16.to_le_bytes());
buf.extend_from_slice(&SAMPLE_RATE.to_le_bytes());
buf.extend_from_slice(&(SAMPLE_RATE * 2).to_le_bytes());
buf.extend_from_slice(&2u16.to_le_bytes());
buf.extend_from_slice(&16u16.to_le_bytes());
buf.extend_from_slice(b"data");
buf.extend_from_slice(&data_len.to_le_bytes());
for &s in samples {
buf.extend_from_slice(&s.to_le_bytes());
}
buf
}
fn sine_samples(freq: f32, duration: f32, volume: f32) -> Vec<i16> {
let n = (SAMPLE_RATE as f32 * duration) as usize;
(0..n)
.map(|i| {
let t = i as f32 / SAMPLE_RATE as f32;
let fade = 1.0 - (t / duration);
let sample = (t * freq * std::f32::consts::TAU).sin() * volume * fade;
(sample * i16::MAX as f32) as i16
})
.collect()
}
fn noise_samples(duration: f32, volume: f32) -> Vec<i16> {
let n = (SAMPLE_RATE as f32 * duration) as usize;
(0..n)
.map(|i| {
let t = i as f32 / SAMPLE_RATE as f32;
let fade = 1.0 - (t / duration);
let noise = macroquad::rand::gen_range(-1.0f32, 1.0);
(noise * volume * fade * i16::MAX as f32) as i16
})
.collect()
}
pub struct Sounds {
pub shoot: Sound,
pub explode: Sound,
pub damage: Sound,
pub thrust: Sound,
pub volume: f32,
}
impl Sounds {
pub async fn load() -> Self {
let shoot_wav = generate_wav(&sine_samples(880.0, 0.08, 0.3));
let explode_wav = generate_wav(&noise_samples(0.2, 0.4));
let damage_wav = generate_wav(&sine_samples(220.0, 0.25, 0.5));
let thrust_wav = generate_wav(&noise_samples(0.1, 0.15));
Self {
shoot: load_sound_from_bytes(&shoot_wav).await.unwrap(),
explode: load_sound_from_bytes(&explode_wav).await.unwrap(),
damage: load_sound_from_bytes(&damage_wav).await.unwrap(),
thrust: load_sound_from_bytes(&thrust_wav).await.unwrap(),
volume: 0.5,
}
}
pub fn play(&self, sound: &Sound) {
play_sound(sound, PlaySoundParams { looped: false, volume: self.volume });
}
}
src/particle.rs
use macroquad::prelude::*;
pub struct Particle {
pos: Vec2,
vel: Vec2,
lifetime: f32,
max_lifetime: f32,
radius: f32,
color: Color,
}
impl Particle {
pub fn new(pos: Vec2, vel: Vec2, lifetime: f32, radius: f32, color: Color) -> Self {
Self { pos, vel, lifetime, max_lifetime: lifetime, radius, color }
}
pub fn is_alive(&self) -> bool {
self.lifetime > 0.0
}
}
pub struct Particles {
pool: Vec<Particle>,
}
impl Particles {
pub fn new() -> Self {
Self { pool: Vec::new() }
}
pub fn update(&mut self, dt: f32) {
for p in &mut self.pool {
p.pos += p.vel * dt;
p.vel *= 1.0 - 3.0 * dt;
p.lifetime -= dt;
}
self.pool.retain(|p| p.is_alive());
}
pub fn draw(&self) {
for p in &self.pool {
let t = p.lifetime / p.max_lifetime;
let r = p.radius * t;
let mut c = p.color;
c.a = t;
draw_circle(p.pos.x, p.pos.y, r, c);
}
}
pub fn burst(&mut self, pos: Vec2, count: usize, speed: f32, color: Color) {
for _ in 0..count {
let angle = rand::gen_range(0.0f32, std::f32::consts::TAU);
let spd = rand::gen_range(speed * 0.3, speed);
let vel = Vec2::from_angle(angle) * spd;
let lifetime = rand::gen_range(0.3f32, 0.8);
let radius = rand::gen_range(1.5f32, 4.0);
self.pool.push(Particle::new(pos, vel, lifetime, radius, color));
}
}
}
src/game.rs
use macroquad::prelude::*;
use crate::player::{Player, PLAYER_RADIUS, PLAYER_MASS};
use crate::asteroid::{self, Asteroid};
use crate::bullet::Bullet;
use crate::collision;
use crate::sound::Sounds;
use crate::particle::Particles;
#[derive(PartialEq)]
enum GameState {
Playing,
GameOver,
}
pub struct Game {
player: Player,
asteroids: Vec<Asteroid>,
bullets: Vec<Bullet>,
score: f32,
spawn_timer: f32,
spawn_interval: f32,
shoot_cooldown: f32,
state: GameState,
sounds: Sounds,
particles: Particles,
}
impl Game {
pub fn new(sounds: Sounds) -> Self {
Self {
player: Player::new(),
asteroids: Vec::new(),
bullets: Vec::new(),
score: 0.0,
spawn_timer: 0.0,
spawn_interval: 3.0,
shoot_cooldown: 0.0,
state: GameState::Playing,
sounds,
particles: Particles::new(),
}
}
pub fn reset(&mut self) {
self.player = Player::new();
self.asteroids.clear();
self.bullets.clear();
self.score = 0.0;
self.spawn_timer = 0.0;
self.spawn_interval = 3.0;
self.shoot_cooldown = 0.0;
self.state = GameState::Playing;
self.particles = Particles::new();
}
pub fn update(&mut self, dt: f32) {
if self.state == GameState::GameOver {
if is_key_pressed(KeyCode::R) { self.reset(); }
return;
}
self.player.update(dt);
self.score += dt;
if is_key_down(KeyCode::W) {
self.sounds.play(&self.sounds.thrust.clone());
}
self.shoot_cooldown -= dt;
if is_key_down(KeyCode::Space) && self.shoot_cooldown <= 0.0 {
self.bullets.push(self.player.spawn_bullet());
self.shoot_cooldown = 0.15;
self.sounds.play(&self.sounds.shoot.clone());
}
self.spawn_timer += dt;
if self.spawn_timer >= self.spawn_interval {
self.spawn_timer = 0.0;
self.asteroids.push(Asteroid::spawn());
self.spawn_interval = (self.spawn_interval - 0.15).max(0.6);
}
for asteroid in &mut self.asteroids {
asteroid.update(dt);
}
asteroid::resolve_collisions(&mut self.asteroids);
self.update_bullets();
self.particles.update(dt);
self.asteroids.retain(|a| !a.is_off_screen());
self.update_collisions();
}
fn update_bullets(&mut self) {
for bullet in &mut self.bullets {
bullet.update(get_frame_time());
}
self.bullets.retain(|b| !b.is_off_screen());
let mut asteroids_hit: Vec<usize> = Vec::new();
self.bullets.retain(|bullet| {
for (i, asteroid) in self.asteroids.iter().enumerate() {
if collision::point_in_polygon(bullet.pos, asteroid.pos, &asteroid.vertices) {
if !asteroids_hit.contains(&i) {
asteroids_hit.push(i);
}
return false;
}
}
true
});
let mut fragments: Vec<Asteroid> = Vec::new();
let mut hit_positions: Vec<(Vec2, f32)> = Vec::new();
for &i in &asteroids_hit {
let asteroid = &self.asteroids[i];
hit_positions.push((asteroid.pos, asteroid.radius));
if asteroid.generation == 0 {
let spread = Vec2::from_angle(rand::gen_range(0.0f32, std::f32::consts::TAU));
fragments.push(Asteroid::spawn_fragment(
asteroid.pos, asteroid.vel, asteroid.radius, spread,
));
fragments.push(Asteroid::spawn_fragment(
asteroid.pos, asteroid.vel, asteroid.radius, -spread,
));
}
}
asteroids_hit.sort_unstable();
for i in asteroids_hit.into_iter().rev() {
self.asteroids.swap_remove(i);
}
self.asteroids.extend(fragments);
for &(pos, radius) in &hit_positions {
let count = (radius * 0.8) as usize;
self.particles.burst(pos, count, radius * 2.0, LIGHTGRAY);
}
if !hit_positions.is_empty() {
self.sounds.play(&self.sounds.explode.clone());
}
}
fn update_collisions(&mut self) {
let player_aabb = self.player.aabb();
let mut player_pos = self.player.pos;
let mut player_vel = self.player.vel;
let player_invincible = self.player.invincibility > 0.0;
let mut took_damage = false;
for asteroid in &mut self.asteroids {
if !player_aabb.overlaps(&asteroid.aabb()) { continue; }
if !collision::circle_hits_polygon(
player_pos, PLAYER_RADIUS, asteroid.pos, &asteroid.vertices,
) { continue; }
let normal = (asteroid.pos - player_pos).normalize_or_zero();
let vel_along = (player_vel - asteroid.vel).dot(normal);
if vel_along > 0.0 {
let ma = asteroid.mass();
let j = 2.0 * vel_along / (1.0 / PLAYER_MASS + 1.0 / ma);
player_vel -= normal * (j / PLAYER_MASS);
asteroid.vel += normal * (j / ma);
let overlap = PLAYER_RADIUS + asteroid.radius
- player_pos.distance(asteroid.pos);
if overlap > 0.0 {
let correction = normal * overlap * 0.5;
player_pos -= correction;
asteroid.pos += correction;
}
}
if !player_invincible {
took_damage = true;
}
}
self.player.pos = player_pos;
self.player.vel = player_vel;
if took_damage {
self.player.hp -= 20.0;
self.player.invincibility = 1.5;
self.particles.burst(self.player.pos, 12, 80.0, ORANGE);
self.sounds.play(&self.sounds.damage.clone());
if self.player.hp <= 0.0 {
self.state = GameState::GameOver;
}
}
}
pub fn draw(&self) {
clear_background(BLACK);
self.particles.draw();
draw_text(
&format!("FPS: {}", get_fps()),
screen_width() - 90.0, 24.0, 20.0, DARKGREEN,
);
for asteroid in &self.asteroids {
asteroid.draw();
}
for bullet in &self.bullets {
bullet.draw();
}
self.player.draw();
draw_text(&format!("Score: {}", self.score as u32), 20.0, 24.0, 24.0, WHITE);
draw_rectangle(20.0, 36.0, 200.0, 16.0, DARKGRAY);
let fill_width = 200.0 * (self.player.hp / 100.0).clamp(0.0, 1.0);
let bar_color = if self.player.hp > 60.0 { GREEN }
else if self.player.hp > 30.0 { YELLOW }
else { RED };
draw_rectangle(20.0, 36.0, fill_width, 16.0, bar_color);
draw_rectangle_lines(20.0, 36.0, 200.0, 16.0, 1.5, WHITE);
if self.state == GameState::GameOver {
let msg = format!("Game Over — Score: {}", self.score as u32);
draw_text(&msg, screen_width() / 2.0 - 180.0, screen_height() / 2.0, 40.0, WHITE);
draw_text("Press R to restart", screen_width() / 2.0 - 110.0, screen_height() / 2.0 + 50.0, 24.0, GRAY);
}
}
}
Next steps
- Weapons and Bombs — four fire modes and deployable area-damage explosives