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 i8s. 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 u8s 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 Positions' 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.