# 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]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<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<=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<=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)