BitBully 0.0.59-a2
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 import bitbully_databases as bbd
119
120 # TODO: allow choosing opening book
121 db_path: str = bbd.BitBullyDatabases.get_database_path("12-ply-dist")
122 self.bitbully_agent = bitbully_core.BitBullyCore(Path(db_path))
123
124 def _reset(self) -> None:
125 self.m_movelist = []
126 self.m_redolist = []
127 self.m_height = np.zeros(7, dtype=np.int32)
128 self.m_gameover = False
129
130 for im in self.ims:
131 im.set_data(self.m_png[0]["plain"])
132
133 self.m_fig.canvas.draw_idle()
134 self.m_fig.canvas.flush_events()
136
137 def _get_fig_size_px(self) -> npt.NDArray[np.float64]:
138 # Get the size in inches
139 size_in_inches = self.m_fig.get_size_inches()
140 self.m_logger.debug("Figure size in inches: %f", size_in_inches)
141
142 # Get the DPI
143 dpi = self.m_fig.dpi
144 self.m_logger.debug("Figure DPI: %d", dpi)
145
146 # Convert to pixels
147 return size_in_inches * dpi
148
149 def _create_control_buttons(self) -> None:
150 self.m_control_buttons = {}
151
152 # Create buttons for each column
153 self.m_logger.debug("Figure size: ", self._get_fig_size_px())
154
155 fig_size_px = self._get_fig_size_px()
156 wh = f"{-3 + (fig_size_px[1] / self.m_n_row)}px"
157 btn_layout = Layout(height=wh, width=wh)
158
159 button = Button(description="🔄", tooltip="Reset Game", layout=btn_layout)
160 button.on_click(lambda b: self._reset())
161 self.m_control_buttons["reset"] = button
162
163 button = Button(description="â†Šī¸", tooltip="Undo Move", layout=btn_layout)
164 button.disabled = True
165 button.on_click(lambda b: self._undo_move())
166 self.m_control_buttons["undo"] = button
167
168 button = Button(description="â†Ēī¸", tooltip="Redo Move", layout=btn_layout)
169 button.disabled = True
170 button.on_click(lambda b: self._redo_move())
171 self.m_control_buttons["redo"] = button
172
173 button = Button(description="đŸ•šī¸", tooltip="Computer Move", layout=btn_layout)
174 button.on_click(lambda b: self._computer_move())
175 self.m_control_buttons["move"] = button
176
177 button = Button(description="📊", tooltip="Evaluate Board", layout=btn_layout)
178 self.m_control_buttons["evaluate"] = button
179
180 def _computer_move(self) -> None:
181 self.is_busy = True
183 b = bitbully_core.BoardCore()
184 assert b.setBoard([mv[1] for mv in self.m_movelist])
185 move_scores = self.bitbully_agent.scoreMoves(b)
186 self.is_busy = False
187 self._insert_token(int(np.argmax(move_scores)))
188
189 def _create_board(self) -> None:
190 self.output = Output()
191
192 with self.output:
193 fig, axs = plt.subplots(
194 self.m_n_row,
195 self.m_n_col,
196 figsize=(
197 self.m_board_size / self.m_n_row * self.m_n_col,
198 self.m_board_size,
199 ),
200 )
201 axs = axs.flatten()
202 self.ims = []
203 for ax in axs:
204 self.ims.append(ax.imshow(self.m_png[0]["plain"], animated=True))
205 ax.axis("off")
206 ax.set_xticklabels([])
207 ax.set_yticklabels([])
208
209 fig.tight_layout()
210 plt.subplots_adjust(wspace=0.05, hspace=0.05, left=0.0, right=1.0, top=1.0, bottom=0.0)
211 fig.suptitle("")
212 fig.set_facecolor("darkgray")
213 fig.canvas.toolbar_visible = False # type: ignore[attr-defined]
214 fig.canvas.resizable = False # type: ignore[attr-defined]
215 fig.canvas.toolbar_visible = False # type: ignore[attr-defined]
216 fig.canvas.header_visible = False # type: ignore[attr-defined]
217 fig.canvas.footer_visible = False # type: ignore[attr-defined]
218 fig.canvas.capture_scroll = True # type: ignore[attr-defined]
219 plt.show(block=False)
220
221 self.m_fig = fig
222 self.m_axs = axs
223
224 notify_output: widgets.Output = widgets.Output()
225 display(notify_output)
226
227 @notify_output.capture()
228 def _popup(self, text: str) -> None:
229 clear_output()
230 display(Javascript(f"alert('{text}')"))
231
232 def _is_legal_move(self, col: int) -> bool:
233 return not self.m_height[col] >= self.m_n_row
234
235 def _insert_token(self, col: int, reset_redo_list: bool = True) -> None:
236 if self.is_busy:
237 return
238 self.is_busy = True
239
240 for button in self.m_insert_buttons:
241 button.disabled = True
242
243 board = bitbully_core.BoardCore()
244 board.setBoard([mv[1] for mv in self.m_movelist])
245 if self.m_gameover or not board.play(col):
247 self.is_busy = False
248 return
249
250 try:
251 # Get player
252 player = 1 if not self.m_movelist else 3 - self.m_movelist[-1][0]
253 self.m_movelist.append((player, col, self.m_height[col]))
254 self._paint_token()
255 self.m_height[col] += 1
256
257 # Usually, after a move is performed, there is no possibility to
258 # redo a move again
259 if reset_redo_list:
260 self.m_redolist = []
261
262 self._check_winner(board)
263
264 except Exception as e:
265 self.m_logger.error("Error: %s", str(e))
266 raise
267 finally:
268 time.sleep(0.5) # debounce button
269 # Re-enable all buttons (if columns not full)
270 self.is_busy = False
272
273 def _redo_move(self) -> None:
274 if len(self.m_redolist) < 1:
275 return
276 _p, col, _row = self.m_redolist.pop()
277 self._insert_token(col, reset_redo_list=False)
278
279 def _undo_move(self) -> None:
280 if len(self.m_movelist) < 1:
281 return
282
283 if self.is_busy:
284 return
285 self.is_busy = True
286
287 try:
288 _p, col, row = mv = self.m_movelist.pop()
289 self.m_redolist.append(mv)
290
291 self.m_height[col] -= 1
292 assert row == self.m_height[col]
293
294 img_idx = self._get_img_idx(col, row)
295
296 self.ims[img_idx].set_data(self.m_png[0]["plain"])
297 self.m_axs[img_idx].draw_artist(self.ims[img_idx])
298 if len(self.m_movelist) > 0:
299 self._paint_token()
300 else:
301 self.m_fig.canvas.blit(self.ims[img_idx].get_clip_box())
302 self.m_fig.canvas.flush_events()
303
304 self.m_gameover = False
305
306 except Exception as e:
307 self.m_logger.error("Error: %s", str(e))
308 raise
309 finally:
310 # Re-enable all buttons (if columns not full)
311 self.is_busy = False
313
314 time.sleep(0.5) # debounce button
315
316 def _update_insert_buttons(self) -> None:
317 for button, col in zip(self.m_insert_buttons, range(self.m_n_col)):
318 button.disabled = bool(self.m_height[col] >= self.m_n_row) or self.m_gameover or self.is_busy
319
320 self.m_control_buttons["undo"].disabled = len(self.m_movelist) < 1 or self.is_busy
321 self.m_control_buttons["redo"].disabled = len(self.m_redolist) < 1 or self.is_busy
322 self.m_control_buttons["move"].disabled = self.m_gameover or self.is_busy
323 self.m_control_buttons["evaluate"].disabled = self.m_gameover or self.is_busy
324
325 def _get_img_idx(self, col: int, row: int) -> int:
326 """Translates a column and row ID into the corresponding image ID.
327
328 Args:
329 col (int): column (0-6) of the considered board cell.
330 row (int): row (0-5) of the considered board cell.
331
332 Returns:
333 int: The corresponding image id (0-41).
334 """
335 self.m_logger.debug("Got column: %d", col)
336
337 return col % self.m_n_col + (self.m_n_row - row - 1) * self.m_n_col
338
339 def _paint_token(self) -> None:
340 if len(self.m_movelist) < 1:
341 return
342
343 p, col, row = self.m_movelist[-1]
344 img_idx = self._get_img_idx(col, row)
345 self.m_logger.debug("Paint token: %d", img_idx)
346
347 #
348 # no need to reset background, since we anyhow overwrite it again
349 # self.m_fig.canvas.restore_region(self.m_background[img_idx])
350 self.ims[img_idx].set_data(self.m_png[p]["corner"])
351
352 # see: https://matplotlib.org/3.4.3/Matplotlib.pdf
353 # 2.3.1 Faster rendering by using blitting
354 blit_boxes = []
355 self.m_axs[img_idx].draw_artist(self.ims[img_idx])
356 blit_boxes.append(self.ims[img_idx].get_clip_box())
357 # self.m_fig.canvas.blit()
358
359 if len(self.m_movelist) > 1:
360 # Remove the white corners for the second-to-last move
361 # TODO: redundant code above
362 p, col, row = self.m_movelist[-2]
363 img_idx = self._get_img_idx(col, row)
364 self.ims[img_idx].set_data(self.m_png[p]["plain"])
365 self.m_axs[img_idx].draw_artist(self.ims[img_idx])
366 blit_boxes.append(self.ims[img_idx].get_clip_box())
367
368 self.m_fig.canvas.blit(blit_boxes[0])
369
370 # self.m_fig.canvas.restore_region(self.m_background[img_idx])
371 # self.m_fig.canvas.blit(self.ims[img_idx].get_clip_box())
372 # self.m_fig.canvas.draw_idle()
373 self.m_fig.canvas.flush_events()
374
375 def _create_buttons(self) -> None:
376 # Create buttons for each column
377 self.m_logger.debug("Figure size: ", self._get_fig_size_px())
378
379 fig_size_px = self._get_fig_size_px()
380
381 self.m_insert_buttons = []
382 for col in range(self.m_n_col):
383 button = Button(
384 description="âŦ",
385 layout=Layout(width=f"{-3 + (fig_size_px[0] / self.m_n_col)}px", height="50px"),
386 )
387 button.on_click(lambda b, col=col: self._insert_token(col))
388 self.m_insert_buttons.append(button)
389
390 def _create_column_labels(self) -> HBox:
391 """Creates a row with the column labels 'a' to 'g'.
392
393 Returns:
394 HBox: A row of textboxes containing the columns labels 'a' to 'g'.
395 """
396 fig_size_px = self._get_fig_size_px()
397 width = f"{-3 + (fig_size_px[0] / self.m_n_col)}px"
398 textboxes = [
399 widgets.Label(
400 value=chr(ord("a") + i),
401 layout=Layout(justify_content="center", align_items="center", width=width),
402 )
403 for i in range(self.m_n_col)
404 ]
405 return HBox(
406 textboxes,
407 layout=Layout(
408 display="flex",
409 flex_flow="row wrap", # or "column" depending on your layout needs
410 justify_content="center", # Left alignment
411 align_items="center", # Top alignment
412 ),
413 )
414
415 def _on_field_click(self, event: mpl_backend_bases.Event) -> None:
416 """Based on the column where the click was detected, insert a token.
417
418 Args:
419 event (mpl_backend_bases.Event): A matplotlib mouse event.
420 """
421 if isinstance(event, mpl_backend_bases.MouseEvent):
422 ix, iy = event.xdata, event.ydata
423 self.m_logger.debug("click (x,y): %d, %d", ix, iy)
424 idx = np.where(self.m_axs == event.inaxes)[0][0] % self.m_n_col
425 self._insert_token(idx)
426
427 def get_widget(self) -> AppLayout:
428 """Get the widget.
429
430 Examples:
431 Generally, you should this method to retreive and display the widget.
432
433 ```pycon
434 >>> %matplotlib ipympl
435 >>> c4gui = GuiC4()
436 >>> display(c4gui.get_widget())
437 ```
438
439 Returns:
440 AppLayout: the widget.
441 """
442 # Arrange buttons in a row
443 insert_button_row = HBox(
444 self.m_insert_buttons,
445 layout=Layout(
446 display="flex",
447 flex_flow="row wrap", # or "column" depending on your layout needs
448 justify_content="center", # Left alignment
449 align_items="center", # Top alignment
450 ),
451 )
452 control_buttons_col = HBox(
453 [VBox(list(self.m_control_buttons.values()))],
454 layout=Layout(
455 display="flex",
456 flex_flow="row wrap", # or "column" depending on your layout needs
457 justify_content="flex-end", # Left alignment
458 align_items="center", # Top alignment
459 ),
460 )
461
462 tb = self._create_column_labels()
463
464 return AppLayout(
465 header=None,
466 left_sidebar=control_buttons_col,
467 center=VBox(
468 [insert_button_row, self.output, tb],
469 layout=Layout(
470 display="flex",
471 flex_flow="column wrap",
472 justify_content="flex-start", # Left alignment
473 align_items="flex-start", # Top alignment
474 ),
475 ),
476 footer=None,
477 right_sidebar=None,
478 )
479
480 def _check_winner(self, board: bitbully_core.BoardCore) -> None:
481 """Check for Win or draw."""
482 if board.hasWin():
483 winner = "Yellow" if board.movesLeft() % 2 else "Red"
484 self._popup(f"Game over! {winner} wins!")
485 self.m_gameover = True
486 if board.movesLeft() == 0:
487 self._popup("Game over! Draw!")
488 self.m_gameover = True
489
490 def destroy(self) -> None:
491 """Destroy and release the acquired resources."""
492 plt.close(self.m_fig)
493 del self.bitbully_agent
494 del self.m_axs
495 del self.m_fig
496 del self.output
None _undo_move(self)
Definition gui_c4.py:279
None destroy(self)
Definition gui_c4.py:490
None __init__(self)
Definition gui_c4.py:52
None _on_field_click(self, mpl_backend_bases.Event event)
Definition gui_c4.py:415
HBox _create_column_labels(self)
Definition gui_c4.py:390
None _update_insert_buttons(self)
Definition gui_c4.py:316
None _insert_token(self, int col, bool reset_redo_list=True)
Definition gui_c4.py:235
None _paint_token(self)
Definition gui_c4.py:339
None _popup(self, str text)
Definition gui_c4.py:228
None _redo_move(self)
Definition gui_c4.py:273
None _reset(self)
Definition gui_c4.py:124
None _create_board(self)
Definition gui_c4.py:189
None _computer_move(self)
Definition gui_c4.py:180
npt.NDArray[np.float64] _get_fig_size_px(self)
Definition gui_c4.py:137
int _get_img_idx(self, int col, int row)
Definition gui_c4.py:325
AppLayout get_widget(self)
Definition gui_c4.py:427
None _create_buttons(self)
Definition gui_c4.py:375
None _check_winner(self, bitbully_core.BoardCore board)
Definition gui_c4.py:480
None _create_control_buttons(self)
Definition gui_c4.py:149