完成サンプルをブラウザーで遊ぶ
この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。
完成版の主な操作:左右キーで移動、Spaceでひもをつなぐ・離す、上下キーでひもの長さを変える、AとDで杭を選ぶ、Zで休んだ場所に戻る、Rでやり直し、Pで一時停止
ボタンを押すと、ゲームの読み込みが始まります。
画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。
うまく表示されないとき
ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。
力・速度・位置を、この順番で更新する。
いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、5つのステップで作っていきます。どのステップのファイルもそれだけで動くので、動きを確かめてから次へ進みましょう。
dt ずつ積み上げる- 必要な環境
- Python 3.11以上 / Pyxel 2.9.9
- 知っておくとよいこと
- Python 3.11以上と仮想環境の基本操作 / 変数・if・for・関数・クラスの初歩 / 足し算・引き算・掛け算・割り算。ベクトルと平方根は本文で説明します
- 教材に入っているもの
- ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
- 完成した教材の操作
- 左右:歩く/空中で動きを調整する / Space:ひもをつなぐ・切り離す(02から) / 上下:自然長を変える(03から) / Z:棚に戻る(04から) / A D:杭を選ぶ(05) / P:一時停止 / R:最初に戻す / Esc:終了
- 難易度の目安
- 中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
- 学習順に迷ったら
- おすすめの学ぶ順番を見る
コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。
教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。
位置・速度・重力を分ける
1秒あたりの変化を、短い時間 dt ずつ積み上げる。
画面では、右が x のプラス、下が y のプラスです。x・y は位置で、単位は px(画面の点)です。vx・vy は速度で、単位は px/s(1秒に何px進むか)です。ax・ay は加速度(速度が1秒にどれだけ変わるか)で、単位は px/s² です。まず、速度に「加速度×dt」を足します。そして、その新しい速度で位置を進めます。DT=1/120 秒の更新を、描画1回につき4回行います。描画は30Hz(1秒に30回)なので、1秒に120刻み進みます。人物は、最初に (36,52) から落ちます。半径4の体の中心が、床の上面より4上の 104−4=100 に来ると止まります。空中で左右を押すと加速度24、地上では速度36で動きます。この段階は、広い床が1枚あるだけです。着地は「上面を通り過ぎたか」だけを見る、簡単な判定です。横の面へのぶつかりも、ひももありません。
下のコードは 01_motion.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
vx += ax * dt
vy += ay * dt実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 01_motion.py.venv/bin/python 01_motion.py- 位置・速度・加速度の単位を言える
- 床の上面104と人物の中心100を区別する
- 4刻みで1/30秒になると計算できる
dt を掛け忘れると、120倍の速さになります。数学のグラフでは y が上向きですが、この画面ではちがいます。vy がプラスなら下向きです。赤い線は、速度の向きを見せる図です。1フレームで動く距離ではありません。空中での最初の1刻みで、vy=100/120≈0.833 になり、位置は 0.833/120≈0.00694 だけ増えます。紙で計算して、test_gravity_units と比べましょう。GRAVITY を50に変える前に、同じ1刻みでどれだけ増えるかを予想してみましょう。
このステップのコード全文(243行)
# 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.
# 教材にはNoto字形を含めません。ASCIIと図形だけを描きます。
"""ゴムひもの山歩き・教材 1/5。Python 3.11+ / Pyxel 2.9.9。"""
from dataclasses import dataclass, replace, asdict
from math import hypot, isfinite
FPS = 30
DT = 1 / 120 # 秒。1描画の間に4回更新する
RADIUS = 4.0
GRAVITY = 100.0 # px/s²。画面の下が正
MAX_SPEED = 100.0 # px/s
REACH = 68.0
@dataclass(frozen=True)
class Rock:
left: float
right: float
top: float
danger: bool = False
@dataclass(frozen=True)
class Route:
title: str
rocks: tuple
pins: tuple
start: tuple
goal: int
hint: str
ROUTE = Route('MOTION LAB', (Rock(8, 188, 104),), (), (36, 52), -1, 'Watch gravity, then walk')
@dataclass(frozen=True)
class Input:
move: int = 0
@dataclass(frozen=True)
class State:
x: float
y: float
vx: float = 0.0
vy: float = 0.0
grounded: int = -1
elapsed: float = 0.0
def initial(route):
return State(*route.start)
def step(route, s, inp=Input(), dt=DT):
if not 0 < dt <= 1 / 30:
raise ValueError('dt must be positive and <= 1/30')
if not all(isfinite(v) for v in (s.x, s.y, s.vx, s.vy)):
raise ValueError('nonfinite state')
move = max(-1, min(1, inp.move))
ax, ay = 24.0 * move, GRAVITY
vx, vy, ground = s.vx, s.vy, s.grounded
if ground >= 0:
rock = route.rocks[ground]
if not rock.left <= s.x <= rock.right:
ground = -1
else:
vx, vy = 36.0 * move, 0.0
if ground >= 0:
ax = 0.0
if ay < -0.1:
ground = -1
else:
ay = 0.0
# 加速度→速度→位置。dtを掛けて単位を合わせる。
vx += ax * dt
vy += ay * dt
speed = hypot(vx, vy)
if speed > MAX_SPEED:
vx *= MAX_SPEED / speed
vy *= MAX_SPEED / speed
x, y = s.x + vx * dt, s.y + vy * dt
# 01〜03だけの簡略判定。上から棚の上面を越えた時だけ止める。
for i, rock in enumerate(route.rocks):
if vy > 0 and s.y <= rock.top - RADIUS <= y and rock.left <= x <= rock.right:
y, vy, ground = rock.top - RADIUS, 0.0, i
if x < 4:
x, vx = 4.0, max(0.0, vx)
if x > 188:
x, vx = 188.0, min(0.0, vx)
if y < 4:
y, vy = 4.0, max(0.0, vy)
if ground >= 0 and not route.rocks[ground].left <= x <= route.rocks[ground].right:
ground = -1
if y > 140:
return initial(route) # 練習版は開始位置へ戻す(経過も初期化)
return replace(s, x=x, y=y, vx=vx, vy=vy, grounded=ground, elapsed=s.elapsed+dt)
def frame(route, state, inp=Input()):
for n in range(4):
once = inp
state = step(route, state, once)
return state
class Controller:
"""実操作とリプレイの両方が通る、同じ入力窓口。"""
def __init__(self):
self.state = initial(ROUTE)
self.paused = False
def tick(self, held=(), pressed=()):
held, pressed = set(held), set(pressed)
if 'R' in pressed:
self.state = initial(ROUTE)
self.paused = False
return
if 'P' in pressed:
self.paused = not self.paused
return
if self.paused:
return
inp = Input(move=int('RIGHT' in held)-int('LEFT' in held))
self.state = frame(ROUTE, self.state, inp)
KEYS = ('LEFT', 'RIGHT', 'P', 'R')
def sample_keys(pyxel):
"""Pyxelの連続入力btnと押し始めbtnpを分ける。"""
return ([name for name in KEYS if pyxel.btn(getattr(pyxel, 'KEY_'+name))],
[name for name in KEYS if pyxel.btnp(getattr(pyxel, 'KEY_'+name))])
def main(headless=False, replay=None, screenshot=None, report=None):
import json
from pathlib import Path
# PyxelがCWDを変更しても、指定した出力先を維持する。
screenshot = str(Path(screenshot).resolve()) if screenshot else None
report = str(Path(report).resolve()) if report else None
import pyxel
control = Controller()
frames, events = 0, []
duration = max(12, len(replay or []))
def update():
nonlocal frames
frames += 1
if headless:
if replay is not None and frames <= len(replay):
e = replay[frames-1]
control.tick(e.get('held', []), e.get('pressed', []))
events.append({'frame': frames, 'input': e, 'state': asdict(control.state)})
else:
control.tick(*sample_keys(pyxel))
def draw():
s = control.state
pyxel.cls(1)
pyxel.tri(0,240,82,64,188,240,5)
pyxel.tri(120,240,250,74,384,240,13)
for r in ROUTE.rocks:
x,y,w = r.left*2, r.top*2, (r.right-r.left)*2
pyxel.rect(x,y,w,288-y,4 if not r.danger else 2)
pyxel.line(x,y,x+w,y,11 if not r.danger else 8)
for yy in range(int(y)+12, 288, 15):
pyxel.line(x+2,yy,x+w-2,yy,9 if not r.danger else 8)
pyxel.circ(s.x*2,s.y*2,RADIUS*2,10)
pyxel.circb(s.x*2,s.y*2,RADIUS*2,7)
pyxel.line(s.x*2,s.y*2,s.x*2+s.vx*.3,s.y*2+s.vy*.3,8)
pyxel.rect(0,0,384,31,1)
pyxel.text(8,7,'ROPE HIKE / STEP 1 - '+ROUTE.title,10)
pyxel.text(8,20,f'POS {s.x:6.2f},{s.y:6.2f} VEL {s.vx:6.2f},{s.vy:6.2f}',7)
pyxel.rect(0,288,384,96,1)
pyxel.text(8,293,f'TIME {s.elapsed:5.2f}s GROUND {s.grounded} RED LINE = VELOCITY',7)
pyxel.text(8,307,'UNITS: px, px/s, px/s2 / Y POINTS DOWN',6)
pyxel.text(8,321,'FALL ONTO THE FLOOR, THEN WALK.',10)
pyxel.text(8,347,'LEFT/RIGHT WALK',7)
pyxel.text(8,361,'P PAUSE / R RESET / ESC QUIT / SILENT',6)
if control.paused:
pyxel.rect(92,130,202,24,0)
pyxel.text(106,139,'PAUSED - P TO RESUME',10)
if headless and frames >= duration:
if screenshot:
pyxel.screenshot(screenshot, scale=2)
if report:
Path(report).write_text(json.dumps({
'label': 'Synthetic Controller input; real Pyxel headless; silent',
'render_frames': frames, 'input_frames': len(events),
'events': events, 'final_state': asdict(s), 'paused': control.paused,
}, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
pyxel.quit()
pyxel.init(384,384,title='Rope Hike - Step 1',fps=FPS,headless=headless)
pyxel.run(update,draw)
if __name__ == '__main__':
import argparse
import json
from pathlib import Path
parser = argparse.ArgumentParser(description=__doc__)
modes = parser.add_mutually_exclusive_group()
modes.add_argument('--headless-smoke', action='store_true')
modes.add_argument('--headless-replay', help='UTF-8 JSON held/pressed list')
parser.add_argument('--screenshot')
parser.add_argument('--report')
args = parser.parse_args()
headless = args.headless_smoke or args.headless_replay is not None
if not headless and (args.screenshot or args.report):
parser.error('screenshot/report require headless mode')
replay = None
if args.headless_replay:
replay = json.loads(Path(args.headless_replay).read_text(encoding='utf-8'))
if not isinstance(replay, list):
parser.error('Replay must be a list')
previous = set()
for e in replay:
if not isinstance(e, dict) or set(e)-{'held','pressed'} or any(
not isinstance(e.get(k, []), list) or any(not isinstance(v,str) or v not in KEYS for v in e.get(k, []))
for k in ('held','pressed')):
parser.error('Use supported held/pressed key names')
held, pressed = set(e.get('held', [])), set(e.get('pressed', []))
if not pressed <= held or pressed & previous:
parser.error('pressed must be newly held keys')
previous = held
main(headless, replay, args.screenshot, args.report)張ったときだけ引くばねを作る
差・距離・単位ベクトル(長さ1の向きの矢印)で、力の向きを決める。
スタートの棚を短くして、杭 (70,48) と、自然長38のひもを加えます。Space を押し始めたときにひもをつなぎ、もう一度押すと離します。dx=杭のx−人物のx、dy=杭のy−人物のy で、杭へ向かう矢印になります。hypot(dx,dy) が距離 d です。(dx/d,dy/d) は、長さ1の向きです。d<=自然長なら、張力(ひもが引っぱる力)は0です。ひもが伸びている間は、「14×伸び」に「3×外向きの速度」を足します。それを0〜240に制限した加速度で、人物を杭の方へ引きます。これは、重さ(質量)を1とみなした、ゲーム用の簡単な計算です。離れていく動きを弱める部分もふくむので、伸びていても素早く近づいているときは、張力が0になることがあります。ひもをつなげる距離は68以下です。岩がひもをさえぎる処理は、04まで扱いません。
下のコードは 02_spring.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if d<=length or d<1e-9:return (0.,0.,0.)実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 02_spring.py.venv/bin/python 02_spring.pydx・dyと距離dを区別する- 距離が自然長と同じときは引かない
- Space を押し続けても、つなぐ・離すが続けて切り替わらない
dx・dy をそのまま力に掛けると、距離が長いほど向きのベクトルまで大きくなります。距離で割って、長さを1にします。d=0 のときは割り算ができないので、その前に処理を終えて戻ります。また、d−length をマイナスのまま使うと、たるんだひもが人物を押してしまいます。人物が (0,0)、杭が (30,40)、自然長が40、速度が0なら、d=50、向きは (0.6,0.8)、張力は140、加速度は (84,112) です。次に、距離38・自然長38のときに張力が0になることを、test_zero_distance_slack_and_exact_natural_length で確かめましょう。
このステップのコード全文(282行)
# 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.
# 教材にはNoto字形を含めません。ASCIIと図形だけを描きます。
"""ゴムひもの山歩き・教材 2/5。Python 3.11+ / Pyxel 2.9.9。"""
from dataclasses import dataclass, replace, asdict
from math import hypot, isfinite
FPS = 30
DT = 1 / 120 # 秒。1描画の間に4回更新する
RADIUS = 4.0
GRAVITY = 100.0 # px/s²。画面の下が正
MAX_SPEED = 100.0 # px/s
REACH = 68.0
@dataclass(frozen=True)
class Rock:
left: float
right: float
top: float
danger: bool = False
@dataclass(frozen=True)
class Route:
title: str
rocks: tuple
pins: tuple
start: tuple
goal: int
hint: str
ROUTE = Route('SPRING LAB', (Rock(8, 50, 104),), ((70, 48),), (36, 100), -1, 'Attach, swing, release')
@dataclass(frozen=True)
class Input:
move: int = 0
toggle: bool = False # 押し始めだけTrue
@dataclass(frozen=True)
class State:
x: float
y: float
vx: float = 0.0
vy: float = 0.0
grounded: int = 0
elapsed: float = 0.0
pin: int = -1
length: float = 38.0
tension: float = 0.0
reason: str = 'start'
def initial(route):
return State(*route.start)
def tension_accel(point, velocity, anchor, length):
# 杭への差(dx,dy)、距離d、長さ1の向き(nx,ny)を求める。
dx,dy=anchor[0]-point[0],anchor[1]-point[1];d=hypot(dx,dy)
if d<=length or d<1e-9:return (0.,0.,0.)
nx,ny=dx/d,dy/d
outward=-(velocity[0]*nx+velocity[1]*ny)
force=max(0.,min(240.,14.*(d-length)+3.*outward))
return nx*force,ny*force,force
def step(route, s, inp=Input(), dt=DT):
if not 0 < dt <= 1 / 30:
raise ValueError('dt must be positive and <= 1/30')
if not all(isfinite(v) for v in (s.x, s.y, s.vx, s.vy)):
raise ValueError('nonfinite state')
if inp.toggle:
if s.pin >= 0:
s = replace(s, pin=-1, tension=0.0, reason='release')
elif hypot(route.pins[0][0]-s.x, route.pins[0][1]-s.y) <= REACH:
s = replace(s, pin=0, reason='attach')
else:
s = replace(s, reason='out_of_reach')
move = max(-1, min(1, inp.move))
ax, ay = 24.0 * move, GRAVITY
vx, vy, ground = s.vx, s.vy, s.grounded
if ground >= 0:
rock = route.rocks[ground]
if not rock.left <= s.x <= rock.right:
ground = -1
else:
vx, vy = 36.0 * move, 0.0
force = 0.0
if s.pin >= 0:
tx, ty, force = tension_accel((s.x, s.y), (vx, vy), route.pins[s.pin], s.length)
ax += tx
ay += ty
if ground >= 0:
ax = 0.0
if ay < -0.1:
ground = -1
else:
ay = 0.0
# 加速度→速度→位置。dtを掛けて単位を合わせる。
vx += ax * dt
vy += ay * dt
speed = hypot(vx, vy)
if speed > MAX_SPEED:
vx *= MAX_SPEED / speed
vy *= MAX_SPEED / speed
x, y = s.x + vx * dt, s.y + vy * dt
# 01〜03だけの簡略判定。上から棚の上面を越えた時だけ止める。
for i, rock in enumerate(route.rocks):
if vy > 0 and s.y <= rock.top - RADIUS <= y and rock.left <= x <= rock.right:
y, vy, ground = rock.top - RADIUS, 0.0, i
if x < 4:
x, vx = 4.0, max(0.0, vx)
if x > 188:
x, vx = 188.0, min(0.0, vx)
if y < 4:
y, vy = 4.0, max(0.0, vy)
if ground >= 0 and not route.rocks[ground].left <= x <= route.rocks[ground].right:
ground = -1
if y > 140:
return initial(route) # 練習版は開始位置へ戻す(経過も初期化)
return replace(s, x=x, y=y, vx=vx, vy=vy, grounded=ground, elapsed=s.elapsed+dt, tension=force)
def frame(route, state, inp=Input()):
for n in range(4):
once = inp if n == 0 else replace(inp, toggle=False)
state = step(route, state, once)
return state
class Controller:
"""実操作とリプレイの両方が通る、同じ入力窓口。"""
def __init__(self):
self.state = initial(ROUTE)
self.paused = False
self.space_ready = True
def tick(self, held=(), pressed=()):
held, pressed = set(held), set(pressed)
if 'SPACE' not in held:
self.space_ready = True
if 'R' in pressed:
self.state = initial(ROUTE)
self.paused = False
self.space_ready = False
return
if 'P' in pressed:
self.paused = not self.paused
self.space_ready = False
return
if self.paused:
return
inp = Input(move=int('RIGHT' in held)-int('LEFT' in held),
toggle='SPACE' in pressed and self.space_ready)
self.state = frame(ROUTE, self.state, inp)
KEYS = ('LEFT', 'RIGHT', 'P', 'R', 'SPACE')
def sample_keys(pyxel):
"""Pyxelの連続入力btnと押し始めbtnpを分ける。"""
return ([name for name in KEYS if pyxel.btn(getattr(pyxel, 'KEY_'+name))],
[name for name in KEYS if pyxel.btnp(getattr(pyxel, 'KEY_'+name))])
def main(headless=False, replay=None, screenshot=None, report=None):
import json
from pathlib import Path
# PyxelがCWDを変更しても、指定した出力先を維持する。
screenshot = str(Path(screenshot).resolve()) if screenshot else None
report = str(Path(report).resolve()) if report else None
import pyxel
control = Controller()
frames, events = 0, []
duration = max(12, len(replay or []))
def update():
nonlocal frames
frames += 1
if headless:
if replay is not None and frames <= len(replay):
e = replay[frames-1]
control.tick(e.get('held', []), e.get('pressed', []))
events.append({'frame': frames, 'input': e, 'state': asdict(control.state)})
else:
control.tick(*sample_keys(pyxel))
def draw():
s = control.state
pyxel.cls(1)
pyxel.tri(0,240,82,64,188,240,5)
pyxel.tri(120,240,250,74,384,240,13)
for r in ROUTE.rocks:
x,y,w = r.left*2, r.top*2, (r.right-r.left)*2
pyxel.rect(x,y,w,288-y,4 if not r.danger else 2)
pyxel.line(x,y,x+w,y,11 if not r.danger else 8)
for yy in range(int(y)+12, 288, 15):
pyxel.line(x+2,yy,x+w-2,yy,9 if not r.danger else 8)
for n, (x,y) in enumerate(ROUTE.pins):
pyxel.circ(x*2,y*2,4,9)
pyxel.text(x*2+8,y*2-2,str(n+1),7)
if s.pin >= 0:
anchor = ROUTE.pins[s.pin]
pyxel.line(s.x*2,s.y*2,anchor[0]*2,anchor[1]*2,10 if s.tension>0 else 6)
pyxel.circ(s.x*2,s.y*2,RADIUS*2,10)
pyxel.circb(s.x*2,s.y*2,RADIUS*2,7)
pyxel.line(s.x*2,s.y*2,s.x*2+s.vx*.3,s.y*2+s.vy*.3,8)
pyxel.rect(0,0,384,31,1)
pyxel.text(8,7,'ROPE HIKE / STEP 2 - '+ROUTE.title,10)
pyxel.text(8,20,f'POS {s.x:6.2f},{s.y:6.2f} VEL {s.vx:6.2f},{s.vy:6.2f}',7)
pyxel.rect(0,288,384,96,1)
pyxel.text(8,293,f'TIME {s.elapsed:5.2f}s GROUND {s.grounded} RED LINE = VELOCITY',7)
pyxel.text(8,307,f'LENGTH {s.length:4.1f} TENSION {s.tension:5.1f} PIN {s.pin+1}',10)
pyxel.text(8,321,'EVENT '+s.reason.upper(),7)
pyxel.text(8,347,'ARROWS WALK / SPACE ATTACH-RELEASE',7)
pyxel.text(8,361,'P PAUSE / R RESET / ESC QUIT / SILENT',6)
if control.paused:
pyxel.rect(92,130,202,24,0)
pyxel.text(106,139,'PAUSED - P TO RESUME',10)
if headless and frames >= duration:
if screenshot:
pyxel.screenshot(screenshot, scale=2)
if report:
Path(report).write_text(json.dumps({
'label': 'Synthetic Controller input; real Pyxel headless; silent',
'render_frames': frames, 'input_frames': len(events),
'events': events, 'final_state': asdict(s), 'paused': control.paused,
}, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
pyxel.quit()
pyxel.init(384,384,title='Rope Hike - Step 2',fps=FPS,headless=headless)
pyxel.run(update,draw)
if __name__ == '__main__':
import argparse
import json
from pathlib import Path
parser = argparse.ArgumentParser(description=__doc__)
modes = parser.add_mutually_exclusive_group()
modes.add_argument('--headless-smoke', action='store_true')
modes.add_argument('--headless-replay', help='UTF-8 JSON held/pressed list')
parser.add_argument('--screenshot')
parser.add_argument('--report')
args = parser.parse_args()
headless = args.headless_smoke or args.headless_replay is not None
if not headless and (args.screenshot or args.report):
parser.error('screenshot/report require headless mode')
replay = None
if args.headless_replay:
replay = json.loads(Path(args.headless_replay).read_text(encoding='utf-8'))
if not isinstance(replay, list):
parser.error('Replay must be a list')
previous = set()
for e in replay:
if not isinstance(e, dict) or set(e)-{'held','pressed'} or any(
not isinstance(e.get(k, []), list) or any(not isinstance(v,str) or v not in KEYS for v in e.get(k, []))
for k in ('held','pressed')):
parser.error('Use supported held/pressed key names')
held, pressed = set(e.get('held', [])), set(e.get('pressed', []))
if not pressed <= held or pressed & previous:
parser.error('pressed must be newly held keys')
previous = held
main(headless, replay, args.screenshot, args.report)巻き取りと切り離しを比べる
自然長を変えることと、速度を変えることを分ける。
ひもをつないでいる間だけ、上キーで自然長を毎秒12px短くし、下キーで長くします。範囲は14〜42です。描画1回は1/30秒なので、上を1フレーム押すと0.4px短くなります。自然長を短くすると、伸び(d−length)が増えます。その結果として、張力が変わります。位置に直接代入して、杭へ引き寄せる処理ではありません。Space で切り離すと、pin(つないでいる杭)と張力だけを外します。vx・vy に、ジャンプの分を足したりはしません。ただし、切り離した同じ刻みでも、重力や空中での操作は続きます。そのため、1刻み後の速度がまったく同じとは限りません。ひもなしで自由に動いた場合と比べると、切り離しで余分に跳ぶ力が加わっていないことを確かめられます。
下のコードは 03_reel_release.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
s = replace(s, length=max(14.0, min(42.0, s.length + max(-1, min(1, inp.reel)) * 12 * dt)))実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 03_reel_release.py.venv/bin/python 03_reel_release.py- 自然長と今の距離は、別の量だと説明できる
- ひもをつないでいない間は、上下キーで長さが変わらない
- 切り離しを自由な動きと比べて、余分な力が加わっていないと分かる
vx=vy=0 にすると、その場で勢いがなくなります。逆に vy-=50 を加えると、元のゲームにないジャンプになります。巻き取り器は、外からエネルギーを加える装置として簡単に扱っています。そのため、自然長を変えたときに、力学的エネルギー(動きと高さのエネルギーの合計)が変わらない、とは説明できません。qa/03_winding_inputs.json と 03_release_inputs.json を実行して、VEL(速度)の横の成分を比べます。次に、巻き取りを下キーに変えて、38から42まで10フレームで届くことを予想します。14より短く、42より長くしようとしても止まる理由を、max と min から読み取りましょう。
このステップのコード全文(287行)
# 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.
# 教材にはNoto字形を含めません。ASCIIと図形だけを描きます。
"""ゴムひもの山歩き・教材 3/5。Python 3.11+ / Pyxel 2.9.9。"""
from dataclasses import dataclass, replace, asdict
from math import hypot, isfinite
FPS = 30
DT = 1 / 120 # 秒。1描画の間に4回更新する
RADIUS = 4.0
GRAVITY = 100.0 # px/s²。画面の下が正
MAX_SPEED = 100.0 # px/s
REACH = 68.0
@dataclass(frozen=True)
class Rock:
left: float
right: float
top: float
danger: bool = False
@dataclass(frozen=True)
class Route:
title: str
rocks: tuple
pins: tuple
start: tuple
goal: int
hint: str
ROUTE = Route('SPRING LAB', (Rock(8, 50, 104),), ((70, 48),), (36, 100), -1, 'Attach, swing, release')
@dataclass(frozen=True)
class Input:
move: int = 0
toggle: bool = False
reel: int = 0 # -1: 短くする、+1: 長くする
@dataclass(frozen=True)
class State:
x: float
y: float
vx: float = 0.0
vy: float = 0.0
grounded: int = 0
elapsed: float = 0.0
pin: int = -1
length: float = 38.0
tension: float = 0.0
reason: str = 'start'
def initial(route):
return State(*route.start)
def tension_accel(point, velocity, anchor, length):
# 杭への差(dx,dy)、距離d、長さ1の向き(nx,ny)を求める。
dx,dy=anchor[0]-point[0],anchor[1]-point[1];d=hypot(dx,dy)
if d<=length or d<1e-9:return (0.,0.,0.)
nx,ny=dx/d,dy/d
outward=-(velocity[0]*nx+velocity[1]*ny)
force=max(0.,min(240.,14.*(d-length)+3.*outward))
return nx*force,ny*force,force
def step(route, s, inp=Input(), dt=DT):
if not 0 < dt <= 1 / 30:
raise ValueError('dt must be positive and <= 1/30')
if not all(isfinite(v) for v in (s.x, s.y, s.vx, s.vy)):
raise ValueError('nonfinite state')
if inp.toggle:
if s.pin >= 0:
s = replace(s, pin=-1, tension=0.0, reason='release')
elif hypot(route.pins[0][0]-s.x, route.pins[0][1]-s.y) <= REACH:
s = replace(s, pin=0, reason='attach')
else:
s = replace(s, reason='out_of_reach')
if s.pin >= 0:
# 巻取りは自然長を変える。人物を杭へワープさせない。
s = replace(s, length=max(14.0, min(42.0, s.length + max(-1, min(1, inp.reel)) * 12 * dt)))
move = max(-1, min(1, inp.move))
ax, ay = 24.0 * move, GRAVITY
vx, vy, ground = s.vx, s.vy, s.grounded
if ground >= 0:
rock = route.rocks[ground]
if not rock.left <= s.x <= rock.right:
ground = -1
else:
vx, vy = 36.0 * move, 0.0
force = 0.0
if s.pin >= 0:
tx, ty, force = tension_accel((s.x, s.y), (vx, vy), route.pins[s.pin], s.length)
ax += tx
ay += ty
if ground >= 0:
ax = 0.0
if ay < -0.1:
ground = -1
else:
ay = 0.0
# 加速度→速度→位置。dtを掛けて単位を合わせる。
vx += ax * dt
vy += ay * dt
speed = hypot(vx, vy)
if speed > MAX_SPEED:
vx *= MAX_SPEED / speed
vy *= MAX_SPEED / speed
x, y = s.x + vx * dt, s.y + vy * dt
# 01〜03だけの簡略判定。上から棚の上面を越えた時だけ止める。
for i, rock in enumerate(route.rocks):
if vy > 0 and s.y <= rock.top - RADIUS <= y and rock.left <= x <= rock.right:
y, vy, ground = rock.top - RADIUS, 0.0, i
if x < 4:
x, vx = 4.0, max(0.0, vx)
if x > 188:
x, vx = 188.0, min(0.0, vx)
if y < 4:
y, vy = 4.0, max(0.0, vy)
if ground >= 0 and not route.rocks[ground].left <= x <= route.rocks[ground].right:
ground = -1
if y > 140:
return initial(route) # 練習版は開始位置へ戻す(経過も初期化)
return replace(s, x=x, y=y, vx=vx, vy=vy, grounded=ground, elapsed=s.elapsed+dt, tension=force)
def frame(route, state, inp=Input()):
for n in range(4):
once = inp if n == 0 else replace(inp, toggle=False)
state = step(route, state, once)
return state
class Controller:
"""実操作とリプレイの両方が通る、同じ入力窓口。"""
def __init__(self):
self.state = initial(ROUTE)
self.paused = False
self.space_ready = True
def tick(self, held=(), pressed=()):
held, pressed = set(held), set(pressed)
if 'SPACE' not in held:
self.space_ready = True
if 'R' in pressed:
self.state = initial(ROUTE)
self.paused = False
self.space_ready = False
return
if 'P' in pressed:
self.paused = not self.paused
self.space_ready = False
return
if self.paused:
return
inp = Input(move=int('RIGHT' in held)-int('LEFT' in held),
toggle='SPACE' in pressed and self.space_ready,
reel=int('DOWN' in held)-int('UP' in held))
self.state = frame(ROUTE, self.state, inp)
KEYS = ('LEFT', 'RIGHT', 'P', 'R', 'SPACE', 'UP', 'DOWN')
def sample_keys(pyxel):
"""Pyxelの連続入力btnと押し始めbtnpを分ける。"""
return ([name for name in KEYS if pyxel.btn(getattr(pyxel, 'KEY_'+name))],
[name for name in KEYS if pyxel.btnp(getattr(pyxel, 'KEY_'+name))])
def main(headless=False, replay=None, screenshot=None, report=None):
import json
from pathlib import Path
# PyxelがCWDを変更しても、指定した出力先を維持する。
screenshot = str(Path(screenshot).resolve()) if screenshot else None
report = str(Path(report).resolve()) if report else None
import pyxel
control = Controller()
frames, events = 0, []
duration = max(12, len(replay or []))
def update():
nonlocal frames
frames += 1
if headless:
if replay is not None and frames <= len(replay):
e = replay[frames-1]
control.tick(e.get('held', []), e.get('pressed', []))
events.append({'frame': frames, 'input': e, 'state': asdict(control.state)})
else:
control.tick(*sample_keys(pyxel))
def draw():
s = control.state
pyxel.cls(1)
pyxel.tri(0,240,82,64,188,240,5)
pyxel.tri(120,240,250,74,384,240,13)
for r in ROUTE.rocks:
x,y,w = r.left*2, r.top*2, (r.right-r.left)*2
pyxel.rect(x,y,w,288-y,4 if not r.danger else 2)
pyxel.line(x,y,x+w,y,11 if not r.danger else 8)
for yy in range(int(y)+12, 288, 15):
pyxel.line(x+2,yy,x+w-2,yy,9 if not r.danger else 8)
for n, (x,y) in enumerate(ROUTE.pins):
pyxel.circ(x*2,y*2,4,9)
pyxel.text(x*2+8,y*2-2,str(n+1),7)
if s.pin >= 0:
anchor = ROUTE.pins[s.pin]
pyxel.line(s.x*2,s.y*2,anchor[0]*2,anchor[1]*2,10 if s.tension>0 else 6)
pyxel.circ(s.x*2,s.y*2,RADIUS*2,10)
pyxel.circb(s.x*2,s.y*2,RADIUS*2,7)
pyxel.line(s.x*2,s.y*2,s.x*2+s.vx*.3,s.y*2+s.vy*.3,8)
pyxel.rect(0,0,384,31,1)
pyxel.text(8,7,'ROPE HIKE / STEP 3 - '+ROUTE.title,10)
pyxel.text(8,20,f'POS {s.x:6.2f},{s.y:6.2f} VEL {s.vx:6.2f},{s.vy:6.2f}',7)
pyxel.rect(0,288,384,96,1)
pyxel.text(8,293,f'TIME {s.elapsed:5.2f}s GROUND {s.grounded} RED LINE = VELOCITY',7)
pyxel.text(8,307,f'LENGTH {s.length:4.1f} TENSION {s.tension:5.1f} PIN {s.pin+1}',10)
pyxel.text(8,321,'EVENT '+s.reason.upper(),7)
pyxel.text(8,347,'ARROWS WALK-REEL / SPACE ATTACH-RELEASE',7)
pyxel.text(8,361,'P PAUSE / R RESET / ESC QUIT / SILENT',6)
if control.paused:
pyxel.rect(92,130,202,24,0)
pyxel.text(106,139,'PAUSED - P TO RESUME',10)
if headless and frames >= duration:
if screenshot:
pyxel.screenshot(screenshot, scale=2)
if report:
Path(report).write_text(json.dumps({
'label': 'Synthetic Controller input; real Pyxel headless; silent',
'render_frames': frames, 'input_frames': len(events),
'events': events, 'final_state': asdict(s), 'paused': control.paused,
}, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
pyxel.quit()
pyxel.init(384,384,title='Rope Hike - Step 3',fps=FPS,headless=headless)
pyxel.run(update,draw)
if __name__ == '__main__':
import argparse
import json
from pathlib import Path
parser = argparse.ArgumentParser(description=__doc__)
modes = parser.add_mutually_exclusive_group()
modes.add_argument('--headless-smoke', action='store_true')
modes.add_argument('--headless-replay', help='UTF-8 JSON held/pressed list')
parser.add_argument('--screenshot')
parser.add_argument('--report')
args = parser.parse_args()
headless = args.headless_smoke or args.headless_replay is not None
if not headless and (args.screenshot or args.report):
parser.error('screenshot/report require headless mode')
replay = None
if args.headless_replay:
replay = json.loads(Path(args.headless_replay).read_text(encoding='utf-8'))
if not isinstance(replay, list):
parser.error('Replay must be a list')
previous = set()
for e in replay:
if not isinstance(e, dict) or set(e)-{'held','pressed'} or any(
not isinstance(e.get(k, []), list) or any(not isinstance(v,str) or v not in KEYS for v in e.get(k, []))
for k in ('held','pressed')):
parser.error('Use supported held/pressed key names')
held, pressed = set(e.get('held', [])), set(e.get('pressed', []))
if not pressed <= held or pressed & previous:
parser.error('pressed must be newly held keys')
previous = held
main(headless, replay, args.screenshot, args.report)移動した線分で岩棚を調べる
上面だけの判定から、元のゲームの掃引衝突(動いた道すじ全体で調べる当たり判定)へ進む。
元のゲームのルート1の向こう岸を置き、元のゲームの segment_box と step の当たり判定の処理を取り入れます。移動前から移動後までを結ぶ線分が、半径4pxぶん大きくした岩の四角形に、最初に入る時刻を調べます。当たった面の向き(法線)が上からの (0,−1) で、下に向かって動いているなら着地です。横の面なら、vx だけを0にします。ぶつかったあとは0.01px外へ離し、残りの時間の移動を、最大4回のぶつかりまで処理します。大きくした四角形の角は四角いままなので、本物の円と角のぶつかりを正確に計算しているわけではありません。ひもの線分は、大きくする前の岩で調べます。ひもが岩でふさがれたら、SNAG(引っかかり)で止まります。Z で、スタートの棚に戻れます。距離が90をこえた状態が0.3秒続くと、ひもは自動で切り離されます。この段階には、一定時間止まったら到着とする判定と、中棚のチェックポイント(途中の復帰地点)は、まだありません。
下のコードは 04_collision.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if n==(0.,-1.) and vy>0:ground=i;vy=0.
elif n[0]:vx=0.
else:vy=0.実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 04_collision.py.venv/bin/python 04_collision.py- 棚への着地と、横の壁へのぶつかりを区別する
- 半径4だけ大きくすることと、0.01離すことを説明できる
- SNAG で止まり、Z で戻れる
grounded を設定すると、壁が床になってしまいます。また、元のゲームの岩は、上面から y=144 まであります。体のために大きくした四角形は、下のはしが148までです。見た目どおりの、細い線だけの足場とはちがいます。test_top_landing_uses_radius_and_separation と test_side_hit_stops_horizontal_not_landing を読みましょう。同じ岩でも、上からと横からでは、止める速度がちがうことを説明します。segment_box の戻り値が t=.1 なら、移動の線分の10%の場所で最初にぶつかっています。
このステップのコード全文(343行)
# 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.
# 教材にはNoto字形を含めません。ASCIIと図形だけを描きます。
"""ゴムひもの山歩き・教材 4/5。Python 3.11+ / Pyxel 2.9.9。"""
from dataclasses import dataclass, replace, asdict
from math import hypot, isfinite
FPS = 30
DT = 1 / 120 # 秒。1描画の間に4回更新する
RADIUS = 4.0
GRAVITY = 100.0 # px/s²。画面の下が正
MAX_SPEED = 100.0 # px/s
REACH = 68.0
@dataclass(frozen=True)
class Rock:
left: float
right: float
top: float
danger: bool = False
@dataclass(frozen=True)
class Route:
title: str
rocks: tuple
pins: tuple
start: tuple
goal: int
hint: str
ROUTE = Route('LOW SHELF', (Rock(8, 50, 104), Rock(100, 178, 100)), ((70, 48),), (36, 100), 1, 'Land on the right shelf')
@dataclass(frozen=True)
class Input:
move: int = 0
reel: int = 0 # -1 shorten, +1 lengthen
toggle: bool = False
recover: bool = False
@dataclass(frozen=True)
class State:
x: float
y: float
vx: float = 0.0
vy: float = 0.0
grounded: int = 0
pin: int = -1
length: float = 38.0
elapsed: float = 0.0
falls: int = 0
uses: tuple = ()
status: str = 'walking'
reason: str = 'start'
tension: float = 0.0
warning: float = 0.0
def initial(route):
return State(*route.start)
def segment_box(a,b,box):
"""First normalized hit and outward normal, or None (slab sweep)."""
lo,hi=0.0,1.0; normal=(0.0,0.0)
for axis,(lower,upper) in enumerate(((box[0],box[2]),(box[1],box[3]))):
p=a[axis];v=b[axis]-p
if abs(v)<1e-12:
if p<lower or p>upper:return None
continue
t1=(lower-p)/v;t2=(upper-p)/v
n=(-1.0,0.0) if axis==0 else (0.0,-1.0)
if t1>t2:t1,t2=t2,t1;n=(1.0,0.0) if axis==0 else (0.0,1.0)
if t1>lo:lo=t1;normal=n
hi=min(hi,t2)
if lo>hi:return None
if hi<0 or lo>1:return None
return (lo,normal)
def rope_clear(route, point, pin):
# Endpoint is character center, always radius 4 outside a safe rock.
for rock in route.rocks:
if segment_box(point,pin,(rock.left,rock.top,rock.right,144)) is not None:return False
return True
def candidates(route,state):
return tuple(i for i,p in enumerate(route.pins)
if hypot(p[0]-state.x,p[1]-state.y)<=REACH and rope_clear(route,(state.x,state.y),p))
def tension_accel(point, velocity, anchor, length):
dx,dy=anchor[0]-point[0],anchor[1]-point[1];d=hypot(dx,dy)
if d<=length or d<1e-9:return (0.,0.,0.)
nx,ny=dx/d,dy/d
outward=-(velocity[0]*nx+velocity[1]*ny)
force=max(0.,min(240.,14.*(d-length)+3.*outward))
return nx*force,ny*force,force
def recover(route,state,reason='fall'):
rock=route.rocks[0]
return replace(state,x=route.start[0],y=rock.top-RADIUS,vx=0.,vy=0.,
grounded=0,pin=-1,length=38.,status='walking',
tension=0.,warning=0.,falls=state.falls+int(reason!='return'),reason=reason)
def step(route,s,inp=Input(),dt=DT,adjustable=True):
if not 0<dt<=1/30:raise ValueError('dt must be fixed and <= 1/30')
if not all(isfinite(v) for v in (s.x,s.y,s.vx,s.vy,s.length)):raise ValueError('nonfinite state')
if inp.recover:return recover(route,s,'return')
if s.status in ('clear','snag'):return s
old=s
if inp.toggle:
if s.pin>=0:s=replace(s,pin=-1,warning=0.,tension=0.,reason='release')
elif 0 in candidates(route,s):
s=replace(s,pin=0,uses=s.uses+(0,),reason='attach')
else:s=replace(s,reason='out_of_reach')
if s.pin>=0 and adjustable:s=replace(s,length=max(14.,min(42.,s.length+max(-1,min(1,inp.reel))*12*dt)))
ax=24.*max(-1,min(1,inp.move));ay=GRAVITY;force=0.
vx,vy=s.vx,s.vy;ground=s.grounded
if ground>=0:
rock=route.rocks[ground]
if not rock.left<=s.x<=rock.right:ground=-1
else:vx=36.*max(-1,min(1,inp.move));vy=0.
if s.pin>=0:
tx,ty,force=tension_accel((s.x,s.y),(vx,vy),route.pins[s.pin],s.length);ax+=tx;ay+=ty
if ground>=0:
ax=0.
if ay<-.1:ground=-1
else:ay=0.
vx+=ax*dt;vy+=ay*dt
speed=hypot(vx,vy)
if speed>MAX_SPEED:vx*=MAX_SPEED/speed;vy*=MAX_SPEED/speed
x=s.x+vx*dt;y=s.y+vy*dt
# First swept collision; up to 4 contacts, remaining time discarded after 4.
remaining=dt;px,py=s.x,s.y
for _ in range(4):
target=(px+vx*remaining,py+vy*remaining);hit=None
for i,r in enumerate(route.rocks):
# Ignore the current support if travelling tangentially/away.
if i==ground and py<=r.top-RADIUS+.02 and vy<=0:continue
h=segment_box((px,py),target,(r.left-RADIUS,r.top-RADIUS,r.right+RADIUS,148))
if h is not None and h[1]!=(0.,0.) and (hit is None or h[0]<hit[0]):hit=(h[0],i,h[1])
if hit is None:px,py=target;break
t,i,n=hit;r=route.rocks[i]
px+=(target[0]-px)*t;py+=(target[1]-py)*t
if r.danger:return recover(route,replace(s,elapsed=s.elapsed+dt),'rock')
px+=n[0]*.01;py+=n[1]*.01
if n==(0.,-1.) and vy>0:ground=i;vy=0.
elif n[0]:vx=0.
else:vy=0.
remaining*=1.-t
x,y=px,py
if x<4:x=4.;vx=max(0.,vx)
if x>188:x=188.;vx=min(0.,vx)
if y<4:y=4.;vy=max(0.,vy)
if ground>=0 and not route.rocks[ground].left<=x<=route.rocks[ground].right:ground=-1
out=replace(s,x=x,y=y,vx=vx,vy=vy,grounded=ground,tension=force,elapsed=s.elapsed+dt)
if y>140:return recover(route,out,'fall')
if out.pin>=0:
if not rope_clear(route,(x,y),route.pins[out.pin]):
return replace(s,x=old.x,y=old.y,vx=old.vx,vy=old.vy,status='snag',reason='snag',elapsed=out.elapsed)
d=hypot(x-route.pins[out.pin][0],y-route.pins[out.pin][1])
warn=out.warning+dt if d>90 else 0.
out=replace(out,warning=warn)
if warn>=.3:out=replace(out,pin=-1,warning=0.,reason='too_far',tension=0.)
return out
def frame(route,state,inp=Input(),adjustable=True,substeps=4):
for n in range(substeps):
once=inp if n==0 else replace(inp,toggle=False,recover=False)
state=step(route,state,once,1/(30*substeps),adjustable)
return state
class Controller:
"""実操作とリプレイの両方が通る、同じ入力窓口。"""
def __init__(self):
self.state = initial(ROUTE)
self.paused = False
self.space_ready = True
def tick(self, held=(), pressed=()):
held, pressed = set(held), set(pressed)
if 'SPACE' not in held:
self.space_ready = True
if 'R' in pressed:
self.state = initial(ROUTE)
self.paused = False
self.space_ready = False
return
if 'P' in pressed:
self.paused = not self.paused
self.space_ready = False
return
if self.paused:
return
inp = Input(move=int('RIGHT' in held)-int('LEFT' in held),
toggle='SPACE' in pressed and self.space_ready,
reel=int('DOWN' in held)-int('UP' in held),
recover='Z' in pressed)
self.state = frame(ROUTE, self.state, inp)
KEYS = ('LEFT', 'RIGHT', 'P', 'R', 'SPACE', 'UP', 'DOWN', 'Z')
def sample_keys(pyxel):
"""Pyxelの連続入力btnと押し始めbtnpを分ける。"""
return ([name for name in KEYS if pyxel.btn(getattr(pyxel, 'KEY_'+name))],
[name for name in KEYS if pyxel.btnp(getattr(pyxel, 'KEY_'+name))])
def main(headless=False, replay=None, screenshot=None, report=None):
import json
from pathlib import Path
# PyxelがCWDを変更しても、指定した出力先を維持する。
screenshot = str(Path(screenshot).resolve()) if screenshot else None
report = str(Path(report).resolve()) if report else None
import pyxel
control = Controller()
frames, events = 0, []
duration = max(12, len(replay or []))
def update():
nonlocal frames
frames += 1
if headless:
if replay is not None and frames <= len(replay):
e = replay[frames-1]
control.tick(e.get('held', []), e.get('pressed', []))
events.append({'frame': frames, 'input': e, 'state': asdict(control.state)})
else:
control.tick(*sample_keys(pyxel))
def draw():
s = control.state
pyxel.cls(1)
pyxel.tri(0,240,82,64,188,240,5)
pyxel.tri(120,240,250,74,384,240,13)
for r in ROUTE.rocks:
x,y,w = r.left*2, r.top*2, (r.right-r.left)*2
pyxel.rect(x,y,w,288-y,4 if not r.danger else 2)
pyxel.line(x,y,x+w,y,11 if not r.danger else 8)
for yy in range(int(y)+12, 288, 15):
pyxel.line(x+2,yy,x+w-2,yy,9 if not r.danger else 8)
for n, (x,y) in enumerate(ROUTE.pins):
pyxel.circ(x*2,y*2,4,9)
pyxel.text(x*2+8,y*2-2,str(n+1),7)
if s.pin >= 0:
anchor = ROUTE.pins[s.pin]
pyxel.line(s.x*2,s.y*2,anchor[0]*2,anchor[1]*2,10 if s.tension>0 else 6)
target = ROUTE.rocks[ROUTE.goal]
fx, fy = (target.right-8)*2, target.top*2
pyxel.line(fx,fy,fx,fy-25,7)
pyxel.tri(fx,fy-25,fx+14,fy-20,fx,fy-14,10)
pyxel.circ(s.x*2,s.y*2,RADIUS*2,10)
pyxel.circb(s.x*2,s.y*2,RADIUS*2,7)
pyxel.line(s.x*2,s.y*2,s.x*2+s.vx*.3,s.y*2+s.vy*.3,8)
pyxel.rect(0,0,384,31,1)
pyxel.text(8,7,'ROPE HIKE / STEP 4 - '+ROUTE.title,10)
pyxel.text(8,20,f'POS {s.x:6.2f},{s.y:6.2f} VEL {s.vx:6.2f},{s.vy:6.2f}',7)
pyxel.rect(0,288,384,96,1)
pyxel.text(8,293,f'TIME {s.elapsed:5.2f}s GROUND {s.grounded} RED LINE = VELOCITY',7)
pyxel.text(8,307,f'LENGTH {s.length:4.1f} TENSION {s.tension:5.1f} PIN {s.pin+1}',10)
pyxel.text(8,321,'EVENT '+s.reason.upper(),7)
pyxel.text(8,332,'PRACTICE ONLY / NO CLEAR / Z RETURNS TO START',6)
pyxel.text(8,347,'ARROWS WALK-REEL / SPACE TOGGLE / Z RETURN',7)
pyxel.text(8,361,'P PAUSE / R RESET / ESC QUIT / SILENT',6)
if control.paused:
pyxel.rect(92,130,202,24,0)
pyxel.text(106,139,'PAUSED - P TO RESUME',10)
if s.status == 'snag':
pyxel.rect(92,156,202,24,0)
pyxel.text(104,165,'ROPE BLOCKED - Z TO RETURN',8)
if headless and frames >= duration:
if screenshot:
pyxel.screenshot(screenshot, scale=2)
if report:
Path(report).write_text(json.dumps({
'label': 'Synthetic Controller input; real Pyxel headless; silent',
'render_frames': frames, 'input_frames': len(events),
'events': events, 'final_state': asdict(s), 'paused': control.paused,
}, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
pyxel.quit()
pyxel.init(384,384,title='Rope Hike - Step 4',fps=FPS,headless=headless)
pyxel.run(update,draw)
if __name__ == '__main__':
import argparse
import json
from pathlib import Path
parser = argparse.ArgumentParser(description=__doc__)
modes = parser.add_mutually_exclusive_group()
modes.add_argument('--headless-smoke', action='store_true')
modes.add_argument('--headless-replay', help='UTF-8 JSON held/pressed list')
parser.add_argument('--screenshot')
parser.add_argument('--report')
args = parser.parse_args()
headless = args.headless_smoke or args.headless_replay is not None
if not headless and (args.screenshot or args.report):
parser.error('screenshot/report require headless mode')
replay = None
if args.headless_replay:
replay = json.loads(Path(args.headless_replay).read_text(encoding='utf-8'))
if not isinstance(replay, list):
parser.error('Replay must be a list')
previous = set()
for e in replay:
if not isinstance(e, dict) or set(e)-{'held','pressed'} or any(
not isinstance(e.get(k, []), list) or any(not isinstance(v,str) or v not in KEYS for v in e.get(k, []))
for k in ('held','pressed')):
parser.error('Use supported held/pressed key names')
held, pressed = set(e.get('held', [])), set(e.get('pressed', []))
if not pressed <= held or pressed & previous:
parser.error('pressed must be newly held keys')
previous = held
main(headless, replay, args.screenshot, args.report)中棚で休み、二つの杭で頂上へ
元のゲームの1つのルートを、チェックポイント(途中の復帰地点)とゴールの条件でつなぐ。
元のゲームのルート3「高い棚と回り道」の、棚・杭・スタート位置を使います。A か D を押し始めたときに、杭を選びます。Space で選んだ杭にひもをつなぐには、距離が68以内で、しかも岩にさえぎられていないことが必要です。足場の上でひもを離し、横の速度の絶対値が1未満のまま0.25秒以上休むと、チェックポイントが更新されます。旗の棚で同じ条件を満たすと、clear(クリア)です。落ちたときや危険な岩にふれたときは、最後のチェックポイントに戻ります。Z でも同じ場所に戻りますが、落下の回数は増えません。戻ったときは、速度0、自然長38、ひもはつないでいない状態です。元のゲームのこのルートで State を更新するルールは、すべてそのままです。元のゲームから作った各フレームの hash(状態を短い値にまとめたもの)と、全18項目を比べます。タイトル画面での3つのルートの選択、日本語の描画、録画の画面は省いた、学習用の版です。
下のコードは 05_summit.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
stable=out.stable+dt if ground>=0 and abs(vx)<1. and out.pin<0 else 0.実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 05_summit.py.venv/bin/python 05_summit.py- 中棚がチェックポイントになり、落下後もそこへ戻る
- 選んだ杭に届かないときは、ひもをつながない
- 旗の棚・ひもをつないでいない・止まっている・休んだ時間、の4つの条件で到着する
- 元のゲームの3つのルートの画面を、全部再現した教材ではないと分かる
x が、棚の left〜right の間にあることも必要です。付属の 05_middle_inputs.json で中棚まで進めて、状態を読みましょう。Z で戻ると自然長が22から38に戻ることを、test_middle_checkpoint_and_recovery で調べます。次に、元のゲームとのちがいを lesson_meta.json で確かめます。元のゲームの ROUTES[1] の、危険な岩があるコースを追加する前に、どんな描画と入力の画面が必要かを書き出してみましょう。
このステップのコード全文(364行)
# 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.
# 教材にはNoto字形を含めません。ASCIIと図形だけを描きます。
"""ゴムひもの山歩き・教材 5/5。Python 3.11+ / Pyxel 2.9.9。"""
from dataclasses import dataclass, replace, asdict
from math import hypot, isfinite
FPS = 30
DT = 1 / 120 # 秒。1描画の間に4回更新する
RADIUS = 4.0
GRAVITY = 100.0 # px/s²。画面の下が正
MAX_SPEED = 100.0 # px/s
REACH = 68.0
@dataclass(frozen=True)
class Rock:
left: float
right: float
top: float
danger: bool = False
@dataclass(frozen=True)
class Route:
title: str
rocks: tuple
pins: tuple
start: tuple
goal: int
hint: str
ROUTE = Route('HIGH SHELF DETOUR', (Rock(8, 48, 110), Rock(96, 128, 88), Rock(146, 188, 62)), ((68, 48), (140, 26)), (36, 106), 2, 'Rest in the middle, then climb')
@dataclass(frozen=True)
class Input:
move: int = 0
reel: int = 0 # -1 shorten, +1 lengthen
toggle: bool = False
select: int = 0
recover: bool = False
@dataclass(frozen=True)
class State:
x: float
y: float
vx: float = 0.0
vy: float = 0.0
grounded: int = 0
pin: int = -1
selected: int = 0
length: float = 38.0
stable: float = 0.0
checkpoint: int = 0
checkpoint_x: float = 36.0
elapsed: float = 0.0
falls: int = 0
uses: tuple = ()
status: str = 'walking'
reason: str = 'start'
tension: float = 0.0
warning: float = 0.0
def initial(route):
return State(*route.start, checkpoint_x=route.start[0])
def segment_box(a,b,box):
"""First normalized hit and outward normal, or None (slab sweep)."""
lo,hi=0.0,1.0; normal=(0.0,0.0)
for axis,(lower,upper) in enumerate(((box[0],box[2]),(box[1],box[3]))):
p=a[axis];v=b[axis]-p
if abs(v)<1e-12:
if p<lower or p>upper:return None
continue
t1=(lower-p)/v;t2=(upper-p)/v
n=(-1.0,0.0) if axis==0 else (0.0,-1.0)
if t1>t2:t1,t2=t2,t1;n=(1.0,0.0) if axis==0 else (0.0,1.0)
if t1>lo:lo=t1;normal=n
hi=min(hi,t2)
if lo>hi:return None
if hi<0 or lo>1:return None
return (lo,normal)
def rope_clear(route, point, pin):
# Endpoint is character center, always radius 4 outside a safe rock.
for rock in route.rocks:
if segment_box(point,pin,(rock.left,rock.top,rock.right,144)) is not None:return False
return True
def candidates(route,state):
return tuple(i for i,p in enumerate(route.pins)
if hypot(p[0]-state.x,p[1]-state.y)<=REACH and rope_clear(route,(state.x,state.y),p))
def tension_accel(point, velocity, anchor, length):
dx,dy=anchor[0]-point[0],anchor[1]-point[1];d=hypot(dx,dy)
if d<=length or d<1e-9:return (0.,0.,0.)
nx,ny=dx/d,dy/d
outward=-(velocity[0]*nx+velocity[1]*ny)
force=max(0.,min(240.,14.*(d-length)+3.*outward))
return nx*force,ny*force,force
def recover(route,state,reason='fall'):
rock=route.rocks[state.checkpoint]
return replace(state,x=state.checkpoint_x,y=rock.top-RADIUS,vx=0.,vy=0.,
grounded=state.checkpoint,pin=-1,length=38.,stable=0.,status='walking',
tension=0.,warning=0.,falls=state.falls+int(reason!='return'),reason=reason)
def step(route,s,inp=Input(),dt=DT,adjustable=True):
if not 0<dt<=1/30:raise ValueError('dt must be fixed and <= 1/30')
if not all(isfinite(v) for v in (s.x,s.y,s.vx,s.vy,s.length)):raise ValueError('nonfinite state')
if inp.recover:return recover(route,s,'return')
if s.status in ('clear','snag'):return s
old=s
if inp.select:s=replace(s,selected=(s.selected+inp.select)%len(route.pins))
if inp.toggle:
if s.pin>=0:s=replace(s,pin=-1,warning=0.,tension=0.,reason='release')
elif s.selected in candidates(route,s):
s=replace(s,pin=s.selected,uses=s.uses+(s.selected,),reason='attach')
else:s=replace(s,reason='out_of_reach')
if s.pin>=0 and adjustable:s=replace(s,length=max(14.,min(42.,s.length+max(-1,min(1,inp.reel))*12*dt)))
ax=24.*max(-1,min(1,inp.move));ay=GRAVITY;force=0.
vx,vy=s.vx,s.vy;ground=s.grounded
if ground>=0:
rock=route.rocks[ground]
if not rock.left<=s.x<=rock.right:ground=-1
else:vx=36.*max(-1,min(1,inp.move));vy=0.
if s.pin>=0:
tx,ty,force=tension_accel((s.x,s.y),(vx,vy),route.pins[s.pin],s.length);ax+=tx;ay+=ty
if ground>=0:
ax=0.
if ay<-.1:ground=-1
else:ay=0.
vx+=ax*dt;vy+=ay*dt
speed=hypot(vx,vy)
if speed>MAX_SPEED:vx*=MAX_SPEED/speed;vy*=MAX_SPEED/speed
x=s.x+vx*dt;y=s.y+vy*dt
# First swept collision; up to 4 contacts, remaining time discarded after 4.
remaining=dt;px,py=s.x,s.y
for _ in range(4):
target=(px+vx*remaining,py+vy*remaining);hit=None
for i,r in enumerate(route.rocks):
# Ignore the current support if travelling tangentially/away.
if i==ground and py<=r.top-RADIUS+.02 and vy<=0:continue
h=segment_box((px,py),target,(r.left-RADIUS,r.top-RADIUS,r.right+RADIUS,148))
if h is not None and h[1]!=(0.,0.) and (hit is None or h[0]<hit[0]):hit=(h[0],i,h[1])
if hit is None:px,py=target;break
t,i,n=hit;r=route.rocks[i]
px+=(target[0]-px)*t;py+=(target[1]-py)*t
if r.danger:return recover(route,replace(s,elapsed=s.elapsed+dt),'rock')
px+=n[0]*.01;py+=n[1]*.01
if n==(0.,-1.) and vy>0:ground=i;vy=0.
elif n[0]:vx=0.
else:vy=0.
remaining*=1.-t
x,y=px,py
if x<4:x=4.;vx=max(0.,vx)
if x>188:x=188.;vx=min(0.,vx)
if y<4:y=4.;vy=max(0.,vy)
if ground>=0 and not route.rocks[ground].left<=x<=route.rocks[ground].right:ground=-1
out=replace(s,x=x,y=y,vx=vx,vy=vy,grounded=ground,tension=force,elapsed=s.elapsed+dt)
if y>140:return recover(route,out,'fall')
if out.pin>=0:
if not rope_clear(route,(x,y),route.pins[out.pin]):
return replace(s,x=old.x,y=old.y,vx=old.vx,vy=old.vy,status='snag',reason='snag',elapsed=out.elapsed)
d=hypot(x-route.pins[out.pin][0],y-route.pins[out.pin][1])
warn=out.warning+dt if d>90 else 0.
out=replace(out,warning=warn)
if warn>=.3:out=replace(out,pin=-1,warning=0.,reason='too_far',tension=0.)
stable=out.stable+dt if ground>=0 and abs(vx)<1. and out.pin<0 else 0.
out=replace(out,stable=stable)
if stable>=.25:
out=replace(out,checkpoint=ground,checkpoint_x=x)
if ground==route.goal:out=replace(out,status='clear',reason='clear')
return out
def frame(route,state,inp=Input(),adjustable=True,substeps=4):
for n in range(substeps):
once=inp if n==0 else replace(inp,toggle=False,select=0,recover=False)
state=step(route,state,once,1/(30*substeps),adjustable)
return state
class Controller:
"""実操作とリプレイの両方が通る、同じ入力窓口。"""
def __init__(self):
self.state = initial(ROUTE)
self.paused = False
self.space_ready = True
def tick(self, held=(), pressed=()):
held, pressed = set(held), set(pressed)
if 'SPACE' not in held:
self.space_ready = True
if 'R' in pressed:
self.state = initial(ROUTE)
self.paused = False
self.space_ready = False
return
if 'P' in pressed:
self.paused = not self.paused
self.space_ready = False
return
if self.paused:
return
if self.state.status == 'clear':
return # 到着後はRでやり直す
inp = Input(move=int('RIGHT' in held)-int('LEFT' in held),
toggle='SPACE' in pressed and self.space_ready,
reel=int('DOWN' in held)-int('UP' in held),
recover='Z' in pressed,
select=int('D' in pressed)-int('A' in pressed))
self.state = frame(ROUTE, self.state, inp)
KEYS = ('LEFT', 'RIGHT', 'P', 'R', 'SPACE', 'UP', 'DOWN', 'Z', 'A', 'D')
def sample_keys(pyxel):
"""Pyxelの連続入力btnと押し始めbtnpを分ける。"""
return ([name for name in KEYS if pyxel.btn(getattr(pyxel, 'KEY_'+name))],
[name for name in KEYS if pyxel.btnp(getattr(pyxel, 'KEY_'+name))])
def main(headless=False, replay=None, screenshot=None, report=None):
import json
from pathlib import Path
# PyxelがCWDを変更しても、指定した出力先を維持する。
screenshot = str(Path(screenshot).resolve()) if screenshot else None
report = str(Path(report).resolve()) if report else None
import pyxel
control = Controller()
frames, events = 0, []
duration = max(12, len(replay or []))
def update():
nonlocal frames
frames += 1
if headless:
if replay is not None and frames <= len(replay):
e = replay[frames-1]
control.tick(e.get('held', []), e.get('pressed', []))
events.append({'frame': frames, 'input': e, 'state': asdict(control.state)})
else:
control.tick(*sample_keys(pyxel))
def draw():
s = control.state
pyxel.cls(1)
pyxel.tri(0,240,82,64,188,240,5)
pyxel.tri(120,240,250,74,384,240,13)
for r in ROUTE.rocks:
x,y,w = r.left*2, r.top*2, (r.right-r.left)*2
pyxel.rect(x,y,w,288-y,4 if not r.danger else 2)
pyxel.line(x,y,x+w,y,11 if not r.danger else 8)
for yy in range(int(y)+12, 288, 15):
pyxel.line(x+2,yy,x+w-2,yy,9 if not r.danger else 8)
for n, (x,y) in enumerate(ROUTE.pins):
pyxel.circ(x*2,y*2,4,9)
pyxel.text(x*2+8,y*2-2,str(n+1),7)
if n == s.selected:
color = 10 if n in candidates(ROUTE, s) else 13
pyxel.rectb(x*2-10,y*2-10,21,21,color)
if s.pin >= 0:
anchor = ROUTE.pins[s.pin]
pyxel.line(s.x*2,s.y*2,anchor[0]*2,anchor[1]*2,10 if s.tension>0 else 6)
target = ROUTE.rocks[ROUTE.goal]
fx, fy = (target.right-8)*2, target.top*2
pyxel.line(fx,fy,fx,fy-25,7)
pyxel.tri(fx,fy-25,fx+14,fy-20,fx,fy-14,10)
pyxel.circ(s.x*2,s.y*2,RADIUS*2,10)
pyxel.circb(s.x*2,s.y*2,RADIUS*2,7)
pyxel.line(s.x*2,s.y*2,s.x*2+s.vx*.3,s.y*2+s.vy*.3,8)
pyxel.rect(0,0,384,31,1)
pyxel.text(8,7,'ROPE HIKE / STEP 5 - '+ROUTE.title,10)
pyxel.text(8,20,f'POS {s.x:6.2f},{s.y:6.2f} VEL {s.vx:6.2f},{s.vy:6.2f}',7)
pyxel.rect(0,288,384,96,1)
pyxel.text(8,293,f'TIME {s.elapsed:5.2f}s GROUND {s.grounded} RED LINE = VELOCITY',7)
pyxel.text(8,307,f'LENGTH {s.length:4.1f} TENSION {s.tension:5.1f} PIN {s.pin+1}',10)
pyxel.text(8,321,'EVENT '+s.reason.upper(),7)
pyxel.text(8,332,f'CHECKPOINT {s.checkpoint} REST {s.stable:.2f}s FALLS {s.falls}',6)
pyxel.text(8,347,'ARROWS MOVE-REEL / SPACE TOGGLE / A D SELECT',7)
pyxel.text(8,361,'P PAUSE / R RESET / ESC QUIT / SILENT',6)
pyxel.text(8,373,'Z RETURN / REST .25s DETACHED ON FLAG SHELF',6)
if control.paused:
pyxel.rect(92,130,202,24,0)
pyxel.text(106,139,'PAUSED - P TO RESUME',10)
if s.status == 'snag':
pyxel.rect(92,156,202,24,0)
pyxel.text(104,165,'ROPE BLOCKED - Z TO RETURN',8)
if s.status == 'clear':
pyxel.rect(92,130,202,24,0)
pyxel.text(119,139,'SUMMIT! R TO RESTART',10)
if headless and frames >= duration:
if screenshot:
pyxel.screenshot(screenshot, scale=2)
if report:
Path(report).write_text(json.dumps({
'label': 'Synthetic Controller input; real Pyxel headless; silent',
'render_frames': frames, 'input_frames': len(events),
'events': events, 'final_state': asdict(s), 'paused': control.paused,
}, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
pyxel.quit()
pyxel.init(384,384,title='Rope Hike - Step 5',fps=FPS,headless=headless)
pyxel.run(update,draw)
if __name__ == '__main__':
import argparse
import json
from pathlib import Path
parser = argparse.ArgumentParser(description=__doc__)
modes = parser.add_mutually_exclusive_group()
modes.add_argument('--headless-smoke', action='store_true')
modes.add_argument('--headless-replay', help='UTF-8 JSON held/pressed list')
parser.add_argument('--screenshot')
parser.add_argument('--report')
args = parser.parse_args()
headless = args.headless_smoke or args.headless_replay is not None
if not headless and (args.screenshot or args.report):
parser.error('screenshot/report require headless mode')
replay = None
if args.headless_replay:
replay = json.loads(Path(args.headless_replay).read_text(encoding='utf-8'))
if not isinstance(replay, list):
parser.error('Replay must be a list')
previous = set()
for e in replay:
if not isinstance(e, dict) or set(e)-{'held','pressed'} or any(
not isinstance(e.get(k, []), list) or any(not isinstance(v,str) or v not in KEYS for v in e.get(k, []))
for k in ('held','pressed')):
parser.error('Use supported held/pressed key names')
held, pressed = set(e.get('held', [])), set(e.get('pressed', []))
if not pressed <= held or pressed & previous:
parser.error('pressed must be newly held keys')
previous = held
main(headless, replay, args.screenshot, args.report)仕組みがわかったら、元の完成版を読んでみよう。
描画は30Hzで、1回につき4刻み進めます。dt=1/120 秒です。重力100、速度の上限100、地上の速度36、空中の加速度24です。距離<=自然長なら、張力は0です。それ以外は clamp(14×伸び+3×外向き速度,0,240) です。自然長は14〜42で、毎秒12ずつ巻き取ります。ひもをつなげるのは、距離<=68で、岩にさえぎられていないときです。ひもを離しても、余分な力は加わりません。体の半径4だけ四角形を大きくして、動いた線分で当たりを調べます。ぶつかったら0.01離し、最大4回のぶつかりまで処理します。距離>90が0.3秒続くとひもを離し、ひもが岩に掛かると snag で止まります。棚の上で、ひもをつながず、|vx|<1 のまま0.25秒休むとチェックポイントになります。旗の棚なら clear です。落下(y>140)や危険な岩で戻ります。Z は、落下の回数を増やさずに戻ります。
ここまでに作ったのは、元のゲームの仕組みだけを取り出して作り直した「簡略版」です。元のゲームのソースは手を加えずに、別のZIP(元の完成版ソース)に入れてあります。
| 比べる点 | 5ステップの教材 | 元の完成版 |
|---|---|---|
| 段階とコース | 01は広い床への落下。02〜03は、棚1つと杭1つの練習。04は元のゲームのルート1の配置で着地まで、クリアなし。05は元のゲームのルート3の配置と、モデル(ゲームのルールの部分)のすべてのルールが対象。 | 手作りの3本のルートを、タイトル画面で選ぶ。ルート2には危険な岩、ルート3には中棚と2つの杭がある。 |
| たるみ・張力・ひもを離すこと | 02からは、元のゲームの tension_accel をそのまま使う。距離=自然長でも0。伸びていても、動きを弱める部分(減衰項)と下限のため、張力が0のことがある。切り離しはその時の速度を保ち、そのあとも重力は続く。 | 境目、減衰、張力の上限は同じ。物理的に正確なゴムではなく、質量を1とした、引っぱる力だけの簡単な計算。 |
| 巻取り | 02は自然長38のまま。03からは14〜42、12px/s、ひもをつないでいる間だけ。巻き取りは外から力を加えるので、エネルギーは保存されない。 | ふつうは調整できる。--baseline で、長さを固定した比較もできる。 |
| 当たり判定・さえぎり | 01〜03は、上面だけの簡単な判定。中心の x が棚の中にあるときだけ着地する。岩の横の面と、ひもをさえぎる処理はない。04〜05は、元のゲームの大きくした四角形、掃引、法線、0.01離す処理、4回のぶつかり、ひもと岩の判定をそのまま使う。 | すべてのルートで、掃引による当たり判定と、ひものさえぎりがある。角は安全側に四角く大きくする。ひもが巻きつく計算はない。 |
| 戻る処理と終わり方 | 01〜03は、落ちたときに状態を全部最初に戻す。04はスタートの棚に戻り、時間と落下の回数はそのまま。到着はない。05は、中棚で休むチェックポイント、元のゲームと同じ戻り方、旗の棚で止まったときの到着がある。 | すべてのルートで、休んだ棚に戻る。旗の棚で、ひもをつながずに0.25秒以上止まっていれば到着。時間の制限はない。 |
| 危険な岩とほかのコース | 05は危険な岩の処理をふくみ、テスト用に作った場面でも確かめる。ただし、ルート3に危険な岩は置かない。ルート2を実際に遊ぶこと、3つのルートの選択、次のルートへ進むこと、難しさを比べる実験はふくめない。 | ルート2で、危険な岩をよける体験ができる。実験、成功したときのリプレイ、次のルートに進む操作も付いている。 |
| 画面と、確かめた範囲 | 英字(ASCII)、丸い体、速度の矢印、数値で学ぶ。P と R は残し、A・D と Z は使う段階で追加。用意した Controller の入力、本物の Pyxel での headless(画面を出さずに動かす)描画、ファイル1つだけでの起動を確かめた。 | 日本語の文字、人物の絵、タイトル画面での選択、H・N・F12、通った道すじの表示・記録・自動の映像。教材にはふくめない。人が操作すること、OS からの入力、Windows と macOS の画面、ブラウザ、音は、今回はまだ確かめていない。 |
完成版のコードで、次に読むところ
- 元のゲームの
model.pyのtension_accelで、向き・減衰項・上限を照らし合わせる。 - 元のゲームの
segment_boxとstepで、ぶつかる時刻と、残り時間のくり返しを追う。 - 元のゲームの
recoverとstableの判定で、チェックポイントと落下の回数を比べる。 - 元のゲームの
ROUTESとload_routesで、3つのルートをデータから読みこむ作り方に進む。 - 元のゲームの
app.pyのspace_ready、タイトル画面、H・N の操作、描画を読み、教材で省いた画面の部分を調べる。
最初は、数字かルールを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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。
うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る
