Debug Labs — typowe błędy gry zdarzeniowej
Każdy błąd tutaj występuje w prawdziwych projektach studenckich — naucz się rozpoznawać objaw, zanim otworzysz debugator.
Niewielka kolekcja typowych błędów w grach opartych na wydarzeniach, każdy z objawem i Poprawka. Niektóre z nich już widzieliście wcześniej w rozdziale; Tutaj są one zebrane jako źródło referencyjne.
root.bind("<Key>", on_key)
entry = tk.Entry(root)
entry.pack()
entry.bind("<Key>", lambda e: "break") # zatrzymuje łańcuch bindtags
entry.focus_set()
# Пока фокус в entry, нажатия цифр не долетают до on_key —
# entry.bind(..., 'break') обрывает цепочку прямо здесь.
Zdarzenie klawiatury najpierw trafia do skoncentrowanego widżetu, a następnie bindtags: sam widżet → jego → root → klasie „all” "break" - dalsze wiązania w łańcuchu (w tym root.bind) na to wydarzenie po prostu nie zostanie zwołane. Bez tego, "break" zdarzenie normalnie kontynuowałoby ścieżkę do root też — samo skupienie nie blokuje przechwytywania na najwyższym poziomie.
root.bind("<Key>", on_key)
entry = tk.Entry(root)
entry.pack()
entry.focus_set() # bez <Key>wiązania -binding, zdarzenie osiąga root
for indeks in range(9):
btn = tk.Button(frame, command=lambda: attempt_move(indeks))
btn.grid(row=indeks // 3, column=indeks % 3)
# Клик по ЛЮБОЙ из девяти кнопок ставит отметку в клетку 8 —
# потому что все lambda читают ОДНУ И ТУ ЖЕ переменную indeks.
W momencie kliknięcia cykl dawno się zakończył, i indeks wynosi 8 dla wszystkich dziewięciu domknięć naraz — nie „pamiętały”
for indeks in range(9):
btn = tk.Button(frame, command=lambda i=indeks: attempt_move(i))
btn.grid(row=indeks // 3, column=indeks % 3)
def on_cell_enter(self, index):
self.buttons[index].config(text=self.state.current_player)
def find_winner_from_widgets(self):
board = [b["text"] for b in self.buttons] # czyta TEKST PRZYCISKU
return find_winner(board)
# Просто наведя курсор на три пустые клетки одной линии подряд,
# можно получить объявление победы без единого клика.
Jeśli czek zwycięzcy odczytuje status z button["text"], a kursor tymczasowo zapisuje tam tekst — podgląd staje się nie do odróżnienia od rzeczywistego ruchu. Rozdział 17.18 wyjaśnia, dlaczego model musi być źródłem prawdy.
def on_cell_enter(self, index):
if self.state.game_over or self.state.board[index]:
return
self.buttons[index].config(text=self.state.current_player, fg=HOVER_COLOR)
def find_winner_call(self):
return find_winner(self.state.board) # odczytuje MODEL, nie przyciski
state.board[index] = state.current_player
state.current_player = "O" if state.current_player == "X" else "X"
winner, line = find_winner(state.board)
if winner:
status_var.set(f"Wygrana gracza {winner}!") # ale current_player już się zmieniło
# Статус верный ('Победил X'), но если где-то дальше по коду
# используется state.current_player как 'кто только что выиграл' — там уже 'O'.
Kolejność operacji jest ważna: zmiana gracza musi nastąpić PO weryfikacji zwycięzcy i TYLKO jeśli gra trwa dalej. W przeciwnym razie każdy kod, który patrzy na current_player zaraz po turze zobaczy następnego gracza, a nie autora zwycięskiego ruchu.
state.board[index] = state.current_player
winner, line = find_winner(state.board)
if winner:
state.winner = winner # 'kto wygrał' jest ustalone PRZED zmianą gracza
state.game_over = True
elif not is_draw(state.board):
state.current_player = "O" if state.current_player == "X" else "X"
if all(state.board):
status_var.set(„Draw!”)
elif find_winner(state.board)[0]:
status_var.set(„Zwycięstwo!”)
# Девятый ход одновременно заполняет поле и выигрывает диагональ —
# программа объявляет 'Ничья!', хотя есть явный победитель.
Rozdział 17.17 pokazała dokładnie taki scenariusz. Sprawdzanie, czy pole jest wypełnione, musi zostać wykonane PO sprawdzeniu zwycięzcy, w przeciwnym razie ostatni ruch przegrywa przez fałszywe losowanie.
winner, line = find_winner(state.board)
if winner:
status_var.set(f"Wygrana gracza {winner}!")
elif all(state.board):
status_var.set(„Draw!”)
def attempt_move(index):
state.board[index] = state.current_player #brak weryfikacji!
# Повторный клик по уже занятой клетке перезаписывает X на O
# (или наоборот) — чужой ход стирается.
Walidacja powinna być PIERWSZĄ linią, zanim zmieni stan – sekcja 17.15 określa to jako obowiązkowy pierwszy krok algorytmu ruchu.
def attempt_move(index):
if state.game_over or state.board[index]:
return
state.board[index] = state.current_player
def attempt_move(index):
if not state.board[index]:
state.board[index] = state.current_player
state.current_player = "O" if state.current_player == "X" else "X" # na zewnątrz if!
# Клик по занятой клетке ничего не рисует —
# но ход всё равно передаётся сопернику. Игрок теряет очередь ни за что.
Linia zmiany gracza znalazła się POZA blokiem if — wykonuje się niezależnie od tego, czy ruch był prawidłowy. Rozdział 17.15: nieprawidłowy ruch nie powinien zmieniać gracza.
def attempt_move(index):
if state.board[index]:
return
state.board[index] = state.current_player
state.current_player = "O" if state.current_player == "X" else "X"
def attempt_move(index):
if state.board[index]:
return
state.board[index] = state.current_player
# ... sprawdzanie zwycięzcy, ale BRAK return/guard na początku funkcji
# После объявления победы X можно продолжать кликать по пустым клеткам —
# и даже 'перезаписать' исход партии дополнительными ходами O.
Bez jawnej weryfikacji state.game_over na początku attempt_move() nic nie przeszkodzi ci kontynuować grę po jej zakończeniu.
def attempt_move(index):
if state.game_over or state.board[index]:
return
...
WINNING_LINES = (
(0, 1, 2), (3, 4, 5), (6, 7, 8),
(0, 3, 6), (1, 4, 7), (2, 5, 8),
(0, 4, 6), (2, 4, 6), # pierwsza przekątna musi być (0, 4, 8)!
)
# X ставит 0, 4 и 8 — три подряд по настоящей диагонали —
# но игра не объявляет победителя, потому что такой кортеж не в списке.
Literówka jest cicha: kod jest poprawny składniowo i przechodzi nawet powierzchowny test dla „pewnej”
WINNING_LINES = (
(0, 1, 2), (3, 4, 5), (6, 7, 8),
(0, 3, 6), (1, 4, 7), (2, 5, 8),
(0, 4, 8), (2, 4, 6),
)
def new_round(self):
for btn in self.buttons:
btn.config(text="") # guziki są czyste...
# ... Ale self.state.board wciąż trzyma stare X/O!
# Поле выглядит пустым, но следующий attempt_move()
# натыкается на 'клетка уже занята' там, где на экране пусто.
Reset wizualny nie jest tym samym co resetowanie modelu. Rozdział 17.18: Przyciski wyświetlają tylko stan; jeśli wyczyścisz tylko je, model nadal kłamie co do rzeczywistego stanu partii.
def new_round(self):
self.state.board = [""] * 9
self.state.current_player = "X"
self.state.game_over = False
self.render() #UI jest aktualizowany OD modelu, a nie osobno
def new_round(self):
self.state.board = [""] * 9
self.state.current_player = "X"
self.state.game_over = False
# zapomniano self.render() na końcu!
# Модель для нового раунда готова правильно —
# но на экране всё ещё видны старые X и O из прошлой партии.
Druga strona tego samego błędu: brak połączenia render() zmiany w modelu nigdy nie pojawiają się na ekranie. Rozdział 17.20 stanowi tę zasadę: render() to miejsce, które rysuje widżety Z modelu (state) i musi być wywoływane jawnie po każdej zmianie tego modelu.
def new_round(self):
self.state.board = [""] * 9
self.state.current_player = "X"
self.state.game_over = False
self.render()
def new_round(self):
self.state.score_x = 0 # powinno być tylko w new_match()!
self.state.score_o = 0
self.state.board = [""] * 9
# После каждой победы счёт тут же обнуляется —
# турнирный счёт невозможно накопить.
Rozdział 17.27: New Round i New Match to różne transakcje. Zeroowanie rachunku należy WYŁĄCZNIE new_match().
def new_round(self):
self.state.board = [""] * 9
# konto (score_x/score_o/draws) nie dotykaj tutaj
def on_cell_leave(self, index):
self.buttons[index].config(text="") #naively „usuń podglądy”
# После того как игрок делает настоящий ход и потом уводит курсор,
# отметка на клетке пропадает с экрана — хотя в модели ход остался.
on_cell_leave nie wie, czy był zapowiedź, czy prawdziwy ruch na klatce — po prostu bezwarunkowo usuwa tekst. Właściwe podejście to nie zgadywać, lecz rysować na podstawie modelu, który zna prawdę dokładnie.
def on_cell_leave(self, index):
self.render() # decyduje, co powinno być na komórce
def on_click(self, event):
event.widget.config(text=self.state.current_player)
# gdzie jest wpis w board tutaj? Nie ma go — państwo żyje TYLKO w widgetzie
# find_winner(self.state.board) никогда не видит эти ходы —
# победа не определяется, потому что модель не менялась.
event.widget — źródło UI- zdarzenia (rozdział 17.8), a nie domena gry. Zapis ruchu musi trafić do state.board, w przeciwnym razie cała logika domenowa (szukanie zwycięzcy, remis) działa na ślepo.
def on_click(self, event, index):
self.attempt_move(index) # zmienia state.board, potem render()
btn.bind("<Button-1>", lambda e, i=index: attempt_move(i))
#command= wcale nie jest ustawiony
# Обработчик связан только с конкретным событием мыши.
# Основное действие Button больше не описано через command=.
Rozdział 17.10: command= opisuje główną czynność Button, a wbudowane zachowanie klasy widgetu decyduje, które sposoby aktywacji go wywołują. Konkretne klawisze zależą od rodzaju Button, motywu i platformy. Same powiązania <Button-1> zna tylko kliknięcie myszką i omija tę semantykę — więc nie używaj go zamiast domyślnego command.
btn.config(command=lambda i=index: attempt_move(i))
import time
def pulse_winning_line(self):
for _ in range(6):
self.buttons[0].config(bg="green")
time.sleep(0.15)
self.buttons[0].config(bg="white")
time.sleep(0.15)
# Окно перестаёт отвечать почти на две секунды анимации —
# та же ошибка, что и в главе 16 (раздел 16.32).
time.sleep() blokuje całą pętlę zdarzeń. Dla animacji nieblokujących musisz self-rescheduling przejść after() - Rozdział 17.30.
def pulse_winning_line(self, tick=0):
color = PULSE_BG if tick == 0 else WIN_BG
for i in self.state.winning_line:
self.buttons[i].config(bg=color)
if tick == 0:
self.root.after(400, self.pulse_winning_line, 1)
btn = ttk.Button(controls, text=„Nowa runda”, command=self.new_round)
btn.bind("<Button-1>", lambda e: self.new_round(), add="+")
# teraz jedno kliknięcie uruchamia new_round() PO command I PO bind – dwa razy z rzędu
# Само по себе new_round() дважды подряд не ломает игру —
# но если бы это была, например, функция увеличения счёта, счёт скакнул бы на 2.
add="+" dodaje JEDEN DODATKOWY handler bez zastępowania istniejącego (sekcja 17.12 określała to jako „trochę głębszy” command=, powtarzające wiązanie poprzez bind(..., add="+") uruchamia logikę ponownie na tym samym zdarzeniu add="+" świadomie tylko wtedy, gdy naprawdę potrzebujesz KILKU niezależnych opiekunów tego samego wydarzenia.
btn = ttk.Button(controls, text=„Nowa runda”, command=self.new_round)
# I to wszystko – jedno wiązanie wystarczy