After having finished the first road-race
exercise
in the Ultimate Rust 2
course, I wanted to try and
see if I could write a simple Asteroids
game.
After cloning, download the assets:
cd rusty-asteroids
curl -L https://github.com/CleanCut/rusty_engine/archive/refs/heads/main.tar.gz | tar -zxv --strip-components=1 rusty_engine-main/assets
then compile and run:
cargo run
Shots are fired with SPACE
, thrust is given with the UP
key
and rotation is done by the LEFT
and RIGHT
key.
First I started to think about shooting projectiles from the Ship.
From the Rusty Engine
I could get the rotation, in Radians, of
the Player. Assuming the projectiles travels with a certain speed
I should be able to calculate the movement in (x,y).
The code became very simple, the SHOT_SPEED
times a scaling factor
times the cos/sin of the rotation angle got me the additional (x,y)
to be added to the current position:
// Move the shots
for sprite in engine.sprites.values_mut() {
if sprite.label.starts_with("shot") {
sprite.translation.x +=
SHOT_SPEED * engine.delta_f32 * (sprite.rotation as f64).cos() as f32;
sprite.translation.y +=
SHOT_SPEED * engine.delta_f32 * (sprite.rotation as f64).sin() as f32;
}
}
Next, I wanted to move the ship like in space. I imagined that movements in space behave somewhat similar to a boat, i.e you can't do sharp turns and have to rotate in combination with using the rockets to control the direction you move in.
Assuming the ship moves in one direction with speed S1, then that translates to some movements in (x,y) for each frame in the game. If the ship rotates and give some thrust to the rockets, a new speed component (vector) adds to the existing speed vector. The sum of those vectors forms the new speed vector that represent the new movements (x,y) to be performed in the succeeding frames.
The code became almost identical; the current speed of the ship speed as (x,y) is kept in the game state. Then we just add the new speed vector according to our rotation angle. Note that if no new thrust was given, we then just continue in the same speed as before.
// Give thrust
if give_thrust {
game_state.speed.x += THRUST_SPEED * (player_rotation as f64).cos() as f32;
game_state.speed.y += THRUST_SPEED * (player_rotation as f64).sin() as f32;
}
// Move the player
player.translation.x += game_state.speed.x * engine.delta_f32;
player.translation.y += game_state.speed.y * engine.delta_f32;
The Meteoroids was positioned randomly. Also, the scale, rotation (i.e the direction of movement), and the speed was randomly generated.
sprite.scale = thread_rng().gen_range(0.1..1.0);
sprite.rotation = thread_rng().gen_range(0.0..TAU);
...
let speed = METEOROID_SPEED * thread_rng().gen_range(0.5..1.5) as f32;
sprite.translation.x += speed * engine.delta_f32 * (sprite.rotation as f64).cos() as f32;
sprite.translation.y += speed * engine.delta_f32 * (sprite.rotation as f64).cos() as f32;
To avoid placing meteoroids on top of the Player position I looped until I got a position sufficiently far away. The friendly compiler (it is incredibly helpful) suggested a neat compact range test.
'random: loop {
x = thread_rng().gen_range(game_state.min_x..game_state.max_x);
y = thread_rng().gen_range(game_state.min_y..game_state.max_y);
if !(-30.0..30.0).contains(&x) && !(-30.0..30.0).contains(&y) {
break 'random;
}
}
To get the Thrust and Shooting to behave smoothly I needed to slow down
how fast they could react. This was done by making use of some Timer
structs stored in the game state that count down every frame. When they
reach zero it is ok to react to either a thrust, or shoot, command.
// As setup in the game state
shot_timer: Timer::new(Duration::from_millis(RELOAD_TIME), false),
thrust_timer: Timer::new(Duration::from_millis(THRUST_TIME), false),
...
// Update the timers every frame
game_state.shot_timer.tick(engine.delta);
game_state.thrust_timer.tick(engine.delta);
...
// Keyboard handling
if engine.keyboard_state.pressed(KeyCode::Space) && game_state.shot_timer.finished() {
shoot = true;
game_state.shot_timer.reset(); // restart the timer
}
The game is over when the Player collides with a Meteoroid and the
game is won when all meteoroids are destroyed. The latter gave me
some unexpected problems. I could not find an easy way to extract
if one meteoroid had been shot. I ended up keeping a Vec<String>
of the names of the meteoroid sprites. The final problem that
took some time to figure out was how to remove the destroyed names
from the Vector.
// deal with collisions
for event in engine.collision_events.drain(..) {
...
// Remove any destroyed meteoroid from our "alive" list.
game_state.meteoroids.retain(|x| *x != event.pair.0);
game_state.meteoroids.retain(|x| *x != event.pair.1);
...
}
...
// check for won game
if game_state.meteoroids.is_empty() {
...
}
To finish it off I made it possible to restart the game and to maintain
a Best Time high score. This was a very fun exercise and it is quite addictive
to play some rusty-asteroids
... :-)