パズルLESSON 88道の整備と搬送期限を扱う経路パズル原作059

雪道の救助隊の作り方

帰りにも使える道を、先に整えておきます。
距離・雪の量・担架の重さから、移動にかかる時間(移動費)を計算します。到着の期限、整備した道、1人ずつの搬送(運ぶこと)、状態全体の Undo(元に戻す)を作ります。

完成版の遊び方急いで人に連絡しに行くか、時間をかけて道を整備するかを選びます。整備した道は、担架で帰るときや次の往復で安く通れます。その準備を残しながら進みます。

雪道の救助隊の元の完成版。自動操作によるPyxelの実描画画面。
元の完成版の地図Cを、自動で動かして描いた画面です。下の01は費用の実験、02と03は地図A、04と05は地図A・B・Cを使います。
PLAY / 試験公開

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

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

完成版の主な操作:矢印キーで行動を選ぶ、Spaceで歩く・整備するを切り替える、Zで決める、Xで戻す、Rでやり直し、Pで説明、1〜3で状況(地図)を選ぶ

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

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

うまく表示されないとき

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

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

移動にかかる時間の計算と、到着したあとの期限の判定を分ける。

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

1. 距離、担架を運んでいるか、整備の印(フラグ)で費用を分ける条件式
2. 地図の道(グラフの辺)、到着した時刻、条件のちょうど境目
3. ビット集合(1つの整数に印をまとめる方法)、一度だけ払う費用、ずっと残る道
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
変数・if・for・関数 / tuple(タプル)と添字・dataclass・replace / ビット集合は本文で説明します
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
矢印:01は距離とモード、02以降は行き先を選ぶ / Space:01は雪の量、03以降は歩く・整備するの切り替え / Tab:01だけ、整備済みの道の式と比べる / Z・Enter:02以降、選んだ1つの判断を決める / 1・2・3:04以降、地図A・B・Cの最初の状態にする / X:05だけ、ゲームの状態(モデル)を1つ戻す / R:同じ地図を最初の状態に戻す(プログラムは読み直さない) / Esc:終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_costs.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

同じ道を、違う費用で歩く

距離、担架を運んでいるか、整備の印(フラグ)で費用を分ける条件式。

最初は地図を使わず、1本の辺(2つの地点を結ぶ道)の費用だけを確かめます。距離 d と雪 snow は整数です。まだ整備していない道では、何も持たずに歩くと d×snow です。整備しながら進むと d×(snow+1) です。担架を運ぶと d×(snow+2) です。同じ距離でも、今の状態によって費用が変わります。画面では、上下キーで距離1〜4、左右キーで WALK(歩く)・PREPARE(整備する)・CARRY(運ぶ)、Space で雪1と2、Tab で整備済みかどうかを切り替えます。

cost は、状態 s、地図 l、辺の番号 i、整備するかどうか prep を受け取り、数値を返すだけです。時刻や居場所は変えません。s.prepared >> i & 1 が1なら、その辺は前に整備した道です。ふつうのルールでは、費用は距離だけになります。担架で人を運んでいるかどうかは、carrier>=0 で調べます。carrier=-1 は、だれも運んでいない状態です。返す数値には「刻」という時間の単位を付けて考えます。画面に描く線の長さや、30fps(1秒に30回)の描画の回数とは混ぜないようにします。

この段階の Tab は、式を比べるための実験です。実際のゲームで、道をただで整備するボタンではありません。あとの03では、整備の費用を払って移動を終えたときに、整備の印(フラグ)が残ります。また、「整備しながら担架を運ぶ」ことは、実際のゲームでは受け付けません。cost 関数は費用の計算だけをします。その操作をしてよいかどうかは、move や walk で別に調べます。関数が数値を返せることと、その操作を実行してよいことは別です。

元のゲームの Level・State と cost を、最初の段階から同じ形で入れてあります。まだ使わない people や outcome は、02以降で意味を持たせるための欄です。全部を覚えなくてかまいません。まずは、cost に渡す prepared と carrier、辺の距離と雪に注目しましょう。これは作り話の経路ゲームの式です。本物の雪道でかかる時間や、救助の手順を表すものではありません。

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

01_costs.py / 注目する部分
def cost(s,l,i,prep=False,variant=False):
 _,_,d,snow=l.edges[i]
 if s.prepared>>i&1:return d*(snow+2) if s.carrier>=0 and variant else d
 return d*(snow+2 if s.carrier>=0 else snow+1 if prep else snow)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_costs.py
macOS / Linux
.venv/bin/python 01_costs.py
画面で見ること最初は距離2・雪2・WALK で、4刻です。右キーで PREPARE にすると6、さらに右で CARRY にすると8になります。Tab で整備済みにすると2刻です。時計や人の状態は、まだ進みません。
  • 今の状態によって、費用の計算を分ける
  • 費用の計算と、操作してよいかの判定を別にする
  • ゲームの時間「刻」と、画面の描画の回数(フレーム)を分けて考える
つまずきやすいところ「もう整備した道」と「これから整備する操作」を、同じ変数で表さないようにします。cost が値を返したからといって、「実行できる」と決めてはいけません。画面に描いた線の長さを、費用にしないようにします。
少し変えてみよう

START_DISTANCE = 2 を START_DISTANCE = 3 に、1か所だけ変えます。保存して Esc で終了し、同じ 01_costs.py をもう一度実行します。最初の歩きは6、右キーで整備は9、さらに右で担架は12、Tab で整備済みにすると3になることを確かめましょう。元に戻すときも、保存・終了・もう一度実行、の順にします。R は実行中の状態だけを戻し、コードは読み直しません。

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

"""059 stage 1: simplified teaching UI; see LESSON.md."""
STAGE = 1
from dataclasses import dataclass, asdict, replace
@dataclass(frozen=True)
class Level:
 seed:str
 title:str
 edges:tuple
 deadlines:tuple
 end:int

LEVELS=(
 Level('A-01-v1','先に着く一刻',(('H','J',2,1),('J','A',2,2),('J','B',3,1),('H','K',3,1),('K','B',2,1),('J','K',1,1)),(9,25),38),
 Level('B-01-v1','近い人を後で',(('H','J',1,1),('J','A',2,1),('A','K',1,1),('K','B',1,2),('K','H',2,2)),(14,16),38),
 Level('C-01-v1','二度使う幹線',(('H','J',3,2),('J','A',1,1),('J','B',1,2),('H','K',2,1),('K','A',2,2),('K','B',2,1)),(14,28),44))
@dataclass(frozen=True)
class State:
 node:str='H'
 t:int=0
 prepared:int=0
 people:tuple=(0,0) # 0 untouched,1 contacted,2 carried,3 delivered,4 missed
 carrier:int=-1
 tick:int=0
 outcome:str='running'

def options(s,l):return [(i,e[1] if e[0]==s.node else e[0]) for i,e in enumerate(l.edges) if s.node in e[:2]]

def cost(s,l,i,prep=False,variant=False):
 _,_,d,snow=l.edges[i]
 if s.prepared>>i&1:return d*(snow+2) if s.carrier>=0 and variant else d
 return d*(snow+2 if s.carrier>=0 else snow+1 if prep else snow)


# Teaching UI: ASCII, no audio, one complete key release between decisions.
START_DISTANCE = 2
START_SNOW = 2
START_LEVEL = 0
LAB_MODES = ('WALK', 'PREPARE', 'CARRY')
POS = {'H': (55, 198), 'J': (155, 95), 'A': (276, 65), 'K': (178, 263), 'B': (279, 226)}
PEOPLE = ('UNCONTACTED', 'CONTACTED', 'ON STRETCHER', 'DELIVERED', 'MISSED')

class App:
    def __init__(self):
        self.level_index = START_LEVEL
        self.previous = set()
        self.gate = False
        self.reset()
    def reset(self):
        self.timeline = Timeline() if STAGE >= 4 else None
        self.simple_state = State()
        self.selected = 0
        self.preparing = False
        self.reason = 'ready'
        self.distance = START_DISTANCE
        self.snow = START_SNOW
        self.mode = 0
        self.prepared_lab = False
    @property
    def state(self):
        return self.timeline.state if STAGE >= 4 else self.simple_state
    @property
    def level(self):
        return LEVELS[self.level_index]
    def choices(self):
        state = self.state
        rows = []
        if STAGE >= 4 and state.node in ('A', 'B') and state.people['AB'.index(state.node)] == 1 and state.carrier < 0:
            rows.append(('rescue', 'START RESCUE (+1 TICK)'))
        for edge, destination in options(state, self.level):
            action = f'move:{edge}:' + ('prep' if self.preparing else 'walk')
            rows.append((action, ('PREP ' if self.preparing else 'WALK ') + destination))
        if STAGE >= 4 and state.node == 'H':
            rows.append(('finish', 'FINISH AT HOME'))
        return rows
    def action(self, action):
        if STAGE >= 4:
            if action == 'undo' and STAGE < 5:
                return
            self.timeline, self.reason = step(self.timeline, self.level, action)
        elif action.startswith('move:'):
            _, edge, mode = action.split(':')
            self.simple_state, self.reason = walk(self.simple_state, self.level, int(edge), mode == 'prep')
        self.selected = 0
    def laboratory_cost(self):
        level = Level('LAB', 'Cost laboratory', (('H', 'A', self.distance, self.snow),), (9, 25), 38)
        state = State(prepared=int(self.prepared_lab), carrier=0 if self.mode == 2 else -1)
        return cost(state, level, 0, self.mode == 1)
    def control(self, down):
        down = set(down)
        fresh = down - self.previous
        self.previous = down
        if self.gate:
            if not down:
                self.gate = False
            return
        if not fresh:
            return
        if len(down) != 1:
            self.gate = True
            return
        key = next(iter(fresh))
        self.gate = True
        if key == 'R':
            self.reset()
            return
        if STAGE == 1:
            if key == 'UP': self.distance = min(4, self.distance+1)
            elif key == 'DOWN': self.distance = max(1, self.distance-1)
            elif key == 'RIGHT': self.mode = (self.mode+1) % 3
            elif key == 'LEFT': self.mode = (self.mode-1) % 3
            elif key == 'SPACE': self.snow = 3-self.snow
            elif key == 'TAB': self.prepared_lab = not self.prepared_lab
            return
        if STAGE >= 4 and key in ('1', '2', '3'):
            self.level_index = int(key)-1
            self.reset()
            return
        if STAGE >= 5 and key == 'X':
            self.action('undo')
            return
        if self.state.outcome != 'running':
            return
        if key in ('UP', 'LEFT'): self.selected = (self.selected-1) % len(self.choices())
        elif key in ('DOWN', 'RIGHT'): self.selected = (self.selected+1) % len(self.choices())
        elif key == 'SPACE' and STAGE >= 3: self.preparing = not self.preparing
        elif key in ('Z', 'RETURN'): self.action(self.choices()[self.selected][0])
    def snapshot(self):
        return {'stage': STAGE, 'level': self.level_index, 'state': asdict(self.state),
                'selected': self.selected, 'preparing': self.preparing, 'reason': self.reason,
                'lab': [self.distance, self.snow, self.mode, self.prepared_lab, self.laboratory_cost()]}
    def draw(self, p):
        p.cls(1)
        p.text(14, 10, f'059 SNOW ROUTES / STEP {STAGE}', 10)
        p.text(14, 26, 'A fictional route model, not real rescue instructions.', 13)
        if STAGE == 1:
            p.text(32, 65, 'EDGE COST LABORATORY', 11)
            p.text(32, 94, f'DISTANCE {self.distance}   SNOW {self.snow}', 7)
            p.text(32, 124, f'MODE {LAB_MODES[self.mode]}   PREPARED {self.prepared_lab}', 7)
            p.text(32, 168, f'TRAVEL COST: {self.laboratory_cost()} TICKS', 10)
            p.text(32, 214, 'Unprepared: walk d*s / prepare d*(s+1) / carry d*(s+2)', 6)
            p.text(32, 238, 'Prepared: all travel costs d. This is a formula experiment.', 6)
            p.text(32, 278, 'Preparation is a paid journey in the actual game.', 9)
            p.text(32, 360, 'UP/DOWN distance  LEFT/RIGHT mode  SPACE snow  TAB prepared', 7)
        else:
            s = self.state
            p.text(14, 44, f'MAP {self.level.seed}   CLOCK {s.t}/{self.level.end}   {s.outcome.upper()}', 11)
            for edge, (u, v, distance, snow) in enumerate(self.level.edges):
                x,y=POS[u];xx,yy=POS[v]
                color=11 if s.prepared >> edge & 1 else 5
                p.line(x,y,xx,yy,color)
                mx,my=(x+xx)//2,(y+yy)//2
                if self.level_index == 2 and edge in (2, 4):
                    mx,my=round(x+(xx-x)*0.72),round(y+(yy-y)*0.72)
                p.rect(mx-22,my-6,48,12,1)
                p.text(mx-20,my-4,f'{edge}: {distance}/{snow}',color)
            for name,(x,y) in POS.items():
                p.circ(x,y,12,3 if name == s.node else 2)
                p.circb(x,y,12,10 if name == s.node else 6)
                p.text(x-2,y-3,name,7)
            p.text(14, 299, 'Edge labels: index / distance / snow.', 13)
            p.text(14, 314, 'Line length on screen is NOT edge distance.', 6)
            for i in range(2):
                p.text(352, 75+i*28, f'{"AB"[i]}: {PEOPLE[s.people[i]]}', 7)
                p.text(352, 88+i*28, f'CONTACT BY {self.level.deadlines[i]}', 13)
            p.text(352, 135, 'MODE: PREPARE' if self.preparing else 'MODE: WALK', 11)
            rows = self.choices()
            for i,(action,label) in enumerate(rows):
                y=164+i*22
                if i == self.selected: p.rect(344,y-3,280,17,3)
                p.text(352,y,('> ' if i == self.selected else '  ')+label,7)
            if STAGE >= 5:
                forecast_state, reason = move(s, self.level, rows[self.selected][0])
                p.text(352, 310, f'NEXT CLOCK {forecast_state.t}   {reason.upper()}', 10)
                p.text(352, 326, f'NEXT PEOPLE {forecast_state.people}', 6)
                p.text(352, 342, f'HISTORY {len(self.timeline.history)}  PREVIEW ONLY', 13)
            p.text(14, 363, 'Reason: '+self.reason, 10)
            p.text(14, 385, 'Arrows select   Z/ENTER commit   R reset', 7)
            p.text(14, 401, ('SPACE walk/prep   ' if STAGE >= 3 else 'Walking/contact only. No completed rescue yet. ')+('1/2/3 maps' if STAGE >= 4 else ''), 6)
            if STAGE >= 5: p.text(14, 417, 'X undo all model state. Preview does not advance time.', 13)
        p.text(14, 440, 'Release all keys between decisions. ESC exits. Save / quit / rerun edits.', 6)


def demo_keys():
    if STAGE == 1:
        return ['RIGHT', 'RIGHT', 'TAB']
    if STAGE == 2:
        return ['Z', 'DOWN', 'Z']
    if STAGE == 3:
        return ['SPACE', 'Z', 'SPACE', 'DOWN', 'Z']
    return ['SPACE', 'Z', 'SPACE', 'DOWN', 'Z', 'Z', 'Z', 'Z']


def main():
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description='Standalone teaching stage; no files are overwritten.')
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--output', type=Path)
    parser.add_argument('--state-json', type=Path)
    args = parser.parse_args()
    if (args.demo_checkpoint or args.output is not None or args.state_json is not None) and not args.headless_smoke:
        parser.error('Use --headless-smoke with demo/output options.')
    for name,suffix in [('output','.png'),('state_json','.json')]:
        path=getattr(args,name)
        if path is None: continue
        path=path.absolute()
        if path.suffix.lower()!=suffix or path.exists() or path.is_symlink() or not path.parent.is_dir():
            parser.error(f'{name} needs a new {suffix} file under an existing directory.')
        setattr(args,name,path)
    import pyxel
    app=App()
    pyxel.init(640,456,title=f'059 Snow Routes - {STAGE}',fps=30,display_scale=1,headless=args.headless_smoke,capture_sec=0)
    keys=('UP','DOWN','LEFT','RIGHT','SPACE','TAB','Z','X','R','RETURN','1','2','3')
    frames=0
    demo=demo_keys() if args.demo_checkpoint else []
    stop_frame=2*len(demo)+3
    def update():
        nonlocal frames
        if args.headless_smoke:
            current=frames//2
            down={demo[current]} if frames%2==0 and current<len(demo) else set()
        else: down={k for k in keys if pyxel.btn(getattr(pyxel,'KEY_'+k))}
        app.control(down)
        frames+=1
    def draw():
        app.draw(pyxel)
        if args.headless_smoke and frames>=stop_frame:
            if args.output is not None: pyxel.screenshot(str(args.output),scale=1)
            if args.state_json is not None:
                import json
                with args.state_json.open('x',encoding='utf-8') as f: json.dump(app.snapshot(),f,ensure_ascii=False,indent=2)
            pyxel.quit()
    pyxel.run(update,draw)

if __name__ == '__main__':
    main()
STEP 02 / 05

到着してから、連絡期限を判定する

地図の道(グラフの辺)、到着した時刻、条件のちょうど境目。

地図を加えて、Aまで歩いて連絡するところまで作ります。LEVELS[0] の edges は、「出発点・到着点・距離・雪」を並べた tuple です。options は、今いる場所がどちらかのはしにある辺だけを返します。その辺の反対側のはしが、移動先になります。画面の道の形は説明のためのものです。ななめの線の長さを、距離として測ることはしません。矢印キーで右側の行き先を選び、Z か Enter で決めます。

walk は、まず次の操作を受け付けません。もう終わったゲームでの操作、今いる場所とつながっていない辺、この段階にはない整備の操作です。受け付けられる操作なら、今の時刻と cost を足して、到着時刻 arrival を計算します。そのあとで、着いた場所の人に連絡します。期限ちょうどに着いたときは間に合ったことにするので、<= を使います。まだ連絡していないまま期限より後に着いたら、MISSED(間に合わなかった)です。もう連絡した人は、期限を過ぎても「まだ連絡していない」には戻しません。

people の値は、0が「まだ連絡していない」、1が「連絡した」、4が「期限に間に合わなかった」です。この段階には、担架に乗せる操作がありません。なので、1は救助が終わったという意味ではありません。Aに着いて CONTACTED(連絡した)になっても、H(基地)まで人を運んだことにはしません。あとの04で、2「担架に乗っている」と3「Hまで運び終えた」を使います。State は replace で作り直し、受け付けない操作では元の State をそのまま返します。だから、行き先を選んだだけで時計が進むこともありません。

地図Aでは、H→Jが2刻、J→Aが4刻なので、合計6刻でAに着きます。連絡の期限9より早いので、Aは CONTACTED です。別の道へ遠回りすると、同じAでも、着いた時刻によって結果が変わります。勤務の終わりを表す end も、別の境目です。end より後に着いたとき、または end ちょうどにH以外の場所へ着いたときは、shift_end(勤務終了)になります。この模型(ゲームを簡単にした作り)では、移動の途中を1フレームずつ足していく計算はしません。1回の移動を、まとめて確定します。

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

02_contact.py / 注目する部分
def walk(state, level, edge, prepare=False):
    if state.outcome != 'running':
        return state, 'ended'
    available = dict(options(state, level))
    if edge not in available or (prepare and STAGE < 3):
        return state, 'invalid'
    if prepare and state.prepared >> edge & 1:
        return state, 'already_prepared'
    arrival = state.t + cost(state, level, edge, prepare)
    destination = available[edge]
    prepared = state.prepared | (1 << edge) if prepare else state.prepared
    people = list(state.people)
    if destination in ('A', 'B'):
        person = 'AB'.index(destination)
        if people[person] == 0 and arrival <= level.deadlines[person]:
            people[person] = 1
    for person in range(2):
        if people[person] == 0 and arrival > level.deadlines[person]:
            people[person] = 4
    outcome = 'shift_end' if arrival > level.end or (arrival == level.end and destination != 'H') else 'running'
    return replace(state, node=destination, t=arrival, prepared=prepared,
                   people=tuple(people), tick=state.tick+1, outcome=outcome), 'move'

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_contact.py
macOS / Linux
.venv/bin/python 02_contact.py
画面で見ることZ でH→J、下→Z でJ→Aに進みます。CLOCK が6、Aは CONTACTED、Bは UNCONTACTED(まだ連絡していない)です。戻る行き先を選ぶだけなら、値は変わりません。Space を押しても、整備には切り替わりません。
  • 今いる場所とつながっている辺だけを選べる
  • 着いたあとの時刻で、連絡できたかを決める
  • <= と > の境目を実験する
つまずきやすいところ期限の判定を、移動する前の時刻で行わないようにします。「連絡した」人を「運び終えた」人として数えないようにします。到着時刻が期限とちょうど同じ(==)ときを、失敗にしないようにします。
少し変えてみよう

LEVELS のAにある (9,25),38) を (5,25),38) に1か所だけ変えて、Aの連絡の期限だけを5にします。保存して終了し、02_contact.py をもう一度実行して、Z→下→Z と押します。同じ6刻で着いても、Aは MISSED になります。9に戻すと、CONTACTED に戻ります。ゲーム中に R を押すだけでは、変えた期限は読み直されません。

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

"""059 stage 2: simplified teaching UI; see LESSON.md."""
STAGE = 2
from dataclasses import dataclass, asdict, replace
@dataclass(frozen=True)
class Level:
 seed:str
 title:str
 edges:tuple
 deadlines:tuple
 end:int

LEVELS=(
 Level('A-01-v1','先に着く一刻',(('H','J',2,1),('J','A',2,2),('J','B',3,1),('H','K',3,1),('K','B',2,1),('J','K',1,1)),(9,25),38),
 Level('B-01-v1','近い人を後で',(('H','J',1,1),('J','A',2,1),('A','K',1,1),('K','B',1,2),('K','H',2,2)),(14,16),38),
 Level('C-01-v1','二度使う幹線',(('H','J',3,2),('J','A',1,1),('J','B',1,2),('H','K',2,1),('K','A',2,2),('K','B',2,1)),(14,28),44))
@dataclass(frozen=True)
class State:
 node:str='H'
 t:int=0
 prepared:int=0
 people:tuple=(0,0) # 0 untouched,1 contacted,2 carried,3 delivered,4 missed
 carrier:int=-1
 tick:int=0
 outcome:str='running'

def options(s,l):return [(i,e[1] if e[0]==s.node else e[0]) for i,e in enumerate(l.edges) if s.node in e[:2]]

def cost(s,l,i,prep=False,variant=False):
 _,_,d,snow=l.edges[i]
 if s.prepared>>i&1:return d*(snow+2) if s.carrier>=0 and variant else d
 return d*(snow+2 if s.carrier>=0 else snow+1 if prep else snow)


def walk(state, level, edge, prepare=False):
    if state.outcome != 'running':
        return state, 'ended'
    available = dict(options(state, level))
    if edge not in available or (prepare and STAGE < 3):
        return state, 'invalid'
    if prepare and state.prepared >> edge & 1:
        return state, 'already_prepared'
    arrival = state.t + cost(state, level, edge, prepare)
    destination = available[edge]
    prepared = state.prepared | (1 << edge) if prepare else state.prepared
    people = list(state.people)
    if destination in ('A', 'B'):
        person = 'AB'.index(destination)
        if people[person] == 0 and arrival <= level.deadlines[person]:
            people[person] = 1
    for person in range(2):
        if people[person] == 0 and arrival > level.deadlines[person]:
            people[person] = 4
    outcome = 'shift_end' if arrival > level.end or (arrival == level.end and destination != 'H') else 'running'
    return replace(state, node=destination, t=arrival, prepared=prepared,
                   people=tuple(people), tick=state.tick+1, outcome=outcome), 'move'

# Teaching UI: ASCII, no audio, one complete key release between decisions.
START_DISTANCE = 2
START_SNOW = 2
START_LEVEL = 0
LAB_MODES = ('WALK', 'PREPARE', 'CARRY')
POS = {'H': (55, 198), 'J': (155, 95), 'A': (276, 65), 'K': (178, 263), 'B': (279, 226)}
PEOPLE = ('UNCONTACTED', 'CONTACTED', 'ON STRETCHER', 'DELIVERED', 'MISSED')

class App:
    def __init__(self):
        self.level_index = START_LEVEL
        self.previous = set()
        self.gate = False
        self.reset()
    def reset(self):
        self.timeline = Timeline() if STAGE >= 4 else None
        self.simple_state = State()
        self.selected = 0
        self.preparing = False
        self.reason = 'ready'
        self.distance = START_DISTANCE
        self.snow = START_SNOW
        self.mode = 0
        self.prepared_lab = False
    @property
    def state(self):
        return self.timeline.state if STAGE >= 4 else self.simple_state
    @property
    def level(self):
        return LEVELS[self.level_index]
    def choices(self):
        state = self.state
        rows = []
        if STAGE >= 4 and state.node in ('A', 'B') and state.people['AB'.index(state.node)] == 1 and state.carrier < 0:
            rows.append(('rescue', 'START RESCUE (+1 TICK)'))
        for edge, destination in options(state, self.level):
            action = f'move:{edge}:' + ('prep' if self.preparing else 'walk')
            rows.append((action, ('PREP ' if self.preparing else 'WALK ') + destination))
        if STAGE >= 4 and state.node == 'H':
            rows.append(('finish', 'FINISH AT HOME'))
        return rows
    def action(self, action):
        if STAGE >= 4:
            if action == 'undo' and STAGE < 5:
                return
            self.timeline, self.reason = step(self.timeline, self.level, action)
        elif action.startswith('move:'):
            _, edge, mode = action.split(':')
            self.simple_state, self.reason = walk(self.simple_state, self.level, int(edge), mode == 'prep')
        self.selected = 0
    def laboratory_cost(self):
        level = Level('LAB', 'Cost laboratory', (('H', 'A', self.distance, self.snow),), (9, 25), 38)
        state = State(prepared=int(self.prepared_lab), carrier=0 if self.mode == 2 else -1)
        return cost(state, level, 0, self.mode == 1)
    def control(self, down):
        down = set(down)
        fresh = down - self.previous
        self.previous = down
        if self.gate:
            if not down:
                self.gate = False
            return
        if not fresh:
            return
        if len(down) != 1:
            self.gate = True
            return
        key = next(iter(fresh))
        self.gate = True
        if key == 'R':
            self.reset()
            return
        if STAGE == 1:
            if key == 'UP': self.distance = min(4, self.distance+1)
            elif key == 'DOWN': self.distance = max(1, self.distance-1)
            elif key == 'RIGHT': self.mode = (self.mode+1) % 3
            elif key == 'LEFT': self.mode = (self.mode-1) % 3
            elif key == 'SPACE': self.snow = 3-self.snow
            elif key == 'TAB': self.prepared_lab = not self.prepared_lab
            return
        if STAGE >= 4 and key in ('1', '2', '3'):
            self.level_index = int(key)-1
            self.reset()
            return
        if STAGE >= 5 and key == 'X':
            self.action('undo')
            return
        if self.state.outcome != 'running':
            return
        if key in ('UP', 'LEFT'): self.selected = (self.selected-1) % len(self.choices())
        elif key in ('DOWN', 'RIGHT'): self.selected = (self.selected+1) % len(self.choices())
        elif key == 'SPACE' and STAGE >= 3: self.preparing = not self.preparing
        elif key in ('Z', 'RETURN'): self.action(self.choices()[self.selected][0])
    def snapshot(self):
        return {'stage': STAGE, 'level': self.level_index, 'state': asdict(self.state),
                'selected': self.selected, 'preparing': self.preparing, 'reason': self.reason,
                'lab': [self.distance, self.snow, self.mode, self.prepared_lab, self.laboratory_cost()]}
    def draw(self, p):
        p.cls(1)
        p.text(14, 10, f'059 SNOW ROUTES / STEP {STAGE}', 10)
        p.text(14, 26, 'A fictional route model, not real rescue instructions.', 13)
        if STAGE == 1:
            p.text(32, 65, 'EDGE COST LABORATORY', 11)
            p.text(32, 94, f'DISTANCE {self.distance}   SNOW {self.snow}', 7)
            p.text(32, 124, f'MODE {LAB_MODES[self.mode]}   PREPARED {self.prepared_lab}', 7)
            p.text(32, 168, f'TRAVEL COST: {self.laboratory_cost()} TICKS', 10)
            p.text(32, 214, 'Unprepared: walk d*s / prepare d*(s+1) / carry d*(s+2)', 6)
            p.text(32, 238, 'Prepared: all travel costs d. This is a formula experiment.', 6)
            p.text(32, 278, 'Preparation is a paid journey in the actual game.', 9)
            p.text(32, 360, 'UP/DOWN distance  LEFT/RIGHT mode  SPACE snow  TAB prepared', 7)
        else:
            s = self.state
            p.text(14, 44, f'MAP {self.level.seed}   CLOCK {s.t}/{self.level.end}   {s.outcome.upper()}', 11)
            for edge, (u, v, distance, snow) in enumerate(self.level.edges):
                x,y=POS[u];xx,yy=POS[v]
                color=11 if s.prepared >> edge & 1 else 5
                p.line(x,y,xx,yy,color)
                mx,my=(x+xx)//2,(y+yy)//2
                if self.level_index == 2 and edge in (2, 4):
                    mx,my=round(x+(xx-x)*0.72),round(y+(yy-y)*0.72)
                p.rect(mx-22,my-6,48,12,1)
                p.text(mx-20,my-4,f'{edge}: {distance}/{snow}',color)
            for name,(x,y) in POS.items():
                p.circ(x,y,12,3 if name == s.node else 2)
                p.circb(x,y,12,10 if name == s.node else 6)
                p.text(x-2,y-3,name,7)
            p.text(14, 299, 'Edge labels: index / distance / snow.', 13)
            p.text(14, 314, 'Line length on screen is NOT edge distance.', 6)
            for i in range(2):
                p.text(352, 75+i*28, f'{"AB"[i]}: {PEOPLE[s.people[i]]}', 7)
                p.text(352, 88+i*28, f'CONTACT BY {self.level.deadlines[i]}', 13)
            p.text(352, 135, 'MODE: PREPARE' if self.preparing else 'MODE: WALK', 11)
            rows = self.choices()
            for i,(action,label) in enumerate(rows):
                y=164+i*22
                if i == self.selected: p.rect(344,y-3,280,17,3)
                p.text(352,y,('> ' if i == self.selected else '  ')+label,7)
            if STAGE >= 5:
                forecast_state, reason = move(s, self.level, rows[self.selected][0])
                p.text(352, 310, f'NEXT CLOCK {forecast_state.t}   {reason.upper()}', 10)
                p.text(352, 326, f'NEXT PEOPLE {forecast_state.people}', 6)
                p.text(352, 342, f'HISTORY {len(self.timeline.history)}  PREVIEW ONLY', 13)
            p.text(14, 363, 'Reason: '+self.reason, 10)
            p.text(14, 385, 'Arrows select   Z/ENTER commit   R reset', 7)
            p.text(14, 401, ('SPACE walk/prep   ' if STAGE >= 3 else 'Walking/contact only. No completed rescue yet. ')+('1/2/3 maps' if STAGE >= 4 else ''), 6)
            if STAGE >= 5: p.text(14, 417, 'X undo all model state. Preview does not advance time.', 13)
        p.text(14, 440, 'Release all keys between decisions. ESC exits. Save / quit / rerun edits.', 6)


def demo_keys():
    if STAGE == 1:
        return ['RIGHT', 'RIGHT', 'TAB']
    if STAGE == 2:
        return ['Z', 'DOWN', 'Z']
    if STAGE == 3:
        return ['SPACE', 'Z', 'SPACE', 'DOWN', 'Z']
    return ['SPACE', 'Z', 'SPACE', 'DOWN', 'Z', 'Z', 'Z', 'Z']


def main():
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description='Standalone teaching stage; no files are overwritten.')
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--output', type=Path)
    parser.add_argument('--state-json', type=Path)
    args = parser.parse_args()
    if (args.demo_checkpoint or args.output is not None or args.state_json is not None) and not args.headless_smoke:
        parser.error('Use --headless-smoke with demo/output options.')
    for name,suffix in [('output','.png'),('state_json','.json')]:
        path=getattr(args,name)
        if path is None: continue
        path=path.absolute()
        if path.suffix.lower()!=suffix or path.exists() or path.is_symlink() or not path.parent.is_dir():
            parser.error(f'{name} needs a new {suffix} file under an existing directory.')
        setattr(args,name,path)
    import pyxel
    app=App()
    pyxel.init(640,456,title=f'059 Snow Routes - {STAGE}',fps=30,display_scale=1,headless=args.headless_smoke,capture_sec=0)
    keys=('UP','DOWN','LEFT','RIGHT','SPACE','TAB','Z','X','R','RETURN','1','2','3')
    frames=0
    demo=demo_keys() if args.demo_checkpoint else []
    stop_frame=2*len(demo)+3
    def update():
        nonlocal frames
        if args.headless_smoke:
            current=frames//2
            down={demo[current]} if frames%2==0 and current<len(demo) else set()
        else: down={k for k in keys if pyxel.btn(getattr(pyxel,'KEY_'+k))}
        app.control(down)
        frames+=1
    def draw():
        app.draw(pyxel)
        if args.headless_smoke and frames>=stop_frame:
            if args.output is not None: pyxel.screenshot(str(args.output),scale=1)
            if args.state_json is not None:
                import json
                with args.state_json.open('x',encoding='utf-8') as f: json.dump(app.snapshot(),f,ensure_ascii=False,indent=2)
            pyxel.quit()
    pyxel.run(update,draw)

if __name__ == '__main__':
    main()
STEP 03 / 05

一度の整備を、次の往復にも残す

ビット集合、一度だけ払う費用、ずっと残る道。

Space で WALK と PREPARE を切り替えられるようにします。整備は、その場で印を付けるだけの、ただの操作ではありません。選んだ道を整備しながら、反対側のはしまで進む判断です。まだ整備していない道を何も持たずに歩くより、距離の分だけ多く時間がかかります。着いたあとで、state.prepared に 1<<edge を OR(ビットごとの「または」)で加えます。こうして、次に同じ道を使うときも印が残ります。

prepared は、辺ごとの bool(真か偽か)の一覧を、1つの整数にまとめたものです。0番の道だけなら1、1番の道だけなら2、両方なら3です。prepared >> edge & 1 で、1つの辺が入っているかを読みます。図で緑になった辺は、整備済みです。道はどちら向きにも通れます。なので、H→Jで整備した0番の道は、J→Hでも同じ印を見ます。

同じ道をもう一度 PREPARE しても、already_prepared(もう整備した)となり、何も変わりません。画面のモードを切り替えただけでも、時計は進みません。整備の費用、移動したあとの連絡、期限切れ、状態の確定は、walk の1回の操作にまとめてあります。図の色だけを先に変えると、取り消した計画や受け付けない入力でも、道ができあがってしまいます。なので、描画は確定した State から読むだけにします。

地図Aでは、H→Jの整備に4刻かかります。そのあと WALK に切り替えて、J→Aに4刻かかります。合計8刻なら、期限9に間に合います。一方、H→JとJ→Aを両方整備すると、4+6=10刻かかり、Aに間に合いません。あとの帰り道を安くすることと、今の期限を守ること。その両方を考える、小さな判断です。まだ担架はありません。先に行って帰ってくるだけでも、緑の辺の費用が距離だけに変わることを確かめられます。

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

03_prepare.py / 注目する部分
def walk(state, level, edge, prepare=False):
    if state.outcome != 'running':
        return state, 'ended'
    available = dict(options(state, level))
    if edge not in available or (prepare and STAGE < 3):
        return state, 'invalid'
    if prepare and state.prepared >> edge & 1:
        return state, 'already_prepared'
    arrival = state.t + cost(state, level, edge, prepare)
    destination = available[edge]
    prepared = state.prepared | (1 << edge) if prepare else state.prepared
    people = list(state.people)
    if destination in ('A', 'B'):
        person = 'AB'.index(destination)
        if people[person] == 0 and arrival <= level.deadlines[person]:
            people[person] = 1
    for person in range(2):
        if people[person] == 0 and arrival > level.deadlines[person]:
            people[person] = 4
    outcome = 'shift_end' if arrival > level.end or (arrival == level.end and destination != 'H') else 'running'
    return replace(state, node=destination, t=arrival, prepared=prepared,
                   people=tuple(people), tick=state.tick+1, outcome=outcome), 'move'

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_prepare.py
macOS / Linux
.venv/bin/python 03_prepare.py
画面で見ることSpace→Z でH→Jを整備すると、CLOCK が4になり、辺0が緑になります。もう一度 Z で同じ道の整備を選ぶと already_prepared になり、4刻のままです。Space で歩きに戻して Z を押すと、6刻でHに戻ります。R→Space→Z→Space→下→Z と押すと、8刻でAに着いて連絡できます。
  • 辺の番号のビットを、OR で残す
  • 同じ道を、行きと帰りの両方の向きで使う
  • あとで時間を節約することと、今の期限を比べる
つまずきやすいところ移動するたびに、prepared を0に戻さないようにします。「整備するモード」と「もう整備した道」を混ぜないようにします。同じ道をもう一度整備して、時間を2回払わせないようにします。
少し変えてみよう

cost の中の snow+1 if prep を、snow if prep に1か所だけ変えます。整備の追加の費用をなくした、比べるための版です。保存して終了し、03_prepare.py をもう一度実行します。Space→Z でH→Jを整備すると、4刻ではなく2刻になり、道の緑は残ります。これは、元のゲームとはちがうルールです。確かめたら +1 を元に戻し、保存して起動し直しましょう。

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

"""059 stage 3: simplified teaching UI; see LESSON.md."""
STAGE = 3
from dataclasses import dataclass, asdict, replace
@dataclass(frozen=True)
class Level:
 seed:str
 title:str
 edges:tuple
 deadlines:tuple
 end:int

LEVELS=(
 Level('A-01-v1','先に着く一刻',(('H','J',2,1),('J','A',2,2),('J','B',3,1),('H','K',3,1),('K','B',2,1),('J','K',1,1)),(9,25),38),
 Level('B-01-v1','近い人を後で',(('H','J',1,1),('J','A',2,1),('A','K',1,1),('K','B',1,2),('K','H',2,2)),(14,16),38),
 Level('C-01-v1','二度使う幹線',(('H','J',3,2),('J','A',1,1),('J','B',1,2),('H','K',2,1),('K','A',2,2),('K','B',2,1)),(14,28),44))
@dataclass(frozen=True)
class State:
 node:str='H'
 t:int=0
 prepared:int=0
 people:tuple=(0,0) # 0 untouched,1 contacted,2 carried,3 delivered,4 missed
 carrier:int=-1
 tick:int=0
 outcome:str='running'

def options(s,l):return [(i,e[1] if e[0]==s.node else e[0]) for i,e in enumerate(l.edges) if s.node in e[:2]]

def cost(s,l,i,prep=False,variant=False):
 _,_,d,snow=l.edges[i]
 if s.prepared>>i&1:return d*(snow+2) if s.carrier>=0 and variant else d
 return d*(snow+2 if s.carrier>=0 else snow+1 if prep else snow)


def walk(state, level, edge, prepare=False):
    if state.outcome != 'running':
        return state, 'ended'
    available = dict(options(state, level))
    if edge not in available or (prepare and STAGE < 3):
        return state, 'invalid'
    if prepare and state.prepared >> edge & 1:
        return state, 'already_prepared'
    arrival = state.t + cost(state, level, edge, prepare)
    destination = available[edge]
    prepared = state.prepared | (1 << edge) if prepare else state.prepared
    people = list(state.people)
    if destination in ('A', 'B'):
        person = 'AB'.index(destination)
        if people[person] == 0 and arrival <= level.deadlines[person]:
            people[person] = 1
    for person in range(2):
        if people[person] == 0 and arrival > level.deadlines[person]:
            people[person] = 4
    outcome = 'shift_end' if arrival > level.end or (arrival == level.end and destination != 'H') else 'running'
    return replace(state, node=destination, t=arrival, prepared=prepared,
                   people=tuple(people), tick=state.tick+1, outcome=outcome), 'move'

# Teaching UI: ASCII, no audio, one complete key release between decisions.
START_DISTANCE = 2
START_SNOW = 2
START_LEVEL = 0
LAB_MODES = ('WALK', 'PREPARE', 'CARRY')
POS = {'H': (55, 198), 'J': (155, 95), 'A': (276, 65), 'K': (178, 263), 'B': (279, 226)}
PEOPLE = ('UNCONTACTED', 'CONTACTED', 'ON STRETCHER', 'DELIVERED', 'MISSED')

class App:
    def __init__(self):
        self.level_index = START_LEVEL
        self.previous = set()
        self.gate = False
        self.reset()
    def reset(self):
        self.timeline = Timeline() if STAGE >= 4 else None
        self.simple_state = State()
        self.selected = 0
        self.preparing = False
        self.reason = 'ready'
        self.distance = START_DISTANCE
        self.snow = START_SNOW
        self.mode = 0
        self.prepared_lab = False
    @property
    def state(self):
        return self.timeline.state if STAGE >= 4 else self.simple_state
    @property
    def level(self):
        return LEVELS[self.level_index]
    def choices(self):
        state = self.state
        rows = []
        if STAGE >= 4 and state.node in ('A', 'B') and state.people['AB'.index(state.node)] == 1 and state.carrier < 0:
            rows.append(('rescue', 'START RESCUE (+1 TICK)'))
        for edge, destination in options(state, self.level):
            action = f'move:{edge}:' + ('prep' if self.preparing else 'walk')
            rows.append((action, ('PREP ' if self.preparing else 'WALK ') + destination))
        if STAGE >= 4 and state.node == 'H':
            rows.append(('finish', 'FINISH AT HOME'))
        return rows
    def action(self, action):
        if STAGE >= 4:
            if action == 'undo' and STAGE < 5:
                return
            self.timeline, self.reason = step(self.timeline, self.level, action)
        elif action.startswith('move:'):
            _, edge, mode = action.split(':')
            self.simple_state, self.reason = walk(self.simple_state, self.level, int(edge), mode == 'prep')
        self.selected = 0
    def laboratory_cost(self):
        level = Level('LAB', 'Cost laboratory', (('H', 'A', self.distance, self.snow),), (9, 25), 38)
        state = State(prepared=int(self.prepared_lab), carrier=0 if self.mode == 2 else -1)
        return cost(state, level, 0, self.mode == 1)
    def control(self, down):
        down = set(down)
        fresh = down - self.previous
        self.previous = down
        if self.gate:
            if not down:
                self.gate = False
            return
        if not fresh:
            return
        if len(down) != 1:
            self.gate = True
            return
        key = next(iter(fresh))
        self.gate = True
        if key == 'R':
            self.reset()
            return
        if STAGE == 1:
            if key == 'UP': self.distance = min(4, self.distance+1)
            elif key == 'DOWN': self.distance = max(1, self.distance-1)
            elif key == 'RIGHT': self.mode = (self.mode+1) % 3
            elif key == 'LEFT': self.mode = (self.mode-1) % 3
            elif key == 'SPACE': self.snow = 3-self.snow
            elif key == 'TAB': self.prepared_lab = not self.prepared_lab
            return
        if STAGE >= 4 and key in ('1', '2', '3'):
            self.level_index = int(key)-1
            self.reset()
            return
        if STAGE >= 5 and key == 'X':
            self.action('undo')
            return
        if self.state.outcome != 'running':
            return
        if key in ('UP', 'LEFT'): self.selected = (self.selected-1) % len(self.choices())
        elif key in ('DOWN', 'RIGHT'): self.selected = (self.selected+1) % len(self.choices())
        elif key == 'SPACE' and STAGE >= 3: self.preparing = not self.preparing
        elif key in ('Z', 'RETURN'): self.action(self.choices()[self.selected][0])
    def snapshot(self):
        return {'stage': STAGE, 'level': self.level_index, 'state': asdict(self.state),
                'selected': self.selected, 'preparing': self.preparing, 'reason': self.reason,
                'lab': [self.distance, self.snow, self.mode, self.prepared_lab, self.laboratory_cost()]}
    def draw(self, p):
        p.cls(1)
        p.text(14, 10, f'059 SNOW ROUTES / STEP {STAGE}', 10)
        p.text(14, 26, 'A fictional route model, not real rescue instructions.', 13)
        if STAGE == 1:
            p.text(32, 65, 'EDGE COST LABORATORY', 11)
            p.text(32, 94, f'DISTANCE {self.distance}   SNOW {self.snow}', 7)
            p.text(32, 124, f'MODE {LAB_MODES[self.mode]}   PREPARED {self.prepared_lab}', 7)
            p.text(32, 168, f'TRAVEL COST: {self.laboratory_cost()} TICKS', 10)
            p.text(32, 214, 'Unprepared: walk d*s / prepare d*(s+1) / carry d*(s+2)', 6)
            p.text(32, 238, 'Prepared: all travel costs d. This is a formula experiment.', 6)
            p.text(32, 278, 'Preparation is a paid journey in the actual game.', 9)
            p.text(32, 360, 'UP/DOWN distance  LEFT/RIGHT mode  SPACE snow  TAB prepared', 7)
        else:
            s = self.state
            p.text(14, 44, f'MAP {self.level.seed}   CLOCK {s.t}/{self.level.end}   {s.outcome.upper()}', 11)
            for edge, (u, v, distance, snow) in enumerate(self.level.edges):
                x,y=POS[u];xx,yy=POS[v]
                color=11 if s.prepared >> edge & 1 else 5
                p.line(x,y,xx,yy,color)
                mx,my=(x+xx)//2,(y+yy)//2
                if self.level_index == 2 and edge in (2, 4):
                    mx,my=round(x+(xx-x)*0.72),round(y+(yy-y)*0.72)
                p.rect(mx-22,my-6,48,12,1)
                p.text(mx-20,my-4,f'{edge}: {distance}/{snow}',color)
            for name,(x,y) in POS.items():
                p.circ(x,y,12,3 if name == s.node else 2)
                p.circb(x,y,12,10 if name == s.node else 6)
                p.text(x-2,y-3,name,7)
            p.text(14, 299, 'Edge labels: index / distance / snow.', 13)
            p.text(14, 314, 'Line length on screen is NOT edge distance.', 6)
            for i in range(2):
                p.text(352, 75+i*28, f'{"AB"[i]}: {PEOPLE[s.people[i]]}', 7)
                p.text(352, 88+i*28, f'CONTACT BY {self.level.deadlines[i]}', 13)
            p.text(352, 135, 'MODE: PREPARE' if self.preparing else 'MODE: WALK', 11)
            rows = self.choices()
            for i,(action,label) in enumerate(rows):
                y=164+i*22
                if i == self.selected: p.rect(344,y-3,280,17,3)
                p.text(352,y,('> ' if i == self.selected else '  ')+label,7)
            if STAGE >= 5:
                forecast_state, reason = move(s, self.level, rows[self.selected][0])
                p.text(352, 310, f'NEXT CLOCK {forecast_state.t}   {reason.upper()}', 10)
                p.text(352, 326, f'NEXT PEOPLE {forecast_state.people}', 6)
                p.text(352, 342, f'HISTORY {len(self.timeline.history)}  PREVIEW ONLY', 13)
            p.text(14, 363, 'Reason: '+self.reason, 10)
            p.text(14, 385, 'Arrows select   Z/ENTER commit   R reset', 7)
            p.text(14, 401, ('SPACE walk/prep   ' if STAGE >= 3 else 'Walking/contact only. No completed rescue yet. ')+('1/2/3 maps' if STAGE >= 4 else ''), 6)
            if STAGE >= 5: p.text(14, 417, 'X undo all model state. Preview does not advance time.', 13)
        p.text(14, 440, 'Release all keys between decisions. ESC exits. Save / quit / rerun edits.', 6)


def demo_keys():
    if STAGE == 1:
        return ['RIGHT', 'RIGHT', 'TAB']
    if STAGE == 2:
        return ['Z', 'DOWN', 'Z']
    if STAGE == 3:
        return ['SPACE', 'Z', 'SPACE', 'DOWN', 'Z']
    return ['SPACE', 'Z', 'SPACE', 'DOWN', 'Z', 'Z', 'Z', 'Z']


def main():
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description='Standalone teaching stage; no files are overwritten.')
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--output', type=Path)
    parser.add_argument('--state-json', type=Path)
    args = parser.parse_args()
    if (args.demo_checkpoint or args.output is not None or args.state_json is not None) and not args.headless_smoke:
        parser.error('Use --headless-smoke with demo/output options.')
    for name,suffix in [('output','.png'),('state_json','.json')]:
        path=getattr(args,name)
        if path is None: continue
        path=path.absolute()
        if path.suffix.lower()!=suffix or path.exists() or path.is_symlink() or not path.parent.is_dir():
            parser.error(f'{name} needs a new {suffix} file under an existing directory.')
        setattr(args,name,path)
    import pyxel
    app=App()
    pyxel.init(640,456,title=f'059 Snow Routes - {STAGE}',fps=30,display_scale=1,headless=args.headless_smoke,capture_sec=0)
    keys=('UP','DOWN','LEFT','RIGHT','SPACE','TAB','Z','X','R','RETURN','1','2','3')
    frames=0
    demo=demo_keys() if args.demo_checkpoint else []
    stop_frame=2*len(demo)+3
    def update():
        nonlocal frames
        if args.headless_smoke:
            current=frames//2
            down={demo[current]} if frames%2==0 and current<len(demo) else set()
        else: down={k for k in keys if pyxel.btn(getattr(pyxel,'KEY_'+k))}
        app.control(down)
        frames+=1
    def draw():
        app.draw(pyxel)
        if args.headless_smoke and frames>=stop_frame:
            if args.output is not None: pyxel.screenshot(str(args.output),scale=1)
            if args.state_json is not None:
                import json
                with args.state_json.open('x',encoding='utf-8') as f: json.dump(app.snapshot(),f,ensure_ascii=False,indent=2)
            pyxel.quit()
    pyxel.run(update,draw)

if __name__ == '__main__':
    main()
STEP 04 / 05

一人を乗せ、基地へ届ける

人の状態の段階、運ぶときの決まり、終わりを判定する順番。

ここで元のゲームのモデル(ルールの部分)を全部入れて、3つの地図と1人用の担架を扱います。1・2・3で、地図A・B・Cの最初の状態に切り替えられます。連絡した人のいる場所にいると、START RESCUE(救助を始める)が表示されます。これを確定すると1刻使って、people を2に、carrier をその人の番号に変えます。1人を運んでいる間に別の人を乗せようとすると、full(いっぱい)になります。

担架が空のときと、人が乗っているときでは、道の費用がちがいます。人を運ぶときは、まだ整備していない道なら距離×(雪+2) です。整備済みの道なら、ふつうのルールでは距離だけです。運びながら新しい道を整備することはできません。carry_no_prep となり、時刻も状態も変わりません。帰りは、Space で WALK に戻してから確定します。モードは、画面で選ぶためのものです。Undo で保存する人や道の記録とは別です。

Hに着いて、時刻が end 以下なら、carrier の人を3「運び終えた」にして、担架を空に戻します。2人とも3なら won(成功)です。1人をHに届けただけでは、もう1人への連絡が残っています。Hで FINISH AT HOME(基地で終える)を選ぶと、そこで切り上げて partial(一部だけ成功)の結果を残せます。この判断では時計は進まず、判断の回数 tick だけが1増えます。時計(clock)の t と、操作の回数 tick を、同じ数だと思わないようにしましょう。

到着、連絡、期限切れ、Hで担架から降ろす、終了、という順番は、model.py の move と同じです。end ちょうどにHに着いたときは、降ろせます。H以外なら shift_end です。ここで学んでいるのは、整数だけで作った作り話の模型です。本物の雪道や、救助の優先順位に使う手順ではありません。保存・再生・Timeline などのモデルの関数も、元のゲームのままこのファイルに置いてあります。ただし、この段階の画面と操作(UI)では、X を受け付けません。次の05で、Undo と予告を操作につなぎます。

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

04_carry_home.py / 注目する部分
def move(s,l,a,variant=False):
 if s.outcome!='running':return s,'ended'
 if a=='finish':
  if s.node!='H':return s,'home_only'
  return replace(s,tick=s.tick+1,outcome='won' if s.people==(3,3) else 'partial'),'finish'
 p=list(s.people);node=s.node;prepared=s.prepared;carrier=s.carrier
 if a=='rescue':
  if node not in ('A','B'):return s,'no_person'
  j='AB'.index(node)
  if p[j]!=1:return s,'not_waiting'
  if carrier>=0:return s,'full'
  carrier=j;p[j]=2;dt=1
 elif isinstance(a,str) and a.startswith('move:'):
  try:_,raw,mode=a.split(':');i=int(raw)
  except (ValueError,TypeError):return s,'invalid'
  if mode not in ('walk','prep') or i not in [n for n,_ in options(s,l)]:return s,'invalid'
  if mode=='prep' and carrier>=0:return s,'carry_no_prep'
  if mode=='prep' and prepared>>i&1:return s,'already_prepared'
  dt=cost(s,l,i,mode=='prep',variant);node=dict(options(s,l))[i]
  if mode=='prep':prepared|=1<<i
 else:return s,'invalid'
 t=s.t+dt
 # Arrival establishes contact before checking that person's deadline.
 if node in ('A','B'):
  j='AB'.index(node)
  if p[j]==0 and t<=l.deadlines[j]:p[j]=1
 for j in range(2):
  if p[j]==0 and t>l.deadlines[j]:p[j]=4
 if node=='H' and carrier>=0 and t<=l.end:p[carrier]=3;carrier=-1
 outcome='won' if p==[3,3] else 'shift_end' if t>l.end or (t==l.end and node!='H') else 'running'
 return State(node,t,prepared,tuple(p),carrier,s.tick+1,outcome),'rescue' if a=='rescue' else 'move'

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_carry_home.py
macOS / Linux
.venv/bin/python 04_carry_home.py
画面で見ること地図Aで、Space→Z→Space→下→Z→Z→Z→Z と押します。H→Jを整備し、8刻でAに着いて連絡し、9刻で担架に乗せます。17刻でJに、19刻でHに戻ります。Aは DELIVERED(運び終えた)、Bは UNCONTACTED、結果はまだ RUNNING(続いている)です。
  • 「連絡した」「担架に乗っている」「運び終えた」を分ける
  • 担架には1人しか乗せられない決まりを守る
  • 到着と終了を、決まった順番で調べる
つまずきやすいところ担架に乗せた瞬間を、運び終えた成功にしないようにします。carrier だけを消して、people を3にするのを忘れないようにします。時刻 t と、判断の回数 tick を混同しないようにします。
少し変えてみよう

START_LEVEL = 0 を START_LEVEL = 2 に変えます。保存して終了し、04_carry_home.py をもう一度実行すると、地図Cになります。Space→Z→下→Z→Z→Space→Z→Z→Z→Space→下→下→Z→Z→Space→Z→Z と、1回ずつ押します。H-JとJ-Aを整備してAをHまで運び、次にJ-Bを整備してBを運びます。27刻で2人とも DELIVERED になり、WON(成功)になることを確かめましょう。確かめたら0に戻して、起動し直します。

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

"""059 stage 4: simplified teaching UI; see LESSON.md."""
STAGE = 4
"""059 pure snow-route model. Fixed validated finite bank; no rendering or RNG."""
from dataclasses import dataclass,asdict,replace
import json,hashlib,platform
VERSION='059-rules-1.0'; GENERATOR='snow-bank-1'
@dataclass(frozen=True)
class Level:
 seed:str
 title:str
 edges:tuple
 deadlines:tuple
 end:int
LEVELS=(
 Level('A-01-v1','先に着く一刻',(('H','J',2,1),('J','A',2,2),('J','B',3,1),('H','K',3,1),('K','B',2,1),('J','K',1,1)),(9,25),38),
 Level('B-01-v1','近い人を後で',(('H','J',1,1),('J','A',2,1),('A','K',1,1),('K','B',1,2),('K','H',2,2)),(14,16),38),
 Level('C-01-v1','二度使う幹線',(('H','J',3,2),('J','A',1,1),('J','B',1,2),('H','K',2,1),('K','A',2,2),('K','B',2,1)),(14,28),44))
def canonical(x):return json.dumps(x,sort_keys=True,ensure_ascii=False,separators=(',',':'))
def digest(x):return hashlib.sha256(canonical(x).encode('utf-8')).hexdigest()
def board_data(l,variant=False):return {'level':asdict(l),'generator':GENERATOR,'rules':VERSION,'prepared_stretcher_cost':'raw' if variant else 'distance'}
def board_hash(l,variant=False):return digest(board_data(l,variant))
@dataclass(frozen=True)
class State:
 node:str='H'
 t:int=0
 prepared:int=0
 people:tuple=(0,0) # 0 untouched,1 contacted,2 carried,3 delivered,4 missed
 carrier:int=-1
 tick:int=0
 outcome:str='running'
@dataclass(frozen=True)
class Timeline:
 state:State=State()
 history:tuple=()
def options(s,l):return [(i,e[1] if e[0]==s.node else e[0]) for i,e in enumerate(l.edges) if s.node in e[:2]]
def cost(s,l,i,prep=False,variant=False):
 _,_,d,snow=l.edges[i]
 if s.prepared>>i&1:return d*(snow+2) if s.carrier>=0 and variant else d
 return d*(snow+2 if s.carrier>=0 else snow+1 if prep else snow)
def move(s,l,a,variant=False):
 if s.outcome!='running':return s,'ended'
 if a=='finish':
  if s.node!='H':return s,'home_only'
  return replace(s,tick=s.tick+1,outcome='won' if s.people==(3,3) else 'partial'),'finish'
 p=list(s.people);node=s.node;prepared=s.prepared;carrier=s.carrier
 if a=='rescue':
  if node not in ('A','B'):return s,'no_person'
  j='AB'.index(node)
  if p[j]!=1:return s,'not_waiting'
  if carrier>=0:return s,'full'
  carrier=j;p[j]=2;dt=1
 elif isinstance(a,str) and a.startswith('move:'):
  try:_,raw,mode=a.split(':');i=int(raw)
  except (ValueError,TypeError):return s,'invalid'
  if mode not in ('walk','prep') or i not in [n for n,_ in options(s,l)]:return s,'invalid'
  if mode=='prep' and carrier>=0:return s,'carry_no_prep'
  if mode=='prep' and prepared>>i&1:return s,'already_prepared'
  dt=cost(s,l,i,mode=='prep',variant);node=dict(options(s,l))[i]
  if mode=='prep':prepared|=1<<i
 else:return s,'invalid'
 t=s.t+dt
 # Arrival establishes contact before checking that person's deadline.
 if node in ('A','B'):
  j='AB'.index(node)
  if p[j]==0 and t<=l.deadlines[j]:p[j]=1
 for j in range(2):
  if p[j]==0 and t>l.deadlines[j]:p[j]=4
 if node=='H' and carrier>=0 and t<=l.end:p[carrier]=3;carrier=-1
 outcome='won' if p==[3,3] else 'shift_end' if t>l.end or (t==l.end and node!='H') else 'running'
 return State(node,t,prepared,tuple(p),carrier,s.tick+1,outcome),'rescue' if a=='rescue' else 'move'
def step(t,l,a,variant=False):
 if a=='undo':return (Timeline(t.history[-1],t.history[:-1]),'undo') if t.history else (t,'no_undo')
 ns,r=move(t.state,l,a,variant)
 return (Timeline(ns,t.history+(t.state,)),r) if ns!=t.state else (t,r)
def legal(s,l):
 aa=['rescue'] if s.node in 'AB' and s.people['AB'.index(s.node)]==1 and s.carrier<0 else []
 for i,_ in options(s,l):
  aa.append(f'move:{i}:walk')
  if s.carrier<0 and not s.prepared>>i&1:aa.append(f'move:{i}:prep')
 return aa
RULE_NAMES={'ready':'先の往復まで見て、道を選ぼう。','move':'到着。固めた道は次の往復にも残ります。','rescue':'担架に乗せました。Hへ運んで救助完了。','carry_no_prep':'担架を持つ間は整備できません。歩行へ切替。','already_prepared':'整備済みです。歩行へ切替。追加費用は不要。','no_person':'ここには救助待ちの人がいません。','not_waiting':'ここで新たに担架へ乗せる人はいません。','full':'担架は一人分。先にHへ届けよう。','home_only':'切り上げはHへ帰ってから。','undo':'一つ前の判断へ戻しました。','no_undo':'戻せる判断がありません。','finish':'帰還記録を残しました。','invalid':'その操作はできません。','ended':'Xで戻すか、Rで同じ雪道へ。'}
def record(l,actions,variant=False):
 t=Timeline();events=[]
 for n,a in enumerate(actions):
  t,r=step(t,l,a,variant);events.append({'input_tick':n,'action':a,'logic_tick':t.state.tick,'reason':r,'state_sha256':digest(asdict(t.state))})
 return {'seed':l.seed,'board':board_data(l,variant),'board_sha256':board_hash(l,variant),'environment':{'python':platform.python_version(),'engine':'Pyxel 2.9.9','model':'stdlib-only'},'inputs':events,'final':asdict(t.state),'final_sha256':digest(asdict(t.state))}
def replay(data):
 l=next((l for l in LEVELS if l.seed==data['seed']),None)
 if l is None:raise ValueError('Unknown finite bank seed')
 v=data['board']['prepared_stretcher_cost']=='raw'
 if data['board']!=json.loads(canonical(board_data(l,v))) or data['board_sha256']!=board_hash(l,v):raise ValueError('Board, rule, or generator differs')
 actual=record(l,[e['action'] for e in data['inputs']],v)
 if actual['environment']!=data.get('environment'):raise ValueError('Environment version mismatch: strict Python/Pyxel/model compatibility required')
 if actual['inputs']!=data['inputs'] or actual['final_sha256']!=data['final_sha256'] or canonical(actual['final'])!=canonical(data['final']):raise ValueError('Replay state or input mismatch')
 return actual

# Teaching UI: ASCII, no audio, one complete key release between decisions.
START_DISTANCE = 2
START_SNOW = 2
START_LEVEL = 0
LAB_MODES = ('WALK', 'PREPARE', 'CARRY')
POS = {'H': (55, 198), 'J': (155, 95), 'A': (276, 65), 'K': (178, 263), 'B': (279, 226)}
PEOPLE = ('UNCONTACTED', 'CONTACTED', 'ON STRETCHER', 'DELIVERED', 'MISSED')

class App:
    def __init__(self):
        self.level_index = START_LEVEL
        self.previous = set()
        self.gate = False
        self.reset()
    def reset(self):
        self.timeline = Timeline() if STAGE >= 4 else None
        self.simple_state = State()
        self.selected = 0
        self.preparing = False
        self.reason = 'ready'
        self.distance = START_DISTANCE
        self.snow = START_SNOW
        self.mode = 0
        self.prepared_lab = False
    @property
    def state(self):
        return self.timeline.state if STAGE >= 4 else self.simple_state
    @property
    def level(self):
        return LEVELS[self.level_index]
    def choices(self):
        state = self.state
        rows = []
        if STAGE >= 4 and state.node in ('A', 'B') and state.people['AB'.index(state.node)] == 1 and state.carrier < 0:
            rows.append(('rescue', 'START RESCUE (+1 TICK)'))
        for edge, destination in options(state, self.level):
            action = f'move:{edge}:' + ('prep' if self.preparing else 'walk')
            rows.append((action, ('PREP ' if self.preparing else 'WALK ') + destination))
        if STAGE >= 4 and state.node == 'H':
            rows.append(('finish', 'FINISH AT HOME'))
        return rows
    def action(self, action):
        if STAGE >= 4:
            if action == 'undo' and STAGE < 5:
                return
            self.timeline, self.reason = step(self.timeline, self.level, action)
        elif action.startswith('move:'):
            _, edge, mode = action.split(':')
            self.simple_state, self.reason = walk(self.simple_state, self.level, int(edge), mode == 'prep')
        self.selected = 0
    def laboratory_cost(self):
        level = Level('LAB', 'Cost laboratory', (('H', 'A', self.distance, self.snow),), (9, 25), 38)
        state = State(prepared=int(self.prepared_lab), carrier=0 if self.mode == 2 else -1)
        return cost(state, level, 0, self.mode == 1)
    def control(self, down):
        down = set(down)
        fresh = down - self.previous
        self.previous = down
        if self.gate:
            if not down:
                self.gate = False
            return
        if not fresh:
            return
        if len(down) != 1:
            self.gate = True
            return
        key = next(iter(fresh))
        self.gate = True
        if key == 'R':
            self.reset()
            return
        if STAGE == 1:
            if key == 'UP': self.distance = min(4, self.distance+1)
            elif key == 'DOWN': self.distance = max(1, self.distance-1)
            elif key == 'RIGHT': self.mode = (self.mode+1) % 3
            elif key == 'LEFT': self.mode = (self.mode-1) % 3
            elif key == 'SPACE': self.snow = 3-self.snow
            elif key == 'TAB': self.prepared_lab = not self.prepared_lab
            return
        if STAGE >= 4 and key in ('1', '2', '3'):
            self.level_index = int(key)-1
            self.reset()
            return
        if STAGE >= 5 and key == 'X':
            self.action('undo')
            return
        if self.state.outcome != 'running':
            return
        if key in ('UP', 'LEFT'): self.selected = (self.selected-1) % len(self.choices())
        elif key in ('DOWN', 'RIGHT'): self.selected = (self.selected+1) % len(self.choices())
        elif key == 'SPACE' and STAGE >= 3: self.preparing = not self.preparing
        elif key in ('Z', 'RETURN'): self.action(self.choices()[self.selected][0])
    def snapshot(self):
        return {'stage': STAGE, 'level': self.level_index, 'state': asdict(self.state),
                'selected': self.selected, 'preparing': self.preparing, 'reason': self.reason,
                'lab': [self.distance, self.snow, self.mode, self.prepared_lab, self.laboratory_cost()]}
    def draw(self, p):
        p.cls(1)
        p.text(14, 10, f'059 SNOW ROUTES / STEP {STAGE}', 10)
        p.text(14, 26, 'A fictional route model, not real rescue instructions.', 13)
        if STAGE == 1:
            p.text(32, 65, 'EDGE COST LABORATORY', 11)
            p.text(32, 94, f'DISTANCE {self.distance}   SNOW {self.snow}', 7)
            p.text(32, 124, f'MODE {LAB_MODES[self.mode]}   PREPARED {self.prepared_lab}', 7)
            p.text(32, 168, f'TRAVEL COST: {self.laboratory_cost()} TICKS', 10)
            p.text(32, 214, 'Unprepared: walk d*s / prepare d*(s+1) / carry d*(s+2)', 6)
            p.text(32, 238, 'Prepared: all travel costs d. This is a formula experiment.', 6)
            p.text(32, 278, 'Preparation is a paid journey in the actual game.', 9)
            p.text(32, 360, 'UP/DOWN distance  LEFT/RIGHT mode  SPACE snow  TAB prepared', 7)
        else:
            s = self.state
            p.text(14, 44, f'MAP {self.level.seed}   CLOCK {s.t}/{self.level.end}   {s.outcome.upper()}', 11)
            for edge, (u, v, distance, snow) in enumerate(self.level.edges):
                x,y=POS[u];xx,yy=POS[v]
                color=11 if s.prepared >> edge & 1 else 5
                p.line(x,y,xx,yy,color)
                mx,my=(x+xx)//2,(y+yy)//2
                if self.level_index == 2 and edge in (2, 4):
                    mx,my=round(x+(xx-x)*0.72),round(y+(yy-y)*0.72)
                p.rect(mx-22,my-6,48,12,1)
                p.text(mx-20,my-4,f'{edge}: {distance}/{snow}',color)
            for name,(x,y) in POS.items():
                p.circ(x,y,12,3 if name == s.node else 2)
                p.circb(x,y,12,10 if name == s.node else 6)
                p.text(x-2,y-3,name,7)
            p.text(14, 299, 'Edge labels: index / distance / snow.', 13)
            p.text(14, 314, 'Line length on screen is NOT edge distance.', 6)
            for i in range(2):
                p.text(352, 75+i*28, f'{"AB"[i]}: {PEOPLE[s.people[i]]}', 7)
                p.text(352, 88+i*28, f'CONTACT BY {self.level.deadlines[i]}', 13)
            p.text(352, 135, 'MODE: PREPARE' if self.preparing else 'MODE: WALK', 11)
            rows = self.choices()
            for i,(action,label) in enumerate(rows):
                y=164+i*22
                if i == self.selected: p.rect(344,y-3,280,17,3)
                p.text(352,y,('> ' if i == self.selected else '  ')+label,7)
            if STAGE >= 5:
                forecast_state, reason = move(s, self.level, rows[self.selected][0])
                p.text(352, 310, f'NEXT CLOCK {forecast_state.t}   {reason.upper()}', 10)
                p.text(352, 326, f'NEXT PEOPLE {forecast_state.people}', 6)
                p.text(352, 342, f'HISTORY {len(self.timeline.history)}  PREVIEW ONLY', 13)
            p.text(14, 363, 'Reason: '+self.reason, 10)
            p.text(14, 385, 'Arrows select   Z/ENTER commit   R reset', 7)
            p.text(14, 401, ('SPACE walk/prep   ' if STAGE >= 3 else 'Walking/contact only. No completed rescue yet. ')+('1/2/3 maps' if STAGE >= 4 else ''), 6)
            if STAGE >= 5: p.text(14, 417, 'X undo all model state. Preview does not advance time.', 13)
        p.text(14, 440, 'Release all keys between decisions. ESC exits. Save / quit / rerun edits.', 6)


def demo_keys():
    if STAGE == 1:
        return ['RIGHT', 'RIGHT', 'TAB']
    if STAGE == 2:
        return ['Z', 'DOWN', 'Z']
    if STAGE == 3:
        return ['SPACE', 'Z', 'SPACE', 'DOWN', 'Z']
    return ['SPACE', 'Z', 'SPACE', 'DOWN', 'Z', 'Z', 'Z', 'Z']


def main():
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description='Standalone teaching stage; no files are overwritten.')
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--output', type=Path)
    parser.add_argument('--state-json', type=Path)
    args = parser.parse_args()
    if (args.demo_checkpoint or args.output is not None or args.state_json is not None) and not args.headless_smoke:
        parser.error('Use --headless-smoke with demo/output options.')
    for name,suffix in [('output','.png'),('state_json','.json')]:
        path=getattr(args,name)
        if path is None: continue
        path=path.absolute()
        if path.suffix.lower()!=suffix or path.exists() or path.is_symlink() or not path.parent.is_dir():
            parser.error(f'{name} needs a new {suffix} file under an existing directory.')
        setattr(args,name,path)
    import pyxel
    app=App()
    pyxel.init(640,456,title=f'059 Snow Routes - {STAGE}',fps=30,display_scale=1,headless=args.headless_smoke,capture_sec=0)
    keys=('UP','DOWN','LEFT','RIGHT','SPACE','TAB','Z','X','R','RETURN','1','2','3')
    frames=0
    demo=demo_keys() if args.demo_checkpoint else []
    stop_frame=2*len(demo)+3
    def update():
        nonlocal frames
        if args.headless_smoke:
            current=frames//2
            down={demo[current]} if frames%2==0 and current<len(demo) else set()
        else: down={k for k in keys if pyxel.btn(getattr(pyxel,'KEY_'+k))}
        app.control(down)
        frames+=1
    def draw():
        app.draw(pyxel)
        if args.headless_smoke and frames>=stop_frame:
            if args.output is not None: pyxel.screenshot(str(args.output),scale=1)
            if args.state_json is not None:
                import json
                with args.state_json.open('x',encoding='utf-8') as f: json.dump(app.snapshot(),f,ensure_ascii=False,indent=2)
            pyxel.quit()
    pyxel.run(update,draw)

if __name__ == '__main__':
    main()
STEP 05 / 05

次の一手を予測し、状態ごと戻す

読むだけの予測、書き換えない履歴、Undo。

最後に、次の操作の予告と、X の Undo を操作につなぎます。右の NEXT CLOCK と NEXT PEOPLE は、今の State と選んだ action を、元のゲームの move に渡して得た、仮の結果です。move は、受け取った値を直接書き換えません。なので、予告を何回描いても、時刻や人の状態は進みません。同じ State から同じ action を実際に確定すれば、予告した State と同じになります。

予告するのは、次の1回の操作だけです。着いた先でそのあと別の操作をすること、2人とも最後まで救助できること、いちばん短い道であることまでは約束しません。受け付けない整備や運搬なら、その理由と、変わらない同じ時刻が返ります。画面では「選ぶ」「結果を読む」「Z で確定する」を分けます。描画の中から action を呼ばないようにします。

Timeline は、今の state と、過去の State を並べた history(履歴)を持ちます。step は、move で状態が変わったときだけ、その前の State を履歴に追加します。X なら、最後の1つを取り出します。戻すのは、居場所・時刻・整備した道・人の状態の段階・担架・判断の回数・結果を含む、状態全体です。位置だけを戻すと、先の時刻や道の整備が残ってしまいます。受け付けない入力は、履歴に入れません。Hでの切り上げは outcome と tick が変わるので、戻せます。

矢印キーでの選択や、歩く・整備するのモードは、モデルの判断ではありません。なので、この教材では Undo で戻しません。X のあとは、選んでいる行をいちばん上に戻しますが、モードはそのままです。R は、今の地図の状態と表示を最初に戻します。元のゲームの record・replay(記録と再生)は、モデルとして残してあります。ただ、この簡単な画面には、保存や読み込みのボタンはありません。JSONに保存して読み直すと、tuple が配列(リスト)に変わります。replay は、それを前提に調べています。元のゲームへ進むときに、このことも確かめてみましょう。

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

05_preview_undo.py / 注目する部分
def step(t,l,a,variant=False):
 if a=='undo':return (Timeline(t.history[-1],t.history[:-1]),'undo') if t.history else (t,'no_undo')
 ns,r=move(t.state,l,a,variant)
 return (Timeline(ns,t.history+(t.state,)),r) if ns!=t.state else (t,r)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_preview_undo.py
macOS / Linux
.venv/bin/python 05_preview_undo.py
画面で見ること04と同じように地図AでAを運び、H・19刻・A DELIVERED の状態で X を押します。1つ前の、J・17刻・A ON STRETCHER(担架の上)に戻ります。右の予告は、H・19刻でAを降ろす結果です。Z でもう一度Hに着くと、予告と同じになります。
  • 予測しても、渡した State は変わらない
  • State 全体を履歴に保存する
  • ゲームを終わらせる判断も Undo できる
つまずきやすいところ描画している間に、timeline を予告の値で置きかえないようにします。受け付けない操作を、履歴に積まないようにします。カーソルやモードまで Undo で戻る、と思いこんで説明しないようにします。
少し変えてみよう

コードは変えません。19刻でAを運び終えたあと、X→Z で同じ結果に戻します。次に、Hで下を2回押して FINISH AT HOME を選び、Z を押します。PARTIAL(一部だけ成功)になり、時計は19のままです。X で、切り上げる前の RUNNING に戻せます。最後に R で最初に戻し、X を押して、過去の履歴が消えたことを確かめましょう。別のコードの改造を試すときは、保存して終了し、同じ 05_preview_undo.py をもう一度実行します。

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

"""059 stage 5: simplified teaching UI; see LESSON.md."""
STAGE = 5
"""059 pure snow-route model. Fixed validated finite bank; no rendering or RNG."""
from dataclasses import dataclass,asdict,replace
import json,hashlib,platform
VERSION='059-rules-1.0'; GENERATOR='snow-bank-1'
@dataclass(frozen=True)
class Level:
 seed:str
 title:str
 edges:tuple
 deadlines:tuple
 end:int
LEVELS=(
 Level('A-01-v1','先に着く一刻',(('H','J',2,1),('J','A',2,2),('J','B',3,1),('H','K',3,1),('K','B',2,1),('J','K',1,1)),(9,25),38),
 Level('B-01-v1','近い人を後で',(('H','J',1,1),('J','A',2,1),('A','K',1,1),('K','B',1,2),('K','H',2,2)),(14,16),38),
 Level('C-01-v1','二度使う幹線',(('H','J',3,2),('J','A',1,1),('J','B',1,2),('H','K',2,1),('K','A',2,2),('K','B',2,1)),(14,28),44))
def canonical(x):return json.dumps(x,sort_keys=True,ensure_ascii=False,separators=(',',':'))
def digest(x):return hashlib.sha256(canonical(x).encode('utf-8')).hexdigest()
def board_data(l,variant=False):return {'level':asdict(l),'generator':GENERATOR,'rules':VERSION,'prepared_stretcher_cost':'raw' if variant else 'distance'}
def board_hash(l,variant=False):return digest(board_data(l,variant))
@dataclass(frozen=True)
class State:
 node:str='H'
 t:int=0
 prepared:int=0
 people:tuple=(0,0) # 0 untouched,1 contacted,2 carried,3 delivered,4 missed
 carrier:int=-1
 tick:int=0
 outcome:str='running'
@dataclass(frozen=True)
class Timeline:
 state:State=State()
 history:tuple=()
def options(s,l):return [(i,e[1] if e[0]==s.node else e[0]) for i,e in enumerate(l.edges) if s.node in e[:2]]
def cost(s,l,i,prep=False,variant=False):
 _,_,d,snow=l.edges[i]
 if s.prepared>>i&1:return d*(snow+2) if s.carrier>=0 and variant else d
 return d*(snow+2 if s.carrier>=0 else snow+1 if prep else snow)
def move(s,l,a,variant=False):
 if s.outcome!='running':return s,'ended'
 if a=='finish':
  if s.node!='H':return s,'home_only'
  return replace(s,tick=s.tick+1,outcome='won' if s.people==(3,3) else 'partial'),'finish'
 p=list(s.people);node=s.node;prepared=s.prepared;carrier=s.carrier
 if a=='rescue':
  if node not in ('A','B'):return s,'no_person'
  j='AB'.index(node)
  if p[j]!=1:return s,'not_waiting'
  if carrier>=0:return s,'full'
  carrier=j;p[j]=2;dt=1
 elif isinstance(a,str) and a.startswith('move:'):
  try:_,raw,mode=a.split(':');i=int(raw)
  except (ValueError,TypeError):return s,'invalid'
  if mode not in ('walk','prep') or i not in [n for n,_ in options(s,l)]:return s,'invalid'
  if mode=='prep' and carrier>=0:return s,'carry_no_prep'
  if mode=='prep' and prepared>>i&1:return s,'already_prepared'
  dt=cost(s,l,i,mode=='prep',variant);node=dict(options(s,l))[i]
  if mode=='prep':prepared|=1<<i
 else:return s,'invalid'
 t=s.t+dt
 # Arrival establishes contact before checking that person's deadline.
 if node in ('A','B'):
  j='AB'.index(node)
  if p[j]==0 and t<=l.deadlines[j]:p[j]=1
 for j in range(2):
  if p[j]==0 and t>l.deadlines[j]:p[j]=4
 if node=='H' and carrier>=0 and t<=l.end:p[carrier]=3;carrier=-1
 outcome='won' if p==[3,3] else 'shift_end' if t>l.end or (t==l.end and node!='H') else 'running'
 return State(node,t,prepared,tuple(p),carrier,s.tick+1,outcome),'rescue' if a=='rescue' else 'move'
def step(t,l,a,variant=False):
 if a=='undo':return (Timeline(t.history[-1],t.history[:-1]),'undo') if t.history else (t,'no_undo')
 ns,r=move(t.state,l,a,variant)
 return (Timeline(ns,t.history+(t.state,)),r) if ns!=t.state else (t,r)
def legal(s,l):
 aa=['rescue'] if s.node in 'AB' and s.people['AB'.index(s.node)]==1 and s.carrier<0 else []
 for i,_ in options(s,l):
  aa.append(f'move:{i}:walk')
  if s.carrier<0 and not s.prepared>>i&1:aa.append(f'move:{i}:prep')
 return aa
RULE_NAMES={'ready':'先の往復まで見て、道を選ぼう。','move':'到着。固めた道は次の往復にも残ります。','rescue':'担架に乗せました。Hへ運んで救助完了。','carry_no_prep':'担架を持つ間は整備できません。歩行へ切替。','already_prepared':'整備済みです。歩行へ切替。追加費用は不要。','no_person':'ここには救助待ちの人がいません。','not_waiting':'ここで新たに担架へ乗せる人はいません。','full':'担架は一人分。先にHへ届けよう。','home_only':'切り上げはHへ帰ってから。','undo':'一つ前の判断へ戻しました。','no_undo':'戻せる判断がありません。','finish':'帰還記録を残しました。','invalid':'その操作はできません。','ended':'Xで戻すか、Rで同じ雪道へ。'}
def record(l,actions,variant=False):
 t=Timeline();events=[]
 for n,a in enumerate(actions):
  t,r=step(t,l,a,variant);events.append({'input_tick':n,'action':a,'logic_tick':t.state.tick,'reason':r,'state_sha256':digest(asdict(t.state))})
 return {'seed':l.seed,'board':board_data(l,variant),'board_sha256':board_hash(l,variant),'environment':{'python':platform.python_version(),'engine':'Pyxel 2.9.9','model':'stdlib-only'},'inputs':events,'final':asdict(t.state),'final_sha256':digest(asdict(t.state))}
def replay(data):
 l=next((l for l in LEVELS if l.seed==data['seed']),None)
 if l is None:raise ValueError('Unknown finite bank seed')
 v=data['board']['prepared_stretcher_cost']=='raw'
 if data['board']!=json.loads(canonical(board_data(l,v))) or data['board_sha256']!=board_hash(l,v):raise ValueError('Board, rule, or generator differs')
 actual=record(l,[e['action'] for e in data['inputs']],v)
 if actual['environment']!=data.get('environment'):raise ValueError('Environment version mismatch: strict Python/Pyxel/model compatibility required')
 if actual['inputs']!=data['inputs'] or actual['final_sha256']!=data['final_sha256'] or canonical(actual['final'])!=canonical(data['final']):raise ValueError('Replay state or input mismatch')
 return actual

# Teaching UI: ASCII, no audio, one complete key release between decisions.
START_DISTANCE = 2
START_SNOW = 2
START_LEVEL = 0
LAB_MODES = ('WALK', 'PREPARE', 'CARRY')
POS = {'H': (55, 198), 'J': (155, 95), 'A': (276, 65), 'K': (178, 263), 'B': (279, 226)}
PEOPLE = ('UNCONTACTED', 'CONTACTED', 'ON STRETCHER', 'DELIVERED', 'MISSED')

class App:
    def __init__(self):
        self.level_index = START_LEVEL
        self.previous = set()
        self.gate = False
        self.reset()
    def reset(self):
        self.timeline = Timeline() if STAGE >= 4 else None
        self.simple_state = State()
        self.selected = 0
        self.preparing = False
        self.reason = 'ready'
        self.distance = START_DISTANCE
        self.snow = START_SNOW
        self.mode = 0
        self.prepared_lab = False
    @property
    def state(self):
        return self.timeline.state if STAGE >= 4 else self.simple_state
    @property
    def level(self):
        return LEVELS[self.level_index]
    def choices(self):
        state = self.state
        rows = []
        if STAGE >= 4 and state.node in ('A', 'B') and state.people['AB'.index(state.node)] == 1 and state.carrier < 0:
            rows.append(('rescue', 'START RESCUE (+1 TICK)'))
        for edge, destination in options(state, self.level):
            action = f'move:{edge}:' + ('prep' if self.preparing else 'walk')
            rows.append((action, ('PREP ' if self.preparing else 'WALK ') + destination))
        if STAGE >= 4 and state.node == 'H':
            rows.append(('finish', 'FINISH AT HOME'))
        return rows
    def action(self, action):
        if STAGE >= 4:
            if action == 'undo' and STAGE < 5:
                return
            self.timeline, self.reason = step(self.timeline, self.level, action)
        elif action.startswith('move:'):
            _, edge, mode = action.split(':')
            self.simple_state, self.reason = walk(self.simple_state, self.level, int(edge), mode == 'prep')
        self.selected = 0
    def laboratory_cost(self):
        level = Level('LAB', 'Cost laboratory', (('H', 'A', self.distance, self.snow),), (9, 25), 38)
        state = State(prepared=int(self.prepared_lab), carrier=0 if self.mode == 2 else -1)
        return cost(state, level, 0, self.mode == 1)
    def control(self, down):
        down = set(down)
        fresh = down - self.previous
        self.previous = down
        if self.gate:
            if not down:
                self.gate = False
            return
        if not fresh:
            return
        if len(down) != 1:
            self.gate = True
            return
        key = next(iter(fresh))
        self.gate = True
        if key == 'R':
            self.reset()
            return
        if STAGE == 1:
            if key == 'UP': self.distance = min(4, self.distance+1)
            elif key == 'DOWN': self.distance = max(1, self.distance-1)
            elif key == 'RIGHT': self.mode = (self.mode+1) % 3
            elif key == 'LEFT': self.mode = (self.mode-1) % 3
            elif key == 'SPACE': self.snow = 3-self.snow
            elif key == 'TAB': self.prepared_lab = not self.prepared_lab
            return
        if STAGE >= 4 and key in ('1', '2', '3'):
            self.level_index = int(key)-1
            self.reset()
            return
        if STAGE >= 5 and key == 'X':
            self.action('undo')
            return
        if self.state.outcome != 'running':
            return
        if key in ('UP', 'LEFT'): self.selected = (self.selected-1) % len(self.choices())
        elif key in ('DOWN', 'RIGHT'): self.selected = (self.selected+1) % len(self.choices())
        elif key == 'SPACE' and STAGE >= 3: self.preparing = not self.preparing
        elif key in ('Z', 'RETURN'): self.action(self.choices()[self.selected][0])
    def snapshot(self):
        return {'stage': STAGE, 'level': self.level_index, 'state': asdict(self.state),
                'selected': self.selected, 'preparing': self.preparing, 'reason': self.reason,
                'lab': [self.distance, self.snow, self.mode, self.prepared_lab, self.laboratory_cost()]}
    def draw(self, p):
        p.cls(1)
        p.text(14, 10, f'059 SNOW ROUTES / STEP {STAGE}', 10)
        p.text(14, 26, 'A fictional route model, not real rescue instructions.', 13)
        if STAGE == 1:
            p.text(32, 65, 'EDGE COST LABORATORY', 11)
            p.text(32, 94, f'DISTANCE {self.distance}   SNOW {self.snow}', 7)
            p.text(32, 124, f'MODE {LAB_MODES[self.mode]}   PREPARED {self.prepared_lab}', 7)
            p.text(32, 168, f'TRAVEL COST: {self.laboratory_cost()} TICKS', 10)
            p.text(32, 214, 'Unprepared: walk d*s / prepare d*(s+1) / carry d*(s+2)', 6)
            p.text(32, 238, 'Prepared: all travel costs d. This is a formula experiment.', 6)
            p.text(32, 278, 'Preparation is a paid journey in the actual game.', 9)
            p.text(32, 360, 'UP/DOWN distance  LEFT/RIGHT mode  SPACE snow  TAB prepared', 7)
        else:
            s = self.state
            p.text(14, 44, f'MAP {self.level.seed}   CLOCK {s.t}/{self.level.end}   {s.outcome.upper()}', 11)
            for edge, (u, v, distance, snow) in enumerate(self.level.edges):
                x,y=POS[u];xx,yy=POS[v]
                color=11 if s.prepared >> edge & 1 else 5
                p.line(x,y,xx,yy,color)
                mx,my=(x+xx)//2,(y+yy)//2
                if self.level_index == 2 and edge in (2, 4):
                    mx,my=round(x+(xx-x)*0.72),round(y+(yy-y)*0.72)
                p.rect(mx-22,my-6,48,12,1)
                p.text(mx-20,my-4,f'{edge}: {distance}/{snow}',color)
            for name,(x,y) in POS.items():
                p.circ(x,y,12,3 if name == s.node else 2)
                p.circb(x,y,12,10 if name == s.node else 6)
                p.text(x-2,y-3,name,7)
            p.text(14, 299, 'Edge labels: index / distance / snow.', 13)
            p.text(14, 314, 'Line length on screen is NOT edge distance.', 6)
            for i in range(2):
                p.text(352, 75+i*28, f'{"AB"[i]}: {PEOPLE[s.people[i]]}', 7)
                p.text(352, 88+i*28, f'CONTACT BY {self.level.deadlines[i]}', 13)
            p.text(352, 135, 'MODE: PREPARE' if self.preparing else 'MODE: WALK', 11)
            rows = self.choices()
            for i,(action,label) in enumerate(rows):
                y=164+i*22
                if i == self.selected: p.rect(344,y-3,280,17,3)
                p.text(352,y,('> ' if i == self.selected else '  ')+label,7)
            if STAGE >= 5:
                forecast_state, reason = move(s, self.level, rows[self.selected][0])
                p.text(352, 310, f'NEXT CLOCK {forecast_state.t}   {reason.upper()}', 10)
                p.text(352, 326, f'NEXT PEOPLE {forecast_state.people}', 6)
                p.text(352, 342, f'HISTORY {len(self.timeline.history)}  PREVIEW ONLY', 13)
            p.text(14, 363, 'Reason: '+self.reason, 10)
            p.text(14, 385, 'Arrows select   Z/ENTER commit   R reset', 7)
            p.text(14, 401, ('SPACE walk/prep   ' if STAGE >= 3 else 'Walking/contact only. No completed rescue yet. ')+('1/2/3 maps' if STAGE >= 4 else ''), 6)
            if STAGE >= 5: p.text(14, 417, 'X undo all model state. Preview does not advance time.', 13)
        p.text(14, 440, 'Release all keys between decisions. ESC exits. Save / quit / rerun edits.', 6)


def demo_keys():
    if STAGE == 1:
        return ['RIGHT', 'RIGHT', 'TAB']
    if STAGE == 2:
        return ['Z', 'DOWN', 'Z']
    if STAGE == 3:
        return ['SPACE', 'Z', 'SPACE', 'DOWN', 'Z']
    return ['SPACE', 'Z', 'SPACE', 'DOWN', 'Z', 'Z', 'Z', 'Z']


def main():
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description='Standalone teaching stage; no files are overwritten.')
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--output', type=Path)
    parser.add_argument('--state-json', type=Path)
    args = parser.parse_args()
    if (args.demo_checkpoint or args.output is not None or args.state_json is not None) and not args.headless_smoke:
        parser.error('Use --headless-smoke with demo/output options.')
    for name,suffix in [('output','.png'),('state_json','.json')]:
        path=getattr(args,name)
        if path is None: continue
        path=path.absolute()
        if path.suffix.lower()!=suffix or path.exists() or path.is_symlink() or not path.parent.is_dir():
            parser.error(f'{name} needs a new {suffix} file under an existing directory.')
        setattr(args,name,path)
    import pyxel
    app=App()
    pyxel.init(640,456,title=f'059 Snow Routes - {STAGE}',fps=30,display_scale=1,headless=args.headless_smoke,capture_sec=0)
    keys=('UP','DOWN','LEFT','RIGHT','SPACE','TAB','Z','X','R','RETURN','1','2','3')
    frames=0
    demo=demo_keys() if args.demo_checkpoint else []
    stop_frame=2*len(demo)+3
    def update():
        nonlocal frames
        if args.headless_smoke:
            current=frames//2
            down={demo[current]} if frames%2==0 and current<len(demo) else set()
        else: down={k for k in keys if pyxel.btn(getattr(pyxel,'KEY_'+k))}
        app.control(down)
        frames+=1
    def draw():
        app.draw(pyxel)
        if args.headless_smoke and frames>=stop_frame:
            if args.output is not None: pyxel.screenshot(str(args.output),scale=1)
            if args.state_json is not None:
                import json
                with args.state_json.open('x',encoding='utf-8') as f: json.dump(app.snapshot(),f,ensure_ascii=False,indent=2)
            pyxel.quit()
    pyxel.run(update,draw)

if __name__ == '__main__':
    main()
AFTER STEP 05

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

まだ整備していない道の費用は、何も持たずに歩くと d×雪、整備しながらだと d×(雪+1)、担架で運ぶと d×(雪+2) です。整備済みの道は距離だけです。期限までに着けば連絡できます。1刻で1人を担架に乗せ、勤務が終わるまでにHへ届けます。2人を届ければ成功です。1回の判断ごとに Undo できます。

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

比べる点5ステップの教材元の完成版
段階01は費用の実験、02は歩きと連絡、03は整備、04はモデル全部と搬送、05は予告と Undo。02と03には搬送がない。最初から3つの地図、搬送、整備、予告、Undo がある。
表示と操作1つの .py ファイルだけで動き、英字(ASCII)で表示。表紙、説明での一時停止、録画、音、比較の切り替えはない。1回の操作ごとに、キーを全部離す必要がある。日本語の文字、表紙と説明、比較(B)、記録(S)、収録(F12)。
残してあるモデル04と05のモデルの定義とデータは、すべて元のゲームのまま。04では X を画面の操作につながず、05で加える。record・replay は残すが、保存と読み込みの画面はない。記録のためのJSONの保存と、再生して正しいかを調べる仕組みがある。

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

  1. model.py の cost と move(費用の計算と、操作してよいかの判定を分ける)
  2. model.py の options と prepared(辺の番号の使い方)
  3. model.py の move(到着・連絡・降ろす・終了の順番)
  4. model.py の step・Timeline(State 全体の保存)
  5. model.py の record・replay(JSONと、読み直せる条件)
  6. app.py の choices(1手先の予告と、日本語の表示)
自分のゲームに変えるなら

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

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