完成サンプルをブラウザーで遊ぶ
この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。
完成版の主な操作:矢印キーで泳ぐ、Zで本を抜く・拾う・返す・渡す、Xで本を置く、Pで一時停止、停止中にBackspaceで空気のある時点へ戻る
ボタンを押すと、ゲームの読み込みが始まります。
画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。
うまく表示されないとき
ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。
人だけを入口へ戻す救助と、世界全体の巻き戻しを分ける。
いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、5つのステップで作っていきます。どのステップのファイルもそれだけで動くので、動きを確かめてから次へ進みましょう。
- 必要な環境
- Python 3.11以上 / Pyxel 2.9.9
- 知っておくとよいこと
- Pythonの変数、if、for、関数、tuple(タプル)の基本 / dataclass・replace・ビット演算(整数を0と1の並びとして使う計算)は本文と合わせて読む / 05ではset(集合)・JSON・Path・例外も使う
- 教材に入っているもの
- ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
- 完成した教材の操作
- 矢印:長押しで泳ぐ。手ぶらなら2刻、本を持つと3刻で一マス / Z:02から、棚や床の本を取る・入口で返す。04からはロボットへ渡す / X:02から、空いている床に本を置く。05の確認画面では取り消し / P:時間を止める・再開する。切り替えたあとは全部のキーを一度離す / 1・2・3:01〜04では館を選んで最初の状態にする。05では表紙(タイトル画面)でだけ使う / B:03〜04では水流のONとOFFを切り替えて、最初の状態にする。05では表紙でだけ使う / SpaceとEnter:05の表紙で開始。Enterは確認にも使う / Backspace:05の一時停止中に、直前の空気のある時点へ世界全体を巻き戻す / SとL:05で保存と読み込み(読み込むと一時停止になる)。保存できるのは入口・空気のある場所・結果画面 / H・M・F12:05で説明、表紙へ戻る確認、画面のPNG保存 / R:今読み込んでいる同じ館を最初の状態にする。05では確認あり。コードを読み直すわけではない / Esc:01〜04では終了、05では終了の確認
- 難易度の目安
- 中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
- 学習順に迷ったら
- おすすめの学ぶ順番を見る
コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。
教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。
刻を数えて書架へ泳ぐ
書き換えない状態、移動の待ち時間、通れるかの判定、探索を記録するビット。
ここで使う前提は、Pythonの変数、if、for、関数、tuple(タプル)の添字(何番目かを表す番号)です。dataclass(frozen=True) は、状態をひとまとめにした、書き換えられない記録だと考えます。replace は、一部だけを変えた新しい記録を作るものです。座標は左上が(0,0)で、右へ行くほど x が増え、下へ行くほど y が増えます。館の広さは幅9・高さ5で、入口Oは(0,2)です。館Aの最初の書架(本棚)は(3,2)にあり、本を抜く前は壁と同じです。この段階では本を抜けません。そこで、まずとなりの(2,2)まで泳いで、本の題名を見つけることを目標にします。暗い床は、まだ探索していない場所です。ただし、壁と書架の形は最初から見えるようにしています。
画面の描画は30fps(1秒に30回)です。モデル(ゲームのルールの部分)を1回進める単位を「刻」と呼びます。刻は6フレームごと、つまり0.2秒ごとに進みます。キーを押していなくても刻は進みます。ただし、この段階では酸素は減らず、ロボットも動きません。手ぶらのときの移動の待ち時間 wait は、最大2までたまります。方向キーを入れた刻に wait が2以上なら、となりのマスを調べます。通れるなら pos(位置)を変えて、wait を0に戻します。壁なら、wait はたまったままです。最初の状態から右を2刻続けて押すと(1,2)へ、4刻で(2,2)へ進みます。止まって待ち時間をためたあとなら、方向キーを押した最初の刻で移動できます。
State、Input、館A・B・Cの定数、持ち主の番号、あとの段階で使う関数の形は、先に置いてあります。これは、あとで使うための足場です。01の step には、本の操作、水流、ロボット、酸素が減る処理、救助の処理はありません。carry、drift、restore などが「定義されていること」と、「ゲームの更新で実際に使われること」は別だと考えましょう。Rで最初の状態に戻しても、ファイルは読み直しません。
explored は、訪れたマスの集合を、1つの整数のビット(0と1の並び)で表したものです。y*W+x 番目のビットを1にして、OR(ビットごとの「または」)で元の集合に加えます。revealed は、三つの書架それぞれについて別のビットを持ちます。書架とのマンハッタン距離(縦と横の差を足した距離)が1以下になると、その書架の題名だけを公開します。表示用の文字を作る visible_books は、まだ公開していない題名を ??? に置き換えます。元の状態は変えません。最初からすべての題名を画面に出してしまうと、この「見つける」仕組みがなくなってしまいます。
下のコードは 01_swim.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if (a.dx or a.dy) and wait>=speed:
q=(pos[0]+a.dx,pos[1]+a.dy)
if walkable(s,q):pos=q;wait=0;events.append('move')
else:events.append('wall')実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 01_swim.py.venv/bin/python 01_swim.py- フレームと刻を区別する(6フレームで一刻)
- 棚と壁を、通れるかの判定に入れる
- 題名は、近づいたときだけ公開する
wait を毎刻0に戻すと、いつまでたっても泳げません。描画の中で step を呼ぶと、画面を描いただけでゲームが進んでしまいます。01_swim.py の speed=3 if carry(s) is not None else 2 を、speed=3 if carry(s) is not None else 1 に1か所だけ変えます。保存して、Escで終了し、python 01_swim.py でもう一度実行します。手ぶらなら1刻で一マス進むようになります。python exercises/01_faster_swim.py は、変更した版を実行し、RIGHT(右)を6フレーム入力して「tick 1, pos (1, 2)」になることを確かめます。Rはコードの読み直しではありません。確かめたら、2に戻してください。
このステップのコード全文(308行)
# 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.
"""092 海底図書館・教材 1。原作と省略点は LESSON.md を参照。"""
STAGE = 1
"""092 pure 0.2-second tick model: movement, books, current, robot, oxygen."""
from dataclasses import dataclass,asdict,replace
import hashlib,json
VERSION='1.0.0';GAME='092';O=(0,2);W=9;H=5
TITLES={'A':'横の水路','B':'奥へ運ぶ流れ','C':'上下の本'}
BOOK_TITLES=('帰り道の潮汐図','眠る珊瑚のうた','小さな灯台の記憶')
WALLS={'A':((3,0),(3,1),(3,3),(3,4)),'B':((4,0),(4,1),(4,3),(4,4)),'C':((2,1),(3,1),(5,3),(6,3))}
SHELVES={'A':((3,2),(6,0),(7,4)),'B':((4,2),(7,1),(7,3)),'C':((4,1),(4,3),(8,2))}
AIR={'A':((5,2),),'B':((6,2),),'C':((4,2),(7,4))}
BANDS={'A':((((3,2),(4,2),(5,2)),(-1,0)),(((4,0),(5,0),(6,0)),(-1,0)),(((5,4),(6,4),(7,4)),(-1,0))), 'B':((((2,2),(3,2),(4,2)),(1,0)),(((5,1),(6,1),(7,1)),(-1,0)),(((5,3),(6,3),(7,3)),(-1,0))), 'C':((((4,0),(4,1)),(0,1)),(((4,3),(4,4)),(0,-1)),(((6,2),(7,2),(8,2)),(-1,0)))}
# owner: 0=unopened shelf, 1=floor, 2=player, 3=robot, 4=returned.
@dataclass(frozen=True)
class State:
case:str='A';pos:tuple=O;wait:int=0;oxygen:int=60;books:tuple=((0,3,2),(0,6,0),(0,7,4));opened:int=0;explored:int=1<<18;revealed:int=0;ridx:int=0;tick:int=0;rescues:int=0;returned_order:tuple=();robot_returns:int=0;currents:bool=True;history:tuple=();last:tuple=();longest:int=0;air_gap:int=0
@dataclass(frozen=True)
class Input:
dx:int=0;dy:int=0;op:str=''
def new(case='A',currents=True):return State(case=case,books=tuple((0,x,y) for x,y in SHELVES[case]),currents=currents)
def route(case):
n={'A':2,'B':3,'C':4}[case];return tuple((x,2) for x in range(n+1))+tuple((x,2) for x in range(n-1,0,-1))
def robot(s):return route(s.case)[s.ridx]
def carry(s,who=2):return next((i for i,b in enumerate(s.books) if b[0]==who),None)
def distance(a,b):return abs(a[0]-b[0])+abs(a[1]-b[1])
def walkable(s,p):
return 0<=p[0]<W and 0<=p[1]<H and p not in WALLS[s.case] and not any(b[0]==0 and tuple(b[1:])==p for b in s.books)
def drift(s,p):
if s.currents:
for i,(cells,d) in enumerate(BANDS[s.case]):
if s.opened&(1<<i) and p in cells:
q=(p[0]+d[0],p[1]+d[1]);return q if walkable(s,q) else p
return p
def air(s):return s.pos==O or s.pos in AIR[s.case]
def success(s):return all(b[0]==4 for b in s.books)
def step(s,a=Input(),record=True):
if success(s):return s
if type(a.dx)!=int or type(a.dy)!=int or abs(a.dx)+abs(a.dy)>1 or a.op not in ('','z','x'):raise ValueError('意味入力が不正')
t=s.tick+1;pos=s.pos;books=list(s.books);opened=s.opened;revealed=s.revealed;events=[];order=s.returned_order;rr=s.robot_returns;speed=3 if carry(s) is not None else 2;wait=min(speed,s.wait+1)
if (a.dx or a.dy) and wait>=speed:
q=(pos[0]+a.dx,pos[1]+a.dy)
if walkable(s,q):pos=q;wait=0;events.append('move')
else:events.append('wall')
explored=s.explored|(1<<(pos[1]*W+pos[0]))
for i,p in enumerate(SHELVES[s.case]):
if distance(pos,p)<=1:revealed|=1<<i
for i in order:revealed|=1<<i
return replace(s,pos=pos,wait=wait,books=tuple(books),opened=opened,explored=explored,revealed=revealed,tick=t,returned_order=order,history=s.history+((a.dx,a.dy,a.op),) if record else s.history,last=tuple(events))
def digest(s):return hashlib.sha256(json.dumps(asdict(s),sort_keys=True,ensure_ascii=False).encode('utf-8')).hexdigest()
def restore(d):
if not isinstance(d,dict) or set(d)!=set(asdict(new())):raise ValueError('状態の項目が違います')
if d['case'] not in TITLES or type(d['currents'])!=bool:raise ValueError('状況が不正')
if type(d['tick'])!=int or not 0<=d['tick']<=100000 or not isinstance(d['history'],(list,tuple)) or len(d['history'])!=d['tick']:raise ValueError('履歴が不正')
s=new(d['case'],d['currents'])
for e in d['history']:
if not isinstance(e,(list,tuple)) or len(e)!=3:raise ValueError('入力履歴が不正')
s=step(s,Input(*e))
if json.loads(json.dumps(asdict(s)))!=json.loads(json.dumps(d)):raise ValueError('履歴と状態が一致しません')
return s
from pathlib import Path
KEYS={'UP','DOWN','LEFT','RIGHT','Z','X','P','R','1','2','3','B','ESCAPE'}
DIR={'LEFT':(-1,0),'RIGHT':(1,0),'UP':(0,-1),'DOWN':(0,1)}
class LessonController:
"""Teaching controls. Original title, help, saves and checkpoint Undo arrive in 05."""
def __init__(self,path=None):
self.s=new();self.checkpoint=self.s;self.previous=set();self.blocked=set();self.gate=False;self.frame=0;self.pending='';self.paused=False;self.title=False;self.help=False;self.dialog='';self.notice=''
def reset(self,case=None):
self.s=new(case or self.s.case,self.s.currents);self.checkpoint=self.s;self.frame=0;self.pending='';self.paused=False
def transition(self):self.gate=True;self.pending='';self.frame=0;self.blocked|=self.previous
def advance(self,a):
before=self.s;self.s=step(self.s,a)
if air(self.s):self.checkpoint=self.s
return 'tick' if self.s!=before else 'terminal'
def feed(self,down):
down=set(down)&KEYS;fresh=down-self.previous;self.previous=down;self.blocked&=down
if self.gate:
self.blocked|=down
if not down:self.gate=False
return None
commands=fresh-set(DIR)
if commands:
if len(commands)>1:self.transition();return 'ignored:chord'
k=next(iter(commands))
if k=='ESCAPE':return 'quit'
if k=='P':self.paused=not self.paused;self.transition();return 'pause'
if k=='R':self.reset();self.transition();return 'reset'
if k in ('1','2','3'):self.reset('ABC'[int(k)-1]);self.transition();return 'case'
if k=='B' and STAGE>=3:self.s=new(self.s.case,not self.s.currents);self.checkpoint=self.s;self.transition();return 'variant'
if k in ('Z','X') and STAGE>=2 and not self.paused:self.pending=k.lower()
if self.paused or success(self.s):return None
self.frame+=1
if self.frame%6:return None
active=down-self.blocked;dx=int('RIGHT'in active)-int('LEFT'in active);dy=int('DOWN'in active)-int('UP'in active)
if dx and dy:dx=dy=0
a=Input(dx,dy,self.pending);self.pending='';return self.advance(a)
# The ASCII teaching view needs no assets; original names above remain canonical.
BOOK_LABELS=('Tide chart for the way home','Song of sleeping coral','Memory of a small lighthouse')
OWNER_LABELS=('SHELF','FLOOR','DIVER','ROBOT','RETURNED')
STAGE_LABELS=('Swim and discover','Carry one book','Open the currents','Robot and oxygen','Checkpoint and save')
DEMO_SOLUTION = ((0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, 'z'), (0, 0, ''), (0, 0, ''), (0, 0, 'z'), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (0, -1, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, 'z'), (0, 0, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, ''), (0, 0, ''), (0, 1, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, 'z'), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (0, 1, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, 'z'), (0, 0, ''), (-1, 0, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, ''), (0, 0, ''), (0, -1, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, 'z'))
def visible_books(s):
return tuple(BOOK_LABELS[i] if s.revealed&(1<<i) else '??? Approach the shelf' for i in range(3))
def visible_bands(s):
return tuple((i,cells,d,bool(s.opened&(1<<i)) and s.currents) for i,(cells,d) in enumerate(BANDS[s.case]) if s.revealed&(1<<i)) if STAGE>=3 else ()
def checkpoint_demo(u):
"""Synthetic semantic input, never represented as physical keyboard play."""
u.title=False
for _ in range(4):u.advance(Input(1))
if STAGE>=2:u.advance(Input(op='z'))
if STAGE>=3:
for _ in range(3):u.advance(Input(1))
u.paused=True
def boundary_demo(u,name):
"""Narrow fabricated states are labelled fixtures; terminal uses valid inputs."""
u.title=False
if name=='open-current':
u.s=replace(new(),pos=(4,2),tick=3);u.advance(Input(op='z'))
elif name=='rescue':
u.s=replace(new(),pos=(1,0),oxygen=1,opened=1,books=((2,-1,-1),new().books[1],new().books[2]));u.advance(Input())
elif name=='hidden-robot':
u.s=replace(new(),books=((3,-1,-1),new().books[1],new().books[2]),ridx=2,opened=1)
elif name=='terminal':
u.s=new();u.checkpoint=u.s
for values in DEMO_SOLUTION:u.advance(Input(*values))
assert success(u.s)
elif name=='undo':
checkpoint_demo(u);u.feed(set());u.feed({'BACKSPACE'});u.feed(set())
elif name=='comparison':
u.s=new(currents=False);checkpoint_demo(u)
u.paused=True
class App:
def __init__(self,args):
self.caller_directory=Path.cwd()
self.args=args;self.frames=0;self.snap=None;self.text_log=[];self.status_text=''
self.ui=Controller(args.save_file) if STAGE==5 else LessonController()
if args.demo_checkpoint:checkpoint_demo(self.ui)
if args.boundary:boundary_demo(self.ui,args.boundary)
if args.boundary=='undo':self.status_text='REWOUND ALL WORLD STATE TO THE LAST AIR CHECKPOINT.'
self.keys={k:getattr(pyxel,'KEY_'+k) for k in KEYS}
pyxel.init(960,650,title='092 Seabed Library / lesson '+str(STAGE),fps=30,quit_key=pyxel.KEY_NONE,display_scale=1,headless=args.headless_smoke)
self.font=self.make_font()
pyxel.run(self.update,self.draw)
def make_font(self):
base=pyxel.Image(380,6);base.cls(0);base.text(0,0,''.join(chr(c) for c in range(32,127)),7)
font=pyxel.Image(760,12);font.cls(0)
for y in range(6):
for x in range(380):
if base.pget(x,y):font.rect(x*2,y*2,2,2,7)
return font
def update(self):
self.frames+=1
if self.args.headless_smoke:
if self.frames>=3:pyxel.quit()
return
event=self.ui.feed({k for k,v in self.keys.items() if pyxel.btn(v)})
if event in ('save','load','rewind','save-error'):
self.status_text={'save':'SAVED: '+str(self.args.save_file.name),'load':'LOADED AND PAUSED. P THEN RELEASE ALL KEYS TO CONTINUE.','rewind':'REWOUND ALL WORLD STATE TO THE LAST AIR CHECKPOINT.','save-error':'SAVE/LOAD ERROR: WORLD AND FILE PRESERVED. SEE TERMINAL DETAILS.'}[event]
if event=='save-error':print(self.ui.notice)
if event=='quit':pyxel.quit()
if event=='screenshot':
# Explicit F12 action: exclusive, caller-directory output.
from datetime import datetime,timezone
self.snap=self.caller_directory/('092_'+datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S_%f')+'.png')
def text(self,x,y,value,color=7):
value=str(value);self.text_log.append(value)
if not hasattr(self,'font'):
pyxel.text(x,y,value,color);return
pyxel.pal(7,color)
for ch in value:
code=ord(ch) if 32<=ord(ch)<127 else ord('?')
pyxel.blt(x,y,self.font,(code-32)*8,0,8,12,0);x+=8
pyxel.pal()
def draw(self):
self.text_log=[];s=self.ui.s;u=self.ui;pyxel.cls(1)
self.text(20,14,'092 SEABED LIBRARY / '+str(STAGE)+' '+STAGE_LABELS[STAGE-1],10)
mode='TITLE' if u.title else 'HELP' if u.help else 'CONFIRM '+u.dialog if u.dialog else 'PAUSED' if u.paused else 'RETURNED ALL 3' if success(s) else 'RUNNING'
self.text(20,32,f'CASE {s.case} TICK {s.tick} 1 TICK = 0.2 SEC {mode}',7)
self.text(20,48,f'WAIT {s.wait}/{3 if carry(s) is not None else 2} / EMPTY 2 / LOADED 3 TICKS',6)
if STAGE>=3:self.text(20,64,f'CURRENT IN {4-s.tick%4} TICKS' if s.currents else 'COMPARISON: CURRENTS OFF',11)
else:self.text(20,64,'CURRENTS NOT ACTIVE IN THIS STAGE',13)
if STAGE>=4:
self.text(490,48,f'OXYGEN {s.oxygen}/60 RESCUES {s.rescues}',11 if s.oxygen>15 else 8)
pyxel.rect(490,63,240,7,5);pyxel.rect(490,63,s.oxygen*4,7,11)
else:self.text(490,48,'OXYGEN / ROBOT NOT ACTIVE YET',13)
gx,gy,cell=20,92,64
for y in range(H):
for x in range(W):
p=(x,y);px=gx+x*cell;yy=gy+y*cell;seen=bool(s.explored&(1<<(y*W+x)))
pyxel.rect(px,yy,cell-2,cell-2,5 if seen else 1);pyxel.rectb(px,yy,cell-2,cell-2,6 if seen else 5)
if p in WALLS[s.case]:pyxel.rect(px+5,yy+5,cell-12,cell-12,13)
if p==O or (STAGE>=4 and p in AIR[s.case]):
pyxel.circb(px+30,yy+26,21,10);self.text(px+4,yy+3,'O' if p==O else 'AIR',10)
if STAGE>=4:
for x,y in route(s.case):pyxel.line(gx+x*cell+5,gy+y*cell+57,gx+x*cell+55,gy+y*cell+57,13)
for i,cells,d,active in visible_bands(s):
for x,y in cells:
px=gx+x*cell+30;yy=gy+y*cell+17;dx,dy=d;ex=px+dx*14;ey=yy+dy*14;color=11 if active else 13
pyxel.line(px-dx*14,yy-dy*14,ex,ey,color)
pyxel.line(ex,ey,ex-dx*6-dy*5,ey-dy*6+dx*5,color)
pyxel.line(ex,ey,ex-dx*6+dy*5,ey-dy*6-dx*5,color)
for i,(owner,x,y) in enumerate(s.books):
if owner in (0,1):
px=gx+x*cell+24;yy=gy+y*cell+25
if owner==0:pyxel.rect(px-10,yy-5,42,34,4)
pyxel.rect(px,yy,18,24,8+i);self.text(px+6,yy+9,i+1,1)
if STAGE>=4:
x,y=robot(s);px=gx+x*cell+47;yy=gy+y*cell+45;pyxel.rect(px-10,yy-7,21,14,13)
if carry(s,3) is not None:pyxel.rect(px-4,yy-16,8,10,10)
x,y=s.pos;px=gx+x*cell+25;yy=gy+y*cell+38;pyxel.circ(px,yy,11,12);pyxel.rect(px+1,yy-5,10,5,10)
if carry(s) is not None:pyxel.rect(px+9,yy+4,10,13,8+carry(s))
self.text(618,94,'BOOKS: '+str(len(s.returned_order))+' / 3 RETURNED',10)
for i,title in enumerate(visible_books(s)):
y=126+i*74;self.text(618,y,f'{i+1}: {OWNER_LABELS[s.books[i][0]]}',7)
self.text(618,y+16,title,10 if s.revealed&(1<<i) else 6)
self.text(618,y+32,'CHANNEL OPEN' if s.opened&(1<<i) else 'SHELF CLOSED',11 if s.opened&(1<<i) else 13)
self.text(618,356,'CARRY CAPACITY: 1 BOOK',7)
self.text(618,372,'ROBOT RETURNS: '+str(s.robot_returns) if STAGE>=4 else 'ROBOT: FUTURE STAGE 04',13)
self.text(20,428,'POS '+str(s.pos)+' CARRY '+str(None if carry(s) is None else carry(s)+1)+' LAST '+(', '.join(s.last) or '(none)'),7)
self.text(20,446,'DARK FLOOR: UNEXPLORED. WALLS AND CLOSED SHELVES ARE PUBLIC.',6)
self.text(20,462,'ARROWS: PURPLE = DISCOVERED FUTURE FLOW, GREEN = OPEN ACTIVE FLOW.' if STAGE>=3 else 'APPROACH A SHELF TO REVEAL ITS TITLE. CLOSED SHELVES BLOCK SWIMMING.',6)
self.text(20,484,'HOLD ARROWS: SWIM. RELEASE ALL KEYS AFTER EVERY MODE CHANGE.',7)
if STAGE>=2:self.text(20,500,'Z: OPEN / PICK UP / RETURN'+(' / GIVE TO ADJACENT EMPTY ROBOT' if STAGE>=4 else '')+' X: DROP',7)
else:self.text(20,500,'STAGE 01: SWIM TO (2,2). BOOK OPERATIONS ARRIVE IN STAGE 02.',10)
if STAGE==5:
self.text(20,518,'SPACE: START P: PAUSE H: HELP BACKSPACE WHILE PAUSED: LAST AIR CHECKPOINT',10)
self.text(20,534,'S: SAVE AT ENTRY/AIR/RESULT L: LOAD PAUSED R/M/ESC: CONFIRM WITH ENTER, X CANCEL',7)
self.text(20,550,'TITLE: 1/2/3 CASE, B CURRENT ON/OFF. F12: SCREENSHOT. R DOES NOT RELOAD CODE.',6)
self.text(20,568,f'CHECKPOINT: TICK {u.checkpoint.tick} POS {u.checkpoint.pos} ALL WORLD STATE REWINDS',11)
else:
self.text(20,518,'P: PAUSE R: RESET 1/2/3: CASE ESC: QUIT'+(' B: CURRENT ON/OFF + RESET' if STAGE>=3 else ''),7)
self.text(20,536,'R DOES NOT RELOAD CODE. SAVE YOUR EDIT, QUIT, THEN RUN PYTHON AGAIN.',6)
if STAGE>=4:self.text(20,592,'OXYGEN: -1/TICK; ENTRY/AIR REFILLS TO 60 BEFORE RESCUE. RESCUE LEAVES THE BOOK.',6)
if u.help:self.text(20,610,'HELP PAUSES TIME. USE THE MAP, OWNER LABELS AND COUNTDOWN; H TO CLOSE.',10)
elif u.dialog:self.text(20,610,'CONFIRM '+u.dialog.upper()+': RELEASE KEYS, ENTER YES / X CANCEL.',10)
elif u.title:self.text(20,610,'RETURN THREE BOOKS. SELECT A/B/C WITH 1/2/3, THEN SPACE; RELEASE TO BEGIN.',10)
elif u.notice:self.text(20,610,getattr(self,'status_text','') or 'CONTROL NOTICE: SEE TERMINAL DETAILS.',10)
elif success(s):self.text(20,610,'ALL THREE RETURNED. ORDER '+str(tuple(i+1 for i in s.returned_order))+'; NO EXTRA QUOTA.',10)
if self.args.headless_smoke and self.frames==1:
if self.args.screenshot:pyxel.screenshot(str(self.args.screenshot),scale=1)
if self.args.state_json:
with self.args.state_json.open('x',encoding='utf-8') as f:
json.dump({'stage':STAGE,'state':asdict(s),'checkpoint':asdict(u.checkpoint),'title':u.title,'paused':u.paused,'texts':self.text_log,'visible_books':visible_books(s),'visible_bands':visible_bands(s),'boundary':self.args.boundary,'scope':'Actual Linux headless Pyxel; synthetic semantic inputs or labelled fixtures'},f,ensure_ascii=False,indent=2)
if self.snap:
if self.snap.exists():raise FileExistsError(self.snap)
pyxel.screenshot(str(self.snap),scale=1);self.snap=None
def parse_args(argv=None):
import argparse
p=argparse.ArgumentParser(description='092 standalone lesson; source and JSON use UTF-8')
p.add_argument('--headless-smoke',action='store_true')
p.add_argument('--demo-checkpoint',action='store_true')
p.add_argument('--boundary',choices=('open-current','rescue','hidden-robot','terminal','undo','comparison'))
p.add_argument('--screenshot',type=Path)
p.add_argument('--state-json',type=Path)
p.add_argument('--save-file',type=Path,default=Path('092_save.json'))
args=p.parse_args(argv)
if (args.demo_checkpoint or args.boundary or args.screenshot or args.state_json) and not args.headless_smoke:p.error('capture/demo options require --headless-smoke')
if args.boundary and (STAGE<3 or (args.boundary in ('rescue','hidden-robot','terminal') and STAGE<4) or (args.boundary=='undo' and STAGE<5)):p.error('boundary is unavailable in this lesson')
if args.boundary and args.demo_checkpoint:p.error('choose one demo')
for name,suffix in (('screenshot','.png'),('state_json','.json')):
path=getattr(args,name)
if path is not None:
path=path.expanduser().resolve()
if path.suffix.lower()!=suffix:p.error(name+' must end with '+suffix)
if not path.parent.is_dir() or path.exists():p.error(name+' parent must exist and output must be new')
setattr(args,name,path)
args.save_file=args.save_file.expanduser().resolve()
if args.save_file.suffix.lower()!='.json' or not args.save_file.parent.is_dir() or args.save_file.is_dir():p.error('save-file needs an existing parent and .json filename')
if args.save_file in (args.screenshot,args.state_json):p.error('save and capture outputs must differ')
return args
if __name__=='__main__':
args=parse_args()
import pyxel
App(args)三冊を、一冊ずつ運ぶ
本の持ち主の移り変わり、一度に一冊だけ持てる決まり、操作の優先順。
前提は、01の pos、wait、replace と、tuple を一時的に list に変えて更新する書き方です。books には、いつも三冊の本があります。一冊の記録は (owner,x,y) です。owner(持ち主)は、0が「まだ抜いていない書架の中」、1が「床」、2が「自分」、3が「ロボット」、4が「もう返した」です。持っている本と返した本の座標は(-1,-1)にします。本の冊数を増やしたり、本の番号を作り直したりはしません。同じ三冊の持ち主を変えていきます。教材の02では、ロボットはまだ動きません。そのため、ふつうの操作で owner=3 になることはありません。
Zキーの意味は、本を持っているかどうかと、今いる場所で決まります。本を持っていれば、入口で返却します。02では入口以外の場所では渡せず、cannot-give(渡せない)になります。本を持っていなければ、まず同じマスの床にある本(床本)を探します。なければ、距離1にある、まだ抜いていない書架を探します。見つけた一冊だけを、自分の持ち物にします。書架から本を抜くと、opened と revealed の、その書架に対応するビットを立てます。すると、棚のあった場所を通れるようになります。三冊のうち番号の小さい本から順に探すので、床本が重なっていても、いちばん小さい番号の本を拾います。
Xキーは、持っている本を今いるマスに置きます。ただし、同じマスにもう床本があれば floor-occupied(床がふさがっている)となり、本は持ったままです。床は「1マスの冊数の上限」をいつも守ってくれる箱ではありません。Xで置くときにだけ調べる決まりです。あとの段階の水流や救助では、何冊かが同じ場所に集まることもあります。本を棚の元の場所に置いても、opened は消えず、棚が閉じ直すこともありません。本の持ち主と、通路が開いているかどうかは、別の情報だからです。
本を持つと、一マス進むのに3刻(0.6秒)かかります。移動の速さは、その刻が始まったときに本を持っているかどうかで決めます。同じ刻に移動とZを入れた場合は、移動を終えてから、新しい位置で本の操作をします。Zを押した瞬間に毎フレーム処理するのではありません。次のモデルの刻まで、1回分の操作を pending(処理待ち)に置いておきます。押しっぱなしにしても、毎刻抜いたり置いたりはしません。02では、水流、酸素、ロボットを省いています。それでも、三冊を自分で返すことと、全部返したら止まる処理は動きます。
下のコードは 02_one_book.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
found=next((i for i,b in enumerate(books) if b[0]==1 and tuple(b[1:])==pos),None)
if found is None:found=next((i for i,b in enumerate(books) if b[0]==0 and distance(pos,tuple(b[1:]))==1),None)実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 02_one_book.py.venv/bin/python 02_one_book.py- 持ち主の番号0・1・2・3・4の変化を追う
- 床本と書架で、探す条件を分ける
- 移動のあとで本の操作をする
LessonController.__init__ の self.s=new();self.checkpoint=self.s;self.previous=set() を、self.s=new('B');self.checkpoint=self.s;self.previous=set() に変えます。保存して、Escで終了し、python 02_one_book.py でもう一度実行します。館Bは最初の棚が(4,2)にあるので、(3,2)まで進んでZを押します。python exercises/02_start_at_b.py は、RIGHT(右)を36フレーム、Zと解放(キーを離す)を5フレーム実行し、t7で本1を持っていることを確かめます。Rではコードを読み直しません。最後に new() に戻します。
このステップのコード全文(323行)
# 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.
"""092 海底図書館・教材 2。原作と省略点は LESSON.md を参照。"""
STAGE = 2
"""092 pure 0.2-second tick model: movement, books, current, robot, oxygen."""
from dataclasses import dataclass,asdict,replace
import hashlib,json
VERSION='1.0.0';GAME='092';O=(0,2);W=9;H=5
TITLES={'A':'横の水路','B':'奥へ運ぶ流れ','C':'上下の本'}
BOOK_TITLES=('帰り道の潮汐図','眠る珊瑚のうた','小さな灯台の記憶')
WALLS={'A':((3,0),(3,1),(3,3),(3,4)),'B':((4,0),(4,1),(4,3),(4,4)),'C':((2,1),(3,1),(5,3),(6,3))}
SHELVES={'A':((3,2),(6,0),(7,4)),'B':((4,2),(7,1),(7,3)),'C':((4,1),(4,3),(8,2))}
AIR={'A':((5,2),),'B':((6,2),),'C':((4,2),(7,4))}
BANDS={'A':((((3,2),(4,2),(5,2)),(-1,0)),(((4,0),(5,0),(6,0)),(-1,0)),(((5,4),(6,4),(7,4)),(-1,0))), 'B':((((2,2),(3,2),(4,2)),(1,0)),(((5,1),(6,1),(7,1)),(-1,0)),(((5,3),(6,3),(7,3)),(-1,0))), 'C':((((4,0),(4,1)),(0,1)),(((4,3),(4,4)),(0,-1)),(((6,2),(7,2),(8,2)),(-1,0)))}
# owner: 0=unopened shelf, 1=floor, 2=player, 3=robot, 4=returned.
@dataclass(frozen=True)
class State:
case:str='A';pos:tuple=O;wait:int=0;oxygen:int=60;books:tuple=((0,3,2),(0,6,0),(0,7,4));opened:int=0;explored:int=1<<18;revealed:int=0;ridx:int=0;tick:int=0;rescues:int=0;returned_order:tuple=();robot_returns:int=0;currents:bool=True;history:tuple=();last:tuple=();longest:int=0;air_gap:int=0
@dataclass(frozen=True)
class Input:
dx:int=0;dy:int=0;op:str=''
def new(case='A',currents=True):return State(case=case,books=tuple((0,x,y) for x,y in SHELVES[case]),currents=currents)
def route(case):
n={'A':2,'B':3,'C':4}[case];return tuple((x,2) for x in range(n+1))+tuple((x,2) for x in range(n-1,0,-1))
def robot(s):return route(s.case)[s.ridx]
def carry(s,who=2):return next((i for i,b in enumerate(s.books) if b[0]==who),None)
def distance(a,b):return abs(a[0]-b[0])+abs(a[1]-b[1])
def walkable(s,p):
return 0<=p[0]<W and 0<=p[1]<H and p not in WALLS[s.case] and not any(b[0]==0 and tuple(b[1:])==p for b in s.books)
def drift(s,p):
if s.currents:
for i,(cells,d) in enumerate(BANDS[s.case]):
if s.opened&(1<<i) and p in cells:
q=(p[0]+d[0],p[1]+d[1]);return q if walkable(s,q) else p
return p
def air(s):return s.pos==O or s.pos in AIR[s.case]
def success(s):return all(b[0]==4 for b in s.books)
def step(s,a=Input(),record=True):
if success(s):return s
if type(a.dx)!=int or type(a.dy)!=int or abs(a.dx)+abs(a.dy)>1 or a.op not in ('','z','x'):raise ValueError('意味入力が不正')
t=s.tick+1;pos=s.pos;books=list(s.books);opened=s.opened;revealed=s.revealed;events=[];order=s.returned_order;rr=s.robot_returns;speed=3 if carry(s) is not None else 2;wait=min(speed,s.wait+1)
if (a.dx or a.dy) and wait>=speed:
q=(pos[0]+a.dx,pos[1]+a.dy)
if walkable(s,q):pos=q;wait=0;events.append('move')
else:events.append('wall')
held=next((i for i,b in enumerate(books) if b[0]==2),None)
if a.op=='z':
if held is not None:
if pos==O:books[held]=(4,-1,-1);order+=(held,);events.append('return:'+str(held))
else:events.append('cannot-give')
else:
found=next((i for i,b in enumerate(books) if b[0]==1 and tuple(b[1:])==pos),None)
if found is None:found=next((i for i,b in enumerate(books) if b[0]==0 and distance(pos,tuple(b[1:]))==1),None)
if found is not None:
if books[found][0]==0:opened|=1<<found;revealed|=1<<found;events.append('open:'+str(found))
else:events.append('pickup:'+str(found))
books[found]=(2,-1,-1)
if a.op=='x' and held is not None:
if not any(b[0]==1 and tuple(b[1:])==pos for b in books):books[held]=(1,*pos);events.append('drop:'+str(held))
else:events.append('floor-occupied')
explored=s.explored|(1<<(pos[1]*W+pos[0]))
for i,p in enumerate(SHELVES[s.case]):
if distance(pos,p)<=1:revealed|=1<<i
for i in order:revealed|=1<<i
return replace(s,pos=pos,wait=wait,books=tuple(books),opened=opened,explored=explored,revealed=revealed,tick=t,returned_order=order,history=s.history+((a.dx,a.dy,a.op),) if record else s.history,last=tuple(events))
def digest(s):return hashlib.sha256(json.dumps(asdict(s),sort_keys=True,ensure_ascii=False).encode('utf-8')).hexdigest()
def restore(d):
if not isinstance(d,dict) or set(d)!=set(asdict(new())):raise ValueError('状態の項目が違います')
if d['case'] not in TITLES or type(d['currents'])!=bool:raise ValueError('状況が不正')
if type(d['tick'])!=int or not 0<=d['tick']<=100000 or not isinstance(d['history'],(list,tuple)) or len(d['history'])!=d['tick']:raise ValueError('履歴が不正')
s=new(d['case'],d['currents'])
for e in d['history']:
if not isinstance(e,(list,tuple)) or len(e)!=3:raise ValueError('入力履歴が不正')
s=step(s,Input(*e))
if json.loads(json.dumps(asdict(s)))!=json.loads(json.dumps(d)):raise ValueError('履歴と状態が一致しません')
return s
from pathlib import Path
KEYS={'UP','DOWN','LEFT','RIGHT','Z','X','P','R','1','2','3','B','ESCAPE'}
DIR={'LEFT':(-1,0),'RIGHT':(1,0),'UP':(0,-1),'DOWN':(0,1)}
class LessonController:
"""Teaching controls. Original title, help, saves and checkpoint Undo arrive in 05."""
def __init__(self,path=None):
self.s=new();self.checkpoint=self.s;self.previous=set();self.blocked=set();self.gate=False;self.frame=0;self.pending='';self.paused=False;self.title=False;self.help=False;self.dialog='';self.notice=''
def reset(self,case=None):
self.s=new(case or self.s.case,self.s.currents);self.checkpoint=self.s;self.frame=0;self.pending='';self.paused=False
def transition(self):self.gate=True;self.pending='';self.frame=0;self.blocked|=self.previous
def advance(self,a):
before=self.s;self.s=step(self.s,a)
if air(self.s):self.checkpoint=self.s
return 'tick' if self.s!=before else 'terminal'
def feed(self,down):
down=set(down)&KEYS;fresh=down-self.previous;self.previous=down;self.blocked&=down
if self.gate:
self.blocked|=down
if not down:self.gate=False
return None
commands=fresh-set(DIR)
if commands:
if len(commands)>1:self.transition();return 'ignored:chord'
k=next(iter(commands))
if k=='ESCAPE':return 'quit'
if k=='P':self.paused=not self.paused;self.transition();return 'pause'
if k=='R':self.reset();self.transition();return 'reset'
if k in ('1','2','3'):self.reset('ABC'[int(k)-1]);self.transition();return 'case'
if k=='B' and STAGE>=3:self.s=new(self.s.case,not self.s.currents);self.checkpoint=self.s;self.transition();return 'variant'
if k in ('Z','X') and STAGE>=2 and not self.paused:self.pending=k.lower()
if self.paused or success(self.s):return None
self.frame+=1
if self.frame%6:return None
active=down-self.blocked;dx=int('RIGHT'in active)-int('LEFT'in active);dy=int('DOWN'in active)-int('UP'in active)
if dx and dy:dx=dy=0
a=Input(dx,dy,self.pending);self.pending='';return self.advance(a)
# The ASCII teaching view needs no assets; original names above remain canonical.
BOOK_LABELS=('Tide chart for the way home','Song of sleeping coral','Memory of a small lighthouse')
OWNER_LABELS=('SHELF','FLOOR','DIVER','ROBOT','RETURNED')
STAGE_LABELS=('Swim and discover','Carry one book','Open the currents','Robot and oxygen','Checkpoint and save')
DEMO_SOLUTION = ((0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, 'z'), (0, 0, ''), (0, 0, ''), (0, 0, 'z'), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (0, -1, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, 'z'), (0, 0, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, ''), (0, 0, ''), (0, 1, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, 'z'), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (0, 1, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, 'z'), (0, 0, ''), (-1, 0, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, ''), (0, 0, ''), (0, -1, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, 'z'))
def visible_books(s):
return tuple(BOOK_LABELS[i] if s.revealed&(1<<i) else '??? Approach the shelf' for i in range(3))
def visible_bands(s):
return tuple((i,cells,d,bool(s.opened&(1<<i)) and s.currents) for i,(cells,d) in enumerate(BANDS[s.case]) if s.revealed&(1<<i)) if STAGE>=3 else ()
def checkpoint_demo(u):
"""Synthetic semantic input, never represented as physical keyboard play."""
u.title=False
for _ in range(4):u.advance(Input(1))
if STAGE>=2:u.advance(Input(op='z'))
if STAGE>=3:
for _ in range(3):u.advance(Input(1))
u.paused=True
def boundary_demo(u,name):
"""Narrow fabricated states are labelled fixtures; terminal uses valid inputs."""
u.title=False
if name=='open-current':
u.s=replace(new(),pos=(4,2),tick=3);u.advance(Input(op='z'))
elif name=='rescue':
u.s=replace(new(),pos=(1,0),oxygen=1,opened=1,books=((2,-1,-1),new().books[1],new().books[2]));u.advance(Input())
elif name=='hidden-robot':
u.s=replace(new(),books=((3,-1,-1),new().books[1],new().books[2]),ridx=2,opened=1)
elif name=='terminal':
u.s=new();u.checkpoint=u.s
for values in DEMO_SOLUTION:u.advance(Input(*values))
assert success(u.s)
elif name=='undo':
checkpoint_demo(u);u.feed(set());u.feed({'BACKSPACE'});u.feed(set())
elif name=='comparison':
u.s=new(currents=False);checkpoint_demo(u)
u.paused=True
class App:
def __init__(self,args):
self.caller_directory=Path.cwd()
self.args=args;self.frames=0;self.snap=None;self.text_log=[];self.status_text=''
self.ui=Controller(args.save_file) if STAGE==5 else LessonController()
if args.demo_checkpoint:checkpoint_demo(self.ui)
if args.boundary:boundary_demo(self.ui,args.boundary)
if args.boundary=='undo':self.status_text='REWOUND ALL WORLD STATE TO THE LAST AIR CHECKPOINT.'
self.keys={k:getattr(pyxel,'KEY_'+k) for k in KEYS}
pyxel.init(960,650,title='092 Seabed Library / lesson '+str(STAGE),fps=30,quit_key=pyxel.KEY_NONE,display_scale=1,headless=args.headless_smoke)
self.font=self.make_font()
pyxel.run(self.update,self.draw)
def make_font(self):
base=pyxel.Image(380,6);base.cls(0);base.text(0,0,''.join(chr(c) for c in range(32,127)),7)
font=pyxel.Image(760,12);font.cls(0)
for y in range(6):
for x in range(380):
if base.pget(x,y):font.rect(x*2,y*2,2,2,7)
return font
def update(self):
self.frames+=1
if self.args.headless_smoke:
if self.frames>=3:pyxel.quit()
return
event=self.ui.feed({k for k,v in self.keys.items() if pyxel.btn(v)})
if event in ('save','load','rewind','save-error'):
self.status_text={'save':'SAVED: '+str(self.args.save_file.name),'load':'LOADED AND PAUSED. P THEN RELEASE ALL KEYS TO CONTINUE.','rewind':'REWOUND ALL WORLD STATE TO THE LAST AIR CHECKPOINT.','save-error':'SAVE/LOAD ERROR: WORLD AND FILE PRESERVED. SEE TERMINAL DETAILS.'}[event]
if event=='save-error':print(self.ui.notice)
if event=='quit':pyxel.quit()
if event=='screenshot':
# Explicit F12 action: exclusive, caller-directory output.
from datetime import datetime,timezone
self.snap=self.caller_directory/('092_'+datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S_%f')+'.png')
def text(self,x,y,value,color=7):
value=str(value);self.text_log.append(value)
if not hasattr(self,'font'):
pyxel.text(x,y,value,color);return
pyxel.pal(7,color)
for ch in value:
code=ord(ch) if 32<=ord(ch)<127 else ord('?')
pyxel.blt(x,y,self.font,(code-32)*8,0,8,12,0);x+=8
pyxel.pal()
def draw(self):
self.text_log=[];s=self.ui.s;u=self.ui;pyxel.cls(1)
self.text(20,14,'092 SEABED LIBRARY / '+str(STAGE)+' '+STAGE_LABELS[STAGE-1],10)
mode='TITLE' if u.title else 'HELP' if u.help else 'CONFIRM '+u.dialog if u.dialog else 'PAUSED' if u.paused else 'RETURNED ALL 3' if success(s) else 'RUNNING'
self.text(20,32,f'CASE {s.case} TICK {s.tick} 1 TICK = 0.2 SEC {mode}',7)
self.text(20,48,f'WAIT {s.wait}/{3 if carry(s) is not None else 2} / EMPTY 2 / LOADED 3 TICKS',6)
if STAGE>=3:self.text(20,64,f'CURRENT IN {4-s.tick%4} TICKS' if s.currents else 'COMPARISON: CURRENTS OFF',11)
else:self.text(20,64,'CURRENTS NOT ACTIVE IN THIS STAGE',13)
if STAGE>=4:
self.text(490,48,f'OXYGEN {s.oxygen}/60 RESCUES {s.rescues}',11 if s.oxygen>15 else 8)
pyxel.rect(490,63,240,7,5);pyxel.rect(490,63,s.oxygen*4,7,11)
else:self.text(490,48,'OXYGEN / ROBOT NOT ACTIVE YET',13)
gx,gy,cell=20,92,64
for y in range(H):
for x in range(W):
p=(x,y);px=gx+x*cell;yy=gy+y*cell;seen=bool(s.explored&(1<<(y*W+x)))
pyxel.rect(px,yy,cell-2,cell-2,5 if seen else 1);pyxel.rectb(px,yy,cell-2,cell-2,6 if seen else 5)
if p in WALLS[s.case]:pyxel.rect(px+5,yy+5,cell-12,cell-12,13)
if p==O or (STAGE>=4 and p in AIR[s.case]):
pyxel.circb(px+30,yy+26,21,10);self.text(px+4,yy+3,'O' if p==O else 'AIR',10)
if STAGE>=4:
for x,y in route(s.case):pyxel.line(gx+x*cell+5,gy+y*cell+57,gx+x*cell+55,gy+y*cell+57,13)
for i,cells,d,active in visible_bands(s):
for x,y in cells:
px=gx+x*cell+30;yy=gy+y*cell+17;dx,dy=d;ex=px+dx*14;ey=yy+dy*14;color=11 if active else 13
pyxel.line(px-dx*14,yy-dy*14,ex,ey,color)
pyxel.line(ex,ey,ex-dx*6-dy*5,ey-dy*6+dx*5,color)
pyxel.line(ex,ey,ex-dx*6+dy*5,ey-dy*6-dx*5,color)
for i,(owner,x,y) in enumerate(s.books):
if owner in (0,1):
px=gx+x*cell+24;yy=gy+y*cell+25
if owner==0:pyxel.rect(px-10,yy-5,42,34,4)
pyxel.rect(px,yy,18,24,8+i);self.text(px+6,yy+9,i+1,1)
if STAGE>=4:
x,y=robot(s);px=gx+x*cell+47;yy=gy+y*cell+45;pyxel.rect(px-10,yy-7,21,14,13)
if carry(s,3) is not None:pyxel.rect(px-4,yy-16,8,10,10)
x,y=s.pos;px=gx+x*cell+25;yy=gy+y*cell+38;pyxel.circ(px,yy,11,12);pyxel.rect(px+1,yy-5,10,5,10)
if carry(s) is not None:pyxel.rect(px+9,yy+4,10,13,8+carry(s))
self.text(618,94,'BOOKS: '+str(len(s.returned_order))+' / 3 RETURNED',10)
for i,title in enumerate(visible_books(s)):
y=126+i*74;self.text(618,y,f'{i+1}: {OWNER_LABELS[s.books[i][0]]}',7)
self.text(618,y+16,title,10 if s.revealed&(1<<i) else 6)
self.text(618,y+32,'CHANNEL OPEN' if s.opened&(1<<i) else 'SHELF CLOSED',11 if s.opened&(1<<i) else 13)
self.text(618,356,'CARRY CAPACITY: 1 BOOK',7)
self.text(618,372,'ROBOT RETURNS: '+str(s.robot_returns) if STAGE>=4 else 'ROBOT: FUTURE STAGE 04',13)
self.text(20,428,'POS '+str(s.pos)+' CARRY '+str(None if carry(s) is None else carry(s)+1)+' LAST '+(', '.join(s.last) or '(none)'),7)
self.text(20,446,'DARK FLOOR: UNEXPLORED. WALLS AND CLOSED SHELVES ARE PUBLIC.',6)
self.text(20,462,'ARROWS: PURPLE = DISCOVERED FUTURE FLOW, GREEN = OPEN ACTIVE FLOW.' if STAGE>=3 else 'APPROACH A SHELF TO REVEAL ITS TITLE. CLOSED SHELVES BLOCK SWIMMING.',6)
self.text(20,484,'HOLD ARROWS: SWIM. RELEASE ALL KEYS AFTER EVERY MODE CHANGE.',7)
if STAGE>=2:self.text(20,500,'Z: OPEN / PICK UP / RETURN'+(' / GIVE TO ADJACENT EMPTY ROBOT' if STAGE>=4 else '')+' X: DROP',7)
else:self.text(20,500,'STAGE 01: SWIM TO (2,2). BOOK OPERATIONS ARRIVE IN STAGE 02.',10)
if STAGE==5:
self.text(20,518,'SPACE: START P: PAUSE H: HELP BACKSPACE WHILE PAUSED: LAST AIR CHECKPOINT',10)
self.text(20,534,'S: SAVE AT ENTRY/AIR/RESULT L: LOAD PAUSED R/M/ESC: CONFIRM WITH ENTER, X CANCEL',7)
self.text(20,550,'TITLE: 1/2/3 CASE, B CURRENT ON/OFF. F12: SCREENSHOT. R DOES NOT RELOAD CODE.',6)
self.text(20,568,f'CHECKPOINT: TICK {u.checkpoint.tick} POS {u.checkpoint.pos} ALL WORLD STATE REWINDS',11)
else:
self.text(20,518,'P: PAUSE R: RESET 1/2/3: CASE ESC: QUIT'+(' B: CURRENT ON/OFF + RESET' if STAGE>=3 else ''),7)
self.text(20,536,'R DOES NOT RELOAD CODE. SAVE YOUR EDIT, QUIT, THEN RUN PYTHON AGAIN.',6)
if STAGE>=4:self.text(20,592,'OXYGEN: -1/TICK; ENTRY/AIR REFILLS TO 60 BEFORE RESCUE. RESCUE LEAVES THE BOOK.',6)
if u.help:self.text(20,610,'HELP PAUSES TIME. USE THE MAP, OWNER LABELS AND COUNTDOWN; H TO CLOSE.',10)
elif u.dialog:self.text(20,610,'CONFIRM '+u.dialog.upper()+': RELEASE KEYS, ENTER YES / X CANCEL.',10)
elif u.title:self.text(20,610,'RETURN THREE BOOKS. SELECT A/B/C WITH 1/2/3, THEN SPACE; RELEASE TO BEGIN.',10)
elif u.notice:self.text(20,610,getattr(self,'status_text','') or 'CONTROL NOTICE: SEE TERMINAL DETAILS.',10)
elif success(s):self.text(20,610,'ALL THREE RETURNED. ORDER '+str(tuple(i+1 for i in s.returned_order))+'; NO EXTRA QUOTA.',10)
if self.args.headless_smoke and self.frames==1:
if self.args.screenshot:pyxel.screenshot(str(self.args.screenshot),scale=1)
if self.args.state_json:
with self.args.state_json.open('x',encoding='utf-8') as f:
json.dump({'stage':STAGE,'state':asdict(s),'checkpoint':asdict(u.checkpoint),'title':u.title,'paused':u.paused,'texts':self.text_log,'visible_books':visible_books(s),'visible_bands':visible_bands(s),'boundary':self.args.boundary,'scope':'Actual Linux headless Pyxel; synthetic semantic inputs or labelled fixtures'},f,ensure_ascii=False,indent=2)
if self.snap:
if self.snap.exists():raise FileExistsError(self.snap)
pyxel.screenshot(str(self.snap),scale=1);self.snap=None
def parse_args(argv=None):
import argparse
p=argparse.ArgumentParser(description='092 standalone lesson; source and JSON use UTF-8')
p.add_argument('--headless-smoke',action='store_true')
p.add_argument('--demo-checkpoint',action='store_true')
p.add_argument('--boundary',choices=('open-current','rescue','hidden-robot','terminal','undo','comparison'))
p.add_argument('--screenshot',type=Path)
p.add_argument('--state-json',type=Path)
p.add_argument('--save-file',type=Path,default=Path('092_save.json'))
args=p.parse_args(argv)
if (args.demo_checkpoint or args.boundary or args.screenshot or args.state_json) and not args.headless_smoke:p.error('capture/demo options require --headless-smoke')
if args.boundary and (STAGE<3 or (args.boundary in ('rescue','hidden-robot','terminal') and STAGE<4) or (args.boundary=='undo' and STAGE<5)):p.error('boundary is unavailable in this lesson')
if args.boundary and args.demo_checkpoint:p.error('choose one demo')
for name,suffix in (('screenshot','.png'),('state_json','.json')):
path=getattr(args,name)
if path is not None:
path=path.expanduser().resolve()
if path.suffix.lower()!=suffix:p.error(name+' must end with '+suffix)
if not path.parent.is_dir() or path.exists():p.error(name+' parent must exist and output must be new')
setattr(args,name,path)
args.save_file=args.save_file.expanduser().resolve()
if args.save_file.suffix.lower()!='.json' or not args.save_file.parent.is_dir() or args.save_file.is_dir():p.error('save-file needs an existing parent and .json filename')
if args.save_file in (args.screenshot,args.state_json):p.error('save and capture outputs must differ')
return args
if __name__=='__main__':
args=parse_args()
import pyxel
App(args)抜いた本が、帰り道の流れを開く
決まった間隔で動く処理、開いた水路のビット、先に見せる予告、条件を1つだけ変えた比較。
前提は、02の持ち主の番号と opened です。ここから、書架ごとに対応する水路が動きます。BANDS は館ごとに、「流れるマスの tuple と、流れの向き (dx,dy)」を三組持ちます。i番の書架が開いていて、位置がその帯(水路の範囲)の中なら、一マスだけ押し流す行き先の候補を作ります。候補が壁、盤の外、まだ抜いていない書架なら、その場に残ります。流されて帯の外に出たものを、同じ刻にもう一度押すことはありません。手ぶらでも本を持っていても、水流で動くのは一マスです。
水流が動くのは、新しい tick(刻の番号)が4の倍数のとき、つまり0.8秒ごとです。処理の順番は、入力と移動→本の操作→水流です。t3で書架のとなりにいてZを入れると、t4の本の操作で opened のビットが立ちます。そして、その同じt4に、もう水が流れます。水流の処理に使う n が、本の操作のあとの books と opened を使うからです。通路が開くのを次の刻まで遅らせると、別の遊びになってしまいます。流れるのは自分だけではありません。owner=1 の床本も流れます。持っている本は、持ち主と一緒に動きます。
まだ見つけていない水流は、最初から全部を表示したりはしません。revealed の対応するビットが立った書架についてだけ、その水路の全範囲を紫の矢印で示します。書架が開いて水流が動いていれば、矢印は緑です。これは「あとでその棚を抜いたら、ここが流れる」という範囲の予告です。まだ入力していない移動を推測して、いちばんよい道を予測するものではありません。CURRENT IN(次の水流まで)は 4-tick%4 で計算します。最初は4、t3では1、t4では次の流れまで4です。Pで時間を止めれば、今の状態と矢印をゆっくり読めます。
Bキーは、水流のありとなしを切り替えて、最初の状態に戻します。比べるために水流をOFFにしても、本を抜く、棚のあった場所を通る、三冊を返すというルールは同じです。03では、酸素とロボットはまだ省いています。館Aの最初の状態から右を4刻、Z、右を3刻と入れると、t8で一度(3,2)に入った直後に流され、最後の位置は(2,2)になります。OFFなら、同じ入力で(3,2)にいます。コードの練習で水流の間隔だけを変えたときは、表示の間隔を変えていないので、予告と合わなくなります。そのちがいも確かめたら、必ず元のゲームの4に戻しましょう。
下のコードは 03_open_currents.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if t%4==0:
q=drift(n,pos)
if q!=pos:pos=q;events.append('current-player')実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 03_open_currents.py.venv/bin/python 03_open_currents.py- 本を抜いたその刻に、水流も有効になる
- 床本にも同じ
driftを使う - 題名と水路の予告は、同じ条件で公開する
if t%4==0: を if t%2==0: に1か所だけ変えます。保存して、Escで終了し、python 03_open_currents.py でもう一度実行します。右4刻、Z、右5刻と入れると、t10でも押し流され、位置は(2,2)になります。元の版では、t10は(3,2)です。python exercises/03_flow_every_two.py が、この変更とこの入力の並びを実行します。CURRENT IN の式は4のままなので、変更した版の間隔とは合いません。動きを作る部分と予告を、いっしょに保つ必要があることを観察しましょう。最後に、条件を4に戻します。Rではコードを読み直しません。
このステップのコード全文(331行)
# 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.
"""092 海底図書館・教材 3。原作と省略点は LESSON.md を参照。"""
STAGE = 3
"""092 pure 0.2-second tick model: movement, books, current, robot, oxygen."""
from dataclasses import dataclass,asdict,replace
import hashlib,json
VERSION='1.0.0';GAME='092';O=(0,2);W=9;H=5
TITLES={'A':'横の水路','B':'奥へ運ぶ流れ','C':'上下の本'}
BOOK_TITLES=('帰り道の潮汐図','眠る珊瑚のうた','小さな灯台の記憶')
WALLS={'A':((3,0),(3,1),(3,3),(3,4)),'B':((4,0),(4,1),(4,3),(4,4)),'C':((2,1),(3,1),(5,3),(6,3))}
SHELVES={'A':((3,2),(6,0),(7,4)),'B':((4,2),(7,1),(7,3)),'C':((4,1),(4,3),(8,2))}
AIR={'A':((5,2),),'B':((6,2),),'C':((4,2),(7,4))}
BANDS={'A':((((3,2),(4,2),(5,2)),(-1,0)),(((4,0),(5,0),(6,0)),(-1,0)),(((5,4),(6,4),(7,4)),(-1,0))), 'B':((((2,2),(3,2),(4,2)),(1,0)),(((5,1),(6,1),(7,1)),(-1,0)),(((5,3),(6,3),(7,3)),(-1,0))), 'C':((((4,0),(4,1)),(0,1)),(((4,3),(4,4)),(0,-1)),(((6,2),(7,2),(8,2)),(-1,0)))}
# owner: 0=unopened shelf, 1=floor, 2=player, 3=robot, 4=returned.
@dataclass(frozen=True)
class State:
case:str='A';pos:tuple=O;wait:int=0;oxygen:int=60;books:tuple=((0,3,2),(0,6,0),(0,7,4));opened:int=0;explored:int=1<<18;revealed:int=0;ridx:int=0;tick:int=0;rescues:int=0;returned_order:tuple=();robot_returns:int=0;currents:bool=True;history:tuple=();last:tuple=();longest:int=0;air_gap:int=0
@dataclass(frozen=True)
class Input:
dx:int=0;dy:int=0;op:str=''
def new(case='A',currents=True):return State(case=case,books=tuple((0,x,y) for x,y in SHELVES[case]),currents=currents)
def route(case):
n={'A':2,'B':3,'C':4}[case];return tuple((x,2) for x in range(n+1))+tuple((x,2) for x in range(n-1,0,-1))
def robot(s):return route(s.case)[s.ridx]
def carry(s,who=2):return next((i for i,b in enumerate(s.books) if b[0]==who),None)
def distance(a,b):return abs(a[0]-b[0])+abs(a[1]-b[1])
def walkable(s,p):
return 0<=p[0]<W and 0<=p[1]<H and p not in WALLS[s.case] and not any(b[0]==0 and tuple(b[1:])==p for b in s.books)
def drift(s,p):
if s.currents:
for i,(cells,d) in enumerate(BANDS[s.case]):
if s.opened&(1<<i) and p in cells:
q=(p[0]+d[0],p[1]+d[1]);return q if walkable(s,q) else p
return p
def air(s):return s.pos==O or s.pos in AIR[s.case]
def success(s):return all(b[0]==4 for b in s.books)
def step(s,a=Input(),record=True):
if success(s):return s
if type(a.dx)!=int or type(a.dy)!=int or abs(a.dx)+abs(a.dy)>1 or a.op not in ('','z','x'):raise ValueError('意味入力が不正')
t=s.tick+1;pos=s.pos;books=list(s.books);opened=s.opened;revealed=s.revealed;events=[];order=s.returned_order;rr=s.robot_returns;speed=3 if carry(s) is not None else 2;wait=min(speed,s.wait+1)
if (a.dx or a.dy) and wait>=speed:
q=(pos[0]+a.dx,pos[1]+a.dy)
if walkable(s,q):pos=q;wait=0;events.append('move')
else:events.append('wall')
held=next((i for i,b in enumerate(books) if b[0]==2),None)
if a.op=='z':
if held is not None:
if pos==O:books[held]=(4,-1,-1);order+=(held,);events.append('return:'+str(held))
else:events.append('cannot-give')
else:
found=next((i for i,b in enumerate(books) if b[0]==1 and tuple(b[1:])==pos),None)
if found is None:found=next((i for i,b in enumerate(books) if b[0]==0 and distance(pos,tuple(b[1:]))==1),None)
if found is not None:
if books[found][0]==0:opened|=1<<found;revealed|=1<<found;events.append('open:'+str(found))
else:events.append('pickup:'+str(found))
books[found]=(2,-1,-1)
if a.op=='x' and held is not None:
if not any(b[0]==1 and tuple(b[1:])==pos for b in books):books[held]=(1,*pos);events.append('drop:'+str(held))
else:events.append('floor-occupied')
n=replace(s,pos=pos,books=tuple(books),opened=opened)
if t%4==0:
q=drift(n,pos)
if q!=pos:pos=q;events.append('current-player')
for i,b in enumerate(books):
if b[0]==1:
q=drift(n,tuple(b[1:]));books[i]=(1,*q)
if q!=tuple(b[1:]):events.append('current-book:'+str(i))
explored=s.explored|(1<<(pos[1]*W+pos[0]))
for i,p in enumerate(SHELVES[s.case]):
if distance(pos,p)<=1:revealed|=1<<i
for i in order:revealed|=1<<i
return replace(s,pos=pos,wait=wait,books=tuple(books),opened=opened,explored=explored,revealed=revealed,tick=t,returned_order=order,history=s.history+((a.dx,a.dy,a.op),) if record else s.history,last=tuple(events))
def digest(s):return hashlib.sha256(json.dumps(asdict(s),sort_keys=True,ensure_ascii=False).encode('utf-8')).hexdigest()
def restore(d):
if not isinstance(d,dict) or set(d)!=set(asdict(new())):raise ValueError('状態の項目が違います')
if d['case'] not in TITLES or type(d['currents'])!=bool:raise ValueError('状況が不正')
if type(d['tick'])!=int or not 0<=d['tick']<=100000 or not isinstance(d['history'],(list,tuple)) or len(d['history'])!=d['tick']:raise ValueError('履歴が不正')
s=new(d['case'],d['currents'])
for e in d['history']:
if not isinstance(e,(list,tuple)) or len(e)!=3:raise ValueError('入力履歴が不正')
s=step(s,Input(*e))
if json.loads(json.dumps(asdict(s)))!=json.loads(json.dumps(d)):raise ValueError('履歴と状態が一致しません')
return s
from pathlib import Path
KEYS={'UP','DOWN','LEFT','RIGHT','Z','X','P','R','1','2','3','B','ESCAPE'}
DIR={'LEFT':(-1,0),'RIGHT':(1,0),'UP':(0,-1),'DOWN':(0,1)}
class LessonController:
"""Teaching controls. Original title, help, saves and checkpoint Undo arrive in 05."""
def __init__(self,path=None):
self.s=new();self.checkpoint=self.s;self.previous=set();self.blocked=set();self.gate=False;self.frame=0;self.pending='';self.paused=False;self.title=False;self.help=False;self.dialog='';self.notice=''
def reset(self,case=None):
self.s=new(case or self.s.case,self.s.currents);self.checkpoint=self.s;self.frame=0;self.pending='';self.paused=False
def transition(self):self.gate=True;self.pending='';self.frame=0;self.blocked|=self.previous
def advance(self,a):
before=self.s;self.s=step(self.s,a)
if air(self.s):self.checkpoint=self.s
return 'tick' if self.s!=before else 'terminal'
def feed(self,down):
down=set(down)&KEYS;fresh=down-self.previous;self.previous=down;self.blocked&=down
if self.gate:
self.blocked|=down
if not down:self.gate=False
return None
commands=fresh-set(DIR)
if commands:
if len(commands)>1:self.transition();return 'ignored:chord'
k=next(iter(commands))
if k=='ESCAPE':return 'quit'
if k=='P':self.paused=not self.paused;self.transition();return 'pause'
if k=='R':self.reset();self.transition();return 'reset'
if k in ('1','2','3'):self.reset('ABC'[int(k)-1]);self.transition();return 'case'
if k=='B' and STAGE>=3:self.s=new(self.s.case,not self.s.currents);self.checkpoint=self.s;self.transition();return 'variant'
if k in ('Z','X') and STAGE>=2 and not self.paused:self.pending=k.lower()
if self.paused or success(self.s):return None
self.frame+=1
if self.frame%6:return None
active=down-self.blocked;dx=int('RIGHT'in active)-int('LEFT'in active);dy=int('DOWN'in active)-int('UP'in active)
if dx and dy:dx=dy=0
a=Input(dx,dy,self.pending);self.pending='';return self.advance(a)
# The ASCII teaching view needs no assets; original names above remain canonical.
BOOK_LABELS=('Tide chart for the way home','Song of sleeping coral','Memory of a small lighthouse')
OWNER_LABELS=('SHELF','FLOOR','DIVER','ROBOT','RETURNED')
STAGE_LABELS=('Swim and discover','Carry one book','Open the currents','Robot and oxygen','Checkpoint and save')
DEMO_SOLUTION = ((0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, 'z'), (0, 0, ''), (0, 0, ''), (0, 0, 'z'), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (0, -1, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, 'z'), (0, 0, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, ''), (0, 0, ''), (0, 1, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, 'z'), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (0, 1, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, 'z'), (0, 0, ''), (-1, 0, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, ''), (0, 0, ''), (0, -1, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, 'z'))
def visible_books(s):
return tuple(BOOK_LABELS[i] if s.revealed&(1<<i) else '??? Approach the shelf' for i in range(3))
def visible_bands(s):
return tuple((i,cells,d,bool(s.opened&(1<<i)) and s.currents) for i,(cells,d) in enumerate(BANDS[s.case]) if s.revealed&(1<<i)) if STAGE>=3 else ()
def checkpoint_demo(u):
"""Synthetic semantic input, never represented as physical keyboard play."""
u.title=False
for _ in range(4):u.advance(Input(1))
if STAGE>=2:u.advance(Input(op='z'))
if STAGE>=3:
for _ in range(3):u.advance(Input(1))
u.paused=True
def boundary_demo(u,name):
"""Narrow fabricated states are labelled fixtures; terminal uses valid inputs."""
u.title=False
if name=='open-current':
u.s=replace(new(),pos=(4,2),tick=3);u.advance(Input(op='z'))
elif name=='rescue':
u.s=replace(new(),pos=(1,0),oxygen=1,opened=1,books=((2,-1,-1),new().books[1],new().books[2]));u.advance(Input())
elif name=='hidden-robot':
u.s=replace(new(),books=((3,-1,-1),new().books[1],new().books[2]),ridx=2,opened=1)
elif name=='terminal':
u.s=new();u.checkpoint=u.s
for values in DEMO_SOLUTION:u.advance(Input(*values))
assert success(u.s)
elif name=='undo':
checkpoint_demo(u);u.feed(set());u.feed({'BACKSPACE'});u.feed(set())
elif name=='comparison':
u.s=new(currents=False);checkpoint_demo(u)
u.paused=True
class App:
def __init__(self,args):
self.caller_directory=Path.cwd()
self.args=args;self.frames=0;self.snap=None;self.text_log=[];self.status_text=''
self.ui=Controller(args.save_file) if STAGE==5 else LessonController()
if args.demo_checkpoint:checkpoint_demo(self.ui)
if args.boundary:boundary_demo(self.ui,args.boundary)
if args.boundary=='undo':self.status_text='REWOUND ALL WORLD STATE TO THE LAST AIR CHECKPOINT.'
self.keys={k:getattr(pyxel,'KEY_'+k) for k in KEYS}
pyxel.init(960,650,title='092 Seabed Library / lesson '+str(STAGE),fps=30,quit_key=pyxel.KEY_NONE,display_scale=1,headless=args.headless_smoke)
self.font=self.make_font()
pyxel.run(self.update,self.draw)
def make_font(self):
base=pyxel.Image(380,6);base.cls(0);base.text(0,0,''.join(chr(c) for c in range(32,127)),7)
font=pyxel.Image(760,12);font.cls(0)
for y in range(6):
for x in range(380):
if base.pget(x,y):font.rect(x*2,y*2,2,2,7)
return font
def update(self):
self.frames+=1
if self.args.headless_smoke:
if self.frames>=3:pyxel.quit()
return
event=self.ui.feed({k for k,v in self.keys.items() if pyxel.btn(v)})
if event in ('save','load','rewind','save-error'):
self.status_text={'save':'SAVED: '+str(self.args.save_file.name),'load':'LOADED AND PAUSED. P THEN RELEASE ALL KEYS TO CONTINUE.','rewind':'REWOUND ALL WORLD STATE TO THE LAST AIR CHECKPOINT.','save-error':'SAVE/LOAD ERROR: WORLD AND FILE PRESERVED. SEE TERMINAL DETAILS.'}[event]
if event=='save-error':print(self.ui.notice)
if event=='quit':pyxel.quit()
if event=='screenshot':
# Explicit F12 action: exclusive, caller-directory output.
from datetime import datetime,timezone
self.snap=self.caller_directory/('092_'+datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S_%f')+'.png')
def text(self,x,y,value,color=7):
value=str(value);self.text_log.append(value)
if not hasattr(self,'font'):
pyxel.text(x,y,value,color);return
pyxel.pal(7,color)
for ch in value:
code=ord(ch) if 32<=ord(ch)<127 else ord('?')
pyxel.blt(x,y,self.font,(code-32)*8,0,8,12,0);x+=8
pyxel.pal()
def draw(self):
self.text_log=[];s=self.ui.s;u=self.ui;pyxel.cls(1)
self.text(20,14,'092 SEABED LIBRARY / '+str(STAGE)+' '+STAGE_LABELS[STAGE-1],10)
mode='TITLE' if u.title else 'HELP' if u.help else 'CONFIRM '+u.dialog if u.dialog else 'PAUSED' if u.paused else 'RETURNED ALL 3' if success(s) else 'RUNNING'
self.text(20,32,f'CASE {s.case} TICK {s.tick} 1 TICK = 0.2 SEC {mode}',7)
self.text(20,48,f'WAIT {s.wait}/{3 if carry(s) is not None else 2} / EMPTY 2 / LOADED 3 TICKS',6)
if STAGE>=3:self.text(20,64,f'CURRENT IN {4-s.tick%4} TICKS' if s.currents else 'COMPARISON: CURRENTS OFF',11)
else:self.text(20,64,'CURRENTS NOT ACTIVE IN THIS STAGE',13)
if STAGE>=4:
self.text(490,48,f'OXYGEN {s.oxygen}/60 RESCUES {s.rescues}',11 if s.oxygen>15 else 8)
pyxel.rect(490,63,240,7,5);pyxel.rect(490,63,s.oxygen*4,7,11)
else:self.text(490,48,'OXYGEN / ROBOT NOT ACTIVE YET',13)
gx,gy,cell=20,92,64
for y in range(H):
for x in range(W):
p=(x,y);px=gx+x*cell;yy=gy+y*cell;seen=bool(s.explored&(1<<(y*W+x)))
pyxel.rect(px,yy,cell-2,cell-2,5 if seen else 1);pyxel.rectb(px,yy,cell-2,cell-2,6 if seen else 5)
if p in WALLS[s.case]:pyxel.rect(px+5,yy+5,cell-12,cell-12,13)
if p==O or (STAGE>=4 and p in AIR[s.case]):
pyxel.circb(px+30,yy+26,21,10);self.text(px+4,yy+3,'O' if p==O else 'AIR',10)
if STAGE>=4:
for x,y in route(s.case):pyxel.line(gx+x*cell+5,gy+y*cell+57,gx+x*cell+55,gy+y*cell+57,13)
for i,cells,d,active in visible_bands(s):
for x,y in cells:
px=gx+x*cell+30;yy=gy+y*cell+17;dx,dy=d;ex=px+dx*14;ey=yy+dy*14;color=11 if active else 13
pyxel.line(px-dx*14,yy-dy*14,ex,ey,color)
pyxel.line(ex,ey,ex-dx*6-dy*5,ey-dy*6+dx*5,color)
pyxel.line(ex,ey,ex-dx*6+dy*5,ey-dy*6-dx*5,color)
for i,(owner,x,y) in enumerate(s.books):
if owner in (0,1):
px=gx+x*cell+24;yy=gy+y*cell+25
if owner==0:pyxel.rect(px-10,yy-5,42,34,4)
pyxel.rect(px,yy,18,24,8+i);self.text(px+6,yy+9,i+1,1)
if STAGE>=4:
x,y=robot(s);px=gx+x*cell+47;yy=gy+y*cell+45;pyxel.rect(px-10,yy-7,21,14,13)
if carry(s,3) is not None:pyxel.rect(px-4,yy-16,8,10,10)
x,y=s.pos;px=gx+x*cell+25;yy=gy+y*cell+38;pyxel.circ(px,yy,11,12);pyxel.rect(px+1,yy-5,10,5,10)
if carry(s) is not None:pyxel.rect(px+9,yy+4,10,13,8+carry(s))
self.text(618,94,'BOOKS: '+str(len(s.returned_order))+' / 3 RETURNED',10)
for i,title in enumerate(visible_books(s)):
y=126+i*74;self.text(618,y,f'{i+1}: {OWNER_LABELS[s.books[i][0]]}',7)
self.text(618,y+16,title,10 if s.revealed&(1<<i) else 6)
self.text(618,y+32,'CHANNEL OPEN' if s.opened&(1<<i) else 'SHELF CLOSED',11 if s.opened&(1<<i) else 13)
self.text(618,356,'CARRY CAPACITY: 1 BOOK',7)
self.text(618,372,'ROBOT RETURNS: '+str(s.robot_returns) if STAGE>=4 else 'ROBOT: FUTURE STAGE 04',13)
self.text(20,428,'POS '+str(s.pos)+' CARRY '+str(None if carry(s) is None else carry(s)+1)+' LAST '+(', '.join(s.last) or '(none)'),7)
self.text(20,446,'DARK FLOOR: UNEXPLORED. WALLS AND CLOSED SHELVES ARE PUBLIC.',6)
self.text(20,462,'ARROWS: PURPLE = DISCOVERED FUTURE FLOW, GREEN = OPEN ACTIVE FLOW.' if STAGE>=3 else 'APPROACH A SHELF TO REVEAL ITS TITLE. CLOSED SHELVES BLOCK SWIMMING.',6)
self.text(20,484,'HOLD ARROWS: SWIM. RELEASE ALL KEYS AFTER EVERY MODE CHANGE.',7)
if STAGE>=2:self.text(20,500,'Z: OPEN / PICK UP / RETURN'+(' / GIVE TO ADJACENT EMPTY ROBOT' if STAGE>=4 else '')+' X: DROP',7)
else:self.text(20,500,'STAGE 01: SWIM TO (2,2). BOOK OPERATIONS ARRIVE IN STAGE 02.',10)
if STAGE==5:
self.text(20,518,'SPACE: START P: PAUSE H: HELP BACKSPACE WHILE PAUSED: LAST AIR CHECKPOINT',10)
self.text(20,534,'S: SAVE AT ENTRY/AIR/RESULT L: LOAD PAUSED R/M/ESC: CONFIRM WITH ENTER, X CANCEL',7)
self.text(20,550,'TITLE: 1/2/3 CASE, B CURRENT ON/OFF. F12: SCREENSHOT. R DOES NOT RELOAD CODE.',6)
self.text(20,568,f'CHECKPOINT: TICK {u.checkpoint.tick} POS {u.checkpoint.pos} ALL WORLD STATE REWINDS',11)
else:
self.text(20,518,'P: PAUSE R: RESET 1/2/3: CASE ESC: QUIT'+(' B: CURRENT ON/OFF + RESET' if STAGE>=3 else ''),7)
self.text(20,536,'R DOES NOT RELOAD CODE. SAVE YOUR EDIT, QUIT, THEN RUN PYTHON AGAIN.',6)
if STAGE>=4:self.text(20,592,'OXYGEN: -1/TICK; ENTRY/AIR REFILLS TO 60 BEFORE RESCUE. RESCUE LEAVES THE BOOK.',6)
if u.help:self.text(20,610,'HELP PAUSES TIME. USE THE MAP, OWNER LABELS AND COUNTDOWN; H TO CLOSE.',10)
elif u.dialog:self.text(20,610,'CONFIRM '+u.dialog.upper()+': RELEASE KEYS, ENTER YES / X CANCEL.',10)
elif u.title:self.text(20,610,'RETURN THREE BOOKS. SELECT A/B/C WITH 1/2/3, THEN SPACE; RELEASE TO BEGIN.',10)
elif u.notice:self.text(20,610,getattr(self,'status_text','') or 'CONTROL NOTICE: SEE TERMINAL DETAILS.',10)
elif success(s):self.text(20,610,'ALL THREE RETURNED. ORDER '+str(tuple(i+1 for i in s.returned_order))+'; NO EXTRA QUOTA.',10)
if self.args.headless_smoke and self.frames==1:
if self.args.screenshot:pyxel.screenshot(str(self.args.screenshot),scale=1)
if self.args.state_json:
with self.args.state_json.open('x',encoding='utf-8') as f:
json.dump({'stage':STAGE,'state':asdict(s),'checkpoint':asdict(u.checkpoint),'title':u.title,'paused':u.paused,'texts':self.text_log,'visible_books':visible_books(s),'visible_bands':visible_bands(s),'boundary':self.args.boundary,'scope':'Actual Linux headless Pyxel; synthetic semantic inputs or labelled fixtures'},f,ensure_ascii=False,indent=2)
if self.snap:
if self.snap.exists():raise FileExistsError(self.snap)
pyxel.screenshot(str(self.snap),scale=1);self.snap=None
def parse_args(argv=None):
import argparse
p=argparse.ArgumentParser(description='092 standalone lesson; source and JSON use UTF-8')
p.add_argument('--headless-smoke',action='store_true')
p.add_argument('--demo-checkpoint',action='store_true')
p.add_argument('--boundary',choices=('open-current','rescue','hidden-robot','terminal','undo','comparison'))
p.add_argument('--screenshot',type=Path)
p.add_argument('--state-json',type=Path)
p.add_argument('--save-file',type=Path,default=Path('092_save.json'))
args=p.parse_args(argv)
if (args.demo_checkpoint or args.boundary or args.screenshot or args.state_json) and not args.headless_smoke:p.error('capture/demo options require --headless-smoke')
if args.boundary and (STAGE<3 or (args.boundary in ('rescue','hidden-robot','terminal') and STAGE<4) or (args.boundary=='undo' and STAGE<5)):p.error('boundary is unavailable in this lesson')
if args.boundary and args.demo_checkpoint:p.error('choose one demo')
for name,suffix in (('screenshot','.png'),('state_json','.json')):
path=getattr(args,name)
if path is not None:
path=path.expanduser().resolve()
if path.suffix.lower()!=suffix:p.error(name+' must end with '+suffix)
if not path.parent.is_dir() or path.exists():p.error(name+' parent must exist and output must be new')
setattr(args,name,path)
args.save_file=args.save_file.expanduser().resolve()
if args.save_file.suffix.lower()!='.json' or not args.save_file.parent.is_dir() or args.save_file.is_dir():p.error('save-file needs an existing parent and .json filename')
if args.save_file in (args.screenshot,args.state_json):p.error('save and capture outputs must differ')
return args
if __name__=='__main__':
args=parse_args()
import pyxel
App(args)ロボットへ託し、空気へ戻る
処理の順番で変わる結果、持てる冊数、救助で残るもの、純粋なモデル(画面やファイルにふれず、状態だけを計算する部分)。
前提は、03の更新の順番と持ち主の番号です。この段階からは、元のゲームの model.py の定義・定数・館A・B・Cのデータをすべてそのまま使います。step も元のゲームと同じです。入口から出るロボットの通り道は、館Aが x=0,1,2,1、館Bが 0,1,2,3,2,1、館Cが 0,1,2,3,4,3,2,1 で、yはいつも2です。ロボットは一刻に一マス進み、水流や酸素の影響を受けません。泳ぐ人とぶつかることもありません。
自分が本を一冊持っていて、入口以外の場所で、何も持っていないロボットとの距離が1なら、Zで本を渡します。このとき調べるロボットの位置は、ロボットが動く前の位置です。同じマスにいるときは距離0なので、渡せません。入口でZを押したときは、自分で返却するほうが優先です。そのあと、ロボットが通り道の次のマスへ動きます。何も持っていなければ、そのマスの床本を番号の順に拾います。本を持って入口に着くと、自動で返却します。水流の処理はこれより前なので、流されてきた床本を同じ刻に拾うこともあります。ロボットも、一度に持てるのは一冊です。
酸素は毎刻1ずつ減ります。位置が入口か、館の空気鐘(空気を補給できる場所)なら、1減らした直後に60へ回復します。そのため、酸素1で空気のある場所に着いても、救助にはなりません。それ以外の場所で酸素が0以下になったら、持っている本をその場の床に置きます。そして、人だけを入口に戻し、酸素を60、wait を0にします。開いた通路、もう返した本、探索した場所、ロボットの進み具合はそのまま残ります。これは、失敗したら全部を最初に戻す処理ではありません。救助の直後には、checkpoint(巻き戻す先の記録)の候補も、入口にいる新しい状態全体になります。
目標は、三冊を返すことです。救助なしで進めたか、返した順番、ロボットを使った回数、空気を補給する間のいちばん長い間隔は、自由に見るための記録です。達成しなければいけない追加の目標ではありません。元のゲームは、本物の潜水の安全計算ではありません。決まった整数で動くゲームです。手ぶらの移動は0.4秒、本を持った移動は0.6秒、水流は0.8秒、酸素は60刻(12秒分)と、それぞれ周期がちがうことを読み分けましょう。まだ題名を知らない本をロボットだけが先に拾った場合は、運んでいる間も題名を伏せておき、自動で返却したときに公開します。04は、完全なモデルに簡単なコントローラ(入力を受けてモデルを動かす部分)をつないだ段階です。保存と、空気のある時点への巻き戻しは、次の05で作ります。
下のコードは 04_robot_oxygen.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
oxygen=s.oxygen-1;rescues=s.rescues;gap=s.air_gap+1;longest=max(s.longest,gap)
if pos==O or pos in AIR[s.case]:oxygen=60;gap=0;events.append('air')
elif oxygen<=0:実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 04_robot_oxygen.py.venv/bin/python 04_robot_oxygen.py- 水流→ロボット→酸素の順番を守る
- 手渡しできるのは、となりにいて何も持っていないロボットだけ
- 空気の補給と救助の優先順を確かめる
oxygen=s.oxygen-1; を oxygen=s.oxygen-2; に変えます。保存して、Escで終了し、python 04_robot_oxygen.py でもう一度実行します。右を2刻押すと(1,2)にいて、酸素は58です。そのまま29刻待つと、t31で救助の数が1になり、入口に戻って酸素は60になります。python exercises/04_oxygen_cost.py が、変更した版で同じ入力の並びを実行します。Rはコードの読み直しではありません。確かめたら -1 に戻します。
このステップのコード全文(343行)
# 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.
"""092 海底図書館・教材 4。原作と省略点は LESSON.md を参照。"""
STAGE = 4
"""092 pure 0.2-second tick model: movement, books, current, robot, oxygen."""
from dataclasses import dataclass,asdict,replace
import hashlib,json
VERSION='1.0.0';GAME='092';O=(0,2);W=9;H=5
TITLES={'A':'横の水路','B':'奥へ運ぶ流れ','C':'上下の本'}
BOOK_TITLES=('帰り道の潮汐図','眠る珊瑚のうた','小さな灯台の記憶')
WALLS={'A':((3,0),(3,1),(3,3),(3,4)),'B':((4,0),(4,1),(4,3),(4,4)),'C':((2,1),(3,1),(5,3),(6,3))}
SHELVES={'A':((3,2),(6,0),(7,4)),'B':((4,2),(7,1),(7,3)),'C':((4,1),(4,3),(8,2))}
AIR={'A':((5,2),),'B':((6,2),),'C':((4,2),(7,4))}
BANDS={'A':((((3,2),(4,2),(5,2)),(-1,0)),(((4,0),(5,0),(6,0)),(-1,0)),(((5,4),(6,4),(7,4)),(-1,0))), 'B':((((2,2),(3,2),(4,2)),(1,0)),(((5,1),(6,1),(7,1)),(-1,0)),(((5,3),(6,3),(7,3)),(-1,0))), 'C':((((4,0),(4,1)),(0,1)),(((4,3),(4,4)),(0,-1)),(((6,2),(7,2),(8,2)),(-1,0)))}
# owner: 0=unopened shelf, 1=floor, 2=player, 3=robot, 4=returned.
@dataclass(frozen=True)
class State:
case:str='A';pos:tuple=O;wait:int=0;oxygen:int=60;books:tuple=((0,3,2),(0,6,0),(0,7,4));opened:int=0;explored:int=1<<18;revealed:int=0;ridx:int=0;tick:int=0;rescues:int=0;returned_order:tuple=();robot_returns:int=0;currents:bool=True;history:tuple=();last:tuple=();longest:int=0;air_gap:int=0
@dataclass(frozen=True)
class Input:
dx:int=0;dy:int=0;op:str=''
def new(case='A',currents=True):return State(case=case,books=tuple((0,x,y) for x,y in SHELVES[case]),currents=currents)
def route(case):
n={'A':2,'B':3,'C':4}[case];return tuple((x,2) for x in range(n+1))+tuple((x,2) for x in range(n-1,0,-1))
def robot(s):return route(s.case)[s.ridx]
def carry(s,who=2):return next((i for i,b in enumerate(s.books) if b[0]==who),None)
def distance(a,b):return abs(a[0]-b[0])+abs(a[1]-b[1])
def walkable(s,p):
return 0<=p[0]<W and 0<=p[1]<H and p not in WALLS[s.case] and not any(b[0]==0 and tuple(b[1:])==p for b in s.books)
def drift(s,p):
if s.currents:
for i,(cells,d) in enumerate(BANDS[s.case]):
if s.opened&(1<<i) and p in cells:
q=(p[0]+d[0],p[1]+d[1]);return q if walkable(s,q) else p
return p
def air(s):return s.pos==O or s.pos in AIR[s.case]
def success(s):return all(b[0]==4 for b in s.books)
def step(s,a=Input(),record=True):
if success(s):return s
if type(a.dx)!=int or type(a.dy)!=int or abs(a.dx)+abs(a.dy)>1 or a.op not in ('','z','x'):raise ValueError('意味入力が不正')
t=s.tick+1;pos=s.pos;books=list(s.books);opened=s.opened;revealed=s.revealed;events=[];order=s.returned_order;rr=s.robot_returns;speed=3 if carry(s) is not None else 2;wait=min(speed,s.wait+1)
if (a.dx or a.dy) and wait>=speed:
q=(pos[0]+a.dx,pos[1]+a.dy)
if walkable(s,q):pos=q;wait=0;events.append('move')
else:events.append('wall')
held=next((i for i,b in enumerate(books) if b[0]==2),None)
if a.op=='z':
if held is not None:
if pos==O:books[held]=(4,-1,-1);order+=(held,);events.append('return:'+str(held))
elif distance(pos,robot(s))==1 and carry(s,3) is None:books[held]=(3,-1,-1);events.append('handoff:'+str(held))
else:events.append('cannot-give')
else:
found=next((i for i,b in enumerate(books) if b[0]==1 and tuple(b[1:])==pos),None)
if found is None:found=next((i for i,b in enumerate(books) if b[0]==0 and distance(pos,tuple(b[1:]))==1),None)
if found is not None:
if books[found][0]==0:opened|=1<<found;revealed|=1<<found;events.append('open:'+str(found))
else:events.append('pickup:'+str(found))
books[found]=(2,-1,-1)
if a.op=='x' and held is not None:
if not any(b[0]==1 and tuple(b[1:])==pos for b in books):books[held]=(1,*pos);events.append('drop:'+str(held))
else:events.append('floor-occupied')
n=replace(s,pos=pos,books=tuple(books),opened=opened)
if t%4==0:
q=drift(n,pos)
if q!=pos:pos=q;events.append('current-player')
for i,b in enumerate(books):
if b[0]==1:
q=drift(n,tuple(b[1:]));books[i]=(1,*q)
if q!=tuple(b[1:]):events.append('current-book:'+str(i))
ridx=(s.ridx+1)%len(route(s.case));rp=route(s.case)[ridx];load=next((i for i,b in enumerate(books) if b[0]==3),None)
if load is None:
load=next((i for i,b in enumerate(books) if b[0]==1 and tuple(b[1:])==rp),None)
if load is not None:books[load]=(3,-1,-1);events.append('robot-pickup:'+str(load))
if load is not None and rp==O:books[load]=(4,-1,-1);order+=(load,);rr+=1;events.append('robot-return:'+str(load))
oxygen=s.oxygen-1;rescues=s.rescues;gap=s.air_gap+1;longest=max(s.longest,gap)
if pos==O or pos in AIR[s.case]:oxygen=60;gap=0;events.append('air')
elif oxygen<=0:
held=next((i for i,b in enumerate(books) if b[0]==2),None)
if held is not None:books[held]=(1,*pos)
pos=O;wait=0;oxygen=60;rescues+=1;gap=0;events.append('rescue')
explored=s.explored|(1<<(pos[1]*W+pos[0]))
for i,p in enumerate(SHELVES[s.case]):
if distance(pos,p)<=1:revealed|=1<<i
for i in order:revealed|=1<<i
return replace(s,pos=pos,wait=wait,oxygen=oxygen,books=tuple(books),opened=opened,explored=explored,revealed=revealed,ridx=ridx,tick=t,rescues=rescues,returned_order=order,robot_returns=rr,history=s.history+((a.dx,a.dy,a.op),) if record else s.history,last=tuple(events),longest=longest,air_gap=gap)
def digest(s):return hashlib.sha256(json.dumps(asdict(s),sort_keys=True,ensure_ascii=False).encode('utf-8')).hexdigest()
def restore(d):
if not isinstance(d,dict) or set(d)!=set(asdict(new())):raise ValueError('状態の項目が違います')
if d['case'] not in TITLES or type(d['currents'])!=bool:raise ValueError('状況が不正')
if type(d['tick'])!=int or not 0<=d['tick']<=100000 or not isinstance(d['history'],(list,tuple)) or len(d['history'])!=d['tick']:raise ValueError('履歴が不正')
s=new(d['case'],d['currents'])
for e in d['history']:
if not isinstance(e,(list,tuple)) or len(e)!=3:raise ValueError('入力履歴が不正')
s=step(s,Input(*e))
if json.loads(json.dumps(asdict(s)))!=json.loads(json.dumps(d)):raise ValueError('履歴と状態が一致しません')
return s
from pathlib import Path
KEYS={'UP','DOWN','LEFT','RIGHT','Z','X','P','R','1','2','3','B','ESCAPE'}
DIR={'LEFT':(-1,0),'RIGHT':(1,0),'UP':(0,-1),'DOWN':(0,1)}
class LessonController:
"""Teaching controls. Original title, help, saves and checkpoint Undo arrive in 05."""
def __init__(self,path=None):
self.s=new();self.checkpoint=self.s;self.previous=set();self.blocked=set();self.gate=False;self.frame=0;self.pending='';self.paused=False;self.title=False;self.help=False;self.dialog='';self.notice=''
def reset(self,case=None):
self.s=new(case or self.s.case,self.s.currents);self.checkpoint=self.s;self.frame=0;self.pending='';self.paused=False
def transition(self):self.gate=True;self.pending='';self.frame=0;self.blocked|=self.previous
def advance(self,a):
before=self.s;self.s=step(self.s,a)
if air(self.s):self.checkpoint=self.s
return 'tick' if self.s!=before else 'terminal'
def feed(self,down):
down=set(down)&KEYS;fresh=down-self.previous;self.previous=down;self.blocked&=down
if self.gate:
self.blocked|=down
if not down:self.gate=False
return None
commands=fresh-set(DIR)
if commands:
if len(commands)>1:self.transition();return 'ignored:chord'
k=next(iter(commands))
if k=='ESCAPE':return 'quit'
if k=='P':self.paused=not self.paused;self.transition();return 'pause'
if k=='R':self.reset();self.transition();return 'reset'
if k in ('1','2','3'):self.reset('ABC'[int(k)-1]);self.transition();return 'case'
if k=='B' and STAGE>=3:self.s=new(self.s.case,not self.s.currents);self.checkpoint=self.s;self.transition();return 'variant'
if k in ('Z','X') and STAGE>=2 and not self.paused:self.pending=k.lower()
if self.paused or success(self.s):return None
self.frame+=1
if self.frame%6:return None
active=down-self.blocked;dx=int('RIGHT'in active)-int('LEFT'in active);dy=int('DOWN'in active)-int('UP'in active)
if dx and dy:dx=dy=0
a=Input(dx,dy,self.pending);self.pending='';return self.advance(a)
# The ASCII teaching view needs no assets; original names above remain canonical.
BOOK_LABELS=('Tide chart for the way home','Song of sleeping coral','Memory of a small lighthouse')
OWNER_LABELS=('SHELF','FLOOR','DIVER','ROBOT','RETURNED')
STAGE_LABELS=('Swim and discover','Carry one book','Open the currents','Robot and oxygen','Checkpoint and save')
DEMO_SOLUTION = ((0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, 'z'), (0, 0, ''), (0, 0, ''), (0, 0, 'z'), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (0, -1, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, 'z'), (0, 0, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, ''), (0, 0, ''), (0, 1, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, 'z'), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (0, 1, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, 'z'), (0, 0, ''), (-1, 0, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, ''), (0, 0, ''), (0, -1, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, 'z'))
def visible_books(s):
return tuple(BOOK_LABELS[i] if s.revealed&(1<<i) else '??? Approach the shelf' for i in range(3))
def visible_bands(s):
return tuple((i,cells,d,bool(s.opened&(1<<i)) and s.currents) for i,(cells,d) in enumerate(BANDS[s.case]) if s.revealed&(1<<i)) if STAGE>=3 else ()
def checkpoint_demo(u):
"""Synthetic semantic input, never represented as physical keyboard play."""
u.title=False
for _ in range(4):u.advance(Input(1))
if STAGE>=2:u.advance(Input(op='z'))
if STAGE>=3:
for _ in range(3):u.advance(Input(1))
u.paused=True
def boundary_demo(u,name):
"""Narrow fabricated states are labelled fixtures; terminal uses valid inputs."""
u.title=False
if name=='open-current':
u.s=replace(new(),pos=(4,2),tick=3);u.advance(Input(op='z'))
elif name=='rescue':
u.s=replace(new(),pos=(1,0),oxygen=1,opened=1,books=((2,-1,-1),new().books[1],new().books[2]));u.advance(Input())
elif name=='hidden-robot':
u.s=replace(new(),books=((3,-1,-1),new().books[1],new().books[2]),ridx=2,opened=1)
elif name=='terminal':
u.s=new();u.checkpoint=u.s
for values in DEMO_SOLUTION:u.advance(Input(*values))
assert success(u.s)
elif name=='undo':
checkpoint_demo(u);u.feed(set());u.feed({'BACKSPACE'});u.feed(set())
elif name=='comparison':
u.s=new(currents=False);checkpoint_demo(u)
u.paused=True
class App:
def __init__(self,args):
self.caller_directory=Path.cwd()
self.args=args;self.frames=0;self.snap=None;self.text_log=[];self.status_text=''
self.ui=Controller(args.save_file) if STAGE==5 else LessonController()
if args.demo_checkpoint:checkpoint_demo(self.ui)
if args.boundary:boundary_demo(self.ui,args.boundary)
if args.boundary=='undo':self.status_text='REWOUND ALL WORLD STATE TO THE LAST AIR CHECKPOINT.'
self.keys={k:getattr(pyxel,'KEY_'+k) for k in KEYS}
pyxel.init(960,650,title='092 Seabed Library / lesson '+str(STAGE),fps=30,quit_key=pyxel.KEY_NONE,display_scale=1,headless=args.headless_smoke)
self.font=self.make_font()
pyxel.run(self.update,self.draw)
def make_font(self):
base=pyxel.Image(380,6);base.cls(0);base.text(0,0,''.join(chr(c) for c in range(32,127)),7)
font=pyxel.Image(760,12);font.cls(0)
for y in range(6):
for x in range(380):
if base.pget(x,y):font.rect(x*2,y*2,2,2,7)
return font
def update(self):
self.frames+=1
if self.args.headless_smoke:
if self.frames>=3:pyxel.quit()
return
event=self.ui.feed({k for k,v in self.keys.items() if pyxel.btn(v)})
if event in ('save','load','rewind','save-error'):
self.status_text={'save':'SAVED: '+str(self.args.save_file.name),'load':'LOADED AND PAUSED. P THEN RELEASE ALL KEYS TO CONTINUE.','rewind':'REWOUND ALL WORLD STATE TO THE LAST AIR CHECKPOINT.','save-error':'SAVE/LOAD ERROR: WORLD AND FILE PRESERVED. SEE TERMINAL DETAILS.'}[event]
if event=='save-error':print(self.ui.notice)
if event=='quit':pyxel.quit()
if event=='screenshot':
# Explicit F12 action: exclusive, caller-directory output.
from datetime import datetime,timezone
self.snap=self.caller_directory/('092_'+datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S_%f')+'.png')
def text(self,x,y,value,color=7):
value=str(value);self.text_log.append(value)
if not hasattr(self,'font'):
pyxel.text(x,y,value,color);return
pyxel.pal(7,color)
for ch in value:
code=ord(ch) if 32<=ord(ch)<127 else ord('?')
pyxel.blt(x,y,self.font,(code-32)*8,0,8,12,0);x+=8
pyxel.pal()
def draw(self):
self.text_log=[];s=self.ui.s;u=self.ui;pyxel.cls(1)
self.text(20,14,'092 SEABED LIBRARY / '+str(STAGE)+' '+STAGE_LABELS[STAGE-1],10)
mode='TITLE' if u.title else 'HELP' if u.help else 'CONFIRM '+u.dialog if u.dialog else 'PAUSED' if u.paused else 'RETURNED ALL 3' if success(s) else 'RUNNING'
self.text(20,32,f'CASE {s.case} TICK {s.tick} 1 TICK = 0.2 SEC {mode}',7)
self.text(20,48,f'WAIT {s.wait}/{3 if carry(s) is not None else 2} / EMPTY 2 / LOADED 3 TICKS',6)
if STAGE>=3:self.text(20,64,f'CURRENT IN {4-s.tick%4} TICKS' if s.currents else 'COMPARISON: CURRENTS OFF',11)
else:self.text(20,64,'CURRENTS NOT ACTIVE IN THIS STAGE',13)
if STAGE>=4:
self.text(490,48,f'OXYGEN {s.oxygen}/60 RESCUES {s.rescues}',11 if s.oxygen>15 else 8)
pyxel.rect(490,63,240,7,5);pyxel.rect(490,63,s.oxygen*4,7,11)
else:self.text(490,48,'OXYGEN / ROBOT NOT ACTIVE YET',13)
gx,gy,cell=20,92,64
for y in range(H):
for x in range(W):
p=(x,y);px=gx+x*cell;yy=gy+y*cell;seen=bool(s.explored&(1<<(y*W+x)))
pyxel.rect(px,yy,cell-2,cell-2,5 if seen else 1);pyxel.rectb(px,yy,cell-2,cell-2,6 if seen else 5)
if p in WALLS[s.case]:pyxel.rect(px+5,yy+5,cell-12,cell-12,13)
if p==O or (STAGE>=4 and p in AIR[s.case]):
pyxel.circb(px+30,yy+26,21,10);self.text(px+4,yy+3,'O' if p==O else 'AIR',10)
if STAGE>=4:
for x,y in route(s.case):pyxel.line(gx+x*cell+5,gy+y*cell+57,gx+x*cell+55,gy+y*cell+57,13)
for i,cells,d,active in visible_bands(s):
for x,y in cells:
px=gx+x*cell+30;yy=gy+y*cell+17;dx,dy=d;ex=px+dx*14;ey=yy+dy*14;color=11 if active else 13
pyxel.line(px-dx*14,yy-dy*14,ex,ey,color)
pyxel.line(ex,ey,ex-dx*6-dy*5,ey-dy*6+dx*5,color)
pyxel.line(ex,ey,ex-dx*6+dy*5,ey-dy*6-dx*5,color)
for i,(owner,x,y) in enumerate(s.books):
if owner in (0,1):
px=gx+x*cell+24;yy=gy+y*cell+25
if owner==0:pyxel.rect(px-10,yy-5,42,34,4)
pyxel.rect(px,yy,18,24,8+i);self.text(px+6,yy+9,i+1,1)
if STAGE>=4:
x,y=robot(s);px=gx+x*cell+47;yy=gy+y*cell+45;pyxel.rect(px-10,yy-7,21,14,13)
if carry(s,3) is not None:pyxel.rect(px-4,yy-16,8,10,10)
x,y=s.pos;px=gx+x*cell+25;yy=gy+y*cell+38;pyxel.circ(px,yy,11,12);pyxel.rect(px+1,yy-5,10,5,10)
if carry(s) is not None:pyxel.rect(px+9,yy+4,10,13,8+carry(s))
self.text(618,94,'BOOKS: '+str(len(s.returned_order))+' / 3 RETURNED',10)
for i,title in enumerate(visible_books(s)):
y=126+i*74;self.text(618,y,f'{i+1}: {OWNER_LABELS[s.books[i][0]]}',7)
self.text(618,y+16,title,10 if s.revealed&(1<<i) else 6)
self.text(618,y+32,'CHANNEL OPEN' if s.opened&(1<<i) else 'SHELF CLOSED',11 if s.opened&(1<<i) else 13)
self.text(618,356,'CARRY CAPACITY: 1 BOOK',7)
self.text(618,372,'ROBOT RETURNS: '+str(s.robot_returns) if STAGE>=4 else 'ROBOT: FUTURE STAGE 04',13)
self.text(20,428,'POS '+str(s.pos)+' CARRY '+str(None if carry(s) is None else carry(s)+1)+' LAST '+(', '.join(s.last) or '(none)'),7)
self.text(20,446,'DARK FLOOR: UNEXPLORED. WALLS AND CLOSED SHELVES ARE PUBLIC.',6)
self.text(20,462,'ARROWS: PURPLE = DISCOVERED FUTURE FLOW, GREEN = OPEN ACTIVE FLOW.' if STAGE>=3 else 'APPROACH A SHELF TO REVEAL ITS TITLE. CLOSED SHELVES BLOCK SWIMMING.',6)
self.text(20,484,'HOLD ARROWS: SWIM. RELEASE ALL KEYS AFTER EVERY MODE CHANGE.',7)
if STAGE>=2:self.text(20,500,'Z: OPEN / PICK UP / RETURN'+(' / GIVE TO ADJACENT EMPTY ROBOT' if STAGE>=4 else '')+' X: DROP',7)
else:self.text(20,500,'STAGE 01: SWIM TO (2,2). BOOK OPERATIONS ARRIVE IN STAGE 02.',10)
if STAGE==5:
self.text(20,518,'SPACE: START P: PAUSE H: HELP BACKSPACE WHILE PAUSED: LAST AIR CHECKPOINT',10)
self.text(20,534,'S: SAVE AT ENTRY/AIR/RESULT L: LOAD PAUSED R/M/ESC: CONFIRM WITH ENTER, X CANCEL',7)
self.text(20,550,'TITLE: 1/2/3 CASE, B CURRENT ON/OFF. F12: SCREENSHOT. R DOES NOT RELOAD CODE.',6)
self.text(20,568,f'CHECKPOINT: TICK {u.checkpoint.tick} POS {u.checkpoint.pos} ALL WORLD STATE REWINDS',11)
else:
self.text(20,518,'P: PAUSE R: RESET 1/2/3: CASE ESC: QUIT'+(' B: CURRENT ON/OFF + RESET' if STAGE>=3 else ''),7)
self.text(20,536,'R DOES NOT RELOAD CODE. SAVE YOUR EDIT, QUIT, THEN RUN PYTHON AGAIN.',6)
if STAGE>=4:self.text(20,592,'OXYGEN: -1/TICK; ENTRY/AIR REFILLS TO 60 BEFORE RESCUE. RESCUE LEAVES THE BOOK.',6)
if u.help:self.text(20,610,'HELP PAUSES TIME. USE THE MAP, OWNER LABELS AND COUNTDOWN; H TO CLOSE.',10)
elif u.dialog:self.text(20,610,'CONFIRM '+u.dialog.upper()+': RELEASE KEYS, ENTER YES / X CANCEL.',10)
elif u.title:self.text(20,610,'RETURN THREE BOOKS. SELECT A/B/C WITH 1/2/3, THEN SPACE; RELEASE TO BEGIN.',10)
elif u.notice:self.text(20,610,getattr(self,'status_text','') or 'CONTROL NOTICE: SEE TERMINAL DETAILS.',10)
elif success(s):self.text(20,610,'ALL THREE RETURNED. ORDER '+str(tuple(i+1 for i in s.returned_order))+'; NO EXTRA QUOTA.',10)
if self.args.headless_smoke and self.frames==1:
if self.args.screenshot:pyxel.screenshot(str(self.args.screenshot),scale=1)
if self.args.state_json:
with self.args.state_json.open('x',encoding='utf-8') as f:
json.dump({'stage':STAGE,'state':asdict(s),'checkpoint':asdict(u.checkpoint),'title':u.title,'paused':u.paused,'texts':self.text_log,'visible_books':visible_books(s),'visible_bands':visible_bands(s),'boundary':self.args.boundary,'scope':'Actual Linux headless Pyxel; synthetic semantic inputs or labelled fixtures'},f,ensure_ascii=False,indent=2)
if self.snap:
if self.snap.exists():raise FileExistsError(self.snap)
pyxel.screenshot(str(self.snap),scale=1);self.snap=None
def parse_args(argv=None):
import argparse
p=argparse.ArgumentParser(description='092 standalone lesson; source and JSON use UTF-8')
p.add_argument('--headless-smoke',action='store_true')
p.add_argument('--demo-checkpoint',action='store_true')
p.add_argument('--boundary',choices=('open-current','rescue','hidden-robot','terminal','undo','comparison'))
p.add_argument('--screenshot',type=Path)
p.add_argument('--state-json',type=Path)
p.add_argument('--save-file',type=Path,default=Path('092_save.json'))
args=p.parse_args(argv)
if (args.demo_checkpoint or args.boundary or args.screenshot or args.state_json) and not args.headless_smoke:p.error('capture/demo options require --headless-smoke')
if args.boundary and (STAGE<3 or (args.boundary in ('rescue','hidden-robot','terminal') and STAGE<4) or (args.boundary=='undo' and STAGE<5)):p.error('boundary is unavailable in this lesson')
if args.boundary and args.demo_checkpoint:p.error('choose one demo')
for name,suffix in (('screenshot','.png'),('state_json','.json')):
path=getattr(args,name)
if path is not None:
path=path.expanduser().resolve()
if path.suffix.lower()!=suffix:p.error(name+' must end with '+suffix)
if not path.parent.is_dir() or path.exists():p.error(name+' parent must exist and output must be new')
setattr(args,name,path)
args.save_file=args.save_file.expanduser().resolve()
if args.save_file.suffix.lower()!='.json' or not args.save_file.parent.is_dir() or args.save_file.is_dir():p.error('save-file needs an existing parent and .json filename')
if args.save_file in (args.screenshot,args.state_json):p.error('save and capture outputs must differ')
return args
if __name__=='__main__':
args=parse_args()
import pyxel
App(args)空気の時点へ全世界を巻き戻す
コントローラ、キーを全部離すまでの待ち、中身を確かめる保存、チェックポイント(戻る先の記録)。
前提は、04の完全なモデルと、集合の差集合(片方にだけあるものを取り出す計算)、JSON、Path、例外です。05では、元のゲームの Controller のクラス定義、KEYS、DIR もそのまま使います。最初の画面は TITLE(表紙)です。1・2・3で館A・B・Cを選び、Bで水流の比較を切り替え、SpaceかEnterで始めます。始めたら、全部のキーを一度離します。前の段階とちがい、館と比較の設定は表紙で切り替えます。R・M・Escには確認があります。Rは同じ館を最初から始め、Mは表紙に戻り、Escは終了します。確認はEnter、取り消しはXです。Hの説明、Pの一時停止、表紙、確認中、三冊を返したあとは、時計が止まります。
transition は、pending を捨てて frame を0にし、押したままのキーを blocked に入れます。gate が立っている間は、全部のキーを離すまで次の操作を受け付けません。これで、前の画面の入力が持ち越されるのを防ぎます。たとえば、開始キーを押したまま右へ動いてしまうこと、一時停止中に押したZが再開した直後に働くこと、RとEnterを同時に押して確認を飛ばすことです。矢印の左右を同時に押すと打ち消し合い、上下と左右を同時に押す斜めの入力も移動0です。方向以外のコマンドを複数同時に新しく押した場合も無視して、キーを離すまで待ちます。
advance は一刻を進めます。進めた結果の位置が空気のある場所なら、その State 全体を checkpoint に保存します。Pで一時停止してBackspaceを押すと、直前の空気のある時点に戻ります。これは、1つ前の操作だけを取り消す Undo(元に戻す)ではありません。本の持ち主と位置、開いた水路、探索と公開、ロボット、酸素、tick、入力の履歴、自由に見る記録も、全部戻ります。一時停止中でなければ、Backspaceを押しても巻き戻しません。救助は、本を海の底に残したまま、進み具合を残す操作です。巻き戻しは、世界全体を過去に戻す操作です。この2つは結果がちがいます。
Sで保存できるのは、入口・空気鐘・結果画面のときだけです。保存先は、ふだんは実行した作業フォルダの 092_save.json です。Lで読み込むと、必ず一時停止の状態になります。Pを押して全部のキーを離すと再開します。JSONはUTF-8で書き、保存はいったん一時ファイルに書いてから置き換えます。読み込むときは、作品ID・版・状態の項目を確かめます。checkpoint が同じ館・同じ比較の設定で、その履歴が今の履歴の始めの部分と一致することも確かめます。さらに、全部の入力をもう一度実行した結果も確かめます。ファイルがこわれていたら、ファイルは消さず、読み込む前の状態のまま停止します。教材のCLI(コマンドで動かす道具)の出力先のパスは、Pyxelの import や init より前に、呼び出した作業フォルダを基準にして決めます。PNGと確認用のJSONは、すでにあるファイルを上書きしません。
下のコードは 05_checkpoint_save.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
def advance(self,a):
before=self.s;self.s=step(self.s,a)
if air(self.s):self.checkpoint=self.s
return 'tick' if self.s!=before else 'terminal'実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 05_checkpoint_save.py.venv/bin/python 05_checkpoint_save.pycheckpoint と一致します。S→泳ぐ→Lと操作すると、保存した状態が一時停止の状態で読み込まれます。三冊を返したあとも、Pから巻き戻しできます。- 1操作だけ戻すUndoと、空気のある時点への巻き戻しを分ける
- 画面を切り替えたあとは、全部のキーを離すまで待つ
- 保存と読み込みで、同じ世界の状態になることを確かめる
tick や酸素だけを戻しても、本やロボットは未来の状態のまま残ってしまいます。checkpoint を今の state に一部だけ混ぜるのではなく、記録全体と置き換えます。RやLは、Pythonのコードを読み直す操作ではありません。Controller.reset の self.checkpoint=self.s;self.frame=0;self.pending='';self.dialog='';self.paused=False のうち、最後だけを self.paused=True に変えます。保存して、Esc→全部のキーを離す→Enterで終了し、python 05_checkpoint_save.py でもう一度実行します。始めたあとでR→全部のキーを離す→Enterと押すと、t0で一時停止した状態になり、右を押しても進みません。P→全部のキーを離す、のあとで右を12フレーム押すと、t2で(1,2)になります。python exercises/05_pause_after_reset.py で、このコードの変更と操作の入力を実行します。Rはコードの読み直しではありません。最後に False に戻します。
このステップのコード全文(375行)
# 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.
"""092 海底図書館・教材 5。原作と省略点は LESSON.md を参照。"""
STAGE = 5
"""092 pure 0.2-second tick model: movement, books, current, robot, oxygen."""
from dataclasses import dataclass,asdict,replace
import hashlib,json
VERSION='1.0.0';GAME='092';O=(0,2);W=9;H=5
TITLES={'A':'横の水路','B':'奥へ運ぶ流れ','C':'上下の本'}
BOOK_TITLES=('帰り道の潮汐図','眠る珊瑚のうた','小さな灯台の記憶')
WALLS={'A':((3,0),(3,1),(3,3),(3,4)),'B':((4,0),(4,1),(4,3),(4,4)),'C':((2,1),(3,1),(5,3),(6,3))}
SHELVES={'A':((3,2),(6,0),(7,4)),'B':((4,2),(7,1),(7,3)),'C':((4,1),(4,3),(8,2))}
AIR={'A':((5,2),),'B':((6,2),),'C':((4,2),(7,4))}
BANDS={'A':((((3,2),(4,2),(5,2)),(-1,0)),(((4,0),(5,0),(6,0)),(-1,0)),(((5,4),(6,4),(7,4)),(-1,0))), 'B':((((2,2),(3,2),(4,2)),(1,0)),(((5,1),(6,1),(7,1)),(-1,0)),(((5,3),(6,3),(7,3)),(-1,0))), 'C':((((4,0),(4,1)),(0,1)),(((4,3),(4,4)),(0,-1)),(((6,2),(7,2),(8,2)),(-1,0)))}
# owner: 0=unopened shelf, 1=floor, 2=player, 3=robot, 4=returned.
@dataclass(frozen=True)
class State:
case:str='A';pos:tuple=O;wait:int=0;oxygen:int=60;books:tuple=((0,3,2),(0,6,0),(0,7,4));opened:int=0;explored:int=1<<18;revealed:int=0;ridx:int=0;tick:int=0;rescues:int=0;returned_order:tuple=();robot_returns:int=0;currents:bool=True;history:tuple=();last:tuple=();longest:int=0;air_gap:int=0
@dataclass(frozen=True)
class Input:
dx:int=0;dy:int=0;op:str=''
def new(case='A',currents=True):return State(case=case,books=tuple((0,x,y) for x,y in SHELVES[case]),currents=currents)
def route(case):
n={'A':2,'B':3,'C':4}[case];return tuple((x,2) for x in range(n+1))+tuple((x,2) for x in range(n-1,0,-1))
def robot(s):return route(s.case)[s.ridx]
def carry(s,who=2):return next((i for i,b in enumerate(s.books) if b[0]==who),None)
def distance(a,b):return abs(a[0]-b[0])+abs(a[1]-b[1])
def walkable(s,p):
return 0<=p[0]<W and 0<=p[1]<H and p not in WALLS[s.case] and not any(b[0]==0 and tuple(b[1:])==p for b in s.books)
def drift(s,p):
if s.currents:
for i,(cells,d) in enumerate(BANDS[s.case]):
if s.opened&(1<<i) and p in cells:
q=(p[0]+d[0],p[1]+d[1]);return q if walkable(s,q) else p
return p
def air(s):return s.pos==O or s.pos in AIR[s.case]
def success(s):return all(b[0]==4 for b in s.books)
def step(s,a=Input(),record=True):
if success(s):return s
if type(a.dx)!=int or type(a.dy)!=int or abs(a.dx)+abs(a.dy)>1 or a.op not in ('','z','x'):raise ValueError('意味入力が不正')
t=s.tick+1;pos=s.pos;books=list(s.books);opened=s.opened;revealed=s.revealed;events=[];order=s.returned_order;rr=s.robot_returns;speed=3 if carry(s) is not None else 2;wait=min(speed,s.wait+1)
if (a.dx or a.dy) and wait>=speed:
q=(pos[0]+a.dx,pos[1]+a.dy)
if walkable(s,q):pos=q;wait=0;events.append('move')
else:events.append('wall')
held=next((i for i,b in enumerate(books) if b[0]==2),None)
if a.op=='z':
if held is not None:
if pos==O:books[held]=(4,-1,-1);order+=(held,);events.append('return:'+str(held))
elif distance(pos,robot(s))==1 and carry(s,3) is None:books[held]=(3,-1,-1);events.append('handoff:'+str(held))
else:events.append('cannot-give')
else:
found=next((i for i,b in enumerate(books) if b[0]==1 and tuple(b[1:])==pos),None)
if found is None:found=next((i for i,b in enumerate(books) if b[0]==0 and distance(pos,tuple(b[1:]))==1),None)
if found is not None:
if books[found][0]==0:opened|=1<<found;revealed|=1<<found;events.append('open:'+str(found))
else:events.append('pickup:'+str(found))
books[found]=(2,-1,-1)
if a.op=='x' and held is not None:
if not any(b[0]==1 and tuple(b[1:])==pos for b in books):books[held]=(1,*pos);events.append('drop:'+str(held))
else:events.append('floor-occupied')
n=replace(s,pos=pos,books=tuple(books),opened=opened)
if t%4==0:
q=drift(n,pos)
if q!=pos:pos=q;events.append('current-player')
for i,b in enumerate(books):
if b[0]==1:
q=drift(n,tuple(b[1:]));books[i]=(1,*q)
if q!=tuple(b[1:]):events.append('current-book:'+str(i))
ridx=(s.ridx+1)%len(route(s.case));rp=route(s.case)[ridx];load=next((i for i,b in enumerate(books) if b[0]==3),None)
if load is None:
load=next((i for i,b in enumerate(books) if b[0]==1 and tuple(b[1:])==rp),None)
if load is not None:books[load]=(3,-1,-1);events.append('robot-pickup:'+str(load))
if load is not None and rp==O:books[load]=(4,-1,-1);order+=(load,);rr+=1;events.append('robot-return:'+str(load))
oxygen=s.oxygen-1;rescues=s.rescues;gap=s.air_gap+1;longest=max(s.longest,gap)
if pos==O or pos in AIR[s.case]:oxygen=60;gap=0;events.append('air')
elif oxygen<=0:
held=next((i for i,b in enumerate(books) if b[0]==2),None)
if held is not None:books[held]=(1,*pos)
pos=O;wait=0;oxygen=60;rescues+=1;gap=0;events.append('rescue')
explored=s.explored|(1<<(pos[1]*W+pos[0]))
for i,p in enumerate(SHELVES[s.case]):
if distance(pos,p)<=1:revealed|=1<<i
for i in order:revealed|=1<<i
return replace(s,pos=pos,wait=wait,oxygen=oxygen,books=tuple(books),opened=opened,explored=explored,revealed=revealed,ridx=ridx,tick=t,rescues=rescues,returned_order=order,robot_returns=rr,history=s.history+((a.dx,a.dy,a.op),) if record else s.history,last=tuple(events),longest=longest,air_gap=gap)
def digest(s):return hashlib.sha256(json.dumps(asdict(s),sort_keys=True,ensure_ascii=False).encode('utf-8')).hexdigest()
def restore(d):
if not isinstance(d,dict) or set(d)!=set(asdict(new())):raise ValueError('状態の項目が違います')
if d['case'] not in TITLES or type(d['currents'])!=bool:raise ValueError('状況が不正')
if type(d['tick'])!=int or not 0<=d['tick']<=100000 or not isinstance(d['history'],(list,tuple)) or len(d['history'])!=d['tick']:raise ValueError('履歴が不正')
s=new(d['case'],d['currents'])
for e in d['history']:
if not isinstance(e,(list,tuple)) or len(e)!=3:raise ValueError('入力履歴が不正')
s=step(s,Input(*e))
if json.loads(json.dumps(asdict(s)))!=json.loads(json.dumps(d)):raise ValueError('履歴と状態が一致しません')
return s
from pathlib import Path
KEYS={'SPACE','RETURN','Z','X','P','H','R','M','UP','DOWN','LEFT','RIGHT','1','2','3','B','F12','ESCAPE','BACKSPACE','S','L'}
DIR={'LEFT':(-1,0),'RIGHT':(1,0),'UP':(0,-1),'DOWN':(0,1)}
class Controller:
def __init__(self,path=None):
self.s=new();self.title=True;self.help=False;self.paused=False;self.dialog='';self.previous=set();self.blocked=set();self.gate=False;self.notice='';self.frame=0;self.pending='';self.checkpoint=self.s;self.path=Path(path) if path else Path(__file__).parent/'save.json'
def reset(self,case=None):
self.s=new(case or self.s.case,self.s.currents);self.checkpoint=self.s;self.frame=0;self.pending='';self.dialog='';self.paused=False
def transition(self):self.gate=True;self.pending='';self.frame=0;self.blocked|=self.previous
def advance(self,a):
before=self.s;self.s=step(self.s,a)
if air(self.s):self.checkpoint=self.s
return 'tick' if self.s!=before else 'terminal'
def save(self):
if not (air(self.s) or success(self.s)):raise ValueError('保存は入口・空気鐘・結果でできます')
d={'game':GAME,'version':VERSION,'state':asdict(self.s),'checkpoint':asdict(self.checkpoint),'settings':{'currents':self.s.currents}}
tmp=self.path.with_suffix('.tmp');tmp.write_text(json.dumps(d,ensure_ascii=False,indent=2),encoding='utf-8');tmp.replace(self.path)
def load(self):
d=json.loads(self.path.read_text(encoding='utf-8'))
if set(d)!={'game','version','state','checkpoint','settings'} or d['game']!=GAME or d['version']!=VERSION:raise ValueError('未知の保存版。Rで新規開始できます')
s=restore(d['state']);cp=restore(d['checkpoint'])
if not (air(s) or success(s)) or not air(cp) or cp.case!=s.case or cp.currents!=s.currents or cp.history!=s.history[:cp.tick] or d['settings']!={'currents':s.currents}:raise ValueError('保存地点が不正')
self.s=s;self.checkpoint=cp;self.title=False;self.paused=True
def feed(self,down):
down=set(down)&KEYS;fresh=down-self.previous;self.previous=down;self.blocked&=down
if self.gate:
self.blocked|=down
if not down:self.gate=False
return None
# Controls are always release-polled before any pause/terminal return.
commands=fresh-set(DIR)
if commands:
if len(commands)>1:self.transition();return 'ignored:chord'
k=next(iter(commands));self.notice=''
if k=='F12':return 'screenshot'
if self.dialog:
if k in ('X','ESCAPE'):self.dialog='';self.transition();return 'cancel'
if k=='RETURN':
d=self.dialog;self.dialog='';self.transition()
if d=='quit':return 'quit'
if d=='retry':self.reset();return 'reset'
if d=='menu':self.title=True;return 'menu'
return 'ignored:dialog'
if k=='ESCAPE':self.dialog='quit';self.transition();return 'confirm:quit'
if k=='H':self.help=not self.help;self.transition();return 'help'
if self.help:return 'ignored:help'
if self.title:
if k in ('1','2','3'):self.reset('ABC'[int(k)-1]);self.transition();return 'case'
if k=='B':self.s=new(self.s.case,not self.s.currents);self.checkpoint=self.s;self.transition();return 'variant'
if k in ('SPACE','RETURN'):self.title=False;self.transition();return 'start'
return 'ignored:title'
if k=='P':self.paused=not self.paused;self.transition();return 'pause'
if k=='BACKSPACE' and self.paused:self.s=self.checkpoint;self.notice='直前の空気補給へ、ロボットと本も巻戻しました';self.transition();return 'rewind'
if k in ('S','L'):
try:
if k=='S':self.save();self.notice='保存しました'
else:self.load();self.notice='停止して読込みました。Pで再開'
self.transition();return 'save' if k=='S' else 'load'
except (ValueError,OSError,TypeError,KeyError) as e:self.notice='保存を変更せず停止: '+str(e);self.paused=True;self.transition();return 'save-error'
if k=='R':self.dialog='retry';self.transition();return 'confirm:retry'
if k=='M':self.dialog='menu';self.transition();return 'confirm:menu'
if not self.paused and not success(self.s) and k in ('Z','X'):self.pending=k.lower()
if self.title or self.help or self.paused or self.dialog or success(self.s):return None
self.frame+=1
if self.frame%6:return None
active=down-self.blocked;dx=int('RIGHT'in active)-int('LEFT'in active);dy=int('DOWN'in active)-int('UP'in active)
if dx and dy:dx=dy=0
a=Input(dx,dy,self.pending);self.pending='';return self.advance(a)
# The ASCII teaching view needs no assets; original names above remain canonical.
BOOK_LABELS=('Tide chart for the way home','Song of sleeping coral','Memory of a small lighthouse')
OWNER_LABELS=('SHELF','FLOOR','DIVER','ROBOT','RETURNED')
STAGE_LABELS=('Swim and discover','Carry one book','Open the currents','Robot and oxygen','Checkpoint and save')
DEMO_SOLUTION = ((0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, 'z'), (0, 0, ''), (0, 0, ''), (0, 0, 'z'), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (0, -1, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, 'z'), (0, 0, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, ''), (0, 0, ''), (0, 1, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, 'z'), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (0, 1, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, ''), (1, 0, ''), (0, 0, 'z'), (0, 0, ''), (-1, 0, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, ''), (0, 0, ''), (0, -1, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, ''), (0, 0, ''), (-1, 0, ''), (0, 0, 'z'))
def visible_books(s):
return tuple(BOOK_LABELS[i] if s.revealed&(1<<i) else '??? Approach the shelf' for i in range(3))
def visible_bands(s):
return tuple((i,cells,d,bool(s.opened&(1<<i)) and s.currents) for i,(cells,d) in enumerate(BANDS[s.case]) if s.revealed&(1<<i)) if STAGE>=3 else ()
def checkpoint_demo(u):
"""Synthetic semantic input, never represented as physical keyboard play."""
u.title=False
for _ in range(4):u.advance(Input(1))
if STAGE>=2:u.advance(Input(op='z'))
if STAGE>=3:
for _ in range(3):u.advance(Input(1))
u.paused=True
def boundary_demo(u,name):
"""Narrow fabricated states are labelled fixtures; terminal uses valid inputs."""
u.title=False
if name=='open-current':
u.s=replace(new(),pos=(4,2),tick=3);u.advance(Input(op='z'))
elif name=='rescue':
u.s=replace(new(),pos=(1,0),oxygen=1,opened=1,books=((2,-1,-1),new().books[1],new().books[2]));u.advance(Input())
elif name=='hidden-robot':
u.s=replace(new(),books=((3,-1,-1),new().books[1],new().books[2]),ridx=2,opened=1)
elif name=='terminal':
u.s=new();u.checkpoint=u.s
for values in DEMO_SOLUTION:u.advance(Input(*values))
assert success(u.s)
elif name=='undo':
checkpoint_demo(u);u.feed(set());u.feed({'BACKSPACE'});u.feed(set())
elif name=='comparison':
u.s=new(currents=False);checkpoint_demo(u)
u.paused=True
class App:
def __init__(self,args):
self.caller_directory=Path.cwd()
self.args=args;self.frames=0;self.snap=None;self.text_log=[];self.status_text=''
self.ui=Controller(args.save_file) if STAGE==5 else LessonController()
if args.demo_checkpoint:checkpoint_demo(self.ui)
if args.boundary:boundary_demo(self.ui,args.boundary)
if args.boundary=='undo':self.status_text='REWOUND ALL WORLD STATE TO THE LAST AIR CHECKPOINT.'
self.keys={k:getattr(pyxel,'KEY_'+k) for k in KEYS}
pyxel.init(960,650,title='092 Seabed Library / lesson '+str(STAGE),fps=30,quit_key=pyxel.KEY_NONE,display_scale=1,headless=args.headless_smoke)
self.font=self.make_font()
pyxel.run(self.update,self.draw)
def make_font(self):
base=pyxel.Image(380,6);base.cls(0);base.text(0,0,''.join(chr(c) for c in range(32,127)),7)
font=pyxel.Image(760,12);font.cls(0)
for y in range(6):
for x in range(380):
if base.pget(x,y):font.rect(x*2,y*2,2,2,7)
return font
def update(self):
self.frames+=1
if self.args.headless_smoke:
if self.frames>=3:pyxel.quit()
return
event=self.ui.feed({k for k,v in self.keys.items() if pyxel.btn(v)})
if event in ('save','load','rewind','save-error'):
self.status_text={'save':'SAVED: '+str(self.args.save_file.name),'load':'LOADED AND PAUSED. P THEN RELEASE ALL KEYS TO CONTINUE.','rewind':'REWOUND ALL WORLD STATE TO THE LAST AIR CHECKPOINT.','save-error':'SAVE/LOAD ERROR: WORLD AND FILE PRESERVED. SEE TERMINAL DETAILS.'}[event]
if event=='save-error':print(self.ui.notice)
if event=='quit':pyxel.quit()
if event=='screenshot':
# Explicit F12 action: exclusive, caller-directory output.
from datetime import datetime,timezone
self.snap=self.caller_directory/('092_'+datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S_%f')+'.png')
def text(self,x,y,value,color=7):
value=str(value);self.text_log.append(value)
if not hasattr(self,'font'):
pyxel.text(x,y,value,color);return
pyxel.pal(7,color)
for ch in value:
code=ord(ch) if 32<=ord(ch)<127 else ord('?')
pyxel.blt(x,y,self.font,(code-32)*8,0,8,12,0);x+=8
pyxel.pal()
def draw(self):
self.text_log=[];s=self.ui.s;u=self.ui;pyxel.cls(1)
self.text(20,14,'092 SEABED LIBRARY / '+str(STAGE)+' '+STAGE_LABELS[STAGE-1],10)
mode='TITLE' if u.title else 'HELP' if u.help else 'CONFIRM '+u.dialog if u.dialog else 'PAUSED' if u.paused else 'RETURNED ALL 3' if success(s) else 'RUNNING'
self.text(20,32,f'CASE {s.case} TICK {s.tick} 1 TICK = 0.2 SEC {mode}',7)
self.text(20,48,f'WAIT {s.wait}/{3 if carry(s) is not None else 2} / EMPTY 2 / LOADED 3 TICKS',6)
if STAGE>=3:self.text(20,64,f'CURRENT IN {4-s.tick%4} TICKS' if s.currents else 'COMPARISON: CURRENTS OFF',11)
else:self.text(20,64,'CURRENTS NOT ACTIVE IN THIS STAGE',13)
if STAGE>=4:
self.text(490,48,f'OXYGEN {s.oxygen}/60 RESCUES {s.rescues}',11 if s.oxygen>15 else 8)
pyxel.rect(490,63,240,7,5);pyxel.rect(490,63,s.oxygen*4,7,11)
else:self.text(490,48,'OXYGEN / ROBOT NOT ACTIVE YET',13)
gx,gy,cell=20,92,64
for y in range(H):
for x in range(W):
p=(x,y);px=gx+x*cell;yy=gy+y*cell;seen=bool(s.explored&(1<<(y*W+x)))
pyxel.rect(px,yy,cell-2,cell-2,5 if seen else 1);pyxel.rectb(px,yy,cell-2,cell-2,6 if seen else 5)
if p in WALLS[s.case]:pyxel.rect(px+5,yy+5,cell-12,cell-12,13)
if p==O or (STAGE>=4 and p in AIR[s.case]):
pyxel.circb(px+30,yy+26,21,10);self.text(px+4,yy+3,'O' if p==O else 'AIR',10)
if STAGE>=4:
for x,y in route(s.case):pyxel.line(gx+x*cell+5,gy+y*cell+57,gx+x*cell+55,gy+y*cell+57,13)
for i,cells,d,active in visible_bands(s):
for x,y in cells:
px=gx+x*cell+30;yy=gy+y*cell+17;dx,dy=d;ex=px+dx*14;ey=yy+dy*14;color=11 if active else 13
pyxel.line(px-dx*14,yy-dy*14,ex,ey,color)
pyxel.line(ex,ey,ex-dx*6-dy*5,ey-dy*6+dx*5,color)
pyxel.line(ex,ey,ex-dx*6+dy*5,ey-dy*6-dx*5,color)
for i,(owner,x,y) in enumerate(s.books):
if owner in (0,1):
px=gx+x*cell+24;yy=gy+y*cell+25
if owner==0:pyxel.rect(px-10,yy-5,42,34,4)
pyxel.rect(px,yy,18,24,8+i);self.text(px+6,yy+9,i+1,1)
if STAGE>=4:
x,y=robot(s);px=gx+x*cell+47;yy=gy+y*cell+45;pyxel.rect(px-10,yy-7,21,14,13)
if carry(s,3) is not None:pyxel.rect(px-4,yy-16,8,10,10)
x,y=s.pos;px=gx+x*cell+25;yy=gy+y*cell+38;pyxel.circ(px,yy,11,12);pyxel.rect(px+1,yy-5,10,5,10)
if carry(s) is not None:pyxel.rect(px+9,yy+4,10,13,8+carry(s))
self.text(618,94,'BOOKS: '+str(len(s.returned_order))+' / 3 RETURNED',10)
for i,title in enumerate(visible_books(s)):
y=126+i*74;self.text(618,y,f'{i+1}: {OWNER_LABELS[s.books[i][0]]}',7)
self.text(618,y+16,title,10 if s.revealed&(1<<i) else 6)
self.text(618,y+32,'CHANNEL OPEN' if s.opened&(1<<i) else 'SHELF CLOSED',11 if s.opened&(1<<i) else 13)
self.text(618,356,'CARRY CAPACITY: 1 BOOK',7)
self.text(618,372,'ROBOT RETURNS: '+str(s.robot_returns) if STAGE>=4 else 'ROBOT: FUTURE STAGE 04',13)
self.text(20,428,'POS '+str(s.pos)+' CARRY '+str(None if carry(s) is None else carry(s)+1)+' LAST '+(', '.join(s.last) or '(none)'),7)
self.text(20,446,'DARK FLOOR: UNEXPLORED. WALLS AND CLOSED SHELVES ARE PUBLIC.',6)
self.text(20,462,'ARROWS: PURPLE = DISCOVERED FUTURE FLOW, GREEN = OPEN ACTIVE FLOW.' if STAGE>=3 else 'APPROACH A SHELF TO REVEAL ITS TITLE. CLOSED SHELVES BLOCK SWIMMING.',6)
self.text(20,484,'HOLD ARROWS: SWIM. RELEASE ALL KEYS AFTER EVERY MODE CHANGE.',7)
if STAGE>=2:self.text(20,500,'Z: OPEN / PICK UP / RETURN'+(' / GIVE TO ADJACENT EMPTY ROBOT' if STAGE>=4 else '')+' X: DROP',7)
else:self.text(20,500,'STAGE 01: SWIM TO (2,2). BOOK OPERATIONS ARRIVE IN STAGE 02.',10)
if STAGE==5:
self.text(20,518,'SPACE: START P: PAUSE H: HELP BACKSPACE WHILE PAUSED: LAST AIR CHECKPOINT',10)
self.text(20,534,'S: SAVE AT ENTRY/AIR/RESULT L: LOAD PAUSED R/M/ESC: CONFIRM WITH ENTER, X CANCEL',7)
self.text(20,550,'TITLE: 1/2/3 CASE, B CURRENT ON/OFF. F12: SCREENSHOT. R DOES NOT RELOAD CODE.',6)
self.text(20,568,f'CHECKPOINT: TICK {u.checkpoint.tick} POS {u.checkpoint.pos} ALL WORLD STATE REWINDS',11)
else:
self.text(20,518,'P: PAUSE R: RESET 1/2/3: CASE ESC: QUIT'+(' B: CURRENT ON/OFF + RESET' if STAGE>=3 else ''),7)
self.text(20,536,'R DOES NOT RELOAD CODE. SAVE YOUR EDIT, QUIT, THEN RUN PYTHON AGAIN.',6)
if STAGE>=4:self.text(20,592,'OXYGEN: -1/TICK; ENTRY/AIR REFILLS TO 60 BEFORE RESCUE. RESCUE LEAVES THE BOOK.',6)
if u.help:self.text(20,610,'HELP PAUSES TIME. USE THE MAP, OWNER LABELS AND COUNTDOWN; H TO CLOSE.',10)
elif u.dialog:self.text(20,610,'CONFIRM '+u.dialog.upper()+': RELEASE KEYS, ENTER YES / X CANCEL.',10)
elif u.title:self.text(20,610,'RETURN THREE BOOKS. SELECT A/B/C WITH 1/2/3, THEN SPACE; RELEASE TO BEGIN.',10)
elif u.notice:self.text(20,610,getattr(self,'status_text','') or 'CONTROL NOTICE: SEE TERMINAL DETAILS.',10)
elif success(s):self.text(20,610,'ALL THREE RETURNED. ORDER '+str(tuple(i+1 for i in s.returned_order))+'; NO EXTRA QUOTA.',10)
if self.args.headless_smoke and self.frames==1:
if self.args.screenshot:pyxel.screenshot(str(self.args.screenshot),scale=1)
if self.args.state_json:
with self.args.state_json.open('x',encoding='utf-8') as f:
json.dump({'stage':STAGE,'state':asdict(s),'checkpoint':asdict(u.checkpoint),'title':u.title,'paused':u.paused,'texts':self.text_log,'visible_books':visible_books(s),'visible_bands':visible_bands(s),'boundary':self.args.boundary,'scope':'Actual Linux headless Pyxel; synthetic semantic inputs or labelled fixtures'},f,ensure_ascii=False,indent=2)
if self.snap:
if self.snap.exists():raise FileExistsError(self.snap)
pyxel.screenshot(str(self.snap),scale=1);self.snap=None
def parse_args(argv=None):
import argparse
p=argparse.ArgumentParser(description='092 standalone lesson; source and JSON use UTF-8')
p.add_argument('--headless-smoke',action='store_true')
p.add_argument('--demo-checkpoint',action='store_true')
p.add_argument('--boundary',choices=('open-current','rescue','hidden-robot','terminal','undo','comparison'))
p.add_argument('--screenshot',type=Path)
p.add_argument('--state-json',type=Path)
p.add_argument('--save-file',type=Path,default=Path('092_save.json'))
args=p.parse_args(argv)
if (args.demo_checkpoint or args.boundary or args.screenshot or args.state_json) and not args.headless_smoke:p.error('capture/demo options require --headless-smoke')
if args.boundary and (STAGE<3 or (args.boundary in ('rescue','hidden-robot','terminal') and STAGE<4) or (args.boundary=='undo' and STAGE<5)):p.error('boundary is unavailable in this lesson')
if args.boundary and args.demo_checkpoint:p.error('choose one demo')
for name,suffix in (('screenshot','.png'),('state_json','.json')):
path=getattr(args,name)
if path is not None:
path=path.expanduser().resolve()
if path.suffix.lower()!=suffix:p.error(name+' must end with '+suffix)
if not path.parent.is_dir() or path.exists():p.error(name+' parent must exist and output must be new')
setattr(args,name,path)
args.save_file=args.save_file.expanduser().resolve()
if args.save_file.suffix.lower()!='.json' or not args.save_file.parent.is_dir() or args.save_file.is_dir():p.error('save-file needs an existing parent and .json filename')
if args.save_file in (args.screenshot,args.state_json):p.error('save and capture outputs must differ')
return args
if __name__=='__main__':
args=parse_args()
import pyxel
App(args)仕組みがわかったら、元の完成版を読んでみよう。
9×5の館で、三冊の本を一冊ずつ運びます。1刻の処理は、移動→本の操作→4刻ごとの水流→ロボット→酸素→補給または救助の順です。三冊を返すと止まります。空気のある時点へ、世界全体を巻き戻せます。
ここまでに作ったのは、元のゲームの仕組みだけを取り出して作り直した「簡略版」です。元のゲームのソースは手を加えずに、別のZIP(元の完成版ソース)に入れてあります。
| 比べる点 | 5ステップの教材 | 元の完成版 |
|---|---|---|
| 段階 | 01は移動と発見、02は本の持ち主、03は水流、04は元のゲームのモデル全部。05は元のゲームの Controller も使う。最初のデータは、どの段階も館A・B・Cの元の値。 | 最初から全部の機能を使う。 |
| 描画 | 960×650の、英字(ASCII)だけの教材画面。元の題名は、model の日本語データと日本語の本文に残している。実行するときに assets(画像などの素材)はいらない。 | 1024×720。日本語の文字(グリフ)と泡、結果と説明のパネル。 |
| 記録 | 05のSとLは元のゲームの形式。保存先は、ふだんは呼び出した場所の 092_save.json。F12は、呼び出した場所に別のPNGを出す。確認用のCLI(コマンドで動かす道具)を追加。 | 元のゲームのフォルダの save.json と、media の中の、ソース・フレーム・イベントを続けて残す記録。 |
| 予告 | 見つけた水路の全範囲と、4刻のカウントダウン。まだ見つけていない題名は伏せる。いちばんよい道や、まだ入力していない先の予測はしない。 | 同じ公開のルールとカウントダウン。 |
| 足場 | 01〜03でも、State の全部の項目と、あとで使う補助の関数(helper)を先に置く。実際の step の処理は、段階ごとに増える。 | モデルの機能がすべて使える。 |
完成版のコードで、次に読むところ
model.py:walkableとdriftの、通れるかどうかの条件model.py:stepのheldと、本の操作の優先順model.py:stepの水流→ロボット→酸素の順番model.py:digestとrestoreで入力をもう一度実行するところcontroller.py:advance、transition、feedcontroller.py:saveとloadでのcheckpointの確認app.py:drawのrevealedの条件と、recordで続けて残す記録
最初は、数字かルールを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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。
うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る
