BitBully 0.0.56-a6
Loading...
Searching...
No Matches
gui_c4.py
1"""GUI module for the BitBully Connect-4 interactive widget."""
2
3import importlib.resources
4import logging
5import time
6from pathlib import Path
7
8import matplotlib.backend_bases as mpl_backend_bases
9import matplotlib.pyplot as plt
10import numpy as np
11import numpy.typing as npt
12from IPython.display import Javascript, clear_output, display
13from ipywidgets import AppLayout, Button, HBox, Layout, Output, VBox, widgets
14
15from bitbully import bitbully_core
16
17
18class GuiC4:
19 """A class which allows to create an interactive Connect-4 widget.
20
21 GuiC4 is an interactive Connect-4 graphical user interface (GUI) implemented using
22 Matplotlib, IPython widgets, and a backend agent from the BitBully engine. It
23 provides the following main features:
24
25 - Interactive Game Board: Presents a dynamic 6-row by 7-column
26 Connect-4 board with clickable board cells.
27 - Matplotlib Integration: Utilizes Matplotlib figures
28 to render high-quality game visuals directly within Jupyter notebook environments.
29 - User Interaction: Captures and processes mouse clicks and button events, enabling
30 intuitive gameplay via either direct board interaction or button controls.
31 - Undo/Redo Moves: Supports undo and redo functionalities, allowing users to
32 navigate through their move history during gameplay.
33 - Automated Agent Moves: Incorporates BitBully, a Connect-4 backend engine, enabling
34 computer-generated moves and board evaluations.
35 - Game State Handling: Detects game-over scenarios, including win/draw conditions,
36 and provides immediate user feedback through popup alerts.
37
38 Attributes:
39 notify_output (widgets.Output): Output widget for notifications and popups.
40
41 Examples:
42 Generally, you should this method to retreive and display the widget.
43
44 ```pycon
45 >>> %matplotlib ipympl
46 >>> c4gui = GuiC4()
47 >>> display(c4gui.get_widget())
48 ```
49
50 """
51
52 def __init__(self) -> None:
53 """Init the GuiC4 widget."""
54 # Create a logger with the class name
55 self.m_logger = logging.getLogger(self.__class__.__name__)
56 self.m_logger.setLevel(logging.DEBUG) # Set the logging level
57
58 # Create a console handler (optional)
59 ch = logging.StreamHandler()
60 ch.setLevel(logging.INFO) # Set level for the handler
61
62 # Create a formatter and add it to the handler
63 formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
64 ch.setFormatter(formatter)
65
66 # Add the handler to the logger
67 self.m_logger.addHandler(ch)
68
69 # Avoid adding handlers multiple times
70 self.m_logger.propagate = False
71 assets_pth = Path(str(importlib.resources.files("bitbully").joinpath("assets")))
72 png_empty = plt.imread(assets_pth.joinpath("empty.png"), format=None)
73 png_empty_m = plt.imread(assets_pth.joinpath("empty_m.png"), format=None)
74 png_empty_r = plt.imread(assets_pth.joinpath("empty_r.png"), format=None)
75 png_red = plt.imread(assets_pth.joinpath("red.png"), format=None)
76 png_red_m = plt.imread(assets_pth.joinpath("red_m.png"), format=None)
77 png_yellow = plt.imread(assets_pth.joinpath("yellow.png"), format=None)
78 png_yellow_m = plt.imread(assets_pth.joinpath("yellow_m.png"), format=None)
79 self.m_png = {
80 0: {"plain": png_empty, "corner": png_empty_m, "underline": png_empty_r},
81 1: {"plain": png_yellow, "corner": png_yellow_m},
82 2: {"plain": png_red, "corner": png_red_m},
83 }
84
85 self.m_n_row, self.m_n_col = 6, 7
86
87 # TODO: probably not needed:
88 self.m_height = np.zeros(7, dtype=np.int32)
89
90 self.m_board_size = 3.5
91 # self.m_player = 1
92 self.is_busy = False
93
94 self.last_event_time = time.time()
95
96 # Create board first
97 self._create_board()
98
99 # Generate buttons for inserting the tokens:
100 self._create_buttons()
101
102 # Create control buttons
104
105 # Capture clicks on the field
106 _ = self.m_fig.canvas.mpl_connect("button_press_event", self._on_field_click)
107
108 # Movelist
109 self.m_movelist: list[tuple[int, int, int]] = []
110
111 # Redo list
112 self.m_redolist: list[tuple[int, int, int]] = []
113
114 # Gameover flag:
115 self.m_gameover = False
116
117 # C4 agent
118 # db_path = importlib.resources.files("bitbully").joinpath("assets/book_12ply_distances.dat")
119 # self.bitbully_agent = bitbully_core.BitBullyCore(db_path)
120
121 resource = importlib.resources.files("bitbully").joinpath("assets/book_12ply_distances.dat")
122 with importlib.resources.as_file(resource) as db_path:
123 self.bitbully_agent = bitbully_core.BitBullyCore(db_path)
124
125 def _reset(self) -> None:
126 self.m_movelist = []
127 self.m_redolist = []
128 self.m_height = np.zeros(7, dtype=np.int32)
129 self.m_gameover = False
130
131 for im in self.ims:
132 im.set_data(self.m_png[0]["plain"])
133
134 self.m_fig.canvas.draw_idle()
135 self.m_fig.canvas.flush_events()
137
138 def _get_fig_size_px(self) -> npt.NDArray[np.float64]:
139 # Get the size in inches
140 size_in_inches = self.m_fig.get_size_inches()
141 self.m_logger.debug("Figure size in inches: %f", size_in_inches)
142
143 # Get the DPI
144 dpi = self.m_fig.dpi
145 self.m_logger.debug("Figure DPI: %d", dpi)
146
147 # Convert to pixels
148 return size_in_inches * dpi
149
150 def _create_control_buttons(self) -> None:
151 self.m_control_buttons = {}
152
153 # Create buttons for each column
154 self.m_logger.debug("Figure size: ", self._get_fig_size_px())
155
156 fig_size_px = self._get_fig_size_px()
157 wh = f"{-3 + (fig_size_px[1] / self.m_n_row)}px"
158 btn_layout = Layout(height=wh, width=wh)
159
160 button = Button(description="🔄", tooltip="Reset Game", layout=btn_layout)
161 button.on_click(lambda b: self._reset())
162 self.m_control_buttons["reset"] = button
163
164 button = Button(description="â†Šī¸", tooltip="Undo Move", layout=btn_layout)
165 button.disabled = True
166 button.on_click(lambda b: self._undo_move())
167 self.m_control_buttons["undo"] = button
168
169 button = Button(description="â†Ēī¸", tooltip="Redo Move", layout=btn_layout)
170 button.disabled = True
171 button.on_click(lambda b: self._redo_move())
172 self.m_control_buttons["redo"] = button
173
174 button = Button(description="đŸ•šī¸", tooltip="Computer Move", layout=btn_layout)
175 button.on_click(lambda b: self._computer_move())
176 self.m_control_buttons["move"] = button
177
178 button = Button(description="📊", tooltip="Evaluate Board", layout=btn_layout)
179 self.m_control_buttons["evaluate"] = button
180
181 def _computer_move(self) -> None:
182 self.is_busy = True
184 b = bitbully_core.BoardCore()
185 assert b.setBoard([mv[1] for mv in self.m_movelist])
186 move_scores = self.bitbully_agent.scoreMoves(b)
187 self.is_busy = False
188 self._insert_token(int(np.argmax(move_scores)))
189
190 def _create_board(self) -> None:
191 self.output = Output()
192
193 with self.output:
194 fig, axs = plt.subplots(
195 self.m_n_row,
196 self.m_n_col,
197 figsize=(
198 self.m_board_size / self.m_n_row * self.m_n_col,
199 self.m_board_size,
200 ),
201 )
202 axs = axs.flatten()
203 self.ims = []
204 for ax in axs:
205 self.ims.append(ax.imshow(self.m_png[0]["plain"], animated=True))
206 ax.axis("off")
207 ax.set_xticklabels([])
208 ax.set_yticklabels([])
209
210 fig.tight_layout()
211 plt.subplots_adjust(wspace=0.05, hspace=0.05, left=0.0, right=1.0, top=1.0, bottom=0.0)
212 fig.suptitle("")
213 fig.set_facecolor("darkgray")
214 fig.canvas.toolbar_visible = False # type: ignore[attr-defined]
215 fig.canvas.resizable = False # type: ignore[attr-defined]
216 fig.canvas.toolbar_visible = False # type: ignore[attr-defined]
217 fig.canvas.header_visible = False # type: ignore[attr-defined]
218 fig.canvas.footer_visible = False # type: ignore[attr-defined]
219 fig.canvas.capture_scroll = True # type: ignore[attr-defined]
220 plt.show(block=False)
221
222 self.m_fig = fig
223 self.m_axs = axs
224
225 notify_output: widgets.Output = widgets.Output()
226 display(notify_output)
227
228 @notify_output.capture()
229 def _popup(self, text: str) -> None:
230 clear_output()
231 display(Javascript(f"alert('{text}')"))
232
233 def _is_legal_move(self, col: int) -> bool:
234 return not self.m_height[col] >= self.m_n_row
235
236 def _insert_token(self, col: int, reset_redo_list: bool = True) -> None:
237 if self.is_busy:
238 return
239 self.is_busy = True
240
241 for button in self.m_insert_buttons:
242 button.disabled = True
243
244 board = bitbully_core.BoardCore()
245 board.setBoard([mv[1] for mv in self.m_movelist])
246 if self.m_gameover or not board.playMove(col):
248 self.is_busy = False
249 return
250
251 try:
252 # Get player
253 player = 1 if not self.m_movelist else 3 - self.m_movelist[-1][0]
254 self.m_movelist.append((player, col, self.m_height[col]))
255 self._paint_token()
256 self.m_height[col] += 1
257
258 # Usually, after a move is performed, there is no possibility to
259 # redo a move again
260 if reset_redo_list:
261 self.m_redolist = []
262
263 self._check_winner(board)
264
265 except Exception as e:
266 self.m_logger.error("Error: %s", str(e))
267 raise
268 finally:
269 time.sleep(0.5) # debounce button
270 # Re-enable all buttons (if columns not full)
271 self.is_busy = False
273
274 def _redo_move(self) -> None:
275 if len(self.m_redolist) < 1:
276 return
277 _p, col, _row = self.m_redolist.pop()
278 self._insert_token(col, reset_redo_list=False)
279
280 def _undo_move(self) -> None:
281 if len(self.m_movelist) < 1:
282 return
283
284 if self.is_busy:
285 return
286 self.is_busy = True
287
288 try:
289 _p, col, row = mv = self.m_movelist.pop()
290 self.m_redolist.append(mv)
291
292 self.m_height[col] -= 1
293 assert row == self.m_height[col]
294
295 img_idx = self._get_img_idx(col, row)
296
297 self.ims[img_idx].set_data(self.m_png[0]["plain"])
298 self.m_axs[img_idx].draw_artist(self.ims[img_idx])
299 if len(self.m_movelist) > 0:
300 self._paint_token()
301 else:
302 self.m_fig.canvas.blit(self.ims[img_idx].get_clip_box())
303 self.m_fig.canvas.flush_events()
304
305 self.m_gameover = False
306
307 except Exception as e:
308 self.m_logger.error("Error: %s", str(e))
309 raise
310 finally:
311 # Re-enable all buttons (if columns not full)
312 self.is_busy = False
314
315 time.sleep(0.5) # debounce button
316
317 def _update_insert_buttons(self) -> None:
318 for button, col in zip(self.m_insert_buttons, range(self.m_n_col)):
319 button.disabled = bool(self.m_height[col] >= self.m_n_row) or self.m_gameover or self.is_busy
320
321 self.m_control_buttons["undo"].disabled = len(self.m_movelist) < 1 or self.is_busy
322 self.m_control_buttons["redo"].disabled = len(self.m_redolist) < 1 or self.is_busy
323 self.m_control_buttons["move"].disabled = self.m_gameover or self.is_busy
324 self.m_control_buttons["evaluate"].disabled = self.m_gameover or self.is_busy
325
326 def _get_img_idx(self, col: int, row: int) -> int:
327 """Translates a column and row ID into the corresponding image ID.
328
329 Args:
330 col (int): column (0-6) of the considered board cell.
331 row (int): row (0-5) of the considered board cell.
332
333 Returns:
334 int: The corresponding image id (0-41).
335 """
336 self.m_logger.debug("Got column: %d", col)
337
338 return col % self.m_n_col + (self.m_n_row - row - 1) * self.m_n_col
339
340 def _paint_token(self) -> None:
341 if len(self.m_movelist) < 1:
342 return
343
344 p, col, row = self.m_movelist[-1]
345 img_idx = self._get_img_idx(col, row)
346 self.m_logger.debug("Paint token: %d", img_idx)
347
348 #
349 # no need to reset background, since we anyhow overwrite it again
350 # self.m_fig.canvas.restore_region(self.m_background[img_idx])
351 self.ims[img_idx].set_data(self.m_png[p]["corner"])
352
353 # see: https://matplotlib.org/3.4.3/Matplotlib.pdf
354 # 2.3.1 Faster rendering by using blitting
355 blit_boxes = []
356 self.m_axs[img_idx].draw_artist(self.ims[img_idx])
357 blit_boxes.append(self.ims[img_idx].get_clip_box())
358 # self.m_fig.canvas.blit()
359
360 if len(self.m_movelist) > 1:
361 # Remove the white corners for the second-to-last move
362 # TODO: redundant code above
363 p, col, row = self.m_movelist[-2]
364 img_idx = self._get_img_idx(col, row)
365 self.ims[img_idx].set_data(self.m_png[p]["plain"])
366 self.m_axs[img_idx].draw_artist(self.ims[img_idx])
367 blit_boxes.append(self.ims[img_idx].get_clip_box())
368
369 self.m_fig.canvas.blit(blit_boxes[0])
370
371 # self.m_fig.canvas.restore_region(self.m_background[img_idx])
372 # self.m_fig.canvas.blit(self.ims[img_idx].get_clip_box())
373 # self.m_fig.canvas.draw_idle()
374 self.m_fig.canvas.flush_events()
375
376 def _create_buttons(self) -> None:
377 # Create buttons for each column
378 self.m_logger.debug("Figure size: ", self._get_fig_size_px())
379
380 fig_size_px = self._get_fig_size_px()
381
382 self.m_insert_buttons = []
383 for col in range(self.m_n_col):
384 button = Button(
385 description="âŦ",
386 layout=Layout(width=f"{-3 + (fig_size_px[0] / self.m_n_col)}px", height="50px"),
387 )
388 button.on_click(lambda b, col=col: self._insert_token(col))
389 self.m_insert_buttons.append(button)
390
391 def _create_column_labels(self) -> HBox:
392 """Creates a row with the column labels 'a' to 'g'.
393
394 Returns:
395 HBox: A row of textboxes containing the columns labels 'a' to 'g'.
396 """
397 fig_size_px = self._get_fig_size_px()
398 width = f"{-3 + (fig_size_px[0] / self.m_n_col)}px"
399 textboxes = [
400 widgets.Label(
401 value=chr(ord("a") + i),
402 layout=Layout(justify_content="center", align_items="center", width=width),
403 )
404 for i in range(self.m_n_col)
405 ]
406 return HBox(
407 textboxes,
408 layout=Layout(
409 display="flex",
410 flex_flow="row wrap", # or "column" depending on your layout needs
411 justify_content="center", # Left alignment
412 align_items="center", # Top alignment
413 ),
414 )
415
416 def _on_field_click(self, event: mpl_backend_bases.Event) -> None:
417 """Based on the column where the click was detected, insert a token.
418
419 Args:
420 event (mpl_backend_bases.Event): A matplotlib mouse event.
421 """
422 if isinstance(event, mpl_backend_bases.MouseEvent):
423 ix, iy = event.xdata, event.ydata
424 self.m_logger.debug("click (x,y): %d, %d", ix, iy)
425 idx = np.where(self.m_axs == event.inaxes)[0][0] % self.m_n_col
426 self._insert_token(idx)
427
428 def get_widget(self) -> AppLayout:
429 """Get the widget.
430
431 Examples:
432 Generally, you should this method to retreive and display the widget.
433
434 ```pycon
435 >>> %matplotlib ipympl
436 >>> c4gui = GuiC4()
437 >>> display(c4gui.get_widget())
438 ```
439
440 Returns:
441 AppLayout: the widget.
442 """
443 # Arrange buttons in a row
444 insert_button_row = HBox(
445 self.m_insert_buttons,
446 layout=Layout(
447 display="flex",
448 flex_flow="row wrap", # or "column" depending on your layout needs
449 justify_content="center", # Left alignment
450 align_items="center", # Top alignment
451 ),
452 )
453 control_buttons_col = HBox(
454 [VBox(list(self.m_control_buttons.values()))],
455 layout=Layout(
456 display="flex",
457 flex_flow="row wrap", # or "column" depending on your layout needs
458 justify_content="flex-end", # Left alignment
459 align_items="center", # Top alignment
460 ),
461 )
462
463 tb = self._create_column_labels()
464
465 return AppLayout(
466 header=None,
467 left_sidebar=control_buttons_col,
468 center=VBox(
469 [insert_button_row, self.output, tb],
470 layout=Layout(
471 display="flex",
472 flex_flow="column wrap",
473 justify_content="flex-start", # Left alignment
474 align_items="flex-start", # Top alignment
475 ),
476 ),
477 footer=None,
478 right_sidebar=None,
479 )
480
481 def _check_winner(self, board: bitbully_core.BoardCore) -> None:
482 """Check for Win or draw."""
483 if board.hasWin():
484 winner = "Yellow" if board.movesLeft() % 2 else "Red"
485 self._popup(f"Game over! {winner} wins!")
486 self.m_gameover = True
487 if board.movesLeft() == 0:
488 self._popup("Game over! Draw!")
489 self.m_gameover = True
490
491 def destroy(self) -> None:
492 """Destroy and release the acquired resources."""
493 plt.close(self.m_fig)
494 del self.bitbully_agent
495 del self.m_axs
496 del self.m_fig
497 del self.output
None _undo_move(self)
Definition gui_c4.py:280
None destroy(self)
Definition gui_c4.py:491
None __init__(self)
Definition gui_c4.py:52
None _on_field_click(self, mpl_backend_bases.Event event)
Definition gui_c4.py:416
HBox _create_column_labels(self)
Definition gui_c4.py:391
None _update_insert_buttons(self)
Definition gui_c4.py:317
None _insert_token(self, int col, bool reset_redo_list=True)
Definition gui_c4.py:236
None _paint_token(self)
Definition gui_c4.py:340
None _popup(self, str text)
Definition gui_c4.py:229
None _redo_move(self)
Definition gui_c4.py:274
None _reset(self)
Definition gui_c4.py:125
None _create_board(self)
Definition gui_c4.py:190
None _computer_move(self)
Definition gui_c4.py:181
npt.NDArray[np.float64] _get_fig_size_px(self)
Definition gui_c4.py:138
int _get_img_idx(self, int col, int row)
Definition gui_c4.py:326
AppLayout get_widget(self)
Definition gui_c4.py:428
None _create_buttons(self)
Definition gui_c4.py:376
None _check_winner(self, bitbully_core.BoardCore board)
Definition gui_c4.py:481
None _create_control_buttons(self)
Definition gui_c4.py:150