探索・アドベンチャーLESSON 73歩測で知識を増やすグラフ探索原作055

迷子の地図屋の作り方

歩いたところから、地図になる。
通路の途中まで歩いて分かった距離と、端から端まで歩いて測り終えた通路の長さを、分けて記録します。そして、もう知っている道だけを使って帰り道を計算する、小さな探索ゲームを作ります。

完成版の遊び方洞窟を実際に歩いて、通路の長さを測ります。二つの目印の部屋を訪ねたら、測った道を通って家へ帰ります。

迷子の地図屋の元の完成版。自動操作によるPyxelの実描画画面。
元の完成版で、洞窟Bを測ったあとの画面を自動で描いたものです。下の講座は洞窟Aから始めます。04と05では、決まった3つの洞窟(固定盤面)を扱います。
PLAY / 試験公開

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

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

完成版の主な操作:矢印キーで一歩進む、Zで家にいるときに提出する、Xで戻す、Rでやり直す、Pで説明、1〜3で洞窟を選ぶ、Sで記録

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

画面に「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
知っておくとよいこと
変数・if・for・関数 / tuple・list・dict と、書き換えない dataclass / 幅優先探索(近い順に広げて調べる方法)と heapq は03で説明します
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
矢印:1回押すと一歩進む。操作キーを全部離すまで、次の入力は受け付けない / Z:04と05で、Hから提出する / X:05で、状態全体を Undo(元に戻す) / 1・2・3:04と05で、決まった洞窟A・B・Cに切り替えて、新しく探索を始める / R:同じ洞窟を最初の状態に戻す。新しく押したキーを優先する / Esc:終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_walking.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

部屋から部屋へ飛ばず、通路を一歩ずつ

通路(グラフの辺)の途中にいることを表す状態。

最初は、決まった形の洞窟Aで、一歩ずつ動く仕組みを作ります。部屋はグラフ(点と線のつながりを表す図)の頂点(点)で、通路は辺(線)です。Hから右どなりのAまでは四歩です。矢印を1回押すとAへ飛ぶのではなく、通路の途中を三回通ってから着きます。この段階には、地図の知識、目印、提出、Undo(元に戻す)はありません。画面に出している edge と offset は、プログラムの中の状態を読むための、教材用の表示です。

State は node・edge・offset・steps の4つの値を持ちます。部屋にいるときは、node に部屋の名前、edge に-1、offset に0を入れます。通路にいるときは、node を空の文字列にします。そして、edge に辺の番号、offset に辺の最初の端からの歩数を入れます。こうして、同じ現在地を2通りの書き方で表さないようにします。たとえば、HとAの間の通路を一歩進んだところは、node=''、edge=0、offset=1 です。

部屋にいるときは、その部屋につながる辺と、出口の方向を調べます。通路にいるときは、その辺の向きと、その逆向きにだけ進めます。直角に曲がろうとしても壁になり、State と歩数はそのままです。辺の端に着いたときにだけ、部屋の名前に戻します。length は、決まった座標から辺の長さを求めます。ただし、台帳(記録の一覧)に書く長さを、絵の長さで測っているわけではありません。

dataclass(frozen=True) を使うので、元の State を直接書き換えず、新しい State を返します。draw は表示するだけで、move は呼びません。controller は、一度キーが押されたら、操作キーが全部離されるまで待ちます。そのため、長押しや、斜めに2つ同時に押したときに、余分に歩くことはありません。

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

01_walking.py / 注目する部分
    node = u if offset == 0 else v if offset == length(level, i) else ''

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_walking.py
macOS / Linux
.venv/bin/python 01_walking.py
画面で見ること右を押して離すと、CORRIDOR(通路)、EDGE0、OFFSET1、STEPS1 になります。右を合計四回押すと、ROOM A、STEPS4 です。そこから左を四回押すと H に戻り、STEPS8 です。通路の中で上や下を押しても壁なので、歩数は増えません。
  • 部屋(頂点)と、通路(辺)の途中を分けて表す
  • 通路の中では、直角に曲がる移動を受け付けない
  • 1回押すごとに、一歩だけ進める
つまずきやすいところnode が空なら、通路の中にいます。edge=-1 のまま辺のリストを参照すると、Pythonでは最後の辺を指してしまいます。そのため、まず部屋にいるのか通路にいるのかを分けて考えます。また、offset は、これまでに歩いた合計の歩数ではありません。
少し変えてみよう

State の「node: str = 'H'」の1か所だけを「node: str = 'A'」に変えて、起動してみましょう。Aから左を四回押すと H に着き、STEPS4 になることを確かめます。元に戻すと、また H から始まります。これは01だけの練習です。02からは origin や seen も合わせて整える必要があるので、この1行の変更を、そのまま次の段階に持ちこまないでください。

01_walking.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(199行)
01_walking.py / 全文
# MIT License
# 
# Copyright (c) 2026
# 
# 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.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""迷子の地図屋・教材1/5。各ファイルを単独で実行できます。"""
from dataclasses import dataclass, replace

DIRECTIONS = {'R': (1, 0), 'D': (0, 1), 'L': (-1, 0), 'U': (0, -1)}

@dataclass(frozen=True)
class Level:
    seed: str
    title: str
    nodes: tuple
    edges: tuple
    marks: tuple

    def points(self):
        return {n: (x, y) for n, x, y in self.nodes}

LEVELS = (Level('A-01-v1', '四角の向こう', (('H', 0, 0), ('A', 4, 0), ('B', 4, 3), ('C', 0, 3), ('D', 7, 0), ('E', 7, 3)), (('H', 'A'), ('A', 'B'), ('B', 'C'), ('C', 'H'), ('A', 'D'), ('D', 'E'), ('E', 'B')), ('B', 'E')),)

def length(l, i):
    p = l.points()
    u, v = l.edges[i]
    return abs(p[u][0] - p[v][0]) + abs(p[u][1] - p[v][1])

def edge_dir(l, i, n):
    p = l.points()
    u, v = l.edges[i]
    other = v if n == u else u
    x, y = p[n]
    xx, yy = p[other]
    return next((k for k, (dx, dy) in DIRECTIONS.items() if (xx - x > 0) - (xx - x < 0) == dx and (yy - y > 0) - (yy - y < 0) == dy))

@dataclass(frozen=True)
class State:
    node: str = 'H'
    edge: int = -1
    offset: int = 0
    steps: int = 0

def initial(level):
    return State()

def move(s, level, action):
    if action not in DIRECTIONS:
        return s, 'invalid'
    if s.node:
        options = [i for i, ends in enumerate(level.edges)
                   if s.node in ends and edge_dir(level, i, s.node) == action]
        if not options:
            return s, 'wall'
        i = options[0]
        u, v = level.edges[i]
        offset = 1 if s.node == u else length(level, i) - 1
    else:
        i = s.edge
        u, v = level.edges[i]
        forward = edge_dir(level, i, u)
        if action == forward:
            offset = s.offset + 1
        elif DIRECTIONS[action] == tuple(-d for d in DIRECTIONS[forward]):
            offset = s.offset - 1
        else:
            return s, 'wall'
    node = u if offset == 0 else v if offset == length(level, i) else ''
    return State(node, -1 if node else i, 0 if node else offset, s.steps + 1), 'walk'

STAGE = 1
WIDTH, HEIGHT = 640, 440

class App:
    def __init__(self, level_index=0):
        self.level_index = level_index
        self.reset()

    @property
    def level(self):
        return LEVELS[self.level_index]

    def reset(self):
        self.state = initial(self.level)
        self.message = 'ready'
        self.gated = False

    def commit(self, action):
        self.state, self.message = move(self.state, self.level, action)

    def control(self, pressed, held):
        # A new reset wins even during a release gate. Held R is not a new reset.
        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))
        self.gated = True
        arrows = {'UP': 'U', 'RIGHT': 'R', 'DOWN': 'D', 'LEFT': 'L'}
        if key in arrows: self.commit(arrows[key])

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(14, 12, 'MEASURED MAP / STAGE '+str(STAGE)+' / '+self.level.seed, 10)
        p.text(14, 31, 'ARROW = ONE WALKING STEP / RELEASE BEFORE NEXT KEY', 7)
        p.text(14, 50, 'STEPS '+bounded(s.steps), 11)
        p.rectb(14, 78, 612, 210, 5)
        p.text(32, 99, 'FIRST: WALK ALONG AN EDGE, NOT ROOM TO ROOM', 6)
        p.text(32, 133, 'ROOM '+s.node if s.node else 'INSIDE A CORRIDOR', 10)
        p.text(32, 164, 'EDGE '+str(s.edge)+' / OFFSET '+str(s.offset), 7)
        p.text(32, 197, 'OFFSET IS FROM THE FIRST END OF AN INTERNAL EDGE.', 13)
        p.text(32, 225, 'NO MAP KNOWLEDGE OR GOAL IN THIS PRACTICE YET.', 13)
        p.text(32, 252, 'TRY RIGHT x4: H -> CORRIDOR -> A. LEFT x4: HOME.', 11)
        p.text(14, 367, self.message.upper(), 10)
        p.text(14, 390, 'ARROWS: WALK / R: RESET SAME BANK / ESC: QUIT', 7)
        p.text(14, 408, 'A FIXED BANK / NO RANDOM GENERATION', 13)

def bounded(n):
    return str(n) if n <= 9999 else '9999+'

def make_demo_keys():
    return ('RIGHT',)*4

def main(headless=False, screenshot=None, demo=False, bank=1):
    import pyxel as p
    p.init(WIDTH, HEIGHT, title='Measured map lesson '+str(STAGE), fps=30,
           display_scale=1, headless=headless)
    app = App(bank-1)
    sequence = make_demo_keys() if demo else ()
    keys = {k: getattr(p, 'KEY_'+k) for k in
            ('UP','RIGHT','DOWN','LEFT','Z','X','R','1','2','3')}
    limit = max(20, 2*len(sequence)+8)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count >= limit:
            if screenshot:
                app.draw(p)
                p.screen.save(screenshot, 1)
            p.quit()
            return
        if headless:
            if demo:
                index = p.frame_count//2
                down = {sequence[index]} if p.frame_count%2 == 0 and index < len(sequence) else set()
                app.control(down, down)
        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
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--bank', type=int, choices=range(1, len(LEVELS)+1), default=1)
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--screenshot')
    args = parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:
        parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:
        parser.error('screenshot requires headless mode')
    if args.screenshot and (Path(args.screenshot).suffix.lower() != '.png' or
                            not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):
        parser.error('use a new .png in an existing directory')
    # Pyxel may change cwd during initialization. Anchor the validated path first.
    screenshot = str(Path(args.screenshot).resolve()) if args.screenshot else None
    main(args.headless_smoke, screenshot, args.demo_checkpoint, args.bank)
STEP 02 / 05

歩いた最遠地点と、測り終えた長さを分ける

一部だけ分かった知識を、減らさずに増やしていく。

一歩ずつ動く仕組みに、seen・measured・lower を加えます。seen は、もう訪れた部屋の順番です。measured は、辺の一方の端から別の端まで歩き切って分かった、辺の全長です。lower は、途中まで歩いて分かった下限(少なくともこれだけはある、という長さ)です。まだ、近くを見る視界(局所視界)や、帰り道の計算はありません。画面右の台帳は、訪れた順に部屋を並べた一覧です。欄の位置や幅は、距離も、洞窟の本当の座標(世界座標)も表していません。

Hから右へ二歩進むと、Hの右の出口は「>=2」(2歩以上)、行き先は「?」(まだ分からない)になります。左へ二歩戻っても、「少なくとも二歩ある」という知識は消えません。low[i]=max(low[i],progress) で、いちばん遠くまで行けた距離を保ちます。progress は、直前に出た部屋 origin から、今いる場所までの距離です。offset は、辺の最初の端からの距離です。辺の反対側から入ったときは、length-offset で progress を求めます。

別の部屋まで歩き切ったときだけ、measured[i]=progress を記入して、その部屋を seen に加えます。右・左・右・右・右・右と足踏みしながらAに着くと、歩いた合計は6歩でも、辺の全長は4です。途中で行ったり来たりした分を距離に足してしまうと、まちがった地図になります。同じ辺を反対向きに歩いても、測り終えた辺の数は1つのままです。

ledger は、画面に渡す情報をしぼる関数です。まだ訪れていない部屋は、台帳の行にしません。まだ測っていない出口の target(行き先)は「?」にします。訪れた部屋の出口の方向は分かります。でも、まだ測っていない出口の先にある部屋の名前や全長は返しません。画面の側が Level の座標を直接使って描くと、この「知っていること」と「本当の洞窟」の境目が壊れてしまいます。

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

02_measurement.py / 注目する部分
    low[i] = max(low[i], progress)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_measurement.py
macOS / Linux
.venv/bin/python 02_measurement.py
画面で見ること右・右・左・左と押すと H に戻り、STEPS4 です。Hの右の欄は「R >=2 -> ?」、MEASURED0/7(測った辺は7本中0本)です。続けて右を四回押すと A に着きます。右の欄は「R 4 -> A」、Aの左の欄は「L 4 -> H」になります。
  • 下限と全長を別々に記録する
  • 途中で行ったり来たりした分を、全長に足さない
  • まだ測っていない行き先は「?」にする
つまずきやすいところ戻るたびに lower を progress で上書きすると、もう分かっていた長さを忘れてしまいます。measured に合計の歩数 steps を入れると、寄り道した分まで辺の長さに混ざってしまいます。
少し変えてみよう

わざとまちがえて試してみましょう。「low[i] = max(low[i], progress)」を、一時的に「low[i] = progress」に変えます。右・右・左と押すと、「>=1」に減ってしまいます。元の max の形に直して同じ操作をすると、「>=2」のままです。まちがえた版は次の段階に持ちこまず、必ず元に戻します。

02_measurement.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(253行)
02_measurement.py / 全文
# MIT License
# 
# Copyright (c) 2026
# 
# 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.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""迷子の地図屋・教材2/5。各ファイルを単独で実行できます。"""
from dataclasses import dataclass, replace

DIRECTIONS = {'R': (1, 0), 'D': (0, 1), 'L': (-1, 0), 'U': (0, -1)}

@dataclass(frozen=True)
class Level:
    seed: str
    title: str
    nodes: tuple
    edges: tuple
    marks: tuple

    def points(self):
        return {n: (x, y) for n, x, y in self.nodes}

LEVELS = (Level('A-01-v1', '四角の向こう', (('H', 0, 0), ('A', 4, 0), ('B', 4, 3), ('C', 0, 3), ('D', 7, 0), ('E', 7, 3)), (('H', 'A'), ('A', 'B'), ('B', 'C'), ('C', 'H'), ('A', 'D'), ('D', 'E'), ('E', 'B')), ('B', 'E')),)

def length(l, i):
    p = l.points()
    u, v = l.edges[i]
    return abs(p[u][0] - p[v][0]) + abs(p[u][1] - p[v][1])

def edge_dir(l, i, n):
    p = l.points()
    u, v = l.edges[i]
    other = v if n == u else u
    x, y = p[n]
    xx, yy = p[other]
    return next((k for k, (dx, dy) in DIRECTIONS.items() if (xx - x > 0) - (xx - x < 0) == dx and (yy - y > 0) - (yy - y < 0) == dy))

@dataclass(frozen=True)
class State:
    node: str = 'H'
    edge: int = -1
    offset: int = 0
    origin: str = 'H'
    progress: int = 0
    seen: tuple = ('H',)
    measured: tuple = (0,) * 7
    lower: tuple = (0,) * 7
    steps: int = 0

def initial(level):
    return State(measured=(0,) * len(level.edges), lower=(0,) * len(level.edges))

def move(s, level, action):
    if action not in DIRECTIONS:
        return s, 'invalid'
    if s.node:
        options = [i for i, ends in enumerate(level.edges)
                   if s.node in ends and edge_dir(level, i, s.node) == action]
        if not options:
            return s, 'wall'
        i = options[0]
        u, v = level.edges[i]
        origin = s.node
        offset = 1 if origin == u else length(level, i) - 1
        progress = 1
    else:
        i = s.edge
        u, v = level.edges[i]
        origin = s.origin
        forward = edge_dir(level, i, u)
        if action == forward:
            offset = s.offset + 1
        elif DIRECTIONS[action] == tuple(-d for d in DIRECTIONS[forward]):
            offset = s.offset - 1
        else:
            return s, 'wall'
        progress = offset if origin == u else length(level, i) - offset
    # lower is knowledge: reversing does not erase the farthest reached distance.
    low = list(s.lower)
    low[i] = max(low[i], progress)
    measured = list(s.measured)
    seen = s.seen
    node = u if offset == 0 else v if offset == length(level, i) else ''
    if node and node != origin:
        measured[i] = progress
        if node not in seen:
            seen = seen + (node,)
    new = State(node, -1 if node else i, 0 if node else offset,
                node or origin, 0 if node else progress, seen,
                tuple(measured), tuple(low), s.steps + 1)
    return new, 'measured' if node and node != origin else 'walk'

def ledger(s, level):
    """Only visited rooms and measured endpoints; no remote coordinates."""
    rows = []
    for node in s.seen:
        exits = []
        for i, (u, v) in enumerate(level.edges):
            if node not in (u, v):
                continue
            distance = s.measured[i]
            other = v if node == u else u
            exits.append({'direction': edge_dir(level, i, node),
                          'target': other if distance else '?',
                          'distance': distance,
                          'lower': 0 if distance else s.lower[i]})
        exits.sort(key=lambda item: 'URDL'.index(item['direction']))
        rows.append({'node': node, 'mark': False, 'exits': exits})
    return rows

STAGE = 2
WIDTH, HEIGHT = 640, 440

class App:
    def __init__(self, level_index=0):
        self.level_index = level_index
        self.reset()

    @property
    def level(self):
        return LEVELS[self.level_index]

    def reset(self):
        self.state = initial(self.level)
        self.message = 'ready'
        self.gated = False

    def commit(self, action):
        self.state, self.message = move(self.state, self.level, action)

    def control(self, pressed, held):
        # A new reset wins even during a release gate. Held R is not a new reset.
        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))
        self.gated = True
        arrows = {'UP': 'U', 'RIGHT': 'R', 'DOWN': 'D', 'LEFT': 'L'}
        if key in arrows: self.commit(arrows[key])

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(14, 12, 'MEASURED MAP / STAGE '+str(STAGE)+' / '+self.level.seed, 10)
        p.text(14, 31, 'ARROW = ONE WALKING STEP / RELEASE BEFORE NEXT KEY', 7)
        p.text(14, 50, 'STEPS '+bounded(s.steps), 11)
        rows = ledger(s, self.level)
        p.rectb(12, 91, 224, 218, 5)
        p.text(23, 108, 'MEASUREMENT ONLY', 6)
        p.text(23, 133, 'ROOM '+s.node if s.node else 'FROM '+s.origin, 10)
        p.text(23, 154, 'PROGRESS '+str(s.progress), 7)
        p.text(23, 184, '? = NOT MEASURED', 13)
        p.text(23, 202, '>=N = AT LEAST N STEPS', 13)
        p.text(23, 229, 'TURNING BACK KEEPS >=N', 11)
        p.text(23, 255, 'NO LOCAL VIEW YET', 6)
        p.text(253, 74, 'LEDGER: LAYOUT IS NOT DISTANCE', 6)
        for row_index, row in enumerate(rows):
            y = 94 + row_index*34
            p.rect(252, y, 374, 30, 3 if row['node'] == s.node else 2)
            p.text(258, y+5, row['node']+('*' if row['mark'] else ''), 10)
            for exit_index, item in enumerate(row['exits']):
                number = str(item['distance']) if item['distance'] else (
                    '>='+str(item['lower']) if item['lower'] else '?')
                label = item['direction']+' '+number+' -> '+item['target']
                p.text(283+exit_index*108, y+5, label, 11 if item['distance'] else 13)
            p.text(283, y+18, 'UNMEASURED TARGETS STAY ?', 13)
        p.text(14, 325, 'ROOM '+s.node if s.node else 'FROM '+s.origin+' / PROGRESS '+str(s.progress), 10)
        p.text(253, 325, 'MEASURED '+str(sum(bool(x) for x in s.measured))+'/'+str(len(self.level.edges)), 11)
        p.text(14, 367, self.message.upper(), 10)
        p.text(14, 390, 'ARROWS: WALK / R: RESET SAME BANK / ESC: QUIT', 7)
        p.text(14, 408, 'A FIXED BANK / NO RANDOM GENERATION', 13)

def bounded(n):
    return str(n) if n <= 9999 else '9999+'

def make_demo_keys():
    return ('RIGHT','RIGHT','LEFT','LEFT')

def main(headless=False, screenshot=None, demo=False, bank=1):
    import pyxel as p
    p.init(WIDTH, HEIGHT, title='Measured map lesson '+str(STAGE), fps=30,
           display_scale=1, headless=headless)
    app = App(bank-1)
    sequence = make_demo_keys() if demo else ()
    keys = {k: getattr(p, 'KEY_'+k) for k in
            ('UP','RIGHT','DOWN','LEFT','Z','X','R','1','2','3')}
    limit = max(20, 2*len(sequence)+8)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count >= limit:
            if screenshot:
                app.draw(p)
                p.screen.save(screenshot, 1)
            p.quit()
            return
        if headless:
            if demo:
                index = p.frame_count//2
                down = {sequence[index]} if p.frame_count%2 == 0 and index < len(sequence) else set()
                app.control(down, down)
        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
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--bank', type=int, choices=range(1, len(LEVELS)+1), default=1)
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--screenshot')
    args = parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:
        parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:
        parser.error('screenshot requires headless mode')
    if args.screenshot and (Path(args.screenshot).suffix.lower() != '.png' or
                            not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):
        parser.error('use a new .png in an existing directory')
    # Pyxel may change cwd during initialization. Anchor the validated path first.
    screenshot = str(Path(args.screenshot).resolve()) if args.screenshot else None
    main(args.headless_smoke, screenshot, args.demo_checkpoint, args.bank)
STEP 03 / 05

二歩先だけ見て、知っている道で帰る

近くだけを見る探索(局所探索)と、長さを考えた最短路(重み付き最短路)の役割のちがい。

画面の左に、今いる場所から歩ける二歩以内の範囲を加えます。画面の下には、知っている道だけでHへ戻るときの歩数を加えます。どちらも探索ですが、調べるものがちがいます。視界は、実際の一歩ぶんのマスを、幅優先探索(近い順に広げて調べる方法)でたどります。帰り道は、全長を実際に測った部屋と部屋の間の辺を、距離を考えながらたどります。まだ目印と提出はありません。

view は、プログラムの中の座標から通路のマスを作り、今いる場所から距離0、1、2の順に広げます。返す x と y は、今いる場所からの差(相対座標)です。遠くの世界座標は返しません。二歩先にまだ訪れていない部屋が見えても、丸い部屋として見えるだけで、その名前は None です。台帳にも、まだ訪れていない部屋は加えません。「見えること」と、「歩き切って測り、つながりが確定すること」を分けます。

known_return は、measured が0でない辺だけを使って、隣接リスト(それぞれの部屋がどこへつながっているかの一覧)を作ります。そして、heapq を使って、合計の距離がいちばん小さい候補から順に取り出します。これをダイクストラ法(Dijkstra法)といいます。通る辺の本数が少ない道と、歩数が短い道は、同じとは限りません。そのため、台帳の行の数や、絵の線の長さで帰り道を決めたりはしません。

まだ測っていない通路の途中にいるときは、歩いて来た origin へ progress 歩で戻れることだけを、出発点の候補にします。もう測った通路の途中なら、両方の端までの距離が分かるので、2つの候補を入れます。知っている道での最短の帰り道が、洞窟全体での最短の道とは限りません。まだ測っていない近道があっても候補に入れないことが、このゲームのいちばん大事なところです。

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

03_known_return.py / 注目する部分
    starts = [(0, s.node)] if s.node else [(s.progress, s.origin)]

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_known_return.py
macOS / Linux
.venv/bin/python 03_known_return.py
画面で見ること右を四回、下を三回押すと B に着き、STEPS7、MEASURED2/7、KNOWN RETURN7(知っている道での帰り道は7歩)です。さらに左を一回押すと、まだ測っていない通路の途中なので、帰り道は8になります。左側に部屋が見えていても、まだ知らない近道なので、帰り道には使いません。
  • 二歩までの視界を、今いる場所からの相対座標で返す
  • 実際に測った辺だけを使って、最短路を求める
  • 通路の途中にいるときも、安全な帰り道を求める
つまずきやすいところ帰り道(return)の計算に Level のすべての辺を入れると、まだ探していない近道の情報がもれてしまいます。local の x と y は相対座標で、台帳の並びは訪れた順です。この2種類の位置の意味を混ぜないようにします。
少し変えてみよう

「LOCAL_RADIUS = 2」を「LOCAL_RADIUS = 1」に変えて起動してみましょう。Hで明るく表示されるマスは5個から3個に減り、表示も LOCAL:1 になります。知っている道での帰り道0と、測った状態は変わりません。見える範囲だけが変わったことを確かめたら、2に戻します。

03_known_return.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(315行)
03_known_return.py / 全文
# MIT License
# 
# Copyright (c) 2026
# 
# 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.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""迷子の地図屋・教材3/5。各ファイルを単独で実行できます。"""
import heapq

from dataclasses import dataclass, replace

DIRECTIONS = {'R': (1, 0), 'D': (0, 1), 'L': (-1, 0), 'U': (0, -1)}

@dataclass(frozen=True)
class Level:
    seed: str
    title: str
    nodes: tuple
    edges: tuple
    marks: tuple

    def points(self):
        return {n: (x, y) for n, x, y in self.nodes}

LEVELS = (Level('A-01-v1', '四角の向こう', (('H', 0, 0), ('A', 4, 0), ('B', 4, 3), ('C', 0, 3), ('D', 7, 0), ('E', 7, 3)), (('H', 'A'), ('A', 'B'), ('B', 'C'), ('C', 'H'), ('A', 'D'), ('D', 'E'), ('E', 'B')), ('B', 'E')),)

def length(l, i):
    p = l.points()
    u, v = l.edges[i]
    return abs(p[u][0] - p[v][0]) + abs(p[u][1] - p[v][1])

def edge_dir(l, i, n):
    p = l.points()
    u, v = l.edges[i]
    other = v if n == u else u
    x, y = p[n]
    xx, yy = p[other]
    return next((k for k, (dx, dy) in DIRECTIONS.items() if (xx - x > 0) - (xx - x < 0) == dx and (yy - y > 0) - (yy - y < 0) == dy))

@dataclass(frozen=True)
class State:
    node: str = 'H'
    edge: int = -1
    offset: int = 0
    origin: str = 'H'
    progress: int = 0
    seen: tuple = ('H',)
    measured: tuple = (0,) * 7
    lower: tuple = (0,) * 7
    steps: int = 0

def initial(level):
    return State(measured=(0,) * len(level.edges), lower=(0,) * len(level.edges))

def move(s, level, action):
    if action not in DIRECTIONS:
        return s, 'invalid'
    if s.node:
        options = [i for i, ends in enumerate(level.edges)
                   if s.node in ends and edge_dir(level, i, s.node) == action]
        if not options:
            return s, 'wall'
        i = options[0]
        u, v = level.edges[i]
        origin = s.node
        offset = 1 if origin == u else length(level, i) - 1
        progress = 1
    else:
        i = s.edge
        u, v = level.edges[i]
        origin = s.origin
        forward = edge_dir(level, i, u)
        if action == forward:
            offset = s.offset + 1
        elif DIRECTIONS[action] == tuple(-d for d in DIRECTIONS[forward]):
            offset = s.offset - 1
        else:
            return s, 'wall'
        progress = offset if origin == u else length(level, i) - offset
    # lower is knowledge: reversing does not erase the farthest reached distance.
    low = list(s.lower)
    low[i] = max(low[i], progress)
    measured = list(s.measured)
    seen = s.seen
    node = u if offset == 0 else v if offset == length(level, i) else ''
    if node and node != origin:
        measured[i] = progress
        if node not in seen:
            seen = seen + (node,)
    new = State(node, -1 if node else i, 0 if node else offset,
                node or origin, 0 if node else progress, seen,
                tuple(measured), tuple(low), s.steps + 1)
    return new, 'measured' if node and node != origin else 'walk'


def position(l, s):
    if s.node:
        return l.points()[s.node]
    u, v = l.edges[s.edge]
    x, y = l.points()[u]
    dx, dy = DIRECTIONS[edge_dir(l, s.edge, u)]
    return (x + dx * s.offset, y + dy * s.offset)

def known_return(s, l):
    adj = {n: [] for n in s.seen}
    for i, d in enumerate(s.measured):
        if d:
            u, v = l.edges[i]
            adj[u].append((v, d))
            adj[v].append((u, d))
    starts = [(0, s.node)] if s.node else [(s.progress, s.origin)]
    if not s.node and s.measured[s.edge]:
        u, v = l.edges[s.edge]
        starts = [(s.offset, u), (s.measured[s.edge] - s.offset, v)]
    heap = starts[:]
    heapq.heapify(heap)
    best = {}
    while heap:
        d, n = heapq.heappop(heap)
        if n in best:
            continue
        best[n] = d
        if n == 'H':
            return d
        for nxt, cost in adj[n]:
            heapq.heappush(heap, (d + cost, nxt))
    return None

def view(s, l):
    """The only map UI interface. Unknown endpoints and world coordinates omitted."""
    rows = []
    for n in s.seen:
        exits = []
        for i, e in enumerate(l.edges):
            if n not in e:
                continue
            d = s.measured[i]
            other = e[1] if n == e[0] else e[0]
            exits.append({'direction': edge_dir(l, i, n), 'target': other if d else '?', 'distance': d, 'lower': 0 if d else s.lower[i]})
        exits.sort(key=lambda e: 'URDL'.index(e['direction']))
        rows.append({'node': n, 'mark': False, 'exits': exits})
    tiles = {}
    p = l.points()
    for i, (u, v) in enumerate(l.edges):
        x, y = p[u]
        dx, dy = DIRECTIONS[edge_dir(l, i, u)]
        for k in range(length(l, i) + 1):
            tiles[x + k * dx, y + k * dy] = True
    at = position(l, s)
    front = [(at, 0)]
    visited = {at}
    local = []
    while front:
        (x, y), d = front.pop(0)
        node = next((n for n, px, py in l.nodes if (px, py) == (x, y)), None)
        local.append({'x': x - at[0], 'y': y - at[1], 'node': node if node in s.seen else None, 'mark': False, 'room': node is not None})
        if d < LOCAL_RADIUS:
            for dx, dy in DIRECTIONS.values():
                pos = (x + dx, y + dy)
                if pos in tiles and pos not in visited:
                    visited.add(pos)
                    front.append((pos, d + 1))
    return {'rows': rows, 'local': local, 'steps': s.steps, 'count': sum((bool(d) for d in s.measured)), 'progress': s.progress, 'origin': s.origin, 'return': known_return(s, l)}

STAGE = 3
LOCAL_RADIUS = 2
WIDTH, HEIGHT = 640, 440

class App:
    def __init__(self, level_index=0):
        self.level_index = level_index
        self.reset()

    @property
    def level(self):
        return LEVELS[self.level_index]

    def reset(self):
        self.state = initial(self.level)
        self.message = 'ready'
        self.gated = False

    def commit(self, action):
        self.state, self.message = move(self.state, self.level, action)

    def control(self, pressed, held):
        # A new reset wins even during a release gate. Held R is not a new reset.
        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))
        self.gated = True
        arrows = {'UP': 'U', 'RIGHT': 'R', 'DOWN': 'D', 'LEFT': 'L'}
        if key in arrows: self.commit(arrows[key])

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(14, 12, 'MEASURED MAP / STAGE '+str(STAGE)+' / '+self.level.seed, 10)
        p.text(14, 31, 'ARROW = ONE WALKING STEP / RELEASE BEFORE NEXT KEY', 7)
        p.text(14, 50, 'STEPS '+bounded(s.steps), 11)
        projected = view(s, self.level)
        rows = projected['rows']
        p.text(14, 74, 'LOCAL: '+str(LOCAL_RADIUS)+' WALKING STEPS', 6)
        p.rectb(12, 91, 224, 218, 5)
        cx, cy = 124, 198
        for cell in projected['local']:
            x, y = cx + cell['x']*36, cy + cell['y']*36
            p.rect(x-15, y-15, 31, 31, 3 if cell['room'] else 5)
            p.rectb(x-15, y-15, 31, 31, 6)
            if cell['room']: p.circb(x, y, 10, 10 if cell['mark'] else 13)
            if cell['node']: p.text(x-2, y-3, cell['node'], 7)
            if cell['mark']: p.text(x+8, y-14, '*', 10)
        p.circ(cx, cy+5, 4, 10)
        p.tri(cx-4, cy+2, cx+4, cy+2, cx, cy-5, 7)
        p.text(20, 291, 'ONLY RELATIVE LOCAL OFFSETS', 13)
        p.text(253, 74, 'LEDGER: LAYOUT IS NOT DISTANCE', 6)
        for row_index, row in enumerate(rows):
            y = 94 + row_index*34
            p.rect(252, y, 374, 30, 3 if row['node'] == s.node else 2)
            p.text(258, y+5, row['node']+('*' if row['mark'] else ''), 10)
            for exit_index, item in enumerate(row['exits']):
                number = str(item['distance']) if item['distance'] else (
                    '>='+str(item['lower']) if item['lower'] else '?')
                label = item['direction']+' '+number+' -> '+item['target']
                p.text(283+exit_index*108, y+5, label, 11 if item['distance'] else 13)
            p.text(283, y+18, 'UNMEASURED TARGETS STAY ?', 13)
        p.text(14, 325, 'ROOM '+s.node if s.node else 'FROM '+s.origin+' / PROGRESS '+str(s.progress), 10)
        p.text(253, 325, 'MEASURED '+str(sum(bool(x) for x in s.measured))+'/'+str(len(self.level.edges)), 11)
        p.text(14, 344, 'KNOWN RETURN TO H: '+str(projected['return'])+' STEPS', 6)
        p.text(14, 367, self.message.upper(), 10)
        p.text(14, 390, 'ARROWS: WALK / R: RESET SAME BANK / ESC: QUIT', 7)
        p.text(14, 408, 'A FIXED BANK / NO RANDOM GENERATION', 13)

def bounded(n):
    return str(n) if n <= 9999 else '9999+'

def make_demo_keys():
    return ('RIGHT',)*4+('DOWN',)*3

def main(headless=False, screenshot=None, demo=False, bank=1):
    import pyxel as p
    p.init(WIDTH, HEIGHT, title='Measured map lesson '+str(STAGE), fps=30,
           display_scale=1, headless=headless)
    app = App(bank-1)
    sequence = make_demo_keys() if demo else ()
    keys = {k: getattr(p, 'KEY_'+k) for k in
            ('UP','RIGHT','DOWN','LEFT','Z','X','R','1','2','3')}
    limit = max(20, 2*len(sequence)+8)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count >= limit:
            if screenshot:
                app.draw(p)
                p.screen.save(screenshot, 1)
            p.quit()
            return
        if headless:
            if demo:
                index = p.frame_count//2
                down = {sequence[index]} if p.frame_count%2 == 0 and index < len(sequence) else set()
                app.control(down, down)
        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
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--bank', type=int, choices=range(1, len(LEVELS)+1), default=1)
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--screenshot')
    args = parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:
        parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:
        parser.error('screenshot requires headless mode')
    if args.screenshot and (Path(args.screenshot).suffix.lower() != '.png' or
                            not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):
        parser.error('use a new .png in an existing directory')
    # Pyxel may change cwd during initialization. Anchor the validated path first.
    screenshot = str(Path(args.screenshot).resolve()) if args.screenshot else None
    main(args.headless_smoke, screenshot, args.demo_checkpoint, args.bank)
STEP 04 / 05

目印を二つ記録して、Hで提出する

目標(目印)の記録と、提出できる場所の条件。

ここで、元のゲームのふつうの移動・知識・目印・提出のルールをそろえます。洞窟Aでは、BとEが目印の部屋です。近くの視界に「*」が見えても、実際にその部屋に着くまでは marks に入りません。同じ目印を何度訪ねても、数えるのは1つだけです。2つの目印を訪ねてHへ戻り、Zで提出すると won(成功)です。2つそろった瞬間や、Hに着いた瞬間に、自動で提出することはありません。

H以外の場所でZを押すと、home_only(家でだけ提出できる)として受け付けられず、歩数も tick も変わりません。Hにいれば、目印が足りなくても partial(一部達成)として提出できます。これは危険や時間切れによるものではなく、安全に帰ってきたところまでの成果です。提出は歩くことではないので、steps は増えません。状態が変わった回数を数える時計 tick だけが1増えます。提出したあとは、移動できなくなります。04にはまだ Undo がないので、R でやり直します。

この段階から、1・2・3のキーで、元のゲームの決まった洞窟A・B・Cを選べます。好きな seed(乱数のもとになる値)で作ったり、いくらでも新しい洞窟を作ったりはしません。洞窟を切り替えると新しい探索になり、それまでの知識・歩数・結果は引き継ぎません。R は、今の同じ洞窟を最初の状態に戻します。ファイルに --bank 2 を付けて起動すると、Bから始めることもできます。

すべての辺を測らなくても成功です。洞窟Aで H-A-D-E-B-C-H と一周する20歩は、7本の辺のうち6本を測って成功する例です。すべての情報を集めることを必須にすると、「少ない知識で帰る」という元のゲームの選び方が変わってしまいます。距離を公開して比べるための view 引数はモデルに残していますが、教材の画面はふつうの実測モードだけです。

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

04_delivery.py / 注目する部分
        return (replace(s, tick=s.tick + 1, outcome='won' if len(s.marks) == 2 else 'partial'), 'submit')

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_delivery.py
macOS / Linux
.venv/bin/python 04_delivery.py
画面で見ること洞窟Aで、右七回→下三回→左七回→上三回→Zと押します。H、STEPS20、MARKS2/2、MEASURED6/7、WON(成功)になります。R→Zと押すと、STEPS0、PARTIAL(一部達成)です。Hから右に一歩進んで→Zと押すと、HOME_ONLY です。
  • 目印は、その部屋に着いたときに一度だけ数える
  • Hでだけ提出できる
  • すべての辺を測ることを、成功の条件にしない
つまずきやすいところ目印が2つそろっただけで won にしてはいけません。Hで提出するという条件も必要です。また、partial を、遭難や失敗で無理やり帰らされたことだと考えないでください。自分で選んで、安全に提出した結果です。
少し変えてみよう

R で洞窟Aを最初の状態に戻します。右七回→下三回→左三回→上三回→左四回→Zと押してみましょう。同じ20歩の WON ですが、MEASURED5/7 で、訪れていない C が台帳にない成功です。いちばん少ない歩数や、すべての辺を回ることを競うのではありません。いくつかある正しい帰り方を比べます。

04_delivery.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(330行)
04_delivery.py / 全文
# MIT License
# 
# Copyright (c) 2026
# 
# 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.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""迷子の地図屋・教材4/5。各ファイルを単独で実行できます。"""
import heapq

from dataclasses import dataclass, replace

DIRECTIONS = {'R': (1, 0), 'D': (0, 1), 'L': (-1, 0), 'U': (0, -1)}

@dataclass(frozen=True)
class Level:
    seed: str
    title: str
    nodes: tuple
    edges: tuple
    marks: tuple

    def points(self):
        return {n: (x, y) for n, x, y in self.nodes}

LEVELS = (Level('A-01-v1', '四角の向こう', (('H', 0, 0), ('A', 4, 0), ('B', 4, 3), ('C', 0, 3), ('D', 7, 0), ('E', 7, 3)), (('H', 'A'), ('A', 'B'), ('B', 'C'), ('C', 'H'), ('A', 'D'), ('D', 'E'), ('E', 'B')), ('B', 'E')), Level('B-01-v1', '近そうな長い横道', (('H', 0, 0), ('A', 6, 0), ('B', 6, 2), ('C', 0, 2), ('D', 6, 6), ('E', 0, 6)), (('H', 'A'), ('A', 'B'), ('B', 'C'), ('C', 'H'), ('B', 'D'), ('D', 'E'), ('E', 'C')), ('A', 'D')), Level('C-01-v1', '短い中央の接続', (('H', 0, 0), ('A', 3, 0), ('B', 8, 0), ('C', 0, 4), ('D', 3, 4), ('E', 8, 4)), (('H', 'A'), ('A', 'B'), ('H', 'C'), ('A', 'D'), ('B', 'E'), ('C', 'D'), ('D', 'E')), ('B', 'C')))

def length(l, i):
    p = l.points()
    u, v = l.edges[i]
    return abs(p[u][0] - p[v][0]) + abs(p[u][1] - p[v][1])

def edge_dir(l, i, n):
    p = l.points()
    u, v = l.edges[i]
    other = v if n == u else u
    x, y = p[n]
    xx, yy = p[other]
    return next((k for k, (dx, dy) in DIRECTIONS.items() if (xx - x > 0) - (xx - x < 0) == dx and (yy - y > 0) - (yy - y < 0) == dy))

@dataclass(frozen=True)
class State:
    node: str = 'H'
    edge: int = -1
    offset: int = 0
    origin: str = 'H'
    progress: int = 0
    seen: tuple = ('H',)
    measured: tuple = (0,) * 7
    lower: tuple = (0,) * 7
    marks: tuple = ()
    steps: int = 0
    tick: int = 0
    outcome: str = 'running'

def initial(l):
    return State(measured=(0,) * len(l.edges), lower=(0,) * len(l.edges))

def position(l, s):
    if s.node:
        return l.points()[s.node]
    u, v = l.edges[s.edge]
    x, y = l.points()[u]
    dx, dy = DIRECTIONS[edge_dir(l, s.edge, u)]
    return (x + dx * s.offset, y + dy * s.offset)

def move(s, l, a):
    if s.outcome != 'running':
        return (s, 'ended')
    if a == 'submit':
        if s.node != 'H':
            return (s, 'home_only')
        return (replace(s, tick=s.tick + 1, outcome='won' if len(s.marks) == 2 else 'partial'), 'submit')
    if a not in DIRECTIONS:
        return (s, 'invalid')
    if s.node:
        options = [i for i, e in enumerate(l.edges) if s.node in e and edge_dir(l, i, s.node) == a]
        if not options:
            return (s, 'wall')
        i = options[0]
        u, v = l.edges[i]
        origin = s.node
        offset = 1 if origin == u else length(l, i) - 1
        progress = 1
    else:
        i = s.edge
        u, v = l.edges[i]
        origin = s.origin
        forward = edge_dir(l, i, u)
        if a == forward:
            offset = s.offset + 1
        elif DIRECTIONS[a] == tuple((-d for d in DIRECTIONS[forward])):
            offset = s.offset - 1
        else:
            return (s, 'wall')
        progress = offset if origin == u else length(l, i) - offset
    low = list(s.lower)
    low[i] = max(low[i], progress)
    measured = list(s.measured)
    seen = s.seen
    marks = s.marks
    node = u if offset == 0 else v if offset == length(l, i) else ''
    if node and node != origin:
        measured[i] = progress
        if node not in seen:
            seen = seen + (node,)
        if node in l.marks and node not in marks:
            marks = marks + (node,)
    ns = State(node, -1 if node else i, 0 if node else offset, node or origin, 0 if node else progress, seen, tuple(measured), tuple(low), marks, s.steps + 1, s.tick + 1, s.outcome)
    return (ns, 'measured' if node and node != origin else 'walk')

def known_return(s, l):
    adj = {n: [] for n in s.seen}
    for i, d in enumerate(s.measured):
        if d:
            u, v = l.edges[i]
            adj[u].append((v, d))
            adj[v].append((u, d))
    starts = [(0, s.node)] if s.node else [(s.progress, s.origin)]
    if not s.node and s.measured[s.edge]:
        u, v = l.edges[s.edge]
        starts = [(s.offset, u), (s.measured[s.edge] - s.offset, v)]
    heap = starts[:]
    heapq.heapify(heap)
    best = {}
    while heap:
        d, n = heapq.heappop(heap)
        if n in best:
            continue
        best[n] = d
        if n == 'H':
            return d
        for nxt, cost in adj[n]:
            heapq.heappush(heap, (d + cost, nxt))
    return None

def view(s, l, public_lengths=False):
    """The only map UI interface. Unknown endpoints and world coordinates omitted."""
    rows = []
    for n in s.seen:
        exits = []
        for i, e in enumerate(l.edges):
            if n not in e:
                continue
            d = s.measured[i]
            other = e[1] if n == e[0] else e[0]
            exits.append({'direction': edge_dir(l, i, n), 'target': other if d else '?', 'distance': length(l, i) if public_lengths else d, 'lower': 0 if d or public_lengths else s.lower[i]})
        exits.sort(key=lambda e: 'URDL'.index(e['direction']))
        rows.append({'node': n, 'mark': n in s.marks, 'exits': exits})
    tiles = {}
    p = l.points()
    for i, (u, v) in enumerate(l.edges):
        x, y = p[u]
        dx, dy = DIRECTIONS[edge_dir(l, i, u)]
        for k in range(length(l, i) + 1):
            tiles[x + k * dx, y + k * dy] = True
    at = position(l, s)
    front = [(at, 0)]
    visited = {at}
    local = []
    while front:
        (x, y), d = front.pop(0)
        node = next((n for n, px, py in l.nodes if (px, py) == (x, y)), None)
        local.append({'x': x - at[0], 'y': y - at[1], 'node': node if node in s.seen else None, 'mark': bool(node in l.marks), 'room': node is not None})
        if d < LOCAL_RADIUS:
            for dx, dy in DIRECTIONS.values():
                pos = (x + dx, y + dy)
                if pos in tiles and pos not in visited:
                    visited.add(pos)
                    front.append((pos, d + 1))
    return {'rows': rows, 'local': local, 'steps': s.steps, 'count': sum((bool(d) for d in s.measured)), 'marks': s.marks, 'progress': s.progress, 'origin': s.origin, 'return': known_return(s, l)}


STAGE = 4
LOCAL_RADIUS = 2
WIDTH, HEIGHT = 640, 440

class App:
    def __init__(self, level_index=0):
        self.level_index = level_index
        self.reset()

    @property
    def level(self):
        return LEVELS[self.level_index]

    def reset(self):
        self.state = initial(self.level)
        self.message = 'ready'
        self.gated = False

    def commit(self, action):
        self.state, self.message = move(self.state, self.level, action)

    def control(self, pressed, held):
        # A new reset wins even during a release gate. Held R is not a new reset.
        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))
        self.gated = True
        if key in ('1', '2', '3'):
            self.level_index = int(key) - 1
            self.reset()
            self.gated = True
            return
        arrows = {'UP': 'U', 'RIGHT': 'R', 'DOWN': 'D', 'LEFT': 'L'}
        if key in arrows: self.commit(arrows[key])
        elif key == 'Z': self.commit('submit')

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(14, 12, 'MEASURED MAP / STAGE '+str(STAGE)+' / '+self.level.seed, 10)
        p.text(14, 31, 'ARROW = ONE WALKING STEP / RELEASE BEFORE NEXT KEY', 7)
        p.text(14, 50, 'STEPS '+bounded(s.steps), 11)
        projected = view(s, self.level)
        rows = projected['rows']
        p.text(14, 74, 'LOCAL: '+str(LOCAL_RADIUS)+' WALKING STEPS', 6)
        p.rectb(12, 91, 224, 218, 5)
        cx, cy = 124, 198
        for cell in projected['local']:
            x, y = cx + cell['x']*36, cy + cell['y']*36
            p.rect(x-15, y-15, 31, 31, 3 if cell['room'] else 5)
            p.rectb(x-15, y-15, 31, 31, 6)
            if cell['room']: p.circb(x, y, 10, 10 if cell['mark'] else 13)
            if cell['node']: p.text(x-2, y-3, cell['node'], 7)
            if cell['mark']: p.text(x+8, y-14, '*', 10)
        p.circ(cx, cy+5, 4, 10)
        p.tri(cx-4, cy+2, cx+4, cy+2, cx, cy-5, 7)
        p.text(20, 291, 'ONLY RELATIVE LOCAL OFFSETS', 13)
        p.text(253, 74, 'LEDGER: LAYOUT IS NOT DISTANCE', 6)
        for row_index, row in enumerate(rows):
            y = 94 + row_index*34
            p.rect(252, y, 374, 30, 3 if row['node'] == s.node else 2)
            p.text(258, y+5, row['node']+('*' if row['mark'] else ''), 10)
            for exit_index, item in enumerate(row['exits']):
                number = str(item['distance']) if item['distance'] else (
                    '>='+str(item['lower']) if item['lower'] else '?')
                label = item['direction']+' '+number+' -> '+item['target']
                p.text(283+exit_index*108, y+5, label, 11 if item['distance'] else 13)
            p.text(283, y+18, 'UNMEASURED TARGETS STAY ?', 13)
        p.text(14, 325, 'ROOM '+s.node if s.node else 'FROM '+s.origin+' / PROGRESS '+str(s.progress), 10)
        p.text(253, 325, 'MEASURED '+str(sum(bool(x) for x in s.measured))+'/'+str(len(self.level.edges)), 11)
        p.text(14, 344, 'KNOWN RETURN TO H: '+str(projected['return'])+' STEPS', 6)
        p.text(340, 344, 'MARKS '+str(len(s.marks))+'/2 / '+s.outcome.upper(), 10)
        p.text(14, 367, self.message.upper(), 10)
        p.text(14, 390, 'ARROWS: WALK / R: RESET SAME BANK / ESC: QUIT', 7)
        p.text(14, 408, 'Z: SUBMIT AT H / 1-3: FIXED BANKS', 7)

def bounded(n):
    return str(n) if n <= 9999 else '9999+'

def make_demo_keys():
    return ('RIGHT',)*7+('DOWN',)*3+('LEFT',)*7+('UP',)*3+('Z',)

def main(headless=False, screenshot=None, demo=False, bank=1):
    import pyxel as p
    p.init(WIDTH, HEIGHT, title='Measured map lesson '+str(STAGE), fps=30,
           display_scale=1, headless=headless)
    app = App(bank-1)
    sequence = make_demo_keys() if demo else ()
    keys = {k: getattr(p, 'KEY_'+k) for k in
            ('UP','RIGHT','DOWN','LEFT','Z','X','R','1','2','3')}
    limit = max(20, 2*len(sequence)+8)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count >= limit:
            if screenshot:
                app.draw(p)
                p.screen.save(screenshot, 1)
            p.quit()
            return
        if headless:
            if demo:
                index = p.frame_count//2
                down = {sequence[index]} if p.frame_count%2 == 0 and index < len(sequence) else set()
                app.control(down, down)
        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
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--bank', type=int, choices=range(1, len(LEVELS)+1), default=1)
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--screenshot')
    args = parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:
        parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:
        parser.error('screenshot requires headless mode')
    if args.screenshot and (Path(args.screenshot).suffix.lower() != '.png' or
                            not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):
        parser.error('use a new .png in an existing directory')
    # Pyxel may change cwd during initialization. Anchor the validated path first.
    screenshot = str(Path(args.screenshot).resolve()) if args.screenshot else None
    main(args.headless_smoke, screenshot, args.demo_checkpoint, args.bank)
STEP 05 / 05

一歩を戻す時、地図の知識も戻す

状態全体を保存する履歴と、状態を読むだけの描画。

Timeline に、今の State と history(履歴)を持たせます。move で State が変わったときだけ、変わる前の State を履歴に加えます。壁にぶつかったとき、受け付けない入力、H以外での提出、終わったあとの歩行では、履歴は増えません。X の Undo は move より先に処理するので、提出したあとでも戻って続けられます。履歴が空のときは no_undo(戻せない)です。

位置だけを戻す Undo では足りません。初めてHからAに着いた四歩目を戻すと、Aを訪れた記録と、HとAの間の全長4も消えます。そして、三歩目の「>=3」に戻らなければなりません。目印を取ったこと・歩数・tick・結果も同じ State に入っているので、保存した State をそのまま戻せば済みます。lower がふだん減らないのは、探索を先に進めている間の話です。過去に戻る Undo なら、減るのが正しいのです。

Hでの partial や won を X で戻すと、提出する直前の running(探索中)に戻ります。もう一度Zを押せば、同じ結果をもう一度出せます。R と洞窟の切り替えは、履歴も消して新しく探索を始める操作なので、Undo では戻せません。Redo(やり直し)、ファイルへの保存、無制限の自動追跡は加えません。

これで、元のゲームのふつうのルール(モデル)の中心部分と同じになります。自動テストでは、決まった3つの洞窟について、状態・すべての履歴・理由・view を、元のゲームから取り出した3,779個の状態の移り変わり(遷移)と照らし合わせます。これは決まった数の操作を確かめただけです。あらゆる操作の組み合わせを全部調べたわけでも、人が遊んで楽しいと確かめたわけでもありません。元のゲームの表紙、一時停止、記録の保存、比較の切り替え、日本語の文字を使った画面は、完成版のプロジェクトを読む次の課題です。

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

05_undo.py / 注目する部分
    return (Timeline(ns, t.history + (t.state,)), reason) if ns != t.state else (t, reason)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_undo.py
macOS / Linux
.venv/bin/python 05_undo.py
画面で見ること右を四回押すと A に着き、測った辺は1/7です。X を押すと、通路の三歩目に戻ります。STEPS3、HISTORY3 で、右の出口は「>=3 -> ?」です。R→Zと押すと PARTIAL になり、キーを離して X を押すと、最初の RUNNING、STEPS0 に戻ります。
  • 変わる前の State を履歴に入れる
  • 位置と知識をいっしょに巻き戻す
  • 提出したあとも Undo できる
つまずきやすいところhistory に新しい State を保存すると、Undo しても戻りません。変わる前の状態を加えます。また、知識を State の外にある、書き換えられる辞書(dict)に置くと、位置だけが巻き戻って、地図は未来のままになってしまいます。
少し変えてみよう

R→右→右と押すと「>=2」になります。X を1回押すと一歩目の「>=1」に、もう1回 X を押すと、Hのまだ測っていない「?」に戻ります。続けて X を押すと、NO_UNDO(戻せない)で何も変わりません。次に、04の成功した操作→Z→X→Zと押して、同じ20歩の WON をもう一度出してみましょう。

05_undo.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(347行)
05_undo.py / 全文
# MIT License
# 
# Copyright (c) 2026
# 
# 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.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""迷子の地図屋・教材5/5。各ファイルを単独で実行できます。"""
import heapq

from dataclasses import dataclass, replace

DIRECTIONS = {'R': (1, 0), 'D': (0, 1), 'L': (-1, 0), 'U': (0, -1)}

@dataclass(frozen=True)
class Level:
    seed: str
    title: str
    nodes: tuple
    edges: tuple
    marks: tuple

    def points(self):
        return {n: (x, y) for n, x, y in self.nodes}

LEVELS = (Level('A-01-v1', '四角の向こう', (('H', 0, 0), ('A', 4, 0), ('B', 4, 3), ('C', 0, 3), ('D', 7, 0), ('E', 7, 3)), (('H', 'A'), ('A', 'B'), ('B', 'C'), ('C', 'H'), ('A', 'D'), ('D', 'E'), ('E', 'B')), ('B', 'E')), Level('B-01-v1', '近そうな長い横道', (('H', 0, 0), ('A', 6, 0), ('B', 6, 2), ('C', 0, 2), ('D', 6, 6), ('E', 0, 6)), (('H', 'A'), ('A', 'B'), ('B', 'C'), ('C', 'H'), ('B', 'D'), ('D', 'E'), ('E', 'C')), ('A', 'D')), Level('C-01-v1', '短い中央の接続', (('H', 0, 0), ('A', 3, 0), ('B', 8, 0), ('C', 0, 4), ('D', 3, 4), ('E', 8, 4)), (('H', 'A'), ('A', 'B'), ('H', 'C'), ('A', 'D'), ('B', 'E'), ('C', 'D'), ('D', 'E')), ('B', 'C')))

def length(l, i):
    p = l.points()
    u, v = l.edges[i]
    return abs(p[u][0] - p[v][0]) + abs(p[u][1] - p[v][1])

def edge_dir(l, i, n):
    p = l.points()
    u, v = l.edges[i]
    other = v if n == u else u
    x, y = p[n]
    xx, yy = p[other]
    return next((k for k, (dx, dy) in DIRECTIONS.items() if (xx - x > 0) - (xx - x < 0) == dx and (yy - y > 0) - (yy - y < 0) == dy))

@dataclass(frozen=True)
class State:
    node: str = 'H'
    edge: int = -1
    offset: int = 0
    origin: str = 'H'
    progress: int = 0
    seen: tuple = ('H',)
    measured: tuple = (0,) * 7
    lower: tuple = (0,) * 7
    marks: tuple = ()
    steps: int = 0
    tick: int = 0
    outcome: str = 'running'

def initial(l):
    return State(measured=(0,) * len(l.edges), lower=(0,) * len(l.edges))

def position(l, s):
    if s.node:
        return l.points()[s.node]
    u, v = l.edges[s.edge]
    x, y = l.points()[u]
    dx, dy = DIRECTIONS[edge_dir(l, s.edge, u)]
    return (x + dx * s.offset, y + dy * s.offset)

def move(s, l, a):
    if s.outcome != 'running':
        return (s, 'ended')
    if a == 'submit':
        if s.node != 'H':
            return (s, 'home_only')
        return (replace(s, tick=s.tick + 1, outcome='won' if len(s.marks) == 2 else 'partial'), 'submit')
    if a not in DIRECTIONS:
        return (s, 'invalid')
    if s.node:
        options = [i for i, e in enumerate(l.edges) if s.node in e and edge_dir(l, i, s.node) == a]
        if not options:
            return (s, 'wall')
        i = options[0]
        u, v = l.edges[i]
        origin = s.node
        offset = 1 if origin == u else length(l, i) - 1
        progress = 1
    else:
        i = s.edge
        u, v = l.edges[i]
        origin = s.origin
        forward = edge_dir(l, i, u)
        if a == forward:
            offset = s.offset + 1
        elif DIRECTIONS[a] == tuple((-d for d in DIRECTIONS[forward])):
            offset = s.offset - 1
        else:
            return (s, 'wall')
        progress = offset if origin == u else length(l, i) - offset
    low = list(s.lower)
    low[i] = max(low[i], progress)
    measured = list(s.measured)
    seen = s.seen
    marks = s.marks
    node = u if offset == 0 else v if offset == length(l, i) else ''
    if node and node != origin:
        measured[i] = progress
        if node not in seen:
            seen = seen + (node,)
        if node in l.marks and node not in marks:
            marks = marks + (node,)
    ns = State(node, -1 if node else i, 0 if node else offset, node or origin, 0 if node else progress, seen, tuple(measured), tuple(low), marks, s.steps + 1, s.tick + 1, s.outcome)
    return (ns, 'measured' if node and node != origin else 'walk')

def known_return(s, l):
    adj = {n: [] for n in s.seen}
    for i, d in enumerate(s.measured):
        if d:
            u, v = l.edges[i]
            adj[u].append((v, d))
            adj[v].append((u, d))
    starts = [(0, s.node)] if s.node else [(s.progress, s.origin)]
    if not s.node and s.measured[s.edge]:
        u, v = l.edges[s.edge]
        starts = [(s.offset, u), (s.measured[s.edge] - s.offset, v)]
    heap = starts[:]
    heapq.heapify(heap)
    best = {}
    while heap:
        d, n = heapq.heappop(heap)
        if n in best:
            continue
        best[n] = d
        if n == 'H':
            return d
        for nxt, cost in adj[n]:
            heapq.heappush(heap, (d + cost, nxt))
    return None

def view(s, l, public_lengths=False):
    """The only map UI interface. Unknown endpoints and world coordinates omitted."""
    rows = []
    for n in s.seen:
        exits = []
        for i, e in enumerate(l.edges):
            if n not in e:
                continue
            d = s.measured[i]
            other = e[1] if n == e[0] else e[0]
            exits.append({'direction': edge_dir(l, i, n), 'target': other if d else '?', 'distance': length(l, i) if public_lengths else d, 'lower': 0 if d or public_lengths else s.lower[i]})
        exits.sort(key=lambda e: 'URDL'.index(e['direction']))
        rows.append({'node': n, 'mark': n in s.marks, 'exits': exits})
    tiles = {}
    p = l.points()
    for i, (u, v) in enumerate(l.edges):
        x, y = p[u]
        dx, dy = DIRECTIONS[edge_dir(l, i, u)]
        for k in range(length(l, i) + 1):
            tiles[x + k * dx, y + k * dy] = True
    at = position(l, s)
    front = [(at, 0)]
    visited = {at}
    local = []
    while front:
        (x, y), d = front.pop(0)
        node = next((n for n, px, py in l.nodes if (px, py) == (x, y)), None)
        local.append({'x': x - at[0], 'y': y - at[1], 'node': node if node in s.seen else None, 'mark': bool(node in l.marks), 'room': node is not None})
        if d < LOCAL_RADIUS:
            for dx, dy in DIRECTIONS.values():
                pos = (x + dx, y + dy)
                if pos in tiles and pos not in visited:
                    visited.add(pos)
                    front.append((pos, d + 1))
    return {'rows': rows, 'local': local, 'steps': s.steps, 'count': sum((bool(d) for d in s.measured)), 'marks': s.marks, 'progress': s.progress, 'origin': s.origin, 'return': known_return(s, l)}


@dataclass(frozen=True)
class Timeline:
    state: State = State()
    history: tuple = ()

def step(t, l, a):
    if a == 'undo':
        return (Timeline(t.history[-1], t.history[:-1]), 'undo') if t.history else (t, 'no_undo')
    ns, reason = move(t.state, l, a)
    return (Timeline(ns, t.history + (t.state,)), reason) if ns != t.state else (t, reason)

STAGE = 5
LOCAL_RADIUS = 2
WIDTH, HEIGHT = 640, 440

class App:
    def __init__(self, level_index=0):
        self.level_index = level_index
        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.message = 'ready'
        self.gated = False

    def commit(self, action):
        self.timeline, self.message = step(self.timeline, self.level, action)

    def control(self, pressed, held):
        # A new reset wins even during a release gate. Held R is not a new reset.
        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))
        self.gated = True
        if key in ('1', '2', '3'):
            self.level_index = int(key) - 1
            self.reset()
            self.gated = True
            return
        arrows = {'UP': 'U', 'RIGHT': 'R', 'DOWN': 'D', 'LEFT': 'L'}
        if key in arrows: self.commit(arrows[key])
        elif key == 'Z': self.commit('submit')
        elif key == 'X': self.commit('undo')

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(14, 12, 'MEASURED MAP / STAGE '+str(STAGE)+' / '+self.level.seed, 10)
        p.text(14, 31, 'ARROW = ONE WALKING STEP / RELEASE BEFORE NEXT KEY', 7)
        p.text(14, 50, 'STEPS '+bounded(s.steps), 11)
        projected = view(s, self.level)
        rows = projected['rows']
        p.text(14, 74, 'LOCAL: '+str(LOCAL_RADIUS)+' WALKING STEPS', 6)
        p.rectb(12, 91, 224, 218, 5)
        cx, cy = 124, 198
        for cell in projected['local']:
            x, y = cx + cell['x']*36, cy + cell['y']*36
            p.rect(x-15, y-15, 31, 31, 3 if cell['room'] else 5)
            p.rectb(x-15, y-15, 31, 31, 6)
            if cell['room']: p.circb(x, y, 10, 10 if cell['mark'] else 13)
            if cell['node']: p.text(x-2, y-3, cell['node'], 7)
            if cell['mark']: p.text(x+8, y-14, '*', 10)
        p.circ(cx, cy+5, 4, 10)
        p.tri(cx-4, cy+2, cx+4, cy+2, cx, cy-5, 7)
        p.text(20, 291, 'ONLY RELATIVE LOCAL OFFSETS', 13)
        p.text(253, 74, 'LEDGER: LAYOUT IS NOT DISTANCE', 6)
        for row_index, row in enumerate(rows):
            y = 94 + row_index*34
            p.rect(252, y, 374, 30, 3 if row['node'] == s.node else 2)
            p.text(258, y+5, row['node']+('*' if row['mark'] else ''), 10)
            for exit_index, item in enumerate(row['exits']):
                number = str(item['distance']) if item['distance'] else (
                    '>='+str(item['lower']) if item['lower'] else '?')
                label = item['direction']+' '+number+' -> '+item['target']
                p.text(283+exit_index*108, y+5, label, 11 if item['distance'] else 13)
            p.text(283, y+18, 'UNMEASURED TARGETS STAY ?', 13)
        p.text(14, 325, 'ROOM '+s.node if s.node else 'FROM '+s.origin+' / PROGRESS '+str(s.progress), 10)
        p.text(253, 325, 'MEASURED '+str(sum(bool(x) for x in s.measured))+'/'+str(len(self.level.edges)), 11)
        p.text(14, 344, 'KNOWN RETURN TO H: '+str(projected['return'])+' STEPS', 6)
        p.text(340, 344, 'MARKS '+str(len(s.marks))+'/2 / '+s.outcome.upper(), 10)
        p.text(488, 50, 'HISTORY '+bounded(len(self.timeline.history)), 6)
        p.text(14, 367, self.message.upper(), 10)
        p.text(14, 390, 'ARROWS: WALK / R: RESET SAME BANK / ESC: QUIT', 7)
        p.text(14, 408, 'Z: SUBMIT AT H / 1-3: FIXED BANKS / X: WHOLE-STATE UNDO', 7)

def bounded(n):
    return str(n) if n <= 9999 else '9999+'

def make_demo_keys():
    return ('RIGHT',)*4+('X',)

def main(headless=False, screenshot=None, demo=False, bank=1):
    import pyxel as p
    p.init(WIDTH, HEIGHT, title='Measured map lesson '+str(STAGE), fps=30,
           display_scale=1, headless=headless)
    app = App(bank-1)
    sequence = make_demo_keys() if demo else ()
    keys = {k: getattr(p, 'KEY_'+k) for k in
            ('UP','RIGHT','DOWN','LEFT','Z','X','R','1','2','3')}
    limit = max(20, 2*len(sequence)+8)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count >= limit:
            if screenshot:
                app.draw(p)
                p.screen.save(screenshot, 1)
            p.quit()
            return
        if headless:
            if demo:
                index = p.frame_count//2
                down = {sequence[index]} if p.frame_count%2 == 0 and index < len(sequence) else set()
                app.control(down, down)
        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
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--bank', type=int, choices=range(1, len(LEVELS)+1), default=1)
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--screenshot')
    args = parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:
        parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:
        parser.error('screenshot requires headless mode')
    if args.screenshot and (Path(args.screenshot).suffix.lower() != '.png' or
                            not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):
        parser.error('use a new .png in an existing directory')
    # Pyxel may change cwd during initialization. Anchor the validated path first.
    screenshot = str(Path(args.screenshot).resolve()) if args.screenshot else None
    main(args.headless_smoke, screenshot, args.demo_checkpoint, args.bank)
AFTER STEP 05

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

決まった3つの洞窟を使います。矢印キーで一歩ずつ歩いて通路を測り、反対の端まで歩き切った辺だけ、全長とつながる先を確定します。途中まで歩いて分かった下限と、二歩までの近くの視界は分けて扱います。帰り道は、知っている辺と、歩いた部分だけで計算します。目印を2つ訪ねて、Hで提出します。Undo は、知識もいっしょに戻します。

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

比べる点5ステップの教材元の完成版
段階01は一歩ずつの移動だけ。02は下限と測定。03は二歩の視界と、知っている道での帰り道まで。04は提出、05で状態全体の Undo。01〜03は洞窟Aに固定。すべての機能と、決まった洞窟A・B・C、記録の保存と比較を最初から使える。
画面と操作英字と数字だけの画面。R と Esc はすぐに効く。同時押しは受け付けず、キーを全部離すまで待つ。04と05は、数字キーと --bank で決まった洞窟を選ぶ。日本語の文字、表紙・一時停止・前回の結果、比較の切り替え、記録の保存。同時に押したときは、元のゲームの優先順で処理する。
再現している範囲04と05は、ふつうのルール(モデル)と、距離を公開する view を残している。公開して比べるモードを画面で切り替える操作と、保存・再生の決まった手順(プロトコル)は、まだ作っていない。3,779個の決まった遷移などで比べている。形をそろえたJSON(正規化JSON)、盤面のハッシュ(状態を短い値にまとめたもの)、意味ごとにまとめた入力、tick、最後の状態を保存し、再生して照らし合わせる。
権利とテスト元のゲームの MIT・NOTO・Pyxel の権利の文書を残している。文字は Pyxel の標準の ASCII(英数字)で、字形のデータは使わない。本物の Pyxel で、音を出さずに自動で描画できることを確かめている。同梱している Noto をもとにした日本語の文字を使う。以前の元のゲームの品質確認(QA)と、今回の教材の QA は、別々に記録している。

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

  1. model.py の move:origin・offset・progress と下限
  2. model.py の known_return:2つの出発点と、実際に測った辺だけを使う探索
  3. model.py の view:近くの範囲だけを映す処理と、まだ知らない辺の先の部屋を隠す仕組み
  4. model.py の step・record・replay:状態全体と、そのつじつまが合っているか(整合性)
  5. app.py:表紙・一時停止・比較・記録保存で、どの入力を受け付けるか
自分のゲームに変えるなら

最初は、数字かルールを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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。

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