シミュレーションLESSON 21交通整理 × FIFOと安全な状態遷移原作005

ねぼすけ信号係の作り方

待っている車と、渡っている車を分ける。
先に来た車から順に通す「待ち行列」と、信号の切り替えを作り、歩行者が安全に渡れる状態を考えます。

完成版の遊び方車の列と待っている人を見て、どの信号をいつ赤にするか、いつ青を予約するかを決めます。

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

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

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

完成版の主な操作:左右かA・Dで車線を選ぶ、Spaceで赤にする・青を予約する、Pで一時停止、Rでやり直し、Escで選択画面、Qで終了

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

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

うまく表示されないとき

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

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

待ち行列と、通っている途中の状態を分ける。

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

1. deque の append と popleft で「先に来たものから順に」を表す
2. None か「残り時間の組」で、横断帯がふさがっているかを表す
3. 信号の指示と、進んでいる途中の動きを分ける
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
Python 3.11以上と仮想環境の基本 / 変数・if・for・関数・リスト・タプル / クラスの初歩(deque・None を使った状態・フレームの時間は本文で説明します)
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
01 Space:先頭の車を通す / 02 自動で発進 / 03〜05 左右・A/D:車線、Space:赤と青 / すべての段階で P:一時停止、R:やり直し、Esc:終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_fifo.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

到着順の待ち列を作る

deque の append と popleft で「先に来たものから順に」を表す。

車の待ち列 q には、(到着したフレーム, 通り抜けるのにかかるフレーム) の組を入れます。1秒に30回更新するので、3秒は90フレームです。時刻 t に着いた車は append で列の最後に足し、Space を押した瞬間に popleft で先頭から1台取り出します。これが、先に来たものから先に処理する「FIFO(先入れ先出し)」です。元のゲームの B の A 車線だけを取り出し、0・1・2・3・4・5・8・9秒に着く8台を使います。3秒に着く車はバスです。この段階だけは、まだ通り抜ける時間を使わず、Space を押した瞬間に通ったことにします。到着する車も待ち列もなくなれば WIN(成功)です。

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

01_fifo.py / 注目する部分
            self.last = self.q[0].popleft()
            self.passed += 1

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_fifo.py
macOS / Linux
.venv/bin/python 01_fifo.py
画面で見ること始めた直後は列が空で、最初の更新で0秒の車が着きます。qa/01_fifo_checkpoint_inputs.json では、96フレームの途中と最後に Space を1回押しています。TIME 3.20s、PASSED 1/8、QUEUE 3、LAST SERVED 0.0s になり、1・2・3秒の車が順番に残ります。オレンジの車が、3秒に着くバスです。
  • 追加は最後に append、取り出しは先頭から popleft、と説明できる
  • 到着した秒数のラベルで、順番を追える
  • この段階は「すぐ通り抜ける」という簡略化だとわかる
つまずきやすいところpop() で取り出すと、最後に来た車が先に出る「LIFO(後入れ先出し)」になってしまいます。appendleft() で追加しても、到着の順番が逆になります。また、Space の押しっぱなしを btn で調べると、1秒に30回も車を出してしまいます。押した瞬間を調べる btnp を使います。
少し変えてみよう

R で戻し、3秒ほど待ってから Space を1回ずつ押して、0→1→2→3秒の順に出るか見てみましょう。popleft() を pop() に変えて違いを観察し、確かめたら元に戻してください。最初のフレームでは入力の処理が到着の処理より先なので、起動と同時に Space を押してもまだ車を出せない点も読み取ってみましょう。

01_fifo.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(210行)
01_fifo.py / 全文
# MIT License
# 
# Copyright (c) 2026 Tidepost 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.
# 
# Exception: assets/glyphs.json contains rasterized glyph data derived from
# Noto Sans CJK JP (SIL Open Font License 1.1). See assets/NOTO-LICENSE.txt.
# Pyxel is an external dependency under its own MIT license.
# 教材にNoto字形は含めません。描画はASCIIと図形です。

"""ねぼすけ信号係・教材1/5。単独ファイルで起動。"""
from collections import deque

FPS = 30
CAR = 21
BUS = 66
LANES = 1


def arrivals(a, b, buses=()):
    """秒で記した到着表を、(フレーム, 車線, 通過時間)へ変換する。"""
    return tuple(sorted(
        (int(t * FPS), lane, BUS if (lane, t) in buses else CAR)
        for lane, times in enumerate((a, b)) for t in times
    ))


CARS = arrivals(range(6), [], ((0, 3),)) + arrivals([8, 9], [])


class Model:
    def __init__(self):
        self.t = 0
        self.status = 'play'
        self.events = deque(sorted(CARS))
        self.q = [deque()]
        self.passed = 0
        self.last = None

    def serve(self):
        """01だけの簡略操作。先頭1台を瞬時に通したことにする。"""
        if self.status == 'play' and self.q[0]:
            self.last = self.q[0].popleft()
            self.passed += 1

    def tick(self):
        if self.status != 'play':
            return
        while self.events and self.events[0][0] <= self.t:
            at, lane, duration = self.events.popleft()
            self.q[lane].append((at, duration))
        self.t += 1
        if not self.events and not self.q[0]:
            self.status = 'win'

    def snapshot(self):
        return {'t': self.t, 'status': self.status, 'queue': [list(q) for q in self.q],
                'events': list(self.events), 'passed': self.passed, 'last': self.last}


class Controller:
    """合成入力でも実際のキーでも、同じ分岐を通す。"""
    def __init__(self):
        self.model = Model()
        self.paused = False

    def tick(self, held=(), pressed=()):
        pressed = set(pressed)
        if 'R' in pressed:
            self.__init__()
            return
        if 'P' in pressed:
            self.paused = not self.paused
            return
        if self.paused or self.model.status != 'play':
            return
        if 'SPACE' in pressed:
            self.model.serve()
        self.model.tick()


KEYS = ('P', 'R', 'SPACE')


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 = 0
    events = []
    duration = max(12, len(replay or []))
    keys = {name: getattr(pyxel, 'KEY_' + name) for name in KEYS}

    def update():
        nonlocal frames
        frames += 1
        if headless:
            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 vehicle(x, y, duration, color=12):
        width = 22 if duration == BUS else 16
        pyxel.rect(x - width, y - 6, width, 12, color)
        pyxel.rectb(x - width, y - 6, width, 12, 7)
        pyxel.rect(x - width + 3, y - 4, 5, 4, 1)
        pyxel.rect(x - width + 2, y + 6, 4, 2, 0)
        pyxel.rect(x - 6, y + 6, 4, 2, 0)

    def draw():
        m = control.model
        pyxel.cls(1)
        pyxel.text(12, 9, 'SLEEPY SIGNAL / 1: FIFO', 10)
        pyxel.text(12, 23, f'TIME {m.t / FPS:5.2f}s   PASSED {m.passed}/{len(CARS)}   {m.status.upper()}', 7)
        pyxel.rect(8, 39, 444, 42, 0)
        for lane in range(LANES):
            upcoming = [e for e in m.events if e[1] == lane][:3]
            items = '  '.join(f'{max(0, at - m.t) / FPS:.1f}s ' + ('BUS' if dur == BUS else 'CAR')
                              for at, _, dur in upcoming)
            pyxel.text(16, 47 + lane * 14, 'NEXT ' + 'AB'[lane] + ': ' + (items or 'NONE'), 6)
        for lane in range(LANES):
            y = 118 + lane * 64
            pyxel.rect(8, y - 20, 444, 43, 5)
            pyxel.line(290, y - 20, 290, y + 22, 7)
            for x in range(304, 340, 8):
                pyxel.rect(x, y - 17, 4, 34, 13)
            pyxel.text(13, y - 16, 'AB'[lane], 7)
            pyxel.text(13, y + 26, f'QUEUE {len(m.q[lane])}', 6)
            for j, (at, dur) in enumerate(list(m.q[lane])[:6]):
                x = 278 - j * 27
                vehicle(x, y, dur, 9 if dur == BUS else 12)
                pyxel.text(x - 15, y - 17, str(at // FPS), 7)
            if len(m.q[lane]) > 6:
                pyxel.text(101, y + 11, '+' + str(len(m.q[lane]) - 6) + ' MORE', 8)
        pyxel.text(12, 186, 'APPEND AT BACK -> POPLEFT FROM FRONT', 11)
        pyxel.text(12, 201, 'LABELS = ARRIVAL SECONDS / ORANGE = BUS', 6)
        pyxel.text(12, 217, 'LAST SERVED: ' + (str(m.last[0] / FPS) + 's' if m.last else 'NONE'), 10)
        pyxel.text(12, 235, 'SIMPLIFIED: SPACE PASSES ONE CAR INSTANTLY.', 7)
        pyxel.text(12, 251, 'SPACE: SERVE FRONT   P: PAUSE   R: RESET', 7)
        pyxel.text(12, 283, 'ESC: QUIT   SILENT / NO RANDOM ARRIVALS', 6)
        if control.paused:
            pyxel.rect(139, 157, 180, 26, 0)
            pyxel.text(164, 166, '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({
                    'scope': 'Synthetic controller inputs; actual Pyxel headless renderer; silent',
                    'render_frames': frames, '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(460, 302, title='Sleepy Signal - 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')
    modes.add_argument('--headless-replay', help='UTF-8 JSON held/pressed input list')
    parser.add_argument('--screenshot', help='Output PNG in headless mode')
    parser.add_argument('--report', help='Output UTF-8 JSON in headless mode')
    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'))
        valid = isinstance(replay, list) and all(
            isinstance(event, dict) and set(event) <= {'held', 'pressed'} and all(
                isinstance(event.get(k, []), list) and all(isinstance(v, str) and v in KEYS
                    for v in event.get(k, [])) for k in ('held', 'pressed'))
            for event in replay)
        if not valid:
            parser.error('Use a list of held/pressed dictionaries with supported key names')
    main(headless, replay, args.screenshot, args.report)
STEP 02 / 05

待ち列と通過中の車を分ける

None か「残り時間の組」で、横断帯がふさがっているかを表す。

q は停止線の手前で待っている車、active は横断帯を通っている途中の車です。active には (残り時間, 通り抜けるのにかかる全部の時間) を持たせ、毎フレーム残り時間を1ずつ減らします。0になったら車が出ていったので passed を増やし、active を None(だれもいない)に戻します。そのあと、空いていて列に車がいれば、次の1台を popleft します。小さな車は21フレーム(0.7秒)、バスは66フレーム(2.2秒)で、元のゲームと同じです。新しく出した車を同じフレームで減らさないように、「通り抜け終わり→次の発進」の順に並べます。まだ信号も、発進の準備も、歩行者もなく、自動で発進します。

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

02_occupied.py / 注目する部分
        if not self.active[0] and self.q[0]:
            at, duration = self.q[0].popleft()
            self.active[0] = (duration, duration)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_occupied.py
macOS / Linux
.venv/bin/python 02_occupied.py
画面で見ること何も押さずに見ていると、3秒に着いたバスが横断帯を長く占めます。付属の125フレームのリプレイでは、TIME 4.17s、PASSED 3/8、BUSY 32、QUEUE 1 です。通っている途中のバスとは別に、4秒に着いた小さな車が待っています。最初の12フレームだけ描く headless-smoke は、時間を進めない確認用のモードです。
  • q と active の役割を区別できる
  • 新しい車は、通り抜ける全部の時間を持って発進する
  • バスが通っている間、後ろの車は q で待つ
つまずきやすいところq が空だからといって、通っている途中の車もいないとは限りません。active の車は、もう q から取り出してあります。また、active を q に残したまま描くと、2台に数えてしまいます。残り0を消さずにマイナスまで減らし続けると、次の車が出ません。
少し変えてみよう

P で止めて、画面の待っている台数と、BUSY の残り時間を別々に読んでみましょう。BUS を66から90に変えた版では、バスが3秒間ふさぎます。待ち列がどう長くなるか予想してから実行してください(これは元のゲームの通り抜ける時間を変える改造です)。

02_occupied.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(219行)
02_occupied.py / 全文
# MIT License
# 
# Copyright (c) 2026 Tidepost 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.
# 
# Exception: assets/glyphs.json contains rasterized glyph data derived from
# Noto Sans CJK JP (SIL Open Font License 1.1). See assets/NOTO-LICENSE.txt.
# Pyxel is an external dependency under its own MIT license.
# 教材にNoto字形は含めません。描画はASCIIと図形です。

"""ねぼすけ信号係・教材2/5。単独ファイルで起動。"""
from collections import deque

FPS = 30
CAR = 21
BUS = 66
LANES = 1


def arrivals(a, b, buses=()):
    """秒で記した到着表を、(フレーム, 車線, 通過時間)へ変換する。"""
    return tuple(sorted(
        (int(t * FPS), lane, BUS if (lane, t) in buses else CAR)
        for lane, times in enumerate((a, b)) for t in times
    ))


CARS = arrivals(range(6), [], ((0, 3),)) + arrivals([8, 9], [])


class Model:
    def __init__(self):
        self.t = 0
        self.status = 'play'
        self.events = deque(sorted(CARS))
        self.q = [deque()]
        self.active = [None]  # 待ち列とは別。(残り時間, 全通過時間)
        self.passed = 0

    def tick(self):
        if self.status != 'play':
            return
        while self.events and self.events[0][0] <= self.t:
            at, lane, duration = self.events.popleft()
            self.q[lane].append((at, duration))
        if self.active[0]:
            remaining, duration = self.active[0]
            remaining -= 1
            if remaining <= 0:
                self.active[0] = None
                self.passed += 1
            else:
                self.active[0] = (remaining, duration)
        if not self.active[0] and self.q[0]:
            at, duration = self.q[0].popleft()
            self.active[0] = (duration, duration)
        self.t += 1
        if not (self.events or self.q[0] or self.active[0]):
            self.status = 'win'

    def snapshot(self):
        return {'t': self.t, 'status': self.status, 'queue': [list(q) for q in self.q],
                'events': list(self.events), 'passed': self.passed, 'active': self.active.copy()}


class Controller:
    """合成入力でも実際のキーでも、同じ分岐を通す。"""
    def __init__(self):
        self.model = Model()
        self.paused = False

    def tick(self, held=(), pressed=()):
        pressed = set(pressed)
        if 'R' in pressed:
            self.__init__()
            return
        if 'P' in pressed:
            self.paused = not self.paused
            return
        if self.paused or self.model.status != 'play':
            return
        self.model.tick()


KEYS = ('P', 'R')


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 = 0
    events = []
    duration = max(12, len(replay or []))
    keys = {name: getattr(pyxel, 'KEY_' + name) for name in KEYS}

    def update():
        nonlocal frames
        frames += 1
        if headless:
            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 vehicle(x, y, duration, color=12):
        width = 22 if duration == BUS else 16
        pyxel.rect(x - width, y - 6, width, 12, color)
        pyxel.rectb(x - width, y - 6, width, 12, 7)
        pyxel.rect(x - width + 3, y - 4, 5, 4, 1)
        pyxel.rect(x - width + 2, y + 6, 4, 2, 0)
        pyxel.rect(x - 6, y + 6, 4, 2, 0)

    def draw():
        m = control.model
        pyxel.cls(1)
        pyxel.text(12, 9, 'SLEEPY SIGNAL / 2: OCCUPIED CROSSING', 10)
        pyxel.text(12, 23, f'TIME {m.t / FPS:5.2f}s   PASSED {m.passed}/{len(CARS)}   {m.status.upper()}', 7)
        pyxel.rect(8, 39, 444, 42, 0)
        for lane in range(LANES):
            upcoming = [e for e in m.events if e[1] == lane][:3]
            items = '  '.join(f'{max(0, at - m.t) / FPS:.1f}s ' + ('BUS' if dur == BUS else 'CAR')
                              for at, _, dur in upcoming)
            pyxel.text(16, 47 + lane * 14, 'NEXT ' + 'AB'[lane] + ': ' + (items or 'NONE'), 6)
        for lane in range(LANES):
            y = 118 + lane * 64
            pyxel.rect(8, y - 20, 444, 43, 5)
            pyxel.line(290, y - 20, 290, y + 22, 7)
            for x in range(304, 340, 8):
                pyxel.rect(x, y - 17, 4, 34, 13)
            pyxel.text(13, y - 16, 'AB'[lane], 7)
            pyxel.text(13, y + 26, f'QUEUE {len(m.q[lane])}', 6)
            for j, (at, dur) in enumerate(list(m.q[lane])[:6]):
                x = 278 - j * 27
                vehicle(x, y, dur, 9 if dur == BUS else 12)
                pyxel.text(x - 15, y - 17, str(at // FPS), 7)
            if len(m.q[lane]) > 6:
                pyxel.text(101, y + 11, '+' + str(len(m.q[lane]) - 6) + ' MORE', 8)
            if m.active[lane]:
                remaining, dur = m.active[lane]
                x = 312 + (dur - remaining) / dur * 112
                vehicle(x, y, dur, 9 if dur == BUS else 12)
                pyxel.text(346, y - 17, f'BUSY {remaining:02}', 10)
            else:
                pyxel.text(347, y - 17, 'CLEAR', 11)
        pyxel.text(12, 186, 'QUEUE IS NOT THE OCCUPIED CROSSING.', 11)
        pyxel.text(12, 203, 'CAR = 21 FRAMES / BUS = 66 FRAMES', 6)
        pyxel.text(12, 220, 'SIMPLIFIED: AUTO START, NO SIGNAL / PEOPLE.', 7)
        pyxel.text(12, 251, 'P: PAUSE   R: RESET   WATCH THE BUS AT 3s', 7)
        pyxel.text(12, 283, 'ESC: QUIT   SILENT / NO RANDOM ARRIVALS', 6)
        if control.paused:
            pyxel.rect(139, 157, 180, 26, 0)
            pyxel.text(164, 166, '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({
                    'scope': 'Synthetic controller inputs; actual Pyxel headless renderer; silent',
                    'render_frames': frames, '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(460, 302, title='Sleepy Signal - 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')
    modes.add_argument('--headless-replay', help='UTF-8 JSON held/pressed input list')
    parser.add_argument('--screenshot', help='Output PNG in headless mode')
    parser.add_argument('--report', help='Output UTF-8 JSON in headless mode')
    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'))
        valid = isinstance(replay, list) and all(
            isinstance(event, dict) and set(event) <= {'held', 'pressed'} and all(
                isinstance(event.get(k, []), list) and all(isinstance(v, str) and v in KEYS
                    for v in event.get(k, [])) for k in ('held', 'pressed'))
            for event in replay)
        if not valid:
            parser.error('Use a list of held/pressed dictionaries with supported key names')
    main(headless, replay, args.screenshot, args.report)
STEP 03 / 05

赤・青と発進準備を加える

信号の指示と、進んでいる途中の動きを分ける。

A と B の2車線にして、元のゲームの B の12台を同じ時刻に着かせます。左右か A・D で車線を選び、Space でその車線の green(青かどうか)を切り替えます。赤は新しい車の発進を止めますが、active の車はそのまま進みます。青に切り替えたときだけ ready(発進までの準備時間)を21にし、青の間は更新のたびに1ずつ減らします。0になったら発進できます。青が続いている間は、後ろの車にそのたびに21を足すことはしません。元のゲームの道路は並んだ2車線なので、両方同時に青にできます。この段階は車だけの練習で、人・列の上限・失敗の判定はまだ加えません。

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

03_signals.py / 注目する部分
        if green:
            self.ready[lane] = START

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_signals.py
macOS / Linux
.venv/bin/python 03_signals.py
画面で見ること起動から20回更新しても、最初の車はまだ止まっています。21回目で A と B が発進します。付属の確認用の操作では、3秒のバスが入ったあと、t=95 で A を赤にします。t=125 では A は RED、BUSY 32、QUEUE 1 です。赤にしても通っている途中のバスは消えず、後ろの車だけが止まります。
  • 赤は新しい発進だけを止める
  • 青に切り替えたときだけ START(準備時間)を設定する
  • 両方同時に青にできるのは、元のゲームの並んだ車線のルールだとわかる
つまずきやすいところ赤に変えた瞬間に active=None にすると、車が途中で消えてしまいます。ready に毎フレーム21を代入すると、いつまでも発進できません。青の指示に変えたときに1回だけ設定し、そのあとの更新で減らします。
少し変えてみよう

最初から A を赤にして列を作り、青に戻して START が21から減っていく様子を見ましょう。P で止めている間は Space を押しても信号は変わらず、R は P より優先して最初に戻します。通り抜ける時間の CAR=21 と、発進の準備の START=21 は、値は同じでも役割が違うことを説明してください。

03_signals.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(251行)
03_signals.py / 全文
# MIT License
# 
# Copyright (c) 2026 Tidepost 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.
# 
# Exception: assets/glyphs.json contains rasterized glyph data derived from
# Noto Sans CJK JP (SIL Open Font License 1.1). See assets/NOTO-LICENSE.txt.
# Pyxel is an external dependency under its own MIT license.
# 教材にNoto字形は含めません。描画はASCIIと図形です。

"""ねぼすけ信号係・教材3/5。単独ファイルで起動。"""
from collections import deque

FPS = 30
CAR = 21
BUS = 66
START = 21
LANES = 2


def arrivals(a, b, buses=()):
    """秒で記した到着表を、(フレーム, 車線, 通過時間)へ変換する。"""
    return tuple(sorted(
        (int(t * FPS), lane, BUS if (lane, t) in buses else CAR)
        for lane, times in enumerate((a, b)) for t in times
    ))


# 原作B『列より先に人』と同じ12台。3秒のAはバス。
CARS = arrivals(range(6), [0, 3, 6, 9], ((0, 3),)) + arrivals([8, 9], [])


class Model:
    def __init__(self):
        self.t = 0
        self.status = 'play'
        self.events = deque(sorted(CARS))
        self.q = [deque(), deque()]
        self.green = [True, True]  # 指令。04以降は横断中の予約も表す。
        self.ready = [START, START]
        self.active = [None, None]
        self.passed = 0

    def set_signal(self, lane, green):
        if self.status != 'play' or self.green[lane] == green:
            return
        self.green[lane] = green
        if green:
            self.ready[lane] = START

    def toggle(self, lane):
        self.set_signal(lane, not self.green[lane])

    def tick(self):
        if self.status != 'play':
            return
        while self.events and self.events[0][0] <= self.t:
            at, lane, duration = self.events.popleft()
            self.q[lane].append((at, duration))
        # 赤になっても、すでに入った車は最後まで通過する。
        for lane in range(2):
            if self.active[lane]:
                remaining, duration = self.active[lane]
                remaining -= 1
                if remaining <= 0:
                    self.active[lane] = None
                    self.passed += 1
                else:
                    self.active[lane] = (remaining, duration)
        for lane in range(2):
            if self.green[lane]:
                self.ready[lane] = max(0, self.ready[lane] - 1)
                if not self.ready[lane] and not self.active[lane] and self.q[lane]:
                    at, duration = self.q[lane].popleft()
                    self.active[lane] = (duration, duration)
        self.t += 1
        if self.status == 'play' and not (self.events or any(self.q) or any(self.active)):
            self.status = 'win'

    def snapshot(self):
        return {'t': self.t, 'status': self.status, 'queue': [list(q) for q in self.q],
                'events': list(self.events), 'passed': self.passed, 'active': self.active.copy(),
                'green': self.green.copy(), 'ready': self.ready.copy()}


class Controller:
    """合成入力でも実際のキーでも、同じ分岐を通す。"""
    def __init__(self):
        self.model = Model()
        self.paused = False
        self.selected = 0

    def tick(self, held=(), pressed=()):
        pressed = set(pressed)
        if 'R' in pressed:
            self.__init__()
            return
        if 'P' in pressed:
            self.paused = not self.paused
            return
        if self.paused or self.model.status != 'play':
            return
        if 'LEFT' in pressed or 'A' in pressed:
            self.selected = 0
        if 'RIGHT' in pressed or 'D' in pressed:
            self.selected = 1
        if 'SPACE' in pressed:
            self.model.toggle(self.selected)
        self.model.tick()


KEYS = ('P', 'R', 'LEFT', 'RIGHT', 'A', 'D', 'SPACE')


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 = 0
    events = []
    duration = max(12, len(replay or []))
    keys = {name: getattr(pyxel, 'KEY_' + name) for name in KEYS}

    def update():
        nonlocal frames
        frames += 1
        if headless:
            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 vehicle(x, y, duration, color=12):
        width = 22 if duration == BUS else 16
        pyxel.rect(x - width, y - 6, width, 12, color)
        pyxel.rectb(x - width, y - 6, width, 12, 7)
        pyxel.rect(x - width + 3, y - 4, 5, 4, 1)
        pyxel.rect(x - width + 2, y + 6, 4, 2, 0)
        pyxel.rect(x - 6, y + 6, 4, 2, 0)

    def draw():
        m = control.model
        pyxel.cls(1)
        pyxel.text(12, 9, 'SLEEPY SIGNAL / 3: SIGNALS + STARTUP', 10)
        pyxel.text(12, 23, f'TIME {m.t / FPS:5.2f}s   PASSED {m.passed}/{len(CARS)}   {m.status.upper()}', 7)
        pyxel.rect(8, 39, 444, 42, 0)
        for lane in range(LANES):
            upcoming = [e for e in m.events if e[1] == lane][:3]
            items = '  '.join(f'{max(0, at - m.t) / FPS:.1f}s ' + ('BUS' if dur == BUS else 'CAR')
                              for at, _, dur in upcoming)
            pyxel.text(16, 47 + lane * 14, 'NEXT ' + 'AB'[lane] + ': ' + (items or 'NONE'), 6)
        for lane in range(LANES):
            y = 118 + lane * 64
            pyxel.rect(8, y - 20, 444, 43, 5)
            pyxel.line(290, y - 20, 290, y + 22, 7)
            for x in range(304, 340, 8):
                pyxel.rect(x, y - 17, 4, 34, 13)
            pyxel.text(13, y - 16, 'AB'[lane], 7)
            if control.selected == lane:
                pyxel.rectb(8, y - 20, 286, 43, 10)
            effective_green = m.green[lane]
            state = 'GREEN' if effective_green else 'RED'
            pyxel.circ(30, y + 3, 8, 11 if effective_green else 8)
            pyxel.text(44, y, state, 7)
            pyxel.text(13, y + 26, f'QUEUE {len(m.q[lane])}  START {m.ready[lane]:02} FRAMES', 6)
            for j, (at, dur) in enumerate(list(m.q[lane])[:6]):
                x = 278 - j * 27
                vehicle(x, y, dur, 9 if dur == BUS else 12)
                pyxel.text(x - 15, y - 17, str(at // FPS), 7)
            if len(m.q[lane]) > 6:
                pyxel.text(101, y + 11, '+' + str(len(m.q[lane]) - 6) + ' MORE', 8)
            if m.active[lane]:
                remaining, dur = m.active[lane]
                x = 312 + (dur - remaining) / dur * 112
                vehicle(x, y, dur, 9 if dur == BUS else 12)
                pyxel.text(346, y - 17, f'BUSY {remaining:02}', 10)
            else:
                pyxel.text(347, y - 17, 'CLEAR', 11)
        pyxel.text(12, 225, 'RED BLOCKS NEW CARS. BUSY CARS KEEP MOVING.', 11)
        pyxel.text(12, 240, 'RED -> GREEN NEEDS 21 UPDATES. NO PEOPLE YET.', 6)
        pyxel.text(12, 269, 'LEFT/RIGHT or A/D: LANE   SPACE: TOGGLE', 7)
        pyxel.text(12, 283, 'P: PAUSE   R: RESET   ESC: QUIT   SILENT', 6)
        if control.paused:
            pyxel.rect(139, 157, 180, 26, 0)
            pyxel.text(164, 166, '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({
                    'scope': 'Synthetic controller inputs; actual Pyxel headless renderer; silent',
                    'render_frames': frames, '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(460, 302, title='Sleepy Signal - 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')
    modes.add_argument('--headless-replay', help='UTF-8 JSON held/pressed input list')
    parser.add_argument('--screenshot', help='Output PNG in headless mode')
    parser.add_argument('--report', help='Output UTF-8 JSON in headless mode')
    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'))
        valid = isinstance(replay, list) and all(
            isinstance(event, dict) and set(event) <= {'held', 'pressed'} and all(
                isinstance(event.get(k, []), list) and all(isinstance(v, str) and v in KEYS
                    for v in event.get(k, [])) for k in ('held', 'pressed'))
            for event in replay)
        if not valid:
            parser.error('Use a list of held/pressed dictionaries with supported key names')
    main(headless, replay, args.screenshot, args.report)
STEP 04 / 05

両赤・空き確認・青予約をつなぐ

いくつもの条件と、安全な更新の順番で、横断を始める。

3.8秒に1組の歩行者が来て、渡るのに2秒かかります。「待っている人がいる」「だれも渡っていない」「両方の信号が青の指示ではない(False)」「両方の active が None」の4つの条件がそろうと、渡り始めます。q に車が残っていても、赤で止まっていればかまいません。人が渡っている間に青を選ぶと、green=True を「予約」として保存します。表示は赤のまま RESERVED(予約済み)となり、ready も21で止まります。渡り終わった更新で ready を20に減らし、合計21回の許された更新で発進します。予約したあとにもう一度押せば、赤に戻せます。ここでは、待ち時間や列の長さによる失敗はまだありません。

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

04_pedestrians.py / 注目する部分
        if self.waiting and not self.walk and not any(self.green) and not any(self.active):
            self.walk = self.walk_total = max(p[2] for p in self.waiting)
            self.walk_batch = len(self.waiting)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_pedestrians.py
macOS / Linux
.venv/bin/python 04_pedestrians.py
画面で見ること付属の操作では、t=84 で A を赤、96 で B を赤にして、3秒の A のバスを手前で止めます。人は t=114 で渡り始めます。125 で A、140 で B の青を予約すると、t=141 の画面は両方 RESERVED、START 21、WALK 1.13s です。別の bus_blocked のリプレイでは、バスが入ったあとに両方を赤にするので、t=125 でもまだ渡り始めず、BUSY 32 が空くのを待ちます。
  • 列に車がいても、両方赤なら渡れる
  • バスが通っている途中は渡らない
  • 青の予約と、実際の青の表示を区別できる
つまずきやすいところnot any(q) を渡る条件にすると、赤で止めた車の列がなくなるまで、人がいつまでも待ってしまいます。空である必要があるのは active です。また、人が渡っている間も ready を減らすと、渡り終わった直後に予約した車が飛び出し、元のゲームの発進の準備のルールと変わってしまいます。
少し変えてみよう

qa/04_pedestrians_checkpoint_inputs.json の125フレーム目あたりを読み、t=125 の青の予約を赤に戻す入力を追加してみましょう。渡り終えても、A は赤のままになるはずです。2組が同時に待つように広げるなら、人数を足し、渡る時間はいちばん長いものを max で選んでいる理由も考えてみてください。

04_pedestrians.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(289行)
04_pedestrians.py / 全文
# MIT License
# 
# Copyright (c) 2026 Tidepost 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.
# 
# Exception: assets/glyphs.json contains rasterized glyph data derived from
# Noto Sans CJK JP (SIL Open Font License 1.1). See assets/NOTO-LICENSE.txt.
# Pyxel is an external dependency under its own MIT license.
# 教材にNoto字形は含めません。描画はASCIIと図形です。

"""ねぼすけ信号係・教材4/5。単独ファイルで起動。"""
from collections import deque

FPS = 30
CAR = 21
BUS = 66
START = 21
LANES = 2


def arrivals(a, b, buses=()):
    """秒で記した到着表を、(フレーム, 車線, 通過時間)へ変換する。"""
    return tuple(sorted(
        (int(t * FPS), lane, BUS if (lane, t) in buses else CAR)
        for lane, times in enumerate((a, b)) for t in times
    ))


# 原作B『列より先に人』と同じ12台。3秒のAはバス。
CARS = arrivals(range(6), [0, 3, 6, 9], ((0, 3),)) + arrivals([8, 9], [])
PEOPLE = ((114, 180, 60),)  # 到着3.8秒・許容待ち6秒・横断2秒


class Model:
    def __init__(self):
        self.t = 0
        self.status = 'play'
        self.events = deque(sorted(CARS))
        self.q = [deque(), deque()]
        self.green = [True, True]  # 指令。04以降は横断中の予約も表す。
        self.ready = [START, START]
        self.active = [None, None]
        self.passed = 0
        self.p_events = deque(PEOPLE)
        self.waiting = []
        self.walk = 0
        self.walk_total = 0
        self.walk_batch = 0
        self.walked = 0
        self.log = []

    def set_signal(self, lane, green):
        if self.status != 'play' or self.green[lane] == green:
            return
        self.green[lane] = green
        if green:
            self.ready[lane] = START
        self.log.append((self.t, 'signal', lane, green))

    def toggle(self, lane):
        self.set_signal(lane, not self.green[lane])

    def tick(self):
        if self.status != 'play':
            return
        while self.events and self.events[0][0] <= self.t:
            at, lane, duration = self.events.popleft()
            self.q[lane].append((at, duration))
        while self.p_events and self.p_events[0][0] <= self.t:
            self.waiting.append(self.p_events.popleft())
        if self.walk:
            self.walk -= 1
            if not self.walk:
                self.walked += self.walk_batch
                self.log.append((self.t, 'walk_end', self.walk_batch))
        # 赤になっても、すでに入った車は最後まで通過する。
        for lane in range(2):
            if self.active[lane]:
                remaining, duration = self.active[lane]
                remaining -= 1
                if remaining <= 0:
                    self.active[lane] = None
                    self.passed += 1
                else:
                    self.active[lane] = (remaining, duration)
        # 空にするのは待ち列qではなく、横断部分active。
        if self.waiting and not self.walk and not any(self.green) and not any(self.active):
            self.walk = self.walk_total = max(p[2] for p in self.waiting)
            self.walk_batch = len(self.waiting)
            self.log.append((self.t, 'walk_start', self.walk_batch))
            self.waiting = []
        for lane in range(2):
            if self.green[lane] and not self.walk:
                self.ready[lane] = max(0, self.ready[lane] - 1)
                if not self.ready[lane] and not self.active[lane] and self.q[lane]:
                    at, duration = self.q[lane].popleft()
                    self.active[lane] = (duration, duration)
        assert not (self.walk and any(self.active)), 'unsafe crossing'
        self.t += 1
        if self.status == 'play' and not (self.events or any(self.q) or any(self.active) or self.p_events or self.waiting or self.walk):
            self.status = 'win'

    def snapshot(self):
        return {'t': self.t, 'status': self.status, 'queue': [list(q) for q in self.q],
                'events': list(self.events), 'passed': self.passed, 'active': self.active.copy(),
                'green': self.green.copy(), 'ready': self.ready.copy(), 'p_events': list(self.p_events),
                'waiting': self.waiting.copy(), 'walk': self.walk, 'walk_total': self.walk_total,
                'walk_batch': self.walk_batch, 'walked': self.walked, 'log': self.log.copy()}


class Controller:
    """合成入力でも実際のキーでも、同じ分岐を通す。"""
    def __init__(self):
        self.model = Model()
        self.paused = False
        self.selected = 0

    def tick(self, held=(), pressed=()):
        pressed = set(pressed)
        if 'R' in pressed:
            self.__init__()
            return
        if 'P' in pressed:
            self.paused = not self.paused
            return
        if self.paused or self.model.status != 'play':
            return
        if 'LEFT' in pressed or 'A' in pressed:
            self.selected = 0
        if 'RIGHT' in pressed or 'D' in pressed:
            self.selected = 1
        if 'SPACE' in pressed:
            self.model.toggle(self.selected)
        self.model.tick()


KEYS = ('P', 'R', 'LEFT', 'RIGHT', 'A', 'D', 'SPACE')


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 = 0
    events = []
    duration = max(12, len(replay or []))
    keys = {name: getattr(pyxel, 'KEY_' + name) for name in KEYS}

    def update():
        nonlocal frames
        frames += 1
        if headless:
            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 vehicle(x, y, duration, color=12):
        width = 22 if duration == BUS else 16
        pyxel.rect(x - width, y - 6, width, 12, color)
        pyxel.rectb(x - width, y - 6, width, 12, 7)
        pyxel.rect(x - width + 3, y - 4, 5, 4, 1)
        pyxel.rect(x - width + 2, y + 6, 4, 2, 0)
        pyxel.rect(x - 6, y + 6, 4, 2, 0)

    def draw():
        m = control.model
        pyxel.cls(1)
        pyxel.text(12, 9, 'SLEEPY SIGNAL / 4: PEDESTRIANS + RESERVATION', 10)
        pyxel.text(12, 23, f'TIME {m.t / FPS:5.2f}s   PASSED {m.passed}/{len(CARS)}   {m.status.upper()}', 7)
        pyxel.rect(8, 39, 444, 42, 0)
        for lane in range(LANES):
            upcoming = [e for e in m.events if e[1] == lane][:3]
            items = '  '.join(f'{max(0, at - m.t) / FPS:.1f}s ' + ('BUS' if dur == BUS else 'CAR')
                              for at, _, dur in upcoming)
            pyxel.text(16, 47 + lane * 14, 'NEXT ' + 'AB'[lane] + ': ' + (items or 'NONE'), 6)
        for lane in range(LANES):
            y = 118 + lane * 64
            pyxel.rect(8, y - 20, 444, 43, 5)
            pyxel.line(290, y - 20, 290, y + 22, 7)
            for x in range(304, 340, 8):
                pyxel.rect(x, y - 17, 4, 34, 13)
            pyxel.text(13, y - 16, 'AB'[lane], 7)
            if control.selected == lane:
                pyxel.rectb(8, y - 20, 286, 43, 10)
            effective_green = m.green[lane] and not m.walk
            state = 'RESERVED' if m.green[lane] and m.walk else ('GREEN' if effective_green else 'RED')
            pyxel.circ(30, y + 3, 8, 11 if effective_green else 8)
            pyxel.text(44, y, state, 7)
            pyxel.text(13, y + 26, f'QUEUE {len(m.q[lane])}  START {m.ready[lane]:02} FRAMES', 6)
            for j, (at, dur) in enumerate(list(m.q[lane])[:6]):
                x = 278 - j * 27
                vehicle(x, y, dur, 9 if dur == BUS else 12)
                pyxel.text(x - 15, y - 17, str(at // FPS), 7)
            if len(m.q[lane]) > 6:
                pyxel.text(101, y + 11, '+' + str(len(m.q[lane]) - 6) + ' MORE', 8)
            if m.active[lane]:
                remaining, dur = m.active[lane]
                x = 312 + (dur - remaining) / dur * 112
                vehicle(x, y, dur, 9 if dur == BUS else 12)
                pyxel.text(346, y - 17, f'BUSY {remaining:02}', 10)
            else:
                pyxel.text(347, y - 17, 'CLEAR', 11)
        if m.walk:
            pyxel.text(12, 225, f'WALK {m.walk / FPS:.2f}s  BATCH {m.walk_batch}  CARS MUST WAIT', 10)
            yy = 99 + (1 - m.walk / m.walk_total) * 108
            pyxel.circ(324, yy - 4, 3, 10)
            pyxel.line(324, yy, 324, yy + 7, 10)
            pyxel.line(324, yy + 7, 320, yy + 12, 10)
            pyxel.line(324, yy + 7, 328, yy + 12, 10)
        elif m.waiting:
            left = min(p[1] - (m.t - p[0]) for p in m.waiting) / FPS
            pyxel.text(12, 225, f'WAITING {len(m.waiting)} / NO WAIT LIMIT IN STEP 4', 10)
        else:
            next_person = max(0, m.p_events[0][0] - m.t) / FPS if m.p_events else None
            pyxel.text(12, 225, f'WALKED {m.walked}/{len(PEOPLE)} / NEXT PERSON: ' +
                       (f'{next_person:.1f}s' if next_person is not None else 'NONE'), 6)
        pyxel.text(12, 241, 'BOTH RED + CLEAR -> WALK / GREEN CAN BE RESERVED', 11)
        pyxel.text(12, 269, 'LEFT/RIGHT or A/D: LANE   SPACE: TOGGLE', 7)
        pyxel.text(12, 283, 'P: PAUSE   R: RESET   ESC: QUIT   SILENT', 6)
        if control.paused:
            pyxel.rect(139, 157, 180, 26, 0)
            pyxel.text(164, 166, '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({
                    'scope': 'Synthetic controller inputs; actual Pyxel headless renderer; silent',
                    'render_frames': frames, '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(460, 302, title='Sleepy Signal - 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')
    modes.add_argument('--headless-replay', help='UTF-8 JSON held/pressed input list')
    parser.add_argument('--screenshot', help='Output PNG in headless mode')
    parser.add_argument('--report', help='Output UTF-8 JSON in headless mode')
    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'))
        valid = isinstance(replay, list) and all(
            isinstance(event, dict) and set(event) <= {'held', 'pressed'} and all(
                isinstance(event.get(k, []), list) and all(isinstance(v, str) and v in KEYS
                    for v in event.get(k, [])) for k in ('held', 'pressed'))
            for event in replay)
        if not valid:
            parser.error('Use a list of held/pressed dictionaries with supported key names')
    main(headless, replay, args.screenshot, args.report)
STEP 05 / 05

有限の一仕事と失敗条件を完成させる

続いている時間・待てる限界・終わり・別々の評価の値。

元のゲームの B の到着表に、元のゲームどおりの上限を戻します。列が7台以上になった更新では overflow を増やし、6台以下なら0に戻します。これが21回続くと「詰まり」で失敗です。人は、着いてから180フレーム待つと失敗です。ただし、同じ更新の中で先に渡り始められた人は待っている人から外れるので、ちょうど限界のときに渡り始めれば間に合います。到着予定の車・列・通っている途中の車・到着予定の人・待っている人・渡っている人が、すべてなくなれば WIN です。一律の時間切れや、まとめた点数はありません。車と人の待ち時間をそれぞれ足し、信号を切り替えた回数も表示します。

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

05_scenario.py / 注目する部分
            if len(self.q[lane]) > 6:
                self.overflow[lane] += 1
            else:
                self.overflow[lane] = 0
            if self.overflow[lane] >= CAR:
                self.fail('QUEUE ' + 'AB'[lane] + ' OVERFLOW')

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_scenario.py
macOS / Linux
.venv/bin/python 05_scenario.py
画面で見ること付属の完成リプレイでは、345回の更新(11.5秒)で12台と1組が通り、WIN になります。WAIT SUM CAR 18.6s(中では557フレーム・台)、PERSON 0.0s、SWITCHES 4 です。何も操作しないと人が渡れず、t=295(9.83秒)で PEDESTRIAN WAIT LIMIT(歩行者の待ち時間の限界)です。失敗したらゲームは止まり、R で同じ列をやり直せます。600入力の自動リプレイの後半は、終わって止まっているので時刻は増えません。
  • 「7台以上が21回続く」という条件を読める
  • 人が待てる限界の境目と、処理の順番を説明できる
  • 「全員通す」と「待ち時間を短くする」を、別々の目標にできる
つまずきやすいところ7台目が来た瞬間に失敗にすると、元のゲームにある21フレームの猶予がなくなってしまいます。また、車の待ち時間の合計に active まで数えると、「停止線の手前で待った時間」という意味が変わってしまいます。到着の予定表だけで終わりを判断すると、最後の車が通っている途中でも WIN になってしまいます。
少し変えてみよう

まず何も操作せずに失敗するところを見ましょう。次に、A を2.8秒ごろ、B を3.2秒ごろに赤にして、人が渡っている間に青を予約し、全員を通します。A も B も3.2秒ごろに止める作戦なら、バスを先に通すので、人は少し待ちます。車と人の待ち時間の合計を分けて比べ、どちらを優先したいか考えてみましょう。フレーム単位の正確な比べ方は、付属のテストでできます。

05_scenario.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(317行)
05_scenario.py / 全文
# MIT License
# 
# Copyright (c) 2026 Tidepost 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.
# 
# Exception: assets/glyphs.json contains rasterized glyph data derived from
# Noto Sans CJK JP (SIL Open Font License 1.1). See assets/NOTO-LICENSE.txt.
# Pyxel is an external dependency under its own MIT license.
# 教材にNoto字形は含めません。描画はASCIIと図形です。

"""ねぼすけ信号係・教材5/5。単独ファイルで起動。"""
from collections import deque

FPS = 30
CAR = 21
BUS = 66
START = 21
LANES = 2


def arrivals(a, b, buses=()):
    """秒で記した到着表を、(フレーム, 車線, 通過時間)へ変換する。"""
    return tuple(sorted(
        (int(t * FPS), lane, BUS if (lane, t) in buses else CAR)
        for lane, times in enumerate((a, b)) for t in times
    ))


# 原作B『列より先に人』と同じ12台。3秒のAはバス。
CARS = arrivals(range(6), [0, 3, 6, 9], ((0, 3),)) + arrivals([8, 9], [])
PEOPLE = ((114, 180, 60),)  # 到着3.8秒・許容待ち6秒・横断2秒


class Model:
    def __init__(self):
        self.t = 0
        self.status = 'play'
        self.events = deque(sorted(CARS))
        self.q = [deque(), deque()]
        self.green = [True, True]  # 指令。04以降は横断中の予約も表す。
        self.ready = [START, START]
        self.active = [None, None]
        self.passed = 0
        self.p_events = deque(PEOPLE)
        self.waiting = []
        self.walk = 0
        self.walk_total = 0
        self.walk_batch = 0
        self.walked = 0
        self.log = []
        self.overflow = [0, 0]
        self.car_wait = 0
        self.person_wait = 0
        self.max_person_wait = 0
        self.switches = 0
        self.reason = ''

    def set_signal(self, lane, green):
        if self.status != 'play' or self.green[lane] == green:
            return
        self.green[lane] = green
        self.switches += 1
        if green:
            self.ready[lane] = START
        self.log.append((self.t, 'signal', lane, green))

    def toggle(self, lane):
        self.set_signal(lane, not self.green[lane])

    def tick(self):
        if self.status != 'play':
            return
        while self.events and self.events[0][0] <= self.t:
            at, lane, duration = self.events.popleft()
            self.q[lane].append((at, duration))
        while self.p_events and self.p_events[0][0] <= self.t:
            self.waiting.append(self.p_events.popleft())
        if self.walk:
            self.walk -= 1
            if not self.walk:
                self.walked += self.walk_batch
                self.log.append((self.t, 'walk_end', self.walk_batch))
        # 赤になっても、すでに入った車は最後まで通過する。
        for lane in range(2):
            if self.active[lane]:
                remaining, duration = self.active[lane]
                remaining -= 1
                if remaining <= 0:
                    self.active[lane] = None
                    self.passed += 1
                else:
                    self.active[lane] = (remaining, duration)
        # 空にするのは待ち列qではなく、横断部分active。
        if self.waiting and not self.walk and not any(self.green) and not any(self.active):
            self.walk = self.walk_total = max(p[2] for p in self.waiting)
            self.walk_batch = len(self.waiting)
            self.max_person_wait = max(self.max_person_wait, max(self.t - p[0] for p in self.waiting))
            self.log.append((self.t, 'walk_start', self.walk_batch))
            self.waiting = []
        for lane in range(2):
            if self.green[lane] and not self.walk:
                self.ready[lane] = max(0, self.ready[lane] - 1)
                if not self.ready[lane] and not self.active[lane] and self.q[lane]:
                    at, duration = self.q[lane].popleft()
                    self.active[lane] = (duration, duration)
            # 7台以上が連続21フレーム。1回でも6台以下なら解除。
            if len(self.q[lane]) > 6:
                self.overflow[lane] += 1
            else:
                self.overflow[lane] = 0
            if self.overflow[lane] >= CAR:
                self.fail('QUEUE ' + 'AB'[lane] + ' OVERFLOW')
        self.car_wait += sum(len(q) for q in self.q)
        self.person_wait += len(self.waiting)
        if any(self.t - p[0] >= p[1] for p in self.waiting):
            self.fail('PEDESTRIAN WAIT LIMIT')
        assert not (self.walk and any(self.active)), 'unsafe crossing'
        self.t += 1
        if self.status == 'play' and not (self.events or any(self.q) or any(self.active) or self.p_events or self.waiting or self.walk):
            self.status = 'win'

    def fail(self, reason):
        self.status = 'fail'
        self.reason = reason

    def snapshot(self):
        return {'t': self.t, 'status': self.status, 'queue': [list(q) for q in self.q],
                'events': list(self.events), 'passed': self.passed, 'active': self.active.copy(),
                'green': self.green.copy(), 'ready': self.ready.copy(), 'p_events': list(self.p_events),
                'waiting': self.waiting.copy(), 'walk': self.walk, 'walk_total': self.walk_total,
                'walk_batch': self.walk_batch, 'walked': self.walked, 'log': self.log.copy(),
                'overflow': self.overflow.copy(), 'car_wait': self.car_wait,
                'person_wait': self.person_wait, 'max_person_wait': self.max_person_wait,
                'switches': self.switches, 'reason': self.reason}


class Controller:
    """合成入力でも実際のキーでも、同じ分岐を通す。"""
    def __init__(self):
        self.model = Model()
        self.paused = False
        self.selected = 0

    def tick(self, held=(), pressed=()):
        pressed = set(pressed)
        if 'R' in pressed:
            self.__init__()
            return
        if 'P' in pressed:
            self.paused = not self.paused
            return
        if self.paused or self.model.status != 'play':
            return
        if 'LEFT' in pressed or 'A' in pressed:
            self.selected = 0
        if 'RIGHT' in pressed or 'D' in pressed:
            self.selected = 1
        if 'SPACE' in pressed:
            self.model.toggle(self.selected)
        self.model.tick()


KEYS = ('P', 'R', 'LEFT', 'RIGHT', 'A', 'D', 'SPACE')


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 = 0
    events = []
    duration = max(12, len(replay or []))
    keys = {name: getattr(pyxel, 'KEY_' + name) for name in KEYS}

    def update():
        nonlocal frames
        frames += 1
        if headless:
            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 vehicle(x, y, duration, color=12):
        width = 22 if duration == BUS else 16
        pyxel.rect(x - width, y - 6, width, 12, color)
        pyxel.rectb(x - width, y - 6, width, 12, 7)
        pyxel.rect(x - width + 3, y - 4, 5, 4, 1)
        pyxel.rect(x - width + 2, y + 6, 4, 2, 0)
        pyxel.rect(x - 6, y + 6, 4, 2, 0)

    def draw():
        m = control.model
        pyxel.cls(1)
        pyxel.text(12, 9, 'SLEEPY SIGNAL / 5: SCENARIO B', 10)
        pyxel.text(12, 23, f'TIME {m.t / FPS:5.2f}s   PASSED {m.passed}/{len(CARS)}   {m.status.upper()}', 7)
        pyxel.rect(8, 39, 444, 42, 0)
        for lane in range(LANES):
            upcoming = [e for e in m.events if e[1] == lane][:3]
            items = '  '.join(f'{max(0, at - m.t) / FPS:.1f}s ' + ('BUS' if dur == BUS else 'CAR')
                              for at, _, dur in upcoming)
            pyxel.text(16, 47 + lane * 14, 'NEXT ' + 'AB'[lane] + ': ' + (items or 'NONE'), 6)
        for lane in range(LANES):
            y = 118 + lane * 64
            pyxel.rect(8, y - 20, 444, 43, 5)
            pyxel.line(290, y - 20, 290, y + 22, 7)
            for x in range(304, 340, 8):
                pyxel.rect(x, y - 17, 4, 34, 13)
            pyxel.text(13, y - 16, 'AB'[lane], 7)
            if control.selected == lane:
                pyxel.rectb(8, y - 20, 286, 43, 10)
            effective_green = m.green[lane] and not m.walk
            state = 'RESERVED' if m.green[lane] and m.walk else ('GREEN' if effective_green else 'RED')
            pyxel.circ(30, y + 3, 8, 11 if effective_green else 8)
            pyxel.text(44, y, state, 7)
            pyxel.text(13, y + 26, f'QUEUE {len(m.q[lane])}  START {m.ready[lane]:02} FRAMES', 6)
            for j, (at, dur) in enumerate(list(m.q[lane])[:6]):
                x = 278 - j * 27
                vehicle(x, y, dur, 9 if dur == BUS else 12)
                pyxel.text(x - 15, y - 17, str(at // FPS), 7)
            if len(m.q[lane]) > 6:
                pyxel.text(101, y + 11, '+' + str(len(m.q[lane]) - 6) + ' MORE', 8)
            if m.active[lane]:
                remaining, dur = m.active[lane]
                x = 312 + (dur - remaining) / dur * 112
                vehicle(x, y, dur, 9 if dur == BUS else 12)
                pyxel.text(346, y - 17, f'BUSY {remaining:02}', 10)
            else:
                pyxel.text(347, y - 17, 'CLEAR', 11)
        if m.walk:
            pyxel.text(12, 225, f'WALK {m.walk / FPS:.2f}s  BATCH {m.walk_batch}  CARS MUST WAIT', 10)
            yy = 99 + (1 - m.walk / m.walk_total) * 108
            pyxel.circ(324, yy - 4, 3, 10)
            pyxel.line(324, yy, 324, yy + 7, 10)
            pyxel.line(324, yy + 7, 320, yy + 12, 10)
            pyxel.line(324, yy + 7, 328, yy + 12, 10)
        elif m.waiting:
            left = min(p[1] - (m.t - p[0]) for p in m.waiting) / FPS
            pyxel.text(12, 225, f'WAITING {len(m.waiting)} / PATIENCE LEFT {max(0, left):.2f}s', 10)
        else:
            next_person = max(0, m.p_events[0][0] - m.t) / FPS if m.p_events else None
            pyxel.text(12, 225, f'WALKED {m.walked}/{len(PEOPLE)} / NEXT PERSON: ' +
                       (f'{next_person:.1f}s' if next_person is not None else 'NONE'), 6)
        pyxel.text(12, 239, f'WAIT SUM CAR {m.car_wait / FPS:.1f}s / PERSON {m.person_wait / FPS:.1f}s', 6)
        pyxel.text(12, 252, 'FAIL: ' + m.reason if m.status == 'fail' else
                   f'OVERFLOW A {m.overflow[0]}/21  B {m.overflow[1]}/21 / SWITCHES {m.switches}', 8 if m.status == 'fail' else 6)
        pyxel.text(12, 269, 'LEFT/RIGHT or A/D: LANE   SPACE: TOGGLE', 7)
        pyxel.text(12, 283, 'P: PAUSE   R: RESET   ESC: QUIT   SILENT', 6)
        if control.paused:
            pyxel.rect(139, 157, 180, 26, 0)
            pyxel.text(164, 166, '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({
                    'scope': 'Synthetic controller inputs; actual Pyxel headless renderer; silent',
                    'render_frames': frames, '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(460, 302, title='Sleepy Signal - 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')
    modes.add_argument('--headless-replay', help='UTF-8 JSON held/pressed input list')
    parser.add_argument('--screenshot', help='Output PNG in headless mode')
    parser.add_argument('--report', help='Output UTF-8 JSON in headless mode')
    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'))
        valid = isinstance(replay, list) and all(
            isinstance(event, dict) and set(event) <= {'held', 'pressed'} and all(
                isinstance(event.get(k, []), list) and all(isinstance(v, str) and v in KEYS
                    for v in event.get(k, [])) for k in ('held', 'pressed'))
            for event in replay)
        if not valid:
            parser.error('Use a list of held/pressed dictionaries with supported key names')
    main(headless, replay, args.screenshot, args.report)
AFTER STEP 05

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

元のゲームは、1秒に30回更新する、並んだ2車線の道路です。先に来た車から順に通し、小さな車は21フレーム・バスは66フレームで通り抜け、青に切り替えてから21フレームの発進の準備がいります。両方が赤で、通っている途中の車がいないときに、人が渡ります。青の予約をしても、人が渡っている間は発進せず、準備の時計も止まります。6台を超える列が21回の更新続くと詰まりで失敗、人は待てる限界以上待つと失敗です。決まった数の車と人を全員通せば WIN です。事故・一律の時間切れ・まとめた点数はありません。

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

比べる点5ステップの教材元の完成版
状況と段階01と02は、元のゲームのBのA車線の8台だけ。03は元のゲームのBの12台すべてで、人はなし。04と05は、元のゲームのB「列より先に人」と同じ12台と、3.8秒に来る1組の人を使う。A・B・Cの、手作りの3つの状況から選べる。
最初の段階の簡略化01はSpaceですぐに通り抜ける、02は準備なしで自動発進。03で2車線と元のゲームの21フレームの準備を加える。01〜04は、列の上限や待てる限界による失敗と、待ち時間の合計を省いている。最初から、通り抜ける時間・信号・準備・歩行者・失敗の条件・待ち時間の合計がある。
最後の段階のルール05は、元のゲームのBの到着・先入れ先出し・横断帯のふさがり・準備・横断・予約・猶予・待てる限界・全員通過・それぞれの待ち時間の合計・切り替えの回数をそのまま使う。失敗の理由の文は英語にしている。同じ更新の順番の Model.tick と set_signal を使い、日本語のお知らせや理由も出す。
画面と操作英字(ASCII)と図形。始めてすぐ開始し、各車線の次の3台を秒で予告する。01と02は1車線専用の操作。03からは選択と切り替えがある。Escで終了。日本語の文字、6秒分のタイムライン、タイトルと次の状況、選択の枠、渡る残り時間、失敗したときの直前の記録。
省いた画面と記録AとCの選択、タイトル、Q・N・Enter、F12、GIF、失敗直前6秒の記録、日本語のお知らせ、まとめた表示での複数の人の描画は省いている。PとRはある。画面を表示しない確認用のJSONの入力と、PNG・JSONの保存を教材用に加えている。3つの状況の切り替え、くわしい結果と記録、Noto由来の文字データと自動映像用の機能がある。
実際に確かめた範囲純粋なモデル・自動の Controller の入力・実際の Pyxel で画面を表示せずに描いた静止画を確かめた。人の操作、OSのキーボード、ブラウザー、Windows/macOSの画面、音の出力は確かめていない。元のゲームの19のテストや、新しい自動デモの確認は、教材の新しいテストの数には含めない。元のゲームも音なし。

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

  1. 元のゲームの Model.tick で、「到着→渡っている人の残り→車の残り→新しい横断→発進→失敗→終わり」の順番を比べましょう。
  2. 元のゲームの Model.set_signal で、人が渡っている間のお知らせ(notice)と ready の関係を読みましょう。
  3. 元のゲームの Model.snapshot と history を使って、失敗する直前を振り返る表示を加えてみましょう。
  4. 元のゲームの SITUATIONS の A と C を読み、待っている人を一緒に渡らせる選択へ広げてみましょう。
  5. 元のゲームの app.py の preview の表示、change、タイトルでの選択と N・Enter を読みましょう。
自分のゲームに変えるなら

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

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