Python プログラミング教室

2025年10月5日の実践コースはPython(Pygame)の続き

前回に続いてPythonでゲームを作っていきます。
前回までに作ったプログラムをきれいにしましたので、以下のプログラムからスタートしたいと思います。

コードを整えただけでなく、今回はこっそり「リファクタリング」という作業も行いました。
おかげでたくさんあった関数が整理されており、メイン処理もすっきりして読みやすくなっています。

from __future__ import annotations
import sys
import random
import pygame

# =============================================================================
# 定数(ゲーム全体で使う値)
# =============================================================================

FPS = 60                      # 1秒あたりの描画回数(フレームレート)
CELL = 30                     # 1マスの大きさ(ピクセル)
COLS, ROWS = 10, 20           # 盤面の列数(横)、行数(縦)
PANEL_WIDTH = 240             # 右側の情報パネルの幅(ピクセル)

# 画面サイズ(横幅は「盤面の横幅 + パネル幅」)
WIDTH = CELL * COLS + PANEL_WIDTH
HEIGHT = CELL * ROWS

# 色(RGB)
WHITE = (255, 255, 255)
GRAY = (200, 200, 200)
BLACK = (0, 0, 0)

COLORS = [
    (0, 180, 255), (255, 200, 0), (170, 0, 255),
    (0, 200, 80), (255, 100, 0), (255, 0, 100), (0, 120, 255)
]

# 4つの相対座標で形を表現(テトリスの7種)
SHAPES = {
    "I": [(-2, 0), (-1, 0), (0, 0), (1, 0)],
    "O": [(-1, 0), (0, 0), (-1, 1), (0, 1)],
    "T": [(-1, 0), (0, 0), (1, 0), (0, 1)],
    "S": [(0, 0), (1, 0), (-1, 1), (0, 1)],
    "Z": [(-1, 0), (0, 0), (0, 1), (1, 1)],
    "J": [(-1, 0), (-1, 1), (0, 0), (1, 0)],
    "L": [(-1, 0), (0, 0), (1, 0), (1, 1)],
}

# 落下間隔(通常/ソフトドロップ)
FALL_MS_NORMAL = 700
FALL_MS_SOFT = 80


# =============================================================================
# スプライト(1マス)クラス
# =============================================================================

class CellBlock(pygame.sprite.Sprite):
    """盤面上の『1マス』を表すスプライト。

    grid_x, grid_y は「マスの座標」(ピクセルではない点に注意)。
    画面上のピクセル位置は update_rect() で計算して rect に反映します。
    """

    def __init__(self, grid_x: int, grid_y: int, color: tuple[int, int, int], cell_size: int) -> None:
        super().__init__()
        # -1 するのは、グリッド線と重なって見えづらくならないための微調整。
        self.image = pygame.Surface((cell_size - 1, cell_size - 1))
        self.image.fill(color)

        self.rect = self.image.get_rect()
        self.grid_x = grid_x
        self.grid_y = grid_y
        self.cell_size = cell_size

        self.update_rect()

    def update_rect(self) -> None:
        """グリッド座標(マス)→ 画面上のピクセル座標に変換して配置する。"""
        self.rect.topleft = (self.grid_x * self.cell_size, self.grid_y * self.cell_size)

# =============================================================================
# ピースクラス
# =============================================================================

class Piece:
    """4マスで構成される落下ピース。

    - 形は SHAPES の相対座標(原点を中心としたオフセット)で表現。
    - 盤面上の位置は「基準座標 (self.x, self.y)」+各相対座標。
    - 見た目は 4 個の CellBlock スプライトで描画(self.group にまとめる)。
    - 盤面との当たり判定は Board を使って行う。
    """

    def __init__(self, kind: str | None = None, color: tuple[int, int, int] | None = None) -> None:
        # ピースの種類(I, O, T, S, Z, J, L)と色。指定がなければランダム。
        self.kind = kind or random.choice(list(SHAPES.keys()))
        self.color = color or random.choice(COLORS)

        # ピースの「基準位置(軸)」を盤面中央付近に置く(x は列、y は行)。
        self.x = COLS // 2
        self.y = 1

        # 形を表す4つの相対座標((0,0) が基準位置)。
        self.offsets: list[list[int]] = [list(p) for p in SHAPES[self.kind]]

        # 見た目としての 4 マス分のスプライトを生成し、描画グループに入れる。
        self.blocks: list[CellBlock] = []
        self.group = pygame.sprite.Group()
        for (ox, oy) in self.offsets:
            b = CellBlock(self.x + ox, self.y + oy, self.color, CELL)
            self.blocks.append(b)
            self.group.add(b)

    # --------- 位置・形のユーティリティ ---------

    def world_cells(self) -> list[tuple[int, int]]:
        """現在の基準位置 (x, y) を反映した4マス分のグリッド座標を返す。"""
        return [(self.x + ox, self.y + oy) for (ox, oy) in self.offsets]

    def _update_sprites(self) -> None:
        """内部状態(x, y, offsets)をスプライト座標(ピクセル)に反映する。"""
        for i, (ox, oy) in enumerate(self.offsets):
            self.blocks[i].grid_x = self.x + ox
            self.blocks[i].grid_y = self.y + oy
            self.blocks[i].update_rect()

    # --------- 移動(盤面に合わせて判定) ---------

    def try_move(self, board: Board, dx: int, dy: int) -> bool:
        """(dx, dy) マスだけ移動できるなら移動し、True を返す。ダメなら何もしないで False。"""
        if board.can_place(self, dx=dx, dy=dy):
            self.x += dx
            self.y += dy
            self._update_sprites()
            return True
        return False

    def move_left(self, board: Board) -> None:
        """左へ1マス移動(できる場合のみ)。"""
        self.try_move(board, dx=-1, dy=0)

    def move_right(self, board: Board) -> None:
        """右へ1マス移動(できる場合のみ)。"""
        self.try_move(board, dx=1, dy=0)

    def move_down_once(self, board: Board) -> bool:
        """下へ1マス移動(できたら True、できなければ False)。"""
        return self.try_move(board, dx=0, dy=1)

    # --------- 回転(簡易壁キック込み) ---------

    def _apply_rotation_with_kick(self, board: Board, new_offsets: list[list[int]]) -> bool:
        """回転後の相対座標を仮適用し、壁キック(左右ずらし)で入れば確定する。"""
        old_offsets, old_x = self.offsets, self.x
        # ずらし量の候補(0 → 左1 → 右1 → 左2 → 右2)
        for dx in [0, -1, 1, -2, 2]:
            if board.can_place(self, dx=dx, dy=0, offsets=new_offsets):
                self.x = old_x + dx
                self.offsets = [list(p) for p in new_offsets]
                self._update_sprites()
                return True
        # すべて失敗:元に戻す
        self.offsets = old_offsets
        self.x = old_x
        return False

    def rotate_cw(self, board: Board) -> None:
        """時計回りに回転(壁キックあり)。点 (x, y) → (-y, x)。"""
        new_offsets = [[-oy, ox] for (ox, oy) in self.offsets]
        self._apply_rotation_with_kick(board, new_offsets)

    def rotate_ccw(self, board: Board) -> None:
        """反時計回りに回転(壁キックあり)。点 (x, y) → (y, -x)。"""
        new_offsets = [[oy, -ox] for (ox, oy) in self.offsets]
        self._apply_rotation_with_kick(board, new_offsets)

    # --------- 描画 ---------

    def draw(self, screen: pygame.Surface) -> None:
        """4マス分のスプライトをまとめて描画。"""
        self.group.draw(screen)


# =============================================================================
# 盤面クラス
# =============================================================================

class Board:
    """固定ブロックの保持と、盤面の当たり判定・描画を担当するクラス。"""

    def __init__(self, cols: int, rows: int, cell_size: int) -> None:
        self.cols = cols
        self.rows = rows
        self.cell = cell_size
        # 2次元配列(None=空/(R,G,B)=固定ブロックの色)
        self.grid: list[list[tuple[int, int, int] | None]] = [
            [None for _ in range(cols)] for _ in range(rows)
        ]

    # --------- 当たり判定系 ---------

    def in_bounds(self, gx: int, gy: int) -> bool:
        """(gx, gy) が盤面内かどうか。"""
        return 0 <= gx < self.cols and 0 <= gy < self.rows

    def is_empty(self, gx: int, gy: int) -> bool:
        """(gx, gy) が空マスかどうか(盤面外は空ではない扱い)。"""
        return self.in_bounds(gx, gy) and self.grid[gy][gx] is None

    def can_place(self, piece: "Piece", dx: int = 0, dy: int = 0, offsets: list[list[int]] | None = None) -> bool:
        """ピースを (dx, dy) だけ動かした位置に『置けるか?』を調べる。

        offsets を渡すと、回転後など“仮の形”での判定に使える。
        """
        offs = offsets if offsets is not None else piece.offsets
        for ox, oy in offs:
            gx = piece.x + dx + ox
            gy = piece.y + dy + oy
            if not self.in_bounds(gx, gy):
                return False
            if self.grid[gy][gx] is not None:
                return False
        return True

    # --------- 更新/確定 ---------

    def lock_piece(self, piece: "Piece") -> None:
        """ピースを現在位置で固定(grid に色を書き込む)。"""
        for gx, gy in piece.world_cells():
            if self.in_bounds(gx, gy):
                self.grid[gy][gx] = piece.color

    # --------- 描画 ---------

    def draw_background(self, screen: pygame.Surface) -> None:
        """左側の盤面(白背景+マス目の線)を描画する。"""
        # 盤面の白い背景(左側だけを塗る)
        screen.fill(WHITE, (0, 0, self.cols * self.cell, HEIGHT))
        # 薄いグリッド線(縦線)
        for x in range(self.cols + 1):
            pygame.draw.line(screen, GRAY, (x * self.cell, 0), (x * self.cell, HEIGHT))
        # 薄いグリッド線(横線)
        for y in range(self.rows + 1):
            pygame.draw.line(screen, GRAY, (0, y * self.cell), (self.cols * self.cell, y * self.cell))

    def draw_fixed(self, screen: pygame.Surface) -> None:
        """固定ブロック(gridの中身)を描画する。"""
        for y in range(self.rows):
            for x in range(self.cols):
                color = self.grid[y][x]
                if color is not None:
                    rect = pygame.Rect(x * self.cell, y * self.cell, self.cell - 1, self.cell - 1)
                    pygame.draw.rect(screen, color, rect)


# =============================================================================
# メイン処理
# =============================================================================

def main() -> None:
    """ゲームのエントリポイント。

    ここでは「入力受付」「重力タイマー」「描画」という“ゲーム進行”に集中させ、
    ピースの具体的な動作は Piece / Board のメソッドに任せています。
    """
    pygame.init()
    pygame.display.set_caption("落ちゲー(リファクタリング版)")
    screen = pygame.display.set_mode((WIDTH, HEIGHT))
    clock = pygame.time.Clock()

    board = Board(COLS, ROWS, CELL)
    current = Piece()

    fall_timer = 0
    fall_interval = FALL_MS_NORMAL  # 現在の落下間隔(下キーで一時的に短縮)

    running = True
    while running:
        dt = clock.tick(FPS)

        # --------- 入力(イベント) ---------
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False

            elif event.type == pygame.KEYDOWN:
                if event.key == pygame.K_LEFT:
                    current.move_left(board)
                elif event.key == pygame.K_RIGHT:
                    current.move_right(board)
                elif event.key == pygame.K_DOWN:
                    # 単発の1マス落下(押しっぱなしの加速は下のソフトドロップで実現)
                    current.move_down_once(board)
                elif event.key == pygame.K_UP:
                    # 時計回り回転(簡易壁キック込み)
                    current.rotate_cw(board)
                elif event.key == pygame.K_z:
                    # 反時計回り回転(簡易壁キック込み)
                    current.rotate_ccw(board)

        # --------- ソフトドロップ(下キー長押しで落下間隔を短縮) ---------
        keys = pygame.key.get_pressed()
        fall_interval = FALL_MS_SOFT if keys[pygame.K_DOWN] else FALL_MS_NORMAL

        # --------- 重力(一定間隔で1マス落下) ---------
        fall_timer += dt
        if fall_timer >= fall_interval:
            fall_timer = 0
            if not current.move_down_once(board):
                # これ以上下へ行けない → 固定して次のピースへ
                board.lock_piece(current)
                current = Piece()

        # --------- 描画 ---------
        screen.fill((30, 30, 30))           # 背景(盤面外の暗色)
        board.draw_background(screen)        # 盤面の白背景+グリッド
        board.draw_fixed(screen)             # 固定ブロック
        current.draw(screen)                 # 落下中のピース
        pygame.display.flip()

    pygame.quit()
    sys.exit()


if __name__ == "__main__":
    main()

ステップ1 1マスのスプライトを登場させる

ステップ1はこちらの記事を参照してください。

ステップ2 4マスのピースを登場させる

ステップ2はこちらの記事を参照してください。

ステップ3 重力・固定・次のブロック

2025年9月28日の実践コースはPython(Pygame)の続き - 彩都こどもプログラミング教室

ステップ4 横にそろったら消える

ステップ4-1:横1列そろったら消える

追加はこの2か所だけ

① Board クラスの中に、行消去の2メソッドを追加
② main() の「ピース固定後」に、消去を呼び出す1行を追加

Board クラスにメソッドを追加

入れる場所

class Board: を見つけて、その(インデント4スペース)に下の2つを貼り付けます。
おすすめ位置:lock_piece() のすぐ下(「更新/確定」あたり)。

追加コード(そのまま貼り付け):

    def full_lines(self) -> list[int]:
        """すべてのマスが埋まっている行(満行)のインデックスを返す。"""
        return [
            y for y in range(self.rows)
            if all(self.grid[y][x] is not None for x in range(self.cols))
        ]

    def clear_lines(self) -> int:
        """満行を削除して上に空行を補充する。消した行数を返す。"""
        full = self.full_lines()
        if not full:
            return 0

        # 満行ではない行だけを残して下に詰める
        new_grid = [row for y, row in enumerate(self.grid) if y not in full]

        # 消えた行数ぶん、先頭(上側)に空行を追加
        for _ in range(len(full)):
            new_grid.insert(0, [None for _ in range(self.cols)])

        self.grid = new_grid
        return len(full)

⚠️ インデント注意:def full_lines / def clear_linesBoardクラスの中 なので、
class Board: より 1段下げ(空白4つ)で始めます。

② ピース固定後に「消去」を呼び出す

入れる場所

main() のゲームループで、重力のところに次のような処理があります(元コードの形):

if fall_timer >= fall_interval:
    fall_timer = 0
    if not current.move_down_once(board):
        # これ以上下へ行けない → 固定して次のピースへ
        board.lock_piece(current)
        current = Piece()

このうち、board.lock_piece(current)直後1行 だけ追加します。
(新しいピース current = Piece() を作るに呼ぶのがポイント)

変更後の完成形(差し替えてOK)

if fall_timer >= fall_interval:
    fall_timer = 0
    if not current.move_down_once(board):
        # これ以上下へ行けない → 固定
        board.lock_piece(current)

        # ★ここで行消去を実行(返り値は使わなくてOK)★
        board.clear_lines()

        # 次のピースへ
        current = Piece()

動作確認のコツ
  1. 手動でブロックを横に並べて1行埋め、下に落として固定する
  2. その行がシュッと消えて、上の行が下へ詰まることを確認
  3. 2行同時でも消える(今回のロジックで同時消しもOK)

よくあるつまずき
  • エラー:AttributeError: 'Board' object has no attribute 'clear_lines'
    → ②を先に書いて、①を入れ忘れている/インデントがずれてBoardの外に出ている可能性。
  • 消えない/ずれる
    → ②の board.clear_lines()current = Piece() の前 に入れているか確認。

ステップ4-2:この先は各自で何を追加するか考えてみよう

ここまでで、ほぼテトリスができていると思います。
次に何を追加したいのかは各自で考えて意見を出してみよう。そして、実現に向けて考えてみよう。実現方法については生成AIに相談してもOKです。

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