フェーズ 6 捕まったらゲームオーバー

「今どの画面か」を変数 1 つで覚えて、処理を振り分ける

トップページstep1 概要 / フェーズ 6

ゴール

お化けに捕まったら GAME OVER の文字が出て、動きが止まるようにします。 まだやり直しはできません(それはフェーズ 7 です)。 このフェーズは「画面を切りかえるしくみ」を作ることが目的です。

書き足すのは 20 行ほどですが、step1 でいちばん大事な考え方が出てきます。 step1 概要の「土台 2」で予告した scene という変数で画面を覚えるやり方です。 これができると、フェーズ 7 のタイトル画面は同じ形をもう 1 つ足すだけになります。

1. 考え方

1-1. 「今どの画面か」は、変数 1 つで表せる

タイトル・ゲーム・ゲームオーバーの 3 画面は、いつでもどれか 1 つだけが映っています。 2 つ同時ということはありません。「同時に 1 つしか成り立たないもの」は、変数 1 つで表せます。

そこで self.scene という変数を用意し、そこへ 「いまはゲーム中」「いまはゲームオーバー」を入れておきます。 そして update()draw()入口で振り分けるだけです。

1 フレームのあいだに起きること scene == SCENE_PLAY あそんでいるとき update() └ update_play() move_boy / move_ghost check_candy / check_ghost ← ここで捕まると scene が変わる draw() ├ cls() └ draw_play() お菓子・お化け・男の子・スコア (ゲームオーバーの文字は描かない) 捕まった scene == SCENE_GAMEOVER 捕まったあと update() (何もしない) 動かす処理を呼ばないので、 男の子もお化けも止まる draw() ├ cls() ├ draw_play() └ draw_gameover() 止まった画面の上に文字を重ねる
scene の値を書きかえるだけで、次のフレームから中身が入れかわる

1-2. 数字のままにせず、名前を付ける

self.scene = 1 と書いても動きますが、あとから読んだときに 1 が何なのか分かりません。そこで名前を付けます。

SCENE_PLAY = 0          # あそんでいる画面
SCENE_GAMEOVER = 1      # ゲームオーバーの画面

こうしておくと if self.scene == SCENE_GAMEOVER:そのまま日本語のように読めます。これはフェーズ 3 の is_hit() や フェーズ 4 の BOY_SIZE と同じ考え方で、 「読んで分かる名前を付ける」というだけのことです。

値そのものに意味はありません 01ただの区別のための番号です。 1020 でも、1007 でも動きます。 だからフェーズ 7 でタイトル画面を足すときも、順番を気にせず SCENE_TITLE = 2 と足せば済みます。 番号を振り直す必要はありません。名前で読むからです。

1-3. 「何も書かない」ことが、そのまま「止まる」になる

ゲームオーバーにしたら、男の子もお化けも止めたくなります。 ここで 「止める処理」を書く必要はありません

    def update(self):
        if self.scene == SCENE_PLAY:
            self.update_play()
        # SCENE_GAMEOVER のときは、何も呼ばない → 何も動かない

そもそも「動く」というのは update() の中で座標を書きかえているからです。 呼ばなければ、座標は変わりません。止めるための特別な仕掛けはいりません。

1-4. 捕まる判定は、お菓子と同じ広さにそろえる

判定そのものはフェーズ 3 で書いた is_hit() をそのまま使います。 相手をお菓子からお化けに変えるだけです。

このときお菓子と同じ HIT_SIZE を使います。理由は、 備忘録でお菓子について調べたことが そのまま当てはまるからです。8×8 のままだと 当たる位置の 36% は、絵が 1 ドットも重なっていません。 お菓子なら「取れて嬉しい」で済みますが、お化けは「かすっただけで死んだ」になります。 理不尽に感じるのは、こちらのほうがずっと強いので、判定はそろえておきます。

同じ is_hit() を、3 か所目に使いまわします ① お菓子を置く場所を決めるとき(8×8・広め)、② お菓子を取ったか(4×4・狭め)、 ③ お化けに捕まったか(4×4・狭め)。 目的によって渡す四角の大きさを変えるだけで、関数は 1 つのままです。 フェーズ 3 で is_hit()class の外に置いておいた甲斐がありました。

2. このフェーズで使う機能

新しい API は pyxel.text() だけですが、それもフェーズ 4 で使ったものです。 新しいのは「考え方」のほうです。

機能役割
定数で状態を表すSCENE_PLAY / SCENE_GAMEOVER
if による振り分けupdate()draw() の入口で分ける
is_hit()(再利用)お化けに捕まったかを調べる
len()文字数を数えて、中央ぞろえの位置を計算する
pyxel.text()GAME OVER の文字を描く
pyxel.COLOR_RED色の番号 8。8 と書いてもよいが、名前のほうが読みやすい

3. 手順

3-1. シーンの定数を足す

HIT_OFFSET の下に、空行をはさんで足します。

HIT_SIZE = 4            # 当たり判定に使う四角の大きさ(絵の中央だけを見る)
HIT_OFFSET = (BOY_SIZE - HIT_SIZE) // 2     # 絵の左上から、判定の四角までの距離(= 2)

SCENE_PLAY = 0          # あそんでいる画面
SCENE_GAMEOVER = 1      # ゲームオーバーの画面

3-2. 中央に文字を描く関数を足す

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 ドットずつ右へ進みます。

3-3. 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

3-4. 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 行増える

3-5. 捕まったかを調べる

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

3-6. 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() に置きます。

4. できあがりのコード

ここまでを反映した全体です。増えたのは 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()
動かして確かめること

5. コードの解説

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_xself.scene を 触るので、class の中でなければ書けません。

中央にそろえる式

(SCREEN_WIDTH - len(s) * 4) // 2 の意味を "GAME OVER" で追います。

画面の幅 160 ドット GAME OVER 62 ドット 35 ドット 62 ドット ① 文字数を数える len("GAME OVER") = 9 ② 幅を出す(1 文字 4 ドット送り) 9 * 4 - 1 = 35 ③ 残りを半分ずつに分ける (160 - 9 * 4) // 2 = 62 → x = 62 に描くと、左右の余白が 62 ドットずつでそろう
文字数さえ分かれば幅が出るので、中央ぞろえは引き算と割り算だけでできる
式の中が 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 増えてからゲームオーバーになります。逆にすると、 取ったはずのお菓子が数えられません。プレイヤーに有利なほうを先に置いています。

6. もう一歩(やらなくても大丈夫)

GAME OVER の文字を点滅させる

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 のネストを深くしないための書き方です。

7. うまくいかないときは

症状見るところ
捕まっても何も起きない 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() よりあとに書いています

8. 次のフェーズ

画面を切りかえるしくみができました。次はタイトル画面を足して 3 画面にし、 もう一度あそべるようにします。 やることはこのフェーズと同じ形をもう 1 つ増やすだけ—— SCENE_TITLE を足し、update()draw() に枝をもう 1 本生やします。

新しく出てくるのは「やり直すときに、変数を初期状態へ戻す」という話です。 スコアもお化けの位置も前回のままでは困るので、 __init__() に書いた初期化を呼び直せる形に整理します。

お化けの速さについて GHOST_SPEED = 1 は、全力で逃げ続けても 5 秒で捕まる速さです (実際に数えました → 備忘録 #chase-speed)。 いまは 1 のままで進み、お化けが増えるフェーズ 8 であらためて調整します。