# 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。Python 3.11以上。単独実行できます。""" from dataclasses import dataclass, replace WIDTH, HEIGHT = 4, 3 ROCKS = (5, 6) MAX_FLOWERS = 6 COLORS = (0, 10, 7, 8, 12, 7, 2) @dataclass(frozen=True) class State: flowers: tuple = (0,) * 12 view: int = 0 submitted: bool = False def kind(value): return -1 if value == 0 else int(value >= 4) def mask(state, phase): return tuple(i for i, value in enumerate(state.flowers) if value and kind(value) == phase) def render_data(state, phase): # 昼夜の画像はこの一つの配列から計算。描画は状態を変えない。 return tuple((value, kind(value), COLORS[value], bool(value) and kind(value) == phase, i in ROCKS) for i, value in enumerate(state.flowers)) def components(indices): remaining = set(indices) count = 0 while remaining: count += 1 stack = [remaining.pop()] while stack: index = stack.pop() neighbours = [other for other in remaining if abs(other % WIDTH - index % WIDTH) + abs(other // WIDTH - index // WIDTH) == 1] for other in neighbours: remaining.remove(other) stack.append(other) return count def statistics(state): day, night = mask(state, 0), mask(state, 1) return len(day), len(night), components(day), components(night) def evaluate(state): day, night, day_groups, night_groups = statistics(state) checks = (day + night == MAX_FLOWERS, day == 3 and day_groups == 1, night == 3 and night_groups == 1) return {"total": day + night, "day": day, "night": night, "day_groups": day_groups, "night_groups": night_groups, "checks": checks, "success": all(checks)} def step(state, action): if action == "view": return replace(state, view=1-state.view) if action == "submit": return replace(state, submitted=True) if isinstance(action, str) and action.startswith("load:"): return decode(json.loads(action[5:])) if not isinstance(action, str): return state parts = action.split(":") if not parts or parts[0] not in ("plant", "remove"): return state try: index = int(parts[1]) if len(parts) != (3 if parts[0] == "plant" else 2): return state except (ValueError, IndexError): return state if not 0 <= index < WIDTH * HEIGHT: return state flowers = list(state.flowers) if parts[0] == "remove": flowers[index] = 0 else: try: plant_kind = int(parts[2]) except ValueError: return state if plant_kind not in (0, 1) or index in ROCKS: return state if not flowers[index] and sum(bool(v) for v in flowers) >= MAX_FLOWERS: return state first = 1 if plant_kind == 0 else 4 old = flowers[index] flowers[index] = first if kind(old) != plant_kind else (old-first+1) % 3 + first return replace(state, flowers=tuple(flowers), submitted=False) MAX_ACTIONS = 2048 @dataclass(frozen=True) class Timeline: state: State = State() past: tuple = () actions: tuple = () def apply(timeline, action): if action == "undo": if not timeline.past: return timeline return Timeline(timeline.past[-1], timeline.past[:-1], timeline.actions + (action,)) if action == "view": return Timeline(step(timeline.state, action), timeline.past, timeline.actions + (action,)) if len(timeline.actions) >= MAX_ACTIONS: return timeline state = step(timeline.state, action) if state == timeline.state: return timeline return Timeline(state, timeline.past + (timeline.state,), timeline.actions + (action,)) from pathlib import Path import json import os import tempfile SCHEMA = "night-garden-lesson-B-v1" SAVE_ROOT = Path(__file__).resolve().parent / "tutorial_saves_078" def payload(state): report = evaluate(state) return {"schema": SCHEMA, "level": "B", "flowers": list(state.flowers), "view": state.view, "submitted": state.submitted, "evaluation": {**report, "checks": list(report["checks"])}, "label": "依頼に沿った花壇" if report["success"] else "自分の案・依頼未達"} def decode(data): if not isinstance(data, dict) or data.get("schema") != SCHEMA or data.get("level") != "B": raise ValueError("WRONG SAVE VERSION / LEVEL") flowers = data.get("flowers") if (not isinstance(flowers, list) or len(flowers) != WIDTH * HEIGHT or any(type(v) is not int or not 0 <= v <= 6 for v in flowers)): raise ValueError("INVALID FLOWER ARRAY") if any(flowers[i] for i in ROCKS) or sum(bool(v) for v in flowers) > MAX_FLOWERS: raise ValueError("INVALID ROCK / FLOWER COUNT") if type(data.get("view")) is not int or data["view"] not in (0, 1): raise ValueError("INVALID VIEW") if type(data.get("submitted")) is not bool: raise ValueError("INVALID SUBMITTED VALUE") state = State(tuple(flowers), data["view"], data["submitted"]) expected = payload(state) # bool と 1 も区別。保存された達成フラグを信用しない。 canonical = lambda value: json.dumps(value, ensure_ascii=False, sort_keys=True) if canonical(data) != canonical(expected): raise ValueError("SAVE DOES NOT MATCH FLOWERS") return state class Store: def __init__(self, root=SAVE_ROOT): self.root = Path(root) def path(self, slot): if type(slot) is not int or slot not in (0, 1, 2): raise ValueError("INVALID SLOT") # 意図しないリンク先へ書かない。通常はこの.py隣の専用ディレクトリ。 if any(part.is_symlink() for part in (self.root, *self.root.parents)): raise ValueError("SYMLINK SAVE FOLDER REJECTED") path = self.root / ("design_" + str(slot + 1) + ".json") if path.is_symlink(): raise ValueError("SYMLINK SAVE FILE REJECTED") return path def exists(self, slot): return self.path(slot).exists() def write(self, slot, state, overwrite=False): path = self.path(slot) data = payload(state) decode(data) self.root.mkdir(parents=True, exist_ok=True) temporary = None try: with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=self.root, prefix=".design-", suffix=".tmp", delete=False) as file: temporary = Path(file.name) json.dump(data, file, ensure_ascii=False, indent=2) file.flush() os.fsync(file.fileno()) self.path(slot) # 置換直前にもリンクを拒否。 if overwrite: os.replace(temporary, path) else: # 新規枠は排他的に作成。確認前に既存ファイルを置換しない。 os.link(temporary, path) finally: if temporary is not None: temporary.unlink(missing_ok=True) def read(self, slot): path = self.path(slot) if path.stat().st_size > 20000: raise ValueError("SAVE TOO LARGE") return decode(json.loads(path.read_text(encoding="utf-8"))) class App: def __init__(self, store=None): self.store = store if store is not None else Store() self.slot = 0 self.reset() def reset(self): self.timeline = Timeline() self.cursor = 0 self.plant_kind = 0 self.previous = set() self.gate = False self.message = "PLANT YOUR OWN DESIGN" self.dialog = "" @property def state(self): return self.timeline.state def commit(self, action): if len(self.timeline.actions) >= MAX_ACTIONS and action not in ("view", "undo"): self.message = "HISTORY FULL: SAVE, RESET, LOAD" return old = self.timeline self.timeline = apply(old, action) self.message = "UPDATED" if old != self.timeline else "NO CHANGE / ROCK / MAX 6" def save_slot(self, overwrite=False): self.store.write(self.slot, self.state, overwrite=overwrite) self.dialog = "" self.message = "SAVED DESIGN " + str(self.slot+1) def feed(self, down): # 全キーを離すまで1操作。複数新規キーは採用しない。 down = set(down) & set(KEYS) fresh = down - self.previous self.previous = down if self.gate: if not down: self.gate = False return None if len(fresh) > 1: self.gate = True self.message = "ONE KEY AT A TIME" return None if not fresh: return None key = next(iter(fresh)) self.gate = True self.message = "" if self.dialog: if key in ("X", "ESCAPE"): self.dialog = "" self.message = "CANCELLED" return None if self.dialog in ("save", "load"): if key in ("LEFT", "RIGHT"): self.slot = (self.slot + (1 if key == "RIGHT" else -1)) % 3 elif key in ("1", "2", "3"): self.slot = int(key)-1 elif key == "Z": try: if self.dialog == "save": if self.store.exists(self.slot): self.dialog = "overwrite" else: self.save_slot() else: loaded = self.store.read(self.slot) self.commit("load:" + json.dumps(payload(loaded), ensure_ascii=False)) self.dialog = "" self.cursor = 0 except (OSError, ValueError, UnicodeError, RecursionError) as error: self.message = "FILE ERROR / SEE README" return None if key == "Z": if self.dialog == "quit": return "quit" if self.dialog == "reset": self.reset() self.gate = True elif self.dialog == "overwrite": try: self.save_slot(overwrite=True) except (OSError, ValueError, UnicodeError, RecursionError): self.message = "FILE ERROR / SEE README" return None if key in ("ESCAPE", "R"): self.dialog = "quit" if key == "ESCAPE" else "reset" return None if key == "RETURN": self.commit("submit") return None if key in ("S", "L"): self.dialog = "save" if key == "S" else "load" return None if key == "X": self.commit("undo") return None if key == "SPACE": self.commit("view") return None if key == "TAB": self.plant_kind = 1-self.plant_kind return None x, y = self.cursor % WIDTH, self.cursor // WIDTH if key in ("LEFT", "RIGHT"): self.cursor = y*WIDTH + (x + (1 if key == "RIGHT" else -1)) % WIDTH elif key in ("UP", "DOWN"): self.cursor = ((y + (1 if key == "DOWN" else -1)) % HEIGHT)*WIDTH + x elif key == "Z": self.commit(f"plant:{self.cursor}:{self.plant_kind}") elif key == "BACKSPACE": self.commit(f"remove:{self.cursor}") return None def garden(self, p, left, phase, active): p.rect(left-4, 45, 176, 133, 6 if phase == 0 else 0) p.text(left+4, 49, "DAY: D BLOOMS" if phase == 0 else "NIGHT: N BLOOMS", 1 if phase == 0 else 7) for index, (value, flower_kind, color, opened, rock) in enumerate(render_data(self.state, phase)): x, y = left+(index % WIDTH)*42, 61+(index // WIDTH)*37 p.rect(x, y, 39, 34, 4) p.rectb(x, y, 39, 34, 9) p.text(x+2, y+2, str(index), 6) if rock: p.rect(x+11, y+13, 20, 13, 5) p.line(x+13, y+14, x+24, y+11, 13) elif value: cx, cy = x+22, y+18 p.line(cx, cy, cx, cy+9, 11) p.line(cx, cy+7, cx-5, cy+4, 3) if opened: for dx, dy in ((-4, 0), (4, 0), (0, -4), (0, 4)): p.circ(cx+dx, cy+dy, 3, color) p.circ(cx, cy, 2, 9 if phase == 0 else 10) else: p.circ(cx, cy, 3, 3) p.line(cx, cy-3, cx, cy+3, 11) p.text(x+3, y+24, "D" if flower_kind == 0 else "N", 7) if index == self.cursor: p.rectb(x-1, y-1, 41, 36, 7 if active else 13) def draw(self, p): p.cls(1) p.text(8, 8, "NIGHT GARDEN / STAGE 5", 10) p.text(8, 22, "FLOWERS " + str(sum(bool(v) for v in self.state.flowers)) + "/6 ROCKS 5,6", 7) p.text(210, 22, "BRUSH " + ("DAY" if self.plant_kind == 0 else "NIGHT"), 11) self.garden(p, 10, 0, self.state.view == 0) self.garden(p, 206, 1, self.state.view == 1) report = evaluate(self.state) for line, (label, ok) in enumerate(zip(("TOTAL 6", "DAY 3 / GROUP 1", "NIGHT 3 / GROUP 1"), report["checks"])): p.text(8+line*125, 186, ("OK " if ok else ".. ")+label, 11 if ok else 6) p.text(8, 198, "REQUEST MET" if report["success"] else "YOUR DESIGN / REQUEST NOT MET", 11 if report["success"] else 10) p.text(8, 210, "SUBMITTED / KEEP EDITING" if self.state.submitted else "ENTER CHECK / GOALS ARE OPTIONAL", 6) p.text(8, 230, self.message, 10) p.text(8, 245, "ARROWS SELECT Z PLANT BACKSPACE REMOVE", 7) p.text(8, 257, "TAB D/N Z AGAIN COLOR SPACE DAY/NIGHT", 6) p.text(8, 269, "S SAVE L LOAD X UNDO R CLEAR ESC QUIT", 6) p.text(8, 283, "3 SEPARATE DESIGNS / NO SUCCESS GATE", 11) if self.dialog: p.rect(35, 91, 314, 83, 1) p.rectb(35, 91, 314, 83, 10) p.text(49, 104, self.dialog.upper() + "?", 10) if self.dialog in ("save", "load"): p.text(49, 118, "SLOT " + str(self.slot+1) + " / LEFT RIGHT OR 1 2 3", 7) elif self.dialog == "overwrite": p.text(49, 118, "REPLACE DESIGN " + str(self.slot+1), 7) p.text(49, 134, "Z CONFIRM / X OR ESC CANCEL", 7) p.text(49, 150, self.message, 8) KEYS = ('UP', 'DOWN', 'LEFT', 'RIGHT', 'Z', 'BACKSPACE', 'R', 'ESCAPE', 'SPACE', 'TAB', 'RETURN', 'X', 'S', 'L', '1', '2', '3') DEMO_KEYS = ('Z', 'RIGHT', 'Z', 'RIGHT', 'Z', 'TAB', 'DOWN', 'DOWN', 'Z', 'LEFT', 'Z', 'LEFT', 'Z', 'SPACE', 'RETURN') def main(headless=False, screenshot=None, demo=False): from pathlib import Path import pyxel as p target = Path(screenshot).resolve() if screenshot else None if target: target.parent.mkdir(parents=True, exist_ok=True) app = App() p.init(384, 300, title="078 Night Garden", fps=30, display_scale=2, headless=headless, quit_key=p.KEY_NONE) keymap = {key: getattr(p, "KEY_"+key) for key in KEYS} limit = max(12, 2*len(DEMO_KEYS)+4) if demo else 12 captured = False def update(): if headless and p.frame_count >= limit: from dataclasses import asdict import json print(json.dumps({"state": asdict(app.state), "message": app.message}, ensure_ascii=True, sort_keys=True), flush=True) p.quit() return if headless and demo: index = p.frame_count//2 down = {DEMO_KEYS[index]} if p.frame_count%2 == 0 and index < len(DEMO_KEYS) else set() else: down = {key for key, value in keymap.items() if p.btn(value)} if app.feed(down) == "quit": p.quit() def draw(): nonlocal captured app.draw(p) if headless and target and p.frame_count == limit-1 and not captured: p.screenshot(str(target), scale=2) captured = True p.run(update, draw) return app if __name__ == "__main__": import argparse parser = argparse.ArgumentParser() 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)