戦略・対戦LESSON 17ターン制剣戟 × 3手の記憶原作034

三手の剣舞の作り方

相手は、あなたの直前の3手を覚えている。
これまでの行動の記録と多数決から、相手の次の構えを決めます。公開された予告を見ながら、1手ずつ戦うゲームを作ります。

完成版の遊び方直前の3手を読んでくる相手の構えを見て、続けるほど威力が上がる斬りと、間合いを変える動きを選びます。

三手の剣舞の元の完成版。自動操作によるPyxelの実描画画面。
元の完成版の画面です。この講座では、仕組みがわかりやすいように小さくした「教材版」を、5つのステップで作ります。
PLAY / 試験公開

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

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

完成版の主な操作:左かAで後ろへ、上かZで斬る、右かDで前へ、Spaceでその場で止まる、Xで1手戻す、Rでやり直し、Pで一時停止、1〜3で選択

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

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

うまく表示されないとき

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

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

記録を確定してから、次の行動を決める。

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

1. 座標・移動の候補・盤の外への入力を分ける
2. 距離による条件分岐と、ゲームが終わった状態
3. 決まった数だけの記録・count・同じ数のときは新しい手を選ぶ
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
Python 3.11以上と、仮想環境の起動(古い個別のREADMEにある3.10という表記より、共通の要件を優先してください) / 変数・if・for・関数・辞書の基礎 / リストやタプルの順番、[-3:]、count、reversed は本文の例で学びます / dataclass・frozen・replace は本文で役割を説明します。クラスは「データと操作をまとめる箱」と考えれば大丈夫です。
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
左/A:後ろへ / 右/D:前へ / Space:止まる / 上/Z:斬る(02から) / X:1手戻す(05) / R:やり直し / Esc:終了
難易度の目安
初級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_distance.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

9マスの間合いを作る

座標・移動の候補・盤の外への入力を分ける。

自分は3、相手は6のマスから始めます。右(F)は+1、左(B)は-1、止まる(W)は0です。candidate で仮の位置を作り、1〜9の外なら元の State を返します。相手と同じマスに入ろうとする前進ではその場に止まりますが、有効な1手なので turns(手数)を増やします。State はマスの番号だけを持ち、画面のピクセルの座標は fighter で計算します。この段階は移動の練習で、攻撃・体力・勝ち負け・記憶はありません。

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

01_distance.py / 注目する部分
    if not 1 <= candidate <= 9:
        return s  # 盤外入力は手数を使わない。
    if candidate >= s.enemy:
        candidate = s.player  # 同じマスへ入れない。有効な1手として数える。

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_distance.py
macOS / Linux
.venv/bin/python 01_distance.py
画面で見ること右を2回押すと、自分は5、距離1、TURN 2 になります。さらに右を1回押すと、位置は5のまま TURN 3 です。R で戻して左を3回押すと、位置1、TURN 2 のままです。何も押さないと進みません。
  • 右2回で距離1になる
  • 相手にぶつかる前進と、盤の外への後退を区別できる
  • 押しっぱなしでも、勝手に連打されない
つまずきやすいところ相手にぶつかった場合と盤の外の場合を同じ return にすると、ぶつかった手まで消えてしまいます。元のゲームでは、盤の外だけが無効で、ぶつかった前進は記録に残る有効な手です。
少し変えてみよう

最初の State の player を3から2に変えて、相手のとなりの5に着くまで右を何回押すか予想しましょう。元に戻したら、ぶつかる前進では手数が増え、左端への後退では増えないことを説明してみましょう。

01_distance.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(181行)
01_distance.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 contributors
# 
# 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.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
# この教材はNoto字形を同梱せず、ASCIIと図形のみで描画します。

"""三手の剣舞・教材 1/5。単独で実行できる段階別プログラム。"""
from dataclasses import dataclass, replace, asdict

ACTIONS = ('F', 'B', 'W')

@dataclass(frozen=True)
class State:
    player: int = 3
    enemy: int = 6
    turns: int = 0


def initial():
    return State()


def transition(s, action):
    if action not in ACTIONS:
        return s
    candidate = s.player + {'F': 1, 'B': -1, 'W': 0}[action]
    if not 1 <= candidate <= 9:
        return s  # 盤外入力は手数を使わない。
    if candidate >= s.enemy:
        candidate = s.player  # 同じマスへ入れない。有効な1手として数える。
    return State(candidate, s.enemy, s.turns + 1)

class Game:
    def __init__(self):
        self.reset()

    def reset(self):
        self.state = initial()

    def act(self, action):
        self.state = transition(self.state, action)

KEY_ACTIONS = {'LEFT': 'B', 'A': 'B', 'RIGHT': 'F', 'D': 'F', 'SPACE': 'W'}

class Controller:
    """Pyxelの押下とゲーム1手を分離。合成入力でも同じ窓口を使う。"""
    def __init__(self):
        self.game = Game()
        self.release_gate = False

    def tick(self, pressed=(), held=()):
        pressed, held = set(pressed), set(held)
        controls = set(KEY_ACTIONS) | {'R', 'X'}
        if self.release_gate:
            if not (held & controls):
                self.release_gate = False
            return
        if 'R' in pressed:
            self.game.reset()
            self.release_gate = True
            return
        chosen = {action for key, action in KEY_ACTIONS.items() if key in pressed}
        held_actions = {action for key, action in KEY_ACTIONS.items() if key in held}
        if len(chosen) == 1 and len(held_actions) == 1:
            self.game.act(next(iter(chosen)))

def main(headless=False, screenshot=None, actions='', frames=12, report=None):
    import json
    from pathlib import Path
    # Pyxel初期化によるcwd変更前に利用者の出力先を確定する。
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    report = str(Path(report).resolve()) if report else None
    import pyxel

    controller = Controller()
    events = []
    tick = 0
    keys = {key: getattr(pyxel, 'KEY_' + key) for key in (*KEY_ACTIONS, 'R', 'X')}
    replay_keys = {'F': 'RIGHT', 'B': 'LEFT', 'W': 'SPACE', 'S': 'Z', 'K': 'R', 'X': 'X'}

    def update():
        nonlocal tick
        tick += 1
        if headless:
            if tick <= len(actions):
                # QA専用の合成押下。OSキーボード操作・人の試遊ではない。
                key = replay_keys[actions[tick - 1]]
                controller.tick((), ())
                controller.tick((key,), (key,))
                events.append({'tick': tick, 'action': actions[tick - 1],
                               'state': asdict(controller.game.state)})
        else:
            controller.tick([key for key, value in keys.items() if pyxel.btnp(value)],
                            [key for key, value in keys.items() if pyxel.btn(value)])

    def fighter(position, y, color, direction):
        x = 32 + (position - 1) * 42
        pyxel.circ(x, y - 13, 5, color)
        pyxel.line(x, y - 7, x, y + 8, color)
        pyxel.line(x, y + 8, x - 6, y + 16, color)
        pyxel.line(x, y + 8, x + 6, y + 16, color)
        pyxel.line(x, y - 3, x + direction * 12, y - 9, color)
        pyxel.line(x + direction * 12, y - 9, x + direction * 21, y - 20, 7)

    def draw():
        s = controller.game.state
        pyxel.cls(1)
        pyxel.text(10, 8, 'THREE MOVE DANCE / STEP 1', 10)
        pyxel.text(10, 21, 'TRAINING: MOVEMENT ONLY', 13)
        pyxel.text(10, 35, f'TURN {min(s.turns,9999)}   DISTANCE {s.enemy - s.player}', 7)
        pyxel.text(10, 48, 'TARGET: REACH CELL 5; NO DAMAGE YET', 6)
        pyxel.text(10, 74, 'HISTORY: NOT ADDED IN THIS STAGE', 5)
        pyxel.text(10, 109, 'ENEMY DOES NOT MOVE OR ATTACK.', 13)
        for position in range(1, 10):
            x = 32 + (position - 1) * 42
            pyxel.rectb(x - 19, 133, 38, 57, 5)
            pyxel.text(x - 2, 194, str(position), 13)

        fighter(s.player, 166, 6, 1)
        fighter(s.enemy, 161, 8, -1)
        pyxel.text(10, 216, 'F/B: ONE CELL. W: ONE TURN WITHOUT MOVING.', 6)
        pyxel.text(10, 228, 'WALL INPUT: NO TURN. CONTACT: STAY, USE TURN.', 13)
        pyxel.text(10, 252, 'LEFT/A: BACK  RIGHT/D: FORWARD  SPACE: WAIT', 7)
        pyxel.text(10, 266, 'R: RESET  ESC: QUIT', 7)
        pyxel.text(10, 284, 'NO TIME LIMIT / SILENT / R TO RESTART', 13)
        if headless and tick >= frames:
            if screenshot:
                pyxel.screenshot(screenshot, scale=2)
            if report:
                Path(report).write_text(json.dumps({
                    'label': 'Synthetic input; actual Pyxel headless render; silent',
                    'frames': tick, 'actions': actions, 'events': events,
                    'final_state': asdict(s),
                }, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
            pyxel.quit()

    pyxel.init(400, 302, title='Three Move Dance - Stage 1', fps=30, headless=headless)
    pyxel.run(update, draw)


if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--headless-replay', help='Synthetic actions; K resets; not OS keys')
    parser.add_argument('--actions', default='', help='Synthetic actions within a 12-frame smoke')
    parser.add_argument('--screenshot', help='PNG path, headless only')
    parser.add_argument('--report', help='UTF-8 JSON path, headless only')
    args = parser.parse_args()
    if args.headless_smoke and args.headless_replay is not None:
        parser.error('Choose smoke or replay, not both')
    actions = args.actions if args.headless_replay is None else args.headless_replay
    if any(action not in 'FBWK' for action in actions):
        parser.error('Supported replay actions: FBWK')
    headless = args.headless_smoke or args.headless_replay is not None
    if not headless and (args.actions or args.screenshot or args.report):
        parser.error('actions, screenshot and report require a headless mode')
    if args.headless_smoke and len(actions) > 12:
        parser.error('Smoke accepts at most 12 actions; use replay')
    frames = 12 if args.headless_replay is None else max(12, len(actions) + 2)
    main(headless, args.screenshot, actions, frames, args.report)
STEP 02 / 05

届く斬りと体力を足す

距離による条件分岐と、ゲームが終わった状態。

斬る(S)を追加し、移動したあとの distance(距離)が1のときだけ、相手の体力を1減らします。max(0, ...) でマイナスにならないようにし、体力が0なら outcome を won(勝ち)にします。終わったあとは、R でやり直すまで状態を変えません。相手は止まったままの練習台で、反撃も移動もしません。続けて斬っても威力は1で、次の段階の「続けて斬る」とはまだ違います。

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

02_slash.py / 注目する部分
    damage = 1 if action == 'S' and distance == 1 else 0
    hp = max(0, s.enemy_hp - damage)
    outcome = 'won' if hp == 0 else 'playing'

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_slash.py
macOS / Linux
.venv/bin/python 02_slash.py
画面で見ること始めてすぐ Z を押すと、距離が3なので空振りで、相手の HP は5のまま TURN 1 になります。R のあと 右右ZZZZZ と押すと、相手の HP が0になって WON、TURN 7 です。WON のあとは、動いても斬っても何も変わりません。
  • 距離1の斬りだけ当たる
  • 5回当てると相手を倒せる
  • WON のあとの入力では状態が変わらない
つまずきやすいところS を押しただけで HP を減らすと、遠くからでも当たってしまいます。「入力」と「距離が1」の2つの条件を and でつなぎます。また、相手の HP が0になった直後にも動けると、終わった状態が固定されません。
少し変えてみよう

damage の1を2に変えて、最初の HP が5の相手に何回当てれば勝てるか予想しましょう。最後の一撃で HP が-1にならず0になることも確かめたら、1に戻します。これは元のゲームのルールとは別の改造です。

02_slash.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(187行)
02_slash.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 contributors
# 
# 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.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
# この教材はNoto字形を同梱せず、ASCIIと図形のみで描画します。

"""三手の剣舞・教材 2/5。単独で実行できる段階別プログラム。"""
from dataclasses import dataclass, replace, asdict

ACTIONS = ('F', 'S', 'B', 'W')

@dataclass(frozen=True)
class State:
    player: int = 3
    enemy: int = 6
    enemy_hp: int = 5
    turns: int = 0
    outcome: str = 'playing'


def initial():
    return State()


def transition(s, action):
    if s.outcome != 'playing' or action not in ACTIONS:
        return s
    candidate = s.player + {'F': 1, 'S': 0, 'B': -1, 'W': 0}[action]
    if not 1 <= candidate <= 9:
        return s
    if candidate >= s.enemy:
        candidate = s.player
    distance = s.enemy - candidate
    damage = 1 if action == 'S' and distance == 1 else 0
    hp = max(0, s.enemy_hp - damage)
    outcome = 'won' if hp == 0 else 'playing'
    return State(candidate, s.enemy, hp, s.turns + 1, outcome)

class Game:
    def __init__(self):
        self.reset()

    def reset(self):
        self.state = initial()

    def act(self, action):
        self.state = transition(self.state, action)

KEY_ACTIONS = {'LEFT': 'B', 'A': 'B', 'UP': 'S', 'Z': 'S', 'RIGHT': 'F', 'D': 'F', 'SPACE': 'W'}

class Controller:
    """Pyxelの押下とゲーム1手を分離。合成入力でも同じ窓口を使う。"""
    def __init__(self):
        self.game = Game()
        self.release_gate = False

    def tick(self, pressed=(), held=()):
        pressed, held = set(pressed), set(held)
        controls = set(KEY_ACTIONS) | {'R', 'X'}
        if self.release_gate:
            if not (held & controls):
                self.release_gate = False
            return
        if 'R' in pressed:
            self.game.reset()
            self.release_gate = True
            return
        chosen = {action for key, action in KEY_ACTIONS.items() if key in pressed}
        held_actions = {action for key, action in KEY_ACTIONS.items() if key in held}
        if len(chosen) == 1 and len(held_actions) == 1:
            self.game.act(next(iter(chosen)))

def main(headless=False, screenshot=None, actions='', frames=12, report=None):
    import json
    from pathlib import Path
    # Pyxel初期化によるcwd変更前に利用者の出力先を確定する。
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    report = str(Path(report).resolve()) if report else None
    import pyxel

    controller = Controller()
    events = []
    tick = 0
    keys = {key: getattr(pyxel, 'KEY_' + key) for key in (*KEY_ACTIONS, 'R', 'X')}
    replay_keys = {'F': 'RIGHT', 'B': 'LEFT', 'W': 'SPACE', 'S': 'Z', 'K': 'R', 'X': 'X'}

    def update():
        nonlocal tick
        tick += 1
        if headless:
            if tick <= len(actions):
                # QA専用の合成押下。OSキーボード操作・人の試遊ではない。
                key = replay_keys[actions[tick - 1]]
                controller.tick((), ())
                controller.tick((key,), (key,))
                events.append({'tick': tick, 'action': actions[tick - 1],
                               'state': asdict(controller.game.state)})
        else:
            controller.tick([key for key, value in keys.items() if pyxel.btnp(value)],
                            [key for key, value in keys.items() if pyxel.btn(value)])

    def fighter(position, y, color, direction):
        x = 32 + (position - 1) * 42
        pyxel.circ(x, y - 13, 5, color)
        pyxel.line(x, y - 7, x, y + 8, color)
        pyxel.line(x, y + 8, x - 6, y + 16, color)
        pyxel.line(x, y + 8, x + 6, y + 16, color)
        pyxel.line(x, y - 3, x + direction * 12, y - 9, color)
        pyxel.line(x + direction * 12, y - 9, x + direction * 21, y - 20, 7)

    def draw():
        s = controller.game.state
        pyxel.cls(1)
        pyxel.text(10, 8, 'THREE MOVE DANCE / STEP 2', 10)
        pyxel.text(10, 21, 'TRAINING: STATIONARY TARGET', 13)
        pyxel.text(10, 35, f'TURN {min(s.turns,9999)}   DISTANCE {s.enemy - s.player}', 7)
        pyxel.text(10, 48, f'ENEMY HP {s.enemy_hp}/5  {s.outcome.upper()}', 11 if s.outcome == 'won' else 7)
        pyxel.text(10, 74, 'HISTORY: NOT ADDED IN THIS STAGE', 5)
        pyxel.text(10, 109, 'ENEMY DOES NOT MOVE OR ATTACK.', 13)
        for position in range(1, 10):
            x = 32 + (position - 1) * 42
            pyxel.rectb(x - 19, 133, 38, 57, 5)
            pyxel.text(x - 2, 194, str(position), 13)

        fighter(s.player, 166, 6, 1)
        fighter(s.enemy, 161, 8, -1)
        pyxel.text(10, 216, 'SLASH REACH = 1 CELL / DAMAGE = 1', 6)
        pyxel.text(10, 228, 'MOVE TO CELL 5, THEN SLASH FIVE TIMES.', 13)
        pyxel.text(10, 252, 'LEFT/A: BACK  UP/Z: SLASH  RIGHT/D: FORWARD', 7)
        pyxel.text(10, 266, 'SPACE: WAIT  R: RESET  ESC: QUIT', 7)
        pyxel.text(10, 284, 'NO TIME LIMIT / SILENT / R TO RESTART', 13)
        if headless and tick >= frames:
            if screenshot:
                pyxel.screenshot(screenshot, scale=2)
            if report:
                Path(report).write_text(json.dumps({
                    'label': 'Synthetic input; actual Pyxel headless render; silent',
                    'frames': tick, 'actions': actions, 'events': events,
                    'final_state': asdict(s),
                }, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
            pyxel.quit()

    pyxel.init(400, 302, title='Three Move Dance - Stage 2', fps=30, headless=headless)
    pyxel.run(update, draw)


if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--headless-replay', help='Synthetic actions; K resets; not OS keys')
    parser.add_argument('--actions', default='', help='Synthetic actions within a 12-frame smoke')
    parser.add_argument('--screenshot', help='PNG path, headless only')
    parser.add_argument('--report', help='UTF-8 JSON path, headless only')
    args = parser.parse_args()
    if args.headless_smoke and args.headless_replay is not None:
        parser.error('Choose smoke or replay, not both')
    actions = args.actions if args.headless_replay is None else args.headless_replay
    if any(action not in 'FSBWK' for action in actions):
        parser.error('Supported replay actions: FSBWK')
    headless = args.headless_smoke or args.headless_replay is not None
    if not headless and (args.actions or args.screenshot or args.report):
        parser.error('actions, screenshot and report require a headless mode')
    if args.headless_smoke and len(actions) > 12:
        parser.error('Smoke accepts at most 12 actions; use replay')
    frames = 12 if args.headless_replay is None else max(12, len(actions) + 2)
    main(headless, args.screenshot, actions, frames, args.report)
STEP 03 / 05

直前3手を数え、同数なら新しい手を読む

直前の3手を数え、同じ数なら新しいほうの手を読む。

Memory.actions は、古い順に並んだタプルです。(古い履歴 + (今回の手,))[-3:] で、直前の3手だけを残します。前進(F)・斬る(S)・後退(B)を count で数え、いちばん多い手を、reversed を使って新しいほうから探します。止まる(W)も3つの枠のうち1つを使いますが、票には数えません。記録が空か、W だけのときは、F への対策をとります。自分の直前の手が S なら、次に当たったときは2点になります。空振りの S でも、この条件になります。この段階では、相手の読みと構えを表示するだけで、相手はまだ動いたり攻撃したりしません。

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

03_history.py / 注目する部分
    counts = {a: recent.count(a) for a in ('F', 'S', 'B')}
    most = 'F'  # 空履歴・Wだけなら前進への対策。
    maximum = max(counts.values())
    for action in reversed(recent):
        if action in counts and counts[action] == maximum:
            most = action  # 同数なら新しい方から最初に見つかった手。
            break

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_history.py
macOS / Linux
.venv/bin/python 03_history.py
画面で見ることR のあと Z、右、Space、左 の順に押すと、記録は F W B、READ(読んだ手)は B、NEXT(次の構え)は CHASE(追う)です。相手の位置は6のままです。R のあと 右右ZZZ と押すと、1+2+2 で WON です。R のあと ZZ と押すと、2回とも空振りでも、記録には S S が残ります。
  • W も3つの枠のうち1つを使う
  • 同じ数なら、新しいほうの F・S・B を選ぶ
  • 空振りの S の直後でも、次の斬りの威力は2
  • 03の NEXT は表示だけだと区別できる
つまずきやすいところ先に W を記録から取り除くと、「直前の3手」ではなく「直前の F・S・B を3つ」になってしまいます。3つの枠で切り取ったあとに、F・S・B だけを数えます。同じ数のときに、いつも F・S・B の決まった順で決めるのも、元のゲームとは違います。
少し変えてみよう

記録が (F,S,B)、(S,F,S)、(S,W,F)、(W,W,W) のときの READ を先に紙に書き、policy(Memory(...)) とテストの test_stage_03_majority_recency_and_wait で確かめましょう。もし W も数えるなら、新しい対策の決め方も必要になります。

03_history.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(228行)
03_history.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 contributors
# 
# 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.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
# この教材はNoto字形を同梱せず、ASCIIと図形のみで描画します。

"""三手の剣舞・教材 3/5。単独で実行できる段階別プログラム。"""
from dataclasses import dataclass, replace, asdict

ACTIONS = ('F', 'S', 'B', 'W')

@dataclass(frozen=True)
class Memory:
    actions: tuple = ()

@dataclass(frozen=True)
class Intent:
    kind: str
    move: int
    ranges: tuple
    counter: str

@dataclass(frozen=True)
class State:
    player: int
    enemy: int
    enemy_hp: int
    memory: Memory
    intent: Intent
    turns: int = 0
    outcome: str = 'playing'


def policy(memory):
    # 呼び出し時に確定済みの過去3手だけを読む。今回の入力は引数にしない。
    recent = memory.actions[-3:]
    counts = {a: recent.count(a) for a in ('F', 'S', 'B')}
    most = 'F'  # 空履歴・Wだけなら前進への対策。
    maximum = max(counts.values())
    for action in reversed(recent):
        if action in counts and counts[action] == maximum:
            most = action  # 同数なら新しい方から最初に見つかった手。
            break
    if most == 'F':
        return Intent('guard', 0, (1, 2), most)
    if most == 'S':
        return Intent('retreat', 1, (2,), most)
    return Intent('chase', -2, (), most)


def initial():
    memory = Memory()
    return State(3, 6, 5, memory, policy(memory))


def transition(s, action):
    if s.outcome != 'playing' or action not in ACTIONS:
        return s
    candidate = s.player + {'F': 1, 'S': 0, 'B': -1, 'W': 0}[action]
    if not 1 <= candidate <= 9:
        return s
    if candidate >= s.enemy:
        candidate = s.player
    # 03は読みを表示する練習版。相手のIntentはまだ実行しない。
    combo = bool(s.memory.actions and s.memory.actions[-1] == 'S')
    damage = (2 if combo else 1) if action == 'S' and s.enemy - candidate == 1 else 0
    hp = max(0, s.enemy_hp - damage)
    outcome = 'won' if hp == 0 else 'playing'
    memory = Memory((s.memory.actions + (action,))[-3:])
    return State(candidate, s.enemy, hp, memory, policy(memory), s.turns + 1, outcome)

class Game:
    def __init__(self):
        self.reset()

    def reset(self):
        self.state = initial()

    def act(self, action):
        self.state = transition(self.state, action)

KEY_ACTIONS = {'LEFT': 'B', 'A': 'B', 'UP': 'S', 'Z': 'S', 'RIGHT': 'F', 'D': 'F', 'SPACE': 'W'}

class Controller:
    """Pyxelの押下とゲーム1手を分離。合成入力でも同じ窓口を使う。"""
    def __init__(self):
        self.game = Game()
        self.release_gate = False

    def tick(self, pressed=(), held=()):
        pressed, held = set(pressed), set(held)
        controls = set(KEY_ACTIONS) | {'R', 'X'}
        if self.release_gate:
            if not (held & controls):
                self.release_gate = False
            return
        if 'R' in pressed:
            self.game.reset()
            self.release_gate = True
            return
        chosen = {action for key, action in KEY_ACTIONS.items() if key in pressed}
        held_actions = {action for key, action in KEY_ACTIONS.items() if key in held}
        if len(chosen) == 1 and len(held_actions) == 1:
            self.game.act(next(iter(chosen)))

def main(headless=False, screenshot=None, actions='', frames=12, report=None):
    import json
    from pathlib import Path
    # Pyxel初期化によるcwd変更前に利用者の出力先を確定する。
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    report = str(Path(report).resolve()) if report else None
    import pyxel

    controller = Controller()
    events = []
    tick = 0
    keys = {key: getattr(pyxel, 'KEY_' + key) for key in (*KEY_ACTIONS, 'R', 'X')}
    replay_keys = {'F': 'RIGHT', 'B': 'LEFT', 'W': 'SPACE', 'S': 'Z', 'K': 'R', 'X': 'X'}

    def update():
        nonlocal tick
        tick += 1
        if headless:
            if tick <= len(actions):
                # QA専用の合成押下。OSキーボード操作・人の試遊ではない。
                key = replay_keys[actions[tick - 1]]
                controller.tick((), ())
                controller.tick((key,), (key,))
                events.append({'tick': tick, 'action': actions[tick - 1],
                               'state': asdict(controller.game.state)})
        else:
            controller.tick([key for key, value in keys.items() if pyxel.btnp(value)],
                            [key for key, value in keys.items() if pyxel.btn(value)])

    def fighter(position, y, color, direction):
        x = 32 + (position - 1) * 42
        pyxel.circ(x, y - 13, 5, color)
        pyxel.line(x, y - 7, x, y + 8, color)
        pyxel.line(x, y + 8, x - 6, y + 16, color)
        pyxel.line(x, y + 8, x + 6, y + 16, color)
        pyxel.line(x, y - 3, x + direction * 12, y - 9, color)
        pyxel.line(x + direction * 12, y - 9, x + direction * 21, y - 20, 7)

    def draw():
        s = controller.game.state
        pyxel.cls(1)
        pyxel.text(10, 8, 'THREE MOVE DANCE / STEP 3', 10)
        pyxel.text(10, 21, 'TRAINING: INTENT IS READ-ONLY', 13)
        pyxel.text(10, 35, f'TURN {min(s.turns,9999)}   DISTANCE {s.enemy - s.player}', 7)
        pyxel.text(10, 48, f'ENEMY HP {s.enemy_hp}/5  {s.outcome.upper()}', 11 if s.outcome == 'won' else 7)
        history = ('-',) * (3 - len(s.memory.actions)) + s.memory.actions
        pyxel.text(10, 66, 'PAST 3 COMMITTED ACTIONS: OLD -> NEW', 6)
        for i, action in enumerate(history):
            x = 10 + i * 57
            pyxel.rectb(x, 77, 49, 18, 6)
            pyxel.text(x + 22, 83, action, 7)
        pyxel.text(196, 81, 'F=FORWARD S=SLASH', 13)
        pyxel.text(196, 90, 'B=BACK    W=WAIT', 13)
        pyxel.text(10, 109, f'NEXT: {s.intent.kind.upper()}  READ: {s.intent.counter}', 10)
        for position in range(1, 10):
            x = 32 + (position - 1) * 42
            pyxel.rectb(x - 19, 133, 38, 57, 5)
            pyxel.text(x - 2, 194, str(position), 13)

        fighter(s.player, 166, 6, 1)
        fighter(s.enemy, 161, 8, -1)
        pyxel.text(10, 210, 'PREVIOUS ACTION S: NEXT HIT DEALS 2.', 6)
        pyxel.text(10, 223, 'W TAKES A MEMORY SLOT, BUT GETS NO VOTES.', 13)
        pyxel.text(10, 236, 'NEXT IS ONLY A PREVIEW IN STEP 3.', 10)
        pyxel.text(10, 252, 'LEFT/A: BACK  UP/Z: SLASH  RIGHT/D: FORWARD', 7)
        pyxel.text(10, 266, 'SPACE: WAIT  R: RESET  ESC: QUIT', 7)
        pyxel.text(10, 284, 'NO TIME LIMIT / SILENT / R TO RESTART', 13)
        if headless and tick >= frames:
            if screenshot:
                pyxel.screenshot(screenshot, scale=2)
            if report:
                Path(report).write_text(json.dumps({
                    'label': 'Synthetic input; actual Pyxel headless render; silent',
                    'frames': tick, 'actions': actions, 'events': events,
                    'final_state': asdict(s),
                }, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
            pyxel.quit()

    pyxel.init(400, 302, title='Three Move Dance - Stage 3', fps=30, headless=headless)
    pyxel.run(update, draw)


if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--headless-replay', help='Synthetic actions; K resets; not OS keys')
    parser.add_argument('--actions', default='', help='Synthetic actions within a 12-frame smoke')
    parser.add_argument('--screenshot', help='PNG path, headless only')
    parser.add_argument('--report', help='UTF-8 JSON path, headless only')
    args = parser.parse_args()
    if args.headless_smoke and args.headless_replay is not None:
        parser.error('Choose smoke or replay, not both')
    actions = args.actions if args.headless_replay is None else args.headless_replay
    if any(action not in 'FSBWK' for action in actions):
        parser.error('Supported replay actions: FSBWK')
    headless = args.headless_smoke or args.headless_replay is not None
    if not headless and (args.actions or args.screenshot or args.report):
        parser.error('actions, screenshot and report require a headless mode')
    if args.headless_smoke and len(actions) > 12:
        parser.error('Smoke accepts at most 12 actions; use replay')
    frames = 12 if args.headless_replay is None else max(12, len(actions) + 2)
    main(headless, args.screenshot, actions, frames, args.report)
STEP 04 / 05

公開した構えで同時に動き、攻撃後は息継ぎする

確定する前に見てよい情報の範囲と、同時に処理する順番。

ここから、元のゲームの A の戦いのルールで動きます。入力の前から決まっている相手の構え old_intent を固定し、元の位置から両者の移動の候補を作ります。ぶつかるなら両者とも元に戻し、最後の距離で両者のダメージを計算します。両方の HP が0なら mutual(相打ち)です。最後に、今回の手を記録に加えて、次の構えを作ります。相手は「置き斬り」や「引き突き」を試みた次の手では、空振りでも1手だけ息つぎをします。「追い足」の次には、自動の息つぎはありません。

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

04_simultaneous.py / 注目する部分
    old_intent = s.intent  # 1. 入力前から公開済みの構えを固定。
    player = s.player + {'F': 1, 'S': 0, 'B': -1, 'W': 0}[action]
    if not 1 <= player <= 9:
        return Result(s, False, 'edge')
    enemy = enemy_proposal(s)  # 2. 両者の旧位置から候補を作る。
    collision = player >= enemy
    if collision:
        player, enemy = s.player, s.enemy  # 3. 衝突なら双方を元に戻す。

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_simultaneous.py
macOS / Linux
.venv/bin/python 04_simultaneous.py
画面で見ることR から Z右右ZZZ で6手で勝ち、残りの HP は1です。無傷で勝つ例は Z左左ZZ右Z右Z の9手で、残りの HP は3です。最初に Space を押すと、相手の攻撃は届かず、NEXT が BREATH(息つぎ)になります。もう一度 Space を押すと、NEXT は GUARD(守り)に戻ります。
  • 今回の入力を、相手の構えが先読みしない
  • ぶつかったら、両者の移動を元に戻す
  • 両者の HP を同時に減らす
  • 空振りの攻撃のあとも、1手だけ息つぎする
つまずきやすいところ今回の入力を先に記録に足してから構えを作ると、相手がまだ確定していない手を読めてしまいます。また、自分が先に倒した相手の攻撃を取り消すと、同時攻撃と相打ちがなくなってしまいます。ぶつかったときに片方だけを止めるのも誤りです。
少し変えてみよう

テストの test_collision_rolls_back_both_candidates にある「自分2・相手5・相手の構えが CHASE のときに F を選ぶ」例を、紙に描いてみましょう。候補がどちらも3になってぶつかり、結果が2と5に両方戻ることを説明します。続けて test_follow_retreat_into_old_enemy_cell_is_legal で、4と5から5と6へ動ける理由も比べましょう。

04_simultaneous.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(293行)
04_simultaneous.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 contributors
# 
# 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.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
# この教材はNoto字形を同梱せず、ASCIIと図形のみで描画します。

"""三手の剣舞・教材 4/5。単独で実行できる段階別プログラム。"""
from dataclasses import dataclass, replace, asdict

ACTIONS = ('F', 'S', 'B', 'W')

@dataclass(frozen=True)
class Level:
    title: str
    player: int
    enemy: int
    history: tuple

LEVEL = Level('一回斬って、間を置く', 3, 6, ())  # 原作Aだけを収録。

@dataclass(frozen=True)
class Observation:
    distance: int
    own_position: int

@dataclass(frozen=True)
class Memory:
    actions: tuple
    recovering: bool

@dataclass(frozen=True)
class Intent:
    kind: str
    move: int
    ranges: tuple
    counter: str

@dataclass(frozen=True)
class State:
    player: int
    enemy: int
    hp: int
    enemy_hp: int
    memory: Memory
    intent: Intent
    turns: int = 0
    outcome: str = 'playing'

@dataclass(frozen=True)
class Result:
    state: State
    accepted: bool
    reason: str
    dealt: int = 0
    taken: int = 0
    collision: bool = False


def observe(s):
    return Observation(s.enemy - s.player, s.enemy)


def policy(observation, memory):
    # observationは原作との共通窓口。この方策では過去の手と回復だけを使う。
    # 今回の未確定入力を引数に渡さない。
    if memory.recovering:
        return Intent('breath', 0, (), '-')
    recent = memory.actions[-3:]
    counts = {a: recent.count(a) for a in ('F', 'S', 'B')}
    most = 'F'
    maximum = max(counts.values())
    for action in reversed(recent):
        if action in counts and counts[action] == maximum:
            most = action
            break
    if most == 'F':
        return Intent('guard', 0, (1, 2), most)
    if most == 'S':
        return Intent('retreat', 1, (2,), most)
    return Intent('chase', -2, (), most)


def initial():
    memory = Memory(LEVEL.history, False)
    s = State(LEVEL.player, LEVEL.enemy, 3, 5, memory, Intent('', 0, (), ''))
    return replace(s, intent=policy(observe(s), memory))


def enemy_proposal(s):
    # 追い足は自分の旧位置の隣まで、引き突きは右端9まで。
    return max(s.player + 1, min(9, s.enemy + s.intent.move))


def transition(s, action):
    if s.outcome != 'playing':
        return Result(s, False, 'terminal')
    if action not in ACTIONS:
        return Result(s, False, 'invalid')
    old_intent = s.intent  # 1. 入力前から公開済みの構えを固定。
    player = s.player + {'F': 1, 'S': 0, 'B': -1, 'W': 0}[action]
    if not 1 <= player <= 9:
        return Result(s, False, 'edge')
    enemy = enemy_proposal(s)  # 2. 両者の旧位置から候補を作る。
    collision = player >= enemy
    if collision:
        player, enemy = s.player, s.enemy  # 3. 衝突なら双方を元に戻す。
    distance = enemy - player
    combo = bool(s.memory.actions and s.memory.actions[-1] == 'S')
    dealt = (2 if combo else 1) if action == 'S' and distance == 1 else 0
    taken = 1 if distance in old_intent.ranges else 0
    hp = max(0, s.hp - taken)  # 4. 最終距離で同時にダメージ。
    enemy_hp = max(0, s.enemy_hp - dealt)
    if hp == 0 and enemy_hp == 0:
        outcome = 'mutual'
    elif hp == 0:
        outcome = 'lost'
    elif enemy_hp == 0:
        outcome = 'won'
    else:
        outcome = 'playing'
    # 5. 今回の手をここで初めて確定履歴へ。空振りSも残す。
    memory = Memory((s.memory.actions + (action,))[-3:],
                    old_intent.kind in ('guard', 'retreat'))
    new = State(player, enemy, hp, enemy_hp, memory, old_intent, s.turns + 1, outcome)
    new = replace(new, intent=policy(observe(new), memory))
    reason = ('mutual' if outcome == 'mutual' else 'hit' if dealt else
              'hurt' if taken else 'clash' if collision else 'miss' if action == 'S' else 'moved')
    return Result(new, True, reason, dealt, taken, collision)

class Game:
    def __init__(self):
        self.reset()

    def reset(self):
        self.state = initial()
        self.last = None

    def act(self, action):
        self.last = transition(self.state, action)
        self.state = self.last.state

KEY_ACTIONS = {'LEFT': 'B', 'A': 'B', 'UP': 'S', 'Z': 'S', 'RIGHT': 'F', 'D': 'F', 'SPACE': 'W'}

class Controller:
    """Pyxelの押下とゲーム1手を分離。合成入力でも同じ窓口を使う。"""
    def __init__(self):
        self.game = Game()
        self.release_gate = False

    def tick(self, pressed=(), held=()):
        pressed, held = set(pressed), set(held)
        controls = set(KEY_ACTIONS) | {'R', 'X'}
        if self.release_gate:
            if not (held & controls):
                self.release_gate = False
            return
        if 'R' in pressed:
            self.game.reset()
            self.release_gate = True
            return
        chosen = {action for key, action in KEY_ACTIONS.items() if key in pressed}
        held_actions = {action for key, action in KEY_ACTIONS.items() if key in held}
        if len(chosen) == 1 and len(held_actions) == 1:
            self.game.act(next(iter(chosen)))

def main(headless=False, screenshot=None, actions='', frames=12, report=None):
    import json
    from pathlib import Path
    # Pyxel初期化によるcwd変更前に利用者の出力先を確定する。
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    report = str(Path(report).resolve()) if report else None
    import pyxel

    controller = Controller()
    events = []
    tick = 0
    keys = {key: getattr(pyxel, 'KEY_' + key) for key in (*KEY_ACTIONS, 'R', 'X')}
    replay_keys = {'F': 'RIGHT', 'B': 'LEFT', 'W': 'SPACE', 'S': 'Z', 'K': 'R', 'X': 'X'}

    def update():
        nonlocal tick
        tick += 1
        if headless:
            if tick <= len(actions):
                # QA専用の合成押下。OSキーボード操作・人の試遊ではない。
                key = replay_keys[actions[tick - 1]]
                controller.tick((), ())
                controller.tick((key,), (key,))
                events.append({'tick': tick, 'action': actions[tick - 1],
                               'state': asdict(controller.game.state)})
        else:
            controller.tick([key for key, value in keys.items() if pyxel.btnp(value)],
                            [key for key, value in keys.items() if pyxel.btn(value)])

    def fighter(position, y, color, direction):
        x = 32 + (position - 1) * 42
        pyxel.circ(x, y - 13, 5, color)
        pyxel.line(x, y - 7, x, y + 8, color)
        pyxel.line(x, y + 8, x - 6, y + 16, color)
        pyxel.line(x, y + 8, x + 6, y + 16, color)
        pyxel.line(x, y - 3, x + direction * 12, y - 9, color)
        pyxel.line(x + direction * 12, y - 9, x + direction * 21, y - 20, 7)

    def draw():
        s = controller.game.state
        pyxel.cls(1)
        pyxel.text(10, 8, 'THREE MOVE DANCE / STEP 4', 10)
        pyxel.text(10, 21, 'ORIGINAL A RULES / NO UNDO', 13)
        pyxel.text(10, 35, f'TURN {min(s.turns,9999)}   DISTANCE {s.enemy - s.player}', 7)
        pyxel.text(10, 48, f'HP {s.hp}/3  ENEMY HP {s.enemy_hp}/5  {s.outcome.upper()}', 11 if s.outcome == 'won' else 7)
        history = ('-',) * (3 - len(s.memory.actions)) + s.memory.actions
        pyxel.text(10, 66, 'PAST 3 COMMITTED ACTIONS: OLD -> NEW', 6)
        for i, action in enumerate(history):
            x = 10 + i * 57
            pyxel.rectb(x, 77, 49, 18, 6)
            pyxel.text(x + 22, 83, action, 7)
        pyxel.text(196, 81, 'F=FORWARD S=SLASH', 13)
        pyxel.text(196, 90, 'B=BACK    W=WAIT', 13)
        pyxel.text(10, 109, f'NEXT: {s.intent.kind.upper()}  READ: {s.intent.counter}', 10)
        for position in range(1, 10):
            x = 32 + (position - 1) * 42
            pyxel.rectb(x - 19, 133, 38, 57, 5)
            pyxel.text(x - 2, 194, str(position), 13)
            proposal = enemy_proposal(s)
            if position == proposal:
                pyxel.text(x - 8, 123, 'NEXT', 10)
            if proposal - position in s.intent.ranges:
                for offset in range(-14, 15, 7):
                    pyxel.line(x + offset, 182, x + offset + 4, 174, 8)
        fighter(s.player, 166, 6, 1)
        fighter(s.enemy, 161, 8, -1)
        pyxel.text(10, 210, 'NEXT: ENEMY CANDIDATE. RED: ATTACK DISTANCES.', 13)
        pyxel.text(10, 223, 'CLASH: BOTH STAY; DAMAGE USES THE FINAL GAP.', 13)
        pyxel.text(10, 236, 'PREVIOUS S: POWER 2 / ATTACK -> ONE BREATH.', 6)
        pyxel.text(10, 252, 'LEFT/A: BACK  UP/Z: SLASH  RIGHT/D: FORWARD', 7)
        pyxel.text(10, 266, 'SPACE: WAIT  R: RESET  ESC: QUIT', 7)
        pyxel.text(10, 284, 'NO TIME LIMIT / SILENT / R TO RESTART', 13)
        if headless and tick >= frames:
            if screenshot:
                pyxel.screenshot(screenshot, scale=2)
            if report:
                Path(report).write_text(json.dumps({
                    'label': 'Synthetic input; actual Pyxel headless render; silent',
                    'frames': tick, 'actions': actions, 'events': events,
                    'final_state': asdict(s),
                }, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
            pyxel.quit()

    pyxel.init(400, 302, title='Three Move Dance - Stage 4', fps=30, headless=headless)
    pyxel.run(update, draw)


if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--headless-replay', help='Synthetic actions; K resets; not OS keys')
    parser.add_argument('--actions', default='', help='Synthetic actions within a 12-frame smoke')
    parser.add_argument('--screenshot', help='PNG path, headless only')
    parser.add_argument('--report', help='UTF-8 JSON path, headless only')
    args = parser.parse_args()
    if args.headless_smoke and args.headless_replay is not None:
        parser.error('Choose smoke or replay, not both')
    actions = args.actions if args.headless_replay is None else args.headless_replay
    if any(action not in 'FSBWK' for action in actions):
        parser.error('Supported replay actions: FSBWK')
    headless = args.headless_smoke or args.headless_replay is not None
    if not headless and (args.actions or args.screenshot or args.report):
        parser.error('actions, screenshot and report require a headless mode')
    if args.headless_smoke and len(actions) > 12:
        parser.error('Smoke accepts at most 12 actions; use replay')
    frames = 12 if args.headless_replay is None else max(12, len(actions) + 2)
    main(headless, args.screenshot, actions, frames, args.report)
STEP 05 / 05

体力・記憶・構えを丸ごと一手戻す

書き換えられない State と、別の履歴で Undo を作る。

相手が数える「直前の3手」の Memory とは別に、受け付けたそれぞれの手の前の State を Timeline.past に保存します。X キーでは最後の State 全体を取り出すので、自分と相手の位置・両方の HP・相手の記憶・回復・構え・手数・勝ち負けが一度に戻ります。盤の外への入力や無効な入力、終わったあとの入力は保存しません。R キーは最初の状態と空の Timeline に戻します。元のゲームの A の基本の戦いと Undo をそなえた、小さな完成版です。

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

05_undo.py / 注目する部分
    result = transition(timeline.state, action)
    if not result.accepted:
        return timeline, result
    return Timeline(result.state, timeline.past + (timeline.state,)), result

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_undo.py
macOS / Linux
.venv/bin/python 05_undo.py
画面で見ることZ右右ZZZ で WON にしてから X を押すと、TURN 5 の PLAYING(対戦中)に戻ります。キーを全部離してから Z を押し直すと、同じように WON になります。途中で R を押してから X を押しても、前回の稽古には戻れません。
  • 勝ち・負けの直前に戻れる
  • 位置だけでなく、HP・記憶・構えも戻る
  • 無効な手は保存しない
  • R キーで Undo の履歴も空にする
つまずきやすいところ相手の「直前の3手」の記録を Undo にも使うと、古い体力や構えを元に戻せません。Memory は相手が見る情報、Timeline はプレイヤーが戻すためのすべての状態、と役割が違います。書き換えられるリストを共有して過去まで変わってしまわないよう、frozen とタプルを使います。
少し変えてみよう

テストの test_undo_restores_complete_state_and_redo を読み、SFF の結果と、SFF→undo→F の結果が asdict で完全に同じになることを確かめましょう。位置だけを戻した場合に、どの項目が食い違うかも挙げてみましょう。

05_undo.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(316行)
05_undo.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 contributors
# 
# 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.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
# この教材はNoto字形を同梱せず、ASCIIと図形のみで描画します。

"""三手の剣舞・教材 5/5。単独で実行できる段階別プログラム。"""
from dataclasses import dataclass, replace, asdict

ACTIONS = ('F', 'S', 'B', 'W')

@dataclass(frozen=True)
class Level:
    title: str
    player: int
    enemy: int
    history: tuple

LEVEL = Level('一回斬って、間を置く', 3, 6, ())  # 原作Aだけを収録。

@dataclass(frozen=True)
class Observation:
    distance: int
    own_position: int

@dataclass(frozen=True)
class Memory:
    actions: tuple
    recovering: bool

@dataclass(frozen=True)
class Intent:
    kind: str
    move: int
    ranges: tuple
    counter: str

@dataclass(frozen=True)
class State:
    player: int
    enemy: int
    hp: int
    enemy_hp: int
    memory: Memory
    intent: Intent
    turns: int = 0
    outcome: str = 'playing'

@dataclass(frozen=True)
class Result:
    state: State
    accepted: bool
    reason: str
    dealt: int = 0
    taken: int = 0
    collision: bool = False


def observe(s):
    return Observation(s.enemy - s.player, s.enemy)


def policy(observation, memory):
    # observationは原作との共通窓口。この方策では過去の手と回復だけを使う。
    # 今回の未確定入力を引数に渡さない。
    if memory.recovering:
        return Intent('breath', 0, (), '-')
    recent = memory.actions[-3:]
    counts = {a: recent.count(a) for a in ('F', 'S', 'B')}
    most = 'F'
    maximum = max(counts.values())
    for action in reversed(recent):
        if action in counts and counts[action] == maximum:
            most = action
            break
    if most == 'F':
        return Intent('guard', 0, (1, 2), most)
    if most == 'S':
        return Intent('retreat', 1, (2,), most)
    return Intent('chase', -2, (), most)


def initial():
    memory = Memory(LEVEL.history, False)
    s = State(LEVEL.player, LEVEL.enemy, 3, 5, memory, Intent('', 0, (), ''))
    return replace(s, intent=policy(observe(s), memory))


def enemy_proposal(s):
    # 追い足は自分の旧位置の隣まで、引き突きは右端9まで。
    return max(s.player + 1, min(9, s.enemy + s.intent.move))


def transition(s, action):
    if s.outcome != 'playing':
        return Result(s, False, 'terminal')
    if action not in ACTIONS:
        return Result(s, False, 'invalid')
    old_intent = s.intent  # 1. 入力前から公開済みの構えを固定。
    player = s.player + {'F': 1, 'S': 0, 'B': -1, 'W': 0}[action]
    if not 1 <= player <= 9:
        return Result(s, False, 'edge')
    enemy = enemy_proposal(s)  # 2. 両者の旧位置から候補を作る。
    collision = player >= enemy
    if collision:
        player, enemy = s.player, s.enemy  # 3. 衝突なら双方を元に戻す。
    distance = enemy - player
    combo = bool(s.memory.actions and s.memory.actions[-1] == 'S')
    dealt = (2 if combo else 1) if action == 'S' and distance == 1 else 0
    taken = 1 if distance in old_intent.ranges else 0
    hp = max(0, s.hp - taken)  # 4. 最終距離で同時にダメージ。
    enemy_hp = max(0, s.enemy_hp - dealt)
    if hp == 0 and enemy_hp == 0:
        outcome = 'mutual'
    elif hp == 0:
        outcome = 'lost'
    elif enemy_hp == 0:
        outcome = 'won'
    else:
        outcome = 'playing'
    # 5. 今回の手をここで初めて確定履歴へ。空振りSも残す。
    memory = Memory((s.memory.actions + (action,))[-3:],
                    old_intent.kind in ('guard', 'retreat'))
    new = State(player, enemy, hp, enemy_hp, memory, old_intent, s.turns + 1, outcome)
    new = replace(new, intent=policy(observe(new), memory))
    reason = ('mutual' if outcome == 'mutual' else 'hit' if dealt else
              'hurt' if taken else 'clash' if collision else 'miss' if action == 'S' else 'moved')
    return Result(new, True, reason, dealt, taken, collision)

@dataclass(frozen=True)
class Timeline:
    state: State
    past: tuple = ()


def step(timeline, action):
    if action == 'undo':
        if timeline.past:
            return Timeline(timeline.past[-1], timeline.past[:-1]), None
        return timeline, None
    result = transition(timeline.state, action)
    if not result.accepted:
        return timeline, result
    return Timeline(result.state, timeline.past + (timeline.state,)), result

class Game:
    def __init__(self):
        self.reset()

    @property
    def state(self):
        return self.timeline.state

    def reset(self):
        self.timeline = Timeline(initial())
        self.last = None

    def act(self, action):
        self.timeline, self.last = step(self.timeline, action)

KEY_ACTIONS = {'LEFT': 'B', 'A': 'B', 'UP': 'S', 'Z': 'S', 'RIGHT': 'F', 'D': 'F', 'SPACE': 'W'}

class Controller:
    """Pyxelの押下とゲーム1手を分離。合成入力でも同じ窓口を使う。"""
    def __init__(self):
        self.game = Game()
        self.release_gate = False

    def tick(self, pressed=(), held=()):
        pressed, held = set(pressed), set(held)
        controls = set(KEY_ACTIONS) | {'R', 'X'}
        if self.release_gate:
            if not (held & controls):
                self.release_gate = False
            return
        if 'R' in pressed:
            self.game.reset()
            self.release_gate = True
            return
        if 'X' in pressed:
            self.game.act('undo')
            self.release_gate = True
            return
        chosen = {action for key, action in KEY_ACTIONS.items() if key in pressed}
        held_actions = {action for key, action in KEY_ACTIONS.items() if key in held}
        if len(chosen) == 1 and len(held_actions) == 1:
            self.game.act(next(iter(chosen)))

def main(headless=False, screenshot=None, actions='', frames=12, report=None):
    import json
    from pathlib import Path
    # Pyxel初期化によるcwd変更前に利用者の出力先を確定する。
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    report = str(Path(report).resolve()) if report else None
    import pyxel

    controller = Controller()
    events = []
    tick = 0
    keys = {key: getattr(pyxel, 'KEY_' + key) for key in (*KEY_ACTIONS, 'R', 'X')}
    replay_keys = {'F': 'RIGHT', 'B': 'LEFT', 'W': 'SPACE', 'S': 'Z', 'K': 'R', 'X': 'X'}

    def update():
        nonlocal tick
        tick += 1
        if headless:
            if tick <= len(actions):
                # QA専用の合成押下。OSキーボード操作・人の試遊ではない。
                key = replay_keys[actions[tick - 1]]
                controller.tick((), ())
                controller.tick((key,), (key,))
                events.append({'tick': tick, 'action': actions[tick - 1],
                               'state': asdict(controller.game.state)})
        else:
            controller.tick([key for key, value in keys.items() if pyxel.btnp(value)],
                            [key for key, value in keys.items() if pyxel.btn(value)])

    def fighter(position, y, color, direction):
        x = 32 + (position - 1) * 42
        pyxel.circ(x, y - 13, 5, color)
        pyxel.line(x, y - 7, x, y + 8, color)
        pyxel.line(x, y + 8, x - 6, y + 16, color)
        pyxel.line(x, y + 8, x + 6, y + 16, color)
        pyxel.line(x, y - 3, x + direction * 12, y - 9, color)
        pyxel.line(x + direction * 12, y - 9, x + direction * 21, y - 20, 7)

    def draw():
        s = controller.game.state
        pyxel.cls(1)
        pyxel.text(10, 8, 'THREE MOVE DANCE / STEP 5', 10)
        pyxel.text(10, 21, 'ORIGINAL A RULES / FULL UNDO', 13)
        pyxel.text(10, 35, f'TURN {min(s.turns,9999)}   DISTANCE {s.enemy - s.player}', 7)
        pyxel.text(10, 48, f'HP {s.hp}/3  ENEMY HP {s.enemy_hp}/5  {s.outcome.upper()}', 11 if s.outcome == 'won' else 7)
        history = ('-',) * (3 - len(s.memory.actions)) + s.memory.actions
        pyxel.text(10, 66, 'PAST 3 COMMITTED ACTIONS: OLD -> NEW', 6)
        for i, action in enumerate(history):
            x = 10 + i * 57
            pyxel.rectb(x, 77, 49, 18, 6)
            pyxel.text(x + 22, 83, action, 7)
        pyxel.text(196, 81, 'F=FORWARD S=SLASH', 13)
        pyxel.text(196, 90, 'B=BACK    W=WAIT', 13)
        pyxel.text(10, 109, f'NEXT: {s.intent.kind.upper()}  READ: {s.intent.counter}', 10)
        for position in range(1, 10):
            x = 32 + (position - 1) * 42
            pyxel.rectb(x - 19, 133, 38, 57, 5)
            pyxel.text(x - 2, 194, str(position), 13)
            proposal = enemy_proposal(s)
            if position == proposal:
                pyxel.text(x - 8, 123, 'NEXT', 10)
            if proposal - position in s.intent.ranges:
                for offset in range(-14, 15, 7):
                    pyxel.line(x + offset, 182, x + offset + 4, 174, 8)
        fighter(s.player, 166, 6, 1)
        fighter(s.enemy, 161, 8, -1)
        pyxel.text(10, 210, 'NEXT: CANDIDATE. COLLISION CANCELS BOTH MOVES.', 13)
        pyxel.text(10, 223, 'RED: ATTACK DISTANCES / PREVIOUS S: POWER 2', 13)
        pyxel.text(10, 236, 'X RESTORES HP, MEMORY, INTENT AND TURN.', 6)
        pyxel.text(10, 252, 'LEFT/A: BACK  UP/Z: SLASH  RIGHT/D: FORWARD', 7)
        pyxel.text(10, 266, 'SPACE: WAIT  X: UNDO  R: RESET  ESC: QUIT', 7)
        pyxel.text(10, 284, 'NO TIME LIMIT / SILENT / R TO RESTART', 13)
        if headless and tick >= frames:
            if screenshot:
                pyxel.screenshot(screenshot, scale=2)
            if report:
                Path(report).write_text(json.dumps({
                    'label': 'Synthetic input; actual Pyxel headless render; silent',
                    'frames': tick, 'actions': actions, 'events': events,
                    'final_state': asdict(s),
                }, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
            pyxel.quit()

    pyxel.init(400, 302, title='Three Move Dance - Stage 5', fps=30, headless=headless)
    pyxel.run(update, draw)


if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--headless-replay', help='Synthetic actions; K resets; not OS keys')
    parser.add_argument('--actions', default='', help='Synthetic actions within a 12-frame smoke')
    parser.add_argument('--screenshot', help='PNG path, headless only')
    parser.add_argument('--report', help='UTF-8 JSON path, headless only')
    args = parser.parse_args()
    if args.headless_smoke and args.headless_replay is not None:
        parser.error('Choose smoke or replay, not both')
    actions = args.actions if args.headless_replay is None else args.headless_replay
    if any(action not in 'FSBWKX' for action in actions):
        parser.error('Supported replay actions: FSBWKX')
    headless = args.headless_smoke or args.headless_replay is not None
    if not headless and (args.actions or args.screenshot or args.report):
        parser.error('actions, screenshot and report require a headless mode')
    if args.headless_smoke and len(actions) > 12:
        parser.error('Smoke accepts at most 12 actions; use replay')
    frames = 12 if args.headless_replay is None else max(12, len(actions) + 2)
    main(headless, args.screenshot, actions, frames, args.report)
AFTER STEP 05

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

元のゲームには3つの場面があります。自分の HP は3、相手は5です。相手は、直前に確定した3手の中でいちばん多い F・S・B への対策をとります。同じ数なら新しいほうを選び、W は枠だけ使います。F への対策は「距離1か2に置き斬り」、S への対策は「1歩引いて距離2へ攻撃」、B への対策は「2歩追いかけて攻撃はしない」です。攻撃のあとは、空振りでも1手息つぎします。動いてぶつかったら両者とも元に戻し、最後の距離で同時に攻撃します。斬りは距離1で当たり、直前も S なら2点です。空振りも S として記録されます。両方の HP が0なら相打ちです。

ここまでに作ったのは、元のゲームの仕組みだけを取り出して作り直した「簡略版」です。元のゲームのソースは手を加えずに、別のZIP(元の完成版ソース)に入れてあります。

比べる点5ステップの教材元の完成版
場面04と05は、元のゲームのA「一回斬って、間を置く」だけ。最初の位置3/6・HP 3/5・記録は空・数える枠は3。A・B・Cの3場面。Bは4/5とFSSから、Cは2/6とBFBから始まる。
少しずつ増やす段階01は移動だけ、02は止まった練習台に1点の斬り、03は記録・続けて斬る・構えの表示だけ、04でAの戦い、05でUndo。最初から相手が動いて攻撃し、記憶・続けて斬る・Undoが有効。
表示と入力英字(ASCII)と図形。すぐに練習を始める。違う行動のキーの同時押しは受け付けず、RやXのあとはキーが離されるのを待つ。日本語の文字、表紙、Pで一時停止、Mで表紙、1〜3で選択、F12で記録。
実験と確認数える枠は3で固定。単体テスト、元のゲームのAとの全状態の比較、自動入力による実際のPyxelでの画面なし描画(PNG)。音はなし。Bキーで枠3と枠1を比べられる。答えを探すプログラム、実験、動画や記録の補助ツールがついている。元のゲームも音なし。

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

  1. model.py の policy を読み、window=1 と3で、直前の読みが変わる条件を比べましょう。
  2. model.py の transition と enemy_proposal で、「元の位置→候補→最後の距離」の順番を確かめましょう。
  3. model.py の LEVELS で、B と C の最初の記録を比べましょう。教材の A での比較の結果を、そのまま B や C に当てはめないようにします。
  4. model.py の step と、app.py の入力の仕組みを読み、ゲームの Undo と、キーが離されるのを待つ処理を分けて考えましょう。
  5. solver.py の solve と state_key で、手数を「同じ状態かどうか」の判定から外して、限りのある探索をしている理由を調べましょう。
自分のゲームに変えるなら

最初は、数字かルールを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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。

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