1use 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}