# 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+ / 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は左/中/右を開閉。 TARGET = 3 PLANT_RANGES = (((6, 10), (4, 9)), ((3, 8), (6, 11))) @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) g: tuple[int, int] = (0, 0) @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 diffuse(old): # 2本の流れを、どちらも同じoldから計算する。 differences = (old[0] - old[1], old[1] - old[2]) flows = tuple((1 if d >= 0 else -1) * (abs(d) // 4) for d in differences) left, right = flows new = (old[0] - left, old[1] + left - right, old[2] + right) return new def comfortable(plant, T, H): (t0, t1), (h0, h1) = PLANT_RANGES[plant] return t0 <= T <= t1 and h0 <= H <= h1 def outcome(state): if not state.ended: return "PLAYING" return "SUCCESS" if min(state.g) >= TARGET else "INCOMPLETE" 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)) # ③ T/Hをそれぞれ同時拡散。④ 最後に0〜12へ制限。 final_T = clamp(diffuse(vent_T)) final_H = clamp(diffuse(vent_H)) # ⑤ 最終値で判定。左鉢は区画0、右鉢は区画2。 growth = tuple(int(comfortable(p, final_T[zone], final_H[zone])) for p, zone in enumerate((0, 2))) g = tuple(min(8, state.g[p] + growth[p]) for p in range(2)) return replace(state, t=state.t + 1, T=final_T, H=final_H, windows=tuple(windows), g=g) def forecast(state, action=-1): # stepと同じ関数を呼ぶ。結果を表示するだけで現在のstateには代入しない。 return None if state.ended else step(state, action) 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 self.pending = None self.history = [] def commit(self, action): new = step(self.state, action) self.pending = None if new == self.state: self.message = "EIGHT TICKS FINISHED / R: RESET" return self.history.append(self.state) 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 == "X": self.pending = None if self.history: self.state = self.history.pop() self.message = "UNDO: TIME / T / H / WINDOWS / GROWTH RESTORED" else: self.message = "NO EARLIER TICK" return if key in ("LEFT", "RIGHT"): self.cursor = (self.cursor + (-1 if key == "LEFT" else 1)) % 3 self.pending = None self.message = "SELECTED ZONE " + str(self.cursor + 1) return if key == "Z": self.pending = None if self.pending == self.cursor else self.cursor self.message = "CANDIDATE ONLY / ENTER TO COMMIT" return if key == "RETURN": self.commit(self.pending if self.pending is not None else -1) return if key == "SPACE": self.commit(-1) def draw(self, p): s = self.state p.cls(1) p.text(8, 8, "SHARED GREENHOUSE / 5: FORECAST AND UNDO", 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) if i != 1: plant = i // 2 p.rect(x+18, 222, 30, 12, 4) p.line(x+33, 222, x+33, 211-s.g[plant]*3, 11) for leaf in range(s.g[plant]): p.line(x+33, 219-leaf*3, x+22, 213-leaf*3, 3) p.line(x+33, 218-leaf*3, x+44, 210-leaf*3, 11) p.text(x+57, 202, "GROW " + str(s.g[plant]) + "/3", 11) p.text(x+57, 214, "READY" if comfortable(plant, s.T[i], s.H[i]) else "REST", 10) p.text(x+8, 240, "T6..10 H4..9" if i == 0 else "T3..8 H6..11", 7) else: p.text(x+8, 202, "NO PLANT HERE", 6) action = self.pending if self.pending is not None else -1 future = forecast(s, action) label = "KEEP WINDOWS" if action == -1 else "TOGGLE ZONE " + str(action+1) p.text(8, 260, outcome(s) + " / CANDIDATE: " + label, 10) if future: p.text(8, 272, "NEXT T"+str(future.T)+" H"+str(future.H)+" G"+str(future.g), 11) p.text(8, 284, "EXACTLY ONE TICK / ENTER USES THIS CANDIDATE", 6) else: p.text(8, 272, "NO NEXT TICK / X UNDO OR R RESET", 6) p.text(8, 300, self.message, 10) p.text(8, 318, "LEFT/RIGHT SELECT / Z CANDIDATE / ENTER COMMIT / SPACE KEEP", 7) p.text(8, 330, "X: UNDO / 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', 'Z', 'X') DEMO_KEYS = ('SPACE', 'SPACE', 'RIGHT', 'Z', 'RETURN', 'X', 'Z') 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)