# 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。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 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 step(state, action): if action == "view": return replace(state, view=1-state.view) 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)) class App: def __init__(self): self.reset() def reset(self): self.state = State() self.cursor = 0 self.plant_kind = 0 self.previous = set() self.gate = False self.message = "PLANT YOUR OWN DESIGN" def commit(self, action): old = self.state self.state = step(old, action) self.message = "UPDATED" if old != self.state else "NO CHANGE / ROCK / MAX 6" 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 key == "ESCAPE": return "quit" if key == "R": self.reset() self.gate = True 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 2", 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) p.text(8, 186, "ONE ARRAY, TWO VIEWS. TIME DOES NOT PASS.", 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, "R CLEAR ESC QUIT", 6) KEYS = ('UP', 'DOWN', 'LEFT', 'RIGHT', 'Z', 'BACKSPACE', 'R', 'ESCAPE', 'SPACE', 'TAB') DEMO_KEYS = ('Z', 'RIGHT', 'Z', 'TAB', 'RIGHT', 'Z', 'SPACE') 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)