パズルLESSON 07実験 × 推理パズル原作026

音のないピアノの作り方

わかったことを、次の一手へ。
鍵盤を押すと、3つの灯りの点灯・消灯が入れ替わります。どの鍵盤がどの灯りにつながっているかを観察しながら、ビット演算、答えの候補を絞り込む方法、わかったことを残したまま取り消す仕組みを学びます。

完成版の遊び方鍵盤を押す前と後で、どの灯りが入れ替わったかを観察します。配線をすべて当てなくても、目標どおりの灯りを作ればクリアです。

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

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

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

完成版の主な操作:左右で鍵盤を選ぶ、Spaceで押す、Zで灯りを1つ戻す、Rで灯りを最初に戻す、上下で観察の記録を見る、Pで一時停止、Mで面を選ぶ

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

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

うまく表示されないとき

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

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

灯りの変化と、観察してわかったことを分けて記録する。

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

1. 灯りを使って、bit(ビット)と XOR を理解する
2. 観察の結果から、入れ替わった灯り(mask)を取り出す
3. まだ調べていないことと、推理でわかることを分ける
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
変数・if・関数の基本
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
← →:鍵盤を選ぶ / Space:押す / Z:灯りを戻す / R:灯りを000に / N:すべて最初から(ZとNは段階4・5)
難易度の目安
初級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_bits.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

3つの灯りを反転する

灯りを使って、bit(ビット)と XOR を理解する。

1桁の bit は、0か1のどちらかです。ここでは0が消灯、1が点灯です。左から右へ3桁で表し、100は左だけ、010は中央だけ、001は右だけが点いている状態です。それぞれの桁の重みは4・2・1なので、110は整数の6になります。Python で 0b110 と書いても6を表します。XOR は、同じ桁どうしを比べて、同じなら0、違えば1にする計算です。Python では ^ と書きます。灯りと配線の mask を XOR すると、mask が1の灯りだけが入れ替わります。000 ^ 110 = 110、110 ^ 110 = 000 です。左右キーで鍵盤を選び、Space で1回だけ入れ替えます。まだ目標や観察の記録はありません。

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

01_bits.py / 注目する部分
        self.lamps = before ^ mask

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_bits.py
macOS / Linux
.venv/bin/python 01_bits.py
画面で見ることA を選んだまま Space を1回押すと110、離してもう1回押すと000になります。右キーで B を選んで Space を押すと011になります。R キーで練習を最初に戻せます。
  • 100が左の灯りだと分かる
  • 同じ鍵を2回で元へ戻る
  • Rで000へ戻る
つまずきやすいところ^ は足し算でも、累乗(何乗)でもありません。また、110は普通の整数として書くと「百十」の意味になります。2進数として書くなら 0b110、画面に出す文字なら "110"、整数なら6を使います。
少し変えてみよう

まず A を2回押して、000に戻ることを確かめましょう。次に MASKS の A だけを 0b101 に変えて、左右の灯りだけが入れ替わるか確かめたら、元に戻します。

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

# 上記は原作の通知全文です。この教材にNoto由来の画像・文字資産は含めません。
"""026 音のないピアノ / 教材 1/5。単独で実行できます。"""
import argparse
from pathlib import Path

# 1桁のbitは0(消灯)か1(点灯)。左から重み4, 2, 1です。
# 例: 110は4+2=6。0b110はPythonで2進数を書く記法です。
# XOR(^)は各桁を比較し、違うなら1、同じなら0にします。
# 灯り ^ 配線maskでは、maskが1の場所だけ0<->1が反転します。
# 000 ^ 110 = 110、110 ^ 110 = 000。足し算ではありません。
MASKS = (0b110, 0b011, 0b001)
WIDTH, HEIGHT = 384, 360
FPS = 30


def bits(value):
    """整数を左→右の3桁へ変換。100は左だけ点灯。"""
    return format(value, "03b")


class Game:
    def __init__(self):
        self.lamps = 0

    def press(self, key):
        if not isinstance(key, int) or isinstance(key, bool) or not 0 <= key < 3:
            return False
        before = self.lamps
        mask = MASKS[key]
        self.lamps = before ^ mask
        return True


def main(headless_smoke=False, screenshot=None, demo=False):
    import pyxel  # 純粋なGameのテストだけならPyxelを読み込みません。
    game = Game()
    selected = 0
    frames = 0
    locked = False
    message = "LEFT/RIGHT select. SPACE presses one key."
    controls = [pyxel.KEY_LEFT, pyxel.KEY_RIGHT, pyxel.KEY_SPACE, pyxel.KEY_R]
    pyxel.init(WIDTH, HEIGHT, title="026 SILENT PIANO - STEP 1",
               fps=FPS, display_scale=2, headless=headless_smoke)

    def update():
        nonlocal game, selected, frames, locked, message
        frames += 1
        if demo:
            if frames in (2, 4):
                game.press(0)
            elif frames == 6:
                selected = 1
                game.press(1)
            message = "AUTO DEMO: scripted actions, not a human test."
            return
        # R/Z/Nの直後に押しっぱなしの別キーが流入しないよう全解放を待つ。
        if locked:
            if not any(pyxel.btn(key) for key in controls):
                locked = False
            return
        if pyxel.btnp(pyxel.KEY_R):
            game = Game()
            selected = 0
            locked = True
            message = "NEW PRACTICE: everything reset."
            return
        left = pyxel.btnp(pyxel.KEY_LEFT)
        right = pyxel.btnp(pyxel.KEY_RIGHT)
        if (left or right) and pyxel.btn(pyxel.KEY_LEFT) and pyxel.btn(pyxel.KEY_RIGHT):
            message = "Choose one direction at a time."
            return
        if left or right:
            selected = (selected + (-1 if left else 1)) % 3
            return
        if pyxel.btnp(pyxel.KEY_SPACE):
            game.press(selected)
            message = "Pressed " + "ABC"[selected] + ": compare before and after."

    def draw_lamps(x, y, value):
        for index, digit in enumerate(bits(value)):
            xx = x + index * 30
            pyxel.circ(xx, y, 10, 10 if digit == "1" else 0)
            pyxel.circb(xx, y, 10, 6)
            pyxel.text(xx - 2, y - 2, digit, 1 if digit == "1" else 7)

    def draw():
        pyxel.cls(1)
        pyxel.text(12, 10, "026 SILENT PIANO / STEP 1", 7)
        pyxel.text(12, 26, "NOW " + bits(game.lamps) + "    LEFT -> RIGHT", 6)
        draw_lamps(40, 57, game.lamps)
        pyxel.text(224, 44, "PRACTICE: NO GOAL YET", 13)
        for key in range(3):
            x = 12 + key * 120
            pyxel.rect(x, 79, 110, 30, 7)
            pyxel.rectb(x, 79, 110, 30, 10 if key == selected else 5)
            pyxel.text(x + 12, 92, "ABC"[key] + (" <SELECTED>" if key == selected else ""), 1)
        pyxel.text(12, 118, message, 7)
        pyxel.text(12, 142, "  ".join("ABC"[i] + "=" + bits(mask) for i, mask in enumerate(MASKS)) + " : 1 means TOGGLE", 10)
        pyxel.text(12, 166, "XOR per bit: same -> 0, different -> 1", 6)
        pyxel.text(12, 184, "000 XOR 110 = 110", 7)
        pyxel.text(12, 202, "110 XOR 110 = 000", 7)
        pyxel.text(12, 230, "Press the same key twice: lamps come back.", 13)
        pyxel.text(12, 310, "LEFT/RIGHT select  SPACE press", 7)
        pyxel.text(12, 326, "R new practice  ESC quit", 7)
        pyxel.text(12, 344, "SILENT / ASCII UI / 30 FPS", 5)
        # 12回目の描画後に保存・終了。実際のPyxel描画を使います。
        if frames == 12 and screenshot:
            path = Path(screenshot)
            path.parent.mkdir(parents=True, exist_ok=True)
            pyxel.screenshot(str(path), scale=2)
        if frames == 12 and headless_smoke:
            print("headless-smoke: 12 frames rendered", flush=True)
            pyxel.quit()

    pyxel.run(update, draw)


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="026 silent piano teaching stage")
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--screenshot", metavar="PATH")
    parser.add_argument("--demo", action="store_true", help="run deterministic scripted actions")
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo)
STEP 02 / 05

押す前と後を記録する

観察の結果から、入れ替わった灯り(mask)を取り出す。

journal(観察の記録)に、(鍵盤の番号, 押す前 before, 押した後 after) を追加します。before ^ after は、前と後で違った桁だけが1になるので、実際に入れ替わった mask を取り出せます。記録は画面用の文章ではなく整数の組で保存し、draw が3桁の文字に直して表示します。画面には最新の3行だけを表示しますが、journal にはすべて残っています。まだ候補の図や成功の判定はありません。

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

02_observe.py / 注目する部分
        self.journal.append((key, before, self.lamps))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_observe.py
macOS / Linux
.venv/bin/python 02_observe.py
画面で見ることA→右→B の順に押すと、「A 000→110 / 110」「B 110→101 / 011」の2行が表示され、PRESSES は2です。Space を押しっぱなしにしても、行は増えません。この段階では、R キーで記録も消えます。
  • 押す前と後が別々に保存される
  • before XOR after で入れ替わった灯りがわかる
  • 押した回数は、記録の行数と同じ
つまずきやすいところ灯りを変えてから before を保存すると、前と後が同じになり、観察した mask が000になってしまいます。before は、入れ替える前に保存します。
少し変えてみよう

A を4回押してみましょう。表示は最新の3行だけでも、PRESSES は4になることを確かめます。テストの test_long_journal_not_limited_to_three_visible_rows でも、記録が中に残っていることを確認しましょう。

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

# 上記は原作の通知全文です。この教材にNoto由来の画像・文字資産は含めません。
"""026 音のないピアノ / 教材 2/5。単独で実行できます。"""
import argparse
from pathlib import Path

# 1桁のbitは0(消灯)か1(点灯)。左から重み4, 2, 1です。
# 例: 110は4+2=6。0b110はPythonで2進数を書く記法です。
# XOR(^)は各桁を比較し、違うなら1、同じなら0にします。
# 灯り ^ 配線maskでは、maskが1の場所だけ0<->1が反転します。
# 000 ^ 110 = 110、110 ^ 110 = 000。足し算ではありません。
MASKS = (0b110, 0b011, 0b001)
WIDTH, HEIGHT = 384, 360
FPS = 30


def bits(value):
    """整数を左→右の3桁へ変換。100は左だけ点灯。"""
    return format(value, "03b")


class Game:
    def __init__(self):
        self.lamps = 0
        self.journal = []  # (鍵番号, 押す前, 押した後)の累積記録

    def press(self, key):
        if not isinstance(key, int) or isinstance(key, bool) or not 0 <= key < 3:
            return False
        before = self.lamps
        mask = MASKS[key]
        self.lamps = before ^ mask
        self.journal.append((key, before, self.lamps))
        return True


def main(headless_smoke=False, screenshot=None, demo=False):
    import pyxel  # 純粋なGameのテストだけならPyxelを読み込みません。
    game = Game()
    selected = 0
    frames = 0
    locked = False
    message = "LEFT/RIGHT select. SPACE presses one key."
    controls = [pyxel.KEY_LEFT, pyxel.KEY_RIGHT, pyxel.KEY_SPACE, pyxel.KEY_R]
    pyxel.init(WIDTH, HEIGHT, title="026 SILENT PIANO - STEP 2",
               fps=FPS, display_scale=2, headless=headless_smoke)

    def update():
        nonlocal game, selected, frames, locked, message
        frames += 1
        if demo:
            if frames in (2, 4):
                selected = 0 if frames == 2 else 1
                game.press(selected)
            message = "AUTO DEMO: scripted actions, not a human test."
            return
        # R/Z/Nの直後に押しっぱなしの別キーが流入しないよう全解放を待つ。
        if locked:
            if not any(pyxel.btn(key) for key in controls):
                locked = False
            return
        if pyxel.btnp(pyxel.KEY_R):
            game = Game()
            selected = 0
            locked = True
            message = "NEW PRACTICE: everything reset."
            return
        left = pyxel.btnp(pyxel.KEY_LEFT)
        right = pyxel.btnp(pyxel.KEY_RIGHT)
        if (left or right) and pyxel.btn(pyxel.KEY_LEFT) and pyxel.btn(pyxel.KEY_RIGHT):
            message = "Choose one direction at a time."
            return
        if left or right:
            selected = (selected + (-1 if left else 1)) % 3
            return
        if pyxel.btnp(pyxel.KEY_SPACE):
            game.press(selected)
            message = "Pressed " + "ABC"[selected] + ": compare before and after."

    def draw_lamps(x, y, value):
        for index, digit in enumerate(bits(value)):
            xx = x + index * 30
            pyxel.circ(xx, y, 10, 10 if digit == "1" else 0)
            pyxel.circb(xx, y, 10, 6)
            pyxel.text(xx - 2, y - 2, digit, 1 if digit == "1" else 7)

    def draw():
        pyxel.cls(1)
        pyxel.text(12, 10, "026 SILENT PIANO / STEP 2", 7)
        pyxel.text(12, 26, "NOW " + bits(game.lamps) + "    LEFT -> RIGHT", 6)
        draw_lamps(40, 57, game.lamps)
        pyxel.text(224, 44, "PRACTICE: NO GOAL YET", 13)
        for key in range(3):
            x = 12 + key * 120
            pyxel.rect(x, 79, 110, 30, 7)
            pyxel.rectb(x, 79, 110, 30, 10 if key == selected else 5)
            pyxel.text(x + 12, 92, "ABC"[key] + (" <SELECTED>" if key == selected else ""), 1)
        pyxel.text(12, 118, message, 7)
        pyxel.text(12, 137, "OBSERVATIONS: KEY / BEFORE -> AFTER / CHANGED", 6)
        for line, (key, before, after) in enumerate(game.journal[-3:]):
            text = f"{'ABC'[key]}  {bits(before)} -> {bits(after)} / {bits(before ^ after)}"
            pyxel.text(12, 150 + line * 12, text, 7)
        pyxel.text(250, 180, "PRESSES " + str(len(game.journal)), 13)
        pyxel.text(12, 208, "CHANGED = BEFORE XOR AFTER", 10)
        pyxel.text(12, 228, "A: 000 -> 110 means A toggles 110.", 7)
        pyxel.text(12, 248, "Only the latest 3 observations are shown.", 6)
        pyxel.text(12, 310, "LEFT/RIGHT select  SPACE press", 7)
        pyxel.text(12, 326, "R new practice  ESC quit", 7)
        pyxel.text(12, 344, "SILENT / ASCII UI / 30 FPS", 5)
        # 12回目の描画後に保存・終了。実際のPyxel描画を使います。
        if frames == 12 and screenshot:
            path = Path(screenshot)
            path.parent.mkdir(parents=True, exist_ok=True)
            pyxel.screenshot(str(path), scale=2)
        if frames == 12 and headless_smoke:
            print("headless-smoke: 12 frames rendered", flush=True)
            pyxel.quit()

    pyxel.run(update, draw)


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="026 silent piano teaching stage")
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--screenshot", metavar="PATH")
    parser.add_argument("--demo", action="store_true", help="run deterministic scripted actions")
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo)
STEP 03 / 05

観測に合う候補を残す

まだ調べていないことと、推理でわかることを分ける。

元のゲームの C の面にある、4つの配線図を DIAGRAMS に入れます。本当の配線は、0から数えて番号2(画面では図3)に決まっています。notes の None は「まだ観察していない」ことを表します。candidates は、観察した鍵盤の mask と合う図だけを残す処理で、本当の配線の番号は使いません。known_masks は、残ったすべての図で値が同じ鍵盤だけを「わかったこと」とします。A を押すだけで、候補は図3と図4に絞れます。まだ押していない B の011も、残った図すべてで同じなので、わかったことになります。C は図によって違うので ??? です。

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

03_candidates.py / 注目する部分
        return tuple(i for i, row in enumerate(DIAGRAMS)
                     if all(mask is None or row[key] == mask
                            for key, mask in enumerate(self.notes)))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_candidates.py
macOS / Linux
.venv/bin/python 03_candidates.py
画面で見ること最初は COMPATIBLE(合う候補)が 4/4、KNOWN(わかったこと)はすべて ??? です。A を1回押すと110になり、o が図3と図4につき、KNOWN は「110 011 ???」になります。合わない図も、比べられるように x をつけて残します。R キーで観察も最初に戻ります。
  • 本当の配線の番号は、候補の絞り込みに使わない
  • まだ調べていないことと、推理でわかることを区別できる
  • 合わない図には x がつく
つまずきやすいところactual_index(本当の配線の番号)を使って候補を1枚にすると、まだ観察していない情報を使ってしまうことになります。また、notes[B] が None でも、残ったすべての図で B が同じなら、推理でわかったことにできます。
少し変えてみよう

A を押したあとに C を押して、図3だけが残ることを確かめましょう。次に、R→A のあとで B を押しても候補が2枚のままなのはなぜか、表の B の列を見て説明してみましょう。まだ成功の判定はありません。

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

# 上記は原作の通知全文です。この教材にNoto由来の画像・文字資産は含めません。
"""026 音のないピアノ / 教材 3/5。単独で実行できます。"""
import argparse
from pathlib import Path

# 1桁のbitは0(消灯)か1(点灯)。左から重み4, 2, 1です。
# 例: 110は4+2=6。0b110はPythonで2進数を書く記法です。
# XOR(^)は各桁を比較し、違うなら1、同じなら0にします。
# 灯り ^ 配線maskでは、maskが1の場所だけ0<->1が反転します。
# 000 ^ 110 = 110、110 ^ 110 = 000。足し算ではありません。
MASKS = (0b110, 0b011, 0b001)
# 原作C面の候補図4枚。各行はA, B, Cの反転マスクです。
DIAGRAMS = ((4, 1, 2), (4, 1, 6), (6, 3, 1), (6, 3, 7))
ACTUAL_INDEX = 2  # 0始まり。画面では図3。乱数で変えません。
WIDTH, HEIGHT = 384, 360
FPS = 30


def bits(value):
    """整数を左→右の3桁へ変換。100は左だけ点灯。"""
    return format(value, "03b")


class Game:
    def __init__(self, actual_index=ACTUAL_INDEX):
        if not 0 <= actual_index < len(DIAGRAMS):
            raise ValueError("actual diagram out of bounds")
        self.actual_index = actual_index
        self.lamps = 0
        self.journal = []  # (鍵番号, 押す前, 押した後)の累積記録
        self.notes = [None, None, None]  # Noneは未観測

    def press(self, key):
        if not isinstance(key, int) or isinstance(key, bool) or not 0 <= key < 3:
            return False
        before = self.lamps
        mask = DIAGRAMS[self.actual_index][key]
        self.lamps = before ^ mask
        self.journal.append((key, before, self.lamps))
        self.notes[key] = before ^ self.lamps
        return True

    def candidates(self):
        # 実物の番号は参照しない。知っている観測だけで比べる。
        return tuple(i for i, row in enumerate(DIAGRAMS)
                     if all(mask is None or row[key] == mask
                            for key, mask in enumerate(self.notes)))

    def known_masks(self):
        rows = [DIAGRAMS[i] for i in self.candidates()]
        known = []
        for key in range(3):
            values = {row[key] for row in rows}
            known.append(next(iter(values)) if len(values) == 1 else None)
        return tuple(known)


def main(headless_smoke=False, screenshot=None, demo=False):
    import pyxel  # 純粋なGameのテストだけならPyxelを読み込みません。
    game = Game()
    selected = 0
    frames = 0
    locked = False
    message = "LEFT/RIGHT select. SPACE presses one key."
    controls = [pyxel.KEY_LEFT, pyxel.KEY_RIGHT, pyxel.KEY_SPACE, pyxel.KEY_R]
    pyxel.init(WIDTH, HEIGHT, title="026 SILENT PIANO - STEP 3",
               fps=FPS, display_scale=2, headless=headless_smoke)

    def update():
        nonlocal game, selected, frames, locked, message
        frames += 1
        if demo:
            if frames == 2:
                game.press(0)
            message = "AUTO DEMO: scripted actions, not a human test."
            return
        # R/Z/Nの直後に押しっぱなしの別キーが流入しないよう全解放を待つ。
        if locked:
            if not any(pyxel.btn(key) for key in controls):
                locked = False
            return
        if pyxel.btnp(pyxel.KEY_R):
            game = Game()
            selected = 0
            locked = True
            message = "NEW PRACTICE: everything reset."
            return
        left = pyxel.btnp(pyxel.KEY_LEFT)
        right = pyxel.btnp(pyxel.KEY_RIGHT)
        if (left or right) and pyxel.btn(pyxel.KEY_LEFT) and pyxel.btn(pyxel.KEY_RIGHT):
            message = "Choose one direction at a time."
            return
        if left or right:
            selected = (selected + (-1 if left else 1)) % 3
            return
        if pyxel.btnp(pyxel.KEY_SPACE):
            game.press(selected)
            message = "Pressed " + "ABC"[selected] + ": compare before and after."

    def draw_lamps(x, y, value):
        for index, digit in enumerate(bits(value)):
            xx = x + index * 30
            pyxel.circ(xx, y, 10, 10 if digit == "1" else 0)
            pyxel.circb(xx, y, 10, 6)
            pyxel.text(xx - 2, y - 2, digit, 1 if digit == "1" else 7)

    def draw():
        pyxel.cls(1)
        pyxel.text(12, 10, "026 SILENT PIANO / STEP 3", 7)
        pyxel.text(12, 26, "NOW " + bits(game.lamps) + "    LEFT -> RIGHT", 6)
        draw_lamps(40, 57, game.lamps)
        pyxel.text(224, 44, "PRACTICE: NO GOAL YET", 13)
        for key in range(3):
            x = 12 + key * 120
            pyxel.rect(x, 79, 110, 30, 7)
            pyxel.rectb(x, 79, 110, 30, 10 if key == selected else 5)
            pyxel.text(x + 12, 92, "ABC"[key] + (" <SELECTED>" if key == selected else ""), 1)
        pyxel.text(12, 118, message, 7)
        pyxel.text(12, 137, "OBSERVATIONS: KEY / BEFORE -> AFTER / CHANGED", 6)
        for line, (key, before, after) in enumerate(game.journal[-3:]):
            text = f"{'ABC'[key]}  {bits(before)} -> {bits(after)} / {bits(before ^ after)}"
            pyxel.text(12, 150 + line * 12, text, 7)
        pyxel.text(250, 180, "PRESSES " + str(len(game.journal)), 13)
        alive = game.candidates()
        pyxel.text(12, 194, f"COMPATIBLE {len(alive)} / 4      A   B   C", 6)
        for index, row in enumerate(DIAGRAMS):
            ok = index in alive
            text = ("o " if ok else "x ") + str(index + 1) + "                 "
            text += " ".join(bits(mask) for mask in row)
            pyxel.text(12, 207 + index * 12, text, 7 if ok else 5)
        known = " ".join("???" if mask is None else bits(mask) for mask in game.known_masks())
        pyxel.text(12, 259, "KNOWN FROM OBSERVATIONS: " + known, 10)
        pyxel.text(12, 277, "Actual identity is not used to filter rows.", 13)
        pyxel.text(12, 310, "LEFT/RIGHT select  SPACE press", 7)
        pyxel.text(12, 326, "R new practice  ESC quit", 7)
        pyxel.text(12, 344, "SILENT / ASCII UI / 30 FPS", 5)
        # 12回目の描画後に保存・終了。実際のPyxel描画を使います。
        if frames == 12 and screenshot:
            path = Path(screenshot)
            path.parent.mkdir(parents=True, exist_ok=True)
            pyxel.screenshot(str(path), scale=2)
        if frames == 12 and headless_smoke:
            print("headless-smoke: 12 frames rendered", flush=True)
            pyxel.quit()

    pyxel.run(update, draw)


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="026 silent piano teaching stage")
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--screenshot", metavar="PATH")
    parser.add_argument("--demo", action="store_true", help="run deterministic scripted actions")
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo)
STEP 04 / 05

灯りを戻しても知識を残す

灯りの履歴と、観察の記録を別々に持つ。

history には、押す直前の灯りだけを積みます。Z キーは最後の1つを pop して灯りを戻しますが、notes と journal(わかったこと)は変えません。R キーも灯りを000にして、灯りの履歴だけを空にします。Z と R は「鍵盤を押す」操作ではないので、観察の行は増えません。取り消したあとで新しく押すと、今の灯りから続きます。取り消した先の履歴が戻ってくることはありません。実験をすべてやり直す N キーでは Game を作り直し、わかったことも消します。この段階では、まだ成功しても入力は止まりません。

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

04_undo.py / 注目する部分
        self.lamps = self.history.pop()

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_undo.py
macOS / Linux
.venv/bin/python 04_undo.py
画面で見ることA→B で101、Z で110、R で000に戻ります。どちらの場合も、候補2枚と PRESSES 2 は残ります。そのあと A を押すと PRESSES 3 になり、記録の「押す前の灯り」は000です。N を押すと、候補4枚・押した回数0に戻ります。
  • Z/Rは灯りだけを戻す
  • これまでの観察と候補は残る
  • Nはわかったことも含めて最初に戻る
つまずきやすいところGame 全体を保存して Z で戻すと、観察してわかったことまで消えてしまいます。取り消し用の history と、わかったことを残す journal は役割が違います。なお、観察の行と行の間で灯りを戻すと、前の行の after と次の行の before は同じになりません。
少し変えてみよう

A→B→Z→C の順に押して、灯りが111、PRESSES が3になることを確かめましょう。Z を2回押して000に戻したあと、3回目の Z では何も変わりません。記録は3件残っていることを確かめます。

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

# 上記は原作の通知全文です。この教材にNoto由来の画像・文字資産は含めません。
"""026 音のないピアノ / 教材 4/5。単独で実行できます。"""
import argparse
from pathlib import Path

# 1桁のbitは0(消灯)か1(点灯)。左から重み4, 2, 1です。
# 例: 110は4+2=6。0b110はPythonで2進数を書く記法です。
# XOR(^)は各桁を比較し、違うなら1、同じなら0にします。
# 灯り ^ 配線maskでは、maskが1の場所だけ0<->1が反転します。
# 000 ^ 110 = 110、110 ^ 110 = 000。足し算ではありません。
MASKS = (0b110, 0b011, 0b001)
# 原作C面の候補図4枚。各行はA, B, Cの反転マスクです。
DIAGRAMS = ((4, 1, 2), (4, 1, 6), (6, 3, 1), (6, 3, 7))
ACTUAL_INDEX = 2  # 0始まり。画面では図3。乱数で変えません。
WIDTH, HEIGHT = 384, 360
FPS = 30


def bits(value):
    """整数を左→右の3桁へ変換。100は左だけ点灯。"""
    return format(value, "03b")


class Game:
    def __init__(self, actual_index=ACTUAL_INDEX):
        if not 0 <= actual_index < len(DIAGRAMS):
            raise ValueError("actual diagram out of bounds")
        self.actual_index = actual_index
        self.lamps = 0
        self.journal = []  # (鍵番号, 押す前, 押した後)の累積記録
        self.notes = [None, None, None]  # Noneは未観測
        self.history = []  # 戻せる「物理的な灯り」だけの履歴

    def press(self, key):
        if not isinstance(key, int) or isinstance(key, bool) or not 0 <= key < 3:
            return False
        before = self.lamps
        mask = DIAGRAMS[self.actual_index][key]
        self.history.append(before)
        self.lamps = before ^ mask
        self.journal.append((key, before, self.lamps))
        self.notes[key] = before ^ self.lamps
        return True

    def candidates(self):
        # 実物の番号は参照しない。知っている観測だけで比べる。
        return tuple(i for i, row in enumerate(DIAGRAMS)
                     if all(mask is None or row[key] == mask
                            for key, mask in enumerate(self.notes)))

    def known_masks(self):
        rows = [DIAGRAMS[i] for i in self.candidates()]
        known = []
        for key in range(3):
            values = {row[key] for row in rows}
            known.append(next(iter(values)) if len(values) == 1 else None)
        return tuple(known)

    def undo(self):
        if not self.history:
            return False
        self.lamps = self.history.pop()
        # notes / journalは戻さない。「知ったこと」は残す。
        return True

    def reset_lamps(self):
        if not self.history and self.lamps == 0:
            return False
        self.lamps = 0
        self.history.clear()
        return True


def main(headless_smoke=False, screenshot=None, demo=False):
    import pyxel  # 純粋なGameのテストだけならPyxelを読み込みません。
    game = Game()
    selected = 0
    frames = 0
    locked = False
    message = "LEFT/RIGHT select. SPACE presses one key."
    controls = [pyxel.KEY_LEFT, pyxel.KEY_RIGHT, pyxel.KEY_SPACE, pyxel.KEY_R]
    controls += [pyxel.KEY_Z, pyxel.KEY_N]
    pyxel.init(WIDTH, HEIGHT, title="026 SILENT PIANO - STEP 4",
               fps=FPS, display_scale=2, headless=headless_smoke)

    def update():
        nonlocal game, selected, frames, locked, message
        frames += 1
        if demo:
            if frames in (2, 4, 10):
                selected = 1 if frames == 4 else 0
                game.press(selected)
            elif frames == 6:
                game.undo()
            elif frames == 8:
                game.reset_lamps()
            message = "AUTO DEMO: scripted actions, not a human test."
            return
        # R/Z/Nの直後に押しっぱなしの別キーが流入しないよう全解放を待つ。
        if locked:
            if not any(pyxel.btn(key) for key in controls):
                locked = False
            return
        if pyxel.btnp(pyxel.KEY_N):
            game = Game()
            selected = 0
            locked = True
            message = "NEW: lamps, observations and identity reset."
            return
        if pyxel.btnp(pyxel.KEY_R):
            game.reset_lamps()
            locked = True
            message = "RESET LAMPS: knowledge and press count stay."
            return
        if pyxel.btnp(pyxel.KEY_Z):
            changed = game.undo()
            locked = True
            message = "UNDO: knowledge stays." if changed else "No physical history to undo."
            return
        left = pyxel.btnp(pyxel.KEY_LEFT)
        right = pyxel.btnp(pyxel.KEY_RIGHT)
        if (left or right) and pyxel.btn(pyxel.KEY_LEFT) and pyxel.btn(pyxel.KEY_RIGHT):
            message = "Choose one direction at a time."
            return
        if left or right:
            selected = (selected + (-1 if left else 1)) % 3
            return
        if pyxel.btnp(pyxel.KEY_SPACE):
            game.press(selected)
            message = "Pressed " + "ABC"[selected] + ": compare before and after."

    def draw_lamps(x, y, value):
        for index, digit in enumerate(bits(value)):
            xx = x + index * 30
            pyxel.circ(xx, y, 10, 10 if digit == "1" else 0)
            pyxel.circb(xx, y, 10, 6)
            pyxel.text(xx - 2, y - 2, digit, 1 if digit == "1" else 7)

    def draw():
        pyxel.cls(1)
        pyxel.text(12, 10, "026 SILENT PIANO / STEP 4", 7)
        pyxel.text(12, 26, "NOW " + bits(game.lamps) + "    LEFT -> RIGHT", 6)
        draw_lamps(40, 57, game.lamps)
        pyxel.text(224, 44, "PRACTICE: NO GOAL YET", 13)
        for key in range(3):
            x = 12 + key * 120
            pyxel.rect(x, 79, 110, 30, 7)
            pyxel.rectb(x, 79, 110, 30, 10 if key == selected else 5)
            pyxel.text(x + 12, 92, "ABC"[key] + (" <SELECTED>" if key == selected else ""), 1)
        pyxel.text(12, 118, message, 7)
        pyxel.text(12, 137, "OBSERVATIONS: KEY / BEFORE -> AFTER / CHANGED", 6)
        for line, (key, before, after) in enumerate(game.journal[-3:]):
            text = f"{'ABC'[key]}  {bits(before)} -> {bits(after)} / {bits(before ^ after)}"
            pyxel.text(12, 150 + line * 12, text, 7)
        pyxel.text(250, 180, "PRESSES " + str(len(game.journal)), 13)
        alive = game.candidates()
        pyxel.text(12, 194, f"COMPATIBLE {len(alive)} / 4      A   B   C", 6)
        for index, row in enumerate(DIAGRAMS):
            ok = index in alive
            text = ("o " if ok else "x ") + str(index + 1) + "                 "
            text += " ".join(bits(mask) for mask in row)
            pyxel.text(12, 207 + index * 12, text, 7 if ok else 5)
        known = " ".join("???" if mask is None else bits(mask) for mask in game.known_masks())
        pyxel.text(12, 259, "KNOWN FROM OBSERVATIONS: " + known, 10)
        pyxel.text(12, 277, "UNDO / RESET: lamps change; knowledge stays.", 13)
        pyxel.text(12, 310, "LEFT/RIGHT select  SPACE press  Z undo", 7)
        pyxel.text(12, 326, "R reset lamps  N new experiment  ESC quit", 7)
        pyxel.text(12, 344, "SILENT / ASCII UI / 30 FPS", 5)
        # 12回目の描画後に保存・終了。実際のPyxel描画を使います。
        if frames == 12 and screenshot:
            path = Path(screenshot)
            path.parent.mkdir(parents=True, exist_ok=True)
            pyxel.screenshot(str(path), scale=2)
        if frames == 12 and headless_smoke:
            print("headless-smoke: 12 frames rendered", flush=True)
            pyxel.quit()

    pyxel.run(update, draw)


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="026 silent piano teaching stage")
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--screenshot", metavar="PATH")
    parser.add_argument("--demo", action="store_true", help="run deterministic scripted actions")
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo)
STEP 05 / 05

全配線を知らずに目標を作る

「目標と同じ灯り」と「配線をすべて当てる」を分ける。

TARGET=0b101 と今の灯りが同じなら成功です。候補が1枚に絞れたかどうかは、成功の条件ではありません。A→B で101になると、候補は2枚のまま、C は観察していないまま CLEAR です。成功している間は新しく押せませんが、Z や R で灯りを戻すと再開できます。opened は、一度成功すると本当の配線(番号3)を公開し、Z や R のあとも残ります。ただし、candidates と known_masks は、公開された番号を使いません。N キーで実験全体を最初に戻します。

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

05_goal.py / 注目する部分
    def won(self):
        return self.lamps == TARGET

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_goal.py
macOS / Linux
.venv/bin/python 05_goal.py
画面で見ること最初の状態から Space→右→Space で CLEAR になります。COMPATIBLE は 2/4、KNOWN は「110 011 ???」、ACTUAL(本当の配線)は3、PRESSES は2です。Z で110に戻しても、ACTUAL 3 とわかったことは残ります。R では000、N では押した回数0・未公開の状態に戻ります。
  • A→Bで目標101へ到達する
  • 候補2枚・Cを観察していなくても成功する
  • Z/Rでも、公開された情報と観察は残る
つまずきやすいところ「候補が1つになったら成功」にしたり、公開された本当の番号で候補を絞ったりすると、元のゲームとは別のゲームになります。「答えを見て知ること」と「押して観察してわかること」は別です。
少し変えてみよう

ACTUAL_INDEX を0に変えて、新しい実験で A→B を試してみましょう。今度は100→101になりますが、候補2枚・C は観察していないまま成功します。試したら2に戻して、テストをもう一度実行しましょう。

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

# 上記は原作の通知全文です。この教材にNoto由来の画像・文字資産は含めません。
"""026 音のないピアノ / 教材 5/5。単独で実行できます。"""
import argparse
from pathlib import Path

# 1桁のbitは0(消灯)か1(点灯)。左から重み4, 2, 1です。
# 例: 110は4+2=6。0b110はPythonで2進数を書く記法です。
# XOR(^)は各桁を比較し、違うなら1、同じなら0にします。
# 灯り ^ 配線maskでは、maskが1の場所だけ0<->1が反転します。
# 000 ^ 110 = 110、110 ^ 110 = 000。足し算ではありません。
MASKS = (0b110, 0b011, 0b001)
# 原作C面の候補図4枚。各行はA, B, Cの反転マスクです。
DIAGRAMS = ((4, 1, 2), (4, 1, 6), (6, 3, 1), (6, 3, 7))
ACTUAL_INDEX = 2  # 0始まり。画面では図3。乱数で変えません。
TARGET = 0b101
WIDTH, HEIGHT = 384, 360
FPS = 30


def bits(value):
    """整数を左→右の3桁へ変換。100は左だけ点灯。"""
    return format(value, "03b")


class Game:
    def __init__(self, actual_index=ACTUAL_INDEX):
        if not 0 <= actual_index < len(DIAGRAMS):
            raise ValueError("actual diagram out of bounds")
        self.actual_index = actual_index
        self.lamps = 0
        self.journal = []  # (鍵番号, 押す前, 押した後)の累積記録
        self.notes = [None, None, None]  # Noneは未観測
        self.history = []  # 戻せる「物理的な灯り」だけの履歴
        self.opened = False  # 成功後に実物の番号を開封する

    def won(self):
        return self.lamps == TARGET

    def press(self, key):
        if not isinstance(key, int) or isinstance(key, bool) or not 0 <= key < 3:
            return False
        if self.won():
            return False
        before = self.lamps
        mask = DIAGRAMS[self.actual_index][key]
        self.history.append(before)
        self.lamps = before ^ mask
        self.journal.append((key, before, self.lamps))
        self.notes[key] = before ^ self.lamps
        self.opened = self.opened or self.won()
        return True

    def candidates(self):
        # 実物の番号は参照しない。知っている観測だけで比べる。
        return tuple(i for i, row in enumerate(DIAGRAMS)
                     if all(mask is None or row[key] == mask
                            for key, mask in enumerate(self.notes)))

    def known_masks(self):
        rows = [DIAGRAMS[i] for i in self.candidates()]
        known = []
        for key in range(3):
            values = {row[key] for row in rows}
            known.append(next(iter(values)) if len(values) == 1 else None)
        return tuple(known)

    def undo(self):
        if not self.history:
            return False
        self.lamps = self.history.pop()
        # notes / journal / openedは戻さない。「知ったこと」は残す。
        return True

    def reset_lamps(self):
        if not self.history and self.lamps == 0:
            return False
        self.lamps = 0
        self.history.clear()
        return True


def main(headless_smoke=False, screenshot=None, demo=False):
    import pyxel  # 純粋なGameのテストだけならPyxelを読み込みません。
    game = Game()
    selected = 0
    frames = 0
    locked = False
    message = "LEFT/RIGHT select. SPACE presses one key."
    controls = [pyxel.KEY_LEFT, pyxel.KEY_RIGHT, pyxel.KEY_SPACE, pyxel.KEY_R]
    controls += [pyxel.KEY_Z, pyxel.KEY_N]
    pyxel.init(WIDTH, HEIGHT, title="026 SILENT PIANO - STEP 5",
               fps=FPS, display_scale=2, headless=headless_smoke)

    def update():
        nonlocal game, selected, frames, locked, message
        frames += 1
        if demo:
            if frames in (2, 4):
                selected = 0 if frames == 2 else 1
                game.press(selected)
            message = "AUTO DEMO: scripted actions, not a human test."
            return
        # R/Z/Nの直後に押しっぱなしの別キーが流入しないよう全解放を待つ。
        if locked:
            if not any(pyxel.btn(key) for key in controls):
                locked = False
            return
        if pyxel.btnp(pyxel.KEY_N):
            game = Game()
            selected = 0
            locked = True
            message = "NEW: lamps, observations and identity reset."
            return
        if pyxel.btnp(pyxel.KEY_R):
            game.reset_lamps()
            locked = True
            message = "RESET LAMPS: knowledge and press count stay."
            return
        if pyxel.btnp(pyxel.KEY_Z):
            changed = game.undo()
            locked = True
            message = "UNDO: knowledge stays." if changed else "No physical history to undo."
            return
        if game.won():
            return
        left = pyxel.btnp(pyxel.KEY_LEFT)
        right = pyxel.btnp(pyxel.KEY_RIGHT)
        if (left or right) and pyxel.btn(pyxel.KEY_LEFT) and pyxel.btn(pyxel.KEY_RIGHT):
            message = "Choose one direction at a time."
            return
        if left or right:
            selected = (selected + (-1 if left else 1)) % 3
            return
        if pyxel.btnp(pyxel.KEY_SPACE):
            game.press(selected)
            message = "Pressed " + "ABC"[selected] + ": compare before and after."

    def draw_lamps(x, y, value):
        for index, digit in enumerate(bits(value)):
            xx = x + index * 30
            pyxel.circ(xx, y, 10, 10 if digit == "1" else 0)
            pyxel.circb(xx, y, 10, 6)
            pyxel.text(xx - 2, y - 2, digit, 1 if digit == "1" else 7)

    def draw():
        pyxel.cls(1)
        pyxel.text(12, 10, "026 SILENT PIANO / STEP 5", 7)
        pyxel.text(12, 26, "NOW " + bits(game.lamps) + "    LEFT -> RIGHT", 6)
        draw_lamps(40, 57, game.lamps)
        pyxel.text(224, 26, "TARGET " + bits(TARGET), 10)
        draw_lamps(244, 57, TARGET)
        for key in range(3):
            x = 12 + key * 120
            pyxel.rect(x, 79, 110, 30, 7)
            pyxel.rectb(x, 79, 110, 30, 10 if key == selected else 5)
            pyxel.text(x + 12, 92, "ABC"[key] + (" <SELECTED>" if key == selected else ""), 1)
        pyxel.text(12, 118, message, 7)
        pyxel.text(12, 137, "OBSERVATIONS: KEY / BEFORE -> AFTER / CHANGED", 6)
        for line, (key, before, after) in enumerate(game.journal[-3:]):
            text = f"{'ABC'[key]}  {bits(before)} -> {bits(after)} / {bits(before ^ after)}"
            pyxel.text(12, 150 + line * 12, text, 7)
        pyxel.text(250, 180, "PRESSES " + str(len(game.journal)), 13)
        alive = game.candidates()
        pyxel.text(12, 194, f"COMPATIBLE {len(alive)} / 4      A   B   C", 6)
        for index, row in enumerate(DIAGRAMS):
            ok = index in alive
            text = ("o " if ok else "x ") + str(index + 1) + "                 "
            text += " ".join(bits(mask) for mask in row)
            pyxel.text(12, 207 + index * 12, text, 7 if ok else 5)
        known = " ".join("???" if mask is None else bits(mask) for mask in game.known_masks())
        pyxel.text(12, 259, "KNOWN FROM OBSERVATIONS: " + known, 10)
        actual = str(game.actual_index + 1) if game.opened else "?"
        pyxel.text(12, 277, "ACTUAL " + actual + "   " + ("CLEAR: MATCHED TARGET!" if game.won() else "MATCH THE TARGET TO WIN"), 10)
        pyxel.text(12, 291, "Exact wiring identification is NOT required.", 13)
        pyxel.text(12, 310, "LEFT/RIGHT select  SPACE press  Z undo", 7)
        pyxel.text(12, 326, "R reset lamps  N new experiment  ESC quit", 7)
        pyxel.text(12, 344, "SILENT / ASCII UI / 30 FPS", 5)
        # 12回目の描画後に保存・終了。実際のPyxel描画を使います。
        if frames == 12 and screenshot:
            path = Path(screenshot)
            path.parent.mkdir(parents=True, exist_ok=True)
            pyxel.screenshot(str(path), scale=2)
        if frames == 12 and headless_smoke:
            print("headless-smoke: 12 frames rendered", flush=True)
            pyxel.quit()

    pyxel.run(update, draw)


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="026 silent piano teaching stage")
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--screenshot", metavar="PATH")
    parser.add_argument("--demo", action="store_true", help="run deterministic scripted actions")
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo)
AFTER STEP 05

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

元のゲームには、手作りの3つの問題があります。鍵盤は決まった配線(mask)で3つの灯りを XOR で入れ替え、目標と同じになれば成功です。候補は、押して観察した結果だけで絞ります。Z と R は灯りと灯りの履歴だけを戻し、観察の記録・押した回数・公開された本当の番号は残ります。時間や回数の制限、失敗したときの罰はありません。

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

比べる点5ステップの教材元の完成版
問題最後の段階は、元のゲームのC「知らなくても弾ける二鍵」の1問だけ。4つの候補・3つの鍵盤・本当の配線は図3・目標は101。A・B・Cの3問。Bには鍵盤が4つある。
段階1〜4は練習用で、成功しても止まらない。1〜3のRはすべて初期化。4・5のRは灯りだけ戻し、Nですべて初期化。すべての問題で、成功すると止まる。Rは灯りだけ戻す。Mで面を選び直すとすべて初期化、成功後のNは次の面へ。
表示英字(ASCII)と図形。候補は数字で表示し、観察は最新の3行だけ表示。すべての記録は中に残っている。日本語・ピアノの絵・候補ごとの小さな灯り。上下キーですべての観察の記録を見られる。
操作と資料面の選択・一時停止・観察のスクロール・F12・リプレイの書き出しはなし。--demo は短い自動チェック用。タイトル、面の選択、Pで一時停止、上下でスクロール、F12で記録、長い自動映像。
中心のルール最後の段階は、C面の入れ替え・観察による候補・わかった mask・成功で止まる・undo/reset・公開された情報が残る、のすべてが元のゲームと同じ。音なし。同じ中心のルールを3問で使う。音なし。

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

  1. 元のゲームの model.py の Observation.mask と transition を読み、押す前と後の XOR からわかることを比べましょう。
  2. 元のゲームの model.py の candidates と known_masks で、本当の番号を使わずに、観察だけから推理する作り方を読みましょう。
  3. 元のゲームの model.py の transition で、undo や reset のあとも notes・journal・opened が残ることを確かめましょう。
  4. 元のゲームの app.py の update で、M で選び直す・P で止める・上下で観察をスクロールする処理を探しましょう。
  5. 元のゲームの app.py の draw で、最新3行だけの表示を、過去の観察も読める表示に広げているところを見てみましょう。
自分のゲームに変えるなら

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

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