ランダムな場所にお菓子を出し、重なったら次の場所へ移す
お菓子を画面のどこかに 1 個置き、男の子が重なったら消して、また別の場所に出す ところまでを作ります。これができれば、ゲームの中心になる部分は完成です。
この step の山場です。新しく出てくるものは 3 つあります。
random — 置き場所を毎回でたらめに決めるTrue か False を「返す」関数を書く
フェーズ 2 で男の子に self.boy_x / self.boy_y を持たせたのと同じで、
お菓子にも self.candy_x / self.candy_y を持たせます。
違うのは決め方です。男の子は「画面の中央」と決め打ちでしたが、 お菓子は毎回ちがう場所に出したいので、乱数(ランダムな数)で決めます。
お菓子も 8×8 なので、置ける座標の範囲は男の子とまったく同じです。
0 〜 SCREEN_WIDTH - CANDY_SIZE(= 152)0 〜 SCREEN_HEIGHT - CANDY_SIZE(= 112)
この範囲からでたらめに 1 つ選ぶのが random.randint(0, 152) です。
ここが本題です。横と縦を分けて考えるので、まず横だけを見ます。
男の子の横の範囲は boy_x から boy_x + 8 まで、
お菓子の横の範囲は candy_x から candy_x + 8 までです。
この 2 つの範囲が重なっている条件は、次の 2 つが同時に成り立つことです。
boy_x < candy_x + 8 # 男の子の左端が、お菓子の右端より左
candy_x < boy_x + 8 # お菓子の左端が、男の子の右端より左
縦もまったく同じ形です。x を y に置きかえるだけで、
boy_y < candy_y + 8
candy_y < boy_y + 8
となります。この 4 つがすべて成り立ったときだけ「当たった」——これが当たり判定です。
<= ではなく < なのか
boy_x = 40、candy_x = 48 のとき、男の子は 40〜47 のドット、
お菓子は 48〜55 のドットを使います。共有しているドットは 1 つもありませんので、
これは「当たっていない」が正解です。candy_x < boy_x + 8 は
48 < 48 となって False。ぴったり隣り合ったときに
当たらない、という正しい結果になります。<= にすると、
隣に並んだだけで当たってしまいます。
「お菓子を消して、次のお菓子を出す」と考えると難しく感じますが、 実際にやることは座標を新しい値に書きかえるだけです。 お菓子は最初から最後までずっと 1 個で、場所を移動しているだけと考えます。
if 重なった:
self.place_candy() # 座標を決めなおす = 別の場所に現れる
消す処理も、作る処理もいりません。draw() は毎フレーム
「いまの candy_x / candy_y に 1 個描く」だけなので、
座標が変わればそのまま新しい場所に現れます。
| 機能 | 役割 |
|---|---|
import random | 乱数を使うための標準ライブラリを読み込む |
random.randint(a, b) | a 以上 b 以下の整数をでたらめに 1 つ返す(両端を含む) |
return | 関数の結果を呼び出し元へ返す |
and | 左右の条件が両方 True のときだけ True |
random を読み込み、定数を足す
ファイルのいちばん上に import random を足します。
import random
import pyxel
random を上、pyxel を下に書くのはなぜ?
Python には「標準ライブラリ → その他のライブラリの順に、あいだを 1 行空けて書く」という
慣習(PEP 8)があります。random は Python にはじめから入っている標準ライブラリ、
pyxel は pip で入れたものなので、この順になります。
逆に書いても動きますが、そろえておくと読む人が迷いません。
定数にお菓子の大きさを足します。
BOY_SIZE = 8 # 男の子の絵の大きさ(縦横とも 8 ドット)
BOY_SPEED = 2 # 1 フレームで進むドット数
CANDY_SIZE = 8 # お菓子の絵の大きさ
CANDY_SIZE を 1 か所変えるだけで済みます。
1 つの定数を使い回していると、どの 8 がお菓子のものか分からなくなります。
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)
__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 個出れば成功です。 重なってもまだ何も起きません。
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()
実行して、お菓子に触れると別の場所へ移れば完成です。
# step1 フェーズ1:描いた 3 つの絵を並べて表示する の
ままになっているはずです。いまの中身に合わせて書きかえておくと、
あとで開いたときに迷いません。
# 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()
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
これは「条件式そのものがすでに True か False」なので、
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 までなのと違って、randint は
152 も出ます。ここでは 152 に出てほしい(右端にぴったり着く位置)ので、
これで正解です。
random.randint(0, 152) … 0 〜 152(152 を含む)random.randrange(0, 152) … 0 〜 151(152 を含まない。range と同じ)random.random() … 0.0 以上 1.0 未満の小数randint がいちばん素直です。
draw() でお菓子を先に、男の子をあとに描いています。
あとに描いたものが上に重なるので、こうすると男の子がお菓子の上を歩いているように見えます。
逆にすると、重なったときに男の子がお菓子の下に隠れてしまいます。
self.candy_x を __init__ に直接書いていない
男の子は __init__ の中で self.boy_x = ... と直接書きましたが、
お菓子は self.place_candy() を呼ぶ形にしています。
self. の変数は、__init__ から呼ばれた関数の中で作っても大丈夫です。
こうしている理由は、同じ処理をあとでもう一度使うからです。 「最初に置く」も「食べられたので置きなおす」も、やることはまったく同じなので、 1 つの関数にまとめておけば、両方から呼べます。
いまのコードには、小さな穴が 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 でお化けに捕まる判定を書くときにも効いてくるので、 気になったらそこで読んでください。
| 症状 | 見るところ |
|---|---|
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() の順番が逆です。お菓子を先に描きます |
SyntaxError(is_hit の行で) |
return のうしろの ( ) が抜けています。
カッコがないと 2 行に分けられません |
ゲームの中心ができました。ただ、いまは何個集めたのか分かりません。
次は集めた数をスコアとして画面に表示します。
数える変数を 1 つ増やして、pyxel.text() で描くだけなので、
このフェーズよりずっと短くなります。