# 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. """097 鏡の国の小競り合い / 教材 1。""" STAGE = 1 from dataclasses import dataclass, asdict, replace from pathlib import Path import json DIR = ((0,-1),(-1,0),(0,1),(1,0)) WALLS = ((3,1),(3,3)) @dataclass(frozen=True) class State: pos: tuple = ((0,1),) hp: tuple = (3,) case: str = 'A' ended: str = '' def new(case='A', variant='both'): return State() def inside(p): return 0 <= p[0] < 6 and 0 <= p[1] < 5 def legal(s): out = [] for dx, dy in DIR: p = (s.pos[0][0]+dx, s.pos[0][1]+dy) if inside(p) and p not in WALLS: out.append(f'move:0:{p[0]}:{p[1]}') return tuple(out) def step(s, a): if a not in legal(s): return s _, _, x, y = a.split(':') return replace(s, pos=((int(x),int(y)),)) KEYS={'SPACE','RETURN','X','P','H','R','M','UP','DOWN','LEFT','RIGHT','TAB','1','2','3','B','F12','ESCAPE','T'} class Controller: def __init__(self,path='save_097.json'): self.path=path;self.s=new();self.title=True;self.help=False;self.dialog='';self.previous=set();self.gate=False;self.person=0;self.choice=0;self.overlay=0;self.notice='' def reset(self,case=None,variant=None): self.s=new(case or self.s.case,variant or getattr(self.s,'variant','both'));self.person=0;self.choice=0;self.dialog='';self.notice='' def options(self):return tuple(a for a in legal(self.s) if int(a.split(':')[1])==self.person) def selected(self): opts=self.options();return opts[self.choice%len(opts)] if opts else '' def apply(self,a): old=self.s;self.s=step(old,a) if self.s==old:return 'ignored:'+a self.choice=0 if not self.s.hp[self.person]:self.person=1-self.person return a def feed(self,down): down=set(down)&KEYS;fresh=down-self.previous;self.previous=down if self.gate: if not down:self.gate=False return None if not fresh:return None self.gate=True if len(fresh)>1:return 'ignored:multiple' 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 ('H','P'):self.help=not self.help;return 'help' if self.help:return 'ignored:help' if self.title: 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 self.s.ended:return 'ignored:result' if k in ('UP','DOWN'):self.choice+=(-1 if k=='UP' else 1);return 'select:action' if k=='RETURN':return self.apply(self.selected()) return 'ignored:key' # Teaching view; original model/controller behavior is separate from rendering. def tap(ui,key): ui.feed(set()) event=ui.feed({key}) ui.feed(set()) return event def commit(ui,action): """Synthetic UI exercise: select an existing option and press/release Enter.""" who=int(action.split(':')[1]) if ui.person != who:tap(ui,'TAB') opts=ui.options() if action not in opts:raise ValueError(action) for _ in range((opts.index(action)-ui.choice)%len(opts)):tap(ui,'DOWN') return tap(ui,'RETURN') class App: def __init__(self,save_path='save_097.json'): self.ui=Controller(save_path) self.event='READY' def prediction(self): if STAGE != 5 or self.ui.s.ended:return None s=self.ui.s; a=self.ui.selected() if not a:return None pred=step(s,a) # Same forecast convention as original app: chosen action, then wait. if not pred.ended and pred.round==s.round:pred=enemy_phase(pred) return pred def draw(self,p): u=self.ui;s=u.s;p.cls(1) p.text(16,12,'MIRROR SKIRMISH / STAGE '+str(STAGE),10) p.text(16,28,'CURRENT STATE / CASE '+s.case+' / '+getattr(s,'variant','training'),7) walls=WALLS if STAGE==1 else LAYOUT[s.case][3] shown=replace(s,equipped=True) if STAGE>=4 and u.overlay==2 else s for y in range(5): for x in range(6): px=16+x*62;py=56+y*62;cell=(x,y) p.rect(px,py,59,59,5 if cell in walls else 2 if (x+y)%2 else 1) p.rectb(px,py,59,59,13 if cell in walls else 5) p.text(px+3,py+3,f'{x},{y}',6) if STAGE>=3 and cell==LAYOUT[s.case][1]:p.text(px+3,py+44,'FLAG',10) if STAGE>=4 and cell==LAYOUT[s.case][2]:p.text(px+3,py+44,'ALTAR',13) if STAGE>=4 and u.overlay: if cell in zone(shown,u.person):p.rectb(px+6,py+8,47,43,7) if any(cell in zone(shown,i) for i in (2,3)):p.rectb(px+10,py+12,39,35,8) elif f'move:{u.person}:{x}:{y}' in legal(s):p.rectb(px+6,py+8,47,43,11) for i,pos in enumerate(s.pos): if not s.hp[i]:continue x=16+pos[0]*62;y=56+pos[1]*62 color=12 if i<2 else 8;p.circ(x+30,y+27,10,color) p.text(x+24,y+25,('A' if i<2 else 'E')+str(i%2+1),7) for n in range(s.hp[i]):p.rect(x+14+n*11,y+52,8,3,11 if i<2 else 9) if i==u.person:p.circb(x+30,y+27,14,10) p.text(410,56,'CURRENT HP '+str(s.hp),11) if STAGE>=2:p.text(410,72,'ACTIONS '+str(s.left)+'/2 ROUND '+str(s.round)+'/10',7) if STAGE>=3:p.text(410,88,'FLAG STREAK '+str(s.flag)+'/2',10) if STAGE>=4:p.text(410,104,'EQUIPPED '+str(s.equipped)+' / '+{'A':'PIKE','B':'HAMMER','C':'DIAGONAL'}[s.case],7) p.text(410,128,'ALLY '+str(u.person+1)+' / SELECT THEN ENTER',12) opts=u.options() for i,a in enumerate(opts): selected=i==u.choice%len(opts) if selected:p.rect(404,145+i*18,302,16,5) p.text(410,150+i*18,('> ' if selected else ' ')+a,10 if selected else 7) if STAGE==5: p.rectb(404,326,302,88,10);p.text(412,335,'FUTURE: CHOSEN ACTION + REST WAIT',10) pred=self.prediction() p.text(412,354,'HP '+str(pred.hp) if pred else ('NO FORECAST: RESULT' if s.ended else 'NO FORECAST: SELECT A LIVING ALLY'),7) if pred:p.text(412,372,'ROUND '+str(pred.round)+' FLAG '+str(pred.flag)+' END '+(pred.ended or '-'),6) p.text(412,396,'THIS BOX DOES NOT CHANGE CURRENT STATE',6) p.text(16,383,('MOVES: GREEN','CURRENT ATTACK: WHITE=ALLY / RED=ENEMY','FUTURE IF EQUIPPED: WHITE=ALLY / RED=ENEMY')[u.overlay] if STAGE>=4 else 'GREEN = LEGAL MOVE / COORDINATES START AT ZERO',10) if s.ended:p.text(16,405,'RESULT: '+s.ended.upper(),10) else:p.text(16,405,['','MOVE ONLY / NO TURNS','STATIC ENEMIES / NO FLAG','UNARMED / ENEMIES + FLAG',('BOTH ARMIES EQUIP AT ONCE' if getattr(s,'variant','both')=='both' else 'COMPARISON: ONLY ALLIES EQUIP'),'X UNDO / S SAVE / L LOAD'][STAGE],6) p.text(16,435,'UP/DOWN ACTION / ENTER COMMIT' if STAGE==1 else 'UP/DOWN ACTION / LEFT/RIGHT/TAB ALLY / ENTER COMMIT',7) p.text(16,452,'R RETRY / M TITLE / H HELP / ESC QUIT: THEN ENTER OR X CANCEL',6) p.text(16,469,'RELEASE EVERY KEY / R RESETS STATE, DOES NOT RELOAD CODE',6) p.text(16,490,'LAST INPUT: '+self.event[:100],10) if u.notice:p.text(410,310,'NOTICE: '+('SAVED' if self.event=='save' and '保存しました' in u.notice else 'LOADED' if self.event=='load' and '保存を読みました' in u.notice else 'IO FAILED / CURRENT STATE KEPT'),10) if u.title or u.help: p.rect(40,65,640,350,1);p.rectb(40,65,640,350,10) lines=['MIRROR SKIRMISH / STAGE '+str(STAGE),'SPACE / ENTER START (RELEASE AFTER PRESS)', 'UP/DOWN SELECT ACTION, ENTER COMMITS ONE ACTION', 'LEFT/RIGHT/TAB SELECT ALLY (STAGE 02+)', '1/2/3 SELECT CASE AT TITLE (STAGE 03+)', 'B: BOTH / ALLY EQUIPMENT AT TITLE (STAGE 04+)', 'T: MOVE / CURRENT REACH / IF EQUIPPED (STAGE 04+)', 'X: UNDO, S: SAVE, L: LOAD (STAGE 05)', 'H OR P: HELP / INPUT PAUSED WHILE HELP IS OPEN', 'R/M/ESC: CONFIRM WITH ENTER, CANCEL WITH X', 'ASCII TEACHING VIEW / SEE JAPANESE LESSON.md', 'CURRENT CASE '+s.case+' / '+getattr(s,'variant','training')] for j,line in enumerate(lines):p.text(58,88+j*25,line,10 if j in (0,11) else 7) if u.dialog: p.rect(80,175,560,120,1);p.rectb(80,175,560,120,8) p.text(104,198,'CONFIRM '+u.dialog.upper(),10) p.text(104,229,'RELEASE ALL KEYS / ENTER YES / X CANCEL',7) p.text(104,260,'R DOES NOT RELOAD MODIFIED PYTHON SOURCE',6) def demo(app): u=app.ui tap(u,'SPACE') if STAGE==1:commit(u,'move:0:0:2') elif STAGE==2:commit(u,'attack:0:2') elif STAGE==3: commit(u,'move:0:0:2');commit(u,'move:0:1:2') else: tap(u,'M');tap(u,'RETURN');tap(u,'2');tap(u,'SPACE') commit(u,'pick:0');commit(u,'move:0:2:1') app.event='SYNTHETIC CHECKPOINT' def main(headless=False,screenshot=None,state_json=None,checkpoint=False): # Save path is also resolved before Pyxel can change the process cwd. save_path=str((Path.cwd()/'save_097.json').resolve()) import pyxel as p app=App(save_path) if checkpoint:demo(app) p.init(720,520,title='Mirror lesson '+str(STAGE),fps=30,display_scale=1,quit_key=p.KEY_NONE,headless=headless) keys={k:getattr(p,'KEY_'+k) for k in KEYS} def update(): if headless and p.frame_count>=5: app.draw(p) if screenshot:p.screen.save(screenshot,1) if state_json:Path(state_json).write_text(json.dumps({'stage':STAGE,'state':asdict(app.ui.s),'title':app.ui.title,'scope':'actual Linux headless Pyxel, synthetic controls'},ensure_ascii=False,indent=2)+'\n',encoding='utf-8') p.quit();return e=app.ui.feed({k for k,v in keys.items() if p.btn(v)}) if not headless else None if e: app.event=e if e=='quit':p.quit() p.run(update,lambda:app.draw(p)) if __name__=='__main__': import argparse parser=argparse.ArgumentParser(description='Standalone mirror tutorial') parser.add_argument('--headless-smoke',action='store_true') parser.add_argument('--demo-checkpoint',action='store_true') parser.add_argument('--screenshot') parser.add_argument('--state-json') args=parser.parse_args() if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke:parser.error('outputs/checkpoint require --headless-smoke') for field,suffix in (('screenshot','.png'),('state_json','.json')): value=getattr(args,field) if value is not None: target=Path(value).resolve() if target.suffix.lower()!=suffix or not target.parent.is_dir() or target.exists():parser.error('output must be a new '+suffix+' in an existing directory') setattr(args,field,str(target)) main(args.headless_smoke,args.screenshot,args.state_json,args.demo_checkpoint)