アクションLESSON 46掃除経路が一時的な壁になるアクション原作014

流れ星の清掃員の作り方

なぞった線が、しばらく壁になる。
掃除したあとの線に「寿命」を付けます。自分が線からはなれるまでの猶予と、岩がはね返るルールを分けて作るアクションゲームです。

完成版の遊び方掃いた線が、しばらくの間だけ壁になります。掃除する順番と、帰り道を考えて選びます。

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

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

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

完成版の主な操作:方向移動、Space掃除、Z巻戻し、R再開、P停止、N次区画

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

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

うまく表示されないとき

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

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

線の寿命と、相手ごとの「ぶつかるルール」を分ける。

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

1. 速さの単位、ななめの長さをそろえる、点と線分の距離
2. 限りある寿命、線を区間に分ける、期限を延ばさない記録
3. 自分がはなれたかの記録、猶予と寿命は別の時計、太さのある線分での当たり判定
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
Python 3.10以上(確認は Python 3.12.14 / Pyxel 2.9.9)/ 変数・if・for・関数・list/tuple・座標 / dataclass・内積・法線・deepcopy は本文で説明します
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
方向キー:保持で移動。逆方向同時保持は相殺、斜めは同速 / SPACE:保持で掃除。05タイトルは新押下で開始、次フレームの保持はそのまま掃除 / R:同じ原作Bの初期状態へ戻る / P:05本編だけ一時停止/再開 / Z:05本編だけ1秒前へ戻る。停止中は無効、成功/接触後は有効 / Esc:Pyxel終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_sweep.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

移動した線分で星くずを拾う

速さの単位、ななめの長さをそろえる、点と線分の距離。

最初は、元のゲームの状況 B の位置と、6つの星くずだけを使います。方向キーの向き(ベクトル)を長さ1にそろえ、42 px/秒に DT=1/120 秒を掛けて動かします。入力は1秒に30回なので、1回の入力フレームで4回更新します。SPACE を押し続けているときだけ、「動く前から動いたあと」までの線分から5 px 以内にある星くずを集めます。光の線・自分の壁・岩・一時停止・巻き戻しは、まだありません。

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

01_sweep.py / 注目する部分
        remaining = [p for p in s.dust if distance(p, old, s.pos) > 5. + 1e-9]
        s.collected += len(s.dust) - len(remaining)
        s.dust = remaining

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_sweep.py
macOS / Linux
.venv/bin/python 01_sweep.py
画面で見ること右を1入力フレーム押すと、x=24 から 25.4 に進みます。1秒なら、まっすぐでもななめでも42 px です。止まったまま SPACE を押しても、足もとから5 px 以内なら拾えますが、はなれたごみは消えません。--demo は6つを約4.033333秒で集め、位置は約 (124.8,47.8201) で止まります。外わくでは、中心がふちから3 px 内側で止まります。
  • 座標の px、速さの px/秒、DT の秒を区別できる
  • 入力1回が、物理の更新4回だと分かる
  • 星くずは、SPACE を押している間だけ集める
  • 線分の上でいちばん近い点を使い、途中でふれたものも拾える
つまずきやすいところ速さの42を、毎フレームそのまま足すと、単位がちがってしまいます。ななめを (1,1) のまま使うと、√2倍の速さになります。動いたあとの点だけで集めると、途中でふれたごみを見落とします。SPACE は「押した瞬間」だけでなく、「押し続け」を読みます。
少し変えてみよう

右と右上に、それぞれ120回の物理の更新だけ進めて、出発点からの距離を予想してみましょう(どちらも42 px)。次に、プレイヤーを (24,96)、ごみを (24,91) と (24,90.99999) に置いて止まったまま掃除し、前者だけを拾う境目を、test_03〜test_07 で確かめてください。

01_sweep.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(291行)
01_sweep.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.
"""014 第1段階。独立実行。日本語の解説はLESSON.md。"""
from dataclasses import dataclass, field, asdict
from copy import deepcopy
import math, json, hashlib

DT = 1 / 120       # 物理の1刻み。入力は30Hzなので4回進める。
SPEED = 42.0       # px/s。斜めも同じ速さ。
PLAYER_R = 3.0
BOUNDS = (12., 16., 180., 120.)
Vec = tuple[float, float]
START = (24., 96.)
DUST = ((40.,80.), (56.,80.), (72.,80.), (88.,80.), (120.,48.), (136.,48.))

def add(a,b):return a[0]+b[0],a[1]+b[1]


def sub(a,b):return a[0]-b[0],a[1]-b[1]


def mul(a,k):return a[0]*k,a[1]*k


def dot(a,b):return a[0]*b[0]+a[1]*b[1]


def norm(a):
    d=math.hypot(*a)
    return mul(a,1/d) if d>1e-12 else (0.,0.)


def closest(p,a,b):
    v=sub(b,a);den=dot(v,v)
    t=max(0.,min(1.,dot(sub(p,a),v)/den)) if den>1e-12 else 0.
    return add(a,mul(v,t))


def distance(p,a,b):return math.dist(p,closest(p,a,b))


@dataclass
class State:
    level: int
    pos: Vec
    dust: list[Vec]
    age: float = 0.
    status: str = 'playing'
    collected: int = 0
    blocks: int = 0
    reason: str = ''
    moving_distance: float = 0.
    last_block: float = -10.

def new_state():
    return State(1, START, list(DUST))

def boundary_hits(p,d,r):
    x0,y0,x1,y1=BOUNDS;out=[]
    for axis,lo,hi in ((0,x0+r,x1-r),(1,y0+r,y1-r)):
        if d[axis]<-1e-12 and p[axis]+d[axis]<=lo:
            n=(1.,0.) if axis==0 else (0.,1.);out.append(((lo-p[axis])/d[axis],n))
        if d[axis]>1e-12 and p[axis]+d[axis]>=hi:
            n=(-1.,0.) if axis==0 else (0.,-1.);out.append(((hi-p[axis])/d[axis],n))
    return out


def first_hit(p,d,r,trails=()):
    hits=boundary_hits(p,d,r)
    if not hits:return None
    tt=min(h[0] for h in hits)
    ns=sorted(set(h[1] for h in hits if abs(h[0]-tt)<1e-7))
    # Equal-time corner normals are combined, avoiding scan-order dependence.
    n=norm((sum(v[0] for v in ns),sum(v[1] for v in ns)))
    if n==(0.,0.):n=ns[0]
    return max(0.,min(1.,tt)),n


def tick(s, axis=(0.,0.), clean=False, dt=DT):
    if s.status != 'playing': return
    if not (math.isfinite(dt) and 0 < dt <= DT + 1e-10):
        raise ValueError('Use fixed substeps <= 1/120 second')
    if not all(math.isfinite(v) for v in axis): raise ValueError('Non-finite input')
    s.age += dt
    d = mul(norm(axis), SPEED * dt)
    old = s.pos
    hit = first_hit(old, d, PLAYER_R)
    if hit:
        fraction, normal = hit
        s.pos = add(add(old, mul(d, fraction)), mul(normal, .001))
        if s.age - s.last_block > .2: s.blocks += 1; s.last_block = s.age
        s.reason = 'edge'
    else:
        s.pos = add(old, d)
    moved = math.dist(old, s.pos)
    s.moving_distance += moved
    if clean:
        remaining = [p for p in s.dust if distance(p, old, s.pos) > 5. + 1e-9]
        s.collected += len(s.dust) - len(remaining)
        s.dust = remaining
    if not s.dust: s.status = 'clear'; s.reason = 'clear'

class Session:
    """30Hzの入力から、120Hzの物理を4回呼ぶ。"""
    def __init__(self): self.reset()
    def reset(self): self.state = new_state()
    def frame(self, axis=(0.,0.), clean=False, edge=None):
        if edge == 'restart': self.reset(); return
        for _ in range(4): tick(self.state, axis, clean)

STAGE = 1
STAGE_TITLE = 'MOVE + SWEPT CLEANUP'
CONTROLS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'SPACE', 'R')
DEMO_RUNS = ((11, ('UP', 'SPACE')), (79, ('RIGHT', 'SPACE')), (1, ('RIGHT', 'UP', 'SPACE')), (22, ('UP', 'SPACE')), (1, ('LEFT', 'UP', 'SPACE')), (7, ('LEFT', 'SPACE')))

class Controller:
    def __init__(self):
        self.session = Session()
        self.title = STAGE == 5
    @property
    def state(self): return self.session.state
    def tick(self, pressed=(), held=()):
        pressed = set(pressed); held = set(held)
        if self.title:
            if 'SPACE' in pressed: self.title = False; self.session.reset()
            return
        edge = None
        if 'R' in pressed: edge = 'restart'
        elif STAGE == 5 and 'P' in pressed: edge = 'pause'
        elif STAGE == 5 and 'Z' in pressed: edge = 'rewind'
        axis = (int('RIGHT' in held)-int('LEFT' in held),
                int('DOWN' in held)-int('UP' in held))
        self.session.frame(axis, 'SPACE' in held, edge)
    def snapshot(self):
        return {'state': asdict(self.state), 'title': self.title,
                'paused': getattr(self.session,'paused',False),
                'rewinds': getattr(self.session,'rewinds',0),
                'history_hashes': [state_hash(s) for s in getattr(self.session,'history',[])],
                'hash': state_hash(self.state)}

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

def draw_board(pyxel, controller):
    """観察するだけ。ここで寿命・離脱・物理を更新しない。"""
    s = controller.state
    pyxel.cls(1)
    pyxel.text(12,10,f'STARLIGHT SWEEPER 014 / STEP {STAGE}',10)
    pyxel.text(12,25,STAGE_TITLE,7)
    ox, oy, scale = 0, 26, 3
    def point(p): return ox+p[0]*scale, oy+p[1]*scale
    def line(a,b,color): pyxel.line(*point(a),*point(b),color)
    def dashed(a,b,color):
        length=math.dist(a,b)
        if length<1e-5:return
        for j in range(0,int(length)+1,3):
            line(add(a,mul(sub(b,a),j/length)),add(a,mul(sub(b,a),min(1,(j+1.4)/length))),color)
    pyxel.rect(*point((12,16)),504,312,0)
    pyxel.rectb(*point((12,16)),505,313,5)
    for x in range(18,180,12):
        for y in range(22,120,12):pyxel.pset(*point((x,y)),1)
    for t in getattr(s,'trails',[]):
        remain=LIFE-(s.age-t.born)
        safe=not getattr(t,'left',False) or s.age-t.born<GRACE
        color=10 if remain>1.2 else 9 if remain>.5 else 5
        if safe or remain<.5:dashed(t.a,t.b,color)
        else:
            line(t.a,t.b,color)
            line(add(t.a,(0,.6)),add(t.b,(0,.6)),color)
            pyxel.pset(*point(add(t.a,(0,-1.1))),7)
    for p in s.dust:
        x,y=point(p);pyxel.line(x-5,y,x+5,y,6);pyxel.line(x,y-5,x,y+5,6)
    for r in getattr(s,'rocks',[]):
        pyxel.circ(*point(r.pos),15,13);pyxel.circb(*point(r.pos),15,7)
        end=add(r.pos,mul(norm(r.vel),10));line(r.pos,end,8)
        v=norm(r.vel);tip=add(end,mul(v,-3));side=(-v[1]*2,v[0]*2)
        line(end,add(tip,side),8);line(end,sub(tip,side),8)
    pyxel.circ(*point(s.pos),9,7);pyxel.circ(*point(s.pos),6,12)
    if getattr(s,'collision_point',None):pyxel.circb(*point(s.collision_point),22,8)
    active=sum(bool(getattr(t,'left',False) and s.age-t.born>=GRACE) for t in getattr(s,'trails',[])) if STAGE>=2 else 0
    info=[('CASE','B / CROSSING ROCK'),('STATUS',s.status.upper()),('AGE',f'{s.age:.3f} s'),
          ('DUST',f'{s.collected} / 6'),('POSITION',f'{s.pos[0]:.2f}, {s.pos[1]:.2f}'),('SPEED','42 px/s'),('BLOCKS',str(s.blocks))]
    if STAGE>=2:info += [('CELLS',str(len(s.trails))),('LIFE','2.4 s'),('OWNER WALLS',str(active)),('GRACE','0.18 s' if STAGE>=3 else 'not used yet')]
    if STAGE>=4:info += [('ROCK VY',f'{s.rocks[0].vel[1]:.2f} px/s')]
    if STAGE==5:info += [('HISTORY',str(len(controller.session.history))),('REWINDS',str(controller.session.rewinds))]
    for n,(key,value) in enumerate(info):
        pyxel.text(557,58+n*20,key,13);pyxel.text(650,58+n*20,value,7)
    pyxel.text(12,406,'ARROWS: move    SPACE (hold): sweep    R: reset    ESC: quit',7)
    notes={1:'LAB: sweep dust only. No trails, self walls, or rocks.',
           2:'LAB: finite trail cells only. They do not block you yet.',
           3:'LAB: owner wall + exit grace. Original B rock is still omitted.',
           4:'Original B physics: new trails reflect the rock immediately.',
           5:'Original B + title / pause / rewind. P: pause   Z: rewind 1 second'}
    pyxel.text(12,423,notes[STAGE],10)
    pyxel.text(12,440,'CYAN CROSS: dust   BLUE: player   GRAY: rock   DASHED: owner-safe / expiring',6)
    pyxel.text(12,456,'30 Hz input / 120 Hz physics. Drawing never advances the world.',13)
    if controller.title:
        pyxel.rect(75,148,414,96,1);pyxel.rectb(75,148,414,96,10)
        pyxel.text(95,168,'STARLIGHT SWEEPER / CASE B',10)
        pyxel.text(95,190,'Sweep across first, then travel to the upper dust.',7)
        pyxel.text(95,219,'SPACE: start (holding it sweeps on the next frame)',10)
    elif getattr(controller.session,'paused',False):
        pyxel.rect(36,43,505,22,1);pyxel.rectb(36,43,505,22,10)
        pyxel.text(48,51,'PAUSED: rock / lifetime / history all frozen',10)
    elif s.status!='playing':
        pyxel.rect(36,43,505,22,1);pyxel.rectb(36,43,505,22,10)
        pyxel.text(48,51,'CLEAR! / R: reset' if s.status=='clear' else 'ROCK CONTACT / R: reset',10)

def demo_keys(controller, frame_number):
    """原作B成功入力をrun-length化。状態変更やテレポートはしない。"""
    if STAGE==5:
        if frame_number==1:return ('SPACE',)
        frame_number-=1
    for count,keys in DEMO_RUNS:
        if frame_number<=count:return keys
        frame_number-=count
    return ()

def main(headless=False, frames=180, demo=False, replay=None, screenshot=None, report=None):
    from pathlib import Path
    import pyxel  # モデルのimport、標準ライブラリの試験には不要。
    controller=Controller();frame_number=0;old_held=set();changes=[]
    codes={name:getattr(pyxel,'KEY_'+name) for name in CONTROLS}
    screenshot=str(Path(screenshot).resolve()) if screenshot else None
    report=str(Path(report).resolve()) if report else None
    def update():
        nonlocal frame_number,old_held
        frame_number+=1
        if headless:
            held=set(replay[frame_number-1]) if replay is not None and frame_number<=len(replay) else set(demo_keys(controller,frame_number) 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)}
        previous=controller.state.status
        controller.tick(pressed,held)
        if pressed or previous!=controller.state.status:
            changes.append({'frame':frame_number,'pressed':sorted(pressed),'held':sorted(held),'status':controller.state.status,'hash':state_hash(controller.state)})
    def draw():
        draw_board(pyxel,controller)
        if headless and frame_number>=frames:
            if screenshot:pyxel.screenshot(screenshot,scale=1)
            if report:Path(report).write_text(json.dumps({'label':'Actual Pyxel headless; synthetic input, not human play',
                'stage':STAGE,'requested_frames':frames,'actual_frames':frame_number,'demo':demo,'changes':changes,
                'snapshot':controller.snapshot()},ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            pyxel.quit()
    pyxel.init(790,477,title=f'Starlight Sweeper / 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; current directory relative')
    parser.add_argument('--report',help='UTF-8 JSON output, headless only')
    args=parser.parse_args()
    if not 12<=args.frames<=100000:parser.error('Use 12 through 100000 frames')
    if not args.headless and (args.demo or args.replay_json or args.screenshot or args.report):parser.error('QA 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

掃いた跡を誕生時刻付きの小区間にする

限りある寿命、線を区間に分ける、期限を延ばさない記録。

SPACE を押しながら動くと、動いた線分を Trail(a,b,born)(始点・終点・生まれた時刻)として保存します。同じ直線上・同じ向き・はしがつながっていて、全体の長さが2 px 以内なら、最後の区間をのばします。そのとき born は変えません。毎回の物理の更新で、時刻を進めた直後に、生まれてから2.4秒たった区間を取り除きます。ここでは、線はまだ飾りで、プレイヤーも岩もさえぎりません。岩も、まだ出てきません。

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

02_lifetime.py / 注目する部分
    s.age += dt
    s.trails = [t for t in s.trails if s.age-t.born < LIFE-1e-9]

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_lifetime.py
macOS / Linux
.venv/bin/python 02_lifetime.py
画面で見ること右へ5回の物理の更新だけ掃くと、最初の区間は長さ1.75 px、生まれた時刻は 1/120 秒のままです。6回目で合計2.1 px になるので、次の区間ができます。最初の区間は、生まれてから287回目の更新ではまだ残り、288回目で消えます。止まって SPACE を押し続けても、長さ0の区間は増えません。
  • 位置と生まれた時刻を、同じ区間に保存できる
  • 区間をのばしても、寿命はリセットしない
  • 物理の更新のはじめに、期限が切れた区間を取り除く
  • 02 の線は、通れる教材用のルールだと分かる
つまずきやすいところ区間をのばすたびに born を今の時刻に変えると、寿命が延びてしまいます。通った線全体を1本にまとめると、古い部分だけを消せません。「最大の数をこえたら古い線を消す」というルールに変えると、曲がった回数で寿命が変わってしまいます。
少し変えてみよう

test_12〜test_16 で、長さの上限、曲がったときに新しい区間ができること、生まれてから288回目の更新で期限が切れることを確かめましょう。LIFE だけを3.0に変えたら何回残るか予想してみます(360回)。変更は実験用のコピーで行い、元の2.4に戻してから、全部のテストをもう一度実行します。

02_lifetime.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(323行)
02_lifetime.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.
"""014 第2段階。独立実行。日本語の解説はLESSON.md。"""
from dataclasses import dataclass, field, asdict
from copy import deepcopy
import math, json, hashlib

DT = 1 / 120       # 物理の1刻み。入力は30Hzなので4回進める。
SPEED = 42.0       # px/s。斜めも同じ速さ。
PLAYER_R = 3.0
BOUNDS = (12., 16., 180., 120.)
Vec = tuple[float, float]
START = (24., 96.)
DUST = ((40.,80.), (56.,80.), (72.,80.), (88.,80.), (120.,48.), (136.,48.))

LIFE = 2.4
GRACE = .18
WALL_R = 1.0

def add(a,b):return a[0]+b[0],a[1]+b[1]


def sub(a,b):return a[0]-b[0],a[1]-b[1]


def mul(a,k):return a[0]*k,a[1]*k


def dot(a,b):return a[0]*b[0]+a[1]*b[1]


def norm(a):
    d=math.hypot(*a)
    return mul(a,1/d) if d>1e-12 else (0.,0.)


def closest(p,a,b):
    v=sub(b,a);den=dot(v,v)
    t=max(0.,min(1.,dot(sub(p,a),v)/den)) if den>1e-12 else 0.
    return add(a,mul(v,t))


def distance(p,a,b):return math.dist(p,closest(p,a,b))


@dataclass
class Trail:
    a:Vec
    b:Vec
    born:float


@dataclass
class State:
    level: int
    pos: Vec
    dust: list[Vec]
    trails: list[Trail] = field(default_factory=list)
    swept_distance: float = 0.
    max_trails: int = 0
    age: float = 0.
    status: str = 'playing'
    collected: int = 0
    blocks: int = 0
    reason: str = ''
    moving_distance: float = 0.
    last_block: float = -10.

def new_state():
    return State(1, START, list(DUST))

def boundary_hits(p,d,r):
    x0,y0,x1,y1=BOUNDS;out=[]
    for axis,lo,hi in ((0,x0+r,x1-r),(1,y0+r,y1-r)):
        if d[axis]<-1e-12 and p[axis]+d[axis]<=lo:
            n=(1.,0.) if axis==0 else (0.,1.);out.append(((lo-p[axis])/d[axis],n))
        if d[axis]>1e-12 and p[axis]+d[axis]>=hi:
            n=(-1.,0.) if axis==0 else (0.,-1.);out.append(((hi-p[axis])/d[axis],n))
    return out


def first_hit(p,d,r,trails=()):
    hits=boundary_hits(p,d,r)
    if not hits:return None
    tt=min(h[0] for h in hits)
    ns=sorted(set(h[1] for h in hits if abs(h[0]-tt)<1e-7))
    # Equal-time corner normals are combined, avoiding scan-order dependence.
    n=norm((sum(v[0] for v in ns),sum(v[1] for v in ns)))
    if n==(0.,0.):n=ns[0]
    return max(0.,min(1.,tt)),n


def append_trail(s,a,b):
    """At most 2 px per timed cell; collinear cells are drawn continuously.

    A current cell is extended only while its length is <=2 px. Its own birth time is retained, never refreshed by extension.
    At 42 px/s this coarsens expiry by at most 0.048 seconds, documented in UI.
    """
    if math.dist(a,b)<1e-9:return
    last=s.trails[-1] if s.trails else None
    if last and math.dist(last.b,a)<1e-7 and math.dist(last.a,b)<=2.00001:
        u=sub(last.b,last.a);v=sub(b,last.b)
        if abs(u[0]*v[1]-u[1]*v[0])<1e-7 and dot(u,v)>=0:
            last.b=b;return
    s.trails.append(Trail(a,b,s.age))


def tick(s, axis=(0.,0.), clean=False, dt=DT):
    if s.status != 'playing': return
    if not (math.isfinite(dt) and 0 < dt <= DT + 1e-10):
        raise ValueError('Use fixed substeps <= 1/120 second')
    if not all(math.isfinite(v) for v in axis): raise ValueError('Non-finite input')
    s.age += dt
    s.trails = [t for t in s.trails if s.age-t.born < LIFE-1e-9]
    d = mul(norm(axis), SPEED * dt)
    old = s.pos
    hit = first_hit(old, d, PLAYER_R)
    if hit:
        fraction, normal = hit
        s.pos = add(add(old, mul(d, fraction)), mul(normal, .001))
        if s.age - s.last_block > .2: s.blocks += 1; s.last_block = s.age
        s.reason = 'edge'
    else:
        s.pos = add(old, d)
    moved = math.dist(old, s.pos)
    s.moving_distance += moved
    if clean:
        remaining = [p for p in s.dust if distance(p, old, s.pos) > 5. + 1e-9]
        s.collected += len(s.dust) - len(remaining)
        s.dust = remaining
        if moved > 1e-8: append_trail(s, old, s.pos); s.swept_distance += moved
    s.max_trails = max(s.max_trails, len(s.trails))
    if not s.dust: s.status = 'clear'; s.reason = 'clear'

class Session:
    """30Hzの入力から、120Hzの物理を4回呼ぶ。"""
    def __init__(self): self.reset()
    def reset(self): self.state = new_state()
    def frame(self, axis=(0.,0.), clean=False, edge=None):
        if edge == 'restart': self.reset(); return
        for _ in range(4): tick(self.state, axis, clean)

STAGE = 2
STAGE_TITLE = 'FINITE LIFE + TIMED CELLS'
CONTROLS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'SPACE', 'R')
DEMO_RUNS = ((11, ('UP', 'SPACE')), (79, ('RIGHT', 'SPACE')), (1, ('RIGHT', 'UP', 'SPACE')), (22, ('UP', 'SPACE')), (1, ('LEFT', 'UP', 'SPACE')), (7, ('LEFT', 'SPACE')))

class Controller:
    def __init__(self):
        self.session = Session()
        self.title = STAGE == 5
    @property
    def state(self): return self.session.state
    def tick(self, pressed=(), held=()):
        pressed = set(pressed); held = set(held)
        if self.title:
            if 'SPACE' in pressed: self.title = False; self.session.reset()
            return
        edge = None
        if 'R' in pressed: edge = 'restart'
        elif STAGE == 5 and 'P' in pressed: edge = 'pause'
        elif STAGE == 5 and 'Z' in pressed: edge = 'rewind'
        axis = (int('RIGHT' in held)-int('LEFT' in held),
                int('DOWN' in held)-int('UP' in held))
        self.session.frame(axis, 'SPACE' in held, edge)
    def snapshot(self):
        return {'state': asdict(self.state), 'title': self.title,
                'paused': getattr(self.session,'paused',False),
                'rewinds': getattr(self.session,'rewinds',0),
                'history_hashes': [state_hash(s) for s in getattr(self.session,'history',[])],
                'hash': state_hash(self.state)}

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

def draw_board(pyxel, controller):
    """観察するだけ。ここで寿命・離脱・物理を更新しない。"""
    s = controller.state
    pyxel.cls(1)
    pyxel.text(12,10,f'STARLIGHT SWEEPER 014 / STEP {STAGE}',10)
    pyxel.text(12,25,STAGE_TITLE,7)
    ox, oy, scale = 0, 26, 3
    def point(p): return ox+p[0]*scale, oy+p[1]*scale
    def line(a,b,color): pyxel.line(*point(a),*point(b),color)
    def dashed(a,b,color):
        length=math.dist(a,b)
        if length<1e-5:return
        for j in range(0,int(length)+1,3):
            line(add(a,mul(sub(b,a),j/length)),add(a,mul(sub(b,a),min(1,(j+1.4)/length))),color)
    pyxel.rect(*point((12,16)),504,312,0)
    pyxel.rectb(*point((12,16)),505,313,5)
    for x in range(18,180,12):
        for y in range(22,120,12):pyxel.pset(*point((x,y)),1)
    for t in getattr(s,'trails',[]):
        remain=LIFE-(s.age-t.born)
        safe=not getattr(t,'left',False) or s.age-t.born<GRACE
        color=10 if remain>1.2 else 9 if remain>.5 else 5
        if safe or remain<.5:dashed(t.a,t.b,color)
        else:
            line(t.a,t.b,color)
            line(add(t.a,(0,.6)),add(t.b,(0,.6)),color)
            pyxel.pset(*point(add(t.a,(0,-1.1))),7)
    for p in s.dust:
        x,y=point(p);pyxel.line(x-5,y,x+5,y,6);pyxel.line(x,y-5,x,y+5,6)
    for r in getattr(s,'rocks',[]):
        pyxel.circ(*point(r.pos),15,13);pyxel.circb(*point(r.pos),15,7)
        end=add(r.pos,mul(norm(r.vel),10));line(r.pos,end,8)
        v=norm(r.vel);tip=add(end,mul(v,-3));side=(-v[1]*2,v[0]*2)
        line(end,add(tip,side),8);line(end,sub(tip,side),8)
    pyxel.circ(*point(s.pos),9,7);pyxel.circ(*point(s.pos),6,12)
    if getattr(s,'collision_point',None):pyxel.circb(*point(s.collision_point),22,8)
    active=sum(bool(getattr(t,'left',False) and s.age-t.born>=GRACE) for t in getattr(s,'trails',[])) if STAGE>=2 else 0
    info=[('CASE','B / CROSSING ROCK'),('STATUS',s.status.upper()),('AGE',f'{s.age:.3f} s'),
          ('DUST',f'{s.collected} / 6'),('POSITION',f'{s.pos[0]:.2f}, {s.pos[1]:.2f}'),('SPEED','42 px/s'),('BLOCKS',str(s.blocks))]
    if STAGE>=2:info += [('CELLS',str(len(s.trails))),('LIFE','2.4 s'),('OWNER WALLS',str(active)),('GRACE','0.18 s' if STAGE>=3 else 'not used yet')]
    if STAGE>=4:info += [('ROCK VY',f'{s.rocks[0].vel[1]:.2f} px/s')]
    if STAGE==5:info += [('HISTORY',str(len(controller.session.history))),('REWINDS',str(controller.session.rewinds))]
    for n,(key,value) in enumerate(info):
        pyxel.text(557,58+n*20,key,13);pyxel.text(650,58+n*20,value,7)
    pyxel.text(12,406,'ARROWS: move    SPACE (hold): sweep    R: reset    ESC: quit',7)
    notes={1:'LAB: sweep dust only. No trails, self walls, or rocks.',
           2:'LAB: finite trail cells only. They do not block you yet.',
           3:'LAB: owner wall + exit grace. Original B rock is still omitted.',
           4:'Original B physics: new trails reflect the rock immediately.',
           5:'Original B + title / pause / rewind. P: pause   Z: rewind 1 second'}
    pyxel.text(12,423,notes[STAGE],10)
    pyxel.text(12,440,'CYAN CROSS: dust   BLUE: player   GRAY: rock   DASHED: owner-safe / expiring',6)
    pyxel.text(12,456,'30 Hz input / 120 Hz physics. Drawing never advances the world.',13)
    if controller.title:
        pyxel.rect(75,148,414,96,1);pyxel.rectb(75,148,414,96,10)
        pyxel.text(95,168,'STARLIGHT SWEEPER / CASE B',10)
        pyxel.text(95,190,'Sweep across first, then travel to the upper dust.',7)
        pyxel.text(95,219,'SPACE: start (holding it sweeps on the next frame)',10)
    elif getattr(controller.session,'paused',False):
        pyxel.rect(36,43,505,22,1);pyxel.rectb(36,43,505,22,10)
        pyxel.text(48,51,'PAUSED: rock / lifetime / history all frozen',10)
    elif s.status!='playing':
        pyxel.rect(36,43,505,22,1);pyxel.rectb(36,43,505,22,10)
        pyxel.text(48,51,'CLEAR! / R: reset' if s.status=='clear' else 'ROCK CONTACT / R: reset',10)

def demo_keys(controller, frame_number):
    """原作B成功入力をrun-length化。状態変更やテレポートはしない。"""
    if STAGE==5:
        if frame_number==1:return ('SPACE',)
        frame_number-=1
    for count,keys in DEMO_RUNS:
        if frame_number<=count:return keys
        frame_number-=count
    return ()

def main(headless=False, frames=180, demo=False, replay=None, screenshot=None, report=None):
    from pathlib import Path
    import pyxel  # モデルのimport、標準ライブラリの試験には不要。
    controller=Controller();frame_number=0;old_held=set();changes=[]
    codes={name:getattr(pyxel,'KEY_'+name) for name in CONTROLS}
    screenshot=str(Path(screenshot).resolve()) if screenshot else None
    report=str(Path(report).resolve()) if report else None
    def update():
        nonlocal frame_number,old_held
        frame_number+=1
        if headless:
            held=set(replay[frame_number-1]) if replay is not None and frame_number<=len(replay) else set(demo_keys(controller,frame_number) 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)}
        previous=controller.state.status
        controller.tick(pressed,held)
        if pressed or previous!=controller.state.status:
            changes.append({'frame':frame_number,'pressed':sorted(pressed),'held':sorted(held),'status':controller.state.status,'hash':state_hash(controller.state)})
    def draw():
        draw_board(pyxel,controller)
        if headless and frame_number>=frames:
            if screenshot:pyxel.screenshot(screenshot,scale=1)
            if report:Path(report).write_text(json.dumps({'label':'Actual Pyxel headless; synthetic input, not human play',
                'stage':STAGE,'requested_frames':frames,'actual_frames':frame_number,'demo':demo,'changes':changes,
                'snapshot':controller.snapshot()},ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            pyxel.quit()
    pyxel.init(790,477,title=f'Starlight Sweeper / 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; current directory relative')
    parser.add_argument('--report',help='UTF-8 JSON output, headless only')
    args=parser.parse_args()
    if not 12<=args.frames<=100000:parser.error('Use 12 through 100000 frames')
    if not args.headless and (args.demo or args.replay_json or args.screenshot or args.report):parser.error('QA 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

自分が離れた線だけを壁として扱う

自分がはなれたかの記録、猶予と寿命は別の時計、太さのある線分での当たり判定。

Trail に left(はなれた)を加えます。更新を始めたときの位置が線分から4.02 px より遠くなったら、一度だけ True にします。自分を止めるのは、left=True で、しかも生まれてから0.18秒以上たった線だけです。判定は、「動いた線分」と、「壁の半径1+自分の半径3」で太くした線分(カプセル)で行います。側面と、両はしの円から、最初にふれるところを選びます。岩はまだありません。

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

03_owner.py / 注目する部分
    for t in s.trails:
        if not t.left and distance(s.pos,t.a,t.b) > PLAYER_R+WALL_R+.02: t.left=True
    active = [t for t in s.trails if t.left and s.age-t.born >= GRACE]

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_owner.py
macOS / Linux
.venv/bin/python 03_owner.py
画面で見ること足もとの線に0.5秒重なっていても、left=False なので、そこからはなれられます。一度はなれた y=50 の線に、y=54.1 から戻ると、壁が十分古ければ y=54.001 で止まります。壁にふれても失敗にはならず、寿命で消えれば通れます。同じ時刻に消える線は先に取り除かれるので、その更新ではさえぎりません。
  • left は「一度はなれた」という記憶だと分かる
  • 自分の猶予0.18秒と、壁の寿命2.4秒をまぜない
  • カプセルには、側面と、はしの円もふくまれる
  • 同時にふれた向きを合わせて、調べる順番による差をなくす
つまずきやすいところ0.18秒たったら必ず壁にすると、掃除を始めた足もとで、自分を閉じこめてしまいます。はなれた瞬間にすぐ壁にするのも、元のゲームとはちがいます。左へ戻るたびに left を False に戻すと、いつまでもすりぬけられてしまいます。見た目が点線でも、残り0.5秒の古い壁はまだ有効です。
少し変えてみよう

test_18〜test_22 で、「はなれずに待つ」「はなれたが0.18秒より前」「はなれて十分古い」を比べましょう。手で試すときは、SPACE+上で下の列へ行き、右へ掃いてから SPACE を離して下へよけ、上へ戻ります。壁に止められたら、止まったまま消えるまで待ってください。

03_owner.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(367行)
03_owner.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.
"""014 第3段階。独立実行。日本語の解説はLESSON.md。"""
from dataclasses import dataclass, field, asdict
from copy import deepcopy
import math, json, hashlib

DT = 1 / 120       # 物理の1刻み。入力は30Hzなので4回進める。
SPEED = 42.0       # px/s。斜めも同じ速さ。
PLAYER_R = 3.0
BOUNDS = (12., 16., 180., 120.)
Vec = tuple[float, float]
START = (24., 96.)
DUST = ((40.,80.), (56.,80.), (72.,80.), (88.,80.), (120.,48.), (136.,48.))

LIFE = 2.4
GRACE = .18
WALL_R = 1.0

def add(a,b):return a[0]+b[0],a[1]+b[1]


def sub(a,b):return a[0]-b[0],a[1]-b[1]


def mul(a,k):return a[0]*k,a[1]*k


def dot(a,b):return a[0]*b[0]+a[1]*b[1]


def norm(a):
    d=math.hypot(*a)
    return mul(a,1/d) if d>1e-12 else (0.,0.)


def closest(p,a,b):
    v=sub(b,a);den=dot(v,v)
    t=max(0.,min(1.,dot(sub(p,a),v)/den)) if den>1e-12 else 0.
    return add(a,mul(v,t))


def distance(p,a,b):return math.dist(p,closest(p,a,b))


def sweep_circle(p,d,c,r):
    q=sub(p,c);aa=dot(d,d);cc=dot(q,q)-r*r
    if cc<=1e-8:
        n=norm(q)
        if dot(d,n)<-1e-10:return 0.,n
        return None
    if aa<1e-16:return None
    bb=2*dot(q,d);disc=bb*bb-4*aa*cc
    if disc<0:return None
    t=(-bb-math.sqrt(max(0.,disc)))/(2*aa)
    if -1e-9<=t<=1+1e-9:
        t=max(0.,min(1.,t));n=norm(sub(add(p,mul(d,t)),c))
        if dot(d,n)<-1e-10:return t,n
    return None


def sweep_capsule(p,d,a,b,r):
    """Earliest hit against a radius-expanded segment; endpoints included."""
    hits=[];v=sub(b,a);length=math.hypot(*v)
    for c in (a,b):
        hit=sweep_circle(p,d,c,r)
        if hit:hits.append(hit)
    if length>1e-12:
        u=mul(v,1/length);n=(-u[1],u[0]);q=sub(p,a);s=dot(q,n);ds=dot(d,n)
        for side in (-1,1):
            if abs(ds)<1e-12:continue
            t=(side*r-s)/ds
            if -1e-9<=t<=1+1e-9 and ds*side<0:
                x=dot(add(q,mul(d,t)),u)
                if -1e-9<=x<=length+1e-9:hits.append((max(0.,t),mul(n,side)))
        if abs(s)<r and 0<=dot(q,u)<=length:
            nn=mul(n,1 if s>=0 else -1)
            if dot(d,nn)<-1e-10:hits.append((0.,nn))
    return min(hits,key=lambda h:(h[0],h[1])) if hits else None


@dataclass
class Trail:
    a:Vec
    b:Vec
    born:float
    left:bool=False


@dataclass
class State:
    level: int
    pos: Vec
    dust: list[Vec]
    trails: list[Trail] = field(default_factory=list)
    swept_distance: float = 0.
    max_trails: int = 0
    age: float = 0.
    status: str = 'playing'
    collected: int = 0
    blocks: int = 0
    reason: str = ''
    moving_distance: float = 0.
    last_block: float = -10.

def new_state():
    return State(1, START, list(DUST))

def boundary_hits(p,d,r):
    x0,y0,x1,y1=BOUNDS;out=[]
    for axis,lo,hi in ((0,x0+r,x1-r),(1,y0+r,y1-r)):
        if d[axis]<-1e-12 and p[axis]+d[axis]<=lo:
            n=(1.,0.) if axis==0 else (0.,1.);out.append(((lo-p[axis])/d[axis],n))
        if d[axis]>1e-12 and p[axis]+d[axis]>=hi:
            n=(-1.,0.) if axis==0 else (0.,-1.);out.append(((hi-p[axis])/d[axis],n))
    return out


def first_hit(p,d,r,trails=()):
    hits=boundary_hits(p,d,r)
    for t in trails:
        h=sweep_capsule(p,d,t.a,t.b,r+WALL_R)
        if h:hits.append(h)
    if not hits:return None
    tt=min(h[0] for h in hits)
    ns=sorted(set(h[1] for h in hits if abs(h[0]-tt)<1e-7))
    # Equal-time corner normals are combined, avoiding scan-order dependence.
    n=norm((sum(v[0] for v in ns),sum(v[1] for v in ns)))
    if n==(0.,0.):n=ns[0]
    return max(0.,min(1.,tt)),n


def append_trail(s,a,b):
    """At most 2 px per timed cell; collinear cells are drawn continuously.

    A current cell is extended only while its length is <=2 px and its owner
    has not left. Its own birth time is retained, never refreshed by extension.
    At 42 px/s this coarsens expiry by at most 0.048 seconds, documented in UI.
    """
    if math.dist(a,b)<1e-9:return
    last=s.trails[-1] if s.trails else None
    if last and not last.left and math.dist(last.b,a)<1e-7 and math.dist(last.a,b)<=2.00001:
        u=sub(last.b,last.a);v=sub(b,last.b)
        if abs(u[0]*v[1]-u[1]*v[0])<1e-7 and dot(u,v)>=0:
            last.b=b;return
    s.trails.append(Trail(a,b,s.age))


def tick(s, axis=(0.,0.), clean=False, dt=DT):
    if s.status != 'playing': return
    if not (math.isfinite(dt) and 0 < dt <= DT + 1e-10):
        raise ValueError('Use fixed substeps <= 1/120 second')
    if not all(math.isfinite(v) for v in axis): raise ValueError('Non-finite input')
    s.age += dt
    s.trails = [t for t in s.trails if s.age-t.born < LIFE-1e-9]
    for t in s.trails:
        if not t.left and distance(s.pos,t.a,t.b) > PLAYER_R+WALL_R+.02: t.left=True
    active = [t for t in s.trails if t.left and s.age-t.born >= GRACE]
    d = mul(norm(axis), SPEED * dt)
    old = s.pos
    hit = first_hit(old, d, PLAYER_R, active)
    if hit:
        fraction, normal = hit
        s.pos = add(add(old, mul(d, fraction)), mul(normal, .001))
        if s.age - s.last_block > .2: s.blocks += 1; s.last_block = s.age
        s.reason = 'edge' if any(abs(t-fraction)<1e-7 for t,n in boundary_hits(old,d,PLAYER_R)) else 'trail'
    else:
        s.pos = add(old, d)
    moved = math.dist(old, s.pos)
    s.moving_distance += moved
    if clean:
        remaining = [p for p in s.dust if distance(p, old, s.pos) > 5. + 1e-9]
        s.collected += len(s.dust) - len(remaining)
        s.dust = remaining
        if moved > 1e-8: append_trail(s, old, s.pos); s.swept_distance += moved
    s.max_trails = max(s.max_trails, len(s.trails))
    if not s.dust: s.status = 'clear'; s.reason = 'clear'

class Session:
    """30Hzの入力から、120Hzの物理を4回呼ぶ。"""
    def __init__(self): self.reset()
    def reset(self): self.state = new_state()
    def frame(self, axis=(0.,0.), clean=False, edge=None):
        if edge == 'restart': self.reset(); return
        for _ in range(4): tick(self.state, axis, clean)

STAGE = 3
STAGE_TITLE = 'OWNER EXIT + SELF WALLS'
CONTROLS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'SPACE', 'R')
DEMO_RUNS = ((11, ('UP', 'SPACE')), (79, ('RIGHT', 'SPACE')), (1, ('RIGHT', 'UP', 'SPACE')), (22, ('UP', 'SPACE')), (1, ('LEFT', 'UP', 'SPACE')), (7, ('LEFT', 'SPACE')))

class Controller:
    def __init__(self):
        self.session = Session()
        self.title = STAGE == 5
    @property
    def state(self): return self.session.state
    def tick(self, pressed=(), held=()):
        pressed = set(pressed); held = set(held)
        if self.title:
            if 'SPACE' in pressed: self.title = False; self.session.reset()
            return
        edge = None
        if 'R' in pressed: edge = 'restart'
        elif STAGE == 5 and 'P' in pressed: edge = 'pause'
        elif STAGE == 5 and 'Z' in pressed: edge = 'rewind'
        axis = (int('RIGHT' in held)-int('LEFT' in held),
                int('DOWN' in held)-int('UP' in held))
        self.session.frame(axis, 'SPACE' in held, edge)
    def snapshot(self):
        return {'state': asdict(self.state), 'title': self.title,
                'paused': getattr(self.session,'paused',False),
                'rewinds': getattr(self.session,'rewinds',0),
                'history_hashes': [state_hash(s) for s in getattr(self.session,'history',[])],
                'hash': state_hash(self.state)}

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

def draw_board(pyxel, controller):
    """観察するだけ。ここで寿命・離脱・物理を更新しない。"""
    s = controller.state
    pyxel.cls(1)
    pyxel.text(12,10,f'STARLIGHT SWEEPER 014 / STEP {STAGE}',10)
    pyxel.text(12,25,STAGE_TITLE,7)
    ox, oy, scale = 0, 26, 3
    def point(p): return ox+p[0]*scale, oy+p[1]*scale
    def line(a,b,color): pyxel.line(*point(a),*point(b),color)
    def dashed(a,b,color):
        length=math.dist(a,b)
        if length<1e-5:return
        for j in range(0,int(length)+1,3):
            line(add(a,mul(sub(b,a),j/length)),add(a,mul(sub(b,a),min(1,(j+1.4)/length))),color)
    pyxel.rect(*point((12,16)),504,312,0)
    pyxel.rectb(*point((12,16)),505,313,5)
    for x in range(18,180,12):
        for y in range(22,120,12):pyxel.pset(*point((x,y)),1)
    for t in getattr(s,'trails',[]):
        remain=LIFE-(s.age-t.born)
        safe=not getattr(t,'left',False) or s.age-t.born<GRACE
        color=10 if remain>1.2 else 9 if remain>.5 else 5
        if safe or remain<.5:dashed(t.a,t.b,color)
        else:
            line(t.a,t.b,color)
            line(add(t.a,(0,.6)),add(t.b,(0,.6)),color)
            pyxel.pset(*point(add(t.a,(0,-1.1))),7)
    for p in s.dust:
        x,y=point(p);pyxel.line(x-5,y,x+5,y,6);pyxel.line(x,y-5,x,y+5,6)
    for r in getattr(s,'rocks',[]):
        pyxel.circ(*point(r.pos),15,13);pyxel.circb(*point(r.pos),15,7)
        end=add(r.pos,mul(norm(r.vel),10));line(r.pos,end,8)
        v=norm(r.vel);tip=add(end,mul(v,-3));side=(-v[1]*2,v[0]*2)
        line(end,add(tip,side),8);line(end,sub(tip,side),8)
    pyxel.circ(*point(s.pos),9,7);pyxel.circ(*point(s.pos),6,12)
    if getattr(s,'collision_point',None):pyxel.circb(*point(s.collision_point),22,8)
    active=sum(bool(getattr(t,'left',False) and s.age-t.born>=GRACE) for t in getattr(s,'trails',[])) if STAGE>=2 else 0
    info=[('CASE','B / CROSSING ROCK'),('STATUS',s.status.upper()),('AGE',f'{s.age:.3f} s'),
          ('DUST',f'{s.collected} / 6'),('POSITION',f'{s.pos[0]:.2f}, {s.pos[1]:.2f}'),('SPEED','42 px/s'),('BLOCKS',str(s.blocks))]
    if STAGE>=2:info += [('CELLS',str(len(s.trails))),('LIFE','2.4 s'),('OWNER WALLS',str(active)),('GRACE','0.18 s' if STAGE>=3 else 'not used yet')]
    if STAGE>=4:info += [('ROCK VY',f'{s.rocks[0].vel[1]:.2f} px/s')]
    if STAGE==5:info += [('HISTORY',str(len(controller.session.history))),('REWINDS',str(controller.session.rewinds))]
    for n,(key,value) in enumerate(info):
        pyxel.text(557,58+n*20,key,13);pyxel.text(650,58+n*20,value,7)
    pyxel.text(12,406,'ARROWS: move    SPACE (hold): sweep    R: reset    ESC: quit',7)
    notes={1:'LAB: sweep dust only. No trails, self walls, or rocks.',
           2:'LAB: finite trail cells only. They do not block you yet.',
           3:'LAB: owner wall + exit grace. Original B rock is still omitted.',
           4:'Original B physics: new trails reflect the rock immediately.',
           5:'Original B + title / pause / rewind. P: pause   Z: rewind 1 second'}
    pyxel.text(12,423,notes[STAGE],10)
    pyxel.text(12,440,'CYAN CROSS: dust   BLUE: player   GRAY: rock   DASHED: owner-safe / expiring',6)
    pyxel.text(12,456,'30 Hz input / 120 Hz physics. Drawing never advances the world.',13)
    if controller.title:
        pyxel.rect(75,148,414,96,1);pyxel.rectb(75,148,414,96,10)
        pyxel.text(95,168,'STARLIGHT SWEEPER / CASE B',10)
        pyxel.text(95,190,'Sweep across first, then travel to the upper dust.',7)
        pyxel.text(95,219,'SPACE: start (holding it sweeps on the next frame)',10)
    elif getattr(controller.session,'paused',False):
        pyxel.rect(36,43,505,22,1);pyxel.rectb(36,43,505,22,10)
        pyxel.text(48,51,'PAUSED: rock / lifetime / history all frozen',10)
    elif s.status!='playing':
        pyxel.rect(36,43,505,22,1);pyxel.rectb(36,43,505,22,10)
        pyxel.text(48,51,'CLEAR! / R: reset' if s.status=='clear' else 'ROCK CONTACT / R: reset',10)

def demo_keys(controller, frame_number):
    """原作B成功入力をrun-length化。状態変更やテレポートはしない。"""
    if STAGE==5:
        if frame_number==1:return ('SPACE',)
        frame_number-=1
    for count,keys in DEMO_RUNS:
        if frame_number<=count:return keys
        frame_number-=count
    return ()

def main(headless=False, frames=180, demo=False, replay=None, screenshot=None, report=None):
    from pathlib import Path
    import pyxel  # モデルのimport、標準ライブラリの試験には不要。
    controller=Controller();frame_number=0;old_held=set();changes=[]
    codes={name:getattr(pyxel,'KEY_'+name) for name in CONTROLS}
    screenshot=str(Path(screenshot).resolve()) if screenshot else None
    report=str(Path(report).resolve()) if report else None
    def update():
        nonlocal frame_number,old_held
        frame_number+=1
        if headless:
            held=set(replay[frame_number-1]) if replay is not None and frame_number<=len(replay) else set(demo_keys(controller,frame_number) 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)}
        previous=controller.state.status
        controller.tick(pressed,held)
        if pressed or previous!=controller.state.status:
            changes.append({'frame':frame_number,'pressed':sorted(pressed),'held':sorted(held),'status':controller.state.status,'hash':state_hash(controller.state)})
    def draw():
        draw_board(pyxel,controller)
        if headless and frame_number>=frames:
            if screenshot:pyxel.screenshot(screenshot,scale=1)
            if report:Path(report).write_text(json.dumps({'label':'Actual Pyxel headless; synthetic input, not human play',
                'stage':STAGE,'requested_frames':frames,'actual_frames':frame_number,'demo':demo,'changes':changes,
                'snapshot':controller.snapshot()},ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            pyxel.quit()
    pyxel.init(790,477,title=f'Starlight Sweeper / 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; current directory relative')
    parser.add_argument('--report',help='UTF-8 JSON output, headless only')
    args=parser.parse_args()
    if not 12<=args.frames<=100000:parser.error('Use 12 through 100000 frames')
    if not args.headless and (args.demo or args.replay_json or args.screenshot or args.report):parser.error('QA 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つの動きの差での判定、処理の順番。

元のゲームの状況 B の岩を、最初の位置 (104,24)、速さ (0,18) px/秒で加えて、物理の処理を完成させます。プレイヤーを動かし、新しい線を足してから、岩を動かします。岩は left や0.18秒の猶予を見ず、生きているすべての区間とぶつかります。速さは v−2(v・n)n ではね返し、残りの時間も進めます。岩とプレイヤーは、2つの動きの差を使って、途中でふれたかどうかも調べます。

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

04_rocks.py / 注目する部分
            if hit:
                _,n=hit;rock.pos=add(rock.pos,mul(n,.01));rock.vel=sub(rock.vel,mul(n,2*dot(rock.vel,n)))
            spent=remaining*fraction;elapsed+=spent;remaining-=spent

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_rocks.py
macOS / Linux
.venv/bin/python 04_rocks.py
画面で見ること生まれて1回目の更新で、left=False の線にも、岩ははね返ります。横の壁 y=50 に向かって、岩 (60,43.9)・速さ (0,24) を1回だけ動かすテストでは、速さ (0,-24)、位置 y=43.89 になります。--demo は横の壁で岩をはね返し、元のゲームの状況 B と同じ4.033333秒で6つを集めます。最後の岩は約 (104,52.59)、速さ (0,-18) です。
  • 自分用の active と、岩用の「生きている全区間」を分けられる
  • はね返りでは、壁に向かう方向だけを反対にし、速さの大きさは保つ
  • 2つの動きの差で、岩と自分の途中の接触を調べる
  • 岩にふれたことは、同じ更新ですべて集めたことより優先だと分かる
つまずきやすいところ自分用の active だけを岩に渡すと、できたばかりの壁を岩がすりぬけてしまいます。はね返りで速さの大きさを変えたり、毎回残りの時間を捨てたりすると、動き方が変わってしまいます。最後のごみを先に拾っても、同じ更新で岩にふれたら、collision(衝突)が優先です。
少し変えてみよう

test_27〜test_32 で、新しい線、期限が切れた線、wall_enabled=False を比べましょう。岩の半径5+壁の半径1なので、ぶつかるときの中心は y=44 です。0.1 px 進んで当たり、残りの0.1 px 戻り、はなすための0.01 px も引くので、y=43.89 になることを計算してみましょう。

04_rocks.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(396行)
04_rocks.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.
"""014 第4段階。独立実行。日本語の解説はLESSON.md。"""
from dataclasses import dataclass, field, asdict
from copy import deepcopy
import math, json, hashlib

DT = 1 / 120       # 物理の1刻み。入力は30Hzなので4回進める。
SPEED = 42.0       # px/s。斜めも同じ速さ。
PLAYER_R = 3.0
BOUNDS = (12., 16., 180., 120.)
Vec = tuple[float, float]
START = (24., 96.)
DUST = ((40.,80.), (56.,80.), (72.,80.), (88.,80.), (120.,48.), (136.,48.))

LIFE = 2.4
GRACE = .18
WALL_R = 1.0

ROCK_R = 5.0

def add(a,b):return a[0]+b[0],a[1]+b[1]


def sub(a,b):return a[0]-b[0],a[1]-b[1]


def mul(a,k):return a[0]*k,a[1]*k


def dot(a,b):return a[0]*b[0]+a[1]*b[1]


def norm(a):
    d=math.hypot(*a)
    return mul(a,1/d) if d>1e-12 else (0.,0.)


def closest(p,a,b):
    v=sub(b,a);den=dot(v,v)
    t=max(0.,min(1.,dot(sub(p,a),v)/den)) if den>1e-12 else 0.
    return add(a,mul(v,t))


def distance(p,a,b):return math.dist(p,closest(p,a,b))


def sweep_circle(p,d,c,r):
    q=sub(p,c);aa=dot(d,d);cc=dot(q,q)-r*r
    if cc<=1e-8:
        n=norm(q)
        if dot(d,n)<-1e-10:return 0.,n
        return None
    if aa<1e-16:return None
    bb=2*dot(q,d);disc=bb*bb-4*aa*cc
    if disc<0:return None
    t=(-bb-math.sqrt(max(0.,disc)))/(2*aa)
    if -1e-9<=t<=1+1e-9:
        t=max(0.,min(1.,t));n=norm(sub(add(p,mul(d,t)),c))
        if dot(d,n)<-1e-10:return t,n
    return None


def sweep_capsule(p,d,a,b,r):
    """Earliest hit against a radius-expanded segment; endpoints included."""
    hits=[];v=sub(b,a);length=math.hypot(*v)
    for c in (a,b):
        hit=sweep_circle(p,d,c,r)
        if hit:hits.append(hit)
    if length>1e-12:
        u=mul(v,1/length);n=(-u[1],u[0]);q=sub(p,a);s=dot(q,n);ds=dot(d,n)
        for side in (-1,1):
            if abs(ds)<1e-12:continue
            t=(side*r-s)/ds
            if -1e-9<=t<=1+1e-9 and ds*side<0:
                x=dot(add(q,mul(d,t)),u)
                if -1e-9<=x<=length+1e-9:hits.append((max(0.,t),mul(n,side)))
        if abs(s)<r and 0<=dot(q,u)<=length:
            nn=mul(n,1 if s>=0 else -1)
            if dot(d,nn)<-1e-10:hits.append((0.,nn))
    return min(hits,key=lambda h:(h[0],h[1])) if hits else None


@dataclass
class Trail:
    a:Vec
    b:Vec
    born:float
    left:bool=False


@dataclass
class Rock:
    pos:Vec
    vel:Vec


@dataclass
class State:
    level:int
    pos:Vec
    dust:list[Vec]
    rocks:list[Rock]
    trails:list[Trail]=field(default_factory=list)
    age:float=0.
    status:str='playing'
    collected:int=0
    collisions:int=0
    blocks:int=0
    reason:str=''
    collision_point:Vec|None=None
    wall_enabled:bool=True
    swept_distance:float=0.
    moving_distance:float=0.
    max_trails:int=0
    last_block:float=-10.


def new_state(level=1,wall_enabled=True):
    if level != 1: raise ValueError("This tutorial keeps original B only")
    return State(1,START,list(DUST),[Rock((104.,24.),(0.,18.))],wall_enabled=wall_enabled)

def boundary_hits(p,d,r):
    x0,y0,x1,y1=BOUNDS;out=[]
    for axis,lo,hi in ((0,x0+r,x1-r),(1,y0+r,y1-r)):
        if d[axis]<-1e-12 and p[axis]+d[axis]<=lo:
            n=(1.,0.) if axis==0 else (0.,1.);out.append(((lo-p[axis])/d[axis],n))
        if d[axis]>1e-12 and p[axis]+d[axis]>=hi:
            n=(-1.,0.) if axis==0 else (0.,-1.);out.append(((hi-p[axis])/d[axis],n))
    return out


def first_hit(p,d,r,trails=()):
    hits=boundary_hits(p,d,r)
    for t in trails:
        h=sweep_capsule(p,d,t.a,t.b,r+WALL_R)
        if h:hits.append(h)
    if not hits:return None
    tt=min(h[0] for h in hits)
    ns=sorted(set(h[1] for h in hits if abs(h[0]-tt)<1e-7))
    # Equal-time corner normals are combined, avoiding scan-order dependence.
    n=norm((sum(v[0] for v in ns),sum(v[1] for v in ns)))
    if n==(0.,0.):n=ns[0]
    return max(0.,min(1.,tt)),n


def append_trail(s,a,b):
    """At most 2 px per timed cell; collinear cells are drawn continuously.

    A current cell is extended only while its length is <=2 px and its owner
    has not left. Its own birth time is retained, never refreshed by extension.
    At 42 px/s this coarsens expiry by at most 0.048 seconds, documented in UI.
    """
    if math.dist(a,b)<1e-9:return
    last=s.trails[-1] if s.trails else None
    if last and not last.left and math.dist(last.b,a)<1e-7 and math.dist(last.a,b)<=2.00001:
        u=sub(last.b,last.a);v=sub(b,last.b)
        if abs(u[0]*v[1]-u[1]*v[0])<1e-7 and dot(u,v)>=0:
            last.b=b;return
    s.trails.append(Trail(a,b,s.age))


def tick(s,axis=(0.,0.),clean=False,dt=DT):
    if s.status!='playing':return
    if not (math.isfinite(dt) and 0<dt<=DT+1e-10):raise ValueError('Use fixed substeps <= 1/120 second')
    if not all(math.isfinite(v) for v in axis):raise ValueError('Non-finite input')
    s.age+=dt
    s.trails=[t for t in s.trails if s.age-t.born<LIFE-1e-9]
    for t in s.trails:
        if not t.left and distance(s.pos,t.a,t.b)>PLAYER_R+WALL_R+.02:t.left=True
    active=[t for t in s.trails if t.left and s.age-t.born>=GRACE] if s.wall_enabled else []
    d=mul(norm(axis),SPEED*dt);old=s.pos;hit=first_hit(old,d,PLAYER_R,active)
    if hit:
        frac,n=hit;s.pos=add(add(old,mul(d,frac)),mul(n,.001))
        if s.age-s.last_block>.2:s.blocks+=1;s.last_block=s.age
        s.reason='edge' if any(abs(t-frac)<1e-7 for t,n in boundary_hits(old,d,PLAYER_R)) else 'trail'
    else:s.pos=add(old,d)
    moved=math.dist(old,s.pos);s.moving_distance+=moved
    if clean:
        remaining=[p for p in s.dust if distance(p,old,s.pos)>5.+1e-9]
        s.collected+=len(s.dust)-len(remaining);s.dust=remaining
        if moved>1e-8:append_trail(s,old,s.pos);s.swept_distance+=moved
    # The new trail already reflects rocks this tick. Rock/player uses relative
    # swept circles, so even crossing between endpoints cannot skip a collision.
    for rock in s.rocks:
        remaining=dt;elapsed=0.
        for _ in range(4):
            if remaining<1e-10:break
            p=rock.pos;delta=mul(rock.vel,remaining)
            hit=first_hit(p,delta,ROCK_R,s.trails if s.wall_enabled else ())
            fraction=hit[0] if hit else 1.
            rd=mul(delta,fraction)
            player_start=add(old,mul(sub(s.pos,old),elapsed/dt))
            player_delta=mul(sub(s.pos,old),remaining*fraction/dt)
            contact=sweep_circle(sub(p,player_start),sub(rd,player_delta),(0.,0.),ROCK_R+PLAYER_R)
            if contact or math.dist(p,player_start)<ROCK_R+PLAYER_R-1e-6:
                s.status='collision';s.reason='rock';s.collision_point=add(p,mul(rd,contact[0] if contact else 0.));s.collisions+=1
            rock.pos=add(p,rd)
            if hit:
                _,n=hit;rock.pos=add(rock.pos,mul(n,.01));rock.vel=sub(rock.vel,mul(n,2*dot(rock.vel,n)))
            spent=remaining*fraction;elapsed+=spent;remaining-=spent
            if not hit:break
        # If 4 contacts occur, omit this substep's tiny remainder; never loop.
    s.max_trails=max(s.max_trails,len(s.trails))
    if not s.dust and s.status=='playing':s.status='clear';s.reason='clear'


class Session:
    """30Hzの入力から、120Hzの物理を4回呼ぶ。"""
    def __init__(self): self.reset()
    def reset(self): self.state = new_state()
    def frame(self, axis=(0.,0.), clean=False, edge=None):
        if edge == 'restart': self.reset(); return
        for _ in range(4): tick(self.state, axis, clean)

STAGE = 4
STAGE_TITLE = 'IMMEDIATE ROCK REFLECTION'
CONTROLS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'SPACE', 'R')
DEMO_RUNS = ((11, ('UP', 'SPACE')), (79, ('RIGHT', 'SPACE')), (1, ('RIGHT', 'UP', 'SPACE')), (22, ('UP', 'SPACE')), (1, ('LEFT', 'UP', 'SPACE')), (7, ('LEFT', 'SPACE')))

class Controller:
    def __init__(self):
        self.session = Session()
        self.title = STAGE == 5
    @property
    def state(self): return self.session.state
    def tick(self, pressed=(), held=()):
        pressed = set(pressed); held = set(held)
        if self.title:
            if 'SPACE' in pressed: self.title = False; self.session.reset()
            return
        edge = None
        if 'R' in pressed: edge = 'restart'
        elif STAGE == 5 and 'P' in pressed: edge = 'pause'
        elif STAGE == 5 and 'Z' in pressed: edge = 'rewind'
        axis = (int('RIGHT' in held)-int('LEFT' in held),
                int('DOWN' in held)-int('UP' in held))
        self.session.frame(axis, 'SPACE' in held, edge)
    def snapshot(self):
        return {'state': asdict(self.state), 'title': self.title,
                'paused': getattr(self.session,'paused',False),
                'rewinds': getattr(self.session,'rewinds',0),
                'history_hashes': [state_hash(s) for s in getattr(self.session,'history',[])],
                'hash': state_hash(self.state)}

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

def draw_board(pyxel, controller):
    """観察するだけ。ここで寿命・離脱・物理を更新しない。"""
    s = controller.state
    pyxel.cls(1)
    pyxel.text(12,10,f'STARLIGHT SWEEPER 014 / STEP {STAGE}',10)
    pyxel.text(12,25,STAGE_TITLE,7)
    ox, oy, scale = 0, 26, 3
    def point(p): return ox+p[0]*scale, oy+p[1]*scale
    def line(a,b,color): pyxel.line(*point(a),*point(b),color)
    def dashed(a,b,color):
        length=math.dist(a,b)
        if length<1e-5:return
        for j in range(0,int(length)+1,3):
            line(add(a,mul(sub(b,a),j/length)),add(a,mul(sub(b,a),min(1,(j+1.4)/length))),color)
    pyxel.rect(*point((12,16)),504,312,0)
    pyxel.rectb(*point((12,16)),505,313,5)
    for x in range(18,180,12):
        for y in range(22,120,12):pyxel.pset(*point((x,y)),1)
    for t in getattr(s,'trails',[]):
        remain=LIFE-(s.age-t.born)
        safe=not getattr(t,'left',False) or s.age-t.born<GRACE
        color=10 if remain>1.2 else 9 if remain>.5 else 5
        if safe or remain<.5:dashed(t.a,t.b,color)
        else:
            line(t.a,t.b,color)
            line(add(t.a,(0,.6)),add(t.b,(0,.6)),color)
            pyxel.pset(*point(add(t.a,(0,-1.1))),7)
    for p in s.dust:
        x,y=point(p);pyxel.line(x-5,y,x+5,y,6);pyxel.line(x,y-5,x,y+5,6)
    for r in getattr(s,'rocks',[]):
        pyxel.circ(*point(r.pos),15,13);pyxel.circb(*point(r.pos),15,7)
        end=add(r.pos,mul(norm(r.vel),10));line(r.pos,end,8)
        v=norm(r.vel);tip=add(end,mul(v,-3));side=(-v[1]*2,v[0]*2)
        line(end,add(tip,side),8);line(end,sub(tip,side),8)
    pyxel.circ(*point(s.pos),9,7);pyxel.circ(*point(s.pos),6,12)
    if getattr(s,'collision_point',None):pyxel.circb(*point(s.collision_point),22,8)
    active=sum(bool(getattr(t,'left',False) and s.age-t.born>=GRACE) for t in getattr(s,'trails',[])) if STAGE>=2 else 0
    info=[('CASE','B / CROSSING ROCK'),('STATUS',s.status.upper()),('AGE',f'{s.age:.3f} s'),
          ('DUST',f'{s.collected} / 6'),('POSITION',f'{s.pos[0]:.2f}, {s.pos[1]:.2f}'),('SPEED','42 px/s'),('BLOCKS',str(s.blocks))]
    if STAGE>=2:info += [('CELLS',str(len(s.trails))),('LIFE','2.4 s'),('OWNER WALLS',str(active)),('GRACE','0.18 s' if STAGE>=3 else 'not used yet')]
    if STAGE>=4:info += [('ROCK VY',f'{s.rocks[0].vel[1]:.2f} px/s')]
    if STAGE==5:info += [('HISTORY',str(len(controller.session.history))),('REWINDS',str(controller.session.rewinds))]
    for n,(key,value) in enumerate(info):
        pyxel.text(557,58+n*20,key,13);pyxel.text(650,58+n*20,value,7)
    pyxel.text(12,406,'ARROWS: move    SPACE (hold): sweep    R: reset    ESC: quit',7)
    notes={1:'LAB: sweep dust only. No trails, self walls, or rocks.',
           2:'LAB: finite trail cells only. They do not block you yet.',
           3:'LAB: owner wall + exit grace. Original B rock is still omitted.',
           4:'Original B physics: new trails reflect the rock immediately.',
           5:'Original B + title / pause / rewind. P: pause   Z: rewind 1 second'}
    pyxel.text(12,423,notes[STAGE],10)
    pyxel.text(12,440,'CYAN CROSS: dust   BLUE: player   GRAY: rock   DASHED: owner-safe / expiring',6)
    pyxel.text(12,456,'30 Hz input / 120 Hz physics. Drawing never advances the world.',13)
    if controller.title:
        pyxel.rect(75,148,414,96,1);pyxel.rectb(75,148,414,96,10)
        pyxel.text(95,168,'STARLIGHT SWEEPER / CASE B',10)
        pyxel.text(95,190,'Sweep across first, then travel to the upper dust.',7)
        pyxel.text(95,219,'SPACE: start (holding it sweeps on the next frame)',10)
    elif getattr(controller.session,'paused',False):
        pyxel.rect(36,43,505,22,1);pyxel.rectb(36,43,505,22,10)
        pyxel.text(48,51,'PAUSED: rock / lifetime / history all frozen',10)
    elif s.status!='playing':
        pyxel.rect(36,43,505,22,1);pyxel.rectb(36,43,505,22,10)
        pyxel.text(48,51,'CLEAR! / R: reset' if s.status=='clear' else 'ROCK CONTACT / R: reset',10)

def demo_keys(controller, frame_number):
    """原作B成功入力をrun-length化。状態変更やテレポートはしない。"""
    if STAGE==5:
        if frame_number==1:return ('SPACE',)
        frame_number-=1
    for count,keys in DEMO_RUNS:
        if frame_number<=count:return keys
        frame_number-=count
    return ()

def main(headless=False, frames=180, demo=False, replay=None, screenshot=None, report=None):
    from pathlib import Path
    import pyxel  # モデルのimport、標準ライブラリの試験には不要。
    controller=Controller();frame_number=0;old_held=set();changes=[]
    codes={name:getattr(pyxel,'KEY_'+name) for name in CONTROLS}
    screenshot=str(Path(screenshot).resolve()) if screenshot else None
    report=str(Path(report).resolve()) if report else None
    def update():
        nonlocal frame_number,old_held
        frame_number+=1
        if headless:
            held=set(replay[frame_number-1]) if replay is not None and frame_number<=len(replay) else set(demo_keys(controller,frame_number) 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)}
        previous=controller.state.status
        controller.tick(pressed,held)
        if pressed or previous!=controller.state.status:
            changes.append({'frame':frame_number,'pressed':sorted(pressed),'held':sorted(held),'status':controller.state.status,'hash':state_hash(controller.state)})
    def draw():
        draw_board(pyxel,controller)
        if headless and frame_number>=frames:
            if screenshot:pyxel.screenshot(screenshot,scale=1)
            if report:Path(report).write_text(json.dumps({'label':'Actual Pyxel headless; synthetic input, not human play',
                'stage':STAGE,'requested_frames':frames,'actual_frames':frame_number,'demo':demo,'changes':changes,
                'snapshot':controller.snapshot()},ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            pyxel.quit()
    pyxel.init(790,477,title=f'Starlight Sweeper / 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; current directory relative')
    parser.add_argument('--report',help='UTF-8 JSON output, headless only')
    args=parser.parse_args()
    if not 12<=args.frames<=100000:parser.error('Use 12 through 100000 frames')
    if not args.headless and (args.demo or args.replay_json or args.screenshot or args.report):parser.error('QA 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 で開始・P で一時停止・Z で巻き戻しを加えます。入力の優先順位は「R → P → 一時停止中 → Z → 終わりの判定 → ふつうの更新」です。ふつうの入力の前に状態を deepcopy し、最大150入力フレーム分を保存します。Z で30フレーム前(それより短ければ開始時)に戻り、使った履歴は消します。位置だけでなく、ごみ、線の born/left、岩の速さと状態も戻します。

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

05_rewind.py / 注目する部分
        if edge=='rewind':
            if self.history:
                take=min(30,len(self.history));self.state=deepcopy(self.history[-take]);del self.history[-take:];self.rewinds+=1
            return

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_rewind.py
macOS / Linux
.venv/bin/python 05_rewind.py
画面で見ることタイトルを SPACE でぬけたフレームは、時刻0のままです。SPACE を押し続ければ、次のフレームから掃除します。P で、位置・岩・寿命・履歴が全部止まり、一時停止中の Z は効きません。45フレーム進めて Z を押すと、15フレーム目に戻り、履歴は15件、巻き戻した回数は1になります。R と P を同時に押すとリセットです。
  • R/P/Z と、ふつうの更新の優先順位を追える
  • deepcopy で、過去を別々の状態として残す
  • 一時停止中は、寿命もふくめて全部の時間が止まる
  • 元のゲームの状況 B で残した機能と、省いた画面の機能を区別できる
つまずきやすいところ履歴に state そのものを入れると、あとの更新で過去まで変わってしまいます。位置だけを戻すと、掃除したごみや壁の残り時間と合いません。一時停止中に年齢だけを進めると、再開したときに壁が消えてしまいます。SPACE を離すまで待つ処理は元のゲームの014にはないので、別のゲームから足してはいけません。
少し変えてみよう

test_33〜test_39 で、短い履歴、30フレームの巻き戻し、一時停止中の Z、R/P/Z の同時入力、成功したあとの巻き戻しを確かめましょう。「ふつうの入力45回 → P → Z → P → Z」では、どの時点に戻るか答えてみます(一時停止中の Z は無視され、再開したあとの Z で15フレーム目に戻ります)。

05_rewind.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(408行)
05_rewind.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.
"""014 第5段階。独立実行。日本語の解説はLESSON.md。"""
from dataclasses import dataclass, field, asdict
from copy import deepcopy
import math, json, hashlib

DT = 1 / 120       # 物理の1刻み。入力は30Hzなので4回進める。
SPEED = 42.0       # px/s。斜めも同じ速さ。
PLAYER_R = 3.0
BOUNDS = (12., 16., 180., 120.)
Vec = tuple[float, float]
START = (24., 96.)
DUST = ((40.,80.), (56.,80.), (72.,80.), (88.,80.), (120.,48.), (136.,48.))

LIFE = 2.4
GRACE = .18
WALL_R = 1.0

ROCK_R = 5.0

def add(a,b):return a[0]+b[0],a[1]+b[1]


def sub(a,b):return a[0]-b[0],a[1]-b[1]


def mul(a,k):return a[0]*k,a[1]*k


def dot(a,b):return a[0]*b[0]+a[1]*b[1]


def norm(a):
    d=math.hypot(*a)
    return mul(a,1/d) if d>1e-12 else (0.,0.)


def closest(p,a,b):
    v=sub(b,a);den=dot(v,v)
    t=max(0.,min(1.,dot(sub(p,a),v)/den)) if den>1e-12 else 0.
    return add(a,mul(v,t))


def distance(p,a,b):return math.dist(p,closest(p,a,b))


def sweep_circle(p,d,c,r):
    q=sub(p,c);aa=dot(d,d);cc=dot(q,q)-r*r
    if cc<=1e-8:
        n=norm(q)
        if dot(d,n)<-1e-10:return 0.,n
        return None
    if aa<1e-16:return None
    bb=2*dot(q,d);disc=bb*bb-4*aa*cc
    if disc<0:return None
    t=(-bb-math.sqrt(max(0.,disc)))/(2*aa)
    if -1e-9<=t<=1+1e-9:
        t=max(0.,min(1.,t));n=norm(sub(add(p,mul(d,t)),c))
        if dot(d,n)<-1e-10:return t,n
    return None


def sweep_capsule(p,d,a,b,r):
    """Earliest hit against a radius-expanded segment; endpoints included."""
    hits=[];v=sub(b,a);length=math.hypot(*v)
    for c in (a,b):
        hit=sweep_circle(p,d,c,r)
        if hit:hits.append(hit)
    if length>1e-12:
        u=mul(v,1/length);n=(-u[1],u[0]);q=sub(p,a);s=dot(q,n);ds=dot(d,n)
        for side in (-1,1):
            if abs(ds)<1e-12:continue
            t=(side*r-s)/ds
            if -1e-9<=t<=1+1e-9 and ds*side<0:
                x=dot(add(q,mul(d,t)),u)
                if -1e-9<=x<=length+1e-9:hits.append((max(0.,t),mul(n,side)))
        if abs(s)<r and 0<=dot(q,u)<=length:
            nn=mul(n,1 if s>=0 else -1)
            if dot(d,nn)<-1e-10:hits.append((0.,nn))
    return min(hits,key=lambda h:(h[0],h[1])) if hits else None


@dataclass
class Trail:
    a:Vec
    b:Vec
    born:float
    left:bool=False


@dataclass
class Rock:
    pos:Vec
    vel:Vec


@dataclass
class State:
    level:int
    pos:Vec
    dust:list[Vec]
    rocks:list[Rock]
    trails:list[Trail]=field(default_factory=list)
    age:float=0.
    status:str='playing'
    collected:int=0
    collisions:int=0
    blocks:int=0
    reason:str=''
    collision_point:Vec|None=None
    wall_enabled:bool=True
    swept_distance:float=0.
    moving_distance:float=0.
    max_trails:int=0
    last_block:float=-10.


def new_state(level=1,wall_enabled=True):
    if level != 1: raise ValueError("This tutorial keeps original B only")
    return State(1,START,list(DUST),[Rock((104.,24.),(0.,18.))],wall_enabled=wall_enabled)

def boundary_hits(p,d,r):
    x0,y0,x1,y1=BOUNDS;out=[]
    for axis,lo,hi in ((0,x0+r,x1-r),(1,y0+r,y1-r)):
        if d[axis]<-1e-12 and p[axis]+d[axis]<=lo:
            n=(1.,0.) if axis==0 else (0.,1.);out.append(((lo-p[axis])/d[axis],n))
        if d[axis]>1e-12 and p[axis]+d[axis]>=hi:
            n=(-1.,0.) if axis==0 else (0.,-1.);out.append(((hi-p[axis])/d[axis],n))
    return out


def first_hit(p,d,r,trails=()):
    hits=boundary_hits(p,d,r)
    for t in trails:
        h=sweep_capsule(p,d,t.a,t.b,r+WALL_R)
        if h:hits.append(h)
    if not hits:return None
    tt=min(h[0] for h in hits)
    ns=sorted(set(h[1] for h in hits if abs(h[0]-tt)<1e-7))
    # Equal-time corner normals are combined, avoiding scan-order dependence.
    n=norm((sum(v[0] for v in ns),sum(v[1] for v in ns)))
    if n==(0.,0.):n=ns[0]
    return max(0.,min(1.,tt)),n


def append_trail(s,a,b):
    """At most 2 px per timed cell; collinear cells are drawn continuously.

    A current cell is extended only while its length is <=2 px and its owner
    has not left. Its own birth time is retained, never refreshed by extension.
    At 42 px/s this coarsens expiry by at most 0.048 seconds, documented in UI.
    """
    if math.dist(a,b)<1e-9:return
    last=s.trails[-1] if s.trails else None
    if last and not last.left and math.dist(last.b,a)<1e-7 and math.dist(last.a,b)<=2.00001:
        u=sub(last.b,last.a);v=sub(b,last.b)
        if abs(u[0]*v[1]-u[1]*v[0])<1e-7 and dot(u,v)>=0:
            last.b=b;return
    s.trails.append(Trail(a,b,s.age))


def tick(s,axis=(0.,0.),clean=False,dt=DT):
    if s.status!='playing':return
    if not (math.isfinite(dt) and 0<dt<=DT+1e-10):raise ValueError('Use fixed substeps <= 1/120 second')
    if not all(math.isfinite(v) for v in axis):raise ValueError('Non-finite input')
    s.age+=dt
    s.trails=[t for t in s.trails if s.age-t.born<LIFE-1e-9]
    for t in s.trails:
        if not t.left and distance(s.pos,t.a,t.b)>PLAYER_R+WALL_R+.02:t.left=True
    active=[t for t in s.trails if t.left and s.age-t.born>=GRACE] if s.wall_enabled else []
    d=mul(norm(axis),SPEED*dt);old=s.pos;hit=first_hit(old,d,PLAYER_R,active)
    if hit:
        frac,n=hit;s.pos=add(add(old,mul(d,frac)),mul(n,.001))
        if s.age-s.last_block>.2:s.blocks+=1;s.last_block=s.age
        s.reason='edge' if any(abs(t-frac)<1e-7 for t,n in boundary_hits(old,d,PLAYER_R)) else 'trail'
    else:s.pos=add(old,d)
    moved=math.dist(old,s.pos);s.moving_distance+=moved
    if clean:
        remaining=[p for p in s.dust if distance(p,old,s.pos)>5.+1e-9]
        s.collected+=len(s.dust)-len(remaining);s.dust=remaining
        if moved>1e-8:append_trail(s,old,s.pos);s.swept_distance+=moved
    # The new trail already reflects rocks this tick. Rock/player uses relative
    # swept circles, so even crossing between endpoints cannot skip a collision.
    for rock in s.rocks:
        remaining=dt;elapsed=0.
        for _ in range(4):
            if remaining<1e-10:break
            p=rock.pos;delta=mul(rock.vel,remaining)
            hit=first_hit(p,delta,ROCK_R,s.trails if s.wall_enabled else ())
            fraction=hit[0] if hit else 1.
            rd=mul(delta,fraction)
            player_start=add(old,mul(sub(s.pos,old),elapsed/dt))
            player_delta=mul(sub(s.pos,old),remaining*fraction/dt)
            contact=sweep_circle(sub(p,player_start),sub(rd,player_delta),(0.,0.),ROCK_R+PLAYER_R)
            if contact or math.dist(p,player_start)<ROCK_R+PLAYER_R-1e-6:
                s.status='collision';s.reason='rock';s.collision_point=add(p,mul(rd,contact[0] if contact else 0.));s.collisions+=1
            rock.pos=add(p,rd)
            if hit:
                _,n=hit;rock.pos=add(rock.pos,mul(n,.01));rock.vel=sub(rock.vel,mul(n,2*dot(rock.vel,n)))
            spent=remaining*fraction;elapsed+=spent;remaining-=spent
            if not hit:break
        # If 4 contacts occur, omit this substep's tiny remainder; never loop.
    s.max_trails=max(s.max_trails,len(s.trails))
    if not s.dust and s.status=='playing':s.status='clear';s.reason='clear'


class Session:
    """30 Hz frame boundary, including immutable snapshots for 1 s rewind."""
    def __init__(self,level=1,wall_enabled=True):
        self.level=level;self.wall_enabled=wall_enabled;self.reset()
    def reset(self):
        self.state=new_state(self.level,self.wall_enabled);self.paused=False;self.history=[];self.rewinds=0
    def frame(self,axis=(0.,0.),clean=False,edge=None):
        if edge=='restart':self.reset();return
        if edge=='pause':self.paused=not self.paused;return
        if self.paused:return
        if edge=='rewind':
            if self.history:
                take=min(30,len(self.history));self.state=deepcopy(self.history[-take]);del self.history[-take:];self.rewinds+=1
            return
        if self.state.status!='playing':return
        self.history.append(deepcopy(self.state))
        if len(self.history)>150:self.history.pop(0)
        for _ in range(4):tick(self.state,axis,clean)


STAGE = 5
STAGE_TITLE = 'TITLE + PAUSE + EXACT REWIND'
CONTROLS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'SPACE', 'R', 'P', 'Z')
DEMO_RUNS = ((11, ('UP', 'SPACE')), (79, ('RIGHT', 'SPACE')), (1, ('RIGHT', 'UP', 'SPACE')), (22, ('UP', 'SPACE')), (1, ('LEFT', 'UP', 'SPACE')), (7, ('LEFT', 'SPACE')))

class Controller:
    def __init__(self):
        self.session = Session()
        self.title = STAGE == 5
    @property
    def state(self): return self.session.state
    def tick(self, pressed=(), held=()):
        pressed = set(pressed); held = set(held)
        if self.title:
            if 'SPACE' in pressed: self.title = False; self.session.reset()
            return
        edge = None
        if 'R' in pressed: edge = 'restart'
        elif STAGE == 5 and 'P' in pressed: edge = 'pause'
        elif STAGE == 5 and 'Z' in pressed: edge = 'rewind'
        axis = (int('RIGHT' in held)-int('LEFT' in held),
                int('DOWN' in held)-int('UP' in held))
        self.session.frame(axis, 'SPACE' in held, edge)
    def snapshot(self):
        return {'state': asdict(self.state), 'title': self.title,
                'paused': getattr(self.session,'paused',False),
                'rewinds': getattr(self.session,'rewinds',0),
                'history_hashes': [state_hash(s) for s in getattr(self.session,'history',[])],
                'hash': state_hash(self.state)}

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

def draw_board(pyxel, controller):
    """観察するだけ。ここで寿命・離脱・物理を更新しない。"""
    s = controller.state
    pyxel.cls(1)
    pyxel.text(12,10,f'STARLIGHT SWEEPER 014 / STEP {STAGE}',10)
    pyxel.text(12,25,STAGE_TITLE,7)
    ox, oy, scale = 0, 26, 3
    def point(p): return ox+p[0]*scale, oy+p[1]*scale
    def line(a,b,color): pyxel.line(*point(a),*point(b),color)
    def dashed(a,b,color):
        length=math.dist(a,b)
        if length<1e-5:return
        for j in range(0,int(length)+1,3):
            line(add(a,mul(sub(b,a),j/length)),add(a,mul(sub(b,a),min(1,(j+1.4)/length))),color)
    pyxel.rect(*point((12,16)),504,312,0)
    pyxel.rectb(*point((12,16)),505,313,5)
    for x in range(18,180,12):
        for y in range(22,120,12):pyxel.pset(*point((x,y)),1)
    for t in getattr(s,'trails',[]):
        remain=LIFE-(s.age-t.born)
        safe=not getattr(t,'left',False) or s.age-t.born<GRACE
        color=10 if remain>1.2 else 9 if remain>.5 else 5
        if safe or remain<.5:dashed(t.a,t.b,color)
        else:
            line(t.a,t.b,color)
            line(add(t.a,(0,.6)),add(t.b,(0,.6)),color)
            pyxel.pset(*point(add(t.a,(0,-1.1))),7)
    for p in s.dust:
        x,y=point(p);pyxel.line(x-5,y,x+5,y,6);pyxel.line(x,y-5,x,y+5,6)
    for r in getattr(s,'rocks',[]):
        pyxel.circ(*point(r.pos),15,13);pyxel.circb(*point(r.pos),15,7)
        end=add(r.pos,mul(norm(r.vel),10));line(r.pos,end,8)
        v=norm(r.vel);tip=add(end,mul(v,-3));side=(-v[1]*2,v[0]*2)
        line(end,add(tip,side),8);line(end,sub(tip,side),8)
    pyxel.circ(*point(s.pos),9,7);pyxel.circ(*point(s.pos),6,12)
    if getattr(s,'collision_point',None):pyxel.circb(*point(s.collision_point),22,8)
    active=sum(bool(getattr(t,'left',False) and s.age-t.born>=GRACE) for t in getattr(s,'trails',[])) if STAGE>=2 else 0
    info=[('CASE','B / CROSSING ROCK'),('STATUS',s.status.upper()),('AGE',f'{s.age:.3f} s'),
          ('DUST',f'{s.collected} / 6'),('POSITION',f'{s.pos[0]:.2f}, {s.pos[1]:.2f}'),('SPEED','42 px/s'),('BLOCKS',str(s.blocks))]
    if STAGE>=2:info += [('CELLS',str(len(s.trails))),('LIFE','2.4 s'),('OWNER WALLS',str(active)),('GRACE','0.18 s' if STAGE>=3 else 'not used yet')]
    if STAGE>=4:info += [('ROCK VY',f'{s.rocks[0].vel[1]:.2f} px/s')]
    if STAGE==5:info += [('HISTORY',str(len(controller.session.history))),('REWINDS',str(controller.session.rewinds))]
    for n,(key,value) in enumerate(info):
        pyxel.text(557,58+n*20,key,13);pyxel.text(650,58+n*20,value,7)
    pyxel.text(12,406,'ARROWS: move    SPACE (hold): sweep    R: reset    ESC: quit',7)
    notes={1:'LAB: sweep dust only. No trails, self walls, or rocks.',
           2:'LAB: finite trail cells only. They do not block you yet.',
           3:'LAB: owner wall + exit grace. Original B rock is still omitted.',
           4:'Original B physics: new trails reflect the rock immediately.',
           5:'Original B + title / pause / rewind. P: pause   Z: rewind 1 second'}
    pyxel.text(12,423,notes[STAGE],10)
    pyxel.text(12,440,'CYAN CROSS: dust   BLUE: player   GRAY: rock   DASHED: owner-safe / expiring',6)
    pyxel.text(12,456,'30 Hz input / 120 Hz physics. Drawing never advances the world.',13)
    if controller.title:
        pyxel.rect(75,148,414,96,1);pyxel.rectb(75,148,414,96,10)
        pyxel.text(95,168,'STARLIGHT SWEEPER / CASE B',10)
        pyxel.text(95,190,'Sweep across first, then travel to the upper dust.',7)
        pyxel.text(95,219,'SPACE: start (holding it sweeps on the next frame)',10)
    elif getattr(controller.session,'paused',False):
        pyxel.rect(36,43,505,22,1);pyxel.rectb(36,43,505,22,10)
        pyxel.text(48,51,'PAUSED: rock / lifetime / history all frozen',10)
    elif s.status!='playing':
        pyxel.rect(36,43,505,22,1);pyxel.rectb(36,43,505,22,10)
        pyxel.text(48,51,'CLEAR! / R: reset' if s.status=='clear' else 'ROCK CONTACT / R: reset',10)

def demo_keys(controller, frame_number):
    """原作B成功入力をrun-length化。状態変更やテレポートはしない。"""
    if STAGE==5:
        if frame_number==1:return ('SPACE',)
        frame_number-=1
    for count,keys in DEMO_RUNS:
        if frame_number<=count:return keys
        frame_number-=count
    return ()

def main(headless=False, frames=180, demo=False, replay=None, screenshot=None, report=None):
    from pathlib import Path
    import pyxel  # モデルのimport、標準ライブラリの試験には不要。
    controller=Controller();frame_number=0;old_held=set();changes=[]
    codes={name:getattr(pyxel,'KEY_'+name) for name in CONTROLS}
    screenshot=str(Path(screenshot).resolve()) if screenshot else None
    report=str(Path(report).resolve()) if report else None
    def update():
        nonlocal frame_number,old_held
        frame_number+=1
        if headless:
            held=set(replay[frame_number-1]) if replay is not None and frame_number<=len(replay) else set(demo_keys(controller,frame_number) 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)}
        previous=controller.state.status
        controller.tick(pressed,held)
        if pressed or previous!=controller.state.status:
            changes.append({'frame':frame_number,'pressed':sorted(pressed),'held':sorted(held),'status':controller.state.status,'hash':state_hash(controller.state)})
    def draw():
        draw_board(pyxel,controller)
        if headless and frame_number>=frames:
            if screenshot:pyxel.screenshot(screenshot,scale=1)
            if report:Path(report).write_text(json.dumps({'label':'Actual Pyxel headless; synthetic input, not human play',
                'stage':STAGE,'requested_frames':frames,'actual_frames':frame_number,'demo':demo,'changes':changes,
                'snapshot':controller.snapshot()},ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            pyxel.quit()
    pyxel.init(790,477,title=f'Starlight Sweeper / 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; current directory relative')
    parser.add_argument('--report',help='UTF-8 JSON output, headless only')
    args=parser.parse_args()
    if not 12<=args.frames<=100000:parser.error('Use 12 through 100000 frames')
    if not args.headless and (args.demo or args.replay_json or args.screenshot or args.report):parser.error('QA 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

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

元のゲームの状況 B「横切る岩」(LEVELS[1])です。出発点 (24,96)、ごみ6つ、岩 (104,24)・速さ (0,18) px/秒。入力は1秒に30回、物理は120回です。プレイヤーは42 px/秒・半径3、岩は半径5、壁は半径1です。線分から5 px 以内を掃除し、区間は2 px、寿命は2.4秒です。自分にとって壁になるのは「4.02 px よりはなれた」と「生まれてから0.18秒以上」の両方を満たしたときです。岩は、新しい区間でもすぐにはね返ります。

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

比べる点5ステップの教材元の完成版
段階01 は掃除だけ、02 は期限つきの通れる線、03 は自分の壁(岩なし)、04 は元のゲームの状況 B の物理、05 は表紙・一時停止・巻き戻しです。すべての機能が最初からあります。
対象区画元のゲームの状況 B だけです。04/05 の state.level は1で、new_state はほかの面を受け付けません。A・B・C の3つの区画を、タイトルの左右で選びます。
入力方向・SPACE・R、05 で P/Z があります。タイトルは SPACE だけで始まります。さらに、タイトルでの左右、成功したあとの N、F12 があります。教材ではこれらと、--baseline のコマンドを省いています。
表示と素材790×477、文字(ASCII)と図形、確認用の数値です。説明は日本語の本文にあります。画像・文字データ・音は、動かすときに必要ありません。384×328、Noto をもとにした日本語の文字と独自の画面配置、録画の機能があります。
比較用機構04/05 のモデルは wall_enabled を持ってテストしますが、画面での切りかえや比較の画面は付けていません。--baseline で、壁を飾りに変えて比べる機能があります。
検証範囲新しく、標準ライブラリのテスト、元のゲームをもとにした fixture、実際の Pyxel で画面を出さずに作った入力で動かすテスト、ファイル単体での移動を確かめています。元のゲームの、これまでの動画やテストを、教材を新しく実行した証拠としては使っていません。

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

  1. model.py:LEVELS[0]/[2] を加えて、面の選択と、次の区画へ進む N を元どおりにしてみましょう。
  2. app.py:dashed と日本語の text を読み、権利の表示を残しながら、文字を取り入れてみましょう。
  3. model.py:first_hit の同時にふれた向きや、4回ふれたら残りの時間を打ち切る処理を、もっと多くの形の場合で考えてみましょう。
  4. experiment.py:comparison_inputs/run_comparison で、壁にぶつかるかどうかだけを変える比較の実験を読んでみましょう。
  5. solve.py:drive の8方向の目標点での操作と、success_inputs.json の決まった入力を比べてみましょう。
自分のゲームに変えるなら

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

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