シューティングLESSON 37反射シューティング・時間窓と認証原作036

返せる弾丸の作り方

ほんの短い時間だけ出る「はね返しの輪」で、敵の弾を返す。
予告・本物の弾・弾の持ち主・壁を通れる資格を分けて考え、入力と当たり判定の順番を守るシューティングゲームを作ります。

完成版の遊び方敵の弾を短いはね返しの輪で返して壁を開き、ふつうの弾と、返した弾でコアをけずります。

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

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

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

完成版の主な操作:左右移動、Z保持射撃、X押下反射輪、P停止、R再開、1〜3編隊、M表紙

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

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

うまく表示されないとき

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

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

見た目の弾と、「だれの弾か」という状態を分ける。

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

1. 毎 tick の更新・弾の持ち主・次に撃てるまでの待ち時間
2. 見る・ねらいを決める・予約する・線分で当たりを調べる
3. 押した瞬間・はね返しの時間・別々の時計・全部を先に処理する
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
Python 3.11以上と Pyxel 2.9.9 / 変数・if・for・関数・リスト / class・dataclass・deepcopy・集合・線分との距離は本文で説明します
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
左右:毎tick移動、同時押しは相殺 / Z:保持で24tickごとの普通弾 / X:03以降、新押下の12tick反射 / R:全段階リセット、全操作キー解放待ち / Space:05の表紙で開始 / P:05で全ゲーム時間の停止・再開 / M:05で表紙 / Esc:Pyxel終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_motion.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

保持入力と弾の移動を分ける

毎 tick の更新・弾の持ち主・次に撃てるまでの待ち時間。

左右は、押している間、毎 tick 1.8 ずつ動きます。Z も押し続けを読みますが、shot_cooldown(撃てるまでの待ち時間)が0のときだけ1発作ります。弾は、位置・速さ・ident(番号)・owner(持ち主)を持ち、入力とは関係なく毎 tick 進みます。この段階では、敵の弾・当たり判定・壁・勝ち負けはまだありません。

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

01_motion.py / 注目する部分
    if inp.z and s.shot_cooldown == 0:
        s.bullets.append(Bullet(s.next_id, 'player', s.x, 93.0, 0.0, -3.0, 'player'))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_motion.py
macOS / Linux
.venv/bin/python 01_motion.py
画面で見ることZ を50 tick 押し続けると、更新を始めた時刻が 0、24、48 のときに、合計3発撃ちます。撃つことと動くことが同じ更新の中で起きるので、最初に描いたときの弾の y は 93 から 90 になっています。左右を同時に押すと動かず、動きながら Z で撃つことはできます。
  • 「押し続け」と「押した瞬間」のちがいを説明できる
  • 左右同時は打ち消し合い、Z とは一緒に使える
  • 弾は、撃ったあとも自分の速さで進む
  • この段階には勝ち負けがないと分かる
つまずきやすいところZ を btnp(押した瞬間)だけで読むと、押し続けても1発で止まってしまいます。反対に、待ち時間を作らず btn だけで撃つと、毎 tick 1発になってしまいます。弾のリストを順番に見ている途中で remove すると、次の弾を読み飛ばしてしまいます。
少し変えてみよう

shot_cooldown の24を12に変える前に、50 tick の間に撃つ時刻を予想してみましょう。答えは 0、12、24、36、48 です。試したら24に戻し、元の3発になることをもう一度確かめます。

01_motion.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(309行)
01_motion.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.
# 教材画面は標準ASCIIと図形のみ。字形データは同梱しません。

"""返せる弾丸・教材1/5。日本語説明はLESSON.md。"""
from dataclasses import dataclass, field, asdict
from copy import deepcopy
import math
import json
import hashlib

@dataclass(frozen=True)
class Level:
    name: str
    start: float
    speed: float
    schedule: tuple
    ring_ticks: int = 12

# 原作A。一周180tick、中央へ30/100tickで発射。数値は変えない。
LEVEL = Level('一発を返す', 80.0, 1.4, ((30, 80), (100, 80)))

@dataclass
class Bullet:
    ident: int
    owner: str
    x: float
    y: float
    vx: float
    vy: float
    source: str = 'core'

@dataclass(frozen=True)
class Input:
    dx: int = 0
    z: bool = False
    x: bool = False

@dataclass
class WorldState:
    tick: int = 0
    x: float = 80.0
    shot_cooldown: int = 0
    bullets: list = field(default_factory=list)
    next_id: int = 1
    status: str = 'playing'
    events: tuple = ()


def initial(level=LEVEL):
    return WorldState(x=level.start)


def step(level, old, inp=Input()):
    if old.status != 'playing':
        return deepcopy(old)
    s = deepcopy(old)  # 前の状態を残す。テストでも比較しやすい。
    events = []
    oldx = s.x
    for name in ('shot_cooldown',):
        setattr(s, name, max(0, getattr(s, name)-1))
    s.x = max(16, min(144, s.x + max(-1, min(1, inp.dx))*1.8))
    if inp.z and s.shot_cooldown == 0:
        s.bullets.append(Bullet(s.next_id, 'player', s.x, 93.0, 0.0, -3.0, 'player'))
        s.next_id += 1
        s.shot_cooldown = 24
        events.append(('shot', s.next_id-1))
    alive = []
    moved = [(b, b.x, b.y, b.x+b.vx, b.y+b.vy) for b in s.bullets]
    for b, x, y, nx, ny in moved:
        b.x, b.y = round(nx, 6), round(ny, 6)
        if -8 <= b.x <= 168 and -8 <= b.y <= 116:
            alive.append(b)
        else:
            events.append(('missed', b.ident, b.owner))
    s.bullets = alive
    s.tick += 1
    s.events = tuple(events)
    return s


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

STAGE = 1
STAGE_TITLE = 'MOVE AND HOLD FIRE'

CONTROLS = ('LEFT', 'RIGHT', 'Z', 'R')

class Controller:
    def __init__(self):
        self.state = initial()
        self.gate = False
        self.reason = 'ready'
        self.last_events = ()

    def reset(self):
        self.state = initial()
        self.gate = True
        self.last_events = ()

    def tick(self, pressed=(), held=()):
        pressed = set(pressed) & set(CONTROLS)
        held = set(held) & set(CONTROLS)
        if self.gate:
            if not held:
                self.gate = False
            return  # 全解放フレーム自体も世界を進めない。
        if 'R' in pressed:
            self.reset()
            self.reason = 'reset'
            return
        if self.state.status != 'playing':
            return
        # 左右同時は0。Zは保持で連射し、Xの新押下はモデル側で判断する。
        inp = Input(int('RIGHT' in held)-int('LEFT' in held), 'Z' in held, 'X' in held)
        self.state = step(LEVEL, self.state, inp)
        self.reason = 'play'
        if self.state.events:
            self.last_events = self.state.events

    def snapshot(self):
        result = {'state': asdict(self.state), 'hash': state_hash(self.state),
                  'gate': self.gate, 'reason': self.reason, 'last_events': self.last_events}
        return result


def draw_board(pyxel, controller):
    s = controller.state
    pyxel.cls(1)
    pyxel.text(12, 10, f'RETURN BULLETS 036 / STEP {STAGE}', 10)
    pyxel.text(12, 24, STAGE_TITLE, 7)
    pyxel.rect(20, 44, 320, 236, 0)
    pyxel.rectb(20, 44, 320, 236, 5)
    def xy(x, y):
        return 20+x*2, 44+y*2
    for lane in (40, 80, 120):
        x, _ = xy(lane, 0)
        for y in range(84, 276, 12):
            pyxel.line(x, y, x, y+3, 1)
    if STAGE >= 2:
        for intent in s.memory.committed:
            x, _ = xy(intent.lane, 0)
            for y in range(110, 269, 10):
                pyxel.line(x, y, x, y+4, 9)
            pyxel.text(x+6, 146, 'FIRE IN '+str(max(0, intent.fire_tick-s.tick+1)), 9)
    cx, cy = xy(80, 18)
    pyxel.circ(cx, cy, 22, 2)
    pyxel.rect(cx-12, cy-12, 24, 24, 8 if STAGE>=4 else 5)
    pyxel.text(cx-8, cy-2, 'CORE', 7)
    if STAGE>=4:
        for j in range(6):
            pyxel.rect(138+j*14, 49, 10, 4, 8 if j<s.core_hp else 1)
        _, wy = xy(0, 32)
        pyxel.line(28, wy, 332, wy, 11 if s.open else 12)
        if not s.open:
            for x in range(28, 330, 10):
                pyxel.line(x, wy-3, x+4, wy+3, 12)
    pyxel.clip(21,45,318,234)
    for b in s.bullets:
        x,y = xy(b.x,b.y)
        if b.owner=='enemy':
            pyxel.circ(x,y,5,8)
            pyxel.circb(x,y,5,10)
        elif b.owner=='player':
            pyxel.tri(x,y-5,x-3,y+3,x+3,y+3,7)
        else:
            pyxel.tri(x,y-7,x-6,y,x,y+7,11)
            pyxel.tri(x,y-7,x+6,y,x,y+7,11)
    x,y = xy(s.x,100)
    if STAGE>=3 and s.ring:
        pyxel.circb(x,y,34,11)  # 原作の可視半径17。衝突判定半径は20のまま。
    if not getattr(s,'invuln',0) or s.tick%4<2:
        pyxel.tri(x,y-10,x-9,y+8,x+9,y+8,7)
        pyxel.rect(x-3,y-4,6,8,12)
    pyxel.clip()
    lines=[('WORLD TICK',str(s.tick),7),('STATUS',s.status.upper(),10),
           ('X POSITION',f'{s.x:.1f}',6),('SHOT WAIT',str(s.shot_cooldown),7)]
    if STAGE>=2: lines += [('PLAYER HP',str(s.hp)+'/3',8),('INVULNERABLE',str(s.invuln),6)]
    if STAGE>=3: lines += [('CHARGE',str(s.charge)+'/3',11),('RING ACTIVE',str(s.ring),11),
                           ('REUSE WAIT',str(s.cooldown),6),('RECHARGE IN',str(90-s.tick%90),6)]
    if STAGE>=4: lines += [('BARRIER',('OPEN '+str(s.open)) if s.open else 'CLOSED',11 if s.open else 12),('CORE HP',str(s.core_hp)+'/6',8)]
    for i,(label,value,color) in enumerate(lines):
        pyxel.text(356,48+i*17,label,13)
        pyxel.text(446,48+i*17,value,color)
    pyxel.text(12,292,'WHITE: PLAYER SHOT   RED: ENEMY   GREEN: AUTHENTICATED RETURN',7)
    if STAGE==1: message='LAB: movement + shots only. No damage, wall, enemies, or ending.'
    elif STAGE==2: message='LAB: telegraphs + enemy contact. No reflection, wall, or victory.'
    elif STAGE==3: message='LAB: reflection only. Returned bullets cannot damage the core yet.'
    else: message='GOAL: destroy core AND survive. Simultaneous zero HP is a loss.'
    pyxel.text(12,308,message,6)
    controls='LEFT/RIGHT: move  Z: hold fire  R: reset  ESC: quit'
    if STAGE>=3: controls='ARROWS: move  Z: hold fire  X: press reflect  R: reset  ESC: quit'
    pyxel.text(12,327,controls,7)
    if STAGE==5:
        pyxel.text(12,342,'SPACE: start  P: pause  M: title / after changes: release ALL keys',10)
    else:
        pyxel.text(12,342,'After reset: release ALL control keys. Fixed 30 ticks/second.',13)
    if s.status!='playing':
        pyxel.rect(62,160,236,44,1)
        pyxel.rectb(62,160,236,44,10)
        pyxel.text(92,172,'CORE DESTROYED / YOU SURVIVED' if s.status=='won' else 'PLAYER LOST / PRESS R',10)
        pyxel.text(92,188,'R: reset the same original case A',7)
    if getattr(controller,'title',False) or getattr(controller,'pause',False):
        pyxel.rect(38,122,284,94,1)
        pyxel.rectb(38,122,284,94,11)
        pyxel.text(52,134,'RETURN BULLETS' if controller.title else 'PAUSED: ALL GAME CLOCKS STOP',10)
        pyxel.text(52,153,'Return one red bullet to open the wall.',7)
        pyxel.text(52,170,'X lasts 12 ticks. Z fires while held.',7)
        pyxel.text(52,191,'SPACE: start' if controller.title else 'P: resume / release all keys after',11)


def demo_keys(controller, frame):
    """公開状態だけを見る技術QA用入力。人間の試遊ではない。"""
    if STAGE==5:
        if frame==1: return ('SPACE',)
        if frame==2: return ()
    s=controller.state
    if STAGE==1: return ('Z','RIGHT') if s.tick<10 else ('Z',)
    if STAGE==2: return ()
    keys=['Z']
    if any(b.owner=='enemy' and 73<=b.y<101 and abs(b.x-s.x)<21
           for b in s.bullets) and s.charge>0 and s.cooldown<=1 and not s.xheld:
        keys.append('X')
    return tuple(keys)


def main(headless=False, frames=180, demo=False, replay=None, screenshot=None, report=None):
    from pathlib import Path
    import pyxel  # 通常テスト/インポートにPyxelや画面を要求しない。
    controller=Controller()
    codes={name:getattr(pyxel,'KEY_'+name) for name in CONTROLS}
    frame=0
    old_held=set()
    changes=[]
    screenshot=str(Path(screenshot).resolve()) if screenshot else None
    report=str(Path(report).resolve()) if report else None
    def update():
        nonlocal frame,old_held
        frame+=1
        if headless:
            held=set(replay[frame-1]) if replay is not None and frame<=len(replay) else set(demo_keys(controller,frame) if demo else ())
            pressed=held-old_held
            old_held=held
        else:
            held={name for name,code in codes.items() if pyxel.btn(code)}
            pressed={name for name,code in codes.items() if pyxel.btnp(code)}
        before=controller.snapshot()
        controller.tick(pressed,held)
        if headless and (controller.state.events or before['reason']!=controller.reason):
            changes.append({'frame':frame,'pressed':sorted(pressed),'held':sorted(held),'snapshot':controller.snapshot()})
    def draw():
        draw_board(pyxel,controller)
        if headless and frame>=frames:
            if screenshot: pyxel.screenshot(screenshot,scale=1)
            if report:
                Path(report).write_text(json.dumps({'label':'Actual Pyxel headless; synthetic controller inputs; silent; not human play',
                    'stage':STAGE,'frames':frame,'demo':demo,'changes':changes,
                    'snapshot':controller.snapshot()},ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            pyxel.quit()
    pyxel.init(520,360,title=f'Return Bullets / Step {STAGE}',fps=30,headless=headless)
    pyxel.run(update,draw)


if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless',action='store_true')
    parser.add_argument('--frames',type=int,default=180)
    parser.add_argument('--demo',action='store_true')
    parser.add_argument('--replay-json',help='UTF-8 JSON list of held-key lists, one per frame')
    parser.add_argument('--screenshot',help='PNG output; headless only, relative to current directory')
    parser.add_argument('--report',help='UTF-8 JSON output; headless only')
    args=parser.parse_args()
    if args.frames<12: parser.error('Use at least 12 frames')
    if not args.headless and (args.demo or args.replay_json or args.screenshot or args.report):
        parser.error('QA input and output options require --headless')
    if args.demo and args.replay_json: parser.error('Choose demo or replay, not both')
    replay=None
    if args.replay_json:
        try: replay=json.loads(Path(args.replay_json).read_text(encoding='utf-8'))
        except (OSError,ValueError) as error: parser.error(str(error))
        if not isinstance(replay,list) or any(not isinstance(keys,list) or any(not isinstance(k,str) or k not in CONTROLS for k in keys) for keys in replay):
            parser.error('Replay must be a list of lists of supported key names')
    main(args.headless,args.frames,args.demo,replay,args.screenshot,args.report)
STEP 02 / 05

予告を確定し、30tick後に発射する

見る・ねらいを決める・予約する・線分で当たりを調べる。

元のゲームの状況 A の、まん中に飛んでくる弾を加えます。tick 0 で「tick 30 に撃つ」、tick 70 で「tick 100 に撃つ」と予定を覚え、180 tick ごとにくり返します。黄色い線は予定の表示で、弾でも攻撃でもありません。撃たれると赤い弾になり、プレイヤーに線分で当たると HP が減ります。

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

02_telegraph.py / 注目する部分
        if intent.fire_tick == s.tick:
            s.bullets.append(Bullet(s.next_id, 'enemy', intent.lane, 18.0, 0.0, level.speed))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_telegraph.py
macOS / Linux
.venv/bin/python 02_telegraph.py
画面で見ること何も押さずに始めると予告の線が見えますが、30回更新した時点では、赤い弾はまだありません。31回目は更新を始める時刻が tick 30 なので撃ち、y 19.4 まで進みます。まん中にいつづけると当たって、3回で LOST(負け)です。はね返しと勝ちはまだありません。
  • 予告中と、本物の弾が飛んでいる間を区別できる
  • 予告で保存した lane(通り道)を、撃つときにも使う
  • 「更新を始める時刻 tick 30」と「30回更新したあと」のちがいを説明できる
  • 1回の更新で何回ふれても、HP は1だけ減る
つまずきやすいところ予告の表示だけを固定して、撃つときにもう一度プレイヤーの位置を読むと、予告と本物の弾がずれてしまいます。また、弾が着いた点だけを調べると、速い弾がプレイヤーを飛びこえてしまいます。
少し変えてみよう

最初の予告が出てから右へ動き、赤い弾がまん中の x 80 に出ることを確かめましょう。発展として、schedule を一時的に ((30,None),) に変えて、予告したときの x だけをねらうことを確かめます。元のゲームの状況 A は決まった通り道なので、終わったら元に戻します。

02_telegraph.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(374行)
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.
# 教材画面は標準ASCIIと図形のみ。字形データは同梱しません。

"""返せる弾丸・教材2/5。日本語説明はLESSON.md。"""
from dataclasses import dataclass, field, asdict
from copy import deepcopy
import math
import json
import hashlib

@dataclass(frozen=True)
class Level:
    name: str
    start: float
    speed: float
    schedule: tuple
    ring_ticks: int = 12

# 原作A。一周180tick、中央へ30/100tickで発射。数値は変えない。
LEVEL = Level('一発を返す', 80.0, 1.4, ((30, 80), (100, 80)))

@dataclass
class Bullet:
    ident: int
    owner: str
    x: float
    y: float
    vx: float
    vy: float
    source: str = 'core'

@dataclass(frozen=True)
class Input:
    dx: int = 0
    z: bool = False
    x: bool = False

@dataclass(frozen=True)
class Observation:
    player_x: float
    advertised_lane: float | None
    fire_tick: int
    ident: int

@dataclass(frozen=True)
class Intent:
    lane: float
    fire_tick: int
    ident: int
    aimed: bool

@dataclass
class Memory:
    committed: list = field(default_factory=list)


def policy(obs, memory):
    # Aは固定レーン。Noneを使う原作Cも同じ関数で「予告時だけ」狙う。
    lane = obs.player_x if obs.advertised_lane is None else obs.advertised_lane
    return Intent(lane, obs.fire_tick, obs.ident, obs.advertised_lane is None)


def segment_distance(x1, y1, x2, y2, cx, cy):
    dx, dy = x2-x1, y2-y1
    length2 = dx*dx + dy*dy
    t = 0 if length2 == 0 else max(0, min(1, ((cx-x1)*dx+(cy-y1)*dy)/length2))
    return math.hypot(x1+t*dx-cx, y1+t*dy-cy)


def touches(x1, y1, x2, y2, cx, cy, radius):
    return segment_distance(x1, y1, x2, y2, cx, cy) <= radius

@dataclass
class WorldState:
    tick: int = 0
    x: float = 80.0
    hp: int = 3
    invuln: int = 0
    shot_cooldown: int = 0
    memory: Memory = field(default_factory=Memory)
    bullets: list = field(default_factory=list)
    next_id: int = 1
    status: str = 'playing'
    events: tuple = ()


def initial(level=LEVEL):
    return WorldState(x=level.start)


def step(level, old, inp=Input()):
    if old.status != 'playing':
        return deepcopy(old)
    s = deepcopy(old)  # 前の状態を残す。テストでも比較しやすい。
    events = []
    oldx = s.x
    for name in ('invuln', 'shot_cooldown'):
        setattr(s, name, max(0, getattr(s, name)-1))
    cycle = s.tick // 180
    for k, (fire, lane) in enumerate(level.schedule):
        if s.tick % 180 == fire-30:
            observation = Observation(old.x, lane, cycle*180+fire, cycle*len(level.schedule)+k)
            intent = policy(observation, s.memory)
            s.memory.committed.append(intent)
            events.append(('warning', intent.ident, intent.lane, intent.fire_tick, intent.aimed))
    for intent in s.memory.committed:
        if intent.fire_tick == s.tick:
            s.bullets.append(Bullet(s.next_id, 'enemy', intent.lane, 18.0, 0.0, level.speed))
            s.next_id += 1
            events.append(('fire', intent.ident, intent.lane, s.next_id-1))
    s.memory.committed = [intent for intent in s.memory.committed if intent.fire_tick >= s.tick]
    s.x = max(16, min(144, s.x + max(-1, min(1, inp.dx))*1.8))
    if inp.z and s.shot_cooldown == 0:
        s.bullets.append(Bullet(s.next_id, 'player', s.x, 93.0, 0.0, -3.0, 'player'))
        s.next_id += 1
        s.shot_cooldown = 24
        events.append(('shot', s.next_id-1))
    alive = []
    player_hit = False
    moved = [(b, b.x, b.y, b.x+b.vx, b.y+b.vy) for b in s.bullets]
    for b, x, y, nx, ny in moved:
        if b.owner == 'enemy':
            if touches(x-oldx, y-100, nx-s.x, ny-100, 0, 0, 8):
                if s.invuln == 0:
                    player_hit = True
                events.append(('enemy_contact', b.ident))
                continue
        b.x, b.y = round(nx, 6), round(ny, 6)
        if -8 <= b.x <= 168 and -8 <= b.y <= 116:
            alive.append(b)
        else:
            events.append(('missed', b.ident, b.owner))
    s.bullets = alive
    if player_hit:
        s.hp -= 1
        s.invuln = 30
        events.append(('hurt',))
    if s.hp <= 0:
        s.status = 'lost'
        events.append(('lost',))
    s.tick += 1
    s.events = tuple(events)
    return s


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

STAGE = 2
STAGE_TITLE = 'COMMIT A TELEGRAPH'

CONTROLS = ('LEFT', 'RIGHT', 'Z', 'R')

class Controller:
    def __init__(self):
        self.state = initial()
        self.gate = False
        self.reason = 'ready'
        self.last_events = ()

    def reset(self):
        self.state = initial()
        self.gate = True
        self.last_events = ()

    def tick(self, pressed=(), held=()):
        pressed = set(pressed) & set(CONTROLS)
        held = set(held) & set(CONTROLS)
        if self.gate:
            if not held:
                self.gate = False
            return  # 全解放フレーム自体も世界を進めない。
        if 'R' in pressed:
            self.reset()
            self.reason = 'reset'
            return
        if self.state.status != 'playing':
            return
        # 左右同時は0。Zは保持で連射し、Xの新押下はモデル側で判断する。
        inp = Input(int('RIGHT' in held)-int('LEFT' in held), 'Z' in held, 'X' in held)
        self.state = step(LEVEL, self.state, inp)
        self.reason = 'play'
        if self.state.events:
            self.last_events = self.state.events

    def snapshot(self):
        result = {'state': asdict(self.state), 'hash': state_hash(self.state),
                  'gate': self.gate, 'reason': self.reason, 'last_events': self.last_events}
        return result


def draw_board(pyxel, controller):
    s = controller.state
    pyxel.cls(1)
    pyxel.text(12, 10, f'RETURN BULLETS 036 / STEP {STAGE}', 10)
    pyxel.text(12, 24, STAGE_TITLE, 7)
    pyxel.rect(20, 44, 320, 236, 0)
    pyxel.rectb(20, 44, 320, 236, 5)
    def xy(x, y):
        return 20+x*2, 44+y*2
    for lane in (40, 80, 120):
        x, _ = xy(lane, 0)
        for y in range(84, 276, 12):
            pyxel.line(x, y, x, y+3, 1)
    if STAGE >= 2:
        for intent in s.memory.committed:
            x, _ = xy(intent.lane, 0)
            for y in range(110, 269, 10):
                pyxel.line(x, y, x, y+4, 9)
            pyxel.text(x+6, 146, 'FIRE IN '+str(max(0, intent.fire_tick-s.tick+1)), 9)
    cx, cy = xy(80, 18)
    pyxel.circ(cx, cy, 22, 2)
    pyxel.rect(cx-12, cy-12, 24, 24, 8 if STAGE>=4 else 5)
    pyxel.text(cx-8, cy-2, 'CORE', 7)
    if STAGE>=4:
        for j in range(6):
            pyxel.rect(138+j*14, 49, 10, 4, 8 if j<s.core_hp else 1)
        _, wy = xy(0, 32)
        pyxel.line(28, wy, 332, wy, 11 if s.open else 12)
        if not s.open:
            for x in range(28, 330, 10):
                pyxel.line(x, wy-3, x+4, wy+3, 12)
    pyxel.clip(21,45,318,234)
    for b in s.bullets:
        x,y = xy(b.x,b.y)
        if b.owner=='enemy':
            pyxel.circ(x,y,5,8)
            pyxel.circb(x,y,5,10)
        elif b.owner=='player':
            pyxel.tri(x,y-5,x-3,y+3,x+3,y+3,7)
        else:
            pyxel.tri(x,y-7,x-6,y,x,y+7,11)
            pyxel.tri(x,y-7,x+6,y,x,y+7,11)
    x,y = xy(s.x,100)
    if STAGE>=3 and s.ring:
        pyxel.circb(x,y,34,11)  # 原作の可視半径17。衝突判定半径は20のまま。
    if not getattr(s,'invuln',0) or s.tick%4<2:
        pyxel.tri(x,y-10,x-9,y+8,x+9,y+8,7)
        pyxel.rect(x-3,y-4,6,8,12)
    pyxel.clip()
    lines=[('WORLD TICK',str(s.tick),7),('STATUS',s.status.upper(),10),
           ('X POSITION',f'{s.x:.1f}',6),('SHOT WAIT',str(s.shot_cooldown),7)]
    if STAGE>=2: lines += [('PLAYER HP',str(s.hp)+'/3',8),('INVULNERABLE',str(s.invuln),6)]
    if STAGE>=3: lines += [('CHARGE',str(s.charge)+'/3',11),('RING ACTIVE',str(s.ring),11),
                           ('REUSE WAIT',str(s.cooldown),6),('RECHARGE IN',str(90-s.tick%90),6)]
    if STAGE>=4: lines += [('BARRIER',('OPEN '+str(s.open)) if s.open else 'CLOSED',11 if s.open else 12),('CORE HP',str(s.core_hp)+'/6',8)]
    for i,(label,value,color) in enumerate(lines):
        pyxel.text(356,48+i*17,label,13)
        pyxel.text(446,48+i*17,value,color)
    pyxel.text(12,292,'WHITE: PLAYER SHOT   RED: ENEMY   GREEN: AUTHENTICATED RETURN',7)
    if STAGE==1: message='LAB: movement + shots only. No damage, wall, enemies, or ending.'
    elif STAGE==2: message='LAB: telegraphs + enemy contact. No reflection, wall, or victory.'
    elif STAGE==3: message='LAB: reflection only. Returned bullets cannot damage the core yet.'
    else: message='GOAL: destroy core AND survive. Simultaneous zero HP is a loss.'
    pyxel.text(12,308,message,6)
    controls='LEFT/RIGHT: move  Z: hold fire  R: reset  ESC: quit'
    if STAGE>=3: controls='ARROWS: move  Z: hold fire  X: press reflect  R: reset  ESC: quit'
    pyxel.text(12,327,controls,7)
    if STAGE==5:
        pyxel.text(12,342,'SPACE: start  P: pause  M: title / after changes: release ALL keys',10)
    else:
        pyxel.text(12,342,'After reset: release ALL control keys. Fixed 30 ticks/second.',13)
    if s.status!='playing':
        pyxel.rect(62,160,236,44,1)
        pyxel.rectb(62,160,236,44,10)
        pyxel.text(92,172,'CORE DESTROYED / YOU SURVIVED' if s.status=='won' else 'PLAYER LOST / PRESS R',10)
        pyxel.text(92,188,'R: reset the same original case A',7)
    if getattr(controller,'title',False) or getattr(controller,'pause',False):
        pyxel.rect(38,122,284,94,1)
        pyxel.rectb(38,122,284,94,11)
        pyxel.text(52,134,'RETURN BULLETS' if controller.title else 'PAUSED: ALL GAME CLOCKS STOP',10)
        pyxel.text(52,153,'Return one red bullet to open the wall.',7)
        pyxel.text(52,170,'X lasts 12 ticks. Z fires while held.',7)
        pyxel.text(52,191,'SPACE: start' if controller.title else 'P: resume / release all keys after',11)


def demo_keys(controller, frame):
    """公開状態だけを見る技術QA用入力。人間の試遊ではない。"""
    if STAGE==5:
        if frame==1: return ('SPACE',)
        if frame==2: return ()
    s=controller.state
    if STAGE==1: return ('Z','RIGHT') if s.tick<10 else ('Z',)
    if STAGE==2: return ()
    keys=['Z']
    if any(b.owner=='enemy' and 73<=b.y<101 and abs(b.x-s.x)<21
           for b in s.bullets) and s.charge>0 and s.cooldown<=1 and not s.xheld:
        keys.append('X')
    return tuple(keys)


def main(headless=False, frames=180, demo=False, replay=None, screenshot=None, report=None):
    from pathlib import Path
    import pyxel  # 通常テスト/インポートにPyxelや画面を要求しない。
    controller=Controller()
    codes={name:getattr(pyxel,'KEY_'+name) for name in CONTROLS}
    frame=0
    old_held=set()
    changes=[]
    screenshot=str(Path(screenshot).resolve()) if screenshot else None
    report=str(Path(report).resolve()) if report else None
    def update():
        nonlocal frame,old_held
        frame+=1
        if headless:
            held=set(replay[frame-1]) if replay is not None and frame<=len(replay) else set(demo_keys(controller,frame) if demo else ())
            pressed=held-old_held
            old_held=held
        else:
            held={name for name,code in codes.items() if pyxel.btn(code)}
            pressed={name for name,code in codes.items() if pyxel.btnp(code)}
        before=controller.snapshot()
        controller.tick(pressed,held)
        if headless and (controller.state.events or before['reason']!=controller.reason):
            changes.append({'frame':frame,'pressed':sorted(pressed),'held':sorted(held),'snapshot':controller.snapshot()})
    def draw():
        draw_board(pyxel,controller)
        if headless and frame>=frames:
            if screenshot: pyxel.screenshot(screenshot,scale=1)
            if report:
                Path(report).write_text(json.dumps({'label':'Actual Pyxel headless; synthetic controller inputs; silent; not human play',
                    'stage':STAGE,'frames':frame,'demo':demo,'changes':changes,
                    'snapshot':controller.snapshot()},ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            pyxel.quit()
    pyxel.init(520,360,title=f'Return Bullets / Step {STAGE}',fps=30,headless=headless)
    pyxel.run(update,draw)


if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless',action='store_true')
    parser.add_argument('--frames',type=int,default=180)
    parser.add_argument('--demo',action='store_true')
    parser.add_argument('--replay-json',help='UTF-8 JSON list of held-key lists, one per frame')
    parser.add_argument('--screenshot',help='PNG output; headless only, relative to current directory')
    parser.add_argument('--report',help='UTF-8 JSON output; headless only')
    args=parser.parse_args()
    if args.frames<12: parser.error('Use at least 12 frames')
    if not args.headless and (args.demo or args.replay_json or args.screenshot or args.report):
        parser.error('QA input and output options require --headless')
    if args.demo and args.replay_json: parser.error('Choose demo or replay, not both')
    replay=None
    if args.replay_json:
        try: replay=json.loads(Path(args.replay_json).read_text(encoding='utf-8'))
        except (OSError,ValueError) as error: parser.error(str(error))
        if not isinstance(replay,list) or any(not isinstance(keys,list) or any(not isinstance(k,str) or k not in CONTROLS for k in keys) for keys in replay):
            parser.error('Replay must be a list of lists of supported key names')
    main(args.headless,args.frames,args.demo,replay,args.screenshot,args.report)
STEP 03 / 05

12tickの窓で弾の所有者を変える

押した瞬間・はね返しの時間・別々の時計・全部を先に処理する。

X を新しく押したときだけ、はね返しの輪を12 tick 出し、はね返しの力を1使います。輪が出ている時間、次に使えるまでの36 tick、90 tick ごとの回復は、別々の時計です。輪にふれた敵の弾は、同じ ident と source(撃った元)のまま、owner だけを reflected(返した)に変え、コアの方向へ速さ3で戻します。コアへのダメージと壁は、まだありません。

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

03_reflection.py / 注目する部分
            b.owner = 'reflected'
            b.x, b.y = nx, ny
            dx, dy = 80-nx, 18-ny

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_reflection.py
macOS / Linux
.venv/bin/python 03_reflection.py
画面で見ること弾が輪に入りそうなときに X を押すと、赤い丸が緑のひし形になり、HP は減りません。X を押し続けても、輪は 12→…→0 で消え、もう一度は出ません。元のゲームと同じく、見える輪は半径17、はね返しの判定は半径20です。緑の弾は、この段階ではコアを通りぬけます。
  • 同じ弾の番号と撃った元のまま、持ち主だけを変えられる
  • 12・36・90 の時計をまぜない
  • X を早く押しても予約にはならないと分かる
  • 見える半径17と、判定の半径20を区別できる
  • 当たったかより先に、はね返しを判定する理由を説明できる
つまずきやすいところX を押し続けるたびに輪を12に戻すと、いつでもはね返せてしまいます。はね返した弾を新しく作り直すと、番号や撃った元が分からなくなります。はね返した直後に、戻る方向にも動かすと、1回の更新で2回進んでしまいます。
少し変えてみよう

X を50 tick 押し続けると、はね返しの力はいくつになるか予想してから確かめましょう(最初の3から2)。次に X を離して、もう一度押します。判定の境目は、中心の差が20ならはね返し、20.000001 ならはね返さないことを、test_19 で確かめてください。

03_reflection.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(405行)
03_reflection.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.
# 教材画面は標準ASCIIと図形のみ。字形データは同梱しません。

"""返せる弾丸・教材3/5。日本語説明はLESSON.md。"""
from dataclasses import dataclass, field, asdict
from copy import deepcopy
import math
import json
import hashlib

@dataclass(frozen=True)
class Level:
    name: str
    start: float
    speed: float
    schedule: tuple
    ring_ticks: int = 12

# 原作A。一周180tick、中央へ30/100tickで発射。数値は変えない。
LEVEL = Level('一発を返す', 80.0, 1.4, ((30, 80), (100, 80)))

@dataclass
class Bullet:
    ident: int
    owner: str
    x: float
    y: float
    vx: float
    vy: float
    source: str = 'core'

@dataclass(frozen=True)
class Input:
    dx: int = 0
    z: bool = False
    x: bool = False

@dataclass(frozen=True)
class Observation:
    player_x: float
    advertised_lane: float | None
    fire_tick: int
    ident: int

@dataclass(frozen=True)
class Intent:
    lane: float
    fire_tick: int
    ident: int
    aimed: bool

@dataclass
class Memory:
    committed: list = field(default_factory=list)


def policy(obs, memory):
    # Aは固定レーン。Noneを使う原作Cも同じ関数で「予告時だけ」狙う。
    lane = obs.player_x if obs.advertised_lane is None else obs.advertised_lane
    return Intent(lane, obs.fire_tick, obs.ident, obs.advertised_lane is None)


def segment_distance(x1, y1, x2, y2, cx, cy):
    dx, dy = x2-x1, y2-y1
    length2 = dx*dx + dy*dy
    t = 0 if length2 == 0 else max(0, min(1, ((cx-x1)*dx+(cy-y1)*dy)/length2))
    return math.hypot(x1+t*dx-cx, y1+t*dy-cy)


def touches(x1, y1, x2, y2, cx, cy, radius):
    return segment_distance(x1, y1, x2, y2, cx, cy) <= radius

@dataclass
class WorldState:
    tick: int = 0
    x: float = 80.0
    hp: int = 3
    invuln: int = 0
    charge: int = 3
    ring: int = 0
    cooldown: int = 0
    shot_cooldown: int = 0
    xheld: bool = False
    memory: Memory = field(default_factory=Memory)
    bullets: list = field(default_factory=list)
    next_id: int = 1
    status: str = 'playing'
    events: tuple = ()


def initial(level=LEVEL):
    return WorldState(x=level.start)


def step(level, old, inp=Input()):
    if old.status != 'playing':
        return deepcopy(old)
    s = deepcopy(old)  # 前の状態を残す。テストでも比較しやすい。
    events = []
    oldx = s.x
    for name in ('invuln', 'ring', 'cooldown', 'shot_cooldown'):
        setattr(s, name, max(0, getattr(s, name)-1))
    # 使用時刻と独立。tick=90,180,... の更新開始時に回復する。
    if s.tick and s.tick % 90 == 0:
        s.charge = min(3, s.charge+1)
        events.append(('recharge', s.charge))
    cycle = s.tick // 180
    for k, (fire, lane) in enumerate(level.schedule):
        if s.tick % 180 == fire-30:
            observation = Observation(old.x, lane, cycle*180+fire, cycle*len(level.schedule)+k)
            intent = policy(observation, s.memory)
            s.memory.committed.append(intent)
            events.append(('warning', intent.ident, intent.lane, intent.fire_tick, intent.aimed))
    for intent in s.memory.committed:
        if intent.fire_tick == s.tick:
            s.bullets.append(Bullet(s.next_id, 'enemy', intent.lane, 18.0, 0.0, level.speed))
            s.next_id += 1
            events.append(('fire', intent.ident, intent.lane, s.next_id-1))
    s.memory.committed = [intent for intent in s.memory.committed if intent.fire_tick >= s.tick]
    s.x = max(16, min(144, s.x + max(-1, min(1, inp.dx))*1.8))
    if inp.x and not s.xheld and s.charge > 0 and s.cooldown == 0:
        s.ring = level.ring_ticks
        s.cooldown = 36
        s.charge -= 1
        events.append(('ring', s.charge))
    s.xheld = inp.x  # 押し続けても新しい押下ではない。
    if inp.z and s.shot_cooldown == 0:
        s.bullets.append(Bullet(s.next_id, 'player', s.x, 93.0, 0.0, -3.0, 'player'))
        s.next_id += 1
        s.shot_cooldown = 24
        events.append(('shot', s.next_id-1))
    alive = []
    player_hit = False
    reflected = set()
    moved = [(b, b.x, b.y, b.x+b.vx, b.y+b.vy) for b in s.bullets]
    # 全弾の反射を先に決める。リストの順で防壁判定を変えない。
    for b, x, y, nx, ny in moved:
        if b.owner == 'enemy' and s.ring > 0 and touches(x-oldx, y-100, nx-s.x, ny-100, 0, 0, 20):
            b.owner = 'reflected'
            b.x, b.y = nx, ny
            dx, dy = 80-nx, 18-ny
            norm = math.hypot(dx, dy)
            b.vx, b.vy = 3*dx/norm, 3*dy/norm
            reflected.add(b.ident)
            events.append(('reflect', b.ident, b.source))
    for b, x, y, nx, ny in moved:
        if b.ident in reflected:
            alive.append(b)
            continue  # 今tickで向きが変わった弾を二度動かさない。
        if b.owner == 'enemy':
            if touches(x-oldx, y-100, nx-s.x, ny-100, 0, 0, 8):
                if s.invuln == 0:
                    player_hit = True
                events.append(('enemy_contact', b.ident))
                continue
        b.x, b.y = round(nx, 6), round(ny, 6)
        if -8 <= b.x <= 168 and -8 <= b.y <= 116:
            alive.append(b)
        else:
            events.append(('missed', b.ident, b.owner))
    s.bullets = alive
    if player_hit:
        s.hp -= 1
        s.invuln = 30
        events.append(('hurt',))
    if s.hp <= 0:
        s.status = 'lost'
        events.append(('lost',))
    s.tick += 1
    s.events = tuple(events)
    return s


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

STAGE = 3
STAGE_TITLE = 'REFLECT AND CHANGE OWNER'

CONTROLS = ('LEFT', 'RIGHT', 'Z', 'X', 'R')

class Controller:
    def __init__(self):
        self.state = initial()
        self.gate = False
        self.reason = 'ready'
        self.last_events = ()

    def reset(self):
        self.state = initial()
        self.gate = True
        self.last_events = ()

    def tick(self, pressed=(), held=()):
        pressed = set(pressed) & set(CONTROLS)
        held = set(held) & set(CONTROLS)
        # 停止中/全解放待ちでもXを離した事実は覚える。原作と同じ順序。
        if 'X' not in held:
            self.state.xheld = False
        if self.gate:
            if not held:
                self.gate = False
            return  # 全解放フレーム自体も世界を進めない。
        if 'R' in pressed:
            self.reset()
            self.reason = 'reset'
            return
        if self.state.status != 'playing':
            return
        # 左右同時は0。Zは保持で連射し、Xの新押下はモデル側で判断する。
        inp = Input(int('RIGHT' in held)-int('LEFT' in held), 'Z' in held, 'X' in held)
        self.state = step(LEVEL, self.state, inp)
        self.reason = 'play'
        if self.state.events:
            self.last_events = self.state.events

    def snapshot(self):
        result = {'state': asdict(self.state), 'hash': state_hash(self.state),
                  'gate': self.gate, 'reason': self.reason, 'last_events': self.last_events}
        return result


def draw_board(pyxel, controller):
    s = controller.state
    pyxel.cls(1)
    pyxel.text(12, 10, f'RETURN BULLETS 036 / STEP {STAGE}', 10)
    pyxel.text(12, 24, STAGE_TITLE, 7)
    pyxel.rect(20, 44, 320, 236, 0)
    pyxel.rectb(20, 44, 320, 236, 5)
    def xy(x, y):
        return 20+x*2, 44+y*2
    for lane in (40, 80, 120):
        x, _ = xy(lane, 0)
        for y in range(84, 276, 12):
            pyxel.line(x, y, x, y+3, 1)
    if STAGE >= 2:
        for intent in s.memory.committed:
            x, _ = xy(intent.lane, 0)
            for y in range(110, 269, 10):
                pyxel.line(x, y, x, y+4, 9)
            pyxel.text(x+6, 146, 'FIRE IN '+str(max(0, intent.fire_tick-s.tick+1)), 9)
    cx, cy = xy(80, 18)
    pyxel.circ(cx, cy, 22, 2)
    pyxel.rect(cx-12, cy-12, 24, 24, 8 if STAGE>=4 else 5)
    pyxel.text(cx-8, cy-2, 'CORE', 7)
    if STAGE>=4:
        for j in range(6):
            pyxel.rect(138+j*14, 49, 10, 4, 8 if j<s.core_hp else 1)
        _, wy = xy(0, 32)
        pyxel.line(28, wy, 332, wy, 11 if s.open else 12)
        if not s.open:
            for x in range(28, 330, 10):
                pyxel.line(x, wy-3, x+4, wy+3, 12)
    pyxel.clip(21,45,318,234)
    for b in s.bullets:
        x,y = xy(b.x,b.y)
        if b.owner=='enemy':
            pyxel.circ(x,y,5,8)
            pyxel.circb(x,y,5,10)
        elif b.owner=='player':
            pyxel.tri(x,y-5,x-3,y+3,x+3,y+3,7)
        else:
            pyxel.tri(x,y-7,x-6,y,x,y+7,11)
            pyxel.tri(x,y-7,x+6,y,x,y+7,11)
    x,y = xy(s.x,100)
    if STAGE>=3 and s.ring:
        pyxel.circb(x,y,34,11)  # 原作の可視半径17。衝突判定半径は20のまま。
    if not getattr(s,'invuln',0) or s.tick%4<2:
        pyxel.tri(x,y-10,x-9,y+8,x+9,y+8,7)
        pyxel.rect(x-3,y-4,6,8,12)
    pyxel.clip()
    lines=[('WORLD TICK',str(s.tick),7),('STATUS',s.status.upper(),10),
           ('X POSITION',f'{s.x:.1f}',6),('SHOT WAIT',str(s.shot_cooldown),7)]
    if STAGE>=2: lines += [('PLAYER HP',str(s.hp)+'/3',8),('INVULNERABLE',str(s.invuln),6)]
    if STAGE>=3: lines += [('CHARGE',str(s.charge)+'/3',11),('RING ACTIVE',str(s.ring),11),
                           ('REUSE WAIT',str(s.cooldown),6),('RECHARGE IN',str(90-s.tick%90),6)]
    if STAGE>=4: lines += [('BARRIER',('OPEN '+str(s.open)) if s.open else 'CLOSED',11 if s.open else 12),('CORE HP',str(s.core_hp)+'/6',8)]
    for i,(label,value,color) in enumerate(lines):
        pyxel.text(356,48+i*17,label,13)
        pyxel.text(446,48+i*17,value,color)
    pyxel.text(12,292,'WHITE: PLAYER SHOT   RED: ENEMY   GREEN: AUTHENTICATED RETURN',7)
    if STAGE==1: message='LAB: movement + shots only. No damage, wall, enemies, or ending.'
    elif STAGE==2: message='LAB: telegraphs + enemy contact. No reflection, wall, or victory.'
    elif STAGE==3: message='LAB: reflection only. Returned bullets cannot damage the core yet.'
    else: message='GOAL: destroy core AND survive. Simultaneous zero HP is a loss.'
    pyxel.text(12,308,message,6)
    controls='LEFT/RIGHT: move  Z: hold fire  R: reset  ESC: quit'
    if STAGE>=3: controls='ARROWS: move  Z: hold fire  X: press reflect  R: reset  ESC: quit'
    pyxel.text(12,327,controls,7)
    if STAGE==5:
        pyxel.text(12,342,'SPACE: start  P: pause  M: title / after changes: release ALL keys',10)
    else:
        pyxel.text(12,342,'After reset: release ALL control keys. Fixed 30 ticks/second.',13)
    if s.status!='playing':
        pyxel.rect(62,160,236,44,1)
        pyxel.rectb(62,160,236,44,10)
        pyxel.text(92,172,'CORE DESTROYED / YOU SURVIVED' if s.status=='won' else 'PLAYER LOST / PRESS R',10)
        pyxel.text(92,188,'R: reset the same original case A',7)
    if getattr(controller,'title',False) or getattr(controller,'pause',False):
        pyxel.rect(38,122,284,94,1)
        pyxel.rectb(38,122,284,94,11)
        pyxel.text(52,134,'RETURN BULLETS' if controller.title else 'PAUSED: ALL GAME CLOCKS STOP',10)
        pyxel.text(52,153,'Return one red bullet to open the wall.',7)
        pyxel.text(52,170,'X lasts 12 ticks. Z fires while held.',7)
        pyxel.text(52,191,'SPACE: start' if controller.title else 'P: resume / release all keys after',11)


def demo_keys(controller, frame):
    """公開状態だけを見る技術QA用入力。人間の試遊ではない。"""
    if STAGE==5:
        if frame==1: return ('SPACE',)
        if frame==2: return ()
    s=controller.state
    if STAGE==1: return ('Z','RIGHT') if s.tick<10 else ('Z',)
    if STAGE==2: return ()
    keys=['Z']
    if any(b.owner=='enemy' and 73<=b.y<101 and abs(b.x-s.x)<21
           for b in s.bullets) and s.charge>0 and s.cooldown<=1 and not s.xheld:
        keys.append('X')
    return tuple(keys)


def main(headless=False, frames=180, demo=False, replay=None, screenshot=None, report=None):
    from pathlib import Path
    import pyxel  # 通常テスト/インポートにPyxelや画面を要求しない。
    controller=Controller()
    codes={name:getattr(pyxel,'KEY_'+name) for name in CONTROLS}
    frame=0
    old_held=set()
    changes=[]
    screenshot=str(Path(screenshot).resolve()) if screenshot else None
    report=str(Path(report).resolve()) if report else None
    def update():
        nonlocal frame,old_held
        frame+=1
        if headless:
            held=set(replay[frame-1]) if replay is not None and frame<=len(replay) else set(demo_keys(controller,frame) if demo else ())
            pressed=held-old_held
            old_held=held
        else:
            held={name for name,code in codes.items() if pyxel.btn(code)}
            pressed={name for name,code in codes.items() if pyxel.btnp(code)}
        before=controller.snapshot()
        controller.tick(pressed,held)
        if headless and (controller.state.events or before['reason']!=controller.reason):
            changes.append({'frame':frame,'pressed':sorted(pressed),'held':sorted(held),'snapshot':controller.snapshot()})
    def draw():
        draw_board(pyxel,controller)
        if headless and frame>=frames:
            if screenshot: pyxel.screenshot(screenshot,scale=1)
            if report:
                Path(report).write_text(json.dumps({'label':'Actual Pyxel headless; synthetic controller inputs; silent; not human play',
                    'stage':STAGE,'frames':frame,'demo':demo,'changes':changes,
                    'snapshot':controller.snapshot()},ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            pyxel.quit()
    pyxel.init(520,360,title=f'Return Bullets / Step {STAGE}',fps=30,headless=headless)
    pyxel.run(update,draw)


if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless',action='store_true')
    parser.add_argument('--frames',type=int,default=180)
    parser.add_argument('--demo',action='store_true')
    parser.add_argument('--replay-json',help='UTF-8 JSON list of held-key lists, one per frame')
    parser.add_argument('--screenshot',help='PNG output; headless only, relative to current directory')
    parser.add_argument('--report',help='UTF-8 JSON output; headless only')
    args=parser.parse_args()
    if args.frames<12: parser.error('Use at least 12 frames')
    if not args.headless and (args.demo or args.replay_json or args.screenshot or args.report):
        parser.error('QA input and output options require --headless')
    if args.demo and args.replay_json: parser.error('Choose demo or replay, not both')
    replay=None
    if args.replay_json:
        try: replay=json.loads(Path(args.replay_json).read_text(encoding='utf-8'))
        except (OSError,ValueError) as error: parser.error(str(error))
        if not isinstance(replay,list) or any(not isinstance(keys,list) or any(not isinstance(k,str) or k not in CONTROLS for k in keys) for keys in replay):
            parser.error('Replay must be a list of lists of supported key names')
    main(args.headless,args.frames,args.demo,replay,args.screenshot,args.report)
STEP 04 / 05

認証された弾と通常のダメージを分ける

持ち主による通行・2段階の当たり判定・勝ち負けの優先。

ここで、元のゲームの状況 A の中身が完成します。はね返しが起きると、壁の open を75にします。閉じた壁では、ふつうの弾だけを消し、返した弾は、壁が閉じても通します。どちらもコアに当たれば1ダメージで、「返した弾は強い」という別の意味にはしません。全部の弾のはね返しを先に処理するので、同じ tick のふつうの弾にも、開いた壁が使われます。

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

04_barrier.py / 注目する部分
            if b.owner == 'player' and s.open == 0 and y >= 32 and ny <= 32:
                events.append(('blocked', b.ident))
                continue

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_barrier.py
macOS / Linux
.venv/bin/python 04_barrier.py
画面で見ることZ だけでは、閉じた壁でふつうの弾が消え、コアの HP 6 は減りません。X で1発返してから Z を押し続けると、壁が開いている間に、ふつうの弾も届きます。コアの HP が0で、自分が生きていれば WON(勝ち)です。プレイヤーとコアが同時に HP 0 なら LOST です。
  • ふつうの弾も返した弾も、同じ1ダメージだと分かる
  • 「壁を通れる資格」と「HP を減らす」を別々に判定する
  • リストの順番で、同じ tick の結果が変わらない
  • 同時に HP 0 なら負けだと説明できる
つまずきやすいところ弾ごとに「はね返し → 壁」を続けて処理すると、リストの先頭にあるふつうの弾だけが、閉じた壁に消されてしまいます。返した弾にも open>0 を求めると、飛んでいる間に壁が閉じたとき、通れる資格が取り消されてしまいます。
少し変えてみよう

test_26 のふつうの弾と敵の弾の順番を逆にしても、core_hp が5になることを確かめましょう。次に、test_24 で open=0 なのに返した弾が1ダメージになる理由を、owner と source のちがいもふくめて説明してください。

04_barrier.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(421行)
04_barrier.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.
# 教材画面は標準ASCIIと図形のみ。字形データは同梱しません。

"""返せる弾丸・教材4/5。日本語説明はLESSON.md。"""
from dataclasses import dataclass, field, asdict
from copy import deepcopy
import math
import json
import hashlib

@dataclass(frozen=True)
class Level:
    name: str
    start: float
    speed: float
    schedule: tuple
    ring_ticks: int = 12

# 原作A。一周180tick、中央へ30/100tickで発射。数値は変えない。
LEVEL = Level('一発を返す', 80.0, 1.4, ((30, 80), (100, 80)))

@dataclass
class Bullet:
    ident: int
    owner: str
    x: float
    y: float
    vx: float
    vy: float
    source: str = 'core'

@dataclass(frozen=True)
class Input:
    dx: int = 0
    z: bool = False
    x: bool = False

@dataclass(frozen=True)
class Observation:
    player_x: float
    advertised_lane: float | None
    fire_tick: int
    ident: int

@dataclass(frozen=True)
class Intent:
    lane: float
    fire_tick: int
    ident: int
    aimed: bool

@dataclass
class Memory:
    committed: list = field(default_factory=list)


def policy(obs, memory):
    # Aは固定レーン。Noneを使う原作Cも同じ関数で「予告時だけ」狙う。
    lane = obs.player_x if obs.advertised_lane is None else obs.advertised_lane
    return Intent(lane, obs.fire_tick, obs.ident, obs.advertised_lane is None)


def segment_distance(x1, y1, x2, y2, cx, cy):
    dx, dy = x2-x1, y2-y1
    length2 = dx*dx + dy*dy
    t = 0 if length2 == 0 else max(0, min(1, ((cx-x1)*dx+(cy-y1)*dy)/length2))
    return math.hypot(x1+t*dx-cx, y1+t*dy-cy)


def touches(x1, y1, x2, y2, cx, cy, radius):
    return segment_distance(x1, y1, x2, y2, cx, cy) <= radius

@dataclass
class WorldState:
    tick: int = 0
    x: float = 80.0
    hp: int = 3
    core_hp: int = 6
    invuln: int = 0
    charge: int = 3
    ring: int = 0
    cooldown: int = 0
    open: int = 0
    shot_cooldown: int = 0
    xheld: bool = False
    memory: Memory = field(default_factory=Memory)
    bullets: list = field(default_factory=list)
    next_id: int = 1
    status: str = 'playing'
    events: tuple = ()


def initial(level=LEVEL):
    return WorldState(x=level.start)


def step(level, old, inp=Input()):
    if old.status != 'playing':
        return deepcopy(old)
    s = deepcopy(old)  # 前の状態を残す。テストでも比較しやすい。
    events = []
    oldx = s.x
    for name in ('invuln', 'ring', 'cooldown', 'open', 'shot_cooldown'):
        setattr(s, name, max(0, getattr(s, name)-1))
    # 使用時刻と独立。tick=90,180,... の更新開始時に回復する。
    if s.tick and s.tick % 90 == 0:
        s.charge = min(3, s.charge+1)
        events.append(('recharge', s.charge))
    cycle = s.tick // 180
    for k, (fire, lane) in enumerate(level.schedule):
        if s.tick % 180 == fire-30:
            observation = Observation(old.x, lane, cycle*180+fire, cycle*len(level.schedule)+k)
            intent = policy(observation, s.memory)
            s.memory.committed.append(intent)
            events.append(('warning', intent.ident, intent.lane, intent.fire_tick, intent.aimed))
    for intent in s.memory.committed:
        if intent.fire_tick == s.tick:
            s.bullets.append(Bullet(s.next_id, 'enemy', intent.lane, 18.0, 0.0, level.speed))
            s.next_id += 1
            events.append(('fire', intent.ident, intent.lane, s.next_id-1))
    s.memory.committed = [intent for intent in s.memory.committed if intent.fire_tick >= s.tick]
    s.x = max(16, min(144, s.x + max(-1, min(1, inp.dx))*1.8))
    if inp.x and not s.xheld and s.charge > 0 and s.cooldown == 0:
        s.ring = level.ring_ticks
        s.cooldown = 36
        s.charge -= 1
        events.append(('ring', s.charge))
    s.xheld = inp.x  # 押し続けても新しい押下ではない。
    if inp.z and s.shot_cooldown == 0:
        s.bullets.append(Bullet(s.next_id, 'player', s.x, 93.0, 0.0, -3.0, 'player'))
        s.next_id += 1
        s.shot_cooldown = 24
        events.append(('shot', s.next_id-1))
    alive = []
    core_damage = 0
    player_hit = False
    reflected = set()
    moved = [(b, b.x, b.y, b.x+b.vx, b.y+b.vy) for b in s.bullets]
    # 全弾の反射を先に決める。リストの順で防壁判定を変えない。
    for b, x, y, nx, ny in moved:
        if b.owner == 'enemy' and s.ring > 0 and touches(x-oldx, y-100, nx-s.x, ny-100, 0, 0, 20):
            b.owner = 'reflected'
            b.x, b.y = nx, ny
            dx, dy = 80-nx, 18-ny
            norm = math.hypot(dx, dy)
            b.vx, b.vy = 3*dx/norm, 3*dy/norm
            s.open = 75
            reflected.add(b.ident)
            events.append(('reflect', b.ident, b.source))
    for b, x, y, nx, ny in moved:
        if b.ident in reflected:
            alive.append(b)
            continue  # 今tickで向きが変わった弾を二度動かさない。
        if b.owner == 'enemy':
            if touches(x-oldx, y-100, nx-s.x, ny-100, 0, 0, 8):
                if s.invuln == 0:
                    player_hit = True
                events.append(('enemy_contact', b.ident))
                continue
        else:
            if b.owner == 'player' and s.open == 0 and y >= 32 and ny <= 32:
                events.append(('blocked', b.ident))
                continue
            if touches(x, y, nx, ny, 80, 18, 15 if b.owner == 'reflected' else 14):
                core_damage += 1
                events.append(('core_hit', b.ident, b.owner))
                continue
        b.x, b.y = round(nx, 6), round(ny, 6)
        if -8 <= b.x <= 168 and -8 <= b.y <= 116:
            alive.append(b)
        else:
            events.append(('missed', b.ident, b.owner))
    s.bullets = alive
    s.core_hp = max(0, s.core_hp-core_damage)
    if player_hit:
        s.hp -= 1
        s.invuln = 30
        events.append(('hurt',))
    if s.hp <= 0:
        s.status = 'lost'
        events.append(('lost',))
    elif s.core_hp <= 0:
        s.status = 'won'
        events.append(('won',))
    s.tick += 1
    s.events = tuple(events)
    return s


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

STAGE = 4
STAGE_TITLE = 'AUTHENTICATE THE BARRIER'

CONTROLS = ('LEFT', 'RIGHT', 'Z', 'X', 'R')

class Controller:
    def __init__(self):
        self.state = initial()
        self.gate = False
        self.reason = 'ready'
        self.last_events = ()

    def reset(self):
        self.state = initial()
        self.gate = True
        self.last_events = ()

    def tick(self, pressed=(), held=()):
        pressed = set(pressed) & set(CONTROLS)
        held = set(held) & set(CONTROLS)
        # 停止中/全解放待ちでもXを離した事実は覚える。原作と同じ順序。
        if 'X' not in held:
            self.state.xheld = False
        if self.gate:
            if not held:
                self.gate = False
            return  # 全解放フレーム自体も世界を進めない。
        if 'R' in pressed:
            self.reset()
            self.reason = 'reset'
            return
        if self.state.status != 'playing':
            return
        # 左右同時は0。Zは保持で連射し、Xの新押下はモデル側で判断する。
        inp = Input(int('RIGHT' in held)-int('LEFT' in held), 'Z' in held, 'X' in held)
        self.state = step(LEVEL, self.state, inp)
        self.reason = 'play'
        if self.state.events:
            self.last_events = self.state.events

    def snapshot(self):
        result = {'state': asdict(self.state), 'hash': state_hash(self.state),
                  'gate': self.gate, 'reason': self.reason, 'last_events': self.last_events}
        return result


def draw_board(pyxel, controller):
    s = controller.state
    pyxel.cls(1)
    pyxel.text(12, 10, f'RETURN BULLETS 036 / STEP {STAGE}', 10)
    pyxel.text(12, 24, STAGE_TITLE, 7)
    pyxel.rect(20, 44, 320, 236, 0)
    pyxel.rectb(20, 44, 320, 236, 5)
    def xy(x, y):
        return 20+x*2, 44+y*2
    for lane in (40, 80, 120):
        x, _ = xy(lane, 0)
        for y in range(84, 276, 12):
            pyxel.line(x, y, x, y+3, 1)
    if STAGE >= 2:
        for intent in s.memory.committed:
            x, _ = xy(intent.lane, 0)
            for y in range(110, 269, 10):
                pyxel.line(x, y, x, y+4, 9)
            pyxel.text(x+6, 146, 'FIRE IN '+str(max(0, intent.fire_tick-s.tick+1)), 9)
    cx, cy = xy(80, 18)
    pyxel.circ(cx, cy, 22, 2)
    pyxel.rect(cx-12, cy-12, 24, 24, 8 if STAGE>=4 else 5)
    pyxel.text(cx-8, cy-2, 'CORE', 7)
    if STAGE>=4:
        for j in range(6):
            pyxel.rect(138+j*14, 49, 10, 4, 8 if j<s.core_hp else 1)
        _, wy = xy(0, 32)
        pyxel.line(28, wy, 332, wy, 11 if s.open else 12)
        if not s.open:
            for x in range(28, 330, 10):
                pyxel.line(x, wy-3, x+4, wy+3, 12)
    pyxel.clip(21,45,318,234)
    for b in s.bullets:
        x,y = xy(b.x,b.y)
        if b.owner=='enemy':
            pyxel.circ(x,y,5,8)
            pyxel.circb(x,y,5,10)
        elif b.owner=='player':
            pyxel.tri(x,y-5,x-3,y+3,x+3,y+3,7)
        else:
            pyxel.tri(x,y-7,x-6,y,x,y+7,11)
            pyxel.tri(x,y-7,x+6,y,x,y+7,11)
    x,y = xy(s.x,100)
    if STAGE>=3 and s.ring:
        pyxel.circb(x,y,34,11)  # 原作の可視半径17。衝突判定半径は20のまま。
    if not getattr(s,'invuln',0) or s.tick%4<2:
        pyxel.tri(x,y-10,x-9,y+8,x+9,y+8,7)
        pyxel.rect(x-3,y-4,6,8,12)
    pyxel.clip()
    lines=[('WORLD TICK',str(s.tick),7),('STATUS',s.status.upper(),10),
           ('X POSITION',f'{s.x:.1f}',6),('SHOT WAIT',str(s.shot_cooldown),7)]
    if STAGE>=2: lines += [('PLAYER HP',str(s.hp)+'/3',8),('INVULNERABLE',str(s.invuln),6)]
    if STAGE>=3: lines += [('CHARGE',str(s.charge)+'/3',11),('RING ACTIVE',str(s.ring),11),
                           ('REUSE WAIT',str(s.cooldown),6),('RECHARGE IN',str(90-s.tick%90),6)]
    if STAGE>=4: lines += [('BARRIER',('OPEN '+str(s.open)) if s.open else 'CLOSED',11 if s.open else 12),('CORE HP',str(s.core_hp)+'/6',8)]
    for i,(label,value,color) in enumerate(lines):
        pyxel.text(356,48+i*17,label,13)
        pyxel.text(446,48+i*17,value,color)
    pyxel.text(12,292,'WHITE: PLAYER SHOT   RED: ENEMY   GREEN: AUTHENTICATED RETURN',7)
    if STAGE==1: message='LAB: movement + shots only. No damage, wall, enemies, or ending.'
    elif STAGE==2: message='LAB: telegraphs + enemy contact. No reflection, wall, or victory.'
    elif STAGE==3: message='LAB: reflection only. Returned bullets cannot damage the core yet.'
    else: message='GOAL: destroy core AND survive. Simultaneous zero HP is a loss.'
    pyxel.text(12,308,message,6)
    controls='LEFT/RIGHT: move  Z: hold fire  R: reset  ESC: quit'
    if STAGE>=3: controls='ARROWS: move  Z: hold fire  X: press reflect  R: reset  ESC: quit'
    pyxel.text(12,327,controls,7)
    if STAGE==5:
        pyxel.text(12,342,'SPACE: start  P: pause  M: title / after changes: release ALL keys',10)
    else:
        pyxel.text(12,342,'After reset: release ALL control keys. Fixed 30 ticks/second.',13)
    if s.status!='playing':
        pyxel.rect(62,160,236,44,1)
        pyxel.rectb(62,160,236,44,10)
        pyxel.text(92,172,'CORE DESTROYED / YOU SURVIVED' if s.status=='won' else 'PLAYER LOST / PRESS R',10)
        pyxel.text(92,188,'R: reset the same original case A',7)
    if getattr(controller,'title',False) or getattr(controller,'pause',False):
        pyxel.rect(38,122,284,94,1)
        pyxel.rectb(38,122,284,94,11)
        pyxel.text(52,134,'RETURN BULLETS' if controller.title else 'PAUSED: ALL GAME CLOCKS STOP',10)
        pyxel.text(52,153,'Return one red bullet to open the wall.',7)
        pyxel.text(52,170,'X lasts 12 ticks. Z fires while held.',7)
        pyxel.text(52,191,'SPACE: start' if controller.title else 'P: resume / release all keys after',11)


def demo_keys(controller, frame):
    """公開状態だけを見る技術QA用入力。人間の試遊ではない。"""
    if STAGE==5:
        if frame==1: return ('SPACE',)
        if frame==2: return ()
    s=controller.state
    if STAGE==1: return ('Z','RIGHT') if s.tick<10 else ('Z',)
    if STAGE==2: return ()
    keys=['Z']
    if any(b.owner=='enemy' and 73<=b.y<101 and abs(b.x-s.x)<21
           for b in s.bullets) and s.charge>0 and s.cooldown<=1 and not s.xheld:
        keys.append('X')
    return tuple(keys)


def main(headless=False, frames=180, demo=False, replay=None, screenshot=None, report=None):
    from pathlib import Path
    import pyxel  # 通常テスト/インポートにPyxelや画面を要求しない。
    controller=Controller()
    codes={name:getattr(pyxel,'KEY_'+name) for name in CONTROLS}
    frame=0
    old_held=set()
    changes=[]
    screenshot=str(Path(screenshot).resolve()) if screenshot else None
    report=str(Path(report).resolve()) if report else None
    def update():
        nonlocal frame,old_held
        frame+=1
        if headless:
            held=set(replay[frame-1]) if replay is not None and frame<=len(replay) else set(demo_keys(controller,frame) if demo else ())
            pressed=held-old_held
            old_held=held
        else:
            held={name for name,code in codes.items() if pyxel.btn(code)}
            pressed={name for name,code in codes.items() if pyxel.btnp(code)}
        before=controller.snapshot()
        controller.tick(pressed,held)
        if headless and (controller.state.events or before['reason']!=controller.reason):
            changes.append({'frame':frame,'pressed':sorted(pressed),'held':sorted(held),'snapshot':controller.snapshot()})
    def draw():
        draw_board(pyxel,controller)
        if headless and frame>=frames:
            if screenshot: pyxel.screenshot(screenshot,scale=1)
            if report:
                Path(report).write_text(json.dumps({'label':'Actual Pyxel headless; synthetic controller inputs; silent; not human play',
                    'stage':STAGE,'frames':frame,'demo':demo,'changes':changes,
                    'snapshot':controller.snapshot()},ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            pyxel.quit()
    pyxel.init(520,360,title=f'Return Bullets / Step {STAGE}',fps=30,headless=headless)
    pyxel.run(update,draw)


if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless',action='store_true')
    parser.add_argument('--frames',type=int,default=180)
    parser.add_argument('--demo',action='store_true')
    parser.add_argument('--replay-json',help='UTF-8 JSON list of held-key lists, one per frame')
    parser.add_argument('--screenshot',help='PNG output; headless only, relative to current directory')
    parser.add_argument('--report',help='UTF-8 JSON output; headless only')
    args=parser.parse_args()
    if args.frames<12: parser.error('Use at least 12 frames')
    if not args.headless and (args.demo or args.replay_json or args.screenshot or args.report):
        parser.error('QA input and output options require --headless')
    if args.demo and args.replay_json: parser.error('Choose demo or replay, not both')
    replay=None
    if args.replay_json:
        try: replay=json.loads(Path(args.replay_json).read_text(encoding='utf-8'))
        except (OSError,ValueError) as error: parser.error(str(error))
        if not isinstance(replay,list) or any(not isinstance(keys,list) or any(not isinstance(k,str) or k not in CONTROLS for k in keys) for keys in replay):
            parser.error('Replay must be a list of lists of supported key names')
    main(args.headless,args.frames,args.demo,replay,args.screenshot,args.report)
STEP 05 / 05

停止と再開をゲーム全体で揃える

入力の優先順位・全部離すまで待つ・状態を止める。

元のゲームの表紙、Space で開始、M で表紙、R でリセット、P で一時停止を元どおりにします。操作の優先順位は「M → R → P → 一時停止中 → 勝ち負けで終了 → ふつうの更新」です。場面が変わったあとは、全部のキーを離すまで待ち、最後の1つを離したフレームでも、ゲームは進めません。一時停止中でも、X を離したことだけは、最初に記録します。

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

05_controller.py / 注目する部分
        if self.gate:
            if not held:
                self.gate = False
            return  # 全解放フレーム自体も世界を進めない。

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_controller.py
macOS / Linux
.venv/bin/python 05_controller.py
画面で見ることSpace で始めて、Space を押したまま X を足しても、はね返しません。全部のキーを離したあとの X は有効です。P で、弾・次に使えるまでの時間・輪・回復・予告の時計が全部止まります。R と P を同時に押すとリセット、M と R と P を同時に押すと表紙に戻ります。
  • 押し続けと押した瞬間を、Controller から別々に渡せる
  • M・R・P を同時に押したときの優先順位を説明できる
  • 一時停止中は描くだけで、step を呼ばない
  • 元のゲームと比べた範囲と、省いた操作を説明できる
つまずきやすいところ描くのだけ止めて step を呼び続けると、見えない間に弾が当たってしまいます。P を押した直後の、押し続けている Z をそのまま通すと、思っていない射撃が再開してしまいます。全部離すまで待つ処理で、X を離したかの確認まで省くと、「押した瞬間」を取りこぼしてしまいます。
少し変えてみよう

Space → Space+X → X だけ → 全部離す → X の順に入力し、最後だけはね返しの力が減ることを確かめましょう。P で止めて90フレーム待ち、再開する前後で、ゲームの tick が変わっていないことも確かめます。

05_controller.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(445行)
05_controller.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.
# 教材画面は標準ASCIIと図形のみ。字形データは同梱しません。

"""返せる弾丸・教材5/5。日本語説明はLESSON.md。"""
from dataclasses import dataclass, field, asdict
from copy import deepcopy
import math
import json
import hashlib

@dataclass(frozen=True)
class Level:
    name: str
    start: float
    speed: float
    schedule: tuple
    ring_ticks: int = 12

# 原作A。一周180tick、中央へ30/100tickで発射。数値は変えない。
LEVEL = Level('一発を返す', 80.0, 1.4, ((30, 80), (100, 80)))

@dataclass
class Bullet:
    ident: int
    owner: str
    x: float
    y: float
    vx: float
    vy: float
    source: str = 'core'

@dataclass(frozen=True)
class Input:
    dx: int = 0
    z: bool = False
    x: bool = False

@dataclass(frozen=True)
class Observation:
    player_x: float
    advertised_lane: float | None
    fire_tick: int
    ident: int

@dataclass(frozen=True)
class Intent:
    lane: float
    fire_tick: int
    ident: int
    aimed: bool

@dataclass
class Memory:
    committed: list = field(default_factory=list)


def policy(obs, memory):
    # Aは固定レーン。Noneを使う原作Cも同じ関数で「予告時だけ」狙う。
    lane = obs.player_x if obs.advertised_lane is None else obs.advertised_lane
    return Intent(lane, obs.fire_tick, obs.ident, obs.advertised_lane is None)


def segment_distance(x1, y1, x2, y2, cx, cy):
    dx, dy = x2-x1, y2-y1
    length2 = dx*dx + dy*dy
    t = 0 if length2 == 0 else max(0, min(1, ((cx-x1)*dx+(cy-y1)*dy)/length2))
    return math.hypot(x1+t*dx-cx, y1+t*dy-cy)


def touches(x1, y1, x2, y2, cx, cy, radius):
    return segment_distance(x1, y1, x2, y2, cx, cy) <= radius

@dataclass
class WorldState:
    tick: int = 0
    x: float = 80.0
    hp: int = 3
    core_hp: int = 6
    invuln: int = 0
    charge: int = 3
    ring: int = 0
    cooldown: int = 0
    open: int = 0
    shot_cooldown: int = 0
    xheld: bool = False
    memory: Memory = field(default_factory=Memory)
    bullets: list = field(default_factory=list)
    next_id: int = 1
    status: str = 'playing'
    events: tuple = ()


def initial(level=LEVEL):
    return WorldState(x=level.start)


def step(level, old, inp=Input()):
    if old.status != 'playing':
        return deepcopy(old)
    s = deepcopy(old)  # 前の状態を残す。テストでも比較しやすい。
    events = []
    oldx = s.x
    for name in ('invuln', 'ring', 'cooldown', 'open', 'shot_cooldown'):
        setattr(s, name, max(0, getattr(s, name)-1))
    # 使用時刻と独立。tick=90,180,... の更新開始時に回復する。
    if s.tick and s.tick % 90 == 0:
        s.charge = min(3, s.charge+1)
        events.append(('recharge', s.charge))
    cycle = s.tick // 180
    for k, (fire, lane) in enumerate(level.schedule):
        if s.tick % 180 == fire-30:
            observation = Observation(old.x, lane, cycle*180+fire, cycle*len(level.schedule)+k)
            intent = policy(observation, s.memory)
            s.memory.committed.append(intent)
            events.append(('warning', intent.ident, intent.lane, intent.fire_tick, intent.aimed))
    for intent in s.memory.committed:
        if intent.fire_tick == s.tick:
            s.bullets.append(Bullet(s.next_id, 'enemy', intent.lane, 18.0, 0.0, level.speed))
            s.next_id += 1
            events.append(('fire', intent.ident, intent.lane, s.next_id-1))
    s.memory.committed = [intent for intent in s.memory.committed if intent.fire_tick >= s.tick]
    s.x = max(16, min(144, s.x + max(-1, min(1, inp.dx))*1.8))
    if inp.x and not s.xheld and s.charge > 0 and s.cooldown == 0:
        s.ring = level.ring_ticks
        s.cooldown = 36
        s.charge -= 1
        events.append(('ring', s.charge))
    s.xheld = inp.x  # 押し続けても新しい押下ではない。
    if inp.z and s.shot_cooldown == 0:
        s.bullets.append(Bullet(s.next_id, 'player', s.x, 93.0, 0.0, -3.0, 'player'))
        s.next_id += 1
        s.shot_cooldown = 24
        events.append(('shot', s.next_id-1))
    alive = []
    core_damage = 0
    player_hit = False
    reflected = set()
    moved = [(b, b.x, b.y, b.x+b.vx, b.y+b.vy) for b in s.bullets]
    # 全弾の反射を先に決める。リストの順で防壁判定を変えない。
    for b, x, y, nx, ny in moved:
        if b.owner == 'enemy' and s.ring > 0 and touches(x-oldx, y-100, nx-s.x, ny-100, 0, 0, 20):
            b.owner = 'reflected'
            b.x, b.y = nx, ny
            dx, dy = 80-nx, 18-ny
            norm = math.hypot(dx, dy)
            b.vx, b.vy = 3*dx/norm, 3*dy/norm
            s.open = 75
            reflected.add(b.ident)
            events.append(('reflect', b.ident, b.source))
    for b, x, y, nx, ny in moved:
        if b.ident in reflected:
            alive.append(b)
            continue  # 今tickで向きが変わった弾を二度動かさない。
        if b.owner == 'enemy':
            if touches(x-oldx, y-100, nx-s.x, ny-100, 0, 0, 8):
                if s.invuln == 0:
                    player_hit = True
                events.append(('enemy_contact', b.ident))
                continue
        else:
            if b.owner == 'player' and s.open == 0 and y >= 32 and ny <= 32:
                events.append(('blocked', b.ident))
                continue
            if touches(x, y, nx, ny, 80, 18, 15 if b.owner == 'reflected' else 14):
                core_damage += 1
                events.append(('core_hit', b.ident, b.owner))
                continue
        b.x, b.y = round(nx, 6), round(ny, 6)
        if -8 <= b.x <= 168 and -8 <= b.y <= 116:
            alive.append(b)
        else:
            events.append(('missed', b.ident, b.owner))
    s.bullets = alive
    s.core_hp = max(0, s.core_hp-core_damage)
    if player_hit:
        s.hp -= 1
        s.invuln = 30
        events.append(('hurt',))
    if s.hp <= 0:
        s.status = 'lost'
        events.append(('lost',))
    elif s.core_hp <= 0:
        s.status = 'won'
        events.append(('won',))
    s.tick += 1
    s.events = tuple(events)
    return s


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

STAGE = 5
STAGE_TITLE = 'PAUSE THE WHOLE WORLD'

CONTROLS = ('LEFT', 'RIGHT', 'Z', 'X', 'SPACE', 'P', 'R', 'M')

class Controller:
    def __init__(self):
        self.state = initial()
        self.gate = False
        self.reason = 'ready'
        self.last_events = ()
        self.title = True
        self.pause = False

    def reset(self):
        self.state = initial()
        self.gate = True
        self.last_events = ()
        self.pause = False

    def tick(self, pressed=(), held=()):
        pressed = set(pressed) & set(CONTROLS)
        held = set(held) & set(CONTROLS)
        # 停止中/全解放待ちでもXを離した事実は覚える。原作と同じ順序。
        if 'X' not in held:
            self.state.xheld = False
        if self.gate:
            if not held:
                self.gate = False
            return  # 全解放フレーム自体も世界を進めない。
        if self.title:
            if 'SPACE' in pressed:
                self.title = False
                self.reset()
                self.reason = 'start'
            return
        # 原作の残した操作の優先順: M > R > P > pause > terminal > gameplay。
        if 'M' in pressed:
            self.title = True
            self.pause = False
            self.gate = True
            self.reason = 'menu'
            return
        if 'R' in pressed:
            self.reset()
            self.reason = 'reset'
            return
        if 'P' in pressed:
            self.pause = not self.pause
            self.gate = True
            self.reason = 'pause' if self.pause else 'resume'
            return
        if self.pause:
            return
        if self.state.status != 'playing':
            return
        # 左右同時は0。Zは保持で連射し、Xの新押下はモデル側で判断する。
        inp = Input(int('RIGHT' in held)-int('LEFT' in held), 'Z' in held, 'X' in held)
        self.state = step(LEVEL, self.state, inp)
        self.reason = 'play'
        if self.state.events:
            self.last_events = self.state.events

    def snapshot(self):
        result = {'state': asdict(self.state), 'hash': state_hash(self.state),
                  'gate': self.gate, 'reason': self.reason, 'last_events': self.last_events}
        result.update(title=self.title, pause=self.pause)
        return result


def draw_board(pyxel, controller):
    s = controller.state
    pyxel.cls(1)
    pyxel.text(12, 10, f'RETURN BULLETS 036 / STEP {STAGE}', 10)
    pyxel.text(12, 24, STAGE_TITLE, 7)
    pyxel.rect(20, 44, 320, 236, 0)
    pyxel.rectb(20, 44, 320, 236, 5)
    def xy(x, y):
        return 20+x*2, 44+y*2
    for lane in (40, 80, 120):
        x, _ = xy(lane, 0)
        for y in range(84, 276, 12):
            pyxel.line(x, y, x, y+3, 1)
    if STAGE >= 2:
        for intent in s.memory.committed:
            x, _ = xy(intent.lane, 0)
            for y in range(110, 269, 10):
                pyxel.line(x, y, x, y+4, 9)
            pyxel.text(x+6, 146, 'FIRE IN '+str(max(0, intent.fire_tick-s.tick+1)), 9)
    cx, cy = xy(80, 18)
    pyxel.circ(cx, cy, 22, 2)
    pyxel.rect(cx-12, cy-12, 24, 24, 8 if STAGE>=4 else 5)
    pyxel.text(cx-8, cy-2, 'CORE', 7)
    if STAGE>=4:
        for j in range(6):
            pyxel.rect(138+j*14, 49, 10, 4, 8 if j<s.core_hp else 1)
        _, wy = xy(0, 32)
        pyxel.line(28, wy, 332, wy, 11 if s.open else 12)
        if not s.open:
            for x in range(28, 330, 10):
                pyxel.line(x, wy-3, x+4, wy+3, 12)
    pyxel.clip(21,45,318,234)
    for b in s.bullets:
        x,y = xy(b.x,b.y)
        if b.owner=='enemy':
            pyxel.circ(x,y,5,8)
            pyxel.circb(x,y,5,10)
        elif b.owner=='player':
            pyxel.tri(x,y-5,x-3,y+3,x+3,y+3,7)
        else:
            pyxel.tri(x,y-7,x-6,y,x,y+7,11)
            pyxel.tri(x,y-7,x+6,y,x,y+7,11)
    x,y = xy(s.x,100)
    if STAGE>=3 and s.ring:
        pyxel.circb(x,y,34,11)  # 原作の可視半径17。衝突判定半径は20のまま。
    if not getattr(s,'invuln',0) or s.tick%4<2:
        pyxel.tri(x,y-10,x-9,y+8,x+9,y+8,7)
        pyxel.rect(x-3,y-4,6,8,12)
    pyxel.clip()
    lines=[('WORLD TICK',str(s.tick),7),('STATUS',s.status.upper(),10),
           ('X POSITION',f'{s.x:.1f}',6),('SHOT WAIT',str(s.shot_cooldown),7)]
    if STAGE>=2: lines += [('PLAYER HP',str(s.hp)+'/3',8),('INVULNERABLE',str(s.invuln),6)]
    if STAGE>=3: lines += [('CHARGE',str(s.charge)+'/3',11),('RING ACTIVE',str(s.ring),11),
                           ('REUSE WAIT',str(s.cooldown),6),('RECHARGE IN',str(90-s.tick%90),6)]
    if STAGE>=4: lines += [('BARRIER',('OPEN '+str(s.open)) if s.open else 'CLOSED',11 if s.open else 12),('CORE HP',str(s.core_hp)+'/6',8)]
    for i,(label,value,color) in enumerate(lines):
        pyxel.text(356,48+i*17,label,13)
        pyxel.text(446,48+i*17,value,color)
    pyxel.text(12,292,'WHITE: PLAYER SHOT   RED: ENEMY   GREEN: AUTHENTICATED RETURN',7)
    if STAGE==1: message='LAB: movement + shots only. No damage, wall, enemies, or ending.'
    elif STAGE==2: message='LAB: telegraphs + enemy contact. No reflection, wall, or victory.'
    elif STAGE==3: message='LAB: reflection only. Returned bullets cannot damage the core yet.'
    else: message='GOAL: destroy core AND survive. Simultaneous zero HP is a loss.'
    pyxel.text(12,308,message,6)
    controls='LEFT/RIGHT: move  Z: hold fire  R: reset  ESC: quit'
    if STAGE>=3: controls='ARROWS: move  Z: hold fire  X: press reflect  R: reset  ESC: quit'
    pyxel.text(12,327,controls,7)
    if STAGE==5:
        pyxel.text(12,342,'SPACE: start  P: pause  M: title / after changes: release ALL keys',10)
    else:
        pyxel.text(12,342,'After reset: release ALL control keys. Fixed 30 ticks/second.',13)
    if s.status!='playing':
        pyxel.rect(62,160,236,44,1)
        pyxel.rectb(62,160,236,44,10)
        pyxel.text(92,172,'CORE DESTROYED / YOU SURVIVED' if s.status=='won' else 'PLAYER LOST / PRESS R',10)
        pyxel.text(92,188,'R: reset the same original case A',7)
    if getattr(controller,'title',False) or getattr(controller,'pause',False):
        pyxel.rect(38,122,284,94,1)
        pyxel.rectb(38,122,284,94,11)
        pyxel.text(52,134,'RETURN BULLETS' if controller.title else 'PAUSED: ALL GAME CLOCKS STOP',10)
        pyxel.text(52,153,'Return one red bullet to open the wall.',7)
        pyxel.text(52,170,'X lasts 12 ticks. Z fires while held.',7)
        pyxel.text(52,191,'SPACE: start' if controller.title else 'P: resume / release all keys after',11)


def demo_keys(controller, frame):
    """公開状態だけを見る技術QA用入力。人間の試遊ではない。"""
    if STAGE==5:
        if frame==1: return ('SPACE',)
        if frame==2: return ()
    s=controller.state
    if STAGE==1: return ('Z','RIGHT') if s.tick<10 else ('Z',)
    if STAGE==2: return ()
    keys=['Z']
    if any(b.owner=='enemy' and 73<=b.y<101 and abs(b.x-s.x)<21
           for b in s.bullets) and s.charge>0 and s.cooldown<=1 and not s.xheld:
        keys.append('X')
    return tuple(keys)


def main(headless=False, frames=180, demo=False, replay=None, screenshot=None, report=None):
    from pathlib import Path
    import pyxel  # 通常テスト/インポートにPyxelや画面を要求しない。
    controller=Controller()
    codes={name:getattr(pyxel,'KEY_'+name) for name in CONTROLS}
    frame=0
    old_held=set()
    changes=[]
    screenshot=str(Path(screenshot).resolve()) if screenshot else None
    report=str(Path(report).resolve()) if report else None
    def update():
        nonlocal frame,old_held
        frame+=1
        if headless:
            held=set(replay[frame-1]) if replay is not None and frame<=len(replay) else set(demo_keys(controller,frame) if demo else ())
            pressed=held-old_held
            old_held=held
        else:
            held={name for name,code in codes.items() if pyxel.btn(code)}
            pressed={name for name,code in codes.items() if pyxel.btnp(code)}
        before=controller.snapshot()
        controller.tick(pressed,held)
        if headless and (controller.state.events or before['reason']!=controller.reason):
            changes.append({'frame':frame,'pressed':sorted(pressed),'held':sorted(held),'snapshot':controller.snapshot()})
    def draw():
        draw_board(pyxel,controller)
        if headless and frame>=frames:
            if screenshot: pyxel.screenshot(screenshot,scale=1)
            if report:
                Path(report).write_text(json.dumps({'label':'Actual Pyxel headless; synthetic controller inputs; silent; not human play',
                    'stage':STAGE,'frames':frame,'demo':demo,'changes':changes,
                    'snapshot':controller.snapshot()},ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            pyxel.quit()
    pyxel.init(520,360,title=f'Return Bullets / Step {STAGE}',fps=30,headless=headless)
    pyxel.run(update,draw)


if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless',action='store_true')
    parser.add_argument('--frames',type=int,default=180)
    parser.add_argument('--demo',action='store_true')
    parser.add_argument('--replay-json',help='UTF-8 JSON list of held-key lists, one per frame')
    parser.add_argument('--screenshot',help='PNG output; headless only, relative to current directory')
    parser.add_argument('--report',help='UTF-8 JSON output; headless only')
    args=parser.parse_args()
    if args.frames<12: parser.error('Use at least 12 frames')
    if not args.headless and (args.demo or args.replay_json or args.screenshot or args.report):
        parser.error('QA input and output options require --headless')
    if args.demo and args.replay_json: parser.error('Choose demo or replay, not both')
    replay=None
    if args.replay_json:
        try: replay=json.loads(Path(args.replay_json).read_text(encoding='utf-8'))
        except (OSError,ValueError) as error: parser.error(str(error))
        if not isinstance(replay,list) or any(not isinstance(keys,list) or any(not isinstance(k,str) or k not in CONTROLS for k in keys) for keys in replay):
            parser.error('Replay must be a list of lists of supported key names')
    main(args.headless,args.frames,args.demo,replay,args.screenshot,args.report)
AFTER STEP 05

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

元のゲームの状況 A(LEVELS[0])です。x 80 から始まり、敵の弾の速さは1.4、180 tick ごとの tick 30 と 100 にまん中の弾を撃ちます。予告は30 tick です。プレイヤーの HP 3、コアの HP 6。はね返しの輪12、次に使えるまで36、回復90、壁が開く時間75、連射の間かく24 tick です。コアをこわして、自分が生きていれば勝ちです。同時に HP 0 なら負けです。

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

比べる点5ステップの教材元の完成版
段階と範囲01 は移動とふつうの弾、02 は予告・敵・当たる、03 ははね返し、04 で元のゲームの状況 A の中身が完成、05 は表紙と一時停止です。A・B・C の3つの敵の並び、C の「予告したときの位置をねらう弾」、比較用の6 tick の輪があります。教材のふつうのプレイは A だけです。
入力05 は「M > R > P > 一時停止 > 終了 > ふつうの更新」の優先順位を守っています。1/2/3 での並びの選択、終わったあとの N、F12、比較モードでの起動は省いています。01〜04 はすぐに始まり、P/M/Space はありません。並びの選択(1/2/3)は、表紙より先に処理します。終わったあとの N で次の並びへ。F12 で画像を保存します。
反射の見た目と判定元のゲームのとおり、見える半径17と判定の半径20を守り、本文と境目のテストで区別しています。README の「半径17」は見た目のことです。model.py のはね返しの判定は20、app.py の描画は17です。
表示と素材文字(ASCII)と図形、数字のパネルです。元の MIT と、参照している Noto の権利の文書をそのまま入れていますが、文字データは入れていません。日本語の文字、元のゲームの配置、録画や比較の画面があります。
検証新しく、実際の Pyxel で画面を出さないテストと、作った入力による操作のテスト、状況 A の元のゲームの保存データとの比較をしています。元のゲームの動画や実験の結果は、教材の新しい確認には数えていません。人が遊んだ感想・OS のキー入力・音・ブラウザー・Windows/macOS の画面は確かめていません。

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

  1. model.py: policy / Observation / Intent、原作Cの固定された狙い
  2. model.py: stepの二段階衝突と相対線分距離
  3. app.py: 1/2/3、N、ReleaseGate、baseline切替
  4. solver.py: selective / all_return / empty_then_wait と比較
自分のゲームに変えるなら

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

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