戦略・対戦LESSON 24公開手札のターン制カードパズル原作058

交換する魔法の作り方

使った魔法のカードは、次は相手の手札に入ります。
魔法の効果、防壁、カードの持ち主の移動、敵の選択を、決まった順番で処理する小さなカード対戦を作ります。

完成版の遊び方使った魔法のカードは相手の手札に移ります。敵がそのあと何をするかまで読んで、自分の1手と敵の1手(1往復)がどうなるかを考えながら、使うカードを選びます。

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

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

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

完成版の主な操作:左右キーでカードを選ぶ、Z・Spaceで使う、Xで1往復戻す、Rでやり直し、Pで一時停止、1〜3でカードの配り方(場面)を選ぶ

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

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

うまく表示されないとき

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

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

効果・カードの持ち主・勝ち負けを、決まった順番で更新する。

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

1. 書き換えない状態、HPの上限と下限、終わったあとの入力を無視する
2. 使う人と相手、防壁で減るダメージ、回復の上限、攻撃以外でも切れる防壁
3. 1枚ずつちがうカードID、持ち主の移動、途中で1枚対3枚になる状態
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
Python 3.11以上、ターミナルと仮想環境の基本 / if・for・関数・tuple(タプル)の基本 / dataclass・replace・純粋関数(同じ入力なら必ず同じ結果を返し、ほかを書き換えない関数)・履歴は本文で説明します
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
1〜4:その番号のカードを使う / Tab:使う人を切り替える(02・03) / 左右:予告するカードを選ぶ(05) / Space・Enter:選んだカードを使う(05) / X:1往復戻す(05) / R:最初に戻す / Esc:終了
難易度の目安
初級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_fire.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

火球だけでHPを減らす

書き換えない状態、HPの上限と下限、終わったあとの入力を無視する。

最初は、火球のカード1枚だけで敵をねらう「的当て」です。fire_step は今の State(ゲームの状態)を受け取り、敵のHPから3を引いた新しい State を返します。max(0, ...) を使って、HPがマイナスにならないようにします。敵のHPが0になったら won(勝ち)にします。frozen=True の dataclass と replace を使うので、状態はあとから書き換えられません。描画は状態を読むだけです。この段階は、まだ元のゲームの対戦ではありません。カードを相手に渡す処理、敵の行動、ほかの3枚のカードは、まだ作っていません。

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

01_fire.py / 注目する部分
    enemy_hp = max(0, state.ehp - 3)
    result = replace(state, ehp=enemy_hp, outcome='won' if enemy_hp == 0 else 'running')

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_fire.py
macOS / Linux
.venv/bin/python 01_fire.py
画面で見ること1を押して離すと、敵のHPが6→3になります。もう一度1を押すと0になり、WON(勝ち)と表示されます。3回目は何も変わりません。自分のHPは7のまま、火球も自分の手札のままです。
  • HPが0より下にならない
  • 勝ったあとに押しても、回数が増えない
  • 元のゲームとちがい、カードの受け渡しと敵の行動がない練習だと分かる
つまずきやすいところdraw のたびにHPを減らすと、1秒に30回の描画のたびにHPが減り、勝手にゲームが進んでしまいます。状態を作り直すのは、キー入力が確定したときの処理だけにします。
少し変えてみよう

練習用にコピーしたファイルで、敵の最初のHPを5や7に変えてみましょう。勝つまでに火球が何回必要か、先に予想してから確かめます。

01_fire.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(285行)
01_fire.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.
# 教材にNoto由来の字形は含みません。標準ASCIIと図形だけを描きます。

"""交換する魔法・教材 1/5。Python 3.11+、この1ファイルだけで起動。"""
from dataclasses import dataclass, replace, asdict

FIRE, SPARK, WALL, HEAL = range(4)
CARD_NAMES = ('FIRE', 'SPARK', 'WALL', 'HEAL')
CARD_RULES = ('3 damage', '1 damage', 'Shield 2: next opposing action', 'Heal 2, up to initial HP')

@dataclass(frozen=True)
class Level:
    seed: str
    title: str
    php: int
    ehp: int
    player: tuple
    enemy: tuple
    ai: str
    rounds: int = 12

LEVEL = Level('A-01-g1', '大技を持たせない', 7, 6, (FIRE, SPARK), (WALL, HEAL), 'A')

@dataclass(frozen=True)
class State:
    php: int
    ehp: int
    player: tuple
    enemy: tuple
    pbar: int = 0
    ebar: int = 0
    round: int = 0
    outcome: str = 'running'

@dataclass(frozen=True)
class Event:
    actor: str
    card: int
    damage: int
    healing: int
    expired: int
    transfer: bool

STAGE = 1
STAGE_TITLE = 'FIRE EFFECT'
SCOPE = 'LAB: 1 spell, no exchange, no enemy turn.'
LAB_NOTES = ('Only FIRE exists.', 'A tiny target practice.', '1 twice defeats the target.')
CONTROLS = ('1', 'R')
HELP_ONE = '1: FIRE   R: reset'
HELP_TWO = 'Step 1 stops on target defeat. No card exchange here.'

def initial():
    return State(7, 6, (FIRE,), ())

def fire_step(state, card):
    if state.outcome != 'running' or type(card) is not int or card != FIRE:
        return state, ()
    enemy_hp = max(0, state.ehp - 3)
    result = replace(state, ehp=enemy_hp, outcome='won' if enemy_hp == 0 else 'running')
    return result, (Event('player', FIRE, 3, 0, 0, False),)

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

    def reset(self):
        self.state = initial()
        self.actor = 'player'
        self.events = ()
        self.reason = 'ready'
        self.moves = 0

    def act(self, card):
        self.state, events = fire_step(self.state, card)
        self.events = events
        self.reason = 'cast' if events else 'unavailable'
        self.moves += bool(events)

class Controller:
    """GUIと合成キー試験が共用する入力処理。押して離すと一判断。"""
    def __init__(self):
        self.game = Game()
        self.selected = 0
        self.gate = False

    def tick(self, pressed=(), held=()):
        pressed = set(pressed) & set(CONTROLS)
        held = set(held) & set(CONTROLS)
        if self.gate:
            if not held:
                self.gate = False
            return
        if len(pressed) != 1 or len(held) != 1 or pressed != held:
            return
        key = next(iter(pressed))
        self.gate = True
        if key == 'R':
            self.game.reset()
            self.selected = 0
        elif key == 'TAB':
            self.game.actor = 'enemy' if self.game.actor == 'player' else 'player'
            self.game.reason = 'actor_changed'
        elif key == 'LEFT':
            self.selected = (self.selected - 1) % len(self.game.state.player)
        elif key == 'RIGHT':
            self.selected = (self.selected + 1) % len(self.game.state.player)
        elif key in ('SPACE', 'RETURN'):
            self.game.act(self.game.state.player[self.selected])
        elif key == 'X':
            self.game.act('undo')
        elif key in '1234':
            self.game.act(int(key) - 1)
        self.selected = min(self.selected, len(self.game.state.player) - 1)


def names(cards):
    return ', '.join(CARD_NAMES[c] for c in cards) or '(none)'


def main(headless=False, screenshot=None, actions='', report=None):
    import json
    from pathlib import Path
    import pyxel
    # 相対出力は呼出元CWD基準。Pyxel初期化より先に確定する。
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    report = str(Path(report).resolve()) if report else None
    controller = Controller()
    key_codes = {key: getattr(pyxel, 'KEY_' + key) for key in CONTROLS}
    replay = {key: key for key in '1234RX'}
    replay.update(T='TAB', L='LEFT', G='RIGHT', E='RETURN', S='SPACE')
    frame = 0
    records = []
    frame_limit = max(12, 2 * len(actions) + 2)

    def update():
        nonlocal frame
        frame += 1
        if headless:
            index = (frame - 1) // 2
            if frame % 2 == 1 and index < len(actions):
                key = replay[actions[index]]
                controller.tick((key,), (key,))
                records.append({'input': actions[index], 'actor': controller.game.actor,
                                'state': asdict(controller.game.state),
                                'events': [asdict(e) for e in controller.game.events],
                                'reason': controller.game.reason})
            else:
                controller.tick()
        else:
            controller.tick([k for k, code in key_codes.items() if pyxel.btnp(code)],
                            [k for k, code in key_codes.items() if pyxel.btn(code)])

    def text(x, y, value, color=7):
        pyxel.text(x, y, str(value), color)

    def draw():
        game = controller.game
        state = game.state
        pyxel.cls(1)
        text(12, 10, 'EXCHANGE SPELLS / STEP ' + str(STAGE) + ' / ' + STAGE_TITLE, 10)
        text(12, 26, SCOPE, 13)
        pyxel.rect(8, 40, 624, 41, 2)
        text(16, 48, f'PLAYER HP {state.php}/{LEVEL.php}  SHIELD {state.pbar}', 11)
        text(16, 64, 'HAND: ' + names(state.player), 7)
        text(330, 48, f'ENEMY HP {state.ehp}/{LEVEL.ehp}  SHIELD {state.ebar}', 7)
        text(330, 64, 'HAND: ' + names(state.enemy), 7)
        text(12, 92, 'KEY / CARD / EFFECT / OWNER', 6)
        for card in range(4):
            y = 108 + card * 43
            owner = 'PLAYER' if card in state.player else 'ENEMY' if card in state.enemy else 'NOT IN STEP 1'
            color = (7, 10, 12, 11)[card] if STAGE > 1 or card == FIRE else 5
            active = STAGE == 5 and state.outcome == 'running' and card == state.player[controller.selected]
            pyxel.rect(8, y - 4, 380, 38, 2)
            pyxel.rectb(8, y - 4, 380, 38, 10 if active else 5)
            text(16, y, f'{card + 1}  {CARD_NAMES[card]:5}   {CARD_RULES[card]}', color)
            text(28, y + 15, 'OWNER: ' + owner + ('  < PREVIEW' if active else ''), 10 if active else 6)
        text(12, 292, 'ACTOR: ' + game.actor.upper() + '    STATUS: ' + state.outcome.upper(), 11)
        counter = f'ROUND: {state.round}/12' if STAGE >= 4 else f'CASTS: {game.moves}'
        text(12, 308, counter + '    LAST INPUT: ' + game.reason.upper(), 7)
        text(12, 328, 'LAST EFFECTS (damage / heal / target shield expired):', 6)
        for i, event in enumerate(game.events):
            text(12, 343 + 15 * i,
                 f'{event.actor.upper()} {CARD_NAMES[event.card]}: {event.damage} / {event.healing} / {event.expired}' + ('  PASS' if event.transfer else '  KEEP'), 7)
        pyxel.rectb(397, 91, 235, 287, 5)
        text(405, 100, 'RESOLUTION ORDER', 10)
        lines = ('1. Spell effect', '2. Target shield expires',
                 '3. Played card changes owner' if STAGE >= 3 else '3. Owner unchanged (lesson only)',
                 '4. Check enemy HP = 0' if STAGE >= 4 else 'Manual action laboratory',
                 '5. Enemy chooses AFTER transfer' if STAGE >= 4 else 'No automatic enemy yet',
                 '6. Enemy effect, expiry, transfer' if STAGE >= 4 else 'R restores the lesson setup',
                 '7. Loss, then 12-round draw' if STAGE >= 4 else 'Read the stage notes below')
        if STAGE == 1:
            lines = ('1. FIRE deals 3 damage', '2. Clamp enemy HP to zero', '3. Stop when enemy HP is zero',
                     'Only FIRE exists in this lab.', 'No enemy action or transfer.', 'R restores enemy HP to 6.')
        for i, line in enumerate(lines):
            text(405, 117 + 12 * i, line, 6)
        if STAGE >= 4:
            text(405, 211, 'A: HEAL if missing HP >= 2;', 7)
            text(405, 223, 'else FIRE > SPARK > WALL > HEAL', 7)
        if STAGE == 5 and state.outcome == 'running':
            card = state.player[controller.selected]
            predicted, events = forecast(state, card)
            text(405, 246, 'EXACT ONE-ROUND FORECAST', 10)
            text(405, 259, 'PLAY: ' + CARD_NAMES[card], 7)
            text(405, 272, 'YOU: damage ' + str(events[0].damage) + ' / heal ' + str(events[0].healing), 7)
            text(405, 285, 'ENEMY: ' + CARD_NAMES[events[1].card] if len(events) > 1 else 'ENEMY: no counterattack', 7)
            text(405, 298, f'AFTER HP P={predicted.php} E={predicted.ehp}', 11)
            text(405, 311, f'SHIELD P={predicted.pbar} E={predicted.ebar}', 12)
            text(405, 324, 'P: ' + names(predicted.player), 7)
            text(405, 337, 'E: ' + names(predicted.enemy), 7)
            text(405, 350, 'RESULT: ' + predicted.outcome.upper(), 10)
        elif STAGE >= 4:
            text(405, 259, 'A-01-g1 / complete round rules', 6)
            text(405, 276, 'Try: SPARK, FIRE, SPARK, FIRE', 10)
            text(405, 293, 'Keys: 2, 1, 2, 1', 7)
            if state.outcome != 'running':
                text(405, 319, state.outcome.upper() + ': R retries' + ('; X undoes' if STAGE == 5 else ''), 11)
        else:
            text(405, 229, 'DELIBERATE SIMPLIFICATION', 13)
            for i, line in enumerate(LAB_NOTES):
                text(405, 246 + i * 15, line, 7)
        text(12, 395, HELP_ONE, 7)
        text(12, 411, HELP_TWO, 6)
        text(12, 433, 'Press and release each key.  No timer.  ESC: quit.  Silent.', 13)
        if headless and frame >= frame_limit:
            if screenshot:
                pyxel.screenshot(screenshot, scale=1)
            if report:
                Path(report).write_text(json.dumps({
                    'label': 'Actual Pyxel headless draw; synthetic Controller keys; silent',
                    'stage': STAGE, 'frames': frame, 'actions': actions,
                    'state': asdict(state), 'actor': game.actor, 'records': records,
                    'history_length': len(game.timeline.history) if STAGE == 5 else 0,
                }, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
            pyxel.quit()

    pyxel.init(640, 452, title='Exchange Spells - Step ' + str(STAGE), 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='1..4=card, T=actor, L/G=select, S/E=cast, X=undo, R=reset')
    parser.add_argument('--screenshot', help='PNG output; headless only')
    parser.add_argument('--report', help='UTF-8 JSON output; headless only')
    args = parser.parse_args()
    if args.headless_smoke and args.headless_replay is not None:
        parser.error('Choose smoke or replay')
    headless = args.headless_smoke or args.headless_replay is not None
    if not headless and (args.screenshot or args.report):
        parser.error('Output options require headless mode')
    actions = args.headless_replay or ''
    allowed = set('1R' if STAGE == 1 else '1234R')
    if STAGE in (2, 3):
        allowed.add('T')
    if STAGE == 5:
        allowed.update('LGSEX')
    if any(key not in allowed for key in actions):
        parser.error('Unsupported key in this stage')
    main(headless, args.screenshot, actions, args.report)
STEP 02 / 05

四効果と防壁の期限を分ける

使う人と相手、防壁で減るダメージ、回復の上限、攻撃以外でも切れる防壁。

効果を観察しやすいように、自分のHPを5(最大7)、自分のカードを防壁と手当、敵のカードを火球と火花に変えます。Tab で、カードを使う人(行動者)を手で切り替えて、その人が持っているカードを使います。ここでの「対象」は、カードを使う人の相手のことです。攻撃のダメージ(傷)は、対象の防壁の分だけ減らしてから与え、そのあとで対象の防壁を0にします。攻撃だけでなく、手当や防壁を使ったときも、対象の防壁は切れます。防壁を使うと、自分の防壁を2に「設定」します(足し算ではありません)。手当は2回復しますが、最大HPは超えません。この段階では、カードの持ち主は変わりません。HPが0でも行動でき、勝ち負けや往復の回数制限もない練習用の台です。

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

02_effects.py / 注目する部分
    hurt = damage(card, other_barrier)
    healed = min(2, (LEVEL.php if player else LEVEL.ehp) - hp) if card == HEAL else 0
    hp += healed
    other_hp = max(0, other_hp - hurt)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_effects.py
macOS / Linux
.venv/bin/python 02_effects.py
画面で見ること3→Tab→1と押すと、自分が防壁を使い、敵が火球を使います。自分のHPは5→4、防壁は2→0になります。R→4→4と押すと、1回目は2回復、2回目は回復0になり、HPは7を超えません。
  • 防壁で減らしたダメージを先に計算し、そのあとで対象の防壁を消す
  • 防壁は足し算ではなく、2に設定する
  • 回復が0になる使い方も、正しい行動として受け付ける
  • この段階では、カードの持ち主が変わらない
つまずきやすいところ攻撃の前に対象の防壁を消すと、今回のダメージを防げなくなります。逆に、攻撃したときだけ消すと、「攻撃以外でも防壁は切れる」という元のゲームのルールと合わなくなります。
少し変えてみよう

R→3→Tab→2と押して、火花のダメージが0になることを確かめましょう。続けて1を押すと3ダメージになります。その理由を説明してみてください。

02_effects.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(315行)
02_effects.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.
# 教材にNoto由来の字形は含みません。標準ASCIIと図形だけを描きます。

"""交換する魔法・教材 2/5。Python 3.11+、この1ファイルだけで起動。"""
from dataclasses import dataclass, replace, asdict

FIRE, SPARK, WALL, HEAL = range(4)
CARD_NAMES = ('FIRE', 'SPARK', 'WALL', 'HEAL')
CARD_RULES = ('3 damage', '1 damage', 'Shield 2: next opposing action', 'Heal 2, up to initial HP')

@dataclass(frozen=True)
class Level:
    seed: str
    title: str
    php: int
    ehp: int
    player: tuple
    enemy: tuple
    ai: str
    rounds: int = 12

LEVEL = Level('A-01-g1', '大技を持たせない', 7, 6, (FIRE, SPARK), (WALL, HEAL), 'A')

@dataclass(frozen=True)
class State:
    php: int
    ehp: int
    player: tuple
    enemy: tuple
    pbar: int = 0
    ebar: int = 0
    round: int = 0
    outcome: str = 'running'

@dataclass(frozen=True)
class Event:
    actor: str
    card: int
    damage: int
    healing: int
    expired: int
    transfer: bool

STAGE = 2
STAGE_TITLE = 'FOUR EFFECTS'
SCOPE = 'LAB: fixed owners, manual actor, no victory or round limit.'
LAB_NOTES = ('Player: WALL, HEAL; HP 5/7.', 'Enemy: FIRE, SPARK; HP 6/6.', 'TAB changes the manual actor.', 'Ownership does NOT move yet.', 'HP 0 is allowed to act here.')
CONTROLS = ('1', '2', '3', '4', 'R', 'TAB')
HELP_ONE = '1..4: owned spell   TAB: change actor   R: reset'
HELP_TWO = 'Try 3, TAB, 1: WALL blocks 2, then expires.'

def initial():
    # 効果の観察用にHPと所有者を変更。原作Aの初期状態ではない。
    return State(5, 6, (WALL, HEAL), (FIRE, SPARK))

def damage(card, barrier):
    return max(0, {FIRE: 3, SPARK: 1}.get(card, 0) - barrier)


def cast(state, actor, card):
    """効果 → 対象の防壁期限 → 使用札の行先。勝敗と敵選択はまだ行わない。"""
    if actor not in ('player', 'enemy'):
        raise ValueError('Unknown actor')
    player = actor == 'player'
    own = state.player if player else state.enemy
    other = state.enemy if player else state.player
    if type(card) is not int or card not in own:
        raise ValueError('Card not owned')
    hp = state.php if player else state.ehp
    other_hp = state.ehp if player else state.php
    barrier = state.pbar if player else state.ebar
    other_barrier = state.ebar if player else state.pbar
    hurt = damage(card, other_barrier)
    healed = min(2, (LEVEL.php if player else LEVEL.ehp) - hp) if card == HEAL else 0
    hp += healed
    other_hp = max(0, other_hp - hurt)
    if card == WALL:
        barrier = 2
    # この段階では所有者を固定する。03でこの2行を追加する。
    if player:
        result = replace(state, php=hp, ehp=other_hp, player=own, enemy=other,
                         pbar=barrier, ebar=0)
    else:
        result = replace(state, ehp=hp, php=other_hp, enemy=own, player=other,
                         ebar=barrier, pbar=0)
    return result, Event(actor, card, hurt, healed, other_barrier, False)

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

    def reset(self):
        self.state = initial()
        self.actor = 'player'
        self.events = ()
        self.reason = 'ready'
        self.moves = 0

    def act(self, card):
        own = self.state.player if self.actor == 'player' else self.state.enemy
        if type(card) is not int or card not in own:
            self.reason = 'unavailable'
            return
        self.state, event = cast(self.state, self.actor, card)
        self.events = (event,)
        self.reason = 'cast'
        self.moves += 1

class Controller:
    """GUIと合成キー試験が共用する入力処理。押して離すと一判断。"""
    def __init__(self):
        self.game = Game()
        self.selected = 0
        self.gate = False

    def tick(self, pressed=(), held=()):
        pressed = set(pressed) & set(CONTROLS)
        held = set(held) & set(CONTROLS)
        if self.gate:
            if not held:
                self.gate = False
            return
        if len(pressed) != 1 or len(held) != 1 or pressed != held:
            return
        key = next(iter(pressed))
        self.gate = True
        if key == 'R':
            self.game.reset()
            self.selected = 0
        elif key == 'TAB':
            self.game.actor = 'enemy' if self.game.actor == 'player' else 'player'
            self.game.reason = 'actor_changed'
        elif key == 'LEFT':
            self.selected = (self.selected - 1) % len(self.game.state.player)
        elif key == 'RIGHT':
            self.selected = (self.selected + 1) % len(self.game.state.player)
        elif key in ('SPACE', 'RETURN'):
            self.game.act(self.game.state.player[self.selected])
        elif key == 'X':
            self.game.act('undo')
        elif key in '1234':
            self.game.act(int(key) - 1)
        self.selected = min(self.selected, len(self.game.state.player) - 1)


def names(cards):
    return ', '.join(CARD_NAMES[c] for c in cards) or '(none)'


def main(headless=False, screenshot=None, actions='', report=None):
    import json
    from pathlib import Path
    import pyxel
    # 相対出力は呼出元CWD基準。Pyxel初期化より先に確定する。
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    report = str(Path(report).resolve()) if report else None
    controller = Controller()
    key_codes = {key: getattr(pyxel, 'KEY_' + key) for key in CONTROLS}
    replay = {key: key for key in '1234RX'}
    replay.update(T='TAB', L='LEFT', G='RIGHT', E='RETURN', S='SPACE')
    frame = 0
    records = []
    frame_limit = max(12, 2 * len(actions) + 2)

    def update():
        nonlocal frame
        frame += 1
        if headless:
            index = (frame - 1) // 2
            if frame % 2 == 1 and index < len(actions):
                key = replay[actions[index]]
                controller.tick((key,), (key,))
                records.append({'input': actions[index], 'actor': controller.game.actor,
                                'state': asdict(controller.game.state),
                                'events': [asdict(e) for e in controller.game.events],
                                'reason': controller.game.reason})
            else:
                controller.tick()
        else:
            controller.tick([k for k, code in key_codes.items() if pyxel.btnp(code)],
                            [k for k, code in key_codes.items() if pyxel.btn(code)])

    def text(x, y, value, color=7):
        pyxel.text(x, y, str(value), color)

    def draw():
        game = controller.game
        state = game.state
        pyxel.cls(1)
        text(12, 10, 'EXCHANGE SPELLS / STEP ' + str(STAGE) + ' / ' + STAGE_TITLE, 10)
        text(12, 26, SCOPE, 13)
        pyxel.rect(8, 40, 624, 41, 2)
        text(16, 48, f'PLAYER HP {state.php}/{LEVEL.php}  SHIELD {state.pbar}', 11)
        text(16, 64, 'HAND: ' + names(state.player), 7)
        text(330, 48, f'ENEMY HP {state.ehp}/{LEVEL.ehp}  SHIELD {state.ebar}', 7)
        text(330, 64, 'HAND: ' + names(state.enemy), 7)
        text(12, 92, 'KEY / CARD / EFFECT / OWNER', 6)
        for card in range(4):
            y = 108 + card * 43
            owner = 'PLAYER' if card in state.player else 'ENEMY' if card in state.enemy else 'NOT IN STEP 1'
            color = (7, 10, 12, 11)[card] if STAGE > 1 or card == FIRE else 5
            active = STAGE == 5 and state.outcome == 'running' and card == state.player[controller.selected]
            pyxel.rect(8, y - 4, 380, 38, 2)
            pyxel.rectb(8, y - 4, 380, 38, 10 if active else 5)
            text(16, y, f'{card + 1}  {CARD_NAMES[card]:5}   {CARD_RULES[card]}', color)
            text(28, y + 15, 'OWNER: ' + owner + ('  < PREVIEW' if active else ''), 10 if active else 6)
        text(12, 292, 'ACTOR: ' + game.actor.upper() + '    STATUS: ' + state.outcome.upper(), 11)
        counter = f'ROUND: {state.round}/12' if STAGE >= 4 else f'CASTS: {game.moves}'
        text(12, 308, counter + '    LAST INPUT: ' + game.reason.upper(), 7)
        text(12, 328, 'LAST EFFECTS (damage / heal / target shield expired):', 6)
        for i, event in enumerate(game.events):
            text(12, 343 + 15 * i,
                 f'{event.actor.upper()} {CARD_NAMES[event.card]}: {event.damage} / {event.healing} / {event.expired}' + ('  PASS' if event.transfer else '  KEEP'), 7)
        pyxel.rectb(397, 91, 235, 287, 5)
        text(405, 100, 'RESOLUTION ORDER', 10)
        lines = ('1. Spell effect', '2. Target shield expires',
                 '3. Played card changes owner' if STAGE >= 3 else '3. Owner unchanged (lesson only)',
                 '4. Check enemy HP = 0' if STAGE >= 4 else 'Manual action laboratory',
                 '5. Enemy chooses AFTER transfer' if STAGE >= 4 else 'No automatic enemy yet',
                 '6. Enemy effect, expiry, transfer' if STAGE >= 4 else 'R restores the lesson setup',
                 '7. Loss, then 12-round draw' if STAGE >= 4 else 'Read the stage notes below')
        if STAGE == 1:
            lines = ('1. FIRE deals 3 damage', '2. Clamp enemy HP to zero', '3. Stop when enemy HP is zero',
                     'Only FIRE exists in this lab.', 'No enemy action or transfer.', 'R restores enemy HP to 6.')
        for i, line in enumerate(lines):
            text(405, 117 + 12 * i, line, 6)
        if STAGE >= 4:
            text(405, 211, 'A: HEAL if missing HP >= 2;', 7)
            text(405, 223, 'else FIRE > SPARK > WALL > HEAL', 7)
        if STAGE == 5 and state.outcome == 'running':
            card = state.player[controller.selected]
            predicted, events = forecast(state, card)
            text(405, 246, 'EXACT ONE-ROUND FORECAST', 10)
            text(405, 259, 'PLAY: ' + CARD_NAMES[card], 7)
            text(405, 272, 'YOU: damage ' + str(events[0].damage) + ' / heal ' + str(events[0].healing), 7)
            text(405, 285, 'ENEMY: ' + CARD_NAMES[events[1].card] if len(events) > 1 else 'ENEMY: no counterattack', 7)
            text(405, 298, f'AFTER HP P={predicted.php} E={predicted.ehp}', 11)
            text(405, 311, f'SHIELD P={predicted.pbar} E={predicted.ebar}', 12)
            text(405, 324, 'P: ' + names(predicted.player), 7)
            text(405, 337, 'E: ' + names(predicted.enemy), 7)
            text(405, 350, 'RESULT: ' + predicted.outcome.upper(), 10)
        elif STAGE >= 4:
            text(405, 259, 'A-01-g1 / complete round rules', 6)
            text(405, 276, 'Try: SPARK, FIRE, SPARK, FIRE', 10)
            text(405, 293, 'Keys: 2, 1, 2, 1', 7)
            if state.outcome != 'running':
                text(405, 319, state.outcome.upper() + ': R retries' + ('; X undoes' if STAGE == 5 else ''), 11)
        else:
            text(405, 229, 'DELIBERATE SIMPLIFICATION', 13)
            for i, line in enumerate(LAB_NOTES):
                text(405, 246 + i * 15, line, 7)
        text(12, 395, HELP_ONE, 7)
        text(12, 411, HELP_TWO, 6)
        text(12, 433, 'Press and release each key.  No timer.  ESC: quit.  Silent.', 13)
        if headless and frame >= frame_limit:
            if screenshot:
                pyxel.screenshot(screenshot, scale=1)
            if report:
                Path(report).write_text(json.dumps({
                    'label': 'Actual Pyxel headless draw; synthetic Controller keys; silent',
                    'stage': STAGE, 'frames': frame, 'actions': actions,
                    'state': asdict(state), 'actor': game.actor, 'records': records,
                    'history_length': len(game.timeline.history) if STAGE == 5 else 0,
                }, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
            pyxel.quit()

    pyxel.init(640, 452, title='Exchange Spells - Step ' + str(STAGE), 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='1..4=card, T=actor, L/G=select, S/E=cast, X=undo, R=reset')
    parser.add_argument('--screenshot', help='PNG output; headless only')
    parser.add_argument('--report', help='UTF-8 JSON output; headless only')
    args = parser.parse_args()
    if args.headless_smoke and args.headless_replay is not None:
        parser.error('Choose smoke or replay')
    headless = args.headless_smoke or args.headless_replay is not None
    if not headless and (args.screenshot or args.report):
        parser.error('Output options require headless mode')
    actions = args.headless_replay or ''
    allowed = set('1R' if STAGE == 1 else '1234R')
    if STAGE in (2, 3):
        allowed.add('T')
    if STAGE == 5:
        allowed.update('LGSEX')
    if any(key not in allowed for key in actions):
        parser.error('Unsupported key in this stage')
    main(headless, args.screenshot, actions, args.report)
STEP 03 / 05

使った一枚を相手へ渡す

1枚ずつちがうカードID、持ち主の移動、途中で1枚対3枚になる状態。

HPとカードの配り方を、元のゲームの場面Aの最初の状態に戻します。効果を処理したあと、使ったカードを自分の手札(tuple)から外して、相手の手札に加えます。カードごとにちがう番号(固有ID)をそのまま移すので、4枚のカードが増えたり消えたりすることはありません。相手は、受け取ったカードをすぐに使えます。回復が0だったときも、カードは相手に渡ります。まだ手で動かす練習なので、Tab で使う人を切り替えます。勝ち負けと、敵が自動で動く処理は04で作ります。

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

03_transfer.py / 注目する部分
    own = tuple(c for c in own if c != card)
    other = tuple(sorted((*other, card)))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_transfer.py
macOS / Linux
.venv/bin/python 03_transfer.py
画面で見ること1→Tab→1と押すと、火球が敵の手札に移り、敵がその火球を撃ち返します。敵のHPは3、自分のHPは4、手札は元どおり2枚対2枚です。R→Tab→3→Tab→3と押すと、自分の防壁は2、敵の防壁は0になります。攻撃でなくても防壁が切れることが分かります。
  • 使ったカードが、元の持ち主の手札から消える
  • 4枚のカードの番号(固有ID)が、いつも1回ずつだけある
  • 敵は、受け取ったばかりのカードを使える
  • 手で動かす練習なので、手番は自動で交代しない
つまずきやすいところ相手の手札に加えるだけで自分の手札から外さないと、カードが増えてしまいます。また、途中で1枚対3枚になるのは正しい状態です。これをまちがいとして元に戻すと、「使ったらすぐ渡す」仕組みが壊れます。
少し変えてみよう

R→Tab→4→Tab→4と押してみましょう。2人とも回復は0なのに、手当のカードの持ち主は2回変わります。その流れを追ってみましょう。

03_transfer.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(315行)
03_transfer.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.
# 教材にNoto由来の字形は含みません。標準ASCIIと図形だけを描きます。

"""交換する魔法・教材 3/5。Python 3.11+、この1ファイルだけで起動。"""
from dataclasses import dataclass, replace, asdict

FIRE, SPARK, WALL, HEAL = range(4)
CARD_NAMES = ('FIRE', 'SPARK', 'WALL', 'HEAL')
CARD_RULES = ('3 damage', '1 damage', 'Shield 2: next opposing action', 'Heal 2, up to initial HP')

@dataclass(frozen=True)
class Level:
    seed: str
    title: str
    php: int
    ehp: int
    player: tuple
    enemy: tuple
    ai: str
    rounds: int = 12

LEVEL = Level('A-01-g1', '大技を持たせない', 7, 6, (FIRE, SPARK), (WALL, HEAL), 'A')

@dataclass(frozen=True)
class State:
    php: int
    ehp: int
    player: tuple
    enemy: tuple
    pbar: int = 0
    ebar: int = 0
    round: int = 0
    outcome: str = 'running'

@dataclass(frozen=True)
class Event:
    actor: str
    card: int
    damage: int
    healing: int
    expired: int
    transfer: bool

STAGE = 3
STAGE_TITLE = 'ATOMIC TRANSFER'
SCOPE = 'LAB: A setup, manual actors, transfer on every cast, no ending.'
LAB_NOTES = ('Start from original A setup.', 'TAB changes the manual actor.', 'No automatic response yet.', 'Played card moves immediately.', 'HP 0 is allowed to act here.')
CONTROLS = ('1', '2', '3', '4', 'R', 'TAB')
HELP_ONE = '1..4: owned spell   TAB: change actor   R: reset'
HELP_TWO = 'Try 1, TAB, 1: the enemy can use the FIRE you just passed.'

def initial():
    return State(LEVEL.php, LEVEL.ehp, LEVEL.player, LEVEL.enemy)

def damage(card, barrier):
    return max(0, {FIRE: 3, SPARK: 1}.get(card, 0) - barrier)


def cast(state, actor, card):
    """効果 → 対象の防壁期限 → 使用札の行先。勝敗と敵選択はまだ行わない。"""
    if actor not in ('player', 'enemy'):
        raise ValueError('Unknown actor')
    player = actor == 'player'
    own = state.player if player else state.enemy
    other = state.enemy if player else state.player
    if type(card) is not int or card not in own:
        raise ValueError('Card not owned')
    hp = state.php if player else state.ehp
    other_hp = state.ehp if player else state.php
    barrier = state.pbar if player else state.ebar
    other_barrier = state.ebar if player else state.pbar
    hurt = damage(card, other_barrier)
    healed = min(2, (LEVEL.php if player else LEVEL.ehp) - hp) if card == HEAL else 0
    hp += healed
    other_hp = max(0, other_hp - hurt)
    if card == WALL:
        barrier = 2
    own = tuple(c for c in own if c != card)
    other = tuple(sorted((*other, card)))
    if player:
        result = replace(state, php=hp, ehp=other_hp, player=own, enemy=other,
                         pbar=barrier, ebar=0)
    else:
        result = replace(state, ehp=hp, php=other_hp, enemy=own, player=other,
                         ebar=barrier, pbar=0)
    return result, Event(actor, card, hurt, healed, other_barrier, True)

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

    def reset(self):
        self.state = initial()
        self.actor = 'player'
        self.events = ()
        self.reason = 'ready'
        self.moves = 0

    def act(self, card):
        own = self.state.player if self.actor == 'player' else self.state.enemy
        if type(card) is not int or card not in own:
            self.reason = 'unavailable'
            return
        self.state, event = cast(self.state, self.actor, card)
        self.events = (event,)
        self.reason = 'cast'
        self.moves += 1

class Controller:
    """GUIと合成キー試験が共用する入力処理。押して離すと一判断。"""
    def __init__(self):
        self.game = Game()
        self.selected = 0
        self.gate = False

    def tick(self, pressed=(), held=()):
        pressed = set(pressed) & set(CONTROLS)
        held = set(held) & set(CONTROLS)
        if self.gate:
            if not held:
                self.gate = False
            return
        if len(pressed) != 1 or len(held) != 1 or pressed != held:
            return
        key = next(iter(pressed))
        self.gate = True
        if key == 'R':
            self.game.reset()
            self.selected = 0
        elif key == 'TAB':
            self.game.actor = 'enemy' if self.game.actor == 'player' else 'player'
            self.game.reason = 'actor_changed'
        elif key == 'LEFT':
            self.selected = (self.selected - 1) % len(self.game.state.player)
        elif key == 'RIGHT':
            self.selected = (self.selected + 1) % len(self.game.state.player)
        elif key in ('SPACE', 'RETURN'):
            self.game.act(self.game.state.player[self.selected])
        elif key == 'X':
            self.game.act('undo')
        elif key in '1234':
            self.game.act(int(key) - 1)
        self.selected = min(self.selected, len(self.game.state.player) - 1)


def names(cards):
    return ', '.join(CARD_NAMES[c] for c in cards) or '(none)'


def main(headless=False, screenshot=None, actions='', report=None):
    import json
    from pathlib import Path
    import pyxel
    # 相対出力は呼出元CWD基準。Pyxel初期化より先に確定する。
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    report = str(Path(report).resolve()) if report else None
    controller = Controller()
    key_codes = {key: getattr(pyxel, 'KEY_' + key) for key in CONTROLS}
    replay = {key: key for key in '1234RX'}
    replay.update(T='TAB', L='LEFT', G='RIGHT', E='RETURN', S='SPACE')
    frame = 0
    records = []
    frame_limit = max(12, 2 * len(actions) + 2)

    def update():
        nonlocal frame
        frame += 1
        if headless:
            index = (frame - 1) // 2
            if frame % 2 == 1 and index < len(actions):
                key = replay[actions[index]]
                controller.tick((key,), (key,))
                records.append({'input': actions[index], 'actor': controller.game.actor,
                                'state': asdict(controller.game.state),
                                'events': [asdict(e) for e in controller.game.events],
                                'reason': controller.game.reason})
            else:
                controller.tick()
        else:
            controller.tick([k for k, code in key_codes.items() if pyxel.btnp(code)],
                            [k for k, code in key_codes.items() if pyxel.btn(code)])

    def text(x, y, value, color=7):
        pyxel.text(x, y, str(value), color)

    def draw():
        game = controller.game
        state = game.state
        pyxel.cls(1)
        text(12, 10, 'EXCHANGE SPELLS / STEP ' + str(STAGE) + ' / ' + STAGE_TITLE, 10)
        text(12, 26, SCOPE, 13)
        pyxel.rect(8, 40, 624, 41, 2)
        text(16, 48, f'PLAYER HP {state.php}/{LEVEL.php}  SHIELD {state.pbar}', 11)
        text(16, 64, 'HAND: ' + names(state.player), 7)
        text(330, 48, f'ENEMY HP {state.ehp}/{LEVEL.ehp}  SHIELD {state.ebar}', 7)
        text(330, 64, 'HAND: ' + names(state.enemy), 7)
        text(12, 92, 'KEY / CARD / EFFECT / OWNER', 6)
        for card in range(4):
            y = 108 + card * 43
            owner = 'PLAYER' if card in state.player else 'ENEMY' if card in state.enemy else 'NOT IN STEP 1'
            color = (7, 10, 12, 11)[card] if STAGE > 1 or card == FIRE else 5
            active = STAGE == 5 and state.outcome == 'running' and card == state.player[controller.selected]
            pyxel.rect(8, y - 4, 380, 38, 2)
            pyxel.rectb(8, y - 4, 380, 38, 10 if active else 5)
            text(16, y, f'{card + 1}  {CARD_NAMES[card]:5}   {CARD_RULES[card]}', color)
            text(28, y + 15, 'OWNER: ' + owner + ('  < PREVIEW' if active else ''), 10 if active else 6)
        text(12, 292, 'ACTOR: ' + game.actor.upper() + '    STATUS: ' + state.outcome.upper(), 11)
        counter = f'ROUND: {state.round}/12' if STAGE >= 4 else f'CASTS: {game.moves}'
        text(12, 308, counter + '    LAST INPUT: ' + game.reason.upper(), 7)
        text(12, 328, 'LAST EFFECTS (damage / heal / target shield expired):', 6)
        for i, event in enumerate(game.events):
            text(12, 343 + 15 * i,
                 f'{event.actor.upper()} {CARD_NAMES[event.card]}: {event.damage} / {event.healing} / {event.expired}' + ('  PASS' if event.transfer else '  KEEP'), 7)
        pyxel.rectb(397, 91, 235, 287, 5)
        text(405, 100, 'RESOLUTION ORDER', 10)
        lines = ('1. Spell effect', '2. Target shield expires',
                 '3. Played card changes owner' if STAGE >= 3 else '3. Owner unchanged (lesson only)',
                 '4. Check enemy HP = 0' if STAGE >= 4 else 'Manual action laboratory',
                 '5. Enemy chooses AFTER transfer' if STAGE >= 4 else 'No automatic enemy yet',
                 '6. Enemy effect, expiry, transfer' if STAGE >= 4 else 'R restores the lesson setup',
                 '7. Loss, then 12-round draw' if STAGE >= 4 else 'Read the stage notes below')
        if STAGE == 1:
            lines = ('1. FIRE deals 3 damage', '2. Clamp enemy HP to zero', '3. Stop when enemy HP is zero',
                     'Only FIRE exists in this lab.', 'No enemy action or transfer.', 'R restores enemy HP to 6.')
        for i, line in enumerate(lines):
            text(405, 117 + 12 * i, line, 6)
        if STAGE >= 4:
            text(405, 211, 'A: HEAL if missing HP >= 2;', 7)
            text(405, 223, 'else FIRE > SPARK > WALL > HEAL', 7)
        if STAGE == 5 and state.outcome == 'running':
            card = state.player[controller.selected]
            predicted, events = forecast(state, card)
            text(405, 246, 'EXACT ONE-ROUND FORECAST', 10)
            text(405, 259, 'PLAY: ' + CARD_NAMES[card], 7)
            text(405, 272, 'YOU: damage ' + str(events[0].damage) + ' / heal ' + str(events[0].healing), 7)
            text(405, 285, 'ENEMY: ' + CARD_NAMES[events[1].card] if len(events) > 1 else 'ENEMY: no counterattack', 7)
            text(405, 298, f'AFTER HP P={predicted.php} E={predicted.ehp}', 11)
            text(405, 311, f'SHIELD P={predicted.pbar} E={predicted.ebar}', 12)
            text(405, 324, 'P: ' + names(predicted.player), 7)
            text(405, 337, 'E: ' + names(predicted.enemy), 7)
            text(405, 350, 'RESULT: ' + predicted.outcome.upper(), 10)
        elif STAGE >= 4:
            text(405, 259, 'A-01-g1 / complete round rules', 6)
            text(405, 276, 'Try: SPARK, FIRE, SPARK, FIRE', 10)
            text(405, 293, 'Keys: 2, 1, 2, 1', 7)
            if state.outcome != 'running':
                text(405, 319, state.outcome.upper() + ': R retries' + ('; X undoes' if STAGE == 5 else ''), 11)
        else:
            text(405, 229, 'DELIBERATE SIMPLIFICATION', 13)
            for i, line in enumerate(LAB_NOTES):
                text(405, 246 + i * 15, line, 7)
        text(12, 395, HELP_ONE, 7)
        text(12, 411, HELP_TWO, 6)
        text(12, 433, 'Press and release each key.  No timer.  ESC: quit.  Silent.', 13)
        if headless and frame >= frame_limit:
            if screenshot:
                pyxel.screenshot(screenshot, scale=1)
            if report:
                Path(report).write_text(json.dumps({
                    'label': 'Actual Pyxel headless draw; synthetic Controller keys; silent',
                    'stage': STAGE, 'frames': frame, 'actions': actions,
                    'state': asdict(state), 'actor': game.actor, 'records': records,
                    'history_length': len(game.timeline.history) if STAGE == 5 else 0,
                }, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
            pyxel.quit()

    pyxel.init(640, 452, title='Exchange Spells - Step ' + str(STAGE), 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='1..4=card, T=actor, L/G=select, S/E=cast, X=undo, R=reset')
    parser.add_argument('--screenshot', help='PNG output; headless only')
    parser.add_argument('--report', help='UTF-8 JSON output; headless only')
    args = parser.parse_args()
    if args.headless_smoke and args.headless_replay is not None:
        parser.error('Choose smoke or replay')
    headless = args.headless_smoke or args.headless_replay is not None
    if not headless and (args.screenshot or args.report):
        parser.error('Output options require headless mode')
    actions = args.headless_replay or ''
    allowed = set('1R' if STAGE == 1 else '1234R')
    if STAGE in (2, 3):
        allowed.add('T')
    if STAGE == 5:
        allowed.update('LGSEX')
    if any(key not in allowed for key in actions):
        parser.error('Unsupported key in this stage')
    main(headless, args.screenshot, actions, args.report)
STEP 04 / 05

敵の選択と終局を一往復にする

カードを渡したあとの敵の判断、反撃より先に行う勝ちの判定、負けと12往復の判定の順番。

元のゲームの場面Aの、ふつうのルールを完成させます。自分がカードを使う処理(cast)のあとで、敵のHPを調べます。0なら、カードは渡したまま、敵は反撃せずに自分の勝ちです。敵が生き残ったときだけ、カードを渡したあとの状態を見て、敵Aが使うカードを選びます。敵は、HPが最大より2以上減っていて手当を持っていれば、手当を優先します。それ以外は、火球→火花→防壁→手当の順に選びます。敵の cast のあとは、まず自分のHPが0かどうか(負け)を調べ、次に12往復で決着がつかなかったかを調べます。

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

04_round.py / 注目する部分
    after_player, player_event = cast(state, 'player', card)
    if after_player.ehp == 0:
        return replace(after_player, round=state.round + 1, outcome='won'), (player_event,)
    enemy_card = enemy_choice(after_player)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_round.py
macOS / Linux
.venv/bin/python 04_round.py
画面で見ること最初に1を押すと、敵は手当を選びます。自分のHPは7、敵は5になり、自分の手札は火花と手当になります。最初に2を押すと、敵は渡された火花を使い、自分のHPは6、敵は5になります。R→2→1→2→1と押すと、自分のHPが3のまま、4往復目で勝ちます。
  • 敵は、効果とカードの受け渡しが終わったあとの状態を見て選ぶ
  • 敵のHPが0なら反撃しない(カードはもう渡してある)
  • 敵が動いたあとは、12往復の判定より先に負けを調べる
  • 元のゲームの場面Aと同じ手順で勝てる
つまずきやすいところ自分のカードの効果や受け渡しより前に敵の選択をすると、古いHPと手札を見て選んでしまいます。敵を倒したあとに反撃させるのもまちがいです。
少し変えてみよう

勝った直後、自分のカードは1枚、敵のカードは3枚になります。その理由を、cast と勝ちの判定の順番から説明してみてください。

04_round.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(338行)
04_round.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.
# 教材にNoto由来の字形は含みません。標準ASCIIと図形だけを描きます。

"""交換する魔法・教材 4/5。Python 3.11+、この1ファイルだけで起動。"""
from dataclasses import dataclass, replace, asdict

FIRE, SPARK, WALL, HEAL = range(4)
CARD_NAMES = ('FIRE', 'SPARK', 'WALL', 'HEAL')
CARD_RULES = ('3 damage', '1 damage', 'Shield 2: next opposing action', 'Heal 2, up to initial HP')

@dataclass(frozen=True)
class Level:
    seed: str
    title: str
    php: int
    ehp: int
    player: tuple
    enemy: tuple
    ai: str
    rounds: int = 12

LEVEL = Level('A-01-g1', '大技を持たせない', 7, 6, (FIRE, SPARK), (WALL, HEAL), 'A')

@dataclass(frozen=True)
class State:
    php: int
    ehp: int
    player: tuple
    enemy: tuple
    pbar: int = 0
    ebar: int = 0
    round: int = 0
    outcome: str = 'running'

@dataclass(frozen=True)
class Event:
    actor: str
    card: int
    damage: int
    healing: int
    expired: int
    transfer: bool

STAGE = 4
STAGE_TITLE = 'ENEMY AND ENDING'
SCOPE = 'ORIGINAL A: effects -> expiry -> transfer -> victory -> enemy.'
LAB_NOTES = ()
CONTROLS = ('1', '2', '3', '4', 'R')
HELP_ONE = '1..4: play owned spell + enemy reply   R: reset'
HELP_TWO = 'Victory: enemy HP 0. Loss: player HP 0. Draw: 12 rounds.'

def initial():
    return State(LEVEL.php, LEVEL.ehp, LEVEL.player, LEVEL.enemy)

def damage(card, barrier):
    return max(0, {FIRE: 3, SPARK: 1}.get(card, 0) - barrier)


def cast(state, actor, card):
    """効果 → 対象の防壁期限 → 使用札の行先。勝敗と敵選択はまだ行わない。"""
    if actor not in ('player', 'enemy'):
        raise ValueError('Unknown actor')
    player = actor == 'player'
    own = state.player if player else state.enemy
    other = state.enemy if player else state.player
    if type(card) is not int or card not in own:
        raise ValueError('Card not owned')
    hp = state.php if player else state.ehp
    other_hp = state.ehp if player else state.php
    barrier = state.pbar if player else state.ebar
    other_barrier = state.ebar if player else state.pbar
    hurt = damage(card, other_barrier)
    healed = min(2, (LEVEL.php if player else LEVEL.ehp) - hp) if card == HEAL else 0
    hp += healed
    other_hp = max(0, other_hp - hurt)
    if card == WALL:
        barrier = 2
    own = tuple(c for c in own if c != card)
    other = tuple(sorted((*other, card)))
    if player:
        result = replace(state, php=hp, ehp=other_hp, player=own, enemy=other,
                         pbar=barrier, ebar=0)
    else:
        result = replace(state, ehp=hp, php=other_hp, enemy=own, player=other,
                         ebar=barrier, pbar=0)
    return result, Event(actor, card, hurt, healed, other_barrier, True)

def enemy_choice(state):
    """原作Aだけを実装。自分の効果と移譲が済んだstateを受け取る。"""
    hand = state.enemy
    if LEVEL.ehp - state.ehp >= 2 and HEAL in hand:
        return HEAL
    for card in (FIRE, SPARK):
        if card in hand:
            return card
    if WALL in hand:
        return WALL
    if HEAL in hand:
        return HEAL
    return min(hand)


def round_step(state, card):
    """一往復を不可分に更新。敵が倒れたときは反撃しない。"""
    if state.outcome != 'running' or type(card) is not int or card not in state.player:
        return state, ()
    after_player, player_event = cast(state, 'player', card)
    if after_player.ehp == 0:
        return replace(after_player, round=state.round + 1, outcome='won'), (player_event,)
    enemy_card = enemy_choice(after_player)
    after_enemy, enemy_event = cast(after_player, 'enemy', enemy_card)
    outcome = 'lost' if after_enemy.php == 0 else ('draw' if state.round + 1 >= LEVEL.rounds else 'running')
    return replace(after_enemy, round=state.round + 1, outcome=outcome), (player_event, enemy_event)

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

    def reset(self):
        self.state = initial()
        self.actor = 'player'
        self.events = ()
        self.reason = 'ready'
        self.moves = 0

    def act(self, card):
        self.state, events = round_step(self.state, card)
        self.events = events
        self.reason = 'round' if events else 'unavailable'
        self.moves += bool(events)

class Controller:
    """GUIと合成キー試験が共用する入力処理。押して離すと一判断。"""
    def __init__(self):
        self.game = Game()
        self.selected = 0
        self.gate = False

    def tick(self, pressed=(), held=()):
        pressed = set(pressed) & set(CONTROLS)
        held = set(held) & set(CONTROLS)
        if self.gate:
            if not held:
                self.gate = False
            return
        if len(pressed) != 1 or len(held) != 1 or pressed != held:
            return
        key = next(iter(pressed))
        self.gate = True
        if key == 'R':
            self.game.reset()
            self.selected = 0
        elif key == 'TAB':
            self.game.actor = 'enemy' if self.game.actor == 'player' else 'player'
            self.game.reason = 'actor_changed'
        elif key == 'LEFT':
            self.selected = (self.selected - 1) % len(self.game.state.player)
        elif key == 'RIGHT':
            self.selected = (self.selected + 1) % len(self.game.state.player)
        elif key in ('SPACE', 'RETURN'):
            self.game.act(self.game.state.player[self.selected])
        elif key == 'X':
            self.game.act('undo')
        elif key in '1234':
            self.game.act(int(key) - 1)
        self.selected = min(self.selected, len(self.game.state.player) - 1)


def names(cards):
    return ', '.join(CARD_NAMES[c] for c in cards) or '(none)'


def main(headless=False, screenshot=None, actions='', report=None):
    import json
    from pathlib import Path
    import pyxel
    # 相対出力は呼出元CWD基準。Pyxel初期化より先に確定する。
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    report = str(Path(report).resolve()) if report else None
    controller = Controller()
    key_codes = {key: getattr(pyxel, 'KEY_' + key) for key in CONTROLS}
    replay = {key: key for key in '1234RX'}
    replay.update(T='TAB', L='LEFT', G='RIGHT', E='RETURN', S='SPACE')
    frame = 0
    records = []
    frame_limit = max(12, 2 * len(actions) + 2)

    def update():
        nonlocal frame
        frame += 1
        if headless:
            index = (frame - 1) // 2
            if frame % 2 == 1 and index < len(actions):
                key = replay[actions[index]]
                controller.tick((key,), (key,))
                records.append({'input': actions[index], 'actor': controller.game.actor,
                                'state': asdict(controller.game.state),
                                'events': [asdict(e) for e in controller.game.events],
                                'reason': controller.game.reason})
            else:
                controller.tick()
        else:
            controller.tick([k for k, code in key_codes.items() if pyxel.btnp(code)],
                            [k for k, code in key_codes.items() if pyxel.btn(code)])

    def text(x, y, value, color=7):
        pyxel.text(x, y, str(value), color)

    def draw():
        game = controller.game
        state = game.state
        pyxel.cls(1)
        text(12, 10, 'EXCHANGE SPELLS / STEP ' + str(STAGE) + ' / ' + STAGE_TITLE, 10)
        text(12, 26, SCOPE, 13)
        pyxel.rect(8, 40, 624, 41, 2)
        text(16, 48, f'PLAYER HP {state.php}/{LEVEL.php}  SHIELD {state.pbar}', 11)
        text(16, 64, 'HAND: ' + names(state.player), 7)
        text(330, 48, f'ENEMY HP {state.ehp}/{LEVEL.ehp}  SHIELD {state.ebar}', 7)
        text(330, 64, 'HAND: ' + names(state.enemy), 7)
        text(12, 92, 'KEY / CARD / EFFECT / OWNER', 6)
        for card in range(4):
            y = 108 + card * 43
            owner = 'PLAYER' if card in state.player else 'ENEMY' if card in state.enemy else 'NOT IN STEP 1'
            color = (7, 10, 12, 11)[card] if STAGE > 1 or card == FIRE else 5
            active = STAGE == 5 and state.outcome == 'running' and card == state.player[controller.selected]
            pyxel.rect(8, y - 4, 380, 38, 2)
            pyxel.rectb(8, y - 4, 380, 38, 10 if active else 5)
            text(16, y, f'{card + 1}  {CARD_NAMES[card]:5}   {CARD_RULES[card]}', color)
            text(28, y + 15, 'OWNER: ' + owner + ('  < PREVIEW' if active else ''), 10 if active else 6)
        text(12, 292, 'ACTOR: ' + game.actor.upper() + '    STATUS: ' + state.outcome.upper(), 11)
        counter = f'ROUND: {state.round}/12' if STAGE >= 4 else f'CASTS: {game.moves}'
        text(12, 308, counter + '    LAST INPUT: ' + game.reason.upper(), 7)
        text(12, 328, 'LAST EFFECTS (damage / heal / target shield expired):', 6)
        for i, event in enumerate(game.events):
            text(12, 343 + 15 * i,
                 f'{event.actor.upper()} {CARD_NAMES[event.card]}: {event.damage} / {event.healing} / {event.expired}' + ('  PASS' if event.transfer else '  KEEP'), 7)
        pyxel.rectb(397, 91, 235, 287, 5)
        text(405, 100, 'RESOLUTION ORDER', 10)
        lines = ('1. Spell effect', '2. Target shield expires',
                 '3. Played card changes owner' if STAGE >= 3 else '3. Owner unchanged (lesson only)',
                 '4. Check enemy HP = 0' if STAGE >= 4 else 'Manual action laboratory',
                 '5. Enemy chooses AFTER transfer' if STAGE >= 4 else 'No automatic enemy yet',
                 '6. Enemy effect, expiry, transfer' if STAGE >= 4 else 'R restores the lesson setup',
                 '7. Loss, then 12-round draw' if STAGE >= 4 else 'Read the stage notes below')
        if STAGE == 1:
            lines = ('1. FIRE deals 3 damage', '2. Clamp enemy HP to zero', '3. Stop when enemy HP is zero',
                     'Only FIRE exists in this lab.', 'No enemy action or transfer.', 'R restores enemy HP to 6.')
        for i, line in enumerate(lines):
            text(405, 117 + 12 * i, line, 6)
        if STAGE >= 4:
            text(405, 211, 'A: HEAL if missing HP >= 2;', 7)
            text(405, 223, 'else FIRE > SPARK > WALL > HEAL', 7)
        if STAGE == 5 and state.outcome == 'running':
            card = state.player[controller.selected]
            predicted, events = forecast(state, card)
            text(405, 246, 'EXACT ONE-ROUND FORECAST', 10)
            text(405, 259, 'PLAY: ' + CARD_NAMES[card], 7)
            text(405, 272, 'YOU: damage ' + str(events[0].damage) + ' / heal ' + str(events[0].healing), 7)
            text(405, 285, 'ENEMY: ' + CARD_NAMES[events[1].card] if len(events) > 1 else 'ENEMY: no counterattack', 7)
            text(405, 298, f'AFTER HP P={predicted.php} E={predicted.ehp}', 11)
            text(405, 311, f'SHIELD P={predicted.pbar} E={predicted.ebar}', 12)
            text(405, 324, 'P: ' + names(predicted.player), 7)
            text(405, 337, 'E: ' + names(predicted.enemy), 7)
            text(405, 350, 'RESULT: ' + predicted.outcome.upper(), 10)
        elif STAGE >= 4:
            text(405, 259, 'A-01-g1 / complete round rules', 6)
            text(405, 276, 'Try: SPARK, FIRE, SPARK, FIRE', 10)
            text(405, 293, 'Keys: 2, 1, 2, 1', 7)
            if state.outcome != 'running':
                text(405, 319, state.outcome.upper() + ': R retries' + ('; X undoes' if STAGE == 5 else ''), 11)
        else:
            text(405, 229, 'DELIBERATE SIMPLIFICATION', 13)
            for i, line in enumerate(LAB_NOTES):
                text(405, 246 + i * 15, line, 7)
        text(12, 395, HELP_ONE, 7)
        text(12, 411, HELP_TWO, 6)
        text(12, 433, 'Press and release each key.  No timer.  ESC: quit.  Silent.', 13)
        if headless and frame >= frame_limit:
            if screenshot:
                pyxel.screenshot(screenshot, scale=1)
            if report:
                Path(report).write_text(json.dumps({
                    'label': 'Actual Pyxel headless draw; synthetic Controller keys; silent',
                    'stage': STAGE, 'frames': frame, 'actions': actions,
                    'state': asdict(state), 'actor': game.actor, 'records': records,
                    'history_length': len(game.timeline.history) if STAGE == 5 else 0,
                }, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
            pyxel.quit()

    pyxel.init(640, 452, title='Exchange Spells - Step ' + str(STAGE), 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='1..4=card, T=actor, L/G=select, S/E=cast, X=undo, R=reset')
    parser.add_argument('--screenshot', help='PNG output; headless only')
    parser.add_argument('--report', help='UTF-8 JSON output; headless only')
    args = parser.parse_args()
    if args.headless_smoke and args.headless_replay is not None:
        parser.error('Choose smoke or replay')
    headless = args.headless_smoke or args.headless_replay is not None
    if not headless and (args.screenshot or args.report):
        parser.error('Output options require headless mode')
    actions = args.headless_replay or ''
    allowed = set('1R' if STAGE == 1 else '1234R')
    if STAGE in (2, 3):
        allowed.add('T')
    if STAGE == 5:
        allowed.update('LGSEX')
    if any(key not in allowed for key in actions):
        parser.error('Unsupported key in this stage')
    main(headless, args.screenshot, actions, args.report)
STEP 05 / 05

正確な予告と一往復Undo

予告と実行で同じ関数を使う、書き換えない履歴、予告と実際の結果を一致させる。

左右キーでカードを選び、Space か Enter で使います。forecast(予告)は、実際に進めるときと同じ round_step を呼び出し、1往復後のHP・防壁・両方の手札・結果を表示します。State は書き換えられないので、予告を見るだけではゲームは進みません。Timeline は、正しく行えた往復の前の State だけを履歴に入れます。X を押すと、敵の行動も含めた1往復を戻します。完成版は、元のゲームの場面Aのルール部分(モデル)と一致させてあります。場面BとC、比較モード、元のゲームの記録や演出は省いています。

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

05_forecast.py / 注目する部分
def forecast(state, card):
    """別の予測式は作らず、実処理と同じ純粋関数を呼ぶ。元のstateは不変。"""
    return round_step(state, card)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_forecast.py
macOS / Linux
.venv/bin/python 05_forecast.py
画面で見ること最初に右キーで火花に切り替えると、予告は自分のHP6・敵5になります。今の状態は7・6、0往復のままです。Space を押すと、予告どおりに進みます。R→2→1→2→1→Xと押すと、自分のHP3・敵3、3往復の RUNNING(対戦中)に戻ります。
  • 予告を表示しても状態と履歴が変わらない
  • 実行したあとの両方の手札も、予告と同じになる
  • X で、勝った状態から1つ前の状態に戻れる
  • 元のゲームとどこまで同じで、何を省いたかを説明できる
つまずきやすいところ予告のためだけに簡単なダメージの式を別に作ると、防壁が切れること、回復、カードの持ち主、反撃なしの勝ちなどを入れ忘れやすくなります。また、Undo(元に戻す)でHPだけを戻すと、手札と敵の次の判断がおかしくなります。
少し変えてみよう

左右キーで何度切り替えても、履歴が増えないことを確かめます。次に、1往復を実行してから X で戻し、別のカードを選んだときの予告と、実際の結果を比べてみましょう。

05_forecast.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(363行)
05_forecast.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.
# 教材にNoto由来の字形は含みません。標準ASCIIと図形だけを描きます。

"""交換する魔法・教材 5/5。Python 3.11+、この1ファイルだけで起動。"""
from dataclasses import dataclass, replace, asdict

FIRE, SPARK, WALL, HEAL = range(4)
CARD_NAMES = ('FIRE', 'SPARK', 'WALL', 'HEAL')
CARD_RULES = ('3 damage', '1 damage', 'Shield 2: next opposing action', 'Heal 2, up to initial HP')

@dataclass(frozen=True)
class Level:
    seed: str
    title: str
    php: int
    ehp: int
    player: tuple
    enemy: tuple
    ai: str
    rounds: int = 12

LEVEL = Level('A-01-g1', '大技を持たせない', 7, 6, (FIRE, SPARK), (WALL, HEAL), 'A')

@dataclass(frozen=True)
class State:
    php: int
    ehp: int
    player: tuple
    enemy: tuple
    pbar: int = 0
    ebar: int = 0
    round: int = 0
    outcome: str = 'running'

@dataclass(frozen=True)
class Event:
    actor: str
    card: int
    damage: int
    healing: int
    expired: int
    transfer: bool

STAGE = 5
STAGE_TITLE = 'FORECAST AND UNDO'
SCOPE = 'ORIGINAL A: same pure round function for forecast and execution.'
LAB_NOTES = ()
CONTROLS = ('1', '2', '3', '4', 'R', 'LEFT', 'RIGHT', 'SPACE', 'RETURN', 'X')
HELP_ONE = 'LEFT/RIGHT: preview   SPACE/ENTER: cast   1..4: direct cast'
HELP_TWO = 'X: undo one complete round   R: reset. Preview changes nothing.'

def initial():
    return State(LEVEL.php, LEVEL.ehp, LEVEL.player, LEVEL.enemy)

def damage(card, barrier):
    return max(0, {FIRE: 3, SPARK: 1}.get(card, 0) - barrier)


def cast(state, actor, card):
    """効果 → 対象の防壁期限 → 使用札の行先。勝敗と敵選択はまだ行わない。"""
    if actor not in ('player', 'enemy'):
        raise ValueError('Unknown actor')
    player = actor == 'player'
    own = state.player if player else state.enemy
    other = state.enemy if player else state.player
    if type(card) is not int or card not in own:
        raise ValueError('Card not owned')
    hp = state.php if player else state.ehp
    other_hp = state.ehp if player else state.php
    barrier = state.pbar if player else state.ebar
    other_barrier = state.ebar if player else state.pbar
    hurt = damage(card, other_barrier)
    healed = min(2, (LEVEL.php if player else LEVEL.ehp) - hp) if card == HEAL else 0
    hp += healed
    other_hp = max(0, other_hp - hurt)
    if card == WALL:
        barrier = 2
    own = tuple(c for c in own if c != card)
    other = tuple(sorted((*other, card)))
    if player:
        result = replace(state, php=hp, ehp=other_hp, player=own, enemy=other,
                         pbar=barrier, ebar=0)
    else:
        result = replace(state, ehp=hp, php=other_hp, enemy=own, player=other,
                         ebar=barrier, pbar=0)
    return result, Event(actor, card, hurt, healed, other_barrier, True)

def enemy_choice(state):
    """原作Aだけを実装。自分の効果と移譲が済んだstateを受け取る。"""
    hand = state.enemy
    if LEVEL.ehp - state.ehp >= 2 and HEAL in hand:
        return HEAL
    for card in (FIRE, SPARK):
        if card in hand:
            return card
    if WALL in hand:
        return WALL
    if HEAL in hand:
        return HEAL
    return min(hand)


def round_step(state, card):
    """一往復を不可分に更新。敵が倒れたときは反撃しない。"""
    if state.outcome != 'running' or type(card) is not int or card not in state.player:
        return state, ()
    after_player, player_event = cast(state, 'player', card)
    if after_player.ehp == 0:
        return replace(after_player, round=state.round + 1, outcome='won'), (player_event,)
    enemy_card = enemy_choice(after_player)
    after_enemy, enemy_event = cast(after_player, 'enemy', enemy_card)
    outcome = 'lost' if after_enemy.php == 0 else ('draw' if state.round + 1 >= LEVEL.rounds else 'running')
    return replace(after_enemy, round=state.round + 1, outcome=outcome), (player_event, enemy_event)

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


def step(timeline, action):
    if action == 'undo':
        if timeline.history:
            return Timeline(timeline.history[-1], timeline.history[:-1]), ()
        return timeline, ()
    state, events = round_step(timeline.state, action)
    if state != timeline.state:
        return Timeline(state, timeline.history + (timeline.state,)), events
    return timeline, ()


def forecast(state, card):
    """別の予測式は作らず、実処理と同じ純粋関数を呼ぶ。元のstateは不変。"""
    return round_step(state, card)

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

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

    def reset(self):
        self.timeline = Timeline(initial())
        self.actor = 'player'
        self.events = ()
        self.reason = 'ready'
        self.moves = 0

    def act(self, action):
        before = self.timeline
        self.timeline, self.events = step(self.timeline, action)
        self.reason = ('undo' if action == 'undo' else 'round') if self.timeline != before else 'unavailable'
        self.moves = self.state.round

class Controller:
    """GUIと合成キー試験が共用する入力処理。押して離すと一判断。"""
    def __init__(self):
        self.game = Game()
        self.selected = 0
        self.gate = False

    def tick(self, pressed=(), held=()):
        pressed = set(pressed) & set(CONTROLS)
        held = set(held) & set(CONTROLS)
        if self.gate:
            if not held:
                self.gate = False
            return
        if len(pressed) != 1 or len(held) != 1 or pressed != held:
            return
        key = next(iter(pressed))
        self.gate = True
        if key == 'R':
            self.game.reset()
            self.selected = 0
        elif key == 'TAB':
            self.game.actor = 'enemy' if self.game.actor == 'player' else 'player'
            self.game.reason = 'actor_changed'
        elif key == 'LEFT':
            self.selected = (self.selected - 1) % len(self.game.state.player)
        elif key == 'RIGHT':
            self.selected = (self.selected + 1) % len(self.game.state.player)
        elif key in ('SPACE', 'RETURN'):
            self.game.act(self.game.state.player[self.selected])
        elif key == 'X':
            self.game.act('undo')
        elif key in '1234':
            self.game.act(int(key) - 1)
        self.selected = min(self.selected, len(self.game.state.player) - 1)


def names(cards):
    return ', '.join(CARD_NAMES[c] for c in cards) or '(none)'


def main(headless=False, screenshot=None, actions='', report=None):
    import json
    from pathlib import Path
    import pyxel
    # 相対出力は呼出元CWD基準。Pyxel初期化より先に確定する。
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    report = str(Path(report).resolve()) if report else None
    controller = Controller()
    key_codes = {key: getattr(pyxel, 'KEY_' + key) for key in CONTROLS}
    replay = {key: key for key in '1234RX'}
    replay.update(T='TAB', L='LEFT', G='RIGHT', E='RETURN', S='SPACE')
    frame = 0
    records = []
    frame_limit = max(12, 2 * len(actions) + 2)

    def update():
        nonlocal frame
        frame += 1
        if headless:
            index = (frame - 1) // 2
            if frame % 2 == 1 and index < len(actions):
                key = replay[actions[index]]
                controller.tick((key,), (key,))
                records.append({'input': actions[index], 'actor': controller.game.actor,
                                'state': asdict(controller.game.state),
                                'events': [asdict(e) for e in controller.game.events],
                                'reason': controller.game.reason})
            else:
                controller.tick()
        else:
            controller.tick([k for k, code in key_codes.items() if pyxel.btnp(code)],
                            [k for k, code in key_codes.items() if pyxel.btn(code)])

    def text(x, y, value, color=7):
        pyxel.text(x, y, str(value), color)

    def draw():
        game = controller.game
        state = game.state
        pyxel.cls(1)
        text(12, 10, 'EXCHANGE SPELLS / STEP ' + str(STAGE) + ' / ' + STAGE_TITLE, 10)
        text(12, 26, SCOPE, 13)
        pyxel.rect(8, 40, 624, 41, 2)
        text(16, 48, f'PLAYER HP {state.php}/{LEVEL.php}  SHIELD {state.pbar}', 11)
        text(16, 64, 'HAND: ' + names(state.player), 7)
        text(330, 48, f'ENEMY HP {state.ehp}/{LEVEL.ehp}  SHIELD {state.ebar}', 7)
        text(330, 64, 'HAND: ' + names(state.enemy), 7)
        text(12, 92, 'KEY / CARD / EFFECT / OWNER', 6)
        for card in range(4):
            y = 108 + card * 43
            owner = 'PLAYER' if card in state.player else 'ENEMY' if card in state.enemy else 'NOT IN STEP 1'
            color = (7, 10, 12, 11)[card] if STAGE > 1 or card == FIRE else 5
            active = STAGE == 5 and state.outcome == 'running' and card == state.player[controller.selected]
            pyxel.rect(8, y - 4, 380, 38, 2)
            pyxel.rectb(8, y - 4, 380, 38, 10 if active else 5)
            text(16, y, f'{card + 1}  {CARD_NAMES[card]:5}   {CARD_RULES[card]}', color)
            text(28, y + 15, 'OWNER: ' + owner + ('  < PREVIEW' if active else ''), 10 if active else 6)
        text(12, 292, 'ACTOR: ' + game.actor.upper() + '    STATUS: ' + state.outcome.upper(), 11)
        counter = f'ROUND: {state.round}/12' if STAGE >= 4 else f'CASTS: {game.moves}'
        text(12, 308, counter + '    LAST INPUT: ' + game.reason.upper(), 7)
        text(12, 328, 'LAST EFFECTS (damage / heal / target shield expired):', 6)
        for i, event in enumerate(game.events):
            text(12, 343 + 15 * i,
                 f'{event.actor.upper()} {CARD_NAMES[event.card]}: {event.damage} / {event.healing} / {event.expired}' + ('  PASS' if event.transfer else '  KEEP'), 7)
        pyxel.rectb(397, 91, 235, 287, 5)
        text(405, 100, 'RESOLUTION ORDER', 10)
        lines = ('1. Spell effect', '2. Target shield expires',
                 '3. Played card changes owner' if STAGE >= 3 else '3. Owner unchanged (lesson only)',
                 '4. Check enemy HP = 0' if STAGE >= 4 else 'Manual action laboratory',
                 '5. Enemy chooses AFTER transfer' if STAGE >= 4 else 'No automatic enemy yet',
                 '6. Enemy effect, expiry, transfer' if STAGE >= 4 else 'R restores the lesson setup',
                 '7. Loss, then 12-round draw' if STAGE >= 4 else 'Read the stage notes below')
        if STAGE == 1:
            lines = ('1. FIRE deals 3 damage', '2. Clamp enemy HP to zero', '3. Stop when enemy HP is zero',
                     'Only FIRE exists in this lab.', 'No enemy action or transfer.', 'R restores enemy HP to 6.')
        for i, line in enumerate(lines):
            text(405, 117 + 12 * i, line, 6)
        if STAGE >= 4:
            text(405, 211, 'A: HEAL if missing HP >= 2;', 7)
            text(405, 223, 'else FIRE > SPARK > WALL > HEAL', 7)
        if STAGE == 5 and state.outcome == 'running':
            card = state.player[controller.selected]
            predicted, events = forecast(state, card)
            text(405, 246, 'EXACT ONE-ROUND FORECAST', 10)
            text(405, 259, 'PLAY: ' + CARD_NAMES[card], 7)
            text(405, 272, 'YOU: damage ' + str(events[0].damage) + ' / heal ' + str(events[0].healing), 7)
            text(405, 285, 'ENEMY: ' + CARD_NAMES[events[1].card] if len(events) > 1 else 'ENEMY: no counterattack', 7)
            text(405, 298, f'AFTER HP P={predicted.php} E={predicted.ehp}', 11)
            text(405, 311, f'SHIELD P={predicted.pbar} E={predicted.ebar}', 12)
            text(405, 324, 'P: ' + names(predicted.player), 7)
            text(405, 337, 'E: ' + names(predicted.enemy), 7)
            text(405, 350, 'RESULT: ' + predicted.outcome.upper(), 10)
        elif STAGE >= 4:
            text(405, 259, 'A-01-g1 / complete round rules', 6)
            text(405, 276, 'Try: SPARK, FIRE, SPARK, FIRE', 10)
            text(405, 293, 'Keys: 2, 1, 2, 1', 7)
            if state.outcome != 'running':
                text(405, 319, state.outcome.upper() + ': R retries' + ('; X undoes' if STAGE == 5 else ''), 11)
        else:
            text(405, 229, 'DELIBERATE SIMPLIFICATION', 13)
            for i, line in enumerate(LAB_NOTES):
                text(405, 246 + i * 15, line, 7)
        text(12, 395, HELP_ONE, 7)
        text(12, 411, HELP_TWO, 6)
        text(12, 433, 'Press and release each key.  No timer.  ESC: quit.  Silent.', 13)
        if headless and frame >= frame_limit:
            if screenshot:
                pyxel.screenshot(screenshot, scale=1)
            if report:
                Path(report).write_text(json.dumps({
                    'label': 'Actual Pyxel headless draw; synthetic Controller keys; silent',
                    'stage': STAGE, 'frames': frame, 'actions': actions,
                    'state': asdict(state), 'actor': game.actor, 'records': records,
                    'history_length': len(game.timeline.history) if STAGE == 5 else 0,
                }, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
            pyxel.quit()

    pyxel.init(640, 452, title='Exchange Spells - Step ' + str(STAGE), 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='1..4=card, T=actor, L/G=select, S/E=cast, X=undo, R=reset')
    parser.add_argument('--screenshot', help='PNG output; headless only')
    parser.add_argument('--report', help='UTF-8 JSON output; headless only')
    args = parser.parse_args()
    if args.headless_smoke and args.headless_replay is not None:
        parser.error('Choose smoke or replay')
    headless = args.headless_smoke or args.headless_replay is not None
    if not headless and (args.screenshot or args.report):
        parser.error('Output options require headless mode')
    actions = args.headless_replay or ''
    allowed = set('1R' if STAGE == 1 else '1234R')
    if STAGE in (2, 3):
        allowed.add('T')
    if STAGE == 5:
        allowed.update('LGSEX')
    if any(key not in allowed for key in actions):
        parser.error('Unsupported key in this stage')
    main(headless, args.screenshot, actions, args.report)
AFTER STEP 05

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

カードは4枚です。火球は3ダメージ、火花は1ダメージ、防壁は自分の防壁を2にし、手当は2回復します。効果のあと、対象の防壁は攻撃でなくても切れ、使ったカードは相手に渡します。敵を倒したときは、カードを渡したうえで、反撃なしで勝ちです。敵が生き残ったら、カードを渡したあとの状態を見て、公開されている優先順の表から選びます。敵の効果とカードの受け渡しのあと、自分のHPが0なら負け、2人とも生き残ったまま12往復になったら決着なしです。決まった3つの場面A・B・C、正確な1往復の予告、Undo、カードの持ち主を固定した比較もあります。

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

比べる点5ステップの教材元の完成版
場面と敵の選び方04と05は場面A(A-01-g1)だけ。最初のHP・手札・敵A・12往復・勝ち負けは、元のゲームの場面Aのふつうのルールと同じ。A・B・Cの3つの配り方と、3種類の敵の優先順の表。
最初の練習段階01は火球だけの的当てで、持ち主は固定。02は自分のHP5・自分のカードが防壁と手当の効果の練習で、持ち主は固定。03は場面Aの最初の状態で、カードの受け渡しあり。02と03は使う人を手で切り替え、勝ち負けがなく、HPが0でも行動できる。最初から4つの効果、カードの受け渡し、自動で動く敵、勝ち負けと往復の制限がある。
予告と履歴05では、予告と実際の処理が同じ round_step を使い、State 全体を1往復戻す Undo を追加。04は対戦のルールは同じだが、予告と Undo はない。どの場面でも予告と Undo が使える。
画面と入力英字(ASCII)と四角形だけで表示。数字キーはカードの番号。タイトル画面、一時停止、演出、場面の選択、F12、日本語の文字は省略。キーを押して、対応するキーを全部離したら1回と数える簡単な Controller。日本語の文字、左右キーとZ、開始画面、一時停止の説明、カードを渡す演出、場面の選択など。
比較と保存持ち主を固定した比較、ソルバー(答えを自動で探すプログラム)、元のゲームのリプレイ形式は省略。教材には、画面なしで動かして(headless)PNGとUTF-8のJSONを出す確認の仕組み、同梱のテスト用データ(fixture)、元のゲームと比べる任意のツールを用意。持ち主を固定した比較、ルールの版が付いた記録と再生、ソルバー、デモや映像がある。
素材と音Noto の日本語の文字や、外部の画像・音は含めない。音の処理もない。Noto をもとにした日本語の文字を同梱。元のゲームも音のない設計。

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

  1. model.py の LEVELS・enemy_choice から場面BとCを追加し、カードを使ったあとに残る手札の条件を読む。
  2. cast の exchange 引数を読み、カードの持ち主の扱いだけを変えた比較を作る。
  3. replay_data・check_replay で、ルールの版と、途中の状態のハッシュ(状態を短い値にまとめたもの)が入った再現の記録を読む。
  4. app.py の action・update を読み、一時停止・演出・キーを離す処理を、ゲームの状態と分ける。
自分のゲームに変えるなら

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

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