Kompletny program: gra w warcaby

Przykłady, które pojawiały się do tej pory, były z reguły dość proste. Można by odnieść wrażenie, że prościej było by je napisać bez obiektów. Zalety obiektowości ujawniają się w przypadku większych projektów; gra w warcaby wciąż jest dośc prostym programem. W mniej niż 300 liniach kodu udało się zmieścić program z obowiązkowym biciem i damkami. Jedyne uproszczenia to:

  • w jednym ruchu możliwe jest tylko jedno bicie

  • damka musi zakończyć ruch na polu bezpośrednio za zbitym pionem

W gotową grę możesz zagrać tu:

Poniżej zaś znajduje się opis programu a na samym dole tej strony - kompletny kod.

Pionki, ich położenie i kierunek ruchu

Zastanówmy się najpierw, jakich obiektów będziemy potrzebować. W prawdziwej grze udział biorą gracze, pionki i szachownica. Dodatkowo jakoś trzeba zaimplementować reguły gry. Każdy z tych elementów będzie oddzielną klasą.

Zacznijmy od reguł, bo one powodują najwięcej komplikacji (np. damka rusza się inaczej niż zwykły pionek). Dodatkowo, zmiana sposobu implementacji tych reguł pociąga za sobą konieczność przebudowania większości programu. Sprawdzenie kilku potencjalnych możliwości pokazało, że najlepiej będzie, kiedy to pionek będzie wiedział, gdzie może się ruszyć. Oprócz zwykłych pionków będą się też pojawiać damki - obiekty kalsy Queen, które będą poruszały się wg innych reguł. Pionek koniecznie musi wiedzieć, jakiego jest koloru: biały, czy czarny. Klasa bazowa PieceBase wyglądać będzie zatem nastepująco:

class PieceBase(ABC):
    def __init__(self, color:Color):
        self.__color = color

    @property
    def color(self) -> Color: return self.__color

    @abstractmethod
    def move_proposals(self, loc:Location, brd): pass

Zauważ, że aby metoda move_proposals() mogła wymienić wszystkie dostępne prawidłowe ruchy, potrzebne jest położenie tego pionka na planszy oraz położenie wszystkich innych pionków. Potrzebny jest też kolor ruszanego pionka, ale tą informację pionek sam przechowuje już jako pole prywatne. Z położeniem sytuacja jest bardziej skomplikowana, bo informacja ta przydała by się w 2 miejscach:

  1. pionek powinien znać swoje położenie

  2. szachownica powinna przechowywać wszystkie pionki, a zatem znać ich położenia

Reguły programowania obiektowego wymagają, aby informacja o położeniu przechowywana była tylko w jednym miejscu; inaczej mógłby się pojawić problem jego z aktualizowaniem. Wybieramy zatem opcję 2: położenia wszystkich pionków trzyma szachownica. W tej sytuacji obiekt klasy Board trzeba przekazać metodzie move_proposals() aby mogła zaproponować możliwe ruchy.

Do zapisywania pozycji pionków stworzymy specjalną klasę - Location. Na pierwszy rzut oka ktoś mógłby zarzucić, że to nadmiar szcześcia. Położenie na szachownicy jest jednak dwuwymiarowe ido tego potrzebna jest jakaś struktura danych. Można oczywiście użyć listy lub krotki; dedykowaną klasę możemy jednak wyposażyć w dodatkowe metody, jak np. drukowanie położenia w notacji szachowej (pionek z C2 na D3). W tym celu Location trzyma prywatne pole - listę liter użytych do numerowania linii szachownicy:

class Location:

    __letters = list("_ABCDEFGHIJKLMNOP")
    board_size = 8

    def __init__(self, row: int, col: int):
        self.__row, self.__col = row, col

    def __str__(self):
        return "%c%d" % (Location.__letters[self.__row], self.__col)

    @property
    def row(self) -> int: return self.__row

    @property
    def col(self) -> int: return self.__col

Przyda się też klasa do zapisywania kierunku ruchu; Direction to w zasadzie wektor przesunięcia pionka na szachownicy:

class Direction:
    def __init__(self,dr,dc):
        self.__dr = dr
        self.__dc = dc

    @property
    def dr(self): return self.__dr

    @property
    def dc(self): return self.__dc

Direction.UR = Direction(1,1)
Direction.UL = Direction(1,-1)
Direction.BR = Direction(-1,1)
Direction.BL = Direction(-1,-1)

Od razu zdefiniowaliśmy wszystkie możliwe kierunki: UR, UL, DR i DL. Przechowane są jako pola klasy Direction. Teraz możemy dodać do klasy Location metodę przesuwającą położenie o zadany wektor. Zauważ, że publiczna metoda move_by() akceptuje argument typu Direction; obliczeniami zajmuje się metoda prywatna.

class Location:

    def move_by(self, d: Direction, n: int = 1):
        return self.__move_by_vector(d.dr * n, d.dc * n)

    def __move_by_vector(self, dr, dc):
        r, c = self.__row + dr, self.__col + dc
        if any([r < 1, r > Location.board_size, c < 1, c > Location.board_size]): return None
        return Location(r, c)

Jak już wspomniano, pionek musi mieć swój kolor. Informację tę można przechowywać na wiele sposobów: można użyć napisów "BLACK" i "WHITE" albo umówić się, że czarne to 0 a białe to 1. Rozwiązania te mogą jednak powodować niejednoznaczności; ktoś mógłby pomylić 0 z 1, albo napisać "White". Aby uniknąć problemów, użyjemy typu Enum (enumeration, czyli wyliczenie). Jest to klasa, która zawiera pola - konkretne wartości:

from enum import Enum

class Color(Enum):
    BLACK = 1
    WHITE = 2

    @staticmethod
    def enemy_color(col):
        return Color.WHITE if col==Color.BLACK else Color.BLACK

Jak widać na powyższym przykładzie, w języku Python wyliczenie jest klasą, która dziedziczy po typie bazowym Enum i jak każda klasa - może mieć metody. Tu dodaliśmy statyczną metodę, która zwraca kolor przeciwnika.

Podsumowując: stworzyliśmy klasę bazową dla pionków (PieceBase), z której w przyszłości wyprowadzimy klasy pionka i damki. Najważniejszą funkcjonalnością pionka jest metoda move_proposals(), która na podstawie aktualnego stanu planszy zwróci listę dozwolonych ruchów. Stworzyliśmy też pomocnicze klasy, które przechowują potrzebne nam informacje: położenie (Location), kierunek ruchu pionka (Direction) i jego kolor (Color).

Szachownica

Programowanie obiektowe wymaga podejmowania decyzji “kto co robi”, np. jakie będą obowiązki pionka a jakie gracza, oraz “kto co ma”, np. kto zawiera obiekty reprezentujący pionki: szachownica, czy może gracz. W tym przypadku zdecydowałem, że to szachownica zawiera pionki. A zatem klasa Board:

  • wie, które pola są czarne a które białe (wzór z Wikipedii)

  • umie zwrócić listę wszystkich lokacji dostęnych dla pionków (metoda locations()) - w warcaby gramy jedynie na czarnych polach!

  • posiada pionki; w tym celu powinna posiadać jako składową jakiś pojemnik - słownik albo listę. Którą z nich wybrać? Na pewno można zastosować zarówno jedną jak i drugą; ja zdecydopwałem się na listę. Ponieważ szachownica jest 2D, potrzebna będzie lista list. Dodatkowo powinniśmy konsekwentnie stosować klasę Location: szachownica powinna umieć powiedzieć, co stoi na polu C3.

  • Szachownica powinna wykonywać ruch. Co prawda w prawdziwej partii robi to gracz, ale w aktualnej implementacji tylko szachownica zna położenia wszystkich pionków i to ona powinna usuwać zbite.

  • Dodatkowo szachownica musi zwracać listę pionków danego koloru wraz z ich położeniami; inaczej gracz nie będzie mógł zrobić ruchu.

  • Konstruktor klasy Board umożlwia grę na innych planszach niż 8x8 i z inną niż standardowa liczą pionów.

    def __init__(self, size=8, n_pieces=12):
        # should check if size is even!
        Location.board_size = size
        self.__size = size
        self.__b = [[None for _ in range(size+1)] for _ in range(size+1)]
        blacks = self.locations(Color.BLACK)
        if n_pieces > 0:
            for pos in blacks[:n_pieces]:
                self[pos] = Piece(Color.WHITE)

            for pos in blacks[-n_pieces:]:
                self[pos] = Piece(Color.BLACK)

    def __getitem__(self, loc:Location):
        return self.__b[loc.row][loc.col]

    def __setitem__(self, loc:Location, item: Piece):
        self.__b[loc.row][loc.col] = item

    def pieces(self, color:Color) -> list[tuple[Location, PieceBase]]:
        ret = []
        for r in range(1, self.__size+1):
            for c in range(1, self.__size + 1):
                piece = self.__b[r][c]
                if piece is not None and piece.color == color:
                    ret.append((Location(r, c), piece))
        return ret

    def locations(self, color: Color):
        all_loc = []
        for row in range(1, self.__size + 1):
            for col in range(1, self.__size + 1):
                if l := Location(row, col):
                    if Board.color(l) == color:
                        all_loc.append(l)
        return all_loc

    def make_move(self, m:Move):
        piece: PieceBase = self[m.start]
        self[m.start] = None
        self[m.stop] = piece
        if piece.color == Color.BLACK and m.stop.row == 1:
            self[m.stop] = Queen(Color.BLACK)
        elif piece.color == Color.WHITE and m.stop.row == self.__size:
            self[m.stop] = Queen(Color.WHITE)
        if m.captured is not None:
            self[m.captured]=None

    @staticmethod
    def color(loc:Location):
        if (loc.col + loc.row) % 2 == 0:
            return Color.BLACK
        else:
            return Color.WHITE

Ruchy pionków

Generalnie przebieg pojedynczego ruchu jednego gracza będzie następujący:

  • Gracz pobiera z planszy swoje pionki, wywołując Board.pieces()

  • Gracz odpytuje każdy z pionków o jego ruchy, wywołując AbstractPiece.move_proposals(); każdy pionek zwraca listę możliwych ruchów a gracz gromadzi je w liście; do reprezentowania ruchu potrzebna będzie klasa Move

  • Mając listę wszystkich możliwych ruchów, gracz podejmuje decyzję, wybierając najlepszy wg niego obiekt klasy Move.

  • Wykonuje ruch, wywołując metodę Board.make_move(self, m:Move)

Klasa Move przechowuje początkową i końcową pozycję ruchu a także ewentualną pozycję zbijanego piona. Dodatkowo klasa ta posiada metodę __str__(), wypisującą ruch na ekranie.

class Move:
    def __init__(self, start: Location, stop: Location, captured: Location = None):
        self.__start = start
        self.__stop = stop
        self.__captured = captured

    @property
    def start(self): return self.__start

    @property
    def stop(self): return self.__stop

    @property
    def captured(self): return self.__captured

    def __str__(self):
        return str(self.__start)+" -> "+str(self.__stop)

Mając obiekt Move, jego wykonanie jest proste: szachownica musi przesunąć ruszany pionek i ewentualnie usunąć ten zbijany. Dodatkowo jednak musimy spradzić, czy pionek nie jest promowany do damki: dzieję się to wtedy, kiedy biały pion dojdzie linii ostatniej (najczęściej 8) albo czarny do linii 1.

class Board:
    def make_move(self, m:Move):
        piece: PieceBase = self[m.start]
        self[m.start] = None
        self[m.stop] = piece
        if piece.color == Color.BLACK and m.stop.row == 1:
            self[m.stop] = Queen(Color.BLACK)
        elif piece.color == Color.WHITE and m.stop.row == self.__size:
            self[m.stop] = Queen(Color.WHITE)
        if m.captured is not None:
            self[m.captured]=None

Pozostała zatem do napisania ta część programu, w której proponowane są ruchy:

class Piece(PieceBase):
    def move_proposals(self, loc: Location, brd) -> list[Move]:

        moves = []
        if self.color == Color.BLACK:
            dirs = [Direction.BL, Direction.BR]
        else:
            dirs = [Direction.UL, Direction.UR]

        for d in dirs:
            if l:=loc.move_by(d):                    # --- l==None means the move went outside the board
                if brd[l] is None: moves.append(Move(loc, l))
                elif brd[l].color == Color.enemy_color(self.color):
                    if ll := l.move_by(d):
                        if brd[ll] is None:
                            moves.append(Move(loc, ll, l))
        return moves

Algorytm jest stosunkowo prosty:

  • Jeżeli pionek jest czarny, to możliwy jest ruch w kierunku BL i BR (Bottom-Left i Bottom-Right), jeżeli biały zaś, to UL i UR; zakładamy bowiem, że białe zaczynają na dole a czarne na górze planszy

  • Następnie sprawdzamy, czy pole docelowe po wykonaniu ruchu jest wolne; jeżeli tak, to ruch trafia na listę. Jeżeli jednak nie jest wolne, to sprawdzamy, czy wolne jest następne pole w tym kierunku i czy przeskakujemy pionek przeciwnika. Jeżeli tak, to mamy bicie!

  • Zauważ, że nie sprawdzamy tu, czy aby ruch nie wychodzi poza planszę - tym zajmie się klasa Board

Mamy jednak jeszcze jedną klasę dziedziczącą po PieceBase - klasę damka (Queen). W tym przypadku generowanie ruchów jest bardziej skomplikowane; pełen kod można znaleźć poniżej.

Kod programu

W około 250 liniach programu udało się zawrzeć kompletną grę w warcaby z damkami i obowiązkowym biciem. Klasa Board umie narysować stan gry w terminalu, można więc śledzić rozgrywkę. Jedyne poważne mankamenty gry to, wspomniane już na wstępie, to:

  • Pionek może wykonać tylko jedno bicie

  • Damka również może zbić tylko jeden pionek i ląduje wtedy na polu bezpośrednio następującym po zbitym pionie

  1from __future__ import annotations
  2
  3import random
  4from abc import ABC, abstractmethod
  5from enum import Enum
  6
  7
  8class Color(Enum):
  9    BLACK = 1
 10    WHITE = 2
 11
 12    @staticmethod
 13    def enemy_color(col):
 14        return Color.WHITE if col==Color.BLACK else Color.BLACK
 15
 16class Direction:
 17    def __init__(self,dr,dc):
 18        self.__dr = dr
 19        self.__dc = dc
 20
 21    @property
 22    def dr(self): return self.__dr
 23
 24    @property
 25    def dc(self): return self.__dc
 26
 27Direction.UR = Direction(1,1)
 28Direction.UL = Direction(1,-1)
 29Direction.BR = Direction(-1,1)
 30Direction.BL = Direction(-1,-1)
 31
 32
 33class Location:
 34
 35    __letters = list("_ABCDEFGHIJKLMNOP")
 36    board_size = 8
 37
 38    def __init__(self, row: int, col: int):
 39        self.__row, self.__col = row, col
 40
 41    def __str__(self):
 42        return "%c%d" % (Location.__letters[self.__row], self.__col)
 43
 44    @property
 45    def row(self) -> int: return self.__row
 46
 47    @property
 48    def col(self) -> int: return self.__col
 49
 50    def move_by(self, d: Direction, n: int = 1):
 51        return self.__move_by_vector(d.dr * n, d.dc * n)
 52
 53    def __move_by_vector(self, dr, dc):
 54        r, c = self.__row + dr, self.__col + dc
 55        if any([r < 1, r > Location.board_size, c < 1, c > Location.board_size]): return None
 56        return Location(r, c)
 57
 58
 59class PieceBase(ABC):
 60    def __init__(self, color:Color):
 61        self.__color = color
 62
 63    @property
 64    def color(self) -> Color: return self.__color
 65
 66    @abstractmethod
 67    def move_proposals(self, loc:Location, brd): pass
 68
 69    @abstractmethod
 70    def symbol(self): pass
 71
 72
 73class Move:
 74    def __init__(self, start: Location, stop: Location, captured: Location = None):
 75        self.__start = start
 76        self.__stop = stop
 77        self.__captured = captured
 78
 79    @property
 80    def start(self): return self.__start
 81
 82    @property
 83    def stop(self): return self.__stop
 84
 85    @property
 86    def captured(self): return self.__captured
 87
 88    def __str__(self):
 89        return str(self.__start)+" -> "+str(self.__stop)
 90
 91class Piece(PieceBase):
 92    def __init__(self, color: Color):
 93        super().__init__(color)
 94
 95    def symbol(self):
 96        return '*' if self.color==Color.BLACK else 'o'
 97
 98    def move_proposals(self, loc: Location, brd) -> list[Move]:
 99
100        moves = []
101        if self.color == Color.BLACK:
102            dirs = [Direction.BL, Direction.BR]
103        else:
104            dirs = [Direction.UL, Direction.UR]
105
106        for d in dirs:
107            if l:=loc.move_by(d):                    # --- l==None means the move went outside the board
108                if brd[l] is None: moves.append(Move(loc, l))
109                elif brd[l].color == Color.enemy_color(self.color):
110                    if ll := l.move_by(d):
111                        if brd[ll] is None:
112                            moves.append(Move(loc, ll, l))
113        return moves
114
115
116class Queen(PieceBase):
117    def __init__(self, color: Color):
118        super().__init__(color)
119
120    def symbol(self):
121        return 'X' if self.color==Color.BLACK else 'O'
122
123    def move_proposals(self, loc: Location, brd):
124
125        moves = []
126        dirs = [Direction.BL, Direction.BR, Direction.UL, Direction.UR]
127
128        for d in dirs:
129            for i in range(1,Location.board_size):
130                l = loc.move_by(d, i)
131                if l is None: break             # --- None means the move went outside the board
132                if brd[l] is None:
133                    moves.append(Move(loc, l))
134                elif brd[l].color == Color.enemy_color(self.color):
135                    if ll := l.move_by(d):
136                        if brd[ll] is None:
137                            moves.append(Move(loc, ll, l))
138                else:
139                    break
140
141        return moves
142
143
144class Board:
145    def __init__(self, size=8, n_pieces=12):
146        # should check if size is even!
147        Location.board_size = size
148        self.__size = size
149        self.__b = [[None for _ in range(size+1)] for _ in range(size+1)]
150        blacks = self.locations(Color.BLACK)
151        if n_pieces > 0:
152            for pos in blacks[:n_pieces]:
153                self[pos] = Piece(Color.WHITE)
154
155            for pos in blacks[-n_pieces:]:
156                self[pos] = Piece(Color.BLACK)
157
158    def __getitem__(self, loc:Location):
159        return self.__b[loc.row][loc.col]
160
161    def __setitem__(self, loc:Location, item: Piece):
162        self.__b[loc.row][loc.col] = item
163
164    def pieces(self, color:Color) -> list[tuple[Location, PieceBase]]:
165        ret = []
166        for r in range(1, self.__size+1):
167            for c in range(1, self.__size + 1):
168                piece = self.__b[r][c]
169                if piece is not None and piece.color == color:
170                    ret.append((Location(r, c), piece))
171        return ret
172
173    def locations(self, color: Color):
174        all_loc = []
175        for row in range(1, self.__size + 1):
176            for col in range(1, self.__size + 1):
177                if l := Location(row, col):
178                    if Board.color(l) == color:
179                        all_loc.append(l)
180        return all_loc
181
182    def make_move(self, m:Move):
183        piece: PieceBase = self[m.start]
184        self[m.start] = None
185        self[m.stop] = piece
186        if piece.color == Color.BLACK and m.stop.row == 1:
187            self[m.stop] = Queen(Color.BLACK)
188        elif piece.color == Color.WHITE and m.stop.row == self.__size:
189            self[m.stop] = Queen(Color.WHITE)
190        if m.captured is not None:
191            self[m.captured]=None
192
193    @staticmethod
194    def color(loc:Location):
195        if (loc.col + loc.row) % 2 == 0:
196            return Color.BLACK
197        else:
198            return Color.WHITE
199
200    def __str__(self):
201        s = ""
202        for r in range(1, self.__size+1):
203            s += Location.letters[r] + " |"
204            for c in range(1, self.__size + 1):
205                if self.__b[r][c] == None: s += " |"
206                else:
207                    s += self.__b[r][c].symbol()+"|"
208            s += "\n"
209        s += "   "
210        for c in range(1, self.__size + 1):
211            s += str(c) + "|"
212        return s + "\n"
213
214
215class Player:
216    def __init__(self, color:Color):
217        self.__my_color = color
218
219    @property
220    def color(self): return self.__my_color
221
222    def move(self, board:Board):
223        moves = self.move_proposals(board)
224        if len(moves) == 0: return None
225        # --- check for capturing moves
226        capt = [m for m in moves if m.captured is not None]
227        if len(capt) != 0:
228            return random.choice(capt)
229        else:
230            return random.choice(moves)
231
232    def move_proposals(self, board:Board) -> list[Move]:
233        pcs = board.pieces(self.color)
234        moves = []
235        for loc, p in pcs:
236            moves.extend(p.move_proposals(loc, board))
237        return moves
238
239
240if __name__ == "__main__":
241
242    # random.seed(1)
243    board = Board(8, 12)
244    players = (Player(Color.WHITE), Player(Color.BLACK))
245
246    i = 0
247    while True:
248        the_player = players[i % 2]
249        move = the_player.move(board)
250        if move is None:
251            print("Player", the_player.color, "won", "after", i + 1, "moves")
252            print(board)
253            break
254        else:
255            board.make_move(move)
256            i += 1

Dodatek: Interfejs graficzny gry

Powyższy program gra sam ze sobą. Można nim np sprawdzić, ile średnio ruchów potrzeba, aby rozegrać partię, albo czy białe wygrywają częściej niż czarne. Aby jednak samemu zagrać w warcaby, potrzebny jest interfejs graficzny. Napiszemy go również w Pythonie; dzięki bibliotece brython.js będzie on działał w przeglądace na komputerze użytkownika.

Rysowanie planszy

Klasa CheckersGui rysuje planszę w oknie przeglądarki; wykorzystuje do tego bibliotekę graficzną VisuaLife. Metody draw_piece() oraz draw_checkerboard() rysują (odpowiednio): pionki oraz szachownicę. Metody te podpinają też zdarzenia - procedury wywoływane przez przeglądarkę w momencie, kiedy gracz kliknie na pole lub pionek. Dla działania gry kluczowe jest połączenie obiektów gry z ich graficzną reprezentacją na ekranie. Wykorzystujemy tu tekstową reprezentację opisanej powyżej klasy Location, np B3 lub C2. Każdy z elementów narysowanych na stronie (np kółko reprezentujace pionek) ma unikalny identyfikator, w którym ostatnie dwa znaki to właśnie owa lokaliozacja. Tworzenie owych unikalnych identyfikatorów wygląda następująco:

            self.__viewport.circle("p-"+str(loc), x, y, r/3.0, fill=color, stroke_width="3", stroke="black")
            self.__viewport.define_binding("p-"+str(loc), "click", move_start)

    def draw_checkerboard(self):

Przy odczytywaniu zaś pobieramy cel (target) zdarzenia (obiekt evt, który jest elementem strony), jego id zawiera szukany napis:


Pętla gry

Drugim ważnym elementem interfejsu jest główna pętla gry. W programie opisanym powyżej (linie 247-256 sekcji __main__) mamy nieskończoną pętlę. Rozwiązanie takie nie powinno być stosowane w GUI gdyż zablokowało by przeglądarkę.

W tym przypadku mamy trzy niezależne funkcje, które działają równolegle: play_with_gui(), move_start(evt) oraz move_end(). Dwie ostatnie uruchamiane są przez zdarzenia, kiedy użytkownik kliknie na pionek lub pole planszy. Ich efektem jest zapisanie odpowiedniej lokacji w prywatnym polu klasy CheckersGui. Funkcja play_with_gui() zaś jest uruchamiana cyklicznie co 300 milisekund dzięki modułowi timer:


Po zakończeniu gry wywołania kończy instrukcja: timer.clear_interval(loop_handler) (linia 112). A oto pełen kod gry interaktywnej:

  1from __future__ import annotations
  2
  3from browser import document, timer
  4from checkers2 import *
  5from visualife.core import HtmlViewport
  6
  7
  8class CheckersGui:
  9
 10    def __init__(self, width:float, board:Board, viewport: HtmlViewport):
 11        self.__n = Location.board_size
 12        self.__side = width/self.__n - 5
 13        self.__viewport = viewport
 14        self.__board = board
 15        self.draw_checkerboard()
 16        self.__n_click = 0
 17        self.__move_start = None
 18        self.__move_end = None
 19
 20    def draw_piece(self, loc:Location, is_queen):
 21
 22        # callback to store location of the move starting point: cut the last two characters to handle both "A1" and "p-A1" cases
 23        def move_start(evt): self.__move_start = evt.target.id[-2:]
 24
 25        pc = self.__board[loc]
 26        x = (loc.col - 0.5) * self.__side
 27        y = (loc.row - 0.5) * self.__side
 28        r = self.__side/2 - 3
 29        if pc.color == Color.WHITE:
 30            color = "white"
 31            stroke = "darkgray"
 32            self.__viewport.define_binding(str(loc), "click", move_start)
 33        else:
 34            stroke = "white"
 35            color = "darkgray"
 36        self.__viewport.circle(str(loc), x, y, r, fill=color, stroke_width="2", stroke=stroke)
 37        if is_queen:
 38            self.__viewport.circle("p-"+str(loc), x, y, r/3.0, fill=color, stroke_width="3", stroke="black")
 39            self.__viewport.define_binding("p-"+str(loc), "click", move_start)
 40
 41    def draw_checkerboard(self):
 42
 43        # callback to store location of the move ending location
 44        def move_end(evt): self.__move_end = evt.target.id[2:]
 45
 46        self.__viewport.clear()
 47
 48        # --- the board itself
 49        for loc in self.__board.locations(Color.BLACK):
 50            x, y = (loc.col - 1) * self.__side, (loc.row - 1) * self.__side
 51            name = "b-" + str(loc)          # Every ID of a board square is a location string preceded with "b-"
 52            self.__viewport.define_binding(name, "click", move_end)
 53            self.__viewport.rect(name, x, y, self.__side, self.__side, fill="black", stroke="black", stroke_width="1")
 54
 55        for loc, pc in self.__board.pieces(Color.WHITE):
 56            self.draw_piece(loc, isinstance(pc, Queen))
 57
 58        for loc, pc in self.__board.pieces(Color.BLACK):
 59            self.draw_piece(loc, isinstance(pc, Queen))
 60
 61        self.__viewport.close()
 62        self.__viewport.apply_binding()
 63
 64    def get_move(self):
 65        if self.__move_start is not None and self.__move_end is not None:
 66            out = (self.__move_start, self.__move_end)
 67            self.__move_start, self.__move_end = None, None
 68            return out          # return a move given as the two end points
 69        return None             # None means no move ready yet!
 70
 71    def message(self, msg):
 72        document["msg"].innerHTML = msg
 73
 74
 75class HumanPlayer(Player):
 76    def __init__(self, color:Color, gui:CheckersGui):
 77        super().__init__(color)
 78        self.gui = gui
 79        self.__move_ready = False
 80        self.__gui_lock = None
 81
 82    def move(self, board:Board):
 83
 84        m = self.gui.get_move()
 85        if m is None: return []                     # Empty list means move is not yet ready!
 86        moves = self.move_proposals(board)
 87        # --- check for capturing moves; they are mandatory!
 88        capt = [m for m in moves if m.captured is not None]
 89
 90        move_from, move_to = m                      # unpack the move since it's not empty
 91        for m in moves:
 92            if str(m.start) == move_from and str(m.stop) == move_to:
 93                if m.captured is None and len(capt) > 0:
 94                    self.gui.message("you have a capturing move!")
 95                    return []
 96                self.gui.message("")                # successful move clears a previous error message
 97                return m
 98        self.gui.message("illegal move!")
 99        return []                                   # Return "move not ready" signal
100
101
102def play_with_gui(players: list[Player], board: Board, gui: CheckersGui):
103
104    global loop_handler, n_moves
105
106    the_player = players[n_moves % 2]
107    move = the_player.move(board)
108    if move is None:
109        msg = "Player %s won after %d moves!" % (str(Color.enemy_color(the_player.color)), n_moves + 1)
110        gui.message(msg)
111        timer.clear_interval(loop_handler)
112    elif isinstance(move, list) and len(move) == 0:     # --- GUI player not ready
113        pass
114    else:
115        print("MOVING: ", move)
116        board.make_move(move)
117        n_moves += 1
118        gui.draw_checkerboard()
119
120
121board_types = {"6x6": (6, 6), "8x8": (8, 12), "10x10": (10, 15)}
122
123def restart(evt=None):
124
125    global loop_handler, drawing
126    board_type = document["select_board"].get(selector='input[name="board"]:checked')[0].id
127    size, n_pieces = board_types[board_type]
128    if loop_handler is not None:
129        timer.clear_interval(loop_handler)
130    drawing.clear()
131    board = Board(size, n_pieces)
132    game = CheckersGui(500, board, drawing)
133    players = (HumanPlayer(Color.WHITE, game), Player(Color.BLACK))
134    loop_handler = timer.set_interval(play_with_gui, 300, players, board, game)
135
136document["restart"].bind("click", restart)
137loop_handler = None
138n_moves = 0
139drawing = HtmlViewport(document['drawing'], 500, 500)
140restart()