アクションLESSON 67予兆を読んで横へ避け、敵同士も巻き込むリアルタイム戦闘原作032

盾のない騎士の作り方

攻撃の予兆を見て、狙いの横へよける。
無敵時間に頼らない回避、敵の狙いの固定、敵どうしの同士討ち、柱の向こうを「最後に見た場所」で覚える敵を、元のゲームと照らし合わせながら作ります。

完成版の遊び方敵の攻撃の予兆を見てよけ、すきを突いて斬り返します。敵どうしの攻撃を利用することもできますが、必須ではありません。

盾のない騎士の元の完成版。自動操作によるPyxelの実描画画面。
元の完成版Cの自動描画です。下の教材は移動だけから始め、05でA/B/Cの規則へ進みます。
PLAY / 試験公開

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

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

完成版の主な操作:方向移動、Z剣、X回避(無敵なし)、R再開、P停止、M選択、N次庭

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

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

うまく表示されないとき

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

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

予兆・命中・敵が見た情報を、それぞれ別の状態として持つ。

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

1. 整数で細かい位置を表す座標・斜めの速さをそろえる・更新と描画を分ける
2. 段階ごとに切り替わる敵の状態・狙いの固定・長押しと押し直し・攻撃の番号
3. 剣の構え・有効・戻り/ダメージをまとめて反映/State に項目を足す
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
Python 3.11以上、Pyxel 2.9.9、ウィンドウを表示できる自分のパソコン(ローカルGUI環境) / 変数・if・for・関数・タプル / dataclass / replace と書き換えない状態は01で説明します
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
矢印:押している間移動。左右・上下の同時押しは相殺 / Z:03〜05で剣。押しっぱなしは再発動しない / X:02〜05で高速回避。無敵なし / R:同じ段階・中庭を初期化 / P:停止・再開。R/P後は全操作キーの解放を待つ / Esc:終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_movement.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

盾のない騎士を、まず中庭で歩かせる

整数で細かい位置を表す座標・斜めの速さをそろえる・更新と描画を分ける。

最初は、青い騎士を矢印キーで動かすところだけを作ります。敵・剣・回避・勝ち負けはまだありません。R で最初の位置に戻り、P で一時停止・再開です。この段階を終えてもクリアの表示が出ないのは仕様です。01から05までの各ファイルは独立しているので、前の段階を起動したり、別のゲームの model.py をコピーしたりする必要はありません。①Pyxel のウィンドウでキー入力を受け取る → ②Controller がそれを入力 Input に変える → ③step が状態を1回(1 tick)進める → ④draw は結果を読んで描くだけ、という流れを最初から守ります。

x=300、y=520 は「画面の300ピクセル目」という意味ではありません。元のゲームと同じく、内部の座標の1が0.1px(論理ピクセル)にあたるので、(30.0,52.0)px の位置です。小数を使わずに、整数で細かい位置を表すための工夫です。教材の画面では見やすいように2倍に拡大し、point で (32+x/5,45+y/5) に変換して描きます。画面左上の POSITION は、変換する前の整数です。ふだんの速さ15は、1 tick(1回の更新)で1.5論理px です。1秒に30回(30fps)更新するので、右へ10 tick 歩くと、内部の x は 300+15×10=450 になります。

Input は「右キーの番号」ではなく、dx・dy(横と縦にどちらへ動きたいか)という意味を持つ入力です。左右を同時に押すと 1-1=0 で動かず、上下も同じです。斜めは unit で長さをそろえます。(1,1) にそのまま速さ15を掛けると、斜めだけ約1.41倍の距離を進んでしまうからです。unit は方向を1000倍した整数として持ち、実際に動かすときに round(四捨五入)で戻します。そのため1回の斜め移動は (11,11) になり、整数に丸めた分のわずかな誤差があります。「完全に同じ距離」ではなく、「元のゲームと同じ方法で長さをそろえ、丸めている」と理解してください。

State は frozen=True の dataclass です。s.x に直接足すのではなく、replace で新しい State を作って返します。そのため、描画を2回しても移動は増えず、2つの Controller がおたがいの位置を変えてしまうこともありません。move は、まず横、次に縦の順に調べます。騎士の半径50を含めて、x は130〜1470、y は130〜910 の範囲にだけ置けます。壁に向かう一歩が範囲の外に出るなら、その向き(軸)だけは動かさず、もう一方の向きには動けます。範囲の端にぴったりそろえる clamp とは違うので、壁との間に少し隙間が残ることもあります。

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

01_movement.py / 注目する部分
    direction = unit(dx, dy) if dx or dy else s.facing
    x, y = move(b, s.x, s.y, direction, WALK_SPEED) if dx or dy else (s.x, s.y)
    return replace(s, x=x, y=y, facing=direction, tick=s.tick + 1)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_movement.py
macOS / Linux
.venv/bin/python 01_movement.py
画面で見ること最初の POSITION は 300,520 です。右を10 tick 押すと 450,520 になります。確認用に python 01_movement.py --demo --headless --frames 30 --output qa/my_movement を実行すると、右10 tick → 下10 tick → 右下10 tick の入力が正確に入り、TICK 30・POSITION 560,780 になります。勝ち負けの表示は PLAY のままです。
  • 内部の座標と、画面に描く座標を区別する
  • 矢印キーは押している間だけ動き、反対向きどうしは打ち消し合う
  • draw は状態を変えず、R で同じ最初の状態に戻る
つまずきやすいところdraw の中で x を増やさないでください。画面の座標と、内部の0.1px単位の座標を混ぜないでください。斜めの dx/dy にそれぞれ速さ15を掛けると、長さをそろえる処理(正規化)がなくなってしまいます。
少し変えてみよう

01 のファイルだけで、WALK_SPEED = 15 を WALK_SPEED = 20 に変えてみましょう。最初の状態から右へ10 tick 進むと、POSITION 500,520 になります。同じ変更を実行して確かめるテストは python test_stages.py Stages.test_01_literal_speed_exercise です。確認したら15に戻してください。

01_movement.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(224行)
01_movement.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段階。詳細はLESSON.md。"""
from dataclasses import dataclass, replace, asdict
from math import hypot
import json, hashlib
WALK_SPEED = 15

@dataclass(frozen=True)
class Level:
    title: str
    start: tuple
    enemies: tuple = ()
    wall: tuple | None = None
LEVELS = (Level('歩く練習', (300, 520)),)

@dataclass(frozen=True)
class Input:
    dx: int = 0
    dy: int = 0
    sword: bool = False
    dodge: bool = False

@dataclass(frozen=True)
class State:
    x: int
    y: int
    facing: tuple = (1000, 0)
    tick: int = 0
    status: str = 'play'
    enemies: tuple = ()

def initial(level=0):
    b = LEVELS[level] if isinstance(level, int) else level
    return State(*b.start)

def unit(dx, dy):
    length = hypot(dx, dy)
    return (round(dx / length * 1000), round(dy / length * 1000)) if length else (1000, 0)

def valid(b, x, y, r=50):
    return 80 + r <= x <= 1520 - r and 80 + r <= y <= 960 - r

def move(b, x, y, direction, speed):
    xx = x + round(direction[0] * speed / 1000)
    yy = y + round(direction[1] * speed / 1000)
    if valid(b, xx, y):
        x = xx
    if valid(b, x, yy):
        y = yy
    return (x, y)

def step(b, s, inp=Input()):
    dx = max(-1, min(1, inp.dx))
    dy = max(-1, min(1, inp.dy))
    direction = unit(dx, dy) if dx or dy else s.facing
    x, y = move(b, s.x, s.y, direction, WALK_SPEED) if dx or dy else (s.x, s.y)
    return replace(s, x=x, y=y, facing=direction, tick=s.tick + 1)

def state_hash(s):
    return hashlib.sha256(json.dumps(asdict(s), sort_keys=True, separators=(',', ':')).encode('utf-8')).hexdigest()
from pathlib import Path
import argparse
STAGE = 1
DEFAULT_LEVEL = 'A'
LEVEL_NAMES = ('A',)
DEMO_RUNS = {'A': ((10, 1, 0, 0, 0), (10, 0, 1, 0, 0), (10, 1, 1, 0, 0))}

def demo_input(frame, level):
    """1始まりのフレーム。各行は(回数, dx, dy, sword, dodge)。"""
    for count, dx, dy, sword, dodge in DEMO_RUNS[level]:
        if frame <= count:
            return Input(dx, dy, bool(sword), bool(dodge))
        frame -= count
    return Input()

class Controller:
    """キー集合を意味入力へ。R/Pのあとだけ全キー解放を待つ。"""

    def __init__(self, level=DEFAULT_LEVEL):
        self.b = LEVELS[LEVEL_NAMES.index(level)]
        self.s = initial(self.b)
        self.paused = False
        self.locked = False
        self.previous = set()

    def feed(self, held):
        held = set(held)
        pressed = held - self.previous
        self.previous = held
        if 'R' in pressed:
            self.s = initial(self.b)
            self.paused = False
            self.locked = True
            return
        if self.locked:
            if not held:
                self.locked = False
                fields = {name: False for name in ('prev_sword', 'prev_dodge') if hasattr(self.s, name)}
                self.s = replace(self.s, **fields)
            return
        if 'P' in pressed:
            self.paused = not self.paused
            self.locked = True
            return
        if self.paused:
            return
        inp = Input(int('RIGHT' in held) - int('LEFT' in held), int('DOWN' in held) - int('UP' in held), 'Z' in held, 'X' in held)
        self.s = step(self.b, self.s, inp)

class App:

    def __init__(self, args, pyxel):
        self.p = pyxel
        self.args = args
        self.c = Controller(args.level)
        self.frame = 0
        self.saved = False
        self.p.init(384, 320, title='032 Shieldless Knight - Stage ' + str(STAGE), fps=30, display_scale=2, headless=args.headless)
        self.p.run(self.update, self.draw)

    def update(self):
        if self.saved or self.p.btnp(self.p.KEY_ESCAPE):
            self.p.quit()
            return
        self.frame += 1
        if self.args.still:
            return
        if self.args.demo:
            self.c.s = step(self.c.b, self.c.s, demo_input(self.frame, self.args.level))
        else:
            names = ('UP', 'DOWN', 'LEFT', 'RIGHT', 'Z', 'X', 'P', 'R')
            self.c.feed({k for k in names if self.p.btn(getattr(self.p, 'KEY_' + k))})

    def point(self, x, y):
        return (32 + x / 5, 45 + y / 5)

    def draw(self):
        p, s = (self.p, self.c.s)
        p.cls(1)
        p.text(12, 8, '032 SHIELDLESS KNIGHT  STAGE ' + str(STAGE), 7)
        hp = getattr(s, 'hp', '-')
        p.text(12, 22, f'LEVEL {self.args.level}  TICK {s.tick}  HP {hp}  {s.status.upper()}', 10)
        p.text(12, 34, f'POSITION (tenths): {s.x},{s.y}', 6)
        p.rect(48, 61, 288, 176, 3)
        p.rectb(46, 59, 292, 180, 11)
        if self.c.b.wall:
            x1, y1, x2, y2 = self.c.b.wall
            xx, yy = self.point(x1, y1)
            p.rect(xx, yy, (x2 - x1) / 5, (y2 - y1) / 5, 5)
            p.rectb(xx, yy, (x2 - x1) / 5, (y2 - y1) / 5, 13)
        xx, yy = self.point(s.x, s.y)
        p.circ(xx, yy, 6, 12)
        d = s.sword_dir if getattr(s, 'sword', 0) else s.facing
        active = 13 <= getattr(s, 'sword', 0) <= 18
        length = 36 if active else 16
        p.line(xx, yy, xx + d[0] * length / 1000, yy + d[1] * length / 1000, 10 if active else 7)
        if getattr(s, 'dash', 0):
            p.circb(xx, yy, 12, 6)
        p.rect(0, 242, 384, 78, 1)
        p.text(12, 246, 'ARROWS MOVE / R RESET / P PAUSE / ESC QUIT', 7)
        p.text(12, 257, 'MOVEMENT ONLY: NO ENEMIES OR CLEAR YET', 6)
        p.text(12, 269, f'WALK {WALK_SPEED} / X 130..1470 / Y 130..910', 7)
        p.text(12, 281, 'BLUE: YOU / WHITE LINE: FACING', 13)
        p.text(12, 293, 'NO ATTACK OR CLEAR IN THIS PRACTICE', 13)
        label = 'PAUSED' if self.c.paused else 'ALL ENEMIES DEFEATED' if s.status == 'won' else 'DEFEATED - R TO RESET' if s.status == 'lost' else ''
        if label:
            p.rect(85, 135, 220, 20, 1)
            p.text(100, 142, label, 10)
        if self.args.frames is not None and self.frame >= self.args.frames:
            if self.args.output:
                self.args.output.mkdir(parents=True, exist_ok=True)
                stem = f'stage{STAGE}_{self.args.level}_{self.frame:03}'
                p.screenshot(str(self.args.output / (stem + '.png')), scale=2)
                report = {'stage': STAGE, 'level': self.args.level, 'frames': self.frame, 'headless': self.args.headless, 'demo': self.args.demo, 'label': 'Actual Pyxel render, fixed input, silent; not human playtest', 'state': asdict(s), 'state_hash': state_hash(s)}
                (self.args.output / (stem + '.json')).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8')
            self.saved = True

def parse_args(argv=None):
    parser = argparse.ArgumentParser(description='盾のない騎士・第' + str(STAGE) + '段階')
    parser.add_argument('--level', choices=LEVEL_NAMES, default=DEFAULT_LEVEL)
    parser.add_argument('--headless', action='store_true')
    parser.add_argument('--demo', action='store_true', help='固定入力を再生する')
    parser.add_argument('--still', action='store_true', help='状態を進めず描画する(検証用)')
    parser.add_argument('--frames', type=int, help='描画フレーム数。1以上')
    parser.add_argument('--output', type=Path, help='画像とUTF-8状態JSONの保存先')
    args = parser.parse_args(argv)
    if args.frames is not None and args.frames < 1:
        parser.error('--frames must be >= 1')
    if args.headless and args.frames is None:
        parser.error('--headless requires --frames')
    if args.output is not None and args.frames is None:
        parser.error('--output requires --frames')
    return args

def main(argv=None):
    args = parse_args(argv)
    import pyxel
    App(args, pyxel)
if __name__ == '__main__':
    main()
STEP 02 / 05

槍の予兆を見て横へ逃げる。回避にも無敵はない

段階ごとに切り替わる敵の状態・狙いの固定・長押しと押し直し・攻撃の番号。

赤い槍の敵を1体置きます。この段階だけ、騎士の最初の位置を (500,520) に近づけ、敵は (940,520) から歩かない練習用にしています。元のゲームの状況 A の開始位置や、追いかけてくる敵の動き(AI)とは違います。剣もまだないので敵は倒せず、クリアの条件もありません。止まっていると攻撃を受けて負けること、横へ逃げると生き残れることを確かめられます。敵にただ触れただけでは、ダメージはありません。

敵の状態 Enemy.stage は、idle(待機)→ windup(予兆)→ active(攻撃中)→ recover(戻り)→ idle の順に進みます。idle のとき、騎士が距離600以内にいれば、騎士がこの tick で動く前の位置に向けて狙う向き direction を決め、予兆の timer を36にします。続く36回の更新でカウントを減らし、0になった更新で攻撃 Attack を1つ発生させます。つまり、始まってすぐの tick1 で予兆36、tick36 で予兆1、tick37 で攻撃が有効になります。「36回目に発生する」と勘違いしないよう、予兆を始めると決めた tick と、カウントを減らす tick を分けて考えます。攻撃中は狙いを取り直さないので、騎士が横へ歩いても、槍は元の向きのままです。

槍の攻撃範囲は、長さ520・幅の半分が70の長方形で、人物の半径50も判定に含めます。region_hit は、攻撃の向きに沿った距離 along と、それに直角な向きの距離 side を求め、長方形から人物の中心までの距離を、人物の半径と比べます。四角を半径の分だけ単純に大きくすると、角の部分で、本当は当たらない場所まで当たりになってしまいます。画面の茶色の斜線が予兆、黄色が攻撃が有効な範囲、白い輪が戻りの最中です。描画と当たり判定が同じ region_hit を使うので、届く距離の数値を別々に持つ必要はありません。

X は12 tick の高速移動(回避)です。始めたときに向きを固定し、速さは28、次に使えるまでの待ち時間は30です。途中で矢印キーを離しても、回避 dash が残っている間は進み続けます。始める条件は、inp.dodge が真で prev_dodge が偽、つまり「いったん離して押し直した」ときです。押しっぱなしでは2回目の回避は出ません。攻撃を受けたあとの invuln=30 は続けてダメージを受けないためのもので、回避そのものに無敵時間はありません。回避しても槍の線の上に残っていれば、攻撃を受けます。

Attack.hit に騎士を表す -1 を記録して、同じ攻撃から何度もダメージを受けないようにします。残り時間 remaining が1の攻撃は、次の更新で使う attacks からは外しますが、その tick の resolved_attacks(判定に使った攻撃)には残します。こうすると、最後の有効な tick だけ、当たるのに画面に見えない攻撃になる食い違いを防げます。R や P を押したあとは、キーが全部離されるまで時間も進めません。押したままだった X が、すぐに回避として出てしまわないようにするためです。

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

02_telegraph.py / 注目する部分
    if inp.dodge and (not s.prev_dodge) and (not s.dash) and (not s.dash_cd):
        dash, cd, dd = (12, 30, direction)
    speed = 28 if dash else 15

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_telegraph.py
macOS / Linux
.venv/bin/python 02_telegraph.py
画面で見ることキーを押さないと、tick1 で WINDUP 36、tick37 で HP 2 になります。確認用の入力は、上12 tick → 何も押さない1 tick → 上+X を1 tick → 何も押さない26 tick です。python 02_telegraph.py --demo --headless --frames 40 --output qa/my_telegraph を実行すると、TICK 40・POSITION 500,144・HP 3 で、槍は ACTIVE・timer 3 になります。上の端の近くでは、範囲の検査で移動が止まります。
  • 予兆を始める前の位置に、狙いを固定する
  • 回避は速く動くだけで、無敵ではない
  • 長押しでの連続回避と、同じ攻撃が何度も当たることを防ぐ
つまずきやすいところ「回避中だから」という理由でダメージを消さないでください。予兆の間に狙いを毎回更新すると、「予兆を見て横へ逃げる」というルールが成り立たなくなります。X の長押しで、毎フレーム新しい回避を作らないでください。
少し変えてみよう

WINDUP = 36 を WINDUP = 18 に変え、最初の位置から動かずに確かめてみましょう。tick18 では HP 3・予兆1、tick19 で HP 2・ACTIVE になります。python test_stages.py Stages.test_02_literal_windup_exercise が、同じ書き換えを実行して確かめます。確認したら36に戻します。

02_telegraph.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(349行)
02_telegraph.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段階。詳細はLESSON.md。"""
from dataclasses import dataclass, replace, asdict
from math import hypot
import json, hashlib
WINDUP = 36

@dataclass(frozen=True)
class Level:
    title: str
    start: tuple
    enemies: tuple
    wall: tuple | None = None
LEVELS = (Level('槍を横へ避ける', (500, 520), (('spear', 940, 520),)),)

@dataclass(frozen=True)
class Input:
    dx: int = 0
    dy: int = 0
    sword: bool = False
    dodge: bool = False

@dataclass(frozen=True)
class Enemy:
    id: int
    kind: str
    x: int
    y: int
    hp: int = 3
    stage: str = 'idle'
    timer: int = 0
    direction: tuple = (1000, 0)
    attack_id: int = 0

@dataclass(frozen=True)
class Attack:
    id: int
    owner: int
    kind: str
    x: int
    y: int
    direction: tuple
    remaining: int = 6
    hit: tuple = ()

@dataclass(frozen=True)
class State:
    x: int
    y: int
    enemies: tuple
    facing: tuple = (1000, 0)
    hp: int = 3
    invuln: int = 0
    dash: int = 0
    dash_cd: int = 0
    dash_dir: tuple = (1000, 0)
    prev_dodge: bool = False
    attacks: tuple = ()
    resolved_attacks: tuple = ()
    tick: int = 0
    serial: int = 0
    status: str = 'play'

def initial(level=0):
    b = LEVELS[level] if isinstance(level, int) else level
    return State(*b.start, tuple((Enemy(i, kind, x, y) for i, (kind, x, y) in enumerate(b.enemies))))

def unit(dx, dy):
    length = hypot(dx, dy)
    return (round(dx / length * 1000), round(dy / length * 1000)) if length else (1000, 0)

def valid(b, x, y, r=50):
    return 80 + r <= x <= 1520 - r and 80 + r <= y <= 960 - r

def move(b, x, y, direction, speed):
    xx = x + round(direction[0] * speed / 1000)
    yy = y + round(direction[1] * speed / 1000)
    if valid(b, xx, y):
        x = xx
    if valid(b, x, yy):
        y = yy
    return (x, y)

def los(b, a, z):
    return True

def region_hit(kind, origin, d, p, r=50):
    dx, dy = (p[0] - origin[0], p[1] - origin[1])
    along = (dx * d[0] + dy * d[1]) / 1000
    side = abs(dx * d[1] - dy * d[0]) / 1000
    outside_x = max(-along, 0, along - 520)
    outside_y = max(0, side - 70)
    return outside_x * outside_x + outside_y * outside_y <= r * r

def step(b, s, inp=Input()):
    if s.status != 'play':
        return s
    e = s.enemies[0]
    serial = s.serial
    new_attacks = ()
    if e.stage == 'idle':
        if hypot(s.x - e.x, s.y - e.y) <= 600:
            serial += 1
            e = replace(e, stage='windup', timer=WINDUP, direction=unit(s.x - e.x, s.y - e.y), attack_id=serial)
    else:
        timer = e.timer - 1
        if timer > 0:
            e = replace(e, timer=timer)
        elif e.stage == 'windup':
            new_attacks = (Attack(e.attack_id, e.id, e.kind, e.x, e.y, e.direction),)
            e = replace(e, stage='active', timer=6)
        elif e.stage == 'active':
            e = replace(e, stage='recover', timer=42)
        else:
            e = replace(e, stage='idle', timer=0)
    dx, dy = (max(-1, min(1, inp.dx)), max(-1, min(1, inp.dy)))
    direction = unit(dx, dy) if dx or dy else s.facing
    dash, cd, dd = (max(0, s.dash - 1), max(0, s.dash_cd - 1), s.dash_dir)
    if inp.dodge and (not s.prev_dodge) and (not s.dash) and (not s.dash_cd):
        dash, cd, dd = (12, 30, direction)
    speed = 28 if dash else 15
    x, y = move(b, s.x, s.y, dd if dash else direction, speed) if dash or dx or dy else (s.x, s.y)
    hurt = 0
    attacks, resolved = ([], [])
    for a in s.attacks + new_attacks:
        hit = a.hit
        if -1 not in hit and region_hit(a.kind, (a.x, a.y), a.direction, (x, y)):
            hit += (-1,)
            if s.invuln == 0 and (not hurt):
                hurt = 1
        resolved.append(replace(a, hit=hit))
        if a.remaining > 1:
            attacks.append(replace(a, remaining=a.remaining - 1, hit=hit))
    hp = max(0, s.hp - hurt)
    return replace(s, x=x, y=y, enemies=(e,), facing=direction, hp=hp, invuln=30 if hurt else max(0, s.invuln - 1), dash=dash, dash_cd=cd, dash_dir=dd, prev_dodge=inp.dodge, attacks=tuple(attacks), resolved_attacks=tuple(resolved), tick=s.tick + 1, serial=serial, status='lost' if hp == 0 else 'play')

def state_hash(s):
    return hashlib.sha256(json.dumps(asdict(s), sort_keys=True, separators=(',', ':')).encode('utf-8')).hexdigest()

# --- 教材用の操作と描画。上のモデルはPyxelを知らない。 ---
from pathlib import Path
import argparse

STAGE = 2
DEFAULT_LEVEL = 'A'
LEVEL_NAMES = ('A',)
DEMO_RUNS = {
    'A': (
        (12, 0, -1, 0, 0),
        (1, 0, 0, 0, 0),
        (1, 0, -1, 0, 1),
    ),
}

def demo_input(frame, level):
    """1始まりのフレーム。各行は(回数, dx, dy, sword, dodge)。"""
    for count, dx, dy, sword, dodge in DEMO_RUNS[level]:
        if frame <= count:
            return Input(dx, dy, bool(sword), bool(dodge))
        frame -= count
    return Input()

class Controller:
    """キー集合を意味入力へ。R/Pのあとだけ全キー解放を待つ。"""
    def __init__(self, level=DEFAULT_LEVEL):
        self.b = LEVELS[LEVEL_NAMES.index(level)]
        self.s = initial(self.b)
        self.paused = False
        self.locked = False
        self.previous = set()

    def feed(self, held):
        held = set(held)
        pressed = held - self.previous
        self.previous = held
        if "R" in pressed:
            self.s = initial(self.b)
            self.paused = False
            self.locked = True
            return
        if self.locked:
            if not held:
                self.locked = False
                fields = {name: False for name in ("prev_sword", "prev_dodge")
                          if hasattr(self.s, name)}
                self.s = replace(self.s, **fields)
            return
        if "P" in pressed:
            self.paused = not self.paused
            self.locked = True
            return
        if self.paused:
            return
        inp = Input(int("RIGHT" in held) - int("LEFT" in held),
                    int("DOWN" in held) - int("UP" in held),
                    "Z" in held, "X" in held)
        self.s = step(self.b, self.s, inp)

class App:
    def __init__(self, args, pyxel):
        self.p = pyxel
        self.args = args
        self.c = Controller(args.level)
        self.frame = 0
        self.saved = False
        self.p.init(384, 320, title="032 Shieldless Knight - Stage " + str(STAGE),
                    fps=30, display_scale=2, headless=args.headless)
        self.p.run(self.update, self.draw)

    def update(self):
        # 最終フレームはdrawで保存し、その次のupdateで終了する。
        if self.saved or self.p.btnp(self.p.KEY_ESCAPE):
            self.p.quit()
            return
        self.frame += 1
        if self.args.still:
            return
        if self.args.demo:
            self.c.s = step(self.c.b, self.c.s, demo_input(self.frame, self.args.level))
        else:
            names = ("UP", "DOWN", "LEFT", "RIGHT", "Z", "X", "P", "R")
            self.c.feed({k for k in names if self.p.btn(getattr(self.p, "KEY_" + k))})

    def point(self, x, y):
        return 32 + x / 5, 45 + y / 5

    def hazard(self, kind, x, y, direction, color, fill=False):
        # 原作と同じregion_hitとlosで、予兆と有効範囲を描く。
        for px in range(8, 153):
            for py in range(8, 97):
                point = (px * 10, py * 10)
                if region_hit(kind, (x, y), direction, point, 0) and los(self.c.b, (x, y), point):
                    edge = any(not region_hit(kind, (x, y), direction,
                               (point[0] + dx, point[1] + dy), 0)
                               for dx, dy in ((10, 0), (-10, 0), (0, 10), (0, -10)))
                    if fill or edge or (px + py) % 9 == 0:
                        xx, yy = self.point(*point)
                        self.p.rect(xx, yy, 2, 2, color)

    def draw(self):
        p, s = self.p, self.c.s
        p.cls(1)
        p.text(12, 8, "032 SHIELDLESS KNIGHT  STAGE " + str(STAGE), 7)
        hp = getattr(s, "hp", "-")
        p.text(12, 22, f"LEVEL {self.args.level}  TICK {s.tick}  HP {hp}  {s.status.upper()}", 10)
        p.text(12, 34, f"POSITION (tenths): {s.x},{s.y}", 6)
        p.rect(48, 61, 288, 176, 3)
        p.rectb(46, 59, 292, 180, 11)
        for e in s.enemies:
            if e.hp > 0 and e.stage == "windup":
                self.hazard(e.kind, e.x, e.y, e.direction, 4)
        for a in getattr(s, "resolved_attacks", ()):
            self.hazard(a.kind, a.x, a.y, a.direction, 10, True)
        if self.c.b.wall:
            x1, y1, x2, y2 = self.c.b.wall
            xx, yy = self.point(x1, y1)
            p.rect(xx, yy, (x2 - x1) / 5, (y2 - y1) / 5, 5)
            p.rectb(xx, yy, (x2 - x1) / 5, (y2 - y1) / 5, 13)
        for e in s.enemies:
            xx, yy = self.point(e.x, e.y)
            if e.hp <= 0:
                p.line(xx - 5, yy - 3, xx + 5, yy + 3, 5)
                continue
            if e.stage == "recover":
                p.circb(xx, yy, 14, 7)
            p.circ(xx, yy, 7, 8 if e.kind == "spear" else 9)
            p.rect(xx - 9, yy + 12, 14, 9, 1)
            p.text(xx - 8, yy + 13, str(e.hp), 7)
            if e.stage == "windup":
                p.text(xx - 5, yy - 18, str(e.timer), 8)
        xx, yy = self.point(s.x, s.y)
        p.circ(xx, yy, 6, 12)
        d = s.sword_dir if getattr(s, "sword", 0) else s.facing
        active = 13 <= getattr(s, "sword", 0) <= 18
        length = 36 if active else 16
        p.line(xx, yy, xx + d[0] * length / 1000, yy + d[1] * length / 1000, 10 if active else 7)
        if getattr(s, "dash", 0):
            p.circb(xx, yy, 12, 6)
        p.rect(0, 242, 384, 78, 1)
        p.text(12, 246, "ARROWS MOVE / R RESET / P PAUSE / ESC QUIT", 7)
        p.text(12, 257, 'X: DODGE = FAST MOVEMENT, NOT INVINCIBLE', 6)
        p.text(12, 269, f"DASH {s.dash}  COOLDOWN {s.dash_cd}", 7)
        p.text(12, 281, f"SPEAR {s.enemies[0].stage.upper()} / TIMER {s.enemies[0].timer}", 7)
        p.text(12, 293, "BLUE: YOU / RED: SPEAR / NO CLEAR YET", 13)
        label = "PAUSED" if self.c.paused else "ALL ENEMIES DEFEATED" if s.status == "won" else "DEFEATED - R TO RESET" if s.status == "lost" else ""
        if label:
            p.rect(85, 135, 220, 20, 1)
            p.text(100, 142, label, 10)
        if self.args.frames is not None and self.frame >= self.args.frames:
            if self.args.output:
                self.args.output.mkdir(parents=True, exist_ok=True)
                stem = f"stage{STAGE}_{self.args.level}_{self.frame:03}"
                p.screenshot(str(self.args.output / (stem + ".png")), scale=2)
                report = {"stage": STAGE, "level": self.args.level, "frames": self.frame,
                          "headless": self.args.headless, "demo": self.args.demo,
                          "label": "Actual Pyxel render, fixed input, silent; not human playtest",
                          "state": asdict(s), "state_hash": state_hash(s)}
                (self.args.output / (stem + ".json")).write_text(
                    json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
            self.saved = True

def parse_args(argv=None):
    parser = argparse.ArgumentParser(description="盾のない騎士・第" + str(STAGE) + "段階")
    parser.add_argument("--level", choices=LEVEL_NAMES, default=DEFAULT_LEVEL)
    parser.add_argument("--headless", action="store_true")
    parser.add_argument("--demo", action="store_true", help="固定入力を再生する")
    parser.add_argument("--still", action="store_true", help="状態を進めず描画する(検証用)")
    parser.add_argument("--frames", type=int, help="描画フレーム数。1以上")
    parser.add_argument("--output", type=Path, help="画像とUTF-8状態JSONの保存先")
    args = parser.parse_args(argv)
    if args.frames is not None and args.frames < 1:
        parser.error("--frames must be >= 1")
    if args.headless and args.frames is None:
        parser.error("--headless requires --frames")
    if args.output is not None and args.frames is None:
        parser.error("--output requires --frames")
    return args

def main(argv=None):
    args = parse_args(argv)  # 無効な引数をPyxelのimportより先に拒否。
    import pyxel
    App(args, pyxel)

if __name__ == "__main__":
    main()
STEP 03 / 05

白い隙へ近づき、剣を一度だけ当てる

剣の構え・有効・戻り/ダメージをまとめて反映/State に項目を足す。

元のゲームの状況 A「横へ、一歩」の開始位置と、追いかけてくる敵を使い、Z で振る剣を加えます。ここからは、すべての敵の HP を0にすると WON、騎士の HP が0になると LOST です。前の段階の「動かない敵をよけるだけ」から、相手の攻撃が終わるのを待って近づく戦いへ進みます。状況 A には槍の敵が1体だけで柱もないので、敵どうしのダメージと、柱をよける道の計算は、まだ省いています。元のゲームで保存した状況 A の入力を、すべての tick で比べて、同じ状態になることを確かめています。

Input.sword を新しく押すと、sword を24にします。値が24〜19の6 tick は構え(まだ当たらない)、18〜13の6 tick が有効、12〜1の12 tick が戻りです。前に長さ180・幅の半分が100の剣の範囲に、半径50の敵が触れたら1ダメージです。sword_hit は、この一振りがもう当たった敵の番号を並べたタプルです。敵が6 tick ずっと範囲の中にいても、6ダメージにはなりません。振り始めの向き sword_dir も固定し、そのあと移動しても剣の向きは変わりません。

剣を振っている間の速さは12、ふだんは15、回避中は28です。剣の戻りの最中でもゆっくり動けますが、新しく剣を振ったり、回避を始めたりはできません。Z と X を同じ tick に新しく押したときは、剣を優先します。前の状態の s.sword が0かどうかで判定するので、戻りが終わるのとまったく同じ tick には、新しい一振りを始められません。一度キーを離し、動きが終わってから押し直すと確実です。

敵は70論理px以内にいる騎士を見つけ(観測)、1 tick に0.6px ずつ近づき、槍なら60px以内で予兆に入ります。処理の順番は、observe(見る)→ policy(どうするか決める)→ 位置の更新で、この tick のプレイヤーの入力を先に読むことはありません。見失ったとき用の記憶 Memory はこの段階から状態に持っていますが、柱で見えなくなる仕組みを使うのは05からです。ゲームのコードが長くなるので、最初は Input・State に足した項目と、step の剣の当たり判定の部分に集中しましょう。敵の動き(AI)と描画は、動かしながらあとで読み返してかまいません。

ダメージはいったん damage のリストにためておき、すべての攻撃を調べ終わってから、敵の HP にまとめて反映します。剣を当てても、敵の予兆は途中で止まりません。すでに出た攻撃は、攻撃した敵が倒れても、残り時間が終わるまで続きます。最後の敵と騎士が同じ tick に倒れたときは、status の条件式が LOST を先に選びます(相打ちは負け)。見た目に合わせて敵の HP を先に0に書き換える作りでは、この相打ちのルールを正しく扱えません。

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

03_sword.py / 注目する部分
    if 13 <= sw <= 18:
        for e in enemies:
            if e.hp > 0 and e.id not in sh and region_hit('sword', (x, y), sd, (e.x, e.y)) and los(b, (x, y), (e.x, e.y)):
                damage[e.id] += 1
                sh += (e.id,)
                swh += 1

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_sword.py
macOS / Linux
.venv/bin/python 03_sword.py
画面で見ること決まった入力の最初の部分は、右3 tick → 待つ42 tick → 右24 tick → 右+Z を1 tick → 右4 tick → 待つ3 tick です。python 03_sword.py --demo --headless --frames 77 --output qa/my_sword を実行すると、HP 3・SWORD HITS 1・敵 HP 2 になります。--frames 162 に変えると、元のゲームの状況 A の保存入力を最後まで再生し、WON・TICK 162・HP 2・SWORD HITS 3 になります。入力は、ファイルの最後にある DEMO_RUNS に、くり返す回数つきで入っています。
  • 剣の24 tick を、構え6・有効6・戻り12に分ける
  • 1回の振りで、同じ敵には1回だけ当たる
  • ダメージはまとめて反映し、相打ちは負け
つまずきやすいところ有効な6 tick のたびに、同じ敵へダメージを足さないでください。剣が当たった瞬間に敵をひるませたり、攻撃した敵が倒れたからといって、すでに出た攻撃を消したりしないでください。
少し変えてみよう

剣の当たり判定の damage[e.id] += 1 を、damage[e.id] += 2 に1か所だけ変えてみましょう。77 tick の同じ入力では、SWORD HITS は1のまま、敵の HP だけが2から1に変わります。python test_stages.py Stages.test_03_literal_sword_exercise で、同じ書き換えを確かめられます。確認したら1に戻します。

03_sword.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(497行)
03_sword.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段階。詳細はLESSON.md。"""
"""Pure 30Hz combat, fixed-point tenths of pixels, perception-bounded enemies."""
from dataclasses import dataclass, replace, asdict
from math import hypot
import json, hashlib, heapq
SCALE = 10

@dataclass(frozen=True)
class Level:
    title: str
    start: tuple
    enemies: tuple
    wall: tuple | None = None
LEVELS = (Level('横へ、一歩', (300, 520), (('spear', 940, 520),)),)

@dataclass(frozen=True)
class Input:
    dx: int = 0
    dy: int = 0
    sword: bool = False
    dodge: bool = False

@dataclass(frozen=True)
class Observation:
    seen: tuple | None

@dataclass(frozen=True)
class Memory:
    point: tuple | None = None
    wait: int = 0

@dataclass(frozen=True)
class Intent:
    goal: tuple | None
    start: bool
    direction: tuple
    memory: Memory

@dataclass(frozen=True)
class Enemy:
    id: int
    kind: str
    x: int
    y: int
    home: tuple
    hp: int = 3
    stage: str = 'idle'
    timer: int = 0
    direction: tuple = (1000, 0)
    memory: Memory = Memory()
    attack_id: int = 0

@dataclass(frozen=True)
class Attack:
    id: int
    owner: int
    kind: str
    x: int
    y: int
    direction: tuple
    remaining: int = 6
    hit: tuple = ()

@dataclass(frozen=True)
class State:
    x: int
    y: int
    enemies: tuple
    facing: tuple = (1000, 0)
    hp: int = 3
    invuln: int = 0
    dash: int = 0
    dash_cd: int = 0
    dash_dir: tuple = (1000, 0)
    sword: int = 0
    sword_dir: tuple = (1000, 0)
    sword_hit: tuple = ()
    prev_sword: bool = False
    prev_dodge: bool = False
    attacks: tuple = ()
    tick: int = 0
    serial: int = 0
    status: str = 'play'
    friendly_hits: int = 0
    sword_hits: int = 0
    damage_cause: str = ''
    observations: tuple = ()
    resolved_attacks: tuple = ()

def initial(level=0):
    b = LEVELS[level] if isinstance(level, int) else level
    return State(*b.start, tuple((Enemy(i, k, x, y, (x, y)) for i, (k, x, y) in enumerate(b.enemies))))

def unit(dx, dy):
    h = hypot(dx, dy)
    return (round(dx / h * 1000), round(dy / h * 1000)) if h else (1000, 0)

def los(b, a, z):
    return True

def valid(b, x, y, r=50):
    return 80 + r <= x <= 1520 - r and 80 + r <= y <= 960 - r

def move(b, x, y, d, speed):
    xx = x + round(d[0] * speed / 1000)
    yy = y + round(d[1] * speed / 1000)
    if valid(b, xx, y):
        x = xx
    if valid(b, x, yy):
        y = yy
    return (x, y)

def waypoint(b, p, target):
    return target

def observe(b, e, player):
    return Observation(player if hypot(player[0] - e.x, player[1] - e.y) <= 700 and los(b, (e.x, e.y), player) else None)

def policy(o, m, e):
    if e.stage != 'idle' or e.hp <= 0:
        return Intent(None, False, e.direction, m)
    if o.seen is not None:
        dist = hypot(o.seen[0] - e.x, o.seen[1] - e.y)
        reach = 600 if e.kind == 'spear' else 370
        return Intent(o.seen, dist <= reach, unit(o.seen[0] - e.x, o.seen[1] - e.y), Memory(o.seen, 12))
    if m.point is not None:
        if hypot(m.point[0] - e.x, m.point[1] - e.y) > 10:
            return Intent(m.point, False, e.direction, m)
        if m.wait > 0:
            return Intent(None, False, e.direction, replace(m, wait=m.wait - 1))
    return Intent(e.home, False, e.direction, Memory())

def region_hit(kind, origin, d, p, r=50):
    dx, dy = (p[0] - origin[0], p[1] - origin[1])
    if kind == 'axe':
        return dx * dx + dy * dy <= (290 + r) ** 2
    along = (dx * d[0] + dy * d[1]) / 1000
    side = abs(dx * d[1] - dy * d[0]) / 1000
    length, half = (520, 70) if kind == 'spear' else (180, 100)
    outside_x = max(-along, 0, along - length)
    outside_y = max(0, side - half)
    return outside_x * outside_x + outside_y * outside_y <= r * r

def step(b, s, inp=Input(), friend_damage=2):
    if s.status != 'play':
        return s
    enemies = []
    obs = []
    serial = s.serial
    new_attacks = []
    for e in s.enemies:
        o = observe(b, e, (s.x, s.y)) if e.stage == 'idle' and e.hp > 0 else Observation(None)
        obs.append(o)
        it = policy(o, e.memory, e)
        if e.hp <= 0:
            enemies.append(e)
            continue
        if e.stage == 'idle':
            if it.start:
                serial += 1
                e = replace(e, stage='windup', timer=36 if e.kind == 'spear' else 42, direction=it.direction, memory=it.memory, attack_id=serial)
            else:
                xx, yy = (e.x, e.y)
                if it.goal is not None:
                    dest = waypoint(b, (e.x, e.y), it.goal)
                    if hypot(dest[0] - e.x, dest[1] - e.y) > 6:
                        xx, yy = move(b, e.x, e.y, unit(dest[0] - e.x, dest[1] - e.y), 6)
                e = replace(e, x=xx, y=yy, memory=it.memory)
        else:
            timer = e.timer - 1
            if timer <= 0:
                if e.stage == 'windup':
                    new_attacks.append(Attack(e.attack_id, e.id, e.kind, e.x, e.y, e.direction))
                    e = replace(e, stage='active', timer=6)
                elif e.stage == 'active':
                    e = replace(e, stage='recover', timer=42)
                else:
                    e = replace(e, stage='idle', timer=0)
            else:
                e = replace(e, timer=timer)
        enemies.append(e)
    dx = max(-1, min(1, inp.dx))
    dy = max(-1, min(1, inp.dy))
    direction = unit(dx, dy) if dx or dy else s.facing
    facing = direction if dx or dy else s.facing
    dash = max(0, s.dash - 1)
    cd = max(0, s.dash_cd - 1)
    sw = max(0, s.sword - 1)
    dd = s.dash_dir
    sd = s.sword_dir
    sh = s.sword_hit
    if inp.sword and (not s.prev_sword) and (not s.sword) and (not s.dash):
        sw = 24
        sd = facing
        sh = ()
    elif inp.dodge and (not s.prev_dodge) and (not s.sword) and (not s.dash) and (not s.dash_cd):
        dash = 12
        cd = 30
        dd = direction
    speed = 28 if dash else 12 if sw else 15
    x, y = move(b, s.x, s.y, dd if dash else direction, speed) if dash or dx or dy else (s.x, s.y)
    damage = [0] * len(enemies)
    hurt = 0
    cause = s.damage_cause
    fh = s.friendly_hits
    swh = s.sword_hits
    if 13 <= sw <= 18:
        for e in enemies:
            if e.hp > 0 and e.id not in sh and region_hit('sword', (x, y), sd, (e.x, e.y)) and los(b, (x, y), (e.x, e.y)):
                damage[e.id] += 1
                sh += (e.id,)
                swh += 1
    attacks = []
    resolved = []
    for a in s.attacks + tuple(new_attacks):
        hit = a.hit
        if -1 not in hit and region_hit(a.kind, (a.x, a.y), a.direction, (x, y)) and los(b, (a.x, a.y), (x, y)):
            hit += (-1,)
            if s.invuln == 0 and (not hurt):
                hurt = 1
                cause = f'{a.kind}#{a.id}'
        resolved.append(replace(a, hit=hit))
        if a.remaining > 1:
            attacks.append(replace(a, remaining=a.remaining - 1, hit=hit))
    enemies = tuple((replace(e, hp=max(0, e.hp - damage[e.id]), stage='dead' if e.hp - damage[e.id] <= 0 else e.stage) for e in enemies))
    hp = max(0, s.hp - hurt)
    status = 'lost' if hp == 0 else 'won' if all((e.hp == 0 for e in enemies)) else 'play'
    return State(x, y, enemies, facing, hp, 30 if hurt else max(0, s.invuln - 1), dash, cd, dd, sw, sd, sh, inp.sword, inp.dodge, tuple(attacks), s.tick + 1, serial, status, fh, swh, cause, tuple(obs), tuple(resolved))

def state_hash(s):
    return hashlib.sha256(json.dumps(asdict(s), sort_keys=True, separators=(',', ':')).encode('utf-8')).hexdigest()

# --- 教材用の操作と描画。上のモデルはPyxelを知らない。 ---
from pathlib import Path
import argparse

STAGE = 3
DEFAULT_LEVEL = 'A'
LEVEL_NAMES = ('A',)
DEMO_RUNS = {
    'A': (
        (3, 1, 0, 0, 0),
        (42, 0, 0, 0, 0),
        (24, 1, 0, 0, 0),
        (1, 1, 0, 1, 0),
        (4, 1, 0, 0, 0),
        (3, 0, 0, 0, 0),
        (1, -1, 0, 0, 0),
        (3, 0, -1, 0, 0),
        (7, 0, 0, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 1, 0, 1, 0),
        (1, 1, -1, 0, 0),
        (23, 0, 0, 0, 0),
        (1, 1, 0, 1, 0),
        (1, -1, -1, 0, 0),
        (5, 0, 0, 0, 0),
    ),
}

def demo_input(frame, level):
    """1始まりのフレーム。各行は(回数, dx, dy, sword, dodge)。"""
    for count, dx, dy, sword, dodge in DEMO_RUNS[level]:
        if frame <= count:
            return Input(dx, dy, bool(sword), bool(dodge))
        frame -= count
    return Input()

class Controller:
    """キー集合を意味入力へ。R/Pのあとだけ全キー解放を待つ。"""
    def __init__(self, level=DEFAULT_LEVEL):
        self.b = LEVELS[LEVEL_NAMES.index(level)]
        self.s = initial(self.b)
        self.paused = False
        self.locked = False
        self.previous = set()

    def feed(self, held):
        held = set(held)
        pressed = held - self.previous
        self.previous = held
        if "R" in pressed:
            self.s = initial(self.b)
            self.paused = False
            self.locked = True
            return
        if self.locked:
            if not held:
                self.locked = False
                fields = {name: False for name in ("prev_sword", "prev_dodge")
                          if hasattr(self.s, name)}
                self.s = replace(self.s, **fields)
            return
        if "P" in pressed:
            self.paused = not self.paused
            self.locked = True
            return
        if self.paused:
            return
        inp = Input(int("RIGHT" in held) - int("LEFT" in held),
                    int("DOWN" in held) - int("UP" in held),
                    "Z" in held, "X" in held)
        self.s = step(self.b, self.s, inp)

class App:
    def __init__(self, args, pyxel):
        self.p = pyxel
        self.args = args
        self.c = Controller(args.level)
        self.frame = 0
        self.saved = False
        self.p.init(384, 320, title="032 Shieldless Knight - Stage " + str(STAGE),
                    fps=30, display_scale=2, headless=args.headless)
        self.p.run(self.update, self.draw)

    def update(self):
        # 最終フレームはdrawで保存し、その次のupdateで終了する。
        if self.saved or self.p.btnp(self.p.KEY_ESCAPE):
            self.p.quit()
            return
        self.frame += 1
        if self.args.still:
            return
        if self.args.demo:
            self.c.s = step(self.c.b, self.c.s, demo_input(self.frame, self.args.level))
        else:
            names = ("UP", "DOWN", "LEFT", "RIGHT", "Z", "X", "P", "R")
            self.c.feed({k for k in names if self.p.btn(getattr(self.p, "KEY_" + k))})

    def point(self, x, y):
        return 32 + x / 5, 45 + y / 5

    def hazard(self, kind, x, y, direction, color, fill=False):
        # 原作と同じregion_hitとlosで、予兆と有効範囲を描く。
        for px in range(8, 153):
            for py in range(8, 97):
                point = (px * 10, py * 10)
                if region_hit(kind, (x, y), direction, point, 0) and los(self.c.b, (x, y), point):
                    edge = any(not region_hit(kind, (x, y), direction,
                               (point[0] + dx, point[1] + dy), 0)
                               for dx, dy in ((10, 0), (-10, 0), (0, 10), (0, -10)))
                    if fill or edge or (px + py) % 9 == 0:
                        xx, yy = self.point(*point)
                        self.p.rect(xx, yy, 2, 2, color)

    def draw(self):
        p, s = self.p, self.c.s
        p.cls(1)
        p.text(12, 8, "032 SHIELDLESS KNIGHT  STAGE " + str(STAGE), 7)
        hp = getattr(s, "hp", "-")
        p.text(12, 22, f"LEVEL {self.args.level}  TICK {s.tick}  HP {hp}  {s.status.upper()}", 10)
        p.text(12, 34, f"POSITION (tenths): {s.x},{s.y}", 6)
        p.rect(48, 61, 288, 176, 3)
        p.rectb(46, 59, 292, 180, 11)
        for e in s.enemies:
            if e.hp > 0 and e.stage == "windup":
                self.hazard(e.kind, e.x, e.y, e.direction, 4)
        for a in getattr(s, "resolved_attacks", ()):
            self.hazard(a.kind, a.x, a.y, a.direction, 10, True)
        if self.c.b.wall:
            x1, y1, x2, y2 = self.c.b.wall
            xx, yy = self.point(x1, y1)
            p.rect(xx, yy, (x2 - x1) / 5, (y2 - y1) / 5, 5)
            p.rectb(xx, yy, (x2 - x1) / 5, (y2 - y1) / 5, 13)
        for e in s.enemies:
            xx, yy = self.point(e.x, e.y)
            if e.hp <= 0:
                p.line(xx - 5, yy - 3, xx + 5, yy + 3, 5)
                continue
            if e.stage == "recover":
                p.circb(xx, yy, 14, 7)
            p.circ(xx, yy, 7, 8 if e.kind == "spear" else 9)
            p.rect(xx - 9, yy + 12, 14, 9, 1)
            p.text(xx - 8, yy + 13, str(e.hp), 7)
            if e.stage == "windup":
                p.text(xx - 5, yy - 18, str(e.timer), 8)
        xx, yy = self.point(s.x, s.y)
        p.circ(xx, yy, 6, 12)
        d = s.sword_dir if getattr(s, "sword", 0) else s.facing
        active = 13 <= getattr(s, "sword", 0) <= 18
        length = 36 if active else 16
        p.line(xx, yy, xx + d[0] * length / 1000, yy + d[1] * length / 1000, 10 if active else 7)
        if getattr(s, "dash", 0):
            p.circb(xx, yy, 12, 6)
        p.rect(0, 242, 384, 78, 1)
        p.text(12, 246, "ARROWS MOVE / R RESET / P PAUSE / ESC QUIT", 7)
        p.text(12, 257, 'Z: SWORD / X: DODGE (NO INVINCIBILITY)', 6)
        p.text(12, 269, f"SWORD {getattr(s,'sword',0)}  DASH {getattr(s,'dash',0)}  COOLDOWN {getattr(s,'dash_cd',0)}", 7)
        p.text(12, 281, f"SWORD HITS {getattr(s,'sword_hits',0)}  FRIENDLY HITS {getattr(s,'friendly_hits',0)}", 7)
        p.text(12, 293, "BLUE: YOU / RED: SPEAR / ORANGE: AXE", 13)
        label = "PAUSED" if self.c.paused else "ALL ENEMIES DEFEATED" if s.status == "won" else "DEFEATED - R TO RESET" if s.status == "lost" else ""
        if label:
            p.rect(85, 135, 220, 20, 1)
            p.text(100, 142, label, 10)
        if self.args.frames is not None and self.frame >= self.args.frames:
            if self.args.output:
                self.args.output.mkdir(parents=True, exist_ok=True)
                stem = f"stage{STAGE}_{self.args.level}_{self.frame:03}"
                p.screenshot(str(self.args.output / (stem + ".png")), scale=2)
                report = {"stage": STAGE, "level": self.args.level, "frames": self.frame,
                          "headless": self.args.headless, "demo": self.args.demo,
                          "label": "Actual Pyxel render, fixed input, silent; not human playtest",
                          "state": asdict(s), "state_hash": state_hash(s)}
                (self.args.output / (stem + ".json")).write_text(
                    json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
            self.saved = True

def parse_args(argv=None):
    parser = argparse.ArgumentParser(description="盾のない騎士・第" + str(STAGE) + "段階")
    parser.add_argument("--level", choices=LEVEL_NAMES, default=DEFAULT_LEVEL)
    parser.add_argument("--headless", action="store_true")
    parser.add_argument("--demo", action="store_true", help="固定入力を再生する")
    parser.add_argument("--still", action="store_true", help="状態を進めず描画する(検証用)")
    parser.add_argument("--frames", type=int, help="描画フレーム数。1以上")
    parser.add_argument("--output", type=Path, help="画像とUTF-8状態JSONの保存先")
    args = parser.parse_args(argv)
    if args.frames is not None and args.frames < 1:
        parser.error("--frames must be >= 1")
    if args.headless and args.frames is None:
        parser.error("--headless requires --frames")
    if args.output is not None and args.frames is None:
        parser.error("--output requires --frames")
    return args

def main(argv=None):
    args = parse_args(argv)  # 無効な引数をPyxelのimportより先に拒否。
    import pyxel
    App(args, pyxel)

if __name__ == "__main__":
    main()
STEP 04 / 05

敵の大振りを、別の敵にも当てる

攻撃した敵の番号・相手ごとの命中記録・円の範囲・「もし〜なら」の比較。

元のゲームの状況 B「槍の向こう」へ進みます。最初の位置は、騎士 (380,520)、槍 (900,520)、斧 (1220,520) です。斧は半径290の円の範囲で周りを攻撃し、予兆42 tick、有効6 tick、戻り42 tick です。槍のまっすぐな攻撃を横へよける動きと、斧の円から離れる動きは違います。敵が2体になっただけでなく、「敵の大振りを、別の敵に当てる」という選び方が加わります。状況 B には柱がないので、柱はまだ作りません。

Attack.owner には攻撃を出した敵の番号が入り、ほかの敵に当たるかを調べるとき、e.id != a.owner で自分自身を除きます。敵に当たったときのダメージは friend_damage=2、騎士に当たったときは1です。剣が敵に与える1とは別の数値なので、1つの DAMAGE 定数にまとめないほうが、ルールを追いやすくなります。元のゲームの敵どうしは、仲間として上手に攻撃をよけたりはしません。固定された攻撃範囲の中にいれば巻き込まれます。

同じ Attack.hit の中に、騎士なら -1、敵ならその id を保存します。1回の攻撃が、騎士と別の敵の両方に当たることはありますが、同じ相手に何度も当たることはありません。owner と attack_id は役割が違います。owner は攻撃した敵、attack_id は「何回目の大振りか」を表す、ほかと重ならない番号です。攻撃が6 tick 続く間に毎回新しい ID を作ると、「1回だけ当たる」という約束が崩れます。

s.friendly_hits は、敵どうしの攻撃が当たってダメージが出た回数で、倒した敵の数ではありません。一方、s.sword_hits は剣が当たった回数です。同士討ちがなくても剣だけで倒せるので、勝ちの条件に friendly_hits >= 1 を加えることはしません。状況 B の保存入力は、ふだんのルールでは HP 3、剣4回、同士討ち1回で成功します。同じ入力のまま、敵どうしのダメージだけを0にしても278 tick で成功しますが、HP 2、剣6回になります。

この比較は「同士討ちがあるほうが、いつでも早く勝てる」という実験ではありません。相手の位置を見て判断し直す人やAIどうしを比べるのではなく、まったく同じキー入力を入れて、結果がどこで変わるかを見る「もし〜だったら」の比較(反実仮想)です。ダメージ0の版で同士討ちの回数を増やさないのも、「実際にダメージが出た回数」という意味を守るためです。画面だけでなく、HP・当たった回数・tick を一緒に読むと、最後が同じ WON でも、そこまでの流れが違うことを確かめられます。

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

04_friendly_fire.py / 注目する部分
        for e in enemies:
            if e.hp > 0 and e.id != a.owner and (e.id not in hit) and region_hit(a.kind, (a.x, a.y), a.direction, (e.x, e.y)) and los(b, (a.x, a.y), (e.x, e.y)):
                hit += (e.id,)
                damage[e.id] += friend_damage
                if friend_damage:
                    fh += 1

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_friendly_fire.py
macOS / Linux
.venv/bin/python 04_friendly_fire.py
画面で見ることpython 04_friendly_fire.py --demo --headless --frames 77 --output qa/my_friendly を実行すると、HP 3・SWORD HITS 1・FRIENDLY HITS 1、槍 HP 0・斧 HP 3 になります。--frames 278 なら WON・HP 3・剣4回・同士討ち1回です。入力の最初は、右1 tick → 右上12 tick → 右17 tick で、残りも DEMO_RUNS の決まった入力として再生されます。
  • 攻撃した敵の番号と、攻撃ごとの番号(ID)を区別する
  • 騎士へ1・ほかの敵へ2・剣で1と、ダメージを分ける
  • 同じ入力での比較から言えることは、限られている
つまずきやすいところ攻撃した敵自身を、巻き込まれる相手に含めないでください。friendly_hits を倒した敵の数として数えないでください。friend_damage=0 は、騎士が受けるダメージまで消す設定ではありません。
少し変えてみよう

def step(b, s, inp=Input(), friend_damage=2): の2だけを0に変えて、同じ278 tick を再生してみましょう。WON・HP 2・SWORD HITS 6・FRIENDLY HITS 0 になります。python test_stages.py Stages.test_04_literal_friendly_exercise が、同じ書き換えと結果を確かめます。確認したら2に戻します。

04_friendly_fire.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(486行)
04_friendly_fire.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段階。詳細はLESSON.md。"""
"""Pure 30Hz combat, fixed-point tenths of pixels, perception-bounded enemies."""
from dataclasses import dataclass, replace, asdict
from math import hypot
import json, hashlib, heapq
SCALE = 10

@dataclass(frozen=True)
class Level:
    title: str
    start: tuple
    enemies: tuple
    wall: tuple | None = None
LEVELS = (Level('槍の向こう', (380, 520), (('spear', 900, 520), ('axe', 1220, 520))),)

@dataclass(frozen=True)
class Input:
    dx: int = 0
    dy: int = 0
    sword: bool = False
    dodge: bool = False

@dataclass(frozen=True)
class Observation:
    seen: tuple | None

@dataclass(frozen=True)
class Memory:
    point: tuple | None = None
    wait: int = 0

@dataclass(frozen=True)
class Intent:
    goal: tuple | None
    start: bool
    direction: tuple
    memory: Memory

@dataclass(frozen=True)
class Enemy:
    id: int
    kind: str
    x: int
    y: int
    home: tuple
    hp: int = 3
    stage: str = 'idle'
    timer: int = 0
    direction: tuple = (1000, 0)
    memory: Memory = Memory()
    attack_id: int = 0

@dataclass(frozen=True)
class Attack:
    id: int
    owner: int
    kind: str
    x: int
    y: int
    direction: tuple
    remaining: int = 6
    hit: tuple = ()

@dataclass(frozen=True)
class State:
    x: int
    y: int
    enemies: tuple
    facing: tuple = (1000, 0)
    hp: int = 3
    invuln: int = 0
    dash: int = 0
    dash_cd: int = 0
    dash_dir: tuple = (1000, 0)
    sword: int = 0
    sword_dir: tuple = (1000, 0)
    sword_hit: tuple = ()
    prev_sword: bool = False
    prev_dodge: bool = False
    attacks: tuple = ()
    tick: int = 0
    serial: int = 0
    status: str = 'play'
    friendly_hits: int = 0
    sword_hits: int = 0
    damage_cause: str = ''
    observations: tuple = ()
    resolved_attacks: tuple = ()

def initial(level=0):
    b = LEVELS[level] if isinstance(level, int) else level
    return State(*b.start, tuple((Enemy(i, k, x, y, (x, y)) for i, (k, x, y) in enumerate(b.enemies))))

def unit(dx, dy):
    h = hypot(dx, dy)
    return (round(dx / h * 1000), round(dy / h * 1000)) if h else (1000, 0)

def los(b, a, z):
    return True

def valid(b, x, y, r=50):
    return 80 + r <= x <= 1520 - r and 80 + r <= y <= 960 - r

def move(b, x, y, d, speed):
    xx = x + round(d[0] * speed / 1000)
    yy = y + round(d[1] * speed / 1000)
    if valid(b, xx, y):
        x = xx
    if valid(b, x, yy):
        y = yy
    return (x, y)

def waypoint(b, p, target):
    return target

def observe(b, e, player):
    return Observation(player if hypot(player[0] - e.x, player[1] - e.y) <= 700 and los(b, (e.x, e.y), player) else None)

def policy(o, m, e):
    if e.stage != 'idle' or e.hp <= 0:
        return Intent(None, False, e.direction, m)
    if o.seen is not None:
        dist = hypot(o.seen[0] - e.x, o.seen[1] - e.y)
        reach = 600 if e.kind == 'spear' else 370
        return Intent(o.seen, dist <= reach, unit(o.seen[0] - e.x, o.seen[1] - e.y), Memory(o.seen, 12))
    if m.point is not None:
        if hypot(m.point[0] - e.x, m.point[1] - e.y) > 10:
            return Intent(m.point, False, e.direction, m)
        if m.wait > 0:
            return Intent(None, False, e.direction, replace(m, wait=m.wait - 1))
    return Intent(e.home, False, e.direction, Memory())

def region_hit(kind, origin, d, p, r=50):
    dx, dy = (p[0] - origin[0], p[1] - origin[1])
    if kind == 'axe':
        return dx * dx + dy * dy <= (290 + r) ** 2
    along = (dx * d[0] + dy * d[1]) / 1000
    side = abs(dx * d[1] - dy * d[0]) / 1000
    length, half = (520, 70) if kind == 'spear' else (180, 100)
    outside_x = max(-along, 0, along - length)
    outside_y = max(0, side - half)
    return outside_x * outside_x + outside_y * outside_y <= r * r

def step(b, s, inp=Input(), friend_damage=2):
    if s.status != 'play':
        return s
    enemies = []
    obs = []
    serial = s.serial
    new_attacks = []
    for e in s.enemies:
        o = observe(b, e, (s.x, s.y)) if e.stage == 'idle' and e.hp > 0 else Observation(None)
        obs.append(o)
        it = policy(o, e.memory, e)
        if e.hp <= 0:
            enemies.append(e)
            continue
        if e.stage == 'idle':
            if it.start:
                serial += 1
                e = replace(e, stage='windup', timer=36 if e.kind == 'spear' else 42, direction=it.direction, memory=it.memory, attack_id=serial)
            else:
                xx, yy = (e.x, e.y)
                if it.goal is not None:
                    dest = waypoint(b, (e.x, e.y), it.goal)
                    if hypot(dest[0] - e.x, dest[1] - e.y) > 6:
                        xx, yy = move(b, e.x, e.y, unit(dest[0] - e.x, dest[1] - e.y), 6)
                e = replace(e, x=xx, y=yy, memory=it.memory)
        else:
            timer = e.timer - 1
            if timer <= 0:
                if e.stage == 'windup':
                    new_attacks.append(Attack(e.attack_id, e.id, e.kind, e.x, e.y, e.direction))
                    e = replace(e, stage='active', timer=6)
                elif e.stage == 'active':
                    e = replace(e, stage='recover', timer=42)
                else:
                    e = replace(e, stage='idle', timer=0)
            else:
                e = replace(e, timer=timer)
        enemies.append(e)
    dx = max(-1, min(1, inp.dx))
    dy = max(-1, min(1, inp.dy))
    direction = unit(dx, dy) if dx or dy else s.facing
    facing = direction if dx or dy else s.facing
    dash = max(0, s.dash - 1)
    cd = max(0, s.dash_cd - 1)
    sw = max(0, s.sword - 1)
    dd = s.dash_dir
    sd = s.sword_dir
    sh = s.sword_hit
    if inp.sword and (not s.prev_sword) and (not s.sword) and (not s.dash):
        sw = 24
        sd = facing
        sh = ()
    elif inp.dodge and (not s.prev_dodge) and (not s.sword) and (not s.dash) and (not s.dash_cd):
        dash = 12
        cd = 30
        dd = direction
    speed = 28 if dash else 12 if sw else 15
    x, y = move(b, s.x, s.y, dd if dash else direction, speed) if dash or dx or dy else (s.x, s.y)
    damage = [0] * len(enemies)
    hurt = 0
    cause = s.damage_cause
    fh = s.friendly_hits
    swh = s.sword_hits
    if 13 <= sw <= 18:
        for e in enemies:
            if e.hp > 0 and e.id not in sh and region_hit('sword', (x, y), sd, (e.x, e.y)) and los(b, (x, y), (e.x, e.y)):
                damage[e.id] += 1
                sh += (e.id,)
                swh += 1
    attacks = []
    resolved = []
    for a in s.attacks + tuple(new_attacks):
        hit = a.hit
        if -1 not in hit and region_hit(a.kind, (a.x, a.y), a.direction, (x, y)) and los(b, (a.x, a.y), (x, y)):
            hit += (-1,)
            if s.invuln == 0 and (not hurt):
                hurt = 1
                cause = f'{a.kind}#{a.id}'
        for e in enemies:
            if e.hp > 0 and e.id != a.owner and (e.id not in hit) and region_hit(a.kind, (a.x, a.y), a.direction, (e.x, e.y)) and los(b, (a.x, a.y), (e.x, e.y)):
                hit += (e.id,)
                damage[e.id] += friend_damage
                if friend_damage:
                    fh += 1
        resolved.append(replace(a, hit=hit))
        if a.remaining > 1:
            attacks.append(replace(a, remaining=a.remaining - 1, hit=hit))
    enemies = tuple((replace(e, hp=max(0, e.hp - damage[e.id]), stage='dead' if e.hp - damage[e.id] <= 0 else e.stage) for e in enemies))
    hp = max(0, s.hp - hurt)
    status = 'lost' if hp == 0 else 'won' if all((e.hp == 0 for e in enemies)) else 'play'
    return State(x, y, enemies, facing, hp, 30 if hurt else max(0, s.invuln - 1), dash, cd, dd, sw, sd, sh, inp.sword, inp.dodge, tuple(attacks), s.tick + 1, serial, status, fh, swh, cause, tuple(obs), tuple(resolved))

def state_hash(s):
    return hashlib.sha256(json.dumps(asdict(s), sort_keys=True, separators=(',', ':')).encode('utf-8')).hexdigest()

# --- 教材用の操作と描画。上のモデルはPyxelを知らない。 ---
from pathlib import Path
import argparse

STAGE = 4
DEFAULT_LEVEL = 'B'
LEVEL_NAMES = ('B',)
DEMO_RUNS = {
    'B': (
        (1, 1, 0, 0, 0),
        (12, 1, -1, 0, 0),
        (17, 1, 0, 0, 0),
        (1, 1, -1, 0, 0),
        (12, 0, 0, 0, 0),
        (1, 1, 1, 1, 0),
        (1, -1, 0, 0, 0),
        (1, -1, 1, 0, 0),
        (2, 0, 1, 0, 0),
        (1, -1, 1, 0, 0),
        (19, 0, 0, 0, 0),
        (1, 1, 1, 1, 0),
        (1, -1, 0, 0, 0),
        (1, -1, 1, 0, 0),
        (2, 0, 0, 0, 0),
        (1, 0, -1, 0, 0),
        (5, 0, 0, 0, 0),
        (14, 1, 0, 0, 0),
        (1, 1, 0, 1, 0),
        (1, 1, 1, 0, 0),
        (2, 1, 0, 0, 0),
        (14, 0, 0, 0, 0),
        (15, -1, -1, 0, 0),
        (1, 0, -1, 0, 0),
        (43, 0, 0, 0, 0),
        (10, 1, 1, 0, 0),
        (1, 1, 1, 1, 0),
        (3, 1, 1, 0, 0),
        (1, 1, 0, 0, 0),
        (1, 1, -1, 0, 0),
        (16, 0, 0, 0, 0),
        (14, -1, -1, 0, 0),
        (1, -1, 1, 0, 0),
        (44, 0, 0, 0, 0),
        (10, 1, 1, 0, 0),
        (1, 1, 1, 1, 0),
        (3, 1, 1, 0, 0),
        (1, 1, 0, 0, 0),
        (1, 1, -1, 0, 0),
        (1, 0, 0, 0, 0),
    ),
}

def demo_input(frame, level):
    """1始まりのフレーム。各行は(回数, dx, dy, sword, dodge)。"""
    for count, dx, dy, sword, dodge in DEMO_RUNS[level]:
        if frame <= count:
            return Input(dx, dy, bool(sword), bool(dodge))
        frame -= count
    return Input()

class Controller:
    """キー集合を意味入力へ。R/Pのあとだけ全キー解放を待つ。"""
    def __init__(self, level=DEFAULT_LEVEL):
        self.b = LEVELS[LEVEL_NAMES.index(level)]
        self.s = initial(self.b)
        self.paused = False
        self.locked = False
        self.previous = set()

    def feed(self, held):
        held = set(held)
        pressed = held - self.previous
        self.previous = held
        if "R" in pressed:
            self.s = initial(self.b)
            self.paused = False
            self.locked = True
            return
        if self.locked:
            if not held:
                self.locked = False
                fields = {name: False for name in ("prev_sword", "prev_dodge")
                          if hasattr(self.s, name)}
                self.s = replace(self.s, **fields)
            return
        if "P" in pressed:
            self.paused = not self.paused
            self.locked = True
            return
        if self.paused:
            return
        inp = Input(int("RIGHT" in held) - int("LEFT" in held),
                    int("DOWN" in held) - int("UP" in held),
                    "Z" in held, "X" in held)
        self.s = step(self.b, self.s, inp)

class App:
    def __init__(self, args, pyxel):
        self.p = pyxel
        self.args = args
        self.c = Controller(args.level)
        self.frame = 0
        self.saved = False
        self.p.init(384, 320, title="032 Shieldless Knight - Stage " + str(STAGE),
                    fps=30, display_scale=2, headless=args.headless)
        self.p.run(self.update, self.draw)

    def update(self):
        # 最終フレームはdrawで保存し、その次のupdateで終了する。
        if self.saved or self.p.btnp(self.p.KEY_ESCAPE):
            self.p.quit()
            return
        self.frame += 1
        if self.args.still:
            return
        if self.args.demo:
            self.c.s = step(self.c.b, self.c.s, demo_input(self.frame, self.args.level))
        else:
            names = ("UP", "DOWN", "LEFT", "RIGHT", "Z", "X", "P", "R")
            self.c.feed({k for k in names if self.p.btn(getattr(self.p, "KEY_" + k))})

    def point(self, x, y):
        return 32 + x / 5, 45 + y / 5

    def hazard(self, kind, x, y, direction, color, fill=False):
        # 原作と同じregion_hitとlosで、予兆と有効範囲を描く。
        for px in range(8, 153):
            for py in range(8, 97):
                point = (px * 10, py * 10)
                if region_hit(kind, (x, y), direction, point, 0) and los(self.c.b, (x, y), point):
                    edge = any(not region_hit(kind, (x, y), direction,
                               (point[0] + dx, point[1] + dy), 0)
                               for dx, dy in ((10, 0), (-10, 0), (0, 10), (0, -10)))
                    if fill or edge or (px + py) % 9 == 0:
                        xx, yy = self.point(*point)
                        self.p.rect(xx, yy, 2, 2, color)

    def draw(self):
        p, s = self.p, self.c.s
        p.cls(1)
        p.text(12, 8, "032 SHIELDLESS KNIGHT  STAGE " + str(STAGE), 7)
        hp = getattr(s, "hp", "-")
        p.text(12, 22, f"LEVEL {self.args.level}  TICK {s.tick}  HP {hp}  {s.status.upper()}", 10)
        p.text(12, 34, f"POSITION (tenths): {s.x},{s.y}", 6)
        p.rect(48, 61, 288, 176, 3)
        p.rectb(46, 59, 292, 180, 11)
        for e in s.enemies:
            if e.hp > 0 and e.stage == "windup":
                self.hazard(e.kind, e.x, e.y, e.direction, 4)
        for a in getattr(s, "resolved_attacks", ()):
            self.hazard(a.kind, a.x, a.y, a.direction, 10, True)
        if self.c.b.wall:
            x1, y1, x2, y2 = self.c.b.wall
            xx, yy = self.point(x1, y1)
            p.rect(xx, yy, (x2 - x1) / 5, (y2 - y1) / 5, 5)
            p.rectb(xx, yy, (x2 - x1) / 5, (y2 - y1) / 5, 13)
        for e in s.enemies:
            xx, yy = self.point(e.x, e.y)
            if e.hp <= 0:
                p.line(xx - 5, yy - 3, xx + 5, yy + 3, 5)
                continue
            if e.stage == "recover":
                p.circb(xx, yy, 14, 7)
            p.circ(xx, yy, 7, 8 if e.kind == "spear" else 9)
            p.rect(xx - 9, yy + 12, 14, 9, 1)
            p.text(xx - 8, yy + 13, str(e.hp), 7)
            if e.stage == "windup":
                p.text(xx - 5, yy - 18, str(e.timer), 8)
        xx, yy = self.point(s.x, s.y)
        p.circ(xx, yy, 6, 12)
        d = s.sword_dir if getattr(s, "sword", 0) else s.facing
        active = 13 <= getattr(s, "sword", 0) <= 18
        length = 36 if active else 16
        p.line(xx, yy, xx + d[0] * length / 1000, yy + d[1] * length / 1000, 10 if active else 7)
        if getattr(s, "dash", 0):
            p.circb(xx, yy, 12, 6)
        p.rect(0, 242, 384, 78, 1)
        p.text(12, 246, "ARROWS MOVE / R RESET / P PAUSE / ESC QUIT", 7)
        p.text(12, 257, 'Z: SWORD / X: DODGE (NO INVINCIBILITY)', 6)
        p.text(12, 269, f"SWORD {getattr(s,'sword',0)}  DASH {getattr(s,'dash',0)}  COOLDOWN {getattr(s,'dash_cd',0)}", 7)
        p.text(12, 281, f"SWORD HITS {getattr(s,'sword_hits',0)}  FRIENDLY HITS {getattr(s,'friendly_hits',0)}", 7)
        p.text(12, 293, "BLUE: YOU / RED: SPEAR / ORANGE: AXE", 13)
        label = "PAUSED" if self.c.paused else "ALL ENEMIES DEFEATED" if s.status == "won" else "DEFEATED - R TO RESET" if s.status == "lost" else ""
        if label:
            p.rect(85, 135, 220, 20, 1)
            p.text(100, 142, label, 10)
        if self.args.frames is not None and self.frame >= self.args.frames:
            if self.args.output:
                self.args.output.mkdir(parents=True, exist_ok=True)
                stem = f"stage{STAGE}_{self.args.level}_{self.frame:03}"
                p.screenshot(str(self.args.output / (stem + ".png")), scale=2)
                report = {"stage": STAGE, "level": self.args.level, "frames": self.frame,
                          "headless": self.args.headless, "demo": self.args.demo,
                          "label": "Actual Pyxel render, fixed input, silent; not human playtest",
                          "state": asdict(s), "state_hash": state_hash(s)}
                (self.args.output / (stem + ".json")).write_text(
                    json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
            self.saved = True

def parse_args(argv=None):
    parser = argparse.ArgumentParser(description="盾のない騎士・第" + str(STAGE) + "段階")
    parser.add_argument("--level", choices=LEVEL_NAMES, default=DEFAULT_LEVEL)
    parser.add_argument("--headless", action="store_true")
    parser.add_argument("--demo", action="store_true", help="固定入力を再生する")
    parser.add_argument("--still", action="store_true", help="状態を進めず描画する(検証用)")
    parser.add_argument("--frames", type=int, help="描画フレーム数。1以上")
    parser.add_argument("--output", type=Path, help="画像とUTF-8状態JSONの保存先")
    args = parser.parse_args(argv)
    if args.frames is not None and args.frames < 1:
        parser.error("--frames must be >= 1")
    if args.headless and args.frames is None:
        parser.error("--headless requires --frames")
    if args.output is not None and args.frames is None:
        parser.error("--output requires --frames")
    return args

def main(argv=None):
    args = parse_args(argv)  # 無効な引数をPyxelのimportより先に拒否。
    import pyxel
    App(args, pyxel)

if __name__ == "__main__":
    main()
STEP 05 / 05

柱の向こうは見えない。最後に見た位置を覚える

見ることと決めることを分ける・柱で見えない・最後に見た場所の記憶・柱をよける道探し・元のゲームとの対応。

最後は、元のゲームの状況 C「斧のそばで待つ」を最初に選ばれる場面にして、柱と視界を加えます。起動するときに --level A または --level B を付ければ、前の中庭も遊べます。ゲームのルールの部分は、元のゲームの model.py を整えて1つのファイルにまとめたもので、関数・dataclass・盤面・更新の順番が、プログラムの構造(構文木)のレベルで元のゲームと一致しています。タイトルでの選択、日本語の文字の表示、記録の保存の画面は、教材用の簡単な画面(UI)に置き換えていますが、A/B/C の戦いのルールは同じです。

observe は、「距離が700以内で、しかも柱を横切らないまっすぐな線で結べる」ときだけ、Observation.seen(見えた位置)に騎士の位置を入れます。距離が近くても、柱の向こうなら None(見えない)です。policy に渡すのは Observation・Memory・Enemy の3つだけで、騎士の本当の位置や、この先のキー入力は渡しません。「同じものを見て、同じことを覚えていれば、同じ判断をする」という作りが、壁の向こうの騎士をズルして追いかけない根拠になります。予兆や攻撃の最中は新しい位置を見ず、向きも固定したままです。

見えた場所は Memory.point に保存し、待ち時間 wait を12に戻します。見失うと、最後に見た場所へ進み、着いたら12回待って、それでも見えなければ持ち場 home に戻ります。None は (0,0) とは違う、「見えない」という意味の値です。見えていないときに、毎 tick 本当の位置で point を上書きしてしまうと、画面に柱があっても公平な視界にはなりません。敵が戻る場所を知っているのは、自分の持ち場と地図を覚えているからです。

柱があるときは、柱を人物の半径50の分だけ広げた範囲に入れません。一番短い道を探す waypoint は、柱を少し余裕を持たせて55だけ広げた長方形の4つの角を中継点にし、どの点どうしがまっすぐ見通せるかのつながり(グラフ)を作って、heapq で短い道を選びます。マップ全体での騎士の位置を毎 tick 知っていて追いかけるのではなく、知っている柱と、記憶にある目的地から、次に向かう1地点を返します。攻撃が通るかを調べる los(見通し線)にも同じ柱を使い、剣や敵の攻撃が柱の向こうに当たらないようにします。

最後の確認は、成功の画面を1回見るだけではありません。元のゲームで保存した A/B/C の入力を、すべての tick で照らし合わせ、毎回の State の全項目を比べます。さらに、決まった乱数による入力3000 tick、相打ち、攻撃した敵が倒れたあとの攻撃、柱に向かっての回避、記憶による待機と持ち場への帰り道を、それぞれ別に調べます。ただし、画面を出さない(headless)Pyxel の描画と自動入力で確かめられたことは、人が説明を読んで理解できたという証拠ではありません。ブラウザー、Windows/macOS の実際のパソコン、音、人にとっての遊びやすさは、まだ確かめていません。

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

05_perception.py / 注目する部分
def observe(b, e, player):
    return Observation(player if hypot(player[0] - e.x, player[1] - e.y) <= 700 and los(b, (e.x, e.y), player) else None)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_perception.py
macOS / Linux
.venv/bin/python 05_perception.py
画面で見ることpython 05_perception.py --demo --headless --frames 233 --output qa/my_perception を実行すると、状況 C を決まった入力で再生し、WON・TICK 233・HP 2・剣4回・同士討ち1回になります。A なら --level A --frames 162、B なら --level B --frames 278 です。--still --headless --frames 1 なら、時間を進めずに、柱を含む最初の画面だけを保存できます。
  • 敵に見えている位置と、本当の位置を分ける
  • 最後に見た場所 → 12 tick 待つ → 持ち場、の順に動く
  • 元のゲームのルールと、教材で省いた画面を区別する
つまずきやすいところ見えないときの None を、本当の座標で埋めないでください。予兆の間に observe をやり直さないでください。柱の判定を描画だけで使わず、移動・見通し線・攻撃範囲のすべてに反映してください。
少し変えてみよう

python qa/perception_exercise.py を実行してみましょう。敵 (750,350) から騎士 (450,350) は、柱があると None(見えない)、同じ盤面で柱だけを外すと (450,350) が見えます。次に、記憶の目的地に着いていて wait=1 のときは、1回目は待って0になり、2回目は持ち場 home へ戻って point=None になります。python test_stages.py Combat.test_perception_pillar_and_distance Combat.test_memory_exercise でも確かめられます。

05_perception.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(678行)
05_perception.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段階。詳細はLESSON.md。"""
"""Pure 30Hz combat, fixed-point tenths of pixels, perception-bounded enemies."""
from dataclasses import dataclass, replace, asdict
from math import hypot
import json, hashlib, heapq
SCALE = 10

@dataclass(frozen=True)
class Level:
    title: str
    start: tuple
    enemies: tuple
    wall: tuple | None = None
LEVELS = (Level('横へ、一歩', (300, 520), (('spear', 940, 520),)), Level('槍の向こう', (380, 520), (('spear', 900, 520), ('axe', 1220, 520))), Level('斧のそばで待つ', (300, 760), (('axe', 820, 540), ('spear', 1170, 380)), (550, 200, 670, 470)))

@dataclass(frozen=True)
class Input:
    dx: int = 0
    dy: int = 0
    sword: bool = False
    dodge: bool = False

@dataclass(frozen=True)
class Observation:
    seen: tuple | None

@dataclass(frozen=True)
class Memory:
    point: tuple | None = None
    wait: int = 0

@dataclass(frozen=True)
class Intent:
    goal: tuple | None
    start: bool
    direction: tuple
    memory: Memory

@dataclass(frozen=True)
class Enemy:
    id: int
    kind: str
    x: int
    y: int
    home: tuple
    hp: int = 3
    stage: str = 'idle'
    timer: int = 0
    direction: tuple = (1000, 0)
    memory: Memory = Memory()
    attack_id: int = 0

@dataclass(frozen=True)
class Attack:
    id: int
    owner: int
    kind: str
    x: int
    y: int
    direction: tuple
    remaining: int = 6
    hit: tuple = ()

@dataclass(frozen=True)
class State:
    x: int
    y: int
    enemies: tuple
    facing: tuple = (1000, 0)
    hp: int = 3
    invuln: int = 0
    dash: int = 0
    dash_cd: int = 0
    dash_dir: tuple = (1000, 0)
    sword: int = 0
    sword_dir: tuple = (1000, 0)
    sword_hit: tuple = ()
    prev_sword: bool = False
    prev_dodge: bool = False
    attacks: tuple = ()
    tick: int = 0
    serial: int = 0
    status: str = 'play'
    friendly_hits: int = 0
    sword_hits: int = 0
    damage_cause: str = ''
    observations: tuple = ()
    resolved_attacks: tuple = ()

def initial(level=0):
    b = LEVELS[level] if isinstance(level, int) else level
    return State(*b.start, tuple((Enemy(i, k, x, y, (x, y)) for i, (k, x, y) in enumerate(b.enemies))))

def unit(dx, dy):
    h = hypot(dx, dy)
    return (round(dx / h * 1000), round(dy / h * 1000)) if h else (1000, 0)

def segment_box(a, z, box):
    lo, hi = (0.0, 1.0)
    for av, dv, mn, mx in ((a[0], z[0] - a[0], box[0], box[2]), (a[1], z[1] - a[1], box[1], box[3])):
        if dv == 0:
            if not mn <= av <= mx:
                return False
        else:
            u, v = ((mn - av) / dv, (mx - av) / dv)
            lo = max(lo, min(u, v))
            hi = min(hi, max(u, v))
            if lo > hi:
                return False
    return True

def los(b, a, z):
    return not b.wall or not segment_box(a, z, b.wall)

def valid(b, x, y, r=50):
    if not 80 + r <= x <= 1520 - r or not 80 + r <= y <= 960 - r:
        return False
    return not b.wall or not (b.wall[0] - r < x < b.wall[2] + r and b.wall[1] - r < y < b.wall[3] + r)

def move(b, x, y, d, speed):
    xx = x + round(d[0] * speed / 1000)
    yy = y + round(d[1] * speed / 1000)
    if valid(b, xx, y):
        x = xx
    if valid(b, x, yy):
        y = yy
    return (x, y)

def waypoint(b, p, target):
    if not b.wall:
        return target
    x1, y1, x2, y2 = b.wall
    box = (x1 - 55, y1 - 55, x2 + 55, y2 + 55)
    if not segment_box(p, target, box):
        return target
    nodes = (p, target, (box[0] - 2, box[1] - 2), (box[2] + 2, box[1] - 2), (box[0] - 2, box[3] + 2), (box[2] + 2, box[3] + 2))
    q = [(0, 0, ())]
    seen = set()
    while q:
        cost, i, route = heapq.heappop(q)
        if i in seen:
            continue
        seen.add(i)
        if i == 1:
            return nodes[route[0]] if route else target
        for j, z in enumerate(nodes):
            if j != i and j not in seen and valid(b, *z) and (not segment_box(nodes[i], z, box)):
                heapq.heappush(q, (cost + hypot(z[0] - nodes[i][0], z[1] - nodes[i][1]), j, route + (j,)))
    return p

def observe(b, e, player):
    return Observation(player if hypot(player[0] - e.x, player[1] - e.y) <= 700 and los(b, (e.x, e.y), player) else None)

def policy(o, m, e):
    if e.stage != 'idle' or e.hp <= 0:
        return Intent(None, False, e.direction, m)
    if o.seen is not None:
        dist = hypot(o.seen[0] - e.x, o.seen[1] - e.y)
        reach = 600 if e.kind == 'spear' else 370
        return Intent(o.seen, dist <= reach, unit(o.seen[0] - e.x, o.seen[1] - e.y), Memory(o.seen, 12))
    if m.point is not None:
        if hypot(m.point[0] - e.x, m.point[1] - e.y) > 10:
            return Intent(m.point, False, e.direction, m)
        if m.wait > 0:
            return Intent(None, False, e.direction, replace(m, wait=m.wait - 1))
    return Intent(e.home, False, e.direction, Memory())

def region_hit(kind, origin, d, p, r=50):
    dx, dy = (p[0] - origin[0], p[1] - origin[1])
    if kind == 'axe':
        return dx * dx + dy * dy <= (290 + r) ** 2
    along = (dx * d[0] + dy * d[1]) / 1000
    side = abs(dx * d[1] - dy * d[0]) / 1000
    length, half = (520, 70) if kind == 'spear' else (180, 100)
    outside_x = max(-along, 0, along - length)
    outside_y = max(0, side - half)
    return outside_x * outside_x + outside_y * outside_y <= r * r

def step(b, s, inp=Input(), friend_damage=2):
    if s.status != 'play':
        return s
    enemies = []
    obs = []
    serial = s.serial
    new_attacks = []
    for e in s.enemies:
        o = observe(b, e, (s.x, s.y)) if e.stage == 'idle' and e.hp > 0 else Observation(None)
        obs.append(o)
        it = policy(o, e.memory, e)
        if e.hp <= 0:
            enemies.append(e)
            continue
        if e.stage == 'idle':
            if it.start:
                serial += 1
                e = replace(e, stage='windup', timer=36 if e.kind == 'spear' else 42, direction=it.direction, memory=it.memory, attack_id=serial)
            else:
                xx, yy = (e.x, e.y)
                if it.goal is not None:
                    dest = waypoint(b, (e.x, e.y), it.goal)
                    if hypot(dest[0] - e.x, dest[1] - e.y) > 6:
                        xx, yy = move(b, e.x, e.y, unit(dest[0] - e.x, dest[1] - e.y), 6)
                e = replace(e, x=xx, y=yy, memory=it.memory)
        else:
            timer = e.timer - 1
            if timer <= 0:
                if e.stage == 'windup':
                    new_attacks.append(Attack(e.attack_id, e.id, e.kind, e.x, e.y, e.direction))
                    e = replace(e, stage='active', timer=6)
                elif e.stage == 'active':
                    e = replace(e, stage='recover', timer=42)
                else:
                    e = replace(e, stage='idle', timer=0)
            else:
                e = replace(e, timer=timer)
        enemies.append(e)
    dx = max(-1, min(1, inp.dx))
    dy = max(-1, min(1, inp.dy))
    direction = unit(dx, dy) if dx or dy else s.facing
    facing = direction if dx or dy else s.facing
    dash = max(0, s.dash - 1)
    cd = max(0, s.dash_cd - 1)
    sw = max(0, s.sword - 1)
    dd = s.dash_dir
    sd = s.sword_dir
    sh = s.sword_hit
    if inp.sword and (not s.prev_sword) and (not s.sword) and (not s.dash):
        sw = 24
        sd = facing
        sh = ()
    elif inp.dodge and (not s.prev_dodge) and (not s.sword) and (not s.dash) and (not s.dash_cd):
        dash = 12
        cd = 30
        dd = direction
    speed = 28 if dash else 12 if sw else 15
    x, y = move(b, s.x, s.y, dd if dash else direction, speed) if dash or dx or dy else (s.x, s.y)
    damage = [0] * len(enemies)
    hurt = 0
    cause = s.damage_cause
    fh = s.friendly_hits
    swh = s.sword_hits
    if 13 <= sw <= 18:
        for e in enemies:
            if e.hp > 0 and e.id not in sh and region_hit('sword', (x, y), sd, (e.x, e.y)) and los(b, (x, y), (e.x, e.y)):
                damage[e.id] += 1
                sh += (e.id,)
                swh += 1
    attacks = []
    resolved = []
    for a in s.attacks + tuple(new_attacks):
        hit = a.hit
        if -1 not in hit and region_hit(a.kind, (a.x, a.y), a.direction, (x, y)) and los(b, (a.x, a.y), (x, y)):
            hit += (-1,)
            if s.invuln == 0 and (not hurt):
                hurt = 1
                cause = f'{a.kind}#{a.id}'
        for e in enemies:
            if e.hp > 0 and e.id != a.owner and (e.id not in hit) and region_hit(a.kind, (a.x, a.y), a.direction, (e.x, e.y)) and los(b, (a.x, a.y), (e.x, e.y)):
                hit += (e.id,)
                damage[e.id] += friend_damage
                if friend_damage:
                    fh += 1
        resolved.append(replace(a, hit=hit))
        if a.remaining > 1:
            attacks.append(replace(a, remaining=a.remaining - 1, hit=hit))
    enemies = tuple((replace(e, hp=max(0, e.hp - damage[e.id]), stage='dead' if e.hp - damage[e.id] <= 0 else e.stage) for e in enemies))
    hp = max(0, s.hp - hurt)
    status = 'lost' if hp == 0 else 'won' if all((e.hp == 0 for e in enemies)) else 'play'
    return State(x, y, enemies, facing, hp, 30 if hurt else max(0, s.invuln - 1), dash, cd, dd, sw, sd, sh, inp.sword, inp.dodge, tuple(attacks), s.tick + 1, serial, status, fh, swh, cause, tuple(obs), tuple(resolved))

def state_hash(s):
    return hashlib.sha256(json.dumps(asdict(s), sort_keys=True, separators=(',', ':')).encode('utf-8')).hexdigest()

# --- 教材用の操作と描画。上のモデルはPyxelを知らない。 ---
from pathlib import Path
import argparse

STAGE = 5
DEFAULT_LEVEL = 'C'
LEVEL_NAMES = ('A', 'B', 'C')
DEMO_RUNS = {
    'A': (
        (3, 1, 0, 0, 0),
        (42, 0, 0, 0, 0),
        (24, 1, 0, 0, 0),
        (1, 1, 0, 1, 0),
        (4, 1, 0, 0, 0),
        (3, 0, 0, 0, 0),
        (1, -1, 0, 0, 0),
        (3, 0, -1, 0, 0),
        (7, 0, 0, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 1, 0, 1, 0),
        (1, 1, -1, 0, 0),
        (23, 0, 0, 0, 0),
        (1, 1, 0, 1, 0),
        (1, -1, -1, 0, 0),
        (5, 0, 0, 0, 0),
    ),
    'B': (
        (1, 1, 0, 0, 0),
        (12, 1, -1, 0, 0),
        (17, 1, 0, 0, 0),
        (1, 1, -1, 0, 0),
        (12, 0, 0, 0, 0),
        (1, 1, 1, 1, 0),
        (1, -1, 0, 0, 0),
        (1, -1, 1, 0, 0),
        (2, 0, 1, 0, 0),
        (1, -1, 1, 0, 0),
        (19, 0, 0, 0, 0),
        (1, 1, 1, 1, 0),
        (1, -1, 0, 0, 0),
        (1, -1, 1, 0, 0),
        (2, 0, 0, 0, 0),
        (1, 0, -1, 0, 0),
        (5, 0, 0, 0, 0),
        (14, 1, 0, 0, 0),
        (1, 1, 0, 1, 0),
        (1, 1, 1, 0, 0),
        (2, 1, 0, 0, 0),
        (14, 0, 0, 0, 0),
        (15, -1, -1, 0, 0),
        (1, 0, -1, 0, 0),
        (43, 0, 0, 0, 0),
        (10, 1, 1, 0, 0),
        (1, 1, 1, 1, 0),
        (3, 1, 1, 0, 0),
        (1, 1, 0, 0, 0),
        (1, 1, -1, 0, 0),
        (16, 0, 0, 0, 0),
        (14, -1, -1, 0, 0),
        (1, -1, 1, 0, 0),
        (44, 0, 0, 0, 0),
        (10, 1, 1, 0, 0),
        (1, 1, 1, 1, 0),
        (3, 1, 1, 0, 0),
        (1, 1, 0, 0, 0),
        (1, 1, -1, 0, 0),
        (1, 0, 0, 0, 0),
    ),
    'C': (
        (5, 1, -1, 0, 0),
        (1, 1, 0, 0, 0),
        (1, 1, -1, 0, 0),
        (3, 1, 0, 0, 0),
        (1, -1, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (47, 0, 0, 0, 0),
        (2, 1, -1, 0, 0),
        (1, 1, 0, 0, 0),
        (1, 1, -1, 0, 0),
        (1, 1, 0, 0, 0),
        (1, 1, -1, 0, 0),
        (2, 1, 0, 0, 0),
        (1, 1, -1, 0, 0),
        (1, 1, 0, 0, 0),
        (1, 1, -1, 0, 0),
        (1, 1, 0, 1, 0),
        (1, 1, -1, 0, 0),
        (1, 1, 0, 0, 0),
        (1, 1, -1, 0, 0),
        (1, 1, 1, 0, 0),
        (16, 0, 0, 0, 0),
        (14, -1, 1, 0, 0),
        (1, -1, -1, 0, 0),
        (4, 0, 0, 0, 0),
        (2, 1, -1, 0, 0),
        (4, 1, 0, 0, 0),
        (18, 1, -1, 0, 0),
        (1, 1, 0, 0, 0),
        (1, 1, -1, 0, 0),
        (1, 1, 0, 0, 0),
        (1, 1, -1, 0, 0),
        (2, 1, 0, 0, 0),
        (1, 1, -1, 0, 0),
        (1, 1, 0, 0, 0),
        (1, 1, -1, 0, 0),
        (1, 1, 0, 0, 0),
        (1, 1, -1, 0, 0),
        (1, 1, 0, 0, 0),
        (1, 1, -1, 0, 0),
        (2, 1, 0, 0, 0),
        (1, 1, -1, 1, 0),
        (1, 1, 0, 0, 0),
        (1, 1, -1, 0, 0),
        (1, 1, 0, 0, 0),
        (1, 0, -1, 0, 0),
        (5, 0, 0, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 0, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (1, 1, 0, 1, 0),
        (1, 1, 1, 0, 0),
        (1, 0, -1, 0, 0),
        (22, 0, 0, 0, 0),
        (1, 1, 0, 1, 0),
        (1, -1, 0, 0, 0),
        (5, 0, 0, 0, 0),
    ),
}

def demo_input(frame, level):
    """1始まりのフレーム。各行は(回数, dx, dy, sword, dodge)。"""
    for count, dx, dy, sword, dodge in DEMO_RUNS[level]:
        if frame <= count:
            return Input(dx, dy, bool(sword), bool(dodge))
        frame -= count
    return Input()

class Controller:
    """キー集合を意味入力へ。R/Pのあとだけ全キー解放を待つ。"""
    def __init__(self, level=DEFAULT_LEVEL):
        self.b = LEVELS[LEVEL_NAMES.index(level)]
        self.s = initial(self.b)
        self.paused = False
        self.locked = False
        self.previous = set()

    def feed(self, held):
        held = set(held)
        pressed = held - self.previous
        self.previous = held
        if "R" in pressed:
            self.s = initial(self.b)
            self.paused = False
            self.locked = True
            return
        if self.locked:
            if not held:
                self.locked = False
                fields = {name: False for name in ("prev_sword", "prev_dodge")
                          if hasattr(self.s, name)}
                self.s = replace(self.s, **fields)
            return
        if "P" in pressed:
            self.paused = not self.paused
            self.locked = True
            return
        if self.paused:
            return
        inp = Input(int("RIGHT" in held) - int("LEFT" in held),
                    int("DOWN" in held) - int("UP" in held),
                    "Z" in held, "X" in held)
        self.s = step(self.b, self.s, inp)

class App:
    def __init__(self, args, pyxel):
        self.p = pyxel
        self.args = args
        self.c = Controller(args.level)
        self.frame = 0
        self.saved = False
        self.p.init(384, 320, title="032 Shieldless Knight - Stage " + str(STAGE),
                    fps=30, display_scale=2, headless=args.headless)
        self.p.run(self.update, self.draw)

    def update(self):
        # 最終フレームはdrawで保存し、その次のupdateで終了する。
        if self.saved or self.p.btnp(self.p.KEY_ESCAPE):
            self.p.quit()
            return
        self.frame += 1
        if self.args.still:
            return
        if self.args.demo:
            self.c.s = step(self.c.b, self.c.s, demo_input(self.frame, self.args.level))
        else:
            names = ("UP", "DOWN", "LEFT", "RIGHT", "Z", "X", "P", "R")
            self.c.feed({k for k in names if self.p.btn(getattr(self.p, "KEY_" + k))})

    def point(self, x, y):
        return 32 + x / 5, 45 + y / 5

    def hazard(self, kind, x, y, direction, color, fill=False):
        # 原作と同じregion_hitとlosで、予兆と有効範囲を描く。
        for px in range(8, 153):
            for py in range(8, 97):
                point = (px * 10, py * 10)
                if region_hit(kind, (x, y), direction, point, 0) and los(self.c.b, (x, y), point):
                    edge = any(not region_hit(kind, (x, y), direction,
                               (point[0] + dx, point[1] + dy), 0)
                               for dx, dy in ((10, 0), (-10, 0), (0, 10), (0, -10)))
                    if fill or edge or (px + py) % 9 == 0:
                        xx, yy = self.point(*point)
                        self.p.rect(xx, yy, 2, 2, color)

    def draw(self):
        p, s = self.p, self.c.s
        p.cls(1)
        p.text(12, 8, "032 SHIELDLESS KNIGHT  STAGE " + str(STAGE), 7)
        hp = getattr(s, "hp", "-")
        p.text(12, 22, f"LEVEL {self.args.level}  TICK {s.tick}  HP {hp}  {s.status.upper()}", 10)
        p.text(12, 34, f"POSITION (tenths): {s.x},{s.y}", 6)
        p.rect(48, 61, 288, 176, 3)
        p.rectb(46, 59, 292, 180, 11)
        for e in s.enemies:
            if e.hp > 0 and e.stage == "windup":
                self.hazard(e.kind, e.x, e.y, e.direction, 4)
        for a in getattr(s, "resolved_attacks", ()):
            self.hazard(a.kind, a.x, a.y, a.direction, 10, True)
        if self.c.b.wall:
            x1, y1, x2, y2 = self.c.b.wall
            xx, yy = self.point(x1, y1)
            p.rect(xx, yy, (x2 - x1) / 5, (y2 - y1) / 5, 5)
            p.rectb(xx, yy, (x2 - x1) / 5, (y2 - y1) / 5, 13)
        for e in s.enemies:
            xx, yy = self.point(e.x, e.y)
            if e.hp <= 0:
                p.line(xx - 5, yy - 3, xx + 5, yy + 3, 5)
                continue
            if e.stage == "recover":
                p.circb(xx, yy, 14, 7)
            p.circ(xx, yy, 7, 8 if e.kind == "spear" else 9)
            p.rect(xx - 9, yy + 12, 14, 9, 1)
            p.text(xx - 8, yy + 13, str(e.hp), 7)
            if e.stage == "windup":
                p.text(xx - 5, yy - 18, str(e.timer), 8)
        xx, yy = self.point(s.x, s.y)
        p.circ(xx, yy, 6, 12)
        d = s.sword_dir if getattr(s, "sword", 0) else s.facing
        active = 13 <= getattr(s, "sword", 0) <= 18
        length = 36 if active else 16
        p.line(xx, yy, xx + d[0] * length / 1000, yy + d[1] * length / 1000, 10 if active else 7)
        if getattr(s, "dash", 0):
            p.circb(xx, yy, 12, 6)
        p.rect(0, 242, 384, 78, 1)
        p.text(12, 246, "ARROWS MOVE / R RESET / P PAUSE / ESC QUIT", 7)
        p.text(12, 257, 'Z: SWORD / X: DODGE (NO INVINCIBILITY)', 6)
        p.text(12, 269, f"SWORD {getattr(s,'sword',0)}  DASH {getattr(s,'dash',0)}  COOLDOWN {getattr(s,'dash_cd',0)}", 7)
        p.text(12, 281, f"SWORD HITS {getattr(s,'sword_hits',0)}  FRIENDLY HITS {getattr(s,'friendly_hits',0)}", 7)
        p.text(12, 293, "BLUE: YOU / RED: SPEAR / ORANGE: AXE", 13)
        label = "PAUSED" if self.c.paused else "ALL ENEMIES DEFEATED" if s.status == "won" else "DEFEATED - R TO RESET" if s.status == "lost" else ""
        if label:
            p.rect(85, 135, 220, 20, 1)
            p.text(100, 142, label, 10)
        if self.args.frames is not None and self.frame >= self.args.frames:
            if self.args.output:
                self.args.output.mkdir(parents=True, exist_ok=True)
                stem = f"stage{STAGE}_{self.args.level}_{self.frame:03}"
                p.screenshot(str(self.args.output / (stem + ".png")), scale=2)
                report = {"stage": STAGE, "level": self.args.level, "frames": self.frame,
                          "headless": self.args.headless, "demo": self.args.demo,
                          "label": "Actual Pyxel render, fixed input, silent; not human playtest",
                          "state": asdict(s), "state_hash": state_hash(s)}
                (self.args.output / (stem + ".json")).write_text(
                    json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
            self.saved = True

def parse_args(argv=None):
    parser = argparse.ArgumentParser(description="盾のない騎士・第" + str(STAGE) + "段階")
    parser.add_argument("--level", choices=LEVEL_NAMES, default=DEFAULT_LEVEL)
    parser.add_argument("--headless", action="store_true")
    parser.add_argument("--demo", action="store_true", help="固定入力を再生する")
    parser.add_argument("--still", action="store_true", help="状態を進めず描画する(検証用)")
    parser.add_argument("--frames", type=int, help="描画フレーム数。1以上")
    parser.add_argument("--output", type=Path, help="画像とUTF-8状態JSONの保存先")
    args = parser.parse_args(argv)
    if args.frames is not None and args.frames < 1:
        parser.error("--frames must be >= 1")
    if args.headless and args.frames is None:
        parser.error("--headless requires --frames")
    if args.output is not None and args.frames is None:
        parser.error("--output requires --frames")
    return args

def main(argv=None):
    args = parse_args(argv)  # 無効な引数をPyxelのimportより先に拒否。
    import pyxel
    App(args, pyxel)

if __name__ == "__main__":
    main()
AFTER STEP 05

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

ルールのまとめ(元のゲームの状況 A/B/C):1秒に30回(30Hz)の更新、0.1px単位の座標、HP3。剣のダメージ1、敵の攻撃が騎士に当たると1、同士討ちは2。予兆は槍36・斧42、有効6、戻り42。回避12 tick は速く動くだけ(無敵なし)。予兆の間は狙いを固定、同じ攻撃は同じ相手に1回だけ、相打ちは負け。柱で見えなくなり、敵は最後に見た位置を覚えます。

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

比べる点5ステップの教材元の完成版
段階01 は移動だけ。02 は騎士を近づけた、動かない槍での回避の練習(剣と勝ちはなし)。03 は状況 A の敵1体、04 は状況 B の敵2体、05 は元のゲームのルール全部。最初から、状況 A/B/C の剣・回避・敵の動き(AI)・同士討ち・柱があります。
操作起動したらすぐプレイ。05 の中庭は、コマンド(CLI)の --level A/B/C で選びます。R/P のあとはキーが全部離されるのを待ちます。Undo はありません。タイトルで左右キーで選び、SPACE で開始、M で選択画面へ、成功したら N で次へ。Undo は元のゲームにもありません。
表示と記録英数字と図形で表示。--demo は保存した入力を、くり返す回数つきで1つのファイルに入れています。--output は、最後のフレームの画像と状態の JSON だけを保存します。音はありません。日本語の文字、説明の画面、F12 キー、出来事の記録、状況 B/C のデモ動画用の画面の記録。
権利と依存各段階のファイルは、Pyxel 以外に必要なものはありません。元の MIT ライセンスと Noto フォントの通知・文字データは同じ ZIP に残していますが、表示は英数字(ASCII)だけです。実行するときに assets/glyphs.json、ui_support.py、model.py、replays.json を読み込みます。

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

  1. model.py の State / Input / step:前の状態から、全部をまとめて更新する方法
  2. model.py の region_hit / los:当たり判定と、画面に描く範囲を一致させる方法
  3. model.py の observe / policy / Memory:敵が知ることのできる情報の制限
  4. model.py の waypoint:柱を回りこむ道の探し方
  5. app.py の ReleaseGate(キーを離すまで待つ仕組み)と日本語の画面、plans.py:自動で操作する方針と保存した入力
自分のゲームに変えるなら

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

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