【Pythonだけ】Reflexで手軽に作るゲームアプリ

## はじめに
PythonのみでWebアプリを作れる系のフレームワーク、[Reflex](https://reflex.dev/)。
今回は画面をタップしてスコアを競う簡単なゲームを作ってみました。（音が鳴りますのでご注意ください。）

※スマートフォン推奨

https://reflex-tap.reflex.run/

<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/649608/db7998f3-822e-18d2-fdcb-86725243409b.gif width=50%>


例によってPWA化しているので、ホーム画面に追加して暇つぶしにどうぞ。

https://qiita.com/papasim824/items/1e3bfd3984b7af155ade

※利用している画像素材はすべてChatGPT(Dall-E 3)を利用して生成しています。


## コード解説

※コード全体はリポジトリを参照ください。

https://github.com/ppspps824/reflex_tap

### ページの切り替え
```rx.cond```を利用して状態に応じてレンダリングする画面を切り替えています。

https://reflex.dev/docs/library/dynamic-rendering/cond/

> このコンポーネントは、コンポーネントを条件付きでレンダリングするために使用されます。
> condコンポーネントは、条件と2つのコンポーネントを受け取る。条件が True の場合は最初のコンポーネントがレンダリングされ、そうでない場合は 2 番目のコンポーネントがレンダリングされます。

```GameState```で状態を管理しており、```GameState.game_active(ゲームが開始しているかどうか)``` と ```GameState.time_remaining(カウントダウン)```を見て判断しています。

ちょっと分かりづらいですが、

```python
@rx.page(route="/", title="Tap!Tap!Tap!", meta=meta)
def index() -> rx.box:
    """ゲームのインデックスページを生成します。

    Returns:
        rx.box: ゲームのUIを構成するボックス。
    """
    return rx.box(
        rx.cond(
            # ゲームスタート前、かつ　カウントダウンタイマーが0じゃない
            ~GameState.game_active & GameState.time_remaining,
            # タイトル画面,
        rx.cond(
            # ゲームスタート後
            GameState.game_active,
            # ゲーム画面
        ),
        rx.cond(
            # カウントダウンタイマーが0
            GameState.time_remaining == 0,
            # ゲームオーバー画面
        )

```
ボタンを押すことで```on_click=GameState.start_game```が発火して
```python
                rx.button(
                    rx.chakra.image(
                        src="start.png",
                        width="90%",
                        loading="lazy",
                        border_radius="50px",
                        border="5px solid black",
                    ),
                    on_click=GameState.start_game,
                    variant="ghost",
                    position="absolute",
                    bottom="15vh",  # 下からの位置を指定
                    left="50%",  # 左右中央に配置
                    transform="translateX(-50%)",  # 中央揃えの調整
                )
                
```
ゲームが開始します。（```self.game_active = True```）
```python
    def start_game(self) -> list:
        """ゲームを開始し、初期スコアと時間を設定します。

        Returns:
            list: ゲームの進行を管理するための関数リスト。
        """
        self.score = 0
        self.time_remaining = GAME_DURATION
        self.game_active = True
        self.generate_positions()
        return [GameState.tick, rx.call_script("playBGM()")]
```

### ゲーム画面のターゲットボタン表示・非表示

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/649608/5371e959-feab-9107-2f8f-b0b267029421.png)

ボタンを表示する処理の実体は```rx.cond```で、後述する```rx.foreach```から受け取ったパラメータ（ボタンのIDと表示・非表示の真偽値）を使って制御しています。
＝（ボタンID,真）だったら表示、（ボタンID,偽）だったら非表示

ボタンをクリックすると```on_click=GameState.hide_button(info[0])```が発火し、ボタンIDに対する真偽値を偽に更新します。

```python
def button(info: tuple[str, bool]) -> rx.cond:
    """ボタンを生成します。

    Args:
        info (tuple[str, bool]): ボタンの情報（IDと可視性）。

    Returns:
        rx.cond: ボタンの表示制御。
    """
    return rx.cond(
        info[1],
        rx.button(
            rx.chakra.image(
                src="/button.png",
                loading="lazy",
                border_radius="full",
                width=BUTTON_WIDTH,
            ),
            on_click=GameState.hide_button(info[0]),
            position="absolute",
            top=rx.cond(
                GameState.button_positions.contains(info[0]),
                GameState.button_positions[info[0]]["top"],
                "0vh",
            ),
            left=rx.cond(
                GameState.button_positions.contains(info[0]),
                GameState.button_positions[info[0]]["left"],
                "0vw",
            ),
            variant="ghost",
        ),
        rx.text(""),
    )

```

先ほどのボタンを指定した数だけ画面上に展開するために```rx.foreach```を使います。

https://reflex.dev/docs/library/dynamic-rendering/foreach/

> rx.foreachコンポーネントは、iterable（リスト、タプル、またはdict）と、リスト内の各項目をレンダリングする関数を取ります。
> これは、ステートで定義されたアイテムのリストを動的にレンダリングするのに便利です。

```python
# ボタンの数を指定しておいて
class GameState(rx.State):
    button_visibility: dict[str, bool] = {
        f"button{num}": True for num in range(BUTTON_NUM)
    }
```

```python
# ここでリスト分を繰り返してbuttonを呼び出す（＝rx.condで判定された結果が表示される）
        rx.cond(
            GameState.game_active,
            rx.box(
                rx.foreach(
                    GameState.button_visibility,
                    button,
                ),
                special_button(),
                penalty_button(),
                bg="lightblue",
                width="100vw",
                height="100vh",
            ),
```

ボタンが押されると、↓が動いてボタンIDに対する真偽値を偽にする。
```python 
    def hide_button(self, button_id: str) -> None:
        """指定されたボタンを消し、スコアを更新します。

        Args:
            button_id (str): 消すボタンのID。
        """
        if not self.game_active:
            return None
        self.button_visibility[button_id] = False
        self.score += 1
        if all(not visibility for visibility in self.button_visibility.values()):
            self.generate_positions()

        return rx.call_script("playFromStart('button_sfx')")
```

### タイマー処理
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/649608/96d3e839-684b-747a-6741-6d4c87e4505b.png)

ゲームといえばタイマーかなと思いますが、こちらの例では```rx.background```を利用して、ゲームがアクティブの間は1秒ごとにカウントダウンを行います。
0秒になるとゲームを非アクティブにし、BGMを終了します。

https://reflex.dev/docs/events/background-events/

> バックグラウンドタスクは、他のEventHandler関数と同時に実行できる特別なタイプのEventHandlerです。
> これにより、UIのインタラクティブ性をブロックすることなく、長時間タスクを実行することができます。
> バックグラウンド・タスクがステートを操作する必要があるときは、必ずasync with selfコンテキスト・ブロックに入り、ステートをリフレッシュし、他のタスクやイベント・ハンドラが同時にステートを変更できないように排他ロックをかけます。



``` python
class GameState(rx.State):
    ~~
    time_remaining: int = GAME_DURATION
    game_active: bool = False

    ~~
    
    @rx.background
    async def tick(self) -> None:
        """ゲームの残り時間をカウントダウンします。"""
        while self.game_active:
            await asyncio.sleep(1)
            async with self:
                self.time_remaining -= 1
            if self.time_remaining == 0:
                async with self:
                    self.game_active = False
                return rx.call_script("stopBGM()")
        return None
```


### BGM及びSEの再生
BGMとSEを動的に操作するいい方法が見つからず、JavaScriptを利用する形になりました。（いい方法があったら教えてください。。）

https://reflex.dev/docs/library/other/script/

> Scriptコンポーネントは、インラインjavascriptやURLによるjavascriptファイルをインクルードするために使用できます。
>スクリプトの注入にはnext/scriptコンポーネントを使用し、スクリプトの副作用を状態によって制御できるように、条件付きレンダリングで安全に使用できます。

なお、```rx.script```で定義したJavaScriptの関数は```rx.call_script()```でPython側から呼び出すことができます（！）~~黒魔術感~~

https://reflex.dev/docs/api-reference/browser-javascript/

```python
        rx.script(
            """
            var button_sfx = new Audio("/button_se.mp3")
            var button_sfx2 = new Audio("/button_se2.mp3")
            var penalty_sfx = new Audio("/penalty_se.mp3")
            var bgm = new Audio("/bgm.mp3")
            function playFromStart(sfx) {
                if (sfx === 'button_sfx') {
                    button_sfx.currentTime = 0;
                    button_sfx.play();
                } else if (sfx === 'button_sfx2') {
                    button_sfx2.currentTime = 0;
                    button_sfx2.play();
                } else if (sfx === 'penalty_sfx') {
                    penalty_sfx.currentTime = 0;
                    penalty_sfx.play();
                }
            }
            function playBGM() {
                bgm.currentTime = 0;
                bgm.play();
            }
            function stopBGM() {
                bgm.pause();
                bgm.currentTime = 0;
            }
        """
        ),
```

※```playBGM()```を呼び出す
```python
    def start_game(self) -> list:
        """ゲームを開始し、初期スコアと時間を設定します。

        Returns:
            list: ゲームの進行を管理するための関数リスト。
        """
        self.score = 0
        self.time_remaining = GAME_DURATION
        self.game_active = True
        self.generate_positions()
        return [GameState.tick, rx.call_script("playBGM()")]
```

### OGP設定（meta属性の追加）
ゲームと全然関係ないですが、Xなどでリンクを張るとカード表示されるやつです。
```python
meta = [
    {"name": "twitter:card", "content": "summary"},
    {"property": "og:description", "content": "Tapして遊ぶ簡単ゲーム。目指せ高得点！"},
    {"property": "og:title", "content": "Tap!Tap!Tap!"},
    {"property": "og:image", "content": "https://ul.h3z.jp/V4SJUWtE.png"},
    {"property": "og:type", "content": "website"},
    {"property": "og:url", "content": "https://reflex-tap.reflex.run/"},
]

@rx.page(route="/", title="Tap!Tap!Tap!", meta=meta)
def index() -> rx.box:
~~
```

## おわりに

なんといっても公式サイトのAIチャットが非常に優秀で、コンポーネントの使い方などを詳細に教えてくれるのでサクサク開発が進みます！

画面右上の↓を押すと
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/649608/97ddbf39-0e0b-b4d0-bdec-b63abdd11e1c.png)

チャット画面が出てきます。（日本語で聞くと日本語で返してくれます。）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/649608/7ab72245-e92a-b90e-5ef9-ef05742db18a.png)