前回に続いて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マスのスプライトを登場させる
ステップ2 4マスのピースを登場させる
ステップ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_lines は Boardクラスの中 なので、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行埋め、下に落として固定する
- その行がシュッと消えて、上の行が下へ詰まることを確認
- 2行同時でも消える(今回のロジックで同時消しもOK)
よくあるつまずき
- エラー:AttributeError: 'Board' object has no attribute 'clear_lines'
→ ②を先に書いて、①を入れ忘れている/インデントがずれてBoardの外に出ている可能性。 - 消えない/ずれる
→ ②のboard.clear_lines()をcurrent = Piece()の前 に入れているか確認。
ステップ4-2:この先は各自で何を追加するか考えてみよう
ここまでで、ほぼテトリスができていると思います。
次に何を追加したいのかは各自で考えて意見を出してみよう。そして、実現に向けて考えてみよう。実現方法については生成AIに相談してもOKです。