Rozdział 17 · Testy

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.

Debug Lab 1: Niestandardowe powiązanie widgetów z break wycisza klawisze gry
focus_problem.py
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') обрывает цепочку прямо здесь.
Co widać na ekranie

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.

Poprawiony kod
focus_fixed.py
root.bind("<Key>", on_key)

entry = tk.Entry(root)
entry.pack()
entry.focus_set()   # bez <Key>wiązania -binding, zdarzenie osiąga root
Debug Lab 2: lambda bez i=indeks — późne przypisanie
lambda_pozdnyaya_privyazka.py
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.
Co widać na ekranie

W momencie kliknięcia cykl dawno się zakończył, i indeks wynosi 8 dla wszystkich dziewięciu domknięć naraz — nie „pamiętały”

Poprawiony kod
lambda_fixed.py
for indeks in range(9):
    btn = tk.Button(frame, command=lambda i=indeks: attempt_move(i))
    btn.grid(row=indeks // 3, column=indeks % 3)
Debug Lab 3: Najechanie myli zapowiedź z prawdziwym ruchem
hover_kak_hod.py
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)
# Просто наведя курсор на три пустые клетки одной линии подряд,
# можно получить объявление победы без единого клика.
Co widać na ekranie

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.

Poprawiony kod
hover_ne_hod.py
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
Debug Lab 4: Gracz przełącza się PRZED sprawdzeniem zwycięzcy
smena_do_proverki.py
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'.
Co widać na ekranie

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.

Poprawiony kod
smena_posle_proverki.py
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"
Debug Lab 5: Remis sprawdzany jest przed zwycięstwem
nichya_do_pobedy.py
if all(state.board):
    status_var.set(„Draw!”)
elif find_winner(state.board)[0]:
    status_var.set(„Zwycięstwo!”)
# Девятый ход одновременно заполняет поле и выигрывает диагональ —
# программа объявляет 'Ничья!', хотя есть явный победитель.
Co widać na ekranie

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.

Poprawiony kod
pobeda_do_nichyej.py
winner, line = find_winner(state.board)
if winner:
    status_var.set(f"Wygrana gracza {winner}!")
elif all(state.board):
    status_var.set(„Draw!”)
Debug Lab 6: Ruch na zajętą komórkę jest dozwolony
zanyataya_kletka.py
def attempt_move(index):
    state.board[index] = state.current_player   #brak weryfikacji!
# Повторный клик по уже занятой клетке перезаписывает X на O
# (или наоборот) — чужой ход стирается.
Co widać na ekranie

Walidacja powinna być PIERWSZĄ linią, zanim zmieni stan – sekcja 17.15 określa to jako obowiązkowy pierwszy krok algorytmu ruchu.

Poprawiony kod
zanyataya_kletka_fixed.py
def attempt_move(index):
    if state.game_over or state.board[index]:
        return
    state.board[index] = state.current_player
Debug Lab 7: Gracz przełącza się nawet po nieprawidłowym kliknięciu
smena_pri_nevalidnom_klike.py
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!
# Клик по занятой клетке ничего не рисует —
# но ход всё равно передаётся сопернику. Игрок теряет очередь ни за что.
Co widać na ekranie

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.

Poprawiony kod
smena_pri_nevalidnom_klike_fixed.py
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"
Debug Lab 8: Brak game_over check – gra trwa dalej po wygranej
net_game_over.py
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.
Co widać na ekranie

Bez jawnej weryfikacji state.game_over na początku attempt_move() nic nie przeszkodzi ci kontynuować grę po jej zakończeniu.

Poprawiony kod
game_over_guard.py
def attempt_move(index):
    if state.game_over or state.board[index]:
        return
    ...
Debug Lab 9: Literówka w WINNING_LINES
opechatka_v_linii.py
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 — три подряд по настоящей диагонали —
# но игра не объявляет победителя, потому что такой кортеж не в списке.
Co widać na ekranie

Literówka jest cicha: kod jest poprawny składniowo i przechodzi nawet powierzchowny test dla „pewnej”

Poprawiony kod
linii_fixed.py
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),
)
Debug Lab 10: Zresetuj interfejs, ale nie model
reset_tolko_ui.py
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()
# натыкается на 'клетка уже занята' там, где на экране пусто.
Co widać na ekranie

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.

Poprawiony kod
reset_model_i_ui.py
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
Debug Lab 11: Zresetowałem model, ale nie zadzwoniłem do render()
reset_tolko_model.py
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 из прошлой партии.
Co widać na ekranie

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.

Poprawiony kod
reset_s_render.py
def new_round(self):
    self.state.board = [""] * 9
    self.state.current_player = "X"
    self.state.game_over = False
    self.render()
Debug Lab 12: „Nowa gra” losowo zeruje wynik meczu
novaya_igra_obnulyaet_schet.py
def new_round(self):
    self.state.score_x = 0   # powinno być tylko w new_match()!
    self.state.score_o = 0
    self.state.board = [""] * 9
# После каждой победы счёт тут же обнуляется —
# турнирный счёт невозможно накопить.
Co widać na ekranie

Rozdział 17.27: New Round i New Match to różne transakcje. Zeroowanie rachunku należy WYŁĄCZNIE new_match().

Poprawiony kod
new_round_bez_scheta.py
def new_round(self):
    self.state.board = [""] * 9
    # konto (score_x/score_o/draws) nie dotykaj tutaj
Debug Lab 13: on_cell_leave wymazuje już wykonany ruch
leave_stiraet_hod.py
def on_cell_leave(self, index):
    self.buttons[index].config(text="")   #naively „usuń podglądy”
# После того как игрок делает настоящий ход и потом уводит курсор,
# отметка на клетке пропадает с экрана — хотя в модели ход остался.
Co widać na ekranie

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.

Poprawiony kod
leave_fixed.py
def on_cell_leave(self, index):
    self.render()   # decyduje, co powinno być na komórce
Debug Lab 14:event.widget używany jako stan gry
widget_kak_state.py
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) никогда не видит эти ходы —
# победа не определяется, потому что модель не менялась.
Co widać na ekranie

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.

Poprawiony kod
widget_i_model.py
def on_click(self, event, index):
    self.attempt_move(index)   # zmienia state.board, potem render()
Debug Lab: Naked <Button-1> zastępuje Semantyczne Działanie Button
goloj_button1.py
btn.bind("<Button-1>", lambda e, i=index: attempt_move(i))
#command= wcale nie jest ustawiony
# Обработчик связан только с конкретным событием мыши.
# Основное действие Button больше не описано через command=.
Co widać na ekranie

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.

Poprawiony kod
command_fixed.py
btn.config(command=lambda i=index: attempt_move(i))
Debug Lab: time.sleep() w animacji zwycięstwa zawiesza okno
sleep_v_animacii.py
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).
Co widać na ekranie

time.sleep() blokuje całą pętlę zdarzeń. Dla animacji nieblokujących musisz self-rescheduling przejść after() - Rozdział 17.30.

Poprawiony kod
after_fixed.py
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)
Debug Lab 17: Dodatkowa bind(..., add=„+”
dublirovannaya_privyazka.py
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.
Co widać na ekranie

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.

Poprawiony kod
bez_dublirovaniya.py
btn = ttk.Button(controls, text=„Nowa runda”, command=self.new_round)
# I to wszystko – jedno wiązanie wystarczy
Praktyka: wykrywanie wirusa na podstawie objawu
Automatyczna kontrola — dla zestawu opisanych objawów wybieramy właściwą przyczynę/naprawę
Otwórz praktykę →