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

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

---

## **0. **Pymunk**のご紹介**

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/234391/586a3dbe-4714-3984-377d-5238a785d047.png" width="400">


---

## **1. **Pyxel**と**Pymunk**の準備**

### ライブラリのインストール

- **Pyxel** のインストール

https://github.com/kitao/pyxel/blob/main/docs/README.ja.md#%E3%82%A4%E3%83%B3%E3%82%B9%E3%83%88%E3%83%BC%E3%83%AB%E6%96%B9%E6%B3%95

- **Pymunk** のインストール

https://www.pymunk.org/en/latest/installation.html

---

## **2. アプリケーションの基本構成**

### **2.1 **Pyxel**の基本構成**

```python
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**アプリが動きます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/234391/5ba25153-3469-e7a2-5ad3-51e10f1def32.gif" width="400">

---

### **2.2 **Pymunk**の基本構成**

```python
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**の基本的なプログラム構成です。
大きな流れで言うと
- 物理空間を作る
- 物理空間に物体を追加する
- 物理空間の時間を Δｔ秒毎に進めながら物体の位置を計算する
  - その時点の物体の位置を取得（出力）する

なところです。

上記コードの＃印の説明も加えておきます。
- #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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/234391/0dbae948-f442-22ad-d9b6-019061189cac.png)

---

### **2.3 **Pyxel**と**Pymunk**の融合**

```python
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（１秒間にFPS回）で定義します
    - **Pymunk**： `update`メソッド内の`space.step`メソッドの引数（1 / FPS 秒）で定義します
- 空間（位置）
  - 空間（位置）は、**Pymunk**側の物理空間`space`に追加した物体`ball`の位置情報を元に**Pyxel**側の`draw`メソッドで描画すれば問題ありません

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/234391/f27765d9-199d-74d4-1f37-ad27fac7b714.gif" width="400">

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/234391/43b591e1-ba39-69f2-a806-f4b8f2607ba7.png)

---

## **3. 基礎から応用へ**

### **3.1 跳ねるボール**

ボールが自由落下して地面で跳ね返るコードを考えましょう。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/234391/edf4826c-9e64-3d56-6fad-153178ab06dc.gif" width="400">
 

```python
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 ボールを動かす**

　ボールを動かす方法は、主に２つあります。ボールに速度を与える方法とボールに瞬間的に力を加える方法です。
　私のお薦めは、瞬間的に力を加える方法です。また、この方法にはワールド座標とローカル座標があり、お薦めはローカル座標です。この例について紹介します。

```python
IMPULSE = (100, -400)
IMPACT_POS = (0, 0)
ball_body.apply_impulse_at_local_point(IMPULSE, IMPACT_POS)
```
　物性オブジェクト`body`の`apply_impulse_at_local_point`メソッドでローカル座標の位置に瞬間的に力を加えます。
　`IMPULSE`は力積（力×Δｔ）のことで、まさしく瞬間的に力を加えることを意味し、ベクトルで設定します。`IMPULSE = (100, -400)`は、Ｘ軸が増加する方向に100・Ｙ軸が減少する方向に400を意味し、**Pyxel**画面で言うと右上方向になります。
　`IMPACT_POS = (0, 0)`は力を加えるローカル座標で、`(0, 0)`は重心の位置を意味します。`(0, 10)`とするとＹ軸が増加する方向に10となり、**Pyxel**画面では下方向となります。ゴルフボールに例えると、ボールの下半分に力が加わりバックスピンがかかります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/234391/d29f6e67-c9a4-402c-774b-ee71784568d4.gif" width="400">

　`ball_body`オブジェクトのプロパティ`position`と`angle`（ラジアン）で、ボールの位置と角度を取得できます。

```python
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**の新機能です。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/234391/96666832-5ce8-452e-cc2f-881185bed99d.gif" width="400">

---

### **3.3 ゲームへの応用**

　最後に、ゲーム作りに必要な入力操作と衝突検知について、ブロック崩しを例に説明します。
 　マウスでパドルを操作し、ボールがブロックに衝突したことを検知して、ブロックの消去を行います。（ボールがブロックやパドルそして壁に反射する処理は**Pymunk**がやってくれます。ありがたい！）

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/234391/36790d71-fbf0-8785-557c-eb130a554692.gif" width="400">

　マウスで操作するパドルは、`KINEMATIC`ボディで定義します。`KINEMATIC`ボディは、**Pymunk**で位置の計算は行いません。
```python
# パドルの定義
paddle_body = pymunk.Body(body_type=pymunk.Body.KINEMATIC)
```
↓ その代わりに、マウスの位置情報をパドルの位置（重心）に設定します。
```python
# パドル操作
x = pyxel.mouse_x  # マウスのｘ座標を取得
self.paddle_body.position = (
    max(PADDLE_WIDTH // 2, min(x, SCREEN_WIDTH - PADDLE_WIDTH // 2)),
    SCREEN_HEIGHT - 10,
)
```
余談ですが、円形のときは、`position`で位置（重心）の座標を取得した方が描画し易いですが、四角い`paddle_shape`オブジェクトの場合は、`bb`プロパティでバウンディングボックス座標（左上・右下）を取得した方が四角形の描画に便利です。↓ 

```python
# パドルの描画
x, y, *_ = self.paddle_shape.bb
pyxel.rect(x, y, PADDLE_WIDTH, PADDLE_HEIGHT, pyxel.COLOR_YELLOW)
```
次に、衝突検知です。
　ボールとブロックの衝突検知は、互いの形状オブジェクト`shape`を使って行います。
`（ボールの形状オブジェクト）.shapes_collide(ブロックの形状オブジェクト).points`で、衝突があれば衝突箇所の座標がリストで返ってきて、無ければ空のリストを返します。
```python
# 衝突後にブロックを削除
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**なら、ボールが三角形でも問題ありません。しっかり衝突検知できますよ。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/234391/dc31161c-e459-1716-5ec5-35369ffc0add.gif" width="400">

---

## **4. おわりに**

　レトロなビジュアルを持つ**Pyxel**と、2D物理ライブラリの**Pymunk**を組み合わせることで、ユニークなゲームやアプリケーションを作成することができます。本記事ではその基本的な使い方から応用例までを解説しました。

　コードを組み合わせて自分だけのシミュレーションやゲームを作り出す楽しさは格別です。アイデアを膨らませて、ぜひ試してみてください！

　未来の名作があなたの手から生まれるかもしれませんね。

---

## **付録１　コードの公開**

この記事で紹介したコードは、すべて公開しています。
https://github.com/malo21st/Pyxel_Pymunk

---

## **付録２　小ネタ集**

本編では流れを重視してざっくりした説明に留めました。細かいことは、ここで語りたいと思います。

- **単　位**
　単位が気になる人がいると思いますが、残念ながら**Pymunk**には一般的な単位はありません。無理に単位を定義すると、距離はピクセルで、速度はピクセル／Δｔとかピクセル・ＦＰＳ（＝ピクセル／秒）などが考えられます。
 
- **重　力**
　**Pymunk**のコードを見ていると、`space.gravity = (0, 900)`で重力をよく定義しています。ＰＣの画面サイズで重力っぽく動くのが`900`という数字みたいです。

- **慣性モーメント**
　２章では、慣性モーメントを`float('inf')`無限大で定義しました。慣性モーメントは、小さいと回りやすく、大きいと回りにくくなります。なので、慣性モーメント無限大は、決して回転しない物体となります。物体の回転を考慮しないシミュレーションで使用されます。
 　**Pymunk**には、質量と形状から慣性モーメントを求める関数があります。慣性モーメントの値に困った時はご利用下さい。
  ```python
  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`で設定します。０～１の値をとります。
  - 反発係数　０：まったく反発しない　１：減速せずに跳ね返ります
  - 摩擦係数　０：まったく摩擦がない　１：（１超過も実はＯＫ）
参考までに、素材ごとの摩擦係数が**Pymunk**のドキュメントに載っています。
　https://www.pymunk.org/en/latest/pymunk.html#pymunk.Circle.friction

- **描画の順番**
イメージバンクの画像（弾丸）を回転描画させた時の話です。
下図は、地面→ブロック→弾丸 の順に描画
  ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/234391/79db8be6-bc6c-dbde-8c39-d5393f9f21b4.png)
一方こちらは、弾丸→ブロック→地面
  ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/234391/069918d1-8081-f854-5fb4-42c1b148ea47.png)
  描画の順番を工夫することで、弾丸周りの黒いスペース（前者）を無くすことができます。（公開後追記 2024.12.15 9:23）

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

- **Web版Pyxel で、Pymunkは動きます！** 
一工夫が必要ではありますが、**Pymunk**は **Web版Pyxel** で動きます。  
@Kazuhito さんに感謝です。（公開後追記 2024.12.24 09:17）  
[「Pyxel × Pymunkで物理シミュレーションを始めよう！」を Webで動かしたい🌎](https://qiita.com/Kazuhito/items/cf7f2e0f42f47e611f3e)  
【 Pyxel Advent Calendar 2024　24日目 】

- [**Pyxel_Web_Test**](https://github.com/gomatama5/Pyxel_Web_Test?tab=readme-ov-file#pyxel_web_test)
  HTMLをちょっと追記するだけで、動きますよ。
  ゴマ卵 @gomatama5 さんに感謝です。（公開後追記 2025.02.16 14:30）