パズルLESSON 09配置 × 架空の反応パズル原作075

ちいさな錬金冷蔵庫の作り方

置く場所が、できあがりを変える。
6つの棚と、架空の材料を使った反応パズルです。となり合っているかの判定、表を使って結果を決める方法、全部を同時に変える処理を作ります。

完成版の遊び方冷たい段と温かい段の棚、となりに置いた触媒(反応を助ける材料)を使い分けて、架空のおやつを「二刻」(2回の時間経過)で作ります。

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

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

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

完成版の主な操作:矢印キーで枠を動かす、Zで持つ・置く・入れ替える、Enterで試運転、Spaceで1刻進める、Pで自動運転・停止、Xで1手戻す

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

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

うまく表示されないとき

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

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

となりにあるものから、結果を決める。

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

1. データを入れ替えて、持つ・置く・交換するを作る
2. 材料と棚の組み合わせで、表から結果を引く
3. 縦横のとなりの条件を、表の引き方に加える
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
変数・if・関数の基本
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
矢印:枠を動かす / Z:持つ・置く・入れ替える / Enter:運転開始 / Space:1刻進める / X:1手戻す(05) / R:最初に戻す
難易度の目安
入門このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_hand.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

六枠と一つの手

データを入れ替えて、持つ・置く・交換するを作る。

元のゲームの A にある F1、F2、M1 の瓶を、6つの枠を持つ tuple に入れます。空の枠は空の文字列、手に持っている瓶は hand です。選んだ枠と手の中身を「同時代入」で入れ替えると、持つ・置く・交換するの3つの操作を1つの式で書けます。State は書き換えられないようにして(凍結)、replace で新しい状態を作ります。この段階には、棚の温度・反応・運転開始・時間の進行はまだありません。

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

01_hand.py / 注目する部分
slots[index], hand = state.hand, slots[index]

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_hand.py
macOS / Linux
.venv/bin/python 01_hand.py
画面で見ること方向キーで黄色い枠が動き、Z で瓶が HAND(手)に移ります。瓶がある別の枠で Z を押すと入れ替わり、EMPTY(空)の枠で Z を押すと置けます。R キーで最初の状態に戻ります。
  • 持つ・置く・入れ替えるができる
  • F1 と F2 を区別できる
  • R キーで手も棚も元に戻る
つまずきやすいところ先に slots[index] に手の瓶を入れてから、その枠を hand にコピーすると、元の瓶がなくなってしまいます。右側を先に計算してから代入する「同時代入」を使います。
少し変えてみよう

F1 を持ち、M1 の枠で入れ替えて、空いている枠に M1 を置いてみましょう。棚と手を合わせた瓶の ID の集まりが、最初と同じになっているか説明してみましょう。

01_hand.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(222行)
01_hand.py / 全文
# 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.
# This teaching edition uses ASCII only; no Noto glyph data is bundled.
# Pyxel is an external dependency under its own MIT license.

"""075 ちいさな錬金冷蔵庫・教材 1/5: 持つ・置く・交換する。
架空の反応パズルであり、現実の食品保存・化学の安全指導ではありません。
各ファイルは単独で実行できます。
"""
from dataclasses import dataclass, replace

# 原作Aの瓶IDを保持。同じ種類でもF1とF2は別の瓶です。
INITIAL_SLOTS = ("F1", "", "", "F2", "M1", "")
PRODUCTS = ("MOON JAM", "ICE CANDY", "STAR POP", "GOLD JAM",
            "FROST CREAM", "CLOUD JELLY", "PUFF CLOUD", "WHITE PUDDING")
COLORS = (12, 6, 10, 9, 7, 13, 14, 15)


@dataclass(frozen=True)
class State:
    slots: tuple = INITIAL_SLOTS
    hand: str = ""
    t: int = 0
    status: str = "edit"
    products: tuple = (-1,) * 6


def initial():
    return State()


def validate(state):
    """瓶のIDは棚か手のどちらかに必ず一つだけ存在します。"""
    actual = sorted(token for token in state.slots + (state.hand,) if token)
    expected = sorted(token for token in INITIAL_SLOTS if token)
    assert actual == expected
    assert len(state.slots) == len(state.products) == 6
    assert 0 <= state.t <= 2
    return True


def act(state, action):
    if action.startswith("pick:") and state.status == "edit":
        index = int(action.split(":")[1])
        if not 0 <= index < 6:
            return state
        slots = list(state.slots)
        # 空の手→持つ、空枠→置く、瓶入りの枠→交換、を同じ式で表します。
        slots[index], hand = state.hand, slots[index]
        return replace(state, slots=tuple(slots), hand=hand)
    return state


KEYS = {"LEFT", "RIGHT", "UP", "DOWN", "Z", "RETURN", "SPACE", "X", "R"}


class Controller:
    def __init__(self):
        self.previous = set()
        self.gate = False
        self.reset()

    def reset(self):
        self.state = initial()
        self.cursor = 0
        self.history = []
        self.notice = "Move M1 from slot 5 to slot 2."

    def commit(self, action):
        new = act(self.state, action)
        if new == self.state:
            if action == "start" and self.state.hand:
                self.notice = "Put the held bottle down first."
            return False
        self.state = new
        validate(self.state)
        self.notice = "One action accepted."
        return True

    def feed(self, down):
        """押しっぱなし・同時押しで勝手に複数の操作をしないようにします。"""
        down = set(down) & KEYS
        fresh = down - self.previous
        self.previous = down
        if self.gate:
            if not down:
                self.gate = False
            return None
        if len(fresh) > 1:
            self.gate = True
            self.notice = "Release all keys, then press one."
            return "chord"
        if not fresh:
            return None
        key = next(iter(fresh))
        self.gate = True
        if key == "R":
            self.reset()
            return "reset"
        if self.state.status != "edit":
            return None
        if key in ("LEFT", "RIGHT"):
            delta = -1 if key == "LEFT" else 1
            self.cursor = (self.cursor // 3) * 3 + (self.cursor % 3 + delta) % 3
        elif key in ("UP", "DOWN"):
            self.cursor = (self.cursor + 3) % 6
        elif key == "Z":
            self.commit("pick:" + str(self.cursor))
        return key


# smoke用の短い入力。通常起動では自動操作はありません。
# 原作Aの解: 初期位置から下、右、Z、上、ZでM1を5番から2番へ。
DEMO_KEYS = ('DOWN', 'RIGHT', 'Z', 'UP', 'Z')


def main(headless=False, screenshot=None, smoke_scene="result"):
    import pyxel
    from pathlib import Path

    ui = Controller()
    rendered = 0
    keymap = {key: getattr(pyxel, "KEY_" + key) for key in KEYS}

    def update():
        # 12回の実draw後に保存。描画前の空バッファは保存しません。
        if headless and rendered >= 12:
            if screenshot:
                Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
                pyxel.screenshot(screenshot, scale=2)
            pyxel.quit()
            return
        if headless:
            down = set()
            if smoke_scene == "result" and rendered < len(DEMO_KEYS):
                # テストでは各操作の前に全キーを離します。
                ui.feed(set())
                down = {DEMO_KEYS[rendered]}
        else:
            down = {key for key, code in keymap.items() if pyxel.btn(code)}
        ui.feed(down)

    def draw():
        nonlocal rendered
        s = ui.state
        pyxel.cls(1)
        pyxel.text(10, 8, "ALCHEMY FRIDGE / STEP 1", 10)
        pyxel.text(10, 20, "A HAND AND SIX SLOTS", 7)
        pyxel.text(10, 34, "CASE A: TWO FRUITS + ONE LEAF", 6)
        pyxel.rect(8, 48, 202, 158, 5)
        pyxel.rect(11, 51, 196, 72, 1)
        pyxel.rect(11, 128, 196, 75, 2)
        pyxel.text(16, 54, "TOP ROW", 12)
        pyxel.text(16, 132, "BOTTOM ROW", 14)
        for i, token in enumerate(s.slots):
            x, y = 17 + i % 3 * 63, 66 + i // 3 * 77
            pyxel.rectb(x, y, 57, 53, 10 if i == ui.cursor else 5)
            pyxel.text(x + 3, y + 3, str(i + 1), 6)
            product = s.products[i]
            color = COLORS[product] if product >= 0 else 10
            if token:
                if token[0] == "M":
                    pyxel.line(x + 27, y + 31, x + 27, y + 10, 3)
                    pyxel.elli(x + 15, y + 11, 14, 13, 11)
                    pyxel.elli(x + 28, y + 17, 13, 12, 3)
                else:
                    pyxel.rect(x + 22, y + 9, 15, 4, 4)
                    pyxel.rect(x + 19, y + 13, 21, 22, 5)
                    pyxel.rect(x + 21, y + 16, 17, 17, color)
                pyxel.text(x + 22, y + 38, token, 7)
            else:
                pyxel.text(x + 17, y + 25, "EMPTY", 5)
        pyxel.text(10, 214, "HAND: " + (s.hand or "EMPTY"), 10)
        pyxel.text(220, 50, "ONE SWAP EXPRESSION", 10)
        for row, text in enumerate(('One hand, six slots.', 'Z on a bottle: pick it up.', 'Z on EMPTY: put it down.', 'Z on another: swap.', '', 'Bottle IDs are preserved.', 'F1 and F2 are different.', '', 'No reactions in step 1.')):
            pyxel.text(220, 64 + row * 11, text, 7 if row % 2 == 0 else 6)
        pyxel.text(10, 230, ui.notice, 11)
        pyxel.text(10, 245, "ARROWS: SELECT  Z: PICK/PLACE/SWAP  R: RESET", 7)
        pyxel.text(10, 257, "ESC: QUIT  RELEASE KEYS BETWEEN ACTIONS", 6)
        pyxel.text(10, 276, "FICTIONAL PUZZLE. NOT FOOD OR CHEMISTRY ADVICE.", 5)
        rendered += 1

    pyxel.init(420, 290, title="Alchemy Fridge - Stage 1",
               fps=30, headless=headless)
    pyxel.run(update, draw)


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", "--headless-smoke12frames",
                        action="store_true", dest="headless_smoke",
                        help="Render 12 frames and quit; not human playtesting")
    parser.add_argument("--screenshot", help="PNG path; use with --headless-smoke")
    parser.add_argument("--smoke-scene", choices=("initial", "result"), default="result",
                        help="Automatic QA scene only, not a gameplay feature")
    args = parser.parse_args()
    if args.screenshot and not args.headless_smoke:
        parser.error("--screenshot requires --headless-smoke")
    main(args.headless_smoke, args.screenshot, args.smoke_scene)
STEP 02 / 05

冷たい棚と温かい棚

材料と棚の組み合わせで、表から結果を引く。

0〜2番は冷たい棚、3〜5番は温かい棚です。ROW_TABLE は、(材料, 温かい棚か warm) の組み合わせから、できあがる完成品の番号を返す表です。まずは触媒を使わない反応だけを扱い、M はまだ無視します。predict は状態を変えずに、それぞれの枠の完成品の番号(NEXT P番号)を計算する予告です。C の材料の表もありますが、今回の A の配置には C の瓶はありません。

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

02_rows.py / 注目する部分
return ROW_TABLE[(token[0], warm)]

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_rows.py
macOS / Linux
.venv/bin/python 02_rows.py
画面で見ることF を上の段に置くと NEXT P1(氷あめ)、下の段に置くと NEXT P3(金ジャム)になります。M1 をとなりに置いても、この段階では変わりません。
  • 上の段と下の段で、予告が変わる
  • M の効果はまだない
  • 予告では状態を変えない
つまずきやすいところ画面の「1番」と、リストの「0番」を取り違えると、棚の境目を間違えます。温かい棚かどうかは index >= 3 で決めます。
少し変えてみよう

F を画面の3番と4番に交互に置いて、完成品の予告が変わる境目を確かめましょう。予告を見るだけでは完成しない理由も説明してみましょう。

02_rows.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(243行)
02_rows.py / 全文
# 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.
# This teaching edition uses ASCII only; no Noto glyph data is bundled.
# Pyxel is an external dependency under its own MIT license.

"""075 ちいさな錬金冷蔵庫・教材 2/5: 冷たい棚と温かい棚。
架空の反応パズルであり、現実の食品保存・化学の安全指導ではありません。
各ファイルは単独で実行できます。
"""
from dataclasses import dataclass, replace

# 原作Aの瓶IDを保持。同じ種類でもF1とF2は別の瓶です。
INITIAL_SLOTS = ("F1", "", "", "F2", "M1", "")
PRODUCTS = ("MOON JAM", "ICE CANDY", "STAR POP", "GOLD JAM",
            "FROST CREAM", "CLOUD JELLY", "PUFF CLOUD", "WHITE PUDDING")
COLORS = (12, 6, 10, 9, 7, 13, 14, 15)


@dataclass(frozen=True)
class State:
    slots: tuple = INITIAL_SLOTS
    hand: str = ""
    t: int = 0
    status: str = "edit"
    products: tuple = (-1,) * 6


def initial():
    return State()


def validate(state):
    """瓶のIDは棚か手のどちらかに必ず一つだけ存在します。"""
    actual = sorted(token for token in state.slots + (state.hand,) if token)
    expected = sorted(token for token in INITIAL_SLOTS if token)
    assert actual == expected
    assert len(state.slots) == len(state.products) == 6
    assert 0 <= state.t <= 2
    return True


# 触媒は次段階。今は素材と棚だけで完成品の番号を引きます。
ROW_TABLE = {("F", False): 1, ("F", True): 3,
             ("C", False): 5, ("C", True): 7}


def reaction(slots, index):
    token = slots[index]
    if not token or token[0] not in "FC":
        return -1
    warm = index >= 3
    return ROW_TABLE[(token[0], warm)]


def predict(state):
    return tuple(reaction(state.slots, i) for i in range(6))


def act(state, action):
    if action.startswith("pick:") and state.status == "edit":
        index = int(action.split(":")[1])
        if not 0 <= index < 6:
            return state
        slots = list(state.slots)
        # 空の手→持つ、空枠→置く、瓶入りの枠→交換、を同じ式で表します。
        slots[index], hand = state.hand, slots[index]
        return replace(state, slots=tuple(slots), hand=hand)
    return state


KEYS = {"LEFT", "RIGHT", "UP", "DOWN", "Z", "RETURN", "SPACE", "X", "R"}


class Controller:
    def __init__(self):
        self.previous = set()
        self.gate = False
        self.reset()

    def reset(self):
        self.state = initial()
        self.cursor = 0
        self.history = []
        self.notice = "Move M1 from slot 5 to slot 2."

    def commit(self, action):
        new = act(self.state, action)
        if new == self.state:
            if action == "start" and self.state.hand:
                self.notice = "Put the held bottle down first."
            return False
        self.state = new
        validate(self.state)
        self.notice = "One action accepted."
        return True

    def feed(self, down):
        """押しっぱなし・同時押しで勝手に複数の操作をしないようにします。"""
        down = set(down) & KEYS
        fresh = down - self.previous
        self.previous = down
        if self.gate:
            if not down:
                self.gate = False
            return None
        if len(fresh) > 1:
            self.gate = True
            self.notice = "Release all keys, then press one."
            return "chord"
        if not fresh:
            return None
        key = next(iter(fresh))
        self.gate = True
        if key == "R":
            self.reset()
            return "reset"
        if self.state.status != "edit":
            return None
        if key in ("LEFT", "RIGHT"):
            delta = -1 if key == "LEFT" else 1
            self.cursor = (self.cursor // 3) * 3 + (self.cursor % 3 + delta) % 3
        elif key in ("UP", "DOWN"):
            self.cursor = (self.cursor + 3) % 6
        elif key == "Z":
            self.commit("pick:" + str(self.cursor))
        return key


# smoke用の短い入力。通常起動では自動操作はありません。
# 原作Aの解: 初期位置から下、右、Z、上、ZでM1を5番から2番へ。
DEMO_KEYS = ('DOWN', 'RIGHT', 'Z', 'UP', 'Z')


def main(headless=False, screenshot=None, smoke_scene="result"):
    import pyxel
    from pathlib import Path

    ui = Controller()
    rendered = 0
    keymap = {key: getattr(pyxel, "KEY_" + key) for key in KEYS}

    def update():
        # 12回の実draw後に保存。描画前の空バッファは保存しません。
        if headless and rendered >= 12:
            if screenshot:
                Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
                pyxel.screenshot(screenshot, scale=2)
            pyxel.quit()
            return
        if headless:
            down = set()
            if smoke_scene == "result" and rendered < len(DEMO_KEYS):
                # テストでは各操作の前に全キーを離します。
                ui.feed(set())
                down = {DEMO_KEYS[rendered]}
        else:
            down = {key for key, code in keymap.items() if pyxel.btn(code)}
        ui.feed(down)

    def draw():
        nonlocal rendered
        s = ui.state
        pyxel.cls(1)
        pyxel.text(10, 8, "ALCHEMY FRIDGE / STEP 2", 10)
        pyxel.text(10, 20, "ROWS CHOOSE THE PRODUCT", 7)
        pyxel.text(10, 34, "CASE A: TWO FRUITS + ONE LEAF", 6)
        pyxel.rect(8, 48, 202, 158, 5)
        pyxel.rect(11, 51, 196, 72, 1)
        pyxel.rect(11, 128, 196, 75, 2)
        pyxel.text(16, 54, "COLD ROW: SLOTS 1 2 3", 12)
        pyxel.text(16, 132, "WARM ROW: SLOTS 4 5 6", 14)
        forecast = predict(s)
        for i, token in enumerate(s.slots):
            x, y = 17 + i % 3 * 63, 66 + i // 3 * 77
            pyxel.rectb(x, y, 57, 53, 10 if i == ui.cursor else 5)
            pyxel.text(x + 3, y + 3, str(i + 1), 6)
            product = s.products[i]
            color = COLORS[product] if product >= 0 else 10
            if token:
                if token[0] == "M":
                    pyxel.line(x + 27, y + 31, x + 27, y + 10, 3)
                    pyxel.elli(x + 15, y + 11, 14, 13, 11)
                    pyxel.elli(x + 28, y + 17, 13, 12, 3)
                else:
                    pyxel.rect(x + 22, y + 9, 15, 4, 4)
                    pyxel.rect(x + 19, y + 13, 21, 22, 5)
                    pyxel.rect(x + 21, y + 16, 17, 17, color)
                pyxel.text(x + 22, y + 38, token, 7)
            else:
                pyxel.text(x + 17, y + 25, "EMPTY", 5)
            if forecast[i] >= 0:
                label = "P" if s.status == "result" else "NEXT P"
                pyxel.text(x + 5, y + 46, label + str(forecast[i]), 12)
        pyxel.text(10, 214, "HAND: " + (s.hand or "EMPTY"), 10)
        pyxel.text(220, 50, "REACTION TABLE / FORECAST", 10)
        for row, text in enumerate(('F + COLD = ICE CANDY', 'F + WARM = GOLD JAM', '', 'C + COLD = CLOUD JELLY', 'C + WARM = WHITE PUDDING', '', 'M1 is ignored in step 2.', 'NEXT labels are forecasts.', 'No ticks or completion yet.', 'P1: ICE CANDY / P3: GOLD JAM')):
            pyxel.text(220, 64 + row * 11, text, 7 if row % 2 == 0 else 6)
        pyxel.text(10, 230, ui.notice, 11)
        pyxel.text(10, 245, "ARROWS: SELECT  Z: PICK/PLACE/SWAP  R: RESET", 7)
        pyxel.text(10, 257, "ESC: QUIT  RELEASE KEYS BETWEEN ACTIONS", 6)
        pyxel.text(10, 276, "FICTIONAL PUZZLE. NOT FOOD OR CHEMISTRY ADVICE.", 5)
        rendered += 1

    pyxel.init(420, 290, title="Alchemy Fridge - Stage 2",
               fps=30, headless=headless)
    pyxel.run(update, draw)


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", "--headless-smoke12frames",
                        action="store_true", dest="headless_smoke",
                        help="Render 12 frames and quit; not human playtesting")
    parser.add_argument("--screenshot", help="PNG path; use with --headless-smoke")
    parser.add_argument("--smoke-scene", choices=("initial", "result"), default="result",
                        help="Automatic QA scene only, not a gameplay feature")
    args = parser.parse_args()
    if args.screenshot and not args.headless_smoke:
        parser.error("--screenshot requires --headless-smoke")
    main(args.headless_smoke, args.screenshot, args.smoke_scene)
STEP 03 / 05

縦横の隣と反応表

縦横のとなりの条件を、表の引き方に加える。

列は i % 3、行は i // 3 で求めます。縦か横に1マスだけ離れた枠(マンハッタン距離が1の枠)だけを「となり」とします。冷たい段の F や C は、となりに M があるか、温かい段では、となりに B があるかを調べ、(材料, warm, active) の組み合わせで8通りの表を引きます。触媒がどちらの段にあるかではなく、反応する瓶がある段で、効く触媒の種類を決めるので、縦のとなりも効きます。今回は A の配置なので、実際にある瓶は F と M だけです。

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

03_catalyst.py / 注目する部分
return abs(i % 3 - j % 3) + abs(i // 3 - j // 3) == 1

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_catalyst.py
macOS / Linux
.venv/bin/python 03_catalyst.py
画面で見ることM1 を5番から2番へ動かすと、1番の F1 は NEXT P0(月ジャム)になり、4番の F2 は P3(金ジャム)のままです。ななめの M や、リスト上で2番→3番と続いているだけの枠は、となりになりません。
  • 縦と横のとなりだけに効く
  • 冷たい段は M、温かい段は B を使い分ける
  • 触媒は使ってもなくならない
つまずきやすいところabs(i-j)==1 だけで判定すると、上の段の右端と下の段の左端を、まちがってとなりとしてつないでしまいます。また、冷たい段の B や温かい段の M は、効く触媒ではありません。
少し変えてみよう

M1 を4番、F1 を1番に置いて、段をまたいだ縦の効果を確かめましょう。さらに M1 をまん中に置いて、2つの F が同じ触媒を共有できるか調べてみましょう。

03_catalyst.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(257行)
03_catalyst.py / 全文
# 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.
# This teaching edition uses ASCII only; no Noto glyph data is bundled.
# Pyxel is an external dependency under its own MIT license.

"""075 ちいさな錬金冷蔵庫・教材 3/5: 縦横の触媒と反応表。
架空の反応パズルであり、現実の食品保存・化学の安全指導ではありません。
各ファイルは単独で実行できます。
"""
from dataclasses import dataclass, replace

# 原作Aの瓶IDを保持。同じ種類でもF1とF2は別の瓶です。
INITIAL_SLOTS = ("F1", "", "", "F2", "M1", "")
PRODUCTS = ("MOON JAM", "ICE CANDY", "STAR POP", "GOLD JAM",
            "FROST CREAM", "CLOUD JELLY", "PUFF CLOUD", "WHITE PUDDING")
COLORS = (12, 6, 10, 9, 7, 13, 14, 15)


@dataclass(frozen=True)
class State:
    slots: tuple = INITIAL_SLOTS
    hand: str = ""
    t: int = 0
    status: str = "edit"
    products: tuple = (-1,) * 6


def initial():
    return State()


def validate(state):
    """瓶のIDは棚か手のどちらかに必ず一つだけ存在します。"""
    actual = sorted(token for token in state.slots + (state.hand,) if token)
    expected = sorted(token for token in INITIAL_SLOTS if token)
    assert actual == expected
    assert len(state.slots) == len(state.products) == 6
    assert 0 <= state.t <= 2
    return True


# (素材, 温かい棚か, 有効触媒が隣か) -> 完成品番号
REACTION_TABLE = {
    ("F", False, True): 0, ("F", False, False): 1,
    ("F", True, True): 2, ("F", True, False): 3,
    ("C", False, True): 4, ("C", False, False): 5,
    ("C", True, True): 6, ("C", True, False): 7,
}


def adjacent(i, j):
    """縦横の一枠だけ。2と3は配列で隣でも棚では隣ではありません。"""
    return abs(i % 3 - j % 3) + abs(i // 3 - j // 3) == 1


def reaction(slots, index):
    token = slots[index]
    if not token or token[0] not in "FC":
        return -1
    warm = index >= 3
    catalyst = "B" if warm else "M"
    active = any(token and token[0] == catalyst and adjacent(index, j)
                 for j, token in enumerate(slots))
    return REACTION_TABLE[(token[0], warm, bool(active))]


def predict(state):
    # 元の棚を変えずに全枠を計算します。完成品からの連鎖はありません。
    snapshot = state.slots
    return tuple(reaction(snapshot, i) for i in range(6))


def act(state, action):
    if action.startswith("pick:") and state.status == "edit":
        index = int(action.split(":")[1])
        if not 0 <= index < 6:
            return state
        slots = list(state.slots)
        # 空の手→持つ、空枠→置く、瓶入りの枠→交換、を同じ式で表します。
        slots[index], hand = state.hand, slots[index]
        return replace(state, slots=tuple(slots), hand=hand)
    return state


KEYS = {"LEFT", "RIGHT", "UP", "DOWN", "Z", "RETURN", "SPACE", "X", "R"}


class Controller:
    def __init__(self):
        self.previous = set()
        self.gate = False
        self.reset()

    def reset(self):
        self.state = initial()
        self.cursor = 0
        self.history = []
        self.notice = "Move M1 from slot 5 to slot 2."

    def commit(self, action):
        new = act(self.state, action)
        if new == self.state:
            if action == "start" and self.state.hand:
                self.notice = "Put the held bottle down first."
            return False
        self.state = new
        validate(self.state)
        self.notice = "One action accepted."
        return True

    def feed(self, down):
        """押しっぱなし・同時押しで勝手に複数の操作をしないようにします。"""
        down = set(down) & KEYS
        fresh = down - self.previous
        self.previous = down
        if self.gate:
            if not down:
                self.gate = False
            return None
        if len(fresh) > 1:
            self.gate = True
            self.notice = "Release all keys, then press one."
            return "chord"
        if not fresh:
            return None
        key = next(iter(fresh))
        self.gate = True
        if key == "R":
            self.reset()
            return "reset"
        if self.state.status != "edit":
            return None
        if key in ("LEFT", "RIGHT"):
            delta = -1 if key == "LEFT" else 1
            self.cursor = (self.cursor // 3) * 3 + (self.cursor % 3 + delta) % 3
        elif key in ("UP", "DOWN"):
            self.cursor = (self.cursor + 3) % 6
        elif key == "Z":
            self.commit("pick:" + str(self.cursor))
        return key


# smoke用の短い入力。通常起動では自動操作はありません。
# 原作Aの解: 初期位置から下、右、Z、上、ZでM1を5番から2番へ。
DEMO_KEYS = ('DOWN', 'RIGHT', 'Z', 'UP', 'Z')


def main(headless=False, screenshot=None, smoke_scene="result"):
    import pyxel
    from pathlib import Path

    ui = Controller()
    rendered = 0
    keymap = {key: getattr(pyxel, "KEY_" + key) for key in KEYS}

    def update():
        # 12回の実draw後に保存。描画前の空バッファは保存しません。
        if headless and rendered >= 12:
            if screenshot:
                Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
                pyxel.screenshot(screenshot, scale=2)
            pyxel.quit()
            return
        if headless:
            down = set()
            if smoke_scene == "result" and rendered < len(DEMO_KEYS):
                # テストでは各操作の前に全キーを離します。
                ui.feed(set())
                down = {DEMO_KEYS[rendered]}
        else:
            down = {key for key, code in keymap.items() if pyxel.btn(code)}
        ui.feed(down)

    def draw():
        nonlocal rendered
        s = ui.state
        pyxel.cls(1)
        pyxel.text(10, 8, "ALCHEMY FRIDGE / STEP 3", 10)
        pyxel.text(10, 20, "NEIGHBORS CHANGE THE RECIPE", 7)
        pyxel.text(10, 34, "CASE A: TWO FRUITS + ONE LEAF", 6)
        pyxel.rect(8, 48, 202, 158, 5)
        pyxel.rect(11, 51, 196, 72, 1)
        pyxel.rect(11, 128, 196, 75, 2)
        pyxel.text(16, 54, "COLD: M LEAF CHANGES F/C", 12)
        pyxel.text(16, 132, "WARM: B STONE CHANGES F/C", 14)
        forecast = predict(s)
        for i, token in enumerate(s.slots):
            x, y = 17 + i % 3 * 63, 66 + i // 3 * 77
            pyxel.rectb(x, y, 57, 53, 10 if i == ui.cursor else 5)
            pyxel.text(x + 3, y + 3, str(i + 1), 6)
            product = s.products[i]
            color = COLORS[product] if product >= 0 else 10
            if token:
                if token[0] == "M":
                    pyxel.line(x + 27, y + 31, x + 27, y + 10, 3)
                    pyxel.elli(x + 15, y + 11, 14, 13, 11)
                    pyxel.elli(x + 28, y + 17, 13, 12, 3)
                else:
                    pyxel.rect(x + 22, y + 9, 15, 4, 4)
                    pyxel.rect(x + 19, y + 13, 21, 22, 5)
                    pyxel.rect(x + 21, y + 16, 17, 17, color)
                pyxel.text(x + 22, y + 38, token, 7)
            else:
                pyxel.text(x + 17, y + 25, "EMPTY", 5)
            if forecast[i] >= 0:
                label = "P" if s.status == "result" else "NEXT P"
                pyxel.text(x + 5, y + 46, label + str(forecast[i]), 12)
        pyxel.text(10, 214, "HAND: " + (s.hand or "EMPTY"), 10)
        pyxel.text(220, 50, "REACTION TABLE / FORECAST", 10)
        for row, text in enumerate(('F COLD + M -> MOON JAM', 'F COLD no M -> ICE CANDY', 'F WARM + B -> STAR POP', 'F WARM no B -> GOLD JAM', 'C COLD + M -> FROST CREAM', 'C COLD no M -> CLOUD JELLY', 'C WARM + B -> PUFF CLOUD', 'C WARM no B -> WHITE PUDDING', '', '+ means orthogonal neighbor.', 'No diagonals, no row wrap.', 'Catalysts are NOT consumed.', 'P0/1/2/3 = F products above.')):
            pyxel.text(220, 64 + row * 11, text, 7 if row % 2 == 0 else 6)
        pyxel.text(10, 230, ui.notice, 11)
        pyxel.text(10, 245, "ARROWS: SELECT  Z: PICK/PLACE/SWAP  R: RESET", 7)
        pyxel.text(10, 257, "ESC: QUIT  RELEASE KEYS BETWEEN ACTIONS", 6)
        pyxel.text(10, 276, "FICTIONAL PUZZLE. NOT FOOD OR CHEMISTRY ADVICE.", 5)
        rendered += 1

    pyxel.init(420, 290, title="Alchemy Fridge - Stage 3",
               fps=30, headless=headless)
    pyxel.run(update, draw)


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", "--headless-smoke12frames",
                        action="store_true", dest="headless_smoke",
                        help="Render 12 frames and quit; not human playtesting")
    parser.add_argument("--screenshot", help="PNG path; use with --headless-smoke")
    parser.add_argument("--smoke-scene", choices=("initial", "result"), default="result",
                        help="Automatic QA scene only, not a gameplay feature")
    args = parser.parse_args()
    if args.screenshot and not args.headless_smoke:
        parser.error("--screenshot requires --headless-smoke")
    main(args.headless_smoke, args.screenshot, args.smoke_scene)
STEP 04 / 05

二刻で同時に完成

「配置中」「運転中」「結果」を状態で分ける。

手に何も持っていないときだけ、Enter で edit(配置中)から run(運転中)に移ります。Space で1刻目(t=1)に進んでも、完成品はすべて -1(まだない)のままです。2刻目に、predict が変える前の同じ slots を読み、すべての products(完成品)をまとめて確定します。運転中は瓶を動かせず、完成品からさらに反応が起きることもありません。画面のフレームは表示のためのもので、刻を進めるのは Space だけです。依頼の判定と Undo は次の段階で作ります。

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

04_two_ticks.py / 注目する部分
products = predict(state) if t == 2 else state.products

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_two_ticks.py
macOS / Linux
.venv/bin/python 04_two_ticks.py
画面で見ることEnter のあとに Space を1回押すと RUN T 1/2、もう1回で RESULT T 2/2 です。F の色は2刻目に同時に変わり、M1 と瓶の ID は残ります。手に瓶を持っていると、運転を始められません。
  • 瓶を持っている間は運転を始めない
  • 1刻目では完成しない
  • 2刻目に、同じ棚の状態から一斉に確定する
つまずきやすいところ枠を1つずつ完成品に書き換えながら次を計算すると、後ろの瓶だけが新しい状態を読んでしまいます。元の棚の状態(snapshot)からすべての結果を作り終えてから、まとめて保存します。
少し変えてみよう

1刻目と2刻目の products を比べ、M1 を共有した2つの F が同時に P0 になる配置を試してみましょう。結果が出ている間に Space を押しても変わらないことも確かめます。

04_two_ticks.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(271行)
04_two_ticks.py / 全文
# 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.
# This teaching edition uses ASCII only; no Noto glyph data is bundled.
# Pyxel is an external dependency under its own MIT license.

"""075 ちいさな錬金冷蔵庫・教材 4/5: 二刻で全てを同時に完成。
架空の反応パズルであり、現実の食品保存・化学の安全指導ではありません。
各ファイルは単独で実行できます。
"""
from dataclasses import dataclass, replace

# 原作Aの瓶IDを保持。同じ種類でもF1とF2は別の瓶です。
INITIAL_SLOTS = ("F1", "", "", "F2", "M1", "")
PRODUCTS = ("MOON JAM", "ICE CANDY", "STAR POP", "GOLD JAM",
            "FROST CREAM", "CLOUD JELLY", "PUFF CLOUD", "WHITE PUDDING")
COLORS = (12, 6, 10, 9, 7, 13, 14, 15)


@dataclass(frozen=True)
class State:
    slots: tuple = INITIAL_SLOTS
    hand: str = ""
    t: int = 0
    status: str = "edit"
    products: tuple = (-1,) * 6


def initial():
    return State()


def validate(state):
    """瓶のIDは棚か手のどちらかに必ず一つだけ存在します。"""
    actual = sorted(token for token in state.slots + (state.hand,) if token)
    expected = sorted(token for token in INITIAL_SLOTS if token)
    assert actual == expected
    assert len(state.slots) == len(state.products) == 6
    assert 0 <= state.t <= 2
    return True


# (素材, 温かい棚か, 有効触媒が隣か) -> 完成品番号
REACTION_TABLE = {
    ("F", False, True): 0, ("F", False, False): 1,
    ("F", True, True): 2, ("F", True, False): 3,
    ("C", False, True): 4, ("C", False, False): 5,
    ("C", True, True): 6, ("C", True, False): 7,
}


def adjacent(i, j):
    """縦横の一枠だけ。2と3は配列で隣でも棚では隣ではありません。"""
    return abs(i % 3 - j % 3) + abs(i // 3 - j // 3) == 1


def reaction(slots, index):
    token = slots[index]
    if not token or token[0] not in "FC":
        return -1
    warm = index >= 3
    catalyst = "B" if warm else "M"
    active = any(token and token[0] == catalyst and adjacent(index, j)
                 for j, token in enumerate(slots))
    return REACTION_TABLE[(token[0], warm, bool(active))]


def predict(state):
    # 元の棚を変えずに全枠を計算します。完成品からの連鎖はありません。
    snapshot = state.slots
    return tuple(reaction(snapshot, i) for i in range(6))


def act(state, action):
    if action.startswith("pick:") and state.status == "edit":
        index = int(action.split(":")[1])
        if not 0 <= index < 6:
            return state
        slots = list(state.slots)
        # 空の手→持つ、空枠→置く、瓶入りの枠→交換、を同じ式で表します。
        slots[index], hand = state.hand, slots[index]
        return replace(state, slots=tuple(slots), hand=hand)
    if action == "start" and state.status == "edit" and not state.hand:
        return replace(state, status="run")
    if action == "tick" and state.status == "run":
        t = state.t + 1
        # 一刻目は素材のまま。二刻目に同じ変更前の棚から一斉に確定します。
        products = predict(state) if t == 2 else state.products
        return replace(state, t=t, products=products,
                       status="result" if t == 2 else "run")
    return state


KEYS = {"LEFT", "RIGHT", "UP", "DOWN", "Z", "RETURN", "SPACE", "X", "R"}


class Controller:
    def __init__(self):
        self.previous = set()
        self.gate = False
        self.reset()

    def reset(self):
        self.state = initial()
        self.cursor = 0
        self.history = []
        self.notice = "Move M1 from slot 5 to slot 2."

    def commit(self, action):
        new = act(self.state, action)
        if new == self.state:
            if action == "start" and self.state.hand:
                self.notice = "Put the held bottle down first."
            return False
        self.state = new
        validate(self.state)
        self.notice = "One action accepted."
        return True

    def feed(self, down):
        """押しっぱなし・同時押しで勝手に複数の操作をしないようにします。"""
        down = set(down) & KEYS
        fresh = down - self.previous
        self.previous = down
        if self.gate:
            if not down:
                self.gate = False
            return None
        if len(fresh) > 1:
            self.gate = True
            self.notice = "Release all keys, then press one."
            return "chord"
        if not fresh:
            return None
        key = next(iter(fresh))
        self.gate = True
        if key == "R":
            self.reset()
            return "reset"
        if key == "RETURN":
            return self.commit("start")
        if key == "SPACE":
            return self.commit("tick")
        if self.state.status != "edit":
            return None
        if key in ("LEFT", "RIGHT"):
            delta = -1 if key == "LEFT" else 1
            self.cursor = (self.cursor // 3) * 3 + (self.cursor % 3 + delta) % 3
        elif key in ("UP", "DOWN"):
            self.cursor = (self.cursor + 3) % 6
        elif key == "Z":
            self.commit("pick:" + str(self.cursor))
        return key


# smoke用の短い入力。通常起動では自動操作はありません。
# 原作Aの解: 初期位置から下、右、Z、上、ZでM1を5番から2番へ。
DEMO_KEYS = ('DOWN', 'RIGHT', 'Z', 'UP', 'Z', 'RETURN', 'SPACE', 'SPACE')


def main(headless=False, screenshot=None, smoke_scene="result"):
    import pyxel
    from pathlib import Path

    ui = Controller()
    rendered = 0
    keymap = {key: getattr(pyxel, "KEY_" + key) for key in KEYS}

    def update():
        # 12回の実draw後に保存。描画前の空バッファは保存しません。
        if headless and rendered >= 12:
            if screenshot:
                Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
                pyxel.screenshot(screenshot, scale=2)
            pyxel.quit()
            return
        if headless:
            down = set()
            if smoke_scene == "result" and rendered < len(DEMO_KEYS):
                # テストでは各操作の前に全キーを離します。
                ui.feed(set())
                down = {DEMO_KEYS[rendered]}
        else:
            down = {key for key, code in keymap.items() if pyxel.btn(code)}
        ui.feed(down)

    def draw():
        nonlocal rendered
        s = ui.state
        pyxel.cls(1)
        pyxel.text(10, 8, "ALCHEMY FRIDGE / STEP 4", 10)
        pyxel.text(10, 20, "CLOSE THE DOOR, THEN TWO TICKS", 7)
        pyxel.text(10, 34, "CASE A: TWO FRUITS + ONE LEAF", 6)
        pyxel.rect(8, 48, 202, 158, 5)
        pyxel.rect(11, 51, 196, 72, 1)
        pyxel.rect(11, 128, 196, 75, 2)
        pyxel.text(16, 54, "COLD: M LEAF CHANGES F/C", 12)
        pyxel.text(16, 132, "WARM: B STONE CHANGES F/C", 14)
        forecast = predict(s)
        for i, token in enumerate(s.slots):
            x, y = 17 + i % 3 * 63, 66 + i // 3 * 77
            pyxel.rectb(x, y, 57, 53, 10 if i == ui.cursor else 5)
            pyxel.text(x + 3, y + 3, str(i + 1), 6)
            product = s.products[i]
            color = COLORS[product] if product >= 0 else 10
            if token:
                if token[0] == "M":
                    pyxel.line(x + 27, y + 31, x + 27, y + 10, 3)
                    pyxel.elli(x + 15, y + 11, 14, 13, 11)
                    pyxel.elli(x + 28, y + 17, 13, 12, 3)
                else:
                    pyxel.rect(x + 22, y + 9, 15, 4, 4)
                    pyxel.rect(x + 19, y + 13, 21, 22, 5)
                    pyxel.rect(x + 21, y + 16, 17, 17, color)
                pyxel.text(x + 22, y + 38, token, 7)
            else:
                pyxel.text(x + 17, y + 25, "EMPTY", 5)
            if forecast[i] >= 0:
                label = "P" if s.status == "result" else "NEXT P"
                pyxel.text(x + 5, y + 46, label + str(forecast[i]), 12)
        pyxel.text(10, 214, "HAND: " + (s.hand or "EMPTY"), 10)
        pyxel.text(110, 214, f"{s.status.upper()}  T {s.t}/2", 11)
        pyxel.text(220, 50, "REACTION TABLE / FORECAST", 10)
        for row, text in enumerate(('F COLD + M -> MOON JAM', 'F COLD no M -> ICE CANDY', 'F WARM + B -> STAR POP', 'F WARM no B -> GOLD JAM', 'C COLD + M -> FROST CREAM', 'C COLD no M -> CLOUD JELLY', 'C WARM + B -> PUFF CLOUD', 'C WARM no B -> WHITE PUDDING', '', '+ means orthogonal neighbor.', 'No diagonals, no row wrap.', 'Catalysts are NOT consumed.', 'P0/1/2/3 = F products above.')):
            pyxel.text(220, 64 + row * 11, text, 7 if row % 2 == 0 else 6)
        pyxel.text(220, 218, "T1: NO PRODUCTS / T2: ALL", 11)
        pyxel.text(10, 230, ui.notice, 11)
        pyxel.text(10, 245, "ARROWS: SELECT  Z: PICK/PLACE/SWAP  R: RESET", 7)
        pyxel.text(10, 257, "ENTER: CLOSE  SPACE: TICK  ESC: QUIT", 6)
        pyxel.text(10, 276, "FICTIONAL PUZZLE. NOT FOOD OR CHEMISTRY ADVICE.", 5)
        rendered += 1

    pyxel.init(420, 290, title="Alchemy Fridge - Stage 4",
               fps=30, headless=headless)
    pyxel.run(update, draw)


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", "--headless-smoke12frames",
                        action="store_true", dest="headless_smoke",
                        help="Render 12 frames and quit; not human playtesting")
    parser.add_argument("--screenshot", help="PNG path; use with --headless-smoke")
    parser.add_argument("--smoke-scene", choices=("initial", "result"), default="result",
                        help="Automatic QA scene only, not a gameplay feature")
    args = parser.parse_args()
    if args.screenshot and not args.headless_smoke:
        parser.error("--screenshot requires --headless-smoke")
    main(args.headless_smoke, args.screenshot, args.smoke_scene)
STEP 05 / 05

依頼判定と一手戻す

必要な数がそろったかの判定と、書き換えられない状態の履歴。

元のゲームの A の依頼は、P0(月ジャム)1瓶と P3(金ジャム)1瓶です。結果が出ていて、必要な数以上あれば成功です。変える前の State を history に保存し、X キーで pop して戻します。手に持っている瓶・材料の配置・時刻・完成品・状態・理由も、まとめて戻ります。選んでいる枠と画面のメッセージは、モデルの履歴とは別なので、X では戻りません。元のゲームと同じ reason(理由)も持たせているので、手に何も持たずに空の枠を選び、理由だけが変わる場合も履歴に入ります。

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

05_request_undo.py / 注目する部分
self.history.append(self.state)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_request_undo.py
macOS / Linux
.venv/bin/python 05_request_undo.py
画面で見ること最初の配置のまま運転すると、依頼は未達成です。X を3回押して配置中に戻り、M1 を5番から2番に移して、Enter、Space、Space と押すと REQUEST CLEAR!(依頼達成)です。結果から X を3回押すと運転を始める前に戻り、瓶を置き直せます。R キーは履歴も消します。
  • 月ジャム1つと金ジャム1つで達成
  • 結果からも X で戻せる
  • R キーは最初の配置と空の履歴に戻す
つまずきやすいところslots だけを保存すると、手に持った瓶や時刻を戻し忘れます。State 全体を保存し、選んでいる枠や表示用のメッセージとは区別しておきます。
少し変えてみよう

失敗した結果から X を3回押して戻り、正解の配置を作ってみましょう。次に、成功した結果から X を1回だけ押して T1 に戻し、Space で同じ完成品になることを確かめます。

05_request_undo.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(302行)
05_request_undo.py / 全文
# 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.
# This teaching edition uses ASCII only; no Noto glyph data is bundled.
# Pyxel is an external dependency under its own MIT license.

"""075 ちいさな錬金冷蔵庫・教材 5/5: 依頼を達成して一手戻す。
架空の反応パズルであり、現実の食品保存・化学の安全指導ではありません。
各ファイルは単独で実行できます。
"""
from dataclasses import dataclass, replace

# 原作Aの瓶IDを保持。同じ種類でもF1とF2は別の瓶です。
INITIAL_SLOTS = ("F1", "", "", "F2", "M1", "")
PRODUCTS = ("MOON JAM", "ICE CANDY", "STAR POP", "GOLD JAM",
            "FROST CREAM", "CLOUD JELLY", "PUFF CLOUD", "WHITE PUDDING")
COLORS = (12, 6, 10, 9, 7, 13, 14, 15)


@dataclass(frozen=True)
class State:
    slots: tuple = INITIAL_SLOTS
    hand: str = ""
    t: int = 0
    status: str = "edit"
    products: tuple = (-1,) * 6
    level: str = "A"
    mode: str = "cross"
    reason: str = "瓶を並べて、二刻で完成。"


def initial():
    return State()


def validate(state):
    """瓶のIDは棚か手のどちらかに必ず一つだけ存在します。"""
    actual = sorted(token for token in state.slots + (state.hand,) if token)
    expected = sorted(token for token in INITIAL_SLOTS if token)
    assert actual == expected
    assert len(state.slots) == len(state.products) == 6
    assert 0 <= state.t <= 2
    return True


# (素材, 温かい棚か, 有効触媒が隣か) -> 完成品番号
REACTION_TABLE = {
    ("F", False, True): 0, ("F", False, False): 1,
    ("F", True, True): 2, ("F", True, False): 3,
    ("C", False, True): 4, ("C", False, False): 5,
    ("C", True, True): 6, ("C", True, False): 7,
}


def adjacent(i, j):
    """縦横の一枠だけ。2と3は配列で隣でも棚では隣ではありません。"""
    return abs(i % 3 - j % 3) + abs(i // 3 - j // 3) == 1


def reaction(slots, index):
    token = slots[index]
    if not token or token[0] not in "FC":
        return -1
    warm = index >= 3
    catalyst = "B" if warm else "M"
    active = any(token and token[0] == catalyst and adjacent(index, j)
                 for j, token in enumerate(slots))
    return REACTION_TABLE[(token[0], warm, bool(active))]


def predict(state):
    # 元の棚を変えずに全枠を計算します。完成品からの連鎖はありません。
    snapshot = state.slots
    return tuple(reaction(snapshot, i) for i in range(6))


REQUEST = ((0, 1), (3, 1))  # 月ジャム1瓶と金ジャム1瓶。余りは自由。


def success(state):
    return state.status == "result" and all(
        state.products.count(product) >= count for product, count in REQUEST)


def act(state, action):
    if action.startswith("pick:") and state.status == "edit":
        index = int(action.split(":")[1])
        if not 0 <= index < 6:
            return state
        slots = list(state.slots)
        # 空の手→持つ、空枠→置く、瓶入りの枠→交換、を同じ式で表します。
        slots[index], hand = state.hand, slots[index]
        return replace(state, slots=tuple(slots), hand=hand,
                       reason="持っている瓶を空枠へ置いてから試運転。" if hand else
                       "配置できました。表と予告を確かめられます。")
    if action == "start" and state.status == "edit" and not state.hand:
        return replace(state, status="run", reason="扉を閉めました。Spaceで一刻。")
    if action == "tick" and state.status == "run":
        t = state.t + 1
        # 一刻目は素材のまま。二刻目に同じ変更前の棚から一斉に確定します。
        products = predict(state) if t == 2 else state.products
        out = replace(state, t=t, products=products,
                      status="result" if t == 2 else "run",
                      reason="熟成1/2。まだ素材のまま。" if t == 1 else "二刻で全瓶が同時に完成しました。")
        if t == 2:
            out = replace(out, reason="依頼達成! 余った品の色と図鑑も楽しめます。" if success(out) else
                          "別のおやつができました。Xで戻して並べ直せます。")
        return out
    return state


KEYS = {"LEFT", "RIGHT", "UP", "DOWN", "Z", "RETURN", "SPACE", "X", "R"}


class Controller:
    def __init__(self):
        self.previous = set()
        self.gate = False
        self.reset()

    def reset(self):
        self.state = initial()
        self.cursor = 0
        self.history = []
        self.notice = "Move M1 from slot 5 to slot 2."

    def commit(self, action):
        if action == "undo":
            if not self.history:
                return False
            self.state = self.history.pop()
            self.notice = "Restored one action."
            return True
        new = act(self.state, action)
        if new == self.state:
            if action == "start" and self.state.hand:
                self.notice = "Put the held bottle down first."
            return False
        self.history.append(self.state)
        self.state = new
        validate(self.state)
        self.notice = "One action accepted."
        return True

    def feed(self, down):
        """押しっぱなし・同時押しで勝手に複数の操作をしないようにします。"""
        down = set(down) & KEYS
        fresh = down - self.previous
        self.previous = down
        if self.gate:
            if not down:
                self.gate = False
            return None
        if len(fresh) > 1:
            self.gate = True
            self.notice = "Release all keys, then press one."
            return "chord"
        if not fresh:
            return None
        key = next(iter(fresh))
        self.gate = True
        if key == "R":
            self.reset()
            return "reset"
        if key == "X":
            return self.commit("undo")
        if key == "RETURN":
            return self.commit("start")
        if key == "SPACE":
            return self.commit("tick")
        if self.state.status != "edit":
            return None
        if key in ("LEFT", "RIGHT"):
            delta = -1 if key == "LEFT" else 1
            self.cursor = (self.cursor // 3) * 3 + (self.cursor % 3 + delta) % 3
        elif key in ("UP", "DOWN"):
            self.cursor = (self.cursor + 3) % 6
        elif key == "Z":
            self.commit("pick:" + str(self.cursor))
        return key


# smoke用の短い入力。通常起動では自動操作はありません。
# 原作Aの解: 初期位置から下、右、Z、上、ZでM1を5番から2番へ。
DEMO_KEYS = ('DOWN', 'RIGHT', 'Z', 'UP', 'Z', 'RETURN', 'SPACE', 'SPACE', 'X', 'SPACE')


def main(headless=False, screenshot=None, smoke_scene="result"):
    import pyxel
    from pathlib import Path

    ui = Controller()
    rendered = 0
    keymap = {key: getattr(pyxel, "KEY_" + key) for key in KEYS}

    def update():
        # 12回の実draw後に保存。描画前の空バッファは保存しません。
        if headless and rendered >= 12:
            if screenshot:
                Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
                pyxel.screenshot(screenshot, scale=2)
            pyxel.quit()
            return
        if headless:
            down = set()
            if smoke_scene == "result" and rendered < len(DEMO_KEYS):
                # テストでは各操作の前に全キーを離します。
                ui.feed(set())
                down = {DEMO_KEYS[rendered]}
        else:
            down = {key for key, code in keymap.items() if pyxel.btn(code)}
        ui.feed(down)

    def draw():
        nonlocal rendered
        s = ui.state
        pyxel.cls(1)
        pyxel.text(10, 8, "ALCHEMY FRIDGE / STEP 5", 10)
        pyxel.text(10, 20, "DELIVER THE REQUEST / UNDO", 7)
        pyxel.text(10, 34, "CASE A: TWO FRUITS + ONE LEAF", 6)
        pyxel.rect(8, 48, 202, 158, 5)
        pyxel.rect(11, 51, 196, 72, 1)
        pyxel.rect(11, 128, 196, 75, 2)
        pyxel.text(16, 54, "COLD: M LEAF CHANGES F/C", 12)
        pyxel.text(16, 132, "WARM: B STONE CHANGES F/C", 14)
        forecast = predict(s)
        for i, token in enumerate(s.slots):
            x, y = 17 + i % 3 * 63, 66 + i // 3 * 77
            pyxel.rectb(x, y, 57, 53, 10 if i == ui.cursor else 5)
            pyxel.text(x + 3, y + 3, str(i + 1), 6)
            product = s.products[i]
            color = COLORS[product] if product >= 0 else 10
            if token:
                if token[0] == "M":
                    pyxel.line(x + 27, y + 31, x + 27, y + 10, 3)
                    pyxel.elli(x + 15, y + 11, 14, 13, 11)
                    pyxel.elli(x + 28, y + 17, 13, 12, 3)
                else:
                    pyxel.rect(x + 22, y + 9, 15, 4, 4)
                    pyxel.rect(x + 19, y + 13, 21, 22, 5)
                    pyxel.rect(x + 21, y + 16, 17, 17, color)
                pyxel.text(x + 22, y + 38, token, 7)
            else:
                pyxel.text(x + 17, y + 25, "EMPTY", 5)
            if forecast[i] >= 0:
                label = "P" if s.status == "result" else "NEXT P"
                pyxel.text(x + 5, y + 46, label + str(forecast[i]), 12)
        pyxel.text(10, 214, "HAND: " + (s.hand or "EMPTY"), 10)
        pyxel.text(110, 214, f"{s.status.upper()}  T {s.t}/2", 11)
        pyxel.text(220, 50, "REACTION TABLE / FORECAST", 10)
        for row, text in enumerate(('F COLD + M -> MOON JAM', 'F COLD no M -> ICE CANDY', 'F WARM + B -> STAR POP', 'F WARM no B -> GOLD JAM', 'C COLD + M -> FROST CREAM', 'C COLD no M -> CLOUD JELLY', 'C WARM + B -> PUFF CLOUD', 'C WARM no B -> WHITE PUDDING', '', '+ means orthogonal neighbor.', 'No diagonals, no row wrap.', 'Catalysts are NOT consumed.', 'P0/1/2/3 = F products above.')):
            pyxel.text(220, 64 + row * 11, text, 7 if row % 2 == 0 else 6)
        got = s.products.count(0), s.products.count(3)
        pyxel.text(220, 218, f"NEED P0 {got[0]}/1 + P3 {got[1]}/1", 11)
        if s.status == "result":
            ui_text = "REQUEST CLEAR!" if success(s) else "TRY AGAIN: X X X"
            pyxel.text(220, 230, ui_text, 10 if success(s) else 8)
        pyxel.text(10, 230, ui.notice, 11)
        pyxel.text(10, 245, "ARROWS: SELECT  Z: PICK/PLACE/SWAP  R: RESET", 7)
        pyxel.text(10, 257, "ENTER: CLOSE  SPACE: TICK  X: UNDO  ESC: QUIT", 6)
        pyxel.text(10, 276, "FICTIONAL PUZZLE. NOT FOOD OR CHEMISTRY ADVICE.", 5)
        rendered += 1

    pyxel.init(420, 290, title="Alchemy Fridge - Stage 5",
               fps=30, headless=headless)
    pyxel.run(update, draw)


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", "--headless-smoke12frames",
                        action="store_true", dest="headless_smoke",
                        help="Render 12 frames and quit; not human playtesting")
    parser.add_argument("--screenshot", help="PNG path; use with --headless-smoke")
    parser.add_argument("--smoke-scene", choices=("initial", "result"), default="result",
                        help="Automatic QA scene only, not a gameplay feature")
    args = parser.parse_args()
    if args.screenshot and not args.headless_smoke:
        parser.error("--screenshot requires --headless-smoke")
    main(args.headless_smoke, args.screenshot, args.smoke_scene)
AFTER STEP 05

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

元のゲーム(075-v1)の A を使っています。最初の棚(F1, 空, 空, F2, M1, 空)、冷たい段は0〜2・温かい段は3〜5、縦横1マスの触媒、2刻で同時に完成、依頼は月ジャム1つ+金ジャム1つ、という部分は元のゲームと同じです。瓶を持ったままでは運転を始められず、触媒はなくならず、完成品から次の反応は起きません。

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

比べる点5ステップの教材元の完成版
問題Aの決まった配置だけ。8通りの反応表は03から持っている。BとCの材料は、表とテストの中だけで扱う。A・B・Cの3つの依頼。材料はFとC、触媒はMとB。
始め方01は入れ替えだけ、02は触媒なしの予告、03で予告が完成、04で2刻の運転、05で依頼とUndo。最初から全部の反応、2刻の運転、依頼とUndoがそろっている。
比べ方と補助機能縦横版だけ。図鑑・保存・自動運転・表紙・説明画面・発見の表示・左右だけの比較は省いている。図鑑、JSONでの保存、Pの自動運転、比較モードなどがある。
表示と記録英字(ASCII)・図形・音なし。12フレームの自動描画は確認用。操作回数や記録の上限、自動記録は省いている。日本語の文字表示、詳しい記録、試行256回・出来事4096件の上限、F12で画像保存。
状態05は、元のゲームのA(cross)の State の値とすべて同じ。Undoはモデルを戻すが、選んでいる枠は戻さない。State全体を履歴に保存し、選んでいる枠と図鑑は別に持つ。

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

  1. model.py の adjacent で、cross(縦横)と horizontal(横だけ)の違いを読み、縦だけが効く配置を比べましょう。
  2. model.py の reaction・predict・act で、同じ棚から作る予告と、2刻目の完成が一致していることを確かめましょう。
  3. model.py の LEVELS と success で、B と C の材料と必要な数を確かめましょう。
  4. controller.py の commit・feed で、Undo と図鑑を別々に管理する方法と、すべてのキーが離されるのを待つ入力の仕組みを読みましょう。
  5. app.py の demo_update・export で、自動デモ・記録・図鑑の保存を読みましょう。
自分のゲームに変えるなら

最初は、数字かルールを1つだけ変えてみましょう。遊ぶ前に「どう変わるか」を予想しておくと、仕組みがよくわかります。慣れてきたら、見た目や音も足してみてください。元のファイルは残しておき、コピーした別のフォルダーで改造すると、いつでも元に戻せます。

教材をダウンロードする

無料で、登録なしでダウンロードできます。ZIPファイルは必ず展開してから、中のファイルを実行してください。元の完成版は、同じZIPに入っている assets フォルダー(画像などの素材)も一緒に使います。

コードと図形はMITライセンス、元の完成版に含まれる日本語の文字データ(Noto由来)はSIL OFL 1.1です。配ったり改造したりするときは、ZIPに入っているLICENSEなどの権利表示を残してください。出典・権利について

検証について

どこまで動作を確認したかを見る

2026年10月5日以降、Linux(Python 3.12・Pyxel 2.9.9)で、教材のコードとゲームのルールを自動テストしています。全500ステップの教材が、実際のPyxelで起動して画面を描けることも確かめました。元の完成版も、自動で遊ぶデモが起動して画面を描き、正常に終わることを確認しています。

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

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