Asteroid Dodger: Weapons and Bombs
This tutorial picks up where the sound and particles tutorial left off. The ship has one fire mode — a single stream of bullets. This tutorial adds three more weapon types and a deployable bomb, giving the player tactical choices.
What you’ll add
- A
WeaponTypeenum with four fire modes: Blaster, Spread, Rapid, and Laser - Number keys to switch weapons, with HUD display
- A
Bombstruct with slow travel, timed fuse, and area-of-effect explosion - Limited bomb inventory shown on the HUD
Milestones overview
Prerequisites
- Completed the sound and particles tutorial
- Project compiles and runs with sound, particles, shooting, and physics
Milestone 1 — Weapon system
Milestone 1 of 2Step 1 — Weapon types (src/weapon.rs)
Create src/weapon.rs and register it in main.rs:
mod weapon;
// src/weapon.rs
#[derive(Clone, Copy, PartialEq)]
pub enum WeaponType {
Blaster,
Spread,
Rapid,
Laser,
}
impl WeaponType {
pub const ALL: [WeaponType; 4] = [
Self::Blaster,
Self::Spread,
Self::Rapid,
Self::Laser,
];
pub fn cooldown(&self) -> f32 {
match self {
Self::Blaster => 0.15,
Self::Spread => 0.4,
Self::Rapid => 0.05,
Self::Laser => 0.12,
}
}
pub fn label(&self) -> &'static str {
match self {
Self::Blaster => "Blaster",
Self::Spread => "Spread",
Self::Rapid => "Rapid",
Self::Laser => "Laser",
}
}
pub fn color(&self) -> macroquad::prelude::Color {
use macroquad::prelude::*;
match self {
Self::Blaster => YELLOW,
Self::Spread => ORANGE,
Self::Rapid => SKYBLUE,
Self::Laser => LIME,
}
}
}
Each weapon has a distinct fire rate and color. Spread fires slowly but covers a wide area. Rapid fires very fast but each bullet is small. Laser pierces through asteroids without being consumed.
Step 2 — Expand bullets (src/bullet.rs)
Add piercing and radius fields to the Bullet struct so different weapons produce different projectiles:
// src/bullet.rs
use macroquad::prelude::*;
const SPEED: f32 = 600.0;
pub struct Bullet {
pub pos: Vec2,
vel: Vec2,
pub piercing: bool,
pub radius: f32,
pub color: Color,
}
impl Bullet {
pub fn new(pos: Vec2, angle: f32) -> Self {
Self {
pos,
vel: Vec2::from_angle(angle) * SPEED,
piercing: false,
radius: 3.0,
color: YELLOW,
}
}
pub fn new_with(pos: Vec2, angle: f32, speed: f32, piercing: bool, radius: f32, color: Color) -> Self {
Self {
pos,
vel: Vec2::from_angle(angle) * speed,
piercing,
radius,
color,
}
}
pub fn update(&mut self, dt: f32) {
self.pos += self.vel * dt;
}
pub fn is_off_screen(&self) -> bool {
let (sw, sh) = (screen_width(), screen_height());
self.pos.x < 0.0 || self.pos.x > sw || self.pos.y < 0.0 || self.pos.y > sh
}
pub fn draw(&self) {
draw_circle(self.pos.x, self.pos.y, self.radius, self.color);
}
}
The existing new() constructor still works for the default blaster. new_with() gives full control for other weapon types.
Step 3 — Weapon-specific fire patterns (src/player.rs + src/game.rs)
src/player.rs — replace the single spawn_bullet method with one that takes a weapon type:
use crate::weapon::WeaponType;
pub fn spawn_bullets(&self, weapon: WeaponType) -> Vec<Bullet> {
let forward = Vec2::from_angle(self.angle);
let tip = self.pos + forward * 22.0;
match weapon {
WeaponType::Blaster => {
vec![Bullet::new(tip, self.angle)]
}
WeaponType::Spread => {
let spread = 0.15; // radians, ~8.5 degrees
vec![
Bullet::new_with(tip, self.angle - spread, 550.0, false, 2.5, ORANGE),
Bullet::new_with(tip, self.angle, 600.0, false, 2.5, ORANGE),
Bullet::new_with(tip, self.angle + spread, 550.0, false, 2.5, ORANGE),
]
}
WeaponType::Rapid => {
vec![Bullet::new_with(tip, self.angle, 700.0, false, 1.5, SKYBLUE)]
}
WeaponType::Laser => {
vec![Bullet::new_with(tip, self.angle, 900.0, true, 1.5, LIME)]
}
}
}
src/game.rs — add weapon state and update the fire logic:
use crate::weapon::WeaponType;
Add to the Game struct:
current_weapon: WeaponType,
Initialise in new() and reset():
current_weapon: WeaponType::Blaster,
Replace the shooting block in update with weapon-aware logic:
// Weapon switching (1-4 keys, but only when not in settings rebind mode)
if is_key_pressed(KeyCode::Key1) { self.current_weapon = WeaponType::Blaster; }
if is_key_pressed(KeyCode::Key2) { self.current_weapon = WeaponType::Spread; }
if is_key_pressed(KeyCode::Key3) { self.current_weapon = WeaponType::Rapid; }
if is_key_pressed(KeyCode::Key4) { self.current_weapon = WeaponType::Laser; }
self.shoot_cooldown -= dt;
if is_key_down(self.keybindings.shoot) && self.shoot_cooldown <= 0.0 {
let new_bullets = self.player.spawn_bullets(self.current_weapon);
self.bullets.extend(new_bullets);
self.shoot_cooldown = self.current_weapon.cooldown();
self.sounds.play(&self.sounds.shoot.clone());
}
Update update_bullets — laser bullets are piercing, so they don’t get consumed on hit. Change the bullet retain logic:
self.bullets.retain(|bullet| {
let mut hit = false;
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);
}
hit = true;
if !bullet.piercing { return false; } // consumed
}
}
true // piercing bullets or misses survive
});
Add weapon display to Game::draw, near the score text:
draw_text(
&format!("[{}] {}",
WeaponType::ALL.iter().position(|w| *w == self.current_weapon).unwrap() + 1,
self.current_weapon.label()
),
20.0, 70.0, 20.0, self.current_weapon.color(),
);
Weapon switching uses hardcoded number keys (1-4) while the settings screen also
uses 1-4 for rebinding. This works because weapon switching only runs in the
Playing state, and rebinding only runs in Settings. The
state machine prevents conflicts.
cargo run — press 1-4 to switch weapons. Blaster is
the familiar single shot. Spread fires a 3-bullet fan. Rapid sprays fast tiny
bullets. Laser fires green piercing shots that pass through asteroids, hitting
everything in their path.
Milestone 2 — Bombs
Milestone 2 of 2Step 4 — Bomb struct (src/bomb.rs)
Create src/bomb.rs and register it in main.rs:
mod bomb;
// src/bomb.rs
use macroquad::prelude::*;
const BOMB_SPEED: f32 = 200.0;
const BOMB_FUSE: f32 = 2.0;
pub const EXPLOSION_RADIUS: f32 = 150.0;
pub struct Bomb {
pub pos: Vec2,
vel: Vec2,
pub timer: f32,
pub exploded: bool,
}
impl Bomb {
pub fn new(pos: Vec2, angle: f32) -> Self {
Self {
pos,
vel: Vec2::from_angle(angle) * BOMB_SPEED,
timer: BOMB_FUSE,
exploded: false,
}
}
pub fn update(&mut self, dt: f32) {
self.pos += self.vel * dt;
self.vel *= 1.0 - 1.5 * dt; // heavy drag — bombs slow down
self.timer -= dt;
if self.timer <= 0.0 {
self.exploded = true;
}
}
pub fn is_off_screen(&self) -> bool {
let margin = 200.0;
let (sw, sh) = (screen_width(), screen_height());
self.pos.x < -margin || self.pos.x > sw + margin
|| self.pos.y < -margin || self.pos.y > sh + margin
}
pub fn draw(&self) {
// Pulsing red circle that speeds up near detonation
let pulse = (self.timer * 6.0).sin().abs();
let r = 6.0 + pulse * 2.0;
draw_circle(self.pos.x, self.pos.y, r, RED);
draw_circle_lines(self.pos.x, self.pos.y, r + 2.0, 1.0, ORANGE);
}
}
Bombs travel slowly with heavy drag, pulsing red. The fuse counts down and triggers explosion after 2 seconds. Contact with an asteroid also triggers explosion — that’s handled in game.rs.
Step 5 — Bomb deployment and explosion (src/game.rs)
Add bomb state to Game:
use crate::bomb::{Bomb, EXPLOSION_RADIUS};
pub struct Game {
// ... existing fields ...
bombs: Vec<Bomb>,
bomb_count: u32,
}
// In new():
bombs: Vec::new(),
bomb_count: 3,
// In reset():
self.bombs.clear();
self.bomb_count = 3;
Add bomb deployment in update, after the shooting block:
if is_key_pressed(KeyCode::B) && self.bomb_count > 0 {
let forward = Vec2::from_angle(0.0); // bombs need player angle
self.bombs.push(self.player.spawn_bomb());
self.bomb_count -= 1;
self.sounds.play(&self.sounds.shoot.clone());
}
src/player.rs — add spawn_bomb:
use crate::bomb::Bomb;
pub fn spawn_bomb(&self) -> Bomb {
let forward = Vec2::from_angle(self.angle);
Bomb::new(self.pos + forward * 22.0, self.angle)
}
Back in game.rs, add an update_bombs method:
fn update_bombs(&mut self) {
for bomb in &mut self.bombs {
bomb.update(get_frame_time());
}
// Check bomb-asteroid contact — trigger explosion
for bomb in &mut self.bombs {
if bomb.exploded { continue; }
for asteroid in &self.asteroids {
if bomb.pos.distance(asteroid.pos) < asteroid.radius {
bomb.exploded = true;
bomb.timer = 0.0;
break;
}
}
}
// Process explosions
let mut exploded_positions: Vec<Vec2> = Vec::new();
for bomb in &self.bombs {
if !bomb.exploded { continue; }
exploded_positions.push(bomb.pos);
}
for pos in &exploded_positions {
// Destroy all asteroids within explosion radius
let mut hit_indices: Vec<usize> = Vec::new();
let mut fragments: Vec<Asteroid> = Vec::new();
for (i, asteroid) in self.asteroids.iter().enumerate() {
if pos.distance(asteroid.pos) < EXPLOSION_RADIUS {
hit_indices.push(i);
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,
));
}
self.particles.burst(asteroid.pos, (asteroid.radius * 0.6) as usize, asteroid.radius * 1.5, LIGHTGRAY);
}
}
hit_indices.sort_unstable();
for i in hit_indices.into_iter().rev() {
self.asteroids.swap_remove(i);
}
self.asteroids.extend(fragments);
// Big explosion particles
self.particles.burst(*pos, 40, 200.0, RED);
self.particles.burst(*pos, 25, 150.0, ORANGE);
self.sounds.play(&self.sounds.explode.clone());
}
// Remove exploded bombs
self.bombs.retain(|b| !b.exploded && !b.is_off_screen());
}
Call update_bombs from update, after update_bullets:
self.update_bullets();
self.update_bombs();
Add bomb drawing and HUD to Game::draw:
// Draw bombs (with other game objects)
for bomb in &self.bombs {
bomb.draw();
}
Add bomb count to the HUD:
draw_text(
&format!("Bombs: {}", self.bomb_count),
20.0, 88.0, 20.0, RED,
);
Bomb explosions use the same splitting logic as bullet hits — generation-0 asteroids produce fragments, generation-1 asteroids are just destroyed. The explosion radius (150px) is large enough to catch several asteroids at once, producing a satisfying chain of fragments and particles.
cargo run — press B to deploy a bomb. It drifts
forward with heavy drag, pulsing red. After 2 seconds (or on asteroid contact) it
explodes, destroying everything in a wide radius with a burst of red and orange
particles. You start with 3 bombs — the count shows on the HUD.
Complete listing
The project now has 11 source files. New files: weapon.rs, bomb.rs. Modified: bullet.rs, player.rs, game.rs.
src/main.rs
use macroquad::prelude::*;
mod collision;
mod player;
mod asteroid;
mod bullet;
mod game;
mod sound;
mod particle;
mod grid;
mod weapon;
mod bomb;
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/weapon.rs
#[derive(Clone, Copy, PartialEq)]
pub enum WeaponType {
Blaster,
Spread,
Rapid,
Laser,
}
impl WeaponType {
pub const ALL: [WeaponType; 4] = [
Self::Blaster,
Self::Spread,
Self::Rapid,
Self::Laser,
];
pub fn cooldown(&self) -> f32 {
match self {
Self::Blaster => 0.15,
Self::Spread => 0.4,
Self::Rapid => 0.05,
Self::Laser => 0.12,
}
}
pub fn label(&self) -> &'static str {
match self {
Self::Blaster => "Blaster",
Self::Spread => "Spread",
Self::Rapid => "Rapid",
Self::Laser => "Laser",
}
}
pub fn color(&self) -> macroquad::prelude::Color {
use macroquad::prelude::*;
match self {
Self::Blaster => YELLOW,
Self::Spread => ORANGE,
Self::Rapid => SKYBLUE,
Self::Laser => LIME,
}
}
}
src/bomb.rs
use macroquad::prelude::*;
const BOMB_SPEED: f32 = 200.0;
const BOMB_FUSE: f32 = 2.0;
pub const EXPLOSION_RADIUS: f32 = 150.0;
pub struct Bomb {
pub pos: Vec2,
vel: Vec2,
pub timer: f32,
pub exploded: bool,
}
impl Bomb {
pub fn new(pos: Vec2, angle: f32) -> Self {
Self {
pos,
vel: Vec2::from_angle(angle) * BOMB_SPEED,
timer: BOMB_FUSE,
exploded: false,
}
}
pub fn update(&mut self, dt: f32) {
self.pos += self.vel * dt;
self.vel *= 1.0 - 1.5 * dt;
self.timer -= dt;
if self.timer <= 0.0 {
self.exploded = true;
}
}
pub fn is_off_screen(&self) -> bool {
let margin = 200.0;
let (sw, sh) = (screen_width(), screen_height());
self.pos.x < -margin || self.pos.x > sw + margin
|| self.pos.y < -margin || self.pos.y > sh + margin
}
pub fn draw(&self) {
let pulse = (self.timer * 6.0).sin().abs();
let r = 6.0 + pulse * 2.0;
draw_circle(self.pos.x, self.pos.y, r, RED);
draw_circle_lines(self.pos.x, self.pos.y, r + 2.0, 1.0, ORANGE);
}
}
src/bullet.rs
use macroquad::prelude::*;
const SPEED: f32 = 600.0;
pub struct Bullet {
pub pos: Vec2,
vel: Vec2,
pub piercing: bool,
pub radius: f32,
pub color: Color,
}
impl Bullet {
pub fn new(pos: Vec2, angle: f32) -> Self {
Self {
pos,
vel: Vec2::from_angle(angle) * SPEED,
piercing: false,
radius: 3.0,
color: YELLOW,
}
}
pub fn new_with(pos: Vec2, angle: f32, speed: f32, piercing: bool, radius: f32, color: Color) -> Self {
Self {
pos,
vel: Vec2::from_angle(angle) * speed,
piercing,
radius,
color,
}
}
pub fn update(&mut self, dt: f32) {
self.pos += self.vel * dt;
}
pub fn is_off_screen(&self) -> bool {
let (sw, sh) = (screen_width(), screen_height());
self.pos.x < 0.0 || self.pos.x > sw || self.pos.y < 0.0 || self.pos.y > sh
}
pub fn draw(&self) {
draw_circle(self.pos.x, self.pos.y, self.radius, self.color);
}
}
src/player.rs
use macroquad::prelude::*;
use crate::collision::Aabb;
use crate::bullet::Bullet;
use crate::bomb::Bomb;
use crate::game::Keybindings;
use crate::weapon::WeaponType;
pub const PLAYER_RADIUS: f32 = 16.0;
pub const PLAYER_MASS: f32 = 500.0;
const THRUST: f32 = 300.0;
const DRAG: f32 = 1.8;
const ROTATE_SPEED: f32 = 3.0;
const MAX_SPEED: f32 = 420.0;
pub struct Player {
pub pos: Vec2,
pub vel: Vec2,
pub hp: f32,
pub invincibility: f32,
angle: f32,
}
impl Player {
pub fn new() -> Self {
Self {
pos: Vec2::new(screen_width() / 2.0, screen_height() / 2.0),
vel: Vec2::ZERO,
hp: 100.0,
invincibility: 0.0,
angle: -std::f32::consts::FRAC_PI_2,
}
}
pub fn update(&mut self, dt: f32, keys: &Keybindings) {
if is_key_down(keys.rotate_left) { self.angle -= ROTATE_SPEED * dt; }
if is_key_down(keys.rotate_right) { self.angle += ROTATE_SPEED * dt; }
if is_key_down(keys.thrust) {
self.vel += Vec2::from_angle(self.angle) * THRUST * dt;
}
self.vel *= 1.0 - DRAG * dt;
if self.vel.length() > MAX_SPEED {
self.vel = self.vel.normalize() * MAX_SPEED;
}
self.pos += self.vel * dt;
let (sw, sh) = (screen_width(), screen_height());
if self.pos.x < 0.0 { self.pos.x += sw; }
if self.pos.x > sw { self.pos.x -= sw; }
if self.pos.y < 0.0 { self.pos.y += sh; }
if self.pos.y > sh { self.pos.y -= sh; }
if self.invincibility > 0.0 {
self.invincibility -= dt;
}
}
pub fn aabb(&self) -> Aabb {
Aabb::from_center_radius(self.pos, PLAYER_RADIUS)
}
pub fn spawn_bullets(&self, weapon: WeaponType) -> Vec<Bullet> {
let forward = Vec2::from_angle(self.angle);
let tip = self.pos + forward * 22.0;
match weapon {
WeaponType::Blaster => {
vec![Bullet::new(tip, self.angle)]
}
WeaponType::Spread => {
let spread = 0.15;
vec![
Bullet::new_with(tip, self.angle - spread, 550.0, false, 2.5, ORANGE),
Bullet::new_with(tip, self.angle, 600.0, false, 2.5, ORANGE),
Bullet::new_with(tip, self.angle + spread, 550.0, false, 2.5, ORANGE),
]
}
WeaponType::Rapid => {
vec![Bullet::new_with(tip, self.angle, 700.0, false, 1.5, SKYBLUE)]
}
WeaponType::Laser => {
vec![Bullet::new_with(tip, self.angle, 900.0, true, 1.5, LIME)]
}
}
}
pub fn spawn_bomb(&self) -> Bomb {
let forward = Vec2::from_angle(self.angle);
Bomb::new(self.pos + forward * 22.0, self.angle)
}
pub fn draw(&self) {
if self.invincibility > 0.0 && (self.invincibility * 8.0) as u32 % 2 == 0 {
return;
}
let forward = Vec2::from_angle(self.angle);
let side = Vec2::new(-forward.y, forward.x);
draw_triangle(
self.pos + forward * 20.0,
self.pos - forward * 10.0 + side * 12.0,
self.pos - forward * 10.0 - side * 12.0,
WHITE,
);
}
}
collision.rs, asteroid.rs, sound.rs, particle.rs, and grid.rs are unchanged from the previous tutorial.
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::bomb::{Bomb, EXPLOSION_RADIUS};
use crate::collision;
use crate::sound::Sounds;
use crate::particle::Particles;
use crate::grid::Grid;
use crate::weapon::WeaponType;
#[derive(Clone, Copy, PartialEq)]
pub enum Action {
Thrust,
RotateLeft,
RotateRight,
Shoot,
}
impl Action {
pub const ALL: [Action; 4] = [
Action::Thrust,
Action::RotateLeft,
Action::RotateRight,
Action::Shoot,
];
pub fn label(&self) -> &'static str {
match self {
Action::Thrust => "Thrust",
Action::RotateLeft => "Rotate Left",
Action::RotateRight => "Rotate Right",
Action::Shoot => "Shoot",
}
}
}
pub struct Keybindings {
pub thrust: KeyCode,
pub rotate_left: KeyCode,
pub rotate_right: KeyCode,
pub shoot: KeyCode,
}
impl Keybindings {
pub fn default_bindings() -> Self {
Self {
thrust: KeyCode::W,
rotate_left: KeyCode::A,
rotate_right: KeyCode::D,
shoot: KeyCode::Space,
}
}
pub fn key_for(&self, action: Action) -> KeyCode {
match action {
Action::Thrust => self.thrust,
Action::RotateLeft => self.rotate_left,
Action::RotateRight => self.rotate_right,
Action::Shoot => self.shoot,
}
}
pub fn set_key(&mut self, action: Action, key: KeyCode) {
match action {
Action::Thrust => self.thrust = key,
Action::RotateLeft => self.rotate_left = key,
Action::RotateRight => self.rotate_right = key,
Action::Shoot => self.shoot = key,
}
}
}
#[derive(PartialEq)]
enum GameState {
Menu,
Playing,
GameOver,
Settings,
AwaitingKey(Action),
}
pub struct Game {
player: Player,
asteroids: Vec<Asteroid>,
bullets: Vec<Bullet>,
bombs: Vec<Bomb>,
score: f32,
spawn_timer: f32,
spawn_interval: f32,
shoot_cooldown: f32,
state: GameState,
sounds: Sounds,
particles: Particles,
grid: Grid,
keybindings: Keybindings,
high_score: u32,
current_weapon: WeaponType,
bomb_count: u32,
}
impl Game {
pub fn new(sounds: Sounds) -> Self {
Self {
player: Player::new(),
asteroids: Vec::new(),
bullets: Vec::new(),
bombs: Vec::new(),
score: 0.0,
spawn_timer: 0.0,
spawn_interval: 3.0,
shoot_cooldown: 0.0,
state: GameState::Menu,
sounds,
particles: Particles::new(),
grid: Grid::new(screen_width(), screen_height(), 120.0),
keybindings: Keybindings::default_bindings(),
high_score: 0,
current_weapon: WeaponType::Blaster,
bomb_count: 3,
}
}
pub fn reset(&mut self) {
self.player = Player::new();
self.asteroids.clear();
self.bullets.clear();
self.bombs.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();
self.grid = Grid::new(screen_width(), screen_height(), 120.0);
self.current_weapon = WeaponType::Blaster;
self.bomb_count = 3;
}
pub fn update(&mut self, dt: f32) {
match &self.state {
GameState::Menu => {
if is_key_pressed(KeyCode::Enter) {
self.reset();
self.state = GameState::Playing;
}
if is_key_pressed(KeyCode::S) {
self.state = GameState::Settings;
}
if is_key_pressed(KeyCode::Escape) {
std::process::exit(0);
}
return;
}
GameState::GameOver => {
let final_score = self.score as u32;
if final_score > self.high_score {
self.high_score = final_score;
}
if is_key_pressed(KeyCode::R) {
self.reset();
}
if is_key_pressed(KeyCode::Escape) {
self.state = GameState::Menu;
}
return;
}
GameState::Settings | GameState::AwaitingKey(_) => {
self.update_settings();
return;
}
GameState::Playing => {}
}
self.player.update(dt, &self.keybindings);
self.score += dt;
if is_key_down(self.keybindings.thrust) {
self.sounds.play(&self.sounds.thrust.clone());
}
// Weapon switching
if is_key_pressed(KeyCode::Key1) { self.current_weapon = WeaponType::Blaster; }
if is_key_pressed(KeyCode::Key2) { self.current_weapon = WeaponType::Spread; }
if is_key_pressed(KeyCode::Key3) { self.current_weapon = WeaponType::Rapid; }
if is_key_pressed(KeyCode::Key4) { self.current_weapon = WeaponType::Laser; }
self.shoot_cooldown -= dt;
if is_key_down(self.keybindings.shoot) && self.shoot_cooldown <= 0.0 {
let new_bullets = self.player.spawn_bullets(self.current_weapon);
self.bullets.extend(new_bullets);
self.shoot_cooldown = self.current_weapon.cooldown();
self.sounds.play(&self.sounds.shoot.clone());
}
// Bomb deployment
if is_key_pressed(KeyCode::B) && self.bomb_count > 0 {
self.bombs.push(self.player.spawn_bomb());
self.bomb_count -= 1;
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);
}
self.grid.clear();
for (i, asteroid) in self.asteroids.iter().enumerate() {
self.grid.insert(i, asteroid.pos.x, asteroid.pos.y, asteroid.radius);
}
let pairs = self.grid.potential_pairs();
asteroid::resolve_collisions(&mut self.asteroids, &pairs);
self.update_bullets();
self.update_bombs();
self.particles.update(dt);
self.asteroids.retain(|a| !a.is_off_screen());
self.update_collisions();
let current_score = self.score as u32;
if current_score > self.high_score {
self.high_score = current_score;
}
}
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);
}
if !bullet.piercing { 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_bombs(&mut self) {
for bomb in &mut self.bombs {
bomb.update(get_frame_time());
}
for bomb in &mut self.bombs {
if bomb.exploded { continue; }
for asteroid in &self.asteroids {
if bomb.pos.distance(asteroid.pos) < asteroid.radius {
bomb.exploded = true;
bomb.timer = 0.0;
break;
}
}
}
let mut exploded_positions: Vec<Vec2> = Vec::new();
for bomb in &self.bombs {
if bomb.exploded {
exploded_positions.push(bomb.pos);
}
}
for pos in &exploded_positions {
let mut hit_indices: Vec<usize> = Vec::new();
let mut fragments: Vec<Asteroid> = Vec::new();
for (i, asteroid) in self.asteroids.iter().enumerate() {
if pos.distance(asteroid.pos) < EXPLOSION_RADIUS {
hit_indices.push(i);
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,
));
}
self.particles.burst(asteroid.pos, (asteroid.radius * 0.6) as usize, asteroid.radius * 1.5, LIGHTGRAY);
}
}
hit_indices.sort_unstable();
for i in hit_indices.into_iter().rev() {
self.asteroids.swap_remove(i);
}
self.asteroids.extend(fragments);
self.particles.burst(*pos, 40, 200.0, RED);
self.particles.burst(*pos, 25, 150.0, ORANGE);
self.sounds.play(&self.sounds.explode.clone());
}
self.bombs.retain(|b| !b.exploded && !b.is_off_screen());
}
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;
}
}
}
fn update_settings(&mut self) {
if is_key_pressed(KeyCode::Escape) {
self.state = GameState::Menu;
return;
}
if let GameState::AwaitingKey(action) = self.state {
if let Some(key) = get_last_key_pressed() {
if key != KeyCode::Escape {
self.keybindings.set_key(action, key);
}
self.state = GameState::Settings;
}
return;
}
if is_key_pressed(KeyCode::Left) {
self.sounds.volume = (self.sounds.volume - 0.1).max(0.0);
}
if is_key_pressed(KeyCode::Right) {
self.sounds.volume = (self.sounds.volume + 0.1).min(1.0);
}
let action_keys = [KeyCode::Key1, KeyCode::Key2, KeyCode::Key3, KeyCode::Key4];
for (i, &key) in action_keys.iter().enumerate() {
if is_key_pressed(key) {
self.state = GameState::AwaitingKey(Action::ALL[i]);
}
}
}
fn draw_menu(&self) {
let cx = screen_width() / 2.0;
let cy = screen_height() / 2.0;
draw_text("ASTEROID DODGER", cx - 220.0, cy - 80.0, 52.0, WHITE);
draw_text("[Enter] Play", cx - 70.0, cy, 28.0, GREEN);
draw_text("[S] Settings", cx - 70.0, cy + 40.0, 28.0, LIGHTGRAY);
draw_text("[Esc] Exit", cx - 60.0, cy + 80.0, 28.0, GRAY);
if self.high_score > 0 {
draw_text(
&format!("High Score: {}", self.high_score),
cx - 90.0, cy + 140.0, 24.0, YELLOW,
);
}
}
fn draw_game_over(&self) {
let cx = screen_width() / 2.0;
let cy = screen_height() / 2.0;
let final_score = self.score as u32;
let msg = format!("Game Over — Score: {}", final_score);
draw_text(&msg, cx - 180.0, cy, 40.0, WHITE);
if final_score >= self.high_score {
draw_text("NEW HIGH SCORE!", cx - 110.0, cy + 40.0, 28.0, YELLOW);
}
draw_text("[R] Restart [Esc] Menu", cx - 150.0, cy + 80.0, 24.0, GRAY);
}
fn draw_settings(&self) {
let cx = screen_width() / 2.0;
let mut y = 100.0;
draw_text("SETTINGS", cx - 80.0, y, 40.0, WHITE);
y += 60.0;
draw_text("Volume", cx - 180.0, y, 24.0, LIGHTGRAY);
let bar_x = cx - 20.0;
let bar_w = 200.0;
draw_rectangle(bar_x, y - 14.0, bar_w, 16.0, DARKGRAY);
draw_rectangle(bar_x, y - 14.0, bar_w * self.sounds.volume, 16.0, GREEN);
draw_rectangle_lines(bar_x, y - 14.0, bar_w, 16.0, 1.0, WHITE);
draw_text("[<-/->]", cx + 200.0, y, 18.0, GRAY);
y += 50.0;
draw_text("Keybindings", cx - 180.0, y, 24.0, WHITE);
y += 30.0;
for (i, action) in Action::ALL.iter().enumerate() {
let key = self.keybindings.key_for(*action);
let key_name = format!("{:?}", key);
let label = format!("[{}] {} — {}", i + 1, action.label(), key_name);
let color = if self.state == GameState::AwaitingKey(*action) {
YELLOW
} else {
LIGHTGRAY
};
draw_text(&label, cx - 180.0, y, 20.0, color);
if self.state == GameState::AwaitingKey(*action) {
draw_text("press any key...", cx + 120.0, y, 18.0, YELLOW);
}
y += 30.0;
}
y += 20.0;
draw_text("[Esc] Back", cx - 180.0, y, 20.0, GRAY);
}
pub fn draw(&self) {
clear_background(BLACK);
match &self.state {
GameState::Menu => { self.draw_menu(); return; }
GameState::Settings | GameState::AwaitingKey(_) => {
self.draw_settings();
return;
}
_ => {}
}
self.particles.draw();
for asteroid in &self.asteroids {
asteroid.draw();
}
for bullet in &self.bullets {
bullet.draw();
}
for bomb in &self.bombs {
bomb.draw();
}
self.player.draw();
draw_text(
&format!("FPS: {}", get_fps()),
screen_width() - 90.0, 24.0, 20.0, DARKGREEN,
);
draw_text(&format!("Score: {}", self.score as u32), 20.0, 24.0, 24.0, WHITE);
draw_text(
&format!("Best: {}", self.high_score),
screen_width() - 120.0, 48.0, 20.0, GRAY,
);
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);
draw_text(
&format!("[{}] {}",
WeaponType::ALL.iter().position(|w| *w == self.current_weapon).unwrap() + 1,
self.current_weapon.label()
),
20.0, 70.0, 20.0, self.current_weapon.color(),
);
draw_text(
&format!("Bombs: {}", self.bomb_count),
20.0, 88.0, 20.0, RED,
);
if self.state == GameState::GameOver {
self.draw_game_over();
}
}
}
Next steps
- Traits and Generics — extract shared behavior into traits and write generic functions
- Menu, Power-ups, and Shop — title screen, keybinding editor, collectible power-ups, and shop