フェーズ 6 ステージを 10 個にする

データを増やす。コードは、ほとんど増やさない

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

ゴール

ステージを 10 個にします。クリアしたら Enter で次へ、 10 個ぜんぶ終わったら 全クリアです。

ここでうれしいのは、頭を使って書くコードが 25 行しかないことです。 残りはぜんぶ地図のデータ。フェーズ 1 で load_stage() を「文字の地図をもらって、遊べる状態を作る係」にしておいたので、 渡す地図を変えるだけでステージが変わります。 フェーズ 5 の 218 行に足して、できあがりは 356 行になります。

これが step2 の柱です 概要の 3 に「データでゲームを作る」と書きました。 ステージを増やすのに、コードを書き足さない。 この形にしておくと、11 個目を足したくなったときも地図を 1 つ書いて、 リストに名前を 1 つ足すだけで済みます。

1. 考え方

1-1. 地図を並べて、番号で取り出す

いまは STAGE1 という名前を load_stage() に直接渡しています。 これを 10 個まとめたリストにして、「いま何番目か」を数ひとつで覚える形に変えます。

地図に名前をつけ、1 本のリストに並べて、番号で取り出します STAGE1 STAGE2 STAGE10 STAGES 01 2 345 678 9 STG1STG2 STG3 STG4STG5STG6 STG7STG8STG9 STG10 self.stage_no = 2 self.load_stage(STAGES[self.stage_no]) 番号は 0 から数えるので、STAGES[2]3 番目の地図です 画面に出すときだけ stage_no + 1 にして「STAGE 3」と書きます
覚えておくのは数ひとつだけ。盤面そのものは load_stage() が毎回まっさらに作り直します

1-2. クリアしてから、次が始まるまで

ステージが 1 つだったときは、クリアしたらそこで終わりでした。 これからはその先があります。

プレイ中 全部乗った CLEAR! と表示 Enter 最後のステージ? いいえ stage_no を 1 増やして、次の地図を読み込む はい ALL CLEAR! と表示 Enter で 1 面へ 「最後のステージ?」を、コードではこう書きます if self.stage_no + 1 < len(STAGES): R は、いつ押しても「いま遊んでいるステージ」の最初に戻ります(1 面ではありません)
増える状態は self.stage_noself.all_cleared2 つだけです
最後のステージでも、まず CLEAR! が出ます 10 面を解いた瞬間に ALL CLEAR! が出るのではなく、 いったん CLEAR! が出て、Enter を押すと ALL CLEAR! になります。
「1 面ぶんを解いた」と「10 面ぜんぶ終わった」は別のできごとなので、 分けて見せたほうが気持ちがそろいます。コードのうえでも、 10 面目だけ特別扱いする分岐が増えません

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

機能役割
リストの中にリスト STAGES は「地図(文字列のリスト)」を 10 個並べたもの。 MOVES と同じ「並べて、番号で引く」
len(STAGES) ステージの個数10 と直接書かないので、増やしても直す場所がない
pyxel.btnp(pyxel.KEY_RETURN) Enter キー。step1 のタイトル画面と同じ
f 文字列 f"STAGE {self.stage_no + 1}/{len(STAGES)}"。step1 のスコア表示と同じ
class の外の関数 draw_center()self を使わないので外に置く

3. 手順

7 段階です。3-1 の入力がいちばん長いので、気長にいきましょう

3-1. ステージの地図を 9 個足す

STAGE1 の下に、STAGE2 から STAGE10 まで並べます。 10 個とも、解けることを確かめてあります(確かめ方は 7 に書きました)。

STAGE2 = [          # 横にも押せる
    "########",
    "#......#",
    "#.$..O.#",
    "#.@....#",
    "#.$..O.#",
    "#......#",
    "########",
]

STAGE3 = [          # 縦と横を組み合わせる
    "#########",
    "#.O...O.#",
    "#.......#",
    "#..$....#",
    "#....$..#",
    "#..@....#",
    "#.......#",
    "#########",
]

STAGE4 = [          # 壁があるので、押す側へ回り込む
    "##########",
    "#........#",
    "#.O.##.O.#",
    "#...##...#",
    "#..$..$..#",
    "#........#",
    "#...@....#",
    "##########",
]

STAGE5 = [          # 段が仕切られていて、行き来に回り道がいる
    "##########",
    "#........#",
    "#O..$....#",
    "#...##...#",
    "#O..$....#",
    "#...##...#",
    "#O..$....#",
    "#....@...#",
    "##########",
]

STAGE6 = [          # どれから動かすか、順番を考える
    "##########",
    "#........#",
    "#.O.O.O..#",
    "#........#",
    "#.$......#",
    "#..$.....#",
    "#...$....#",
    "#....@...#",
    "##########",
]

STAGE7 = [          # 細い通路。押せる向きが決まってしまう
    "##########",
    "#....#...#",
    "#.$..#.O.#",
    "#....#...#",
    "#.$......#",
    "#....#...#",
    "#.@..#.O.#",
    "#....#...#",
    "##########",
]

STAGE8 = [          # 真ん中のゴールへ、四方から運ぶ
    "############",
    "#..........#",
    "#.$......$.#",
    "#....##....#",
    "#...OOOO...#",
    "#....##....#",
    "#.$......$.#",
    "#.....@....#",
    "############",
]

STAGE9 = [          # 中央の壁を大きく回る
    "###########",
    "#.........#",
    "#.OO..$$..#",
    "#.........#",
    "#...###...#",
    "#.........#",
    "#.OO..$$..#",
    "#....@....#",
    "###########",
]

STAGE10 = [         # 仕上げ。四隅へ運ぶ
    "###########",
    "#O.......O#",
    "#.........#",
    "#...$.$...#",
    "#..#...#..#",
    "#...$.$...#",
    "#.........#",
    "#O...@...O#",
    "###########",
]
ここだけは、写し間違いが命取りです #. を 1 つ間違えるだけで、解けない地図になります。 写したあとで 検算ツールを走らせると、その場で分かります
とくに気をつけるのは行の長さです。同じステージの中では、 すべての行が同じ文字数でなければいけません。

3-2. 10 個をリストにまとめる

STAGE10 のすぐ下です。並べた順が、遊ぶ順になります。

STAGES = [STAGE1, STAGE2, STAGE3, STAGE4, STAGE5,
          STAGE6, STAGE7, STAGE8, STAGE9, STAGE10]

3-3. 文字を中央に描く関数を足す

STAGES の下、class App:手前に書きます。

def draw_center(text, y, color):
    """文字を、画面の横まんなかに描く"""
    x = (SCREEN_WIDTH - len(text) * 4) // 2
    pyxel.text(x, y, text, color)
なぜ class の外なのか この関数は self1 度も使いません。 画面の幅と文字列だけで答えが出ます。
step1 でも is_hit()draw_center() を外に置きました。 基準は同じで、「ゲームの状態を見ないなら、外に出す」です。 フェーズ 5 では 1 か所でしか使わなかったので draw() の中に直接書いていましたが、 2 か所から使うことになったので、名前をつけて外に出します

3-4. __init__() に、進み具合を 2 つ足す

    def __init__(self):
        """起動時の設定"""
        pyxel.init(SCREEN_WIDTH, SCREEN_HEIGHT, title="Sokoban")
        pyxel.load("sokoban.pyxres")
        self.stage_no = 0               # いま何番目のステージか(0 から数える)   ← 足す
        self.all_cleared = False        # 10 個ぜんぶ終わったか                    ← 足す
        self.load_stage(STAGES[self.stage_no])      # ← STAGE1 から書き換える
        pyxel.run(self.update, self.draw)
load_stage() の中に書いてはいけません self.stage_no__init__() に書きますload_stage() に書くと、R を押すたびに 1 面に戻ってしまいます
見分け方は「1 回だけやること」か「ステージが変わるたびにやること」かです。 walk_timercleared はステージごとに 0 / False へ。 stage_noステージをまたいで覚えておく数なので、外側です。

3-5. next_stage() を足す

is_cleared() のうしろに書きます。

    def next_stage(self):
        """次のステージへ進む。最後だったら全クリア"""
        if self.stage_no + 1 < len(STAGES):
            self.stage_no += 1
            self.load_stage(STAGES[self.stage_no])
        else:
            self.all_cleared = True

3-6. update() の先頭を書き換える

R の 3 行を差し替えて、その前後にブロックを 2 つ足します。

    def update(self):
        """フレーム毎の更新処理"""
        if self.all_cleared:                    # ぜんぶ終わった。Enter で 1 面から   ← 足す
            if pyxel.btnp(pyxel.KEY_RETURN):
                self.stage_no = 0
                self.all_cleared = False
                self.load_stage(STAGES[self.stage_no])
            return

        if pyxel.btnp(pyxel.KEY_R):             # いつでも、このステージをやり直せる
            self.load_stage(STAGES[self.stage_no])      # ← STAGE1 から書き換える
            return

        if self.cleared:                        # クリアした。Enter で次のステージへ   ← 足す
            if pyxel.btnp(pyxel.KEY_RETURN):
                self.next_stage()
            return

        if self.walk_timer > 0:                 # 歩いている途中(ここから下はそのまま)
いちばん下の and not self.cleared は、そのまま残しますif self.cleared: return を足したのだから、もういらないのでは?」と思うかもしれません。 いります。
上の return が効くのは、フレームの初めからクリアしていたときだけです。 いままさに、このフレームでクリアしたときは通り抜けてしまいます。 フェーズ 5 の 6-2 と同じ話です。

3-7. draw() の表示を書き換える

draw() のいちばん下、フェーズ 5 で書いた 5 行を差し替えます。

        # 画面の上の表示
        if self.all_cleared:
            draw_center("ALL CLEAR!  ENTER", 3, pyxel.COLOR_YELLOW)
        else:
            pyxel.text(2, 3, f"STAGE {self.stage_no + 1}/{len(STAGES)}",
                       pyxel.COLOR_GRAY)
            if self.cleared:
                draw_center("CLEAR!  ENTER", 3, pyxel.COLOR_YELLOW)

4. できあがりのコード

地図が 113 行、コードが 25 行の追加です。

# sokoban.py
# step2 フェーズ6:ステージを 10 個にする

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.#",
    "#..$.$.#",
    "#..@...#",
    "########",
]

STAGE2 = [          # 横にも押せる
    "########",
    "#......#",
    "#.$..O.#",
    "#.@....#",
    "#.$..O.#",
    "#......#",
    "########",
]

STAGE3 = [          # 縦と横を組み合わせる
    "#########",
    "#.O...O.#",
    "#.......#",
    "#..$....#",
    "#....$..#",
    "#..@....#",
    "#.......#",
    "#########",
]

STAGE4 = [          # 壁があるので、押す側へ回り込む
    "##########",
    "#........#",
    "#.O.##.O.#",
    "#...##...#",
    "#..$..$..#",
    "#........#",
    "#...@....#",
    "##########",
]

STAGE5 = [          # 段が仕切られていて、行き来に回り道がいる
    "##########",
    "#........#",
    "#O..$....#",
    "#...##...#",
    "#O..$....#",
    "#...##...#",
    "#O..$....#",
    "#....@...#",
    "##########",
]

STAGE6 = [          # どれから動かすか、順番を考える
    "##########",
    "#........#",
    "#.O.O.O..#",
    "#........#",
    "#.$......#",
    "#..$.....#",
    "#...$....#",
    "#....@...#",
    "##########",
]

STAGE7 = [          # 細い通路。押せる向きが決まってしまう
    "##########",
    "#....#...#",
    "#.$..#.O.#",
    "#....#...#",
    "#.$......#",
    "#....#...#",
    "#.@..#.O.#",
    "#....#...#",
    "##########",
]

STAGE8 = [          # 真ん中のゴールへ、四方から運ぶ
    "############",
    "#..........#",
    "#.$......$.#",
    "#....##....#",
    "#...OOOO...#",
    "#....##....#",
    "#.$......$.#",
    "#.....@....#",
    "############",
]

STAGE9 = [          # 中央の壁を大きく回る
    "###########",
    "#.........#",
    "#.OO..$$..#",
    "#.........#",
    "#...###...#",
    "#.........#",
    "#.OO..$$..#",
    "#....@....#",
    "###########",
]

STAGE10 = [         # 仕上げ。四隅へ運ぶ
    "###########",
    "#O.......O#",
    "#.........#",
    "#...$.$...#",
    "#..#...#..#",
    "#...$.$...#",
    "#.........#",
    "#O...@...O#",
    "###########",
]

STAGES = [STAGE1, STAGE2, STAGE3, STAGE4, STAGE5,
          STAGE6, STAGE7, STAGE8, STAGE9, STAGE10]

def draw_center(text, y, color):
    """文字を、画面の横まんなかに描く"""
    x = (SCREEN_WIDTH - len(text) * 4) // 2
    pyxel.text(x, y, text, color)


class App:
    def __init__(self):
        """起動時の設定"""
        pyxel.init(SCREEN_WIDTH, SCREEN_HEIGHT, title="Sokoban")
        pyxel.load("sokoban.pyxres")
        self.stage_no = 0               # いま何番目のステージか(0 から数える)
        self.all_cleared = False        # 10 個ぜんぶ終わったか
        self.load_stage(STAGES[self.stage_no])
        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 self.all_cleared:                    # ぜんぶ終わった。Enter で 1 面から
            if pyxel.btnp(pyxel.KEY_RETURN):
                self.stage_no = 0
                self.all_cleared = False
                self.load_stage(STAGES[self.stage_no])
            return

        if pyxel.btnp(pyxel.KEY_R):             # いつでも、このステージをやり直せる
            self.load_stage(STAGES[self.stage_no])
            return

        if self.cleared:                        # クリアした。Enter で次のステージへ
            if pyxel.btnp(pyxel.KEY_RETURN):
                self.next_stage()
            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 next_stage(self):
        """次のステージへ進む。最後だったら全クリア"""
        if self.stage_no + 1 < len(STAGES):
            self.stage_no += 1
            self.load_stage(STAGES[self.stage_no])
        else:
            self.all_cleared = True

    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.all_cleared:
            draw_center("ALL CLEAR!  ENTER", 3, pyxel.COLOR_YELLOW)
        else:
            pyxel.text(2, 3, f"STAGE {self.stage_no + 1}/{len(STAGES)}",
                       pyxel.COLOR_GRAY)
            if self.cleared:
                draw_center("CLEAR!  ENTER", 3, pyxel.COLOR_YELLOW)

App()

5. 実行する

cd step2
python sokoban.py
やってみることこうなれば成功
起動する画面の左上に STAGE 1/10 と出る。盤面はいままでと同じ
1 面を解くCLEAR! ENTER が出る
Enter を押す2 面が出る。左上が STAGE 2/10 になり、盤面の形も変わる
3 面へ進む盤面が大きくなり、位置も真ん中のままずれない
2 面の途中で R2 面の最初に戻る(1 面には戻らない)
Enter を押しっぱなし1 つだけ進む。一気に飛ばない
10 面を解くCLEAR! ENTEREnterALL CLEAR! ENTER
全クリアで Enter1 面に戻る。最初から遊び直せる
ステージの手ごたえ 10 個は、だんだん難しくなるように並べてあります。 「押し」は荷物を押した回数、「矢印」はそのときの矢印キーの回数です (どちらも検算ツールが出した数です)。
大きさ荷物押し矢印ねらい
18×6227押すことを覚える
28×72613横にも押せる
39×82720縦と横を組み合わせる
410×82622壁を回り込む
510×93940仕切りがあって遠回り
610×931235どれから動かすか
710×921634細い通路
812×941876真ん中のゴールへ四方から
911×942082中央の壁を大きく回る
1011×942057四隅へ運ぶ
10 面は手数こそ短いのに、いちばん頭を使います。 短い=やさしい、ではないのが倉庫番のおもしろいところです。

6. コードの解説

6-1. 0 から数える番号と、1 から数える表示

self.stage_no = 0                       # 中では 0 から
f"STAGE {self.stage_no + 1}/{len(STAGES)}"   # 見せるときだけ +1

リストの番号は 0 から始まります。STAGES[0] が 1 面です。 でも遊ぶ人に「STAGE 0」と見せるのは変なので、描くときだけ +1 します

この +1 は、なるべく 1 か所にまとめます。 あちこちで足したり引いたりすると、どちらの数なのか分からなくなります。 「中はいつも 0 から。画面に出すときだけ +1」と決めておくと迷いません。

6-2. 10 と書かずに len(STAGES) と書く

if self.stage_no + 1 < len(STAGES):     # 「まだ次がある?」

ここを if self.stage_no + 1 < 10: と書いても、いまは同じように動きます。 でも11 個目を足した瞬間に、10 面で全クリアになってしまいます

len(STAGES) と書いておけば、地図を 1 つ足すだけで、 どこも直さずに 11 ステージのゲームになります。 画面の STAGE 3/1010 も、同じ理由で len(STAGES) です。

「数えられるものは、数えさせる」 step1 でも GHOST_MOVE_EVERY[len(self.ghosts)] という書き方をしました。 いま何体いるかを、リストの長さから取り出すやり方です。 自分で数を書くと、本体とその数の 2 か所を、いつもそろえる義務が生まれます。 数えさせれば、その義務は消えます。

6-3. update() の頭に、早い return が 3 つ並ぶ

update() の上半分は、「ふつうの操作ではないもの」を先に片づけて帰る形になりました。

順番見るものなぜその順か
1全クリアしているか いちばん特別な状態。ここでは R も矢印も意味がない
2R が押されたか 歩いている途中でも効かせたいので、歩きの処理より前
3ステージをクリアしたか Enter だけを受け付ける。矢印はもう見ない
4ふつうの操作 歩きを進めて、キーを見る

特別な場合を上から順に return で落としていくと、いちばん下に 「ふつうのとき」だけが残ります。 if を何重にも入れ子にするより、ずっと読みやすくなります。

順番を入れ替えると、動きが変わります たとえば R の判定を if self.cleared:あとに置くと、 クリアしたあとに R が効かなくなりますclearedreturn で引き返してしまうため)。
早い return は読みやすい反面、並べた順がそのまま優先順位になります。 足すときは「これは、どれより先に見るべきか」を必ず考えてください。

6-4. どこで初期化するか、3 つの段

置き場所いつ実行されるか何を置くか
__init__()起動のとき 1 回だけ stage_noall_cleared
load_stage()ステージが変わるたびR も含む) tilesboxesplayer_x/ywalk_timerpushingclearedox/oy
update() の中毎フレーム 途中で変わる値(walk_timer を増やす、など)

新しい変数を足すときは、まずこの表のどこに置くかを決めます。 迷ったら「R を押したとき、これは戻ってほしいか?」と考えてください。 戻ってほしいなら load_stage()、覚えていてほしいなら __init__() です。

7. 自分でステージを作る

ここからはおまけです。10 個の地図はもう用意してありますが、 作りたくなったときのために、考え方と道具を置いておきます。

7-1. 詰みの形は、おぼえてしまえば 4 つ

倉庫番の地図づくりでいちばん大事なのは、「荷物が二度と動かせなくなる形」を知ることです。

この 4 つに入ったら、その荷物はもう動きません ① 角(2 辺が壁) × × 右か下にしか押せず、 その × に立てない。 もう二度と動かない ② 壁ぎわ(1 辺が壁) その壁沿いにしか 動けない。同じ列に ゴールがなければ詰み ③ 2×2 のかたまり たがいが壁になり、 4 つとも動かない。 壁との組でも同じ ④ 押す側に立てない × 上へ押したいのに、 下から押す形。壁の 中には立てない いちばん確実な作り方:「解けた形」から逆に作る ① ゴールの上に荷物を置いた、クリア済みの盤面を先に描く ② 荷物を「引っぱって」散らしていく(押すのと逆向きに動かす) ③ できた形が、出題の形。引いてこられた道は、必ず押して戻せます
①③は絶対に詰み、②④は形しだいです。検算ツールは、この 4 つをぜんぶ調べています
「引っぱって作る」がうまくいく理由 押す操作をそのまま逆に再生すれば、出題の形からクリアの形へ戻れるからです。 作った時点で、解き方が 1 つ手に入っていることになります。
この考え方は、検算ツールの中でも使っています。 「ゴールから荷物を引っぱって行けるマス」をぜんぶ集めておき、 そこに入っていないマスへ荷物が入ったら、その時点で詰みと判断して調べるのをやめます

7-2. 検算ツールの使い方

作った地図が本当に解けるのかを、手で確かめるのは大変です。 リポジトリの .todo/solve.py が調べてくれます。

python .todo/solve.py            # step2/sokoban.py の地図をぜんぶ調べる
python .todo/solve.py 7          # 7 番目だけ調べて、解き方も表示する
D:\claude\learning_pyxel\step2\sokoban.py から 10 個のステージを読みました

  STAGE1   8x6   荷物2/ゴール2  ✅ 押し  2 回 / 矢印   7 回
  STAGE2   8x7   荷物2/ゴール2  ✅ 押し  6 回 / 矢印  13 回
  ...
  STAGE10  11x9  荷物4/ゴール4  ✅ 押し 20 回 / 矢印  57 回

10 個中 10 個が解けました

sokoban.py実行せずに読んでいるので、走らせてもゲームは始まりません。 地図を書き換えたら、そのまま走らせるだけです。

出てくるもの意味
✅ 押し ○ 回 / 矢印 ○ 回 解けます。押しは荷物を押した回数、矢印はそのときの矢印キーの回数。 押しが多いほど頭を使い、矢印が多いほど歩き回ります
❌ 解けません どう動かしてもゴールに全部は乗りません。作り直しです
⚠ 荷物 ○ 個 / ゴール ○ 個 数が合っていません。$O の打ち間違いです
⚠ ○ 通りまで調べても… 盤面が広すぎるか、荷物が多すぎます。少し小さくしてください
手ごたえのめやす 「押し」の回数が、だいたいの難しさです。2〜6 がやさしい/10 前後がふつう/ 18 以上は歯ごたえあり、という感じで並べました。
一方「矢印」は歩く量です。ここが 100 を超えると、 難しいというよりただ長いステージになりがちです。 10 面が「押し 20・矢印 57」と短いのは、考えどころは深いのに、手は短いという形だからです。
いまの地図で書けないこと load_stage() は、ゴールの上に荷物や主人公が最初から乗っている状態を読めません。 ふつうの倉庫番では *(ゴールの上の荷物)や +(ゴールの上の主人公)という 文字を使いますが、いまは対応していないので使わずに作ってください
もう一歩:対応させるのは簡単です。load_stage()if に 2 つ足して、mark = "O" を残すようにするだけです。

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

症状原因と対処
R を押すと 1 面に戻ってしまう self.load_stage(STAGE1) のままです。 STAGES[self.stage_no] に書き換えてください
クリアしても Enter で進まない if self.cleared: のブロックを書いていないか、 update()いちばん下に置いています。 歩きの処理よりです
Enter を 1 回押したら 2〜3 面まとめて進む btn になっています。btnp です
10 面を解いても ALL CLEAR! にならない next_stage()if<= になっていませんか。< です。 stage_no + 1 < len(STAGES)
IndexError: list index out of range 同じ原因です。STAGES[10] を読もうとしています(番号は 0〜9)
2 面に進むと、盤面が画面からはみ出す/ずれる load_stage()ox / oy の計算を消していませんか。 毎回そこで計算しているから、大きさが変わっても真ん中に来ます
IndexError が、ある面だけで出る その地図の行の長さがそろっていません。 短い行があると、tiles[y][x] でその行だけはみ出します
ある面が、どうしても解けない 写し間違いかもしれません。python .todo/solve.py で確かめてください (7-2
左上の表示が STAGE 0/10 から始まる self.stage_no + 1+ 1 を書いていません
ステージが変わっても表示が変わらない f"..."f を書き忘れていませんか。 f がないと、{} がそのまま文字として出ます
全クリアのあと Enter を押しても何も起きない self.all_cleared = False に戻していません。 True のままなので、ずっと全クリア画面です
NameError: name 'draw_center' is not defined def draw_center(...)class App:に書いています。 外(手前)です

9. 次のフェーズ

これでゲームとして遊べるものが完成しました。10 面あって、解けて、やり直せて、 全部クリアできます。

残り 5 つのフェーズは、遊びやすさと仕上げです。 次はタイトル画面とステージ選択——起動していきなり 1 面が始まるのではなく、 どの面から遊ぶかを選べるようにします。

次に出てくる考え方 画面遷移は step1 のフェーズ 7 でやりました。self.scene に 「いまどの画面か」を入れて、update()draw() で振り分けます。 今回はその復習です。
新しいのはカーソルです。ステージ選択の画面で、 どの面を指しているかを矢印キーで動かします。 stage_no をそのまま動かせばよいので、じつは もう半分できています
あわせて、step1 の宿題だった 「クリアした面だけ選べるようにするか、いつでも全部選べるようにするか」も決めます。