# -*- 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)