完成サンプルをブラウザーで遊ぶ
この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。
完成版の主な操作:左右・Tab味方/上下行動/Enter確定、T射線表示、X一行動戻す、R再開、S保存/L読込
ボタンを押すと、ゲームの読み込みが始まります。
画面に「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は01で説明します /deque・JSON・Path・例外(エラー)は段階を追って読みます - 教材に入っているもの
- ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
- 完成した教材の操作
- Space/Enter:表紙で開始 / 上下:行動選択 / 左右/Tab:02以降、味方選択 / Enter:一行動確定 / 1/2/3:03以降、表紙で配置 / B:04以降、表紙で両軍/味方のみ比較 / T:04以降、移動/現在射線/装備仮定射線 / X:05でUndo。確認中は取消 / S/L:05で作業フォルダーへ保存/読込 / H/P:説明と停止 / R/M/Esc:再試行/表紙/終了の確認。離してEnter確定
- 難易度の目安
- 中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
- 学習順に迷ったら
- おすすめの学ぶ順番を見る
コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。
教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。
盤面の境界と、一歩だけの移動
tuple・状態を変えない関数・選ぶことと確定すること。
前提は、Python の変数、if、for、関数と tuple です。dataclass(frozen=True) は書き換えられない記録の型、replace は一部だけを差しかえた別の記録を作る関数です。この段階は、元のゲームの状況 A の6×5の盤面と、壁 (3,1),(3,3) を借りて、味方 A1 だけを (0,1) に置きます。敵、ターン、装備、旗の判定はまだありません。完成したルールを隠している段階ではなく、State は位置と表示に必要な最小限の情報だけです。
座標は左上が (0,0)、右下が (5,4) です。DIR は、上 → 左 → 下 → 右 の順の、座標の差です。legal は、今の位置に差を足して、inside(盤の中か)と壁の検査を通った行き先だけを、文字列にします。たとえば move:0:0:2 は「駒0を (0,2) へ動かす」という命令です。step は、命令が legal に入っているかを先に調べ、正しくない文字列や壁への命令なら、同じ State を返します。画面のカーソルが動いても、step を呼ぶまでは位置は変わりません。
起動すると、まず表紙です。Space を1回押して離し、下を1回押して離すと、move:0:0:2 が選ばれます。Enter を押して離すと、A1 が1段下に動きます。右のパネルの命令は0から数え、駒の名前 A1 は人が読むために1から数えます。座標の数字、駒の中央、HP の短い線は、分けて表示しています。何回描いても、駒は動きません。
下のコードは 01_movement.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if a not in legal(s):
return s
_, _, x, y = a.split(':')実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 01_movement.py.venv/bin/python 01_movement.pymove:0:0:0 のまま Enter を押して離すと (0,0) です。壁や盤の外は、候補に出てきません。- 盤の外と壁を、候補から外す
- 候補を選ぶだけでは、動かない
- 無効な命令では、状態を変えない
pos を書き換えると、予測や Undo の準備に使えないので、replace で別の状態を返します。pos: tuple = ((0,1),) を pos: tuple = ((1,1),) に1行変えてみましょう。UTF-8 で保存 → Esc を押して離す → Enter を押して離す で終了 → python 01_movement.py で実行し直します。Space を押して離す → Enter を押して離す と (1,0) です。変える前の (0,0) との違いを確かめたら、元に戻します。python exercises/01_edit_control.py は、同じ書き換えをメモリーの中で実行します。R ではコードを読み直しません。
このステップのコード全文(247行)
# 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)二人に配る二行動と、体力の減算
回数の限られた行動・場所がふさがっていること・終わり。
前提は、01 と、list/tuple の変換、番号(添字)での選択です。味方 A1/A2、敵 E1/E2 の4体と HP を加えます。この段階だけ、練習用に A1=(2,1)、A2=(0,3)、E1=(2,2)、E2=(5,3) に置き、すぐとなりに攻撃を試せるようにしています。元のゲームの状況 A の開始の配置とは違います。壁は状況 A のままです。敵は動かず、旗と装備もまだありません。
1人の味方専用の2回の行動ではありません。left(残りの行動回数)は2人で共通です。同じ味方を2回動かしてもよく、Tab か左右キーで、操作する味方を替えます。legal は、生きている駒だけを対象に、空いているとなりのマスへの移動、縦横のとなりの敵への攻撃、待つ、を書き出します。倒れた駒は場所をふさぎません。攻撃は HP を1減らし、max(0,...) でマイナスにならないようにします。敵2体の HP が0なら defeated(全滅させた)です。
1回行動するごとに left を減らし、2回目の行動のあとは left=2 に戻して、round を1増やします。この段階では、敵の番のかわりに、時計だけが進みます。10周で draw(引き分け)です。勝ち負けが決まる攻撃では、その結果をそのまま残し、そのあと legal は空になります。01 のように何回でも駒を動かせる課題から、行動の回数と終わりのある、小さな対戦に進みます。元のゲームの敵の反撃は、03 で加えます。
下のコードは 02_action_budget.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
left=s.left-1
if left==0 and not s.ended:
n=s.round+1実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 02_action_budget.py.venv/bin/python 02_action_budget.pyattack:0:2 を選び、Enter を押します。CURRENT HP=(3,3,2,3)、ACTIONS=1、ROUND=0 です。wait:0 を選んで Enter を押すと、ACTIONS=2、ROUND=1 で、敵の位置は変わりません。- 2人で分け合う、2回の行動
- 生きている駒は、通り道をふさぐ
- 結果が出たら、できる操作は空になる
legal で、ルール上できるかを確かめます。行動の回数は味方ごとではなく共通で、待つことも1回の行動です。def damage(s,i): のすぐ下の return 1 を return 2 に変えてみましょう。UTF-8 で保存 → Esc、離す、Enter、離す で終了 → python 02_action_budget.py で実行し直します。Space を押して離す → 下を2回(毎回離す)→ Enter を押して離す と、HP=(3,3,1,3)、left=1 です。python exercises/02_edit_control.py で、書き換えた版と同じ入力を実行し、最後に1に戻します。R は、読みこみ済みの状態を最初に戻すだけです。
このステップのコード全文(282行)
# 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 鏡の国の小競り合い / 教材 2。"""
STAGE = 2
from dataclasses import dataclass, asdict, replace
from collections import deque
from pathlib import Path
import json, hashlib, os
VERSION=1
TITLES={'A':'伸びる槍','B':'近すぎる重槌','C':'角の向こう'}
EQUIPMENT={'A':'長槍','B':'重槌','C':'斜め刃'}
LAYOUT={'A':(((0,1),(0,3),(5,1),(5,3)),(2,2),(1,2),((3,1),(3,3))), 'B':(((1,1),(1,3),(4,1),(4,3)),(2,2),(2,1),((3,2),)), 'C':(((0,2),(1,4),(5,0),(5,3)),(3,2),(2,3),((2,1),(3,1),(4,3)))}
DIR=((0,-1),(-1,0),(0,1),(1,0))
@dataclass(frozen=True)
class State:
case: str = 'A'
pos: tuple = ((2,1),(0,3),(2,2),(5,3))
hp: tuple = (3,3,3,3)
left: int = 2
round: int = 0
ended: str = ''
def new(case='A',variant='both'):
return State()
def reach(s,i,p):
return bool(s.hp[i]) and inside(p) and p not in LAYOUT[s.case][3] and md(s.pos[i],p)==1
def damage(s,i):
return 1
def inside(p):return 0<=p[0]<6 and 0<=p[1]<5
def md(a,b):return abs(a[0]-b[0])+abs(a[1]-b[1])
def living(s,p):return [i for i in range(4) if s.hp[i] and s.pos[i]==p]
def victory(s):
if not any(s.hp[:2]):return replace(s,ended='lost')
if not any(s.hp[2:]):return replace(s,ended='defeated')
return s
def attack(s,i,j):
hp=list(s.hp);hp[j]=max(0,hp[j]-damage(s,i));return victory(replace(s,hp=tuple(hp)))
def legal(s):
if s.ended:return ()
out=[]
for i in range(2):
if not s.hp[i]:continue
for dx,dy in DIR:
p=(s.pos[i][0]+dx,s.pos[i][1]+dy)
if inside(p) and p not in LAYOUT[s.case][3] and not living(s,p):out.append(f'move:{i}:{p[0]}:{p[1]}')
for j in (2,3):
if s.hp[j] and reach(s,i,s.pos[j]):out.append(f'attack:{i}:{j}')
out.append(f'wait:{i}')
return tuple(out)
def step(s,a):
if a not in legal(s):
return s
bits=a.split(':'); i=int(bits[1])
if bits[0]=='move':
pos=list(s.pos); pos[i]=(int(bits[2]),int(bits[3])); s=replace(s,pos=tuple(pos))
elif bits[0]=='attack':
s=attack(s,i,int(bits[2]))
left=s.left-1
if left==0 and not s.ended:
n=s.round+1
return replace(s,left=2,round=n,ended='draw' if n>=10 else '')
return replace(s,left=left)
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 ('LEFT','RIGHT','TAB'):self.person=1-self.person;self.choice=0;return 'select:unit'
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)敵の最短路と、旗の連続占領
幅優先探索・順番・続けて満たす条件。
前提は、02 と、deque、集合、min の key です。元のゲームの状況 A/B/C の配置を取り入れます。表紙で 1/2/3 を押して離すと配置が変わり、Space を押して離すと始まります。装備だけはまだありません。State に flag(旗)と events(出来事)を加え、元のゲームの enemy_choice と enemy_phase をそのまま使います。
敵は、E1、次に E2 の順に処理します。2体とも古い盤面から同時に計画する仕組みではありません。E2 は、E1 が移動・攻撃したあとの状態を見ます。攻撃できる味方がいれば、HP の低い味方、同じなら ID の小さい味方をねらいます。攻撃できないときは、幅優先探索で、味方のとなりに行ける道の長さを比べます。同じ距離なら HP の低い順 → ID の小さい順で、道を広げる順番は 上 → 左 → 下 → 右 です。壁と生きている駒が、道をふさぎます。移動した敵が、さらに攻撃することはありません。
旗の判定は、敵の番の最後です。生きている味方が旗の上にいれば flag を1増やし、2回続けて終えれば、旗による勝ちです。味方の行動で旗が空いた瞬間にも0に戻すので、同じ2回の行動の中で戻ってきても、続けた回数は最初からになります。味方の全滅は旗より先に lost(負け)になり、すでに決まった勝ち負けを、旗で上書きすることはありません。敵の番10回で draw です。画面の ROUND は、終わった敵の番の数で、入力したフレームの数や、押し続けた時間ではありません。
下のコードは 03_enemy_flag.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
n=s.round+1;count=s.flag+1 if any(s.hp[i] and s.pos[i]==LAYOUT[s.case][1] for i in range(2)) else 0
end=s.ended or ('flag' if count>=2 else ('draw' if n>=10 else ''))実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 03_enemy_flag.py.venv/bin/python 03_enemy_flag.pymove:0:0:2 を選んで Enter、次に move:0:1:2 を選んで Enter(キーは毎回全部離す)と押します。ROUND=1、E1=(4,1)、E2=(5,2)、味方の HP は 3/3 です。まだ FLAG=0 です。- E2 は、E1 が動いたあとの状態を見る
- 旗を空けると、続けた回数が切れる
- 全滅と旗の、優先順位
enemy_phase の count>=2 を count>=1 に変えて UTF-8 で保存 → Esc を離して Enter で終了 → python 03_enemy_flag.py で実行し直してみましょう。表紙で 1 を押して離す → Space を押して離す。上下で右の欄を選び、move:0:0:2、move:0:1:2、move:0:2:2、wait:0 の順に、それぞれ Enter で確定し、キーは毎回全部離します。ROUND=2、FLAG=1 で、旗による勝ちになります。python exercises/03_edit_control.py で同じ操作を実行できます。確認したら2に戻します。R はコードを読み直しません。
このステップのコード全文(311行)
# 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 鏡の国の小競り合い / 教材 3。"""
STAGE = 3
from dataclasses import dataclass, asdict, replace
from collections import deque
from pathlib import Path
import json, hashlib, os
VERSION=1
TITLES={'A':'伸びる槍','B':'近すぎる重槌','C':'角の向こう'}
EQUIPMENT={'A':'長槍','B':'重槌','C':'斜め刃'}
LAYOUT={'A':(((0,1),(0,3),(5,1),(5,3)),(2,2),(1,2),((3,1),(3,3))), 'B':(((1,1),(1,3),(4,1),(4,3)),(2,2),(2,1),((3,2),)), 'C':(((0,2),(1,4),(5,0),(5,3)),(3,2),(2,3),((2,1),(3,1),(4,3)))}
DIR=((0,-1),(-1,0),(0,1),(1,0))
@dataclass(frozen=True)
class State:
case: str = 'A'
pos: tuple = LAYOUT['A'][0]
hp: tuple = (3,3,3,3)
left: int = 2
round: int = 0
flag: int = 0
ended: str = ''
events: tuple = ()
def new(case='A',variant='both'):
return State(case=case,pos=LAYOUT[case][0])
def reach(s,i,p):
return bool(s.hp[i]) and inside(p) and p not in LAYOUT[s.case][3] and md(s.pos[i],p)==1
def damage(s,i):
return 1
def inside(p):return 0<=p[0]<6 and 0<=p[1]<5
def md(a,b):return abs(a[0]-b[0])+abs(a[1]-b[1])
def living(s,p):return [i for i in range(4) if s.hp[i] and s.pos[i]==p]
def victory(s):
if not any(s.hp[:2]):return replace(s,ended='lost')
if not any(s.hp[2:]):return replace(s,ended='defeated')
return s
def attack(s,i,j):
hp=list(s.hp);hp[j]=max(0,hp[j]-damage(s,i));return victory(replace(s,hp=tuple(hp)))
def enemy_choice(s,i):
targets=[j for j in range(2) if s.hp[j]]
hit=[j for j in targets if reach(s,i,s.pos[j])]
if hit:return ('attack',min(hit,key=lambda j:(s.hp[j],j)))
# BFS stops next to targets. All living pieces and walls obstruct routes.
blocked=set(LAYOUT[s.case][3])|{s.pos[j] for j in range(4) if s.hp[j] and j!=i};q=deque([(s.pos[i],None,0)]);seen={s.pos[i]};candidates=[]
while q:
p,first,dist=q.popleft()
for j in targets:
if md(p,s.pos[j])==1:candidates.append((dist+1,s.hp[j],j,first))
for dx,dy in DIR:
n=(p[0]+dx,p[1]+dy)
if inside(n) and n not in blocked and n not in seen:seen.add(n);q.append((n,first or n,dist+1))
if not candidates:return ('wait',None)
best=min(candidates,key=lambda t:t[:3]);return ('move',best[3]) if best[3] is not None else ('wait',None)
def enemy_phase(s):
log=[]
for i in (2,3):
if s.ended:break
if not s.hp[i]:continue
kind,v=enemy_choice(s,i)
if kind=='attack':before=s.hp[v];s=attack(s,i,v);log.append(f'敵{i-1}→味方{v+1} {before-s.hp[v]}ダメージ')
elif kind=='move':p=list(s.pos);p[i]=v;s=replace(s,pos=tuple(p));log.append(f'敵{i-1}が{v[0]+1},{v[1]+1}へ移動')
else:log.append(f'敵{i-1}は待つ')
n=s.round+1;count=s.flag+1 if any(s.hp[i] and s.pos[i]==LAYOUT[s.case][1] for i in range(2)) else 0
end=s.ended or ('flag' if count>=2 else ('draw' if n>=10 else ''))
return replace(s,left=2,round=n,flag=count,ended=end,events=tuple(log))
def legal(s):
if s.ended:return ()
out=[]
for i in range(2):
if not s.hp[i]:continue
for dx,dy in DIR:
p=(s.pos[i][0]+dx,s.pos[i][1]+dy)
if inside(p) and p not in LAYOUT[s.case][3] and not living(s,p):out.append(f'move:{i}:{p[0]}:{p[1]}')
for j in (2,3):
if s.hp[j] and reach(s,i,s.pos[j]):out.append(f'attack:{i}:{j}')
out.append(f'wait:{i}')
return tuple(out)
def step(s,a):
if a not in legal(s):return s
bits=a.split(':');i=int(bits[1]);log=''
if bits[0]=='move':p=list(s.pos);p[i]=(int(bits[2]),int(bits[3]));s=replace(s,pos=tuple(p));log=f'味方{i+1}が移動'
elif bits[0]=='attack':s=attack(s,i,int(bits[2]));log=f'味方{i+1}が攻撃'
else:log=f'味方{i+1}は待つ'
# Occupation continuity is broken as soon as the flag becomes empty.
if not any(s.hp[j] and s.pos[j]==LAYOUT[s.case][1] for j in (0,1)):s=replace(s,flag=0)
s=replace(s,left=s.left-1,events=(log,))
return enemy_phase(s) if not s.ended and s.left==0 else s
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 ('1','2','3'):self.reset('ABC'[int(k)-1]);return 'case'
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 ('LEFT','RIGHT','TAB'):self.person=1-self.person;self.choice=0;return 'select:unit'
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)拾った瞬間、両軍へ届く装備
両軍で共有する印・さえぎるもの・1つの条件だけを変える比較。
前提は、03 と、bool、replace での一部の更新です。ここで、元のゲームの State、すべての配置のデータ、戦い・移動・装備・終わりを受け持つ定義を、そのまま取り入れます。祭壇の縦横のとなりで pick(拾う)を選べるのは、まだ取っていない間だけです。拾うのは1回の行動で、equipped という共有の印を True にするので、ふつうの版では、味方も敵もすぐに武器を使います。HP の回復はありません。
状況 A の長い槍は、縦横に距離2まで届きます。途中に壁か生きている駒があれば届きません。状況 B の重い槌は、縦横のとなりのままで、2ダメージです。状況 C の斜めの刃は、斜めのとなりにも届きますが、その角の両側が壁なら通せません。武器は攻撃の範囲やダメージを変えますが、動く速さや、敵の行動の回数は変えません。敵の道探しは 03 と同じで、となりに行ける場所を目指します。
表紙で B を押して離すと、both(両軍)/ally(味方だけ)を切り替えて、最初の状態にします。ally では、拾ったあとも敵は素手のままです。ほかの配置・HP・終わりの条件は同じなので、拾うことの効果を、1つの条件だけを変えて比べられます。T を押して離すたびに、「動ける場所 → 今の攻撃が届く範囲 → 装備したあとならの範囲(仮定)」と切り替わります。画面の下の FUTURE IF EQUIPPED は仮定で、右上の CURRENT の EQUIPPED=False なら、まだ拾っていません。描画の shown は別の State で、本当の状態は書き換えません。
下のコードは 04_mirror_equipment.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
elif bits[0]=='pick':s=replace(s,equipped=True);log=EQUIPMENT[s.case]+('を両軍へ' if s.variant=='both' else 'を味方だけへ')実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 04_mirror_equipment.py.venv/bin/python 04_mirror_equipment.pypick:0 を選んで Enter で equipped=True、left=1、HP は全員3です。move:0:2:1 を Enter で確定すると、ROUND=1、E1=(3,1)、E2=(3,3) です。次に attack:0:2 で、E1 の HP=1 になります。- 拾うのは1回の行動で、両軍に反映される
- 攻撃が届く範囲の、壁と生きている駒を確かめる
- 仮定の表示と、今の値を分ける
step の中の敵の番で、強くなった敵が動きます。damage の return 2 if weapon(s,i)=='重槌' else 1 を return 1 if weapon(s,i)=='重槌' else 1 に変えてみましょう。UTF-8 で保存 → Esc を離して Enter で終了 → python 04_mirror_equipment.py で実行し直します。表紙で 2 を押して離す → Space を押して離す。上下で pick:0、move:0:2:1、attack:0:2 を順に選び、それぞれ Enter を押して離して確定します。E1 の HP=2 になります。python exercises/04_edit_control.py で、同じ書き換えと操作を確かめられます。最後に2に戻します。R はコードを読み直しません。
このステップのコード全文(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.
"""097 鏡の国の小競り合い / 教材 4。"""
STAGE = 4
from dataclasses import dataclass, asdict, replace
from collections import deque
from pathlib import Path
import json, hashlib, os
VERSION=1
TITLES={'A':'伸びる槍','B':'近すぎる重槌','C':'角の向こう'}
EQUIPMENT={'A':'長槍','B':'重槌','C':'斜め刃'}
LAYOUT={'A':(((0,1),(0,3),(5,1),(5,3)),(2,2),(1,2),((3,1),(3,3))), 'B':(((1,1),(1,3),(4,1),(4,3)),(2,2),(2,1),((3,2),)), 'C':(((0,2),(1,4),(5,0),(5,3)),(3,2),(2,3),((2,1),(3,1),(4,3)))}
DIR=((0,-1),(-1,0),(0,1),(1,0))
@dataclass(frozen=True)
class State:
case:str='A';variant:str='both';pos:tuple=LAYOUT['A'][0];hp:tuple=(3,3,3,3);equipped:bool=False;left:int=2;round:int=0;flag:int=0;ended:str='';events:tuple=()
def new(case='A',variant='both'):return State(case,variant,LAYOUT[case][0])
def inside(p):return 0<=p[0]<6 and 0<=p[1]<5
def md(a,b):return abs(a[0]-b[0])+abs(a[1]-b[1])
def living(s,p):return [i for i in range(4) if s.hp[i] and s.pos[i]==p]
def weapon(s,i):return EQUIPMENT[s.case] if s.equipped and (i<2 or s.variant=='both') else '素手'
def reach(s,i,p):
if not s.hp[i] or s.pos[i]==p or not inside(p):return False
a=s.pos[i];d=md(a,p);w=weapon(s,i);walls=LAYOUT[s.case][3]
if p in walls:return False
if d==1:return True
if w=='長槍' and d==2 and (a[0]==p[0] or a[1]==p[1]):
mid=((a[0]+p[0])//2,(a[1]+p[1])//2);return mid not in walls and not living(s,mid)
if w=='斜め刃' and abs(a[0]-p[0])==abs(a[1]-p[1])==1:return not ((a[0],p[1]) in walls and (p[0],a[1]) in walls)
return False
def zone(s,i):return tuple((x,y) for y in range(5) for x in range(6) if reach(s,i,(x,y)))
def damage(s,i):return 2 if weapon(s,i)=='重槌' else 1
def victory(s):
if not any(s.hp[:2]):return replace(s,ended='lost')
if not any(s.hp[2:]):return replace(s,ended='defeated')
return s
def attack(s,i,j):
hp=list(s.hp);hp[j]=max(0,hp[j]-damage(s,i));return victory(replace(s,hp=tuple(hp)))
def enemy_choice(s,i):
targets=[j for j in range(2) if s.hp[j]]
hit=[j for j in targets if reach(s,i,s.pos[j])]
if hit:return ('attack',min(hit,key=lambda j:(s.hp[j],j)))
# BFS stops next to targets. All living pieces and walls obstruct routes.
blocked=set(LAYOUT[s.case][3])|{s.pos[j] for j in range(4) if s.hp[j] and j!=i};q=deque([(s.pos[i],None,0)]);seen={s.pos[i]};candidates=[]
while q:
p,first,dist=q.popleft()
for j in targets:
if md(p,s.pos[j])==1:candidates.append((dist+1,s.hp[j],j,first))
for dx,dy in DIR:
n=(p[0]+dx,p[1]+dy)
if inside(n) and n not in blocked and n not in seen:seen.add(n);q.append((n,first or n,dist+1))
if not candidates:return ('wait',None)
best=min(candidates,key=lambda t:t[:3]);return ('move',best[3]) if best[3] is not None else ('wait',None)
def enemy_phase(s):
log=[]
for i in (2,3):
if s.ended:break
if not s.hp[i]:continue
kind,v=enemy_choice(s,i)
if kind=='attack':before=s.hp[v];s=attack(s,i,v);log.append(f'敵{i-1}→味方{v+1} {before-s.hp[v]}ダメージ')
elif kind=='move':p=list(s.pos);p[i]=v;s=replace(s,pos=tuple(p));log.append(f'敵{i-1}が{v[0]+1},{v[1]+1}へ移動')
else:log.append(f'敵{i-1}は待つ')
n=s.round+1;count=s.flag+1 if any(s.hp[i] and s.pos[i]==LAYOUT[s.case][1] for i in range(2)) else 0
end=s.ended or ('flag' if count>=2 else ('draw' if n>=10 else ''))
return replace(s,left=2,round=n,flag=count,ended=end,events=tuple(log))
def legal(s):
if s.ended:return ()
out=[]
for i in range(2):
if not s.hp[i]:continue
for dx,dy in DIR:
p=(s.pos[i][0]+dx,s.pos[i][1]+dy)
if inside(p) and p not in LAYOUT[s.case][3] and not living(s,p):out.append(f'move:{i}:{p[0]}:{p[1]}')
for j in (2,3):
if s.hp[j] and reach(s,i,s.pos[j]):out.append(f'attack:{i}:{j}')
if not s.equipped and md(s.pos[i],LAYOUT[s.case][2])==1:out.append(f'pick:{i}')
out.append(f'wait:{i}')
return tuple(out)
def step(s,a):
if a not in legal(s):return s
bits=a.split(':');i=int(bits[1]);log=''
if bits[0]=='move':p=list(s.pos);p[i]=(int(bits[2]),int(bits[3]));s=replace(s,pos=tuple(p));log=f'味方{i+1}が移動'
elif bits[0]=='attack':s=attack(s,i,int(bits[2]));log=f'味方{i+1}が攻撃'
elif bits[0]=='pick':s=replace(s,equipped=True);log=EQUIPMENT[s.case]+('を両軍へ' if s.variant=='both' else 'を味方だけへ')
else:log=f'味方{i+1}は待つ'
# Occupation continuity is broken as soon as the flag becomes empty.
if not any(s.hp[j] and s.pos[j]==LAYOUT[s.case][1] for j in (0,1)):s=replace(s,flag=0)
s=replace(s,left=s.left-1,events=(log,))
return enemy_phase(s) if not s.ended and s.left==0 else s
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 ('1','2','3'):self.reset('ABC'[int(k)-1]);return 'case'
if k=='B':self.reset(variant='ally' if self.s.variant=='both' else 'both');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=='T':self.overlay=(self.overlay+1)%3;return 'overlay'
if self.s.ended:return 'ignored:result'
if k in ('LEFT','RIGHT','TAB'):self.person=1-self.person;self.choice=0;return 'select:unit'
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)予告とUndo、壊れた保存からの復帰
状態の写し・状態を変えない予測・UTF-8。
前提は、04 と、JSON、Path、try/except、リストの pop です。元のゲームの model.py の一番外側の関数・クラスと定数すべて、Controller のメソッドすべてを入れています。見た目だけが、720×520の英数字(ASCII)の教材の表示です。元のゲームの日本語の文字の描画、PNG の自動記録、F12 での実際の画像の保存、GIF のデモ、くわしい日本語の出来事の一覧は省いています。F12 の Controller の出来事は残っていますが、この教材では手動の画像の保存はしません。
元のゲームの model.forecast は、選んだ1回の行動の step そのものです。教材の右下の FUTURE 欄は、元のゲームの app と同じく、「選んだ行動+残りは待つ」と仮定して、次の敵の番が終わったあとの状態を表示します。1回の行動の forecast と、画面の予告の範囲を区別しましょう。2回目の行動を別の手にすれば、結果は変わります。選ぶことや T の表示だけでは、CURRENT の HP や ROUND は変わりません。
X は、最後の味方の1回の行動の直前に戻ります。2回目の行動に敵の番が含まれていれば、その敵の移動・攻撃・旗の判定も、まとめて戻ります。これは逆算でも、ランダムな実行し直しでもなく、history に保存した、書き換えない State を元に戻すものです。終わったあとも Undo できます。S は起動した作業フォルダーの save_097.json に UTF-8 で保存し、L で読みこみます。書きこみは、一時的なファイルができあがってから置きかえる方式で、すでにある同じ名前の保存を更新します。読みこめない JSON、知らない版、範囲外の値では、今の状態をそのままにして、IO FAILED と表示します。
保存の形式の検査は、元のゲームのままです。これは、読みこんだどんな状態でも、実際の行動の列からたどり着けることの証明ではありません。履歴の形や値の範囲などを検査する、という範囲です。R は、同じ配置・同じ比較のゲームの状態を最初に戻すだけで、コードを読み直しません。書き換えたあとは、UTF-8 で保存し、Esc を離して Enter で終了し、もう一度 python を実行してください。
下のコードは 05_forecast_history.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if a=='undo':
if self.history:self.s=self.history.pop();self.choice=0;return 'undo'
return 'ignored:undo'実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 05_forecast_history.py.venv/bin/python 05_forecast_history.py- 1回の行動の予告と、敵の番までの予告を区別する
- Undo では、敵の番も一緒に戻る
- こわれたファイルを読みこんでも、今の状態は残す
self.ui.s に代入すると、描くだけでゲームが進んでしまいます。Undo で round だけを減らしても、装備・HP・旗・敵の位置は戻らず、つじつまが合わなくなります。S はコードの保存ではなく、ゲームの状態の保存です。App.__init__ の self.ui=Controller(save_path) の次の行に、同じ字下げで self.ui.overlay=2 を加えてみましょう。UTF-8 で保存 → Esc を離して Enter で終了 → python 05_forecast_history.py で実行し直します。Space を押して離すと FUTURE IF EQUIPPED の表示になりますが、CURRENT の EQUIPPED=False と HP は最初のままです。Enter を押して離す → X を押して離す と、ROUND=0 に完全に戻ります。python exercises/05_edit_control.py で、書き換え、予告で変わらないこと、Undo を実行します。加えた行を消して保存し、起動し直します。R はコードを読み直しません。
このステップのコード全文(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.
"""097 鏡の国の小競り合い / 教材 5。"""
STAGE = 5
"""097: immutable deterministic tactics; UI/rendering never alter rule time."""
from dataclasses import dataclass,asdict,replace
from collections import deque
from pathlib import Path
import json,hashlib,os
VERSION=1
TITLES={'A':'伸びる槍','B':'近すぎる重槌','C':'角の向こう'}
EQUIPMENT={'A':'長槍','B':'重槌','C':'斜め刃'}
LAYOUT={'A':(((0,1),(0,3),(5,1),(5,3)),(2,2),(1,2),((3,1),(3,3))), 'B':(((1,1),(1,3),(4,1),(4,3)),(2,2),(2,1),((3,2),)), 'C':(((0,2),(1,4),(5,0),(5,3)),(3,2),(2,3),((2,1),(3,1),(4,3)))}
DIR=((0,-1),(-1,0),(0,1),(1,0))
@dataclass(frozen=True)
class State:
case:str='A';variant:str='both';pos:tuple=LAYOUT['A'][0];hp:tuple=(3,3,3,3);equipped:bool=False;left:int=2;round:int=0;flag:int=0;ended:str='';events:tuple=()
def new(case='A',variant='both'):return State(case,variant,LAYOUT[case][0])
def inside(p):return 0<=p[0]<6 and 0<=p[1]<5
def md(a,b):return abs(a[0]-b[0])+abs(a[1]-b[1])
def living(s,p):return [i for i in range(4) if s.hp[i] and s.pos[i]==p]
def weapon(s,i):return EQUIPMENT[s.case] if s.equipped and (i<2 or s.variant=='both') else '素手'
def reach(s,i,p):
if not s.hp[i] or s.pos[i]==p or not inside(p):return False
a=s.pos[i];d=md(a,p);w=weapon(s,i);walls=LAYOUT[s.case][3]
if p in walls:return False
if d==1:return True
if w=='長槍' and d==2 and (a[0]==p[0] or a[1]==p[1]):
mid=((a[0]+p[0])//2,(a[1]+p[1])//2);return mid not in walls and not living(s,mid)
if w=='斜め刃' and abs(a[0]-p[0])==abs(a[1]-p[1])==1:return not ((a[0],p[1]) in walls and (p[0],a[1]) in walls)
return False
def zone(s,i):return tuple((x,y) for y in range(5) for x in range(6) if reach(s,i,(x,y)))
def damage(s,i):return 2 if weapon(s,i)=='重槌' else 1
def victory(s):
if not any(s.hp[:2]):return replace(s,ended='lost')
if not any(s.hp[2:]):return replace(s,ended='defeated')
return s
def attack(s,i,j):
hp=list(s.hp);hp[j]=max(0,hp[j]-damage(s,i));return victory(replace(s,hp=tuple(hp)))
def enemy_choice(s,i):
targets=[j for j in range(2) if s.hp[j]]
hit=[j for j in targets if reach(s,i,s.pos[j])]
if hit:return ('attack',min(hit,key=lambda j:(s.hp[j],j)))
# BFS stops next to targets. All living pieces and walls obstruct routes.
blocked=set(LAYOUT[s.case][3])|{s.pos[j] for j in range(4) if s.hp[j] and j!=i};q=deque([(s.pos[i],None,0)]);seen={s.pos[i]};candidates=[]
while q:
p,first,dist=q.popleft()
for j in targets:
if md(p,s.pos[j])==1:candidates.append((dist+1,s.hp[j],j,first))
for dx,dy in DIR:
n=(p[0]+dx,p[1]+dy)
if inside(n) and n not in blocked and n not in seen:seen.add(n);q.append((n,first or n,dist+1))
if not candidates:return ('wait',None)
best=min(candidates,key=lambda t:t[:3]);return ('move',best[3]) if best[3] is not None else ('wait',None)
def enemy_phase(s):
log=[]
for i in (2,3):
if s.ended:break
if not s.hp[i]:continue
kind,v=enemy_choice(s,i)
if kind=='attack':before=s.hp[v];s=attack(s,i,v);log.append(f'敵{i-1}→味方{v+1} {before-s.hp[v]}ダメージ')
elif kind=='move':p=list(s.pos);p[i]=v;s=replace(s,pos=tuple(p));log.append(f'敵{i-1}が{v[0]+1},{v[1]+1}へ移動')
else:log.append(f'敵{i-1}は待つ')
n=s.round+1;count=s.flag+1 if any(s.hp[i] and s.pos[i]==LAYOUT[s.case][1] for i in range(2)) else 0
end=s.ended or ('flag' if count>=2 else ('draw' if n>=10 else ''))
return replace(s,left=2,round=n,flag=count,ended=end,events=tuple(log))
def legal(s):
if s.ended:return ()
out=[]
for i in range(2):
if not s.hp[i]:continue
for dx,dy in DIR:
p=(s.pos[i][0]+dx,s.pos[i][1]+dy)
if inside(p) and p not in LAYOUT[s.case][3] and not living(s,p):out.append(f'move:{i}:{p[0]}:{p[1]}')
for j in (2,3):
if s.hp[j] and reach(s,i,s.pos[j]):out.append(f'attack:{i}:{j}')
if not s.equipped and md(s.pos[i],LAYOUT[s.case][2])==1:out.append(f'pick:{i}')
out.append(f'wait:{i}')
return tuple(out)
def step(s,a):
if a not in legal(s):return s
bits=a.split(':');i=int(bits[1]);log=''
if bits[0]=='move':p=list(s.pos);p[i]=(int(bits[2]),int(bits[3]));s=replace(s,pos=tuple(p));log=f'味方{i+1}が移動'
elif bits[0]=='attack':s=attack(s,i,int(bits[2]));log=f'味方{i+1}が攻撃'
elif bits[0]=='pick':s=replace(s,equipped=True);log=EQUIPMENT[s.case]+('を両軍へ' if s.variant=='both' else 'を味方だけへ')
else:log=f'味方{i+1}は待つ'
# Occupation continuity is broken as soon as the flag becomes empty.
if not any(s.hp[j] and s.pos[j]==LAYOUT[s.case][1] for j in (0,1)):s=replace(s,flag=0)
s=replace(s,left=s.left-1,events=(log,))
return enemy_phase(s) if not s.ended and s.left==0 else s
def forecast(s,a):return step(s,a)
def digest(s):return hashlib.sha256(json.dumps(asdict(s),sort_keys=True).encode()).hexdigest()
def unpack(d):
if not isinstance(d,dict) or set(d)!=set(asdict(new())):raise ValueError('状態の形式が違います')
if d['case'] not in TITLES or d['variant'] not in ('both','ally'):raise ValueError('未知の状況')
if type(d['equipped']) is not bool or type(d['left']) is not int or d['left'] not in (0,1,2):raise ValueError('行動数')
if type(d['round']) is not int or not 0<=d['round']<=10 or type(d['flag']) is not int or not 0<=d['flag']<=2:raise ValueError('時刻')
if d['ended'] not in ('','lost','defeated','flag','draw'):raise ValueError('結果')
if not isinstance(d['hp'],list) or len(d['hp'])!=4 or any(type(h)is not int or not 0<=h<=3 for h in d['hp']):raise ValueError('体力')
if not isinstance(d['pos'],list) or len(d['pos'])!=4 or any(not isinstance(p,list) or len(p)!=2 or any(type(v)is not int for v in p) or not inside(p) or tuple(p) in LAYOUT[d['case']][3] for p in d['pos']):raise ValueError('位置')
if len({tuple(p) for p,h in zip(d['pos'],d['hp']) if h})!=sum(bool(h) for h in d['hp']):raise ValueError('重なり')
if not isinstance(d['events'],list) or any(not isinstance(e,str) or len(e)>100 for e in d['events']):raise ValueError('記録')
d=dict(d);d['pos']=tuple(map(tuple,d['pos']));d['hp']=tuple(d['hp']);d['events']=tuple(d['events']);return State(**d)
def save(path,s,history):
p=Path(path);data={'game':'097','version':VERSION,'case':s.case,'state':asdict(s),'history':[asdict(t) for t in history],'settings':{'variant':s.variant}};tmp=p.with_suffix('.tmp');tmp.write_text(json.dumps(data,ensure_ascii=False,indent=2),encoding='utf-8');os.replace(tmp,p)
def load(path):
d=json.loads(Path(path).read_text(encoding='utf-8'))
if not isinstance(d,dict) or d.get('game')!='097' or type(d.get('version'))is not int or d['version']!=VERSION:raise ValueError('保存の作品または版が違います')
s=unpack(d['state']);h=[unpack(t) for t in d['history']]
if len(h)>20 or d['case']!=s.case or d['settings']!={'variant':s.variant} or any(t.case!=s.case or t.variant!=s.variant for t in h):raise ValueError('保存履歴が違います')
return s,h
KEYS={'SPACE','RETURN','Z','X','P','H','R','M','UP','DOWN','LEFT','RIGHT','TAB','1','2','3','B','F12','ESCAPE','S','L','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.history=[];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 self.s.variant);self.history=[];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):
if a=='undo':
if self.history:self.s=self.history.pop();self.choice=0;return 'undo'
return 'ignored:undo'
old=self.s;self.s=step(old,a)
if self.s==old:return 'ignored:'+a
self.history.append(old);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 ('1','2','3'):self.reset('ABC'[int(k)-1]);return 'case'
if k=='B':self.reset(variant='ally' if self.s.variant=='both' else 'both');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=='S':
try:save(self.path,self.s,self.history);self.notice='保存しました。'
except OSError:self.notice='保存できません。以前の保存は残します。'
return 'save'
if k=='L':
try:self.s,self.history=load(self.path);self.person=0 if self.s.hp[0] else 1;self.choice=0;self.notice='保存を読みました。'
except (OSError,ValueError,KeyError,TypeError):self.notice='保存が読めません。今の状態を保持。Rで新規開始。'
return 'load'
if k=='T':self.overlay=(self.overlay+1)%3;return 'overlay'
if self.s.ended:return 'ignored:result'
if k in ('LEFT','RIGHT','TAB'):self.person=1-self.person;self.choice=0;return 'select:unit'
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)仕組みがわかったら、元の完成版を読んでみよう。
ルールのまとめ:6×5の盤。味方2人で分け合う2回の行動のあと、敵が ID の順に1回ずつ行動。全滅・敵を全部倒す・旗を敵の番2回守る、で勝ち負け。敵の番10回で引き分け。装備を拾うと両軍が強くなる。
ここまでに作ったのは、元のゲームの仕組みだけを取り出して作り直した「簡略版」です。元のゲームのソースは手を加えずに、別のZIP(元の完成版ソース)に入れてあります。
| 比べる点 | 5ステップの教材 | 元の完成版 |
|---|---|---|
| 段階 | 01 駒1つ、02 練習用の配置と動かない敵、03 素手だけ、04 装備、05 元のゲームのルール全部と Controller。 | 最初からルール全部。 |
| 表示 | 720×520の英数字(ASCII)。座標は0から。予告と今の状態を別の枠に表示。 | 1024×720の日本語の文字、座標の表示は1から。 |
| 記録 | 05 の S/L は、起動した作業フォルダーに保存。F12・自動の PNG・GIF・出来事の記録は省略。 | 元のゲームのフォルダーに保存し、実行するたびに画像などを記録。 |
完成版のコードで、次に読むところ
- model.py の
legal/step:確かめる順番 - model.py の
enemy_choice:幅優先探索と、敵の ID の順番 - model.py の
weapon/reach/damage:両軍で共有する装備 - model.py の
enemy_phase:勝ち負けの優先順位と旗 - model.py の
unpack/save/load:確かめてから置きかえる仕組み - controller.py の
feedのキーを待つ仕組み(gate)と確認、applyの状態全部の Undo - app.py の
draw:予測(pred)と、日本語の出来事の表示
最初は、数字かルールを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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。
うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る
