# 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+ / 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 OUTSIDE_T = 4 OUTSIDE_H = 2 ACTIONS = (-1, 0, 1, 2) # -1は窓を変えない。0/1/2は左/中/右を開閉。 @dataclass(frozen=True) class State: t: int = 0 T: tuple[int, int, int] = (7, 7, 6) H: tuple[int, int, int] = (7, 6, 8) windows: tuple[bool, bool, bool] = (False, False, False) @property def ended(self): return self.t == TICKS def clamp(values): return tuple(max(0, min(12, value)) for value in values) def approach(value, outside): # 差が+なら増やし、-なら減らす。近いときは外気で止まる。 return value + max(-2, min(2, outside - value)) def step(state, action=-1): if type(action) is not int or action not in ACTIONS: raise ValueError("action must be -1, 0, 1 or 2") if state.ended: return state windows = list(state.windows) if action != -1: windows[action] = not windows[action] # ① 日差しと土の湿り気。まだ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)) # ② この刻の開閉を反映した窓で、外気へ最大2近づける。 vent_T = tuple(approach(heat_T[i], OUTSIDE_T) if windows[i] else heat_T[i] for i in range(3)) vent_H = tuple(approach(heat_H[i], OUTSIDE_H) if windows[i] else heat_H[i] for i in range(3)) final_T = clamp(vent_T) final_H = clamp(vent_H) return replace(state, t=state.t + 1, T=final_T, H=final_H, windows=tuple(windows)) class App: def __init__(self): self.locked = False self.reset() def reset(self): self.state = State() self.message = "SPACE: ADVANCE ONE TICK" self.cursor = 0 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 in ("LEFT", "RIGHT"): self.cursor = (self.cursor + (-1 if key == "LEFT" else 1)) % 3 self.message = "SELECTED ZONE " + str(self.cursor + 1) return if key == "RETURN": self.commit(self.cursor) return if key == "SPACE": self.commit(-1) def draw(self, p): s = self.state p.cls(1) p.text(8, 8, "SHARED GREENHOUSE / 2: PERSISTENT WINDOWS", 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, "OUTSIDE T4 H2 / OPEN WINDOW MOVES BY AT MOST 2", 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, 10 if i == self.cursor else 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.rectb(x+100, 148, 32, 25, 6) if s.windows[i]: p.line(x+100, 148, x+88, 143, 11) p.line(x+88, 143, x+88, 168, 11) else: p.line(x+100, 148, x+132, 173, 6) p.text(x+8, 154, "WINDOW " + ("OPEN" if s.windows[i] else "CLOSED"), 11 if s.windows[i] else 6) 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 -> WINDOWS -> CLAMP / NO DIFFUSION", 10) p.text(8, 300, self.message, 10) p.text(8, 318, "LEFT/RIGHT: SELECT / ENTER: TOGGLE + TICK / SPACE: KEEP + TICK", 7) p.text(8, 330, "R: RESET / ESC: QUIT", 7) p.text(8, 348, "RELEASE ALL KEYS BETWEEN ACTIONS / NOT REAL GROWING ADVICE", 5) KEYS = ('SPACE', 'R', 'LEFT', 'RIGHT', 'RETURN') DEMO_KEYS = ('RETURN', '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)