# 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. """ねじまき工房・教材1/5。整数の玩具で、実機の設計・安全の助言ではありません。""" from dataclasses import dataclass, replace, asdict import math @dataclass(frozen=True) class Level: id:str title:str edges:tuple start:tuple rest:int limit:int=24 LEVELS=( Level('A','平たい舞台',('DA','AL','DR','AR'),('DA','AL','DR'),2), Level('B','乾きかけの糊床',('DA','AL','DB','BR','DR'),('DA','AL','DB','BR'),3), Level('C','中継を一つ省く',('DA','AB','BL','AL','DR','CR','DC'),('DA','AB','BL','DR'),2)) def signs_for(level,mask): adj={n:[] for n in 'DABCLR'} for i,e in enumerate(level.edges): if mask>>i&1:adj[e[0]].append((e[1],e));adj[e[1]].append((e[0],e)) signs={'D':1};queue=['D'];conflict='' for a in queue: for b,e in adj[a]: if b not in signs:signs[b]=-signs[a];queue.append(b) elif signs[b]!=-signs[a] and not conflict:conflict=e return tuple(sorted(signs.items())),conflict @dataclass(frozen=True) class State: edges: int teeth: tuple = (4, 4) offsets: tuple = (3, 3) t: int = 0 status: str = 'edit' fired: tuple = () def initial(level): return State(sum(1 << i for i, edge in enumerate(level.edges) if edge in level.start)) def tick(level, s): if s.status != 'run': return s signs, conflict = signs_for(level, s.edges) return replace(s, t=s.t + 1, status='fault' if conflict else ('time' if s.t + 1 >= level.limit else 'run')) STAGE = 1 class App: def __init__(self): self.level = 0 self.mode = 'base' self.reset() @property def board(self): return LEVELS[self.level] def reset(self): self.state = initial(self.board) self.focus = 0 self.cursor = 0 self.pending = None self.gated = False self.message = 'READY' def commit(self, action): s = self.state if action == 'start' and s.status == 'edit': self.state = replace(s, status='run') elif action == 'tick': self.state = tick(self.board, s) elif s.status == 'edit': parts = action.split(':') i = int(parts[1]) if parts[0] == 'edge': self.state = replace(s, edges=s.edges ^ (1 << i)) else: values = list(s.teeth if parts[0] == 'teeth' else s.offsets) values[i] = int(parts[2]) if parts[0] == 'teeth': offsets = tuple(o % n for o, n in zip(s.offsets, values)) self.state = replace(s, teeth=tuple(values), offsets=offsets) else: self.state = replace(s, offsets=tuple(values)) def press(self, key): if key == 'R': self.reset() return if key in ('1', '2', '3'): self.level = int(key) - 1 self.reset() return if key == 'RETURN': self.commit('start') return if key == 'SPACE': self.commit('tick') return if self.state.status != 'edit': return if key == 'TAB': self.focus = (self.focus + 1) % 1 self.cursor = 0 self.pending = None return size = len(self.board.edges) if self.focus == 0 else 2 if key in ('LEFT', 'RIGHT'): self.cursor = (self.cursor + (-1 if key == 'LEFT' else 1)) % size self.pending = None elif key in ('UP', 'DOWN'): if self.focus == 0: self.cursor = (self.cursor + (-1 if key == 'UP' else 1)) % size else: s = self.state value = self.pending if self.pending is not None else (s.teeth[self.cursor] if self.focus == 1 else s.offsets[self.cursor]) self.pending = 12 - value if self.focus == 1 else (value + (1 if key == 'UP' else -1)) % s.teeth[self.cursor] elif key == 'Z': if self.focus == 0: self.commit('edge:' + str(self.cursor)) else: value = self.pending if self.pending is not None else (self.state.teeth[self.cursor] if self.focus == 1 else self.state.offsets[self.cursor]) self.commit(('teeth' if self.focus == 1 else 'cam') + ':' + str(self.cursor) + ':' + str(value)) self.pending = None def control(self, pressed, held): # Release gate: never turn a held key or a rejected chord into repeat input. if self.gated: if not pressed and not held: self.gated = False return if len(pressed | held) > 1: self.gated = True self.message = 'RELEASE ALL KEYS / CHORD REJECTED' return if len(pressed) == 1: self.press(next(iter(pressed))) self.gated = True def draw(self, p): s = self.state signs, conflict = signs_for(self.board, s.edges) directions = dict(signs) p.cls(1) p.text(12,12,'CLOCKWORK WORKSHOP / STAGE ' + str(STAGE) + ' / LEVEL ' + self.board.id,10) p.text(12,26,'INTEGER TOY / NOT REAL GEAR DESIGN OR SAFETY ADVICE',6) p.text(12,44,'TICK ' + str(s.t) + ' / 24 STATUS ' + s.status.upper(),7) coords = {'D':(60,155),'A':(165,105),'B':(270,105),'C':(270,215),'L':(390,105),'R':(390,215)} for i, edge in enumerate(self.board.edges): a, b = coords[edge[0]], coords[edge[1]] color = 10 if self.focus == 0 and self.cursor == i else (11 if s.edges >> i & 1 else 5) p.line(*a,*b,color) p.text((a[0]+b[0])//2,(a[1]+b[1])//2,edge,color) nodes = set('DLR') | set(''.join(self.board.edges)) for name in nodes: x,y = coords[name] sign = 0 if conflict else directions.get(name,0) i = 'LR'.find(name) teeth = s.teeth[i] if i >= 0 else 4 offset = s.offsets[i] if i >= 0 else 0 p.circ(x,y,22,4) p.circb(x,y,22,9) phase = (sign * s.t + offset) % teeth angle = phase / teeth * math.tau - math.pi / 2 p.line(x,y,x+math.cos(angle)*18,y+math.sin(angle)*18,8) p.text(x-3,y-32,name,10) p.text(x-16,y+28,('+' if sign>0 else '-' if sign<0 else 'STOP'),11) p.text(456,70,'D = +1',10) p.text(456,85,'ONE EDGE FLIPS SIGN',7) p.text(456,100,'ONLY D COMPONENT MOVES',7) p.text(456,115,'L NEEDS + / R NEEDS -',7) p.text(456,135,'JAM: '+(conflict or 'NONE'),8 if conflict else 11) p.text(12,263,'ONLY THE LINES CONNECT SHAFTS. NEARBY GEARS DO NOT AUTO-CONNECT.',6) p.text(12,281,'FIELD '+('EDGES','TEETH','CAM')[self.focus]+' / TARGET '+(self.board.edges[self.cursor]+(' ON' if s.edges>>self.cursor&1 else ' OFF') if self.focus==0 else 'LR'[self.cursor]),10) p.text(270,281,'CANDIDATE '+str(self.pending) if self.pending is not None else 'NO UNCOMMITTED CANDIDATE',6) p.text(12,307,'DIRECTIONS '+str(signs),7) p.text(12,329,'NO CAM EVENTS, WALKING, FORECAST OR UNDO IN THIS STAGE.',6) p.text(12,419,'LEFT/RIGHT SELECT / Z TOGGLE / ENTER START / SPACE ONE TICK',7) p.text(12,442,self.message,10) p.text(12,458,'R RESET / 1-3 LEVEL / ESC QUIT / RELEASE EACH KEY',6) def demo_keys(): return ('RETURN','SPACE') def main(headless=False, screenshot=None, demo=False): import pyxel as p p.init(700,480,title='Clockwork lesson '+str(STAGE),fps=30,display_scale=2,headless=headless) app = App() sequence = demo_keys() if demo else () keys = {k:getattr(p,'KEY_'+k) for k in ('LEFT','RIGHT','UP','DOWN','TAB','RETURN','SPACE','Z','X','R','1','2','3','B')} limit = max(20, 2*len(sequence)+8) def update(): if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count >= limit: if screenshot: app.draw(p) p.screen.save(screenshot,1) p.quit() if headless: if demo: index = p.frame_count // 2 pressed = {sequence[index]} if p.frame_count % 2 == 0 and index < len(sequence) else set() app.control(pressed,pressed) else: app.control({k for k,v in keys.items() if p.btnp(v)}, {k for k,v in keys.items() if p.btn(v)}) 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') args = parser.parse_args() if args.demo_checkpoint and not args.headless_smoke: parser.error('demo requires headless mode') if args.screenshot and not args.headless_smoke: parser.error('screenshot requires headless mode') # Resolve against launch cwd BEFORE importing or initializing Pyxel. if args.screenshot: target = Path(args.screenshot).resolve() if target.suffix.lower() != '.png' or not target.parent.is_dir() or target.exists(): parser.error('use a new .png in an existing directory') args.screenshot = str(target) main(args.headless_smoke,args.screenshot,args.demo_checkpoint)