完成サンプルをブラウザーで遊ぶ
この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。
完成版の主な操作:矢印キーで一歩進む、Zで家にいるときに提出する、Xで戻す、Rでやり直す、Pで説明、1〜3で洞窟を選ぶ、Sで記録
ボタンを押すと、ゲームの読み込みが始まります。
画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。
うまく表示されないとき
ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。
本当の洞窟のデータと、自分が知っている地図を分ける。
いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、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:終了
- 難易度の目安
- 中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
- 学習順に迷ったら
- おすすめの学ぶ順番を見る
コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。
教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。
部屋から部屋へ飛ばず、通路を一歩ずつ
通路(グラフの辺)の途中にいることを表す状態。
最初は、決まった形の洞窟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 の一部です。前のステップから追加・変更したところだけを抜き出しています。
node = u if offset == 0 else v if offset == length(level, i) else ''実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 01_walking.py.venv/bin/python 01_walking.py- 部屋(頂点)と、通路(辺)の途中を分けて表す
- 通路の中では、直角に曲がる移動を受け付けない
- 1回押すごとに、一歩だけ進める
node が空なら、通路の中にいます。edge=-1 のまま辺のリストを参照すると、Pythonでは最後の辺を指してしまいます。そのため、まず部屋にいるのか通路にいるのかを分けて考えます。また、offset は、これまでに歩いた合計の歩数ではありません。State の「node: str = 'H'」の1か所だけを「node: str = 'A'」に変えて、起動してみましょう。Aから左を四回押すと H に着き、STEPS4 になることを確かめます。元に戻すと、また H から始まります。これは01だけの練習です。02からは origin や seen も合わせて整える必要があるので、この1行の変更を、そのまま次の段階に持ちこまないでください。
このステップのコード全文(199行)
# 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)歩いた最遠地点と、測り終えた長さを分ける
一部だけ分かった知識を、減らさずに増やしていく。
一歩ずつ動く仕組みに、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 の一部です。前のステップから追加・変更したところだけを抜き出しています。
low[i] = max(low[i], progress)実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 02_measurement.py.venv/bin/python 02_measurement.py- 下限と全長を別々に記録する
- 途中で行ったり来たりした分を、全長に足さない
- まだ測っていない行き先は「?」にする
lower を progress で上書きすると、もう分かっていた長さを忘れてしまいます。measured に合計の歩数 steps を入れると、寄り道した分まで辺の長さに混ざってしまいます。わざとまちがえて試してみましょう。「low[i] = max(low[i], progress)」を、一時的に「low[i] = progress」に変えます。右・右・左と押すと、「>=1」に減ってしまいます。元の max の形に直して同じ操作をすると、「>=2」のままです。まちがえた版は次の段階に持ちこまず、必ず元に戻します。
このステップのコード全文(253行)
# 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)二歩先だけ見て、知っている道で帰る
近くだけを見る探索(局所探索)と、長さを考えた最短路(重み付き最短路)の役割のちがい。
画面の左に、今いる場所から歩ける二歩以内の範囲を加えます。画面の下には、知っている道だけでHへ戻るときの歩数を加えます。どちらも探索ですが、調べるものがちがいます。視界は、実際の一歩ぶんのマスを、幅優先探索(近い順に広げて調べる方法)でたどります。帰り道は、全長を実際に測った部屋と部屋の間の辺を、距離を考えながらたどります。まだ目印と提出はありません。
view は、プログラムの中の座標から通路のマスを作り、今いる場所から距離0、1、2の順に広げます。返す x と y は、今いる場所からの差(相対座標)です。遠くの世界座標は返しません。二歩先にまだ訪れていない部屋が見えても、丸い部屋として見えるだけで、その名前は None です。台帳にも、まだ訪れていない部屋は加えません。「見えること」と、「歩き切って測り、つながりが確定すること」を分けます。
known_return は、measured が0でない辺だけを使って、隣接リスト(それぞれの部屋がどこへつながっているかの一覧)を作ります。そして、heapq を使って、合計の距離がいちばん小さい候補から順に取り出します。これをダイクストラ法(Dijkstra法)といいます。通る辺の本数が少ない道と、歩数が短い道は、同じとは限りません。そのため、台帳の行の数や、絵の線の長さで帰り道を決めたりはしません。
まだ測っていない通路の途中にいるときは、歩いて来た origin へ progress 歩で戻れることだけを、出発点の候補にします。もう測った通路の途中なら、両方の端までの距離が分かるので、2つの候補を入れます。知っている道での最短の帰り道が、洞窟全体での最短の道とは限りません。まだ測っていない近道があっても候補に入れないことが、このゲームのいちばん大事なところです。
下のコードは 03_known_return.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
starts = [(0, s.node)] if s.node else [(s.progress, s.origin)]実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 03_known_return.py.venv/bin/python 03_known_return.py- 二歩までの視界を、今いる場所からの相対座標で返す
- 実際に測った辺だけを使って、最短路を求める
- 通路の途中にいるときも、安全な帰り道を求める
return)の計算に Level のすべての辺を入れると、まだ探していない近道の情報がもれてしまいます。local の x と y は相対座標で、台帳の並びは訪れた順です。この2種類の位置の意味を混ぜないようにします。「LOCAL_RADIUS = 2」を「LOCAL_RADIUS = 1」に変えて起動してみましょう。Hで明るく表示されるマスは5個から3個に減り、表示も LOCAL:1 になります。知っている道での帰り道0と、測った状態は変わりません。見える範囲だけが変わったことを確かめたら、2に戻します。
このステップのコード全文(315行)
# 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)目印を二つ記録して、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 の一部です。前のステップから追加・変更したところだけを抜き出しています。
return (replace(s, tick=s.tick + 1, outcome='won' if len(s.marks) == 2 else 'partial'), 'submit')実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 04_delivery.py.venv/bin/python 04_delivery.py- 目印は、その部屋に着いたときに一度だけ数える
- Hでだけ提出できる
- すべての辺を測ることを、成功の条件にしない
won にしてはいけません。Hで提出するという条件も必要です。また、partial を、遭難や失敗で無理やり帰らされたことだと考えないでください。自分で選んで、安全に提出した結果です。R で洞窟Aを最初の状態に戻します。右七回→下三回→左三回→上三回→左四回→Zと押してみましょう。同じ20歩の WON ですが、MEASURED5/7 で、訪れていない C が台帳にない成功です。いちばん少ない歩数や、すべての辺を回ることを競うのではありません。いくつかある正しい帰り方を比べます。
このステップのコード全文(330行)
# 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)一歩を戻す時、地図の知識も戻す
状態全体を保存する履歴と、状態を読むだけの描画。
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 の一部です。前のステップから追加・変更したところだけを抜き出しています。
return (Timeline(ns, t.history + (t.state,)), reason) if ns != t.state else (t, reason)実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 05_undo.py.venv/bin/python 05_undo.py- 変わる前の
Stateを履歴に入れる - 位置と知識をいっしょに巻き戻す
- 提出したあとも Undo できる
history に新しい State を保存すると、Undo しても戻りません。変わる前の状態を加えます。また、知識を State の外にある、書き換えられる辞書(dict)に置くと、位置だけが巻き戻って、地図は未来のままになってしまいます。R→右→右と押すと「>=2」になります。X を1回押すと一歩目の「>=1」に、もう1回 X を押すと、Hのまだ測っていない「?」に戻ります。続けて X を押すと、NO_UNDO(戻せない)で何も変わりません。次に、04の成功した操作→Z→X→Zと押して、同じ20歩の WON をもう一度出してみましょう。
このステップのコード全文(347行)
# 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)仕組みがわかったら、元の完成版を読んでみよう。
決まった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 は、別々に記録している。 |
完成版のコードで、次に読むところ
model.pyのmove:origin・offset・progressと下限model.pyのknown_return:2つの出発点と、実際に測った辺だけを使う探索model.pyのview:近くの範囲だけを映す処理と、まだ知らない辺の先の部屋を隠す仕組みmodel.pyのstep・record・replay:状態全体と、そのつじつまが合っているか(整合性)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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。
うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る
