シミュレーションLESSON 48周期の風と有限燃料の航路計画原作053

風まかせ郵便気球の作り方

1つ上の高さの風が、帰り道になる。
3つの高さでくり返し変わる風と、限りある燃料を使って郵便を届けます。高さを選ぶ・風に流される・燃料を補給する・帰ってくる、を決まった順番で処理します。

完成版の遊び方3つの高さの風と、限りある補給を読みながら、低く飛んで郵便を届け、家の桟橋へ帰ります。

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

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

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

完成版の主な操作:上下高度、Z確定、左右予報、Space係留待ち、C家で帰還、X戻す、R再開

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

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

うまく表示されないとき

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

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

高さを選んだあとの風と、燃料の移動を、1手にまとめる。

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

1. ゲームの時刻と、配列の番号
2. 高さを変えてから、横に動く
3. 払う・動く・補給する、の順番
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
変数・if・for・関数 / 割り算の余りとタプル / ビットによる配達の印と、書きかえない State は本文で説明します
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
上:飛行中に一層上がって確定 / 下:飛行中に一層下がって確定 / 右:飛行中に高度維持で確定 / Z:係留から離陸 / Space:係留中に待つ(01は一手進行) / C:04/05で家から安全に切り上げ / X:05のUndo / R:初期化 / Esc:終了
難易度の目安
初級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_wind.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

一手ごとに周期の風を適用する

ゲームの時刻と、配列の番号。

まずは、中くらいの高さ H1 の風だけで、横の位置を変える練習です。最初の位置 X1 から、Space で1手ずつ進めます。T はゲームの中で判断した回数で、待っている本当の時間では増えません。

WINDS の1行は、1つの高さの風のくり返しです。H1 は 0・+1・0・-1 です。state.t%4(4で割った余り)は 0・1・2・3・0… とくり返すので、その列の風を1回だけ位置に足します。最初の Space では風が0なので場所は変わりませんが、時刻は1に進みます。

State は、時刻・横の位置・高さ・結果をまとめた、書きかえない箱です。step は次の State を返し、draw は今の状態と予告を読むだけです。画面の右にある「次の X/H/T」を何回計算しても、時間は進みません。

この段階では、中くらいの高さを飛んでいる状態から始め、燃料・桟橋にとめること・郵便・期限は省いています。風の予報は3つの高さすべてを表示しますが、高さを選べるようにするのは次の段階です。

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

01_wind.py / 注目する部分
    wind = WINDS[h][state.t%len(WINDS[0])]
    x = state.x+wind

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_wind.py
macOS / Linux
.venv/bin/python 01_wind.py
画面で見ることSpace を2回押すと T2・X2・H1、4回押すと T4・X1 に戻ります。キーを離してから次を押してください。
  • 1手で風を1回だけ使う
  • 時刻を、くり返しの長さで折り返す
  • 予告では状態を使わない
つまずきやすいところ配列の t 番目をそのまま読むと、4手目からは範囲の外になってしまいます。くり返しの長さで割った余りを使い、どの高さもくり返しの長さをそろえておきます。
少し変えてみよう

WINDS の中くらいの高さの最初の0を1に変えて、もう一度起動してみましょう。最初の Space で T1・X2 になります。確かめたら0に戻します。

01_wind.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(156行)
01_wind.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。原作Aの有限航路。"""
from dataclasses import dataclass, replace

WINDS = ((-1,-1,1,1),(0,1,0,-1),(2,2,-2,-2))
START = 1

@dataclass(frozen=True)
class State:
    t: int = 0
    x: int = START
    h: int = 1
    outcome: str = "flying"


def initial():
    return State()


def step(state, action):
    if state.outcome != "flying":
        return state, "end"
    if action != "stay":
        return state, "invalid"
    h = 1
    wind = WINDS[h][state.t%len(WINDS[0])]
    x = state.x+wind
    return State(state.t+1,x,h,"flying" if 0<=x<=12 else "drift"), "ok"

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

    def reset(self):
        self.state = initial()
        self.gated = False
        self.message = "READY"

    def control(self, pressed, held):
        if "R" in pressed:
            self.reset()
            self.gated = True
            return
        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
        key = next(iter(pressed))
        if self.state.outcome!="flying":
            return
        action = "stay" if key=="SPACE" else None
        if action is None:
            return
        self.state,self.message = step(self.state,action)
        self.gated = True

    def draw(self, p):
        p.cls(1)
        p.text(8,8,"WIND POST / STAGE 1",10)
        s = self.state
        p.text(8,23,"T "+str(s.t)+"  X "+str(s.x)+"  H "+str(s.h)+"  "+s.outcome.upper(),7)
        for h in range(3):
            y = 150-h*36
            p.line(40,y,400,y,5)
            p.text(8,y-3,"H"+str(h),6)
        for x in range(13):
            p.text(39+x*30,165,str(x),6)
        if 0<=s.x<=12:
            x=40+s.x*30;y=150-s.h*36
            p.circ(x,y-9,5,10)
            p.line(x,y-4,x,y,7)
            p.rect(x-2,y,5,3,9)
        phase=s.t%len(WINDS[0])
        p.text(8,199,"WIND BY TURN COLUMN / NOW "+str(phase),7)
        for h in range(3):
            p.text(8,214+h*14,"H"+str(h),6)
            for col,wind in enumerate(WINDS[h]):
                x=38+col*45
                if col==phase:p.rectb(x-2,211+h*14,30,11,10)
                p.text(x,214+h*14,format(wind,"+d"),7)
        preview_actions = (('SPACE', 'stay'),)
        for row,(key,action) in enumerate(preview_actions):
            after,reason=step(s,action)
            text=key+": X"+str(after.x)+" H"+str(after.h)+" T"+str(after.t)
            if hasattr(after,"fuel"):text+=" F"+str(after.fuel)
            if after==s:text=key+": "+reason.upper()
            p.text(235,214+row*14,text,11 if after!=s else 6)
        p.text(8,263,self.message.upper(),7)
        p.text(8,280,"SPACE ADVANCE  R RESET  ESC QUIT",6)
        p.text(8,306,"ESC QUIT",6)

DEMO_KEYS = ('SPACE', 'SPACE')
KEYS = ("UP","DOWN","RIGHT","Z","SPACE","C","X","R")


def main(headless=False,screenshot=None,demo=False):
    from pathlib import Path
    import pyxel as p
    screenshot=str(Path(screenshot).resolve()) if screenshot else None
    if screenshot:
        Path(screenshot).parent.mkdir(parents=True,exist_ok=True)
    app=App()
    p.init(448,320,title="Wind Post",fps=30,display_scale=2,headless=headless)
    keys={k:getattr(p,"KEY_"+k) for k 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=2)
            p.quit();return
        if headless and demo:
            index=p.frame_count//2
            pressed={DEMO_KEYS[index]} if p.frame_count%2==0 and index<len(DEMO_KEYS) 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
    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

選んだ高さの風を先に決める

高さを変えてから、横に動く。

上で1段上がり、下で1段下がり、右で今の高さのままにします。キーを押すと、その1手がすぐ決まる、教材用の操作です。一度に2段は変えられず、高さ0より下や2より上には行けません。

大事なのは、先に高さ h を変えて、その新しい高さの風を読むことです。T0・X1・H1 から上を押すと、H2 の最初の風 +2 を使って X3 に行きます。元の高さの風0を先に使ってから上がると、別のゲームになってしまいます。

同じ高さでも、時刻(列)が変わると風の向きも変わります。いちばん上の高さは +2・+2・-2・-2 です。速い風の高さが、いつも目的地へ近づけてくれるとは限りません。くり返し全体の表と、3つの操作それぞれの予告を見比べましょう。

まだ燃料は使わず、低い高さの桟橋にもとまりません。横の範囲0〜12から出ると DRIFT(流された)で終わりです。これは本物の天気や飛行機の再現ではなく、整数で1手ずつ進む航路の練習です。

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

02_layers.py / 注目する部分
    h = state.h+{"up":1,"down":-1,"stay":0}[action]
    if not 0<=h<=2:
        return state, "height"

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_layers.py
macOS / Linux
.venv/bin/python 02_layers.py
画面で見ること上 → 右 → 下 → 下 と押すと T4・X6・H0 になります。最初の上だけなら T1・X3・H2 です。
  • 高さを選んだあとの風を使う
  • はしの高さで、さらに上下には行けない
  • 受け付けられなかったら時刻は進まない
つまずきやすいところ高さを変える前と後の、両方の高さの風を足してはいけません。選んだあとの高さの風を、1回だけ使います。
少し変えてみよう

R → 上 → 上 と押してみましょう。2回目は HEIGHT(高さ)で受け付けられず、T1・X3・H2 のままです。もう一度上を押しても、時刻は進みません。

02_layers.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(158行)
02_layers.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。原作Aの有限航路。"""
from dataclasses import dataclass, replace

WINDS = ((-1,-1,1,1),(0,1,0,-1),(2,2,-2,-2))
START = 1

@dataclass(frozen=True)
class State:
    t: int = 0
    x: int = START
    h: int = 1
    outcome: str = "flying"


def initial():
    return State()


def step(state, action):
    if state.outcome != "flying":
        return state, "end"
    if action not in ("up","down","stay"):
        return state, "invalid"
    h = state.h+{"up":1,"down":-1,"stay":0}[action]
    if not 0<=h<=2:
        return state, "height"
    wind = WINDS[h][state.t%len(WINDS[0])]
    x = state.x+wind
    return State(state.t+1,x,h,"flying" if 0<=x<=12 else "drift"), "ok"

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

    def reset(self):
        self.state = initial()
        self.gated = False
        self.message = "READY"

    def control(self, pressed, held):
        if "R" in pressed:
            self.reset()
            self.gated = True
            return
        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
        key = next(iter(pressed))
        if self.state.outcome!="flying":
            return
        action = {"UP":"up","DOWN":"down","RIGHT":"stay"}.get(key)
        if action is None:
            return
        self.state,self.message = step(self.state,action)
        self.gated = True

    def draw(self, p):
        p.cls(1)
        p.text(8,8,"WIND POST / STAGE 2",10)
        s = self.state
        p.text(8,23,"T "+str(s.t)+"  X "+str(s.x)+"  H "+str(s.h)+"  "+s.outcome.upper(),7)
        for h in range(3):
            y = 150-h*36
            p.line(40,y,400,y,5)
            p.text(8,y-3,"H"+str(h),6)
        for x in range(13):
            p.text(39+x*30,165,str(x),6)
        if 0<=s.x<=12:
            x=40+s.x*30;y=150-s.h*36
            p.circ(x,y-9,5,10)
            p.line(x,y-4,x,y,7)
            p.rect(x-2,y,5,3,9)
        phase=s.t%len(WINDS[0])
        p.text(8,199,"WIND BY TURN COLUMN / NOW "+str(phase),7)
        for h in range(3):
            p.text(8,214+h*14,"H"+str(h),6)
            for col,wind in enumerate(WINDS[h]):
                x=38+col*45
                if col==phase:p.rectb(x-2,211+h*14,30,11,10)
                p.text(x,214+h*14,format(wind,"+d"),7)
        preview_actions = (('UP', 'up'), ('RIGHT', 'stay'), ('DOWN', 'down'))
        for row,(key,action) in enumerate(preview_actions):
            after,reason=step(s,action)
            text=key+": X"+str(after.x)+" H"+str(after.h)+" T"+str(after.t)
            if hasattr(after,"fuel"):text+=" F"+str(after.fuel)
            if after==s:text=key+": "+reason.upper()
            p.text(235,214+row*14,text,11 if after!=s else 6)
        p.text(8,263,self.message.upper(),7)
        p.text(8,280,"UP / DOWN CHANGE LAYER  RIGHT STAY  R RESET",6)
        p.text(8,306,"ESC QUIT",6)

DEMO_KEYS = ('UP', 'RIGHT', 'DOWN', 'DOWN')
KEYS = ("UP","DOWN","RIGHT","Z","SPACE","C","X","R")


def main(headless=False,screenshot=None,demo=False):
    from pathlib import Path
    import pyxel as p
    screenshot=str(Path(screenshot).resolve()) if screenshot else None
    if screenshot:
        Path(screenshot).parent.mkdir(parents=True,exist_ok=True)
    app=App()
    p.init(448,320,title="Wind Post",fps=30,display_scale=2,headless=headless)
    keys={k:getattr(p,"KEY_"+k) for k 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=2)
            p.quit();return
        if headless and demo:
            index=p.frame_count//2
            pressed={DEMO_KEYS[index]} if p.frame_count%2==0 and index<len(DEMO_KEYS) 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
    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

有限の燃料と桟橋を加える

払う・動く・補給する、の順番。

ここからは、家の桟橋(D0・X1・低い高さ H0)にとめた状態から始めます。Z で中くらいの高さへ飛び立つと燃料1、飛んでいる間に上がるときも燃料1を使います。下がるときと高さを保つときは燃料を使いません。燃料が0でも、下がったり高さを保ったりはできるので、すぐに失敗にはなりません。

低い高さで風に運ばれた先が桟橋なら、そこにとまります。とまっているときに Space を押すと、その場で1手待ち、風の列だけが進みます。飛んでいる間の Space は受け付けません。高さを保って風に流されたいときは右を押します。

補給の桟橋 D1 は X6 にあり、在庫は3だけです。着いたときに min(残りの在庫, 6-手持ちの燃料) の分を移し、同じ量を在庫から減らします。受け取った燃料を桟橋にも残したままにすると、いくらでも補給できてしまうので、両方を一緒に更新します。spent は使った燃料の合計です。

この段階では、まだ郵便を数えず、期限もありません。State の delivered(配達済み)は、次の段階のために0のままにしています。とまることと燃料だけを、落ち着いて試すための練習用のルールです。

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

03_fuel.py / 注目する部分
                transfer = min(stocks[i], 6 - fuel)
                fuel += transfer
                stocks[i] -= transfer

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_fuel.py
macOS / Linux
.venv/bin/python 03_fuel.py
画面で見ることZ → 右 → 下 → 右 → 上 → 右 → 下 と押します。T7・X6・H0 で D1 にとまり、FUEL 5・D1の在庫0・SPENT 2 です。
  • 燃料を使うのは、上がるときと飛び立つときだけ
  • 桟橋にとまって風を待てる
  • 補給は在庫から移す
つまずきやすいところ補給の在庫を毎回3に戻さないようにしましょう。stocks を State に持たせ、移した分だけ減らします。待つだけでは燃料は回復しません。
少し変えてみよう

INITIAL_FUEL を4から6に変えて、同じ7手を試してみましょう。燃料は最大の6で止まり、D1 に在庫が1残ります。確かめたら4に戻します。

03_fuel.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(205行)
03_fuel.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。原作Aの有限航路。"""
from dataclasses import dataclass, replace

WINDS = ((-1,-1,1,1),(0,1,0,-1),(2,2,-2,-2))
START = 1
INITIAL_FUEL = 4
DOCKS = ((1,0),(6,3))

@dataclass(frozen=True)
class State:
    t: int
    x: int
    h: int
    moored: int
    fuel: int
    stocks: tuple
    delivered: int = 0
    spent: int = 0
    outcome: str = 'flying'

def initial():
    return State(0,START,0,0,INITIAL_FUEL,tuple(n for _,n in DOCKS))

def step(s, action):
    if s.outcome != 'flying':
        return (s, 'end')
    h = s.h
    fuel = s.fuel
    spent = s.spent
    if s.moored >= 0:
        if action == 'wait':
            ns = replace(s, t=s.t + 1)
            return (ns, 'wait')
        if action != 'launch':
            return (s, 'moored')
        if fuel < 1:
            return (s, 'fuel')
        h = 1
        fuel -= 1
        spent += 1
    else:
        if action not in ('up', 'down', 'stay'):
            return (s, 'air')
        h += {'up': 1, 'down': -1, 'stay': 0}[action]
        if not 0 <= h <= 2:
            return (s, 'height')
        if action == 'up':
            if fuel < 1:
                return (s, 'fuel')
            fuel -= 1
            spent += 1
    wind = WINDS[h][s.t % len(WINDS[0])]
    x = s.x + wind
    delivered = s.delivered
    stocks = list(s.stocks)
    moored = -1
    if h == 0 and 0 <= x <= 12:
        for i, (pos, _) in enumerate(DOCKS):
            if x == pos:
                moored = i
                transfer = min(stocks[i], 6 - fuel)
                fuel += transfer
                stocks[i] -= transfer
                break
    ns = State(s.t + 1, x, h, moored, fuel, tuple(stocks), delivered, spent)
    if not 0 <= x <= 12:
        ns = replace(ns, outcome='drift')
    return (ns, 'ok')

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

    def reset(self):
        self.state = initial()
        self.gated = False
        self.message = "READY"

    def control(self, pressed, held):
        if "R" in pressed:
            self.reset()
            self.gated = True
            return
        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
        key = next(iter(pressed))
        if self.state.outcome!="flying":
            return
        action = {"UP":"up","DOWN":"down","RIGHT":"stay","Z":"launch","SPACE":"wait"}.get(key)
        if action is None:
            return
        self.state,self.message = step(self.state,action)
        self.gated = True

    def draw(self, p):
        p.cls(1)
        p.text(8,8,"WIND POST / STAGE 3",10)
        s = self.state
        p.text(8,23,"T "+str(s.t)+"  X "+str(s.x)+"  H "+str(s.h)+"  "+s.outcome.upper(),7)
        p.text(8,36,"FUEL "+str(s.fuel)+"/6  SPENT "+str(s.spent)+"  "+("MOORED D"+str(s.moored) if s.moored>=0 else "IN AIR"),11)
        for h in range(3):
            y = 150-h*36
            p.line(40,y,400,y,5)
            p.text(8,y-3,"H"+str(h),6)
        for x in range(13):
            p.text(39+x*30,165,str(x),6)
        for i,(pos,_) in enumerate(DOCKS):
            x=40+pos*30
            p.rect(x-5,145,11,9,6)
            p.text(x-7,180,"D"+str(i)+":"+str(s.stocks[i]),7)
        if 0<=s.x<=12:
            x=40+s.x*30;y=150-s.h*36
            p.circ(x,y-9,5,10)
            p.line(x,y-4,x,y,7)
            p.rect(x-2,y,5,3,9)
        phase=s.t%len(WINDS[0])
        p.text(8,199,"WIND BY TURN COLUMN / NOW "+str(phase),7)
        for h in range(3):
            p.text(8,214+h*14,"H"+str(h),6)
            for col,wind in enumerate(WINDS[h]):
                x=38+col*45
                if col==phase:p.rectb(x-2,211+h*14,30,11,10)
                p.text(x,214+h*14,format(wind,"+d"),7)
        preview_actions = (('UP', 'up'), ('RIGHT', 'stay'), ('DOWN', 'down'))
        if s.moored>=0:
            preview_actions = (("Z","launch"),("SPACE","wait"))
        for row,(key,action) in enumerate(preview_actions):
            after,reason=step(s,action)
            text=key+": X"+str(after.x)+" H"+str(after.h)+" T"+str(after.t)
            if hasattr(after,"fuel"):text+=" F"+str(after.fuel)
            if after==s:text=key+": "+reason.upper()
            p.text(235,214+row*14,text,11 if after!=s else 6)
        p.text(8,263,self.message.upper(),7)
        p.text(8,280,"Z LAUNCH  SPACE MOORED WAIT  R RESET",6)
        p.text(8,292,"UP / DOWN / RIGHT: FLY",6)
        p.text(8,306,"ESC QUIT",6)

DEMO_KEYS = ('Z', 'RIGHT', 'DOWN', 'RIGHT', 'UP', 'RIGHT', 'DOWN')
KEYS = ("UP","DOWN","RIGHT","Z","SPACE","C","X","R")


def main(headless=False,screenshot=None,demo=False):
    from pathlib import Path
    import pyxel as p
    screenshot=str(Path(screenshot).resolve()) if screenshot else None
    if screenshot:
        Path(screenshot).parent.mkdir(parents=True,exist_ok=True)
    app=App()
    p.init(448,320,title="Wind Post",fps=30,display_scale=2,headless=headless)
    keys={k:getattr(p,"KEY_"+k) for k 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=2)
            p.quit();return
        if headless and demo:
            index=p.frame_count//2
            pressed={DEMO_KEYS[index]} if p.frame_count%2==0 and index<len(DEMO_KEYS) 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
    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

低空で配達し、家へ帰る

目標の達成と、安全に切り上げることを区別する。

元のゲームの状況 A のふつうのルールを完成させます。郵便は X4 と X9 に届けます。風で流されたあとの位置がそこで、高さが0のときだけ配達できます。高いところで上を通りすぎただけや、動く前にその場所にいただけでは、配達になりません。

delivered は、2通の郵便を届けたかどうかを、整数のビットで持ちます。1<<i がその郵便の印で、|= で一度付けた印を残します。2通とも届けても、まだ終わりではありません。家の D0 へ低く飛んで帰ると WON(成功)です。燃料0で戻っても成功です。

24手に達すると LATE(遅れ)ですが、ちょうどその最後の1手で全部配達して家に帰ったなら、成功を先に判定します。範囲から出た DRIFT は、それより優先です。判定の順番を変えると、同じ最後の1手でも結果が変わってしまうので、条件を先に文章で決めてからプログラムにします。

家にとまっているときに C を押すと、配達の途中でも RETURNED(帰還)として安全に切り上げます。時刻は進まず、失敗ではありません。ただし、全部配達した WON とは別の結果として記録します。操作は、元のゲームの「高さの案を選んで Z で決める」方式から、教材用にキーで直接決める方式へ簡単にしています。

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

04_delivery.py / 注目する部分
    elif moored == 0 and delivered == (1 << len(LETTERS)) - 1:
        ns = replace(ns, outcome='won')
    elif ns.t >= DEADLINE:
        ns = replace(ns, outcome='late')

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_delivery.py
macOS / Linux
.venv/bin/python 04_delivery.py
画面で見ること7手の組を3回続けます。① Z・右・下・右・上・右・下 ② Z・上・下・下・上・下・右 ③ Z・右・下・右・上・右・下。T21・X1・MAIL 2/2・FUEL 0 で WON です。①で1通、②で2通目を届けます。
  • 低い高さで着いた場所で配達する
  • 全部配達してから家に帰る
  • 安全な切り上げは、別の結果
つまずきやすいところ全部配達しただけで成功にしないようにしましょう。家に戻る条件も確かめます。安全に帰った RETURNED を、「終わらなかった失敗」と同じにしません。
少し変えてみよう

DEADLINE を21にすると、同じ航路でちょうど最後の1手で WON になります。20にすると途中で LATE になり、それ以上進めません。確かめたら24に戻します。

04_delivery.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(225行)
04_delivery.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。原作Aの有限航路。"""
from dataclasses import dataclass, replace

WINDS = ((-1,-1,1,1),(0,1,0,-1),(2,2,-2,-2))
START = 1
INITIAL_FUEL = 4
DOCKS = ((1,0),(6,3))
LETTERS = (4,9)
DEADLINE = 24

@dataclass(frozen=True)
class State:
    t: int
    x: int
    h: int
    moored: int
    fuel: int
    stocks: tuple
    delivered: int = 0
    spent: int = 0
    outcome: str = 'flying'

def initial():
    return State(0,START,0,0,INITIAL_FUEL,tuple(n for _,n in DOCKS))

def step(s, action):
    if s.outcome != 'flying':
        return (s, 'end')
    if action == 'cut':
        if s.moored != 0:
            return (s, 'home')
        return (replace(s, outcome='returned'), 'cut')
    h = s.h
    fuel = s.fuel
    spent = s.spent
    if s.moored >= 0:
        if action == 'wait':
            ns = replace(s, t=s.t + 1)
            if ns.t >= DEADLINE:
                ns = replace(ns, outcome='late')
            return (ns, 'wait')
        if action != 'launch':
            return (s, 'moored')
        if fuel < 1:
            return (s, 'fuel')
        h = 1
        fuel -= 1
        spent += 1
    else:
        if action not in ('up', 'down', 'stay'):
            return (s, 'air')
        h += {'up': 1, 'down': -1, 'stay': 0}[action]
        if not 0 <= h <= 2:
            return (s, 'height')
        if action == 'up':
            if fuel < 1:
                return (s, 'fuel')
            fuel -= 1
            spent += 1
    wind = WINDS[h][s.t % len(WINDS[0])]
    x = s.x + wind
    delivered = s.delivered
    stocks = list(s.stocks)
    moored = -1
    if h == 0 and 0 <= x <= 12:
        for i, pos in enumerate(LETTERS):
            if x == pos:
                delivered |= 1 << i
        for i, (pos, _) in enumerate(DOCKS):
            if x == pos:
                moored = i
                transfer = min(stocks[i], 6 - fuel)
                fuel += transfer
                stocks[i] -= transfer
                break
    ns = State(s.t + 1, x, h, moored, fuel, tuple(stocks), delivered, spent)
    if not 0 <= x <= 12:
        ns = replace(ns, outcome='drift')
    elif moored == 0 and delivered == (1 << len(LETTERS)) - 1:
        ns = replace(ns, outcome='won')
    elif ns.t >= DEADLINE:
        ns = replace(ns, outcome='late')
    return (ns, 'ok')

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

    def reset(self):
        self.state = initial()
        self.gated = False
        self.message = "READY"

    def control(self, pressed, held):
        if "R" in pressed:
            self.reset()
            self.gated = True
            return
        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
        key = next(iter(pressed))
        if self.state.outcome!="flying":
            return
        action = {"UP":"up","DOWN":"down","RIGHT":"stay","Z":"launch","SPACE":"wait","C":"cut"}.get(key)
        if action is None:
            return
        self.state,self.message = step(self.state,action)
        self.gated = True

    def draw(self, p):
        p.cls(1)
        p.text(8,8,"WIND POST / STAGE 4",10)
        s = self.state
        p.text(8,23,"T "+str(s.t)+"  X "+str(s.x)+"  H "+str(s.h)+"  "+s.outcome.upper(),7)
        p.text(8,36,"FUEL "+str(s.fuel)+"/6  SPENT "+str(s.spent)+"  "+("MOORED D"+str(s.moored) if s.moored>=0 else "IN AIR"),11)
        p.text(245,36,"MAIL "+str(s.delivered.bit_count())+"/2  LIMIT "+str(DEADLINE),10)
        for h in range(3):
            y = 150-h*36
            p.line(40,y,400,y,5)
            p.text(8,y-3,"H"+str(h),6)
        for x in range(13):
            p.text(39+x*30,165,str(x),6)
        for i,(pos,_) in enumerate(DOCKS):
            x=40+pos*30
            p.rect(x-5,145,11,9,6)
            p.text(x-7,180,"D"+str(i)+":"+str(s.stocks[i]),7)
        for i,pos in enumerate(LETTERS):
            x=40+pos*30
            p.rectb(x-4,139,9,7,11 if s.delivered&(1<<i) else 10)
            p.text(x-4,131,"M"+str(i+1),7)
        if 0<=s.x<=12:
            x=40+s.x*30;y=150-s.h*36
            p.circ(x,y-9,5,10)
            p.line(x,y-4,x,y,7)
            p.rect(x-2,y,5,3,9)
        phase=s.t%len(WINDS[0])
        p.text(8,199,"WIND BY TURN COLUMN / NOW "+str(phase),7)
        for h in range(3):
            p.text(8,214+h*14,"H"+str(h),6)
            for col,wind in enumerate(WINDS[h]):
                x=38+col*45
                if col==phase:p.rectb(x-2,211+h*14,30,11,10)
                p.text(x,214+h*14,format(wind,"+d"),7)
        preview_actions = (('UP', 'up'), ('RIGHT', 'stay'), ('DOWN', 'down'))
        if s.moored>=0:
            preview_actions = (("Z","launch"),("SPACE","wait"))
        for row,(key,action) in enumerate(preview_actions):
            after,reason=step(s,action)
            text=key+": X"+str(after.x)+" H"+str(after.h)+" T"+str(after.t)
            if hasattr(after,"fuel"):text+=" F"+str(after.fuel)
            if after==s:text=key+": "+reason.upper()
            p.text(235,214+row*14,text,11 if after!=s else 6)
        p.text(8,263,self.message.upper(),7)
        p.text(8,280,"Z LAUNCH  SPACE MOORED WAIT  R RESET",6)
        p.text(8,292,"UP / DOWN / RIGHT: FLY  C SAFE RETURN",6)
        p.text(8,306,"ESC QUIT",6)

DEMO_KEYS = ('Z', 'RIGHT', 'DOWN', 'RIGHT', 'UP', 'RIGHT', 'DOWN', 'Z', 'UP', 'DOWN', 'DOWN', 'UP', 'DOWN', 'RIGHT', 'Z', 'RIGHT', 'DOWN', 'RIGHT', 'UP', 'RIGHT', 'DOWN')
KEYS = ("UP","DOWN","RIGHT","Z","SPACE","C","X","R")


def main(headless=False,screenshot=None,demo=False):
    from pathlib import Path
    import pyxel as p
    screenshot=str(Path(screenshot).resolve()) if screenshot else None
    if screenshot:
        Path(screenshot).parent.mkdir(parents=True,exist_ok=True)
    app=App()
    p.init(448,320,title="Wind Post",fps=30,display_scale=2,headless=headless)
    keys={k:getattr(p,"KEY_"+k) for k 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=2)
            p.quit();return
        if headless and demo:
            index=p.frame_count//2
            pressed={DEMO_KEYS[index]} if p.frame_count%2==0 and index<len(DEMO_KEYS) 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
    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

燃料も配達も、一つ前へ戻す

状態全体と、受け付けた判断の履歴。

Timeline に、今の State と過去の State を保存し、X で1つ前の判断に戻します。時刻だけでなく、位置・高さ・とまっているか・燃料・補給の在庫・郵便の印・使った燃料の合計・結果を、全部戻します。

apply は、step がちがう State を返したときだけ履歴に積みます。燃料不足や行けない高さなど、受け付けなかった操作は積みません。一方、C による安全な帰還は、時刻が0のままでも結果が変わるので、1つの判断として積みます。

結果が出たあとも X を先に受け付けるので、WON・RETURNED・DRIFT・LATE から戻せます。R は履歴もふくめて最初に戻します。画面の予告は step を読むだけの仮の計算で、履歴には積みません。

元のゲームの状況 A のふつうのルールでたどりつける 7749 の状態と、54243 通りの変化の候補を比べています。どんな風や、いくらでも作れる面でも正しく動く、と保証するものではありません。状況 B・C、いちばん上の風を変えた比較版、表紙・一時停止、再現ファイルの保存は、教材では省いています。元のゲームの step と replay_data で、処理の順番と、入力の意味の記録を読み進めてみましょう。

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

05_undo.py / 注目する部分
    return (Timeline(state,timeline.history+(timeline.state,)),reason) if state!=timeline.state else (timeline,reason)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_undo.py
macOS / Linux
.venv/bin/python 05_undo.py
画面で見ることR → C と押すと T0 で RETURNED になり、X で T0 の FLYING(飛行中)に戻ります。21手で WON にしたあとも、X を押せば1手前に戻れます。
  • 在庫と配達の印も Undo で戻る
  • 時刻0の切り上げも履歴に入る
  • 予告では履歴は変わらない
つまずきやすいところ燃料だけを戻して在庫を戻さないと、補給の記録が合わなくなります。書きかえない State 全体を保存します。
少し変えてみよう

03 の7手で D1 に着いたら、X を押してみましょう。T6・X5・H1・FUEL 2、D1 の在庫3に戻ります。もう一度下を押すと同じ補給になり、燃料が増え続けないことを確かめます。

05_undo.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(246行)
05_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。原作Aの有限航路。"""
from dataclasses import dataclass, replace

WINDS = ((-1,-1,1,1),(0,1,0,-1),(2,2,-2,-2))
START = 1
INITIAL_FUEL = 4
DOCKS = ((1,0),(6,3))
LETTERS = (4,9)
DEADLINE = 24

@dataclass(frozen=True)
class State:
    t: int
    x: int
    h: int
    moored: int
    fuel: int
    stocks: tuple
    delivered: int = 0
    spent: int = 0
    outcome: str = 'flying'

def initial():
    return State(0,START,0,0,INITIAL_FUEL,tuple(n for _,n in DOCKS))

def step(s, action):
    if s.outcome != 'flying':
        return (s, 'end')
    if action == 'cut':
        if s.moored != 0:
            return (s, 'home')
        return (replace(s, outcome='returned'), 'cut')
    h = s.h
    fuel = s.fuel
    spent = s.spent
    if s.moored >= 0:
        if action == 'wait':
            ns = replace(s, t=s.t + 1)
            if ns.t >= DEADLINE:
                ns = replace(ns, outcome='late')
            return (ns, 'wait')
        if action != 'launch':
            return (s, 'moored')
        if fuel < 1:
            return (s, 'fuel')
        h = 1
        fuel -= 1
        spent += 1
    else:
        if action not in ('up', 'down', 'stay'):
            return (s, 'air')
        h += {'up': 1, 'down': -1, 'stay': 0}[action]
        if not 0 <= h <= 2:
            return (s, 'height')
        if action == 'up':
            if fuel < 1:
                return (s, 'fuel')
            fuel -= 1
            spent += 1
    wind = WINDS[h][s.t % len(WINDS[0])]
    x = s.x + wind
    delivered = s.delivered
    stocks = list(s.stocks)
    moored = -1
    if h == 0 and 0 <= x <= 12:
        for i, pos in enumerate(LETTERS):
            if x == pos:
                delivered |= 1 << i
        for i, (pos, _) in enumerate(DOCKS):
            if x == pos:
                moored = i
                transfer = min(stocks[i], 6 - fuel)
                fuel += transfer
                stocks[i] -= transfer
                break
    ns = State(s.t + 1, x, h, moored, fuel, tuple(stocks), delivered, spent)
    if not 0 <= x <= 12:
        ns = replace(ns, outcome='drift')
    elif moored == 0 and delivered == (1 << len(LETTERS)) - 1:
        ns = replace(ns, outcome='won')
    elif ns.t >= DEADLINE:
        ns = replace(ns, outcome='late')
    return (ns, 'ok')

@dataclass(frozen=True)
class Timeline:
    state: State
    history: tuple = ()


def apply(timeline, action):
    if action == "undo":
        return (Timeline(timeline.history[-1],timeline.history[:-1]),"undo") if timeline.history else (timeline,"empty")
    state, reason = step(timeline.state,action)
    return (Timeline(state,timeline.history+(timeline.state,)),reason) if state!=timeline.state else (timeline,reason)


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

    def reset(self):
        self.timeline = Timeline(initial())
        self.gated = False
        self.message = "READY"

    @property
    def state(self):
        return self.timeline.state

    def control(self, pressed, held):
        if "R" in pressed:
            self.reset()
            self.gated = True
            return
        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
        key = next(iter(pressed))
        if key=="X":
            self.timeline,self.message = apply(self.timeline,"undo")
            self.gated = True
            return
        if self.state.outcome!="flying":
            return
        action = {"UP":"up","DOWN":"down","RIGHT":"stay","Z":"launch","SPACE":"wait","C":"cut"}.get(key)
        if action is None:
            return
        self.timeline,self.message = apply(self.timeline,action)
        self.gated = True

    def draw(self, p):
        p.cls(1)
        p.text(8,8,"WIND POST / STAGE 5",10)
        s = self.state
        p.text(8,23,"T "+str(s.t)+"  X "+str(s.x)+"  H "+str(s.h)+"  "+s.outcome.upper(),7)
        p.text(8,36,"FUEL "+str(s.fuel)+"/6  SPENT "+str(s.spent)+"  "+("MOORED D"+str(s.moored) if s.moored>=0 else "IN AIR"),11)
        p.text(245,36,"MAIL "+str(s.delivered.bit_count())+"/2  LIMIT "+str(DEADLINE),10)
        for h in range(3):
            y = 150-h*36
            p.line(40,y,400,y,5)
            p.text(8,y-3,"H"+str(h),6)
        for x in range(13):
            p.text(39+x*30,165,str(x),6)
        for i,(pos,_) in enumerate(DOCKS):
            x=40+pos*30
            p.rect(x-5,145,11,9,6)
            p.text(x-7,180,"D"+str(i)+":"+str(s.stocks[i]),7)
        for i,pos in enumerate(LETTERS):
            x=40+pos*30
            p.rectb(x-4,139,9,7,11 if s.delivered&(1<<i) else 10)
            p.text(x-4,131,"M"+str(i+1),7)
        if 0<=s.x<=12:
            x=40+s.x*30;y=150-s.h*36
            p.circ(x,y-9,5,10)
            p.line(x,y-4,x,y,7)
            p.rect(x-2,y,5,3,9)
        phase=s.t%len(WINDS[0])
        p.text(8,199,"WIND BY TURN COLUMN / NOW "+str(phase),7)
        for h in range(3):
            p.text(8,214+h*14,"H"+str(h),6)
            for col,wind in enumerate(WINDS[h]):
                x=38+col*45
                if col==phase:p.rectb(x-2,211+h*14,30,11,10)
                p.text(x,214+h*14,format(wind,"+d"),7)
        preview_actions = (('UP', 'up'), ('RIGHT', 'stay'), ('DOWN', 'down'))
        if s.moored>=0:
            preview_actions = (("Z","launch"),("SPACE","wait"))
        for row,(key,action) in enumerate(preview_actions):
            after,reason=step(s,action)
            text=key+": X"+str(after.x)+" H"+str(after.h)+" T"+str(after.t)
            if hasattr(after,"fuel"):text+=" F"+str(after.fuel)
            if after==s:text=key+": "+reason.upper()
            p.text(235,214+row*14,text,11 if after!=s else 6)
        p.text(8,263,self.message.upper(),7)
        p.text(8,280,"Z LAUNCH  SPACE MOORED WAIT  R RESET",6)
        p.text(8,292,"UP / DOWN / RIGHT: FLY  C SAFE RETURN",6)
        p.text(8,306,"X UNDO  ESC QUIT",6)

DEMO_KEYS = ('Z', 'RIGHT', 'DOWN', 'RIGHT', 'UP', 'RIGHT', 'DOWN', 'Z', 'UP', 'DOWN', 'DOWN', 'UP', 'DOWN', 'RIGHT', 'Z', 'RIGHT', 'DOWN', 'RIGHT', 'UP', 'RIGHT', 'DOWN')
KEYS = ("UP","DOWN","RIGHT","Z","SPACE","C","X","R")


def main(headless=False,screenshot=None,demo=False):
    from pathlib import Path
    import pyxel as p
    screenshot=str(Path(screenshot).resolve()) if screenshot else None
    if screenshot:
        Path(screenshot).parent.mkdir(parents=True,exist_ok=True)
    app=App()
    p.init(448,320,title="Wind Post",fps=30,display_scale=2,headless=headless)
    keys={k:getattr(p,"KEY_"+k) for k 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=2)
            p.quit();return
        if headless and demo:
            index=p.frame_count//2
            pressed={DEMO_KEYS[index]} if p.frame_count%2==0 and index<len(DEMO_KEYS) 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
    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 です。4手でくり返す3つの高さの風、家は X1、郵便は X4 と X9、補給は X6 で在庫3、期限は24手です。飛び立つときと上がるときに燃料1を使い、選んだあとの高さの風を1回だけ使います。低い高さで着いた場所で、配達・とまる・補給をして、全部配達したら家に帰ります。安全な途中での帰還は、失敗とは区別します。

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

比べる点5ステップの教材元の完成版
段階01 は中くらいの高さだけ、02 は高さの選択(飛んでいる状態から開始)、03 は燃料ととまることだけで、郵便と期限はなし。04 と 05 が元のゲームの状況 A のふつうのルールです。A・B・C の3つの手作りの便と、いちばん上の風を変えた比較があります。
操作矢印キーで飛び方をすぐ決めます。1回操作したら、全部のキーを離すまで待ちます。R を押した瞬間を優先します。上下で高さの案を選び、Z で決めます。左右で、予報のどの列を読むかを選びます。表紙・一時停止と、入力の専用の制御があります。
表示と記録文字(ASCII)で、4列すべての風と、操作したあとの予告を表示します。05 に Undo があります。ファイルの保存はありません。日本語の文字、3手先までの表示、再現の記録と、それを正確に再生して確かめる機能があります。

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

  1. model.py: step の高度→風→配達→係留→補給→期限
  2. model.py: initial / actions の係留状態
  3. model.py: replay_data の無効操作とUndoの記録
  4. experiments.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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。

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