第5章
M5Stackでブロック崩しゲームを少しずつ作る
背景・文字・図形の描画から小さく動作確認を重ね、起動時にタッチか傾き操作を選べるブロック崩しを作ります。
この章の進め方
1つの breakout.py に少しずつコードを足します。自分で定義する関数は使わず、「初期設定 → 入力 → 移動 → 描画」の順に上から読める形で作ります。各ステップで実行してから次へ進みましょう。
最初はタッチ操作だけを作り、ステップ8で起動時の操作選択画面を追加します。それ以降は、起動するたびにTOUCHかTILTを選んで遊びます。プレイ中や再挑戦時は選択を維持し、操作方法を変えたいときは再実行します。
| ステップ | 作るもの |
|---|---|
| 1〜2 | 背景と文字 |
| 3〜4 | 長方形のパドルと丸いボール |
| 5〜6 | ブロック1個、横1列、3段 |
| 7 | タッチ入力とパドルの再描画 |
| 8 | 起動時の選択画面と傾き入力 |
| 9〜10 | ボール移動、壁とパドルでの反射 |
| 11〜12 | ブロックの消去、得点、残機、開始・終了 |
CoreS3-Lite、通信できるUSBケーブル、UIFlow 2.0を用意します。環境構築の章を済ませ、Python編集画面を使ってください。各ステップの後にPCにも保存します。
「追加」は指定した行の直後へ、「置き換え」は指定した範囲を丸ごと入れ替えます。字下げは半角スペース4個です。途中のコードもその段階で実行できます。変更箇所を全部反映してから「Run Once」で実行してください。
ステップ1:背景を塗る
新しい breakout.py に次の7行を入力します。ここでは画面を使える状態にし、背景を塗るだけです。
import M5
M5.begin()
lcd = M5.Lcd
lcd.setRotation(1)
BG = 0x101820
lcd.fillScreen(BG)
実行して確かめる
画面全体が濃い青になります。BG は色です。0x000000(黒)や 0x003366 に変え、違いを確かめてから元に戻します。
M5.begin() は本体の初期化、lcd は描画先です。320×240の横向き画面を使います。描画APIはDisplayの公式資料で確認できます。
ステップ2:文字を1行表示する
末尾へ次を追加します。
WHITE = 0xFFFFFF
lcd.setTextColor(WHITE, BG)
lcd.setTextSize(1)
lcd.drawString("Breakout", 8, 8)
実行して確かめる
左上に Breakout が表示されます。画面左上が (0, 0) で、右へ進むとX、下へ進むとYが増えます。
drawString(文字, X, Y) の 8, 8 を 40, 30 に変えるとどちらへ動くか、予想して試します。setTextSize(2) も試し、確認後はコードを掲載時の値へ戻してください。
ステップ3:長方形でパドルを描く
末尾へ追加します。パドルは、ボールを打ち返す横長の板です。
px = 128
paddle_y = 198
paddle_w = 64
lcd.fillRect(px, paddle_y, paddle_w, 8, 0x7CC7FF)
実行して確かめる
画面下側に水色の横長の板が1枚出ます。
fillRect(X, Y, 幅, 高さ, 色) は左上の位置と大きさを指定します。px は左端です。px = 20、paddle_w = 80 を別々に試し、位置と幅が別の設定であることを確かめます。次へ進む前に128・64へ戻します。
ステップ4:円でボールを描く
末尾へ追加します。
ball_x = 160
ball_y = 150
radius = 4
lcd.fillCircle(ball_x, ball_y, radius, WHITE)
実行して確かめる
パドルより上に白い丸が表示されます。まだ動きません。
fillCircle(中心X, 中心Y, 半径, 色) は中心を指定します。長方形の左上座標との違いに注目しましょう。radius = 8 で大きさを確認したら4へ戻します。
ステップ5:ブロックを1個描く
末尾へ追加します。パドルと同じ長方形の描画命令を使います。
lcd.fillRect(12, 40, 46, 14, 0xFF7868)
実行して確かめる
上側に赤いブロックが1個出ます。12, 40 は左上、46, 14 は幅と高さです。
同じ描画命令でも、位置・大きさ・色を変えることで別の部品になります。Yを60にしてパドルとの距離を比べたら、40へ戻してください。
ステップ6:ブロックを横に並べ、段を増やす
まず、ステップ5で追加した lcd.fillRect(12, 40, 46, 14, 0xFF7868) の1行を次に置き換えます。
for col in range(6):
brick_x = 12 + col * 50
lcd.fillRect(brick_x, 40, 46, 14, 0xFF7868)
ここで一度実行します。 ブロックが横に6個並びます。左端の間隔は50、幅は46なので、隙間は4ピクセルです。col は0〜5になります。
次に、今追加した for col から描画の行までの3行を次に置き換えます。
colors = (0xFF7868, 0xFFC857, 0x5AD6C7)
for row in range(3):
for col in range(6):
brick_x = 12 + col * 50
brick_y = 40 + row * 20
lcd.fillRect(brick_x, brick_y, 46, 14, colors[row])
実行して確かめる
6列×3段、合計18個が表示されます。外側の繰り返しが段、内側が列です。各段の高さ間隔20に対してブロックの高さは14なので、縦の隙間は6です。
色の順を入れ替えて再実行します。次のステップでは、ここまでの描画を繰り返し処理へ移してパドルを動かします。
ステップ7:タッチでパドルを動かし、描き直す
静止画からアニメーションに進みます。次の3か所を変更します。
① 先頭の import M5 の直後に追加します。
import time
② BG = 0x101820 の直後に追加します。キャンバスは画面と同じ大きさの「下書き用の画面」です。一度だけ用意します。
if (lcd.width(), lcd.height()) != (320, 240):
raise RuntimeError("Expected 320x240; check rotation")
canvas = lcd.newCanvas(320, 240, 16, True)
③ lcd.fillScreen(BG) の行からファイル末尾までを次に置き換えます。これまで描いた部品を while True: の中へ移し、描画先を lcd から canvas に変えています。1行ずつ、前の描画命令と対応させながら入力してください。
WHITE = 0xFFFFFF
px = 128
paddle_y = 198
paddle_w = 64
ball_x = 160
ball_y = 150
radius = 4
colors = (0xFF7868, 0xFFC857, 0x5AD6C7)
while True:
# 入力:下部の帯に触れたときだけパドルを動かす
M5.update()
down = M5.Touch.getCount() > 0
tx = M5.Touch.getX() if down else 0
ty = M5.Touch.getY() if down else 0
if down and ty >= 212:
px = max(0, min(320 - paddle_w, tx - paddle_w / 2))
# 描画:消す → 部品を描く → まとめて表示
canvas.fillRect(0, 0, 320, 240, BG)
canvas.setTextColor(WHITE, BG)
canvas.setTextSize(1)
canvas.drawString("Breakout", 8, 8)
for row in range(3):
for col in range(6):
brick_x = 12 + col * 50
brick_y = 40 + row * 20
canvas.fillRect(brick_x, brick_y, 46, 14, colors[row])
canvas.fillRect(int(px), paddle_y, paddle_w, 8, 0x7CC7FF)
canvas.fillCircle(int(ball_x), int(ball_y), radius, WHITE)
canvas.fillRect(0, 212, 320, 28, 0x253848)
canvas.setTextColor(WHITE, 0x253848)
canvas.drawString("SLIDE HERE", 8, 220)
canvas.push(0, 0)
time.sleep_ms(33)
実行して確かめる
下部の SLIDE HERE を指で滑らせると、パドルだけが左右に動きます。文字・ブロック・ボールはその場に残ります。指を離すとパドルが止まり、端でも画面からはみ出しません。
tx - paddle_w / 2 は、パドルの中心を指に合わせる計算です。max・min で左端を0〜256に収めています。
描画順を確かめる: 毎回背景で消し、すべての部品を描き直し、最後に push() します。これで古いパドルの跡が残りません。消去を含む途中の画面はキャンバス上で作るため、画面全体のちらつきを抑えられます。int() は小数の座標を描画用の整数に直します。
ステップ8:起動時にタッチか傾きかを選ぶ
ここから、同じプログラムに2つの操作方法を持たせます。起動時だけ選択画面を出し、選んだ後はゲームへ進みます。
① canvas = lcd.newCanvas(320, 240, 16, True) の直後へ追加します。まずは選択画面だけを動かします。
mode = None
lcd.fillScreen(BG)
lcd.setTextColor(0xFFFFFF, BG)
lcd.setTextSize(1)
lcd.drawString("Choose control", 8, 40)
lcd.fillRect(20, 100, 130, 60, 0x285880)
lcd.fillRect(170, 100, 130, 60, 0x285880)
lcd.setTextColor(0xFFFFFF, 0x285880)
lcd.drawString("TOUCH", 55, 125)
lcd.drawString("TILT", 215, 125)
while mode is None:
M5.update()
if M5.Touch.getCount() > 0:
tx = M5.Touch.getX()
ty = M5.Touch.getY()
if 100 <= ty < 160:
if 20 <= tx < 150:
mode = "touch"
elif 170 <= tx < 300:
mode = "tilt"
time.sleep_ms(20)
# 選択時のタッチを、ゲーム側に持ち越さない
while True:
M5.update()
if M5.Touch.getCount() == 0:
break
time.sleep_ms(20)
ここで実行します。 起動時に2つの長方形と文字が表示され、ボタンの外側では進みません。どちらかを触って指を離すとゲーム画面へ進みます。この時点ではTILTを選んでも操作はタッチのままです。
ボタンもステップ3の長方形とステップ2の文字の組み合わせです。左のボタンは 20 ≤ X < 150、右は 170 ≤ X < 300。描画した範囲とタッチ判定の範囲をそろえています。
② WHITE = 0xFFFFFF の直前に、次を追加します。
TILT_AXIS = 0 # 0=X、1=Y。持ち方に合わせる
TILT_SIGN = 1 # 左右が逆なら-1
neutral = 0.0
filtered = 0.0
if mode == "tilt":
lcd.fillScreen(BG)
lcd.setTextColor(0xFFFFFF, BG)
lcd.drawString("Hold still...", 8, 100)
time.sleep_ms(300)
total = 0
for _ in range(40):
M5.update()
total += M5.Imu.getAccel()[TILT_AXIS]
time.sleep_ms(10)
neutral = total / 40
③ ゲーム側の while True: 内で、if down and ty >= 212: から px = ... までの2行を次に置き換えます。選択画面の繰り返し処理は変更しません。
if mode == "touch":
if down and ty >= 212:
px = tx - paddle_w / 2
else:
value = (M5.Imu.getAccel()[TILT_AXIS] - neutral) * TILT_SIGN
filtered = 0.8 * filtered + 0.2 * value
tilt = 0 if abs(filtered) < 0.04 else filtered
px += tilt * 20
px = max(0, min(320 - paddle_w, px))
④ 描画部分の canvas.drawString("SLIDE HERE", 8, 220) の1行を次に置き換えます。
hint = "SLIDE HERE" if mode == "touch" else "TILT LEFT / RIGHT"
canvas.drawString(hint, 8, 220)
実行して確かめる
再実行し、TOUCHとTILTをそれぞれ試します。TILTを選んだら Hold still... の間に普段の持ち方で静止させ、その姿勢から少し左右に傾けます。右へ傾けて右に動くか確認してください。
左右が逆なら TILT_SIGN = -1、前後に傾けたときだけ動くなら TILT_AXIS = 1 を試します。neutral は選択直後の姿勢の基準、filtered は細かな揺れをならす値です。
この段階では傾きに応じて1回のループで動く量を決めています。ステップ9で経過時間を使う方式に変えます。操作方法や姿勢の基準を変えるときは再実行し、起動時に選び直します。ゲーム画面には切り替えボタンを作りません。
ステップ9:ボールを動かし、四方の壁で反射させる
① ゲーム側の while True: の直前に追加します。
vx = 70
vy = -110
last = time.ticks_ms()
② 同じ while True: の中で、# 入力: のコメントの直前に次を追加します。
now = time.ticks_ms()
dt = max(0, min(0.05, time.ticks_diff(now, last) / 1000))
last = now
dt は前回からの経過秒数です。最大0.05秒に制限し、一時的に処理が止まった後の大ジャンプを防ぎます。
③ 傾き操作の px += tilt * 20 の1行を次に置き換えます。字下げは同じです。
px += tilt * 650 * dt
④ # 描画: のコメントの直前へ追加します。
# 移動
ball_x += vx * dt
ball_y += vy * dt
if ball_x < radius:
ball_x = radius
vx = abs(vx)
elif ball_x > 320 - radius:
ball_x = 320 - radius
vx = -abs(vx)
if ball_y < 25 + radius:
ball_y = 25 + radius
vy = abs(vy)
elif ball_y > 240 - radius:
ball_y = 240 - radius
vy = -abs(vy)
実行して確かめる
ボールが左右・上下の壁で反射します。この段階ではパドルもブロックも通り抜けて構いません。
vx は横、vy は縦の速度(ピクセル/秒)です。正なら右・下、負なら左・上へ進みます。反射時には位置も内側へ戻します。
vx = 40 で動きがどう変わるか試し、70へ戻します。傾き操作も時間を掛ける方式に変わったので、描画の速さによる移動量の差を抑えられます。
ステップ10:パドルで打ち返す
ステップ9で追加した # 移動 から、描画コメント直前までを次に置き換えます。下の壁を取り除き、パドルとの接触と画面外への落下を調べます。
# 移動
ball_x += vx * dt
old_y = ball_y
ball_y += vy * dt
if ball_x < radius:
ball_x = radius
vx = abs(vx)
elif ball_x > 320 - radius:
ball_x = 320 - radius
vx = -abs(vx)
if ball_y < 25 + radius:
ball_y = 25 + radius
vy = abs(vy)
if (vy > 0 and old_y + radius <= paddle_y <= ball_y + radius
and ball_x + radius >= px
and ball_x - radius <= px + paddle_w):
ball_y = paddle_y - radius
vy = -abs(vy)
if ball_y - radius > 240:
ball_x = px + paddle_w / 2
ball_y = paddle_y - radius - 1
vx, vy = 70, -110
実行して確かめる
パドルで受けたときだけ上へ反射し、受け損ねるとボールがパドルの上へ戻ります。まだ自動で再発射し、何度でも遊べます。
old_y は移動前の高さです。「下へ進んでいる」「パドル上面をまたいだ」「横の範囲が重なる」の3条件を確認します。パドルの下から当たっても打ち返しません。
ここでは反射を理解しやすくするため、当たる位置によって角度を変える処理は入れていません。
ステップ11:ブロックを消して得点を表示する
① 最初の colors = ... の直後に追加します。描くだけだったブロックをリストで持ち、当たったものを削除できるようにします。
bricks = []
for row in range(3):
for col in range(6):
bricks.append([12 + col * 50, 40 + row * 20, colors[row]])
score = 0
② ゲームの描画部分にある for row in range(3): から、その内側の canvas.fillRect(...) までを次に置き換えます。初期化側の繰り返しは残します。
for bx, by, color in bricks:
canvas.fillRect(bx, by, 46, 14, color)
③ canvas.drawString("Breakout", 8, 8) の1行を次に置き換えます。
canvas.drawString("Score: " + str(score), 8, 8)
ここで実行します。 18個のブロックがこれまでと同じ位置・色で表示され、得点0が出れば、リストから描く変更は成功です。まだ消えません。
④ # 移動 から描画コメント直前までを、次に置き換えます。
# 移動:1回の移動量を2px以下に分ける
steps = max(1, int(max(abs(vx), abs(vy)) * dt / 2) + 1)
for _ in range(steps):
old_x, old_y = ball_x, ball_y
ball_x += vx * dt / steps
ball_y += vy * dt / steps
if ball_x < radius:
ball_x, vx = radius, abs(vx)
elif ball_x > 320 - radius:
ball_x, vx = 320 - radius, -abs(vx)
if ball_y < 25 + radius:
ball_y, vy = 25 + radius, abs(vy)
for i, (bx, by, color) in enumerate(bricks):
if (ball_x + radius > bx and ball_x - radius < bx + 46
and ball_y + radius > by and ball_y - radius < by + 14):
if old_y + radius <= by:
ball_y, vy = by - radius, -abs(vy)
elif old_y - radius >= by + 14:
ball_y, vy = by + 14 + radius, abs(vy)
elif old_x < bx:
ball_x, vx = bx - radius, -abs(vx)
else:
ball_x, vx = bx + 46 + radius, abs(vx)
del bricks[i]
score += 10
break
if (vy > 0 and old_y + radius <= paddle_y <= ball_y + radius
and ball_x + radius >= px
and ball_x - radius <= px + paddle_w):
ball_y, vy = paddle_y - radius, -abs(vy)
if ball_y - radius > 240:
ball_x = px + paddle_w / 2
ball_y = paddle_y - radius - 1
vx, vy = 70, -110
break
実行して確かめる
ブロックが1個消えるたびに10点増え、ミスしても得点と残りのブロックが維持されます。全部消すと180点ですが、まだボールは動き続けます。
衝突はボールを囲む正方形とブロックの重なりで簡易判定しています。角での厳密な円の衝突ではありません。移動を細かく分けて、ブロックを飛び越える「すり抜け」を減らします。
del bricks[i] でリストから消すと、描画ループでも描かれなくなります。score += 10 で変えた値を文字にして表示しています。
ステップ12:開始待ち・残機・終了表示を加える
① ゲーム側の while True: の直前に追加します。起動時の選択画面のループはそのままです。
restart = True
was_down = False
② ゲーム側の while True: の直後、now = time.ticks_ms() の前へ追加します。初回と再挑戦時だけ、ゲームの値を戻します。
if restart:
px = (320 - paddle_w) / 2
ball_x = px + paddle_w / 2
ball_y = paddle_y - radius - 1
vx, vy = 70, -110
bricks = []
for row in range(3):
for col in range(6):
bricks.append([12 + col * 50, 40 + row * 20, colors[row]])
score = 0
lives = 3
state = "ready"
restart = False
③ ty = M5.Touch.getY() if down else 0 の直後へ追加します。同じ行が選択画面にはないことを確認してください。
pressed = down and not was_down
was_down = down
if pressed and 25 <= ty < paddle_y:
if state == "ready":
state = "play"
elif state in ("clear", "over"):
restart = True
continue
④ ゲーム内の if mode == "touch": から px = max(...) までを次に置き換えます。READYのときはボールもパドルについて動くようにします。
if state in ("ready", "play"):
if mode == "touch":
if down and ty >= 212:
px = tx - paddle_w / 2
else:
value = (M5.Imu.getAccel()[TILT_AXIS] - neutral) * TILT_SIGN
filtered = 0.8 * filtered + 0.2 * value
tilt = 0 if abs(filtered) < 0.04 else filtered
px += tilt * 650 * dt
px = max(0, min(320 - paddle_w, px))
if state == "ready":
ball_x = px + paddle_w / 2
ball_y = paddle_y - radius - 1
⑤ ステップ11の # 移動: から描画コメント直前までを次に置き換えます。移動するのはplayのときだけです。
# 移動
if state == "play":
# 移動:1回の移動量を2px以下に分ける
steps = max(1, int(max(abs(vx), abs(vy)) * dt / 2) + 1)
for _ in range(steps):
old_x, old_y = ball_x, ball_y
ball_x += vx * dt / steps
ball_y += vy * dt / steps
if ball_x < radius:
ball_x, vx = radius, abs(vx)
elif ball_x > 320 - radius:
ball_x, vx = 320 - radius, -abs(vx)
if ball_y < 25 + radius:
ball_y, vy = 25 + radius, abs(vy)
for i, (bx, by, color) in enumerate(bricks):
if (ball_x + radius > bx and ball_x - radius < bx + 46
and ball_y + radius > by and ball_y - radius < by + 14):
if old_y + radius <= by:
ball_y, vy = by - radius, -abs(vy)
elif old_y - radius >= by + 14:
ball_y, vy = by + 14 + radius, abs(vy)
elif old_x < bx:
ball_x, vx = bx - radius, -abs(vx)
else:
ball_x, vx = bx + 46 + radius, abs(vx)
del bricks[i]
score += 10
break
if not bricks:
state = "clear"
break
if (vy > 0 and old_y + radius <= paddle_y <= ball_y + radius
and ball_x + radius >= px
and ball_x - radius <= px + paddle_w):
ball_y, vy = paddle_y - radius, -abs(vy)
if ball_y - radius > 240:
lives -= 1
state = "over" if lives == 0 else "ready"
if state == "ready":
ball_x = px + paddle_w / 2
ball_y = paddle_y - radius - 1
vx, vy = 70, -110
break
⑥ 得点表示の canvas.drawString("Score: " + str(score), 8, 8) を次の1行に置き換えます。
canvas.drawString("Score:%d Lives:%d %s" % (score, lives, mode.upper()), 8, 8)
⑦ canvas.push(0, 0) の直前に追加します。最初に背景色の長方形を描き、その上に状態の文字を重ねます。
if state != "play":
if state == "ready":
message = "READY: tap field to start"
elif state == "clear":
message = "CLEAR! Tap field to retry"
else:
message = "GAME OVER: tap field to retry"
canvas.fillRect(4, 112, 312, 28, BG)
canvas.setTextColor(WHITE, BG)
canvas.drawString(message, 8, 122)
実行して確かめる
起動時に操作方法を選んだあと、READYでボールが止まります。盤面を新しくタップすると発射し、ミスしたら残機が1減ってREADYに戻ります。3回ミスでGAME OVER、全消去でCLEARです。
終了後に指を離して盤面をタップすると、同じ操作方法のまま得点0・残機3・ブロック18個に戻ります。もう一度タップして発射します。TOUCH/TILTの再選択はプログラムの再実行時です。
描画を振り返る: 状態表示も「長方形で下地 → 色を指定 → 文字」という、最初に学んだ組み合わせです。canvas.push() より前に描くことで、そのフレームに表示されます。
完成後の確認
| 操作 | 確認する結果 |
|---|---|
| 起動する | 操作選択が出る。ボタン以外では先へ進まない |
| TOUCHを選んで指を離す | READYになる。下部の帯でパドルが動く |
| 再実行してTILTを選ぶ | 基準測定の後にREADYとなり、傾きで動く |
| READYでパドルを動かす | ボールもパドルの上について動く |
| 盤面をタップ | 発射する。触れ続けるだけで再発射しない |
| 壁・パドル・ブロックに当てる | 反射し、ブロックだけが消える |
| 1回落とす | 残機だけが1減り、得点とブロックは維持される |
| 3回落とす | GAME OVERになる |
| 全消去 | 180点でCLEARになる |
| 再挑戦 | 同じ操作方式で最初から遊べる |
本体へ保存する場合は、動作確認後に「Run Always」を使います。UIFlow 2.0の公式手順も参照してください。表示や操作感は実機で確かめ、使用ファームウェアと調整値をメモします。
途中でうまく動かないとき
| 症状 | 確認すること |
|---|---|
| 画面に出ない | M5.begin()、画面サイズ・回転、ステップ7以降は canvas.push() |
| 描画の跡が残る | 背景の消去が毎フレームの描画の最初にあるか |
| 文字の背景色が変 | 直前の setTextColor() の背景色を確認する |
| ボタンを押しても進まない | ステップ8の座標範囲と、指を離す処理を確認する |
| TILTでもタッチの動きになる | ステップ8の②〜④まで反映したか |
| 傾きの方向が違う・流れる | 軸・符号、基準測定時の姿勢を確認する。必要なら再実行する |
| NameError | その変数の追加位置と、そのステップの全変更を確認する |
| IndentationError | if・for・while の内側を半角4スペースずつ下げているか |
| ボールが部品を通り抜ける | ステップ9までは仕様。以降は該当の当たり判定を確認する |
| キャンバスのAPI・メモリのエラー | CoreS3用UIFlow 2.0の版とPSRAM対応を確認し、再起動する |
発展課題
- パドルの幅とボールの速度を1つずつ変え、難しさを比べる。
- ブロックの耐久値を追加し、2回当たると消えるものを作る。
- パドルに当たった位置によって打ち返す角度を変える。
- 処理を理解できたら、繰り返し出てくる初期化や描画を自分で関数にまとめてみる。