完成サンプルをブラウザーで遊ぶ
この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。
完成版の主な操作:矢印キーで移動・押す、Spaceで予告の表示、Zで1手戻す、Rでやり直し、Pで一時停止、Mで部屋の選択、Nで次の部屋
ボタンを押すと、ゲームの読み込みが始まります。
画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。
うまく表示されないとき
ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。
全員の動きを確かめてから、移動を確定する。
いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、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:終了
- 難易度の目安
- 初級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
- 学習順に迷ったら
- おすすめの学ぶ順番を見る
コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。
教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。
座標で1マス歩く
盤面の座標と、画面の座標を分ける。
元のゲームの A の部屋から、外側の壁だけを残した盤面です。左上を (0, 0) とし、右へ行くと x、下へ行くと y が増えます。人の位置は (1, 3) です。DIRECTIONS は、上・右・下・左に動くときの差分(x と y がいくつ変わるか)の組です。add で次の座標 target を作り、floor で外側や壁でないかを調べてから、State を作り直します。絵を描くときは、20 + x * 24、52 + y * 24 でピクセルに直すだけです。
下のコードは 01_walk.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
target = add(state.player, DIRECTIONS[action])
if not level.floor(target):
return Result(state, False, "walk_wall")実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 01_walk.py.venv/bin/python 01_walk.py- 右でxが1増える
- 下でyが1増える
- 壁では手数が増えない
- R キーで (1, 3) に戻る
State に保存すると、歩幅や当たり判定まで、絵の大きさに左右されてしまいます。State にはマス目の座標を保存します。また、壁で進めなかったときに、手数だけ増やさないようにします。draw の cell を24から20に変えても、歩くマスや壁の判定が変わらないことを確かめましょう。確かめたら24に戻します。
このステップのコード全文(204行)
# 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)箱の向こう側も調べる
「歩く」と「押す」で処理を分ける。
元のゲームの A にある箱1だけを (2, 3) に置きます。人の行き先 target に箱がなければ歩きます。箱があるときは、その箱に同じ direction(向き)を足した destination(箱の行き先)も床かどうか確かめます。通れるなら、人は箱のいた場所へ、箱はその先へ動き、MOVES と PUSHES をそれぞれ1増やします。まだ相棒の箱や目標はありません。
下のコードは 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を展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 02_push.py.venv/bin/python 02_push.py- 歩くだけではPUSHESが増えない
- 押すと、人と箱が1マスずつ動く
- 箱の先が壁なら、全員止まる
target に動かしてから箱の先の壁を調べると、進めなかったときに人と箱が重なってしまいます。新しい State を作るのは、行き先が使えるとわかったあとです。右を6回押してみましょう。5回目までは進めて、6回目は壁で止まります。最後の人・箱・MOVES・PUSHES の値を紙に書いて確かめましょう。
このステップのコード全文(211行)
# 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)相棒を逆向きへ動かす
番号のついた2つの箱と、反対向きのベクトル。
元のゲームの A にある箱2を (6, 3) に戻します。target にある箱の番号 pushed を調べると、もう一方の箱の番号は 1 - pushed です。押した箱には direction を、相棒の箱には、向きの x と y の符号を反対にした opposite を足します。proposed は動かしたあとの「候補」の座標で、まだ本当の state ではありません。この段階から、壁・ぶつかり・入れ替わり・人の位置を調べて、安全に確定します。次の段階で、調べる部分を関数に取り出し、元のゲームの B の壁を使ってくわしく学びます。
下のコードは 03_reverse.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
proposed[pushed] = add(state.crates[pushed], direction)
opposite = (-direction[0], -direction[1])
proposed[partner] = add(state.crates[partner], opposite)実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 03_reverse.py.venv/bin/python 03_reverse.py- 片方が右に動けば、相棒は左に動く
- 箱2を直接押しても、相棒は反対向きに動く
- 行き先が同じなら、どちらも動かない
- 2つの箱の x 座標の合計と y 座標の合計は変わらない
1 - pushed です。R で戻し、上→右→右→右→右→右→右→下→左 と進んで、今度は箱2を左へ押してみましょう。最初に箱1を右へ押したときと、箱の着く位置が同じになることを確かめましょう。
このステップのコード全文(225行)
# 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)全員の行先を調べてから確定
「候補を作る」「調べる」「確定する」を分ける。
元のゲームの B にある壁 (5, 3) を追加し、調べる処理を reject_reason(動けない理由)という関数に取り出します。最初に右を押したときの候補は、人 (2, 3)、箱1 (3, 3)、箱2 (5, 3) です。箱1は進めても、箱2の行き先は壁なので、動く前の State をそのまま返します。「壁」「同じ行き先」「相手の元の位置」「人の元の位置や新しい位置」の順に調べ、すべて通ったときだけ新しい State を作ります。元のゲームの「動けないときは全員そのまま」を守るための仕組みです。
下のコードは 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を展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 04_validate.py.venv/bin/python 04_validate.py- 相棒の行き先が壁なら、押した箱も止まる
- 動けなかったときは手数が増えない
- 候補は、動く前の状態から作る
- 成功したときだけ、新しい State に変わる
proposed を計算している間は、state を書き換えません。動けない理由を表示するときも、位置や手数は変えません。一時的に LEVEL の壁 (5, 3) を (3, 3) に変えてみましょう。最初に右を押すと、今度は BOX 1 WALL になり、同じように全員止まることを確かめたら、元に戻しましょう。
このステップのコード全文(232行)
# 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)同じ番号の目標へ運び、一手戻す
番号を区別した成功の判定と、確定する前の履歴。
元のゲームの B の目標として、箱1は (3, 2)、箱2は (5, 4) を追加します。crates と goals は同じ順番の組なので、そのまま比べると、番号まで一致している必要があります。World.move は、受け付けた移動のときだけ、変える前の State を history に保存します。Z キーは最後の State を取り出して元に戻すので、人・2つの箱・手数・押した回数がまとめて戻ります。成功したあとは方向キーを受け付けませんが、Z と R は使えます。
下のコードは 05_goals_undo.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if result.accepted:
self.history.append(self.state)
self.state = result.state実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 05_goals_undo.py.venv/bin/python 05_goals_undo.py- 同じ番号どうしがそろったときだけ成功
- 動けなかった入力では、手数も履歴も増えない
- Zで、歩いた分も押した分も戻る
- 成功したあとも Z と R が使える
set(state.crates) == set(level.goals) と書くと、番号が入れ替わっていても成功になってしまいます。また、動けなかった入力まで history に積むと、Z を押しても見た目が変わらない、むだな履歴ができてしまいます。成功の直前で Z を押して何手か戻り、別の方向へ歩いてみましょう。新しく受け付けた動きだけが履歴に増えることを確かめます。続けて R を押し、手数・押した回数・履歴がすべて0になることも確かめましょう。
このステップのコード全文(263行)
# 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)仕組みがわかったら、元の完成版を読んでみよう。
元のゲームには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 で更新する |
完成版のコードで、次に読むところ
- 元のゲームの
model.pyのtransitionで、候補のdest(行き先)と動けない理由、成功したときだけ作られるStateを読みましょう。 - 元のゲームの
model.pyのapplyとundoで、教材のhistoryのリストを、変更できないTimelineに置きかえた作りを比べましょう。 - 元のゲームの
model.pyのvalidate_levelで、2つの箱の座標の合計と、目標の座標の合計を比べている理由を考えてみましょう。 - 元のゲームの
solver.pyのexploreで、手数を除いた状態を使って、たどり着けるすべての状態を探す方法を読みましょう。 - 元のゲームの
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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。
うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る
