# 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)