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