フェーズ 8 お化けを増やす

「同じものがいくつもある」をリストとクラスで扱う

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

ゴール

お菓子を 5 個取るごとにお化けが 1 体増え、最大 4 体になるようにします。 step1 最後の山場です。

むずかしいのは処理そのものではありません。 「同じものがいくつもある」状態をどう持つか——ここが今回のテーマです。 そしてこれは、step2 の倉庫番でも step3 のシューティングでも、 そのまま使い回せる考え方になります。

1. 考え方

1-1. 変数を増やして持つのは、すぐ行き詰まる

いま、お化けは 2 つの変数で持っています。

        self.ghost_x = 0
        self.ghost_y = 0

素直に考えると「4 体なら 8 個の変数」です。書けなくはありません。

# こうはしたくない
        self.ghost_x1 = 0
        self.ghost_y1 = 0
        self.ghost_x2 = 0
        self.ghost_y2 = 0
        ...

しかし、これでは「すべてのお化けを動かす」が書けません。 1 体ずつ、同じ処理を 4 回コピーすることになります。

# 動かす処理が 4 つ、当たり判定も 4 つ、描画も 4 つ…
        if self.ghost_x1 < self.boy_x:
            self.ghost_x1 += 1
        ...
        if self.ghost_x2 < self.boy_x:
            self.ghost_x2 += 1
        ...
数が変わると、コードを書き足さないといけない これがいちばんの問題です。お化けは 1 体から始まって、途中で増えます。 「いま何体いるか」で処理の数が変わる書き方は、そもそも成り立ちません

1-2. リストなら「何個あっても同じ書き方」になる

リストの本当のありがたみは、「数を気にしなくてよくなる」ことです。

        for ghost in self.ghosts:
            ghost.move(self.boy_x, self.boy_y)

これは1 体でも 4 体でも、そのまま動きます。 「4 体になったら書き足す」必要がありません。 数が変わっても書き方が変わらない——これがリストを使う理由です。

1-3. 1 体ぶんの情報は、1 つにまとめて持つ

では、リストの中身は何にすればよいでしょうか。 「x のリスト」と「y のリスト」の 2 本にする手もありますが、これはおすすめしません

# 2 本のリストで持つ(おすすめしない)
        self.ghost_xs = [0, 152, 0]
        self.ghost_ys = [0, 0, 112]

2 本の対応がずれたら壊れるからです。1 体増やすときは必ず両方に足す、 消すときは必ず両方から消す——という約束を、人間が守り続けることになります。

そこで「お化け 1 体」を 1 つのものとして持ちます。 そのための道具が class です。

① 変数を並べる ghost_x1 ghost_y1 ghost_x2 ghost_y2 ghost_x3 ghost_y3 ghost_x4 ghost_y4 体数が変わると コードを書き足す 動かす処理 ×4 当たり判定 ×4 描画 ×4 途中で増やせない ② リストを 2 本 ghost_xs = [0, 152, 0] ghost_ys = [0, 0, 112] 番号でしか結びついていない 増やすときは両方に足す 消すときは両方から消す 1 か所忘れると 静かにずれて壊れる 人間が約束を守る係 ③ Ghost をリストに ghosts = [ Ghost(x=0, y=0, …) Ghost(x=152, y=0, …) Ghost(x=0, y=112, …) ] 1 体ぶんが 1 つの箱 ずれようがない for で全部まわせる
「同じものがいくつもある」ときは、1 個ぶんをまとめた箱をリストに入れる

1-4. class は「同じ形のものを、いくつも作る」ための型

class App はすでに書いています。ただ App は 1 つしか作らなかったので、 class の本当のありがたみが見えていませんでした。

class Ghost:
    """お化け 1 体ぶんの居場所と動き"""

    def __init__(self, x, y, move_every):
        """1 体作るときに、置く場所と動く間隔を決める"""
        self.x = x
        self.y = y
        self.move_every = move_every

これは設計図です。この設計図から Ghost(0, 0, 1) と書けば、 実物が 1 体でき上がります。何体でも作れて、それぞれが自分の xymove_every を持ちます

self の意味が、ここではっきりします self.x「この 1 体の x」という意味です。 4 体いれば self.x は 4 つあり、それぞれ別の値です。 App が 1 つしかなかったときは 「self = このアプリ」でぼんやりしていましたが、 同じ設計図から複数の実物ができると、self が 「いま扱っている 1 個」を指していることがはっきり分かります
動きもクラスの中に入れます 「男の子に近づく」という処理は、お化け自身のふるまいです。 App ではなく Ghostmove() として持たせると、 App 側は ghost.move(...)頼むだけになります。 どう動くかは Ghost が知っていればよい、という分け方です。

1-5. 全部おなじ速さだと、4 体が 1 体に見える

お化けは全員同じ相手(男の子)を、同じやり方で追いかけます。 ということは、速さも同じなら、いずれ位置まで同じになります。 横のずれも縦のずれも、毎フレーム同じだけ縮まっていくからです。

実際に 600 フレーム(20 秒)動かして数えました。

速さの設定20 秒後、4 体が何か所に分かれているか
全部おなじ1 か所(完全に重なって 1 体に見える)
2 体だけ同じ3 か所
4 体とも違う4 か所

つまり速さを散らすのは、見た目のためだけではありません。 散らさないと、4 体に増やした意味がなくなります

1-6. 遅くするには「1 回に進む量」ではなく「動く回数」を減らす

速さを散らすとき、素直に思いつくのは 0.50.75 のような小数です。 ですがこれには落とし穴があります

男の子が x = 76 で止まっているとき(実測)

  速さ 0.5   → 76.0, 76.0, 76.0 …          ぴったり止まる
  速さ 0.75  → 75.75, 76.5, 75.75, 76.5 …  ★ 永久に往復する

0.750.5 + 0.25 なので2 進数では正確に表せます。 それでも往復するのは、お化けが立てる座標に 76 が含まれていないからです (0.75 の倍数で整数になるのは 3 の倍数だけ)。 くわしくは備忘録 #chase-speed にまとめました。

そこで小数を使わない方法を採ります。進む量は 1 ドットのまま、 動くフレームを間引くのです。

    def move(self, target_x, target_y):
        if pyxel.frame_count % self.move_every != 0:
            return                          # 今回は自分の番ではない

        ...ここから下は 1 ドットずつ動かすだけ...
フレーム番号 → 012 345 678 91011 move_every=1 1.00 move_every=2 0.50 move_every=3 0.33 move_every=4 0.25 実質の速さ ● は「1 ドット動くフレーム」。動く量はいつも 1 ドットなので、座標はずっと整数のまま
move_every を変えると、動く量ではなく動く回数が変わる
見た目は小数を使ったときと変わりません 描画はどうせ整数のドットに丸められるので、 「0.5 ずつ進む」も「2 フレームに 1 ドット進む」も画面上は同じです。 それでいて 1/3 のような、小数では危ない速さが安全に使えます。

1-7. 出てくる場所は「いちばん遠い角」

増えたお化けが男の子のとなりに出てきたら理不尽です。 4 つの角のうち、そのとき男の子からいちばん遠い角を選びます。

def far_corner(x, y):
    """(x, y) からいちばん遠い角を返す"""
    best_x, best_y = CORNERS[0]
    best_distance = -1

    for corner_x, corner_y in CORNERS:
        distance = abs(corner_x - x) + abs(corner_y - y)
        if distance > best_distance:
            best_x, best_y = corner_x, corner_y
            best_distance = distance

    return best_x, best_y

「いちばん◯◯なものを 1 つ選ぶ」の型です。 「これまでで最高の記録」を best_distance に持ち、 それを超えたら更新する。この形はこの先も何度も出てきます

abs() で「距離」を測っています abs(a - b) は差の絶対値——マイナスを取り払った、へだたりです。 横のへだたりと縦のへだたりを足したものを距離として使っています。 ほんとうの直線距離ではありませんが、どの角がいちばん遠いかを比べるだけなら これで十分で、ルートの計算もいりません。

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

機能役割
class Ghost:お化け 1 体ぶんの設計図。2 つめのクラス
リスト [].append()お化けを何体でも持つ/1 体足す
for ghost in self.ghosts:いる体数ぶんだけ、同じ処理をくり返す
len()いま何体いるかを数える
%(あまり)「5 個ごと」「n フレームに 1 回」を作る
abs()2 点のへだたりを測る
タプルのリスト4 隅の座標をまとめて持つ

3. 手順

書きかえる場所が多いので、上から順に進めます。

3-1. 定数を入れかえる

GHOST_SPEED は使わなくなります。2 行に入れかえます。

GHOST_SIZE = 8          # お化けの絵の大きさ
GHOST_MOVE_EVERY = [1, 2, 3, 4]     # 出てくる順に「何フレームに 1 回動くか」
GHOST_SPAWN_SCORE = 5   # お菓子を何個取るごとに 1 体増やすか
リストの長さが、そのまま「最大の体数」になります GHOST_MOVE_EVERY には 4 つ入っているので、お化けは最大 4 体です。 GHOST_MAX = 4 のような定数はいりません。 len(GHOST_MOVE_EVERY) で数えられるからです。 同じことを 2 か所に書くと、片方だけ直して食い違います。

3-2. 4 隅の座標を定数にする

BLINK_ON の下に足します。

CORNERS = [                                     # お化けが出てくる 4 隅
    (0, 0),
    (SCREEN_WIDTH - GHOST_SIZE, 0),
    (0, SCREEN_HEIGHT - GHOST_SIZE),
    (SCREEN_WIDTH - GHOST_SIZE, SCREEN_HEIGHT - GHOST_SIZE),
]
(0, 0) のような丸カッコの組を「タプル」と言います リストとほとんど同じですが、あとから中身を変えられません。 「セットで 1 つ」という意味あいのものに向いています。座標はまさにそれです。
x, y = (10, 20) のように2 つの変数へ一度に取り出せるのも、 座標を扱うときに便利なところです(フェーズ 5 の「もう一歩」でも使いました)。

3-3. far_corner() を足す

is_blink_on() の下、class の外に書きます。 self を使わないので外に置けます(フェーズ 6 と同じ基準)。

def far_corner(x, y):
    """(x, y) からいちばん遠い角を返す"""
    best_x, best_y = CORNERS[0]
    best_distance = -1

    for corner_x, corner_y in CORNERS:
        distance = abs(corner_x - x) + abs(corner_y - y)
        if distance > best_distance:
            best_x, best_y = corner_x, corner_y
            best_distance = distance

    return best_x, best_y

3-4. Ghost クラスを書く

class App:すぐ上に書きます。

class Ghost:
    """お化け 1 体ぶんの居場所と動き"""

    def __init__(self, x, y, move_every):
        """1 体作るときに、置く場所と動く間隔を決める"""
        self.x = x
        self.y = y
        self.move_every = move_every

    def move(self, target_x, target_y):
        """自分の番のフレームだけ、目標へ 1 ドット近づく"""
        if pyxel.frame_count % self.move_every != 0:
            return

        if self.x < target_x:
            self.x += 1
        elif self.x > target_x:
            self.x -= 1

        if self.y < target_y:
            self.y += 1
        elif self.y > target_y:
            self.y -= 1

中身はいまの move_ghost() とほとんど同じです。 self.ghost_xself.x に、 GHOST_SPEED1 に変わり、 先頭に「自分の番か」の判定が付いただけです。

3-5. reset_game() を書きかえ、add_ghost() を足す

    def reset_game(self):
        """ゲームの中身を、最初の状態に戻す"""
        self.boy_x = SCREEN_WIDTH // 2 - BOY_SIZE // 2
        self.boy_y = SCREEN_HEIGHT // 2 - BOY_SIZE // 2
        self.score = 0
        self.ghosts = []            # ← 空のリストから始める
        self.add_ghost()            # ← 1 体目を置く
        self.place_candy()

    def add_ghost(self):
        """男の子からいちばん遠い角に、お化けを 1 体足す"""
        move_every = GHOST_MOVE_EVERY[len(self.ghosts)]
        x, y = far_corner(self.boy_x, self.boy_y)
        self.ghosts.append(Ghost(x, y, move_every))
self.ghosts = [] を先に書きます add_ghost()self.ghosts長さを数えてから足すので、 空のリストが先にできていないと動きません。
やり直すたびに [] で作り直すのも大事です。 .clear() でも同じですが、[] のほうが 「まっさらから始める」という意味が読み取りやすくなります。

3-6. 動かす・判定するところを、すべて for にする

update_play() の呼び出し名を、複数形に変えます。

    def update_play(self):
        """あそんでいる間の更新"""
        self.move_boy()
        self.move_ghosts()          # ← s が付く
        self.check_candy()
        self.check_ghosts()         # ← s が付く

move_ghost()まるごとこの 4 行に置きかえます。 中身は Ghost へ引っ越したので、ここは頼むだけです。

    def move_ghosts(self):
        """すべてのお化けを、男の子に近づける"""
        for ghost in self.ghosts:
            ghost.move(self.boy_x, self.boy_y)

check_ghost() も置きかえます。

    def check_ghosts(self):
        """どれか 1 体にでも捕まったら、ゲームオーバーにする"""
        for ghost in self.ghosts:
            if is_hit(self.boy_x + HIT_OFFSET, self.boy_y + HIT_OFFSET, HIT_SIZE,
                      ghost.x + HIT_OFFSET, ghost.y + HIT_OFFSET, HIT_SIZE):
                self.scene = SCENE_GAMEOVER
                self.gameover_frame = pyxel.frame_count
                return

3-7. お菓子を 5 個取るごとに 1 体増やす

check_candy() の中、お菓子を置きなおしたあとに足します。

    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()

            if (self.score % GHOST_SPAWN_SCORE == 0
                    and len(self.ghosts) < len(GHOST_MOVE_EVERY)):
                self.add_ghost()

3-8. 何体でも描けるようにする

        for ghost in self.ghosts:                               # お化けは何体でも
            pyxel.blt(ghost.x, ghost.y, 0, 8, 0,
                      GHOST_SIZE, GHOST_SIZE, pyxel.COLOR_BLACK)

4. できあがりのコード

# candy_hunt.py
# step1 フェーズ8:お菓子を集めるほどお化けを増やす

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_MOVE_EVERY = [1, 2, 3, 4]     # 出てくる順に「何フレームに 1 回動くか」
GHOST_SPAWN_SCORE = 5   # お菓子を何個取るごとに 1 体増やすか

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

SCENE_PLAY = 0          # あそんでいる画面
SCENE_GAMEOVER = 1      # ゲームオーバーの画面
SCENE_TITLE = 2         # タイトル画面

GAMEOVER_WAIT = 15      # 捕まってから文字を出すまでのフレーム数(0.5秒)
BLINK_CYCLE = 30         # 点滅 1 周期のフレーム数(1秒)
BLINK_ON = 20           # そのうち文字が見えているフレーム数

CORNERS = [                                     # お化けが出てくる 4 隅
    (0, 0),
    (SCREEN_WIDTH - GHOST_SIZE, 0),
    (0, SCREEN_HEIGHT - GHOST_SIZE),
    (SCREEN_WIDTH - GHOST_SIZE, SCREEN_HEIGHT - GHOST_SIZE),
]

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     # 4 は1文字分のフォント幅
    pyxel.text(x, y, s, col)

def is_blink_on():
    """点滅の「見えている」タイミングなら True を返す"""
    return pyxel.frame_count % BLINK_CYCLE < BLINK_ON

def far_corner(x, y):
    """(x, y) からいちばん遠い角を返す"""
    best_x, best_y = CORNERS[0]
    best_distance = -1

    for corner_x, corner_y in CORNERS:
        distance = abs(corner_x - x) + abs(corner_y - y)
        if distance > best_distance:
            best_x, best_y = corner_x, corner_y
            best_distance = distance

    return best_x, best_y

class Ghost:
    """お化け 1 体ぶんの居場所と動き"""

    def __init__(self, x, y, move_every):
        """1 体作るときに、置く場所と動く間隔を決める"""
        self.x = x
        self.y = y
        self.move_every = move_every

    def move(self, target_x, target_y):
        """自分の番のフレームだけ、目標へ 1 ドット近づく"""
        if pyxel.frame_count % self.move_every != 0:
            return

        if self.x < target_x:
            self.x += 1
        elif self.x > target_x:
            self.x -= 1

        if self.y < target_y:
            self.y += 1
        elif self.y > target_y:
            self.y -= 1

class App:
    def __init__(self):
        """起動時の設定"""
        pyxel.init(SCREEN_WIDTH, SCREEN_HEIGHT, title="Candy Hunt")
        pyxel.load("candy_hunt.pyxres")

        self.scene = SCENE_TITLE
        self.reset_game()

        pyxel.run(self.update, self.draw)

    def reset_game(self):
        """ゲームの中身を、最初の状態に戻す"""
        self.boy_x = SCREEN_WIDTH // 2 - BOY_SIZE // 2
        self.boy_y = SCREEN_HEIGHT // 2 - BOY_SIZE // 2
        self.score = 0
        self.ghosts = []
        self.add_ghost()
        self.place_candy()

    def add_ghost(self):
        """男の子からいちばん遠い角に、お化けを 1 体足す"""
        move_every = GHOST_MOVE_EVERY[len(self.ghosts)]
        x, y = far_corner(self.boy_x, self.boy_y)
        self.ghosts.append(Ghost(x, y, move_every))

    def update(self):
        """フレーム毎の更新処理"""
        if self.scene == SCENE_TITLE:
            self.update_title()
        elif self.scene == SCENE_PLAY:
            self.update_play()
        else:
            self.update_gameover()

    def update_title(self):
        """タイトル画面。Enter でゲームを始める"""
        if pyxel.btnp(pyxel.KEY_RETURN):
            self.reset_game()
            self.scene = SCENE_PLAY

    def update_gameover(self):
        """ゲームオーバー画面。少し待ってから、Enter でゲームを再び始める"""
        if pyxel.frame_count - self.gameover_frame < GAMEOVER_WAIT:
            return

        if pyxel.btnp(pyxel.KEY_RETURN):
            self.reset_game()
            self.scene = SCENE_PLAY

    def update_play(self):
        """あそんでいる間の更新"""
        self.move_boy()
        self.move_ghosts()
        self.check_candy()
        self.check_ghosts()

    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_ghosts(self):
        """すべてのお化けを、男の子に近づける"""
        for ghost in self.ghosts:
            ghost.move(self.boy_x, self.boy_y)

    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()

            if (self.score % GHOST_SPAWN_SCORE == 0
                    and len(self.ghosts) < len(GHOST_MOVE_EVERY)):
                self.add_ghost()

    def check_ghosts(self):
        """どれか 1 体にでも捕まったら、ゲームオーバーにする"""
        for ghost in self.ghosts:
            if is_hit(self.boy_x + HIT_OFFSET, self.boy_y + HIT_OFFSET, HIT_SIZE,
                      ghost.x + HIT_OFFSET, ghost.y + HIT_OFFSET, HIT_SIZE):
                self.scene = SCENE_GAMEOVER
                self.gameover_frame = pyxel.frame_count     # 捕まった時刻を覚えておく
                return

    def draw(self):
        """フレーム毎の描画処理"""
        pyxel.cls(pyxel.COLOR_BLACK)

        if self.scene == SCENE_TITLE:
            self.draw_title()
            return
        
        self.draw_play()

        if self.scene == SCENE_GAMEOVER:
            self.draw_gameover()

    def draw_title(self):
        """タイトル画面を描く"""
        draw_center("CANDY HUNT", 44, pyxel.COLOR_YELLOW)

        if is_blink_on():
            draw_center("PRESS ENTER", 68, pyxel.COLOR_WHITE)

    def draw_play(self):
        """あそんでいる画面を描く"""
        pyxel.blt(self.candy_x, self.candy_y, 0, 16, 0,
                  CANDY_SIZE, CANDY_SIZE, pyxel.COLOR_BLACK)    # お菓子
        for ghost in self.ghosts:                               # お化けは何体でも
            pyxel.blt(ghost.x, 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):
        """ゲームオーバーの文字を重ねて描く"""
        elapsed = pyxel.frame_count - self.gameover_frame       # 捕まってからの経過
        if elapsed < GAMEOVER_WAIT:
            return
        
        if elapsed % BLINK_CYCLE < BLINK_ON:
            draw_center("GAME OVER", 52, pyxel.COLOR_RED)
            draw_center("PRESS ENTER TO RETRY", 80, pyxel.COLOR_GRAY)

        draw_center(f"SCORE {self.score}", 66, pyxel.COLOR_WHITE)

App()
動かして確かめること

5. コードの解説

GHOST_MOVE_EVERY[len(self.ghosts)] の読み方

add_ghost() の 1 行目です。リストの長さが、そのまま次の番号になります。

いまいる体数len(self.ghosts)取り出す値
0 体(これから 1 体目)0GHOST_MOVE_EVERY[0] = 1
1 体(これから 2 体目)1GHOST_MOVE_EVERY[1] = 2
2 体(これから 3 体目)2GHOST_MOVE_EVERY[2] = 3
3 体(これから 4 体目)3GHOST_MOVE_EVERY[3] = 4

リストの番号が 0 から始まることが、ここでちょうど都合よくはまっています。 「何体目か」を数える変数を別に持つ必要がありません。

check_ghosts()return

        for ghost in self.ghosts:
            if is_hit(...):
                self.scene = SCENE_GAMEOVER
                self.gameover_frame = pyxel.frame_count
                return                      # ← 1 体見つかれば、もう調べなくてよい

捕まったかどうかが知りたいだけなので、1 体見つかった時点で終わりです。 残りを調べても答えは変わりません。

return がないと、gameover_frame が上書きされます 4 体が重なっている状態だと、for が 4 回とも成立します。 return がないと gameover_frame4 回書きかえられ、 結果として最後の 1 体の時刻になります。
このフレームでは値は同じなので害はありませんが、 「見つかったら抜ける」と書いておくほうが、意図がはっきりします

条件が長いときの折り返し方

            if (self.score % GHOST_SPAWN_SCORE == 0
                    and len(self.ghosts) < len(GHOST_MOVE_EVERY)):
                self.add_ghost()

条件が 1 行に収まらないときは、全体を丸カッコで囲むと途中で改行できます。 2 行目を深めに下げているのは、中身の self.add_ghost() と 見分けがつくようにするためです。同じ深さだと、どこまでが条件か分かりません。

「5 個ごと」を % で書く

self.score % GHOST_SPAWN_SCORE == 0「スコアを 5 で割ったあまりが 0」= 5 の倍数、という意味です。 点滅で使った % と同じ道具です。

「取った瞬間」に調べているのがポイントです update() で毎フレーム「スコアは 5 の倍数か」を調べると、 5 個目を取ったあとの数十フレーム、ずっと成立し続けます。 お化けが一瞬で 4 体に増えてしまいます。
「増えた瞬間だけ 1 回」にするために、 self.score += 1 のすぐそばに置いています。 これは備忘録の「1 回だけ処理する書き方」と同じ考え方です。

お化けは、なぜ画面の外へ出ないのか

フェーズ 5 と同じ理由です。お化けは男の子の座標へ近づくだけで、 男の子は画面内に押しとどめられています。 だからお化けにも maxmin はいりません

角から出るときも同じです。CORNERSSCREEN_WIDTH - GHOST_SIZE のように絵の大きさを引いてあるので、 右下の角に置いても絵が画面からはみ出しません。

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

増えた瞬間を、点滅で知らせる

遊んでいるといつの間にか増えているので、気づいたときには囲まれています。 出てきた直後だけ点滅させると、「増えた」が伝わります

class Ghost:
    def __init__(self, x, y, move_every):
        """1 体作るときに、置く場所と動く間隔を決める"""
        self.x = x
        self.y = y
        self.move_every = move_every
        self.born_frame = pyxel.frame_count     # 生まれた時刻を覚えておく
    def draw_play(self):
        ...
        for ghost in self.ghosts:
            if pyxel.frame_count - ghost.born_frame < 30 and not is_blink_on():
                continue                    # 出てきて 1 秒は、点滅の消える側で描かない

            pyxel.blt(ghost.x, ghost.y, 0, 8, 0,
                      GHOST_SIZE, GHOST_SIZE, pyxel.COLOR_BLACK)
continue は「このくり返しだけ飛ばす」 return関数ごと抜けるのに対し、 continueいま扱っている 1 個をとばして、次の 1 個へ進みますfor の中でだけ使える書き方です。
「描かない」がなぜ点滅になるのか draw() の先頭で cls()毎フレーム画面を消しているので、 blt() を呼ばなかったフレームは、そこに何もないことになります。 つまり消す命令はいりません。描くフレームと描かないフレームが交互になると、 点滅して見えます。
フレームごとに分解した図と、and not を読みやすく書き直す案を 備忘録 #blink-by-skipping にまとめました。

お化けの数を画面に出す

        pyxel.text(4, 4, f"SCORE {self.score}", pyxel.COLOR_WHITE)
        pyxel.text(4, 12, f"GHOST {len(self.ghosts)}", pyxel.COLOR_GRAY)

len() で数えるだけです。体数を覚えておく変数はいりません。 リストが自分で知っています。

難しさの調整つまみ

遊んでみて手ごたえが合わなければ、定数を変えるだけで調整できます。 どれもコードを書きかえずに済むように作ってあります。

変えるもの効果
GHOST_SPAWN_SCORE を 8 に増えるペースがゆるやかになる。いちばん効きます
GHOST_MOVE_EVERY[2, 3, 4, 5] 全体に遅くなる。1 体目でも逃げ切る時間が 5.0 秒 → 9.9 秒
GHOST_MOVE_EVERY に 5 つめを足す最大 5 体になる。ほかは何も直さなくてよい
HIT_SIZE を 3 にすりぬけやすくなる(#hitbox-size
2 体目が出た時点で、逃げ切れる時間は半分以下になります 最善手で逃げ続けた場合を数えると、 1 体で 5.0 秒、2 体で 3.2 秒でした([1, 2, 3, 4] のとき)。 3 体・4 体になってもこの数字自体はあまり変わりません—— すでに追いつめられているからです。
ただし実際はお菓子を取りに行かなければならないので、 体数が増えるほど安全な道が減っていきます。そこが難しさになります。

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

症状見るところ
AttributeError: ... 'ghost_x' どこかに self.ghost_x が残っています。 move_ghosts / check_ghosts / draw_play の 3 か所を確認します
IndexError: list index out of range 5 体目を作ろうとしています。check_candy()len(self.ghosts) < len(GHOST_MOVE_EVERY) が抜けています
お化けが 1 体も出ない reset_game()self.add_ghost() を書き忘れています
お化けが増えない check_candy() の中、if is_hit(...) の内側に 書けているか確認します。1 段浅いと、毎フレーム調べることになります
お菓子を 5 個取った瞬間に一気に 4 体出る 増やす判定を update() 側に書いています。 取った瞬間の 1 回だけにするため、self.score += 1 のそばに置きます
4 体いるはずが 1 体にしか見えない GHOST_MOVE_EVERY が全部同じ数になっていませんか。 同じ速さだと本当に重なります(1-5 を参照)
2 体目が男の子のすぐ近くに出る add_ghost()far_corner() を使わず、 (0, 0) を直接書いていませんか
お化けがぷるぷる震える move() の縦・横を elif でつないでいます。 横のブロックと縦のブロックは別々の if です(フェーズ 5 と同じ)
やり直しても 4 体のまま reset_game()self.ghosts = [] がありません
TypeError: 'Ghost' object is not subscriptable ghost[0] のように書いています。 Ghost はリストではないので ghost.x と書きます

8. 次のフェーズ

これでゲームの中身は完成です。残りは仕上げの 2 つになります。

フェーズ 9 では効果音と BGM をつけます。 お菓子を取った音、捕まった音、それに BGM。 「取った感じが薄い」で 「効果音がいちばん効く」と書いたとおり、 手ざわりがいちばん変わるフェーズです。 音は step0 で BGM の作り方 をやったので、その続きになります。

フェーズ 10 はパッケージ化して Web で公開です。 step0 の Star Voyager と同じ手順なので、こちらは復習になります。