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(
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. 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>,
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 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.
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,
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.