完成サンプルをブラウザーで遊ぶ
この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。
完成版の主な操作:上下で操作を選ぶ・左右で個数、Zで実行、Spaceで待つ、Xで1手戻す、Rでやり直し、1〜3で状況を選ぶ、Pで一時停止
ボタンを押すと、ゲームの読み込みが始まります。
画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。
うまく表示されないとき
ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。
操作する前の受付と、操作したあとの締め切りを分ける。
いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、5つのステップで作っていきます。どのステップのファイルもそれだけで動くので、動きを確かめてから次へ進みましょう。
- 必要な環境
- Python 3.11以上 / Pyxel 2.9.9
- 知っておくとよいこと
- Pythonの変数・if・関数 / タプルとリストの基本 / dataclass と replace は本文で説明します
- 教材に入っているもの
- ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
- 完成した教材の操作
- 上下:操作を選ぶ / 左右:個数1か2 / Space:1手を確定 / R:最初に戻す / Z:05だけ Undo / Esc:終了
- 難易度の目安
- 初級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
- 学習順に迷ったら
- おすすめの学ぶ順番を見る
コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。
教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。
時計と熱を一手ずつ進める
操作する前と、操作したあとを分ける。
最初は窯だけを作ります。最初の熱は2、薪は1、時刻は0です。上下キーで WAIT(待つ)か FIRE(火を入れる)を選び、Space で1回確定します。選んで考えている時間に制限はなく、メニューを動かすだけでは時刻も熱も変わりません。
State は、その瞬間の数値をまとめた箱です。frozen=True なので、state.heat を直接減らすのではなく、replace(state, heat=...) で新しい箱を作ります。apply は、古い箱と操作の組を受け取り、新しい箱と表示する文を返す関数です。見た目を担当する App から、この計算を切り離しておくと、画面を開かなくてもテストできます。
WAIT が成功すると、時刻が1増え、熱が1減ります。ただし max(0, heat-1) でマイナスにはしません。FIRE は薪を1使い、操作が終わったときの熱を4にします(4からさらに1を引くわけではありません)。先に薪があるかを調べ、薪がないときは同じ State を返すので、失敗した操作では時刻も進みません。
キーを押しっぱなしにして一気に冷めないように、一度受け付けたら、すべてのキーが離されるまで待つ仕組みも入れています。この段階は、在庫や注文のない操作の練習です。クリアの表示が出ないのは故障ではありません。
下のコードは 01_heat.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
candidate = replace(candidate, t=state.t+1,
heat=4 if kind == "fire" else max(0, state.heat-1))実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 01_heat.py.venv/bin/python 01_heat.py- 何も押さなかったり、メニューを動かしたりするだけでは時刻が進まない
- 火を入れた直後の熱は4
- 薪がないときの FIRE では状態が変わらない
heat を減らすと、考えている間にも窯が冷めてしまいます。この教材はリアルタイムではなく、「確定した1手」の apply の中だけで時計を進めます。START_HEAT を4にして起動し直し、WAIT を1回実行してみましょう。HEAT 3 になると予想してから確かめます。FIRE のあとの熱4は、この定数とは別のルールです。
このステップのコード全文(150行)
# 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
START_HEAT = 2
START_WOOD = 1
@dataclass(frozen=True)
class State:
t: int = 0
heat: int = START_HEAT
wood: int = START_WOOD
def apply(state, action):
# 検証中は元の不変Stateを一切書き換えない。
if not isinstance(action, tuple) or not action:
return state, "INVALID ACTION"
kind = action[0]
candidate = state
if kind == "fire" and len(action) == 1:
if not state.wood:
return state, "NO WOOD / TIME UNCHANGED"
candidate = replace(state, wood=state.wood-1)
elif kind == "wait" and len(action) == 1:
pass
else:
return state, "INVALID ACTION"
# 在庫の候補ができた後にだけ、時計と熱を確定する。
candidate = replace(candidate, t=state.t+1,
heat=4 if kind == "fire" else max(0, state.heat-1))
return candidate, kind.upper()+" / TURN COMMITTED"
CHOICES = (('WAIT', ('wait',)), ('FIRE', ('fire',)))
class App:
def __init__(self):
self.reset()
def reset(self):
self.state = State()
self.selected = 0
self.message = "UP/DOWN CHOOSE, SPACE EXECUTE"
self.locked = False
def control(self, pressed, held):
# リセットや操作の後は全キー解放を待ち、持越し連打を防ぐ。
if self.locked:
if not held:
self.locked = False
return
if "R" in pressed:
self.reset()
self.locked = True
return
if len(pressed) != 1:
return
key = next(iter(pressed))
if key == "UP":
self.selected = (self.selected-1) % len(CHOICES)
elif key == "DOWN":
self.selected = (self.selected+1) % len(CHOICES)
elif key == "SPACE":
action = CHOICES[self.selected][1]
self.state, self.message = apply(self.state, action)
else:
return
self.locked = True
def draw(self, p):
p.cls(1)
p.text(8, 8, "LATE OVEN / STAGE 1", 10)
p.text(8, 22, "TURN "+str(self.state.t)+" HEAT "+str(self.state.heat)+" WOOD "+str(self.state.wood), 7)
for h in range(4):
p.rect(8+h*24, 34, 20, 12, 9 if h < self.state.heat else 5)
p.text(8, 75, "PRACTICE / NO ORDERS YET", 11)
for i, (label, action) in enumerate(CHOICES):
p.text(12, 108+i*11, ("> " if self.selected==i else " ")+label, 10 if self.selected==i else 6)
p.text(8, 184, self.message, 10)
p.text(8, 199, "UP/DOWN MENU SPACE EXECUTE", 6)
p.text(8, 210, "R RESET ESC QUIT", 6)
DEMO_KEYS = ('DOWN', 'SPACE', 'DOWN', 'SPACE')
def main(headless=False, screenshot=None, demo=False):
from pathlib import Path
import pyxel as p
# Resolve before Pyxel may change its working directory.
screenshot = str(Path(screenshot).resolve()) if screenshot else None
if screenshot:
Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
app = App()
p.init(256, 226, title="Late Oven", fps=30, display_scale=3, headless=headless)
keys = {name: getattr(p, "KEY_"+name) for name in
("UP", "DOWN", "LEFT", "RIGHT", "SPACE", "R", "Z")}
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=3)
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個と丸パン2個を加えます。元のゲームの B にある「前からあるパン」と「新しい生地」を区別するためです。BAKE(焼く)ができるのは、熱が3以上で、選んだ個数の生地が残っているときです。左で1個、右で2個を選びます。
action は、たとえば ("bake", 2) のようなタプルです。最初の要素が動作、2番目が個数で、文字の "2" ではなく整数の2を使います。条件を満たさないときは、その場で元の state を返します。すべての条件を通ったあとでだけ、dough(生地)を減らして bread(丸パン)を増やした candidate(候補)を作ります。
在庫の候補を作ったあとで、共通の「時計と熱の更新」に進みます。1個焼いても2個まとめて焼いても、同じ1手なので、時刻+1・熱-1です。個数の分だけくり返して毎回時計を進めると、元のゲームとは違うルールになってしまいます。
最初の熱2で BAKE を試すと、受け付けられません。先に FIRE で熱を4にしてから、2個焼きましょう。材料や熱が足りなくても操作を試せるのは、失敗しても資源を失わない作りだからです。まだ販売や締め切りはありません。
下のコードは 02_bread.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
candidate = replace(state, dough=state.dough-count, bread=state.bread+count)実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 02_bread.py.venv/bin/python 02_bread.py- 熱2の BAKE では何も変わらない
- 1個でも2個でも1手
- 生地と丸パンの合計は4
return するより前に、在庫を書き換えないでください。R でやり直し、FIRE→BAKE を1個に変えて比べてみましょう。時刻と熱は2個のときと同じで、DOUGH 1 / BREAD 3 になります。
このステップのコード全文(172行)
# 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
START_HEAT = 2
START_WOOD = 1
START_DOUGH = 2
START_BREAD = 2
@dataclass(frozen=True)
class State:
t: int = 0
heat: int = START_HEAT
wood: int = START_WOOD
dough: int = START_DOUGH
bread: int = START_BREAD
def apply(state, action):
# 検証中は元の不変Stateを一切書き換えない。
if not isinstance(action, tuple) or not action:
return state, "INVALID ACTION"
kind = action[0]
candidate = state
if kind in ("bake", "rusk", "sell"):
count = action[-1]
if type(count) is not int or count not in (1, 2):
return state, "CHOOSE 1 OR 2"
if kind == "bake" and len(action) == 2:
if state.heat < 3:
return state, "BREAD NEEDS HEAT 3-4"
if state.dough < count:
return state, "NOT ENOUGH DOUGH"
candidate = replace(state, dough=state.dough-count, bread=state.bread+count)
elif kind == "fire" and len(action) == 1:
if not state.wood:
return state, "NO WOOD / TIME UNCHANGED"
candidate = replace(state, wood=state.wood-1)
elif kind == "wait" and len(action) == 1:
pass
else:
return state, "INVALID ACTION"
# 在庫の候補ができた後にだけ、時計と熱を確定する。
candidate = replace(candidate, t=state.t+1,
heat=4 if kind == "fire" else max(0, state.heat-1))
return candidate, kind.upper()+" / TURN COMMITTED"
CHOICES = (('WAIT', ('wait',)), ('FIRE', ('fire',)), ('BAKE', ('bake',)))
class App:
def __init__(self):
self.reset()
def reset(self):
self.state = State()
self.selected = 0
self.count = 1
self.message = "UP/DOWN CHOOSE, SPACE EXECUTE"
self.locked = False
def control(self, pressed, held):
# リセットや操作の後は全キー解放を待ち、持越し連打を防ぐ。
if self.locked:
if not held:
self.locked = False
return
if "R" in pressed:
self.reset()
self.locked = True
return
if len(pressed) != 1:
return
key = next(iter(pressed))
if key == "UP":
self.selected = (self.selected-1) % len(CHOICES)
elif key == "DOWN":
self.selected = (self.selected+1) % len(CHOICES)
elif key in ("LEFT", "RIGHT"):
self.count = 1 if key == "LEFT" else 2
elif key == "SPACE":
action = CHOICES[self.selected][1]
if action[0] in ("bake", "rusk", "sell"):
action += (self.count,)
self.state, self.message = apply(self.state, action)
else:
return
self.locked = True
def draw(self, p):
p.cls(1)
p.text(8, 8, "LATE OVEN / STAGE 2", 10)
p.text(8, 22, "TURN "+str(self.state.t)+" HEAT "+str(self.state.heat)+" WOOD "+str(self.state.wood), 7)
for h in range(4):
p.rect(8+h*24, 34, 20, 12, 9 if h < self.state.heat else 5)
p.text(8, 54, "DOUGH "+str(self.state.dough)+" BREAD "+str(self.state.bread), 7)
p.text(8, 75, "PRACTICE / NO ORDERS YET", 11)
for i, (label, action) in enumerate(CHOICES):
p.text(12, 108+i*11, ("> " if self.selected==i else " ")+label, 10 if self.selected==i else 6)
p.text(150, 108, "QUANTITY "+str(self.count), 7)
p.text(150, 120, "LEFT 1 / RIGHT 2", 6)
p.text(8, 184, self.message, 10)
p.text(8, 199, "UP/DOWN MENU SPACE EXECUTE", 6)
p.text(8, 210, "R RESET ESC QUIT", 6)
DEMO_KEYS = ('DOWN', 'SPACE', 'DOWN', 'RIGHT', 'SPACE')
def main(headless=False, screenshot=None, demo=False):
from pathlib import Path
import pyxel as p
# Resolve before Pyxel may change its working directory.
screenshot = str(Path(screenshot).resolve()) if screenshot else None
if screenshot:
Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
app = App()
p.init(256, 226, title="Late Oven", fps=30, display_scale=3, headless=headless)
keys = {name: getattr(p, "KEY_"+name) for name in
("UP", "DOWN", "LEFT", "RIGHT", "SPACE", "R", "Z")}
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=3)
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)余熱でラスクを作る
材料を、次の材料へつないで変えていく。
RUSK(ラスクを作る)を追加します。丸パンをラスクに変えられるのは、熱が1〜2のときです。熱3〜4でパンを焼くときとは、ちょうどよい温度が違います。窯の熱をただ上げ続ければよいゲームではなく、順番を考える小さな工程のパズルになります。
rusk は新しい在庫です。RUSK が成功すると、bread を個数分減らし、rusk を同じ数だけ増やします。熱は操作のあとで1下がるので、熱1のときにも作れて、そのあと0になります。「先に判定し、あとで冷ます」という順番が大切です。
最初から丸パンが2個あるので、まず RUSK を2個作れます。そのあと FIRE で熱を4に戻し、BAKE で生地2個を新しい丸パンに変えると、両方を用意できます。生地・丸パン・ラスクの合計は、いつも4のままです。作った瞬間に増えるのは種類ごとの個数で、材料全体が増えるわけではありません。
この段階は材料を変える練習で、注文する人や締め切りはまだありません。別の順番で行き詰まったら、R で最初に戻して比べてみましょう。元のゲームのように注文を渡すには、次の段階の時間の条件が必要です。
下のコードは 03_rusk.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if not 1 <= state.heat <= 2:
return state, "RUSK NEEDS HEAT 1-2"実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 03_rusk.py.venv/bin/python 03_rusk.py- 熱1と2がちょうどよい温度
- 熱0・3・4では受け付けない
- 3種類の在庫の合計は4
heat<2 だけにすると熱2が入らず、heat<=2 だけにすると熱0まで許してしまいます。1 <= heat <= 2 と、両方の境目を含めて書きます。R のあと、まず FIRE を実行して熱を4にし、RUSK を試してください。受け付けられず、時刻は1のままです。WAIT を2回して熱を2にすると、RUSK が成功します。
このステップのコード全文(180行)
# 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
START_HEAT = 2
START_WOOD = 1
START_DOUGH = 2
START_BREAD = 2
@dataclass(frozen=True)
class State:
t: int = 0
heat: int = START_HEAT
wood: int = START_WOOD
dough: int = START_DOUGH
bread: int = START_BREAD
rusk: int = 0
def apply(state, action):
# 検証中は元の不変Stateを一切書き換えない。
if not isinstance(action, tuple) or not action:
return state, "INVALID ACTION"
kind = action[0]
candidate = state
if kind in ("bake", "rusk", "sell"):
count = action[-1]
if type(count) is not int or count not in (1, 2):
return state, "CHOOSE 1 OR 2"
if kind == "bake" and len(action) == 2:
if state.heat < 3:
return state, "BREAD NEEDS HEAT 3-4"
if state.dough < count:
return state, "NOT ENOUGH DOUGH"
candidate = replace(state, dough=state.dough-count, bread=state.bread+count)
elif kind == "rusk" and len(action) == 2:
if not 1 <= state.heat <= 2:
return state, "RUSK NEEDS HEAT 1-2"
if state.bread < count:
return state, "NOT ENOUGH BREAD"
candidate = replace(state, bread=state.bread-count, rusk=state.rusk+count)
elif kind == "fire" and len(action) == 1:
if not state.wood:
return state, "NO WOOD / TIME UNCHANGED"
candidate = replace(state, wood=state.wood-1)
elif kind == "wait" and len(action) == 1:
pass
else:
return state, "INVALID ACTION"
# 在庫の候補ができた後にだけ、時計と熱を確定する。
candidate = replace(candidate, t=state.t+1,
heat=4 if kind == "fire" else max(0, state.heat-1))
return candidate, kind.upper()+" / TURN COMMITTED"
CHOICES = (('WAIT', ('wait',)), ('FIRE', ('fire',)), ('BAKE', ('bake',)), ('RUSK', ('rusk',)))
class App:
def __init__(self):
self.reset()
def reset(self):
self.state = State()
self.selected = 0
self.count = 1
self.message = "UP/DOWN CHOOSE, SPACE EXECUTE"
self.locked = False
def control(self, pressed, held):
# リセットや操作の後は全キー解放を待ち、持越し連打を防ぐ。
if self.locked:
if not held:
self.locked = False
return
if "R" in pressed:
self.reset()
self.locked = True
return
if len(pressed) != 1:
return
key = next(iter(pressed))
if key == "UP":
self.selected = (self.selected-1) % len(CHOICES)
elif key == "DOWN":
self.selected = (self.selected+1) % len(CHOICES)
elif key in ("LEFT", "RIGHT"):
self.count = 1 if key == "LEFT" else 2
elif key == "SPACE":
action = CHOICES[self.selected][1]
if action[0] in ("bake", "rusk", "sell"):
action += (self.count,)
self.state, self.message = apply(self.state, action)
else:
return
self.locked = True
def draw(self, p):
p.cls(1)
p.text(8, 8, "LATE OVEN / STAGE 3", 10)
p.text(8, 22, "TURN "+str(self.state.t)+" HEAT "+str(self.state.heat)+" WOOD "+str(self.state.wood), 7)
for h in range(4):
p.rect(8+h*24, 34, 20, 12, 9 if h < self.state.heat else 5)
p.text(8, 54, "DOUGH "+str(self.state.dough)+" BREAD "+str(self.state.bread), 7)
p.text(150, 54, "RUSK "+str(self.state.rusk), 7)
p.text(8, 75, "PRACTICE / NO ORDERS YET", 11)
for i, (label, action) in enumerate(CHOICES):
p.text(12, 108+i*11, ("> " if self.selected==i else " ")+label, 10 if self.selected==i else 6)
p.text(150, 108, "QUANTITY "+str(self.count), 7)
p.text(150, 120, "LEFT 1 / RIGHT 2", 6)
p.text(8, 184, self.message, 10)
p.text(8, 199, "UP/DOWN MENU SPACE EXECUTE", 6)
p.text(8, 210, "R RESET ESC QUIT", 6)
DEMO_KEYS = ('DOWN', 'DOWN', 'DOWN', 'RIGHT', 'SPACE', 'DOWN', 'DOWN', 'SPACE', 'DOWN', 'SPACE')
def main(headless=False, screenshot=None, demo=False):
from pathlib import Path
import pyxel as p
# Resolve before Pyxel may change its working directory.
screenshot = str(Path(screenshot).resolve()) if screenshot else None
if screenshot:
Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
app = App()
p.init(256, 226, title="Late Oven", fps=30, display_scale=3, headless=headless)
keys = {name: getattr(p, "KEY_"+name) for name in
("UP", "DOWN", "LEFT", "RIGHT", "SPACE", "R", "Z")}
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=3)
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「残りの生地」にある2件の注文を加えて、ここで最後まで遊べるゲームになります。丸パン2個は時刻1〜5に、ラスク2個は時刻2〜8に渡します。受付の始まりと終わりの時刻も含みます。画面の LEFT は、注文の残りの数です。
ORDERS の1行は、(商品, 必要な数, 始まり, 終わり) です。SELL BREAD か SELL RUSK を選ぶと、操作する前の時刻・残りの注文・商品の在庫を調べます。どれかが足りなければ、すべての状態をそのままにします。成功したときだけ、商品と注文の残りを同じ数だけ減らし、その接客を1手として、時計と熱を進めます。
たとえば、時刻5に丸パンを渡すのは間に合います。渡したあとは時刻6になりますが、丸パンの注文の残りが0なので、締め切りの失敗にはなりません。時刻5で WAIT を選び、渡さないまま6に進むと LOST(失敗)です。この違いは、「受付を前の時刻で判定→在庫→時計と熱→新しい時刻で締め切りを判定」という順番から生まれます。
2件とも渡し終えたら WON(成功)、必ず渡す注文が残ったまま終わりの時刻を過ぎたら LOST です。この教材の B には、渡さなくてもよい注文がないので、終わったら止めます。元のゲームの C にある「成功したあとも、任意の注文を続けられる」ルールは扱いません。
下のコードは 04_orders.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
missed = any(n and candidate.t > order[3]
for order, n in zip(ORDERS, candidate.remaining))実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 04_orders.py.venv/bin/python 04_orders.py- 時刻5の販売を受け付ける
- 渡さないまま6に進むと失敗
- 成功したあとは Space を押しても変わらない
state.t と、締め切りの判定に使う candidate.t を混同しないでください。ORDERS の丸パンの終わりの時刻5を6に変えて、起動し直します。WAIT を6回ではまだ PLAYING(遊んでいる途中)で、7回目で LOST になります。試したら5に戻し、成功の手順ももう一度確かめましょう。
このステップのコード全文(208行)
# 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
START_HEAT = 2
START_WOOD = 1
START_DOUGH = 2
START_BREAD = 2
ORDERS = (("bread", 2, 1, 5), ("rusk", 2, 2, 8))
@dataclass(frozen=True)
class State:
t: int = 0
heat: int = START_HEAT
wood: int = START_WOOD
dough: int = START_DOUGH
bread: int = START_BREAD
rusk: int = 0
remaining: tuple = tuple(o[1] for o in ORDERS)
outcome: str = "playing"
def apply(state, action):
# 検証中は元の不変Stateを一切書き換えない。
if state.outcome != "playing":
return state, "FINISHED / R TO RESTART"
if not isinstance(action, tuple) or not action:
return state, "INVALID ACTION"
kind = action[0]
candidate = state
if kind in ("bake", "rusk", "sell"):
count = action[-1]
if type(count) is not int or count not in (1, 2):
return state, "CHOOSE 1 OR 2"
if kind == "bake" and len(action) == 2:
if state.heat < 3:
return state, "BREAD NEEDS HEAT 3-4"
if state.dough < count:
return state, "NOT ENOUGH DOUGH"
candidate = replace(state, dough=state.dough-count, bread=state.bread+count)
elif kind == "rusk" and len(action) == 2:
if not 1 <= state.heat <= 2:
return state, "RUSK NEEDS HEAT 1-2"
if state.bread < count:
return state, "NOT ENOUGH BREAD"
candidate = replace(state, bread=state.bread-count, rusk=state.rusk+count)
elif kind == "sell" and len(action) == 3 and type(action[1]) is int and 0 <= action[1] < len(ORDERS):
index = action[1]
product, required, start, end = ORDERS[index]
# 受付判定は操作前の時刻。開始・終了をともに含む。
if not start <= state.t <= end:
return state, "OUTSIDE ORDER WINDOW"
if state.remaining[index] < count:
return state, "TOO MANY FOR ORDER"
if getattr(state, product) < count:
return state, "NOT ENOUGH STOCK"
remaining = list(state.remaining)
remaining[index] -= count
candidate = replace(state, remaining=tuple(remaining),
**{product: getattr(state, product)-count})
elif kind == "fire" and len(action) == 1:
if not state.wood:
return state, "NO WOOD / TIME UNCHANGED"
candidate = replace(state, wood=state.wood-1)
elif kind == "wait" and len(action) == 1:
pass
else:
return state, "INVALID ACTION"
# 在庫の候補ができた後にだけ、時計と熱を確定する。
candidate = replace(candidate, t=state.t+1,
heat=4 if kind == "fire" else max(0, state.heat-1))
complete = all(n == 0 for n in candidate.remaining)
missed = any(n and candidate.t > order[3]
for order, n in zip(ORDERS, candidate.remaining))
outcome = "won" if complete else "lost" if missed else "playing"
candidate = replace(candidate, outcome=outcome)
return candidate, kind.upper()+" / TURN COMMITTED"
CHOICES = (('WAIT', ('wait',)), ('FIRE', ('fire',)), ('BAKE', ('bake',)), ('RUSK', ('rusk',)), ('SELL BREAD', ('sell', 0)), ('SELL RUSK', ('sell', 1)))
class App:
def __init__(self):
self.reset()
def reset(self):
self.state = State()
self.selected = 0
self.count = 1
self.message = "UP/DOWN CHOOSE, SPACE EXECUTE"
self.locked = False
def control(self, pressed, held):
# リセットや操作の後は全キー解放を待ち、持越し連打を防ぐ。
if self.locked:
if not held:
self.locked = False
return
if "R" in pressed:
self.reset()
self.locked = True
return
if self.state.outcome != "playing":
return
if len(pressed) != 1:
return
key = next(iter(pressed))
if key == "UP":
self.selected = (self.selected-1) % len(CHOICES)
elif key == "DOWN":
self.selected = (self.selected+1) % len(CHOICES)
elif key in ("LEFT", "RIGHT"):
self.count = 1 if key == "LEFT" else 2
elif key == "SPACE":
action = CHOICES[self.selected][1]
if action[0] in ("bake", "rusk", "sell"):
action += (self.count,)
self.state, self.message = apply(self.state, action)
else:
return
self.locked = True
def draw(self, p):
p.cls(1)
p.text(8, 8, "LATE OVEN / STAGE 4", 10)
p.text(8, 22, "TURN "+str(self.state.t)+" HEAT "+str(self.state.heat)+" WOOD "+str(self.state.wood), 7)
for h in range(4):
p.rect(8+h*24, 34, 20, 12, 9 if h < self.state.heat else 5)
p.text(8, 54, "DOUGH "+str(self.state.dough)+" BREAD "+str(self.state.bread), 7)
p.text(150, 54, "RUSK "+str(self.state.rusk), 7)
for i, (product, required, start, end) in enumerate(ORDERS):
p.text(8, 68+i*10, product.upper()+" LEFT "+str(self.state.remaining[i])+" / ACCEPT T"+str(start)+".."+str(end), 11)
p.text(8, 90, self.state.outcome.upper(), 10 if self.state.outcome=="won" else 8 if self.state.outcome=="lost" else 7)
for i, (label, action) in enumerate(CHOICES):
p.text(12, 108+i*11, ("> " if self.selected==i else " ")+label, 10 if self.selected==i else 6)
p.text(150, 108, "QUANTITY "+str(self.count), 7)
p.text(150, 120, "LEFT 1 / RIGHT 2", 6)
p.text(8, 184, self.message, 10)
p.text(8, 199, "UP/DOWN MENU SPACE EXECUTE", 6)
p.text(8, 210, "R RESET ESC QUIT", 6)
DEMO_KEYS = ('DOWN', 'DOWN', 'DOWN', 'RIGHT', 'SPACE', 'DOWN', 'DOWN', 'DOWN', 'DOWN', 'SPACE', 'DOWN', 'SPACE', 'DOWN', 'DOWN', 'SPACE', 'DOWN', 'SPACE')
def main(headless=False, screenshot=None, demo=False):
from pathlib import Path
import pyxel as p
# Resolve before Pyxel may change its working directory.
screenshot = str(Path(screenshot).resolve()) if screenshot else None
if screenshot:
Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
app = App()
p.init(256, 226, title="Late Oven", fps=30, display_scale=3, headless=headless)
keys = {name: getattr(p, "KEY_"+name) for name in
("UP", "DOWN", "LEFT", "RIGHT", "SPACE", "R", "Z")}
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=3)
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 キーで Undo(1手戻す)ができるようにします。Timeline は、今の State と、過去の State のタプルを持ちます。State は書き換えられないので、保存した過去の在庫や注文が、あとから書き換わることはありません。
step は apply を呼び、新しい状態が元と違うときだけ、古い State を履歴に追加します。熱不足や在庫不足などで受け付けられなかったときは履歴を増やさないので、Z を1回押すと、必ず「最後に確定した操作」が1手戻ります。メニューの位置や個数の選択は、操作を選ぶための画面の部品なので、ゲームの状態の履歴には入りません。
戻すときは、時刻だけでなく、熱・薪・生地・丸パン・ラスク・2件の注文の残り・勝ち負けを一緒に戻します。WON や LOST のときでも Z を受け付けるので、結果の画面から学び直せます。R は、履歴も含めて最初からやり直します。
この5本目は、元のゲームの B のふつうのルールと見比べられる、完成した教材です。元のゲームにある A と C の選択、任意の注文、比べるためのラスクの温度の変更、予告の表示、日本語の文字、記録の保存は省いています。この小さな完成版から、元のゲームの apply・step・forecast へ読み進めましょう。
下のコードは 05_undo.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if state == timeline.state:
return timeline, message
return Timeline(state, timeline.history+(timeline.state,)), message実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 05_undo.py.venv/bin/python 05_undo.py- 受け付けられなかった操作では、履歴は増えない
- WON や LOST からも、すべての状態を Undo できる
- R キーで履歴が空になる
state 全体を1つの値として保存し、受け付けられない操作のときは追加しません。わざと熱が足りない状態で BAKE を試してから Z を押し、受け付けられなかった BAKE ではなく、最後に成功した1手に戻ることを確かめましょう。WON の直前と直後の State を紙に書いて比べると、戻す対象がよくわかります。
このステップのコード全文(233行)
# 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
START_HEAT = 2
START_WOOD = 1
START_DOUGH = 2
START_BREAD = 2
ORDERS = (("bread", 2, 1, 5), ("rusk", 2, 2, 8))
@dataclass(frozen=True)
class State:
t: int = 0
heat: int = START_HEAT
wood: int = START_WOOD
dough: int = START_DOUGH
bread: int = START_BREAD
rusk: int = 0
remaining: tuple = tuple(o[1] for o in ORDERS)
outcome: str = "playing"
def apply(state, action):
# 検証中は元の不変Stateを一切書き換えない。
if state.outcome != "playing":
return state, "FINISHED / R TO RESTART"
if not isinstance(action, tuple) or not action:
return state, "INVALID ACTION"
kind = action[0]
candidate = state
if kind in ("bake", "rusk", "sell"):
count = action[-1]
if type(count) is not int or count not in (1, 2):
return state, "CHOOSE 1 OR 2"
if kind == "bake" and len(action) == 2:
if state.heat < 3:
return state, "BREAD NEEDS HEAT 3-4"
if state.dough < count:
return state, "NOT ENOUGH DOUGH"
candidate = replace(state, dough=state.dough-count, bread=state.bread+count)
elif kind == "rusk" and len(action) == 2:
if not 1 <= state.heat <= 2:
return state, "RUSK NEEDS HEAT 1-2"
if state.bread < count:
return state, "NOT ENOUGH BREAD"
candidate = replace(state, bread=state.bread-count, rusk=state.rusk+count)
elif kind == "sell" and len(action) == 3 and type(action[1]) is int and 0 <= action[1] < len(ORDERS):
index = action[1]
product, required, start, end = ORDERS[index]
# 受付判定は操作前の時刻。開始・終了をともに含む。
if not start <= state.t <= end:
return state, "OUTSIDE ORDER WINDOW"
if state.remaining[index] < count:
return state, "TOO MANY FOR ORDER"
if getattr(state, product) < count:
return state, "NOT ENOUGH STOCK"
remaining = list(state.remaining)
remaining[index] -= count
candidate = replace(state, remaining=tuple(remaining),
**{product: getattr(state, product)-count})
elif kind == "fire" and len(action) == 1:
if not state.wood:
return state, "NO WOOD / TIME UNCHANGED"
candidate = replace(state, wood=state.wood-1)
elif kind == "wait" and len(action) == 1:
pass
else:
return state, "INVALID ACTION"
# 在庫の候補ができた後にだけ、時計と熱を確定する。
candidate = replace(candidate, t=state.t+1,
heat=4 if kind == "fire" else max(0, state.heat-1))
complete = all(n == 0 for n in candidate.remaining)
missed = any(n and candidate.t > order[3]
for order, n in zip(ORDERS, candidate.remaining))
outcome = "won" if complete else "lost" if missed else "playing"
candidate = replace(candidate, outcome=outcome)
return candidate, kind.upper()+" / TURN COMMITTED"
@dataclass(frozen=True)
class Timeline:
state: State = State()
history: tuple = ()
def step(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 = apply(timeline.state, action)
if state == timeline.state:
return timeline, message
return Timeline(state, timeline.history+(timeline.state,)), message
CHOICES = (('WAIT', ('wait',)), ('FIRE', ('fire',)), ('BAKE', ('bake',)), ('RUSK', ('rusk',)), ('SELL BREAD', ('sell', 0)), ('SELL RUSK', ('sell', 1)))
class App:
def __init__(self):
self.reset()
def reset(self):
self.timeline = Timeline()
self.selected = 0
self.count = 1
self.message = "UP/DOWN CHOOSE, SPACE EXECUTE"
self.locked = False
@property
def state(self):
return self.timeline.state
def control(self, pressed, held):
# リセットや操作の後は全キー解放を待ち、持越し連打を防ぐ。
if self.locked:
if not held:
self.locked = False
return
if "R" in pressed:
self.reset()
self.locked = True
return
if "Z" in pressed:
self.timeline, self.message = step(self.timeline, ("undo",))
self.locked = True
return
if self.state.outcome != "playing":
return
if len(pressed) != 1:
return
key = next(iter(pressed))
if key == "UP":
self.selected = (self.selected-1) % len(CHOICES)
elif key == "DOWN":
self.selected = (self.selected+1) % len(CHOICES)
elif key in ("LEFT", "RIGHT"):
self.count = 1 if key == "LEFT" else 2
elif key == "SPACE":
action = CHOICES[self.selected][1]
if action[0] in ("bake", "rusk", "sell"):
action += (self.count,)
self.timeline, self.message = step(self.timeline, action)
else:
return
self.locked = True
def draw(self, p):
p.cls(1)
p.text(8, 8, "LATE OVEN / STAGE 5", 10)
p.text(8, 22, "TURN "+str(self.state.t)+" HEAT "+str(self.state.heat)+" WOOD "+str(self.state.wood), 7)
for h in range(4):
p.rect(8+h*24, 34, 20, 12, 9 if h < self.state.heat else 5)
p.text(8, 54, "DOUGH "+str(self.state.dough)+" BREAD "+str(self.state.bread), 7)
p.text(150, 54, "RUSK "+str(self.state.rusk), 7)
for i, (product, required, start, end) in enumerate(ORDERS):
p.text(8, 68+i*10, product.upper()+" LEFT "+str(self.state.remaining[i])+" / ACCEPT T"+str(start)+".."+str(end), 11)
p.text(8, 90, self.state.outcome.upper(), 10 if self.state.outcome=="won" else 8 if self.state.outcome=="lost" else 7)
for i, (label, action) in enumerate(CHOICES):
p.text(12, 108+i*11, ("> " if self.selected==i else " ")+label, 10 if self.selected==i else 6)
p.text(150, 108, "QUANTITY "+str(self.count), 7)
p.text(150, 120, "LEFT 1 / RIGHT 2", 6)
p.text(8, 184, self.message, 10)
p.text(8, 199, "UP/DOWN MENU SPACE EXECUTE", 6)
p.text(8, 210, "R RESET Z UNDO ESC QUIT", 6)
DEMO_KEYS = ('DOWN', 'DOWN', 'DOWN', 'RIGHT', 'SPACE', 'DOWN', 'DOWN', 'DOWN', 'DOWN', 'SPACE', 'DOWN', 'SPACE', 'DOWN', 'DOWN', 'SPACE', 'DOWN', 'SPACE')
def main(headless=False, screenshot=None, demo=False):
from pathlib import Path
import pyxel as p
# Resolve before Pyxel may change its working directory.
screenshot = str(Path(screenshot).resolve()) if screenshot else None
if screenshot:
Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
app = App()
p.init(256, 226, title="Late Oven", fps=30, display_scale=3, headless=headless)
keys = {name: getattr(p, "KEY_"+name) for name in
("UP", "DOWN", "LEFT", "RIGHT", "SPACE", "R", "Z")}
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=3)
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)仕組みがわかったら、元の完成版を読んでみよう。
熱3〜4で生地を丸パンに、熱1〜2で丸パンをラスクに変えます。1手で1〜2個です。元のゲームの B の注文の受付時間は、丸パンが1〜5、ラスクが2〜8です。受け付けられる操作だけを、「在庫→熱→時刻→締め切り」の順に確定します。
ここまでに作ったのは、元のゲームの仕組みだけを取り出して作り直した「簡略版」です。元のゲームのソースは手を加えずに、別のZIP(元の完成版ソース)に入れてあります。
| 比べる点 | 5ステップの教材 | 元の完成版 |
|---|---|---|
| 状況 | 04と05は元のゲームのBだけ。01〜03は注文なしの練習。 | A・B・Cの3つの状況。Cは、必要な注文を終えたあとも、任意の注文を続けられる。 |
| 表示と操作 | 英語の画面、上下のメニュー、Spaceで確定、05のZでUndo。 | 日本語の文字、予告の表示、比較版、表紙と状況の選択、XでUndoなど。 |
| 記録と音 | ふつうのゲームでは保存も音もなし。確認用のPNGを出すことはできる。 | ソース一式に、確認や自動描画のための補助ツールがついている。音はなし。 |
完成版のコードで、次に読むところ
model.pyのapply(条件の確かめ方と、確定する順番)model.pyのterminal・completedと、元のゲームの C の任意の注文model.pyのstep・forecast・invariant(1手の処理・予告・いつも成り立つ条件)app.pyのメニュー・予告・結果の表示
最初は、数字かルールを1つだけ変えてみましょう。遊ぶ前に「どう変わるか」を予想しておくと、仕組みがよくわかります。慣れてきたら、見た目や音も足してみてください。元のファイルは残しておき、コピーした別のフォルダーで改造すると、いつでも元に戻せます。
教材をダウンロードする
無料で、登録なしでダウンロードできます。ZIPファイルは必ず展開してから、中のファイルを実行してください。元の完成版は、同じZIPに入っている assets フォルダー(画像などの素材)も一緒に使います。
コードと図形はMITライセンス、元の完成版に含まれる日本語の文字データ(Noto由来)はSIL OFL 1.1です。配ったり改造したりするときは、ZIPに入っているLICENSEなどの権利表示を残してください。出典・権利について
検証について
どこまで動作を確認したかを見る
2026年10月5日以降、Linux(Python 3.12・Pyxel 2.9.9)で、教材のコードとゲームのルールを自動テストしています。全500ステップの教材が、実際のPyxelで起動して画面を描けることも確かめました。元の完成版も、自動で遊ぶデモが起動して画面を描き、正常に終わることを確認しています。
この確認は、画面を表示しない自動テストで行ったものです。Windows・Macの実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。
うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る
