# -*- coding: utf-8 -*- # 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. STAGE = 4 """Simultaneous guests follow their own wishes; closing stalls opens public paths.""" from dataclasses import dataclass,replace,asdict,fields from collections import deque import hashlib,json GAME_ID='094';VERSION='094-1.0' NAMES=('ユイ','タク','エン');STORES=('茶店','面屋','花火屋');PLACES=('出口','茶前','花火前','橋','面前','舞台');SHOP_POS=(1,4,2) PERMANENT=((0,3),(3,4),(4,5),(5,2),(2,1),(1,0));SHORTCUTS=((0,4),(1,4),(1,5)) TITLES={'A':'あと一杯のお茶','B':'舞台の帰り','C':'橋の向こうの三人'} INITIAL={'A':((5,0),(3,1),(2,2)),'B':((5,1),(5,0),(4,2)),'C':((3,2),(4,0),(5,1))} @dataclass(frozen=True) class Guest: pos:int;wish:int;status:str='pending';exited:bool=False;guided:bool=False;shortcuts:int=0;exit_t:int=-1;farewell:str='' @dataclass(frozen=True) class State: case:str='A';variant:str='paths';t:int=0;opened:tuple=(True,True,True);guests:tuple=();sales:tuple=(0,0,0);last:int=-1;closed_self:int=0;cleanup:bool=False;phase:str='play';walk_t:int=0;notes:tuple=('終わるお祭りに、どの道を残そう。',) def new(case='A',variant='paths'): if case not in INITIAL or variant not in ('paths','fixed'):raise ValueError('setup') return State(case=case,variant=variant,guests=tuple(Guest(p,w) for p,w in INITIAL[case])) def edges(s):return PERMANENT+tuple(e for i,e in enumerate(SHORTCUTS) if not s.opened[i] and s.variant=='paths') def route(s,start,target): if start==target:return () graph={i:[] for i in range(6)} for a,b in edges(s):graph[a].append(b);graph[b].append(a) dist={target:0};q=deque([target]) while q: p=q.popleft() for n in graph[p]: if n not in dist:dist[n]=dist[p]+1;q.append(n) result=[];p=start while p!=target:p=min(n for n in graph[p] if dist[n]==dist[p]-1);result.append(p) return tuple(result) def goal(g):return SHOP_POS[g.wish] if g.status=='pending' else 0 def goodbye(s,i,g): first=NAMES[i]+'「'+(STORES[g.wish]+'に寄れた。' if g.status=='bought' else STORES[g.wish]+'はまた今度。') middle='脇道が帰り道になった。' if g.shortcuts else '広場の外周を歩いて帰るね。' end='案内ありがとう。' if g.guided else ('さっき'+STORES[s.last]+'を閉めたんだね。' if s.last>=0 else 'まだお祭りの灯りが見えるね。') return first+middle+end+'」' def move_guests(s,guide=-1,walkout=False): result=[];notes=[] for i,old in enumerate(s.guests): if old.exited:result.append(old);continue g=replace(old,guided=old.guided or guide==i) if g.status=='pending' and not s.opened[g.wish]:g=replace(g,status='skipped');notes.append(NAMES[i]+'の'+STORES[g.wish]+'は今回は見送り。') target=goal(g);path=route(s,g.pos,target);steps=path[:2 if guide==i else 1];prev=g.pos;bits=g.shortcuts for n in steps: for j,e in enumerate(SHORTCUTS): if s.variant=='paths' and not s.opened[j] and set((prev,n))==set(e):bits|=1<1:return 'reject:chord' k=next(iter(fresh));self.notice='' if k=='F12':return 'screenshot' if self.dialog: if k in ('X','ESCAPE'):self.dialog='';return 'cancel' if k=='RETURN': d=self.dialog;self.dialog='' 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';return 'confirm:quit' if k in ('P','H'):self.help=not self.help;return 'help' if self.help:return 'ignored:help' if self.detail: if k in ('Z','X'):self.detail=False;return 'detail:close' return 'ignored:detail' if self.title: if k in ('1','2','3'):self.reset('ABC'[int(k)-1]);return 'case' if k=='B':self.s=new(self.s.case,'fixed' if self.s.variant=='paths' else 'paths');self.history=[];self.log=[];return 'variant' if k in ('SPACE','RETURN'):self.title=False;return 'start' return 'ignored:title' if k=='M':self.dialog='menu';return 'confirm:menu' if k=='R':self.dialog='retry';return 'confirm:retry' if k=='X':return self.apply('undo') if k=='Z':self.detail=True;return 'detail:open' if s:=self.s: if s.phase=='walkout': if k=='SPACE':self.auto=not self.auto;return 'auto' if k=='RETURN':return self.apply('walk') return 'ignored:walkout' if s.phase=='done':return 'ignored:done' if k=='TAB':self.mode=(self.mode+1)%3;return 'select:mode' if k in ('UP','LEFT'):self.target=(self.target-1)%3;return 'select:target' if k in ('DOWN','RIGHT'):self.target=(self.target+1)%3;return 'select:target' if k=='SPACE':self.mode=2;return 'select:wait' if k=='RETURN':return self.apply(self.selection()) return 'ignored:key' import argparse import json from pathlib import Path POINTS = ((45,96),(166,96),(287,96),(45,232),(166,232),(287,232)) COLORS = (10,12,14) SHOP_LABELS = ('TEA','MASK','FIREWORK') def tap(ui, key): event = ui.feed({key}) ui.feed(set()) return event def choose(ui, action): if STAGE == 1: while ui.target != action: tap(ui,'RIGHT') elif action == 'walk': pass elif action == 'wait': tap(ui,'SPACE') else: mode = 0 if action.startswith('close:') else 1 while ui.mode != mode: tap(ui,'TAB') target = int(action[-1]) while ui.target != target: tap(ui,'RIGHT') def commit(ui, action): choose(ui, action) return tap(ui,'RETURN') def demo(ui, scenario): if scenario == 'initial': return if ui.title: if scenario in ('case_b','case_c','fixed'): tap(ui,{'case_b':'2','case_c':'3','fixed':'B'}[scenario]) tap(ui,'RETURN') if scenario in ('case_b','case_c','fixed') and STAGE >= 4: commit(ui,'close:0' if scenario == 'fixed' else 'wait') return if scenario == 'finish_practice' and STAGE == 2: for _ in range(6):commit(ui,'wait') return if scenario == 'loaded' and STAGE == 5: tap(ui,'L') return if scenario == 'checkpoint': commit(ui,0 if STAGE == 1 else 'guide:0' if STAGE == 2 else 'close:0') elif scenario == 'confirm': commit(ui,0 if STAGE == 1 else 'wait');tap(ui,'R') elif scenario == 'arrived' and STAGE == 1: for _ in range(3):commit(ui,0) elif scenario == 'guide' and STAGE >= 2: commit(ui,'guide:0') elif scenario == 'preview' and STAGE >= 2: choose(ui,'guide:0') elif scenario in ('closed','invalid_close') and STAGE >= 3: commit(ui,'close:2') if scenario == 'invalid_close':commit(ui,'close:2') elif scenario in ('success','cleanup','sixth','walkout','undo','saved','loaded','auto_help','auto_detail','auto_done') and STAGE >= 3: if scenario in ('walkout','auto_help','auto_detail','auto_done'): # Explicit constructed boundary, not presented as a reachable natural campaign. ui.s = replace(ui.s,t=5,guests=(Guest(5,0),Guest(4,2),Guest(3,2))) commit(ui,'wait') elif scenario == 'cleanup': for _ in range(6):commit(ui,'wait') else: for action in ('guide:0','close:0','close:1','close:2','wait','wait')[:5 if scenario=='sixth' else 6]: commit(ui,action) if scenario in ('success','cleanup','saved','loaded'): while ui.s.phase == 'walkout':commit(ui,'walk') if scenario == 'undo':tap(ui,'X') if scenario in ('saved','loaded'):tap(ui,'S' if scenario=='saved' else 'L') if scenario.startswith('auto_'): tap(ui,'SPACE') if scenario == 'auto_help':tap(ui,'H') if scenario == 'auto_detail':tap(ui,'Z') elif scenario == 'detail' and STAGE >= 4: tap(ui,'Z') else: raise ValueError('scenario unavailable') def parse_args(argv=None): p = argparse.ArgumentParser(description='Single-file Pyxel lesson 094') p.add_argument('--headless-smoke',action='store_true') p.add_argument('--demo-checkpoint',action='store_true') scenarios=['initial','checkpoint','confirm']+(['arrived'] if STAGE==1 else ['guide','preview']) if STAGE==2:scenarios+=['finish_practice'] if STAGE>=3:scenarios+=['closed','invalid_close','success','cleanup','sixth','walkout'] if STAGE>=4:scenarios+=['undo','detail','auto_help','auto_detail','auto_done','case_b','case_c','fixed'] if STAGE==5:scenarios+=['saved','loaded'] p.add_argument('--scenario',choices=scenarios) p.add_argument('--screenshot',type=Path) p.add_argument('--state-json',type=Path) p.add_argument('--save-file',type=Path,default=Path('save_094.json')) a=p.parse_args(argv) if not a.headless_smoke and (a.demo_checkpoint or a.scenario or a.screenshot or a.state_json):p.error('diagnostics require --headless-smoke') if a.demo_checkpoint and a.scenario:p.error('choose checkpoint or scenario') for name,suffix in (('screenshot','.png'),('state_json','.json')): path=getattr(a,name) if path is None:continue path=path.resolve() if path.suffix.lower()!=suffix or not path.parent.is_dir() or path.exists():p.error(name+': require a new file of correct type in an existing directory') setattr(a,name,path) a.save_file=a.save_file.resolve() if a.save_file.suffix.lower()!='.json' or not a.save_file.parent.is_dir() or a.save_file.is_dir():p.error('save-file: require .json in an existing directory') if a.save_file in (a.screenshot,a.state_json):p.error('save-file and diagnostic output must differ') if a.scenario=='saved' and a.save_file.exists():p.error('saved scenario requires a new save file') return a class App: def __init__(self,args): self.args=args;self.ui=Controller(args.save_file);self.frame=0;self.auto_clock=0 if args.headless_smoke:demo(self.ui,args.scenario or ('checkpoint' if args.demo_checkpoint else 'initial')) self.capture_frame=182 if args.scenario in ('auto_done','auto_help','auto_detail') else 2 pyxel.init(600,420,title='094 FESTIVAL / STAGE '+str(STAGE),fps=30,quit_key=pyxel.KEY_NONE,headless=args.headless_smoke,display_scale=1) self.keys={key:getattr(pyxel,'KEY_'+key) for key in KEYS} pyxel.run(self.update,self.draw) def advance_auto(self): u=self.ui if u.auto and getattr(u.s,'phase','play')=='walkout' and not (u.title or u.help or u.detail or u.dialog or u.gate): self.auto_clock+=1 if self.auto_clock>=30: self.auto_clock=0;u.apply('walk') else:self.auto_clock=0 def update(self): self.frame+=1 if self.args.headless_smoke: self.advance_auto() if self.frame>self.capture_frame:pyxel.quit() return event=self.ui.feed({key for key,code in self.keys.items() if pyxel.btn(code)}) if event=='quit':pyxel.quit() self.advance_auto() def draw(self): u,s=self.ui,self.ui.s;pyxel.cls(1) pyxel.text(12,10,'094 FESTIVAL AFTERGLOW | STAGE '+str(STAGE),10) pyxel.text(12,25,'TICK: '+str(s.t)+' PHASE: '+getattr(s,'phase','walk').upper(),7) edge_list=edges(s) if STAGE>=3 else PERMANENT for a,b in edge_list: x,y=POINTS[a];xx,yy=POINTS[b] pyxel.line(x,y,xx,yy,11 if (a,b) not in PERMANENT else 5) for i,(x,y) in enumerate(POINTS): pyxel.circ(x,y,15,5);pyxel.circb(x,y,15,6) # Solid label background prevents graph edges crossing text. pyxel.rect(x-20,y+21,62,10,1) pyxel.text(x-19,y+23,str(i)+' '+('EXIT','TEA','FIREWORK','BRIDGE','MASK','STAGE')[i],7) guests=() if STAGE==1 else s.guests if STAGE==1: x,y=POINTS[s.pos];pyxel.circ(x,y,5,10) else: for i,g in enumerate(guests): x,y=POINTS[g.pos];x+=(i-1)*9 if g.exited:pyxel.circb(x,y,4,COLORS[i]) else:pyxel.circ(x,y,4,COLORS[i]) pyxel.rect(345,48,243,228,2) if STAGE==1: pyxel.text(355,60,'POSITION: '+str(s.pos)+' TARGET: '+str(u.target),10) pyxel.text(355,80,'PATH: '+str(route(s,s.pos,u.target)),7) pyxel.text(355,104,'Shortest path; small next node wins ties.',6) else: for i,g in enumerate(guests): y=60+i*48 pyxel.text(355,y,'G'+str(i)+' POS '+str(g.pos)+' WISH '+SHOP_LABELS[g.wish],COLORS[i]) pyxel.text(355,y+13,g.status.upper()+(' / HOME' if g.exited else ''),7) pyxel.text(355,y+26,'PATH '+str(route(s,g.pos,goal(g))),6) if STAGE>=3: pyxel.text(355,213,'OPEN: '+str(tuple(int(v) for v in s.opened)),11) pyxel.text(355,229,'SALES: '+str(s.sales)+' SELF: '+str(s.closed_self),7) pyxel.text(355,245,'CLEANUP: '+str(int(s.cleanup))+' WALK: '+str(s.walk_t),7) phase=getattr(s,'phase','play') pyxel.rect(12,286,576,42,2) if phase=='play': selected=u.selection();preview=step(s,selected) pyxel.text(20,295,'SELECT: '+str(selected)+' PREVIEW ONLY',10) if STAGE==1:message='NEXT POS '+str(preview.pos) elif preview==s:message='INVALID / no time or history change' else:message='NEXT POS '+str(tuple(g.pos for g in preview.guests))+' NEXT TICK '+str(preview.t) pyxel.text(20,312,message,6) elif phase=='walkout': pyxel.text(20,295,'SIX SLOTS ENDED / WALKOUT ONLY',10) pyxel.text(20,312,'ENTER: next step SPACE: auto (stage 4+) / no more sales',7) else: achieved=complete(s) if STAGE>=3 else all(g.exited for g in s.guests) pyxel.text(20,295,'RESULT: '+('SELF-CLOSED + ALL HOME' if achieved and STAGE>=3 else 'ALL HOME' if achieved else 'CLEANUP HELP / REQUEST NOT COMPLETE'),11) pyxel.text(20,312,'Result is not a single moral or enjoyment score.',7) pyxel.text(12,342,'Arrows: target ENTER: commit one step R: confirm restart',7) if STAGE>=2:pyxel.text(12,358,'TAB: mode SPACE: select wait Release ALL keys between presses',6) if STAGE>=4:pyxel.text(12,374,'X: Undo Z: detail H/P: help M: menu Title 1/2/3 + B',6) if STAGE==5:pyxel.text(12,390,'S/L: save/load current state; UTF-8 JSON in caller folder',6) pyxel.text(12,407,'ESC: confirm quit. R never reloads edited Python code.',7) if u.title or u.help: pyxel.rect(35,65,530,203,1);pyxel.rectb(35,65,530,203,10) pyxel.text(50,83,'SIX SLOTS: CLOSE THREE STALLS AND SEE EVERYONE HOME',10) pyxel.text(50,110,'TAB: close / guide / wait. Arrows: target. ENTER commits.',7) pyxel.text(50,132,'Each guest keeps their own wish. Guiding means two edges.',7) pyxel.text(50,154,'Closing happens before walking and buying. Sales count open stalls.',7) pyxel.text(50,176,'Case '+s.case+' Variant '+s.variant+' / 1,2,3 case; B variant',11) pyxel.text(50,200,'ENTER starts. H/P closes help. Release all keys first.',10) pyxel.text(50,223,'After six slots: only walkout; cleanup is not self-closure.',6) if u.detail: g=s.guests[u.target];pyxel.rect(35,65,530,203,1);pyxel.rectb(35,65,530,203,11) pyxel.text(50,83,'PUBLIC GUEST FACTS / G'+str(u.target),10) pyxel.text(50,110,'WISH '+SHOP_LABELS[g.wish]+' POS '+str(g.pos)+' '+g.status.upper(),7) pyxel.text(50,132,'GUIDED '+str(g.guided)+' SHORTCUT BITS '+str(g.shortcuts),7) pyxel.text(50,154,'EXIT TICK '+str(g.exit_t)+' LAST SELF-CLOSED '+str(s.last),7) pyxel.text(50,178,'Japanese farewell is retained in state JSON and source.',6) pyxel.text(50,220,'Z/X closes detail. World and auto playback are paused.',10) if u.dialog: pyxel.rect(35,110,530,114,1);pyxel.rectb(35,110,530,114,8) pyxel.text(50,133,'CONFIRM '+u.dialog.upper(),10) pyxel.text(50,158,'Release ALL keys. ENTER confirms; X cancels.',7) pyxel.text(50,183,'Restart resets this evening, leaving saved files untouched.',6) if u.notice: msg='SAVE OK' if '保存しました' in u.notice else 'LOAD OK' if '読み込みました' in u.notice else 'SAVE/LOAD ERROR: original file preserved' pyxel.rect(345,30,243,11,1);pyxel.text(350,32,msg,10) if self.args.headless_smoke and self.frame==self.capture_frame: if self.args.screenshot:pyxel.screenshot(str(self.args.screenshot),scale=1) if self.args.state_json: data={'stage':STAGE,'state':asdict(s),'mode':u.mode,'target':u.target,'title':u.title,'dialog':u.dialog,'detail':u.detail,'help':u.help,'auto':u.auto,'history':[asdict(x) for x in u.history],'scope':'Actual Pyxel Linux headless; synthetic input; walkout scenarios use an explicitly constructed boundary'} with self.args.state_json.open('x',encoding='utf-8') as f:json.dump(data,f,ensure_ascii=False,indent=2) if __name__=='__main__': args=parse_args() import pyxel App(args)