相手の座標と自分の座標を見くらべて、近づく向きへ動かす
お化けを 1 体登場させ、男の子をどこまでも追いかけてくるようにします。 まだ捕まっても何も起きません(それはフェーズ 6 です)。 このフェーズは「追いかける」という動きだけを作ります。
書き足すのは 15 行ほどです。フェーズ 2 で書いた move_boy() と
よく似た形になりますが、1 か所だけ決定的に違うところがあります。そこが今回の山です。
むずかしそうに聞こえますが、やることは「相手の座標と自分の座標のどちらが大きいか」を 見て、小さいほうへ寄せていくだけです。横と縦を別々に考えます。
お化けの x が男の子の x より小さい → お化けの x を増やす(右へ)
お化けの x が男の子の x より大きい → お化けの x を減らす(左へ)
同じ → x は動かさない
縦もまったく同じです。横と縦をそれぞれ独立に決めるので、 斜め方向にいる男の子には自然と斜めに近づいていきます。
elif を使う(フェーズ 2 とは逆)
フェーズ 2 では「elif にせず if を 4 つ並べる」と説明しました。
今回は逆で、elif が正解です。理由は条件の性質が違うからです。
| 条件どうしの関係 | 書き方 | |
|---|---|---|
| フェーズ 2 (男の子の移動) |
同時に成り立ちうる ← と ↑ は同時に押せる |
if を並べる |
| フェーズ 5 (お化けの追跡) |
同時には成り立たない 「相手より左」と「相手より右」は両立しない |
if / elif |
どちらも「あてはまるものを全部やるのか、1 つだけ選ぶのか」で決まります。
横の動きは 3 択から 1 つなので elif、
キー入力は押されているものすべてなので if の並列、というわけです。
男の子は 1 フレームに 2 ドット進みます。お化けを同じ速さにすると、
いちど追いつかれたら二度と離れられません。
GHOST_SPEED = 1(男の子の半分)にして、逃げ切れる余地を作ります。
GHOST_SPEED を調整できます。
0.5 など)も使えますが、0.3 のような値を選ぶと
お化けが目標の周りで永久に往復します。
測った数字と理由は備忘録 #chase-speed にまとめました。
男の子には max / min で押し戻す処理を書きましたが、
お化けには要りません。お化けは「男の子の座標へ近づく」ようにしか動かず、
その男の子が画面内に収まっているからです。
しかも GHOST_SPEED = 1 なら 1 ドットずつしか動かないので、
男の子の座標を行き過ぎることもありません。
新しい API はありません。すでに知っているものだけで書けます。
| 機能 | 役割 |
|---|---|
if / elif | 3 つの選択肢から 1 つだけ選ぶ |
< > | 2 つの座標のどちらが大きいか比べる |
pyxel.blt() | お化けを描く(切り出し位置は u = 8) |
CANDY_SIZE = 8 # お菓子の絵の大きさ
GHOST_SIZE = 8 # お化けの絵の大きさ
GHOST_SPEED = 1 # 1 フレームで進むドット数(男の子の半分)
__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()
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
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) # 男の子
実行して、左上からお化けが近づいてくれば完成です。 捕まってもまだ何も起きません。男の子のほうが速いので、逃げ続けられるはずです。
# 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 を使って作ります。
else ではなく elif にする理由
2 つめを else にすると、こう書けそうに見えます。
# これはうまくいかない
if self.ghost_x < self.boy_x:
self.ghost_x += GHOST_SPEED
else: # ← 「小さくないなら」= 大きい+等しい
self.ghost_x -= GHOST_SPEED
問題は「等しいとき」も else に入ってしまうことです。
真上に重なった瞬間から、お化けは左右にふるえ続けます。
| フレーム | ghost_x | boy_x | 判定 | 次の ghost_x |
|---|---|---|---|---|
| 1 | 76 | 76 | 小さくない → else | 75 |
| 2 | 75 | 76 | 小さい → if | 76 |
| 3 | 76 | 76 | 小さくない → else | 75 |
| 4 | 75 | 76 | 小さい → if | 76 |
elif self.ghost_x > self.boy_x: と書けば、
等しいときはどちらにも入らないので、その場にとどまります。
if / elif だけで else を書かないと、
「どちらの条件にも当てはまらないときは何もしない」という意味になります。
何もしないことを明示するために else: pass と書く必要はありません。
今回のように「動かない」が正解の場合、書かないのがいちばん素直です。
move_ghost() は横のブロックと縦のブロックが独立しているので、
両方が同時に成り立てば斜めに 1 ドットずつ進みます。
そのため、お化けの軌跡は「まず斜めに進み、縦か横がそろったら直線」になります。
本当にまっすぐ向かわせたければ角度の計算が必要になりますが、 この形のほうが「じわじわ迫ってくる」感じが出ます。 昔のゲームのお化けも、たいていこの単純な追い方をしています。
フェーズ 3 で見たとおり、あとに描いたものが上になります。 男の子をいちばん最後に描くことで、お化けに重なられても 自分の位置を見失わずに済みます。
(0, 0) にした理由男の子が画面の中央にいるので、いちばん遠い場所のひとつだからです。 起動した瞬間に隣にいると、避ける間もありません。
self.ghost_x = 0 は place_candy() と違って単純でよい
お菓子は「取られるたびに置きなおす」ので関数にしましたが、
お化けの初期位置はいまのところ 1 回しか使いません。
フェーズ 7 で「もう一度あそぶ」を作るときに、
お菓子と同じくやり直し用の関数へまとめることになります。
そのときにまとめれば十分です。
いつも左上から来るので、慣れると先読みできてしまいます。 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 つの変数へ一度に受け取っています。
| 症状 | 見るところ |
|---|---|
| お化けが動かない | update() に self.move_ghost() を足したか確認します |
| お化けが表示されない | blt() の切り出し位置が 8, 0 になっているか確認します。
お化けはイメージバンク 0 の u = 8 です |
| お化けが小刻みにふるえる | 2 つめが else になっています。elif にします |
| お化けが遠ざかっていく | += と -= が逆です。
自分が小さい(左にいる)なら増やす(右へ進む)です |
| お化けが真横・真下にしか動かない | 横と縦を elif でつないでしまっています。
横のブロックと縦のブロックは別々の if です。
あいだを 1 行空けておくと間違えにくくなります |
| すぐ捕まって逃げられない | GHOST_SPEED が BOY_SPEED 以上になっていませんか。
お化けは 1、男の子は 2 です |
| お化けが男の子の下に隠れて見えない | 仕様どおりです。男の子をいちばん最後に描いているためです。
気になる場合は blt() の順番を入れかえてください |
AttributeError: ... 'ghost_x' |
__init__ の self.ghost_x = 0 を書き忘れているか、
pyxel.run() よりあとに書いています |
追いかけられるようになりました。次はお化けに捕まったらゲームオーバー画面へ切りかえます。
step1 概要の「土台 2:今どの画面かを変数で覚える」で説明した
画面の切りかえが、いよいよ登場します。
捕まった判定には、フェーズ 3 で書いた is_hit() をそのまま使います。