# MIT License # # Copyright (c) 2026 Pyxel 100 contributors # # 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. # # The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL. # See assets/NOTO-LICENSE.txt. # The bundled Latin/digit bitmap glyphs derive from DejaVu Sans Mono. # See assets/DEJAVU-LICENSE.txt for its separate font license. # 教材画面はPyxel標準ASCIIと図形のみ。字形データは同梱しません。 """折りたたみ駅・教材5/5。説明はLESSON.md。画面はASCIIのみ。""" from dataclasses import dataclass @dataclass(frozen=True) class Station: id: str pos: tuple[int, int] @dataclass(frozen=True) class Rail: a: str b: str points: tuple[tuple[int, int], ...] @dataclass(frozen=True) class Level: title: str stations: tuple[Station, ...] rails: tuple[Rail, ...] folds: tuple[str, ...] start: str = 'S' goal: str = 'G' # 原作B「折れ目は運休」。座標と路線の端点を変更しない。 LEVEL = Level('折れ目は運休', (Station('S', (0, 0)), Station('Q', (3, 0)), Station('R', (1, 0)), Station('G', (1, 3))), (Rail('Q', 'R', ((3, 0), (2, 0), (1, 0))),), ('V', 'H')) def positions(level): return {station.id: station.pos for station in level.stations} def validate(level): ids = [station.id for station in level.stations] p = positions(level) assert len(ids) == len(set(ids)) and len(set(p.values())) == len(ids) assert level.start in ids and level.goal in ids assert all(fold in ('V', 'H') for fold in level.folds) assert all(0 <= value <= 3 for pos in p.values() for value in pos) assert len({frozenset((r.a, r.b)) for r in level.rails}) == len(level.rails) for rail in level.rails: assert rail.a != rail.b assert rail.points[0] == p[rail.a] and rail.points[-1] == p[rail.b] assert all(0 <= value <= 3 for pos in rail.points for value in pos) assert all(abs(a[0]-b[0])+abs(a[1]-b[1]) == 1 for a, b in zip(rail.points, rail.points[1:])) validate(LEVEL) @dataclass(frozen=True) class State: station: str = 'S' v: bool = False h: bool = False folds: int = 0 moves: int = 0 @dataclass(frozen=True) class Result: state: State accepted: bool reason: str def initial(level=LEVEL): return State(level.start) def key(state): return state.station, state.v, state.h def project(pos, v=False, h=False): # 元座標に毎回適用する。折った図を次の元座標にしない。 x, y = pos return (3-x if v and x >= 2 else x, 3-y if h and y >= 2 else y) @dataclass(frozen=True) class Option: action: str target: str kind: str blocked: tuple[str, ...] = () def overlaps(level, state): p = positions(level) here = project(p[state.station], state.v, state.h) return tuple(station.id for station in level.stations if station.id != state.station and project(station.pos, state.v, state.h) == here) def crosses(rail): # 線路を構成する元の単位線分の両端で、折れ目の側が変わるか。 return tuple(axis for axis, index in (('V', 0), ('H', 1)) if any((a[index] < 2) != (b[index] < 2) for a, b in zip(rail.points, rail.points[1:]))) def blocked(rail, state, suspend=True): return tuple(axis for axis in crosses(rail) if suspend and (state.v if axis == 'V' else state.h)) def options(level, state, suspend=True): result = [Option('T:'+target, target, 'transfer') for target in overlaps(level, state)] for rail in level.rails: if state.station in (rail.a, rail.b): target = rail.b if state.station == rail.a else rail.a result.append(Option('R:'+target, target, 'rail', blocked(rail, state, suspend))) return tuple(result) def won(level, state): return state.station == level.goal and not state.v and not state.h def transition(level, state, action, suspend=True, terminal=True): if terminal and won(level, state): return Result(state, False, 'complete') if action in ('V', 'H'): if action not in level.folds: return Result(state, False, 'unused') return Result(State(state.station, not state.v if action == 'V' else state.v, not state.h if action == 'H' else state.h, state.folds+1, state.moves), True, 'unfold' if (state.v if action == 'V' else state.h) else 'fold') option = next((o for o in options(level, state, suspend) if o.action == action), None) if option is None: return Result(state, False, 'no_route') if option.blocked: return Result(state, False, 'suspended') return Result(State(option.target, state.v, state.h, state.folds, state.moves+1), True, option.kind) @dataclass(frozen=True) class Timeline: state: State past: tuple[State, ...] = () def apply(level, timeline, action, suspend=True): result = transition(level, timeline.state, action, suspend) if result.accepted: return Timeline(result.state, timeline.past+(timeline.state,)), result return timeline, result def undo(timeline): if not timeline.past: return timeline return Timeline(timeline.past[-1], timeline.past[:-1]) def state_hash(state): import hashlib import json payload = (state.station, state.v, state.h, state.folds, state.moves) return hashlib.sha256(json.dumps(payload, separators=(',', ':')).encode('utf-8')).hexdigest() STAGE = 5 STAGE_TITLE = 'FOLDING STATION PUZZLE' CONTROLS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'SPACE', 'X', 'C', 'Z', 'R') REPLAY = {'L': 'LEFT', 'T': 'RIGHT', 'U': 'UP', 'D': 'DOWN', 'S': 'SPACE', 'X': 'X', 'C': 'C', 'Z': 'Z', 'R': 'R'} class Controller: """画面入力を純粋な関数へ渡す。拒否した和音も全解放まで保留する。""" def __init__(self): self.gate = False self.reset() def reset(self): self.timeline = Timeline(initial()) self.cursor = None self.reason = 'ready' # gateを消さない。Rも全操作キーを離すまで再受付しない。 @property def state(self): return self.timeline.state def tick(self, pressed=(), held=()): pressed = set(pressed) & set(CONTROLS) held = set(held) & set(CONTROLS) if self.gate: if not held: self.gate = False return if not pressed and len(held) <= 1: return if len(pressed) != 1 or pressed != held: self.reason = 'invalid' self.gate = True return key_name = next(iter(pressed)) self.gate = True if key_name == 'R': self.reset() return if key_name == 'Z': self.timeline = undo(self.timeline) self.cursor = None self.reason = 'undo' return if won(LEVEL, self.state): self.reason = 'complete' return if key_name in ('X', 'C'): action = 'V' if key_name == 'X' else 'H' elif key_name in ('LEFT', 'RIGHT', 'UP', 'DOWN'): routes = options(LEVEL, self.state) if routes: step = 1 if key_name in ('RIGHT', 'DOWN') else -1 self.cursor = (0 if step == 1 else len(routes)-1) if self.cursor is None else (self.cursor+step) % len(routes) self.reason = 'select' else: self.reason = 'no_route' return elif key_name == 'SPACE': routes = options(LEVEL, self.state) if self.cursor is None or self.cursor >= len(routes): self.reason = 'unselected' return action = routes[self.cursor].action else: self.reason = 'unused' return self.timeline, result = apply(LEVEL, self.timeline, action) self.reason = result.reason if result.accepted: self.cursor = None def snapshot(self): s = self.state result = {'station': s.station, 'v': s.v, 'h': s.h, 'folds': s.folds, 'moves': s.moves, 'reason': self.reason, 'cursor': self.cursor, 'projected': list(project(positions(LEVEL)[s.station], s.v, s.h))} result['options'] = [{'action': o.action, 'target': o.target, 'kind': o.kind, 'blocked': list(o.blocked)} for o in options(LEVEL, s)] result.update(won=won(LEVEL, s), history_depth=len(self.timeline.past), hash=state_hash(s)) return result def draw_board(pyxel, controller): pyxel.cls(1) pyxel.text(12, 10, f'FOLDING STATION / STEP {STAGE} / {STAGE_TITLE}', 10) pyxel.text(12, 26, 'Original case B: stable station IDs, changing paper positions.', 6) pyxel.text(20, 46, 'CURRENT PAPER', 7) pyxel.text(270, 46, 'ORIGINAL COORDINATES', 7) snapshot = controller.snapshot() v, h = snapshot.get('v', False), snapshot.get('h', False) passenger = snapshot.get('station') focused = snapshot.get('focus') def dashed(a, b, color): x, y = a u, w = b steps = max(abs(u-x), abs(w-y), 1) for n in range(0, int(steps)+1, 6): end = min(n+2, steps) pyxel.line(x+(u-x)*n/steps, y+(w-y)*n/steps, x+(u-x)*end/steps, y+(w-y)*end/steps, color) def map_view(ox, reference): oy, step = 92, 46 def point(pos): p = pos if reference else project(pos, v, h) return ox+p[0]*step, oy+p[1]*step width = 4 if reference or not v else 2 height = 4 if reference or not h else 2 pyxel.rect(ox-22, oy-22, width*step, height*step, 15) pyxel.rectb(ox-22, oy-22, width*step, height*step, 4) if reference or not v: dashed((ox+1.5*step, oy-22), (ox+1.5*step, oy+3*step+22), 13) if reference or not h: dashed((ox-22, oy+1.5*step), (ox+3*step+22, oy+1.5*step), 13) for x in range(width): for y in range(height): pyxel.pset(ox+x*step, oy+y*step, 4) for rail in LEVEL.rails: off = bool(blocked(rail, controller.state)) and not reference points = [point(p) for p in rail.points] for a, b in zip(points, points[1:]): if off: dashed(a, b, 8) else: pyxel.line(*a, *b, 5) pyxel.line(a[0], a[1]+2, b[0], b[1]+2, 5) groups = {} for station in LEVEL.stations: groups.setdefault(point(station.pos), []).append(station.id) for (x, y), ids in groups.items(): for index, station_id in enumerate(ids): bx = x-(len(ids)*20-2)//2+index*20 color = 10 if station_id == passenger else 1 pyxel.rect(bx, y-8, 18, 17, color) pyxel.rectb(bx, y-8, 18, 17, 8 if station_id == LEVEL.goal else 5) pyxel.text(bx+7, y-2, station_id, 1 if color == 10 else 7) if station_id == focused: pyxel.rectb(bx-2, y-10, 22, 21, 11) if station_id == passenger: pyxel.tri(bx+5, y-14, bx+13, y-14, bx+9, y-10, 8) for n in range(4): pyxel.text(ox+n*step-1, 257, str(n), 13) pyxel.text(ox-22, 268, 'x: 0..3 / y: 0..3 (down)', 13) map_view(42, False) map_view(292, True) pyxel.text(12, 286, f'TICKET: {passenger} / V: {v} / H: {h} / FOLDS: {controller.state.folds} / MOVES: {controller.state.moves}', 10) routes = options(LEVEL, controller.state) if not routes: pyxel.text(12, 303, 'ROUTES: (none)', 13) for index, route in enumerate(routes): marker = '> ' if index == controller.cursor else ' ' label = marker+route.kind.upper()+' TO '+route.target if route.blocked: label += ' / SUSPENDED '+','.join(route.blocked) pyxel.text(12, 303+index*16, label, 8 if route.blocked else 7) if won(LEVEL, controller.state): pyxel.rect(259, 298, 208, 36, 3) pyxel.text(271, 311, 'COMPLETE: G + BOTH UNFOLDED!', 10) pyxel.text(12, 346, 'EVENT: '+controller.reason.upper(), 8 if controller.reason in ('invalid','suspended') else 10) pyxel.text(12, 365, 'ARROWS: select SPACE: move X: V fold C: H fold', 7) pyxel.text(12, 385, 'Z: undo R: reset ESC: quit / GOAL: G + both open', 6) pyxel.text(12, 408, 'ASCII tutorial / silent / no time limit', 13) def main(headless=False, actions='', screenshot=None, report=None): import json from pathlib import Path screenshot = str(Path(screenshot).resolve()) if screenshot else None report = str(Path(report).resolve()) if report else None import pyxel controller = Controller() codes = {name: getattr(pyxel, 'KEY_'+name) for name in CONTROLS} frame = 0 events = [] last_frame = max(12, len(actions)*2+2) def update(): nonlocal frame frame += 1 if headless: index = (frame-1)//2 if frame % 2 and index < len(actions): name = REPLAY[actions[index]] controller.tick((name,), (name,)) events.append({'input': actions[index], 'snapshot': controller.snapshot()}) else: controller.tick() else: controller.tick([name for name, code in codes.items() if pyxel.btnp(code)], [name for name, code in codes.items() if pyxel.btn(code)]) def draw(): draw_board(pyxel, controller) if headless and frame >= last_frame: if screenshot: pyxel.screenshot(screenshot, scale=1) if report: Path(report).write_text(json.dumps({ 'label': 'Actual Pyxel headless; synthetic Controller.tick inputs; silent', 'stage': STAGE, 'frames': frame, 'actions': actions, 'events': events, 'snapshot': controller.snapshot(), }, ensure_ascii=False, indent=2)+'\n', encoding='utf-8') pyxel.quit() pyxel.init(480, 424, title=f'Folding Station - Step {STAGE}', fps=30, headless=headless) pyxel.run(update, draw) if __name__ == '__main__': import argparse parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--headless-smoke', action='store_true') parser.add_argument('--headless-replay', help='Synthetic keys: '+str(REPLAY)) parser.add_argument('--screenshot', help='PNG output; headless only') parser.add_argument('--report', help='UTF-8 JSON output; headless only') args = parser.parse_args() if args.headless_smoke and args.headless_replay is not None: parser.error('Choose smoke or replay') headless = args.headless_smoke or args.headless_replay is not None if not headless and (args.screenshot or args.report): parser.error('Outputs require headless mode') actions = args.headless_replay or '' if any(action not in REPLAY for action in actions): parser.error('Unsupported replay character') main(headless, actions, args.screenshot, args.report)