To start off a 2048 game we need to spawn two tiles onto the board. To do that we're going to create a new startup system that will handle picking board positions and spawning the tiles.
We're going to create three new Components to help us with the playing tiles. Remember that Components hold state or are labels for entities.
Our first component, Points
will hold the value of the tile. In 2048, that value is in the sequence 2^n: 2,4,8,16,32,etc because our tiles start at 2 and combine with tiles of the same point value.
struct Points {
value: u32,
}
Our second component holds the position of the tile on the grid with an x and a y value.
struct Position {
x: u8,
y: u8,
}
Then we create a unit struct, that is: a struct with no fields, to tag the text so we can find it later.
struct TileText;
The spawn_tiles System
The spawn_tiles
system is going to be responsible for spawning two tiles to start the game.
The system will need
Commands
to spawn tile spritesRes<Materials>
to set the color on the tileQuery<&Board>
to get the board size
fn spawn_tiles(
mut commands: Commands,
materials: Res<Materials>,
query_board: Query<&Board>,
) {
let board = query_board
.single()
.expect("always expect a board");
let mut rng = rand::thread_rng();
let starting_tiles: Vec<(u8, u8)> = (0..board.size)
.cartesian_product(0..board.size)
.choose_multiple(&mut rng, 2);
for (x, y) in starting_tiles.iter() {
let pos = Position { x: *x, y: *y };
commands
.spawn_bundle(SpriteBundle {
material: materials.tile.clone(),
sprite: Sprite::new(Vec2::new(
TILE_SIZE, TILE_SIZE,
)),
transform: Transform::from_xyz(
board.cell_position_to_physical(pos.x),
board.cell_position_to_physical(pos.y),
1.0,
),
..Default::default()
})
.with_children(|child_builder| {
child_builder
.spawn_bundle(Text2dBundle {
text: Text::with_section(
"2",
TextStyle {
font_size: 40.0,
color: Color::BLACK,
..Default::default()
},
TextAlignment {
vertical:
VerticalAlign::Center,
horizontal:
HorizontalAlign::Center,
},
),
transform: Transform::from_xyz(
0.0, 0.0, 1.0,
),
..Default::default()
})
.insert(TileText);
})
.insert(Points { value: 2 })
.insert(pos);
}
}
The board query will only get one result ever because we only have one board, so we can use .single()
on the query to get the single board. single
returns a result, so we can .expect
it since if we don't have a board, we can't continue.
let board = query_board
.single()
.expect("always expect a board");
Then we'll use the rand
crate to set up a random number generator. After generating all of the possible grid locations, the random number generator will allow us to choose two of them, randomly.
starting_tiles
will be our two tiles to spawn on the board. We store them in a Vec
as a two-tuple of u8
numbers. We already talked about using .cartesian_product
to generate all the grid tiles and we do the same here.
After generating the iterator of (u8,u8)
, we can chain .choose_multiple
to use the random number generator to pick two of them.
let mut rng = rand::thread_rng();
let starting_tiles: Vec<(u8, u8)> = (0..board.size)
.cartesian_product(0..board.size)
.choose_multiple(&mut rng, 2);
choose_multiple
comes from an extension trait in the rand
prelude, so we'll want to bring that trait into scope as well.
use rand::prelude::*;
with the starting_tiles
picked out, we can iterate over the tuples and insert each of the titles. First we'll destructure the x and y values for each item using a for loop.
After destructuring the values we can create a new Position
component with the x and y values. We do this early in the functions because it makes it a bit more clear when we use it later on.
Iterating over the starting_tiles
gives us a reference into the tuple values, so we need to dereference them to construct the Position
component with the x and y u8 values, not references to the u8 values.
for (x, y) in starting_tiles.iter() {
let pos = Position { x: *x, y: *y };
Spawning a SpriteBundle
using commands
is the same as spawning the board and the tile placeholders. The sprite will be TILE_SIZE
width and height size. The materials will be a new tile
color that we'll add to Materials
, and we fill in the rest of the fields with the Default
s.
commands
.spawn_bundle(SpriteBundle {
material: materials.tile.clone(),
sprite: Sprite::new(Vec2::new(
TILE_SIZE, TILE_SIZE,
)),
transform: Transform::from_xyz(
board.cell_position_to_physical(pos.x),
board.cell_position_to_physical(pos.y),
1.0,
),
..Default::default()
})
Which leaves us with the Transform
. The Transform
will be the same calculation that we used to place the tile_placeholder
s, so instead of copying that logic we'll implement two new methods in the context of the Board
struct: Board::new
and Board::cell_position_to_physical
.
Board::new
will instantiate a new Board
struct for us. The pattern of defining a new
method is fairly common in the Rust ecosystem. We can take advantage of this to add an additional physical_size
field on the struct, even though the user will never need to know how it's created.
Self
in this case refers to Board
because that's the type we're implementing methods for.
cell_position_to_physical
is a copy/paste of the logic we've already defined. The biggest difference is that we're now using self.physical_size
because we can call this method on an instantiated struct, which means we have access to the fields on the Board
struct. cell_position_to_physical
will turn a grid position like 0,1,2,3 into a physical position like 10.0, 50.0, etc.
struct Board {
size: u8,
physical_size: f32,
}
impl Board {
fn new(size: u8) -> Self {
let physical_size = f32::from(size) * TILE_SIZE
+ f32::from(size + 1) * TILE_SPACER;
Board {
size,
physical_size,
}
}
fn cell_position_to_physical(&self, pos: u8) -> f32 {
let offset =
-self.physical_size / 2.0 + 0.5 * TILE_SIZE;
offset
+ f32::from(pos) * TILE_SIZE
+ f32::from(pos + 1) * TILE_SPACER
}
}
In spawn_board
we can now instantiate the board with
let board = Board::new(4);
and use board.physical_size
in our spawn_bundle
.
commands
.spawn_bundle(SpriteBundle {
material: materials.board.clone(),
sprite: Sprite::new(Vec2::new(
board.physical_size,
board.physical_size,
)),
..Default::default()
})
Our Transform
component for the tiles and tile_placeholders can now use the board to translate an x or y into a physical position, cleaning up our code quite a bit by removing the math bits.
transform: Transform::from_xyz(
board.cell_position_to_physical(pos.x),
board.cell_position_to_physical(pos.y),
1.0,
),
The tile sprite children will be a Text2dBundle
to display the value of each tile. Text
uses sections to define updateable content. We initialize the displayed string to "2"
because that will always be the starting value.
We also insert the TileText
label component on the entity we got from spawning the Text2dBundle
so we can find it later for all tiles.
.with_children(|child_builder| {
child_builder
.spawn_bundle(Text2dBundle {
text: Text::with_section(
"2",
TextStyle {
font_size: 40.0,
color: Color::BLACK,
..Default::default()
},
TextAlignment {
vertical:
VerticalAlign::Center,
horizontal:
HorizontalAlign::Center,
},
),
transform: Transform::from_xyz(
0.0, 0.0, 1.0,
),
..Default::default()
})
.insert(TileText);
})
Finally we insert the Points
component with a default value of 2 and the Position
component we created earlier.
.insert(Points { value: 2 })
.insert(pos);
After building out the system, we can add this as a startup system:
.add_startup_system(spawn_tiles.system())
but the game immediately panics and crashes.
thread 'main' panicked at 'always expect a board: NoEntities("bevy_ecs::system::query::Query<&boxes::Board>")',
This is because spawn_tiles
depends on the board being present, so we need to add some ordering to our startup systems.
We can add order in two ways, one is explicit system ordering using labels. This works well for systems that don't rely on other system's Commands
because Commands
only commit at the end of the stage. The second way is to use more stages. Stages are effectively hard synchronization points and are the only way to ensure that all commands have been comitted.
For us, we add the Board
in our spawn_board
startup system via Commands
and access it in spawn_tiles
, which means we have to use Stages. Bevy has a variety of stages for different purposes; it's how Bevy ensures core logic runs at the appropriate time compared to our code. There are three StartupStage
s: PreStartup
, Startup
, and PostStartup
. We'll use .add_startup_system_to_stage
to add our system to StartupStage::PostStartup
which will ensure our system runs after the rest of our startup systems which run in the Startup
stage.
fn main() {
App::build()
.add_plugins(DefaultPlugins)
.init_resource::<Materials>()
.add_startup_system(setup.system())
.add_startup_system(spawn_board.system())
.add_startup_system_to_stage(
StartupStage::PostStartup,
spawn_tiles.system(),
)
.run()
}