Python プログラミング教室

2025年12月13日の実践コースはPythonでスイカゲームのステップ4 物理エンジン(pymunk)登場!

『Scratchで諦めたシリーズ』ということでス〇カゲームに挑戦しています。

前回までのスイカゲームは、手作りの重力(vyとgravity)でフルーツを落としていました。
今回からは、いよいよ 物理エンジン pymunk を組み込んで「壁に当たる」「転がる」「積み上がる」などの“本物っぽさ”を出していきます。

前回作ったプログラムを今回のスタート地点にします。まずは以下のコードをコピーして実行してみよう!ファイル名の最後は「.py」にすることをお忘れなく!
※一部リファクタリングで修正していますので、全員このコードをコピーしてください。

import sys                                # 標準ライブラリ:終了処理などに使用
import pygame                             # Pygame:描画と入力

W, H = 400, 600                           # 画面サイズ(幅・高さ)
FPS = 60                                  # フレームレート
DROP_MIN_X = 60                           # 落下可能な最小X(左の壁から少し内側)
DROP_MAX_X = W - 60                       # 落下可能な最大X(右の壁から少し内側)
DROP_Y = 60                               # 投入(落下開始)高さ
TOP_LINE_Y = 100                          # 天井ライン(ゲームオーバー判定用の目安)
MAX_FRUITS = 10                           # 何個落としたらゲームオーバーにするか

# ------------------------------------------------------------
# Player:ユーザが操作できるオブジェクト
# ------------------------------------------------------------
class Player:
    def __init__(self):
        self.aim_x = (DROP_MIN_X + DROP_MAX_X) // 2         # 照準Xの初期位置(中央寄り)
        self.aim_y = DROP_Y                                 # 照準Yは固定(投入高さ)
        self.line_top_y = 0                                 # 照準縦線の開始Y
        self.line_bottom_y = 60                             # 照準縦線の終了Y(見た目用)
        self.line_width = 6                                 # 縦線の太さ
        self.dot_radius = 10                                # ドット(照準点)の半径
        self.color = (140,160,240)                          # 照準の色(淡いブルー)

    def update(self):
        mx, my = pygame.mouse.get_pos()                     # 現在のマウスのX座標を取得 ※Y座標も取得しているが未使用
        self.aim_x = min(max(DROP_MIN_X, mx), DROP_MAX_X)   # マウスXを範囲内に制限

    def draw(self, screen: pygame.Surface):
        pygame.draw.line(screen, self.color, (self.aim_x, self.line_top_y), (self.aim_x, self.line_bottom_y), self.line_width) # 上部の狙いガイド縦線

# ------------------------------------------------------------
# 見た目担当:壁・床・天井ライン・ヒント表示
# ------------------------------------------------------------
class WorldView:
    def __init__(self, screen: pygame.Surface):
        self.screen = screen                                 # 描画先Surfaceを保持
        self.font = pygame.font.SysFont(None, 40)            # 文字表示用フォント

    def draw(self, game_over: bool):
        self.screen.fill((245, 245, 250))                    # 背景色を塗る(薄いグレー)
        pygame.draw.line(self.screen, (200,200,210), (40, H-40), (W-40, H-40), 20)       # 床ライン(グレー・太線)
        pygame.draw.line(self.screen, (200,200,210), (40, 80), (40, H-30), 20)
        pygame.draw.line(self.screen, (200,200,210), (W-40, 80), (W-40, H-30), 20)
        pygame.draw.line(self.screen, (220,120,120), (50, TOP_LINE_Y), (W - 50, TOP_LINE_Y), 2) # 天井ライン(赤系・細線) 

        # ゲームオーバー時の表示
        if game_over:
            text = self.font.render("GAME OVER", True, (200, 50, 50))
            rect = text.get_rect(center=(W // 2, H // 2))
            self.screen.blit(text, rect)

# ------------------------------------------------------------
# Fruit:落ちてくる果物(仮)オブジェクト
# ------------------------------------------------------------
class Fruit:
    def __init__(self, x, y, radius=20):
        self.x = x
        self.y = y
        self.radius = radius
        self.color = (250, 200, 200)    # 薄いピンク(仮の色)

        # 落下用の変数
        self.vy = 0.0                   # たて方向の速さ
        self.gravity = 900.0            # 重力っぽい加速度
        self.is_falling = False         # 最初は「落ちていない」状態
        self.has_landed = False         # このフルーツがもう着地したかどうか

    def update(self, dt: float):
        # 落ちていないときは何もしない
        if not self.is_falling:
            return

        # 重力っぽく、たて方向の速さを増やす
        self.vy += self.gravity * dt

        # 速さを使って、y座標を増やす(下に落ちる)
        self.y += self.vy * dt

        # 床の高さ(だいたい床ラインの少し上)を決める
        floor_y = H - 60

        # 床にぶつかったら止める
        if self.y + self.radius >= floor_y:
            self.y = floor_y - self.radius      # めり込まないように調整
            self.vy = 0.0
            self.is_falling = False             # 落下中判定をオフ
            self.has_landed = True              # 着地フラグをオン

    def draw(self, screen: pygame.Surface):
        pygame.draw.circle(screen, self.color, (int(self.x), int(self.y)), self.radius)

# ------------------------------------------------------------
# ゲーム全体
# ------------------------------------------------------------
class Game:
    def __init__(self):
        pygame.init()                                        # Pygame初期化
        pygame.display.set_caption("Suika Step 4")           # タイトル設定
        self.screen = pygame.display.set_mode((W, H))        # ウィンドウ生成
        self.clock = pygame.time.Clock()                     # 時間管理(FPS制御)
        self.worldView = WorldView(self.screen)              # 見た目管理を構築
        self.player = Player()                               # ユーザ操作クラス(Aim)
        self.fruits = []                                     # これまでに落ちたフルーツを入れておく箱(リスト)

        # 今操作しているフルーツ
        self.current_fruit = Fruit(self.player.aim_x, self.player.aim_y)

        self.game_over = False                              # ゲームオーバーフラグ

    def handle_events(self) -> bool:
        for e in pygame.event.get():                         # イベントループ開始
            if e.type == pygame.QUIT:                        # 閉じるボタンが押されたら
                return False                                 # ループ終了を指示
            if e.type == pygame.KEYDOWN:                     # キーボードが押されたら
                if e.key == pygame.K_ESCAPE:                 # ESCキーなら
                    return False                             # ループ終了を指示
                if e.key == pygame.K_SPACE:                  # スペースキーが押された
                    # ゲームオーバー中は何もしない
                    if self.game_over:
                        continue
                    # 「落ちていないときだけ」落下開始にする
                    if self.current_fruit is not None and not self.current_fruit.is_falling:
                        self.current_fruit.is_falling = True
        return True                                          # 続行する

    def update(self, dt: float):
        self.player.update()                      # マウス位置からAimのXを更新
        # フルーツがまだ落ちていない間は、Aimにくっつけておく
        if self.current_fruit is not None and not self.current_fruit.is_falling:
            self.current_fruit.x = self.player.aim_x
            self.current_fruit.y = self.player.aim_y

        # 落下中の処理(is_falling が True のときだけ動く)
        if self.current_fruit is not None:
            self.current_fruit.update(dt)
            # current_fruit が着地したら、リストに移して新しいフルーツを作る
            if self.current_fruit.has_landed:
                self.fruits.append(self.current_fruit)

                # ゲームオーバー判定 ※いったんコメントアウトしておく
                # if len(self.fruits) >= MAX_FRUITS:
                #     # 規定個数に達したらゲームオーバー
                #     self.game_over = True
                #     self.current_fruit = None
                #     return  # ここで update を終わらせてもOK

                # まだゲームオーバーでなければ次のフルーツ
                self.current_fruit = Fruit(self.player.aim_x, self.player.aim_y)

    def draw(self):
        self.worldView.draw(self.game_over)                  # 背景・壁・天井ライン・GAME OVERを描画
        self.player.draw(self.screen)                        # Aim(照準)を描画
        # これまでに落ちたフルーツリストを全部描画
        for f in self.fruits:
            f.draw(self.screen)

        # 今操作中のフルーツを描画
        if self.current_fruit is not None:
            self.current_fruit.draw(self.screen)

        pygame.display.flip()                                # 画面を更新(ダブルバッファ反映)

    def run(self):
        running = True                                       # メインループの継続フラグ
        while running:                                       # メインループ開始
            dt = self.clock.tick(FPS) / 1000.0               # 経過時間(秒)を取得
            running = self.handle_events()                   # 入力イベント処理(継続可否)
            self.update(dt)                                  # 状態更新(Aim/物理)
            self.draw()                                      # 描画
        pygame.quit()                                        # Pygameの終了処理
        sys.exit()                                           # プロセスを終了

if __name__ == "__main__":
    Game().run()                                             # ゲームを起動

ステップ4 物理エンジン(pymunk)登場!

今回のゴール(Step4-4)

  • pymunkでフルーツが落ちる
  • 壁や床に当たって転がる
  • 複数落とすと積み上がる

ステップ4-0:現状(前回のコード)をまず実行

まずは、今のプログラム(この記事の最後に貼っている“スタート地点”)をそのまま実行してください。

✅ 確認ポイント

  • マウスで照準が左右に動く
  • SPACEでフルーツが落ちる
  • 床で止まる(手作り重力)
  • GAME OVER 表示は WorldView が担当している(Gameがスッキリ)

ここまで確認できたら、次に進みます。

読み方のコツ

この講座では何度かプログラミングの上手な読み方をお伝えしています。
1行1行読むのではなく、全体像を把握するように塊ごとにどんなことをしているのかを大雑把に読んでいきます。

ある程度理解できたら少しずつプログラムを修正していきましょう。

ステップ4-1:pymunkを“読み込むだけ”(実行OK)

狙い:「状態をフラグで覚えておく」という考え方の練習。

1) pymunkをインストール

ターミナルで:

pip install pymunk

2) importを追加

ファイル先頭の import に1行追加:

import pymunk                             # pymunk:物理エンジン

3) FLOOR_Y を追加

定数エリアに追加:

FLOOR_Y = H - 60                          # pymunk用:床のY

✅この時点の結果

  • まだpymunkは使っていないので 動きは一切変わりません
  • でも「導入準備ができた」状態です

ステップ4-2:Worldクラスだけ追加(まだ使わない / 実行OK)

次に、pymunkの物理世界を管理する Worldクラスを追加します。
ただし まだGameから使わないので、動きは変わりません。

追加するコード(Worldクラスを貼り付け)

WorldViewクラスの上でも下でもOKですが、今回は分かりやすく WorldViewの上に置くのがおすすめです。

# ------------------------------------------------------------
# World:床・壁(描画)+ pymunk床・壁(物理)をまとめて管理
# ------------------------------------------------------------
class World:
    def __init__(self, screen: pygame.Surface):
        self.screen = screen                                 # 描画先Surfaceを保持

        # pymunkの物理空間
        self.space = pymunk.Space()
        self.space.gravity = (0, 900)                        # 重力

        # 見た目と物理をなるべく合わせるための座標
        self.wall_x_left = 50
        self.wall_x_right = W - 50
        self.wall_top_y = 90
        self.floor_y = FLOOR_Y

        self._create_static_boundaries()

    def _create_static_boundaries(self):
        static_body = self.space.static_body

        floor = pymunk.Segment(static_body, (self.wall_x_left, self.floor_y), (self.wall_x_right, self.floor_y), 6)
        left  = pymunk.Segment(static_body, (self.wall_x_left, self.wall_top_y), (self.wall_x_left, self.floor_y), 6)
        right = pymunk.Segment(static_body, (self.wall_x_right, self.wall_top_y), (self.wall_x_right, self.floor_y), 6)

        for s in (floor, left, right):
            s.elasticity = 0.2
            s.friction = 0.9

        self.space.add(floor, left, right)

    def step(self, dt: float):
        self.space.step(dt)

    def draw(self, game_over: bool):
        # いまはWorldViewと同じ見た目を描く(後でWorldViewを消すため)
        self.screen.fill((245, 245, 250))
        pygame.draw.line(self.screen, (200,200,210), (40, H-40), (W-40, H-40), 20)
        pygame.draw.line(self.screen, (200,200,210), (40, 80), (40, H-30), 20)
        pygame.draw.line(self.screen, (200,200,210), (W-40, 80), (W-40, H-30), 20)
        pygame.draw.line(self.screen, (220,120,120), (50, TOP_LINE_Y), (W - 50, TOP_LINE_Y), 2)

        if game_over:
            font = pygame.font.SysFont(None, 40)
            text = font.render("GAME OVER", True, (200, 50, 50))
            rect = text.get_rect(center=(W // 2, H // 2))
            self.screen.blit(text, rect)

✅ この時点の結果

  • Worldクラスを追加しただけなので、動きは変わりません
  • エラーなく実行できればOKです

ステップ4-3:GameがWorldを使って“物理だけ回す”(見た目変わらず / 実行OK)

「ここから少しずつpymunkが動き始めます。
ただし、まだFruitは手作り重力なので、見た目はほぼ変わりません。

1) Game.init に world を追加

self.worldView = ... の近くに、これを追加:

self.world = World(self.screen)                           # 物理世界(pymunk)+見た目(描画)

2) update() に world.step(dt) を追加

self.player.update() の近くに追加:

self.world.step(dt)                                       # pymunkの物理を進める(まだFruitは使ってない)

3) draw() を world.draw() に変更

今のこの行を:

self.worldView.draw(self.game_over)

こう変える:

self.world.draw(self.game_over)

✅ この時点の結果

  • 画面の見た目はほぼ同じ
  • でも裏でpymunkのspaceが進んでいる
  • まだFruitは手作り落下なので、落ち方はほぼ変わらない

ステップ4-4:Fruitクラスを差し替え(ここで一気に“本物の物理”に変わる)

ここが一番楽しいところです。
Fruitを丸ごと差し替えると、落ち方が本格的に変化します。

  • 壁に当たる
  • 転がる
  • 複数落とすと積み上がる

1) Fruitクラスを丸ごと差し替え(貼り替えOK)

今の Fruit を、下のクラスに置き換えます。

# ------------------------------------------------------------
# Fruit:落ちてくる果物(pymunk版)
# ------------------------------------------------------------
class Fruit:
    def __init__(self, space: pymunk.Space, x, y, radius=20):
        self.radius = radius
        self.color = (250, 200, 200)

        mass = 1.0
        moment = pymunk.moment_for_circle(mass, 0, radius)
        self.body = pymunk.Body(mass, moment)
        self.body.position = (x, y)

        self.shape = pymunk.Circle(self.body, radius)
        self.shape.elasticity = 0.2
        self.shape.friction = 0.9

        space.add(self.body, self.shape)

    def draw(self, screen: pygame.Surface):
        x, y = self.body.position
        pygame.draw.circle(screen, self.color, (int(x), int(y)), self.radius)

2) Game.__init__ を修正(プレビュー用の設定を追加)

Game.__init__ の中で、current_fruit をやめて「プレビュー円(まだ物理じゃない)」の設定を持たせます。

修正ポイント:

  • self.current_fruit = ...削除
  • プレビュー用の preview_radius / preview_color追加
class Game:
    def __init__(self):
        pygame.init()                                        # Pygame初期化
        pygame.display.set_caption("Suika Step 4")           # タイトル設定
        self.screen = pygame.display.set_mode((W, H))        # ウィンドウ生成
        self.clock = pygame.time.Clock()                     # 時間管理(FPS制御)
        self.world = World(self.screen)                      # ★物理世界(pymunk)
        self.player = Player()                               # ユーザ操作クラス(Aim)
        self.fruits = []                                     # これまでに落ちたフルーツを入れておく箱(リスト)

        # ★追加:落とす前プレビュー(物理ボディは作らない)
        self.preview_radius = 20
        self.preview_color = (240, 170, 170)

        # ★以下を削除
        # # 今操作しているフルーツ
        # self.current_fruit = Fruit(self.player.aim_x, self.player.aim_y)

        self.game_over = False                               # ゲームオーバーフラグ

3)handle_events() の SPACE 処理を修正(ここで spawn する)

SPACE が押されたら、その場で Fruit を生成して fruits に追加します。
(あなたの最終系の「spawn_fruit 相当」の動きになります)

    def handle_events(self) -> bool:
        for e in pygame.event.get():                         # イベントループ開始
            if e.type == pygame.QUIT:                        # 閉じるボタンが押されたら
                return False                                 # ループ終了を指示
            if e.type == pygame.KEYDOWN:                     # キーボードが押されたら
                if e.key == pygame.K_ESCAPE:                 # ESCキーなら
                    return False                             # ループ終了を指示
                if e.key == pygame.K_SPACE:                  # スペースキーが押された
                    # ゲームオーバー中は何もしない
                    if self.game_over:
                        continue

                    # ★追加:SPACEが押された瞬間に「物理フルーツ」を生成してspaceに追加する
                    f = Fruit(self.world.space, self.player.aim_x, self.player.aim_y, self.preview_radius)
                    self.fruits.append(f)

                    # ★以下を削除
                    # # 「落ちていないときだけ」落下開始にする
                    # if self.current_fruit is not None and not self.current_fruit.is_falling:
                    #     self.current_fruit.is_falling = True

        return True                                          # 続行する

4)update() を修正(手作り落下をやめて「物理だけ」進める)

もう current_fruit は無いので、updateはシンプルに、以下の2行の処理だけを残して他を削除します。

    def update(self, dt: float):
        self.player.update()                                 # マウス位置からAimのXを更新
        self.world.step(dt)                                  # ★pymunkの物理を進める

5)draw() を修正(プレビュー円+物理フルーツを描画)

  • 背景は world.draw() に任せる(あなたの最終系どおり)
  • 物理フルーツを全部描画
  • 最後に「プレビュー円」を描画
    def draw(self):
        self.world.draw(self.game_over)                      # 背景・壁・天井ライン・GAME OVERを描画
        self.player.draw(self.screen)                        # Aim(照準)を描画

        # これまでに落ちたフルーツリストを全部描画
        for f in self.fruits:
            f.draw(self.screen)

        # ★追加:今操作中のフルーツを描画(プレビュー円)
        pygame.draw.circle(
            self.screen, self.preview_color,
            (int(self.player.aim_x), int(self.player.aim_y)),
            self.preview_radius
        )

        # ★以下は削除
        # # 今操作中のフルーツを描画
        # if self.current_fruit is not None:
        #     self.current_fruit.draw(self.screen)

        pygame.display.flip()                                # 画面を更新(ダブルバッファ反映)

✅ この時点の結果(今回のゴール!)

  • 落ち方が「本物の物理」になる
  • 壁に当たって跳ねる・転がる
  • 何個も落とすと積み上がる

そして最後に:WorldViewクラスは削除してOK

Step4-3で描画を self.world.draw() に切り替えたので、もう WorldView は使っていません。

✅ 授業で「最後の片付け」としてやるなら

  • WorldView クラスを丸ごと削除
  • Game.__init__self.worldView = ... を削除

これで WorldViewは卒業です。

ステップ4-5:今後は何をしてけばいいか考えよう

次に何を追加したいのかは各自で考えて意見を出してみよう。
今回出た意見に合わせて次回追加する内容を考えたいと思います。

-Python, プログラミング教室