パズルLESSON 84接続グラフと整数位相の機構パズル原作071

ねじまき工房の作り方

かみ合う線と時刻から、人形の4歩へ。
動力につながった軸に回る向きを伝え、歯の数とカム(ずらし)で、左右の脚が動くタイミングを決めます。同じルールで計算する予告と、判断ごとに戻せる Undo も作ります。

完成版の遊び方つなぎ方と歯の数とカムを調整して、左右交互の4歩を、木の人形にプレゼントします。

ねじまき工房の元の完成版。自動操作によるPyxelの実描画画面。
元の完成版Bの自動描画です。下の導入はAの接続例から説明し、歯数・カム・歩行・予告・Undoを段階的に加えます。
PLAY / 試験公開

完成サンプルをブラウザーで遊ぶ

この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。

完成版の主な操作:Tab接続/歯数/カム、左右対象/上下値/Z確定、Enter試運転、Space一刻、X戻す

ボタンを押すと、ゲームの読み込みが始まります。

画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。

うまく表示されないとき

ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。

同じPyxelアプリをダウンロード(.pyxapp) / 別のタブで開く

つなぎ方と、整数のくり返しの周期から、交互の1歩を作る。

いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、5つのステップで作っていきます。どのステップのファイルもそれだけで動くので、動きを確かめてから次へ進みましょう。

1. ビットで表す選択(ビットマスク)・つながりの表・D からの幅優先探索
2. 割った余り・0への到着・候補と確定した値を分ける
3. 書き換えない状態と、止まる条件の優先順位
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
変数・if・for・関数 / tuple と dict / dataclass と replace は本文で説明します / ビット演算と剰余(割った余り)は小さな例から
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
左右:接続線または左右の脚を選ぶ / 上下:値の候補を変更 / Tab:02以降、接続/歯数/カムを切替 / Z:線を入切、候補を確定 / Enter:試運転、03以降は基本達成後の引渡し / Space:一刻だけ進める / X:05のみ一判断Undo / 1/2/3:A/B/Cへ切替・初期化 / B:03以降、Aの必要間隔2/1を比較・初期化 / R:同じコードで現在状況を初期化 / Esc:終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_connections.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。

教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。

STEP 01 / 05

線をたどると、回転の向きが決まる

ビットで表す選択(ビットマスク)・つながりの表・D からの幅優先探索。

まずは「線でつながっている軸だけが回る」という小さな実験を完成させます。左上の D が動力で、回る向きの符号は +1 です。1本かみ合うたびに符号が反対になり、D→A→L なら +、−、+ です。最初の状況 A では DA・AL・DR がつながっていて、左の L は +、右の R は − です。歯車の絵が近くにあるだけでは、つながっていません。左右キーで線を選び、Z でつなぐ・外すを切り替え、Enter で試運転、Space 1回で1刻進みます。

Level.edges は線の候補を並べた tuple、State.edges は使う線をビットで表した整数です。状況 A では DA が1、AL が2、DR が4、AR が8 です。最初の値7は 1+2+4 です。「mask >> i & 1」で i 番目の線が入っているかを読み、「mask ^ (1 << i)」でそこだけを反転します。OR で入れ続ける操作とは違い、同じ Z で線を外せます。

signs_for は、まず両方向のつながりの表を作り、D だけを順番待ちの列 queue に入れます。まだ符号の決まっていない軸に、反対の符号を割り当てていきます。すでに決めた符号と食い違えば、奇数本の線でできた輪(奇数閉路)です。すべての軸から勝手に探索を広げないことが大切で、D から切り離された三角形は、その場で止まるだけです。つながっている動力まで止める原因にはしません。

この段階には、脚の動き、歩行、予告、Undo はありません。Space で時計と針だけが進み、D につながる部分に食い違いがあれば fault(故障)、24刻で time(時間切れ)です。試運転の途中で線を直すことはできないので、R で戻します。描画は30fpsですが、ルール上の時刻は Space でしか進みません。

下のコードは 01_connections.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

01_connections.py / 注目する部分
 signs={'D':1};queue=['D'];conflict=''

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 01_connections.py
macOS / Linux
.venv/bin/python 01_connections.py
画面で見ること最初の状況 A は、D+、A−、L+、R− です。Enter → Space で TICK 1・RUN になります。R → 右を3回 → Z で AR を足し、Enter → Space と押すと、FAULT・JAM(かみ合わない)と、原因の候補の線が表示されます。
  • 線の番号とビットを対応させる
  • 1本ごとに、回る向きの符号を反対にする
  • D につながる部分だけを調べる
つまずきやすいところすべての軸を出発点にして探索すると、D と関係のない奇数閉路まで、全体を止める原因にしてしまいます。queue の最初は D 1つだけです。conflict は、原因がその1本だけだと証明するものではなく、探索で見つかった食い違いの候補です。
少し変えてみよう

R → Z で、最初に選ばれている DA を外し、Enter → Space と押してみましょう。D と R だけが回り、A と L は STOP(止まる)です。次に exercises/01_disconnected.py を実行し、D から切り離された AB-BC-CA の三角形が conflict にならないことを調べます。さらに、LEVELS の A の最初のつながり ('DA','AL','DR') を ('AL','DR') に変え、保存 → 閉じる → 実行し直します。最初から D/R だけのつながりになることを確かめ、元に戻します。

01_connections.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(252行)
01_connections.py / 全文
# 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)
STEP 02 / 05

歯数を周期に、カムを時刻のずれにする

割った余り・0への到着・候補と確定した値を分ける。

同じつなぎ方の図に、左右別々の歯の数とカムを加えます。Tab で「つなぎ方 → 歯の数 → カム」を切り替えます。左右キーで L/R を選び、上下キーで候補の値を変え、Z で確定します。歯の数は4か8、カムは0から(歯の数−1)までです。候補を動かしている間は、State も時計も変えません。NO UNCOMMITTED CANDIDATE(未確定の候補なし)が候補の値に変わったことと、TEETH/CAM の確定した値を見比べましょう。

毎刻の目盛りの位置(位相)は「(符号×時刻+カム) % 歯の数」です。割った余り % で、0から(歯の数−1)の範囲に折り返します。Python ではマイナスの数の余りでも、右の R の −1 によって 3→2→1→0 と進む位相が得られます。歯の数が4なら4刻ごと、8なら8刻ごとに同じ目盛りに戻ります。これは整数で作ったおもちゃのルールで、現実の歯車の回る速さの比を求める模型ではありません。

tick は、先に t を1つ増やし、その新しい時刻で0になったかを調べます。始めたときにカム0でも、その瞬間には脚は上がりません。止まっている軸は、位相が0でも対象になりません。最初の状況 A では、L が1刻目、R が3刻目に0に着きます。この段階の ARRIVED AT ZERO は、直前の1刻で起きたことで、それまでの足跡の一覧ではありません。

まだ人形は歩かず、左右同時・逆向き・順番・休みで止まる判定もありません。両方が0に着けば LR と表示して続けます。このわざと簡単にした部分を、次の歩く段階の成功の条件と混同しないようにしましょう。つなぎ方の奇数閉路だけは、引き続き止まります。

下のコードは 02_cam_phase.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

02_cam_phase.py / 注目する部分
                  if leg in directions and (directions[leg] * t + s.offsets[i]) % s.teeth[i] == 0)

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 02_cam_phase.py
macOS / Linux
.venv/bin/python 02_cam_phase.py
画面で見ることEnter → Space で ARRIVED AT ZERO:L です。もう1刻 Space を押すと NONE、さらに1刻で R です。R → Tab → 上 と押すと候補の8だけが変わり、Z を押すまでは TEETH (4,4) のままです。
  • 候補を動かすだけでは、State を書き換えない
  • 新しい時刻で0に着いたときだけ、脚が動く
  • つながっていない脚は対象から外す
つまずきやすいところ始めたときに位相0を見て、脚を動かさないでください。t=0 を判定してから進めると、そのあとの脚が動く時刻も1刻ずれてしまいます。
少し変えてみよう

State の offsets: tuple = (3, 3) を (0, 0) に1か所だけ変えてみましょう。保存 → Esc で終了 → 同じファイルを実行し直します。Enter で試運転を始め、一度キーを離してから Space を1回ずつ4回押します。始めたときと Space 3回目までは NONE、4回目で LR です。確認したら (3, 3) に戻して、保存・実行し直します。R では、書き換えたコードは読み直されません。

02_cam_phase.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(259行)
02_cam_phase.py / 全文
# 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.

"""ねじまき工房・教材2/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)
    t = s.t + 1
    if conflict:
        return replace(s, t=t, status='fault', fired=())
    directions = dict(signs)
    fired = tuple(leg for i, leg in enumerate('LR')
                  if leg in directions and (directions[leg] * t + s.offsets[i]) % s.teeth[i] == 0)
    return replace(s, t=t, fired=fired, status='time' if t >= level.limit else 'run')

STAGE = 2

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) % 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(12,325,'ARRIVED AT ZERO: '+(''.join(s.fired) or 'NONE'),11)
        p.text(12,346,'EVENTS ONLY. NO DIRECTION, ORDER OR REST FAULTS YET.',6)
        p.text(12,419,'TAB FIELD / ARROWS CANDIDATE / Z COMMIT / ENTER START / 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 demo_keys():
    return ('RETURN','SPACE','SPACE','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)
STEP 03 / 05

左右のイベントを、四歩の依頼につなぐ

書き換えない状態と、止まる条件の優先順位。

ここで、元のゲームの State と tick を取り入れ、人形の歩数、次に出す脚、前の1歩の時刻、足跡、basic(基本の目標達成)を持たせます。書き換えない dataclass を replace で置きかえ、描画はその状態を読むだけです。判定の順番は、① D につながる部分の奇数閉路 → ② 動いた脚の向き → ③ 両脚が同時 → ④ 次の脚か → ⑤ 前の1歩からの間隔、です。いくつもの条件がまちがっているとき、どの原因を先に知らせるかも、ルールの一部です。

正しい向きは、L が +、R が − です。最初の1歩は L、次は R、その次は L です。状況 A/C では2刻、B では3刻以上あけます。間隔は直前の描画のフレームではなく、今回の t−previous(前の1歩の時刻)です。最初の1歩は previous=-1 なので、間隔は調べません。歩いたあとで、previous と next_leg(次の脚)をまとめて更新します。

最初の状況 A は、設定を変えなくても、1L・3R・5L・7R の4歩を達成します。最初から解ける例をわざわざ壊す必要はありません。4歩で basic=True になり、Enter で人形をプレゼントできます。Space で続けた先の6歩は自由な目標です。24刻目の処理では、その刻の1歩を先に数えてから、時間切れを判定します。4歩の達成は、そのあとの試しで起きた fault/time でも消えません。

1/2/3 で状況 A/B/C を選べます。状況 B の最初の状態は右脚が逆向きで、1刻目に両脚が動いても、まず右脚の向きの理由を出します。B キーは状況 A に移って、必要な間隔だけを 2↔1 で比べます。つなぎ方、歯の数、カム、目標は同じです。まだ Undo と予告はありません。R で最初からやり直し、原因を1つずつ変えていきましょう。

下のコードは 03_walking.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

03_walking.py / 注目する部分
  if s.previous>=0 and t-s.previous<rest(level,mode):return replace(out,status='fault',reason='歩の間隔が不足。必要 '+str(rest(level,mode))+' 刻。')

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 03_walking.py
macOS / Linux
.venv/bin/python 03_walking.py
画面で見ること最初の状況 A で Enter → Space を7回押します。STEPS 4・BASIC TRUE、足跡は (1,L)(3,R)(5,L)(7,R) です。Enter で DELIVERED(プレゼント完了)。R → 2 → Enter → Space なら、WRONG LEG DIRECTION(脚の向きがちがう)です。
  • 止まる理由の順番を守る
  • 最初の状況 A の4歩を確かめる
  • 4歩と、自由な目標の6歩を分ける
つまずきやすいところ両脚が同時かの判定を前に動かすと、元のゲームの状況 B で最初に出る止まる理由が変わってしまいます。また、basic を毎刻「pos>=4」だけから作り直すような別の仕様を混ぜず、元のゲームの「s.basic or pos>=4」をそのまま使います。
少し変えてみよう

State の offsets:tuple=(3,3) を (3,2) に変え、保存 → 閉じる → 実行し直してみましょう。状況 A で Enter → Space 2回で REST TOO SHORT(休みが短すぎる)です。B キーで比較版に切り替え → Enter → Space 6回なら、4歩になります。同じ設定で、必要な間隔だけが違うことを確かめ、最後に (3,3) に戻します。

03_walking.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(299行)
03_walking.py / 全文
# 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。整数の玩具で、実機の設計・安全の助言ではありません。"""
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))

@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

def initial(level):return State(sum(1<<i for i,e in enumerate(level.edges) if e in level.start))

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

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<rest(level,mode):return replace(out,status='fault',reason='歩の間隔が不足。必要 '+str(rest(level,mode))+' 刻。')
  pos=s.pos+1
  out=replace(out,pos=pos,next_leg='R' if leg=='L' else 'L',previous=t,steps=s.steps+((t,leg),),basic=s.basic or pos>=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

STAGE = 3

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, self.mode)
        elif action == 'deliver' and s.basic:
            self.state = replace(s, status='delivered', reason='4歩の人形を届けました。追加試験は任意です。')
        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 == 'B':
            self.level = 0
            self.mode = 'compare' if self.mode == 'base' else 'base'
            self.reset()
            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,430,'B: A REST 2/1 COMPARE / NO UNDO YET',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

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)
STEP 04 / 05

未来の八刻を、同じtickで読む

同じ関数の使い回しと、範囲を限った予測。

この先8刻のようすを、小さな欄に表示します。forecast は、編集中なら写しの status だけを run に変え、今の State には代入せずに、tick を最大8回呼びます。本体とは別の近似の式を作らないので、向き・同時・順番・休み・時間の上限が、すべて同じです。編集中に何回描画されても、実際の時刻は0のままで、入力を待ちます。

予告の各行は、t、fire、step、status、reason を持つ辞書です。fire は0に着いた脚、step は歩数が増えたかを表します。両脚が同時に動いても歩けないので、「脚が動いた=1歩」ではありません。画面の上の段には時刻と動いた脚を、下の段には STEP か状態を表示します。止まった行も含め、次の tick で状態が変わらなくなったら、予告を終えます。

最初の状況 A の予告は、1L STEP、2 待つ、3R STEP、4 待つ、5L STEP、6 待つ、7R STEP、8 待つ、です。線を全部つなぐと、次の1刻で FAULT になり、予告は1行だけになります。プレゼントしたあとは、先の行はありません。予告を必ず8マス埋めて、止まったあとも歩き続ける絵を作らないようにします。

8刻ぶんの予告は、「今の設定で、これから8刻だけ」の説明です。24刻全体の成功の判定でも、すべての設定から一番良い答えを探すものでもありません。歯の数8でゆっくり歩く設定では、4歩目が予告の外にあっても、失敗とは決めつけられません。状況 B を直した例は、13刻で4歩です。限られた数の設定を全部試すのは別の動作確認(QA)で、人が考えるすべての操作の履歴を調べたこととは区別します。

下のコードは 04_forecast.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

04_forecast.py / 注目する部分
  n=tick(level,a,mode)

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 04_forecast.py
macOS / Linux
.venv/bin/python 04_forecast.py
画面で見ること最初の画面の FORECAST には、1L/3R/5L/7R で STEP が見えます。R → 右を3回 → Z で AR を足すと、予告は「1 FAULT」の1行になります。実際の時刻はまだ0です。
  • 本番と予告で、同じ tick を使う
  • 止まった行も含めて、限られた長さで予告する
  • 8刻の予告と、挑戦の全体を区別する
つまずきやすいところ描くたびに self.state=tick(...) と代入すると、読むだけでゲームが進んでしまいます。forecast の中の一時的な変数 a を動かし、元の State は変えません。
少し変えてみよう

def forecast(level,s,mode='base',count=8): の count=8 だけを count=4 に変えてみましょう。保存 → 閉じる → 実行し直すと、予告は1〜4刻の4行に減ります。Enter で試運転を始め、一度キーを離してから Space を1回ずつ7回押すと、実際の進み方は4歩のままです。確認したら8に戻します。exercises/04_forecast.py でも、予告の前とあとで State が同じことを確かめられます。

04_forecast.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(314行)
04_forecast.py / 全文
# 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.

"""ねじまき工房・教材4/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))

@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

def initial(level):return State(sum(1<<i for i,e in enumerate(level.edges) if e in level.start))

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

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<rest(level,mode):return replace(out,status='fault',reason='歩の間隔が不足。必要 '+str(rest(level,mode))+' 刻。')
  pos=s.pos+1
  out=replace(out,pos=pos,next_leg='R' if leg=='L' else 'L',previous=t,steps=s.steps+((t,leg),),basic=s.basic or pos>=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 forecast(level,s,mode='base',count=8):
 out=[];a=replace(s,status='run') if s.status=='edit' else s
 for _ in range(count):
  n=tick(level,a,mode)
  if n==a:break
  out.append({'t':n.t,'fire':tuple(leg for i,leg in enumerate('LR') if not signs_for(level,n.edges)[1] and leg in dict(n.signs) and (dict(n.signs)[leg]*n.t+n.offsets[i])%n.teeth[i]==0),'step':n.pos>a.pos,'status':n.status,'reason':n.reason})
  a=n
 return tuple(out)

STAGE = 4

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, self.mode)
        elif action == 'deliver' and s.basic:
            self.state = replace(s, status='delivered', reason='4歩の人形を届けました。追加試験は任意です。')
        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 == 'B':
            self.level = 0
            self.mode = 'compare' if self.mode == 'base' else 'base'
            self.reset()
            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(12,430,'B: A REST 2/1 COMPARE / NO UNDO YET',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

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)
STEP 05 / 05

状態を丸ごと戻し、判断の上限は返さない

状態の写しの履歴と、ふえ続けるカウンター。

最後に、元のゲームの Timeline と act を組みこみます。timeline.state が今の状態、past が過去の State を並べた tuple、decisions がこの挑戦で確定した判断の合計回数です。つなぎ方・歯の数・カム・開始・1刻・プレゼントで状態が変わると、変わる前の State を past の最後に加えます。候補の移動、同じ値の確定し直し、無効な入力では、判断の回数は増えません。

X は past の最後の State に戻り、その1件を履歴から外します。時刻だけでなく、つなぎ方、歯の数、位相、足跡、次の脚、前の1歩、basic、止まった理由を一緒に戻します。試運転を始める前まで戻ると、設定の編集に戻れます。プレゼントした直後の X もできます。「故障しても basic を失わない」ことと、「Undo で4歩目より前に戻れば、まだ達成していない状態に戻る」ことは矛盾しません。後者は、挑戦の過去の状態を元に戻しているからです。

Undo しても decisions は減りません。最大256回の判断に達すると新しい判断は受け付けず、X だけは過去に戻せます。Undo → やり直し、で上限を何度でもすり抜けることはできません。R は別の挑戦として最初に戻して0にしますが、Python のファイルを読み直すことはしません。

完成版のファイルでは、元のゲームの model.py の全17個の定義・定数(Level、State、Timeline、使っていない replay なども含む)を、プログラムの構造(AST)で照らし合わせています。表示と入力は教材用で、元のゲームの Controller をそのまま移したものではありません。表紙・日本語の文字の描画・P での自動運転・H でのヘルプ・実行中の S での記録や F12 での画像などは省いています。元のゲームの状況 A/B/C、比較用の条件、歩き方のルールとデータはそのままです。予告も更新も、同じ tick を使います。

予告は State から計算するので、判断の上限に達したあとも、「仮に進めたらどうなるか」の未来を表示します。実際の入力は、Timeline の上限で断られます。DECISIONS と上限のメッセージを一緒に読み、予告が表示されていることを「入力できる」という保証だと思わないでください。

下のコードは 05_timeline.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

05_timeline.py / 注目する部分
 if action=='undo':return Timeline(tl.past[-1],tl.past[:-1],tl.decisions) if tl.past else tl

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 05_timeline.py
macOS / Linux
.venv/bin/python 05_timeline.py
画面で見ること最初の状況 A で Enter → Space を7回 → Enter と押すと DELIVERED です。X で t7・4歩・BASIC TRUE に戻り、判断の回数9は減りません。さらに X で t6・3歩・BASIC FALSE に戻り、判断の回数は9のままです。
  • State 一式を Undo で戻す
  • 判断の合計回数 decisions は返さない
  • 「基本の達成を保つこと」と「履歴を元に戻すこと」を区別する
つまずきやすいところTimeline(..., tl.decisions-1) とすると、Undo で判断の残り回数が戻ってしまいます。また、歩数だけを戻すと、次の脚・休みの基準・basic のつじつまが合わなくなるので、State を丸ごと保存する方法を使います。
少し変えてみよう

MAX_DECISIONS=256 を MAX_DECISIONS=4 に変え、保存 → 閉じる → 実行し直して、Z を4回押してみましょう。DECISIONS 4 で、つなぎ方は元に戻ります。X で1つ前に戻っても4のままで、Z や Enter は断られます。R で0に戻り、新しい挑戦を始められます。最後に256に戻します。

05_timeline.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(346行)
05_timeline.py / 全文
# 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 for i,e in enumerate(level.edges) if e in level.start))
def new(level):return Timeline(initial(level))
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

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<rest(level,mode):return replace(out,status='fault',reason='歩の間隔が不足。必要 '+str(rest(level,mode))+' 刻。')
  pos=s.pos+1
  out=replace(out,pos=pos,next_leg='R' if leg=='L' else 'L',previous=t,steps=s.steps+((t,leg),),basic=s.basic or pos>=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])<len(level.edges):n=replace(s,edges=s.edges^(1<<int(p[1])))
  elif len(p)==3 and p[0] in ('teeth','cam'):
   try:i,v=int(p[1]),int(p[2])
   except ValueError:return tl
   if i not in (0,1):return tl
   if p[0]=='teeth' and v in (4,8):
    vals=list(s.teeth);vals[i]=v;offs=list(s.offsets);offs[i]%=v;n=replace(s,teeth=tuple(vals),offsets=tuple(offs))
   elif p[0]=='cam' and 0<=v<s.teeth[i]:vals=list(s.offsets);vals[i]=v;n=replace(s,offsets=tuple(vals))
 if n==s:return tl
 return Timeline(n,tl.past+(s,),tl.decisions+1)

def forecast(level,s,mode='base',count=8):
 out=[];a=replace(s,status='run') if s.status=='edit' else s
 for _ in range(count):
  n=tick(level,a,mode)
  if n==a:break
  out.append({'t':n.t,'fire':tuple(leg for i,leg in enumerate('LR') if not signs_for(level,n.edges)[1] and leg in dict(n.signs) and (dict(n.signs)[leg]*n.t+n.offsets[i])%n.teeth[i]==0),'step':n.pos>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)
AFTER STEP 05

仕組みがわかったら、元の完成版を読んでみよう。

ルールのまとめ:D につながる部分では、かみ合うたびに回る向きの符号が反対になります。左右の4枚か8枚の歯とカムで、0に着いたら脚が動きます。判定は、正しい向き → 同時は禁止 → 左右交互 → 最小の間隔、の順。24刻までに4歩、6歩は自由な目標。基本の達成は、そのあとの故障や時間切れでも消えません。Undo しても、合計256回の判断の数は戻りません。

ここまでに作ったのは、元のゲームの仕組みだけを取り出して作り直した「簡略版」です。元のゲームのソースは手を加えずに、別のZIP(元の完成版ソース)に入れてあります。

比べる点5ステップの教材元の完成版
段階01 つなぎ方と時計、02 カムで脚が動くまで、03 歩く、04 予告、05 元のゲームのルール全部と Undo。最初から、ルール全部・予告・Undo があります。
画面と入力段階ごとに1つのファイル。英数字(ASCII)の画面、手で1刻ずつ進める、表紙なし。R ですぐ最初に戻す。キーが離されるのを待つ仕組み(release gate)は教材用。日本語の文字、表紙とヘルプ、P での連続運転、記録や画像のキー、Controller。
保存実行中の S での保存と F12 はなし。コマンド(CLI)で画面を出さずに作る画像だけ。replay などの元のゲームの関数は、05 にそのまま入っています。出来事の記録、S での JSON の書き出し、F12 での画像。

完成版のコードで、次に読むところ

  1. model.py の signs_for:D からだけ広げる探索
  2. model.py の tick:判定の優先順位と basic
  3. model.py の forecast / simulate
  4. model.py の act:履歴と判断の回数
  5. controller.py の feed:キーが離されるのを待つ仕組みと、automatic_tick
  6. app.py:描画・セッションの記録・15フレームごとの自動の刻
自分のゲームに変えるなら

最初は、数字かルールを1つだけ変えてみましょう。遊ぶ前に「どう変わるか」を予想しておくと、仕組みがよくわかります。慣れてきたら、見た目や音も足してみてください。元のファイルは残しておき、コピーした別のフォルダーで改造すると、いつでも元に戻せます。

教材をダウンロードする

無料で、登録なしでダウンロードできます。ZIPファイルは必ず展開してから、中のファイルを実行してください。元の完成版は、同じZIPに入っている assets フォルダー(画像などの素材)も一緒に使います。

コードと図形はMITライセンス、元の完成版に含まれる日本語の文字データ(Noto由来)はSIL OFL 1.1です。配ったり改造したりするときは、ZIPに入っているLICENSEなどの権利表示を残してください。出典・権利について

検証について

どこまで動作を確認したかを見る

2026年10月5日以降、Linux(Python 3.12・Pyxel 2.9.9)で、教材のコードとゲームのルールを自動テストしています。全500ステップの教材が、実際のPyxelで起動して画面を描けることも確かめました。元の完成版も、自動で遊ぶデモが起動して画面を描き、正常に終わることを確認しています。

この確認は、画面を表示しない自動テストで行ったものです。Windows・Macの実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。

うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る