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 NewTileEvent
s. 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. EventWriter
s 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 NewTileEvent
s. 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 map
ing the tile tuples into Position
s, and we'll be filtering out any Position
s 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 Position
s 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 derive
s 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_map
ing 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.