戦略・対戦LESSON 80季節の予告と下書きを扱う資源戦略原作091

風車村の冬支度の作り方

建てる前に、次の季節をのぞいてみよう。
発電・電池にためる電気・暖房を、小さな整数の模型で考えます。何度でも直せる「下書き」と「確定」を分け、風上をふさぐ雪よけと、季節ごとに戻せる Undo を作ります。

完成版の遊び方雪よけと風車の位置、電池と熱の配り方を選び、暖かい避難所も使いながら、3軒の家で4つの季節を越します。

風車村の冬支度の元の完成版。自動操作によるPyxelの実描画画面。
元の完成版Aの自動描画です。下の教材もAを使い、電池だけの実験から計画・確定・季節Undoへ進みます。
PLAY / 試験公開

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

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

完成版の主な操作:左右建設/Z計画、Tab家/上下熱、Enter確認と季節確定、X戻す、S保存/L読込

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

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

うまく表示されないとき

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

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

季節の予測と、計画の確定を分ける。

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

1. 終わりのある時計と、上限つきの足し算
2. 読むだけの予告と、2段階での実行
3. 下書きの置きかえと、確定するときの費用
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
変数・if・for・関数 / tuple と書き換えられない dataclass / 辞書のキーとスライス(本文で補足します)
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
Space:01のみ一季節進む / Tab:02以降、配分する家を選択 / 上下:02以降、熱配分の下書き / 左右:03以降、建設候補を選択 / Z:03以降、候補を建設案へ入れる / Enter:02以降、予告を確認し再押下で確定 / X:確認中は取消、05編集/結果では直前季節Undo / R:現在の段階を初期化 / Esc:終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_battery.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

四つの季節と、あふれる電池

終わりのある時計と、上限つきの足し算。

最初は、発電と電池だけの実験です。Space を1回押すたびに、秋 → 初冬 → 真冬 → 雪どけ、と1つずつ季節が進みます。画面は30fpsで描いていますが、季節は自動では進みません。長押しや、いくつものキーを一緒に押す操作は止めて、状態を読んでから次に進めます。

State は、季節 season、電池の量 battery、電池の容量 capacity を持つ、書き換えられない dataclass です。forecast は今の状態を読むだけで、風で発電したあとの電池の量を計算します。min で容量までに切りつめ、入りきらなかった分を spill(あふれ)にします。同じ電気を、電池とあふれの両方に数えないようにします。

最初は電池4、容量8、秋の風が3なので、次は7です。初冬の風4を足すと11ですが、電池に残るのは8で、あふれるのは3です。step は replace で新しい State を返し、元の State は書き換えません。

この段階には、家、暖房、資材の支払い、成功・失敗はありません。元のゲームの状況 A の風だけを取り出しています。4つの季節が終わったあと、表の5番目を読みに行かず、Space を受け付けないことも、小さな完成の条件です。

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

01_battery.py / 注目する部分
    return {'power':wind,'available':min(s.capacity,s.battery+wind),
            'spill':max(0,s.battery+wind-s.capacity)}

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_battery.py
macOS / Linux
.venv/bin/python 01_battery.py
画面で見ることSpace を1回押すと SEASON 1・BATTERY 7 です。次の予告は NEXT BATTERY 8・SPILL 3 です。合計4回押すと SEASON 4・BATTERY 8 になり、そのあと Space を押しても何も変わりません。R で最初に戻れます。
  • 季節の番号と、描画のフレームを分ける
  • min と max で、容量とあふれを分ける
  • 4つの季節が終わったら、入力を止める
つまずきやすいところ容量に切りつめたあとの値から spill を求めると、いつも0になってしまいます。あふれた量は、切りつめる前の battery+wind から求めます。
少し変えてみよう

State の capacity: int = 8 だけを6に変えてみましょう。保存 → 閉じる → 実行し直したら、Space を1回押すと BATTERY 6 です。最初の予告の SPILL は1です。確認したら8に戻します。

01_battery.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(125行)
01_battery.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。架空の季節資源模型。現実の発電・暖房設計ではありません。"""
from dataclasses import dataclass,replace,asdict

WEATHER = {'A': ((3, -1, 1, 0), (4, -1, 2, 1), (4, -1, 2, 2), (3, -1, 1, 0)), 'B': ((3, -1, 1, 0), (4, 1, 2, 1), (5, -1, 3, 3), (3, 1, 1, 0)), 'C': ((5, 1, 1, 0), (2, 1, 2, 2), (4, 1, 2, 2), (4, -1, 1, 0))}

@dataclass(frozen=True)
class State:
    season: int = 0
    battery: int = 4
    capacity: int = 8

def forecast(s):
    if s.season>=4:raise ValueError('four seasons finished')
    wind=WEATHER['A'][s.season][0]
    return {'power':wind,'available':min(s.capacity,s.battery+wind),
            'spill':max(0,s.battery+wind-s.capacity)}

def step(s):
    f=forecast(s)
    return replace(s,season=s.season+1,battery=f['available'])

STAGE = 1
SEASON_LABELS=("AUTUMN","EARLY WINTER","DEEP WINTER","THAW")

class App:
    def __init__(self):
        self.reset()

    def reset(self):
        self.state=State()
        self.house=0
        self.choice=0
        self.confirm=False
        self.gated=False
        self.message='READY'

    def press(self,key):
        if key=='R':self.reset();return
        if self.state.season>=4:return
        if key=="SPACE":self.state=step(self.state)

    def control(self,pressed,held):
        if self.gated:
            if not pressed and not held:self.gated=False
            return
        if len(pressed|held)>1:
            self.gated=True;self.message='RELEASE ALL KEYS';return
        if len(pressed)!=1:return
        self.press(next(iter(pressed)));self.gated=True

    def draw(self,p):
        s=self.state;p.cls(1)
        p.text(12,12,'WINDMILL VILLAGE / STAGE '+str(STAGE)+' / A',10)
        p.text(12,29,'FICTIONAL TOY / NOT REAL HEATING OR GENERATOR DESIGN',6)
        p.text(12,51,'SEASON '+str(s.season)+' / 4  '+(SEASON_LABELS[s.season] if s.season<4 else 'FINISHED'),7)
        p.text(12,68,'BATTERY '+str(s.battery)+' / CAPACITY '+str(s.capacity),11)
        for i,w in enumerate(WEATHER['A']):
            x=12+i*145;p.rectb(x,88,138,36,10 if i==s.season else 5)
            p.text(x+6,95,SEASON_LABELS[i],7)
            p.text(x+6,110,'WIND '+str(w[0])+' / '+('W -> E' if w[1]==-1 else 'E -> W'),6)
        if s.season<4:
            f=forecast(s)
            p.text(12,150,'GENERATION '+str(f['power'])+' / NEXT BATTERY '+str(f['available'])+' / SPILL '+str(f['spill']),11)
        p.text(12,186,'STAGE 1 HAS NO HOUSES, HEATING, MATERIAL COST OR SCORE.',7)
        p.text(12,206,'SPACE ADVANCES EXACTLY ONE SEASON. HELD KEYS DO NOT REPEAT.',7)
        p.text(12,432,'SPACE: NEXT SEASON / R: RESET / ESC: QUIT',7)
        p.text(12,382,self.message,10)

def demo_keys():
    return ('SPACE','SPACE','SPACE')

def main(headless=False,screenshot=None,demo=False):
    import pyxel as p
    p.init(600,464,title='Windmill village lesson '+str(STAGE),fps=30,display_scale=2,headless=headless)
    app=App();sequence=demo_keys() if demo else ()
    keys={'LEFT':p.KEY_LEFT,'RIGHT':p.KEY_RIGHT,'UP':p.KEY_UP,'DOWN':p.KEY_DOWN,'TAB':p.KEY_TAB,'RETURN':p.KEY_RETURN,'SPACE':p.KEY_SPACE,'Z':p.KEY_Z,'X':p.KEY_X,'R':p.KEY_R}
    limit=max(20,2*len(sequence)+8)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:app.draw(p);p.screen.save(screenshot,1)
            p.quit()
        if headless:
            if demo:
                index=p.frame_count//2
                pressed={sequence[index]} if p.frame_count%2==0 and index<len(sequence) else set()
                app.control(pressed,pressed)
        else:app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true');parser.add_argument('--demo-checkpoint',action='store_true');parser.add_argument('--screenshot')
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    if args.screenshot and (Path(args.screenshot).suffix.lower()!='.png' or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):parser.error('use a new .png in an existing directory')
    if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 02 / 05

配分の下書きと、季節の確定

読むだけの予告と、2段階での実行。

3軒の家と、熱の配り方を加えます。Tab で左・真ん中・右の家を選び、上下キーで配る熱を増やしたり減らしたりします。必要な量より多くは配れません。この編集の間は電池は減らず、計画 Plan に候補を置くだけです。Enter を1回押すと予告を確認し、もう1回押すと季節を確定します。確認中に X を押すと取り消しで、配り方の案は残ります。

更新の順番は、発電 → 容量を超えた分を捨てる → 配った熱を使う → 快適さ、です。たとえば電池7に風4を足し、その季節に熱を3使う場合、11から3を引いて8になるのではありません。先に容量の8に切りつめてから3を使うので、残りは5です。満タンの電池を先に使ったことにはしません。

家に必要な熱を満たせば、快適さが1増えて最大3、足りなければ足りない分だけ減って最小0です。0は、暖かい避難所(暖所)を使った季節の印で、家や住人を失う取り返しのつかない失敗ではありません。4つの季節が終わったときに、全部の家が1以上なら成功です。途中をいつも満点にする必要はありません。

この段階では、まだ建設はありません。3つの状況すべての天気のデータを持っていますが、画面は状況 A だけです。季節の終わりの資材+1も、元のゲームの State に残して、次の建設に備えます。雪の量は、元のゲームでは景色のための公開された情報で、必要な熱にこっそり足される被害ではありません。

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

02_warmth.py / 注目する部分
    f['remaining'] = f['available'] - sum(p.heat)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_warmth.py
macOS / Linux
.venv/bin/python 02_warmth.py
画面で見ること最初の状態から、上 → Tab → 上 → Tab → 上 と押します。HEAT は (1,1,1) で、まだ SEASON 0・BATTERY 4 です。Enter を2回押すと、SEASON 1・BATTERY 4・COMFORT (3,3,3) になります。
  • 下書きの Plan と、確定した State を分ける
  • 熱を使う前に、容量の上限を当てはめる
  • 暖所を使うことと、最後の成功を区別する
つまずきやすいところ予告を描くたびに step を呼んで、今の State に代入しないでください。predict は読むだけで、2回目の Enter だけが状態を入れかえます。
少し変えてみよう

R から、配る熱を0のまま Enter を2回、を3組くり返してみましょう。真冬が終わると COMFORT (0,0,0) です。最後の雪どけでは各家に1ずつ配って Enter を2回押します。COMFORT (1,1,1) で SUCCESS になり、暖所を使ってもすぐ失敗にはならないことを確かめましょう。

02_warmth.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(189行)
02_warmth.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。架空の季節資源模型。現実の発電・暖房設計ではありません。"""
from dataclasses import dataclass,replace,asdict

WEATHER = {'A': ((3, -1, 1, 0), (4, -1, 2, 1), (4, -1, 2, 2), (3, -1, 1, 0)), 'B': ((3, -1, 1, 0), (4, 1, 2, 1), (5, -1, 3, 3), (3, 1, 1, 0)), 'C': ((5, 1, 1, 0), (2, 1, 2, 2), (4, 1, 2, 2), (4, -1, 1, 0))}

TITLES = {'A': '西からの初雪', 'B': '向きの変わる冬', 'C': '古い蓄電池'}

@dataclass(frozen=True)
class State:
    case: str = 'A'
    season: int = 0
    mill: int = 1
    shields: tuple = (False, False, False)
    materials: int = 5
    battery: int = 4
    capacity: int = 8
    comfort: tuple = (3, 3, 3)
    shelter: int = 0
    history: tuple = ()
    blocking: bool = True

@dataclass(frozen=True)
class Plan:
    build: str = 'none'
    heat: tuple = (0, 0, 0)

def new(case='A', blocking=True):
    return State(case=case, battery={'A': 4, 'B': 5, 'C': 7}[case], comfort=(2, 3, 2) if case == 'C' else (3, 3, 3), blocking=blocking)

def forecast(s,b='none'):
    if b!='none' or s.season>=4:raise ValueError('no construction in this stage')
    wind,direction,heat,snow=WEATHER[s.case][s.season]
    return {'shields':s.shields,'mill':s.mill,'capacity':s.capacity,'cost':0,
            'wind':wind,'direction':direction,'snow':snow,'blocked':0,
            'power':wind,'needs':(heat,heat,heat),
            'available':min(s.capacity,s.battery+wind),
            'spill':max(0,s.battery+wind-s.capacity)}

def predict(s, p):
    f = forecast(s, p.build)
    if not isinstance(p.heat, tuple) or len(p.heat) != 3 or any((type(h) != int or not 0 <= h <= n for h, n in zip(p.heat, f['needs']))):
        raise ValueError('配分は必要量までです')
    if sum(p.heat) > f['available']:
        raise ValueError('電力が足りません')
    f['comfort'] = tuple((min(3, c + 1) if h >= n else max(0, c - (n - h)) for c, h, n in zip(s.comfort, p.heat, f['needs'])))
    f['shelter'] = any((c == 0 for c in f['comfort']))
    f['remaining'] = f['available'] - sum(p.heat)
    return f

def step(s, p):
    f = predict(s, p)
    entry = (s.season, p.build, p.heat, f['power'], f['spill'], f['comfort'], f['remaining'], f['shelter'])
    return replace(s, season=s.season + 1, mill=f['mill'], shields=f['shields'], capacity=f['capacity'], materials=s.materials - f['cost'] + 1, battery=f['remaining'], comfort=f['comfort'], shelter=s.shelter + int(f['shelter']), history=s.history + (entry,))

def success(s):
    return s.season == 4 and min(s.comfort) >= 1

STAGE = 2
SEASON_LABELS=("AUTUMN","EARLY WINTER","DEEP WINTER","THAW")

class App:
    def __init__(self):
        self.reset()

    def reset(self):
        self.state=new("A",blocking=True)
        self.plan=Plan()
        self.house=0
        self.choice=0
        self.confirm=False
        self.gated=False
        self.message='READY'

    def press(self,key):
        if key=='R':self.reset();return
        if self.confirm:
            if key=='X':self.confirm=False;self.message='DRAFT KEPT';return
            if key=='RETURN':
                self.state=step(self.state,self.plan);self.plan=Plan()
                self.confirm=False;self.message='SEASON COMMITTED'
            return
        if self.state.season>=4:return
        if key=='TAB':self.house=(self.house+1)%3
        if key in ('UP','DOWN'):
            f=forecast(self.state,self.plan.build);heat=list(self.plan.heat)
            heat[self.house]=max(0,min(f['needs'][self.house],heat[self.house]+(1 if key=='UP' else -1)))
            self.plan=Plan(self.plan.build,tuple(heat));self.message='FREE DRAFT'
        if key=='RETURN':
            try:predict(self.state,self.plan);self.confirm=True;self.message='PREVIEW / ENTER AGAIN TO COMMIT'
            except ValueError:self.message='NOT ENOUGH ENERGY / LOWER HEAT'

    def control(self,pressed,held):
        if self.gated:
            if not pressed and not held:self.gated=False
            return
        if len(pressed|held)>1:
            self.gated=True;self.message='RELEASE ALL KEYS';return
        if len(pressed)!=1:return
        self.press(next(iter(pressed)));self.gated=True

    def draw(self,p):
        s=self.state;p.cls(1)
        p.text(12,12,'WINDMILL VILLAGE / STAGE '+str(STAGE)+' / A',10)
        p.text(12,29,'FICTIONAL TOY / NOT REAL HEATING OR GENERATOR DESIGN',6)
        p.text(12,51,'SEASON '+str(s.season)+' / 4  '+(SEASON_LABELS[s.season] if s.season<4 else 'FINISHED'),7)
        p.text(12,68,'BATTERY '+str(s.battery)+' / CAPACITY '+str(s.capacity),11)
        for i,w in enumerate(WEATHER['A']):
            x=12+i*145;p.rectb(x,88,138,36,10 if i==s.season else 5)
            p.text(x+6,95,SEASON_LABELS[i],7)
            p.text(x+6,110,'WIND '+str(w[0])+' / '+('W -> E' if w[1]==-1 else 'E -> W'),6)
        p.text(12,139,'MATERIALS '+str(s.materials)+' / SHELTER SEASONS '+str(s.shelter),6)
        if s.season<4:
            f=forecast(s,self.plan.build)
            p.text(12,157,'POWER '+str(f['power'])+' / AVAILABLE '+str(f['available'])+' / SPILL '+str(f['spill'])+' / DRAFT COST '+str(f['cost']),11)
            for i in range(3):
                x=12+i*193;p.rectb(x,187,181,65,10 if i==self.house else 5)
                p.text(x+7,196,('LEFT','MIDDLE','RIGHT')[i]+' HOUSE',7)
                p.text(x+7,213,'HEAT '+str(self.plan.heat[i])+' / NEED '+str(f['needs'][i]),11)
                p.text(x+7,230,'COMFORT '+str(s.comfort[i])+' / DRAFT SHIELD '+str(int(f['shields'][i])),6)
            try:
                prediction=predict(s,self.plan)
                p.text(12,268,'NEXT COMFORT '+str(prediction['comfort'])+' / BATTERY '+str(prediction['remaining']),10)
            except ValueError:p.text(12,268,'HEAT TOTAL EXCEEDS AVAILABLE ENERGY',8)
        else:
            p.text(12,188,'FINAL COMFORT '+str(s.comfort)+' / '+('SUCCESS' if success(s) else 'TRY ANOTHER PLAN'),10)
            p.text(12,213,'SHELTER IS NOT PERMANENT FAILURE; ONLY FINAL COMFORT MATTERS.',7)
        p.text(12,342,'SHIELDS '+('DO NOT BLOCK WIND IN THIS SIMPLIFIED STAGE' if STAGE==3 else 'BLOCK UPSTREAM WIND' if STAGE>=4 else 'NOT INTRODUCED'),14)
        p.text(12,359,'SEASONS FINISHED / R TO RESTART' if s.season>=4 else 'PREVIEW: ENTER TO COMMIT / X TO CANCEL' if self.confirm else 'EDITING: NO RESOURCE HAS BEEN CHARGED',10)
        p.text(12,403,'TAB: HOUSE / UP/DOWN: HEAT / ENTER: PREVIEW, THEN COMMIT',7)
        p.text(12,437,'R: RESET / ESC: QUIT',7)
        p.text(12,382,self.message,10)

def demo_keys():
    return ('UP','TAB','UP','TAB','UP','RETURN','RETURN')

def main(headless=False,screenshot=None,demo=False):
    import pyxel as p
    p.init(600,464,title='Windmill village lesson '+str(STAGE),fps=30,display_scale=2,headless=headless)
    app=App();sequence=demo_keys() if demo else ()
    keys={'LEFT':p.KEY_LEFT,'RIGHT':p.KEY_RIGHT,'UP':p.KEY_UP,'DOWN':p.KEY_DOWN,'TAB':p.KEY_TAB,'RETURN':p.KEY_RETURN,'SPACE':p.KEY_SPACE,'Z':p.KEY_Z,'X':p.KEY_X,'R':p.KEY_R}
    limit=max(20,2*len(sequence)+8)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:app.draw(p);p.screen.save(screenshot,1)
            p.quit()
        if headless:
            if demo:
                index=p.frame_count//2
                pressed={sequence[index]} if p.frame_count%2==0 and index<len(sequence) else set()
                app.control(pressed,pressed)
        else:app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true');parser.add_argument('--demo-checkpoint',action='store_true');parser.add_argument('--screenshot')
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    if args.screenshot and (Path(args.screenshot).suffix.lower()!='.png' or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):parser.error('use a new .png in an existing directory')
    if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 03 / 05

建設を選んでも、まだ資材を払わない

下書きの置きかえと、確定するときの費用。

左右キーで建設の候補を選び、Z で Plan に入れます。候補をながめるだけでは、下書きも変わりません。雪よけは資材2、風車の移設は1、電池の容量+2は2です。取り壊しは無料ですが、お金は戻りません。電池の容量は最大12です。1つの季節に確定できる建設は1件です。

同じ Z を何回押しても、その候補が Plan に入り直すだけで、資材は減りません。Enter を2回押して確定したときに一度だけ費用を払い、季節の終わりに資材が1戻ります。資材が足りない、同じ列への移設、すでにある雪よけへの重ねての建設、ない雪よけの取り壊しは、受け付けません。

雪よけは、その家に必要な熱を1減らします。建設の案を変えて必要な量が下がったら、すでに入っていた熱の配り方も、新しい必要量までに切りつめます。余った熱を勝手に別の家に配らず、もう一度自分で案を作れるようにしています。

ここでは、元のゲームにある比較用の条件 blocking=False を使い、雪よけが風をさえぎる効果だけを無効にしています。建設の費用、必要な熱が減る効果、予報、成功の条件は同じです。これは段階を追って理解するための簡略化です。画面にも DO NOT BLOCK WIND(風をさえぎらない)と表示し、ふつうのルールだとは説明しません。

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

03_draft_build.py / 注目する部分
                self.plan=Plan(BUILDS[self.choice],tuple(min(h,n) for h,n in zip(self.plan.heat,f['needs'])))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_draft_build.py
macOS / Linux
.venv/bin/python 03_draft_build.py
画面で見ること最初の状態から、右を3回 → Z で、右の家の雪よけを下書きします。上 → Tab → 上 で HEAT (1,1,0) です。確定する前の資材は5です。Enter を2回押すと、資材4・電池5・COMFORT (3,3,3) になり、右の家だけに雪よけが付きます。
  • 選んでいる候補と、下書きに入れた候補を区別する
  • 費用を払うのは、確定するときの1回だけ
  • 必要な熱が減ったら、配り方もそれに合わせて切りつめる
つまずきやすいところ左右キーで選んでいる choice と、Z で下書きに入れた plan.build を同じ値として扱わないでください。まだ選んでいるだけの別の候補で電気の予告を計算すると、配り方の案と表示がずれてしまいます。
少し変えてみよう

R → 右 → Z で左の家の雪よけを下書きし、Tab → 上 → Tab → 上 で HEAT (0,1,1) にしてみましょう。POWER 3・確定後 BATTERY 5 です。同じ入力を次の 04 でも試し、「雪よけが風をさえぎる」という1つの変更だけを比べます。

03_draft_build.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(288行)
03_draft_build.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。架空の季節資源模型。現実の発電・暖房設計ではありません。"""
from dataclasses import dataclass, asdict, replace

from itertools import product

import json, hashlib

VERSION = '1.0.0'

GAME = '091'

TITLES = {'A': '西からの初雪', 'B': '向きの変わる冬', 'C': '古い蓄電池'}

SEASONS = ('秋', '初冬', '厳冬', '雪解け')

WEATHER = {'A': ((3, -1, 1, 0), (4, -1, 2, 1), (4, -1, 2, 2), (3, -1, 1, 0)), 'B': ((3, -1, 1, 0), (4, 1, 2, 1), (5, -1, 3, 3), (3, 1, 1, 0)), 'C': ((5, 1, 1, 0), (2, 1, 2, 2), (4, 1, 2, 2), (4, -1, 1, 0))}

BUILDS = ('none', 'shield:0', 'shield:1', 'shield:2', 'move:0', 'move:1', 'move:2', 'capacity', 'remove:0', 'remove:1', 'remove:2')

BUILD_NAMES = ('建設なし', '左に雪よけ', '中に雪よけ', '右に雪よけ', '風車を左へ', '風車を中へ', '風車を右へ', '電池を増設', '左の雪よけ撤去', '中の雪よけ撤去', '右の雪よけ撤去')

@dataclass(frozen=True)
class State:
    case: str = 'A'
    season: int = 0
    mill: int = 1
    shields: tuple = (False, False, False)
    materials: int = 5
    battery: int = 4
    capacity: int = 8
    comfort: tuple = (3, 3, 3)
    shelter: int = 0
    history: tuple = ()
    blocking: bool = True

@dataclass(frozen=True)
class Plan:
    build: str = 'none'
    heat: tuple = (0, 0, 0)

def new(case='A', blocking=True):
    return State(case=case, battery={'A': 4, 'B': 5, 'C': 7}[case], comfort=(2, 3, 2) if case == 'C' else (3, 3, 3), blocking=blocking)

def construction(s, b):
    if b not in BUILDS:
        raise ValueError('未知の建設')
    if s.season >= 4:
        raise ValueError('全季節終了')
    sh = list(s.shields)
    m = s.mill
    cap = s.capacity
    cost = 0
    if b.startswith('shield:'):
        i = int(b[-1])
        cost = 2
        if sh[i]:
            raise ValueError('ここには雪よけがあります')
        sh[i] = True
    elif b.startswith('remove:'):
        i = int(b[-1])
        if not sh[i]:
            raise ValueError('撤去する雪よけがありません')
        sh[i] = False
    elif b.startswith('move:'):
        m = int(b[-1])
        cost = 1
        if m == s.mill:
            raise ValueError('風車は同じ列です')
    elif b == 'capacity':
        if cap >= 12:
            raise ValueError('電池は最大です')
        cost = 2
        cap += 2
    if cost > s.materials:
        raise ValueError('資材が足りません')
    return (tuple(sh), m, cap, cost)

def forecast(s, b='none'):
    sh, m, cap, cost = construction(s, b)
    w, d, h, snow = WEATHER[s.case][s.season]
    blocked = sum((v for i, v in enumerate(sh) if i <= m)) if d == -1 else sum((v for i, v in enumerate(sh) if i >= m))
    power = max(0, w - (blocked if s.blocking else 0))
    needs = tuple((max(0, h - int(v)) for v in sh))
    available = min(cap, s.battery + power)
    return {'shields': sh, 'mill': m, 'capacity': cap, 'cost': cost, 'wind': w, 'direction': d, 'snow': snow, 'blocked': blocked if s.blocking else 0, 'power': power, 'needs': needs, 'available': available, 'spill': max(0, s.battery + power - cap)}

def predict(s, p):
    f = forecast(s, p.build)
    if not isinstance(p.heat, tuple) or len(p.heat) != 3 or any((type(h) != int or not 0 <= h <= n for h, n in zip(p.heat, f['needs']))):
        raise ValueError('配分は必要量までです')
    if sum(p.heat) > f['available']:
        raise ValueError('電力が足りません')
    f['comfort'] = tuple((min(3, c + 1) if h >= n else max(0, c - (n - h)) for c, h, n in zip(s.comfort, p.heat, f['needs'])))
    f['shelter'] = any((c == 0 for c in f['comfort']))
    f['remaining'] = f['available'] - sum(p.heat)
    return f

def step(s, p):
    f = predict(s, p)
    entry = (s.season, p.build, p.heat, f['power'], f['spill'], f['comfort'], f['remaining'], f['shelter'])
    return replace(s, season=s.season + 1, mill=f['mill'], shields=f['shields'], capacity=f['capacity'], materials=s.materials - f['cost'] + 1, battery=f['remaining'], comfort=f['comfort'], shelter=s.shelter + int(f['shelter']), history=s.history + (entry,))

def success(s):
    return s.season == 4 and min(s.comfort) >= 1

def plans(s):
    for b in BUILDS:
        try:
            f = forecast(s, b)
        except ValueError:
            continue
        for heat in product(*(range(n + 1) for n in f['needs'])):
            if sum(heat) <= f['available']:
                yield Plan(b, heat)

def digest(s):
    return hashlib.sha256(json.dumps(asdict(s), sort_keys=True, ensure_ascii=False).encode('utf-8')).hexdigest()

def restore(d):
    if not isinstance(d, dict) or set(d) != set(asdict(new())):
        raise ValueError('状態の項目が違います')
    if d['case'] not in TITLES or type(d['blocking']) != bool:
        raise ValueError('状況が不正')
    for k, lo, hi in [('season', 0, 4), ('mill', 0, 2), ('materials', 0, 9), ('battery', 0, 12), ('capacity', 8, 12), ('shelter', 0, 4)]:
        if type(d[k]) != int or not lo <= d[k] <= hi:
            raise ValueError('状態の範囲が不正')
    if d['capacity'] not in (8, 10, 12) or d['battery'] > d['capacity']:
        raise ValueError('電池が不正')
    if not isinstance(d['shields'], (list, tuple)) or len(d['shields']) != 3 or any((type(x) != bool for x in d['shields'])):
        raise ValueError('雪よけが不正')
    if not isinstance(d['comfort'], (list, tuple)) or len(d['comfort']) != 3 or any((type(x) != int or not 0 <= x <= 3 for x in d['comfort'])):
        raise ValueError('快適度が不正')
    if not isinstance(d['history'], (list, tuple)) or len(d['history']) != d['season']:
        raise ValueError('履歴が不正')
    s = new(d['case'], d['blocking'])
    for e in d['history']:
        if not isinstance(e, (list, tuple)) or len(e) != 8:
            raise ValueError('履歴が不正')
        s = step(s, Plan(e[1], tuple(e[2])))
    if json.loads(json.dumps(asdict(s))) != json.loads(json.dumps(d)):
        raise ValueError('履歴と状態が一致しません')
    return s


STAGE = 3
SEASON_LABELS=("AUTUMN","EARLY WINTER","DEEP WINTER","THAW")
BUILD_LABELS=("NONE","SHIELD LEFT","SHIELD MIDDLE","SHIELD RIGHT","MOVE LEFT","MOVE MIDDLE","MOVE RIGHT","CAPACITY +2","REMOVE LEFT","REMOVE MIDDLE","REMOVE RIGHT")

class App:
    def __init__(self):
        self.reset()

    def reset(self):
        self.state=new("A",blocking=False)
        self.plan=Plan()
        self.house=0
        self.choice=0
        self.confirm=False
        self.gated=False
        self.message='READY'

    def press(self,key):
        if key=='R':self.reset();return
        if self.confirm:
            if key=='X':self.confirm=False;self.message='DRAFT KEPT';return
            if key=='RETURN':
                self.state=step(self.state,self.plan);self.plan=Plan()
                self.confirm=False;self.message='SEASON COMMITTED'
            return
        if self.state.season>=4:return
        if key in ('LEFT','RIGHT'):
            self.choice=(self.choice+(-1 if key=='LEFT' else 1))%len(BUILDS)
        if key=='Z':
            try:
                f=forecast(self.state,BUILDS[self.choice])
                self.plan=Plan(BUILDS[self.choice],tuple(min(h,n) for h,n in zip(self.plan.heat,f['needs'])))
                self.message='FREE DRAFT / NOTHING CHARGED'
            except ValueError:self.message='CONSTRUCTION REFUSED'
        if key=='TAB':self.house=(self.house+1)%3
        if key in ('UP','DOWN'):
            f=forecast(self.state,self.plan.build);heat=list(self.plan.heat)
            heat[self.house]=max(0,min(f['needs'][self.house],heat[self.house]+(1 if key=='UP' else -1)))
            self.plan=Plan(self.plan.build,tuple(heat));self.message='FREE DRAFT'
        if key=='RETURN':
            try:predict(self.state,self.plan);self.confirm=True;self.message='PREVIEW / ENTER AGAIN TO COMMIT'
            except ValueError:self.message='NOT ENOUGH ENERGY / LOWER HEAT'

    def control(self,pressed,held):
        if self.gated:
            if not pressed and not held:self.gated=False
            return
        if len(pressed|held)>1:
            self.gated=True;self.message='RELEASE ALL KEYS';return
        if len(pressed)!=1:return
        self.press(next(iter(pressed)));self.gated=True

    def draw(self,p):
        s=self.state;p.cls(1)
        p.text(12,12,'WINDMILL VILLAGE / STAGE '+str(STAGE)+' / A',10)
        p.text(12,29,'FICTIONAL TOY / NOT REAL HEATING OR GENERATOR DESIGN',6)
        p.text(12,51,'SEASON '+str(s.season)+' / 4  '+(SEASON_LABELS[s.season] if s.season<4 else 'FINISHED'),7)
        p.text(12,68,'BATTERY '+str(s.battery)+' / CAPACITY '+str(s.capacity),11)
        for i,w in enumerate(WEATHER['A']):
            x=12+i*145;p.rectb(x,88,138,36,10 if i==s.season else 5)
            p.text(x+6,95,SEASON_LABELS[i],7)
            p.text(x+6,110,'WIND '+str(w[0])+' / '+('W -> E' if w[1]==-1 else 'E -> W'),6)
        p.text(12,139,'MATERIALS '+str(s.materials)+' / SHELTER SEASONS '+str(s.shelter),6)
        if s.season<4:
            f=forecast(s,self.plan.build)
            p.text(12,157,'POWER '+str(f['power'])+' / AVAILABLE '+str(f['available'])+' / SPILL '+str(f['spill'])+' / DRAFT COST '+str(f['cost']),11)
            for i in range(3):
                x=12+i*193;p.rectb(x,187,181,65,10 if i==self.house else 5)
                p.text(x+7,196,('LEFT','MIDDLE','RIGHT')[i]+' HOUSE',7)
                p.text(x+7,213,'HEAT '+str(self.plan.heat[i])+' / NEED '+str(f['needs'][i]),11)
                p.text(x+7,230,'COMFORT '+str(s.comfort[i])+' / DRAFT SHIELD '+str(int(f['shields'][i])),6)
            try:
                prediction=predict(s,self.plan)
                p.text(12,268,'NEXT COMFORT '+str(prediction['comfort'])+' / BATTERY '+str(prediction['remaining']),10)
            except ValueError:p.text(12,268,'HEAT TOTAL EXCEEDS AVAILABLE ENERGY',8)
            p.text(12,296,'SELECT '+BUILD_LABELS[self.choice]+' / DRAFT '+BUILD_LABELS[BUILDS.index(self.plan.build)],7)
            p.text(12,314,'DRAFT MILL COLUMN '+str(f['mill'])+' / UPSTREAM SHIELDS '+str(f['blocked']),6)
        else:
            p.text(12,188,'FINAL COMFORT '+str(s.comfort)+' / '+('SUCCESS' if success(s) else 'TRY ANOTHER PLAN'),10)
            p.text(12,213,'SHELTER IS NOT PERMANENT FAILURE; ONLY FINAL COMFORT MATTERS.',7)
        p.text(12,342,'SHIELDS '+('DO NOT BLOCK WIND IN THIS SIMPLIFIED STAGE' if STAGE==3 else 'BLOCK UPSTREAM WIND' if STAGE>=4 else 'NOT INTRODUCED'),14)
        p.text(12,359,'SEASONS FINISHED / R TO RESTART' if s.season>=4 else 'PREVIEW: ENTER TO COMMIT / X TO CANCEL' if self.confirm else 'EDITING: NO RESOURCE HAS BEEN CHARGED',10)
        p.text(12,403,'TAB: HOUSE / UP/DOWN: HEAT / ENTER: PREVIEW, THEN COMMIT',7)
        p.text(12,420,'LEFT/RIGHT: BUILD CHOICE / Z: PUT IT IN DRAFT / R: RESET',7)
        p.text(12,437,'R: RESET / ESC: QUIT',7)
        p.text(12,382,self.message,10)

def demo_keys():
    return ('RIGHT','RIGHT','RIGHT','Z','UP','TAB','UP','RETURN','RETURN')

def main(headless=False,screenshot=None,demo=False):
    import pyxel as p
    p.init(600,464,title='Windmill village lesson '+str(STAGE),fps=30,display_scale=2,headless=headless)
    app=App();sequence=demo_keys() if demo else ()
    keys={'LEFT':p.KEY_LEFT,'RIGHT':p.KEY_RIGHT,'UP':p.KEY_UP,'DOWN':p.KEY_DOWN,'TAB':p.KEY_TAB,'RETURN':p.KEY_RETURN,'SPACE':p.KEY_SPACE,'Z':p.KEY_Z,'X':p.KEY_X,'R':p.KEY_R}
    limit=max(20,2*len(sequence)+8)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:app.draw(p);p.screen.save(screenshot,1)
            p.quit()
        if headless:
            if demo:
                index=p.frame_count//2
                pressed={sequence[index]} if p.frame_count%2==0 and index<len(sequence) else set()
                app.control(pressed,pressed)
        else:app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true');parser.add_argument('--demo-checkpoint',action='store_true');parser.add_argument('--screenshot')
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    if args.screenshot and (Path(args.screenshot).suffix.lower()!='.png' or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):parser.error('use a new .png in an existing directory')
    if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 04 / 05

家を守る雪よけが、風も減らす

場所と風の向きで変わる、費用に見合う効果。

ふつうの、風をさえぎる効果を有効にします。西風(表の direction=-1)なら風車より左と同じ列、東風(+1)なら右と同じ列の雪よけを数えます。風車のとなりだけではありません。数えた雪よけの数を風の強さから引き、発電の量は0より小さくしません。

03 の建設と、必要な熱を減らす効果はそのままです。最初の状況 A の風車は真ん中にあります。右の雪よけは西風の風下なので、最初の発電を減らしません。左の雪よけは、必要な熱を1減らすかわりに、発電も1減らします。同じ列の雪よけもさえぎるので、風車を雪よけの列に移せば必ずさけられる、とは言えません。

予告のために別の近似の式を作らず、確定に使う forecast/predict をそのまま画面に表示します。ふつうのルールの関数すべてと、状況 A/B/C の天気は、元のゲームと一致しています。画面は状況 A の4つの季節にしぼっていて、B/C や、風をさえぎる効果の比較は、コードと元のゲームを読む発展の項目です。

これは整数で作った、架空の資源のゲームです。実際の風車の発電量や、建物・暖房の安全性を計算する道具ではありません。建設しなくても、元のゲームの状況 A/B/C の基本の目標には届きます。配置を直すことや、全部の季節を満点にすることを、ただ1つの正解として押しつけません。

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

04_upstream.py / 注目する部分
    blocked = sum((v for i, v in enumerate(sh) if i <= m)) if d == -1 else sum((v for i, v in enumerate(sh) if i >= m))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_upstream.py
macOS / Linux
.venv/bin/python 04_upstream.py
画面で見ること03 と同じ左の雪よけの入力で、POWER 2・確定後 BATTERY 4 です。必要な熱と資材の費用は変わりません。R から右の雪よけの例を試すと、POWER 3・BATTERY 5 です。
  • 風の向きの符号(プラス・マイナス)と、列を対応させる
  • 同じ列の雪よけも、風をさえぎる数に含める
  • 必要な熱と発電、2つの効果を読む
つまずきやすいところblocked を計算するときに i<m に変えると、同じ列の雪よけが数えられなくなります。西風は i<=mill、東風は i>=mill です。
少し変えてみよう

同じ ZIP に入っている exercises/04_spill.py を開き、python exercises/04_spill.py で実行してみましょう。出力は「4 2」です。状況 C の最初の風5と電池7では、容量8なら4あふれ、容量10なら2あふれます。この短い例は画面を起動せずに同じ forecast を呼ぶので、状況 A だけの画面を別の状況と読み間違えずに比べられます。例の "C" を "A" に変えると「0 0」です。確認したら C に戻します。

04_upstream.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(288行)
04_upstream.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。架空の季節資源模型。現実の発電・暖房設計ではありません。"""
from dataclasses import dataclass, asdict, replace

from itertools import product

import json, hashlib

VERSION = '1.0.0'

GAME = '091'

TITLES = {'A': '西からの初雪', 'B': '向きの変わる冬', 'C': '古い蓄電池'}

SEASONS = ('秋', '初冬', '厳冬', '雪解け')

WEATHER = {'A': ((3, -1, 1, 0), (4, -1, 2, 1), (4, -1, 2, 2), (3, -1, 1, 0)), 'B': ((3, -1, 1, 0), (4, 1, 2, 1), (5, -1, 3, 3), (3, 1, 1, 0)), 'C': ((5, 1, 1, 0), (2, 1, 2, 2), (4, 1, 2, 2), (4, -1, 1, 0))}

BUILDS = ('none', 'shield:0', 'shield:1', 'shield:2', 'move:0', 'move:1', 'move:2', 'capacity', 'remove:0', 'remove:1', 'remove:2')

BUILD_NAMES = ('建設なし', '左に雪よけ', '中に雪よけ', '右に雪よけ', '風車を左へ', '風車を中へ', '風車を右へ', '電池を増設', '左の雪よけ撤去', '中の雪よけ撤去', '右の雪よけ撤去')

@dataclass(frozen=True)
class State:
    case: str = 'A'
    season: int = 0
    mill: int = 1
    shields: tuple = (False, False, False)
    materials: int = 5
    battery: int = 4
    capacity: int = 8
    comfort: tuple = (3, 3, 3)
    shelter: int = 0
    history: tuple = ()
    blocking: bool = True

@dataclass(frozen=True)
class Plan:
    build: str = 'none'
    heat: tuple = (0, 0, 0)

def new(case='A', blocking=True):
    return State(case=case, battery={'A': 4, 'B': 5, 'C': 7}[case], comfort=(2, 3, 2) if case == 'C' else (3, 3, 3), blocking=blocking)

def construction(s, b):
    if b not in BUILDS:
        raise ValueError('未知の建設')
    if s.season >= 4:
        raise ValueError('全季節終了')
    sh = list(s.shields)
    m = s.mill
    cap = s.capacity
    cost = 0
    if b.startswith('shield:'):
        i = int(b[-1])
        cost = 2
        if sh[i]:
            raise ValueError('ここには雪よけがあります')
        sh[i] = True
    elif b.startswith('remove:'):
        i = int(b[-1])
        if not sh[i]:
            raise ValueError('撤去する雪よけがありません')
        sh[i] = False
    elif b.startswith('move:'):
        m = int(b[-1])
        cost = 1
        if m == s.mill:
            raise ValueError('風車は同じ列です')
    elif b == 'capacity':
        if cap >= 12:
            raise ValueError('電池は最大です')
        cost = 2
        cap += 2
    if cost > s.materials:
        raise ValueError('資材が足りません')
    return (tuple(sh), m, cap, cost)

def forecast(s, b='none'):
    sh, m, cap, cost = construction(s, b)
    w, d, h, snow = WEATHER[s.case][s.season]
    blocked = sum((v for i, v in enumerate(sh) if i <= m)) if d == -1 else sum((v for i, v in enumerate(sh) if i >= m))
    power = max(0, w - (blocked if s.blocking else 0))
    needs = tuple((max(0, h - int(v)) for v in sh))
    available = min(cap, s.battery + power)
    return {'shields': sh, 'mill': m, 'capacity': cap, 'cost': cost, 'wind': w, 'direction': d, 'snow': snow, 'blocked': blocked if s.blocking else 0, 'power': power, 'needs': needs, 'available': available, 'spill': max(0, s.battery + power - cap)}

def predict(s, p):
    f = forecast(s, p.build)
    if not isinstance(p.heat, tuple) or len(p.heat) != 3 or any((type(h) != int or not 0 <= h <= n for h, n in zip(p.heat, f['needs']))):
        raise ValueError('配分は必要量までです')
    if sum(p.heat) > f['available']:
        raise ValueError('電力が足りません')
    f['comfort'] = tuple((min(3, c + 1) if h >= n else max(0, c - (n - h)) for c, h, n in zip(s.comfort, p.heat, f['needs'])))
    f['shelter'] = any((c == 0 for c in f['comfort']))
    f['remaining'] = f['available'] - sum(p.heat)
    return f

def step(s, p):
    f = predict(s, p)
    entry = (s.season, p.build, p.heat, f['power'], f['spill'], f['comfort'], f['remaining'], f['shelter'])
    return replace(s, season=s.season + 1, mill=f['mill'], shields=f['shields'], capacity=f['capacity'], materials=s.materials - f['cost'] + 1, battery=f['remaining'], comfort=f['comfort'], shelter=s.shelter + int(f['shelter']), history=s.history + (entry,))

def success(s):
    return s.season == 4 and min(s.comfort) >= 1

def plans(s):
    for b in BUILDS:
        try:
            f = forecast(s, b)
        except ValueError:
            continue
        for heat in product(*(range(n + 1) for n in f['needs'])):
            if sum(heat) <= f['available']:
                yield Plan(b, heat)

def digest(s):
    return hashlib.sha256(json.dumps(asdict(s), sort_keys=True, ensure_ascii=False).encode('utf-8')).hexdigest()

def restore(d):
    if not isinstance(d, dict) or set(d) != set(asdict(new())):
        raise ValueError('状態の項目が違います')
    if d['case'] not in TITLES or type(d['blocking']) != bool:
        raise ValueError('状況が不正')
    for k, lo, hi in [('season', 0, 4), ('mill', 0, 2), ('materials', 0, 9), ('battery', 0, 12), ('capacity', 8, 12), ('shelter', 0, 4)]:
        if type(d[k]) != int or not lo <= d[k] <= hi:
            raise ValueError('状態の範囲が不正')
    if d['capacity'] not in (8, 10, 12) or d['battery'] > d['capacity']:
        raise ValueError('電池が不正')
    if not isinstance(d['shields'], (list, tuple)) or len(d['shields']) != 3 or any((type(x) != bool for x in d['shields'])):
        raise ValueError('雪よけが不正')
    if not isinstance(d['comfort'], (list, tuple)) or len(d['comfort']) != 3 or any((type(x) != int or not 0 <= x <= 3 for x in d['comfort'])):
        raise ValueError('快適度が不正')
    if not isinstance(d['history'], (list, tuple)) or len(d['history']) != d['season']:
        raise ValueError('履歴が不正')
    s = new(d['case'], d['blocking'])
    for e in d['history']:
        if not isinstance(e, (list, tuple)) or len(e) != 8:
            raise ValueError('履歴が不正')
        s = step(s, Plan(e[1], tuple(e[2])))
    if json.loads(json.dumps(asdict(s))) != json.loads(json.dumps(d)):
        raise ValueError('履歴と状態が一致しません')
    return s


STAGE = 4
SEASON_LABELS=("AUTUMN","EARLY WINTER","DEEP WINTER","THAW")
BUILD_LABELS=("NONE","SHIELD LEFT","SHIELD MIDDLE","SHIELD RIGHT","MOVE LEFT","MOVE MIDDLE","MOVE RIGHT","CAPACITY +2","REMOVE LEFT","REMOVE MIDDLE","REMOVE RIGHT")

class App:
    def __init__(self):
        self.reset()

    def reset(self):
        self.state=new("A",blocking=True)
        self.plan=Plan()
        self.house=0
        self.choice=0
        self.confirm=False
        self.gated=False
        self.message='READY'

    def press(self,key):
        if key=='R':self.reset();return
        if self.confirm:
            if key=='X':self.confirm=False;self.message='DRAFT KEPT';return
            if key=='RETURN':
                self.state=step(self.state,self.plan);self.plan=Plan()
                self.confirm=False;self.message='SEASON COMMITTED'
            return
        if self.state.season>=4:return
        if key in ('LEFT','RIGHT'):
            self.choice=(self.choice+(-1 if key=='LEFT' else 1))%len(BUILDS)
        if key=='Z':
            try:
                f=forecast(self.state,BUILDS[self.choice])
                self.plan=Plan(BUILDS[self.choice],tuple(min(h,n) for h,n in zip(self.plan.heat,f['needs'])))
                self.message='FREE DRAFT / NOTHING CHARGED'
            except ValueError:self.message='CONSTRUCTION REFUSED'
        if key=='TAB':self.house=(self.house+1)%3
        if key in ('UP','DOWN'):
            f=forecast(self.state,self.plan.build);heat=list(self.plan.heat)
            heat[self.house]=max(0,min(f['needs'][self.house],heat[self.house]+(1 if key=='UP' else -1)))
            self.plan=Plan(self.plan.build,tuple(heat));self.message='FREE DRAFT'
        if key=='RETURN':
            try:predict(self.state,self.plan);self.confirm=True;self.message='PREVIEW / ENTER AGAIN TO COMMIT'
            except ValueError:self.message='NOT ENOUGH ENERGY / LOWER HEAT'

    def control(self,pressed,held):
        if self.gated:
            if not pressed and not held:self.gated=False
            return
        if len(pressed|held)>1:
            self.gated=True;self.message='RELEASE ALL KEYS';return
        if len(pressed)!=1:return
        self.press(next(iter(pressed)));self.gated=True

    def draw(self,p):
        s=self.state;p.cls(1)
        p.text(12,12,'WINDMILL VILLAGE / STAGE '+str(STAGE)+' / A',10)
        p.text(12,29,'FICTIONAL TOY / NOT REAL HEATING OR GENERATOR DESIGN',6)
        p.text(12,51,'SEASON '+str(s.season)+' / 4  '+(SEASON_LABELS[s.season] if s.season<4 else 'FINISHED'),7)
        p.text(12,68,'BATTERY '+str(s.battery)+' / CAPACITY '+str(s.capacity),11)
        for i,w in enumerate(WEATHER['A']):
            x=12+i*145;p.rectb(x,88,138,36,10 if i==s.season else 5)
            p.text(x+6,95,SEASON_LABELS[i],7)
            p.text(x+6,110,'WIND '+str(w[0])+' / '+('W -> E' if w[1]==-1 else 'E -> W'),6)
        p.text(12,139,'MATERIALS '+str(s.materials)+' / SHELTER SEASONS '+str(s.shelter),6)
        if s.season<4:
            f=forecast(s,self.plan.build)
            p.text(12,157,'POWER '+str(f['power'])+' / AVAILABLE '+str(f['available'])+' / SPILL '+str(f['spill'])+' / DRAFT COST '+str(f['cost']),11)
            for i in range(3):
                x=12+i*193;p.rectb(x,187,181,65,10 if i==self.house else 5)
                p.text(x+7,196,('LEFT','MIDDLE','RIGHT')[i]+' HOUSE',7)
                p.text(x+7,213,'HEAT '+str(self.plan.heat[i])+' / NEED '+str(f['needs'][i]),11)
                p.text(x+7,230,'COMFORT '+str(s.comfort[i])+' / DRAFT SHIELD '+str(int(f['shields'][i])),6)
            try:
                prediction=predict(s,self.plan)
                p.text(12,268,'NEXT COMFORT '+str(prediction['comfort'])+' / BATTERY '+str(prediction['remaining']),10)
            except ValueError:p.text(12,268,'HEAT TOTAL EXCEEDS AVAILABLE ENERGY',8)
            p.text(12,296,'SELECT '+BUILD_LABELS[self.choice]+' / DRAFT '+BUILD_LABELS[BUILDS.index(self.plan.build)],7)
            p.text(12,314,'DRAFT MILL COLUMN '+str(f['mill'])+' / UPSTREAM SHIELDS '+str(f['blocked']),6)
        else:
            p.text(12,188,'FINAL COMFORT '+str(s.comfort)+' / '+('SUCCESS' if success(s) else 'TRY ANOTHER PLAN'),10)
            p.text(12,213,'SHELTER IS NOT PERMANENT FAILURE; ONLY FINAL COMFORT MATTERS.',7)
        p.text(12,342,'SHIELDS '+('DO NOT BLOCK WIND IN THIS SIMPLIFIED STAGE' if STAGE==3 else 'BLOCK UPSTREAM WIND' if STAGE>=4 else 'NOT INTRODUCED'),14)
        p.text(12,359,'SEASONS FINISHED / R TO RESTART' if s.season>=4 else 'PREVIEW: ENTER TO COMMIT / X TO CANCEL' if self.confirm else 'EDITING: NO RESOURCE HAS BEEN CHARGED',10)
        p.text(12,403,'TAB: HOUSE / UP/DOWN: HEAT / ENTER: PREVIEW, THEN COMMIT',7)
        p.text(12,420,'LEFT/RIGHT: BUILD CHOICE / Z: PUT IT IN DRAFT / R: RESET',7)
        p.text(12,437,'R: RESET / ESC: QUIT',7)
        p.text(12,382,self.message,10)

def demo_keys():
    return ('RIGHT','RIGHT','RIGHT','Z','UP','TAB','UP','RETURN','RETURN')

def main(headless=False,screenshot=None,demo=False):
    import pyxel as p
    p.init(600,464,title='Windmill village lesson '+str(STAGE),fps=30,display_scale=2,headless=headless)
    app=App();sequence=demo_keys() if demo else ()
    keys={'LEFT':p.KEY_LEFT,'RIGHT':p.KEY_RIGHT,'UP':p.KEY_UP,'DOWN':p.KEY_DOWN,'TAB':p.KEY_TAB,'RETURN':p.KEY_RETURN,'SPACE':p.KEY_SPACE,'Z':p.KEY_Z,'X':p.KEY_X,'R':p.KEY_R}
    limit=max(20,2*len(sequence)+8)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:app.draw(p);p.screen.save(screenshot,1)
            p.quit()
        if headless:
            if demo:
                index=p.frame_count//2
                pressed={sequence[index]} if p.frame_count%2==0 and index<len(sequence) else set()
                app.control(pressed,pressed)
        else:app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true');parser.add_argument('--demo-checkpoint',action='store_true');parser.add_argument('--screenshot')
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    if args.screenshot and (Path(args.screenshot).suffix.lower()!='.png' or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):parser.error('use a new .png in an existing directory')
    if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 05 / 05

直前の季節と、使った計画を戻す

履歴を実行し直して、つじつまの合った状態に戻す。

編集の画面で X を押すと、直前に確定した季節に戻ります。電池だけでなく、資材・容量・風車の位置・雪よけ・3軒の快適さ・暖所を使った回数・履歴も戻します。さらに、その季節に使った建設と熱の配り方を Plan に戻すので、少しだけ直して、もう一度確定できます。

元のゲームは、過去の State を全部並べて持つ方式ではありません。history に確定した計画と結果を記録し、Undo では、最初の状態を作ってから、最後の1季節以外を step で実行し直します。元と同じ関数を使い、資材を返したり快適さを足したりする、別の逆算の式は使いません。季節は最大4つなので、実行し直す長さも限られています。

最初の季節で X を押すと、Plan だけを空にし、State は変えません。結果の画面からも、X で最後の季節に戻れます。確認の画面の X は、これまでどおり確認を取り消すだけで、季節は戻しません。R はすべてを最初に戻します。

元のゲームの保存と読みこみの画面、日本語の文字、表紙、一時停止や確認の画面の一部は、教材では省いています。完成版の restore は元のゲームと同じく、履歴を実行し直して状態を確かめますが、この画面でファイルに保存できるという意味ではありません。元のゲームと照らし合わせた、限られた数の計画は検証の記録にはっきり書いていて、4つの季節のすべての入力の履歴や、一番良い計画の証明にはしていません。

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

05_season_undo.py / 注目する部分
                for entry in old.history[:-1]:
                    restored=step(restored,Plan(entry[1],entry[2]))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_season_undo.py
macOS / Linux
.venv/bin/python 05_season_undo.py
画面で見ること右の雪よけと HEAT (1,1,0) を Enter 2回で確定し、編集の画面で X を押します。SEASON 0・BATTERY 4・MATERIALS 5 に戻り、Plan には右の雪よけと (1,1,0) が戻ってきます。もう一度 Enter を2回押すと、同じ季節の結果になります。
  • 状態一式を、履歴から実行し直して戻す
  • 直前の計画を、編集中の下書きに戻す
  • 確認の取り消しと、季節の Undo を区別する
つまずきやすいところold.history[:-1](最後を除いた履歴)に戻すのに、実行し直す最後に old.history[-1] まで含めると、1つも季節が戻りません。最後の計画は State で実行せず、編集中の Plan に戻します。
少し変えてみよう

同じ季節を確定 → X → 左を3回押して NONE(建設なし)を選び Z で下書きへ → 熱を3軒に1ずつに直す → 確定、と試してみましょう。雪よけは付かず、資材は6、電池4、快適さはすべて3です。戻る前の雪よけの費用を、二重に払っていないことを確かめます。

05_season_undo.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(299行)
05_season_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。架空の季節資源模型。現実の発電・暖房設計ではありません。"""
from dataclasses import dataclass, asdict, replace

from itertools import product

import json, hashlib

VERSION = '1.0.0'

GAME = '091'

TITLES = {'A': '西からの初雪', 'B': '向きの変わる冬', 'C': '古い蓄電池'}

SEASONS = ('秋', '初冬', '厳冬', '雪解け')

WEATHER = {'A': ((3, -1, 1, 0), (4, -1, 2, 1), (4, -1, 2, 2), (3, -1, 1, 0)), 'B': ((3, -1, 1, 0), (4, 1, 2, 1), (5, -1, 3, 3), (3, 1, 1, 0)), 'C': ((5, 1, 1, 0), (2, 1, 2, 2), (4, 1, 2, 2), (4, -1, 1, 0))}

BUILDS = ('none', 'shield:0', 'shield:1', 'shield:2', 'move:0', 'move:1', 'move:2', 'capacity', 'remove:0', 'remove:1', 'remove:2')

BUILD_NAMES = ('建設なし', '左に雪よけ', '中に雪よけ', '右に雪よけ', '風車を左へ', '風車を中へ', '風車を右へ', '電池を増設', '左の雪よけ撤去', '中の雪よけ撤去', '右の雪よけ撤去')

@dataclass(frozen=True)
class State:
    case: str = 'A'
    season: int = 0
    mill: int = 1
    shields: tuple = (False, False, False)
    materials: int = 5
    battery: int = 4
    capacity: int = 8
    comfort: tuple = (3, 3, 3)
    shelter: int = 0
    history: tuple = ()
    blocking: bool = True

@dataclass(frozen=True)
class Plan:
    build: str = 'none'
    heat: tuple = (0, 0, 0)

def new(case='A', blocking=True):
    return State(case=case, battery={'A': 4, 'B': 5, 'C': 7}[case], comfort=(2, 3, 2) if case == 'C' else (3, 3, 3), blocking=blocking)

def construction(s, b):
    if b not in BUILDS:
        raise ValueError('未知の建設')
    if s.season >= 4:
        raise ValueError('全季節終了')
    sh = list(s.shields)
    m = s.mill
    cap = s.capacity
    cost = 0
    if b.startswith('shield:'):
        i = int(b[-1])
        cost = 2
        if sh[i]:
            raise ValueError('ここには雪よけがあります')
        sh[i] = True
    elif b.startswith('remove:'):
        i = int(b[-1])
        if not sh[i]:
            raise ValueError('撤去する雪よけがありません')
        sh[i] = False
    elif b.startswith('move:'):
        m = int(b[-1])
        cost = 1
        if m == s.mill:
            raise ValueError('風車は同じ列です')
    elif b == 'capacity':
        if cap >= 12:
            raise ValueError('電池は最大です')
        cost = 2
        cap += 2
    if cost > s.materials:
        raise ValueError('資材が足りません')
    return (tuple(sh), m, cap, cost)

def forecast(s, b='none'):
    sh, m, cap, cost = construction(s, b)
    w, d, h, snow = WEATHER[s.case][s.season]
    blocked = sum((v for i, v in enumerate(sh) if i <= m)) if d == -1 else sum((v for i, v in enumerate(sh) if i >= m))
    power = max(0, w - (blocked if s.blocking else 0))
    needs = tuple((max(0, h - int(v)) for v in sh))
    available = min(cap, s.battery + power)
    return {'shields': sh, 'mill': m, 'capacity': cap, 'cost': cost, 'wind': w, 'direction': d, 'snow': snow, 'blocked': blocked if s.blocking else 0, 'power': power, 'needs': needs, 'available': available, 'spill': max(0, s.battery + power - cap)}

def predict(s, p):
    f = forecast(s, p.build)
    if not isinstance(p.heat, tuple) or len(p.heat) != 3 or any((type(h) != int or not 0 <= h <= n for h, n in zip(p.heat, f['needs']))):
        raise ValueError('配分は必要量までです')
    if sum(p.heat) > f['available']:
        raise ValueError('電力が足りません')
    f['comfort'] = tuple((min(3, c + 1) if h >= n else max(0, c - (n - h)) for c, h, n in zip(s.comfort, p.heat, f['needs'])))
    f['shelter'] = any((c == 0 for c in f['comfort']))
    f['remaining'] = f['available'] - sum(p.heat)
    return f

def step(s, p):
    f = predict(s, p)
    entry = (s.season, p.build, p.heat, f['power'], f['spill'], f['comfort'], f['remaining'], f['shelter'])
    return replace(s, season=s.season + 1, mill=f['mill'], shields=f['shields'], capacity=f['capacity'], materials=s.materials - f['cost'] + 1, battery=f['remaining'], comfort=f['comfort'], shelter=s.shelter + int(f['shelter']), history=s.history + (entry,))

def success(s):
    return s.season == 4 and min(s.comfort) >= 1

def plans(s):
    for b in BUILDS:
        try:
            f = forecast(s, b)
        except ValueError:
            continue
        for heat in product(*(range(n + 1) for n in f['needs'])):
            if sum(heat) <= f['available']:
                yield Plan(b, heat)

def digest(s):
    return hashlib.sha256(json.dumps(asdict(s), sort_keys=True, ensure_ascii=False).encode('utf-8')).hexdigest()

def restore(d):
    if not isinstance(d, dict) or set(d) != set(asdict(new())):
        raise ValueError('状態の項目が違います')
    if d['case'] not in TITLES or type(d['blocking']) != bool:
        raise ValueError('状況が不正')
    for k, lo, hi in [('season', 0, 4), ('mill', 0, 2), ('materials', 0, 9), ('battery', 0, 12), ('capacity', 8, 12), ('shelter', 0, 4)]:
        if type(d[k]) != int or not lo <= d[k] <= hi:
            raise ValueError('状態の範囲が不正')
    if d['capacity'] not in (8, 10, 12) or d['battery'] > d['capacity']:
        raise ValueError('電池が不正')
    if not isinstance(d['shields'], (list, tuple)) or len(d['shields']) != 3 or any((type(x) != bool for x in d['shields'])):
        raise ValueError('雪よけが不正')
    if not isinstance(d['comfort'], (list, tuple)) or len(d['comfort']) != 3 or any((type(x) != int or not 0 <= x <= 3 for x in d['comfort'])):
        raise ValueError('快適度が不正')
    if not isinstance(d['history'], (list, tuple)) or len(d['history']) != d['season']:
        raise ValueError('履歴が不正')
    s = new(d['case'], d['blocking'])
    for e in d['history']:
        if not isinstance(e, (list, tuple)) or len(e) != 8:
            raise ValueError('履歴が不正')
        s = step(s, Plan(e[1], tuple(e[2])))
    if json.loads(json.dumps(asdict(s))) != json.loads(json.dumps(d)):
        raise ValueError('履歴と状態が一致しません')
    return s


STAGE = 5
SEASON_LABELS=("AUTUMN","EARLY WINTER","DEEP WINTER","THAW")
BUILD_LABELS=("NONE","SHIELD LEFT","SHIELD MIDDLE","SHIELD RIGHT","MOVE LEFT","MOVE MIDDLE","MOVE RIGHT","CAPACITY +2","REMOVE LEFT","REMOVE MIDDLE","REMOVE RIGHT")

class App:
    def __init__(self):
        self.reset()

    def reset(self):
        self.state=new("A",blocking=True)
        self.plan=Plan()
        self.house=0
        self.choice=0
        self.confirm=False
        self.gated=False
        self.message='READY'

    def press(self,key):
        if key=='R':self.reset();return
        if self.confirm:
            if key=='X':self.confirm=False;self.message='DRAFT KEPT';return
            if key=='RETURN':
                self.state=step(self.state,self.plan);self.plan=Plan()
                self.confirm=False;self.message='SEASON COMMITTED'
            return
        if key=='X':
            old=self.state
            if old.season:
                restored=new(old.case,old.blocking)
                for entry in old.history[:-1]:
                    restored=step(restored,Plan(entry[1],entry[2]))
                self.state=restored
                self.plan=Plan(old.history[-1][1],old.history[-1][2])
                self.message='PREVIOUS SEASON AND DRAFT RESTORED'
            else:self.plan=Plan();self.message='INITIAL DRAFT CLEARED'
            return
        if self.state.season>=4:return
        if key in ('LEFT','RIGHT'):
            self.choice=(self.choice+(-1 if key=='LEFT' else 1))%len(BUILDS)
        if key=='Z':
            try:
                f=forecast(self.state,BUILDS[self.choice])
                self.plan=Plan(BUILDS[self.choice],tuple(min(h,n) for h,n in zip(self.plan.heat,f['needs'])))
                self.message='FREE DRAFT / NOTHING CHARGED'
            except ValueError:self.message='CONSTRUCTION REFUSED'
        if key=='TAB':self.house=(self.house+1)%3
        if key in ('UP','DOWN'):
            f=forecast(self.state,self.plan.build);heat=list(self.plan.heat)
            heat[self.house]=max(0,min(f['needs'][self.house],heat[self.house]+(1 if key=='UP' else -1)))
            self.plan=Plan(self.plan.build,tuple(heat));self.message='FREE DRAFT'
        if key=='RETURN':
            try:predict(self.state,self.plan);self.confirm=True;self.message='PREVIEW / ENTER AGAIN TO COMMIT'
            except ValueError:self.message='NOT ENOUGH ENERGY / LOWER HEAT'

    def control(self,pressed,held):
        if self.gated:
            if not pressed and not held:self.gated=False
            return
        if len(pressed|held)>1:
            self.gated=True;self.message='RELEASE ALL KEYS';return
        if len(pressed)!=1:return
        self.press(next(iter(pressed)));self.gated=True

    def draw(self,p):
        s=self.state;p.cls(1)
        p.text(12,12,'WINDMILL VILLAGE / STAGE '+str(STAGE)+' / A',10)
        p.text(12,29,'FICTIONAL TOY / NOT REAL HEATING OR GENERATOR DESIGN',6)
        p.text(12,51,'SEASON '+str(s.season)+' / 4  '+(SEASON_LABELS[s.season] if s.season<4 else 'FINISHED'),7)
        p.text(12,68,'BATTERY '+str(s.battery)+' / CAPACITY '+str(s.capacity),11)
        for i,w in enumerate(WEATHER['A']):
            x=12+i*145;p.rectb(x,88,138,36,10 if i==s.season else 5)
            p.text(x+6,95,SEASON_LABELS[i],7)
            p.text(x+6,110,'WIND '+str(w[0])+' / '+('W -> E' if w[1]==-1 else 'E -> W'),6)
        p.text(12,139,'MATERIALS '+str(s.materials)+' / SHELTER SEASONS '+str(s.shelter),6)
        if s.season<4:
            f=forecast(s,self.plan.build)
            p.text(12,157,'POWER '+str(f['power'])+' / AVAILABLE '+str(f['available'])+' / SPILL '+str(f['spill'])+' / DRAFT COST '+str(f['cost']),11)
            for i in range(3):
                x=12+i*193;p.rectb(x,187,181,65,10 if i==self.house else 5)
                p.text(x+7,196,('LEFT','MIDDLE','RIGHT')[i]+' HOUSE',7)
                p.text(x+7,213,'HEAT '+str(self.plan.heat[i])+' / NEED '+str(f['needs'][i]),11)
                p.text(x+7,230,'COMFORT '+str(s.comfort[i])+' / DRAFT SHIELD '+str(int(f['shields'][i])),6)
            try:
                prediction=predict(s,self.plan)
                p.text(12,268,'NEXT COMFORT '+str(prediction['comfort'])+' / BATTERY '+str(prediction['remaining']),10)
            except ValueError:p.text(12,268,'HEAT TOTAL EXCEEDS AVAILABLE ENERGY',8)
            p.text(12,296,'SELECT '+BUILD_LABELS[self.choice]+' / DRAFT '+BUILD_LABELS[BUILDS.index(self.plan.build)],7)
            p.text(12,314,'DRAFT MILL COLUMN '+str(f['mill'])+' / UPSTREAM SHIELDS '+str(f['blocked']),6)
        else:
            p.text(12,188,'FINAL COMFORT '+str(s.comfort)+' / '+('SUCCESS' if success(s) else 'TRY ANOTHER PLAN'),10)
            p.text(12,213,'SHELTER IS NOT PERMANENT FAILURE; ONLY FINAL COMFORT MATTERS.',7)
        p.text(12,342,'SHIELDS '+('DO NOT BLOCK WIND IN THIS SIMPLIFIED STAGE' if STAGE==3 else 'BLOCK UPSTREAM WIND' if STAGE>=4 else 'NOT INTRODUCED'),14)
        p.text(12,359,'SEASONS FINISHED / R TO RESTART' if s.season>=4 else 'PREVIEW: ENTER TO COMMIT / X TO CANCEL' if self.confirm else 'EDITING: NO RESOURCE HAS BEEN CHARGED',10)
        p.text(12,403,'TAB: HOUSE / UP/DOWN: HEAT / ENTER: PREVIEW, THEN COMMIT',7)
        p.text(12,420,'LEFT/RIGHT: BUILD CHOICE / Z: PUT IT IN DRAFT / R: RESET',7)
        p.text(12,437,'X: PREVIOUS SEASON+DRAFT (OUTSIDE PREVIEW) / ESC: QUIT',7)
        p.text(12,382,self.message,10)

def demo_keys():
    return ('RIGHT','RIGHT','RIGHT','Z','UP','TAB','UP','RETURN','RETURN')+("X",)

def main(headless=False,screenshot=None,demo=False):
    import pyxel as p
    p.init(600,464,title='Windmill village lesson '+str(STAGE),fps=30,display_scale=2,headless=headless)
    app=App();sequence=demo_keys() if demo else ()
    keys={'LEFT':p.KEY_LEFT,'RIGHT':p.KEY_RIGHT,'UP':p.KEY_UP,'DOWN':p.KEY_DOWN,'TAB':p.KEY_TAB,'RETURN':p.KEY_RETURN,'SPACE':p.KEY_SPACE,'Z':p.KEY_Z,'X':p.KEY_X,'R':p.KEY_R}
    limit=max(20,2*len(sequence)+8)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:app.draw(p);p.screen.save(screenshot,1)
            p.quit()
        if headless:
            if demo:
                index=p.frame_count//2
                pressed={sequence[index]} if p.frame_count%2==0 and index<len(sequence) else set()
                app.control(pressed,pressed)
        else:app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true');parser.add_argument('--demo-checkpoint',action='store_true');parser.add_argument('--screenshot')
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    if args.screenshot and (Path(args.screenshot).suffix.lower()!='.png' or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):parser.error('use a new .png in an existing directory')
    if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
AFTER STEP 05

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

ルールのまとめ:4つの季節それぞれで、建設 → 発電 → 容量で切りつめ → 熱を使う → 快適さ → 資材のごほうび、の順。雪よけは必要な熱を1減らすが、風上と同じ列にあると発電も1減らす。雪どけの時点で、すべての家の快適さが1以上なら基本の目標達成。途中で暖所を使っても、すぐ失敗にはならない。

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

比べる点5ステップの教材元の完成版
段階01 電池だけ、02 建設なし、03 風をさえぎらない、04/05 ふつうの状況 A。最初から、建設・風をさえぎる効果・4つの季節・Undo があります。風をさえぎらない場合との比較もあります。
画面と入力英数字(ASCII)・状況 A だけ・段階ごとに1つのファイル。R と Esc はすぐ実行。季節は手で1つずつ進める。状況 A/B/C の選択、日本語の文字、表紙・一時停止・説明・再開の確認。
ルールと保存03 以降はルールの定義をすべて持っていて、03 の画面は blocking=False。04/05 はふつうのルール。保存と読みこみの画面はなし。元のゲームには、履歴つきの保存・実行し直しての確認、比較のレポートがあります。

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

  1. model.py の construction:建設できるかの判定と費用
  2. model.py の forecast:風の向き・容量・必要な熱
  3. model.py の predict / step:共通の式と更新の順番
  4. model.py の restore:履歴を実行し直す仕組み
  5. controller.py の apply / save / load と、確定の画面
  6. experiments.py:3つのやり方と、「風をさえぎる効果」だけを変えた比較
自分のゲームに変えるなら

最初は、数字かルールを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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。

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