完成サンプルをブラウザーで遊ぶ
この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。
完成版の主な操作:矢印キーで見るものを選ぶ、Zでまばたき(時間が進む)、Xで1手戻す、Spaceで予告の表示切り替え、Rでやり直し、Pで説明、1〜3で演目を選ぶ
ボタンを押すと、ゲームの読み込みが始まります。
画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。
うまく表示されないとき
ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。
見ているものごとに、動きを止める。
いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、5つのステップで作っていきます。どのステップのファイルもそれだけで動くので、動きを確かめてから次へ進みましょう。
- 必要な環境
- Python 3.11以上 / Pyxel 2.9.9
- 知っておくとよいこと
- 変数・if・関数の基本
- 教材に入っているもの
- ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
- 完成した教材の操作
- ← →:見るものを選ぶ / Z:まばたき / X:1手戻す(05) / R:やり直し / Esc:終了
- 難易度の目安
- 入門このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
- 学習順に迷ったら
- おすすめの学ぶ順番を見る
コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。
教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。
見ているものだけを止める
「どこを見ているか」と「時間」を別々の状態として持つ。
人形・橋・灯りを、0・1・2の番号で選びます。左右キーでは見ているもの gaze だけを変え、Z キーを押した瞬間だけ advance が新しい State を返します(これが「まばたき」で、時間が1つ進みます)。見ているものの値はそのままで、それ以外は1段階進みます。人形は右へ1マス進み、橋と灯りは0〜3をくり返します。この段階では、橋を渡れるかの判定と星は省いていて、人形は位置5に着くと止まります。
下のコードは 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を展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 01_gaze.py.venv/bin/python 01_gaze.py- 左右キーだけでは 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)。
このステップのコード全文(187行)
# 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)新しい橋の位相で渡る
物を動かす順番を、ルールとして決めておく。
位置1と位置3から右へ出るところに、橋を置きます。橋の状態は、0=たたんでいる、1=開きかけ、2=開いている、3=閉じかけ、の4つです。先に橋を1段階進め、その新しい状態が2なら、人形が渡れます。閉じた橋の前では、落ちずに待ちます。最初は橋が2なので、橋を見守り続ければ、5回のまばたきで出口まで進めます。
下のコードは 02_bridge.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if pos not in BRIDGES or bridge == 2:
pos += 1実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 02_bridge.py.venv/bin/python 02_bridge.py- 橋の状態が2のときだけ渡れる
- 閉じた橋の前では安全に待つ
- 橋を見守れば、開いたままにできる
s.bridge を使うと、閉じた直後に渡れたり、開いた直後に待ったりしてしまいます。新しい bridge で判定します。また、BRIDGES に入っているのは、着く位置ではなく、出発する位置です。R→右→Z→右で、位置1・橋2・見ているものが LIGHT の状態を作ります。Z を1回ずつ押すと、橋が3→0→1→2と変わり、4回目で初めて位置2へ渡ることを確かめましょう。
このステップのコード全文(193行)
# 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)同じモデルで次を予告する
同じ関数を、本番と予告の両方で使う。
予告を作る forecast は、本番と同じ advance をそのまま呼びます。State は frozen=True(書き換えられない)なので、予告を計算しても今の状態は変わりません。NEXT には、次の位置・橋・灯りを表示し、位置が変わるときは薄い人形も描きます。描くためだけの別のルールを作らないので、予告と本番が食い違いません。星の判定はまだしません。
下のコードは 03_forecast.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
def forecast(s):
"""予告用の別ルールを作らず、本番と同じ純粋関数を呼ぶ。"""
return advance(s)実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 03_forecast.py.venv/bin/python 03_forecast.py- NEXT を見るだけでは、状態は変わらない
- 見るものを変えると、NEXT も変わる
- Z を押したあとの状態が、予告と同じになる
session.blink を呼ぶと、画面を描くたびに本番の時間まで進んでしまいます。「状態を受け取って新しい状態を返すだけの advance」と、「実際に確定する Session.blink」を分けておきます。R→右→Z で位置1へ進み、さらに右で LIGHT を選びます。NEXT は「POS 1 / BRIDGE 3 / LIGHT 3」です。Z で予告と同じになることを確かめ、位置が変わらない予告にも意味があることを説明してみましょう。
このステップのコード全文(201行)
# 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)星と基本クリアを分ける
「装置→人形→星→出口」の順番を守る。
位置2に星を置きます。まず装置(橋と灯り)を進め、人形を動かしたあと、新しい位置が2で、新しい灯りが1なら星を取れます。人形を見守って足を止めていても、灯りが届けば取れます。一度取った星は残ります。位置5に着けば基本のクリアなので、星を取らずに CLEAR しても正しい結果です。
下のコードは 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を展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 04_optional_star.py.venv/bin/python 04_optional_star.py- 新しい位置と新しい灯りで、星を取れるか判定する
- 人形を止めている間も星を取れる
- 星なしの CLEAR も成功になる
won の条件に star を入れると、おまけのはずの星が必須になってしまいます。R→右→Z→Z で、位置2・灯り0の状態を作ります(キーは1回ずつ離してください)。ここで Z を押すと位置3へ進み、星を取り逃します。R から同じ2手をやり直し、今度は左で DOLL を選んで Z を押すと、位置2のまま灯りが1になり、星を取れます。
このステップのコード全文(210行)
# 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)まばたき前の全状態へ戻す
書き換えられない状態を、まるごと履歴に積む。
まばたきを確定する直前の State を history に保存します。X キーでは、位置だけでなく、橋・灯り・星・見ているもの・まばたきの回数をまとめて戻します。見るものを選ぶだけでは、履歴は増えません。そのため、まばたきのあとで見るものを変えても、X ではそのまばたきの直前に見ていたものに戻ります。クリアしたあとも X を使えます。R キーは履歴ごと最初に戻します。
下のコードは 05_undo.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
def undo(self):
if self.history:
self.state = self.history[-1]
self.history = self.history[:-1]実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 05_undo.py.venv/bin/python 05_undo.py- 星・装置・見ているものも戻る
- 見るものを選ぶだけでは履歴が増えない
- クリアしたあとも X で戻れる
frozen の State と tuple で履歴を持っています。R→右→Z→Z→左→Z で星を取ります。さらに左で LIGHT を選んでから X を押してください。POS=2、BRIDGE=2、LIGHT=0、星は未取得、GAZE=DOLL、BLINKS=2 にすべて戻ることを確かめます。次に Z を押すと、同じ星をもう一度取れます。
このステップのコード全文(221行)
# 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)仕組みがわかったら、元の完成版を読んでみよう。
元のゲームには、A・B・C の3つの演目があります。選んだ人形・橋・灯りだけが止まります。「装置→人形→星→出口」の順に進み、橋は状態が2のときだけ渡れます。全員が退場すれば基本のクリアで、星はおまけです。予告は本番と同じ仕組みで作り、Undo はすべての状態を戻します。
ここまでに作ったのは、元のゲームの仕組みだけを取り出して作り直した「簡略版」です。元のゲームのソースは手を加えずに、別のZIP(元の完成版ソース)に入れてあります。
| 比べる点 | 5ステップの教材 | 元の完成版 |
|---|---|---|
| 舞台 | B「二度渡る舞台」の人形1人・位置0→5・橋は1と3・星は2・最初の橋は2、灯りは2 | A・B・Cの3演目。Cは2人の人形が装置を共有する |
| 段階 | 01は橋の判定なし、02と03は星なし。04と05はBのすべての状態の変わり方を再現 | 最初から橋・星・予告・Undoがそろっている |
| 入力と補助機能 | 左右・Z・R、05だけX。予告は03からずっと表示 | 上下左右、演目の選択、説明や一時停止、予告の切り替え、比較、記録の書き出しがある |
| 表示 | 英字(ASCII)・図形・音なし。Noto由来の文字データは入れていない | 日本語の文字表示、複数の人形、表紙と説明画面 |
完成版のコードで、次に読むところ
- 元のゲームの
model.pyのadvanceで、len(level.tracks)を使った「見ているもの」の番号と、すべての人形をくり返し処理するところを読みましょう。 - 元のゲームの
model.pyのforecastで、「退場済み」「閉じた橋の前で待つ」「星を置いていく」というお知らせを組み立てているところを読みましょう。 - 元のゲームの
model.pyのstepで、gaze・blink・undo・resetをTimelineにまとめる方法を比べましょう。 - 元のゲームの
model.pyのsolveとkeyで、見るものを自由に選べる前提の「幅優先探索」(答えを順番に探す方法)を読みましょう。 - 元のゲームの
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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。
うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る
