パズルLESSON 13箱押し × 逆向きペア原作021

逆向き倉庫の作り方

ひとつを押すと、相棒は反対へ。
離れた2つの箱を同時に調べて、全員が動けるときだけ動きを確定する、箱押しパズルを作ります。

完成版の遊び方同じ紋章の箱を押すと、相棒の箱は反対向きに動きます。2つの箱の行き先を見ながら、それぞれ同じ番号の床へ運びます。

逆向き倉庫の元の完成版。自動操作によるPyxelの実描画画面。
元の完成版の画面です。この講座では、仕組みがわかりやすいように小さくした「教材版」を、5つのステップで作ります。
PLAY / 試験公開

完成サンプルをブラウザーで遊ぶ

この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。

完成版の主な操作:矢印キーで移動・押す、Spaceで予告の表示、Zで1手戻す、Rでやり直し、Pで一時停止、Mで部屋の選択、Nで次の部屋

ボタンを押すと、ゲームの読み込みが始まります。

画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。

うまく表示されないとき

ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。

同じPyxelアプリをダウンロード(.pyxapp) / 別のタブで開く

全員の動きを確かめてから、移動を確定する。

いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、5つのステップで作っていきます。どのステップのファイルもそれだけで動くので、動きを確かめてから次へ進みましょう。

1. 盤面の座標と、画面の座標を分ける
2. 「歩く」と「押す」で処理を分ける
3. 番号のついた2つの箱と、反対向きのベクトル
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
Python 3.11以上とPyxel 2.9.9 / 変数、if、関数、for、リストと tuple の基本 / クラスの self が読めること(dataclass(frozen=True) は本文で説明します)
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
矢印:歩く・押す / R:最初に戻す / 最後の段階のZ:1手戻す / Esc:終了
難易度の目安
初級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_walk.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。

教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。

STEP 01 / 05

座標で1マス歩く

盤面の座標と、画面の座標を分ける。

元のゲームの A の部屋から、外側の壁だけを残した盤面です。左上を (0, 0) とし、右へ行くと x、下へ行くと y が増えます。人の位置は (1, 3) です。DIRECTIONS は、上・右・下・左に動くときの差分(x と y がいくつ変わるか)の組です。add で次の座標 target を作り、floor で外側や壁でないかを調べてから、State を作り直します。絵を描くときは、20 + x * 24、52 + y * 24 でピクセルに直すだけです。

下のコードは 01_walk.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

01_walk.py / 注目する部分
target = add(state.player, DIRECTIONS[action])
if not level.floor(target):
    return Result(state, False, "walk_wall")

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 01_walk.py
macOS / Linux
.venv/bin/python 01_walk.py
画面で見ること右キーを1回押すと、青い人が (1, 3) から (2, 3) へ動き、MOVES が1になります。上に2回進んだあとに上を押すと壁なので、人も MOVES も変わりません。R キーで最初に戻ります。
  • 右でxが1増える
  • 下でyが1増える
  • 壁では手数が増えない
  • R キーで (1, 3) に戻る
つまずきやすいところ画面のピクセルを State に保存すると、歩幅や当たり判定まで、絵の大きさに左右されてしまいます。State にはマス目の座標を保存します。また、壁で進めなかったときに、手数だけ増やさないようにします。
少し変えてみよう

draw の cell を24から20に変えても、歩くマスや壁の判定が変わらないことを確かめましょう。確かめたら24に戻します。

01_walk.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(204行)
01_walk.py / 全文
# MIT License
#
# Copyright (c) 2026 Pyxel 100 contributors
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

"""逆向き倉庫・教材 1/5。実行: python 01_walk.py
GUIはASCIIと図形のみ。原作の座標を段階的に学ぶ独立した1ファイルです。
"""
from dataclasses import dataclass

DIRECTIONS = {"U": (0, -1), "R": (1, 0), "D": (0, 1), "L": (-1, 0)}
BORDER = frozenset((x, y) for x in range(9) for y in range(7)
                   if x in (0, 8) or y in (0, 6))


@dataclass(frozen=True)
class Level:
    walls: frozenset
    player: tuple
    crates: tuple
    goals: tuple = ()
    width: int = 9
    height: int = 7

    def floor(self, position):
        x, y = position
        return 0 <= x < self.width and 0 <= y < self.height and position not in self.walls


@dataclass(frozen=True)
class State:
    player: tuple
    crates: tuple = ()
    moves: int = 0
    pushes: int = 0


@dataclass(frozen=True)
class Result:
    state: State
    accepted: bool
    reason: str
    destinations: tuple = ()


def add(position, direction):
    return position[0] + direction[0], position[1] + direction[1]


def initial(level):
    return State(level.player, level.crates)


LEVEL = Level(BORDER, (1, 3), ())


def transition(level, state, action):
    if action not in DIRECTIONS:
        return Result(state, False, "invalid")
    target = add(state.player, DIRECTIONS[action])
    if not level.floor(target):
        return Result(state, False, "walk_wall")
    return Result(State(target, state.crates, state.moves + 1, state.pushes), True, "walk")


class World:
    def __init__(self, level=LEVEL):
        self.level = level
        self.reset()

    def reset(self):
        self.state = initial(self.level)
        self.reason = "ready"
        self.last_result = None

    def move(self, action):
        result = transition(self.level, self.state, action)
        self.state = result.state
        self.reason = result.reason
        self.last_result = result
        return result.accepted


class App:
    def __init__(self, pyxel, headless=False, screenshot=None, replay=""):
        self.p = pyxel
        self.world = World()
        self.headless = headless
        self.screenshot = screenshot
        self.replay = replay
        self.lock = False
        self.arrows = [(pyxel.KEY_UP, "U"), (pyxel.KEY_RIGHT, "R"),
                       (pyxel.KEY_DOWN, "D"), (pyxel.KEY_LEFT, "L")]
        self.controls = [key for key, _ in self.arrows] + [pyxel.KEY_R]
        pyxel.init(256, 284, title="Reverse Crates - Stage 1", fps=30, headless=headless)
        pyxel.run(self.update, self.draw)

    def update(self):
        p = self.p
        if self.headless and p.frame_count >= 12:
            if self.screenshot:
                p.screenshot(self.screenshot, scale=2)
            p.quit()
            return
        # 自動確認用。通常の起動ではキー入力だけを使う。
        if self.headless and p.frame_count < len(self.replay):
            action = self.replay[p.frame_count]
            if action == "X":
                self.world.reset()
            else:
                self.world.move(action)
            return
        if self.lock:
            if not any(p.btn(key) for key in self.controls):
                self.lock = False
            return
        if p.btnp(p.KEY_R):
            self.world.reset()
            self.lock = True
            return
        pressed = [action for key, action in self.arrows if p.btnp(key, 12, 4)]
        held = sum(bool(p.btn(key)) for key, _ in self.arrows)
        if len(pressed) == 1 and held <= 1:
            self.world.move(pressed[0])
        elif pressed:
            self.world.reason = "invalid"

    def draw(self):
        p = self.p
        w = self.world
        p.cls(1)
        p.text(10, 8, "REVERSE CRATES / 01", 10)
        p.text(10, 20, "GRID WALK", 7)
        p.text(10, 32, f"MOVES {w.state.moves}  PUSHES {w.state.pushes}", 6)
        cell, ox, oy = 24, 20, 52
        # 盤面の数字は0始まり。文章の座標と画面を対応させる。
        for x in range(w.level.width):
            p.text(ox + x * cell + 10, 43, str(x), 13)
        for y in range(w.level.height):
            p.text(10, oy + y * cell + 10, str(y), 13)
            for x in range(w.level.width):
                px, py = ox + x * cell, oy + y * cell
                color = 5 if (x, y) in w.level.walls else 3
                p.rect(px, py, cell, cell, color)
                p.rectb(px, py, cell, cell, 1)
        for index, (x, y) in enumerate(w.state.crates):
            px, py = ox + x * cell + 4, oy + y * cell + 4
            p.rect(px, py, 16, 16, 9)
            p.rectb(px, py, 16, 16, 4)
            p.text(px + 6, py + 6, str(index + 1), 1)
        x, y = w.state.player
        p.circ(ox + x * cell + 12, oy + y * cell + 12, 7, 12)
        p.pset(ox + x * cell + 10, oy + y * cell + 10, 7)
        p.pset(ox + x * cell + 14, oy + y * cell + 10, 7)
        if w.last_result and not w.last_result.accepted:
            for x, y in w.last_result.destinations:
                p.rectb(ox + x * cell + 1, oy + y * cell + 1, 22, 22, 8)
        messages = {"ready": "BLUE: YOU / GRAY: WALL", "walk": "WALK: 1 CELL", "push": "BOTH BOXES MOVED",
                    "walk_wall": "NO MOVE: WALL", "box_wall_1": "NO MOVE: BOX 1 WALL",
                    "box_wall_2": "NO MOVE: BOX 2 WALL", "meet": "NO MOVE: SHARED DESTINATION",
                    "cross": "NO MOVE: BOXES CANNOT CROSS", "player": "NO MOVE: PLAYER CELL",
                    "invalid": "ONE ARROW AT A TIME", "complete": "CLEAR! SAME NUMBERS MATCH",
                    "undo": "UNDID ONE ACCEPTED MOVE", "empty": "NOTHING TO UNDO"}
        status = w.reason
        p.text(10, 230, messages[status], 10 if status == "complete" else 7)
        p.text(10, 244, "X: RIGHT / Y: DOWN", 6)
        p.text(10, 258, "ARROWS: WALK", 7)
        p.text(10, 272, "R: RESET  ESC: QUIT", 6)


def main(headless=False, screenshot=None, replay=""):
    import pyxel
    App(pyxel, headless, screenshot, replay)


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true", help="12 rendered frames, then quit")
    parser.add_argument("--screenshot", help="PNG path; use with --headless-smoke")
    parser.add_argument("--replay", default="", help="Synthetic actions U/R/D/L/X(reset); headless only")
    args = parser.parse_args()
    if (args.screenshot or args.replay) and not args.headless_smoke:
        parser.error("--screenshot and --replay require --headless-smoke")
    if len(args.replay) > 12 or any(action not in "URDLX" for action in args.replay):
        parser.error("--replay requires at most 12 valid action letters")
    main(args.headless_smoke, args.screenshot, args.replay)
STEP 02 / 05

箱の向こう側も調べる

「歩く」と「押す」で処理を分ける。

元のゲームの A にある箱1だけを (2, 3) に置きます。人の行き先 target に箱がなければ歩きます。箱があるときは、その箱に同じ direction(向き)を足した destination(箱の行き先)も床かどうか確かめます。通れるなら、人は箱のいた場所へ、箱はその先へ動き、MOVES と PUSHES をそれぞれ1増やします。まだ相棒の箱や目標はありません。

下のコードは 02_push.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

02_push.py / 注目する部分
destination = add(state.crates[0], direction)
if not level.floor(destination):
    return Result(state, False, "box_wall_1", (destination,))
new_state = State(target, (destination,), state.moves + 1, state.pushes + 1)

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 02_push.py
macOS / Linux
.venv/bin/python 02_push.py
画面で見ること最初に右を押すと、人が (2, 3)、箱が (3, 3) になり、MOVES 1・PUSHES 1 になります。箱を右端の手前 (7, 3) まで運ぶと、それ以上右へは押せず、人も止まります。
  • 歩くだけではPUSHESが増えない
  • 押すと、人と箱が1マスずつ動く
  • 箱の先が壁なら、全員止まる
つまずきやすいところ先に人だけを target に動かしてから箱の先の壁を調べると、進めなかったときに人と箱が重なってしまいます。新しい State を作るのは、行き先が使えるとわかったあとです。
少し変えてみよう

右を6回押してみましょう。5回目までは進めて、6回目は壁で止まります。最後の人・箱・MOVES・PUSHES の値を紙に書いて確かめましょう。

02_push.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(211行)
02_push.py / 全文
# MIT License
#
# Copyright (c) 2026 Pyxel 100 contributors
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

"""逆向き倉庫・教材 2/5。実行: python 02_push.py
GUIはASCIIと図形のみ。原作の座標を段階的に学ぶ独立した1ファイルです。
"""
from dataclasses import dataclass

DIRECTIONS = {"U": (0, -1), "R": (1, 0), "D": (0, 1), "L": (-1, 0)}
BORDER = frozenset((x, y) for x in range(9) for y in range(7)
                   if x in (0, 8) or y in (0, 6))


@dataclass(frozen=True)
class Level:
    walls: frozenset
    player: tuple
    crates: tuple
    goals: tuple = ()
    width: int = 9
    height: int = 7

    def floor(self, position):
        x, y = position
        return 0 <= x < self.width and 0 <= y < self.height and position not in self.walls


@dataclass(frozen=True)
class State:
    player: tuple
    crates: tuple = ()
    moves: int = 0
    pushes: int = 0


@dataclass(frozen=True)
class Result:
    state: State
    accepted: bool
    reason: str
    destinations: tuple = ()


def add(position, direction):
    return position[0] + direction[0], position[1] + direction[1]


def initial(level):
    return State(level.player, level.crates)


LEVEL = Level(BORDER, (1, 3), ((2, 3),))


def transition(level, state, action):
    if action not in DIRECTIONS:
        return Result(state, False, "invalid")
    direction = DIRECTIONS[action]
    target = add(state.player, direction)
    if not level.floor(target):
        return Result(state, False, "walk_wall")
    if target not in state.crates:
        return Result(State(target, state.crates, state.moves + 1, state.pushes), True, "walk")
    destination = add(state.crates[0], direction)
    if not level.floor(destination):
        return Result(state, False, "box_wall_1", (destination,))
    new_state = State(target, (destination,), state.moves + 1, state.pushes + 1)
    return Result(new_state, True, "push", (destination,))


class World:
    def __init__(self, level=LEVEL):
        self.level = level
        self.reset()

    def reset(self):
        self.state = initial(self.level)
        self.reason = "ready"
        self.last_result = None

    def move(self, action):
        result = transition(self.level, self.state, action)
        self.state = result.state
        self.reason = result.reason
        self.last_result = result
        return result.accepted


class App:
    def __init__(self, pyxel, headless=False, screenshot=None, replay=""):
        self.p = pyxel
        self.world = World()
        self.headless = headless
        self.screenshot = screenshot
        self.replay = replay
        self.lock = False
        self.arrows = [(pyxel.KEY_UP, "U"), (pyxel.KEY_RIGHT, "R"),
                       (pyxel.KEY_DOWN, "D"), (pyxel.KEY_LEFT, "L")]
        self.controls = [key for key, _ in self.arrows] + [pyxel.KEY_R]
        pyxel.init(256, 284, title="Reverse Crates - Stage 2", fps=30, headless=headless)
        pyxel.run(self.update, self.draw)

    def update(self):
        p = self.p
        if self.headless and p.frame_count >= 12:
            if self.screenshot:
                p.screenshot(self.screenshot, scale=2)
            p.quit()
            return
        # 自動確認用。通常の起動ではキー入力だけを使う。
        if self.headless and p.frame_count < len(self.replay):
            action = self.replay[p.frame_count]
            if action == "X":
                self.world.reset()
            else:
                self.world.move(action)
            return
        if self.lock:
            if not any(p.btn(key) for key in self.controls):
                self.lock = False
            return
        if p.btnp(p.KEY_R):
            self.world.reset()
            self.lock = True
            return
        pressed = [action for key, action in self.arrows if p.btnp(key, 12, 4)]
        held = sum(bool(p.btn(key)) for key, _ in self.arrows)
        if len(pressed) == 1 and held <= 1:
            self.world.move(pressed[0])
        elif pressed:
            self.world.reason = "invalid"

    def draw(self):
        p = self.p
        w = self.world
        p.cls(1)
        p.text(10, 8, "REVERSE CRATES / 02", 10)
        p.text(10, 20, "PUSH ONE BOX", 7)
        p.text(10, 32, f"MOVES {w.state.moves}  PUSHES {w.state.pushes}", 6)
        cell, ox, oy = 24, 20, 52
        # 盤面の数字は0始まり。文章の座標と画面を対応させる。
        for x in range(w.level.width):
            p.text(ox + x * cell + 10, 43, str(x), 13)
        for y in range(w.level.height):
            p.text(10, oy + y * cell + 10, str(y), 13)
            for x in range(w.level.width):
                px, py = ox + x * cell, oy + y * cell
                color = 5 if (x, y) in w.level.walls else 3
                p.rect(px, py, cell, cell, color)
                p.rectb(px, py, cell, cell, 1)
        for index, (x, y) in enumerate(w.state.crates):
            px, py = ox + x * cell + 4, oy + y * cell + 4
            p.rect(px, py, 16, 16, 9)
            p.rectb(px, py, 16, 16, 4)
            p.text(px + 6, py + 6, str(index + 1), 1)
        x, y = w.state.player
        p.circ(ox + x * cell + 12, oy + y * cell + 12, 7, 12)
        p.pset(ox + x * cell + 10, oy + y * cell + 10, 7)
        p.pset(ox + x * cell + 14, oy + y * cell + 10, 7)
        if w.last_result and not w.last_result.accepted:
            for x, y in w.last_result.destinations:
                p.rectb(ox + x * cell + 1, oy + y * cell + 1, 22, 22, 8)
        messages = {"ready": "PUSH THE BOX TO THE RIGHT", "walk": "WALK: 1 CELL", "push": "ONE BOX MOVED",
                    "walk_wall": "NO MOVE: WALL", "box_wall_1": "NO MOVE: BOX 1 WALL",
                    "box_wall_2": "NO MOVE: BOX 2 WALL", "meet": "NO MOVE: SHARED DESTINATION",
                    "cross": "NO MOVE: BOXES CANNOT CROSS", "player": "NO MOVE: PLAYER CELL",
                    "invalid": "ONE ARROW AT A TIME", "complete": "CLEAR! SAME NUMBERS MATCH",
                    "undo": "UNDID ONE ACCEPTED MOVE", "empty": "NOTHING TO UNDO"}
        status = w.reason
        p.text(10, 230, messages[status], 10 if status == "complete" else 7)
        p.text(10, 244, "ORANGE: BOX / NUMBERS: COORDINATES", 6)
        p.text(10, 258, "ARROWS: WALK + PUSH", 7)
        p.text(10, 272, "R: RESET  ESC: QUIT", 6)


def main(headless=False, screenshot=None, replay=""):
    import pyxel
    App(pyxel, headless, screenshot, replay)


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true", help="12 rendered frames, then quit")
    parser.add_argument("--screenshot", help="PNG path; use with --headless-smoke")
    parser.add_argument("--replay", default="", help="Synthetic actions U/R/D/L/X(reset); headless only")
    args = parser.parse_args()
    if (args.screenshot or args.replay) and not args.headless_smoke:
        parser.error("--screenshot and --replay require --headless-smoke")
    if len(args.replay) > 12 or any(action not in "URDLX" for action in args.replay):
        parser.error("--replay requires at most 12 valid action letters")
    main(args.headless_smoke, args.screenshot, args.replay)
STEP 03 / 05

相棒を逆向きへ動かす

番号のついた2つの箱と、反対向きのベクトル。

元のゲームの A にある箱2を (6, 3) に戻します。target にある箱の番号 pushed を調べると、もう一方の箱の番号は 1 - pushed です。押した箱には direction を、相棒の箱には、向きの x と y の符号を反対にした opposite を足します。proposed は動かしたあとの「候補」の座標で、まだ本当の state ではありません。この段階から、壁・ぶつかり・入れ替わり・人の位置を調べて、安全に確定します。次の段階で、調べる部分を関数に取り出し、元のゲームの B の壁を使ってくわしく学びます。

下のコードは 03_reverse.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

03_reverse.py / 注目する部分
proposed[pushed] = add(state.crates[pushed], direction)
opposite = (-direction[0], -direction[1])
proposed[partner] = add(state.crates[partner], opposite)

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 03_reverse.py
macOS / Linux
.venv/bin/python 03_reverse.py
画面で見ること最初に右を押すと、箱1は (3, 3)、箱2は (5, 3) へ同時に動きます。もう一度右を押すと、2つとも (4, 3) に入ろうとするので、全員止まります。目標の判定はまだありません。
  • 片方が右に動けば、相棒は左に動く
  • 箱2を直接押しても、相棒は反対向きに動く
  • 行き先が同じなら、どちらも動かない
  • 2つの箱の x 座標の合計と y 座標の合計は変わらない
つまずきやすいところ相棒の候補の位置を、先に動かした箱の位置から計算しないでください。どちらも、同じ「動く前の state」から計算します。番号は0と1なので、相棒の番号は 1 - pushed です。
少し変えてみよう

R で戻し、上→右→右→右→右→右→右→下→左 と進んで、今度は箱2を左へ押してみましょう。最初に箱1を右へ押したときと、箱の着く位置が同じになることを確かめましょう。

03_reverse.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(225行)
03_reverse.py / 全文
# MIT License
#
# Copyright (c) 2026 Pyxel 100 contributors
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

"""逆向き倉庫・教材 3/5。実行: python 03_reverse.py
GUIはASCIIと図形のみ。原作の座標を段階的に学ぶ独立した1ファイルです。
"""
from dataclasses import dataclass

DIRECTIONS = {"U": (0, -1), "R": (1, 0), "D": (0, 1), "L": (-1, 0)}
BORDER = frozenset((x, y) for x in range(9) for y in range(7)
                   if x in (0, 8) or y in (0, 6))


@dataclass(frozen=True)
class Level:
    walls: frozenset
    player: tuple
    crates: tuple
    goals: tuple = ()
    width: int = 9
    height: int = 7

    def floor(self, position):
        x, y = position
        return 0 <= x < self.width and 0 <= y < self.height and position not in self.walls


@dataclass(frozen=True)
class State:
    player: tuple
    crates: tuple = ()
    moves: int = 0
    pushes: int = 0


@dataclass(frozen=True)
class Result:
    state: State
    accepted: bool
    reason: str
    destinations: tuple = ()


def add(position, direction):
    return position[0] + direction[0], position[1] + direction[1]


def initial(level):
    return State(level.player, level.crates)


LEVEL = Level(BORDER, (1, 3), ((2, 3), (6, 3)))


def transition(level, state, action):
    if action not in DIRECTIONS:
        return Result(state, False, "invalid")
    direction = DIRECTIONS[action]
    target = add(state.player, direction)
    if not level.floor(target):
        return Result(state, False, "walk_wall")
    if target not in state.crates:
        return Result(State(target, state.crates, state.moves + 1, state.pushes), True, "walk")
    pushed = state.crates.index(target)
    partner = 1 - pushed
    proposed = list(state.crates)
    proposed[pushed] = add(state.crates[pushed], direction)
    opposite = (-direction[0], -direction[1])
    proposed[partner] = add(state.crates[partner], opposite)
    proposed = tuple(proposed)
    # 逆向きに動かす段階から、危ない候補は全員まとめて拒否する。
    for index, position in enumerate(proposed):
        if not level.floor(position):
            return Result(state, False, "box_wall_" + str(index + 1), proposed)
    if proposed[0] == proposed[1]:
        return Result(state, False, "meet", proposed)
    if proposed[0] == state.crates[1] or proposed[1] == state.crates[0]:
        return Result(state, False, "cross", proposed)
    if any(position in (state.player, target) for position in proposed):
        return Result(state, False, "player", proposed)
    new_state = State(target, proposed, state.moves + 1, state.pushes + 1)
    return Result(new_state, True, "push", proposed)


class World:
    def __init__(self, level=LEVEL):
        self.level = level
        self.reset()

    def reset(self):
        self.state = initial(self.level)
        self.reason = "ready"
        self.last_result = None

    def move(self, action):
        result = transition(self.level, self.state, action)
        self.state = result.state
        self.reason = result.reason
        self.last_result = result
        return result.accepted


class App:
    def __init__(self, pyxel, headless=False, screenshot=None, replay=""):
        self.p = pyxel
        self.world = World()
        self.headless = headless
        self.screenshot = screenshot
        self.replay = replay
        self.lock = False
        self.arrows = [(pyxel.KEY_UP, "U"), (pyxel.KEY_RIGHT, "R"),
                       (pyxel.KEY_DOWN, "D"), (pyxel.KEY_LEFT, "L")]
        self.controls = [key for key, _ in self.arrows] + [pyxel.KEY_R]
        pyxel.init(256, 284, title="Reverse Crates - Stage 3", fps=30, headless=headless)
        pyxel.run(self.update, self.draw)

    def update(self):
        p = self.p
        if self.headless and p.frame_count >= 12:
            if self.screenshot:
                p.screenshot(self.screenshot, scale=2)
            p.quit()
            return
        # 自動確認用。通常の起動ではキー入力だけを使う。
        if self.headless and p.frame_count < len(self.replay):
            action = self.replay[p.frame_count]
            if action == "X":
                self.world.reset()
            else:
                self.world.move(action)
            return
        if self.lock:
            if not any(p.btn(key) for key in self.controls):
                self.lock = False
            return
        if p.btnp(p.KEY_R):
            self.world.reset()
            self.lock = True
            return
        pressed = [action for key, action in self.arrows if p.btnp(key, 12, 4)]
        held = sum(bool(p.btn(key)) for key, _ in self.arrows)
        if len(pressed) == 1 and held <= 1:
            self.world.move(pressed[0])
        elif pressed:
            self.world.reason = "invalid"

    def draw(self):
        p = self.p
        w = self.world
        p.cls(1)
        p.text(10, 8, "REVERSE CRATES / 03", 10)
        p.text(10, 20, "OPPOSITE PAIR", 7)
        p.text(10, 32, f"MOVES {w.state.moves}  PUSHES {w.state.pushes}", 6)
        cell, ox, oy = 24, 20, 52
        # 盤面の数字は0始まり。文章の座標と画面を対応させる。
        for x in range(w.level.width):
            p.text(ox + x * cell + 10, 43, str(x), 13)
        for y in range(w.level.height):
            p.text(10, oy + y * cell + 10, str(y), 13)
            for x in range(w.level.width):
                px, py = ox + x * cell, oy + y * cell
                color = 5 if (x, y) in w.level.walls else 3
                p.rect(px, py, cell, cell, color)
                p.rectb(px, py, cell, cell, 1)
        for index, (x, y) in enumerate(w.state.crates):
            px, py = ox + x * cell + 4, oy + y * cell + 4
            p.rect(px, py, 16, 16, 9)
            p.rectb(px, py, 16, 16, 4)
            p.text(px + 6, py + 6, str(index + 1), 1)
        x, y = w.state.player
        p.circ(ox + x * cell + 12, oy + y * cell + 12, 7, 12)
        p.pset(ox + x * cell + 10, oy + y * cell + 10, 7)
        p.pset(ox + x * cell + 14, oy + y * cell + 10, 7)
        if w.last_result and not w.last_result.accepted:
            for x, y in w.last_result.destinations:
                p.rectb(ox + x * cell + 1, oy + y * cell + 1, 22, 22, 8)
        messages = {"ready": "TRY RIGHT: BOTH BOXES MOVE", "walk": "WALK: 1 CELL", "push": "BOTH BOXES MOVED",
                    "walk_wall": "NO MOVE: WALL", "box_wall_1": "NO MOVE: BOX 1 WALL",
                    "box_wall_2": "NO MOVE: BOX 2 WALL", "meet": "NO MOVE: SHARED DESTINATION",
                    "cross": "NO MOVE: BOXES CANNOT CROSS", "player": "NO MOVE: PLAYER CELL",
                    "invalid": "ONE ARROW AT A TIME", "complete": "CLEAR! SAME NUMBERS MATCH",
                    "undo": "UNDID ONE ACCEPTED MOVE", "empty": "NOTHING TO UNDO"}
        status = w.reason
        p.text(10, 230, messages[status], 10 if status == "complete" else 7)
        p.text(10, 244, "PUSH +D / PARTNER -D", 6)
        p.text(10, 258, "ARROWS: WALK + PUSH", 7)
        p.text(10, 272, "R: RESET  ESC: QUIT", 6)


def main(headless=False, screenshot=None, replay=""):
    import pyxel
    App(pyxel, headless, screenshot, replay)


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true", help="12 rendered frames, then quit")
    parser.add_argument("--screenshot", help="PNG path; use with --headless-smoke")
    parser.add_argument("--replay", default="", help="Synthetic actions U/R/D/L/X(reset); headless only")
    args = parser.parse_args()
    if (args.screenshot or args.replay) and not args.headless_smoke:
        parser.error("--screenshot and --replay require --headless-smoke")
    if len(args.replay) > 12 or any(action not in "URDLX" for action in args.replay):
        parser.error("--replay requires at most 12 valid action letters")
    main(args.headless_smoke, args.screenshot, args.replay)
STEP 04 / 05

全員の行先を調べてから確定

「候補を作る」「調べる」「確定する」を分ける。

元のゲームの B にある壁 (5, 3) を追加し、調べる処理を reject_reason(動けない理由)という関数に取り出します。最初に右を押したときの候補は、人 (2, 3)、箱1 (3, 3)、箱2 (5, 3) です。箱1は進めても、箱2の行き先は壁なので、動く前の State をそのまま返します。「壁」「同じ行き先」「相手の元の位置」「人の元の位置や新しい位置」の順に調べ、すべて通ったときだけ新しい State を作ります。元のゲームの「動けないときは全員そのまま」を守るための仕組みです。

下のコードは 04_validate.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

04_validate.py / 注目する部分
reason = reject_reason(level, state, target, proposed)
if reason is not None:
    return Result(state, False, reason, proposed)
new_state = State(target, proposed, state.moves + 1, state.pushes + 1)

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 04_validate.py
macOS / Linux
.venv/bin/python 04_validate.py
画面で見ること最初に右を押すと、NO MOVE: BOX 2 WALL(箱2が壁で動けない)と出て、人 (1, 3)・箱1 (2, 3)・箱2 (6, 3)・MOVES 0 のままです。下→右→上と押すと、箱1が (2, 2)、箱2が (6, 4) に動き、MOVES 3・PUSHES 1 になります。
  • 相棒の行き先が壁なら、押した箱も止まる
  • 動けなかったときは手数が増えない
  • 候補は、動く前の状態から作る
  • 成功したときだけ、新しい State に変わる
つまずきやすいところ箱1を確定してから箱2を調べると、相棒だけ止まって片方だけ動いてしまいます。proposed を計算している間は、state を書き換えません。動けない理由を表示するときも、位置や手数は変えません。
少し変えてみよう

一時的に LEVEL の壁 (5, 3) を (3, 3) に変えてみましょう。最初に右を押すと、今度は BOX 1 WALL になり、同じように全員止まることを確かめたら、元に戻しましょう。

04_validate.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(232行)
04_validate.py / 全文
# MIT License
#
# Copyright (c) 2026 Pyxel 100 contributors
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

"""逆向き倉庫・教材 4/5。実行: python 04_validate.py
GUIはASCIIと図形のみ。原作の座標を段階的に学ぶ独立した1ファイルです。
"""
from dataclasses import dataclass

DIRECTIONS = {"U": (0, -1), "R": (1, 0), "D": (0, 1), "L": (-1, 0)}
BORDER = frozenset((x, y) for x in range(9) for y in range(7)
                   if x in (0, 8) or y in (0, 6))


@dataclass(frozen=True)
class Level:
    walls: frozenset
    player: tuple
    crates: tuple
    goals: tuple = ()
    width: int = 9
    height: int = 7

    def floor(self, position):
        x, y = position
        return 0 <= x < self.width and 0 <= y < self.height and position not in self.walls


@dataclass(frozen=True)
class State:
    player: tuple
    crates: tuple = ()
    moves: int = 0
    pushes: int = 0


@dataclass(frozen=True)
class Result:
    state: State
    accepted: bool
    reason: str
    destinations: tuple = ()


def add(position, direction):
    return position[0] + direction[0], position[1] + direction[1]


def initial(level):
    return State(level.player, level.crates)


LEVEL = Level(BORDER | {(5, 3)}, (1, 3), ((2, 3), (6, 3)))


def reject_reason(level, state, target, proposed):
    # まだ旧状態のまま、候補だけを検査する。
    for index, position in enumerate(proposed):
        if not level.floor(position):
            return "box_wall_" + str(index + 1)
    if proposed[0] == proposed[1]:
        return "meet"
    if proposed[0] == state.crates[1] or proposed[1] == state.crates[0]:
        return "cross"
    if any(position in (state.player, target) for position in proposed):
        return "player"
    return None


def transition(level, state, action):
    if action not in DIRECTIONS:
        return Result(state, False, "invalid")
    direction = DIRECTIONS[action]
    target = add(state.player, direction)
    if not level.floor(target):
        return Result(state, False, "walk_wall")
    if target not in state.crates:
        return Result(State(target, state.crates, state.moves + 1, state.pushes), True, "walk")
    pushed = state.crates.index(target)
    partner = 1 - pushed
    proposed = list(state.crates)
    proposed[pushed] = add(state.crates[pushed], direction)
    opposite = (-direction[0], -direction[1])
    proposed[partner] = add(state.crates[partner], opposite)
    proposed = tuple(proposed)
    reason = reject_reason(level, state, target, proposed)
    if reason is not None:
        return Result(state, False, reason, proposed)
    new_state = State(target, proposed, state.moves + 1, state.pushes + 1)
    return Result(new_state, True, "push", proposed)


class World:
    def __init__(self, level=LEVEL):
        self.level = level
        self.reset()

    def reset(self):
        self.state = initial(self.level)
        self.reason = "ready"
        self.last_result = None

    def move(self, action):
        result = transition(self.level, self.state, action)
        self.state = result.state
        self.reason = result.reason
        self.last_result = result
        return result.accepted


class App:
    def __init__(self, pyxel, headless=False, screenshot=None, replay=""):
        self.p = pyxel
        self.world = World()
        self.headless = headless
        self.screenshot = screenshot
        self.replay = replay
        self.lock = False
        self.arrows = [(pyxel.KEY_UP, "U"), (pyxel.KEY_RIGHT, "R"),
                       (pyxel.KEY_DOWN, "D"), (pyxel.KEY_LEFT, "L")]
        self.controls = [key for key, _ in self.arrows] + [pyxel.KEY_R]
        pyxel.init(256, 284, title="Reverse Crates - Stage 4", fps=30, headless=headless)
        pyxel.run(self.update, self.draw)

    def update(self):
        p = self.p
        if self.headless and p.frame_count >= 12:
            if self.screenshot:
                p.screenshot(self.screenshot, scale=2)
            p.quit()
            return
        # 自動確認用。通常の起動ではキー入力だけを使う。
        if self.headless and p.frame_count < len(self.replay):
            action = self.replay[p.frame_count]
            if action == "X":
                self.world.reset()
            else:
                self.world.move(action)
            return
        if self.lock:
            if not any(p.btn(key) for key in self.controls):
                self.lock = False
            return
        if p.btnp(p.KEY_R):
            self.world.reset()
            self.lock = True
            return
        pressed = [action for key, action in self.arrows if p.btnp(key, 12, 4)]
        held = sum(bool(p.btn(key)) for key, _ in self.arrows)
        if len(pressed) == 1 and held <= 1:
            self.world.move(pressed[0])
        elif pressed:
            self.world.reason = "invalid"

    def draw(self):
        p = self.p
        w = self.world
        p.cls(1)
        p.text(10, 8, "REVERSE CRATES / 04", 10)
        p.text(10, 20, "CHECK EVERY DESTINATION", 7)
        p.text(10, 32, f"MOVES {w.state.moves}  PUSHES {w.state.pushes}", 6)
        cell, ox, oy = 24, 20, 52
        # 盤面の数字は0始まり。文章の座標と画面を対応させる。
        for x in range(w.level.width):
            p.text(ox + x * cell + 10, 43, str(x), 13)
        for y in range(w.level.height):
            p.text(10, oy + y * cell + 10, str(y), 13)
            for x in range(w.level.width):
                px, py = ox + x * cell, oy + y * cell
                color = 5 if (x, y) in w.level.walls else 3
                p.rect(px, py, cell, cell, color)
                p.rectb(px, py, cell, cell, 1)
        for index, (x, y) in enumerate(w.state.crates):
            px, py = ox + x * cell + 4, oy + y * cell + 4
            p.rect(px, py, 16, 16, 9)
            p.rectb(px, py, 16, 16, 4)
            p.text(px + 6, py + 6, str(index + 1), 1)
        x, y = w.state.player
        p.circ(ox + x * cell + 12, oy + y * cell + 12, 7, 12)
        p.pset(ox + x * cell + 10, oy + y * cell + 10, 7)
        p.pset(ox + x * cell + 14, oy + y * cell + 10, 7)
        if w.last_result and not w.last_result.accepted:
            for x, y in w.last_result.destinations:
                p.rectb(ox + x * cell + 1, oy + y * cell + 1, 22, 22, 8)
        messages = {"ready": "TRY RIGHT: PARTNER HAS A WALL", "walk": "WALK: 1 CELL", "push": "BOTH BOXES MOVED",
                    "walk_wall": "NO MOVE: WALL", "box_wall_1": "NO MOVE: BOX 1 WALL",
                    "box_wall_2": "NO MOVE: BOX 2 WALL", "meet": "NO MOVE: SHARED DESTINATION",
                    "cross": "NO MOVE: BOXES CANNOT CROSS", "player": "NO MOVE: PLAYER CELL",
                    "invalid": "ONE ARROW AT A TIME", "complete": "CLEAR! SAME NUMBERS MATCH",
                    "undo": "UNDID ONE ACCEPTED MOVE", "empty": "NOTHING TO UNDO"}
        status = w.reason
        p.text(10, 230, messages[status], 10 if status == "complete" else 7)
        p.text(10, 244, "RED: REJECTED DESTINATIONS", 6)
        p.text(10, 258, "ARROWS: WALK + PUSH", 7)
        p.text(10, 272, "R: RESET  ESC: QUIT", 6)


def main(headless=False, screenshot=None, replay=""):
    import pyxel
    App(pyxel, headless, screenshot, replay)


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true", help="12 rendered frames, then quit")
    parser.add_argument("--screenshot", help="PNG path; use with --headless-smoke")
    parser.add_argument("--replay", default="", help="Synthetic actions U/R/D/L/X(reset); headless only")
    args = parser.parse_args()
    if (args.screenshot or args.replay) and not args.headless_smoke:
        parser.error("--screenshot and --replay require --headless-smoke")
    if len(args.replay) > 12 or any(action not in "URDLX" for action in args.replay):
        parser.error("--replay requires at most 12 valid action letters")
    main(args.headless_smoke, args.screenshot, args.replay)
STEP 05 / 05

同じ番号の目標へ運び、一手戻す

番号を区別した成功の判定と、確定する前の履歴。

元のゲームの B の目標として、箱1は (3, 2)、箱2は (5, 4) を追加します。crates と goals は同じ順番の組なので、そのまま比べると、番号まで一致している必要があります。World.move は、受け付けた移動のときだけ、変える前の State を history に保存します。Z キーは最後の State を取り出して元に戻すので、人・2つの箱・手数・押した回数がまとめて戻ります。成功したあとは方向キーを受け付けませんが、Z と R は使えます。

下のコードは 05_goals_undo.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

05_goals_undo.py / 注目する部分
if result.accepted:
    self.history.append(self.state)
self.state = result.state

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 05_goals_undo.py
macOS / Linux
.venv/bin/python 05_goals_undo.py
画面で見ること下→右→上→左→上→右 の6手で CLEAR! になり、MOVES 6・PUSHES 2・UNDO 6 です。Z で成功の1手前に戻り、右でもう一度成功します。最初の右のように動けない入力をしても、手数と履歴は増えません。
  • 同じ番号どうしがそろったときだけ成功
  • 動けなかった入力では、手数も履歴も増えない
  • Zで、歩いた分も押した分も戻る
  • 成功したあとも Z と R が使える
つまずきやすいところset(state.crates) == set(level.goals) と書くと、番号が入れ替わっていても成功になってしまいます。また、動けなかった入力まで history に積むと、Z を押しても見た目が変わらない、むだな履歴ができてしまいます。
少し変えてみよう

成功の直前で Z を押して何手か戻り、別の方向へ歩いてみましょう。新しく受け付けた動きだけが履歴に増えることを確かめます。続けて R を押し、手数・押した回数・履歴がすべて0になることも確かめましょう。

05_goals_undo.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(263行)
05_goals_undo.py / 全文
# MIT License
#
# Copyright (c) 2026 Pyxel 100 contributors
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

"""逆向き倉庫・教材 5/5。実行: python 05_goals_undo.py
GUIはASCIIと図形のみ。原作の座標を段階的に学ぶ独立した1ファイルです。
"""
from dataclasses import dataclass

DIRECTIONS = {"U": (0, -1), "R": (1, 0), "D": (0, 1), "L": (-1, 0)}
BORDER = frozenset((x, y) for x in range(9) for y in range(7)
                   if x in (0, 8) or y in (0, 6))


@dataclass(frozen=True)
class Level:
    walls: frozenset
    player: tuple
    crates: tuple
    goals: tuple = ()
    width: int = 9
    height: int = 7

    def floor(self, position):
        x, y = position
        return 0 <= x < self.width and 0 <= y < self.height and position not in self.walls


@dataclass(frozen=True)
class State:
    player: tuple
    crates: tuple = ()
    moves: int = 0
    pushes: int = 0


@dataclass(frozen=True)
class Result:
    state: State
    accepted: bool
    reason: str
    destinations: tuple = ()


def add(position, direction):
    return position[0] + direction[0], position[1] + direction[1]


def initial(level):
    return State(level.player, level.crates)


LEVEL = Level(BORDER | {(5, 3)}, (1, 3), ((2, 3), (6, 3)), ((3, 2), (5, 4)))


def won(level, state):
    # 箱0は目標0、箱1は目標1。番号を入れ替えると成功ではない。
    return state.crates == level.goals


def reject_reason(level, state, target, proposed):
    # まだ旧状態のまま、候補だけを検査する。
    for index, position in enumerate(proposed):
        if not level.floor(position):
            return "box_wall_" + str(index + 1)
    if proposed[0] == proposed[1]:
        return "meet"
    if proposed[0] == state.crates[1] or proposed[1] == state.crates[0]:
        return "cross"
    if any(position in (state.player, target) for position in proposed):
        return "player"
    return None


def transition(level, state, action, terminal=True):
    if action not in DIRECTIONS:
        return Result(state, False, "invalid")
    if terminal and won(level, state):
        return Result(state, False, "complete")
    direction = DIRECTIONS[action]
    target = add(state.player, direction)
    if not level.floor(target):
        return Result(state, False, "walk_wall")
    if target not in state.crates:
        return Result(State(target, state.crates, state.moves + 1, state.pushes), True, "walk")
    pushed = state.crates.index(target)
    partner = 1 - pushed
    proposed = list(state.crates)
    proposed[pushed] = add(state.crates[pushed], direction)
    opposite = (-direction[0], -direction[1])
    proposed[partner] = add(state.crates[partner], opposite)
    proposed = tuple(proposed)
    reason = reject_reason(level, state, target, proposed)
    if reason is not None:
        return Result(state, False, reason, proposed)
    new_state = State(target, proposed, state.moves + 1, state.pushes + 1)
    return Result(new_state, True, "push", proposed)


class World:
    def __init__(self, level=LEVEL):
        self.level = level
        self.reset()

    def reset(self):
        self.state = initial(self.level)
        self.reason = "ready"
        self.last_result = None
        self.history = []

    def move(self, action):
        result = transition(self.level, self.state, action)
        if result.accepted:
            self.history.append(self.state)
        self.state = result.state
        self.reason = result.reason
        self.last_result = result
        return result.accepted

    def undo(self):
        if not self.history:
            self.reason = "empty"
            self.last_result = None
            return False
        self.state = self.history.pop()
        self.reason = "undo"
        self.last_result = None
        return True


class App:
    def __init__(self, pyxel, headless=False, screenshot=None, replay=""):
        self.p = pyxel
        self.world = World()
        self.headless = headless
        self.screenshot = screenshot
        self.replay = replay
        self.lock = False
        self.arrows = [(pyxel.KEY_UP, "U"), (pyxel.KEY_RIGHT, "R"),
                       (pyxel.KEY_DOWN, "D"), (pyxel.KEY_LEFT, "L")]
        self.controls = [key for key, _ in self.arrows] + [pyxel.KEY_R, pyxel.KEY_Z]
        pyxel.init(256, 284, title="Reverse Crates - Stage 5", fps=30, headless=headless)
        pyxel.run(self.update, self.draw)

    def update(self):
        p = self.p
        if self.headless and p.frame_count >= 12:
            if self.screenshot:
                p.screenshot(self.screenshot, scale=2)
            p.quit()
            return
        # 自動確認用。通常の起動ではキー入力だけを使う。
        if self.headless and p.frame_count < len(self.replay):
            action = self.replay[p.frame_count]
            if action == "X":
                self.world.reset()
            elif action == "Z":
                self.world.undo()
            else:
                self.world.move(action)
            return
        if self.lock:
            if not any(p.btn(key) for key in self.controls):
                self.lock = False
            return
        if p.btnp(p.KEY_R):
            self.world.reset()
            self.lock = True
            return
        if p.btnp(p.KEY_Z):
            self.world.undo()
            return
        pressed = [action for key, action in self.arrows if p.btnp(key, 12, 4)]
        held = sum(bool(p.btn(key)) for key, _ in self.arrows)
        if len(pressed) == 1 and held <= 1:
            self.world.move(pressed[0])
        elif pressed:
            self.world.reason = "invalid"

    def draw(self):
        p = self.p
        w = self.world
        p.cls(1)
        p.text(10, 8, "REVERSE CRATES / 05", 10)
        p.text(10, 20, "NUMBERED GOALS + UNDO", 7)
        p.text(10, 32, f"MOVES {w.state.moves}  PUSHES {w.state.pushes}  UNDO {len(w.history)}", 6)
        cell, ox, oy = 24, 20, 52
        # 盤面の数字は0始まり。文章の座標と画面を対応させる。
        for x in range(w.level.width):
            p.text(ox + x * cell + 10, 43, str(x), 13)
        for y in range(w.level.height):
            p.text(10, oy + y * cell + 10, str(y), 13)
            for x in range(w.level.width):
                px, py = ox + x * cell, oy + y * cell
                color = 5 if (x, y) in w.level.walls else 3
                p.rect(px, py, cell, cell, color)
                p.rectb(px, py, cell, cell, 1)
        for index, (x, y) in enumerate(w.level.goals):
            px, py = ox + x * cell, oy + y * cell
            p.rectb(px + 2, py + 2, 20, 20, 10)
            p.text(px + 10, py + 10, str(index + 1), 10)
        for index, (x, y) in enumerate(w.state.crates):
            px, py = ox + x * cell + 4, oy + y * cell + 4
            p.rect(px, py, 16, 16, 9)
            p.rectb(px, py, 16, 16, 4)
            p.text(px + 6, py + 6, str(index + 1), 1)
            if w.state.crates[index] == w.level.goals[index]:
                p.rectb(px - 3, py - 3, 22, 22, 11)
        x, y = w.state.player
        p.circ(ox + x * cell + 12, oy + y * cell + 12, 7, 12)
        p.pset(ox + x * cell + 10, oy + y * cell + 10, 7)
        p.pset(ox + x * cell + 14, oy + y * cell + 10, 7)
        if w.last_result and not w.last_result.accepted:
            for x, y in w.last_result.destinations:
                p.rectb(ox + x * cell + 1, oy + y * cell + 1, 22, 22, 8)
        messages = {"ready": "MATCH BOX 1 TO GOAL 1, 2 TO 2", "walk": "WALK: 1 CELL", "push": "BOTH BOXES MOVED",
                    "walk_wall": "NO MOVE: WALL", "box_wall_1": "NO MOVE: BOX 1 WALL",
                    "box_wall_2": "NO MOVE: BOX 2 WALL", "meet": "NO MOVE: SHARED DESTINATION",
                    "cross": "NO MOVE: BOXES CANNOT CROSS", "player": "NO MOVE: PLAYER CELL",
                    "invalid": "ONE ARROW AT A TIME", "complete": "CLEAR! SAME NUMBERS MATCH",
                    "undo": "UNDID ONE ACCEPTED MOVE", "empty": "NOTHING TO UNDO"}
        status = "complete" if won(w.level, w.state) else w.reason
        p.text(10, 230, messages[status], 10 if status == "complete" else 7)
        p.text(10, 244, "YELLOW: GOALS / GREEN: MATCH", 6)
        p.text(10, 258, "ARROWS: WALK + PUSH", 7)
        p.text(10, 272, "Z: UNDO  R: RESET  ESC: QUIT", 6)


def main(headless=False, screenshot=None, replay=""):
    import pyxel
    App(pyxel, headless, screenshot, replay)


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true", help="12 rendered frames, then quit")
    parser.add_argument("--screenshot", help="PNG path; use with --headless-smoke")
    parser.add_argument("--replay", default="", help="Synthetic actions U/R/D/L/Z(undo)/X(reset); headless only")
    args = parser.parse_args()
    if (args.screenshot or args.replay) and not args.headless_smoke:
        parser.error("--screenshot and --replay require --headless-smoke")
    if len(args.replay) > 12 or any(action not in "URDLXZ" for action in args.replay):
        parser.error("--replay requires at most 12 valid action letters")
    main(args.headless_smoke, args.screenshot, args.replay)
AFTER STEP 05

仕組みがわかったら、元の完成版を読んでみよう。

元のゲームには3つの部屋があります。人が箱1か箱2を押すと、相棒は同じ距離だけ反対向きに動きます。「壁」「同じ行き先」「箱どうしが相手の元の位置に入る」「人の元の位置や新しい位置に入る」のどれかに当てはまると、その1手全体が取り消され、手数と履歴は増えません。箱1と箱2をそれぞれ同じ番号の目標へ運び、Z で歩いた分も押した分も戻せます。

ここまでに作ったのは、元のゲームの仕組みだけを取り出して作り直した「簡略版」です。元のゲームのソースは手を加えずに、別のZIP(元の完成版ソース)に入れてあります。

比べる点5ステップの教材元の完成版
部屋01〜03は元のゲームのAの外壁と座標を順番に導入。04〜05は元のゲームのBの1部屋。最後の版はBの目標もそのまま使うA・B・Cの3部屋と、部屋の選択・次の部屋への移動
押せるかどうかの判定03から元のゲームと同じ「2つの箱そろって動けないと取り消し」のルール。04で調べる処理を独立した関数に整理。05で元のゲームのBの成功で止まる仕組みと履歴を再現変更できないState・Result・Timelineを使い、3部屋で同じ調べ方を共有
表示と操作英字(ASCII)・図形・音なし。座標のラベル、動けなかった候補の赤い枠。30fps。矢印の長押しは、12フレーム後から4フレームごとにくり返す日本語の文字、荷物を上げる機械の絵、押す前の予告の切り替え、説明・一時停止、部屋の選択、記録機能
履歴05の World が、動く前の State のリストを持ち、受け付けた動きのときだけ追加するTimeline.past の変更できない tuple を、apply と undo で更新する

完成版のコードで、次に読むところ

  1. 元のゲームの model.py の transition で、候補の dest(行き先)と動けない理由、成功したときだけ作られる State を読みましょう。
  2. 元のゲームの model.py の apply と undo で、教材の history のリストを、変更できない Timeline に置きかえた作りを比べましょう。
  3. 元のゲームの model.py の validate_level で、2つの箱の座標の合計と、目標の座標の合計を比べている理由を考えてみましょう。
  4. 元のゲームの solver.py の explore で、手数を除いた状態を使って、たどり着けるすべての状態を探す方法を読みましょう。
  5. 元のゲームの app.py の cue と draw で、確定する前の候補を見せる「予告」への発展を見てみましょう。
自分のゲームに変えるなら

最初は、数字かルールを1つだけ変えてみましょう。遊ぶ前に「どう変わるか」を予想しておくと、仕組みがよくわかります。慣れてきたら、見た目や音も足してみてください。元のファイルは残しておき、コピーした別のフォルダーで改造すると、いつでも元に戻せます。

教材をダウンロードする

無料で、登録なしでダウンロードできます。ZIPファイルは必ず展開してから、中のファイルを実行してください。元の完成版は、同じZIPに入っている assets フォルダー(画像などの素材)も一緒に使います。

コードと図形はMITライセンス、元の完成版に含まれる日本語の文字データ(Noto由来)はSIL OFL 1.1です。配ったり改造したりするときは、ZIPに入っているLICENSEなどの権利表示を残してください。出典・権利について

検証について

どこまで動作を確認したかを見る

2026年10月5日以降、Linux(Python 3.12・Pyxel 2.9.9)で、教材のコードとゲームのルールを自動テストしています。全500ステップの教材が、実際のPyxelで起動して画面を描けることも確かめました。元の完成版も、自動で遊ぶデモが起動して画面を描き、正常に終わることを確認しています。

この確認は、画面を表示しない自動テストで行ったものです。Windows・Macの実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。

うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る