PyScript(ブラウザ)でPygame-ce(Pygame)を動かす

## 概要

2025年になって、[PyodideがPygame-ce(Pygameのフォーク)を公式サポート](https://blog.pyodide.org/posts/0.26-release/#pygame-ce-support)したことにより、[PyScriptでもPygame-ceを使ったゲームプログラミングができるモードが追加された](https://docs.pyscript.net/2025.3.1/user-guide/pygame-ce/)ので、早速試してみたところさすがに今までのPygame-ce(Pygame)のコードがそのまま動くというわけではなかったのでどうすればいいのかまとめてみた。

※2025年5月現在はまだ実験的段階ですので、将来この記事は意味がなくなってるかもしれません。

## なぜそのまま動かないのか

CPythonのPygame-ce(Pygame)はライブラリ自身がウインドウ(SDL)を管理していたので単純なwhileループでも制御をウインドウ(SDL)に渡せましたが、PyScript(Pyodide)ではライブラリが管理するウインドウではなくブラウザに制御を渡すためのコードを別途書く必要があるためCPythonのPygame-ce(Pygame)のコードそのままではPyScript(Pyodide)では動かないようです。

JavaScript(ブラウザ)で同期関数のwhileループ内からブラウザに制御を渡す方法があればどうにかなったのかもしれませんが、残念ながらそのような方法は存在しないようなので対応のしようがなかったのだろうと思います。

## とりあえずテストコード(これはPyScriptでは動きません)

まずはCPythonのPygame-ce(Pygame)で動くコードを作りました、本題とは関係ないコードを長々と書いても仕方ないので1フレームに1ピクセルずつ赤い円が右に動いてある程度進んだら初期位置に戻ってまた右に動くだけという単純なスクリプトにしました。

```py
import sys
import pygame


def main():
    pygame.init()
    screen = pygame.display.set_mode((500, 500))
    clock = pygame.time.Clock()
    fps = 60
    counter = 0
    running = True

    while running:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False

        screen.fill((0, 0, 0))
        pygame.draw.circle(screen, (255, 0, 0), (100 + counter, 250), 50)
        counter += 1
        counter %= 300
        pygame.display.flip()
        clock.tick(fps)

    pygame.quit()
    sys.exit()


if __name__ == '__main__':
    main()
```

これはCPythonでは動きますがPyScript(Pyodide)では動かないコードになります、動かしてもブラウザに制御が渡らないため画面の描画はされず、イベントも受け取れない無限ループとなってしまいページが固まってします。

次からはこれをPyScriptで動くように変更していきます。また、さらにCPythonとPyScriptで同じコードが動くようにもしていきます。

## 変更1: 非同期関数の中でループをまわす(Pyodide推奨)

修正する必要がある旨の説明と方法は[Pyodideのドキュメントに載っています](https://pyodide.org/en/stable/usage/sdl.html)ので、まずはこの方法にしたがって修正します。

記事公開当初はHTMLにスクリプトを埋め込む形のコードを出していたのですが、コードハイライトが酷かったので思い直してHTML外部Pytonコードを呼び出す形にして本題のコードは外部Pythonコードとして掲載する形にしました。

ひとまず、以下のHTMLファイルをindex.htmlとして保存してください。

```index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="stylesheet" href="https://pyscript.net/releases/2025.3.1/core.css" />
    <script type="module" src="https://pyscript.net/releases/2025.3.1/core.js"></script>
</head>
<body>
    <canvas id="canvas"></canvas>
    <script type="py-game" src="main.py"></script>
</body>
</html>
```

PyScriptで動くように修正したコードは以下になります。これをmain.pyとして保存してください。

```main.py
import sys
import pygame
import asyncio  # asyncioモジュールを利用


async def main():  # 非同期関数でループを実行
    pygame.init()
    screen = pygame.display.set_mode((500, 500))
    # clock = pygame.time.Clock()
    fps = 60
    counter = 0
    running = True

    while running:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False

        screen.fill((0, 0, 0))
        pygame.draw.circle(screen, (255, 0, 0), (100 + counter, 250), 50)
        counter += 1
        counter %= 300
        pygame.display.flip()
        await asyncio.sleep(1 / fps)  # asyncio.sleepでブラウザに制御を戻す

    pygame.quit()
    # sys.exit()  # PyScriptではsys.exit()は不要


if __name__ == '__main__':
    await main()
```

先ほどのスクリプトと異なる箇所にコメントを付けました。ループがある`main`関数を`async`で非同期関数に変更し、`clock.tick(fps)`の代わりに`await asyncio.sleep(1 / fps)`とすることで制御をブラウザに戻せるよう修正しました。

CPythonではトップレベルにawaitを書くとエラーになりますが、現在のPyScriptはTop-level await(PyScriptの独自機能?)が[デフォルトで有効](https://docs.pyscript.net/2025.3.1/user-guide/first-steps/)になっているのでトップレベルにawaitを書いても動作します。

PyScriptはブラウザの制約でWebサーバーを通じてページを読み込まないと動作しません。Python環境がある場合はコマンドラインからHTMLがあるディレクトリで

```bat
python -m http.server 8080
```

を実行すると簡易なローカル用Webサーバーを立てることができます。この場合、http://localhost:8080 でアクセスすることができます。


## 変更2: 同期関数のままブラウザに制御を戻す

Python環境では個人的には思い当たらないのですが(JavaScript環境内ではあった)、どうしても非同期関数/asyncioを使いたくないという場面があるかもしれません。

そこで、前述のasyncioを使わない形でブラウザに制御を戻す方法を考えました。

繰り返しになりますが、同期関数内でwhileループを回しつつブラウザに制御を渡す方法は無いようですので構造自体を変更する必要があります。

```main.py
import sys
import pygame
# JavaScriptの関数を呼び出すために必要
import js
# Python関数をJavaScriptから呼び出すために必要
from pyodide.ffi import create_proxy

# 初期化関係はmain関数の外で行う(別関数にしてもいい)
pygame.init()
screen = pygame.display.set_mode((500, 500))
# clock = pygame.time.Clock()
fps = 60
counter = 0

# mainは同期関数
# requestAnimationFrameを使う場合はtimestampを引数に取らないと実行時エラーになる
def main(timestamp=0):
    global counter

    running = True
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

    screen.fill((0, 0, 0))
    pygame.draw.circle(screen, (255, 0, 0), (100 + counter, 250), 50)
    counter += 1
    counter %= 300
    pygame.display.flip()

    if running:
        # JavaScriptのsetTimeoutを使ってmain関数を呼び出しブラウザに制御を戻しつつループ
        js.setTimeout(proxy_main, 1000 / fps)

        # requestAnimationFrameを使う場合はこっち
        # js.requestAnimationFrame(proxy_main)
    else:
        proxy_main.destroy()  # Pyodideでは使い終わったproxyは明示的に破棄する必要がある
        pygame.quit()
        # sys.exit()


# JavaScriptのコールバックとしてPython関数を渡すためにはcreate_proxyを使ってproxyを作成する必要がある
proxy_main = create_proxy(main)

if __name__ == '__main__':
    main()
```

whileでループを回す代わりにJavaScriptの`setTimeout`や`requestAnimationFrame`で再帰的に`main`関数を呼び出すことで同期関数でブラウザに制御を渡しつつループしています。

同期関数のままPyScript対応に変更する場合、構造から変更する形となりどうしても変更点が多くなってしまいます。特に理由がなければPyodide推奨の非同期関数でループを回す方法をお勧めします。

## コードを共通化(非同期版)

すでにCPython向けに作ったコードをそのままPyScriptにというわけにはいきませんが、同じコードでCPythonとPyScript両方で動くようになれば便利だと思いますので作ってみました。

```main.py
import sys
import pygame
import asyncio  # asyncioモジュールを使う


async def main():  # main関数は非同期関数にする
    pygame.init()
    screen = pygame.display.set_mode((500, 500))
    clock = pygame.time.Clock()
    fps = 60
    counter = 0
    running = True

    while running:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False

        screen.fill((0, 0, 0))
        pygame.draw.circle(screen, (255, 0, 0), (100 + counter, 250), 50)
        counter += 1
        counter %= 300
        pygame.display.flip()
        clock.tick(fps)  # tick関数はPyScriptでも有効
        await asyncio.sleep(0)  # 0秒のasyncio.sleepでブラウザに制御を戻す(CPythonでは特に意味のないコード)

    pygame.quit()
    sys.exit()


if __name__ == '__main__':
    # CPythonとPyScriptでは非同期関数の呼び方が異なる
    if sys.platform == 'emscripten':
        # PyScriptの場合
        asyncio.create_task(main())  # awaitを使うとCPythonでエラーとなるのでcreate_taskで呼び出す
    else:
        # CPythonの場合
        asyncio.run(main())
```

CPython版からの変更部分をコメントに残しています。CPythonでは`asyncio.run`で非同期関数を呼ぶことができますが、PyScriptではすでに`asyncio.run`が走っていてその上で動いている形になっているようなので`asyncio.run`を使うとエラーになります。また`await`を使おうとすると`if`で分岐していてもCPythonでエラーとなるので同じコードでCPythonとPyScriptどちらでも非同期関数を呼べるようにしたい場合はPyScript側では`asyncio.create_task`を使って非同期関数を呼び出す必要があるようです。

この記事ではやりませんが、個人的にはCPython/PyScript両対応のためのコードをクラスにラップすると後々手間が省けて便利になるんじゃないかなと思います。

## コードを共通化(同期版)

非同期関数/asyncioを使わない同期版でもCPytonとPyScript両方同じコードで動くようにしてみました。個人的には非同期版を推奨しますが、参考程度にどうぞ。

```main.py
import sys
import pygame

# 初期化関係のコードは関数外に移動
pygame.init()
screen = pygame.display.set_mode((500, 500))
clock = pygame.time.Clock()
fps = 60
counter = 0

is_pyscript = sys.platform == 'emscripten'  # PyScriptかどうかの判定


def main():
    global counter

    running = True

    while running:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False

        screen.fill((0, 0, 0))
        pygame.draw.circle(screen, (255, 0, 0), (100 + counter, 250), 50)
        counter += 1
        counter %= 300
        pygame.display.flip()
        clock.tick(fps)

        # PyScriptの場合、js.setTimeoutを使ってmainを再帰的に呼び出し、returnでループを抜ける
        if is_pyscript:
            if running:
                import js
                js.setTimeout(main_proxy, 0)
            else:
                main_proxy.destroy()
                pygame.quit()
            return

    pygame.quit()
    sys.exit()


if __name__ == '__main__':
    # main関数のproxyを作成
    if is_pyscript:
        from pyodide.ffi import create_proxy
        main_proxy = create_proxy(main)

    main()
```

## ローディング画面を作る

PyScript(Pyodide)は実行開始までに結構なタイムラグがあります、また初回ロードの場合はモジュールのダウンロードとインストールも実行されるためかなりの時間がかかります。ロード画面の実装は必須だと思います。

PyScript上でロード画面を作っても意味がないので、JavaScriptやCSSでロード画面を構築し、PyScriptでロード画面用の要素を非表示にするのが無難だと思います。

ここでは個人的な理由でionic frameworkを使ってローディング画面を作ってみます。

```index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@ionic/core/css/ionic.bundle.css" />
    <link rel="stylesheet" href="https://pyscript.net/releases/2025.3.1/core.css" />
    <style>
        body {
            background: white;
        }

        #loading {
            color: white;
            filter: invert(100%) grayscale(100%) contrast(100);
            height: 100vh;
            display: flex;
            align-items: center;
            justify-content: center;
        }
    </style>
    <script type="module" src="https://pyscript.net/releases/2025.3.1/core.js"></script>
    <script type="module" src="https://cdn.jsdelivr.net/npm/@ionic/core/dist/ionic/ionic.esm.js"></script>
    <script type="module" src="https://cdn.jsdelivr.net/npm/ionicons@latest/dist/ionicons/ionicons.esm.js"></script>
</head>
<body>
    <div id="loading" class="ion-text-center ion-justify-content-center ion-align-items-center ion-padding">
        <ion-spinner name="crescent"></ion-spinner><span>Loading PyScript...</span>
    </div>
    <canvas id="canvas"></canvas>
    <script type="py-game" src="main.py"></script>
</body>
</html>
```

```py
# ロード画面を消去
if sys.platform == 'emscripten':
    import js
    js.document.getElementById('loading').style.display = 'none'
```