paiagram/interface/tabs/
minesweeper.rs

1use super::Tab;
2use bevy::prelude::*;
3use egui::{Rect, Ui, Vec2};
4use serde::{Deserialize, Serialize};
5use std::time::Duration;
6
7#[derive(PartialEq, Debug, Clone, Copy, Serialize, Deserialize)]
8pub struct MinesweeperTab;
9
10impl Tab for MinesweeperTab {
11    const NAME: &'static str = "Minesweeper";
12    fn main_display(&mut self, world: &mut bevy::ecs::world::World, ui: &mut Ui) {
13        if let Err(e) = world.run_system_cached_with(show_minesweeper, ui) {
14            bevy::log::error!("UI Error while displaying minesweeper page: {}", e)
15        }
16    }
17    fn edit_display(&mut self, world: &mut bevy::ecs::world::World, ui: &mut Ui) {
18        ui.heading("Minesweeper");
19        ui.label("Tired of trains? Here's a minesweeper!");
20        ui.add_space(10.0);
21        let time = world.resource::<Time>().elapsed();
22        let mut data = world.resource_mut::<MinesweeperData>();
23        const SEGMENT_SPACING: f32 = 5.0;
24        let width = (ui.available_width() - SEGMENT_SPACING - SEGMENT_SPACING) / 3.0;
25        ui.horizontal(|ui| {
26            ui.spacing_mut().item_spacing.x = SEGMENT_SPACING;
27            if ui
28                .add_sized([width, 30.0], egui::Button::new("Easy"))
29                .clicked()
30            {
31                data.map.initialize(MinesweeperDifficulty::Easy);
32                data.started = true;
33                data.game_over = false;
34                data.won = false;
35                data.start_time = time;
36                data.elapsed = Duration::ZERO;
37            }
38            if ui
39                .add_sized([width, 30.0], egui::Button::new("Medium"))
40                .clicked()
41            {
42                data.map.initialize(MinesweeperDifficulty::Medium);
43                data.started = true;
44                data.game_over = false;
45                data.won = false;
46                data.start_time = time;
47                data.elapsed = Duration::ZERO;
48            }
49            if ui
50                .add_sized([width, 30.0], egui::Button::new("Hard"))
51                .clicked()
52            {
53                data.map.initialize(MinesweeperDifficulty::Hard);
54                data.started = true;
55                data.game_over = false;
56                data.won = false;
57                data.start_time = time;
58                data.elapsed = Duration::ZERO;
59            }
60        });
61        ui.label(format!("Elapsed: {:.0}s", data.elapsed.as_secs_f32()));
62        ui.ctx().request_repaint_after(Duration::from_millis(100));
63        ui.heading("High scores");
64        for (i, record) in data.record.iter().enumerate() {
65            ui.label(format!("{}. {:.2}s", i + 1, record.as_secs_f32()));
66        }
67    }
68}
69
70const MINE_STR: &str = "💣";
71const FLAG_STR: &str = "🚩";
72
73enum MinesweeperDifficulty {
74    Easy,
75    Medium,
76    Hard,
77}
78
79impl MinesweeperDifficulty {
80    fn parameters(&self) -> (u8, u8, u8) {
81        match self {
82            MinesweeperDifficulty::Easy => (9, 9, 10),
83            MinesweeperDifficulty::Medium => (16, 16, 40),
84            MinesweeperDifficulty::Hard => (30, 16, 99),
85        }
86    }
87}
88
89#[derive(Resource, Default)]
90pub struct MinesweeperData {
91    map: MinesweeperMap,
92    started: bool,
93    game_over: bool,
94    won: bool,
95    record: Vec<Duration>,
96    start_time: Duration,
97    elapsed: Duration,
98}
99
100#[derive(Default)]
101struct MinesweeperMap {
102    width: u8,
103    height: u8,
104    mines: Vec<(u8, u8)>,
105    revealed: Vec<(u8, u8)>,
106    flagged: Vec<(u8, u8)>,
107}
108
109impl MinesweeperMap {
110    fn initialize(&mut self, difficulty: MinesweeperDifficulty) -> bool {
111        let (width, height, mines) = difficulty.parameters();
112        self.width = width;
113        self.height = height;
114        self.mines.clear();
115        self.revealed.clear();
116        self.flagged.clear();
117        self.generate_mines(mines)
118    }
119    fn generate_mines(&mut self, mine_count: u8) -> bool {
120        // Simple random mine generation (not optimized)
121        use rand::Rng;
122        let mut rng = rand::rng();
123        if self.width as u16 * self.height as u16 <= mine_count as u16 {
124            return false;
125        }
126        while self.mines.len() < mine_count as usize {
127            let x = rng.random_range(0..self.width);
128            let y = rng.random_range(0..self.height);
129            if !self.mines.contains(&(x, y)) {
130                self.mines.push((x, y));
131            }
132        }
133        true
134    }
135    fn nearby_mines(&self, x: u8, y: u8) -> u8 {
136        let mut count = 0;
137        for xi in x.saturating_sub(1)..=(x + 1).min(self.width - 1) {
138            for yi in y.saturating_sub(1)..=(y + 1).min(self.height - 1) {
139                if self.mines.contains(&(xi, yi)) {
140                    count += 1;
141                }
142            }
143        }
144        count
145    }
146    fn reveal(&mut self, x: u8, y: u8) -> bool {
147        if self.mines.contains(&(x, y)) {
148            return true; // Boom
149        }
150        if self.revealed.contains(&(x, y)) || self.flagged.contains(&(x, y)) {
151            return false;
152        }
153
154        self.revealed.push((x, y));
155
156        // If this cell is empty, reveal all neighbors
157        if self.nearby_mines(x, y) == 0 {
158            for xi in x.saturating_sub(1)..=(x + 1).min(self.width - 1) {
159                for yi in y.saturating_sub(1)..=(y + 1).min(self.height - 1) {
160                    self.reveal(xi, yi);
161                }
162            }
163        }
164        false
165    }
166    fn flag(&mut self, x: u8, y: u8) {
167        if self.revealed.contains(&(x, y)) {
168            return;
169        }
170        let i = self.flagged.iter().position(|&p| p == (x, y));
171        if let Some(i) = i {
172            self.flagged.remove(i);
173        } else {
174            self.flagged.push((x, y));
175        }
176    }
177}
178
179fn show_minesweeper(InMut(ui): InMut<Ui>, mut data: ResMut<MinesweeperData>, time: Res<Time>) {
180    if !data.started {
181        ui.centered_and_justified(|ui| ui.heading("Start from assistance panel"));
182        return;
183    }
184
185    if !data.game_over {
186        data.elapsed = time.elapsed().saturating_sub(data.start_time);
187    }
188
189    fn show_mine(x: u8, y: u8, ui: &mut Ui, data: &mut MinesweeperData) {
190        let is_revealed = data.map.revealed.contains(&(x, y));
191        let is_flagged = data.map.flagged.contains(&(x, y));
192        let is_mine = data.map.mines.contains(&(x, y));
193
194        let mut text = egui::WidgetText::from(" ");
195        if is_revealed {
196            if is_mine {
197                text = MINE_STR.into();
198            } else {
199                let count = data.map.nearby_mines(x, y);
200                if count > 0 {
201                    text = count.to_string().into();
202                }
203            }
204        } else if is_flagged {
205            text = FLAG_STR.into();
206        }
207
208        let button = ui.add_sized([25.0, 25.0], egui::Button::new(text).selected(is_revealed));
209
210        if !data.game_over {
211            if button.clicked() && !is_flagged {
212                if data.map.reveal(x, y) {
213                    data.game_over = true;
214                    // Reveal all mines on game over
215                    let mines = data.map.mines.clone();
216                    data.map.revealed.extend_from_slice(&mines);
217                    data.map.revealed.sort_unstable();
218                    data.map.revealed.dedup();
219                }
220            }
221            if button.secondary_clicked() {
222                data.map.flag(x, y);
223            }
224        }
225    }
226
227    ui.spacing_mut().item_spacing = Vec2 { x: 2.0, y: 2.0 };
228    ui.spacing_mut().interact_size = Vec2::ZERO;
229    let h_space = (ui.available_width() - data.map.width as f32 * 30.0 + 5.0) / 2.0;
230    let v_space = (ui.available_height() - data.map.height as f32 * 30.0 + 5.0) / 2.0;
231    let max_rect = ui.max_rect();
232    ui.add_space(v_space.max(0.0));
233    ui.horizontal(|ui| {
234        ui.add_space(h_space.max(0.0));
235        egui::Grid::new("minesweeper_grid")
236            .spacing(Vec2 { x: 5.0, y: 5.0 })
237            .show(ui, |ui| {
238                for y in 0..data.map.height {
239                    for x in 0..data.map.width {
240                        show_mine(x, y, ui, &mut data);
241                    }
242                    ui.end_row();
243                }
244            });
245        let total_cells = (data.map.width as usize) * (data.map.height as usize);
246        let revealed_cells = data.map.revealed.len();
247        let mine_count = data.map.mines.len();
248        if !data.won && revealed_cells + mine_count == total_cells {
249            data.won = true;
250            data.game_over = true;
251            let elapsed = data.elapsed;
252            data.record.push(elapsed);
253            data.record.sort();
254        }
255    });
256    if data.won {
257        ui.place(
258            Rect::from_pos(max_rect.center()).expand2(Vec2 { x: 80.0, y: 20.0 }),
259            |ui: &mut Ui| {
260                ui.painter().rect(
261                    ui.max_rect(),
262                    5.0,
263                    ui.visuals().widgets.active.bg_fill,
264                    ui.visuals().widgets.active.bg_stroke,
265                    egui::StrokeKind::Middle,
266                );
267                ui.centered_and_justified(|ui: &mut Ui| ui.heading("You won!"))
268                    .response
269            },
270        );
271    }
272}