シミュレーションLESSON 92観察と充電を別の階で行う一人用段取りパズル原作081

交代灯台守の作り方

見た時刻を書き残して、次の船を迎える。
灯りの向き、電池の充電、船が着いたかの判定を、決まった順番で処理します。古い観察ノートと、もう一度通る船からの無線を区別し、表示の比較と、状態全体を戻せる Undo を作ります。

完成版の遊び方上の階で見た船の予定を覚えておき、灯りを向けたまま下の階で電池を充電する、1人で切り盛りする灯台の仕事です。

交代灯台守の元の完成版。自動操作によるPyxelの実描画画面。
元の完成版Bの自動描画です。下の教材は一便の方向と充電から始め、03以降で原作A/B/Cを扱います。原作の階段・再開始確認と教材の直接操作は異なります。
PLAY / 試験公開

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

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

完成版の主な操作:左右操作/Z・Enter確定、Space待機、Tab階段確認、X一刻戻す、R再開確認

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

画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。

うまく表示されないとき

ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。

同じPyxelアプリをダウンロード(.pyxapp) / 別のタブで開く

観察した時刻と、今の状態を分けて扱う。

いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、5つのステップで作っていきます。どのステップのファイルもそれだけで動くので、動きを確かめてから次へ進みましょう。

1. 選ぶことと確定すること・船が着く時刻・書き換えない状態
2. できる操作・処理の順番・中止と上限
3. 船が現れる予定・ID の順の判定・安全なもう一度の通過
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
変数・if・for・関数・tuple / dataclass/replace は本文で説明します / enumerate・zip・all・list を使った履歴
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
左右:合法操作の選択、時刻は進まない / Z/Enter:選択を確定して一刻 / Space:wait、充電中は不採用 / Tab:02以降階段、充電中は中止して上へ / 1/2/3:03以降A/B/Cへ初期化 / B:04以降表示比較のみ、時刻/物理/履歴/初期化に影響しない / X:05一刻Undo / R:読込済みコードで即初期化 / Esc:終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_beam_timing.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

灯りの向きと一刻の確定

選ぶことと確定すること・船が着く時刻・書き換えない状態。

最初は、あらかじめ見えている1隻の船だけで、灯りのルールを学びます。前提は、変数、if、関数、tuple です。Lab は、t(刻)、light(灯りの向き)、battery(電池)、result(結果)を持つ記録で、frozen=True なので、あとから値を変えません。advance は次の Lab を返します。最初の灯りは L、電池は1、船は M の航路を3刻目に通ります。これは元のゲームの状況 A/B/C を短くした、教材専用の、答えがわかっている実験で、隠された予定や充電、外の港へのもう一度の通過はありません。

左右キーは App.selection(選んでいる候補)だけを動かし、時刻は変えません。OPTIONS で M を選んで Z か Enter を押すと、advance に 'M' を渡します。light を決めてから t=s.t+1 を作るので、この確定も1刻かかります。同じ向きをもう一度確定しても1刻かかります。Space は wait(待つ)を確定します。描いている時間や、考えている時間では進みません。App.control は押しっぱなしを1回として扱い、キーを全部離してから次を受け付けます。同時押しは受け付けません。

船が通ったかの判定は、次の時刻 t が DUE(予定の時刻)と同じときだけ行います。その刻の灯りが SHIP_LANE(船の航路)と同じで、電池が1以上なら到着で、電池を1だけ減らします。待つことや向きを変えることだけでは、電池は減りません。だから、早めに M に向けて待ち続けても、この実験では到着まで電池1が残ります。最後の刻に M に確定しても間に合います。result が決まると legal(できる操作)は空になり、そのあとの確定では何も変わりません。

画面の航路の図はわかっている予定、三角形は灯りです。実際の光や海の事故をまねたものではありません。共通の App には、あとの段階のための条件分岐が残っていますが、STAGE=1 には階段・充電・何隻もの船の定義はありません。R は、読みこんだコードの最初の値に戻すだけです。次は、02 の legal/advance、元のゲームの model.py の legal と、step の向きを変える部分を読みます。

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

01_beam_timing.py / 注目する部分
    light = action if action in LANES else s.light
    t = s.t + 1
    battery = s.battery

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_beam_timing.py
macOS / Linux
.venv/bin/python 01_beam_timing.py
画面で見ること右 → Z で M に向けて1刻、Space を2回で3刻目に SUCCESS、電池0です。左右だけを何回押しても0刻のままです。
  • 選ぶことと、確定することを分ける
  • 情報を見た刻を確かめる
  • 保存→ゲームを閉じる→再実行で、変更が反映される
つまずきやすいところ右を押しただけで、時刻や light を変えないでください。選ぶための selection と、確定した状態 light を分けます。
少し変えてみよう

DUE = 3 を DUE = 4 に変えてみましょう。保存 → Esc で終了 → python 01_beam_timing.py で実行し直します。右 → Z → Space 2回では、3刻・電池1・結果なしです。さらに Space で、4刻に SUCCESS・電池0 になります。入力は1回ずつキーを離します。R では書き換えは読み直されません。自動の確認は python exercises/01_due.py です。

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

"""081 交代灯台守: 教材段階 1。"""
STAGE = 1

from dataclasses import dataclass, replace, asdict
DUE = 3
SHIP_LANE = 'M'
LANES = ('L', 'M', 'R')
@dataclass(frozen=True)
class Lab:
    t: int = 0
    light: str = 'L'
    battery: int = 1
    result: str = ''

def initial():
    return Lab()

def legal(s):
    return () if s.result else ('L','M','R','wait')

def advance(s, action):
    if action not in legal(s):
        return s
    light = action if action in LANES else s.light
    t = s.t + 1
    battery = s.battery
    result = ''
    if t == DUE:
        arrived = light == SHIP_LANE and battery >= 1
        if arrived:
            battery -= 1
        result = 'success' if arrived else 'missed'
    return Lab(t, light, battery, result)

# Teaching interface: ASCII rendering and direct stairs, separate from original Controller.
class App:
    def __init__(self):
        self.level_index = 0
        self.compare = False
        self.gated = False
        self.reset()

    @property
    def board(self):
        return BOARDS[self.level_index] if STAGE >= 3 else None

    @property
    def state(self):
        return self.timeline.state if STAGE == 5 else self.s

    def reset(self):
        if STAGE == 5:
            self.timeline = Timeline(self.board)
        else:
            self.s = new(self.board) if STAGE >= 3 else initial()
        self.selection = 0
        self.message = 'READY / SELECTION DOES NOT ADVANCE TIME'

    def commit(self, action):
        before = self.state
        if STAGE == 5:
            self.timeline.act(action)
        else:
            self.s = step(self.board, self.s, action) if STAGE >= 3 else advance(self.s, action)
        self.selection = 0
        self.message = ('ACCEPTED: ' if before != self.state else 'UNCHANGED: ') + action.upper()

    def press(self, key):
        if key == 'R':
            self.reset()
            return
        if STAGE >= 3 and key in ('1','2','3'):
            self.level_index = int(key) - 1
            self.reset()
            return
        if STAGE >= 4 and key == 'B':
            self.compare = not self.compare
            self.message = 'DISPLAY COMPARISON ONLY / NO TICK OR RESET'
            return
        if STAGE == 5 and key == 'X':
            changed = self.timeline.undo()
            self.selection = 0
            self.message = 'UNDO ONE TICK' if changed else 'NOTHING TO UNDO'
            return
        choices = legal(self.state)
        if not choices:
            return
        if key in ('LEFT','RIGHT'):
            self.selection = (self.selection + (1 if key == 'RIGHT' else -1)) % len(choices)
        elif key in ('Z','RETURN'):
            self.commit(choices[self.selection])
        elif key == 'SPACE':
            self.commit('wait')
        elif STAGE >= 2 and key == 'TAB':
            self.commit('cancel' if self.state.charging else 'down' if self.state.floor == 'upper' else 'up')

    def control(self, pressed, held):
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True
            self.message = 'CHORD REJECTED / RELEASE ALL KEYS'
        elif len(pressed) == 1:
            self.press(next(iter(pressed)))
            self.gated = True

    def view(self):
        s = self.state
        if STAGE >= 4:
            return public(self.board, s, self.compare)
        if STAGE == 3:
            # Stage 03 uses the stored observation. The radio/compare projection is added in 04.
            return {'t':s.t,'floor':s.floor,'light':s.light,'battery':s.battery,'charging':s.charging,
                    'seen_at':s.seen_at,'known':s.seen,'radio':(),
                    'spawn_schedule':tuple(x[0] for x in self.board.ships),
                    'arrived':sum(sh.status=='arrived' for sh in s.ships),'result':s.result}
        return {'t':s.t,'floor':s.floor if STAGE == 2 else 'upper','light':s.light,
                'battery':s.battery,'charging':s.charging if STAGE == 2 else False,
                'seen_at':0,'known':((0,SHIP_LANE,DUE,'arrived' if s.result=='success' else 'missed' if s.result=='missed' else 'pending'),),
                'radio':(),'spawn_schedule':(0,),'arrived':int(s.result=='success'),'result':s.result}

    def draw(self, p):
        v = self.view()
        p.cls(1)
        p.text(14,12,'LIGHTHOUSE SHIFT / STAGE ' + str(STAGE) + (' / BOARD ' + self.board.id if STAGE >= 3 else ' / KNOWN LAB'),10)
        p.text(14,28,'FICTIONAL ROUTES / ONE CONFIRMED ACTION = ONE TICK',6)
        p.text(14,48,'TIME ' + str(v['t']) + ' / FLOOR ' + v['floor'].upper() + ' / LIGHT ' + v['light'] + ' / BATTERY ' + str(v['battery']) + '/4',7)
        p.rect(12,68,624,180,0)
        if v['floor'] == 'upper' or STAGE >= 4 and self.compare:
            for i,lane in enumerate(LANES):
                x = 118 + i * 204
                p.line(x,98,x,205,5)
                if lane == v['light']:
                    p.tri(322,234,x-40,94,x+40,94,4)
                p.text(x-3,82,lane,10)
                for ship_id,known_lane,due,status in v['known']:
                    if known_lane == lane and status == 'pending':
                        y = 108 + min(5,ship_id) * 18
                        p.rect(x-6,y,12,6,7);p.text(x+13,y,str(ship_id+1)+' DUE '+str(due),10)
            p.rect(311,216,22,26,7)
        else:
            p.text(28,86,'DOWNSTAIRS / BEAM KEEPS POINTING ' + v['light'],10)
            p.text(28,107,'KNOWN LAB: SCHEDULE REMAINS PUBLIC' if STAGE == 2 else 'NO CURRENT SEA VIEW / THE NOTEBOOK BELOW IS OLD',6)
            for i in range(4):
                p.rect(136+i*94,139,70,68,11 if i<v['battery'] else 5)
            p.text(28,229,'CHARGING: FINISH OR CANCEL' if v['charging'] else 'NOT CHARGING AUTOMATICALLY',10)
        p.text(14,263,'KNOWN LAB SCHEDULE / NO VISIBILITY RULE YET' if STAGE < 3 else 'NOTEBOOK / LAST OBSERVED AT ' + str(v['seen_at']) + (' / LIVE COMPARISON' if STAGE >= 4 and self.compare else ''),10)
        for j,(i,lane,due,status) in enumerate(v['known']):
            p.text(14,283+j*20,'SHIP '+str(i+1)+' / '+lane+' / DUE '+str(due)+' / LEFT '+str(due-v['t'])+' / OBSERVED '+status.upper(),7)
        if STAGE >= 3:
            p.text(14,392,'PUBLIC SPAWN TIMES ' + str(v['spawn_schedule']) + ' / ARRIVED ' + str(v['arrived']),6)
        if STAGE >= 4:
            p.text(14,410,'UNSEEN IDS ' + str(tuple(i+1 for i in v['unseen'])),8)
            radio = [(i+1,lane,due,status) for i,lane,due,status in v['radio']]
            p.text(14,428,'RADIO RETRIES: '+str(radio),11)
        else:
            p.text(14,410,'NO RETRIES IN THIS LAB' if STAGE < 3 else 'RETRY RULE ACTIVE / RADIO DISPLAY IS ADDED IN STAGE 04',6)
        opts = legal(self.state)
        p.text(14,457,'OPTIONS: '+' / '.join(('['+a.upper()+']') if i==self.selection else a.upper() for i,a in enumerate(opts)),10)
        if v['result']:
            p.text(14,478,'RESULT '+v['result'].upper()+(' / LAB ENDED / R RESTART' if STAGE < 3 else ' / OUTER HARBOR IS SAFE / R RESTART'),11 if v['result']=='success' else 8)
        else:
            p.text(14,478,'ONLY AN ARRIVAL USES BATTERY / TURNING COSTS ONE TICK' if STAGE == 1 else 'FINISH ADDS BATTERY BEFORE ARRIVAL CHECKS',6)
        p.text(14,503,self.message,10)
        p.text(14,526,'LEFT/RIGHT SELECT / Z OR ENTER CONFIRM / SPACE WAIT / R RESET / ESC QUIT',7)
        if STAGE >= 2:
            p.text(14,543,'TAB STAIRS (CANCEL WHEN CHARGING) / RELEASE ALL KEYS',6)
        if STAGE >= 3:
            p.text(14,560,'1-3 BOARD' + (' / B DISPLAY ONLY' if STAGE >= 4 else '') + (' / X UNDO ONE TICK' if STAGE == 5 else ''),6)

def demo_keys():
    if STAGE == 1:
        return ('RIGHT','Z','SPACE','SPACE')
    if STAGE == 2:
        return ('TAB','Z','Z')
    return ('SPACE','SPACE','RIGHT','Z','TAB','Z','Z')

def main(headless=False,screenshot=None,state_json=None,demo=False):
    import pyxel as p
    import json
    from pathlib import Path
    p.init(648,584,title='Lighthouse lesson '+str(STAGE),fps=30,display_scale=1,headless=headless)
    app=App();sequence=demo_keys() if demo else ();keynames=('LEFT','RIGHT','Z','RETURN','SPACE','TAB','R','B','X','1','2','3')
    keys={k:getattr(p,'KEY_'+k) for k in keynames};limit=max(8,len(sequence)*2+6)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:
                app.draw(p);p.screen.save(screenshot,1)
            if state_json:
                Path(state_json).write_text(json.dumps({'stage':STAGE,'public':app.view(),'scope':'Actual Linux Pyxel synthetic controls, no human playtest'},ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            p.quit()
        if headless:
            i=p.frame_count//2;pressed={sequence[i]} if demo and p.frame_count%2==0 and i<len(sequence) else set();app.control(pressed,pressed)
        else:app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description='Standalone lighthouse teaching stage')
    parser.add_argument('--headless-smoke',action='store_true');parser.add_argument('--demo-checkpoint',action='store_true');parser.add_argument('--screenshot');parser.add_argument('--state-json')
    args=parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke:parser.error('demo and outputs require --headless-smoke')
    for field,suffix in (('screenshot','.png'),('state_json','.json')):
        value=getattr(args,field)
        if value:
            target=Path(value).resolve()
            if target.suffix.lower()!=suffix or not target.parent.is_dir() or target.exists():parser.error('output must be a new '+suffix+' in an existing directory')
            setattr(args,field,str(target))
    main(args.headless_smoke,args.screenshot,args.state_json,args.demo_checkpoint)
STEP 02 / 05

階段と二段階の充電

できる操作・処理の順番・中止と上限。

01 に、upper/lower(上の階・下の階)と、charging(充電の途中)という状態を加えます。前提は、if/elif、min、bool、tuple への代入です。実験用の最初の値を、灯り M・電池0 に変えます。船は引き続き、M を3刻目に通る、わかっている予定の1隻です。これは、充電が終わる刻での案内を短い手順で確かめるためで、元のゲームの状況 A/B/C の最初の値とは違います。

legal は、まず結果が出ていれば空、充電中なら finish(仕上げ)/cancel(中止)、それ以外では階によって選べる操作を変えます。上の階では L/M/R/down/wait、下の階では charge/up/wait です。灯りの向きを変えられるのは上の階だけです。下の階に降りても、灯りは前の M のままです。この教材では、Tab で階段の移動をすぐに確定するので、1刻かかります。元のゲームの階段の確認の画面は、あとで読みます。

下の階で charge を確定した刻は、charging=True になるだけです。次の finish で、battery=min(4,battery+CHARGE_AMOUNT) を実行します。down → charge → finish は 1→2→3刻目で、3刻目の船の判定より先に、電池を3に増やします。そのため、すぐあとで同じ刻の船を案内でき、電池は2残ります。充電中の wait はできない操作で、Space を押しても時刻は増えません。放っておくだけで充電が終わる仕組みもありません。

cancel は充電をやめて上の階に移り、1刻進みます。充電した分は増えません。Tab は、充電中なら cancel を選びます。ここまでの結果は、01 と同じく1隻の成功・失敗で終わり、もう一度の通過はまだありません。わかっている実験の予定表は、「見えるかどうか」のルールをまだ扱いません。共通の画面には使っていない分かれ道が残っていますが、元のゲームの Ship や Board は次で加えます。次は、03 と元のゲームの model.py の step で、充電の処理のあとに、船を ID の順に調べる for を読みます。

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

02_charge_stairs.py / 注目する部分
    elif action == 'finish':
        battery = min(4, battery + CHARGE_AMOUNT)
        charging = False

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_charge_stairs.py
macOS / Linux
.venv/bin/python 02_charge_stairs.py
画面で見ることTab → Z → Z で、下の階に1刻 → 充電中に2刻 → 3刻目に SUCCESS、灯り M、電池2 です。Tab → Z → Tab なら、充電をやめて上の階に行き、電池0で MISSED(見逃し)です。
  • 選ぶことと、確定することを分ける
  • 情報を見た刻を確かめる
  • 保存→ゲームを閉じる→再実行で、変更が反映される
つまずきやすいところ船の判定のあとで充電すると、充電が終わった刻の船に、新しい電池を使えません。「充電 → t+1 → 船の判定」の順番を守ります。
少し変えてみよう

CHARGE_AMOUNT = 3 を CHARGE_AMOUNT = 1 に変えてみましょう。保存 → Esc で終了 → python 02_charge_stairs.py で実行し直します。Tab → Z → Z を、1回ずつ離して入力します。3刻目に SUCCESS でも、電池は0になります。元の3なら2残ります。R はコードを読み直しません。自動の確認は python exercises/02_amount.py です。

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

"""081 交代灯台守: 教材段階 2。"""
STAGE = 2

from dataclasses import dataclass, replace, asdict
DUE = 3
SHIP_LANE = 'M'
CHARGE_AMOUNT = 3
LANES = ('L', 'M', 'R')
@dataclass(frozen=True)
class Lab:
    t: int = 0
    floor: str = 'upper'
    light: str = 'M'
    battery: int = 0
    charging: bool = False
    result: str = ''

def initial():
    return Lab()

def legal(s):
    if s.result:
        return ()
    if s.charging:
        return ('finish','cancel')
    return ('L','M','R','down','wait') if s.floor == 'upper' else ('charge','up','wait')

def advance(s, action):
    if action not in legal(s):
        return s
    floor, light, battery, charging = s.floor, s.light, s.battery, s.charging
    if action in LANES:
        light = action
    elif action in ('down','up','cancel'):
        floor = 'lower' if action == 'down' else 'upper'
        charging = False
    elif action == 'charge':
        charging = True
    elif action == 'finish':
        battery = min(4, battery + CHARGE_AMOUNT)
        charging = False
    t = s.t + 1
    result = ''
    if t == DUE:
        arrived = light == SHIP_LANE and battery >= 1
        if arrived:
            battery -= 1
        result = 'success' if arrived else 'missed'
    return Lab(t, floor, light, battery, charging, result)

# Teaching interface: ASCII rendering and direct stairs, separate from original Controller.
class App:
    def __init__(self):
        self.level_index = 0
        self.compare = False
        self.gated = False
        self.reset()

    @property
    def board(self):
        return BOARDS[self.level_index] if STAGE >= 3 else None

    @property
    def state(self):
        return self.timeline.state if STAGE == 5 else self.s

    def reset(self):
        if STAGE == 5:
            self.timeline = Timeline(self.board)
        else:
            self.s = new(self.board) if STAGE >= 3 else initial()
        self.selection = 0
        self.message = 'READY / SELECTION DOES NOT ADVANCE TIME'

    def commit(self, action):
        before = self.state
        if STAGE == 5:
            self.timeline.act(action)
        else:
            self.s = step(self.board, self.s, action) if STAGE >= 3 else advance(self.s, action)
        self.selection = 0
        self.message = ('ACCEPTED: ' if before != self.state else 'UNCHANGED: ') + action.upper()

    def press(self, key):
        if key == 'R':
            self.reset()
            return
        if STAGE >= 3 and key in ('1','2','3'):
            self.level_index = int(key) - 1
            self.reset()
            return
        if STAGE >= 4 and key == 'B':
            self.compare = not self.compare
            self.message = 'DISPLAY COMPARISON ONLY / NO TICK OR RESET'
            return
        if STAGE == 5 and key == 'X':
            changed = self.timeline.undo()
            self.selection = 0
            self.message = 'UNDO ONE TICK' if changed else 'NOTHING TO UNDO'
            return
        choices = legal(self.state)
        if not choices:
            return
        if key in ('LEFT','RIGHT'):
            self.selection = (self.selection + (1 if key == 'RIGHT' else -1)) % len(choices)
        elif key in ('Z','RETURN'):
            self.commit(choices[self.selection])
        elif key == 'SPACE':
            self.commit('wait')
        elif STAGE >= 2 and key == 'TAB':
            self.commit('cancel' if self.state.charging else 'down' if self.state.floor == 'upper' else 'up')

    def control(self, pressed, held):
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True
            self.message = 'CHORD REJECTED / RELEASE ALL KEYS'
        elif len(pressed) == 1:
            self.press(next(iter(pressed)))
            self.gated = True

    def view(self):
        s = self.state
        if STAGE >= 4:
            return public(self.board, s, self.compare)
        if STAGE == 3:
            # Stage 03 uses the stored observation. The radio/compare projection is added in 04.
            return {'t':s.t,'floor':s.floor,'light':s.light,'battery':s.battery,'charging':s.charging,
                    'seen_at':s.seen_at,'known':s.seen,'radio':(),
                    'spawn_schedule':tuple(x[0] for x in self.board.ships),
                    'arrived':sum(sh.status=='arrived' for sh in s.ships),'result':s.result}
        return {'t':s.t,'floor':s.floor if STAGE == 2 else 'upper','light':s.light,
                'battery':s.battery,'charging':s.charging if STAGE == 2 else False,
                'seen_at':0,'known':((0,SHIP_LANE,DUE,'arrived' if s.result=='success' else 'missed' if s.result=='missed' else 'pending'),),
                'radio':(),'spawn_schedule':(0,),'arrived':int(s.result=='success'),'result':s.result}

    def draw(self, p):
        v = self.view()
        p.cls(1)
        p.text(14,12,'LIGHTHOUSE SHIFT / STAGE ' + str(STAGE) + (' / BOARD ' + self.board.id if STAGE >= 3 else ' / KNOWN LAB'),10)
        p.text(14,28,'FICTIONAL ROUTES / ONE CONFIRMED ACTION = ONE TICK',6)
        p.text(14,48,'TIME ' + str(v['t']) + ' / FLOOR ' + v['floor'].upper() + ' / LIGHT ' + v['light'] + ' / BATTERY ' + str(v['battery']) + '/4',7)
        p.rect(12,68,624,180,0)
        if v['floor'] == 'upper' or STAGE >= 4 and self.compare:
            for i,lane in enumerate(LANES):
                x = 118 + i * 204
                p.line(x,98,x,205,5)
                if lane == v['light']:
                    p.tri(322,234,x-40,94,x+40,94,4)
                p.text(x-3,82,lane,10)
                for ship_id,known_lane,due,status in v['known']:
                    if known_lane == lane and status == 'pending':
                        y = 108 + min(5,ship_id) * 18
                        p.rect(x-6,y,12,6,7);p.text(x+13,y,str(ship_id+1)+' DUE '+str(due),10)
            p.rect(311,216,22,26,7)
        else:
            p.text(28,86,'DOWNSTAIRS / BEAM KEEPS POINTING ' + v['light'],10)
            p.text(28,107,'KNOWN LAB: SCHEDULE REMAINS PUBLIC' if STAGE == 2 else 'NO CURRENT SEA VIEW / THE NOTEBOOK BELOW IS OLD',6)
            for i in range(4):
                p.rect(136+i*94,139,70,68,11 if i<v['battery'] else 5)
            p.text(28,229,'CHARGING: FINISH OR CANCEL' if v['charging'] else 'NOT CHARGING AUTOMATICALLY',10)
        p.text(14,263,'KNOWN LAB SCHEDULE / NO VISIBILITY RULE YET' if STAGE < 3 else 'NOTEBOOK / LAST OBSERVED AT ' + str(v['seen_at']) + (' / LIVE COMPARISON' if STAGE >= 4 and self.compare else ''),10)
        for j,(i,lane,due,status) in enumerate(v['known']):
            p.text(14,283+j*20,'SHIP '+str(i+1)+' / '+lane+' / DUE '+str(due)+' / LEFT '+str(due-v['t'])+' / OBSERVED '+status.upper(),7)
        if STAGE >= 3:
            p.text(14,392,'PUBLIC SPAWN TIMES ' + str(v['spawn_schedule']) + ' / ARRIVED ' + str(v['arrived']),6)
        if STAGE >= 4:
            p.text(14,410,'UNSEEN IDS ' + str(tuple(i+1 for i in v['unseen'])),8)
            radio = [(i+1,lane,due,status) for i,lane,due,status in v['radio']]
            p.text(14,428,'RADIO RETRIES: '+str(radio),11)
        else:
            p.text(14,410,'NO RETRIES IN THIS LAB' if STAGE < 3 else 'RETRY RULE ACTIVE / RADIO DISPLAY IS ADDED IN STAGE 04',6)
        opts = legal(self.state)
        p.text(14,457,'OPTIONS: '+' / '.join(('['+a.upper()+']') if i==self.selection else a.upper() for i,a in enumerate(opts)),10)
        if v['result']:
            p.text(14,478,'RESULT '+v['result'].upper()+(' / LAB ENDED / R RESTART' if STAGE < 3 else ' / OUTER HARBOR IS SAFE / R RESTART'),11 if v['result']=='success' else 8)
        else:
            p.text(14,478,'ONLY AN ARRIVAL USES BATTERY / TURNING COSTS ONE TICK' if STAGE == 1 else 'FINISH ADDS BATTERY BEFORE ARRIVAL CHECKS',6)
        p.text(14,503,self.message,10)
        p.text(14,526,'LEFT/RIGHT SELECT / Z OR ENTER CONFIRM / SPACE WAIT / R RESET / ESC QUIT',7)
        if STAGE >= 2:
            p.text(14,543,'TAB STAIRS (CANCEL WHEN CHARGING) / RELEASE ALL KEYS',6)
        if STAGE >= 3:
            p.text(14,560,'1-3 BOARD' + (' / B DISPLAY ONLY' if STAGE >= 4 else '') + (' / X UNDO ONE TICK' if STAGE == 5 else ''),6)

def demo_keys():
    if STAGE == 1:
        return ('RIGHT','Z','SPACE','SPACE')
    if STAGE == 2:
        return ('TAB','Z','Z')
    return ('SPACE','SPACE','RIGHT','Z','TAB','Z','Z')

def main(headless=False,screenshot=None,state_json=None,demo=False):
    import pyxel as p
    import json
    from pathlib import Path
    p.init(648,584,title='Lighthouse lesson '+str(STAGE),fps=30,display_scale=1,headless=headless)
    app=App();sequence=demo_keys() if demo else ();keynames=('LEFT','RIGHT','Z','RETURN','SPACE','TAB','R','B','X','1','2','3')
    keys={k:getattr(p,'KEY_'+k) for k in keynames};limit=max(8,len(sequence)*2+6)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:
                app.draw(p);p.screen.save(screenshot,1)
            if state_json:
                Path(state_json).write_text(json.dumps({'stage':STAGE,'public':app.view(),'scope':'Actual Linux Pyxel synthetic controls, no human playtest'},ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            p.quit()
        if headless:
            i=p.frame_count//2;pressed={sequence[i]} if demo and p.frame_count%2==0 and i<len(sequence) else set();app.control(pressed,pressed)
        else:app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description='Standalone lighthouse teaching stage')
    parser.add_argument('--headless-smoke',action='store_true');parser.add_argument('--demo-checkpoint',action='store_true');parser.add_argument('--screenshot');parser.add_argument('--state-json')
    args=parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke:parser.error('demo and outputs require --headless-smoke')
    for field,suffix in (('screenshot','.png'),('state_json','.json')):
        value=getattr(args,field)
        if value:
            target=Path(value).resolve()
            if target.suffix.lower()!=suffix or not target.parent.is_dir() or target.exists():parser.error('output must be a new '+suffix+' in an existing directory')
            setattr(args,field,str(target))
    main(args.headless_smoke,args.screenshot,args.state_json,args.demo_checkpoint)
STEP 03 / 05

複数便を案内し、外港から再通過する

船が現れる予定・ID の順の判定・安全なもう一度の通過。

03 で、元のゲームの Board、BOARDS、Ship、State、observe、new、legal、step を、そのまま加えます。前提は、enumerate、zip、replace、all です。1/2/3 で状況 A/B/C を選びます。BOARDS のそれぞれの船は (spawn,due,lane) で、現れる刻、最初に通る刻、航路を表します。Ship は、更新される due、status、retry、first を別に持ちます。Board の決まった値と、進んでいく記録を分けているので、もう一度の通過で元の予定表を上書きしません。

step は、02 と同じく操作を反映してから t を1つ増やし、すべての船を ID の順に調べます。pending(待っている)で、sh.due==t の船だけが判定の対象です。灯りの向きと電池が足りていれば arrived(到着)で電池を1使い、first は、もう一度の通過でない場合だけ True になります。何隻かが同じ刻なら、ID が先の船が先に電池を使い、次の船は残りの量を見ます。この for の間に、灯りの向きは変わりません。

最初の通過を見逃すと、due=t+4、retry=1 にして、pending のまま残します。もう一度の通過も見逃すと outer(外の港)になり、安全な外の港で待ちます。沈没や座礁ではありません。全部の船が arrived なら success、最後の船の最初の予定+4刻までに全部がそろわなければ unfinished です。最後の刻に最後の船を案内できた場合は、success が優先されます。first や階段の回数は自由な記録で、外の港を通ったことに、隠れた減点はありません。

new は0刻目の observe(観察)を実行するので、最初から見える船が観察ノートに入ります。step の最後でも、到着したあとの階が upper なら observe で記録を更新し、lower なら前の seen と seen_at をそのまま残します。この段階の画面は、その観察ノートを読みます。公開されている「船が現れる刻」は表示しますが、下の階にいる間に新しく現れた船の航路はのぞきません。State の seen/seen_at は、この段階で使う「見えるかどうか」の足場です。radio(無線)と、比較専用の見せ方は 04 で加えるので、03 では、もう一度の通過のルールが動いていても、無線の欄は省いています。次は、04 と元のゲームの model.py の public の known/radio/unseen を読みます。

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

03_arrivals_retry.py / 注目する部分
   elif not sh.retry:sh=replace(sh,due=t+4,retry=1);e.append(f'{i+1}便 外港へ / {lane} 刻{t+4}に再通過')

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_arrivals_retry.py
macOS / Linux
.venv/bin/python 03_arrivals_retry.py
画面で見ること状況 A で Space を2回、右 → Z で M、Tab → Z → Z で、6刻・下の階・電池2・到着2隻です。観察ノートは、上の階にいた3刻目の記録のままです。状況 B は、M のまま下の階で案内できます。
  • 選ぶことと、確定することを分ける
  • 情報を見た刻を確かめる
  • 保存→ゲームを閉じる→再実行で、変更が反映される
つまずきやすいところ元の Board の due だけを毎回見ると、4刻後のもう一度の通過が起きません。船の判定には、更新された Ship.due を使います。
少し変えてみよう

step の中の due=t+4,retry=1 を due=t+3,retry=1 に変えてみましょう。保存 → Esc で終了 → python 03_arrivals_retry.py で実行し直します。状況 A で 右 → Z を1回(M に向ける)、Space を5回押します。1隻目は最初の2刻目を見逃し、変えたあとの5刻目でもう一度見逃して outer になり、6刻目に M の船を案内して電池0です。これは待ち時間を変えた実験で、元のゲームとは違います。R は読み直しをしません。自動の確認は python exercises/03_retry.py です。

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

"""081 交代灯台守: 教材段階 3。"""
STAGE = 3

from dataclasses import dataclass, replace, asdict
VERSION='081.1.0'
LANES=('L','M','R')
@dataclass(frozen=True)
class Board:
 id:str;title:str;battery:int;light:str;ships:tuple
BOARDS=(Board('A','晴れた四便',1,'L',((0,2,'L'),(2,6,'M'),(5,10,'R'),(9,14,'L'))),Board('B','同じ航路の列',1,'M',((0,3,'M'),(1,5,'M'),(5,9,'L'),(8,13,'R'),(12,17,'R'))),Board('C','戻って見る理由',2,'L',((0,3,'L'),(3,7,'R'),(6,11,'M'),(9,15,'L'),(13,19,'M'))))
@dataclass(frozen=True)
class Ship:
 due:int;status:str='pending';retry:int=0;first:bool=False
@dataclass(frozen=True)
class State:
 t:int;floor:str;light:str;battery:int;charging:bool;ships:tuple;seen:tuple;seen_at:int;stairs:int=0;charges:int=0;events:tuple=();result:str=''
def observe(board,s):
 seen=tuple((i,lane,sh.due,sh.status) for i,((spawn,_,lane),sh) in enumerate(zip(board.ships,s.ships)) if spawn<=s.t)
 return replace(s,seen=seen,seen_at=s.t)
def new(board):return observe(board,State(0,'upper',board.light,board.battery,False,tuple(Ship(d) for _,d,_ in board.ships),(),0))
def legal(s):
 if s.result:return ()
 if s.charging:return ('finish','cancel')
 return ('L','M','R','down','wait') if s.floor=='upper' else ('charge','up','wait')
def step(board,s,a):
 if a not in legal(s):return s
 f=s.floor;l=s.light;b=s.battery;c=s.charging;stairs=s.stairs;charges=s.charges;e=[]
 if a in LANES:l=a
 elif a in ('down','up','cancel'):f='lower' if a=='down' else 'upper';c=False;stairs+=1
 elif a=='charge':c=True;e.append('充電開始: 次の仕上げで+3')
 elif a=='finish':b=min(4,b+3);c=False;charges+=1;e.append('充電完了: 電池+3 / 上限4')
 t=s.t+1;ships=[]
 for i,((spawn,due,lane),sh) in enumerate(zip(board.ships,s.ships)):
  if sh.status=='pending' and sh.due==t:
   if l==lane and b>=1:b-=1;sh=replace(sh,status='arrived',first=not sh.retry);e.append(f'{i+1}便 案内完了 / {lane}')
   elif not sh.retry:sh=replace(sh,due=t+4,retry=1);e.append(f'{i+1}便 外港へ / {lane} 刻{t+4}に再通過')
   else:sh=replace(sh,status='outer');e.append(f'{i+1}便 外港で待機 / 再挑戦できます')
  ships.append(sh)
 result='success' if all(sh.status=='arrived' for sh in ships) else ('unfinished' if t>=max(d for _,d,_ in board.ships)+4 else '')
 out=State(t,f,l,b,c,tuple(ships),s.seen,s.seen_at,stairs,charges,tuple(e),result)
 return observe(board,out) if f=='upper' else out

# Teaching interface: ASCII rendering and direct stairs, separate from original Controller.
class App:
    def __init__(self):
        self.level_index = 0
        self.compare = False
        self.gated = False
        self.reset()

    @property
    def board(self):
        return BOARDS[self.level_index] if STAGE >= 3 else None

    @property
    def state(self):
        return self.timeline.state if STAGE == 5 else self.s

    def reset(self):
        if STAGE == 5:
            self.timeline = Timeline(self.board)
        else:
            self.s = new(self.board) if STAGE >= 3 else initial()
        self.selection = 0
        self.message = 'READY / SELECTION DOES NOT ADVANCE TIME'

    def commit(self, action):
        before = self.state
        if STAGE == 5:
            self.timeline.act(action)
        else:
            self.s = step(self.board, self.s, action) if STAGE >= 3 else advance(self.s, action)
        self.selection = 0
        self.message = ('ACCEPTED: ' if before != self.state else 'UNCHANGED: ') + action.upper()

    def press(self, key):
        if key == 'R':
            self.reset()
            return
        if STAGE >= 3 and key in ('1','2','3'):
            self.level_index = int(key) - 1
            self.reset()
            return
        if STAGE >= 4 and key == 'B':
            self.compare = not self.compare
            self.message = 'DISPLAY COMPARISON ONLY / NO TICK OR RESET'
            return
        if STAGE == 5 and key == 'X':
            changed = self.timeline.undo()
            self.selection = 0
            self.message = 'UNDO ONE TICK' if changed else 'NOTHING TO UNDO'
            return
        choices = legal(self.state)
        if not choices:
            return
        if key in ('LEFT','RIGHT'):
            self.selection = (self.selection + (1 if key == 'RIGHT' else -1)) % len(choices)
        elif key in ('Z','RETURN'):
            self.commit(choices[self.selection])
        elif key == 'SPACE':
            self.commit('wait')
        elif STAGE >= 2 and key == 'TAB':
            self.commit('cancel' if self.state.charging else 'down' if self.state.floor == 'upper' else 'up')

    def control(self, pressed, held):
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True
            self.message = 'CHORD REJECTED / RELEASE ALL KEYS'
        elif len(pressed) == 1:
            self.press(next(iter(pressed)))
            self.gated = True

    def view(self):
        s = self.state
        if STAGE >= 4:
            return public(self.board, s, self.compare)
        if STAGE == 3:
            # Stage 03 uses the stored observation. The radio/compare projection is added in 04.
            return {'t':s.t,'floor':s.floor,'light':s.light,'battery':s.battery,'charging':s.charging,
                    'seen_at':s.seen_at,'known':s.seen,'radio':(),
                    'spawn_schedule':tuple(x[0] for x in self.board.ships),
                    'arrived':sum(sh.status=='arrived' for sh in s.ships),'result':s.result}
        return {'t':s.t,'floor':s.floor if STAGE == 2 else 'upper','light':s.light,
                'battery':s.battery,'charging':s.charging if STAGE == 2 else False,
                'seen_at':0,'known':((0,SHIP_LANE,DUE,'arrived' if s.result=='success' else 'missed' if s.result=='missed' else 'pending'),),
                'radio':(),'spawn_schedule':(0,),'arrived':int(s.result=='success'),'result':s.result}

    def draw(self, p):
        v = self.view()
        p.cls(1)
        p.text(14,12,'LIGHTHOUSE SHIFT / STAGE ' + str(STAGE) + (' / BOARD ' + self.board.id if STAGE >= 3 else ' / KNOWN LAB'),10)
        p.text(14,28,'FICTIONAL ROUTES / ONE CONFIRMED ACTION = ONE TICK',6)
        p.text(14,48,'TIME ' + str(v['t']) + ' / FLOOR ' + v['floor'].upper() + ' / LIGHT ' + v['light'] + ' / BATTERY ' + str(v['battery']) + '/4',7)
        p.rect(12,68,624,180,0)
        if v['floor'] == 'upper' or STAGE >= 4 and self.compare:
            for i,lane in enumerate(LANES):
                x = 118 + i * 204
                p.line(x,98,x,205,5)
                if lane == v['light']:
                    p.tri(322,234,x-40,94,x+40,94,4)
                p.text(x-3,82,lane,10)
                for ship_id,known_lane,due,status in v['known']:
                    if known_lane == lane and status == 'pending':
                        y = 108 + min(5,ship_id) * 18
                        p.rect(x-6,y,12,6,7);p.text(x+13,y,str(ship_id+1)+' DUE '+str(due),10)
            p.rect(311,216,22,26,7)
        else:
            p.text(28,86,'DOWNSTAIRS / BEAM KEEPS POINTING ' + v['light'],10)
            p.text(28,107,'KNOWN LAB: SCHEDULE REMAINS PUBLIC' if STAGE == 2 else 'NO CURRENT SEA VIEW / THE NOTEBOOK BELOW IS OLD',6)
            for i in range(4):
                p.rect(136+i*94,139,70,68,11 if i<v['battery'] else 5)
            p.text(28,229,'CHARGING: FINISH OR CANCEL' if v['charging'] else 'NOT CHARGING AUTOMATICALLY',10)
        p.text(14,263,'KNOWN LAB SCHEDULE / NO VISIBILITY RULE YET' if STAGE < 3 else 'NOTEBOOK / LAST OBSERVED AT ' + str(v['seen_at']) + (' / LIVE COMPARISON' if STAGE >= 4 and self.compare else ''),10)
        for j,(i,lane,due,status) in enumerate(v['known']):
            p.text(14,283+j*20,'SHIP '+str(i+1)+' / '+lane+' / DUE '+str(due)+' / LEFT '+str(due-v['t'])+' / OBSERVED '+status.upper(),7)
        if STAGE >= 3:
            p.text(14,392,'PUBLIC SPAWN TIMES ' + str(v['spawn_schedule']) + ' / ARRIVED ' + str(v['arrived']),6)
        if STAGE >= 4:
            p.text(14,410,'UNSEEN IDS ' + str(tuple(i+1 for i in v['unseen'])),8)
            radio = [(i+1,lane,due,status) for i,lane,due,status in v['radio']]
            p.text(14,428,'RADIO RETRIES: '+str(radio),11)
        else:
            p.text(14,410,'NO RETRIES IN THIS LAB' if STAGE < 3 else 'RETRY RULE ACTIVE / RADIO DISPLAY IS ADDED IN STAGE 04',6)
        opts = legal(self.state)
        p.text(14,457,'OPTIONS: '+' / '.join(('['+a.upper()+']') if i==self.selection else a.upper() for i,a in enumerate(opts)),10)
        if v['result']:
            p.text(14,478,'RESULT '+v['result'].upper()+(' / LAB ENDED / R RESTART' if STAGE < 3 else ' / OUTER HARBOR IS SAFE / R RESTART'),11 if v['result']=='success' else 8)
        else:
            p.text(14,478,'ONLY AN ARRIVAL USES BATTERY / TURNING COSTS ONE TICK' if STAGE == 1 else 'FINISH ADDS BATTERY BEFORE ARRIVAL CHECKS',6)
        p.text(14,503,self.message,10)
        p.text(14,526,'LEFT/RIGHT SELECT / Z OR ENTER CONFIRM / SPACE WAIT / R RESET / ESC QUIT',7)
        if STAGE >= 2:
            p.text(14,543,'TAB STAIRS (CANCEL WHEN CHARGING) / RELEASE ALL KEYS',6)
        if STAGE >= 3:
            p.text(14,560,'1-3 BOARD' + (' / B DISPLAY ONLY' if STAGE >= 4 else '') + (' / X UNDO ONE TICK' if STAGE == 5 else ''),6)

def demo_keys():
    if STAGE == 1:
        return ('RIGHT','Z','SPACE','SPACE')
    if STAGE == 2:
        return ('TAB','Z','Z')
    return ('SPACE','SPACE','RIGHT','Z','TAB','Z','Z')

def main(headless=False,screenshot=None,state_json=None,demo=False):
    import pyxel as p
    import json
    from pathlib import Path
    p.init(648,584,title='Lighthouse lesson '+str(STAGE),fps=30,display_scale=1,headless=headless)
    app=App();sequence=demo_keys() if demo else ();keynames=('LEFT','RIGHT','Z','RETURN','SPACE','TAB','R','B','X','1','2','3')
    keys={k:getattr(p,'KEY_'+k) for k in keynames};limit=max(8,len(sequence)*2+6)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:
                app.draw(p);p.screen.save(screenshot,1)
            if state_json:
                Path(state_json).write_text(json.dumps({'stage':STAGE,'public':app.view(),'scope':'Actual Linux Pyxel synthetic controls, no human playtest'},ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            p.quit()
        if headless:
            i=p.frame_count//2;pressed={sequence[i]} if demo and p.frame_count%2==0 and i<len(sequence) else set();app.control(pressed,pressed)
        else:app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description='Standalone lighthouse teaching stage')
    parser.add_argument('--headless-smoke',action='store_true');parser.add_argument('--demo-checkpoint',action='store_true');parser.add_argument('--screenshot');parser.add_argument('--state-json')
    args=parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke:parser.error('demo and outputs require --headless-smoke')
    for field,suffix in (('screenshot','.png'),('state_json','.json')):
        value=getattr(args,field)
        if value:
            target=Path(value).resolve()
            if target.suffix.lower()!=suffix or not target.parent.is_dir() or target.exists():parser.error('output must be a new '+suffix+' in an existing directory')
            setattr(args,field,str(target))
    main(args.headless_smoke,args.screenshot,args.state_json,args.demo_checkpoint)
STEP 04 / 05

古い観察帳と現在の無線を分ける

情報の時刻・見せてよい情報だけの写し・表示だけの比較。

04 では、元のゲームの public を加えて、画面が見てよい情報の境目を1か所に集めます。前提は、集合の内包表記、条件式、dict です。state はすべての船の本当の進み方を持っていますが、画面に何でも渡してはいけません。known(知っている情報)は、上の階なら observe(board,s).seen で今の観察を、下の階なら s.seen で最後に上の階にいたときの記録を使います。seen_at も同じ条件で、今の刻か、保存した観察の刻です。

下の階で t が進んでも、観察ノートの due や status は古いままのことがあります。画面の LEFT は、記録された due から今の t を引いた値なので、マイナスになることもあります。古い記録の時刻をはっきり見せるのは、今の海のようすだと思いこませないためです。上の階に戻る操作も1刻かかり、着いたときの observe で更新されます。船が現れる刻だけは、最初から spawn_schedule として公開されています。unseen は、もう現れているのに観察ノートにない船の番号で、航路は含みません。

無線 radio は、retry がある(もう一度通る)船についてだけ、今の航路・もう一度通る予定・状態を知らせます。下の階でも、もう一度の通過の情報は届く、という元のゲームのはっきりした例外です。これを理由に、まだ見ていない最初の通過の船の航路を見せてはいけません。arrived は全体の到着数で、これも元のゲームが公開している情報です。描画は、この public の返り値だけから、航路の図、古いノート、無線を組み立てます。

B は、下の階でも今の海が見える、比較用の表示です。public の compare=True で known を今の情報に更新しますが、元の State.seen は書き換えません。ボタンを押しても、時刻、電池、灯り、船の進み方、最初からのやり直しには影響しません。もう一度 B を押すと、古いノートに戻ります。元のゲームと同じ、表示だけの比較で、苔の待ちを変えて最初からにする079とは違います。次は、05 と元のゲームの Timeline の states/actions、undo/replay を読み、失敗した1刻も丸ごと戻せるようにします。

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

04_observation_radio.py / 注目する部分
 known=tuple((i,lane,due,status) for i,lane,due,status in (observe(board,s).seen if s.floor=='upper' or compare else s.seen))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_observation_radio.py
macOS / Linux
.venv/bin/python 04_observation_radio.py
画面で見ること3 で状況 C、右 → Z で M にして1刻、Tab で下の階へ2刻、Z で充電3刻です。1隻目 L の無線は届きますが、新しく現れた2隻目 R はまだ確認していません。B で2隻目が見えても、3刻・電池2のままです。
  • 選ぶことと、確定することを分ける
  • 情報を見た刻を確かめる
  • 保存→ゲームを閉じる→再実行で、変更が反映される
つまずきやすいところpublic から、observe の返り値を元の状態に代入すると、比較の表示を閉じても古いノートに戻れなくなります。見せるための写しとして、読むだけにします。
少し変えてみよう

App.__init__ の self.compare = False を self.compare = True に変えてみましょう。保存 → Esc で終了 → python 04_observation_radio.py で実行し直します。3 → 右 → Z → Tab → Z を、1つずつ離して入力します。3刻目の下の階でも、2隻目 R を表示します。B を1回押すと「まだ確認していない船2」になり、3刻・電池2・灯り M は同じです。R はコードを読み直しません。自動の確認は python exercises/04_compare.py です。

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

"""081 交代灯台守: 教材段階 4。"""
STAGE = 4

from dataclasses import dataclass, replace, asdict
VERSION='081.1.0'
LANES=('L','M','R')
@dataclass(frozen=True)
class Board:
 id:str;title:str;battery:int;light:str;ships:tuple
BOARDS=(Board('A','晴れた四便',1,'L',((0,2,'L'),(2,6,'M'),(5,10,'R'),(9,14,'L'))),Board('B','同じ航路の列',1,'M',((0,3,'M'),(1,5,'M'),(5,9,'L'),(8,13,'R'),(12,17,'R'))),Board('C','戻って見る理由',2,'L',((0,3,'L'),(3,7,'R'),(6,11,'M'),(9,15,'L'),(13,19,'M'))))
@dataclass(frozen=True)
class Ship:
 due:int;status:str='pending';retry:int=0;first:bool=False
@dataclass(frozen=True)
class State:
 t:int;floor:str;light:str;battery:int;charging:bool;ships:tuple;seen:tuple;seen_at:int;stairs:int=0;charges:int=0;events:tuple=();result:str=''
def observe(board,s):
 seen=tuple((i,lane,sh.due,sh.status) for i,((spawn,_,lane),sh) in enumerate(zip(board.ships,s.ships)) if spawn<=s.t)
 return replace(s,seen=seen,seen_at=s.t)
def new(board):return observe(board,State(0,'upper',board.light,board.battery,False,tuple(Ship(d) for _,d,_ in board.ships),(),0))
def legal(s):
 if s.result:return ()
 if s.charging:return ('finish','cancel')
 return ('L','M','R','down','wait') if s.floor=='upper' else ('charge','up','wait')
def step(board,s,a):
 if a not in legal(s):return s
 f=s.floor;l=s.light;b=s.battery;c=s.charging;stairs=s.stairs;charges=s.charges;e=[]
 if a in LANES:l=a
 elif a in ('down','up','cancel'):f='lower' if a=='down' else 'upper';c=False;stairs+=1
 elif a=='charge':c=True;e.append('充電開始: 次の仕上げで+3')
 elif a=='finish':b=min(4,b+3);c=False;charges+=1;e.append('充電完了: 電池+3 / 上限4')
 t=s.t+1;ships=[]
 for i,((spawn,due,lane),sh) in enumerate(zip(board.ships,s.ships)):
  if sh.status=='pending' and sh.due==t:
   if l==lane and b>=1:b-=1;sh=replace(sh,status='arrived',first=not sh.retry);e.append(f'{i+1}便 案内完了 / {lane}')
   elif not sh.retry:sh=replace(sh,due=t+4,retry=1);e.append(f'{i+1}便 外港へ / {lane} 刻{t+4}に再通過')
   else:sh=replace(sh,status='outer');e.append(f'{i+1}便 外港で待機 / 再挑戦できます')
  ships.append(sh)
 result='success' if all(sh.status=='arrived' for sh in ships) else ('unfinished' if t>=max(d for _,d,_ in board.ships)+4 else '')
 out=State(t,f,l,b,c,tuple(ships),s.seen,s.seen_at,stairs,charges,tuple(e),result)
 return observe(board,out) if f=='upper' else out
def public(board,s,compare=False):
 # Radio reveals only re-entry schedules; downstairs never leaks newly spawned routes.
 known=tuple((i,lane,due,status) for i,lane,due,status in (observe(board,s).seen if s.floor=='upper' or compare else s.seen))
 return {'t':s.t,'floor':s.floor,'light':s.light,'battery':s.battery,'charging':s.charging,'seen_at':s.t if s.floor=='upper' or compare else s.seen_at,'known':known,'radio':tuple((i,board.ships[i][2],sh.due,sh.status) for i,sh in enumerate(s.ships) if sh.retry),'spawn_schedule':tuple(x[0] for x in board.ships),'unseen':tuple(i for i,(sp,_,_) in enumerate(board.ships) if sp<=s.t and i not in {k[0] for k in known}),'arrived':sum(sh.status=='arrived' for sh in s.ships),'result':s.result}

# Teaching interface: ASCII rendering and direct stairs, separate from original Controller.
class App:
    def __init__(self):
        self.level_index = 0
        self.compare = False
        self.gated = False
        self.reset()

    @property
    def board(self):
        return BOARDS[self.level_index] if STAGE >= 3 else None

    @property
    def state(self):
        return self.timeline.state if STAGE == 5 else self.s

    def reset(self):
        if STAGE == 5:
            self.timeline = Timeline(self.board)
        else:
            self.s = new(self.board) if STAGE >= 3 else initial()
        self.selection = 0
        self.message = 'READY / SELECTION DOES NOT ADVANCE TIME'

    def commit(self, action):
        before = self.state
        if STAGE == 5:
            self.timeline.act(action)
        else:
            self.s = step(self.board, self.s, action) if STAGE >= 3 else advance(self.s, action)
        self.selection = 0
        self.message = ('ACCEPTED: ' if before != self.state else 'UNCHANGED: ') + action.upper()

    def press(self, key):
        if key == 'R':
            self.reset()
            return
        if STAGE >= 3 and key in ('1','2','3'):
            self.level_index = int(key) - 1
            self.reset()
            return
        if STAGE >= 4 and key == 'B':
            self.compare = not self.compare
            self.message = 'DISPLAY COMPARISON ONLY / NO TICK OR RESET'
            return
        if STAGE == 5 and key == 'X':
            changed = self.timeline.undo()
            self.selection = 0
            self.message = 'UNDO ONE TICK' if changed else 'NOTHING TO UNDO'
            return
        choices = legal(self.state)
        if not choices:
            return
        if key in ('LEFT','RIGHT'):
            self.selection = (self.selection + (1 if key == 'RIGHT' else -1)) % len(choices)
        elif key in ('Z','RETURN'):
            self.commit(choices[self.selection])
        elif key == 'SPACE':
            self.commit('wait')
        elif STAGE >= 2 and key == 'TAB':
            self.commit('cancel' if self.state.charging else 'down' if self.state.floor == 'upper' else 'up')

    def control(self, pressed, held):
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True
            self.message = 'CHORD REJECTED / RELEASE ALL KEYS'
        elif len(pressed) == 1:
            self.press(next(iter(pressed)))
            self.gated = True

    def view(self):
        s = self.state
        if STAGE >= 4:
            return public(self.board, s, self.compare)
        if STAGE == 3:
            # Stage 03 uses the stored observation. The radio/compare projection is added in 04.
            return {'t':s.t,'floor':s.floor,'light':s.light,'battery':s.battery,'charging':s.charging,
                    'seen_at':s.seen_at,'known':s.seen,'radio':(),
                    'spawn_schedule':tuple(x[0] for x in self.board.ships),
                    'arrived':sum(sh.status=='arrived' for sh in s.ships),'result':s.result}
        return {'t':s.t,'floor':s.floor if STAGE == 2 else 'upper','light':s.light,
                'battery':s.battery,'charging':s.charging if STAGE == 2 else False,
                'seen_at':0,'known':((0,SHIP_LANE,DUE,'arrived' if s.result=='success' else 'missed' if s.result=='missed' else 'pending'),),
                'radio':(),'spawn_schedule':(0,),'arrived':int(s.result=='success'),'result':s.result}

    def draw(self, p):
        v = self.view()
        p.cls(1)
        p.text(14,12,'LIGHTHOUSE SHIFT / STAGE ' + str(STAGE) + (' / BOARD ' + self.board.id if STAGE >= 3 else ' / KNOWN LAB'),10)
        p.text(14,28,'FICTIONAL ROUTES / ONE CONFIRMED ACTION = ONE TICK',6)
        p.text(14,48,'TIME ' + str(v['t']) + ' / FLOOR ' + v['floor'].upper() + ' / LIGHT ' + v['light'] + ' / BATTERY ' + str(v['battery']) + '/4',7)
        p.rect(12,68,624,180,0)
        if v['floor'] == 'upper' or STAGE >= 4 and self.compare:
            for i,lane in enumerate(LANES):
                x = 118 + i * 204
                p.line(x,98,x,205,5)
                if lane == v['light']:
                    p.tri(322,234,x-40,94,x+40,94,4)
                p.text(x-3,82,lane,10)
                for ship_id,known_lane,due,status in v['known']:
                    if known_lane == lane and status == 'pending':
                        y = 108 + min(5,ship_id) * 18
                        p.rect(x-6,y,12,6,7);p.text(x+13,y,str(ship_id+1)+' DUE '+str(due),10)
            p.rect(311,216,22,26,7)
        else:
            p.text(28,86,'DOWNSTAIRS / BEAM KEEPS POINTING ' + v['light'],10)
            p.text(28,107,'KNOWN LAB: SCHEDULE REMAINS PUBLIC' if STAGE == 2 else 'NO CURRENT SEA VIEW / THE NOTEBOOK BELOW IS OLD',6)
            for i in range(4):
                p.rect(136+i*94,139,70,68,11 if i<v['battery'] else 5)
            p.text(28,229,'CHARGING: FINISH OR CANCEL' if v['charging'] else 'NOT CHARGING AUTOMATICALLY',10)
        p.text(14,263,'KNOWN LAB SCHEDULE / NO VISIBILITY RULE YET' if STAGE < 3 else 'NOTEBOOK / LAST OBSERVED AT ' + str(v['seen_at']) + (' / LIVE COMPARISON' if STAGE >= 4 and self.compare else ''),10)
        for j,(i,lane,due,status) in enumerate(v['known']):
            p.text(14,283+j*20,'SHIP '+str(i+1)+' / '+lane+' / DUE '+str(due)+' / LEFT '+str(due-v['t'])+' / OBSERVED '+status.upper(),7)
        if STAGE >= 3:
            p.text(14,392,'PUBLIC SPAWN TIMES ' + str(v['spawn_schedule']) + ' / ARRIVED ' + str(v['arrived']),6)
        if STAGE >= 4:
            p.text(14,410,'UNSEEN IDS ' + str(tuple(i+1 for i in v['unseen'])),8)
            radio = [(i+1,lane,due,status) for i,lane,due,status in v['radio']]
            p.text(14,428,'RADIO RETRIES: '+str(radio),11)
        else:
            p.text(14,410,'NO RETRIES IN THIS LAB' if STAGE < 3 else 'RETRY RULE ACTIVE / RADIO DISPLAY IS ADDED IN STAGE 04',6)
        opts = legal(self.state)
        p.text(14,457,'OPTIONS: '+' / '.join(('['+a.upper()+']') if i==self.selection else a.upper() for i,a in enumerate(opts)),10)
        if v['result']:
            p.text(14,478,'RESULT '+v['result'].upper()+(' / LAB ENDED / R RESTART' if STAGE < 3 else ' / OUTER HARBOR IS SAFE / R RESTART'),11 if v['result']=='success' else 8)
        else:
            p.text(14,478,'ONLY AN ARRIVAL USES BATTERY / TURNING COSTS ONE TICK' if STAGE == 1 else 'FINISH ADDS BATTERY BEFORE ARRIVAL CHECKS',6)
        p.text(14,503,self.message,10)
        p.text(14,526,'LEFT/RIGHT SELECT / Z OR ENTER CONFIRM / SPACE WAIT / R RESET / ESC QUIT',7)
        if STAGE >= 2:
            p.text(14,543,'TAB STAIRS (CANCEL WHEN CHARGING) / RELEASE ALL KEYS',6)
        if STAGE >= 3:
            p.text(14,560,'1-3 BOARD' + (' / B DISPLAY ONLY' if STAGE >= 4 else '') + (' / X UNDO ONE TICK' if STAGE == 5 else ''),6)

def demo_keys():
    if STAGE == 1:
        return ('RIGHT','Z','SPACE','SPACE')
    if STAGE == 2:
        return ('TAB','Z','Z')
    return ('SPACE','SPACE','RIGHT','Z','TAB','Z','Z')

def main(headless=False,screenshot=None,state_json=None,demo=False):
    import pyxel as p
    import json
    from pathlib import Path
    p.init(648,584,title='Lighthouse lesson '+str(STAGE),fps=30,display_scale=1,headless=headless)
    app=App();sequence=demo_keys() if demo else ();keynames=('LEFT','RIGHT','Z','RETURN','SPACE','TAB','R','B','X','1','2','3')
    keys={k:getattr(p,'KEY_'+k) for k in keynames};limit=max(8,len(sequence)*2+6)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:
                app.draw(p);p.screen.save(screenshot,1)
            if state_json:
                Path(state_json).write_text(json.dumps({'stage':STAGE,'public':app.view(),'scope':'Actual Linux Pyxel synthetic controls, no human playtest'},ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            p.quit()
        if headless:
            i=p.frame_count//2;pressed={sequence[i]} if demo and p.frame_count%2==0 and i<len(sequence) else set();app.control(pressed,pressed)
        else:app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description='Standalone lighthouse teaching stage')
    parser.add_argument('--headless-smoke',action='store_true');parser.add_argument('--demo-checkpoint',action='store_true');parser.add_argument('--screenshot');parser.add_argument('--state-json')
    args=parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke:parser.error('demo and outputs require --headless-smoke')
    for field,suffix in (('screenshot','.png'),('state_json','.json')):
        value=getattr(args,field)
        if value:
            target=Path(value).resolve()
            if target.suffix.lower()!=suffix or not target.parent.is_dir() or target.exists():parser.error('output must be a new '+suffix+' in an existing directory')
            setattr(args,field,str(target))
    main(args.headless_smoke,args.screenshot,args.state_json,args.demo_checkpoint)
STEP 05 / 05

全状態を一刻戻し、入力を再生する

履歴・再現の確認用ハッシュ・終わったあとからの Undo。

05 では、元のゲームの model.py を全部入れ、Timeline、digest、POLICIES を加えます。前提は、list の append/pop、スライス、asdict、JSON です。Timeline は最初の状態を states[0] に入れ、actions は空で始めます。act は step を呼び、状態が変わらなければ False を返して、履歴を増やしません。できる操作で状態が変わったときだけ、新しい状態と行動を加えます。向きを選んでいるだけ、充電中の wait、結果が出たあとの確定では、履歴は増えません。

undo は、states と actions を1件ずつ取り出し(pop)ます。電池だけを足して戻す操作ではなく、階、灯り、船のもう一度の通過、観察ノート、観察した時刻、充電の途中、自由な記録、結果を含む、状態全体が戻ります。X は1刻戻す操作です。たとえば、6刻目の finish で充電と M の船の到着を同時に起こしてから X を押すと、5刻目の charging=True に戻り、電池や船もその時点に戻ります。結果の画面からでも X を使えます。最初の状態では False で、何も変わりません。

replay は、版、Board、意味のある入力、すべての刻、それぞれの状態のハッシュ、最後の状態を、辞書にまとめます。digest は、キーの順番を決めた JSON を UTF-8 に変えて、SHA-256(内容から作る確認用の値)を作ります。同じ盤面と同じ意味の入力なら、同じ状態の列になるかを、別の Timeline で再生して比べられます。これは、セーブファイルの認証や暗号化ではありません。元のゲームの POLICIES は確かめ済みの入力の例で、一番短い・ただ1つの答え、という主張ではありません。

A_first は1回の充電で4隻、A_outer は4隻とも外の港を通って、18刻で達成します。B_first は2回の充電で5隻、C_once は1回の充電で5隻、C_first は2回充電する別の手順です。最初の通過で案内した数は記録ですが、外の港を通っても、全部到着すれば同じ success です。元のゲームのデータとルールはすべて残していますが、教材の画面は英数字(ASCII)、表紙なし、Tab ですぐ確定、R ですぐ最初に戻す、です。元のゲームの Controller の階段・やり直しの確認、説明の表示、S/F12、長いデモは入れていません。

次に、元のゲームの controller.py の feed の確認の分かれ道とキーを待つ仕組み(release gate)、app.py の record/save と、下の階での海の表示の条件を読むと、記録と安全な操作の確認をつなげられます。元のゲームの replay の全部の状態には、まだ確認していない船の中の情報も含まれるので、ふつうの教材の JSON の出力は、public の情報だけにしています。

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

05_timeline_undo.py / 注目する部分
 def undo(self):
  if len(self.states)<2:return False
  self.states.pop();self.actions.pop();return True

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_timeline_undo.py
macOS / Linux
.venv/bin/python 05_timeline_undo.py
画面で見ること状況 A で Space を2回 → 右 → Z → Tab → Z → Z で、6刻・下の階・電池2です。X で5刻目の充電中・電池0に戻り、Z でまた同じ6刻目に戻ります。B を切り替えても、履歴は増えません。
  • 選ぶことと、確定することを分ける
  • 情報を見た刻を確かめる
  • 保存→ゲームを閉じる→再実行で、変更が反映される
つまずきやすいところundo で t だけを減らすと、船や観察ノートが未来のまま残ってしまいます。状態全体の保存と、actions の削除を、同じ単位で行います。
少し変えてみよう

App.__init__ の self.level_index = 0 を self.level_index = 1 に変えてみましょう。保存 → Esc で終了 → python 05_timeline_undo.py で実行し直します。状況 B で Space → Tab → Z → Z と押すと、4刻・下の階・灯り M・電池3・到着1隻です。X で3刻目の充電中・電池0に戻り、Z で4刻目の同じ状態に戻ります。キーは1つずつ離します。R はコードを読み直しません。自動の確認は python exercises/05_undo.py です。

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

"""081 交代灯台守: 教材段階 5。"""
STAGE = 5

"""081 immutable turn model. Only decisions advance time; no rendering imports."""
from dataclasses import dataclass, replace, asdict
import hashlib,json
VERSION='081.1.0'
LANES=('L','M','R')
@dataclass(frozen=True)
class Board:
 id:str;title:str;battery:int;light:str;ships:tuple
BOARDS=(Board('A','晴れた四便',1,'L',((0,2,'L'),(2,6,'M'),(5,10,'R'),(9,14,'L'))),Board('B','同じ航路の列',1,'M',((0,3,'M'),(1,5,'M'),(5,9,'L'),(8,13,'R'),(12,17,'R'))),Board('C','戻って見る理由',2,'L',((0,3,'L'),(3,7,'R'),(6,11,'M'),(9,15,'L'),(13,19,'M'))))
@dataclass(frozen=True)
class Ship:
 due:int;status:str='pending';retry:int=0;first:bool=False
@dataclass(frozen=True)
class State:
 t:int;floor:str;light:str;battery:int;charging:bool;ships:tuple;seen:tuple;seen_at:int;stairs:int=0;charges:int=0;events:tuple=();result:str=''
def observe(board,s):
 seen=tuple((i,lane,sh.due,sh.status) for i,((spawn,_,lane),sh) in enumerate(zip(board.ships,s.ships)) if spawn<=s.t)
 return replace(s,seen=seen,seen_at=s.t)
def new(board):return observe(board,State(0,'upper',board.light,board.battery,False,tuple(Ship(d) for _,d,_ in board.ships),(),0))
def legal(s):
 if s.result:return ()
 if s.charging:return ('finish','cancel')
 return ('L','M','R','down','wait') if s.floor=='upper' else ('charge','up','wait')
def step(board,s,a):
 if a not in legal(s):return s
 f=s.floor;l=s.light;b=s.battery;c=s.charging;stairs=s.stairs;charges=s.charges;e=[]
 if a in LANES:l=a
 elif a in ('down','up','cancel'):f='lower' if a=='down' else 'upper';c=False;stairs+=1
 elif a=='charge':c=True;e.append('充電開始: 次の仕上げで+3')
 elif a=='finish':b=min(4,b+3);c=False;charges+=1;e.append('充電完了: 電池+3 / 上限4')
 t=s.t+1;ships=[]
 for i,((spawn,due,lane),sh) in enumerate(zip(board.ships,s.ships)):
  if sh.status=='pending' and sh.due==t:
   if l==lane and b>=1:b-=1;sh=replace(sh,status='arrived',first=not sh.retry);e.append(f'{i+1}便 案内完了 / {lane}')
   elif not sh.retry:sh=replace(sh,due=t+4,retry=1);e.append(f'{i+1}便 外港へ / {lane} 刻{t+4}に再通過')
   else:sh=replace(sh,status='outer');e.append(f'{i+1}便 外港で待機 / 再挑戦できます')
  ships.append(sh)
 result='success' if all(sh.status=='arrived' for sh in ships) else ('unfinished' if t>=max(d for _,d,_ in board.ships)+4 else '')
 out=State(t,f,l,b,c,tuple(ships),s.seen,s.seen_at,stairs,charges,tuple(e),result)
 return observe(board,out) if f=='upper' else out
def public(board,s,compare=False):
 # Radio reveals only re-entry schedules; downstairs never leaks newly spawned routes.
 known=tuple((i,lane,due,status) for i,lane,due,status in (observe(board,s).seen if s.floor=='upper' or compare else s.seen))
 return {'t':s.t,'floor':s.floor,'light':s.light,'battery':s.battery,'charging':s.charging,'seen_at':s.t if s.floor=='upper' or compare else s.seen_at,'known':known,'radio':tuple((i,board.ships[i][2],sh.due,sh.status) for i,sh in enumerate(s.ships) if sh.retry),'spawn_schedule':tuple(x[0] for x in board.ships),'unseen':tuple(i for i,(sp,_,_) in enumerate(board.ships) if sp<=s.t and i not in {k[0] for k in known}),'arrived':sum(sh.status=='arrived' for sh in s.ships),'result':s.result}
def digest(s):return hashlib.sha256(json.dumps(asdict(s) if hasattr(s,'__dataclass_fields__') else s,ensure_ascii=False,sort_keys=True,separators=(',',':')).encode('utf-8')).hexdigest()
class Timeline:
 def __init__(self,board):self.board=board;self.states=[new(board)];self.actions=[]
 @property
 def state(self):return self.states[-1]
 def act(self,a):
  n=step(self.board,self.state,a)
  if n==self.state:return False
  self.states.append(n);self.actions.append(a);return True
 def undo(self):
  if len(self.states)<2:return False
  self.states.pop();self.actions.pop();return True
 def replay(self):return {'version':VERSION,'board':asdict(self.board),'actions':self.actions[:],'ticks':[s.t for s in self.states],'hashes':[digest(s) for s in self.states],'final':asdict(self.state)}
# Explicit verified candidates, not optimality claims. Expanded below by tools/verify_policies.py.
POLICIES={'A_first':('wait','wait','M','down','charge','finish','up','R','wait','wait','L','wait','wait','wait'),'A_outer':('M','down','charge','finish','up','L','M','wait','wait','wait','R','wait','wait','wait','L','wait','wait','wait'),'B_first':('wait','down','charge','finish','wait','up','L','wait','wait','R','down','charge','finish','up','wait','wait','wait'),'C_first':('wait','wait','wait','R','down','charge','finish','up','M','wait','wait','L','down','charge','finish','up','M','wait','wait')}

POLICIES['C_once']=('wait','wait','wait','R','down','charge','finish','up','M','wait','wait','L','wait','wait','wait','M','wait','wait','wait')

# Teaching interface: ASCII rendering and direct stairs, separate from original Controller.
class App:
    def __init__(self):
        self.level_index = 0
        self.compare = False
        self.gated = False
        self.reset()

    @property
    def board(self):
        return BOARDS[self.level_index] if STAGE >= 3 else None

    @property
    def state(self):
        return self.timeline.state if STAGE == 5 else self.s

    def reset(self):
        if STAGE == 5:
            self.timeline = Timeline(self.board)
        else:
            self.s = new(self.board) if STAGE >= 3 else initial()
        self.selection = 0
        self.message = 'READY / SELECTION DOES NOT ADVANCE TIME'

    def commit(self, action):
        before = self.state
        if STAGE == 5:
            self.timeline.act(action)
        else:
            self.s = step(self.board, self.s, action) if STAGE >= 3 else advance(self.s, action)
        self.selection = 0
        self.message = ('ACCEPTED: ' if before != self.state else 'UNCHANGED: ') + action.upper()

    def press(self, key):
        if key == 'R':
            self.reset()
            return
        if STAGE >= 3 and key in ('1','2','3'):
            self.level_index = int(key) - 1
            self.reset()
            return
        if STAGE >= 4 and key == 'B':
            self.compare = not self.compare
            self.message = 'DISPLAY COMPARISON ONLY / NO TICK OR RESET'
            return
        if STAGE == 5 and key == 'X':
            changed = self.timeline.undo()
            self.selection = 0
            self.message = 'UNDO ONE TICK' if changed else 'NOTHING TO UNDO'
            return
        choices = legal(self.state)
        if not choices:
            return
        if key in ('LEFT','RIGHT'):
            self.selection = (self.selection + (1 if key == 'RIGHT' else -1)) % len(choices)
        elif key in ('Z','RETURN'):
            self.commit(choices[self.selection])
        elif key == 'SPACE':
            self.commit('wait')
        elif STAGE >= 2 and key == 'TAB':
            self.commit('cancel' if self.state.charging else 'down' if self.state.floor == 'upper' else 'up')

    def control(self, pressed, held):
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True
            self.message = 'CHORD REJECTED / RELEASE ALL KEYS'
        elif len(pressed) == 1:
            self.press(next(iter(pressed)))
            self.gated = True

    def view(self):
        s = self.state
        if STAGE >= 4:
            return public(self.board, s, self.compare)
        if STAGE == 3:
            # Stage 03 uses the stored observation. The radio/compare projection is added in 04.
            return {'t':s.t,'floor':s.floor,'light':s.light,'battery':s.battery,'charging':s.charging,
                    'seen_at':s.seen_at,'known':s.seen,'radio':(),
                    'spawn_schedule':tuple(x[0] for x in self.board.ships),
                    'arrived':sum(sh.status=='arrived' for sh in s.ships),'result':s.result}
        return {'t':s.t,'floor':s.floor if STAGE == 2 else 'upper','light':s.light,
                'battery':s.battery,'charging':s.charging if STAGE == 2 else False,
                'seen_at':0,'known':((0,SHIP_LANE,DUE,'arrived' if s.result=='success' else 'missed' if s.result=='missed' else 'pending'),),
                'radio':(),'spawn_schedule':(0,),'arrived':int(s.result=='success'),'result':s.result}

    def draw(self, p):
        v = self.view()
        p.cls(1)
        p.text(14,12,'LIGHTHOUSE SHIFT / STAGE ' + str(STAGE) + (' / BOARD ' + self.board.id if STAGE >= 3 else ' / KNOWN LAB'),10)
        p.text(14,28,'FICTIONAL ROUTES / ONE CONFIRMED ACTION = ONE TICK',6)
        p.text(14,48,'TIME ' + str(v['t']) + ' / FLOOR ' + v['floor'].upper() + ' / LIGHT ' + v['light'] + ' / BATTERY ' + str(v['battery']) + '/4',7)
        p.rect(12,68,624,180,0)
        if v['floor'] == 'upper' or STAGE >= 4 and self.compare:
            for i,lane in enumerate(LANES):
                x = 118 + i * 204
                p.line(x,98,x,205,5)
                if lane == v['light']:
                    p.tri(322,234,x-40,94,x+40,94,4)
                p.text(x-3,82,lane,10)
                for ship_id,known_lane,due,status in v['known']:
                    if known_lane == lane and status == 'pending':
                        y = 108 + min(5,ship_id) * 18
                        p.rect(x-6,y,12,6,7);p.text(x+13,y,str(ship_id+1)+' DUE '+str(due),10)
            p.rect(311,216,22,26,7)
        else:
            p.text(28,86,'DOWNSTAIRS / BEAM KEEPS POINTING ' + v['light'],10)
            p.text(28,107,'KNOWN LAB: SCHEDULE REMAINS PUBLIC' if STAGE == 2 else 'NO CURRENT SEA VIEW / THE NOTEBOOK BELOW IS OLD',6)
            for i in range(4):
                p.rect(136+i*94,139,70,68,11 if i<v['battery'] else 5)
            p.text(28,229,'CHARGING: FINISH OR CANCEL' if v['charging'] else 'NOT CHARGING AUTOMATICALLY',10)
        p.text(14,263,'KNOWN LAB SCHEDULE / NO VISIBILITY RULE YET' if STAGE < 3 else 'NOTEBOOK / LAST OBSERVED AT ' + str(v['seen_at']) + (' / LIVE COMPARISON' if STAGE >= 4 and self.compare else ''),10)
        for j,(i,lane,due,status) in enumerate(v['known']):
            p.text(14,283+j*20,'SHIP '+str(i+1)+' / '+lane+' / DUE '+str(due)+' / LEFT '+str(due-v['t'])+' / OBSERVED '+status.upper(),7)
        if STAGE >= 3:
            p.text(14,392,'PUBLIC SPAWN TIMES ' + str(v['spawn_schedule']) + ' / ARRIVED ' + str(v['arrived']),6)
        if STAGE >= 4:
            p.text(14,410,'UNSEEN IDS ' + str(tuple(i+1 for i in v['unseen'])),8)
            radio = [(i+1,lane,due,status) for i,lane,due,status in v['radio']]
            p.text(14,428,'RADIO RETRIES: '+str(radio),11)
        else:
            p.text(14,410,'NO RETRIES IN THIS LAB' if STAGE < 3 else 'RETRY RULE ACTIVE / RADIO DISPLAY IS ADDED IN STAGE 04',6)
        opts = legal(self.state)
        p.text(14,457,'OPTIONS: '+' / '.join(('['+a.upper()+']') if i==self.selection else a.upper() for i,a in enumerate(opts)),10)
        if v['result']:
            p.text(14,478,'RESULT '+v['result'].upper()+(' / LAB ENDED / R RESTART' if STAGE < 3 else ' / OUTER HARBOR IS SAFE / R RESTART'),11 if v['result']=='success' else 8)
        else:
            p.text(14,478,'ONLY AN ARRIVAL USES BATTERY / TURNING COSTS ONE TICK' if STAGE == 1 else 'FINISH ADDS BATTERY BEFORE ARRIVAL CHECKS',6)
        p.text(14,503,self.message,10)
        p.text(14,526,'LEFT/RIGHT SELECT / Z OR ENTER CONFIRM / SPACE WAIT / R RESET / ESC QUIT',7)
        if STAGE >= 2:
            p.text(14,543,'TAB STAIRS (CANCEL WHEN CHARGING) / RELEASE ALL KEYS',6)
        if STAGE >= 3:
            p.text(14,560,'1-3 BOARD' + (' / B DISPLAY ONLY' if STAGE >= 4 else '') + (' / X UNDO ONE TICK' if STAGE == 5 else ''),6)

def demo_keys():
    if STAGE == 1:
        return ('RIGHT','Z','SPACE','SPACE')
    if STAGE == 2:
        return ('TAB','Z','Z')
    return ('SPACE','SPACE','RIGHT','Z','TAB','Z','Z')

def main(headless=False,screenshot=None,state_json=None,demo=False):
    import pyxel as p
    import json
    from pathlib import Path
    p.init(648,584,title='Lighthouse lesson '+str(STAGE),fps=30,display_scale=1,headless=headless)
    app=App();sequence=demo_keys() if demo else ();keynames=('LEFT','RIGHT','Z','RETURN','SPACE','TAB','R','B','X','1','2','3')
    keys={k:getattr(p,'KEY_'+k) for k in keynames};limit=max(8,len(sequence)*2+6)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:
                app.draw(p);p.screen.save(screenshot,1)
            if state_json:
                Path(state_json).write_text(json.dumps({'stage':STAGE,'public':app.view(),'scope':'Actual Linux Pyxel synthetic controls, no human playtest'},ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            p.quit()
        if headless:
            i=p.frame_count//2;pressed={sequence[i]} if demo and p.frame_count%2==0 and i<len(sequence) else set();app.control(pressed,pressed)
        else:app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description='Standalone lighthouse teaching stage')
    parser.add_argument('--headless-smoke',action='store_true');parser.add_argument('--demo-checkpoint',action='store_true');parser.add_argument('--screenshot');parser.add_argument('--state-json')
    args=parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke:parser.error('demo and outputs require --headless-smoke')
    for field,suffix in (('screenshot','.png'),('state_json','.json')):
        value=getattr(args,field)
        if value:
            target=Path(value).resolve()
            if target.suffix.lower()!=suffix or not target.parent.is_dir() or target.exists():parser.error('output must be a new '+suffix+' in an existing directory')
            setattr(args,field,str(target))
    main(args.headless_smoke,args.screenshot,args.state_json,args.demo_checkpoint)
AFTER STEP 05

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

ルールのまとめ:1回の確定で1刻。灯りの向きを変えられるのは上の階だけ。充電の開始と仕上げで +3、上限4で、仕上げた刻の船にも使える。見逃した船は、+4刻で一度だけもう一度通る。全部の船が到着すれば成功。

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

比べる点5ステップの教材元の完成版
段階01 1隻の船と灯りの向き、02 わかっている予定での充電、03 元のゲームのすべての状態の変化と観察、04 見せてよい情報と無線、05 model 全部と Timeline。最初からルール全部。
初期値01 は M の船が3刻目・灯り L・電池1、02 は同じ船・灯り M・電池0。03 からは元のゲームの状況 A/B/C。BOARDS の3つの勤務。
操作英数字(ASCII)で648×584、Tab ですぐ確定、R ですぐ最初に戻す、表紙なし。日本語で900×660、階段・やり直しの確認、説明、S/F12。
情報03 は観察ノートだけ。04/05 で元のゲームの public を使い、もう一度の通過の無線と表示の比較。ふつうの JSON も public の情報だけ。描画は public、再現用の記録には中の状態全部も保存。

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

  1. model.py の legal/step:充電 → 時刻 → ID の順に案内
  2. model.py の observe/public:seen_at と radio
  3. model.py の Timeline.act/undo/replay/digest
  4. model.py の POLICIES:A_outer/C_once
  5. controller.py の feed:確認とキーを待つ仕組み(gate)
  6. app.py の record/save/sea
自分のゲームに変えるなら

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

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