# 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。""" """066 Deterministic 60-tick musical clock. No rendering or audio dependencies.""" from dataclasses import dataclass, asdict, replace import hashlib, json BAR = 120 ROOFS = ('木', '瓦', 'トタン') @dataclass(frozen=True) class Level: id: str title: str main: tuple[int, ...] decor: tuple[int, ...] rests: tuple[int, ...] bars: int = 4 def build(id, title, patterns): main = tuple((b * BAR + t for b, pat in enumerate(patterns) for t in pat)) decor = tuple((b * BAR + t for b in range(4) for t in (15, 45, 75, 105))) rests = tuple((b * BAR + t for b, pat in enumerate(patterns) for t in (0, 30, 60, 90) if t not in pat)) return Level(id, title, main, decor, rests) LEVELS = (build('066-A-v1', 'まっすぐの雨', ((0, 30, 60, 90),) * 4), build('066-B-v1', '空いた三拍目', ((0, 30, 90),) * 4), build('066-C-v1', '返事を待つ軒', ((0, 60, 90), (0, 30, 90), (0, 60, 90), (0, 30, 90)))) @dataclass(frozen=True) class Settings: correction: int = 0 tin_delay: int = 45 speed: int = 100 control: str = 'asd' def __post_init__(self): if not -6 <= self.correction <= 6: raise ValueError('correction -6..6') if self.tin_delay not in (15, 45): raise ValueError('tin delay 15 or45') if self.speed not in (75, 100): raise ValueError('speed 75 or100') if self.control not in ('asd', 'arrows'): raise ValueError('unknown input mode') @dataclass(frozen=True) class Reservation: id: int roof: int target: int reserved_at: int source_slot: int @dataclass(frozen=True) class Event: tick: int kind: str roof: int = -1 slot: int = -1 reservation: int = -1 @dataclass(frozen=True) class Input: tick: int roof: int corrected: int kind: str slot: int accepted: bool @dataclass(frozen=True) class State: tick: int main: tuple[int, ...] decor: tuple[bool, ...] reservations: tuple[Reservation, ...] = () events: tuple[Event, ...] = () inputs: tuple[Input, ...] = () done: bool = False def initial(level): return State(-BAR, tuple((0 for _ in level.main)), tuple((False for _ in level.decor))) def delay(roof, settings): return (0, 15, settings.tin_delay)[roof] def close_tick(level, settings): return max(max(level.main) + 4, max(level.decor) + 3) - settings.correction def ending_tick(level, settings): return max(max(level.main), max(level.decor)) + max(15, settings.tin_delay) + 10 def match_slot(s, level, settings): corrected = s.tick + settings.correction for i, t in enumerate(level.main): if abs(corrected - t) <= 4: return ('main', i, t, s.main[i] == 0) for i, t in enumerate(level.decor): if abs(corrected - t) <= 3: return ('decor', i, t, not s.decor[i]) return ('outside', -1, -1, False) def future(s): groups = {} for r in s.reservations: if r.target >= s.tick: groups.setdefault(r.target, []).append(r) out = [] for target, rs in sorted(groups.items()): order = sorted(rs, key=lambda r: (r.reserved_at, r.roof, r.id)) out.extend(((r, r == order[0]) for r in order)) return tuple(out) def reservation_preview(s, level, settings, roof): corrected = s.tick + settings.correction slots = [t for t, v in zip(level.main, s.main) if v == 0 and corrected <= t + 4] slots += [t for t, v in zip(level.decor, s.decor) if not v and corrected <= t + 3] if not slots: return None slot = min(slots) d = delay(roof, settings) target = slot + d if d else None return {'slot': slot, 'target': target, 'overlap': bool(d and any((r.target == target for r in s.reservations)))} def step(s, level, settings, roof=None): """Process raw tick t, manual input, due echo, misses; then advance to t+1.""" if s.done: return s if roof is not None and roof not in (0, 1, 2): raise ValueError('roof must be0/1/2') t = s.tick main = list(s.main) decor = list(s.decor) reservations = list(s.reservations) events = list(s.events) inputs = list(s.inputs) if roof is not None and t <= close_tick(level, settings): kind, i, slot, accepted = match_slot(s, level, settings) inputs.append(Input(t, roof, t + settings.correction, kind, slot, accepted)) if accepted: if kind == 'main': main[i] = 1 else: decor[i] = True events.append(Event(t, 'manual', roof, slot)) d = delay(roof, settings) if d: assert slot + d > t, 'A legal reply must be future, never retroactive.' reservations.append(Reservation(len(reservations), roof, slot + d, t, slot)) else: events.append(Event(t, 'wrong', roof, slot)) elif roof is not None: events.append(Event(t, 'closed', roof)) if t < 0 and t % 30 == 0: events.append(Event(t, 'count')) due = sorted((r for r in reservations if r.target == t), key=lambda r: (r.reserved_at, r.roof, r.id)) if due: for r in due: events.append(Event(t, 'echo', r.roof, r.source_slot, r.id)) for i, slot in enumerate(level.main): if main[i] == 0 and t + settings.correction > slot + 4: main[i] = -1 events.append(Event(t, 'miss', -1, slot)) return State(t + 1, tuple(main), tuple(decor), tuple(reservations), tuple(events), tuple(inputs), t >= ending_tick(level, settings)) def stats(s, level): hit = sum((x == 1 for x in s.main)) dec = sum(s.decor) echoes = [e for e in s.events if e.kind == 'echo'] manual = [e for e in s.events if e.kind == 'manual'] echo_targets = {e.tick for e in echoes} overlaps = sum((1 for e in manual if e.slot in level.decor and e.slot in echo_targets)) used = {e.slot for e in manual} | echo_targets return {'hits': hit, 'total': len(level.main), 'hit_percent': 100 * hit // len(level.main), 'complete': hit * 4 >= len(level.main) * 3, 'decorations': dec, 'automatic_replies': len(echoes), 'overlaid_slots': overlaps, 'blank_decorations': sum((t not in used for t in level.decor)), 'reservation_collisions': sum((e.kind == 'suppressed' for e in s.events)), 'wrong': sum((e.kind == 'wrong' for e in s.events)), 'manual_hits': len(manual)} def normalized(v): return json.dumps(v, ensure_ascii=False, sort_keys=True, separators=(',', ':')) def digest(v): return hashlib.sha256(normalized(v).encode('utf-8')).hexdigest() STAGE = 3 # Display uses ASCII so this one file needs no glyph or image assets. # The original sound definitions are retained, without claiming audible testing. SOUNDS = (('c2c1', 't', '64', 'ff', 4), ('f4c4', 'p', '54', 'ff', 3), ('c3c2', 'n', '43', 'ff', 4), ('c4', 't', '3', 'f', 3)) COLORS = (9, 12, 6) class Controller: """Pyxel-independent input edges and score clock. tick is the NEXT raw tick.""" def __init__(self): self.level_index = 0 self.settings = Settings() self.frame = 0 self.previous = set() self.pending = [] self.audio_log = [] self.reset(stop=False) @property def level(self): return LEVELS[self.level_index] @property def state(self): return self.session.state if STAGE == 5 else self._state def reset(self, stop=True): if stop: self.stop_audio() if STAGE == 5: self.session = new_session(self.level, self.settings) else: self._state = initial(self.level) self.running = False self.title = True self.paused = False self.gate = False self.countdown = 0 self.accum = 0 self.flashes = [-1000000] * 3 self.caption = 'PRESS ENTER TO START. SPACE = ONE SCORE TICK.' def emit(self, kind, tick, channel=None, sound=None): item = {'kind': kind, 'tick': tick, 'frame': self.frame, 'channel': channel, 'sound': sound} self.pending.append(item) self.audio_log.append(item.copy()) def stop_audio(self): self.emit('stop', self.state.tick) self.flashes = [-1000000] * 3 def apply_tick(self, roof=None): if self.state.done: return before = len(self.state.events) if STAGE == 5: self.session = session_step(self.session, self.level, roof) else: self._state = step(self.state, self.level, self.settings, roof) for event in self.state.events[before:]: if event.kind == 'manual': self.emit('manual', event.tick, event.roof, event.roof) self.flashes[event.roof] = self.frame self.caption = 'MANUAL raw %d / score %d' % (event.tick, event.slot) elif event.kind == 'echo': self.emit('echo', event.tick, 3, event.roof) self.flashes[event.roof] = self.frame self.caption = 'ECHO target %d / source %d' % (event.tick, event.slot) elif event.kind == 'count': self.emit('count', event.tick, 3, 3) elif event.kind == 'suppressed': self.caption = 'SAME TARGET: EARLIER RESERVATION WINS' elif event.kind == 'wrong': self.caption = 'OUTSIDE WINDOW OR SLOT ALREADY USED' if self.state.done: self.running = False self.gate = True def rewind(self, previous=False): if STAGE != 5: return self.stop_audio() self.session = restart_bar(self.session, self.level, previous) self.countdown = BAR self.accum = 0 self.running = True self.paused = False self.gate = True self.caption = 'RESTART: SCORE FROZEN DURING 120-TICK COUNT' def control(self, down): """One call per renderer frame; fresh is set difference, never key repeat.""" self.frame += 1 down = set(down) fresh = down - self.previous self.previous = down if self.gate: if not down: self.gate = False return if 'R' in fresh: self.reset() self.gate = True return if self.title: changed = False if STAGE >= 2: correction = self.settings.correction if 'LEFT' in fresh: correction = max(-6, correction - 1) elif 'RIGHT' in fresh: correction = min(6, correction + 1) changed = correction != self.settings.correction self.settings = replace(self.settings, correction=correction) if STAGE >= 4: for i, key in enumerate(('1', '2', '3')): if key in fresh: self.level_index = i changed = True break if 'B' in fresh: self.settings = replace(self.settings, tin_delay=15 if self.settings.tin_delay == 45 else 45) changed = True if 'V' in fresh: self.settings = replace(self.settings, speed=75 if self.settings.speed == 100 else 100) changed = True if changed: self.reset() self.gate = True return if 'RETURN' in fresh: self.title = False self.gate = True self.caption = 'SPACE: STEP / ENTER: AUTO / A S D: ONE FRESH HIT' return if STAGE == 5 and 'P' in fresh: if self.paused: self.rewind(False) else: self.paused = True self.running = False self.stop_audio() self.gate = True return if self.paused: return if STAGE == 5 and 'X' in fresh: self.rewind(True) return if self.state.done: return if 'RETURN' in fresh: self.running = not self.running if not self.running: self.stop_audio() self.gate = True return # A chord is one hit; the canonical order is A, then S, then D. roof = next((i for i, key in enumerate(('A', 'S', 'D')) if key in fresh and (STAGE >= 3 or i == 0)), None) if not self.running: if 'SPACE' in fresh or roof is not None: self.apply_tick(roof) return # 30 renderer frames/sec; 2 logical ticks/frame (or 1,2,1,2 at 75%). self.accum += 4 if self.settings.speed == 100 else 3 dt, self.accum = divmod(self.accum, 2) if self.countdown: if self.countdown % 30 < dt: self.emit('restart_count', self.state.tick, 3, 3) self.countdown = max(0, self.countdown - dt) if not self.countdown: self.gate = True return for i in range(dt): self.apply_tick(roof if i == 0 else None) def flush_audio(self, pyxel): """Drain only the new effects; drawing or draining twice never replays them.""" pending, self.pending = self.pending, [] for event in pending: if event['kind'] == 'stop': pyxel.stop() else: pyxel.play(event['channel'], event['sound']) return pending def demo(self): self.title = False inputs = {0: 0, 30: 0, 60: 0} if STAGE < 3 else {0: 2, 30: 1, 60: 0} if STAGE == 2: inputs[15] = 0 while self.state.tick <= 75: self.frame += 1 self.apply_tick(inputs.get(self.state.tick)) self.caption = ('CHECKPOINT: 3 MAIN HITS' if STAGE < 3 else '0+45 = 30+15: SAME TARGET 45') def draw(self, p): s = self.state stat = stats(s, self.level) p.cls(1) p.text(14, 10, 'RAIN ROOF DRUMS / STAGE %d / %s' % (STAGE, self.level.id), 10) p.text(14, 27, 'NEXT RAW %d / CORRECTED %d / SPEED %d%%' % (s.tick, s.tick + self.settings.correction, self.settings.speed), 7) mode = 'TITLE' if self.title else 'PAUSED' if self.paused else 'RESULT' if s.done else 'AUTO' if self.running else 'STEP' p.text(14, 44, mode + ' / 60 SCORE TICKS = 1 SEC AT 100% / 120 = ONE BAR', 6) p.text(14, 63, 'MAIN %d/%d DECOR %d ECHO %d SUPPRESSED %d' % (stat['hits'], stat['total'], stat['decorations'], stat['automatic_replies'], stat['reservation_collisions']), 11) bar = max(0, min(3, max(0, s.tick) // BAR)) for row in range(2): start = (bar + row) * BAR y = 88 + row * 80 p.rect(12, y, 616, 71, 0) label = 'BAR %d' % (bar + row + 1) if bar + row < 4 else 'TAIL' p.text(18, y + 4, '%s / SCORE %d..%d' % (label, start, start + 119), 6) for t in range(start, start + 120, 15): x = 85 + (t - start) * 4 p.line(x, y + 20, x, y + 63, 5) p.text(x - 4, y + 11, str(t), 5) if t in self.level.main: value = s.main[self.level.main.index(t)] p.rectb(x - 4, y + 26, 9, 9, 11 if value == 1 else 8 if value == -1 else 7) elif t in self.level.rests: p.text(x - 5, y + 28, '-', 13) if STAGE >= 2 and t in self.level.decor: p.circb(x, y + 44, 3, 10 if s.decor[self.level.decor.index(t)] else 5) if STAGE >= 3: for reservation, active in future(s): if start <= reservation.target < start + 120: x = 85 + (reservation.target - start) * 4 color = COLORS[reservation.roof] if active or STAGE == 3 else 5 if reservation.roof == 1: p.circb(x, y + 54, 5, color) else: p.tri(x, y + 49, x - 4, y + 57, x + 4, y + 57, color) if start <= s.tick < start + 120: x = 85 + (s.tick - start) * 4 p.line(x, y + 21, x, y + 64, 10) for i, name in enumerate(('A WOOD', 'S TILE +15', 'D TIN +%d' % self.settings.tin_delay)): x = 18 + i * 207 enabled = i == 0 or STAGE >= 3 # Age is zero on the event frame; ages 0..4 light five frames. lit = 0 <= self.frame - self.flashes[i] < 5 p.tri(x, 286, x + 92, 250, x + 183, 286, COLORS[i] if lit else 5) p.rectb(x + 12, 287, 158, 28, COLORS[i] if enabled else 5) p.text(x + 22, 298, name if enabled else 'ADDED IN STAGE 03', 7 if enabled else 5) p.text(14, 326, self.caption, 10) p.text(14, 344, 'LAST EVENTS: raw tick / kind / roof / source slot', 6) for i, e in enumerate(s.events[-6:]): p.text(22, 358 + i * 12, '%4d %-10s roof %2d score %4d' % (e.tick, e.kind, e.roof, e.slot), 7) if STAGE >= 3: p.text(365, 344, 'NEXT REPLIES', 6) pending = future(s)[:4] for i, (reservation, active) in enumerate(pending): p.text(365, 360 + i * 12, '%d %s %s' % (reservation.target, ('WOOD', 'TILE', 'TIN')[reservation.roof], 'ACTIVE' if active or STAGE == 3 else 'MUTED'), COLORS[reservation.roof] if active or STAGE == 3 else 5) if not pending: p.text(365, 360, '(none)', 5) if self.countdown: p.text(365, 415, 'RESTART COUNT %d' % self.countdown, 10) if s.done: p.text(365, 415, 'COMPLETE' if stat['complete'] else 'TRY AGAIN', 11) p.text(14, 438, 'ENTER: START / AUTO-STEP TOGGLE SPACE: ONE TICK R: RESET', 7) p.text(14, 453, ('A: WOOD' if STAGE < 3 else 'A/S/D: ROOFS') + ' ESC: QUIT HOLD = NO REPEAT', 7) p.text(14, 468, 'TITLE: LEFT/RIGHT CORRECTION' if STAGE >= 2 else 'STAGE 01: MAIN BEATS ONLY / NO REPLIES', 6) if STAGE >= 4: p.text(300, 468, '1/2/3 CHART B DELAY V SPEED', 6) p.text(14, 484, 'P: PAUSE / RESTART CURRENT BAR X: PREVIOUS BAR' if STAGE == 5 else 'SAVE EDIT -> QUIT -> RUN THIS SAME FILE AGAIN', 13) if self.title: p.rect(70, 106, 500, 100, 1) p.rectb(70, 106, 500, 100, 12) p.text(85, 120, 'PRESS ENTER. THEN SPACE STEPS OR ENTER RUNS.', 10) p.text(85, 140, 'MAIN: +/-4 TICKS. DECOR FROM STAGE 02: +/-3.', 7) p.text(85, 160, 'NEXT RAW = TICK THAT THE NEXT UPDATE WILL PROCESS.', 6) p.text(85, 180, 'R RESETS STATE. IT DOES NOT RELOAD SAVED CODE.', 13) def main(headless=False, screenshot=None, report=None, checkpoint=False): # CLI validates and resolves paths before reaching this import. import pyxel as p p.init(640, 504, title='Rain roof lesson %d' % STAGE, fps=30, display_scale=1, headless=headless) for i, sound in enumerate(SOUNDS): p.sounds[i].set(*sound) app = Controller() actual_calls = [] if checkpoint: app.title = False keys = {k: getattr(p, 'KEY_' + k) for k in ('RETURN', 'SPACE', 'R', 'A', 'S', 'D', 'P', 'X', 'LEFT', 'RIGHT', '1', '2', '3', 'B', 'V')} def update(): if not headless: app.control({k for k, value in keys.items() if p.btn(value)}) elif checkpoint and app.state.tick <= 75: app.frame += 1 inputs = {0: 0, 30: 0, 60: 0} if STAGE < 3 else {0: 2, 30: 1, 60: 0} if STAGE == 2: inputs[15] = 0 for _ in range(2): if app.state.tick <= 75: app.apply_tick(inputs.get(app.state.tick)) actual_calls.extend(app.flush_audio(p)) if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count >= 3 and (not checkpoint or app.state.tick > 75): app.stop_audio() actual_calls.extend(app.flush_audio(p)) app.draw(p) if screenshot: p.screen.save(screenshot, 1) if report: from pathlib import Path payload = {'stage': STAGE, 'state': asdict(app.state), 'stats': stats(app.state, app.level), 'audio_calls': actual_calls, 'scope': 'Actual Pyxel API calls; dummy driver is not audible verification', 'screenshot': screenshot} Path(report).write_text(json.dumps(payload, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') p.quit() p.run(update, lambda: app.draw(p)) if __name__ == '__main__': import argparse from pathlib import Path parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--headless-smoke', action='store_true') parser.add_argument('--demo-checkpoint', action='store_true') parser.add_argument('--screenshot') parser.add_argument('--report') args = parser.parse_args() if (args.demo_checkpoint or args.screenshot or args.report) and not args.headless_smoke: parser.error('checkpoint and output files require --headless-smoke') for name, suffix in (('screenshot', '.png'), ('report', '.json')): value = getattr(args, name) if value: path = Path(value).resolve() if path.suffix.lower() != suffix or not path.parent.is_dir() or path.exists(): parser.error('use a NEW ' + suffix + ' file in an existing directory') if path.parent == Path(__file__).resolve().parent: parser.error('output must be outside the script directory') setattr(args, name, str(path)) main(args.headless_smoke, args.screenshot, args.report, args.demo_checkpoint)