15from ipywidgets
import AppLayout, Button, HBox, Layout, Output, VBox, widgets
22 """A class which allows to create an interactive Connect-4 widget.
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:
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.
42 notify_output (widgets.Output): Output widget for notifications and popups.
45 Generally, you should this method to retreive and display the widget.
48 >>> %matplotlib ipympl
50 >>> display(c4gui.get_widget())
57 agents: dict[str, Connect4Agent] | Sequence[Connect4Agent] |
None =
None,
59 autoplay: bool =
False,
61 """Init the GuiC4 widget."""
63 self.
m_logger = logging.getLogger(self.__class__.__name__)
64 self.
m_logger.setLevel(logging.DEBUG)
67 ch = logging.StreamHandler()
68 ch.setLevel(logging.INFO)
71 formatter = logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(message)s")
72 ch.setFormatter(formatter)
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)
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},
97 self.
m_height = np.zeros(7, dtype=np.int32)
108 self.
agents: dict[str, Connect4Agent] = {}
109 elif isinstance(agents, dict):
110 self.
agents = dict(agents)
112 self.
agents = {f
"agent{i + 1}": a
for i, a
in enumerate(agents)}
147 self.
m_movelist: list[tuple[int, int, int]] = []
150 self.
m_redolist: list[tuple[int, int, int]] = []
159 """Create UI controls for player assignment, autoplay, and evaluation agent."""
161 player_options = [
"human", *agent_options]
162 eval_options = [
"auto", *agent_options]
166 options=player_options,
168 description=
"Yellow:",
169 layout=Layout(width=
"200px"),
171 self.
dd_red = widgets.Dropdown(
172 options=player_options,
175 layout=Layout(width=
"200px"),
181 description=
"Autoplay",
183 layout=Layout(width=
"auto"),
188 options=eval_options,
191 layout=Layout(width=
"200px"),
194 def _on_players_change(_change: object) ->
None:
201 def _on_autoplay_change(_change: object) ->
None:
207 def _on_eval_agent_change(_change: object) ->
None:
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")
220 justify_content=
"flex-start",
221 align_items=
"center",
230 justify_content=
"flex-end",
231 align_items=
"flex-end",
240 justify_content=
"flex-end",
241 align_items=
"flex-end",
246 """Create a row that shows the computation time of the last agent move."""
249 layout=Layout(width=
"80%"),
253 layout=Layout(width=
"20%", justify_content=
"flex-end", align_items=
"center"),
260 justify_content=
"flex-start",
261 align_items=
"center",
267 """Create the move list display and clipboard buttons."""
272 layout=Layout(width=
"100%", height=
"100%"),
276 description=
"📋 Copy move sequence",
277 tooltip=
"Copy the position string used by Board(...), e.g. '3431'",
278 layout=Layout(width=
"100%"),
281 description=
"📋 Copy ASCII board",
282 tooltip=
"Copy the ascii representation of the board",
283 layout=Layout(width=
"100%"),
299 align_items=
"stretch",
311 """Return the position encoding compatible with Board(...).
313 BitBully's Board examples use strings like "341" (columns as digits),
314 so we follow the same convention.
316 return "".join(str(col)
for (_p, col, _row)
in self.
m_movelist)
319 """Return board as ascii string.."""
325 """Refresh the move list textarea."""
329 lines: list[str] = []
332 f
"moves: {pos or '—'}",
333 f
"\nplies: {len(self.m_movelist)}",
334 f
"\nboard:\n{ag or '—'}",
338 self.
ta_moves.value =
"\n".join(lines)
341 """Copy text to clipboard in Jupyter (best-effort)."""
347 await navigator.clipboard.writeText({text!r});
349 // Fallback for stricter environments
350 const ta = document.createElement('textarea');
352 document.body.appendChild(ta);
354 document.execCommand('copy');
355 document.body.removeChild(ta);
362 def _copy_position_string(self) -> None:
366 def _copy_moves_ag(self) -> None:
372 """Return player to move: 1 (Yellow) starts, then alternates."""
373 return 1
if (len(self.
m_movelist) % 2 == 0)
else 2
375 def _controller_for_player(self, player: int) -> str:
378 def _agent_for_player(self, player: int) -> Connect4Agent |
None:
380 if controller ==
"human":
382 return self.
agents.get(controller)
385 """Return the agent used for the Evaluate button based on dropdown selection."""
389 choice = getattr(self,
"eval_agent_choice",
"auto")
391 return self.
agents.get(choice)
398 def _reset(self) -> None:
401 self.
m_height = np.zeros(7, dtype=np.int32)
405 im.set_data(self.
m_png[0][
"plain"])
407 self.
m_fig.canvas.draw_idle()
408 self.
m_fig.canvas.flush_events()
414 def _get_fig_size_px(self) -> npt.NDArray[np.float64]:
416 size_in_inches = self.
m_fig.get_size_inches()
417 self.
m_logger.debug(
"Figure size in inches: %f", size_in_inches)
421 self.
m_logger.debug(
"Figure DPI: %d", dpi)
424 return size_in_inches * dpi
426 def _create_control_buttons(self) -> None:
433 wh = f
"{-3 + (fig_size_px[1] / self.m_n_row)}px"
434 btn_layout = Layout(height=wh, width=wh)
436 button = Button(description=
"🔄", tooltip=
"Reset Game", layout=btn_layout)
437 button.on_click(
lambda b: self.
_reset())
440 button = Button(description=
"↩️", tooltip=
"Undo Move", layout=btn_layout)
441 button.disabled =
True
445 button = Button(description=
"↪️", tooltip=
"Redo Move", layout=btn_layout)
446 button.disabled =
True
450 button = Button(description=
"🕹️", tooltip=
"Computer Move", layout=btn_layout)
454 button = Button(description=
"📊", tooltip=
"Evaluate Board", layout=btn_layout)
461 """Create a row of 7 labels to display per-column evaluation scores."""
463 width = f
"{-3 + (fig_size_px[0] / self.m_n_col)}px"
468 layout=Layout(justify_content=
"center", align_items=
"center", width=width),
476 flex_flow=
"row wrap",
477 justify_content=
"center",
478 align_items=
"center",
483 """Clear all evaluation score labels."""
488 """Compute and display per-column evaluation scores for the current position."""
506 t0 = time.perf_counter()
507 scores = agent.score_all_moves(board)
508 dt_ms = (time.perf_counter() - t0) * 1000.0
518 if agent_name ==
"human":
520 self.
m_status_label.value = f
"📊 Evaluation: {agent_name} — ⏱️ {dt_ms:.1f} ms"
523 legal = set(board.legal_moves())
524 for col
in range(self.
m_n_col):
530 except Exception
as e:
531 self.
m_logger.error(
"Evaluation failed: %s", str(e))
540 def _computer_move(self) -> None:
558 t0 = time.perf_counter()
559 best_move = agent.best_move(b)
560 dt_ms = (time.perf_counter() - t0) * 1000.0
563 color =
"🟡" if player == 1
else "🔴"
564 self.
m_status_label.value = f
"🕹️ Last move: {color} ({agent_name}) — ⏱️ {dt_ms:.1f} ms."
572 def _create_board(self) -> None:
576 fig, axs = plt.subplots(
587 self.
ims.append(ax.imshow(self.
m_png[0][
"plain"], animated=
True))
589 ax.set_xticklabels([])
590 ax.set_yticklabels([])
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)
595 fig.set_facecolor(
"darkgray")
596 fig.canvas.toolbar_visible =
False
597 fig.canvas.resizable =
False
598 fig.canvas.toolbar_visible =
False
599 fig.canvas.header_visible =
False
600 fig.canvas.footer_visible =
False
601 fig.canvas.capture_scroll =
True
602 plt.show(block=
False)
607 notify_output: widgets.Output = widgets.Output()
608 display(notify_output)
610 @notify_output.capture()
611 def _popup(self, text: str) ->
None:
613 display(Javascript(f
"alert('{text}')"))
615 def _board_from_history(self) -> Board:
618 def _insert_token(self, col: int, reset_redo_list: bool =
True) ->
None:
624 button.disabled =
True
627 if self.
m_gameover or not board.play(int(col)):
649 except Exception
as e:
650 self.
m_logger.error(
"Error: %s", str(e))
660 def _redo_move(self) -> None:
666 def _undo_move(self) -> None:
683 self.
ims[img_idx].set_data(self.
m_png[0][
"plain"])
684 self.
m_axs[img_idx].draw_artist(self.
ims[img_idx])
690 self.
m_fig.canvas.blit(self.
ims[img_idx].get_clip_box())
691 self.
m_fig.canvas.flush_events()
698 except Exception
as e:
699 self.
m_logger.error(
"Error: %s", str(e))
708 def _update_insert_buttons(self) -> None:
733 if hasattr(self,
"dd_eval_agent"):
735 if hasattr(self,
"cb_autoplay"):
738 active_player =
"🔴" if player == 2
else "🟡"
744 """Translates a column and row ID into the corresponding image ID.
747 col (int): column (0-6) of the considered board cell.
748 row (int): row (0-5) of the considered board cell.
751 int: The corresponding image id (0-41).
753 self.
m_logger.debug(
"Got column: %d", col)
757 def _paint_token(self) -> None:
763 self.
m_logger.debug(
"Paint token: %d", img_idx)
768 self.
ims[img_idx].set_data(self.
m_png[p][
"corner"])
773 self.
m_axs[img_idx].draw_artist(self.
ims[img_idx])
774 blit_boxes.append(self.
ims[img_idx].get_clip_box())
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())
786 self.
m_fig.canvas.blit(blit_boxes[0])
791 self.
m_fig.canvas.flush_events()
793 def _create_buttons(self) -> None:
800 for col
in range(self.
m_n_col):
803 layout=Layout(width=f
"{-4 + (fig_size_px[0] / self.m_n_col)}px", height=
"50px"),
809 """Creates a row with the column labels 'a' to 'g'.
812 HBox: A row of textboxes containing the columns labels 'a' to 'g'.
815 width = f
"{-3 + (fig_size_px[0] / self.m_n_col)}px"
818 value=chr(ord(
"a") + i),
819 layout=Layout(justify_content=
"center", align_items=
"center", width=width),
827 flex_flow=
"row wrap",
828 justify_content=
"center",
829 align_items=
"center",
834 """Based on the column where the click was detected, insert a token.
837 event (mpl_backend_bases.Event): A matplotlib mouse event.
842 if not isinstance(event, mpl_backend_bases.MouseEvent):
844 if event.inaxes
is None or event.xdata
is None:
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
856 Generally, you should this method to retreive and display the widget.
859 >>> %matplotlib ipympl
861 >>> display(c4gui.get_widget())
865 AppLayout: the widget.
868 insert_button_row = HBox(
872 flex_flow=
"row wrap",
873 justify_content=
"center",
874 align_items=
"center",
877 control_buttons_col = HBox(
881 flex_flow=
"row wrap",
882 justify_content=
"flex-end",
883 align_items=
"center",
895 justify_content=
"flex-start",
896 align_items=
"stretch",
916 align_items=
"flex-start",
924 align_items=
"flex-start",
925 justify_content=
"flex-start",
933 left_sidebar=control_buttons_col,
937 layout=Layout(grid_gap=
"0px"),
941 """If it's an agent-controlled turn, immediately play its move."""
949 """Check for Win or draw."""
951 winner =
"Yellow (🟡)" if board.winner() == 1
else "Red (🔴)"
952 msg = f
"🏆 Game over! {winner} wins!"
956 elif board.is_full():
957 msg =
"🤝 Game over! It's a draw!"
963 """Destroy and release the acquired resources."""
964 plt.close(self.
m_fig)