音楽・リズムLESSON 63隣席の位相で八ステップを組み替える演奏パズル原作056

即席オーケストラの作り方

席を入れ替えると、音が鳴るタイミングがずれる。
となりの席の奏者による「ずれ(位相差)」を元の譜面に一度だけ加える方法と、席の変更を次の小節の頭まで予約しておく仕組みを、音の出ない画面で学びます。

完成版の遊び方5つの席の並び方で音の鳴る時刻が変わる8ステップの譜面を、空席も使って組み替えます。

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

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

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

完成版の主な操作:左右席/上下奏者、Z配置、Space試奏、Enter本番、X戻す、P停止、M消音

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

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

うまく表示されないとき

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

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

譜面のデータと、再生中の時計を分けて扱う。

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

1. 辞書・タプル・剰余(割った余り)と、状態を読むだけの描画
2. となりだけを見るルール・ずれを一度に合計する・席の交換
3. 列ごとに数える・any/all・条件を小さく分ける
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
Python 3.11以上とPyxel 2.9.9 / 変数、if、for、関数、辞書、タプル / 剰余(割った余り)・書き換えない dataclass・any/all・履歴は本文で説明します / 楽譜の知識、元のゲームのファイル、スピーカーなどの音声機器は不要です
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
左右:02以降の選択席 / 上下:02以降の候補奏者 / Z:02以降で配置、既出なら交換 / Space:01は列を一つ進める。04/05は試奏/停止 / P:04/05で停止と小節頭から再同期 / Enter:05で四小節の本番 / X:05で配置だけUndo / R:全初期化 / Esc:終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_score.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

八つの時刻に音高を結び付ける

辞書・タプル・剰余(割った余り)と、状態を読むだけの描画。

最初は、元のゲームの状況 A の元の譜面を読むだけです。K は太鼓、B は鈴、F は笛、U は低音です。SCORE は奏者の文字をキー(見出し)にした辞書で、値は (time, pitch)(時刻と音の高さ)の組を並べたタプルです。たとえば笛の (0, "c4") は時刻0に鳴る C4(ド)、(4, "g4") は時刻4に鳴る G4(ソ)です。音の高さは文字列のまま持っておき、この教材では音として鳴らしません。楽譜が読めなくても、横に並ぶ時刻と、縦に並ぶ奏者を追えば進められます。

Space を押すと、読む位置 cursor を1つ進めます。%8(8で割った余り)を使うので、7の次は0に戻ります。これは手で動かす読む位置で、この段階では、時間がたっても、Pyxel のフレームが進んでも、音符は進みません。位置0の列には太鼓と笛、1には低音、2には鈴と笛があります。何も鳴らない休みの列も、8つの時刻の一部です。

dataclass(frozen=True) は、今の状態をまとめた「中身を書き換えられない箱」です。replace は元の箱を変えずに、新しい cursor を入れた別の箱を返します。draw はその箱を読んで描くだけなので、何度描いても cursor は変わりません。画面の薄い枠が元の譜面、色のついた四角がこの段階で読む音符、下の文字が音の高さです。

最初の段階には、となりの席からの合図、席の入れ替え、自動で進む時計、依頼(クリア条件)の判定はまだありません。画面の席は完成版と同じ並びですが、ずれ SHIFT は全員0です。次の段階では、元の譜面からずらした譜面を作り、この手動の読む位置はいったん外します。

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

01_score.py / 注目する部分
            self.state = replace(self.state, cursor=(self.state.cursor + 1) % STEPS)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_score.py
macOS / Linux
.venv/bin/python 01_score.py
画面で見ることSpace を3回押すと列3へ、8回押すと列0に戻ります。1回ごとにキーを離してください。音が出ないのは、この教材の仕様です。
  • 音符は「時刻」と「音の高さ」の組
  • 割った余りで、0〜7をくり返す
  • draw は状態を変えない
つまずきやすいところ時刻と音の高さを別々のリストに分けると、並べ替えたあとに、どの時刻がどの高さだったかがずれてしまいます。(time, pitch) の組をひとまとまりとして扱います。cursor に %8 を付け忘れると、8列の外へ進んでしまいます。
少し変えてみよう

R で最初に戻してから、Space を10回押してみましょう。答えは列2(10を8で割った余り)で、元の譜面の鈴 G4 と笛 E4 が同じ時刻に並びます。test_01 と test_35 で、この余りの答えを確かめています。

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

"""即席オーケストラ・教材1/5。原作Aを使う無音の視覚教材。"""
from dataclasses import dataclass, replace

MUSICIANS = "KBFU"
INITIAL_SEATS = ("K", "F", ".", "B", "U")
# Original A score. Keep each time and its pitch in the same pair.
SCORE = {
    "K": ((0, "c1"), (4, "c1")),
    "B": ((2, "g4"), (6, "a4")),
    "F": ((0, "c4"), (2, "e4"), (4, "g4"), (6, "e4")),
    "U": ((1, "c3"), (5, "g2")),
}
STEPS = 8

@dataclass(frozen=True)
class State:
    seats: tuple = INITIAL_SEATS
    cursor: int = 0


def initial():
    return State()


class App:
    def __init__(self):
        self.reset()

    def reset(self):
        self.state = initial()
        self.seat = 1
        self.person = 0
        self.gated = False
        self.message = "READY"


    def control(self, pressed, held):
        if "R" in pressed:
            self.reset()
            self.gated = True
            return
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.message = "RELEASE ALL KEYS"
            self.gated = True
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        if key == "SPACE":
            self.state = replace(self.state, cursor=(self.state.cursor + 1) % STEPS)
            self.message = "MANUAL STEP"
        self.gated = True

    def advance(self):
        pass  # No model clock in this stage.

    def draw(self, p):
        p.cls(1)
        p.text(10, 10, "POCKET ORCHESTRA / STAGE 1", 10)
        p.text(364, 10, "SILENT VISUAL LESSON", 6)
        seats = self.state.seats
        future = seats
        p.text(10, 28, "K DRUM   B BELL   F FLUTE   U BASS   . EMPTY", 7)
        for i, musician in enumerate(future):
            x = 10 + i * 108
            p.rect(x, 45, 100, 43, 2)
            p.rectb(x, 45, 100, 43, 10 if i == self.seat else 5)
            p.text(x + 8, 53, str(i + 1) + ": " + musician, 7)
            shift = 0
            p.text(x + 8, 70, "SHIFT " + format(shift, "+d") if musician != "." else "EMPTY", 6)
        p.text(10, 97, "CURRENT " + "".join(seats) + "    CANDIDATE " + (MUSICIANS + ".")[self.person], 7)
        p.text(10, 111, "BASE SCORE / NO PHASE SHIFT", 9)
        score = SCORE
        active = self.state.cursor
        p.text(10, 131, "ORIGINAL: OUTLINE   ARRANGED: FILLED / TIME+PITCH", 6)
        for beat in range(STEPS):
            x = 139 + beat * 51
            p.text(x, 150, str(beat), 10)
            if beat == active:
                p.rect(x - 8, 162, 42, 120, 3)
        for row, musician in enumerate(MUSICIANS):
            y = 175 + row * 29
            p.text(10, y, musician, 7)
            p.text(33, y, "BASE", 6)
            for beat in range(STEPS):
                p.pset(141 + beat * 51, y + 3, 5)
            for time, pitch in SCORE[musician]:
                p.rectb(137 + time * 51, y - 2, 8, 8, 5)
            for time, pitch in score.get(musician, ()):
                p.rect(138 + time * 51, y - 1, 6, 6, COLORS[musician])
                p.text(131 + time * 51, y + 10, pitch.upper(), 7)
        p.text(10, 294, "EACH CELL IS (TIME, PITCH). 8 STEPS REPEAT.", 6)
        p.text(10, 326, "STATIC SCORE / DRAWING NEVER ADVANCES TIME", 7)
        p.text(10, 346, self.message.upper(), 10)
        p.text(10, 366, "SPACE NEXT STEP   R RESET", 7)
        p.text(10, 382, "ESC QUIT / NO AUDIO", 6)


COLORS = {"K": 9, "B": 10, "F": 11, "U": 12}
KEYS = ('SPACE', 'R')
DEMO_FRAMES = 10


def demo(app, frame):
    app.control({"SPACE"}, {"SPACE"}) if frame in (1, 3, 5) else app.control(set(), set())


def main(args):
    # CLI validation is complete before this optional graphics import.
    from pathlib import Path
    import pyxel as p
    app = App()
    shot = Path(args.screenshot).resolve() if args.screenshot else None
    if shot:
        shot.parent.mkdir(parents=True, exist_ok=True)
    limit = args.qa_frames or (DEMO_FRAMES if args.demo_checkpoint else 12)
    p.init(560, 400, title="056 Pocket Orchestra", fps=30,
           display_scale=1, headless=args.headless_smoke)
    keys = {key: getattr(p, "KEY_" + key) for key in KEYS}
    frame = 0
    def update():
        nonlocal frame
        frame += 1
        if args.headless_smoke and frame >= limit:
            if shot:
                p.screenshot(str(shot), scale=1)
            p.quit()
            return
        if args.headless_smoke and args.demo_checkpoint:
            demo(app, frame)
        elif not args.headless_smoke:
            app.control({k for k, v in keys.items() if p.btnp(v)},
                        {k for k, v in keys.items() if p.btn(v)})
        app.advance()
    p.run(update, lambda: app.draw(p))


def parse_args(argv=None):
    import argparse
    parser = argparse.ArgumentParser(description="056 silent visual tutorial; Python 3.11+")
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--screenshot")
    parser.add_argument("--qa-frames", type=int)
    args = parser.parse_args(argv)
    import sys
    if sys.version_info < (3, 11):
        parser.error("Python 3.11 or newer is required")
    if (args.demo_checkpoint or args.screenshot or args.qa_frames is not None) and not args.headless_smoke:
        parser.error("QA options require --headless-smoke")
    if args.qa_frames is not None and not 2 <= args.qa_frames <= 600:
        parser.error("--qa-frames must be in 2..600")
    if args.demo_checkpoint and args.qa_frames is not None and args.qa_frames < DEMO_FRAMES:
        parser.error("--qa-frames is too short for the demo checkpoint")
    if args.screenshot and not args.screenshot.lower().endswith(".png"):
        parser.error("--screenshot must end in .png")
    return args


if __name__ == "__main__":
    main(parse_args())
STEP 02 / 05

隣席の合図を元譜へ一度だけ加える

となりだけを見るルール・ずれを一度に合計する・席の交換。

左右キーで席を、上下キーで置く奏者を選び、Z で配置します。候補は K→B→F→U→空席 の順に切り替わります。すでに別の席にいる奏者を置くと、その奏者の席と、選んだ席を入れ替えます。控えにいる奏者を置く場合は、選んだ席の奏者と置き換え、空席を置くと、その席の奏者を外します。同じ奏者が2人にならないよう、placement は先に、その奏者が今どの席にいるかを探します。

phases は、席を左から順に見て、すぐ左が太鼓 K なら +1、すぐ右が鈴 B なら -1 を足し合わせます。+1 は1つ遅い時刻、-1 は1つ早い時刻にずれるという意味です。K・F・B と並んだ笛は、+1-1=0 でずれません。空席をはさむと、合図は届きません。端の席では、存在しない「となり」を読まないように条件が必要です。合図を出す K と B も、合図を受ける側になります。K・B と並ぶと、K は(右が鈴なので)-1、B は(左が太鼓なので)+1 です。

全員のずれを、今の席の並びだけから求めたあと、arranged が元の譜面 SCORE から新しい譜面を作ります。式は (time + shifts[musician]) %8 です。ずらした結果を、次に描くときにさらにずらしてはいけません。また、「太鼓の音符が動いたから、その右の奏者も続けて動く」という連鎖のルールでもありません。席の並びが同じなら、何度計算しても同じ譜面になります。

sorted は、時刻と音の高さの組をまとめて並べ替えます。笛を -1 ずらすと、元の時刻0の C4 は時刻7に(0−1 を8で割った余り)、元の時刻2の E4 は時刻1に移ります。時刻だけを先に並べ替えて、音の高さを元の順番のまま残すと、メロディーの組み合わせが変わってしまいます。この段階は止まった譜面を比べるだけで、Space での再生や依頼の判定はまだありません。

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

02_phases.py / 注目する部分
        left_drum = 1 if i > 0 and seats[i - 1] == "K" else 0
        right_bell = 1 if i < 4 and seats[i + 1] == "B" else 0
        shifts[musician] = left_drum - right_bell

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_phases.py
macOS / Linux
.venv/bin/python 02_phases.py
画面で見ること起動したときの並びは K・F・空席・B・U で、笛の時刻は 1/3/5/7 です。右→下→Z で3番目の席に B を移すと K・F・B・空席・U になり、笛は 0/2/4/6 に戻ります。キーは1つずつ離してから次を押してください。
  • 両側からの合図は打ち消し合う
  • 空席があると合図は届かない
  • 元の譜面に一度だけ、音の高さごとずらす
つまずきやすいところ合図を順番に上書きするのではなく、左からの +1 と右からの -1 を先に合計します。arranged には前回の arranged の結果を渡さず、毎回元の SCORE を使います。
少し変えてみよう

INITIAL_SEATS を ("F", "B", "K", "U", ".") に変えて起動し直してみましょう。笛は -1、低音は +1 ずれます。笛の答えは (1,E4),(3,G4),(5,E4),(7,C4) です。確認したら元の並びに戻します。test_02/03/35 で、この結果を確かめています。

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

"""即席オーケストラ・教材2/5。原作Aを使う無音の視覚教材。"""
from dataclasses import dataclass, replace

MUSICIANS = "KBFU"
INITIAL_SEATS = ("K", "F", ".", "B", "U")
# Original A score. Keep each time and its pitch in the same pair.
SCORE = {
    "K": ((0, "c1"), (4, "c1")),
    "B": ((2, "g4"), (6, "a4")),
    "F": ((0, "c4"), (2, "e4"), (4, "g4"), (6, "e4")),
    "U": ((1, "c3"), (5, "g2")),
}
STEPS = 8

def phases(seats):
    shifts = {}
    for i, musician in enumerate(seats):
        if musician == ".":
            continue
        left_drum = 1 if i > 0 and seats[i - 1] == "K" else 0
        right_bell = 1 if i < 4 and seats[i + 1] == "B" else 0
        shifts[musician] = left_drum - right_bell
    return shifts


def arranged(seats):
    shifts = phases(seats)
    return {
        musician: tuple(sorted(((time + shifts[musician]) % STEPS, pitch)
                                for time, pitch in notes))
        for musician, notes in SCORE.items() if musician in shifts
    }


def placement(seats, seat, musician):
    if type(seat) is not int or not 0 <= seat < 5:
        return seats
    if type(musician) is not str or musician not in tuple(MUSICIANS + "."):
        return seats
    result = list(seats)
    if musician != "." and musician in result:
        previous = result.index(musician)
        result[previous], result[seat] = result[seat], musician
    else:
        result[seat] = musician
    return tuple(result)


@dataclass(frozen=True)
class State:
    seats: tuple = INITIAL_SEATS


def initial():
    return State()


class App:
    def __init__(self):
        self.reset()

    def reset(self):
        self.state = initial()
        self.seat = 1
        self.person = 0
        self.gated = False
        self.message = "READY"


    def control(self, pressed, held):
        if "R" in pressed:
            self.reset()
            self.gated = True
            return
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.message = "RELEASE ALL KEYS"
            self.gated = True
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        if key == "LEFT":
            self.seat = (self.seat - 1) % 5
        elif key == "RIGHT":
            self.seat = (self.seat + 1) % 5
        elif key == "UP":
            self.person = (self.person - 1) % 5
        elif key == "DOWN":
            self.person = (self.person + 1) % 5
        elif key == "Z":
            new = placement(self.state.seats, self.seat, (MUSICIANS + ".")[self.person])
            self.message = "SAME" if new == self.state.seats else "PLACED"
            self.state = replace(self.state, seats=new)
        self.gated = True

    def advance(self):
        pass  # No model clock in this stage.

    def draw(self, p):
        p.cls(1)
        p.text(10, 10, "POCKET ORCHESTRA / STAGE 2", 10)
        p.text(364, 10, "SILENT VISUAL LESSON", 6)
        seats = self.state.seats
        future = seats
        p.text(10, 28, "K DRUM   B BELL   F FLUTE   U BASS   . EMPTY", 7)
        for i, musician in enumerate(future):
            x = 10 + i * 108
            p.rect(x, 45, 100, 43, 2)
            p.rectb(x, 45, 100, 43, 10 if i == self.seat else 5)
            p.text(x + 8, 53, str(i + 1) + ": " + musician, 7)
            shift = phases(future).get(musician, 0)
            p.text(x + 8, 70, "SHIFT " + format(shift, "+d") if musician != "." else "EMPTY", 6)
        p.text(10, 97, "CURRENT " + "".join(seats) + "    CANDIDATE " + (MUSICIANS + ".")[self.person], 7)
        p.text(10, 111, "K -> RIGHT +1    B -> LEFT -1    EMPTY BLOCKS", 9)
        score = arranged(seats)
        active = -1
        p.text(10, 131, "ORIGINAL: OUTLINE   ARRANGED: FILLED / TIME+PITCH", 6)
        for beat in range(STEPS):
            x = 139 + beat * 51
            p.text(x, 150, str(beat), 10)
            if beat == active:
                p.rect(x - 8, 162, 42, 120, 3)
        for row, musician in enumerate(MUSICIANS):
            y = 175 + row * 29
            p.text(10, y, musician, 7)
            p.text(33, y, format(phases(seats).get(musician, 0), "+d") if musician in score else "OFF", 6)
            for beat in range(STEPS):
                p.pset(141 + beat * 51, y + 3, 5)
            for time, pitch in SCORE[musician]:
                p.rectb(137 + time * 51, y - 2, 8, 8, 5)
            for time, pitch in score.get(musician, ()):
                p.rect(138 + time * 51, y - 1, 6, 6, COLORS[musician])
                p.text(131 + time * 51, y + 10, pitch.upper(), 7)
        p.text(10, 294, "EACH CELL IS (TIME, PITCH). 8 STEPS REPEAT.", 6)
        p.text(10, 326, "STATIC SCORE / DRAWING NEVER ADVANCES TIME", 7)
        p.text(10, 346, self.message.upper(), 10)
        p.text(10, 366, "ARROWS SEAT/PERSON   Z PLACE   R RESET", 7)
        p.text(10, 382, "ESC QUIT / NO AUDIO", 6)


COLORS = {"K": 9, "B": 10, "F": 11, "U": 12}
KEYS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'Z', 'R')
DEMO_FRAMES = 16


def demo(app, frame):
    key = {1: 'RIGHT', 3: 'DOWN', 5: 'Z'}.get(frame)
    app.control({key} if key else set(), {key} if key else set())


def main(args):
    # CLI validation is complete before this optional graphics import.
    from pathlib import Path
    import pyxel as p
    app = App()
    shot = Path(args.screenshot).resolve() if args.screenshot else None
    if shot:
        shot.parent.mkdir(parents=True, exist_ok=True)
    limit = args.qa_frames or (DEMO_FRAMES if args.demo_checkpoint else 12)
    p.init(560, 400, title="056 Pocket Orchestra", fps=30,
           display_scale=1, headless=args.headless_smoke)
    keys = {key: getattr(p, "KEY_" + key) for key in KEYS}
    frame = 0
    def update():
        nonlocal frame
        frame += 1
        if args.headless_smoke and frame >= limit:
            if shot:
                p.screenshot(str(shot), scale=1)
            p.quit()
            return
        if args.headless_smoke and args.demo_checkpoint:
            demo(app, frame)
        elif not args.headless_smoke:
            app.control({k for k, v in keys.items() if p.btnp(v)},
                        {k for k, v in keys.items() if p.btn(v)})
        app.advance()
    p.run(update, lambda: app.draw(p))


def parse_args(argv=None):
    import argparse
    parser = argparse.ArgumentParser(description="056 silent visual tutorial; Python 3.11+")
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--screenshot")
    parser.add_argument("--qa-frames", type=int)
    args = parser.parse_args(argv)
    import sys
    if sys.version_info < (3, 11):
        parser.error("Python 3.11 or newer is required")
    if (args.demo_checkpoint or args.screenshot or args.qa_frames is not None) and not args.headless_smoke:
        parser.error("QA options require --headless-smoke")
    if args.qa_frames is not None and not 2 <= args.qa_frames <= 600:
        parser.error("--qa-frames must be in 2..600")
    if args.demo_checkpoint and args.qa_frames is not None and args.qa_frames < DEMO_FRAMES:
        parser.error("--qa-frames is too short for the demo checkpoint")
    if args.screenshot and not args.screenshot.lower().endswith(".png"):
        parser.error("--screenshot must end in .png")
    return args


if __name__ == "__main__":
    main(parse_args())
STEP 03 / 05

同時発音と休符で依頼を判定する

列ごとに数える・any/all・条件を小さく分ける。

配置から派生譜を作れるようになったら、各時刻に誰が発音するかを集計します。simultaneousは八列を走査し、各奏者の譜面にその時刻の音符が一つでもあるかをanyで調べます。結果は、時刻0の奏者の組、時刻1の奏者の組、という八要素のタプルです。空の組()は全員休符。MUSICIANSの順で集めるので表示や検査の順序が安定します。

元のゲームの状況 A の依頼(クリア条件)は4つです。①3人以上が席にいる、②時刻0と4の両方で誰かの音が鳴る、③時刻7は全員が休む、④同時に鳴るのは2人まで。0と4で鳴る奏者は同じでなくてもかまいません。状況 B の「笛が0と4で鳴る」と混同しないでください。checks に条件ごとの True/False を入れ、最後に all(全部 True なら True)でまとめます。

rest は何も鳴らない列の数、peak は1つの列で同時に鳴る最大の人数です。SCORE の音符の総数を数えても、同時に鳴る人数にはなりません。また「3人以上」は席にいる奏者の数のことで、同じ瞬間に3人鳴らすことではありません。席にいる人数と、同時に鳴る人数を分けて表示します。

K・F・B・空席・U なら、休みの列は3と7、同時に鳴るのは最大2人で、すべての条件を満たします。起動したときの並びも最大2人ですが、笛が時刻7で鳴るので条件を満たしません。ここでの PASS True は、譜面を見ただけの予測です。本番を最後まで演奏した結果としての WON は、第5段階で加えます。描くたびに予測しても、席・時計・履歴は変えません。

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

03_ensemble.py / 注目する部分
    checks = (("three", players >= 3),
              ("zero_four", bool(at[0] and at[4])),
              ("seven_rest", not at[7]),
              ("no_three", peak < 3))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_ensemble.py
macOS / Linux
.venv/bin/python 03_ensemble.py
画面で見ること右→下→Z で TARGET RESTS 2、PEAK 2、PASS True になります。列0/4 は K と F、2/6 は B と F、1/5 は U、3/7 は休みです。
  • 席にいる人数と、同時に鳴る人数を区別する
  • 休みは、誰も鳴らない列
  • 4つの条件を all でまとめる
つまずきやすいところ「3人以上」を peak>=3 と書くと、元の状況 A の条件と逆になってしまいます。players>=3(席に3人以上)と peak<3(同時は2人まで)を別々に確かめます。
少し変えてみよう

INITIAL_SEATS を ("K", "F", "B", ".", ".") に変えてみましょう。答えは、3人・休み4列・最大2人で PASS True です。U がいないので、1と5も休みになります。確認したら元に戻します。test_08/35 で確かめています。

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

"""即席オーケストラ・教材3/5。原作Aを使う無音の視覚教材。"""
from dataclasses import dataclass, replace

MUSICIANS = "KBFU"
INITIAL_SEATS = ("K", "F", ".", "B", "U")
# Original A score. Keep each time and its pitch in the same pair.
SCORE = {
    "K": ((0, "c1"), (4, "c1")),
    "B": ((2, "g4"), (6, "a4")),
    "F": ((0, "c4"), (2, "e4"), (4, "g4"), (6, "e4")),
    "U": ((1, "c3"), (5, "g2")),
}
STEPS = 8

def phases(seats):
    shifts = {}
    for i, musician in enumerate(seats):
        if musician == ".":
            continue
        left_drum = 1 if i > 0 and seats[i - 1] == "K" else 0
        right_bell = 1 if i < 4 and seats[i + 1] == "B" else 0
        shifts[musician] = left_drum - right_bell
    return shifts


def arranged(seats):
    shifts = phases(seats)
    return {
        musician: tuple(sorted(((time + shifts[musician]) % STEPS, pitch)
                                for time, pitch in notes))
        for musician, notes in SCORE.items() if musician in shifts
    }


def placement(seats, seat, musician):
    if type(seat) is not int or not 0 <= seat < 5:
        return seats
    if type(musician) is not str or musician not in tuple(MUSICIANS + "."):
        return seats
    result = list(seats)
    if musician != "." and musician in result:
        previous = result.index(musician)
        result[previous], result[seat] = result[seat], musician
    else:
        result[seat] = musician
    return tuple(result)


def simultaneous(seats):
    score = arranged(seats)
    return tuple(tuple(m for m in MUSICIANS if m in score
                       and any(time == beat for time, pitch in score[m]))
                 for beat in range(STEPS))


def evaluation(seats):
    at = simultaneous(seats)
    players = len(arranged(seats))
    rests = sum(not group for group in at)
    peak = max(map(len, at))
    checks = (("three", players >= 3),
              ("zero_four", bool(at[0] and at[4])),
              ("seven_rest", not at[7]),
              ("no_three", peak < 3))
    return {"checks": list(checks), "passed": all(ok for _, ok in checks),
            "rest": rests, "peak": peak, "players": players,
            "steps": at, "shifts": phases(seats)}


@dataclass(frozen=True)
class State:
    seats: tuple = INITIAL_SEATS


def initial():
    return State()


class App:
    def __init__(self):
        self.reset()

    def reset(self):
        self.state = initial()
        self.seat = 1
        self.person = 0
        self.gated = False
        self.message = "READY"


    def control(self, pressed, held):
        if "R" in pressed:
            self.reset()
            self.gated = True
            return
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.message = "RELEASE ALL KEYS"
            self.gated = True
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        if key == "LEFT":
            self.seat = (self.seat - 1) % 5
        elif key == "RIGHT":
            self.seat = (self.seat + 1) % 5
        elif key == "UP":
            self.person = (self.person - 1) % 5
        elif key == "DOWN":
            self.person = (self.person + 1) % 5
        elif key == "Z":
            new = placement(self.state.seats, self.seat, (MUSICIANS + ".")[self.person])
            self.message = "SAME" if new == self.state.seats else "PLACED"
            self.state = replace(self.state, seats=new)
        self.gated = True

    def advance(self):
        pass  # No model clock in this stage.

    def draw(self, p):
        p.cls(1)
        p.text(10, 10, "POCKET ORCHESTRA / STAGE 3", 10)
        p.text(364, 10, "SILENT VISUAL LESSON", 6)
        seats = self.state.seats
        future = seats
        p.text(10, 28, "K DRUM   B BELL   F FLUTE   U BASS   . EMPTY", 7)
        for i, musician in enumerate(future):
            x = 10 + i * 108
            p.rect(x, 45, 100, 43, 2)
            p.rectb(x, 45, 100, 43, 10 if i == self.seat else 5)
            p.text(x + 8, 53, str(i + 1) + ": " + musician, 7)
            shift = phases(future).get(musician, 0)
            p.text(x + 8, 70, "SHIFT " + format(shift, "+d") if musician != "." else "EMPTY", 6)
        p.text(10, 97, "CURRENT " + "".join(seats) + "    CANDIDATE " + (MUSICIANS + ".")[self.person], 7)
        p.text(10, 111, "K -> RIGHT +1    B -> LEFT -1    EMPTY BLOCKS", 9)
        score = arranged(seats)
        active = -1
        p.text(10, 131, "ORIGINAL: OUTLINE   ARRANGED: FILLED / TIME+PITCH", 6)
        for beat in range(STEPS):
            x = 139 + beat * 51
            p.text(x, 150, str(beat), 10)
            if beat == active:
                p.rect(x - 8, 162, 42, 120, 3)
        for row, musician in enumerate(MUSICIANS):
            y = 175 + row * 29
            p.text(10, y, musician, 7)
            p.text(33, y, format(phases(seats).get(musician, 0), "+d") if musician in score else "OFF", 6)
            for beat in range(STEPS):
                p.pset(141 + beat * 51, y + 3, 5)
            for time, pitch in SCORE[musician]:
                p.rectb(137 + time * 51, y - 2, 8, 8, 5)
            for time, pitch in score.get(musician, ()):
                p.rect(138 + time * 51, y - 1, 6, 6, COLORS[musician])
                p.text(131 + time * 51, y + 10, pitch.upper(), 7)
        result = evaluation(future)
        names = ("3+ PLAYERS", "SOUND 0/4", "REST AT 7", "PEAK <= 2")
        for i, ((name, ok), label) in enumerate(zip(result["checks"], names)):
            p.text(10 + i * 135, 291, ("OK " if ok else "-- ") + label, 11 if ok else 13)
        p.text(10, 307, "TARGET RESTS " + str(result["rest"]) + "  PEAK " + str(result["peak"])
               + "  PASS " + str(result["passed"]), 6)
        p.text(10, 326, "STATIC SCORE / DRAWING NEVER ADVANCES TIME", 7)
        p.text(10, 346, self.message.upper(), 10)
        p.text(10, 366, "ARROWS SEAT/PERSON   Z PLACE   R RESET", 7)
        p.text(10, 382, "ESC QUIT / NO AUDIO", 6)


COLORS = {"K": 9, "B": 10, "F": 11, "U": 12}
KEYS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'Z', 'R')
DEMO_FRAMES = 16


def demo(app, frame):
    key = {1: 'RIGHT', 3: 'DOWN', 5: 'Z'}.get(frame)
    app.control({key} if key else set(), {key} if key else set())


def main(args):
    # CLI validation is complete before this optional graphics import.
    from pathlib import Path
    import pyxel as p
    app = App()
    shot = Path(args.screenshot).resolve() if args.screenshot else None
    if shot:
        shot.parent.mkdir(parents=True, exist_ok=True)
    limit = args.qa_frames or (DEMO_FRAMES if args.demo_checkpoint else 12)
    p.init(560, 400, title="056 Pocket Orchestra", fps=30,
           display_scale=1, headless=args.headless_smoke)
    keys = {key: getattr(p, "KEY_" + key) for key in KEYS}
    frame = 0
    def update():
        nonlocal frame
        frame += 1
        if args.headless_smoke and frame >= limit:
            if shot:
                p.screenshot(str(shot), scale=1)
            p.quit()
            return
        if args.headless_smoke and args.demo_checkpoint:
            demo(app, frame)
        elif not args.headless_smoke:
            app.control({k for k, v in keys.items() if p.btnp(v)},
                        {k for k, v in keys.items() if p.btn(v)})
        app.advance()
    p.run(update, lambda: app.draw(p))


def parse_args(argv=None):
    import argparse
    parser = argparse.ArgumentParser(description="056 silent visual tutorial; Python 3.11+")
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--screenshot")
    parser.add_argument("--qa-frames", type=int)
    args = parser.parse_args(argv)
    import sys
    if sys.version_info < (3, 11):
        parser.error("Python 3.11 or newer is required")
    if (args.demo_checkpoint or args.screenshot or args.qa_frames is not None) and not args.headless_smoke:
        parser.error("QA options require --headless-smoke")
    if args.qa_frames is not None and not 2 <= args.qa_frames <= 600:
        parser.error("--qa-frames must be in 2..600")
    if args.demo_checkpoint and args.qa_frames is not None and args.qa_frames < DEMO_FRAMES:
        parser.error("--qa-frames is too short for the demo checkpoint")
    if args.screenshot and not args.screenshot.lower().endswith(".png"):
        parser.error("--screenshot must end in .png")
    return args


if __name__ == "__main__":
    main(parse_args())
STEP 04 / 05

整数の時計と次小節の予約を作る

今の並びと予約を分ける・小節の区切り・止めてから合わせ直す。

Space で自由な試し演奏を始めます。最初の30回のフレーム(frame)は数え始めの合図(カウントイン)に使い、その次のフレームから列0が始まります。STEP_FRAMES=10 なので、1列が10フレーム、1小節(8列)が80フレームです。Pyxel には1秒に30回(30fps)の更新を頼みますが、パソコンが遅ければ、実際にかかる時間は長くなります。tick はゲームが処理したフレームの合計で、パソコンの時計や音の再生位置からは計算しません。この教材の再生は、音の出ない画面表示だけです。

State の phase は「次に処理する、小節の中で何フレーム目か」、beat は「直前に表示した列」です。phase が79から0に戻るフレームでは、最後の列7を処理し終えたばかりです。その次のフレームの最初に phase==0 を見て予約を確定し、新しい列0の音(イベント)を作ります。小節の区切りで、1フレーム早く並びを変えてしまわないことが大切です。

試し演奏の途中で Z を押すと、今の席 seats は変えずに、予約 queued に保存します。画面上の席と TARGET の判定は予約の内容を、譜面と CURRENT は今演奏している内容を表します。予約がある間にさらに配置すると、target(s)=queued or seats(予約があれば予約、なければ今の席)を元に編集するので、2回目の編集で1回目の予約が消えることはありません。カウントインの間にした予約は、最初の列0から使われます。

Space で止めると、予約はその場で確定します。P を押すと時計が止まり、もう一度 P を押すと、小節の頭から30フレーム数え直してから再開します(合わせ直し)。止めた途中の列の続きからは再開しません。イベント(鳴った音の記録)は、そのフレームの (tick, 小節番号, 時刻, 奏者, 音の高さ) だけです。次のフレームでは空にし、draw の中では作りません。event_count と event_hash は、第5段階や元のゲームとの照合で使う記録用の値です。この段階では、本番と Undo はまだ使えません。

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

04_reservations.py / 注目する部分
            seats = s.queued if s.phase == 0 and s.queued else s.seats
            queued = () if s.phase == 0 else s.queued

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_reservations.py
macOS / Linux
.venv/bin/python 04_reservations.py
画面で見ることSpace を押すと COUNT が30から減り、そのあと列が順に進みます。試し演奏の途中で右→下→Z を押すと、CURRENT KF.BU と QUEUED KFB.U が別々に表示され、次の小節の頭で CURRENT も KFB.U に変わります。P で止めている間は TICK が止まり、再開はカウントインから始まります。
  • 今の席と予約した席を、別々に保存する
  • 予約は、小節の区切りの次のフレームで確定する
  • 描画や音を、ゲームの時計から切り離す
つまずきやすいところphase が0に変わった直後に、予約を使ってはいけません。今処理しているフレームの最初の phase で判定します。queued の表示につられて、今演奏中の譜面まで先に差し替えないでください。
少し変えてみよう

モデル上で play のあと45フレーム進め、("place",2,"B") を予約します。さらに65フレーム進めると、phase=0、bars=1 で、seats は元のままです。もう1フレーム進めると予約が確定し、beat=0、鳴る音(イベント)は K と F です。test_12/35 が、この区切りの動きを再現しています。

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

"""即席オーケストラ・教材4/5。原作Aを使う無音の視覚教材。"""
from dataclasses import dataclass, replace

MUSICIANS = "KBFU"
INITIAL_SEATS = ("K", "F", ".", "B", "U")
# Original A score. Keep each time and its pitch in the same pair.
SCORE = {
    "K": ((0, "c1"), (4, "c1")),
    "B": ((2, "g4"), (6, "a4")),
    "F": ((0, "c4"), (2, "e4"), (4, "g4"), (6, "e4")),
    "U": ((1, "c3"), (5, "g2")),
}
STEPS = 8

def phases(seats):
    shifts = {}
    for i, musician in enumerate(seats):
        if musician == ".":
            continue
        left_drum = 1 if i > 0 and seats[i - 1] == "K" else 0
        right_bell = 1 if i < 4 and seats[i + 1] == "B" else 0
        shifts[musician] = left_drum - right_bell
    return shifts


def arranged(seats):
    shifts = phases(seats)
    return {
        musician: tuple(sorted(((time + shifts[musician]) % STEPS, pitch)
                                for time, pitch in notes))
        for musician, notes in SCORE.items() if musician in shifts
    }


def placement(seats, seat, musician):
    if type(seat) is not int or not 0 <= seat < 5:
        return seats
    if type(musician) is not str or musician not in tuple(MUSICIANS + "."):
        return seats
    result = list(seats)
    if musician != "." and musician in result:
        previous = result.index(musician)
        result[previous], result[seat] = result[seat], musician
    else:
        result[seat] = musician
    return tuple(result)


def simultaneous(seats):
    score = arranged(seats)
    return tuple(tuple(m for m in MUSICIANS if m in score
                       and any(time == beat for time, pitch in score[m]))
                 for beat in range(STEPS))


def evaluation(seats):
    at = simultaneous(seats)
    players = len(arranged(seats))
    rests = sum(not group for group in at)
    peak = max(map(len, at))
    checks = (("three", players >= 3),
              ("zero_four", bool(at[0] and at[4])),
              ("seven_rest", not at[7]),
              ("no_three", peak < 3))
    return {"checks": list(checks), "passed": all(ok for _, ok in checks),
            "rest": rests, "peak": peak, "players": players,
            "steps": at, "shifts": phases(seats)}


STEP_FRAMES = 10
BAR_FRAMES = STEPS * STEP_FRAMES
COUNT_IN = 30


@dataclass(frozen=True)
class State:
    seats: tuple = INITIAL_SEATS
    queued: tuple = ()
    phase: int = 0
    beat: int = -1
    bars: int = 0
    mode: str = "stopped"
    countin: int = 0
    resume_mode: str = "preview"
    tick: int = 0
    events: tuple = ()
    event_hash: str = ""
    event_count: int = 0
    outcome: str = ""


@dataclass(frozen=True)
class Timeline:
    state: State = State()



def initial():
    return Timeline()


def target(state):
    return state.queued or state.seats


def digest(value):
    import hashlib
    import json
    data = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(data.encode("utf-8")).hexdigest()


def valid_action(action):
    if not isinstance(action, (tuple, list)) or not action or type(action[0]) is not str:
        return False
    if action[0] == "place":
        return (len(action) == 3 and type(action[1]) is int and 0 <= action[1] < 5
                and type(action[2]) is str and action[2] in tuple(MUSICIANS + "."))
    return len(action) == 1 and action[0] in ('play', 'pause', 'frame')


def step(timeline, action):
    if not valid_action(action):
        return timeline, "invalid"
    kind = action[0]
    s = timeline.state
    ns = s

    if kind == "place":
        if s.mode in ("paused", "show"):
            return timeline, "locked"
        new = placement(target(s), action[1], action[2])
        if new == target(s):
            return timeline, "same"

        if s.mode == "preview":
            ns = replace(s, queued=new)
            reason = "queued"
        else:
            ns = replace(s, seats=new, queued=(), mode="stopped", outcome="")
            reason = "placed"

    elif kind == "play":
        if s.mode == "paused":
            return timeline, "locked"
        if s.mode in ("preview", "show"):
            ns = replace(s, seats=target(s), queued=(), mode="stopped",
                         phase=0, beat=-1, countin=0, events=())
            reason = "stop"
        else:
            ns = replace(s, seats=target(s), queued=(), phase=0, beat=-1, bars=0,
                         mode="preview", countin=COUNT_IN, events=(),
                         event_hash="", event_count=0, outcome="")
            reason = "preview"

    elif kind == "pause":
        if s.mode == "paused":
            ns = replace(s, mode=s.resume_mode, phase=0, beat=-1,
                         countin=COUNT_IN if s.resume_mode in ("preview", "show") else 0,
                         events=())
            reason = "resync"
        else:
            ns = replace(s, mode="paused", resume_mode=s.mode, beat=-1, events=())
            reason = "pause"
    else:  # One model frame. Drawing never calls this branch.
        if s.mode not in ("preview", "show"):
            return timeline, "idle"
        if s.countin:
            ns = replace(s, countin=s.countin - 1, tick=s.tick + 1, beat=-1, events=())
            reason = "countin"
        else:
            seats = s.queued if s.phase == 0 and s.queued else s.seats
            queued = () if s.phase == 0 else s.queued
            events = ()
            if s.phase % STEP_FRAMES == 0:
                at = s.phase // STEP_FRAMES
                for musician, notes in arranged(seats).items():
                    for note, pitch in notes:
                        if note == at:
                            events += ((s.tick, s.bars, at, musician, pitch),)
            phase = (s.phase + 1) % BAR_FRAMES
            bars = s.bars + (phase == 0)
            mode, outcome = s.mode, s.outcome

            ns = replace(s, seats=seats, queued=queued, phase=phase,
                         beat=s.phase // STEP_FRAMES, bars=bars, mode=mode,
                         tick=s.tick + 1, events=events, outcome=outcome,
                         event_hash=digest((s.event_hash, events)) if events else s.event_hash,
                         event_count=s.event_count + len(events))
            reason = "finished" if mode == "result" else "frame"
    return Timeline(ns), reason


class App:
    def __init__(self):
        self.reset()

    def reset(self):
        self.timeline = initial()
        self.seat = 1
        self.person = 0
        self.gated = False
        self.message = "READY"

    @property
    def state(self):
        return self.timeline.state

    def control(self, pressed, held):
        if "R" in pressed:
            self.reset()
            self.gated = True
            return
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.message = "RELEASE ALL KEYS"
            self.gated = True
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        if key == "LEFT":
            self.seat = (self.seat - 1) % 5
        elif key == "RIGHT":
            self.seat = (self.seat + 1) % 5
        elif key == "UP":
            self.person = (self.person - 1) % 5
        elif key == "DOWN":
            self.person = (self.person + 1) % 5
        elif key == "Z":
            self.timeline, self.message = step(self.timeline, ("place", self.seat, (MUSICIANS + ".")[self.person]))
        elif key in {'SPACE': 'play', 'P': 'pause'}:
            self.timeline, self.message = step(self.timeline, ({'SPACE': 'play', 'P': 'pause'}[key],))
        self.gated = True

    def advance(self):
        self.timeline, reason = step(self.timeline, ("frame",))
        if reason == "finished":
            self.message = self.state.outcome.upper()
            self.gated = True

    def draw(self, p):
        p.cls(1)
        p.text(10, 10, "POCKET ORCHESTRA / STAGE 4", 10)
        p.text(364, 10, "SILENT VISUAL LESSON", 6)
        seats = self.state.seats
        future = target(self.state)
        p.text(10, 28, "K DRUM   B BELL   F FLUTE   U BASS   . EMPTY", 7)
        for i, musician in enumerate(future):
            x = 10 + i * 108
            p.rect(x, 45, 100, 43, 2)
            p.rectb(x, 45, 100, 43, 10 if i == self.seat else 5)
            p.text(x + 8, 53, str(i + 1) + ": " + musician, 7)
            shift = phases(future).get(musician, 0)
            p.text(x + 8, 70, "SHIFT " + format(shift, "+d") if musician != "." else "EMPTY", 6)
        p.text(10, 97, "CURRENT " + "".join(seats) + "    CANDIDATE " + (MUSICIANS + ".")[self.person], 7)
        p.text(10, 111, ("QUEUED " + "".join(self.state.queued) + " / NEXT BAR") if self.state.queued else "NO PENDING SEATS", 9)
        score = arranged(seats)
        active = self.state.beat if self.state.mode in ("preview", "show") else -1
        p.text(10, 131, "ORIGINAL: OUTLINE   ARRANGED: FILLED / TIME+PITCH", 6)
        for beat in range(STEPS):
            x = 139 + beat * 51
            p.text(x, 150, str(beat), 10)
            if beat == active:
                p.rect(x - 8, 162, 42, 120, 3)
        for row, musician in enumerate(MUSICIANS):
            y = 175 + row * 29
            p.text(10, y, musician, 7)
            p.text(33, y, format(phases(seats).get(musician, 0), "+d") if musician in score else "OFF", 6)
            for beat in range(STEPS):
                p.pset(141 + beat * 51, y + 3, 5)
            for time, pitch in SCORE[musician]:
                p.rectb(137 + time * 51, y - 2, 8, 8, 5)
            for time, pitch in score.get(musician, ()):
                p.rect(138 + time * 51, y - 1, 6, 6, COLORS[musician])
                p.text(131 + time * 51, y + 10, pitch.upper(), 7)
        result = evaluation(future)
        names = ("3+ PLAYERS", "SOUND 0/4", "REST AT 7", "PEAK <= 2")
        for i, ((name, ok), label) in enumerate(zip(result["checks"], names)):
            p.text(10 + i * 135, 291, ("OK " if ok else "-- ") + label, 11 if ok else 13)
        p.text(10, 307, "TARGET RESTS " + str(result["rest"]) + "  PEAK " + str(result["peak"])
               + "  PASS " + str(result["passed"]), 6)
        s = self.state
        p.text(10, 326, s.mode.upper() + "  TICK " + str(s.tick) + "  PHASE " + str(s.phase)
               + "  BEAT " + str(s.beat) + "  BARS " + str(s.bars) + "  COUNT " + str(s.countin), 7)
        p.text(10, 346, self.message.upper(), 10)
        p.text(10, 366, "ARROWS SEAT/PERSON   Z PLACE   R RESET", 7)
        p.text(10, 382, "SPACE PREVIEW/STOP  P PAUSE/RESYNC  ESC QUIT", 6)


COLORS = {"K": 9, "B": 10, "F": 11, "U": 12}
KEYS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'Z', 'R', 'SPACE', 'P')
DEMO_FRAMES = 48


def demo(app, frame):
    key = {1: 'SPACE', 41: 'RIGHT', 43: 'DOWN', 45: 'Z'}.get(frame)
    app.control({key} if key else set(), {key} if key else set())


def main(args):
    # CLI validation is complete before this optional graphics import.
    from pathlib import Path
    import pyxel as p
    app = App()
    shot = Path(args.screenshot).resolve() if args.screenshot else None
    if shot:
        shot.parent.mkdir(parents=True, exist_ok=True)
    limit = args.qa_frames or (DEMO_FRAMES if args.demo_checkpoint else 12)
    p.init(560, 400, title="056 Pocket Orchestra", fps=30,
           display_scale=1, headless=args.headless_smoke)
    keys = {key: getattr(p, "KEY_" + key) for key in KEYS}
    frame = 0
    def update():
        nonlocal frame
        frame += 1
        if args.headless_smoke and frame >= limit:
            if shot:
                p.screenshot(str(shot), scale=1)
            p.quit()
            return
        if args.headless_smoke and args.demo_checkpoint:
            demo(app, frame)
        elif not args.headless_smoke:
            app.control({k for k, v in keys.items() if p.btnp(v)},
                        {k for k, v in keys.items() if p.btn(v)})
        app.advance()
    p.run(update, lambda: app.draw(p))


def parse_args(argv=None):
    import argparse
    parser = argparse.ArgumentParser(description="056 silent visual tutorial; Python 3.11+")
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--screenshot")
    parser.add_argument("--qa-frames", type=int)
    args = parser.parse_args(argv)
    import sys
    if sys.version_info < (3, 11):
        parser.error("Python 3.11 or newer is required")
    if (args.demo_checkpoint or args.screenshot or args.qa_frames is not None) and not args.headless_smoke:
        parser.error("QA options require --headless-smoke")
    if args.qa_frames is not None and not 2 <= args.qa_frames <= 600:
        parser.error("--qa-frames must be in 2..600")
    if args.demo_checkpoint and args.qa_frames is not None and args.qa_frames < DEMO_FRAMES:
        parser.error("--qa-frames is too short for the demo checkpoint")
    if args.screenshot and not args.screenshot.lower().endswith(".png"):
        parser.error("--screenshot must end in .png")
    return args


if __name__ == "__main__":
    main(parse_args())
STEP 05 / 05

四小節の本番と配置だけのUndoを加える

モードの切り替え・履歴に残すもの・本番の結果判定。

Enter で本番を始めます。席の奏者が3人より少なければ TOO_FEW と表示して始めません。3人以上なら、条件を満たしていなくても始められます。始めるときに予約していた並びを確定し、30フレームのカウントインと、4小節ぶんの320フレームを演奏します。合計350フレームで RESULT(結果)になり、状況 A の4つの条件を満たせば WON、満たさなければ REVISE(やり直し)です。本番中は並びの変更も Undo もできません。Space で止めると、また編集できます。

Timeline には、State と履歴 history を持たせます。ただし、このゲームの Undo は、状態全体を巻き戻すものではありません。履歴に積むのは、編集する前の target(s)、つまり5つの席の並び(タプル)だけです。並びが変わらない操作や、受け付けなかった操作、再生中のフレームは積みません。X は最後に積んだ並びを取り出し、止まっているときは seats に、試し演奏中なら予約 queued に戻します。tick・phase・bars・すでに鳴らしたイベントの合計は巻き戻しません。

予約が確定する前に Undo すると、予約が「元の並びに戻す予約」に置き換わります。確定したあとに Undo しても、今鳴っている小節を途中で変えることはなく、次の小節から戻す予約になります。P で止めている間と本番中の X は LOCKED(使えない)です。結果画面では X が使え、並びを戻して結果の表示を消し、stopped(停止中)に戻ります。ただし tick はそのまま残ります。

R は Undo と違い、Timeline を作り直して、時計・イベント・並び・履歴・選択位置をすべて最初に戻します。ふつうの操作は1つずつ受け付け、操作キーが全部離されるまで次の操作を待ちます。ただし、新しく押した R は優先します。キーが離されるのを待っている間も、試し演奏の時計は進みます。この教材は状況 A のルールを元のゲームと比べて確かめていますが、表紙・日本語の文字・音を鳴らす仕組み・状況 B/C・比較モード・ファイルへの記録と再生は、次に読む元のゲームの機能です。

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

05_performance.py / 注目する部分
        old, history = history[-1], history[:-1]
        if s.mode == "preview":
            ns = replace(s, queued=old)
        else:
            ns = replace(s, seats=old, queued=(), mode="stopped", outcome="")

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_performance.py
macOS / Linux
.venv/bin/python 05_performance.py
画面で見ること右→下→Z→Enter と押すと、350フレーム(約11.7秒ぶん)のあとに WON になります。TICK 350、BARS 4 です。最初の並びのまま Enter を押すと REVISE です。結果画面で X を押すと、元の並びに戻せます。実際にかかる時間は、パソコンによって変わります。
  • 本番の4小節を最後まで演奏してから判定する
  • Undo で戻すのは並びだけ、時計はそのまま
  • R は履歴も時計も最初に戻す
つまずきやすいところほかの教材の「State 全体を戻す Undo」をそのまま使うと、「並びだけを戻す」というこのゲームのルールが変わってしまいます。履歴に積むのは席のタプルだけです。再開しても、もう一度演奏しても、tick は合計を保ち、R だけが0に戻します。
少し変えてみよう

右→下→Z→Enter で WON まで進めてから X を押してみましょう。答えは CURRENT KF.BU、STOPPED、TICK 350、履歴0、outcome(結果)は空です。続けて R を押すと TICK 0 になります。test_26/27/35 で確かめています。

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

"""即席オーケストラ・教材5/5。原作Aを使う無音の視覚教材。"""
from dataclasses import dataclass, replace

MUSICIANS = "KBFU"
INITIAL_SEATS = ("K", "F", ".", "B", "U")
# Original A score. Keep each time and its pitch in the same pair.
SCORE = {
    "K": ((0, "c1"), (4, "c1")),
    "B": ((2, "g4"), (6, "a4")),
    "F": ((0, "c4"), (2, "e4"), (4, "g4"), (6, "e4")),
    "U": ((1, "c3"), (5, "g2")),
}
STEPS = 8

def phases(seats):
    shifts = {}
    for i, musician in enumerate(seats):
        if musician == ".":
            continue
        left_drum = 1 if i > 0 and seats[i - 1] == "K" else 0
        right_bell = 1 if i < 4 and seats[i + 1] == "B" else 0
        shifts[musician] = left_drum - right_bell
    return shifts


def arranged(seats):
    shifts = phases(seats)
    return {
        musician: tuple(sorted(((time + shifts[musician]) % STEPS, pitch)
                                for time, pitch in notes))
        for musician, notes in SCORE.items() if musician in shifts
    }


def placement(seats, seat, musician):
    if type(seat) is not int or not 0 <= seat < 5:
        return seats
    if type(musician) is not str or musician not in tuple(MUSICIANS + "."):
        return seats
    result = list(seats)
    if musician != "." and musician in result:
        previous = result.index(musician)
        result[previous], result[seat] = result[seat], musician
    else:
        result[seat] = musician
    return tuple(result)


def simultaneous(seats):
    score = arranged(seats)
    return tuple(tuple(m for m in MUSICIANS if m in score
                       and any(time == beat for time, pitch in score[m]))
                 for beat in range(STEPS))


def evaluation(seats):
    at = simultaneous(seats)
    players = len(arranged(seats))
    rests = sum(not group for group in at)
    peak = max(map(len, at))
    checks = (("three", players >= 3),
              ("zero_four", bool(at[0] and at[4])),
              ("seven_rest", not at[7]),
              ("no_three", peak < 3))
    return {"checks": list(checks), "passed": all(ok for _, ok in checks),
            "rest": rests, "peak": peak, "players": players,
            "steps": at, "shifts": phases(seats)}


STEP_FRAMES = 10
BAR_FRAMES = STEPS * STEP_FRAMES
COUNT_IN = 30


@dataclass(frozen=True)
class State:
    seats: tuple = INITIAL_SEATS
    queued: tuple = ()
    phase: int = 0
    beat: int = -1
    bars: int = 0
    mode: str = "stopped"
    countin: int = 0
    resume_mode: str = "preview"
    tick: int = 0
    events: tuple = ()
    event_hash: str = ""
    event_count: int = 0
    outcome: str = ""


@dataclass(frozen=True)
class Timeline:
    state: State = State()
    history: tuple = ()


def initial():
    return Timeline()


def target(state):
    return state.queued or state.seats


def digest(value):
    import hashlib
    import json
    data = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(data.encode("utf-8")).hexdigest()


def valid_action(action):
    if not isinstance(action, (tuple, list)) or not action or type(action[0]) is not str:
        return False
    if action[0] == "place":
        return (len(action) == 3 and type(action[1]) is int and 0 <= action[1] < 5
                and type(action[2]) is str and action[2] in tuple(MUSICIANS + "."))
    return len(action) == 1 and action[0] in ('undo', 'play', 'perform', 'pause', 'frame')


def step(timeline, action):
    if not valid_action(action):
        return timeline, "invalid"
    kind = action[0]
    s = timeline.state
    ns = s
    history = timeline.history
    if kind == "place":
        if s.mode in ("paused", "show"):
            return timeline, "locked"
        new = placement(target(s), action[1], action[2])
        if new == target(s):
            return timeline, "same"
        history += (target(s),)
        if s.mode == "preview":
            ns = replace(s, queued=new)
            reason = "queued"
        else:
            ns = replace(s, seats=new, queued=(), mode="stopped", outcome="")
            reason = "placed"
    elif kind == "undo":
        if s.mode in ("paused", "show"):
            return timeline, "locked"
        if not history:
            return timeline, "no_undo"
        old, history = history[-1], history[:-1]
        if s.mode == "preview":
            ns = replace(s, queued=old)
        else:
            ns = replace(s, seats=old, queued=(), mode="stopped", outcome="")
        reason = "undo"
    elif kind == "play":
        if s.mode == "paused":
            return timeline, "locked"
        if s.mode in ("preview", "show"):
            ns = replace(s, seats=target(s), queued=(), mode="stopped",
                         phase=0, beat=-1, countin=0, events=())
            reason = "stop"
        else:
            ns = replace(s, seats=target(s), queued=(), phase=0, beat=-1, bars=0,
                         mode="preview", countin=COUNT_IN, events=(),
                         event_hash="", event_count=0, outcome="")
            reason = "preview"
    elif kind == "perform":
        if s.mode in ("paused", "show"):
            return timeline, "locked"
        if sum(m != "." for m in target(s)) < 3:
            return timeline, "too_few"
        ns = replace(s, seats=target(s), queued=(), phase=0, beat=-1, bars=0,
                     mode="show", countin=COUNT_IN, events=(),
                     event_hash="", event_count=0, outcome="")
        reason = "perform"
    elif kind == "pause":
        if s.mode == "paused":
            ns = replace(s, mode=s.resume_mode, phase=0, beat=-1,
                         countin=COUNT_IN if s.resume_mode in ("preview", "show") else 0,
                         events=())
            reason = "resync"
        else:
            ns = replace(s, mode="paused", resume_mode=s.mode, beat=-1, events=())
            reason = "pause"
    else:  # One model frame. Drawing never calls this branch.
        if s.mode not in ("preview", "show"):
            return timeline, "idle"
        if s.countin:
            ns = replace(s, countin=s.countin - 1, tick=s.tick + 1, beat=-1, events=())
            reason = "countin"
        else:
            seats = s.queued if s.phase == 0 and s.queued else s.seats
            queued = () if s.phase == 0 else s.queued
            events = ()
            if s.phase % STEP_FRAMES == 0:
                at = s.phase // STEP_FRAMES
                for musician, notes in arranged(seats).items():
                    for note, pitch in notes:
                        if note == at:
                            events += ((s.tick, s.bars, at, musician, pitch),)
            phase = (s.phase + 1) % BAR_FRAMES
            bars = s.bars + (phase == 0)
            mode, outcome = s.mode, s.outcome
            if mode == "show" and bars == 4:
                mode = "result"
                outcome = "won" if evaluation(seats)["passed"] else "revise"
            ns = replace(s, seats=seats, queued=queued, phase=phase,
                         beat=s.phase // STEP_FRAMES, bars=bars, mode=mode,
                         tick=s.tick + 1, events=events, outcome=outcome,
                         event_hash=digest((s.event_hash, events)) if events else s.event_hash,
                         event_count=s.event_count + len(events))
            reason = "finished" if mode == "result" else "frame"
    return Timeline(ns, history), reason


class App:
    def __init__(self):
        self.reset()

    def reset(self):
        self.timeline = initial()
        self.seat = 1
        self.person = 0
        self.gated = False
        self.message = "READY"

    @property
    def state(self):
        return self.timeline.state

    def control(self, pressed, held):
        if "R" in pressed:
            self.reset()
            self.gated = True
            return
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.message = "RELEASE ALL KEYS"
            self.gated = True
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        if key == "LEFT":
            self.seat = (self.seat - 1) % 5
        elif key == "RIGHT":
            self.seat = (self.seat + 1) % 5
        elif key == "UP":
            self.person = (self.person - 1) % 5
        elif key == "DOWN":
            self.person = (self.person + 1) % 5
        elif key == "Z":
            self.timeline, self.message = step(self.timeline, ("place", self.seat, (MUSICIANS + ".")[self.person]))
        elif key in {'SPACE': 'play', 'P': 'pause', 'X': 'undo', 'RETURN': 'perform'}:
            self.timeline, self.message = step(self.timeline, ({'SPACE': 'play', 'P': 'pause', 'X': 'undo', 'RETURN': 'perform'}[key],))
        self.gated = True

    def advance(self):
        self.timeline, reason = step(self.timeline, ("frame",))
        if reason == "finished":
            self.message = self.state.outcome.upper()
            self.gated = True

    def draw(self, p):
        p.cls(1)
        p.text(10, 10, "POCKET ORCHESTRA / STAGE 5", 10)
        p.text(364, 10, "SILENT VISUAL LESSON", 6)
        seats = self.state.seats
        future = target(self.state)
        p.text(10, 28, "K DRUM   B BELL   F FLUTE   U BASS   . EMPTY", 7)
        for i, musician in enumerate(future):
            x = 10 + i * 108
            p.rect(x, 45, 100, 43, 2)
            p.rectb(x, 45, 100, 43, 10 if i == self.seat else 5)
            p.text(x + 8, 53, str(i + 1) + ": " + musician, 7)
            shift = phases(future).get(musician, 0)
            p.text(x + 8, 70, "SHIFT " + format(shift, "+d") if musician != "." else "EMPTY", 6)
        p.text(10, 97, "CURRENT " + "".join(seats) + "    CANDIDATE " + (MUSICIANS + ".")[self.person], 7)
        p.text(10, 111, ("QUEUED " + "".join(self.state.queued) + " / NEXT BAR") if self.state.queued else "NO PENDING SEATS", 9)
        score = arranged(seats)
        active = self.state.beat if self.state.mode in ("preview", "show") else -1
        p.text(10, 131, "ORIGINAL: OUTLINE   ARRANGED: FILLED / TIME+PITCH", 6)
        for beat in range(STEPS):
            x = 139 + beat * 51
            p.text(x, 150, str(beat), 10)
            if beat == active:
                p.rect(x - 8, 162, 42, 120, 3)
        for row, musician in enumerate(MUSICIANS):
            y = 175 + row * 29
            p.text(10, y, musician, 7)
            p.text(33, y, format(phases(seats).get(musician, 0), "+d") if musician in score else "OFF", 6)
            for beat in range(STEPS):
                p.pset(141 + beat * 51, y + 3, 5)
            for time, pitch in SCORE[musician]:
                p.rectb(137 + time * 51, y - 2, 8, 8, 5)
            for time, pitch in score.get(musician, ()):
                p.rect(138 + time * 51, y - 1, 6, 6, COLORS[musician])
                p.text(131 + time * 51, y + 10, pitch.upper(), 7)
        result = evaluation(future)
        names = ("3+ PLAYERS", "SOUND 0/4", "REST AT 7", "PEAK <= 2")
        for i, ((name, ok), label) in enumerate(zip(result["checks"], names)):
            p.text(10 + i * 135, 291, ("OK " if ok else "-- ") + label, 11 if ok else 13)
        p.text(10, 307, "TARGET RESTS " + str(result["rest"]) + "  PEAK " + str(result["peak"])
               + "  PASS " + str(result["passed"]), 6)
        s = self.state
        p.text(10, 326, s.mode.upper() + "  TICK " + str(s.tick) + "  PHASE " + str(s.phase)
               + "  BEAT " + str(s.beat) + "  BARS " + str(s.bars) + "  COUNT " + str(s.countin), 7)
        p.text(10, 346, self.message.upper(), 10)
        p.text(10, 366, "ARROWS SEAT/PERSON   Z PLACE   R RESET", 7)
        p.text(10, 382, "SPACE PLAY/STOP  P PAUSE  ENTER SHOW  X UNDO  ESC QUIT", 6)


COLORS = {"K": 9, "B": 10, "F": 11, "U": 12}
KEYS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'Z', 'R', 'SPACE', 'P', 'RETURN', 'X')
DEMO_FRAMES = 358


def demo(app, frame):
    key = {1: 'RIGHT', 3: 'DOWN', 5: 'Z', 7: 'RETURN'}.get(frame)
    app.control({key} if key else set(), {key} if key else set())


def main(args):
    # CLI validation is complete before this optional graphics import.
    from pathlib import Path
    import pyxel as p
    app = App()
    shot = Path(args.screenshot).resolve() if args.screenshot else None
    if shot:
        shot.parent.mkdir(parents=True, exist_ok=True)
    limit = args.qa_frames or (DEMO_FRAMES if args.demo_checkpoint else 12)
    p.init(560, 400, title="056 Pocket Orchestra", fps=30,
           display_scale=1, headless=args.headless_smoke)
    keys = {key: getattr(p, "KEY_" + key) for key in KEYS}
    frame = 0
    def update():
        nonlocal frame
        frame += 1
        if args.headless_smoke and frame >= limit:
            if shot:
                p.screenshot(str(shot), scale=1)
            p.quit()
            return
        if args.headless_smoke and args.demo_checkpoint:
            demo(app, frame)
        elif not args.headless_smoke:
            app.control({k for k, v in keys.items() if p.btnp(v)},
                        {k for k, v in keys.items() if p.btn(v)})
        app.advance()
    p.run(update, lambda: app.draw(p))


def parse_args(argv=None):
    import argparse
    parser = argparse.ArgumentParser(description="056 silent visual tutorial; Python 3.11+")
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--screenshot")
    parser.add_argument("--qa-frames", type=int)
    args = parser.parse_args(argv)
    import sys
    if sys.version_info < (3, 11):
        parser.error("Python 3.11 or newer is required")
    if (args.demo_checkpoint or args.screenshot or args.qa_frames is not None) and not args.headless_smoke:
        parser.error("QA options require --headless-smoke")
    if args.qa_frames is not None and not 2 <= args.qa_frames <= 600:
        parser.error("--qa-frames must be in 2..600")
    if args.demo_checkpoint and args.qa_frames is not None and args.qa_frames < DEMO_FRAMES:
        parser.error("--qa-frames is too short for the demo checkpoint")
    if args.screenshot and not args.screenshot.lower().endswith(".png"):
        parser.error("--screenshot must end in .png")
    return args


if __name__ == "__main__":
    main(parse_args())
AFTER STEP 05

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

ルールのまとめ:状況 A の4人の奏者を5つの席に並べます。太鼓の右どなりは +1、鈴の左どなりは -1、両方なら打ち消し合って0。元の (time,pitch) に一度だけ、8で割った余り(mod8)でずれを加えます。条件は、3人以上・時刻0と4で音が鳴る・時刻7は休み・同時に鳴るのは2人まで。本番は30フレームのカウントイン+80フレーム×4小節。試し演奏中の編集は次の小節の頭から有効になり、Undo は並びだけを戻します。

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

比べる点5ステップの教材元の完成版
段階01 は元の譜面と手動の読む位置、02 はずれと席、03 は譜面を見て状況 A を判定、04 は音の出ない試し演奏と予約、05 は状況 A の本番と並びの Undo。最初から状況 A/B/C、音、比較モード、表紙、入力の記録があります。
音と表示英数字(ASCII)と四角形で時刻と音の高さを表示する、音の出ない教材。音の準備や再生は作っていません。日本語のドット文字(bitmap)と、4チャンネルの短い音。engine_note で音の高さの書き方を変換します。
操作・記録操作キーの同時押しを受け付けず、全部離されるまで待つ簡単な入力処理(Controller)。R が優先。記録はメモリーの中だけ。方向キーと決定キーの専用の押下管理(ラッチ)、S で記録、replay で記録と厳密に照合。元のゲームの操作画面(UI)と完全に一致するとは主張していません。

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

  1. model.py の phases / arranged:元の譜面と、ずれの計算
  2. model.py の target / placement / step:予約の確定と、並びの履歴
  3. model.py の evaluation:状況 B/C の条件。record / replay:記録した操作との照合
  4. app.py の action / advance / sounding_step:音を鳴らすタイミングと表示
  5. model.py の engine_note と app.py の prepare_audio:音の高さの変換。実際に耳で聞いて確かめるのは別に必要です
自分のゲームに変えるなら

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

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