I made a little game in Macroquad. You are a circle with a gun that shoots automatically at other enemies. The enemies follow you around and your goal is to dodge them. The game looks like this:
Cargo.toml
[package]
name = "bullet_rogue"
version = "0.1.0"
edition = "2021"
[dependencies]
macroquad = "0.4.13"
src/main.rs
use macroquad::prelude::*;
mod constants;
use constants::*;
mod entities;
use entities::*;
mod keypress;
use keypress::*;
fn window_conf() -> Conf {
Conf {
window_title: "Simple Auto Bullet".to_owned(),
sample_count: 4,
window_width: 800,
window_height: 600,
..Default::default()
}
}
#[macroquad::main(window_conf)]
async fn main() {
let mut char_pos = vec2(screen_width() / 2.0, screen_height() / 2.0);
let mut enemies: Vec<Enemy> = Vec::new();
let mut bullets: Vec<Bullet> = Vec::new();
enemies.push(Enemy {
pos: char_pos + vec2(40., 40.),
typ: EnemyType::CIRCLE,
radius: 10.0,
collided: false
});
let mut weapon_refresh_count = WEAPON_RATE;
let mut spawn_counter = SPAWN_RATE;
let mut game_over = false;
loop {
if game_over {
draw_text("Game over!",
screen_width() / 2.0 - 60.,
screen_height() / 2.0,
30.0,
WHITE);
} else {
clear_background(BACKGROUND_COLOR);
enemies = draw_enemies(enemies, char_pos);
bullets = draw_bullets(bullets);
// Handle character movement.
let dir = handle_keypress();
char_pos += dir;
for enemy in &mut enemies {
for bullet in &bullets {
if Vec2::distance(enemy.pos, bullet.pos) < enemy.radius {
enemy.collided = true;
}
}
}
enemies.retain(|e| e.collided == false);
// Find closest enemy.
let mut closest_dist = f32::INFINITY;
// Dummy enemy that will be replaced by closest enemy.
let mut closest: &Enemy = &Enemy {
pos: char_pos,
typ: EnemyType::CIRCLE,
radius: 10.0,
collided: false
};
for enemy in &enemies {
let d = Vec2::distance(enemy.pos, char_pos);
if d < closest_dist {
closest_dist = d;
closest = enemy;
}
// Check if any enemy is hitting the player.
if d < ENEMY_SIZE + CHARACTER_SIZE {
game_over = true;
}
}
// Shoot at it.
draw_character(char_pos, closest);
if weapon_refresh_count == 0 {
bullets.push(Bullet {
pos: char_pos,
dir: (char_pos - closest.pos).normalize() * -1.,
lifetime: 200
});
weapon_refresh_count = WEAPON_RATE;
}
weapon_refresh_count -= 1;
if spawn_counter == 0 {
let random_offset = vec2(
rand::rand() as f32,
rand::rand() as f32).normalize_or_zero() * 100.;
enemies.push(Enemy {
pos: char_pos + random_offset,
typ: EnemyType::CIRCLE,
collided: false,
radius: ENEMY_SIZE
});
spawn_counter = SPAWN_RATE;
}
spawn_counter -= 1;
}
next_frame().await
}
}
src/constants.rs
use macroquad::prelude::*;
pub const BACKGROUND_COLOR: Color
= Color::new(0.0 / 255.0, 32.0 / 255., 46.0 / 255., 1.0);
pub const CHAR_COLOR: Color
= Color::new(188. / 255., 80. / 255., 144. / 255., 1.0);
pub const ENEMY_COLOR: Color
= Color::new(255. / 255., 166. / 255., 0. / 255., 1.0);
pub const CHARACTER_SIZE: f32 = 10.0;
pub const ENEMY_SIZE: f32 = 10.0;
pub const GUN_LEN: f32 = 13.0;
pub const GUN_WIDTH: f32 = 4.0;
pub const SPEED: f32 = 1.0;
pub const WEAPON_RATE: i32 = 300;
pub const SPAWN_RATE: i32 = 200;
src/entities.rs
use macroquad::prelude::*;
use crate::constants::*;
pub enum EnemyType {
CIRCLE,
}
pub struct Enemy {
pub pos: Vec2,
pub typ: EnemyType,
pub collided: bool,
pub radius: f32
}
pub struct Bullet {
pub pos: Vec2,
pub dir: Vec2,
pub lifetime: i32
}
/// Draws the character of the game.
pub fn draw_character(p: Vec2, closest_enemy: &Enemy) {
let angle = Vec2::from_angle((closest_enemy.pos.y - p.y)
.atan2(closest_enemy.pos.x - p.x));
draw_circle(p.x, p.y, CHARACTER_SIZE, CHAR_COLOR);
draw_line(
p.x, p.y,
p.x + GUN_LEN * angle.x, p.y + GUN_LEN * angle.y,
GUN_WIDTH, LIGHTGRAY
);
}
/// Draws the bullets to the screen.
/// Returns the bullets again so they can be used later.
pub fn draw_bullets(mut bullets: Vec<Bullet>) -> Vec<Bullet> {
// Clean up bullets.
bullets.retain(|x| x.lifetime >= 0);
for bullet in &bullets {
draw_circle(bullet.pos.x, bullet.pos.y, 3.0, RED);
}
for bullet in &mut bullets {
bullet.pos += bullet.dir;
bullet.lifetime -= 1;
}
bullets
}
/// Draws the enemies to the screen.
/// Returns the enemies again so they can be used later.
pub fn draw_enemies(mut enemies: Vec<Enemy>, player_position: Vec2)
-> Vec<Enemy> {
for enemy in &mut enemies {
match enemy.typ {
// For now just one enemy type.
EnemyType::CIRCLE => {
draw_circle(enemy.pos.x,
enemy.pos.y,
ENEMY_SIZE,
ENEMY_COLOR);
}
}
let dir = (player_position - enemy.pos).normalize_or_zero();
enemy.pos += dir * 0.1;
}
enemies
}
src/keypress.rs
use macroquad::prelude::*;
use crate::constants::*;
pub fn handle_keypress() -> Vec2 {
let mut dir = vec2(0.0, 0.0);
if is_key_down(KeyCode::Left) {
dir.x = -SPEED;
}
if is_key_down(KeyCode::Right) {
dir.x = SPEED;
}
if is_key_down(KeyCode::Up) {
dir.y = -SPEED;
}
if is_key_down(KeyCode::Down) {
dir.y = SPEED;
}
dir.normalize_or_zero()
}
1 Answer 1
This is wonderful. Good job!
constants
There is room for greater consistency.
That said, no, I do not insist on "no magic numbers!".
When drawing a bullet, e.g., ..., 3.0, RED);
is just fine as-is.
But some figures relate to others, and expressing them symbolically
would improve clarity.
pub const ENEMY_SIZE: f32 = 10.0;
enemies.push(Enemy {
pos: char_pos + vec2(40., 40.),
typ: EnemyType::CIRCLE,
radius: 10.0,
collided: false
});
That appears to just be a "whoops!".
Having defined the size, might as well go ahead and use it.
Farther down we see a proper mention of radius: ENEMY_SIZE
.
This is in terms of px / frame:
pub const SPEED: f32 = 1.0;
Imagine that we're playing at 30 FPS, or 60, or 90 FPS. I would prefer that this be in terms of px / second. And similarly for enemy and bullet speed. For one thing, it would make it easier for someone armed with a stopwatch to do system testing, to verify that entities spawn and move as specified.
Same deal here:
pub const WEAPON_RATE: i32 = 300;
pub const SPAWN_RATE: i32 = 200;
Prefer to express that in terms of seconds. And maybe put "interval" in the name.
BTW the draw_character()
method is very clear, thank you.
good use of helpers
let dir = handle_keypress();
char_pos += dir;
Thank you for that.
The main loop is on the big side -- one must vertically scroll to read all of it.
Consider breaking out additional helpers, such as the enemy update for
loop.
Maybe push details like "track remaining bullet lifetime" out of the main loop.
relative speed
One of the essential parameters determining player viability is the relative speed of player to enemy. It turns out that enemies move at 10% of player speed. But that is not at all apparent here:
enemy.pos += dir * 0.1;
Prefer to use a symbolic name there, so we can see it adjacent to the SPEED declaration.
Alternatively, consider making that expression += dir * 0.1 * SPEED
,
so enemies don't really have their own speed; rather we choose
a definition that is explicitly in terms of the player's speed.
Then if we were to strain human reflexes a bit more
by making player vehicle move faster, it won't change
that aspect of how hard the game is.
(A DeepMind Atari player with instant reflexes would find the game unchanged.)
frame rate
next_frame().await
Macroquad runs on multiple platforms, and apparently doesn't offer control over length of time between frames. Different platforms default to different speeds, and then there is VSync 0. I already expressed a preference for SI units over "frames".
Consider making get_time()
drive the game physics.
-
\$\begingroup\$ Thanks! There appears to be a function
get_frame_time()
. Do you think I could use that to drive the physics? I guess I'm still trying to think of a clean way to useget_time()
to drive the physics if that makes sense. \$\endgroup\$Dair– Dair2024年11月06日 00:08:43 +00:00Commented Nov 6, 2024 at 0:08 -
\$\begingroup\$ My reading was that function would tell you elapsed time on previous frame. Which can be variable. It may be good for deciding "last frame was slow, so shed load, do less work on this frame!". But I don't feel it's a good foundation for physics. Among other things, we'd want to avoid adding up a bunch of roundoff errors. // An enemy has fixed espeed and is heading in some direction theta, with (dx,dy) components. So position at time t=0 is origin (x0, y0), and at time t is (t * dx * espeed + x0, t * dy * espeed + y0). Get time t from the wall clock. No cumulative roundoff trouble there! \$\endgroup\$J_H– J_H2024年11月06日 05:40:15 +00:00Commented Nov 6, 2024 at 5:40