概要
2025年になって、PyodideがPygame-ce(Pygameのフォーク)を公式サポートしたことにより、PyScriptでも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ピクセルずつ赤い円が右に動いてある程度進んだら初期位置に戻ってまた右に動くだけという単純なスクリプトにしました。
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のドキュメントに載っていますので、まずはこの方法にしたがって修正します。
記事公開当初はHTMLにスクリプトを埋め込む形のコードを出していたのですが、コードハイライトが酷かったので思い直してHTML外部Pytonコードを呼び出す形にして本題のコードは外部Pythonコードとして掲載する形にしました。
ひとまず、以下の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として保存してください。
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の独自機能?)がデフォルトで有効になっているのでトップレベルにawaitを書いても動作します。
PyScriptはブラウザの制約でWebサーバーを通じてページを読み込まないと動作しません。Python環境がある場合はコマンドラインからHTMLがあるディレクトリで
python -m http.server 8080
を実行すると簡易なローカル用Webサーバーを立てることができます。この場合、http://localhost:8080 でアクセスすることができます。
変更2: 同期関数のままブラウザに制御を戻す
Python環境では個人的には思い当たらないのですが(JavaScript環境内ではあった)、どうしても非同期関数/asyncioを使いたくないという場面があるかもしれません。
そこで、前述のasyncioを使わない形でブラウザに制御を戻す方法を考えました。
繰り返しになりますが、同期関数内でwhileループを回しつつブラウザに制御を渡す方法は無いようですので構造自体を変更する必要があります。
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両方で動くようになれば便利だと思いますので作ってみました。
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両方同じコードで動くようにしてみました。個人的には非同期版を推奨しますが、参考程度にどうぞ。
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を使ってローディング画面を作ってみます。
<!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>
# ロード画面を消去
if sys.platform == 'emscripten':
import js
js.document.getElementById('loading').style.display = 'none'