# 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. """拾ったルール・第1段階。原作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) @dataclass(frozen=True) class State: pos: tuple moves: int = 0 def initial(board): return State(board.start) def forecast(board, state, direction): if type(direction) is not int or direction not in range(4): return Forecast(state.pos, reason="INVALID DIRECTION") dx, dy = DIRS[direction] target = (state.pos[0] + dx, state.pos[1] + dy) if board.tile(target) in ("#", "o", "~"): return Forecast(state.pos, reason="BLOCKED TILE") return Forecast(target, (target,), (target,)) def step(board, state, action): if not isinstance(action, (tuple, list)) or len(action) != 2: return state kind, direction = action if kind != "move": return state result = forecast(board, state, direction) if not result.valid: return state return replace(state, pos=result.final, moves=state.moves + 1) 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 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 "NO CARDS YET") return 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 in D_NAMES: self.commit(("move", D_NAMES.index(key))) 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 1 / 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, "MOVEMENT PRACTICE / MARKS ARE DISPLAY ONLY", 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, 260, "NO CARDS YET / ARROWS MOVE ONE CELL", 6) p.text(12, 294, "NO CLEAR CONDITION IN THIS STAGE", 10) p.text(12, 312, self.message, 11) p.text(12, 330, 'ARROWS MOVE R RESET', 7) p.text(12, 345, "RELEASE ALL KEYS BETWEEN DECISIONS / ESC: QUIT", 6) KEYS = ('UP', 'RIGHT', 'DOWN', 'LEFT', 'R') DEMO_KEYS = ('RIGHT',) 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 1", 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()