フェーズ 6 矢印キーで宇宙船を動かす

step0:星空を飛ぶ宇宙船

トップページstep0 概要 / フェーズ 6

ゴール

フェーズ 5 で画面に出した宇宙船を、矢印キーで上下左右に動かします。 あわせて、画面の外へ出ていかないように制限します。

ここが「ゲームらしさ」の入り口 フェーズ 5 までは、見ているだけの作品でした。 今回はじめてプレイヤーの操作が画面に反映されます。 そして書き換えるのは、フェーズ 5 で用意した self.ship_xself.ship_y の 2 つだけです。描画のコード(draw)はいっさい触りません。

1. 考え方

1-1. キー入力は「今どうなっているか」を毎フレーム聞きにいく

ふつうのアプリは「キーが押されたら、この処理を呼ぶ」という書き方(イベント)をします。 Pyxel はそうではありません。毎フレーム、こちらから「今このキーは押されてる?」と 聞きにいきます。

if pyxel.btn(pyxel.KEY_LEFT):   # 今、左キーは押されている?
    self.ship_x -= SHIP_SPEED   # 押されていれば 2 ドット左へ

これが 1 秒に 30 回くり返されます。キーを押しっぱなしにすると、 1 フレームに 2 ドットずつ、なめらかに動いていくわけです。 「移動する処理」を書いているのではなく、「1 フレームぶんの移動」を書いている と考えてください。

用語:ポーリング 「向こうから知らせが来る」のを待つのではなく、自分から定期的に状態を見にいく やり方をポーリングと呼びます。ゲームでは毎フレーム必ず処理が回るので、 この方式のほうが素直に書けます。

1-2. btnbtnp の違い

似た名前の関数が 2 つあります。この違いが分かっていないと、 「一瞬しか動かない」という壁にぶつかります。

関数True になるのは向いている用途
pyxel.btn(key) 押されているあいだ、ずっと 移動、ショットの連射
pyxel.btnp(key) 押した瞬間の 1 フレームだけ(p は press) 決定ボタン、ジャンプ、画面の切り替え
フレーム 1 2 3 4 5 6 7 8 9 10 ← キーの状態 押しっぱなし pyxel.btn True True True True True True pyxel.btnp True False False False False False ship_x(btn) 80 80 78 76 74 72 70 68 68 68
左キーを 3 〜 8 フレームのあいだ押した場合。btnp にすると True は 3 フレーム目だけなので、ship_x は 78 になったきり動かない
移動には btn を使う btnp で移動を書くと、キーを押しっぱなしにしても 1 回しか動きません。 連打しないと進まないゲームになってしまいます。

1-3. 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 が成立しなかったときだけ調べる」という意味なので、 左と上を同時に押しても、最初に見つかった左だけが実行されて終わります。

if を 4 つ並べる ← と ↑ を同時に押すと x も y も動く = 斜めに進む elif でつなぐ ← と ↑ を同時に押しても 最初の if だけ実行 = 左にしか進まない
4 方向を独立して調べたいので、elif ではなく if を並べる
左右を同時に押したらどうなる? -2 してから +2 されるので、差し引きゼロでその場に止まります。 elif なら左が優先されます。どちらも間違いではありませんが、 止まるほうが自然なので、このままにします。

1-4. 画面の外に出さない = 範囲へ押し戻す

このままだと、左キーを押し続ければ宇宙船は画面の外へ消えていきます (エラーにはなりませんが、二度と見えません)。そこで、移動させたあとに 「行きすぎていたら引き戻す」という処理を足します。

ここで大事なのが、blt に渡す x, y宇宙船の左上のかどだということです(フェーズ 5)。 だから右端は SCREEN_WIDTH(160)ではなく、 SCREEN_WIDTH - SHIP_WIDTH(144)になります。 144 のとき、宇宙船の右端がちょうど画面の右端に届きます。

画面(160 x 120 ドット)を 2 倍で表示 宇宙船の左上が動ける範囲 x : 0 〜 144 y : 0 〜 104 (0, 0) (144, 104) 右端 144 + 16 = 160 はみ出したら min / max で 範囲へ押し戻す
宇宙船の座標は「左上のかど」。16 ドットぶん引いておかないと、右端と下端ではみ出す

押し戻しは maxmin の組み合わせで 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 の役目
用語:クランプ(clamp) 値を「決めた範囲からはみ出さないよう、両端で止める」ことをクランプといいます。 ゲームではキャラクターの座標・体力・音量など、いたるところで使います。 max(下限, min(値, 上限)) は決まり文句として覚えてしまってかまいません。

2. このフェーズで使う API

API意味
pyxel.btn(key) key押されているあいだ True を返す
pyxel.btnp(key) key押した瞬間の 1 フレームだけ True を返す
pyxel.btnr(key) key離した瞬間の 1 フレームだけ True を返す(今回は使わない)
pyxel.KEY_LEFTpyxel.KEY_RIGHT
pyxel.KEY_UPpyxel.KEY_DOWN
矢印キーを表す定数。中身はただの数値だが、数値のまま書くと何のキーか分からなくなる

Python の組み込み関数も 2 つ使います。Pyxel とは関係なく、Python にもともとあるものです。

関数意味
max(a, b)大きいほうを返すmax(0, -6)0
min(a, b)小さいほうを返すmin(150, 144)144

3. コードを書き換える

変更は 3 か所だけです。draw は 1 文字も触りません。

3-1. 定数を足す(SHIP_HEIGHT の下)

SHIP_SPEED = 2          # 1 フレームに進むドット数

3-2. 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' になります。

3-3. update の先頭から呼び出す

    def update(self):
        """毎フレームの更新処理"""
        self.move_ship()        # ← 追加:先に宇宙船を動かす

        for star in self.stars:
            star[1] += star[2]
            ...(以下はそのまま)

3-4. 書き換え後の全文

# 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
キーが効かないときは Pyxel のウィンドウを一度クリックして選択状態(アクティブ)にしてください。 別のウィンドウにフォーカスがあると、キー入力は届きません。

4. コードの解説

なぜ update に書くのか。draw ではだめか

draw に書いても動いてしまいますが、書きません。 step0 概要の「土台 2」で決めたとおり、役割を分けているからです。

この分担を守っておくと、あとで「タイトル画面のあいだは動かさない」(step1 の画面遷移) のように更新だけを止めたい場面で、素直に書けます。

move_ship という別のメソッドに分けた理由

update の中に直接書いても動きます。分けたのは、update「今フレームで何が起きるかの目次」になるからです。

    def update(self):
        self.move_ship()      # 1. 宇宙船を動かす
        (星を動かす処理)      # 2. 星を動かす

step1 以降、ここに「敵を動かす」「当たり判定をする」「スコアを更新する」が並びます。 1 つの関数が長くなったら、意味のかたまりで切り出して名前を付ける。 make_starstext_center と同じ考え方です。

self. を付け忘れると別の変数になる

self.ship_x -= SHIP_SPEEDself. を落として ship_x -= SHIP_SPEED と書くと、Python はこれを move_ship の中だけのローカル変数だと解釈します。 そんな変数はまだ無いので、次のエラーになります。

UnboundLocalError: local variable 'ship_x' referenced before assignment

self. は「この App が覚えておくデータ」の目印です(フェーズ 5 参照)。 メソッドをまたいで持ち越したい値には、必ず付けます。

KEY_LEFT の正体はただの数値

pyxel.KEY_LEFTprint してみると、 1073741904 のような大きな数値が入っています。 つまり pyxel.btn(1073741904) と書いても動きますが、 読んだ人(=未来の自分)に何のキーか分かりません。 フェーズ 5 で出てきたマジックナンバーを避ける、という話と同じです。

斜め移動は、ほんの少しだけ速い

左と上を同時に押すと、x が 2、y が 2 動きます。 実際に進んだ距離は 2 + 2 = 4 ではなく、直角三角形の斜辺なので約 2.83 ドット。 まっすぐ動くより、斜めのほうが約 1.4 倍速いことになります。 本格的なゲームでは補正しますが(step3 のシューティングで扱います)、 step0 では気にしなくてかまいません。

押し戻しは、4 つの ifあとに書く

順番が大事です。「動かす → はみ出しを直す」の順にしておけば、 どの方向から来ても、どんな速さでも、最後には必ず範囲内へ収まります。 先に押し戻してから動かすと、その 1 フレームは画面外に出たまま描画されてしまいます。

5. 試してみよう

1 つずつ変えて実行し、変えたら元に戻して次へ進んでください。

やってみること確かめたいこと
SHIP_SPEED18 に変える 操作感が変わる。8 は速すぎて狙った位置に止めにくい。 ゲームの気持ちよさは数値の調整で決まる
btnbtnp に変える(4 か所) 押しっぱなしでは 1 フレームぶんしか動かない。連打が必要になる
押し戻しの 2 行を # でコメントアウトする 宇宙船が画面の外へ消える。エラーは出ない。逆方向のキーを押せば戻ってくる
押し戻しの上限を SCREEN_WIDTH にする
- SHIP_WIDTH を消す)
右端で 16 ドットはみ出す。座標が左上のかどであることがはっきり分かる
4 つの ifif / elif / elif / elif にする 斜めに動けなくなる。← と ↑ を押すと左にしか進まない
左キーの行を
if pyxel.btn(pyxel.KEY_LEFT) or pyxel.btn(pyxel.KEY_A):
に変える(他も KEY_DKEY_WKEY_S で同様に)
矢印キーと WASD の両対応になる。or でつなぐだけでよい
GUIDE"ARROW KEYS TO MOVE" に変える 操作説明が画面に出る。中央ぞろえなので文字数が変わっても位置は自動で決まる
シフトキーでダッシュさせる。move_ship の先頭に
speed = SHIP_SPEED
if pyxel.btn(pyxel.KEY_SHIFT):
    speed = SHIP_SPEED * 2
を足し、4 か所の SHIP_SPEEDspeed にする
押しているあいだだけ速くなる。定数をその場の変数に置き換えるという、 この先よく使う手が身につく

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

症状原因と対処
キーを押しても動かない(エラーなし) updateself.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_LEFTKEY_RIGHTKEY_UPKEY_DOWN(すべて大文字、アンダースコアは 1 個)
押しっぱなしでも 1 回しか動かない btnp を使っている。移動は btn
斜めに動かない elif でつないでいる。if を 4 つ並べる
画面の外へ飛び出していく 押し戻しの 2 行が無いか、4 つの if よりにある。 あとへ移動する
右端・下端で 16 ドット手前で止まるように見える 正常です。座標は左上のかどなので、 x = 144 のとき船の右辺が画面の右辺にぴったり接しています
上下だけ、または左右だけ動かない その if の中で ship_xship_y を 取り違えている。上下は ship_y
↑ キーで下に動く 符号が逆。y は下向きが正なので、上へ動かすには -=
次のフェーズ フェーズ 7 では、ふたたびリソースエディタを開いて BGM を作ります。 サウンド(音色とメロディ)とミュージック(複数チャンネルの組み合わせ)を編集し、 pyxel.playm でループ再生させます。 絵と同じく、音も assets.pyxres の中に保存されます。