Skip to main content

egui_minesweeper/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use egui::{
4    Align2, Color32, CornerRadius, FontId, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui,
5    Vec2, Widget,
6};
7
8// ─── Game types ────────────────────────────────────────────────────────────────
9
10/// The visibility state of a single cell.
11#[derive(Clone, Copy, PartialEq, Eq, Debug)]
12pub enum CellState {
13    /// The cell has not been revealed or flagged yet.
14    Hidden,
15    /// The cell has been revealed by the player.
16    Revealed,
17    /// The player has placed a flag on this cell.
18    Flagged,
19}
20
21/// A single cell on the Minesweeper board.
22#[derive(Clone, Debug)]
23pub struct Cell {
24    /// Whether this cell contains a mine.
25    pub is_mine: bool,
26    /// Current visibility state of the cell.
27    pub state: CellState,
28    /// Number of mines in the 8 neighboring cells (0 if this cell is a mine).
29    pub adjacent_mines: u8,
30}
31
32impl Default for Cell {
33    fn default() -> Self {
34        Self {
35            is_mine: false,
36            state: CellState::Hidden,
37            adjacent_mines: 0,
38        }
39    }
40}
41
42/// The current status of the game.
43#[derive(Clone, Copy, PartialEq, Eq, Debug)]
44pub enum GameStatus {
45    /// Waiting for the first click, or game is in progress.
46    Playing,
47    /// All non-mine cells have been revealed — the player won.
48    Won,
49    /// The player revealed a mine — the game is over.
50    Lost,
51}
52
53// ─── Game logic ────────────────────────────────────────────────────────────────
54
55/// The Minesweeper game state.
56///
57/// Holds the board dimensions, the cell grid, and the current game status.
58/// Mines are not placed until the first [`reveal`](Self::reveal) call, ensuring
59/// the player can never lose on their first click.
60pub struct MinesweeperGame {
61    /// Board width in cells.
62    pub width: usize,
63    /// Board height in cells.
64    pub height: usize,
65    /// Total number of mines on the board.
66    pub mines: usize,
67    /// Flat row-major grid of cells (`cells[y * width + x]`).
68    pub cells: Vec<Cell>,
69    /// Current game status.
70    pub status: GameStatus,
71    initialized: bool,
72}
73
74impl MinesweeperGame {
75    /// Create a new game. Mines are placed on the first [`reveal`] call so
76    /// the player can never lose on the very first click.
77    pub fn new(width: usize, height: usize, mines: usize) -> Self {
78        assert!(width > 0 && height > 0);
79        let mines = mines.min(width * height - 1);
80        Self {
81            width,
82            height,
83            mines,
84            cells: vec![Cell::default(); width * height],
85            status: GameStatus::Playing,
86            initialized: false,
87        }
88    }
89
90    /// Reset to a fresh game with the same parameters.
91    pub fn reset(&mut self) {
92        *self = Self::new(self.width, self.height, self.mines);
93    }
94
95    /// Number of flags the player has placed.
96    pub fn flags_placed(&self) -> usize {
97        self.cells
98            .iter()
99            .filter(|c| c.state == CellState::Flagged)
100            .count()
101    }
102
103    // ── Internal helpers ──────────────────────────────────────────────────────
104
105    #[inline]
106    fn idx(&self, x: usize, y: usize) -> usize {
107        y * self.width + x
108    }
109
110    fn neighbors(&self, x: usize, y: usize) -> Vec<(usize, usize)> {
111        let mut out = Vec::with_capacity(8);
112        for dy in -1i32..=1 {
113            for dx in -1i32..=1 {
114                if dx == 0 && dy == 0 {
115                    continue;
116                }
117                let nx = x as i32 + dx;
118                let ny = y as i32 + dy;
119                if nx >= 0 && ny >= 0 && nx < self.width as i32 && ny < self.height as i32 {
120                    out.push((nx as usize, ny as usize));
121                }
122            }
123        }
124        out
125    }
126
127    fn initialize(&mut self, safe_x: usize, safe_y: usize) {
128        let safe = self.idx(safe_x, safe_y);
129
130        // Collect candidate positions and shuffle them with fastrand.
131        let mut positions: Vec<usize> = (0..self.width * self.height)
132            .filter(|&i| i != safe)
133            .collect();
134
135        // Partial Fisher-Yates – only shuffle the first `mines` elements.
136        let mines = self.mines.min(positions.len());
137        for i in 0..mines {
138            let j = i + fastrand::usize(0..(positions.len() - i));
139            positions.swap(i, j);
140        }
141
142        for &pos in &positions[..mines] {
143            self.cells[pos].is_mine = true;
144        }
145
146        // Pre-compute adjacent mine counts.
147        for y in 0..self.height {
148            for x in 0..self.width {
149                if !self.cells[self.idx(x, y)].is_mine {
150                    let count = self
151                        .neighbors(x, y)
152                        .iter()
153                        .filter(|&&(nx, ny)| self.cells[self.idx(nx, ny)].is_mine)
154                        .count();
155                    let idx = self.idx(x, y);
156                    self.cells[idx].adjacent_mines = count as u8;
157                }
158            }
159        }
160
161        self.initialized = true;
162    }
163
164    fn check_win(&mut self) {
165        let all_safe_revealed = self
166            .cells
167            .iter()
168            .all(|c| c.is_mine || c.state == CellState::Revealed);
169        if all_safe_revealed {
170            self.status = GameStatus::Won;
171        }
172    }
173
174    // ── Public actions ────────────────────────────────────────────────────────
175
176    /// Reveal a cell. Recursively reveals empty neighbours (flood-fill).
177    pub fn reveal(&mut self, x: usize, y: usize) {
178        if self.status != GameStatus::Playing {
179            return;
180        }
181        if !self.initialized {
182            self.initialize(x, y);
183        }
184
185        // Iterative flood-fill to avoid stack overflows on large boards.
186        let mut stack = vec![(x, y)];
187        while let Some((cx, cy)) = stack.pop() {
188            let idx = self.idx(cx, cy);
189            if self.cells[idx].state != CellState::Hidden {
190                continue;
191            }
192            self.cells[idx].state = CellState::Revealed;
193
194            if self.cells[idx].is_mine {
195                self.status = GameStatus::Lost;
196                // Reveal all mines on loss.
197                for cell in &mut self.cells {
198                    if cell.is_mine {
199                        cell.state = CellState::Revealed;
200                    }
201                }
202                return;
203            }
204
205            if self.cells[idx].adjacent_mines == 0 {
206                for neighbor in self.neighbors(cx, cy) {
207                    stack.push(neighbor);
208                }
209            }
210        }
211
212        self.check_win();
213    }
214
215    /// Toggle a flag on a hidden cell.
216    pub fn toggle_flag(&mut self, x: usize, y: usize) {
217        if self.status != GameStatus::Playing {
218            return;
219        }
220        let idx = self.idx(x, y);
221        match self.cells[idx].state {
222            CellState::Hidden => self.cells[idx].state = CellState::Flagged,
223            CellState::Flagged => self.cells[idx].state = CellState::Hidden,
224            CellState::Revealed => {}
225        }
226    }
227}
228
229// ─── egui widget ───────────────────────────────────────────────────────────────
230
231/// An egui widget that renders the minesweeper grid.
232///
233/// Left-click reveals a cell; right-click toggles a flag.
234///
235/// ```no_run
236/// ui.add(egui_minesweeper::MinesweeperWidget::new(&mut game));
237/// ```
238pub struct MinesweeperWidget<'a> {
239    game: &'a mut MinesweeperGame,
240    cell_size: Option<f32>,
241}
242
243impl<'a> MinesweeperWidget<'a> {
244    pub fn new(game: &'a mut MinesweeperGame) -> Self {
245        Self {
246            game,
247            cell_size: None,
248        }
249    }
250
251    /// Override the size (in logical pixels) of each cell.
252    /// When not set, the cell size is computed automatically to fill the
253    /// available space of the parent container.
254    pub fn cell_size(mut self, size: f32) -> Self {
255        self.cell_size = Some(size);
256        self
257    }
258}
259
260fn number_color(n: u8) -> Color32 {
261    match n {
262        1 => Color32::from_rgb(0, 0, 255),
263        2 => Color32::from_rgb(0, 128, 0),
264        3 => Color32::from_rgb(200, 0, 0),
265        4 => Color32::from_rgb(0, 0, 128),
266        5 => Color32::from_rgb(128, 0, 0),
267        6 => Color32::from_rgb(0, 128, 128),
268        7 => Color32::BLACK,
269        _ => Color32::DARK_GRAY,
270    }
271}
272
273fn draw_cell(painter: &egui::Painter, rect: Rect, cell: &Cell, cell_size: f32) {
274    let inner = rect.shrink(1.0);
275    let rounding = CornerRadius::same(2);
276
277    match cell.state {
278        CellState::Hidden => {
279            // Raised 3-D look (classic Minesweeper style).
280            painter.rect_filled(inner, rounding, Color32::from_rgb(192, 192, 192));
281            // Highlight edges (top-left bright, bottom-right dark).
282            let tl = inner.left_top();
283            let tr = inner.right_top();
284            let bl = inner.left_bottom();
285            let br = inner.right_bottom();
286            let highlight = Color32::WHITE;
287            let shadow = Color32::from_rgb(100, 100, 100);
288            let w = 2.0;
289            painter.line_segment([tl, tr], Stroke::new(w, highlight));
290            painter.line_segment([tl, bl], Stroke::new(w, highlight));
291            painter.line_segment([tr, br], Stroke::new(w, shadow));
292            painter.line_segment([bl, br], Stroke::new(w, shadow));
293        }
294        CellState::Flagged => {
295            painter.rect_filled(inner, rounding, Color32::from_rgb(192, 192, 192));
296            let tl = inner.left_top();
297            let tr = inner.right_top();
298            let bl = inner.left_bottom();
299            let br = inner.right_bottom();
300            let highlight = Color32::WHITE;
301            let shadow = Color32::from_rgb(100, 100, 100);
302            let w = 2.0;
303            painter.line_segment([tl, tr], Stroke::new(w, highlight));
304            painter.line_segment([tl, bl], Stroke::new(w, highlight));
305            painter.line_segment([tr, br], Stroke::new(w, shadow));
306            painter.line_segment([bl, br], Stroke::new(w, shadow));
307            // Draw a simple flag: a filled triangle for the flag and a pole.
308            let cx = rect.center().x;
309            let top = inner.min.y + cell_size * 0.15;
310            let mid = inner.min.y + cell_size * 0.55;
311            let bot = inner.max.y - cell_size * 0.15;
312            // Pole
313            painter.line_segment(
314                [Pos2::new(cx, top), Pos2::new(cx, bot)],
315                Stroke::new(2.0, Color32::BLACK),
316            );
317            // Flag triangle
318            let flag_pts = vec![
319                Pos2::new(cx, top),
320                Pos2::new(cx + cell_size * 0.35, (top + mid) / 2.0),
321                Pos2::new(cx, mid),
322            ];
323            painter.add(egui::Shape::convex_polygon(
324                flag_pts,
325                Color32::RED,
326                Stroke::NONE,
327            ));
328        }
329        CellState::Revealed => {
330            if cell.is_mine {
331                painter.rect_filled(inner, CornerRadius::ZERO, Color32::from_rgb(255, 80, 80));
332                // Draw a simple mine: filled circle with spikes.
333                let c = rect.center();
334                let r = cell_size * 0.22;
335                painter.circle_filled(c, r, Color32::BLACK);
336                // 8 spikes
337                for i in 0..8u32 {
338                    let angle = i as f32 * std::f32::consts::TAU / 8.0;
339                    let inner_pt = c + Vec2::new(angle.cos(), angle.sin()) * r;
340                    let outer_pt = c + Vec2::new(angle.cos(), angle.sin()) * (r * 1.7);
341                    painter.line_segment([inner_pt, outer_pt], Stroke::new(2.0, Color32::BLACK));
342                }
343                // Shine dot
344                painter.circle_filled(c + Vec2::new(-r * 0.3, -r * 0.3), r * 0.25, Color32::WHITE);
345            } else {
346                painter.rect_filled(inner, CornerRadius::ZERO, Color32::from_rgb(210, 210, 210));
347                painter.rect_stroke(
348                    inner,
349                    CornerRadius::ZERO,
350                    Stroke::new(0.5, Color32::GRAY),
351                    StrokeKind::Inside,
352                );
353                if cell.adjacent_mines > 0 {
354                    painter.text(
355                        rect.center(),
356                        Align2::CENTER_CENTER,
357                        cell.adjacent_mines.to_string(),
358                        FontId::monospace(cell_size * 0.58),
359                        number_color(cell.adjacent_mines),
360                    );
361                }
362            }
363        }
364    }
365}
366
367impl Widget for MinesweeperWidget<'_> {
368    fn ui(self, ui: &mut Ui) -> Response {
369        let cell_size = self.cell_size.unwrap_or_else(|| {
370            let available = ui.available_size();
371            let by_width = available.x / self.game.width as f32;
372            let by_height = available.y / self.game.height as f32;
373            by_width.min(by_height).max(1.0)
374        });
375
376        let total = Vec2::new(self.game.width as f32, self.game.height as f32) * cell_size;
377
378        let (response, painter) = ui.allocate_painter(total, Sense::click());
379        let origin = response.rect.min;
380
381        // ── Input handling ────────────────────────────────────────────────────
382        if (response.clicked() || response.secondary_clicked())
383            && self.game.status == GameStatus::Playing
384        {
385            if let Some(pos) = response.interact_pointer_pos() {
386                let local = pos - origin;
387                let cx = (local.x / cell_size).floor() as usize;
388                let cy = (local.y / cell_size).floor() as usize;
389                if cx < self.game.width && cy < self.game.height {
390                    if response.clicked() {
391                        self.game.reveal(cx, cy);
392                    } else {
393                        self.game.toggle_flag(cx, cy);
394                    }
395                }
396            }
397        }
398
399        // ── Painting ──────────────────────────────────────────────────────────
400        for y in 0..self.game.height {
401            for x in 0..self.game.width {
402                let cell_rect = Rect::from_min_size(
403                    origin + Vec2::new(x as f32, y as f32) * cell_size,
404                    Vec2::splat(cell_size),
405                );
406                let cell = &self.game.cells[y * self.game.width + x];
407                draw_cell(&painter, cell_rect, cell, cell_size);
408            }
409        }
410
411        response
412    }
413}