# 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. """一歩貸してください・教材 5/5。原作1部屋目の仕組みを段階化。 実行: python 05_undo.py コード・図形: MIT。原作者と条件は隣の LICENSE を参照。 """ from dataclasses import dataclass ROWS = ("#######", "#P..G.#", "#.....#", "#E.S..#", "#######") DIRECTIONS = {"U": (0, -1), "D": (0, 1), "L": (-1, 0), "R": (1, 0), "W": (0, 0)} TILE = 24 def find(char): return next((x, y) for y, row in enumerate(ROWS) for x, cell in enumerate(row) if cell == char) def move(position, command): dx, dy = DIRECTIONS[command] x, y = position[0] + dx, position[1] + dy if 0 <= y < len(ROWS) and 0 <= x < len(ROWS[y]) and ROWS[y][x] != "#": return x, y return position @dataclass(frozen=True) class State: player: tuple = find("P") echo: tuple = find("E") memory: str = "W" status: str = "play" turns: int = 0 def step(state, command): if command not in DIRECTIONS: return state if state.status != "play": return state player = move(state.player, command) # 新しい command を保存する前に、一手前の memory を使う。 echo = move(state.echo, state.memory) if player == find("G") and echo != find("S"): player = state.player # 自分が先に動くので、影がこれから退く場所にも入れない。 collision = player == state.echo or player == echo status = "hit" if collision else "play" if not collision and player == find("G") and echo == find("S"): status = "win" return State(player=player, echo=echo, memory=command, status=status, turns=state.turns + 1) class Session: def __init__(self): self.state = State() self.history = [] def act(self, command): next_state = step(self.state, command) if next_state != self.state: self.history.append(self.state) self.state = next_state def undo(self): if self.history: self.state = self.history.pop() def restart(self): self.state = State() self.history.clear() def main(headless=False, screenshot=None): import pyxel session = Session() keys = ((pyxel.KEY_UP, "U"), (pyxel.KEY_DOWN, "D"), (pyxel.KEY_LEFT, "L"), (pyxel.KEY_RIGHT, "R"), (pyxel.KEY_SPACE, "W")) def update(): if headless and pyxel.frame_count >= 12: if screenshot: pyxel.screenshot(screenshot, scale=2) pyxel.quit() return if pyxel.btnp(pyxel.KEY_R): session.restart() return if pyxel.btnp(pyxel.KEY_Z): session.undo() return for key, command in keys: if pyxel.btnp(key): session.act(command) break # 同じフレームに複数の一手を進めない。 def draw(): current = session.state pyxel.cls(1) pyxel.text(10, 8, "ECHO STEPS / STEP 5", 10) pyxel.text(10, 20, f"TURNS {current.turns}", 7) for y, row in enumerate(ROWS): for x, char in enumerate(row): px, py = 10 + x * TILE, 40 + y * TILE pyxel.rect(px, py, TILE - 1, TILE - 1, 5 if char == "#" else 0) if char == "S": color = 10 if current.echo == (x, y) else 4 pyxel.rect(px + 5, py + 5, 14, 14, color) pyxel.text(px + 10, py + 9, "S", 0) if char == "G": color = 11 if current.echo == find("S") else 4 pyxel.rectb(px + 3, py + 3, 18, 19, color) pyxel.text(px + 10, py + 9, "G", color) ex, ey = current.echo pyxel.circ(22 + ex * TILE, 52 + ey * TILE, 7, 14) pyxel.text(10, 175, f"NEXT ECHO: {current.memory}", 14) x, y = current.player pyxel.rect(16 + x * TILE, 46 + y * TILE, 12, 12, 12) pyxel.text(10, 190, "ARROWS: STEP SPACE: WAIT", 6) pyxel.text(10, 202, "Z: UNDO R: RESET ESC: QUIT", 6) if current.status == "hit": pyxel.text(10, 164, "HIT! R: RESET", 8) elif current.status == "win": pyxel.text(10, 164, "CLEAR! R: PLAY AGAIN", 11) pyxel.init(188, 218, title="Echo Steps - Stage 5", fps=30, headless=headless) pyxel.run(update, draw) if __name__ == "__main__": import argparse parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--headless-smoke", action="store_true", help="12 frames then quit; not a playtest") parser.add_argument("--screenshot", help="PNG path used with --headless-smoke") args = parser.parse_args() main(headless=args.headless_smoke, screenshot=args.screenshot)