In 2048, a new tile gets placed on the board after each move from the user.
We're going to use Bevy's event system to trigger the placement of new tiles. To start, create a unit struct to represent the event.
struct NewTileEvent;
And we'll have to add the event to the App builder.
.add_event::<NewTileEvent>()
Then in board_shift we can ask for an EventWriter that can write NewTileEvents. We'll call that tile_writer.
fn board_shift(
query_board: Query<&Board>,
mut commands: Commands,
keyboard_input: Res<Input<KeyCode>>,
mut tiles: Query<(Entity, &mut Position, &mut Points)>,
mut tile_writer: EventWriter<NewTileEvent>,
) {
inside of the if-let for the board shift movement we'll send a NewTileEvent after any valid board movement. EventWriters can send a single event or a batch of events.
if let Some(board_shift) = shift_direction {
...
tile_writer.send(NewTileEvent);
}
We'll create a new system called new_tile_handler to listen to the NewTileEvents. It accesses a couple queries and a few resources which we've seen before. It also accesses an EventReader<NewTileEvent>, which we'll use to check for new events to respond to.
fn new_tile_handler(
mut tile_reader: EventReader<NewTileEvent>,
mut commands: Commands,
query_board: Query<&Board>,
materials: Res<Materials>,
tiles: Query<&Position>,
font_spec: Res<FontSpec>,
) {
let board = query_board
.single()
.expect("expect there to always be a board");
for _event in tile_reader.iter() {
// insert new tile
let mut rng = rand::thread_rng();
let possible_position: Option<Position> = (0
..board.size)
.cartesian_product(0..board.size)
.filter_map(|tile_pos| {
let new_pos = Position {
x: tile_pos.0,
y: tile_pos.1,
};
match tiles
.iter()
.find(|pos| **pos == new_pos)
{
Some(_) => None,
None => Some(new_pos),
}
})
.choose(&mut rng);
if let Some(pos) = possible_position {
spawn_tile(
&mut commands,
&materials,
board,
&font_spec,
pos,
);
}
}
}
tile_reader.iter() will gives us all of the events we haven't handled yet as an Iterator and also clear the event queue for future processing. That means the next time we call .iter() on the reader no events that were created before the current point in time will exist, so we have to handle them now if we want to.
This is the reason we're using a for loop, so that all events are processed. We don't have any data in our event so I've chosen to label it as _event. The underscore is a Rust convention for "we're not using this". If we didn't include it we'd get a warning about an unused event variable.
for _event in tile_reader.iter() {
Using the cartesian_product like we have before, we'll generate an Iterator of all of the possible tiles on the board. Then we'll use filter_map to filter out all of the tiles that already exist.
filter_map allows us to filter and map at the same time. We'll be maping the tile tuples into Positions, and we'll be filtering out any Positions that are already on the board. This will give us an Iterator of all the empty tile positions on the board which we can use along with the random number generator to choose one of the positions.
The find is the other new thing in this code. .iter on a query gives us a reference to the query elements. In this case that means we're iterating over &Position. .find also takes a reference so that leads to the potentially confusing situation in which pos is the type &&Position, or a double-reference to a Position. When we do the comparison we need to compare values at the same "level". The easiest way to do that is to de-reference the pos argument or double reference the new_pos, both will work.
.find(|pos| **pos == new_pos)
.find(|pos| pos == &&new_pos)
To actually compare two Positions we do need to derive PartialEq on Position.
Detour
At this point there is a third option. We're going to take a small detour to explore what that option is but you don't need to understand everything I'm about to talk about yet. Feel free to read through this part and come back to it later. The only purpose of the following explanation is to expose you to words you may hear and understand later in your Rust journey.
The third option is: we can destructure the shared references by including the && before the pos argument. pos is now a Position instead of a &&Position. The destructuring works the same as destructuring any other struct or enum.
.find(|&&pos| pos == new_pos)
To get this to work, we'll need to derive Copy as well. If we don't derive Copy on Position we'll see the following error.
error[E0507]: cannot move out of a shared reference
--> src/main.rs:405:28
|
405 | .find(|&&pos| pos == new_pos)
| ^^---
| | |
| | data moved here
| | move occurs because `pos` has type `Position`, which does not implement the `Copy` trait
| help: consider removing the `&`: `&pos`
The important part of this error is telling us that a move occurs. By default Rust applies move semantics. This is what we're seeing when we pass references around and deal with Ownership in general. We can choose to apply copy semantics instead, which will copy our struct around instead of trying to move it and thus losing ownership over it.
To tell Rust we want copy semantics in this situation, we have to derive Copy for our Position. To derive Copy we also need to derive Clone, which is a supertrait of Copy. If we don't derive Clone, we'll see the following error:
error[E0277]: the trait bound `Position: Clone` is not satisfied
--> src/main.rs:41:28
|
41 | #[derive(Debug, PartialEq, Copy)]
| ^^^^ the trait `Clone` is not implemented for `Position`
|
::: /Users/chris/.rustup/toolchains/stable-x86_64-apple-darwin/lib/rustlib/src/rust/library/core/src/marker.rs:385:17
|
385 | pub trait Copy: Clone {
| ----- required by this bound in `std::marker::Copy`
|
= note: this error originates in a derive macro (in Nightly builds, run with -Z macro-backtrace for more info)
So we end up with this set of derives on our Position.
#[derive(Debug, PartialEq, Copy, Clone)]
struct Position {
x: u8,
y: u8,
}
and that results in the Position in the destructure being copied instead of moved.
::end detour::
If the Iterator we're filter_maping on is empty, .choose will return None into possible_position so we use if-let to only spawn a new tile if we have a position to put it in.
if let Some(pos) = possible_position {
spawn_tile(
&mut commands,
&materials,
board,
&font_spec,
pos,
);
}
spawn_tile is a copy/paste of the same spawning logic from the spawn_tiles system. We take shared references to most of the types we need access to. The only exceptions are commands which we need a mutable reference to so we can call .spawn_bundle, and pos, which takes ownership over a Position so we can .insert it.
fn spawn_tile(
commands: &mut Commands,
materials: &Res<Materials>,
board: &Board,
font_spec: &Res<FontSpec>,
pos: Position,
) {
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: font_spec.family.clone(),
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);
}
Because this is a copy/paste of the logic in the spawn_tiles system, we can replace that spawn logic with the new function as well.
fn spawn_tiles(
mut commands: Commands,
materials: Res<Materials>,
query_board: Query<&Board>,
font_spec: Res<FontSpec>,
) {
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 };
spawn_tile(
&mut commands,
&materials,
board,
&font_spec,
pos,
);
}
}
And now when we run our game, we'll get new tiles every time we move the board.