シミュレーションLESSON 31片手の段取り・料理パズル(架空のゲーム)原作006

片手の料理人の作り方

手が空いたら、次の焼き上がりを待つ。
1つしか持てない手と、同時に進む焼き時間。「だれが何を持っているか」と「料理の時間」を分けて考える、段取りのゲームです。

完成版の遊び方1枚だけ持てる手と、棚と、焼き台を使って、料理の準備を重ねていきます。時計もふくめて、Undo(1つ前に戻す)できます。

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

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

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

完成版の主な操作:左右/A D持ち場、上下/W S注文、Space受渡し、Z戻す、X焦げ皿廃棄、R再開、P停止

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

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

うまく表示されないとき

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

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

持ち物の移動と、時間の更新を分ける。

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

1. だれが持っているか(持ち主)と、None
2. 条件分岐・ID・入れかえ
3. 毎フレームの更新・状態・時刻の差
それだけで動く小さなプログラムを、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:アプリ終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_ownership.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

一枚の皿に居場所を一つだけ

だれが持っているか(持ち主)と、None。

手と棚には、皿を入れるか、空っぽを表す None を入れます。注文0の皿がもうどこかにあれば、材料からもう1枚は作れません。棚で手の皿と入れかえるときは、両方の中身を先に読んでから入れかえます。

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

01_ownership.py / 注目する部分
                self.hand, self.shelf = self.shelf, self.hand

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_ownership.py
macOS / Linux
.venv/bin/python 01_ownership.py
画面で見ることSpace で1枚取り、D → Space で棚に置きます。A → Space を押しても、同じ注文の皿は増えません。D → Space で、同じ皿を取り戻せます。
  • None が「空いている場所」を表すと説明できる
  • 棚に置くと、手が空く
  • 材料から、同じ注文の皿を2枚作れない
つまずきやすいところself.shelf = self.hand だけで終わらせると、手と棚が同じ皿を持っていることになってしまいます。移す前の場所を空にするか、入れかえるようにしてください。
少し変えてみよう

invariant(いつも守るべき条件のチェック)の直前に self.shelf = self.hand と書いたら何が起きるか予想し、エラーが出ることを確かめてから元に戻しましょう。

01_ownership.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(234行)
01_ownership.py / 全文
# 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()
STEP 02 / 05

焼き台と棚でルールを分ける

条件分岐・ID・入れかえ。

注文を2つにして、焼き台を加えます。手と焼き台の両方に皿があるときの操作は受け付けません。一方、棚では入れかえができます。注文の ID は「どの皿か」を区別するためのもの、kind は料理の種類なので、使い道を分けます。

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

02_transfers.py / 注目する部分
            if self.hand is not None and self.oven is not None:
                self.message = "HAND AND GRILL FULL: USE SHELF"

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_transfers.py
macOS / Linux
.venv/bin/python 02_transfers.py
画面で見ること厚焼きを焼き台に、パンを棚に置きます。焼き台から厚焼きを取って棚で Space を押すと、手がパン、棚が厚焼きになります。この段階では、まだ焼けません。
  • 焼き台で受け付けないことと、棚での入れかえを区別できる
  • kind が同じでも、ID がちがえば別の皿だと分かる
  • W/S で選ぶ番号がマイナスになっても、余りの計算で正しく選べる
つまずきやすいところ焼き台にも棚と同じ入れかえの式を書くと、取り出すために手を空ける必要がなくなり、ゲームの考えどころが変わってしまいます。
少し変えてみよう

両方に皿がある焼き台で受け付けられなかったとき、その前後の id を書き留めてみましょう。W/S で材料の選択を変えても、すでにだれかが持っている ID は出てこないことを確かめます。

02_transfers.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(259行)
02_transfers.py / 全文
# 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()
STEP 03 / 05

加熱量と完成後の時計を分ける

毎フレームの更新・状態・時刻の差。

1秒は30 tick です。heat(焼けた量)は、焼き台にある皿だけが増えます。ready_at には、はじめて焼き上がったときのゲーム全体の時刻 t を保存し、焼き上がってからの時間は t-ready_at で求めます。手や棚に移しても、この時間は増えていきます。

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

03_clocks.py / 注目する部分
            if plate.ready_at is None and plate.heat >= COOK[plate.kind]:
                plate.ready_at = self.t  # 完成した世界時刻を一度だけ記録

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_clocks.py
macOS / Linux
.venv/bin/python 03_clocks.py
画面で見ること厚焼きは120 tick で READY(焼き上がり)です。取り出すと焼くのは止まりますが、棚に置いても AGE(焼き上がってからの時間)は増えます。焼き上がりから180 tick までは HOT(温かい)、181 tick から COLD(冷めた)です。焼き台に置いたままだと、焼き上がりからさらに60 tick で BURNT(焦げ)です。
  • 取り出すと、焼くことだけが止まる
  • 棚でも、焼き上がってからの時計は進む
  • 焼き直しても ready_at は更新されない
  • この段階では、焦げたら R でやり直すしかない
つまずきやすいところ取り出した時刻で ready_at を書きかえると、取り出すだけで温かさが元に戻ってしまいます。また、heat と t を同じ変数にすると、棚の上でも焦げてしまいます。
少し変えてみよう

50 tick で取り出した厚焼きを、75 tick 棚に置いてから、焼き台に戻します。焼き上がりまでに必要な残りの焼き時間70 tick と、そのときのゲーム全体の時刻195を計算して、確かめてみましょう。

03_clocks.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(290行)
03_clocks.py / 全文
# 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()
STEP 04 / 05

四皿の営業を終える

注文を受ける窓・判定・やり直し・終わりの状態。

元のゲームの状況 B の4つの注文を使い、配膳口・温かさによる追加の評価・焦げた手持ちの皿を捨てる操作を加えます。注文は「配膳した数+2」まで受け付け、だれかが持っている注文と配膳済みの注文は、材料の選択から外します。冷めていても配膳できます。

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

04_service.py / 注目する部分
                hot = self.t - plate.ready_at <= WARM_TICKS
                self.delivered.append(plate.id)
                self.warm += hot
                self.hand = None

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_service.py
macOS / Linux
.venv/bin/python 04_service.py
画面で見ること4皿を配膳すると DONE(完了)になり、時間も操作も止まります。焦げた皿は取り出して、X を24回の更新分押し続けると捨てられます。同じ注文が材料に戻り、生の状態から作り直せます。R で同じ条件に戻ります。
  • 冷めた皿の配膳も、ふつうの成功になる
  • 捨てられるのは、手に持った焦げた皿だけ
  • 配膳すると、受け付ける注文の窓が広がる
  • DONE になったら、R 以外の操作は止まる
つまずきやすいところ冷めた皿まで受け付けないようにすると、ふつうのクリアの条件を難しく変えてしまいます。焼き台の上の焦げた皿を直接消せるようにすると、「手は1つだけ」というルールを飛ばしてしまいます。
少し変えてみよう

焼き上がった皿を取り出して181 tick 待ってから配膳し、SERVED(配膳済み)だけが増えることを確かめましょう。X を押す長さを23回の更新・24回の更新・押し続け、で比べてみます。

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

移動中にも料理を進め、時刻ごと戻す

移動の予約・更新の順番・中身まで丸ごとコピー・Undo。

1区間を10 tick で移動する仕組み、最後に押した左右1つだけの予約、P での一時停止、Z で「成功した操作の直前」に戻る機能を加えます。元のゲームの状況 B と、ゲームの状態・入力の順番・履歴を、1フレームずつ比べています。

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

05_timeline.py / 注目する部分
        state = copy.deepcopy({k: v for k, v in vars(self).items() if k != "history"})
        self.history.append(state)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_timeline.py
macOS / Linux
.venv/bin/python 05_timeline.py
画面で見ること移動している間も、焼くことと焼き上がってからの時計は進み、着く前の Space は受け付けられません。棚を使う自動の手順では、4皿とも温かいまま、捨てた皿0で終わります。Z は、完了の画面や一時停止中にも使えます。
  • 移動中の Space が受け付けられない理由を説明できる
  • 成功した操作だけが履歴を作る
  • 一時停止中や終わったあとの Undo と、R を使い分ける
  • 元のゲームとのちがいと、比べた状態の範囲を言える
つまずきやすいところ皿だけを浅くコピーして保存すると、履歴の中の皿も、今の皿と一緒に焼けてしまいます。tick を入力より先に処理すると、着いたフレームや焼き上がりの境目の結果が、元のゲームとずれてしまいます。
少し変えてみよう

移動を始める → 反対の方向 → 元の方向 と入力して、予約には最後の元の方向だけが残ることを確かめましょう。焼き上がる直前に取り出す操作を Undo して、時刻・heat・ready_at も戻る理由を説明してみます。

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

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

元のゲームの状況 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 の画面での確認はしていません。元のゲームのこれまでの実験・動画・試遊の報告は、この教材を新しく確かめた証拠としては使っていません。

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

  1. model.py: active / available / owned / invariant
  2. model.py: tick / move / action / discard
  3. model.py: save / undo と初営業のlesson停止
  4. app.py: update の優先順、タイトルとN/V
  5. 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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。

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