# MIT License # # Copyright (c) 2026 # # 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. # # Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed # under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt. """いたずら庭師: 日本語教材 5/5。LICENSEとLESSON.mdも読んでください。""" from dataclasses import dataclass, replace ENTRY = 10 NEST = 14 FLOWERS = (1, 11, 21, 8, 18) ROCKS = (12,) def neighbors(cell): """盤面の外へ出ず、左右端もつながない四近傍。""" x, y = cell % 5, cell // 5 return tuple(ny * 5 + nx for nx, ny in ((x-1, y), (x+1, y), (x, y-1), (x, y+1)) if 0 <= nx < 5 and 0 <= ny < 5) def connected(cells, start): found = {start} if start in cells else set() frontier = list(found) while frontier: for cell in neighbors(frontier.pop()): if cell in cells and cell not in found: found.add(cell) frontier.append(cell) return frozenset(found) @dataclass(frozen=True) class State: paths: tuple = (ENTRY,) roots: tuple = (NEST,) water: tuple = (1, 1, 1, 1, 1) round: int = 1 role: str = "gardener" left: int = 2 drained: tuple = () winner: str = "" tick: int = 0 last: str = "READY" def initial(): return State() def path_network(s): return connected(s.paths, ENTRY) def root_network(s): return connected(s.roots, NEST) def legal(s, kind, cell=-1): if s.winner: return False if kind == "wait": return True if cell not in range(25): return False if s.role == "gardener": network = path_network(s) near = cell in network or any(n in network for n in neighbors(cell)) if kind == "path": return (cell not in (*s.paths, *FLOWERS, *ROCKS, NEST) and any(n in network for n in neighbors(cell))) if kind == "water": return cell in FLOWERS and near and s.water[FLOWERS.index(cell)] < 2 if kind == "dig": return near and cell not in (*ROCKS, NEST) elif s.role == "mole" and kind == "root": return (cell not in (*s.roots, *ROCKS) and any(n in root_network(s) for n in neighbors(cell))) return False def step(s, action): kind, cell = action if not legal(s, kind, cell): return s paths, roots, water = set(s.paths), set(s.roots), list(s.water) if kind == "path": paths.add(cell) elif kind == "root": roots.add(cell) elif kind == "water": water[FLOWERS.index(cell)] += 1 elif kind == "dig": roots.difference_update(q for q in (cell, *neighbors(cell)) if q != NEST) last = {"path": "PATH ADDED", "root": "UNDERGROUND WORK DONE", "water": "WATER ADDED", "wait": "WAITED"}.get(kind, "DIG DONE") if kind == "dig": last = "ROOT CUT" if set(s.roots)-roots else "NO ROOT" n = replace(s, paths=tuple(sorted(paths)), roots=tuple(sorted(roots)), water=tuple(water), left=s.left-1, tick=s.tick+1, last=last) if n.left: return n if n.role == "gardener": return replace(n, role="mole", left=2, last="HANDOFF") # 吸水はモグラの二手目の後だけ。全花壇を同じ連結集合で計算する。 network = root_network(n) drained = tuple(p for i, p in enumerate(FLOWERS) if p in network and water[i] > 0) water = tuple(max(0, w-int(p in network)) for p, w in zip(FLOWERS, water)) winner = ("gardener" if water.count(2) >= 3 else "mole") if n.round == 8 else "" return replace(n, water=water, drained=drained, role="gardener", left=2, round=n.round if winner else n.round+1, winner=winner, last="DRAIN FINISHED") def visible(s): # 庭師用辞書には根、根数、私的な最終操作を一切入れない。 reveal = s.role == "mole" or bool(s.winner) return dict(paths=s.paths, water=s.water, round=s.round, role=s.role, left=s.left, drained=s.drained, winner=s.winner, tick=s.tick, roots=s.roots if reveal else (), network=root_network(s) if reveal else (), revealed=reveal) def forecast(s, kind, cell): if not legal(s, kind, cell): return "BLOCKED / NO COST" # 掘る前に命中判定しない。庭師の予告は非公開の根に依存しない。 return {"path": "EXTEND FROM ENTRY", "water": "WATER +1 / MAX 2", "root": "EXTEND FROM NEST", "dig": "DIG CENTER + 4 SIDES", "cut": "LAB: CUT CENTER + SIDES", "drain": "LAB: DRAIN CONNECTED", "wait": "SPEND ONE ACTION"}[kind] class Controller: def __init__(self): self.previous = set() self.gate = False self.swapped = False self.reset() def reset(self): self.s = initial() self.cursor = ENTRY self.kind = 0 self.pending = None self.notice = "" self.help = False self.dialog = "" self.curtain = True @property def kinds(self): return ("path", "water", "dig", "wait") if self.s.role == "gardener" else ("root", "wait") def commit(self, action): if self.curtain or self.help or self.dialog: return "blocked:overlay" before = self.s after = step(before, action) if after == before: return "reject:action" self.s = after self.pending = None self.notice = after.last if before.role != after.role and not after.winner: self.curtain = True self.kind = 0 self.cursor = ENTRY if after.role == "gardener" else NEST self.notice = "" self.pending = None return "committed" def feed(self, down): down = set(down) & set(CONTROL_KEYS) fresh = down - self.previous self.previous = down if self.gate: if not down: self.gate = False return None if not fresh: return None self.gate = True if len(fresh) > 1: return "reject:chord" key = next(k for k in CONTROL_KEYS if k in fresh) self.notice = "" if self.dialog: if key in ("X", "ESCAPE"): self.dialog = "" return "cancel_dialog" if key == "RETURN": if self.dialog == "reset1": self.dialog = "reset2" return "confirm:second_player" if self.dialog == "reset2": self.reset() return "reset" if self.dialog == "quit": return "quit" return "ignored:dialog" if key == "ESCAPE": self.dialog = "quit" return "confirm:quit" if key == "H": self.help = not self.help return "help" if self.help: return "ignored:help" if key == "R": self.dialog = "reset1" return "confirm:reset" if self.curtain: if key == "RETURN": self.curtain = False return "ready" return "ignored:curtain" if self.s.winner: if key == "SPACE": self.swapped = not self.swapped self.reset() return "swap_roles" return "ignored:result" if key == "TAB": self.kind = (self.kind+1) % len(self.kinds) self.pending = None return "kind" if key in ("LEFT", "RIGHT", "UP", "DOWN"): dx = {"LEFT": -1, "RIGHT": 1}.get(key, 0) dy = {"UP": -1, "DOWN": 1}.get(key, 0) self.cursor = ((self.cursor//5+dy)%5)*5 + (self.cursor%5+dx)%5 self.pending = None return "cursor" if key == "X": self.pending = None return "cancel_selection" if key == "Z": kind = self.kinds[self.kind] action = (kind, -1 if kind in ("wait", "drain") else self.cursor) if legal(self.s, *action): self.pending = action return "selected" self.notice = "BLOCKED / NO COST" return "reject:selection" if key == "RETURN" and self.pending is not None: return self.commit(self.pending) return "ignored" def tap(ui, *keys): result = ui.feed(set(keys)) ui.feed(set()) return result def scene(ui): # 覆いの場面はここで返す。下の盤面や選択・通知を重ね描きしない。 if ui.dialog: return {"screen": ui.dialog} if ui.help: return {"screen": "help"} if ui.curtain: return {"screen": "curtain", "role": ui.s.role, "swapped": ui.swapped} view = visible(ui.s) view.update(screen="board", cursor=ui.cursor, kind=ui.kinds[ui.kind], pending=ui.pending is not None, notice=ui.notice, forecast=forecast(ui.s, ui.kinds[ui.kind], -1 if ui.kinds[ui.kind] in ("wait", "drain") else ui.cursor)) return view def draw(p, view): p.cls(1) p.text(12, 10, "MISCHIEF GARDEN / STAGE 5", 10) screen = view["screen"] if screen != "board": p.rectb(12, 40, 360, 172, 10) if screen == "curtain": lines = ("HANDOFF / BOARD HIDDEN", "NEXT: "+view["role"].upper(), "OTHER PLAYER: LOOK AWAY", "RELEASE KEYS, THEN ENTER", "ROLES SWAPPED" if view["swapped"] else "TWO PEOPLE / ONE SCREEN", "A PLAY CONVENTION, NOT SECURITY") elif screen == "help": lines = ("PATH FROM ENTRY / ROOT FROM NEST", "GARDENER: PATH, WATER, DIG*", "MOLE: ROOT / TWO ACTIONS EACH", "AFTER MOLE: CONNECTED ROOTS DRAIN", "ROUND 8: 3 BLOOMS -> GARDENER", "* DIG BEGINS IN STAGE 4", "X CANCELS SELECTION, NEVER UNDO", "H CLOSE / BOARD IS HIDDEN") else: lines = ({"reset1": "GARDENER: AGREE TO RESET?", "reset2": "MOLE: ALSO AGREE TO RESET?", "quit": "QUIT THIS WINDOW?"}[screen], "RELEASE, ENTER: YES / X: CANCEL", "NO BOARD, PREVIEW OR HISTORY HERE") for i, line in enumerate(lines): p.text(24, 58+i*18, line, 7 if i else 10) return p.text(12, 26, view["role"].upper()+" / "+("SOLO LAB" if not view["round"] else "ROUND "+str(view["round"])+" / LEFT "+str(view["left"])), 7) for cell in range(25): x, y = 12+(cell%5)*34, 48+(cell//5)*34 p.rect(x, y, 31, 31, 4) if cell in view["paths"]: p.rect(x+2, y+2, 27, 27, 13) if cell in ROCKS: p.rect(x+6, y+7, 19, 17, 5) if cell in view["roots"]: color = 10 if cell in view["network"] else 6 p.circ(x+15, y+15, 3, color) for other in neighbors(cell): if other in view["roots"]: p.line(x+15, y+15, x+15+(other%5-cell%5)*17, y+15+(other//5-cell//5)*17, color) p.text(x+2, y+2, str(cell), 7) if cell in FLOWERS: water = view["water"][FLOWERS.index(cell)] p.circ(x+16, y+16, 5 if water==2 else 3, 14 if water else 5) p.text(x+20, y+23, str(water), 7) if cell == ENTRY: p.text(x+6, y+23, "IN", 1) if cell == NEST: p.text(x+7, y+23, "NEST", 7) if cell in view["drained"]: p.text(x+23, y+2, "D", 12) if cell == view["cursor"] and not view["winner"]: p.rectb(x-1, y-1, 33, 33, 7) p.text(194, 50, "BLOOMS "+str(view["water"].count(2))+" / 5", 10) p.text(194, 66, "ROOTS: "+("VISIBLE" if view["revealed"] else "HIDDEN"), 11) p.text(194, 82, "YELLOW: CONNECTED" if view["revealed"] else "PUBLIC WATER ONLY", 6) p.text(194, 94, "GRAY: DISCONNECTED" if view["revealed"] else "NO PRIVATE PREVIEW", 6) p.text(194, 112, "ACTION: "+view["kind"].upper(), 10) p.text(194, 128, "ENTER: COMMIT" if view["pending"] else "Z: SELECT", 7) p.text(194, 144, view["forecast"], 7) p.text(194, 160, "TICK "+str(view["tick"]), 6) p.text(194, 176, view["notice"], 11) if view["winner"]: p.text(194, 193, "WIN: "+view["winner"].upper(), 10) p.text(194, 205, "SPACE: SWAP AND REPLAY", 7) p.text(12, 228, "ARROWS CELL / TAB ACTION / Z SELECT / ENTER COMMIT", 7) p.text(12, 241, "X CANCEL / R RESET / H HELP* / ESC QUIT", 6) CONTROL_KEYS = ("ESCAPE", "R", "H", "RETURN", "SPACE", "X", "TAB", "UP", "DOWN", "LEFT", "RIGHT", "Z") DEMO_ACTIONS = (('path', 5), ('path', 6), ('root', 13), ('root', 8), ('dig', 7), ('wait', -1)) def demo_controller(): ui = Controller() if ui.curtain: tap(ui, "RETURN") for action in DEMO_ACTIONS: if ui.curtain: tap(ui, "RETURN") ui.kind = ui.kinds.index(action[0]) ui.cursor = action[1] if action[1] >= 0 else ui.cursor tap(ui, "Z") tap(ui, "RETURN") return ui def main(headless=False, screenshot=None, demo=False): # import時にウィンドウを開かない。モデルのテストにPyxelは不要。 from pathlib import Path import pyxel as p output = Path(screenshot).resolve() if screenshot else None if output: output.parent.mkdir(parents=True, exist_ok=True) ui = demo_controller() if demo else Controller() p.init(384, 256, title="Mischief Garden 5", fps=30, display_scale=2, quit_key=p.KEY_NONE, headless=headless) keymap = {key: getattr(p, "KEY_"+key) for key in CONTROL_KEYS} def update(): if headless and p.frame_count >= 8: if output: p.screenshot(str(output), scale=2) p.quit() return result = ui.feed({key for key, value in keymap.items() if p.btn(value)}) if result == "quit": p.quit() p.run(update, lambda: draw(p, scene(ui))) if __name__ == "__main__": import argparse parser = argparse.ArgumentParser(description="いたずら庭師: 単体実行できる段階教材") parser.add_argument("--headless-smoke", action="store_true") parser.add_argument("--demo-checkpoint", action="store_true") parser.add_argument("--screenshot") args = parser.parse_args() main(args.headless_smoke, args.screenshot, args.demo_checkpoint)