At some point there won't be any more tiles that can be placed on the board and we won't be able to merge any of the tiles using a board shift. This is when the game ends.
We'll use an end_game
system to check to see if the game should be over.
.add_systems((
render_tile_points,
board_shift,
render_tiles,
new_tile_handler,
end_game,
))
The end_game
system will query for the Position
and Points
components for each tile as well as the Board
itself.
fn end_game(
tiles: Query<(&Position, &Points)>,
query_board: Query<&Board>,
) {
...
}
The game can only be over if the board is full, so we do a quick check to see if there are 16 tiles on the board.
if tiles.iter().len() == 16 {
...
}
We're going to build up a HashMap
. A HashMap
is very similar to a JavaScript object except we can use structs as keys and values. The key in our HashMap
is the Position
and the value is the Points
value of the tile.
To use Position
as the keys in our HashMap
, we will need to add the Eq
and Hash
traits to our derive.
#[derive(
Debug, Component, PartialEq, Copy, Clone, Eq, Hash,
)]
struct Position {
x: u8,
y: u8,
}
We can create the HashMap<&Position, &Points>
in one line since we already have an Iterator
over items of (&Position, &Points)
.
let map: HashMap<&Position, &Points> = tiles.iter().collect();
The HashMap
allows us to access each Points
value by Position
. This helps us because we need to check each Points
value in the left, top, right, and bottom side neighbors of each tile.
We can construct an array of four x,y tuples with the relative values of each tile position. In this case, the first tuple: (-1,0)
means that we're going to check the tile to the left of our current tile.
let neighbor_points = [(-1, 0), (0, 1), (1, 0), (0, -1)];
Then we'll create a Range
of i8
numbers from 0
to the size of the board. We need an i8
because we have negative numbers in our neighbor_points
which when added to a tile position could result in a negative number.
let board_range: Range<i8> = 0..(board.size as i8);
To determine if there is any valid move on the board, we'll iterate over the tiles
and check to see if any of them have a valid move available using any
. The resulting boolean will then tell us whether or not we found a valid move.
We can destructure the x
and y
values out of a Position
to use in the any
closure.
let has_move = tiles
.iter()
.any(|(Position { x, y }, value)| {
...
});
Once we have a tile to work with, we can iterate over the neighbor_points
to get each of the tiles adjacent to the one we have now.
We'll take advantage of filter_map
which lets us both .filter
and .map
at the same time. This is ergonomically useful for us as we plan to return an Option
type, and filter
will remove None
values. We also plan to map the relative positions into the relevant Points
values.
neighbor_points
.iter()
.filter_map(...)
Since we could go negative (think of a tile at Position{ x: 0, y: 0}
) we'll need to convert our u8
x
and y
into i8
s. We do this by derefencing the variable and using as
to cast the type.
In general, we should avoid using as
where possible. There are alternatives in many situations that are preferable such as .from
or .into
which are guaranteed to not fail, or try_from
and try_into
which explicitly encode what failures can happen. If you choose to use as
to cast one numeric type into another, be sure that you're aware of how it can fail for those types.
In our case, the possible values for our x
or y
u8
s will be at most 4
which comfortably fits in both a u8
and an i8
.
let new_x = *x as i8 - x2;
let new_y = *y as i8 - y2;
After generating the tile position for the neighbor tile, we check to see if the new x and y positions exist inside of the board grid. If they don't, for example (-1, 0)
would not, then we return None
because there can be not tile there.
To wrap up our filter_map
we can use try_into
to convert back to u8
and .get
the value from our HashMap
using the new Position
struct. .get
will return an Option<&Points>
if the Position
is valid. We can convert safely back into a u8
because the board_range
check will have caught any negative numbers.
neighbor_points
.iter()
.filter_map(|(x2, y2)| {
let new_x = *x as i8 - x2;
let new_y = *y as i8 - y2;
if !board_range.contains(&new_x)
|| !board_range.contains(&new_y)
{
return None;
};
map.get(&Position {
x: new_x.try_into().unwrap(),
y: new_y.try_into().unwrap(),
})
})
.any(|&v| v == value)
Finally we check to see if .any
of the valid tile Position
s' Points
values can be merged with the tile we're checking.
To compare two Points
values we need to derive the PartialEq
trait for Points
.
#[derive(Debug, Component, PartialEq)]
struct Points {
value: u32,
}
All in all, we check all of the neighbor tiles for each tile in the board and return as soon as we find one valid move. If there turns out to be no valid move, then the game is over.
if has_move == false {
dbg!("game over!");
}
Note that in the current state, the game over!
notification will spam the console because the system is still running every tick.