フェーズ 3 荷物を押す

行き先に荷物があったら、その 1 つ先まで見てから決める

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

ゴール

主人公が荷物を押せるようにします。押せるのは 荷物の 1 つ先が空いているときだけ。壁や別の荷物がつかえていたら動きません。 あわせてステージに荷物とゴールをもう 1 組足して、押せない場面も試せるようにします。 フェーズ 2 のコードに 24 行書き足して、できあがりは 156 行になります。

ここで倉庫番になります 荷物が動くようになると、いよいよパズルとして遊べます。 同時に「押しまちがえると戻せない」という、このゲームの厳しさも出てきます。 やり直し(R)はフェーズ 5 で足すので、 それまでは行き詰まったらウィンドウを閉じて起動し直してください。

1. 考え方

1-1. 押せるかどうかは「2 マス先」で決まる

フェーズ 2 では、行き先の 1 マスだけを見ていました。 荷物が入ると、それでは足りません。行き先に荷物があったとき、 その荷物がどこへ行くのか——つまりもう 1 つ先のマスを見ないと、 押していいかどうか決められないからです。

step2 概要の土台 2 に挙げた 4 パターンを、 そのまま判断の順番に並べ替えると、次の流れになります。

↑ キーが押された 行き先 = 今いるマス + (0, -1) ※ まだ動いていない 行き先は壁? はい 向きだけ変えて、おしまい いいえ 行き先に 荷物がある? いいえ 1 マス動く(フェーズ 2 と同じ) はい 荷物の、もう 1 つ先を見る 行き先 + (0, -1) そこは壁? 別の荷物? はい 向きだけ変えて、おしまい いいえ 荷物を 1 マス押して 自分も 1 マス動く
ピンクの行き止まりが 2 つあります。どちらも return で表します
調べる順番には理由があります 壁 → 荷物 → 荷物の 1 つ先の順です。逆にはできません。
行き先が壁なら、そこに荷物が乗っていることはありえないので、 壁を先に片づけておくと、あとの判断が単純になります。 また「荷物の 1 つ先」は、行き先に荷物があったときにしか意味を持たないので、 荷物を見つけたあとで初めて計算します。

1-2. 荷物は self.tiles に入っていない。だから探しに行く

フェーズ 1 で、地図を3 つに分けて持つことにしました。 ここでその設計が効いてきます。

持っているもの中身これで答えられること
self.tiles地形だけの二次元リスト そのマスは壁か(tiles[y][x] == "#"
self.player_x / self.player_y主人公のマス目 主人公がどこにいるか
self.boxes[[3, 3], [5, 3]] のような座標の一覧 そのマスに荷物があるか —— 一覧を最後まで見ないと分からない

壁かどうかは tiles[y][x]一発で分かります。 ところが荷物は、座標の一覧として持っているので、 「(4, 3) に荷物はある?」と聞かれたら一覧を順に見ていくしかありません

盤面(8 × 6 マス) リンゴ 2 つ、ゴール 2 つ プログラムが持っているもの self.boxes = [[3, 3], [5, 3]] tiles[3] は ['#','.','.','.','.','.','.','#'] ↑ 荷物がいるマスも、ただの床のまま だから「(4, 3) に荷物がある?」には、 self.boxes を先頭から順に見て答えるしかありません。 その役をするのが、これから作る has_box() です。
荷物がゴールから出たとき「床に戻し忘れる」不具合が起きないのは、この形のおかげです

1-3. 「押す」とは、一覧の中の数字を書き換えること

荷物を動かすといっても、画面の中で何かをつかんで運ぶわけではありません。 やることは self.boxes の中の数字を書き換えるだけです。 そのあと draw() が新しい座標で絵を描くので、動いたように見えます。

上へ押す前 self.boxes [[3, 3], [5, 3]] 押す 上へ押したあと self.boxes [[3, 2], [5, 3]] 書き換えているのは、かっこの中の数字ひとつだけです。 リストの並びは変わりません。荷物を入れ替えたり、消して作り直したりはしていません。 box[1] += dy …… この 1 行で、self.boxes の中身が変わります。 なぜそうなるのかは、6-2 でくわしく見ます。ここでは「変わる」とだけ覚えてください。
盤面の見た目が変わるのは、この数字が書き換わったからです

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

新しい Pyxel の機能は出てきません。Python だけです。

機能役割
return True / return False 答えを返して終わる。「ある/ない」を答えるメソッドを書くのに使う
for の中の return 見つかった時点でループごと抜ける。最後まで回さない
and2 つの条件が両方とも成り立つか(step1 の当たり判定で使いました)
box[0] += dxリストの中の数字をその場で書き換える

3. 手順

3 段階に分けます。今回は最後まで書かないと動きが変わらないので、 3-3 まで書いてから実行してください。

3-1. ステージに、荷物とゴールをもう 1 組足す

STAGE1 を次のように書き換えます。盤面の大きさは変えません (8 × 6 のままなので、中央寄せの計算結果も変わりません)。

STAGE1 = [
    "########",
    "#......#",
    "#..O.O.#",     # ← ゴールを 1 つ増やす
    "#..$.$.#",     # ← 荷物を 1 つ増やす
    "#..@...#",
    "########",
]
なぜ荷物を 2 つにするのか 荷物が 1 つだと、「その先に別の荷物があって押せない」を試せません。 2 つあれば、荷物を並べてつかえさせる場面を自分で作れます。 ゴールも 2 つにしておくと、フェーズ 5 の「全部乗ったらクリア」がそのまま使えます。

3-2. 荷物を調べる・動かすメソッドを足す

try_move()手前に、メソッドを 2 つ足します。 どちらも self.boxes を先頭から順に見ていくだけの、短いメソッドです。

    def has_box(self, x, y):
        """そのマスに荷物があるかどうかを答える"""
        for box in self.boxes:
            if box[0] == x and box[1] == y:
                return True
        return False

    def move_box(self, x, y, dx, dy):
        """(x, y) にある荷物を、(dx, dy) だけ動かす"""
        for box in self.boxes:
            if box[0] == x and box[1] == y:
                box[0] += dx
                box[1] += dy
                return
return False の位置に注意してください return Falsefor外側for と同じ深さ)に置きます。 もし for の中に入れてしまうと、 1 つめの荷物を見た時点で「ない」と答えてしまい、2 つめ以降を見に行きません。
「全部見たけれど見つからなかった」という意味なので、 ループが終わったあとに置くのが正しい位置です。

3-3. try_move() に「押す」を足す

壁の判定と、位置を書き換える 2 行のあいだに差し込みます。

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

        if self.has_box(next_x, next_y):         # 行き先に荷物があるなら
            far_x = next_x + dx                  # 荷物の、1 つ先のマス
            far_y = next_y + dy
            if self.tiles[far_y][far_x] == "#":  # その先が壁なら押せない
                return
            if self.has_box(far_x, far_y):       # その先に別の荷物があっても押せない
                return
            self.move_box(next_x, next_y, dx, dy)

        self.player_x = next_x
        self.player_y = next_y
字下げに注意 far_x から move_box() までの 6 行は、 if self.has_box(...) の中です(self.player_x = next_x より 1 段深い)。
いちばん下の 2 行(self.player_xself.player_y)は外側に置きます。 荷物を押したときも、押さずに進むときも、どちらでも主人公は動くからです。 ここを 1 段深く書いてしまうと、荷物を押したときしか主人公が動かなくなります。

4. できあがりのコード

書き足したのは STAGE1 の 2 行と、has_box()move_box()、 そして try_move() の中ほどだけです。

# sokoban.py
# step2 フェーズ3:荷物を押す

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.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 has_box(self, x, y):
        """そのマスに荷物があるかどうかを答える"""
        for box in self.boxes:
            if box[0] == x and box[1] == y:
                return True
        return False

    def move_box(self, x, y, dx, dy):
        """(x, y) にある荷物を、(dx, dy) だけ動かす"""
        for box in self.boxes:
            if box[0] == x and box[1] == y:
                box[0] += dx
                box[1] += dy
                return

    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                               # 何もしないで戻る

        if self.has_box(next_x, next_y):         # 行き先に荷物があるなら
            far_x = next_x + dx                  # 荷物の、1 つ先のマス
            far_y = next_y + dy
            if self.tiles[far_y][far_x] == "#":  # その先が壁なら押せない
                return
            if self.has_box(far_x, far_y):       # その先に別の荷物があっても押せない
                return
            self.move_box(next_x, next_y, dx, dy)

        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

盤面にリンゴが 2 つ、ゴールが 2 つ出ます。確かめてほしいのは次の 5 つです。

やってみることこうなれば成功
荷物に向かって進む荷物と主人公が、いっしょに 1 マス動く
そのまま押し続ける荷物が壁の手前まで来たら止まる。主人公も止まる
荷物をゴールに乗せる荷物がゴールの印の上に重なる(見た目はまだ変わりません)
ゴールから押し出すゴールの印がちゃんと残っている
荷物を 2 つ並べて押す動かない。手前の荷物も主人公も止まったまま
荷物を 2 つ並べるには 最初の配置から と押すと、 左のリンゴが 1 マス右に動いて、2 つのリンゴが隣どうしになります。 そこでもう一度 を押しても、もう動きません。 倉庫番で荷物をまとめて押せないのは、この判定のおかげです。
数字で確かめたいとき try_move() の最後に print(self.boxes) を書き足すと、 動くたびに荷物の座標が出ます。はじめは [[3, 3], [5, 3]] で、 を 1 回押すと [[3, 2], [5, 3]] になります。 もう一度押すと [[3, 1], [5, 3]]、3 回目は変わりません(その先が壁のため)。 確かめ終わったら消してください。
行き詰まったら、いったん閉じてください 荷物を壁ぎわや角に押し込むと、もう戻せません。 これは不具合ではなく、倉庫番というゲームそのものの性質です。 やり直し(R)はフェーズ 5 で足すので、それまではウィンドウを閉じて起動し直してください。

6. コードの解説

6-1. has_box() は「見つけたら即答、なければ最後に否定」

    def has_box(self, x, y):
        for box in self.boxes:
            if box[0] == x and box[1] == y:
                return True          # 見つかった。ここで終わり
        return False                 # 全部見たけれど、なかった

return はメソッドをその場で終わらせるので、 for の途中にあってもループごと抜けます。 荷物が 10 個あっても、1 個目で見つかれば残りの 9 個は見に行きません。

そして return Falseforにあります。 ここに到達したということは、ループが最後まで回りきった—— つまりどの荷物とも一致しなかったということです。

「ある?」と聞かれたら TrueFalse を返す has_box(4, 3) は「(4, 3) に荷物はある?」という質問で、 答えは TrueFalse です。 だから if self.has_box(next_x, next_y): と、 if にそのまま置けます
== True と書き足す必要はありません。 if self.has_box(...) == True: は間違いではありませんが、 「ある?が本当なら」と二度言っているようなもので、回りくどくなります。

6-2. なぜ box[0] += dxself.boxes が変わるのか

move_box()self.boxes という名前を一度も書き換えていません。 それなのに、荷物の座標が変わります。ここは Python の大事なところなので、 小さな例で確かめてみてください。

>>> kazu = [1, 2, 3]
>>> for n in kazu:
...     n += 1
>>> kazu
[1, 2, 3]              ← 変わらない

>>> boxes = [[3, 3], [5, 3]]
>>> for box in boxes:
...     box[0] += 1
>>> boxes
[[4, 3], [6, 3]]       ← 変わる!

同じ形の for なのに、結果が違います。違いは何をしているかです。

書き方やっていること元のリストは
n += 1 n という名札を、別の数に付け替えている。 数そのものは書き換えられない 変わらない
box[0] += 1 box が指しているリストの中身を書き換えている。 名札は付け替えていない 変わる

for box in self.boxes: と書いたとき、boxself.boxes の中のリストそのものを指しています。 写しではありません。だから box[0] を書き換えると、 self.boxes の中身も同時に変わります。

この性質は、フェーズ 5 で牙をむきます いまは都合よく働いてくれているこの性質が、 フェーズ 5 で「R でやり直す」を作るときにいちばんの落とし穴になります。 backup = self.boxes と書いても控えになりません同じリストに名札が 2 枚付くだけで、片方を押すともう片方も動いてしまうからです。
いまは「リストは名札で扱われる」とだけ頭の隅に置いておいてください。

6-3. 同じ荷物を 2 回探している

よく見ると、has_box() で見つけた荷物を、 move_box()もう一度探し直しています。 1 回で済ませたほうが速いのは確かです。

それでもこう書いたのは、2 つのメソッドの役目がはっきり分かれるからです。 has_box()調べるだけで、盤面を変えません。 move_box()動かすだけです。混ぜると、 「調べたつもりが動いていた」という分かりにくい不具合を生みます。

そして荷物はせいぜい数個です。2 回探しても、 1 回のキー入力で見る回数は多くて十数回。60 分の 1 秒の中では、無いに等しい手間です。 速さより読みやすさを選んでよい場面だと判断しました。

6-4. フェーズ 1 の「3 つに分ける」が効いています

荷物を self.tiles の中に "$" として置く作りにしていたら、 押すたびに2 か所を書き換えることになっていました。

tiles[3][3] = "."      # 荷物がいたマスを、床に戻す
tiles[2][3] = "$"      # 新しいマスに、荷物を置く

一見わかりやすそうですが、ゴールの上に乗った荷物で破綻します。 ゴール "O" のマスに荷物を置くと "O""$" で上書きされ、 そこから押し出したときに"." に戻してしまう—— ゴールが消えるのです

いまの作りなら、この不具合は起こりようがありませんself.tiles は最初から最後まで地形だけで、荷物がどこにあっても書き換えないからです。 実行して「ゴールから押し出してもゴールの印が残る」ことを確かめてください。 何もしていないのに正しく動く——それが、設計で不具合を防ぐということです。

6-5. far_x / far_y という名前

            far_x = next_x + dx      # 荷物の、1 つ先のマス
            far_y = next_y + dy

next(次の)に対して far(遠い)です。 主人公から見て 2 マス先を指しています。 next_next_x でも間違いではありませんが、長くて読みにくくなります。

この 2 行を if self.has_box(...)に置いているのも意図があります。 行き先に荷物があったときにしか、この座標は意味を持たないからです。 使う直前に、使う場所で計算する——こうしておくと、 関係のない変数が増えず、あとで読み返したときに迷いません。

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

症状原因と対処
荷物をすり抜けてしまう try_move()if self.has_box(next_x, next_y): の ひとかたまりを足し忘れています
主人公だけ動いて、荷物がその場に残る self.move_box(next_x, next_y, dx, dy) を呼んでいないか、 渡す座標が違います。渡すのは「荷物がいまいるマス」far_x ではなく next_x)です
荷物を押したときしか、主人公が動かない いちばん下の self.player_x = next_x の 2 行が、 if の中に入っています。字下げを 1 段浅くしてください
荷物が壁にめり込む/盤面から出る if self.tiles[far_y][far_x] == "#": を書いていません。 next ではなく far を見ているか確認してください
荷物どうしが重なる if self.has_box(far_x, far_y): を書いていません
荷物 2 つが同時に動く move_box() の中で far_x を使っています。 動かす荷物を探すのは x, y(=next の位置)で、 足すのは dx, dy です
荷物の手前で必ず止まる(押せない) has_box()return Falsefor の中に入っていませんか。 1 個目を見た時点で答えを返してしまい、 far の判定が常に「荷物あり」になっている可能性もあります
IndexError: list index out of range 地図の外周が壁で囲まれていません。 2 マス先を見るようになったぶん、フェーズ 2 より起きやすくなります
ゴールに乗せた荷物が、見た目で分からない いまはそれで正しいです。「ゴールに乗った荷物」の絵に切り替えるのは フェーズ 5 です(配置図u = 32 に描く予定の 1 枚)
荷物を角に入れてしまい、詰んだ 不具合ではありません。倉庫番はそういうゲームです。 いったんウィンドウを閉じて起動し直してください。 R でのやり直しはフェーズ 5 で足します

8. 次のフェーズ

これで倉庫番として遊べるようになりました。ただし動きはまだカクッと瞬間移動です。 次は歩くアニメを入れて、隣のマスへすべるように動かします。

次に出てくる考え方 マス目の位置と、画面に見えている位置を分けます。 キーを押した瞬間、盤面の上ではもう隣のマスに着いていて、 あとから追いつくのは絵だけ——という作りです (step2 概要の土台 3 に図があります)。
絵も使います。足踏みの 2 コマ目(4 方向ぶん)配置図の 3 段目(v = 16)に描き足すことになります。 1 コマ目とほとんど同じ絵で、足の位置だけ左右を入れ替えるのが手軽です。