完成サンプルをブラウザーで遊ぶ
この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。
完成版の主な操作:左右移動、Z保持射撃、X押下反射輪、P停止、R再開、1〜3編隊、M表紙
ボタンを押すと、ゲームの読み込みが始まります。
画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。
うまく表示されないとき
ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。
見た目の弾と、「だれの弾か」という状態を分ける。
いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、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終了
- 難易度の目安
- 中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
- 学習順に迷ったら
- おすすめの学ぶ順番を見る
コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。
教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。
保持入力と弾の移動を分ける
毎 tick の更新・弾の持ち主・次に撃てるまでの待ち時間。
左右は、押している間、毎 tick 1.8 ずつ動きます。Z も押し続けを読みますが、shot_cooldown(撃てるまでの待ち時間)が0のときだけ1発作ります。弾は、位置・速さ・ident(番号)・owner(持ち主)を持ち、入力とは関係なく毎 tick 進みます。この段階では、敵の弾・当たり判定・壁・勝ち負けはまだありません。
下のコードは 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を展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 01_motion.py.venv/bin/python 01_motion.py- 「押し続け」と「押した瞬間」のちがいを説明できる
- 左右同時は打ち消し合い、Z とは一緒に使える
- 弾は、撃ったあとも自分の速さで進む
- この段階には勝ち負けがないと分かる
btnp(押した瞬間)だけで読むと、押し続けても1発で止まってしまいます。反対に、待ち時間を作らず btn だけで撃つと、毎 tick 1発になってしまいます。弾のリストを順番に見ている途中で remove すると、次の弾を読み飛ばしてしまいます。shot_cooldown の24を12に変える前に、50 tick の間に撃つ時刻を予想してみましょう。答えは 0、12、24、36、48 です。試したら24に戻し、元の3発になることをもう一度確かめます。
このステップのコード全文(309行)
# 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)予告を確定し、30tick後に発射する
見る・ねらいを決める・予約する・線分で当たりを調べる。
元のゲームの状況 A の、まん中に飛んでくる弾を加えます。tick 0 で「tick 30 に撃つ」、tick 70 で「tick 100 に撃つ」と予定を覚え、180 tick ごとにくり返します。黄色い線は予定の表示で、弾でも攻撃でもありません。撃たれると赤い弾になり、プレイヤーに線分で当たると HP が減ります。
下のコードは 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を展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 02_telegraph.py.venv/bin/python 02_telegraph.py- 予告中と、本物の弾が飛んでいる間を区別できる
- 予告で保存した
lane(通り道)を、撃つときにも使う - 「更新を始める時刻 tick 30」と「30回更新したあと」のちがいを説明できる
- 1回の更新で何回ふれても、HP は1だけ減る
最初の予告が出てから右へ動き、赤い弾がまん中の x 80 に出ることを確かめましょう。発展として、schedule を一時的に ((30,None),) に変えて、予告したときの x だけをねらうことを確かめます。元のゲームの状況 A は決まった通り道なので、終わったら元に戻します。
このステップのコード全文(374行)
# 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)12tickの窓で弾の所有者を変える
押した瞬間・はね返しの時間・別々の時計・全部を先に処理する。
X を新しく押したときだけ、はね返しの輪を12 tick 出し、はね返しの力を1使います。輪が出ている時間、次に使えるまでの36 tick、90 tick ごとの回復は、別々の時計です。輪にふれた敵の弾は、同じ ident と source(撃った元)のまま、owner だけを reflected(返した)に変え、コアの方向へ速さ3で戻します。コアへのダメージと壁は、まだありません。
下のコードは 03_reflection.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
b.owner = 'reflected'
b.x, b.y = nx, ny
dx, dy = 80-nx, 18-ny実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 03_reflection.py.venv/bin/python 03_reflection.py- 同じ弾の番号と撃った元のまま、持ち主だけを変えられる
- 12・36・90 の時計をまぜない
- X を早く押しても予約にはならないと分かる
- 見える半径17と、判定の半径20を区別できる
- 当たったかより先に、はね返しを判定する理由を説明できる
X を50 tick 押し続けると、はね返しの力はいくつになるか予想してから確かめましょう(最初の3から2)。次に X を離して、もう一度押します。判定の境目は、中心の差が20ならはね返し、20.000001 ならはね返さないことを、test_19 で確かめてください。
このステップのコード全文(405行)
# 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)認証された弾と通常のダメージを分ける
持ち主による通行・2段階の当たり判定・勝ち負けの優先。
ここで、元のゲームの状況 A の中身が完成します。はね返しが起きると、壁の open を75にします。閉じた壁では、ふつうの弾だけを消し、返した弾は、壁が閉じても通します。どちらもコアに当たれば1ダメージで、「返した弾は強い」という別の意味にはしません。全部の弾のはね返しを先に処理するので、同じ tick のふつうの弾にも、開いた壁が使われます。
下のコードは 04_barrier.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if b.owner == 'player' and s.open == 0 and y >= 32 and ny <= 32:
events.append(('blocked', b.ident))
continue実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 04_barrier.py.venv/bin/python 04_barrier.py- ふつうの弾も返した弾も、同じ1ダメージだと分かる
- 「壁を通れる資格」と「HP を減らす」を別々に判定する
- リストの順番で、同じ tick の結果が変わらない
- 同時に HP 0 なら負けだと説明できる
open>0 を求めると、飛んでいる間に壁が閉じたとき、通れる資格が取り消されてしまいます。test_26 のふつうの弾と敵の弾の順番を逆にしても、core_hp が5になることを確かめましょう。次に、test_24 で open=0 なのに返した弾が1ダメージになる理由を、owner と source のちがいもふくめて説明してください。
このステップのコード全文(421行)
# 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)停止と再開をゲーム全体で揃える
入力の優先順位・全部離すまで待つ・状態を止める。
元のゲームの表紙、Space で開始、M で表紙、R でリセット、P で一時停止を元どおりにします。操作の優先順位は「M → R → P → 一時停止中 → 勝ち負けで終了 → ふつうの更新」です。場面が変わったあとは、全部のキーを離すまで待ち、最後の1つを離したフレームでも、ゲームは進めません。一時停止中でも、X を離したことだけは、最初に記録します。
下のコードは 05_controller.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if self.gate:
if not held:
self.gate = False
return # 全解放フレーム自体も世界を進めない。実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 05_controller.py.venv/bin/python 05_controller.py- 押し続けと押した瞬間を、
Controllerから別々に渡せる - M・R・P を同時に押したときの優先順位を説明できる
- 一時停止中は描くだけで、
stepを呼ばない - 元のゲームと比べた範囲と、省いた操作を説明できる
step を呼び続けると、見えない間に弾が当たってしまいます。P を押した直後の、押し続けている Z をそのまま通すと、思っていない射撃が再開してしまいます。全部離すまで待つ処理で、X を離したかの確認まで省くと、「押した瞬間」を取りこぼしてしまいます。Space → Space+X → X だけ → 全部離す → X の順に入力し、最後だけはね返しの力が減ることを確かめましょう。P で止めて90フレーム待ち、再開する前後で、ゲームの tick が変わっていないことも確かめます。
このステップのコード全文(445行)
# 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)仕組みがわかったら、元の完成版を読んでみよう。
元のゲームの状況 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 の画面は確かめていません。 |
完成版のコードで、次に読むところ
- model.py: policy / Observation / Intent、原作Cの固定された狙い
- model.py: stepの二段階衝突と相対線分距離
- app.py: 1/2/3、N、ReleaseGate、baseline切替
- 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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。
うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る
