29
25

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PyxelAdvent Calendar 2024

Day 15

Pyxel × Pymunkで物理シミュレーションを始めよう!

Last updated at Posted at 2024-12-14

 レトロなビジュアルとリアルな物理挙動を融合することで、魅力的なシミュレーションやゲームを作ってみませんか?
 本記事では、Pythonのライブラリ、PyxelPymunk を使って、物理シミュレーションの基礎を学びながら簡単なゲームを作成します。


0. Pymunkのご紹介

 Pymunk は、使い易い Python 2D物理ライブラリ です。2007年から開発を始めて以来、15 年以上経った今でもメンテナンスされています。下の画像は、開発者がPymunkを使って作成した初めてのゲーム作品です。
 Pymunkについて、「シンプルなゲームで使い易い 2D物理ライブラリ が欲しかった」と開発者は語っています。PyxelPymunkの相性が悪い訳ありません。


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軸方向の位置の増分が加速度で次第に増加していることが分かります。

image.png


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()

上記コードは、PyxelPymunkを統合したプログラムです。ポイントは、PyxelPymunkで時空を合わせることです。

  • 時間
    • 時間は、FPS(フレームレート)を合わせることで実現できます
      • Pyxel__init __コンストラクタ内のpyxel.initメソッドの引数fps(1秒間にFPS回)で定義します
      • Pymunkupdateメソッド内のspace.stepメソッドの引数(1 / FPS 秒)で定義します
  • 空間(位置)
    • 空間(位置)は、Pymunk側の物理空間spaceに追加した物体ballの位置情報を元にPyxel側のdrawメソッドで描画すれば問題ありません

以上紹介した3つのコードを俯瞰的にまとめたのが下図(クリックすると拡大)になります。

image.png


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)

 物性オブジェクトbodyapply_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オブジェクトのプロパティpositionangle(ラジアン)で、ボールの位置と角度を取得できます。

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)  # ボールに線を描画

 上の例では、Pyxelcircline関数で回転しているボールを描きました。
 一方、下の例は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
  • 描画の順番
    イメージバンクの画像(弾丸)を回転描画させた時の話です。
    下図は、地面→ブロック→弾丸 の順に描画
    image.png
    一方こちらは、弾丸→ブロック→地面
    image.png
    描画の順番を工夫することで、弾丸周りの黒いスペース(前者)を無くすことができます。(公開後追記 2024.12.15 9:23)

  • Web版Pyxel では、Pymunkは動かない
    残念なことに、PymunkはPyodide非対応のため、Web版Pyxelでは動きません。残念です。(公開後追記 2024.12.17 17:02)

29
25
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
29
25

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?