# 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. """いたずら庭師: 日本語教材 2/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: roots: tuple = (NEST,) water: tuple = (1, 1, 1, 1, 1) drained: tuple = () tick: int = 0 def initial(): return State() def legal(s, kind, cell): if kind == "drain": return True if cell not in range(25): return False if kind == "cut": return cell not in (*ROCKS, NEST) return (kind == "root" and cell not in (*s.roots, *ROCKS) and any(n in connected(s.roots, NEST) for n in neighbors(cell))) def step(s, action): kind, cell = action if not legal(s, kind, cell): return s if kind == "root": return replace(s, roots=tuple(sorted((*s.roots, cell))), tick=s.tick+1) if kind == "cut": removed = set((cell, *neighbors(cell))) - {NEST} return replace(s, roots=tuple(p for p in s.roots if p not in removed), tick=s.tick+1) network = connected(s.roots, NEST) drained = tuple(p for i, p in enumerate(FLOWERS) if p in network and s.water[i] > 0) water = tuple(max(0, w-int(p in network)) for p, w in zip(FLOWERS, s.water)) return replace(s, water=water, drained=drained, tick=s.tick+1) def visible(s): return dict(paths=(ENTRY,), roots=s.roots, network=connected(s.roots, NEST), water=s.water, role="mole", round=0, left=0, winner="", tick=s.tick, drained=s.drained, revealed=True) 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 = NEST self.kind = 0 self.pending = None self.notice = "" self.help = False self.dialog = "" self.curtain = False @property def kinds(self): return ("root", "cut", "drain") 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 = "ACTION OK" 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 # 前半教材は明示したCONTROL_KEYS順で一キーを選ぶ。 key = next(k for k in CONTROL_KEYS if k in fresh) self.notice = "" if key == "ESCAPE": return "quit" if key == "R": self.reset() return "reset" 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 2", 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 = (('root', 13), ('root', 8), ('root', 3), ('root', 2), ('root', 1), ('cut', 13)) 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 2", 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)