0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

MonsterUIでリバーシ(オセロ)を作る

0
Posted at

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のコンポーネントは、CellBoardUIScoreBoardGameContainer関数にまとめています
    • 全体がGameContainerで、ScoreBoardBoardUI、リセットボタンで構成されています
    • 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_posthx_targetを使うことで、マスのクリックやリセット時に、盤面全体を再描画せずに状態を更新できました。

MonsterUIを使った最小構成のサンプルとして参考になればと思います。

参考

MonsterUIに似たWebフレームワークとしてNiceGUIがあります。次はそのリバーシの実装例です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?