レトロなビジュアルとリアルな物理挙動を融合することで、魅力的なシミュレーションやゲームを作ってみませんか?
本記事では、Pythonのライブラリ、Pyxel と Pymunk を使って、物理シミュレーションの基礎を学びながら簡単なゲームを作成します。
0. Pymunkのご紹介
Pymunk は、使い易い Python 2D物理ライブラリ です。2007年から開発を始めて以来、15 年以上経った今でもメンテナンスされています。下の画像は、開発者がPymunkを使って作成した初めてのゲーム作品です。
Pymunkについて、「シンプルなゲームで使い易い 2D物理ライブラリ が欲しかった」と開発者は語っています。PyxelとPymunkの相性が悪い訳ありません。
1. PyxelとPymunkの準備
ライブラリのインストール
- Pyxel のインストール
- Pymunk のインストール
2. アプリケーションの基本構成
2.1 Pyxelの基本構成
import pyxel
WIDTH, HEIGHT = 160, 120
INIT_POS = WIDTH // 2, 0
FPS = 30
class App:
def __init__(self):
pyxel.init(WIDTH, HEIGHT, fps=FPS, title="等速運動")
self.create_ball(*INIT_POS)
pyxel.run(self.update, self.draw)
def create_ball(self, x, y):
self.ball_pos = (x, y)
def update(self):
if self.ball_pos[1] > HEIGHT:
self.create_ball(*INIT_POS)
if self.ball_pos:
self.ball_pos = self.ball_pos[0], self.ball_pos[1] + 3
def draw(self):
pyxel.cls(0)
x, y = self.ball_pos
pyxel.circ(x, y, 5, 8)
App()
上記コードは、Pyxelの基本的なプログラム構成です。
App
クラスのコンストラクタ(__init __
)内のpyxel.run
メソッドの引数に、update
メソッドとdraw
メソッドを使用することで、Pyxelアプリが動きます。
2.2 Pymunkの基本構成
import pymunk
INIT_POS = 80, 0
FPS = 30
BALL_PROPERTY = 1, float('inf') #0 質量, 慣性モーメント
# 物理空間の生成と重力場の設定
space = pymunk.Space() #1
space.gravity = (0, 900) #2
# ボールの生成とボールを物理空間に追加
ball_body = pymunk.Body(*BALL_PROPERTY) #3
ball_body.position = INIT_POS #4
space.add(ball_body) #5
# 物理シミュレーションの実行
for _ in range(10):
space.step(1 / FPS) #6
x, y = ball_body.position #7
print(f"Ball Position: {x}, {y}")
上記コードは、Pymunkの基本的なプログラム構成です。
大きな流れで言うと
- 物理空間を作る
- 物理空間に物体を追加する
- 物理空間の時間を Δt秒毎に進めながら物体の位置を計算する
- その時点の物体の位置を取得(出力)する
なところです。
上記コードの#印の説明も加えておきます。
- #0:物体
ball
の物性情報(質量と慣性モーメント) - #1:物理空間
space
の生成 - #2:物理空間
space
の重力加速度を設定。Y軸方向に900(Pyxel画面の下方向) - #3:物体
ball
の生成。引数で、物性情報( 質量 と 慣性モーメント )を登録 - #4:物体
ball
の位置の初期値を設定 - #5:物理空間
space
に、物体 ball を追加 - #6:物理空間
space
を次のステップ(1 / FPS 秒後)に進める - #7:物体
ball
のその時点の位置を取得する
処理結果は以下のとおり、Y軸方向の位置の増分が加速度で次第に増加していることが分かります。
2.3 PyxelとPymunkの統合
import pyxel
import pymunk
WIDTH, HEIGHT = 160, 120
INIT_POS = WIDTH // 2, 0
FPS = 30
BALL_PROPERTY = 1, float('inf') #0 質量, 慣性モーメント
class App:
def __init__(self):
pyxel.init(WIDTH, HEIGHT, fps=FPS, title="自由落下運動")
self.space = pymunk.Space() #1
self.space.gravity = (0, 900) #2
self.create_ball(*INIT_POS)
pyxel.run(self.update, self.draw)
def create_ball(self, x, y):
self.ball_body = pymunk.Body(*BALL_PROPERTY) #3
self.ball_body.position = (x, y) #4
self.space.add(self.ball_body) #5
def update(self):
self.space.step(1 / FPS) #6
if self.ball_body.position[1] > HEIGHT:
self.create_ball(*INIT_POS)
def draw(self):
pyxel.cls(0)
x, y = self.ball_body.position #7
pyxel.circ(x, y, 5, 8)
App()
上記コードは、PyxelとPymunkを統合したプログラムです。ポイントは、PyxelとPymunkで時空を合わせることです。
- 時間
- 時間は、FPS(フレームレート)を合わせることで実現できます
-
Pyxel:
__init __
コンストラクタ内のpyxel.init
メソッドの引数fps(1秒間にFPS回)で定義します -
Pymunk:
update
メソッド内のspace.step
メソッドの引数(1 / FPS 秒)で定義します
-
Pyxel:
- 時間は、FPS(フレームレート)を合わせることで実現できます
- 空間(位置)
- 空間(位置)は、Pymunk側の物理空間
space
に追加した物体ball
の位置情報を元にPyxel側のdraw
メソッドで描画すれば問題ありません
- 空間(位置)は、Pymunk側の物理空間
以上紹介した3つのコードを俯瞰的にまとめたのが下図(クリックすると拡大)になります。
3. 基礎から応用へ
3.1 跳ねるボール
ボールが自由落下して地面で跳ね返るコードを考えましょう。
import pyxel
import pymunk
WIDTH, HEIGHT = 160, 120
INIT_POS = WIDTH // 2, 0
FPS = 60
BALL_PROPERTY = 1, float('inf') # 質量, 慣性モーメント
STOP_THRESHOLD = 0.1 # ボールが止まったとみなす速度の閾値
class App:
def __init__(self):
pyxel.init(WIDTH, HEIGHT, fps=FPS)
self.space = pymunk.Space() # 物理空間の作成
self.space.gravity = (0, 900) # 重力を設定
self.create_ball(*INIT_POS)
self.create_ground()
pyxel.run(self.update, self.draw)
def create_ball(self, x, y):
self.ball_body = pymunk.Body(*BALL_PROPERTY) # ボールの動的ボディを作成
self.ball_body.position = (x, y) # ボールの初期位置を設定
self.ball_shape = pymunk.Circle(self.ball_body, 5) # ボールを円形として定義
self.ball_shape.elasticity = 0.9 # 反射係数を設定
self.space.add(self.ball_body, self.ball_shape)
def create_ground(self):
# 地面を静的なボックスとして定義
self.ground_body = pymunk.Body(body_type=pymunk.Body.STATIC) # 静的ボディ
self.ground_body.position = (WIDTH / 2, HEIGHT - 5) # ボックスの中心位置を設定
self.ground_shape = pymunk.Poly.create_box(self.ground_body, (WIDTH, 10)) # ボックスを作成
self.ground_shape.elasticity = 0.8 # 反射係数を設定
self.space.add(self.ground_body, self.ground_shape)
def reset_ball_position(self):
self.ball_body.position = INIT_POS # ボールの位置を初期化
self.ball_body.velocity = (0, 0) # ボールの速度をリセット
def update(self):
self.space.step(1 / FPS) # 時間ステップの更新
# ボールが止まったかどうかを確認
if abs(self.ball_body.velocity.y) < STOP_THRESHOLD:
self.reset_ball_position()
def draw(self):
pyxel.cls(0) # 画面をクリア
x, y = self.ball_body.position # ボールの位置を取得
pyxel.circ(x, y, 5, 8) # ボールを描画
left, bottom, right, top = self.ground_shape.bb # 地面の位置を取得
pyxel.rect(left, bottom, right - left, top - bottom, 11) # 地面を描画
App()
前章では、ボールに対して干渉する物がありませんでしたが、今回は地面が干渉します。
物理空間に複数の物体がある場合、物体の物性情報(質量・慣性モーメント)だけでなく、形状情報を物理空間に追加します。
というのもPymunkは物体の重心の位置を計算しますから、形状が分からないと正しい衝突位置が分かりません。そこで、create_ball
メソッドやcreate_ground
メソッドでは、物性情報body
だけでなく形状情報shape
も物理空間space
に追加します。
特筆すべきは、重力の影響を受けない地面を静的ボディpymunk.Body.STATIC
で登録していることです。これで、時間が進んでも地面は変化しませし、ボールに干渉し続けます。
ちなみに、物理空間内で重力や力の影響を受けるボールは、動的ボディDYNAMIC
と呼びます。あとで説明しますが、ゲームパッドやキー操作から動かすボディをKINEMATIC
と呼びます。
3.2 ボールを動かす
ボールを動かす方法は、主に2つあります。ボールに速度を与える方法とボールに瞬間的に力を加える方法です。
私のお薦めは、瞬間的に力を加える方法です。また、この方法にはワールド座標とローカル座標があり、お薦めはローカル座標です。この例について紹介します。
IMPULSE = (100, -400)
IMPACT_POS = (0, 0)
ball_body.apply_impulse_at_local_point(IMPULSE, IMPACT_POS)
物性オブジェクトbody
のapply_impulse_at_local_point
メソッドでローカル座標の位置に瞬間的に力を加えます。
IMPULSE
は力積(力×Δt)のことで、まさしく瞬間的に力を加えることを意味し、ベクトルで設定します。IMPULSE = (100, -400)
は、X軸が増加する方向に100・Y軸が減少する方向に400を意味し、Pyxel画面で言うと右上方向になります。
IMPACT_POS = (0, 0)
は力を加えるローカル座標で、(0, 0)
は重心の位置を意味します。(0, 10)
とするとY軸が増加する方向に10となり、Pyxel画面では下方向となります。ゴルフボールに例えると、ボールの下半分に力が加わりバックスピンがかかります。
ball_body
オブジェクトのプロパティposition
とangle
(ラジアン)で、ボールの位置と角度を取得できます。
x, y = ball_body.position # ボールの位置を取得
angle = ball_body.angle # ボールの角度を取得
rx, ry = x + BALL_RADIUS * np.cos(angle), y + BALL_RADIUS * np.sin(angle)
pyxel.circ(x, y, BALL_RADIUS, 7) # ボールを描画
pyxel.line(x, y, rx, ry, 8) # ボールに線を描画
上の例では、Pyxelのcirc
・line
関数で回転しているボールを描きました。
一方、下の例はPyxelのイメージバンクに弾丸とブロックの画像を登録して、pyxel.blt(〜 略 〜 , rotate=angle)
で、回転する弾丸とブロックを描いてみました。画像の回転機能は、2024年8月に追加されたPyxelの新機能です。
3.3 ゲームへの応用
最後に、ゲーム作りに必要な入力操作と衝突検知について、ブロック崩しを例に説明します。
マウスでパドルを操作し、ボールがブロックに衝突したことを検知して、ブロックの消去を行います。(ボールがブロックやパドルそして壁に反射する処理はPymunkがやってくれます。ありがたい!)
マウスで操作するパドルは、KINEMATIC
ボディで定義します。KINEMATIC
ボディは、Pymunkで位置の計算は行いません。
# パドルの定義
paddle_body = pymunk.Body(body_type=pymunk.Body.KINEMATIC)
↓ その代わりに、マウスの位置情報をパドルの位置(重心)に設定します。
# パドル操作
x = pyxel.mouse_x # マウスのx座標を取得
self.paddle_body.position = (
max(PADDLE_WIDTH // 2, min(x, SCREEN_WIDTH - PADDLE_WIDTH // 2)),
SCREEN_HEIGHT - 10,
)
円形のときは、position
で位置(重心)の座標を取得した方が描画し易いですが、四角いpaddle_shape
オブジェクトの場合は、bb
プロパティでボックス座標(左上・右下)を取得した方が四角形の描画に便利です。↓
# パドルの描画
x, y, *_ = self.paddle_shape.bb
pyxel.rect(x, y, PADDLE_WIDTH, PADDLE_HEIGHT, pyxel.COLOR_YELLOW)
次に、衝突検知です。
ボールとブロックの衝突検知は、互いの形状オブジェクトshape
を使って行います。
(ボールの形状オブジェクト).shapes_collide(ブロックの形状オブジェクト).points
で、衝突があれば衝突箇所の座標がリストで返ってきて、無ければ空のリストを返します。
# 衝突後にブロックを削除
for block_body, block_shape in self.blocks:
if self.ball_shape.shapes_collide(block_shape).points:
self.blocks.remove((block_body, block_shape))
self.space.remove(block_body, block_shape)
衝突後の処理では、アプリのクラス変数blocks
から当該ブロックを削除するだけでなく、物理空間space
からも削除しているのがポイントです。
Pymunkなら、ボールが三角形でも問題ありません。しっかり衝突検知できますよ。
4. おわりに
レトロなビジュアルを持つPyxelと、2D物理ライブラリのPymunkを組み合わせることで、ユニークなゲームやアプリケーションを作成することができます。本記事ではその基本的な使い方から応用例までを解説しました。
コードを組み合わせて自分だけのシミュレーションやゲームを作り出す楽しさは格別です。アイデアを膨らませて、ぜひ試してみてください!
未来の名作があなたの手から生まれるかもしれませんね。
付録1 コードの公開
この記事で紹介したコードは、すべて公開しています。
https://github.com/malo21st/Pyxel_Pymunk
付録2 小ネタ集
本編では流れを重視してざっくりした説明に留めました。細かいことは、ここで語りたいと思います。
-
単 位
単位が気になる人がいると思いますが、残念ながらPymunkには一般的な単位はありません。無理に単位を定義すると、距離はピクセルで、速度はピクセル/Δtとかピクセル・FPS(=ピクセル/秒)などが考えられます。 -
重 力
Pymunkのコードを見ていると、space.gravity = (0, 900)
で重力をよく定義しています。PCの画面サイズで重力っぽく動くのが900
という数字みたいです。 -
慣性モーメント
2章では、慣性モーメントをfloat('inf')
無限大で定義しました。慣性モーメントは、小さいと回りやすく、大きいと回りにくくなります。なので、慣性モーメント無限大は、決して回転しない物体となります。物体の回転を考慮しないシミュレーションで使用されます。
Pymunkには、質量と形状から慣性モーメントを求める関数があります。慣性モーメントの値に困った時はご利用下さい。pymunk.moment_for_box(mass: float, size: Tuple[float, float]) pymunk.moment_for_circle(mass: float, inner_radius: float, outer_radius: float, offset: Tuple[float, float] = (0, 0)) pymunk.moment_for_poly(mass: float, vertices: Sequence[Tuple[float, float]], offset: Tuple[float, float] = (0, 0), radius: float = 0)
-
反発係数と摩擦係数
反発係数elasticity
・摩擦係数friction
とも形状オブジェクトshape
で設定します。0~1の値をとります。- 反発係数 0:まったく反発しない 1:減速せずに跳ね返ります
- 摩擦係数 0:まったく摩擦がない 1:(1超過も実はOK)
参考までに、素材ごとの摩擦係数がPymunkのドキュメントに載っています。
https://www.pymunk.org/en/latest/pymunk.html#pymunk.Circle.friction
-
描画の順番
イメージバンクの画像(弾丸)を回転描画させた時の話です。
下図は、地面→ブロック→弾丸 の順に描画
一方こちらは、弾丸→ブロック→地面
描画の順番を工夫することで、弾丸周りの黒いスペース(前者)を無くすことができます。(公開後追記 2024.12.15 9:23) -
Web版Pyxel では、Pymunkは動かない
残念なことに、PymunkはPyodide非対応のため、Web版Pyxelでは動きません。残念です。(公開後追記 2024.12.17 17:02)