# 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と図形だけです。 """まちがい時計・教材 4/5。原作Cを学ぶ独立プログラム。""" from dataclasses import dataclass, replace CLOCKS = ('A', 'B', 'C', 'D') MARGIN = 2 VALUES = tuple(range(-MARGIN, MARGIN + 1)) # (手前, 矢印の先, 先のずれ - 手前のずれ) EDGES = (('A', 'B', 1), ('B', 'C', 1), ('C', 'D', 1)) def difference_checks(values): offsets = dict(zip(CLOCKS, values)) return tuple(offsets[target] - offsets[source] == delta for source, target, delta in EDGES) def consistent(values): """ずれの範囲、正確は1台、すべての差が同時に成り立つ。""" return (len(values) == len(CLOCKS) and all(type(value) is int and value in VALUES for value in values) and values.count(0) == 1 and all(difference_checks(values))) def assignments(): """5通りを4台ぶん掛けた625通り。条件を満たす行だけ残す。""" from itertools import product return tuple(values for values in product(VALUES, repeat=len(CLOCKS)) if consistent(values)) def possible_correct(solutions): """どれか1行で0なら候補。すべての行で0である必要はない。""" return frozenset(clock for values in solutions for clock, value in zip(CLOCKS, values) if value == 0) def witnesses(clock, solutions): if clock not in CLOCKS: raise ValueError('Unknown clock') index = CLOCKS.index(clock) return tuple(values for values in solutions if values[index] == 0) STAGE = 4 STAGE_TITLE = 'POSSIBLE CORRECT CLOCKS' CONTROLS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'R', 'N', 'X') REPLAY = {'L': 'LEFT', 'T': 'RIGHT', 'U': 'UP', 'D': 'DOWN', 'R': 'R', 'N': 'N', 'X': 'X'} HELP = 'N: next assignment X: witness for focused clock (if one exists)' @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) elif key == 'N': solutions = assignments() index = (self.state.solution_index + 1) % len(solutions) self.state = replace(self.state, values=solutions[index], solution_index=index, reason='consistent_assignment') elif key == 'X': found = witnesses(CLOCKS[self.state.cursor], assignments()) if found: self.state = replace(self.state, values=found[0], solution_index=-1, reason='witness') else: self.state = replace(self.state, reason='no_witness') 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) checks = difference_checks(state.values) for index, (source, target, delta) in enumerate(EDGES): actual = state.values[CLOCKS.index(target)] - state.values[CLOCKS.index(source)] label = f'{target} - {source} = {delta:+d} actual {actual:+d} ' + ('PASS' if checks[index] else 'FAIL') pyxel.text(12, 226 + index * 16, label, 11 if checks[index] else 8) pyxel.text(12, 282, 'ZERO COUNT: ' + str(state.values.count(0)) + ' FULL RULE: ' + ('VALID' if consistent(state.values) else 'INVALID'), 10) pyxel.text(12, 300, '625 assignments searched / ' + str(len(assignments())) + ' consistent rows', 6) possible = possible_correct(assignments()) pyxel.text(12, 320, 'POSSIBLE (=0 in ANY row): ' + ' '.join(sorted(possible)), 11) certain = [c for i, c in enumerate(CLOCKS) if all(v[i] == 0 for v in assignments())] pyxel.text(12, 338, 'CERTAIN (=0 in EVERY row): ' + (' '.join(certain) or '(none)'), 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)