A simple minesweeper-style game in Bevy UI.
use bevy::color::palettes::css::{DARK_GREEN, LIME, ORANGE, ORANGE_RED, RED, SEA_GREEN, YELLOW};
use bevy::prelude::*;
use bevy::text::LineHeight;
use rand::seq::SliceRandom;
use std::ops::Range;
const TILE_SIZE: f32 = 30.0;
const TILE_GAP: f32 = 6.0;
const BOARD_PADDING: f32 = 15.0;
const BACKGROUND_COLOR: Color = Color::Srgba(DARK_GREEN);
const TILE_BORDER_COLOR: Color = Color::Srgba(SEA_GREEN);
const HOVERED_TILE_BORDER_COLOR: Color = Color::Srgba(LIME);
const MILD_DANGER_COLOR: Color = Color::Srgba(YELLOW);
const SOME_DANGER_COLOR: Color = Color::Srgba(ORANGE);
const VERY_DANGER_COLOR: Color = Color::Srgba(ORANGE_RED);
const MOST_DANGER_COLOR: Color = Color::Srgba(RED);
#[derive(Default, Clone, Copy)]
struct Tile {
mined: bool,
revealed: bool,
flagged: bool,
}
struct MineField {
width: i32,
height: i32,
tiles: Vec<Tile>,
}
impl MineField {
fn xs(&self) -> Range<i32> {
0..self.width
}
fn ys(&self) -> Range<i32> {
0..self.height
}
fn get(&self, p: IVec2) -> Option<usize> {
if self.xs().contains(&p.x) && self.ys().contains(&p.y) {
Some((p.y * self.width + p.x) as usize)
} else {
None
}
}
fn new(width: i32, height: i32, mines: usize) -> Self {
let mut tiles = vec![Tile::default(); (width * height) as usize];
tiles
.iter_mut()
.take(mines)
.for_each(|tile| tile.mined = true);
tiles.shuffle(&mut rand::rng());
Self {
width,
height,
tiles,
}
}
fn get_adjacent(&self, target: IVec2) -> impl Iterator<Item = IVec2> + '_ {
[
(1, 1),
(1, 0),
(1, -1),
(0, 1),
(0, -1),
(-1, 1),
(-1, 0),
(-1, -1),
]
.map(|d| target + IVec2::from(d))
.into_iter()
.filter(|adjacent| self.get(*adjacent).is_some())
}
fn count_adjacent_mines(&self, target: IVec2) -> usize {
self.get_adjacent(target)
.filter(|&adjacent| self[adjacent].mined)
.count()
}
fn reveal(&mut self, target: IVec2) {
let mut open = vec![target];
while let Some(current) = open.pop() {
if !self[current].revealed {
self[current].revealed = true;
if self.count_adjacent_mines(current) == 0 {
open.extend(self.get_adjacent(current));
}
}
}
}
fn iter(&self) -> impl Iterator<Item = (IVec2, Tile)> + '_ {
self.ys()
.flat_map(|y| self.xs().map(move |x| IVec2::new(x, y)))
.map(|tile| (tile, self[tile]))
}
fn flag_count(&self) -> usize {
self.tiles.iter().filter(|tile| tile.flagged).count()
}
fn mine_count(&self) -> usize {
self.tiles.iter().filter(|tile| tile.mined).count()
}
fn is_cleared(&self) -> bool {
self.tiles
.iter()
.all(|tile| tile.revealed && !tile.mined || tile.mined && tile.flagged)
}
}
impl std::ops::Index<IVec2> for MineField {
type Output = Tile;
fn index(&self, tile: IVec2) -> &Self::Output {
let index = self.get(tile).unwrap();
&self.tiles[index]
}
}
impl std::ops::IndexMut<IVec2> for MineField {
fn index_mut(&mut self, tile: IVec2) -> &mut Self::Output {
let index = self.get(tile).unwrap();
&mut self.tiles[index]
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Difficulty {
Easy,
Normal,
Hard,
}
impl Difficulty {
fn field(self) -> MineField {
match self {
Difficulty::Easy => MineField::new(15, 10, 15),
Difficulty::Normal => MineField::new(20, 15, 50),
Difficulty::Hard => MineField::new(25, 15, 75),
}
}
}
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Hash, States)]
enum GameState {
#[default]
Menu,
Playing,
}
#[derive(Resource)]
struct Game {
field: MineField,
game_over: bool,
}
impl Default for Game {
fn default() -> Self {
Self {
field: Difficulty::Normal.field(),
game_over: false,
}
}
}
#[derive(Component)]
struct GameRoot;
#[derive(Component)]
struct TileCell(IVec2);
#[derive(Component, Clone, Copy)]
enum ButtonAction {
NewGame(Difficulty),
Menu,
}
fn text_button(label: &'static str, action: ButtonAction) -> impl Bundle {
(
action,
Node {
min_width: px(144),
border: px(3).all(),
border_radius: px(8).into(),
box_sizing: BoxSizing::ContentBox,
..default()
},
BorderColor::all(TILE_BORDER_COLOR),
BackgroundColor(BACKGROUND_COLOR),
Text::new(label),
TextFont::from_font_size(24.0),
TextLayout::justify(Justify::Center),
LineHeight::Px(40.),
)
}
fn setup(mut commands: Commands) {
commands.spawn(Camera2d);
}
fn setup_menu(mut commands: Commands) {
commands.spawn((
DespawnOnExit(GameState::Menu),
Node {
width: percent(100),
height: percent(100),
flex_direction: FlexDirection::Column,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
row_gap: px(20),
..default()
},
children![
(Text::new("Bevy Mines"), TextFont::from_font_size(35.0)),
(
Node {
flex_direction: FlexDirection::Column,
row_gap: px(10),
..default()
},
children![
text_button("easy", ButtonAction::NewGame(Difficulty::Easy)),
text_button("normal", ButtonAction::NewGame(Difficulty::Normal)),
text_button("hard", ButtonAction::NewGame(Difficulty::Hard)),
],
),
],
));
}
fn setup_game(mut commands: Commands, game: Res<Game>, assets: Res<AssetServer>) {
let root = commands
.spawn((
GameRoot,
DespawnOnExit(GameState::Playing),
Node {
margin: auto().all(),
..default()
},
))
.id();
rebuild_game_ui(&mut commands, root, &game, &assets);
}
fn rebuild_game_ui(commands: &mut Commands, root: Entity, game: &Game, assets: &AssetServer) {
commands.entity(root).despawn_related::<Children>();
commands.entity(root).with_children(|parent| {
parent
.spawn(Node {
flex_direction: FlexDirection::Column,
row_gap: px(20),
..default()
})
.with_children(|parent| {
parent
.spawn((
Node {
display: Display::Grid,
padding: UiRect::all(px(BOARD_PADDING)),
border_radius: BorderRadius::all(px(25)),
row_gap: px(TILE_GAP),
column_gap: px(TILE_GAP),
grid_template_columns: RepeatedGridTrack::px(
game.field.width,
TILE_SIZE,
),
grid_template_rows: RepeatedGridTrack::px(game.field.height, TILE_SIZE),
..default()
},
BackgroundColor(BACKGROUND_COLOR),
))
.with_children(|parent| {
for (position, tile) in game.field.iter() {
let tile_node = Node {
grid_column: GridPlacement::start((position.x + 1) as i16),
grid_row: GridPlacement::start((position.y + 1) as i16),
border: px(4).all(),
border_radius: px(8).into(),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
};
if game.game_over && tile.mined {
parent.spawn((
tile_node,
BackgroundColor(BACKGROUND_COLOR),
BorderColor::all(Color::NONE),
Pickable::IGNORE,
children![(
Text::new("*"),
TextFont::from_font_size(24.0),
TextColor(MOST_DANGER_COLOR),
)],
));
} else if game.game_over || tile.revealed {
let mine_count = game.field.count_adjacent_mines(position);
let color = match mine_count {
0 => continue,
1 => MILD_DANGER_COLOR,
2 => SOME_DANGER_COLOR,
3 => VERY_DANGER_COLOR,
_ => MOST_DANGER_COLOR,
};
parent.spawn((
tile_node,
BackgroundColor(BACKGROUND_COLOR),
BorderColor::all(color),
Pickable::IGNORE,
children![(
Text::new(mine_count.to_string()),
TextFont::from_font_size(20.0),
TextColor(color),
)],
));
} else {
parent
.spawn((
tile_node,
TileCell(position),
BackgroundColor(BACKGROUND_COLOR),
BorderColor::all(TILE_BORDER_COLOR),
))
.with_children(|parent| {
if tile.flagged {
parent.spawn((
ImageNode::new(assets.load("textures/flag.png")),
Node {
width: px(20),
height: px(20),
..default()
},
Pickable::IGNORE,
));
}
});
}
}
});
parent
.spawn(Node {
width: percent(100),
height: px(50),
margin: px(15).vertical(),
justify_content: JustifyContent::SpaceBetween,
align_items: AlignItems::Center,
..default()
})
.with_children(|parent| {
parent.spawn((
Node {
align_items: AlignItems::Center,
column_gap: px(3),
..default()
},
children![
(
ImageNode::new(assets.load("textures/flag.png")),
Node {
width: px(28),
height: px(28),
..default()
},
Pickable::IGNORE,
),
(
Text::new(
(game.field.mine_count() - game.field.flag_count())
.to_string()
),
TextFont::from_font_size(24.0),
)
],
));
if game.field.is_cleared() || game.game_over {
parent.spawn((
Node {
width: percent(100),
height: percent(100),
position_type: PositionType::Absolute,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
Pickable::IGNORE,
children![(
Text::new(if game.field.is_cleared() {
"mines cleared!"
} else {
"boom!"
}),
TextFont::from_font_size(24.0),
)],
));
parent.spawn(text_button("new game", ButtonAction::Menu));
}
});
});
});
}
fn on_button_click(
click: On<PointerClick>,
buttons: Query<&ButtonAction>,
mut game: ResMut<Game>,
mut next_state: ResMut<NextState<GameState>>,
) {
if click.button != PointerButton::Primary {
return;
}
let Ok(action) = buttons.get(click.event_target()) else {
return;
};
match *action {
ButtonAction::NewGame(difficulty) => {
game.field = difficulty.field();
game.game_over = false;
next_state.set(GameState::Playing);
}
ButtonAction::Menu => {
next_state.set(GameState::Menu);
}
}
}
fn on_button_over(
over: On<PointerOver>,
mut buttons: Query<(&mut BorderColor, &mut BackgroundColor), With<ButtonAction>>,
) {
if let Ok((mut border, mut background)) = buttons.get_mut(over.event_target()) {
border.set_all(HOVERED_TILE_BORDER_COLOR);
background.0 = BACKGROUND_COLOR;
}
}
fn on_button_out(
out: On<PointerOut>,
mut buttons: Query<(&mut BorderColor, &mut BackgroundColor), With<ButtonAction>>,
) {
if let Ok((mut border, mut background)) = buttons.get_mut(out.event_target()) {
border.set_all(TILE_BORDER_COLOR);
background.0 = BACKGROUND_COLOR;
}
}
fn on_tile_over(over: On<PointerOver>, mut tiles: Query<&mut BorderColor, With<TileCell>>) {
if let Ok(mut border) = tiles.get_mut(over.event_target()) {
border.set_all(HOVERED_TILE_BORDER_COLOR);
}
}
fn on_tile_out(out: On<PointerOut>, mut tiles: Query<&mut BorderColor, With<TileCell>>) {
if let Ok(mut border) = tiles.get_mut(out.event_target()) {
border.set_all(TILE_BORDER_COLOR);
}
}
fn on_tile_click(
click: On<PointerClick>,
tiles: Query<&TileCell>,
mut game: ResMut<Game>,
mut commands: Commands,
game_root: Single<Entity, With<GameRoot>>,
assets: Res<AssetServer>,
) {
if game.game_over || game.field.is_cleared() {
return;
}
let Ok(&TileCell(position)) = tiles.get(click.event_target()) else {
return;
};
match click.button {
PointerButton::Primary => {
if game.field[position].mined {
game.game_over = true;
} else {
game.field.reveal(position);
}
}
PointerButton::Secondary => {
if game.field[position].flagged {
game.field[position].flagged = false;
} else if game.field.flag_count() < game.field.mine_count() {
game.field[position].flagged = true;
}
}
_ => {
return;
}
}
rebuild_game_ui(&mut commands, *game_root, &game, &assets);
}
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.init_resource::<Game>()
.init_state::<GameState>()
.add_systems(Startup, setup)
.add_systems(OnEnter(GameState::Menu), setup_menu)
.add_systems(OnEnter(GameState::Playing), setup_game)
.add_observer(on_button_click)
.add_observer(on_button_over)
.add_observer(on_button_out)
.add_observer(on_tile_click)
.add_observer(on_tile_over)
.add_observer(on_tile_out)
.run();
}