# 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] + (" " 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)