# MIT License # # Copyright (c) 2026 Pyxel 100 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. """逆向き倉庫・教材 3/5。実行: python 03_reverse.py GUIはASCIIと図形のみ。原作の座標を段階的に学ぶ独立した1ファイルです。 """ from dataclasses import dataclass DIRECTIONS = {"U": (0, -1), "R": (1, 0), "D": (0, 1), "L": (-1, 0)} BORDER = frozenset((x, y) for x in range(9) for y in range(7) if x in (0, 8) or y in (0, 6)) @dataclass(frozen=True) class Level: walls: frozenset player: tuple crates: tuple goals: tuple = () width: int = 9 height: int = 7 def floor(self, position): x, y = position return 0 <= x < self.width and 0 <= y < self.height and position not in self.walls @dataclass(frozen=True) class State: player: tuple crates: tuple = () moves: int = 0 pushes: int = 0 @dataclass(frozen=True) class Result: state: State accepted: bool reason: str destinations: tuple = () def add(position, direction): return position[0] + direction[0], position[1] + direction[1] def initial(level): return State(level.player, level.crates) LEVEL = Level(BORDER, (1, 3), ((2, 3), (6, 3))) def transition(level, state, action): if action not in DIRECTIONS: return Result(state, False, "invalid") direction = DIRECTIONS[action] target = add(state.player, direction) if not level.floor(target): return Result(state, False, "walk_wall") if target not in state.crates: return Result(State(target, state.crates, state.moves + 1, state.pushes), True, "walk") pushed = state.crates.index(target) partner = 1 - pushed proposed = list(state.crates) proposed[pushed] = add(state.crates[pushed], direction) opposite = (-direction[0], -direction[1]) proposed[partner] = add(state.crates[partner], opposite) proposed = tuple(proposed) # 逆向きに動かす段階から、危ない候補は全員まとめて拒否する。 for index, position in enumerate(proposed): if not level.floor(position): return Result(state, False, "box_wall_" + str(index + 1), proposed) if proposed[0] == proposed[1]: return Result(state, False, "meet", proposed) if proposed[0] == state.crates[1] or proposed[1] == state.crates[0]: return Result(state, False, "cross", proposed) if any(position in (state.player, target) for position in proposed): return Result(state, False, "player", proposed) new_state = State(target, proposed, state.moves + 1, state.pushes + 1) return Result(new_state, True, "push", proposed) class World: def __init__(self, level=LEVEL): self.level = level self.reset() def reset(self): self.state = initial(self.level) self.reason = "ready" self.last_result = None def move(self, action): result = transition(self.level, self.state, action) self.state = result.state self.reason = result.reason self.last_result = result return result.accepted class App: def __init__(self, pyxel, headless=False, screenshot=None, replay=""): self.p = pyxel self.world = World() self.headless = headless self.screenshot = screenshot self.replay = replay self.lock = False self.arrows = [(pyxel.KEY_UP, "U"), (pyxel.KEY_RIGHT, "R"), (pyxel.KEY_DOWN, "D"), (pyxel.KEY_LEFT, "L")] self.controls = [key for key, _ in self.arrows] + [pyxel.KEY_R] pyxel.init(256, 284, title="Reverse Crates - Stage 3", fps=30, headless=headless) pyxel.run(self.update, self.draw) def update(self): p = self.p if self.headless and p.frame_count >= 12: if self.screenshot: p.screenshot(self.screenshot, scale=2) p.quit() return # 自動確認用。通常の起動ではキー入力だけを使う。 if self.headless and p.frame_count < len(self.replay): action = self.replay[p.frame_count] if action == "X": self.world.reset() else: self.world.move(action) return if self.lock: if not any(p.btn(key) for key in self.controls): self.lock = False return if p.btnp(p.KEY_R): self.world.reset() self.lock = True return pressed = [action for key, action in self.arrows if p.btnp(key, 12, 4)] held = sum(bool(p.btn(key)) for key, _ in self.arrows) if len(pressed) == 1 and held <= 1: self.world.move(pressed[0]) elif pressed: self.world.reason = "invalid" def draw(self): p = self.p w = self.world p.cls(1) p.text(10, 8, "REVERSE CRATES / 03", 10) p.text(10, 20, "OPPOSITE PAIR", 7) p.text(10, 32, f"MOVES {w.state.moves} PUSHES {w.state.pushes}", 6) cell, ox, oy = 24, 20, 52 # 盤面の数字は0始まり。文章の座標と画面を対応させる。 for x in range(w.level.width): p.text(ox + x * cell + 10, 43, str(x), 13) for y in range(w.level.height): p.text(10, oy + y * cell + 10, str(y), 13) for x in range(w.level.width): px, py = ox + x * cell, oy + y * cell color = 5 if (x, y) in w.level.walls else 3 p.rect(px, py, cell, cell, color) p.rectb(px, py, cell, cell, 1) for index, (x, y) in enumerate(w.state.crates): px, py = ox + x * cell + 4, oy + y * cell + 4 p.rect(px, py, 16, 16, 9) p.rectb(px, py, 16, 16, 4) p.text(px + 6, py + 6, str(index + 1), 1) x, y = w.state.player p.circ(ox + x * cell + 12, oy + y * cell + 12, 7, 12) p.pset(ox + x * cell + 10, oy + y * cell + 10, 7) p.pset(ox + x * cell + 14, oy + y * cell + 10, 7) if w.last_result and not w.last_result.accepted: for x, y in w.last_result.destinations: p.rectb(ox + x * cell + 1, oy + y * cell + 1, 22, 22, 8) messages = {"ready": "TRY RIGHT: BOTH BOXES MOVE", "walk": "WALK: 1 CELL", "push": "BOTH BOXES MOVED", "walk_wall": "NO MOVE: WALL", "box_wall_1": "NO MOVE: BOX 1 WALL", "box_wall_2": "NO MOVE: BOX 2 WALL", "meet": "NO MOVE: SHARED DESTINATION", "cross": "NO MOVE: BOXES CANNOT CROSS", "player": "NO MOVE: PLAYER CELL", "invalid": "ONE ARROW AT A TIME", "complete": "CLEAR! SAME NUMBERS MATCH", "undo": "UNDID ONE ACCEPTED MOVE", "empty": "NOTHING TO UNDO"} status = w.reason p.text(10, 230, messages[status], 10 if status == "complete" else 7) p.text(10, 244, "PUSH +D / PARTNER -D", 6) p.text(10, 258, "ARROWS: WALK + PUSH", 7) p.text(10, 272, "R: RESET ESC: QUIT", 6) def main(headless=False, screenshot=None, replay=""): import pyxel App(pyxel, headless, screenshot, replay) if __name__ == "__main__": import argparse parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--headless-smoke", action="store_true", help="12 rendered frames, then quit") parser.add_argument("--screenshot", help="PNG path; use with --headless-smoke") parser.add_argument("--replay", default="", help="Synthetic actions U/R/D/L/X(reset); headless only") args = parser.parse_args() if (args.screenshot or args.replay) and not args.headless_smoke: parser.error("--screenshot and --replay require --headless-smoke") if len(args.replay) > 12 or any(action not in "URDLX" for action in args.replay): parser.error("--replay requires at most 12 valid action letters") main(args.headless_smoke, args.screenshot, args.replay)