フェーズ 2 主人公を矢印キーで動かす

動かす前に行き先を調べて、壁なら進まない。向いた方向の絵に切り替える

トップページstep2 概要 / フェーズ 2

ゴール

矢印キーで主人公が1 マスずつ動くようにします。壁の方向を押しても進みません。 そして向いている方向の絵に切り替わります。 フェーズ 1 のコードに 38 行書き足して、できあがりは 132 行になります。

このフェーズで使う絵 主人公の 4 方向(下・上・左・右)を使います。 フェーズ 1 で下向き 1 枚だけ描いた方は、残り 3 枚(上・左・右)を先に描いてくださいフェーズ 1 の配置図のとおり、 2 段目(v = 8)の左から 下・上・左・右の順に並べます。
足踏みの 2 コマ目(3 段目)は、まだ描かなくて構いません。 歩くアニメはフェーズ 4 で扱います。
荷物は、まだすり抜けます このフェーズでは壁だけを調べます。荷物のマスには入り込めてしまいますが、 それで正しい動きです。荷物を押す処理はフェーズ 3 で足します。

1. 考え方

1-1. 動かしてから直すのではなく、動かす前に調べる

step1 では、まず動かして、画面の外に出ていたら maxminあとから押し戻していました。ドット単位でぬるぬる動くゲームでは、それでうまくいきます。

倉庫番はマス単位です。1 マスは 8 ドットあるので、 壁にめり込んでから戻すとガタッと見えてしまいます。 それに、このあと荷物を押すようになると 「動かしてから戻す」では荷物まで巻き戻さなければならず、話がややこしくなります。

そこで倉庫番では順番を逆にします行き先のマスを先に計算して、そこが壁かどうか調べてから、動かすかどうかを決めます。

↑ キーが押された 行き先 = 今いるマス + (0, -1) ※ この時点では、まだ動いていない 行き先のマスは 壁? はい 向きだけ変える 位置はそのまま いいえ 1 マス動く 向きも変える
この流れが、そのまま try_move() というメソッド 1 つになります
壁を押したときも、向きは変わります 上の図で、壁だったときも「向きだけ変える」としているのは、 そのほうが手ざわりが良いからです。 壁に向かってキーを押したのに主人公が知らん顔をしていると、 キーを取りこぼしたのか、壁なのか、区別が付きません。 向きが変われば「押したことは伝わっている。ただし壁だ」と一目で分かります。
この「入力に必ず何か反応を返す」という考え方は、 フェーズ 8 のクリア演出まで、ずっと出てきます。

1-2. 向きを「番号」にすると、進む先も絵も同じ番号で引ける

矢印キーは 4 つあります。押されたキーごとに 「x を 1 増やす」「y を 1 減らす」……と書いていくと、 同じような行が 4 つ並びます。フェーズ 3 で荷物を押すようになると、 その 4 つがもう一組必要になり、8 か所を書き分けることになります。

そこで向きに番号を振ります。フェーズ 1 で 下 0・上 1・左 2・右 3 と決めておいた、あの順番です。 番号が決まっていれば、進む先の計算も、絵の切り出し位置も、同じ番号 1 つで引けます

向き 番号 MOVES[番号] 絵の切り出し位置 0 (0, 1) y が 1 増える u = 0 1 (0, -1) y が 1 減る u = 8 2 (-1, 0) x が 1 減る u = 16 3 (1, 0) x が 1 増える u = 24 この 1 つの番号が 進む先を決め、 絵も決めます(u = 番号 × 8)
フェーズ 1 で絵を「下・上・左・右」の順に並べたのは、この表を成立させるためです
この形は step1 でも使いました MOVES は、かっこの組をリストに並べたものです。 step1 フェーズ 9 の CORNERS(お化けが出てくる 4 隅)とまったく同じ形で、 MOVES[2] と番号で引くと (-1, 0) が返ります。
かっこの組はタプルといって、リストの兄弟のようなものです。 あとから書き換えない並びに使います。 「左は x が 1 減る」は今後も変わらないので、タプルがふさわしい場所です。

1-3. 行き先が壁かどうかは self.tiles[y][x] で調べる

フェーズ 1 で作った self.tiles は、地形だけが入った二次元リストです。 調べたいマスの文字が "#" なら壁、それ以外(".""O")なら通れます。

気を付けるのはかっこの順番です。self.tiles行のリストなので、 先に行(y)を選び、あとから列(x)を選びますtiles[x][y] と書くと、盤面の縦横が入れ替わったところを見てしまいます。

x(列)→ 0 1 2 3 4 5 6 7 y(行) 0 1 2 3 4 5 self.tiles[4][3] y が先、x があと 上へ 1 歩進むと self.tiles[3][3] y は下へ行くほど大きくなります。画面の座標と同じ向きです。 だから「上へ進む」は (0, -1) と、マイナスになります。
主人公がいる (x=3, y=4) のマスは self.tiles[4][3] と書きます

2. このフェーズで使う機能

機能役割
pyxel.btnp(キー)押した瞬間の 1 フレームだけ True になる。step1 フェーズ 2 で扱いました
pyxel.KEY_DOWN ほか 3 つ矢印キー。KEY_UP / KEY_LEFT / KEY_RIGHT
(0, 1) のようなタプルあとから書き換えない、値の組
dx, dy = MOVES[n]組を2 つの変数へ一度に受け取る(step1 の best_x, best_y = CORNERS[0] と同じ形)
return(値なし)メソッドをそこで終わらせる。「何もしない」を書くのに使う
ifelifどれか1 つだけを選ぶ。「1 フレームに 1 方向だけ」にするために使う

3. 手順

フェーズ 1 の sokoban.py に書き足していきます。3 段階に分けて進めます。

3-1. 向きの番号と、向きごとのずれを用意する

定数の並びのいちばん下、STAGE1手前に足します。

U_BOX = 24

# 向き。この番号が、そのまま主人公の絵の切り出し位置になる
DIR_DOWN = 0
DIR_UP = 1
DIR_LEFT = 2
DIR_RIGHT = 3

# 向きごとの、1 歩ぶんのずれ(x のずれ, y のずれ)
MOVES = [
    (0, 1),             # 下
    (0, -1),            # 上
    (-1, 0),            # 左
    (1, 0),             # 右
]

次に、はじめの向きを覚えさせます。 load_stage()self.boxes = [] の下に 1 行足してください。

    def load_stage(self, rows):
        """文字の地図を読んで、地形・主人公・荷物に分ける"""
        self.tiles = []
        self.boxes = []
        self.player_dir = DIR_DOWN      # ← 足す:はじめは下を向いている
なぜ MOVES の順番を変えてはいけないのか MOVES の並びは 下・上・左・右で、 DIR_DOWN が 0、DIR_UP が 1……という番号とそろえてあります。 ここを入れ替えると、右を押したのに左へ進むといったことが起きます。 絵の並びとも連動しているので、3 か所(番号・MOVES・絵)を必ずそろえてください。

3-2. キーを見て、動かす

update()pass を消して、メソッドを 2 つ足します。 update() のすぐ下に続けて書いてください。

    def update(self):
        """フレーム毎の更新処理"""
        self.handle_key()

    def handle_key(self):
        """矢印キーを見て、押された向きへ 1 歩進もうとする"""
        if pyxel.btnp(pyxel.KEY_DOWN):
            self.try_move(DIR_DOWN)
        elif pyxel.btnp(pyxel.KEY_UP):
            self.try_move(DIR_UP)
        elif pyxel.btnp(pyxel.KEY_LEFT):
            self.try_move(DIR_LEFT)
        elif pyxel.btnp(pyxel.KEY_RIGHT):
            self.try_move(DIR_RIGHT)

    def try_move(self, direction):
        """その向きへ動けるか調べて、動けるときだけ 1 マス進む"""
        self.player_dir = direction     # 動けなくても、向きだけは変える

        dx, dy = MOVES[direction]
        next_x = self.player_x + dx
        next_y = self.player_y + dy

        if self.tiles[next_y][next_x] == "#":    # 行き先が壁なら
            return                               # 何もしないで戻る

        self.player_x = next_x
        self.player_y = next_y

ここで一度実行してください。矢印キーで主人公が 1 マスずつ動き、壁で止まれば成功です。 まだ絵は下向きのままで、荷物はすり抜けます。

3-3. 向いている方向の絵にする

draw() のいちばん下、主人公を描いている blt()0, のところを self.player_dir * TILE, に書き換えます

        # 主人公(向いている方向の絵)
        pyxel.blt(self.ox + self.player_x * TILE, self.oy + self.player_y * TILE,
                  0, self.player_dir * TILE, V_PLAYER, TILE, TILE, pyxel.COLOR_BLACK)
書き換えるのは 1 か所だけです もとのコードは 0, 0, V_PLAYER, ... と 0 が 2 つ並んでいました。 1 つめの 0 はイメージバンクの番号(そのまま)、 2 つめの 0 が切り出し位置 u です。書き換えるのは2 つめです。

4. できあがりのコード

# sokoban.py
# step2 フェーズ2:主人公を矢印キーで動かす

import pyxel

SCREEN_WIDTH = 160
SCREEN_HEIGHT = 120
TILE = 8                # 1 マスの大きさ(ドット)
HUD_HEIGHT = 12         # 画面の上、手数などを出すために空けておく高さ

# イメージバンク 0 の、切り出し位置
V_TILE = 0              # タイルの段
V_PLAYER = 8            # 主人公の段(コマ A)
U_FLOOR = 0
U_WALL = 8
U_GOAL = 16
U_BOX = 24

# 向き。この番号が、そのまま主人公の絵の切り出し位置になる
DIR_DOWN = 0
DIR_UP = 1
DIR_LEFT = 2
DIR_RIGHT = 3

# 向きごとの、1 歩ぶんのずれ(x のずれ, y のずれ)
MOVES = [
    (0, 1),             # 下
    (0, -1),            # 上
    (-1, 0),            # 左
    (1, 0),             # 右
]

# ステージの地図
# # 壁  . 床  O ゴール  $ 荷物  @ 主人公
STAGE1 = [
    "########",
    "#......#",
    "#..O...#",
    "#..$...#",
    "#..@...#",
    "########",
]

class App:
    def __init__(self):
        """起動時の設定"""
        pyxel.init(SCREEN_WIDTH, SCREEN_HEIGHT, title="Sokoban")
        pyxel.load("sokoban.pyxres")
        self.load_stage(STAGE1)
        pyxel.run(self.update, self.draw)

    def load_stage(self, rows):
        """文字の地図を読んで、地形・主人公・荷物に分ける"""
        self.tiles = []
        self.boxes = []
        self.player_dir = DIR_DOWN      # はじめは下を向いている
        for y in range(len(rows)):
            line = []
            for x in range(len(rows[y])):
                mark = rows[y][x]
                if mark == "@":
                    self.player_x = x
                    self.player_y = y
                    mark = "."      # 主人公の足元は床
                elif mark == "$":
                    self.boxes.append([x, y])
                    mark = "."      # 荷物の下も床
                line.append(mark)
            self.tiles.append(line)

        # 盤面を画面の中央に置くための、ずらし幅
        board_w = len(self.tiles[0]) * TILE
        board_h = len(self.tiles) * TILE
        self.ox = (SCREEN_WIDTH - board_w) // 2
        self.oy = HUD_HEIGHT + (SCREEN_HEIGHT - HUD_HEIGHT - board_h) // 2

    def update(self):
        """フレーム毎の更新処理"""
        self.handle_key()

    def handle_key(self):
        """矢印キーを見て、押された向きへ 1 歩進もうとする"""
        if pyxel.btnp(pyxel.KEY_DOWN):
            self.try_move(DIR_DOWN)
        elif pyxel.btnp(pyxel.KEY_UP):
            self.try_move(DIR_UP)
        elif pyxel.btnp(pyxel.KEY_LEFT):
            self.try_move(DIR_LEFT)
        elif pyxel.btnp(pyxel.KEY_RIGHT):
            self.try_move(DIR_RIGHT)

    def try_move(self, direction):
        """その向きへ動けるか調べて、動けるときだけ 1 マス進む"""
        self.player_dir = direction     # 動けなくても、向きだけは変える

        dx, dy = MOVES[direction]
        next_x = self.player_x + dx
        next_y = self.player_y + dy

        if self.tiles[next_y][next_x] == "#":    # 行き先が壁なら
            return                               # 何もしないで戻る

        self.player_x = next_x
        self.player_y = next_y

    def draw(self):
        """描画処理"""
        pyxel.cls(pyxel.COLOR_BLACK)

        # 地形を、上の行から 1 マスずつ描く
        for y in range(len(self.tiles)):
            for x in range(len(self.tiles[y])):
                mark = self.tiles[y][x]
                if mark == "#":
                    u = U_WALL
                elif mark == "O":
                    u = U_GOAL
                else:
                    u = U_FLOOR
                pyxel.blt(self.ox + x * TILE, self.oy + y * TILE,
                          0, u, V_TILE, TILE, TILE)

        # 荷物
        for box in self.boxes:
            pyxel.blt(self.ox + box[0] * TILE, self.oy + box[1] * TILE,
                      0, U_BOX, V_TILE, TILE, TILE, pyxel.COLOR_BLACK)

        # 主人公(向いている方向の絵)
        pyxel.blt(self.ox + self.player_x * TILE, self.oy + self.player_y * TILE,
                  0, self.player_dir * TILE, V_PLAYER, TILE, TILE, pyxel.COLOR_BLACK)

App()

5. 実行する

cd step2
python sokoban.py

確かめてほしいのは、次の 4 つです。

やってみることこうなれば成功
矢印キーを1 回ずつ押す主人公が1 マスずつ動く
壁に向かって押す動かないが、向きだけ変わる
4 方向すべてに向く下・上・左・右の絵が正しく切り替わる
矢印キーを押しっぱなしにする1 マスだけ動いて止まる(これで正しい)
押しっぱなしで進まないのは、わざとです btnp()押した瞬間の 1 フレームだけ True になるので、 押しっぱなしでは 1 歩しか進みません。
pyxel.btnp(キー, 12, 4) のように数を 2 つ足すと 「12 フレーム押しっぱなしにしたら、以降 4 フレームごとに繰り返す」という動きにできます。 ただしフェーズ 4 で歩くアニメを入れるときに、この仕組みごと btn() に作り替えます。 ここでは足さずに進んでください。
数字で確かめたいとき try_move() の最後に print(self.player_x, self.player_y, self.player_dir) を書き足すと、動くたびにターミナルへマス目の位置と向きが出ます。 はじめの位置は 3 4 0 です。右を 3 回押すと 6 4 3 になり、 4 回目は壁なので何も出ませんreturn で先に戻っているためです)。 確かめ終わったら消してください。

6. コードの解説

6-1. if を 4 つ並べず、elif でつなぐ

step1 の move_boy() では、if4 つ並べていました。 上と右を同時に押したら斜めに動いてほしかったからです。

倉庫番は逆です。1 回の入力で動くのは、1 マスだけ。 斜めには進めませんし、上と右を同時に押したときに 2 マスぶん動いてしまっては困ります。

        if pyxel.btnp(pyxel.KEY_DOWN):
            self.try_move(DIR_DOWN)
        elif pyxel.btnp(pyxel.KEY_UP):      # ← elif なので、下が成立したら見に来ない
            self.try_move(DIR_UP)

elif でつなぐと、上から順に見ていって、最初に当てはまった 1 つだけが実行されます。 だから同時押しでも、動くのは 1 方向だけです (この書き方では下がいちばん優先されます。倉庫番では困らないので、この順のままにします)。

6-2. dx, dy = MOVES[direction] —— 組を 2 つの変数で受け取る

MOVES[direction](0, -1) のような2 つ組を返します。 左辺にカンマで 2 つ変数を並べると、順番どおりに 1 つずつ入ります

dx, dy = MOVES[1]       # MOVES[1] は (0, -1)
                        # → dx に 0、dy に -1 が入る

step1 フェーズ 9 の best_x, best_y = CORNERS[0] と同じ書き方です。 1 行で済むうえ、MOVES[direction][0] のように かっこを二重に書かなくてよいので読みやすくなります。

dxdy という名前について d は「difference(差)」の頭文字で、 「どれだけ変化するか」を表す変数によく使われる名前です。 ゲームのコードでは dxdy がほぼ決まり文句なので、 ここでもその慣習に合わせています。 move_xmove_y と書いても動きは同じです。

6-3. return だけを書いて「何もしない」を表す

        if self.tiles[next_y][next_x] == "#":
            return

        self.player_x = next_x
        self.player_y = next_y

returnそこでメソッドを終わらせる合図です。 値を書かずに return とだけ置くと、「ここで打ち切り」という意味になります。 壁だったときは位置を書き換えないまま終わるので、主人公はその場に留まります。

else: を使って書くこともできますが、return のほうが字下げが浅く済みます。 フェーズ 3 で「荷物があるか」「その先はどうか」と調べる条件が増えると、 この差がはっきり効いてきます。step1 の check_ghosts() でも同じ書き方をしました。

6-4. なぜ handle_key()try_move() に分けたのか

1 つのメソッドにまとめて書くこともできます。分けたのは、 2 つが答えている問いが違うからです。

メソッド答えている問いこの先どう変わるか
handle_key()どの向きが指示されたか フェーズ 10 でスマホの十字キーにも対応します。変わるのはここだけです
try_move()その向きへ動けるか、動くとどうなるか フェーズ 3 で荷物を押す処理が入り、フェーズ 8 で手数を数える行が入ります

分けておくと、あとで片方だけを直せば済みます。 「キーボードでもスマホでも、指示された向きは try_move() に渡すだけ」という形にしておくと、 入力の種類が増えても移動のルールには手を触れずに済みます。

6-5. self.player_dir * TILE で切り出し位置を求める

        pyxel.blt(self.ox + self.player_x * TILE, self.oy + self.player_y * TILE,
                  0, self.player_dir * TILE, V_PLAYER, TILE, TILE, pyxel.COLOR_BLACK)

主人公の絵は横に 8 ドットずつ並べて描いてあるので、 向きの番号に 8 を掛ければ、そのまま切り出し位置になります。

下(0) → 0 * 8 = 0
上(1) → 1 * 8 = 8
左(2) → 2 * 8 = 16
右(3) → 3 * 8 = 24

8 と直接書かずに TILE を使っているのは、 この 8 が「絵 1 枚の幅」だからです。 self.player_x * TILE(マス目をドットに直す)と同じ理由の 8 です。 このように意味が同じ数は、同じ名前で書いておくと、 あとで数を変えたくなったときに直す場所が 1 か所で済みます。

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

症状原因と対処
キーを押しても何も起きない update()pass が残っていませんか。 self.handle_key() に書き換わっているか確認してください
AttributeError: 'App' object has no attribute 'player_dir' load_stage()self.player_dir = DIR_DOWN を足し忘れています
TypeError: try_move() takes 1 positional argument but 2 were given def try_move(self, direction):self, が抜けています。 メソッドの引数は必ず self から始めます
押した向きとに動く/上下と左右が入れ替わる MOVES の並びが違います。上から (0, 1)(0, -1)(-1, 0)(1, 0) の順です。 組の中身は「x のずれ, y のずれ」の順である点にも注意してください
壁をすり抜ける self.tiles[next_x][next_y]順番が逆になっていませんか。 y が先、x があとです
IndexError: list index out of range 地図の外周が壁で囲まれていません。盤面の端から出ようとして、 リストの外を見に行っています。倉庫番の地図は必ず外周をすべて # にする 決まりにしておくと、範囲を調べる処理を書かずに済みます
絵が下向きのまま変わらない blt()2 つめの 0self.player_dir * TILE に 書き換えたか確認してください(1 つめはイメージバンク番号なので 0 のままです)
向きを変えると荷物やゴールの絵が出る V_PLAYER のところが V_TILE(または 0)になっています。 主人公は2 段目(v = 8)から切り出します
ある向きだけ絵が真っ黒/崩れている リソースエディタの 2 段目(v = 8)に 4 枚そろっていません。 左から 下・上・左・右の順に、8 ドット刻みで並べてください
1 回押すと 2 マス動く btnpbtn になっていませんか。 btn押している間ずっと True なので、毎フレーム動いてしまいます
荷物のマスに入り込める いまはそれで正しい動きです。フェーズ 3 で荷物を押す処理を足します
ゴールのマスに乗ると、ゴールの印が消える 主人公が上に描かれているだけで、地形は消えていません。正しい動きです

8. 次のフェーズ

主人公が動くようになりました。次は荷物を押します。 考え方は同じで、調べる範囲が 1 マス先から 2 マス先に広がるだけです。

次に出てくる考え方 「行き先に荷物があるなら、その 1 つ先も見る」—— step2 概要の土台 2 にある 4 パターンのうち、 残りの 2 つ(3 番目と 4 番目)を try_move() に足します。
そのとき、荷物が置かれているマスをどうやって調べるかが問題になります。 荷物は self.tiles ではなく self.boxes(座標のリスト)で持っているので、 「そのマスに荷物があるか」を答える小さなメソッドを 1 つ作ることになります。