アクションLESSON 26ばねの運動 × 山歩き原作012

ゴムひもの山歩きの作り方

ひもがたるむと、ひもの力はかからない。
引っぱるときだけ働くばね、ひもの巻き取り、切り離し、岩棚への着地を作ります。小さな山歩きのゲームで、力と速度を分けて考える方法を学びます。

完成版の遊び方ピンと張ったひもの力を使って、体を動かします。ひもを離して、旗のある棚に着地します。

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

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

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

完成版の主な操作:左右キーで移動、Spaceでひもをつなぐ・離す、上下キーでひもの長さを変える、AとDで杭を選ぶ、Zで休んだ場所に戻る、Rでやり直し、Pで一時停止

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

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

うまく表示されないとき

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

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

力・速度・位置を、この順番で更新する。

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

1. 1秒あたりの変化を、短い時間 dt ずつ積み上げる
2. 差・距離・単位ベクトル(長さ1の向きの矢印)で、力の向きを決める
3. 自然長(引っぱっていないときのひもの長さ)を変えることと、速度を変えることを分ける
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
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:終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_motion.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

位置・速度・重力を分ける

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 の一部です。前のステップから追加・変更したところだけを抜き出しています。

01_motion.py / 注目する部分
    vx += ax * dt
    vy += ay * dt

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_motion.py
macOS / Linux
.venv/bin/python 01_motion.py
画面で見ること起動すると、人物が落ちて床で止まります。付属の例では、右キーを45描画フレームの間押し続けます。結果は1.5秒、位置 (66.30,100.00)、速度 (36,0)、GROUND 0 です。地上だけを歩くなら、1秒に36px進みます。最初の画面を確かめる smoke(簡単な動作確認)では、入力も時間も進めず、空中にいる最初の姿を表示します。
  • 位置・速度・加速度の単位を言える
  • 床の上面104と人物の中心100を区別する
  • 4刻みで1/30秒になると計算できる
つまずきやすいところ位置に重力の100を直接足すと、加速度と位置がごちゃまぜになります。dt を掛け忘れると、120倍の速さになります。数学のグラフでは y が上向きですが、この画面ではちがいます。vy がプラスなら下向きです。赤い線は、速度の向きを見せる図です。1フレームで動く距離ではありません。
少し変えてみよう

空中での最初の1刻みで、vy=100/120≈0.833 になり、位置は 0.833/120≈0.00694 だけ増えます。紙で計算して、test_gravity_units と比べましょう。GRAVITY を50に変える前に、同じ1刻みでどれだけ増えるかを予想してみましょう。

01_motion.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(243行)
01_motion.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 contributors
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
# 教材には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)
STEP 02 / 05

張ったときだけ引くばねを作る

差・距離・単位ベクトル(長さ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 の一部です。前のステップから追加・変更したところだけを抜き出しています。

02_spring.py / 注目する部分
    if d<=length or d<1e-9:return (0.,0.,0.)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_spring.py
macOS / Linux
.venv/bin/python 02_spring.py
画面で見ることSpace でひもをつなぎ、右キーを12フレーム押すと、0.4秒で位置は約 (59.07,95.42)、張力68.28、PIN 1 になります。人物は棚から上へ引っぱられます。ひもを離しても、その瞬間の速度のまま飛び続けます。下へ落ちると、スタートの位置に戻ります。この段階には、向こう岸も、ゲームが終わる条件もありません。
  • dx・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 で確かめましょう。

02_spring.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(282行)
02_spring.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 contributors
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
# 教材には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)
STEP 03 / 05

巻き取りと切り離しを比べる

自然長を変えることと、速度を変えることを分ける。

ひもをつないでいる間だけ、上キーで自然長を毎秒12px短くし、下キーで長くします。範囲は14〜42です。描画1回は1/30秒なので、上を1フレーム押すと0.4px短くなります。自然長を短くすると、伸び(d−length)が増えます。その結果として、張力が変わります。位置に直接代入して、杭へ引き寄せる処理ではありません。Space で切り離すと、pin(つないでいる杭)と張力だけを外します。vx・vy に、ジャンプの分を足したりはしません。ただし、切り離した同じ刻みでも、重力や空中での操作は続きます。そのため、1刻み後の速度がまったく同じとは限りません。ひもなしで自由に動いた場合と比べると、切り離しで余分に跳ぶ力が加わっていないことを確かめられます。

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

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を展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 03_reel_release.py
macOS / Linux
.venv/bin/python 03_reel_release.py
画面で見ることSpace と上キーを押してから30フレームで、自然長26、位置は約 (75.20,82.28) になります。次に上キーを放し、Space でひもを離して10フレーム待ちます。PIN 0 になり、横の速度は約50.84のままです。縦の速度は約−8.20から25.13に増え、人物は落ちていきます。1秒巻き取ってから1/3秒自由に落ちる、付属のリプレイ(操作の記録の再生)で、同じ結果を再現できます。
  • 自然長と今の距離は、別の量だと説明できる
  • ひもをつないでいない間は、上下キーで長さが変わらない
  • 切り離しを自由な動きと比べて、余分な力が加わっていないと分かる
つまずきやすいところ切り離したときに vx=vy=0 にすると、その場で勢いがなくなります。逆に vy-=50 を加えると、元のゲームにないジャンプになります。巻き取り器は、外からエネルギーを加える装置として簡単に扱っています。そのため、自然長を変えたときに、力学的エネルギー(動きと高さのエネルギーの合計)が変わらない、とは説明できません。
少し変えてみよう

qa/03_winding_inputs.json と 03_release_inputs.json を実行して、VEL(速度)の横の成分を比べます。次に、巻き取りを下キーに変えて、38から42まで10フレームで届くことを予想します。14より短く、42より長くしようとしても止まる理由を、max と min から読み取りましょう。

03_reel_release.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(287行)
03_reel_release.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 contributors
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
# 教材には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)
STEP 04 / 05

移動した線分で岩棚を調べる

上面だけの判定から、元のゲームの掃引衝突(動いた道すじ全体で調べる当たり判定)へ進む。

元のゲームのルート1の向こう岸を置き、元のゲームの segment_box と step の当たり判定の処理を取り入れます。移動前から移動後までを結ぶ線分が、半径4pxぶん大きくした岩の四角形に、最初に入る時刻を調べます。当たった面の向き(法線)が上からの (0,−1) で、下に向かって動いているなら着地です。横の面なら、vx だけを0にします。ぶつかったあとは0.01px外へ離し、残りの時間の移動を、最大4回のぶつかりまで処理します。大きくした四角形の角は四角いままなので、本物の円と角のぶつかりを正確に計算しているわけではありません。ひもの線分は、大きくする前の岩で調べます。ひもが岩でふさがれたら、SNAG(引っかかり)で止まります。Z で、スタートの棚に戻れます。距離が90をこえた状態が0.3秒続くと、ひもは自動で切り離されます。この段階には、一定時間止まったら到着とする判定と、中棚のチェックポイント(途中の復帰地点)は、まだありません。

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

04_collision.py / 注目する部分
        if n==(0.,-1.) and vy>0:ground=i;vy=0.
        elif n[0]:vx=0.
        else:vy=0.

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_collision.py
macOS / Linux
.venv/bin/python 04_collision.py
画面で見ること付属の例では、右キーと Space を押してから30フレーム進めます。次に右キーを放し、Space で離して120フレーム待ちます。すると、向こう岸で中心の座標が約 (149.31,95.99)、GROUND 1、落下0 になります。95.99は、棚の上面100−半径4−離す距離0.01 です。旗に着いても、練習は続きます。P で一時停止、R で最初から、Z でスタートの棚に戻ります。
  • 棚への着地と、横の壁へのぶつかりを区別する
  • 半径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%の場所で最初にぶつかっています。

04_collision.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(343行)
04_collision.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 contributors
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
# 教材には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)
STEP 05 / 05

中棚で休み、二つの杭で頂上へ

元のゲームの1つのルートを、チェックポイント(途中の復帰地点)とゴールの条件でつなぐ。

元のゲームのルート3「高い棚と回り道」の、棚・杭・スタート位置を使います。A か D を押し始めたときに、杭を選びます。Space で選んだ杭にひもをつなぐには、距離が68以内で、しかも岩にさえぎられていないことが必要です。足場の上でひもを離し、横の速度の絶対値が1未満のまま0.25秒以上休むと、チェックポイントが更新されます。旗の棚で同じ条件を満たすと、clear(クリア)です。落ちたときや危険な岩にふれたときは、最後のチェックポイントに戻ります。Z でも同じ場所に戻りますが、落下の回数は増えません。戻ったときは、速度0、自然長38、ひもはつないでいない状態です。元のゲームのこのルートで State を更新するルールは、すべてそのままです。元のゲームから作った各フレームの hash(状態を短い値にまとめたもの)と、全18項目を比べます。タイトル画面での3つのルートの選択、日本語の描画、録画の画面は省いた、学習用の版です。

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

05_summit.py / 注目する部分
    stable=out.stable+dt if ground>=0 and abs(vx)<1. and out.pin<0 else 0.

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_summit.py
macOS / Linux
.venv/bin/python 05_summit.py
画面で見ること付属の例を説明します。上キーと Space で40フレーム進めます。次に上キーを放し、Space で離して90フレームで中棚に着きます。D と Space で次の杭にひもをつないで30フレーム進め、Space で離して100フレーム待ちます。すると SUMMIT!(頂上)、FALLS 0、使った杭 (0,1) になります。ゲームの中の時間で約6.23秒で到着して、止まります。用意した入力の列は全部で260描画フレームありますが、到着したあとは時間も進みません。
  • 中棚がチェックポイントになり、落下後もそこへ戻る
  • 選んだ杭に届かないときは、ひもをつながない
  • 旗の棚・ひもをつないでいない・止まっている・休んだ時間、の4つの条件で到着する
  • 元のゲームの3つのルートの画面を、全部再現した教材ではないと分かる
つまずきやすいところ旗や杭にふれるだけでは終わりません。ひもをつないだまま休んでも、横へ動きながらでも、別の棚で休んでも、頂上に着いたことにはなりません。0.25秒は、小数の計算(浮動小数)の誤差のため、実際の例では0.2583秒まで進んでから判定されます。着地では、体の中心の x が、棚の left〜right の間にあることも必要です。
少し変えてみよう

付属の 05_middle_inputs.json で中棚まで進めて、状態を読みましょう。Z で戻ると自然長が22から38に戻ることを、test_middle_checkpoint_and_recovery で調べます。次に、元のゲームとのちがいを lesson_meta.json で確かめます。元のゲームの ROUTES[1] の、危険な岩があるコースを追加する前に、どんな描画と入力の画面が必要かを書き出してみましょう。

05_summit.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(364行)
05_summit.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 contributors
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
# 教材には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)
AFTER STEP 05

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

描画は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 の画面、ブラウザ、音は、今回はまだ確かめていない。

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

  1. 元のゲームの model.py の tension_accel で、向き・減衰項・上限を照らし合わせる。
  2. 元のゲームの segment_box と step で、ぶつかる時刻と、残り時間のくり返しを追う。
  3. 元のゲームの recover と stable の判定で、チェックポイントと落下の回数を比べる。
  4. 元のゲームの ROUTES と load_routes で、3つのルートをデータから読みこむ作り方に進む。
  5. 元のゲームの 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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。

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