完成サンプルをブラウザーで遊ぶ
この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。
完成版の主な操作:左右/A D持ち場、上下/W S注文、Space受渡し、Z戻す、X焦げ皿廃棄、R再開、P停止
ボタンを押すと、ゲームの読み込みが始まります。
画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。
うまく表示されないとき
ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。
持ち物の移動と、時間の更新を分ける。
いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、5つのステップで作っていきます。どのステップのファイルもそれだけで動くので、動きを確かめてから次へ進みましょう。
- 必要な環境
- Python 3.11以上 / Pyxel 2.9.9
- 知っておくとよいこと
- Python 3.11以上と Pyxel 2.9.9 / 変数・if・for・関数・リスト / class・dataclass・None・集合・deepcopy は本文で説明します
- 教材に入っているもの
- ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
- 完成した教材の操作
- A/D・左右:移動。05は10tick、途中入力は最後一個を予約 / W/S・上下:02以降の材料選択 / Space:取る・焼き台へ移動・棚と交換・04以降配膳 / X:04以降、焦げた手持ち皿を24ゲーム更新分長押しで廃棄 / P:05の停止と再開 / Z:05で成功操作前へ時刻ごと戻す / R:同じ教材を初期化 / Esc:アプリ終了
- 難易度の目安
- 中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
- 学習順に迷ったら
- おすすめの学ぶ順番を見る
コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。
教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。
一枚の皿に居場所を一つだけ
だれが持っているか(持ち主)と、None。
手と棚には、皿を入れるか、空っぽを表す None を入れます。注文0の皿がもうどこかにあれば、材料からもう1枚は作れません。棚で手の皿と入れかえるときは、両方の中身を先に読んでから入れかえます。
下のコードは 01_ownership.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
self.hand, self.shelf = self.shelf, self.hand実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 01_ownership.py.venv/bin/python 01_ownership.pyNoneが「空いている場所」を表すと説明できる- 棚に置くと、手が空く
- 材料から、同じ注文の皿を2枚作れない
self.shelf = self.hand だけで終わらせると、手と棚が同じ皿を持っていることになってしまいます。移す前の場所を空にするか、入れかえるようにしてください。invariant(いつも守るべき条件のチェック)の直前に self.shelf = self.hand と書いたら何が起きるか予想し、エラーが出ることを確かめてから元に戻しましょう。
このステップのコード全文(234行)
# 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。ASCII表示、Python 3.11+、単独実行。"""
from dataclasses import dataclass, asdict
STAGE = 1
STATIONS = ('SUPPLY', 'SHELF')
FPS = 30
@dataclass
class Plate:
id: int
class Model:
def __init__(self):
self.pos = 0
self.hand = None
self.shelf = None
self.message = "SPACE: TAKE A PLATE"
self.orders = [0]
def owned(self):
return [p for p in (self.hand, self.shelf) if p is not None]
def available(self):
owned_ids = [p.id for p in self.owned()]
return [i for i in range(len(self.orders)) if i not in owned_ids]
def invariant(self):
ids = [p.id for p in self.owned()]
assert len(ids) == len(set(ids)), "duplicate plate ownership"
assert all(0 <= i < len(self.orders) for i in ids)
def move(self, direction):
self.pos = max(0, min(len(STATIONS) - 1, self.pos + direction))
def action(self):
ok = False
if self.pos == 0:
available = self.available()
if self.hand is not None:
self.message = "HAND FULL: USE THE SHELF"
elif not available:
self.message = "NO AVAILABLE ORDER"
else:
index = available[0]
self.hand = Plate(index)
self.message = "TAKEN: ORDER " + str(index + 1)
ok = True
elif self.pos == 1:
if self.hand is None and self.shelf is None:
self.message = "SHELF EMPTY"
else:
self.hand, self.shelf = self.shelf, self.hand
self.message = "HAND AND SHELF SWAPPED"
ok = True
self.invariant()
return ok
class App:
def __init__(self):
self.reset()
def reset(self):
self.m = Model()
def control(self, pressed, held):
# pressedは今フレームの押下、heldは押しているキー。原作の処理順。
if "R" in pressed:
self.reset()
return
for key, direction in (("A", -1), ("LEFT", -1), ("D", 1), ("RIGHT", 1)):
if key in pressed:
self.m.move(direction)
if "SPACE" in pressed:
self.m.action()
def draw(self, p):
m = self.m
p.cls(1)
p.text(10, 8, "ONE HAND COOK / STAGE " + str(STAGE), 10)
p.text(10, 20, "FICTIONAL GAME - NOT FOOD SAFETY GUIDANCE", 6)
p.text(10, 36, "TRANSFER PRACTICE / NO CLOCK / NO ENDING", 7)
for i, name in enumerate(STATIONS):
x = 10 + i * 100
p.rect(x, 74, 92, 100, 4)
p.rectb(x, 74, 92, 100, 10 if i == m.pos else 5)
p.text(x + 4, 80, name, 7)
available = m.available()
if available:
selected = available[0]
p.text(14, 104, "ORDER " + str(selected + 1), 10)
p.text(14, 116, "ONE PLATE ONLY", 7)
else:
p.text(14, 104, "NO ORDER", 6)
self.draw_plate(p, 114, 101, m.shelf)
x = 54 + m.pos * 100
p.circ(x, 190, 6, 15)
p.text(10, 208, "HAND", 6)
self.draw_plate(p, 64, 204, m.hand)
p.text(10, 268, m.message, 10)
p.text(10, 282, "A/D OR ARROWS: MOVE SPACE: TRANSFER R: RESET", 6)
p.text(10, 294, "ESC: QUIT ONE ORDER CANNOT EXIST IN TWO PLACES", 6)
def draw_plate(self, p, x, y, plate):
if plate is None:
p.text(x, y, "EMPTY", 6)
return
p.elli(x + 20, y + 9, 38, 10, 6)
color = 15
p.rect(x + 27, y, 22, 13, color)
p.text(x, y + 23, "ORDER " + str(plate.id + 1), 7)
def demo_plan():
# 自動入力をcontrolへ渡す。人のキーボード入力を確認するものではない。
return [("act",), ("go", 1), ("act",)]
class Demo:
def __init__(self):
self.plan = demo_plan()
self.release = False
self.wait = 0
def keys(self, app):
if self.release:
self.release = False
return set()
if self.wait:
self.wait -= 1
return set()
if not self.plan:
return set()
m = app.m
if getattr(m, "moving", 0):
return set()
command = self.plan[0]
if command[0] == "go" and m.pos != command[1]:
self.release = True
return {"D" if command[1] > m.pos else "A"}
if command[0] == "ready" and (m.oven is None or m.oven.ready_at is None):
return set()
self.plan.pop(0)
if command[0] == "act":
self.release = True
return {"SPACE"}
if command[0] == "wait":
self.wait = command[1]
return set()
def main():
import argparse
import json
from pathlib import Path
import pyxel
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--headless", action="store_true")
parser.add_argument("--demo", action="store_true")
parser.add_argument("--frames", type=int, default=0)
parser.add_argument("--capture", type=Path)
parser.add_argument("--state", type=Path)
args = parser.parse_args()
app = App()
demo = Demo()
frames = 0
mapping = {name: getattr(pyxel, "KEY_" + name) for name in
("A", "D", "LEFT", "RIGHT", "W", "S", "UP", "DOWN", "SPACE", "R", "X", "Z", "P")}
pyxel.init(420, 340, title="006 One Hand Cook - Stage " + str(STAGE), fps=FPS,
display_scale=2, quit_key=pyxel.KEY_NONE, headless=args.headless)
def update():
nonlocal frames
if pyxel.btnp(pyxel.KEY_ESCAPE):
pyxel.quit()
return
if args.demo:
pressed = demo.keys(app)
held = pressed
else:
pressed = {name for name, key in mapping.items() if pyxel.btnp(key)}
held = {name for name, key in mapping.items() if pyxel.btn(key)}
app.control(pressed, held)
frames += 1
def draw():
app.draw(pyxel)
if args.demo:
pyxel.text(250, 8, "AUTOMATIC CONTROLLER DEMO", 8)
if args.frames and frames >= args.frames:
if args.capture:
args.capture.parent.mkdir(parents=True, exist_ok=True)
pyxel.screenshot(str(args.capture), scale=2)
if args.state:
data = {key: value for key, value in vars(app.m).items() if key != "history"}
data = json.loads(json.dumps(data, default=lambda value: asdict(value)))
data["history_count"] = len(getattr(app.m, "history", []))
data["automatic_controller_demo"] = args.demo
args.state.parent.mkdir(parents=True, exist_ok=True)
args.state.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
pyxel.quit()
pyxel.run(update, draw)
if __name__ == "__main__":
main()焼き台と棚でルールを分ける
条件分岐・ID・入れかえ。
注文を2つにして、焼き台を加えます。手と焼き台の両方に皿があるときの操作は受け付けません。一方、棚では入れかえができます。注文の ID は「どの皿か」を区別するためのもの、kind は料理の種類なので、使い道を分けます。
下のコードは 02_transfers.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if self.hand is not None and self.oven is not None:
self.message = "HAND AND GRILL FULL: USE SHELF"実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 02_transfers.py.venv/bin/python 02_transfers.py- 焼き台で受け付けないことと、棚での入れかえを区別できる
kindが同じでも、ID がちがえば別の皿だと分かる- W/S で選ぶ番号がマイナスになっても、余りの計算で正しく選べる
両方に皿がある焼き台で受け付けられなかったとき、その前後の id を書き留めてみましょう。W/S で材料の選択を変えても、すでにだれかが持っている ID は出てこないことを確かめます。
このステップのコード全文(259行)
# 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。ASCII表示、Python 3.11+、単独実行。"""
from dataclasses import dataclass, asdict
STAGE = 2
STATIONS = ('SUPPLY', 'GRILL', 'SHELF')
FPS = 30
@dataclass
class Plate:
id: int
kind: int
class Model:
def __init__(self):
self.pos = 0
self.hand = None
self.shelf = None
self.message = "SPACE: TAKE A PLATE"
self.oven = None
self.choice = 0
self.orders = [1, 0]
def owned(self):
return [p for p in (self.hand, self.oven, self.shelf) if p is not None]
def available(self):
owned_ids = [p.id for p in self.owned()]
return [i for i in range(len(self.orders)) if i not in owned_ids]
def invariant(self):
ids = [p.id for p in self.owned()]
assert len(ids) == len(set(ids)), "duplicate plate ownership"
assert all(0 <= i < len(self.orders) for i in ids)
def move(self, direction):
self.pos = max(0, min(len(STATIONS) - 1, self.pos + direction))
def action(self):
ok = False
if self.pos == 0:
available = self.available()
if self.hand is not None:
self.message = "HAND FULL: USE THE SHELF"
elif not available:
self.message = "NO AVAILABLE ORDER"
else:
index = available[self.choice % len(available)]
self.hand = Plate(index, self.orders[index])
self.message = "TAKEN: ORDER " + str(index + 1)
ok = True
elif self.pos == 1:
if self.hand is not None and self.oven is not None:
self.message = "HAND AND GRILL FULL: USE SHELF"
elif self.hand is not None:
self.oven = self.hand
self.hand = None # 移動元を必ず空ける
self.message = "PLACED ON GRILL"
ok = True
elif self.oven is not None:
self.hand = self.oven
self.oven = None
self.message = "REMOVED FROM GRILL"
ok = True
else:
self.message = "GRILL EMPTY: BRING A PLATE"
elif self.pos == 2:
if self.hand is None and self.shelf is None:
self.message = "SHELF EMPTY"
else:
self.hand, self.shelf = self.shelf, self.hand
self.message = "HAND AND SHELF SWAPPED"
ok = True
self.invariant()
return ok
class App:
def __init__(self):
self.reset()
def reset(self):
self.m = Model()
def control(self, pressed, held):
# pressedは今フレームの押下、heldは押しているキー。原作の処理順。
if "R" in pressed:
self.reset()
return
for key, direction in (("A", -1), ("LEFT", -1), ("D", 1), ("RIGHT", 1)):
if key in pressed:
self.m.move(direction)
if "W" in pressed or "UP" in pressed:
self.m.choice -= 1
if "S" in pressed or "DOWN" in pressed:
self.m.choice += 1
if "SPACE" in pressed:
self.m.action()
def draw(self, p):
m = self.m
p.cls(1)
p.text(10, 8, "ONE HAND COOK / STAGE " + str(STAGE), 10)
p.text(10, 20, "FICTIONAL GAME - NOT FOOD SAFETY GUIDANCE", 6)
p.text(10, 36, "TRANSFER PRACTICE / NO CLOCK / NO ENDING", 7)
for i, name in enumerate(STATIONS):
x = 10 + i * 100
p.rect(x, 74, 92, 100, 4)
p.rectb(x, 74, 92, 100, 10 if i == m.pos else 5)
p.text(x + 4, 80, name, 7)
available = m.available()
if available:
selected = available[m.choice % len(available)]
p.text(14, 104, "ORDER " + str(selected + 1), 10)
p.text(14, 116, ("TOAST", "THICK")[m.orders[selected]], 7)
else:
p.text(14, 104, "NO ORDER", 6)
self.draw_plate(p, 114, 101, m.oven)
self.draw_plate(p, 214, 101, m.shelf)
x = 54 + m.pos * 100
p.circ(x, 190, 6, 15)
p.text(10, 208, "HAND", 6)
self.draw_plate(p, 64, 204, m.hand)
p.text(10, 268, m.message, 10)
p.text(10, 282, "A/D OR ARROWS: MOVE SPACE: TRANSFER R: RESET", 6)
p.text(10, 294, "W/S OR UP/DOWN: ORDER ESC: QUIT", 6)
def draw_plate(self, p, x, y, plate):
if plate is None:
p.text(x, y, "EMPTY", 6)
return
p.elli(x + 20, y + 9, 38, 10, 6)
color = 15
p.rect(x + 27, y, 22, 13, color)
p.text(x, y + 23, "ORDER " + str(plate.id + 1), 7)
p.text(x, y + 33, ("TOAST", "THICK")[plate.kind], 7)
def demo_plan():
# 自動入力をcontrolへ渡す。人のキーボード入力を確認するものではない。
return [("act",), ("go", 1), ("act",), ("go", 0), ("act",),
("go", 2), ("act",), ("go", 1), ("act",), ("go", 2), ("act",)]
class Demo:
def __init__(self):
self.plan = demo_plan()
self.release = False
self.wait = 0
def keys(self, app):
if self.release:
self.release = False
return set()
if self.wait:
self.wait -= 1
return set()
if not self.plan:
return set()
m = app.m
if getattr(m, "moving", 0):
return set()
command = self.plan[0]
if command[0] == "go" and m.pos != command[1]:
self.release = True
return {"D" if command[1] > m.pos else "A"}
if command[0] == "ready" and (m.oven is None or m.oven.ready_at is None):
return set()
self.plan.pop(0)
if command[0] == "act":
self.release = True
return {"SPACE"}
if command[0] == "wait":
self.wait = command[1]
return set()
def main():
import argparse
import json
from pathlib import Path
import pyxel
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--headless", action="store_true")
parser.add_argument("--demo", action="store_true")
parser.add_argument("--frames", type=int, default=0)
parser.add_argument("--capture", type=Path)
parser.add_argument("--state", type=Path)
args = parser.parse_args()
app = App()
demo = Demo()
frames = 0
mapping = {name: getattr(pyxel, "KEY_" + name) for name in
("A", "D", "LEFT", "RIGHT", "W", "S", "UP", "DOWN", "SPACE", "R", "X", "Z", "P")}
pyxel.init(420, 340, title="006 One Hand Cook - Stage " + str(STAGE), fps=FPS,
display_scale=2, quit_key=pyxel.KEY_NONE, headless=args.headless)
def update():
nonlocal frames
if pyxel.btnp(pyxel.KEY_ESCAPE):
pyxel.quit()
return
if args.demo:
pressed = demo.keys(app)
held = pressed
else:
pressed = {name for name, key in mapping.items() if pyxel.btnp(key)}
held = {name for name, key in mapping.items() if pyxel.btn(key)}
app.control(pressed, held)
frames += 1
def draw():
app.draw(pyxel)
if args.demo:
pyxel.text(250, 8, "AUTOMATIC CONTROLLER DEMO", 8)
if args.frames and frames >= args.frames:
if args.capture:
args.capture.parent.mkdir(parents=True, exist_ok=True)
pyxel.screenshot(str(args.capture), scale=2)
if args.state:
data = {key: value for key, value in vars(app.m).items() if key != "history"}
data = json.loads(json.dumps(data, default=lambda value: asdict(value)))
data["history_count"] = len(getattr(app.m, "history", []))
data["automatic_controller_demo"] = args.demo
args.state.parent.mkdir(parents=True, exist_ok=True)
args.state.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
pyxel.quit()
pyxel.run(update, draw)
if __name__ == "__main__":
main()加熱量と完成後の時計を分ける
毎フレームの更新・状態・時刻の差。
1秒は30 tick です。heat(焼けた量)は、焼き台にある皿だけが増えます。ready_at には、はじめて焼き上がったときのゲーム全体の時刻 t を保存し、焼き上がってからの時間は t-ready_at で求めます。手や棚に移しても、この時間は増えていきます。
下のコードは 03_clocks.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if plate.ready_at is None and plate.heat >= COOK[plate.kind]:
plate.ready_at = self.t # 完成した世界時刻を一度だけ記録実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 03_clocks.py.venv/bin/python 03_clocks.py- 取り出すと、焼くことだけが止まる
- 棚でも、焼き上がってからの時計は進む
- 焼き直しても
ready_atは更新されない - この段階では、焦げたら R でやり直すしかない
ready_at を書きかえると、取り出すだけで温かさが元に戻ってしまいます。また、heat と t を同じ変数にすると、棚の上でも焦げてしまいます。50 tick で取り出した厚焼きを、75 tick 棚に置いてから、焼き台に戻します。焼き上がりまでに必要な残りの焼き時間70 tick と、そのときのゲーム全体の時刻195を計算して、確かめてみましょう。
このステップのコード全文(290行)
# 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。ASCII表示、Python 3.11+、単独実行。"""
from dataclasses import dataclass, asdict
STAGE = 3
STATIONS = ('SUPPLY', 'GRILL', 'SHELF')
FPS = 30
COOK = (60, 120)
WARM_TICKS = 180
@dataclass
class Plate:
id: int
kind: int
heat: int = 0
ready_at: int | None = None
burnt: bool = False
class Model:
def __init__(self):
self.pos = 0
self.hand = None
self.shelf = None
self.message = "SPACE: TAKE A PLATE"
self.oven = None
self.choice = 0
self.orders = [1, 0]
self.t = 0
def owned(self):
return [p for p in (self.hand, self.oven, self.shelf) if p is not None]
def available(self):
owned_ids = [p.id for p in self.owned()]
return [i for i in range(len(self.orders)) if i not in owned_ids]
def invariant(self):
ids = [p.id for p in self.owned()]
assert len(ids) == len(set(ids)), "duplicate plate ownership"
assert all(0 <= i < len(self.orders) for i in ids)
def move(self, direction):
self.pos = max(0, min(len(STATIONS) - 1, self.pos + direction))
def tick(self):
self.t += 1
if self.oven is not None:
plate = self.oven
plate.heat += 1
if plate.ready_at is None and plate.heat >= COOK[plate.kind]:
plate.ready_at = self.t # 完成した世界時刻を一度だけ記録
self.message = "READY: TAKE IT OFF THE GRILL"
if plate.heat >= COOK[plate.kind] + 60 and not plate.burnt:
plate.burnt = True
self.message = "BURNT: REMOVE THEN HOLD X"
self.message = "BURNT: R TO RESTART THIS PRACTICE"
def temperature(self, plate):
if plate.burnt:
return "BURNT"
if plate.ready_at is None:
return "RAW"
return "HOT" if self.t - plate.ready_at <= WARM_TICKS else "COLD"
def action(self):
ok = False
if self.pos == 0:
available = self.available()
if self.hand is not None:
self.message = "HAND FULL: USE THE SHELF"
elif not available:
self.message = "NO AVAILABLE ORDER"
else:
index = available[self.choice % len(available)]
self.hand = Plate(index, self.orders[index])
self.message = "TAKEN: ORDER " + str(index + 1)
ok = True
elif self.pos == 1:
if self.hand is not None and self.oven is not None:
self.message = "HAND AND GRILL FULL: USE SHELF"
elif self.hand is not None:
self.oven = self.hand
self.hand = None # 移動元を必ず空ける
self.message = "PLACED ON GRILL"
ok = True
elif self.oven is not None:
self.hand = self.oven
self.oven = None
self.message = "REMOVED FROM GRILL"
ok = True
else:
self.message = "GRILL EMPTY: BRING A PLATE"
elif self.pos == 2:
if self.hand is None and self.shelf is None:
self.message = "SHELF EMPTY"
else:
self.hand, self.shelf = self.shelf, self.hand
self.message = "HAND AND SHELF SWAPPED"
ok = True
self.invariant()
return ok
class App:
def __init__(self):
self.reset()
def reset(self):
self.m = Model()
def control(self, pressed, held):
# pressedは今フレームの押下、heldは押しているキー。原作の処理順。
if "R" in pressed:
self.reset()
return
for key, direction in (("A", -1), ("LEFT", -1), ("D", 1), ("RIGHT", 1)):
if key in pressed:
self.m.move(direction)
if "W" in pressed or "UP" in pressed:
self.m.choice -= 1
if "S" in pressed or "DOWN" in pressed:
self.m.choice += 1
if "SPACE" in pressed:
self.m.action()
self.m.tick() # 操作、廃棄、その後に時間を1つ進める
def draw(self, p):
m = self.m
p.cls(1)
p.text(10, 8, "ONE HAND COOK / STAGE " + str(STAGE), 10)
p.text(10, 20, "FICTIONAL GAME - NOT FOOD SAFETY GUIDANCE", 6)
p.text(10, 36, "TIME " + str(m.t) + " TICKS 30 TICKS = 1 SECOND", 7)
p.text(10, 48, "CLOCK PRACTICE / R RESETS A BURNT PLATE", 11)
for i, name in enumerate(STATIONS):
x = 10 + i * 100
p.rect(x, 74, 92, 100, 4)
p.rectb(x, 74, 92, 100, 10 if i == m.pos else 5)
p.text(x + 4, 80, name, 7)
available = m.available()
if available:
selected = available[m.choice % len(available)]
p.text(14, 104, "ORDER " + str(selected + 1), 10)
p.text(14, 116, ("TOAST", "THICK")[m.orders[selected]], 7)
else:
p.text(14, 104, "NO ORDER", 6)
self.draw_plate(p, 114, 101, m.oven)
self.draw_plate(p, 214, 101, m.shelf)
x = 54 + m.pos * 100
p.circ(x, 190, 6, 15)
p.text(10, 208, "HAND", 6)
self.draw_plate(p, 64, 204, m.hand)
p.text(10, 268, m.message, 10)
p.text(10, 282, "A/D OR ARROWS: MOVE SPACE: TRANSFER R: RESET", 6)
p.text(10, 294, "W/S OR UP/DOWN: ORDER ESC: QUIT", 6)
def draw_plate(self, p, x, y, plate):
if plate is None:
p.text(x, y, "EMPTY", 6)
return
p.elli(x + 20, y + 9, 38, 10, 6)
color = 0 if plate.burnt else 9 if plate.ready_at is not None else 15
p.rect(x + 27, y, 22, 13, color)
p.text(x, y + 23, "ORDER " + str(plate.id + 1), 7)
p.text(x, y + 33, ("TOAST", "THICK")[plate.kind], 7)
p.text(x, y + 43, f"HEAT {plate.heat} {self.m.temperature(plate)}", 10)
if plate.ready_at is not None:
p.text(x, y + 53, "AGE " + str(self.m.t - plate.ready_at), 6)
def demo_plan():
# 自動入力をcontrolへ渡す。人のキーボード入力を確認するものではない。
return [("act",), ("go", 1), ("act",), ("ready",), ("act",),
("go", 2), ("act",), ("wait", 181)]
class Demo:
def __init__(self):
self.plan = demo_plan()
self.release = False
self.wait = 0
def keys(self, app):
if self.release:
self.release = False
return set()
if self.wait:
self.wait -= 1
return set()
if not self.plan:
return set()
m = app.m
if getattr(m, "moving", 0):
return set()
command = self.plan[0]
if command[0] == "go" and m.pos != command[1]:
self.release = True
return {"D" if command[1] > m.pos else "A"}
if command[0] == "ready" and (m.oven is None or m.oven.ready_at is None):
return set()
self.plan.pop(0)
if command[0] == "act":
self.release = True
return {"SPACE"}
if command[0] == "wait":
self.wait = command[1]
return set()
def main():
import argparse
import json
from pathlib import Path
import pyxel
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--headless", action="store_true")
parser.add_argument("--demo", action="store_true")
parser.add_argument("--frames", type=int, default=0)
parser.add_argument("--capture", type=Path)
parser.add_argument("--state", type=Path)
args = parser.parse_args()
app = App()
demo = Demo()
frames = 0
mapping = {name: getattr(pyxel, "KEY_" + name) for name in
("A", "D", "LEFT", "RIGHT", "W", "S", "UP", "DOWN", "SPACE", "R", "X", "Z", "P")}
pyxel.init(420, 340, title="006 One Hand Cook - Stage " + str(STAGE), fps=FPS,
display_scale=2, quit_key=pyxel.KEY_NONE, headless=args.headless)
def update():
nonlocal frames
if pyxel.btnp(pyxel.KEY_ESCAPE):
pyxel.quit()
return
if args.demo:
pressed = demo.keys(app)
held = pressed
else:
pressed = {name for name, key in mapping.items() if pyxel.btnp(key)}
held = {name for name, key in mapping.items() if pyxel.btn(key)}
app.control(pressed, held)
frames += 1
def draw():
app.draw(pyxel)
if args.demo:
pyxel.text(250, 8, "AUTOMATIC CONTROLLER DEMO", 8)
if args.frames and frames >= args.frames:
if args.capture:
args.capture.parent.mkdir(parents=True, exist_ok=True)
pyxel.screenshot(str(args.capture), scale=2)
if args.state:
data = {key: value for key, value in vars(app.m).items() if key != "history"}
data = json.loads(json.dumps(data, default=lambda value: asdict(value)))
data["history_count"] = len(getattr(app.m, "history", []))
data["automatic_controller_demo"] = args.demo
args.state.parent.mkdir(parents=True, exist_ok=True)
args.state.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
pyxel.quit()
pyxel.run(update, draw)
if __name__ == "__main__":
main()四皿の営業を終える
注文を受ける窓・判定・やり直し・終わりの状態。
元のゲームの状況 B の4つの注文を使い、配膳口・温かさによる追加の評価・焦げた手持ちの皿を捨てる操作を加えます。注文は「配膳した数+2」まで受け付け、だれかが持っている注文と配膳済みの注文は、材料の選択から外します。冷めていても配膳できます。
下のコードは 04_service.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
hot = self.t - plate.ready_at <= WARM_TICKS
self.delivered.append(plate.id)
self.warm += hot
self.hand = None実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 04_service.py.venv/bin/python 04_service.py- 冷めた皿の配膳も、ふつうの成功になる
- 捨てられるのは、手に持った焦げた皿だけ
- 配膳すると、受け付ける注文の窓が広がる
- DONE になったら、R 以外の操作は止まる
焼き上がった皿を取り出して181 tick 待ってから配膳し、SERVED(配膳済み)だけが増えることを確かめましょう。X を押す長さを23回の更新・24回の更新・押し続け、で比べてみます。
このステップのコード全文(343行)
# 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。ASCII表示、Python 3.11+、単独実行。"""
from dataclasses import dataclass, asdict
STAGE = 4
STATIONS = ('SUPPLY', 'GRILL', 'SHELF', 'SERVE')
FPS = 30
COOK = (60, 120)
WARM_TICKS = 180
@dataclass
class Plate:
id: int
kind: int
heat: int = 0
ready_at: int | None = None
burnt: bool = False
class Model:
def __init__(self):
self.pos = 0
self.hand = None
self.shelf = None
self.message = "SPACE: TAKE A PLATE"
self.oven = None
self.choice = 0
self.orders = [1, 0, 0, 1]
self.delivered = []
self.warm = 0
self.waste = 0
self.status = "play"
self.t = 0
def owned(self):
return [p for p in (self.hand, self.oven, self.shelf) if p is not None]
def active(self):
count = min(len(self.orders), len(self.delivered) + 2)
return [i for i in range(count) if i not in self.delivered]
def available(self):
owned_ids = [p.id for p in self.owned()]
return [i for i in self.active() if i not in owned_ids]
def invariant(self):
ids = [p.id for p in self.owned()] + self.delivered
assert len(ids) == len(set(ids)), "duplicate plate ownership"
assert all(0 <= i < len(self.orders) for i in ids)
assert self.warm <= len(self.delivered)
def move(self, direction):
self.pos = max(0, min(len(STATIONS) - 1, self.pos + direction))
def tick(self):
if self.status != "play":
return
self.t += 1
if self.oven is not None:
plate = self.oven
plate.heat += 1
if plate.ready_at is None and plate.heat >= COOK[plate.kind]:
plate.ready_at = self.t # 完成した世界時刻を一度だけ記録
self.message = "READY: TAKE IT OFF THE GRILL"
if plate.heat >= COOK[plate.kind] + 60 and not plate.burnt:
plate.burnt = True
self.message = "BURNT: REMOVE THEN HOLD X"
def temperature(self, plate):
if plate.burnt:
return "BURNT"
if plate.ready_at is None:
return "RAW"
return "HOT" if self.t - plate.ready_at <= WARM_TICKS else "COLD"
def action(self):
if self.status != "play":
return False
ok = False
if self.pos == 0:
available = self.available()
if self.hand is not None:
self.message = "HAND FULL: USE THE SHELF"
elif not available:
self.message = "NO AVAILABLE ORDER"
else:
index = available[self.choice % len(available)]
self.hand = Plate(index, self.orders[index])
self.message = "TAKEN: ORDER " + str(index + 1)
ok = True
elif self.pos == 1:
if self.hand is not None and self.oven is not None:
self.message = "HAND AND GRILL FULL: USE SHELF"
elif self.hand is not None:
self.oven = self.hand
self.hand = None # 移動元を必ず空ける
self.message = "PLACED ON GRILL"
ok = True
elif self.oven is not None:
self.hand = self.oven
self.oven = None
self.message = "REMOVED FROM GRILL"
ok = True
else:
self.message = "GRILL EMPTY: BRING A PLATE"
elif self.pos == 2:
if self.hand is None and self.shelf is None:
self.message = "SHELF EMPTY"
else:
self.hand, self.shelf = self.shelf, self.hand
self.message = "HAND AND SHELF SWAPPED"
ok = True
else:
if self.hand is None:
self.message = "BRING A PLATE TO SERVE"
elif self.hand.burnt:
self.message = "BURNT: HOLD X TO DISCARD"
elif self.hand.ready_at is None:
self.message = "RAW: RETURN IT TO THE GRILL"
else:
plate = self.hand
hot = self.t - plate.ready_at <= WARM_TICKS
self.delivered.append(plate.id)
self.warm += hot
self.hand = None
self.message = "HOT DELIVERY" if hot else "COLD DELIVERY IS STILL VALID"
ok = True
if len(self.delivered) == len(self.orders):
self.status = "done"
self.invariant()
return ok
def discard(self):
if self.hand is None or not self.hand.burnt:
self.message = "ONLY A BURNT HAND PLATE CAN BE DISCARDED"
return
self.hand = None
self.waste += 1
self.message = "ORDER CAN NOW BE TAKEN AGAIN"
self.invariant()
class App:
def __init__(self):
self.reset()
def reset(self):
self.m = Model()
self.discard_frames = 0
def control(self, pressed, held):
# pressedは今フレームの押下、heldは押しているキー。原作の処理順。
if "R" in pressed:
self.reset()
return
if self.m.status != "play":
return
for key, direction in (("A", -1), ("LEFT", -1), ("D", 1), ("RIGHT", 1)):
if key in pressed:
self.m.move(direction)
if "W" in pressed or "UP" in pressed:
self.m.choice -= 1
if "S" in pressed or "DOWN" in pressed:
self.m.choice += 1
if "SPACE" in pressed:
self.m.action()
self.discard_frames = self.discard_frames + 1 if "X" in held else 0
if self.discard_frames == 24:
self.m.discard()
self.m.tick() # 操作、廃棄、その後に時間を1つ進める
def draw(self, p):
m = self.m
p.cls(1)
p.text(10, 8, "ONE HAND COOK / STAGE " + str(STAGE), 10)
p.text(10, 20, "FICTIONAL GAME - NOT FOOD SAFETY GUIDANCE", 6)
p.text(10, 36, "TIME " + str(m.t) + " TICKS 30 TICKS = 1 SECOND", 7)
p.text(10, 48, f"SERVED {len(m.delivered)}/4 HOT {m.warm} WASTE {m.waste}", 11)
for i, name in enumerate(STATIONS):
x = 10 + i * 100
p.rect(x, 74, 92, 100, 4)
p.rectb(x, 74, 92, 100, 10 if i == m.pos else 5)
p.text(x + 4, 80, name, 7)
available = m.available()
if available:
selected = available[m.choice % len(available)]
p.text(14, 104, "ORDER " + str(selected + 1), 10)
p.text(14, 116, ("TOAST", "THICK")[m.orders[selected]], 7)
else:
p.text(14, 104, "NO ORDER", 6)
self.draw_plate(p, 114, 101, m.oven)
self.draw_plate(p, 214, 101, m.shelf)
p.text(314, 110, "SPACE: SERVE", 10)
p.text(314, 126, m.status.upper(), 11)
x = 54 + m.pos * 100
p.circ(x, 190, 6, 15)
p.text(10, 208, "HAND", 6)
self.draw_plate(p, 64, 204, m.hand)
p.text(10, 268, m.message, 10)
p.text(10, 282, "A/D OR ARROWS: MOVE SPACE: TRANSFER R: RESET", 6)
p.text(10, 294, "W/S OR UP/DOWN: ORDER ESC: QUIT", 6)
p.text(10, 306, "HOLD X 24 FRAMES: DISCARD BURNT HAND PLATE", 6)
p.rect(240, 244, min(160, self.discard_frames * 160 / 24), 4, 8)
def draw_plate(self, p, x, y, plate):
if plate is None:
p.text(x, y, "EMPTY", 6)
return
p.elli(x + 20, y + 9, 38, 10, 6)
color = 0 if plate.burnt else 9 if plate.ready_at is not None else 15
p.rect(x + 27, y, 22, 13, color)
p.text(x, y + 23, "ORDER " + str(plate.id + 1), 7)
p.text(x, y + 33, ("TOAST", "THICK")[plate.kind], 7)
p.text(x, y + 43, f"HEAT {plate.heat} {self.m.temperature(plate)}", 10)
if plate.ready_at is not None:
p.text(x, y + 53, "AGE " + str(self.m.t - plate.ready_at), 6)
def demo_plan():
# 自動入力をcontrolへ渡す。人のキーボード入力を確認するものではない。
pair = [("go", 0), ("act",), ("go", 1), ("act",),
("go", 0), ("act",), ("go", 2), ("act",),
("go", 1), ("ready",), ("act",), ("go", 2), ("act",),
("go", 1), ("act",), ("go", 2), ("act",),
("go", 3), ("act",), ("go", 1), ("ready",),
("act",), ("go", 3), ("act",)]
return pair * 2
class Demo:
def __init__(self):
self.plan = demo_plan()
self.release = False
self.wait = 0
def keys(self, app):
if self.release:
self.release = False
return set()
if self.wait:
self.wait -= 1
return set()
if not self.plan:
return set()
m = app.m
if getattr(m, "moving", 0):
return set()
command = self.plan[0]
if command[0] == "go" and m.pos != command[1]:
self.release = True
return {"D" if command[1] > m.pos else "A"}
if command[0] == "ready" and (m.oven is None or m.oven.ready_at is None):
return set()
self.plan.pop(0)
if command[0] == "act":
self.release = True
return {"SPACE"}
if command[0] == "wait":
self.wait = command[1]
return set()
def main():
import argparse
import json
from pathlib import Path
import pyxel
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--headless", action="store_true")
parser.add_argument("--demo", action="store_true")
parser.add_argument("--frames", type=int, default=0)
parser.add_argument("--capture", type=Path)
parser.add_argument("--state", type=Path)
args = parser.parse_args()
app = App()
demo = Demo()
frames = 0
mapping = {name: getattr(pyxel, "KEY_" + name) for name in
("A", "D", "LEFT", "RIGHT", "W", "S", "UP", "DOWN", "SPACE", "R", "X", "Z", "P")}
pyxel.init(420, 340, title="006 One Hand Cook - Stage " + str(STAGE), fps=FPS,
display_scale=2, quit_key=pyxel.KEY_NONE, headless=args.headless)
def update():
nonlocal frames
if pyxel.btnp(pyxel.KEY_ESCAPE):
pyxel.quit()
return
if args.demo:
pressed = demo.keys(app)
held = pressed
else:
pressed = {name for name, key in mapping.items() if pyxel.btnp(key)}
held = {name for name, key in mapping.items() if pyxel.btn(key)}
app.control(pressed, held)
frames += 1
def draw():
app.draw(pyxel)
if args.demo:
pyxel.text(250, 8, "AUTOMATIC CONTROLLER DEMO", 8)
if args.frames and frames >= args.frames:
if args.capture:
args.capture.parent.mkdir(parents=True, exist_ok=True)
pyxel.screenshot(str(args.capture), scale=2)
if args.state:
data = {key: value for key, value in vars(app.m).items() if key != "history"}
data = json.loads(json.dumps(data, default=lambda value: asdict(value)))
data["history_count"] = len(getattr(app.m, "history", []))
data["automatic_controller_demo"] = args.demo
args.state.parent.mkdir(parents=True, exist_ok=True)
args.state.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
pyxel.quit()
pyxel.run(update, draw)
if __name__ == "__main__":
main()移動中にも料理を進め、時刻ごと戻す
移動の予約・更新の順番・中身まで丸ごとコピー・Undo。
1区間を10 tick で移動する仕組み、最後に押した左右1つだけの予約、P での一時停止、Z で「成功した操作の直前」に戻る機能を加えます。元のゲームの状況 B と、ゲームの状態・入力の順番・履歴を、1フレームずつ比べています。
下のコードは 05_timeline.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
state = copy.deepcopy({k: v for k, v in vars(self).items() if k != "history"})
self.history.append(state)実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 05_timeline.py.venv/bin/python 05_timeline.py- 移動中の Space が受け付けられない理由を説明できる
- 成功した操作だけが履歴を作る
- 一時停止中や終わったあとの Undo と、R を使い分ける
- 元のゲームとのちがいと、比べた状態の範囲を言える
tick を入力より先に処理すると、着いたフレームや焼き上がりの境目の結果が、元のゲームとずれてしまいます。移動を始める → 反対の方向 → 元の方向 と入力して、予約には最後の元の方向だけが残ることを確かめましょう。焼き上がる直前に取り出す操作を Undo して、時刻・heat・ready_at も戻る理由を説明してみます。
このステップのコード全文(400行)
# 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。ASCII表示、Python 3.11+、単独実行。"""
from dataclasses import dataclass, asdict
import copy
STAGE = 5
STATIONS = ('SUPPLY', 'GRILL', 'SHELF', 'SERVE')
FPS = 30
COOK = (60, 120)
WARM_TICKS = 180
@dataclass
class Plate:
id: int
kind: int
heat: int = 0
ready_at: int | None = None
burnt: bool = False
class Model:
def __init__(self):
self.pos = 0
self.hand = None
self.shelf = None
self.message = "SPACE: TAKE A PLATE"
self.oven = None
self.choice = 0
self.orders = [1, 0, 0, 1]
self.delivered = []
self.warm = 0
self.waste = 0
self.status = "play"
self.t = 0
self.target = 0
self.moving = 0
self.buffer = None
self.history = []
self.undo_used = False
def owned(self):
return [p for p in (self.hand, self.oven, self.shelf) if p is not None]
def active(self):
count = min(len(self.orders), len(self.delivered) + 2)
return [i for i in range(count) if i not in self.delivered]
def available(self):
owned_ids = [p.id for p in self.owned()]
return [i for i in self.active() if i not in owned_ids]
def invariant(self):
ids = [p.id for p in self.owned()] + self.delivered
assert len(ids) == len(set(ids)), "duplicate plate ownership"
assert all(0 <= i < len(self.orders) for i in ids)
assert self.warm <= len(self.delivered)
def save(self):
# 皿だけでなく時刻・移動予約も戻す。history自身は複製しない。
state = copy.deepcopy({k: v for k, v in vars(self).items() if k != "history"})
self.history.append(state)
def undo(self):
if not self.history:
self.message = "NOTHING TO UNDO"
return
state = self.history.pop()
history = self.history
self.__dict__.update(state)
self.history = history
self.undo_used = True
self.message = "UNDO: TIME AND PLATES RESTORED"
def move(self, direction):
if self.moving:
self.buffer = direction # 最後の1個だけを予約
return
destination = max(0, min(len(STATIONS) - 1, self.pos + direction))
if destination != self.pos:
self.target = destination
self.moving = 10
def tick(self):
if self.status != "play":
return
self.t += 1
if self.moving:
self.moving -= 1
if self.moving == 0:
self.pos = self.target
if self.buffer is not None:
direction = self.buffer
self.buffer = None
self.move(direction)
if self.oven is not None:
plate = self.oven
plate.heat += 1
if plate.ready_at is None and plate.heat >= COOK[plate.kind]:
plate.ready_at = self.t # 完成した世界時刻を一度だけ記録
self.message = "READY: TAKE IT OFF THE GRILL"
if plate.heat >= COOK[plate.kind] + 60 and not plate.burnt:
plate.burnt = True
self.message = "BURNT: REMOVE THEN HOLD X"
def temperature(self, plate):
if plate.burnt:
return "BURNT"
if plate.ready_at is None:
return "RAW"
return "HOT" if self.t - plate.ready_at <= WARM_TICKS else "COLD"
def action(self):
if self.moving:
self.message = "MOVING: WAIT TO ARRIVE"
return False
if self.status != "play":
return False
self.save()
ok = False
if self.pos == 0:
available = self.available()
if self.hand is not None:
self.message = "HAND FULL: USE THE SHELF"
elif not available:
self.message = "NO AVAILABLE ORDER"
else:
index = available[self.choice % len(available)]
self.hand = Plate(index, self.orders[index])
self.message = "TAKEN: ORDER " + str(index + 1)
ok = True
elif self.pos == 1:
if self.hand is not None and self.oven is not None:
self.message = "HAND AND GRILL FULL: USE SHELF"
elif self.hand is not None:
self.oven = self.hand
self.hand = None # 移動元を必ず空ける
self.message = "PLACED ON GRILL"
ok = True
elif self.oven is not None:
self.hand = self.oven
self.oven = None
self.message = "REMOVED FROM GRILL"
ok = True
else:
self.message = "GRILL EMPTY: BRING A PLATE"
elif self.pos == 2:
if self.hand is None and self.shelf is None:
self.message = "SHELF EMPTY"
else:
self.hand, self.shelf = self.shelf, self.hand
self.message = "HAND AND SHELF SWAPPED"
ok = True
else:
if self.hand is None:
self.message = "BRING A PLATE TO SERVE"
elif self.hand.burnt:
self.message = "BURNT: HOLD X TO DISCARD"
elif self.hand.ready_at is None:
self.message = "RAW: RETURN IT TO THE GRILL"
else:
plate = self.hand
hot = self.t - plate.ready_at <= WARM_TICKS
self.delivered.append(plate.id)
self.warm += hot
self.hand = None
self.message = "HOT DELIVERY" if hot else "COLD DELIVERY IS STILL VALID"
ok = True
if len(self.delivered) == len(self.orders):
self.status = "done"
if not ok:
self.history.pop() # 拒否はUndoの履歴を増やさない
self.invariant()
return ok
def discard(self):
if self.hand is None or not self.hand.burnt:
self.message = "ONLY A BURNT HAND PLATE CAN BE DISCARDED"
return
self.save()
self.hand = None
self.waste += 1
self.message = "ORDER CAN NOW BE TAKEN AGAIN"
self.invariant()
class App:
def __init__(self):
self.reset()
def reset(self):
self.m = Model()
self.discard_frames = 0
self.paused = False
def control(self, pressed, held):
# pressedは今フレームの押下、heldは押しているキー。原作の処理順。
if "R" in pressed:
self.reset()
return
if "P" in pressed:
self.paused = not self.paused
return
if "Z" in pressed:
self.m.undo()
return
if self.m.status != "play":
return
if self.paused:
return
for key, direction in (("A", -1), ("LEFT", -1), ("D", 1), ("RIGHT", 1)):
if key in pressed:
self.m.move(direction)
if "W" in pressed or "UP" in pressed:
self.m.choice -= 1
if "S" in pressed or "DOWN" in pressed:
self.m.choice += 1
if "SPACE" in pressed:
self.m.action()
self.discard_frames = self.discard_frames + 1 if "X" in held else 0
if self.discard_frames == 24:
self.m.discard()
self.m.tick() # 操作、廃棄、その後に時間を1つ進める
def draw(self, p):
m = self.m
p.cls(1)
p.text(10, 8, "ONE HAND COOK / STAGE " + str(STAGE), 10)
p.text(10, 20, "FICTIONAL GAME - NOT FOOD SAFETY GUIDANCE", 6)
p.text(10, 36, "TIME " + str(m.t) + " TICKS 30 TICKS = 1 SECOND", 7)
p.text(10, 48, f"SERVED {len(m.delivered)}/4 HOT {m.warm} WASTE {m.waste}", 11)
for i, name in enumerate(STATIONS):
x = 10 + i * 100
p.rect(x, 74, 92, 100, 4)
p.rectb(x, 74, 92, 100, 10 if i == m.pos else 5)
p.text(x + 4, 80, name, 7)
available = m.available()
if available:
selected = available[m.choice % len(available)]
p.text(14, 104, "ORDER " + str(selected + 1), 10)
p.text(14, 116, ("TOAST", "THICK")[m.orders[selected]], 7)
else:
p.text(14, 104, "NO ORDER", 6)
self.draw_plate(p, 114, 101, m.oven)
self.draw_plate(p, 214, 101, m.shelf)
p.text(314, 110, "SPACE: SERVE", 10)
p.text(314, 126, m.status.upper(), 11)
x = 54 + m.pos * 100
if m.moving:
x += (m.target - m.pos) * 100 * (10 - m.moving) / 10
p.circ(x, 190, 6, 15)
p.text(10, 208, "HAND", 6)
self.draw_plate(p, 64, 204, m.hand)
p.text(10, 268, m.message, 10)
p.text(10, 282, "A/D OR ARROWS: MOVE SPACE: TRANSFER R: RESET", 6)
p.text(10, 294, "W/S OR UP/DOWN: ORDER ESC: QUIT", 6)
p.text(10, 306, "HOLD X 24 FRAMES: DISCARD BURNT HAND PLATE", 6)
p.rect(240, 244, min(160, self.discard_frames * 160 / 24), 4, 8)
p.text(10, 318, "Z: UNDO SUCCESS P: PAUSE 10 TICKS PER MOVE", 6)
if self.paused:
p.text(200, 208, "PAUSED / P TO CONTINUE", 10)
def draw_plate(self, p, x, y, plate):
if plate is None:
p.text(x, y, "EMPTY", 6)
return
p.elli(x + 20, y + 9, 38, 10, 6)
color = 0 if plate.burnt else 9 if plate.ready_at is not None else 15
p.rect(x + 27, y, 22, 13, color)
p.text(x, y + 23, "ORDER " + str(plate.id + 1), 7)
p.text(x, y + 33, ("TOAST", "THICK")[plate.kind], 7)
p.text(x, y + 43, f"HEAT {plate.heat} {self.m.temperature(plate)}", 10)
if plate.ready_at is not None:
p.text(x, y + 53, "AGE " + str(self.m.t - plate.ready_at), 6)
def demo_plan():
# 自動入力をcontrolへ渡す。人のキーボード入力を確認するものではない。
pair = [("go", 0), ("act",), ("go", 1), ("act",),
("go", 0), ("act",), ("go", 2), ("act",),
("go", 1), ("ready",), ("act",), ("go", 2), ("act",),
("go", 1), ("act",), ("go", 2), ("act",),
("go", 3), ("act",), ("go", 1), ("ready",),
("act",), ("go", 3), ("act",)]
return pair * 2
class Demo:
def __init__(self):
self.plan = demo_plan()
self.release = False
self.wait = 0
def keys(self, app):
if self.release:
self.release = False
return set()
if self.wait:
self.wait -= 1
return set()
if not self.plan:
return set()
m = app.m
if getattr(m, "moving", 0):
return set()
command = self.plan[0]
if command[0] == "go" and m.pos != command[1]:
self.release = True
return {"D" if command[1] > m.pos else "A"}
if command[0] == "ready" and (m.oven is None or m.oven.ready_at is None):
return set()
self.plan.pop(0)
if command[0] == "act":
self.release = True
return {"SPACE"}
if command[0] == "wait":
self.wait = command[1]
return set()
def main():
import argparse
import json
from pathlib import Path
import pyxel
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--headless", action="store_true")
parser.add_argument("--demo", action="store_true")
parser.add_argument("--frames", type=int, default=0)
parser.add_argument("--capture", type=Path)
parser.add_argument("--state", type=Path)
args = parser.parse_args()
app = App()
demo = Demo()
frames = 0
mapping = {name: getattr(pyxel, "KEY_" + name) for name in
("A", "D", "LEFT", "RIGHT", "W", "S", "UP", "DOWN", "SPACE", "R", "X", "Z", "P")}
pyxel.init(420, 340, title="006 One Hand Cook - Stage " + str(STAGE), fps=FPS,
display_scale=2, quit_key=pyxel.KEY_NONE, headless=args.headless)
def update():
nonlocal frames
if pyxel.btnp(pyxel.KEY_ESCAPE):
pyxel.quit()
return
if args.demo:
pressed = demo.keys(app)
held = pressed
else:
pressed = {name for name, key in mapping.items() if pyxel.btnp(key)}
held = {name for name, key in mapping.items() if pyxel.btn(key)}
app.control(pressed, held)
frames += 1
def draw():
app.draw(pyxel)
if args.demo:
pyxel.text(250, 8, "AUTOMATIC CONTROLLER DEMO", 8)
if args.frames and frames >= args.frames:
if args.capture:
args.capture.parent.mkdir(parents=True, exist_ok=True)
pyxel.screenshot(str(args.capture), scale=2)
if args.state:
data = {key: value for key, value in vars(app.m).items() if key != "history"}
data = json.loads(json.dumps(data, default=lambda value: asdict(value)))
data["history_count"] = len(getattr(app.m, "history", []))
data["automatic_controller_demo"] = args.demo
args.state.parent.mkdir(parents=True, exist_ok=True)
args.state.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
pyxel.quit()
pyxel.run(update, draw)
if __name__ == "__main__":
main()仕組みがわかったら、元の完成版を読んでみよう。
元のゲームの状況 B(index=1)で、棚あり・種類の入れかえなし・4つの注文 [1,0,0,1] です。焼き時間は60 tick と120 tick で、さらに60 tick で焦げます。焼き上がりから180 tick 以内なら温かい評価で、冷めても配膳できます。4皿で完了です。
ここまでに作ったのは、元のゲームの仕組みだけを取り出して作り直した「簡略版」です。元のゲームのソースは手を加えずに、別のZIP(元の完成版ソース)に入れてあります。
| 比べる点 | 5ステップの教材 | 元の完成版 |
|---|---|---|
| 段階 | 01 は皿1枚と棚だけ、02 は皿2枚と焼き台、03 は時計、04 は状況 B の4皿(移動はすぐ終わる)、05 で状況 B の移動・Undo・一時停止を元どおりにします。 | 3つの営業、料理の種類の入れかえ、棚なしの比較、注文2つの比較、最初の営業だけ1回の説明の一時停止があります。 |
| 入力・終了 | 05 の営業中は、R/P/Z を優先し、「左右 → 選択 → Space → X → tick」の順番を元のゲームどおりに守ります。Esc ですぐ終了します。タイトル、N で次の営業、V で入れかえは省いています。 | Esc で営業の選択に戻ります。選択画面の Q で終了します。結果の画面の N/V などで条件を変えます。 |
| 表示・素材 | 文字(ASCII)と図形です。Noto をもとにした文字データは配っていません。元の LICENSE と、参照している NOTO-LICENSE はそのまま残しています。 | 日本語の文字、説明の画面、比較の表示、画像や動画の保存があります。 |
| 検証 | 実際の Pyxel で画面を出さずに描くテスト、作った入力による操作のテスト、状況 B の元のゲームとの状態と操作の記録の照合をしています。人が遊んだ感想・OS のキー入力・音・ブラウザー・Windows/macOS の画面での確認はしていません。 | 元のゲームのこれまでの実験・動画・試遊の報告は、この教材を新しく確かめた証拠としては使っていません。 |
完成版のコードで、次に読むところ
- model.py: active / available / owned / invariant
- model.py: tick / move / action / discard
- model.py: save / undo と初営業のlesson停止
- app.py: update の優先順、タイトルとN/V
- plans.py: pipeline / sequential と公平な比較
最初は、数字かルールを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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。
うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る
