Skip to main content

mines/
mines.rs

1//! A simple minesweeper-style game in Bevy UI.
2
3use bevy::color::palettes::css::{DARK_GREEN, LIME, ORANGE, ORANGE_RED, RED, SEA_GREEN, YELLOW};
4use bevy::prelude::*;
5use bevy::text::LineHeight;
6use rand::seq::SliceRandom;
7use std::ops::Range;
8
9const TILE_SIZE: f32 = 30.0;
10const TILE_GAP: f32 = 6.0;
11const BOARD_PADDING: f32 = 15.0;
12
13const BACKGROUND_COLOR: Color = Color::Srgba(DARK_GREEN);
14const TILE_BORDER_COLOR: Color = Color::Srgba(SEA_GREEN);
15const HOVERED_TILE_BORDER_COLOR: Color = Color::Srgba(LIME);
16const MILD_DANGER_COLOR: Color = Color::Srgba(YELLOW);
17const SOME_DANGER_COLOR: Color = Color::Srgba(ORANGE);
18const VERY_DANGER_COLOR: Color = Color::Srgba(ORANGE_RED);
19const MOST_DANGER_COLOR: Color = Color::Srgba(RED);
20
21#[derive(Default, Clone, Copy)]
22struct Tile {
23    mined: bool,
24    revealed: bool,
25    flagged: bool,
26}
27
28struct MineField {
29    width: i32,
30    height: i32,
31    tiles: Vec<Tile>,
32}
33
34impl MineField {
35    fn xs(&self) -> Range<i32> {
36        0..self.width
37    }
38
39    fn ys(&self) -> Range<i32> {
40        0..self.height
41    }
42
43    fn get(&self, p: IVec2) -> Option<usize> {
44        if self.xs().contains(&p.x) && self.ys().contains(&p.y) {
45            Some((p.y * self.width + p.x) as usize)
46        } else {
47            None
48        }
49    }
50
51    fn new(width: i32, height: i32, mines: usize) -> Self {
52        let mut tiles = vec![Tile::default(); (width * height) as usize];
53        tiles
54            .iter_mut()
55            .take(mines)
56            .for_each(|tile| tile.mined = true);
57
58        tiles.shuffle(&mut rand::rng());
59
60        Self {
61            width,
62            height,
63            tiles,
64        }
65    }
66
67    fn get_adjacent(&self, target: IVec2) -> impl Iterator<Item = IVec2> + '_ {
68        [
69            (1, 1),
70            (1, 0),
71            (1, -1),
72            (0, 1),
73            (0, -1),
74            (-1, 1),
75            (-1, 0),
76            (-1, -1),
77        ]
78        .map(|d| target + IVec2::from(d))
79        .into_iter()
80        .filter(|adjacent| self.get(*adjacent).is_some())
81    }
82
83    fn count_adjacent_mines(&self, target: IVec2) -> usize {
84        self.get_adjacent(target)
85            .filter(|&adjacent| self[adjacent].mined)
86            .count()
87    }
88
89    fn reveal(&mut self, target: IVec2) {
90        let mut open = vec![target];
91        while let Some(current) = open.pop() {
92            if !self[current].revealed {
93                self[current].revealed = true;
94                if self.count_adjacent_mines(current) == 0 {
95                    open.extend(self.get_adjacent(current));
96                }
97            }
98        }
99    }
100
101    fn iter(&self) -> impl Iterator<Item = (IVec2, Tile)> + '_ {
102        self.ys()
103            .flat_map(|y| self.xs().map(move |x| IVec2::new(x, y)))
104            .map(|tile| (tile, self[tile]))
105    }
106
107    fn flag_count(&self) -> usize {
108        self.tiles.iter().filter(|tile| tile.flagged).count()
109    }
110
111    fn mine_count(&self) -> usize {
112        self.tiles.iter().filter(|tile| tile.mined).count()
113    }
114
115    fn is_cleared(&self) -> bool {
116        self.tiles
117            .iter()
118            .all(|tile| tile.revealed && !tile.mined || tile.mined && tile.flagged)
119    }
120}
121
122impl std::ops::Index<IVec2> for MineField {
123    type Output = Tile;
124
125    fn index(&self, tile: IVec2) -> &Self::Output {
126        let index = self.get(tile).unwrap();
127        &self.tiles[index]
128    }
129}
130
131impl std::ops::IndexMut<IVec2> for MineField {
132    fn index_mut(&mut self, tile: IVec2) -> &mut Self::Output {
133        let index = self.get(tile).unwrap();
134        &mut self.tiles[index]
135    }
136}
137
138#[derive(Clone, Copy, PartialEq, Eq)]
139enum Difficulty {
140    Easy,
141    Normal,
142    Hard,
143}
144
145impl Difficulty {
146    fn field(self) -> MineField {
147        match self {
148            Difficulty::Easy => MineField::new(15, 10, 15),
149            Difficulty::Normal => MineField::new(20, 15, 50),
150            Difficulty::Hard => MineField::new(25, 15, 75),
151        }
152    }
153}
154
155#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Hash, States)]
156enum GameState {
157    #[default]
158    Menu,
159    Playing,
160}
161
162#[derive(Resource)]
163struct Game {
164    field: MineField,
165    game_over: bool,
166}
167
168impl Default for Game {
169    fn default() -> Self {
170        Self {
171            field: Difficulty::Normal.field(),
172            game_over: false,
173        }
174    }
175}
176
177#[derive(Component)]
178struct GameRoot;
179
180#[derive(Component)]
181struct TileCell(IVec2);
182
183#[derive(Component, Clone, Copy)]
184enum ButtonAction {
185    NewGame(Difficulty),
186    Menu,
187}
188
189fn text_button(label: &'static str, action: ButtonAction) -> impl Bundle {
190    (
191        action,
192        Node {
193            min_width: px(144),
194            border: px(3).all(),
195            border_radius: px(8).into(),
196            box_sizing: BoxSizing::ContentBox,
197            ..default()
198        },
199        BorderColor::all(TILE_BORDER_COLOR),
200        BackgroundColor(BACKGROUND_COLOR),
201        Text::new(label),
202        TextFont::from_font_size(24.0),
203        TextLayout::justify(Justify::Center),
204        LineHeight::Px(40.),
205    )
206}
207
208fn setup(mut commands: Commands) {
209    commands.spawn(Camera2d);
210}
211
212fn setup_menu(mut commands: Commands) {
213    commands.spawn((
214        DespawnOnExit(GameState::Menu),
215        Node {
216            width: percent(100),
217            height: percent(100),
218            flex_direction: FlexDirection::Column,
219            justify_content: JustifyContent::Center,
220            align_items: AlignItems::Center,
221            row_gap: px(20),
222            ..default()
223        },
224        children![
225            (Text::new("Bevy Mines"), TextFont::from_font_size(35.0)),
226            (
227                Node {
228                    flex_direction: FlexDirection::Column,
229                    row_gap: px(10),
230                    ..default()
231                },
232                children![
233                    text_button("easy", ButtonAction::NewGame(Difficulty::Easy)),
234                    text_button("normal", ButtonAction::NewGame(Difficulty::Normal)),
235                    text_button("hard", ButtonAction::NewGame(Difficulty::Hard)),
236                ],
237            ),
238        ],
239    ));
240}
241
242fn setup_game(mut commands: Commands, game: Res<Game>, assets: Res<AssetServer>) {
243    let root = commands
244        .spawn((
245            GameRoot,
246            DespawnOnExit(GameState::Playing),
247            Node {
248                margin: auto().all(),
249                ..default()
250            },
251        ))
252        .id();
253
254    rebuild_game_ui(&mut commands, root, &game, &assets);
255}
256
257fn rebuild_game_ui(commands: &mut Commands, root: Entity, game: &Game, assets: &AssetServer) {
258    commands.entity(root).despawn_related::<Children>();
259
260    commands.entity(root).with_children(|parent| {
261        parent
262            .spawn(Node {
263                flex_direction: FlexDirection::Column,
264                row_gap: px(20),
265                ..default()
266            })
267            .with_children(|parent| {
268                parent
269                    .spawn((
270                        Node {
271                            display: Display::Grid,
272                            padding: UiRect::all(px(BOARD_PADDING)),
273                            border_radius: BorderRadius::all(px(25)),
274                            row_gap: px(TILE_GAP),
275                            column_gap: px(TILE_GAP),
276                            grid_template_columns: RepeatedGridTrack::px(
277                                game.field.width,
278                                TILE_SIZE,
279                            ),
280                            grid_template_rows: RepeatedGridTrack::px(game.field.height, TILE_SIZE),
281                            ..default()
282                        },
283                        BackgroundColor(BACKGROUND_COLOR),
284                    ))
285                    .with_children(|parent| {
286                        for (position, tile) in game.field.iter() {
287                            let tile_node = Node {
288                                grid_column: GridPlacement::start((position.x + 1) as i16),
289                                grid_row: GridPlacement::start((position.y + 1) as i16),
290                                border: px(4).all(),
291                                border_radius: px(8).into(),
292                                justify_content: JustifyContent::Center,
293                                align_items: AlignItems::Center,
294                                ..default()
295                            };
296
297                            if game.game_over && tile.mined {
298                                parent.spawn((
299                                    tile_node,
300                                    BackgroundColor(BACKGROUND_COLOR),
301                                    BorderColor::all(Color::NONE),
302                                    Pickable::IGNORE,
303                                    children![(
304                                        Text::new("*"),
305                                        TextFont::from_font_size(24.0),
306                                        TextColor(MOST_DANGER_COLOR),
307                                    )],
308                                ));
309                            } else if game.game_over || tile.revealed {
310                                let mine_count = game.field.count_adjacent_mines(position);
311                                let color = match mine_count {
312                                    0 => continue,
313                                    1 => MILD_DANGER_COLOR,
314                                    2 => SOME_DANGER_COLOR,
315                                    3 => VERY_DANGER_COLOR,
316                                    _ => MOST_DANGER_COLOR,
317                                };
318                                parent.spawn((
319                                    tile_node,
320                                    BackgroundColor(BACKGROUND_COLOR),
321                                    BorderColor::all(color),
322                                    Pickable::IGNORE,
323                                    children![(
324                                        Text::new(mine_count.to_string()),
325                                        TextFont::from_font_size(20.0),
326                                        TextColor(color),
327                                    )],
328                                ));
329                            } else {
330                                parent
331                                    .spawn((
332                                        tile_node,
333                                        TileCell(position),
334                                        BackgroundColor(BACKGROUND_COLOR),
335                                        BorderColor::all(TILE_BORDER_COLOR),
336                                    ))
337                                    .with_children(|parent| {
338                                        if tile.flagged {
339                                            parent.spawn((
340                                                ImageNode::new(assets.load("textures/flag.png")),
341                                                Node {
342                                                    width: px(20),
343                                                    height: px(20),
344                                                    ..default()
345                                                },
346                                                Pickable::IGNORE,
347                                            ));
348                                        }
349                                    });
350                            }
351                        }
352                    });
353
354                parent
355                    .spawn(Node {
356                        width: percent(100),
357                        height: px(50),
358                        margin: px(15).vertical(),
359                        justify_content: JustifyContent::SpaceBetween,
360                        align_items: AlignItems::Center,
361                        ..default()
362                    })
363                    .with_children(|parent| {
364                        parent.spawn((
365                            Node {
366                                align_items: AlignItems::Center,
367                                column_gap: px(3),
368                                ..default()
369                            },
370                            children![
371                                (
372                                    ImageNode::new(assets.load("textures/flag.png")),
373                                    Node {
374                                        width: px(28),
375                                        height: px(28),
376                                        ..default()
377                                    },
378                                    Pickable::IGNORE,
379                                ),
380                                (
381                                    Text::new(
382                                        (game.field.mine_count() - game.field.flag_count())
383                                            .to_string()
384                                    ),
385                                    TextFont::from_font_size(24.0),
386                                )
387                            ],
388                        ));
389
390                        if game.field.is_cleared() || game.game_over {
391                            parent.spawn((
392                                Node {
393                                    width: percent(100),
394                                    height: percent(100),
395                                    position_type: PositionType::Absolute,
396                                    justify_content: JustifyContent::Center,
397                                    align_items: AlignItems::Center,
398                                    ..default()
399                                },
400                                Pickable::IGNORE,
401                                children![(
402                                    Text::new(if game.field.is_cleared() {
403                                        "mines cleared!"
404                                    } else {
405                                        "boom!"
406                                    }),
407                                    TextFont::from_font_size(24.0),
408                                )],
409                            ));
410
411                            parent.spawn(text_button("new game", ButtonAction::Menu));
412                        }
413                    });
414            });
415    });
416}
417
418fn on_button_click(
419    click: On<PointerClick>,
420    buttons: Query<&ButtonAction>,
421    mut game: ResMut<Game>,
422    mut next_state: ResMut<NextState<GameState>>,
423) {
424    if click.button != PointerButton::Primary {
425        return;
426    }
427
428    let Ok(action) = buttons.get(click.event_target()) else {
429        return;
430    };
431
432    match *action {
433        ButtonAction::NewGame(difficulty) => {
434            game.field = difficulty.field();
435            game.game_over = false;
436            next_state.set(GameState::Playing);
437        }
438        ButtonAction::Menu => {
439            next_state.set(GameState::Menu);
440        }
441    }
442}
443
444fn on_button_over(
445    over: On<PointerOver>,
446    mut buttons: Query<(&mut BorderColor, &mut BackgroundColor), With<ButtonAction>>,
447) {
448    if let Ok((mut border, mut background)) = buttons.get_mut(over.event_target()) {
449        border.set_all(HOVERED_TILE_BORDER_COLOR);
450        background.0 = BACKGROUND_COLOR;
451    }
452}
453
454fn on_button_out(
455    out: On<PointerOut>,
456    mut buttons: Query<(&mut BorderColor, &mut BackgroundColor), With<ButtonAction>>,
457) {
458    if let Ok((mut border, mut background)) = buttons.get_mut(out.event_target()) {
459        border.set_all(TILE_BORDER_COLOR);
460        background.0 = BACKGROUND_COLOR;
461    }
462}
463
464fn on_tile_over(over: On<PointerOver>, mut tiles: Query<&mut BorderColor, With<TileCell>>) {
465    if let Ok(mut border) = tiles.get_mut(over.event_target()) {
466        border.set_all(HOVERED_TILE_BORDER_COLOR);
467    }
468}
469
470fn on_tile_out(out: On<PointerOut>, mut tiles: Query<&mut BorderColor, With<TileCell>>) {
471    if let Ok(mut border) = tiles.get_mut(out.event_target()) {
472        border.set_all(TILE_BORDER_COLOR);
473    }
474}
475
476fn on_tile_click(
477    click: On<PointerClick>,
478    tiles: Query<&TileCell>,
479    mut game: ResMut<Game>,
480    mut commands: Commands,
481    game_root: Single<Entity, With<GameRoot>>,
482    assets: Res<AssetServer>,
483) {
484    if game.game_over || game.field.is_cleared() {
485        return;
486    }
487
488    let Ok(&TileCell(position)) = tiles.get(click.event_target()) else {
489        return;
490    };
491
492    match click.button {
493        PointerButton::Primary => {
494            if game.field[position].mined {
495                game.game_over = true;
496            } else {
497                game.field.reveal(position);
498            }
499        }
500        PointerButton::Secondary => {
501            if game.field[position].flagged {
502                game.field[position].flagged = false;
503            } else if game.field.flag_count() < game.field.mine_count() {
504                game.field[position].flagged = true;
505            }
506        }
507        _ => {
508            return;
509        }
510    }
511
512    rebuild_game_ui(&mut commands, *game_root, &game, &assets);
513}
514
515fn main() {
516    App::new()
517        .add_plugins(DefaultPlugins)
518        .init_resource::<Game>()
519        .init_state::<GameState>()
520        .add_systems(Startup, setup)
521        .add_systems(OnEnter(GameState::Menu), setup_menu)
522        .add_systems(OnEnter(GameState::Playing), setup_game)
523        .add_observer(on_button_click)
524        .add_observer(on_button_over)
525        .add_observer(on_button_out)
526        .add_observer(on_tile_click)
527        .add_observer(on_tile_over)
528        .add_observer(on_tile_out)
529        .run();
530}