mirror of
https://github.com/bec-project/bec_widgets.git
synced 2025-07-14 03:31:50 +02:00
feat(widgets): added minesweeper widget
This commit is contained in:
3
bec_widgets/widgets/games/__init__.py
Normal file
3
bec_widgets/widgets/games/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from bec_widgets.widgets.games.minesweeper import Minesweeper
|
||||||
|
|
||||||
|
__ALL__ = ["Minesweeper"]
|
408
bec_widgets/widgets/games/minesweeper.py
Normal file
408
bec_widgets/widgets/games/minesweeper.py
Normal file
@ -0,0 +1,408 @@
|
|||||||
|
import enum
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
|
||||||
|
from bec_qthemes import material_icon
|
||||||
|
from qtpy.QtCore import QSize, Qt, QTimer, Signal, Slot
|
||||||
|
from qtpy.QtGui import QBrush, QColor, QPainter, QPen
|
||||||
|
from qtpy.QtWidgets import (
|
||||||
|
QApplication,
|
||||||
|
QComboBox,
|
||||||
|
QGridLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QPushButton,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
NUM_COLORS = {
|
||||||
|
1: QColor("#f44336"),
|
||||||
|
2: QColor("#9C27B0"),
|
||||||
|
3: QColor("#3F51B5"),
|
||||||
|
4: QColor("#03A9F4"),
|
||||||
|
5: QColor("#00BCD4"),
|
||||||
|
6: QColor("#4CAF50"),
|
||||||
|
7: QColor("#E91E63"),
|
||||||
|
8: QColor("#FF9800"),
|
||||||
|
}
|
||||||
|
|
||||||
|
LEVELS: dict[str, tuple[int, int]] = {"1": (8, 10), "2": (16, 40), "3": (24, 99)}
|
||||||
|
|
||||||
|
|
||||||
|
class GameStatus(enum.Enum):
|
||||||
|
READY = 0
|
||||||
|
PLAYING = 1
|
||||||
|
FAILED = 2
|
||||||
|
SUCCESS = 3
|
||||||
|
|
||||||
|
|
||||||
|
class Pos(QWidget):
|
||||||
|
expandable = Signal(int, int)
|
||||||
|
clicked = Signal()
|
||||||
|
ohno = Signal()
|
||||||
|
|
||||||
|
def __init__(self, x, y, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.setFixedSize(QSize(20, 20))
|
||||||
|
|
||||||
|
self.x = x
|
||||||
|
self.y = y
|
||||||
|
self.is_start = False
|
||||||
|
self.is_mine = False
|
||||||
|
self.adjacent_n = 0
|
||||||
|
self.is_revealed = False
|
||||||
|
self.is_flagged = False
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
"""Restore the tile to its original state before mine status is assigned"""
|
||||||
|
self.is_start = False
|
||||||
|
self.is_mine = False
|
||||||
|
self.adjacent_n = 0
|
||||||
|
|
||||||
|
self.is_revealed = False
|
||||||
|
self.is_flagged = False
|
||||||
|
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def paintEvent(self, event):
|
||||||
|
p = QPainter(self)
|
||||||
|
|
||||||
|
r = event.rect()
|
||||||
|
|
||||||
|
if self.is_revealed:
|
||||||
|
color = self.palette().base().color()
|
||||||
|
outer, inner = color, color
|
||||||
|
else:
|
||||||
|
outer, inner = (self.palette().highlightedText().color(), self.palette().text().color())
|
||||||
|
|
||||||
|
p.fillRect(r, QBrush(inner))
|
||||||
|
pen = QPen(outer)
|
||||||
|
pen.setWidth(1)
|
||||||
|
p.setPen(pen)
|
||||||
|
p.drawRect(r)
|
||||||
|
|
||||||
|
if self.is_revealed:
|
||||||
|
if self.is_mine:
|
||||||
|
p.drawPixmap(r, material_icon("experiment", convert_to_pixmap=True, filled=True))
|
||||||
|
|
||||||
|
elif self.adjacent_n > 0:
|
||||||
|
pen = QPen(NUM_COLORS[self.adjacent_n])
|
||||||
|
p.setPen(pen)
|
||||||
|
f = p.font()
|
||||||
|
f.setBold(True)
|
||||||
|
p.setFont(f)
|
||||||
|
p.drawText(r, Qt.AlignHCenter | Qt.AlignVCenter, str(self.adjacent_n))
|
||||||
|
|
||||||
|
elif self.is_flagged:
|
||||||
|
p.drawPixmap(
|
||||||
|
r,
|
||||||
|
material_icon(
|
||||||
|
"flag",
|
||||||
|
size=(50, 50),
|
||||||
|
convert_to_pixmap=True,
|
||||||
|
filled=True,
|
||||||
|
color=self.palette().base().color(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
p.end()
|
||||||
|
|
||||||
|
def flag(self):
|
||||||
|
self.is_flagged = not self.is_flagged
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
self.clicked.emit()
|
||||||
|
|
||||||
|
def reveal(self):
|
||||||
|
self.is_revealed = True
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def click(self):
|
||||||
|
if not self.is_revealed:
|
||||||
|
self.reveal()
|
||||||
|
if self.adjacent_n == 0:
|
||||||
|
self.expandable.emit(self.x, self.y)
|
||||||
|
|
||||||
|
self.clicked.emit()
|
||||||
|
|
||||||
|
def mouseReleaseEvent(self, event):
|
||||||
|
if event.button() == Qt.MouseButton.RightButton and not self.is_revealed:
|
||||||
|
self.flag()
|
||||||
|
return
|
||||||
|
|
||||||
|
if event.button() == Qt.MouseButton.LeftButton:
|
||||||
|
self.click()
|
||||||
|
if self.is_mine:
|
||||||
|
self.ohno.emit()
|
||||||
|
|
||||||
|
|
||||||
|
class Minesweeper(QWidget):
|
||||||
|
|
||||||
|
PLUGIN = True
|
||||||
|
ICON_NAME = "videogame_asset"
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self._ui_initialised = False
|
||||||
|
self._timer_start_num_seconds = 0
|
||||||
|
self._set_level_params(LEVELS["1"])
|
||||||
|
|
||||||
|
self._init_ui()
|
||||||
|
self._init_map()
|
||||||
|
|
||||||
|
self.update_status(GameStatus.READY)
|
||||||
|
self.reset_map()
|
||||||
|
self.update_status(GameStatus.READY)
|
||||||
|
|
||||||
|
def _init_ui(self):
|
||||||
|
if self._ui_initialised:
|
||||||
|
return
|
||||||
|
self._ui_initialised = True
|
||||||
|
|
||||||
|
status_hb = QHBoxLayout()
|
||||||
|
self.mines = QLabel()
|
||||||
|
self.mines.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
|
||||||
|
f = self.mines.font()
|
||||||
|
f.setPointSize(24)
|
||||||
|
self.mines.setFont(f)
|
||||||
|
|
||||||
|
self.reset_button = QPushButton()
|
||||||
|
self.reset_button.setFixedSize(QSize(32, 32))
|
||||||
|
self.reset_button.setIconSize(QSize(32, 32))
|
||||||
|
self.reset_button.setFlat(True)
|
||||||
|
self.reset_button.pressed.connect(self.reset_button_pressed)
|
||||||
|
|
||||||
|
self.clock = QLabel()
|
||||||
|
self.clock.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
|
||||||
|
self.clock.setFont(f)
|
||||||
|
self._timer = QTimer()
|
||||||
|
self._timer.timeout.connect(self.update_timer)
|
||||||
|
self._timer.start(1000) # 1 second timer
|
||||||
|
self.mines.setText(f"{self.num_mines:03d}")
|
||||||
|
self.clock.setText("000")
|
||||||
|
|
||||||
|
status_hb.addWidget(self.mines)
|
||||||
|
status_hb.addWidget(self.reset_button)
|
||||||
|
status_hb.addWidget(self.clock)
|
||||||
|
|
||||||
|
level_hb = QHBoxLayout()
|
||||||
|
self.level_selector = QComboBox()
|
||||||
|
self.level_selector.addItems(list(LEVELS.keys()))
|
||||||
|
level_hb.addWidget(QLabel("Level: "))
|
||||||
|
level_hb.addWidget(self.level_selector)
|
||||||
|
self.level_selector.currentTextChanged.connect(self.change_level)
|
||||||
|
|
||||||
|
vb = QVBoxLayout()
|
||||||
|
vb.addLayout(level_hb)
|
||||||
|
vb.addLayout(status_hb)
|
||||||
|
|
||||||
|
self.grid = QGridLayout()
|
||||||
|
self.grid.setSpacing(5)
|
||||||
|
|
||||||
|
vb.addLayout(self.grid)
|
||||||
|
self.setLayout(vb)
|
||||||
|
|
||||||
|
def _init_map(self):
|
||||||
|
"""Redraw the grid of mines"""
|
||||||
|
|
||||||
|
# Remove any previous grid items and reset the grid
|
||||||
|
for i in reversed(range(self.grid.count())):
|
||||||
|
w: Pos = self.grid.itemAt(i).widget()
|
||||||
|
w.clicked.disconnect(self.on_click)
|
||||||
|
w.expandable.disconnect(self.expand_reveal)
|
||||||
|
w.ohno.disconnect(self.game_over)
|
||||||
|
w.setParent(None)
|
||||||
|
w.deleteLater()
|
||||||
|
|
||||||
|
# Add positions to the map
|
||||||
|
for x in range(0, self.b_size):
|
||||||
|
for y in range(0, self.b_size):
|
||||||
|
w = Pos(x, y)
|
||||||
|
self.grid.addWidget(w, y, x)
|
||||||
|
# Connect signal to handle expansion.
|
||||||
|
w.clicked.connect(self.on_click)
|
||||||
|
w.expandable.connect(self.expand_reveal)
|
||||||
|
w.ohno.connect(self.game_over)
|
||||||
|
|
||||||
|
def reset_map(self):
|
||||||
|
"""
|
||||||
|
Reset the map and add new mines.
|
||||||
|
"""
|
||||||
|
# Clear all mine positions
|
||||||
|
for x in range(0, self.b_size):
|
||||||
|
for y in range(0, self.b_size):
|
||||||
|
w = self.grid.itemAtPosition(y, x).widget()
|
||||||
|
w.reset()
|
||||||
|
|
||||||
|
# Add mines to the positions
|
||||||
|
positions = []
|
||||||
|
while len(positions) < self.num_mines:
|
||||||
|
x, y = (random.randint(0, self.b_size - 1), random.randint(0, self.b_size - 1))
|
||||||
|
if (x, y) not in positions:
|
||||||
|
w = self.grid.itemAtPosition(y, x).widget()
|
||||||
|
w.is_mine = True
|
||||||
|
positions.append((x, y))
|
||||||
|
|
||||||
|
def get_adjacency_n(x, y):
|
||||||
|
positions = self.get_surrounding(x, y)
|
||||||
|
num_mines = sum(1 if w.is_mine else 0 for w in positions)
|
||||||
|
|
||||||
|
return num_mines
|
||||||
|
|
||||||
|
# Add adjacencies to the positions
|
||||||
|
for x in range(0, self.b_size):
|
||||||
|
for y in range(0, self.b_size):
|
||||||
|
w = self.grid.itemAtPosition(y, x).widget()
|
||||||
|
w.adjacent_n = get_adjacency_n(x, y)
|
||||||
|
|
||||||
|
# Place starting marker
|
||||||
|
while True:
|
||||||
|
x, y = (random.randint(0, self.b_size - 1), random.randint(0, self.b_size - 1))
|
||||||
|
w = self.grid.itemAtPosition(y, x).widget()
|
||||||
|
# We don't want to start on a mine.
|
||||||
|
if (x, y) not in positions:
|
||||||
|
w = self.grid.itemAtPosition(y, x).widget()
|
||||||
|
w.is_start = True
|
||||||
|
|
||||||
|
# Reveal all positions around this, if they are not mines either.
|
||||||
|
for w in self.get_surrounding(x, y):
|
||||||
|
if not w.is_mine:
|
||||||
|
w.click()
|
||||||
|
break
|
||||||
|
|
||||||
|
def get_surrounding(self, x, y):
|
||||||
|
positions = []
|
||||||
|
for xi in range(max(0, x - 1), min(x + 2, self.b_size)):
|
||||||
|
for yi in range(max(0, y - 1), min(y + 2, self.b_size)):
|
||||||
|
positions.append(self.grid.itemAtPosition(yi, xi).widget())
|
||||||
|
return positions
|
||||||
|
|
||||||
|
def get_num_hidden(self) -> int:
|
||||||
|
"""
|
||||||
|
Get the number of hidden positions.
|
||||||
|
"""
|
||||||
|
return sum(
|
||||||
|
1
|
||||||
|
for x in range(0, self.b_size)
|
||||||
|
for y in range(0, self.b_size)
|
||||||
|
if not self.grid.itemAtPosition(y, x).widget().is_revealed
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_num_remaining_flags(self) -> int:
|
||||||
|
"""
|
||||||
|
Get the number of remaining flags.
|
||||||
|
"""
|
||||||
|
return self.num_mines - sum(
|
||||||
|
1
|
||||||
|
for x in range(0, self.b_size)
|
||||||
|
for y in range(0, self.b_size)
|
||||||
|
if self.grid.itemAtPosition(y, x).widget().is_flagged
|
||||||
|
)
|
||||||
|
|
||||||
|
def reset_button_pressed(self):
|
||||||
|
match self.status:
|
||||||
|
case GameStatus.PLAYING:
|
||||||
|
self.game_over()
|
||||||
|
case GameStatus.FAILED | GameStatus.SUCCESS:
|
||||||
|
self.reset_map()
|
||||||
|
|
||||||
|
def reveal_map(self):
|
||||||
|
for x in range(0, self.b_size):
|
||||||
|
for y in range(0, self.b_size):
|
||||||
|
w = self.grid.itemAtPosition(y, x).widget()
|
||||||
|
w.reveal()
|
||||||
|
|
||||||
|
@Slot(str)
|
||||||
|
def change_level(self, level: str):
|
||||||
|
self._set_level_params(LEVELS[level])
|
||||||
|
self._init_map()
|
||||||
|
self.reset_map()
|
||||||
|
|
||||||
|
@Slot(int, int)
|
||||||
|
def expand_reveal(self, x, y):
|
||||||
|
"""
|
||||||
|
Expand the reveal to the surrounding
|
||||||
|
|
||||||
|
Args:
|
||||||
|
x (int): The x position.
|
||||||
|
y (int): The y position.
|
||||||
|
"""
|
||||||
|
for xi in range(max(0, x - 1), min(x + 2, self.b_size)):
|
||||||
|
for yi in range(max(0, y - 1), min(y + 2, self.b_size)):
|
||||||
|
w = self.grid.itemAtPosition(yi, xi).widget()
|
||||||
|
if not w.is_mine:
|
||||||
|
w.click()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def on_click(self):
|
||||||
|
"""
|
||||||
|
Handle the click event. If the game is not started, start the game.
|
||||||
|
"""
|
||||||
|
self.update_available_flags()
|
||||||
|
if self.status != GameStatus.PLAYING:
|
||||||
|
# First click.
|
||||||
|
self.update_status(GameStatus.PLAYING)
|
||||||
|
# Start timer.
|
||||||
|
self._timer_start_num_seconds = int(time.time())
|
||||||
|
return
|
||||||
|
self.check_win()
|
||||||
|
|
||||||
|
def update_available_flags(self):
|
||||||
|
"""
|
||||||
|
Update the number of available flags.
|
||||||
|
"""
|
||||||
|
self.mines.setText(f"{self.get_num_remaining_flags():03d}")
|
||||||
|
|
||||||
|
def check_win(self):
|
||||||
|
"""
|
||||||
|
Check if the game is won.
|
||||||
|
"""
|
||||||
|
if self.get_num_hidden() == self.num_mines:
|
||||||
|
self.update_status(GameStatus.SUCCESS)
|
||||||
|
|
||||||
|
def update_status(self, status: GameStatus):
|
||||||
|
"""
|
||||||
|
Update the status of the game.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
status (GameStatus): The status of the game.
|
||||||
|
"""
|
||||||
|
self.status = status
|
||||||
|
match status:
|
||||||
|
case GameStatus.READY:
|
||||||
|
icon = material_icon(icon_name="add", convert_to_pixmap=False)
|
||||||
|
case GameStatus.PLAYING:
|
||||||
|
icon = material_icon(icon_name="smart_toy", convert_to_pixmap=False)
|
||||||
|
case GameStatus.FAILED:
|
||||||
|
icon = material_icon(icon_name="error", convert_to_pixmap=False)
|
||||||
|
case GameStatus.SUCCESS:
|
||||||
|
icon = material_icon(icon_name="celebration", convert_to_pixmap=False)
|
||||||
|
self.reset_button.setIcon(icon)
|
||||||
|
|
||||||
|
def update_timer(self):
|
||||||
|
"""
|
||||||
|
Update the timer.
|
||||||
|
"""
|
||||||
|
if self.status == GameStatus.PLAYING:
|
||||||
|
num_seconds = int(time.time()) - self._timer_start_num_seconds
|
||||||
|
self.clock.setText(f"{num_seconds:03d}")
|
||||||
|
|
||||||
|
def game_over(self):
|
||||||
|
"""Cause the game to end early"""
|
||||||
|
self.reveal_map()
|
||||||
|
self.update_status(GameStatus.FAILED)
|
||||||
|
|
||||||
|
def _set_level_params(self, level: tuple[int, int]):
|
||||||
|
self.b_size, self.num_mines = level
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
from bec_widgets.utils.colors import set_theme
|
||||||
|
|
||||||
|
app = QApplication([])
|
||||||
|
set_theme("light")
|
||||||
|
widget = Minesweeper()
|
||||||
|
widget.show()
|
||||||
|
|
||||||
|
app.exec_()
|
50
tests/unit_tests/test_minesweeper.py
Normal file
50
tests/unit_tests/test_minesweeper.py
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from qtpy.QtCore import Qt
|
||||||
|
|
||||||
|
from bec_widgets.widgets.games import Minesweeper
|
||||||
|
from bec_widgets.widgets.games.minesweeper import LEVELS, GameStatus, Pos
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def minesweeper(qtbot):
|
||||||
|
widget = Minesweeper()
|
||||||
|
qtbot.addWidget(widget)
|
||||||
|
qtbot.waitExposed(widget)
|
||||||
|
yield widget
|
||||||
|
|
||||||
|
|
||||||
|
def test_minesweeper_init(minesweeper: Minesweeper):
|
||||||
|
assert minesweeper.status == GameStatus.READY
|
||||||
|
|
||||||
|
|
||||||
|
def test_changing_level_updates_size_and_removes_old_grid_items(minesweeper: Minesweeper):
|
||||||
|
assert minesweeper.b_size == LEVELS["1"][0]
|
||||||
|
grid_items = [minesweeper.grid.itemAt(i).widget() for i in range(minesweeper.grid.count())]
|
||||||
|
for w in grid_items:
|
||||||
|
assert w.parent() is not None
|
||||||
|
minesweeper.change_level("2")
|
||||||
|
assert minesweeper.b_size == LEVELS["2"][0]
|
||||||
|
for w in grid_items:
|
||||||
|
assert w.parent() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_game_state_changes_to_failed_on_loss(qtbot, minesweeper: Minesweeper):
|
||||||
|
assert minesweeper.status == GameStatus.READY
|
||||||
|
grid_items: list[Pos] = [
|
||||||
|
minesweeper.grid.itemAt(i).widget() for i in range(minesweeper.grid.count())
|
||||||
|
]
|
||||||
|
mine = [p for p in grid_items if p.is_mine][0]
|
||||||
|
|
||||||
|
with qtbot.waitSignal(mine.ohno, timeout=1000):
|
||||||
|
qtbot.mouseRelease(mine, Qt.MouseButton.LeftButton)
|
||||||
|
assert minesweeper.status == GameStatus.FAILED
|
||||||
|
|
||||||
|
|
||||||
|
def test_game_resets_on_reset_click(minesweeper: Minesweeper):
|
||||||
|
assert minesweeper.status == GameStatus.READY
|
||||||
|
minesweeper.grid.itemAt(1).widget().ohno.emit()
|
||||||
|
assert minesweeper.status == GameStatus.FAILED
|
||||||
|
minesweeper.reset_button_pressed()
|
||||||
|
assert minesweeper.status == GameStatus.PLAYING
|
Reference in New Issue
Block a user