アクションLESSON 16協調操作 × 雨の日の帰り道原作009

ふたりで一つの傘の作り方

寄りすぎず、離れすぎず。
2人のまん中の位置と、2人の間の距離から、それぞれの座標を計算します。体全体が傘に入っているかの判定と、目標の位置へゆっくり追いつく動きを作ります。

完成版の遊び方2人の間の距離と傘の位置を調整しながら、雨と水たまりのある道を通って家へ帰ります。

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

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

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

完成版の主な操作:左右で2人のまん中を動かす、上下で間の距離を変える、Spaceで傘を寄せる向きを選ぶ、Tで区間の練習、Rでやり直し、Pで一時停止、QかEscで選択画面

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

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

うまく表示されないとき

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

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

まん中の位置と距離から、2人の位置を求める。

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

1. 2つの座標を、まん中と距離から計算する
2. 点の判定を、幅のある体の判定に広げる
3. 押した瞬間の入力と、毎フレームのなめらかな動き
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
Python 3.11以上・仮想環境の起動 / 変数・if・for・関数・リストの基礎 / クラスとプロパティの初歩(set・dataclass・補間は本文で説明します)
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
左右:2人のまん中 / 上:近づく / 下:離れる / Space:傘を寄せる向き(03から)・濡れて止まったあとに続ける / P:一時停止 / R:最初から / Esc:終了
難易度の目安
初級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_pair.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

中心と間隔でふたりを動かす

2つの座標を、まん中と距離から計算する。

2人のまん中 c を64、間の距離 d を20とし、左の人の中心を c-d/2、右の人を c+d/2 で求めます。距離の半分ずつを左右に分けるので、2人の距離はいつも d になります。左右キーは c を1フレームに0.8、上下キーは d を0.6ずつ変えます。1秒に30回(30Hz)更新するので、1秒で30回変わります。体の半径3も含めた外側が、道幅128からはみ出さないように、clamp でまん中の位置を制限します。この段階は、雨も傘も時間制限もない練習です。

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

01_pair.py / 注目する部分
    @property
    def feet(self):
        return self.c - self.d / 2, self.c + self.d / 2

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_pair.py
macOS / Linux
.venv/bin/python 01_pair.py
画面で見ること始めたときの FEET(足の位置)は 54.0 / 74.0 です。右と下を20フレーム同時に押した付属のリプレイでは、CENTER 80.0、DISTANCE 32.0、FEET 64.0 / 96.0 になります。実際に操作すると、押している長さによって小数の値が変わります。
  • まん中64・距離20から、54と74を計算できる
  • 上下キーで、距離が12〜42の範囲で変わる
  • 端でも、体全体が道の中に残る
つまずきやすいところ左を c-d、右を c+d にすると、2人の距離は 2d になってしまいます。また、体の中心だけを道の中に収めると、体のはしの3だけが外にはみ出します。
少し変えてみよう

R で戻し、右だけを長く押して、端で止まることを確かめましょう。次に、下で距離を42まで広げた状態では、まん中 c の右の限界が104になる理由を、式で説明してみましょう。

01_pair.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(177行)
01_pair.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。単独起動できる30Hzのプログラム。"""
FPS = 30


def clamp(value, low, high):
    """値をlow以上high以下に収める。"""
    return max(low, min(high, value))


class Model:
    """ゲーム内の座標。描画用の2倍拡大とは分けて考える。"""
    def __init__(self):
        self.c = 64.0  # ふたりの中心
        self.d = 20.0  # ふたりの間隔

    @property
    def feet(self):
        return self.c - self.d / 2, self.c + self.d / 2

    def tick(self, move=0, spread=0):
        width = 128
        self.d = clamp(self.d + spread * 0.6, 12, 42)
        half = self.d / 2 + 3
        self.c = clamp(self.c + move * 0.8, 64 - width / 2 + half, 64 + width / 2 - half)

    def snapshot(self):
        return self.c, self.d


class Controller:
    """heldは押し続けるキー、pressedはこのフレームに押し始めたキー。"""
    def __init__(self):
        self.model = Model()
        self.paused = False

    def tick(self, held=(), pressed=()):
        held, pressed = set(held), set(pressed)
        if 'R' in pressed:
            self.model = Model()
            self.paused = False
            return
        if 'P' in pressed:
            self.paused = not self.paused
            return
        if self.paused:
            return
        move = int('RIGHT' in held) - int('LEFT' in held)
        spread = int('DOWN' in held) - int('UP' in held)
        self.model.tick(move, spread)


KEYS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'P', 'R' )


def main(headless=False, replay=None, screenshot=None, report=None):
    import json
    from pathlib import Path
    # Pyxel.initが作業ディレクトリを変えても保存先を変えない。
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    report = str(Path(report).resolve()) if report else None
    import pyxel

    control = Controller()
    frames = 0
    events = []
    keys = {name: getattr(pyxel, 'KEY_' + name) for name in KEYS}
    duration = max(12, len(replay or []))

    def update():
        nonlocal frames
        frames += 1
        if headless:
            # smokeは初期状態を描画するだけ。replayのみ合成キーを入力する。
            if replay is not None and frames <= len(replay):
                event = replay[frames - 1]
                control.tick(event.get('held', []), event.get('pressed', []))
                events.append({'frame': frames, 'input': event, 'state': control.model.snapshot()})
        else:
            control.tick([name for name, key in keys.items() if pyxel.btn(key)],
                         [name for name, key in keys.items() if pyxel.btnp(key)])

    def screen_x(x):
        return 16 + x * 2

    def draw():
        m = control.model
        pyxel.cls(1)
        pyxel.text(10, 9, 'SHARED UMBRELLA / STEP 1', 10)
        pyxel.text(10, 22, f'CENTER {m.c:5.1f}   DISTANCE {m.d:4.1f}', 7)
        pyxel.rect(16, 48, 256, 138, 4)
        pyxel.line(16, 48, 16, 186, 15)
        pyxel.line(272, 48, 272, 186, 15)
        pyxel.text(288, 53, 'DRY PRACTICE', 11)
        pyxel.text(288, 67, 'NO TIMER', 6)
        pyxel.line(16, 171, 272, 171, 9)
        pyxel.line(screen_x(m.c), 174, screen_x(m.c), 186, 7)
        for person, x in enumerate(m.feet):
            sx = screen_x(x)
            color = 10 if person == 0 else 14
            pyxel.circ(sx, 155, 6, color)
            pyxel.rect(sx - 6, 160, 12, 11, color)
            pyxel.text(sx - 2, 178, str(person + 1), color)
        pyxel.text(10, 194, f'FEET {m.feet[0]:.1f} / {m.feet[1]:.1f}', 7)
        pyxel.text(10, 207, 'FEET = CENTER -/+ DISTANCE / 2', 11)
        pyxel.text(10, 227, 'PRACTICE: WATCH THE NUMBERS AS YOU HOLD ARROWS.', 6)
        pyxel.text(10, 245, 'LEFT/RIGHT: CENTER   UP/DOWN: CLOSE/SPREAD', 7)
        pyxel.text(10, 257, 'BODY RADIUS 3 / DISTANCE RANGE 12 TO 42', 6)
        pyxel.text(10, 270, 'P: PAUSE  R: RESET  ESC: QUIT  NO WIND / SILENT', 6)
        if control.paused:
            pyxel.rect(136, 91, 160, 24, 0)
            pyxel.text(152, 100, '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; actual Pyxel headless renderer; silent',
                    'render_frames': frames, 'model_input_frames': len(events),
                    'events': events, 'final_state': m.snapshot(), 'paused': control.paused,
                }, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
            pyxel.quit()

    pyxel.init(432, 288, title='Shared Umbrella - Stage 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', help='Render initial state for 12 frames')
    modes.add_argument('--headless-replay', help='UTF-8 JSON list of held/pressed input frames')
    parser.add_argument('--screenshot', help='Output PNG (headless only)')
    parser.add_argument('--report', help='Output UTF-8 JSON (headless only)')
    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 a headless mode')
    replay = None
    if args.headless_replay:
        replay = json.loads(Path(args.headless_replay).read_text(encoding='utf-8'))
        valid = isinstance(replay, list) and all(
            isinstance(e, dict) and set(e) <= {'held', 'pressed'} and all(
                isinstance(e.get(k, []), list) and all(isinstance(v, str) and v in KEYS for v in e.get(k, []))
                for k in ('held', 'pressed')) for e in replay)
        if not valid:
            parser.error('Replay must be a list of held/pressed dictionaries using supported key names')
    main(headless, replay, args.screenshot, args.report)
STEP 02 / 05

身体全体が傘の中か調べる

点の判定を、幅のある体の判定に広げる。

傘の中心を、2人のまん中 c と同じ位置に置きます。傘の半分の幅は18、体の半径は3です。体の左はし x-3 が傘の左はし u-18 以上で、しかも右はし x+3 が u+18 以下なら、傘に入っています。まん中の傘で2人とも入れる条件は d/2+3<=18、つまり d<=30 です。BODY IN/OUT は傘に入っているかどうかを表示しているだけで、まだ雨や濡れは計算しません。

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

02_coverage.py / 注目する部分
    return x - 3 >= umbrella - 18 - 1e-8 and x + 3 <= umbrella + 18 + 1e-8

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_coverage.py
macOS / Linux
.venv/bin/python 02_coverage.py
画面で見ること最初は2人とも BODY IN です。下を25フレーム押した付属のリプレイでは距離が35になり、2人とも OUT になって「!」が出ます。上を押して距離を30以下にすると、2人とも IN に戻ります。
  • 人の中心と、体のはしを区別する
  • d=30 は IN、30より大きいと OUT になる
  • 1e-8 は、小数の計算のわずかな誤差への対策だとわかる
つまずきやすいところabs(x-u)<=18 だけでは、体の中心だけの判定になってしまいます。たとえば x=47、u=64 では、中心は傘の中でも、左はしの44が傘の左はしの46より外に出ています。
少し変えてみよう

紙の上で、u=64 のときの x=49 と x=48.99 を比べてみましょう。ちょうど境目の x=49 は IN です。傘の半分の幅を20に改造するなら、2人とも入れる距離の上限がいくつになるか、先に計算してみましょう。

02_coverage.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(195行)
02_coverage.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。単独起動できる30Hzのプログラム。"""
FPS = 30


def clamp(value, low, high):
    """値をlow以上high以下に収める。"""
    return max(low, min(high, value))


def covered(x, umbrella):
    """中心点でなく、半径3の身体全体が半幅18の傘に入るか。"""
    return x - 3 >= umbrella - 18 - 1e-8 and x + 3 <= umbrella + 18 + 1e-8


class Model:
    """ゲーム内の座標。描画用の2倍拡大とは分けて考える。"""
    def __init__(self):
        self.c = 64.0  # ふたりの中心
        self.d = 20.0  # ふたりの間隔

    @property
    def feet(self):
        return self.c - self.d / 2, self.c + self.d / 2

    @property
    def umbrella(self):
        return self.c

    def tick(self, move=0, spread=0):
        width = 128
        self.d = clamp(self.d + spread * 0.6, 12, 42)
        half = self.d / 2 + 3
        self.c = clamp(self.c + move * 0.8, 64 - width / 2 + half, 64 + width / 2 - half)

    def snapshot(self):
        return self.c, self.d, self.umbrella


class Controller:
    """heldは押し続けるキー、pressedはこのフレームに押し始めたキー。"""
    def __init__(self):
        self.model = Model()
        self.paused = False

    def tick(self, held=(), pressed=()):
        held, pressed = set(held), set(pressed)
        if 'R' in pressed:
            self.model = Model()
            self.paused = False
            return
        if 'P' in pressed:
            self.paused = not self.paused
            return
        if self.paused:
            return
        move = int('RIGHT' in held) - int('LEFT' in held)
        spread = int('DOWN' in held) - int('UP' in held)
        self.model.tick(move, spread)


KEYS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'P', 'R' )


def main(headless=False, replay=None, screenshot=None, report=None):
    import json
    from pathlib import Path
    # Pyxel.initが作業ディレクトリを変えても保存先を変えない。
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    report = str(Path(report).resolve()) if report else None
    import pyxel

    control = Controller()
    frames = 0
    events = []
    keys = {name: getattr(pyxel, 'KEY_' + name) for name in KEYS}
    duration = max(12, len(replay or []))

    def update():
        nonlocal frames
        frames += 1
        if headless:
            # smokeは初期状態を描画するだけ。replayのみ合成キーを入力する。
            if replay is not None and frames <= len(replay):
                event = replay[frames - 1]
                control.tick(event.get('held', []), event.get('pressed', []))
                events.append({'frame': frames, 'input': event, 'state': control.model.snapshot()})
        else:
            control.tick([name for name, key in keys.items() if pyxel.btn(key)],
                         [name for name, key in keys.items() if pyxel.btnp(key)])

    def screen_x(x):
        return 16 + x * 2

    def draw():
        m = control.model
        pyxel.cls(1)
        pyxel.text(10, 9, 'SHARED UMBRELLA / STEP 2', 10)
        pyxel.text(10, 22, f'CENTER {m.c:5.1f}   DISTANCE {m.d:4.1f}', 7)
        pyxel.rect(16, 48, 256, 138, 4)
        pyxel.line(16, 48, 16, 186, 15)
        pyxel.line(272, 48, 272, 186, 15)
        pyxel.text(288, 53, 'DRY PRACTICE', 11)
        pyxel.text(288, 67, 'NO TIMER', 6)
        pyxel.line(16, 171, 272, 171, 9)
        pyxel.line(screen_x(m.c), 174, screen_x(m.c), 186, 7)
        u = screen_x(m.umbrella)
        pyxel.rectb(u - 36, 137, 73, 37, 11)
        pyxel.line(u - 36, 137, u - 18, 125, 11)
        pyxel.line(u - 18, 125, u + 18, 125, 11)
        pyxel.line(u + 18, 125, u + 36, 137, 11)
        for person, x in enumerate(m.feet):
            sx = screen_x(x)
            color = 10 if person == 0 else 14
            pyxel.circ(sx, 155, 6, color)
            pyxel.rect(sx - 6, 160, 12, 11, color)
            pyxel.text(sx - 2, 178, str(person + 1), color)
            inside = covered(x, m.umbrella)
            pyxel.text(288, 191 + person * 12, f'BODY {person + 1}: ' + ('IN' if inside else 'OUT'), 11 if inside else 8)
            if not inside:
                pyxel.text(sx - 2, 142, '!', 8)
        pyxel.text(10, 194, f'FEET {m.feet[0]:.1f} / {m.feet[1]:.1f}', 7)
        pyxel.text(10, 207, 'UMBRELLA HALF WIDTH 18 / BODY RADIUS 3', 11)
        pyxel.text(10, 227, 'PRACTICE: WATCH THE NUMBERS AS YOU HOLD ARROWS.', 6)
        pyxel.text(10, 245, 'LEFT/RIGHT: CENTER   UP/DOWN: CLOSE/SPREAD', 7)
        pyxel.text(10, 257, 'BODY RADIUS 3 / DISTANCE RANGE 12 TO 42', 6)
        pyxel.text(10, 270, 'P: PAUSE  R: RESET  ESC: QUIT  NO WIND / SILENT', 6)
        if control.paused:
            pyxel.rect(136, 91, 160, 24, 0)
            pyxel.text(152, 100, '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; actual Pyxel headless renderer; silent',
                    'render_frames': frames, 'model_input_frames': len(events),
                    'events': events, 'final_state': m.snapshot(), 'paused': control.paused,
                }, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
            pyxel.quit()

    pyxel.init(432, 288, title='Shared Umbrella - Stage 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', help='Render initial state for 12 frames')
    modes.add_argument('--headless-replay', help='UTF-8 JSON list of held/pressed input frames')
    parser.add_argument('--screenshot', help='Output PNG (headless only)')
    parser.add_argument('--report', help='Output UTF-8 JSON (headless only)')
    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 a headless mode')
    replay = None
    if args.headless_replay:
        replay = json.loads(Path(args.headless_replay).read_text(encoding='utf-8'))
        valid = isinstance(replay, list) and all(
            isinstance(e, dict) and set(e) <= {'held', 'pressed'} and all(
                isinstance(e.get(k, []), list) and all(isinstance(v, str) and v in KEYS for v in e.get(k, []))
                for k in ('held', 'pressed')) for e in replay)
        if not valid:
            parser.error('Replay must be a list of held/pressed dictionaries using supported key names')
    main(headless, replay, args.screenshot, args.report)
STEP 03 / 05

傘の目標と現在位置を分ける

押した瞬間の入力と、毎フレームのなめらかな動き。

aim は傘を寄せる向き(まん中0・左-1・右1)、offset は2人のまん中 c から、傘が実際にずれている量です。Space を押した瞬間だけ、0→-1→1→0 の順に向きを選び、目標を aim*12 にします。今の値には、目標との差を最大0.8ずつ足していきます。残りが小さいときはその分だけ足すので、行き過ぎることはありません。傘の座標は c+offset です。「目標を選ぶ処理」と「追いつく処理」を分けているので、1回押してキーを離しても、傘は動き続けます。

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

03_target.py / 注目する部分
    return value + clamp(target - value, -speed, speed)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_target.py
macOS / Linux
.venv/bin/python 03_target.py
画面で見ること最初に Space を1回押すと AIM -1 になり、OFFSET が0から-12へ、0.5秒ほどで近づきます。押しっぱなしにしても、向きは次々には変わりません。6フレームの付属のリプレイは OFFSET -4.8 で止まり、動いている途中の様子を確かめられます。
  • 目標の aim と、実際の offset の違いを説明できる
  • Space を押し続けても、何度も切り替わらない
  • 目標の手前で行き過ぎない
つまずきやすいところpyxel.btn(SPACE) で aim を切り替えると、押している間、1秒に30回も目標が切り替わってしまいます。押した瞬間は btnp、左右や上下の連続した移動は btn を使います。また、offset=aim*12 と直接代入すると、瞬間移動になってしまいます。
少し変えてみよう

まん中から左へ12動くには、12÷0.8=15フレームかかります。では、左の-12から右の+12までは何フレームでしょうか。答えの30フレームを、テストの test_umbrella_reaches_offset_in_15_ticks で確かめましょう。

03_target.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(208行)
03_target.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。単独起動できる30Hzのプログラム。"""
FPS = 30


def clamp(value, low, high):
    """値をlow以上high以下に収める。"""
    return max(low, min(high, value))


def covered(x, umbrella):
    """中心点でなく、半径3の身体全体が半幅18の傘に入るか。"""
    return x - 3 >= umbrella - 18 - 1e-8 and x + 3 <= umbrella + 18 + 1e-8


def approach(value, target, speed):
    """目標までの差を1フレームの最大移動量に制限する。"""
    return value + clamp(target - value, -speed, speed)


class Model:
    """ゲーム内の座標。描画用の2倍拡大とは分けて考える。"""
    def __init__(self):
        self.c = 64.0  # ふたりの中心
        self.d = 20.0  # ふたりの間隔
        self.offset = 0.0
        self.aim = 0  # -1:左、0:中央、1:右

    @property
    def feet(self):
        return self.c - self.d / 2, self.c + self.d / 2

    @property
    def umbrella(self):
        return self.c + self.offset

    def tick(self, move=0, spread=0, aim=None):
        if aim is not None:
            self.aim = clamp(aim, -1, 1)
        width = 128
        self.d = clamp(self.d + spread * 0.6, 12, 42)
        half = self.d / 2 + 3
        self.c = clamp(self.c + move * 0.8, 64 - width / 2 + half, 64 + width / 2 - half)
        self.offset = approach(self.offset, self.aim * 12, 0.8)

    def snapshot(self):
        return self.c, self.d, self.offset, self.aim


class Controller:
    """heldは押し続けるキー、pressedはこのフレームに押し始めたキー。"""
    def __init__(self):
        self.model = Model()
        self.paused = False

    def tick(self, held=(), pressed=()):
        held, pressed = set(held), set(pressed)
        if 'R' in pressed:
            self.model = Model()
            self.paused = False
            return
        if 'P' in pressed:
            self.paused = not self.paused
            return
        if self.paused:
            return
        aim = self.model.aim
        if 'SPACE' in pressed:
            aim = {0: -1, -1: 1, 1: 0}[aim]
        move = int('RIGHT' in held) - int('LEFT' in held)
        spread = int('DOWN' in held) - int('UP' in held)
        self.model.tick(move, spread, aim)


KEYS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'P', 'R' , 'SPACE')


def main(headless=False, replay=None, screenshot=None, report=None):
    import json
    from pathlib import Path
    # Pyxel.initが作業ディレクトリを変えても保存先を変えない。
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    report = str(Path(report).resolve()) if report else None
    import pyxel

    control = Controller()
    frames = 0
    events = []
    keys = {name: getattr(pyxel, 'KEY_' + name) for name in KEYS}
    duration = max(12, len(replay or []))

    def update():
        nonlocal frames
        frames += 1
        if headless:
            # smokeは初期状態を描画するだけ。replayのみ合成キーを入力する。
            if replay is not None and frames <= len(replay):
                event = replay[frames - 1]
                control.tick(event.get('held', []), event.get('pressed', []))
                events.append({'frame': frames, 'input': event, 'state': control.model.snapshot()})
        else:
            control.tick([name for name, key in keys.items() if pyxel.btn(key)],
                         [name for name, key in keys.items() if pyxel.btnp(key)])

    def screen_x(x):
        return 16 + x * 2

    def draw():
        m = control.model
        pyxel.cls(1)
        pyxel.text(10, 9, 'SHARED UMBRELLA / STEP 3', 10)
        pyxel.text(10, 22, f'CENTER {m.c:5.1f}   DISTANCE {m.d:4.1f}', 7)
        pyxel.rect(16, 48, 256, 138, 4)
        pyxel.line(16, 48, 16, 186, 15)
        pyxel.line(272, 48, 272, 186, 15)
        pyxel.text(288, 53, 'DRY PRACTICE', 11)
        pyxel.text(288, 67, 'NO TIMER', 6)
        pyxel.line(16, 171, 272, 171, 9)
        pyxel.line(screen_x(m.c), 174, screen_x(m.c), 186, 7)
        u = screen_x(m.umbrella)
        pyxel.rectb(u - 36, 137, 73, 37, 11)
        pyxel.line(u - 36, 137, u - 18, 125, 11)
        pyxel.line(u - 18, 125, u + 18, 125, 11)
        pyxel.line(u + 18, 125, u + 36, 137, 11)
        for person, x in enumerate(m.feet):
            sx = screen_x(x)
            color = 10 if person == 0 else 14
            pyxel.circ(sx, 155, 6, color)
            pyxel.rect(sx - 6, 160, 12, 11, color)
            pyxel.text(sx - 2, 178, str(person + 1), color)
            inside = covered(x, m.umbrella)
            pyxel.text(288, 191 + person * 12, f'BODY {person + 1}: ' + ('IN' if inside else 'OUT'), 11 if inside else 8)
            if not inside:
                pyxel.text(sx - 2, 142, '!', 8)
        pyxel.text(10, 194, f'FEET {m.feet[0]:.1f} / {m.feet[1]:.1f}', 7)
        pyxel.text(10, 207, f'AIM {m.aim:+d}  OFFSET {m.offset:+5.1f} / TARGET {m.aim * 12:+d}', 11)
        pyxel.text(10, 227, 'PRACTICE: WATCH THE NUMBERS AS YOU HOLD ARROWS.', 6)
        pyxel.text(10, 245, 'LEFT/RIGHT: CENTER   UP/DOWN: CLOSE/SPREAD', 7)
        pyxel.text(10, 257, 'SPACE: CENTER -> LEFT -> RIGHT -> CENTER', 7)
        pyxel.text(10, 270, 'P: PAUSE  R: RESET  ESC: QUIT  NO WIND / SILENT', 6)
        if control.paused:
            pyxel.rect(136, 91, 160, 24, 0)
            pyxel.text(152, 100, '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; actual Pyxel headless renderer; silent',
                    'render_frames': frames, 'model_input_frames': len(events),
                    'events': events, 'final_state': m.snapshot(), 'paused': control.paused,
                }, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
            pyxel.quit()

    pyxel.init(432, 288, title='Shared Umbrella - Stage 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', help='Render initial state for 12 frames')
    modes.add_argument('--headless-replay', help='UTF-8 JSON list of held/pressed input frames')
    parser.add_argument('--screenshot', help='Output PNG (headless only)')
    parser.add_argument('--report', help='Output UTF-8 JSON (headless only)')
    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 a headless mode')
    replay = None
    if args.headless_replay:
        replay = json.loads(Path(args.headless_replay).read_text(encoding='utf-8'))
        valid = isinstance(replay, list) and all(
            isinstance(e, dict) and set(e) <= {'held', 'pressed'} and all(
                isinstance(e.get(k, []), list) and all(isinstance(v, str) and v in KEYS for v in e.get(k, []))
                for k in ('held', 'pressed')) for e in replay)
        if not valid:
            parser.error('Replay must be a list of held/pressed dictionaries using supported key names')
    main(headless, replay, args.screenshot, args.report)
STEP 04 / 05

雨・水たまり・安全な橋入口を加える

毎フレーム足していくものと、一度だけ起きる出来事を分ける。

準備の道90フレーム、左から雨が降る橋120フレーム、帰り道90フレームの、3つの区間を作ります。体に雨が当たっていて、傘の外にいると、濡れ具合を1フレームに0.24ずつ足します。水たまりは傘では防げず、足が水たまりにかかった人に、その区間で1回だけ20足します。set 型の splashed で、もう濡れた人の番号を覚えておきます。次の幅56の橋に体全体が入らないときは、手前の広い乾いた道で blocked(足止め)になり、時間も濡れも進まない間に並び方を直せます。

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

04_hazards.py / 注目する部分
        if self.status == 'blocked':
            if self.legal(self.band.width):
                self.status = 'play'
            return

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_hazards.py
macOS / Linux
.venv/bin/python 04_hazards.py
画面で見ること何も操作しないと、91フレーム目に橋の上で2人とも WET 20.00 になります。そのままでも家には帰れます。最初の3秒間に左へ動き続けると、橋の入り口で STATE BLOCKED になり、濡れ0・進み具合30%のまま安全に待ちます。右に戻して、2人とも橋の内側に入ると、次のフレームから進みます。
  • 雨は1フレームに0.24、水たまりは1人1区間に20、と区別する
  • 傘に入っていても、水たまりでは濡れる
  • 橋の入り口で、瞬間移動も余計な濡れも起きない
つまずきやすいところ水たまりの判定で毎フレーム20を足すと、あっという間に100になってしまいます。また、橋の幅に合わせて座標を先に切り詰めると、橋の内側へ瞬間移動してしまいます。足止め中は幅128で動けるようにし、legal で次の橋に入れるかを調べます。
少し変えてみよう

距離38、まん中64、傘を左に寄せた状態で、橋に入ってみましょう。幅28の水たまりのはしは50と78、2人の体の中心は45と83になるので、2人とも水をよけながら、左の人を傘で守れます。

04_hazards.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(326行)
04_hazards.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。単独起動できる30Hzのプログラム。"""
from dataclasses import dataclass

FPS = 30


def clamp(value, low, high):
    """値をlow以上high以下に収める。"""
    return max(low, min(high, value))


def covered(x, umbrella):
    """中心点でなく、半径3の身体全体が半幅18の傘に入るか。"""
    return x - 3 >= umbrella - 18 - 1e-8 and x + 3 <= umbrella + 18 + 1e-8


def approach(value, target, speed):
    """目標までの差を1フレームの最大移動量に制限する。"""
    return value + clamp(target - value, -speed, speed)


@dataclass(frozen=True)
class Band:
    name: str
    frames: int = 90
    rain: str = 'none'
    puddle: int = 0
    width: int = 128


BANDS = (Band('PREPARE'), Band('LEFT BRIDGE', 120, 'left', 28, 56), Band('HOME PATH'))


class Model:
    """ゲーム内の座標。描画用の2倍拡大とは分けて考える。"""
    def __init__(self):
        self.c = 64.0  # ふたりの中心
        self.d = 20.0  # ふたりの間隔
        self.offset = 0.0
        self.aim = 0  # -1:左、0:中央、1:右
        self.bands = BANDS
        self.wet = [0.0, 0.0]
        self.i = 0
        self.t = 0
        self.total = 0
        self.status = 'play'
        self.splashed = set()
        self.exposed = False
        self.clean = 0
        self.alerted = False

    @property
    def feet(self):
        return self.c - self.d / 2, self.c + self.d / 2

    @property
    def umbrella(self):
        return self.c + self.offset

    @property
    def band(self):
        return self.bands[min(self.i, len(self.bands) - 1)]

    def legal(self, width):
        return all(64 - width / 2 + 3 <= x <= 64 + width / 2 - 3 for x in self.feet)

    def rain_on(self, x):
        rain = self.band.rain
        return rain == 'full' or (rain == 'left' and x - 3 < 64) or (rain == 'right' and x + 3 > 64)

    def continue_wet(self):
        self.alerted = True
        self.status = 'play'

    def tick(self, move=0, spread=0, aim=None):
        if self.status not in ('play', 'blocked'):
            return
        if aim is not None:
            self.aim = clamp(aim, -1, 1)
        width = 128 if self.status == 'blocked' else self.band.width
        self.d = clamp(self.d + spread * 0.6, 12, 42)
        half = self.d / 2 + 3
        self.c = clamp(self.c + move * 0.8, 64 - width / 2 + half, 64 + width / 2 - half)
        self.offset = approach(self.offset, self.aim * 12, 0.8)
        # 橋に入れない間は前の広い道にいる。雨・水たまり・時間を進めない。
        if self.status == 'blocked':
            if self.legal(self.band.width):
                self.status = 'play'
            return
        b = self.band
        for person, x in enumerate(self.feet):
            if self.rain_on(x) and not covered(x, self.umbrella):
                self.exposed = True
                self.wet[person] = min(100, self.wet[person] + 0.24)
            if b.puddle and abs(x - 64) < b.puddle / 2 + 3 and person not in self.splashed:
                self.exposed = True
                self.wet[person] = min(100, self.wet[person] + 20)
                self.splashed.add(person)
        if max(self.wet) >= 100 and not self.alerted:
            self.status = 'wet'
            return
        self.t += 1
        self.total += 1
        if self.t >= b.frames:
            self.clean += int(not self.exposed)
            self.exposed = False
            self.i += 1
            self.t = 0
            self.splashed.clear()
            if self.i == len(self.bands):
                self.status = 'done'
            elif not self.legal(self.band.width):
                self.status = 'blocked'

    def snapshot(self):
        return self.c, self.d, self.offset, tuple(self.wet), self.i, self.t, self.total, self.status, self.clean


class Controller:
    """heldは押し続けるキー、pressedはこのフレームに押し始めたキー。"""
    def __init__(self):
        self.model = Model()
        self.paused = False

    def tick(self, held=(), pressed=()):
        held, pressed = set(held), set(pressed)
        if 'R' in pressed:
            self.model = Model()
            self.paused = False
            return
        if 'P' in pressed:
            self.paused = not self.paused
            return
        if self.paused:
            return
        if self.model.status == 'wet':
            if 'SPACE' in pressed:
                self.model.continue_wet()
            return
        if self.model.status == 'done':
            return
        aim = self.model.aim
        if 'SPACE' in pressed:
            aim = {0: -1, -1: 1, 1: 0}[aim]
        move = int('RIGHT' in held) - int('LEFT' in held)
        spread = int('DOWN' in held) - int('UP' in held)
        self.model.tick(move, spread, aim)


KEYS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'P', 'R' , 'SPACE')


def main(headless=False, replay=None, screenshot=None, report=None):
    import json
    from pathlib import Path
    # Pyxel.initが作業ディレクトリを変えても保存先を変えない。
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    report = str(Path(report).resolve()) if report else None
    import pyxel

    control = Controller()
    frames = 0
    events = []
    keys = {name: getattr(pyxel, 'KEY_' + name) for name in KEYS}
    duration = max(12, len(replay or []))

    def update():
        nonlocal frames
        frames += 1
        if headless:
            # smokeは初期状態を描画するだけ。replayのみ合成キーを入力する。
            if replay is not None and frames <= len(replay):
                event = replay[frames - 1]
                control.tick(event.get('held', []), event.get('pressed', []))
                events.append({'frame': frames, 'input': event, 'state': control.model.snapshot()})
        else:
            control.tick([name for name, key in keys.items() if pyxel.btn(key)],
                         [name for name, key in keys.items() if pyxel.btnp(key)])

    def screen_x(x):
        return 16 + x * 2

    def draw():
        m = control.model
        pyxel.cls(1)
        pyxel.text(10, 9, 'SHARED UMBRELLA / STEP 4', 10)
        pyxel.text(10, 22, f'CENTER {m.c:5.1f}   DISTANCE {m.d:4.1f}', 7)
        pyxel.rect(16, 48, 256, 138, 4)
        pyxel.line(16, 48, 16, 186, 15)
        pyxel.line(272, 48, 272, 186, 15)
        b = m.band
        left = screen_x(64 - b.width / 2)
        right = screen_x(64 + b.width / 2)
        if b.width < 128:
            pyxel.rect(16, 48, left - 16, 138, 3)
            pyxel.rect(right, 48, 272 - right, 138, 3)
            for y in range(48, 185, 12):
                pyxel.rect(left - 2, y, 4, 6, 15)
                pyxel.rect(right - 2, y, 4, 6, 15)
        if b.puddle:
            puddle_left = screen_x(64 - b.puddle / 2)
            pyxel.rect(puddle_left, 48, b.puddle * 2, 138, 1)
            for y in range(54, 185, 12):
                pyxel.line(puddle_left + 3, y, puddle_left + b.puddle * 2 - 4, y, 12)
        if b.rain != 'none':
            rain_left = screen_x(64) if b.rain == 'right' else left
            rain_right = screen_x(64) if b.rain == 'left' else right
            for x in range(int(rain_left) + 4, int(rain_right), 12):
                for y in range(51, 182, 16):
                    pyxel.line(x, y, x, y + 6, 6)
        nxt = m.bands[m.i + 1].name if m.i + 1 < len(m.bands) else 'HOME'
        pyxel.text(10, 35, f'NOW {b.name} / NEXT {nxt}', 6)
        # 入口停止中は橋の手前の乾いた広い道にいることを絵にも反映。
        if m.status == 'blocked':
            pyxel.rect(17, 126, 255, 60, 4)
            pyxel.line(17, 126, left, 102, 15)
            pyxel.line(271, 126, right, 102, 15)
        progress = m.total / sum(band.frames for band in m.bands)
        pyxel.rect(288, 56, 126, 5, 5)
        pyxel.rect(288, 56, progress * 126, 5, 10)
        pyxel.text(288, 45, f'HOME {progress * 100:3.0f}%', 7)
        for person in range(2):
            pyxel.text(288, 80 + person * 32, f'WET {person + 1}: {m.wet[person]:5.2f}', 7)
            pyxel.rect(288, 90 + person * 32, 100, 4, 5)
            pyxel.rect(288, 90 + person * 32, m.wet[person], 4, 8)
        pyxel.text(288, 149, f'STATE {m.status.upper()}', 11)
        pyxel.text(288, 162, f'CLEAN {m.clean}/{len(m.bands)}', 6)
        pyxel.line(16, 171, 272, 171, 9)
        pyxel.line(screen_x(m.c), 174, screen_x(m.c), 186, 7)
        u = screen_x(m.umbrella)
        pyxel.rectb(u - 36, 137, 73, 37, 11)
        pyxel.line(u - 36, 137, u - 18, 125, 11)
        pyxel.line(u - 18, 125, u + 18, 125, 11)
        pyxel.line(u + 18, 125, u + 36, 137, 11)
        for person, x in enumerate(m.feet):
            sx = screen_x(x)
            color = 10 if person == 0 else 14
            pyxel.circ(sx, 155, 6, color)
            pyxel.rect(sx - 6, 160, 12, 11, color)
            pyxel.text(sx - 2, 178, str(person + 1), color)
            inside = covered(x, m.umbrella)
            pyxel.text(288, 191 + person * 12, f'BODY {person + 1}: ' + ('IN' if inside else 'OUT'), 11 if inside else 8)
            if not inside:
                pyxel.text(sx - 2, 142, '!', 8)
        pyxel.text(10, 194, f'FEET {m.feet[0]:.1f} / {m.feet[1]:.1f}', 7)
        pyxel.text(10, 207, f'AIM {m.aim:+d}  OFFSET {m.offset:+5.1f} / TARGET {m.aim * 12:+d}', 11)
        if m.status == 'blocked':
            pyxel.text(10, 227, 'SAFE STOP: MOVE BOTH BODIES INSIDE THE BRIDGE.', 10)
        elif m.status == 'wet':
            pyxel.text(10, 227, 'WET 100: SPACE TO CONTINUE, OR R TO RESTART.', 8)
        elif m.status == 'done':
            pyxel.text(10, 227, 'WELCOME HOME! GETTING WET IS NOT A GAME OVER.', 10)
        else:
            pyxel.text(10, 227, 'RAIN: UMBRELLA. PUDDLE: MOVE YOUR FEET.', 6)
        pyxel.text(10, 245, 'LEFT/RIGHT: CENTER   UP/DOWN: CLOSE/SPREAD', 7)
        pyxel.text(10, 257, 'SPACE: CENTER -> LEFT -> RIGHT -> CENTER', 7)
        pyxel.text(10, 270, 'P: PAUSE  R: RESET  ESC: QUIT  NO WIND / SILENT', 6)
        if control.paused:
            pyxel.rect(136, 91, 160, 24, 0)
            pyxel.text(152, 100, '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; actual Pyxel headless renderer; silent',
                    'render_frames': frames, 'model_input_frames': len(events),
                    'events': events, 'final_state': m.snapshot(), 'paused': control.paused,
                }, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
            pyxel.quit()

    pyxel.init(432, 288, title='Shared Umbrella - Stage 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', help='Render initial state for 12 frames')
    modes.add_argument('--headless-replay', help='UTF-8 JSON list of held/pressed input frames')
    parser.add_argument('--screenshot', help='Output PNG (headless only)')
    parser.add_argument('--report', help='Output UTF-8 JSON (headless only)')
    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 a headless mode')
    replay = None
    if args.headless_replay:
        replay = json.loads(Path(args.headless_replay).read_text(encoding='utf-8'))
        valid = isinstance(replay, list) and all(
            isinstance(e, dict) and set(e) <= {'held', 'pressed'} and all(
                isinstance(e.get(k, []), list) and all(isinstance(v, str) and v in KEYS for v in e.get(k, []))
                for k in ('held', 'pressed')) for e in replay)
        if not valid:
            parser.error('Replay must be a list of held/pressed dictionaries using supported key names')
    main(headless, replay, args.screenshot, args.report)
STEP 05 / 05

次の地面を読み、ふたりで家へ帰る

データの並びで、準備と操作をつなぐ短いコースを作る。

Model の更新のルールは04のままで、BANDS を元のゲームの C の7つの区間に置きかえます。左の橋のあとに2秒の戻す道、全面の雨4秒、3秒の離れる道、右の橋4秒が並び、「橋では離れる」「全面の雨の前には寄り直す」といった準備の順番を学びます。NOW と NEXT は、今と次の区間の予告です。690フレーム(23秒)で家に着き、濡れ0は「できたらうれしい」上達の目標です。濡れが100になると止まりますが、Space で続けられ、ゲームオーバーにはなりません。

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

05_home.py / 注目する部分
BANDS = (
    Band('PREPARE'),
    Band('LEFT BRIDGE', 120, 'left', 28, 56),
    Band('RETURN', 60),
    Band('FULL RAIN', 120, 'full'),
    Band('SPREAD', 90),
    Band('RIGHT BRIDGE', 120, 'right', 28, 56),
    Band('HOME PATH'),
)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_home.py
macOS / Linux
.venv/bin/python 05_home.py
画面で見ること付属の 05_home_inputs.json の自動操作では、690回の更新で WELCOME HOME、WET 0/0、CLEAN 7/7 になります。何も操作しなくても23秒で家に着き、その場合は水たまり2つ分で WET 40/40、CLEAN 5/7 になります。「家に帰る」ことと「濡れずに帰る」ことは、別の目標として確かめます。
  • 左の橋→寄り直し→全面の雨→離れ直し→右の橋、の順番を試す
  • 濡れても家に着けば終わる
  • 元のゲームと合わせた範囲と、省いた機能がわかる
つまずきやすいところ全面の雨に入ってから距離を縮めると、縮めている最初の数フレームで濡れてしまいます。NEXT を見て、乾いた区間のうちに準備しましょう。また、最後の区間では i が区間の数と同じになるので、band プロパティは最後の区間を安全に参照するようにしています。
少し変えてみよう

まず何も操作せずに家まで帰ってみましょう。次は、橋の前に距離38・傘を雨の側へ、全面の雨の前に距離20・傘をまん中へ戻して、濡れずに帰ることを目指します。そのあと BANDS の準備の区間を90から120に変えて、遊びやすさと全体の時間がどう変わるか確かめましょう(時間を変えた版は、元のゲームの C とは違う改造です)。

05_home.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(335行)
05_home.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。単独起動できる30Hzのプログラム。"""
from dataclasses import dataclass

FPS = 30


def clamp(value, low, high):
    """値をlow以上high以下に収める。"""
    return max(low, min(high, value))


def covered(x, umbrella):
    """中心点でなく、半径3の身体全体が半幅18の傘に入るか。"""
    return x - 3 >= umbrella - 18 - 1e-8 and x + 3 <= umbrella + 18 + 1e-8


def approach(value, target, speed):
    """目標までの差を1フレームの最大移動量に制限する。"""
    return value + clamp(target - value, -speed, speed)


@dataclass(frozen=True)
class Band:
    name: str
    frames: int = 90
    rain: str = 'none'
    puddle: int = 0
    width: int = 128


# 原作Cの形・時間・雨・水たまり幅・道幅。名前だけASCIIに置換。
BANDS = (
    Band('PREPARE'),
    Band('LEFT BRIDGE', 120, 'left', 28, 56),
    Band('RETURN', 60),
    Band('FULL RAIN', 120, 'full'),
    Band('SPREAD', 90),
    Band('RIGHT BRIDGE', 120, 'right', 28, 56),
    Band('HOME PATH'),
)


class Model:
    """ゲーム内の座標。描画用の2倍拡大とは分けて考える。"""
    def __init__(self):
        self.c = 64.0  # ふたりの中心
        self.d = 20.0  # ふたりの間隔
        self.offset = 0.0
        self.aim = 0  # -1:左、0:中央、1:右
        self.bands = BANDS
        self.wet = [0.0, 0.0]
        self.i = 0
        self.t = 0
        self.total = 0
        self.status = 'play'
        self.splashed = set()
        self.exposed = False
        self.clean = 0
        self.alerted = False

    @property
    def feet(self):
        return self.c - self.d / 2, self.c + self.d / 2

    @property
    def umbrella(self):
        return self.c + self.offset

    @property
    def band(self):
        return self.bands[min(self.i, len(self.bands) - 1)]

    def legal(self, width):
        return all(64 - width / 2 + 3 <= x <= 64 + width / 2 - 3 for x in self.feet)

    def rain_on(self, x):
        rain = self.band.rain
        return rain == 'full' or (rain == 'left' and x - 3 < 64) or (rain == 'right' and x + 3 > 64)

    def continue_wet(self):
        self.alerted = True
        self.status = 'play'

    def tick(self, move=0, spread=0, aim=None):
        if self.status not in ('play', 'blocked'):
            return
        if aim is not None:
            self.aim = clamp(aim, -1, 1)
        width = 128 if self.status == 'blocked' else self.band.width
        self.d = clamp(self.d + spread * 0.6, 12, 42)
        half = self.d / 2 + 3
        self.c = clamp(self.c + move * 0.8, 64 - width / 2 + half, 64 + width / 2 - half)
        self.offset = approach(self.offset, self.aim * 12, 0.8)
        # 橋に入れない間は前の広い道にいる。雨・水たまり・時間を進めない。
        if self.status == 'blocked':
            if self.legal(self.band.width):
                self.status = 'play'
            return
        b = self.band
        for person, x in enumerate(self.feet):
            if self.rain_on(x) and not covered(x, self.umbrella):
                self.exposed = True
                self.wet[person] = min(100, self.wet[person] + 0.24)
            if b.puddle and abs(x - 64) < b.puddle / 2 + 3 and person not in self.splashed:
                self.exposed = True
                self.wet[person] = min(100, self.wet[person] + 20)
                self.splashed.add(person)
        if max(self.wet) >= 100 and not self.alerted:
            self.status = 'wet'
            return
        self.t += 1
        self.total += 1
        if self.t >= b.frames:
            self.clean += int(not self.exposed)
            self.exposed = False
            self.i += 1
            self.t = 0
            self.splashed.clear()
            if self.i == len(self.bands):
                self.status = 'done'
            elif not self.legal(self.band.width):
                self.status = 'blocked'

    def snapshot(self):
        return self.c, self.d, self.offset, tuple(self.wet), self.i, self.t, self.total, self.status, self.clean


class Controller:
    """heldは押し続けるキー、pressedはこのフレームに押し始めたキー。"""
    def __init__(self):
        self.model = Model()
        self.paused = False

    def tick(self, held=(), pressed=()):
        held, pressed = set(held), set(pressed)
        if 'R' in pressed:
            self.model = Model()
            self.paused = False
            return
        if 'P' in pressed:
            self.paused = not self.paused
            return
        if self.paused:
            return
        if self.model.status == 'wet':
            if 'SPACE' in pressed:
                self.model.continue_wet()
            return
        if self.model.status == 'done':
            return
        aim = self.model.aim
        if 'SPACE' in pressed:
            aim = {0: -1, -1: 1, 1: 0}[aim]
        move = int('RIGHT' in held) - int('LEFT' in held)
        spread = int('DOWN' in held) - int('UP' in held)
        self.model.tick(move, spread, aim)


KEYS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'P', 'R' , 'SPACE')


def main(headless=False, replay=None, screenshot=None, report=None):
    import json
    from pathlib import Path
    # Pyxel.initが作業ディレクトリを変えても保存先を変えない。
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    report = str(Path(report).resolve()) if report else None
    import pyxel

    control = Controller()
    frames = 0
    events = []
    keys = {name: getattr(pyxel, 'KEY_' + name) for name in KEYS}
    duration = max(12, len(replay or []))

    def update():
        nonlocal frames
        frames += 1
        if headless:
            # smokeは初期状態を描画するだけ。replayのみ合成キーを入力する。
            if replay is not None and frames <= len(replay):
                event = replay[frames - 1]
                control.tick(event.get('held', []), event.get('pressed', []))
                events.append({'frame': frames, 'input': event, 'state': control.model.snapshot()})
        else:
            control.tick([name for name, key in keys.items() if pyxel.btn(key)],
                         [name for name, key in keys.items() if pyxel.btnp(key)])

    def screen_x(x):
        return 16 + x * 2

    def draw():
        m = control.model
        pyxel.cls(1)
        pyxel.text(10, 9, 'SHARED UMBRELLA / STEP 5', 10)
        pyxel.text(10, 22, f'CENTER {m.c:5.1f}   DISTANCE {m.d:4.1f}', 7)
        pyxel.rect(16, 48, 256, 138, 4)
        pyxel.line(16, 48, 16, 186, 15)
        pyxel.line(272, 48, 272, 186, 15)
        b = m.band
        left = screen_x(64 - b.width / 2)
        right = screen_x(64 + b.width / 2)
        if b.width < 128:
            pyxel.rect(16, 48, left - 16, 138, 3)
            pyxel.rect(right, 48, 272 - right, 138, 3)
            for y in range(48, 185, 12):
                pyxel.rect(left - 2, y, 4, 6, 15)
                pyxel.rect(right - 2, y, 4, 6, 15)
        if b.puddle:
            puddle_left = screen_x(64 - b.puddle / 2)
            pyxel.rect(puddle_left, 48, b.puddle * 2, 138, 1)
            for y in range(54, 185, 12):
                pyxel.line(puddle_left + 3, y, puddle_left + b.puddle * 2 - 4, y, 12)
        if b.rain != 'none':
            rain_left = screen_x(64) if b.rain == 'right' else left
            rain_right = screen_x(64) if b.rain == 'left' else right
            for x in range(int(rain_left) + 4, int(rain_right), 12):
                for y in range(51, 182, 16):
                    pyxel.line(x, y, x, y + 6, 6)
        nxt = m.bands[m.i + 1].name if m.i + 1 < len(m.bands) else 'HOME'
        pyxel.text(10, 35, f'NOW {b.name} / NEXT {nxt}', 6)
        # 入口停止中は橋の手前の乾いた広い道にいることを絵にも反映。
        if m.status == 'blocked':
            pyxel.rect(17, 126, 255, 60, 4)
            pyxel.line(17, 126, left, 102, 15)
            pyxel.line(271, 126, right, 102, 15)
        progress = m.total / sum(band.frames for band in m.bands)
        pyxel.rect(288, 56, 126, 5, 5)
        pyxel.rect(288, 56, progress * 126, 5, 10)
        pyxel.text(288, 45, f'HOME {progress * 100:3.0f}%', 7)
        for person in range(2):
            pyxel.text(288, 80 + person * 32, f'WET {person + 1}: {m.wet[person]:5.2f}', 7)
            pyxel.rect(288, 90 + person * 32, 100, 4, 5)
            pyxel.rect(288, 90 + person * 32, m.wet[person], 4, 8)
        pyxel.text(288, 149, f'STATE {m.status.upper()}', 11)
        pyxel.text(288, 162, f'CLEAN {m.clean}/{len(m.bands)}', 6)
        pyxel.line(16, 171, 272, 171, 9)
        pyxel.line(screen_x(m.c), 174, screen_x(m.c), 186, 7)
        u = screen_x(m.umbrella)
        pyxel.rectb(u - 36, 137, 73, 37, 11)
        pyxel.line(u - 36, 137, u - 18, 125, 11)
        pyxel.line(u - 18, 125, u + 18, 125, 11)
        pyxel.line(u + 18, 125, u + 36, 137, 11)
        for person, x in enumerate(m.feet):
            sx = screen_x(x)
            color = 10 if person == 0 else 14
            pyxel.circ(sx, 155, 6, color)
            pyxel.rect(sx - 6, 160, 12, 11, color)
            pyxel.text(sx - 2, 178, str(person + 1), color)
            inside = covered(x, m.umbrella)
            pyxel.text(288, 191 + person * 12, f'BODY {person + 1}: ' + ('IN' if inside else 'OUT'), 11 if inside else 8)
            if not inside:
                pyxel.text(sx - 2, 142, '!', 8)
        pyxel.text(10, 194, f'FEET {m.feet[0]:.1f} / {m.feet[1]:.1f}', 7)
        pyxel.text(10, 207, f'AIM {m.aim:+d}  OFFSET {m.offset:+5.1f} / TARGET {m.aim * 12:+d}', 11)
        if m.status == 'blocked':
            pyxel.text(10, 227, 'SAFE STOP: MOVE BOTH BODIES INSIDE THE BRIDGE.', 10)
        elif m.status == 'wet':
            pyxel.text(10, 227, 'WET 100: SPACE TO CONTINUE, OR R TO RESTART.', 8)
        elif m.status == 'done':
            pyxel.text(10, 227, 'WELCOME HOME! GETTING WET IS NOT A GAME OVER.', 10)
        else:
            pyxel.text(10, 227, 'RAIN: UMBRELLA. PUDDLE: MOVE YOUR FEET.', 6)
        pyxel.text(10, 245, 'LEFT/RIGHT: CENTER   UP/DOWN: CLOSE/SPREAD', 7)
        pyxel.text(10, 257, 'SPACE: CENTER -> LEFT -> RIGHT -> CENTER', 7)
        pyxel.text(10, 270, 'P: PAUSE  R: RESET  ESC: QUIT  NO WIND / SILENT', 6)
        if control.paused:
            pyxel.rect(136, 91, 160, 24, 0)
            pyxel.text(152, 100, '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; actual Pyxel headless renderer; silent',
                    'render_frames': frames, 'model_input_frames': len(events),
                    'events': events, 'final_state': m.snapshot(), 'paused': control.paused,
                }, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
            pyxel.quit()

    pyxel.init(432, 288, title='Shared Umbrella - Stage 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', help='Render initial state for 12 frames')
    modes.add_argument('--headless-replay', help='UTF-8 JSON list of held/pressed input frames')
    parser.add_argument('--screenshot', help='Output PNG (headless only)')
    parser.add_argument('--report', help='Output UTF-8 JSON (headless only)')
    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 a headless mode')
    replay = None
    if args.headless_replay:
        replay = json.loads(Path(args.headless_replay).read_text(encoding='utf-8'))
        valid = isinstance(replay, list) and all(
            isinstance(e, dict) and set(e) <= {'held', 'pressed'} and all(
                isinstance(e.get(k, []), list) and all(isinstance(v, str) and v in KEYS for v in e.get(k, []))
                for k in ('held', 'pressed')) for e in replay)
        if not valid:
            parser.error('Replay must be a list of held/pressed dictionaries using supported key names')
    main(headless, replay, args.screenshot, args.report)
AFTER STEP 05

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

元のゲームは1秒に30回(30Hz)更新します。体の中心は c±d/2、体の半径は3、傘の半分の幅は18、距離 d は12〜42です。1フレームに、まん中は0.8・距離は0.6・傘のずれは0.8動きます。雨は傘の外にいると1フレームに0.24、水たまりは1人1区間に+20です。橋の入り口で体が道からはみ出すときは、手前の広い道で安全に止まります。濡れが100になると止まって選べ、続けることもできます。家に着けば終わりで、濡れずに帰るのはおまけの目標です。風やランダムな生成はありません。

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

比べる点5ステップの教材元の完成版
道の作り01〜03は時間制限なしの乾いた道での操作練習。04は準備90・左の橋120・帰り90の3区間。05は元のゲームのCの7区間、合計690フレーム(23秒)を使う。A・B・Cの3つの状況を選べる。Cは、戻る準備を学ぶ7区間。
比べるルール05では、ふつうの進み方での位置の計算・移動・雨・水たまり・橋の入り口・濡れて止まる/続ける・家に着く、を元のゲームと同じにしている。01〜03は危険の計算を省略。04は同じルールで短い別のコース。最初からすべての機能が有効。区間のやり直し(retry_band)・3秒の準備・入ったときの記録・実験の記録もある。
画面と予告英字(ASCII)と図形。NOW と NEXT は文字の予告で、橋の入り口では手前の乾いた広い道を描く。始めてすぐ操作できる。BODY IN/OUT は、雨があるかどうかに関係なく、傘の範囲を表示する。日本語の文字、スクロールする今と次の地面、タイトルでの選択と、くわしい操作パネル。
省いた機能Tの区間練習、Vの左右反転、幅128の比較版(baseline)、AとBの選択、Nで次の道、Qで選択に戻る、F12の記録、GIFの書き出しは省いている。Pの一時停止とRのやり直しはある。これらの選択・練習・比較・記録の機能がある。
音と確認音はなし。付属のJSONは自動の入力、PNGは実際のPyxelで画面を表示せずに描いたもの。人の操作・OSのキー入力・ブラウザー・Windows/macOSの画面・音の出力は確認していない。元のゲームも音なし。元のゲームのデモや、これまでの確認の記録は、教材の新しい確認の数には含めない。

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

  1. 元のゲームの model.py の Model.tick と covered で、判定の境目と更新の順番を比べましょう。
  2. 元のゲームの Model.retry_band を読み、entry_pose と prep で、区間に入ったときの並び方に戻す方法を学びましょう。
  3. 元のゲームの ROADS と guide で、乾いた区間のうちに次の区間の準備をする考え方を読みましょう。
  4. 元のゲームの app.py の terrain で、先の地面をスクロールして見せる描き方を読みましょう。
  5. 元のゲームの Model.__init__ の mirror と baseline で、左右対称や比較の実験の作り方を調べましょう。
自分のゲームに変えるなら

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

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