戦略・対戦LESSON 52公開手帖で誘導する有限札の決闘原作040

まねっこ決闘の作り方

弱い札を使って、相手の次の守りを誘いこむ。
数に限りのある札と気力で戦います。相手は「これまでに上と下を何回攻撃されたか」を見て次の守りを決めるので、それを利用して倒すゲームを作ります。

完成版の遊び方これまでの上・下の攻撃回数を覚えている相手を、わざと誘いこみます。限りのある札・気力・相手の防御札を考えながら倒します。

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

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

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

完成版の主な操作:矢印札選択、Z使う、X戻す、P停止、R再開、1〜3状況、M選択、N次相手

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

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

うまく表示されないとき

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

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

今の攻撃を先に解決してから、相手の記憶を更新する。

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

1. 残りの枚数と、1手の区切り
2. 払う・回復する・上限
3. 見えている情報だけで、相手の行動が決まる
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
変数・if・for・関数 / タプルと書きかえない dataclass(共通入門を参照) / 整数の比べ方と条件式
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
左右 / 数字:札を選ぶ(01は1・2) / Z:選んだ札を使う / X:05だけ一手戻る / R:初期化。新しい押下を優先 / Esc:終了
難易度の目安
初級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_cards.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

有限の札を一枚ずつ使う

残りの枚数と、1手の区切り。

上の小技と下の小技を2枚ずつ持ち、HP 3 の相手を倒す練習から始めます。左右か数字の1・2で札を選び、Z で使います。選ぶだけなら、相手の HP もラウンドも変わりません。上と下のちがいが意味を持つのは、あとの段階です。

State.cards は、札の残りの枚数を並べたタプルです。変更用の list にコピーし、1枚減らしてから tuple に戻します。元の状態を直接変えないので、あとで前後を比べたり、Undo(1つ前に戻す)したりできます。step が札を1枚使った結果を返し、draw はその結果を表示します。

残りが0枚の札は EMPTY として受け付けず、ラウンドも進めません。使えたときだけ HP と残りの枚数を更新し、最後に結果を判定します。HP 0 を先に調べるので、最後のラウンドで倒しても成功です。札を全部使い切る必要はありません。

この最小版は、HP 3・小技だけの教材用の練習です。元のゲームの状況 B にある HP 8・気力・大技・呼吸・守り・相手の記憶は、まだ入っていません。画面の下の NEXT は、step をためしに呼んで出した予告なので、何回表示しても札は減りません。

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

01_cards.py / 注目する部分
    cards = list(state.cards)
    cards[card] -= 1

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_cards.py
macOS / Linux
.venv/bin/python 01_cards.py
画面で見ること数字の1 → Z → Z → 数字の2 → Z と押します。キーを1回ずつ離してから押すと、3ラウンドで HP 0・WON(勝ち)です。下の小技が1枚残ります。
  • 選ぶことと使うことを分ける
  • 残りが0枚なら受け付けない
  • 最後の1手での成功を先に判定する
つまずきやすいところボタンを押している間、毎フレーム札を使うようにすると、考えるひまもなく使い切ってしまいます。btnp(押した瞬間)と、全部のキーを離すまで待つ処理を使い分けます。
少し変えてみよう

INITIAL_HP を3から4に変えて、もう一度起動してみましょう。同じ3枚では HP 1・PLAY(続行中)です。もう一度 Z で最後の下の小技を使うと、4ラウンド目で WON です。確かめたら3に戻します。

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

"""まねっこ決闘 教材1/5: 有限の札。ASCII表示、音声なし。"""
from dataclasses import dataclass, replace, asdict

STAGE = 1
NAMES = ("UP SMALL", "DOWN SMALL")
SIDES = ("up", "down")
DAMAGE = (1, 1)
INITIAL_CARDS = (2, 2)
INITIAL_HP = 3
ROUND_LIMIT = 4

@dataclass(frozen=True)
class State:
    cards: tuple = INITIAL_CARDS
    hp: int = INITIAL_HP
    round: int = 0
    status: str = "play"
    last: int = -1
    damage: int = 0
    reason: str = "ready"


def initial():
    return State()


def refusal(state, card):
    if state.status != "play":
        return "end"
    if type(card) is not int or card not in range(len(NAMES)):
        return "invalid"
    if state.cards[card] == 0:
        return "empty"
    return ""


def step(state, card):
    if refusal(state, card):
        return state
    cards = list(state.cards)
    cards[card] -= 1
    damage = DAMAGE[card]
    hp = max(0, state.hp-damage)
    turn = state.round+1
    available = any(cards)
    status = "won" if hp==0 else "lost" if turn>=ROUND_LIMIT or not available else "play"
    reason = "hit"
    if status != "play":
        reason = "complete" if status=="won" else "remaining"
    return State(cards=tuple(cards), hp=hp, round=turn,
                 status=status, last=card, damage=damage, reason=reason)


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

    def reset(self):
        self.state = initial()
        self.selected = 0
        self.gated = False
        self.message = "READY"

    def control(self, pressed, held):
        if "R" in pressed:
            self.reset()
            self.gated = True
            return
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held)>1:
            self.gated = True
            self.message = "RELEASE ALL KEYS"
            return
        if len(pressed)!=1:
            return
        key = next(iter(pressed))
        self.gated = True
        if key in ("LEFT", "RIGHT"):
            self.selected = (self.selected+(-1 if key=="LEFT" else 1))%len(NAMES)
            self.message = "SELECT ONLY"
        elif key in tuple(str(n+1) for n in range(len(NAMES))):
            self.selected = int(key)-1
            self.message = "SELECT ONLY"
        elif key == "Z":
            why = refusal(self.state, self.selected)
            self.state = step(self.state, self.selected)
            self.message = why.upper() if why else self.state.reason.upper()

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(8,8,"MIMIC DUEL / STAGE "+str(STAGE),10)
        p.text(8,23,"HP "+str(s.hp)+" / "+str(INITIAL_HP)+"   ROUND "+str(s.round)+" / "+str(ROUND_LIMIT)+"   "+s.status.upper(),7)
        p.rect(8,37,240,8,5)
        p.rect(8,37,240*s.hp/INITIAL_HP,8,8)
        p.text(8,55,"SMALL ATTACKS: ONE DAMAGE EACH",11)
        p.text(8,70,"PRACTICE: NO GUARD / NO LEARNING",6)
        for n,name in enumerate(NAMES):
            y = 106+n*23
            p.rect(8,y,400,20,2 if n==self.selected else 1)
            if n==self.selected:
                p.rectb(8,y,400,20,10)
            p.text(15,y+6,str(n+1)+" "+name+"   LEFT "+str(s.cards[n])+"   DMG "+str(DAMAGE[n]),7)
        preview = step(s,self.selected)
        why = refusal(s,self.selected)
        p.text(8,232,"NEXT: "+(why.upper() if why else "HP "+str(preview.hp)+" / "+preview.reason.upper()),10)
        p.text(8,249,self.message,14)
        p.text(8,267,"ARROWS / NUMBER: SELECT   Z: USE   R: RESET",7)
        p.text(8,282,"ESC: QUIT   RELEASE KEYS BETWEEN ACTIONS",7)

DEMO_KEYS = ('1', 'Z', '1', 'Z', '2', 'Z')


def main(headless=False, screenshot=None, demo=False):
    import pyxel as p
    p.init(420,300,title="Mimic Duel lesson "+str(STAGE),fps=30,display_scale=2,headless=headless)
    app = App()
    keys = {"LEFT":p.KEY_LEFT,"RIGHT":p.KEY_RIGHT,"Z":p.KEY_Z,"R":p.KEY_R,"X":p.KEY_X}
    keys.update({str(n):getattr(p,"KEY_"+str(n)) for n in range(1,6)})
    limit = max(16,2*len(DEMO_KEYS)+6) if demo else 16
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:
                app.draw(p)
                p.screen.save(screenshot,1)
            p.quit()
        if headless:
            if demo:
                n=p.frame_count//2
                pressed={DEMO_KEYS[n]} if p.frame_count%2==0 and n<len(DEMO_KEYS) else set()
                app.control(pressed,pressed)
        else:
            app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))


if __name__ == "__main__":
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke",action="store_true")
    parser.add_argument("--demo-checkpoint",action="store_true")
    parser.add_argument("--screenshot")
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:
        parser.error("--demo-checkpoint requires --headless-smoke")
    if args.screenshot and not args.headless_smoke:
        parser.error("--screenshot requires --headless-smoke")
    if args.screenshot and (Path(args.screenshot).suffix.lower()!=".png" or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):
        parser.error("use a new .png file in an existing directory")
    if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 02 / 05

大技と呼吸を足す

払う・回復する・上限。

札を、元のゲームの状況 B と同じ5種類に増やします。上の小技2枚、下の小技2枚、上の大技1枚、下の大技1枚、呼吸1枚です。相手の HP は8、最大7ラウンドです。まだ相手は守らず、攻撃はいつも当たる練習用のルールです。

小技はダメージ1・気力0、大技はダメージ3・気力2です。気力が足りない札は ENERGY として受け付けず、気力も時間も進みません。refusal で「使う前の条件」をまとめて調べ、画面の表示も同じ関数を使います。「使えない理由」と「使ったあとの予想」をまぜないことが大切です。

呼吸は気力を2増やし、4で止まります。すでに4でも、使ってよい1枚なので、札とラウンドを使って BREATH_FULL(気力満タン)になります。親切に注意を表示することはできても、勝手に「使えない操作」に変えてしまうと、元のゲームのルールとちがってしまいます。

札が残っていても、気力が足りない大技しかなく、回復する方法もなければ、続けられません。available は、残りの枚数と、気力が払えるかの両方を調べます。if hp==0 の成功を先に置き、そのあとで「期限が来たか」「使える札があるか」で失敗を判定します。

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

02_energy.py / 注目する部分
    energy = min(ENERGY_CAP, state.energy+2) if card==4 else state.energy-COST[card]

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_energy.py
macOS / Linux
.venv/bin/python 02_energy.py
画面で見ること上の小技・上の小技・上の大技・呼吸・下の大技の順に使います(札の番号 1,1,3,5,4 を選んで、それぞれ Z)。5ラウンドで HP 0、気力0、WON です。
  • 気力が足りないときは、状態が変わらない
  • 気力満タンでも、呼吸は札を使う
  • 使える札が残っているかで、終わりを判定する
つまずきやすいところ大技を選んだ時点で、気力を減らさないようにしましょう。選ぶのは何回変えても無料で、Z で使える札を使ったときだけ払います。
少し変えてみよう

INITIAL_ENERGY を2から0に変えてみましょう。上の大技を選んで Z を押しても、受け付けられません。呼吸 → 上の大技 なら、気力0・HP 5 になります。確かめたら2に戻します。

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

"""まねっこ決闘 教材2/5: 気力の支払い。ASCII表示、音声なし。"""
from dataclasses import dataclass, replace, asdict

STAGE = 2
NAMES = ("UP SMALL", "DOWN SMALL", "UP BIG", "DOWN BIG", "BREATHE")
SIDES = ("up", "down", "up", "down", None)
DAMAGE = (1, 1, 3, 3, 0)
COST = (0, 0, 2, 2, 0)
ENERGY_CAP = 4
INITIAL_ENERGY = 2
INITIAL_CARDS = (2, 2, 1, 1, 1)
INITIAL_HP = 8
ROUND_LIMIT = 7

@dataclass(frozen=True)
class State:
    cards: tuple = INITIAL_CARDS
    hp: int = INITIAL_HP
    energy: int = INITIAL_ENERGY
    round: int = 0
    status: str = "play"
    last: int = -1
    damage: int = 0
    reason: str = "ready"


def initial():
    return State()


def refusal(state, card):
    if state.status != "play":
        return "end"
    if type(card) is not int or card not in range(len(NAMES)):
        return "invalid"
    if state.cards[card] == 0:
        return "empty"
    if state.energy < COST[card]:
        return "energy"
    return ""


def step(state, card):
    if refusal(state, card):
        return state
    cards = list(state.cards)
    cards[card] -= 1
    energy = min(ENERGY_CAP, state.energy+2) if card==4 else state.energy-COST[card]
    damage = DAMAGE[card]
    hp = max(0, state.hp-damage)
    turn = state.round+1
    available = any(n and energy>=COST[j] for j,n in enumerate(cards))
    status = "won" if hp==0 else "lost" if turn>=ROUND_LIMIT or not available else "play"
    reason = "breath_full" if card==4 and state.energy==ENERGY_CAP else "breath" if card==4 else "hit"
    if status != "play":
        reason = "complete" if status=="won" else "remaining"
    return State(cards=tuple(cards), hp=hp, energy=energy, round=turn,
                 status=status, last=card, damage=damage, reason=reason)


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

    def reset(self):
        self.state = initial()
        self.selected = 0
        self.gated = False
        self.message = "READY"

    def control(self, pressed, held):
        if "R" in pressed:
            self.reset()
            self.gated = True
            return
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held)>1:
            self.gated = True
            self.message = "RELEASE ALL KEYS"
            return
        if len(pressed)!=1:
            return
        key = next(iter(pressed))
        self.gated = True
        if key in ("LEFT", "RIGHT"):
            self.selected = (self.selected+(-1 if key=="LEFT" else 1))%len(NAMES)
            self.message = "SELECT ONLY"
        elif key in tuple(str(n+1) for n in range(len(NAMES))):
            self.selected = int(key)-1
            self.message = "SELECT ONLY"
        elif key == "Z":
            why = refusal(self.state, self.selected)
            self.state = step(self.state, self.selected)
            self.message = why.upper() if why else self.state.reason.upper()

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(8,8,"MIMIC DUEL / STAGE "+str(STAGE),10)
        p.text(8,23,"HP "+str(s.hp)+" / "+str(INITIAL_HP)+"   ROUND "+str(s.round)+" / "+str(ROUND_LIMIT)+"   "+s.status.upper(),7)
        p.rect(8,37,240,8,5)
        p.rect(8,37,240*s.hp/INITIAL_HP,8,8)
        p.text(8,55,"ENERGY "+str(s.energy)+" / "+str(ENERGY_CAP),11)
        p.text(8,70,"PRACTICE: NO GUARD / NO LEARNING",6)
        for n,name in enumerate(NAMES):
            y = 106+n*23
            p.rect(8,y,400,20,2 if n==self.selected else 1)
            if n==self.selected:
                p.rectb(8,y,400,20,10)
            p.text(15,y+6,str(n+1)+" "+name+"   LEFT "+str(s.cards[n])+"   DMG "+str(DAMAGE[n]),7)
            p.text(276,y+6,"COST "+str(COST[n]),11)
        preview = step(s,self.selected)
        why = refusal(s,self.selected)
        p.text(8,232,"NEXT: "+(why.upper() if why else "HP "+str(preview.hp)+" / "+preview.reason.upper()),10)
        p.text(8,249,self.message,14)
        p.text(8,267,"ARROWS / NUMBER: SELECT   Z: USE   R: RESET",7)
        p.text(8,282,"ESC: QUIT   RELEASE KEYS BETWEEN ACTIONS",7)

DEMO_KEYS = ('1', 'Z', '1', 'Z', '3', 'Z', '5', 'Z', '4', 'Z')


def main(headless=False, screenshot=None, demo=False):
    import pyxel as p
    p.init(420,300,title="Mimic Duel lesson "+str(STAGE),fps=30,display_scale=2,headless=headless)
    app = App()
    keys = {"LEFT":p.KEY_LEFT,"RIGHT":p.KEY_RIGHT,"Z":p.KEY_Z,"R":p.KEY_R,"X":p.KEY_X}
    keys.update({str(n):getattr(p,"KEY_"+str(n)) for n in range(1,6)})
    limit = max(16,2*len(DEMO_KEYS)+6) if demo else 16
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:
                app.draw(p)
                p.screen.save(screenshot,1)
            p.quit()
        if headless:
            if demo:
                n=p.frame_count//2
                pressed={DEMO_KEYS[n]} if p.frame_count%2==0 and n<len(DEMO_KEYS) else set()
                app.control(pressed,pressed)
        else:
            app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))


if __name__ == "__main__":
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke",action="store_true")
    parser.add_argument("--demo-checkpoint",action="store_true")
    parser.add_argument("--screenshot")
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:
        parser.error("--demo-checkpoint requires --headless-smoke")
    if args.screenshot and not args.headless_smoke:
        parser.error("--screenshot requires --headless-smoke")
    if args.screenshot and (Path(args.screenshot).suffix.lower()!=".png" or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):
        parser.error("use a new .png file in an existing directory")
    if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 03 / 05

今回の守りを先に固定する

見えている情報だけで、相手の行動が決まる。

相手に2枚の防御札を持たせ、記録帳の「上0回・下2回」から、下を守らせます。記録帳で多いほうを守り、同じ数なら tie に書いた側を守ります。これは、元のゲームの状況 B から「記録帳の更新」だけを止めた、比較用のルールです。小さな「これまでの回数」の表で、人の過去のプレイを集める機能ではありません。

守っている側を攻撃して、防御札が残っていれば、防御札を1枚減らしてダメージは0です。反対側への攻撃は当たりますが、防御札は減りません。防御札が0なら、守っている向きと同じ攻撃も当たります。呼吸は防御札に関係ありません。

今回の guard(守る向き)は、札を使う前の State にもう入っています。「カーソルで上の大技を選んだから、相手が上を守るように切りかえる」といった、かくれた後出しにはしません。画面の NEXT も、見えている状態と選んだ札だけから計算します。

札を 1,1,3,5,4 の順に使うと、最後の下の大技が防がれて、HP 3 が残ります。ただし、この固定版がクリアできないわけではありません。3,1,1,2,2,5,4 の7枚なら、2回の下の小技で防御札を使わせて、最後の下の大技が当たります。04 と比べるときは、同じ最初の状態・同じ5枚にそろえましょう。

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

03_guard.py / 注目する部分
    blocked = SIDES[card] is not None and state.defense>0 and SIDES[card]==state.guard
    damage = 0 if blocked else DAMAGE[card]
    defense = state.defense-int(blocked)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_guard.py
macOS / Linux
.venv/bin/python 03_guard.py
画面で見ること札の番号 1,1,3,5,4 をそれぞれ Z で使います。5ラウンドで HP 3、守りは DOWN(下)、防御札1枚で、まだ PLAY です。固定版でも、7枚を使う別の順番なら成功します。
  • 記録帳から守る向きを決める
  • カーソルを動かしても、相手の状態は変わらない
  • 防御札が0なら、攻撃が当たる
つまずきやすいところ防御札を減らすのは、守りに成功したときだけです。上への攻撃が当たったのに防御札も減らすと、資源の意味が変わってしまいます。
少し変えてみよう

R で最初に戻し、下の小技を2枚使うと、HP 8・防御札0 になります。続けて下の大技を使うと、ダメージ3で HP 5 です。同じ向きの攻撃でも、防御札があるかどうかで結果が変わります。

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

"""まねっこ決闘 教材3/5: 固定の守り。ASCII表示、音声なし。"""
from dataclasses import dataclass, replace, asdict

STAGE = 3
NAMES = ("UP SMALL", "DOWN SMALL", "UP BIG", "DOWN BIG", "BREATHE")
SIDES = ("up", "down", "up", "down", None)
DAMAGE = (1, 1, 3, 3, 0)
COST = (0, 0, 2, 2, 0)
ENERGY_CAP = 4
INITIAL_ENERGY = 2
INITIAL_CARDS = (2, 2, 1, 1, 1)
INITIAL_HP = 8
ROUND_LIMIT = 7

@dataclass(frozen=True)
class Memory:
    up: int = 0
    down: int = 2
    tie: str = "down"


def policy(memory):
    if memory.up > memory.down:
        return "up"
    if memory.down > memory.up:
        return "down"
    return memory.tie

@dataclass(frozen=True)
class State:
    cards: tuple = INITIAL_CARDS
    hp: int = INITIAL_HP
    energy: int = INITIAL_ENERGY
    defense: int = 2
    memory: Memory = Memory()
    guard: str = policy(Memory())
    round: int = 0
    status: str = "play"
    last: int = -1
    damage: int = 0
    blocked: bool = False
    reason: str = "ready"


def initial():
    return State()


def refusal(state, card):
    if state.status != "play":
        return "end"
    if type(card) is not int or card not in range(len(NAMES)):
        return "invalid"
    if state.cards[card] == 0:
        return "empty"
    if state.energy < COST[card]:
        return "energy"
    return ""


def step(state, card):
    if refusal(state, card):
        return state
    cards = list(state.cards)
    cards[card] -= 1
    energy = min(ENERGY_CAP, state.energy+2) if card==4 else state.energy-COST[card]
    blocked = SIDES[card] is not None and state.defense>0 and SIDES[card]==state.guard
    damage = 0 if blocked else DAMAGE[card]
    defense = state.defense-int(blocked)
    hp = max(0, state.hp-damage)
    turn = state.round+1
    memory = state.memory
    available = any(n and energy>=COST[j] for j,n in enumerate(cards))
    status = "won" if hp==0 else "lost" if turn>=ROUND_LIMIT or not available else "play"
    reason = "breath_full" if card==4 and state.energy==ENERGY_CAP else "breath" if card==4 else "blocked" if blocked else "hit"
    if status != "play":
        reason = "complete" if status=="won" else "remaining"
    return State(cards=tuple(cards), hp=hp, energy=energy, defense=defense, memory=memory, guard=policy(memory), round=turn,
                 status=status, last=card, damage=damage, blocked=blocked, reason=reason)


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

    def reset(self):
        self.state = initial()
        self.selected = 0
        self.gated = False
        self.message = "READY"

    def control(self, pressed, held):
        if "R" in pressed:
            self.reset()
            self.gated = True
            return
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held)>1:
            self.gated = True
            self.message = "RELEASE ALL KEYS"
            return
        if len(pressed)!=1:
            return
        key = next(iter(pressed))
        self.gated = True
        if key in ("LEFT", "RIGHT"):
            self.selected = (self.selected+(-1 if key=="LEFT" else 1))%len(NAMES)
            self.message = "SELECT ONLY"
        elif key in tuple(str(n+1) for n in range(len(NAMES))):
            self.selected = int(key)-1
            self.message = "SELECT ONLY"
        elif key == "Z":
            why = refusal(self.state, self.selected)
            self.state = step(self.state, self.selected)
            self.message = why.upper() if why else self.state.reason.upper()

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(8,8,"MIMIC DUEL / STAGE "+str(STAGE),10)
        p.text(8,23,"HP "+str(s.hp)+" / "+str(INITIAL_HP)+"   ROUND "+str(s.round)+" / "+str(ROUND_LIMIT)+"   "+s.status.upper(),7)
        p.rect(8,37,240,8,5)
        p.rect(8,37,240*s.hp/INITIAL_HP,8,8)
        p.text(8,55,"ENERGY "+str(s.energy)+" / "+str(ENERGY_CAP),11)
        p.text(8,70,"GUARD "+s.guard.upper()+"   DEFENSE "+str(s.defense),10)
        p.text(8,85,"PUBLIC COUNTS: UP "+str(s.memory.up)+" / DOWN "+str(s.memory.down)+"   TIE: "+s.memory.tie.upper(),6)
        p.text(264,55,"COUNTS FROZEN",11)
        for n,name in enumerate(NAMES):
            y = 106+n*23
            p.rect(8,y,400,20,2 if n==self.selected else 1)
            if n==self.selected:
                p.rectb(8,y,400,20,10)
            p.text(15,y+6,str(n+1)+" "+name+"   LEFT "+str(s.cards[n])+"   DMG "+str(DAMAGE[n]),7)
            p.text(276,y+6,"COST "+str(COST[n]),11)
        preview = step(s,self.selected)
        why = refusal(s,self.selected)
        p.text(8,232,"NEXT: "+(why.upper() if why else "HP "+str(preview.hp)+" / "+preview.reason.upper()),10)
        p.text(8,249,self.message,14)
        p.text(8,267,"ARROWS / NUMBER: SELECT   Z: USE   R: RESET",7)
        p.text(8,282,"ESC: QUIT   RELEASE KEYS BETWEEN ACTIONS",7)

DEMO_KEYS = ('1', 'Z', '1', 'Z', '3', 'Z', '5', 'Z', '4', 'Z')


def main(headless=False, screenshot=None, demo=False):
    import pyxel as p
    p.init(420,300,title="Mimic Duel lesson "+str(STAGE),fps=30,display_scale=2,headless=headless)
    app = App()
    keys = {"LEFT":p.KEY_LEFT,"RIGHT":p.KEY_RIGHT,"Z":p.KEY_Z,"R":p.KEY_R,"X":p.KEY_X}
    keys.update({str(n):getattr(p,"KEY_"+str(n)) for n in range(1,6)})
    limit = max(16,2*len(DEMO_KEYS)+6) if demo else 16
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:
                app.draw(p)
                p.screen.save(screenshot,1)
            p.quit()
        if headless:
            if demo:
                n=p.frame_count//2
                pressed={DEMO_KEYS[n]} if p.frame_count%2==0 and n<len(DEMO_KEYS) else set()
                app.control(pressed,pressed)
        else:
            app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))


if __name__ == "__main__":
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke",action="store_true")
    parser.add_argument("--demo-checkpoint",action="store_true")
    parser.add_argument("--screenshot")
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:
        parser.error("--demo-checkpoint requires --headless-smoke")
    if args.screenshot and not args.headless_smoke:
        parser.error("--screenshot requires --headless-smoke")
    if args.screenshot and (Path(args.screenshot).suffix.lower()!=".png" or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):
        parser.error("use a new .png file in an existing directory")
    if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 04 / 05

使った後に手帖を更新する

見る → 覚える → 次の守りを決める、の順番。

元のゲームの状況 B のふつうのルールを完成させます。攻撃を解決したあとで、その札の上か下かを記録帳に1回だけ足し、新しい記録帳から次のラウンドの guard を決めます。小技も大技も1回と数えます。ダメージの量を回数に足すのではありません。

上の小技を2回使うと、記録帳は上2・下2 です。同じ数のときは下を優先するので、今回の守りはまだ DOWN です。次の上の大技は、その DOWN の守りに対して解決されるので、ダメージ3が当たります。そのあと記録帳は上3・下2 になり、次の守りが UP(上)に変わります。

呼吸は、上でも下でもない None です。記録帳と防御札は変えずに、気力を回復します。最後に下の大技を出すと、UP の反対なので当たり、5枚で WON です。防がれた攻撃も、相手から見える上・下の1手なので、記録帳は増えます。

learn に渡すのは、攻撃した向きと、これまでの記録帳だけです。残りの札、選んでいるカーソル、まだ使っていない予定は渡しません。こうすることで、「相手が何を知っているか」をコードで決められます。これは回数を足していくだけのルールで、古い手を忘れたり、まだ使っていない札を推理したりする、高度な AI ではありません。

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

04_learning.py / 注目する部分
    memory = learn(SIDES[card], state.memory)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_learning.py
macOS / Linux
.venv/bin/python 04_learning.py
画面で見ること同じ 1,1,3,5,4 の5枚で WON です。3枚目の上の大技のあとは、HP 3、気力0、記録帳 UP3/DOWN2、次の守りは UP です。
  • 今の守りで先に解決する
  • 防がれても、1回は記録する
  • 次の守りは最後に更新する
つまずきやすいところ先に記録帳を増やし、その新しい守りで今の攻撃を防がせると、後出しになってしまいます。「古い guard で解決 → 見えた攻撃を1回記録 → 次の guard」の順番です。
少し変えてみよう

03 と 04 で同じ5枚を使い、03 は HP 3・PLAY、04 は HP 0・WON になることを比べましょう。ちがいは、記録帳を更新するかどうかだけです。固定版にも、別の成功の手順があることもあわせて確かめます。

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

"""まねっこ決闘 教材4/5: 公開手帖の更新。ASCII表示、音声なし。"""
from dataclasses import dataclass, replace, asdict

STAGE = 4
NAMES = ("UP SMALL", "DOWN SMALL", "UP BIG", "DOWN BIG", "BREATHE")
SIDES = ("up", "down", "up", "down", None)
DAMAGE = (1, 1, 3, 3, 0)
COST = (0, 0, 2, 2, 0)
ENERGY_CAP = 4
INITIAL_ENERGY = 2
INITIAL_CARDS = (2, 2, 1, 1, 1)
INITIAL_HP = 8
ROUND_LIMIT = 7

@dataclass(frozen=True)
class Memory:
    up: int = 0
    down: int = 2
    tie: str = "down"


def policy(memory):
    if memory.up > memory.down:
        return "up"
    if memory.down > memory.up:
        return "down"
    return memory.tie


def learn(side, memory):
    if side is None:
        return memory
    return replace(memory, up=memory.up+(side=="up"),
                   down=memory.down+(side=="down"))

@dataclass(frozen=True)
class State:
    cards: tuple = INITIAL_CARDS
    hp: int = INITIAL_HP
    energy: int = INITIAL_ENERGY
    defense: int = 2
    memory: Memory = Memory()
    guard: str = policy(Memory())
    round: int = 0
    status: str = "play"
    last: int = -1
    damage: int = 0
    blocked: bool = False
    reason: str = "ready"


def initial():
    return State()


def refusal(state, card):
    if state.status != "play":
        return "end"
    if type(card) is not int or card not in range(len(NAMES)):
        return "invalid"
    if state.cards[card] == 0:
        return "empty"
    if state.energy < COST[card]:
        return "energy"
    return ""


def step(state, card):
    if refusal(state, card):
        return state
    cards = list(state.cards)
    cards[card] -= 1
    energy = min(ENERGY_CAP, state.energy+2) if card==4 else state.energy-COST[card]
    blocked = SIDES[card] is not None and state.defense>0 and SIDES[card]==state.guard
    damage = 0 if blocked else DAMAGE[card]
    defense = state.defense-int(blocked)
    hp = max(0, state.hp-damage)
    turn = state.round+1
    memory = learn(SIDES[card], state.memory)
    available = any(n and energy>=COST[j] for j,n in enumerate(cards))
    status = "won" if hp==0 else "lost" if turn>=ROUND_LIMIT or not available else "play"
    reason = "breath_full" if card==4 and state.energy==ENERGY_CAP else "breath" if card==4 else "blocked" if blocked else "hit"
    if status != "play":
        reason = "complete" if status=="won" else "remaining"
    return State(cards=tuple(cards), hp=hp, energy=energy, defense=defense, memory=memory, guard=policy(memory), round=turn,
                 status=status, last=card, damage=damage, blocked=blocked, reason=reason)


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

    def reset(self):
        self.state = initial()
        self.selected = 0
        self.gated = False
        self.message = "READY"

    def control(self, pressed, held):
        if "R" in pressed:
            self.reset()
            self.gated = True
            return
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held)>1:
            self.gated = True
            self.message = "RELEASE ALL KEYS"
            return
        if len(pressed)!=1:
            return
        key = next(iter(pressed))
        self.gated = True
        if key in ("LEFT", "RIGHT"):
            self.selected = (self.selected+(-1 if key=="LEFT" else 1))%len(NAMES)
            self.message = "SELECT ONLY"
        elif key in tuple(str(n+1) for n in range(len(NAMES))):
            self.selected = int(key)-1
            self.message = "SELECT ONLY"
        elif key == "Z":
            why = refusal(self.state, self.selected)
            self.state = step(self.state, self.selected)
            self.message = why.upper() if why else self.state.reason.upper()

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(8,8,"MIMIC DUEL / STAGE "+str(STAGE),10)
        p.text(8,23,"HP "+str(s.hp)+" / "+str(INITIAL_HP)+"   ROUND "+str(s.round)+" / "+str(ROUND_LIMIT)+"   "+s.status.upper(),7)
        p.rect(8,37,240,8,5)
        p.rect(8,37,240*s.hp/INITIAL_HP,8,8)
        p.text(8,55,"ENERGY "+str(s.energy)+" / "+str(ENERGY_CAP),11)
        p.text(8,70,"GUARD "+s.guard.upper()+"   DEFENSE "+str(s.defense),10)
        p.text(8,85,"PUBLIC COUNTS: UP "+str(s.memory.up)+" / DOWN "+str(s.memory.down)+"   TIE: "+s.memory.tie.upper(),6)
        p.text(264,55,"COUNTS LEARN",11)
        for n,name in enumerate(NAMES):
            y = 106+n*23
            p.rect(8,y,400,20,2 if n==self.selected else 1)
            if n==self.selected:
                p.rectb(8,y,400,20,10)
            p.text(15,y+6,str(n+1)+" "+name+"   LEFT "+str(s.cards[n])+"   DMG "+str(DAMAGE[n]),7)
            p.text(276,y+6,"COST "+str(COST[n]),11)
        preview = step(s,self.selected)
        why = refusal(s,self.selected)
        p.text(8,232,"NEXT: "+(why.upper() if why else "HP "+str(preview.hp)+" / "+preview.reason.upper()),10)
        p.text(8,249,self.message,14)
        p.text(8,267,"ARROWS / NUMBER: SELECT   Z: USE   R: RESET",7)
        p.text(8,282,"ESC: QUIT   RELEASE KEYS BETWEEN ACTIONS",7)

DEMO_KEYS = ('1', 'Z', '1', 'Z', '3', 'Z', '5', 'Z', '4', 'Z')


def main(headless=False, screenshot=None, demo=False):
    import pyxel as p
    p.init(420,300,title="Mimic Duel lesson "+str(STAGE),fps=30,display_scale=2,headless=headless)
    app = App()
    keys = {"LEFT":p.KEY_LEFT,"RIGHT":p.KEY_RIGHT,"Z":p.KEY_Z,"R":p.KEY_R,"X":p.KEY_X}
    keys.update({str(n):getattr(p,"KEY_"+str(n)) for n in range(1,6)})
    limit = max(16,2*len(DEMO_KEYS)+6) if demo else 16
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:
                app.draw(p)
                p.screen.save(screenshot,1)
            p.quit()
        if headless:
            if demo:
                n=p.frame_count//2
                pressed={DEMO_KEYS[n]} if p.frame_count%2==0 and n<len(DEMO_KEYS) else set()
                app.control(pressed,pressed)
        else:
            app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))


if __name__ == "__main__":
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke",action="store_true")
    parser.add_argument("--demo-checkpoint",action="store_true")
    parser.add_argument("--screenshot")
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:
        parser.error("--demo-checkpoint requires --headless-smoke")
    if args.screenshot and not args.headless_smoke:
        parser.error("--screenshot requires --headless-smoke")
    if args.screenshot and (Path(args.screenshot).suffix.lower()!=".png" or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):
        parser.error("use a new .png file in an existing directory")
    if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 05 / 05

手帖も防御札も丸ごと戻す

すべての状態の履歴と、分かれ道。

Timeline に、今の State と過去の State を入れます。使ってよい札を使って State が変わったときだけ、使う前の State を履歴に追加します。選択の変更や、気力不足・残り0枚で受け付けなかった操作では、履歴は増やしません。

X は HP だけでなく、札・気力・防御札・記録帳の上と下の回数・守る向き・ラウンド・結果も、一緒に戻します。記録帳だけ戻し忘れると、巻き戻した相手が「未来を知っている」状態になってしまいます。勝ち負けが決まったあとも、X を先に受け付けるので、最後の1枚をやり直せます。

R は、最初の状態と空の履歴に戻します。R を押した瞬間はほかのキーより優先し、再開したあとは全部のキーを離すまで待ちます。ふつうの操作で複数のキーを同時に押すと、受け付けません。これは教材用の短い制御で、元のゲームの表紙・一時停止・面の選択の入力制御を、すべて作ったものではありません。

テストでは、元のゲームの状況 B のふつうのルール(185の状態・1295通りの変化の候補)と、記録帳を固定した版(178の状態・1246通りの変化の候補)を、別々に照らし合わせています。最後の札や表示用の理由も状態として数えているので、元のゲームの探索で数えた件数とはちがいます。改造したすべての面や、どこまでも続く Undo の履歴が正しいことを証明したものではありません。状況 A・C や比較の実験に進みたいときは、元のゲームの LEVELS・step・experiment.py を読んでください。

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

05_undo.py / 注目する部分
    return Timeline(state, timeline.history+(timeline.state,))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_undo.py
macOS / Linux
.venv/bin/python 05_undo.py
画面で見ること5枚で WON にしてから X を押します。4ラウンド目・HP 3・気力2・記録帳 UP3/DOWN2・守り UP に戻り、下の大技が1枚戻ります。もう一度 Z を押すと、同じ勝ちになります。
  • すべての状態を一緒に保存する
  • 受け付けなかった操作と、選ぶだけの操作は、履歴に入れない
  • 結果が出たあとでも1手戻せる
つまずきやすいところカードの選択は履歴に入れません。X はカーソルを戻す操作ではなく、受け付けた1枚の効果を戻す操作です。
少し変えてみよう

上の小技2枚 → 上の大技 で、守り UP・気力0 になります。X を押すと、記録帳 UP2/DOWN2・守り DOWN・気力2 に戻ります。ここで下の小技を選び直して Z を押すと防がれ、元の上の大技とは別の分かれ道になります。

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

"""まねっこ決闘 教材5/5: 手帖ごとのUndo。ASCII表示、音声なし。"""
from dataclasses import dataclass, replace, asdict

STAGE = 5
NAMES = ("UP SMALL", "DOWN SMALL", "UP BIG", "DOWN BIG", "BREATHE")
SIDES = ("up", "down", "up", "down", None)
DAMAGE = (1, 1, 3, 3, 0)
COST = (0, 0, 2, 2, 0)
ENERGY_CAP = 4
INITIAL_ENERGY = 2
INITIAL_CARDS = (2, 2, 1, 1, 1)
INITIAL_HP = 8
ROUND_LIMIT = 7

@dataclass(frozen=True)
class Memory:
    up: int = 0
    down: int = 2
    tie: str = "down"


def policy(memory):
    if memory.up > memory.down:
        return "up"
    if memory.down > memory.up:
        return "down"
    return memory.tie


def learn(side, memory):
    if side is None:
        return memory
    return replace(memory, up=memory.up+(side=="up"),
                   down=memory.down+(side=="down"))

@dataclass(frozen=True)
class State:
    cards: tuple = INITIAL_CARDS
    hp: int = INITIAL_HP
    energy: int = INITIAL_ENERGY
    defense: int = 2
    memory: Memory = Memory()
    guard: str = policy(Memory())
    round: int = 0
    status: str = "play"
    last: int = -1
    damage: int = 0
    blocked: bool = False
    reason: str = "ready"


def initial():
    return State()


def refusal(state, card):
    if state.status != "play":
        return "end"
    if type(card) is not int or card not in range(len(NAMES)):
        return "invalid"
    if state.cards[card] == 0:
        return "empty"
    if state.energy < COST[card]:
        return "energy"
    return ""


def step(state, card):
    if refusal(state, card):
        return state
    cards = list(state.cards)
    cards[card] -= 1
    energy = min(ENERGY_CAP, state.energy+2) if card==4 else state.energy-COST[card]
    blocked = SIDES[card] is not None and state.defense>0 and SIDES[card]==state.guard
    damage = 0 if blocked else DAMAGE[card]
    defense = state.defense-int(blocked)
    hp = max(0, state.hp-damage)
    turn = state.round+1
    memory = learn(SIDES[card], state.memory)
    available = any(n and energy>=COST[j] for j,n in enumerate(cards))
    status = "won" if hp==0 else "lost" if turn>=ROUND_LIMIT or not available else "play"
    reason = "breath_full" if card==4 and state.energy==ENERGY_CAP else "breath" if card==4 else "blocked" if blocked else "hit"
    if status != "play":
        reason = "complete" if status=="won" else "remaining"
    return State(cards=tuple(cards), hp=hp, energy=energy, defense=defense, memory=memory, guard=policy(memory), round=turn,
                 status=status, last=card, damage=damage, blocked=blocked, reason=reason)

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


def apply(timeline, card):
    state = step(timeline.state, card)
    if state == timeline.state:
        return timeline
    return Timeline(state, timeline.history+(timeline.state,))


def undo(timeline):
    if not timeline.history:
        return timeline
    return Timeline(timeline.history[-1], timeline.history[:-1])


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

    def reset(self):
        self.timeline = Timeline(initial())
        self.selected = 0
        self.gated = False
        self.message = "READY"

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

    def control(self, pressed, held):
        if "R" in pressed:
            self.reset()
            self.gated = True
            return
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held)>1:
            self.gated = True
            self.message = "RELEASE ALL KEYS"
            return
        if len(pressed)!=1:
            return
        key = next(iter(pressed))
        self.gated = True
        if key == "X":
            previous = self.timeline
            self.timeline = undo(self.timeline)
            self.message = "UNDO" if previous!=self.timeline else "NO HISTORY"
            return
        if key in ("LEFT", "RIGHT"):
            self.selected = (self.selected+(-1 if key=="LEFT" else 1))%len(NAMES)
            self.message = "SELECT ONLY"
        elif key in tuple(str(n+1) for n in range(len(NAMES))):
            self.selected = int(key)-1
            self.message = "SELECT ONLY"
        elif key == "Z":
            why = refusal(self.state, self.selected)
            self.timeline = apply(self.timeline, self.selected)
            self.message = why.upper() if why else self.state.reason.upper()

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(8,8,"MIMIC DUEL / STAGE "+str(STAGE),10)
        p.text(8,23,"HP "+str(s.hp)+" / "+str(INITIAL_HP)+"   ROUND "+str(s.round)+" / "+str(ROUND_LIMIT)+"   "+s.status.upper(),7)
        p.rect(8,37,240,8,5)
        p.rect(8,37,240*s.hp/INITIAL_HP,8,8)
        p.text(8,55,"ENERGY "+str(s.energy)+" / "+str(ENERGY_CAP),11)
        p.text(8,70,"GUARD "+s.guard.upper()+"   DEFENSE "+str(s.defense),10)
        p.text(8,85,"PUBLIC COUNTS: UP "+str(s.memory.up)+" / DOWN "+str(s.memory.down)+"   TIE: "+s.memory.tie.upper(),6)
        p.text(264,55,"COUNTS LEARN",11)
        for n,name in enumerate(NAMES):
            y = 106+n*23
            p.rect(8,y,400,20,2 if n==self.selected else 1)
            if n==self.selected:
                p.rectb(8,y,400,20,10)
            p.text(15,y+6,str(n+1)+" "+name+"   LEFT "+str(s.cards[n])+"   DMG "+str(DAMAGE[n]),7)
            p.text(276,y+6,"COST "+str(COST[n]),11)
        preview = step(s,self.selected)
        why = refusal(s,self.selected)
        p.text(8,232,"NEXT: "+(why.upper() if why else "HP "+str(preview.hp)+" / "+preview.reason.upper()),10)
        p.text(8,249,self.message,14)
        p.text(8,267,"ARROWS / NUMBER: SELECT   Z: USE   R: RESET",7)
        p.text(8,282,"X: UNDO   HISTORY "+str(len(self.timeline.history))+"   ESC: QUIT",7)

DEMO_KEYS = ('1', 'Z', '1', 'Z', '3', 'Z', '5', 'Z', '4', 'Z', 'X')


def main(headless=False, screenshot=None, demo=False):
    import pyxel as p
    p.init(420,300,title="Mimic Duel lesson "+str(STAGE),fps=30,display_scale=2,headless=headless)
    app = App()
    keys = {"LEFT":p.KEY_LEFT,"RIGHT":p.KEY_RIGHT,"Z":p.KEY_Z,"R":p.KEY_R,"X":p.KEY_X}
    keys.update({str(n):getattr(p,"KEY_"+str(n)) for n in range(1,6)})
    limit = max(16,2*len(DEMO_KEYS)+6) if demo else 16
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:
                app.draw(p)
                p.screen.save(screenshot,1)
            p.quit()
        if headless:
            if demo:
                n=p.frame_count//2
                pressed={DEMO_KEYS[n]} if p.frame_count%2==0 and n<len(DEMO_KEYS) else set()
                app.control(pressed,pressed)
        else:
            app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))


if __name__ == "__main__":
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke",action="store_true")
    parser.add_argument("--demo-checkpoint",action="store_true")
    parser.add_argument("--screenshot")
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:
        parser.error("--demo-checkpoint requires --headless-smoke")
    if args.screenshot and not args.headless_smoke:
        parser.error("--screenshot requires --headless-smoke")
    if args.screenshot and (Path(args.screenshot).suffix.lower()!=".png" or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):
        parser.error("use a new .png file in an existing directory")
    if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
AFTER STEP 05

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

元のゲームの状況 B です。札 (2,2,1,1,1)、HP 8、気力2、最大7ラウンド、防御札2枚、最初の記録帳は上0・下2で、同じ数なら下を守ります。今回の守りで攻撃を解決したあと、攻撃した向きを1回記録し、次の守りを決めます。

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

比べる点5ステップの教材元の完成版
段階01 は HP 3・小技だけ、02 は HP 8・5種類の札で守りなし、03 は状況 B で記録帳を固定した比較版、04 と 05 は状況 B のふつうのルールです。A・B・C の3人の相手、記録帳を固定した比較と、限られた範囲の探索があります。
操作と表示文字(ASCII)で数字と札の一覧を表示します。数字キーで札を選び、R を優先し、複数の操作は受け付けずに全部離すのを待ちます。選択は Undo しません。日本語の文字、表紙・一時停止・面の選択があります。数字の1〜3で面を切りかえます。
記録05 は、起動している間の Undo だけです。ファイルの保存や、別の面への移動はありません。音もありません。再演による確認、比較の実験、複数の面の自動の手順があります。

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

  1. model.py: policy / learn は相手の知識を限定
  2. model.py: step の守り→観測→次guard
  3. model.py: LEVELS の資源と制限
  4. experiment.py: Bの通常と手帖固定の一変更比較
自分のゲームに変えるなら

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

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