MonsterUIを使って、リバーシ(オセロ)を実装してみました。
MonsterUIは、FastHTML上で動作するUIライブラリで、Pythonのコードだけでボタンやグリッドなどの画面要素を組み立てられます。一方で、日本語の実装例が少ないため、どのように書いたらよいか迷うことが多いと思います。
そこで本記事では、シンプルなゲームであるリバーシを題材に、以下を具体的なコード付きで紹介します。
- MonsterUIを使ったゲーム盤面の表現方法
- Python側でゲーム状態を管理し、UIを更新する方法
- HTMXを使った最小構成のクリック処理
「MonsterUIで簡単なゲームを作る方法」を知りたい方の参考になれば幸いです。
前提
本記事では、Pythonの基本的な文法とクラス定義を理解していることを前提とします。
また、FastHTML/HTMXについては詳細な解説は行いません。「クリック時にサーバーへリクエストを送り、返ってきたHTMLで一部を差し替える仕組み」を把握している程度を想定しています。
作り方の方針
今回は MonsterUI の仕様を LLM に与えてコードを生成し、それを人手で調整する形で実装しました。通常の LLM は MonsterUI の知識が十分ではないため、NotebookLM に公式ドキュメントを読み込ませています。
NotebookLM
MonsterUI の仕様として、公式ドキュメントにある llms-ctx.txt を利用しました。
実装コード
以下が完成したコードです。reversi.py として保存してください。
# /// script
# dependencies = ["monsterui"]
# ///
from fasthtml.common import *
from monsterui.all import *
class ReversiGame:
"""Reversiゲームのロジックを管理するクラス"""
DIRECTIONS = (
(-1, -1), (-1, 0), (-1, 1),
(0, -1), (0, 1),
(1, -1), (1, 0), (1, 1),
)
def __init__(self) -> None:
self.reset()
def reset(self) -> None:
# 0: Empty, 1: Black, 2: White
self.board = [[0] * 8 for _ in range(8)]
self.board[3][4] = self.board[4][3] = 1
self.board[3][3] = self.board[4][4] = 2
self.turn = 1 # 黒番開始
self.winner = None
self.no_move_pass = False
def is_valid_move(self, r: int, c: int, player: int) -> bool:
if self.board[r][c] != 0:
return False
for dr, dc in self.DIRECTIONS:
nr, nc = r + dr, c + dc
flipped = False
while 0 <= nr < 8 and 0 <= nc < 8 and self.board[nr][nc] == 3 - player:
nr += dr
nc += dc
flipped = True
if flipped and 0 <= nr < 8 and 0 <= nc < 8 and self.board[nr][nc] == player:
return True
return False
def get_valid_moves(self) -> list[tuple[int, int]]:
return [
(r, c)
for r in range(8)
for c in range(8)
if self.is_valid_move(r, c, self.turn)
]
def make_move(self, r: int, c: int) -> None:
if not self.is_valid_move(r, c, self.turn):
return
self.board[r][c] = self.turn
for dr, dc in self.DIRECTIONS:
nr, nc = r + dr, c + dc
cells_to_flip = []
while 0 <= nr < 8 and 0 <= nc < 8 and self.board[nr][nc] == 3 - self.turn:
cells_to_flip.append((nr, nc))
nr += dr
nc += dc
if cells_to_flip and 0 <= nr < 8 and 0 <= nc < 8 and self.board[nr][nc] == self.turn:
for fr, fc in cells_to_flip:
self.board[fr][fc] = self.turn
self.no_move_pass = False
self.turn = 3 - self.turn
# 相手が置けない場合はパス
if not self.get_valid_moves():
self.no_move_pass = True
self.turn = 3 - self.turn
# 両者とも置けない場合はゲーム終了
if not self.get_valid_moves():
self.winner = self.get_score()
def get_score(self) -> dict[str, int]:
b_count = sum(row.count(1) for row in self.board)
w_count = sum(row.count(2) for row in self.board)
return {"Black": b_count, "White": w_count}
def Cell(r: int, c: int, value: int, *, is_valid: bool) -> FT:
"""ボードの1マスを描画"""
base_cls = "w-14 h-14 flex items-center justify-center border border-green-800 bg-green-600"
# 既に石がある場合
if value != 0:
color = "bg-slate-800 " if value == 1 else "bg-slate-100 "
return Div(Div(cls=f"w-10 h-10 rounded-full {color} shadow-[2px_2px_4px_rgba(0,0,0,0.6)]"), cls=base_cls)
# 空で置けない場所
if not is_valid:
return Div(cls=base_cls)
# 石が置ける場所の場合
return Button(
Div(cls="w-3 h-3 rounded-full bg-green-900 opacity-50"), # ガイドマーカー
cls=f"{base_cls} hover:bg-green-500 cursor-pointer p-0",
hx_post=f"/move/{r}/{c}",
hx_target="#game-container",
)
def BoardUI() -> FT:
"""8x8グリッドの構築"""
valid_moves = game.get_valid_moves() if not game.winner else []
cells = []
for r in range(8):
for c in range(8):
is_valid = (r, c) in valid_moves
cells.append(Cell(r, c, game.board[r][c], is_valid=is_valid))
return Grid(*cells, cls="gap-0.5 bg-green-900 border-8 border-green-900 rounded-lg", cols=8)
def ScoreBoard() -> FT:
"""スコアとステータスの表示"""
score = game.get_score()
status_text = "Game Over" if game.winner else ("Black's Turn" if game.turn == 1 else "White's Turn")
toast = None
if game.no_move_pass and not game.winner:
toast = Toast(
DivLAligned(UkIcon("info", cls="mr-2"), "置ける場所がないためパスしました"),
id="pass-toast",
alert_cls=AlertT.warning,
cls=(ToastHT.end, ToastVT.top),
)
return Div(
Card(
DivFullySpaced(
DivCentered(H3(str(score["Black"])), P("Black", cls=TextPresets.muted_sm)),
DivCentered(H2(status_text, cls="text-center")),
DivCentered(H3(str(score["White"])), P("White", cls=TextPresets.muted_sm)),
),
cls="mb-4",
),
toast,
)
def GameContainer() -> FT:
"""ゲーム全体のコンテナ"""
return Div(
ScoreBoard(),
BoardUI(),
DivCentered(
Button(
"Reset Game",
cls=(ButtonT.destructive, "rounded-2xl"),
hx_post="/reset",
hx_target="#game-container",
),
cls="mt-6",
),
id="game-container",
)
# 簡便さのためゲーム状態は1つだけ管理
game = ReversiGame()
app, rt = fast_app(hdrs=Theme.slate.headers())
@rt("/")
def index() -> FT:
return DivCentered(GameContainer())
@rt("/move/{r}/{c}")
def move(r: int, c: int) -> FT:
game.make_move(r, c)
return GameContainer()
@rt("/reset")
def reset() -> FT:
game.reset()
return GameContainer()
serve()
PEP723のメタデータをつけているので下記のように実行できます。
uv run reversi.py
http://localhost:5001/を開くと次のように遊べます。
内容説明
プログラムの特徴を簡単に説明します。
- ロジックは
ReversiGameクラスにまとめています - UIのコンポーネントは、
Cell、BoardUI、ScoreBoard、GameContainer関数にまとめています- 全体が
GameContainerで、ScoreBoard、BoardUI、リセットボタンで構成されています -
ScoreBoardはコマの数と手番を表示します -
BoardUIは盤面で、各マスはCellです
- 全体が
アプリのURL(エンドポイント)は、次の3つの関数で定義しています。
-
/(index関数): メインページ -
/move/{r}/{c}(move関数): マスのクリック時 -
/reset(reset関数): リセット時
マスのクリックではhx_postを使って/move/{r}/{c}にPOSTし、そのレスポンスで返されたHTMLをhx_target="#game-container"に差し替えることで、盤面とスコアを更新しています。
まとめ
MonsterUIとFastHTMLを使って、リバーシ(オセロ)を実装しました。
ゲームのルールや状態管理は ReversiGame クラスに集約し、UIは関数ベースのコンポーネント(Cell / BoardUI / ScoreBoard)として構成しています。これにより、PythonだけでロジックとUIを分離したWebアプリを記述できます。
また、HTMXのhx_postとhx_targetを使うことで、マスのクリックやリセット時に、盤面全体を再描画せずに状態を更新できました。
MonsterUIを使った最小構成のサンプルとして参考になればと思います。
参考
MonsterUIに似たWebフレームワークとしてNiceGUIがあります。次はそのリバーシの実装例です。