位置を変数で覚えさせ、キー入力に合わせて毎フレーム動かす
フェーズ 1 で並べた 3 つの絵のうち、男の子だけを画面に残し、 矢印キーで上下左右に動かせるようにします。 画面の端まで行ったら、そこで止まって外へは出ないところまでを作ります。
フェーズ 1 の candy_hunt.py に書き足していく形です。新しいファイルは作りません。
update() の pass が、いよいよ中身に変わります。
.pyxres に残っているので、消す必要はありません。
Pyxel は pyxel.run(self.update, self.draw) を呼んだあと、
1 秒間に 30 回のペースで update() と draw() をくり返し呼び続けます。
絵が動いて見えるのは、この 1 回ごとに描く座標をほんの少しずらしているからです。
パラパラ漫画と同じ仕組みです。
init と load は起動時に 1 回だけ。update と draw だけが毎フレーム呼ばれるinit() の役割でつまずいたら
pyxel.init() が「入れ物を用意する関数」であることと、
load() を先に書くと Pyxel not initialized で落ちる理由は、
備忘録の「init() は何をしている?」にまとめてあります。
self に覚えさせる
男の子の位置は「いま画面のどこにいるか」というずっと持ち続ける情報です。
一方 update() は毎フレーム呼び出され、そのたびに中の処理が最初から実行されます。
つまり update() の中で
def update(self):
boy_x = 76 # ← 毎フレーム 76 に戻ってしまう
if pyxel.btn(pyxel.KEY_RIGHT):
boy_x += 2
と書くと、右キーを押しても boy_x は 78 になった直後に捨てられ、
次のフレームでまた 76 から始まります。1 ドットも進みません。
そこで self.boy_x という形でインスタンス(App 自身)にくっつけて持たせます。
self. がついた変数は __init__ で一度作れば、
update() が何回呼ばれても値が残り続けます。
| 書き方 | 作る場所 | 寿命 |
|---|---|---|
boy_x = 76 | update() の中 | その 1 回の update() が終わるまで |
self.boy_x = 76 | __init__ の中 | プログラムが終わるまで |
self.ship_x、ひよこの self.hiyoko_scale、
魚の self.fish_x ——「フレームをまたいで覚えておきたいもの」は、
どれも __init__ で self. をつけて用意していました。同じ考え方です。
update() で決めて、draw() で描く
キーを見て座標を変えるのは update()、その座標に絵を貼るのは draw() です。
この 2 つは役割を混ぜません。
| やること | やらないこと | |
|---|---|---|
update() | キーを調べる、座標を計算する、当たり判定をする | 画面に描く |
draw() | 画面を消す、絵や文字を描く | 座標を変える |
draw() の中に self.boy_x += 2 と書いても、実は動いてしまいます。
それでも分けるのは、あとで「ゲームオーバー中は動かないが、絵は描き続ける」のような
場面が必ず出てくるからです。分けておけば update() の中身を呼ばないだけで実現できます。
フェーズ 6 と 7 で、この分け方が効いてきます。
何もしないと、男の子は画面の外までどこまでも歩いて行って見えなくなります。 座標に上限と下限を設けて、はみ出したら端の値に押し戻します。
ここで大事なのは、上限が 160 ではなく 152 になることです。
blt() に渡す座標は絵の左上の位置なので、
左上が 152 のとき絵の右端がちょうど 159(画面のいちばん右のドット)に来ます。
縦も同じで、self.boy_y の範囲は 0 から
SCREEN_HEIGHT - BOY_SIZE(= 120 − 8 = 112)までです。
| 機能 | 役割 |
|---|---|
pyxel.btn(キー) | そのキーが押されているあいだ True を返す |
pyxel.KEY_LEFT / KEY_RIGHT / KEY_UP / KEY_DOWN | 矢印キーを表す番号 |
max(a, b) | 2 つのうち大きいほうを返す(下限を作るのに使う) |
min(a, b) | 2 つのうち小さいほうを返す(上限を作るのに使う) |
一度に全部書かず、3 段階に分けて書き足します。 途中で実行して、そのつど動きを確かめてください。
まず、ファイルの上のほうにある定数に 2 行足します。
SCREEN_WIDTH = 160
SCREEN_HEIGHT = 120
BOY_SIZE = 8 # 男の子の絵の大きさ(縦横とも 8 ドット)
BOY_SPEED = 2 # 1 フレームで進むドット数
次に __init__ の pyxel.load() と pyxel.run() のあいだに、
男の子の初期位置を 2 行足します。画面の中央に置きます。
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 # ← 足す
pyxel.run(self.update, self.draw)
- BOY_SIZE // 2 を引くのか
SCREEN_WIDTH // 2(= 80)だけだと、絵の左上が中央に来るため、
見た目は右下に 4 ドットずれます。絵の大きさの半分を引くと、絵の真ん中が画面の中央に来ます。
// を使うのは、座標を整数のままにしておきたいからです
(/ と // の使い分け)。
update() の pass を消して、移動処理を書きます。
移動そのものは move_boy() という別のメソッドに分けます。
def update(self):
"""フレーム毎の更新処理"""
self.move_boy()
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
最後に draw() を書き換えます。3 行あった blt() を 1 つにして、
座標を self.boy_x / self.boy_y に差し替えます。
def draw(self):
"""フレーム毎の描画処理"""
pyxel.cls(pyxel.COLOR_BLACK)
pyxel.blt(self.boy_x, self.boy_y, 0, 0, 0,
BOY_SIZE, BOY_SIZE, pyxel.COLOR_BLACK)
ここで一度実行してください。矢印キーで男の子が動けば成功です。 ただし端まで行くとそのまま画面の外へ出て、見えなくなってしまうはずです。 それを次で直します。
move_boy() の末尾に 2 行足します。
4 つの if がすべて終わったあと、最後にまとめて範囲内へ押し戻すのがコツです。
# 画面の外へ出さない
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))
# candy_hunt.py
# step1 フェーズ2:男の子を矢印キーで動かす
import pyxel
SCREEN_WIDTH = 160
SCREEN_HEIGHT = 120
BOY_SIZE = 8 # 男の子の絵の大きさ(縦横とも 8 ドット)
BOY_SPEED = 2 # 1 フレームで進むドット数
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
pyxel.run(self.update, self.draw)
def update(self):
"""フレーム毎の更新処理"""
self.move_boy()
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 draw(self):
"""フレーム毎の描画処理"""
pyxel.cls(pyxel.COLOR_BLACK)
pyxel.blt(self.boy_x, self.boy_y, 0, 0, 0,
BOY_SIZE, BOY_SIZE, pyxel.COLOR_BLACK)
App()
btn() と btnp() の違い
名前が似ていますが、True を返すタイミングがまったく違います。
| 関数 | True になるとき | 向いている用途 |
|---|---|---|
pyxel.btn(キー) | 押しているあいだ ずっと(毎フレーム) | 移動 |
pyxel.btnp(キー) | 押した瞬間の 1 フレームだけ | 決定、ジャンプ、画面の切り替え |
移動に btnp() を使うと、キーを押しっぱなしにしても2 ドット進んで止まるだけになります。
逆にフェーズ 7 の「タイトル画面でキーを押してゲーム開始」には btnp() を使います。
btn() だと、押した勢いのままゲームオーバー画面まで一瞬で飛んでしまうからです。
if を 4 つ並べる。elif にはしない
4 つの if は、それぞれ独立して判定されます。
これを elif でつなぐと、どれか 1 つが成立した時点で残りは調べられません。
斜めに動けなくなります。
if を並べるmax と min で挟んで範囲に収めるself.boy_x = max(0, min(self.boy_x, SCREEN_WIDTH - BOY_SIZE))
内側から順に読みます。
min(self.boy_x, 152) … 152 を超えていたら 152 にする(右端で止める)max(0, ...) … 0 を下回っていたら 0 にする(左端で止める)そのときの self.boy_x | min(x, 152) の結果 | max(0, ...) の結果 |
|---|---|---|
| −2(左へ出た) | −2 | 0 |
| 76(画面内) | 76 | 76 |
| 154(右へ出た) | 152 | 152 |
if で書くこともできますが 4 行かかります。
max / min なら 1 行で、しかも「この変数は 0 〜 152 に収まる」と
一目で読み取れます。step0 で魚を画面の端で折り返させたときと同じ書き方です。
# if で書くとこうなる(動きは同じ)
if self.boy_x < 0:
self.boy_x = 0
if self.boy_x > SCREEN_WIDTH - BOY_SIZE:
self.boy_x = SCREEN_WIDTH - BOY_SIZE
if 4 つの「あと」に書きます
先に押し戻してから移動すると、そのフレームでまた画面の外へ出た状態で draw() に進みます。
「動かしきってから、最後に範囲へ収める」という順番が大事です。
move_boy() に分けたのか
いまは update() の中身が 1 行だけなので、分けた意味が薄く見えるかもしれません。
これは先を見越した準備です。フェーズが進むと update() はこうなります。
def update(self):
self.move_boy() # フェーズ 2
self.check_candy() # フェーズ 3
self.move_ghosts() # フェーズ 5
self.check_caught() # フェーズ 6
こうしておくと update() を読むだけで「毎フレーム何が起きるか」の一覧になり、
中身を知りたいときだけ下の定義を見に行けばよくなります。
関数を分ける基準は備忘録のこちらにまとめてあります。
blt() の引数を 2 行に折り返しているpyxel.blt(self.boy_x, self.boy_y, 0, 0, 0,
BOY_SIZE, BOY_SIZE, pyxel.COLOR_BLACK)
( と ) の中は、途中で改行してかまいません。
Python は閉じカッコが来るまで 1 つの式として読み続けるからです。
行が長くなって読みにくいときに使える書き方で、2 行目のインデントは自由ですが、
1 行目のカッコの中身に頭を揃えるのが一般的です。1 行のまま書いても問題ありません。
| 症状 | 見るところ |
|---|---|
| キーを押しても動かない | update() に self.move_boy() を書いたか確認します。
pass だけが残っていると、move_boy() は定義されていても呼ばれません |
AttributeError: 'App' object has no attribute 'boy_x' |
__init__ の self.boy_x = ... を書き忘れているか、
pyxel.run() よりあとに書いています。run() はいちばん最後です |
| 男の子が画面の中央にいない | - BOY_SIZE // 2 を引いているか確認します。
引いていないと右下に 4 ドットずれます |
| 斜めに動かない(← と ↑ の同時押しで横にしか進まない) | elif になっています。4 つとも if にします |
| 押しっぱなしでも 1 回しか進まない | btnp() になっています。移動は btn() です |
| 端で止まらず画面の外へ出る | max / min の 2 行が move_boy() の中にあるか、
また 4 つの if より下にあるかを確認します |
| 右端や下端で絵が少しはみ出す | 上限が SCREEN_WIDTH / SCREEN_HEIGHT のままになっています。
- BOY_SIZE を引きます |
| 動きが速すぎる/遅すぎる | BOY_SPEED の数字を変えます。1・2・3 のうち好みのものを選んでください |
操作できるキャラクターができました。次はお菓子を 1 個置き、重なったら消して次の場所に出します。
この step の山場である当たり判定と、置き場所を決めるための random が登場します。
step1 概要の「土台 1:当たり判定は横と縦に分けて考える」を
先に読み返しておくと、すんなり入れるはずです。