# 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字形・フォントは含みません。Pyxel標準ASCIIと図形だけです。 """まちがい時計・教材 1/5。原作Cを学ぶ独立プログラム。""" from dataclasses import dataclass, replace CLOCKS = ('A', 'B', 'C', 'D') MARGIN = 2 VALUES = tuple(range(-MARGIN, MARGIN + 1)) STAGE = 1 STAGE_TITLE = 'FINITE OFFSETS' CONTROLS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'R') REPLAY = {'L': 'LEFT', 'T': 'RIGHT', 'U': 'UP', 'D': 'DOWN', 'R': 'R'} HELP = 'EXPERIMENT: 5 values per clock; 5 x 5 x 5 x 5 = 625 assignments.' @dataclass(frozen=True) class State: values: tuple = (-2, -2, -2, -2) cursor: int = 0 solution_index: int = -1 reason: str = 'ready' def edit(state, delta): """範囲の外は拒否。tupleを作り直して古い状態を壊さない。""" values = list(state.values) value = values[state.cursor] + delta if value not in VALUES: return replace(state, reason='boundary') values[state.cursor] = value return replace(state, values=tuple(values), solution_index=-1, reason='edited') class Controller: """画面とテストが共用する入力入口。押したら全キーを離す。""" def __init__(self): self.state = State() self.gate = False def tick(self, pressed=(), held=()): pressed = set(pressed) & set(CONTROLS) held = set(held) & set(CONTROLS) if self.gate: if not held: self.gate = False return if len(pressed) != 1 or len(held) != 1 or pressed != held: return key = next(iter(pressed)) self.gate = True if key == 'R': self.state = State() elif key in ('LEFT', 'RIGHT'): delta = 1 if key == 'RIGHT' else -1 self.state = replace(self.state, cursor=(self.state.cursor + delta) % len(CLOCKS)) elif key in ('UP', 'DOWN'): self.state = edit(self.state, 1 if key == 'UP' else -1) def snapshot(self): return {'values': list(self.state.values), 'focus': CLOCKS[self.state.cursor], 'solution_index': self.state.solution_index, 'reason': self.state.reason} def draw_board(pyxel, controller): state = controller.state pyxel.cls(1) pyxel.text(12, 10, 'WRONG CLOCKS / STEP ' + str(STAGE) + ' / ' + STAGE_TITLE, 10) pyxel.text(12, 27, 'Original case C: four clocks; integer offsets, NOT a 12-hour cycle.', 6) pyxel.rect(8, 42, 464, 36, 2) pyxel.text(16, 49, 'OFFSET = shown time minus actual time', 7) pyxel.text(16, 64, '0: correct +: ahead -: behind RANGE -2 .. +2', 10) for index, clock in enumerate(CLOCKS): x = 16 + 116 * index pyxel.rect(x, 89, 100, 100, 2) pyxel.circ(x + 50, 123, 25, 5) pyxel.circ(x + 50, 123, 22, 1) pyxel.text(x + 48, 121, clock, 10) pyxel.text(x + 30, 158, 'OFFSET ' + f'{state.values[index]:+d}', 7) if index == state.cursor: pyxel.rectb(x - 2, 87, 104, 104, 7) pyxel.text(12, 204, 'ONE ASSIGNMENT: ' + ' '.join(f'{c}={v:+d}' for c, v in zip(CLOCKS, state.values)), 7) pyxel.text(12, 230, 'Try changing one clock without changing the other three.', 6) pyxel.text(12, 250, 'Bounds are inclusive: -2, -1, 0, +1, +2.', 7) pyxel.text(12, 274, 'Teaching experiment: evidence and correct-count checks come next.', 13) pyxel.text(12, 365, 'RESULT: ' + state.reason.upper(), 11) pyxel.text(12, 383, 'LEFT/RIGHT: clock UP/DOWN: offset R: reset ESC: quit', 7) pyxel.text(12, 400, HELP, 6) def main(headless=False, actions='', screenshot=None, report=None): import json from pathlib import Path # 相対出力先は起動時の作業フォルダー基準。ソースの場所には依存しない。 screenshot = str(Path(screenshot).resolve()) if screenshot else None report = str(Path(report).resolve()) if report else None import pyxel controller = Controller() codes = {key: getattr(pyxel, 'KEY_' + key) for key in CONTROLS} frame = 0 events = [] last_frame = max(12, len(actions) * 2 + 2) def update(): nonlocal frame frame += 1 if headless: index = (frame - 1) // 2 if frame % 2 and index < len(actions): key = REPLAY[actions[index]] controller.tick((key,), (key,)) events.append({'input': actions[index], 'snapshot': controller.snapshot()}) else: controller.tick() else: controller.tick([key for key, code in codes.items() if pyxel.btnp(code)], [key for key, code in codes.items() if pyxel.btn(code)]) def draw(): draw_board(pyxel, controller) if headless and frame >= last_frame: if screenshot: pyxel.screenshot(screenshot, scale=1) if report: Path(report).write_text(json.dumps({ 'label': 'Actual Pyxel headless rendering; synthetic controller inputs; silent', 'stage': STAGE, 'frames': frame, 'actions': actions, 'events': events, 'snapshot': controller.snapshot(), }, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') pyxel.quit() pyxel.init(480, 420, title='Wrong Clocks - Step ' + str(STAGE), fps=30, headless=headless) pyxel.run(update, draw) if __name__ == '__main__': import argparse parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--headless-smoke', action='store_true') parser.add_argument('--headless-replay', help='Synthetic inputs: ' + ', '.join(k + '=' + v for k, v in REPLAY.items())) parser.add_argument('--screenshot', help='PNG output; headless only') parser.add_argument('--report', help='UTF-8 JSON output; headless only') args = parser.parse_args() if args.headless_smoke and args.headless_replay is not None: parser.error('Choose smoke or replay') headless = args.headless_smoke or args.headless_replay is not None if not headless and (args.screenshot or args.report): parser.error('Outputs require headless mode') actions = args.headless_replay or '' if any(key not in REPLAY for key in actions): parser.error('Unsupported input for this stage') main(headless, actions, args.screenshot, args.report)