完成サンプルをブラウザーで遊ぶ
この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。
完成版の主な操作:左かAで後ろへ、上かZで斬る、右かDで前へ、Spaceでその場で止まる、Xで1手戻す、Rでやり直し、Pで一時停止、1〜3で選択
ボタンを押すと、ゲームの読み込みが始まります。
画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。
うまく表示されないとき
ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。
記録を確定してから、次の行動を決める。
いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、5つのステップで作っていきます。どのステップのファイルもそれだけで動くので、動きを確かめてから次へ進みましょう。
- 必要な環境
- Python 3.11以上 / Pyxel 2.9.9
- 知っておくとよいこと
- Python 3.11以上と、仮想環境の起動(古い個別のREADMEにある3.10という表記より、共通の要件を優先してください) / 変数・if・for・関数・辞書の基礎 / リストやタプルの順番、[-3:]、count、reversed は本文の例で学びます / dataclass・frozen・replace は本文で役割を説明します。クラスは「データと操作をまとめる箱」と考えれば大丈夫です。
- 教材に入っているもの
- ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
- 完成した教材の操作
- 左/A:後ろへ / 右/D:前へ / Space:止まる / 上/Z:斬る(02から) / X:1手戻す(05) / R:やり直し / Esc:終了
- 難易度の目安
- 初級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
- 学習順に迷ったら
- おすすめの学ぶ順番を見る
コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。
教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。
9マスの間合いを作る
座標・移動の候補・盤の外への入力を分ける。
自分は3、相手は6のマスから始めます。右(F)は+1、左(B)は-1、止まる(W)は0です。candidate で仮の位置を作り、1〜9の外なら元の State を返します。相手と同じマスに入ろうとする前進ではその場に止まりますが、有効な1手なので turns(手数)を増やします。State はマスの番号だけを持ち、画面のピクセルの座標は fighter で計算します。この段階は移動の練習で、攻撃・体力・勝ち負け・記憶はありません。
下のコードは 01_distance.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if not 1 <= candidate <= 9:
return s # 盤外入力は手数を使わない。
if candidate >= s.enemy:
candidate = s.player # 同じマスへ入れない。有効な1手として数える。実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 01_distance.py.venv/bin/python 01_distance.py- 右2回で距離1になる
- 相手にぶつかる前進と、盤の外への後退を区別できる
- 押しっぱなしでも、勝手に連打されない
return にすると、ぶつかった手まで消えてしまいます。元のゲームでは、盤の外だけが無効で、ぶつかった前進は記録に残る有効な手です。最初の State の player を3から2に変えて、相手のとなりの5に着くまで右を何回押すか予想しましょう。元に戻したら、ぶつかる前進では手数が増え、左端への後退では増えないことを説明してみましょう。
このステップのコード全文(181行)
# MIT License
#
# Copyright (c) 2026 Pyxel 100 contributors
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
# この教材はNoto字形を同梱せず、ASCIIと図形のみで描画します。
"""三手の剣舞・教材 1/5。単独で実行できる段階別プログラム。"""
from dataclasses import dataclass, replace, asdict
ACTIONS = ('F', 'B', 'W')
@dataclass(frozen=True)
class State:
player: int = 3
enemy: int = 6
turns: int = 0
def initial():
return State()
def transition(s, action):
if action not in ACTIONS:
return s
candidate = s.player + {'F': 1, 'B': -1, 'W': 0}[action]
if not 1 <= candidate <= 9:
return s # 盤外入力は手数を使わない。
if candidate >= s.enemy:
candidate = s.player # 同じマスへ入れない。有効な1手として数える。
return State(candidate, s.enemy, s.turns + 1)
class Game:
def __init__(self):
self.reset()
def reset(self):
self.state = initial()
def act(self, action):
self.state = transition(self.state, action)
KEY_ACTIONS = {'LEFT': 'B', 'A': 'B', 'RIGHT': 'F', 'D': 'F', 'SPACE': 'W'}
class Controller:
"""Pyxelの押下とゲーム1手を分離。合成入力でも同じ窓口を使う。"""
def __init__(self):
self.game = Game()
self.release_gate = False
def tick(self, pressed=(), held=()):
pressed, held = set(pressed), set(held)
controls = set(KEY_ACTIONS) | {'R', 'X'}
if self.release_gate:
if not (held & controls):
self.release_gate = False
return
if 'R' in pressed:
self.game.reset()
self.release_gate = True
return
chosen = {action for key, action in KEY_ACTIONS.items() if key in pressed}
held_actions = {action for key, action in KEY_ACTIONS.items() if key in held}
if len(chosen) == 1 and len(held_actions) == 1:
self.game.act(next(iter(chosen)))
def main(headless=False, screenshot=None, actions='', frames=12, report=None):
import json
from pathlib import Path
# Pyxel初期化によるcwd変更前に利用者の出力先を確定する。
screenshot = str(Path(screenshot).resolve()) if screenshot else None
report = str(Path(report).resolve()) if report else None
import pyxel
controller = Controller()
events = []
tick = 0
keys = {key: getattr(pyxel, 'KEY_' + key) for key in (*KEY_ACTIONS, 'R', 'X')}
replay_keys = {'F': 'RIGHT', 'B': 'LEFT', 'W': 'SPACE', 'S': 'Z', 'K': 'R', 'X': 'X'}
def update():
nonlocal tick
tick += 1
if headless:
if tick <= len(actions):
# QA専用の合成押下。OSキーボード操作・人の試遊ではない。
key = replay_keys[actions[tick - 1]]
controller.tick((), ())
controller.tick((key,), (key,))
events.append({'tick': tick, 'action': actions[tick - 1],
'state': asdict(controller.game.state)})
else:
controller.tick([key for key, value in keys.items() if pyxel.btnp(value)],
[key for key, value in keys.items() if pyxel.btn(value)])
def fighter(position, y, color, direction):
x = 32 + (position - 1) * 42
pyxel.circ(x, y - 13, 5, color)
pyxel.line(x, y - 7, x, y + 8, color)
pyxel.line(x, y + 8, x - 6, y + 16, color)
pyxel.line(x, y + 8, x + 6, y + 16, color)
pyxel.line(x, y - 3, x + direction * 12, y - 9, color)
pyxel.line(x + direction * 12, y - 9, x + direction * 21, y - 20, 7)
def draw():
s = controller.game.state
pyxel.cls(1)
pyxel.text(10, 8, 'THREE MOVE DANCE / STEP 1', 10)
pyxel.text(10, 21, 'TRAINING: MOVEMENT ONLY', 13)
pyxel.text(10, 35, f'TURN {min(s.turns,9999)} DISTANCE {s.enemy - s.player}', 7)
pyxel.text(10, 48, 'TARGET: REACH CELL 5; NO DAMAGE YET', 6)
pyxel.text(10, 74, 'HISTORY: NOT ADDED IN THIS STAGE', 5)
pyxel.text(10, 109, 'ENEMY DOES NOT MOVE OR ATTACK.', 13)
for position in range(1, 10):
x = 32 + (position - 1) * 42
pyxel.rectb(x - 19, 133, 38, 57, 5)
pyxel.text(x - 2, 194, str(position), 13)
fighter(s.player, 166, 6, 1)
fighter(s.enemy, 161, 8, -1)
pyxel.text(10, 216, 'F/B: ONE CELL. W: ONE TURN WITHOUT MOVING.', 6)
pyxel.text(10, 228, 'WALL INPUT: NO TURN. CONTACT: STAY, USE TURN.', 13)
pyxel.text(10, 252, 'LEFT/A: BACK RIGHT/D: FORWARD SPACE: WAIT', 7)
pyxel.text(10, 266, 'R: RESET ESC: QUIT', 7)
pyxel.text(10, 284, 'NO TIME LIMIT / SILENT / R TO RESTART', 13)
if headless and tick >= frames:
if screenshot:
pyxel.screenshot(screenshot, scale=2)
if report:
Path(report).write_text(json.dumps({
'label': 'Synthetic input; actual Pyxel headless render; silent',
'frames': tick, 'actions': actions, 'events': events,
'final_state': asdict(s),
}, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
pyxel.quit()
pyxel.init(400, 302, title='Three Move Dance - Stage 1', fps=30, headless=headless)
pyxel.run(update, draw)
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--headless-smoke', action='store_true')
parser.add_argument('--headless-replay', help='Synthetic actions; K resets; not OS keys')
parser.add_argument('--actions', default='', help='Synthetic actions within a 12-frame smoke')
parser.add_argument('--screenshot', help='PNG path, headless only')
parser.add_argument('--report', help='UTF-8 JSON path, headless only')
args = parser.parse_args()
if args.headless_smoke and args.headless_replay is not None:
parser.error('Choose smoke or replay, not both')
actions = args.actions if args.headless_replay is None else args.headless_replay
if any(action not in 'FBWK' for action in actions):
parser.error('Supported replay actions: FBWK')
headless = args.headless_smoke or args.headless_replay is not None
if not headless and (args.actions or args.screenshot or args.report):
parser.error('actions, screenshot and report require a headless mode')
if args.headless_smoke and len(actions) > 12:
parser.error('Smoke accepts at most 12 actions; use replay')
frames = 12 if args.headless_replay is None else max(12, len(actions) + 2)
main(headless, args.screenshot, actions, frames, args.report)届く斬りと体力を足す
距離による条件分岐と、ゲームが終わった状態。
斬る(S)を追加し、移動したあとの distance(距離)が1のときだけ、相手の体力を1減らします。max(0, ...) でマイナスにならないようにし、体力が0なら outcome を won(勝ち)にします。終わったあとは、R でやり直すまで状態を変えません。相手は止まったままの練習台で、反撃も移動もしません。続けて斬っても威力は1で、次の段階の「続けて斬る」とはまだ違います。
下のコードは 02_slash.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
damage = 1 if action == 'S' and distance == 1 else 0
hp = max(0, s.enemy_hp - damage)
outcome = 'won' if hp == 0 else 'playing'実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 02_slash.py.venv/bin/python 02_slash.py- 距離1の斬りだけ当たる
- 5回当てると相手を倒せる
- WON のあとの入力では状態が変わらない
and でつなぎます。また、相手の HP が0になった直後にも動けると、終わった状態が固定されません。damage の1を2に変えて、最初の HP が5の相手に何回当てれば勝てるか予想しましょう。最後の一撃で HP が-1にならず0になることも確かめたら、1に戻します。これは元のゲームのルールとは別の改造です。
このステップのコード全文(187行)
# MIT License
#
# Copyright (c) 2026 Pyxel 100 contributors
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
# この教材はNoto字形を同梱せず、ASCIIと図形のみで描画します。
"""三手の剣舞・教材 2/5。単独で実行できる段階別プログラム。"""
from dataclasses import dataclass, replace, asdict
ACTIONS = ('F', 'S', 'B', 'W')
@dataclass(frozen=True)
class State:
player: int = 3
enemy: int = 6
enemy_hp: int = 5
turns: int = 0
outcome: str = 'playing'
def initial():
return State()
def transition(s, action):
if s.outcome != 'playing' or action not in ACTIONS:
return s
candidate = s.player + {'F': 1, 'S': 0, 'B': -1, 'W': 0}[action]
if not 1 <= candidate <= 9:
return s
if candidate >= s.enemy:
candidate = s.player
distance = s.enemy - candidate
damage = 1 if action == 'S' and distance == 1 else 0
hp = max(0, s.enemy_hp - damage)
outcome = 'won' if hp == 0 else 'playing'
return State(candidate, s.enemy, hp, s.turns + 1, outcome)
class Game:
def __init__(self):
self.reset()
def reset(self):
self.state = initial()
def act(self, action):
self.state = transition(self.state, action)
KEY_ACTIONS = {'LEFT': 'B', 'A': 'B', 'UP': 'S', 'Z': 'S', 'RIGHT': 'F', 'D': 'F', 'SPACE': 'W'}
class Controller:
"""Pyxelの押下とゲーム1手を分離。合成入力でも同じ窓口を使う。"""
def __init__(self):
self.game = Game()
self.release_gate = False
def tick(self, pressed=(), held=()):
pressed, held = set(pressed), set(held)
controls = set(KEY_ACTIONS) | {'R', 'X'}
if self.release_gate:
if not (held & controls):
self.release_gate = False
return
if 'R' in pressed:
self.game.reset()
self.release_gate = True
return
chosen = {action for key, action in KEY_ACTIONS.items() if key in pressed}
held_actions = {action for key, action in KEY_ACTIONS.items() if key in held}
if len(chosen) == 1 and len(held_actions) == 1:
self.game.act(next(iter(chosen)))
def main(headless=False, screenshot=None, actions='', frames=12, report=None):
import json
from pathlib import Path
# Pyxel初期化によるcwd変更前に利用者の出力先を確定する。
screenshot = str(Path(screenshot).resolve()) if screenshot else None
report = str(Path(report).resolve()) if report else None
import pyxel
controller = Controller()
events = []
tick = 0
keys = {key: getattr(pyxel, 'KEY_' + key) for key in (*KEY_ACTIONS, 'R', 'X')}
replay_keys = {'F': 'RIGHT', 'B': 'LEFT', 'W': 'SPACE', 'S': 'Z', 'K': 'R', 'X': 'X'}
def update():
nonlocal tick
tick += 1
if headless:
if tick <= len(actions):
# QA専用の合成押下。OSキーボード操作・人の試遊ではない。
key = replay_keys[actions[tick - 1]]
controller.tick((), ())
controller.tick((key,), (key,))
events.append({'tick': tick, 'action': actions[tick - 1],
'state': asdict(controller.game.state)})
else:
controller.tick([key for key, value in keys.items() if pyxel.btnp(value)],
[key for key, value in keys.items() if pyxel.btn(value)])
def fighter(position, y, color, direction):
x = 32 + (position - 1) * 42
pyxel.circ(x, y - 13, 5, color)
pyxel.line(x, y - 7, x, y + 8, color)
pyxel.line(x, y + 8, x - 6, y + 16, color)
pyxel.line(x, y + 8, x + 6, y + 16, color)
pyxel.line(x, y - 3, x + direction * 12, y - 9, color)
pyxel.line(x + direction * 12, y - 9, x + direction * 21, y - 20, 7)
def draw():
s = controller.game.state
pyxel.cls(1)
pyxel.text(10, 8, 'THREE MOVE DANCE / STEP 2', 10)
pyxel.text(10, 21, 'TRAINING: STATIONARY TARGET', 13)
pyxel.text(10, 35, f'TURN {min(s.turns,9999)} DISTANCE {s.enemy - s.player}', 7)
pyxel.text(10, 48, f'ENEMY HP {s.enemy_hp}/5 {s.outcome.upper()}', 11 if s.outcome == 'won' else 7)
pyxel.text(10, 74, 'HISTORY: NOT ADDED IN THIS STAGE', 5)
pyxel.text(10, 109, 'ENEMY DOES NOT MOVE OR ATTACK.', 13)
for position in range(1, 10):
x = 32 + (position - 1) * 42
pyxel.rectb(x - 19, 133, 38, 57, 5)
pyxel.text(x - 2, 194, str(position), 13)
fighter(s.player, 166, 6, 1)
fighter(s.enemy, 161, 8, -1)
pyxel.text(10, 216, 'SLASH REACH = 1 CELL / DAMAGE = 1', 6)
pyxel.text(10, 228, 'MOVE TO CELL 5, THEN SLASH FIVE TIMES.', 13)
pyxel.text(10, 252, 'LEFT/A: BACK UP/Z: SLASH RIGHT/D: FORWARD', 7)
pyxel.text(10, 266, 'SPACE: WAIT R: RESET ESC: QUIT', 7)
pyxel.text(10, 284, 'NO TIME LIMIT / SILENT / R TO RESTART', 13)
if headless and tick >= frames:
if screenshot:
pyxel.screenshot(screenshot, scale=2)
if report:
Path(report).write_text(json.dumps({
'label': 'Synthetic input; actual Pyxel headless render; silent',
'frames': tick, 'actions': actions, 'events': events,
'final_state': asdict(s),
}, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
pyxel.quit()
pyxel.init(400, 302, title='Three Move Dance - Stage 2', fps=30, headless=headless)
pyxel.run(update, draw)
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--headless-smoke', action='store_true')
parser.add_argument('--headless-replay', help='Synthetic actions; K resets; not OS keys')
parser.add_argument('--actions', default='', help='Synthetic actions within a 12-frame smoke')
parser.add_argument('--screenshot', help='PNG path, headless only')
parser.add_argument('--report', help='UTF-8 JSON path, headless only')
args = parser.parse_args()
if args.headless_smoke and args.headless_replay is not None:
parser.error('Choose smoke or replay, not both')
actions = args.actions if args.headless_replay is None else args.headless_replay
if any(action not in 'FSBWK' for action in actions):
parser.error('Supported replay actions: FSBWK')
headless = args.headless_smoke or args.headless_replay is not None
if not headless and (args.actions or args.screenshot or args.report):
parser.error('actions, screenshot and report require a headless mode')
if args.headless_smoke and len(actions) > 12:
parser.error('Smoke accepts at most 12 actions; use replay')
frames = 12 if args.headless_replay is None else max(12, len(actions) + 2)
main(headless, args.screenshot, actions, frames, args.report)直前3手を数え、同数なら新しい手を読む
直前の3手を数え、同じ数なら新しいほうの手を読む。
Memory.actions は、古い順に並んだタプルです。(古い履歴 + (今回の手,))[-3:] で、直前の3手だけを残します。前進(F)・斬る(S)・後退(B)を count で数え、いちばん多い手を、reversed を使って新しいほうから探します。止まる(W)も3つの枠のうち1つを使いますが、票には数えません。記録が空か、W だけのときは、F への対策をとります。自分の直前の手が S なら、次に当たったときは2点になります。空振りの S でも、この条件になります。この段階では、相手の読みと構えを表示するだけで、相手はまだ動いたり攻撃したりしません。
下のコードは 03_history.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
counts = {a: recent.count(a) for a in ('F', 'S', 'B')}
most = 'F' # 空履歴・Wだけなら前進への対策。
maximum = max(counts.values())
for action in reversed(recent):
if action in counts and counts[action] == maximum:
most = action # 同数なら新しい方から最初に見つかった手。
break実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 03_history.py.venv/bin/python 03_history.py- W も3つの枠のうち1つを使う
- 同じ数なら、新しいほうの F・S・B を選ぶ
- 空振りの S の直後でも、次の斬りの威力は2
- 03の NEXT は表示だけだと区別できる
記録が (F,S,B)、(S,F,S)、(S,W,F)、(W,W,W) のときの READ を先に紙に書き、policy(Memory(...)) とテストの test_stage_03_majority_recency_and_wait で確かめましょう。もし W も数えるなら、新しい対策の決め方も必要になります。
このステップのコード全文(228行)
# MIT License
#
# Copyright (c) 2026 Pyxel 100 contributors
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
# この教材はNoto字形を同梱せず、ASCIIと図形のみで描画します。
"""三手の剣舞・教材 3/5。単独で実行できる段階別プログラム。"""
from dataclasses import dataclass, replace, asdict
ACTIONS = ('F', 'S', 'B', 'W')
@dataclass(frozen=True)
class Memory:
actions: tuple = ()
@dataclass(frozen=True)
class Intent:
kind: str
move: int
ranges: tuple
counter: str
@dataclass(frozen=True)
class State:
player: int
enemy: int
enemy_hp: int
memory: Memory
intent: Intent
turns: int = 0
outcome: str = 'playing'
def policy(memory):
# 呼び出し時に確定済みの過去3手だけを読む。今回の入力は引数にしない。
recent = memory.actions[-3:]
counts = {a: recent.count(a) for a in ('F', 'S', 'B')}
most = 'F' # 空履歴・Wだけなら前進への対策。
maximum = max(counts.values())
for action in reversed(recent):
if action in counts and counts[action] == maximum:
most = action # 同数なら新しい方から最初に見つかった手。
break
if most == 'F':
return Intent('guard', 0, (1, 2), most)
if most == 'S':
return Intent('retreat', 1, (2,), most)
return Intent('chase', -2, (), most)
def initial():
memory = Memory()
return State(3, 6, 5, memory, policy(memory))
def transition(s, action):
if s.outcome != 'playing' or action not in ACTIONS:
return s
candidate = s.player + {'F': 1, 'S': 0, 'B': -1, 'W': 0}[action]
if not 1 <= candidate <= 9:
return s
if candidate >= s.enemy:
candidate = s.player
# 03は読みを表示する練習版。相手のIntentはまだ実行しない。
combo = bool(s.memory.actions and s.memory.actions[-1] == 'S')
damage = (2 if combo else 1) if action == 'S' and s.enemy - candidate == 1 else 0
hp = max(0, s.enemy_hp - damage)
outcome = 'won' if hp == 0 else 'playing'
memory = Memory((s.memory.actions + (action,))[-3:])
return State(candidate, s.enemy, hp, memory, policy(memory), s.turns + 1, outcome)
class Game:
def __init__(self):
self.reset()
def reset(self):
self.state = initial()
def act(self, action):
self.state = transition(self.state, action)
KEY_ACTIONS = {'LEFT': 'B', 'A': 'B', 'UP': 'S', 'Z': 'S', 'RIGHT': 'F', 'D': 'F', 'SPACE': 'W'}
class Controller:
"""Pyxelの押下とゲーム1手を分離。合成入力でも同じ窓口を使う。"""
def __init__(self):
self.game = Game()
self.release_gate = False
def tick(self, pressed=(), held=()):
pressed, held = set(pressed), set(held)
controls = set(KEY_ACTIONS) | {'R', 'X'}
if self.release_gate:
if not (held & controls):
self.release_gate = False
return
if 'R' in pressed:
self.game.reset()
self.release_gate = True
return
chosen = {action for key, action in KEY_ACTIONS.items() if key in pressed}
held_actions = {action for key, action in KEY_ACTIONS.items() if key in held}
if len(chosen) == 1 and len(held_actions) == 1:
self.game.act(next(iter(chosen)))
def main(headless=False, screenshot=None, actions='', frames=12, report=None):
import json
from pathlib import Path
# Pyxel初期化によるcwd変更前に利用者の出力先を確定する。
screenshot = str(Path(screenshot).resolve()) if screenshot else None
report = str(Path(report).resolve()) if report else None
import pyxel
controller = Controller()
events = []
tick = 0
keys = {key: getattr(pyxel, 'KEY_' + key) for key in (*KEY_ACTIONS, 'R', 'X')}
replay_keys = {'F': 'RIGHT', 'B': 'LEFT', 'W': 'SPACE', 'S': 'Z', 'K': 'R', 'X': 'X'}
def update():
nonlocal tick
tick += 1
if headless:
if tick <= len(actions):
# QA専用の合成押下。OSキーボード操作・人の試遊ではない。
key = replay_keys[actions[tick - 1]]
controller.tick((), ())
controller.tick((key,), (key,))
events.append({'tick': tick, 'action': actions[tick - 1],
'state': asdict(controller.game.state)})
else:
controller.tick([key for key, value in keys.items() if pyxel.btnp(value)],
[key for key, value in keys.items() if pyxel.btn(value)])
def fighter(position, y, color, direction):
x = 32 + (position - 1) * 42
pyxel.circ(x, y - 13, 5, color)
pyxel.line(x, y - 7, x, y + 8, color)
pyxel.line(x, y + 8, x - 6, y + 16, color)
pyxel.line(x, y + 8, x + 6, y + 16, color)
pyxel.line(x, y - 3, x + direction * 12, y - 9, color)
pyxel.line(x + direction * 12, y - 9, x + direction * 21, y - 20, 7)
def draw():
s = controller.game.state
pyxel.cls(1)
pyxel.text(10, 8, 'THREE MOVE DANCE / STEP 3', 10)
pyxel.text(10, 21, 'TRAINING: INTENT IS READ-ONLY', 13)
pyxel.text(10, 35, f'TURN {min(s.turns,9999)} DISTANCE {s.enemy - s.player}', 7)
pyxel.text(10, 48, f'ENEMY HP {s.enemy_hp}/5 {s.outcome.upper()}', 11 if s.outcome == 'won' else 7)
history = ('-',) * (3 - len(s.memory.actions)) + s.memory.actions
pyxel.text(10, 66, 'PAST 3 COMMITTED ACTIONS: OLD -> NEW', 6)
for i, action in enumerate(history):
x = 10 + i * 57
pyxel.rectb(x, 77, 49, 18, 6)
pyxel.text(x + 22, 83, action, 7)
pyxel.text(196, 81, 'F=FORWARD S=SLASH', 13)
pyxel.text(196, 90, 'B=BACK W=WAIT', 13)
pyxel.text(10, 109, f'NEXT: {s.intent.kind.upper()} READ: {s.intent.counter}', 10)
for position in range(1, 10):
x = 32 + (position - 1) * 42
pyxel.rectb(x - 19, 133, 38, 57, 5)
pyxel.text(x - 2, 194, str(position), 13)
fighter(s.player, 166, 6, 1)
fighter(s.enemy, 161, 8, -1)
pyxel.text(10, 210, 'PREVIOUS ACTION S: NEXT HIT DEALS 2.', 6)
pyxel.text(10, 223, 'W TAKES A MEMORY SLOT, BUT GETS NO VOTES.', 13)
pyxel.text(10, 236, 'NEXT IS ONLY A PREVIEW IN STEP 3.', 10)
pyxel.text(10, 252, 'LEFT/A: BACK UP/Z: SLASH RIGHT/D: FORWARD', 7)
pyxel.text(10, 266, 'SPACE: WAIT R: RESET ESC: QUIT', 7)
pyxel.text(10, 284, 'NO TIME LIMIT / SILENT / R TO RESTART', 13)
if headless and tick >= frames:
if screenshot:
pyxel.screenshot(screenshot, scale=2)
if report:
Path(report).write_text(json.dumps({
'label': 'Synthetic input; actual Pyxel headless render; silent',
'frames': tick, 'actions': actions, 'events': events,
'final_state': asdict(s),
}, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
pyxel.quit()
pyxel.init(400, 302, title='Three Move Dance - Stage 3', fps=30, headless=headless)
pyxel.run(update, draw)
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--headless-smoke', action='store_true')
parser.add_argument('--headless-replay', help='Synthetic actions; K resets; not OS keys')
parser.add_argument('--actions', default='', help='Synthetic actions within a 12-frame smoke')
parser.add_argument('--screenshot', help='PNG path, headless only')
parser.add_argument('--report', help='UTF-8 JSON path, headless only')
args = parser.parse_args()
if args.headless_smoke and args.headless_replay is not None:
parser.error('Choose smoke or replay, not both')
actions = args.actions if args.headless_replay is None else args.headless_replay
if any(action not in 'FSBWK' for action in actions):
parser.error('Supported replay actions: FSBWK')
headless = args.headless_smoke or args.headless_replay is not None
if not headless and (args.actions or args.screenshot or args.report):
parser.error('actions, screenshot and report require a headless mode')
if args.headless_smoke and len(actions) > 12:
parser.error('Smoke accepts at most 12 actions; use replay')
frames = 12 if args.headless_replay is None else max(12, len(actions) + 2)
main(headless, args.screenshot, actions, frames, args.report)公開した構えで同時に動き、攻撃後は息継ぎする
確定する前に見てよい情報の範囲と、同時に処理する順番。
ここから、元のゲームの A の戦いのルールで動きます。入力の前から決まっている相手の構え old_intent を固定し、元の位置から両者の移動の候補を作ります。ぶつかるなら両者とも元に戻し、最後の距離で両者のダメージを計算します。両方の HP が0なら mutual(相打ち)です。最後に、今回の手を記録に加えて、次の構えを作ります。相手は「置き斬り」や「引き突き」を試みた次の手では、空振りでも1手だけ息つぎをします。「追い足」の次には、自動の息つぎはありません。
下のコードは 04_simultaneous.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
old_intent = s.intent # 1. 入力前から公開済みの構えを固定。
player = s.player + {'F': 1, 'S': 0, 'B': -1, 'W': 0}[action]
if not 1 <= player <= 9:
return Result(s, False, 'edge')
enemy = enemy_proposal(s) # 2. 両者の旧位置から候補を作る。
collision = player >= enemy
if collision:
player, enemy = s.player, s.enemy # 3. 衝突なら双方を元に戻す。実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 04_simultaneous.py.venv/bin/python 04_simultaneous.py- 今回の入力を、相手の構えが先読みしない
- ぶつかったら、両者の移動を元に戻す
- 両者の HP を同時に減らす
- 空振りの攻撃のあとも、1手だけ息つぎする
テストの test_collision_rolls_back_both_candidates にある「自分2・相手5・相手の構えが CHASE のときに F を選ぶ」例を、紙に描いてみましょう。候補がどちらも3になってぶつかり、結果が2と5に両方戻ることを説明します。続けて test_follow_retreat_into_old_enemy_cell_is_legal で、4と5から5と6へ動ける理由も比べましょう。
このステップのコード全文(293行)
# MIT License
#
# Copyright (c) 2026 Pyxel 100 contributors
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
# この教材はNoto字形を同梱せず、ASCIIと図形のみで描画します。
"""三手の剣舞・教材 4/5。単独で実行できる段階別プログラム。"""
from dataclasses import dataclass, replace, asdict
ACTIONS = ('F', 'S', 'B', 'W')
@dataclass(frozen=True)
class Level:
title: str
player: int
enemy: int
history: tuple
LEVEL = Level('一回斬って、間を置く', 3, 6, ()) # 原作Aだけを収録。
@dataclass(frozen=True)
class Observation:
distance: int
own_position: int
@dataclass(frozen=True)
class Memory:
actions: tuple
recovering: bool
@dataclass(frozen=True)
class Intent:
kind: str
move: int
ranges: tuple
counter: str
@dataclass(frozen=True)
class State:
player: int
enemy: int
hp: int
enemy_hp: int
memory: Memory
intent: Intent
turns: int = 0
outcome: str = 'playing'
@dataclass(frozen=True)
class Result:
state: State
accepted: bool
reason: str
dealt: int = 0
taken: int = 0
collision: bool = False
def observe(s):
return Observation(s.enemy - s.player, s.enemy)
def policy(observation, memory):
# observationは原作との共通窓口。この方策では過去の手と回復だけを使う。
# 今回の未確定入力を引数に渡さない。
if memory.recovering:
return Intent('breath', 0, (), '-')
recent = memory.actions[-3:]
counts = {a: recent.count(a) for a in ('F', 'S', 'B')}
most = 'F'
maximum = max(counts.values())
for action in reversed(recent):
if action in counts and counts[action] == maximum:
most = action
break
if most == 'F':
return Intent('guard', 0, (1, 2), most)
if most == 'S':
return Intent('retreat', 1, (2,), most)
return Intent('chase', -2, (), most)
def initial():
memory = Memory(LEVEL.history, False)
s = State(LEVEL.player, LEVEL.enemy, 3, 5, memory, Intent('', 0, (), ''))
return replace(s, intent=policy(observe(s), memory))
def enemy_proposal(s):
# 追い足は自分の旧位置の隣まで、引き突きは右端9まで。
return max(s.player + 1, min(9, s.enemy + s.intent.move))
def transition(s, action):
if s.outcome != 'playing':
return Result(s, False, 'terminal')
if action not in ACTIONS:
return Result(s, False, 'invalid')
old_intent = s.intent # 1. 入力前から公開済みの構えを固定。
player = s.player + {'F': 1, 'S': 0, 'B': -1, 'W': 0}[action]
if not 1 <= player <= 9:
return Result(s, False, 'edge')
enemy = enemy_proposal(s) # 2. 両者の旧位置から候補を作る。
collision = player >= enemy
if collision:
player, enemy = s.player, s.enemy # 3. 衝突なら双方を元に戻す。
distance = enemy - player
combo = bool(s.memory.actions and s.memory.actions[-1] == 'S')
dealt = (2 if combo else 1) if action == 'S' and distance == 1 else 0
taken = 1 if distance in old_intent.ranges else 0
hp = max(0, s.hp - taken) # 4. 最終距離で同時にダメージ。
enemy_hp = max(0, s.enemy_hp - dealt)
if hp == 0 and enemy_hp == 0:
outcome = 'mutual'
elif hp == 0:
outcome = 'lost'
elif enemy_hp == 0:
outcome = 'won'
else:
outcome = 'playing'
# 5. 今回の手をここで初めて確定履歴へ。空振りSも残す。
memory = Memory((s.memory.actions + (action,))[-3:],
old_intent.kind in ('guard', 'retreat'))
new = State(player, enemy, hp, enemy_hp, memory, old_intent, s.turns + 1, outcome)
new = replace(new, intent=policy(observe(new), memory))
reason = ('mutual' if outcome == 'mutual' else 'hit' if dealt else
'hurt' if taken else 'clash' if collision else 'miss' if action == 'S' else 'moved')
return Result(new, True, reason, dealt, taken, collision)
class Game:
def __init__(self):
self.reset()
def reset(self):
self.state = initial()
self.last = None
def act(self, action):
self.last = transition(self.state, action)
self.state = self.last.state
KEY_ACTIONS = {'LEFT': 'B', 'A': 'B', 'UP': 'S', 'Z': 'S', 'RIGHT': 'F', 'D': 'F', 'SPACE': 'W'}
class Controller:
"""Pyxelの押下とゲーム1手を分離。合成入力でも同じ窓口を使う。"""
def __init__(self):
self.game = Game()
self.release_gate = False
def tick(self, pressed=(), held=()):
pressed, held = set(pressed), set(held)
controls = set(KEY_ACTIONS) | {'R', 'X'}
if self.release_gate:
if not (held & controls):
self.release_gate = False
return
if 'R' in pressed:
self.game.reset()
self.release_gate = True
return
chosen = {action for key, action in KEY_ACTIONS.items() if key in pressed}
held_actions = {action for key, action in KEY_ACTIONS.items() if key in held}
if len(chosen) == 1 and len(held_actions) == 1:
self.game.act(next(iter(chosen)))
def main(headless=False, screenshot=None, actions='', frames=12, report=None):
import json
from pathlib import Path
# Pyxel初期化によるcwd変更前に利用者の出力先を確定する。
screenshot = str(Path(screenshot).resolve()) if screenshot else None
report = str(Path(report).resolve()) if report else None
import pyxel
controller = Controller()
events = []
tick = 0
keys = {key: getattr(pyxel, 'KEY_' + key) for key in (*KEY_ACTIONS, 'R', 'X')}
replay_keys = {'F': 'RIGHT', 'B': 'LEFT', 'W': 'SPACE', 'S': 'Z', 'K': 'R', 'X': 'X'}
def update():
nonlocal tick
tick += 1
if headless:
if tick <= len(actions):
# QA専用の合成押下。OSキーボード操作・人の試遊ではない。
key = replay_keys[actions[tick - 1]]
controller.tick((), ())
controller.tick((key,), (key,))
events.append({'tick': tick, 'action': actions[tick - 1],
'state': asdict(controller.game.state)})
else:
controller.tick([key for key, value in keys.items() if pyxel.btnp(value)],
[key for key, value in keys.items() if pyxel.btn(value)])
def fighter(position, y, color, direction):
x = 32 + (position - 1) * 42
pyxel.circ(x, y - 13, 5, color)
pyxel.line(x, y - 7, x, y + 8, color)
pyxel.line(x, y + 8, x - 6, y + 16, color)
pyxel.line(x, y + 8, x + 6, y + 16, color)
pyxel.line(x, y - 3, x + direction * 12, y - 9, color)
pyxel.line(x + direction * 12, y - 9, x + direction * 21, y - 20, 7)
def draw():
s = controller.game.state
pyxel.cls(1)
pyxel.text(10, 8, 'THREE MOVE DANCE / STEP 4', 10)
pyxel.text(10, 21, 'ORIGINAL A RULES / NO UNDO', 13)
pyxel.text(10, 35, f'TURN {min(s.turns,9999)} DISTANCE {s.enemy - s.player}', 7)
pyxel.text(10, 48, f'HP {s.hp}/3 ENEMY HP {s.enemy_hp}/5 {s.outcome.upper()}', 11 if s.outcome == 'won' else 7)
history = ('-',) * (3 - len(s.memory.actions)) + s.memory.actions
pyxel.text(10, 66, 'PAST 3 COMMITTED ACTIONS: OLD -> NEW', 6)
for i, action in enumerate(history):
x = 10 + i * 57
pyxel.rectb(x, 77, 49, 18, 6)
pyxel.text(x + 22, 83, action, 7)
pyxel.text(196, 81, 'F=FORWARD S=SLASH', 13)
pyxel.text(196, 90, 'B=BACK W=WAIT', 13)
pyxel.text(10, 109, f'NEXT: {s.intent.kind.upper()} READ: {s.intent.counter}', 10)
for position in range(1, 10):
x = 32 + (position - 1) * 42
pyxel.rectb(x - 19, 133, 38, 57, 5)
pyxel.text(x - 2, 194, str(position), 13)
proposal = enemy_proposal(s)
if position == proposal:
pyxel.text(x - 8, 123, 'NEXT', 10)
if proposal - position in s.intent.ranges:
for offset in range(-14, 15, 7):
pyxel.line(x + offset, 182, x + offset + 4, 174, 8)
fighter(s.player, 166, 6, 1)
fighter(s.enemy, 161, 8, -1)
pyxel.text(10, 210, 'NEXT: ENEMY CANDIDATE. RED: ATTACK DISTANCES.', 13)
pyxel.text(10, 223, 'CLASH: BOTH STAY; DAMAGE USES THE FINAL GAP.', 13)
pyxel.text(10, 236, 'PREVIOUS S: POWER 2 / ATTACK -> ONE BREATH.', 6)
pyxel.text(10, 252, 'LEFT/A: BACK UP/Z: SLASH RIGHT/D: FORWARD', 7)
pyxel.text(10, 266, 'SPACE: WAIT R: RESET ESC: QUIT', 7)
pyxel.text(10, 284, 'NO TIME LIMIT / SILENT / R TO RESTART', 13)
if headless and tick >= frames:
if screenshot:
pyxel.screenshot(screenshot, scale=2)
if report:
Path(report).write_text(json.dumps({
'label': 'Synthetic input; actual Pyxel headless render; silent',
'frames': tick, 'actions': actions, 'events': events,
'final_state': asdict(s),
}, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
pyxel.quit()
pyxel.init(400, 302, title='Three Move Dance - Stage 4', fps=30, headless=headless)
pyxel.run(update, draw)
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--headless-smoke', action='store_true')
parser.add_argument('--headless-replay', help='Synthetic actions; K resets; not OS keys')
parser.add_argument('--actions', default='', help='Synthetic actions within a 12-frame smoke')
parser.add_argument('--screenshot', help='PNG path, headless only')
parser.add_argument('--report', help='UTF-8 JSON path, headless only')
args = parser.parse_args()
if args.headless_smoke and args.headless_replay is not None:
parser.error('Choose smoke or replay, not both')
actions = args.actions if args.headless_replay is None else args.headless_replay
if any(action not in 'FSBWK' for action in actions):
parser.error('Supported replay actions: FSBWK')
headless = args.headless_smoke or args.headless_replay is not None
if not headless and (args.actions or args.screenshot or args.report):
parser.error('actions, screenshot and report require a headless mode')
if args.headless_smoke and len(actions) > 12:
parser.error('Smoke accepts at most 12 actions; use replay')
frames = 12 if args.headless_replay is None else max(12, len(actions) + 2)
main(headless, args.screenshot, actions, frames, args.report)体力・記憶・構えを丸ごと一手戻す
書き換えられない State と、別の履歴で Undo を作る。
相手が数える「直前の3手」の Memory とは別に、受け付けたそれぞれの手の前の State を Timeline.past に保存します。X キーでは最後の State 全体を取り出すので、自分と相手の位置・両方の HP・相手の記憶・回復・構え・手数・勝ち負けが一度に戻ります。盤の外への入力や無効な入力、終わったあとの入力は保存しません。R キーは最初の状態と空の Timeline に戻します。元のゲームの A の基本の戦いと Undo をそなえた、小さな完成版です。
下のコードは 05_undo.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
result = transition(timeline.state, action)
if not result.accepted:
return timeline, result
return Timeline(result.state, timeline.past + (timeline.state,)), result実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 05_undo.py.venv/bin/python 05_undo.py- 勝ち・負けの直前に戻れる
- 位置だけでなく、HP・記憶・構えも戻る
- 無効な手は保存しない
- R キーで Undo の履歴も空にする
Memory は相手が見る情報、Timeline はプレイヤーが戻すためのすべての状態、と役割が違います。書き換えられるリストを共有して過去まで変わってしまわないよう、frozen とタプルを使います。テストの test_undo_restores_complete_state_and_redo を読み、SFF の結果と、SFF→undo→F の結果が asdict で完全に同じになることを確かめましょう。位置だけを戻した場合に、どの項目が食い違うかも挙げてみましょう。
このステップのコード全文(316行)
# MIT License
#
# Copyright (c) 2026 Pyxel 100 contributors
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
# この教材はNoto字形を同梱せず、ASCIIと図形のみで描画します。
"""三手の剣舞・教材 5/5。単独で実行できる段階別プログラム。"""
from dataclasses import dataclass, replace, asdict
ACTIONS = ('F', 'S', 'B', 'W')
@dataclass(frozen=True)
class Level:
title: str
player: int
enemy: int
history: tuple
LEVEL = Level('一回斬って、間を置く', 3, 6, ()) # 原作Aだけを収録。
@dataclass(frozen=True)
class Observation:
distance: int
own_position: int
@dataclass(frozen=True)
class Memory:
actions: tuple
recovering: bool
@dataclass(frozen=True)
class Intent:
kind: str
move: int
ranges: tuple
counter: str
@dataclass(frozen=True)
class State:
player: int
enemy: int
hp: int
enemy_hp: int
memory: Memory
intent: Intent
turns: int = 0
outcome: str = 'playing'
@dataclass(frozen=True)
class Result:
state: State
accepted: bool
reason: str
dealt: int = 0
taken: int = 0
collision: bool = False
def observe(s):
return Observation(s.enemy - s.player, s.enemy)
def policy(observation, memory):
# observationは原作との共通窓口。この方策では過去の手と回復だけを使う。
# 今回の未確定入力を引数に渡さない。
if memory.recovering:
return Intent('breath', 0, (), '-')
recent = memory.actions[-3:]
counts = {a: recent.count(a) for a in ('F', 'S', 'B')}
most = 'F'
maximum = max(counts.values())
for action in reversed(recent):
if action in counts and counts[action] == maximum:
most = action
break
if most == 'F':
return Intent('guard', 0, (1, 2), most)
if most == 'S':
return Intent('retreat', 1, (2,), most)
return Intent('chase', -2, (), most)
def initial():
memory = Memory(LEVEL.history, False)
s = State(LEVEL.player, LEVEL.enemy, 3, 5, memory, Intent('', 0, (), ''))
return replace(s, intent=policy(observe(s), memory))
def enemy_proposal(s):
# 追い足は自分の旧位置の隣まで、引き突きは右端9まで。
return max(s.player + 1, min(9, s.enemy + s.intent.move))
def transition(s, action):
if s.outcome != 'playing':
return Result(s, False, 'terminal')
if action not in ACTIONS:
return Result(s, False, 'invalid')
old_intent = s.intent # 1. 入力前から公開済みの構えを固定。
player = s.player + {'F': 1, 'S': 0, 'B': -1, 'W': 0}[action]
if not 1 <= player <= 9:
return Result(s, False, 'edge')
enemy = enemy_proposal(s) # 2. 両者の旧位置から候補を作る。
collision = player >= enemy
if collision:
player, enemy = s.player, s.enemy # 3. 衝突なら双方を元に戻す。
distance = enemy - player
combo = bool(s.memory.actions and s.memory.actions[-1] == 'S')
dealt = (2 if combo else 1) if action == 'S' and distance == 1 else 0
taken = 1 if distance in old_intent.ranges else 0
hp = max(0, s.hp - taken) # 4. 最終距離で同時にダメージ。
enemy_hp = max(0, s.enemy_hp - dealt)
if hp == 0 and enemy_hp == 0:
outcome = 'mutual'
elif hp == 0:
outcome = 'lost'
elif enemy_hp == 0:
outcome = 'won'
else:
outcome = 'playing'
# 5. 今回の手をここで初めて確定履歴へ。空振りSも残す。
memory = Memory((s.memory.actions + (action,))[-3:],
old_intent.kind in ('guard', 'retreat'))
new = State(player, enemy, hp, enemy_hp, memory, old_intent, s.turns + 1, outcome)
new = replace(new, intent=policy(observe(new), memory))
reason = ('mutual' if outcome == 'mutual' else 'hit' if dealt else
'hurt' if taken else 'clash' if collision else 'miss' if action == 'S' else 'moved')
return Result(new, True, reason, dealt, taken, collision)
@dataclass(frozen=True)
class Timeline:
state: State
past: tuple = ()
def step(timeline, action):
if action == 'undo':
if timeline.past:
return Timeline(timeline.past[-1], timeline.past[:-1]), None
return timeline, None
result = transition(timeline.state, action)
if not result.accepted:
return timeline, result
return Timeline(result.state, timeline.past + (timeline.state,)), result
class Game:
def __init__(self):
self.reset()
@property
def state(self):
return self.timeline.state
def reset(self):
self.timeline = Timeline(initial())
self.last = None
def act(self, action):
self.timeline, self.last = step(self.timeline, action)
KEY_ACTIONS = {'LEFT': 'B', 'A': 'B', 'UP': 'S', 'Z': 'S', 'RIGHT': 'F', 'D': 'F', 'SPACE': 'W'}
class Controller:
"""Pyxelの押下とゲーム1手を分離。合成入力でも同じ窓口を使う。"""
def __init__(self):
self.game = Game()
self.release_gate = False
def tick(self, pressed=(), held=()):
pressed, held = set(pressed), set(held)
controls = set(KEY_ACTIONS) | {'R', 'X'}
if self.release_gate:
if not (held & controls):
self.release_gate = False
return
if 'R' in pressed:
self.game.reset()
self.release_gate = True
return
if 'X' in pressed:
self.game.act('undo')
self.release_gate = True
return
chosen = {action for key, action in KEY_ACTIONS.items() if key in pressed}
held_actions = {action for key, action in KEY_ACTIONS.items() if key in held}
if len(chosen) == 1 and len(held_actions) == 1:
self.game.act(next(iter(chosen)))
def main(headless=False, screenshot=None, actions='', frames=12, report=None):
import json
from pathlib import Path
# Pyxel初期化によるcwd変更前に利用者の出力先を確定する。
screenshot = str(Path(screenshot).resolve()) if screenshot else None
report = str(Path(report).resolve()) if report else None
import pyxel
controller = Controller()
events = []
tick = 0
keys = {key: getattr(pyxel, 'KEY_' + key) for key in (*KEY_ACTIONS, 'R', 'X')}
replay_keys = {'F': 'RIGHT', 'B': 'LEFT', 'W': 'SPACE', 'S': 'Z', 'K': 'R', 'X': 'X'}
def update():
nonlocal tick
tick += 1
if headless:
if tick <= len(actions):
# QA専用の合成押下。OSキーボード操作・人の試遊ではない。
key = replay_keys[actions[tick - 1]]
controller.tick((), ())
controller.tick((key,), (key,))
events.append({'tick': tick, 'action': actions[tick - 1],
'state': asdict(controller.game.state)})
else:
controller.tick([key for key, value in keys.items() if pyxel.btnp(value)],
[key for key, value in keys.items() if pyxel.btn(value)])
def fighter(position, y, color, direction):
x = 32 + (position - 1) * 42
pyxel.circ(x, y - 13, 5, color)
pyxel.line(x, y - 7, x, y + 8, color)
pyxel.line(x, y + 8, x - 6, y + 16, color)
pyxel.line(x, y + 8, x + 6, y + 16, color)
pyxel.line(x, y - 3, x + direction * 12, y - 9, color)
pyxel.line(x + direction * 12, y - 9, x + direction * 21, y - 20, 7)
def draw():
s = controller.game.state
pyxel.cls(1)
pyxel.text(10, 8, 'THREE MOVE DANCE / STEP 5', 10)
pyxel.text(10, 21, 'ORIGINAL A RULES / FULL UNDO', 13)
pyxel.text(10, 35, f'TURN {min(s.turns,9999)} DISTANCE {s.enemy - s.player}', 7)
pyxel.text(10, 48, f'HP {s.hp}/3 ENEMY HP {s.enemy_hp}/5 {s.outcome.upper()}', 11 if s.outcome == 'won' else 7)
history = ('-',) * (3 - len(s.memory.actions)) + s.memory.actions
pyxel.text(10, 66, 'PAST 3 COMMITTED ACTIONS: OLD -> NEW', 6)
for i, action in enumerate(history):
x = 10 + i * 57
pyxel.rectb(x, 77, 49, 18, 6)
pyxel.text(x + 22, 83, action, 7)
pyxel.text(196, 81, 'F=FORWARD S=SLASH', 13)
pyxel.text(196, 90, 'B=BACK W=WAIT', 13)
pyxel.text(10, 109, f'NEXT: {s.intent.kind.upper()} READ: {s.intent.counter}', 10)
for position in range(1, 10):
x = 32 + (position - 1) * 42
pyxel.rectb(x - 19, 133, 38, 57, 5)
pyxel.text(x - 2, 194, str(position), 13)
proposal = enemy_proposal(s)
if position == proposal:
pyxel.text(x - 8, 123, 'NEXT', 10)
if proposal - position in s.intent.ranges:
for offset in range(-14, 15, 7):
pyxel.line(x + offset, 182, x + offset + 4, 174, 8)
fighter(s.player, 166, 6, 1)
fighter(s.enemy, 161, 8, -1)
pyxel.text(10, 210, 'NEXT: CANDIDATE. COLLISION CANCELS BOTH MOVES.', 13)
pyxel.text(10, 223, 'RED: ATTACK DISTANCES / PREVIOUS S: POWER 2', 13)
pyxel.text(10, 236, 'X RESTORES HP, MEMORY, INTENT AND TURN.', 6)
pyxel.text(10, 252, 'LEFT/A: BACK UP/Z: SLASH RIGHT/D: FORWARD', 7)
pyxel.text(10, 266, 'SPACE: WAIT X: UNDO R: RESET ESC: QUIT', 7)
pyxel.text(10, 284, 'NO TIME LIMIT / SILENT / R TO RESTART', 13)
if headless and tick >= frames:
if screenshot:
pyxel.screenshot(screenshot, scale=2)
if report:
Path(report).write_text(json.dumps({
'label': 'Synthetic input; actual Pyxel headless render; silent',
'frames': tick, 'actions': actions, 'events': events,
'final_state': asdict(s),
}, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
pyxel.quit()
pyxel.init(400, 302, title='Three Move Dance - Stage 5', fps=30, headless=headless)
pyxel.run(update, draw)
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--headless-smoke', action='store_true')
parser.add_argument('--headless-replay', help='Synthetic actions; K resets; not OS keys')
parser.add_argument('--actions', default='', help='Synthetic actions within a 12-frame smoke')
parser.add_argument('--screenshot', help='PNG path, headless only')
parser.add_argument('--report', help='UTF-8 JSON path, headless only')
args = parser.parse_args()
if args.headless_smoke and args.headless_replay is not None:
parser.error('Choose smoke or replay, not both')
actions = args.actions if args.headless_replay is None else args.headless_replay
if any(action not in 'FSBWKX' for action in actions):
parser.error('Supported replay actions: FSBWKX')
headless = args.headless_smoke or args.headless_replay is not None
if not headless and (args.actions or args.screenshot or args.report):
parser.error('actions, screenshot and report require a headless mode')
if args.headless_smoke and len(actions) > 12:
parser.error('Smoke accepts at most 12 actions; use replay')
frames = 12 if args.headless_replay is None else max(12, len(actions) + 2)
main(headless, args.screenshot, actions, frames, args.report)仕組みがわかったら、元の完成版を読んでみよう。
元のゲームには3つの場面があります。自分の HP は3、相手は5です。相手は、直前に確定した3手の中でいちばん多い F・S・B への対策をとります。同じ数なら新しいほうを選び、W は枠だけ使います。F への対策は「距離1か2に置き斬り」、S への対策は「1歩引いて距離2へ攻撃」、B への対策は「2歩追いかけて攻撃はしない」です。攻撃のあとは、空振りでも1手息つぎします。動いてぶつかったら両者とも元に戻し、最後の距離で同時に攻撃します。斬りは距離1で当たり、直前も S なら2点です。空振りも S として記録されます。両方の HP が0なら相打ちです。
ここまでに作ったのは、元のゲームの仕組みだけを取り出して作り直した「簡略版」です。元のゲームのソースは手を加えずに、別のZIP(元の完成版ソース)に入れてあります。
| 比べる点 | 5ステップの教材 | 元の完成版 |
|---|---|---|
| 場面 | 04と05は、元のゲームのA「一回斬って、間を置く」だけ。最初の位置3/6・HP 3/5・記録は空・数える枠は3。 | A・B・Cの3場面。Bは4/5とFSSから、Cは2/6とBFBから始まる。 |
| 少しずつ増やす段階 | 01は移動だけ、02は止まった練習台に1点の斬り、03は記録・続けて斬る・構えの表示だけ、04でAの戦い、05でUndo。 | 最初から相手が動いて攻撃し、記憶・続けて斬る・Undoが有効。 |
| 表示と入力 | 英字(ASCII)と図形。すぐに練習を始める。違う行動のキーの同時押しは受け付けず、RやXのあとはキーが離されるのを待つ。 | 日本語の文字、表紙、Pで一時停止、Mで表紙、1〜3で選択、F12で記録。 |
| 実験と確認 | 数える枠は3で固定。単体テスト、元のゲームのAとの全状態の比較、自動入力による実際のPyxelでの画面なし描画(PNG)。音はなし。 | Bキーで枠3と枠1を比べられる。答えを探すプログラム、実験、動画や記録の補助ツールがついている。元のゲームも音なし。 |
完成版のコードで、次に読むところ
model.pyのpolicyを読み、window=1と3で、直前の読みが変わる条件を比べましょう。model.pyのtransitionとenemy_proposalで、「元の位置→候補→最後の距離」の順番を確かめましょう。model.pyのLEVELSで、B と C の最初の記録を比べましょう。教材の A での比較の結果を、そのまま B や C に当てはめないようにします。model.pyのstepと、app.pyの入力の仕組みを読み、ゲームの Undo と、キーが離されるのを待つ処理を分けて考えましょう。solver.pyのsolveとstate_keyで、手数を「同じ状態かどうか」の判定から外して、限りのある探索をしている理由を調べましょう。
最初は、数字かルールを1つだけ変えてみましょう。遊ぶ前に「どう変わるか」を予想しておくと、仕組みがよくわかります。慣れてきたら、見た目や音も足してみてください。元のファイルは残しておき、コピーした別のフォルダーで改造すると、いつでも元に戻せます。
教材をダウンロードする
無料で、登録なしでダウンロードできます。ZIPファイルは必ず展開してから、中のファイルを実行してください。元の完成版は、同じZIPに入っている assets フォルダー(画像などの素材)も一緒に使います。
コードと図形はMITライセンス、元の完成版に含まれる日本語の文字データ(Noto由来)はSIL OFL 1.1です。配ったり改造したりするときは、ZIPに入っているLICENSEなどの権利表示を残してください。出典・権利について
検証について
どこまで動作を確認したかを見る
2026年10月5日以降、Linux(Python 3.12・Pyxel 2.9.9)で、教材のコードとゲームのルールを自動テストしています。全500ステップの教材が、実際のPyxelで起動して画面を描けることも確かめました。元の完成版も、自動で遊ぶデモが起動して画面を描き、正常に終わることを確認しています。
この確認は、画面を表示しない自動テストで行ったものです。Windows・Macの実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。
うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る
