フェーズ 3 お菓子を置いて、当たり判定をする

ランダムな場所にお菓子を出し、重なったら次の場所へ移す

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

ゴール

お菓子を画面のどこかに 1 個置き、男の子が重なったら消して、また別の場所に出す ところまでを作ります。これができれば、ゲームの中心になる部分は完成です。

この step の山場です。新しく出てくるものは 3 つあります。

先に読んでおくとラクです 当たり判定の考え方そのものは step1 概要の「土台 1:当たり判定は横と縦に分けて考える」 に図で説明してあります。このページでは、それをコードの式に翻訳するところをやります。

1. 考え方

1-1. お菓子も「位置を覚える変数」を持つ

フェーズ 2 で男の子に self.boy_x / self.boy_y を持たせたのと同じで、 お菓子にも self.candy_x / self.candy_y を持たせます。

違うのは決め方です。男の子は「画面の中央」と決め打ちでしたが、 お菓子は毎回ちがう場所に出したいので、乱数(ランダムな数)で決めます。

1-2. 置ける範囲は 0 〜 152(フェーズ 2 と同じ理由)

お菓子も 8×8 なので、置ける座標の範囲は男の子とまったく同じです。

この範囲からでたらめに 1 つ選ぶのが random.randint(0, 152) です。

1-3. 当たり判定を式にする

ここが本題です。横と縦を分けて考えるので、まず横だけを見ます。 男の子の横の範囲は boy_x から boy_x + 8 まで、 お菓子の横の範囲は candy_x から candy_x + 8 までです。

この 2 つの範囲が重なっている条件は、次の 2 つが同時に成り立つことです。

boy_x < candy_x + 8        # 男の子の左端が、お菓子の右端より左
candy_x < boy_x + 8        # お菓子の左端が、男の子の右端より左
① 重なっている場合(boy_x = 40candy_x = 44 男の子 40〜48 お菓子 44〜52 重なり boy_x < candy_x + 8 → 40 < 52 candy_x < boy_x + 8 → 44 < 48 両方そろったので、横は重なっている ② 離れている場合(boy_x = 40candy_x = 52 男の子 40〜48 お菓子 52〜60 すきま boy_x < candy_x + 8 → 40 < 60 candy_x < boy_x + 8 → 52 < 48 片方でも ✕ なら当たりではない
横だけを見れば、ただの数直線の問題になる。縦もまったく同じ形で考える

縦もまったく同じ形です。xy に置きかえるだけで、

boy_y < candy_y + 8
candy_y < boy_y + 8

となります。この 4 つがすべて成り立ったときだけ「当たった」——これが当たり判定です。

なぜ <= ではなく < なのか boy_x = 40candy_x = 48 のとき、男の子は 40〜47 のドット、 お菓子は 48〜55 のドットを使います。共有しているドットは 1 つもありませんので、 これは「当たっていない」が正解です。candy_x < boy_x + 848 < 48 となって False。ぴったり隣り合ったときに 当たらない、という正しい結果になります。<= にすると、 隣に並んだだけで当たってしまいます。

1-4. 「消す」のではなく「移す」

「お菓子を消して、次のお菓子を出す」と考えると難しく感じますが、 実際にやることは座標を新しい値に書きかえるだけです。 お菓子は最初から最後までずっと 1 個で、場所を移動しているだけと考えます。

if 重なった:
    self.place_candy()      # 座標を決めなおす = 別の場所に現れる

消す処理も、作る処理もいりません。draw() は毎フレーム 「いまの candy_x / candy_y に 1 個描く」だけなので、 座標が変わればそのまま新しい場所に現れます。

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

機能役割
import random乱数を使うための標準ライブラリを読み込む
random.randint(a, b)a 以上 b 以下の整数をでたらめに 1 つ返す(両端を含む)
return関数の結果を呼び出し元へ返す
and左右の条件が両方 True のときだけ True

3. 手順

3-1. random を読み込み、定数を足す

ファイルのいちばん上に import random を足します。

import random

import pyxel
random を上、pyxel を下に書くのはなぜ? Python には「標準ライブラリ → その他のライブラリの順に、あいだを 1 行空けて書く」という 慣習(PEP 8)があります。random は Python にはじめから入っている標準ライブラリ、 pyxelpip で入れたものなので、この順になります。 逆に書いても動きますが、そろえておくと読む人が迷いません。

定数にお菓子の大きさを足します。

BOY_SIZE = 8            # 男の子の絵の大きさ(縦横とも 8 ドット)
BOY_SPEED = 2           # 1 フレームで進むドット数

CANDY_SIZE = 8          # お菓子の絵の大きさ
どちらも 8 なのに、なぜ定数を 2 つに分けるのか 「たまたま今は同じ値」だからです。あとでお菓子だけ小さくしたいと思ったとき、 CANDY_SIZE を 1 か所変えるだけで済みます。 1 つの定数を使い回していると、どの 8 がお菓子のものか分からなくなります。

3-2. 当たり判定の関数を作る

class App:、定数の下に、次の関数を書きます。 クラスの中ではありません。インデントなしの、いちばん左から始まる関数です。

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)

3-3. お菓子を置いて、描く

__init__ に 1 行足します。pyxel.run() より前です。

        self.boy_x = SCREEN_WIDTH // 2 - BOY_SIZE // 2
        self.boy_y = SCREEN_HEIGHT // 2 - BOY_SIZE // 2
        self.place_candy()                                  # ← 足す

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

その place_candy() を、move_boy() の下に書きます。

    def place_candy(self):
        """お菓子を画面のどこかへ置きなおす"""
        self.candy_x = random.randint(0, SCREEN_WIDTH - CANDY_SIZE)
        self.candy_y = random.randint(0, SCREEN_HEIGHT - CANDY_SIZE)

draw() にお菓子を描く行を足します。男の子より前に書くのがポイントです。

    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.boy_x, self.boy_y, 0, 0, 0,
                  BOY_SIZE, BOY_SIZE, pyxel.COLOR_BLACK)        # 男の子

ここで一度実行してください。毎回ちがう場所にお菓子が 1 個出れば成功です。 重なってもまだ何も起きません。

3-4. 重なったら次の場所へ移す

update() に 1 行足します。

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

check_candy()place_candy() の下に書きます。

    def check_candy(self):
        """お菓子に重なったら、次の場所へ置きなおす"""
        if is_hit(self.boy_x, self.boy_y, BOY_SIZE,
                  self.candy_x, self.candy_y, CANDY_SIZE):
            self.place_candy()

実行して、お菓子に触れると別の場所へ移れば完成です。

1 行目のコメントも直しておきましょう ファイルの先頭がまだ # step1 フェーズ1:描いた 3 つの絵を並べて表示する の ままになっているはずです。いまの中身に合わせて書きかえておくと、 あとで開いたときに迷いません。

4. できあがりのコード

# candy_hunt.py
# step1 フェーズ3:お菓子を置いて、重なったら次の場所へ移す

import random

import pyxel

SCREEN_WIDTH = 160
SCREEN_HEIGHT = 120

BOY_SIZE = 8            # 男の子の絵の大きさ(縦横とも 8 ドット)
BOY_SPEED = 2           # 1 フレームで進むドット数

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


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

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

    def update(self):
        """フレーム毎の更新処理"""
        self.move_boy()
        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 place_candy(self):
        """お菓子を画面のどこかへ置きなおす"""
        self.candy_x = random.randint(0, SCREEN_WIDTH - CANDY_SIZE)
        self.candy_y = random.randint(0, SCREEN_HEIGHT - CANDY_SIZE)

    def check_candy(self):
        """お菓子に重なったら、次の場所へ置きなおす"""
        if is_hit(self.boy_x, self.boy_y, BOY_SIZE,
                  self.candy_x, self.candy_y, CANDY_SIZE):
            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.boy_x, self.boy_y, 0, 0, 0,
                  BOY_SIZE, BOY_SIZE, pyxel.COLOR_BLACK)        # 男の子

App()

5. コードの解説

return ——「結果を返す」関数

これまで書いてきた move_boy()place_candy() は、 やって終わりの関数でした。is_hit() は違います。 判定した答えを呼び出し元に返します

def is_hit(...):
    return 条件式          # ← この式の結果(True か False)が返る

そのため、呼び出す側では if の条件としてそのまま使えます。

if is_hit(...):            # is_hit(...) が True のときだけ中に入る
    self.place_candy()
return True と書かなくていいの? よくある書き方はこちらですが、
if x1 < x2 + size2 and ...:
    return True
else:
    return False
これは「条件式そのものがすでに TrueFalse」なので、 return 条件式 と書けば同じことです。短いほうが読みやすく、 Python ではこちらが普通です。

なぜ class の外に書いたのか

is_hit()self をまったく使いません。 渡された 6 個の数字だけを見て答えを出す、独立した計算です。 こういう関数はクラスの中に入れる必要がなく、外に出しておくと 「これは App の状態とは無関係だ」と一目で分かります。

実用面でも得があります。フェーズ 5 でお化けが登場したとき、 同じ関数をそのまま使いまわせます

# フェーズ 6 ではこう使う予定
if is_hit(self.boy_x, self.boy_y, BOY_SIZE,
          ghost_x, ghost_y, GHOST_SIZE):
    # 捕まった

and でつないだ 4 つの条件

return (x1 < x2 + size2 and x2 < x1 + size1 and
        y1 < y2 + size2 and y2 < y1 + size1)

and は「両方とも成り立つ」という意味なので、 4 つを and でつなぐと「4 つすべてが成り立つ」になります。 1 つでも成り立たなければ、全体は False です。

全体を ( ) で囲んでいるのは、途中で改行するためです。 フェーズ 2 の blt() と同じで、カッコの中は自由に改行できます。 カッコがないと、Python は 1 行目で式が終わったと判断してエラーになります。

randint両端を含む

random.randint(0, 152)     # 0, 1, 2, ... 151, 152 のどれか

range(0, 152) が 151 までなのと違って、randint152 も出ます。ここでは 152 に出てほしい(右端にぴったり着く位置)ので、 これで正解です。

まぎらわしい仲間たち 座標に使うなら randint がいちばん素直です。

描く順番=重なる順番

draw() でお菓子を先に、男の子をあとに描いています。 あとに描いたものが上に重なるので、こうすると男の子がお菓子の上を歩いているように見えます。 逆にすると、重なったときに男の子がお菓子の下に隠れてしまいます。

① お菓子 → 男の子 の順(このページのコード) お菓子 男の子 重なった角が男の子の色 → 男の子が上 ② 男の子 → お菓子 の順 お菓子 男の子 重なった角がお菓子の色 → 男の子が下
あとから描いたものが上になる。背景 → 遠くのもの → 近くのもの の順に描くのが基本

self.candy_x__init__ に直接書いていない

男の子は __init__ の中で self.boy_x = ... と直接書きましたが、 お菓子は self.place_candy() を呼ぶ形にしています。 self. の変数は、__init__ から呼ばれた関数の中で作っても大丈夫です。

こうしている理由は、同じ処理をあとでもう一度使うからです。 「最初に置く」も「食べられたので置きなおす」も、やることはまったく同じなので、 1 つの関数にまとめておけば、両方から呼べます。

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

いまのコードには、小さな穴が 1 つあります。置きなおした先がたまたま男の子の真下だと、 出た瞬間にまた食べられてしまいます。100 回に 1 回程度なので気にしなくても遊べますが、 気になる場合は place_candy() をこう書きかえます。

    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

while True: は「ずっとくり返す」、break は「くり返しから抜ける」です。 つまり「男の子と重ならない場所が出るまで、くじを引きなおす」という意味になります。 たいてい 1 回目で抜けます。

この書き方にするときの注意 place_candy()self.boy_x を読むようになるので、 __init__ では男の子の座標を決めたあとに呼ばなければいけません。 手順どおりに書いていれば、すでにその順番になっています。

取った手ごたえが薄いと感じたら

遊んでみると、かすっただけでお菓子が消えるのが気になるかもしれません。 判定に使っている 8×8 の四角は絵より大きいため、 見た目にはまだ離れているのに当たってしまう位置があります。 実際に数えると、当たりになる 225 通りのうち 81 通り(36%)は絵が 1 ドットも重なっていません

直し方は「判定に使う四角を、絵より小さくする」です。 数え上げた根拠とコードは、備忘録の 「取った感じが薄い。かすっただけで次のお菓子が出てしまう」 にまとめてあります。フェーズ 6 でお化けに捕まる判定を書くときにも効いてくるので、 気になったらそこで読んでください。

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

症状見るところ
NameError: name 'random' is not defined import random を書き忘れています
AttributeError: 'App' object has no attribute 'candy_x' __init__self.place_candy() を書き忘れているか、 pyxel.run() よりあとに書いています
NameError: name 'is_hit' is not defined is_hit() がクラスのにインデントして入っています。 クラスの外(いちばん左)に出します。 中に置いたままにするなら self.is_hit(...) と呼ぶ必要があります
お菓子が表示されない blt() の切り出し位置が 16, 0 になっているか確認します。 お菓子はイメージバンク 0 の u = 16 です
お菓子が画面の右下だけに出ない/端で切れる randint の上限が SCREEN_WIDTH のままです。 - CANDY_SIZE を引きます
触っていないのにお菓子が移動し続ける is_hit() の不等号の向きが逆になっている可能性があります。 4 つとも < で、左辺が左上の座標、右辺が反対側の右下です
重なっても何も起きない update()self.check_candy() を足したか確認します
男の子がお菓子の下に隠れる draw()blt() の順番が逆です。お菓子を先に描きます
SyntaxErroris_hit の行で) return のうしろの ( ) が抜けています。 カッコがないと 2 行に分けられません

8. 次のフェーズ

ゲームの中心ができました。ただ、いまは何個集めたのか分かりません。 次は集めた数をスコアとして画面に表示します。 数える変数を 1 つ増やして、pyxel.text() で描くだけなので、 このフェーズよりずっと短くなります。