Python プログラミング教室

2025年12月21日の実践コースはPythonでスイカゲームのステップ5 ついに完成!?

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

前回は 物理エンジン pymunk を組み込みました。

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

import sys
from dataclasses import dataclass

import pygame
import pymunk

# ============================================================
# 画面・ゲーム設定(定数)
# ============================================================

W, H = 400, 600           # 画面サイズ(幅・高さ)
FPS = 60                  # フレームレート

# フルーツ投入(落下開始)関連
DROP_MIN_X = 60           # 落下可能な最小X(左の壁から少し内側)
DROP_MAX_X = W - 60       # 落下可能な最大X(右の壁から少し内側)
DROP_Y = 60               # 投入(落下開始)高さ

# ゲームオーバー判定用(今回は線を描くだけで、判定ロジックは未実装)
TOP_LINE_Y = 100          # 天井ライン(目安)

# pymunk用:床のY座標(見た目の床に合わせる)
FLOOR_Y = H - 43

# ============================================================
# 見た目(色)
# ============================================================

BG_COLOR = (245, 245, 250)
WALL_COLOR = (200, 200, 210)
TOP_LINE_COLOR = (220, 120, 120)

AIM_COLOR = (140, 160, 240)
PREVIEW_COLOR = (240, 170, 170)


# ============================================================
# Player:ユーザが操作できる「照準」
# ============================================================
class Player:
    """
    マウスX位置に追従する「照準」のクラス。
    - aim_x は DROP_MIN_X〜DROP_MAX_X の範囲に制限
    - aim_y は固定(DROP_Y)
    """

    def __init__(self):
        # 照準の中心(狙う位置)
        self.aim_x = (DROP_MIN_X + DROP_MAX_X) // 2
        self.aim_y = DROP_Y

        # 照準ライン(縦線)の見た目設定
        self.line_top_y = 0
        self.line_bottom_y = 60
        self.line_width = 6

        # 色
        self.color = AIM_COLOR

    def update(self):
        """マウス位置から照準Xを更新する(Yは使わないので無視)。"""
        mx, _my = pygame.mouse.get_pos()
        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
        )


# ============================================================
# World:床・壁(描画)+ pymunk床・壁(物理)をまとめて管理
# ============================================================
class World:
    """
    - pygame描画(背景、壁、床、ゲームオーバー線)
    - pymunk物理空間(重力、減衰、床壁の静的ボディ)
    をまとめて扱う。
    """

    def __init__(self, screen: pygame.Surface):
        self.screen = screen

        # --- pymunkの物理空間 ---
        self.space = pymunk.Space()
        self.space.gravity = (0, 900)     # 下向きの重力
        self.space.damping = 0.80         # 空気抵抗(1.0に近いほど止まりにくい)

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

        self._create_static_boundaries()

    def _create_static_boundaries(self):
        """床・左右壁を静的なSegmentとして作成し、spaceに追加する。"""
        static_body = self.space.static_body

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

        # 反発・摩擦(静的オブジェクト側の設定)
        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):
        """物理演算を dt 秒進める。"""
        self.space.step(dt)

    def draw(self, game_over: bool):
        """背景・壁・床・ゲームオーバーライン・GAME OVER文字を描画する。"""
        self.screen.fill(BG_COLOR)

        # 見た目の床・壁(太い線で表現)
        pygame.draw.line(self.screen, WALL_COLOR, (40, H - 40), (W - 40, H - 40), 20)
        pygame.draw.line(self.screen, WALL_COLOR, (40, 80), (40, H - 30), 20)
        pygame.draw.line(self.screen, WALL_COLOR, (W - 40, 80), (W - 40, H - 30), 20)

        # 天井ライン(ゲームオーバー判定の目安線)
        pygame.draw.line(self.screen, TOP_LINE_COLOR, (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)


# ============================================================
# Fruit:落ちてくる果物(pymunk版)
# ============================================================
@dataclass
class FruitStyle:
    """見た目・物理パラメータをまとめる(将来、種類を増やす時に便利)。"""
    radius: int
    color: tuple
    mass: float = 1.0
    elasticity: float = 0.2
    friction: float = 0.9


class Fruit:
    """
    pymunkの円(Body + Circle shape)として生成される果物。
    - 物理:spaceに body と shape を登録
    - 描画:pygameで円を描く
    """

    def __init__(self, space: pymunk.Space, x: float, y: float, style: FruitStyle):
        self.style = style

        # pymunkの円を作るための慣性モーメント
        moment = pymunk.moment_for_circle(style.mass, 0, style.radius)

        # 動くボディ(Dynamic Body)
        self.body = pymunk.Body(style.mass, moment)
        self.body.position = (x, y)

        # ボディに当たり判定(円)を付ける
        self.shape = pymunk.Circle(self.body, style.radius)
        self.shape.elasticity = style.elasticity
        self.shape.friction = style.friction

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

    def draw(self, screen: pygame.Surface):
        """pymunkの座標を取り出してpygameで円描画。"""
        x, y = self.body.position
        pygame.draw.circle(screen, self.style.color, (int(x), int(y)), self.style.radius)


# ============================================================
# Game:ゲーム全体(入力→更新→描画 のループを管理)
# ============================================================
class Game:
    def __init__(self):
        # --- pygame初期化 ---
        pygame.init()
        pygame.display.set_caption("Suika Step 5")
        self.screen = pygame.display.set_mode((W, H))
        self.clock = pygame.time.Clock()

        # --- ゲーム要素 ---
        self.world = World(self.screen)
        self.player = Player()
        self.fruits: list[Fruit] = []
        self.game_over = False

        # 「次に落とすフルーツ」の見た目(プレビュー)
        self.preview_style = FruitStyle(radius=20, color=PREVIEW_COLOR)

    def handle_events(self) -> bool:
        """
        入力を処理する。
        戻り値:
          True  = ゲーム継続
          False = 終了
        """
        for e in pygame.event.get():
            if e.type == pygame.QUIT:
                return False

            if e.type == pygame.KEYDOWN:
                if e.key == pygame.K_ESCAPE:
                    return False

                if e.key == pygame.K_SPACE:
                    # ゲームオーバー中は落とせない
                    if self.game_over:
                        continue

                    # SPACEで「物理フルーツ」を生成してspaceに追加
                    fruit = Fruit(
                        self.world.space,
                        self.player.aim_x,
                        self.player.aim_y,
                        self.preview_style
                    )
                    self.fruits.append(fruit)

        return True

    def update(self, dt: float):
        """照準更新 → 物理更新。"""
        self.player.update()
        self.world.step(dt)

        # ※ここに「ゲームオーバー判定」などを入れていく想定
        #   例:フルーツがTOP_LINE_Yより上に一定時間ある…など

    def draw(self):
        """背景→照準→フルーツ→プレビュー→flip の順で描画。"""
        self.world.draw(self.game_over)
        self.player.draw(self.screen)

        for f in self.fruits:
            f.draw(self.screen)

        # プレビュー(次に落とすフルーツ)※物理bodyは作らない
        pygame.draw.circle(
            self.screen,
            self.preview_style.color,
            (int(self.player.aim_x), int(self.player.aim_y)),
            self.preview_style.radius
        )

        pygame.display.flip()

    def run(self):
        """メインループ。"""
        running = True
        while running:
            dt = self.clock.tick(FPS) / 1000.0
            running = self.handle_events()
            self.update(dt)
            self.draw()

        pygame.quit()
        sys.exit()


if __name__ == "__main__":
    Game().run()

ステップ5 フルーツの種類を追加 & フルーツの合体!!

今回のゴールはこの2つだけ!

  • 次に落とすフルーツがランダム(小→中→大…へ合体できるように“種類”を持たせる)
  • 同じ種類がぶつかったら合体して1段階上になる

※ゲームオーバーは今回は作りません。天井ラインは描画されるだけでOK。

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

まずは、今のプログラム(この記事の最後に貼っている“スタート地点”)をそのまま実行してください。物理エンジンが動作していればOKです。

読み方のコツ

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

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

ステップ5-1:フルーツの「種類(レベル)」リストを作る

まずは フルーツを複数種類にします。
今のBaseでは FruitStyle があるので、ここに「種類一覧」を追加するだけです。

✅ 修正する場所

FruitStyle定義の直後に、フルーツ一覧を追加します。

★追記コード(ここを追加)

# ★追加:フルーツの種類(レベル)一覧(小→大)
FRUIT_STYLES = [
    FruitStyle(radius=16, color=(255, 180, 180)),  # Lv0
    FruitStyle(radius=22, color=(255, 210, 140)),  # Lv1
    FruitStyle(radius=30, color=(255, 230, 120)),  # Lv2
    FruitStyle(radius=38, color=(200, 240, 150)),  # Lv3
    FruitStyle(radius=46, color=(140, 230, 190)),  # Lv4(最大)
]

✅ Step5-1で動作確認

この時点ではまだ ゲームの見た目は変わりません(まだ使っていないため)。
でもここまでで「フルーツ種類の土台」は完成です。

ステップ5-2:次に落とすフルーツ(Next)をランダムにする

次は Game に「次に落とすフルーツ」を持たせて、プレビュー表示を連動させます。


① random を import する

ファイルの上部に追記します。

import random  # ★追加

② Game.__init__ に next_level を追加する

self.preview_style = ... のあたりを 置き換えます。

★変更箇所

        # ★削除
        # # 「次に落とすフルーツ」の見た目(プレビュー)
        # self.preview_style = FruitStyle(radius=20, color=PREVIEW_COLOR)

        # ★追加:次に落とすフルーツ(レベル)をランダムに決める
        # いきなり大きいのが出ると難しいので、最初は0~2あたりがおすすめ
        self.next_level = random.randint(0, 2)

③ SPACEで落とすフルーツを next_level にする

handle_events() の SPACE処理を修正します。

★変更箇所

                if e.key == pygame.K_SPACE:
                    # ゲームオーバー中は落とせない
                    if self.game_over:
                        continue

                    # SPACEで「物理フルーツ」を生成してspaceに追加
                    fruit = Fruit(
                        self.world.space,
                        self.player.aim_x,
                        self.player.aim_y,
                        # ★削除 self.preview_style
                        FRUIT_STYLES[self.next_level]  # ★追加
                    )
                    self.fruits.append(fruit)
                    
                    # ★追加:次のフルーツを再抽選
                    self.next_level = random.randint(0, 2)

④ プレビュー円を next_level に連動させる

draw() のプレビュー描画を修正します。

★変更箇所

    def draw(self):
        """背景→照準→フルーツ→プレビュー→flip の順で描画。"""
        self.world.draw(self.game_over)
        self.player.draw(self.screen)

        for f in self.fruits:
            f.draw(self.screen)

        # ★削除
        # プレビュー(次に落とすフルーツ)※物理bodyは作らない
        # pygame.draw.circle(
        #     self.screen,
        #     self.preview_style.color,
        #     (int(self.player.aim_x), int(self.player.aim_y)),
        #     self.preview_style.radius
        # )

        # ★追加:next_level のスタイルでプレビューを描画
        next_style = FRUIT_STYLES[self.next_level]
        pygame.draw.circle(
            self.screen,
            next_style.color,
            (int(self.player.aim_x), int(self.player.aim_y)),
            next_style.radius
        )

        pygame.display.flip()

✅ Step5-2で動作確認

  • プレビューの円が毎回変わる
  • SPACEで落とすフルーツも、その大きさ・色になる

ここまでで「Nextランダム」は完成です。これだけでもかなりスイカゲームになってきました。

ステップ5-3:同じ種類がぶつかったら合体させる(1段階アップ)

ここからが本題です。
ただし pymunkの衝突コールバック内で remove() すると不安定になりやすいので、今回は安全に

  • 衝突で「合体予約」
  • update()で「まとめて合体」

の方式で作ります。

① Fruit に「衝突時に自分が分かる情報」を付ける

今の Fruitshape に何も情報を付けていないので、衝突時に「どのフルーツか」が分かりません。

Fruit.__init__ の最後に2行追加します。

★追記(space.add の直前あたり)

        # ★追加:衝突判定用に識別情報を入れる
        self.shape.collision_type = 1
        self.shape.fruit = self

② Game.__init__ に「合体予約用の箱」と「衝突ハンドラ」を作る

Game.__init__ に追記します(self.next_level の下あたりが分かりやすいです)。

        # ★追加:合体処理を予約するための箱
        self.merge_requests = []          # [(fruitA, fruitB), ...]
        self.merge_reserved_ids = set()   # 同じフルーツを何度も予約しないため

        # ★追加:pymunk衝突ハンドラ登録
        space = self.world.space
        space.on_collision(1, 1, begin=self._on_collision_begin)

③ 衝突したら「合体予約」する

Game クラス内に2つの内部メソッド(_on_collision_begin、_process_merges)を追加します。

    def _on_collision_begin(self, arbiter, space, data):
        """★追加:同種衝突なら合体予約(ここでは消さない!)"""
        f1 = arbiter.shapes[0].fruit
        f2 = arbiter.shapes[1].fruit

        # styleで比較(同じオブジェクトなら同種とみなせる)
        if f1.style is f2.style:
            # ★最大レベル判定(最後は合体しない)
            level1 = FRUIT_STYLES.index(f1.style)
            if level1 >= len(FRUIT_STYLES) - 1:
                return True

            # ★二重予約防止
            if id(f1) in self.merge_reserved_ids or id(f2) in self.merge_reserved_ids:
                return True

            self.merge_reserved_ids.add(id(f1))
            self.merge_reserved_ids.add(id(f2))
            self.merge_requests.append((f1, f2))

        return True
    
    def _process_merges(self):
        """★追加:予約された合体をまとめて実行する"""
        if not self.merge_requests:
            return

        requests = self.merge_requests
        self.merge_requests = []

        for f1, f2 in requests:
            # すでに消えている可能性があるのでチェック
            if f1 not in self.fruits or f2 not in self.fruits:
                continue

            # 今のレベルを調べて、1段階上を作る
            level = FRUIT_STYLES.index(f1.style)
            new_level = level + 1
            if new_level >= len(FRUIT_STYLES):
                continue

            # 合体位置(2つの中間)
            x = (f1.body.position.x + f2.body.position.x) / 2
            y = (f1.body.position.y + f2.body.position.y) / 2

            # 2つ削除
            self.world.space.remove(f1.body, f1.shape)
            self.world.space.remove(f2.body, f2.shape)
            self.fruits.remove(f1)
            self.fruits.remove(f2)

            # 1つ追加(レベル+1)
            new_fruit = Fruit(self.world.space, x, y, FRUIT_STYLES[new_level])
            self.fruits.append(new_fruit)

        # 次フレームのために予約情報をリセット
        self.merge_reserved_ids.clear()

④ update()で合体を実行する

update() の最後に1行追加。

        self._process_merges()  # ★追加

✅ Step5-3で動作確認

  • 同じ色・同じ大きさがぶつかったら 1段階上に合体する
  • Nextがランダムで変わる

これで今回のゴール(2点)は完成です。。

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

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

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