# 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 does not include or use Noto bitmap glyphs. """065 まばたき劇場・教材5/5: まばたき前の全状態へ戻す。 このファイルだけで起動できます。日本語は解説、画面はASCIIです。 """ from dataclasses import dataclass, replace EXIT = 5 BRIDGES = (1, 3) # この位置から右へ出る区間が橋。 STAR_POS = 2 LIT = (1,) LABELS = ("DOLL", "BRIDGE", "LIGHT") PHASES = ("FOLDED", "OPENING", "OPEN", "CLOSING") @dataclass(frozen=True) class State: pos: int = 0 bridge: int = 2 light: int = 2 gaze: int = 0 blinks: int = 0 star: bool = False def won(s): return s.pos == EXIT def advance(s): """1まばたき分。元の状態を変更せず、新しい状態を返す。""" if won(s): return s # 1. 視線外の装置を先に更新する。 bridge = s.bridge if s.gaze == 1 else (s.bridge + 1) % 4 light = s.light if s.gaze == 2 else (s.light + 1) % 4 # 2. 人形を止めるか、移動させるか決める。 pos = s.pos if s.gaze != 0: # 古いs.bridgeではなく、更新後のbridgeを使う。 if pos not in BRIDGES or bridge == 2: pos += 1 # 3. 新位置と新しい灯りで星を判定。止まった人形にも届く。 star = s.star or (pos == STAR_POS and light in LIT) # 4. 新状態の出口到着で基本クリア。星は条件に含めない。 return State(pos, bridge, light, s.gaze, s.blinks + 1, star) def forecast(s): """予告用の別ルールを作らず、本番と同じ純粋関数を呼ぶ。""" return advance(s) class Session: def __init__(self): self.previous = set() self.reset() def reset(self): self.state = State() self.history = () def select(self, delta): # 選択だけでは時間も履歴も進めない。 self.state = replace(self.state, gaze=(self.state.gaze + delta) % 3) def blink(self): if not won(self.state): self.history += (self.state,) self.state = advance(self.state) def undo(self): if self.history: self.state = self.history[-1] self.history = self.history[:-1] def handle(self, down): # 押し始めだけを処理。R/Xを押したフレームのZは無視する。 fresh = set(down) - self.previous self.previous = set(down) if "R" in fresh: self.reset() return if "X" in fresh: self.undo() return if "LEFT" in fresh: self.select(-1) elif "RIGHT" in fresh: self.select(1) if "Z" in fresh: self.blink() def main(headless=False, screenshot=None): import pyxel from pathlib import Path session = Session() frames = 0 keys = ("LEFT", "RIGHT", "Z", "X", "R") def update(): nonlocal frames if headless and frames >= 12: if screenshot: Path(screenshot).parent.mkdir(parents=True, exist_ok=True) pyxel.screenshot(screenshot, scale=2) pyxel.quit() return frames += 1 if headless: # 実描画の短い自動入力。人間のプレイテストではありません。 demo = {1: {"RIGHT"}, 3: {"Z"}, 5: {"Z"}, 6: {"LEFT"}, 7: {"Z"}, 9: {"Z"}, 11: {"X"}} session.handle(demo.get(frames, set())) else: session.handle({k for k in keys if pyxel.btn(getattr(pyxel, "KEY_" + k))}) def puppet(x, y, color, ghost=False): if ghost: pyxel.circb(x, y - 15, 5, color) pyxel.rectb(x - 4, y - 9, 9, 10, color) else: pyxel.circ(x, y - 15, 5, color) pyxel.rect(x - 4, y - 9, 9, 10, color) pyxel.line(x - 2, y, x - 6, y + 7, color) pyxel.line(x + 2, y, x + 6, y + 7, color) def draw(): s = session.state pyxel.cls(1) pyxel.text(10, 8, "BLINK THEATER / LESSON 5", 10) pyxel.text(10, 19, "LOOK TO FREEZE. BLINK TO ADVANCE.", 6) for i, name in enumerate(LABELS): x = 10 + i * 101 pyxel.rect(x, 32, 97, 20, 3 if s.gaze == i else 0) pyxel.rectb(x, 32, 97, 20, 10 if s.gaze == i else 5) pyxel.text(x + 6, 40, ("> " if s.gaze == i else " ") + name, 7) pyxel.rect(10, 59, 299, 83, 0) pyxel.rect(10, 59, 9, 83, 2) pyxel.rect(300, 59, 9, 83, 2) pyxel.line(10, 59, 308, 59, 8) pyxel.elli(128, 63, 63, 6, 10 if s.light in LIT else 5) xs = [35 + 48 * i for i in range(EXIT + 1)] for i, x in enumerate(xs): if i < EXIT: if i not in BRIDGES or s.bridge == 2: pyxel.rect(x + 9, 116, 30, 3, 11) else: pyxel.line(x + 9, 116, x + 23, 103, 5) pyxel.line(x + 25, 103, x + 39, 116, 5) pyxel.rect(x - 8, 116, 17, 5, 4) pyxel.text(x - 2, 128, str(i), 6) pyxel.rect(xs[-1] - 10, 84, 22, 31, 2) pyxel.text(xs[-1] - 7, 76, "EXIT", 7) pyxel.text(xs[STAR_POS] - 2, 77, "*", 5 if s.star else 10) nxt = forecast(s) if nxt.pos != s.pos: puppet(xs[nxt.pos], 108, 5, ghost=True) if not won(s): puppet(xs[s.pos], 108, 12) if s.gaze == 0: pyxel.circb(xs[s.pos], 93, 10, 10) else: pyxel.text(xs[-1] - 7, 98, "OUT", 11) pyxel.text(10, 150, f"BRIDGE {s.bridge}: {PHASES[s.bridge]}", 11) pyxel.text(176, 150, f"LIGHT {s.light}: " + ("ON" if s.light in LIT else "OFF"), 10) pyxel.text(10, 161, f"BLINKS {s.blinks} / GAZE {LABELS[s.gaze]}", 7) pyxel.text(10, 174, f"NEXT: POS {nxt.pos} / BRIDGE {nxt.bridge} / LIGHT {nxt.light}", 6) note = "STAR: GOT IT" if s.star else "STAR: OPTIONAL / LIGHT PHASE 1" if not s.star and s.pos == STAR_POS and nxt.pos > s.pos and not nxt.star: note = "NEXT: STAR WILL BE LEFT BEHIND" pyxel.text(10, 185, note, 10) pyxel.text(222, 161, f"UNDO {len(session.history)}", 13) if won(s): pyxel.text(10, 199, "CLEAR + STAR" if s.star else "CLEAR! STAR IS OPTIONAL", 11) else: pyxel.text(10, 199, "NO TIMER. CHOOSE WHAT SHOULD WAIT.", 7) pyxel.text(10, 214, "LEFT/RIGHT: GAZE Z: BLINK R: RESET", 6) pyxel.text(10, 227, "X: UNDO WHOLE BLINK ESC: QUIT", 6) pyxel.init(320, 240, title="Blink Theater - Lesson 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", help="12 real render frames with scripted input; not a human playtest") parser.add_argument("--screenshot", metavar="PATH", help="PNG output with --headless-smoke") args = parser.parse_args() if args.screenshot and not args.headless_smoke: parser.error("--screenshot requires --headless-smoke") main(headless=args.headless_smoke, screenshot=args.screenshot)