完成サンプルをブラウザーで遊ぶ
この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。
完成版の主な操作:左右枠/上下操作、Z決定、X戻す、R再開、P説明、1〜3相場
ボタンを押すと、ゲームの読み込みが始まります。
画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。
うまく表示されないとき
ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。
状態が変わるたびに、値段を計算し直す。
いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、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:終了
- 難易度の目安
- 初級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
- 学習順に迷ったら
- おすすめの学ぶ順番を見る
コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。
教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。
四枠の荷台と売値を作る
タプルの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 の一部です。前のステップから追加・変更したところだけを抜き出しています。
value = price(state.cargo, slot, market)
cargo[slot] = ''実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 01_prices.py.venv/bin/python 01_prices.py- プログラムの番号0〜3と、画面の1〜4を区別する
- 売ったあとも枠は4つのまま
- 空の枠を売ってもお金は増えない
cargo.pop(slot) を使うと、枠の数が減ってしまいます。cargo[slot] に空の文字列を入れて、4つの枠を保ちましょう。BASE の市場1の値だけを (3, 2, 2) に変えて、もう一度起動してみましょう。C1 を売ると3金、続けて M1 を売ると合計5金になります。確かめたら (2, 2, 2) に戻します。
このステップのコード全文(188行)
# 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)隣の道具で価格を変える
今の並びから、毎回値段を計算する。
荷物を 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 の一部です。前のステップから追加・変更したところだけを抜き出しています。
return any(0 <= other < 4 and cargo[other] and cargo[other][0] == tool
for other in (slot-1, slot+1))実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 02_neighbours.py.venv/bin/python 02_neighbours.py- 道具は売ると0金
- 両どなりに道具があっても、追加は1回だけ
- 売る順番で合計がちがう
price を呼びましょう。START_CARGO = ("L1", "C1", "L2", "") に変えると、C1 は6金です。両側に灯があっても、足して10金にはなりません。この L2 は練習用で、元のゲームの状況 C の箱には入っていません。確かめたら元に戻してください。
このステップのコード全文(202行)
# 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)休憩所で二枠を交換する
今の場面によって、できる操作を変える。
「市場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 の一部です。前のステップから追加・変更したところだけを抜き出しています。
cargo[i], cargo[j] = cargo[j], cargo[i]実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 03_rest_stop.py.venv/bin/python 03_rest_stop.py- 市場の中では入れかえられない
- 休憩所での入れかえは何回でもできる
- 帰っても、残った荷物は売られない
R → Space → 2番で Z → X と押してみましょう。cargo も金額も変わらず、入れかえる元を示す緑の枠だけが消えます。続けて Space → 1番と3番を売る → Space と押し、8金で帰れることを確かめます。
このステップのコード全文(252行)
# 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)出発箱と有限の仕入れを加える
所持金・空いている枠・在庫を、まとめて更新する。
ここからは、元のゲームの状況 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 の一部です。前のステップから追加・変更したところだけを抜き出しています。
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を展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 04_markets.py.venv/bin/python 04_markets.py- 同じ ID の品を2つの枠に積めない
- 空いている枠・在庫・所持金を確かめる
- 売上から仕入れの金額を引いた所持金で、成功かを判定する
出発の箱から何も積まずに Space で市場1へ進み、BUY を確定してみましょう。「NOT ENOUGH COINS」(お金が足りない)と出ます。箱に戻らず、空いている枠で買おうとしても、お金は増えも減りもしません。R でやり直せます。
このステップのコード全文(316行)
# 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)商談全体を戻して予告を見せる
書きかえない状態を、履歴に積み重ねる。
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 の一部です。前のステップから追加・変更したところだけを抜き出しています。
return Timeline(state, timeline.history+(timeline.state,)), message実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 05_undo_forecast.py.venv/bin/python 05_undo_forecast.py- 買い物を Undo すると、在庫も戻る
- 帰ったあとでも、戻して取り引きを続けられる
- 予告・カーソル・受け付けなかった操作では、履歴は増えない
cargo だけを戻すと、金額や M3 の在庫と食いちがってしまいます。状態が変わった操作ごとに、State 全体を保存します。M3 を買った直後に X を押してみましょう。所持金は6、1番は EMPTY、M3 はまた買える状態になり、paid は0に戻ります。もう一度買うと、5金・paid 1 です。R のあとに X を押すと、「NOTHING TO UNDO」(戻せる操作がない)と出ます。
このステップのコード全文(354行)
# 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)仕組みがわかったら、元の完成版を読んでみよう。
元のゲームの状況 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 をもとにした日本語の文字を同梱しています。道具や市場の値段も、架空のパズルのルールです。 |
完成版のコードで、次に読むところ
- model.py: enhanced / price / quotes のwide比較
- model.py: initial / step と全Stateフィールド
- model.py: actions と有限bank A/B
- model.py: replay_data / check_replay の状態hash検証
- 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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。
うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る
