パズルLESSON 29四枠の隣接と売却順を考える商いパズル原作054

地下商人の帰還の作り方

となりの道具しだいで、同じ商品でも値段が変わる。
4つの枠に積んだ荷物と、となり合う道具の効果から売値を計算し、売る順番や休憩所での並べかえを考える、小さな商売のゲームです。

完成版の遊び方4つの枠がある荷台に商品と道具を積みます。道具はとなりの商品の値段を上げるので、何をどの順番で売り、どこで積みかえるかを考えます。

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

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

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

完成版の主な操作:左右枠/上下操作、Z決定、X戻す、R再開、P説明、1〜3相場

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

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

うまく表示されないとき

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

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

状態が変わるたびに、値段を計算し直す。

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

1. タプルの1つの枠だけを空にする
2. 今の並びから、毎回値段を計算する
3. 今の場面によって、できる操作を変える
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
Python 3.11以上、変数・if・for・関数 / リスト・タプルと辞書 / dataclass・replace・any・集合は本文で説明します
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
左右:枠を選ぶ / 上下:04/05の箱・売買メニュー / Z:積載・売買・交換の確定 / Space:03以降で次の滞在先へ / X:03/04は交換元の取消、05は取消を優先してUndo / R:初期化 / Esc:終了
難易度の目安
初級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_prices.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

四枠の荷台と売値を作る

タプルの1つの枠だけを空にする。

まずは商品の売値だけを作ります。荷台は、4つの値が入ったタプルです。C は晶石、M はきのこ、O は鉄鉱で、うしろの数字は1つ1つを区別するための番号(ID)です。M1 と M2 は同じ種類ですが、別々の商品です。空いている枠は空の文字列で表します。画面の1番はプログラムの中では番号0、4番は番号3にあたります。

左右で枠を選んで Z を押すと売れます。price が商品の種類から基本の値段を調べて、coins(所持金)に足します。KINDS.index で、C・M・O を値段の表の列0・1・2に対応させます。この練習では、どれも2金です。道具・旅・仕入れはまだありません。元のゲームの状況 C の基本の値段だけを使い、練習用に4つの商品を最初から積んでいます。

State は、今の cargo(荷台)・coins(所持金)・sold(売った記録)をまとめた、書きかえられないデータです。frozen=True で State への代入を防ぎ、cargo もタプルなので、中身を直接は変えません。step では、いったん作業用のリストを作って1か所を空にし、replace で新しい State を返します。元の状態が残るこの作りは、最後の Undo(1つ前に戻す)で役に立ちます。

売ったあとの穴は、空のまま残します。pop や remove で取り除くと、うしろの商品が左に詰まり、「枠は4つ」というルールがくずれてしまいます。draw は状態を読むだけなので、画面を何回描いても、商品も金額も変わりません。空の枠やありえない番号を選んだときは、同じ状態をそのまま返します。全部売ったあとに空の枠を売ろうとしても、何も起きない練習版です。

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

01_prices.py / 注目する部分
        value = price(state.cargo, slot, market)
        cargo[slot] = ''

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_prices.py
macOS / Linux
.venv/bin/python 01_prices.py
画面で見ること最初に Z を押すと COINS 2 になり、1番だけが EMPTY、2〜4番は M1/M2/O1 のままです。全部の商品を売ると8金になります。
  • プログラムの番号0〜3と、画面の1〜4を区別する
  • 売ったあとも枠は4つのまま
  • 空の枠を売ってもお金は増えない
つまずきやすいところcargo.pop(slot) を使うと、枠の数が減ってしまいます。cargo[slot] に空の文字列を入れて、4つの枠を保ちましょう。
少し変えてみよう

BASE の市場1の値だけを (3, 2, 2) に変えて、もう一度起動してみましょう。C1 を売ると3金、続けて M1 を売ると合計5金になります。確かめたら (2, 2, 2) に戻します。

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

"""地下商人の帰還・教材1/5。ゲーム内の金額は架空の規則です。"""
from dataclasses import dataclass, replace

KINDS = ('C', 'M', 'O')  # 晶石、きのこ、鉄鉱
BASE = ((2, 2, 2), (2, 3, 3))
START_CARGO = ('C1', 'M1', 'M2', 'O1')


@dataclass(frozen=True)
class State:
    cargo: tuple = START_CARGO
    coins: int = 0
    sold: tuple = ()


def valid_slot(slot):
    return type(slot) is int and 0 <= slot < 4


def price(cargo, slot, market=0):
    if not valid_slot(slot) or type(market) is not int or market not in (0, 1):
        return 0
    item = cargo[slot]
    if not item or item[0] not in KINDS:
        return 0
    kind = KINDS.index(item[0])
    return BASE[market][kind]


def step(state, action):
    shapes = {'sell': 2}
    if not isinstance(action, (tuple, list)) or not action:
        return state, "INVALID ACTION"
    op = action[0]
    if not isinstance(op, str) or op not in shapes or len(action) != shapes[op]:
        return state, "INVALID ACTION"
    cargo = list(state.cargo)
    market = 0
    slot = action[1]
    if not valid_slot(slot):
        return state, "INVALID SLOT"
    if op == "sell":
        if not cargo[slot]:
            return state, "EMPTY SLOT"
        item = cargo[slot]
        # 売る直前の配置で値を決め、その枠だけ空にする。
        value = price(state.cargo, slot, market)
        cargo[slot] = ''
        return replace(state, cargo=tuple(cargo), coins=state.coins+value,
                       sold=state.sold+((item, market, value),)), "SOLD FOR "+str(value)
    return state, "INVALID ACTION"


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

    def reset(self):
        self.state = State()
        self.selected = 0
        self.locked = False
        self.message = "CHOOSE A SLOT"

    def act(self, action):
        self.state, self.message = step(self.state, action)

    def control(self, pressed, held):
        # 確定後も複数キー拒否後も全解放を待つ。Rもゲート中は待機。
        if self.locked:
            if not held:
                self.locked = False
            return
        if 'R' in pressed:
            self.reset()
            self.locked = True
            return
        if len(held) > 1 or len(pressed) > 1:
            self.locked = bool(held)
            return
        if len(pressed) != 1 or pressed != held:
            return
        key = next(iter(pressed))
        if key in ('LEFT', 'RIGHT'):
            self.selected = (self.selected+(1 if key == 'RIGHT' else -1)) % 4
        elif key == 'Z':
            self.act(('sell', self.selected))
        else:
            return
        self.locked = True

    def draw(self, p):
        state = self.state
        p.cls(1)
        p.text(8, 8, "CARGO MERCHANT / STAGE 1", 10)
        p.text(8, 22, "MARKET 1   COINS "+str(state.coins), 7)
        p.text(8, 34, "PRACTICE PRESET / R RESTARTS", 6)
        for slot, item in enumerate(state.cargo):
            x = 8+slot*90
            p.rect(x, 52, 84, 94, 2)
            p.rectb(x, 52, 84, 94, 10 if slot == self.selected else 5)
            p.text(x+5, 59, str(slot+1)+": "+(item or 'EMPTY'), 7)
            if item:
                color = {'C': 12, 'M': 8, 'O': 13, 'L': 10, 'K': 6, 'T': 9}[item[0]]
                p.circ(x+42, 87, 12, color)
                p.text(x+40, 85, item[0], 1)
            for market in range(1):
                quote = str(price(state.cargo, slot, market))
                p.text(x+5, 117+12*market, "M"+str(market+1)+" $"+quote, 7)
        p.text(8, 156, "BASE PRICE: "+"   ".join(k+" "+str(BASE[0][i]) for i, k in enumerate(KINDS)), 6)
        p.text(8, 168, "EMPTY SLOTS STAY EMPTY. NO AUTO-SHIFT.", 7)
        p.text(8, 198, "LEFT/RIGHT SELECT / Z SELL", 10)
        p.text(8, 242, self.message, 11)
        p.text(8, 276, "LEFT/RIGHT SELECT   Z CONFIRM   R RESET", 7)
        p.text(8, 290, "ESC QUIT / FICTIONAL GAME PRICES", 6)

DEMO_KEYS = ('Z',)


def main(headless=False, screenshot=None, demo=False, preview=False):
    from pathlib import Path
    from dataclasses import asdict
    import json
    import pyxel as p
    target = Path(screenshot).resolve() if screenshot else None
    if target:
        target.parent.mkdir(parents=True, exist_ok=True)
    app = App()
    p.init(368, 304, title='Cargo Merchant', fps=30, display_scale=2, headless=headless)
    keys = {key: getattr(p, 'KEY_'+key) for key in ('LEFT', 'RIGHT', 'UP', 'DOWN', 'Z', 'SPACE', 'X', 'R')}
    demo_keys = DEMO_KEYS
    limit = max(12, 2*len(demo_keys)+4) if demo or preview else 12

    def update():
        if headless and p.frame_count >= limit:
            if target:
                p.screenshot(str(target), scale=2)
                record = {'stage': 1, 'mode': 'preview' if preview else 'checkpoint' if demo else 'initial',
                          'input_kind': 'synthetic controller keys; not OS/human input',
                          'frames': p.frame_count, 'state': asdict(app.state),
                          'selected': app.selected, 'message': app.message}
                target.with_suffix('.json').write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding='utf-8')
            p.quit()
            return
        if headless and (demo or preview):
            index = p.frame_count//2
            pressed = {demo_keys[index]} if p.frame_count%2 == 0 and index < len(demo_keys) else set()
            app.control(pressed, pressed)
        else:
            app.control({key for key, code in keys.items() if p.btnp(code)},
                        {key for key, code in keys.items() if p.btn(code)})
    p.run(update, lambda: app.draw(p))
    return app


if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--demo-preview', action='store_true')
    parser.add_argument('--screenshot')
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.demo_preview)
STEP 02 / 05

隣の道具で価格を変える

今の並びから、毎回値段を計算する。

荷物を C1-L1-M1-K1 に変えます。灯 L は晶石 C を、冷石 K はきのこ M を、磁石 T は鉄鉱 O を強くします(値段を上げます)。対応表 TOOLS は、「その商品の種類には、どの道具が必要か」を調べる辞書です。番号(ID)は相性には関係ありません。この段階は市場1だけなので、C に付く追加の4金が主役で、M と O の追加は0です。

enhanced は、slot-1 と slot+1(左右のとなり)だけを調べます。先に 0 <= other < 4(枠の範囲の中か)を確かめるので、左はしで -1 を使ったときに、Python がリストのいちばん最後を指してしまう事故を防げます。any は、条件に合う相手が1つでもあれば True になります。両側に道具があっても、追加の値段を2回は足しません。道具の効果が次々と伝わることも、空の枠を飛びこえて効くこともありません。

売値は「基本の値段+となりに道具があるときの追加」です。最初の C1 は 2+4=6金です。L1 は売り物の種類に入っていないので0金です。Z で L1 を手放すと、C1 の値段はすぐに2に下がります。「強くなった」という印を商品に保存せず、毎回 cargo から計算しているので、道具がなくなったら効果も消える、という動きを自然に作れます。

C を先に売ってから L を売ると 6+0=6、L が先なら 0+2=2 です。同じ荷物でも、売る順番で4金ちがいます。最初に出した見積もりでまとめて売るのではなく、1つ売って枠を空けたら、次の売値は新しい荷台から計算します。「道具は0金だから役に立たない」とは限らない、というのがこのゲームで考えるところです。

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

02_neighbours.py / 注目する部分
    return any(0 <= other < 4 and cargo[other] and cargo[other][0] == tool
               for other in (slot-1, slot+1))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_neighbours.py
macOS / Linux
.venv/bin/python 02_neighbours.py
画面で見ること右を押して Z で L1 を0金で手放すと、C1 の値段(市場1)が6から2に下がります。R でやり直してそのまま Z を押せば、先に C1 を6金で売れます。
  • 道具は売ると0金
  • 両どなりに道具があっても、追加は1回だけ
  • 売る順番で合計がちがう
つまずきやすいところ最初に計算した売値を保存して使い続けると、道具を売ったあとも値段が高いままになってしまいます。売る直前に、毎回 price を呼びましょう。
少し変えてみよう

START_CARGO = ("L1", "C1", "L2", "") に変えると、C1 は6金です。両側に灯があっても、足して10金にはなりません。この L2 は練習用で、元のゲームの状況 C の箱には入っていません。確かめたら元に戻してください。

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

"""地下商人の帰還・教材2/5。ゲーム内の金額は架空の規則です。"""
from dataclasses import dataclass, replace

KINDS = ('C', 'M', 'O')  # 晶石、きのこ、鉄鉱
BASE = ((2, 2, 2), (2, 3, 3))
TOOLS = {'C': 'L', 'M': 'K', 'O': 'T'}  # 灯、冷石、磁石
BONUS = ((4, 0, 0), (0, 2, 2))
START_CARGO = ('C1', 'L1', 'M1', 'K1')


@dataclass(frozen=True)
class State:
    cargo: tuple = START_CARGO
    coins: int = 0
    sold: tuple = ()


def valid_slot(slot):
    return type(slot) is int and 0 <= slot < 4


def enhanced(cargo, slot):
    if not valid_slot(slot) or not cargo[slot] or cargo[slot][0] not in TOOLS:
        return False
    tool = TOOLS[cargo[slot][0]]
    # 端を越えない。anyなので左右に道具があっても一度だけ加算。
    return any(0 <= other < 4 and cargo[other] and cargo[other][0] == tool
               for other in (slot-1, slot+1))


def price(cargo, slot, market=0):
    if not valid_slot(slot) or type(market) is not int or market not in (0, 1):
        return 0
    item = cargo[slot]
    if not item or item[0] not in KINDS:
        return 0
    kind = KINDS.index(item[0])
    return BASE[market][kind] + (BONUS[market][kind] if enhanced(cargo, slot) else 0)


def step(state, action):
    shapes = {'sell': 2}
    if not isinstance(action, (tuple, list)) or not action:
        return state, "INVALID ACTION"
    op = action[0]
    if not isinstance(op, str) or op not in shapes or len(action) != shapes[op]:
        return state, "INVALID ACTION"
    cargo = list(state.cargo)
    market = 0
    slot = action[1]
    if not valid_slot(slot):
        return state, "INVALID SLOT"
    if op == "sell":
        if not cargo[slot]:
            return state, "EMPTY SLOT"
        item = cargo[slot]
        # 売る直前の配置で値を決め、その枠だけ空にする。
        value = price(state.cargo, slot, market)
        cargo[slot] = ''
        return replace(state, cargo=tuple(cargo), coins=state.coins+value,
                       sold=state.sold+((item, market, value),)), "SOLD FOR "+str(value)
    return state, "INVALID ACTION"


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

    def reset(self):
        self.state = State()
        self.selected = 0
        self.locked = False
        self.message = "CHOOSE A SLOT"

    def act(self, action):
        self.state, self.message = step(self.state, action)

    def control(self, pressed, held):
        # 確定後も複数キー拒否後も全解放を待つ。Rもゲート中は待機。
        if self.locked:
            if not held:
                self.locked = False
            return
        if 'R' in pressed:
            self.reset()
            self.locked = True
            return
        if len(held) > 1 or len(pressed) > 1:
            self.locked = bool(held)
            return
        if len(pressed) != 1 or pressed != held:
            return
        key = next(iter(pressed))
        if key in ('LEFT', 'RIGHT'):
            self.selected = (self.selected+(1 if key == 'RIGHT' else -1)) % 4
        elif key == 'Z':
            self.act(('sell', self.selected))
        else:
            return
        self.locked = True

    def draw(self, p):
        state = self.state
        p.cls(1)
        p.text(8, 8, "CARGO MERCHANT / STAGE 2", 10)
        p.text(8, 22, "MARKET 1   COINS "+str(state.coins), 7)
        p.text(8, 34, "PRACTICE PRESET / R RESTARTS", 6)
        for slot, item in enumerate(state.cargo):
            x = 8+slot*90
            p.rect(x, 52, 84, 94, 2)
            p.rectb(x, 52, 84, 94, 10 if slot == self.selected else 5)
            p.text(x+5, 59, str(slot+1)+": "+(item or 'EMPTY'), 7)
            if item:
                color = {'C': 12, 'M': 8, 'O': 13, 'L': 10, 'K': 6, 'T': 9}[item[0]]
                p.circ(x+42, 87, 12, color)
                p.text(x+40, 85, item[0], 1)
                if enhanced(state.cargo, slot):
                    p.text(x+5, 103, "ENHANCED", 11)
            for market in range(1):
                quote = str(price(state.cargo, slot, market))
                p.text(x+5, 117+12*market, "M"+str(market+1)+" $"+quote, 7)
        p.text(8, 156, "L -> C   K -> M   T -> O / ADJACENT ONLY", 6)
        p.text(8, 168, "MARKET 1: "+"   ".join(k+" "+str(BASE[0][i])+"+"+str(BONUS[0][i]) for i, k in enumerate(KINDS)), 7)
        p.text(8, 180, "SELLING A TOOL REMOVES ITS BONUS NOW", 8)
        p.text(8, 198, "LEFT/RIGHT SELECT / Z SELL", 10)
        p.text(8, 242, self.message, 11)
        p.text(8, 276, "LEFT/RIGHT SELECT   Z CONFIRM   R RESET", 7)
        p.text(8, 290, "ESC QUIT / FICTIONAL GAME PRICES", 6)

DEMO_KEYS = ('RIGHT', 'Z')


def main(headless=False, screenshot=None, demo=False, preview=False):
    from pathlib import Path
    from dataclasses import asdict
    import json
    import pyxel as p
    target = Path(screenshot).resolve() if screenshot else None
    if target:
        target.parent.mkdir(parents=True, exist_ok=True)
    app = App()
    p.init(368, 304, title='Cargo Merchant', fps=30, display_scale=2, headless=headless)
    keys = {key: getattr(p, 'KEY_'+key) for key in ('LEFT', 'RIGHT', 'UP', 'DOWN', 'Z', 'SPACE', 'X', 'R')}
    demo_keys = DEMO_KEYS
    limit = max(12, 2*len(demo_keys)+4) if demo or preview else 12

    def update():
        if headless and p.frame_count >= limit:
            if target:
                p.screenshot(str(target), scale=2)
                record = {'stage': 2, 'mode': 'preview' if preview else 'checkpoint' if demo else 'initial',
                          'input_kind': 'synthetic controller keys; not OS/human input',
                          'frames': p.frame_count, 'state': asdict(app.state),
                          'selected': app.selected, 'message': app.message}
                target.with_suffix('.json').write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding='utf-8')
            p.quit()
            return
        if headless and (demo or preview):
            index = p.frame_count//2
            pressed = {demo_keys[index]} if p.frame_count%2 == 0 and index < len(demo_keys) else set()
            app.control(pressed, pressed)
        else:
            app.control({key for key, code in keys.items() if p.btnp(code)},
                        {key for key, code in keys.items() if p.btn(code)})
    p.run(update, lambda: app.draw(p))
    return app


if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--demo-preview', action='store_true')
    parser.add_argument('--screenshot')
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.demo_preview)
STEP 03 / 05

休憩所で二枠を交換する

今の場面によって、できる操作を変える。

「市場1 → 休憩所 → 市場2 → 帰る」という phase(場面)を加えます。Space で次の場面へ進みます。時間がたつと旅が進むわけではなく、両方の市場の値段は最初から見えています。市場1の M は 2+0、市場2の M は 3+2 なので、「きのこは後半まで残しておこう」と判断できます。

並べかえの練習のため、M1-L1-M2-K1 を積んで市場1から始めます。出発するときの箱と仕入れは、まだ省いています。この荷台は、元のゲームの状況 C で選べる積み方です。何も売らずに Space で休憩所へ進み、2番で Z を押して入れかえる元を選び、4番に移動して Z を押します。M1-K1-M2-L1 になり、冷石が2つのきのこのとなりに来ます。

1回目の Z では、まだゲームの状態は変わりません。画面側で「どこを選んだか」(anchor)を覚えるだけです。2回目の Z で、入れかえ(swap)を確定します。X は選んだ枠の取り消しだけで、確定した入れかえを戻す Undo は、この段階にはまだありません。休憩所では何回入れかえてもかまいません。「1か所」は、入れかえられる回数の上限ではありません。

市場で、高く売れるたびに並べ直すことはできないように、step が phase を確かめて、市場での入れかえは受け付けません。市場2で M1 と M2 を売ると 5+5=10金、並べかえずに売ると 3+5=8金です。最後に Space で帰り、10金以上なら WON(成功)、足りなければ RETURNED(帰還)です。どちらも無事に帰ったことになり、残った荷物が自動で売られることはありません。

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

03_rest_stop.py / 注目する部分
        cargo[i], cargo[j] = cargo[j], cargo[i]

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_rest_stop.py
macOS / Linux
.venv/bin/python 03_rest_stop.py
画面で見ること何も売らずに休憩所へ行き、2番と4番を入れかえて市場2へ進みます。1番と3番を売って帰ると、WON・10金です。入れかえなければ RETURNED・8金です。
  • 市場の中では入れかえられない
  • 休憩所での入れかえは何回でもできる
  • 帰っても、残った荷物は売られない
つまずきやすいところ休憩所に入っただけでは、並びは変わりません。入れかえる元と先を、2回の Z で確定します。
少し変えてみよう

R → Space → 2番で Z → X と押してみましょう。cargo も金額も変わらず、入れかえる元を示す緑の枠だけが消えます。続けて Space → 1番と3番を売る → Space と押し、8金で帰れることを確かめます。

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

"""地下商人の帰還・教材3/5。ゲーム内の金額は架空の規則です。"""
from dataclasses import dataclass, replace

KINDS = ('C', 'M', 'O')  # 晶石、きのこ、鉄鉱
BASE = ((2, 2, 2), (2, 3, 3))
TOOLS = {'C': 'L', 'M': 'K', 'O': 'T'}  # 灯、冷石、磁石
BONUS = ((4, 0, 0), (0, 2, 2))
START_CARGO = ('M1', 'L1', 'M2', 'K1')
GOAL = 10
PHASES = ('LOAD', 'MARKET 1', 'REST STOP', 'MARKET 2', 'HOME')


@dataclass(frozen=True)
class State:
    phase: int = 1
    cargo: tuple = START_CARGO
    coins: int = 0
    sold: tuple = ()
    outcome: str = "journey"


def valid_slot(slot):
    return type(slot) is int and 0 <= slot < 4


def enhanced(cargo, slot):
    if not valid_slot(slot) or not cargo[slot] or cargo[slot][0] not in TOOLS:
        return False
    tool = TOOLS[cargo[slot][0]]
    # 端を越えない。anyなので左右に道具があっても一度だけ加算。
    return any(0 <= other < 4 and cargo[other] and cargo[other][0] == tool
               for other in (slot-1, slot+1))


def price(cargo, slot, market=0):
    if not valid_slot(slot) or type(market) is not int or market not in (0, 1):
        return 0
    item = cargo[slot]
    if not item or item[0] not in KINDS:
        return 0
    kind = KINDS.index(item[0])
    return BASE[market][kind] + (BONUS[market][kind] if enhanced(cargo, slot) else 0)


def market_index(state):
    return 0 if state.phase == 1 else 1 if state.phase == 3 else None


def step(state, action):
    if state.outcome != "journey":
        return state, "HOME: R TO RESTART"
    shapes = {'sell': 2, 'swap': 3, 'next': 1}
    if not isinstance(action, (tuple, list)) or not action:
        return state, "INVALID ACTION"
    op = action[0]
    if not isinstance(op, str) or op not in shapes or len(action) != shapes[op]:
        return state, "INVALID ACTION"
    cargo = list(state.cargo)
    if op == "next":
        if state.phase == 3:
            outcome = "won" if state.coins >= GOAL else "returned"
            return replace(state, phase=4, outcome=outcome), outcome.upper()
        return replace(state, phase=state.phase+1), "NEXT STOP"
    if op == "swap":
        if state.phase not in (2,):
            return state, "NO SWAP IN A MARKET"
        i, j = action[1:]
        if not valid_slot(i) or not valid_slot(j):
            return state, "INVALID SLOT"
        if cargo[i] == cargo[j]:
            return state, "SAME ARRANGEMENT"
        cargo[i], cargo[j] = cargo[j], cargo[i]
        return replace(state, cargo=tuple(cargo)), "SWAPPED"
    market = market_index(state)
    if market is None:
        return state, "TRADE ONLY IN A MARKET"
    slot = action[1]
    if not valid_slot(slot):
        return state, "INVALID SLOT"
    if op == "sell":
        if not cargo[slot]:
            return state, "EMPTY SLOT"
        item = cargo[slot]
        # 売る直前の配置で値を決め、その枠だけ空にする。
        value = price(state.cargo, slot, market)
        cargo[slot] = ''
        return replace(state, cargo=tuple(cargo), coins=state.coins+value,
                       sold=state.sold+((item, market, value),)), "SOLD FOR "+str(value)
    return state, "INVALID ACTION"


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

    def reset(self):
        self.state = State()
        self.selected = 0
        self.locked = False
        self.message = "CHOOSE A SLOT"
        self.anchor = None

    def act(self, action):
        self.state, self.message = step(self.state, action)
        self.anchor = None

    def control(self, pressed, held):
        # 確定後も複数キー拒否後も全解放を待つ。Rもゲート中は待機。
        if self.locked:
            if not held:
                self.locked = False
            return
        if 'R' in pressed:
            self.reset()
            self.locked = True
            return
        if len(held) > 1 or len(pressed) > 1:
            self.locked = bool(held)
            return
        if len(pressed) != 1 or pressed != held:
            return
        key = next(iter(pressed))
        if key == 'X':
            if self.anchor is not None:
                self.anchor = None
                self.message = "SWAP CANCELLED"
            self.locked = True
            return
        if self.state.outcome != 'journey':
            return
        if key in ('LEFT', 'RIGHT'):
            self.selected = (self.selected+(1 if key == 'RIGHT' else -1)) % 4
        elif key == 'Z':
            if self.state.phase == 2:
                if self.anchor is None:
                    self.anchor = self.selected
                    self.message = "SELECT ANOTHER SLOT, THEN Z"
                else:
                    self.act(('swap', self.anchor, self.selected))
            else:
                self.act(('sell', self.selected))
        elif key == 'SPACE':
            self.act(('next',))
        else:
            return
        self.locked = True

    def draw(self, p):
        state = self.state
        p.cls(1)
        p.text(8, 8, "CARGO MERCHANT / STAGE 3", 10)
        p.text(8, 22, PHASES[state.phase]+"   COINS "+str(state.coins)+" / GOAL "+str(GOAL), 7)
        p.text(8, 34, "PRACTICE PRESET / R RESTARTS", 6)
        for slot, item in enumerate(state.cargo):
            x = 8+slot*90
            p.rect(x, 52, 84, 94, 2)
            p.rectb(x, 52, 84, 94, 10 if slot == self.selected else 5)
            if slot == self.anchor:
                p.rectb(x+2, 54, 80, 90, 11)
            p.text(x+5, 59, str(slot+1)+": "+(item or 'EMPTY'), 7)
            if item:
                color = {'C': 12, 'M': 8, 'O': 13, 'L': 10, 'K': 6, 'T': 9}[item[0]]
                p.circ(x+42, 87, 12, color)
                p.text(x+40, 85, item[0], 1)
                if enhanced(state.cargo, slot):
                    p.text(x+5, 103, "ENHANCED", 11)
            for market in range(2):
                quote = str(price(state.cargo, slot, market))
                p.text(x+5, 117+12*market, "M"+str(market+1)+" $"+quote, 7)
        p.text(8, 156, "L -> C   K -> M   T -> O / ADJACENT ONLY", 6)
        p.text(8, 168, "MARKET 1: "+"   ".join(k+" "+str(BASE[0][i])+"+"+str(BONUS[0][i]) for i, k in enumerate(KINDS)), 7)
        p.text(8, 180, "MARKET 2: "+"   ".join(k+" "+str(BASE[1][i])+"+"+str(BONUS[1][i]) for i, k in enumerate(KINDS)), 7)
        p.text(8, 198, "Z SELL / AT REST: Z START, Z END", 10)
        p.text(8, 210, "SPACE NEXT / X CANCEL SWAP", 6)
        p.text(8, 242, self.message, 11)
        p.text(8, 256, "RESULT: "+state.outcome.upper()+" / UNSOLD CARGO IS KEPT", 7)
        p.text(8, 276, "LEFT/RIGHT SELECT   Z CONFIRM   R RESET", 7)
        p.text(8, 290, "ESC QUIT / FICTIONAL GAME PRICES", 6)

DEMO_KEYS = ('SPACE', 'RIGHT', 'Z', 'RIGHT', 'RIGHT', 'Z', 'SPACE', 'RIGHT', 'Z', 'RIGHT', 'RIGHT', 'Z', 'SPACE')


def main(headless=False, screenshot=None, demo=False, preview=False):
    from pathlib import Path
    from dataclasses import asdict
    import json
    import pyxel as p
    target = Path(screenshot).resolve() if screenshot else None
    if target:
        target.parent.mkdir(parents=True, exist_ok=True)
    app = App()
    p.init(368, 304, title='Cargo Merchant', fps=30, display_scale=2, headless=headless)
    keys = {key: getattr(p, 'KEY_'+key) for key in ('LEFT', 'RIGHT', 'UP', 'DOWN', 'Z', 'SPACE', 'X', 'R')}
    demo_keys = DEMO_KEYS
    limit = max(12, 2*len(demo_keys)+4) if demo or preview else 12

    def update():
        if headless and p.frame_count >= limit:
            if target:
                p.screenshot(str(target), scale=2)
                record = {'stage': 3, 'mode': 'preview' if preview else 'checkpoint' if demo else 'initial',
                          'input_kind': 'synthetic controller keys; not OS/human input',
                          'frames': p.frame_count, 'state': asdict(app.state),
                          'selected': app.selected, 'message': app.message}
                target.with_suffix('.json').write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding='utf-8')
            p.quit()
            return
        if headless and (demo or preview):
            index = p.frame_count//2
            pressed = {demo_keys[index]} if p.frame_count%2 == 0 and index < len(demo_keys) else set()
            app.control(pressed, pressed)
        else:
            app.control({key for key, code in keys.items() if p.btnp(code)},
                        {key for key, code in keys.items() if p.btn(code)})
    p.run(update, lambda: app.draw(p))
    return app


if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--demo-preview', action='store_true')
    parser.add_argument('--screenshot')
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.demo_preview)
STEP 04 / 05

出発箱と有限の仕入れを加える

所持金・空いている枠・在庫を、まとめて更新する。

ここからは、元のゲームの状況 C「一度だけ積み直す」のふつうのルールです。空の4枠と、7つの品が入った箱から始めます。左右で枠を、上下で箱の品を選び、Z で積みます。もともと枠に入っていた品は箱に戻るので、同じ ID の品が2か所に増えることはありません。枠を空に戻す操作「UNLOAD TO BOX」もあります。Space で市場1へ出発します。

市場では、上下で「SELL(売る)」と「BUY M3 COST 1(M3 を1金で買う)」を選びます。買えるのは、市場1の M3 の1つだけです。stock は売り物と値段の表、available はまだ買われていない ID です。この両方を確かめるので、市場2で M3 を買うことも、一度買って売った M3 をもう一度買うこともできません。

buy は、先に「在庫があるか」「空いている枠があるか」「お金が足りるか」を確かめます。全部の条件がそろってから、荷台・所持金・残りの在庫・bought(買った記録)・paid(払った金額)を、1回の replace でまとめて更新します。こうすると、「お金だけ引いたあとで、空いている枠がないと気づく」ような中途半端な取り引きを防げます。売って空いた枠に買った品を入れるので、どの枠を空けるかも考えどころです。

C1-L1-M1-K1 を積み、市場1で C1 を6金で売ります。空いた1番に、M3 を1金で買います。休憩所で2番と4番を入れかえて M3-K1-M1-L1 にすると、市場2で2つの M がそれぞれ5金で売れます。売上16−仕入れ1=所持金15で帰れます。箱に置いた品は、途中で取り出せません。道具は残したまま帰ってもかまいません。

元のゲームの状況 C にある phase/cargo/box/coins/available/sold/bought/paid/outcome をすべて持ち、load・swap・sell・buy・next の意味も元のゲームとそろえました。画面は読みやすい文字(ASCII)だけの小さな版で、状況 A・B や保存などは入れていません。

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

04_markets.py / 注目する部分
        return replace(state, cargo=tuple(cargo), coins=state.coins-cost,
                       available=tuple(x for x in state.available if x != item),
                       bought=state.bought+(item,), paid=state.paid+cost), "BOUGHT "+item

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_markets.py
macOS / Linux
.venv/bin/python 04_markets.py
画面で見ること説明の手順どおりに進めると、HOME・WON・COINS 15、SALES 16−BUY COST 1 になります。M3 は売り切れで、箱には M2/O1/T1 が残ります。
  • 同じ ID の品を2つの枠に積めない
  • 空いている枠・在庫・所持金を確かめる
  • 売上から仕入れの金額を引いた所持金で、成功かを判定する
つまずきやすいところ「お金が足りるか」だけで買えると判断しないようにしましょう。選んだ枠が空いていて、その市場に在庫があり、まだ売り切れていないことも必要です。
少し変えてみよう

出発の箱から何も積まずに Space で市場1へ進み、BUY を確定してみましょう。「NOT ENOUGH COINS」(お金が足りない)と出ます。箱に戻らず、空いている枠で買おうとしても、お金は増えも減りもしません。R でやり直せます。

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

"""地下商人の帰還・教材4/5。ゲーム内の金額は架空の規則です。"""
from dataclasses import dataclass, replace

KINDS = ('C', 'M', 'O')  # 晶石、きのこ、鉄鉱
BASE = ((2, 2, 2), (2, 3, 3))
TOOLS = {'C': 'L', 'M': 'K', 'O': 'T'}  # 灯、冷石、磁石
BONUS = ((4, 0, 0), (0, 2, 2))
BOX = ('C1', 'M1', 'M2', 'O1', 'L1', 'K1', 'T1')
STOCK = ((('M3', 1),), ())  # (商品ID, 買値)。一品は一度だけ。
GOAL = 10
PHASES = ('LOAD', 'MARKET 1', 'REST STOP', 'MARKET 2', 'HOME')


@dataclass(frozen=True)
class State:
    phase: int = 0
    cargo: tuple = ('', '', '', '')
    box: tuple = BOX
    coins: int = 0
    available: tuple = ('M3',)
    sold: tuple = ()
    bought: tuple = ()
    paid: int = 0
    outcome: str = "journey"


def valid_slot(slot):
    return type(slot) is int and 0 <= slot < 4


def enhanced(cargo, slot):
    if not valid_slot(slot) or not cargo[slot] or cargo[slot][0] not in TOOLS:
        return False
    tool = TOOLS[cargo[slot][0]]
    # 端を越えない。anyなので左右に道具があっても一度だけ加算。
    return any(0 <= other < 4 and cargo[other] and cargo[other][0] == tool
               for other in (slot-1, slot+1))


def price(cargo, slot, market=0):
    if not valid_slot(slot) or type(market) is not int or market not in (0, 1):
        return 0
    item = cargo[slot]
    if not item or item[0] not in KINDS:
        return 0
    kind = KINDS.index(item[0])
    return BASE[market][kind] + (BONUS[market][kind] if enhanced(cargo, slot) else 0)


def market_index(state):
    return 0 if state.phase == 1 else 1 if state.phase == 3 else None


def step(state, action):
    if state.outcome != "journey":
        return state, "HOME: R TO RESTART"
    shapes = {'load': 3, 'sell': 2, 'swap': 3, 'buy': 3, 'next': 1}
    if not isinstance(action, (tuple, list)) or not action:
        return state, "INVALID ACTION"
    op = action[0]
    if not isinstance(op, str) or op not in shapes or len(action) != shapes[op]:
        return state, "INVALID ACTION"
    if op in ("load", "buy") and not isinstance(action[2], str):
        return state, "INVALID ITEM"
    cargo = list(state.cargo)
    if op == "next":
        if state.phase == 3:
            outcome = "won" if state.coins >= GOAL else "returned"
            return replace(state, phase=4, outcome=outcome), outcome.upper()
        return replace(state, phase=state.phase+1), "NEXT STOP"
    if op == "load":
        if state.phase != 0:
            return state, "LOAD ONLY BEFORE DEPARTURE"
        slot, item = action[1:]
        if not valid_slot(slot):
            return state, "INVALID SLOT"
        if item == cargo[slot]:
            return state, "SAME ITEM"
        if item and item not in state.box:
            return state, "NOT IN BOX"
        pool = set(state.box)
        if cargo[slot]:
            pool.add(cargo[slot])
        if item:
            pool.remove(item)
        cargo[slot] = item
        return replace(state, cargo=tuple(cargo), box=tuple(x for x in BOX if x in pool)), "LOADED"
    if op == "swap":
        if state.phase not in (0, 2):
            return state, "NO SWAP IN A MARKET"
        i, j = action[1:]
        if not valid_slot(i) or not valid_slot(j):
            return state, "INVALID SLOT"
        if cargo[i] == cargo[j]:
            return state, "SAME ARRANGEMENT"
        cargo[i], cargo[j] = cargo[j], cargo[i]
        return replace(state, cargo=tuple(cargo)), "SWAPPED"
    market = market_index(state)
    if market is None:
        return state, "TRADE ONLY IN A MARKET"
    slot = action[1]
    if not valid_slot(slot):
        return state, "INVALID SLOT"
    if op == "sell":
        if not cargo[slot]:
            return state, "EMPTY SLOT"
        item = cargo[slot]
        # 売る直前の配置で値を決め、その枠だけ空にする。
        value = price(state.cargo, slot, market)
        cargo[slot] = ''
        return replace(state, cargo=tuple(cargo), coins=state.coins+value,
                       sold=state.sold+((item, market, value),)), "SOLD FOR "+str(value)
    if op == "buy":
        item = action[2]
        stock = dict(STOCK[market])
        if item not in stock or item not in state.available:
            return state, "OUT OF STOCK"
        if cargo[slot]:
            return state, "NEED AN EMPTY SLOT"
        cost = stock[item]
        if state.coins < cost:
            return state, "NOT ENOUGH COINS"
        cargo[slot] = item
        return replace(state, cargo=tuple(cargo), coins=state.coins-cost,
                       available=tuple(x for x in state.available if x != item),
                       bought=state.bought+(item,), paid=state.paid+cost), "BOUGHT "+item
    return state, "INVALID ACTION"


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

    def reset(self):
        self.state = State()
        self.selected = 0
        self.locked = False
        self.message = "CHOOSE A SLOT"
        self.anchor = None
        self.option = 0

    def options(self):
        state = self.state
        if state.phase == 0:
            return [('load', self.selected, item) for item in state.box]+[('load', self.selected, '')]
        if state.phase in (1, 3):
            return [('sell', self.selected)]+[('buy', self.selected, item) for item, cost in STOCK[market_index(state)]]
        return [('select_swap', self.selected)] if state.phase == 2 else [('finished',)]

    def label(self, action):
        if action[0] == 'load':
            return 'LOAD '+action[2] if action[2] else 'UNLOAD TO BOX'
        if action[0] == 'sell':
            return 'SELL / TOOLS GIVE 0'
        if action[0] == 'buy':
            item = action[2]
            return 'BUY '+item+' COST 1'+(' / SOLD OUT' if item not in self.state.available else '')
        if action[0] == 'select_swap':
            return 'CHOOSE SWAP END' if self.anchor is not None else 'CHOOSE SWAP START'
        return self.state.outcome.upper()

    def act(self, action):
        self.state, self.message = step(self.state, action)
        self.anchor = None
        self.option = min(self.option, len(self.options())-1)

    def control(self, pressed, held):
        # 確定後も複数キー拒否後も全解放を待つ。Rもゲート中は待機。
        if self.locked:
            if not held:
                self.locked = False
            return
        if 'R' in pressed:
            self.reset()
            self.locked = True
            return
        if len(held) > 1 or len(pressed) > 1:
            self.locked = bool(held)
            return
        if len(pressed) != 1 or pressed != held:
            return
        key = next(iter(pressed))
        if key == 'X':
            if self.anchor is not None:
                self.anchor = None
                self.message = "SWAP CANCELLED"
            self.locked = True
            return
        if self.state.outcome != 'journey':
            return
        if key in ('LEFT', 'RIGHT'):
            self.selected = (self.selected+(1 if key == 'RIGHT' else -1)) % 4
            self.option = min(self.option, len(self.options())-1)
        elif key == 'Z':
            if self.state.phase == 2:
                if self.anchor is None:
                    self.anchor = self.selected
                    self.message = "SELECT ANOTHER SLOT, THEN Z"
                else:
                    self.act(('swap', self.anchor, self.selected))
            else:
                self.act(self.options()[self.option])
        elif key == 'SPACE':
            self.act(('next',))
        elif key in ('UP', 'DOWN'):
            self.option = (self.option+(1 if key == 'DOWN' else -1)) % len(self.options())
        else:
            return
        self.locked = True

    def draw(self, p):
        state = self.state
        p.cls(1)
        p.text(8, 8, "CARGO MERCHANT / STAGE 4", 10)
        p.text(8, 22, PHASES[state.phase]+"   COINS "+str(state.coins)+" / GOAL "+str(GOAL), 7)
        p.text(8, 34, "BOX "+" ".join(state.box), 6)
        for slot, item in enumerate(state.cargo):
            x = 8+slot*90
            p.rect(x, 52, 84, 94, 2)
            p.rectb(x, 52, 84, 94, 10 if slot == self.selected else 5)
            if slot == self.anchor:
                p.rectb(x+2, 54, 80, 90, 11)
            p.text(x+5, 59, str(slot+1)+": "+(item or 'EMPTY'), 7)
            if item:
                color = {'C': 12, 'M': 8, 'O': 13, 'L': 10, 'K': 6, 'T': 9}[item[0]]
                p.circ(x+42, 87, 12, color)
                p.text(x+40, 85, item[0], 1)
                if enhanced(state.cargo, slot):
                    p.text(x+5, 103, "ENHANCED", 11)
            for market in range(2):
                quote = str(price(state.cargo, slot, market))
                p.text(x+5, 117+12*market, "M"+str(market+1)+" $"+quote, 7)
        p.text(8, 156, "L -> C   K -> M   T -> O / ADJACENT ONLY", 6)
        p.text(8, 168, "MARKET 1: "+"   ".join(k+" "+str(BASE[0][i])+"+"+str(BONUS[0][i]) for i, k in enumerate(KINDS)), 7)
        p.text(8, 180, "MARKET 2: "+"   ".join(k+" "+str(BASE[1][i])+"+"+str(BONUS[1][i]) for i, k in enumerate(KINDS)), 7)
        p.text(8, 198, 'UP/DOWN: '+self.label(self.options()[self.option]), 10)
        p.text(8, 210, 'OPTION '+str(self.option+1)+'/'+str(len(self.options()))+'   SPACE: NEXT STOP', 6)
        p.text(8, 224, "SALES "+str(sum(v for _,_,v in state.sold))+" - BUY COST "+str(state.paid)+" = "+str(state.coins), 6)
        p.text(8, 242, self.message, 11)
        p.text(8, 256, "RESULT: "+state.outcome.upper()+" / UNSOLD CARGO IS KEPT", 7)
        p.text(8, 276, "LEFT/RIGHT SELECT   Z CONFIRM   R RESET", 7)
        p.text(8, 290, "ESC QUIT / FICTIONAL GAME PRICES", 6)

DEMO_KEYS = ('Z', 'RIGHT', 'DOWN', 'DOWN', 'DOWN', 'Z', 'RIGHT', 'UP', 'UP', 'UP', 'Z', 'RIGHT', 'DOWN', 'DOWN', 'Z', 'SPACE', 'RIGHT', 'UP', 'Z', 'DOWN', 'Z', 'SPACE', 'RIGHT', 'Z', 'RIGHT', 'RIGHT', 'Z', 'SPACE', 'RIGHT', 'UP', 'Z', 'RIGHT', 'RIGHT', 'Z', 'SPACE')


def main(headless=False, screenshot=None, demo=False, preview=False):
    from pathlib import Path
    from dataclasses import asdict
    import json
    import pyxel as p
    target = Path(screenshot).resolve() if screenshot else None
    if target:
        target.parent.mkdir(parents=True, exist_ok=True)
    app = App()
    p.init(368, 304, title='Cargo Merchant', fps=30, display_scale=2, headless=headless)
    keys = {key: getattr(p, 'KEY_'+key) for key in ('LEFT', 'RIGHT', 'UP', 'DOWN', 'Z', 'SPACE', 'X', 'R')}
    demo_keys = DEMO_KEYS
    limit = max(12, 2*len(demo_keys)+4) if demo or preview else 12

    def update():
        if headless and p.frame_count >= limit:
            if target:
                p.screenshot(str(target), scale=2)
                record = {'stage': 4, 'mode': 'preview' if preview else 'checkpoint' if demo else 'initial',
                          'input_kind': 'synthetic controller keys; not OS/human input',
                          'frames': p.frame_count, 'state': asdict(app.state),
                          'selected': app.selected, 'message': app.message}
                target.with_suffix('.json').write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding='utf-8')
            p.quit()
            return
        if headless and (demo or preview):
            index = p.frame_count//2
            pressed = {demo_keys[index]} if p.frame_count%2 == 0 and index < len(demo_keys) else set()
            app.control(pressed, pressed)
        else:
            app.control({key for key, code in keys.items() if p.btnp(code)},
                        {key for key, code in keys.items() if p.btn(code)})
    p.run(update, lambda: app.draw(p))
    return app


if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--demo-preview', action='store_true')
    parser.add_argument('--screenshot')
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.demo_preview)
STEP 05 / 05

商談全体を戻して予告を見せる

書きかえない状態を、履歴に積み重ねる。

Timeline は、今の State と、過去の State をまとめたものです。apply は step で新しい状態を計算し、何かが変わったときだけ、元の状態を history(履歴)に積みます。できない売り買い・並びが変わらない操作・空の枠を売る操作では、履歴は増やしません。カーソルの移動は取り引きではないので、記録しません。

X で最後の State を取り出すと、荷台だけでなく、箱・金額・在庫・売った記録・仕入れの金額・今いる場所まで、いっぺんに元に戻ります。買い物を戻せば、M3 の在庫も元に戻ります。帰ったあとでも X が使えるように、「終わったら通常の操作は受け付けない」という処理より先に Undo を処理します。R は、状態・履歴・選択をすべて最初に戻します。

休憩所で入れかえる元を選んでいるときに X を押すと、まずその選択だけを取り消します。もう一度 X を押すと、確定した1つ前の操作を戻します。この区別がないと、入れかえをやめたかっただけなのに、その前の買い物まで取り消してしまいます。

forecast(予報)は、選んでいる操作を step に渡して、「もしそうしたら」の状態を返すだけです。確定も、履歴への追加もしません。市場1で C1-L1-M1-K1 のときに灯を選んで SELL を表示すると、C1 の売値に「6 > 2」と出ます。道具を0金で手放すと、となりの品が4金安くなることを、Z を押す前に読めます。入れかえる元を選んだあとも、入れかえ先のカーソルの位置に合わせた「仮の並び」で予告します。

この教材で作るのは、元のゲームの状況 C の「となりの効果」のルール、ふつうの売り買い、2つの市場、休憩所、帰還、Undo までです。すべての枠の道具を比べる機能、状況 A・B の選択、表紙と一時停止、日本語の文字、プレイの保存と検証は省いています。人が操作したり音を聞いたりする確認とは別に、自動テスト・実際の Pyxel での描画・元のゲームとの動きの比較を記録しています。

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

05_undo_forecast.py / 注目する部分
    return Timeline(state, timeline.history+(timeline.state,)), message

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_undo_forecast.py
macOS / Linux
.venv/bin/python 05_undo_forecast.py
画面で見ること04 と同じように15金の WON になったら、X を押すと市場2の JOURNEY(旅の途中)に戻ります。もう一度 X を押すと、最後に売った M1 が戻り、売る前の10金になります。
  • 買い物を Undo すると、在庫も戻る
  • 帰ったあとでも、戻して取り引きを続けられる
  • 予告・カーソル・受け付けなかった操作では、履歴は増えない
つまずきやすいところcargo だけを戻すと、金額や M3 の在庫と食いちがってしまいます。状態が変わった操作ごとに、State 全体を保存します。
少し変えてみよう

M3 を買った直後に X を押してみましょう。所持金は6、1番は EMPTY、M3 はまた買える状態になり、paid は0に戻ります。もう一度買うと、5金・paid 1 です。R のあとに X を押すと、「NOTHING TO UNDO」(戻せる操作がない)と出ます。

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

"""地下商人の帰還・教材5/5。ゲーム内の金額は架空の規則です。"""
from dataclasses import dataclass, replace

KINDS = ('C', 'M', 'O')  # 晶石、きのこ、鉄鉱
BASE = ((2, 2, 2), (2, 3, 3))
TOOLS = {'C': 'L', 'M': 'K', 'O': 'T'}  # 灯、冷石、磁石
BONUS = ((4, 0, 0), (0, 2, 2))
BOX = ('C1', 'M1', 'M2', 'O1', 'L1', 'K1', 'T1')
STOCK = ((('M3', 1),), ())  # (商品ID, 買値)。一品は一度だけ。
GOAL = 10
PHASES = ('LOAD', 'MARKET 1', 'REST STOP', 'MARKET 2', 'HOME')


@dataclass(frozen=True)
class State:
    phase: int = 0
    cargo: tuple = ('', '', '', '')
    box: tuple = BOX
    coins: int = 0
    available: tuple = ('M3',)
    sold: tuple = ()
    bought: tuple = ()
    paid: int = 0
    outcome: str = "journey"


def valid_slot(slot):
    return type(slot) is int and 0 <= slot < 4


def enhanced(cargo, slot):
    if not valid_slot(slot) or not cargo[slot] or cargo[slot][0] not in TOOLS:
        return False
    tool = TOOLS[cargo[slot][0]]
    # 端を越えない。anyなので左右に道具があっても一度だけ加算。
    return any(0 <= other < 4 and cargo[other] and cargo[other][0] == tool
               for other in (slot-1, slot+1))


def price(cargo, slot, market=0):
    if not valid_slot(slot) or type(market) is not int or market not in (0, 1):
        return 0
    item = cargo[slot]
    if not item or item[0] not in KINDS:
        return 0
    kind = KINDS.index(item[0])
    return BASE[market][kind] + (BONUS[market][kind] if enhanced(cargo, slot) else 0)


def market_index(state):
    return 0 if state.phase == 1 else 1 if state.phase == 3 else None


def step(state, action):
    if state.outcome != "journey":
        return state, "HOME: R TO RESTART"
    shapes = {'load': 3, 'sell': 2, 'swap': 3, 'buy': 3, 'next': 1}
    if not isinstance(action, (tuple, list)) or not action:
        return state, "INVALID ACTION"
    op = action[0]
    if not isinstance(op, str) or op not in shapes or len(action) != shapes[op]:
        return state, "INVALID ACTION"
    if op in ("load", "buy") and not isinstance(action[2], str):
        return state, "INVALID ITEM"
    cargo = list(state.cargo)
    if op == "next":
        if state.phase == 3:
            outcome = "won" if state.coins >= GOAL else "returned"
            return replace(state, phase=4, outcome=outcome), outcome.upper()
        return replace(state, phase=state.phase+1), "NEXT STOP"
    if op == "load":
        if state.phase != 0:
            return state, "LOAD ONLY BEFORE DEPARTURE"
        slot, item = action[1:]
        if not valid_slot(slot):
            return state, "INVALID SLOT"
        if item == cargo[slot]:
            return state, "SAME ITEM"
        if item and item not in state.box:
            return state, "NOT IN BOX"
        pool = set(state.box)
        if cargo[slot]:
            pool.add(cargo[slot])
        if item:
            pool.remove(item)
        cargo[slot] = item
        return replace(state, cargo=tuple(cargo), box=tuple(x for x in BOX if x in pool)), "LOADED"
    if op == "swap":
        if state.phase not in (0, 2):
            return state, "NO SWAP IN A MARKET"
        i, j = action[1:]
        if not valid_slot(i) or not valid_slot(j):
            return state, "INVALID SLOT"
        if cargo[i] == cargo[j]:
            return state, "SAME ARRANGEMENT"
        cargo[i], cargo[j] = cargo[j], cargo[i]
        return replace(state, cargo=tuple(cargo)), "SWAPPED"
    market = market_index(state)
    if market is None:
        return state, "TRADE ONLY IN A MARKET"
    slot = action[1]
    if not valid_slot(slot):
        return state, "INVALID SLOT"
    if op == "sell":
        if not cargo[slot]:
            return state, "EMPTY SLOT"
        item = cargo[slot]
        # 売る直前の配置で値を決め、その枠だけ空にする。
        value = price(state.cargo, slot, market)
        cargo[slot] = ''
        return replace(state, cargo=tuple(cargo), coins=state.coins+value,
                       sold=state.sold+((item, market, value),)), "SOLD FOR "+str(value)
    if op == "buy":
        item = action[2]
        stock = dict(STOCK[market])
        if item not in stock or item not in state.available:
            return state, "OUT OF STOCK"
        if cargo[slot]:
            return state, "NEED AN EMPTY SLOT"
        cost = stock[item]
        if state.coins < cost:
            return state, "NOT ENOUGH COINS"
        cargo[slot] = item
        return replace(state, cargo=tuple(cargo), coins=state.coins-cost,
                       available=tuple(x for x in state.available if x != item),
                       bought=state.bought+(item,), paid=state.paid+cost), "BOUGHT "+item
    return state, "INVALID ACTION"


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


def apply(timeline, action):
    if action == ('undo',):
        if not timeline.history:
            return timeline, "NOTHING TO UNDO"
        return Timeline(timeline.history[-1], timeline.history[:-1]), "UNDO"
    state, message = step(timeline.state, action)
    if state == timeline.state:
        return timeline, message
    return Timeline(state, timeline.history+(timeline.state,)), message


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

    def reset(self):
        self.timeline = Timeline()
        self.selected = 0
        self.locked = False
        self.message = "CHOOSE A SLOT"
        self.anchor = None
        self.option = 0

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

    def options(self):
        state = self.state
        if state.phase == 0:
            return [('load', self.selected, item) for item in state.box]+[('load', self.selected, '')]
        if state.phase in (1, 3):
            return [('sell', self.selected)]+[('buy', self.selected, item) for item, cost in STOCK[market_index(state)]]
        return [('select_swap', self.selected)] if state.phase == 2 else [('finished',)]

    def label(self, action):
        if action[0] == 'load':
            return 'LOAD '+action[2] if action[2] else 'UNLOAD TO BOX'
        if action[0] == 'sell':
            return 'SELL / TOOLS GIVE 0'
        if action[0] == 'buy':
            item = action[2]
            return 'BUY '+item+' COST 1'+(' / SOLD OUT' if item not in self.state.available else '')
        if action[0] == 'select_swap':
            return 'CHOOSE SWAP END' if self.anchor is not None else 'CHOOSE SWAP START'
        return self.state.outcome.upper()

    def forecast(self):
        action = self.options()[self.option]
        if action[0] == 'select_swap':
            if self.anchor is None:
                return self.state
            action = ('swap', self.anchor, self.selected)
        return step(self.state, action)[0]

    def act(self, action):
        self.timeline, self.message = apply(self.timeline, action)
        self.anchor = None
        self.option = min(self.option, len(self.options())-1)

    def control(self, pressed, held):
        # 確定後も複数キー拒否後も全解放を待つ。Rもゲート中は待機。
        if self.locked:
            if not held:
                self.locked = False
            return
        if 'R' in pressed:
            self.reset()
            self.locked = True
            return
        if len(held) > 1 or len(pressed) > 1:
            self.locked = bool(held)
            return
        if len(pressed) != 1 or pressed != held:
            return
        key = next(iter(pressed))
        if key == 'X':
            if self.anchor is not None:
                self.anchor = None
                self.message = "SWAP CANCELLED"
            else:
                self.act(('undo',))
            self.locked = True
            return
        if self.state.outcome != 'journey':
            return
        if key in ('LEFT', 'RIGHT'):
            self.selected = (self.selected+(1 if key == 'RIGHT' else -1)) % 4
            self.option = min(self.option, len(self.options())-1)
        elif key == 'Z':
            if self.state.phase == 2:
                if self.anchor is None:
                    self.anchor = self.selected
                    self.message = "SELECT ANOTHER SLOT, THEN Z"
                else:
                    self.act(('swap', self.anchor, self.selected))
            else:
                self.act(self.options()[self.option])
        elif key == 'SPACE':
            self.act(('next',))
        elif key in ('UP', 'DOWN'):
            self.option = (self.option+(1 if key == 'DOWN' else -1)) % len(self.options())
        else:
            return
        self.locked = True

    def draw(self, p):
        state = self.state
        p.cls(1)
        p.text(8, 8, "CARGO MERCHANT / STAGE 5", 10)
        p.text(8, 22, PHASES[state.phase]+"   COINS "+str(state.coins)+" / GOAL "+str(GOAL), 7)
        p.text(8, 34, "BOX "+" ".join(state.box), 6)
        future = self.forecast()
        for slot, item in enumerate(state.cargo):
            x = 8+slot*90
            p.rect(x, 52, 84, 94, 2)
            p.rectb(x, 52, 84, 94, 10 if slot == self.selected else 5)
            if slot == self.anchor:
                p.rectb(x+2, 54, 80, 90, 11)
            p.text(x+5, 59, str(slot+1)+": "+(item or 'EMPTY'), 7)
            if item:
                color = {'C': 12, 'M': 8, 'O': 13, 'L': 10, 'K': 6, 'T': 9}[item[0]]
                p.circ(x+42, 87, 12, color)
                p.text(x+40, 85, item[0], 1)
                if enhanced(state.cargo, slot):
                    p.text(x+5, 103, "ENHANCED", 11)
            for market in range(2):
                quote = str(price(state.cargo, slot, market))
                if item and future.cargo[slot] == item:
                    after = price(future.cargo, slot, market)
                    if after != price(state.cargo, slot, market):
                        quote += " > "+str(after)
                p.text(x+5, 117+12*market, "M"+str(market+1)+" $"+quote, 7)
        p.text(8, 156, "L -> C   K -> M   T -> O / ADJACENT ONLY", 6)
        p.text(8, 168, "MARKET 1: "+"   ".join(k+" "+str(BASE[0][i])+"+"+str(BONUS[0][i]) for i, k in enumerate(KINDS)), 7)
        p.text(8, 180, "MARKET 2: "+"   ".join(k+" "+str(BASE[1][i])+"+"+str(BONUS[1][i]) for i, k in enumerate(KINDS)), 7)
        p.text(8, 198, 'UP/DOWN: '+self.label(self.options()[self.option]), 10)
        p.text(8, 210, 'OPTION '+str(self.option+1)+'/'+str(len(self.options()))+'   SPACE: NEXT STOP', 6)
        p.text(8, 224, "FORECAST COINS "+str(state.coins)+" > "+str(future.coins)+"   UNDO "+str(len(self.timeline.history)), 6)
        p.text(8, 242, self.message, 11)
        p.text(8, 256, "RESULT: "+state.outcome.upper()+" / UNSOLD CARGO IS KEPT", 7)
        p.text(8, 276, "LEFT/RIGHT SELECT   Z CONFIRM   R RESET", 7)
        p.text(8, 290, "X UNDO (OR CANCEL SWAP)   ESC QUIT / FICTIONAL GAME PRICES", 6)

DEMO_KEYS = ('Z', 'RIGHT', 'DOWN', 'DOWN', 'DOWN', 'Z', 'RIGHT', 'UP', 'UP', 'UP', 'Z', 'RIGHT', 'DOWN', 'DOWN', 'Z', 'SPACE', 'RIGHT', 'UP', 'Z', 'DOWN', 'Z', 'SPACE', 'RIGHT', 'Z', 'RIGHT', 'RIGHT', 'Z', 'SPACE', 'RIGHT', 'UP', 'Z', 'RIGHT', 'RIGHT', 'Z', 'SPACE')


def main(headless=False, screenshot=None, demo=False, preview=False):
    from pathlib import Path
    from dataclasses import asdict
    import json
    import pyxel as p
    target = Path(screenshot).resolve() if screenshot else None
    if target:
        target.parent.mkdir(parents=True, exist_ok=True)
    app = App()
    p.init(368, 304, title='Cargo Merchant', fps=30, display_scale=2, headless=headless)
    keys = {key: getattr(p, 'KEY_'+key) for key in ('LEFT', 'RIGHT', 'UP', 'DOWN', 'Z', 'SPACE', 'X', 'R')}
    demo_keys = DEMO_KEYS
    if preview:
        demo_keys = DEMO_KEYS[:18]+('RIGHT',)  # 積載→市場1→灯を選択。まだ売らない。
    limit = max(12, 2*len(demo_keys)+4) if demo or preview else 12

    def update():
        if headless and p.frame_count >= limit:
            if target:
                p.screenshot(str(target), scale=2)
                record = {'stage': 5, 'mode': 'preview' if preview else 'checkpoint' if demo else 'initial',
                          'input_kind': 'synthetic controller keys; not OS/human input',
                          'frames': p.frame_count, 'state': asdict(app.state),
                          'selected': app.selected, 'message': app.message}
                target.with_suffix('.json').write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding='utf-8')
            p.quit()
            return
        if headless and (demo or preview):
            index = p.frame_count//2
            pressed = {demo_keys[index]} if p.frame_count%2 == 0 and index < len(demo_keys) else set()
            app.control(pressed, pressed)
        else:
            app.control({key for key, code in keys.items() if p.btnp(code)},
                        {key for key, code in keys.items() if p.btn(code)})
    p.run(update, lambda: app.draw(p))
    return app


if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--demo-preview', action='store_true')
    parser.add_argument('--screenshot')
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.demo_preview)
AFTER STEP 05

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

元のゲームの状況 C-01-g1 です。箱には C1/M1/M2/O1/L1/K1/T1。市場1の値段はふつう 2/2/2・追加 4/0/0、市場2はふつう 2/3/3・追加 0/2/2 です。M3 は市場1で1金で1回だけ買えます。道具が効くのは左右となりの1枠だけで、道具そのものは0金、目標は10金です。

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

比べる点5ステップの教材元の完成版
段階の範囲01 は道具なしの決まった積み方と市場1、02 は道具ありの決まった積み方と市場1、03 は M1-L1-M2-K1 の決まった積み方と2つの市場です。04 と 05 は元のゲームの状況 C です。3つの決まった箱 A・B・C があり、出発するときの箱から始めます。
状態とふつうの操作04 と 05 の9つの状態の項目と、load/swap/sell/buy/next は元のゲームの状況 C と同じです。05 には、すべての状態を戻せる Undo があります。同じ状態を持ち、画面側で履歴を残して Undo します。
表示と入力文字(ASCII)と図形で表示し、Space で次へ進みます。キーが複数押されたら受け付けず、全部離すまで待ちます。R の優先は入力を待っている間だけです。出発時の直接の入れかえはプログラムにはありますが、画面からは積み直し(load)で行います。05 の予告は、売値と所持金が中心です。日本語の文字、操作メニューの中での「次へ」、両方の市場の売値・理由・経路のくわしい予告、タイトル画面と一時停止があります。
比較・記録・状況の選択状況 A・B、すべての枠の道具の比較、表紙、一時停止、リプレイの保存と検証、F12 は省いています。テスト用のコマンドは、プレイを保存する機能ではありません。1/2/3 の箱の選択、状況 B の広い比較、M/P/E/F12 キー、バージョンと SHA-256 付きの再現記録があります。
素材と価格外部の画像・日本語の文字・音は使いません。金額はすべて、架空のゲームの値です。Noto をもとにした日本語の文字を同梱しています。道具や市場の値段も、架空のパズルのルールです。

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

  1. model.py: enhanced / price / quotes のwide比較
  2. model.py: initial / step と全Stateフィールド
  3. model.py: actions と有限bank A/B
  4. model.py: replay_data / check_replay の状態hash検証
  5. app.py: act / forecast / update の解放ゲート
自分のゲームに変えるなら

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

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