# 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. """ねじまき工房・教材5/5。整数の玩具で、実機の設計・安全の助言ではありません。""" """Finite clockwork toy. Pure integer model; no Pyxel, clock, I/O or randomness.""" from dataclasses import dataclass, replace, asdict import hashlib,json VERSION='071-v1' @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)) @dataclass(frozen=True) class State: edges:int teeth:tuple=(4,4) offsets:tuple=(3,3) t:int=0 signs:tuple=() pos:int=0 next_leg:str='L' previous:int=-1 steps:tuple=() status:str='edit' reason:str='接続とカムを選んで、試運転へ。' basic:bool=False @dataclass(frozen=True) class Timeline: state:State past:tuple=() decisions:int=0 MAX_DECISIONS=256 def initial(level):return State(sum(1<>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 def rest(level,mode='base'):return 1 if mode=='compare' and level.id=='A' else level.rest def tick(level,s,mode='base'): if s.status!='run':return s sg,conflict=signs_for(level,s.edges);d=dict(sg);t=s.t+1 if conflict:return replace(s,t=t,signs=sg,status='fault',reason='奇数閉路で停止。原因候補 '+conflict+' を外して確認。') fired=tuple(leg for i,leg in enumerate('LR') if leg in d and (d[leg]*t+s.offsets[i])%s.teeth[i]==0) out=replace(s,t=t,signs=sg,reason='脚のカムを待っています。') bad=[leg for leg in fired if d[leg]!=(1 if leg=='L' else -1)] if bad:return replace(out,status='fault',reason=('左' if bad[0]=='L' else '右')+'脚は後ろへ蹴る向きです。') if len(fired)==2:return replace(out,status='fault',reason='両脚が同時に上がります。カムをずらそう。') if fired: leg=fired[0] if leg!=s.next_leg:return replace(out,status='fault',reason='同じ脚の番が続きます。次は'+('左' if s.next_leg=='L' else '右')+'脚。') if s.previous>=0 and t-s.previous=4,reason=('左' if leg=='L' else '右')+'脚で一歩。'+('4歩の依頼を達成。Enterで引渡し可。' if pos==4 else '')) if pos>=6:return replace(out,status='done',reason='6歩の小舞台も完歩。基本の依頼も達成。') if t>=level.limit:return replace(out,status='time',reason='24刻の試運転終了。'+('基本の4歩は達成済み。' if out.basic else '接続・周期・カムを見直そう。')) return out def act(level,tl,action,mode='base'): if action=='undo':return Timeline(tl.past[-1],tl.past[:-1],tl.decisions) if tl.past else tl if tl.decisions>=MAX_DECISIONS:return tl s=tl.state;n=s if action=='start' and s.status=='edit':n=replace(s,status='run',reason='Spaceで1刻。Pで連続運転と停止。') elif action=='tick':n=tick(level,s,mode) elif action=='deliver' and s.basic:n=replace(s,status='delivered',reason='4歩の人形を届けました。追加試験は任意です。') elif s.status=='edit': p=action.split(':') if len(p)==2 and p[0]=='edge' and p[1].isdigit() and int(p[1])a.pos,'status':n.status,'reason':n.reason}) a=n return tuple(out) def simulate(level,s,mode='base'): s=replace(s,status='run') while s.status=='run':s=tick(level,s,mode) return s def canonical(value):return json.dumps(value,ensure_ascii=False,sort_keys=True,separators=(',',':')) def digest(value):return hashlib.sha256(canonical(value).encode('utf-8')).hexdigest() def replay(level,actions,mode='base'): tl=new(level);rows=[] for a in actions: tl=act(level,tl,a,mode);rows.append({'action':a,'state':asdict(tl.state),'sha256':digest(asdict(tl.state))}) return {'version':VERSION,'level':asdict(level),'mode':mode,'actions':actions,'rows':rows,'final':asdict(tl.state),'sha256':digest(asdict(tl.state))} import math STAGE = 5 class App: def __init__(self): self.level = 0 self.mode = 'base' self.reset() @property def board(self): return LEVELS[self.level] @property def state(self): return self.timeline.state def reset(self): self.timeline = new(self.board) self.focus = 0 self.cursor = 0 self.pending = None self.gated = False self.message = 'READY' def commit(self, action): old = self.timeline self.timeline = act(self.board, old, action, self.mode) if old.decisions >= MAX_DECISIONS and action != 'undo': self.message = str(MAX_DECISIONS) + ' DECISIONS USED / X MAY UNDO / R STARTS A NEW TRIAL' 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 == 'B': self.level = 0 self.mode = 'compare' if self.mode == 'base' else 'base' self.reset() return if key == 'X': self.pending = None self.commit('undo') return if key == 'RETURN': self.commit('deliver' if self.state.basic else 'start') return if key == 'SPACE': self.commit('tick') return if self.state.status != 'edit': return if key == 'TAB': self.focus = (self.focus + 1) % 3 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,305,'TEETH '+str(s.teeth)+' / CAM '+str(s.offsets),7) p.text(456,165,'STEPS '+str(s.pos)+' / 4',11) p.text(456,185,'NEXT LEG '+s.next_leg,7) p.text(456,205,'REST '+str(rest(self.board,self.mode))+' TICKS',7) p.text(456,225,'BASIC '+str(s.basic).upper(),11 if s.basic else 6) p.text(12,322,'FOOTPRINTS '+str(s.steps),11) p.text(12,340,'RESULT '+reason_label(s),8 if s.status=='fault' else 10) p.text(12,360,'FORECAST / SAME TICK FUNCTION',10) for i,row in enumerate(forecast(self.board,s,self.mode)): x=12+i*85 p.rectb(x,374,80,34,5) p.text(x+4,381,str(row['t'])+' '+(''.join(row['fire']) or '-'),7) p.text(x+4,394,'STEP' if row['step'] else row['status'].upper(),11 if row['step'] else 6) p.text(456,245,'DECISIONS '+str(self.timeline.decisions)+' / '+str(MAX_DECISIONS),6) p.text(12,430,'X UNDO / B A-REST COMPARE / HISTORY '+str(len(self.timeline.past)),6) p.text(12,419,'TAB FIELD / ARROWS CANDIDATE / Z COMMIT / ENTER START-DELIVER / SPACE 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 reason_label(s): if s.status == 'delivered': return 'DELIVERED / BASIC COMPLETE' if s.status == 'done': return 'OPTIONAL 6 STEPS COMPLETE / BASIC RETAINED' if s.status == 'time': return 'TIME LIMIT / BASIC RETAINED' if s.basic else 'TIME LIMIT / TRY ANOTHER SETTING' if s.status != 'fault': return 'BASIC RETAINED / ENTER CAN DELIVER' if s.basic else s.status.upper() for phrase,label in (('奇数閉路','ODD CYCLE IN D COMPONENT'),('後ろへ','WRONG LEG DIRECTION'),('同時','BOTH LEGS AT ONCE'),('同じ脚','WRONG NEXT LEG'),('間隔','REST TOO SHORT')): if phrase in s.reason: return label + (' / BASIC RETAINED' if s.basic else '') return 'FAULT' def demo_keys(): return ('RETURN',)+('SPACE',)*7+('RETURN','X') 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)