step0:星空を飛ぶ宇宙船
フェーズ 5 で画面に出した宇宙船を、矢印キーで上下左右に動かします。 あわせて、画面の外へ出ていかないように制限します。
pyxel.btn で「今キーが押されているか」を調べるself.ship_x / self.ship_y を書き換えるmax と min で、位置を画面の中へ押し戻すself.ship_x / self.ship_y
の 2 つだけです。描画のコード(draw)はいっさい触りません。
ふつうのアプリは「キーが押されたら、この処理を呼ぶ」という書き方(イベント)をします。 Pyxel はそうではありません。毎フレーム、こちらから「今このキーは押されてる?」と 聞きにいきます。
if pyxel.btn(pyxel.KEY_LEFT): # 今、左キーは押されている?
self.ship_x -= SHIP_SPEED # 押されていれば 2 ドット左へ
これが 1 秒に 30 回くり返されます。キーを押しっぱなしにすると、 1 フレームに 2 ドットずつ、なめらかに動いていくわけです。 「移動する処理」を書いているのではなく、「1 フレームぶんの移動」を書いている と考えてください。
btn と btnp の違い似た名前の関数が 2 つあります。この違いが分かっていないと、 「一瞬しか動かない」という壁にぶつかります。
| 関数 | True になるのは | 向いている用途 |
|---|---|---|
pyxel.btn(key) |
押されているあいだ、ずっと | 移動、ショットの連射 |
pyxel.btnp(key) |
押した瞬間の 1 フレームだけ(p は press) | 決定ボタン、ジャンプ、画面の切り替え |
btnp にすると True は 3 フレーム目だけなので、ship_x は 78 になったきり動かないbtn を使う
btnp で移動を書くと、キーを押しっぱなしにしても 1 回しか動きません。
連打しないと進まないゲームになってしまいます。
if を 4 つ並べる(elif にしない)
上下左右の 4 方向を、次のように if を 4 つ並べて書きます。
if pyxel.btn(pyxel.KEY_LEFT):
self.ship_x -= SHIP_SPEED
if pyxel.btn(pyxel.KEY_RIGHT):
self.ship_x += SHIP_SPEED
if pyxel.btn(pyxel.KEY_UP):
self.ship_y -= SHIP_SPEED
if pyxel.btn(pyxel.KEY_DOWN):
self.ship_y += SHIP_SPEED
つい if / elif / elif / elif と書きたくなりますが、それでは斜めに動けません。
elif は「前の if が成立しなかったときだけ調べる」という意味なので、
左と上を同時に押しても、最初に見つかった左だけが実行されて終わります。
elif ではなく if を並べる-2 してから +2 されるので、差し引きゼロでその場に止まります。
elif なら左が優先されます。どちらも間違いではありませんが、
止まるほうが自然なので、このままにします。
このままだと、左キーを押し続ければ宇宙船は画面の外へ消えていきます (エラーにはなりませんが、二度と見えません)。そこで、移動させたあとに 「行きすぎていたら引き戻す」という処理を足します。
ここで大事なのが、blt に渡す x, y は
宇宙船の左上のかどだということです(フェーズ 5)。
だから右端は SCREEN_WIDTH(160)ではなく、
SCREEN_WIDTH - SHIP_WIDTH(144)になります。
144 のとき、宇宙船の右端がちょうど画面の右端に届きます。
押し戻しは max と min の組み合わせで 1 行に書けます。
self.ship_x = max(0, min(self.ship_x, SCREEN_WIDTH - SHIP_WIDTH))
入れ子になっていて読みにくいので、内側から読んでください。
これは次の if 文 2 つと、まったく同じ意味です。
if self.ship_x > SCREEN_WIDTH - SHIP_WIDTH: # 右へ行きすぎ?
self.ship_x = SCREEN_WIDTH - SHIP_WIDTH # → 右端に戻す = min の役目
if self.ship_x < 0: # 左へ行きすぎ?
self.ship_x = 0 # → 左端に戻す = max の役目
max(下限, min(値, 上限)) は決まり文句として覚えてしまってかまいません。
| API | 意味 |
|---|---|
pyxel.btn(key) |
key が押されているあいだ True を返す |
pyxel.btnp(key) |
key を押した瞬間の 1 フレームだけ True を返す |
pyxel.btnr(key) |
key を離した瞬間の 1 フレームだけ True を返す(今回は使わない) |
pyxel.KEY_LEFT / pyxel.KEY_RIGHTpyxel.KEY_UP / pyxel.KEY_DOWN |
矢印キーを表す定数。中身はただの数値だが、数値のまま書くと何のキーか分からなくなる |
Python の組み込み関数も 2 つ使います。Pyxel とは関係なく、Python にもともとあるものです。
| 関数 | 意味 | 例 |
|---|---|---|
max(a, b) | 大きいほうを返す | max(0, -6) → 0 |
min(a, b) | 小さいほうを返す | min(150, 144) → 144 |
変更は 3 か所だけです。draw は 1 文字も触りません。
SHIP_HEIGHT の下)SHIP_SPEED = 2 # 1 フレームに進むドット数
move_ship メソッドを足す(text_center の下) def move_ship(self):
"""矢印キーで宇宙船を動かす。画面の外へは出さない"""
if pyxel.btn(pyxel.KEY_LEFT):
self.ship_x -= SHIP_SPEED
if pyxel.btn(pyxel.KEY_RIGHT):
self.ship_x += SHIP_SPEED
if pyxel.btn(pyxel.KEY_UP):
self.ship_y -= SHIP_SPEED
if pyxel.btn(pyxel.KEY_DOWN):
self.ship_y += SHIP_SPEED
# 画面の外へ出ないように、行きすぎた分を押し戻す
self.ship_x = max(0, min(self.ship_x, SCREEN_WIDTH - SHIP_WIDTH))
self.ship_y = max(0, min(self.ship_y, SCREEN_HEIGHT - SHIP_HEIGHT))
def move_ship(self): は def update(self): と同じ深さ
(スペース 4 個ぶん)にそろえます。ここがずれると
AttributeError: 'App' object has no attribute 'move_ship' になります。
update の先頭から呼び出す def update(self):
"""毎フレームの更新処理"""
self.move_ship() # ← 追加:先に宇宙船を動かす
for star in self.stars:
star[1] += star[2]
...(以下はそのまま)
# star_voyager.py
# step0 フェーズ6:矢印キーで宇宙船を動かす
import random
import pyxel
SCREEN_WIDTH = 160
SCREEN_HEIGHT = 120
TITLE = "STAR VOYAGER"
GUIDE = "PRESS ESC TO QUIT"
STAR_COUNT = 40 # 星の数
STAR_SPEED_MIN = 0.3 # 星の速さの下限(ドット/フレーム)
STAR_SPEED_MAX = 1.8 # 星の速さの上限
SHIP_U = 0 # 台紙のどこから切り出すか(横)
SHIP_V = 0 # 台紙のどこから切り出すか(縦)
SHIP_WIDTH = 16 # 宇宙船の幅
SHIP_HEIGHT = 16 # 宇宙船の高さ
SHIP_SPEED = 2 # 1 フレームに進むドット数
class App:
"""このゲーム全体を受け持つ箱"""
def __init__(self):
"""箱が作られるときに 1 回だけ呼ばれる。ここで準備をすませる"""
pyxel.init(SCREEN_WIDTH, SCREEN_HEIGHT, title="Star Voyager")
pyxel.load("assets.pyxres") # 絵と音のファイルを読み込む
self.stars = [] # 星をぜんぶ入れるリスト
self.make_stars()
self.ship_x = (SCREEN_WIDTH - SHIP_WIDTH) // 2 # 横は画面の中央
self.ship_y = 80 # 縦は下のほう
pyxel.run(self.update, self.draw)
def make_stars(self):
"""星を STAR_COUNT 個ぶん作って self.stars に入れる"""
for i in range(STAR_COUNT):
x = random.randint(0, SCREEN_WIDTH - 1)
y = random.randint(0, SCREEN_HEIGHT - 1)
speed = random.uniform(STAR_SPEED_MIN, STAR_SPEED_MAX)
self.stars.append([x, y, speed])
def star_color(self, speed):
"""速い星ほど明るい色にして、奥行きを表す"""
if speed > 1.2:
return pyxel.COLOR_WHITE
if speed > 0.7:
return pyxel.COLOR_GRAY
return pyxel.COLOR_DARK_BLUE
def text_center(self, y, s, col):
"""文字列 s を、画面の横中央に来るように描く"""
x = (SCREEN_WIDTH - len(s) * pyxel.FONT_WIDTH) // 2
pyxel.text(x, y, s, col)
def move_ship(self):
"""矢印キーで宇宙船を動かす。画面の外へは出さない"""
if pyxel.btn(pyxel.KEY_LEFT):
self.ship_x -= SHIP_SPEED
if pyxel.btn(pyxel.KEY_RIGHT):
self.ship_x += SHIP_SPEED
if pyxel.btn(pyxel.KEY_UP):
self.ship_y -= SHIP_SPEED
if pyxel.btn(pyxel.KEY_DOWN):
self.ship_y += SHIP_SPEED
# 画面の外へ出ないように、行きすぎた分を押し戻す
self.ship_x = max(0, min(self.ship_x, SCREEN_WIDTH - SHIP_WIDTH))
self.ship_y = max(0, min(self.ship_y, SCREEN_HEIGHT - SHIP_HEIGHT))
def update(self):
"""毎フレームの更新処理"""
self.move_ship()
for star in self.stars:
star[1] += star[2] # y に速さを足す
if star[1] >= SCREEN_HEIGHT: # 画面の下に出たら
star[0] = random.randint(0, SCREEN_WIDTH - 1) # x を引き直して
star[1] = 0 # いちばん上に戻す
def draw(self):
"""毎フレームの描画処理。奥のものから順に描く"""
pyxel.cls(0)
# 1. 星(いちばん奥)
for star in self.stars:
pyxel.pset(star[0], star[1], self.star_color(star[2]))
# 2. 宇宙船(星より手前)
pyxel.blt(
self.ship_x, self.ship_y, # 画面のどこに貼るか
0, # どのイメージバンクから取るか
SHIP_U, SHIP_V, # 台紙のどこから切り出すか
SHIP_WIDTH, SHIP_HEIGHT, # 何ドットぶん切り出すか
pyxel.COLOR_BLACK, # この色は塗らない(透過色)
)
# 3. 文字(いちばん手前)
self.text_center(20, TITLE, pyxel.COLOR_YELLOW)
self.text_center(34, GUIDE, pyxel.COLOR_GRAY)
App()
python step0/star_voyager.py
update に書くのか。draw ではだめか
draw に書いても動いてしまいますが、書きません。
step0 概要の「土台 2」で決めたとおり、役割を分けているからです。
update:状態(座標など)を変える。画面には触らないdraw:今の状態を絵にするだけ。状態は変えないこの分担を守っておくと、あとで「タイトル画面のあいだは動かさない」(step1 の画面遷移) のように更新だけを止めたい場面で、素直に書けます。
move_ship という別のメソッドに分けた理由
update の中に直接書いても動きます。分けたのは、update が
「今フレームで何が起きるかの目次」になるからです。
def update(self):
self.move_ship() # 1. 宇宙船を動かす
(星を動かす処理) # 2. 星を動かす
step1 以降、ここに「敵を動かす」「当たり判定をする」「スコアを更新する」が並びます。
1 つの関数が長くなったら、意味のかたまりで切り出して名前を付ける。
make_stars や text_center と同じ考え方です。
self. を付け忘れると別の変数になる
self.ship_x -= SHIP_SPEED の self. を落として
ship_x -= SHIP_SPEED と書くと、Python はこれを
move_ship の中だけのローカル変数だと解釈します。
そんな変数はまだ無いので、次のエラーになります。
UnboundLocalError: local variable 'ship_x' referenced before assignment
self. は「この App が覚えておくデータ」の目印です(フェーズ 5 参照)。
メソッドをまたいで持ち越したい値には、必ず付けます。
KEY_LEFT の正体はただの数値
pyxel.KEY_LEFT を print してみると、
1073741904 のような大きな数値が入っています。
つまり pyxel.btn(1073741904) と書いても動きますが、
読んだ人(=未来の自分)に何のキーか分かりません。
フェーズ 5 で出てきたマジックナンバーを避ける、という話と同じです。
左と上を同時に押すと、x が 2、y が 2 動きます。 実際に進んだ距離は 2 + 2 = 4 ではなく、直角三角形の斜辺なので約 2.83 ドット。 まっすぐ動くより、斜めのほうが約 1.4 倍速いことになります。 本格的なゲームでは補正しますが(step3 のシューティングで扱います)、 step0 では気にしなくてかまいません。
if のあとに書く順番が大事です。「動かす → はみ出しを直す」の順にしておけば、 どの方向から来ても、どんな速さでも、最後には必ず範囲内へ収まります。 先に押し戻してから動かすと、その 1 フレームは画面外に出たまま描画されてしまいます。
1 つずつ変えて実行し、変えたら元に戻して次へ進んでください。
| やってみること | 確かめたいこと |
|---|---|
SHIP_SPEED を 1 / 8 に変える |
操作感が変わる。8 は速すぎて狙った位置に止めにくい。
ゲームの気持ちよさは数値の調整で決まる |
btn を btnp に変える(4 か所) |
押しっぱなしでは 1 フレームぶんしか動かない。連打が必要になる |
押し戻しの 2 行を # でコメントアウトする |
宇宙船が画面の外へ消える。エラーは出ない。逆方向のキーを押せば戻ってくる |
押し戻しの上限を SCREEN_WIDTH にする( - SHIP_WIDTH を消す) |
右端で 16 ドットはみ出す。座標が左上のかどであることがはっきり分かる |
4 つの if を if / elif / elif / elif にする |
斜めに動けなくなる。← と ↑ を押すと左にしか進まない |
左キーの行をif pyxel.btn(pyxel.KEY_LEFT) or pyxel.btn(pyxel.KEY_A):に変える(他も KEY_D / KEY_W / KEY_S で同様に) |
矢印キーと WASD の両対応になる。or でつなぐだけでよい |
GUIDE を "ARROW KEYS TO MOVE" に変える |
操作説明が画面に出る。中央ぞろえなので文字数が変わっても位置は自動で決まる |
シフトキーでダッシュさせる。move_ship の先頭にspeed = SHIP_SPEEDif pyxel.btn(pyxel.KEY_SHIFT): speed = SHIP_SPEED * 2を足し、4 か所の SHIP_SPEED を speed にする |
押しているあいだだけ速くなる。定数をその場の変数に置き換えるという、 この先よく使う手が身につく |
| 症状 | 原因と対処 |
|---|---|
| キーを押しても動かない(エラーなし) | update に self.move_ship() を書き忘れている。
または Pyxel のウィンドウがアクティブになっていない(一度クリックする) |
AttributeError: 'App' object has no attribute 'move_ship' |
def move_ship(self): のインデントがずれて、
class App: の外に出てしまっている |
UnboundLocalError: local variable 'ship_x' ... |
self. の付け忘れ。
ship_x -= ... ではなく self.ship_x -= ... |
NameError: name 'SHIP_SPEED' is not defined |
3-1 の定数を書き忘れている。ファイルの上のほう(クラスより前)に書く |
AttributeError: module 'pyxel' has no attribute 'KEY_LEFT' 系 |
定数名のつづり違い。KEY_LEFT / KEY_RIGHT /
KEY_UP / KEY_DOWN(すべて大文字、アンダースコアは 1 個) |
| 押しっぱなしでも 1 回しか動かない | btnp を使っている。移動は btn |
| 斜めに動かない | elif でつないでいる。if を 4 つ並べる |
| 画面の外へ飛び出していく | 押し戻しの 2 行が無いか、4 つの if より前にある。
あとへ移動する |
| 右端・下端で 16 ドット手前で止まるように見える | 正常です。座標は左上のかどなので、
x = 144 のとき船の右辺が画面の右辺にぴったり接しています |
| 上下だけ、または左右だけ動かない | その if の中で ship_x と ship_y を
取り違えている。上下は ship_y |
| ↑ キーで下に動く | 符号が逆。y は下向きが正なので、上へ動かすには -= |
pyxel.playm でループ再生させます。
絵と同じく、音も assets.pyxres の中に保存されます。