完成サンプルをブラウザーで遊ぶ
この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。
完成版の主な操作:方向キーで移動、Spaceで読む・届ける、Zで1手戻す、Pで一時停止、Rで同じ配置からやり直し、Nで別の配置、Tabで地図
ボタンを押すと、ゲームの読み込みが始まります。
画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。
うまく表示されないとき
ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。
本当のマップと、見えている情報を分ける。
いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、5つのステップで作っていきます。どのステップのファイルもそれだけで動くので、動きを確かめてから次へ進みましょう。
- 必要な環境
- Python 3.11以上 / Pyxel 2.9.9
- 知っておくとよいこと
- Python 3.11以上と仮想環境 / 変数、if、for、関数、クラスの基礎 / tuple・辞書の初歩(set とコピーは本文で説明します)
- 教材に入っているもの
- ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
- 完成した教材の操作
- 矢印:1回押すと1歩 / Space:読む・届ける(02は届けるだけ) / R:同じ配置でやり直し / Z:1手戻す(05) / P:休憩・再開(05) / Esc:終了
- 難易度の目安
- 初級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
- 学習順に迷ったら
- おすすめの学ぶ順番を見る
コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。
教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。
小さな道を集合で作る
通れる座標と、配達する人の位置を分ける。
10マスの小さな十字形の道を、座標の集合 CELLS で表します。スタートの位置は (3,5) です。向きごとの差を DIRS から取り、移動先が CELLS に入っているときだけ、pos(位置)と steps(歩数)を更新します。上は y を1減らす向きです。地形は frozenset(書き換えられない集合)なので、遊んでいる途中に書き換えません。まだ出口・記号・配達はありません。元のゲームと同じ「道の外には動けない」を、小さな教材用の地形で先に確かめます。
下のコードは 01_grid.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
destination = (self.pos[0] + dx, self.pos[1] + dy)
if destination not in CELLS:
self.message = '道は見えている。別の方向へ。'
return False実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 01_grid.py.venv/bin/python 01_grid.py- x は右へ、y は下へ行くほど増えるとわかる
- 道の外への入力では、位置も歩数も変わらない
- 地形の集合と、動いている pos を区別できる
pos を更新すると、進めない入力でも道の外に出てしまいます。画面のピクセルの座標とマスの座標も別のものです。point は、描くときだけ24倍・23倍してピクセルに直します。R で戻し、上、上、左、左 と進んで (1,3) に立ちましょう。もう1回左を押しても STEPS が増えない理由を、destination not in CELLS の条件を使って説明してみましょう。
このステップのコード全文(190行)
# MIT License
#
# Copyright (c) 2026 Tidepost contributors
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# Exception: assets/glyphs.json contains rasterized glyph data derived from
# Noto Sans CJK JP (SIL Open Font License 1.1). See assets/NOTO-LICENSE.txt.
# Pyxel is an external dependency under its own MIT license.
# 教材はASCIIと図形だけを使い、Noto由来の字形データは含みません。
"""消える足あと / 教材 1/5。単独で実行できる小さなプログラム。"""
DIRS = {'U': (0, -1), 'D': (0, 1), 'L': (-1, 0), 'R': (1, 0)}
# 地形は座標の集合。記号の表示状態とは分けて持つ。
CELLS = frozenset({(3, y) for y in range(1, 6)} |
{(x, 3) for x in range(1, 7)})
START = (3, 5)
class Model:
def __init__(self):
self.restart()
def restart(self):
self.pos = START
self.steps = 0
self.message = '道を一歩ずつ歩こう。'
def act(self, action):
if action in DIRS:
dx, dy = DIRS[action]
destination = (self.pos[0] + dx, self.pos[1] + dy)
if destination not in CELLS:
self.message = '道は見えている。別の方向へ。'
return False
self.pos = destination
self.steps += 1
self.message = '道を一歩ずつ歩こう。'
return True
return False
def state(self):
return {'pos': list(self.pos), 'steps': self.steps, 'message': self.message}
class Controller:
"""通常起動とheadlessリプレイが同じ入力処理を通る。"""
def __init__(self):
self.model = Model()
def tick(self, held=(), pressed=()):
# btnp相当のpressedだけを見る。押し続けても一歩を繰り返さない。
pressed = set(pressed)
if 'R' in pressed:
self.model.restart()
return
for key, action in INPUTS:
if key in pressed:
self.model.act(action)
break
INPUTS = (('UP', 'U'), ('DOWN', 'D'), ('LEFT', 'L'), ('RIGHT', 'R'))
KEYS = tuple(key for key, _ in INPUTS) + ('R',)
MESSAGES = {
'道を一歩ずつ歩こう。': 'Walk one tile at a time.',
'見晴台で Space。読む時間は自由。': 'SPACE at V. Take as long as you need.',
'道は見えている。別の方向へ。': 'No path there. Try another direction.',
'地形はそのまま。覚えた出口へ。': 'Terrain stays. Walk to the remembered exit.',
'次の一歩まで記号が見える。': 'Symbols stay until your next legal step.',
'全部届いた! この場所でおしまい。': 'ALL DELIVERED! No return trip needed.',
'届いた! 次の荷物へ。': 'Delivered! Find the other target.',
'ここには配達済み。残りの荷物へ。': 'Already delivered here. Find the others.',
'違う出口。ここから続けられる。': 'Wrong exit. You can continue from here.',
'Space は見晴台・板・出口で使う。': 'Use SPACE at V, a board, or an exit.',
'一手戻した。覚えたことは忘れなくてよい。': 'Undone. Your own memory is still yours.',
}
def main(headless=False, replay=None, screenshot=None, report=None):
import json
from pathlib import Path
# Pyxelがcwdを変えても、呼び出した場所に正しく保存する。
screenshot = str(Path(screenshot).resolve()) if screenshot else None
report = str(Path(report).resolve()) if report else None
import pyxel
control = Controller()
frames = 0
events = []
keys = {name: getattr(pyxel, 'KEY_' + name) for name in KEYS}
duration = max(12, len(replay or []))
def update():
nonlocal frames
frames += 1
if headless:
if replay is not None and frames <= len(replay):
event = replay[frames - 1]
control.tick(event.get('held', []), event.get('pressed', []))
events.append({'frame': frames, 'input': event, 'state': control.model.state()})
else:
control.tick([name for name, key in keys.items() if pyxel.btn(key)],
[name for name, key in keys.items() if pyxel.btnp(key)])
def point(p):
return 30 + p[0] * 24, 76 + p[1] * 23
def draw():
m = control.model
pyxel.cls(1)
pyxel.text(12, 10, 'FADING FOOTPRINTS / STEP 1', 10)
pyxel.text(12, 26, f'STEPS {m.steps} / GRID: ALWAYS VISIBLE', 6)
pyxel.text(12, 44, 'SMALL TRAINING GRID / NO DELIVERY YET', 6)
# 先に地形を全部描く。visibleを地形の条件に使わない。
for p in CELLS:
x, y = point(p)
pyxel.rect(x - 10, y - 10, 21, 21, 5)
pyxel.rect(x - 8, y - 8, 17, 17, 13)
x, y = point(START)
pyxel.rectb(x - 9, y - 9, 19, 19, 11)
pyxel.text(x - 2, y - 6, 'V', 11)
x, y = point(m.pos)
# 配達員を少し下へずらし、出口の記号を隠さない。
pyxel.elli(x - 6, y + 17, 13, 3, 0)
pyxel.rect(x - 4, y + 8, 8, 9, 12)
pyxel.rect(x - 3, y + 3, 7, 6, 15)
pyxel.rect(x - 5, y + 1, 10, 3, 12)
pyxel.pset(x + 2, y + 5, 1)
pyxel.rect(335, 72, 139, 201, 0)
pyxel.text(344, 82, 'TERRAIN ALWAYS STAYS', 11)
pyxel.text(344, 101, 'V = START', 6)
pyxel.text(12, 287, MESSAGES[m.message], 7)
pyxel.text(12, 315, 'ARROWS: ONE STEP R: RESTART ESC: QUIT', 6)
if headless and frames >= duration:
if screenshot:
pyxel.screenshot(screenshot, scale=2)
if report:
Path(report).write_text(json.dumps({
'label': 'Synthetic controller input; actual Pyxel headless renderer; silent',
'render_frames': frames, 'model_input_frames': len(events),
'events': events, 'final_state': m.state(),
}, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
pyxel.quit()
pyxel.init(480, 360, title='Fading Footprints - Stage 1', fps=30, headless=headless)
pyxel.run(update, draw)
if __name__ == '__main__':
import argparse
import json
from pathlib import Path
parser = argparse.ArgumentParser(description=__doc__)
modes = parser.add_mutually_exclusive_group()
modes.add_argument('--headless-smoke', action='store_true', help='Render initial state for 12 frames')
modes.add_argument('--headless-replay', help='UTF-8 JSON list of held/pressed input frames')
parser.add_argument('--screenshot', help='Output PNG (headless only)')
parser.add_argument('--report', help='Output UTF-8 JSON (headless only)')
args = parser.parse_args()
headless = args.headless_smoke or args.headless_replay is not None
if not headless and (args.screenshot or args.report):
parser.error('screenshot/report require a headless mode')
replay = None
if args.headless_replay:
replay = json.loads(Path(args.headless_replay).read_text(encoding='utf-8'))
valid = isinstance(replay, list) and all(
isinstance(e, dict) and set(e) <= {'held', 'pressed'} and all(
isinstance(e.get(k, []), list) and all(isinstance(v, str) and v in KEYS for v in e.get(k, []))
for k in ('held', 'pressed')) for e in replay)
if not valid:
parser.error('Replay must be a list of held/pressed dictionaries using supported key names')
main(headless, replay, args.screenshot, args.report)出口で届ける
「着く」と「届ける操作」、「届ける対象」と「届けたもの」を分ける。
同じ地形の上のはしの E1 に星(S)、左はしの E2 に葉(L)、右はしの E3 に月(M)を置きます。TARGETS は届けるべき記号、delivered は実際に届け終わった記号の集合です。出口に立っただけでは届けたことになりません。Space で出口の番号を調べ、その出口の記号が届ける対象かどうかを判定します。対象の月を届ければ終わりです。wrong はまちがえて届けた回数ですが上限はなく、まちがえた出口からそのまま歩けます。この段階だけは、記号をいつも表示しています。
下のコードは 02_delivery.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
@property
def won(self):
return set(TARGETS) <= self.delivered実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 02_delivery.py.venv/bin/python 02_delivery.py- TARGETS と delivered の役割を説明できる
- 出口に着くことと、届けることは別の操作だとわかる
- まちがえて届けても続けられる
pos in EXITS を勝ちの条件にすると、対象ではない出口や、Space を押していないのに着いただけでも終わってしまいます。set(TARGETS) <= delivered は「必要な記号がすべて届いた」という意味です。まず月の出口に立っても Space を押さず、M[..] のままであることを確かめましょう。次に押して終わらせ、矢印キーでは動かないこと、R なら最初に戻ることを試しましょう。
このステップのコード全文(244行)
# MIT License
#
# Copyright (c) 2026 Tidepost contributors
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# Exception: assets/glyphs.json contains rasterized glyph data derived from
# Noto Sans CJK JP (SIL Open Font License 1.1). See assets/NOTO-LICENSE.txt.
# Pyxel is an external dependency under its own MIT license.
# 教材はASCIIと図形だけを使い、Noto由来の字形データは含みません。
"""消える足あと / 教材 2/5。単独で実行できる小さなプログラム。"""
DIRS = {'U': (0, -1), 'D': (0, 1), 'L': (-1, 0), 'R': (1, 0)}
# 地形は座標の集合。記号の表示状態とは分けて持つ。
CELLS = frozenset({(3, y) for y in range(1, 6)} |
{(x, 3) for x in range(1, 7)})
START = (3, 5)
EXITS = ((3, 1), (1, 3), (6, 3))
SYMBOLS = ('星', '葉', '月')
TARGETS = ('月',)
LABELS = {'月': 'M', '葉': 'L', '星': 'S', '花': 'F'}
class Model:
def __init__(self):
self.restart()
def restart(self):
self.pos = START
self.steps = 0
self.delivered = set()
self.mistakes = 0
self.message = '道を一歩ずつ歩こう。'
@property
def won(self):
return set(TARGETS) <= self.delivered
@property
def visible(self):
return set(range(len(SYMBOLS)))
def act(self, action):
if self.won:
return False
if action in DIRS:
dx, dy = DIRS[action]
destination = (self.pos[0] + dx, self.pos[1] + dy)
if destination not in CELLS:
self.message = '道は見えている。別の方向へ。'
return False
self.pos = destination
self.steps += 1
self.message = '道を一歩ずつ歩こう。'
return True
if action != 'S':
return False
if self.pos in EXITS:
index = EXITS.index(self.pos)
symbol = SYMBOLS[index]
if symbol in TARGETS and symbol not in self.delivered:
self.delivered.add(symbol)
self.message = ('全部届いた! この場所でおしまい。' if self.won
else '届いた! 次の荷物へ。')
elif symbol in self.delivered:
self.message = 'ここには配達済み。残りの荷物へ。'
else:
self.mistakes += 1
self.message = '違う出口。ここから続けられる。'
return True
self.message = 'Space は出口で使う。'
return False
def state(self):
return {'pos': list(self.pos), 'steps': self.steps, 'message': self.message,
'delivered': sorted(self.delivered), 'mistakes': self.mistakes,
'visible': sorted(self.visible), 'won': self.won}
class Controller:
"""通常起動とheadlessリプレイが同じ入力処理を通る。"""
def __init__(self):
self.model = Model()
def tick(self, held=(), pressed=()):
# btnp相当のpressedだけを見る。押し続けても一歩を繰り返さない。
pressed = set(pressed)
if 'R' in pressed:
self.model.restart()
return
for key, action in INPUTS:
if key in pressed:
self.model.act(action)
break
INPUTS = (('UP', 'U'), ('DOWN', 'D'), ('LEFT', 'L'), ('RIGHT', 'R'), ('SPACE', 'S'))
KEYS = tuple(key for key, _ in INPUTS) + ('R',)
MESSAGES = {
'道を一歩ずつ歩こう。': 'Walk one tile at a time.',
'見晴台で Space。読む時間は自由。': 'SPACE at V. Take as long as you need.',
'道は見えている。別の方向へ。': 'No path there. Try another direction.',
'地形はそのまま。覚えた出口へ。': 'Terrain stays. Walk to the remembered exit.',
'次の一歩まで記号が見える。': 'Symbols stay until your next legal step.',
'全部届いた! この場所でおしまい。': 'ALL DELIVERED! No return trip needed.',
'届いた! 次の荷物へ。': 'Delivered! Find the other target.',
'ここには配達済み。残りの荷物へ。': 'Already delivered here. Find the others.',
'違う出口。ここから続けられる。': 'Wrong exit. You can continue from here.',
'Space は出口で使う。': 'Use SPACE at an exit.',
'一手戻した。覚えたことは忘れなくてよい。': 'Undone. Your own memory is still yours.',
}
def main(headless=False, replay=None, screenshot=None, report=None):
import json
from pathlib import Path
# Pyxelがcwdを変えても、呼び出した場所に正しく保存する。
screenshot = str(Path(screenshot).resolve()) if screenshot else None
report = str(Path(report).resolve()) if report else None
import pyxel
control = Controller()
frames = 0
events = []
keys = {name: getattr(pyxel, 'KEY_' + name) for name in KEYS}
duration = max(12, len(replay or []))
def update():
nonlocal frames
frames += 1
if headless:
if replay is not None and frames <= len(replay):
event = replay[frames - 1]
control.tick(event.get('held', []), event.get('pressed', []))
events.append({'frame': frames, 'input': event, 'state': control.model.state()})
else:
control.tick([name for name, key in keys.items() if pyxel.btn(key)],
[name for name, key in keys.items() if pyxel.btnp(key)])
def point(p):
return 30 + p[0] * 24, 76 + p[1] * 23
def draw():
m = control.model
pyxel.cls(1)
pyxel.text(12, 10, 'FADING FOOTPRINTS / STEP 2', 10)
target_text = ' '.join(LABELS[t] + ('[OK]' if t in m.delivered else '[..]') for t in TARGETS)
pyxel.text(12, 26, 'DELIVER: ' + target_text, 11)
pyxel.text(220, 26, f'STEPS {m.steps} / WRONG {m.mistakes}', 6)
pyxel.text(12, 44, 'TRAINING GRID / SYMBOLS ALWAYS VISIBLE', 6)
# 先に地形を全部描く。visibleを地形の条件に使わない。
for p in CELLS:
x, y = point(p)
pyxel.rect(x - 10, y - 10, 21, 21, 5)
pyxel.rect(x - 8, y - 8, 17, 17, 13)
x, y = point(START)
pyxel.rectb(x - 9, y - 9, 19, 19, 11)
pyxel.text(x - 2, y - 6, 'V', 11)
for i, p in enumerate(EXITS):
x, y = point(p)
pyxel.rect(x - 10, y - 10, 21, 21, 1)
pyxel.rectb(x - 10, y - 10, 21, 21, 7)
# 消えるのは出口の記号だけ。出口の箱と番号は残る。
text = LABELS[SYMBOLS[i]] if i in m.visible else '?'
pyxel.text(x - 2, y - 5, text, 10 if i in m.visible else 6)
pyxel.text(x - 4, y - 21, f'E{i + 1}', 6)
if SYMBOLS[i] in m.delivered:
pyxel.line(x + 11, y + 5, x + 14, y + 8, 11)
pyxel.line(x + 14, y + 8, x + 19, y + 1, 11)
x, y = point(m.pos)
# 配達員を少し下へずらし、出口の記号を隠さない。
pyxel.elli(x - 6, y + 17, 13, 3, 0)
pyxel.rect(x - 4, y + 8, 8, 9, 12)
pyxel.rect(x - 3, y + 3, 7, 6, 15)
pyxel.rect(x - 5, y + 1, 10, 3, 12)
pyxel.pset(x + 2, y + 5, 1)
pyxel.rect(335, 72, 139, 201, 0)
pyxel.text(344, 82, 'TERRAIN ALWAYS STAYS', 11)
pyxel.text(344, 101, 'V = START', 6)
pyxel.text(344, 116, 'BOX = EXIT', 6)
pyxel.text(344, 135, 'M: MOON L: LEAF', 7)
pyxel.text(344, 147, 'S: STAR F: FLOWER', 7)
pyxel.text(344, 166, 'CHECK = DELIVERED', 11)
pyxel.text(12, 287, MESSAGES[m.message], 7)
if m.won:
pyxel.rectb(10, 278, 464, 25, 11)
pyxel.text(12, 315, 'ARROWS: ONE STEP R: RESTART ESC: QUIT', 6)
pyxel.text(12, 333, 'SPACE: DELIVER', 6)
if headless and frames >= duration:
if screenshot:
pyxel.screenshot(screenshot, scale=2)
if report:
Path(report).write_text(json.dumps({
'label': 'Synthetic controller input; actual Pyxel headless renderer; silent',
'render_frames': frames, 'model_input_frames': len(events),
'events': events, 'final_state': m.state(),
}, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
pyxel.quit()
pyxel.init(480, 360, title='Fading Footprints - Stage 2', fps=30, headless=headless)
pyxel.run(update, draw)
if __name__ == '__main__':
import argparse
import json
from pathlib import Path
parser = argparse.ArgumentParser(description=__doc__)
modes = parser.add_mutually_exclusive_group()
modes.add_argument('--headless-smoke', action='store_true', help='Render initial state for 12 frames')
modes.add_argument('--headless-replay', help='UTF-8 JSON list of held/pressed input frames')
parser.add_argument('--screenshot', help='Output PNG (headless only)')
parser.add_argument('--report', help='Output UTF-8 JSON (headless only)')
args = parser.parse_args()
headless = args.headless_smoke or args.headless_replay is not None
if not headless and (args.screenshot or args.report):
parser.error('screenshot/report require a headless mode')
replay = None
if args.headless_replay:
replay = json.loads(Path(args.headless_replay).read_text(encoding='utf-8'))
valid = isinstance(replay, list) and all(
isinstance(e, dict) and set(e) <= {'held', 'pressed'} and all(
isinstance(e.get(k, []), list) and all(isinstance(v, str) and v in KEYS for v in e.get(k, []))
for k in ('held', 'pressed')) for e in replay)
if not valid:
parser.error('Replay must be a list of held/pressed dictionaries using supported key names')
main(headless, replay, args.screenshot, args.report)見える記号だけを消す
地図の事実と、今見えている情報を分ける。
SYMBOLS は、それぞれの出口に本当にある記号で、ゲーム中に消したり並べかえたりしません。revealed には、今見えている出口の番号だけを入れます。スタート地点の V で Space を押すとすべての番号が入り、次に動けたときに clear(空に)します。立ち止まっている時間に上限はなく、道の外へ押して動けなかったときも消えません。描くときは、地形を全部描いたあと、visible にある出口にだけ M・L・S を描き、ほかは ? にします。届ける対象は、上の欄に残ります。
下のコードは 03_reveal.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
self.pos = destination
self.steps += 1
self.revealed.clear()実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 03_reveal.py.venv/bin/python 03_reveal.py- SYMBOLS は事実、revealed は今の見え方だと説明できる
- 待つだけや、動けない入力では表示が消えない
- 表示が消えても、道と注文は残る
SYMBOLS を clear すると、「見えない」だけでなく、正解のデータそのものがなくなってしまいます。CELLS を visible で絞って描くと、道まで消えてしまいます。見えている情報と、世界の事実は、別々の集合で管理します。V で Space、左、上 の順に押して、左では残り、上では消える理由を説明してみましょう。そのあと、記号が隠れた月の出口へ動き、Space だけで正しく届けられることも確かめます。
このステップのコード全文(261行)
# MIT License
#
# Copyright (c) 2026 Tidepost contributors
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# Exception: assets/glyphs.json contains rasterized glyph data derived from
# Noto Sans CJK JP (SIL Open Font License 1.1). See assets/NOTO-LICENSE.txt.
# Pyxel is an external dependency under its own MIT license.
# 教材はASCIIと図形だけを使い、Noto由来の字形データは含みません。
"""消える足あと / 教材 3/5。単独で実行できる小さなプログラム。"""
DIRS = {'U': (0, -1), 'D': (0, 1), 'L': (-1, 0), 'R': (1, 0)}
# 地形は座標の集合。記号の表示状態とは分けて持つ。
CELLS = frozenset({(3, y) for y in range(1, 6)} |
{(x, 3) for x in range(1, 7)})
START = (3, 5)
EXITS = ((3, 1), (1, 3), (6, 3))
SYMBOLS = ('星', '葉', '月')
TARGETS = ('月',)
LABELS = {'月': 'M', '葉': 'L', '星': 'S', '花': 'F'}
class Model:
def __init__(self):
self.restart()
def restart(self):
self.pos = START
self.steps = 0
self.delivered = set()
self.mistakes = 0
self.revealed = set()
self.reads = 0
self.message = '見晴台で Space。読む時間は自由。'
@property
def won(self):
return set(TARGETS) <= self.delivered
@property
def visible(self):
return set(self.revealed)
def act(self, action):
if self.won:
return False
if action in DIRS:
dx, dy = DIRS[action]
destination = (self.pos[0] + dx, self.pos[1] + dy)
if destination not in CELLS:
self.message = '道は見えている。別の方向へ。'
return False
self.pos = destination
self.steps += 1
self.revealed.clear()
self.message = '地形はそのまま。覚えた出口へ。'
return True
if action != 'S':
return False
if self.pos == START:
scope = set(range(len(SYMBOLS)))
else:
scope = None
if scope is not None:
self.revealed = scope
self.reads += 1
self.message = '次の一歩まで記号が見える。'
return True
if self.pos in EXITS:
index = EXITS.index(self.pos)
symbol = SYMBOLS[index]
self.revealed = {index}
if symbol in TARGETS and symbol not in self.delivered:
self.delivered.add(symbol)
self.message = ('全部届いた! この場所でおしまい。' if self.won
else '届いた! 次の荷物へ。')
elif symbol in self.delivered:
self.message = 'ここには配達済み。残りの荷物へ。'
else:
self.mistakes += 1
self.message = '違う出口。ここから続けられる。'
return True
self.message = 'Space は見晴台・板・出口で使う。'
return False
def state(self):
return {'pos': list(self.pos), 'steps': self.steps, 'message': self.message,
'delivered': sorted(self.delivered), 'mistakes': self.mistakes,
'visible': sorted(self.visible), 'won': self.won,
'revealed': sorted(self.revealed), 'reads': self.reads}
class Controller:
"""通常起動とheadlessリプレイが同じ入力処理を通る。"""
def __init__(self):
self.model = Model()
def tick(self, held=(), pressed=()):
# btnp相当のpressedだけを見る。押し続けても一歩を繰り返さない。
pressed = set(pressed)
if 'R' in pressed:
self.model.restart()
return
for key, action in INPUTS:
if key in pressed:
self.model.act(action)
break
INPUTS = (('UP', 'U'), ('DOWN', 'D'), ('LEFT', 'L'), ('RIGHT', 'R'), ('SPACE', 'S'))
KEYS = tuple(key for key, _ in INPUTS) + ('R',)
MESSAGES = {
'道を一歩ずつ歩こう。': 'Walk one tile at a time.',
'見晴台で Space。読む時間は自由。': 'SPACE at V. Take as long as you need.',
'道は見えている。別の方向へ。': 'No path there. Try another direction.',
'地形はそのまま。覚えた出口へ。': 'Terrain stays. Walk to the remembered exit.',
'次の一歩まで記号が見える。': 'Symbols stay until your next legal step.',
'全部届いた! この場所でおしまい。': 'ALL DELIVERED! No return trip needed.',
'届いた! 次の荷物へ。': 'Delivered! Find the other target.',
'ここには配達済み。残りの荷物へ。': 'Already delivered here. Find the others.',
'違う出口。ここから続けられる。': 'Wrong exit. You can continue from here.',
'Space は見晴台・板・出口で使う。': 'Use SPACE at V, a board, or an exit.',
'一手戻した。覚えたことは忘れなくてよい。': 'Undone. Your own memory is still yours.',
}
def main(headless=False, replay=None, screenshot=None, report=None):
import json
from pathlib import Path
# Pyxelがcwdを変えても、呼び出した場所に正しく保存する。
screenshot = str(Path(screenshot).resolve()) if screenshot else None
report = str(Path(report).resolve()) if report else None
import pyxel
control = Controller()
frames = 0
events = []
keys = {name: getattr(pyxel, 'KEY_' + name) for name in KEYS}
duration = max(12, len(replay or []))
def update():
nonlocal frames
frames += 1
if headless:
if replay is not None and frames <= len(replay):
event = replay[frames - 1]
control.tick(event.get('held', []), event.get('pressed', []))
events.append({'frame': frames, 'input': event, 'state': control.model.state()})
else:
control.tick([name for name, key in keys.items() if pyxel.btn(key)],
[name for name, key in keys.items() if pyxel.btnp(key)])
def point(p):
return 30 + p[0] * 24, 76 + p[1] * 23
def draw():
m = control.model
pyxel.cls(1)
pyxel.text(12, 10, 'FADING FOOTPRINTS / STEP 3', 10)
target_text = ' '.join(LABELS[t] + ('[OK]' if t in m.delivered else '[..]') for t in TARGETS)
pyxel.text(12, 26, 'DELIVER: ' + target_text, 11)
pyxel.text(220, 26, f'STEPS {m.steps} / WRONG {m.mistakes}', 6)
pyxel.text(12, 44, 'TRAINING GRID / HIDE ON LEGAL STEP', 6)
# 先に地形を全部描く。visibleを地形の条件に使わない。
for p in CELLS:
x, y = point(p)
pyxel.rect(x - 10, y - 10, 21, 21, 5)
pyxel.rect(x - 8, y - 8, 17, 17, 13)
x, y = point(START)
pyxel.rectb(x - 9, y - 9, 19, 19, 11)
pyxel.text(x - 2, y - 6, 'V', 11)
for i, p in enumerate(EXITS):
x, y = point(p)
pyxel.rect(x - 10, y - 10, 21, 21, 1)
pyxel.rectb(x - 10, y - 10, 21, 21, 7)
# 消えるのは出口の記号だけ。出口の箱と番号は残る。
text = LABELS[SYMBOLS[i]] if i in m.visible else '?'
pyxel.text(x - 2, y - 5, text, 10 if i in m.visible else 6)
pyxel.text(x - 4, y - 21, f'E{i + 1}', 6)
if SYMBOLS[i] in m.delivered:
pyxel.line(x + 11, y + 5, x + 14, y + 8, 11)
pyxel.line(x + 14, y + 8, x + 19, y + 1, 11)
x, y = point(m.pos)
# 配達員を少し下へずらし、出口の記号を隠さない。
pyxel.elli(x - 6, y + 17, 13, 3, 0)
pyxel.rect(x - 4, y + 8, 8, 9, 12)
pyxel.rect(x - 3, y + 3, 7, 6, 15)
pyxel.rect(x - 5, y + 1, 10, 3, 12)
pyxel.pset(x + 2, y + 5, 1)
pyxel.rect(335, 72, 139, 201, 0)
pyxel.text(344, 82, 'TERRAIN ALWAYS STAYS', 11)
pyxel.text(344, 101, 'V = START / LOOKOUT', 6)
pyxel.text(344, 116, 'BOX = EXIT', 6)
pyxel.text(344, 135, 'M: MOON L: LEAF', 7)
pyxel.text(344, 147, 'S: STAR F: FLOWER', 7)
pyxel.text(344, 166, 'CHECK = DELIVERED', 11)
pyxel.text(344, 185, 'VISIBLE EXITS:', 6)
pyxel.text(344, 197, ' '.join(str(i + 1) for i in sorted(m.visible)) or '(none)', 10)
pyxel.text(344, 216, f'READS {m.reads} / NO TIMER', 6)
pyxel.text(12, 287, MESSAGES[m.message], 7)
if m.won:
pyxel.rectb(10, 278, 464, 25, 11)
pyxel.text(12, 315, 'ARROWS: ONE STEP R: RESTART ESC: QUIT', 6)
pyxel.text(12, 333, 'SPACE: READ / DELIVER', 6)
if headless and frames >= duration:
if screenshot:
pyxel.screenshot(screenshot, scale=2)
if report:
Path(report).write_text(json.dumps({
'label': 'Synthetic controller input; actual Pyxel headless renderer; silent',
'render_frames': frames, 'model_input_frames': len(events),
'events': events, 'final_state': m.state(),
}, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
pyxel.quit()
pyxel.init(480, 360, title='Fading Footprints - Stage 3', fps=30, headless=headless)
pyxel.run(update, draw)
if __name__ == '__main__':
import argparse
import json
from pathlib import Path
parser = argparse.ArgumentParser(description=__doc__)
modes = parser.add_mutually_exclusive_group()
modes.add_argument('--headless-smoke', action='store_true', help='Render initial state for 12 frames')
modes.add_argument('--headless-replay', help='UTF-8 JSON list of held/pressed input frames')
parser.add_argument('--screenshot', help='Output PNG (headless only)')
parser.add_argument('--report', help='Output UTF-8 JSON (headless only)')
args = parser.parse_args()
headless = args.headless_smoke or args.headless_replay is not None
if not headless and (args.screenshot or args.report):
parser.error('screenshot/report require a headless mode')
replay = None
if args.headless_replay:
replay = json.loads(Path(args.headless_replay).read_text(encoding='utf-8'))
valid = isinstance(replay, list) and all(
isinstance(e, dict) and set(e) <= {'held', 'pressed'} and all(
isinstance(e.get(k, []), list) and all(isinstance(v, str) and v in KEYS for v in e.get(k, []))
for k in ('held', 'pressed')) for e in replay)
if not valid:
parser.error('Replay must be a list of held/pressed dictionaries using supported key names')
main(headless, replay, args.screenshot, args.report)案内板の表示範囲を分ける
見える出口の番号と、届ける記号を混同しない。
V の1歩上の (3,4) に、案内板を置きます。STATIONS は「案内板の座標→見せる出口の番号」の辞書で、この案内板は (0,1)、画面では E1 と E2 だけを見せます。注文は月で、月は E3 にあるので、この案内板では注文の出口は見えません。それでも案内板は正しく動いています。見える範囲は「見るため」のデータで、TARGETS は「届けるため」のデータです。Space は、V・案内板・出口の順に場所を調べ、案内板を読んでも delivered は増えません。
下のコードは 04_signboard.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
elif self.pos in STATIONS:
scope = set(STATIONS[self.pos])実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 04_signboard.py.venv/bin/python 04_signboard.py- 0から数える出口の番号と、画面の E1 という表示を変換できる
- 案内板を読んでも、配達の状態と注文は変わらない
- 案内板の範囲に対象が入っていない例を説明できる
scope を TARGETS と同じ変数にすると、案内板を読んだだけで注文が変わったり、いらない星や葉まで届ける対象になったりします。scope は出口の番号の set、TARGETS は記号の tuple です。STATIONS[(3,4)] を (1,2) に変えて、案内板で葉と月が見えることを試してみましょう。注文を変える必要はありません。この改造は教材の見える範囲を変えるもので、元のゲームそのものの変更ではありません。
このステップのコード全文(277行)
# MIT License
#
# Copyright (c) 2026 Tidepost contributors
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# Exception: assets/glyphs.json contains rasterized glyph data derived from
# Noto Sans CJK JP (SIL Open Font License 1.1). See assets/NOTO-LICENSE.txt.
# Pyxel is an external dependency under its own MIT license.
# 教材はASCIIと図形だけを使い、Noto由来の字形データは含みません。
"""消える足あと / 教材 4/5。単独で実行できる小さなプログラム。"""
DIRS = {'U': (0, -1), 'D': (0, 1), 'L': (-1, 0), 'R': (1, 0)}
# 地形は座標の集合。記号の表示状態とは分けて持つ。
CELLS = frozenset({(3, y) for y in range(1, 6)} |
{(x, 3) for x in range(1, 7)})
START = (3, 5)
EXITS = ((3, 1), (1, 3), (6, 3))
SYMBOLS = ('星', '葉', '月')
TARGETS = ('月',)
LABELS = {'月': 'M', '葉': 'L', '星': 'S', '花': 'F'}
STATIONS = {(3, 4): (0, 1)}
class Model:
def __init__(self):
self.restart()
def restart(self):
self.pos = START
self.steps = 0
self.delivered = set()
self.mistakes = 0
self.revealed = set()
self.reads = 0
self.message = '見晴台で Space。読む時間は自由。'
@property
def won(self):
return set(TARGETS) <= self.delivered
@property
def visible(self):
return set(self.revealed)
def act(self, action):
if self.won:
return False
if action in DIRS:
dx, dy = DIRS[action]
destination = (self.pos[0] + dx, self.pos[1] + dy)
if destination not in CELLS:
self.message = '道は見えている。別の方向へ。'
return False
self.pos = destination
self.steps += 1
self.revealed.clear()
self.message = '地形はそのまま。覚えた出口へ。'
return True
if action != 'S':
return False
if self.pos == START:
scope = set(range(len(SYMBOLS)))
elif self.pos in STATIONS:
scope = set(STATIONS[self.pos])
else:
scope = None
if scope is not None:
self.revealed = scope
self.reads += 1
self.message = '次の一歩まで記号が見える。'
return True
if self.pos in EXITS:
index = EXITS.index(self.pos)
symbol = SYMBOLS[index]
self.revealed = {index}
if symbol in TARGETS and symbol not in self.delivered:
self.delivered.add(symbol)
self.message = ('全部届いた! この場所でおしまい。' if self.won
else '届いた! 次の荷物へ。')
elif symbol in self.delivered:
self.message = 'ここには配達済み。残りの荷物へ。'
else:
self.mistakes += 1
self.message = '違う出口。ここから続けられる。'
return True
self.message = 'Space は見晴台・板・出口で使う。'
return False
def state(self):
return {'pos': list(self.pos), 'steps': self.steps, 'message': self.message,
'delivered': sorted(self.delivered), 'mistakes': self.mistakes,
'visible': sorted(self.visible), 'won': self.won,
'revealed': sorted(self.revealed), 'reads': self.reads}
class Controller:
"""通常起動とheadlessリプレイが同じ入力処理を通る。"""
def __init__(self):
self.model = Model()
def tick(self, held=(), pressed=()):
# btnp相当のpressedだけを見る。押し続けても一歩を繰り返さない。
pressed = set(pressed)
if 'R' in pressed:
self.model.restart()
return
for key, action in INPUTS:
if key in pressed:
self.model.act(action)
break
INPUTS = (('UP', 'U'), ('DOWN', 'D'), ('LEFT', 'L'), ('RIGHT', 'R'), ('SPACE', 'S'))
KEYS = tuple(key for key, _ in INPUTS) + ('R',)
MESSAGES = {
'道を一歩ずつ歩こう。': 'Walk one tile at a time.',
'見晴台で Space。読む時間は自由。': 'SPACE at V. Take as long as you need.',
'道は見えている。別の方向へ。': 'No path there. Try another direction.',
'地形はそのまま。覚えた出口へ。': 'Terrain stays. Walk to the remembered exit.',
'次の一歩まで記号が見える。': 'Symbols stay until your next legal step.',
'全部届いた! この場所でおしまい。': 'ALL DELIVERED! No return trip needed.',
'届いた! 次の荷物へ。': 'Delivered! Find the other target.',
'ここには配達済み。残りの荷物へ。': 'Already delivered here. Find the others.',
'違う出口。ここから続けられる。': 'Wrong exit. You can continue from here.',
'Space は見晴台・板・出口で使う。': 'Use SPACE at V, a board, or an exit.',
'一手戻した。覚えたことは忘れなくてよい。': 'Undone. Your own memory is still yours.',
}
def main(headless=False, replay=None, screenshot=None, report=None):
import json
from pathlib import Path
# Pyxelがcwdを変えても、呼び出した場所に正しく保存する。
screenshot = str(Path(screenshot).resolve()) if screenshot else None
report = str(Path(report).resolve()) if report else None
import pyxel
control = Controller()
frames = 0
events = []
keys = {name: getattr(pyxel, 'KEY_' + name) for name in KEYS}
duration = max(12, len(replay or []))
def update():
nonlocal frames
frames += 1
if headless:
if replay is not None and frames <= len(replay):
event = replay[frames - 1]
control.tick(event.get('held', []), event.get('pressed', []))
events.append({'frame': frames, 'input': event, 'state': control.model.state()})
else:
control.tick([name for name, key in keys.items() if pyxel.btn(key)],
[name for name, key in keys.items() if pyxel.btnp(key)])
def point(p):
return 30 + p[0] * 24, 76 + p[1] * 23
def draw():
m = control.model
pyxel.cls(1)
pyxel.text(12, 10, 'FADING FOOTPRINTS / STEP 4', 10)
target_text = ' '.join(LABELS[t] + ('[OK]' if t in m.delivered else '[..]') for t in TARGETS)
pyxel.text(12, 26, 'DELIVER: ' + target_text, 11)
pyxel.text(220, 26, f'STEPS {m.steps} / WRONG {m.mistakes}', 6)
pyxel.text(12, 44, 'TRAINING GRID / LOCAL BOARD SCOPE', 6)
# 先に地形を全部描く。visibleを地形の条件に使わない。
for p in CELLS:
x, y = point(p)
pyxel.rect(x - 10, y - 10, 21, 21, 5)
pyxel.rect(x - 8, y - 8, 17, 17, 13)
x, y = point(START)
pyxel.rectb(x - 9, y - 9, 19, 19, 11)
pyxel.text(x - 2, y - 6, 'V', 11)
for p, scope in STATIONS.items():
x, y = point(p)
for i in scope:
xx, yy = point(EXITS[i])
count = max(abs(xx - x), abs(yy - y)) // 5
for k in range(0, count, 2):
pyxel.pset(x + (xx - x) * k / count,
y + (yy - y) * k / count, 2)
pyxel.rect(x - 8, y - 8, 17, 11, 4)
pyxel.rectb(x - 8, y - 8, 17, 11, 10)
pyxel.line(x, y + 3, x, y + 9, 4)
pyxel.text(x - 2, y - 5, '?', 7)
for i, p in enumerate(EXITS):
x, y = point(p)
pyxel.rect(x - 10, y - 10, 21, 21, 1)
pyxel.rectb(x - 10, y - 10, 21, 21, 7)
# 消えるのは出口の記号だけ。出口の箱と番号は残る。
text = LABELS[SYMBOLS[i]] if i in m.visible else '?'
pyxel.text(x - 2, y - 5, text, 10 if i in m.visible else 6)
pyxel.text(x - 4, y - 21, f'E{i + 1}', 6)
if SYMBOLS[i] in m.delivered:
pyxel.line(x + 11, y + 5, x + 14, y + 8, 11)
pyxel.line(x + 14, y + 8, x + 19, y + 1, 11)
x, y = point(m.pos)
# 配達員を少し下へずらし、出口の記号を隠さない。
pyxel.elli(x - 6, y + 17, 13, 3, 0)
pyxel.rect(x - 4, y + 8, 8, 9, 12)
pyxel.rect(x - 3, y + 3, 7, 6, 15)
pyxel.rect(x - 5, y + 1, 10, 3, 12)
pyxel.pset(x + 2, y + 5, 1)
pyxel.rect(335, 72, 139, 201, 0)
pyxel.text(344, 82, 'TERRAIN ALWAYS STAYS', 11)
pyxel.text(344, 101, 'V = START / LOOKOUT', 6)
pyxel.text(344, 116, 'BOX = EXIT', 6)
pyxel.text(344, 135, 'M: MOON L: LEAF', 7)
pyxel.text(344, 147, 'S: STAR F: FLOWER', 7)
pyxel.text(344, 166, 'CHECK = DELIVERED', 11)
pyxel.text(344, 185, 'VISIBLE EXITS:', 6)
pyxel.text(344, 197, ' '.join(str(i + 1) for i in sorted(m.visible)) or '(none)', 10)
pyxel.text(344, 216, f'READS {m.reads} / NO TIMER', 6)
pyxel.text(344, 236, 'BOARD ? / DOTTED SCOPE', 6)
pyxel.text(12, 287, MESSAGES[m.message], 7)
if m.won:
pyxel.rectb(10, 278, 464, 25, 11)
pyxel.text(12, 315, 'ARROWS: ONE STEP R: RESTART ESC: QUIT', 6)
pyxel.text(12, 333, 'SPACE: READ / DELIVER', 6)
if headless and frames >= duration:
if screenshot:
pyxel.screenshot(screenshot, scale=2)
if report:
Path(report).write_text(json.dumps({
'label': 'Synthetic controller input; actual Pyxel headless renderer; silent',
'render_frames': frames, 'model_input_frames': len(events),
'events': events, 'final_state': m.state(),
}, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
pyxel.quit()
pyxel.init(480, 360, title='Fading Footprints - Stage 4', fps=30, headless=headless)
pyxel.run(update, draw)
if __name__ == '__main__':
import argparse
import json
from pathlib import Path
parser = argparse.ArgumentParser(description=__doc__)
modes = parser.add_mutually_exclusive_group()
modes.add_argument('--headless-smoke', action='store_true', help='Render initial state for 12 frames')
modes.add_argument('--headless-replay', help='UTF-8 JSON list of held/pressed input frames')
parser.add_argument('--screenshot', help='Output PNG (headless only)')
parser.add_argument('--report', help='Output UTF-8 JSON (headless only)')
args = parser.parse_args()
headless = args.headless_smoke or args.headless_replay is not None
if not headless and (args.screenshot or args.report):
parser.error('screenshot/report require a headless mode')
replay = None
if args.headless_replay:
replay = json.loads(Path(args.headless_replay).read_text(encoding='utf-8'))
valid = isinstance(replay, list) and all(
isinstance(e, dict) and set(e) <= {'held', 'pressed'} and all(
isinstance(e.get(k, []), list) and all(isinstance(v, str) and v in KEYS for v in e.get(k, []))
for k in ('held', 'pressed')) for e in replay)
if not valid:
parser.error('Replay must be a list of held/pressed dictionaries using supported key names')
main(headless, replay, args.screenshot, args.report)二通を届け、一手ずつ戻す
元のゲームの地形に広げて、集合のコピーで過去の状態を保存する。
地形を元のゲームの C「二通の届け先」の配置0に置きかえ、注文を月と葉の2通にします。2つの案内板は、E1・E2 と E3・E4 を別々に見せます。うまくいった移動・読む操作・出口での操作の直前に、snapshot(その時点の写し)を history に積み、Z キーで1件戻します。set をコピーして保存するので、あとで clear や add をしても、過去が書き換わりません。もう届けた葉の出口をもう一度調べても、まちがいにはなりません。2通を届ける順番は自由で、最後の出口で終わり、帰り道は必要ありません。P で休憩している間は、R や Z も含めて操作を止めます。
下のコードは 05_two_letters.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
return (self.pos, set(self.delivered), set(self.revealed),
self.steps, self.mistakes, self.reads, self.message)実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 05_two_letters.py.venv/bin/python 05_two_letters.py- 2通をどちらの順番でも届けられる
- 1手戻すと、位置・配達・表示・歩数・まちがいの数・読んだ回数が元に戻る
- 終わったあとも Z と R が使え、P で止めているときは P で再開してから操作する
- 最後の段階で再現している範囲と、省いた機能を言える
snapshot に self.revealed をそのまま入れると、動いたときの clear で、過去に見た情報まで空になってしまいます。なお、人の記憶は Z では戻せません。undo_count は操作した回数の記録なので、戻すたびに増え、R で最初に戻ります。右3、下、Space で最初の案内板を読み、上、右4、下、Space で2つ目を読みます。最初は E1・E2、次は E3・E4 だけが見えることを確かめましょう。続けて、配達・Z・P・休憩の解除・R を試し、位置・表示・配達・履歴のどれが変わるか予想してから観察します。
このステップのコード全文(339行)
# MIT License
#
# Copyright (c) 2026 Tidepost contributors
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# Exception: assets/glyphs.json contains rasterized glyph data derived from
# Noto Sans CJK JP (SIL Open Font License 1.1). See assets/NOTO-LICENSE.txt.
# Pyxel is an external dependency under its own MIT license.
# 教材はASCIIと図形だけを使い、Noto由来の字形データは含みません。
"""消える足あと / 教材 5/5。単独で実行できる小さなプログラム。"""
DIRS = {'U': (0, -1), 'D': (0, 1), 'L': (-1, 0), 'R': (1, 0)}
# 原作C「二通の届け先」・配置0の地形とデータをそのまま使う。
SEGMENTS = (((1, 4), (4, 4)), ((4, 4), (4, 1)),
((4, 4), (8, 4)), ((8, 4), (8, 2)),
((8, 4), (11, 4)), ((8, 4), (8, 7)),
((4, 4), (4, 5)))
def make_cells(segments):
cells = set()
for start, end in segments:
dx = (end[0] > start[0]) - (end[0] < start[0])
dy = (end[1] > start[1]) - (end[1] < start[1])
p = start
cells.add(p)
while p != end:
p = (p[0] + dx, p[1] + dy)
cells.add(p)
return frozenset(cells)
CELLS = make_cells(SEGMENTS)
START = (1, 4)
EXITS = ((4, 1), (8, 2), (11, 4), (8, 7))
SYMBOLS = ('葉', '星', '月', '花')
TARGETS = ('月', '葉')
LABELS = {'月': 'M', '葉': 'L', '星': 'S', '花': 'F'}
STATIONS = {(4, 5): (0, 1), (8, 5): (2, 3)}
LANDMARKS = (((4, 4), 'ring'), ((8, 4), 'flag'))
class Model:
def __init__(self):
self.restart()
def restart(self):
self.pos = START
self.steps = 0
self.delivered = set()
self.mistakes = 0
self.revealed = set()
self.reads = 0
self.history = []
self.undo_count = 0
self.message = '見晴台で Space。読む時間は自由。'
@property
def won(self):
return set(TARGETS) <= self.delivered
@property
def visible(self):
return set(self.revealed)
def snapshot(self):
# setをコピーし、後のclear/addから過去の状態を守る。
return (self.pos, set(self.delivered), set(self.revealed),
self.steps, self.mistakes, self.reads, self.message)
def undo(self):
if not self.history:
return False
(self.pos, self.delivered, self.revealed, self.steps,
self.mistakes, self.reads, self.message) = self.history.pop()
self.undo_count += 1
self.message = '一手戻した。覚えたことは忘れなくてよい。'
return True
def act(self, action):
if self.won:
return False
if action in DIRS:
dx, dy = DIRS[action]
destination = (self.pos[0] + dx, self.pos[1] + dy)
if destination not in CELLS:
self.message = '道は見えている。別の方向へ。'
return False
self.history.append(self.snapshot())
self.pos = destination
self.steps += 1
self.revealed.clear()
self.message = '地形はそのまま。覚えた出口へ。'
return True
if action != 'S':
return False
if self.pos == START:
scope = set(range(len(SYMBOLS)))
elif self.pos in STATIONS:
scope = set(STATIONS[self.pos])
else:
scope = None
if scope is not None:
self.history.append(self.snapshot())
self.revealed = scope
self.reads += 1
self.message = '次の一歩まで記号が見える。'
return True
if self.pos in EXITS:
self.history.append(self.snapshot())
index = EXITS.index(self.pos)
symbol = SYMBOLS[index]
self.revealed = {index}
if symbol in TARGETS and symbol not in self.delivered:
self.delivered.add(symbol)
self.message = ('全部届いた! この場所でおしまい。' if self.won
else '届いた! 次の荷物へ。')
elif symbol in self.delivered:
self.message = 'ここには配達済み。残りの荷物へ。'
else:
self.mistakes += 1
self.message = '違う出口。ここから続けられる。'
return True
self.message = 'Space は見晴台・板・出口で使う。'
return False
def state(self):
return {'pos': list(self.pos), 'steps': self.steps, 'message': self.message,
'delivered': sorted(self.delivered), 'mistakes': self.mistakes,
'visible': sorted(self.visible), 'won': self.won,
'revealed': sorted(self.revealed), 'reads': self.reads,
'undo_count': self.undo_count, 'history_size': len(self.history)}
class Controller:
"""通常起動とheadlessリプレイが同じ入力処理を通る。"""
def __init__(self):
self.model = Model()
self.paused = False
def tick(self, held=(), pressed=()):
# btnp相当のpressedだけを見る。押し続けても一歩を繰り返さない。
pressed = set(pressed)
if 'P' in pressed:
self.paused = not self.paused
return
if self.paused:
return
if 'R' in pressed:
self.model.restart()
return
if 'Z' in pressed:
self.model.undo()
return
for key, action in INPUTS:
if key in pressed:
self.model.act(action)
break
INPUTS = (('UP', 'U'), ('DOWN', 'D'), ('LEFT', 'L'), ('RIGHT', 'R'), ('SPACE', 'S'))
KEYS = tuple(key for key, _ in INPUTS) + ('R', 'Z', 'P',)
MESSAGES = {
'道を一歩ずつ歩こう。': 'Walk one tile at a time.',
'見晴台で Space。読む時間は自由。': 'SPACE at V. Take as long as you need.',
'道は見えている。別の方向へ。': 'No path there. Try another direction.',
'地形はそのまま。覚えた出口へ。': 'Terrain stays. Walk to the remembered exit.',
'次の一歩まで記号が見える。': 'Symbols stay until your next legal step.',
'全部届いた! この場所でおしまい。': 'ALL DELIVERED! No return trip needed.',
'届いた! 次の荷物へ。': 'Delivered! Find the other target.',
'ここには配達済み。残りの荷物へ。': 'Already delivered here. Find the others.',
'違う出口。ここから続けられる。': 'Wrong exit. You can continue from here.',
'Space は見晴台・板・出口で使う。': 'Use SPACE at V, a board, or an exit.',
'一手戻した。覚えたことは忘れなくてよい。': 'Undone. Your own memory is still yours.',
}
def main(headless=False, replay=None, screenshot=None, report=None):
import json
from pathlib import Path
# Pyxelがcwdを変えても、呼び出した場所に正しく保存する。
screenshot = str(Path(screenshot).resolve()) if screenshot else None
report = str(Path(report).resolve()) if report else None
import pyxel
control = Controller()
frames = 0
events = []
keys = {name: getattr(pyxel, 'KEY_' + name) for name in KEYS}
duration = max(12, len(replay or []))
def update():
nonlocal frames
frames += 1
if headless:
if replay is not None and frames <= len(replay):
event = replay[frames - 1]
control.tick(event.get('held', []), event.get('pressed', []))
events.append({'frame': frames, 'input': event, 'state': control.model.state()})
else:
control.tick([name for name, key in keys.items() if pyxel.btn(key)],
[name for name, key in keys.items() if pyxel.btnp(key)])
def point(p):
return 30 + p[0] * 24, 76 + p[1] * 23
def draw():
m = control.model
pyxel.cls(1)
pyxel.text(12, 10, 'FADING FOOTPRINTS / STEP 5', 10)
target_text = ' '.join(LABELS[t] + ('[OK]' if t in m.delivered else '[..]') for t in TARGETS)
pyxel.text(12, 26, 'DELIVER: ' + target_text, 11)
pyxel.text(220, 26, f'STEPS {m.steps} / WRONG {m.mistakes}', 6)
pyxel.text(12, 44, 'ORIGINAL MAP C / ARRANGEMENT 0 / STATIONS', 6)
# 先に地形を全部描く。visibleを地形の条件に使わない。
for p in CELLS:
x, y = point(p)
pyxel.rect(x - 10, y - 10, 21, 21, 5)
pyxel.rect(x - 8, y - 8, 17, 17, 13)
x, y = point(START)
pyxel.rectb(x - 9, y - 9, 19, 19, 11)
pyxel.text(x - 2, y - 6, 'V', 11)
for p, scope in STATIONS.items():
x, y = point(p)
for i in scope:
xx, yy = point(EXITS[i])
count = max(abs(xx - x), abs(yy - y)) // 5
for k in range(0, count, 2):
pyxel.pset(x + (xx - x) * k / count,
y + (yy - y) * k / count, 2)
pyxel.rect(x - 8, y - 8, 17, 11, 4)
pyxel.rectb(x - 8, y - 8, 17, 11, 10)
pyxel.line(x, y + 3, x, y + 9, 4)
pyxel.text(x - 2, y - 5, '?', 7)
for p, shape in LANDMARKS:
x, y = point(p)
if shape == 'ring':
pyxel.circb(x, y, 7, 7)
else:
pyxel.line(x, y - 7, x, y + 7, 7)
pyxel.tri(x - 5, y - 5, x + 5, y - 5, x + 5, y - 1, 7)
for i, p in enumerate(EXITS):
x, y = point(p)
pyxel.rect(x - 10, y - 10, 21, 21, 1)
pyxel.rectb(x - 10, y - 10, 21, 21, 7)
# 消えるのは出口の記号だけ。出口の箱と番号は残る。
text = LABELS[SYMBOLS[i]] if i in m.visible else '?'
pyxel.text(x - 2, y - 5, text, 10 if i in m.visible else 6)
pyxel.text(x - 4, y - 21, f'E{i + 1}', 6)
if SYMBOLS[i] in m.delivered:
pyxel.line(x + 11, y + 5, x + 14, y + 8, 11)
pyxel.line(x + 14, y + 8, x + 19, y + 1, 11)
x, y = point(m.pos)
# 配達員を少し下へずらし、出口の記号を隠さない。
pyxel.elli(x - 6, y + 17, 13, 3, 0)
pyxel.rect(x - 4, y + 8, 8, 9, 12)
pyxel.rect(x - 3, y + 3, 7, 6, 15)
pyxel.rect(x - 5, y + 1, 10, 3, 12)
pyxel.pset(x + 2, y + 5, 1)
pyxel.rect(335, 72, 139, 201, 0)
pyxel.text(344, 82, 'TERRAIN ALWAYS STAYS', 11)
pyxel.text(344, 101, 'V = START / LOOKOUT', 6)
pyxel.text(344, 116, 'BOX = EXIT', 6)
pyxel.text(344, 135, 'M: MOON L: LEAF', 7)
pyxel.text(344, 147, 'S: STAR F: FLOWER', 7)
pyxel.text(344, 166, 'CHECK = DELIVERED', 11)
pyxel.text(344, 185, 'VISIBLE EXITS:', 6)
pyxel.text(344, 197, ' '.join(str(i + 1) for i in sorted(m.visible)) or '(none)', 10)
pyxel.text(344, 216, f'READS {m.reads} / NO TIMER', 6)
pyxel.text(344, 236, 'BOARD ? / DOTTED SCOPE', 6)
pyxel.text(344, 252, f'UNDO {m.undo_count}', 6)
pyxel.text(12, 287, MESSAGES[m.message], 7)
if m.won:
pyxel.rectb(10, 278, 464, 25, 11)
if control.paused:
pyxel.rect(42, 111, 270, 70, 0)
pyxel.rectb(42, 111, 270, 70, 6)
pyxel.text(54, 129, 'PAUSED: P TO RETURN', 10)
pyxel.text(54, 151, 'No timer. R/Z wait until unpaused.', 6)
pyxel.text(12, 315, 'ARROWS: ONE STEP R: RESTART ESC: QUIT', 6)
pyxel.text(12, 333, 'SPACE: READ / DELIVER Z: UNDO P: PAUSE', 6)
if headless and frames >= duration:
if screenshot:
pyxel.screenshot(screenshot, scale=2)
if report:
Path(report).write_text(json.dumps({
'label': 'Synthetic controller input; actual Pyxel headless renderer; silent',
'render_frames': frames, 'model_input_frames': len(events),
'events': events, 'final_state': m.state(),
'paused': control.paused,
}, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
pyxel.quit()
pyxel.init(480, 360, title='Fading Footprints - Stage 5', fps=30, headless=headless)
pyxel.run(update, draw)
if __name__ == '__main__':
import argparse
import json
from pathlib import Path
parser = argparse.ArgumentParser(description=__doc__)
modes = parser.add_mutually_exclusive_group()
modes.add_argument('--headless-smoke', action='store_true', help='Render initial state for 12 frames')
modes.add_argument('--headless-replay', help='UTF-8 JSON list of held/pressed input frames')
parser.add_argument('--screenshot', help='Output PNG (headless only)')
parser.add_argument('--report', help='Output UTF-8 JSON (headless only)')
args = parser.parse_args()
headless = args.headless_smoke or args.headless_replay is not None
if not headless and (args.screenshot or args.report):
parser.error('screenshot/report require a headless mode')
replay = None
if args.headless_replay:
replay = json.loads(Path(args.headless_replay).read_text(encoding='utf-8'))
valid = isinstance(replay, list) and all(
isinstance(e, dict) and set(e) <= {'held', 'pressed'} and all(
isinstance(e.get(k, []), list) and all(isinstance(v, str) and v in KEYS for v in e.get(k, []))
for k in ('held', 'pressed')) for e in replay)
if not valid:
parser.error('Replay must be a list of held/pressed dictionaries using supported key names')
main(headless, replay, args.screenshot, args.report)仕組みがわかったら、元の完成版を読んでみよう。
道と出口はいつも見えています。Space を押すと、V ではすべての出口、案内板ではその範囲、出口ではその出口自身の記号が見えます。表示は、次にうまく1歩動いたときに消え、待っているときや動けなかったときは消えません。案内板の範囲と、届ける荷物の対象は別のものです。出口で Space を押して対象を届け、まちがえてもその場で続けられます。時間やまちがいの回数の上限、帰ってくる必要はありません。全部届けると操作は止まりますが、Z と R は使えます。P で止めている間は、P で再開するまで R と Z も止まります。
ここまでに作ったのは、元のゲームの仕組みだけを取り出して作り直した「簡略版」です。元のゲームのソースは手を加えずに、別のZIP(元の完成版ソース)に入れてあります。
| 比べる点 | 5ステップの教材 | 元の完成版 |
|---|---|---|
| 地形と進み方 | 01〜04は10マスの教材用の地形。01は移動だけ、02は記号をいつも表示した1通の配達、03でVで見る仕組み、04で案内板の範囲を追加。05は元のゲームのC・配置0・案内板の組み合わせを固定で使う。 | A・B・Cの3つの地図、別の記号の配置、「いつも見える・消える・案内板」の比較モードを選べる。 |
| 最後の段階のルール | 05では、元のゲームのCの地形・記号・注文・案内板の範囲・移動・見る仕組み・配達・まちがい・2回目の配達・終わり・undo/reset/pause と、入力の優先順位をそのまま使う。 | 同じルールに、別の配置や地図・比較モードの切り替えがある。 |
| 画面と記録 | 英字(ASCII)の M・L・S・F と図形。地形・出口の番号・配達済みのチェックは表示したまま。画面を簡単にして、Nの別配置、Tab・Enterの地図、Bの比較、F12の保存、GIFやいつもの手動の記録は省いた。画面を表示しない保存は、指定したときだけ。 | 日本語の文字と、図形の記号を使う。それぞれの切り替え・手動の記録・自動デモがある。 |
| 音と確認 | 音はなし。付属の入力は自動のもので、画像は実際のPyxelで画面を表示せずに描いたもの。人の試遊、OSのキー入力、Windows/macOSの画面、ブラウザーでの実行、音の出力は確かめていない。 | 元のゲームも音なし。元のゲームの8つのテストや、これまでのデモの成功は、教材の新しい確認の数には含めない。 |
完成版のコードで、次に読むところ
- 元のゲームの
model.pyのSession.__init__で、arrangementによる記号の並べ方と、届ける対象の入れ替わり方を読みましょう。 - 元のゲームの
Session.visibleとMODESで、同じ地形での比較の実験を読みましょう。 - 元のゲームの
pathとrouteで、幅優先探索と、わかっている地図での自動の道すじを学びましょう。 - 元のゲームの
app.pyのloadとupdateで、Tab・N・B・Enter による状態の切り替えを調べましょう。
最初は、数字かルールを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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。
うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る
