完成サンプルをブラウザーで遊ぶ
この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。
完成版の主な操作:出発前: 左右箱/上下行先、Z移動、C入替。駅: Z販売/C手へ/V戻す、Space発車、X戻す
ボタンを押すと、ゲームの読み込みが始まります。
画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。
うまく表示されないとき
ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。
持ち物と注文を、まとめて確定する。
いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、5つのステップで作っていきます。どのステップのファイルもそれだけで動くので、動きを確かめてから次へ進みましょう。
- 必要な環境
- Python 3.11以上 / Pyxel 2.9.9
- 知っておくとよいこと
- 変数・if・for・関数 / リスト・タプルの先頭の操作 / 書きかえない State と replace は本文で説明します
- 教材に入っているもの
- ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
- 完成した教材の操作
- 上下:棚A/棚B/手、準備中は行先 / 左右:準備中の品番号 / Z:販売、準備中は移動 / C:手へ取る、準備中は次の品と交換 / V:棚の手前へ戻す / Space:出発/早発車 / X:05のUndo / R:初期化 / Esc:終了
- 難易度の目安
- 初級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
- 学習順に迷ったら
- おすすめの学ぶ順番を見る
コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。
教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。
棚の手前と、一個だけの手を作る
種類と、1つ1つの番号を分けて持つ。
棚 A と棚 B にはそれぞれ3個まで入り、手には1個だけ持てます。上下で「棚 A・棚 B・手」を選び、C で棚のいちばん手前を取り、V で選んだ棚の手前に戻します。手がふさがっていると次を取れず、いっぱいの棚には戻せません。
STOCK は、お弁当1つ1つの種類を並べた表です。F は魚、V は野菜、E は卵です。同じ F でも、番号0と1は別のお弁当です。画面の番号はプログラムの番号+1なので、「5:E」は番号4の卵です。種類だけで持ち物を記録すると、同じ種類を2個持ったときに、片方が消えてしまうまちがいが起きます。
shelves(棚)のタプルをいったんリストにコピーし、pop(0) で先頭だけを取ります。戻すときの insert(0, item) も先頭に入れます。棚から消す処理と手に入れる処理を、同じ replace で一度に確定するので、同じお弁当が2か所に同時にある、ということが起きません。
この段階は、注文・駅・作業回数のない、持ちかえの練習です。奥のお弁当を直接ぬき出したり、棚 A から棚 B へ1回の操作で移したりする機能は作りません。
下のコードは 01_hand.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
shelves[place].insert(0, state.hand)
return replace(state, shelves=tuple(tuple(row) for row in shelves), hand=-1)実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 01_hand.py.venv/bin/python 01_hand.py- 手は1個だけ
- 棚は3個まで
- 取るのも戻すのも、手前だけ
append を使うと、いちばん奥に入ってしまいます。このゲームのルールは「手前に戻す」なので、insert(0, item) を使います。C で取る → 下でいっぱいの棚 B を選ぶ → V と押してみましょう。BLOCKED(できない)と出て、手も棚も変わりません。上 → V なら棚 A に戻せます。
このステップのコード全文(160行)
# MIT License
#
# Copyright (c) 2026 Pyxel 100 contributors
#
# 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.
#
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
"""お弁当列車・教材1/5。原作Bを段階的に組み立てる。"""
from dataclasses import dataclass, replace
STOCK = ("F", "F", "V", "V", "E", "E")
KINDS = "FVE"
PRESET = ((4, 0, 1), (3, 2, 5))
ORDERS = ((1, 1, 0), (1, 0, 1), (0, 1, 1))
WORK = (3, 2, 3)
GOAL = 5
CAPACITY = 3
@dataclass(frozen=True)
class State:
shelves: tuple = PRESET
hand: int = -1
def initial():
return State()
def step(state, action):
if not isinstance(action, tuple) or len(action) != 2:
return state
command, place = action
if place not in (0, 1, 2):
return state
shelves = [list(row) for row in state.shelves]
if command == "take":
if place == 2 or state.hand >= 0 or not shelves[place]:
return state
item = shelves[place].pop(0)
return replace(state, shelves=tuple(tuple(row) for row in shelves), hand=item)
if command == "put":
if place == 2 or state.hand < 0 or len(shelves[place]) >= CAPACITY:
return state
shelves[place].insert(0, state.hand)
return replace(state, shelves=tuple(tuple(row) for row in shelves), hand=-1)
return state
class App:
def __init__(self):
self.reset()
def reset(self):
self.state = initial()
self.selection = 0
self.item = 0
self.gated = False
self.message = "ONE KEY AT A TIME"
def control(self, pressed, held):
if "R" in pressed:
self.reset()
self.gated = True
return
if self.gated:
if not pressed and not held:
self.gated = False
return
if len(pressed | held) > 1:
self.gated = True
self.message = "RELEASE ALL KEYS"
return
if len(pressed) != 1:
return
key = next(iter(pressed))
if key in ("UP", "DOWN"):
self.selection = (self.selection+(-1 if key=="UP" else 1))%3
self.gated = True
return
action = ("take", self.selection) if key=="C" else ("put", self.selection) if key=="V" else None
if action is None:
return
before = self.state
self.state = step(self.state, action)
self.message = "CONFIRMED" if before != self.state else "BLOCKED / NO COST"
self.gated = True
def draw(self, p):
p.cls(1)
p.text(8, 8, "LUNCH TRAIN / STAGE 1", 10)
loading = False
p.text(8, 23, "HAND PRACTICE", 7)
for row, items in enumerate(self.state.shelves):
y = 65+row*47
p.text(8, y+10, ("> " if self.selection==row else " ")+("A" if row==0 else "B"), 10 if self.selection==row else 7)
for depth in range(CAPACITY):
x = 65+depth*75
p.rectb(x, y, 64, 30, 6)
if depth<len(items):
item = items[depth]
p.text(x+6, y+11, str(item+1)+":"+STOCK[item], 7)
p.text(65, y-9, "FRONT -> BACK", 6)
p.text(8, 170, ("> " if self.selection==2 else " ")+"HAND: "+("-" if self.state.hand<0 else str(self.state.hand+1)+":"+STOCK[self.state.hand]), 10 if self.selection==2 else 7)
p.text(8, 255, self.message, 7)
p.text(8, 280, "UP/DOWN SELECT", 6)
p.text(8, 301, "C TAKE V PUT", 6)
p.text(8, 313, "R RESET ESC QUIT", 6)
DEMO_KEYS = ('C',)
CONTROL_KEYS = ("UP", "DOWN", "LEFT", "RIGHT", "Z", "C", "V", "SPACE", "X", "R")
def main(headless=False, screenshot=None, demo=False):
from pathlib import Path
import pyxel as p
screenshot = str(Path(screenshot).resolve()) if screenshot else None
if screenshot:
Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
app = App()
p.init(384, 330, title="Lunch Train", fps=30, display_scale=2, headless=headless)
keys = {k: getattr(p, "KEY_"+k) for k in CONTROL_KEYS}
limit = max(12, 2*len(DEMO_KEYS)+3) if demo else 12
def update():
if headless and p.frame_count >= limit:
if screenshot:
p.screenshot(screenshot, scale=2)
p.quit()
return
if headless and demo:
index = p.frame_count//2
pressed = {DEMO_KEYS[index]} if p.frame_count%2==0 and index<len(DEMO_KEYS) else set()
app.control(pressed, pressed)
else:
app.control({k for k,v in keys.items() if p.btnp(v)}, {k for k,v in keys.items() if p.btn(v)})
p.run(update, lambda: app.draw(p))
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("--screenshot")
args = parser.parse_args()
main(args.headless_smoke, args.screenshot, args.demo_checkpoint)駅の注文に合う弁当だけ売る
持ち物と、残りの注文を一緒に更新する。
Z で、選んだ棚の手前のお弁当か、手に持っているお弁当を売れるようにします。最初の駅の注文は F1・V1・E0 です。卵は手前にあっても、この駅では売れません。C で卵を手によけると、その奥の魚が手前に出てきます。
売るお弁当の番号が決まったら、STOCK[item] で種類を調べ、KINDS.index で注文表の列に変えます。注文が残っていれば1つ減らし、棚か手からそのお弁当を取り除いて、sold(売った記録)に加えます。売上と在庫を、同じ状態の更新で一度に確定します。
注文のないお弁当を売ろうとしても、何も消えません。もし先に棚から取り除いてから注文を調べると、売れなかったはずのお弁当だけが消えてしまいます。「条件を調べてから更新する」という順番を守りましょう。
まだ駅は1つだけで、作業回数も無制限の練習です。F と V の注文を終えると、その駅ではもう売れません。手に残した卵を次の駅へ運ぶ仕組みは、次の段階で加えます。
下のコードは 02_orders.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if state.orders[kind] <= 0:
return state
orders = list(state.orders)
orders[kind] -= 1実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 02_orders.py.venv/bin/python 02_orders.py- 注文のない品は売れない
- 手に持った品も売れる
- 売ったお弁当を、売った記録に移す
pop しないようにしましょう。残りの注文を確かめてから、注文・持ち物・売った記録をまとめて変えます。R → 下 → C → 下 → Z と押してみましょう。野菜を手に取り、手から売ります。残りの注文は 1/0/0、SOLD 1、手は空になります。
このステップのコード全文(180行)
# MIT License
#
# Copyright (c) 2026 Pyxel 100 contributors
#
# 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.
#
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
"""お弁当列車・教材2/5。原作Bを段階的に組み立てる。"""
from dataclasses import dataclass, replace
STOCK = ("F", "F", "V", "V", "E", "E")
KINDS = "FVE"
PRESET = ((4, 0, 1), (3, 2, 5))
ORDERS = ((1, 1, 0), (1, 0, 1), (0, 1, 1))
WORK = (3, 2, 3)
GOAL = 5
CAPACITY = 3
@dataclass(frozen=True)
class State:
shelves: tuple = PRESET
hand: int = -1
orders: tuple = (1, 1, 0)
sold: tuple = ()
def initial():
return State()
def step(state, action):
if not isinstance(action, tuple) or len(action) != 2:
return state
command, place = action
if place not in (0, 1, 2):
return state
shelves = [list(row) for row in state.shelves]
if command == "take":
if place == 2 or state.hand >= 0 or not shelves[place]:
return state
item = shelves[place].pop(0)
return replace(state, shelves=tuple(tuple(row) for row in shelves), hand=item)
if command == "put":
if place == 2 or state.hand < 0 or len(shelves[place]) >= CAPACITY:
return state
shelves[place].insert(0, state.hand)
return replace(state, shelves=tuple(tuple(row) for row in shelves), hand=-1)
if command == "sell":
item = state.hand if place == 2 else shelves[place][0] if shelves[place] else -1
if item < 0:
return state
kind = KINDS.index(STOCK[item])
if state.orders[kind] <= 0:
return state
orders = list(state.orders)
orders[kind] -= 1
hand = state.hand
if place == 2:
hand = -1
else:
shelves[place].pop(0)
return replace(state, shelves=tuple(tuple(row) for row in shelves),
hand=hand, orders=tuple(orders), sold=state.sold+(item,))
return state
class App:
def __init__(self):
self.reset()
def reset(self):
self.state = initial()
self.selection = 0
self.item = 0
self.gated = False
self.message = "ONE KEY AT A TIME"
def control(self, pressed, held):
if "R" in pressed:
self.reset()
self.gated = True
return
if self.gated:
if not pressed and not held:
self.gated = False
return
if len(pressed | held) > 1:
self.gated = True
self.message = "RELEASE ALL KEYS"
return
if len(pressed) != 1:
return
key = next(iter(pressed))
if key in ("UP", "DOWN"):
self.selection = (self.selection+(-1 if key=="UP" else 1))%3
self.gated = True
return
action = ("take", self.selection) if key=="C" else ("put", self.selection) if key=="V" else ("sell", self.selection) if key=="Z" else None
if action is None:
return
before = self.state
self.state = step(self.state, action)
self.message = "CONFIRMED" if before != self.state else "BLOCKED / NO COST"
self.gated = True
def draw(self, p):
p.cls(1)
p.text(8, 8, "LUNCH TRAIN / STAGE 2", 10)
loading = False
p.text(8, 23, "HAND PRACTICE"+" / FIRST STATION / NO WORK LIMIT", 7)
for row, items in enumerate(self.state.shelves):
y = 65+row*47
p.text(8, y+10, ("> " if self.selection==row else " ")+("A" if row==0 else "B"), 10 if self.selection==row else 7)
for depth in range(CAPACITY):
x = 65+depth*75
p.rectb(x, y, 64, 30, 6)
if depth<len(items):
item = items[depth]
p.text(x+6, y+11, str(item+1)+":"+STOCK[item], 7)
p.text(65, y-9, "FRONT -> BACK", 6)
p.text(8, 170, ("> " if self.selection==2 else " ")+"HAND: "+("-" if self.state.hand<0 else str(self.state.hand+1)+":"+STOCK[self.state.hand]), 10 if self.selection==2 else 7)
p.text(8, 202, "ORDERS F/V/E: "+" / ".join(map(str,self.state.orders)), 11)
p.text(8, 218, "SOLD: "+str(len(self.state.sold)), 7)
p.text(8, 255, self.message, 7)
p.text(8, 280, "UP/DOWN SELECT", 6)
p.text(8, 301, "Z SELL C TAKE V PUT", 6)
p.text(8, 313, "R RESET ESC QUIT", 6)
DEMO_KEYS = ('C', 'Z', 'DOWN', 'Z')
CONTROL_KEYS = ("UP", "DOWN", "LEFT", "RIGHT", "Z", "C", "V", "SPACE", "X", "R")
def main(headless=False, screenshot=None, demo=False):
from pathlib import Path
import pyxel as p
screenshot = str(Path(screenshot).resolve()) if screenshot else None
if screenshot:
Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
app = App()
p.init(384, 330, title="Lunch Train", fps=30, display_scale=2, headless=headless)
keys = {k: getattr(p, "KEY_"+k) for k in CONTROL_KEYS}
limit = max(12, 2*len(DEMO_KEYS)+3) if demo else 12
def update():
if headless and p.frame_count >= limit:
if screenshot:
p.screenshot(screenshot, scale=2)
p.quit()
return
if headless and demo:
index = p.frame_count//2
pressed = {DEMO_KEYS[index]} if p.frame_count%2==0 and index<len(DEMO_KEYS) else set()
app.control(pressed, pressed)
else:
app.control({k for k,v in keys.items() if p.btnp(v)}, {k for k,v in keys.items() if p.btn(v)})
p.run(update, lambda: app.draw(p))
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("--screenshot")
args = parser.parse_args()
main(args.headless_smoke, args.screenshot, args.demo_checkpoint)作業回数で駅を進める
実際にできた1手で、作業回数を減らし、自動で発車する。
元のゲームの状況 B の、走っている間のルールを加えます。3つの駅の作業回数は3・2・3、注文は F/V/E の順に 1/1/0、1/0/1、0/1/1 です。売る・取る・戻すが実際にできたら、それぞれ1回の作業です。残りが0になると次の駅へ自動で発車し、Space を押せば、残りの作業を使わずに早めに発車できます。
大事なのは、手に持った物を次の駅へそのまま持っていくことです。ただで棚に戻したりはしません。最初の卵を手によけて、魚と野菜を売る3回の作業で2つ目の駅に着きます。そこで卵の注文に使えます。
step では、持ち物と注文を更新した新しい状態 n を作り、その remaining(残りの作業回数)が0なら advance(次の駅へ)に渡します。操作が受け付けられなかったときは元の State を返すので、作業回数も駅も進みません。Space による早めの発車は、actions(作業回数)には数えません。
最後の駅を出たときに、合計5個以上売れていれば WON(成功)、足りなければ SHORT(不足)です。6個売るやり方もありますが、5個で十分成功です。目標の数に届いた瞬間に終わるのではなく、最後の駅を出るまで操作できます。出発前の積み直しはまだ省いて、最初から走っている状態で始めます。
下のコードは 03_stations.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
return advance(n) if n.remaining == 0 else n実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 03_stations.py.venv/bin/python 03_stations.py- できなかった操作では、作業回数は減らない
- 手に持った物は次の駅へ持っていく
- 5個でも成功。判定は最後に発車したとき
advance は駅と作業回数だけを変え、手・棚・注文はそのまま引きつぎます。R → C → Space と押してみましょう。最初の卵を持ったまま、何も売らずに2つ目の駅へ進みます。HAND 5:E・残りの作業2・SOLD 0 になることを確かめます。
このステップのコード全文(221行)
# MIT License
#
# Copyright (c) 2026 Pyxel 100 contributors
#
# 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.
#
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
"""お弁当列車・教材3/5。原作Bを段階的に組み立てる。"""
from dataclasses import dataclass, replace
STOCK = ("F", "F", "V", "V", "E", "E")
KINDS = "FVE"
PRESET = ((4, 0, 1), (3, 2, 5))
ORDERS = ((1, 1, 0), (1, 0, 1), (0, 1, 1))
WORK = (3, 2, 3)
GOAL = 5
CAPACITY = 3
@dataclass(frozen=True)
class State:
station: int
remaining: int
shelves: tuple
hand: int
warehouse: tuple
sold: tuple
orders: tuple
status: str = 'load'
transfers: int = 0
actions: int = 0
departed: tuple = ()
def initial():
return State(0, WORK[0], PRESET, -1, (), (), ORDERS, status='play', departed=PRESET)
def advance(s):
if s.station == len(WORK) - 1:
return replace(s, remaining=0, status='won' if len(s.sold) >= GOAL else 'short')
return replace(s, station=s.station + 1, remaining=WORK[s.station + 1])
def step(s, a):
if s.status in ('won', 'short') or not isinstance(a, tuple) or (not a):
return s
if a == ('depart',):
return advance(s)
shelves = [list(sh) for sh in s.shelves]
hand = s.hand
sold = s.sold
orders = [list(order) for order in s.orders]
transfer = 0
if a[0] in ('sell', 'take') and len(a) in (2, 3):
src = a[1]
depth = a[2] if len(a) == 3 else 0
if src not in (0, 1, 2) or not isinstance(depth, int) or depth < 0:
return s
if src == 2:
if hand < 0 or a[0] == 'take' or depth != 0:
return s
item = hand
else:
if depth >= len(shelves[src]) or depth != 0:
return s
item = shelves[src][depth]
if a[0] == 'take':
if hand >= 0:
return s
shelves[src].pop(depth)
hand = item
transfer = 1
else:
kind = KINDS.index(STOCK[item])
if orders[s.station][kind] <= 0:
return s
orders[s.station][kind] -= 1
sold = sold + (item,)
if src == 2:
hand = -1
else:
shelves[src].pop(depth)
elif a[0] == 'put' and len(a) == 2:
dest = a[1]
if dest not in (0, 1) or hand < 0 or len(shelves[dest]) >= CAPACITY:
return s
shelves[dest].insert(0, hand)
hand = -1
transfer = 1
else:
return s
n = replace(s, shelves=tuple((tuple(sh) for sh in shelves)), hand=hand, sold=sold, orders=tuple((tuple(row) for row in orders)), remaining=s.remaining - 1, actions=s.actions + 1, transfers=s.transfers + transfer)
return advance(n) if n.remaining == 0 else n
def invariant(s):
owned = sum(s.shelves, ()) + s.warehouse + s.sold + ((s.hand,) if s.hand >= 0 else ())
assert sorted(owned) == list(range(len(STOCK))) and len(set(owned)) == len(owned)
assert all((len(sh) <= CAPACITY for sh in s.shelves))
assert all((n >= 0 for row in s.orders for n in row))
assert sum((sum(row) for row in ORDERS)) - sum((sum(row) for row in s.orders)) == len(s.sold)
assert 0 <= s.station < len(WORK) and 0 <= s.remaining <= WORK[s.station]
assert s.transfers + s.sold.__len__() == s.actions
assert s.status in ('load', 'play', 'won', 'short')
class App:
def __init__(self):
self.reset()
def reset(self):
self.state = initial()
self.selection = 0
self.item = 0
self.gated = False
self.message = "ONE KEY AT A TIME"
def control(self, pressed, held):
if "R" in pressed:
self.reset()
self.gated = True
return
if self.gated:
if not pressed and not held:
self.gated = False
return
if len(pressed | held) > 1:
self.gated = True
self.message = "RELEASE ALL KEYS"
return
if len(pressed) != 1:
return
key = next(iter(pressed))
if self.state.status in ("won", "short"):
return
if key in ("UP", "DOWN"):
self.selection = (self.selection+(-1 if key=="UP" else 1))%3
self.gated = True
return
action = ("take", self.selection) if key=="C" else ("put", self.selection) if key=="V" else ("sell", self.selection) if key=="Z" else ("depart",) if key=="SPACE" else None
if action is None:
return
before = self.state
self.state = step(self.state, action)
self.message = "CONFIRMED" if before != self.state else "BLOCKED / NO COST"
self.gated = True
def draw(self, p):
p.cls(1)
p.text(8, 8, "LUNCH TRAIN / STAGE 3", 10)
loading = self.state.status == "load"
p.text(8, 23, "LOADING / NO TIME COST" if loading else "STATION "+str(self.state.station+1)+" / WORK "+str(self.state.remaining), 7)
for row, items in enumerate(self.state.shelves):
y = 65+row*47
p.text(8, y+10, ("> " if self.selection==row else " ")+("A" if row==0 else "B"), 10 if self.selection==row else 7)
for depth in range(CAPACITY):
x = 65+depth*75
p.rectb(x, y, 64, 30, 6)
if depth<len(items):
item = items[depth]
p.text(x+6, y+11, str(item+1)+":"+STOCK[item], 7)
p.text(65, y-9, "FRONT -> BACK", 6)
p.text(8, 170, ("> " if self.selection==2 else " ")+"HAND: "+("-" if self.state.hand<0 else str(self.state.hand+1)+":"+STOCK[self.state.hand]), 10 if self.selection==2 else 7)
p.text(8, 202, "ORDERS F/V/E: "+" / ".join(map(str,self.state.orders[self.state.station])), 11)
p.text(8, 218, "SOLD: "+str(len(self.state.sold))+" / GOAL "+str(GOAL)+" "+self.state.status.upper(), 7)
for i, order in enumerate(ORDERS):
p.text(8+i*122, 237, str(i+1)+": "+"/".join(map(str,order))+" W"+str(WORK[i]), 6)
p.text(8, 255, self.message, 7)
p.text(8, 280, "UP/DOWN SELECT", 6)
p.text(8, 301, "Z SELL C TAKE V PUT SPACE DEPART", 6)
p.text(8, 313, "R RESET ESC QUIT", 6)
DEMO_KEYS = ('C', 'Z', 'DOWN', 'Z', 'UP', 'Z', 'DOWN', 'DOWN', 'Z', 'UP', 'Z', 'Z', 'SPACE')
CONTROL_KEYS = ("UP", "DOWN", "LEFT", "RIGHT", "Z", "C", "V", "SPACE", "X", "R")
def main(headless=False, screenshot=None, demo=False):
from pathlib import Path
import pyxel as p
screenshot = str(Path(screenshot).resolve()) if screenshot else None
if screenshot:
Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
app = App()
p.init(384, 330, title="Lunch Train", fps=30, display_scale=2, headless=headless)
keys = {k: getattr(p, "KEY_"+k) for k in CONTROL_KEYS}
limit = max(12, 2*len(DEMO_KEYS)+3) if demo else 12
def update():
if headless and p.frame_count >= limit:
if screenshot:
p.screenshot(screenshot, scale=2)
p.quit()
return
if headless and demo:
index = p.frame_count//2
pressed = {DEMO_KEYS[index]} if p.frame_count%2==0 and index<len(DEMO_KEYS) else set()
app.control(pressed, pressed)
else:
app.control({k for k,v in keys.items() if p.btnp(v)}, {k for k,v in keys.items() if p.btn(v)})
p.run(update, lambda: app.draw(p))
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("--screenshot")
args = parser.parse_args()
main(args.headless_smoke, args.screenshot, args.demo_checkpoint)出発前だけ自由に積み直す
「準備」と「走行」の2つの場面。
最初は LOADING(積みこみ)の場面です。左右でお弁当の番号1〜6を、上下で「棚 A・棚 B・倉庫」を選び、Z で選んだお弁当を移します。棚に入れるときは手前に入ります。C は、選んだお弁当と、同じ棚のその次のお弁当を入れかえます。最後のお弁当のときは、先頭と入れかえます。Space で出発します。
準備の間は時間がかからず、手を使わずに積み直せます。ただし、棚は3個までというルールは同じです。倉庫に出したお弁当は、出発したあとは取り出せません。state.status を見て、同じ Z でも、準備中なら place(置く)、走行中なら sell(売る)という別の操作に変えます。
step の中でも場面を確かめます。表示だけを LOADING にして、裏では走行中の take(取る)が通ってしまう、という状態にしないようにしましょう。出発したときの棚は departed に残します。準備中の移動では、作業回数・売った数・移しかえた回数は増えません。
これで、元のゲームの状況 B のふつうのルールを最後まで再現した完成版です。状況 A と C、奥から取り出せる比較版、日本語の文字、走るアニメーションは省いています。プログラムでは次の駅へすぐ進み、教材のキーは1つずつ離してから押します。
下のコードは 04_loading.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if dest < 2 and len(s.shelves[dest]) >= CAPACITY:
return s実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 04_loading.py.venv/bin/python 04_loading.py- 場面ごとに、キーの意味を変える
- 準備には時間がかからない
- 出発したら倉庫からは戻せない
actions に足さないようにしましょう。時間のかからない積み直しと、駅での1回の作業は、別のルールです。R → 右を4回 → C と押してみましょう。選んだ「5:E」が次の「1:F」と入れかわり、棚 A は「1:F・5:E・2:F」になります。LOADING のまま、残りの作業は3です。
このステップのコード全文(272行)
# MIT License
#
# Copyright (c) 2026 Pyxel 100 contributors
#
# 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.
#
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
"""お弁当列車・教材4/5。原作Bを段階的に組み立てる。"""
from dataclasses import dataclass, replace
STOCK = ("F", "F", "V", "V", "E", "E")
KINDS = "FVE"
PRESET = ((4, 0, 1), (3, 2, 5))
ORDERS = ((1, 1, 0), (1, 0, 1), (0, 1, 1))
WORK = (3, 2, 3)
GOAL = 5
CAPACITY = 3
@dataclass(frozen=True)
class State:
station: int
remaining: int
shelves: tuple
hand: int
warehouse: tuple
sold: tuple
orders: tuple
status: str = 'load'
transfers: int = 0
actions: int = 0
departed: tuple = ()
def initial():
return State(0, WORK[0], PRESET, -1, (), (), ORDERS, status='load', departed=())
def advance(s):
if s.station == len(WORK) - 1:
return replace(s, remaining=0, status='won' if len(s.sold) >= GOAL else 'short')
return replace(s, station=s.station + 1, remaining=WORK[s.station + 1])
def step(s, a):
if s.status in ('won', 'short') or not isinstance(a, tuple) or (not a):
return s
if s.status == 'load':
if a == ('depart',):
return replace(s, status='play', departed=s.shelves)
if a[0] == 'place' and len(a) == 3:
_, item, dest = a
if not isinstance(item, int) or not 0 <= item < len(STOCK) or dest not in (0, 1, 2):
return s
owner = next((j for j, sh in enumerate(s.shelves) if item in sh), 2)
if owner == dest:
return s
if dest < 2 and len(s.shelves[dest]) >= CAPACITY:
return s
shelves = [list(sh) for sh in s.shelves]
warehouse = list(s.warehouse)
if owner == 2:
warehouse.remove(item)
else:
shelves[owner].remove(item)
if dest == 2:
warehouse.append(item)
else:
shelves[dest].insert(0, item)
return replace(s, shelves=tuple((tuple(sh) for sh in shelves)), warehouse=tuple(warehouse))
if a[0] == 'swap_next' and len(a) == 2:
item = a[1]
for j, sh in enumerate(s.shelves):
if item in sh and len(sh) > 1:
row = list(sh)
k = row.index(item)
n = (k + 1) % len(row)
row[k], row[n] = (row[n], row[k])
shelves = list(s.shelves)
shelves[j] = tuple(row)
return replace(s, shelves=tuple(shelves))
return s
return s
if a == ('depart',):
return advance(s)
shelves = [list(sh) for sh in s.shelves]
hand = s.hand
sold = s.sold
orders = [list(order) for order in s.orders]
transfer = 0
if a[0] in ('sell', 'take') and len(a) in (2, 3):
src = a[1]
depth = a[2] if len(a) == 3 else 0
if src not in (0, 1, 2) or not isinstance(depth, int) or depth < 0:
return s
if src == 2:
if hand < 0 or a[0] == 'take' or depth != 0:
return s
item = hand
else:
if depth >= len(shelves[src]) or depth != 0:
return s
item = shelves[src][depth]
if a[0] == 'take':
if hand >= 0:
return s
shelves[src].pop(depth)
hand = item
transfer = 1
else:
kind = KINDS.index(STOCK[item])
if orders[s.station][kind] <= 0:
return s
orders[s.station][kind] -= 1
sold = sold + (item,)
if src == 2:
hand = -1
else:
shelves[src].pop(depth)
elif a[0] == 'put' and len(a) == 2:
dest = a[1]
if dest not in (0, 1) or hand < 0 or len(shelves[dest]) >= CAPACITY:
return s
shelves[dest].insert(0, hand)
hand = -1
transfer = 1
else:
return s
n = replace(s, shelves=tuple((tuple(sh) for sh in shelves)), hand=hand, sold=sold, orders=tuple((tuple(row) for row in orders)), remaining=s.remaining - 1, actions=s.actions + 1, transfers=s.transfers + transfer)
return advance(n) if n.remaining == 0 else n
def invariant(s):
owned = sum(s.shelves, ()) + s.warehouse + s.sold + ((s.hand,) if s.hand >= 0 else ())
assert sorted(owned) == list(range(len(STOCK))) and len(set(owned)) == len(owned)
assert all((len(sh) <= CAPACITY for sh in s.shelves))
assert all((n >= 0 for row in s.orders for n in row))
assert sum((sum(row) for row in ORDERS)) - sum((sum(row) for row in s.orders)) == len(s.sold)
assert 0 <= s.station < len(WORK) and 0 <= s.remaining <= WORK[s.station]
assert s.transfers + s.sold.__len__() == s.actions
assert s.status in ('load', 'play', 'won', 'short')
class App:
def __init__(self):
self.reset()
def reset(self):
self.state = initial()
self.selection = 0
self.item = 0
self.gated = False
self.message = "ONE KEY AT A TIME"
def control(self, pressed, held):
if "R" in pressed:
self.reset()
self.gated = True
return
if self.gated:
if not pressed and not held:
self.gated = False
return
if len(pressed | held) > 1:
self.gated = True
self.message = "RELEASE ALL KEYS"
return
if len(pressed) != 1:
return
key = next(iter(pressed))
if self.state.status in ("won", "short"):
return
if key in ("UP", "DOWN"):
self.selection = (self.selection+(-1 if key=="UP" else 1))%3
self.gated = True
return
if self.state.status == "load" and key in ("LEFT", "RIGHT"):
self.item = (self.item+(-1 if key=="LEFT" else 1))%len(STOCK)
self.gated = True
return
if self.state.status == "load":
action = ("place", self.item, self.selection) if key=="Z" else ("swap_next", self.item) if key=="C" else ("depart",) if key=="SPACE" else None
else:
action = ("take", self.selection) if key=="C" else ("put", self.selection) if key=="V" else ("sell", self.selection) if key=="Z" else ("depart",) if key=="SPACE" else None
if action is None:
return
before = self.state
self.state = step(self.state, action)
self.message = "CONFIRMED" if before != self.state else "BLOCKED / NO COST"
self.gated = True
def draw(self, p):
p.cls(1)
p.text(8, 8, "LUNCH TRAIN / STAGE 4", 10)
loading = self.state.status == "load"
p.text(8, 23, "LOADING / NO TIME COST" if loading else "STATION "+str(self.state.station+1)+" / WORK "+str(self.state.remaining), 7)
for row, items in enumerate(self.state.shelves):
y = 65+row*47
p.text(8, y+10, ("> " if self.selection==row else " ")+("A" if row==0 else "B"), 10 if self.selection==row else 7)
for depth in range(CAPACITY):
x = 65+depth*75
p.rectb(x, y, 64, 30, 6)
if depth<len(items):
item = items[depth]
p.text(x+6, y+11, str(item+1)+":"+STOCK[item], 7)
p.text(65, y-9, "FRONT -> BACK", 6)
p.text(8, 170, ("> " if self.selection==2 else " ")+"HAND: "+("-" if self.state.hand<0 else str(self.state.hand+1)+":"+STOCK[self.state.hand]), 10 if self.selection==2 else 7)
if loading:
p.text(8, 39, "SELECT ITEM "+str(self.item+1)+":"+STOCK[self.item]+" / DEST "+("A", "B", "WAREHOUSE")[self.selection], 11)
p.text(8, 186, "WAREHOUSE: "+" ".join(str(i+1)+":"+STOCK[i] for i in self.state.warehouse), 7)
p.text(8, 202, "ORDERS F/V/E: "+" / ".join(map(str,self.state.orders[self.state.station])), 11)
p.text(8, 218, "SOLD: "+str(len(self.state.sold))+" / GOAL "+str(GOAL)+" "+self.state.status.upper(), 7)
for i, order in enumerate(ORDERS):
p.text(8+i*122, 237, str(i+1)+": "+"/".join(map(str,order))+" W"+str(WORK[i]), 6)
p.text(8, 255, self.message, 7)
if loading:
p.text(8, 274, "LEFT/RIGHT ITEM UP/DOWN DEST", 6)
p.text(8, 286, "Z PLACE C SWAP NEXT SPACE DEPART", 6)
else:
p.text(8, 274, "UP/DOWN SOURCE: A / B / HAND", 6)
p.text(8, 286, "FRONT ITEMS ONLY / HAND PERSISTS", 6)
p.text(8, 301, "PLAY: Z SELL C TAKE V PUT SPACE DEPART", 6)
p.text(8, 313, "R RESET ESC QUIT", 6)
DEMO_KEYS = ('SPACE', 'C', 'Z', 'DOWN', 'Z', 'UP', 'Z', 'DOWN', 'DOWN', 'Z', 'UP', 'Z', 'Z', 'SPACE')
CONTROL_KEYS = ("UP", "DOWN", "LEFT", "RIGHT", "Z", "C", "V", "SPACE", "X", "R")
def main(headless=False, screenshot=None, demo=False):
from pathlib import Path
import pyxel as p
screenshot = str(Path(screenshot).resolve()) if screenshot else None
if screenshot:
Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
app = App()
p.init(384, 330, title="Lunch Train", fps=30, display_scale=2, headless=headless)
keys = {k: getattr(p, "KEY_"+k) for k in CONTROL_KEYS}
limit = max(12, 2*len(DEMO_KEYS)+3) if demo else 12
def update():
if headless and p.frame_count >= limit:
if screenshot:
p.screenshot(screenshot, scale=2)
p.quit()
return
if headless and demo:
index = p.frame_count//2
pressed = {DEMO_KEYS[index]} if p.frame_count%2==0 and index<len(DEMO_KEYS) else set()
app.control(pressed, pressed)
else:
app.control({k for k,v in keys.items() if p.btnp(v)}, {k for k,v in keys.items() if p.btn(v)})
p.run(update, lambda: app.draw(p))
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("--screenshot")
args = parser.parse_args()
main(args.headless_smoke, args.screenshot, args.demo_checkpoint)準備も発車も一手ずつ戻す
状態が変わった操作だけを、履歴に入れる。
X で、直前の1手を戻します。Timeline には今の State と過去の State を持たせ、apply で何かが変わったときだけ、その前の状態を history(履歴)に積みます。State の中の棚・倉庫・注文・売った記録もタプルなので、保存した過去があとで書きかわることはありません。
Undo(1つ前に戻す)は、売る操作だけでなく、取る・戻す・発車・出発前の積み直しにも使えます。受け付けられなかった操作では、履歴は増えません。時間のかからない準備も大事な判断なので、「作業回数が増えたときだけ記録する」というやり方では足りません。
WON や SHORT になっても、X を先に受け付けるので、最後の駅の状態に戻れます。R は、棚・手・売上・注文・履歴を最初に戻し、LOADING から始めます。カーソルで選んでいる位置は画面だけの情報なので、Undo の対象にはしません。
元のゲームの状況 B の最初の棚から、走っている間にたどり着けるすべての状態(3810通り)と、240通りの積みこみの状態を比べています。すべての積み直しの手順を調べつくした、という意味ではありません。次は元のゲームの invariant を読んで、同じお弁当が「棚・手・倉庫・売った先」のどこか1か所にだけあることを確かめてみましょう。
下のコードは 05_undo.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
return timeline if state == timeline.state else Timeline(state, timeline.history+(timeline.state,))実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 05_undo.py.venv/bin/python 05_undo.py- 時間のかからない準備も Undo できる
- できなかった操作では履歴は変わらない
- 終わったあとでも1手戻せる
State 全体を保存し、準備や発車も1手として扱います。R → 右を4回 → C → X と押してみましょう。棚 A が元の「5:E・1:F・2:F」に戻ります。選んでいる番号は5のままなので、画面のカーソルと、状態の Undo が別のものだと分かります。
このステップのコード全文(297行)
# MIT License
#
# Copyright (c) 2026 Pyxel 100 contributors
#
# 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.
#
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
"""お弁当列車・教材5/5。原作Bを段階的に組み立てる。"""
from dataclasses import dataclass, replace
STOCK = ("F", "F", "V", "V", "E", "E")
KINDS = "FVE"
PRESET = ((4, 0, 1), (3, 2, 5))
ORDERS = ((1, 1, 0), (1, 0, 1), (0, 1, 1))
WORK = (3, 2, 3)
GOAL = 5
CAPACITY = 3
@dataclass(frozen=True)
class State:
station: int
remaining: int
shelves: tuple
hand: int
warehouse: tuple
sold: tuple
orders: tuple
status: str = 'load'
transfers: int = 0
actions: int = 0
departed: tuple = ()
def initial():
return State(0, WORK[0], PRESET, -1, (), (), ORDERS, status='load', departed=())
def advance(s):
if s.station == len(WORK) - 1:
return replace(s, remaining=0, status='won' if len(s.sold) >= GOAL else 'short')
return replace(s, station=s.station + 1, remaining=WORK[s.station + 1])
def step(s, a):
if s.status in ('won', 'short') or not isinstance(a, tuple) or (not a):
return s
if s.status == 'load':
if a == ('depart',):
return replace(s, status='play', departed=s.shelves)
if a[0] == 'place' and len(a) == 3:
_, item, dest = a
if not isinstance(item, int) or not 0 <= item < len(STOCK) or dest not in (0, 1, 2):
return s
owner = next((j for j, sh in enumerate(s.shelves) if item in sh), 2)
if owner == dest:
return s
if dest < 2 and len(s.shelves[dest]) >= CAPACITY:
return s
shelves = [list(sh) for sh in s.shelves]
warehouse = list(s.warehouse)
if owner == 2:
warehouse.remove(item)
else:
shelves[owner].remove(item)
if dest == 2:
warehouse.append(item)
else:
shelves[dest].insert(0, item)
return replace(s, shelves=tuple((tuple(sh) for sh in shelves)), warehouse=tuple(warehouse))
if a[0] == 'swap_next' and len(a) == 2:
item = a[1]
for j, sh in enumerate(s.shelves):
if item in sh and len(sh) > 1:
row = list(sh)
k = row.index(item)
n = (k + 1) % len(row)
row[k], row[n] = (row[n], row[k])
shelves = list(s.shelves)
shelves[j] = tuple(row)
return replace(s, shelves=tuple(shelves))
return s
return s
if a == ('depart',):
return advance(s)
shelves = [list(sh) for sh in s.shelves]
hand = s.hand
sold = s.sold
orders = [list(order) for order in s.orders]
transfer = 0
if a[0] in ('sell', 'take') and len(a) in (2, 3):
src = a[1]
depth = a[2] if len(a) == 3 else 0
if src not in (0, 1, 2) or not isinstance(depth, int) or depth < 0:
return s
if src == 2:
if hand < 0 or a[0] == 'take' or depth != 0:
return s
item = hand
else:
if depth >= len(shelves[src]) or depth != 0:
return s
item = shelves[src][depth]
if a[0] == 'take':
if hand >= 0:
return s
shelves[src].pop(depth)
hand = item
transfer = 1
else:
kind = KINDS.index(STOCK[item])
if orders[s.station][kind] <= 0:
return s
orders[s.station][kind] -= 1
sold = sold + (item,)
if src == 2:
hand = -1
else:
shelves[src].pop(depth)
elif a[0] == 'put' and len(a) == 2:
dest = a[1]
if dest not in (0, 1) or hand < 0 or len(shelves[dest]) >= CAPACITY:
return s
shelves[dest].insert(0, hand)
hand = -1
transfer = 1
else:
return s
n = replace(s, shelves=tuple((tuple(sh) for sh in shelves)), hand=hand, sold=sold, orders=tuple((tuple(row) for row in orders)), remaining=s.remaining - 1, actions=s.actions + 1, transfers=s.transfers + transfer)
return advance(n) if n.remaining == 0 else n
def invariant(s):
owned = sum(s.shelves, ()) + s.warehouse + s.sold + ((s.hand,) if s.hand >= 0 else ())
assert sorted(owned) == list(range(len(STOCK))) and len(set(owned)) == len(owned)
assert all((len(sh) <= CAPACITY for sh in s.shelves))
assert all((n >= 0 for row in s.orders for n in row))
assert sum((sum(row) for row in ORDERS)) - sum((sum(row) for row in s.orders)) == len(s.sold)
assert 0 <= s.station < len(WORK) and 0 <= s.remaining <= WORK[s.station]
assert s.transfers + s.sold.__len__() == s.actions
assert s.status in ('load', 'play', 'won', 'short')
@dataclass(frozen=True)
class Timeline:
state: State
history: tuple = ()
def apply(timeline, action):
state = step(timeline.state, action)
return timeline if state == timeline.state else Timeline(state, timeline.history+(timeline.state,))
def undo(timeline):
return Timeline(timeline.history[-1], timeline.history[:-1]) if timeline.history else timeline
class App:
def __init__(self):
self.reset()
def reset(self):
self.timeline = Timeline(initial())
self.selection = 0
self.item = 0
self.gated = False
self.message = "ONE KEY AT A TIME"
@property
def state(self):
return self.timeline.state
def control(self, pressed, held):
if "R" in pressed:
self.reset()
self.gated = True
return
if self.gated:
if not pressed and not held:
self.gated = False
return
if len(pressed | held) > 1:
self.gated = True
self.message = "RELEASE ALL KEYS"
return
if len(pressed) != 1:
return
key = next(iter(pressed))
if key == "X":
before = self.state
self.timeline = undo(self.timeline)
self.message = "UNDO" if before != self.state else "NOTHING TO UNDO"
self.gated = True
return
if self.state.status in ("won", "short"):
return
if key in ("UP", "DOWN"):
self.selection = (self.selection+(-1 if key=="UP" else 1))%3
self.gated = True
return
if self.state.status == "load" and key in ("LEFT", "RIGHT"):
self.item = (self.item+(-1 if key=="LEFT" else 1))%len(STOCK)
self.gated = True
return
if self.state.status == "load":
action = ("place", self.item, self.selection) if key=="Z" else ("swap_next", self.item) if key=="C" else ("depart",) if key=="SPACE" else None
else:
action = ("take", self.selection) if key=="C" else ("put", self.selection) if key=="V" else ("sell", self.selection) if key=="Z" else ("depart",) if key=="SPACE" else None
if action is None:
return
before = self.state
self.timeline = apply(self.timeline, action)
self.message = "CONFIRMED" if before != self.state else "BLOCKED / NO COST"
self.gated = True
def draw(self, p):
p.cls(1)
p.text(8, 8, "LUNCH TRAIN / STAGE 5", 10)
loading = self.state.status == "load"
p.text(8, 23, "LOADING / NO TIME COST" if loading else "STATION "+str(self.state.station+1)+" / WORK "+str(self.state.remaining), 7)
for row, items in enumerate(self.state.shelves):
y = 65+row*47
p.text(8, y+10, ("> " if self.selection==row else " ")+("A" if row==0 else "B"), 10 if self.selection==row else 7)
for depth in range(CAPACITY):
x = 65+depth*75
p.rectb(x, y, 64, 30, 6)
if depth<len(items):
item = items[depth]
p.text(x+6, y+11, str(item+1)+":"+STOCK[item], 7)
p.text(65, y-9, "FRONT -> BACK", 6)
p.text(8, 170, ("> " if self.selection==2 else " ")+"HAND: "+("-" if self.state.hand<0 else str(self.state.hand+1)+":"+STOCK[self.state.hand]), 10 if self.selection==2 else 7)
if loading:
p.text(8, 39, "SELECT ITEM "+str(self.item+1)+":"+STOCK[self.item]+" / DEST "+("A", "B", "WAREHOUSE")[self.selection], 11)
p.text(8, 186, "WAREHOUSE: "+" ".join(str(i+1)+":"+STOCK[i] for i in self.state.warehouse), 7)
p.text(8, 202, "ORDERS F/V/E: "+" / ".join(map(str,self.state.orders[self.state.station])), 11)
p.text(8, 218, "SOLD: "+str(len(self.state.sold))+" / GOAL "+str(GOAL)+" "+self.state.status.upper(), 7)
for i, order in enumerate(ORDERS):
p.text(8+i*122, 237, str(i+1)+": "+"/".join(map(str,order))+" W"+str(WORK[i]), 6)
p.text(8, 255, self.message, 7)
if loading:
p.text(8, 274, "LEFT/RIGHT ITEM UP/DOWN DEST", 6)
p.text(8, 286, "Z PLACE C SWAP NEXT SPACE DEPART", 6)
else:
p.text(8, 274, "UP/DOWN SOURCE: A / B / HAND", 6)
p.text(8, 286, "FRONT ITEMS ONLY / HAND PERSISTS", 6)
p.text(8, 301, "PLAY: Z SELL C TAKE V PUT SPACE DEPART", 6)
p.text(8, 313, "X UNDO R RESET ESC QUIT", 6)
DEMO_KEYS = ('SPACE', 'C', 'Z', 'DOWN', 'Z', 'UP', 'Z', 'DOWN', 'DOWN', 'Z', 'UP', 'Z', 'Z', 'SPACE')
CONTROL_KEYS = ("UP", "DOWN", "LEFT", "RIGHT", "Z", "C", "V", "SPACE", "X", "R")
def main(headless=False, screenshot=None, demo=False):
from pathlib import Path
import pyxel as p
screenshot = str(Path(screenshot).resolve()) if screenshot else None
if screenshot:
Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
app = App()
p.init(384, 330, title="Lunch Train", fps=30, display_scale=2, headless=headless)
keys = {k: getattr(p, "KEY_"+k) for k in CONTROL_KEYS}
limit = max(12, 2*len(DEMO_KEYS)+3) if demo else 12
def update():
if headless and p.frame_count >= limit:
if screenshot:
p.screenshot(screenshot, scale=2)
p.quit()
return
if headless and demo:
index = p.frame_count//2
pressed = {DEMO_KEYS[index]} if p.frame_count%2==0 and index<len(DEMO_KEYS) else set()
app.control(pressed, pressed)
else:
app.control({k for k,v in keys.items() if p.btnp(v)}, {k for k,v in keys.items() if p.btn(v)})
p.run(update, lambda: app.draw(p))
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("--screenshot")
args = parser.parse_args()
main(args.headless_smoke, args.screenshot, args.demo_checkpoint)仕組みがわかったら、元の完成版を読んでみよう。
元のゲームの状況 B です。2列×3の棚、手は1個、作業回数3/2/3で3つの駅を回ります。取り出せるのは手前だけで、手に持った物は次の駅へ持っていけます。出発前の積みこみには時間がかかりません。最後に発車したときに5個以上売れていれば成功です。
ここまでに作ったのは、元のゲームの仕組みだけを取り出して作り直した「簡略版」です。元のゲームのソースは手を加えずに、別のZIP(元の完成版ソース)に入れてあります。
| 比べる点 | 5ステップの教材 | 元の完成版 |
|---|---|---|
| 段階と状況 | 01 は持ちかえ、02 は駅1つ・作業無制限、03 は状況 B の走行中、04 と 05 で状況 B のふつうのルールすべてを作ります。 | 状況 A・B・C の3つと、いくつもの成功のやり方があります。 |
| 操作と表示 | 文字(ASCII)で表示し、すぐに始まります。駅はすぐ切りかわり、キーは1つ押すごとに全部離すのを待ちます。Undo は 05 だけです。 | 表紙・状況の選択・日本語の文字、約0.4秒の走る表示と、その間の入力停止があります。 |
| 比較と記録 | 手前だけ取り出せるふつうのルールです。出発したときの棚は状態に残しますが、前回の棚の表示や比較の切りかえは省いています。 | 奥から取れる比較用の条件、前回の種類の並び、答えを探すプログラム(solver)と再現の記録があります。 |
完成版のコードで、次に読むところ
- model.py: step のloadとplayの分岐
- model.py: advance の手持ち引継ぎ
- model.py: invariant の個体所有と作業の保存式
- plans.py / solver.py の五個と六個の成功案
最初は、数字かルールを1つだけ変えてみましょう。遊ぶ前に「どう変わるか」を予想しておくと、仕組みがよくわかります。慣れてきたら、見た目や音も足してみてください。元のファイルは残しておき、コピーした別のフォルダーで改造すると、いつでも元に戻せます。
教材をダウンロードする
無料で、登録なしでダウンロードできます。ZIPファイルは必ず展開してから、中のファイルを実行してください。元の完成版は、同じZIPに入っている assets フォルダー(画像などの素材)も一緒に使います。
コードと図形はMITライセンス、元の完成版に含まれる日本語の文字データ(Noto由来)はSIL OFL 1.1です。配ったり改造したりするときは、ZIPに入っているLICENSEなどの権利表示を残してください。出典・権利について
検証について
どこまで動作を確認したかを見る
2026年10月5日以降、Linux(Python 3.12・Pyxel 2.9.9)で、教材のコードとゲームのルールを自動テストしています。全500ステップの教材が、実際のPyxelで起動して画面を描けることも確かめました。元の完成版も、自動で遊ぶデモが起動して画面を描き、正常に終わることを確認しています。
この確認は、画面を表示しない自動テストで行ったものです。Windows・Macの実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。
うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る
