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(
mut commands: Commands,
input: Res<Input<KeyCode>>,
mut tiles: Query<(Entity, &mut Position, &mut Points)>,
query_board: Query<&Board>,
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>,
tiles: Query<&Position>,
font_spec: Res<FontSpec>,
) {
let board = query_board.single();
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,
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
.
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,
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
, and pos
, which takes ownership over a Position
so we can .insert
it.
fn spawn_tile(
commands: &mut Commands,
board: &Board,
font_spec: &Res<FontSpec>,
pos: Position,
) {
commands
.spawn(SpriteBundle {
sprite: Sprite {
color: colors::TILE,
custom_size: Some(Vec2::new(
TILE_SIZE, TILE_SIZE,
)),
..default()
},
transform: Transform::from_xyz(
board.cell_position_to_physical(pos.x),
board.cell_position_to_physical(pos.y),
2.0,
),
..default()
})
.with_children(|child_builder| {
child_builder
.spawn(Text2dBundle {
text: Text::from_section(
"2",
TextStyle {
font: font_spec.family.clone(),
font_size: 40.0,
color: Color::BLACK,
},
)
.with_alignment(TextAlignment::Center),
transform: Transform::from_xyz(
0.0, 0.0, 1.0,
),
..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,
query_board: Query<&Board>,
font_spec: Res<FontSpec>,
) {
let board = query_board.single();
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, board, &font_spec, pos);
}
}
Don’t forget to add the new_tile_handler
system to our running systems.
.add_systems((
render_tile_points,
board_shift,
render_tiles,
new_tile_handler,
))
And now when we run our game, we'll get new tiles every time we move the board.