完成サンプルをブラウザーで遊ぶ
この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。
完成版の主な操作:準備: 左右折線/Space折る、Enter出走。走行: 左右操舵/上下加減速、X準備確認
ボタンを押すと、ゲームの読み込みが始まります。
画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。
うまく表示されないとき
ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。
区間の切れ目ごとに分けて、速さと曲がりを計算する。
いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、5つのステップで作っていきます。どのステップのファイルもそれだけで動くので、動きを確かめてから次へ進みましょう。
- 必要な環境
- Python 3.11以上 / Pyxel 2.9.9
- 知っておくとよいこと
- if・for・関数・
tuple・辞書 /dataclassとreplaceは本文で説明します / 整数の割り算・整数で小数を表す方法(固定小数点)・JSON は本文で説明します - 教材に入っているもの
- ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
- 完成した教材の操作
- Enter/Z:準備から出走。キーを離して開始 / 上/下:02以降4刻加速/2刻減速。押し続ける / 左右:03以降、操舵q±16 / Space:04以降、準備でOPEN/A/Bの有限候補を巡回 / 1/2/3:04以降、準備でコースA/B/C / B:04以降、AのA折道だけ幅8/14比較 / P:走行中の停止/再開。離して再開 / R:同じ道で再走。コード再読込ではない / X:試走を捨てて準備へ。Undoではない / Esc:終了
- 難易度の目安
- 中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
- 学習順に迷ったら
- おすすめの学ぶ順番を見る
コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。
教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。
一刻0.1秒の、まっすぐな走行を作る
整数の状態・描画の時計とルールの時計・距離の終わり。
最初は、長さ120のまっすぐな道を速さ1で走る、小さな完成品を作ります。Enter または Z で出発し、キーを離すと走り始めます。画面は1秒に30回描きますが、ゲームの時計は3フレームで1刻、1秒に10刻です。State.tick は 0、1、2 と増える整数で、表示する秒数だけを tick/TICK_RATE で計算します。描いた回数や、実際の処理にかかった秒数を、距離に直接足しません。
State は frozen=True の dataclass です。今の区間 segment、その区間で進んだ距離 offset、速さ v を持ちます。01 の initial は v=1 で、そのあともずっと同じです。1刻ごとに offset が1つ進みます。区間の終わりなら segment を1つ増やし、offset を0に戻します。最後の区間を通り過ぎると outcome が won になり、そのあとの step は同じ State を返します。ゴールしたときの segment は区間の数と同じ値なので、そのまま road[segment] を読むことはできません(範囲の外)。
distance は、走り終えた区間の長さの合計に、今の offset を足します。ゴールしたあとも、全体の長さ total と同じ距離を返せます。描画で今のカーブを見せるときだけ、min(segment,len(road)-1) を使います。ルール上のゴールの位置を、むりやり最後の区間に戻さないことが大切です。at は描画で先を見るためのもので、マイナスの距離や、ゴールより先の問い合わせを、道の範囲に収めます。
まだ、加速、減速、ハンドル、カーブ、路肩への接触、折った道、ゴーストはありません。State には、次の段階で使う q や accel などの欄を先に用意していますが、01 では0のままです。開始画面の線は、つながり方を説明する図で、線を描いたピクセルの数が走る距離ではありません。P は時計を止め、R は同じ道を最初から走り直し、X はこの試走をやめて準備の画面に戻ります。どれもコードを読み直す操作ではありません。
下のコードは 01_distance_ticks.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
def distance(s,road):return sum(seg.length for seg in road[:s.segment])+s.offset実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 01_distance_ticks.py.venv/bin/python 01_distance_ticks.py- 1刻と、描画1回を区別する
- 区間と、区間の中で進んだ距離を分ける
- ゴールしたあとは、状態を変えない
draw の中で offset を増やすと、画面を描く回数で速さが変わってしまいます。update の phase が3になったときだけ、step を呼びます。ゴールしたあとの road[segment] は範囲の外です。def initial():return State(v=1) の1を2に変えてみましょう。保存 → Esc で終了 → 実行し直すと、TICK 10 で DIST は20になり、60刻でゴールします。確認したら1に戻します。R は、同じコードのまま状態を最初に戻すだけです。
このステップのコード全文(240行)
# MIT License
#
# Copyright (c) 2026
#
# 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.
#
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.
"""057 紙片のラリー / 第1段階。LESSON.md を参照。"""
STAGE=1
STAGE_NOTE='FIXED SPEED 1 / STRAIGHT 120 / NO ACCEL, STEER OR CONTACT'
"""057 deterministic integer rally. No renderer, clock, filesystem or random state."""
from dataclasses import dataclass, asdict, replace
import hashlib,json
RULE_VERSION='paper-rally-r1'
GENERATOR_VERSION='module-bank-g1'
ENV_VERSION='integer-10Hz-v1'
TICK_RATE=10
@dataclass(frozen=True)
class Segment:
length:int
curve:int
width:int
module:str
@dataclass(frozen=True)
class Level:
seed:str
title:str
modules:tuple
# Both alternatives connect the same ordered entry/exit sockets; no arbitrary geometry.
LEVELS=(Level('LESSON-STRAIGHT','直線練習',(('START',((120,0,14),),None),)),)
@dataclass(frozen=True)
class State:
tick:int=0
segment:int=0
offset:int=0
q:int=0
v:int=0
accel:int=0
brake:int=0
contacts:int=0
stopped:int=0
outcome:str='running'
def folds(level):return ('',)+tuple(name for name,_,folded in level.modules if folded is not None)
def course(level,fold='',wide=False):
if fold not in folds(level):raise ValueError('Unknown fold in finite bank')
if wide and (level.seed[0]!='A' or fold!='A'):raise ValueError('Width comparison is only A folded')
return tuple(Segment(d,c,14 if wide and name==fold else w,name) for name,opened,folded in level.modules for d,c,w in (folded if name==fold else opened))
def initial():return State(v=1)
def distance(s,road):return sum(seg.length for seg in road[:s.segment])+s.offset
def total(road):return sum(s.length for s in road)
def at(road,d):
d=max(0,min(d,total(road)-1))
for i,seg in enumerate(road):
if d<seg.length:return i,d,seg
d-=seg.length
return len(road)-1,road[-1].length-1,road[-1]
def step(s,road,action):
"""action=(steer -1/0/+1, throttle -1/0/+1); invalid actions consume no tick.
Straight-road scaffold: steering, curve drift and shoulder contact are absent.
"""
if s.outcome!='running':return s
if not isinstance(action,(tuple,list)) or len(action)!=2 or any(type(x)!=int or x not in (-1,0,1) for x in action):return s
v=s.v;ac=0;br=0
q=s.q;seg=s.segment;off=s.offset;touch=False
for _ in range(v):
part=road[seg]
off+=1
if off==part.length:
seg+=1;off=0
if seg==len(road):break
return State(s.tick+1,seg,off,q,1 if touch else v,ac,br,s.contacts+int(touch),s.stopped+int(v==0),'won' if seg==len(road) else 'running')
class App:
def __init__(self):
self.level=0
self.fold=''
self.wide=False
self.mode='prep'
pass
self.reset()
@property
def board(self):return LEVELS[self.level]
@property
def road(self):return course(self.board,self.fold,self.wide)
def reset(self):
self.s=initial()
self.phase=0
self.pause=False
self.gate=False
pass
def tick(self,action):
self.s=step(self.s,self.road,action)
pass
if self.s.outcome=='won':
self.mode='result'
pass
def control(self,pressed,held):
if self.gate:
if not held:self.gate=False
return
if 'R' in pressed:
self.reset();self.mode='race';self.gate=True;return
if 'P' in pressed and self.mode=='race':
self.pause=not self.pause;self.gate=True;return
if 'X' in pressed:
self.reset();self.mode='prep';self.gate=True;return
if self.pause:return
if self.mode=='prep':
pass
if 'RETURN' in pressed or 'Z' in pressed:
self.reset();self.mode='race';self.gate=True;return
return
if self.mode!='race':return
self.phase+=1
if self.phase==3:
self.phase=0
self.tick((0,0))
def draw(self,p):
s=self.s;road=self.road;d=distance(s,road)
p.cls(1)
p.text(12,10,'057 PAPER RALLY / LESSON '+str(STAGE),10)
p.text(12,28,self.board.seed+' / FOLD '+(self.fold or 'OPEN')+' / WIDTH '+('14 COMPARE' if self.wide else 'NORMAL'),6)
p.text(12,47,'MODE '+self.mode.upper()+(' / PAUSED' if self.pause else '')+' / TICK '+str(s.tick)+' / TIME '+format(s.tick/TICK_RATE,'.1f')+'s',7)
p.text(430,47,'DIST '+str(d)+' / '+str(total(road)),11)
if self.mode=='prep':
p.rect(12,76,676,210,7)
p.text(24,88,'FINITE MODULE BANK / SYMBOLIC SOCKET DIAGRAM',1)
dx=640//len(self.board.modules)
for i,(name,opened,folded) in enumerate(self.board.modules):
x=28+i*dx;end=x+dx-18;mid=(x+end)//2
if folded:
paths=(((x,180),(x+dx//4,130),(end-dx//4,130),(end,180)),((x,180),(mid,162),(end,180)))
for j,path in enumerate(paths):
color=8 if (j==1)==(self.fold==name) else 6
for a,b in zip(path,path[1:]):p.line(*a,*b,color)
p.line(mid,112,mid,216,9)
else:p.line(x,180,end,180,1)
p.circ(x,180,3,12);p.circ(end,180,3,12)
if i+1<len(self.board.modules):p.line(end,180,x+dx,180,1)
chosen=folded if self.fold==name else opened
p.text(x,229,'MODULE '+str(i+1),1)
p.text(x,247,'LEN '+str(sum(v[0] for v in chosen)),1)
p.text(12,302,'ENTER / Z START. ROAD LENGTH IS THE NUMBER, NOT DIAGRAM PIXELS.',10)
else:
# The perspective is illustrative; collision uses integer q and width.
p.rect(12,77,395,273,3);center=210
for y in range(331,85,-2):
ahead=(331-y)//2;_,_,seg=at(road,d+ahead);center+=seg.curve*.23
half=seg.width*4
p.rect(center-half,y,half*2,2,5)
p.rect(center-half,y,2,2,7);p.rect(center+half-2,y,2,2,7)
if (d+ahead)//6%2==0:p.rect(center,y,2,2,6)
pass
x=210+s.q/4;p.rect(x-5,316,10,20,10);p.rect(x-3,320,6,5,7)
i=min(s.segment,len(road)-1);seg=road[i]
p.text(430,87,'SPEED '+str(s.v)+' / 4',10)
p.text(430,108,'ACCEL '+str(s.accel)+' / BRAKE '+str(s.brake),6)
p.text(430,132,'CONTACTS '+str(s.contacts),8 if s.contacts else 11)
p.text(430,154,'Q '+str(s.q)+' (16 = 1 UNIT)',7)
p.text(430,178,'SEG '+str(s.segment)+' / OFFSET '+str(s.offset),7)
p.text(430,200,'CURVE '+str(seg.curve)+' / HALF WIDTH '+str(seg.width),6)
p.text(430,224,'STOPPED TICKS '+str(s.stopped),7)
p.text(430,247,'OUTCOME '+s.outcome.upper(),10)
if i+1<len(road):
nxt=road[i+1];p.text(430,270,'NEXT C '+str(nxt.curve)+' / W '+str(nxt.width),11)
pass
p.text(12,366,STAGE_NOTE,10)
p.text(12,388,'UP ACCEL / DOWN BRAKE / LEFT-RIGHT STEER (WHEN INTRODUCED)',7)
p.text(12,408,'P PAUSE / R RETRY SAME ROAD / X PREP (DISCARDS RUN) / ESC QUIT',6)
p.text(12,428,'FIXED PRACTICE COURSE / NO FOLD CHOICE YET',7)
p.text(12,449,'INTEGER 10 HZ / 3 DRAW FRAMES PER LOGICAL TICK / RELEASE MENU KEYS',13)
def demo_held(frame):
if frame==0:return {'RETURN'}
if frame<2:return set()
return {'UP'}
def main(headless=False,screenshot=None,report=None,demo=False):
import pyxel as p
p.init(700,470,title='Paper rally lesson '+str(STAGE),fps=30,display_scale=1,headless=headless)
app=App();previous=set();limit=132 if demo else 8
keys={k:getattr(p,'KEY_'+k) for k in ('LEFT','RIGHT','UP','DOWN','RETURN','SPACE','Z','X','R','P','1','2','3','B')}
def update():
nonlocal previous
if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
if screenshot:app.draw(p);p.screen.save(screenshot,1)
if report:
from pathlib import Path
data={'stage':STAGE,'mode':app.mode,'state':asdict(app.s),'scope':'actual Pyxel; synthetic held input; not OS keyboard or human playtest'}
pass
Path(report).write_text(json.dumps(data,ensure_ascii=False,indent=2),encoding='utf-8')
p.quit();return
if headless:
held=demo_held(p.frame_count) if demo else set();pressed=held-previous;previous=held
else:
held={k for k,v in keys.items() if p.btn(v)};pressed={k for k,v in keys.items() if p.btnp(v)}
app.control(pressed,held)
p.run(update,lambda:app.draw(p))
if __name__=='__main__':
import argparse
from pathlib import Path
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--headless-smoke',action='store_true');parser.add_argument('--demo-checkpoint',action='store_true')
parser.add_argument('--screenshot');parser.add_argument('--report')
pass
args=parser.parse_args()
pass
if (args.demo_checkpoint or args.screenshot or args.report) and not args.headless_smoke:parser.error('demo and output require --headless-smoke')
for field,suffix in (('screenshot','.png'),('report','.json')):
value=getattr(args,field)
if value:
target=Path(value).resolve()
if target.suffix.lower()!=suffix or not target.parent.is_dir() or target.exists():parser.error('use a new '+suffix+' in an existing directory')
setattr(args,field,str(target))
main(args.headless_smoke,args.screenshot,args.report,args.demo_checkpoint)押し続けた刻を数え、速度を上げ下げする
押し続けた回数を数えるカウンター・リセット・速さの上限と下限。
まっすぐな道のまま、上を押し続けると加速、下を押し続けると減速できるようにします。最初の速さは0です。出発しても、上を押さなければ進みません。加速のカウンター accel は、上が押されているルール上の刻が続く間だけ増え、4になると速さを1増やして、カウンターを0に戻します。速さは最大4です。減速のカウンター brake も同じように、2刻続くと速さを1減らし、下限は0です。
キーを離した刻には、カウンターを0にします。上を3刻押して1刻離し、もう一度上を押した最初の刻は1です。それまでの3刻の貯金は残りません。速さそのものは、キーを離してもそのままです。上と下を同時に押すと int(UP)-int(DOWN)=0 なので、どちらのカウンターも0に戻り、速さはそのままです。加速1段ぶんは、描画30fpsの4フレームではなく、ルール上の10Hzの4刻、つまり0.4秒です。
カウンターを更新して新しい速さ v を決めてから、その刻に進む距離を決めます。速さ0から上を押し続けると、最初の3刻は止まったままで、4刻目に速さ1になって、同じ刻に1進みます。8刻目には速さ2になって、2進みます。ブレーキも、2刻目に新しい低い速さで進みます。stopped は、その刻に決まった速さが0だった回数です。
01 の一定の速さに、同じ step の中で入力を読む部分を加えました。02 ではハンドルや路肩の判定はなく、左右キーはまだ働きません。画面には q や接触の回数もありますが、0のままです。P を押すと phase や入力のカウンターも止まり、再開したあとは、キーをいったん離す必要があります。開始・一時停止・メニューの操作には、押しっぱなしでの誤入力を防ぐ「キーが離されるのを待つ仕組み(release gate)」を置き、走っている間のアクセルは押し続けられるように、区別しています。
下のコードは 02_speed_counters.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if ac>=4:v=min(4,v+1);ac=0
if br>=2:v=max(0,v-1);br=0実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 02_speed_counters.py.venv/bin/python 02_speed_counters.py- 4刻での加速と、2刻での減速を分ける
- キーを離したら、続けて押した回数を消す
- 新しい速さで、同じ刻に進む
btnp(押した瞬間)だけで加速を数えると、押した最初の1回しか数えられません。走るための入力には、押し続けている状態(held)を使います。一方、Enter や P の切り替えに押し続けている状態だけを使うと、押している間に何度も切り替わってしまいます。if ac>=4: の4を2に変えてみましょう。保存 → 終了 → 実行し直します。上をルール上の4刻入力した時点で、速さ2・DIST 4 になります。元の4に戻して同じ操作をすると、速さ1・DIST 1 です。qa/exercise_checks.py は、実際に書き換えたコピーを実行して、両方を確かめています。
このステップのコード全文(242行)
# MIT License
#
# Copyright (c) 2026
#
# 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.
#
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.
"""057 紙片のラリー / 第2段階。LESSON.md を参照。"""
STAGE=2
STAGE_NOTE='HOLD UP 4 TICKS: SPEED +1 / HOLD DOWN 2 TICKS: SPEED -1'
"""057 deterministic integer rally. No renderer, clock, filesystem or random state."""
from dataclasses import dataclass, asdict, replace
import hashlib,json
RULE_VERSION='paper-rally-r1'
GENERATOR_VERSION='module-bank-g1'
ENV_VERSION='integer-10Hz-v1'
TICK_RATE=10
@dataclass(frozen=True)
class Segment:
length:int
curve:int
width:int
module:str
@dataclass(frozen=True)
class Level:
seed:str
title:str
modules:tuple
# Both alternatives connect the same ordered entry/exit sockets; no arbitrary geometry.
LEVELS=(Level('LESSON-STRAIGHT','直線練習',(('START',((120,0,14),),None),)),)
@dataclass(frozen=True)
class State:
tick:int=0
segment:int=0
offset:int=0
q:int=0
v:int=0
accel:int=0
brake:int=0
contacts:int=0
stopped:int=0
outcome:str='running'
def folds(level):return ('',)+tuple(name for name,_,folded in level.modules if folded is not None)
def course(level,fold='',wide=False):
if fold not in folds(level):raise ValueError('Unknown fold in finite bank')
if wide and (level.seed[0]!='A' or fold!='A'):raise ValueError('Width comparison is only A folded')
return tuple(Segment(d,c,14 if wide and name==fold else w,name) for name,opened,folded in level.modules for d,c,w in (folded if name==fold else opened))
def initial():return State()
def distance(s,road):return sum(seg.length for seg in road[:s.segment])+s.offset
def total(road):return sum(s.length for s in road)
def at(road,d):
d=max(0,min(d,total(road)-1))
for i,seg in enumerate(road):
if d<seg.length:return i,d,seg
d-=seg.length
return len(road)-1,road[-1].length-1,road[-1]
def step(s,road,action):
"""action=(steer -1/0/+1, throttle -1/0/+1); invalid actions consume no tick.
Straight-road scaffold: steering, curve drift and shoulder contact are absent.
"""
if s.outcome!='running':return s
if not isinstance(action,(tuple,list)) or len(action)!=2 or any(type(x)!=int or x not in (-1,0,1) for x in action):return s
steer,throttle=action;v=s.v;ac=s.accel+1 if throttle==1 else 0;br=s.brake+1 if throttle==-1 else 0
if ac>=4:v=min(4,v+1);ac=0
if br>=2:v=max(0,v-1);br=0
q=s.q;seg=s.segment;off=s.offset;touch=False
for _ in range(v):
part=road[seg]
off+=1
if off==part.length:
seg+=1;off=0
if seg==len(road):break
return State(s.tick+1,seg,off,q,1 if touch else v,ac,br,s.contacts+int(touch),s.stopped+int(v==0),'won' if seg==len(road) else 'running')
class App:
def __init__(self):
self.level=0
self.fold=''
self.wide=False
self.mode='prep'
pass
self.reset()
@property
def board(self):return LEVELS[self.level]
@property
def road(self):return course(self.board,self.fold,self.wide)
def reset(self):
self.s=initial()
self.phase=0
self.pause=False
self.gate=False
pass
def tick(self,action):
self.s=step(self.s,self.road,action)
pass
if self.s.outcome=='won':
self.mode='result'
pass
def control(self,pressed,held):
if self.gate:
if not held:self.gate=False
return
if 'R' in pressed:
self.reset();self.mode='race';self.gate=True;return
if 'P' in pressed and self.mode=='race':
self.pause=not self.pause;self.gate=True;return
if 'X' in pressed:
self.reset();self.mode='prep';self.gate=True;return
if self.pause:return
if self.mode=='prep':
pass
if 'RETURN' in pressed or 'Z' in pressed:
self.reset();self.mode='race';self.gate=True;return
return
if self.mode!='race':return
self.phase+=1
if self.phase==3:
self.phase=0
self.tick((0,int('UP' in held)-int('DOWN' in held)))
def draw(self,p):
s=self.s;road=self.road;d=distance(s,road)
p.cls(1)
p.text(12,10,'057 PAPER RALLY / LESSON '+str(STAGE),10)
p.text(12,28,self.board.seed+' / FOLD '+(self.fold or 'OPEN')+' / WIDTH '+('14 COMPARE' if self.wide else 'NORMAL'),6)
p.text(12,47,'MODE '+self.mode.upper()+(' / PAUSED' if self.pause else '')+' / TICK '+str(s.tick)+' / TIME '+format(s.tick/TICK_RATE,'.1f')+'s',7)
p.text(430,47,'DIST '+str(d)+' / '+str(total(road)),11)
if self.mode=='prep':
p.rect(12,76,676,210,7)
p.text(24,88,'FINITE MODULE BANK / SYMBOLIC SOCKET DIAGRAM',1)
dx=640//len(self.board.modules)
for i,(name,opened,folded) in enumerate(self.board.modules):
x=28+i*dx;end=x+dx-18;mid=(x+end)//2
if folded:
paths=(((x,180),(x+dx//4,130),(end-dx//4,130),(end,180)),((x,180),(mid,162),(end,180)))
for j,path in enumerate(paths):
color=8 if (j==1)==(self.fold==name) else 6
for a,b in zip(path,path[1:]):p.line(*a,*b,color)
p.line(mid,112,mid,216,9)
else:p.line(x,180,end,180,1)
p.circ(x,180,3,12);p.circ(end,180,3,12)
if i+1<len(self.board.modules):p.line(end,180,x+dx,180,1)
chosen=folded if self.fold==name else opened
p.text(x,229,'MODULE '+str(i+1),1)
p.text(x,247,'LEN '+str(sum(v[0] for v in chosen)),1)
p.text(12,302,'ENTER / Z START. ROAD LENGTH IS THE NUMBER, NOT DIAGRAM PIXELS.',10)
else:
# The perspective is illustrative; collision uses integer q and width.
p.rect(12,77,395,273,3);center=210
for y in range(331,85,-2):
ahead=(331-y)//2;_,_,seg=at(road,d+ahead);center+=seg.curve*.23
half=seg.width*4
p.rect(center-half,y,half*2,2,5)
p.rect(center-half,y,2,2,7);p.rect(center+half-2,y,2,2,7)
if (d+ahead)//6%2==0:p.rect(center,y,2,2,6)
pass
x=210+s.q/4;p.rect(x-5,316,10,20,10);p.rect(x-3,320,6,5,7)
i=min(s.segment,len(road)-1);seg=road[i]
p.text(430,87,'SPEED '+str(s.v)+' / 4',10)
p.text(430,108,'ACCEL '+str(s.accel)+' / BRAKE '+str(s.brake),6)
p.text(430,132,'CONTACTS '+str(s.contacts),8 if s.contacts else 11)
p.text(430,154,'Q '+str(s.q)+' (16 = 1 UNIT)',7)
p.text(430,178,'SEG '+str(s.segment)+' / OFFSET '+str(s.offset),7)
p.text(430,200,'CURVE '+str(seg.curve)+' / HALF WIDTH '+str(seg.width),6)
p.text(430,224,'STOPPED TICKS '+str(s.stopped),7)
p.text(430,247,'OUTCOME '+s.outcome.upper(),10)
if i+1<len(road):
nxt=road[i+1];p.text(430,270,'NEXT C '+str(nxt.curve)+' / W '+str(nxt.width),11)
pass
p.text(12,366,STAGE_NOTE,10)
p.text(12,388,'UP ACCEL / DOWN BRAKE / LEFT-RIGHT STEER (WHEN INTRODUCED)',7)
p.text(12,408,'P PAUSE / R RETRY SAME ROAD / X PREP (DISCARDS RUN) / ESC QUIT',6)
p.text(12,428,'FIXED PRACTICE COURSE / NO FOLD CHOICE YET',7)
p.text(12,449,'INTEGER 10 HZ / 3 DRAW FRAMES PER LOGICAL TICK / RELEASE MENU KEYS',13)
def demo_held(frame):
if frame==0:return {'RETURN'}
if frame<2:return set()
return {'UP'}
def main(headless=False,screenshot=None,report=None,demo=False):
import pyxel as p
p.init(700,470,title='Paper rally lesson '+str(STAGE),fps=30,display_scale=1,headless=headless)
app=App();previous=set();limit=132 if demo else 8
keys={k:getattr(p,'KEY_'+k) for k in ('LEFT','RIGHT','UP','DOWN','RETURN','SPACE','Z','X','R','P','1','2','3','B')}
def update():
nonlocal previous
if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
if screenshot:app.draw(p);p.screen.save(screenshot,1)
if report:
from pathlib import Path
data={'stage':STAGE,'mode':app.mode,'state':asdict(app.s),'scope':'actual Pyxel; synthetic held input; not OS keyboard or human playtest'}
pass
Path(report).write_text(json.dumps(data,ensure_ascii=False,indent=2),encoding='utf-8')
p.quit();return
if headless:
held=demo_held(p.frame_count) if demo else set();pressed=held-previous;previous=held
else:
held={k for k,v in keys.items() if p.btn(v)};pressed={k for k,v in keys.items() if p.btnp(v)}
app.control(pressed,held)
p.run(update,lambda:app.draw(p))
if __name__=='__main__':
import argparse
from pathlib import Path
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--headless-smoke',action='store_true');parser.add_argument('--demo-checkpoint',action='store_true')
parser.add_argument('--screenshot');parser.add_argument('--report')
pass
args=parser.parse_args()
pass
if (args.demo_checkpoint or args.screenshot or args.report) and not args.headless_smoke:parser.error('demo and output require --headless-smoke')
for field,suffix in (('screenshot','.png'),('report','.json')):
value=getattr(args,field)
if value:
target=Path(value).resolve()
if target.suffix.lower()!=suffix or not target.parent.is_dir() or target.exists():parser.error('use a new '+suffix+' in an existing directory')
setattr(args,field,str(target))
main(args.headless_smoke,args.screenshot,args.report,args.demo_checkpoint)区間をまたぐ一刻を、一距離ずつ進める
整数で細かい値を表す方法(固定小数点)・区間の切れ目での分割・1刻に1回だけの接触。
元のゲームの状況 A の、折っていない道に替えて、左右のハンドル、カーブの強さ(曲率)、路肩への接触を加えます。横のずれ q は、16を1単位とする整数です。右なら +16、左なら −16、同時押しなら0を、移動の前に加えます。速さが決まったら、その速さ v 個ぶんの距離の1つ1つについて、今の区間の curve×v を q に足します。同じ区間の中だけなら、合計は curve×v×v ですが、区間をまたぐと、途中でカーブの強さが変わります。
たとえば、右カーブ +1 の残りが1、次が左カーブ −1、速さ4 なら、右で +4 を1回、左で −4 を3回足して、合計 −8 です。最初の区間のカーブだけを使って +16 にすると、別のゲームになってしまいます。区間の中の距離 off が区間の長さに着いたら seg を増やし、次の距離の1つでは、新しい区間を使います。ゴールに着いたら残りの距離は進まず、distance は total で止まります。
道の幅の半分 width から、車の余白2を引いた (width−2)×16 が、q の限界です。ちょうど限界なら接触ではなく、abs(q)>bound のときだけ接触です。ハンドルを切った直後、距離1つごとに横へ流されたあと、次の狭い区間に入った直後に確かめます。速さ0でも、ハンドルで外に出れば接触するので、止まっている間だけ壁をすり抜けることはできません。
同じ刻に何回路肩に当たっても、touch という1つの True/False で覚え、contacts は最大1だけ増やします。接触した場所で移動をやめず、今の刻に決まった距離 v だけを処理してから、次の State の速さを1に落とします。速さ0で接触した特別な刻は、距離0のまま、stopped が1増え、次の速さは1になります。03 の step は元のゲームと同じです。コースの選択はまだ状況 A の折っていない道だけで、ほかの限られた候補は、次の段階で画面に出します。
下のコードは 03_curve_shoulders.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
for _ in range(v):
part=road[seg];q+=part.curve*v;bound=(part.width-2)*16実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 03_curve_shoulders.py.venv/bin/python 03_curve_shoulders.py- ハンドルは、距離を進める前に切る
- カーブの強さは、距離1つごとに読み直す
- 接触の回数と、決まった距離を分ける
q に curve*v*v を一度だけ足すと、区間の切れ目でまちがえます。touch になった瞬間に v=1 に書き換えると、今の刻に決まった距離まで変わってしまいます。最後に返す State の v だけを1にします。q=s.q+16*steer の16を8に変えてみましょう。保存 → 終了 → 実行し直し、出発したあと、加速せずに右をルール上の1刻だけ入力すると、Q 8 になります。元の16なら Q 16 です。カーブの計算は変えません。さらに exercises/03_boundary.py で、区間の切れ目での −8 を確かめます。
このステップのコード全文(254行)
# MIT License
#
# Copyright (c) 2026
#
# 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.
#
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.
"""057 紙片のラリー / 第3段階。LESSON.md を参照。"""
STAGE=3
STAGE_NOTE='STEER 16 FIRST / CURVE * SPEED PER DISTANCE / ONE CONTACT PER TICK'
"""057 deterministic integer rally. No renderer, clock, filesystem or random state."""
from dataclasses import dataclass, asdict, replace
import hashlib,json
RULE_VERSION='paper-rally-r1'
GENERATOR_VERSION='module-bank-g1'
ENV_VERSION='integer-10Hz-v1'
TICK_RATE=10
@dataclass(frozen=True)
class Segment:
length:int
curve:int
width:int
module:str
@dataclass(frozen=True)
class Level:
seed:str
title:str
modules:tuple
# Both alternatives connect the same ordered entry/exit sockets; no arbitrary geometry.
LEVELS=(
Level('A-01-g1','広い迂回',(('出発',((80,0,14),),None),('A',((80,0,14),(40,1,14),(40,-1,14),(80,0,14)),((35,0,8),(30,2,8),(30,-2,8),(35,0,8))),('帰着',((80,0,14),),None))),
Level('B-01-g1','出口で速度を戻す',(('出発',((60,0,14),),None),('A',((50,1,13),(50,-1,13),(80,0,13)),((35,0,8),(25,2,8),(25,-2,8),(25,0,8))),('帰着',((140,0,14),),None))),
Level('C-01-g1','どちらを折る',(('出発',((50,0,14),),None),('A',((60,1,14),(60,-1,14)),((30,2,8),(30,-2,8))),('中間',((50,0,14),),None),('B',((80,0,14),(40,1,14),(40,-1,14)),((35,2,12),(35,-2,12),(40,0,12))),('帰着',((50,0,14),),None)))
)
@dataclass(frozen=True)
class State:
tick:int=0
segment:int=0
offset:int=0
q:int=0
v:int=0
accel:int=0
brake:int=0
contacts:int=0
stopped:int=0
outcome:str='running'
def folds(level):return ('',)+tuple(name for name,_,folded in level.modules if folded is not None)
def course(level,fold='',wide=False):
if fold not in folds(level):raise ValueError('Unknown fold in finite bank')
if wide and (level.seed[0]!='A' or fold!='A'):raise ValueError('Width comparison is only A folded')
return tuple(Segment(d,c,14 if wide and name==fold else w,name) for name,opened,folded in level.modules for d,c,w in (folded if name==fold else opened))
def initial():return State()
def distance(s,road):return sum(seg.length for seg in road[:s.segment])+s.offset
def total(road):return sum(s.length for s in road)
def at(road,d):
d=max(0,min(d,total(road)-1))
for i,seg in enumerate(road):
if d<seg.length:return i,d,seg
d-=seg.length
return len(road)-1,road[-1].length-1,road[-1]
def step(s,road,action):
"""action=(steer -1/0/+1, throttle -1/0/+1); invalid actions consume no tick.
Steering first, speed counters second. Each committed distance unit uses c*v,
so boundaries split c*v*v correctly. Shoulder check also applies at v=0.
A tick can add only one contact; complete committed distance then end at v=1.
"""
if s.outcome!='running':return s
if not isinstance(action,(tuple,list)) or len(action)!=2 or any(type(x)!=int or x not in (-1,0,1) for x in action):return s
steer,throttle=action;v=s.v;ac=s.accel+1 if throttle==1 else 0;br=s.brake+1 if throttle==-1 else 0
if ac>=4:v=min(4,v+1);ac=0
if br>=2:v=max(0,v-1);br=0
q=s.q+16*steer;seg=s.segment;off=s.offset;touch=False
# Check the current shoulder before movement; a zero-speed steer cannot escape.
bound=(road[seg].width-2)*16
if abs(q)>bound:q=max(-bound,min(bound,q));touch=True
for _ in range(v):
part=road[seg];q+=part.curve*v;bound=(part.width-2)*16
if abs(q)>bound:q=max(-bound,min(bound,q));touch=True
off+=1
if off==part.length:
seg+=1;off=0
if seg==len(road):break
bound=(road[seg].width-2)*16
if abs(q)>bound:q=max(-bound,min(bound,q));touch=True
return State(s.tick+1,seg,off,q,1 if touch else v,ac,br,s.contacts+int(touch),s.stopped+int(v==0),'won' if seg==len(road) else 'running')
class App:
def __init__(self):
self.level=0
self.fold=''
self.wide=False
self.mode='prep'
pass
self.reset()
@property
def board(self):return LEVELS[self.level]
@property
def road(self):return course(self.board,self.fold,self.wide)
def reset(self):
self.s=initial()
self.phase=0
self.pause=False
self.gate=False
pass
def tick(self,action):
self.s=step(self.s,self.road,action)
pass
if self.s.outcome=='won':
self.mode='result'
pass
def control(self,pressed,held):
if self.gate:
if not held:self.gate=False
return
if 'R' in pressed:
self.reset();self.mode='race';self.gate=True;return
if 'P' in pressed and self.mode=='race':
self.pause=not self.pause;self.gate=True;return
if 'X' in pressed:
self.reset();self.mode='prep';self.gate=True;return
if self.pause:return
if self.mode=='prep':
pass
if 'RETURN' in pressed or 'Z' in pressed:
self.reset();self.mode='race';self.gate=True;return
return
if self.mode!='race':return
self.phase+=1
if self.phase==3:
self.phase=0
self.tick((int('RIGHT' in held)-int('LEFT' in held),int('UP' in held)-int('DOWN' in held)))
def draw(self,p):
s=self.s;road=self.road;d=distance(s,road)
p.cls(1)
p.text(12,10,'057 PAPER RALLY / LESSON '+str(STAGE),10)
p.text(12,28,self.board.seed+' / FOLD '+(self.fold or 'OPEN')+' / WIDTH '+('14 COMPARE' if self.wide else 'NORMAL'),6)
p.text(12,47,'MODE '+self.mode.upper()+(' / PAUSED' if self.pause else '')+' / TICK '+str(s.tick)+' / TIME '+format(s.tick/TICK_RATE,'.1f')+'s',7)
p.text(430,47,'DIST '+str(d)+' / '+str(total(road)),11)
if self.mode=='prep':
p.rect(12,76,676,210,7)
p.text(24,88,'FINITE MODULE BANK / SYMBOLIC SOCKET DIAGRAM',1)
dx=640//len(self.board.modules)
for i,(name,opened,folded) in enumerate(self.board.modules):
x=28+i*dx;end=x+dx-18;mid=(x+end)//2
if folded:
paths=(((x,180),(x+dx//4,130),(end-dx//4,130),(end,180)),((x,180),(mid,162),(end,180)))
for j,path in enumerate(paths):
color=8 if (j==1)==(self.fold==name) else 6
for a,b in zip(path,path[1:]):p.line(*a,*b,color)
p.line(mid,112,mid,216,9)
else:p.line(x,180,end,180,1)
p.circ(x,180,3,12);p.circ(end,180,3,12)
if i+1<len(self.board.modules):p.line(end,180,x+dx,180,1)
chosen=folded if self.fold==name else opened
p.text(x,229,'MODULE '+str(i+1),1)
p.text(x,247,'LEN '+str(sum(v[0] for v in chosen)),1)
p.text(12,302,'ENTER / Z START. ROAD LENGTH IS THE NUMBER, NOT DIAGRAM PIXELS.',10)
else:
# The perspective is illustrative; collision uses integer q and width.
p.rect(12,77,395,273,3);center=210
for y in range(331,85,-2):
ahead=(331-y)//2;_,_,seg=at(road,d+ahead);center+=seg.curve*.23
half=seg.width*4
p.rect(center-half,y,half*2,2,5)
p.rect(center-half,y,2,2,7);p.rect(center+half-2,y,2,2,7)
if (d+ahead)//6%2==0:p.rect(center,y,2,2,6)
pass
x=210+s.q/4;p.rect(x-5,316,10,20,10);p.rect(x-3,320,6,5,7)
i=min(s.segment,len(road)-1);seg=road[i]
p.text(430,87,'SPEED '+str(s.v)+' / 4',10)
p.text(430,108,'ACCEL '+str(s.accel)+' / BRAKE '+str(s.brake),6)
p.text(430,132,'CONTACTS '+str(s.contacts),8 if s.contacts else 11)
p.text(430,154,'Q '+str(s.q)+' (16 = 1 UNIT)',7)
p.text(430,178,'SEG '+str(s.segment)+' / OFFSET '+str(s.offset),7)
p.text(430,200,'CURVE '+str(seg.curve)+' / HALF WIDTH '+str(seg.width),6)
p.text(430,224,'STOPPED TICKS '+str(s.stopped),7)
p.text(430,247,'OUTCOME '+s.outcome.upper(),10)
if i+1<len(road):
nxt=road[i+1];p.text(430,270,'NEXT C '+str(nxt.curve)+' / W '+str(nxt.width),11)
pass
p.text(12,366,STAGE_NOTE,10)
p.text(12,388,'UP ACCEL / DOWN BRAKE / LEFT-RIGHT STEER (WHEN INTRODUCED)',7)
p.text(12,408,'P PAUSE / R RETRY SAME ROAD / X PREP (DISCARDS RUN) / ESC QUIT',6)
p.text(12,428,'FIXED PRACTICE COURSE / NO FOLD CHOICE YET',7)
p.text(12,449,'INTEGER 10 HZ / 3 DRAW FRAMES PER LOGICAL TICK / RELEASE MENU KEYS',13)
def demo_held(frame):
if frame==0:return {'RETURN'}
if frame<2:return set()
return {'UP'}
def main(headless=False,screenshot=None,report=None,demo=False):
import pyxel as p
p.init(700,470,title='Paper rally lesson '+str(STAGE),fps=30,display_scale=1,headless=headless)
app=App();previous=set();limit=192 if demo else 8
keys={k:getattr(p,'KEY_'+k) for k in ('LEFT','RIGHT','UP','DOWN','RETURN','SPACE','Z','X','R','P','1','2','3','B')}
def update():
nonlocal previous
if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
if screenshot:app.draw(p);p.screen.save(screenshot,1)
if report:
from pathlib import Path
data={'stage':STAGE,'mode':app.mode,'state':asdict(app.s),'scope':'actual Pyxel; synthetic held input; not OS keyboard or human playtest'}
pass
Path(report).write_text(json.dumps(data,ensure_ascii=False,indent=2),encoding='utf-8')
p.quit();return
if headless:
held=demo_held(p.frame_count) if demo else set();pressed=held-previous;previous=held
else:
held={k for k,v in keys.items() if p.btn(v)};pressed={k for k,v in keys.items() if p.btnp(v)}
app.control(pressed,held)
p.run(update,lambda:app.draw(p))
if __name__=='__main__':
import argparse
from pathlib import Path
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--headless-smoke',action='store_true');parser.add_argument('--demo-checkpoint',action='store_true')
parser.add_argument('--screenshot');parser.add_argument('--report')
pass
args=parser.parse_args()
pass
if (args.demo_checkpoint or args.screenshot or args.report) and not args.headless_smoke:parser.error('demo and output require --headless-smoke')
for field,suffix in (('screenshot','.png'),('report','.json')):
value=getattr(args,field)
if value:
target=Path(value).resolve()
if target.suffix.lower()!=suffix or not target.parent.is_dir() or target.exists():parser.error('use a new '+suffix+' in an existing directory')
setattr(args,field,str(target))
main(args.headless_smoke,args.screenshot,args.report,args.demo_checkpoint)同じ入口と出口に、別の有限ルートをつなぐ
データで決まるコース・限られた候補・1つの条件だけを変える比較。
準備の画面で 1/2/3 を押すと、状況 A/B/C の、用意済みのデータを選べます。Space は、開く → A を折る → B を折る → 開く、と順に切り替わります。ただし、B を折る選択があるのはコース C だけです。いくつもの部品を同時には折りません。走っている間は道を変えられないので、X で試走をやめて準備に戻ってから選び直します。
Level.modules は、名前・開いたときの区間の列・折ったときの区間の列を並べた tuple です。course は部品を順に見て、選んだ名前の部品だけを折った区間の列に差しかえ、Segment の1本の列にします。折った道は、同じ入口と出口を結ぶ、あらかじめ用意された別の形です。紙の好きな形を折る図形の計算でも、seed からランダムに道を作る機能でもありません。ふつうは A が2通り、B が2通り、C が3通りで、合計7ルートの限られた候補です。seed の文字列は、その版を見分けるための名前です。
準備の図では、同じ部品の2本の線が同じ青い端の点につながり、選んだ線が強調されます。距離は、各部品の下の LEN と、上の DIST の分母で確かめます。図の中で短く見える線や、画面に描かれる道は、ぶつかる形そのものではありません。ルールでは、length、curve、width という整数を使います。状況 A の折っていない道は400、折った道は290ですが、短い道がいつも短い時間で走れるとは限りません。狭い急カーブで接触して、速さが落ちるからです。
状況 A で A を折った場合だけ、B キーで道幅の比較を切り替えます。折った道の部品の幅8だけを14に変え、長さ、カーブ、前後の直線、速さのルール、ゴールの条件は同じにします。B や C にこの比較を広げると、元のゲームにない別の版になってしまいます。course は、その指定を ValueError で受け付けません。比較は、1つの条件を変えて、その影響を読む実験です。接触の回数と走った刻の両方を見て、距離だけで結論を出さないようにしましょう。
下のコードは 04_fold_bank.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
return tuple(Segment(d,c,14 if wide and name==fold else w,name) for name,opened,folded in level.modules for d,c,w in (folded if name==fold else opened))実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 04_fold_bank.py.venv/bin/python 04_fold_bank.py- ふつうの7ルートを、全部書き出せる
- 入口と出口は同じままにする
- 道幅の比較では、長さやカーブを変えない
seed という変数の名前だけを見て、乱数で道を作る機能を作らないでください。候補は LEVELS の中で決まっています。C の A を折るのと B を折るのを、同時に使う指定はありません。道幅の比較の対象も、状況 A の A を折った道だけです。exercises/04_width_only.py の WIDE=False を True に変えて、保存 → 終了 → 実行し直してみましょう。折った道の width は8から14になりますが、length と curve の列、そして全体の長さ290は同じです。画面でも A → Space → B で同じ比較を行い、確認したら False に戻します。
このステップのコード全文(262行)
# MIT License
#
# Copyright (c) 2026
#
# 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.
#
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.
"""057 紙片のラリー / 第4段階。LESSON.md を参照。"""
STAGE=4
STAGE_NOTE='FINITE ALTERNATIVES SHARE SOCKETS / ONE FOLD AT A TIME'
"""057 deterministic integer rally. No renderer, clock, filesystem or random state."""
from dataclasses import dataclass, asdict, replace
import hashlib,json
RULE_VERSION='paper-rally-r1'
GENERATOR_VERSION='module-bank-g1'
ENV_VERSION='integer-10Hz-v1'
TICK_RATE=10
@dataclass(frozen=True)
class Segment:
length:int
curve:int
width:int
module:str
@dataclass(frozen=True)
class Level:
seed:str
title:str
modules:tuple
# Both alternatives connect the same ordered entry/exit sockets; no arbitrary geometry.
LEVELS=(
Level('A-01-g1','広い迂回',(('出発',((80,0,14),),None),('A',((80,0,14),(40,1,14),(40,-1,14),(80,0,14)),((35,0,8),(30,2,8),(30,-2,8),(35,0,8))),('帰着',((80,0,14),),None))),
Level('B-01-g1','出口で速度を戻す',(('出発',((60,0,14),),None),('A',((50,1,13),(50,-1,13),(80,0,13)),((35,0,8),(25,2,8),(25,-2,8),(25,0,8))),('帰着',((140,0,14),),None))),
Level('C-01-g1','どちらを折る',(('出発',((50,0,14),),None),('A',((60,1,14),(60,-1,14)),((30,2,8),(30,-2,8))),('中間',((50,0,14),),None),('B',((80,0,14),(40,1,14),(40,-1,14)),((35,2,12),(35,-2,12),(40,0,12))),('帰着',((50,0,14),),None)))
)
@dataclass(frozen=True)
class State:
tick:int=0
segment:int=0
offset:int=0
q:int=0
v:int=0
accel:int=0
brake:int=0
contacts:int=0
stopped:int=0
outcome:str='running'
def folds(level):return ('',)+tuple(name for name,_,folded in level.modules if folded is not None)
def course(level,fold='',wide=False):
if fold not in folds(level):raise ValueError('Unknown fold in finite bank')
if wide and (level.seed[0]!='A' or fold!='A'):raise ValueError('Width comparison is only A folded')
return tuple(Segment(d,c,14 if wide and name==fold else w,name) for name,opened,folded in level.modules for d,c,w in (folded if name==fold else opened))
def initial():return State()
def distance(s,road):return sum(seg.length for seg in road[:s.segment])+s.offset
def total(road):return sum(s.length for s in road)
def at(road,d):
d=max(0,min(d,total(road)-1))
for i,seg in enumerate(road):
if d<seg.length:return i,d,seg
d-=seg.length
return len(road)-1,road[-1].length-1,road[-1]
def step(s,road,action):
"""action=(steer -1/0/+1, throttle -1/0/+1); invalid actions consume no tick.
Steering first, speed counters second. Each committed distance unit uses c*v,
so boundaries split c*v*v correctly. Shoulder check also applies at v=0.
A tick can add only one contact; complete committed distance then end at v=1.
"""
if s.outcome!='running':return s
if not isinstance(action,(tuple,list)) or len(action)!=2 or any(type(x)!=int or x not in (-1,0,1) for x in action):return s
steer,throttle=action;v=s.v;ac=s.accel+1 if throttle==1 else 0;br=s.brake+1 if throttle==-1 else 0
if ac>=4:v=min(4,v+1);ac=0
if br>=2:v=max(0,v-1);br=0
q=s.q+16*steer;seg=s.segment;off=s.offset;touch=False
# Check the current shoulder before movement; a zero-speed steer cannot escape.
bound=(road[seg].width-2)*16
if abs(q)>bound:q=max(-bound,min(bound,q));touch=True
for _ in range(v):
part=road[seg];q+=part.curve*v;bound=(part.width-2)*16
if abs(q)>bound:q=max(-bound,min(bound,q));touch=True
off+=1
if off==part.length:
seg+=1;off=0
if seg==len(road):break
bound=(road[seg].width-2)*16
if abs(q)>bound:q=max(-bound,min(bound,q));touch=True
return State(s.tick+1,seg,off,q,1 if touch else v,ac,br,s.contacts+int(touch),s.stopped+int(v==0),'won' if seg==len(road) else 'running')
class App:
def __init__(self):
self.level=0
self.fold=''
self.wide=False
self.mode='prep'
pass
self.reset()
@property
def board(self):return LEVELS[self.level]
@property
def road(self):return course(self.board,self.fold,self.wide)
def reset(self):
self.s=initial()
self.phase=0
self.pause=False
self.gate=False
pass
def tick(self,action):
self.s=step(self.s,self.road,action)
pass
if self.s.outcome=='won':
self.mode='result'
pass
def control(self,pressed,held):
if self.gate:
if not held:self.gate=False
return
if 'R' in pressed:
self.reset();self.mode='race';self.gate=True;return
if 'P' in pressed and self.mode=='race':
self.pause=not self.pause;self.gate=True;return
if 'X' in pressed:
self.reset();self.mode='prep';self.gate=True;return
if self.pause:return
if self.mode=='prep':
for i,key in enumerate(('1','2','3')):
if key in pressed:
self.level=i;self.fold='';self.wide=False;self.reset();self.gate=True;return
if 'SPACE' in pressed:
choices=folds(self.board);self.fold=choices[(choices.index(self.fold)+1)%len(choices)]
self.wide=False;self.reset();self.gate=True;return
if 'B' in pressed and self.level==0 and self.fold=='A':
self.wide=not self.wide;self.reset();self.gate=True;return
if 'RETURN' in pressed or 'Z' in pressed:
self.reset();self.mode='race';self.gate=True;return
return
if self.mode!='race':return
self.phase+=1
if self.phase==3:
self.phase=0
self.tick((int('RIGHT' in held)-int('LEFT' in held),int('UP' in held)-int('DOWN' in held)))
def draw(self,p):
s=self.s;road=self.road;d=distance(s,road)
p.cls(1)
p.text(12,10,'057 PAPER RALLY / LESSON '+str(STAGE),10)
p.text(12,28,self.board.seed+' / FOLD '+(self.fold or 'OPEN')+' / WIDTH '+('14 COMPARE' if self.wide else 'NORMAL'),6)
p.text(12,47,'MODE '+self.mode.upper()+(' / PAUSED' if self.pause else '')+' / TICK '+str(s.tick)+' / TIME '+format(s.tick/TICK_RATE,'.1f')+'s',7)
p.text(430,47,'DIST '+str(d)+' / '+str(total(road)),11)
if self.mode=='prep':
p.rect(12,76,676,210,7)
p.text(24,88,'FINITE MODULE BANK / SYMBOLIC SOCKET DIAGRAM',1)
dx=640//len(self.board.modules)
for i,(name,opened,folded) in enumerate(self.board.modules):
x=28+i*dx;end=x+dx-18;mid=(x+end)//2
if folded:
paths=(((x,180),(x+dx//4,130),(end-dx//4,130),(end,180)),((x,180),(mid,162),(end,180)))
for j,path in enumerate(paths):
color=8 if (j==1)==(self.fold==name) else 6
for a,b in zip(path,path[1:]):p.line(*a,*b,color)
p.line(mid,112,mid,216,9)
else:p.line(x,180,end,180,1)
p.circ(x,180,3,12);p.circ(end,180,3,12)
if i+1<len(self.board.modules):p.line(end,180,x+dx,180,1)
chosen=folded if self.fold==name else opened
p.text(x,229,'MODULE '+str(i+1),1)
p.text(x,247,'LEN '+str(sum(v[0] for v in chosen)),1)
p.text(12,302,'ENTER / Z START. ROAD LENGTH IS THE NUMBER, NOT DIAGRAM PIXELS.',10)
else:
# The perspective is illustrative; collision uses integer q and width.
p.rect(12,77,395,273,3);center=210
for y in range(331,85,-2):
ahead=(331-y)//2;_,_,seg=at(road,d+ahead);center+=seg.curve*.23
half=seg.width*4
p.rect(center-half,y,half*2,2,5)
p.rect(center-half,y,2,2,7);p.rect(center+half-2,y,2,2,7)
if (d+ahead)//6%2==0:p.rect(center,y,2,2,6)
pass
x=210+s.q/4;p.rect(x-5,316,10,20,10);p.rect(x-3,320,6,5,7)
i=min(s.segment,len(road)-1);seg=road[i]
p.text(430,87,'SPEED '+str(s.v)+' / 4',10)
p.text(430,108,'ACCEL '+str(s.accel)+' / BRAKE '+str(s.brake),6)
p.text(430,132,'CONTACTS '+str(s.contacts),8 if s.contacts else 11)
p.text(430,154,'Q '+str(s.q)+' (16 = 1 UNIT)',7)
p.text(430,178,'SEG '+str(s.segment)+' / OFFSET '+str(s.offset),7)
p.text(430,200,'CURVE '+str(seg.curve)+' / HALF WIDTH '+str(seg.width),6)
p.text(430,224,'STOPPED TICKS '+str(s.stopped),7)
p.text(430,247,'OUTCOME '+s.outcome.upper(),10)
if i+1<len(road):
nxt=road[i+1];p.text(430,270,'NEXT C '+str(nxt.curve)+' / W '+str(nxt.width),11)
pass
p.text(12,366,STAGE_NOTE,10)
p.text(12,388,'UP ACCEL / DOWN BRAKE / LEFT-RIGHT STEER (WHEN INTRODUCED)',7)
p.text(12,408,'P PAUSE / R RETRY SAME ROAD / X PREP (DISCARDS RUN) / ESC QUIT',6)
p.text(12,428,'PREP: 1-3 COURSE / SPACE OPEN-A-B / B WIDTH COMPARE (A FOLD ONLY)',7)
p.text(12,449,'INTEGER 10 HZ / 3 DRAW FRAMES PER LOGICAL TICK / RELEASE MENU KEYS',13)
def demo_held(frame):
if frame==0:return {'SPACE'}
if frame==2:return {'RETURN'}
if frame<4:return set()
return {'UP'}
def main(headless=False,screenshot=None,report=None,demo=False):
import pyxel as p
p.init(700,470,title='Paper rally lesson '+str(STAGE),fps=30,display_scale=1,headless=headless)
app=App();previous=set();limit=132 if demo else 8
keys={k:getattr(p,'KEY_'+k) for k in ('LEFT','RIGHT','UP','DOWN','RETURN','SPACE','Z','X','R','P','1','2','3','B')}
def update():
nonlocal previous
if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
if screenshot:app.draw(p);p.screen.save(screenshot,1)
if report:
from pathlib import Path
data={'stage':STAGE,'mode':app.mode,'state':asdict(app.s),'scope':'actual Pyxel; synthetic held input; not OS keyboard or human playtest'}
pass
Path(report).write_text(json.dumps(data,ensure_ascii=False,indent=2),encoding='utf-8')
p.quit();return
if headless:
held=demo_held(p.frame_count) if demo else set();pressed=held-previous;previous=held
else:
held={k for k,v in keys.items() if p.btn(v)};pressed={k for k,v in keys.items() if p.btnp(v)}
app.control(pressed,held)
p.run(update,lambda:app.draw(p))
if __name__=='__main__':
import argparse
from pathlib import Path
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--headless-smoke',action='store_true');parser.add_argument('--demo-checkpoint',action='store_true')
parser.add_argument('--screenshot');parser.add_argument('--report')
pass
args=parser.parse_args()
pass
if (args.demo_checkpoint or args.screenshot or args.report) and not args.headless_smoke:parser.error('demo and output require --headless-smoke')
for field,suffix in (('screenshot','.png'),('report','.json')):
value=getattr(args,field)
if value:
target=Path(value).resolve()
if target.suffix.lower()!=suffix or not target.parent.is_dir() or target.exists():parser.error('use a new '+suffix+' in an existing directory')
setattr(args,field,str(target))
main(args.headless_smoke,args.screenshot,args.report,args.demo_checkpoint)入力列を検証し、同じ道の前回ベストを見る
形をそろえた JSON・ハッシュ・道ごとの記録。
最後に、元のゲームのリプレイの関数と、同じコース用のゴーストを組みこみます。1刻ごとの行動 action を inputs に保存し、その結果の State を trace に加えます。ゴールすると、コースのハッシュ(内容から作る短い値)を目印にして、走った刻が少ない記録を best に残します。同じ刻なら、接触の回数が少ないほうです。R で同じ道を走り直すと、同じルール上の tick の過去の車を、紫の枠で描きます。前回との距離の差は縦の位置に、前回の q は横の位置に反映します。
別の道の車を同じレースに混ぜないように、目印には seed だけでなく、折り方、道幅の比較、すべての区間、ルールの版、限られた候補の版、整数で10Hzという環境の版を含めます。状況 A の折っていない道のベスト記録は、A を折った道には出てきません。幅8の折った道と、幅14の比較も、別の記録です。best はメモリーの中だけで、この教材を終了すると消えます。ゴーストは、見える範囲にいるときだけ描きます。
replay_data は、最初の状態から入力を順番に step に渡し、各入力の直前の tick と、結果の state_sha256 を記録します。canonical は、キーの順番と区切りの文字を決めた JSON を作り、その UTF-8 のバイト列を SHA-256 に渡します。Python が動くたびに変わる hash() ではありません。check_replay は、コースの版、すべての区間、入力の刻、途中の状態、最後の状態まで作り直し、記録と一致するかを確かめます。ハッシュはうっかりした変更を見つけるためのもので、作った人の署名や、書き換えられないことの証明ではありません。
検査用のコマンド(CLI)の --report には replay が入り、同じ 05 に --check-replay で渡せます。JSON は必ず UTF-8 で読み書きします。ふつうの画面には、元のゲームの S での保存、F12、GIF、確認の画面、折るアニメーション、日本語の文字は付けていません。X はすぐに試走をやめて、準備に戻ります。走っている途中の1手の Undo は、元のゲームにもありません。ゲームオーバー、残り数、時間制限は加えず、路肩に触れて遅くなっても、ゴールまで走れます。
ここで、元のゲームの model.py の関数がすべてそろいます。元のゲームに進むなら、check_replay と app.py の tick を比べ、次に best の道ごとの目印と、race のゴーストの表示、save_run のファイルの書き出しを読みましょう。描く回数をいくら増やしても、入力の列が同じなら、ルールの結果は同じ、という作りの境目を確かめます。
下のコードは 05_replay_ghost.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if canonical(actual)!=canonical(data):raise ValueError('Input ticks/intermediate/final hash mismatch')実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 05_replay_ghost.py.venv/bin/python 05_replay_ghost.py--check-replay は、正しい記録の最後の SHA-256 を返し、1刻だけ書き換えた記録は受け付けません。- 道の全体を、記録の目印にする
- ルール上の1刻ずつを実行し直して確かめる
- X と Undo を混同しない
hash() を記録の目印に使うと、実行するたびに値が変わって一致しません。また、seed だけを目印にすると、折った道や道幅の比較の車が混ざってしまいます。元のゲームと同じ、形をそろえた JSON と、コース全体のハッシュを使います。exercises/05_replay.py の TAMPER=False を True に変えて、保存 → 終了 → 実行し直してみましょう。途中の logical_tick を書き換えた記録が ValueError で断られ、EXPECTED REJECTION(予想どおりの拒否)と出ることを確かめます。False に戻すと REPLAY OK です。画面では、ゴール → R で同じ道のゴースト、X → Space → Enter で別の道には混ざらないことを試します。
このステップのコード全文(298行)
# MIT License
#
# Copyright (c) 2026
#
# 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.
#
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.
"""057 紙片のラリー / 第5段階。LESSON.md を参照。"""
STAGE=5
STAGE_NOTE='SAME-COURSE GHOST / REPLAY HASHES VERIFY EVERY LOGICAL TICK'
"""057 deterministic integer rally. No renderer, clock, filesystem or random state."""
from dataclasses import dataclass, asdict, replace
import hashlib,json
RULE_VERSION='paper-rally-r1'
GENERATOR_VERSION='module-bank-g1'
ENV_VERSION='integer-10Hz-v1'
TICK_RATE=10
@dataclass(frozen=True)
class Segment:
length:int
curve:int
width:int
module:str
@dataclass(frozen=True)
class Level:
seed:str
title:str
modules:tuple
# Both alternatives connect the same ordered entry/exit sockets; no arbitrary geometry.
LEVELS=(
Level('A-01-g1','広い迂回',(('出発',((80,0,14),),None),('A',((80,0,14),(40,1,14),(40,-1,14),(80,0,14)),((35,0,8),(30,2,8),(30,-2,8),(35,0,8))),('帰着',((80,0,14),),None))),
Level('B-01-g1','出口で速度を戻す',(('出発',((60,0,14),),None),('A',((50,1,13),(50,-1,13),(80,0,13)),((35,0,8),(25,2,8),(25,-2,8),(25,0,8))),('帰着',((140,0,14),),None))),
Level('C-01-g1','どちらを折る',(('出発',((50,0,14),),None),('A',((60,1,14),(60,-1,14)),((30,2,8),(30,-2,8))),('中間',((50,0,14),),None),('B',((80,0,14),(40,1,14),(40,-1,14)),((35,2,12),(35,-2,12),(40,0,12))),('帰着',((50,0,14),),None)))
)
@dataclass(frozen=True)
class State:
tick:int=0
segment:int=0
offset:int=0
q:int=0
v:int=0
accel:int=0
brake:int=0
contacts:int=0
stopped:int=0
outcome:str='running'
def folds(level):return ('',)+tuple(name for name,_,folded in level.modules if folded is not None)
def course(level,fold='',wide=False):
if fold not in folds(level):raise ValueError('Unknown fold in finite bank')
if wide and (level.seed[0]!='A' or fold!='A'):raise ValueError('Width comparison is only A folded')
return tuple(Segment(d,c,14 if wide and name==fold else w,name) for name,opened,folded in level.modules for d,c,w in (folded if name==fold else opened))
def initial():return State()
def distance(s,road):return sum(seg.length for seg in road[:s.segment])+s.offset
def total(road):return sum(s.length for s in road)
def at(road,d):
d=max(0,min(d,total(road)-1))
for i,seg in enumerate(road):
if d<seg.length:return i,d,seg
d-=seg.length
return len(road)-1,road[-1].length-1,road[-1]
def step(s,road,action):
"""action=(steer -1/0/+1, throttle -1/0/+1); invalid actions consume no tick.
Steering first, speed counters second. Each committed distance unit uses c*v,
so boundaries split c*v*v correctly. Shoulder check also applies at v=0.
A tick can add only one contact; complete committed distance then end at v=1.
"""
if s.outcome!='running':return s
if not isinstance(action,(tuple,list)) or len(action)!=2 or any(type(x)!=int or x not in (-1,0,1) for x in action):return s
steer,throttle=action;v=s.v;ac=s.accel+1 if throttle==1 else 0;br=s.brake+1 if throttle==-1 else 0
if ac>=4:v=min(4,v+1);ac=0
if br>=2:v=max(0,v-1);br=0
q=s.q+16*steer;seg=s.segment;off=s.offset;touch=False
# Check the current shoulder before movement; a zero-speed steer cannot escape.
bound=(road[seg].width-2)*16
if abs(q)>bound:q=max(-bound,min(bound,q));touch=True
for _ in range(v):
part=road[seg];q+=part.curve*v;bound=(part.width-2)*16
if abs(q)>bound:q=max(-bound,min(bound,q));touch=True
off+=1
if off==part.length:
seg+=1;off=0
if seg==len(road):break
bound=(road[seg].width-2)*16
if abs(q)>bound:q=max(-bound,min(bound,q));touch=True
return State(s.tick+1,seg,off,q,1 if touch else v,ac,br,s.contacts+int(touch),s.stopped+int(v==0),'won' if seg==len(road) else 'running')
def canonical(obj):return json.dumps(obj,sort_keys=True,separators=(',',':'),ensure_ascii=False)
def digest(obj):return hashlib.sha256(canonical(obj).encode('utf-8')).hexdigest()
def state_hash(s):return digest(asdict(s))
def board_data(level,fold='',wide=False):
return {'level':asdict(level),'fold':fold,'wide':wide,'segments':[asdict(s) for s in course(level,fold,wide)],'rules':RULE_VERSION,'generator':GENERATOR_VERSION,'environment':ENV_VERSION}
def course_hash(level,fold='',wide=False):return digest(board_data(level,fold,wide))
def replay_data(level,inputs,fold='',wide=False):
road=course(level,fold,wide);s=initial();events=[]
for a in inputs:
before=s;s=step(s,road,a);events.append({'logical_tick':before.tick,'action':list(a),'state_sha256':state_hash(s)})
b=board_data(level,fold,wide)
return {'board':b,'board_sha256':digest(b),'inputs':events,'final_state':asdict(s),'final_sha256':state_hash(s)}
def check_replay(data):
b=data['board'];matches=[l for l in LEVELS if l.seed==b['level']['seed']]
if len(matches)!=1:raise ValueError('Unknown finite seed')
level=matches[0];expected=board_data(level,b['fold'],b['wide'])
if canonical(expected)!=canonical(b):raise ValueError('Board or rule/generator/environment mismatch')
if digest(b)!=data['board_sha256']:raise ValueError('Board hash mismatch')
actual=replay_data(level,[e['action'] for e in data['inputs']],b['fold'],b['wide'])
if canonical(actual)!=canonical(data):raise ValueError('Input ticks/intermediate/final hash mismatch')
return actual['final_sha256']
class App:
def __init__(self):
self.level=0
self.fold=''
self.wide=False
self.mode='prep'
self.best={}
self.reset()
@property
def board(self):return LEVELS[self.level]
@property
def road(self):return course(self.board,self.fold,self.wide)
def reset(self):
self.s=initial()
self.phase=0
self.pause=False
self.gate=False
self.inputs=[]
self.trace=[self.s]
def tick(self,action):
self.s=step(self.s,self.road,action)
self.inputs.append(action)
self.trace.append(self.s)
if self.s.outcome=='won':
self.mode='result'
key=course_hash(self.board,self.fold,self.wide)
old=self.best.get(key)
if old is None or (self.s.tick,self.s.contacts)<(old[-1].tick,old[-1].contacts):
self.best[key]=tuple(self.trace)
def control(self,pressed,held):
if self.gate:
if not held:self.gate=False
return
if 'R' in pressed:
self.reset();self.mode='race';self.gate=True;return
if 'P' in pressed and self.mode=='race':
self.pause=not self.pause;self.gate=True;return
if 'X' in pressed:
self.reset();self.mode='prep';self.gate=True;return
if self.pause:return
if self.mode=='prep':
for i,key in enumerate(('1','2','3')):
if key in pressed:
self.level=i;self.fold='';self.wide=False;self.reset();self.gate=True;return
if 'SPACE' in pressed:
choices=folds(self.board);self.fold=choices[(choices.index(self.fold)+1)%len(choices)]
self.wide=False;self.reset();self.gate=True;return
if 'B' in pressed and self.level==0 and self.fold=='A':
self.wide=not self.wide;self.reset();self.gate=True;return
if 'RETURN' in pressed or 'Z' in pressed:
self.reset();self.mode='race';self.gate=True;return
return
if self.mode!='race':return
self.phase+=1
if self.phase==3:
self.phase=0
self.tick((int('RIGHT' in held)-int('LEFT' in held),int('UP' in held)-int('DOWN' in held)))
def draw(self,p):
s=self.s;road=self.road;d=distance(s,road)
p.cls(1)
p.text(12,10,'057 PAPER RALLY / LESSON '+str(STAGE),10)
p.text(12,28,self.board.seed+' / FOLD '+(self.fold or 'OPEN')+' / WIDTH '+('14 COMPARE' if self.wide else 'NORMAL'),6)
p.text(12,47,'MODE '+self.mode.upper()+(' / PAUSED' if self.pause else '')+' / TICK '+str(s.tick)+' / TIME '+format(s.tick/TICK_RATE,'.1f')+'s',7)
p.text(430,47,'DIST '+str(d)+' / '+str(total(road)),11)
if self.mode=='prep':
p.rect(12,76,676,210,7)
p.text(24,88,'FINITE MODULE BANK / SYMBOLIC SOCKET DIAGRAM',1)
dx=640//len(self.board.modules)
for i,(name,opened,folded) in enumerate(self.board.modules):
x=28+i*dx;end=x+dx-18;mid=(x+end)//2
if folded:
paths=(((x,180),(x+dx//4,130),(end-dx//4,130),(end,180)),((x,180),(mid,162),(end,180)))
for j,path in enumerate(paths):
color=8 if (j==1)==(self.fold==name) else 6
for a,b in zip(path,path[1:]):p.line(*a,*b,color)
p.line(mid,112,mid,216,9)
else:p.line(x,180,end,180,1)
p.circ(x,180,3,12);p.circ(end,180,3,12)
if i+1<len(self.board.modules):p.line(end,180,x+dx,180,1)
chosen=folded if self.fold==name else opened
p.text(x,229,'MODULE '+str(i+1),1)
p.text(x,247,'LEN '+str(sum(v[0] for v in chosen)),1)
p.text(12,302,'ENTER / Z START. ROAD LENGTH IS THE NUMBER, NOT DIAGRAM PIXELS.',10)
else:
# The perspective is illustrative; collision uses integer q and width.
p.rect(12,77,395,273,3);center=210
for y in range(331,85,-2):
ahead=(331-y)//2;_,_,seg=at(road,d+ahead);center+=seg.curve*.23
half=seg.width*4
p.rect(center-half,y,half*2,2,5)
p.rect(center-half,y,2,2,7);p.rect(center+half-2,y,2,2,7)
if (d+ahead)//6%2==0:p.rect(center,y,2,2,6)
ghost=self.best.get(course_hash(self.board,self.fold,self.wide))
if ghost and s.tick<len(ghost):
g=ghost[s.tick];dy=(distance(g,road)-d)*2
if -5<=dy<=230:p.rectb(210+g.q/4-7,315-dy,14,22,13)
x=210+s.q/4;p.rect(x-5,316,10,20,10);p.rect(x-3,320,6,5,7)
i=min(s.segment,len(road)-1);seg=road[i]
p.text(430,87,'SPEED '+str(s.v)+' / 4',10)
p.text(430,108,'ACCEL '+str(s.accel)+' / BRAKE '+str(s.brake),6)
p.text(430,132,'CONTACTS '+str(s.contacts),8 if s.contacts else 11)
p.text(430,154,'Q '+str(s.q)+' (16 = 1 UNIT)',7)
p.text(430,178,'SEG '+str(s.segment)+' / OFFSET '+str(s.offset),7)
p.text(430,200,'CURVE '+str(seg.curve)+' / HALF WIDTH '+str(seg.width),6)
p.text(430,224,'STOPPED TICKS '+str(s.stopped),7)
p.text(430,247,'OUTCOME '+s.outcome.upper(),10)
if i+1<len(road):
nxt=road[i+1];p.text(430,270,'NEXT C '+str(nxt.curve)+' / W '+str(nxt.width),11)
if ghost:p.text(430,294,'SAME COURSE BEST '+format(ghost[-1].tick/TICK_RATE,'.1f')+'s',13)
p.text(12,366,STAGE_NOTE,10)
p.text(12,388,'UP ACCEL / DOWN BRAKE / LEFT-RIGHT STEER (WHEN INTRODUCED)',7)
p.text(12,408,'P PAUSE / R RETRY SAME ROAD / X PREP (DISCARDS RUN) / ESC QUIT',6)
p.text(12,428,'PREP: 1-3 COURSE / SPACE OPEN-A-B / B WIDTH COMPARE (A FOLD ONLY)',7)
p.text(12,449,'INTEGER 10 HZ / 3 DRAW FRAMES PER LOGICAL TICK / RELEASE MENU KEYS',13)
def demo_held(frame):
if frame==0:return {'SPACE'}
if frame==2:return {'RETURN'}
if frame<4:return set()
return {'UP'}
def main(headless=False,screenshot=None,report=None,demo=False):
import pyxel as p
p.init(700,470,title='Paper rally lesson '+str(STAGE),fps=30,display_scale=1,headless=headless)
app=App();previous=set();limit=132 if demo else 8
keys={k:getattr(p,'KEY_'+k) for k in ('LEFT','RIGHT','UP','DOWN','RETURN','SPACE','Z','X','R','P','1','2','3','B')}
def update():
nonlocal previous
if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
if screenshot:app.draw(p);p.screen.save(screenshot,1)
if report:
from pathlib import Path
data={'stage':STAGE,'mode':app.mode,'state':asdict(app.s),'scope':'actual Pyxel; synthetic held input; not OS keyboard or human playtest'}
data['replay']=replay_data(app.board,app.inputs,app.fold,app.wide)
Path(report).write_text(json.dumps(data,ensure_ascii=False,indent=2),encoding='utf-8')
p.quit();return
if headless:
held=demo_held(p.frame_count) if demo else set();pressed=held-previous;previous=held
else:
held={k for k,v in keys.items() if p.btn(v)};pressed={k for k,v in keys.items() if p.btnp(v)}
app.control(pressed,held)
p.run(update,lambda:app.draw(p))
if __name__=='__main__':
import argparse
from pathlib import Path
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--headless-smoke',action='store_true');parser.add_argument('--demo-checkpoint',action='store_true')
parser.add_argument('--screenshot');parser.add_argument('--report')
parser.add_argument('--check-replay',type=Path)
args=parser.parse_args()
if args.check_replay:
if args.headless_smoke or args.demo_checkpoint or args.screenshot or args.report:parser.error('check-replay is a separate read-only mode')
try:
data=json.loads(args.check_replay.read_text(encoding='utf-8'))
if not isinstance(data,dict):raise ValueError('replay must be a JSON object')
print(check_replay(data.get('replay',data)))
except (OSError,ValueError,KeyError,TypeError,IndexError) as error:parser.error(str(error))
raise SystemExit(0)
if (args.demo_checkpoint or args.screenshot or args.report) and not args.headless_smoke:parser.error('demo and output require --headless-smoke')
for field,suffix in (('screenshot','.png'),('report','.json')):
value=getattr(args,field)
if value:
target=Path(value).resolve()
if target.suffix.lower()!=suffix or not target.parent.is_dir() or target.exists():parser.error('use a new '+suffix+' in an existing directory')
setattr(args,field,str(target))
main(args.headless_smoke,args.screenshot,args.report,args.demo_checkpoint)仕組みがわかったら、元の完成版を読んでみよう。
ルールのまとめ:ふつうのルートは限られた7通り、A を折った道の幅の比較が1つ。10Hz、加速は4刻続けて・減速は2刻続けて、速さ0〜4。移動の前にハンドルで q ±16、距離1つごとに curve×v。接触は1刻に1回まで、決まった距離を進んでから次の速さを1に。ゴールで止まる。
ここまでに作ったのは、元のゲームの仕組みだけを取り出して作り直した「簡略版」です。元のゲームのソースは手を加えずに、別のZIP(元の完成版ソース)に入れてあります。
| 比べる点 | 5ステップの教材 | 元の完成版 |
|---|---|---|
| 段階 | 01 一定の速さで練習用のまっすぐな道、02 速さ、03 元のゲームの step と状況 A の折っていない道、04 限られた道すべて、05 元のゲームのルール全部・リプレイ・ゴースト。State の欄の一部は先に置いています。 | 最初から、限られた道すべてと、すべての機能があります。 |
| 入力と画面 | 英数字(ASCII)、Space で限られた候補を順に切り替え、X ですぐ準備へ。図の距離は数値で確かめます。 | 日本語の文字、折り線の選択、折るアニメーション、X での確認の画面。 |
| 保存 | ベストの記録はメモリーの中だけ。画面を出さない(headless)コマンドの report と check-replay だけ。ふつうの S/F12/GIF はなし。 | S で入力の保存、F12 で画像、出来事の記録と GIF。 |
完成版のコードで、次に読むところ
- model.py の
course/step/at - model.py の
board_data/course_hash/replay_data/check_replay - experiments.py の
driver/run:限られた数の走り方の比較 - app.py の
tick/race/save_run/fold_now
最初は、数字かルールを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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。
うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る
