PyScriptとCPythonで共通のコードで使えるPyGame-ceクラスを作る

## 概要

[以前の記事](https://qiita.com/riafeed/items/0b45d285007751611c27)でPyGame-ceをPyScript上で動かそうとしてもCPython上で動いていたネイティブのPyGame用のコードそのままでは動かないという話をしましたが、そういった差異を吸収して共通のコードでPyGame-ceプログラミングをするためのクラスを作りました。

## PyScriptとCPythonを判別する

CPythonとPyScriptを判別するためには`sys.platform`を使います。これをPyScript環境で実行すると`emscripten`となるのでこれで判別します。(余談ですがこれはPyodideの場合で、MicroPythonでは`webassembly`となる)

## ゲームループ

非同期でも同期でもどちらでもいいのですが、今のところ特に非同期を避ける理由がないので非同期でループを回して`asyncio.sleep`でブラウザに制御を戻す方法にしました。

```python
class PyGameBase:
    def __init__(self, title="PyGameBase", width=800, height=600, fps=60):
        self.width = width
        self.height = height
        self.title = title
        self.fps = fps

    async def pyodide_init(self, js):
        pass

    def init(self):
        pass

    def update(self):
        pass

    def event(self, event):
        pass

    async def __start(self):
        pygame.init()
        self.screen = pygame.display.set_mode((self.width, self.height))
        pygame.display.set_caption(self.title)

        if sys.platform == "emscripten":
            import js
            loading = js.document.getElementById("loading")
            if loading is not None:
                loading.style.display = "none"
            await self.pyodide_init(js)

        self.init()

        self.frame = 0
        self.clock = pygame.time.Clock()
        self.running = True

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

            self.update()
            pygame.display.flip()

            self.clock.tick(self.fps)
            await asyncio.sleep(0)
            self.frame += 1

        pygame.quit()
        sys.exit()

    def run(self):
        if sys.platform == 'emscripten':
            asyncio.create_task(self.__start())
        else:
            asyncio.run(self.__start())
```

## テキスト描画

PyGameでデフォルトで用意されたフォントでは日本語を表示することができません。CPythonでは`pygame.font.SysFont`でOSが用意したフォント名を指定すれば日本語表示が可能ですが、[この記事](https://qiita.com/nak435/items/f1697b53087ad857b7c5)のコードを拝借して実行してみた感じPyScriptではデフォルトのフォントしか使えないようです。ノベルゲームなどテキスト表示が重要なゲームならフォントを別途用意して読み込ませてもいいのでしょうが、簡単なゲームでそこまで手間をかけるのもどうかと思ったので何とかしようと思ったのですが、結局PyGameと別のCanvasを上に重ねてCanvasのほうに文字を描画するのが最も手っ取り早い方法でした。

```html
<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>PyScript</title>
    <script type="module" src="https://pyscript.net/releases/2025.5.1/core.js"></script>
    <link rel="stylesheet" href="https://pyscript.net/releases/2025.5.1/core.css" />
    <style>
        #pygame {
            position: relative;
            margin: 10px;
            z-index: 0;
        }

        #canvas {
            position: absolute;
            z-index: 1;
        }

        #text_canvas {
            position: absolute;
            pointer-events: none;
            z-index: 2;
        }
    </style>
</head>

<body>
    <div id="pygame">
        <canvas id="canvas"></canvas>
        <canvas id="text_canvas"></canvas>
    </div>
    <script type="py-game" src="main.py" config="config.toml"></script>
</body>
```

```python
    def __init__(self, title="PyGameBase", width=800, height=600, fps=60):
        # -- 省略 --
        self.window_font = "msgothic"
        self.browser_font = "sans-serif"

    def render_text(self, text, x, y, color=(0, 0, 0), font_size=24, bold=False, italic=False):
        if sys.platform == "emscripten":
            ctx = self.text_canvas_context
            boldtext = "bold " if bold else ""
            italictext = "italic " if italic else ""
            ctx.font = f"{boldtext}{italictext}{font_size}px {self.browser_font}"
            if isinstance(color, str):
                ctx.fillStyle = color
            elif isinstance(color, tuple) and len(color) == 3:
                ctx.fillStyle = f"rgb({color[0]}, {color[1]}, {color[2]})"
            ctx.fillText(text, x, y)
        else:
            font = pygame.font.SysFont(self.window_font, font_size, bold, italic)
            text_surface = font.render(text, True, color)
            self.screen.blit(text_surface, (x, y))

    def get_text_size(self, text, font_size=24, bold=False, italic=False):
        if sys.platform == "emscripten":
            ctx = self.text_canvas_context
            boldtext = "bold " if bold else ""
            italictext = "italic " if italic else ""
            ctx.font = f"{boldtext}{italictext}{font_size}px {self.browser_font}"
            metrics = ctx.measureText(text)
            return metrics.width, font_size
        else:
            font = pygame.font.SysFont(self.window_font, font_size, bold, italic)
            text_surface = font.render(text, True, (0, 0, 0))
            return text_surface.get_size()

    async def __start(self):
        # -- 省略 --
        if sys.platform == "emscripten":
            import js

            text_canvas = js.document.getElementById("text_canvas")
            text_canvas.width = self.width
            text_canvas.height = self.height
            self.text_canvas_context = text_canvas.getContext("2d")
            self.text_canvas_context.textBaseline = "top"
        # -- 省略 --
```

## 完成品

完成品をgithubにアップロードしました。

https://github.com/uniras/PyGameBase