シミュレーションLESSON 94役割分担・物の保存・同時入力を学ぶ厨房原作088

つながる厨房の作り方

渡す手と、受け取る手を、一緒に決める。
2人の役の移動と手渡し、2人で使う台の取り合い、温めと料理の提出を、決まった順番で処理します。お皿の数の帳じり、押したキーの予約、2人両方の確認と、最大5刻(時間の区切り)まで戻せる Undo を作ります。

完成版の遊び方同じキーボードで2人が料理と配膳を分担し、2人で使う台や手渡しで、6皿を届けます。

つながる厨房の元の完成版。自動操作によるPyxelの実描画画面。
元の完成版Aの合成入力による自動描画です。下の教材は歩行から始め、棚・加熱・手渡し・五刻Undoを加えます。人二人の試遊や物理キーボードの同時押しを確認した画像ではありません。
PLAY / 試験公開

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

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

完成版の主な操作:調理: WASD/E台/Q手渡し。配膳: 矢印/M台/N手渡し。P相談、停止中X五刻戻す

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

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

うまく表示されないとき

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

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

お皿をだれが持っているかと、2人の同時の入力を分ける。

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

1. 座標・役ごとに歩ける範囲・向き・ルール上の時計
2. お皿の持ち主・数の帳じり・同時の操作の取り合い
3. 前からあったものだけを進める・境目で完成・順番どおりに使う
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
Python の if・for・関数・tuple / None と bool / dataclass と replace で、書き換えない状態を読む
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
WASD:C調理役の移動 / E:C台操作の新しい押下 / Q:Cが持っている皿を隣の空手へ手渡し。終了ではない / 矢印:S配膳役の移動 / M:S台操作の新しい押下 / N:Sから手渡し / Enter:表紙から開始/再開始相談のS同意 / P/H:共有停止/再開 / R:再開始相談/取消。コード再読込ではない / Z:再開始相談のC同意 / X:05の停止中に最大5刻Undo / Esc:表紙へ。終了ではない / 1/2/3:04以降、表紙の厨房選択 / B:04以降、表紙の手渡し無効比較 / F12:画像イベントのみ。教材UIは保存しない / ウィンドウの閉じるボタン:終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_roles_movement.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

二つの役が、同じ刻に厨房を歩く

座標・役ごとに歩ける範囲・向き・ルール上の時計。

台所は、横7×縦3の小さなマス目です。左上が (0,0)、右下が (6,2) です。青い C は料理役、ピンクの S は配膳役で、文字の S は移動キーの S とは別の「役の名前」です。C は WASD、S は矢印キーで歩きます。Enter で始めたら、キーを一度全部離します。前提は、変数、if、for、関数、tuple と番号(添字)です。State は書き換えない(frozen)dataclass で、replace は古い記録の一部を差しかえた、新しい記録を作ります。

DIR は、上・右・下・左の順に (0,-1),(1,0),(0,1),(-1,0) です。方向の番号は 0/1/2/3、-1 は移動なしです。C の x は0〜3、S は3〜6の範囲です。棚、温める台、注文口、壁には入れません。真ん中の列は2人とも歩けて、同じマスに重なれます。押し合いや、おたがいの通せんぼはありません。2人の移動は同じ古い s.pos から決まり、1人目が動いた位置を、2人目のじゃまものとして渡しません。

歩けなかったときでも、face(向き)は入力した方向に変わります。たとえば、C が (1,0) から左を向くと、棚のある (0,0) には入れず、その場で棚を見ている状態になります。target は次の操作の相手を計算します。ふつうの台は向いている先だけ、2人で使う台は、足元に立っていれば足元が相手です。画面の青・ピンクの枠で、2人の操作の相手を読めます。

この段階は、移動だけの教材です。材料、温め、注文はまだ変わらず、台や注文の列は、あとで学ぶための表示です。E/M は操作、Q/N は手渡し用に予約してあり、押すと元のゲームと同じく、その役は移動しませんが、この段階では料理の処理はしません。Controller の「15フレームで1刻」、P/H での一時停止、R での2人の確認、Esc で表紙、操作を押した瞬間をためておく pending は、先に用意した足場です。30fps なので、1刻は0.5秒です。方向をいくつも同時に押した役は、方向 -1 になります。

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

01_roles_movement.py / 注目する部分
    for r in range(2):
        if not a.ops[r] and (not a.give[r]) and (a.dirs[r] in range(4)):
            face[r] = a.dirs[r]
            pos[r] = walk(k, s.pos[r], a.dirs[r], r)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_roles_movement.py
macOS / Linux
.venv/bin/python 01_roles_movement.py
画面で見ることEnter → キーを全部離す、と押します。D と左矢印を1刻ずつ2刻押し続けると、2人とも (3,1) に集まり、丸は少し左右にずれて表示されます。C がさらに D を押しても、右側には出られません。
  • 役ごとの x の範囲
  • 2人とも、古い位置から移動を決める
  • 歩けなくても、向きは変わる
つまずきやすいところC を動かしてから S とのぶつかりを解決する、という別のルールを足さないでください。台に歩いて入れないことと、向きを変えられないことも別です。移動に失敗しても、face は更新します。
少し変えてみよう

State の pos の最初の値を、((1, 1), (5, 1)) から ((2, 1), (4, 1)) に変えてみましょう。UTF-8 で保存 → ウィンドウを閉じる → python 01_roles_movement.py で実行し直します。Enter のあと、D と左矢印で1刻進めると、2人とも (3,1) です。python exercises/01_meet_center.py は、同じ書き換えと、15フレームの組み立てた入力を実行します。元の最初の値に戻します。R はコードを読み直しません。

01_roles_movement.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(391行)
01_roles_movement.py / 全文
# -*- coding: utf-8 -*-
# MIT License
# 
# Copyright (c) 2026
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""Standalone tutorial: Japanese instructions in LESSON.md; assets not needed."""
STAGE = 1

"""Immutable, simultaneous, unhurried two-role kitchen. Runtime: stdlib only."""
from dataclasses import dataclass, replace, asdict
import hashlib, json
VERSION = '088-v1.0.1'
DIR = ((0, -1), (1, 0), (0, 1), (-1, 0))
SHELVES = ((0, 0), (0, 2))
HEAT = ((2, 0), (2, 2))

@dataclass(frozen=True)
class Kitchen:
    id: str
    title: str
    shared: tuple
    walls: tuple
    orders: tuple
    counter: tuple
KITCHENS = (Kitchen('A', 'まんなかで会う', ((3, 0), (3, 2)), (), tuple('UBUBUB'), (6, 1)),)

@dataclass(frozen=True)
class Input:
    dirs: tuple = (-1, -1)
    ops: tuple = (False, False)
    give: tuple = (False, False)

@dataclass(frozen=True)
class Dish:
    kind: str
    cooked: bool = False

@dataclass(frozen=True)
class Pan:
    dish: Dish
    remain: int
    placed: int

@dataclass(frozen=True)
class State:
    t: int = 0
    pos: tuple = ((1, 1), (5, 1))
    face: tuple = (0, 1)
    hands: tuple = (None, None)
    heat: tuple = (None, None)
    shared: tuple = (None, None)
    order: int = 0
    created: int = 0
    returned: int = 0
    passes: int = 0
    conflicts: int = 0
    events: tuple = ()
    complete: bool = False

def new(k):
    return State()

def walk(k, p, d, role):
    if d not in range(4):
        return p
    q = (p[0] + DIR[d][0], p[1] + DIR[d][1])
    lo, hi = (0, 3) if role == 0 else (3, 6)
    return q if lo <= q[0] <= hi and 0 <= q[1] < 3 and (q not in SHELVES + HEAT + (k.counter,) + k.walls) else p

def target(k, p, face):
    if p in k.shared:
        return ('shared', k.shared.index(p))
    q = (p[0] + DIR[face][0], p[1] + DIR[face][1])
    if q in k.shared:
        return ('shared', k.shared.index(q))
    if q in SHELVES:
        return ('shelf', SHELVES.index(q))
    if q in HEAT:
        return ('heat', HEAT.index(q))
    if q == k.counter:
        return ('counter', 0)
    return ('none', 0)

def step(k, s, a, handoff=True):
    if s.complete:
        return s
    pos, face = (list(s.pos), list(s.face))
    for r in range(2):
        if not a.ops[r] and (not a.give[r]) and (a.dirs[r] in range(4)):
            face[r] = a.dirs[r]
            pos[r] = walk(k, s.pos[r], a.dirs[r], r)
    return replace(s, t=s.t + 1, pos=tuple(pos), face=tuple(face), events=())

def conservation(s):
    return s.created - s.returned - s.order == sum((x is not None for x in s.hands + s.heat + s.shared))

def digest(s):
    return hashlib.sha256(json.dumps(asdict(s), sort_keys=True, separators=(',', ':')).encode()).hexdigest()

KEYS = {'W', 'D', 'S', 'A', 'E', 'Q', 'UP', 'RIGHT', 'DOWN', 'LEFT', 'M', 'N', 'P', 'H', 'X', 'R', 'Z', 'RETURN', 'SPACE', '1', '2', '3', 'B', 'F12', 'ESCAPE'}

class Controller:

    def __init__(self):
        self.level = 0
        self.variant = False
        self.title = True
        self.paused = False
        self.restart = False
        self.ready = set()
        self.previous = set()
        self.blocked = set()
        self.release_gate = False
        self.pending = set()
        self.clock = 0
        self.history = []
        self.actions = []
        self.state = new(self.kitchen)

    @property
    def kitchen(self):
        return KITCHENS[self.level]

    def reset(self):
        self.state = new(self.kitchen)
        self.history = []
        self.actions = []
        self.clock = 0
        self.pending.clear()
        self.restart = False
        self.paused = False
        self.ready = set()

    def barrier(self, down):
        self.blocked |= down
        self.release_gate = True
        self.pending.clear()
        self.clock = 0

    def tick(self, a):
        old = self.state
        self.state = step(self.kitchen, old, a, not self.variant)
        if old != self.state:
            self.history.append(old)
            self.actions.append(asdict(a))
        return 'tick'

    def feed(self, down):
        down = set(down) & KEYS
        self.blocked &= down
        fresh = down - self.previous - self.blocked
        self.previous = down
        active = down - self.blocked
        if self.release_gate:
            if not down:
                self.release_gate = False
            return None
        if 'F12' in fresh:
            return 'screenshot'
        if 'ESCAPE' in fresh:
            self.title = True
            self.barrier(down)
            return 'menu'
        if self.title:
            if 'RETURN' in fresh:
                self.title = False
                self.reset()
                self.barrier(down)
                return 'start'
            return None
        if 'R' in fresh:
            self.restart = not self.restart
            self.ready = set()
            self.barrier(down)
            return 'restart_request'
        if self.restart:
            if 'Z' in fresh:
                self.ready.add('C')
            if 'RETURN' in fresh:
                self.ready.add('S')
            if len(self.ready) == 2:
                self.reset()
                self.barrier(down)
                return 'restart_confirmed'
            return None
        if 'P' in fresh or 'H' in fresh:
            self.paused = not self.paused
            self.barrier(down)
            return 'pause'
        if self.paused:
            return None
        if self.state.complete:
            return None
        self.pending |= fresh & {'E', 'Q', 'M', 'N'}
        self.clock += 1
        if self.clock < 15:
            return None
        self.clock -= 15

        def direction(keys):
            a = [i for i, k in enumerate(keys) if k in active]
            return a[0] if len(a) == 1 else -1
        a = Input((direction(('W', 'D', 'S', 'A')), direction(('UP', 'RIGHT', 'DOWN', 'LEFT'))), ('E' in self.pending, 'M' in self.pending), ('Q' in self.pending, 'N' in self.pending))
        self.pending.clear()
        return self.tick(a)

# Teaching ASCII renderer. Original input meanings are preserved.
EVENT_LABELS = {
    '同じ台、ひとりずつ': 'SAME SHARED TABLE: BOTH OPERATIONS REJECTED',
    '共有台から受取り': 'PICKED UP FROM SHARED TABLE',
    '共有台へ置きました': 'PLACED ON SHARED TABLE',
    '空いた手と台で受け渡し': 'NEED ONE EMPTY HAND OR EMPTY TABLE',
    'スープの材料': 'TOOK RAW SOUP U', 'パンの材料': 'TOOK RAW BREAD B',
    '材料を棚へ戻しました': 'RAW INGREDIENT RETURNED',
    '生の同じ材料だけ戻せます': 'RETURN ONLY MATCHING RAW INGREDIENT',
    '加熱開始。次の刻から進みます': 'HEATING STARTED; COUNTDOWN STARTS NEXT TICK',
    '出来上がりを受取り': 'PICKED UP COOKED DISH',
    '空手で完成皿を。生皿は空いた台へ': 'NEED EMPTY HAND + READY PAN, OR RAW DISH + EMPTY STOVE',
    '先頭の完成皿をどうぞ。皿は手元に戻ります': 'WRONG OR RAW ORDER: DISH RETAINED',
    '向きの先に自分の作業台はありません': 'NO USABLE STATION IN FRONT FOR THIS ROLE',
    'スープ完成!': 'SOUP U COOKED', 'パン完成!': 'BREAD B COOKED',
    'ごちそうさま。二人も座ろう。': 'ALL SIX ORDERS COMPLETE',
    '比較版: 共有台で受け渡します': 'DIRECT HANDOFF DISABLED; USE SHARED TABLE',
    '手渡しは隣のマスで': 'HANDOFF NEEDS ADJACENT CELLS',
    '片方が空手のときに手渡し': 'HANDOFF NEEDS EXACTLY ONE EMPTY HAND',
    'はい、どうぞ!': 'DIRECT HANDOFF COMPLETE',
}
def event_label(event):
    if event.startswith('いただきます!'):
        return 'CORRECT COOKED ORDER SERVED'
    return EVENT_LABELS.get(event, 'EVENT: SEE JAPANESE STATE JSON')

def dish_label(d):
    return '-' if d is None else d.kind + (' COOKED' if d.cooked else ' RAW')

class App:
    def __init__(self):
        self.ui = Controller()
        self.message = 'ENTER START / RELEASE ALL AFTER TRANSITIONS'

    def control(self, down):
        event = self.ui.feed(down)
        if event:
            self.message = event.upper().replace('_', ' ')
        return event

    def draw(self, p):
        u, s, k = self.ui, self.ui.state, self.ui.kitchen
        p.cls(1)
        p.text(14, 12, 'LINKED KITCHEN / LESSON ' + str(STAGE) + ' / TEACHING UI', 10)
        p.text(14, 28, 'KITCHEN ' + k.id + '  TICK ' + str(s.t) + '  CLOCK ' + str(u.clock) + '/15  DIRECT HANDOFF ' + ('OFF' if u.variant or STAGE < 4 else 'ON'), 7)
        p.text(14, 48, 'ORDERS ' + ''.join(k.orders) + '  SERVED ' + str(s.order) + '/6  CREATED ' + str(s.created) + ' RETURNED ' + str(s.returned), 10)
        p.text(14, 62, 'U = SOUP / B = BREAD / TIME LIMIT: NONE / NO BURNING', 6)
        for y in range(3):
            for x in range(7):
                pos = (x, y); xx, yy = 14 + x * 70, 86 + y * 78
                p.rect(xx, yy, 68, 76, 5 if x == 3 else 1 if x < 3 else 2)
                p.rectb(xx, yy, 68, 76, 0)
                if pos in k.walls:
                    p.rect(xx + 3, yy + 3, 62, 70, 5);p.text(xx + 24, yy + 31, 'WALL', 7)
                elif pos in SHELVES:
                    i = SHELVES.index(pos);p.text(xx + 5, yy + 8, 'SHELF ' + 'UB'[i], 10)
                    p.text(xx + 5, yy + 60, 'E: RAW', 6)
                elif pos in HEAT:
                    i=HEAT.index(pos);pan=s.heat[i];p.text(xx + 5, yy + 8, 'HEAT ' + str(i), 8)
                    p.circb(xx + 34, yy + 38, 16, 8 if pan and pan.remain else 10)
                    if pan:
                        p.text(xx + 15, yy + 35, pan.dish.kind + ' ' + str(pan.remain), 10)
                        p.text(xx + 5, yy + 60, 'READY' if pan.remain == 0 else 'HEATING', 7)
                elif pos in k.shared:
                    i=k.shared.index(pos);p.rectb(xx + 3, yy + 16, 61, 47, 15)
                    p.text(xx + 5, yy + 5, 'SHARED ' + str(i), 7)
                    p.text(xx + 6, yy + 27, dish_label(s.shared[i]), 10)
                    p.text(xx + 5, yy + 65, 'FOOT/FRONT', 6)
                elif pos == k.counter:
                    p.text(xx + 6, yy + 9, 'COUNTER', 10)
                    p.rect(xx + 6, yy + 29, 55, 19, 9)
                    p.text(xx + 6, yy + 60, 'NEXT ' + (k.orders[s.order] if not s.complete else '-'), 7)
        for r in range(2):
            typ,i=target(k,s.pos[r],s.face[r]);dest=k.shared[i] if typ=='shared' else SHELVES[i] if typ=='shelf' else HEAT[i] if typ=='heat' else k.counter if typ=='counter' else None
            if dest:
                xx,yy=14+dest[0]*70,86+dest[1]*78;p.rectb(xx+1+r*2,yy+1+r*2,66-r*4,74-r*4,(12,14)[r])
        for r in range(2):
            pos=s.pos[r];xx,yy=48+pos[0]*70,127+pos[1]*78
            if s.pos[0]==s.pos[1]:xx+=(-9,9)[r]
            p.circ(xx,yy,10,(12,14)[r]);p.text(xx-2,yy-2,'CS'[r],0)
            dx,dy=DIR[s.face[r]];p.line(xx,yy,xx+dx*19,yy+dy*19,(12,14)[r])
        p.text(521, 90, 'C COOK / BLUE', 12)
        p.text(521, 106, 'WASD MOVE', 7);p.text(521, 120, 'E OPERATE', 7);p.text(521, 134, 'Q GIVE', 7)
        p.text(521, 160, 'S SERVER / PINK', 14)
        p.text(521, 176, 'ARROWS MOVE', 7);p.text(521, 190, 'M OPERATE', 7);p.text(521, 204, 'N GIVE', 7)
        p.text(521, 229, 'PASSES ' + str(s.passes), 10);p.text(521, 245, 'CONFLICTS ' + str(s.conflicts), 10)
        p.text(521, 269, '2 TICKS / SECOND', 6)
        p.text(521, 287, 'C/S MAY OVERLAP', 6)
        p.text(14, 329, 'C HAND: ' + dish_label(s.hands[0]) + '   S HAND: ' + dish_label(s.hands[1]) + '   CONSERVED: ' + str(conservation(s)), 7)
        for i,event in enumerate(s.events[-2:]):p.text(14,347+i*15,event_label(event),10)
        p.text(14, 381, self.message, 11)
        p.text(14, 398, 'P/H PAUSE / R RESTART REQUEST: Z + ENTER AGREE, R CANCEL', 7)
        p.text(14, 413, 'ESC MENU / CLOSE WINDOW TO QUIT / F12 EVENT ONLY: SEE CLI', 6)
        p.text(14, 428, ('01: MOVEMENT ONLY' if STAGE == 1 else '02: RAW SHELVES + SHARED ONLY' if STAGE == 2 else '03: COOK + SERVE VIA TABLES' if STAGE == 3 else '1-3 KITCHEN + B COMPARE AT TITLE') + (' / PAUSED X UNDO 5' if STAGE == 5 else ''), 6)
        if s.complete:
            p.rect(122, 156, 428, 95, 1);p.rectb(122,156,428,95,11)
            p.text(144, 177, 'ALL SIX DISHES SERVED / BOTH ROLES COMPLETE',11)
            p.text(144, 200, 'SAME GOAL WITH SHARED TABLES OR DIRECT HANDOFF',7)
            p.text(144, 223, 'NO TIME LIMIT / R REQUESTS A FRESH KITCHEN',6)
        if u.title or u.paused or u.restart:
            p.rect(91,102,490,208,1);p.rectb(91,102,490,208,10)
            if u.title:
                lines=['TWO ROLES, ONE KITCHEN', 'C: WASD / E OPERATE / Q GIVE', 'S: ARROWS / M OPERATE / N GIVE', 'OPERATE BEATS MOVE; GIVE BEATS OPERATE', 'ENTER START, THEN RELEASE ALL KEYS', '1-3 KITCHEN / B DIRECT HANDOFF COMPARISON' if STAGE>=4 else 'KITCHEN A ONLY / MORE RULES IN LATER STAGES', 'SYNTHETIC QA IS NOT A TWO-HUMAN PLAYTEST']
            elif u.restart:
                lines=['RESTART REQUEST / WORLD STOPPED', 'C: Z AGREES    S: ENTER AGREES', 'AGREED: ' + ','.join(sorted(u.ready)), 'R CANCELS / STATE KEPT UNTIL BOTH AGREE', 'R DOES NOT RELOAD PYTHON SOURCE']
            else:
                lines=['PAUSED / ALL DISHES AND BOTH ROLES STOP', 'P/H RESUME, THEN RELEASE ALL KEYS', 'X RESTORES THE WHOLE KITCHEN UP TO 5 TICKS' if STAGE==5 else 'UNDO IS ADDED IN STAGE 05', 'R REQUESTS RESTART / ESC MENU', 'CLOCK AND PENDING OPERATIONS CLEARED ON TRANSITION']
            for i,line in enumerate(lines):p.text(108,122+i*24,line,10 if i==0 else 7)

def tap(app, keys):
    app.control(set())
    return app.control(set(keys))

def start(app):
    tap(app, {'RETURN'})
    app.control(set())

def logical_tick(app, keys=()):
    # For deterministic exercises: one controller window of 15 frames.
    for _ in range(15):app.control(set(keys))

def checkpoint(app):
    start(app)
    logical_tick(app, {'W'})
    logical_tick(app, {'A'})
    logical_tick(app, {'E'})

def main(headless=False, screenshot=None, state_json=None, demo=False):
    import pyxel as p
    from pathlib import Path
    p.init(680,450,title='088 lesson '+str(STAGE),fps=30,display_scale=1,quit_key=p.KEY_NONE,headless=headless)
    app=App()
    if demo:checkpoint(app)
    keys={k:getattr(p,'KEY_'+k) for k in KEYS}
    def update():
        if headless:
            if p.frame_count>=3:
                app.draw(p)
                if screenshot:p.screen.save(screenshot,1)
                if state_json:Path(state_json).write_text(json.dumps({'stage':STAGE,'state':asdict(app.ui.state),'clock':app.ui.clock,'title':app.ui.title,'scope':'Actual headless Pyxel; synthetic controls; dummy audio; no physical keys or two-human play'},ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
                p.quit()
            return
        app.control({k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description='Standalone linked kitchen tutorial')
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--screenshot')
    parser.add_argument('--state-json')
    args=parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke:
        parser.error('demo and outputs require --headless-smoke')
    for field,suffix in (('screenshot','.png'),('state_json','.json')):
        value=getattr(args,field)
        if value:
            output_path=Path(value).resolve()
            if output_path.suffix.lower()!=suffix or not output_path.parent.is_dir() or output_path.exists():
                parser.error('output must be a new '+suffix+' in an existing directory')
            setattr(args,field,str(output_path))
    main(args.headless_smoke,args.screenshot,args.state_json,args.demo_checkpoint)
STEP 02 / 05

一皿だけ持ち、共有台へ置く

お皿の持ち主・数の帳じり・同時の操作の取り合い。

Dish(kind,cooked)(お皿)を動かします。kind は、U がスープ、B がパン、cooked=False は生の材料です。1人が持てるお皿は1つで、手が空なら None です。C が棚に向いて E を押すと、お皿を1つ作り、created(作った数)を1増やします。同じ種類の生のお皿を同じ棚に返すと、returned(返した数)が1増えます。違う種類や、できあがったお皿は消しません。S には、材料の棚を操作する権利はありません。

2人で使う台は2つあり、それぞれお皿1つぶんです。手が空いていてお皿のある台を操作すれば受け取り、お皿を持っていて空の台を操作すれば置きます。手も台も空、または両方にお皿があるときは、何も移しません。C の E と S の M が、同じ刻に同じ共有の台を操作しようとしたときは、両方の操作をまとめて取り消し、conflicts(取り合いの回数)だけを1増やします。C が先に置いて、その同じ刻に S が持っていく、ということにはなりません。

それぞれの役の移動を決めたあと、2人ぶんの操作の相手 targets を作り、同じ共有の台かを判定してから、残った台の操作を C → S の順に実行します。全部を順番なしで処理するわけではありません。取り合いの条件と、処理の順番を分けて読むと、「同時の入力」と「順番に行う処理」を混同しません。この段階は、生のお皿の棚と共有の台だけです。温める台、注文口、直接の手渡しは、まだ働きません。

created−returned−order は、「手2枠+温める台2枠+共有の台2枠」のうち、お皿がある枠の数と同じになります。conservation は、この数の帳じりのルールです。この段階では order と heat が0なので、持ちかえで生のお皿が増えたり消えたりしていないかを確かめられます。

E/M は、長押ししても連続で押したことにはなりません。Controller は、fresh(押した瞬間)を pending にためて、次の15フレームの区切りで1回使って空にします。区切りより前に離しても、操作は届きます。方向は、区切りのときに押されている active を読むので、同じ方式ではありません。棚からお皿を取った直後、E を押し続けても、勝手に返さない理由です。

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

02_shelves_shared.py / 注目する部分
    collision = targets[0] is not None and targets[0] == targets[1] and (targets[0][0] == 'shared')
    if collision:
        conflicts += 1
        events.append('同じ台、ひとりずつ')
        targets = [None, None]

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_shelves_shared.py
macOS / Linux
.venv/bin/python 02_shelves_shared.py
画面で見ること始めたあと、W を1刻 → A を1刻 → E を1刻で、C の手は U RAW(生のスープ)、created=1 です。E をもう1刻押し続けても、そのままです。1刻離して E を押し直すと、手は空になり、returned=1、CONSERVED=True です。
  • 1つの枠に1皿、空なら None
  • 共有の台を同時に取り合ったら、両方とも取り消す
  • 押した瞬間を予約しておく pending
つまずきやすいところE を毎フレーム ops=True に直接つなぐと、長押しで「取る・返す」をくり返してしまいます。また、同じ共有の台を C、S の順にすぐ処理すると、元の「両方とも取り消し」という取り合いのルールがなくなります。
少し変えてみよう

コードは変えずに、保存 → 終了 → python 02_shelves_shared.py と実行してみましょう。W、A、E をそれぞれ1刻、E を押し続けてもう1刻、何も押さずに1刻、E を1刻、の順に試します。python exercises/02_press_release_return.py はこの6刻を実行し、t3 で生のお皿、t4 でも生のお皿、t6 で返したことを assert で確かめます。R は、R → 離す → Z → 離す → Enter の2人の確認で、状態だけを最初に戻します。

02_shelves_shared.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(442行)
02_shelves_shared.py / 全文
# -*- coding: utf-8 -*-
# MIT License
# 
# Copyright (c) 2026
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""Standalone tutorial: Japanese instructions in LESSON.md; assets not needed."""
STAGE = 2

"""Immutable, simultaneous, unhurried two-role kitchen. Runtime: stdlib only."""
from dataclasses import dataclass, replace, asdict
import hashlib, json
VERSION = '088-v1.0.1'
DIR = ((0, -1), (1, 0), (0, 1), (-1, 0))
SHELVES = ((0, 0), (0, 2))
HEAT = ((2, 0), (2, 2))

@dataclass(frozen=True)
class Kitchen:
    id: str
    title: str
    shared: tuple
    walls: tuple
    orders: tuple
    counter: tuple
KITCHENS = (Kitchen('A', 'まんなかで会う', ((3, 0), (3, 2)), (), tuple('UBUBUB'), (6, 1)),)

@dataclass(frozen=True)
class Input:
    dirs: tuple = (-1, -1)
    ops: tuple = (False, False)
    give: tuple = (False, False)

@dataclass(frozen=True)
class Dish:
    kind: str
    cooked: bool = False

@dataclass(frozen=True)
class Pan:
    dish: Dish
    remain: int
    placed: int

@dataclass(frozen=True)
class State:
    t: int = 0
    pos: tuple = ((1, 1), (5, 1))
    face: tuple = (0, 1)
    hands: tuple = (None, None)
    heat: tuple = (None, None)
    shared: tuple = (None, None)
    order: int = 0
    created: int = 0
    returned: int = 0
    passes: int = 0
    conflicts: int = 0
    events: tuple = ()
    complete: bool = False

def new(k):
    return State()

def walk(k, p, d, role):
    if d not in range(4):
        return p
    q = (p[0] + DIR[d][0], p[1] + DIR[d][1])
    lo, hi = (0, 3) if role == 0 else (3, 6)
    return q if lo <= q[0] <= hi and 0 <= q[1] < 3 and (q not in SHELVES + HEAT + (k.counter,) + k.walls) else p

def target(k, p, face):
    if p in k.shared:
        return ('shared', k.shared.index(p))
    q = (p[0] + DIR[face][0], p[1] + DIR[face][1])
    if q in k.shared:
        return ('shared', k.shared.index(q))
    if q in SHELVES:
        return ('shelf', SHELVES.index(q))
    if q in HEAT:
        return ('heat', HEAT.index(q))
    if q == k.counter:
        return ('counter', 0)
    return ('none', 0)

def step(k, s, a, handoff=True):
    if s.complete:
        return s
    pos = list(s.pos)
    face = list(s.face)
    hands = list(s.hands)
    heat = list(s.heat)
    shared = list(s.shared)
    created = s.created
    returned = s.returned
    order = s.order
    passes = s.passes
    conflicts = s.conflicts
    events = []
    tick = s.t + 1
    for r in range(2):
        if not a.ops[r] and (not a.give[r]) and (a.dirs[r] in range(4)):
            face[r] = a.dirs[r]
            pos[r] = walk(k, s.pos[r], a.dirs[r], r)
    participants = set()
    targets = [target(k, pos[r], face[r]) if a.ops[r] and (not a.give[r]) and (r not in participants) else None for r in range(2)]
    collision = targets[0] is not None and targets[0] == targets[1] and (targets[0][0] == 'shared')
    if collision:
        conflicts += 1
        events.append('同じ台、ひとりずつ')
        targets = [None, None]
    for r, tg in enumerate(targets):
        if tg is None:
            continue
        typ, i = tg
        hand = hands[r]
        if typ == 'shared':
            if hand is None and shared[i] is not None:
                hands[r] = shared[i]
                shared[i] = None
                events.append('共有台から受取り')
            elif hand is not None and shared[i] is None:
                shared[i] = hand
                hands[r] = None
                events.append('共有台へ置きました')
            else:
                events.append('空いた手と台で受け渡し')
        elif typ == 'shelf' and r == 0:
            kind = ('U', 'B')[i]
            if hand is None:
                hands[r] = Dish(kind)
                created += 1
                events.append('スープの材料' if kind == 'U' else 'パンの材料')
            elif not hand.cooked and hand.kind == kind:
                hands[r] = None
                returned += 1
                events.append('材料を棚へ戻しました')
            else:
                events.append('生の同じ材料だけ戻せます')
        else:
            events.append('向きの先に自分の作業台はありません')
    complete = order == len(k.orders)
    if complete:
        events.append('ごちそうさま。二人も座ろう。')
    return State(tick, tuple(pos), tuple(face), tuple(hands), tuple(heat), tuple(shared), order, created, returned, passes, conflicts, tuple(events), complete)

def conservation(s):
    return s.created - s.returned - s.order == sum((x is not None for x in s.hands + s.heat + s.shared))

def digest(s):
    return hashlib.sha256(json.dumps(asdict(s), sort_keys=True, separators=(',', ':')).encode()).hexdigest()

KEYS = {'W', 'D', 'S', 'A', 'E', 'Q', 'UP', 'RIGHT', 'DOWN', 'LEFT', 'M', 'N', 'P', 'H', 'X', 'R', 'Z', 'RETURN', 'SPACE', '1', '2', '3', 'B', 'F12', 'ESCAPE'}

class Controller:

    def __init__(self):
        self.level = 0
        self.variant = False
        self.title = True
        self.paused = False
        self.restart = False
        self.ready = set()
        self.previous = set()
        self.blocked = set()
        self.release_gate = False
        self.pending = set()
        self.clock = 0
        self.history = []
        self.actions = []
        self.state = new(self.kitchen)

    @property
    def kitchen(self):
        return KITCHENS[self.level]

    def reset(self):
        self.state = new(self.kitchen)
        self.history = []
        self.actions = []
        self.clock = 0
        self.pending.clear()
        self.restart = False
        self.paused = False
        self.ready = set()

    def barrier(self, down):
        self.blocked |= down
        self.release_gate = True
        self.pending.clear()
        self.clock = 0

    def tick(self, a):
        old = self.state
        self.state = step(self.kitchen, old, a, not self.variant)
        if old != self.state:
            self.history.append(old)
            self.actions.append(asdict(a))
        return 'tick'

    def feed(self, down):
        down = set(down) & KEYS
        self.blocked &= down
        fresh = down - self.previous - self.blocked
        self.previous = down
        active = down - self.blocked
        if self.release_gate:
            if not down:
                self.release_gate = False
            return None
        if 'F12' in fresh:
            return 'screenshot'
        if 'ESCAPE' in fresh:
            self.title = True
            self.barrier(down)
            return 'menu'
        if self.title:
            if 'RETURN' in fresh:
                self.title = False
                self.reset()
                self.barrier(down)
                return 'start'
            return None
        if 'R' in fresh:
            self.restart = not self.restart
            self.ready = set()
            self.barrier(down)
            return 'restart_request'
        if self.restart:
            if 'Z' in fresh:
                self.ready.add('C')
            if 'RETURN' in fresh:
                self.ready.add('S')
            if len(self.ready) == 2:
                self.reset()
                self.barrier(down)
                return 'restart_confirmed'
            return None
        if 'P' in fresh or 'H' in fresh:
            self.paused = not self.paused
            self.barrier(down)
            return 'pause'
        if self.paused:
            return None
        if self.state.complete:
            return None
        self.pending |= fresh & {'E', 'Q', 'M', 'N'}
        self.clock += 1
        if self.clock < 15:
            return None
        self.clock -= 15

        def direction(keys):
            a = [i for i, k in enumerate(keys) if k in active]
            return a[0] if len(a) == 1 else -1
        a = Input((direction(('W', 'D', 'S', 'A')), direction(('UP', 'RIGHT', 'DOWN', 'LEFT'))), ('E' in self.pending, 'M' in self.pending), ('Q' in self.pending, 'N' in self.pending))
        self.pending.clear()
        return self.tick(a)

# Teaching ASCII renderer. Original input meanings are preserved.
EVENT_LABELS = {
    '同じ台、ひとりずつ': 'SAME SHARED TABLE: BOTH OPERATIONS REJECTED',
    '共有台から受取り': 'PICKED UP FROM SHARED TABLE',
    '共有台へ置きました': 'PLACED ON SHARED TABLE',
    '空いた手と台で受け渡し': 'NEED ONE EMPTY HAND OR EMPTY TABLE',
    'スープの材料': 'TOOK RAW SOUP U', 'パンの材料': 'TOOK RAW BREAD B',
    '材料を棚へ戻しました': 'RAW INGREDIENT RETURNED',
    '生の同じ材料だけ戻せます': 'RETURN ONLY MATCHING RAW INGREDIENT',
    '加熱開始。次の刻から進みます': 'HEATING STARTED; COUNTDOWN STARTS NEXT TICK',
    '出来上がりを受取り': 'PICKED UP COOKED DISH',
    '空手で完成皿を。生皿は空いた台へ': 'NEED EMPTY HAND + READY PAN, OR RAW DISH + EMPTY STOVE',
    '先頭の完成皿をどうぞ。皿は手元に戻ります': 'WRONG OR RAW ORDER: DISH RETAINED',
    '向きの先に自分の作業台はありません': 'NO USABLE STATION IN FRONT FOR THIS ROLE',
    'スープ完成!': 'SOUP U COOKED', 'パン完成!': 'BREAD B COOKED',
    'ごちそうさま。二人も座ろう。': 'ALL SIX ORDERS COMPLETE',
    '比較版: 共有台で受け渡します': 'DIRECT HANDOFF DISABLED; USE SHARED TABLE',
    '手渡しは隣のマスで': 'HANDOFF NEEDS ADJACENT CELLS',
    '片方が空手のときに手渡し': 'HANDOFF NEEDS EXACTLY ONE EMPTY HAND',
    'はい、どうぞ!': 'DIRECT HANDOFF COMPLETE',
}
def event_label(event):
    if event.startswith('いただきます!'):
        return 'CORRECT COOKED ORDER SERVED'
    return EVENT_LABELS.get(event, 'EVENT: SEE JAPANESE STATE JSON')

def dish_label(d):
    return '-' if d is None else d.kind + (' COOKED' if d.cooked else ' RAW')

class App:
    def __init__(self):
        self.ui = Controller()
        self.message = 'ENTER START / RELEASE ALL AFTER TRANSITIONS'

    def control(self, down):
        event = self.ui.feed(down)
        if event:
            self.message = event.upper().replace('_', ' ')
        return event

    def draw(self, p):
        u, s, k = self.ui, self.ui.state, self.ui.kitchen
        p.cls(1)
        p.text(14, 12, 'LINKED KITCHEN / LESSON ' + str(STAGE) + ' / TEACHING UI', 10)
        p.text(14, 28, 'KITCHEN ' + k.id + '  TICK ' + str(s.t) + '  CLOCK ' + str(u.clock) + '/15  DIRECT HANDOFF ' + ('OFF' if u.variant or STAGE < 4 else 'ON'), 7)
        p.text(14, 48, 'ORDERS ' + ''.join(k.orders) + '  SERVED ' + str(s.order) + '/6  CREATED ' + str(s.created) + ' RETURNED ' + str(s.returned), 10)
        p.text(14, 62, 'U = SOUP / B = BREAD / TIME LIMIT: NONE / NO BURNING', 6)
        for y in range(3):
            for x in range(7):
                pos = (x, y); xx, yy = 14 + x * 70, 86 + y * 78
                p.rect(xx, yy, 68, 76, 5 if x == 3 else 1 if x < 3 else 2)
                p.rectb(xx, yy, 68, 76, 0)
                if pos in k.walls:
                    p.rect(xx + 3, yy + 3, 62, 70, 5);p.text(xx + 24, yy + 31, 'WALL', 7)
                elif pos in SHELVES:
                    i = SHELVES.index(pos);p.text(xx + 5, yy + 8, 'SHELF ' + 'UB'[i], 10)
                    p.text(xx + 5, yy + 60, 'E: RAW', 6)
                elif pos in HEAT:
                    i=HEAT.index(pos);pan=s.heat[i];p.text(xx + 5, yy + 8, 'HEAT ' + str(i), 8)
                    p.circb(xx + 34, yy + 38, 16, 8 if pan and pan.remain else 10)
                    if pan:
                        p.text(xx + 15, yy + 35, pan.dish.kind + ' ' + str(pan.remain), 10)
                        p.text(xx + 5, yy + 60, 'READY' if pan.remain == 0 else 'HEATING', 7)
                elif pos in k.shared:
                    i=k.shared.index(pos);p.rectb(xx + 3, yy + 16, 61, 47, 15)
                    p.text(xx + 5, yy + 5, 'SHARED ' + str(i), 7)
                    p.text(xx + 6, yy + 27, dish_label(s.shared[i]), 10)
                    p.text(xx + 5, yy + 65, 'FOOT/FRONT', 6)
                elif pos == k.counter:
                    p.text(xx + 6, yy + 9, 'COUNTER', 10)
                    p.rect(xx + 6, yy + 29, 55, 19, 9)
                    p.text(xx + 6, yy + 60, 'NEXT ' + (k.orders[s.order] if not s.complete else '-'), 7)
        for r in range(2):
            typ,i=target(k,s.pos[r],s.face[r]);dest=k.shared[i] if typ=='shared' else SHELVES[i] if typ=='shelf' else HEAT[i] if typ=='heat' else k.counter if typ=='counter' else None
            if dest:
                xx,yy=14+dest[0]*70,86+dest[1]*78;p.rectb(xx+1+r*2,yy+1+r*2,66-r*4,74-r*4,(12,14)[r])
        for r in range(2):
            pos=s.pos[r];xx,yy=48+pos[0]*70,127+pos[1]*78
            if s.pos[0]==s.pos[1]:xx+=(-9,9)[r]
            p.circ(xx,yy,10,(12,14)[r]);p.text(xx-2,yy-2,'CS'[r],0)
            dx,dy=DIR[s.face[r]];p.line(xx,yy,xx+dx*19,yy+dy*19,(12,14)[r])
        p.text(521, 90, 'C COOK / BLUE', 12)
        p.text(521, 106, 'WASD MOVE', 7);p.text(521, 120, 'E OPERATE', 7);p.text(521, 134, 'Q GIVE', 7)
        p.text(521, 160, 'S SERVER / PINK', 14)
        p.text(521, 176, 'ARROWS MOVE', 7);p.text(521, 190, 'M OPERATE', 7);p.text(521, 204, 'N GIVE', 7)
        p.text(521, 229, 'PASSES ' + str(s.passes), 10);p.text(521, 245, 'CONFLICTS ' + str(s.conflicts), 10)
        p.text(521, 269, '2 TICKS / SECOND', 6)
        p.text(521, 287, 'C/S MAY OVERLAP', 6)
        p.text(14, 329, 'C HAND: ' + dish_label(s.hands[0]) + '   S HAND: ' + dish_label(s.hands[1]) + '   CONSERVED: ' + str(conservation(s)), 7)
        for i,event in enumerate(s.events[-2:]):p.text(14,347+i*15,event_label(event),10)
        p.text(14, 381, self.message, 11)
        p.text(14, 398, 'P/H PAUSE / R RESTART REQUEST: Z + ENTER AGREE, R CANCEL', 7)
        p.text(14, 413, 'ESC MENU / CLOSE WINDOW TO QUIT / F12 EVENT ONLY: SEE CLI', 6)
        p.text(14, 428, ('01: MOVEMENT ONLY' if STAGE == 1 else '02: RAW SHELVES + SHARED ONLY' if STAGE == 2 else '03: COOK + SERVE VIA TABLES' if STAGE == 3 else '1-3 KITCHEN + B COMPARE AT TITLE') + (' / PAUSED X UNDO 5' if STAGE == 5 else ''), 6)
        if s.complete:
            p.rect(122, 156, 428, 95, 1);p.rectb(122,156,428,95,11)
            p.text(144, 177, 'ALL SIX DISHES SERVED / BOTH ROLES COMPLETE',11)
            p.text(144, 200, 'SAME GOAL WITH SHARED TABLES OR DIRECT HANDOFF',7)
            p.text(144, 223, 'NO TIME LIMIT / R REQUESTS A FRESH KITCHEN',6)
        if u.title or u.paused or u.restart:
            p.rect(91,102,490,208,1);p.rectb(91,102,490,208,10)
            if u.title:
                lines=['TWO ROLES, ONE KITCHEN', 'C: WASD / E OPERATE / Q GIVE', 'S: ARROWS / M OPERATE / N GIVE', 'OPERATE BEATS MOVE; GIVE BEATS OPERATE', 'ENTER START, THEN RELEASE ALL KEYS', '1-3 KITCHEN / B DIRECT HANDOFF COMPARISON' if STAGE>=4 else 'KITCHEN A ONLY / MORE RULES IN LATER STAGES', 'SYNTHETIC QA IS NOT A TWO-HUMAN PLAYTEST']
            elif u.restart:
                lines=['RESTART REQUEST / WORLD STOPPED', 'C: Z AGREES    S: ENTER AGREES', 'AGREED: ' + ','.join(sorted(u.ready)), 'R CANCELS / STATE KEPT UNTIL BOTH AGREE', 'R DOES NOT RELOAD PYTHON SOURCE']
            else:
                lines=['PAUSED / ALL DISHES AND BOTH ROLES STOP', 'P/H RESUME, THEN RELEASE ALL KEYS', 'X RESTORES THE WHOLE KITCHEN UP TO 5 TICKS' if STAGE==5 else 'UNDO IS ADDED IN STAGE 05', 'R REQUESTS RESTART / ESC MENU', 'CLOCK AND PENDING OPERATIONS CLEARED ON TRANSITION']
            for i,line in enumerate(lines):p.text(108,122+i*24,line,10 if i==0 else 7)

def tap(app, keys):
    app.control(set())
    return app.control(set(keys))

def start(app):
    tap(app, {'RETURN'})
    app.control(set())

def logical_tick(app, keys=()):
    # For deterministic exercises: one controller window of 15 frames.
    for _ in range(15):app.control(set(keys))

def checkpoint(app):
    start(app)
    logical_tick(app, {'W'})
    logical_tick(app, {'A'})
    logical_tick(app, {'E'})

def main(headless=False, screenshot=None, state_json=None, demo=False):
    import pyxel as p
    from pathlib import Path
    p.init(680,450,title='088 lesson '+str(STAGE),fps=30,display_scale=1,quit_key=p.KEY_NONE,headless=headless)
    app=App()
    if demo:checkpoint(app)
    keys={k:getattr(p,'KEY_'+k) for k in KEYS}
    def update():
        if headless:
            if p.frame_count>=3:
                app.draw(p)
                if screenshot:p.screen.save(screenshot,1)
                if state_json:Path(state_json).write_text(json.dumps({'stage':STAGE,'state':asdict(app.ui.state),'clock':app.ui.clock,'title':app.ui.title,'scope':'Actual headless Pyxel; synthetic controls; dummy audio; no physical keys or two-human play'},ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
                p.quit()
            return
        app.control({k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description='Standalone linked kitchen tutorial')
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--screenshot')
    parser.add_argument('--state-json')
    args=parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke:
        parser.error('demo and outputs require --headless-smoke')
    for field,suffix in (('screenshot','.png'),('state_json','.json')):
        value=getattr(args,field)
        if value:
            output_path=Path(value).resolve()
            if output_path.suffix.lower()!=suffix or not output_path.parent.is_dir() or output_path.exists():
                parser.error('output must be a new '+suffix+' in an existing directory')
            setattr(args,field,str(output_path))
    main(args.headless_smoke,args.screenshot,args.state_json,args.demo_checkpoint)
STEP 03 / 05

置いた次の刻から温め、先頭の注文へ

前からあったものだけを進める・境目で完成・順番どおりに使う。

温める台に生のお皿を置くと、Pan(dish,remain,placed) になります。スープ U は remain(残り時間)=3、パン B は2です。placed は置いた刻です。置く操作をしたその刻には、残り時間を減らしません。台の操作のあと、古い s.heat にあって、remain>0 で、今の heat にも同じ Pan が残っているものだけを、1つ減らします。新しく置いたお皿は古い s.heat にないので、対象になりません。

remain が0になる更新で dish.cooked=True にし、できあがりの出来事を1回だけ出します。できあがったあとは、何刻待ってもこげず、時間も0のままです。C が手を空にして E を押せば、取り出せます。まだ温めている途中のお皿は、取り出そうとしても取れません。ただし、その刻の最後にはふつうの温めが進むので、remain=1 で早すぎた操作なら、手は空のまま、その刻の終わりにできあがります。もう一度押し直せば、受け取れます。

共有の台にできあがったお皿を置き、S が M で受け取ります。S が注文口に向いて M を押したとき、cooked=True で、種類が先頭の注文と一致する場合だけ、order(提出した数)を1増やして、手を空にします。生のお皿、違う料理、C からの提出は受け付けません。失敗しても、お皿は手に残ります。状況 A の注文は UBUBUB の6皿です。6皿全部で complete=True になり、そのあとの step は同じ状態を返します。速さは成功の条件ではありません。

処理の順番は、移動 → 共有の台の取り合い → C/S の台の操作 → 古い Pan の温め → 全部のお皿の判定、です。1回の更新でできあがったお皿を、同じ入力ですぐ取り出したことにはしません。「作った数から、返した数と提出した数を引く」帳じりのルールは、温めても成り立ちます。ルールの中のお皿は、生からできあがりに変わりますが、お皿の数は変わりません。

まだ直接の手渡しと、台所 B/C はありません。共有の台だけで6皿を完成できます。画面は、raw(生)/cooked(できあがり)と残りの刻、操作の相手の枠を表示します。元のゲームの日本語のドット文字を使っていないので、手の文字と台の数字を、本文の言葉と対応させて追いましょう。

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

03_heat_orders.py / 注目する部分
    for i, old in enumerate(s.heat):
        if old is not None and old.remain > 0 and (heat[i] == old):
            remaining = old.remain - 1
            heat[i] = Pan(replace(old.dish, cooked=remaining == 0), remaining, old.placed)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_heat_orders.py
macOS / Linux
.venv/bin/python 03_heat_orders.py
画面で見ることW → A → E → D → E を、それぞれ1刻押します。t5 の上の温める台は U 3、手は空です。何もしない3刻で 2→1→0 と減り、U COOKED(できあがり)になります。次の E で受け取り、台は空になります。新しく置いた t5 に2に減ってしまってはいけません。
  • 前からあった Pan だけ温めを進める
  • できあがりの出来事は、境目で1回だけ
  • まちがって提出しても、お皿は消さない
つまずきやすいところ更新の途中の heat 全体を見て、全部を減らすと、置いたばかりのお皿もすぐ減ってしまいます。できあがりを確かめる前に hand を消すと、まちがった注文に出したお皿がなくなってしまいます。
少し変えてみよう

温め始めの 3 if hand.kind == 'U' else 2 の3だけを1に変えてみましょう。保存 → 終了 → python 03_heat_orders.py と実行します。W、A、E、D、E をそれぞれ1刻押すと t5 で remain=1、そのあと何も押さずに1刻で、t6 に0と cooked=True になります。python exercises/03_quick_soup.py は、同じ書き換えと操作を実行します。置いた刻は1のままであることを確かめ、3に戻します。

03_heat_orders.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(466行)
03_heat_orders.py / 全文
# -*- coding: utf-8 -*-
# MIT License
# 
# Copyright (c) 2026
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""Standalone tutorial: Japanese instructions in LESSON.md; assets not needed."""
STAGE = 3

"""Immutable, simultaneous, unhurried two-role kitchen. Runtime: stdlib only."""
from dataclasses import dataclass, replace, asdict
import hashlib, json
VERSION = '088-v1.0.1'
DIR = ((0, -1), (1, 0), (0, 1), (-1, 0))
SHELVES = ((0, 0), (0, 2))
HEAT = ((2, 0), (2, 2))

@dataclass(frozen=True)
class Kitchen:
    id: str
    title: str
    shared: tuple
    walls: tuple
    orders: tuple
    counter: tuple
KITCHENS = (Kitchen('A', 'まんなかで会う', ((3, 0), (3, 2)), (), tuple('UBUBUB'), (6, 1)),)

@dataclass(frozen=True)
class Input:
    dirs: tuple = (-1, -1)
    ops: tuple = (False, False)
    give: tuple = (False, False)

@dataclass(frozen=True)
class Dish:
    kind: str
    cooked: bool = False

@dataclass(frozen=True)
class Pan:
    dish: Dish
    remain: int
    placed: int

@dataclass(frozen=True)
class State:
    t: int = 0
    pos: tuple = ((1, 1), (5, 1))
    face: tuple = (0, 1)
    hands: tuple = (None, None)
    heat: tuple = (None, None)
    shared: tuple = (None, None)
    order: int = 0
    created: int = 0
    returned: int = 0
    passes: int = 0
    conflicts: int = 0
    events: tuple = ()
    complete: bool = False

def new(k):
    return State()

def walk(k, p, d, role):
    if d not in range(4):
        return p
    q = (p[0] + DIR[d][0], p[1] + DIR[d][1])
    lo, hi = (0, 3) if role == 0 else (3, 6)
    return q if lo <= q[0] <= hi and 0 <= q[1] < 3 and (q not in SHELVES + HEAT + (k.counter,) + k.walls) else p

def target(k, p, face):
    if p in k.shared:
        return ('shared', k.shared.index(p))
    q = (p[0] + DIR[face][0], p[1] + DIR[face][1])
    if q in k.shared:
        return ('shared', k.shared.index(q))
    if q in SHELVES:
        return ('shelf', SHELVES.index(q))
    if q in HEAT:
        return ('heat', HEAT.index(q))
    if q == k.counter:
        return ('counter', 0)
    return ('none', 0)

def step(k, s, a, handoff=True):
    if s.complete:
        return s
    pos = list(s.pos)
    face = list(s.face)
    hands = list(s.hands)
    heat = list(s.heat)
    shared = list(s.shared)
    created = s.created
    returned = s.returned
    order = s.order
    passes = s.passes
    conflicts = s.conflicts
    events = []
    tick = s.t + 1
    for r in range(2):
        if not a.ops[r] and (not a.give[r]) and (a.dirs[r] in range(4)):
            face[r] = a.dirs[r]
            pos[r] = walk(k, s.pos[r], a.dirs[r], r)
    participants = set()
    targets = [target(k, pos[r], face[r]) if a.ops[r] and (not a.give[r]) and (r not in participants) else None for r in range(2)]
    collision = targets[0] is not None and targets[0] == targets[1] and (targets[0][0] == 'shared')
    if collision:
        conflicts += 1
        events.append('同じ台、ひとりずつ')
        targets = [None, None]
    for r, tg in enumerate(targets):
        if tg is None:
            continue
        typ, i = tg
        hand = hands[r]
        if typ == 'shared':
            if hand is None and shared[i] is not None:
                hands[r] = shared[i]
                shared[i] = None
                events.append('共有台から受取り')
            elif hand is not None and shared[i] is None:
                shared[i] = hand
                hands[r] = None
                events.append('共有台へ置きました')
            else:
                events.append('空いた手と台で受け渡し')
        elif typ == 'shelf' and r == 0:
            kind = ('U', 'B')[i]
            if hand is None:
                hands[r] = Dish(kind)
                created += 1
                events.append('スープの材料' if kind == 'U' else 'パンの材料')
            elif not hand.cooked and hand.kind == kind:
                hands[r] = None
                returned += 1
                events.append('材料を棚へ戻しました')
            else:
                events.append('生の同じ材料だけ戻せます')
        elif typ == 'heat' and r == 0:
            if hand is not None and (not hand.cooked) and (heat[i] is None):
                heat[i] = Pan(hand, 3 if hand.kind == 'U' else 2, tick)
                hands[r] = None
                events.append('加熱開始。次の刻から進みます')
            elif hand is None and heat[i] is not None and (heat[i].remain == 0):
                hands[r] = heat[i].dish
                heat[i] = None
                events.append('出来上がりを受取り')
            else:
                events.append('空手で完成皿を。生皿は空いた台へ')
        elif typ == 'counter' and r == 1:
            if hand is not None and hand.cooked and (hand.kind == k.orders[order]):
                order += 1
                hands[r] = None
                events.append('いただきます! ' + str(order) + '皿目')
            else:
                events.append('先頭の完成皿をどうぞ。皿は手元に戻ります')
        else:
            events.append('向きの先に自分の作業台はありません')
    for i, old in enumerate(s.heat):
        if old is not None and old.remain > 0 and (heat[i] == old):
            remaining = old.remain - 1
            heat[i] = Pan(replace(old.dish, cooked=remaining == 0), remaining, old.placed)
            if remaining == 0:
                events.append('スープ完成!' if old.dish.kind == 'U' else 'パン完成!')
    complete = order == len(k.orders)
    if complete:
        events.append('ごちそうさま。二人も座ろう。')
    return State(tick, tuple(pos), tuple(face), tuple(hands), tuple(heat), tuple(shared), order, created, returned, passes, conflicts, tuple(events), complete)

def conservation(s):
    return s.created - s.returned - s.order == sum((x is not None for x in s.hands + s.heat + s.shared))

def digest(s):
    return hashlib.sha256(json.dumps(asdict(s), sort_keys=True, separators=(',', ':')).encode()).hexdigest()

KEYS = {'W', 'D', 'S', 'A', 'E', 'Q', 'UP', 'RIGHT', 'DOWN', 'LEFT', 'M', 'N', 'P', 'H', 'X', 'R', 'Z', 'RETURN', 'SPACE', '1', '2', '3', 'B', 'F12', 'ESCAPE'}

class Controller:

    def __init__(self):
        self.level = 0
        self.variant = False
        self.title = True
        self.paused = False
        self.restart = False
        self.ready = set()
        self.previous = set()
        self.blocked = set()
        self.release_gate = False
        self.pending = set()
        self.clock = 0
        self.history = []
        self.actions = []
        self.state = new(self.kitchen)

    @property
    def kitchen(self):
        return KITCHENS[self.level]

    def reset(self):
        self.state = new(self.kitchen)
        self.history = []
        self.actions = []
        self.clock = 0
        self.pending.clear()
        self.restart = False
        self.paused = False
        self.ready = set()

    def barrier(self, down):
        self.blocked |= down
        self.release_gate = True
        self.pending.clear()
        self.clock = 0

    def tick(self, a):
        old = self.state
        self.state = step(self.kitchen, old, a, not self.variant)
        if old != self.state:
            self.history.append(old)
            self.actions.append(asdict(a))
        return 'tick'

    def feed(self, down):
        down = set(down) & KEYS
        self.blocked &= down
        fresh = down - self.previous - self.blocked
        self.previous = down
        active = down - self.blocked
        if self.release_gate:
            if not down:
                self.release_gate = False
            return None
        if 'F12' in fresh:
            return 'screenshot'
        if 'ESCAPE' in fresh:
            self.title = True
            self.barrier(down)
            return 'menu'
        if self.title:
            if 'RETURN' in fresh:
                self.title = False
                self.reset()
                self.barrier(down)
                return 'start'
            return None
        if 'R' in fresh:
            self.restart = not self.restart
            self.ready = set()
            self.barrier(down)
            return 'restart_request'
        if self.restart:
            if 'Z' in fresh:
                self.ready.add('C')
            if 'RETURN' in fresh:
                self.ready.add('S')
            if len(self.ready) == 2:
                self.reset()
                self.barrier(down)
                return 'restart_confirmed'
            return None
        if 'P' in fresh or 'H' in fresh:
            self.paused = not self.paused
            self.barrier(down)
            return 'pause'
        if self.paused:
            return None
        if self.state.complete:
            return None
        self.pending |= fresh & {'E', 'Q', 'M', 'N'}
        self.clock += 1
        if self.clock < 15:
            return None
        self.clock -= 15

        def direction(keys):
            a = [i for i, k in enumerate(keys) if k in active]
            return a[0] if len(a) == 1 else -1
        a = Input((direction(('W', 'D', 'S', 'A')), direction(('UP', 'RIGHT', 'DOWN', 'LEFT'))), ('E' in self.pending, 'M' in self.pending), ('Q' in self.pending, 'N' in self.pending))
        self.pending.clear()
        return self.tick(a)

# Teaching ASCII renderer. Original input meanings are preserved.
EVENT_LABELS = {
    '同じ台、ひとりずつ': 'SAME SHARED TABLE: BOTH OPERATIONS REJECTED',
    '共有台から受取り': 'PICKED UP FROM SHARED TABLE',
    '共有台へ置きました': 'PLACED ON SHARED TABLE',
    '空いた手と台で受け渡し': 'NEED ONE EMPTY HAND OR EMPTY TABLE',
    'スープの材料': 'TOOK RAW SOUP U', 'パンの材料': 'TOOK RAW BREAD B',
    '材料を棚へ戻しました': 'RAW INGREDIENT RETURNED',
    '生の同じ材料だけ戻せます': 'RETURN ONLY MATCHING RAW INGREDIENT',
    '加熱開始。次の刻から進みます': 'HEATING STARTED; COUNTDOWN STARTS NEXT TICK',
    '出来上がりを受取り': 'PICKED UP COOKED DISH',
    '空手で完成皿を。生皿は空いた台へ': 'NEED EMPTY HAND + READY PAN, OR RAW DISH + EMPTY STOVE',
    '先頭の完成皿をどうぞ。皿は手元に戻ります': 'WRONG OR RAW ORDER: DISH RETAINED',
    '向きの先に自分の作業台はありません': 'NO USABLE STATION IN FRONT FOR THIS ROLE',
    'スープ完成!': 'SOUP U COOKED', 'パン完成!': 'BREAD B COOKED',
    'ごちそうさま。二人も座ろう。': 'ALL SIX ORDERS COMPLETE',
    '比較版: 共有台で受け渡します': 'DIRECT HANDOFF DISABLED; USE SHARED TABLE',
    '手渡しは隣のマスで': 'HANDOFF NEEDS ADJACENT CELLS',
    '片方が空手のときに手渡し': 'HANDOFF NEEDS EXACTLY ONE EMPTY HAND',
    'はい、どうぞ!': 'DIRECT HANDOFF COMPLETE',
}
def event_label(event):
    if event.startswith('いただきます!'):
        return 'CORRECT COOKED ORDER SERVED'
    return EVENT_LABELS.get(event, 'EVENT: SEE JAPANESE STATE JSON')

def dish_label(d):
    return '-' if d is None else d.kind + (' COOKED' if d.cooked else ' RAW')

class App:
    def __init__(self):
        self.ui = Controller()
        self.message = 'ENTER START / RELEASE ALL AFTER TRANSITIONS'

    def control(self, down):
        event = self.ui.feed(down)
        if event:
            self.message = event.upper().replace('_', ' ')
        return event

    def draw(self, p):
        u, s, k = self.ui, self.ui.state, self.ui.kitchen
        p.cls(1)
        p.text(14, 12, 'LINKED KITCHEN / LESSON ' + str(STAGE) + ' / TEACHING UI', 10)
        p.text(14, 28, 'KITCHEN ' + k.id + '  TICK ' + str(s.t) + '  CLOCK ' + str(u.clock) + '/15  DIRECT HANDOFF ' + ('OFF' if u.variant or STAGE < 4 else 'ON'), 7)
        p.text(14, 48, 'ORDERS ' + ''.join(k.orders) + '  SERVED ' + str(s.order) + '/6  CREATED ' + str(s.created) + ' RETURNED ' + str(s.returned), 10)
        p.text(14, 62, 'U = SOUP / B = BREAD / TIME LIMIT: NONE / NO BURNING', 6)
        for y in range(3):
            for x in range(7):
                pos = (x, y); xx, yy = 14 + x * 70, 86 + y * 78
                p.rect(xx, yy, 68, 76, 5 if x == 3 else 1 if x < 3 else 2)
                p.rectb(xx, yy, 68, 76, 0)
                if pos in k.walls:
                    p.rect(xx + 3, yy + 3, 62, 70, 5);p.text(xx + 24, yy + 31, 'WALL', 7)
                elif pos in SHELVES:
                    i = SHELVES.index(pos);p.text(xx + 5, yy + 8, 'SHELF ' + 'UB'[i], 10)
                    p.text(xx + 5, yy + 60, 'E: RAW', 6)
                elif pos in HEAT:
                    i=HEAT.index(pos);pan=s.heat[i];p.text(xx + 5, yy + 8, 'HEAT ' + str(i), 8)
                    p.circb(xx + 34, yy + 38, 16, 8 if pan and pan.remain else 10)
                    if pan:
                        p.text(xx + 15, yy + 35, pan.dish.kind + ' ' + str(pan.remain), 10)
                        p.text(xx + 5, yy + 60, 'READY' if pan.remain == 0 else 'HEATING', 7)
                elif pos in k.shared:
                    i=k.shared.index(pos);p.rectb(xx + 3, yy + 16, 61, 47, 15)
                    p.text(xx + 5, yy + 5, 'SHARED ' + str(i), 7)
                    p.text(xx + 6, yy + 27, dish_label(s.shared[i]), 10)
                    p.text(xx + 5, yy + 65, 'FOOT/FRONT', 6)
                elif pos == k.counter:
                    p.text(xx + 6, yy + 9, 'COUNTER', 10)
                    p.rect(xx + 6, yy + 29, 55, 19, 9)
                    p.text(xx + 6, yy + 60, 'NEXT ' + (k.orders[s.order] if not s.complete else '-'), 7)
        for r in range(2):
            typ,i=target(k,s.pos[r],s.face[r]);dest=k.shared[i] if typ=='shared' else SHELVES[i] if typ=='shelf' else HEAT[i] if typ=='heat' else k.counter if typ=='counter' else None
            if dest:
                xx,yy=14+dest[0]*70,86+dest[1]*78;p.rectb(xx+1+r*2,yy+1+r*2,66-r*4,74-r*4,(12,14)[r])
        for r in range(2):
            pos=s.pos[r];xx,yy=48+pos[0]*70,127+pos[1]*78
            if s.pos[0]==s.pos[1]:xx+=(-9,9)[r]
            p.circ(xx,yy,10,(12,14)[r]);p.text(xx-2,yy-2,'CS'[r],0)
            dx,dy=DIR[s.face[r]];p.line(xx,yy,xx+dx*19,yy+dy*19,(12,14)[r])
        p.text(521, 90, 'C COOK / BLUE', 12)
        p.text(521, 106, 'WASD MOVE', 7);p.text(521, 120, 'E OPERATE', 7);p.text(521, 134, 'Q GIVE', 7)
        p.text(521, 160, 'S SERVER / PINK', 14)
        p.text(521, 176, 'ARROWS MOVE', 7);p.text(521, 190, 'M OPERATE', 7);p.text(521, 204, 'N GIVE', 7)
        p.text(521, 229, 'PASSES ' + str(s.passes), 10);p.text(521, 245, 'CONFLICTS ' + str(s.conflicts), 10)
        p.text(521, 269, '2 TICKS / SECOND', 6)
        p.text(521, 287, 'C/S MAY OVERLAP', 6)
        p.text(14, 329, 'C HAND: ' + dish_label(s.hands[0]) + '   S HAND: ' + dish_label(s.hands[1]) + '   CONSERVED: ' + str(conservation(s)), 7)
        for i,event in enumerate(s.events[-2:]):p.text(14,347+i*15,event_label(event),10)
        p.text(14, 381, self.message, 11)
        p.text(14, 398, 'P/H PAUSE / R RESTART REQUEST: Z + ENTER AGREE, R CANCEL', 7)
        p.text(14, 413, 'ESC MENU / CLOSE WINDOW TO QUIT / F12 EVENT ONLY: SEE CLI', 6)
        p.text(14, 428, ('01: MOVEMENT ONLY' if STAGE == 1 else '02: RAW SHELVES + SHARED ONLY' if STAGE == 2 else '03: COOK + SERVE VIA TABLES' if STAGE == 3 else '1-3 KITCHEN + B COMPARE AT TITLE') + (' / PAUSED X UNDO 5' if STAGE == 5 else ''), 6)
        if s.complete:
            p.rect(122, 156, 428, 95, 1);p.rectb(122,156,428,95,11)
            p.text(144, 177, 'ALL SIX DISHES SERVED / BOTH ROLES COMPLETE',11)
            p.text(144, 200, 'SAME GOAL WITH SHARED TABLES',7)
            p.text(144, 223, 'NO TIME LIMIT / R REQUESTS A FRESH KITCHEN',6)
        if u.title or u.paused or u.restart:
            p.rect(91,102,490,208,1);p.rectb(91,102,490,208,10)
            if u.title:
                lines=['TWO ROLES, ONE KITCHEN', 'C: WASD / E OPERATE / Q GIVE', 'S: ARROWS / M OPERATE / N GIVE', 'OPERATE BEATS MOVE; GIVE BEATS OPERATE', 'ENTER START, THEN RELEASE ALL KEYS', '1-3 KITCHEN / B DIRECT HANDOFF COMPARISON' if STAGE>=4 else 'KITCHEN A ONLY / MORE RULES IN LATER STAGES', 'SYNTHETIC QA IS NOT A TWO-HUMAN PLAYTEST']
            elif u.restart:
                lines=['RESTART REQUEST / WORLD STOPPED', 'C: Z AGREES    S: ENTER AGREES', 'AGREED: ' + ','.join(sorted(u.ready)), 'R CANCELS / STATE KEPT UNTIL BOTH AGREE', 'R DOES NOT RELOAD PYTHON SOURCE']
            else:
                lines=['PAUSED / ALL DISHES AND BOTH ROLES STOP', 'P/H RESUME, THEN RELEASE ALL KEYS', 'X RESTORES THE WHOLE KITCHEN UP TO 5 TICKS' if STAGE==5 else 'UNDO IS ADDED IN STAGE 05', 'R REQUESTS RESTART / ESC MENU', 'CLOCK AND PENDING OPERATIONS CLEARED ON TRANSITION']
            for i,line in enumerate(lines):p.text(108,122+i*24,line,10 if i==0 else 7)

def tap(app, keys):
    app.control(set())
    return app.control(set(keys))

def start(app):
    tap(app, {'RETURN'})
    app.control(set())

def logical_tick(app, keys=()):
    # For deterministic exercises: one controller window of 15 frames.
    for _ in range(15):app.control(set(keys))

def checkpoint(app):
    start(app)
    logical_tick(app, {'W'})
    logical_tick(app, {'A'})
    logical_tick(app, {'E'})

def main(headless=False, screenshot=None, state_json=None, demo=False):
    import pyxel as p
    from pathlib import Path
    p.init(680,450,title='088 lesson '+str(STAGE),fps=30,display_scale=1,quit_key=p.KEY_NONE,headless=headless)
    app=App()
    if demo:checkpoint(app)
    keys={k:getattr(p,'KEY_'+k) for k in KEYS}
    def update():
        if headless:
            if p.frame_count>=3:
                app.draw(p)
                if screenshot:p.screen.save(screenshot,1)
                if state_json:Path(state_json).write_text(json.dumps({'stage':STAGE,'state':asdict(app.ui.state),'clock':app.ui.clock,'title':app.ui.title,'scope':'Actual headless Pyxel; synthetic controls; dummy audio; no physical keys or two-human play'},ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
                p.quit()
            return
        app.control({k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description='Standalone linked kitchen tutorial')
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--screenshot')
    parser.add_argument('--state-json')
    args=parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke:
        parser.error('demo and outputs require --headless-smoke')
    for field,suffix in (('screenshot','.png'),('state_json','.json')):
        value=getattr(args,field)
        if value:
            output_path=Path(value).resolve()
            if output_path.suffix.lower()!=suffix or not output_path.parent.is_dir() or output_path.exists():
                parser.error('output must be a new '+suffix+' in an existing directory')
            setattr(args,field,str(output_path))
    main(args.headless_smoke,args.screenshot,args.state_json,args.demo_checkpoint)
STEP 04 / 05

隣の空手へ、持つ側から渡す

移動したあとのとなり・古い手を見る・参加した人の操作を止める。

直接の手渡しを加えると、step と台所のデータすべてが、元のゲームと同じになります。C の Q、S の N は、「持っている自分から相手へ渡す」操作です。受け取る側だけが押して引き寄せる操作ではありません。縦横のマンハッタン距離(縦と横の歩数の合計)が1で、片方だけがお皿を持っていて、持っている側の give が True なら成功です。斜め、同じマス、2人とも手が空、2人とも1皿ずつ持っているときは、渡しません。生のお皿でも、できあがったお皿でも渡せます。

先に2人の移動を決め、その移動したあとの pos で、となりかどうかを判定します。一方、だれがお皿を持っているかは、更新前の s.hands から決めます。受け取る側が1歩近づいた刻に、待っていた渡す側から受け取れます。give を押した役自身は動かず、give は ops(台の操作)より優先されるので、その役の台の操作もしません。2人とも give を押しても、受け渡しは1回です。

手渡しが成功したら participants={0,1}(参加した2人)にして、その刻の2人の台の操作を止めます。受け取ったばかりのお皿を、同じ刻に共有の台に置く、といった2段階の受け渡しにはなりません。手渡しが失敗した場合は、give を押していない役のふつうの操作は残ることがあります。全部の操作を一律に止めるのではなく、targets の条件を読んで追いましょう。

表紙の 1/2/3 で台所を選びます。B は上側の壁と真ん中の台、C は右の真ん中の壁と下の注文口で、注文の順番も変わります。表紙の B キーは、直接の手渡しだけを使えなくする比較です。移動、共有の台、調理の時間、注文は同じです。Q/N を押したときの優先順位も同じで、比較版だからといって、移動に読みかえたりはしません。表紙で比較を切り替えたあと、Enter で新しい試合を始めます。

共有の台だけを使う同じ手順を、両方の版で実行すれば、状態全部が一致します。元のゲームの strategies.make_plan には、共有の台を通る手順と、直接手渡しを使う手順の、参考の手順があります。どちらも6皿を出せますが、一番短い手順や、おもしろさを証明するものではありません。実際のキーボードで、いくつまで同時に押せるかという限界も、この入力の意味の検査だけではわかりません。

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

04_direct_handoff.py / 注目する部分
            donor = 0 if s.hands[0] is not None else 1
            if a.give[donor]:
                hands[1 - donor] = s.hands[donor]
                hands[donor] = None
                participants = {0, 1}

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_direct_handoff.py
macOS / Linux
.venv/bin/python 04_direct_handoff.py
画面で見ること状況 A でスープを完成させて C が受け取ったあと、C を (2,1)、S を (3,1) に動かします。Q を1回押すと、C は空、S は U COOKED、PASSES=1 です。同じマスに重なった場合は、渡せません。
  • 新しい位置と、古い手を使い分ける
  • 持っている側の give が必要
  • 参加した2人の、追加の操作を止める
つまずきやすいところ受け取る側の N だけで、ほかの人のお皿を取らせないでください。手渡しのあとの hands で、渡す側をもう一度探すと、2人とも give のときに、すぐ戻ってくる二重の受け渡しになってしまいます。
少し変えてみよう

コードは変えずに、保存 → 終了 → python 04_direct_handoff.py と実行してみましょう。W、A、E、D、E をそれぞれ1刻、何も押さずに3刻、E、S、D、左矢印、左矢印、Q をそれぞれ1刻押します。t14 で配膳役ができあがったスープを持ちます。python exercises/04_handoff_compare.py は、同じ14刻を、元の版と表紙の B の比較版で実行し、元の版は passes=1、比較版は0になることと、お皿の帳じりを確かめます。

04_direct_handoff.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(491行)
04_direct_handoff.py / 全文
# -*- coding: utf-8 -*-
# MIT License
# 
# Copyright (c) 2026
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""Standalone tutorial: Japanese instructions in LESSON.md; assets not needed."""
STAGE = 4

"""Immutable, simultaneous, unhurried two-role kitchen. Runtime: stdlib only."""
from dataclasses import dataclass, replace, asdict
import hashlib, json
VERSION = '088-v1.0.1'
DIR = ((0, -1), (1, 0), (0, 1), (-1, 0))
SHELVES = ((0, 0), (0, 2))
HEAT = ((2, 0), (2, 2))

@dataclass(frozen=True)
class Kitchen:
    id: str
    title: str
    shared: tuple
    walls: tuple
    orders: tuple
    counter: tuple
KITCHENS = (Kitchen('A', 'まんなかで会う', ((3, 0), (3, 2)), (), tuple('UBUBUB'), (6, 1)), Kitchen('B', '上が混む日', ((3, 0), (3, 1)), ((4, 0),), tuple('UUBUBB'), (6, 1)), Kitchen('C', '長いテーブル', ((3, 0), (3, 2)), ((4, 1),), tuple('BUBBUU'), (6, 2)))

@dataclass(frozen=True)
class Input:
    dirs: tuple = (-1, -1)
    ops: tuple = (False, False)
    give: tuple = (False, False)

@dataclass(frozen=True)
class Dish:
    kind: str
    cooked: bool = False

@dataclass(frozen=True)
class Pan:
    dish: Dish
    remain: int
    placed: int

@dataclass(frozen=True)
class State:
    t: int = 0
    pos: tuple = ((1, 1), (5, 1))
    face: tuple = (0, 1)
    hands: tuple = (None, None)
    heat: tuple = (None, None)
    shared: tuple = (None, None)
    order: int = 0
    created: int = 0
    returned: int = 0
    passes: int = 0
    conflicts: int = 0
    events: tuple = ()
    complete: bool = False

def new(k):
    return State()

def walk(k, p, d, role):
    if d not in range(4):
        return p
    q = (p[0] + DIR[d][0], p[1] + DIR[d][1])
    lo, hi = (0, 3) if role == 0 else (3, 6)
    return q if lo <= q[0] <= hi and 0 <= q[1] < 3 and (q not in SHELVES + HEAT + (k.counter,) + k.walls) else p

def target(k, p, face):
    if p in k.shared:
        return ('shared', k.shared.index(p))
    q = (p[0] + DIR[face][0], p[1] + DIR[face][1])
    if q in k.shared:
        return ('shared', k.shared.index(q))
    if q in SHELVES:
        return ('shelf', SHELVES.index(q))
    if q in HEAT:
        return ('heat', HEAT.index(q))
    if q == k.counter:
        return ('counter', 0)
    return ('none', 0)

def step(k, s, a, handoff=True):
    if s.complete:
        return s
    pos = list(s.pos)
    face = list(s.face)
    hands = list(s.hands)
    heat = list(s.heat)
    shared = list(s.shared)
    created = s.created
    returned = s.returned
    order = s.order
    passes = s.passes
    conflicts = s.conflicts
    events = []
    tick = s.t + 1
    for r in range(2):
        if not a.ops[r] and (not a.give[r]) and (a.dirs[r] in range(4)):
            face[r] = a.dirs[r]
            pos[r] = walk(k, s.pos[r], a.dirs[r], r)
    participants = set()
    if any(a.give):
        if not handoff:
            events.append('比較版: 共有台で受け渡します')
        elif abs(pos[0][0] - pos[1][0]) + abs(pos[0][1] - pos[1][1]) != 1:
            events.append('手渡しは隣のマスで')
        elif (s.hands[0] is None) == (s.hands[1] is None):
            events.append('片方が空手のときに手渡し')
        else:
            donor = 0 if s.hands[0] is not None else 1
            if a.give[donor]:
                hands[1 - donor] = s.hands[donor]
                hands[donor] = None
                participants = {0, 1}
                passes += 1
                events.append('はい、どうぞ!')
    targets = [target(k, pos[r], face[r]) if a.ops[r] and (not a.give[r]) and (r not in participants) else None for r in range(2)]
    collision = targets[0] is not None and targets[0] == targets[1] and (targets[0][0] == 'shared')
    if collision:
        conflicts += 1
        events.append('同じ台、ひとりずつ')
        targets = [None, None]
    for r, tg in enumerate(targets):
        if tg is None:
            continue
        typ, i = tg
        hand = hands[r]
        if typ == 'shared':
            if hand is None and shared[i] is not None:
                hands[r] = shared[i]
                shared[i] = None
                events.append('共有台から受取り')
            elif hand is not None and shared[i] is None:
                shared[i] = hand
                hands[r] = None
                events.append('共有台へ置きました')
            else:
                events.append('空いた手と台で受け渡し')
        elif typ == 'shelf' and r == 0:
            kind = ('U', 'B')[i]
            if hand is None:
                hands[r] = Dish(kind)
                created += 1
                events.append('スープの材料' if kind == 'U' else 'パンの材料')
            elif not hand.cooked and hand.kind == kind:
                hands[r] = None
                returned += 1
                events.append('材料を棚へ戻しました')
            else:
                events.append('生の同じ材料だけ戻せます')
        elif typ == 'heat' and r == 0:
            if hand is not None and (not hand.cooked) and (heat[i] is None):
                heat[i] = Pan(hand, 3 if hand.kind == 'U' else 2, tick)
                hands[r] = None
                events.append('加熱開始。次の刻から進みます')
            elif hand is None and heat[i] is not None and (heat[i].remain == 0):
                hands[r] = heat[i].dish
                heat[i] = None
                events.append('出来上がりを受取り')
            else:
                events.append('空手で完成皿を。生皿は空いた台へ')
        elif typ == 'counter' and r == 1:
            if hand is not None and hand.cooked and (hand.kind == k.orders[order]):
                order += 1
                hands[r] = None
                events.append('いただきます! ' + str(order) + '皿目')
            else:
                events.append('先頭の完成皿をどうぞ。皿は手元に戻ります')
        else:
            events.append('向きの先に自分の作業台はありません')
    for i, old in enumerate(s.heat):
        if old is not None and old.remain > 0 and (heat[i] == old):
            remaining = old.remain - 1
            heat[i] = Pan(replace(old.dish, cooked=remaining == 0), remaining, old.placed)
            if remaining == 0:
                events.append('スープ完成!' if old.dish.kind == 'U' else 'パン完成!')
    complete = order == len(k.orders)
    if complete:
        events.append('ごちそうさま。二人も座ろう。')
    return State(tick, tuple(pos), tuple(face), tuple(hands), tuple(heat), tuple(shared), order, created, returned, passes, conflicts, tuple(events), complete)

def conservation(s):
    return s.created - s.returned - s.order == sum((x is not None for x in s.hands + s.heat + s.shared))

def digest(s):
    return hashlib.sha256(json.dumps(asdict(s), sort_keys=True, separators=(',', ':')).encode()).hexdigest()

KEYS = {'W', 'D', 'S', 'A', 'E', 'Q', 'UP', 'RIGHT', 'DOWN', 'LEFT', 'M', 'N', 'P', 'H', 'X', 'R', 'Z', 'RETURN', 'SPACE', '1', '2', '3', 'B', 'F12', 'ESCAPE'}

class Controller:

    def __init__(self):
        self.level = 0
        self.variant = False
        self.title = True
        self.paused = False
        self.restart = False
        self.ready = set()
        self.previous = set()
        self.blocked = set()
        self.release_gate = False
        self.pending = set()
        self.clock = 0
        self.history = []
        self.actions = []
        self.state = new(self.kitchen)

    @property
    def kitchen(self):
        return KITCHENS[self.level]

    def reset(self):
        self.state = new(self.kitchen)
        self.history = []
        self.actions = []
        self.clock = 0
        self.pending.clear()
        self.restart = False
        self.paused = False
        self.ready = set()

    def barrier(self, down):
        self.blocked |= down
        self.release_gate = True
        self.pending.clear()
        self.clock = 0

    def tick(self, a):
        old = self.state
        self.state = step(self.kitchen, old, a, not self.variant)
        if old != self.state:
            self.history.append(old)
            self.actions.append(asdict(a))
        return 'tick'

    def feed(self, down):
        down = set(down) & KEYS
        self.blocked &= down
        fresh = down - self.previous - self.blocked
        self.previous = down
        active = down - self.blocked
        if self.release_gate:
            if not down:
                self.release_gate = False
            return None
        if 'F12' in fresh:
            return 'screenshot'
        if 'ESCAPE' in fresh:
            self.title = True
            self.barrier(down)
            return 'menu'
        if self.title:
            for k in ('1', '2', '3'):
                if k in fresh:
                    self.level = int(k) - 1
                    self.reset()
                    self.barrier(down)
                    return 'kitchen'
            if 'B' in fresh:
                self.variant = not self.variant
                self.barrier(down)
                return 'compare'
            if 'RETURN' in fresh:
                self.title = False
                self.reset()
                self.barrier(down)
                return 'start'
            return None
        if 'R' in fresh:
            self.restart = not self.restart
            self.ready = set()
            self.barrier(down)
            return 'restart_request'
        if self.restart:
            if 'Z' in fresh:
                self.ready.add('C')
            if 'RETURN' in fresh:
                self.ready.add('S')
            if len(self.ready) == 2:
                self.reset()
                self.barrier(down)
                return 'restart_confirmed'
            return None
        if 'P' in fresh or 'H' in fresh:
            self.paused = not self.paused
            self.barrier(down)
            return 'pause'
        if self.paused:
            return None
        if self.state.complete:
            return None
        self.pending |= fresh & {'E', 'Q', 'M', 'N'}
        self.clock += 1
        if self.clock < 15:
            return None
        self.clock -= 15

        def direction(keys):
            a = [i for i, k in enumerate(keys) if k in active]
            return a[0] if len(a) == 1 else -1
        a = Input((direction(('W', 'D', 'S', 'A')), direction(('UP', 'RIGHT', 'DOWN', 'LEFT'))), ('E' in self.pending, 'M' in self.pending), ('Q' in self.pending, 'N' in self.pending))
        self.pending.clear()
        return self.tick(a)

# Teaching ASCII renderer. Original input meanings are preserved.
EVENT_LABELS = {
    '同じ台、ひとりずつ': 'SAME SHARED TABLE: BOTH OPERATIONS REJECTED',
    '共有台から受取り': 'PICKED UP FROM SHARED TABLE',
    '共有台へ置きました': 'PLACED ON SHARED TABLE',
    '空いた手と台で受け渡し': 'NEED ONE EMPTY HAND OR EMPTY TABLE',
    'スープの材料': 'TOOK RAW SOUP U', 'パンの材料': 'TOOK RAW BREAD B',
    '材料を棚へ戻しました': 'RAW INGREDIENT RETURNED',
    '生の同じ材料だけ戻せます': 'RETURN ONLY MATCHING RAW INGREDIENT',
    '加熱開始。次の刻から進みます': 'HEATING STARTED; COUNTDOWN STARTS NEXT TICK',
    '出来上がりを受取り': 'PICKED UP COOKED DISH',
    '空手で完成皿を。生皿は空いた台へ': 'NEED EMPTY HAND + READY PAN, OR RAW DISH + EMPTY STOVE',
    '先頭の完成皿をどうぞ。皿は手元に戻ります': 'WRONG OR RAW ORDER: DISH RETAINED',
    '向きの先に自分の作業台はありません': 'NO USABLE STATION IN FRONT FOR THIS ROLE',
    'スープ完成!': 'SOUP U COOKED', 'パン完成!': 'BREAD B COOKED',
    'ごちそうさま。二人も座ろう。': 'ALL SIX ORDERS COMPLETE',
    '比較版: 共有台で受け渡します': 'DIRECT HANDOFF DISABLED; USE SHARED TABLE',
    '手渡しは隣のマスで': 'HANDOFF NEEDS ADJACENT CELLS',
    '片方が空手のときに手渡し': 'HANDOFF NEEDS EXACTLY ONE EMPTY HAND',
    'はい、どうぞ!': 'DIRECT HANDOFF COMPLETE',
}
def event_label(event):
    if event.startswith('いただきます!'):
        return 'CORRECT COOKED ORDER SERVED'
    return EVENT_LABELS.get(event, 'EVENT: SEE JAPANESE STATE JSON')

def dish_label(d):
    return '-' if d is None else d.kind + (' COOKED' if d.cooked else ' RAW')

class App:
    def __init__(self):
        self.ui = Controller()
        self.message = 'ENTER START / RELEASE ALL AFTER TRANSITIONS'

    def control(self, down):
        event = self.ui.feed(down)
        if event:
            self.message = event.upper().replace('_', ' ')
        return event

    def draw(self, p):
        u, s, k = self.ui, self.ui.state, self.ui.kitchen
        p.cls(1)
        p.text(14, 12, 'LINKED KITCHEN / LESSON ' + str(STAGE) + ' / TEACHING UI', 10)
        p.text(14, 28, 'KITCHEN ' + k.id + '  TICK ' + str(s.t) + '  CLOCK ' + str(u.clock) + '/15  DIRECT HANDOFF ' + ('OFF' if u.variant or STAGE < 4 else 'ON'), 7)
        p.text(14, 48, 'ORDERS ' + ''.join(k.orders) + '  SERVED ' + str(s.order) + '/6  CREATED ' + str(s.created) + ' RETURNED ' + str(s.returned), 10)
        p.text(14, 62, 'U = SOUP / B = BREAD / TIME LIMIT: NONE / NO BURNING', 6)
        for y in range(3):
            for x in range(7):
                pos = (x, y); xx, yy = 14 + x * 70, 86 + y * 78
                p.rect(xx, yy, 68, 76, 5 if x == 3 else 1 if x < 3 else 2)
                p.rectb(xx, yy, 68, 76, 0)
                if pos in k.walls:
                    p.rect(xx + 3, yy + 3, 62, 70, 5);p.text(xx + 24, yy + 31, 'WALL', 7)
                elif pos in SHELVES:
                    i = SHELVES.index(pos);p.text(xx + 5, yy + 8, 'SHELF ' + 'UB'[i], 10)
                    p.text(xx + 5, yy + 60, 'E: RAW', 6)
                elif pos in HEAT:
                    i=HEAT.index(pos);pan=s.heat[i];p.text(xx + 5, yy + 8, 'HEAT ' + str(i), 8)
                    p.circb(xx + 34, yy + 38, 16, 8 if pan and pan.remain else 10)
                    if pan:
                        p.text(xx + 15, yy + 35, pan.dish.kind + ' ' + str(pan.remain), 10)
                        p.text(xx + 5, yy + 60, 'READY' if pan.remain == 0 else 'HEATING', 7)
                elif pos in k.shared:
                    i=k.shared.index(pos);p.rectb(xx + 3, yy + 16, 61, 47, 15)
                    p.text(xx + 5, yy + 5, 'SHARED ' + str(i), 7)
                    p.text(xx + 6, yy + 27, dish_label(s.shared[i]), 10)
                    p.text(xx + 5, yy + 65, 'FOOT/FRONT', 6)
                elif pos == k.counter:
                    p.text(xx + 6, yy + 9, 'COUNTER', 10)
                    p.rect(xx + 6, yy + 29, 55, 19, 9)
                    p.text(xx + 6, yy + 60, 'NEXT ' + (k.orders[s.order] if not s.complete else '-'), 7)
        for r in range(2):
            typ,i=target(k,s.pos[r],s.face[r]);dest=k.shared[i] if typ=='shared' else SHELVES[i] if typ=='shelf' else HEAT[i] if typ=='heat' else k.counter if typ=='counter' else None
            if dest:
                xx,yy=14+dest[0]*70,86+dest[1]*78;p.rectb(xx+1+r*2,yy+1+r*2,66-r*4,74-r*4,(12,14)[r])
        for r in range(2):
            pos=s.pos[r];xx,yy=48+pos[0]*70,127+pos[1]*78
            if s.pos[0]==s.pos[1]:xx+=(-9,9)[r]
            p.circ(xx,yy,10,(12,14)[r]);p.text(xx-2,yy-2,'CS'[r],0)
            dx,dy=DIR[s.face[r]];p.line(xx,yy,xx+dx*19,yy+dy*19,(12,14)[r])
        p.text(521, 90, 'C COOK / BLUE', 12)
        p.text(521, 106, 'WASD MOVE', 7);p.text(521, 120, 'E OPERATE', 7);p.text(521, 134, 'Q GIVE', 7)
        p.text(521, 160, 'S SERVER / PINK', 14)
        p.text(521, 176, 'ARROWS MOVE', 7);p.text(521, 190, 'M OPERATE', 7);p.text(521, 204, 'N GIVE', 7)
        p.text(521, 229, 'PASSES ' + str(s.passes), 10);p.text(521, 245, 'CONFLICTS ' + str(s.conflicts), 10)
        p.text(521, 269, '2 TICKS / SECOND', 6)
        p.text(521, 287, 'C/S MAY OVERLAP', 6)
        p.text(14, 329, 'C HAND: ' + dish_label(s.hands[0]) + '   S HAND: ' + dish_label(s.hands[1]) + '   CONSERVED: ' + str(conservation(s)), 7)
        for i,event in enumerate(s.events[-2:]):p.text(14,347+i*15,event_label(event),10)
        p.text(14, 381, self.message, 11)
        p.text(14, 398, 'P/H PAUSE / R RESTART REQUEST: Z + ENTER AGREE, R CANCEL', 7)
        p.text(14, 413, 'ESC MENU / CLOSE WINDOW TO QUIT / F12 EVENT ONLY: SEE CLI', 6)
        p.text(14, 428, ('01: MOVEMENT ONLY' if STAGE == 1 else '02: RAW SHELVES + SHARED ONLY' if STAGE == 2 else '03: COOK + SERVE VIA TABLES' if STAGE == 3 else '1-3 KITCHEN + B COMPARE AT TITLE') + (' / PAUSED X UNDO 5' if STAGE == 5 else ''), 6)
        if s.complete:
            p.rect(122, 156, 428, 95, 1);p.rectb(122,156,428,95,11)
            p.text(144, 177, 'ALL SIX DISHES SERVED / BOTH ROLES COMPLETE',11)
            p.text(144, 200, 'SAME GOAL WITH SHARED TABLES OR DIRECT HANDOFF',7)
            p.text(144, 223, 'NO TIME LIMIT / R REQUESTS A FRESH KITCHEN',6)
        if u.title or u.paused or u.restart:
            p.rect(91,102,490,208,1);p.rectb(91,102,490,208,10)
            if u.title:
                lines=['TWO ROLES, ONE KITCHEN', 'C: WASD / E OPERATE / Q GIVE', 'S: ARROWS / M OPERATE / N GIVE', 'OPERATE BEATS MOVE; GIVE BEATS OPERATE', 'ENTER START, THEN RELEASE ALL KEYS', '1-3 KITCHEN / B DIRECT HANDOFF COMPARISON' if STAGE>=4 else 'KITCHEN A ONLY / MORE RULES IN LATER STAGES', 'SYNTHETIC QA IS NOT A TWO-HUMAN PLAYTEST']
            elif u.restart:
                lines=['RESTART REQUEST / WORLD STOPPED', 'C: Z AGREES    S: ENTER AGREES', 'AGREED: ' + ','.join(sorted(u.ready)), 'R CANCELS / STATE KEPT UNTIL BOTH AGREE', 'R DOES NOT RELOAD PYTHON SOURCE']
            else:
                lines=['PAUSED / ALL DISHES AND BOTH ROLES STOP', 'P/H RESUME, THEN RELEASE ALL KEYS', 'X RESTORES THE WHOLE KITCHEN UP TO 5 TICKS' if STAGE==5 else 'UNDO IS ADDED IN STAGE 05', 'R REQUESTS RESTART / ESC MENU', 'CLOCK AND PENDING OPERATIONS CLEARED ON TRANSITION']
            for i,line in enumerate(lines):p.text(108,122+i*24,line,10 if i==0 else 7)

def tap(app, keys):
    app.control(set())
    return app.control(set(keys))

def start(app):
    tap(app, {'RETURN'})
    app.control(set())

def logical_tick(app, keys=()):
    # For deterministic exercises: one controller window of 15 frames.
    for _ in range(15):app.control(set(keys))

def checkpoint(app):
    start(app)
    logical_tick(app, {'W'})
    logical_tick(app, {'A'})
    logical_tick(app, {'E'})

def main(headless=False, screenshot=None, state_json=None, demo=False):
    import pyxel as p
    from pathlib import Path
    p.init(680,450,title='088 lesson '+str(STAGE),fps=30,display_scale=1,quit_key=p.KEY_NONE,headless=headless)
    app=App()
    if demo:checkpoint(app)
    keys={k:getattr(p,'KEY_'+k) for k in KEYS}
    def update():
        if headless:
            if p.frame_count>=3:
                app.draw(p)
                if screenshot:p.screen.save(screenshot,1)
                if state_json:Path(state_json).write_text(json.dumps({'stage':STAGE,'state':asdict(app.ui.state),'clock':app.ui.clock,'title':app.ui.title,'scope':'Actual headless Pyxel; synthetic controls; dummy audio; no physical keys or two-human play'},ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
                p.quit()
            return
        app.control({k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description='Standalone linked kitchen tutorial')
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--screenshot')
    parser.add_argument('--state-json')
    args=parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke:
        parser.error('demo and outputs require --headless-smoke')
    for field,suffix in (('screenshot','.png'),('state_json','.json')):
        value=getattr(args,field)
        if value:
            output_path=Path(value).resolve()
            if output_path.suffix.lower()!=suffix or not output_path.parent.is_dir() or output_path.exists():
                parser.error('output must be a new '+suffix+' in an existing directory')
            setattr(args,field,str(output_path))
    main(args.headless_smoke,args.screenshot,args.state_json,args.demo_checkpoint)
STEP 05 / 05

料理も注文も、厨房ごと五刻戻す

状態全部の履歴・予約した入力を捨てる境目・2人の役の再開始の同意。

Controller.undo を加えると、最後の model と Controller は、元のゲームの定義・定数すべてと同じになります。tick は、更新する前の State を history に、意味のある入力 Input を辞書にして actions に保存します。一時停止中の X は、最後から最大5件を取り除き、その中で一番古い1件の状態に戻ります。履歴が3件なら3刻だけ、空なら undo_empty です。位置だけでなく、手のお皿、温めている途中の remain、共有の台、注文の数、created/returned、手渡しや取り合いの回数も戻ります。

P か H で、2人とすべての台を止めます。X は止めている間だけ働き、走っている間に押しても巻き戻しません。画面が切り替わるときの barrier は、pending を捨て、clock を0に戻して、キーが全部離されるのを待ちます。止める前に「まだ刻の区切りに届いていなかった E」を、再開したあとに勝手に使わないためです。blocked は押されたままのキーを fresh から外し、release_gate は、途中で新しいキーが増えても、キーが全部離されるまで時刻を止めます。

R は、最初からやり直すかの相談です。R を押しただけでは、料理は消えません。キーを離して、C が Z、S が Enter で同意し、両方そろったときに最初に戻します。片方だけなら、状態はそのままで、時間を止めます。もう一度 R を押すと、相談を取り消します。2人で同じ時点に戻ることと、最初からやり直すことは別です。Esc は表紙に戻るキーで、終わるためのキーではありません。Q は C の手渡しなので、終わるのに使わないでください。終わるときは、ウィンドウを閉じます。

保存した Python を書き換えたら、UTF-8 で保存 → ウィンドウを閉じる → 同じファイルを起動し直します。R で2人が同意しても、読みこみ済みの State に戻すだけで、ファイルは読み直しません。それぞれの段階は1つの Python ファイルなので、名前を変えても、日本語や空白を含むフォルダーに置いても実行できます。

教材の画面は英数字で、元のゲームの日本語の文字、料理のイラスト、自動での記録、F12 での画像の保存は省いています。F12 は Controller の出来事として残すだけで、画像が必要なら、README にはっきり書いた、画面を出さない(headless)出力を使います。元のゲームの app.py の dish/draw、record/save、strategies.Plan に進むと、同じルールから、見せ方、記録、参考の手順を作る方法を読めます。

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

05_pause_undo.py / 注目する部分
        n = min(5, len(self.history))
        self.state = self.history[-n]
        self.history = self.history[:-n]
        self.actions = self.actions[:-n]
        return 'undo_5'

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_pause_undo.py
macOS / Linux
.venv/bin/python 05_pause_undo.py
画面で見ることW、A、E、D、E、何もしない3刻、E まで進めると、t9・できあがったスープを手に持っています。P → 離す → X で、t4・生のスープを手に持ち、温める台は空に戻ります。一時停止は続いています。P → 全部離したあと、E、何もしない3刻、E で、同じ t9 の状態に戻れます。
  • 最大5刻ぶん、状態全部を元に戻す
  • 画面の切り替わりで、pending/clock を片づける
  • 最初からやり直すには、Z と Enter の2人の同意が必要
つまずきやすいところ時間だけ t-=5 としても、お皿は未来のまま残ってしまいます。state だけを戻して actions/history を残すと、次の巻き戻しや記録が食い違います。再開に持ち越す pending も、別に消す必要があります。
少し変えてみよう

コードは変えずに、保存 → 終了 → python 05_pause_undo.py と実行してみましょう。前に書いた t9 まで進め、P、X、P、キーを全部離す、E、何もしない3刻、E と押します。python exercises/05_undo_replay.py は、元の t9 と、やり直したあとの状態全部の辞書を比べます。続けて、R → 離す → Z だけでは t9 のまま、離す → Enter で t0 になる、2人の確認も実行します。

05_pause_undo.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(503行)
05_pause_undo.py / 全文
# -*- coding: utf-8 -*-
# MIT License
# 
# Copyright (c) 2026
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""Standalone tutorial: Japanese instructions in LESSON.md; assets not needed."""
STAGE = 5

"""Immutable, simultaneous, unhurried two-role kitchen. Runtime: stdlib only."""
from dataclasses import dataclass, replace, asdict
import hashlib, json
VERSION = '088-v1.0.1'
DIR = ((0, -1), (1, 0), (0, 1), (-1, 0))
SHELVES = ((0, 0), (0, 2))
HEAT = ((2, 0), (2, 2))

@dataclass(frozen=True)
class Kitchen:
    id: str
    title: str
    shared: tuple
    walls: tuple
    orders: tuple
    counter: tuple
KITCHENS = (Kitchen('A', 'まんなかで会う', ((3, 0), (3, 2)), (), tuple('UBUBUB'), (6, 1)), Kitchen('B', '上が混む日', ((3, 0), (3, 1)), ((4, 0),), tuple('UUBUBB'), (6, 1)), Kitchen('C', '長いテーブル', ((3, 0), (3, 2)), ((4, 1),), tuple('BUBBUU'), (6, 2)))

@dataclass(frozen=True)
class Input:
    dirs: tuple = (-1, -1)
    ops: tuple = (False, False)
    give: tuple = (False, False)

@dataclass(frozen=True)
class Dish:
    kind: str
    cooked: bool = False

@dataclass(frozen=True)
class Pan:
    dish: Dish
    remain: int
    placed: int

@dataclass(frozen=True)
class State:
    t: int = 0
    pos: tuple = ((1, 1), (5, 1))
    face: tuple = (0, 1)
    hands: tuple = (None, None)
    heat: tuple = (None, None)
    shared: tuple = (None, None)
    order: int = 0
    created: int = 0
    returned: int = 0
    passes: int = 0
    conflicts: int = 0
    events: tuple = ()
    complete: bool = False

def new(k):
    return State()

def walk(k, p, d, role):
    if d not in range(4):
        return p
    q = (p[0] + DIR[d][0], p[1] + DIR[d][1])
    lo, hi = (0, 3) if role == 0 else (3, 6)
    return q if lo <= q[0] <= hi and 0 <= q[1] < 3 and (q not in SHELVES + HEAT + (k.counter,) + k.walls) else p

def target(k, p, face):
    if p in k.shared:
        return ('shared', k.shared.index(p))
    q = (p[0] + DIR[face][0], p[1] + DIR[face][1])
    if q in k.shared:
        return ('shared', k.shared.index(q))
    if q in SHELVES:
        return ('shelf', SHELVES.index(q))
    if q in HEAT:
        return ('heat', HEAT.index(q))
    if q == k.counter:
        return ('counter', 0)
    return ('none', 0)

def step(k, s, a, handoff=True):
    if s.complete:
        return s
    pos = list(s.pos)
    face = list(s.face)
    hands = list(s.hands)
    heat = list(s.heat)
    shared = list(s.shared)
    created = s.created
    returned = s.returned
    order = s.order
    passes = s.passes
    conflicts = s.conflicts
    events = []
    tick = s.t + 1
    for r in range(2):
        if not a.ops[r] and (not a.give[r]) and (a.dirs[r] in range(4)):
            face[r] = a.dirs[r]
            pos[r] = walk(k, s.pos[r], a.dirs[r], r)
    participants = set()
    if any(a.give):
        if not handoff:
            events.append('比較版: 共有台で受け渡します')
        elif abs(pos[0][0] - pos[1][0]) + abs(pos[0][1] - pos[1][1]) != 1:
            events.append('手渡しは隣のマスで')
        elif (s.hands[0] is None) == (s.hands[1] is None):
            events.append('片方が空手のときに手渡し')
        else:
            donor = 0 if s.hands[0] is not None else 1
            if a.give[donor]:
                hands[1 - donor] = s.hands[donor]
                hands[donor] = None
                participants = {0, 1}
                passes += 1
                events.append('はい、どうぞ!')
    targets = [target(k, pos[r], face[r]) if a.ops[r] and (not a.give[r]) and (r not in participants) else None for r in range(2)]
    collision = targets[0] is not None and targets[0] == targets[1] and (targets[0][0] == 'shared')
    if collision:
        conflicts += 1
        events.append('同じ台、ひとりずつ')
        targets = [None, None]
    for r, tg in enumerate(targets):
        if tg is None:
            continue
        typ, i = tg
        hand = hands[r]
        if typ == 'shared':
            if hand is None and shared[i] is not None:
                hands[r] = shared[i]
                shared[i] = None
                events.append('共有台から受取り')
            elif hand is not None and shared[i] is None:
                shared[i] = hand
                hands[r] = None
                events.append('共有台へ置きました')
            else:
                events.append('空いた手と台で受け渡し')
        elif typ == 'shelf' and r == 0:
            kind = ('U', 'B')[i]
            if hand is None:
                hands[r] = Dish(kind)
                created += 1
                events.append('スープの材料' if kind == 'U' else 'パンの材料')
            elif not hand.cooked and hand.kind == kind:
                hands[r] = None
                returned += 1
                events.append('材料を棚へ戻しました')
            else:
                events.append('生の同じ材料だけ戻せます')
        elif typ == 'heat' and r == 0:
            if hand is not None and (not hand.cooked) and (heat[i] is None):
                heat[i] = Pan(hand, 3 if hand.kind == 'U' else 2, tick)
                hands[r] = None
                events.append('加熱開始。次の刻から進みます')
            elif hand is None and heat[i] is not None and (heat[i].remain == 0):
                hands[r] = heat[i].dish
                heat[i] = None
                events.append('出来上がりを受取り')
            else:
                events.append('空手で完成皿を。生皿は空いた台へ')
        elif typ == 'counter' and r == 1:
            if hand is not None and hand.cooked and (hand.kind == k.orders[order]):
                order += 1
                hands[r] = None
                events.append('いただきます! ' + str(order) + '皿目')
            else:
                events.append('先頭の完成皿をどうぞ。皿は手元に戻ります')
        else:
            events.append('向きの先に自分の作業台はありません')
    for i, old in enumerate(s.heat):
        if old is not None and old.remain > 0 and (heat[i] == old):
            remaining = old.remain - 1
            heat[i] = Pan(replace(old.dish, cooked=remaining == 0), remaining, old.placed)
            if remaining == 0:
                events.append('スープ完成!' if old.dish.kind == 'U' else 'パン完成!')
    complete = order == len(k.orders)
    if complete:
        events.append('ごちそうさま。二人も座ろう。')
    return State(tick, tuple(pos), tuple(face), tuple(hands), tuple(heat), tuple(shared), order, created, returned, passes, conflicts, tuple(events), complete)

def conservation(s):
    return s.created - s.returned - s.order == sum((x is not None for x in s.hands + s.heat + s.shared))

def digest(s):
    return hashlib.sha256(json.dumps(asdict(s), sort_keys=True, separators=(',', ':')).encode()).hexdigest()

KEYS = {'W', 'D', 'S', 'A', 'E', 'Q', 'UP', 'RIGHT', 'DOWN', 'LEFT', 'M', 'N', 'P', 'H', 'X', 'R', 'Z', 'RETURN', 'SPACE', '1', '2', '3', 'B', 'F12', 'ESCAPE'}

class Controller:

    def __init__(self):
        self.level = 0
        self.variant = False
        self.title = True
        self.paused = False
        self.restart = False
        self.ready = set()
        self.previous = set()
        self.blocked = set()
        self.release_gate = False
        self.pending = set()
        self.clock = 0
        self.history = []
        self.actions = []
        self.state = new(self.kitchen)

    @property
    def kitchen(self):
        return KITCHENS[self.level]

    def reset(self):
        self.state = new(self.kitchen)
        self.history = []
        self.actions = []
        self.clock = 0
        self.pending.clear()
        self.restart = False
        self.paused = False
        self.ready = set()

    def barrier(self, down):
        self.blocked |= down
        self.release_gate = True
        self.pending.clear()
        self.clock = 0

    def tick(self, a):
        old = self.state
        self.state = step(self.kitchen, old, a, not self.variant)
        if old != self.state:
            self.history.append(old)
            self.actions.append(asdict(a))
        return 'tick'

    def undo(self):
        if not self.history:
            return 'undo_empty'
        n = min(5, len(self.history))
        self.state = self.history[-n]
        self.history = self.history[:-n]
        self.actions = self.actions[:-n]
        return 'undo_5'

    def feed(self, down):
        down = set(down) & KEYS
        self.blocked &= down
        fresh = down - self.previous - self.blocked
        self.previous = down
        active = down - self.blocked
        if self.release_gate:
            if not down:
                self.release_gate = False
            return None
        if 'F12' in fresh:
            return 'screenshot'
        if 'ESCAPE' in fresh:
            self.title = True
            self.barrier(down)
            return 'menu'
        if self.title:
            for k in ('1', '2', '3'):
                if k in fresh:
                    self.level = int(k) - 1
                    self.reset()
                    self.barrier(down)
                    return 'kitchen'
            if 'B' in fresh:
                self.variant = not self.variant
                self.barrier(down)
                return 'compare'
            if 'RETURN' in fresh:
                self.title = False
                self.reset()
                self.barrier(down)
                return 'start'
            return None
        if 'R' in fresh:
            self.restart = not self.restart
            self.ready = set()
            self.barrier(down)
            return 'restart_request'
        if self.restart:
            if 'Z' in fresh:
                self.ready.add('C')
            if 'RETURN' in fresh:
                self.ready.add('S')
            if len(self.ready) == 2:
                self.reset()
                self.barrier(down)
                return 'restart_confirmed'
            return None
        if 'P' in fresh or 'H' in fresh:
            self.paused = not self.paused
            self.barrier(down)
            return 'pause'
        if self.paused:
            if 'X' in fresh:
                self.barrier(down)
                return self.undo()
            return None
        if self.state.complete:
            return None
        self.pending |= fresh & {'E', 'Q', 'M', 'N'}
        self.clock += 1
        if self.clock < 15:
            return None
        self.clock -= 15

        def direction(keys):
            a = [i for i, k in enumerate(keys) if k in active]
            return a[0] if len(a) == 1 else -1
        a = Input((direction(('W', 'D', 'S', 'A')), direction(('UP', 'RIGHT', 'DOWN', 'LEFT'))), ('E' in self.pending, 'M' in self.pending), ('Q' in self.pending, 'N' in self.pending))
        self.pending.clear()
        return self.tick(a)

# Teaching ASCII renderer. Original input meanings are preserved.
EVENT_LABELS = {
    '同じ台、ひとりずつ': 'SAME SHARED TABLE: BOTH OPERATIONS REJECTED',
    '共有台から受取り': 'PICKED UP FROM SHARED TABLE',
    '共有台へ置きました': 'PLACED ON SHARED TABLE',
    '空いた手と台で受け渡し': 'NEED ONE EMPTY HAND OR EMPTY TABLE',
    'スープの材料': 'TOOK RAW SOUP U', 'パンの材料': 'TOOK RAW BREAD B',
    '材料を棚へ戻しました': 'RAW INGREDIENT RETURNED',
    '生の同じ材料だけ戻せます': 'RETURN ONLY MATCHING RAW INGREDIENT',
    '加熱開始。次の刻から進みます': 'HEATING STARTED; COUNTDOWN STARTS NEXT TICK',
    '出来上がりを受取り': 'PICKED UP COOKED DISH',
    '空手で完成皿を。生皿は空いた台へ': 'NEED EMPTY HAND + READY PAN, OR RAW DISH + EMPTY STOVE',
    '先頭の完成皿をどうぞ。皿は手元に戻ります': 'WRONG OR RAW ORDER: DISH RETAINED',
    '向きの先に自分の作業台はありません': 'NO USABLE STATION IN FRONT FOR THIS ROLE',
    'スープ完成!': 'SOUP U COOKED', 'パン完成!': 'BREAD B COOKED',
    'ごちそうさま。二人も座ろう。': 'ALL SIX ORDERS COMPLETE',
    '比較版: 共有台で受け渡します': 'DIRECT HANDOFF DISABLED; USE SHARED TABLE',
    '手渡しは隣のマスで': 'HANDOFF NEEDS ADJACENT CELLS',
    '片方が空手のときに手渡し': 'HANDOFF NEEDS EXACTLY ONE EMPTY HAND',
    'はい、どうぞ!': 'DIRECT HANDOFF COMPLETE',
}
def event_label(event):
    if event.startswith('いただきます!'):
        return 'CORRECT COOKED ORDER SERVED'
    return EVENT_LABELS.get(event, 'EVENT: SEE JAPANESE STATE JSON')

def dish_label(d):
    return '-' if d is None else d.kind + (' COOKED' if d.cooked else ' RAW')

class App:
    def __init__(self):
        self.ui = Controller()
        self.message = 'ENTER START / RELEASE ALL AFTER TRANSITIONS'

    def control(self, down):
        event = self.ui.feed(down)
        if event:
            self.message = event.upper().replace('_', ' ')
        return event

    def draw(self, p):
        u, s, k = self.ui, self.ui.state, self.ui.kitchen
        p.cls(1)
        p.text(14, 12, 'LINKED KITCHEN / LESSON ' + str(STAGE) + ' / TEACHING UI', 10)
        p.text(14, 28, 'KITCHEN ' + k.id + '  TICK ' + str(s.t) + '  CLOCK ' + str(u.clock) + '/15  DIRECT HANDOFF ' + ('OFF' if u.variant or STAGE < 4 else 'ON'), 7)
        p.text(14, 48, 'ORDERS ' + ''.join(k.orders) + '  SERVED ' + str(s.order) + '/6  CREATED ' + str(s.created) + ' RETURNED ' + str(s.returned), 10)
        p.text(14, 62, 'U = SOUP / B = BREAD / TIME LIMIT: NONE / NO BURNING', 6)
        for y in range(3):
            for x in range(7):
                pos = (x, y); xx, yy = 14 + x * 70, 86 + y * 78
                p.rect(xx, yy, 68, 76, 5 if x == 3 else 1 if x < 3 else 2)
                p.rectb(xx, yy, 68, 76, 0)
                if pos in k.walls:
                    p.rect(xx + 3, yy + 3, 62, 70, 5);p.text(xx + 24, yy + 31, 'WALL', 7)
                elif pos in SHELVES:
                    i = SHELVES.index(pos);p.text(xx + 5, yy + 8, 'SHELF ' + 'UB'[i], 10)
                    p.text(xx + 5, yy + 60, 'E: RAW', 6)
                elif pos in HEAT:
                    i=HEAT.index(pos);pan=s.heat[i];p.text(xx + 5, yy + 8, 'HEAT ' + str(i), 8)
                    p.circb(xx + 34, yy + 38, 16, 8 if pan and pan.remain else 10)
                    if pan:
                        p.text(xx + 15, yy + 35, pan.dish.kind + ' ' + str(pan.remain), 10)
                        p.text(xx + 5, yy + 60, 'READY' if pan.remain == 0 else 'HEATING', 7)
                elif pos in k.shared:
                    i=k.shared.index(pos);p.rectb(xx + 3, yy + 16, 61, 47, 15)
                    p.text(xx + 5, yy + 5, 'SHARED ' + str(i), 7)
                    p.text(xx + 6, yy + 27, dish_label(s.shared[i]), 10)
                    p.text(xx + 5, yy + 65, 'FOOT/FRONT', 6)
                elif pos == k.counter:
                    p.text(xx + 6, yy + 9, 'COUNTER', 10)
                    p.rect(xx + 6, yy + 29, 55, 19, 9)
                    p.text(xx + 6, yy + 60, 'NEXT ' + (k.orders[s.order] if not s.complete else '-'), 7)
        for r in range(2):
            typ,i=target(k,s.pos[r],s.face[r]);dest=k.shared[i] if typ=='shared' else SHELVES[i] if typ=='shelf' else HEAT[i] if typ=='heat' else k.counter if typ=='counter' else None
            if dest:
                xx,yy=14+dest[0]*70,86+dest[1]*78;p.rectb(xx+1+r*2,yy+1+r*2,66-r*4,74-r*4,(12,14)[r])
        for r in range(2):
            pos=s.pos[r];xx,yy=48+pos[0]*70,127+pos[1]*78
            if s.pos[0]==s.pos[1]:xx+=(-9,9)[r]
            p.circ(xx,yy,10,(12,14)[r]);p.text(xx-2,yy-2,'CS'[r],0)
            dx,dy=DIR[s.face[r]];p.line(xx,yy,xx+dx*19,yy+dy*19,(12,14)[r])
        p.text(521, 90, 'C COOK / BLUE', 12)
        p.text(521, 106, 'WASD MOVE', 7);p.text(521, 120, 'E OPERATE', 7);p.text(521, 134, 'Q GIVE', 7)
        p.text(521, 160, 'S SERVER / PINK', 14)
        p.text(521, 176, 'ARROWS MOVE', 7);p.text(521, 190, 'M OPERATE', 7);p.text(521, 204, 'N GIVE', 7)
        p.text(521, 229, 'PASSES ' + str(s.passes), 10);p.text(521, 245, 'CONFLICTS ' + str(s.conflicts), 10)
        p.text(521, 269, '2 TICKS / SECOND', 6)
        p.text(521, 287, 'C/S MAY OVERLAP', 6)
        p.text(14, 329, 'C HAND: ' + dish_label(s.hands[0]) + '   S HAND: ' + dish_label(s.hands[1]) + '   CONSERVED: ' + str(conservation(s)), 7)
        for i,event in enumerate(s.events[-2:]):p.text(14,347+i*15,event_label(event),10)
        p.text(14, 381, self.message, 11)
        p.text(14, 398, 'P/H PAUSE / R RESTART REQUEST: Z + ENTER AGREE, R CANCEL', 7)
        p.text(14, 413, 'ESC MENU / CLOSE WINDOW TO QUIT / F12 EVENT ONLY: SEE CLI', 6)
        p.text(14, 428, ('01: MOVEMENT ONLY' if STAGE == 1 else '02: RAW SHELVES + SHARED ONLY' if STAGE == 2 else '03: COOK + SERVE VIA TABLES' if STAGE == 3 else '1-3 KITCHEN + B COMPARE AT TITLE') + (' / PAUSED X UNDO 5' if STAGE == 5 else ''), 6)
        if s.complete:
            p.rect(122, 156, 428, 95, 1);p.rectb(122,156,428,95,11)
            p.text(144, 177, 'ALL SIX DISHES SERVED / BOTH ROLES COMPLETE',11)
            p.text(144, 200, 'SAME GOAL WITH SHARED TABLES OR DIRECT HANDOFF',7)
            p.text(144, 223, 'NO TIME LIMIT / R REQUESTS A FRESH KITCHEN',6)
        if u.title or u.paused or u.restart:
            p.rect(91,102,490,208,1);p.rectb(91,102,490,208,10)
            if u.title:
                lines=['TWO ROLES, ONE KITCHEN', 'C: WASD / E OPERATE / Q GIVE', 'S: ARROWS / M OPERATE / N GIVE', 'OPERATE BEATS MOVE; GIVE BEATS OPERATE', 'ENTER START, THEN RELEASE ALL KEYS', '1-3 KITCHEN / B DIRECT HANDOFF COMPARISON' if STAGE>=4 else 'KITCHEN A ONLY / MORE RULES IN LATER STAGES', 'SYNTHETIC QA IS NOT A TWO-HUMAN PLAYTEST']
            elif u.restart:
                lines=['RESTART REQUEST / WORLD STOPPED', 'C: Z AGREES    S: ENTER AGREES', 'AGREED: ' + ','.join(sorted(u.ready)), 'R CANCELS / STATE KEPT UNTIL BOTH AGREE', 'R DOES NOT RELOAD PYTHON SOURCE']
            else:
                lines=['PAUSED / ALL DISHES AND BOTH ROLES STOP', 'P/H RESUME, THEN RELEASE ALL KEYS', 'X RESTORES THE WHOLE KITCHEN UP TO 5 TICKS' if STAGE==5 else 'UNDO IS ADDED IN STAGE 05', 'R REQUESTS RESTART / ESC MENU', 'CLOCK AND PENDING OPERATIONS CLEARED ON TRANSITION']
            for i,line in enumerate(lines):p.text(108,122+i*24,line,10 if i==0 else 7)

def tap(app, keys):
    app.control(set())
    return app.control(set(keys))

def start(app):
    tap(app, {'RETURN'})
    app.control(set())

def logical_tick(app, keys=()):
    # For deterministic exercises: one controller window of 15 frames.
    for _ in range(15):app.control(set(keys))

def checkpoint(app):
    start(app)
    logical_tick(app, {'W'})
    logical_tick(app, {'A'})
    logical_tick(app, {'E'})

def main(headless=False, screenshot=None, state_json=None, demo=False):
    import pyxel as p
    from pathlib import Path
    p.init(680,450,title='088 lesson '+str(STAGE),fps=30,display_scale=1,quit_key=p.KEY_NONE,headless=headless)
    app=App()
    if demo:checkpoint(app)
    keys={k:getattr(p,'KEY_'+k) for k in KEYS}
    def update():
        if headless:
            if p.frame_count>=3:
                app.draw(p)
                if screenshot:p.screen.save(screenshot,1)
                if state_json:Path(state_json).write_text(json.dumps({'stage':STAGE,'state':asdict(app.ui.state),'clock':app.ui.clock,'title':app.ui.title,'scope':'Actual headless Pyxel; synthetic controls; dummy audio; no physical keys or two-human play'},ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
                p.quit()
            return
        app.control({k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description='Standalone linked kitchen tutorial')
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--screenshot')
    parser.add_argument('--state-json')
    args=parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke:
        parser.error('demo and outputs require --headless-smoke')
    for field,suffix in (('screenshot','.png'),('state_json','.json')):
        value=getattr(args,field)
        if value:
            output_path=Path(value).resolve()
            if output_path.suffix.lower()!=suffix or not output_path.parent.is_dir() or output_path.exists():
                parser.error('output must be a new '+suffix+' in an existing directory')
            setattr(args,field,str(output_path))
    main(args.headless_smoke,args.screenshot,args.state_json,args.demo_checkpoint)
AFTER STEP 05

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

ルールのまとめ:移動 → 更新前の手で手渡し → 同じ共有の台なら両方取り消し → C/S の台の操作 → 前からあった Pan の温め → 6皿で達成。

ここまでに作ったのは、元のゲームの仕組みだけを取り出して作り直した「簡略版」です。元のゲームのソースは手を加えずに、別のZIP(元の完成版ソース)に入れてあります。

比べる点5ステップの教材元の完成版
段階01 歩く、02 棚と共有の台、03 温めと提出、04 手渡し・すべての台所・比較、05 5刻の Undo。2Hz・予約した操作・一時停止・R での2人の確認は、早い段階からの足場。最初からルール全部。
表示と記録680×450の英数字(ASCII)、状態と台の操作の相手の枠、短い英語の出来事。F12 での保存なし、音なし。900×660の日本語の文字・料理の絵・画像・履歴・コードの自動保存。
実行1つの py ファイルに、ルール・Controller・MIT ライセンスの全文。画像の assets は読みこみません。model/controller/strategies/glyphs を読みこみます。

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

  1. model.py の walk / target:足元と向き
  2. model.py の step:participants / collision / 古い heat
  3. model.py の conservation / digest
  4. controller.py の barrier / feed:pending と release_gate
  5. controller.py の tick / undo / restart:2人の同意
  6. strategies.py の Plan.station / make_plan
  7. app.py の dish / draw / record / save
自分のゲームに変えるなら

最初は、数字かルールを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で起動して画面を描けることも確かめました。元の完成版も、自動で遊ぶデモが起動して画面を描き、正常に終わることを確認しています。2人分の操作は自動入力で確かめたもので、実際のキーボードでの同時押しや、2人で遊んだときの確認はまだです。

この確認は、画面を表示しない自動テストで行ったものです。Windows・Macの実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。

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