Az aknakereső (Minesweeper) egy logikai játék, mely adott sor- és oszlopszámú táblázatban elrendezett mezőcellákat tartalmaz, amelyek közül, meghatározott számú, „aknát” rejt. A cél az egyes cellák felfedésével az összes akna megtalálása, illetve azok elkerülése. Ha sikerül az összes nem aknát tartalmazó cellát felfedni, akkor a játék győzelemmel befejeződik. Ha egy felfedett cellában akna van, akkor a játék vereséggel azonnal véget ér. A részletszabályok az interneten megtalálhatók, többek között például magyarul az „Aknakereső” vagy angolul a „Minesweeper” Wikipedia szócikkeknél.
Ebben a bejegyzésben e játék egy lehetséges megvalósítását mutatjuk grafikus felhasználói felülettel a tkinter modul eszköztárával megvalósítva. Ennek kinézete hasonló lesz más megvalósításokéhoz.
A játékfelület két részre tagolódik. Felül, egy sorban látható a zászlószámláló, illetve annak aktuális értéke, mellette az új játékot indító nyomógomb. Ettől jobbra pedig a játék megkezdése óta eltelt idő látható, ami másodpercenként növekszik. Ez alatt látható a játékmező, amely a felfedezésre váró cellákat négyzethálós elrendezésben jeleníti meg.
A játék indításakor a zászlószámláló az elrejtett aknák számát mutatja. Ha a játéktér bármely fel nem fedett celláján zászlót jelenítünk meg, vagyis úgy gondoljuk, hogy ott akna van, a zászlószámláló értéke eggyel csökken mutatva, hogy még mennyi felderítendő akna van hátra. Az időmérés a játékmezőn történő első cella felfedésével kezdődik, amin soha nincs akna.
A programkódot alább láthatjuk. A részletes kommentek segítik a megértést.
Elsőként a játék logikai modelljét megvalósító osztálydefiníciót mutatjuk. A MinesweeperModel osztály lényegét tekintve egy erre a játékra adaptált bináris mátrixot valósít meg, amelynek a működési elvéről a korábbi, „Bináris mátrixok megvalósítása” című bejegyzésben volt szó.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 |
# modul: minesweeper_model.py from itertools import product from random import randint class MinesweeperModel: """Az aknakereső játék cellamezőit modellező osztály bináris mátrixként megvalósítva, ahol az 1 értékek az elhelyezett aknákat jelentik. """ def __init__(self, row_count: int, column_count: int, mine_count: int | None = None): # A sor és oszlopok száma 8 vagy nagyobb egész szám, az aknák száma pozitív egész, ami kisebb, mint a cellák száma. if not isinstance(row_count, int) or not isinstance(column_count, int) or row_count < 8 or column_count < 8: raise ValueError('A sorok és oszlopok száma egy legalább 8 értékű egész szám kell, hogy legyen.') if mine_count is not None and (not isinstance(mine_count, int) or mine_count <= 0): raise ValueError('Az aknák száma pozitív egész szám kell, hogy legyen.') self.rowcount, self.columncount = row_count, column_count # Ha nincs megadva aknaszám, akkor ez a cellaszámmal úgy lesz arányos, ahogy a 10 akna a 8x8-as tábla 64 cellájával. minecount = int(len(self) * 10 / 64) if mine_count is None else mine_count if minecount >= len(self): raise ValueError('Az aknák száma kisebb kell, hogy legyen a cellák számánál.') self.minecount = minecount self.virtual_list_indexes_of_mines = set() # Az aknák indexei a bináris mátrixot leképező virtuális listában. def __str__(self): return ''.join([str(self.get_value(*self.virtual_list_index_to_gridcoords(i))) + ('\n' if (i + 1) % self.columncount == 0 else ' ') for i in range(len(self))]) def _check_indexes(self, row_index: int, column_index: int): """A sor- és oszlopindexek helyességét ellenőrző segédmetódus.""" if not isinstance(row_index, int): raise TypeError('A sorindex nem egész szám.') if not isinstance(column_index, int): raise TypeError('Az oszlopindex nem egész szám.') if row_index not in range(self.rowcount): raise IndexError(f'A sorindex a 0..{self.rowcount - 1} tartományon kívül esik.') if column_index not in range(self.columncount): raise IndexError(f'Az oszlopindex a 0..{self.columncount - 1} tartományon kívül esik.') def generate_mines_randomly(self): """Adott számú akna véletlenszerű elhelyezése a cellákban.""" self.virtual_list_indexes_of_mines.clear() while len(self.virtual_list_indexes_of_mines) != self.minecount: self.virtual_list_indexes_of_mines.add(randint(0, len(self) - 1)) def gridcoords_to_virtual_list_index(self, row_index, column_index) -> int: """A cellarács koordinátáinak (sor- és oszlopindex pár) megfelelő virtuális listaindexszel tér vissza.""" self._check_indexes(row_index, column_index) return self.columncount * row_index + column_index def virtual_list_index_to_gridcoords(self, virtual_list_index) -> tuple: """A virtuális listaindexnek megfelelő cellarács koordinátákkal (sor- és oszlopindex pár) tér vissza.""" if virtual_list_index not in range(len(self)): raise ValueError("Érvénytelen virtuális lista index.") return divmod(virtual_list_index, self.columncount) def adjacent_cells_coords(self, row_index, column_index) -> list[tuple[int, int]]: """Az argumentumban megadott sor- és oszlopindexekkel azonosított cella szomszédainak sor- és oszlopindexeit tartalmazó tuple-okat adja vissza egy listában. """ self._check_indexes(row_index, column_index) return [(ri, ci) for ri, ci in product(range(row_index - 1, row_index - 1 + 3), range(column_index - 1, column_index - 1 + 3)) if ri in range(self.rowcount) and ci in range(self.columncount) and (ri, ci) != (row_index, column_index)] def number_of_mines_in_adjacent_cells(self, row_index, column_index) -> int: """Visszaadja, hogy az argumentumban megadott sor- és oszlopindexekkel azonosított cella szomszédai összesen hány aknát tartalmaznak. """ self._check_indexes(row_index, column_index) return sum(self.get_value(*cell_coords) for cell_coords in self.adjacent_cells_coords(row_index, column_index)) def get_value(self, row_index, column_index): """Visszaadja megadott sor- és oszlopindexekkel azonosított cella értékét.""" self._check_indexes(row_index, column_index) return 1 if self.gridcoords_to_virtual_list_index(row_index, column_index) in self.virtual_list_indexes_of_mines else 0 def __len__(self): """Visszaadja cellák számát.""" return self.columncount * self.rowcount |
Szükségünk lesz egy időmérő órára, ami az eltelt játékidőt folyamatosan mutatja. E StopWatch nevű osztály definíciója a következő:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
from time import time, strftime, gmtime import tkinter as tk from typing import TypeAlias GraphicalObject: TypeAlias = tk.Tk | tk.Toplevel | tk.Widget class StopWatch: def __init__(self, master: GraphicalObject, controll_variable: tk.StringVar): self.start_time = 0 self.master = master self.control_var = controll_variable self.id = None def start(self): """Időmérés indítása.""" self.start_time = time() # Az indításkori idő az epoch-tól számítva másodpercben. self._measure_time() # Az idő mérésének megkezdése. def _measure_time(self): """Az indítástól eltelt időt méri folyamatosan. A kontrollváltozó értékét 1 másodpercenként aktualizálja az eltelt időt 00:00 (perc, másodperc) formátumú karakterláncként átadva. """ elapsed_time = time() - self.start_time # Az indítástól eltelt idő másodpercben. self.control_var.set(f'{strftime("%M:%S", gmtime(elapsed_time))}') # E metódus hívása 1000 ms eltelte után. # A visszaadott azonosító az ütemezett hívás after_cancel() metódussal való törléséhez # mint argumentum szükséges (ld. stop() metódusban) self.id = self.master.after(1000, self._measure_time) def stop(self): """Időmérés leállítása.""" # Az after() metódussal indított, adott azonosítójú ütemezett hívás törlése. self.master.after_cancel(self.id) def reset(self): self.start() self.stop() |
Az alkalmazást jelentő főprogram az alábbi, amely a fenti két osztályt használja.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 |
# modul: main.py from __future__ import annotations import tkinter as tk from tkinter.simpledialog import askstring from tkinter.messagebox import showinfo, showerror from itertools import product from minesweeper_model import MinesweeperModel from stop_watch import StopWatch class ControlPanel(tk.Frame): def __init__(self, master: MineSweeper, **options): super().__init__(master, **options) self.master: MineSweeper = master # A vezérlőpanel sáv grafikus elemeinek létrehozása. common_options = dict(font=('Helvetica', 12, 'bold')) self.flag_counter_lbl = tk.Label(self, textvariable=self.master.flag_counter, bg='black', fg='yellow', width=5, **common_options) self.new_game_btn = tk.Button(self, text='ÚJ JÁTÉK', **common_options, command=self.master.start_new_game) self.new_game_btn.bind('<Button 3>', lambda event: self.master.size_new_gamefield()) self.playing_time_lbl = tk.Label(self, textvariable=self.master.playing_time, bg='silver', fg='blue', width=7, **common_options) # A vezérlőpanel sáv grafikus elemeinek lehelyezése. self.flag_counter_lbl.grid(row=0, column=0, sticky='news') self.new_game_btn.grid(row=0, column=1, sticky='news') self.playing_time_lbl.grid(row=0, column=2, sticky='news') self.grid_columnconfigure([0, 2], weight=1, uniform='a', pad=0) self.grid_columnconfigure(1, weight=1) class GameField(tk.Frame): def __init__(self, master: MineSweeper, **options): super().__init__(master, **options) self.master: MineSweeper = master # Az időmérő működésre kész induló állapotba hozása. self.stop_watch = self.master.stop_watch self.stop_watch.reset() # A masterből átvett, szükséges példányattribútumok. self.model = self.master.model self.rowcount, self.columncount = self.master.rowcount, self.master.columncount self.cell_size = self.master.cell_size self.flag_counter = self.master.flag_counter # Az egyéb szükséges példányattribútumok. self.current_widget = None # Az aktuálisan kiválasztott cella widget-je. self.is_first_cell = True self.visited_coords = set() # A már felfedezett (meglátogatott) cellák koordinátái. # Egy adott cella szomszédságában levő aknák számát jelző számjegyek színei. self.num_colors = {1: 'blue', 2: 'green', 3: 'red', 4: 'salmon', 5: 'orange', 6: 'brown', 7: 'black', 8: 'gray'} # Új játék indításához az előző tábla grafikus elemeit eltávolítjuk, ha voltak ilyenek. for widget in self.winfo_children(): widget.destroy() # A cellák számának megfelelő mennyiségű Canvas példány létrehozása a meghatározott méretű négyzet alakban. canvas_configs = dict(bd=6, relief=tk.RAISED, highlightthickness=0) canvases = (tk.Canvas(self, width=self.cell_size, height=self.cell_size, **canvas_configs) for _ in range(len(self.model))) # A Canvas példányok lehelyezése táblázatos elrendezésben, valamint a bal és jobb egérgomblenyomás # események és a meghívott eseményekezők hozzárendelése. for cnv, grid_coords in zip(canvases, product(range(self.rowcount), range(self.columncount))): ri, ci = grid_coords cnv.grid(row=ri, column=ci, sticky='news') cnv.bind('<Button 1>', self._on_cell_left_click) cnv.bind('<Button 3>', self._on_cell_right_click) # A táblázat sorai és oszlopai minimális méretének beállítása az aktuális cellaméret és a Canvas példány szegélyvastagsága alapján. self.grid_rowconfigure('all', minsize=self.cell_size + float(2 * canvas_configs.get('bd'))) self.grid_columnconfigure('all', minsize=self.cell_size + float(2 * canvas_configs.get('bd'))) # Az adott számú akna véletlenszerű elhelyezése a cellákban, és az aknaszám mint kezdőérték kiírása a zászlószámlálón. self.model.generate_mines_randomly() self.flag_counter.set(self.model.minecount) def _clear_cell_event_bindings(self): """A játékmező összes grafikus elemét eseményérzéketlenné teszi.""" for wg in self.winfo_children(): wg.unbind('<1>') wg.unbind('<3>') def _is_victory_condition_met(self): """True értékkel tér vissza, ha a győzelmi feltétel teljesül, vagyis a nem felfedett cellák száma megegyezik az aknák számával.""" return len(self.model) - len(self.visited_coords) == self.model.minecount def _on_cell_left_click(self, event): """Bal egérgomb kattintás eseménykezelője.""" # Azonosítjuk az eseménnyel érintett widgetet, és meghatározzuk a tartalmazó cella sor- és oszlopindexeit. self.current_widget = event.widget ri, ci = self.current_widget.grid_info().get('row'), self.current_widget.grid_info().get('column') if self.is_first_cell: if self.model.get_value(ri, ci): # Ha a kiválasztott mező az első és ezen akna van, akkor addig helyezzük újra az aknákat, hogy ezen a mezőn ne legyen. while self.model.gridcoords_to_virtual_list_index(ri, ci) in self.model.virtual_list_indexes_of_mines: self.model.generate_mines_randomly() # A legelső cella kiválasztásakor indítjuk az időmérőt. self.stop_watch.start() self.is_first_cell = False if self.model.get_value(ri, ci): # Ha a kiválasztott mezőn akna van, akkor a játék vereséggel véget ér. self._end_game_defeat() return # A kiválasztott, azaz felfedett cellának megváltoztatjuk a kinézetét. self.current_widget.config(relief=tk.SOLID, bd=1, bg='white') # A cellát megjelöljük felfedettnek (látogatottként) eltárolva a rácskoordinátáit. self.visited_coords.add((ri, ci)) # Ha a kiválasztott cella szomszédai között van legalább egy akna, akkor a mezőre kiírjuk a szomszédos aknák számát. if mine_count := self.model.number_of_mines_in_adjacent_cells(ri, ci): self._show_minecount_on_current_cell(mine_count) else: # Ha a kiválasztott cella szomszédain nincs akna, akkor automatikusan felfedjük az összes cellát, amelyeken # nincs akna, mindaddig, amíg olyan cellákat nem találunk, amelyeknek a szomszédságában van legalább egy akna. # Az ilyen cellákra a szomszédos aknák száma kiírásra kerül. self._explore_safe_fields(ri, ci) # Ha az aktuális cellára kattintás után teljesül a nyerési feltétel, akkor a játék ennek megfelelően véget ér. if self._is_victory_condition_met(): self._end_game_wictory() def _explore_safe_fields(self, ri, ci): """Felfedi az összes olyan cellát, amelyeken nincs akna, mindaddig, amíg olyan cellákat nem találunk, amelyeknek a szomszédságában van legalább egy akna. Az ilyen cellákra a szomszédos aknák száma kiírásra kerül. """ # Az alkalmazott algoritmus: # 1. Nézd meg, hogy az aktuális, (ri, ci) rácskoordinátájú cella szomszédai tartalmaznak-e aknát. # 2a. Ha igen, # - írd ki az aktuális cellában a szomszédos aknák számát. Lépj ki az eljárásból és # várj a köv. cellakiválasztásra. # 2b. Ha nem, azaz nincs a szomszédban akna, akkor # - vedd sorban egymás után az aktuális cella még fel nem fedett szomszédait, # - minden egyes szomszéd cellára, annak koordinátáját véve aktuális cellának # ismételd meg az 1. ponttól. (vagyis hívd meg ezen koordinátákkal e metódust újra). adj_ri, adj_ci = ri, ci # Ha a kiválasztott cella szomszédai között van legalább egy akna, akkor a mezőre kiírjuk a # szomszédos aknák számát és visszatérünk e metódusból. if mine_count := self.model.number_of_mines_in_adjacent_cells(adj_ri, adj_ci): self._show_minecount_on_current_cell(mine_count) else: # Ha a szomszédok között nincs akna, akkor sorra vesszük a még nem felfedett szomszédokat, és megnézzük, hogy # azokban hány akna van. Ha azokban sincs akna, akkor tovább folytatjuk azok szomszédaival. for adj_ri, adj_ci in (set(self.model.adjacent_cells_coords(adj_ri, adj_ci)) - self.visited_coords): self.current_widget: tk.Canvas = self.grid_slaves(adj_ri, adj_ci)[0] # A felfedett cellának megváltoztatjuk a kinézetét. self.current_widget.config(relief=tk.SOLID, bd=1, bg='white') # A cellát megjelöljük felfedettnek eltárolva a rácskoordinátáit. self.visited_coords.add((adj_ri, adj_ci)) # Ha a cella felfedése nyerő helyzetet terent, akkor kilépünk, egyébként pedig folytatjuk a cellák felfedését # az aktuális rácskoordinátájú cellától kiindulva. if self._is_victory_condition_met(): return else: self._explore_safe_fields(adj_ri, adj_ci) def _end_game_wictory(self): """Sikeres játék esetén meghívott metódus, amely leállítja az időmérést, feldob egy üzenetablakot, és eseményérzéketlenné teszi a cellákat.""" self.stop_watch.stop() showinfo('a játék eredménye'.upper(), 'NYERTÉL!', detail='Minden aknát feldezetél.') self._clear_cell_event_bindings() def _end_game_defeat(self): """Sikertelen játék esetén meghívott metódus, amely kirajzolja az aknát, leállítja az időmérést, feldob egy üzenetablakot, és eseményérzéketlenné teszi a cellákat.""" self._draw_mine_symbol_in_current_cell() self.stop_watch.stop() self._clear_cell_event_bindings() showinfo('a játék eredménye'.upper(), 'Aknára léptél, ezért vesztettél!') def _draw_mine_symbol_in_current_cell(self): """Az aktuális cellában egy akna szimbólumot rajzol ki.""" w, h = self.current_widget.winfo_width(), self.current_widget.winfo_height() r = w / 4 cpx, cpy = w / 2, h / 2 x1, y1 = w / 2 - r, h / 2 - r x2, y2 = w / 2 + r, h / 2 + r self.current_widget.create_oval((x1, y1), (x2, y2), fill='black') c = 1.4 a = c * r * 3 ** 0.5 self.current_widget.create_polygon((cpx, cpy - c * r), (cpx + a / 2, cpy + c * r / 2), (cpx - a / 2, cpy + c * r / 2), fill='black') self.current_widget.create_polygon((cpx, cpy + c * r), (cpx - a / 2, cpy - c * r / 2), (cpx + a / 2, cpy - c * r / 2), fill='black') def _show_minecount_on_current_cell(self, mine_count): """Az aktuális cellában egy címkén megjeleníti a szomszédos cellákban található aknák számát.""" cnv_size = self.current_widget.winfo_height() lb = tk.Label(self.current_widget, text=str(mine_count), font=('Tahoma', round(cnv_size * 40 / 80), 'bold'), fg=self.num_colors[mine_count], bg='white') # A címke Canvasra helyezéséhez egy window rajzelemet készítünk a Canvason, amibe a címkét tesszük. self.current_widget.create_window(cnv_size / 2, cnv_size / 2, window=lb, width=self.cell_size, height=self.cell_size) self.current_widget.unbind('<Button 1>') # Az aktuális felfedett cellát eseményérzéketlenné tesszük. def _on_cell_right_click(self, event): """Jobb egérgomb kattintás eseménykezelője. Kattintásra az aktuális cellában megjelenít egy zászlót és a zászló számlálót, ami a még nem megjelenített zászlók számát tartja nyílván, eggyel csökkenti. Egy újabb kattintásra a zászló eltávolításra kerül és a zászló számláló eggyel nö.""" current_widget: tk.Canvas = event.widget if type(event.widget) is tk.Canvas else event.widget.master cnv_size = current_widget.winfo_height() if not current_widget.gettags('flagwindow'): # Ha még nincs, a Canvas példányon egy címkét helyezünk le, ami egy zászló karakter ábrázol. Ehhez egy window # rajzelemet készítünk a Canvason, amibe a címkét tesszük. lb = tk.Label(current_widget, text=chr(0x1F6A9), font=('Courier', round(self.cell_size * 40 / 80), 'bold')) lb.bind('<Button 3>', self._on_cell_right_click) current_widget.create_window(cnv_size / 2, cnv_size / 2, height=self.cell_size, width=self.cell_size, window=lb, tags=('flagwindow',)) # A flag számolót, ami a még nem megjelenített zászlók számát tartja nyílván, eggyel csökkentjük. self.flag_counter.set(self.flag_counter.get() - 1) else: # Ha a Canvas példányon már van zászlócímke egy window elemben, akkor azt töröljük. current_widget.delete('flagwindow') # A flag számolót eggyel visszanöveljük. self.flag_counter.set(self.flag_counter.get() + 1) class MineSweeper(tk.Tk): def __init__(self, row_count=8, column_count=8, mine_count=None): super().__init__() self.title('Aknakereső') self.resizable(False, False) # A modellobjektum létrehozása. self.model = MinesweeperModel(row_count, column_count, mine_count) self.rowcount, self.columncount, self.minecount = row_count, column_count, self.model.minecount # Az aknajelölés (zászlók) számlálójának és a játékidő megjelenítés kontrollváltozók létrehozása. self.flag_counter = tk.IntVar(self, value=0, name='flagcounter') self.playing_time = tk.StringVar(self, value='00:00', name='playingtime') # A játékidőmérő létrehozása. self.stop_watch = StopWatch(self, self.playing_time) # A vezérlőpanel létrehozása és lehelyezése a főablakban. self.control_panel = ControlPanel(self, name='controlpanel', bd=10, relief=tk.RIDGE) self.control_panel.grid(row=0, column=0, sticky='news') # Az aktuális cellaméret meghatározása és a játékterület létrehozása és lehelyezése a főablakban. self.cell_size = self.calc_cell_size(self.rowcount, self.columncount) self.game_field = GameField(self, name='gamefield', bd=10, relief=tk.RIDGE) self.game_field.grid(row=1, column=0, sticky='news') @staticmethod def calc_cell_size(row_count, column_count): """Az aktuális sor- és oszlopszám alapján kiszámolja és visszaadja az alkalmazandó cellaméretet pixelben. Referencia az alapértelmezett 8 sorhoz és 8 oszlophoz tartozó méret.""" return 40 * 8 / min(row_count, column_count) def start_new_game(self): """Új játék indítása. Leállítja az időmérőt, az aktuális modelladatok (sor-, oszlop-, aknaszám) alapján létrehozza az új játékterületet, véletlenszerűen elhelyezi a mezőcellákban az aknákat, és a zászlószámláló kezdőértékét az aknaszámra állítja. """ self.stop_watch.stop() self.game_field = GameField(self, name='gamefield', bd=10, relief=tk.RIDGE) self.game_field.grid(row=1, column=0, sticky='news') self.model.generate_mines_randomly() self.flag_counter.set(self.model.minecount) def size_new_gamefield(self): """A megjelenő párbeszédablakban a játékterület méreteit (sor- és oszlopszám) és opcionálisan az aknák számát lehet megadni. Ha az aknaszám nincs megadva, akkor a megadott sor- és oszlopszámból kiadódó cellaszám egy, a modellben meghatározott aránya lesz az aknaszám. Alapértelmezett játékterület 8x8 méretű 10 aknával, ez jelenik meg a párbeszédablakban kezdőértékként. Az adatok helytelen megadása esetén egy felugró üzenetablak figyelmeztet erre. """ self.stop_watch.stop() default = '8, 8, 10' input_string = askstring('A játékjellemzők meghatározása'.upper(), 'Add meg vesszővel elválasztva a sor és oszlopszámot, valamint az aknák számát (opcionális)', initialvalue=default) txt = input_string if input_string else default try: # Input szintaxis ellenőrzése. self.rowcount, self.columncount, *minecount = (int(c) for c in txt.strip(',').split(',')) self.minecount = int(minecount[0]) if minecount else None except ValueError: showerror(f'játékjellemző megadási hiba'.upper(), 'Hibás sor-, oszlop-, vagy aknaszám megadás.') return try: # Csak a modell szerint helyes adatokkal indul újra a játék. self.model = MinesweeperModel(self.rowcount, self.columncount, self.minecount) self.cell_size = self.calc_cell_size(self.rowcount, self.columncount) self.start_new_game() except ValueError as exc: showerror(f'játékjellemző értékadási hiba'.upper(), 'Nem megfelelő sor-, oszlop-, vagy aknaszám.', detail=exc) def run(self): self.mainloop() MineSweeper().run() |
A játékot a main modul szkriptként futtatásával lehet indítani, amihez Python 3.10+ verzió szükséges. A forráskód elérhető itt is: https://github.com/pythontudasepites/minesweeper
Ami az alkalmazás felépítését illeti, a játékfelület felső részét a ControlPanel osztály, a négyzethálós játékmezőt a GameField osztály valósítja meg. Az időmérőt a StopWatch osztály, a játék logikai modelljét pedig a MinesweeperModel osztály. Minezen komponensekből épül fel a MineSweeper osztály, ami egyben a főablakot is képviseli és jeleníti meg futtatáskor. Az osztálykapcsolatokat a következő ábra mutatja.

A játékmező celláit egy-egy vászon elem (Canvas példány) teszi láthatóvá. Ezek két eseményre reagálnak. Bal egérgomb kattintással felfedjük a cellát. Ekkor, ha azon nem akna van, a szomszédos cellákban rejtőző aknák száma az aktuális cellán egy címke felirataként jelenik meg. Ha viszont az aktuális cella aknát tartalmaz, akkor a játék vereséggel véget ér, és az aknaszimbólum megjelenik. De azért, hogy a rajzolást is gyakoroljuk, ezt nem egy aknát/bombát ábrázoló Unicode karakter címke feliraton való feltüntetésével tesszük, hanem a cella Canvas példányán rajzoljuk ki a vászon elemhez rendelkezésre álló rajzelemekkel. Ennek tervrajzát mutatja ez az ábra:

Ha sem az aktuálisan felfedett cella, sem a közvetlen szomszédai nem tartalmaznak aknát, akkor a cellák automatikusan mindaddig felfedődnek, amíg a szomszédaikban nem lesz legalább egy akna. Ezen cellákra ki is lesz írva a szomszédos aknák száma. Mivel ez az automatikus cellafelfedés kulcseleme a játéknak és megvalósítása némi megfontolást igényel, ezért a GameField osztály _explore_safe_fields() metódusában az algoritmus fő lépéseit kommentben kirészleteztük. Ez hasonlít a mélységi gráfbejáráshoz, amit itt rekurzióval implementáltunk.
Jobb egérgomb kattintás esetén a cella nem lesz felfedve, hanem egy zászlót ábrázoló karaktert jelenítünk meg a cella vászon elemén elhelyezett címkén, és egyúttal a zászlószámlálót eggyel csökkentjük. Újabb jobb egérgomb kattintásra a zászló eltűnik és a zászlószámláló értéke eggyel nő. Vagyis a jobb egérgomb lenyomás oda-vissza kapcsoló (toggle) üzemmódú.
Az „ÚJ JÁTÉK” feliratú gombra kattintva változatlan játékparaméterekkel (sor- és oszlopszám, valamint az elrejtett aknák száma) kezdhető új játék. Ha más paraméterekkel akarunk játékot indítani, akkor az „ÚJ JÁTÉK” gombra a jobb egérgombbal kell kattintani. A felugró párbeszédablak beviteli mezőjébe lehet megadni vesszővel elválasztott egész számokkal az új sor és oszlopszámot, valamint opcionálisan az aknák számát. Mivel az alapértelmezett játékterület 8×8 méretű 10 aknával, ez jelenik meg a párbeszédablakban kezdőértékként. Ha nem adunk meg aknaszámot, akkor a program számolja azt ki a sor- és oszlopértékekből kiadódó összcellaszám alapján. Ez a cellaszámmal úgy lesz arányos, ahogy a 10 akna a 8×8-as tábla 64 cellájával.
A következő képernyőkép alapértelmezett paraméterekkel indított játékot mutat nyert és vesztett végállapotban.

Ez az ábra pedig az alapértelmezettől eltérő paraméterekkel indított játékot mutat, ahol az új játékjellemzők beviteli ablakát is láthatjuk.

Ahogy mindig, most is ajánlott először megpróbálkozni a saját megvalósítással, és csak utána, vagy esetleg menet közben, megnézni az itt közölt megoldást, mert a problémamegoldás és a programnyelvben való jártasság így sokkal hatékonyabban fejlődik.
Egyébként pedig jó szórakozást a játékhoz!
A grafikus felhasználói felület létrehozásával, példákkal illusztrált részletes leírásával a Python tudásépítés lépésről lépésre című e-könyv „Grafikus felhasználói felület készítése” fejezete foglalkozik.