# 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. # # The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL. # See assets/NOTO-LICENSE.txt. """拾ったルール・第5段階。原作Cから、移動の規則を育てる教材。""" from dataclasses import dataclass, replace DIRS = ((0, -1), (1, 0), (0, 1), (-1, 0)) D_NAMES = ("UP", "RIGHT", "DOWN", "LEFT") PLAYER_COLOR = 11 @dataclass(frozen=True) class Level: grid: tuple wind: int = 2 def cells(self, char): return tuple((x, y) for y, row in enumerate(self.grid) for x, value in enumerate(row) if value == char) def tile(self, pos): x, y = pos return self.grid[y][x] if 0 <= x < 7 and 0 <= y < 5 else "#" @property def start(self): return self.cells("S")[0] @property def marks(self): return self.cells("*") @property def goal(self): return self.cells("G")[0] # 原作Cをそのまま使用。座標は0始まり、下方向が+y。 BOARD = Level(("#######", "#S*...#", "#.#o#.#", "#*P~.G#", "#######"), wind=2) @dataclass(frozen=True) class Forecast: final: tuple landings: tuple = () crossed: tuple = () reason: str = "" @property def valid(self): return bool(self.landings) SPRING, SAIL, SLIDE = 1, 2, 4 @dataclass(frozen=True) class State: pos: tuple gear: int = 0 marks: int = 0 status: str = "play" tick: int = 0 moves: int = 0 order: tuple = () pairs: tuple = () def initial(board): return State(board.start) AVAILABLE_CARDS = (1, 2, 4) def forecast(board, state, direction): if type(direction) is not int or direction not in range(4): return Forecast(state.pos, reason="INVALID DIRECTION") if state.status != "play": return Forecast(state.pos, reason="ALREADY CLEARED") if state.gear & SAIL and direction == board.wind: return Forecast(state.pos, reason="HEADWIND") dx, dy = DIRS[direction] stride = 2 if state.gear & SPRING else 1 pos = state.pos landings, crossed = [], [] reason = "" for _ in range(7): cells = tuple((pos[0] + dx * i, pos[1] + dy * i) for i in range(1, stride + 1)) target = cells[-1] if any(board.tile(cell) == "#" for cell in cells): reason = "WALL" break if board.tile(target) == "o": reason = "CANNOT LAND IN A HOLE" break if board.tile(target) == "~" and not state.gear & SAIL: reason = "WATER NEEDS SAIL" break landings.append(target) crossed.extend(cells) pos = target if not state.gear & SLIDE: break return Forecast(pos, tuple(landings), tuple(crossed), reason if not landings else "") def edit_reason(board, state, bit): if state.status != "play": return "ALREADY CLEARED" if type(bit) is not int or bit not in AVAILABLE_CARDS: return "CARD NOT AVAILABLE" if bit == SAIL and state.gear & SAIL and board.tile(state.pos) == "~": return "CANNOT REMOVE SAIL ON WATER" if board.tile(state.pos) not in ("S", "P"): return "EDIT ONLY ON S OR P" gear = state.gear ^ bit if gear.bit_count() > 2: return "TWO CARDS MAXIMUM" return "" def step(board, state, action): if not isinstance(action, (tuple, list)) or len(action) != 2: return state kind, value = action if kind == "edit": if edit_reason(board, state, value): return state gear = state.gear ^ value pairs = state.pairs if gear.bit_count() == 2 and gear not in pairs: pairs += (gear,) return replace(state, gear=gear, tick=state.tick + 1, pairs=pairs) if kind != "move": return state result = forecast(board, state, value) if not result.valid: return state marks, order = state.marks, state.order # 通過点・途中の着地点ではなく、命令が終わった位置だけを調べる。 if result.final in board.marks: index = board.marks.index(result.final) if not marks & (1 << index): marks |= 1 << index order += (index + 1,) status = "won" if result.final == board.goal and marks == 3 else "play" return replace(state, pos=result.final, marks=marks, status=status, tick=state.tick + 1, moves=state.moves + 1, order=order) class App: def __init__(self): self.reset() def reset(self): self.state = initial(BOARD) self.previous = set() self.gated = False self.message = "READY" self.preview = 1 self.history = [] def commit(self, action): before = self.state after = step(BOARD, before, action) if before == after: self.message = (forecast(BOARD, before, action[1]).reason if action[0] == "move" else edit_reason(BOARD, before, action[1])) return self.history.append(before) self.state = after if action[0] == "move": self.preview = action[1] self.message = action[0].upper() + " ACCEPTED" def control(self, down): down = set(down) & set(KEYS) fresh = down - self.previous self.previous = down # 新しく押されたRは解放待ちやクリア状態より優先。 if "R" in fresh: self.reset() self.previous = down self.gated = True return if self.gated: if not down: self.gated = False return if not fresh: return self.gated = True if len(down) != 1: self.message = "RELEASE ALL KEYS" return key = next(iter(fresh)) if key == "X": if self.history: self.state = self.history.pop() self.message = "UNDONE ONE DECISION" else: self.message = "NO HISTORY" return if key in D_NAMES: self.commit(("move", D_NAMES.index(key))) elif key in ('1', '2', '3'): self.commit(("edit", (1, 2, 4)[int(key) - 1])) def draw(self, p): s = self.state gear, marks = getattr(s, "gear", 0), getattr(s, "marks", 0) p.cls(1) p.text(12, 10, "RULE CARDS / STAGE 5 / ORIGINAL C", 10) p.text(12, 25, "POS %s MOVES %d TICK %d" % (s.pos, s.moves, getattr(s, "tick", s.moves)), 7) p.text(12, 39, "MARKS %d/2 GEAR %d/2 HEADWIND: %s" % (marks.bit_count(), gear.bit_count(), D_NAMES[BOARD.wind]), 6) for x in range(7): p.text(29 + x * 32, 58, str(x), 13) for y, row in enumerate(BOARD.grid): p.text(5, 82 + y * 32, str(y), 13) for x, tile in enumerate(row): px, py = 16 + x * 32, 70 + y * 32 color = 5 if tile == "#" else 12 if tile == "~" else 2 p.rect(px, py, 30, 30, color) if tile == "o": p.elli(px + 5, py + 10, 20, 14, 0) p.ellib(px + 5, py + 10, 20, 14, 13) elif tile in ("S", "P", "G"): p.rectb(px + 2, py + 2, 26, 26, 10 if tile != "G" else 11) p.text(px + 4, py + 3, tile, 7) elif tile == "*": number = BOARD.marks.index((x, y)) taken = bool(marks & (1 << number)) p.circ(px + 15, py + 15, 9, 3 if taken else 10) p.text(px + 13, py + 13, str(number + 1), 7 if taken else 1) elif tile == "~": p.line(px + 3, py + 12, px + 25, py + 12, 6) p.line(px + 3, py + 21, px + 25, py + 21, 6) result = forecast(BOARD, s, self.preview) if result.valid: for x, y in result.crossed: p.circ(31 + x * 32, 85 + y * 32, 2, 6) x, y = result.final p.rectb(17 + x * 32, 71 + y * 32, 28, 28, 11) x, y = s.pos p.circ(31 + x * 32, 88 + y * 32, 7, PLAYER_COLOR) p.pset(33 + x * 32, 86 + y * 32, 1) p.text(256, 60, "NEXT FINAL LANDING", 7) for direction in range(4): f = forecast(BOARD, s, direction) y = 77 + direction * 34 p.text(256, y, D_NAMES[direction] + " " + (str(f.final) if f.valid else "X"), 11 if f.valid else 8) p.text(256, y + 11, ("PATH " + str(len(f.crossed)) + " CELLS") if f.valid else f.reason, 6) p.text(12, 242, "S/P: EDIT PLATFORM o: HOLE ~: WATER", 7) p.text(12, 264, "1 SPRING " + ("ON" if gear & 1 else "OFF"), 11 if gear & 1 else 5) p.text(167, 264, "2 SAIL " + ("ON" if gear & 2 else "OFF"), 11 if gear & 2 else 5) p.text(322, 264, "3 SLIDE " + ("ON" if gear & 4 else "OFF"), 11 if gear & 4 else 5) p.text(12, 280, "PAIRS " + str(s.pairs), 6) p.text(256, 280, "HISTORY " + str(len(self.history)), 6) p.text(12, 294, "CLEARED / ORDER " + str(s.order) if s.status == "won" else "FINAL LANDING ONLY / NO DEATH OR TIME LIMIT", 10) p.text(12, 312, self.message, 11) p.text(12, 330, 'ARROWS MOVE 1 SPRING 2 SAIL 3 SLIDE X UNDO R RESET', 7) p.text(12, 345, "RELEASE ALL KEYS BETWEEN DECISIONS / ESC: QUIT", 6) KEYS = ('UP', 'RIGHT', 'DOWN', 'LEFT', 'R', '1', '2', '3', 'X') DEMO_KEYS = ('RIGHT', 'LEFT', '3', 'DOWN', 'RIGHT', '2', 'RIGHT', 'X') def parse_args(argv=None): """引数検査はPyxelのimportより前に行う。""" import argparse from pathlib import Path parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--headless-smoke", action="store_true") parser.add_argument("--demo-checkpoint", action="store_true") parser.add_argument("--frames", type=int) parser.add_argument("--screenshot") args = parser.parse_args(argv) if (args.demo_checkpoint or args.screenshot or args.frames is not None) and not args.headless_smoke: parser.error("QA options require --headless-smoke") minimum = max(8, len(DEMO_KEYS) * 2 + 4) if args.demo_checkpoint else 8 if args.frames is None: args.frames = minimum if not minimum <= args.frames <= 600: parser.error("--frames must be between %d and 600" % minimum) if args.screenshot: path = Path(args.screenshot).expanduser().resolve() if path.suffix.lower() != ".png" or path.exists() or not path.parent.is_dir(): parser.error("screenshot must be a NEW .png in an existing directory") args.screenshot = str(path) return args def main(argv=None): args = parse_args(argv) import json from dataclasses import asdict import pyxel as p app = App() p.init(480, 360, title="Rule Cards / Lesson 5", fps=30, display_scale=2, headless=args.headless_smoke) mapping = {key: getattr(p, "KEY_" + key) for key in KEYS} def update(): if p.btnp(p.KEY_ESCAPE) or args.headless_smoke and p.frame_count >= args.frames: if args.screenshot: app.draw(p) p.screen.save(args.screenshot, 1) print(json.dumps({"state": asdict(app.state), "history_length": len(getattr(app, "history", ())) }, ensure_ascii=True), flush=True) p.quit() return if args.headless_smoke: i = p.frame_count // 2 down = {DEMO_KEYS[i]} if args.demo_checkpoint and p.frame_count % 2 == 0 and i < len(DEMO_KEYS) else set() else: down = {key for key, value in mapping.items() if p.btn(value)} app.control(down) p.run(update, lambda: app.draw(p)) if __name__ == "__main__": main()