フェーズ 5 お化けに追いかけさせる

相手の座標と自分の座標を見くらべて、近づく向きへ動かす

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

ゴール

お化けを 1 体登場させ、男の子をどこまでも追いかけてくるようにします。 まだ捕まっても何も起きません(それはフェーズ 6 です)。 このフェーズは「追いかける」という動きだけを作ります。

書き足すのは 15 行ほどです。フェーズ 2 で書いた move_boy() と よく似た形になりますが、1 か所だけ決定的に違うところがあります。そこが今回の山です。

1. 考え方

1-1. 「追いかける」= 座標を見くらべて、近づく向きへ動く

むずかしそうに聞こえますが、やることは「相手の座標と自分の座標のどちらが大きいか」を 見て、小さいほうへ寄せていくだけです。横と縦を別々に考えます。

お化けの x が男の子の x より小さい  → お化けの x を増やす(右へ)
お化けの x が男の子の x より大きい  → お化けの x を減らす(左へ)
同じ                                → x は動かさない

縦もまったく同じです。横と縦をそれぞれ独立に決めるので、 斜め方向にいる男の子には自然と斜めに近づいていきます。

お化けが男の子から見てどこにいるかで、動く向きが決まる 男の子 x 小 / y 小x 同 / y 小x 大 / y 小 x 小 / y 同重なっているx 大 / y 同 x 小 / y 大x 同 / y 大x 大 / y 大 お化けがいるマス → 矢印の向きへ 1 ドット動く 横の向きと縦の向きを 別々に決めるだけで、 8 方向すべてがそろう 中央のマス(真上に重なった 状態)だけは動かさない 「x が同じ」のときに動かして しまうと、左右にふるえます (→ 5 章で詳しく)
横の向き(3 通り)と縦の向き(3 通り)を独立に決めれば、8 方向の追跡ができる

1-2. ここは elif を使う(フェーズ 2 とは逆)

フェーズ 2 では「elif にせず if を 4 つ並べる」と説明しました。 今回は逆で、elif が正解です。理由は条件の性質が違うからです。

条件どうしの関係書き方
フェーズ 2
(男の子の移動)
同時に成り立ちうる
← と ↑ は同時に押せる
if を並べる
フェーズ 5
(お化けの追跡)
同時には成り立たない
「相手より左」と「相手より右」は両立しない
if / elif

どちらも「あてはまるものを全部やるのか、1 つだけ選ぶのか」で決まります。 横の動きは 3 択から 1 つなので elifキー入力は押されているものすべてなので if の並列、というわけです。

1-3. お化けは男の子より遅くする

男の子は 1 フレームに 2 ドット進みます。お化けを同じ速さにすると、 いちど追いつかれたら二度と離れられませんGHOST_SPEED = 1(男の子の半分)にして、逃げ切れる余地を作ります。

速さの差がそのまま難しさになります お化けが遅すぎると簡単すぎ、速すぎると理不尽になります。 この step ではお化けの数を増やすことで難しくする方針(フェーズ 8)なので、 1 体あたりの速さは控えめにしておきます。 遊んでみて手ごたえが足りなければ、あとから GHOST_SPEED を調整できます。
「半分の速さ」でも、思ったより速く感じます 実際に数えたところ、お菓子を無視して全力で逃げ続けても 5.0 秒で捕まります。 画面が 160×120 と狭く、必ず壁に突き当たるからです。 小数(0.5 など)も使えますが、0.3 のような値を選ぶと お化けが目標の周りで永久に往復します。 測った数字と理由は備忘録 #chase-speed にまとめました。

1-4. お化けは画面の外に出ない(押し戻しがいらない)

男の子には max / min で押し戻す処理を書きましたが、 お化けには要りません。お化けは「男の子の座標へ近づく」ようにしか動かず、 その男の子が画面内に収まっているからです。

しかも GHOST_SPEED = 1 なら 1 ドットずつしか動かないので、 男の子の座標を行き過ぎることもありません

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

新しい API はありません。すでに知っているものだけで書けます。

機能役割
if / elif3 つの選択肢から 1 つだけ選ぶ
< >2 つの座標のどちらが大きいか比べる
pyxel.blt()お化けを描く(切り出し位置は u = 8

3. 手順

3-1. 定数を足す

CANDY_SIZE = 8          # お菓子の絵の大きさ

GHOST_SIZE = 8          # お化けの絵の大きさ
GHOST_SPEED = 1         # 1 フレームで進むドット数(男の子の半分)

3-2. お化けの初期位置を決める

__init__ に 2 行足します。男の子は画面の中央にいるので、 お化けは左上の角から始めます。

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

3-3. 追いかける処理を書く

update() に 1 行足します。

    def update(self):
        """フレーム毎の更新処理"""
        self.move_boy()
        self.move_ghost()       # ← 足す
        self.check_candy()

move_boy() の下に move_ghost() を書きます。

    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

3-4. お化けを描く

draw() の、お菓子と男の子のあいだに 1 つ足します。

        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)        # 男の子

実行して、左上からお化けが近づいてくれば完成です。 捕まってもまだ何も起きません。男の子のほうが速いので、逃げ続けられるはずです。

4. できあがりのコード

# candy_hunt.py
# step1 フェーズ5:お化けに追いかけさせる

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)


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)


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

        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):
        """フレーム毎の更新処理"""
        self.move_boy()
        self.move_ghost()
        self.check_candy()

    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 draw(self):
        """フレーム毎の描画処理"""
        pyxel.cls(pyxel.COLOR_BLACK)

        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)

App()
当たり判定は HIT_SIZE = 4 で確定しています 備忘録の「取った感じが薄い」で検討したとおり、 check_candy()HIT_SIZE / HIT_OFFSET を 使う形になっているはずです。 このフェーズはお化けの移動だけなので、当たり判定には触れません。 お化けに捕まる判定は次のフェーズ 6 で、同じ HIT_SIZE を使って作ります。

5. コードの解説

else ではなく elif にする理由

2 つめを else にすると、こう書けそうに見えます。

# これはうまくいかない
if self.ghost_x < self.boy_x:
    self.ghost_x += GHOST_SPEED
else:                              # ← 「小さくないなら」= 大きい+等しい
    self.ghost_x -= GHOST_SPEED

問題は「等しいとき」も else に入ってしまうことです。 真上に重なった瞬間から、お化けは左右にふるえ続けます。

フレームghost_xboy_x判定次の ghost_x
17676小さくない → else75
27576小さい → if76
37676小さくない → else75
47576小さい → if76

elif self.ghost_x > self.boy_x: と書けば、 等しいときはどちらにも入らないので、その場にとどまります。

「3 つめの選択肢は、何も書かない」 if / elif だけで else を書かないと、 「どちらの条件にも当てはまらないときは何もしない」という意味になります。 何もしないことを明示するために else: pass と書く必要はありません。 今回のように「動かない」が正解の場合、書かないのがいちばん素直です。

横と縦を別々に決めると、斜めに動く

move_ghost() は横のブロックと縦のブロックが独立しているので、 両方が同時に成り立てば斜めに 1 ドットずつ進みます。 そのため、お化けの軌跡は「まず斜めに進み、縦か横がそろったら直線」になります。

画面 160 × 120(図は 2.5 倍で表示/男の子は止まっているものとする) お化け (8, 8) 男の子 (120, 90) ここで縦がそろう 斜めに 82 歩 真横に 30 歩 横に 112・縦に 82 離れているので、少ないほうの 82 歩ぶんが斜めになる
横と縦を独立に詰めるので、まっすぐ一直線ではなく「斜め → 直線」の軌跡になる

本当にまっすぐ向かわせたければ角度の計算が必要になりますが、 この形のほうが「じわじわ迫ってくる」感じが出ます。 昔のゲームのお化けも、たいていこの単純な追い方をしています。

描く順番は お菓子 → お化け → 男の子

フェーズ 3 で見たとおり、あとに描いたものが上になります。 男の子をいちばん最後に描くことで、お化けに重なられても 自分の位置を見失わずに済みます

お化けの初期位置を (0, 0) にした理由

男の子が画面の中央にいるので、いちばん遠い場所のひとつだからです。 起動した瞬間に隣にいると、避ける間もありません。

self.ghost_x = 0place_candy() と違って単純でよい お菓子は「取られるたびに置きなおす」ので関数にしましたが、 お化けの初期位置はいまのところ 1 回しか使いません。 フェーズ 7 で「もう一度あそぶ」を作るときに、 お菓子と同じくやり直し用の関数へまとめることになります。 そのときにまとめれば十分です。

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

出てくる角を毎回変える

いつも左上から来るので、慣れると先読みできてしまいます。 4 つの角からランダムに選ぶと、毎回ちがう展開になります。

        corner_x, corner_y = random.choice([
            (0, 0),                                             # 左上
            (SCREEN_WIDTH - GHOST_SIZE, 0),                     # 右上
            (0, SCREEN_HEIGHT - GHOST_SIZE),                    # 左下
            (SCREEN_WIDTH - GHOST_SIZE, SCREEN_HEIGHT - GHOST_SIZE),   # 右下
        ])
        self.ghost_x = corner_x
        self.ghost_y = corner_y

random.choice(リスト) は、リストの中から 1 つをでたらめに選んで返す関数です。 ここではリストの中身が「2 つの数の組」になっていて、 corner_x, corner_y = ...2 つの変数へ一度に受け取っています。

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

症状見るところ
お化けが動かない update()self.move_ghost() を足したか確認します
お化けが表示されない blt() の切り出し位置が 8, 0 になっているか確認します。 お化けはイメージバンク 0 の u = 8 です
お化けが小刻みにふるえる 2 つめが else になっています。elif にします
お化けが遠ざかっていく +=-= が逆です。 自分が小さい(左にいる)なら増やす(右へ進む)です
お化けが真横・真下にしか動かない 横と縦を elif でつないでしまっています。 横のブロックと縦のブロックは別々の if です。 あいだを 1 行空けておくと間違えにくくなります
すぐ捕まって逃げられない GHOST_SPEEDBOY_SPEED 以上になっていませんか。 お化けは 1、男の子は 2 です
お化けが男の子の下に隠れて見えない 仕様どおりです。男の子をいちばん最後に描いているためです。 気になる場合は blt() の順番を入れかえてください
AttributeError: ... 'ghost_x' __init__self.ghost_x = 0 を書き忘れているか、 pyxel.run() よりあとに書いています

8. 次のフェーズ

追いかけられるようになりました。次はお化けに捕まったらゲームオーバー画面へ切りかえますstep1 概要の「土台 2:今どの画面かを変数で覚える」で説明した 画面の切りかえが、いよいよ登場します。 捕まった判定には、フェーズ 3 で書いた is_hit() をそのまま使います。