Python プログラミング教室

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

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

こちらのコードを活用してテトリスのような落ちもの系パズルゲームを作っていきます。

from __future__ import annotations
import sys, pygame, random

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

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)
BLUE = (0, 120, 255)

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)],
}

# ============================================================================
# クラス定義
# ============================================================================

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 にまとめる)
    """

    def __init__(self, kind=None, color=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) が基準位置)。list(p) にして後で回転で書き換えやすく。
        self.offsets = [list(p) for p in SHAPES[self.kind]]

        # 見た目としての 4 マス分のスプライトを生成し、描画グループに入れる
        self.blocks = []
        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):
        """現在の基準位置 (self.x, self.y) を反映した4マス分の
        グリッド座標 [(x,y), ...] を返す。
        衝突判定や固定処理のときに使う。"""
        return [(self.x + ox, self.y + oy) for (ox, oy) in self.offsets]

    def _hits_wall(self):
        """盤面の外に出ていないかを判定する。
        ※この段階では「固定ブロックとの衝突」は見ない(壁・床・天井のみ)。"""
        for (gx, gy) in self.world_cells():
            if not (0 <= gx < COLS and 0 <= gy < ROWS):
                return True
        return False

    def update_sprites(self):
        """内部状態(self.x, self.y, self.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 move(self, dx, dy):
        """ピースを (dx, dy) だけグリッド単位で移動させる。
        壁に当たるなどで移動できない場合は元に戻して False、成功なら True。"""
        old_x, old_y = self.x, self.y
        self.x += dx
        self.y += dy
        if self._hits_wall():
            # ダメなら元の位置に戻す(ロールバック)
            self.x, self.y = old_x, old_y
            return False
        self.update_sprites()
        return True

    def rotate_cw(self):
        """時計回りに回転する。
        点 (x, y) → (-y, x) の回転を各相対座標に適用。
        その後、簡易的な壁キック(左右に数マスずらす)で盤面内に収まるか試す。"""
        return self._rotate([[-oy, ox] for (ox, oy) in self.offsets])

    def rotate_ccw(self):
        """反時計回りに回転する。
        点 (x, y) → (y, -x) の回転を各相対座標に適用。
        その後、簡易的な壁キックで盤面内に収まるか試す。"""
        return self._rotate([[oy, -ox] for (ox, oy) in self.offsets])

    def _rotate(self, new_offsets):
        """回転後の相対座標(new_offsets)を一時的に適用し、
        入らなければ左右へ少しずらして(壁キック)成立を試みる。
        どれもダメなら元に戻して False、成功で True。"""
        old_offsets, old_x = self.offsets, self.x
        self.offsets = new_offsets

        # ずらし量の候補(0 → 左右1 → 左右2 の順で試す)
        for dx in [0, -1, 1, -2, 2]:
            self.x = old_x + dx
            if not self._hits_wall():
                self.update_sprites()
                return True

        # すべて失敗:回転を諦めて元に戻す
        self.offsets, self.x = old_offsets, old_x
        return False

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


# ============================================================================
# 各種関数
# ============================================================================

def draw_board(screen: pygame.Surface) -> None:
    """左側の盤面(白背景+マス目の線)を描画する。"""
    # 盤面の白い背景(左側だけを塗る)
    screen.fill(WHITE, (0, 0, CELL * COLS, HEIGHT))

    # 薄いグリッド線(縦線)
    for x in range(COLS + 1):
        pygame.draw.line(screen, GRAY, (x * CELL, 0), (x * CELL, HEIGHT))

    # 薄いグリッド線(横線)
    for y in range(ROWS + 1):
        pygame.draw.line(screen, GRAY, (0, y * CELL), (CELL * COLS, y * CELL))

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

def main() -> None:
    """ゲームの初期化・イベント処理・描画を行うメインループ。"""
    pygame.init()
    pygame.display.set_caption("落ちゲー")
    screen = pygame.display.set_mode((WIDTH, HEIGHT))
    clock = pygame.time.Clock()

    # 今落ちているピースを1つ生成
    current = Piece()  # 種類はランダム

    running = True
    while running:
        dt = clock.tick(FPS)  # 経過ミリ秒(将来、落下速度などに使える)
        for event in pygame.event.get():
            # ウィンドウ右上の「×」ボタンで終了
            if event.type == pygame.QUIT:
                running = False

            # キーが押されたとき:1マスずつ動かす(盤面の外に出ないように制限)
            elif event.type == pygame.KEYDOWN:
                if event.key == pygame.K_LEFT:
                    current.move(-1, 0)
                elif event.key == pygame.K_RIGHT:
                    current.move(1, 0)
                elif event.key == pygame.K_DOWN:
                    current.move(0, 1)
                elif event.key == pygame.K_UP:
                    current.rotate_cw()
                elif event.key == pygame.K_z:
                    current.rotate_ccw()

        # 背景を濃いグレーで塗り、盤面(白+グリッド)を描く
        screen.fill((30, 30, 30))
        draw_board(screen)
        
        # 現在のピースを描画
        current.draw(screen)

        # 画面に反映
        pygame.display.flip()

    # 後片付け
    pygame.quit()
    sys.exit()


# スクリプトとして直接実行されたときだけ main() を呼ぶ
if __name__ == "__main__":
    main()

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

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

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

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

ステップ3 

ステップ3-1:重力を実装(一定間隔で自動的に1マス落ちる)

  • やること:一定時間ごとに current.move(0, 1) を試す
  • 底や壁に当たったらそれ以上は落ちません(この段階では固定しない

変更点:

定数部分に落下間隔の定数を追加:

# 追加:落下間隔(ミリ秒)
FALL_MS = 700

メインループに「重力タイマー」を追加:

def main() -> None:
    """ゲームの初期化・イベント処理・描画を行うメインループ。"""
    pygame.init()
    pygame.display.set_caption("落ちゲー")
    screen = pygame.display.set_mode((WIDTH, HEIGHT))
    clock = pygame.time.Clock()
    # --- ここから追加:重力タイマー ---
    fall_timer = 0  # 経過ミリ秒をためる
    # --- 追加ここまで ---

    ・・・・・

    running = True
    while running:
        dt = clock.tick(FPS)  # 経過ミリ秒(将来、落下速度などに使える)
        for event in pygame.event.get():
            # ウィンドウ右上の「×」ボタンで終了
            if event.type == pygame.QUIT:
                running = False

            # キーが押されたとき:1マスずつ動かす(盤面の外に出ないように制限)
            elif event.type == pygame.KEYDOWN:
                ・・・・・

        # --- ここから追加:重力 ---
        fall_timer += dt
        if fall_timer >= FALL_MS:
            fall_timer = 0
            # 1マス落下(底や壁なら何も起きない)
            current.move(0, 1)
        # --- 追加ここまで ---

        # 背景を濃いグレーで塗り、盤面(白+グリッド)を描く
        screen.fill((30, 30, 30))
        draw_board(screen)

これで放っておいてもゆっくり落ちるようになります。
※まだ「固定」はしません。底についたらそれ以上は下がらないだけです。


ステップ3-2:固定を実装(固定後は次のブロックを生成)

追加①:盤面と当たり判定ヘルパー

下記コードを「各種関数」部分に追記

# ★追加:固定ブロックを保持する2次元配列(None=空/(R,G,B)=固定ブロックの色)
def empty_board():
    return [[None for _ in range(COLS)] for _ in range(ROWS)]

# ★追加:boardと壁を含めた当たり判定(dx,dy分だけ動かせるか?)
def can_place(board, piece, dx=0, dy=0, offsets=None):
    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 (0 <= gx < COLS and 0 <= gy < ROWS):
            return False
        # 既に固定ブロック?
        if board[gy][gx] is not None:
            return False
    return True

# ★追加:固定ブロック描画
def draw_fixed_from_board(screen, board):
    for y in range(ROWS):
        for x in range(COLS):
            color = board[y][x]
            if color is not None:
                rect = pygame.Rect(x*CELL, y*CELL, CELL-1, CELL-1)
                pygame.draw.rect(screen, color, rect)

追加②:main() の初期化に盤面を用意(1行だけ)

    fall_timer = 0  # 経過ミリ秒をためる
    board = empty_board()   # ★追加

置換③:重力処理に“固定→次ブロック”だけを追加(数行)

moveに失敗したら固定ではなく、先にcan_placeで落下可否を判定して、
落ちられない場合だけ boardへ書き込み→新ピース生成 にします。
(ステップ3-1のブロックを置き換え)

        # --- 重力(ステップ3-1のブロックを置き換え) ---
        fall_timer += dt
        if fall_timer >= FALL_MS:
            fall_timer = 0
            if can_place(board, current, dx=0, dy=1):
                current.move(0, 1)  # ← 既存のmove(壁のみチェック)をそのまま使用
            else:
                # ★固定:現在ピースの4マスをboardに反映
                for gx, gy in current.world_cells():
                    if 0 <= gx < COLS and 0 <= gy < ROWS:
                        board[gy][gx] = current.color
                # ★次のブロックを出す
                current = Piece()

変更④:描画に固定ブロックを1行追加

        # 背景を濃いグレーで塗り、盤面(白+グリッド)を描く
        screen.fill((30, 30, 30))
        draw_board(screen)
        draw_fixed_from_board(screen, board)  # ★追加:固定を先に描く
       
        # 現在のピースを描画
        current.draw(screen)

ステップ3-3:キー操作でめり込む部分を修正

置換:キー操作(左右・下・回転)を置換

既存の current.move(...) 呼び出しの直前can_place(...) を見て、通る時だけ move を実行します。
回転は簡易壁キック(0, -1, +1)だけ入れていますが、クラスは触りません

            # キーが押されたとき:1マスずつ動かす(盤面の外に出ないように制限)
            elif event.type == pygame.KEYDOWN:
                if event.key == pygame.K_LEFT:
                    if can_place(board, current, dx=-1, dy=0):
                        current.move(-1, 0)  # ← Pieceは壁判定のみの既存実装をそのまま使用
                elif event.key == pygame.K_RIGHT:
                    if can_place(board, current, dx=1, dy=0):
                        current.move(1, 0)
                elif event.key == pygame.K_DOWN:
                    if can_place(board, current, dx=0, dy=1):
                        current.move(0, 1)
                elif event.key == pygame.K_UP:
                    # 簡易:時計回り((x,y)->(-y,x))+ 壁キック[0,-1,+1]
                    new_offsets = [[-oy, ox] for (ox, oy) in current.offsets]
                    for kick in (0, -1, 1):
                        if can_place(board, current, dx=kick, dy=0, offsets=new_offsets):
                            current.x += kick
                            current.offsets = new_offsets
                            current.update_sprites()
                            break
                elif event.key == pygame.K_z:
                    # 反時計回り((x,y)->(y,-x))+ 壁キック
                    new_offsets = [[oy, -ox] for (ox, oy) in current.offsets]
                    for kick in (0, -1, 1):
                        if can_place(board, current, dx=kick, dy=0, offsets=new_offsets):
                            current.x += kick
                            current.offsets = new_offsets
                            current.update_sprites()
                            break

これでできること

  • 放置→自動で落下(従来通り)
  • 底や積みに当たると固定して次のブロック出現
  • 左右/下/回転も、固定ブロックに重ならない範囲のみ通る(最小実装)

※この段階では「ライン消去/ゲームオーバー判定/ネクスト表示/スコア」は未実装。
ステップを崩さず、次に安全に足せます。

ステップ3-4:ソフトドロップ(下キー長押しで落下加速)

  • やること:下キーを押している間だけ、落下間隔を短くする
  • 実装方法:pygame.key.get_pressed() で押下状態を常時チェックし、fall_interval を切り替える

差分:

① 定数の差し替え(ファイル上部・定数群の中)

変更前:

# 追加:落下間隔(ミリ秒)
FALL_MS = 700

変更後:

# 追加:2種類の落下間隔
FALL_MS_NORMAL = 700
FALL_MS_SOFT   = 80

② main() の完成形(ステップ3-2+ソフトドロップのみ追加)

前提:empty_board, can_place, draw_fixed_from_board, draw_board, Piece既に定義済み(ステップ3-2の最小変更版のまま)
下記 main() に置き換えれば、インデントはそのまま通ります。

def main() -> None:
    pygame.init()
    pygame.display.set_caption("落ちゲー")
    screen = pygame.display.set_mode((WIDTH, HEIGHT))
    clock = pygame.time.Clock()

    board = empty_board()
    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:
                    if can_place(board, current, dx=-1, dy=0):
                        current.move(-1, 0)
                elif event.key == pygame.K_RIGHT:
                    if can_place(board, current, dx=1, dy=0):
                        current.move(1, 0)
                elif event.key == pygame.K_DOWN:
                    # 単発の1マス移動(押しっぱなしの加速は下のソフトドロップで実現)
                    if can_place(board, current, dx=0, dy=1):
                        current.move(0, 1)
                elif event.key == pygame.K_UP:
                    # 時計回り回転 + 簡易壁キック
                    new_offsets = [[-oy, ox] for (ox, oy) in current.offsets]
                    for kick in (0, -1, 1):
                        if can_place(board, current, dx=kick, dy=0, offsets=new_offsets):
                            current.x += kick
                            current.offsets = new_offsets
                            current.update_sprites()
                            break
                elif event.key == pygame.K_z:
                    # 反時計回り回転 + 簡易壁キック
                    new_offsets = [[oy, -ox] for (ox, oy) in current.offsets]
                    for kick in (0, -1, 1):
                        if can_place(board, current, dx=kick, dy=0, offsets=new_offsets):
                            current.x += kick
                            current.offsets = new_offsets
                            current.update_sprites()
                            break

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

        # --- 重力(fall_interval を使用) ---
        fall_timer += dt
        if fall_timer >= fall_interval:
            fall_timer = 0
            if can_place(board, current, dx=0, dy=1):
                current.move(0, 1)
            else:
                # 固定 → 次のブロック
                for gx, gy in current.world_cells():
                    if 0 <= gx < COLS and 0 <= gy < ROWS:
                        board[gy][gx] = current.color
                current = Piece()

        # --- 描画 ---
        screen.fill((30, 30, 30))
        draw_board(screen)
        draw_fixed_from_board(screen, board)
        current.draw(screen)
        pygame.display.flip()

    pygame.quit()
    sys.exit()

補足

KEYDOWNK_DOWN1マス即時移動は残しています。
「押した瞬間に1マス進み、押しっぱなし中は高速落下」になります。
これが不要なら、その行(current.move(0, 1))をコメントアウトしてください。


ここまでの進捗まとめ

  • ベースコードをリファクタ→読みやすく拡張しやすい形に整備
  • 重力で自動落下
  • 固定して新しいブロックが出現
  • ソフトドロップで落下加速(長押し対応)

次の提案(この先の小分けステップ)

  1. ライン消去(1行そろったら削除して上から詰める)
  2. ゲームオーバー判定(出現直後に積みに当たったら終了)
  3. ネクスト表示(右パネルの黒背景に次の形をミニ表示)
  4. スコア(消した行数に応じて加点)
  5. ハードドロップ(スペースで一気に着地)
  6. ゴースト(影)表示

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