# 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. """ひとつの温室・教材1/5。Python 3.11+ / Pyxel 2.9.9。""" from dataclasses import dataclass, replace # これは摂氏・相対湿度でなく、ゲームだけの0〜12の整数目盛。 # 原作A: 前半4刻だけ左へ日差し+1。外気は8刻ともT4/H2。 SUN = ((1, 0, 0),) * 4 + ((0, 0, 0),) * 4 TICKS = 8 @dataclass(frozen=True) class State: t: int = 0 T: tuple[int, int, int] = (7, 7, 6) H: tuple[int, int, int] = (7, 6, 8) @property def ended(self): return self.t == TICKS def clamp(values): return tuple(max(0, min(12, value)) for value in values) def step(state, action=-1): if type(action) is not int or action != -1: raise ValueError("stage 1 accepts wait (-1) only") if state.ended: return state # ① 日差しと土の湿り気。まだclampしない。 heat_T = tuple(state.T[i] + SUN[state.t][i] for i in range(3)) heat_H = tuple(state.H[i] + (1 if i in (0, 2) else 0) for i in range(3)) final_T = clamp(heat_T) final_H = clamp(heat_H) return replace(state, t=state.t + 1, T=final_T, H=final_H) class App: def __init__(self): self.locked = False self.reset() def reset(self): self.state = State() self.message = "SPACE: ADVANCE ONE TICK" def commit(self, action): new = step(self.state, action) if new == self.state: self.message = "EIGHT TICKS FINISHED / R: RESET" return self.state = new self.message = "TICK " + str(new.t) + " / ONE ACCEPTED DECISION" def control(self, down): # ゲームで使うキーだけ読む。長押しや同時押しで刻を飛ばさない。 down = set(down) & set(KEYS) if self.locked: if not down: self.locked = False return if not down: return self.locked = True if len(down) != 1: self.message = "ONE KEY AT A TIME / RELEASE ALL" return key = next(iter(down)) if key == "R": self.reset() return if key == "SPACE": self.commit(-1) def draw(self, p): s = self.state p.cls(1) p.text(8, 8, "SHARED GREENHOUSE / 1: SUN AND THREE ZONES", 10) p.text(8, 22, "T: WARMTH H: MOISTURE TOY MARKS 0..12", 6) p.text(8, 36, "TICK " + str(s.t) + "/8 SUN LEFT +1 AT TICKS 1..4", 7) p.text(8, 48, "SOIL ADDS H+1 TO LEFT AND RIGHT EVERY TICK", 7) for i, name in enumerate(("LEFT", "MIDDLE", "RIGHT")): x = 8 + i * 156 p.rect(x, 66, 150, 186, 2 if i != 1 else 0) p.rectb(x, 66, 150, 186, 5) p.text(x+8, 76, name + " / ZONE " + str(i+1), 7) for y, label, values, color in ((98, "T", s.T, 9), (119, "H", s.H, 12)): p.text(x+8, y, label + " " + str(values[i]), color) for n in range(12): p.rect(x+36+n*8, y, 6, 7, color if n < values[i] else 5) p.text(x+8, 204, "NO GROWTH GOAL YET", 6) p.text(x+8, 218, "SPACE CHANGES VALUES", 6) p.text(8, 266, "ORDER: SUN + SOIL -> CLAMP / NO WINDOWS OR DIFFUSION", 10) p.text(8, 300, self.message, 10) p.text(8, 318, "SPACE: ONE TICK / R: RESET / ESC: QUIT", 7) p.text(8, 348, "RELEASE ALL KEYS BETWEEN ACTIONS / NOT REAL GROWING ADVICE", 5) KEYS = ('SPACE', 'R') DEMO_KEYS = ('SPACE', 'SPACE', 'SPACE') def main(headless=False, screenshot=None, demo=False): from pathlib import Path import pyxel as p # 通常起動ではファイルを読まず書かない。QA画像は指定した場所だけへ。 screenshot = str(Path(screenshot).resolve()) if screenshot else None if screenshot: Path(screenshot).parent.mkdir(parents=True, exist_ok=True) app = App() p.init(480, 360, title="Shared Greenhouse", fps=30, display_scale=2, headless=headless) keymap = {key: getattr(p, "KEY_"+key) for key in KEYS} limit = max(12, 2*len(DEMO_KEYS)+3) if demo else 12 def update(): if headless and p.frame_count >= limit: if screenshot: p.screenshot(screenshot, scale=1) 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)} app.control(down) p.run(update, lambda: app.draw(p)) 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)