アクションLESSON 93二役の同時更新と共有履歴を学ぶ協力玩具原作082

ふたりの綱渡りの作り方

相棒の1歩が、自分の板をかたむける。
2人の実際の移動を同じ「前の状態」から決め、結び目・風・支えを順番に処理します。入力の時計と、2人そろっての一時停止、最後の尻もちの直前まで巻き戻す機能を作ります。

完成版の遊び方同じキーボードで2人が「歩く」と「支える」を合わせ、相手を揺らしてしまう2枚の板を渡りきります。

ふたりの綱渡りの元の完成版。自動操作によるPyxelの実描画画面。
元の完成版Bの合成入力による自動描画です。下の教材は二役の同時移動から始め、傾き・風・共有巻戻しを加えます。人二人の試遊や物理キーボードの同時押しを確認した画像ではありません。
PLAY / 試験公開

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

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

完成版の主な操作:二人: A/D移動・S支え、左右矢印移動・下支え。P共有停止、停止中X尻もち前へ

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

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

うまく表示されないとき

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

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

2人の同時の更新と、2人で共有する履歴をそろえる。

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

1. 入力をそろえる・同時の更新・次に動けるまでの待ち時間
2. 実際の移動が相手に伝わる・優先順位・境目の処理のあとでの達成の判定
3. 残り続ける印・1刻先の時刻・処理の順番
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
Python の変数・if・for・関数 / tuple と番号(添字) / dataclass/replace を状態の記録として読む
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
A/D/S:A役 左/右/支え / 左/右/下:B役 左/右/支え / Enter:両者READY後に開始/R確認を実行 / P:共有停止/再開、再開後に全キーを離す / R:同じ橋の最初へ確認。コード再読込ではない / M:時間を止め入力確認へ。単独では初期化しない / 1/2/3:03以降、入力確認中だけ橋選択と初期化 / B:04以降、入力確認中だけ原版/比較切替と初期化 / F2:04以降、固定別配置F/H/GとJ/L/Kへ切替、初期化 / X:05の停止中だけ最後の尻もち直前へ / Q/Esc:終了 / F12:原作Controllerの画像イベントのみ。教材UI保存なし
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_simultaneous_steps.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

二人の一歩を同じ過去から決める

入力をそろえる・同時の更新・次に動けるまでの待ち時間。

2枚の板の上の位置を、State.x=(A,B) という tuple で持ちます。位置は0から8です。frozen=True の dataclass は書き換えない記録なので、step は古い s を読んで、新しい State を返します。前提は、変数、if、for、関数、tuple と番号です。dataclass を「状態の項目の表」、replace を「項目を差しかえた新しい記録」と考えて読めば十分です。画面の英字 A/B は操作する2つの役、BOARD A/B/C は橋の種類で、別の意味です。

まず、S と下矢印を1回ずつ押して離し、2人ぶんの READY を True にします。Enter を押し、キーを全部離すと始まります。ふつうのキーの割り当ては、A の役が A/D で左/右、S で支える。B の役が左/右矢印、下矢印で支える、です。同じルール上の刻に、2人のキーを読みます。左右を同時に押すと0、支えと方向を同時に押すと支えが優先です。どちらも押さない「待つ」と「支える」は、あとの段階では意味が違います。

step の前半で、2人ぶんの移動 moves を作り終え、後半で x に足します。片方を先に動かしてから次の人に渡す方式ではありません。最初の右の入力で1歩、次の刻は cooldown=1(待ち時間)なので休み、その次にもう1歩です。5刻長押しすると3歩です。端で動けないときは last_moves=0 です。2人とも8に着いたあとの step は同じ状態を返し、時刻も増えません。

この段階は、移動だけの教材用の簡単な版です。かたむき・尻もち・結び目・風はまだ働かず、橋は A だけです。State には b や prepared などの、先に用意した項目があり、wind_at や forecast もあとの段階のために入っていますが、画面の値を変えるルールにはまだ入っていません。Controller の開始の確認、キーが全部離されるのを待つ仕組み(gate)、P の一時停止、R の確認、M のメニュー、1秒に4刻(4Hz)の時計は、先に用意した足場です。30回の描画の更新で phase に4ずつ足し、30以上なら30を引いて1刻進めます。余りを捨てないので、300フレームで40刻になります。

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

01_simultaneous_steps.py / 注目する部分
    for i, (direction, brace) in enumerate(desired):
        moved = direction if direction and s.cooldown[i] == 0 and (0 <= s.x[i] + direction <= 8) else 0
        moves.append(moved)
        cool.append(1 if moved else max(0, s.cooldown[i] - 1))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_simultaneous_steps.py
macOS / Linux
.venv/bin/python 01_simultaneous_steps.py
画面で見ること入力の確認のあと、D と右矢印をルール上の5刻まで長押しすると、t=5、POS=(3,3)、BAL=(0,0) です。P で止めると値は止まったままです。左右を同時に押すと、その役の移動は0です。
  • 2人ぶんを計画してから反映する
  • 左右は打ち消し合い、支えが優先
  • フレームと刻と歩数を分ける
つまずきやすいところ2人の入力を順番に step に渡すと、1刻が2回進んでしまいます。2つの入力を1組にして、1回だけ呼びます。表示のフレームとルール上の刻も分け、phase を0に戻して余りを捨てることはしません。
少し変えてみよう

cool.append(1 if moved else max(0, s.cooldown[i] - 1)) の最初の1だけを2に変えてみましょう。保存 → Q で終了 → python 01_simultaneous_steps.py で実行し直し、開始の確認のあと D と右矢印で5刻進めると、POS=(2,2) です。python exercises/01_slower_steps.py は、同じ書き換えをメモリーの中で実行し、38フレームの組み立てた入力で t=5 を確かめます。確認したら1に戻します。R はコードを読み直しません。

01_simultaneous_steps.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(352行)
01_simultaneous_steps.py / 全文
# -*- coding: utf-8 -*-
# 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.

"""Japanese tutorial: see LESSON.md. No external assets required."""

STAGE = 1

'Pure simultaneous 4 Hz toy-balance rules. No graphics or wall-clock dependencies.'

from dataclasses import dataclass, asdict, replace

import json, hashlib

VERSION = '082.1.0'

@dataclass(frozen=True)
class Board:
    id: str
    title: str
    knots: tuple
    wind: bool = False

BOARDS = (Board('A', 'まっすぐな朝', ((), ())),)

@dataclass(frozen=True)
class State:
    t: int = 0
    x: tuple = (0, 0)
    b: tuple = (0, 0)
    cooldown: tuple = (0, 0)
    prepared: tuple = (False, False)
    falls: tuple = (0, 0)
    events: tuple = ()
    result: bool = False
    last_moves: tuple = (0, 0)
    last_brace: tuple = (False, False)
    last_wind: int = 0
    final_together: bool = False

def new(board):
    return State()

def wind_at(board, t):
    return (1 if t >= 8 and t % 8 == 0 else -1 if t >= 12 and t % 8 == 4 else 0) if board.wind else 0

def forecast(board, s):
    return tuple(((t, wind_at(board, t)) for t in range(s.t + 1, s.t + 5) if wind_at(board, t)))

def normalize(inp):
    l, r, h = map(bool, inp)
    return (0 if h or l == r else 1 if r else -1, h)

def step(board, s, inputs, mode='other'):
    if s.result:
        return s
    desired = tuple((normalize(v) for v in inputs))
    moves = []
    cool = []
    for i, (direction, brace) in enumerate(desired):
        moved = direction if direction and s.cooldown[i] == 0 and (0 <= s.x[i] + direction <= 8) else 0
        moves.append(moved)
        cool.append(1 if moved else max(0, s.cooldown[i] - 1))
    x = [s.x[i] + moves[i] for i in range(2)]
    balance = [0, 0]
    falls = list(s.falls)
    events = []
    result = x == [8, 8]
    return State(t=s.t + 1, x=tuple(x), b=tuple(balance), cooldown=tuple(cool), falls=tuple(falls), events=tuple(events), result=result, last_moves=tuple(moves), last_brace=tuple((v[1] for v in desired)), final_together=result and moves == [1, 1])

def digest(s):
    return hashlib.sha256(json.dumps(asdict(s) if hasattr(s, '__dataclass_fields__') else s, sort_keys=True, ensure_ascii=False, separators=(',', ':')).encode('utf-8')).hexdigest()

NONE = (False, False, False)

RIGHT = (False, True, False)

LEFT = (True, False, False)

BRACE = (False, False, True)

class Timeline:

    def __init__(self, board, mode='other'):
        self.board = board
        self.mode = mode
        self.states = [new(board)]
        self.inputs = []

    @property
    def state(self):
        return self.states[-1]

    def act(self, inp):
        if self.state.result:
            return False
        self.inputs.append(tuple((tuple(v) for v in inp)))
        self.states.append(step(self.board, self.state, inp, self.mode))
        return True

    def replay(self):
        return {'version': VERSION, 'board': asdict(self.board), 'mode': self.mode, 'initial': asdict(self.states[0]), 'inputs': self.inputs[:], 'ticks': [s.t for s in self.states], 'hashes': [digest(s) for s in self.states], 'final': asdict(self.state)}
MAPS = ((('A', 'D', 'S'), ('LEFT', 'RIGHT', 'DOWN')), (('F', 'H', 'G'), ('J', 'L', 'K')))

KEYS = {'A', 'D', 'S', 'LEFT', 'RIGHT', 'DOWN', 'F', 'H', 'G', 'J', 'L', 'K', 'RETURN', 'SPACE', 'P', 'R', 'X', 'B', 'M', '1', '2', '3', 'F2', 'F12', 'Q', 'ESCAPE'}

class Controller:

    def __init__(self):
        self.level = 0
        self.mode = 'other'
        self.mapping = 0
        self.title = True
        self.paused = False
        self.confirm = False
        self.ready = [False, False]
        self.previous = set()
        self.gate = True
        self.phase = 0
        self.timeline = Timeline(self.board, self.mode)

    @property
    def board(self):
        return BOARDS[self.level]

    @property
    def state(self):
        return self.timeline.state

    def reset(self):
        self.timeline = Timeline(self.board, self.mode)
        self.phase = 0
        self.ready = [False, False]
        self.title = True
        self.paused = False
        self.confirm = False

    def logical(self, down):
        return tuple((tuple((k in down for k in m)) for m in MAPS[self.mapping]))

    def feed(self, down):
        down = set(down) & KEYS
        fresh = down - self.previous
        self.previous = down
        if self.gate:
            if not down:
                self.gate = False
            return None
        if 'F12' in fresh:
            self.gate = True
            return 'screenshot'
        if fresh & {'Q', 'ESCAPE'}:
            return 'quit'
        if self.confirm:
            if 'RETURN' in fresh:
                self.reset()
                self.gate = True
                return 'restart'
            if fresh:
                self.confirm = False
                self.gate = True
                return 'cancel_restart'
            return None
        if 'M' in fresh:
            self.title = True
            self.gate = True
            return 'menu'
        if 'R' in fresh:
            self.confirm = True
            self.gate = True
            return 'confirm_restart'
        if self.title:
            for i, keys in enumerate(MAPS[self.mapping]):
                if fresh & set(keys):
                    self.ready[i] = True
            if 'RETURN' in fresh and all(self.ready):
                self.title = False
                self.paused = False
                self.phase = 0
                self.gate = True
                return 'start'
            if fresh:
                return 'input_check'
            return None
        if 'P' in fresh:
            self.paused = not self.paused
            self.gate = True
            return 'pause' if self.paused else 'resume'
        if self.paused:
            return None
        if self.state.result:
            return None
        self.phase += 4
        if self.phase < 30:
            return None
        self.phase -= 30
        self.timeline.act(self.logical(down))
        return 'tick'

# The ASCII UI is a teaching replacement, not a copy of original app.py.
class App:
    def __init__(self):
        self.ui = Controller()
        self.message = 'RELEASE ALL KEYS, THEN TEST BOTH PLAYERS'

    def control(self, down):
        event = self.ui.feed(down)
        if event:
            self.message = event.upper().replace('_', ' ')
        return event

    def draw(self, p):
        u, s = self.ui, self.ui.state
        p.cls(1)
        p.text(16, 12, 'TWIN TIGHTROPE / LESSON ' + str(STAGE), 10)
        p.text(16, 27, 'TEACHING UI / 30 FPS -> 4 LOGICAL TICKS PER SECOND', 6)
        p.text(16, 45, 'BOARD ' + u.board.id + '  TICK ' + str(s.t) + '  MODE ' + u.mode.upper(), 7)
        p.text(330, 45, 'COOLDOWN ' + str(s.cooldown) + '  PHASE ' + str(u.phase), 6)
        for i in range(2):
            y = 126 + i * 118
            color = (10, 11)[i]
            tilt = s.b[i]
            p.text(16, y - 36, 'AB'[i] + '  POS ' + str(s.x[i]) + '/8', color)
            p.text(16, y - 23, 'BAL ' + str(tilt) + ' FALLS ' + str(s.falls[i]), color)
            for k in range(9):
                x, yy = 166 + k * 45, y - tilt * 8 + tilt * 2 * k
                p.circ(x, yy, 3, 7)
                if k < 8:
                    p.line(x, yy, x + 45, yy + tilt * 2, 4)
                if STAGE >= 3 and k in u.board.knots[i]:
                    p.rectb(x - 6, yy - 6, 13, 13, 8)
                if k == s.x[i]:
                    p.circ(x, yy - 16, 6, color)
                    p.rect(x - 3, yy - 10, 7, 8, color)
                    if s.last_brace[i]:
                        p.line(x - 10, yy - 5, x + 10, yy - 5, color)
            p.text(168, y + 30, 'MOVE ' + str(s.last_moves[i]) + '  BRACE ' + str(s.last_brace[i]) + '  PREPARED ' + str(s.prepared[i]), color)
        p.text(16, 302, 'WIND LAST ' + str(s.last_wind) + '  NEXT ' + str(forecast(u.board, s) if STAGE >= 3 else ()), 6)
        p.text(16, 319, self.message, 10)
        p.text(16, 337, 'A: ' + '/'.join(MAPS[u.mapping][0]) + '  B: ' + '/'.join(MAPS[u.mapping][1]), 7)
        p.text(16, 351, 'ORDER: LEFT / RIGHT / BRACE   HOLD = ONE STEP PER TWO TICKS', 7)
        p.text(16, 371, 'P PAUSE / R RESTART CONFIRM / M MENU / Q OR ESC QUIT', 6)
        p.text(16, 385, 'F12: USE ORIGINAL APP FOR IMAGE KEY / CLI CAPTURE IN README', 6)
        p.text(16, 399, ('1-3 BOARD / ' if STAGE >= 3 else 'A ONLY / ') + ('B COMPARE / F2 MAPPING / ' if STAGE >= 4 else 'FIXED NORMAL KEYS / ') + ('PAUSED X REWIND' if STAGE == 5 else 'NO REWIND YET'), 6)
        if s.result:
            p.rect(133, 154, 350, 90, 1); p.rectb(133, 154, 350, 90, 11)
            p.text(152, 170, 'BOTH ARRIVED / EQUAL SUCCESS AT ANY PACE', 11)
            p.text(152, 190, 'LAST STEP TOGETHER: ' + str(s.final_together), 7)
            p.text(152, 210, 'R RESTART / M INPUT CHECK', 6)
        if u.paused:
            p.rect(123, 142, 370, 102, 1); p.rectb(123, 142, 370, 102, 10)
            p.text(140, 158, 'PAUSED / TIME, WIND AND BOTH PLAYERS STOP', 10)
            p.text(140, 179, 'P RESUME, THEN RELEASE EVERY KEY', 7)
            p.text(140, 200, 'X: BEFORE MOST RECENT FALL' if STAGE == 5 else 'REWIND IS ADDED IN STAGE 05', 6)
        if u.title:
            p.rect(80, 72, 456, 207, 1); p.rectb(80, 72, 456, 207, 10)
            p.text(96, 89, 'INPUT CHECK / TEST BOTH ROLES', 10)
            for i in range(2):
                p.text(96, 118 + i * 41, 'AB'[i] + ' ' + '/'.join(MAPS[u.mapping][i][0:2]) + ' WALK  ' + MAPS[u.mapping][i][2] + ' BRACE', (10,11)[i])
                p.text(96, 132 + i * 41, 'READY ' + str(u.ready[i]), (10,11)[i])
            p.text(96, 204, 'ENTER AFTER BOTH READY, THEN RELEASE ALL', 7)
            p.text(96, 226, 'SYNTHETIC QA IS NOT TWO-HUMAN PLAYTESTING', 6)
            p.text(96, 248, 'ACTUAL KEYBOARD ROLLOVER IS UNVERIFIED', 6)
        if u.confirm:
            p.rect(106, 149, 404, 82, 1); p.rectb(106, 149, 404, 82, 8)
            p.text(125, 167, 'RESTART SAME BOARD? BOTH STATES WILL RESET', 10)
            p.text(125, 190, 'ENTER YES / OTHER KEY CANCEL', 7)
            p.text(125, 209, 'R DOES NOT RELOAD SAVED PYTHON CODE', 6)


def tap(app, keys):
    app.control(set())
    return app.control(set(keys))


def start(app):
    tap(app, set(MAPS[app.ui.mapping][0][2:]) | set(MAPS[app.ui.mapping][1][2:]))
    tap(app, {'RETURN'})
    app.control(set())


def checkpoint(app):
    start(app)
    for _ in range(38):
        app.control({'D', 'RIGHT'})


def main(headless=False, screenshot=None, state_json=None, demo=False):
    import pyxel as p
    from pathlib import Path
    p.init(616, 420, title='082 lesson ' + str(STAGE), fps=30, display_scale=1, headless=headless)
    app = App()
    if demo:
        checkpoint(app)
    keys = {k: getattr(p, 'KEY_' + k) for k in KEYS}
    def update():
        if headless:
            if p.frame_count >= 3:
                app.draw(p)
                if screenshot:
                    p.screen.save(screenshot, 1)
                if state_json:
                    Path(state_json).write_text(json.dumps({'stage': STAGE, 'state': asdict(app.ui.state), 'phase': app.ui.phase, 'title': app.ui.title, 'scope': 'Actual Pyxel Linux/headless; synthetic controller input; dummy audio; no physical keys or human playtest'}, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
                p.quit()
            return
        if app.control({k for k, v in keys.items() if p.btn(v)}) == 'quit':
            p.quit()
    p.run(update, lambda: app.draw(p))


if __name__ == '__main__':
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description='Standalone twin tightrope tutorial')
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--screenshot')
    parser.add_argument('--state-json')
    args = parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke:
        parser.error('demo and outputs require --headless-smoke')
    # Resolve before Pyxel init can change cwd; validate ALL outputs before import.
    for field, suffix in (('screenshot', '.png'), ('state_json', '.json')):
        value = getattr(args, field)
        if value:
            target = Path(value).resolve()
            if target.suffix.lower() != suffix or not target.parent.is_dir() or target.exists():
                parser.error('output must be a new ' + suffix + ' in an existing directory')
            setattr(args, field, str(target))
    main(args.headless_smoke, args.screenshot, args.state_json, args.demo_checkpoint)
STEP 02 / 05

相手の一歩で傾き、支えて立て直す

実際の移動が相手に伝わる・優先順位・境目の処理のあとでの達成の判定。

1歩が相手に伝わる仕組みを加えます。A の新しいかたむきは「A の古いかたむき+B の実際の移動」、B もその逆です。右に実際に動けば +1、左なら -1 です。押した方向ではなく、実際の移動 moves を足すので、端や待ち時間で動けなかったキーは揺れを生みません。moves[1-i] は、i が0なら1、i が1なら0になり、相手を指します。

位置を仮に進めたあと、それぞれの役のかたむきを計算します。その役が支えていれば0に戻します。支えていないときだけ abs(value)>=3 を調べ、当てはまれば位置を1歩戻し、かたむきを0にして、尻もちの回数を1増やします。0からさらに左には出ません。尻もちで戻った分は moves に入れないので、さらに相手を揺らすことはありません。これは現実の綱渡りの物理ではなく、整数で作ったおもちゃのルールです。

2人が右だけを押すと、5刻目の3回目の実際の移動で2人ともかたむき3になり、3歩目から2歩目に戻ります。反対に、D と下矢印なら、A だけが1歩進み、B はその1歩を支えて打ち消し、2人ともかたむき0です。何も押さないのは支えではないので、残ったかたむきは消えません。支えて待つことが、役割になります。

達成の判定は、尻もちの処理のあとです。2人とも7歩・かたむき2から右に進んでも、尻もちで7に戻るので達成しません。1人が8で支えて待ち、もう1人が8に来ても、同じ達成です。残り数、時間制限、遅さによる減点はありません。この段階も橋 A だけで、prepared・風・結び目はまだありません。画面の FALLS は、失敗でゲームが終わった回数ではなく、立て直した回数です。

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

02_partner_balance.py / 注目する部分
    for i, (direction, brace) in enumerate(desired):
        value = s.b[i] + moves[1 - i]
        if brace:
            value = 0
        elif abs(value) >= 3:

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_partner_balance.py
macOS / Linux
.venv/bin/python 02_partner_balance.py
画面で見ることD と右矢印を5刻まで長押しすると、t=5、POS=(2,2)、BAL=(0,0)、FALLS=(1,1) です。R → キーを全部離す → Enter → 開始の確認から、D と下矢印なら、1刻目は POS=(1,0)、BAL=(0,0) です。
  • 押した方向と、実際の移動を分ける
  • 支えは、尻もちより先に処理する
  • 尻もちで戻った分を、相手に伝え直さない
つまずきやすいところdesired(押した方向)を足すと、動けなかった役まで相手を揺らしてしまいます。また、尻もちで戻る前に x==(8,8) を判定すると、まだ着いていないのに達成になってしまいます。
少し変えてみよう

elif abs(value) >= 3: の3を2に変えてみましょう。保存 → 終了 → python 02_partner_balance.py で実行し直します。2人とも右を3刻押すと、2回目の実際の移動で尻もちになり、POS=(1,1)、FALLS=(1,1) です。python exercises/02_earlier_fall.py は、23フレームの組み立てた入力で、この書き換えを実行します。元の3に戻します。

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

"""Japanese tutorial: see LESSON.md. No external assets required."""

STAGE = 2

'Pure simultaneous 4 Hz toy-balance rules. No graphics or wall-clock dependencies.'

from dataclasses import dataclass, asdict, replace

import json, hashlib

VERSION = '082.1.0'

@dataclass(frozen=True)
class Board:
    id: str
    title: str
    knots: tuple
    wind: bool = False

BOARDS = (Board('A', 'まっすぐな朝', ((), ())),)

@dataclass(frozen=True)
class State:
    t: int = 0
    x: tuple = (0, 0)
    b: tuple = (0, 0)
    cooldown: tuple = (0, 0)
    prepared: tuple = (False, False)
    falls: tuple = (0, 0)
    events: tuple = ()
    result: bool = False
    last_moves: tuple = (0, 0)
    last_brace: tuple = (False, False)
    last_wind: int = 0
    final_together: bool = False

def new(board):
    return State()

def wind_at(board, t):
    return (1 if t >= 8 and t % 8 == 0 else -1 if t >= 12 and t % 8 == 4 else 0) if board.wind else 0

def forecast(board, s):
    return tuple(((t, wind_at(board, t)) for t in range(s.t + 1, s.t + 5) if wind_at(board, t)))

def normalize(inp):
    l, r, h = map(bool, inp)
    return (0 if h or l == r else 1 if r else -1, h)

def step(board, s, inputs, mode='other'):
    if s.result:
        return s
    desired = tuple((normalize(v) for v in inputs))
    moves = []
    cool = []
    for i, (direction, brace) in enumerate(desired):
        moved = direction if direction and s.cooldown[i] == 0 and (0 <= s.x[i] + direction <= 8) else 0
        moves.append(moved)
        cool.append(1 if moved else max(0, s.cooldown[i] - 1))
    x = [s.x[i] + moves[i] for i in range(2)]
    balance = [0, 0]
    falls = list(s.falls)
    events = []
    for i, (direction, brace) in enumerate(desired):
        value = s.b[i] + moves[1 - i]
        if brace:
            value = 0
        elif abs(value) >= 3:
            x[i] = max(0, x[i] - 1)
            value = 0
            falls[i] += 1
            events.append(('A', 'B')[i] + ' 尻もち')
        balance[i] = value
    result = x == [8, 8]
    return State(t=s.t + 1, x=tuple(x), b=tuple(balance), cooldown=tuple(cool), falls=tuple(falls), events=tuple(events), result=result, last_moves=tuple(moves), last_brace=tuple((v[1] for v in desired)), final_together=result and moves == [1, 1])

def digest(s):
    return hashlib.sha256(json.dumps(asdict(s) if hasattr(s, '__dataclass_fields__') else s, sort_keys=True, ensure_ascii=False, separators=(',', ':')).encode('utf-8')).hexdigest()

NONE = (False, False, False)

RIGHT = (False, True, False)

LEFT = (True, False, False)

BRACE = (False, False, True)

class Timeline:

    def __init__(self, board, mode='other'):
        self.board = board
        self.mode = mode
        self.states = [new(board)]
        self.inputs = []

    @property
    def state(self):
        return self.states[-1]

    def act(self, inp):
        if self.state.result:
            return False
        self.inputs.append(tuple((tuple(v) for v in inp)))
        self.states.append(step(self.board, self.state, inp, self.mode))
        return True

    def replay(self):
        return {'version': VERSION, 'board': asdict(self.board), 'mode': self.mode, 'initial': asdict(self.states[0]), 'inputs': self.inputs[:], 'ticks': [s.t for s in self.states], 'hashes': [digest(s) for s in self.states], 'final': asdict(self.state)}
MAPS = ((('A', 'D', 'S'), ('LEFT', 'RIGHT', 'DOWN')), (('F', 'H', 'G'), ('J', 'L', 'K')))

KEYS = {'A', 'D', 'S', 'LEFT', 'RIGHT', 'DOWN', 'F', 'H', 'G', 'J', 'L', 'K', 'RETURN', 'SPACE', 'P', 'R', 'X', 'B', 'M', '1', '2', '3', 'F2', 'F12', 'Q', 'ESCAPE'}

class Controller:

    def __init__(self):
        self.level = 0
        self.mode = 'other'
        self.mapping = 0
        self.title = True
        self.paused = False
        self.confirm = False
        self.ready = [False, False]
        self.previous = set()
        self.gate = True
        self.phase = 0
        self.timeline = Timeline(self.board, self.mode)

    @property
    def board(self):
        return BOARDS[self.level]

    @property
    def state(self):
        return self.timeline.state

    def reset(self):
        self.timeline = Timeline(self.board, self.mode)
        self.phase = 0
        self.ready = [False, False]
        self.title = True
        self.paused = False
        self.confirm = False

    def logical(self, down):
        return tuple((tuple((k in down for k in m)) for m in MAPS[self.mapping]))

    def feed(self, down):
        down = set(down) & KEYS
        fresh = down - self.previous
        self.previous = down
        if self.gate:
            if not down:
                self.gate = False
            return None
        if 'F12' in fresh:
            self.gate = True
            return 'screenshot'
        if fresh & {'Q', 'ESCAPE'}:
            return 'quit'
        if self.confirm:
            if 'RETURN' in fresh:
                self.reset()
                self.gate = True
                return 'restart'
            if fresh:
                self.confirm = False
                self.gate = True
                return 'cancel_restart'
            return None
        if 'M' in fresh:
            self.title = True
            self.gate = True
            return 'menu'
        if 'R' in fresh:
            self.confirm = True
            self.gate = True
            return 'confirm_restart'
        if self.title:
            for i, keys in enumerate(MAPS[self.mapping]):
                if fresh & set(keys):
                    self.ready[i] = True
            if 'RETURN' in fresh and all(self.ready):
                self.title = False
                self.paused = False
                self.phase = 0
                self.gate = True
                return 'start'
            if fresh:
                return 'input_check'
            return None
        if 'P' in fresh:
            self.paused = not self.paused
            self.gate = True
            return 'pause' if self.paused else 'resume'
        if self.paused:
            return None
        if self.state.result:
            return None
        self.phase += 4
        if self.phase < 30:
            return None
        self.phase -= 30
        self.timeline.act(self.logical(down))
        return 'tick'

# The ASCII UI is a teaching replacement, not a copy of original app.py.
class App:
    def __init__(self):
        self.ui = Controller()
        self.message = 'RELEASE ALL KEYS, THEN TEST BOTH PLAYERS'

    def control(self, down):
        event = self.ui.feed(down)
        if event:
            self.message = event.upper().replace('_', ' ')
        return event

    def draw(self, p):
        u, s = self.ui, self.ui.state
        p.cls(1)
        p.text(16, 12, 'TWIN TIGHTROPE / LESSON ' + str(STAGE), 10)
        p.text(16, 27, 'TEACHING UI / 30 FPS -> 4 LOGICAL TICKS PER SECOND', 6)
        p.text(16, 45, 'BOARD ' + u.board.id + '  TICK ' + str(s.t) + '  MODE ' + u.mode.upper(), 7)
        p.text(330, 45, 'COOLDOWN ' + str(s.cooldown) + '  PHASE ' + str(u.phase), 6)
        for i in range(2):
            y = 126 + i * 118
            color = (10, 11)[i]
            tilt = s.b[i]
            p.text(16, y - 36, 'AB'[i] + '  POS ' + str(s.x[i]) + '/8', color)
            p.text(16, y - 23, 'BAL ' + str(tilt) + ' FALLS ' + str(s.falls[i]), color)
            for k in range(9):
                x, yy = 166 + k * 45, y - tilt * 8 + tilt * 2 * k
                p.circ(x, yy, 3, 7)
                if k < 8:
                    p.line(x, yy, x + 45, yy + tilt * 2, 4)
                if STAGE >= 3 and k in u.board.knots[i]:
                    p.rectb(x - 6, yy - 6, 13, 13, 8)
                if k == s.x[i]:
                    p.circ(x, yy - 16, 6, color)
                    p.rect(x - 3, yy - 10, 7, 8, color)
                    if s.last_brace[i]:
                        p.line(x - 10, yy - 5, x + 10, yy - 5, color)
            p.text(168, y + 30, 'MOVE ' + str(s.last_moves[i]) + '  BRACE ' + str(s.last_brace[i]) + '  PREPARED ' + str(s.prepared[i]), color)
        p.text(16, 302, 'WIND LAST ' + str(s.last_wind) + '  NEXT ' + str(forecast(u.board, s) if STAGE >= 3 else ()), 6)
        p.text(16, 319, self.message, 10)
        p.text(16, 337, 'A: ' + '/'.join(MAPS[u.mapping][0]) + '  B: ' + '/'.join(MAPS[u.mapping][1]), 7)
        p.text(16, 351, 'ORDER: LEFT / RIGHT / BRACE   HOLD = ONE STEP PER TWO TICKS', 7)
        p.text(16, 371, 'P PAUSE / R RESTART CONFIRM / M MENU / Q OR ESC QUIT', 6)
        p.text(16, 385, 'F12: USE ORIGINAL APP FOR IMAGE KEY / CLI CAPTURE IN README', 6)
        p.text(16, 399, ('1-3 BOARD / ' if STAGE >= 3 else 'A ONLY / ') + ('B COMPARE / F2 MAPPING / ' if STAGE >= 4 else 'FIXED NORMAL KEYS / ') + ('PAUSED X REWIND' if STAGE == 5 else 'NO REWIND YET'), 6)
        if s.result:
            p.rect(133, 154, 350, 90, 1); p.rectb(133, 154, 350, 90, 11)
            p.text(152, 170, 'BOTH ARRIVED / EQUAL SUCCESS AT ANY PACE', 11)
            p.text(152, 190, 'LAST STEP TOGETHER: ' + str(s.final_together), 7)
            p.text(152, 210, 'R RESTART / M INPUT CHECK', 6)
        if u.paused:
            p.rect(123, 142, 370, 102, 1); p.rectb(123, 142, 370, 102, 10)
            p.text(140, 158, 'PAUSED / TIME, WIND AND BOTH PLAYERS STOP', 10)
            p.text(140, 179, 'P RESUME, THEN RELEASE EVERY KEY', 7)
            p.text(140, 200, 'X: BEFORE MOST RECENT FALL' if STAGE == 5 else 'REWIND IS ADDED IN STAGE 05', 6)
        if u.title:
            p.rect(80, 72, 456, 207, 1); p.rectb(80, 72, 456, 207, 10)
            p.text(96, 89, 'INPUT CHECK / TEST BOTH ROLES', 10)
            for i in range(2):
                p.text(96, 118 + i * 41, 'AB'[i] + ' ' + '/'.join(MAPS[u.mapping][i][0:2]) + ' WALK  ' + MAPS[u.mapping][i][2] + ' BRACE', (10,11)[i])
                p.text(96, 132 + i * 41, 'READY ' + str(u.ready[i]), (10,11)[i])
            p.text(96, 204, 'ENTER AFTER BOTH READY, THEN RELEASE ALL', 7)
            p.text(96, 226, 'SYNTHETIC QA IS NOT TWO-HUMAN PLAYTESTING', 6)
            p.text(96, 248, 'ACTUAL KEYBOARD ROLLOVER IS UNVERIFIED', 6)
        if u.confirm:
            p.rect(106, 149, 404, 82, 1); p.rectb(106, 149, 404, 82, 8)
            p.text(125, 167, 'RESTART SAME BOARD? BOTH STATES WILL RESET', 10)
            p.text(125, 190, 'ENTER YES / OTHER KEY CANCEL', 7)
            p.text(125, 209, 'R DOES NOT RELOAD SAVED PYTHON CODE', 6)


def tap(app, keys):
    app.control(set())
    return app.control(set(keys))


def start(app):
    tap(app, set(MAPS[app.ui.mapping][0][2:]) | set(MAPS[app.ui.mapping][1][2:]))
    tap(app, {'RETURN'})
    app.control(set())


def checkpoint(app):
    start(app)
    for _ in range(38):
        app.control({'D', 'RIGHT'})


def main(headless=False, screenshot=None, state_json=None, demo=False):
    import pyxel as p
    from pathlib import Path
    p.init(616, 420, title='082 lesson ' + str(STAGE), fps=30, display_scale=1, headless=headless)
    app = App()
    if demo:
        checkpoint(app)
    keys = {k: getattr(p, 'KEY_' + k) for k in KEYS}
    def update():
        if headless:
            if p.frame_count >= 3:
                app.draw(p)
                if screenshot:
                    p.screen.save(screenshot, 1)
                if state_json:
                    Path(state_json).write_text(json.dumps({'stage': STAGE, 'state': asdict(app.ui.state), 'phase': app.ui.phase, 'title': app.ui.title, 'scope': 'Actual Pyxel Linux/headless; synthetic controller input; dummy audio; no physical keys or human playtest'}, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
                p.quit()
            return
        if app.control({k for k, v in keys.items() if p.btn(v)}) == 'quit':
            p.quit()
    p.run(update, lambda: app.draw(p))


if __name__ == '__main__':
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description='Standalone twin tightrope tutorial')
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--screenshot')
    parser.add_argument('--state-json')
    args = parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke:
        parser.error('demo and outputs require --headless-smoke')
    # Resolve before Pyxel init can change cwd; validate ALL outputs before import.
    for field, suffix in (('screenshot', '.png'), ('state_json', '.json')):
        value = getattr(args, field)
        if value:
            target = Path(value).resolve()
            if target.suffix.lower() != suffix or not target.parent.is_dir() or target.exists():
                parser.error('output must be a new ' + suffix + ' in an existing directory')
            setattr(args, field, str(target))
    main(args.headless_smoke, args.screenshot, args.state_json, args.demo_checkpoint)
STEP 03 / 05

結び目の準備と、先に見える風

残り続ける印・1刻先の時刻・処理の順番。

ここで、step が元のゲームと同じになります。入力の確認の画面の 1/2/3 で、橋 A/B/C を選べます。A は障害なし。B は、A の役の位置2と6、B の役の位置4に結び目があります。C は、位置3と5の結び目に、風が加わります。赤い四角が結び目です。板の途中にある結び目から右へ出るには、1刻支えて prepared=True(準備済み)にしておく必要があります。結び目に入る移動ではなく、結び目から右へ出る移動の条件です。

準備は、待つ、待ち時間で動けない空振り、左への移動、尻もちでは消えません。実際に右へ進んだときだけ False に戻ります。支えと右を同時に押すと、そろえる処理で移動0になり、その刻は準備をするだけです。1刻で、準備しながら右へ進むことはできません。2人の prepared を古い s から読んで、移動を決めることも大切です。

風は、更新したあとの t=s.t+1 を使います。橋 C の刻8、16、24…で +1、12、20、28…で -1 です。ほかの刻は0です。かたむきは「古い値+相手の実際の移動+風」で、そのあと支えていれば0、最後に尻もちを調べます。風が吹く刻でも、支えていれば立て直せます。風 -1 と右への実際の移動 +1 が打ち消し合うこともあります。

forecast は、この先4刻の間に風が吹く時刻と値だけを返します。今の t は変えず、次の入力も決めません。画面の NEXT ((8,1),) は、「時刻8に +1」という意味です。元のゲームの日本語の画面は「あと何刻」も表示します。教材では、数値を直接読んで対応を追います。R は同じ橋の再開始の確認、M は入力の確認の画面に戻って時間を止めます。M そのものは状態を最初に戻しません。橋を選び直したときは、新しい状態になります。

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

03_knots_wind.py / 注目する部分
        if brace:
            prep[i] = True
        elif moved > 0:
            prep[i] = False

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_knots_wind.py
macOS / Linux
.venv/bin/python 03_knots_wind.py
画面で見ること2 → S と下矢印 → Enter → キーを全部離す、と押します。右だけでは、A の役は2、B の役は4の結び目を越えられません。1刻、S と下矢印を押すと PREPARED=True になり、そのあと右で抜けられます。橋3では、t=4 に NEXT ((8,1),) です。
  • 結び目から右へ出る前に、支える
  • 準備が使われるのは、実際に右へ動いたときだけ
  • 風 → 支え → 尻もち、の順
つまずきやすいところ動こうとしただけで prepared を消すと、待ち時間の間の準備まで失われてしまいます。風を s.t で計算すると、予告と実際の風が1刻ずれてしまいます。
少し変えてみよう

BOARDS の中の B の ((2, 6), (4,)) を ((1, 6), (4,)) に変えてみましょう。保存 → 終了 → python 03_knots_wind.py で実行し直します。橋 B で A の役の右だけを5刻押すと、位置1の結び目で止まります。python exercises/03_move_knot.py は、同じ書き換え、橋の選択、開始、入力を実行して、t=5、x[0]=1、prepared[0]=False を確かめます。最後に2に戻します。

03_knots_wind.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(381行)
03_knots_wind.py / 全文
# -*- coding: utf-8 -*-
# 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.

"""Japanese tutorial: see LESSON.md. No external assets required."""

STAGE = 3

'Pure simultaneous 4 Hz toy-balance rules. No graphics or wall-clock dependencies.'

from dataclasses import dataclass, asdict, replace

import json, hashlib

VERSION = '082.1.0'

@dataclass(frozen=True)
class Board:
    id: str
    title: str
    knots: tuple
    wind: bool = False

BOARDS = (Board('A', 'まっすぐな朝', ((), ())), Board('B', '結び目のすれ違い', ((2, 6), (4,))), Board('C', '旗の鳴る夕方', ((3,), (5,)), True))

@dataclass(frozen=True)
class State:
    t: int = 0
    x: tuple = (0, 0)
    b: tuple = (0, 0)
    cooldown: tuple = (0, 0)
    prepared: tuple = (False, False)
    falls: tuple = (0, 0)
    events: tuple = ()
    result: bool = False
    last_moves: tuple = (0, 0)
    last_brace: tuple = (False, False)
    last_wind: int = 0
    final_together: bool = False

def new(board):
    return State()

def wind_at(board, t):
    return (1 if t >= 8 and t % 8 == 0 else -1 if t >= 12 and t % 8 == 4 else 0) if board.wind else 0

def forecast(board, s):
    return tuple(((t, wind_at(board, t)) for t in range(s.t + 1, s.t + 5) if wind_at(board, t)))

def normalize(inp):
    l, r, h = map(bool, inp)
    return (0 if h or l == r else 1 if r else -1, h)

def step(board, s, inputs, mode='other'):
    if s.result:
        return s
    desired = tuple((normalize(v) for v in inputs))
    moves = []
    cool = []
    prep = list(s.prepared)
    events = []
    for i, (d, brace) in enumerate(desired):
        moved = d if d and s.cooldown[i] == 0 and (0 <= s.x[i] + d <= 8) and (not (d > 0 and s.x[i] in board.knots[i] and (not s.prepared[i]))) else 0
        moves.append(moved)
        cool.append(1 if moved else max(0, s.cooldown[i] - 1))
        if brace:
            prep[i] = True
        elif moved > 0:
            prep[i] = False
        if d > 0 and (not moved) and (s.x[i] in board.knots[i]) and (not s.prepared[i]):
            events.append(('A', 'B')[i] + ' 結び目: 一刻支えてから')
    t = s.t + 1
    wind = wind_at(board, t)
    x = [s.x[i] + moves[i] for i in range(2)]
    bal = []
    falls = list(s.falls)
    for i, (d, brace) in enumerate(desired):
        v = s.b[i] + moves[i if mode == 'self' else 1 - i] + wind
        if brace:
            v = 0
        elif abs(v) >= 3:
            x[i] = max(0, x[i] - 1)
            v = 0
            falls[i] += 1
            events.append(('A', 'B')[i] + ' 尻もち: 一歩戻った / 支えて立て直そう')
        bal.append(v)
    if wind:
        events.insert(0, '風 ' + ('右 +1' if wind > 0 else '左 -1'))
    result = x == [8, 8]
    together = result and moves == [1, 1]
    if result:
        events.append('ふたりで到着!ゆっくりでも同じ達成')
    return State(t, tuple(x), tuple(bal), tuple(cool), tuple(prep), tuple(falls), tuple(events), result, tuple(moves), tuple((v[1] for v in desired)), wind, together)

def digest(s):
    return hashlib.sha256(json.dumps(asdict(s) if hasattr(s, '__dataclass_fields__') else s, sort_keys=True, ensure_ascii=False, separators=(',', ':')).encode('utf-8')).hexdigest()

NONE = (False, False, False)

RIGHT = (False, True, False)

LEFT = (True, False, False)

BRACE = (False, False, True)

class Timeline:

    def __init__(self, board, mode='other'):
        self.board = board
        self.mode = mode
        self.states = [new(board)]
        self.inputs = []

    @property
    def state(self):
        return self.states[-1]

    def act(self, inp):
        if self.state.result:
            return False
        self.inputs.append(tuple((tuple(v) for v in inp)))
        self.states.append(step(self.board, self.state, inp, self.mode))
        return True

    def replay(self):
        return {'version': VERSION, 'board': asdict(self.board), 'mode': self.mode, 'initial': asdict(self.states[0]), 'inputs': self.inputs[:], 'ticks': [s.t for s in self.states], 'hashes': [digest(s) for s in self.states], 'final': asdict(self.state)}
MAPS = ((('A', 'D', 'S'), ('LEFT', 'RIGHT', 'DOWN')), (('F', 'H', 'G'), ('J', 'L', 'K')))

KEYS = {'A', 'D', 'S', 'LEFT', 'RIGHT', 'DOWN', 'F', 'H', 'G', 'J', 'L', 'K', 'RETURN', 'SPACE', 'P', 'R', 'X', 'B', 'M', '1', '2', '3', 'F2', 'F12', 'Q', 'ESCAPE'}

class Controller:

    def __init__(self):
        self.level = 0
        self.mode = 'other'
        self.mapping = 0
        self.title = True
        self.paused = False
        self.confirm = False
        self.ready = [False, False]
        self.previous = set()
        self.gate = True
        self.phase = 0
        self.timeline = Timeline(self.board, self.mode)

    @property
    def board(self):
        return BOARDS[self.level]

    @property
    def state(self):
        return self.timeline.state

    def reset(self):
        self.timeline = Timeline(self.board, self.mode)
        self.phase = 0
        self.ready = [False, False]
        self.title = True
        self.paused = False
        self.confirm = False

    def logical(self, down):
        return tuple((tuple((k in down for k in m)) for m in MAPS[self.mapping]))

    def feed(self, down):
        down = set(down) & KEYS
        fresh = down - self.previous
        self.previous = down
        if self.gate:
            if not down:
                self.gate = False
            return None
        if 'F12' in fresh:
            self.gate = True
            return 'screenshot'
        if fresh & {'Q', 'ESCAPE'}:
            return 'quit'
        if self.confirm:
            if 'RETURN' in fresh:
                self.reset()
                self.gate = True
                return 'restart'
            if fresh:
                self.confirm = False
                self.gate = True
                return 'cancel_restart'
            return None
        if 'M' in fresh:
            self.title = True
            self.gate = True
            return 'menu'
        if 'R' in fresh:
            self.confirm = True
            self.gate = True
            return 'confirm_restart'
        if self.title:
            if fresh & {'1', '2', '3'}:
                self.level = int(sorted(fresh & {'1', '2', '3'})[0]) - 1
                self.reset()
                self.gate = True
                return 'level'
            for i, keys in enumerate(MAPS[self.mapping]):
                if fresh & set(keys):
                    self.ready[i] = True
            if 'RETURN' in fresh and all(self.ready):
                self.title = False
                self.paused = False
                self.phase = 0
                self.gate = True
                return 'start'
            if fresh:
                return 'input_check'
            return None
        if 'P' in fresh:
            self.paused = not self.paused
            self.gate = True
            return 'pause' if self.paused else 'resume'
        if self.paused:
            return None
        if self.state.result:
            return None
        self.phase += 4
        if self.phase < 30:
            return None
        self.phase -= 30
        self.timeline.act(self.logical(down))
        return 'tick'

# The ASCII UI is a teaching replacement, not a copy of original app.py.
class App:
    def __init__(self):
        self.ui = Controller()
        self.message = 'RELEASE ALL KEYS, THEN TEST BOTH PLAYERS'

    def control(self, down):
        event = self.ui.feed(down)
        if event:
            self.message = event.upper().replace('_', ' ')
        return event

    def draw(self, p):
        u, s = self.ui, self.ui.state
        p.cls(1)
        p.text(16, 12, 'TWIN TIGHTROPE / LESSON ' + str(STAGE), 10)
        p.text(16, 27, 'TEACHING UI / 30 FPS -> 4 LOGICAL TICKS PER SECOND', 6)
        p.text(16, 45, 'BOARD ' + u.board.id + '  TICK ' + str(s.t) + '  MODE ' + u.mode.upper(), 7)
        p.text(330, 45, 'COOLDOWN ' + str(s.cooldown) + '  PHASE ' + str(u.phase), 6)
        for i in range(2):
            y = 126 + i * 118
            color = (10, 11)[i]
            tilt = s.b[i]
            p.text(16, y - 36, 'AB'[i] + '  POS ' + str(s.x[i]) + '/8', color)
            p.text(16, y - 23, 'BAL ' + str(tilt) + ' FALLS ' + str(s.falls[i]), color)
            for k in range(9):
                x, yy = 166 + k * 45, y - tilt * 8 + tilt * 2 * k
                p.circ(x, yy, 3, 7)
                if k < 8:
                    p.line(x, yy, x + 45, yy + tilt * 2, 4)
                if STAGE >= 3 and k in u.board.knots[i]:
                    p.rectb(x - 6, yy - 6, 13, 13, 8)
                if k == s.x[i]:
                    p.circ(x, yy - 16, 6, color)
                    p.rect(x - 3, yy - 10, 7, 8, color)
                    if s.last_brace[i]:
                        p.line(x - 10, yy - 5, x + 10, yy - 5, color)
            p.text(168, y + 30, 'MOVE ' + str(s.last_moves[i]) + '  BRACE ' + str(s.last_brace[i]) + '  PREPARED ' + str(s.prepared[i]), color)
        p.text(16, 302, 'WIND LAST ' + str(s.last_wind) + '  NEXT ' + str(forecast(u.board, s) if STAGE >= 3 else ()), 6)
        p.text(16, 319, self.message, 10)
        p.text(16, 337, 'A: ' + '/'.join(MAPS[u.mapping][0]) + '  B: ' + '/'.join(MAPS[u.mapping][1]), 7)
        p.text(16, 351, 'ORDER: LEFT / RIGHT / BRACE   HOLD = ONE STEP PER TWO TICKS', 7)
        p.text(16, 371, 'P PAUSE / R RESTART CONFIRM / M MENU / Q OR ESC QUIT', 6)
        p.text(16, 385, 'F12: USE ORIGINAL APP FOR IMAGE KEY / CLI CAPTURE IN README', 6)
        p.text(16, 399, ('1-3 BOARD / ' if STAGE >= 3 else 'A ONLY / ') + ('B COMPARE / F2 MAPPING / ' if STAGE >= 4 else 'FIXED NORMAL KEYS / ') + ('PAUSED X REWIND' if STAGE == 5 else 'NO REWIND YET'), 6)
        if s.result:
            p.rect(133, 154, 350, 90, 1); p.rectb(133, 154, 350, 90, 11)
            p.text(152, 170, 'BOTH ARRIVED / EQUAL SUCCESS AT ANY PACE', 11)
            p.text(152, 190, 'LAST STEP TOGETHER: ' + str(s.final_together), 7)
            p.text(152, 210, 'R RESTART / M INPUT CHECK', 6)
        if u.paused:
            p.rect(123, 142, 370, 102, 1); p.rectb(123, 142, 370, 102, 10)
            p.text(140, 158, 'PAUSED / TIME, WIND AND BOTH PLAYERS STOP', 10)
            p.text(140, 179, 'P RESUME, THEN RELEASE EVERY KEY', 7)
            p.text(140, 200, 'X: BEFORE MOST RECENT FALL' if STAGE == 5 else 'REWIND IS ADDED IN STAGE 05', 6)
        if u.title:
            p.rect(80, 72, 456, 207, 1); p.rectb(80, 72, 456, 207, 10)
            p.text(96, 89, 'INPUT CHECK / TEST BOTH ROLES', 10)
            for i in range(2):
                p.text(96, 118 + i * 41, 'AB'[i] + ' ' + '/'.join(MAPS[u.mapping][i][0:2]) + ' WALK  ' + MAPS[u.mapping][i][2] + ' BRACE', (10,11)[i])
                p.text(96, 132 + i * 41, 'READY ' + str(u.ready[i]), (10,11)[i])
            p.text(96, 204, 'ENTER AFTER BOTH READY, THEN RELEASE ALL', 7)
            p.text(96, 226, 'SYNTHETIC QA IS NOT TWO-HUMAN PLAYTESTING', 6)
            p.text(96, 248, 'ACTUAL KEYBOARD ROLLOVER IS UNVERIFIED', 6)
        if u.confirm:
            p.rect(106, 149, 404, 82, 1); p.rectb(106, 149, 404, 82, 8)
            p.text(125, 167, 'RESTART SAME BOARD? BOTH STATES WILL RESET', 10)
            p.text(125, 190, 'ENTER YES / OTHER KEY CANCEL', 7)
            p.text(125, 209, 'R DOES NOT RELOAD SAVED PYTHON CODE', 6)


def tap(app, keys):
    app.control(set())
    return app.control(set(keys))


def start(app):
    tap(app, set(MAPS[app.ui.mapping][0][2:]) | set(MAPS[app.ui.mapping][1][2:]))
    tap(app, {'RETURN'})
    app.control(set())


def checkpoint(app):
    start(app)
    for _ in range(38):
        app.control({'D', 'RIGHT'})


def main(headless=False, screenshot=None, state_json=None, demo=False):
    import pyxel as p
    from pathlib import Path
    p.init(616, 420, title='082 lesson ' + str(STAGE), fps=30, display_scale=1, headless=headless)
    app = App()
    if demo:
        checkpoint(app)
    keys = {k: getattr(p, 'KEY_' + k) for k in KEYS}
    def update():
        if headless:
            if p.frame_count >= 3:
                app.draw(p)
                if screenshot:
                    p.screen.save(screenshot, 1)
                if state_json:
                    Path(state_json).write_text(json.dumps({'stage': STAGE, 'state': asdict(app.ui.state), 'phase': app.ui.phase, 'title': app.ui.title, 'scope': 'Actual Pyxel Linux/headless; synthetic controller input; dummy audio; no physical keys or human playtest'}, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
                p.quit()
            return
        if app.control({k for k, v in keys.items() if p.btn(v)}) == 'quit':
            p.quit()
    p.run(update, lambda: app.draw(p))


if __name__ == '__main__':
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description='Standalone twin tightrope tutorial')
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--screenshot')
    parser.add_argument('--state-json')
    args = parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke:
        parser.error('demo and outputs require --headless-smoke')
    # Resolve before Pyxel init can change cwd; validate ALL outputs before import.
    for field, suffix in (('screenshot', '.png'), ('state_json', '.json')):
        value = getattr(args, field)
        if value:
            target = Path(value).resolve()
            if target.suffix.lower() != suffix or not target.parent.is_dir() or target.exists():
                parser.error('output must be a new ' + suffix + ' in an existing directory')
            setattr(args, field, str(target))
    main(args.headless_smoke, args.screenshot, args.state_json, args.demo_checkpoint)
STEP 04 / 05

同じ入力で、伝わる相手だけを変える

1つの条件だけを変える比較・役ごとのキーの割り当て・安全な切り替え。

入力の確認の画面で B を押すと、mode を other/self に切り替えます。other は相手の実際の移動が自分をかたむける元の版、self は自分の実際の移動が自分をかたむける比較版です。変わるのは、moves のどちらを見るかだけです。風、結び目、待ち時間、支え、尻もちで1歩戻る、2人とも8歩という目標は同じです。切り替えると同じ橋を最初からにするので、途中までの違う履歴どうしを比べることはしません。

F2 は、元のゲームにある2つの決まったキーの割り当てを切り替えます。ふつうは A/D/S と左/右/下。もう1つの割り当ては、A の役が F/H/G、B の役が J/L/K で、それぞれ左/右/支えです。好きなキーに割り当て直す機能はありません。どちらの割り当てでも、2人のキーを試して READY を点け、Enter のあと全部離す必要があります。2人が同時にキーを押すこと自体を禁止する、1人用のキー待ちの仕組みではありません。

比較を確かめる一番短い入力は、「A の役が右、B の役は待つ」です。元の版なら位置 (1,0)、かたむき (0,1)。self なら位置 (1,0)、かたむき (1,0) です。2人同時に右だけを押すと、同じかたむきが生まれるので、比べたときの違いが見つけにくくなります。B の役に支えさせると、元の版の B の揺れが0に消えるので、何を同じにしておきたいかを考えて、入力の並びを選びます。

この段階のルールは、03 と同じ元のゲームの step です。新しく学ぶのは、controller の mode と mapping、切り替えたときの reset、logical の2人ぶんのキーの表です。B/F2 が働くのは、title=True のときだけです。走っている間は、元のゲームと同じく切り替えません。最初から持っていた State や replay の項目をもう一度読み、表示、入力の変換、状態を変えないルール、という3つの層を追いましょう。「会話が増える」「協力がおもしろくなる」などは、この自動の比較からは証明できません。

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

04_compare_inputs.py / 注目する部分
        v = s.b[i] + moves[i if mode == 'self' else 1 - i] + wind
        if brace:
            v = 0

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_compare_inputs.py
macOS / Linux
.venv/bin/python 04_compare_inputs.py
画面で見ることM で入力の確認に戻り、F2、B、G と K、Enter、キーを全部離す、と押します。H だけを1刻押すと、MODE SELF、位置 (1,0)、かたむき (1,0) です。ふつうの割り当ての D を押しても、もう1つの割り当てでは A の役の移動になりません。
  • 変えるルールは、揺れをどちらから受けるかだけ
  • 決まった2つの割り当てを、logical にまとめる
  • 切り替えたら、同じ橋を最初からにする
つまずきやすいところキーの割り当ての変更を、model.step の左右のデータに混ぜないでください。logical が同じ (left,right,brace) に変換し、ルールは実際のキーの名前を知りません。比べるときは、同じ最初の状態と、同じ意味の入力を使います。
少し変えてみよう

コードは変えずに、保存してある 04 を終了 → 実行し直します。F2 → B → G と K → Enter → キーを全部離す → H だけを1刻、と押します。python exercises/04_role_mapping.py は、この操作と、元の版での同じ入力の両方を実行し、self=(1,0)、other=(0,1) のかたむきを確かめます。M → B で元の版に、M → F2 でふつうの割り当てに戻します。

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

"""Japanese tutorial: see LESSON.md. No external assets required."""

STAGE = 4

'Pure simultaneous 4 Hz toy-balance rules. No graphics or wall-clock dependencies.'

from dataclasses import dataclass, asdict, replace

import json, hashlib

VERSION = '082.1.0'

@dataclass(frozen=True)
class Board:
    id: str
    title: str
    knots: tuple
    wind: bool = False

BOARDS = (Board('A', 'まっすぐな朝', ((), ())), Board('B', '結び目のすれ違い', ((2, 6), (4,))), Board('C', '旗の鳴る夕方', ((3,), (5,)), True))

@dataclass(frozen=True)
class State:
    t: int = 0
    x: tuple = (0, 0)
    b: tuple = (0, 0)
    cooldown: tuple = (0, 0)
    prepared: tuple = (False, False)
    falls: tuple = (0, 0)
    events: tuple = ()
    result: bool = False
    last_moves: tuple = (0, 0)
    last_brace: tuple = (False, False)
    last_wind: int = 0
    final_together: bool = False

def new(board):
    return State()

def wind_at(board, t):
    return (1 if t >= 8 and t % 8 == 0 else -1 if t >= 12 and t % 8 == 4 else 0) if board.wind else 0

def forecast(board, s):
    return tuple(((t, wind_at(board, t)) for t in range(s.t + 1, s.t + 5) if wind_at(board, t)))

def normalize(inp):
    l, r, h = map(bool, inp)
    return (0 if h or l == r else 1 if r else -1, h)

def step(board, s, inputs, mode='other'):
    if s.result:
        return s
    desired = tuple((normalize(v) for v in inputs))
    moves = []
    cool = []
    prep = list(s.prepared)
    events = []
    for i, (d, brace) in enumerate(desired):
        moved = d if d and s.cooldown[i] == 0 and (0 <= s.x[i] + d <= 8) and (not (d > 0 and s.x[i] in board.knots[i] and (not s.prepared[i]))) else 0
        moves.append(moved)
        cool.append(1 if moved else max(0, s.cooldown[i] - 1))
        if brace:
            prep[i] = True
        elif moved > 0:
            prep[i] = False
        if d > 0 and (not moved) and (s.x[i] in board.knots[i]) and (not s.prepared[i]):
            events.append(('A', 'B')[i] + ' 結び目: 一刻支えてから')
    t = s.t + 1
    wind = wind_at(board, t)
    x = [s.x[i] + moves[i] for i in range(2)]
    bal = []
    falls = list(s.falls)
    for i, (d, brace) in enumerate(desired):
        v = s.b[i] + moves[i if mode == 'self' else 1 - i] + wind
        if brace:
            v = 0
        elif abs(v) >= 3:
            x[i] = max(0, x[i] - 1)
            v = 0
            falls[i] += 1
            events.append(('A', 'B')[i] + ' 尻もち: 一歩戻った / 支えて立て直そう')
        bal.append(v)
    if wind:
        events.insert(0, '風 ' + ('右 +1' if wind > 0 else '左 -1'))
    result = x == [8, 8]
    together = result and moves == [1, 1]
    if result:
        events.append('ふたりで到着!ゆっくりでも同じ達成')
    return State(t, tuple(x), tuple(bal), tuple(cool), tuple(prep), tuple(falls), tuple(events), result, tuple(moves), tuple((v[1] for v in desired)), wind, together)

def digest(s):
    return hashlib.sha256(json.dumps(asdict(s) if hasattr(s, '__dataclass_fields__') else s, sort_keys=True, ensure_ascii=False, separators=(',', ':')).encode('utf-8')).hexdigest()

NONE = (False, False, False)

RIGHT = (False, True, False)

LEFT = (True, False, False)

BRACE = (False, False, True)

class Timeline:

    def __init__(self, board, mode='other'):
        self.board = board
        self.mode = mode
        self.states = [new(board)]
        self.inputs = []

    @property
    def state(self):
        return self.states[-1]

    def act(self, inp):
        if self.state.result:
            return False
        self.inputs.append(tuple((tuple(v) for v in inp)))
        self.states.append(step(self.board, self.state, inp, self.mode))
        return True

    def replay(self):
        return {'version': VERSION, 'board': asdict(self.board), 'mode': self.mode, 'initial': asdict(self.states[0]), 'inputs': self.inputs[:], 'ticks': [s.t for s in self.states], 'hashes': [digest(s) for s in self.states], 'final': asdict(self.state)}
MAPS = ((('A', 'D', 'S'), ('LEFT', 'RIGHT', 'DOWN')), (('F', 'H', 'G'), ('J', 'L', 'K')))

KEYS = {'A', 'D', 'S', 'LEFT', 'RIGHT', 'DOWN', 'F', 'H', 'G', 'J', 'L', 'K', 'RETURN', 'SPACE', 'P', 'R', 'X', 'B', 'M', '1', '2', '3', 'F2', 'F12', 'Q', 'ESCAPE'}

class Controller:

    def __init__(self):
        self.level = 0
        self.mode = 'other'
        self.mapping = 0
        self.title = True
        self.paused = False
        self.confirm = False
        self.ready = [False, False]
        self.previous = set()
        self.gate = True
        self.phase = 0
        self.timeline = Timeline(self.board, self.mode)

    @property
    def board(self):
        return BOARDS[self.level]

    @property
    def state(self):
        return self.timeline.state

    def reset(self):
        self.timeline = Timeline(self.board, self.mode)
        self.phase = 0
        self.ready = [False, False]
        self.title = True
        self.paused = False
        self.confirm = False

    def logical(self, down):
        return tuple((tuple((k in down for k in m)) for m in MAPS[self.mapping]))

    def feed(self, down):
        down = set(down) & KEYS
        fresh = down - self.previous
        self.previous = down
        if self.gate:
            if not down:
                self.gate = False
            return None
        if 'F12' in fresh:
            self.gate = True
            return 'screenshot'
        if fresh & {'Q', 'ESCAPE'}:
            return 'quit'
        if self.confirm:
            if 'RETURN' in fresh:
                self.reset()
                self.gate = True
                return 'restart'
            if fresh:
                self.confirm = False
                self.gate = True
                return 'cancel_restart'
            return None
        if 'M' in fresh:
            self.title = True
            self.gate = True
            return 'menu'
        if 'R' in fresh:
            self.confirm = True
            self.gate = True
            return 'confirm_restart'
        if self.title:
            if fresh & {'1', '2', '3'}:
                self.level = int(sorted(fresh & {'1', '2', '3'})[0]) - 1
                self.reset()
                self.gate = True
                return 'level'
            if 'B' in fresh:
                self.mode = 'self' if self.mode == 'other' else 'other'
                self.reset()
                self.gate = True
                return 'mode'
            if 'F2' in fresh:
                self.mapping = 1 - self.mapping
                self.reset()
                self.gate = True
                return 'mapping'
            for i, keys in enumerate(MAPS[self.mapping]):
                if fresh & set(keys):
                    self.ready[i] = True
            if 'RETURN' in fresh and all(self.ready):
                self.title = False
                self.paused = False
                self.phase = 0
                self.gate = True
                return 'start'
            if fresh:
                return 'input_check'
            return None
        if 'P' in fresh:
            self.paused = not self.paused
            self.gate = True
            return 'pause' if self.paused else 'resume'
        if self.paused:
            return None
        if self.state.result:
            return None
        self.phase += 4
        if self.phase < 30:
            return None
        self.phase -= 30
        self.timeline.act(self.logical(down))
        return 'tick'

# The ASCII UI is a teaching replacement, not a copy of original app.py.
class App:
    def __init__(self):
        self.ui = Controller()
        self.message = 'RELEASE ALL KEYS, THEN TEST BOTH PLAYERS'

    def control(self, down):
        event = self.ui.feed(down)
        if event:
            self.message = event.upper().replace('_', ' ')
        return event

    def draw(self, p):
        u, s = self.ui, self.ui.state
        p.cls(1)
        p.text(16, 12, 'TWIN TIGHTROPE / LESSON ' + str(STAGE), 10)
        p.text(16, 27, 'TEACHING UI / 30 FPS -> 4 LOGICAL TICKS PER SECOND', 6)
        p.text(16, 45, 'BOARD ' + u.board.id + '  TICK ' + str(s.t) + '  MODE ' + u.mode.upper(), 7)
        p.text(330, 45, 'COOLDOWN ' + str(s.cooldown) + '  PHASE ' + str(u.phase), 6)
        for i in range(2):
            y = 126 + i * 118
            color = (10, 11)[i]
            tilt = s.b[i]
            p.text(16, y - 36, 'AB'[i] + '  POS ' + str(s.x[i]) + '/8', color)
            p.text(16, y - 23, 'BAL ' + str(tilt) + ' FALLS ' + str(s.falls[i]), color)
            for k in range(9):
                x, yy = 166 + k * 45, y - tilt * 8 + tilt * 2 * k
                p.circ(x, yy, 3, 7)
                if k < 8:
                    p.line(x, yy, x + 45, yy + tilt * 2, 4)
                if STAGE >= 3 and k in u.board.knots[i]:
                    p.rectb(x - 6, yy - 6, 13, 13, 8)
                if k == s.x[i]:
                    p.circ(x, yy - 16, 6, color)
                    p.rect(x - 3, yy - 10, 7, 8, color)
                    if s.last_brace[i]:
                        p.line(x - 10, yy - 5, x + 10, yy - 5, color)
            p.text(168, y + 30, 'MOVE ' + str(s.last_moves[i]) + '  BRACE ' + str(s.last_brace[i]) + '  PREPARED ' + str(s.prepared[i]), color)
        p.text(16, 302, 'WIND LAST ' + str(s.last_wind) + '  NEXT ' + str(forecast(u.board, s) if STAGE >= 3 else ()), 6)
        p.text(16, 319, self.message, 10)
        p.text(16, 337, 'A: ' + '/'.join(MAPS[u.mapping][0]) + '  B: ' + '/'.join(MAPS[u.mapping][1]), 7)
        p.text(16, 351, 'ORDER: LEFT / RIGHT / BRACE   HOLD = ONE STEP PER TWO TICKS', 7)
        p.text(16, 371, 'P PAUSE / R RESTART CONFIRM / M MENU / Q OR ESC QUIT', 6)
        p.text(16, 385, 'F12: USE ORIGINAL APP FOR IMAGE KEY / CLI CAPTURE IN README', 6)
        p.text(16, 399, ('1-3 BOARD / ' if STAGE >= 3 else 'A ONLY / ') + ('B COMPARE / F2 MAPPING / ' if STAGE >= 4 else 'FIXED NORMAL KEYS / ') + ('PAUSED X REWIND' if STAGE == 5 else 'NO REWIND YET'), 6)
        if s.result:
            p.rect(133, 154, 350, 90, 1); p.rectb(133, 154, 350, 90, 11)
            p.text(152, 170, 'BOTH ARRIVED / EQUAL SUCCESS AT ANY PACE', 11)
            p.text(152, 190, 'LAST STEP TOGETHER: ' + str(s.final_together), 7)
            p.text(152, 210, 'R RESTART / M INPUT CHECK', 6)
        if u.paused:
            p.rect(123, 142, 370, 102, 1); p.rectb(123, 142, 370, 102, 10)
            p.text(140, 158, 'PAUSED / TIME, WIND AND BOTH PLAYERS STOP', 10)
            p.text(140, 179, 'P RESUME, THEN RELEASE EVERY KEY', 7)
            p.text(140, 200, 'X: BEFORE MOST RECENT FALL' if STAGE == 5 else 'REWIND IS ADDED IN STAGE 05', 6)
        if u.title:
            p.rect(80, 72, 456, 207, 1); p.rectb(80, 72, 456, 207, 10)
            p.text(96, 89, 'INPUT CHECK / TEST BOTH ROLES', 10)
            for i in range(2):
                p.text(96, 118 + i * 41, 'AB'[i] + ' ' + '/'.join(MAPS[u.mapping][i][0:2]) + ' WALK  ' + MAPS[u.mapping][i][2] + ' BRACE', (10,11)[i])
                p.text(96, 132 + i * 41, 'READY ' + str(u.ready[i]), (10,11)[i])
            p.text(96, 204, 'ENTER AFTER BOTH READY, THEN RELEASE ALL', 7)
            p.text(96, 226, 'SYNTHETIC QA IS NOT TWO-HUMAN PLAYTESTING', 6)
            p.text(96, 248, 'ACTUAL KEYBOARD ROLLOVER IS UNVERIFIED', 6)
        if u.confirm:
            p.rect(106, 149, 404, 82, 1); p.rectb(106, 149, 404, 82, 8)
            p.text(125, 167, 'RESTART SAME BOARD? BOTH STATES WILL RESET', 10)
            p.text(125, 190, 'ENTER YES / OTHER KEY CANCEL', 7)
            p.text(125, 209, 'R DOES NOT RELOAD SAVED PYTHON CODE', 6)


def tap(app, keys):
    app.control(set())
    return app.control(set(keys))


def start(app):
    tap(app, set(MAPS[app.ui.mapping][0][2:]) | set(MAPS[app.ui.mapping][1][2:]))
    tap(app, {'RETURN'})
    app.control(set())


def checkpoint(app):
    start(app)
    for _ in range(38):
        app.control({'D', 'RIGHT'})


def main(headless=False, screenshot=None, state_json=None, demo=False):
    import pyxel as p
    from pathlib import Path
    p.init(616, 420, title='082 lesson ' + str(STAGE), fps=30, display_scale=1, headless=headless)
    app = App()
    if demo:
        checkpoint(app)
    keys = {k: getattr(p, 'KEY_' + k) for k in KEYS}
    def update():
        if headless:
            if p.frame_count >= 3:
                app.draw(p)
                if screenshot:
                    p.screen.save(screenshot, 1)
                if state_json:
                    Path(state_json).write_text(json.dumps({'stage': STAGE, 'state': asdict(app.ui.state), 'phase': app.ui.phase, 'title': app.ui.title, 'scope': 'Actual Pyxel Linux/headless; synthetic controller input; dummy audio; no physical keys or human playtest'}, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
                p.quit()
            return
        if app.control({k for k, v in keys.items() if p.btn(v)}) == 'quit':
            p.quit()
    p.run(update, lambda: app.draw(p))


if __name__ == '__main__':
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description='Standalone twin tightrope tutorial')
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--screenshot')
    parser.add_argument('--state-json')
    args = parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke:
        parser.error('demo and outputs require --headless-smoke')
    # Resolve before Pyxel init can change cwd; validate ALL outputs before import.
    for field, suffix in (('screenshot', '.png'), ('state_json', '.json')):
        value = getattr(args, field)
        if value:
            target = Path(value).resolve()
            if target.suffix.lower() != suffix or not target.parent.is_dir() or target.exists():
                parser.error('output must be a new ' + suffix + ' in an existing directory')
            setattr(args, field, str(target))
    main(args.headless_smoke, args.screenshot, args.state_json, args.demo_checkpoint)
STEP 05 / 05

共有停止から、最後の尻もち直前へ戻す

状態全部の履歴・出来事の境目・始めたあとまで残った入力。

Timeline.rewind_fall を加えると、model と Controller の一番外側の定義・定数が、元のゲームと同じ構造になります。Timeline は、states に最初の状態と毎刻の新しい状態を、inputs に1刻ごとの2人ぶんの入力を保存します。states は、いつも inputs より1件多くなります。位置だけでなく、かたむき、風の時刻、待ち時間、準備、尻もちの回数、結果、出来事も一緒に残します。

P で2人そろって止めてから X を押すと、states を最後から調べ、falls(尻もちの回数)が1つ前と違う、一番新しい境目を探します。その尻もちが起きる前まで states を切り、対応する inputs も同時に切ります。1刻前に戻す、ふつうの Undo ではありません。尻もちのあと何刻待っていても、最後の尻もちの直前に戻ります。尻もちが一度もなければ no_fall で、状態は変わりません。2人の状態を同じ時点に戻すので、片方の位置だけを戻して関係を壊すことはありません。

止めている間は phase も進まず、新しい風も吹きません。P で再開しても、押したままのキーが残っている間は gate=True で止まります。全部離した1回は、待ちを解くためだけに使い、その次から1秒に4刻の進み方に戻ります。X にも、同じ「キーが全部離されるのを待つ仕組み」があります。達成したあとも P で止めて履歴を調べられますが、X は「最後の尻もちを探す」ルールのままなので、必ず達成の1歩を戻すとは限りません。

R は、最初に戻すかの確認です。R を離して Enter なら、同じ橋・mode・mapping で最初に戻し、ほかのわかるキーなら取り消しです。Q/Escape は確認より先に終了を扱い、F12 も先に画像の出来事を返すのが、元のゲームの順番です。教材の画面は F12 でのファイルの保存を作っておらず、README にはっきり書いたコマンドだけが保存します。日本語の文字、飾りのアニメーション、操作ごとの保存、元のアプリの JSON の履歴の保存は省いています。Python のファイル単体は、画像や assets を読みこみません。元のゲームの app.py の record/save/board_draw へ進むと、同じルールを豊かな表示と記録につなげる方法を学べます。

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

05_rewind_together.py / 注目する部分
        for i in range(len(self.states) - 1, 0, -1):
            if self.states[i].falls != self.states[i - 1].falls:
                self.states = self.states[:i]
                self.inputs = self.inputs[:i - 1]
                return True

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_rewind_together.py
macOS / Linux
.venv/bin/python 05_rewind_together.py
画面で見ることふつうの割り当てで始め、D と右矢印を5刻押します。FALLS=(1,1) です。P → キーを全部離す → X で、t=4、位置 (2,2)、かたむき (2,2)、FALLS=(0,0) に戻ります。一時停止は続いています。P → キーを全部離したあと、S と下矢印で立て直せます。
  • 最後の尻もちの境目を探す
  • 2人の状態全部と、入力の列を一緒に戻す
  • 再開したら、キーが全部離されるのを待つ
つまずきやすいところ巻き戻しで x だけを戻すと、風の刻、prepared、cooldown が未来のまま残ってしまいます。inputs を切らないと、再生した結果も一致しません。X は、走っている間は巻き戻しをしません。
少し変えてみよう

コードは変えずに、保存 → 終了 → python 05_rewind_together.py で実行し直してみましょう。5刻右、P、X、P、キーを全部離す、2人とも1刻支える、と押します。python exercises/05_rewind_recover.py はこの操作を実行し、巻き戻したときの t=4 と、再開したあとの t=5・かたむき0・尻もち0を確かめます。R → Enter では入力の確認に戻って t=0 になりますが、保存したコードの変更は、Q で終了して起動し直すまで反映されません。

05_rewind_together.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(420行)
05_rewind_together.py / 全文
# -*- coding: utf-8 -*-
# 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.

"""Japanese tutorial: see LESSON.md. No external assets required."""

STAGE = 5

'Pure simultaneous 4 Hz toy-balance rules. No graphics or wall-clock dependencies.'

from dataclasses import dataclass, asdict, replace

import json, hashlib

VERSION = '082.1.0'

@dataclass(frozen=True)
class Board:
    id: str
    title: str
    knots: tuple
    wind: bool = False

BOARDS = (Board('A', 'まっすぐな朝', ((), ())), Board('B', '結び目のすれ違い', ((2, 6), (4,))), Board('C', '旗の鳴る夕方', ((3,), (5,)), True))

@dataclass(frozen=True)
class State:
    t: int = 0
    x: tuple = (0, 0)
    b: tuple = (0, 0)
    cooldown: tuple = (0, 0)
    prepared: tuple = (False, False)
    falls: tuple = (0, 0)
    events: tuple = ()
    result: bool = False
    last_moves: tuple = (0, 0)
    last_brace: tuple = (False, False)
    last_wind: int = 0
    final_together: bool = False

def new(board):
    return State()

def wind_at(board, t):
    return (1 if t >= 8 and t % 8 == 0 else -1 if t >= 12 and t % 8 == 4 else 0) if board.wind else 0

def forecast(board, s):
    return tuple(((t, wind_at(board, t)) for t in range(s.t + 1, s.t + 5) if wind_at(board, t)))

def normalize(inp):
    l, r, h = map(bool, inp)
    return (0 if h or l == r else 1 if r else -1, h)

def step(board, s, inputs, mode='other'):
    if s.result:
        return s
    desired = tuple((normalize(v) for v in inputs))
    moves = []
    cool = []
    prep = list(s.prepared)
    events = []
    for i, (d, brace) in enumerate(desired):
        moved = d if d and s.cooldown[i] == 0 and (0 <= s.x[i] + d <= 8) and (not (d > 0 and s.x[i] in board.knots[i] and (not s.prepared[i]))) else 0
        moves.append(moved)
        cool.append(1 if moved else max(0, s.cooldown[i] - 1))
        if brace:
            prep[i] = True
        elif moved > 0:
            prep[i] = False
        if d > 0 and (not moved) and (s.x[i] in board.knots[i]) and (not s.prepared[i]):
            events.append(('A', 'B')[i] + ' 結び目: 一刻支えてから')
    t = s.t + 1
    wind = wind_at(board, t)
    x = [s.x[i] + moves[i] for i in range(2)]
    bal = []
    falls = list(s.falls)
    for i, (d, brace) in enumerate(desired):
        v = s.b[i] + moves[i if mode == 'self' else 1 - i] + wind
        if brace:
            v = 0
        elif abs(v) >= 3:
            x[i] = max(0, x[i] - 1)
            v = 0
            falls[i] += 1
            events.append(('A', 'B')[i] + ' 尻もち: 一歩戻った / 支えて立て直そう')
        bal.append(v)
    if wind:
        events.insert(0, '風 ' + ('右 +1' if wind > 0 else '左 -1'))
    result = x == [8, 8]
    together = result and moves == [1, 1]
    if result:
        events.append('ふたりで到着!ゆっくりでも同じ達成')
    return State(t, tuple(x), tuple(bal), tuple(cool), tuple(prep), tuple(falls), tuple(events), result, tuple(moves), tuple((v[1] for v in desired)), wind, together)

def digest(s):
    return hashlib.sha256(json.dumps(asdict(s) if hasattr(s, '__dataclass_fields__') else s, sort_keys=True, ensure_ascii=False, separators=(',', ':')).encode('utf-8')).hexdigest()

NONE = (False, False, False)

RIGHT = (False, True, False)

LEFT = (True, False, False)

BRACE = (False, False, True)

class Timeline:

    def __init__(self, board, mode='other'):
        self.board = board
        self.mode = mode
        self.states = [new(board)]
        self.inputs = []

    @property
    def state(self):
        return self.states[-1]

    def act(self, inp):
        if self.state.result:
            return False
        self.inputs.append(tuple((tuple(v) for v in inp)))
        self.states.append(step(self.board, self.state, inp, self.mode))
        return True

    def rewind_fall(self):
        for i in range(len(self.states) - 1, 0, -1):
            if self.states[i].falls != self.states[i - 1].falls:
                self.states = self.states[:i]
                self.inputs = self.inputs[:i - 1]
                return True
        return False

    def replay(self):
        return {'version': VERSION, 'board': asdict(self.board), 'mode': self.mode, 'initial': asdict(self.states[0]), 'inputs': self.inputs[:], 'ticks': [s.t for s in self.states], 'hashes': [digest(s) for s in self.states], 'final': asdict(self.state)}

def safe_policy(board, mode='other', alternate=False, fall_first=False):
    tl = Timeline(board, mode)
    if fall_first:
        for _ in range(6):
            tl.act((RIGHT, RIGHT))
    for n in range(160):
        s = tl.state
        if s.result:
            return tl
        if n % 2:
            inp = (BRACE, BRACE)
        elif alternate:
            inp = tuple((RIGHT if i == n // 2 % 2 and s.x[i] < 8 else BRACE for i in range(2)))
        else:
            inp = tuple((RIGHT if s.x[i] < 8 else BRACE for i in range(2)))
        tl.act(inp)
    raise AssertionError('safe policy did not arrive')
MAPS = ((('A', 'D', 'S'), ('LEFT', 'RIGHT', 'DOWN')), (('F', 'H', 'G'), ('J', 'L', 'K')))

KEYS = {'A', 'D', 'S', 'LEFT', 'RIGHT', 'DOWN', 'F', 'H', 'G', 'J', 'L', 'K', 'RETURN', 'SPACE', 'P', 'R', 'X', 'B', 'M', '1', '2', '3', 'F2', 'F12', 'Q', 'ESCAPE'}

class Controller:

    def __init__(self):
        self.level = 0
        self.mode = 'other'
        self.mapping = 0
        self.title = True
        self.paused = False
        self.confirm = False
        self.ready = [False, False]
        self.previous = set()
        self.gate = True
        self.phase = 0
        self.timeline = Timeline(self.board, self.mode)

    @property
    def board(self):
        return BOARDS[self.level]

    @property
    def state(self):
        return self.timeline.state

    def reset(self):
        self.timeline = Timeline(self.board, self.mode)
        self.phase = 0
        self.ready = [False, False]
        self.title = True
        self.paused = False
        self.confirm = False

    def logical(self, down):
        return tuple((tuple((k in down for k in m)) for m in MAPS[self.mapping]))

    def feed(self, down):
        down = set(down) & KEYS
        fresh = down - self.previous
        self.previous = down
        if self.gate:
            if not down:
                self.gate = False
            return None
        if 'F12' in fresh:
            self.gate = True
            return 'screenshot'
        if fresh & {'Q', 'ESCAPE'}:
            return 'quit'
        if self.confirm:
            if 'RETURN' in fresh:
                self.reset()
                self.gate = True
                return 'restart'
            if fresh:
                self.confirm = False
                self.gate = True
                return 'cancel_restart'
            return None
        if 'M' in fresh:
            self.title = True
            self.gate = True
            return 'menu'
        if 'R' in fresh:
            self.confirm = True
            self.gate = True
            return 'confirm_restart'
        if self.title:
            if fresh & {'1', '2', '3'}:
                self.level = int(sorted(fresh & {'1', '2', '3'})[0]) - 1
                self.reset()
                self.gate = True
                return 'level'
            if 'B' in fresh:
                self.mode = 'self' if self.mode == 'other' else 'other'
                self.reset()
                self.gate = True
                return 'mode'
            if 'F2' in fresh:
                self.mapping = 1 - self.mapping
                self.reset()
                self.gate = True
                return 'mapping'
            for i, keys in enumerate(MAPS[self.mapping]):
                if fresh & set(keys):
                    self.ready[i] = True
            if 'RETURN' in fresh and all(self.ready):
                self.title = False
                self.paused = False
                self.phase = 0
                self.gate = True
                return 'start'
            if fresh:
                return 'input_check'
            return None
        if 'P' in fresh:
            self.paused = not self.paused
            self.gate = True
            return 'pause' if self.paused else 'resume'
        if self.paused:
            if 'X' in fresh:
                self.gate = True
                return 'rewind_fall' if self.timeline.rewind_fall() else 'no_fall'
            return None
        if self.state.result:
            return None
        self.phase += 4
        if self.phase < 30:
            return None
        self.phase -= 30
        self.timeline.act(self.logical(down))
        return 'tick'

# The ASCII UI is a teaching replacement, not a copy of original app.py.
class App:
    def __init__(self):
        self.ui = Controller()
        self.message = 'RELEASE ALL KEYS, THEN TEST BOTH PLAYERS'

    def control(self, down):
        event = self.ui.feed(down)
        if event:
            self.message = event.upper().replace('_', ' ')
        return event

    def draw(self, p):
        u, s = self.ui, self.ui.state
        p.cls(1)
        p.text(16, 12, 'TWIN TIGHTROPE / LESSON ' + str(STAGE), 10)
        p.text(16, 27, 'TEACHING UI / 30 FPS -> 4 LOGICAL TICKS PER SECOND', 6)
        p.text(16, 45, 'BOARD ' + u.board.id + '  TICK ' + str(s.t) + '  MODE ' + u.mode.upper(), 7)
        p.text(330, 45, 'COOLDOWN ' + str(s.cooldown) + '  PHASE ' + str(u.phase), 6)
        for i in range(2):
            y = 126 + i * 118
            color = (10, 11)[i]
            tilt = s.b[i]
            p.text(16, y - 36, 'AB'[i] + '  POS ' + str(s.x[i]) + '/8', color)
            p.text(16, y - 23, 'BAL ' + str(tilt) + ' FALLS ' + str(s.falls[i]), color)
            for k in range(9):
                x, yy = 166 + k * 45, y - tilt * 8 + tilt * 2 * k
                p.circ(x, yy, 3, 7)
                if k < 8:
                    p.line(x, yy, x + 45, yy + tilt * 2, 4)
                if STAGE >= 3 and k in u.board.knots[i]:
                    p.rectb(x - 6, yy - 6, 13, 13, 8)
                if k == s.x[i]:
                    p.circ(x, yy - 16, 6, color)
                    p.rect(x - 3, yy - 10, 7, 8, color)
                    if s.last_brace[i]:
                        p.line(x - 10, yy - 5, x + 10, yy - 5, color)
            p.text(168, y + 30, 'MOVE ' + str(s.last_moves[i]) + '  BRACE ' + str(s.last_brace[i]) + '  PREPARED ' + str(s.prepared[i]), color)
        p.text(16, 302, 'WIND LAST ' + str(s.last_wind) + '  NEXT ' + str(forecast(u.board, s) if STAGE >= 3 else ()), 6)
        p.text(16, 319, self.message, 10)
        p.text(16, 337, 'A: ' + '/'.join(MAPS[u.mapping][0]) + '  B: ' + '/'.join(MAPS[u.mapping][1]), 7)
        p.text(16, 351, 'ORDER: LEFT / RIGHT / BRACE   HOLD = ONE STEP PER TWO TICKS', 7)
        p.text(16, 371, 'P PAUSE / R RESTART CONFIRM / M MENU / Q OR ESC QUIT', 6)
        p.text(16, 385, 'F12: USE ORIGINAL APP FOR IMAGE KEY / CLI CAPTURE IN README', 6)
        p.text(16, 399, ('1-3 BOARD / ' if STAGE >= 3 else 'A ONLY / ') + ('B COMPARE / F2 MAPPING / ' if STAGE >= 4 else 'FIXED NORMAL KEYS / ') + ('PAUSED X REWIND' if STAGE == 5 else 'NO REWIND YET'), 6)
        if s.result:
            p.rect(133, 154, 350, 90, 1); p.rectb(133, 154, 350, 90, 11)
            p.text(152, 170, 'BOTH ARRIVED / EQUAL SUCCESS AT ANY PACE', 11)
            p.text(152, 190, 'LAST STEP TOGETHER: ' + str(s.final_together), 7)
            p.text(152, 210, 'R RESTART / M INPUT CHECK', 6)
        if u.paused:
            p.rect(123, 142, 370, 102, 1); p.rectb(123, 142, 370, 102, 10)
            p.text(140, 158, 'PAUSED / TIME, WIND AND BOTH PLAYERS STOP', 10)
            p.text(140, 179, 'P RESUME, THEN RELEASE EVERY KEY', 7)
            p.text(140, 200, 'X: BEFORE MOST RECENT FALL' if STAGE == 5 else 'REWIND IS ADDED IN STAGE 05', 6)
        if u.title:
            p.rect(80, 72, 456, 207, 1); p.rectb(80, 72, 456, 207, 10)
            p.text(96, 89, 'INPUT CHECK / TEST BOTH ROLES', 10)
            for i in range(2):
                p.text(96, 118 + i * 41, 'AB'[i] + ' ' + '/'.join(MAPS[u.mapping][i][0:2]) + ' WALK  ' + MAPS[u.mapping][i][2] + ' BRACE', (10,11)[i])
                p.text(96, 132 + i * 41, 'READY ' + str(u.ready[i]), (10,11)[i])
            p.text(96, 204, 'ENTER AFTER BOTH READY, THEN RELEASE ALL', 7)
            p.text(96, 226, 'SYNTHETIC QA IS NOT TWO-HUMAN PLAYTESTING', 6)
            p.text(96, 248, 'ACTUAL KEYBOARD ROLLOVER IS UNVERIFIED', 6)
        if u.confirm:
            p.rect(106, 149, 404, 82, 1); p.rectb(106, 149, 404, 82, 8)
            p.text(125, 167, 'RESTART SAME BOARD? BOTH STATES WILL RESET', 10)
            p.text(125, 190, 'ENTER YES / OTHER KEY CANCEL', 7)
            p.text(125, 209, 'R DOES NOT RELOAD SAVED PYTHON CODE', 6)


def tap(app, keys):
    app.control(set())
    return app.control(set(keys))


def start(app):
    tap(app, set(MAPS[app.ui.mapping][0][2:]) | set(MAPS[app.ui.mapping][1][2:]))
    tap(app, {'RETURN'})
    app.control(set())


def checkpoint(app):
    start(app)
    for _ in range(38):
        app.control({'D', 'RIGHT'})


def main(headless=False, screenshot=None, state_json=None, demo=False):
    import pyxel as p
    from pathlib import Path
    p.init(616, 420, title='082 lesson ' + str(STAGE), fps=30, display_scale=1, headless=headless)
    app = App()
    if demo:
        checkpoint(app)
    keys = {k: getattr(p, 'KEY_' + k) for k in KEYS}
    def update():
        if headless:
            if p.frame_count >= 3:
                app.draw(p)
                if screenshot:
                    p.screen.save(screenshot, 1)
                if state_json:
                    Path(state_json).write_text(json.dumps({'stage': STAGE, 'state': asdict(app.ui.state), 'phase': app.ui.phase, 'title': app.ui.title, 'scope': 'Actual Pyxel Linux/headless; synthetic controller input; dummy audio; no physical keys or human playtest'}, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
                p.quit()
            return
        if app.control({k for k, v in keys.items() if p.btn(v)}) == 'quit':
            p.quit()
    p.run(update, lambda: app.draw(p))


if __name__ == '__main__':
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description='Standalone twin tightrope tutorial')
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--screenshot')
    parser.add_argument('--state-json')
    args = parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke:
        parser.error('demo and outputs require --headless-smoke')
    # Resolve before Pyxel init can change cwd; validate ALL outputs before import.
    for field, suffix in (('screenshot', '.png'), ('state_json', '.json')):
        value = getattr(args, field)
        if value:
            target = Path(value).resolve()
            if target.suffix.lower() != suffix or not target.parent.is_dir() or target.exists():
                parser.error('output must be a new ' + suffix + ' in an existing directory')
            setattr(args, field, str(target))
    main(args.headless_smoke, args.screenshot, args.state_json, args.demo_checkpoint)
AFTER STEP 05

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

ルールのまとめ:2人の前の状態から実際の移動を決める → 相手(比較版では自分)の実際の移動+風 → 支えていれば0 → ±3なら1歩戻って尻もち → 2人とも8なら達成。

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

比べる点5ステップの教材元の完成版
段階01 移動だけ、02 かたむきと支え、03 結び目と風、04 比較ともう1つのキーの割り当て、05 2人そろっての巻き戻し。早い段階の4Hz・入力の確認・一時停止・R の確認は、先に用意した足場。最初から、すべての機能があります。
画面・保存616×420の英数字(ASCII)の教材の画面。F12 でのファイルの保存なし。画面を出さない(headless)コマンドで、指定した新しい PNG/JSON だけを保存。音なし。900×660の日本語、風までの残りの刻、くわしい出来事、飾り、画像・コード・履歴の自動保存。
配布それぞれの py ファイルに、ルール・Controller・MIT ライセンスの全文を入れています。assets は不要。model/controller/glyphs を別々のファイルとして読みこみます。

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

  1. model.py の normalize / step:moves と prepared
  2. model.py の wind_at / forecast
  3. model.py の Timeline.act / rewind_fall / replay
  4. model.py の safe_policy:交代で進む進め方
  5. controller.py の logical / feed:キーを待つ仕組み(gate)と phase
  6. app.py の record / save / board_draw
自分のゲームに変えるなら

最初は、数字かルールを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で起動して画面を描けることも確かめました。元の完成版も、自動で遊ぶデモが起動して画面を描き、正常に終わることを確認しています。2人分の操作は自動入力で確かめたもので、実際のキーボードでの同時押しや、2人で遊んだときの確認はまだです。

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

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