完成サンプルをブラウザーで遊ぶ
この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。
完成版の主な操作:矢印一歩、Z戻す、R再開、P停止、M面選択、N次面(待機操作なし)
ボタンを押すと、ゲームの読み込みが始まります。
画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。
うまく表示されないとき
ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。
これから起きる地形の変化を、「判断した回数」で予約する。
いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、5つのステップで作っていきます。どのステップのファイルもそれだけで動くので、動きを確かめてから次へ進みましょう。
- 必要な環境
- Python 3.11以上 / Pyxel 2.9.9
- 知っておくとよいこと
- Python の変数・if・for・関数 / tuple・dict・set の基本 / dataclass・frozen・ビットの印は本文で説明します
- 教材に入っているもの
- ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
- 完成した教材の操作
- 矢印:一押し一歩。全操作キーを離して次を押す / N:04/05で次の面へ。履歴も初期化 / Z:05でUndo。成功・失敗からも戻れる / R:同じ面を初期化 / Space:操作なし。待機は存在しない / Esc:終了
- 難易度の目安
- 初級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
- 学習順に迷ったら
- おすすめの学ぶ順番を見る
コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。
教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。
床がある場所へ一歩だけ進む
座標・集合・状態の更新と、描く処理を分ける。
最初は壁が増えず、灯りもありません。青い人を矢印キーで動かし、S から E へ行きましょう。座標は左上が (0,0) で、右へ行くほど x、下へ行くほど y が大きくなります。DIRECTIONS の R=(1,0) を今の位置に足すと、右どなりの座標が求められます。キーボードの R キーはリセットなので、プログラムの中の方向の文字 R とは区別します。
Level は面の変わらない情報、State は動くたびに変わる情報です。sprouts はあとで育つ床の座標の集合で、今はふつうに通れます。floors は sprouts に S と E を加えた集合です。青灰色の動かない壁は、この集合に入れません。target not in level.floors(行き先が床ではない)なら、動く前の state をそのまま返します。
dataclass は関係のある値を1つにまとめるもので、frozen=True は「作った状態を、あとから書きかえない」という約束です。transition は新しい State と「受け付けたか」を返す関数、App.control はキー入力を意味のある操作に変える窓口、draw は状態を読んで描くだけです。画面を30回描いても、手数は0のままです。
1回押したら離して、次のキーを押します。押しっぱなしや複数のキーでは、続けて動きません。受け付けられなかった方向も、いったん全部のキーを離せば押し直せます。R だけは、キーを離すのを待っている間や結果の表示中でも、押した瞬間に優先して処理します。Space で待つ操作はありません。
下のコードは 01_walk.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
target = add(state.player, DIRECTIONS[action])
if target not in level.floors:
return Result(state, False, "fixed_wall", blocked=target)実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 01_walk.py.venv/bin/python 01_walk.py(2,2) です。さらに右 → 右 で MOVES 4、E の (4,2) に着いて WON(成功)です。結果が出たあとは矢印を押しても進まず、R で最初に戻します。- 下へ行くと y が1増える
- 床の集合にない場所には入れない
- 受け付けた移動だけが1手
moves+1 は、移動を受け付けた transition の中だけで行います。青灰色の壁にぶつかっても、手数は増やしません。R → 上 → 右 と押してみましょう。上は動かない壁で受け付けられず、そのあとの右だけが受け付けられます。答えは MOVES 1、位置 (1,2) です。test_01_exercise_wall_does_not_count でも確かめています。
このステップのコード全文(184行)
# 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.
#
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
"""後から来る壁・教材 1/5。図形とASCIIだけで動く独立ファイル。"""
from dataclasses import dataclass
DIRECTIONS = {"U":(0,-1), "R":(1,0), "D":(0,1), "L":(-1,0)}
def add(a, b):
return a[0]+b[0], a[1]+b[1]
@dataclass(frozen=True)
class Level:
title: str
start: tuple[int, int]
exit: tuple[int, int]
sprouts: frozenset[tuple[int, int]]
@property
def floors(self):
return self.sprouts | {self.start, self.exit}
LEVELS = (Level("A", (0,2), (4,2), frozenset({(1,2),(2,2),(3,2),(2,1)})),)
@dataclass(frozen=True)
class State:
player: tuple[int, int]
moves: int = 0
status: str = "playing"
@dataclass(frozen=True)
class Result:
state: State
accepted: bool
reason: str
blocked: tuple[int, int] | None = None
def initial(level):
return State(level.start)
def transition(level, state, action):
# 拒否は新しい手ではない。最初に判定して同じ状態を返す。
if action not in DIRECTIONS:
return Result(state, False, "invalid")
if state.status != "playing":
return Result(state, False, state.status)
target = add(state.player, DIRECTIONS[action])
if target not in level.floors:
return Result(state, False, "fixed_wall", blocked=target)
status = "won" if target == level.exit else "playing"
return Result(State(target, state.moves+1, status), True, status if status == "won" else "walk")
class App:
def __init__(self):
self.level_index = 0
self.reset()
@property
def level(self):
return LEVELS[self.level_index]
def reset(self):
self.state = initial(self.level)
self.gated = False
self.message = "READY"
def control(self, pressed, held):
# 新しいRは終了中・複合入力・解放待ちでも優先。
if "R" in pressed:
self.reset()
self.gated = True
return
if self.gated:
if not pressed and not held:
self.gated = False
return
if len(pressed | held) > 1:
self.message = "RELEASE ALL KEYS"
self.gated = True
return
if len(pressed) != 1:
return
key = next(iter(pressed))
action = {"UP":"U", "RIGHT":"R", "DOWN":"D", "LEFT":"L"}.get(key)
if action is None:
return
result = transition(self.level, self.state, action)
self.state = result.state
self.message = result.reason.upper()
self.gated = True
def draw(self, p):
p.cls(1)
p.text(10,8,"DELAYED WALLS / STAGE 1",10)
s = self.state
p.text(10,24,"LEVEL "+self.level.title+" MOVES "+str(s.moves)+" "+s.status.upper(),7)
for y in range(5):
for x in range(7):
pos = (x,y)
px, py = 16+x*36, 64+y*36
floor = pos in self.level.floors
p.rect(px,py,34,34,3 if floor else 5)
p.rectb(px,py,34,34,11 if floor else 13)
if pos == self.level.start:
p.text(px+3,py+3,"S",6)
if pos == self.level.exit:
p.text(px+3,py+3,"E",10)
px, py = 32+s.player[0]*36, 82+s.player[1]*36
p.rect(px-5,py-3,11,12,12)
p.circ(px,py-8,5,15)
p.pset(px-2,py-9,1)
p.pset(px+2,py-9,1)
p.text(16,254,"S START E EXIT BLUE PLAYER",6)
p.text(288,64,"ARROWS: ONE STEP",6)
p.text(288,80,"RELEASE THEN PRESS",6)
p.text(16,270,"THINKING TIME DOES NOT COUNT AS A MOVE",6)
p.text(16,290,self.message,7)
p.text(16,312,'ARROWS MOVE R RESET ESC QUIT',6)
DEMO_KEYS = ('RIGHT', 'RIGHT')
KEYS = ("UP","RIGHT","DOWN","LEFT","SPACE","N","Z","R")
def main(headless=False, screenshot=None, demo=False):
from pathlib import Path
import pyxel as p
screenshot = str(Path(screenshot).resolve()) if screenshot else None
if screenshot:
Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
app = App()
p.init(448,336,title="Delayed Walls",fps=30,display_scale=2,headless=headless)
keys = {name:getattr(p,"KEY_"+name) for name in KEYS}
limit = max(12,2*len(DEMO_KEYS)+3) if demo else 12
def update():
if headless and p.frame_count >= limit:
if screenshot:
p.screenshot(screenshot,scale=2)
p.quit()
return
if headless:
index = p.frame_count//2
pressed = {DEMO_KEYS[index]} if demo and p.frame_count%2 == 0 and index < len(DEMO_KEYS) else set()
app.control(pressed,pressed)
else:
app.control({k for k,v in keys.items() if p.btnp(v)},
{k for k,v in keys.items() if p.btn(v)})
p.run(update,lambda:app.draw(p))
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--headless-smoke",action="store_true")
parser.add_argument("--demo-checkpoint",action="store_true")
parser.add_argument("--screenshot")
args = parser.parse_args()
# 検査指定の取り違えはウィンドウ作成・Pyxel importより前に拒否。
if (args.demo_checkpoint or args.screenshot is not None) and not args.headless_smoke:
parser.error("--demo-checkpoint/--screenshot requires --headless-smoke")
if args.screenshot is not None and not args.screenshot.lower().endswith(".png"):
parser.error("--screenshot must end with .png")
main(args.headless_smoke,args.screenshot,args.demo_checkpoint)灯りを集めてから出口へ行く
ずっと残る「集めた」状態と、ビットの印。
02 では、上の短い枝の (2,1) に黄色い灯りを置きます。E に着いただけでは終わらず、灯りを持って E に着いたときだけ WON です。まだ床は壁に変わらないので、取り忘れても戻れます。
State に collected(集めた印)を加えました。最初の0は、何も持っていない状態です。i番目の灯りは 1<<i という印で、0番目が1、1番目が2です。collected |= 印 でその印を立て、すでに立っているほかの印はそのまま残します。同じ灯りの上を行ったり来たりしても、数は増えません。画面の bit_count() は、立っている印の数です。
全部集めたときの印は (1<<灯りの数)-1 です。1個なら1、2個なら3になります。今は1個だけですが、04 で2個の面を加えても、同じ処理が使えます。出口の判定は、移動して灯りを集めたあとの、新しい collected で行います。
書きかえない状態なので、collected は関数の中の変数で計算し、最後に新しい State に渡します。前の State を書きかえないこの作りが、あとの予測や Undo につながります。01 とのちがいでは、Level.lights、State.collected、集める条件、出口の条件、黄色い丸の描画に注目しましょう。
下のコードは 02_lights.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
collected = state.collected
if target in level.lights:
collected |= 1 << level.lights.index(target)実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 02_lights.py.venv/bin/python 02_lights.py- 灯りごとの印を、1回だけ立てる
- 全部集めてから E に着く
- 動いて集めたあとの状態で、成功を判定する
collected += 1 と書くと、同じ灯りを2回踏んだだけで増えてしまいます。灯りごとの印を OR(|)で立てます。E に着くだけで成功にしていた条件も、ここで「集めた」条件と組み合わせます。灯りを取り忘れて右を4回押したあと、左 → 左 → 上 → 下 → 右 → 右 と押して、取りに戻ってみましょう。答えは10手で WON です。壁に変わらない 02 だけで試し、03 の解き方とまぜないでください。test_02_exercise_go_back_for_light で確かめています。
このステップのコード全文(197行)
# 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.
#
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
"""後から来る壁・教材 2/5。図形とASCIIだけで動く独立ファイル。"""
from dataclasses import dataclass
DIRECTIONS = {"U":(0,-1), "R":(1,0), "D":(0,1), "L":(-1,0)}
def add(a, b):
return a[0]+b[0], a[1]+b[1]
@dataclass(frozen=True)
class Level:
title: str
start: tuple[int, int]
exit: tuple[int, int]
sprouts: frozenset[tuple[int, int]]
lights: tuple[tuple[int, int], ...]
@property
def floors(self):
return self.sprouts | {self.start, self.exit}
LEVELS = (
Level("A", (0,2), (4,2), frozenset({(1,2),(2,2),(3,2),(2,1)}), ((2,1),)),
)
@dataclass(frozen=True)
class State:
player: tuple[int, int]
collected: int = 0
moves: int = 0
status: str = "playing"
@dataclass(frozen=True)
class Result:
state: State
accepted: bool
reason: str
blocked: tuple[int, int] | None = None
def initial(level):
return State(level.start)
def transition(level, state, action):
# 拒否は新しい手ではない。最初に判定して同じ状態を返す。
if action not in DIRECTIONS:
return Result(state, False, "invalid")
if state.status != "playing":
return Result(state, False, state.status)
target = add(state.player, DIRECTIONS[action])
if target not in level.floors:
return Result(state, False, "fixed_wall", blocked=target)
collected = state.collected
if target in level.lights:
collected |= 1 << level.lights.index(target)
status = "won" if target == level.exit and collected == (1 << len(level.lights))-1 else "playing"
new = State(target, collected, state.moves+1, status)
return Result(new, True, status if status == "won" else "walk")
class App:
def __init__(self):
self.level_index = 0
self.reset()
@property
def level(self):
return LEVELS[self.level_index]
def reset(self):
self.state = initial(self.level)
self.gated = False
self.message = "READY"
def control(self, pressed, held):
# 新しいRは終了中・複合入力・解放待ちでも優先。
if "R" in pressed:
self.reset()
self.gated = True
return
if self.gated:
if not pressed and not held:
self.gated = False
return
if len(pressed | held) > 1:
self.message = "RELEASE ALL KEYS"
self.gated = True
return
if len(pressed) != 1:
return
key = next(iter(pressed))
action = {"UP":"U", "RIGHT":"R", "DOWN":"D", "LEFT":"L"}.get(key)
if action is None:
return
result = transition(self.level, self.state, action)
self.state = result.state
self.message = result.reason.upper()
self.gated = True
def draw(self, p):
p.cls(1)
p.text(10,8,"DELAYED WALLS / STAGE 2",10)
s = self.state
p.text(10,24,"LEVEL "+self.level.title+" MOVES "+str(s.moves)+" "+s.status.upper(),7)
p.text(10,40,"LIGHTS "+str(s.collected.bit_count())+"/"+str(len(self.level.lights)),10)
for y in range(5):
for x in range(7):
pos = (x,y)
px, py = 16+x*36, 64+y*36
floor = pos in self.level.floors
p.rect(px,py,34,34,3 if floor else 5)
p.rectb(px,py,34,34,11 if floor else 13)
if pos == self.level.start:
p.text(px+3,py+3,"S",6)
if pos == self.level.exit:
p.text(px+3,py+3,"E",10)
for index, pos in enumerate(self.level.lights):
if not s.collected & (1 << index):
p.circ(32+pos[0]*36,80+pos[1]*36,7,10)
p.pset(30+pos[0]*36,78+pos[1]*36,7)
px, py = 32+s.player[0]*36, 82+s.player[1]*36
p.rect(px-5,py-3,11,12,12)
p.circ(px,py-8,5,15)
p.pset(px-2,py-9,1)
p.pset(px+2,py-9,1)
p.text(16,254,"S START E EXIT BLUE PLAYER",6)
p.text(288,64,"ARROWS: ONE STEP",6)
p.text(288,80,"RELEASE THEN PRESS",6)
p.text(16,270,"THINKING TIME DOES NOT COUNT AS A MOVE",6)
p.text(16,290,self.message,7)
p.text(16,312,'ARROWS MOVE R RESET ESC QUIT',6)
DEMO_KEYS = ('RIGHT', 'RIGHT', 'UP', 'DOWN', 'RIGHT', 'RIGHT')
KEYS = ("UP","RIGHT","DOWN","LEFT","SPACE","N","Z","R")
def main(headless=False, screenshot=None, demo=False):
from pathlib import Path
import pyxel as p
screenshot = str(Path(screenshot).resolve()) if screenshot else None
if screenshot:
Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
app = App()
p.init(448,336,title="Delayed Walls",fps=30,display_scale=2,headless=headless)
keys = {name:getattr(p,"KEY_"+name) for name in KEYS}
limit = max(12,2*len(DEMO_KEYS)+3) if demo else 12
def update():
if headless and p.frame_count >= limit:
if screenshot:
p.screenshot(screenshot,scale=2)
p.quit()
return
if headless:
index = p.frame_count//2
pressed = {DEMO_KEYS[index]} if demo and p.frame_count%2 == 0 and index < len(DEMO_KEYS) else set()
app.control(pressed,pressed)
else:
app.control({k for k,v in keys.items() if p.btnp(v)},
{k for k,v in keys.items() if p.btn(v)})
p.run(update,lambda:app.draw(p))
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--headless-smoke",action="store_true")
parser.add_argument("--demo-checkpoint",action="store_true")
parser.add_argument("--screenshot")
args = parser.parse_args()
# 検査指定の取り違えはウィンドウ作成・Pyxel importより前に拒否。
if (args.demo_checkpoint or args.screenshot is not None) and not args.headless_smoke:
parser.error("--demo-checkpoint/--screenshot requires --headless-smoke")
if args.screenshot is not None and not args.screenshot.lower().endswith(".png"):
parser.error("--screenshot must end with .png")
main(args.headless_smoke,args.screenshot,args.demo_checkpoint)初めて踏んだ床を三手後に壁へ変える
予約の列・処理の順番・もう一度踏んでも延びない期限。
03 で、このゲームの中心の仕組みを加えます。緑の床を初めて踏んだ手の終わりに、「残り3」を予約します。次に移動が受け付けられると2、さらに1、もう1手で茶色い壁になります。S と E は育ちません。動かない壁にぶつかった入力や Space、考えている本当の時間では、予約は減りません。
queue は「(座標, 残りの手数)」のタプルを並べたものです。まず古い queue から残り1の床を matured(育った)に集め、残り2以上のものだけを1つ減らします。そのあと、今入った床が古い予約になければ、3で追加します。先に追加してから全部を減らすと、最初から2になって1手早く育ってしまうので、順番が大切です。
もう一度踏んだかどうかは、更新したあとの queue ではなく、古い old で判定します。すでに予約した床を踏み直しても、3には戻しません。右 → 右 → 上 → 下 の4手目で (2,2) に戻ると、残りは1です。次の右は受け付けられ、立ち去った (2,2) がその手の終わりに壁になります。行き先に入れるかどうかは育つ前に、壁に育つのは移動したあとに処理します。
この段階は1つの面だけです。行き止まりで自動的に終わる仕組みはまだなく、R でやり直します。collision(重なり)は、処理の順番を決めておくための、念のための条件です。上下左右に1歩ずつ動き、3手(奇数)おくれて壁になるので、床の色の並びが交互になり、ふつうに遊んでいて「踏んだ床が3手後の足元で壁になる」ことは起きません。テストの重なりの例は、わざと作った、実際にはたどり着けない状態です。
下のコードは 03_growth.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
old = dict(state.queue)
matured = tuple(sorted(p for p, n in state.queue if n == 1))
queue = {p:n-1 for p, n in state.queue if n > 1}
scheduled = None
if target in level.sprouts and target not in old:
queue[target] = 3
scheduled = target実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 03_growth.py.venv/bin/python 03_growth.py(2,2)、LIGHTS 1/1 です。(1,2) は壁になり、予約は (2,2)=1 と (2,1)=2 です。右 → 右 と続ければ、6手で WON です。- 初めて踏んだときの表示は3
- 減らすのは、前からある予約だけ
- もう一度踏んでも、受け付けなかった操作でも、本当の時間でも、期限は延びない
queue の数字は、本当の時間の秒数ではありません。queue[target] = 3 の3を4に変えて、もう一度起動し、右 → 右 → 上 → 下 と押してみましょう。答えは、壁はなし、(1,2)=1・(2,2)=2・(2,1)=3 です。さらに右を押すと (1,2) が壁になります。確かめたら3に戻してください。test_03_exercise_delay_four が、変えたあとの値を確かめます。
このステップのコード全文(236行)
# 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.
#
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
"""後から来る壁・教材 3/5。図形とASCIIだけで動く独立ファイル。"""
from dataclasses import dataclass
DIRECTIONS = {"U":(0,-1), "R":(1,0), "D":(0,1), "L":(-1,0)}
def add(a, b):
return a[0]+b[0], a[1]+b[1]
@dataclass(frozen=True)
class Level:
title: str
start: tuple[int, int]
exit: tuple[int, int]
sprouts: frozenset[tuple[int, int]]
lights: tuple[tuple[int, int], ...]
@property
def floors(self):
return self.sprouts | {self.start, self.exit}
LEVELS = (
Level("A", (0,2), (4,2), frozenset({(1,2),(2,2),(3,2),(2,1)}), ((2,1),)),
)
@dataclass(frozen=True)
class State:
player: tuple[int, int]
collected: int = 0
walls: frozenset[tuple[int, int]] = frozenset()
queue: tuple[tuple[tuple[int, int], int], ...] = ()
moves: int = 0
status: str = "playing"
trail: tuple[tuple[int, int], ...] = ()
@dataclass(frozen=True)
class Result:
state: State
accepted: bool
reason: str
matured: tuple[tuple[int, int], ...] = ()
scheduled: tuple[int, int] | None = None
blocked: tuple[int, int] | None = None
def initial(level):
return State(level.start, trail=(level.start,))
def transition(level, state, action):
# 拒否は新しい手ではない。最初に判定して同じ状態を返す。
if action not in DIRECTIONS:
return Result(state, False, "invalid")
if state.status != "playing":
return Result(state, False, state.status)
target = add(state.player, DIRECTIONS[action])
if target not in level.floors:
return Result(state, False, "fixed_wall", blocked=target)
if target in state.walls:
return Result(state, False, "grown_wall", blocked=target)
collected = state.collected
if target in level.lights:
collected |= 1 << level.lights.index(target)
# 古い予約だけを減らす。今踏んだ床は最後に3で登録する。
old = dict(state.queue)
matured = tuple(sorted(p for p, n in state.queue if n == 1))
queue = {p:n-1 for p, n in state.queue if n > 1}
scheduled = None
if target in level.sprouts and target not in old:
queue[target] = 3
scheduled = target
walls = state.walls | frozenset(matured)
status = "playing"
# 通常到達しない合成状態にも、衝突優先という意味を定める。
if target in walls:
status = "collision"
elif target == level.exit and collected == (1 << len(level.lights))-1:
status = "won"
new = State(target, collected, walls, tuple(sorted(queue.items())),
state.moves+1, status, (state.trail+(target,))[-4:])
reason = status if status != "playing" else ("light" if collected != state.collected else "walk")
return Result(new, True, reason, matured, scheduled)
class App:
def __init__(self):
self.level_index = 0
self.reset()
@property
def level(self):
return LEVELS[self.level_index]
def reset(self):
self.state = initial(self.level)
self.gated = False
self.message = "READY"
def control(self, pressed, held):
# 新しいRは終了中・複合入力・解放待ちでも優先。
if "R" in pressed:
self.reset()
self.gated = True
return
if self.gated:
if not pressed and not held:
self.gated = False
return
if len(pressed | held) > 1:
self.message = "RELEASE ALL KEYS"
self.gated = True
return
if len(pressed) != 1:
return
key = next(iter(pressed))
action = {"UP":"U", "RIGHT":"R", "DOWN":"D", "LEFT":"L"}.get(key)
if action is None:
return
result = transition(self.level, self.state, action)
self.state = result.state
self.message = result.reason.upper()
self.gated = True
def draw(self, p):
p.cls(1)
p.text(10,8,"DELAYED WALLS / STAGE 3",10)
s = self.state
p.text(10,24,"LEVEL "+self.level.title+" MOVES "+str(s.moves)+" "+s.status.upper(),7)
p.text(10,40,"LIGHTS "+str(s.collected.bit_count())+"/"+str(len(self.level.lights)),10)
for y in range(5):
for x in range(7):
pos = (x,y)
px, py = 16+x*36, 64+y*36
floor = pos in self.level.floors
p.rect(px,py,34,34,3 if floor else 5)
p.rectb(px,py,34,34,11 if floor else 13)
if pos == self.level.start:
p.text(px+3,py+3,"S",6)
if pos == self.level.exit:
p.text(px+3,py+3,"E",10)
if pos in s.walls:
p.rect(px+1,py+1,32,32,4)
p.rectb(px+1,py+1,32,32,9)
p.line(px+3,py+11,px+30,py+11,9)
p.line(px+10,py+3,px+10,py+10,9)
if pos in dict(s.queue):
n = dict(s.queue)[pos]
p.rect(px+24,py+2,8,10,1)
p.text(px+26,py+4,str(n),10 if n == 1 else 7)
for index, pos in enumerate(self.level.lights):
if not s.collected & (1 << index):
p.circ(32+pos[0]*36,80+pos[1]*36,7,10)
p.pset(30+pos[0]*36,78+pos[1]*36,7)
px, py = 32+s.player[0]*36, 82+s.player[1]*36
p.rect(px-5,py-3,11,12,12)
p.circ(px,py-8,5,15)
p.pset(px-2,py-9,1)
p.pset(px+2,py-9,1)
p.text(16,254,"S START E EXIT BLUE PLAYER",6)
p.text(288,64,"RESERVATIONS",10)
p.text(288,78,"CELL / STEPS LEFT",6)
for i, (pos,n) in enumerate(sorted(s.queue,key=lambda item:(item[1],item[0]))):
label = str(pos[0])+","+str(pos[1])+" / "+str(n)
p.text(288,94+i*14,label,10 if n == 1 else 7)
if not s.queue:
p.text(288,94,"NONE",13)
p.text(288,146,"3 > 2 > 1 > WALL",9)
p.text(288,160,"NO WAIT ACTION",6)
p.text(16,270,"THINKING TIME DOES NOT COUNT AS A MOVE",6)
p.text(16,290,self.message,7)
p.text(16,312,'ARROWS MOVE R RESET ESC QUIT',6)
DEMO_KEYS = ('RIGHT', 'RIGHT', 'UP', 'DOWN')
KEYS = ("UP","RIGHT","DOWN","LEFT","SPACE","N","Z","R")
def main(headless=False, screenshot=None, demo=False):
from pathlib import Path
import pyxel as p
screenshot = str(Path(screenshot).resolve()) if screenshot else None
if screenshot:
Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
app = App()
p.init(448,336,title="Delayed Walls",fps=30,display_scale=2,headless=headless)
keys = {name:getattr(p,"KEY_"+name) for name in KEYS}
limit = max(12,2*len(DEMO_KEYS)+3) if demo else 12
def update():
if headless and p.frame_count >= limit:
if screenshot:
p.screenshot(screenshot,scale=2)
p.quit()
return
if headless:
index = p.frame_count//2
pressed = {DEMO_KEYS[index]} if demo and p.frame_count%2 == 0 and index < len(DEMO_KEYS) else set()
app.control(pressed,pressed)
else:
app.control({k for k,v in keys.items() if p.btnp(v)},
{k for k,v in keys.items() if p.btn(v)})
p.run(update,lambda:app.draw(p))
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--headless-smoke",action="store_true")
parser.add_argument("--demo-checkpoint",action="store_true")
parser.add_argument("--screenshot")
args = parser.parse_args()
# 検査指定の取り違えはウィンドウ作成・Pyxel importより前に拒否。
if (args.demo_checkpoint or args.screenshot is not None) and not args.headless_smoke:
parser.error("--demo-checkpoint/--screenshot requires --headless-smoke")
if args.screenshot is not None and not args.screenshot.lower().endswith(".png"):
parser.error("--screenshot must end with .png")
main(args.headless_smoke,args.screenshot,args.demo_checkpoint)三つの面と一手先の予測を読む
同じ「次の状態を計算する関数」を使い回し、行き止まりを判定する。
04 では、元のゲームの面 A・B・C をすべて入れます。N で次の面に移り、その面の最初の状態に戻ります。A は短い枝、B は灯りが2つ、C は合流する場所をいつ踏むかが課題です。N キーと、プログラムの中の方向の文字には関係がなく、N は移動の1手に数えません。
右側の「NEXT MOVE PREVIEW」は、上下左右(U/R/D/L)に1歩動いたらどうなるかを、仮に計算した結果です。「W+1」は「その1手の終わりに壁が1つ増える」という意味で、待つ時間ではありません。「FIXED_WALL」は床がない場所、「GROWN_WALL」はすでに育った壁です。予測で transition を呼んでも、返ってきた値を今の状態に代入しないので、時間も灯りも変わりません。
壁が育ったあと、となりに通れる床が1つもなければ STRANDED(取り残された)にします。A で 右 → 左 → 右 → 左 と押すと、4手で S に取り残される、実際に起きる失敗の例です。ただし、これは「もう解けない状態」すべてを見つける判定ではありません。行き来できる床が残っていても、必要な灯りへ行けないことがあります。C で 右 → 右 → 右 → 上 → 上 → 下 のあと、下は育った壁で受け付けられませんが、まだ上へは動けるので PLAYING のままです。
成功の判定は、行き止まりの判定より先に行います。灯りを持って E に着いた同じ手で、ただ1つの帰り道が壁になっても WON です。C の正しい解き方の一例は、上 → 上 → 右 → 右 → 右 → 下 → 下 → 右 → 右 → 右 です。合流する場所 (3,3) を行きでは踏まず、帰りに使います。予測は1手先だけで、解き方を保証してくれる探索の機能ではありません。
下のコードは 04_forecast.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
def forecast(level, state):
"""副作用のない仮計算。表示するだけでは時間を進めない。"""
return {action: transition(level, state, action) for action in DIRECTIONS}実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 04_forecast.py.venv/bin/python 04_forecast.py(3,2) のままです。R で C を最初に戻し、上 → 上 → 右 → 右 → 右 → 下 → 下 → 右 → 右 → 右 と押すと、10手で WON です。- 予測は状態を読むだけ
- 壁が育ったあとに、行き止まりかを判定する
- 合流する場所を踏む順番を計画する
self.state を書きかえると、画面を描くたびに4方向へ動いてしまいます。また、行き止まりの判定を「これから必ず失敗する状態すべて」だと思わないでください。N で B にして、右 → 右 → 上 → 下 → 右 → 右 → 上 → 上 → 右 → 右 と押してみましょう。短い枝の灯りを先に取れます。答えは10手、LIGHTS 2/2、WON です。集めた印は2進数で11、整数で3です。test_04_exercise_b_short_first で確かめています。
このステップのコード全文(255行)
# 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.
#
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
"""後から来る壁・教材 4/5。図形とASCIIだけで動く独立ファイル。"""
from dataclasses import dataclass
DIRECTIONS = {"U":(0,-1), "R":(1,0), "D":(0,1), "L":(-1,0)}
def add(a, b):
return a[0]+b[0], a[1]+b[1]
@dataclass(frozen=True)
class Level:
title: str
start: tuple[int, int]
exit: tuple[int, int]
sprouts: frozenset[tuple[int, int]]
lights: tuple[tuple[int, int], ...]
@property
def floors(self):
return self.sprouts | {self.start, self.exit}
LEVELS = (
Level("A", (0,2), (4,2), frozenset({(1,2),(2,2),(3,2),(2,1)}), ((2,1),)),
Level("B", (0,3), (6,1), frozenset({(1,3),(2,3),(2,2),(3,3),(4,3),(4,2),(4,1),(5,1)}), ((2,2),(4,1))),
Level("C", (0,3), (6,3), frozenset({(1,3),(2,3),(3,3),(3,2),(3,1),(0,2),(0,1),(1,1),(2,1),(4,3),(5,3)}), ((3,1),)),
)
@dataclass(frozen=True)
class State:
player: tuple[int, int]
collected: int = 0
walls: frozenset[tuple[int, int]] = frozenset()
queue: tuple[tuple[tuple[int, int], int], ...] = ()
moves: int = 0
status: str = "playing"
trail: tuple[tuple[int, int], ...] = ()
@dataclass(frozen=True)
class Result:
state: State
accepted: bool
reason: str
matured: tuple[tuple[int, int], ...] = ()
scheduled: tuple[int, int] | None = None
blocked: tuple[int, int] | None = None
def initial(level):
return State(level.start, trail=(level.start,))
def transition(level, state, action):
# 拒否は新しい手ではない。最初に判定して同じ状態を返す。
if action not in DIRECTIONS:
return Result(state, False, "invalid")
if state.status != "playing":
return Result(state, False, state.status)
target = add(state.player, DIRECTIONS[action])
if target not in level.floors:
return Result(state, False, "fixed_wall", blocked=target)
if target in state.walls:
return Result(state, False, "grown_wall", blocked=target)
collected = state.collected
if target in level.lights:
collected |= 1 << level.lights.index(target)
# 古い予約だけを減らす。今踏んだ床は最後に3で登録する。
old = dict(state.queue)
matured = tuple(sorted(p for p, n in state.queue if n == 1))
queue = {p:n-1 for p, n in state.queue if n > 1}
scheduled = None
if target in level.sprouts and target not in old:
queue[target] = 3
scheduled = target
walls = state.walls | frozenset(matured)
status = "playing"
# 通常到達しない合成状態にも、衝突優先という意味を定める。
if target in walls:
status = "collision"
elif target == level.exit and collected == (1 << len(level.lights))-1:
status = "won"
elif not any(add(target, d) in level.floors-walls for d in DIRECTIONS.values()):
status = "stranded"
new = State(target, collected, walls, tuple(sorted(queue.items())),
state.moves+1, status, (state.trail+(target,))[-4:])
reason = status if status != "playing" else ("light" if collected != state.collected else "walk")
return Result(new, True, reason, matured, scheduled)
def forecast(level, state):
"""副作用のない仮計算。表示するだけでは時間を進めない。"""
return {action: transition(level, state, action) for action in DIRECTIONS}
class App:
def __init__(self):
self.level_index = 0
self.reset()
@property
def level(self):
return LEVELS[self.level_index]
def reset(self):
self.state = initial(self.level)
self.gated = False
self.message = "READY"
def control(self, pressed, held):
# 新しいRは終了中・複合入力・解放待ちでも優先。
if "R" in pressed:
self.reset()
self.gated = True
return
if self.gated:
if not pressed and not held:
self.gated = False
return
if len(pressed | held) > 1:
self.message = "RELEASE ALL KEYS"
self.gated = True
return
if len(pressed) != 1:
return
key = next(iter(pressed))
if key == "N":
self.level_index = (self.level_index+1) % len(LEVELS)
self.reset()
self.gated = True
return
action = {"UP":"U", "RIGHT":"R", "DOWN":"D", "LEFT":"L"}.get(key)
if action is None:
return
result = transition(self.level, self.state, action)
self.state = result.state
self.message = result.reason.upper()
self.gated = True
def draw(self, p):
p.cls(1)
p.text(10,8,"DELAYED WALLS / STAGE 4",10)
s = self.state
p.text(10,24,"LEVEL "+self.level.title+" MOVES "+str(s.moves)+" "+s.status.upper(),7)
p.text(10,40,"LIGHTS "+str(s.collected.bit_count())+"/"+str(len(self.level.lights)),10)
for y in range(5):
for x in range(7):
pos = (x,y)
px, py = 16+x*36, 64+y*36
floor = pos in self.level.floors
p.rect(px,py,34,34,3 if floor else 5)
p.rectb(px,py,34,34,11 if floor else 13)
if pos == self.level.start:
p.text(px+3,py+3,"S",6)
if pos == self.level.exit:
p.text(px+3,py+3,"E",10)
if pos in s.walls:
p.rect(px+1,py+1,32,32,4)
p.rectb(px+1,py+1,32,32,9)
p.line(px+3,py+11,px+30,py+11,9)
p.line(px+10,py+3,px+10,py+10,9)
if pos in dict(s.queue):
n = dict(s.queue)[pos]
p.rect(px+24,py+2,8,10,1)
p.text(px+26,py+4,str(n),10 if n == 1 else 7)
for index, pos in enumerate(self.level.lights):
if not s.collected & (1 << index):
p.circ(32+pos[0]*36,80+pos[1]*36,7,10)
p.pset(30+pos[0]*36,78+pos[1]*36,7)
px, py = 32+s.player[0]*36, 82+s.player[1]*36
p.rect(px-5,py-3,11,12,12)
p.circ(px,py-8,5,15)
p.pset(px-2,py-9,1)
p.pset(px+2,py-9,1)
p.text(16,254,"S START E EXIT BLUE PLAYER",6)
p.text(288,64,"RESERVATIONS",10)
p.text(288,78,"CELL / STEPS LEFT",6)
for i, (pos,n) in enumerate(sorted(s.queue,key=lambda item:(item[1],item[0]))):
label = str(pos[0])+","+str(pos[1])+" / "+str(n)
p.text(288,94+i*14,label,10 if n == 1 else 7)
if not s.queue:
p.text(288,94,"NONE",13)
p.text(288,146,"3 > 2 > 1 > WALL",9)
p.text(288,160,"NO WAIT ACTION",6)
p.text(288,182,"NEXT MOVE PREVIEW",10)
for i, (action,result) in enumerate(forecast(self.level,s).items()):
label = action+": "+result.reason.upper()
if result.accepted:
label += " W+"+str(len(result.matured))
p.text(288,198+i*13,label,11 if result.accepted else 13)
p.text(16,270,"THINKING TIME DOES NOT COUNT AS A MOVE",6)
p.text(16,290,self.message,7)
p.text(16,312,'ARROWS MOVE N LEVEL R RESET ESC QUIT',6)
DEMO_KEYS = ('N', 'N', 'RIGHT', 'RIGHT', 'RIGHT', 'UP', 'UP', 'DOWN', 'DOWN')
KEYS = ("UP","RIGHT","DOWN","LEFT","SPACE","N","Z","R")
def main(headless=False, screenshot=None, demo=False):
from pathlib import Path
import pyxel as p
screenshot = str(Path(screenshot).resolve()) if screenshot else None
if screenshot:
Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
app = App()
p.init(448,336,title="Delayed Walls",fps=30,display_scale=2,headless=headless)
keys = {name:getattr(p,"KEY_"+name) for name in KEYS}
limit = max(12,2*len(DEMO_KEYS)+3) if demo else 12
def update():
if headless and p.frame_count >= limit:
if screenshot:
p.screenshot(screenshot,scale=2)
p.quit()
return
if headless:
index = p.frame_count//2
pressed = {DEMO_KEYS[index]} if demo and p.frame_count%2 == 0 and index < len(DEMO_KEYS) else set()
app.control(pressed,pressed)
else:
app.control({k for k,v in keys.items() if p.btnp(v)},
{k for k,v in keys.items() if p.btn(v)})
p.run(update,lambda:app.draw(p))
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--headless-smoke",action="store_true")
parser.add_argument("--demo-checkpoint",action="store_true")
parser.add_argument("--screenshot")
args = parser.parse_args()
# 検査指定の取り違えはウィンドウ作成・Pyxel importより前に拒否。
if (args.demo_checkpoint or args.screenshot is not None) and not args.headless_smoke:
parser.error("--demo-checkpoint/--screenshot requires --headless-smoke")
if args.screenshot is not None and not args.screenshot.lower().endswith(".png"):
parser.error("--screenshot must end with .png")
main(args.headless_smoke,args.screenshot,args.demo_checkpoint)壁と灯りも一緒に一手戻す
書きかえない状態の履歴と、受け付けた操作だけの保存。
最後に、Z で1手戻せるようにします。Timeline は、今の State と、過去の State を並べたタプルです。apply は、移動が受け付けられたときだけ、動く前の状態を past の最後に積みます。動かない壁・育った壁・ゲームが終わったあとの入力・存在しない WAIT は、履歴に入りません。
undo は、past の最後を今の状態にして、past を1つ短くします。位置だけでなく、集めた印・壁の集合・予約の残りの手数・移動の回数・結果・最近の道順を、全部戻します。位置だけを戻すと、取った灯りや育った壁と合わなくなります。frozen な State と、その中の tuple / frozenset を使うので、履歴があとから書きかわることはありません。
Z は、成功・失敗の判定より前に受け付けます。WON や STRANDED からも1手前に戻れ、履歴が空なら何もしません。戻ったあとに新しく動くと、その移動が今の履歴に積まれます。やり直し(Redo)はありません。R と N は履歴も消し、別の面の状態がまざらないようにします。予測は transition だけを使い、apply を呼ばないので、履歴には入りません。
04 と 05 のふつうの移動は、元のゲームの3つの面と同じ動きにしています。元のゲームでたどりつける、先の展開が同じになる498の状態と、それぞれ4方向の1992通りの変化の候補を、一緒に入れた資料で照らし合わせています。Undo は、別に24通り×64回の操作で確かめています。これは、すべての入力の組み合わせを調べつくしたとか、自分で追加した面でも正しく動く、という意味ではありません。元のゲームの日本語の描画・表紙・一時停止・再生の記録・壁にならない比較版は省いています。
下のコードは 05_undo.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
result = transition(level, timeline.state, action)
if not result.accepted:
return timeline, result
return Timeline(result.state, timeline.past+(timeline.state,)), result実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 05_undo.py.venv/bin/python 05_undo.py(3,2)、PLAYING、HISTORY 5 に戻ります。右を押せば、また同じ WON になります。- 受け付けた移動だけを履歴に積む
- 状態全体を戻す
- 成功や失敗のあとでも Undo できる
player(位置)だけにすると、予約や灯りが未来のまま残ってしまいます。State 全体を保存します。Z を押しっぱなしにしても続けて Undo はせず、毎回全部のキーを離します。A で 右 → 右 → 上 → 下 のあと Z を押してみましょう。答えは、3手・位置 (2,1)・灯り1・壁なしです。予約は (1,2)=1、(2,2)=2、(2,1)=3 です。もう一度下を押すと、元の4手目と同じになります。test_05_exercise_restore_reservations で確かめています。
このステップのコード全文(281行)
# 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.
#
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
"""後から来る壁・教材 5/5。図形とASCIIだけで動く独立ファイル。"""
from dataclasses import dataclass
DIRECTIONS = {"U":(0,-1), "R":(1,0), "D":(0,1), "L":(-1,0)}
def add(a, b):
return a[0]+b[0], a[1]+b[1]
@dataclass(frozen=True)
class Level:
title: str
start: tuple[int, int]
exit: tuple[int, int]
sprouts: frozenset[tuple[int, int]]
lights: tuple[tuple[int, int], ...]
@property
def floors(self):
return self.sprouts | {self.start, self.exit}
LEVELS = (
Level("A", (0,2), (4,2), frozenset({(1,2),(2,2),(3,2),(2,1)}), ((2,1),)),
Level("B", (0,3), (6,1), frozenset({(1,3),(2,3),(2,2),(3,3),(4,3),(4,2),(4,1),(5,1)}), ((2,2),(4,1))),
Level("C", (0,3), (6,3), frozenset({(1,3),(2,3),(3,3),(3,2),(3,1),(0,2),(0,1),(1,1),(2,1),(4,3),(5,3)}), ((3,1),)),
)
@dataclass(frozen=True)
class State:
player: tuple[int, int]
collected: int = 0
walls: frozenset[tuple[int, int]] = frozenset()
queue: tuple[tuple[tuple[int, int], int], ...] = ()
moves: int = 0
status: str = "playing"
trail: tuple[tuple[int, int], ...] = ()
@dataclass(frozen=True)
class Result:
state: State
accepted: bool
reason: str
matured: tuple[tuple[int, int], ...] = ()
scheduled: tuple[int, int] | None = None
blocked: tuple[int, int] | None = None
def initial(level):
return State(level.start, trail=(level.start,))
def transition(level, state, action):
# 拒否は新しい手ではない。最初に判定して同じ状態を返す。
if action not in DIRECTIONS:
return Result(state, False, "invalid")
if state.status != "playing":
return Result(state, False, state.status)
target = add(state.player, DIRECTIONS[action])
if target not in level.floors:
return Result(state, False, "fixed_wall", blocked=target)
if target in state.walls:
return Result(state, False, "grown_wall", blocked=target)
collected = state.collected
if target in level.lights:
collected |= 1 << level.lights.index(target)
# 古い予約だけを減らす。今踏んだ床は最後に3で登録する。
old = dict(state.queue)
matured = tuple(sorted(p for p, n in state.queue if n == 1))
queue = {p:n-1 for p, n in state.queue if n > 1}
scheduled = None
if target in level.sprouts and target not in old:
queue[target] = 3
scheduled = target
walls = state.walls | frozenset(matured)
status = "playing"
# 通常到達しない合成状態にも、衝突優先という意味を定める。
if target in walls:
status = "collision"
elif target == level.exit and collected == (1 << len(level.lights))-1:
status = "won"
elif not any(add(target, d) in level.floors-walls for d in DIRECTIONS.values()):
status = "stranded"
new = State(target, collected, walls, tuple(sorted(queue.items())),
state.moves+1, status, (state.trail+(target,))[-4:])
reason = status if status != "playing" else ("light" if collected != state.collected else "walk")
return Result(new, True, reason, matured, scheduled)
def forecast(level, state):
"""副作用のない仮計算。表示するだけでは時間を進めない。"""
return {action: transition(level, state, action) for action in DIRECTIONS}
@dataclass(frozen=True)
class Timeline:
state: State
past: tuple[State, ...] = ()
def apply(level, timeline, action):
result = transition(level, timeline.state, action)
if not result.accepted:
return timeline, result
return Timeline(result.state, timeline.past+(timeline.state,)), result
def undo(timeline):
if not timeline.past:
return timeline
return Timeline(timeline.past[-1], timeline.past[:-1])
class App:
def __init__(self):
self.level_index = 0
self.reset()
@property
def level(self):
return LEVELS[self.level_index]
@property
def state(self):
return self.timeline.state
def reset(self):
self.timeline = Timeline(initial(self.level))
self.gated = False
self.message = "READY"
def control(self, pressed, held):
# 新しいRは終了中・複合入力・解放待ちでも優先。
if "R" in pressed:
self.reset()
self.gated = True
return
if self.gated:
if not pressed and not held:
self.gated = False
return
if len(pressed | held) > 1:
self.message = "RELEASE ALL KEYS"
self.gated = True
return
if len(pressed) != 1:
return
key = next(iter(pressed))
if key == "N":
self.level_index = (self.level_index+1) % len(LEVELS)
self.reset()
self.gated = True
return
if key == "Z":
self.timeline = undo(self.timeline)
self.message = "UNDO"
self.gated = True
return
action = {"UP":"U", "RIGHT":"R", "DOWN":"D", "LEFT":"L"}.get(key)
if action is None:
return
self.timeline, result = apply(self.level, self.timeline, action)
self.message = result.reason.upper()
self.gated = True
def draw(self, p):
p.cls(1)
p.text(10,8,"DELAYED WALLS / STAGE 5",10)
s = self.state
p.text(10,24,"LEVEL "+self.level.title+" MOVES "+str(s.moves)+" "+s.status.upper(),7)
p.text(10,40,"LIGHTS "+str(s.collected.bit_count())+"/"+str(len(self.level.lights)),10)
for y in range(5):
for x in range(7):
pos = (x,y)
px, py = 16+x*36, 64+y*36
floor = pos in self.level.floors
p.rect(px,py,34,34,3 if floor else 5)
p.rectb(px,py,34,34,11 if floor else 13)
if pos == self.level.start:
p.text(px+3,py+3,"S",6)
if pos == self.level.exit:
p.text(px+3,py+3,"E",10)
if pos in s.walls:
p.rect(px+1,py+1,32,32,4)
p.rectb(px+1,py+1,32,32,9)
p.line(px+3,py+11,px+30,py+11,9)
p.line(px+10,py+3,px+10,py+10,9)
if pos in dict(s.queue):
n = dict(s.queue)[pos]
p.rect(px+24,py+2,8,10,1)
p.text(px+26,py+4,str(n),10 if n == 1 else 7)
for index, pos in enumerate(self.level.lights):
if not s.collected & (1 << index):
p.circ(32+pos[0]*36,80+pos[1]*36,7,10)
p.pset(30+pos[0]*36,78+pos[1]*36,7)
px, py = 32+s.player[0]*36, 82+s.player[1]*36
p.rect(px-5,py-3,11,12,12)
p.circ(px,py-8,5,15)
p.pset(px-2,py-9,1)
p.pset(px+2,py-9,1)
p.text(16,254,"S START E EXIT BLUE PLAYER",6)
p.text(288,64,"RESERVATIONS",10)
p.text(288,78,"CELL / STEPS LEFT",6)
for i, (pos,n) in enumerate(sorted(s.queue,key=lambda item:(item[1],item[0]))):
label = str(pos[0])+","+str(pos[1])+" / "+str(n)
p.text(288,94+i*14,label,10 if n == 1 else 7)
if not s.queue:
p.text(288,94,"NONE",13)
p.text(288,146,"3 > 2 > 1 > WALL",9)
p.text(288,160,"NO WAIT ACTION",6)
p.text(288,182,"NEXT MOVE PREVIEW",10)
for i, (action,result) in enumerate(forecast(self.level,s).items()):
label = action+": "+result.reason.upper()
if result.accepted:
label += " W+"+str(len(result.matured))
p.text(288,198+i*13,label,11 if result.accepted else 13)
p.text(16,270,"HISTORY "+str(len(self.timeline.past))+" Z RESTORES ALL STATE",6)
p.text(16,290,self.message,7)
p.text(16,312,'ARROWS MOVE N LEVEL Z UNDO R RESET ESC QUIT',6)
DEMO_KEYS = ('RIGHT', 'RIGHT', 'UP', 'DOWN', 'RIGHT', 'RIGHT', 'Z')
KEYS = ("UP","RIGHT","DOWN","LEFT","SPACE","N","Z","R")
def main(headless=False, screenshot=None, demo=False):
from pathlib import Path
import pyxel as p
screenshot = str(Path(screenshot).resolve()) if screenshot else None
if screenshot:
Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
app = App()
p.init(448,336,title="Delayed Walls",fps=30,display_scale=2,headless=headless)
keys = {name:getattr(p,"KEY_"+name) for name in KEYS}
limit = max(12,2*len(DEMO_KEYS)+3) if demo else 12
def update():
if headless and p.frame_count >= limit:
if screenshot:
p.screenshot(screenshot,scale=2)
p.quit()
return
if headless:
index = p.frame_count//2
pressed = {DEMO_KEYS[index]} if demo and p.frame_count%2 == 0 and index < len(DEMO_KEYS) else set()
app.control(pressed,pressed)
else:
app.control({k for k,v in keys.items() if p.btnp(v)},
{k for k,v in keys.items() if p.btn(v)})
p.run(update,lambda:app.draw(p))
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--headless-smoke",action="store_true")
parser.add_argument("--demo-checkpoint",action="store_true")
parser.add_argument("--screenshot")
args = parser.parse_args()
# 検査指定の取り違えはウィンドウ作成・Pyxel importより前に拒否。
if (args.demo_checkpoint or args.screenshot is not None) and not args.headless_smoke:
parser.error("--demo-checkpoint/--screenshot requires --headless-smoke")
if args.screenshot is not None and not args.screenshot.lower().endswith(".png"):
parser.error("--screenshot must end with .png")
main(args.headless_smoke,args.screenshot,args.demo_checkpoint)仕組みがわかったら、元の完成版を読んでみよう。
灯りを全部集めて E へ行きます。緑の床を初めて踏むと「残り3」が予約され、そのあと移動が3回受け付けられると壁になります。もう一度踏んでも期限は延びず、受け付けなかった操作や本当の時間では進みません。処理の順番は「壁が育ったあとの重なり → 全部集めて出口 → となりに床がない」です。手作りの面が3つあります。
ここまでに作ったのは、元のゲームの仕組みだけを取り出して作り直した「簡略版」です。元のゲームのソースは手を加えずに、別のZIP(元の完成版ソース)に入れてあります。
| 比べる点 | 5ステップの教材 | 元の完成版 |
|---|---|---|
| 段階 | 01 は移動と出口、02 は灯り、03 は壁に変わる仕組みだけの面 A、04 と 05 が元のゲームの3つの面のふつうの移動のルールです。 | 起動したときから、3つの面のすべてのルールがあります。 |
| 操作 | 全部のキーを離すまで待ちます。N でいつでも次の面へ。R を押した瞬間を優先します。05 では Z で Undo できます。 | 表紙で左右に選ぶ、成功のあと N、P で一時停止、M で表紙、Z で Undo があります。 |
| 描画と記録 | 文字(ASCII)と図形で表示し、1手先の4方向の予測があります。外部のデータは使いません。 | Noto をもとにした日本語の文字、庭の描画、説明の画面、最近の道順の表示、操作の保存とデモがあります。 |
| 比較実験 | ふつうの「壁に変わる」ルールだけです。 | --baseline で壁に変わらない版、solver による探索や比較の実験があります。 |
完成版のコードで、次に読むところ
- model.py: transitionの拒否→移動→収集→予約→成長→終了の順序
- model.py: keyでmoves/trailを省く将来等価な状態
- model.py: apply/undo/replayとstate_hash
- solver.py: exploreの到達状態探索
- app.py: drawの日本語字形と予約欄、demo_schedule
最初は、数字かルールを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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。
うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る
