「Pyxel × Pymunkで物理シミュレーションを始めよう！」を Webで動かしたい🌎

:::note
2025/2/15追記
micropipを使用しない方法を調査されている方がいらっしゃったため追記
（改めて冷静に考えるとそりゃそーだ。と言う感じですが。。。）
・[gomatama5/Pyxel_Web_Test](https://github.com/gomatama5/Pyxel_Web_Test)
以下の章は、上記のリポジトリの方法に置き換えると良いとおもいます
・Web実行に向けて「3_2_shot_bullet.py」をちょっと改造
・Web用の「web_test.py」と「index.html」を用意

→ GitHubのコードもmicropipを使用しない方法に修正しました
:::

# はじめに
[malo21st](https://qiita.com/malo21st) さんの「[Pyxel × Pymunkで物理シミュレーションを始めよう！](https://qiita.com/malo21st/items/32b7865e7c78d4ac2741) (Pyxel Advent Calendar 2024 の 15日目の投稿)」を「Webで動かしたい」という動機の投稿です🌎

この投稿は、[malo21st](https://qiita.com/malo21st) 様の投稿の上に成り立っております。
素晴らしい投稿ありがとうございます👾👾👾

# Web実行版
若干の紆余曲折はありましたが、Web上で動かすことが出来ました。
以下はお試し用のURLと実行例の画像です。

Web実行版URL：https://kazuhito00.github.io/pyxel-pymunk-web-test/

![mqgaj-qat4l.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/140207/d3bc7a07-f699-c3be-9ace-2b0c2bb2a91e.gif)

今回試したソースコードは以下リポジトリで公開しています。

https://github.com/Kazuhito00/pyxel-pymunk-web-test


以降は紆余曲折部分の解説です🦔

# Pyxel Web版で使用可能なパッケージ
Pyxelユーザーの方には、おなじみだと思いますが、PyxelはWeb実行をサポートしています。
ただし、Web実行はPyodideの仕組みを利用しており、使用できるパッケージに制限があります。
具体的には、Pyodide の [Packages built in Pyodide](https://pyodide.org/en/stable/usage/packages-in-pyodide.html) のページに記載のパッケージが使用できます（＋純粋にPythonのみで書かれたパッケージ）

[別件](https://qiita.com/Kazuhito/items/897e45c46f03e6980067)で、このページを眺めた時に Pymunk は無かった気がしましたが、、、
改めて確認しても、やはり Pymunk を探してもリストにはありませんでした。
（b2dという少々マイナー？な物理エンジンはサポートされていましたが）

# ダメ元でPymunk公式を確認
Pyodide公式にサポートされていないのであれば、望み薄であると思いながら、
ダメ元でPymunk公式に何か情報は無いか探しにいったところ、、、
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/140207/872690bc-1f19-c672-ff00-f6a44ebc2352.png)
Wasm wheelがリリースされていました👀
※PyodideはCPythonをWebAssembly/Emscriptenに移植したもので、Wasm wheelがあればパッケージを動かせる可能性あり

リリースを見に行くと
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/140207/6a772a01-4b79-c975-bfe5-5a69e8a9b2c5.png)
明らかに、Pyodide向けっぽい命名がされていました。

# Pymunk Wasm wheelを動作確認
Pyodide向けっぽい命名だったので、期待しつつ、
とりあえず Pymunk単体で動作確認をしてみようと、以下のスクリプトを用意して確認しました。
```python:main.py
try:
    import micropip

    print("micropip successfully imported")
except ImportError as e:
    print(f"Error importing micropip: {e}")

try:
    await micropip.install("pymunk-6.9.0-cp312-cp312-pyodide_2024_0_wasm32.whl")
    print("Pymunk installed successfully")
except Exception as e:
    print(f"Error installing Pymunk: {e}")

try:
    import pymunk

    print("Pymunk successfully imported")
except ImportError as e:
    print(f"Error importing Pymunk: {e}")
```
```index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <title>Test</title>
    <meta charset="UTF-8">
    <link rel="stylesheet" href="https://pyscript.net/releases/2024.11.1/core.css">
    <script type="module" src="https://pyscript.net/releases/2024.11.1/core.js"></script>
</head>
<body>
    <py-config src="./pyscript.toml"></py-config>
    <py-script src="./main.py"></py-script>
</body>
</html>
```
```pyscript.toml
packages = ["micropip", "cffi"]
name = "Test"
```

実行確認は簡易HTTPサーバー( http://localhost:8000 )で動作確認↓
```python
python -m http.server 8000
```

しかし、実行したところ「Error installing Pymunk: Requested 'cffi>=1.17.1', but cffi==1.16.0 is already installed」と言うエラーが出てしまいました。
Pyodideのサポートしているパッケージバージョンと、Pymunkの要求するパッケージバージョンが食い違ってしまっていますね👀
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/140207/a0879c1d-43ed-a903-204c-cd04773c6f68.png)

# Pymunk公式へIssueで問い合わせてみる
「サポート対象外です」とか言われたら、サクッと諦めようかと思いつつ、、、
とりあえずエラー内容をIssueで問い合わせてみました。

https://github.com/viblo/pymunk/issues/265

問い合わせてみたら、サクッと対応版がリリースされてしまいました。
上記のお試しスクリプトで6.10.0版をインストール・インポートすると、問題無く動作しました。
viblo様、ありがとうございます👾👾👾

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/140207/05bf94d4-08ff-2230-306c-1bf5ef920879.png)

# Web実行に向けて「3_2_shot_bullet.py」をちょっと改造
Web実行に向けてオリジナルの「[malo21st/Pyxel_Pymunk/3_2_shot_bullet.py](https://github.com/malo21st/Pyxel_Pymunk/blob/main/3_2_shot_bullet.py)」をちょっと改造しています。

Web実行では、Pymunkをインストールする処理が必要になります。
今回は、ファイル冒頭でインポートしないようにして、Appクラスの生成直前でインポートして、インポートしたインスタンスをAppクラスに渡すように変更しています（もっと良い方法あるかも）

このようにした理由は以下2点です
・Pymunkのインストールが完了する前に、インポートしてしまうとエラーになるため
・「if \_\_name__ == "\_\_main__":」を利用して、ローカルPC上での実行と、インストールと言う前処理が必要なWeb上での実行を切り分けるため

```python:shot_bullet.py
import pyxel

class App:
    def __init__(self, pymunk, fps=60):
        self.pymunk = pymunk
        self.fps = fps

        pyxel.init(256, 256, fps=fps, title="Vertical Stack with Pyxel")
        pyxel.load("shot_bullet.pyxres")
        self.create_world()
        pyxel.run(self.update, self.draw)

    def create_world(self):
        self.space = self.pymunk.Space()
        self.space.gravity = 0, 900
        self.space.sleep_time_threshold = 0.3

        # Static lines
        static_lines = [
            self.pymunk.Segment(self.space.static_body, (10, 200), (240, 200), 1),
            self.pymunk.Segment(self.space.static_body, (230, 200), (230, 50), 1),
        ]
        for line in static_lines:
            line.friction = 0.3
        self.space.add(*static_lines)

        # Stacked boxes
        self.shapes = []
        for x in range(5):
            for y in range(10):
                size = 10
                mass = 10.0
                moment = self.pymunk.moment_for_box(mass, (size, size))
                block_body = self.pymunk.Body(mass, moment)
                block_body.position = 100 + x * 20, 100 + y * (size + 0.1)
                block_shape = self.pymunk.Poly.create_box(block_body, (size, size))
                block_shape.friction = 0.3
                self.space.add(block_body, block_shape)
                self.shapes.append(block_shape)
        # Bullets
        self.bullets = []

    def update(self):
        step = 5  # Run multiple steps for more stable simulation
        step_dt = 1 / self.fps / step
        for _ in range(step):
            self.space.step(step_dt)

        if pyxel.btnp(pyxel.KEY_SPACE):
            mass = 100
            r = 5
            moment = self.pymunk.moment_for_circle(mass, 0, r, (0, 0))
            bullet_body = self.pymunk.Body(mass, moment)
            bullet_body.position = (10, 165)
            bullet_shape = self.pymunk.Circle(bullet_body, r, (0, 0))
            bullet_shape.friction = 0.3
            self.space.add(bullet_body, bullet_shape)
            self.bullets.append(bullet_shape)

            impulse = 150_000
            bullet_body.apply_impulse_at_local_point((impulse, 0), (0, 0))

        if pyxel.btnp(pyxel.KEY_Q):
            pyxel.quit()

        if pyxel.btnp(pyxel.KEY_A):
            self.create_world()

    def draw(self):
        pyxel.cls(0)

        # Draw bullets
        for bullet_shape in self.bullets:
            x, y, *_ = bullet_shape.bb
            angle = -bullet_shape.body.angle * 180 / 3.14  # radian => degree
            pyxel.blt(x, y, 1, 0, 0, 10, 10, rotate=angle)

        # Draw boxes
        for block_shape in self.shapes:
            x, y, *_ = block_shape.bb
            angle = -block_shape.body.angle * 180 / 3.14  # radian => degree
            pyxel.blt(x, y, 0, 0, 0, 10, 10, rotate=angle)

        # Draw static lines
        pyxel.line(10, 200, 240, 200, 7)
        pyxel.line(230, 200, 230, 50, 7)

        # Draw Key explanation
        pyxel.text(4, 4, "SPACE: Shot Bullet", 7)
        pyxel.text(4, 12, "A    : Reset", 7)


if __name__ == "__main__":
    import pymunk

    FPS = 60
    App(pymunk, FPS)
```

Web版ではなく、ローカルPC上で単体動作させる時は以下です↓
```shell
python shot_bullet.py
```

# Web用の「web_test.py」と「index.html」を用意
こちらも、もっと良い書き方がありそうですが、Web実行用に実行スクリプトを用意しています（await使う都合上、ちょっとゴチャっとしています）
micropipを利用してPymunkをインストールした後、Appクラスを初期化して実行しています。
micropipでインストールするwhlファイルはダウンロードして、同ディレクトリ内に格納してください。
```python:web_test.py
import asyncio
import micropip

from shot_bullet import App


# メイン関数を非同期で実行するための関数
def run_asyncio():
    # 現在のイベントループを取得
    loop = asyncio.get_event_loop()

    # イベントループが実行中か確認
    if loop.is_running():
        # 実行中の場合は、非同期タスクとしてmain()を登録
        asyncio.ensure_future(main())
    else:
        # 実行中でない場合は、新しいイベントループでmain()を実行
        asyncio.run(main())


# 非同期のメイン関数
async def main():
    try:
        # micropipを使ってwhlファイルからpymunkをインストール
        print("Installing Pymunk...")
        await micropip.install("pymunk-6.10.0-cp312-cp312-pyodide_2024_0_wasm32.whl")
        print("Pymunk installed successfully")

        # pymunkをインポート
        import pymunk

        # Appクラスを初期化して実行
        App(pymunk)
    except Exception as e:
        print(f"Error: {e}")


# メイン関数を実行
run_asyncio()
```

index.html は、公式サンプルとほぼ同じですが、micropipを利用する都合上、
「packages="micropip"」を、pyxel-playタグに追加しています。
```python:index.html
<!DOCTYPE html>
<html>
    <head>
        <script src="https://cdn.jsdelivr.net/gh/kitao/pyxel/wasm/pyxel.js"></script>
    </head>

    <body>
        <pyxel-play root="." name="pyxel-pymunk-web-test.pyxapp" packages="micropip"></pyxel-play>
    </body>
</html>
```

Pyxelパッケージ化を行い、簡易HTTPサーバー( http://localhost:8000 )で動作確認↓
※パッケージ化する対象スクリプトは「shot_bullet.py」ではなく「web_test.py」
※Web用ではないパッケージ化の場合は「shot_bullet.py」を対象とする
```python
pyxel package ./ web_test.py
python -m http.server 8000
```

以上。