シミュレーションLESSON 253区画の環境を窓で調整する整数シミュレーション原作072

ひとつの温室の作り方

窓を開けると、となりの区画も変わる。
日差し、換気、拡散(となりへ広がること)、成長を、順番に分けて計算します。3つの区画の古い値から、次の温室の様子を予告する小さな模型を作ります。

完成版の遊び方3つの窓を開け閉めして、温室の暖かさと湿り気を動かします。8刻(このゲームの時間で8回分)のうちに、2つの鉢をそれぞれ3回以上育てます。

ひとつの温室の元の完成版。自動操作によるPyxelの実描画画面。
元の完成版の画面です。この講座では、仕組みがわかりやすいように小さくした「教材版」を、5つのステップで作ります。
PLAY / 試験公開

完成サンプルをブラウザーで遊ぶ

この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。

完成版の主な操作:左右キーで区画を選ぶ、Zで窓の開け閉めの候補を置く、Enterで窓を変えて1刻進める、Spaceで待つ、Xで戻す、Tabで詳しい表示

ボタンを押すと、ゲームの読み込みが始まります。

画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。

うまく表示されないとき

ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。

同じPyxelアプリをダウンロード(.pyxapp) / 別のタブで開く

となりの区画との間の流れを、同じ古い値から計算する。

いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、5つのステップで作っていきます。どのステップのファイルもそれだけで動くので、動きを確かめてから次へ進みましょう。

1. タプル、書き換えない状態、1刻ずつの更新
2. 状態を残しておく真偽値(TrueかFalse)と、上限のある変化
3. 符号(プラスかマイナスか)を考えた整数の割り算と、同時の更新
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
Pythonの変数・if・for・関数 / タプルとリスト、添字(何番目かを表す番号)が0から始まること / dataclass・replace・bool と int・書き換えない状態(不変状態)は本文で説明します
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
Space:すべての段階で、窓を変えずに1刻進める / 左右:02〜05で区画を選ぶ。時刻は進まない / Enter:02〜04では、選んだ窓を開け閉めして1刻進める。05では、候補を確定して1刻進める。候補がなければ待つ / Z:05だけ。選んだ窓の開け閉めを候補にする・取り消す / X:05だけ。状態全体を1刻戻す / R:状態を最初に戻す。05では履歴も消す / Esc:終了
難易度の目安
初級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_sun.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。

教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。

STEP 01 / 05

3区画の暖かさと湿り気を進める

タプル、書き換えない状態、1刻ずつの更新。

まず、3つの区画だけを作ります。プログラムの中の番号0・1・2は、左・中・右の区画です。画面では ZONE 1・2・3 と表示します。T は暖かさ、H は湿り気を表す、このゲームだけの目盛です。摂氏の温度や湿度(相対湿度)ではありません。T=(7,7,6) は、左7・中7・右6という3つの値です。これを、順番のあるタプル(あとから変えられない値の組)で保存しています。このような値の組を、この講座では「ベクトル」と呼びます。数学や本物の気象を先に知らなくても大丈夫です。

State は、t(時刻)、T、H を1つにまとめる dataclass です。frozen=True にして、作った State の中身を、あとから代入で書き換えないようにします。replace(state, t=..., T=..., H=...) は、元の State を残したまま、新しい State を作ります。古い値を読んで新しい値を作るこの流れは、あとで作る同時の拡散や、Undo(元に戻す)にも使います。

Space を押すと、step を1回呼びます。1回で1刻(このゲームの時間の1区切り)進みます。最初の4刻は、日差しで左の T が+1されます。後半の4刻は、日差しが0です。両はしの区画では、土から毎刻 H が+1されます。中央の H は増えません。SUN[state.t][i] の最初の添字は「これから進める刻」を選び、次の添字は区画を選びます。t=0 のときに SUN[0] を使って計算した結果が、t=1 の状態になります。T と H を作ったら、clamp で0〜12の範囲に収めます。窓、区画の間の移動、植物の成長、成功の判定は、まだありません。8刻で止まるのは、どの段階でも共通の観察期間です。

App.control は、キーの入力を操作に変えます。step は、数字の計算だけをします。App.draw は、計算が終わった State を描きます。draw の中で step を呼ぶと、1秒に30回も進んでしまいます。そのため、描画と、時間を進める処理を分けます。キーを押している間は、locked が次の入力を止めます。すべてのキーを離すと、また入力を受け付けます。説明を読むために待っても、フレーム(画面の1コマ)が増えても、ゲームの t は増えません。

下のコードは 01_sun.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

01_sun.py / 注目する部分
    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))

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 01_sun.py
macOS / Linux
.venv/bin/python 01_sun.py
画面で見ること起動してから Space を3回押すと、TICK 3、T=(10,7,6)、H=(10,6,11) になります。8回押すと、T=(11,7,6)、H=(12,6,12) です。9回目は何も変わりません。R を押すと、最初の値に戻ります。
  • 左・中・右を、添字0・1・2で読める
  • t=0 の更新には SUN[0] を使う
  • 何も押さないときや長押しでは、刻が続けて進まない
  • 元の State が書き換わらない
つまずきやすいところ画面で1番目だからといって T[1] を読むと、中央の区画を見てしまいます。プログラムの中の番号は0から始まります。また、t を増やしてから SUN を読むと、日差しの表が1刻ずれます。最後には、表の範囲の外を読んでしまいます。
少し変えてみよう

4刻目と5刻目を比べて、T は増えないのに H は増えようとする理由を説明してみましょう。次に、SUN の先頭だけを (0,1,0) に変えます。最初の Space で、中央の T だけが増えるかを確かめましょう。確かめたら、変更を元に戻します。

01_sun.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(169行)
01_sun.py / 全文
# 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)
STEP 02 / 05

開いたままの窓で外気へ近づける

状態を残しておく真偽値(TrueかFalse)と、上限のある変化。

State に windows=(False,False,False) を追加します。False は閉じている、True は開いているという意味です。窓を開けた効果は、次の刻にも残ります。そのため、キーを押した瞬間だけ使う変数にはしません。左右キーは、選択の枠を動かすだけです。Enter を押すと、選んだ窓の開け閉めを反対にして、その新しい窓の状態で1刻進めます。Space は、窓を変えずに1刻進めます。時間を使わずに、窓の開け閉めだけを何回も行うことはできません。この段階の Enter は、選んだ窓を直接開け閉めします。05では、「候補を置いてから確定する」方式に変わります。

step の最初で、windows をリストにコピーします。そして、windows[action] = not windows[action] で1か所だけを反対にします。保存するときは、tuple(windows) でタプルに戻します。元の State は、タプルのまま残ります。action=-1 は「待つ」を表す特別な値です。そのため、必ず if action != -1 で確かめてから、添字として使います。Python では [-1] が右はしを指します。この確認を忘れると、「待つ」で右の窓が開け閉めされてしまいます。

日差しと土の湿り気を加えたあと、窓が開いている区画にだけ approach を使います。外の空気(外気)は、元のゲームの状況Aと同じ T 4・H 2 で、8刻の間ずっと変わりません。outside-value(外の値から今の値を引いた差)がプラスなら、外のほうが大きいので値を増やします。マイナスなら減らします。max(-2,min(2,差)) は、変化を-2〜+2の範囲に収めます。今が7で外が4なら5へ、今が3で外が4なら4へ動きます。いつも2を引く処理ではないので、外気の値を通り越しません。この関数は、外のほうが湿っていれば湿り気を増やすこともできます。ただし、この教材の状況Aでは、外の H は2です。

更新の順番は、日差しと土 → 窓 → 範囲の制限 です。最初に左の窓を開くと、左の T は7+1=8から6へ、左の H も7+1=8から6へ動きます。次の刻も窓は開いたままなので、T は6+1=7から5へ動きます。区画どうしは、まだつながっていません。ここで観察するのは、窓の状態が残ることと、外気に近づくことだけです。同時の拡散は、次の段階から加えます。

下のコードは 02_windows.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

02_windows.py / 注目する部分
def approach(value, outside):
    # 差が+なら増やし、-なら減らす。近いときは外気で止まる。
    return value + max(-2, min(2, outside - value))

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 02_windows.py
macOS / Linux
.venv/bin/python 02_windows.py
画面で見ること起動してから Enter→Space と押すと、TICK 2 になり、左の窓が OPEN(開いている)、T=(5,7,6)、H=(5,6,10) になります。もう一度 Enter を押すと、左の窓が閉じて3刻目に進み、T=(6,7,6)、H=(6,6,11) になります。
  • 開いた窓は、Space のあとも開いたまま
  • 左右キーだけでは時間が進まない
  • 開け閉めした窓は、その刻から効く
  • 外気の値が近いときも、通り越さない
つまずきやすいところ外気が4だからといって、毎回 value-2 にする作り方では、3が1になり、外気から遠ざかってしまいます。outside-value の符号(プラスかマイナスか)を見ます。また、「待つ」を表す-1を、そのまま窓の添字に使ってはいけません。
少し変えてみよう

approach(3,4)、approach(7,4)、approach(2,10) を紙で計算して、4・5・4になるかを確かめましょう。次に、R のあと右キーで中央を選び、Enter を押します。中央の H は6から4へ下がり、両はしの H は8と9へ増えることを観察します。

02_windows.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(203行)
02_windows.py / 全文
# 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)
STEP 03 / 05

同じ古い値から2本の流れを決める

符号を考えた整数の割り算と、同時の更新。

窓の処理のあと、となり合う左と中、中と右の間で目盛を移します。T と H には、別々に同じ diffuse 関数を使います。これは「差の4分の1を整数で移す」という、このゲームの決まりです。本物の温室で熱や水蒸気が伝わる様子を、正確にまねする式ではありません。この段階で、温室の環境の更新が、元のゲームの状況Aのふつうのモードと同じになります。植物の目標は、まだありません。

old=(左,中,右) を受け取り、差を (old[0]-old[1], old[1]-old[2]) として計算します。流れがプラスなら、左→中、または中→右へ移ります。マイナスなら逆向きです。d=5 なら abs(d)//4 が1なので、流れは+1です。d=-5 なら-1です。d=-1 なら、移す量は0です。Python の -1//4 は、小さいほうの整数に丸めるので-1になります。ほしい答えの0にはなりません。そこで、符号を (1 if d>=0 else -1) で別に取り出します。そして、絶対値(符号を外した大きさ)を4で割ってから、符号を掛け直します。// は小数の部分を切り捨てる計算ですが、マイナスの値では「0に近づける」とは限りません。ここが大事な点です。

左側の流れも右側の流れも、両方とも変更前の old だけを読みます。そのあとで、new=(old[0]-left, old[1]+left-right, old[2]+right) を一度に作ります。たとえば old=(12,0,8) なら、差は (12,-8)、流れは (3,-2) で、new=(9,5,6) になります。中央は、左から3、右から2を受け取ります。もし先に左からの3を中央に足して、その新しい中央の3で右側の流れを計算すると、流れは-1になります。結果は (9,4,7) となり、別のルールになってしまいます。コードを上から順に書いていても、計算に使う値をすべて old にそろえれば、同時の更新になります。

clamp は、拡散が終わった最後に1回だけ使います。T=(12,0,0) に日差しが当たると、いったん (13,0,0) になります。そして、流れ3で (10,3,0) になります。先に13を12に切ると、(9,3,0) になり、1が失われます。拡散だけなら、3つの区画の合計は変わりません。でも、日差し・土・窓・最後の制限までふくめた1刻全体では、合計が変わらないとは限りません。

下のコードは 03_diffusion.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

03_diffusion.py / 注目する部分
    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)

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 03_diffusion.py
macOS / Linux
.venv/bin/python 03_diffusion.py
画面で見ることR のあと、右キー→Enter→Space→Space と押して、中央の窓を開いたまま3刻進めます。T=(8,5,6)、H=(7,6,8) になります。02とちがい、中央の窓の影響が、目盛の差を通して両はしにも届きます。
  • マイナスの小さい差を、0に丸められる
  • 2つの流れが、両方とも同じ old を読む
  • 拡散だけなら合計が変わらない
  • clamp は、拡散のあとに1回だけ
つまずきやすいところ新しくなった中央の値で2本目の流れを計算すると、結果が処理の順番で変わってしまいます。もう1つは、d//4 をそのまま使うまちがいです。マイナスの小さい差でも、勝手に1目盛移してしまいます。
少し変えてみよう

diffuse((0,1,1)) が (0,1,1) に、diffuse((0,4,4)) が (1,3,4) になる理由を説明してみましょう。さらに (12,0,8) を紙で計算して、更新の前後で合計の20が変わらないかを確かめます。

03_diffusion.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(215行)
03_diffusion.py / 全文
# 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.

"""ひとつの温室・教材3/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 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 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))
    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 / 3: SIMULTANEOUS DIFFUSION", 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 -> DIFFUSION -> CLAMP", 10)
        p.text(8, 278, "BOTH FLOWS READ THE SAME OLD VECTOR", 12)
        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 = ('RIGHT', 'RETURN', '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)
STEP 04 / 05

2鉢を8刻で育てる目標を加える

範囲に入っているかの判定と、環境を更新したあとの成長。

左の草花と右のシダに、成長の値 g=(0,0) を追加します。g の添字0は左の鉢、1は右の鉢です。一方、環境の区画の番号は、左0・中1・右2です。右の鉢の環境を T[1] から読むと、中央を見てしまいます。そこで enumerate((0,2)) を使い、「鉢の番号 p」と「区画 zone」を組み合わせます。中央には植物がありません。

草花は、T が6〜10で、しかも H が4〜9のときに育ちます。シダは、T が3〜8で、しかも H が6〜11のときに育ちます。どちらも、はしの値をふくみます。t0 <= T <= t1 and h0 <= H <= h1 で、2つの条件を同時に満たすかを調べます。片方だけが快適でも育ちません。comfortable の True か False を int で1か0に変え、1刻につき各鉢に最大1を足します。範囲の外なら休むだけです。成長が減ったり、枯れたりはしません。画面の READY は、今の T と H が範囲の中にあるという意味です。直前に育ったかどうかを表すものではありません。

判定に使うのは、日差し→窓→同時の拡散→0〜12の制限 をすべて終えた final_T と final_H です。刻の始めに判定したり、T だけを拡散の前に読んだりすると、同じ操作でも成長が変わってしまいます。8刻になると、状態は止まります。両方の g が3以上なら SUCCESS(成功)、どちらかが足りなければ INCOMPLETE(未達成)です。3に届いた瞬間には終わらず、8刻までさらに育つかを確かめられます。g の上限が8なのは、1刻に最大1しか育たないという、このゲームの決まりによるものです。

ここからは目標まで、元のゲームの状況Aのふつうのモードと同じになります。この教材の状況Aで、窓を全部閉じたまま8刻進めると、g=(2,5) で未達成です。これは左の鉢の成長が足りないためです。操作の回数による、隠れた減点はありません。いちばん多く育てる例を紹介します。最初の2刻は Space で進めます。次に選択を中央へ移し、Enter で3刻目に中央の窓を開けます。残りの5刻は Space で進めます。すると両方の鉢が毎刻育ち、g=(8,8) になります。成功したかどうかと、いちばん多く育てることは、別の基準です。はじめは、両方の鉢を3まで育てることを目標にしましょう。

下のコードは 04_plants.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

04_plants.py / 注目する部分
    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))

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 04_plants.py
macOS / Linux
.venv/bin/python 04_plants.py
画面で見ることSpace を2回→右キー→Enter→Space を5回と押すと、TICK 8 / SUCCESS(成功)、g=(8,8)、T=(7,4,6)、H=(9,7,9) になります。そのあとは、Space や Enter を押しても同じ状態のままです。
  • 右の鉢は、区画2の T と H を読む
  • 快適な範囲の、はしの値もふくめる
  • 判定は、最後の clamp のあと
  • 片方だけ3になっても成功しない
  • 8刻が終わったあとの操作では、状態が変わらない
つまずきやすいところ右の鉢の番号1を区画の番号にも使うと、右の植物が中央の環境で育ってしまいます。p は0か1、zone は0か2と、意味を分けて使います。成長の判定を更新の前に移すのも、1刻ずれる原因になります。
少し変えてみよう

草花は、(T, H) が (6,4) と (10,9) のときは育ち、(5,4) と (10,10) のときは休みます。このことを comfortable で確かめましょう。次に、窓を全部閉じたまま8刻進める操作と、3刻目に中央の窓を開ける操作を比べます。「達成できたか」と「いくつ育ったか」を、別々に記録してみましょう。

04_plants.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(242行)
04_plants.py / 全文
# 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.

"""ひとつの温室・教材4/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)


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 / 4: TWO PLANTS / EIGHT TICKS", 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)
        p.text(8, 266, outcome(s) + " / BOTH PLANTS NEED 3 BY TICK 8", 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 = ('SPACE', 'SPACE', 'RIGHT', 'RETURN', 'SPACE', 'SPACE', '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)
STEP 05 / 05

同じ計算の予告と丸ごとUndoを作る

ほかを書き換えない試し計算と、状態の履歴。

最後の段階では、操作のしかたが変わります。左右キーで区画を選び、Z で開け閉めの候補を置き、Enter でその候補を実行します。同じ区画でもう一度 Z を押すと、候補を取り消します。左右キーで選び直したときも、候補は消えます。候補がないときの Enter と Space は、窓を変えずに1刻進めます。Space は、候補があっても「窓を変えない」を実行します。Z や矢印キーだけでは、時間も窓も変わりません。

forecast は step(state,action) を呼び、次の State を返すだけです。今の app.state には代入しません。画面の NEXT T/H/G は、候補をそのまま確定したときの、1刻後の値です。予告のためだけの、だいたいの式を別に書くわけではありません。そのため、Enter で呼ぶ step の結果と必ず一致します。8刻が終わったあとは次の刻がないので、None を返し、NEXT の数字も表示しません。これは、1刻先を確実に計算する試し計算です。この先どの窓を操作するかまで当てる、予言ではありません。

commit では、新しい State が本当に変わったときだけ、history.append(self.state) で、それまでの状態全体を保存します。そして、今の状態を new に入れ替えます。X を押すと、history.pop() で最後の State を取り出して元に戻します。時刻、T、H、窓、成長のすべてが戻ります。予告の候補は消します。選択の枠は画面の操作(UI)の情報なので、そのまま残します。8刻が終わった状態からも戻れます。Z や選択だけでは、履歴は増えません。終わったあとに Space を押しても同じ State なので、意味のない履歴は積みません。R は、今の状態と履歴を最初に戻します。

たとえば2刻待ってから、Z で中央を候補にします。このとき、今の状態は t=2、T=(9,7,6)、H=(9,7,9)、g=(2,2) です。予告は T=(9,6,6)、H=(9,7,9)、g=(3,3) です。中央の T は、換気した直後は5です。でも、左の9との差4から1を受け取るので、最後は6になります。Enter を押すとその状態になり、X で2刻目に戻ります。この差を説明できれば、予告・更新の順番・同時の拡散が、1つにつながって理解できています。

完成版で作るのは、元のゲームの状況Aのふつうのルール、3つの窓、2つの鉢、8刻、候補による1刻先の予告、Undo です。状況BとCの日、拡散を止めて比べる版、元のゲームの各段階の TickReport を全部保存するログ、JSON の再現記録、それ以降の窓をそのままにした場合の8刻目までの試し計算、表紙・一時停止・音・日本語の文字(グリフ)は作りません。元のゲームにある、判断の回数の上限256や、イベントの数の上限4095も省きます。履歴は、今の8刻分だけです。基本のルールが同じことと、元のゲームのアプリ全体が同じことは、分けて考えます。

下のコードは 05_forecast_undo.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

05_forecast_undo.py / 注目する部分
        self.history.append(self.state)
        self.state = new

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 05_forecast_undo.py
macOS / Linux
.venv/bin/python 05_forecast_undo.py
画面で見ることSpace を2回→右→Z と押すと、NEXT T(9,6,6) H(9,7,9) G(3,3) と表示されます。Enter を押すと、予告どおりの3刻目になります。X を押すと、TICK 2・窓は全部閉じた状態・g=(2,2) に戻ります。Z で中央の候補を置き直すと、同じ予告が出ます。
  • Z だけでは State が変わらない
  • 候補の予告と、Enter の結果が一致する
  • Spaceは候補を使わない
  • Xで時刻も窓も成長も戻る
  • 終わったあとの操作では、履歴を増やさない
つまずきやすいところ予告のために self.state=step(...) と代入すると、表示するだけでゲームが進んでしまいます。また、Undo で時計だけを戻すと、窓や成長は先の状態のまま残ります。すると、同じ操作をやり直しても、別の結果になってしまいます。
少し変えてみよう

上の、中央を候補にした状態で、Enter の代わりに Space を押してみましょう。中央の窓が閉じたまま進むことを確かめます。そのあと、R から8刻を終えて、X で7刻目に戻し、Space で同じ終わりの状態をもう一度作ります。

05_forecast_undo.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(272行)
05_forecast_undo.py / 全文
# 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)
AFTER STEP 05

仕組みがわかったら、元の完成版を読んでみよう。

完成版の範囲は、元のゲームの状況Aのふつうのモードです。最初は T が (7,7,6)、H が (7,6,8) です。前半の4刻だけ、左の T が+1されます。毎刻、両はしの H が+1されます。開いた窓からは、外の T 4・H 2 に向かって最大2ずつ近づきます。同じ古いベクトルから、差の1/4を、プラスとマイナスで同じように扱って同時に拡散します。最後に、0〜12の範囲に収めます。左の鉢は T 6〜10・H 4〜9、右の鉢は T 3〜8・H 6〜11 のときに1育ちます。8刻で両方の鉢が3以上なら成功です。

ここまでに作ったのは、元のゲームの仕組みだけを取り出して作り直した「簡略版」です。元のゲームのソースは手を加えずに、別のZIP(元の完成版ソース)に入れてあります。

比べる点5ステップの教材元の完成版
状況とモード元のゲームの状況Aのふつうのモードだけ。BとC、拡散なしの比較は省略。A・B・Cと、Aの拡散だけを止めた比較版。
段階ごとの近づけ方01は日差しと土だけ。02で窓を追加。03で元のゲームの状況Aと同じ環境の更新。04で成長と達成。05で予告と Undo。最初から全部の機能がある。01と02の結果は、元のゲームの再現ではない。
窓の表し方3つの bool のタプル。元のゲームと比べるテストのときだけ整数に変える。0〜7の整数の3ビット(2進数の3けた)。1・2・4が、左・中・右の窓の開け閉めを表す。
入力02〜04の Enter は、選んだ窓を直接開け閉めする。05は Z で候補→Enter。キーを全部離すまで次の入力を受け付けない、1キーずつの仕組み。すべての状況で Z で候補→Enter。表紙、P・H での停止、1〜3での状況の選択、B の比較なども持つ。
予告と履歴次の State を予告し、State 全体を最大8刻分 Undo できる。段階ごとの TickReport、窓をそのままにした最後までの試し計算、記録の保存、判断の上限256は省略。TickReport の完全なログから再生して Undo する。段階(位相)の表示、窓をそのままにした終わりまでの試し計算、SHA-256(データから作る指紋のような値)付きの再現記録、判断やイベントの数の上限がある。
表示と外部の素材解説は日本語、画面は英字(ASCII)の英語。各 .py は1つだけで動き、外部の文字データ・ふつうのファイル保存・音はない。指定したときだけ、確認用(QA)の PNG を出力する。Noto をもとにした日本語の文字と、日本語の画面。記録・画像・映像を作る補助もある。
照らし合わせた範囲03は t・T・H・窓を、04と05は g と達成もふくめて、最後まで進む操作の列65,536通りすべてと、途中までの状態87,381通りで照らし合わせた。05の1刻先の予告も比べた。追加で、境目の状態も調べた。基準は model.py の A と base(ふつうのモード)。元のゲームの Controller の全部のキー、描画、BとC、比較、保存ログ全体まで同じだとは言わない。

完成版のコードで、次に読むところ

  1. model.py の LEVELS にある B と C、outside_T・outside_H・sun
  2. model.py の diffusion、forecast の TickReport と step
  3. model.py の undo・unchanged_forecast・record・replay_record
  4. controller.py の feed の候補、release gate(キーを離すまで待つ仕組み)、比較の切り替え
  5. app.py の段階(位相)の表示、記録、元のゲームの日本語の描画
自分のゲームに変えるなら

最初は、数字かルールを1つだけ変えてみましょう。遊ぶ前に「どう変わるか」を予想しておくと、仕組みがよくわかります。慣れてきたら、見た目や音も足してみてください。元のファイルは残しておき、コピーした別のフォルダーで改造すると、いつでも元に戻せます。

教材をダウンロードする

無料で、登録なしでダウンロードできます。ZIPファイルは必ず展開してから、中のファイルを実行してください。元の完成版は、同じZIPに入っている assets フォルダー(画像などの素材)も一緒に使います。

コードと図形はMITライセンス、元の完成版に含まれる日本語の文字データ(Noto由来)はSIL OFL 1.1です。配ったり改造したりするときは、ZIPに入っているLICENSEなどの権利表示を残してください。出典・権利について

検証について

どこまで動作を確認したかを見る

2026年10月5日以降、Linux(Python 3.12・Pyxel 2.9.9)で、教材のコードとゲームのルールを自動テストしています。全500ステップの教材が、実際のPyxelで起動して画面を描けることも確かめました。元の完成版も、自動で遊ぶデモが起動して画面を描き、正常に終わることを確認しています。

この確認は、画面を表示しない自動テストで行ったものです。Windows・Macの実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。

うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る