docs/bevy/examples/showcase/mines.rs 17.2 K · 530 lines · raw · history

1 //! A simple minesweeper-style game in Bevy UI.
2
3 use bevy::color::palettes::css::{DARK_GREEN, LIME, ORANGE, ORANGE_RED, RED, SEA_GREEN, YELLOW};
4 use bevy::prelude::*;
5 use bevy::text::LineHeight;
6 use rand::seq::SliceRandom;
7 use std::ops::Range;
8
9 const TILE_SIZE: f32 = 30.0;
10 const TILE_GAP: f32 = 6.0;
11 const BOARD_PADDING: f32 = 15.0;
12
13 const BACKGROUND_COLOR: Color = Color::Srgba(DARK_GREEN);
14 const TILE_BORDER_COLOR: Color = Color::Srgba(SEA_GREEN);
15 const HOVERED_TILE_BORDER_COLOR: Color = Color::Srgba(LIME);
16 const MILD_DANGER_COLOR: Color = Color::Srgba(YELLOW);
17 const SOME_DANGER_COLOR: Color = Color::Srgba(ORANGE);
18 const VERY_DANGER_COLOR: Color = Color::Srgba(ORANGE_RED);
19 const MOST_DANGER_COLOR: Color = Color::Srgba(RED);
20
21 #[derive(Default, Clone, Copy)]
22 struct Tile {
23 mined: bool,
24 revealed: bool,
25 flagged: bool,
26 }
27
28 struct MineField {
29 width: i32,
30 height: i32,
31 tiles: Vec<Tile>,
32 }
33
34 impl 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
122 impl 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
131 impl 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)]
139 enum Difficulty {
140 Easy,
141 Normal,
142 Hard,
143 }
144
145 impl 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)]
156 enum GameState {
157 #[default]
158 Menu,
159 Playing,
160 }
161
162 #[derive(Resource)]
163 struct Game {
164 field: MineField,
165 game_over: bool,
166 }
167
168 impl 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)]
178 struct GameRoot;
179
180 #[derive(Component)]
181 struct TileCell(IVec2);
182
183 #[derive(Component, Clone, Copy)]
184 enum ButtonAction {
185 NewGame(Difficulty),
186 Menu,
187 }
188
189 fn 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
208 fn setup(mut commands: Commands) {
209 commands.spawn(Camera2d);
210 }
211
212 fn 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
242 fn 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
257 fn 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
418 fn 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
444 fn 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
454 fn 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
464 fn 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
470 fn 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
476 fn 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
515 fn 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 }