戦略・対戦LESSON 64人物を固定した役札交換のターン戦術原作085

役目を交換する砦の作り方

兵士を動かすか、役目の札を渡すか。
交換しても名前・HP・位置は変わらず、役目の札(槍・弓・盾)だけが入れ替わります。攻撃が届く範囲(射程)、盾が守る範囲、チーム全体で2回だけ動ける行動回数を作ります。

完成版の遊び方兵士の場所と体力はそのままに役目の札だけを渡し合い、チームで2回の行動と敵の動きを読みながら旗を守ります。

役目を交換する砦の元の完成版。自動操作によるPyxelの実描画画面。
元の完成版Bの自動描画です。原作画面の座標は1始まり、下の教材ではコードに合わせ0始まりで表示します。
PLAY / 試験公開

完成サンプルをブラウザーで遊ぶ

この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。

完成版の主な操作:矢印マス/Z味方選択、Tab移動・攻撃・交換・救起、Enter確定、Space敵番、X戻す

ボタンを押すと、ゲームの読み込みが始まります。

画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。

うまく表示されないとき

ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。

同じPyxelアプリをダウンロード(.pyxapp) / 別のタブで開く

兵士の状態はそのままにして、役目だけを交換する。

いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、5つのステップで作っていきます。どのステップのファイルもそれだけで動くので、動きを確かめてから次へ進みましょう。

1. 兵士の位置と、ルール上できる移動
2. 役目だけを交換し、兵士はそのまま
3. 距離・まっすぐな線・途中をふさぐ駒
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
変数・if・for・関数 / マス目の座標と集合(set) / 書き換えない dataclass と replace(共通の入門ページで説明しています)
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
左右:合法行動一覧を選ぶ(直接の地図移動ではない) / Z:選んだ一手を確定 / E:04/05で残りを終えて敵番へ / X:05で自動敵番も含めてUndo / R:初期化。新しい押下を優先 / Esc:終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_bodies.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。

教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。

STEP 01 / 05

名前のある身体を、一マス動かす

兵士の位置と、ルール上できる移動。

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 の一部です。前のステップから追加・変更したところだけを抜き出しています。

01_bodies.py / 注目する部分
            if inside(dest) and dest not in busy:
                out.append(Action('move',i,dest))

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 01_bodies.py
macOS / Linux
.venv/bin/python 01_bodies.py
画面で見ること最初に Z を押すと、AKI が (2,0) に動き、ACTIONS 1 になります。FUMI (2,1) と RUU (1,1) はそのままです。旗のマス (0,0) に入る行動は、一覧に出てきません。
  • 兵士の番号と場所を分けて持つ
  • 地図の外・ほかの駒・旗を検査する
  • 一覧を選ぶだけでは、行動に数えない
つまずきやすいところ左右キーを押すたびに兵士の座標を変えないでください。この教材では、左右キーは一覧から選ぶだけで、Z がゲームの一手です。
少し変えてみよう

BOARDS の B にある味方の最初の位置 ((1,0),(2,1),(1,1)) の、最初の1つだけを (2,0) に変えてみましょう。AKI は (2,0) から始まり、最初の Z で (1,0) へ移動します。確認したら (1,0) に戻します。

01_bodies.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(253行)
01_bodies.py / 全文
# 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)
STEP 02 / 05

身体を残して、役札だけ交換する

役目だけを交換し、兵士はそのまま。

となり合った、生きている味方どうしで 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 の一部です。前のステップから追加・変更したところだけを抜き出しています。

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を展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 02_roles.py
macOS / Linux
.venv/bin/python 02_roles.py
画面で見ることSWAP AKI / RUU を選んで Z を押します。AKI は (1,0)・HP3・SHIELD(盾)、RUU は (1,1)・HP3・SPEAR(槍)になります。旗に緑の守りの枠が付き、ACTIONS 1・SWAPS 1 です。
  • 交換前の2人から、交換後の2人を作る
  • HP と場所は、兵士本人に残る
  • 盾が守る範囲は、交換したらすぐ変わる
つまずきやすいところ役目の札と一緒に HP まで交換しないでください。けがは兵士本人に残り、役目だけを引き継ぎます。
少し変えてみよう

initial の Unit(pos,3,i) を Unit(pos,1 if i==0 else 3,i) に変えて、AKI だけ HP1 で始めてみましょう。交換しても、AKI は HP1、RUU は HP3 のままです。確認したら3に戻します。

02_roles.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(267行)
02_roles.py / 全文
# 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)
STEP 03 / 05

役札から射程と威力を決める

距離・まっすぐな線・途中をふさぐ駒。

敵に HP を加え、攻撃できるようにします。槍はとなりの敵に2、盾はとなりの敵に1、弓は同じ行か同じ列で距離2〜3の敵に1ダメージを与えます。弓は、距離1の敵にも、斜めの敵にも攻撃できません。同じ「近い」でも、役目によって条件が違います。

can_attack は、まず攻撃する兵士が生きているかを調べ、弓のときだけ、まっすぐな線の上か・距離はいくつかを調べます。さらに、途中のマスを1つずつたどって、壁・生きている味方・敵がないかを検査します。狙っている敵自身のマスは、途中のマスに含めません。最初の位置の FUMI から敵1までは、まっすぐ2マスですが、途中に RUU がいてふさいでいます。

攻撃すると、相手の HP を減らします(0より下にはなりません)。HP0 になった兵士や敵は、通り道をふさがなくなりますが、元の位置と役目の札はデータに残ります。倒れた味方はマスの角に小さな文字で表示し、あとからそのマスに入った生きている駒を隠さないようにします。味方が倒れることがあるのは、敵の番を加える次の段階からです。

この段階は、何回でも行動できる練習です。敵は反撃も移動もしません。攻撃・移動・交換の違いだけを確かめます。最後の敵を倒すと一覧は空になりますが、「敵の番を6回しのいで旗を守る」という目標はまだありません。

下のコードは 03_attack.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

03_attack.py / 注目する部分
    if not 2 <= d <= 3 or (u.pos[0] != target[0] and u.pos[1] != target[1]):
        return False

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 03_attack.py
macOS / Linux
.venv/bin/python 03_attack.py
画面で見ることATTACK RUU -> (0,1) を2回確定します。敵1の HP は 2→1→0 と減ります。味方の HP は全員3のままで、ACTIONS 2 です。途中に RUU がいる間は、FUMI の弓で敵1を攻撃する行動は一覧に出ません。
  • 役目によって、届く範囲と威力が変わる
  • 弓は、途中のマスを1つずつ調べる
  • 倒れた駒は、通り道をふさがない
つまずきやすいところ「マンハッタン距離が2なら弓が届く」だけでは足りません(斜めでも距離2になるからです)。同じ行か列にいる・距離2〜3・途中が空いている、を全部調べます。
少し変えてみよう

initial で敵を作る Unit(pos,2) を Unit(pos,3) に変えてみましょう。RUU の盾の攻撃2回では敵1の HP が1残り、3回で0になります。確認したら2に戻します。

03_attack.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(287行)
03_attack.py / 全文
# 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)
STEP 04 / 05

二行動の後、敵を番号順に進める

チームで分け合う行動回数と、順番に動く敵の番。

元のゲームの状況 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 の一部です。前のステップから追加・変更したところだけを抜き出しています。

04_enemy_turn.py / 注目する部分
    if n.remaining == 0:
        e = enemy_phase(b, n)
        n = replace(e, events=tuple(notes) + e.events)

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 04_enemy_turn.py
macOS / Linux
.venv/bin/python 04_enemy_turn.py
画面で見ることSWAP AKI / RUU → E → ATTACK RUU -> (0,1) を確定し、そのあと E を5回押します。ROUND 6・FLAG HP3・HELD SIX ROUNDS(6回しのいだ)、FUMI HP1 になります。
  • 行動回数は、チームで分け合う
  • 敵は、前の敵が動いたあとの状態を順に読む
  • 敵は、移動した番には攻撃しない
つまずきやすいところROUND は、終わった敵の番の数です。旗が壊された敵の番は、途中で終わったので数えません。B で E を3回押すだけだと FLAG LOST(旗を失った)になり、ROUND の表示は2のままです。
少し変えてみよう

R で最初に戻し、交換をせずに、RUU の盾で敵1を2回攻撃してみましょう。そのあと E を5回押しても、ROUND 6・旗 HP3 で成功します。役目の交換だけが正解ではありません。別に、R→E→E→E では旗が壊されることも比べてみましょう。

04_enemy_turn.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(381行)
04_enemy_turn.py / 全文
# 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)
STEP 05 / 05

自動の敵番まで、一つの履歴へ戻す

判断する前の状態を、まるごと保存する。

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 の一部です。前のステップから追加・変更したところだけを抜き出しています。

05_undo.py / 注目する部分
        self.state = self.history.pop()
        self.inputs.pop()

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 05_undo.py
macOS / Linux
.venv/bin/python 05_undo.py
画面で見ること04 と同じ手順を 05 で行い、成功したあとで X を押します。ROUND 5、FUMI HP2、敵3は (2,4)、HISTORY 7 に戻ります。E を押すと、もう一度 ROUND 6・旗 HP3 の同じ成功になります。
  • 確定した味方の一手を、戻す単位にする
  • 自動で進んだ敵の番の結果も一緒に戻す
  • 戻したあと、別の判断で別の未来へ進める
つまずきやすいところ敵1体ずつの処理の途中を、別々の Undo にしないでください。プレイヤーが確定した一手をひとまとまりにして、そのあと自動で起きた結果も一緒に戻します。
少し変えてみよう

R→E で旗の HP が2になり、X で最初の旗 HP3 に戻ります。今度は AKI と RUU を交換してから E を押すと、同じ敵1の攻撃を受けても、旗は HP3 のままです。履歴には、交換と E の2件だけが残ります。

05_undo.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(416行)
05_undo.py / 全文
# 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)
AFTER STEP 05

仕組みがわかったら、元の完成版を読んでみよう。

ルールのまとめ(元のゲームの状況 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 キーを使った描画の機能。

完成版のコードで、次に読むところ

  1. model.py の Unit / Action:兵士と役目の札を別々に持つ方法
  2. model.py の can_attack:攻撃が届く範囲と、途中をふさぐ駒
  3. model.py の enemy_move / enemy_phase:敵が動く順番
  4. model.py の Timeline.act / undo:1回の判断ごとに戻す仕組み
  5. 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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。

うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る