フェーズ 5 クリア判定と、やり直し

解けたことを知らせる。詰んだら、いつでも最初から

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

ゴール

いまは荷物をゴールに乗せても何も起こりません。壁ぎわに押しつけて詰んだら、 ウィンドウを閉じるしかありません。ここでゲームとして成立させますすべての荷物がゴールに乗ったらクリアR キーでいつでも最初から——の 2 つです。 フェーズ 4 のコードに 26 行ほど書き足して、できあがりは 218 行になります。

先に絵を 1 枚描きます ゴールに乗った荷物の絵です(3-1)。 乗っているかどうかが一目で分かると、遊びやすさが一段変わります。
今回は「フェーズ 1 の設計が効く」回です フェーズ 1 で、地図を地形(tiles)・主人公・荷物(boxes)の 3 つに 分けて持つと決めました。あのとき「荷物がゴールから出たとき、床に戻し忘れる不具合を 構造で避けるため」と書いたのを覚えているでしょうか。 クリア判定は、その決定のおかげで 1 行で書けます。

その前に:主人公の透過色を変えました

フェーズ 4 を動かしたあと、主人公の目を黒くしたいという話になりました。 ところが黒は描いても出てきません。透過色(colkey)に黒を指定していたからです。

pyxel.blt() の最後の引数は「この 1 色だけは描かない」という指定です。 主人公の絵は背景を黒で塗っていたので、黒=背景として抜いていました。 目も同じ黒で描いたので、目も背景と区別されずに抜けてしまうわけです。

同じ「下向き・コマ A」の絵を、透過色だけ変えて見たもの 以前(絵の背景=黒 / 抜く色=黒) エディタの絵 目も背景も、同じ黒 床に置いた画面 目が抜けて、床が見える いま(絵の背景=グレー / 抜く色=グレー) エディタの絵 背景だけ塗り替えた 床に置いた画面 目が黒く出る colkey は「この 1 色だけ描かない」という指定です 黒(0)=目。描いてほしい色 グレー(13)=背景。抜いてほしい色 体・ふち・口 pyxel.blt(px, py, 0, u, v, 8, 8, pyxel.COLOR_GRAY) # 主人公はグレーを抜く pyxel.blt(bx, by, 0, u, v, 8, 8, pyxel.COLOR_BLACK) # リンゴは黒のまま 1 回の blt ごとに指定するので、絵によって背景の色を変えてかまいません
実際の sokoban.pyxres のドットです。背景だった黒だけをグレーに塗り替え、目の黒はそのまま残しました

直し方は 2 段階です。リソースエディタで、絵の背景をグレー(色番号 13)に塗り替える。 そしてコード側の pyxel.COLOR_BLACKpyxel.COLOR_GRAY に変える—— 主人公を描いている blt の 1 か所だけです。

        pyxel.blt(px, py, 0, self.player_dir * TILE, v, TILE, TILE, pyxel.COLOR_GRAY)
透過色に選ぶ色は、絵の中で 1 度も使っていない色にします もし犬の体にグレーを使っていたら、そこに穴があきます。 今回の絵は体が黄色・ふちがオレンジ・口が白・目が黒なので、グレーは背景だけに使われています。 だから安全に抜けます。
迷ったら、絵に使っていない色を 1 色決めて「これは背景色」と割り切るのが確実です。 Pyxel の 16 色のうち、紫(2)や緑(3)のような使いどころの少ない色を 背景専用にしてしまう作り方もよく見かけます。
リンゴ(荷物)は黒のままでよいのです 透過色は blt を呼ぶたびに指定するものなので、 絵ごとに違う色を指定してかまいません。 リンゴには黒を使っていないので、背景も黒のまま・指定も COLOR_BLACK のままで困りません。 タイル(床・壁・ゴール)はそもそも透過色を渡していません—— 8×8 をまるごと敷き詰めるので、抜く必要がないからです。
上向きだけ、目のドットが 0 個 4 方向の絵のうち、上向き(後ろ姿)には目がありません。 言われないと気づきませんが、こういう 1 ドットの判断が「ちゃんと後ろを向いている」感じを作ります。

1. 考え方

1-1. クリアとは「荷物の下が、ぜんぶゴールだった」こと

ゴールの位置は tiles に残っています。 地図の O は、荷物や主人公とちがってどこへも動きません。 だから調べることは 1 つだけです。

荷物のいるマスの地形が "O" かどうか。 これを荷物 1 つずつについて調べて、全部そろっていればクリアです。

荷物 1 つずつ「下の地形が O か」を調べ、全部そろったときだけクリア x=3 x=5 y=2 y=3 いまの盤面を、1 つずつ調べると boxes[0] = [3, 2] tiles[2][3] = "O" True boxes[1] = [5, 3] tiles[3][5] = "." False 縦(y)が先、横(x)があと。tiles[box[1]][box[0]] です 「全部そろったか」は all() ひとことで足ります all([True, False]) False まだクリアでない all([True, True]) True クリア! 1 つでも False があれば False。「かつ」を荷物の数だけ並べたものです
boxestiles を別々に持っているから、荷物の下の地形がいつでも分かります
もし地図を 1 つの表で持っていたら 荷物をゴールへ動かした瞬間に、そのマスの O$ で上書きしてしまいます。 すると「もとがゴールだったマス」が分からなくなり、 ゴールから押し出したときに床に戻せません
それを避けるために「O は地形」「$ は別のリスト」と分けました。 設計の良さは、こういう先の場面で効いてきます。

1-2. どこでクリアを調べるか——見た目が変わります

調べる中身は決まりました。次はいつ調べるかです。これで画面の印象が変わります。 候補は 2 つあります。

調べる場所どうなるか
try_move() の中
(キーを押した瞬間)
盤面の上ではもう乗っているので、STAGE CLEAR! が先に出ます。 リンゴがまだ 8 フレームかけてすべっている途中に文字が出て、気が早い感じになります
update() の「着いた」ところ
(すべり終わった瞬間)
リンゴがゴールに収まったのと同時に文字が出ます。こちらを選びます

フェーズ 4 で作った「盤面の位置」と「見た目の位置」の差が、そのまま出てくる話です。 盤面の判定はキーを押した瞬間、見せ方は着いた瞬間——この使い分けを覚えておいてください。

荷物の絵だけは、押した瞬間に切り替えます 乗った荷物の絵(光っているリンゴ)は、すべっている途中から光ります。 文字とちがって、こちらは早いほうが気持ちいいからです。 キーを押した手ごたえがすぐ返り、滑り込みながら光って、着いたところで文字が出る—— という流れになります。
「判定はいつでも正しい。見せ方だけは、その都度いちばん気持ちいいほうを選ぶ」 と考えると迷いません。

1-3. やり直しは「控えを戻す」より「作り直す」

R でステージの最初に戻したい。まず思いつくのは、 始めの状態を控えておいて、それを戻すやり方です。

# 最初に控えを取っておいて……
self.backup = self.boxes

# R が押されたら、戻す
self.boxes = self.backup

ところが、これは 1 文字も動きません
フェーズ 3 の 6-2「リストは中身が共有される」で見た話が、 今度は困る側で出てきます。

backup = self.boxes 名札が 1 枚増えるだけ。控えになっていない self.boxes backup [3, 3] [5, 3] リストは 1 つだけ 押せば、両方とも変わる backup = self.boxes[:] 外側は新しくなる。でも中の [x, y] は同じもの(浅いコピー) self.boxes backup [5, 3] 中の [5, 3] は 1 つだけ 1 つめの [3, 3] も同じ self.load_stage(STAGE1) 文字の地図から、まるごと作り直す。これを選びます STAGE1(文字列) "########" "#..O.O.#" "#..$.$.#" 読む まっさらな中身 tiles boxes 文字列は書き換えられない ので、何度読んでも同じ
A も B も、押した結果が控えにそのまま写ります。C はそもそも控えを持ちません
ずるいようで、これがいちばん正しい 「控えを取らずに作り直す」のは手抜きに見えるかもしれません。でも逆です。
控えを持つと、控えを壊さない義務が増えます。 持たなければ、その義務は生まれません。 load_stage() は最初から「文字の地図 → 遊べる状態」を作る係なので、 もう一度呼ぶだけで済みます。
しかもフェーズ 6 で 10 ステージにするとき、この形がそのまま使えます。 「次のステージへ」は load_stage(STAGES[n]) を呼ぶだけになります。

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

機能役割
all(…) ぜんぶ Trueを調べる Python の関数。1 つでも False があれば False
… for … in …
(ジェネレータ式)
for後ろに書く書き方。「リストの全員について、この値」をひとことで表す
not TrueFalse をひっくり返す。not self.cleared =「まだクリアしていない」
pyxel.btnp(pyxel.KEY_R) R キーを押した瞬間だけ。矢印キーの btn と使い分ける
pyxel.text(x, y, 文字, 色) 文字を描く。step1 のスコア表示と同じもの
return(値を返さない) update()途中で切り上げる。step1 フェーズ 6 で出てきた使い方

3. 手順

6 段階です。3-2 から 3-6 は続きものなので、最後まで書いてから実行してください。

3-1. 「ゴールに乗った荷物」の絵を 1 枚描く

リソースエディタ(pyxel edit sokoban.pyxres)を開き、 イメージバンク 0 の u = 32, v = 0 に 1 枚描きます。 場所は フェーズ 1 の配置図のとおり、タイルの段のいちばん右です。

位置描くもの
u = 24, v = 0ふつうのリンゴ(もうある)
u = 32, v = 0ゴールに乗ったリンゴ(これを描く)
「ちょっと違う」で十分です u = 24 のリンゴをコピーして右に貼り付け、少し明るくするのが手軽です。 ふちを白でひと巻きする、上にキラッと 2 ドット足す、実の色を 1 段明るい色に置き換える—— どれでも伝わります。
形は変えないほうがよいです。同じ荷物が「乗った状態」になっただけ、と分かるほうが 盤面が読みやすくなります。色や明るさで差をつけるのがコツです。
背景は黒のままにしてください(リンゴの透過色は COLOR_BLACK のままです)。

3-2. 定数を 1 つ足す

U_FLOOR = 0
U_WALL = 8
U_GOAL = 16
U_BOX = 24
U_BOX_ON_GOAL = 32      # ゴールに乗っている荷物      ← 足す

3-3. load_stage() に、クリアの旗を足す

self.pushing = False のうしろに 1 行です。

        self.walk_timer = 0             # 0 なら止まっている。1〜8 は歩いている途中
        self.pushing = False            # いまの一歩で、荷物を押しているか
        self.cleared = False            # このステージをクリアしたか      ← 足す
__init__() ではなく load_stage() に書きます self.cleared = False__init__() に書くと、 起動したときの 1 回しか初期化されません。 やり直すたびに False に戻ってほしいので、load_stage() の中です。
step1 フェーズ 7 で「1 回だけやること毎回やることを分ける」話をしました。 ここでも同じ基準です。load_stage()毎回やることの置き場です。

3-4. 調べるメソッドを 2 つ足す

move_box()try_move() のあいだに書きます。 どちらも調べて答えるだけで、盤面を変えません。

    def on_goal(self, box):
        """その荷物が、ゴールのマスに乗っているか"""
        return self.tiles[box[1]][box[0]] == "O"

    def is_cleared(self):
        """すべての荷物がゴールに乗ったか"""
        return all(self.on_goal(box) for box in self.boxes)
なぜ 2 つに分けるのか on_goal()クリア判定だけのものではありませんdraw() でも「この荷物は光っている絵にするか」を決めるのに使います(3-6)。 2 か所から呼ばれるので、名前をつけて独立させる価値があります
is_cleared() のほうは on_goal()荷物の数だけ並べたもの、 という関係です。

3-5. update() を書き換える

先頭に 4 行到着したところに 2 行、そして最後の if の条件を書き換えます。

    def update(self):
        """フレーム毎の更新処理"""
        if pyxel.btnp(pyxel.KEY_R):             # いつでも、最初からやり直せる      ← 足す
            self.load_stage(STAGE1)
            return

        if self.walk_timer > 0:                 # 歩いている途中
            self.walk_timer += 1
            if self.walk_timer > MOVE_FRAMES:   # 隣のマスに着いた
                self.walk_timer = 0
                self.from_x = self.player_x     # 見た目を、いまいるマスに合わせる
                self.from_y = self.player_y
                self.pushing = False
                if self.is_cleared():           # 着いた、その瞬間に調べる      ← 足す
                    self.cleared = True

        # 止まっていて、まだクリアしていないときだけ、キーを見る      ← 条件を足す
        if self.walk_timer == 0 and not self.cleared:
            self.handle_key()
and not self.cleared を忘れないでください フェーズ 4 の 6-1 で、 「着いたその同じフレームで、次の一歩に入れる」のがなめらかさの決め手だと書きました。 ここではそれが裏目に出ます
最後の一歩をキーを押しっぱなしで決めると、 cleared を立てたその同じフレームに handle_key() が呼ばれ、 もう一歩進んでしまいます。せっかく乗せたリンゴを、そのまま押し出してしまうのです。 くわしくは 6-2 を見てください。

3-6. draw() を 2 か所書き換える

荷物の blt3 行ぶんふくらませ、draw() のいちばん下に 5 行足します。

            u = U_BOX
            if self.on_goal(box):               # ゴールに乗っているあいだは別の絵      ← 足す
                u = U_BOX_ON_GOAL
            pyxel.blt(bx, by, 0, u, V_TILE, TILE, TILE, pyxel.COLOR_BLACK)

U_BOX と直接書いていたところが u に変わっている点に注意してください。 そして、いちばん下です。

        # クリアしたら、画面の上に知らせる
        if self.cleared:
            msg = "STAGE CLEAR!"
            x = (SCREEN_WIDTH - len(msg) * 4) // 2
            pyxel.text(x, 3, msg, pyxel.COLOR_YELLOW)
y = 3 は、空けておいた場所です フェーズ 1 で HUD_HEIGHT = 12 と決め、画面の上 12 ドットを盤面に使わずに空けてありました。 そこへ書いています。だから盤面に文字がかぶりません
中央ぞろえの式 (SCREEN_WIDTH - len(msg) * 4) // 2 は step1 と同じです。 Pyxel の標準フォントは1 文字 4 ドット送りなので、文字数 × 4 が文字列の幅になります。 "STAGE CLEAR!" は 12 文字 = 48 ドット、x = 56 です。

4. できあがりのコード

書き足したのは定数 1 つ、load_stage() の 1 行、update() の 6 行、 新しい on_goal()is_cleared()、そして draw() の 2 か所です。

# sokoban.py
# step2 フェーズ5:クリア判定と、やり直し

import pyxel

SCREEN_WIDTH = 160
SCREEN_HEIGHT = 120
TILE = 8                # 1 マスの大きさ(ドット)
HUD_HEIGHT = 12         # 画面の上、手数などを出すために空けておく高さ
MOVE_FRAMES = 8         # 隣のマスへ移りきるまでにかけるフレーム数

# イメージバンク 0 の、切り出し位置
V_TILE = 0              # タイルの段
V_PLAYER_A = 8          # 主人公の段(コマ A)
V_PLAYER_B = 16         # 主人公の段(コマ B・足を入れ替えた絵)
U_FLOOR = 0
U_WALL = 8
U_GOAL = 16
U_BOX = 24
U_BOX_ON_GOAL = 32      # ゴールに乗っている荷物

# 向き。この番号がそのまま主人公の絵の切り出し位置になる
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      # はじめは下を向いている
        self.walk_timer = 0             # 0 なら止まっている。1〜8 は歩いている途中
        self.pushing = False            # いまの一歩で、荷物を押しているか
        self.cleared = False            # このステージをクリアしたか

        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)

        self.from_x = self.player_x     # 見た目の出発マス
        self.from_y = self.player_y

        # 盤面を画面の中央に置くための、ずらし幅
        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):
        """フレーム毎の更新処理"""
        if pyxel.btnp(pyxel.KEY_R):             # いつでも、最初からやり直せる
            self.load_stage(STAGE1)
            return

        if self.walk_timer > 0:                 # 歩いている途中
            self.walk_timer += 1
            if self.walk_timer > MOVE_FRAMES:   # 隣のマスに着いた
                self.walk_timer = 0
                self.from_x = self.player_x     # 見た目を、いまいるマスに合わせる
                self.from_y = self.player_y
                self.pushing = False
                if self.is_cleared():           # 着いた、その瞬間に調べる
                    self.cleared = True

        # 止まっていて、まだクリアしていないときだけ、キーを見る
        if self.walk_timer == 0 and not self.cleared:
            self.handle_key()

    def handle_key(self):
        """矢印キーを見て、押された向きへ 1 歩進もうとする"""
        if pyxel.btn(pyxel.KEY_DOWN):
            self.try_move(DIR_DOWN)
        elif pyxel.btn(pyxel.KEY_UP):
            self.try_move(DIR_UP)
        elif pyxel.btn(pyxel.KEY_LEFT):
            self.try_move(DIR_LEFT)
        elif pyxel.btn(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 on_goal(self, box):
        """その荷物が、ゴールのマスに乗っているか"""
        return self.tiles[box[1]][box[0]] == "O"

    def is_cleared(self):
        """すべての荷物がゴールに乗ったか"""
        return all(self.on_goal(box) for box in self.boxes)

    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.pushing = True                 # この一歩は、荷物を押している

        self.player_x = next_x
        self.player_y = next_y
        self.walk_timer = 1                     # ここから 8 フレームかけて歩く

    def slide_pos(self, origin, from_cell, to_cell):
        """出発マスから到着マスへ、いまどこまで来たかをドットで返す"""
        moved = (to_cell - from_cell) * TILE * self.walk_timer // MOVE_FRAMES
        return origin + from_cell * TILE + moved

    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)

        # 荷物。押している 1 つだけは、主人公といっしょにすべる
        dx, dy = MOVES[self.player_dir]
        push_x = self.player_x + dx             # 押した荷物は、主人公の目の前にいる
        push_y = self.player_y + dy
        for box in self.boxes:
            if self.pushing and box[0] == push_x and box[1] == push_y:
                bx = self.slide_pos(self.ox, box[0] - dx, box[0])
                by = self.slide_pos(self.oy, box[1] - dy, box[1])
            else:
                bx = self.ox + box[0] * TILE
                by = self.oy + box[1] * TILE
            u = U_BOX
            if self.on_goal(box):               # ゴールに乗っているあいだは別の絵
                u = U_BOX_ON_GOAL
            pyxel.blt(bx, by, 0, u, V_TILE, TILE, TILE, pyxel.COLOR_BLACK)

        # 主人公
        px = self.slide_pos(self.ox, self.from_x, self.player_x)
        py = self.slide_pos(self.oy, self.from_y, self.player_y)
        v = V_PLAYER_A
        if self.walk_timer >= MOVE_FRAMES // 2:     # 歩きの後半は、足を入れ替える
            v = V_PLAYER_B
        pyxel.blt(px, py, 0, self.player_dir * TILE, v, TILE, TILE, pyxel.COLOR_GRAY)

        # クリアしたら、画面の上に知らせる
        if self.cleared:
            msg = "STAGE CLEAR!"
            x = (SCREEN_WIDTH - len(msg) * 4) // 2
            pyxel.text(x, 3, msg, pyxel.COLOR_YELLOW)

App()

5. 実行する

cd step2
python sokoban.py
やってみることこうなれば成功
リンゴ 1 個をゴールに乗せる そのリンゴが光っている絵に変わる。まだ STAGE CLEAR! は出ない
乗せたリンゴを、もう一度押して外す ふつうのリンゴの絵に戻る。ゴールの目印も消えていない
2 個ともゴールに乗せる リンゴが収まったその瞬間に画面の上に STAGE CLEAR! が出る
最後の一歩を、キーを押しっぱなしで決める クリアしたところで止まる。そのまま先へ歩いていかない
クリアしたあと、矢印キーを押す もう動かない(向きも変わらない)
R を押す リンゴと主人公が最初の位置に戻るSTAGE CLEAR! も消える
歩いている途中で R を押す すべりが止まって、すぐ初期状態になる
R を押しっぱなしにする 1 回だけやり直る。押し続けても連続でリセットされない
リンゴを壁ぎわに押しつけて詰ませる R で立て直せる。ウィンドウを閉じなくてよくなった
もう一歩:クリアの見せ方を足してみる 本格的なクリア演出はフェーズ 8でやりますが、1 行ずつで試せるものを挙げておきます。 実際に指で触って、気持ちいいほうを選んでください。

6. コードの解説

6-1. all() と、for を後ろに書く書き方

        return all(self.on_goal(box) for box in self.boxes)

はじめて見ると、for が後ろにあるのが不思議に見えると思います。 これは「リストの全員について、この値を出す」を 1 行で書く書き方です。 いつもの for で書くと、こうなります。

# ぜんぶ書き出した版(同じ意味)
    def is_cleared(self):
        for box in self.boxes:
            if not self.on_goal(box):
                return False        # 1 つでも乗っていなければ、その場でおしまい
        return True                 # 最後まで来たなら、ぜんぶ乗っていた

どちらでも正しく動きます。 今回 all() を選んだのは、読んだとおりの意味になるからです。 「all(すべて)が on_goal(ゴールに乗っている)」と、 英語の文のように読めます。

かっこがいらない理由 all([self.on_goal(box) for box in self.boxes])角かっこをつけても動きます。こちらは「リスト内包表記」といって、 先に TrueFalse のリストを作ってから all() に渡します。
かっこなしの書き方(ジェネレータ式)は、リストを作らずに1 つずつ手渡します。 だから False が出た時点で、残りを調べずに打ち切ります。 荷物 2 個では差は出ませんが、短く書けて、しかも無駄がないのでこちらを使います。
all() は、空っぽのとき True です all([])True を返します(実測で確認しました)。 「反例が 1 つもない」=「すべて成り立つ」という決まりです。
つまり荷物を 1 つも置いていない地図を作ると、起動した瞬間にクリアになります$ を書き忘れたときに、これで気づけます。

6-2. クリアした同じフレームに、もう一歩進んでしまう話

and not self.cleared を書かないと何が起きるか、 実際に走らせて 1 フレームずつ記録しました。 最後の一歩を ↑ を押しっぱなしで決めたときの動きです。

フレームboxes主人公walk_timercleared
1[3,2] [5,2](5, 3)1False
2 〜 7[3,2] [5,2](5, 3)2 〜 7False
8[3,2] [5,2](5, 3)8False
9[3,2] [5,1](5, 2)1True

9 フレーム目を見てください。clearedTrue になった、その同じフレームで リンゴが [5,2] から [5,1] へ動いています。 ゴールから押し出してしまったのです。結果はこうなります。

STAGE CLEAR! と出ているのに、リンゴはゴールの上にない
(is_cleared() は False なのに、cleared は True のまま)

理由は、フェーズ 4 でわざとそう作ったところにあります。 update() の 2 つの ifelse ではないので、 「着いた」と「次のキーを見る」が同じフレームに起きます。 なめらかに歩かせるための仕組みが、そのままここへ効いてきます。

        if self.walk_timer > 0:
            ...
                self.walk_timer = 0            # ← 0 になる
                if self.is_cleared():
                    self.cleared = True        # ← 同じフレームで True になる

        if self.walk_timer == 0 and not self.cleared:   # ← だからここで止める
            self.handle_key()
旗を 1 つ足すと、見る場所も 1 つ増えます self.pushing(フェーズ 4)でも同じ話をしました。 旗は、立てる場所・下ろす場所・見る場所の 3 つをセットで考えるものです。
self.cleared は——立てるのが update() の到着のところ、 下ろすのが load_stage()、見るのが update() の入力と draw() の表示。 この 4 か所がそろって、はじめて正しく動きます。
そもそも旗を持たない手もあります self.cleared をやめて、必要なときに毎回 is_cleared() を呼ぶ 作りにもできます(if self.walk_timer == 0 and not self.is_cleared():)。 1 フレームに 2 回ほど余分に数えるだけなので、速さは問題になりません。
それでも旗を持ったのは 2 つの理由です。 is_cleared()盤面の位置で答えるので、 リンゴがすべり終わるのを待たずに True になり、文字が早く出てしまいます フェーズ 8 で「クリアした瞬間から何フレーム経ったか」を使って演出を作ります。 そのとき「瞬間」を捉えられる形にしておきたいからです。

6-3. リストのコピーは、どこまでが「別もの」か

1-3 の図を、実際に動かして確かめた結果です。すべて実測です。

>>> boxes = [[3, 3], [5, 3]]
>>> backup = boxes          # 名札を増やすだけ
>>> backup is boxes
True
>>> boxes[0][1] -= 1        # リンゴを 1 マス押した
>>> backup
[[3, 2], [5, 3]]            # 控えも動いている
>>> boxes = [[3, 3], [5, 3]]
>>> backup = boxes[:]       # 外側だけ新しく作る(浅いコピー)
>>> backup is boxes
False                       # 外側は別もの
>>> backup[0] is boxes[0]
True                        # でも中身は同じもの
>>> boxes[0][1] -= 1
>>> backup
[[3, 2], [5, 3]]            # やはり控えも動いている

boxes[:] のほかに list(boxes)boxes.copy() も ありますが、3 つとも同じ「浅いコピー」です。中の [x, y] は共有されます。

ちゃんと控えを取るなら、中身まで作り直します

>>> boxes = [[3, 3], [5, 3]]
>>> backup = [box[:] for box in boxes]    # 1 つずつコピーして、新しいリストに
>>> backup[0] is boxes[0]
False                                    # 中身も別もの
>>> boxes[0][1] -= 1
>>> backup
[[3, 3], [5, 3]]                         # 控えは無事

import copy して copy.deepcopy(boxes) と書く方法もあり、結果は同じです。

なぜ文字の地図は壊れないのか STAGE1文字列のリストです。文字列は1 文字も書き換えられません
>>> STAGE1[1][3] = "."
TypeError: 'str' object does not support item assignment
だから load_stage(STAGE1) を何度呼んでも、 いつも同じ初期状態が出てきます
「書き換えられないもの」を原本にしておくと、控えの心配がいらなくなる—— これは覚えておくと、この先ずっと役に立つ考え方です。
tiles も同じ注意がいります self.tilesリストのリストなので、 self.tiles[:] では行が共有されます。 いまは tiles を書き換えていないので問題になりませんが、 「二次元リストの控えは、浅いコピーでは取れない」と頭に入れておいてください。

6-4. R だけ btnp にする理由

フェーズ 4 で矢印キーを btnp から btn に変えました。 なのに Rbtnp です。これは矛盾ではありません

キー使うものなぜ
矢印btn 押しっぱなしで歩き続けてほしい。歩いている間は見に行かないので暴走しない
Rbtnp 1 回で足りる。押しっぱなしのあいだ毎フレーム作り直しても、無駄なだけ

もし Rbtn にすると、押しているあいだずっと初期状態に戻され続けますR を押しながら矢印を押しても1 歩も進めません——毎フレーム return で 引き返してしまうからです。btnp なら 1 フレーム目だけ効き、 2 フレーム目からは矢印が通ります(これも実測で確認しました)。

R の判定を update() のいちばん上に置く理由 歩いている途中でも効かせたいからです。 もし下に置くと、if self.walk_timer == 0 … の中に入ってしまい、 すべっているあいだは R が無視されます
return でその下をぜんぶ飛ばしているので、 作り直した直後の状態に、同じフレームで歩きの処理が割り込むこともありません「例外的な操作は、いちばん上で片づけて return——覚えやすい型です。

6-5. 荷物の絵は、毎フレーム選び直しています

            u = U_BOX
            if self.on_goal(box):
                u = U_BOX_ON_GOAL

ここで大事なのは、「乗った」「外した」を覚えていないことです。 毎フレーム、いまの位置を見て、その場で絵を決めています

だからゴールから押し出したときに「絵を戻す」処理がいりません。 覚えていないものは、戻し忘れることもありません。 これはフェーズ 1 で「地形と荷物を分けて持つ」と決めたことの、そのままの成果です。

「状態を覚える」か「毎回計算する」か ゲームを作っていると、いつもこの二択に出会います。 まず「毎回計算できないか」を考え、できないときだけ覚える。 この順番にすると、状態の数が増えず、不具合が減ります

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

症状原因と対処
起動した瞬間から STAGE CLEAR! が出ている 地図に $(荷物)がありません。all([])True なので、 荷物 0 個は「ぜんぶ乗っている」と判定されます(6-1
荷物を乗せても STAGE CLEAR! が出ない 地図の O の数と $ の数が合っていませんか。 荷物のほうが多いと、永久にクリアできません
STAGE CLEAR! が出ているのに、リンゴがゴールに乗っていない and not self.cleared を書いていません。 クリアした同じフレームに、もう一歩進んでいます(6-2
クリアしたあとも歩けてしまう 同じ原因です。if self.walk_timer == 0 and not self.cleared: を確かめてください
クリアの文字が、リンゴが着く前に出る if self.is_cleared():try_move() の中に書いています。 update()到着したところに移してください(1-2
乗せた荷物の絵が変わらない u = U_BOX のままで、blt の引数が U_BOX と直接書かれていませんか。u を渡します
乗せた荷物が消える u = 32, v = 0 に絵を描いていません。 何もない場所を切り出すと、黒(透過色)だけが並びます。3-1 に戻ってください
R を押しても何も起きない pyxel.KEY_R の綴りを確かめてください。 また、update()いちばん上にありますか
R を押すと、矢印キーが効かなくなる btn(pyxel.KEY_R) になっています。btnp です (6-4
R でやり直しても、クリアの文字が消えない self.cleared = Falseload_stage() ではなく __init__() に書いています
AttributeError: 'App' object has no attribute 'cleared' self.cleared = False を書いていません。 load_stage() の中、self.pushing = False のうしろです
TypeError: 'bool' object is not iterable all(self.on_goal(box)) のように、for の部分を書き忘れています。 all(self.on_goal(box) for box in self.boxes) です
IndexError: list index out of range on_goal()self.tiles[box[0]][box[1]] と、 x と y を逆にしていませんか。tiles[box[1]][box[0]] です
文字が画面からはみ出す/左に寄る 中央ぞろえの式が (SCREEN_WIDTH - len(msg)) // 2 になっていませんか。 * 4 が必要です(1 文字 4 ドット)
主人公の目が黒くならない/体に穴があく 透過色の指定を見直してください。絵の背景をグレーにしたら、 blt の最後も pyxel.COLOR_GRAY です(この節

8. 次のフェーズ

これで1 ステージぶんのゲームが完成しました。解ける、勝てる、やり直せる—— ゲームとして必要なものは、ぜんぶそろっています。

次はステージを 10 個にします。 ここでうれしいのは、ほとんど書き足すものがないことです。 load_stage() が「文字の地図をもらって、遊べる状態を作る」形になっているので、 地図を並べたリストを用意して、番号で渡すだけです。

次に出てくる考え方 データを増やして、コードは増やさない:これが step2 の柱です(概要の 3)。 STAGES = [STAGE1, STAGE2, …] と並べ、self.stage_no で 何番目かを覚えます。
10 ステージぶんの地図を、自分で考えます。 これがけっこう楽しく、そして難しいところです。 解けるかどうかを自分で確かめながら作ることになります。 盤面の大きさはステージごとに変えてかまいません—— 中央ぞろえは load_stage() が毎回計算しているので、何マスでも勝手に真ん中に来ます