PyScriptでJavaScriptゲームライブラリp5.jsやPhaser3を使う


## 概要

PyScript(Pyodide)がPygame-ceをサポートしたのですが、別の選択肢としてMicroPytonからJavaScriptゲームライブラリを使う方法もメモとして残しておきます。


## JavaScriptライブラリの使い方

PyScriptでは通常のJavaScript同様にHTMLのscript要素のsrc属性からロードする方法とESModuleを使う方法どちらもサポートしています。

ESModuleを使う方法では静的なimportも動的なimportもどちらも可能ですが、PyScript独自の設定やAPIでロードする必要があります。

今回利用するp5.jsやPhaser3では、script要素のsrc属性を使う方法でロードするので、ESModuleについては別の記事にまとめようかと思います。

p5.jsもPhaser3もロードするとglobalThis(window)にそれぞれp5やPhaserオブジェクトが追加されています。PyScriptではjsモジュールをインポートすると`js`がglobalThis(window)相当の変数になるので、これを通じてp5やPhaserオブジェクトにアクセスすることでJavaScriptライブラリを利用する形になります。

## p5.jsを使う

p5.jsで赤い円が勝手に動くだけの単純なサンプルを作ってみました。

```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>
    <script src="https://cdn.jsdelivr.net/npm/p5@1/lib/p5.js"></script>
</head>
<body>
    <script type="mpy" src="main.py"></script>
</body>
</html>
```

```main.py
import js

def sketch(p5):
    def setup():
        global count
        count = 100
        p5.createCanvas(500, 500)

    def draw():
        global count
        p5.background(0)
        p5.fill(255, 0, 0)
        p5.ellipse(count + 100, 250, 100, 100)

        count += 1
        count %= 300

    p5.setup = setup
    p5.draw = draw

js.p5.new(sketch)
```

HTMLのscript要素でp5.jsを指定してロードするようにしていれば、`js.p5`でp5.jsのAPIを呼び出せるようになります。

自分がプログラミングした範囲ではPyScriptだからと特に気を付ける要素はほとんどありませんでした(コールバック関数くらい)。JavaScriptとほぼ同じ感覚だと思います。

MicroPythonではPyodideと違い、JavaScriptにPythonのコールバック関数を渡す際にproxyを作る必要はないらしいので、そのまま関数を渡せます。

コールバック関数はJavaScriptと違い、PyScript側の関数の引数の数がJavaScriptから呼び出した時の引数と異なると実行時エラーになるようなのでそれだけは気を付ける必要があります。

## Phaser3を使う

p5は機能が限られているので、もっと機能豊富なPhaser3も使ってみることにしました。

Phaser3ではGraphicsやSprite等はコンストラクタにオブジェクトを渡して設定する方法が一般的のようですが、PyScriptの辞書をそのままインスタンスに渡すとPhaser3が例外を送出して動かないので、`pyscript.ffi`モジュールの`to_js`関数で変換する必要があるようです。PyScriptの辞書とJavaScriptオブジェクトは厳密には違うものなのでPhaser3内部でチェックする際に弾かれているようです。

また、Phaser3の要となるシーンはSceneクラスを継承してサブクラスを作るのが一般的なようですが、PyScriptではJavaScriptクラスをそのまま継承することはできないようです。幸い、Phaser3は継承するほかにもSceneコンストラクタにオブジェクトを渡して一括設定したり、Sceneクラスを直接インスタンス化してそのプロパティに代入する方法でもコーディングできるので使えないというわけではないです。ここではSceneクラスを継承せずインスタンス化してそのプロパティに代入する形でコーディングしています。

p5.jsと同じように赤い円が動くだけのサンプルを作ってみました。

```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>
    <script src="https://cdn.jsdelivr.net/npm/phaser@3/dist/phaser.js"></script>
</head>
<body>
    <script type="mpy" src="main.py">
    </script>
</body>
</html>
```

```main.py
import js
from pyscript.ffi import to_js

Phaser = js.Phaser

scene = None
cursor = None
graphics = None
count = 100

def create(data):
    global cursor, graphics
    cursor = scene.input.keyboard.createCursorKeys()
    graphics = scene.add.graphics(to_js({'fillStyle': {'color': 0xff0000}}))


def update(time, delta):
    global count
    graphics.clear()
    graphics.fillCircle(count + 100, 250, 50)
    count += 1
    count %= 300


scene = Phaser.Scene.new('SampleScene')
scene.create = create
scene.update = update

config = {
    'type': Phaser.AUTO,
    'width': 500,
    'height': 500,
    'scene': [scene]
}

Phaser.Game.new(to_js(config))
```

## 感想

Pygame-ce(Pygame)あるんだからそれ使えばいいやんとかJavaScriptライブラリなんだからわざわざ(Micro)Python使わなくてもJavaScriptでいいやんと言われるかもしれませんが、現状はPygame-ce(Pyodide)はロード時間に難があるので、それがどうしても許容できないときの選択肢として考えておいても損はないかなと考えています。