完成サンプルをブラウザーで遊ぶ
この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。
完成版の主な操作:矢印マス/Z味方選択、Tab移動・攻撃・交換・救起、Enter確定、Space敵番、X戻す
ボタンを押すと、ゲームの読み込みが始まります。
画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。
うまく表示されないとき
ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。
兵士の状態はそのままにして、役目だけを交換する。
いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、5つのステップで作っていきます。どのステップのファイルもそれだけで動くので、動きを確かめてから次へ進みましょう。
- 必要な環境
- Python 3.11以上 / Pyxel 2.9.9
- 知っておくとよいこと
- 変数・if・for・関数 / マス目の座標と集合(
set) / 書き換えないdataclassとreplace(共通の入門ページで説明しています) - 教材に入っているもの
- ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
- 完成した教材の操作
- 左右:合法行動一覧を選ぶ(直接の地図移動ではない) / Z:選んだ一手を確定 / E:04/05で残りを終えて敵番へ / X:05で自動敵番も含めてUndo / R:初期化。新しい押下を優先 / Esc:終了
- 難易度の目安
- 中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
- 学習順に迷ったら
- おすすめの学ぶ順番を見る
コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。
教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。
名前のある身体を、一マス動かす
兵士の位置と、ルール上できる移動。
3人の兵士 AKI・FUMI・RUU を、元のゲームの状況 B の、6列×5行の砦に置きます。画面の列・行の番号は0から数えます。左右キーは移動の向きではなく、画面右の「今できる行動の一覧」から1つを選ぶキーです。Z で、選んだ一手を確定します。最初に Z を押すと、AKI が (1,0) から (2,0) に動きます。
Unit は、位置・HP・役目の札を持つ、書き換えないデータです。State.allies(味方の一覧)の何番目かがその兵士の番号なので、場所が動いても名前は変わりません。最初は HP と役目の札を表示するだけで、敵も攻撃もまだありません。
legal は、上下左右の4マスを候補にし、地図の外、壁、ほかの生きている駒、旗のマスを除きます。inside は「0以上、幅6・高さ5より小さい」という地図の内側の範囲です。駒がいるマス occupied と壁と旗を1つの集合にまとめておくと、「何にぶつかるか」を1か所で読めるようになります。
step は、一覧にない行動 Action を受け付けず、変わらない State を返します。受け付けた移動のときだけ、replace で位置を変え、行動の数 actions を1つ増やします。一覧の選択を何度動かしても、actions は増えません。これは地図の上を動くだけの練習で、勝ち負けや「チームで2回」という行動回数の上限は、あとで加えます。
下のコードは 01_bodies.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if inside(dest) and dest not in busy:
out.append(Action('move',i,dest))実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 01_bodies.py.venv/bin/python 01_bodies.py- 兵士の番号と場所を分けて持つ
- 地図の外・ほかの駒・旗を検査する
- 一覧を選ぶだけでは、行動に数えない
BOARDS の B にある味方の最初の位置 ((1,0),(2,1),(1,1)) の、最初の1つだけを (2,0) に変えてみましょう。AKI は (2,0) から始まり、最初の Z で (1,0) へ移動します。確認したら (1,0) に戻します。
このステップのコード全文(253行)
# 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.
"""役目を交換する砦・教材1/5。原作Bの仕組みを段階的に読む。"""
from dataclasses import dataclass, replace, asdict
ROLES = ('槍', '弓', '盾')
NAMES = ('アキ', 'フミ', 'ルウ')
DIRS = ((0, -1), (-1, 0), (0, 1), (1, 0))
@dataclass(frozen=True)
class Board:
id: str
title: str
flag: tuple
allies: tuple
enemies: tuple
walls: tuple
BOARDS = (Board('A', '弓の場所', (0, 2), ((1, 2), (1, 3), (0, 3)), ((5, 1), (5, 2), (5, 3)), ((3, 1), (3, 3))), Board('B', '曲がった入口', (0, 0), ((1, 0), (2, 1), (1, 1)), ((0, 1), (5, 2), (4, 4)), ((3, 0), (3, 1), (1, 3), (2, 3))), Board('C', 'ふたつの門', (2, 4), ((2, 3), (3, 3), (3, 2)), ((1, 4), (3, 0), (5, 2)), ((1, 1), (2, 1), (4, 1), (4, 3))))
@dataclass(frozen=True)
class Unit:
pos: tuple
hp: int
role: int = 0
@dataclass(frozen=True)
class State:
allies: tuple
enemies: tuple = ()
actions: int = 0
swaps: int = 0
result: str = ''
events: tuple = ()
@dataclass(frozen=True)
class Action:
kind: str
actor: int = 0
target: tuple = (0, 0)
def initial(board):
return State(tuple(Unit(pos,3,i) for i,pos in enumerate(board.allies)))
def dist(a, b):
return abs(a[0] - b[0]) + abs(a[1] - b[1])
def inside(p):
return 0 <= p[0] < 6 and 0 <= p[1] < 5
def occupied(s):
return {u.pos for u in s.allies + s.enemies if u.hp > 0}
def legal(board,state):
if state.result:
return ()
out=[]
busy=occupied(state)|set(board.walls)|{board.flag}
for i,unit in enumerate(state.allies):
if unit.hp<=0:
continue
for dx,dy in DIRS:
dest=(unit.pos[0]+dx,unit.pos[1]+dy)
if inside(dest) and dest not in busy:
out.append(Action('move',i,dest))
return tuple(out)
def step(board,state,action):
if action not in legal(board,state):
return state
allies=list(state.allies)
enemies=list(state.enemies)
swaps=state.swaps
if action.kind=="move":
allies[action.actor]=replace(allies[action.actor],pos=action.target)
result=""
return replace(state,allies=tuple(allies),enemies=tuple(enemies),actions=state.actions+1,swaps=swaps,result=result,events=(action.kind,))
STAGE = 1
BOARD = BOARDS[1]
LABELS = ("AKI","FUMI","RUU")
ROLE_LABELS = ("SPEAR","BOW","SHIELD")
ROLE_COLORS = (8,10,11)
def action_label(action):
if action.kind in ('wait','end'):
return 'WAIT ONE ACTION' if action.kind=='wait' else 'END TEAM TURN'
actor=LABELS[action.actor]
if action.kind in ('swap','rescue'):
return action.kind.upper()+' '+actor+' / '+LABELS[action.target[0]]
return action.kind.upper()+' '+actor+' -> '+str(action.target)
class App:
def __init__(self):
self.reset()
def reset(self):
self.state=initial(BOARD)
self.selected=0
self.gated=False
self.message="READY"
def choices(self):
return legal(BOARD,self.state)
def commit(self,action):
before=self.state
self.state=step(BOARD,self.state,action)
self.selected=0
self.message=action_label(action) if self.state!=before else "REFUSED"
def control(self,pressed,held):
if 'R' in pressed:
self.reset()
self.gated=True
return
if self.gated:
if not pressed and not held:
self.gated=False
return
if len(pressed|held)>1:
self.gated=True
self.message='RELEASE ALL KEYS'
return
if len(pressed)!=1:
return
key=next(iter(pressed))
self.gated=True
choices=self.choices()
if not choices:
return
if key in ('LEFT','RIGHT'):
self.selected=(self.selected+(-1 if key=='LEFT' else 1))%len(choices)
elif key=='Z':
self.commit(choices[self.selected])
def draw(self,p):
state=self.state
p.cls(1)
p.text(12,8,'ROLE FORTRESS / STAGE '+str(STAGE)+' / BOARD '+BOARD.id,10)
p.text(12,23,'ACTIONS '+str(state.actions)+' / SWAPS '+str(state.swaps),7)
for y in range(5):
for x in range(6):
px=24+x*34;py=60+y*34
p.rect(px,py,32,32,5 if (x,y) in BOARD.walls else 1)
p.rectb(px,py,32,32,13)
for x in range(6):p.text(38+x*34,46,str(x),6)
for y in range(5):p.text(12,74+y*34,str(y),6)
fx,fy=BOARD.flag;p.tri(36+fx*34,66+fy*34,36+fx*34,83+fy*34,49+fx*34,75+fy*34,10)
for n,unit in enumerate(state.enemies):
x,y=unit.pos;p.rect(33+x*34,69+y*34,16,16,8 if unit.hp>0 else 5)
p.text(37+x*34,74+y*34,str(n+1),7)
for n,unit in enumerate(state.allies):
x,y=unit.pos;color=ROLE_COLORS[unit.role] if unit.hp>0 else 6
if unit.hp>0:
p.circ(40+x*34,76+y*34,10,color)
p.text(38+x*34,74+y*34,'AFR'[n],1)
else:
# A fallen body never hides a living unit occupying this cell.
p.text(26+x*34+n*6,62+y*34,'AFR'[n],6)
p.text(12,248+n*16,LABELS[n]+' '+str(unit.pos)+' HP'+str(unit.hp)+' '+ROLE_LABELS[unit.role],color)
for n,enemy in enumerate(state.enemies):p.text(12,307+n*14,'ENEMY '+str(n+1)+' '+str(enemy.pos)+' HP'+str(enemy.hp),8 if enemy.hp>0 else 6)
choices=self.choices()
p.text(256,46,'LEGAL ACTIONS '+str(len(choices)),7)
start=(self.selected//7)*7
for n,action in enumerate(choices[start:start+7],start):
y=65+(n-start)*22
if n==self.selected:p.rect(252,y-4,328,19,2);p.rectb(252,y-4,328,19,10)
p.text(260,y,action_label(action),7)
p.text(256,230,"PRACTICE / NO ENEMY TURN",6)
p.text(12,363,self.message,14)
p.text(12,382,'LEFT/RIGHT: CHOOSE ACTION Z: DO IT R: RESET ESC: QUIT',7)
p.text(12,398,"ARROWS SELECT THE LIST; THEY DO NOT DIRECTLY MOVE A BODY",7)
DEMO_ACTIONS = (('move', 0, (2, 0)),)
def make_demo_keys():
app=App();keys=[]
def tap(key):
keys.append(key)
app.control({key},{key});app.control(set(),set())
for kind,actor,target in DEMO_ACTIONS:
desired=Action(kind,actor,target)
choices=app.choices()
if desired not in choices:
raise ValueError('The QA route does not fit this modified board.')
index=choices.index(desired)
while app.selected!=index:tap('RIGHT')
tap('Z')
return tuple(keys)
def main(headless=False,screenshot=None,demo=False):
import pyxel as p
p.init(592,420,title='Role fortress lesson '+str(STAGE),fps=30,display_scale=2,headless=headless)
app=App();sequence=make_demo_keys() if demo else ()
keys={'LEFT':p.KEY_LEFT,'RIGHT':p.KEY_RIGHT,'Z':p.KEY_Z,'X':p.KEY_X,'R':p.KEY_R,'E':p.KEY_E}
limit=max(20,2*len(sequence)+8)
def update():
if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
if screenshot:
app.draw(p);p.screen.save(screenshot,1)
p.quit()
if headless:
if demo:
index=p.frame_count//2
pressed={sequence[index]} if p.frame_count%2==0 and index<len(sequence) else set()
app.control(pressed,pressed)
else:app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
p.run(update,lambda:app.draw(p))
if __name__=='__main__':
import argparse
from pathlib import Path
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--headless-smoke',action='store_true')
parser.add_argument('--demo-checkpoint',action='store_true')
parser.add_argument('--screenshot')
args=parser.parse_args()
if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
if args.screenshot and (Path(args.screenshot).suffix.lower()!='.png' or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):parser.error('use a new .png in an existing directory')
if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
main(args.headless_smoke,args.screenshot,args.demo_checkpoint)身体を残して、役札だけ交換する
役目だけを交換し、兵士はそのまま。
となり合った、生きている味方どうしで SWAP(交換)を選べるようにします。AKI は槍、FUMI は弓、RUU は盾を持っています。SWAP AKI / RUU を確定すると、同じ場所にいたまま、AKI が盾、RUU が槍になります。位置・HP・名前は変えません。
交換する2人の Unit を、交換前の値として先に取り出しておき、それぞれに相手の役目 role を渡します。先に片方を書き換えてから、その書き換えた後の役目をもう片方に渡すと、同じ札が2枚になってしまうことがあります。交換前の2人から、交換後の2人を同時に作りましょう。
緑の内側の枠は、盾を持つ生きた兵士から、マンハッタン距離(縦と横の歩数の合計)1以内の「守られる範囲」です。盾を持つ本人のマスと、上下左右のとなりが対象で、斜めは入りません。最初の RUU (1,1) から旗 (0,0) までは距離2ですが、(1,0) にいる AKI に盾を渡すと、旗がすぐ範囲に入ります。まだ敵の番がないので、この段階は守られる範囲の表示を試す練習です。
Action の target は、行動の種類によって意味が違います。move なら地図の (列,行)、swap なら (相手の兵士の番号,0) です。swap の (2,0) は RUU のことで、地図のマス (2,0) ではありません。画面の一覧では、番号を兵士の名前に直して表示します。
下のコードは 02_roles.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
left,right=allies[action.actor],allies[j]
allies[action.actor]=replace(left,role=right.role)
allies[j]=replace(right,role=left.role)実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 02_roles.py.venv/bin/python 02_roles.py- 交換前の2人から、交換後の2人を作る
- HP と場所は、兵士本人に残る
- 盾が守る範囲は、交換したらすぐ変わる
initial の Unit(pos,3,i) を Unit(pos,1 if i==0 else 3,i) に変えて、AKI だけ HP1 で始めてみましょう。交換しても、AKI は HP1、RUU は HP3 のままです。確認したら3に戻します。
このステップのコード全文(267行)
# 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.
"""役目を交換する砦・教材2/5。原作Bの仕組みを段階的に読む。"""
from dataclasses import dataclass, replace, asdict
ROLES = ('槍', '弓', '盾')
NAMES = ('アキ', 'フミ', 'ルウ')
DIRS = ((0, -1), (-1, 0), (0, 1), (1, 0))
@dataclass(frozen=True)
class Board:
id: str
title: str
flag: tuple
allies: tuple
enemies: tuple
walls: tuple
BOARDS = (Board('A', '弓の場所', (0, 2), ((1, 2), (1, 3), (0, 3)), ((5, 1), (5, 2), (5, 3)), ((3, 1), (3, 3))), Board('B', '曲がった入口', (0, 0), ((1, 0), (2, 1), (1, 1)), ((0, 1), (5, 2), (4, 4)), ((3, 0), (3, 1), (1, 3), (2, 3))), Board('C', 'ふたつの門', (2, 4), ((2, 3), (3, 3), (3, 2)), ((1, 4), (3, 0), (5, 2)), ((1, 1), (2, 1), (4, 1), (4, 3))))
@dataclass(frozen=True)
class Unit:
pos: tuple
hp: int
role: int = 0
@dataclass(frozen=True)
class State:
allies: tuple
enemies: tuple = ()
actions: int = 0
swaps: int = 0
result: str = ''
events: tuple = ()
@dataclass(frozen=True)
class Action:
kind: str
actor: int = 0
target: tuple = (0, 0)
def initial(board):
return State(tuple(Unit(pos,3,i) for i,pos in enumerate(board.allies)))
def dist(a, b):
return abs(a[0] - b[0]) + abs(a[1] - b[1])
def inside(p):
return 0 <= p[0] < 6 and 0 <= p[1] < 5
def occupied(s):
return {u.pos for u in s.allies + s.enemies if u.hp > 0}
def protected(s, pos):
return any((u.hp > 0 and u.role == 2 and (dist(u.pos, pos) <= 1) for u in s.allies))
def legal(board,state):
if state.result:
return ()
out=[]
busy=occupied(state)|set(board.walls)|{board.flag}
for i,unit in enumerate(state.allies):
if unit.hp<=0:
continue
for dx,dy in DIRS:
dest=(unit.pos[0]+dx,unit.pos[1]+dy)
if inside(dest) and dest not in busy:
out.append(Action('move',i,dest))
for j,other in enumerate(state.allies):
if i!=j and other.hp>0 and dist(unit.pos,other.pos)==1:
out.append(Action('swap',i,(j,0)))
return tuple(out)
def step(board,state,action):
if action not in legal(board,state):
return state
allies=list(state.allies)
enemies=list(state.enemies)
swaps=state.swaps
if action.kind=="move":
allies[action.actor]=replace(allies[action.actor],pos=action.target)
elif action.kind=="swap":
j=action.target[0]
left,right=allies[action.actor],allies[j]
allies[action.actor]=replace(left,role=right.role)
allies[j]=replace(right,role=left.role)
swaps+=1
result=""
return replace(state,allies=tuple(allies),enemies=tuple(enemies),actions=state.actions+1,swaps=swaps,result=result,events=(action.kind,))
STAGE = 2
BOARD = BOARDS[1]
LABELS = ("AKI","FUMI","RUU")
ROLE_LABELS = ("SPEAR","BOW","SHIELD")
ROLE_COLORS = (8,10,11)
def action_label(action):
if action.kind in ('wait','end'):
return 'WAIT ONE ACTION' if action.kind=='wait' else 'END TEAM TURN'
actor=LABELS[action.actor]
if action.kind in ('swap','rescue'):
return action.kind.upper()+' '+actor+' / '+LABELS[action.target[0]]
return action.kind.upper()+' '+actor+' -> '+str(action.target)
class App:
def __init__(self):
self.reset()
def reset(self):
self.state=initial(BOARD)
self.selected=0
self.gated=False
self.message="READY"
def choices(self):
return legal(BOARD,self.state)
def commit(self,action):
before=self.state
self.state=step(BOARD,self.state,action)
self.selected=0
self.message=action_label(action) if self.state!=before else "REFUSED"
def control(self,pressed,held):
if 'R' in pressed:
self.reset()
self.gated=True
return
if self.gated:
if not pressed and not held:
self.gated=False
return
if len(pressed|held)>1:
self.gated=True
self.message='RELEASE ALL KEYS'
return
if len(pressed)!=1:
return
key=next(iter(pressed))
self.gated=True
choices=self.choices()
if not choices:
return
if key in ('LEFT','RIGHT'):
self.selected=(self.selected+(-1 if key=='LEFT' else 1))%len(choices)
elif key=='Z':
self.commit(choices[self.selected])
def draw(self,p):
state=self.state
p.cls(1)
p.text(12,8,'ROLE FORTRESS / STAGE '+str(STAGE)+' / BOARD '+BOARD.id,10)
p.text(12,23,'ACTIONS '+str(state.actions)+' / SWAPS '+str(state.swaps),7)
for y in range(5):
for x in range(6):
px=24+x*34;py=60+y*34
p.rect(px,py,32,32,5 if (x,y) in BOARD.walls else 1)
p.rectb(px,py,32,32,13)
if protected(state,(x,y)):
p.rectb(px+2,py+2,28,28,11)
for x in range(6):p.text(38+x*34,46,str(x),6)
for y in range(5):p.text(12,74+y*34,str(y),6)
fx,fy=BOARD.flag;p.tri(36+fx*34,66+fy*34,36+fx*34,83+fy*34,49+fx*34,75+fy*34,10)
for n,unit in enumerate(state.enemies):
x,y=unit.pos;p.rect(33+x*34,69+y*34,16,16,8 if unit.hp>0 else 5)
p.text(37+x*34,74+y*34,str(n+1),7)
for n,unit in enumerate(state.allies):
x,y=unit.pos;color=ROLE_COLORS[unit.role] if unit.hp>0 else 6
if unit.hp>0:
p.circ(40+x*34,76+y*34,10,color)
p.text(38+x*34,74+y*34,'AFR'[n],1)
else:
# A fallen body never hides a living unit occupying this cell.
p.text(26+x*34+n*6,62+y*34,'AFR'[n],6)
p.text(12,248+n*16,LABELS[n]+' '+str(unit.pos)+' HP'+str(unit.hp)+' '+ROLE_LABELS[unit.role],color)
for n,enemy in enumerate(state.enemies):p.text(12,307+n*14,'ENEMY '+str(n+1)+' '+str(enemy.pos)+' HP'+str(enemy.hp),8 if enemy.hp>0 else 6)
choices=self.choices()
p.text(256,46,'LEGAL ACTIONS '+str(len(choices)),7)
start=(self.selected//7)*7
for n,action in enumerate(choices[start:start+7],start):
y=65+(n-start)*22
if n==self.selected:p.rect(252,y-4,328,19,2);p.rectb(252,y-4,328,19,10)
p.text(260,y,action_label(action),7)
p.text(256,230,"PRACTICE / NO ENEMY TURN",6)
p.text(12,363,self.message,14)
p.text(12,382,'LEFT/RIGHT: CHOOSE ACTION Z: DO IT R: RESET ESC: QUIT',7)
p.text(12,398,"ARROWS SELECT THE LIST; THEY DO NOT DIRECTLY MOVE A BODY",7)
DEMO_ACTIONS = (('swap', 0, (2, 0)),)
def make_demo_keys():
app=App();keys=[]
def tap(key):
keys.append(key)
app.control({key},{key});app.control(set(),set())
for kind,actor,target in DEMO_ACTIONS:
desired=Action(kind,actor,target)
choices=app.choices()
if desired not in choices:
raise ValueError('The QA route does not fit this modified board.')
index=choices.index(desired)
while app.selected!=index:tap('RIGHT')
tap('Z')
return tuple(keys)
def main(headless=False,screenshot=None,demo=False):
import pyxel as p
p.init(592,420,title='Role fortress lesson '+str(STAGE),fps=30,display_scale=2,headless=headless)
app=App();sequence=make_demo_keys() if demo else ()
keys={'LEFT':p.KEY_LEFT,'RIGHT':p.KEY_RIGHT,'Z':p.KEY_Z,'X':p.KEY_X,'R':p.KEY_R,'E':p.KEY_E}
limit=max(20,2*len(sequence)+8)
def update():
if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
if screenshot:
app.draw(p);p.screen.save(screenshot,1)
p.quit()
if headless:
if demo:
index=p.frame_count//2
pressed={sequence[index]} if p.frame_count%2==0 and index<len(sequence) else set()
app.control(pressed,pressed)
else:app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
p.run(update,lambda:app.draw(p))
if __name__=='__main__':
import argparse
from pathlib import Path
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--headless-smoke',action='store_true')
parser.add_argument('--demo-checkpoint',action='store_true')
parser.add_argument('--screenshot')
args=parser.parse_args()
if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
if args.screenshot and (Path(args.screenshot).suffix.lower()!='.png' or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):parser.error('use a new .png in an existing directory')
if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
main(args.headless_smoke,args.screenshot,args.demo_checkpoint)役札から射程と威力を決める
距離・まっすぐな線・途中をふさぐ駒。
敵に HP を加え、攻撃できるようにします。槍はとなりの敵に2、盾はとなりの敵に1、弓は同じ行か同じ列で距離2〜3の敵に1ダメージを与えます。弓は、距離1の敵にも、斜めの敵にも攻撃できません。同じ「近い」でも、役目によって条件が違います。
can_attack は、まず攻撃する兵士が生きているかを調べ、弓のときだけ、まっすぐな線の上か・距離はいくつかを調べます。さらに、途中のマスを1つずつたどって、壁・生きている味方・敵がないかを検査します。狙っている敵自身のマスは、途中のマスに含めません。最初の位置の FUMI から敵1までは、まっすぐ2マスですが、途中に RUU がいてふさいでいます。
攻撃すると、相手の HP を減らします(0より下にはなりません)。HP0 になった兵士や敵は、通り道をふさがなくなりますが、元の位置と役目の札はデータに残ります。倒れた味方はマスの角に小さな文字で表示し、あとからそのマスに入った生きている駒を隠さないようにします。味方が倒れることがあるのは、敵の番を加える次の段階からです。
この段階は、何回でも行動できる練習です。敵は反撃も移動もしません。攻撃・移動・交換の違いだけを確かめます。最後の敵を倒すと一覧は空になりますが、「敵の番を6回しのいで旗を守る」という目標はまだありません。
下のコードは 03_attack.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if not 2 <= d <= 3 or (u.pos[0] != target[0] and u.pos[1] != target[1]):
return False実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 03_attack.py.venv/bin/python 03_attack.py- 役目によって、届く範囲と威力が変わる
- 弓は、途中のマスを1つずつ調べる
- 倒れた駒は、通り道をふさがない
initial で敵を作る Unit(pos,2) を Unit(pos,3) に変えてみましょう。RUU の盾の攻撃2回では敵1の HP が1残り、3回で0になります。確認したら2に戻します。
このステップのコード全文(287行)
# 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.
"""役目を交換する砦・教材3/5。原作Bの仕組みを段階的に読む。"""
from dataclasses import dataclass, replace, asdict
ROLES = ('槍', '弓', '盾')
NAMES = ('アキ', 'フミ', 'ルウ')
DIRS = ((0, -1), (-1, 0), (0, 1), (1, 0))
@dataclass(frozen=True)
class Board:
id: str
title: str
flag: tuple
allies: tuple
enemies: tuple
walls: tuple
BOARDS = (Board('A', '弓の場所', (0, 2), ((1, 2), (1, 3), (0, 3)), ((5, 1), (5, 2), (5, 3)), ((3, 1), (3, 3))), Board('B', '曲がった入口', (0, 0), ((1, 0), (2, 1), (1, 1)), ((0, 1), (5, 2), (4, 4)), ((3, 0), (3, 1), (1, 3), (2, 3))), Board('C', 'ふたつの門', (2, 4), ((2, 3), (3, 3), (3, 2)), ((1, 4), (3, 0), (5, 2)), ((1, 1), (2, 1), (4, 1), (4, 3))))
@dataclass(frozen=True)
class Unit:
pos: tuple
hp: int
role: int = 0
@dataclass(frozen=True)
class State:
allies: tuple
enemies: tuple = ()
actions: int = 0
swaps: int = 0
result: str = ''
events: tuple = ()
@dataclass(frozen=True)
class Action:
kind: str
actor: int = 0
target: tuple = (0, 0)
def initial(board):
return State(tuple(Unit(pos,3,i) for i,pos in enumerate(board.allies)),tuple(Unit(pos,2) for pos in board.enemies))
def dist(a, b):
return abs(a[0] - b[0]) + abs(a[1] - b[1])
def inside(p):
return 0 <= p[0] < 6 and 0 <= p[1] < 5
def occupied(s):
return {u.pos for u in s.allies + s.enemies if u.hp > 0}
def protected(s, pos):
return any((u.hp > 0 and u.role == 2 and (dist(u.pos, pos) <= 1) for u in s.allies))
def can_attack(b, s, actor, target):
u = s.allies[actor]
d = dist(u.pos, target)
if u.hp <= 0:
return False
if u.role != 1:
return d == 1
if not 2 <= d <= 3 or (u.pos[0] != target[0] and u.pos[1] != target[1]):
return False
dx = (target[0] > u.pos[0]) - (target[0] < u.pos[0])
dy = (target[1] > u.pos[1]) - (target[1] < u.pos[1])
return all(((u.pos[0] + dx * i, u.pos[1] + dy * i) not in set(b.walls) | occupied(s) for i in range(1, d)))
def legal(board,state):
if state.result:
return ()
out=[]
busy=occupied(state)|set(board.walls)|{board.flag}
for i,unit in enumerate(state.allies):
if unit.hp<=0:
continue
for dx,dy in DIRS:
dest=(unit.pos[0]+dx,unit.pos[1]+dy)
if inside(dest) and dest not in busy:
out.append(Action('move',i,dest))
for enemy in state.enemies:
if enemy.hp>0 and can_attack(board,state,i,enemy.pos):
out.append(Action('attack',i,enemy.pos))
for j,other in enumerate(state.allies):
if i!=j and other.hp>0 and dist(unit.pos,other.pos)==1:
out.append(Action('swap',i,(j,0)))
return tuple(out)
def step(board,state,action):
if action not in legal(board,state):
return state
allies=list(state.allies)
enemies=list(state.enemies)
swaps=state.swaps
if action.kind=="move":
allies[action.actor]=replace(allies[action.actor],pos=action.target)
elif action.kind=="swap":
j=action.target[0]
left,right=allies[action.actor],allies[j]
allies[action.actor]=replace(left,role=right.role)
allies[j]=replace(right,role=left.role)
swaps+=1
elif action.kind=="attack":
j=next(j for j,enemy in enumerate(enemies) if enemy.hp>0 and enemy.pos==action.target)
damage=2 if allies[action.actor].role==0 else 1
enemies[j]=replace(enemies[j],hp=max(0,enemies[j].hp-damage))
result="all_defeated" if enemies and all(e.hp==0 for e in enemies) else ""
return replace(state,allies=tuple(allies),enemies=tuple(enemies),actions=state.actions+1,swaps=swaps,result=result,events=(action.kind,))
STAGE = 3
BOARD = BOARDS[1]
LABELS = ("AKI","FUMI","RUU")
ROLE_LABELS = ("SPEAR","BOW","SHIELD")
ROLE_COLORS = (8,10,11)
def action_label(action):
if action.kind in ('wait','end'):
return 'WAIT ONE ACTION' if action.kind=='wait' else 'END TEAM TURN'
actor=LABELS[action.actor]
if action.kind in ('swap','rescue'):
return action.kind.upper()+' '+actor+' / '+LABELS[action.target[0]]
return action.kind.upper()+' '+actor+' -> '+str(action.target)
class App:
def __init__(self):
self.reset()
def reset(self):
self.state=initial(BOARD)
self.selected=0
self.gated=False
self.message="READY"
def choices(self):
return legal(BOARD,self.state)
def commit(self,action):
before=self.state
self.state=step(BOARD,self.state,action)
self.selected=0
self.message=action_label(action) if self.state!=before else "REFUSED"
def control(self,pressed,held):
if 'R' in pressed:
self.reset()
self.gated=True
return
if self.gated:
if not pressed and not held:
self.gated=False
return
if len(pressed|held)>1:
self.gated=True
self.message='RELEASE ALL KEYS'
return
if len(pressed)!=1:
return
key=next(iter(pressed))
self.gated=True
choices=self.choices()
if not choices:
return
if key in ('LEFT','RIGHT'):
self.selected=(self.selected+(-1 if key=='LEFT' else 1))%len(choices)
elif key=='Z':
self.commit(choices[self.selected])
def draw(self,p):
state=self.state
p.cls(1)
p.text(12,8,'ROLE FORTRESS / STAGE '+str(STAGE)+' / BOARD '+BOARD.id,10)
p.text(12,23,'ACTIONS '+str(state.actions)+' / SWAPS '+str(state.swaps),7)
for y in range(5):
for x in range(6):
px=24+x*34;py=60+y*34
p.rect(px,py,32,32,5 if (x,y) in BOARD.walls else 1)
p.rectb(px,py,32,32,13)
if protected(state,(x,y)):
p.rectb(px+2,py+2,28,28,11)
for x in range(6):p.text(38+x*34,46,str(x),6)
for y in range(5):p.text(12,74+y*34,str(y),6)
fx,fy=BOARD.flag;p.tri(36+fx*34,66+fy*34,36+fx*34,83+fy*34,49+fx*34,75+fy*34,10)
for n,unit in enumerate(state.enemies):
x,y=unit.pos;p.rect(33+x*34,69+y*34,16,16,8 if unit.hp>0 else 5)
p.text(37+x*34,74+y*34,str(n+1),7)
for n,unit in enumerate(state.allies):
x,y=unit.pos;color=ROLE_COLORS[unit.role] if unit.hp>0 else 6
if unit.hp>0:
p.circ(40+x*34,76+y*34,10,color)
p.text(38+x*34,74+y*34,'AFR'[n],1)
else:
# A fallen body never hides a living unit occupying this cell.
p.text(26+x*34+n*6,62+y*34,'AFR'[n],6)
p.text(12,248+n*16,LABELS[n]+' '+str(unit.pos)+' HP'+str(unit.hp)+' '+ROLE_LABELS[unit.role],color)
for n,enemy in enumerate(state.enemies):p.text(12,307+n*14,'ENEMY '+str(n+1)+' '+str(enemy.pos)+' HP'+str(enemy.hp),8 if enemy.hp>0 else 6)
choices=self.choices()
p.text(256,46,'LEGAL ACTIONS '+str(len(choices)),7)
start=(self.selected//7)*7
for n,action in enumerate(choices[start:start+7],start):
y=65+(n-start)*22
if n==self.selected:p.rect(252,y-4,328,19,2);p.rectb(252,y-4,328,19,10)
p.text(260,y,action_label(action),7)
p.text(256,230,"PRACTICE / NO ENEMY TURN",6)
p.text(12,363,self.message,14)
p.text(12,382,'LEFT/RIGHT: CHOOSE ACTION Z: DO IT R: RESET ESC: QUIT',7)
p.text(12,398,"ARROWS SELECT THE LIST; THEY DO NOT DIRECTLY MOVE A BODY",7)
DEMO_ACTIONS = (('attack', 2, (0, 1)), ('attack', 2, (0, 1)))
def make_demo_keys():
app=App();keys=[]
def tap(key):
keys.append(key)
app.control({key},{key});app.control(set(),set())
for kind,actor,target in DEMO_ACTIONS:
desired=Action(kind,actor,target)
choices=app.choices()
if desired not in choices:
raise ValueError('The QA route does not fit this modified board.')
index=choices.index(desired)
while app.selected!=index:tap('RIGHT')
tap('Z')
return tuple(keys)
def main(headless=False,screenshot=None,demo=False):
import pyxel as p
p.init(592,420,title='Role fortress lesson '+str(STAGE),fps=30,display_scale=2,headless=headless)
app=App();sequence=make_demo_keys() if demo else ()
keys={'LEFT':p.KEY_LEFT,'RIGHT':p.KEY_RIGHT,'Z':p.KEY_Z,'X':p.KEY_X,'R':p.KEY_R,'E':p.KEY_E}
limit=max(20,2*len(sequence)+8)
def update():
if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
if screenshot:
app.draw(p);p.screen.save(screenshot,1)
p.quit()
if headless:
if demo:
index=p.frame_count//2
pressed={sequence[index]} if p.frame_count%2==0 and index<len(sequence) else set()
app.control(pressed,pressed)
else:app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
p.run(update,lambda:app.draw(p))
if __name__=='__main__':
import argparse
from pathlib import Path
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--headless-smoke',action='store_true')
parser.add_argument('--demo-checkpoint',action='store_true')
parser.add_argument('--screenshot')
args=parser.parse_args()
if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
if args.screenshot and (Path(args.screenshot).suffix.lower()!='.png' or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):parser.error('use a new .png in an existing directory')
if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
main(args.headless_smoke,args.screenshot,args.demo_checkpoint)二行動の後、敵を番号順に進める
チームで分け合う行動回数と、順番に動く敵の番。
元のゲームの状況 B の、ふだんのルールを完成させます。「2回の行動」は1人につき2回ではなく、チーム全員で合わせて2回です。同じ兵士が2回動いてもかまいません。WAIT(待つ)は行動を1回だけ使い、END TEAM TURN または E は、残りの行動を使わずに敵の番へ進めます。どちらも、受け付けた判断として actions が1つ増えます。
敵は番号の順に1体ずつ動きます。旗のとなりにいれば旗を優先して攻撃し、そうでなければ、となりにいる生きた味方のうち、HP が低い順、同じなら兵士の番号順に選んで攻撃します。盾が守る距離1以内なら、1ダメージを0に減らします。前の敵の攻撃で変わった HP を次の敵も見るので、全員がいっせいに、敵の番が始まる前の状態を見て攻撃するわけではありません。
となりに攻撃できる相手がいない敵は、壁と生きている駒をよけて、旗までの一番短い道を1マスだけ進みます。移動した番には攻撃しません。同じ長さの道がいくつかあるときは、上・左・下・右の順で選びます。道がなければ、その場で待ちます。右の欄の IF ENEMIES NOW は「今の状態のまま、すぐ敵の番になったら」の予測で、まだ選んでいない次の手のあとの予言ではありません。
旗が壊されずに敵の番を6回終えるか、敵を全部倒せば成功です。全員が無傷であることや、役目の交換は、成功の条件ではありません。HP0 の味方は、となりにいる生きた味方が行動を1回使うと、HP1 で起き上がらせられます(役目の札も残っています)。ただし、そのマスに別の生きた駒が重なっている間は、起き上がらせられません。
まず AKI と RUU の役目を交換し、E で一度敵の番へ進めます。旗は盾に守られます。次に RUU が槍で (0,1) の敵1を倒し、あとは E を5回押して、敵の番を6回終えます。旗の HP は3、FUMI の HP は1で成功です。これも正しいクリアで、「全員無傷」を隠れた条件にはしていません。
下のコードは 04_enemy_turn.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if n.remaining == 0:
e = enemy_phase(b, n)
n = replace(e, events=tuple(notes) + e.events)実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 04_enemy_turn.py.venv/bin/python 04_enemy_turn.py- 行動回数は、チームで分け合う
- 敵は、前の敵が動いたあとの状態を順に読む
- 敵は、移動した番には攻撃しない
R で最初に戻し、交換をせずに、RUU の盾で敵1を2回攻撃してみましょう。そのあと E を5回押しても、ROUND 6・旗 HP3 で成功します。役目の交換だけが正解ではありません。別に、R→E→E→E では旗が壊されることも比べてみましょう。
このステップのコード全文(381行)
# 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.
"""役目を交換する砦・教材4/5。原作Bの仕組みを段階的に読む。"""
from dataclasses import dataclass, replace, asdict
from collections import deque
import hashlib, json
VERSION = '085.1.0'
ROLES = ('槍', '弓', '盾')
NAMES = ('アキ', 'フミ', 'ルウ')
DIRS = ((0, -1), (-1, 0), (0, 1), (1, 0))
@dataclass(frozen=True)
class Board:
id: str
title: str
flag: tuple
allies: tuple
enemies: tuple
walls: tuple
BOARDS = (Board('A', '弓の場所', (0, 2), ((1, 2), (1, 3), (0, 3)), ((5, 1), (5, 2), (5, 3)), ((3, 1), (3, 3))), Board('B', '曲がった入口', (0, 0), ((1, 0), (2, 1), (1, 1)), ((0, 1), (5, 2), (4, 4)), ((3, 0), (3, 1), (1, 3), (2, 3))), Board('C', 'ふたつの門', (2, 4), ((2, 3), (3, 3), (3, 2)), ((1, 4), (3, 0), (5, 2)), ((1, 1), (2, 1), (4, 1), (4, 3))))
@dataclass(frozen=True)
class Unit:
pos: tuple
hp: int
role: int = 0
@dataclass(frozen=True)
class State:
allies: tuple
enemies: tuple
flag_hp: int = 3
remaining: int = 2
rounds: int = 0
result: str = ''
swaps: int = 0
actions: int = 0
events: tuple = ()
@dataclass(frozen=True)
class Action:
kind: str
actor: int = 0
target: tuple = (0, 0)
def initial(b):
return State(tuple((Unit(p, 3, i) for i, p in enumerate(b.allies))), tuple((Unit(p, 2) for p in b.enemies)))
def digest(s):
return hashlib.sha256(json.dumps(asdict(s), ensure_ascii=False, sort_keys=True).encode('utf-8')).hexdigest()
def dist(a, b):
return abs(a[0] - b[0]) + abs(a[1] - b[1])
def inside(p):
return 0 <= p[0] < 6 and 0 <= p[1] < 5
def occupied(s):
return {u.pos for u in s.allies + s.enemies if u.hp > 0}
def protected(s, pos):
return any((u.hp > 0 and u.role == 2 and (dist(u.pos, pos) <= 1) for u in s.allies))
def can_attack(b, s, actor, target):
u = s.allies[actor]
d = dist(u.pos, target)
if u.hp <= 0:
return False
if u.role != 1:
return d == 1
if not 2 <= d <= 3 or (u.pos[0] != target[0] and u.pos[1] != target[1]):
return False
dx = (target[0] > u.pos[0]) - (target[0] < u.pos[0])
dy = (target[1] > u.pos[1]) - (target[1] < u.pos[1])
return all(((u.pos[0] + dx * i, u.pos[1] + dy * i) not in set(b.walls) | occupied(s) for i in range(1, d)))
def legal(b, s, compare=False):
if s.result:
return ()
out = [Action('wait'), Action('end')]
busy = occupied(s) | set(b.walls) | {b.flag}
for i, u in enumerate(s.allies):
if u.hp <= 0:
continue
for dx, dy in DIRS:
p = (u.pos[0] + dx, u.pos[1] + dy)
if inside(p) and p not in busy:
out.append(Action('move', i, p))
for e in s.enemies:
if e.hp > 0 and can_attack(b, s, i, e.pos):
out.append(Action('attack', i, e.pos))
for j, v in enumerate(s.allies):
if j == i or dist(u.pos, v.pos) != 1:
continue
if v.hp > 0 and (not compare):
out.append(Action('swap', i, (j, 0)))
if v.hp == 0 and v.pos not in occupied(s):
out.append(Action('rescue', i, (j, 0)))
return tuple(out)
def enemy_move(b, s, idx):
"""Shortest path to flag, deterministic up/left/down/right tie order."""
start = s.enemies[idx].pos
blocked = occupied(s) | set(b.walls)
blocked.discard(start)
q = deque([(start, ())])
seen = {start}
while q:
p, path = q.popleft()
if p == b.flag:
return path[0] if path else None
for dx, dy in DIRS:
n = (p[0] + dx, p[1] + dy)
if inside(n) and n not in seen and (n not in blocked):
seen.add(n)
q.append((n, path + (n,)))
return None
def enemy_phase(b, s):
notes = []
for i in range(len(s.enemies)):
e = s.enemies[i]
if e.hp <= 0:
continue
nearby = [j for j, u in enumerate(s.allies) if u.hp > 0 and dist(e.pos, u.pos) == 1]
if dist(e.pos, b.flag) == 1:
dmg = 0 if protected(s, b.flag) else 1
s = replace(s, flag_hp=max(0, s.flag_hp - dmg))
notes.append(f'敵{i + 1} → 旗 {dmg}ダメージ')
elif nearby:
j = min(nearby, key=lambda n: (s.allies[n].hp, n))
u = s.allies[j]
dmg = 0 if protected(s, u.pos) else 1
units = list(s.allies)
units[j] = replace(u, hp=max(0, u.hp - dmg))
s = replace(s, allies=tuple(units))
notes.append(f'敵{i + 1} → {NAMES[j]} {dmg}ダメージ')
else:
p = enemy_move(b, s, i)
if p is not None:
es = list(s.enemies)
es[i] = replace(e, pos=p)
s = replace(s, enemies=tuple(es))
notes.append(f'敵{i + 1} → ({p[0] + 1},{p[1] + 1})へ移動・攻撃なし')
else:
notes.append(f'敵{i + 1} → 道がなく待機')
if s.flag_hp == 0:
return replace(s, result='旗が倒れた', events=tuple(notes))
s = replace(s, rounds=s.rounds + 1, remaining=2)
return replace(s, result='守り成功' if s.rounds >= 6 else '', events=tuple(notes))
def preview(b, s):
if s.result:
return ()
return enemy_phase(b, s).events
def step(b, s, a, compare=False):
if a not in legal(b, s, compare):
return s
units = list(s.allies)
es = list(s.enemies)
notes = []
swaps = s.swaps
if a.kind == 'move':
units[a.actor] = replace(units[a.actor], pos=a.target)
notes = [NAMES[a.actor] + 'が移動']
elif a.kind == 'attack':
j = next((j for j, e in enumerate(es) if e.hp > 0 and e.pos == a.target))
damage = 2 if units[a.actor].role == 0 else 1
es[j] = replace(es[j], hp=max(0, es[j].hp - damage))
notes = [NAMES[a.actor] + 'の' + ROLES[units[a.actor].role] + f' → 敵{j + 1} {damage}ダメージ']
elif a.kind == 'swap':
j = a.target[0]
u, v = (units[a.actor], units[j])
units[a.actor] = replace(u, role=v.role)
units[j] = replace(v, role=u.role)
swaps += 1
notes = [NAMES[a.actor] + 'と' + NAMES[j] + 'が役目交換 / HP・場所はそのまま']
elif a.kind == 'rescue':
j = a.target[0]
units[j] = replace(units[j], hp=1)
notes = [NAMES[a.actor] + 'が' + NAMES[j] + 'をHP1で救起']
else:
notes = ['残りの行動を待つ' if a.kind == 'end' else '一行動待つ']
n = replace(s, allies=tuple(units), enemies=tuple(es), swaps=swaps, remaining=0 if a.kind == 'end' else s.remaining - 1, actions=s.actions + 1, events=tuple(notes))
if all((e.hp == 0 for e in es)):
return replace(n, result='全敵撃破')
if n.remaining == 0:
e = enemy_phase(b, n)
n = replace(e, events=tuple(notes) + e.events)
return n
STAGE = 4
BOARD = BOARDS[1]
LABELS = ("AKI","FUMI","RUU")
ROLE_LABELS = ("SPEAR","BOW","SHIELD")
ROLE_COLORS = (8,10,11)
def action_label(action):
if action.kind in ('wait','end'):
return 'WAIT ONE ACTION' if action.kind=='wait' else 'END TEAM TURN'
actor=LABELS[action.actor]
if action.kind in ('swap','rescue'):
return action.kind.upper()+' '+actor+' / '+LABELS[action.target[0]]
return action.kind.upper()+' '+actor+' -> '+str(action.target)
class App:
def __init__(self):
self.reset()
def reset(self):
self.state=initial(BOARD)
self.selected=0
self.gated=False
self.message="READY"
def choices(self):
return legal(BOARD,self.state)
def commit(self,action):
before=self.state
self.state=step(BOARD,self.state,action)
self.selected=0
self.message=action_label(action) if self.state!=before else "REFUSED"
def control(self,pressed,held):
if 'R' in pressed:
self.reset()
self.gated=True
return
if self.gated:
if not pressed and not held:
self.gated=False
return
if len(pressed|held)>1:
self.gated=True
self.message='RELEASE ALL KEYS'
return
if len(pressed)!=1:
return
key=next(iter(pressed))
self.gated=True
choices=self.choices()
if not choices:
return
if key in ('LEFT','RIGHT'):
self.selected=(self.selected+(-1 if key=='LEFT' else 1))%len(choices)
elif key=='Z':
self.commit(choices[self.selected])
elif key=='E':
self.commit(Action('end'))
def draw(self,p):
state=self.state
p.cls(1)
p.text(12,8,'ROLE FORTRESS / STAGE '+str(STAGE)+' / BOARD '+BOARD.id,10)
p.text(12,23,'ACTIONS '+str(state.actions)+' / SWAPS '+str(state.swaps),7)
for y in range(5):
for x in range(6):
px=24+x*34;py=60+y*34
p.rect(px,py,32,32,5 if (x,y) in BOARD.walls else 1)
p.rectb(px,py,32,32,13)
if protected(state,(x,y)):
p.rectb(px+2,py+2,28,28,11)
for x in range(6):p.text(38+x*34,46,str(x),6)
for y in range(5):p.text(12,74+y*34,str(y),6)
fx,fy=BOARD.flag;p.tri(36+fx*34,66+fy*34,36+fx*34,83+fy*34,49+fx*34,75+fy*34,10)
for n,unit in enumerate(state.enemies):
x,y=unit.pos;p.rect(33+x*34,69+y*34,16,16,8 if unit.hp>0 else 5)
p.text(37+x*34,74+y*34,str(n+1),7)
for n,unit in enumerate(state.allies):
x,y=unit.pos;color=ROLE_COLORS[unit.role] if unit.hp>0 else 6
if unit.hp>0:
p.circ(40+x*34,76+y*34,10,color)
p.text(38+x*34,74+y*34,'AFR'[n],1)
else:
# A fallen body never hides a living unit occupying this cell.
p.text(26+x*34+n*6,62+y*34,'AFR'[n],6)
p.text(12,248+n*16,LABELS[n]+' '+str(unit.pos)+' HP'+str(unit.hp)+' '+ROLE_LABELS[unit.role],color)
for n,enemy in enumerate(state.enemies):p.text(12,307+n*14,'ENEMY '+str(n+1)+' '+str(enemy.pos)+' HP'+str(enemy.hp),8 if enemy.hp>0 else 6)
choices=self.choices()
p.text(256,46,'LEGAL ACTIONS '+str(len(choices)),7)
start=(self.selected//7)*7
for n,action in enumerate(choices[start:start+7],start):
y=65+(n-start)*22
if n==self.selected:p.rect(252,y-4,328,19,2);p.rectb(252,y-4,328,19,10)
p.text(260,y,action_label(action),7)
p.text(256,230,'TEAM LEFT '+str(state.remaining)+' / ROUND '+str(state.rounds)+' / FLAG HP '+str(state.flag_hp),11)
if not state.result:
future=enemy_phase(BOARD,state)
p.text(256,247,'IF ENEMIES NOW: FLAG '+str(future.flag_hp)+' / ALLY HP '+','.join(str(u.hp) for u in future.allies),6)
result={'守り成功':'HELD SIX ROUNDS','全敵撃破':'ALL ENEMIES DEFEATED','旗が倒れた':'FLAG LOST'}.get(state.result,'PLAY')
p.text(256,264,result,10)
p.text(12,363,self.message,14)
p.text(12,382,'LEFT/RIGHT: CHOOSE ACTION Z: DO IT R: RESET ESC: QUIT',7)
p.text(12,398,"E: END TEAM TURN TWO ACTIONS ARE SHARED BY THE WHOLE TEAM",7)
DEMO_ACTIONS = (('swap', 0, (2, 0)), ('end', 0, (0, 0)), ('attack', 2, (0, 1)), ('end', 0, (0, 0)), ('end', 0, (0, 0)), ('end', 0, (0, 0)), ('end', 0, (0, 0)), ('end', 0, (0, 0)))
def make_demo_keys():
app=App();keys=[]
def tap(key):
keys.append(key)
app.control({key},{key});app.control(set(),set())
for kind,actor,target in DEMO_ACTIONS:
desired=Action(kind,actor,target)
choices=app.choices()
if desired not in choices:
raise ValueError('The QA route does not fit this modified board.')
index=choices.index(desired)
while app.selected!=index:tap('RIGHT')
tap('Z')
return tuple(keys)
def main(headless=False,screenshot=None,demo=False):
import pyxel as p
p.init(592,420,title='Role fortress lesson '+str(STAGE),fps=30,display_scale=2,headless=headless)
app=App();sequence=make_demo_keys() if demo else ()
keys={'LEFT':p.KEY_LEFT,'RIGHT':p.KEY_RIGHT,'Z':p.KEY_Z,'X':p.KEY_X,'R':p.KEY_R,'E':p.KEY_E}
limit=max(20,2*len(sequence)+8)
def update():
if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
if screenshot:
app.draw(p);p.screen.save(screenshot,1)
p.quit()
if headless:
if demo:
index=p.frame_count//2
pressed={sequence[index]} if p.frame_count%2==0 and index<len(sequence) else set()
app.control(pressed,pressed)
else:app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
p.run(update,lambda:app.draw(p))
if __name__=='__main__':
import argparse
from pathlib import Path
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--headless-smoke',action='store_true')
parser.add_argument('--demo-checkpoint',action='store_true')
parser.add_argument('--screenshot')
args=parser.parse_args()
if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
if args.screenshot and (Path(args.screenshot).suffix.lower()!='.png' or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):parser.error('use a new .png in an existing directory')
if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
main(args.headless_smoke,args.screenshot,args.demo_checkpoint)自動の敵番まで、一つの履歴へ戻す
判断する前の状態を、まるごと保存する。
Timeline は、今の State と、受け付けた味方の判断の、その直前の State を保存します。2回目の行動や E を確定すると、中で敵の番も進みますが、履歴に入れるのは判断する前の State 1つだけです。X を押せば、動いた敵・減った HP・旗・役目の札・残りの行動回数・敵の番の回数・結果を、まとめて戻せます。
一覧の選択を動かしたときや、一覧にない無効な Action では、履歴を増やしません。履歴が空のときの Undo も、NO HISTORY と表示するだけで何も変えません。undo は State と入力の記録 inputs を一緒に取り出す(pop)ので、戻してから別の判断をすると、別の未来に進みます。やり直しを取り消す Redo や、ファイルへの保存は付けていません。
成功・失敗のあとも X を先に受け付けるので、最後の一手を試し直せます。R で最初に戻すと履歴は空になり、新しく押した R はほかの行動より優先されます。ふつうの同時押しは受け付けず、操作キーを全部離してから次の操作に進みます。Undo のあとは一覧の選択が先頭に戻ります。これは教材用に簡単にした操作です。
元のゲームの3つの砦について、交換あり・なしのそれぞれで、48本ずつの決まった入力の列をたどり、そこで出会った状態から、ルール上できるすべての行動を試して、23694回の状態の変化を元のゲームと比べています。たどり着けるすべての状態や、すべての履歴を調べたわけではありません。教材の画面は状況 B だけを使います。状況 A/C や、交換なしとの比較は、元のゲームで確かめてください。元の状況 A では、待つだけでも旗を守れる「導入用の易しい場面」がわざと残されていて、テストでもそのままにしています。
下のコードは 05_undo.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
self.state = self.history.pop()
self.inputs.pop()実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 05_undo.py.venv/bin/python 05_undo.py- 確定した味方の一手を、戻す単位にする
- 自動で進んだ敵の番の結果も一緒に戻す
- 戻したあと、別の判断で別の未来へ進める
R→E で旗の HP が2になり、X で最初の旗 HP3 に戻ります。今度は AKI と RUU を交換してから E を押すと、同じ敵1の攻撃を受けても、旗は HP3 のままです。履歴には、交換と E の2件だけが残ります。
このステップのコード全文(416行)
# 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.
"""役目を交換する砦・教材5/5。原作Bの仕組みを段階的に読む。"""
from dataclasses import dataclass, replace, asdict
from collections import deque
import hashlib, json
VERSION = '085.1.0'
ROLES = ('槍', '弓', '盾')
NAMES = ('アキ', 'フミ', 'ルウ')
DIRS = ((0, -1), (-1, 0), (0, 1), (1, 0))
@dataclass(frozen=True)
class Board:
id: str
title: str
flag: tuple
allies: tuple
enemies: tuple
walls: tuple
BOARDS = (Board('A', '弓の場所', (0, 2), ((1, 2), (1, 3), (0, 3)), ((5, 1), (5, 2), (5, 3)), ((3, 1), (3, 3))), Board('B', '曲がった入口', (0, 0), ((1, 0), (2, 1), (1, 1)), ((0, 1), (5, 2), (4, 4)), ((3, 0), (3, 1), (1, 3), (2, 3))), Board('C', 'ふたつの門', (2, 4), ((2, 3), (3, 3), (3, 2)), ((1, 4), (3, 0), (5, 2)), ((1, 1), (2, 1), (4, 1), (4, 3))))
@dataclass(frozen=True)
class Unit:
pos: tuple
hp: int
role: int = 0
@dataclass(frozen=True)
class State:
allies: tuple
enemies: tuple
flag_hp: int = 3
remaining: int = 2
rounds: int = 0
result: str = ''
swaps: int = 0
actions: int = 0
events: tuple = ()
@dataclass(frozen=True)
class Action:
kind: str
actor: int = 0
target: tuple = (0, 0)
def initial(b):
return State(tuple((Unit(p, 3, i) for i, p in enumerate(b.allies))), tuple((Unit(p, 2) for p in b.enemies)))
def digest(s):
return hashlib.sha256(json.dumps(asdict(s), ensure_ascii=False, sort_keys=True).encode('utf-8')).hexdigest()
def dist(a, b):
return abs(a[0] - b[0]) + abs(a[1] - b[1])
def inside(p):
return 0 <= p[0] < 6 and 0 <= p[1] < 5
def occupied(s):
return {u.pos for u in s.allies + s.enemies if u.hp > 0}
def protected(s, pos):
return any((u.hp > 0 and u.role == 2 and (dist(u.pos, pos) <= 1) for u in s.allies))
def can_attack(b, s, actor, target):
u = s.allies[actor]
d = dist(u.pos, target)
if u.hp <= 0:
return False
if u.role != 1:
return d == 1
if not 2 <= d <= 3 or (u.pos[0] != target[0] and u.pos[1] != target[1]):
return False
dx = (target[0] > u.pos[0]) - (target[0] < u.pos[0])
dy = (target[1] > u.pos[1]) - (target[1] < u.pos[1])
return all(((u.pos[0] + dx * i, u.pos[1] + dy * i) not in set(b.walls) | occupied(s) for i in range(1, d)))
def legal(b, s, compare=False):
if s.result:
return ()
out = [Action('wait'), Action('end')]
busy = occupied(s) | set(b.walls) | {b.flag}
for i, u in enumerate(s.allies):
if u.hp <= 0:
continue
for dx, dy in DIRS:
p = (u.pos[0] + dx, u.pos[1] + dy)
if inside(p) and p not in busy:
out.append(Action('move', i, p))
for e in s.enemies:
if e.hp > 0 and can_attack(b, s, i, e.pos):
out.append(Action('attack', i, e.pos))
for j, v in enumerate(s.allies):
if j == i or dist(u.pos, v.pos) != 1:
continue
if v.hp > 0 and (not compare):
out.append(Action('swap', i, (j, 0)))
if v.hp == 0 and v.pos not in occupied(s):
out.append(Action('rescue', i, (j, 0)))
return tuple(out)
def enemy_move(b, s, idx):
"""Shortest path to flag, deterministic up/left/down/right tie order."""
start = s.enemies[idx].pos
blocked = occupied(s) | set(b.walls)
blocked.discard(start)
q = deque([(start, ())])
seen = {start}
while q:
p, path = q.popleft()
if p == b.flag:
return path[0] if path else None
for dx, dy in DIRS:
n = (p[0] + dx, p[1] + dy)
if inside(n) and n not in seen and (n not in blocked):
seen.add(n)
q.append((n, path + (n,)))
return None
def enemy_phase(b, s):
notes = []
for i in range(len(s.enemies)):
e = s.enemies[i]
if e.hp <= 0:
continue
nearby = [j for j, u in enumerate(s.allies) if u.hp > 0 and dist(e.pos, u.pos) == 1]
if dist(e.pos, b.flag) == 1:
dmg = 0 if protected(s, b.flag) else 1
s = replace(s, flag_hp=max(0, s.flag_hp - dmg))
notes.append(f'敵{i + 1} → 旗 {dmg}ダメージ')
elif nearby:
j = min(nearby, key=lambda n: (s.allies[n].hp, n))
u = s.allies[j]
dmg = 0 if protected(s, u.pos) else 1
units = list(s.allies)
units[j] = replace(u, hp=max(0, u.hp - dmg))
s = replace(s, allies=tuple(units))
notes.append(f'敵{i + 1} → {NAMES[j]} {dmg}ダメージ')
else:
p = enemy_move(b, s, i)
if p is not None:
es = list(s.enemies)
es[i] = replace(e, pos=p)
s = replace(s, enemies=tuple(es))
notes.append(f'敵{i + 1} → ({p[0] + 1},{p[1] + 1})へ移動・攻撃なし')
else:
notes.append(f'敵{i + 1} → 道がなく待機')
if s.flag_hp == 0:
return replace(s, result='旗が倒れた', events=tuple(notes))
s = replace(s, rounds=s.rounds + 1, remaining=2)
return replace(s, result='守り成功' if s.rounds >= 6 else '', events=tuple(notes))
def preview(b, s):
if s.result:
return ()
return enemy_phase(b, s).events
def step(b, s, a, compare=False):
if a not in legal(b, s, compare):
return s
units = list(s.allies)
es = list(s.enemies)
notes = []
swaps = s.swaps
if a.kind == 'move':
units[a.actor] = replace(units[a.actor], pos=a.target)
notes = [NAMES[a.actor] + 'が移動']
elif a.kind == 'attack':
j = next((j for j, e in enumerate(es) if e.hp > 0 and e.pos == a.target))
damage = 2 if units[a.actor].role == 0 else 1
es[j] = replace(es[j], hp=max(0, es[j].hp - damage))
notes = [NAMES[a.actor] + 'の' + ROLES[units[a.actor].role] + f' → 敵{j + 1} {damage}ダメージ']
elif a.kind == 'swap':
j = a.target[0]
u, v = (units[a.actor], units[j])
units[a.actor] = replace(u, role=v.role)
units[j] = replace(v, role=u.role)
swaps += 1
notes = [NAMES[a.actor] + 'と' + NAMES[j] + 'が役目交換 / HP・場所はそのまま']
elif a.kind == 'rescue':
j = a.target[0]
units[j] = replace(units[j], hp=1)
notes = [NAMES[a.actor] + 'が' + NAMES[j] + 'をHP1で救起']
else:
notes = ['残りの行動を待つ' if a.kind == 'end' else '一行動待つ']
n = replace(s, allies=tuple(units), enemies=tuple(es), swaps=swaps, remaining=0 if a.kind == 'end' else s.remaining - 1, actions=s.actions + 1, events=tuple(notes))
if all((e.hp == 0 for e in es)):
return replace(n, result='全敵撃破')
if n.remaining == 0:
e = enemy_phase(b, n)
n = replace(e, events=tuple(notes) + e.events)
return n
class Timeline:
def __init__(self, b, compare=False):
self.board = b
self.compare = compare
self.state = initial(b)
self.history = []
self.inputs = []
def act(self, a):
n = step(self.board, self.state, a, self.compare)
if n == self.state:
return False
self.history.append(self.state)
self.inputs.append(a)
self.state = n
return True
def undo(self):
if not self.history:
return False
self.state = self.history.pop()
self.inputs.pop()
return True
STAGE = 5
BOARD = BOARDS[1]
LABELS = ("AKI","FUMI","RUU")
ROLE_LABELS = ("SPEAR","BOW","SHIELD")
ROLE_COLORS = (8,10,11)
def action_label(action):
if action.kind in ('wait','end'):
return 'WAIT ONE ACTION' if action.kind=='wait' else 'END TEAM TURN'
actor=LABELS[action.actor]
if action.kind in ('swap','rescue'):
return action.kind.upper()+' '+actor+' / '+LABELS[action.target[0]]
return action.kind.upper()+' '+actor+' -> '+str(action.target)
class App:
def __init__(self):
self.reset()
def reset(self):
self.timeline=Timeline(BOARD)
self.selected=0
self.gated=False
self.message="READY"
@property
def state(self):
return self.timeline.state
def choices(self):
return legal(BOARD,self.state)
def commit(self,action):
before=self.state
self.timeline.act(action)
self.selected=0
self.message=action_label(action) if self.state!=before else "REFUSED"
def control(self,pressed,held):
if 'R' in pressed:
self.reset()
self.gated=True
return
if self.gated:
if not pressed and not held:
self.gated=False
return
if len(pressed|held)>1:
self.gated=True
self.message='RELEASE ALL KEYS'
return
if len(pressed)!=1:
return
key=next(iter(pressed))
self.gated=True
if key=='X':
self.message='UNDO' if self.timeline.undo() else 'NO HISTORY'
self.selected=0
return
choices=self.choices()
if not choices:
return
if key in ('LEFT','RIGHT'):
self.selected=(self.selected+(-1 if key=='LEFT' else 1))%len(choices)
elif key=='Z':
self.commit(choices[self.selected])
elif key=='E':
self.commit(Action('end'))
def draw(self,p):
state=self.state
p.cls(1)
p.text(12,8,'ROLE FORTRESS / STAGE '+str(STAGE)+' / BOARD '+BOARD.id,10)
p.text(12,23,'ACTIONS '+str(state.actions)+' / SWAPS '+str(state.swaps),7)
for y in range(5):
for x in range(6):
px=24+x*34;py=60+y*34
p.rect(px,py,32,32,5 if (x,y) in BOARD.walls else 1)
p.rectb(px,py,32,32,13)
if protected(state,(x,y)):
p.rectb(px+2,py+2,28,28,11)
for x in range(6):p.text(38+x*34,46,str(x),6)
for y in range(5):p.text(12,74+y*34,str(y),6)
fx,fy=BOARD.flag;p.tri(36+fx*34,66+fy*34,36+fx*34,83+fy*34,49+fx*34,75+fy*34,10)
for n,unit in enumerate(state.enemies):
x,y=unit.pos;p.rect(33+x*34,69+y*34,16,16,8 if unit.hp>0 else 5)
p.text(37+x*34,74+y*34,str(n+1),7)
for n,unit in enumerate(state.allies):
x,y=unit.pos;color=ROLE_COLORS[unit.role] if unit.hp>0 else 6
if unit.hp>0:
p.circ(40+x*34,76+y*34,10,color)
p.text(38+x*34,74+y*34,'AFR'[n],1)
else:
# A fallen body never hides a living unit occupying this cell.
p.text(26+x*34+n*6,62+y*34,'AFR'[n],6)
p.text(12,248+n*16,LABELS[n]+' '+str(unit.pos)+' HP'+str(unit.hp)+' '+ROLE_LABELS[unit.role],color)
for n,enemy in enumerate(state.enemies):p.text(12,307+n*14,'ENEMY '+str(n+1)+' '+str(enemy.pos)+' HP'+str(enemy.hp),8 if enemy.hp>0 else 6)
choices=self.choices()
p.text(256,46,'LEGAL ACTIONS '+str(len(choices)),7)
start=(self.selected//7)*7
for n,action in enumerate(choices[start:start+7],start):
y=65+(n-start)*22
if n==self.selected:p.rect(252,y-4,328,19,2);p.rectb(252,y-4,328,19,10)
p.text(260,y,action_label(action),7)
p.text(256,230,'TEAM LEFT '+str(state.remaining)+' / ROUND '+str(state.rounds)+' / FLAG HP '+str(state.flag_hp),11)
if not state.result:
future=enemy_phase(BOARD,state)
p.text(256,247,'IF ENEMIES NOW: FLAG '+str(future.flag_hp)+' / ALLY HP '+','.join(str(u.hp) for u in future.allies),6)
result={'守り成功':'HELD SIX ROUNDS','全敵撃破':'ALL ENEMIES DEFEATED','旗が倒れた':'FLAG LOST'}.get(state.result,'PLAY')
p.text(256,264,result,10)
p.text(256,283,"HISTORY "+str(len(self.timeline.history)),11)
p.text(12,363,self.message,14)
p.text(12,382,'LEFT/RIGHT: CHOOSE ACTION Z: DO IT R: RESET ESC: QUIT',7)
p.text(12,398,"E: END TEAM TURN X: UNDO INCLUDING AUTOMATIC ENEMY PHASE",7)
DEMO_ACTIONS = (('swap', 0, (2, 0)), ('end', 0, (0, 0)), ('attack', 2, (0, 1)), ('end', 0, (0, 0)), ('end', 0, (0, 0)), ('end', 0, (0, 0)), ('end', 0, (0, 0)), ('end', 0, (0, 0)))
def make_demo_keys():
app=App();keys=[]
def tap(key):
keys.append(key)
app.control({key},{key});app.control(set(),set())
for kind,actor,target in DEMO_ACTIONS:
desired=Action(kind,actor,target)
choices=app.choices()
if desired not in choices:
raise ValueError('The QA route does not fit this modified board.')
index=choices.index(desired)
while app.selected!=index:tap('RIGHT')
tap('Z')
tap("X")
return tuple(keys)
def main(headless=False,screenshot=None,demo=False):
import pyxel as p
p.init(592,420,title='Role fortress lesson '+str(STAGE),fps=30,display_scale=2,headless=headless)
app=App();sequence=make_demo_keys() if demo else ()
keys={'LEFT':p.KEY_LEFT,'RIGHT':p.KEY_RIGHT,'Z':p.KEY_Z,'X':p.KEY_X,'R':p.KEY_R,'E':p.KEY_E}
limit=max(20,2*len(sequence)+8)
def update():
if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
if screenshot:
app.draw(p);p.screen.save(screenshot,1)
p.quit()
if headless:
if demo:
index=p.frame_count//2
pressed={sequence[index]} if p.frame_count%2==0 and index<len(sequence) else set()
app.control(pressed,pressed)
else:app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
p.run(update,lambda:app.draw(p))
if __name__=='__main__':
import argparse
from pathlib import Path
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--headless-smoke',action='store_true')
parser.add_argument('--demo-checkpoint',action='store_true')
parser.add_argument('--screenshot')
args=parser.parse_args()
if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
if args.screenshot and (Path(args.screenshot).suffix.lower()!='.png' or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):parser.error('use a new .png in an existing directory')
if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
main(args.headless_smoke,args.screenshot,args.demo_checkpoint)仕組みがわかったら、元の完成版を読んでみよう。
ルールのまとめ(元のゲームの状況 B):3人の名前・位置・HP と、役目の札を分けて持ち、となりの味方との役目の交換は行動1回。チームで2回行動したあと、敵が番号順に、旗、HP の低い味方の順で攻撃するか、一番短い道で1歩進みます。盾は距離1以内を守ります。敵の番を6回しのいで旗が残るか、敵を全部倒せばクリア。
ここまでに作ったのは、元のゲームの仕組みだけを取り出して作り直した「簡略版」です。元のゲームのソースは手を加えずに、別のZIP(元の完成版ソース)に入れてあります。
| 比べる点 | 5ステップの教材 | 元の完成版 |
|---|---|---|
| 段階 | 01 は兵士の移動、02 は役目の交換と盾の範囲、03 は敵が反撃しない攻撃だけの練習。04/05 は状況 B のふだんのルール。 | 状況 A/B/C の3つの砦、交換なしとの比較、起き上がらせる行動と敵の番を、最初から使えます。 |
| 操作 | 左右キーで今できる行動の一覧から選び、Z で確定。E で敵の番へ。R/Esc はすぐ実行。05 では Undo のあと一覧の先頭に戻ります。 | 地図の上のカーソルと行動の種類の切り替え、兵士と相手の選択、やり直し・比較・終了の確認画面。 |
| 表示と記録 | 英数字の表示、0から数える座標。倒れた兵士はマスの角の文字。敵の予測は数字でまとめて表示。ファイルへの保存と、日本語の出来事の表示は省いています。 | 日本語の文字、ゲームの出来事の日本語表示、記録と保存、F12 キーを使った描画の機能。 |
完成版のコードで、次に読むところ
- model.py の
Unit/Action:兵士と役目の札を別々に持つ方法 - model.py の
can_attack:攻撃が届く範囲と、途中をふさぐ駒 - model.py の
enemy_move/enemy_phase:敵が動く順番 - model.py の
Timeline.act/undo:1回の判断ごとに戻す仕組み - controller.py と policy_results.json:操作の処理と、いくつもの成功例
最初は、数字かルールを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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。
うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る
