# 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)