パズルLESSON 51三手遅れて育つ壁のターン制経路パズル原作027

後から来る壁の作り方

いま踏んだ床が、3歩あとに壁になる。
はじめて歩いた床に「あとで壁になる」予定を入れます。実際に動けた1手のときだけ時計を進め、帰り道を考えるパズルを作ります。

完成版の遊び方はじめて踏んだ芽(緑の床)は、3歩あとに壁になります。灯りを集めに行く途中で、帰り道を残しておきましょう。

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

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

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

完成版の主な操作:矢印一歩、Z戻す、R再開、P停止、M面選択、N次面(待機操作なし)

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

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

うまく表示されないとき

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

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

これから起きる地形の変化を、「判断した回数」で予約する。

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

1. 座標・集合・状態の更新と、描く処理を分ける
2. ずっと残る「集めた」状態と、ビットの印
3. 予約の列・処理の順番・もう一度踏んでも延びない期限
それだけで動く小さなプログラムを、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:終了
難易度の目安
初級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_walk.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

床がある場所へ一歩だけ進む

座標・集合・状態の更新と、描く処理を分ける。

最初は壁が増えず、灯りもありません。青い人を矢印キーで動かし、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 の一部です。前のステップから追加・変更したところだけを抜き出しています。

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

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_walk.py
macOS / Linux
.venv/bin/python 01_walk.py
画面で見ること右 → 右 で MOVES 2、位置 (2,2) です。さらに右 → 右 で MOVES 4、E の (4,2) に着いて WON(成功)です。結果が出たあとは矢印を押しても進まず、R で最初に戻します。
  • 下へ行くと y が1増える
  • 床の集合にない場所には入れない
  • 受け付けた移動だけが1手
つまずきやすいところPyxel が画面を描いた回数を手数に足すと、考えている間にもゲームが進んでしまいます。moves+1 は、移動を受け付けた transition の中だけで行います。青灰色の壁にぶつかっても、手数は増やしません。
少し変えてみよう

R → 上 → 右 と押してみましょう。上は動かない壁で受け付けられず、そのあとの右だけが受け付けられます。答えは MOVES 1、位置 (1,2) です。test_01_exercise_wall_does_not_count でも確かめています。

01_walk.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(184行)
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.
# 
# 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)
STEP 02 / 05

灯りを集めてから出口へ行く

ずっと残る「集めた」状態と、ビットの印。

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 の一部です。前のステップから追加・変更したところだけを抜き出しています。

02_lights.py / 注目する部分
    collected = state.collected
    if target in level.lights:
        collected |= 1 << level.lights.index(target)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_lights.py
macOS / Linux
.venv/bin/python 02_lights.py
画面で見ること右 → 右 → 上 → 下 → 右 → 右 で、6手で WON、LIGHTS 1/1 です。右を4回押すだけだと、E に着いても PLAYING(プレイ中)のままで、LIGHTS 0/1 です。
  • 灯りごとの印を、1回だけ立てる
  • 全部集めてから E に着く
  • 動いて集めたあとの状態で、成功を判定する
つまずきやすいところcollected += 1 と書くと、同じ灯りを2回踏んだだけで増えてしまいます。灯りごとの印を OR(|)で立てます。E に着くだけで成功にしていた条件も、ここで「集めた」条件と組み合わせます。
少し変えてみよう

灯りを取り忘れて右を4回押したあと、左 → 左 → 上 → 下 → 右 → 右 と押して、取りに戻ってみましょう。答えは10手で WON です。壁に変わらない 02 だけで試し、03 の解き方とまぜないでください。test_02_exercise_go_back_for_light で確かめています。

02_lights.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(197行)
02_lights.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.
# 
# 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)
STEP 03 / 05

初めて踏んだ床を三手後に壁へ変える

予約の列・処理の順番・もう一度踏んでも延びない期限。

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 の一部です。前のステップから追加・変更したところだけを抜き出しています。

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を展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 03_growth.py
macOS / Linux
.venv/bin/python 03_growth.py
画面で見ること右 → 右 → 上 → 下 で MOVES 4、位置 (2,2)、LIGHTS 1/1 です。(1,2) は壁になり、予約は (2,2)=1 と (2,1)=2 です。右 → 右 と続ければ、6手で WON です。
  • 初めて踏んだときの表示は3
  • 減らすのは、前からある予約だけ
  • もう一度踏んでも、受け付けなかった操作でも、本当の時間でも、期限は延びない
つまずきやすいところ「新しい予約まで同じ手で減らしてしまう」「もう一度踏むと3に戻してしまう」「受け付けなかった入力でも減らしてしまう」の3つに注意しましょう。queue の数字は、本当の時間の秒数ではありません。
少し変えてみよう

queue[target] = 3 の3を4に変えて、もう一度起動し、右 → 右 → 上 → 下 と押してみましょう。答えは、壁はなし、(1,2)=1・(2,2)=2・(2,1)=3 です。さらに右を押すと (1,2) が壁になります。確かめたら3に戻してください。test_03_exercise_delay_four が、変えたあとの値を確かめます。

03_growth.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(236行)
03_growth.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.
# 
# 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)
STEP 04 / 05

三つの面と一手先の予測を読む

同じ「次の状態を計算する関数」を使い回し、行き止まりを判定する。

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 の一部です。前のステップから追加・変更したところだけを抜き出しています。

04_forecast.py / 注目する部分
def forecast(level, state):
    """副作用のない仮計算。表示するだけでは時間を進めない。"""
    return {action: transition(level, state, action) for action in DIRECTIONS}

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_forecast.py
macOS / Linux
.venv/bin/python 04_forecast.py
画面で見ることN を2回押して C にします。右 → 右 → 右 → 上 → 上 → 下 → 下 と押すと、最後の下は GROWN_WALL で受け付けられず、6手・位置 (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 で確かめています。

04_forecast.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(255行)
04_forecast.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.
# 
# 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)
STEP 05 / 05

壁と灯りも一緒に一手戻す

書きかえない状態の履歴と、受け付けた操作だけの保存。

最後に、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 の一部です。前のステップから追加・変更したところだけを抜き出しています。

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を展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 05_undo.py
macOS / Linux
.venv/bin/python 05_undo.py
画面で見ることA で 右 → 右 → 上 → 下 → 右 → 右 と押すと、6手で WON です。Z を押すと MOVES 5、位置 (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 で確かめています。

05_undo.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(281行)
05_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.
# 
# 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)
AFTER STEP 05

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

灯りを全部集めて 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 による探索や比較の実験があります。

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

  1. model.py: transitionの拒否→移動→収集→予約→成長→終了の順序
  2. model.py: keyでmoves/trailを省く将来等価な状態
  3. model.py: apply/undo/replayとstate_hash
  4. solver.py: exploreの到達状態探索
  5. 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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。

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