Game Engine: Implement the Rotation Component
Mục tiêu: Define and implement a Rotation component in the Rust ECS game engine. This component will store an entity’s orientation in radians, laying the groundwork for more dynamic games like Asteroids.
An absolutely stellar achievement! You have successfully built a complete, playable version of Pong, taking your custom-built engine from a collection of theoretical systems to a proven, practical tool. You’ve validated its core capabilities: rendering, input, collision, and UI. Pong, with its axis-aligned movement and simple rules, was the perfect testbed for this foundation.
Now, it’s time to level up. We will push the engine’s capabilities into new territory by building a second, more dynamic game: the arcade classic, Asteroids. This new challenge will force us to evolve our engine, adding features that are essential for a vast number of games. Where Pong was about linear movement, Asteroids is about freedom of movement in all directions, spinning entities, and projectiles.
Our first step on this new journey is to introduce the concept of rotation. The player’s ship in Asteroids doesn’t just move up and down; it turns. The asteroids themselves don’t just drift; they tumble through space. To represent this fundamental property, we will follow the clean, modular philosophy of our Entity-Component-System architecture and create a new component.
From Position to Pose: Introducing Rotation
In 2D game development, an entity’s complete orientation in space is often called its pose, which consists of its position (where it is) and its rotation (which way it’s facing). We already have a Position component. Now, we will create a Rotation component to store the orientation.
This component will be a simple struct holding a single value: the angle of rotation. But in what units? While we often think in degrees (0° to 360°), in virtually all graphics and physics programming, it is standard practice to work with radians.
Radians are a unit of angular measure based on the radius of a circle. One full circle is 2π radians (which is equivalent to 360°). Why use them? Because all the trigonometric functions that are essential for calculating rotated positions and velocities—sin(), cos(), tan()—in Rust’s standard library (and in most programming languages) operate on radians. By storing our angle in radians from the start, we avoid costly conversions on every single frame.
Implementing the Rotation Component
With the theory understood, the implementation is beautifully simple. We just need to define our new component struct.
Open your src/components.rs file and add the Rotation struct alongside your other components.
// src/components.rs
// We need this to derive the Component trait.
use bevy_ecs::prelude::Component;
// --- Your existing components are unchanged ---
#[derive(Component)]
pub struct Position {
pub x: f32,
pub y: f32,
}
// ... (Velocity, Sprite, Collider, Controllable, Ball are unchanged) ...
// --- NEW CODE STARTS HERE ---
/// The Rotation component stores the orientation of an entity in 2D space.
/// Its existence on an entity implies that the entity can be rotated.
#[derive(Component)]
pub struct Rotation {
/// The angle of rotation in radians.
/// A value of 0.0 represents no rotation (facing right, along the positive X-axis).
/// A positive value indicates counter-clockwise rotation.
pub angle: f32,
}
// --- NEW CODE ENDS HERE ---
Deconstructing the Code
This small addition opens up a new dimension of possibilities for your engine.
#[derive(Component)]: As you know well by now, this macro frombevy_ecsregisters ourRotationstruct, allowing it to be added to any entity in ourWorld.pub struct Rotation: We define a new data type that cleanly encapsulates the single concept of orientation.pub angle: f32: This is the heart of the component. We use anf32(a 32-bit floating-point number) because angles are continuous, not discrete integers. It holds the entity’s rotation in radians. The comments in the code are crucial for establishing a convention:0.0radians means the entity is facing “east” (along the positive X-axis), and positive values will rotate it counter-clockwise. This is a common and mathematically convenient convention in 2D graphics.
A Silent Declaration
After adding this code, if you were to build and run your project, you would see… absolutely no change. The Pong game would play exactly as before. This is the correct and expected outcome.
You have once again witnessed a core principle of ECS design: components are just data. The Rotation component is a silent declaration. It’s a piece of information that, for now, no system is reading. An entity could have a Rotation { angle: 1.57 } (90 degrees counter-clockwise), but until a system is programmed to look for this component and act on it, the entity will still be drawn as if its angle were zero.
You have successfully defined the concept of orientation within your engine’s vocabulary. The stage is now perfectly set to teach your engine how to interpret this new information.
Next Steps
The data is in place. The next logical and exciting task is to modify the system responsible for visual representation: the RenderingSystem. You will update its logic to query for entities that have not only a Position and Sprite but also a Rotation component. For these entities, you will use the canvas 2D context’s powerful transformation methods (context.translate() and context.rotate()) to draw their sprites at the correct angle on the screen.
Further Reading
- Radians and Degrees: A clear and concise explanation of radians, why they are used in mathematics and programming, and how to convert between them.
- MDN Web Docs: Transformations on the Canvas: The definitive guide to the
translate(),rotate(), andscale()methods of the canvas 2D context. This is essential reading for the next task. - The Rust Book: Defining and Instantiating Structs (Revisited): It’s always beneficial to refresh your understanding of the fundamentals of creating custom data types in Rust.
Implement Sprite Rotation in the Rendering System
Mục tiêu: Update the RenderingSystem to draw entities based on their Rotation component by implementing 2D canvas transformations (save, translate, rotate, restore). This involves creating separate ECS queries for static and rotatable entities for optimization.
Excellent work on defining the Rotation component. You have successfully created the data structure that represents orientation in your game world. This is the blueprint for any entity that needs to turn, spin, or face a specific direction. However, as you’ve seen time and again in your ECS journey, data is inert. A blueprint doesn’t build the house; it only guides the builder.
Our current task is to upgrade our builder—the RenderingSystem—to read this new blueprint. We will teach it how to use the angle from the Rotation component to draw sprites at their correct orientation. This will involve one of the most fundamental concepts in 2D graphics: matrix transformations.
The Magic of Canvas Transformations
When you draw an image on an HTML canvas, you aren’t just placing pixels. You are drawing onto a coordinate system. The magic is that you can temporarily manipulate this entire coordinate system. Instead of calculating the complex new (x, y) coordinates for every single pixel of a rotated sprite, we can simply tell the canvas: “Hey, before you draw, just tilt the entire universe for a moment.”
This is achieved with a sequence of operations that must be performed in a specific order:
save(): We first save the current state of the canvas’s coordinate system (its position, rotation, and scale). Think of this as creating a bookmark.translate(): We move the origin(0, 0)of the coordinate system to the center of our entity. This is the most crucial step. By default, all rotations happen around the origin. If we didn’t translate first, our sprite would swing around the top-left corner of the canvas. By moving the origin to the sprite’s center, we ensure the rotation happens around its own center point.rotate(): Now that the origin is in the right place, we rotate the entire coordinate system by the angle specified in ourRotationcomponent.- Draw: We draw our sprite. But here’s the trick: since we’ve already moved the entire universe, we draw the sprite centered on the new origin, which means drawing it at
(-width / 2, -height / 2). restore(): Finally, we restore the canvas state to the bookmark we saved in step 1. This instantly undoes our translation and rotation, ensuring that the next entity we draw in the loop isn’t affected by the transformation we just applied.
Updating the RenderingSystem
To implement this, we need to modify our rendering_system. A clean and efficient way to handle this new complexity is to have two separate queries: one for entities that are simple (and don’t rotate) and one for entities that have a Rotation component. This allows us to use the fast, simple drawing path for most objects and apply the more complex transformation logic only where needed.
Let’s update the rendering_system function in src/lib.rs.
// src/lib.rs
// ... (your existing use statements)
// Make sure to add `Rotation` to your component imports and `Without` to bevy_ecs imports.
use crate::components::{Position, Rotation, Sprite};
use bevy_ecs::prelude::{Query, Without, World};
// ...
pub fn rendering_system(world: &World, context: &web_sys::CanvasRenderingContext2d) {
// --- HIGHLIGHTED CHANGES START ---
// Get the AssetManager resource, which contains all our loaded images.
let Some(asset_manager) = world.get_resource::<AssetManager>() else { return };
// --- Query 1: Render non-rotatable entities ---
// This query finds all entities with a Position and a Sprite, but specifically
// EXCLUDES any entity that also has a Rotation component. `Without<T>` is a
// powerful filter for this purpose. This is our "fast path".
let mut simple_sprites_query =
world.query_filtered::<(&Position, &Sprite), Without<Rotation>>();
for (position, sprite) in simple_sprites_query.iter(world) {
if let Some(image) = asset_manager.assets.get(&sprite.asset_key) {
// This is the same simple drawing logic we've always used.
context
.draw_image_with_html_image_element_and_dw_and_dh(
image,
(position.x - sprite.width / 2.0) as f64,
(position.y - sprite.height / 2.0) as f64,
sprite.width as f64,
sprite.height as f64,
)
.unwrap();
}
}
// --- Query 2: Render rotatable entities ---
// This query finds all entities that have Position, Sprite, AND Rotation.
// These entities require the more complex transformation logic.
let mut rotated_sprites_query = world.query::<(&Position, &Sprite, &Rotation)>();
for (position, sprite, rotation) in rotated_sprites_query.iter(world) {
if let Some(image) = asset_manager.assets.get(&sprite.asset_key) {
// 1. Save the current canvas state.
context.save();
// 2. Translate the canvas origin to the entity's center.
context
.translate(position.x as f64, position.y as f64)
.unwrap();
// 3. Rotate the canvas by the entity's rotation angle.
context.rotate(rotation.angle as f64).unwrap();
// 4. Draw the image. Critically, we now draw it centered on the NEW
// origin (0,0), which is currently at the entity's position. So we
// offset the draw by half the sprite's width and height.
context
.draw_image_with_html_image_element_and_dw_and_dh(
image,
(-sprite.width / 2.0) as f64, // Draw relative to the new origin
(-sprite.height / 2.0) as f64, // Draw relative to the new origin
sprite.width as f64,
sprite.height as f64,
)
.unwrap();
// 5. Restore the canvas state to what it was before we saved.
context.restore();
}
}
// --- HIGHLIGHTED CHANGES END ---
}
Deconstructing the Changes
- Two Queries for Clarity: Instead of one complex query, we now have two simple ones.
world.query_filtered::<..., Without<Rotation>>()is a beautiful, declarative way to say “give me the simple stuff.” The second query,world.query::<...>(), gets all entities that have the full set of components for rotation. This is a very common and powerful ECS pattern. - The Transformation Block:
context.save(): Creates that “bookmark” of the canvas’s default state.context.translate(position.x, position.y): Shifts the entire drawing grid so that its(0,0)point is now at the center of our sprite.context.rotate(rotation.angle): Rotates the entire (now translated) grid.context.draw_image_with_html_image_element(...): Draws the image. Notice thexandycoordinates are(-sprite.width / 2.0)and(-sprite.height / 2.0). This is because we are drawing relative to the new origin we just established.context.restore(): Resets the grid back to normal, ready for the next entity.
A Capability in Waiting
After implementing this change, build and run your project. You will see your Pong game running exactly as it did before, with no visible changes.
This is the correct and expected outcome.
You have given the RenderingSystem the ability to draw rotated sprites, but none of the entities in your Pong game have a Rotation component. Therefore, they are all processed by the first “fast path” query, and the new rotation logic is never executed. You have successfully installed a new, powerful capability into your engine, and it is now waiting silently for an entity that needs it.
Next Steps
Your engine can now render rotated objects. The next logical step is to create an entity that can actually be rotated and give the player control over that rotation. This leads you directly to the next task: Update the PlayerControlSystem for the Asteroids ship to handle rotation and forward thrust, where you will finally see this new rendering logic spring into action.
Further Reading
- MDN Web Docs: Transformations on the Canvas: The definitive guide to the
translate(),rotate(), andsave()/restore()methods of the canvas 2D context. This is essential reading to solidify your understanding. - The Bevy Book: Query Filtering: A closer look at the powerful filtering capabilities of
bevy_ecsqueries, including theWithout<T>filter you just used. - Understanding the 2D Transformation Matrix: A more advanced but fascinating look at the mathematics behind how
translate,rotate, andscaleare combined into a single matrix to perform all transformations at once.
Implement Asteroids-Style Player Controls with Rotation and Thrust
Mục tiêu: Refactor the PlayerControlSystem from simple directional movement to a classic arcade spaceship control scheme. Implement logic to rotate the player entity with left/right keys and apply thrust in the direction the entity is facing using the up key, requiring trigonometry (sin/cos) to calculate velocity changes.
Excellent! You have successfully upgraded your engine with a fundamental new capability: rotation. Your RenderingSystem is now a sophisticated piece of machinery, able to draw sprites at any angle using canvas transformations. This powerful feature has been waiting silently in the background, and now it’s time for its grand debut. We are moving from the simple, linear world of Pong to the dynamic, free-form universe of Asteroids.
To do this, we must completely rethink how the player controls their entity. The simple up-and-down movement of a paddle is being replaced by the nuanced mechanics of a spaceship: the ability to turn freely and apply thrust to move in the direction you are facing. This task is about rewiring our PlayerControlSystem to implement these classic, physics-based arcade controls.
Step 1: Setting the Stage for ‘Asteroids’
Before we can control a spaceship, we need one to exist in our world. It’s time to temporarily retire our Pong setup and create a new scene for Asteroids.
First, ensure you have a spaceship sprite. You can reuse the player_ship.png from earlier steps or create a new one, perhaps a triangle pointing to the right, and save it as assets/asteroids_ship.png.
Next, let’s create a new setup function in src/lib.rs and modify Game::new to call it.
// src/lib.rs
// --- Make sure to bring all necessary components into scope ---
use crate::components::{Collider, Controllable, Position, Rotation, Sprite, Velocity};
// ... (after your setup_pong function)
// --- NEW FUNCTION: setup_asteroids ---
// This function will spawn the initial entities for our Asteroids game.
// For now, it's just the player's ship.
pub fn setup_asteroids(world: &mut World, canvas_width: f32, canvas_height: f32) {
// Spawn the player's spaceship
world.spawn((
// The ship starts in the center of the screen.
Position {
x: canvas_width / 2.0,
y: canvas_height / 2.0,
},
// It starts stationary, with no initial velocity.
Velocity { dx: 0.0, dy: 0.0 },
// This is the key new component! The ship starts with no rotation (angle = 0.0),
// which our engine defines as facing to the right (along the positive X-axis).
Rotation { angle: 0.0 },
// The visual representation.
Sprite {
asset_key: String::from("assets/player_ship.png"), // Or your new ship sprite
width: 50.0,
height: 50.0,
},
// The physical hitbox.
Collider {
width: 45.0,
height: 45.0,
},
// The marker component that identifies this as the player-controlled entity.
Controllable,
));
}
// ... (in the impl Game block)
impl Game {
pub fn new() -> Self {
// ... (getting canvas, context, creating world, and inserting resources is unchanged) ...
// --- HIGHLIGHTED CHANGE ---
// We are now commenting out the Pong setup and calling our new Asteroids setup.
// setup_pong(&mut world, canvas.width() as f32, canvas.height() as f32);
setup_asteroids(&mut world, canvas.width() as f32, canvas.height() as f32);
// --- END HIGHLIGHTED CHANGE ---
Self {
world,
canvas,
context,
}
}
// ... (the rest of the Game impl is unchanged)
}
With this change, when you run the game, you’ll see a single spaceship in the center of the screen instead of the Pong setup.
Step 2: The Mathematics of Spaceflight
Asteroids controls are based on two principles: rotation and thrust.
- Rotation: This is simple. When the player presses Left or Right, we will add or subtract a small amount from the
Rotationcomponent’sangleon every frame. - Thrust: This is more interesting. When the player presses Up, we don’t just set a velocity. We apply an acceleration in the direction the ship is currently facing. This creates the feeling of inertia—the ship will continue to drift even after you let go of the key. To do this, we need a little bit of trigonometry.
Given an angle in radians, we can find the direction vector (a combination of an X and Y component) using the cosine and sine functions: * direction_x = angle.cos() * direction_y = angle.sin()
These functions, which are built into Rust, are the mathematical heart of our new control system.
Step 3: Overhauling the PlayerControlSystem
Now we are ready to completely refactor the player_control_system. We will change its query to get access to the entity’s Rotation and implement the new logic.
Replace your existing player_control_system in src/lib.rs with this new version.
// src/lib.rs
// --- Update your `use` statements for the system ---
use crate::components::{Controllable, Rotation, Velocity};
use crate::resources::InputState;
use bevy_ecs::prelude::{Query, Res, With, World};
// --- This is the new, refactored PlayerControlSystem for Asteroids ---
pub fn player_control_system(world: &mut World) {
// --- HIGHLIGHTED CHANGES START ---
// Constants for controlling the ship's movement.
// The rotation speed is in radians per frame. A full circle is 2*PI radians (approx 6.28).
const ROTATION_SPEED: f32 = 0.08;
// The amount of acceleration to apply when the thrust key is held.
const THRUST_AMOUNT: f32 = 0.15;
// We now need to query for both Velocity and Rotation, as we will be modifying both.
// We filter for the entity that has the `Controllable` marker component.
let mut ship_query = world.query_filtered::<(&mut Velocity, &mut Rotation), With<Controllable>>();
if let Some(input_state) = world.get_resource::<InputState>() {
// We still expect only one controllable entity, so `get_single_mut` is perfect.
if let Ok((mut velocity, mut rotation)) = ship_query.get_single_mut(world) {
// --- Rotation Logic ---
// If the left key is pressed, decrease the angle (rotate counter-clockwise).
if input_state.left_pressed {
rotation.angle -= ROTATION_SPEED;
}
// If the right key is pressed, increase the angle (rotate clockwise).
if input_state.right_pressed {
rotation.angle += ROTATION_SPEED;
}
// --- Thrust Logic ---
// If the up key is pressed, apply acceleration in the current direction.
if input_state.up_pressed {
// `rotation.angle.cos()` gives the X component of the direction vector.
let thrust_dx = rotation.angle.cos() * THRUST_AMOUNT;
// `rotation.angle.sin()` gives the Y component of the direction vector.
let thrust_dy = rotation.angle.sin() * THRUST_AMOUNT;
// Add the thrust vector to the current velocity.
velocity.dx += thrust_dx;
velocity.dy += thrust_dy;
}
}
}
// --- HIGHLIGHTED CHANGES END ---
}
The Magic Moment
Build and run your project.
wasm-pack build --target web
Refresh your browser. You will see your spaceship sitting in the middle of the screen. Now, press the Left and Right Arrow Keys. You will see the ship smoothly rotate! This is the first time the rotation logic in your RenderingSystem has been used, and it’s a huge milestone.
Next, while rotating, press the Up Arrow Key. The ship will begin to move forward in whatever direction it is currently pointing. Let go of the key, and the ship will continue to drift with inertia. You have successfully implemented the iconic control scheme of Asteroids!
Next Steps
Your player can now navigate the cosmos. But a spaceship in an empty void isn’t much of a game. It needs something to interact with, and more importantly, a way to defend itself. The next logical step is to arm your ship. Your next task is to implement a ‘shooting’ mechanic: spawning a bullet entity when the player presses a key, bringing your game one step closer to the full Asteroids experience.
Further Reading
- Trigonometry for Game Programming: A fantastic series of tutorials explaining how
sin,cos, and other trig functions are used for rotation and movement in games.- Trigonometry for Game Programmers (Video)
- Using Trigonometry in Games (Article)
- Vector Math in Games: Understanding vectors is the next step up from simple trigonometry. It allows you to represent position, velocity, and acceleration in a unified way.
- The Rust Standard Library:
f32: The documentation for the floating-point type you are using, including methods likecos()andsin().
Implement Spaceship Shooting Mechanic
Mục tiêu: Enable the player’s spaceship to fire projectiles. This task involves updating the input system to handle a ‘fire’ command, defining a bullet entity with its components, and implementing the logic in Rust to spawn bullets dynamically based on the ship’s current position and rotation.
Fantastic work! You’ve successfully implemented the iconic, physics-based movement of the Asteroids spaceship. Your engine can now handle rotation and thrust, and your RenderingSystem beautifully draws the oriented sprite. You have a ship that can navigate the cosmos, a pilot in a vast, empty void. It’s time to give that pilot a way to interact with their environment—it’s time to arm the ship.
This task is about implementing one of the most satisfying mechanics in any action game: shooting. We will enable the player to press a key to fire a projectile from their ship. This is a significant step for your engine, as it will be the first time an entity is created dynamically during the main game loop, not just at the start.
Part 1: The “Fire” Command - Updating Our Input System
If we simply spawn a bullet on every frame the “fire” key is held down, we’ll get a solid, continuous laser beam instead of distinct shots. This isn’t the classic Asteroids feel. To achieve a “one shot per press” mechanic, we need to detect the moment a key is first pressed, not just if it’s being held down.
Step 1: Evolving InputState
We’ll add a new boolean flag to our InputState resource specifically for this purpose.
Open src/resources.rs and update the InputState struct.
// src/resources.rs
// ... (other use statements)
// This resource holds the state of all player inputs.
#[derive(Resource, Default)]
pub struct InputState {
pub up_pressed: bool,
pub down_pressed: bool, // We can keep this for now, it might be useful later.
pub left_pressed: bool,
pub right_pressed: bool,
// --- NEW CODE STARTS HERE ---
// This flag is different. It will be set to `true` only for the single frame
// right after the shoot key is pressed.
pub shoot_pressed: bool,
// --- NEW CODE ENDS HERE ---
}
Step 2: Listening for the “Space” Key
Next, we need to update the JavaScript event listeners in Game::new to listen for the “Space” key and update our new shoot_pressed state. We’ll set it to true on keydown and false on keyup. The logic to make it a “one-shot” press will be handled inside our Rust system.
Navigate to your Game::new function in src/lib.rs.
// src/lib.rs
// ... (in the Game::new function)
// --- KEYDOWN LISTENER ---
let game_clone_down = game.clone();
let on_key_down = Closure::wrap(Box::new(move |event: web_sys::KeyboardEvent| {
let mut game_state = game_clone_down.borrow_mut();
if let Some(mut input_state) = game_state.world.get_resource_mut::<InputState>() {
match event.key().as_str() {
"ArrowUp" => input_state.up_pressed = true,
"ArrowDown" => input_state.down_pressed = true,
"ArrowLeft" => input_state.left_pressed = true,
"ArrowRight" => input_state.right_pressed = true,
// --- HIGHLIGHTED CHANGE ---
" " => input_state.shoot_pressed = true, // Handle Space bar
// --- END HIGHLIGHTED CHANGE ---
_ => {}
}
}
}) as Box<dyn FnMut(_)>);
// --- KEYUP LISTENER ---
let game_clone_up = game.clone();
let on_key_up = Closure::wrap(Box::new(move |event: web_sys::KeyboardEvent| {
let mut game_state = game_clone_up.borrow_mut();
if let Some(mut input_state) = game_state.world.get_resource_mut::<InputState>() {
match event.key().as_str() {
"ArrowUp" => input_state.up_pressed = false,
"ArrowDown" => input_state.down_pressed = false,
"ArrowLeft" => input_state.left_pressed = false,
"ArrowRight" => input_state.right_pressed = false,
// --- HIGHLIGHTED CHANGE ---
" " => input_state.shoot_pressed = false, // Handle Space bar
// --- END HIGHLIGHTED CHANGE ---
_ => {}
}
}
}) as Box<dyn FnMut(_)>);
// ... (the rest of the function is unchanged)
Part 2: Defining the Bullet
A bullet is just another entity, defined by its components. It needs to know where it is (Position), where it’s going (Velocity), what it looks like (Sprite), and its physical shape (Collider).
First, create a simple image for your bullet. A small, white 4x4 pixel PNG named bullet.png in your assets directory is perfect.
Next, we need to load this new asset when the game starts. Let’s add it to our concurrent asset loading block in the run function.
// src/lib.rs
// ... (in the run function)
spawn_local(async move {
// --- UPDATED ASSET LOADING ---
let (ship_img, bullet_img, laser_sound) = join!( // Renamed for clarity
load_image("assets/player_ship.png"),
load_image("assets/bullet.png"), // Load the new bullet image
async {
// ... sound loading logic is unchanged
}
);
log("All assets for Asteroids loaded and decoded successfully.");
// --- ASSET INSERTION ---
let mut game_state = game.borrow_mut();
if let Some(mut asset_manager) = game_state.world.get_resource_mut::<AssetManager>() {
asset_manager
.assets
.insert(String::from("assets/player_ship.png"), ship_img.unwrap());
// Insert the new bullet image into the asset manager
asset_manager
.assets
.insert(String::from("assets/bullet.png"), bullet_img.unwrap());
}
// ... (sound insertion is unchanged)
});
// ...
Part 3: Spawning Bullets on Demand
This is where the magic happens. We’ll upgrade the PlayerControlSystem to check for the shoot_pressed flag. If it’s true, it will spawn a new bullet entity. This introduces a classic challenge with Rust’s borrow checker: you can’t easily mutate the world (by spawning) while you are also borrowing parts of it (through a query).
The cleanest solution is to separate the logic. We’ll handle the continuous movement logic first, then check for the one-time shoot action.
Here is the fully updated player_control_system in src/lib.rs.
// src/lib.rs
// ... (use statements)
pub fn player_control_system(world: &mut World) {
// --- Constants ---
const ROTATION_SPEED: f32 = 0.08;
const THRUST_AMOUNT: f32 = 0.15;
const BULLET_SPEED: f32 = 5.0; // Much faster than the ship
// --- Part 1: Handle Ship Movement (Largely Unchanged) ---
// We get the input state resource once at the beginning.
let Some(input_state) = world.get_resource::<InputState>() else { return };
// This query handles rotation and thrust.
let mut ship_movement_query =
world.query_filtered::<(&mut Velocity, &mut Rotation), With<Controllable>>();
if let Ok((mut velocity, mut rotation)) = ship_movement_query.get_single_mut(world) {
if input_state.left_pressed {
rotation.angle -= ROTATION_SPEED;
}
if input_state.right_pressed {
rotation.angle += ROTATION_SPEED;
}
if input_state.up_pressed {
let thrust_dx = rotation.angle.cos() * THRUST_AMOUNT;
let thrust_dy = rotation.angle.sin() * THRUST_AMOUNT;
velocity.dx += thrust_dx;
velocity.dy += thrust_dy;
}
}
// --- Part 2: Handle Shooting (New Logic) ---
// Now we check if the shoot key is pressed.
if input_state.shoot_pressed {
// We need the ship's current position and rotation to spawn the bullet correctly.
// We query for them here, immutably.
let mut ship_transform_query =
world.query_filtered::<(&Position, &Rotation), With<Controllable>>();
if let Ok((ship_pos, ship_rot)) = ship_transform_query.get_single(world) {
// Calculate the bullet's direction from the ship's rotation.
let direction_x = ship_rot.angle.cos();
let direction_y = ship_rot.angle.sin();
// Calculate the bullet's starting position. We add a small offset so it
// spawns at the "nose" of the ship, not its center.
let bullet_start_x = ship_pos.x + direction_x * 25.0; // 25.0 is half ship's width
let bullet_start_y = ship_pos.y + direction_y * 25.0;
// Spawn the bullet entity with all its necessary components.
world.spawn((
Position {
x: bullet_start_x,
y: bullet_start_y,
},
// The bullet's velocity is the direction vector scaled by its speed.
Velocity {
dx: direction_x * BULLET_SPEED,
dy: direction_y * BULLET_SPEED,
},
Sprite {
asset_key: String::from("assets/bullet.png"),
width: 8.0,
height: 8.0,
},
Collider {
width: 8.0,
height: 8.0,
},
));
}
// --- CRITICAL STEP ---
// We've processed the shot, so we manually set the flag to false.
// This ensures that we only fire ONE bullet per key press, even though
// the key might be held down for multiple frames.
if let Some(mut input_state_mut) = world.get_resource_mut::<InputState>() {
input_state_mut.shoot_pressed = false;
}
}
}
Pew Pew! See It in Action
Build and run your project.
wasm-pack build --target web
Refresh your browser. Your ship will appear as before. You can still fly it around with the arrow keys. But now, tap the Space Bar. A small projectile will be spawned at the nose of your ship and fly forward in the direction the ship is facing! You can fly and shoot at the same time. You’ve just implemented the core action loop of Asteroids.
Next Steps
Your ship is armed, but its weapons are for show. The bullets fly harmlessly through the void. To create a real challenge, we need targets. The next step is to begin populating our game world with asteroids. However, our CollisionSystem is currently only designed to handle simple AABB collisions. To make the game feel right, we’ll need to modify the CollisionSystem to handle projectile-vs-asteroid collisions, which will likely involve introducing different types of colliders or a more nuanced way of processing collision events.
Further Reading
- Game Programming Patterns: Spawning Objects: While not a formal pattern in the book, the concepts of “Object Pools” and “Factories” are highly relevant to dynamically creating entities like bullets.
- The Bevy Book:
Commandsfor Spawning Entities: In a more maturebevy_ecsapplication, you would use a specialCommandsobject to queue up world-mutating actions like spawning. It’s a great concept to be aware of as it’s designed to solve the borrow-checker issues you just navigated manually. - Vector Math for Games: The calculations for the bullet’s position and velocity (
direction_x * speed) are a great introduction to vector math, a fundamental skill in game development.
Implement Typed Collision Detection in a Rust Game Engine
Mục tiêu: Refactor a simple collision system to classify different types of collisions, such as a bullet hitting an asteroid. This involves using marker components in an ECS architecture, upgrading the event system from a generic struct to a specific enum, and updating the system logic to query for and detect interactions between specific entity types.
Of course! Let’s get this task done. Here is the detailed solution structured as a blog article.
Your spaceship is now a formidable presence in the void, capable of navigating with precision and firing projectiles at will. This is a monumental leap in your engine’s capabilities. However, the bullets currently fired are phantoms—they pass harmlessly through space, unable to interact with anything. A game of Asteroids needs asteroids, and more importantly, it needs the satisfying feedback of destroying them.
This brings us to a crucial task: upgrading our CollisionSystem. Right now, it’s a simple detector that reports “something hit something else.” This was perfect for Pong, but for a more complex game, it’s not enough. We need our system to become an intelligent classifier. It must not only detect a collision but also understand what kind of collision it was. Was it a bullet hitting an asteroid? Or the player hitting an asteroid? The game must react differently to each.
Let’s evolve our physics engine to handle these specific interactions, starting with the all-important projectile-versus-asteroid collision.
Step 1: Identifying the Actors with Marker Components
Just as we used Controllable to identify the player’s ship, we need labels to identify our new entity types: bullets and asteroids. The marker component pattern is the perfect tool for this.
First, let’s define these new components in src/components.rs.
// src/components.rs
// ... (other use statements and components are unchanged) ...
#[derive(Component)]
pub struct Controllable;
#[derive(Component)]
pub struct Ball;
// --- NEW CODE STARTS HERE ---
/// A marker component for entities that are bullets.
#[derive(Component)]
pub struct Bullet;
/// A marker component for entities that are asteroids.
#[derive(Component)]
pub struct Asteroid;
// --- NEW CODE ENDS HERE ---
With our labels created, we must apply them.
First, update the player_control_system in src/lib.rs to add the Bullet component every time a projectile is spawned.
// src/lib.rs
// --- Make sure to bring the new Bullet component into scope ---
use crate::components::{Bullet, Collider, Controllable, Position, Rotation, Sprite, Velocity};
// ... (inside player_control_system)
if input_state.shoot_pressed {
// ... (logic to get ship position and rotation is unchanged)
if let Ok((ship_pos, ship_rot)) = ship_transform_query.get_single(world) {
// ... (bullet position and velocity calculation is unchanged)
// Spawn the bullet entity with all its necessary components.
world.spawn((
Position { /* ... */ },
Velocity { /* ... */ },
Sprite { /* ... */ },
Collider { /* ... */ },
// --- HIGHLIGHTED CHANGE ---
// We now tag the new entity with our Bullet marker component.
Bullet,
// --- END HIGHLIGHTED CHANGE ---
));
}
// ... (logic to reset shoot_pressed flag is unchanged)
}
// ... (end of function)
Step 2: Setting the Scene with Asteroids
We can’t test our new collision logic without targets. Let’s spawn a few asteroids for the player to shoot at.
First, create a simple sprite for your asteroid (e.g., a grey, rocky circle) and save it as assets/asteroid.png.
Next, update the asset loading block in your run function to include this new image.
// src/lib.rs
// ... (in the run function)
spawn_local(async move {
// --- UPDATED ASSET LOADING ---
let (ship_img, bullet_img, asteroid_img, laser_sound) = join!(
load_image("assets/player_ship.png"),
load_image("assets/bullet.png"),
load_image("assets/asteroid.png"), // Load the new asteroid image
async { /* ... sound loading ... */ }
);
// ...
if let Some(mut asset_manager) = game_state.world.get_resource_mut::<AssetManager>() {
asset_manager.assets.insert(/* ship asset */);
asset_manager.assets.insert(/* bullet asset */);
// Insert the new asteroid image into the asset manager
asset_manager
.assets
.insert(String::from("assets/asteroid.png"), asteroid_img.unwrap());
}
// ...
});
// ...
Finally, let’s update setup_asteroids to spawn two asteroids, making sure to tag them with our new Asteroid component.
// src/lib.rs
// --- Make sure to bring the new Asteroid component into scope ---
use crate::components::{Asteroid, Bullet, Collider, Controllable, Position, Rotation, Sprite, Velocity};
pub fn setup_asteroids(world: &mut World, canvas_width: f32, canvas_height: f32) {
// --- Spawning the player's ship is unchanged ---
world.spawn(( /* ... ship components ... */ ));
// --- NEW CODE STARTS HERE ---
// Spawn a couple of asteroids for testing.
world.spawn((
Position { x: 100.0, y: 100.0 },
Velocity { dx: 15.0, dy: -10.0 }, // Give them a slow drift
Rotation { angle: 0.0 }, // Asteroids can also rotate
Sprite {
asset_key: String::from("assets/asteroid.png"),
width: 80.0,
height: 80.0,
},
Collider {
width: 70.0,
height: 70.0,
},
// Tag this entity as an Asteroid.
Asteroid,
));
world.spawn((
Position { x: canvas_width - 100.0, y: canvas_height - 100.0 },
Velocity { dx: -12.0, dy: 20.0 },
Rotation { angle: 1.2 },
Sprite {
asset_key: String::from("assets/asteroid.png"),
width: 80.0,
height: 80.0,
},
Collider {
width: 70.0,
height: 70.0,
},
Asteroid,
));
// --- NEW CODE ENDS HERE ---
}
Step 3: Creating a More Expressive Event System
Our current CollisionEvent struct is too generic. The best way to represent different types of events in Rust is with an enum. Let’s refactor our event to be an enum that can describe various specific collision scenarios.
Open src/events.rs and replace the CollisionEvent struct with the following enum.
// src/events.rs
use bevy_ecs::prelude::Entity;
// --- HIGHLIGHTED CHANGES START ---
// By changing CollisionEvent to an enum, we make our events semantically rich.
// Each variant describes a specific type of interaction, carrying exactly
// the data needed for that interaction.
#[derive(Debug, Copy, Clone)]
pub enum CollisionEvent {
// This variant represents a collision between a bullet and an asteroid.
BulletAsteroid {
bullet_entity: Entity,
asteroid_entity: Entity,
},
// We can easily add more variants later, for example:
// PlayerAsteroid {
// player_entity: Entity,
// asteroid_entity: Entity,
// }
}
// --- HIGHLIGHTED CHANGES END ---
This is a huge architectural improvement. Now, instead of receiving a generic event and trying to figure out what it means, listening systems will receive a specific, meaningful event like CollisionEvent::BulletAsteroid.
Step 4: Upgrading the CollisionSystem into a Classifier
Finally, we can implement the core logic. We will change the CollisionSystem to no longer check every object against every other object. Instead, for our current goal, it will specifically check every Bullet against every Asteroid. When it finds an overlap, it will push our new, specific CollisionEvent::BulletAsteroid event.
Replace your existing collision_system in src/lib.rs with this new, more focused version.
// src/lib.rs
// --- Update your `use` statements ---
use crate::components::{Asteroid, Bullet, Collider, Position};
use crate::events::CollisionEvent;
use crate::resources::CollisionEvents;
use bevy_ecs::prelude::{Entity, Query, With, World};
// ...
pub fn collision_system(world: &mut World) {
// --- HIGHLIGHTED CHANGES START ---
// A temporary vector to store the events we detect in this frame.
let mut collision_events_this_frame: Vec<CollisionEvent> = Vec::new();
// Instead of one generic query, we create specific queries for the types
// we care about. This is clearer and can be more performant.
// To satisfy the borrow checker, we collect their data into Vecs.
let asteroids: Vec<(Entity, &Position, &Collider)> = world
.query_filtered::<(Entity, &Position, &Collider), With<Asteroid>>()
.iter(world)
.collect();
let bullets: Vec<(Entity, &Position, &Collider)> = world
.query_filtered::<(Entity, &Position, &Collider), With<Bullet>>()
.iter(world)
.collect();
// The core logic: check every bullet against every asteroid.
for (bullet_entity, bullet_pos, bullet_col) in bullets.iter() {
for (asteroid_entity, asteroid_pos, asteroid_col) in asteroids.iter() {
// AABB intersection check logic (same as before)
let a_left = bullet_pos.x - bullet_col.width / 2.0;
let a_right = bullet_pos.x + bullet_col.width / 2.0;
let a_top = bullet_pos.y - bullet_col.height / 2.0;
let a_bottom = bullet_pos.y + bullet_col.height / 2.0;
let b_left = asteroid_pos.x - asteroid_col.width / 2.0;
let b_right = asteroid_pos.x + asteroid_col.width / 2.0;
let b_top = asteroid_pos.y - asteroid_col.height / 2.0;
let b_bottom = asteroid_pos.y + asteroid_col.height / 2.0;
if a_left < b_right && a_right > b_left && a_top < b_bottom && a_bottom > b_top {
// Collision detected! Create our new, specific event.
log("Bullet-Asteroid Collision Detected!"); // For debugging
let event = CollisionEvent::BulletAsteroid {
bullet_entity: *bullet_entity,
asteroid_entity: *asteroid_entity,
};
collision_events_this_frame.push(event);
}
}
}
// Now that the checks are done, add the detected events to the global resource.
if let Some(mut collision_events_resource) = world.get_resource_mut::<CollisionEvents>() {
collision_events_resource
.events
.append(&mut collision_events_this_frame);
}
// --- HIGHLIGHTED CHANGES END ---
}
What to Expect
Build and run your project. You will now see your ship in a space populated by two drifting, rotating asteroids. Fly around and shoot at them. The bullets will still pass through them visually, but check your browser’s developer console. The moment a bullet’s sprite touches an asteroid’s sprite, you will see the “Bullet-Asteroid Collision Detected!” message.
This confirms your new system is working perfectly! It is correctly identifying the specific entities, performing the AABB check, and generating the correct, specific event type. Your physics engine is no longer just a detector; it’s a classifier, providing rich, meaningful information to the rest of your game.
Next Steps
The news of a collision is being broadcast loud and clear. Now we need someone to listen and act on it. The next thrilling task is to implement the logic for asteroids to break into smaller pieces upon being destroyed. This will involve creating a new system that specifically listens for CollisionEvent::BulletAsteroid and, upon receiving one, despawns both the bullet and the asteroid, creating the satisfying destructive feedback that is core to the Asteroids experience.
Further Reading
- Collision Filtering, Layers, and Masks: A more advanced topic on how professional game engines optimize collision detection by assigning entities to layers (e.g., “PlayerLayer”, “EnemyLayer”) and defining which layers can interact. The marker component approach you’ve used is a simple and effective form of this concept.
- The Rust Book: Enums and Pattern Matching: A deep dive into
enum, one of Rust’s most powerful features. The pattern you’ve used forCollisionEventis a prime example of its utility. - Borrow Checker Workarounds in
bevy_ecs: The pattern of collecting query results into aVecis a common way to work around the borrow checker when you need to iterate over entities while also having mutable access to the world. Understanding why this is necessary is key to mastering Rust with ECS.
Implement Asteroid Destruction and Fragmentation
Mục tiêu: Create a system that processes bullet-asteroid collision events. This system will despawn the bullet and the original asteroid, and, depending on the asteroid’s size, spawn smaller fragments in its place.
Of course! Let’s get this task done. Here is the detailed solution structured as a blog article.
The cosmos is no longer silent! Your engine now diligently reports every collision between a bullet and an asteroid, piping these critical moments into your event system. This is a testament to the robust, decoupled architecture you’ve built. The CollisionSystem acts as the sensory organs of your game, and the CollisionEvent is the nerve impulse it sends. Now, it’s time to build the brain—the logic that receives this impulse and triggers a spectacular reaction.
This task is where the “action” in your action game truly begins. We will implement the satisfying core loop of Asteroids: destruction and propagation. When a bullet strikes an asteroid, both will be annihilated, and in their place, smaller, faster offspring will emerge. This involves creating a new system to process our collision events and evolving our Asteroid component to be aware of its own size.
Step 1: Evolving Asteroids from a Label to a Lifeform
Until now, an Asteroid has been a simple marker. It’s either an asteroid or it isn’t. To implement breaking apart, we need a more nuanced definition. Is it a large, slow-moving boulder, a medium-sized rock, or a small, fast piece of debris? The most elegant way to represent this state is to transform our Asteroid marker component into a data component.
First, let’s define the possible sizes in src/components.rs. An enum is the perfect Rust feature for this. Then, we’ll change the Asteroid struct to hold a value of this new enum type.
// src/components.rs
// ... (other use statements and components are unchanged) ...
#[derive(Component)]
pub struct Bullet;
// --- NEW CODE STARTS HERE ---
// We define an enum to represent the different sizes of asteroids.
// Deriving `Copy` and `Clone` makes it easy to pass these values around.
#[derive(Debug, Copy, Clone)]
pub enum AsteroidSize {
Large,
Medium,
Small,
}
// The `Asteroid` component is no longer just a marker. It now holds data
// about the asteroid's size, which is crucial for our game logic.
#[derive(Component)]
pub struct Asteroid {
pub size: AsteroidSize,
}
// --- NEW CODE ENDS HERE ---
With this change, our engine can now distinguish between different types of asteroids. The next logical step is to update our setup_asteroids function to spawn Large asteroids at the start of the game.
// src/lib.rs
// --- Make sure to bring the new AsteroidSize enum into scope ---
use crate::components::{Asteroid, AsteroidSize, Bullet, ...};
pub fn setup_asteroids(world: &mut World, canvas_width: f32, canvas_height: f32) {
// ... (spawning the player's ship is unchanged) ...
// Spawn a couple of large asteroids for testing.
world.spawn((
Position { x: 100.0, y: 100.0 },
Velocity { dx: 15.0, dy: -10.0 },
Rotation { angle: 0.0 },
Sprite {
asset_key: String::from("assets/asteroid.png"),
width: 80.0,
height: 80.0,
},
Collider {
width: 70.0,
height: 70.0,
},
// --- HIGHLIGHTED CHANGE ---
// We now specify that this is a Large asteroid.
Asteroid {
size: AsteroidSize::Large,
},
// --- END HIGHLIGHTED CHANGE ---
));
world.spawn((
Position { x: canvas_width - 100.0, y: canvas_height - 100.0 },
Velocity { dx: -12.0, dy: 20.0 },
Rotation { angle: 1.2 },
Sprite { /* ... */ },
Collider { /* ... */ },
// --- HIGHLIGHTED CHANGE ---
Asteroid {
size: AsteroidSize::Large,
},
// --- END HIGHLIGHTED CHANGE ---
));
}
Step 2: Adding a Dash of Chaos with the rand Crate
To make the asteroid fragments fly apart in a convincing way, we need a source of randomness. The rand crate is the standard and most popular choice in the Rust ecosystem.
Add it to your Cargo.toml file:
# Cargo.toml
[dependencies]
# ... (your other dependencies)
rand = "0.8"
# ...
Step 3: Creating the destruction_system
This is the core of our task. We will create a new system that listens for CollisionEvent::BulletAsteroid, despawns the involved entities, and spawns new, smaller asteroids.
This system will perform several world-mutating actions (despawning and spawning) while also needing to read data from the world. To safely manage this and satisfy Rust’s borrow checker, we’ll use a common and robust pattern:
- Read the events and decide what actions to take.
- Store these intended actions in temporary local lists (e.g., a list of entities to despawn, a list of new asteroids to spawn).
- After all reading is done, execute the actions from the lists to mutate the world.
Let’s create our new destruction_system in src/lib.rs.
// src/lib.rs
// --- Add new `use` statements at the top of the file ---
use crate::components::{Asteroid, AsteroidSize, Position, Velocity, Rotation, Sprite, Collider};
use crate::events::CollisionEvent;
use crate::resources::CollisionEvents;
use bevy_ecs::prelude::{Entity, World};
use rand::prelude::*; // Import the rand prelude for random number generation
// --- NEW SYSTEM ---
// This system listens for BulletAsteroid collision events and handles the consequences.
pub fn destruction_system(world: &mut World) {
// A place to store the actions we want to perform at the end of the system.
let mut entities_to_despawn: Vec<Entity> = Vec::new();
// This will store the properties of new asteroids we need to spawn.
let mut new_asteroids: Vec<(Position, Velocity, AsteroidSize)> = Vec::new();
// To satisfy the borrow checker, we first read the events and asteroid data
// we need, collecting it into local variables.
let events = world.get_resource::<CollisionEvents>().map_or(Vec::new(), |events| events.events.clone());
let asteroid_query_results: Vec<(Entity, &Position, &Asteroid)> =
world.query::<(Entity, &Position, &Asteroid)>().iter(world).collect();
// 1. READ and DECIDE: Iterate through collision events.
for event in events {
if let CollisionEvent::BulletAsteroid { bullet_entity, asteroid_entity } = event {
// Mark the bullet for despawning.
entities_to_despawn.push(bullet_entity);
// Mark the hit asteroid for despawning.
entities_to_despawn.push(asteroid_entity);
// Find the data for the asteroid that was hit.
if let Some((_, pos, asteroid)) = asteroid_query_results.iter().find(|(e, _, _)| *e == asteroid_entity) {
// Get a random number generator.
let mut rng = thread_rng();
let parent_pos = Position { x: pos.x, y: pos.y };
// Determine what to spawn based on the parent's size.
let fragments_to_spawn = match asteroid.size {
AsteroidSize::Large => Some((2, AsteroidSize::Medium)),
AsteroidSize::Medium => Some((2, AsteroidSize::Small)),
AsteroidSize::Small => None, // Small asteroids are destroyed completely.
};
if let Some((count, new_size)) = fragments_to_spawn {
for _ in 0..count {
// Create a random direction for the new fragment.
let angle = rng.gen_range(0.0..std::f32::consts::PI * 2.0);
let speed = rng.gen_range(20.0..80.0);
let vel = Velocity {
dx: angle.cos() * speed,
dy: angle.sin() * speed,
};
new_asteroids.push((parent_pos, vel, new_size));
}
}
}
}
}
// 2. EXECUTE: Now, we perform the mutations on the world.
for (pos, vel, size) in new_asteroids {
let (width, height) = match size {
AsteroidSize::Medium => (40.0, 40.0),
AsteroidSize::Small => (20.0, 20.0),
_ => (0.0, 0.0), // Should not happen
};
world.spawn((
pos,
vel,
Rotation { angle: 0.0 }, // Can also be randomized
Sprite {
asset_key: String::from("assets/asteroid.png"),
width,
height,
},
Collider {
width: width * 0.9, // Make collider slightly smaller
height: height * 0.9,
},
Asteroid { size },
));
}
// Despawn all marked entities.
for entity in entities_to_despawn {
// `despawn` returns a bool indicating success, we can ignore it for now.
world.despawn(entity);
}
}
Step 4: Integrating the System into the Game Loop
Our powerful new system is ready. The final step is to add it to our Game::tick method. The order is critical: it must run after the collision_system (to read its events) but before the rendering_system (so the changes are visible in the same frame).
// src/lib.rs
impl Game {
// ... (new function is unchanged)
pub fn tick(&mut self) {
// ... (clearing the screen and other housekeeping is unchanged) ...
// --- GAME LOGIC (RUN SYSTEMS) ---
player_control_system(&mut self.world);
movement_system(&mut self.world);
collision_system(&mut self.world);
// We can rename the ball_system if we are not using it, or just keep it
// ball_system(&mut self.world);
// --- HIGHLIGHTED CHANGE ---
// We call our new destruction_system right after collisions are detected.
destruction_system(&mut self.world);
// --- END HIGHLIGHTED CHANGE ---
rendering_system(&self.world, &self.context);
ui_system(&self.world, &self.context);
}
}
The Payoff: A Satisfying Explosion!
Build and run your project.
wasm-pack build --target web
Refresh your browser. Fly your ship and fire at one of the large asteroids. The moment your bullet connects, a beautiful chain reaction will occur: the bullet and the large asteroid will vanish instantly. In their place, two smaller asteroids will burst forth, each flying off in a random direction. If you shoot one of these medium asteroids, they will likewise break into two even smaller pieces. Shooting the smallest fragments will simply make them disappear.
You have just implemented the dynamic, destructive, and deeply satisfying core gameplay loop of Asteroids!
Next Steps
The destruction is visually exciting, but there’s a missing piece: the flourish. A simple disappearance and replacement of sprites is functional, but a true arcade experience has flair. The next task in your journey is to add sprite sheet animation capabilities to your engine for effects like explosions, allowing you to crown each successful hit with a gratifying visual effect.
Further Reading
- Game Programming Patterns: Object Pool: Your current implementation spawns and despawns entities frequently. For performance in larger games, the Object Pool pattern is an excellent way to reuse entities instead of constantly creating and destroying them.
- The Bevy Book: Despawning Entities: The
bevy_ecscrate has a higher-level API calledCommandsthat makes actions like spawning and despawning even more ergonomic and can help manage borrow checker complexities. - Rust
randCrate Documentation: Explore the full power of therandcrate, including different random number distributions that can make your game’s effects feel even more natural.
Implement Explosion VFX using Sprite Sheet Animation
Mục tiêu: Create a sprite sheet animation system in Rust using Bevy ECS to add a visual explosion effect when asteroids are destroyed. This involves creating an Animation component, an animation system, and updating the rendering logic to draw individual frames from a sprite sheet.
Of course! Let’s get this task done. Here is the detailed solution structured as a blog article.
You have built a universe of dynamic action! Asteroids are created, they drift through space, and in a satisfying display of cause and effect, they shatter into smaller pieces when struck by the player’s projectiles. This is the mechanical heart of your Asteroids game. However, the soul of an arcade classic often lies in its flair, its “game feel.” Right now, destruction is an instantaneous, almost sterile event. The asteroid is simply replaced by its children. To elevate the experience, we need to add the punctuation mark at the end of that action: a fiery, brilliant explosion.
This task is about breathing life into these moments by building a sprite sheet animation system. A sprite sheet is a single image file that contains a sequence of images, or “frames,” that, when played in order, create the illusion of movement. By rapidly displaying each frame of an explosion, we can create a powerful and satisfying visual effect that rewards the player for a successful shot.
Part 1: The Blueprint for Motion - The Animation Component
To manage animations, we need a new component to store the animation’s state. It will hold all the necessary data: which sprite sheet to use, the dimensions of each frame, the total number of frames, and timing information.
Let’s head over to src/components.rs and define our new Animation component.
// src/components.rs
// ... (other use statements and components are unchanged) ...
#[derive(Component)]
pub struct Asteroid {
pub size: AsteroidSize,
}
// --- NEW CODE STARTS HERE ---
/// The Animation component stores the state for a sprite sheet animation.
/// Its presence on an entity with a `Sprite` tells the engine to treat
/// the sprite's asset as a sprite sheet.
#[derive(Component)]
pub struct Animation {
/// The width of a single frame in the sprite sheet.
pub frame_width: f32,
/// The total number of frames in the animation strip.
pub frame_count: usize,
/// The current frame index being displayed (0-indexed).
pub current_frame: usize,
/// How many game ticks to wait before advancing to the next frame.
/// A higher number means a slower animation.
pub ticks_per_frame: u32,
/// A counter for the number of ticks that have passed for the current frame.
pub current_tick: u32,
}
// --- NEW CODE ENDS HERE ---
Deconstructing the Animation Component
frame_width: The width, in pixels, of one individual frame on the sprite sheet. We assume the animation is a horizontal strip for simplicity.frame_count: The total number of frames in the animation.current_frame: The index of the frame we are currently showing.ticks_per_frame: Our timing mechanism. Instead of dealing with real-world time, which adds complexity, we’ll simply count game loop “ticks.” This gives us precise control over animation speed.current_tick: The accumulator that counts up toticks_per_frame.
Part 2: The Animator - Creating the AnimationSystem
With our data structure defined, we need a system to bring it to life. The AnimationSystem will be a new piece of logic that runs every frame. Its job is to update the state of every active animation and despawn animations that have finished playing.
Let’s create this new system in src/lib.rs.
// src/lib.rs
// --- Add new `use` statements at the top of the file ---
use crate::components::Animation; // Our new component
use bevy_ecs::prelude::{Commands, Entity, Query, World}; // Add Commands
// --- NEW SYSTEM ---
// The AnimationSystem is responsible for updating the state of all animated entities.
pub fn animation_system(world: &mut World) {
// We use a Commands object to safely queue up entity despawns.
// This is the modern bevy_ecs way to handle mutations while iterating.
let mut commands = Commands::new(world);
// We create a query for all entities that have an Animation component.
// We need the Entity ID to despawn it, and a mutable reference to the
// Animation component to update its state.
let mut animation_query = world.query::<(Entity, &mut Animation)>();
for (entity, mut animation) in animation_query.iter_mut(world) {
// Increment the tick counter for the current frame.
animation.current_tick += 1;
// If enough ticks have passed...
if animation.current_tick >= animation.ticks_per_frame {
// ...reset the counter and advance to the next frame.
animation.current_tick = 0;
animation.current_frame += 1;
// If the animation has gone past its last frame...
if animation.current_frame >= animation.frame_count {
// ...it's finished. We command the world to despawn this entity.
// For this game, we assume all animations are "one-shot" like explosions.
commands.despawn(entity);
}
}
}
}
Now, integrate this system into your main game loop in Game::tick. It should run before the rendering_system so that the frame updates before being drawn.
// src/lib.rs
impl Game {
// ... (new function is unchanged)
pub fn tick(&mut self) {
// ... (clearing the screen and other housekeeping is unchanged) ...
// --- GAME LOGIC (RUN SYSTEMS) ---
player_control_system(&mut self.world);
movement_system(&mut self.world);
collision_system(&mut self.world);
destruction_system(&mut self.world);
// --- HIGHLIGHTED CHANGE ---
// We run the animation system here, after logic but before rendering.
animation_system(&mut self.world);
// --- END HIGHLIGHTED CHANGE ---
rendering_system(&self.world, &self.context);
ui_system(&self.world, &self.context);
}
}
Part 3: The Painter - Upgrading the RenderingSystem
This is the most crucial part. Our RenderingSystem only knows how to draw a whole image. We need to teach it how to draw just a slice of an image—a single frame from our sprite sheet. This requires a more advanced drawing function from web-sys.
The function draw_image_with_html_image_element_and_sw_and_sh_and_dx_and_dy_and_dw_and_dh is our powerful new tool. It takes nine arguments: * The image element. * sx, sy: The X and Y coordinates of the top-left corner of the source rectangle on the sprite sheet. * sw, sh: The width and height of the source rectangle. * dx, dy: The X and Y coordinates for the top-left corner of the destination rectangle on the canvas. * dw, dh: The width and height of the destination rectangle.
We will add a new query to our RenderingSystem to handle entities that have both a Sprite and an Animation component.
// src/lib.rs
// --- Update `use` statements ---
use crate::components::{Animation, Position, Rotation, Sprite};
// ...
pub fn rendering_system(world: &World, context: &web_sys::CanvasRenderingContext2d) {
let Some(asset_manager) = world.get_resource::<AssetManager>() else { return };
// --- Query 1 & 2: Render non-animated entities (rotated and non-rotated) ---
// This existing logic for rendering static sprites remains unchanged.
// ...
// --- NEW: Query 3: Render animated entities ---
let mut animated_sprites_query = world.query::<(&Position, &Sprite, &Animation)>();
for (position, sprite, animation) in animated_sprites_query.iter(world) {
if let Some(image) = asset_manager.assets.get(&sprite.asset_key) {
// Calculate the X position of the source frame on the sprite sheet.
// We assume a horizontal strip, so the Y position is always 0.
let sx = animation.current_frame as f32 * animation.frame_width;
let sy = 0.0;
context
.draw_image_with_html_image_element_and_sw_and_sh_and_dx_and_dy_and_dw_and_dh(
image,
sx as f64, // Source X
sy as f64, // Source Y
animation.frame_width as f64, // Source Width
image.height() as f64, // Source Height (the full image height)
(position.x - sprite.width / 2.0) as f64, // Destination X
(position.y - sprite.height / 2.0) as f64, // Destination Y
sprite.width as f64, // Destination Width
sprite.height as f64, // Destination Height
)
.unwrap();
}
}
}
Part 4: The Spark - Spawning Explosions on Destruction
With the full animation pipeline built, we just need to trigger it.
First, you’ll need an explosion sprite sheet. You can find many free options online by searching for “pixel art explosion sprite sheet.” A good one to start with is a horizontal strip with 6-8 frames. Save it as assets/explosion.png.
Next, load this new asset in your run function.
// src/lib.rs
// ... (in the run function)
spawn_local(async move {
// --- UPDATED ASSET LOADING ---
let (ship_img, bullet_img, asteroid_img, explosion_img, laser_sound) = join!(
// ... (other images)
load_image("assets/explosion.png"), // Load the new explosion image
async { /* ... sound loading ... */ }
);
// ...
if let Some(mut asset_manager) = game_state.world.get_resource_mut::<AssetManager>() {
// ... (insert other assets)
asset_manager
.assets
.insert(String::from("assets/explosion.png"), explosion_img.unwrap());
}
// ...
});
// ...
Finally, modify your destruction_system to spawn an explosion entity at the site of an asteroid’s demise.
// src/lib.rs
// ... (in destruction_system)
pub fn destruction_system(world: &mut World) {
// ... (logic to collect events and asteroid data is unchanged)
// 1. READ and DECIDE: Iterate through collision events.
for event in events {
if let CollisionEvent::BulletAsteroid { bullet_entity, asteroid_entity } = event {
// ... (marking bullet and asteroid for despawning is unchanged)
if let Some((_, pos, asteroid)) = asteroid_query_results.iter().find(|(e, _, _)| *e == asteroid_entity) {
// --- HIGHLIGHTED CHANGE: SPAWN EXPLOSION ---
// We grab the position of the destroyed asteroid for our explosion.
let explosion_pos = Position { x: pos.x, y: pos.y };
// Get the on-screen size of the asteroid to make the explosion match.
let (width, height) = match asteroid.size {
AsteroidSize::Large => (80.0, 80.0),
AsteroidSize::Medium => (40.0, 40.0),
AsteroidSize::Small => (20.0, 20.0),
};
// Spawn the explosion entity. It has no velocity or collider.
// It's a purely visual effect.
commands.spawn((
explosion_pos,
Sprite {
asset_key: String::from("assets/explosion.png"),
width,
height,
},
// Configure the animation.
Animation {
frame_width: 32.0, // ASSUMPTION: Your sprite sheet frames are 32px wide. Adjust if needed!
frame_count: 8, // ASSUMPTION: Your sprite sheet has 8 frames. Adjust if needed!
current_frame: 0,
ticks_per_frame: 2, // Play the animation quickly.
current_tick: 0,
},
));
// --- END HIGHLIGHTED CHANGE ---
// ... (the rest of the logic for spawning fragments is unchanged)
}
}
}
// ... (the logic to execute spawns/despawns is unchanged, but you'll need to
// pass `commands` around or execute them at the end of the system)
// The previous implementation already separated read/execute, so just add the
// explosion spawn to the "execute" phase. A simple way is to use commands
// as shown above, which handles this separation for you.
}
The Grand Finale: A Symphony of Destruction
Build and run your project.
wasm-pack build --target web
Refresh your browser. Fly your ship, take aim at an asteroid, and fire. The moment your bullet hits, you will be rewarded with a beautiful, fleeting explosion animation, perfectly marking the point of impact. The destruction is no longer silent; it’s a spectacle.
You have successfully implemented a core feature of countless games, adding a new layer of polish and excitement to your engine.
Next Steps
Your Asteroids demo is feature-complete in terms of its core mechanics. The final task to turn it into a complete game experience is to build the surrounding game loop: managing player lives, keeping score, and displaying this information on screen. You will build the full Asteroids game loop, including player lives and score.
Further Reading
- Theory of Sprite Sheet Animation: A high-level overview of the techniques and history behind sprite animation.
- MDN
drawImageDocumentation: The definitive guide to the powerfuldrawImagefunction, including the 9-argument version you just used. - Texture Atlases: The concept of a sprite sheet is a simple form of a “texture atlas.” Learn how larger games combine many different game assets into a single image for performance.
Implement Asteroids Game Loop with Scoring, Lives, and UI
Mục tiêu: Finalize an Asteroids game demo by implementing a complete game loop. This involves creating a GameState resource to manage score, lives, and play state, adding player-asteroid collision detection, handling player death, and displaying all relevant information (score, lives, ‘Game Over’ message) in the user interface.
An outstanding accomplishment! Your engine is no longer just a collection of features; it’s a vibrant stage for action. Asteroids shatter, explosions bloom, and a spaceship dances through the chaos under the player’s command. You have built the mechanical heart of a classic arcade game. Now, it’s time to give it a soul—the rules, the stakes, and the feedback loop that transform a simulation into a compelling challenge.
This final task in building your Asteroids demo is about weaving all your systems together to create a complete game loop. We will implement player lives, a scoring system to reward skill, and a user interface to communicate this crucial information. By the end of this task, you will have a fully playable and challenging game.
Step 1: The Game’s Central Nervous System - The GameState Resource
To manage the game’s overall state, we need a single, authoritative source of truth. A simple Score resource was fine for Pong, but Asteroids needs more: lives, score, and a way to know if the game is over. Let’s create a new, more comprehensive GameState resource.
First, open src/resources.rs and define the new structures. We’ll also define an enum to track the current state of play.
// src/resources.rs
// ... (other use statements and resource structs)
// You can now remove the `Score` struct if you wish, as GameState will replace it.
// --- NEW CODE STARTS HERE ---
/// An enum to represent the different states of our game loop.
#[derive(Debug, PartialEq)]
pub enum PlayState {
Playing,
GameOver,
}
/// The GameState resource holds all the global state for the game loop.
#[derive(Resource)]
pub struct GameState {
pub score: u32,
pub lives: u32,
pub state: PlayState,
}
// --- NEW CODE ENDS HERE ---
Next, we must initialize and insert this new resource when the game starts. In Game::new within src/lib.rs, replace the old Score::default() with our new GameState.
// src/lib.rs
// --- Make sure to bring the new resources into scope ---
use crate::resources::{GameState, PlayState};
// ... (in the Game::new function)
impl Game {
pub fn new() -> Self {
// ... (getting canvas, context, creating world is unchanged) ...
// Insert existing resources
// ...
// --- HIGHLIGHTED CHANGE: Replace Score with GameState ---
// world.insert_resource(Score::default()); // REMOVE this line
world.insert_resource(GameState {
score: 0,
lives: 3, // Start the player with 3 lives.
state: PlayState::Playing,
});
// --- END HIGHLIGHTED CHANGE ---
// ... (calling setup_asteroids is unchanged)
}
// ...
}
Step 2: Introducing Danger - Player-Asteroid Collisions
Our CollisionSystem is a specialist; it only looks for bullets hitting asteroids. To create risk, it must also detect when the player’s ship collides with an asteroid.
First, let’s update our CollisionEvent enum in src/events.rs to describe this new, dangerous interaction.
// src/events.rs
// ...
#[derive(Debug, Copy, Clone)]
pub enum CollisionEvent {
BulletAsteroid {
bullet_entity: Entity,
asteroid_entity: Entity,
},
// --- NEW VARIANT ---
PlayerAsteroid {
player_entity: Entity,
asteroid_entity: Entity,
},
}
Now, let’s upgrade the collision_system in src/lib.rs to be a multi-talented detector. It will now perform two separate checks: bullets vs. asteroids, and the player vs. asteroids.
// src/lib.rs
// --- Update `use` statements for this system ---
use crate::components::{Asteroid, Bullet, Collider, Controllable, Position};
// ...
pub fn collision_system(world: &mut World) {
let mut collision_events_this_frame: Vec<CollisionEvent> = Vec::new();
// --- Collect entity data to satisfy the borrow checker ---
let asteroids: Vec<(Entity, &Position, &Collider)> = world
.query_filtered::<(Entity, &Position, &Collider), With<Asteroid>>()
.iter(world)
.collect();
let bullets: Vec<(Entity, &Position, &Collider)> = world
.query_filtered::<(Entity, &Position, &Collider), With<Bullet>>()
.iter(world)
.collect();
// --- NEW: Collect player data ---
let players: Vec<(Entity, &Position, &Collider)> = world
.query_filtered::<(Entity, &Position, &Collider), With<Controllable>>()
.iter(world)
.collect();
// --- Check 1: Bullets vs Asteroids (Unchanged) ---
for (bullet_entity, bullet_pos, bullet_col) in bullets.iter() {
for (asteroid_entity, asteroid_pos, asteroid_col) in asteroids.iter() {
// ... (AABB check is the same)
if /* collision */ {
collision_events_this_frame.push(CollisionEvent::BulletAsteroid { /* ... */ });
}
}
}
// --- HIGHLIGHTED CHANGE: Check 2: Player vs Asteroids ---
for (player_entity, player_pos, player_col) in players.iter() {
for (asteroid_entity, asteroid_pos, asteroid_col) in asteroids.iter() {
// AABB intersection check logic
let a_left = player_pos.x - player_col.width / 2.0;
// ... (full AABB check logic)
let b_bottom = asteroid_pos.y + asteroid_col.height / 2.0;
if a_left < b_right && a_right > b_left && a_top < b_bottom && a_bottom > b_top {
log("Player-Asteroid Collision Detected!"); // For debugging
collision_events_this_frame.push(CollisionEvent::PlayerAsteroid {
player_entity: *player_entity,
asteroid_entity: *asteroid_entity,
});
}
}
}
// --- END HIGHLIGHTED CHANGE ---
// Append all detected events to the resource (Unchanged)
// ...
}
Step 3: The Consequences - Scoring and Player Death
With our new events firing, we need systems to react.
First, let’s add scoring to the destruction_system. When an asteroid is destroyed, points should be awarded.
// src/lib.rs
// ... (in destruction_system)
pub fn destruction_system(world: &mut World) {
// ...
// Get mutable access to the GameState resource at the start.
let Some(mut game_state) = world.get_resource_mut::<GameState>() else { return };
// ... (inside the `for event in events` loop)
if let CollisionEvent::BulletAsteroid { bullet_entity, asteroid_entity } = event {
// ...
if let Some((_, pos, asteroid)) = asteroid_query_results.iter().find(...) {
// ... (spawning explosion logic)
// --- HIGHLIGHTED CHANGE: ADD SCORING ---
match asteroid.size {
AsteroidSize::Large => game_state.score += 20,
AsteroidSize::Medium => game_state.score += 50,
AsteroidSize::Small => game_state.score += 100,
}
log(&format!("Score: {}", game_state.score));
// --- END HIGHLIGHTED CHANGE ---
// ... (spawning fragments logic)
}
}
// ...
}
Next, create a new system to handle the grim reality of player death. Add this function to src/lib.rs.
// src/lib.rs
// --- NEW SYSTEM: player_death_system ---
pub fn player_death_system(world: &mut World) {
let mut commands = Commands::new(world);
let mut despawn_player = None;
// First, read events and game state to decide what to do.
if let Some(mut game_state) = world.get_resource_mut::<GameState>() {
// Only process death if the player is currently playing.
if game_state.state != PlayState::Playing { return; }
let events = world.get_resource::<CollisionEvents>().map_or(Vec::new(), |ev| ev.events.clone());
let player_query = world.query_filtered::<(Entity, &Position), With<Controllable>>();
if let Ok((player_entity, player_pos)) = player_query.get_single(world) {
for event in events {
if let CollisionEvent::PlayerAsteroid { player_entity: pe, .. } = event {
if pe == player_entity {
// Player has collided!
game_state.lives -= 1;
log(&format!("Player hit! Lives remaining: {}", game_state.lives));
// Spawn an explosion at the player's location.
commands.spawn((
Position { x: player_pos.x, y: player_pos.y },
Sprite { /* ... explosion sprite ... */ },
Animation { /* ... explosion animation ... */ },
));
// Mark player for despawn.
despawn_player = Some(player_entity);
if game_state.lives == 0 {
game_state.state = PlayState::GameOver;
log("GAME OVER");
}
break; // Only process one death event per frame.
}
}
}
}
}
// Now, execute the despawn action.
if let Some(player_entity) = despawn_player {
commands.despawn(player_entity);
}
}
Don’t forget to add this new system to your Game::tick loop, right after collision_system.
Step 4: The Final Polish - The Complete UI
Our last step is to provide a clean heads-up display (HUD) for the player. We’ll upgrade our ui_system to show the score, lives, and a “Game Over” message.
// src/lib.rs
// ... (in ui_system)
pub fn ui_system(world: &World, context: &web_sys::CanvasRenderingContext2d) {
let Some(game_state) = world.get_resource::<GameState>() else { return };
let Some(asset_manager) = world.get_resource::<AssetManager>() else { return };
// --- Draw Score ---
let score_text = format!(\"{:06}\", game_state.score); // Padded score
context.set_font(\"32px 'Press Start 2P', sans-serif\"); // A retro font looks great!
context.set_fill_style(&JsValue::from_str(\"white\"));
context.set_text_align(\"left\");
context.fill_text(&score_text, 20.0, 40.0).unwrap();
// --- Draw Lives ---
if let Some(ship_icon) = asset_manager.assets.get(\"assets/player_ship.png\") {
for i in 0..game_state.lives {
context.draw_image_with_html_image_element_and_dw_and_dh(
ship_icon,
20.0 + (i as f64 * 40.0), // Position icons next to each other
60.0,
30.0, // Draw smaller icons
30.0,
).unwrap();
}
}
// --- Draw Game Over Message ---
if game_state.state == PlayState::GameOver {
context.set_font(\"64px 'Press Start 2P', sans-serif\");
context.set_text_align(\"center\");
context.fill_text(
\"GAME OVER\",
(world.get_resource::<GameArea>().unwrap().width / 2.0) as f64,
(world.get_resource::<GameArea>().unwrap().height / 2.0) as f64,
).unwrap();
}
}
Note: For the retro font to work, you may need to load it in your index.html file, for example, from Google Fonts.
You Have Built a Complete Game!
Build and run your project.
wasm-pack build --target web
Refresh your browser. You will see your score in the top-left and your lives represented by ship icons underneath. As you destroy asteroids, your score will climb. If you recklessly fly into an asteroid, your ship will erupt in a familiar explosion, and a life icon will disappear. When your last life is gone, the game will freeze, and a bold “GAME OVER” message will declare your fate.
You have done it. You have constructed a complete, challenging, and rewarding game loop, bringing all the disparate systems of your custom engine together in a symphony of interactive logic.
Possible Enhancements
Congratulations on completing the core project! Your engine and your two demo games are a phenomenal achievement. Should you wish to continue, here are some ideas for enhancements: * Player Respawn: Implement a timer in GameState to respawn the player a few seconds after death, as long as they have lives remaining. The respawn location should be checked for safety (not inside an asteroid). * Level Progression: After all asteroids are cleared, spawn a new, more numerous wave. * UFO Enemy: Create a new enemy type that flies across the screen and shoots at the player, adding a new layer of challenge. * Advanced Physics: Replace the simple AABB collision with more accurate circle-based collision for the asteroids. * Main Menu: Implement a “Start” screen and the ability to restart the game after a “Game Over.”
Further Reading
- Game Programming Patterns: State: A deep dive into the State design pattern, which you have implemented with your
PlayStateenum. This is fundamental for managing complex game flows like menus, playing, and pause states. - Game Feel: A Game Designer’s Guide to Virtual Sensation: A classic book by Steve Swink about the small details—animations, sound, timing, and feedback—that make a game feel responsive and satisfying to play.
- Designing a Heads-Up Display (HUD): Articles on the principles of good UI design in games, focusing on clarity and providing the right information without cluttering the screen.