シミュレーションLESSON 78有限燃料と荷重を考える公開情報の経路パズル原作050

帰り道の燃料の作り方

運べる価値と、帰れる重さ。
出発するときの荷物の重さから燃料の使用量を計算し、数に限りのある補給と、品物を置いた場所を管理します。基地へ帰れるかの予測と、状態全体を戻せる Undo も作ります。

完成版の遊び方拾うほど燃料の使用量が増える荷物を運び、補給と拾う順番を決めて、基地へ一度だけ帰ります。

帰り道の燃料の元の完成版。自動操作によるPyxelの実描画画面。
元の完成版Cの自動描画です。下の教材は燃料費の実験から始め、04/05で三つの状況を扱います。
PLAY / 試験公開

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

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

完成版の主な操作:矢印移動/拾う/置く/補給札、Z/Space確定、X戻す、R再開、P停止、1〜3状況

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

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

うまく表示されないとき

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

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

荷物の重さと、限りのある補給から、帰り道を調べる。

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

1. 引数・式・「全体の量は変わらない」というルール・変える前の値
2. dataclass・tuple・zip・全部まとめて変えるか、何も変えないか
3. min・番号の対応・資源の全体量は変わらない
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
変数・if・for・関数と戻り値 / tuple・list、番号(添字)は0から / dataclass/replace は02、deque を使った探索は04で説明します
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
矢印:01は距離/荷重、02以降は札選択(左右±1、上下±5) / Z/Space:計算実験で消費、経路で選択札を確定 / 1/2/3:04以降、状況A/B/Cを初期化して選択 / X:05のみ、状態が変わった操作を一つ戻す / R:同状況を初期化。コードは再読込しない / Esc:終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_travel_cost.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

同じ道でも、荷重で移動費が変わる

引数・式・「全体の量は変わらない」というルール・変える前の値。

最初は、中身が見える計算の実験です。左右キーで距離1〜3、上下キーで今の荷物の重さ0〜4を選び、Z または Space で1回分の燃料を使います。地図、部品の場所、補給、勝ち負けはまだありません。図の横が荷物の重さ、縦が距離で、黄色い枠の値が次に必要な燃料です。最初の燃料12は、この実験用の決まった値です。

move_cost(distance, load) は、距離×(1+荷物の重さ) を返します。荷物がなくても「1」が残るので、距離の分は燃料を使います。距離2・重さ2なら、2×3=6 です。ここで「1+重さ」を掛け忘れると、品物を増やす順番を考えるゲームになりません。まず、計算と表示を短い関数で結びつけます。

Z の処理では、必要な燃料を計算し、足りるかを調べ、そのあとで残りの燃料 fuel と使った燃料 spent を更新します。足りなければ return して、どちらも変えません。ちょうど同じなら移動でき、fuel は0になります。fuel+spent が12のままかを確かめると、燃料が増えたり消えたりする不具合を見つけられます。

LAST は、最後に成功した移動の (距離, 重さ, 使った燃料) を tuple で記録します。あとから荷物を軽くしても、すでに使った燃料は返ってきません。元のゲームでも、出発するときの重さでその移動の燃料が決まります。次の段階では、この重さを、実際に荷台に積んだ部品の合計から計算します。

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

01_travel_cost.py / 注目する部分
def move_cost(distance, load):
    return distance * (1 + load)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_travel_cost.py
macOS / Linux
.venv/bin/python 01_travel_cost.py
画面で見ること上 → 上 → 右 → Z と押します。距離2・重さ2・NEXT COST 6、FUEL 6、SPENT 6、LAST (2, 2, 6) です。下を押すと NEXT COST は4になりますが、FUEL 6 と LAST は変わりません。
  • 荷物がなくても、距離の分は燃料を使う
  • 足りるかを確かめてから、状態を変える
  • 出発するときの重さを記録する
つまずきやすいところ燃料が足りるかを調べる前に、引き算をしないでください。あとで load を変えたときに、LAST まで計算し直さないでください。R で最初に戻しても、書き換えたコードが読み直されるわけではありません。
少し変えてみよう

START_LOAD = 0 を START_LOAD = 1 に変えてみましょう。保存 → Esc で古い画面を閉じる → 同じ 01_travel_cost.py を起動し直します。距離1で Z を1回押すと、FUEL 10・SPENT 2 です。0に戻すときも、保存・終了・起動し直しをします。R だけでは、書き換えた内容は反映されません。

01_travel_cost.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(161行)
01_travel_cost.py / 全文
# MIT License
# 
# Copyright (c) 2026
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""帰り道の燃料・教材1/5。Python 3.11+ / Pyxel 2.9.9。"""

from dataclasses import dataclass, asdict
STAGE = 1
START_FUEL = 12
START_LOAD = 0

# Stage 1 exposes the arithmetic, without the route model or a win condition.
def move_cost(distance, load):
    return distance * (1 + load)

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

    def reset(self):
        self.distance = 1
        self.load = START_LOAD
        self.fuel = START_FUEL
        self.spent = 0
        self.last = None
        self.reason = 'READY'
        self.gated = False

    def control(self, pressed, held):
        if 'R' in pressed:
            self.reset(); self.gated = True; return
        if self.gated:
            if not held and not pressed: self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True; return
        if len(pressed) != 1: return
        key = next(iter(pressed)); self.gated = True
        if key in ('LEFT','RIGHT'):
            self.distance = max(1, min(3, self.distance+(-1 if key=='LEFT' else 1)))
        elif key in ('UP','DOWN'):
            self.load = max(0, min(4, self.load+(1 if key=='UP' else -1)))
        elif key in ('Z','SPACE'):
            cost = move_cost(self.distance, self.load)
            if self.fuel < cost:
                self.reason = 'NOT ENOUGH FUEL: NOTHING CHANGED'
                return
            self.fuel -= cost
            self.spent += cost
            self.last = (self.distance, self.load, cost)
            self.reason = 'SPENT: CARGO CHANGES AFFECT ONLY FUTURE MOVES'

    def snapshot(self):
        return {'stage':STAGE, 'distance':self.distance, 'load':self.load,
                'fuel':self.fuel, 'spent':self.spent, 'last':self.last, 'reason':self.reason}

    def draw(self,p):
        p.cls(1)
        p.text(14,10,'RETURN FUEL / STAGE 1 / PUBLIC COST LAB',10)
        p.text(14,30,'SIMPLIFIED LEARNING UI: NO MAP, PICKUPS OR WIN CONDITION',7)
        p.text(28,67,'DISTANCE '+str(self.distance),7)
        p.text(28,89,'CURRENT LOAD '+str(self.load),7)
        p.text(28,116,'NEXT COST = '+str(self.distance)+' x (1 + '+str(self.load)+') = '+str(move_cost(self.distance,self.load)),10)
        for d in range(1,4):
            y=154+d*30
            p.text(30,y,'DISTANCE '+str(d),13)
            for load in range(5):
                x=170+load*80
                p.rectb(x-8,y-6,65,22,10 if (d,load)==(self.distance,self.load) else 5)
                p.text(x,y,str(move_cost(d,load)),7)
        for load in range(5):p.text(164+load*80,162,'LOAD '+str(load),6)
        p.text(28,291,'FUEL '+str(self.fuel)+'   SPENT '+str(self.spent)+'   TOTAL '+str(self.fuel+self.spent),11)
        p.text(28,315,'LAST (DISTANCE, LOAD, COST): '+str(self.last),7)
        p.text(28,340,self.reason,10)
        p.text(14,412,'LEFT/RIGHT: DISTANCE   UP/DOWN: LOAD   Z/SPACE: SPEND',7)
        p.text(14,429,'R: RESET EXPERIMENT   ESC: QUIT   NO ROUTE RULES YET',13)
        p.text(14,446,'EDIT -> SAVE -> QUIT -> RERUN. R DOES NOT RELOAD CODE.',6)

def make_demo_keys():
    return ('UP','UP','RIGHT','Z')

def main(argv=None):
    import argparse
    from pathlib import Path
    import json
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke', action='store_true', help='bounded actual Pyxel render')
    parser.add_argument('--demo-checkpoint', action='store_true', help='replay documented keys')
    parser.add_argument('--frames', type=int, help='bounded headless frames, 1..10000')
    parser.add_argument('--screenshot', help='new PNG; relative to launch working directory')
    parser.add_argument('--state-json', help='new JSON; relative to launch working directory')
    args=parser.parse_args(argv)
    if (args.demo_checkpoint or args.frames is not None or args.screenshot or args.state_json) and not args.headless_smoke:
        parser.error('--demo-checkpoint, --frames and outputs require --headless-smoke')
    if args.frames is not None and not 1<=args.frames<=10000:
        parser.error('--frames must be an integer from 1 to 10000')
    sequence=make_demo_keys() if args.demo_checkpoint else ()
    minimum=len(sequence)*2+2 if sequence else 1
    frames=args.frames if args.frames is not None else max(8,minimum)
    if frames<minimum: parser.error('--frames is too short for the full checkpoint; need '+str(minimum))
    # Resolve EVERY output before importing/initializing Pyxel, which may chdir.
    outputs={}
    for name,raw,suffix in (('screenshot',args.screenshot,'.png'),('state_json',args.state_json,'.json')):
        if raw is None: continue
        path=Path(raw).expanduser()
        if not raw or path.suffix.lower()!=suffix or not path.parent.is_dir() or path.exists() or path.is_symlink():
            parser.error(name+' must be a new '+suffix+' file in an existing directory; refusing overwrite')
        outputs[name]=path.resolve()
    if len(set(outputs.values()))!=len(outputs): parser.error('output paths must differ')
    import pyxel as p
    p.init(640,466,title='Return fuel lesson '+str(STAGE),fps=30,display_scale=2,headless=args.headless_smoke)
    app=App()
    names=('LEFT','RIGHT','UP','DOWN','Z','SPACE','R','X','1','2','3')
    keys={name:getattr(p,'KEY_'+name) for name in names}
    elapsed=0
    def finish():
        # Refuse again at save time; JSON uses exclusive creation as well.
        for path in outputs.values():
            if path.exists() or path.is_symlink(): raise FileExistsError('refusing overwrite: '+str(path))
        app.draw(p)
        if 'screenshot' in outputs: p.screen.save(str(outputs['screenshot']),1)
        if 'state_json' in outputs:
            with outputs['state_json'].open('x',encoding='utf-8') as file:
                json.dump(app.snapshot(),file,ensure_ascii=False,indent=2); file.write('\n')
        p.quit()
    def update():
        nonlocal elapsed
        if p.btnp(p.KEY_ESCAPE): finish(); return
        if args.headless_smoke:
            if elapsed>=frames: finish(); return
            index=elapsed//2
            pressed={sequence[index]} if elapsed%2==0 and index<len(sequence) else set()
            app.control(pressed,pressed)
        else:
            app.control({k for k,v in keys.items() if p.btnp(v)}, {k for k,v in keys.items() if p.btn(v)})
        elapsed+=1
    p.run(update,lambda:app.draw(p))

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

部品の所在から荷重と価値を集める

dataclass・tuple・zip・全部まとめて変えるか、何も変えないか。

元のゲームの状況 A で、地図と3つの部品を加えます。矢印キーで札を選び、Z または Space で確定します。MOVE はつながった地点への移動、PICK は今いる場所の品を拾う、DROP は荷台の品を今いる場所に置く操作です。道の数字は距離です。状況 A は燃料12、補給なし、価値の目標は4です。まだ Undo や予告の表示は使いません。

Item は品物の決まった情報、Level は地図と最初の条件、State はゲームの進行中に変わる情報です。frozen=True なので、State の項目は直接書き換えず、replace で変えたあとの別の State を作ります。locations の同じ番号が、items の同じ品物を表し、H/A/B/C なら地面の上、T なら荷台の上です。1つの品物を「地面」と「荷台」の両方で管理してしまう二重管理を防ぎます。

重さ weight と価値 value は、zip で品物と場所を組にして、T(荷台)にあるものだけを合計します。拾う・置く操作そのものは燃料を使いませんが、そのあとの移動の燃料を変えます。拾う前に重さの上限4を超えないかを確かめ、超えるなら State を丸ごとそのまま返します。道がない、遠くの品物、燃料不足のときも同じ約束です。

H から出発したあと H に戻ると、その遠征は1回で終わります。持ち帰った価値が目標以上なら WON、足りなければ SHORT です。燃料0で帰り着いてもかまいません。元のゲームのデータと apply/check は、ここからそのまま使います。補給の分かれ道もコードにはありますが、状況 A 専用の画面では、補給の札はまだ出しません。あとの段階で、このコードを実際の限りのある補給につなげます。

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

02_cargo_route.py / 注目する部分
def weight(s, l):
    return sum((i.weight for i, p in zip(l.items, s.locations) if p == 'T'))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_cargo_route.py
macOS / Linux
.venv/bin/python 02_cargo_route.py
画面で見ることMOVE A → PICK a → MOVE B → PICK b → MOVE C → PICK c → MOVE H と、それぞれの札で Z を押します。使う燃料は 1,2,4,5 で、最後は WON、FUEL 0、LOAD 4、VALUE 9/4 になります。キーの順番は Z→右×3→Z→右×5→Z→右×3→Z→右×5→Z→右×3→Z→右×1→Z です。
  • 品物の場所は、1つにつき1か所だけ
  • 重さの合計と、価値の合計は別のもの
  • 価値の目標は、帰り着いたときだけ判定する
つまずきやすいところ品物の価値 value を、移動の燃料に使わないでください。移動の燃料に使うのは重さ weight です。荷物なしで H に戻ると SHORT で終わってしまうので、H を自由に通り抜ける地点として使う計画はできません。
少し変えてみよう

START_LEVEL = 0 を START_LEVEL = 1 に変え、保存・Esc で終了・同じ 02_cargo_route.py を起動し直してみましょう。状況 B に切り替わりますが、補給の札はありません。MOVE A → MOVE B → PICK b → MOVE C → PICK c → MOVE H で、補給なしの WON・VALUE 8/7・FUEL 1 になることを確かめます。0に戻して、保存・終了・起動し直します。元のゲームの LEVELS の数値は変えません。

02_cargo_route.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(301行)
02_cargo_route.py / 全文
# MIT License
# 
# Copyright (c) 2026
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""帰り道の燃料・教材2/5。Python 3.11+ / Pyxel 2.9.9。"""
from dataclasses import dataclass, replace, asdict
from collections import deque
from functools import lru_cache
import hashlib, json

@dataclass(frozen=True)
class Item:
    id: str
    place: str
    weight: int
    value: int

@dataclass(frozen=True)
class Level:
    title: str
    nodes: tuple
    edges: tuple
    items: tuple
    capacity: int
    initial_fuel: int
    supply: tuple
    goal: int
SQUARE = (('H', 'A', 1), ('A', 'B', 1), ('B', 'C', 1), ('C', 'H', 1))
LEVELS = (Level('同じ一周、違う帰り', ('H', 'A', 'B', 'C'), SQUARE, (Item('a', 'A', 1, 2), Item('b', 'B', 2, 4), Item('c', 'C', 1, 3)), 12, 12, (0, 0, 0, 0), 4), Level('補給の前後', ('H', 'A', 'B', 'C'), SQUARE, (Item('a', 'A', 2, 4), Item('b', 'B', 1, 3), Item('c', 'C', 2, 5)), 9, 9, (0, 0, 3, 0), 7), Level('重い主部品は最後に', ('H', 'A', 'B', 'C', 'D'), (('H', 'A', 1), ('A', 'B', 2), ('B', 'H', 2), ('H', 'C', 2), ('C', 'D', 1), ('D', 'B', 1)), (Item('a', 'A', 1, 2), Item('b', 'B', 3, 6), Item('c', 'C', 1, 3), Item('d', 'D', 1, 3)), 12, 12, (0, 0, 0, 0, 4), 6))

@dataclass(frozen=True)
class State:
    place: str
    fuel: int
    locations: tuple
    supply: tuple
    departed: bool = False
    spent: int = 0
    outcome: str = 'running'
REASONS = {'ready': '遠征は一回。基地Hへ戻ると終了です。', 'ok': '予告どおり確定しました。', 'fuel': '燃料不足。置く・補給・別の道を検討。', 'full': '荷重4を超えます。拾う前に置けます。', 'absent': 'この品は現在地にも荷台にもありません。', 'road': 'この二地点を結ぶ道はありません。', 'supply': 'タンク満杯か、この地点の補給が空です。', 'terminal': '帰還済み。Xで戻すかRで再開。', 'invalid': 'その操作はありません。', 'undo': '一つ前の操作へ戻りました。', 'no_undo': 'まだ戻せる操作はありません。'}

def initial(l):
    return State('H', l.initial_fuel, tuple((i.place for i in l.items)), l.supply)

def weight(s, l):
    return sum((i.weight for i, p in zip(l.items, s.locations) if p == 'T'))

def value(s, l):
    return sum((i.value for i, p in zip(l.items, s.locations) if p == 'T'))

def distance(l, a, b):
    return next((d for u, v, d in l.edges if (a, b) in ((u, v), (v, u))), None)

def actions(l):
    return tuple((('move', n) for n in l.nodes)) + tuple((('item', i) for i in range(len(l.items)))) + (('refuel',),)

def travel_cost(s, l, d, flat=False):
    return d * (1 if flat else 1 + weight(s, l))

def apply(s, l, a, flat=False):
    if s.outcome != 'running':
        return (s, 'terminal')
    if a not in actions(l):
        return (s, 'invalid')
    k = a[0]
    n = s
    if k == 'move':
        dst = a[1]
        d = distance(l, s.place, dst)
        if d is None:
            return (s, 'road')
        cost = travel_cost(s, l, d, flat)
        if s.fuel < cost:
            return (s, 'fuel')
        n = replace(s, place=dst, fuel=s.fuel - cost, spent=s.spent + cost, departed=True)
        if dst == 'H':
            n = replace(n, outcome='won' if value(n, l) >= l.goal else 'short')
    elif k == 'item':
        i = a[1]
        p = s.locations[i]
        if p not in ('T', s.place):
            return (s, 'absent')
        if p != 'T' and weight(s, l) + l.items[i].weight > 4:
            return (s, 'full')
        loc = list(s.locations)
        loc[i] = s.place if p == 'T' else 'T'
        n = replace(s, locations=tuple(loc))
    elif k == 'refuel':
        i = l.nodes.index(s.place)
        amount = min(l.capacity - s.fuel, s.supply[i])
        if amount <= 0:
            return (s, 'supply')
        sup = list(s.supply)
        sup[i] -= amount
        n = replace(s, fuel=s.fuel + amount, supply=tuple(sup))
    check(n, l)
    return (n, 'ok')

def check(s, l):
    assert 0 <= s.fuel <= l.capacity and 0 <= weight(s, l) <= 4 and (s.spent >= 0)
    assert s.fuel + sum(s.supply) + s.spent == l.initial_fuel + sum(l.supply)
    assert len(s.locations) == len(l.items) and all((p in l.nodes + ('T',) for p in s.locations))
    assert all((0 <= a <= b for a, b in zip(s.supply, l.supply)))
    assert s.outcome == 'running' or (s.place == 'H' and s.departed)

# ASCII-only learning UI. The canonical Japanese text/data remain in the model.
STAGE = 2
START_LEVEL = 0
MESSAGES = {'ready':'READY: SELECT AN ACTION, THEN Z', 'ok':'OK',
    'fuel':'NOT ENOUGH FUEL; NO STATE CHANGED', 'full':'LOAD WOULD EXCEED 4; NO STATE CHANGED',
    'absent':'ITEM IS NEITHER HERE NOR IN THE TRUCK', 'road':'NO DIRECT ROAD BETWEEN THESE NODES',
    'supply':'TANK FULL OR LOCAL SUPPLY EMPTY', 'terminal':'ALREADY HOME: R RESTARTS',
    'invalid':'INVALID ACTION', 'undo':'RESTORED THE PREVIOUS STATE',
    'no_undo':'NO EARLIER STATE'}

class App:
    def __init__(self):
        self.level_index = START_LEVEL
        self.reset()

    @property
    def board(self):
        return LEVELS[self.level_index]

    def reset(self):
        self.state = initial(self.board)
        self.selection = 1
        self.reason = 'ready'
        self.gated = False

    def options(self):
        choices = tuple(('move', n) for n in self.board.nodes)
        choices += tuple(('item', i) for i in range(len(self.board.items)))

        return choices

    def act(self, action):
        self.state, self.reason = apply(self.state, self.board, action)

    def control(self, pressed, held):
        # A key must be released before another action; chords are rejected.
        if 'R' in pressed:
            self.reset(); self.gated = True; return
        if self.gated:
            if not held and not pressed: self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True; return
        if len(pressed) != 1: return
        key = next(iter(pressed)); self.gated = True

        if key in ('LEFT', 'RIGHT', 'UP', 'DOWN'):
            delta = {'LEFT':-1, 'RIGHT':1, 'UP':-5, 'DOWN':5}[key]
            self.selection = (self.selection + delta) % len(self.options())
        elif key in ('Z', 'SPACE'):
            self.act(self.options()[self.selection])

    def snapshot(self):
        return {'stage':STAGE, 'level':self.level_index, 'state':asdict(self.state),
                'selection':self.selection, 'reason':self.reason}

    def draw(self, p):
        s, l = self.state, self.board
        p.cls(1)
        p.text(14, 10, 'RETURN FUEL / STAGE '+str(STAGE)+' / SIMPLIFIED LEARNING UI', 10)
        p.text(14, 28, 'LEVEL '+'ABC'[self.level_index]+'   DISTANCE x (1 + CURRENT LOAD)', 7)
        p.text(364, 50, 'FUEL '+str(s.fuel)+'/'+str(l.capacity), 10)
        for i in range(l.capacity): p.rect(364+i*18, 64, 14, 6, 11 if i<s.fuel else 5)
        p.text(364, 80, 'LOAD '+str(weight(s,l))+'/4   VALUE '+str(value(s,l))+'/'+str(l.goal), 7)
        p.text(364, 96, 'SPENT '+str(s.spent)+'   LOCAL OIL '+str(sum(s.supply)), 6)
        coords = {'H':(46,98),'A':(300,98),'B':(300,214),'C':(46,214)}
        if self.level_index == 2:
            coords = {'H':(46,152),'A':(136,88),'B':(300,152),'C':(136,218),'D':(280,238)}
        for u,v,d in l.edges:
            x,y=coords[u]; xx,yy=coords[v]
            p.line(x,y,xx,yy,5)
            p.rect((x+xx)//2-4,(y+yy)//2-5,12,12,1)
            p.text((x+xx)//2,(y+yy)//2-2,str(d),13)
        for i,n in enumerate(l.nodes):
            x,y=coords[n]
            p.circ(x,y,14,3 if n=='H' else 2)
            p.circb(x,y,14,10 if n==s.place else 6)
            p.text(x-2,y-3,n,7)
            if n==s.place: p.rect(x-8,y+7,16,5,10)
            items=''.join(it.id for it,loc in zip(l.items,s.locations) if loc==n)
            p.text(x-14,y+19,'ITEM '+(items or '-'),10)
            if s.supply[i]: p.text(x-14,y-26,'OIL '+str(s.supply[i]),11)
        p.text(364, 123, 'ID   WEIGHT  VALUE  LOCATION', 13)
        for i,it in enumerate(l.items):
            y=142+25*i
            p.rect(358,y-5,266,20,3 if s.locations[i]=='T' else 2)
            place='TRUCK' if s.locations[i]=='T' else s.locations[i]
            p.text(364,y,it.id+'      '+str(it.weight)+'      '+str(it.value)+'      '+place,7)
        p.text(14,276,'Return to H once: VALUE >= GOAL wins; otherwise SHORT.',6)
        p.text(14,294,'Pickup/drop costs 0 fuel. Moves use the departure load.',13)
        for i,action in enumerate(self.options()):
            x=14+(i%5)*124; y=314+(i//5)*26
            p.rectb(x,y,118,23,10 if i==self.selection else 5)
            name = 'MOVE '+action[1] if action[0]=='move' else 'REFUEL'
            if action[0]=='item':
                idx=action[1]
                name=('DROP ' if s.locations[idx]=='T' else 'PICK ')+l.items[idx].id
            p.text(x+7,y+8,name,10 if i==self.selection else 7)
        p.text(14, 375, 'RESULT '+s.outcome.upper()+'   '+MESSAGES[self.reason],11 if s.outcome=='won' else 7)
        p.text(14,395,'Only this lesson UI restricts actions; the model remains explicit.',6)
        p.text(14, 412, 'ARROWS: SELECT   Z/SPACE: DO   R: RESET   ESC: QUIT',7)
        p.text(14, 429, 'LEVEL A ONLY / NO REFUEL NEEDED / NO UNDO YET',13)
        p.text(14, 446, 'EDIT -> SAVE -> QUIT -> RERUN. R DOES NOT RELOAD CODE.',6)

def make_demo_keys():
    app=App(); keys=[]

    plan=[('move', 'A'), ('item', 0), ('move', 'B'), ('item', 1), ('move', 'C'), ('item', 2), ('move', 'H')]
    for action in plan:
        idx=app.options().index(action)
        while app.selection!=idx:
            keys.append('RIGHT'); app.selection=(app.selection+1)%len(app.options())
        keys.append('Z'); app.act(action)

    return tuple(keys)

def main(argv=None):
    import argparse
    from pathlib import Path
    import json
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke', action='store_true', help='bounded actual Pyxel render')
    parser.add_argument('--demo-checkpoint', action='store_true', help='replay documented keys')
    parser.add_argument('--frames', type=int, help='bounded headless frames, 1..10000')
    parser.add_argument('--screenshot', help='new PNG; relative to launch working directory')
    parser.add_argument('--state-json', help='new JSON; relative to launch working directory')
    args=parser.parse_args(argv)
    if (args.demo_checkpoint or args.frames is not None or args.screenshot or args.state_json) and not args.headless_smoke:
        parser.error('--demo-checkpoint, --frames and outputs require --headless-smoke')
    if args.frames is not None and not 1<=args.frames<=10000:
        parser.error('--frames must be an integer from 1 to 10000')
    sequence=make_demo_keys() if args.demo_checkpoint else ()
    minimum=len(sequence)*2+2 if sequence else 1
    frames=args.frames if args.frames is not None else max(8,minimum)
    if frames<minimum: parser.error('--frames is too short for the full checkpoint; need '+str(minimum))
    # Resolve EVERY output before importing/initializing Pyxel, which may chdir.
    outputs={}
    for name,raw,suffix in (('screenshot',args.screenshot,'.png'),('state_json',args.state_json,'.json')):
        if raw is None: continue
        path=Path(raw).expanduser()
        if not raw or path.suffix.lower()!=suffix or not path.parent.is_dir() or path.exists() or path.is_symlink():
            parser.error(name+' must be a new '+suffix+' file in an existing directory; refusing overwrite')
        outputs[name]=path.resolve()
    if len(set(outputs.values()))!=len(outputs): parser.error('output paths must differ')
    import pyxel as p
    p.init(640,466,title='Return fuel lesson '+str(STAGE),fps=30,display_scale=2,headless=args.headless_smoke)
    app=App()
    names=('LEFT','RIGHT','UP','DOWN','Z','SPACE','R','X','1','2','3')
    keys={name:getattr(p,'KEY_'+name) for name in names}
    elapsed=0
    def finish():
        # Refuse again at save time; JSON uses exclusive creation as well.
        for path in outputs.values():
            if path.exists() or path.is_symlink(): raise FileExistsError('refusing overwrite: '+str(path))
        app.draw(p)
        if 'screenshot' in outputs: p.screen.save(str(outputs['screenshot']),1)
        if 'state_json' in outputs:
            with outputs['state_json'].open('x',encoding='utf-8') as file:
                json.dump(app.snapshot(),file,ensure_ascii=False,indent=2); file.write('\n')
        p.quit()
    def update():
        nonlocal elapsed
        if p.btnp(p.KEY_ESCAPE): finish(); return
        if args.headless_smoke:
            if elapsed>=frames: finish(); return
            index=elapsed//2
            pressed={sequence[index]} if elapsed%2==0 and index<len(sequence) else set()
            app.control(pressed,pressed)
        else:
            app.control({k for k,v in keys.items() if p.btnp(v)}, {k for k,v in keys.items() if p.btn(v)})
        elapsed+=1
    p.run(update,lambda:app.draw(p))

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

満タンに入らない補給は、その場に残す

min・番号の対応・資源の全体量は変わらない。

今度は状況 B です。燃料タンクは9、B 地点の補給が3、価値の目標は7です。画面の REFUEL を選ぶと、今いる場所に残っている油を受け取ります。最初の資源の合計は 9+3=12 です。補給があるからといって、何度でも満タンに戻せるわけではありません。

supply は、地点 nodes と同じ順番の tuple です。今いる場所の番号 i を求め、タンクの空き capacity-fuel と、その場所の残り supply[i] の、小さいほうを受け取る量 amount にします。fuel に amount を足し、その場所から同じ amount を引きます。一時的に list に変えて1か所だけ変更し、最後に tuple に戻します。

たとえば荷物なしで H→A→B と進むと、fuel 7、B の油3 です。受け取る量は min(2,3)=2 なので、fuel 9、その場所の残り1 になります。同じ場所でもう一度補給を選んでも、満タンなので断られます。H に戻らずに B→A→B と往復すれば fuel 7 になり、残りの1を受け取って fuel 8、その場所の残り0 です。もう一度来ても、油は元に戻りません。

check の式「fuel+sum(supply)+spent」が最初の燃料の合計と等しいことは、毎回の操作のあとで確かめる「いつも成り立つはずの条件」です。補給は資源の場所を変えるだけで、spent を増やすのは移動だけです。check は内部のつじつまを確かめる assert で、プレイヤーの正しくないキー操作を受け付けるかどうかは、その前に apply が判断します。一番良い順番を教える式ではなく、資源が勝手に増えていないかを調べる式です。

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

03_finite_supply.py / 注目する部分
        amount = min(l.capacity - s.fuel, s.supply[i])
        if amount <= 0:
            return (s, 'supply')

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_finite_supply.py
macOS / Linux
.venv/bin/python 03_finite_supply.py
画面で見ることMOVE A → PICK a → MOVE B → PICK b → REFUEL → MOVE C → MOVE H と進めます。B に着いたとき fuel 5、補給後は8、最後は WON・VALUE 7/7・FUEL 0・その場所の油0 です。キーの順番は Z→右×3→Z→右×6→Z→右×3→Z→右×2→Z→右×4→Z→右×5→Z です。
  • 受け取る量は、タンクの空きと残りの、小さいほう
  • 受け取れなかった油は、その場所に残す
  • タンク+その場所の残り+使った量の合計は変わらない
つまずきやすいところ補給したあとの supply を、毎回 Level の最初の値に戻さないでください。capacity を足すのでもありません。タンクの空きより多く取って、余りを消してしまう作りも、この「合計は変わらない」ルールで見つけられます。
少し変えてみよう

コードを書き換えない、操作の課題です。R → MOVE A → MOVE B → REFUEL で FUEL 9、B に OIL 1 が残ります。もう一度 REFUEL を確定しても変わりません。続けて MOVE A → MOVE B → REFUEL で FUEL 8、OIL 0、SPENT 4 です。8+0+4=12 になることを確かめましょう。

03_finite_supply.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(301行)
03_finite_supply.py / 全文
# MIT License
# 
# Copyright (c) 2026
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""帰り道の燃料・教材3/5。Python 3.11+ / Pyxel 2.9.9。"""
from dataclasses import dataclass, replace, asdict
from collections import deque
from functools import lru_cache
import hashlib, json

@dataclass(frozen=True)
class Item:
    id: str
    place: str
    weight: int
    value: int

@dataclass(frozen=True)
class Level:
    title: str
    nodes: tuple
    edges: tuple
    items: tuple
    capacity: int
    initial_fuel: int
    supply: tuple
    goal: int
SQUARE = (('H', 'A', 1), ('A', 'B', 1), ('B', 'C', 1), ('C', 'H', 1))
LEVELS = (Level('同じ一周、違う帰り', ('H', 'A', 'B', 'C'), SQUARE, (Item('a', 'A', 1, 2), Item('b', 'B', 2, 4), Item('c', 'C', 1, 3)), 12, 12, (0, 0, 0, 0), 4), Level('補給の前後', ('H', 'A', 'B', 'C'), SQUARE, (Item('a', 'A', 2, 4), Item('b', 'B', 1, 3), Item('c', 'C', 2, 5)), 9, 9, (0, 0, 3, 0), 7), Level('重い主部品は最後に', ('H', 'A', 'B', 'C', 'D'), (('H', 'A', 1), ('A', 'B', 2), ('B', 'H', 2), ('H', 'C', 2), ('C', 'D', 1), ('D', 'B', 1)), (Item('a', 'A', 1, 2), Item('b', 'B', 3, 6), Item('c', 'C', 1, 3), Item('d', 'D', 1, 3)), 12, 12, (0, 0, 0, 0, 4), 6))

@dataclass(frozen=True)
class State:
    place: str
    fuel: int
    locations: tuple
    supply: tuple
    departed: bool = False
    spent: int = 0
    outcome: str = 'running'
REASONS = {'ready': '遠征は一回。基地Hへ戻ると終了です。', 'ok': '予告どおり確定しました。', 'fuel': '燃料不足。置く・補給・別の道を検討。', 'full': '荷重4を超えます。拾う前に置けます。', 'absent': 'この品は現在地にも荷台にもありません。', 'road': 'この二地点を結ぶ道はありません。', 'supply': 'タンク満杯か、この地点の補給が空です。', 'terminal': '帰還済み。Xで戻すかRで再開。', 'invalid': 'その操作はありません。', 'undo': '一つ前の操作へ戻りました。', 'no_undo': 'まだ戻せる操作はありません。'}

def initial(l):
    return State('H', l.initial_fuel, tuple((i.place for i in l.items)), l.supply)

def weight(s, l):
    return sum((i.weight for i, p in zip(l.items, s.locations) if p == 'T'))

def value(s, l):
    return sum((i.value for i, p in zip(l.items, s.locations) if p == 'T'))

def distance(l, a, b):
    return next((d for u, v, d in l.edges if (a, b) in ((u, v), (v, u))), None)

def actions(l):
    return tuple((('move', n) for n in l.nodes)) + tuple((('item', i) for i in range(len(l.items)))) + (('refuel',),)

def travel_cost(s, l, d, flat=False):
    return d * (1 if flat else 1 + weight(s, l))

def apply(s, l, a, flat=False):
    if s.outcome != 'running':
        return (s, 'terminal')
    if a not in actions(l):
        return (s, 'invalid')
    k = a[0]
    n = s
    if k == 'move':
        dst = a[1]
        d = distance(l, s.place, dst)
        if d is None:
            return (s, 'road')
        cost = travel_cost(s, l, d, flat)
        if s.fuel < cost:
            return (s, 'fuel')
        n = replace(s, place=dst, fuel=s.fuel - cost, spent=s.spent + cost, departed=True)
        if dst == 'H':
            n = replace(n, outcome='won' if value(n, l) >= l.goal else 'short')
    elif k == 'item':
        i = a[1]
        p = s.locations[i]
        if p not in ('T', s.place):
            return (s, 'absent')
        if p != 'T' and weight(s, l) + l.items[i].weight > 4:
            return (s, 'full')
        loc = list(s.locations)
        loc[i] = s.place if p == 'T' else 'T'
        n = replace(s, locations=tuple(loc))
    elif k == 'refuel':
        i = l.nodes.index(s.place)
        amount = min(l.capacity - s.fuel, s.supply[i])
        if amount <= 0:
            return (s, 'supply')
        sup = list(s.supply)
        sup[i] -= amount
        n = replace(s, fuel=s.fuel + amount, supply=tuple(sup))
    check(n, l)
    return (n, 'ok')

def check(s, l):
    assert 0 <= s.fuel <= l.capacity and 0 <= weight(s, l) <= 4 and (s.spent >= 0)
    assert s.fuel + sum(s.supply) + s.spent == l.initial_fuel + sum(l.supply)
    assert len(s.locations) == len(l.items) and all((p in l.nodes + ('T',) for p in s.locations))
    assert all((0 <= a <= b for a, b in zip(s.supply, l.supply)))
    assert s.outcome == 'running' or (s.place == 'H' and s.departed)

# ASCII-only learning UI. The canonical Japanese text/data remain in the model.
STAGE = 3
START_LEVEL = 1
MESSAGES = {'ready':'READY: SELECT AN ACTION, THEN Z', 'ok':'OK',
    'fuel':'NOT ENOUGH FUEL; NO STATE CHANGED', 'full':'LOAD WOULD EXCEED 4; NO STATE CHANGED',
    'absent':'ITEM IS NEITHER HERE NOR IN THE TRUCK', 'road':'NO DIRECT ROAD BETWEEN THESE NODES',
    'supply':'TANK FULL OR LOCAL SUPPLY EMPTY', 'terminal':'ALREADY HOME: R RESTARTS',
    'invalid':'INVALID ACTION', 'undo':'RESTORED THE PREVIOUS STATE',
    'no_undo':'NO EARLIER STATE'}

class App:
    def __init__(self):
        self.level_index = START_LEVEL
        self.reset()

    @property
    def board(self):
        return LEVELS[self.level_index]

    def reset(self):
        self.state = initial(self.board)
        self.selection = 1
        self.reason = 'ready'
        self.gated = False

    def options(self):
        choices = tuple(('move', n) for n in self.board.nodes)
        choices += tuple(('item', i) for i in range(len(self.board.items)))
        choices += (('refuel',),)
        return choices

    def act(self, action):
        self.state, self.reason = apply(self.state, self.board, action)

    def control(self, pressed, held):
        # A key must be released before another action; chords are rejected.
        if 'R' in pressed:
            self.reset(); self.gated = True; return
        if self.gated:
            if not held and not pressed: self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True; return
        if len(pressed) != 1: return
        key = next(iter(pressed)); self.gated = True

        if key in ('LEFT', 'RIGHT', 'UP', 'DOWN'):
            delta = {'LEFT':-1, 'RIGHT':1, 'UP':-5, 'DOWN':5}[key]
            self.selection = (self.selection + delta) % len(self.options())
        elif key in ('Z', 'SPACE'):
            self.act(self.options()[self.selection])

    def snapshot(self):
        return {'stage':STAGE, 'level':self.level_index, 'state':asdict(self.state),
                'selection':self.selection, 'reason':self.reason}

    def draw(self, p):
        s, l = self.state, self.board
        p.cls(1)
        p.text(14, 10, 'RETURN FUEL / STAGE '+str(STAGE)+' / SIMPLIFIED LEARNING UI', 10)
        p.text(14, 28, 'LEVEL '+'ABC'[self.level_index]+'   DISTANCE x (1 + CURRENT LOAD)', 7)
        p.text(364, 50, 'FUEL '+str(s.fuel)+'/'+str(l.capacity), 10)
        for i in range(l.capacity): p.rect(364+i*18, 64, 14, 6, 11 if i<s.fuel else 5)
        p.text(364, 80, 'LOAD '+str(weight(s,l))+'/4   VALUE '+str(value(s,l))+'/'+str(l.goal), 7)
        p.text(364, 96, 'SPENT '+str(s.spent)+'   LOCAL OIL '+str(sum(s.supply)), 6)
        coords = {'H':(46,98),'A':(300,98),'B':(300,214),'C':(46,214)}
        if self.level_index == 2:
            coords = {'H':(46,152),'A':(136,88),'B':(300,152),'C':(136,218),'D':(280,238)}
        for u,v,d in l.edges:
            x,y=coords[u]; xx,yy=coords[v]
            p.line(x,y,xx,yy,5)
            p.rect((x+xx)//2-4,(y+yy)//2-5,12,12,1)
            p.text((x+xx)//2,(y+yy)//2-2,str(d),13)
        for i,n in enumerate(l.nodes):
            x,y=coords[n]
            p.circ(x,y,14,3 if n=='H' else 2)
            p.circb(x,y,14,10 if n==s.place else 6)
            p.text(x-2,y-3,n,7)
            if n==s.place: p.rect(x-8,y+7,16,5,10)
            items=''.join(it.id for it,loc in zip(l.items,s.locations) if loc==n)
            p.text(x-14,y+19,'ITEM '+(items or '-'),10)
            if s.supply[i]: p.text(x-14,y-26,'OIL '+str(s.supply[i]),11)
        p.text(364, 123, 'ID   WEIGHT  VALUE  LOCATION', 13)
        for i,it in enumerate(l.items):
            y=142+25*i
            p.rect(358,y-5,266,20,3 if s.locations[i]=='T' else 2)
            place='TRUCK' if s.locations[i]=='T' else s.locations[i]
            p.text(364,y,it.id+'      '+str(it.weight)+'      '+str(it.value)+'      '+place,7)
        p.text(14,276,'Return to H once: VALUE >= GOAL wins; otherwise SHORT.',6)
        p.text(14,294,'Pickup/drop costs 0 fuel. Moves use the departure load.',13)
        for i,action in enumerate(self.options()):
            x=14+(i%5)*124; y=314+(i//5)*26
            p.rectb(x,y,118,23,10 if i==self.selection else 5)
            name = 'MOVE '+action[1] if action[0]=='move' else 'REFUEL'
            if action[0]=='item':
                idx=action[1]
                name=('DROP ' if s.locations[idx]=='T' else 'PICK ')+l.items[idx].id
            p.text(x+7,y+8,name,10 if i==self.selection else 7)
        p.text(14, 375, 'RESULT '+s.outcome.upper()+'   '+MESSAGES[self.reason],11 if s.outcome=='won' else 7)
        p.text(14,395,'Only this lesson UI restricts actions; the model remains explicit.',6)
        p.text(14, 412, 'ARROWS: SELECT   Z/SPACE: DO   R: RESET   ESC: QUIT',7)
        p.text(14, 429, 'FINITE SUPPLY: UNUSED OIL STAYS AT THE NODE / NO UNDO YET',13)
        p.text(14, 446, 'EDIT -> SAVE -> QUIT -> RERUN. R DOES NOT RELOAD CODE.',6)

def make_demo_keys():
    app=App(); keys=[]

    plan=[('move', 'A'), ('item', 0), ('move', 'B'), ('item', 1), ('refuel',), ('move', 'C'), ('move', 'H')]
    for action in plan:
        idx=app.options().index(action)
        while app.selection!=idx:
            keys.append('RIGHT'); app.selection=(app.selection+1)%len(app.options())
        keys.append('Z'); app.act(action)

    return tuple(keys)

def main(argv=None):
    import argparse
    from pathlib import Path
    import json
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke', action='store_true', help='bounded actual Pyxel render')
    parser.add_argument('--demo-checkpoint', action='store_true', help='replay documented keys')
    parser.add_argument('--frames', type=int, help='bounded headless frames, 1..10000')
    parser.add_argument('--screenshot', help='new PNG; relative to launch working directory')
    parser.add_argument('--state-json', help='new JSON; relative to launch working directory')
    args=parser.parse_args(argv)
    if (args.demo_checkpoint or args.frames is not None or args.screenshot or args.state_json) and not args.headless_smoke:
        parser.error('--demo-checkpoint, --frames and outputs require --headless-smoke')
    if args.frames is not None and not 1<=args.frames<=10000:
        parser.error('--frames must be an integer from 1 to 10000')
    sequence=make_demo_keys() if args.demo_checkpoint else ()
    minimum=len(sequence)*2+2 if sequence else 1
    frames=args.frames if args.frames is not None else max(8,minimum)
    if frames<minimum: parser.error('--frames is too short for the full checkpoint; need '+str(minimum))
    # Resolve EVERY output before importing/initializing Pyxel, which may chdir.
    outputs={}
    for name,raw,suffix in (('screenshot',args.screenshot,'.png'),('state_json',args.state_json,'.json')):
        if raw is None: continue
        path=Path(raw).expanduser()
        if not raw or path.suffix.lower()!=suffix or not path.parent.is_dir() or path.exists() or path.is_symlink():
            parser.error(name+' must be a new '+suffix+' file in an existing directory; refusing overwrite')
        outputs[name]=path.resolve()
    if len(set(outputs.values()))!=len(outputs): parser.error('output paths must differ')
    import pyxel as p
    p.init(640,466,title='Return fuel lesson '+str(STAGE),fps=30,display_scale=2,headless=args.headless_smoke)
    app=App()
    names=('LEFT','RIGHT','UP','DOWN','Z','SPACE','R','X','1','2','3')
    keys={name:getattr(p,'KEY_'+name) for name in names}
    elapsed=0
    def finish():
        # Refuse again at save time; JSON uses exclusive creation as well.
        for path in outputs.values():
            if path.exists() or path.is_symlink(): raise FileExistsError('refusing overwrite: '+str(path))
        app.draw(p)
        if 'screenshot' in outputs: p.screen.save(str(outputs['screenshot']),1)
        if 'state_json' in outputs:
            with outputs['state_json'].open('x',encoding='utf-8') as file:
                json.dump(app.snapshot(),file,ensure_ascii=False,indent=2); file.write('\n')
        p.quit()
    def update():
        nonlocal elapsed
        if p.btnp(p.KEY_ESCAPE): finish(); return
        if args.headless_smoke:
            if elapsed>=frames: finish(); return
            index=elapsed//2
            pressed={sequence[index]} if elapsed%2==0 and index<len(sequence) else set()
            app.control(pressed,pressed)
        else:
            app.control({k for k,v in keys.items() if p.btnp(v)}, {k for k,v in keys.items() if p.btn(v)})
        elapsed+=1
    p.run(update,lambda:app.draw(p))

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

単純な帰路費と、補給込みの帰還可能性を分ける

状態を変えない予告・一番短い距離・幅優先探索。

1/2/3 キーで、元のゲームの状況 A/B/C を最初の状態から選べます。PREVIEW は、選んでいる札を apply した、仮の State を表示します。カーソルを動かすだけでは、今の State は変わりません。HOME COST は、今の重さのまま、補給せずに一番短い道で H へ行く場合に使う燃料です。地図の距離を探してから、同じ重さの単価を掛けています。

HOME COST が手持ちの燃料より多いだけでは、帰れないとは決められません。状況 B のように、途中の油を使える場合があるからです。return_plan は、今の State から deque を使って、近い手順から順に(幅優先で)候補を調べ、移動と限りのある補給だけで H に着く道順を探します。KEEP CARGO: YES なら、荷台の品物を持ったまま帰る方法があります。None なら、その条件では見つかりません。

荷物を持ったままでは帰れないときだけ、allow_drop=True で、積んでいる品物を地面に置く操作も調べます。KEEP: NO / DROP: YES は、「荷物を減らせば帰れる」という意味です。置いた品物の価値も失うので、目標の達成までは約束しません。この探索には、地面の新しい品物を拾う手は含めません。返す道順は、手数の少ない順で最初に見つかった帰り方で、価値が一番多い道順や、燃料が一番少ない道順を探したものではありません。

状況 C で b を先に拾って D へ運び、補給して d も積むと、fuel 10・重さ4・HOME COST 15 になります。荷物を持ったままでは帰れなくても、b を D に置けば、軽くなって帰れます。品物は消えず、置いた場所に残ります。A/B/C の数値も、移動の式も、元のゲームのままです。最初から H にいると return_plan は空の道順を返すので、開始画面の YES は「もう基地にいる」という意味で、出発したあとの成功の計画ではありません。

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

04_return_forecast.py / 注目する部分
        acts = [('move', n) for n in l.nodes] + [('refuel',)]
        if allow_drop:
            acts += [('item', i) for i, p in enumerate(st.locations) if p == 'T']

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_return_forecast.py
macOS / Linux
.venv/bin/python 04_return_forecast.py
画面で見ること3 → MOVE B → PICK b → MOVE D → REFUEL → PICK d と進めます。D で FUEL 10・LOAD 4・VALUE 9/6、HOME COST 15、KEEP: NO / DROP: YES です。キーの順番は 3→右×1→Z→右×4→Z→右×8→Z→右×5→Z→右×9→Z です。
  • 予告しても、本体の状態は変えない
  • 補給なしの燃料と、補給も含めた探索を区別する
  • 帰れても、価値の目標を達成できるとは限らない
つまずきやすいところ「帰り方がある」を「必要な価値を持ち帰れる」と読みかえないでください。一番短い帰り道の数値は補給なし、KEEP/DROP の可否は限りのある補給も含めた結果で、前提が違います。
少し変えてみよう

上の D の状態から、DROP b → MOVE C → PICK c → MOVE H と進めてみましょう。WON・VALUE 6/6・FUEL 2 で、b は D に残ります。DROP した直後の予測には、c を新しく拾うことは含まれていません。自分で拾う判断を加えたことを説明してみてください。コードの書き換えは不要です。

04_return_forecast.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(356行)
04_return_forecast.py / 全文
# MIT License
# 
# Copyright (c) 2026
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""帰り道の燃料・教材4/5。Python 3.11+ / Pyxel 2.9.9。"""
from dataclasses import dataclass, replace, asdict
from collections import deque
from functools import lru_cache
import hashlib, json

@dataclass(frozen=True)
class Item:
    id: str
    place: str
    weight: int
    value: int

@dataclass(frozen=True)
class Level:
    title: str
    nodes: tuple
    edges: tuple
    items: tuple
    capacity: int
    initial_fuel: int
    supply: tuple
    goal: int
SQUARE = (('H', 'A', 1), ('A', 'B', 1), ('B', 'C', 1), ('C', 'H', 1))
LEVELS = (Level('同じ一周、違う帰り', ('H', 'A', 'B', 'C'), SQUARE, (Item('a', 'A', 1, 2), Item('b', 'B', 2, 4), Item('c', 'C', 1, 3)), 12, 12, (0, 0, 0, 0), 4), Level('補給の前後', ('H', 'A', 'B', 'C'), SQUARE, (Item('a', 'A', 2, 4), Item('b', 'B', 1, 3), Item('c', 'C', 2, 5)), 9, 9, (0, 0, 3, 0), 7), Level('重い主部品は最後に', ('H', 'A', 'B', 'C', 'D'), (('H', 'A', 1), ('A', 'B', 2), ('B', 'H', 2), ('H', 'C', 2), ('C', 'D', 1), ('D', 'B', 1)), (Item('a', 'A', 1, 2), Item('b', 'B', 3, 6), Item('c', 'C', 1, 3), Item('d', 'D', 1, 3)), 12, 12, (0, 0, 0, 0, 4), 6))

@dataclass(frozen=True)
class State:
    place: str
    fuel: int
    locations: tuple
    supply: tuple
    departed: bool = False
    spent: int = 0
    outcome: str = 'running'
REASONS = {'ready': '遠征は一回。基地Hへ戻ると終了です。', 'ok': '予告どおり確定しました。', 'fuel': '燃料不足。置く・補給・別の道を検討。', 'full': '荷重4を超えます。拾う前に置けます。', 'absent': 'この品は現在地にも荷台にもありません。', 'road': 'この二地点を結ぶ道はありません。', 'supply': 'タンク満杯か、この地点の補給が空です。', 'terminal': '帰還済み。Xで戻すかRで再開。', 'invalid': 'その操作はありません。', 'undo': '一つ前の操作へ戻りました。', 'no_undo': 'まだ戻せる操作はありません。'}

def initial(l):
    return State('H', l.initial_fuel, tuple((i.place for i in l.items)), l.supply)

def weight(s, l):
    return sum((i.weight for i, p in zip(l.items, s.locations) if p == 'T'))

def value(s, l):
    return sum((i.value for i, p in zip(l.items, s.locations) if p == 'T'))

def distance(l, a, b):
    return next((d for u, v, d in l.edges if (a, b) in ((u, v), (v, u))), None)

def actions(l):
    return tuple((('move', n) for n in l.nodes)) + tuple((('item', i) for i in range(len(l.items)))) + (('refuel',),)

def travel_cost(s, l, d, flat=False):
    return d * (1 if flat else 1 + weight(s, l))

def apply(s, l, a, flat=False):
    if s.outcome != 'running':
        return (s, 'terminal')
    if a not in actions(l):
        return (s, 'invalid')
    k = a[0]
    n = s
    if k == 'move':
        dst = a[1]
        d = distance(l, s.place, dst)
        if d is None:
            return (s, 'road')
        cost = travel_cost(s, l, d, flat)
        if s.fuel < cost:
            return (s, 'fuel')
        n = replace(s, place=dst, fuel=s.fuel - cost, spent=s.spent + cost, departed=True)
        if dst == 'H':
            n = replace(n, outcome='won' if value(n, l) >= l.goal else 'short')
    elif k == 'item':
        i = a[1]
        p = s.locations[i]
        if p not in ('T', s.place):
            return (s, 'absent')
        if p != 'T' and weight(s, l) + l.items[i].weight > 4:
            return (s, 'full')
        loc = list(s.locations)
        loc[i] = s.place if p == 'T' else 'T'
        n = replace(s, locations=tuple(loc))
    elif k == 'refuel':
        i = l.nodes.index(s.place)
        amount = min(l.capacity - s.fuel, s.supply[i])
        if amount <= 0:
            return (s, 'supply')
        sup = list(s.supply)
        sup[i] -= amount
        n = replace(s, fuel=s.fuel + amount, supply=tuple(sup))
    check(n, l)
    return (n, 'ok')

def check(s, l):
    assert 0 <= s.fuel <= l.capacity and 0 <= weight(s, l) <= 4 and (s.spent >= 0)
    assert s.fuel + sum(s.supply) + s.spent == l.initial_fuel + sum(l.supply)
    assert len(s.locations) == len(l.items) and all((p in l.nodes + ('T',) for p in s.locations))
    assert all((0 <= a <= b for a, b in zip(s.supply, l.supply)))
    assert s.outcome == 'running' or (s.place == 'H' and s.departed)

@lru_cache(None)
def shortest_home(l, start):
    dist = {n: 999 for n in l.nodes}
    dist[start] = 0
    q = [start]
    while q:
        u = min(q, key=lambda n: dist[n])
        q.remove(u)
        for v in l.nodes:
            d = distance(l, u, v)
            if d is not None and dist[v] > dist[u] + d:
                dist[v] = dist[u] + d
                q.append(v)
    return dist['H']

def home_cost(s, l, flat=False):
    return travel_cost(s, l, shortest_home(l, s.place), flat)

@lru_cache(4096)
def return_plan(s, l, flat=False, allow_drop=False):
    """Exact reachable return with current cargo, finite refuels and optional drops."""
    if s.place == 'H':
        return ()
    q = deque([(s, ())])
    seen = {s}
    while q:
        st, path = q.popleft()
        acts = [('move', n) for n in l.nodes] + [('refuel',)]
        if allow_drop:
            acts += [('item', i) for i, p in enumerate(st.locations) if p == 'T']
        for a in acts:
            ns, r = apply(st, l, a, flat)
            if ns == st or ns in seen:
                continue
            if ns.place == 'H':
                return path + (a,)
            seen.add(ns)
            q.append((ns, path + (a,)))
    return None

def forecast(s, l, a, flat=False):
    ns, r = apply(s, l, a, flat)
    return {'state': ns, 'reason': r, 'move_cost': s.fuel - ns.fuel if a[0] == 'move' and ns != s else 0, 'home_before': home_cost(s, l, flat), 'home_after': home_cost(ns, l, flat), 'weight_after': weight(ns, l)}

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

# ASCII-only learning UI. The canonical Japanese text/data remain in the model.
STAGE = 4
START_LEVEL = 0
MESSAGES = {'ready':'READY: SELECT AN ACTION, THEN Z', 'ok':'OK',
    'fuel':'NOT ENOUGH FUEL; NO STATE CHANGED', 'full':'LOAD WOULD EXCEED 4; NO STATE CHANGED',
    'absent':'ITEM IS NEITHER HERE NOR IN THE TRUCK', 'road':'NO DIRECT ROAD BETWEEN THESE NODES',
    'supply':'TANK FULL OR LOCAL SUPPLY EMPTY', 'terminal':'ALREADY HOME: R RESTARTS',
    'invalid':'INVALID ACTION', 'undo':'RESTORED THE PREVIOUS STATE',
    'no_undo':'NO EARLIER STATE'}

class App:
    def __init__(self):
        self.level_index = START_LEVEL
        self.reset()

    @property
    def board(self):
        return LEVELS[self.level_index]

    def reset(self):
        self.state = initial(self.board)
        self.selection = 1
        self.reason = 'ready'
        self.gated = False

    def options(self):
        choices = tuple(('move', n) for n in self.board.nodes)
        choices += tuple(('item', i) for i in range(len(self.board.items)))
        choices += (('refuel',),)
        return choices

    def act(self, action):
        self.state, self.reason = apply(self.state, self.board, action)

    def control(self, pressed, held):
        # A key must be released before another action; chords are rejected.
        if 'R' in pressed:
            self.reset(); self.gated = True; return
        if self.gated:
            if not held and not pressed: self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True; return
        if len(pressed) != 1: return
        key = next(iter(pressed)); self.gated = True
        if key in ('1','2','3'):
            self.level_index = int(key)-1; self.reset(); self.gated=True; return

        if key in ('LEFT', 'RIGHT', 'UP', 'DOWN'):
            delta = {'LEFT':-1, 'RIGHT':1, 'UP':-5, 'DOWN':5}[key]
            self.selection = (self.selection + delta) % len(self.options())
        elif key in ('Z', 'SPACE'):
            self.act(self.options()[self.selection])

    def snapshot(self):
        return {'stage':STAGE, 'level':self.level_index, 'state':asdict(self.state),
                'selection':self.selection, 'reason':self.reason}

    def draw(self, p):
        s, l = self.state, self.board
        p.cls(1)
        p.text(14, 10, 'RETURN FUEL / STAGE '+str(STAGE)+' / SIMPLIFIED LEARNING UI', 10)
        p.text(14, 28, 'LEVEL '+'ABC'[self.level_index]+'   DISTANCE x (1 + CURRENT LOAD)', 7)
        p.text(364, 50, 'FUEL '+str(s.fuel)+'/'+str(l.capacity), 10)
        for i in range(l.capacity): p.rect(364+i*18, 64, 14, 6, 11 if i<s.fuel else 5)
        p.text(364, 80, 'LOAD '+str(weight(s,l))+'/4   VALUE '+str(value(s,l))+'/'+str(l.goal), 7)
        p.text(364, 96, 'SPENT '+str(s.spent)+'   LOCAL OIL '+str(sum(s.supply)), 6)
        coords = {'H':(46,98),'A':(300,98),'B':(300,214),'C':(46,214)}
        if self.level_index == 2:
            coords = {'H':(46,152),'A':(136,88),'B':(300,152),'C':(136,218),'D':(280,238)}
        for u,v,d in l.edges:
            x,y=coords[u]; xx,yy=coords[v]
            p.line(x,y,xx,yy,5)
            p.rect((x+xx)//2-4,(y+yy)//2-5,12,12,1)
            p.text((x+xx)//2,(y+yy)//2-2,str(d),13)
        for i,n in enumerate(l.nodes):
            x,y=coords[n]
            p.circ(x,y,14,3 if n=='H' else 2)
            p.circb(x,y,14,10 if n==s.place else 6)
            p.text(x-2,y-3,n,7)
            if n==s.place: p.rect(x-8,y+7,16,5,10)
            items=''.join(it.id for it,loc in zip(l.items,s.locations) if loc==n)
            p.text(x-14,y+19,'ITEM '+(items or '-'),10)
            if s.supply[i]: p.text(x-14,y-26,'OIL '+str(s.supply[i]),11)
        p.text(364, 123, 'ID   WEIGHT  VALUE  LOCATION', 13)
        for i,it in enumerate(l.items):
            y=142+25*i
            p.rect(358,y-5,266,20,3 if s.locations[i]=='T' else 2)
            place='TRUCK' if s.locations[i]=='T' else s.locations[i]
            p.text(364,y,it.id+'      '+str(it.weight)+'      '+str(it.value)+'      '+place,7)
        selected=self.options()[self.selection]
        f=forecast(s,l,selected)
        ns=f['state']
        p.text(14,274,'PREVIEW '+f['reason'].upper()+'   FUEL '+str(s.fuel)+'>'+str(ns.fuel)+'   LOAD '+str(weight(s,l))+'>'+str(weight(ns,l)),6)
        keep=return_plan(s,l)
        drop=return_plan(s,l,allow_drop=True) if keep is None else None
        advice='KEEP CARGO: YES' if keep is not None else ('KEEP: NO / DROP: YES' if drop is not None else 'NO RETURN FOUND')
        p.text(14,292,'HOME COST (NO REFUEL) '+str(home_cost(s,l))+'   '+advice,13)
        for i,action in enumerate(self.options()):
            x=14+(i%5)*124; y=314+(i//5)*26
            p.rectb(x,y,118,23,10 if i==self.selection else 5)
            name = 'MOVE '+action[1] if action[0]=='move' else 'REFUEL'
            if action[0]=='item':
                idx=action[1]
                name=('DROP ' if s.locations[idx]=='T' else 'PICK ')+l.items[idx].id
            p.text(x+7,y+8,name,10 if i==self.selection else 7)
        p.text(14, 375, 'RESULT '+s.outcome.upper()+'   '+MESSAGES[self.reason],11 if s.outcome=='won' else 7)
        p.text(14,395,'Return plan is not a promise of reaching the value goal.',6)
        p.text(14, 412, 'ARROWS: SELECT   Z/SPACE: DO   R: RESET   ESC: QUIT',7)
        p.text(14, 429, '1/2/3: SWITCH LEVEL / RETURN SEARCH MAY DROP CARGO',13)
        p.text(14, 446, 'EDIT -> SAVE -> QUIT -> RERUN. R DOES NOT RELOAD CODE.',6)

def make_demo_keys():
    app=App(); keys=[]
    keys.append('3'); app.level_index=2; app.reset()
    plan=[('move', 'B'), ('item', 1), ('move', 'D'), ('refuel',), ('item', 3)]
    for action in plan:
        idx=app.options().index(action)
        while app.selection!=idx:
            keys.append('RIGHT'); app.selection=(app.selection+1)%len(app.options())
        keys.append('Z'); app.act(action)

    return tuple(keys)

def main(argv=None):
    import argparse
    from pathlib import Path
    import json
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke', action='store_true', help='bounded actual Pyxel render')
    parser.add_argument('--demo-checkpoint', action='store_true', help='replay documented keys')
    parser.add_argument('--frames', type=int, help='bounded headless frames, 1..10000')
    parser.add_argument('--screenshot', help='new PNG; relative to launch working directory')
    parser.add_argument('--state-json', help='new JSON; relative to launch working directory')
    args=parser.parse_args(argv)
    if (args.demo_checkpoint or args.frames is not None or args.screenshot or args.state_json) and not args.headless_smoke:
        parser.error('--demo-checkpoint, --frames and outputs require --headless-smoke')
    if args.frames is not None and not 1<=args.frames<=10000:
        parser.error('--frames must be an integer from 1 to 10000')
    sequence=make_demo_keys() if args.demo_checkpoint else ()
    minimum=len(sequence)*2+2 if sequence else 1
    frames=args.frames if args.frames is not None else max(8,minimum)
    if frames<minimum: parser.error('--frames is too short for the full checkpoint; need '+str(minimum))
    # Resolve EVERY output before importing/initializing Pyxel, which may chdir.
    outputs={}
    for name,raw,suffix in (('screenshot',args.screenshot,'.png'),('state_json',args.state_json,'.json')):
        if raw is None: continue
        path=Path(raw).expanduser()
        if not raw or path.suffix.lower()!=suffix or not path.parent.is_dir() or path.exists() or path.is_symlink():
            parser.error(name+' must be a new '+suffix+' file in an existing directory; refusing overwrite')
        outputs[name]=path.resolve()
    if len(set(outputs.values()))!=len(outputs): parser.error('output paths must differ')
    import pyxel as p
    p.init(640,466,title='Return fuel lesson '+str(STAGE),fps=30,display_scale=2,headless=args.headless_smoke)
    app=App()
    names=('LEFT','RIGHT','UP','DOWN','Z','SPACE','R','X','1','2','3')
    keys={name:getattr(p,'KEY_'+name) for name in names}
    elapsed=0
    def finish():
        # Refuse again at save time; JSON uses exclusive creation as well.
        for path in outputs.values():
            if path.exists() or path.is_symlink(): raise FileExistsError('refusing overwrite: '+str(path))
        app.draw(p)
        if 'screenshot' in outputs: p.screen.save(str(outputs['screenshot']),1)
        if 'state_json' in outputs:
            with outputs['state_json'].open('x',encoding='utf-8') as file:
                json.dump(app.snapshot(),file,ensure_ascii=False,indent=2); file.write('\n')
        p.quit()
    def update():
        nonlocal elapsed
        if p.btnp(p.KEY_ESCAPE): finish(); return
        if args.headless_smoke:
            if elapsed>=frames: finish(); return
            index=elapsed//2
            pressed={sequence[index]} if elapsed%2==0 and index<len(sequence) else set()
            app.control(pressed,pressed)
        else:
            app.control({k for k,v in keys.items() if p.btnp(v)}, {k for k,v in keys.items() if p.btn(v)})
        elapsed+=1
    p.run(update,lambda:app.draw(p))

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

燃料・補給・部品・結果を一緒に巻き戻す

書き換えない状態・履歴・ゲームのルールと画面の境目。

最後に Timeline と step をつなぎ、X で、成功した1つの操作を取り消します。この段階には、元のゲームの model.py のクラス・関数・定数がすべて入っています。State には、今いる場所、燃料、品物の場所、各地点の補給の残り、出発したか、使った燃料の合計、結果がまとまっています。履歴は、古い State を並べた tuple です。燃料だけを戻して、油の残りを戻し忘れる、といった中途半端な Undo を防げます。

step は、apply の結果が今と違うときだけ、変える前の State を history に追加します。断られた移動、空の補給、遠くの品物では、履歴は増えません。拾う・置くは燃料0でも品物の場所が変わるので、1つの操作として残ります。画面のカーソルの移動も、履歴には入れません。

Undo の分かれ道は apply より前にあるので、WON や SHORT になったあとでも X を使えます。H への最後の移動を取り消すと、帰る前の場所、積み荷、燃料、まだ終わっていない状態に戻ります。もう一度 X を押すと、その前の拾う操作を取り消します。履歴が空なら no_undo です。R は今の状況の最初の状態に戻し、履歴も消します。

この画面では、カーソルは Undo で戻しません。たとえば最後に MOVE H を選んでいたなら、X のあとも MOVE H の札のままです。1/2/3 での状況の切り替えは、新しい遠征として最初からになります。元のゲームの表紙、説明での一時停止、録画、日本語のドット文字、状況 B の比較の切り替えは省いています。重さを無視する比較用のルールの関数(flat)は残してテストしていますが、ふつうの画面は「重さあり」に固定です。たどり着けるすべての State での移動のルールの照合は、限られたデータでの検査で、自由に作り変えた盤面や、人にとっての楽しさまでは保証しません。

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

05_undo.py / 注目する部分
    if a == ('undo',):
        return (Timeline(tl.history[-1], tl.history[:-1]), 'undo') if tl.history else (tl, 'no_undo')

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_undo.py
macOS / Linux
.venv/bin/python 05_undo.py
画面で見ること3 → MOVE C → MOVE D → REFUEL → PICK d → MOVE B → PICK b → MOVE H で、WON、VALUE 9/6、FUEL 0、HISTORY 7 です。X を押すと、B・FUEL 10・LOAD 4・RUNNING・HISTORY 6 に戻ります。自動確認も、この X を押した直後です。
  • 品物の場所・資源・結果を、同じ State で元に戻す
  • 何も変わらない操作と、カーソルの操作は、履歴に入れない
  • 帰り着いたあとも、Undo を先に受け付ける
つまずきやすいところ「最後に押したキー」を戻すのではなく、State が変わった操作を戻します。カーソルや失敗した操作を同じ履歴に混ぜると、何を1手として戻すのかが変わってしまいます。X は、ソースコードの書き換えを戻す Undo でもありません。
少し変えてみよう

上の WON から X を2回押してみましょう。B・FUEL 10・LOAD 1・VALUE 3/6・HISTORY 5 になり、b は B の地面に戻ります。PICK b → MOVE H を確定すると、また WON・FUEL 0・HISTORY 7 です。油は D に1残ったままです。コードを変えたときは、保存 → Esc で終了 → 同じ 05_undo.py を起動し直します。R は、読みこみ済みのコードのまま、状態を最初に戻すだけです。

05_undo.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(373行)
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。Python 3.11+ / Pyxel 2.9.9。"""
from dataclasses import dataclass, replace, asdict
from collections import deque
from functools import lru_cache
import hashlib, json

@dataclass(frozen=True)
class Item:
    id: str
    place: str
    weight: int
    value: int

@dataclass(frozen=True)
class Level:
    title: str
    nodes: tuple
    edges: tuple
    items: tuple
    capacity: int
    initial_fuel: int
    supply: tuple
    goal: int
SQUARE = (('H', 'A', 1), ('A', 'B', 1), ('B', 'C', 1), ('C', 'H', 1))
LEVELS = (Level('同じ一周、違う帰り', ('H', 'A', 'B', 'C'), SQUARE, (Item('a', 'A', 1, 2), Item('b', 'B', 2, 4), Item('c', 'C', 1, 3)), 12, 12, (0, 0, 0, 0), 4), Level('補給の前後', ('H', 'A', 'B', 'C'), SQUARE, (Item('a', 'A', 2, 4), Item('b', 'B', 1, 3), Item('c', 'C', 2, 5)), 9, 9, (0, 0, 3, 0), 7), Level('重い主部品は最後に', ('H', 'A', 'B', 'C', 'D'), (('H', 'A', 1), ('A', 'B', 2), ('B', 'H', 2), ('H', 'C', 2), ('C', 'D', 1), ('D', 'B', 1)), (Item('a', 'A', 1, 2), Item('b', 'B', 3, 6), Item('c', 'C', 1, 3), Item('d', 'D', 1, 3)), 12, 12, (0, 0, 0, 0, 4), 6))

@dataclass(frozen=True)
class State:
    place: str
    fuel: int
    locations: tuple
    supply: tuple
    departed: bool = False
    spent: int = 0
    outcome: str = 'running'

@dataclass(frozen=True)
class Timeline:
    state: State
    history: tuple = ()
REASONS = {'ready': '遠征は一回。基地Hへ戻ると終了です。', 'ok': '予告どおり確定しました。', 'fuel': '燃料不足。置く・補給・別の道を検討。', 'full': '荷重4を超えます。拾う前に置けます。', 'absent': 'この品は現在地にも荷台にもありません。', 'road': 'この二地点を結ぶ道はありません。', 'supply': 'タンク満杯か、この地点の補給が空です。', 'terminal': '帰還済み。Xで戻すかRで再開。', 'invalid': 'その操作はありません。', 'undo': '一つ前の操作へ戻りました。', 'no_undo': 'まだ戻せる操作はありません。'}

def initial(l):
    return State('H', l.initial_fuel, tuple((i.place for i in l.items)), l.supply)

def weight(s, l):
    return sum((i.weight for i, p in zip(l.items, s.locations) if p == 'T'))

def value(s, l):
    return sum((i.value for i, p in zip(l.items, s.locations) if p == 'T'))

def distance(l, a, b):
    return next((d for u, v, d in l.edges if (a, b) in ((u, v), (v, u))), None)

def actions(l):
    return tuple((('move', n) for n in l.nodes)) + tuple((('item', i) for i in range(len(l.items)))) + (('refuel',),)

def travel_cost(s, l, d, flat=False):
    return d * (1 if flat else 1 + weight(s, l))

def apply(s, l, a, flat=False):
    if s.outcome != 'running':
        return (s, 'terminal')
    if a not in actions(l):
        return (s, 'invalid')
    k = a[0]
    n = s
    if k == 'move':
        dst = a[1]
        d = distance(l, s.place, dst)
        if d is None:
            return (s, 'road')
        cost = travel_cost(s, l, d, flat)
        if s.fuel < cost:
            return (s, 'fuel')
        n = replace(s, place=dst, fuel=s.fuel - cost, spent=s.spent + cost, departed=True)
        if dst == 'H':
            n = replace(n, outcome='won' if value(n, l) >= l.goal else 'short')
    elif k == 'item':
        i = a[1]
        p = s.locations[i]
        if p not in ('T', s.place):
            return (s, 'absent')
        if p != 'T' and weight(s, l) + l.items[i].weight > 4:
            return (s, 'full')
        loc = list(s.locations)
        loc[i] = s.place if p == 'T' else 'T'
        n = replace(s, locations=tuple(loc))
    elif k == 'refuel':
        i = l.nodes.index(s.place)
        amount = min(l.capacity - s.fuel, s.supply[i])
        if amount <= 0:
            return (s, 'supply')
        sup = list(s.supply)
        sup[i] -= amount
        n = replace(s, fuel=s.fuel + amount, supply=tuple(sup))
    check(n, l)
    return (n, 'ok')

def step(tl, l, a, flat=False):
    if a == ('undo',):
        return (Timeline(tl.history[-1], tl.history[:-1]), 'undo') if tl.history else (tl, 'no_undo')
    n, r = apply(tl.state, l, a, flat)
    return (Timeline(n, tl.history + (tl.state,)) if n != tl.state else tl, r)

def check(s, l):
    assert 0 <= s.fuel <= l.capacity and 0 <= weight(s, l) <= 4 and (s.spent >= 0)
    assert s.fuel + sum(s.supply) + s.spent == l.initial_fuel + sum(l.supply)
    assert len(s.locations) == len(l.items) and all((p in l.nodes + ('T',) for p in s.locations))
    assert all((0 <= a <= b for a, b in zip(s.supply, l.supply)))
    assert s.outcome == 'running' or (s.place == 'H' and s.departed)

@lru_cache(None)
def shortest_home(l, start):
    dist = {n: 999 for n in l.nodes}
    dist[start] = 0
    q = [start]
    while q:
        u = min(q, key=lambda n: dist[n])
        q.remove(u)
        for v in l.nodes:
            d = distance(l, u, v)
            if d is not None and dist[v] > dist[u] + d:
                dist[v] = dist[u] + d
                q.append(v)
    return dist['H']

def home_cost(s, l, flat=False):
    return travel_cost(s, l, shortest_home(l, s.place), flat)

@lru_cache(4096)
def return_plan(s, l, flat=False, allow_drop=False):
    """Exact reachable return with current cargo, finite refuels and optional drops."""
    if s.place == 'H':
        return ()
    q = deque([(s, ())])
    seen = {s}
    while q:
        st, path = q.popleft()
        acts = [('move', n) for n in l.nodes] + [('refuel',)]
        if allow_drop:
            acts += [('item', i) for i, p in enumerate(st.locations) if p == 'T']
        for a in acts:
            ns, r = apply(st, l, a, flat)
            if ns == st or ns in seen:
                continue
            if ns.place == 'H':
                return path + (a,)
            seen.add(ns)
            q.append((ns, path + (a,)))
    return None

def forecast(s, l, a, flat=False):
    ns, r = apply(s, l, a, flat)
    return {'state': ns, 'reason': r, 'move_cost': s.fuel - ns.fuel if a[0] == 'move' and ns != s else 0, 'home_before': home_cost(s, l, flat), 'home_after': home_cost(ns, l, flat), 'weight_after': weight(ns, l)}

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

# ASCII-only learning UI. The canonical Japanese text/data remain in the model.
STAGE = 5
START_LEVEL = 0
MESSAGES = {'ready':'READY: SELECT AN ACTION, THEN Z', 'ok':'OK',
    'fuel':'NOT ENOUGH FUEL; NO STATE CHANGED', 'full':'LOAD WOULD EXCEED 4; NO STATE CHANGED',
    'absent':'ITEM IS NEITHER HERE NOR IN THE TRUCK', 'road':'NO DIRECT ROAD BETWEEN THESE NODES',
    'supply':'TANK FULL OR LOCAL SUPPLY EMPTY', 'terminal':'ALREADY HOME: R RESTARTS',
    'invalid':'INVALID ACTION', 'undo':'RESTORED THE PREVIOUS STATE',
    'no_undo':'NO EARLIER STATE'}

class App:
    def __init__(self):
        self.level_index = START_LEVEL
        self.reset()

    @property
    def board(self):
        return LEVELS[self.level_index]

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

    def reset(self):
        self.timeline = Timeline(initial(self.board))
        self.selection = 1
        self.reason = 'ready'
        self.gated = False

    def options(self):
        choices = tuple(('move', n) for n in self.board.nodes)
        choices += tuple(('item', i) for i in range(len(self.board.items)))
        choices += (('refuel',),)
        return choices

    def act(self, action):
        self.timeline, self.reason = step(self.timeline, self.board, action)

    def control(self, pressed, held):
        # A key must be released before another action; chords are rejected.
        if 'R' in pressed:
            self.reset(); self.gated = True; return
        if self.gated:
            if not held and not pressed: self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True; return
        if len(pressed) != 1: return
        key = next(iter(pressed)); self.gated = True
        if key in ('1','2','3'):
            self.level_index = int(key)-1; self.reset(); self.gated=True; return
        if key == 'X':
            self.act(('undo',)); return

        if key in ('LEFT', 'RIGHT', 'UP', 'DOWN'):
            delta = {'LEFT':-1, 'RIGHT':1, 'UP':-5, 'DOWN':5}[key]
            self.selection = (self.selection + delta) % len(self.options())
        elif key in ('Z', 'SPACE'):
            self.act(self.options()[self.selection])

    def snapshot(self):
        return {'stage':STAGE, 'level':self.level_index, 'state':asdict(self.state),
                'selection':self.selection, 'reason':self.reason, 'history':[asdict(s) for s in self.timeline.history]}

    def draw(self, p):
        s, l = self.state, self.board
        p.cls(1)
        p.text(14, 10, 'RETURN FUEL / STAGE '+str(STAGE)+' / SIMPLIFIED LEARNING UI', 10)
        p.text(14, 28, 'LEVEL '+'ABC'[self.level_index]+'   DISTANCE x (1 + CURRENT LOAD)', 7)
        p.text(364, 50, 'FUEL '+str(s.fuel)+'/'+str(l.capacity), 10)
        for i in range(l.capacity): p.rect(364+i*18, 64, 14, 6, 11 if i<s.fuel else 5)
        p.text(364, 80, 'LOAD '+str(weight(s,l))+'/4   VALUE '+str(value(s,l))+'/'+str(l.goal), 7)
        p.text(364, 96, 'SPENT '+str(s.spent)+'   LOCAL OIL '+str(sum(s.supply)), 6)
        coords = {'H':(46,98),'A':(300,98),'B':(300,214),'C':(46,214)}
        if self.level_index == 2:
            coords = {'H':(46,152),'A':(136,88),'B':(300,152),'C':(136,218),'D':(280,238)}
        for u,v,d in l.edges:
            x,y=coords[u]; xx,yy=coords[v]
            p.line(x,y,xx,yy,5)
            p.rect((x+xx)//2-4,(y+yy)//2-5,12,12,1)
            p.text((x+xx)//2,(y+yy)//2-2,str(d),13)
        for i,n in enumerate(l.nodes):
            x,y=coords[n]
            p.circ(x,y,14,3 if n=='H' else 2)
            p.circb(x,y,14,10 if n==s.place else 6)
            p.text(x-2,y-3,n,7)
            if n==s.place: p.rect(x-8,y+7,16,5,10)
            items=''.join(it.id for it,loc in zip(l.items,s.locations) if loc==n)
            p.text(x-14,y+19,'ITEM '+(items or '-'),10)
            if s.supply[i]: p.text(x-14,y-26,'OIL '+str(s.supply[i]),11)
        p.text(364, 123, 'ID   WEIGHT  VALUE  LOCATION', 13)
        for i,it in enumerate(l.items):
            y=142+25*i
            p.rect(358,y-5,266,20,3 if s.locations[i]=='T' else 2)
            place='TRUCK' if s.locations[i]=='T' else s.locations[i]
            p.text(364,y,it.id+'      '+str(it.weight)+'      '+str(it.value)+'      '+place,7)
        selected=self.options()[self.selection]
        f=forecast(s,l,selected)
        ns=f['state']
        p.text(14,274,'PREVIEW '+f['reason'].upper()+'   FUEL '+str(s.fuel)+'>'+str(ns.fuel)+'   LOAD '+str(weight(s,l))+'>'+str(weight(ns,l)),6)
        keep=return_plan(s,l)
        drop=return_plan(s,l,allow_drop=True) if keep is None else None
        advice='KEEP CARGO: YES' if keep is not None else ('KEEP: NO / DROP: YES' if drop is not None else 'NO RETURN FOUND')
        p.text(14,292,'HOME COST (NO REFUEL) '+str(home_cost(s,l))+'   '+advice,13)
        for i,action in enumerate(self.options()):
            x=14+(i%5)*124; y=314+(i//5)*26
            p.rectb(x,y,118,23,10 if i==self.selection else 5)
            name = 'MOVE '+action[1] if action[0]=='move' else 'REFUEL'
            if action[0]=='item':
                idx=action[1]
                name=('DROP ' if s.locations[idx]=='T' else 'PICK ')+l.items[idx].id
            p.text(x+7,y+8,name,10 if i==self.selection else 7)
        p.text(14, 375, 'RESULT '+s.outcome.upper()+'   '+MESSAGES[self.reason],11 if s.outcome=='won' else 7)
        p.text(14,395,'HISTORY '+str(len(self.timeline.history))+' / X RESTORES A CHANGED STATE',6)
        p.text(14, 412, 'ARROWS: SELECT   Z/SPACE: DO   R: RESET   ESC: QUIT',7)
        p.text(14, 429, '1/2/3: SWITCH LEVEL   X: UNDO EVEN AFTER RETURN',13)
        p.text(14, 446, 'EDIT -> SAVE -> QUIT -> RERUN. R DOES NOT RELOAD CODE.',6)

def make_demo_keys():
    app=App(); keys=[]
    keys.append('3'); app.level_index=2; app.reset()
    plan=[('move', 'C'), ('move', 'D'), ('refuel',), ('item', 3), ('move', 'B'), ('item', 1), ('move', 'H')]
    for action in plan:
        idx=app.options().index(action)
        while app.selection!=idx:
            keys.append('RIGHT'); app.selection=(app.selection+1)%len(app.options())
        keys.append('Z'); app.act(action)
    keys.append('X')
    return tuple(keys)

def main(argv=None):
    import argparse
    from pathlib import Path
    import json
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke', action='store_true', help='bounded actual Pyxel render')
    parser.add_argument('--demo-checkpoint', action='store_true', help='replay documented keys')
    parser.add_argument('--frames', type=int, help='bounded headless frames, 1..10000')
    parser.add_argument('--screenshot', help='new PNG; relative to launch working directory')
    parser.add_argument('--state-json', help='new JSON; relative to launch working directory')
    args=parser.parse_args(argv)
    if (args.demo_checkpoint or args.frames is not None or args.screenshot or args.state_json) and not args.headless_smoke:
        parser.error('--demo-checkpoint, --frames and outputs require --headless-smoke')
    if args.frames is not None and not 1<=args.frames<=10000:
        parser.error('--frames must be an integer from 1 to 10000')
    sequence=make_demo_keys() if args.demo_checkpoint else ()
    minimum=len(sequence)*2+2 if sequence else 1
    frames=args.frames if args.frames is not None else max(8,minimum)
    if frames<minimum: parser.error('--frames is too short for the full checkpoint; need '+str(minimum))
    # Resolve EVERY output before importing/initializing Pyxel, which may chdir.
    outputs={}
    for name,raw,suffix in (('screenshot',args.screenshot,'.png'),('state_json',args.state_json,'.json')):
        if raw is None: continue
        path=Path(raw).expanduser()
        if not raw or path.suffix.lower()!=suffix or not path.parent.is_dir() or path.exists() or path.is_symlink():
            parser.error(name+' must be a new '+suffix+' file in an existing directory; refusing overwrite')
        outputs[name]=path.resolve()
    if len(set(outputs.values()))!=len(outputs): parser.error('output paths must differ')
    import pyxel as p
    p.init(640,466,title='Return fuel lesson '+str(STAGE),fps=30,display_scale=2,headless=args.headless_smoke)
    app=App()
    names=('LEFT','RIGHT','UP','DOWN','Z','SPACE','R','X','1','2','3')
    keys={name:getattr(p,'KEY_'+name) for name in names}
    elapsed=0
    def finish():
        # Refuse again at save time; JSON uses exclusive creation as well.
        for path in outputs.values():
            if path.exists() or path.is_symlink(): raise FileExistsError('refusing overwrite: '+str(path))
        app.draw(p)
        if 'screenshot' in outputs: p.screen.save(str(outputs['screenshot']),1)
        if 'state_json' in outputs:
            with outputs['state_json'].open('x',encoding='utf-8') as file:
                json.dump(app.snapshot(),file,ensure_ascii=False,indent=2); file.write('\n')
        p.quit()
    def update():
        nonlocal elapsed
        if p.btnp(p.KEY_ESCAPE): finish(); return
        if args.headless_smoke:
            if elapsed>=frames: finish(); return
            index=elapsed//2
            pressed={sequence[index]} if elapsed%2==0 and index<len(sequence) else set()
            app.control(pressed,pressed)
        else:
            app.control({k for k,v in keys.items() if p.btnp(v)}, {k for k,v in keys.items() if p.btn(v)})
        elapsed+=1
    p.run(update,lambda:app.draw(p))

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

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

ルールのまとめ:移動の燃料は、距離×(1+出発するときの荷物の重さ)。重さは4まで。品物は置いた場所に残り、補給はタンクの空きとその場所の残りの小さいほう。H に一度帰ると終わり、価値が目標以上なら成功。成功した操作は Undo できます。

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

比べる点5ステップの教材元の完成版
段階01 中身が見える計算の実験、02 状況 A で拾う・置く、03 状況 B で限りのある補給、04 3つの面で予測、05 元のゲームのルール全部と Undo。02 から、データと apply は元のゲームのまま。最初から、3つの面、限りのある補給、帰れるかの予測、Undo があります。
画面と操作1つのファイル、英数字(ASCII)の表示、640×466。表紙・説明での一時停止・録画・日本語の文字・状況 B の比較の切り替えは省略。キーは1つずつ離して操作。640×480、日本語の文字、表紙・P で説明・M で表紙・F12 で記録・状況 B の比較の切り替え。
予測PREVIEW の燃料と重さ、補給なしの HOME COST、荷物を持ったまま/置いて帰れるか。帰り方の道順そのものは描きません。選ぶ前とあとの帰り道の燃料と価値も表示。帰れるかを日本語で説明。
Undo で戻す範囲ゲームの状態を Undo し、カーソルはそのまま。R はその状況を最初に戻す。同じく Timeline でゲームの状態を戻し、選んでいる札などの表示の状態は別。

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

  1. model.py の apply / travel_cost / check
  2. model.py の shortest_home / home_cost:計算の前提
  3. model.py の return_plan:移動・補給・置く操作の探索
  4. model.py の forecast と step / Timeline
  5. app.py:比較用のルール・表紙・日本語の文字の追加
自分のゲームに変えるなら

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

教材をダウンロードする

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

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

検証について

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

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

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

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