「今どの画面か」を変数 1 つで覚えて、処理を振り分ける
お化けに捕まったら GAME OVER の文字が出て、動きが止まるようにします。 まだやり直しはできません(それはフェーズ 7 です)。 このフェーズは「画面を切りかえるしくみ」を作ることが目的です。
書き足すのは 20 行ほどですが、step1 でいちばん大事な考え方が出てきます。
step1 概要の「土台 2」で予告した
scene という変数で画面を覚えるやり方です。
これができると、フェーズ 7 のタイトル画面は同じ形をもう 1 つ足すだけになります。
タイトル・ゲーム・ゲームオーバーの 3 画面は、いつでもどれか 1 つだけが映っています。 2 つ同時ということはありません。「同時に 1 つしか成り立たないもの」は、変数 1 つで表せます。
そこで self.scene という変数を用意し、そこへ
「いまはゲーム中」「いまはゲームオーバー」を入れておきます。
そして update() と draw() の入口で振り分けるだけです。
scene の値を書きかえるだけで、次のフレームから中身が入れかわる
self.scene = 1 と書いても動きますが、あとから読んだときに
1 が何なのか分かりません。そこで名前を付けます。
SCENE_PLAY = 0 # あそんでいる画面
SCENE_GAMEOVER = 1 # ゲームオーバーの画面
こうしておくと if self.scene == SCENE_GAMEOVER: が
そのまま日本語のように読めます。これはフェーズ 3 の is_hit() や
フェーズ 4 の BOY_SIZE と同じ考え方で、
「読んで分かる名前を付ける」というだけのことです。
0 と 1 はただの区別のための番号です。
10 と 20 でも、100 と 7 でも動きます。
だからフェーズ 7 でタイトル画面を足すときも、順番を気にせず
SCENE_TITLE = 2 と足せば済みます。
番号を振り直す必要はありません。名前で読むからです。
ゲームオーバーにしたら、男の子もお化けも止めたくなります。 ここで 「止める処理」を書く必要はありません。
def update(self):
if self.scene == SCENE_PLAY:
self.update_play()
# SCENE_GAMEOVER のときは、何も呼ばない → 何も動かない
そもそも「動く」というのは update() の中で座標を書きかえているからです。
呼ばなければ、座標は変わりません。止めるための特別な仕掛けはいりません。
判定そのものはフェーズ 3 で書いた is_hit() をそのまま使います。
相手をお菓子からお化けに変えるだけです。
このときお菓子と同じ HIT_SIZE を使います。理由は、
備忘録でお菓子について調べたことが
そのまま当てはまるからです。8×8 のままだと
当たる位置の 36% は、絵が 1 ドットも重なっていません。
お菓子なら「取れて嬉しい」で済みますが、お化けは「かすっただけで死んだ」になります。
理不尽に感じるのは、こちらのほうがずっと強いので、判定はそろえておきます。
is_hit() を、3 か所目に使いまわします
① お菓子を置く場所を決めるとき(8×8・広め)、② お菓子を取ったか(4×4・狭め)、
③ お化けに捕まったか(4×4・狭め)。
目的によって渡す四角の大きさを変えるだけで、関数は 1 つのままです。
フェーズ 3 で is_hit() を class の外に置いておいた甲斐がありました。
新しい API は pyxel.text() だけですが、それもフェーズ 4 で使ったものです。
新しいのは「考え方」のほうです。
| 機能 | 役割 |
|---|---|
| 定数で状態を表す | SCENE_PLAY / SCENE_GAMEOVER |
if による振り分け | update() と draw() の入口で分ける |
is_hit()(再利用) | お化けに捕まったかを調べる |
len() | 文字数を数えて、中央ぞろえの位置を計算する |
pyxel.text() | GAME OVER の文字を描く |
pyxel.COLOR_RED | 色の番号 8。8 と書いてもよいが、名前のほうが読みやすい |
HIT_OFFSET の下に、空行をはさんで足します。
HIT_SIZE = 4 # 当たり判定に使う四角の大きさ(絵の中央だけを見る)
HIT_OFFSET = (BOY_SIZE - HIT_SIZE) // 2 # 絵の左上から、判定の四角までの距離(= 2)
SCENE_PLAY = 0 # あそんでいる画面
SCENE_GAMEOVER = 1 # ゲームオーバーの画面
is_hit() の下に置きます。class の外です。
def draw_center(s, y, col):
"""文字列を画面の横中央に描く"""
x = (SCREEN_WIDTH - len(s) * 4) // 2
pyxel.text(x, y, s, col)
4 という数字はフォント 1 文字ぶんの幅です。
フェーズ 4 で測った値で、
Pyxel の標準フォントは 1 文字が 4 ドットずつ右へ進みます。
self.scene を用意する
__init__() の中、ほかの変数より先に置きます。
self.scene = SCENE_PLAY
self.boy_x = SCREEN_WIDTH // 2 - BOY_SIZE // 2
self.boy_y = SCREEN_HEIGHT // 2 - BOY_SIZE // 2
update() を 2 つに分ける
いまの update() の中身を、そっくり update_play() へ引っこ抜きます。
そして update() は振り分けるだけにします。
def update(self):
"""フレーム毎の更新処理"""
if self.scene == SCENE_PLAY:
self.update_play()
def update_play(self):
"""あそんでいる間の更新"""
self.move_boy()
self.move_ghost()
self.check_candy()
self.check_ghost() # ← 1 行増える
check_candy() の下に足します。check_candy() とほとんど同じ形です。
def check_ghost(self):
"""お化けに捕まったらゲームオーバーにする"""
if is_hit(self.boy_x + HIT_OFFSET, self.boy_y + HIT_OFFSET, HIT_SIZE,
self.ghost_x + HIT_OFFSET, self.ghost_y + HIT_OFFSET, HIT_SIZE):
self.scene = SCENE_GAMEOVER
draw() を分けて、文字を重ねる
draw() も同じように分けます。ただし update() とは違い、
ゲームオーバーでも draw_play() は呼びます。
止まった盤面を見せたままにするためです。
def draw(self):
"""フレーム毎の描画処理"""
pyxel.cls(pyxel.COLOR_BLACK)
self.draw_play()
if self.scene == SCENE_GAMEOVER:
self.draw_gameover()
def draw_play(self):
"""あそんでいる画面を描く"""
# ここは今までの draw() の中身をそのまま(cls は除く)
def draw_gameover(self):
"""ゲームオーバーの文字を重ねて描く"""
draw_center("GAME OVER", 52, pyxel.COLOR_RED)
draw_center(f"SCORE {self.score}", 66, pyxel.COLOR_WHITE)
cls() は draw() に残します
draw_play() へ一緒に持っていってしまうと、
画面を消す処理が「あそんでいる画面を描く」の一部になってしまいます。
画面を消すのはどの画面でも必ずやることなので、入口の draw() に置きます。
ここまでを反映した全体です。増えたのは 20 行ほどです。
# candy_hunt.py
# step1 フェーズ6:お化けに捕まったらゲームオーバー
import random
import pyxel
SCREEN_WIDTH = 160
SCREEN_HEIGHT = 120
BOY_SIZE = 8 # 男の子の絵の大きさ(縦横とも 8 ドット)
BOY_SPEED = 2 # 1 フレームで進むドット数
CANDY_SIZE = 8 # お菓子の絵の大きさ
GHOST_SIZE = 8 # お化けの絵の大きさ
GHOST_SPEED = 1 # 1 フレームで進むドット数(男の子の半分)
HIT_SIZE = 4 # 当たり判定に使う四角の大きさ(絵の中央だけを見る)
HIT_OFFSET = (BOY_SIZE - HIT_SIZE) // 2 # 絵の左上から、判定の四角までの距離(= 2)
SCENE_PLAY = 0 # あそんでいる画面
SCENE_GAMEOVER = 1 # ゲームオーバーの画面
def is_hit(x1, y1, size1, x2, y2, size2):
"""2 つの四角が重なっていれば True を返す"""
return (x1 < x2 + size2 and x2 < x1 + size1 and
y1 < y2 + size2 and y2 < y1 + size1)
def draw_center(s, y, col):
"""文字列を画面の横中央に描く"""
x = (SCREEN_WIDTH - len(s) * 4) // 2
pyxel.text(x, y, s, col)
class App:
def __init__(self):
"""起動時の設定"""
pyxel.init(SCREEN_WIDTH, SCREEN_HEIGHT, title="Candy Hunt")
pyxel.load("candy_hunt.pyxres")
self.scene = SCENE_PLAY
self.boy_x = SCREEN_WIDTH // 2 - BOY_SIZE // 2
self.boy_y = SCREEN_HEIGHT // 2 - BOY_SIZE // 2
self.ghost_x = 0
self.ghost_y = 0
self.score = 0
self.place_candy()
pyxel.run(self.update, self.draw)
def update(self):
"""フレーム毎の更新処理"""
if self.scene == SCENE_PLAY:
self.update_play()
def update_play(self):
"""あそんでいる間の更新"""
self.move_boy()
self.move_ghost()
self.check_candy()
self.check_ghost()
def move_boy(self):
"""矢印キーで男の子を動かす"""
if pyxel.btn(pyxel.KEY_LEFT):
self.boy_x -= BOY_SPEED
if pyxel.btn(pyxel.KEY_RIGHT):
self.boy_x += BOY_SPEED
if pyxel.btn(pyxel.KEY_UP):
self.boy_y -= BOY_SPEED
if pyxel.btn(pyxel.KEY_DOWN):
self.boy_y += BOY_SPEED
self.boy_x = max(0, min(self.boy_x, SCREEN_WIDTH - BOY_SIZE))
self.boy_y = max(0, min(self.boy_y, SCREEN_HEIGHT - BOY_SIZE))
def move_ghost(self):
"""お化けを男の子に近づける"""
if self.ghost_x < self.boy_x:
self.ghost_x += GHOST_SPEED
elif self.ghost_x > self.boy_x:
self.ghost_x -= GHOST_SPEED
if self.ghost_y < self.boy_y:
self.ghost_y += GHOST_SPEED
elif self.ghost_y > self.boy_y:
self.ghost_y -= GHOST_SPEED
def place_candy(self):
"""男の子と重ならない場所へ、お菓子を置きなおす"""
while True:
self.candy_x = random.randint(0, SCREEN_WIDTH - CANDY_SIZE)
self.candy_y = random.randint(0, SCREEN_HEIGHT - CANDY_SIZE)
if not is_hit(self.boy_x, self.boy_y, BOY_SIZE,
self.candy_x, self.candy_y, CANDY_SIZE):
break
def check_candy(self):
"""お菓子にしっかり重なったら、スコアを増やして次の場所に置きなおす"""
if is_hit(self.boy_x + HIT_OFFSET, self.boy_y + HIT_OFFSET, HIT_SIZE,
self.candy_x + HIT_OFFSET, self.candy_y + HIT_OFFSET, HIT_SIZE):
self.score += 1
self.place_candy()
def check_ghost(self):
"""お化けに捕まったらゲームオーバーにする"""
if is_hit(self.boy_x + HIT_OFFSET, self.boy_y + HIT_OFFSET, HIT_SIZE,
self.ghost_x + HIT_OFFSET, self.ghost_y + HIT_OFFSET, HIT_SIZE):
self.scene = SCENE_GAMEOVER
def draw(self):
"""フレーム毎の描画処理"""
pyxel.cls(pyxel.COLOR_BLACK)
self.draw_play()
if self.scene == SCENE_GAMEOVER:
self.draw_gameover()
def draw_play(self):
"""あそんでいる画面を描く"""
pyxel.blt(self.candy_x, self.candy_y, 0, 16, 0,
CANDY_SIZE, CANDY_SIZE, pyxel.COLOR_BLACK) # お菓子
pyxel.blt(self.ghost_x, self.ghost_y, 0, 8, 0,
GHOST_SIZE, GHOST_SIZE, pyxel.COLOR_BLACK) # お化け
pyxel.blt(self.boy_x, self.boy_y, 0, 0, 0,
BOY_SIZE, BOY_SIZE, pyxel.COLOR_BLACK) # 男の子
pyxel.text(4, 4, f"SCORE {self.score}", pyxel.COLOR_WHITE)
def draw_gameover(self):
"""ゲームオーバーの文字を重ねて描く"""
draw_center("GAME OVER", 52, pyxel.COLOR_RED)
draw_center(f"SCORE {self.score}", 66, pyxel.COLOR_WHITE)
App()
draw_center() が class の外にある理由
is_hit() とまったく同じ理由です。この関数は
self をひとつも使っていません。
def draw_center(s, y, col):
x = (SCREEN_WIDTH - len(s) * 4) // 2
pyxel.text(x, y, s, col)
使っているのは渡された 3 つの値と、定数 SCREEN_WIDTH だけです。
男の子の位置もスコアも見ていません。self がいらない処理は
class の外に置ける——これが判断の基準になります。
self. が 1 つも出てこなければ、外に出せます。
逆に check_ghost() は self.boy_x や self.scene を
触るので、class の中でなければ書けません。
(SCREEN_WIDTH - len(s) * 4) // 2 の意味を "GAME OVER" で追います。
len(s) * 4 - 1 ではない理由
文字の実際の幅は最後の 1 文字ぶんの字間を含まないので 35 ドットです。
ただ、中央ぞろえの式に -1 まで入れても
(160 - 35) // 2 = 62 となり結果は同じになります。
1 ドットの差は // 2 で消えるので、短いほうで書いています。
draw() は if の外で draw_play() を呼んでいます。
pyxel.cls(pyxel.COLOR_BLACK)
self.draw_play() # ← いつも描く
if self.scene == SCENE_GAMEOVER:
self.draw_gameover() # ← そのうえに重ねる
こうしないと、捕まった瞬間に画面が真っ黒になって 「どこで捕まったのか」が分からなくなります。 止まった盤面が残っていれば、プレイヤーは「あ、そこにいたのか」と納得できます。
draw_gameover() をあとに呼ぶので、
文字が絵の上に出ます。逆にすると、絵が文字を隠してしまいます。
check_ghost() を check_candy() のあとに呼ぶ
update_play() の並びは 動かす → お菓子 → お化け の順です。
self.move_boy()
self.move_ghost()
self.check_candy()
self.check_ghost()
お菓子とお化けに同時に重なるフレームがあり得ます。この順番なら スコアが 1 増えてからゲームオーバーになります。逆にすると、 取ったはずのお菓子が数えられません。プレイヤーに有利なほうを先に置いています。
pyxel.frame_count は起動してからのフレーム数が入っている変数です。
これを使うと、変数を増やさずに点滅が作れます。
def draw_gameover(self):
"""ゲームオーバーの文字を重ねて描く"""
if pyxel.frame_count % 30 < 20: # 30 フレームのうち 20 だけ描く
draw_center("GAME OVER", 52, pyxel.COLOR_RED)
draw_center(f"SCORE {self.score}", 66, pyxel.COLOR_WHITE)
%(あまり)を使うと、0〜29 をくり返す数が作れます。
そのうち 0〜19 のときだけ描けば、1 秒の周期で 3 分の 2 だけ光ることになります。
スコアは点滅させないほうが読みやすいので、if の外に出しています。
すぐ文字が出るとあっけないので、少し待ってから出す手もあります。 捕まったフレームを覚えておいて、そこからの経過を見ます。
def check_ghost(self):
"""お化けに捕まったらゲームオーバーにする"""
if is_hit(self.boy_x + HIT_OFFSET, self.boy_y + HIT_OFFSET, HIT_SIZE,
self.ghost_x + HIT_OFFSET, self.ghost_y + HIT_OFFSET, HIT_SIZE):
self.scene = SCENE_GAMEOVER
self.gameover_frame = pyxel.frame_count # 捕まった時刻を覚える
def draw_gameover(self):
"""ゲームオーバーの文字を重ねて描く"""
if pyxel.frame_count - self.gameover_frame < 15: # 0.5 秒は文字を出さない
return
draw_center("GAME OVER", 52, pyxel.COLOR_RED)
draw_center(f"SCORE {self.score}", 66, pyxel.COLOR_WHITE)
return だけを書くと「ここで関数をやめる」
値を書かない return は、その先を実行せずに関数を抜けるという意味になります。
if のネストを深くしないための書き方です。
| 症状 | 見るところ |
|---|---|
| 捕まっても何も起きない | update_play() に self.check_ghost() を足したか確認します。
update() のほうに足しても呼ばれません |
| 始めた瞬間にゲームオーバーになる | check_ghost() で比べる相手が candy_x のままになっていませんか。
check_candy() をコピーして作ると直し忘れやすいところです |
| GAME OVER が出ても動けてしまう | update() の if の中に self.update_play() が
入っているか確認します。インデントが 1 段浅いと、いつでも呼ばれてしまいます |
| 捕まると画面が真っ黒になる | self.draw_play() を if の中に入れてしまっています。
if の外で、いつも呼びます |
| 文字が中央からずれる | // 2 が / 2 になっていませんか。
/ だと小数になります(備忘録 #floor-div) |
| 文字が絵に隠れて見えない | draw_gameover() を draw_play() より先に呼んでいます。
あとに呼んだものが上に描かれます |
NameError: name 'SCENE_PLAY' is not defined |
定数の定義が class より下にあります。
定数はファイルの先頭にまとめます(備忘録 #def-order) |
AttributeError: ... 'scene' |
__init__() の self.scene = SCENE_PLAY を書き忘れているか、
pyxel.run() よりあとに書いています |
画面を切りかえるしくみができました。次はタイトル画面を足して 3 画面にし、
もう一度あそべるようにします。
やることはこのフェーズと同じ形をもう 1 つ増やすだけ——
SCENE_TITLE を足し、update() と draw() に枝をもう 1 本生やします。
新しく出てくるのは「やり直すときに、変数を初期状態へ戻す」という話です。
スコアもお化けの位置も前回のままでは困るので、
__init__() に書いた初期化を呼び直せる形に整理します。
GHOST_SPEED = 1 は、全力で逃げ続けても 5 秒で捕まる速さです
(実際に数えました → 備忘録 #chase-speed)。
いまは 1 のままで進み、お化けが増えるフェーズ 8 であらためて調整します。