パズルLESSON 08状態遷移 × 視線パズル原作065

まばたき劇場の作り方

何を止めれば、先へ進める?
見ているものだけが止まる、小さな舞台のパズルです。物を動かす順番の決め方と、クリアに必須ではない「おまけの目標」の作り方を学びます。

完成版の遊び方見守っているものだけが止まります。止めるものを選びながら橋や灯りを動かし、全員を出口へ送ります。星は取らなくてもクリアできるおまけです。

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

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

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

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

画面に「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:まばたき / X:1手戻す(05) / R:やり直し / Esc:終了
難易度の目安
入門このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_gaze.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

見ているものだけを止める

「どこを見ているか」と「時間」を別々の状態として持つ。

人形・橋・灯りを、0・1・2の番号で選びます。左右キーでは見ているもの gaze だけを変え、Z キーを押した瞬間だけ advance が新しい State を返します(これが「まばたき」で、時間が1つ進みます)。見ているものの値はそのままで、それ以外は1段階進みます。人形は右へ1マス進み、橋と灯りは0〜3をくり返します。この段階では、橋を渡れるかの判定と星は省いていて、人形は位置5に着くと止まります。

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

01_gaze.py / 注目する部分
    bridge = s.bridge if s.gaze == 1 else (s.bridge + 1) % 4
    light = s.light if s.gaze == 2 else (s.light + 1) % 4

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_gaze.py
macOS / Linux
.venv/bin/python 01_gaze.py
画面で見ること始めてすぐは DOLL(人形)を見ているので、Z を押すと人形は0のまま、橋と灯りは2→3になります。R で戻し、右キーを1回押して BRIDGE(橋)を選んでから Z を押すと、人形は1、橋は2、灯りは3になります。
  • 左右キーだけでは BLINKS(まばたき回数)が増えない
  • DOLLを選ぶと人形が止まる
  • 止めていない装置は循環する
つまずきやすいところ見るものを選ぶキーで advance まで呼んでしまうと、選んだだけで時間が進んでしまいます。また、画面を描くフレームと、まばたきの回数も別のものです。
少し変えてみよう

R→Z では POS が0、BRIDGE と LIGHT が3になります。次に R→右→Z では POS が1、BRIDGE が2、LIGHT が3になることを比べてみましょう。State.gaze の最初の値を0から2に変えた場合、始めてすぐの Z でどの値が止まるか予想してみましょう(答え:POS=1、BRIDGE=3、LIGHT=2)。

01_gaze.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(187行)
01_gaze.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 does not include or use Noto bitmap glyphs.

"""065 まばたき劇場・教材1/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


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:
        pos += 1  # この段階だけ橋の通行判定を省略。
    return State(pos, bridge, light, s.gaze, s.blinks + 1)


class Session:
    def __init__(self):
        self.previous = set()
        self.reset()

    def reset(self):
        self.state = State()

    def select(self, delta):
        # 選択だけでは時間も履歴も進めない。
        self.state = replace(self.state, gaze=(self.state.gaze + delta) % 3)

    def blink(self):
        if not won(self.state):
            self.state = advance(self.state)

    def handle(self, down):
        # 押し始めだけを処理。R/Xを押したフレームのZは無視する。
        fresh = set(down) - self.previous
        self.previous = set(down)
        if "R" in fresh:
            self.reset()
            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"}}
            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 1", 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:
                pyxel.rect(x + 9, 116, 30, 3, 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)
        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, 177, "STEP 1: BRIDGE DOES NOT BLOCK YET", 6)
        if won(s):
            pyxel.text(10, 199, "CLEAR! R TO RESTART", 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, "ESC: QUIT", 6)

    pyxel.init(320, 240, title="Blink Theater - Lesson 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",
                        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)
STEP 02 / 05

新しい橋の位相で渡る

物を動かす順番を、ルールとして決めておく。

位置1と位置3から右へ出るところに、橋を置きます。橋の状態は、0=たたんでいる、1=開きかけ、2=開いている、3=閉じかけ、の4つです。先に橋を1段階進め、その新しい状態が2なら、人形が渡れます。閉じた橋の前では、落ちずに待ちます。最初は橋が2なので、橋を見守り続ければ、5回のまばたきで出口まで進めます。

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

02_bridge.py / 注目する部分
        if pos not in BRIDGES or bridge == 2:
            pos += 1

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_bridge.py
macOS / Linux
.venv/bin/python 02_bridge.py
画面で見ることR→右で BRIDGE を選び、Z を5回押すと CLEAR です。R→右→Z のあと、さらに右で LIGHT を選んで Z を押すと、橋が2→3に閉じるので、人形は位置1で待ちます。
  • 橋の状態が2のときだけ渡れる
  • 閉じた橋の前では安全に待つ
  • 橋を見守れば、開いたままにできる
つまずきやすいところ渡れるかの判定に、進める前の s.bridge を使うと、閉じた直後に渡れたり、開いた直後に待ったりしてしまいます。新しい bridge で判定します。また、BRIDGES に入っているのは、着く位置ではなく、出発する位置です。
少し変えてみよう

R→右→Z→右で、位置1・橋2・見ているものが LIGHT の状態を作ります。Z を1回ずつ押すと、橋が3→0→1→2と変わり、4回目で初めて位置2へ渡ることを確かめましょう。

02_bridge.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(193行)
02_bridge.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 does not include or use Noto bitmap glyphs.

"""065 まばたき劇場・教材2/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


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
    return State(pos, bridge, light, s.gaze, s.blinks + 1)


class Session:
    def __init__(self):
        self.previous = set()
        self.reset()

    def reset(self):
        self.state = State()

    def select(self, delta):
        # 選択だけでは時間も履歴も進めない。
        self.state = replace(self.state, gaze=(self.state.gaze + delta) % 3)

    def blink(self):
        if not won(self.state):
            self.state = advance(self.state)

    def handle(self, down):
        # 押し始めだけを処理。R/Xを押したフレームのZは無視する。
        fresh = set(down) - self.previous
        self.previous = set(down)
        if "R" in fresh:
            self.reset()
            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"}}
            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 2", 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)
        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, 177, "NEW BRIDGE PHASE 2 ALLOWS CROSSING", 6)
        if won(s):
            pyxel.text(10, 199, "CLEAR! R TO RESTART", 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, "ESC: QUIT", 6)

    pyxel.init(320, 240, title="Blink Theater - Lesson 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",
                        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)
STEP 03 / 05

同じモデルで次を予告する

同じ関数を、本番と予告の両方で使う。

予告を作る forecast は、本番と同じ advance をそのまま呼びます。State は frozen=True(書き換えられない)なので、予告を計算しても今の状態は変わりません。NEXT には、次の位置・橋・灯りを表示し、位置が変わるときは薄い人形も描きます。描くためだけの別のルールを作らないので、予告と本番が食い違いません。星の判定はまだしません。

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

03_forecast.py / 注目する部分
def forecast(s):
    """予告用の別ルールを作らず、本番と同じ純粋関数を呼ぶ。"""
    return advance(s)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_forecast.py
macOS / Linux
.venv/bin/python 03_forecast.py
画面で見ることR→右で BRIDGE を選ぶと、NEXT は「POS 1 / BRIDGE 2 / LIGHT 3」です。待っていても BLINKS は0のままです。Z を押すと、予告どおりの状態になります。
  • NEXT を見るだけでは、状態は変わらない
  • 見るものを変えると、NEXT も変わる
  • Z を押したあとの状態が、予告と同じになる
つまずきやすいところ予告のために session.blink を呼ぶと、画面を描くたびに本番の時間まで進んでしまいます。「状態を受け取って新しい状態を返すだけの advance」と、「実際に確定する Session.blink」を分けておきます。
少し変えてみよう

R→右→Z で位置1へ進み、さらに右で LIGHT を選びます。NEXT は「POS 1 / BRIDGE 3 / LIGHT 3」です。Z で予告と同じになることを確かめ、位置が変わらない予告にも意味があることを説明してみましょう。

03_forecast.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(201行)
03_forecast.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 does not include or use Noto bitmap glyphs.

"""065 まばたき劇場・教材3/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


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
    return State(pos, bridge, light, s.gaze, s.blinks + 1)


def forecast(s):
    """予告用の別ルールを作らず、本番と同じ純粋関数を呼ぶ。"""
    return advance(s)


class Session:
    def __init__(self):
        self.previous = set()
        self.reset()

    def reset(self):
        self.state = State()

    def select(self, delta):
        # 選択だけでは時間も履歴も進めない。
        self.state = replace(self.state, gaze=(self.state.gaze + delta) % 3)

    def blink(self):
        if not won(self.state):
            self.state = advance(self.state)

    def handle(self, down):
        # 押し始めだけを処理。R/Xを押したフレームのZは無視する。
        fresh = set(down) - self.previous
        self.previous = set(down)
        if "R" in fresh:
            self.reset()
            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"}}
            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 3", 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)
        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)
        if won(s):
            pyxel.text(10, 199, "CLEAR! R TO RESTART", 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, "ESC: QUIT", 6)

    pyxel.init(320, 240, title="Blink Theater - Lesson 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",
                        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)
STEP 04 / 05

星と基本クリアを分ける

「装置→人形→星→出口」の順番を守る。

位置2に星を置きます。まず装置(橋と灯り)を進め、人形を動かしたあと、新しい位置が2で、新しい灯りが1なら星を取れます。人形を見守って足を止めていても、灯りが届けば取れます。一度取った星は残ります。位置5に着けば基本のクリアなので、星を取らずに CLEAR しても正しい結果です。

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

04_optional_star.py / 注目する部分
    star = s.star or (pos == STAR_POS and light in LIT)
    # 4. 新状態の出口到着で基本クリア。星は条件に含めない。
    return State(pos, bridge, light, s.gaze, s.blinks + 1, star)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_optional_star.py
macOS / Linux
.venv/bin/python 04_optional_star.py
画面で見ることBRIDGE を見守って Z を5回押すと、星なしで CLEAR です。R で戻し、まばたきのたびに見るものを BRIDGE, BRIDGE, DOLL, DOLL, LIGHT, LIGHT, BRIDGE の順にすると、7回で CLEAR + STAR になります。位置2から進む予告では、「星を置いていく」という注意が出ます。
  • 新しい位置と新しい灯りで、星を取れるか判定する
  • 人形を止めている間も星を取れる
  • 星なしの CLEAR も成功になる
つまずきやすいところ動く前の位置で星を判定すると、星のマスを離れたあとの光で取れてしまいます。また、won の条件に star を入れると、おまけのはずの星が必須になってしまいます。
少し変えてみよう

R→右→Z→Z で、位置2・灯り0の状態を作ります(キーは1回ずつ離してください)。ここで Z を押すと位置3へ進み、星を取り逃します。R から同じ2手をやり直し、今度は左で DOLL を選んで Z を押すと、位置2のまま灯りが1になり、星を取れます。

04_optional_star.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(210行)
04_optional_star.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 does not include or use Noto bitmap glyphs.

"""065 まばたき劇場・教材4/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()

    def select(self, delta):
        # 選択だけでは時間も履歴も進めない。
        self.state = replace(self.state, gaze=(self.state.gaze + delta) % 3)

    def blink(self):
        if not won(self.state):
            self.state = advance(self.state)

    def handle(self, down):
        # 押し始めだけを処理。R/Xを押したフレームのZは無視する。
        fresh = set(down) - self.previous
        self.previous = set(down)
        if "R" in fresh:
            self.reset()
            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"}}
            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 4", 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)
        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, "ESC: QUIT", 6)

    pyxel.init(320, 240, title="Blink Theater - Lesson 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",
                        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)
STEP 05 / 05

まばたき前の全状態へ戻す

書き換えられない状態を、まるごと履歴に積む。

まばたきを確定する直前の State を history に保存します。X キーでは、位置だけでなく、橋・灯り・星・見ているもの・まばたきの回数をまとめて戻します。見るものを選ぶだけでは、履歴は増えません。そのため、まばたきのあとで見るものを変えても、X ではそのまばたきの直前に見ていたものに戻ります。クリアしたあとも X を使えます。R キーは履歴ごと最初に戻します。

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

05_undo.py / 注目する部分
    def undo(self):
        if self.history:
            self.state = self.history[-1]
            self.history = self.history[:-1]

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_undo.py
macOS / Linux
.venv/bin/python 05_undo.py
画面で見ること星を取った直後に X を押すと、星も「取っていない」状態に戻ります。CLEAR のあとに X を押すと、出口の1マス手前に戻り、もう一度 Z でクリアできます。履歴がないときに X を押しても、何も変わりません。
  • 星・装置・見ているものも戻る
  • 見るものを選ぶだけでは履歴が増えない
  • クリアしたあとも X で戻れる
つまずきやすいところ位置しか保存しないと、星を取り直せなかったり、装置の状態がずれたりします。中身を書き換えられるオブジェクトを履歴に入れる方法にも注意が必要です。この教材では、frozen の State と tuple で履歴を持っています。
少し変えてみよう

R→右→Z→Z→左→Z で星を取ります。さらに左で LIGHT を選んでから X を押してください。POS=2、BRIDGE=2、LIGHT=0、星は未取得、GAZE=DOLL、BLINKS=2 にすべて戻ることを確かめます。次に Z を押すと、同じ星をもう一度取れます。

05_undo.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(221行)
05_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 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)
AFTER STEP 05

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

元のゲームには、A・B・C の3つの演目があります。選んだ人形・橋・灯りだけが止まります。「装置→人形→星→出口」の順に進み、橋は状態が2のときだけ渡れます。全員が退場すれば基本のクリアで、星はおまけです。予告は本番と同じ仕組みで作り、Undo はすべての状態を戻します。

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

比べる点5ステップの教材元の完成版
舞台B「二度渡る舞台」の人形1人・位置0→5・橋は1と3・星は2・最初の橋は2、灯りは2A・B・Cの3演目。Cは2人の人形が装置を共有する
段階01は橋の判定なし、02と03は星なし。04と05はBのすべての状態の変わり方を再現最初から橋・星・予告・Undoがそろっている
入力と補助機能左右・Z・R、05だけX。予告は03からずっと表示上下左右、演目の選択、説明や一時停止、予告の切り替え、比較、記録の書き出しがある
表示英字(ASCII)・図形・音なし。Noto由来の文字データは入れていない日本語の文字表示、複数の人形、表紙と説明画面

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

  1. 元のゲームの model.py の advance で、len(level.tracks) を使った「見ているもの」の番号と、すべての人形をくり返し処理するところを読みましょう。
  2. 元のゲームの model.py の forecast で、「退場済み」「閉じた橋の前で待つ」「星を置いていく」というお知らせを組み立てているところを読みましょう。
  3. 元のゲームの model.py の step で、gaze・blink・undo・reset を Timeline にまとめる方法を比べましょう。
  4. 元のゲームの model.py の solve と key で、見るものを自由に選べる前提の「幅優先探索」(答えを順番に探す方法)を読みましょう。
  5. 元のゲームの app.py の update で、開始・説明・キーが離されるのを待つ処理を、draw_stage で予告を描く方法を読みましょう。
自分のゲームに変えるなら

最初は、数字かルールを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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。

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