BitBully 0.0.74
Loading...
Searching...
No Matches
gui_c4.py
1"""GUI module for the BitBully Connect-4 interactive widget."""
2
3import importlib.resources
4import logging
5import textwrap
6import time
7from collections.abc import Sequence
8from pathlib import Path
9
10import matplotlib.backend_bases as mpl_backend_bases
11import matplotlib.pyplot as plt
12import numpy as np
13import numpy.typing as npt
14from IPython.display import Javascript, clear_output, display
15from ipywidgets import AppLayout, Button, HBox, Layout, Output, VBox, widgets
16
17from . import Board
18from .agent_interface import Connect4Agent # adjust if needed
19
20
21class GuiC4:
22 """A class which allows to create an interactive Connect-4 widget.
23
24 GuiC4 is an interactive Connect-4 graphical user interface (GUI) implemented using
25 Matplotlib, IPython widgets, and a backend agent from the BitBully engine. It
26 provides the following main features:
27
28 - Interactive Game Board: Presents a dynamic 6-row by 7-column
29 Connect-4 board with clickable board cells.
30 - Matplotlib Integration: Utilizes Matplotlib figures
31 to render high-quality game visuals directly within Jupyter notebook environments.
32 - User Interaction: Captures and processes mouse clicks and button events, enabling
33 intuitive gameplay via either direct board interaction or button controls.
34 - Undo/Redo Moves: Supports undo and redo functionalities, allowing users to
35 navigate through their move history during gameplay.
36 - Automated Agent Moves: Incorporates BitBully, a Connect-4 backend engine, enabling
37 computer-generated moves and board evaluations.
38 - Game State Handling: Detects game-over scenarios, including win/draw conditions,
39 and provides immediate user feedback through popup alerts.
40
41 Attributes:
42 notify_output (widgets.Output): Output widget for notifications and popups.
43
44 Examples:
45 Generally, you should this method to retreive and display the widget.
46
47 ```pycon
48 >>> %matplotlib ipympl
49 >>> c4gui = GuiC4()
50 >>> display(c4gui.get_widget())
51 ```
52
53 """
54
56 self,
57 agents: dict[str, Connect4Agent] | Sequence[Connect4Agent] | None = None,
58 *,
59 autoplay: bool = False,
60 ) -> None:
61 """Init the GuiC4 widget."""
62 # Create a logger with the class name
63 self.m_logger = logging.getLogger(self.__class__.__name__)
64 self.m_logger.setLevel(logging.DEBUG) # Set the logging level
65
66 # Create a console handler (optional)
67 ch = logging.StreamHandler()
68 ch.setLevel(logging.INFO) # Set level for the handler
69
70 # Create a formatter and add it to the handler
71 formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
72 ch.setFormatter(formatter)
73
74 # Add the handler to the logger
75 if not self.m_logger.handlers:
76 self.m_logger.addHandler(ch)
77
78 # Avoid adding handlers multiple times
79 self.m_logger.propagate = False
80 assets_pth = Path(str(importlib.resources.files("bitbully").joinpath("assets")))
81 png_empty = plt.imread(assets_pth.joinpath("empty.png"), format=None)
82 png_empty_m = plt.imread(assets_pth.joinpath("empty_m.png"), format=None)
83 png_empty_r = plt.imread(assets_pth.joinpath("empty_r.png"), format=None)
84 png_red = plt.imread(assets_pth.joinpath("red.png"), format=None)
85 png_red_m = plt.imread(assets_pth.joinpath("red_m.png"), format=None)
86 png_yellow = plt.imread(assets_pth.joinpath("yellow.png"), format=None)
87 png_yellow_m = plt.imread(assets_pth.joinpath("yellow_m.png"), format=None)
88 self.m_png = {
89 0: {"plain": png_empty, "corner": png_empty_m, "underline": png_empty_r},
90 1: {"plain": png_yellow, "corner": png_yellow_m},
91 2: {"plain": png_red, "corner": png_red_m},
92 }
93
94 self.m_n_row, self.m_n_col = 6, 7
95
96 # TODO: probably not needed:
97 self.m_height = np.zeros(7, dtype=np.int32)
98
99 self.m_board_size = 3.5
100 # self.m_player = 1
101 self.is_busy = False
102
103 # ---------------- multi-agent support ----------------
104 self.autoplay = bool(autoplay)
105
106 # Normalize `agents` into a dict[str, Connect4Agent]
107 if agents is None:
108 self.agents: dict[str, Connect4Agent] = {}
109 elif isinstance(agents, dict):
110 self.agents = dict(agents)
111 else:
112 self.agents = {f"agent{i + 1}": a for i, a in enumerate(agents)}
113
114 self._agent_names: list[str] = list(self.agents.keys())
115
116 # Which controller plays which color
117 # values are either "human" or one of self._agent_names
118 self.yellow_player: str = "human"
119 self.red_player: str = self._agent_names[0] if self._agent_names else "human"
120
121 # Which agent should be used for the "Evaluate" button.
122 # Values: "auto" or one of self._agent_names (if any exist)
123 self.eval_agent_choice: str = "auto"
124
125 # Create board first
126 self._create_board()
127
128 # timing row (must exist before get_widget())
129 self._create_status_bar()
130
131 # Generate buttons for inserting the tokens:
132 self._create_buttons()
133
134 # Create control buttons
136
137 # player selection dropdowns (must exist before get_widget())
139
140 # evaluation row widget (must exist before get_widget())
141 self._create_eval_row()
142
143 # Capture clicks on the field
144 _ = self.m_fig.canvas.mpl_connect("button_press_event", self._on_field_click)
145
146 # Movelist
147 self.m_movelist: list[tuple[int, int, int]] = []
148
149 # Redo list
150 self.m_redolist: list[tuple[int, int, int]] = []
151
152 # Gameover flag:
153 self.m_gameover = False
154
155 # NEW: move list + copy position UI
157
158 def _create_player_selectors(self) -> None:
159 """Create UI controls for player assignment, autoplay, and evaluation agent."""
160 agent_options = list(self._agent_names)
161 player_options = ["human", *agent_options]
162 eval_options = ["auto", *agent_options] # "auto" = use agent for side-to-move, else fallback
163
164 # --- Player assignment ---
165 self.dd_yellow = widgets.Dropdown(
166 options=player_options,
167 value=self.yellow_player if self.yellow_player in player_options else "human",
168 description="Yellow:",
169 layout=Layout(width="200px"),
170 )
171 self.dd_red = widgets.Dropdown(
172 options=player_options,
173 value=self.red_player if self.red_player in player_options else "human",
174 description="Red:",
175 layout=Layout(width="200px"),
176 )
177
178 # --- Autoplay toggle ---
179 self.cb_autoplay = widgets.Checkbox(
180 value=bool(self.autoplay),
181 description="Autoplay",
182 indent=False,
183 layout=Layout(width="auto"), # width="200px"
184 )
185
186 # --- Eval agent selection ---
187 self.dd_eval_agent = widgets.Dropdown(
188 options=eval_options,
189 value=self.eval_agent_choice if self.eval_agent_choice in eval_options else "auto",
190 description="Eval:",
191 layout=Layout(width="200px"),
192 )
193
194 def _on_players_change(_change: object) -> None:
195 self.yellow_player = str(self.dd_yellow.value)
196 self.red_player = str(self.dd_red.value)
198 if self.autoplay:
199 self._maybe_autoplay()
200
201 def _on_autoplay_change(_change: object) -> None:
202 self.autoplay = bool(self.cb_autoplay.value)
203 # If autoplay is turned on mid-game, maybe immediately make the next agent move
204 if self.autoplay:
205 self._maybe_autoplay()
206
207 def _on_eval_agent_change(_change: object) -> None:
208 self.eval_agent_choice = str(self.dd_eval_agent.value)
209
210 self.dd_yellow.observe(_on_players_change, names="value")
211 self.dd_red.observe(_on_players_change, names="value")
212 self.cb_autoplay.observe(_on_autoplay_change, names="value")
213 self.dd_eval_agent.observe(_on_eval_agent_change, names="value")
214
215 row1 = HBox(
216 [self.dd_yellow, self.dd_red],
217 layout=Layout(
218 display="flex",
219 flex_flow="row",
220 justify_content="flex-start",
221 align_items="center",
222 ),
223 )
224
225 row2 = HBox(
226 [self.cb_autoplay, self.dd_eval_agent],
227 layout=Layout(
228 display="flex",
229 flex_flow="row",
230 justify_content="flex-end",
231 align_items="flex-end",
232 ),
233 )
234
235 self.player_select_row = VBox(
236 [row1, row2],
237 layout=Layout(
238 display="flex",
239 flex_flow="column",
240 justify_content="flex-end",
241 align_items="flex-end",
242 ),
243 )
244
245 def _create_status_bar(self) -> None:
246 """Create a row that shows the computation time of the last agent move."""
247 self.m_status_label = widgets.Label(
248 value="",
249 layout=Layout(width="80%"),
250 )
251 self.m_active_player_label = widgets.Label(
252 value="| Next: 🟡",
253 layout=Layout(width="20%", justify_content="flex-end", align_items="center"),
254 )
255 self.m_time_row = HBox(
257 layout=Layout(
258 display="flex",
259 flex_flow="row",
260 justify_content="flex-start",
261 align_items="center",
262 width="100%",
263 ),
264 )
265
266 def _create_move_list_ui(self) -> None:
267 """Create the move list display and clipboard buttons."""
268 self.ta_moves = widgets.Textarea(
269 value="",
270 description="",
271 disabled=True,
272 layout=Layout(width="100%", height="100%"), # was "110px"
273 )
274
275 self.btn_copy_pos = Button(
276 description="📋 Copy move sequence",
277 tooltip="Copy the position string used by Board(...), e.g. '3431'",
278 layout=Layout(width="100%"),
279 )
280 self.btn_copy_moves_ag = Button(
281 description="📋 Copy ASCII board",
282 tooltip="Copy the ascii representation of the board",
283 layout=Layout(width="100%"),
284 )
285
286 # buttons_row = VBox(
287 # [self.btn_copy_pos, self.btn_copy_moves_ag],
288 # layout=Layout(width="100%"),
289 # )
290
291 self.btn_copy_pos.on_click(lambda _b: self._copy_position_string())
292 self.btn_copy_moves_ag.on_click(lambda _b: self._copy_moves_ag())
293
294 self.move_list_row = VBox(
295 [self.btn_copy_pos, self.btn_copy_moves_ag, self.ta_moves],
296 layout=Layout(
297 width="200px",
298 height="100%", # NEW: take all available height in sidebar
299 align_items="stretch",
300 flex="1 1 auto", # NEW: allow growing
301 ),
302 )
303
304 # Make the textarea take the remaining space below the buttons row
305 # buttons_row.layout = Layout(width="100%", flex="0 0 auto")
306 # self.ta_moves.layout = Layout(width="100%", height="100%", flex="1 1 auto")
307
308 self._update_move_list_ui() # initialize
309
310 def _position_string(self) -> str:
311 """Return the position encoding compatible with Board(...).
312
313 BitBully's Board examples use strings like "341" (columns as digits),
314 so we follow the same convention.
315 """
316 return "".join(str(col) for (_p, col, _row) in self.m_movelist)
317
318 def _moves_ag_string(self) -> str:
319 """Return board as ascii string.."""
320 # column 0..6 -> a..g
321 # return " ".join(chr(ord("a") + col) for (_p, col, _row) in self.m_movelist)
322 return textwrap.dedent(self._board_from_history().to_string()).strip()
323
324 def _update_move_list_ui(self) -> None:
325 """Refresh the move list textarea."""
326 pos = self._position_string()
327 ag = self._moves_ag_string()
328
329 lines: list[str] = []
330 lines.extend(
331 [
332 f"moves: {pos or '—'}", #
333 f"\nplies: {len(self.m_movelist)}", #
334 f"\nboard:\n{ag or '—'}",
335 ]
336 )
337
338 self.ta_moves.value = "\n".join(lines)
339
340 def _copy_to_clipboard(self, text: str) -> None:
341 """Copy text to clipboard in Jupyter (best-effort)."""
342 # Works in most modern Jupyter setups; if clipboard is blocked, it just won't copy.
343 js = Javascript(
344 f"""
345 (async () => {{
346 try {{
347 await navigator.clipboard.writeText({text!r});
348 }} catch (e) {{
349 // Fallback for stricter environments
350 const ta = document.createElement('textarea');
351 ta.value = {text!r};
352 document.body.appendChild(ta);
353 ta.select();
354 document.execCommand('copy');
355 document.body.removeChild(ta);
356 }}
357 }})();
358 """
359 )
360 display(js)
361
362 def _copy_position_string(self) -> None:
363 pos = self._position_string()
364 self._copy_to_clipboard(pos)
365
366 def _copy_moves_ag(self) -> None:
367 ag = self._moves_ag_string()
368 self._copy_to_clipboard(ag)
369
370 # TODO: a bit hacky, use board instance instead?
371 def _current_player(self) -> int:
372 """Return player to move: 1 (Yellow) starts, then alternates."""
373 return 1 if (len(self.m_movelist) % 2 == 0) else 2
374
375 def _controller_for_player(self, player: int) -> str:
376 return self.yellow_player if player == 1 else self.red_player
377
378 def _agent_for_player(self, player: int) -> Connect4Agent | None:
379 controller = self._controller_for_player(player)
380 if controller == "human":
381 return None
382 return self.agents.get(controller)
383
384 def _agent_for_evaluation(self) -> Connect4Agent | None:
385 """Return the agent used for the Evaluate button based on dropdown selection."""
386 if not self._agent_names:
387 return None
388
389 choice = getattr(self, "eval_agent_choice", "auto")
390 if choice != "auto":
391 return self.agents.get(choice)
392
393 # "auto": prefer the agent controlling the side to move; fallback to first agent
394 player = self._current_player()
395 agent = self._agent_for_player(player)
396 return agent or self.agents[self._agent_names[0]]
397
398 def _reset(self) -> None:
399 self.m_movelist = []
400 self.m_redolist = []
401 self.m_height = np.zeros(7, dtype=np.int32)
402 self.m_gameover = False
403
404 for im in self.ims:
405 im.set_data(self.m_png[0]["plain"])
406
407 self.m_fig.canvas.draw_idle()
408 self.m_fig.canvas.flush_events()
410 self._clear_eval_row()
411 self.m_status_label.value = ""
413
414 def _get_fig_size_px(self) -> npt.NDArray[np.float64]:
415 # Get the size in inches
416 size_in_inches = self.m_fig.get_size_inches()
417 self.m_logger.debug("Figure size in inches: %f", size_in_inches)
418
419 # Get the DPI
420 dpi = self.m_fig.dpi
421 self.m_logger.debug("Figure DPI: %d", dpi)
422
423 # Convert to pixels
424 return size_in_inches * dpi
425
426 def _create_control_buttons(self) -> None:
427 self.m_control_buttons = {}
428
429 # Create buttons for each column
430 self.m_logger.debug("Figure size: %s", self._get_fig_size_px())
431
432 fig_size_px = self._get_fig_size_px()
433 wh = f"{-3 + (fig_size_px[1] / self.m_n_row)}px"
434 btn_layout = Layout(height=wh, width=wh)
435
436 button = Button(description="🔄", tooltip="Reset Game", layout=btn_layout)
437 button.on_click(lambda b: self._reset())
438 self.m_control_buttons["reset"] = button
439
440 button = Button(description="↩️", tooltip="Undo Move", layout=btn_layout)
441 button.disabled = True
442 button.on_click(lambda b: self._undo_move())
443 self.m_control_buttons["undo"] = button
444
445 button = Button(description="↪️", tooltip="Redo Move", layout=btn_layout)
446 button.disabled = True
447 button.on_click(lambda b: self._redo_move())
448 self.m_control_buttons["redo"] = button
449
450 button = Button(description="🕹️", tooltip="Computer Move", layout=btn_layout)
451 button.on_click(lambda b: self._computer_move())
452 self.m_control_buttons["move"] = button
453
454 button = Button(description="📊", tooltip="Evaluate Board", layout=btn_layout)
455 button.on_click(lambda b: self._evaluate_board())
456 self.m_control_buttons["evaluate"] = button
457
458 # ---------------- evaluation widgets ----------------
459
460 def _create_eval_row(self) -> None:
461 """Create a row of 7 labels to display per-column evaluation scores."""
462 fig_size_px = self._get_fig_size_px()
463 width = f"{-3 + (fig_size_px[0] / self.m_n_col)}px"
464
465 self.m_eval_labels: list[widgets.Label] = [
466 widgets.Label(
467 value="",
468 layout=Layout(justify_content="center", align_items="center", width=width),
469 )
470 for _ in range(self.m_n_col)
471 ]
472 self.m_eval_row = HBox(
473 self.m_eval_labels,
474 layout=Layout(
475 display="flex",
476 flex_flow="row wrap",
477 justify_content="center",
478 align_items="center",
479 ),
480 )
481
482 def _clear_eval_row(self) -> None:
483 """Clear all evaluation score labels."""
484 for lbl in self.m_eval_labels:
485 lbl.value = ""
486
487 def _evaluate_board(self) -> None:
488 """Compute and display per-column evaluation scores for the current position."""
489 if self.is_busy:
490 return
491
492 self.is_busy = True
494
495 try:
496 board = self._board_from_history()
497
498 # If you want: show blanks for illegal moves.
499 # Compute scores for all 7 columns.
500 # scores = self.agent.score_all_moves(board) # -> Sequence[int] of length 7
501 agent = self._agent_for_evaluation()
502 if agent is None:
503 self._clear_eval_row()
504 return
505
506 t0 = time.perf_counter()
507 scores = agent.score_all_moves(board)
508 dt_ms = (time.perf_counter() - t0) * 1000.0
509
510 # Update timing row
511 # Identify agent name for display
512 if self.eval_agent_choice != "auto":
513 agent_name = self.eval_agent_choice
514 else:
515 # auto → agent for side-to-move or fallback
516 player = self._current_player()
517 agent_name = self._controller_for_player(player)
518 if agent_name == "human":
519 agent_name = self._agent_names[0]
520 self.m_status_label.value = f"📊 Evaluation: {agent_name} — ⏱️ {dt_ms:.1f} ms"
521
522 # Fill the label row. (Optionally blank-out illegal moves)
523 legal = set(board.legal_moves())
524 for col in range(self.m_n_col):
525 if col in legal:
526 self.m_eval_labels[col].value = str(int(scores[col]))
527 else:
528 self.m_eval_labels[col].value = "—"
529
530 except Exception as e:
531 self.m_logger.error("Evaluation failed: %s", str(e))
532 # Optional: popup on error
533 # self._popup(f"Evaluation failed: {e}")
534 self._clear_eval_row()
535 raise
536 finally:
537 self.is_busy = False
539
540 def _computer_move(self) -> None:
541 if self.is_busy or self.m_gameover:
542 return
543
544 player = self._current_player()
545 agent = self._agent_for_player(player)
546 if agent is None:
547 # It's a human-controlled side
548 return
549
550 # Identify which agent name is playing this side (for display)
551 agent_name = self._controller_for_player(player)
552
553 self.is_busy = True
555 try:
556 b = self._board_from_history()
557
558 t0 = time.perf_counter()
559 best_move = agent.best_move(b)
560 dt_ms = (time.perf_counter() - t0) * 1000.0
561
562 # Update timing row (only if it was an agent move)
563 color = "🟡" if player == 1 else "🔴"
564 self.m_status_label.value = f"🕹️ Last move: {color} ({agent_name}) — ⏱️ {dt_ms:.1f} ms."
565
566 finally:
567 self.is_busy = False
568
569 # Perform move (this will re-disable/re-enable buttons as usual)
570 self._insert_token(best_move)
571
572 def _create_board(self) -> None:
573 self.output = Output()
574
575 with self.output:
576 fig, axs = plt.subplots(
577 self.m_n_row,
578 self.m_n_col,
579 figsize=(
580 self.m_board_size / self.m_n_row * self.m_n_col,
581 self.m_board_size,
582 ),
583 )
584 axs = axs.flatten()
585 self.ims = []
586 for ax in axs:
587 self.ims.append(ax.imshow(self.m_png[0]["plain"], animated=True))
588 ax.axis("off")
589 ax.set_xticklabels([])
590 ax.set_yticklabels([])
591
592 fig.tight_layout(pad=0.1)
593 plt.subplots_adjust(wspace=0.05, hspace=0.05, left=0.01, right=0.99, top=0.99, bottom=0.01)
594 fig.suptitle("")
595 fig.set_facecolor("darkgray")
596 fig.canvas.toolbar_visible = False # type: ignore[attr-defined]
597 fig.canvas.resizable = False # type: ignore[attr-defined]
598 fig.canvas.toolbar_visible = False # type: ignore[attr-defined]
599 fig.canvas.header_visible = False # type: ignore[attr-defined]
600 fig.canvas.footer_visible = False # type: ignore[attr-defined]
601 fig.canvas.capture_scroll = True # type: ignore[attr-defined]
602 plt.show(block=False)
603
604 self.m_fig = fig
605 self.m_axs = axs
606
607 notify_output: widgets.Output = widgets.Output()
608 display(notify_output)
609
610 @notify_output.capture()
611 def _popup(self, text: str) -> None:
612 clear_output()
613 display(Javascript(f"alert('{text}')"))
614
615 def _board_from_history(self) -> Board:
616 return Board([mv[1] for mv in self.m_movelist])
617
618 def _insert_token(self, col: int, reset_redo_list: bool = True) -> None:
619 if self.is_busy:
620 return
621 self.is_busy = True
622
623 for button in self.m_insert_buttons:
624 button.disabled = True
625
626 board = self._board_from_history()
627 if self.m_gameover or not board.play(int(col)):
628 self.is_busy = False
630 return
631
632 try:
633 # Get player
634 player = 1 if not self.m_movelist else 3 - self.m_movelist[-1][0]
635 self.m_movelist.append((player, col, self.m_height[col]))
637 self._paint_token()
638 self.m_height[col] += 1
639
640 # Usually, after a move is performed, there is no possibility to
641 # redo a move again
642 if reset_redo_list:
643 self.m_redolist = []
644
645 self._check_winner(board)
646 # clear eval row because the position changed
647 self._clear_eval_row()
648
649 except Exception as e:
650 self.m_logger.error("Error: %s", str(e))
651 raise
652 finally:
653 time.sleep(0.25) # debounce button
654 # Re-enable all buttons (if columns not full)
655 self.is_busy = False
657 if self.autoplay:
658 self._maybe_autoplay()
659
660 def _redo_move(self) -> None:
661 if len(self.m_redolist) < 1:
662 return
663 _p, col, _row = self.m_redolist.pop()
664 self._insert_token(col, reset_redo_list=False)
665
666 def _undo_move(self) -> None:
667 if len(self.m_movelist) < 1:
668 return
669
670 if self.is_busy:
671 return
672 self.is_busy = True
673
674 try:
675 _p, col, row = mv = self.m_movelist.pop()
676 self.m_redolist.append(mv)
677
678 self.m_height[col] -= 1
679 assert row == self.m_height[col]
680
681 img_idx = self._get_img_idx(col, row)
682
683 self.ims[img_idx].set_data(self.m_png[0]["plain"])
684 self.m_axs[img_idx].draw_artist(self.ims[img_idx])
685
687 if len(self.m_movelist) > 0:
688 self._paint_token()
689 else:
690 self.m_fig.canvas.blit(self.ims[img_idx].get_clip_box())
691 self.m_fig.canvas.flush_events()
692
693 self.m_gameover = False
694
695 # clear eval row because the position changed
696 self._clear_eval_row()
697
698 except Exception as e:
699 self.m_logger.error("Error: %s", str(e))
700 raise
701 finally:
702 # Re-enable all buttons (if columns not full)
703 self.is_busy = False
705
706 time.sleep(0.25) # debounce button
707
708 def _update_insert_buttons(self) -> None:
709 player = self._current_player()
710 human_turn = self._controller_for_player(player) == "human"
711
712 # ⏬ buttons
713 for button, col in zip(self.m_insert_buttons, range(self.m_n_col)):
714 # disable if column full OR gameover/busy OR not a human turn
715 button.disabled = (
716 bool(self.m_height[col] >= self.m_n_row) or self.m_gameover or self.is_busy or (not human_turn)
717 )
718
719 # ↩️ button
720 self.m_control_buttons["undo"].disabled = len(self.m_movelist) < 1 or self.is_busy
721
722 # ↪️ button
723 self.m_control_buttons["redo"].disabled = len(self.m_redolist) < 1 or self.is_busy
724
725 # 🕹️ only makes sense if current side is agent-controlled
726 self.m_control_buttons["move"].disabled = (
727 self.m_gameover or self.is_busy or (self._agent_for_player(player) is None)
728 )
729
730 # 📊 enable only if we have at least one agent to evaluate with
731 self.m_control_buttons["evaluate"].disabled = self.m_gameover or self.is_busy or (len(self.agents) == 0)
732
733 if hasattr(self, "dd_eval_agent"):
734 self.dd_eval_agent.disabled = (len(self.agents) == 0) or self.is_busy
735 if hasattr(self, "cb_autoplay"):
736 self.cb_autoplay.disabled = self.is_busy
737
738 active_player = "🔴" if player == 2 else "🟡"
739 if self.m_gameover:
740 active_player = "—"
741 self.m_active_player_label.value = f" | Next: {active_player}"
742
743 def _get_img_idx(self, col: int, row: int) -> int:
744 """Translates a column and row ID into the corresponding image ID.
745
746 Args:
747 col (int): column (0-6) of the considered board cell.
748 row (int): row (0-5) of the considered board cell.
749
750 Returns:
751 int: The corresponding image id (0-41).
752 """
753 self.m_logger.debug("Got column: %d", col)
754
755 return col % self.m_n_col + (self.m_n_row - row - 1) * self.m_n_col
756
757 def _paint_token(self) -> None:
758 if len(self.m_movelist) < 1:
759 return
760
761 p, col, row = self.m_movelist[-1]
762 img_idx = self._get_img_idx(col, row)
763 self.m_logger.debug("Paint token: %d", img_idx)
764
765 #
766 # no need to reset background, since we anyhow overwrite it again
767 # self.m_fig.canvas.restore_region(self.m_background[img_idx])
768 self.ims[img_idx].set_data(self.m_png[p]["corner"])
769
770 # see: https://matplotlib.org/3.4.3/Matplotlib.pdf
771 # 2.3.1 Faster rendering by using blitting
772 blit_boxes = []
773 self.m_axs[img_idx].draw_artist(self.ims[img_idx])
774 blit_boxes.append(self.ims[img_idx].get_clip_box())
775 # self.m_fig.canvas.blit()
776
777 if len(self.m_movelist) > 1:
778 # Remove the white corners for the second-to-last move
779 # TODO: redundant code above
780 p, col, row = self.m_movelist[-2]
781 img_idx = self._get_img_idx(col, row)
782 self.ims[img_idx].set_data(self.m_png[p]["plain"])
783 self.m_axs[img_idx].draw_artist(self.ims[img_idx])
784 blit_boxes.append(self.ims[img_idx].get_clip_box())
785
786 self.m_fig.canvas.blit(blit_boxes[0])
787
788 # self.m_fig.canvas.restore_region(self.m_background[img_idx])
789 # self.m_fig.canvas.blit(self.ims[img_idx].get_clip_box())
790 # self.m_fig.canvas.draw_idle()
791 self.m_fig.canvas.flush_events()
792
793 def _create_buttons(self) -> None:
794 # Create buttons for each column
795 self.m_logger.debug("Figure size: %s", self._get_fig_size_px())
796
797 fig_size_px = self._get_fig_size_px()
798
799 self.m_insert_buttons = []
800 for col in range(self.m_n_col):
801 button = Button(
802 description="⏬",
803 layout=Layout(width=f"{-4 + (fig_size_px[0] / self.m_n_col)}px", height="50px"),
804 )
805 button.on_click(lambda b, col=col: self._insert_token(col))
806 self.m_insert_buttons.append(button)
807
808 def _create_column_labels(self) -> HBox:
809 """Creates a row with the column labels 'a' to 'g'.
810
811 Returns:
812 HBox: A row of textboxes containing the columns labels 'a' to 'g'.
813 """
814 fig_size_px = self._get_fig_size_px()
815 width = f"{-3 + (fig_size_px[0] / self.m_n_col)}px"
816 textboxes = [
817 widgets.Label(
818 value=chr(ord("a") + i),
819 layout=Layout(justify_content="center", align_items="center", width=width),
820 )
821 for i in range(self.m_n_col)
822 ]
823 return HBox(
824 textboxes,
825 layout=Layout(
826 display="flex",
827 flex_flow="row wrap", # or "column" depending on your layout needs
828 justify_content="center", # Left alignment
829 align_items="center", # Top alignment
830 ),
831 )
832
833 def _on_field_click(self, event: mpl_backend_bases.Event) -> None:
834 """Based on the column where the click was detected, insert a token.
835
836 Args:
837 event (mpl_backend_bases.Event): A matplotlib mouse event.
838 """
839 if self._controller_for_player(self._current_player()) != "human":
840 return
841
842 if not isinstance(event, mpl_backend_bases.MouseEvent):
843 return
844 if event.inaxes is None or event.xdata is None:
845 return
846 if isinstance(event, mpl_backend_bases.MouseEvent):
847 ix, iy = event.xdata, event.ydata
848 self.m_logger.debug("click (x,y): %d, %d", ix, iy)
849 idx = np.where(self.m_axs == event.inaxes)[0][0] % self.m_n_col
850 self._insert_token(idx)
851
852 def get_widget(self) -> AppLayout:
853 """Get the widget.
854
855 Examples:
856 Generally, you should this method to retreive and display the widget.
857
858 ```pycon
859 >>> %matplotlib ipympl
860 >>> c4gui = GuiC4()
861 >>> display(c4gui.get_widget())
862 ```
863
864 Returns:
865 AppLayout: the widget.
866 """
867 # Arrange buttons in a row
868 insert_button_row = HBox(
869 [VBox(layout=Layout(padding="0px 0px 0px 6px")), *self.m_insert_buttons],
870 layout=Layout(
871 display="flex",
872 flex_flow="row wrap", # or "column" depending on your layout needs
873 justify_content="center", # Left alignment
874 align_items="center", # Top alignment
875 ),
876 )
877 control_buttons_col = HBox(
878 [VBox(list(self.m_control_buttons.values()))],
879 layout=Layout(
880 display="flex",
881 flex_flow="row wrap", # or "column" depending on your layout needs
882 justify_content="flex-end",
883 align_items="center", # bottom alignment
884 ),
885 )
886
887 # deactivate for now
888 # tb = self._create_column_labels()
889
890 right = VBox(
891 [self.move_list_row],
892 layout=Layout(
893 display="flex",
894 flex_flow="column",
895 justify_content="flex-start",
896 align_items="stretch",
897 width="200px",
898 height="90%", # NEW: fill AppLayout height
899 flex="1 1 auto", # NEW: allow it to grow
900 ),
901 )
902
903 main = HBox(
904 [
905 VBox(
906 [
908 insert_button_row,
909 self.output,
910 self.m_eval_row,
911 self.m_time_row,
912 ],
913 layout=Layout(
914 display="flex",
915 flex_flow="column",
916 align_items="flex-start",
917 ),
918 ),
919 right,
920 ],
921 layout=Layout(
922 display="flex",
923 flex_flow="row",
924 align_items="flex-start",
925 justify_content="flex-start",
926 gap="5px", # space between board and sidebar
927 width="100%",
928 ),
929 )
930
931 return AppLayout(
932 header=None,
933 left_sidebar=control_buttons_col,
934 center=main,
935 right_sidebar=None, # <= important
936 footer=None,
937 layout=Layout(grid_gap="0px"),
938 )
939
940 def _maybe_autoplay(self) -> None:
941 """If it's an agent-controlled turn, immediately play its move."""
942 if self.is_busy or self.m_gameover:
943 return
944 if self._agent_for_player(self._current_player()) is None:
945 return
946 self._computer_move()
947
948 def _check_winner(self, board: Board) -> None:
949 """Check for Win or draw."""
950 if board.has_win():
951 winner = "Yellow (🟡)" if board.winner() == 1 else "Red (🔴)"
952 msg = f"🏆 Game over! {winner} wins!"
953 self.m_status_label.value = msg
954 self._popup(msg)
955 self.m_gameover = True
956 elif board.is_full():
957 msg = "🤝 Game over! It's a draw!"
958 self.m_status_label.value = msg
959 self._popup(msg)
960 self.m_gameover = True
961
962 def destroy(self) -> None:
963 """Destroy and release the acquired resources."""
964 plt.close(self.m_fig)
965 del self.agents
966 del self.m_axs
967 del self.m_fig
968 del self.output
int _current_player(self)
Definition gui_c4.py:371
None _evaluate_board(self)
Definition gui_c4.py:487
None _undo_move(self)
Definition gui_c4.py:666
None _copy_position_string(self)
Definition gui_c4.py:362
None destroy(self)
Definition gui_c4.py:962
None _copy_moves_ag(self)
Definition gui_c4.py:366
None _on_field_click(self, mpl_backend_bases.Event event)
Definition gui_c4.py:833
str _moves_ag_string(self)
Definition gui_c4.py:318
HBox _create_column_labels(self)
Definition gui_c4.py:808
None _update_insert_buttons(self)
Definition gui_c4.py:708
None _clear_eval_row(self)
Definition gui_c4.py:482
None _create_eval_row(self)
Definition gui_c4.py:460
None _insert_token(self, int col, bool reset_redo_list=True)
Definition gui_c4.py:618
Connect4Agent|None _agent_for_evaluation(self)
Definition gui_c4.py:384
Board _board_from_history(self)
Definition gui_c4.py:615
None _paint_token(self)
Definition gui_c4.py:757
None _popup(self, str text)
Definition gui_c4.py:611
None _redo_move(self)
Definition gui_c4.py:660
None _create_player_selectors(self)
Definition gui_c4.py:158
None _create_move_list_ui(self)
Definition gui_c4.py:266
None __init__(self, dict[str, Connect4Agent]|Sequence[Connect4Agent]|None agents=None, *, bool autoplay=False)
Definition gui_c4.py:60
Connect4Agent|None _agent_for_player(self, int player)
Definition gui_c4.py:378
str _position_string(self)
Definition gui_c4.py:310
None _reset(self)
Definition gui_c4.py:398
None _copy_to_clipboard(self, str text)
Definition gui_c4.py:340
None _create_board(self)
Definition gui_c4.py:572
str _controller_for_player(self, int player)
Definition gui_c4.py:375
None _update_move_list_ui(self)
Definition gui_c4.py:324
None _computer_move(self)
Definition gui_c4.py:540
npt.NDArray[np.float64] _get_fig_size_px(self)
Definition gui_c4.py:414
None _create_status_bar(self)
Definition gui_c4.py:245
int _get_img_idx(self, int col, int row)
Definition gui_c4.py:743
None _check_winner(self, Board board)
Definition gui_c4.py:948
AppLayout get_widget(self)
Definition gui_c4.py:852
None _maybe_autoplay(self)
Definition gui_c4.py:940
None _create_buttons(self)
Definition gui_c4.py:793
None _create_control_buttons(self)
Definition gui_c4.py:426
list[str] _agent_names
Definition gui_c4.py:114