パズルLESSON 32折り紙・路線グラフのパズル原作025

折りたたみ駅の作り方

紙を折ると、遠い駅がとなりになる。
画面での見た目の近さと、実際の線路のつながりを分けて考え、「折り目をまたいでもいいか」という条件を、グラフ(点と線のつながり)で扱うパズルを作ります。

完成版の遊び方紙を折って駅を重ね、乗りかえます。折り目をまたぐ線路は止まってしまうので、そのときは紙を開きます。

折りたたみ駅の元の完成版。自動操作によるPyxelの実描画画面。
元の完成版の画面です。この講座では、仕組みがわかりやすいように小さくした「教材版」を、5つのステップで作ります。
PLAY / 試験公開

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

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

完成版の主な操作:X縦折れ目/C横折れ目、矢印行先、Space移動/乗換、Z戻す、R再開、P停止

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

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

うまく表示されないとき

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

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

画面に表示する位置と、つながりの情報を別々に持つ。

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

1. 座標・駅の ID・向きのないグラフ・1マス分の線・調べるためのカーソル
2. 元の座標と映した座標、反転、bool、状態の切りかえ
3. 同じ位置に映った駅・駅の ID はそのまま・状態の変化・選ぶことと確定すること
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
Python 3.11以上、.venv とターミナル / 変数・if・for・関数・tuple・dict の基本 / dataclass・向きのないグラフ・bool・履歴は本文で説明します
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
['01: 矢印=参照駅を選ぶ、R=初期化', '02: 矢印=参照、X=V、C=H、R=初期化', '03〜05: 矢印=行先、Space=確定、X=V、C=H、R=初期化', '05: Z=Undo。成功後はZ/Rで再開', '全段階: Escape=終了。全操作キーを離してから次の入力']
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_map.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

元の駅と印刷線路をデータにする

座標・駅の ID・向きのないグラフ・1マス分の線・調べるためのカーソル。

駅を Station(id,pos)、線路を Rail(a,b,points) で表します。4×4の座標は、左上が (0,0) で、右へ行くと x、下へ行くと y が増えます。グラフとは、駅を点、つながりを線として考える方法です。この面の線路は、Q と R を結ぶ1本だけです。points には描くための途中の点もありますが、(2,0) に新しい駅ができるわけではありません。矢印は、調べる駅を変えるだけで、乗客が動くわけではありません。元のゲームの状況 B の駅と線路のデータを、そのまま読みこみます。

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

01_map.py / 注目する部分
def positions(level):
    return {station.id: station.pos for station in level.stations}

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_map.py
macOS / Linux
.venv/bin/python 01_map.py
画面で見ること起動すると、S を緑の枠で調べています。右で Q=(3,0)、もう一度右で R=(1,0)、さらに右で G=(1,3) です。線があるのは Q-R だけで、S と G には線路がありません。左を押すと逆の順番になり、はしでは一周します。
  • 駅の ID は名前、pos は元の位置
  • Q-R は、どちら向きにも使える1本の線路
  • 線の途中の座標は駅ではない
  • 調べるためのカーソルと、乗客を区別する
つまずきやすいところ駅の順番 S,Q,R,G は、x 座標の順ではありません。画面で近い駅を自動でつないだり、線の途中の点を駅として扱ったりすると、別のグラフになってしまいます。
少し変えてみよう

右 → 右 で R=(1,0) を表示して、R → Q という逆向きにも同じ線路を使える理由を説明してみましょう。R キーで S を調べる状態に戻り、左で G へ一周することも確かめます。

01_map.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(266行)
01_map.py / 全文
# 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と図形のみ。字形データは同梱しません。

"""折りたたみ駅・教材1/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)

STAGE = 1
STAGE_TITLE = 'MAP AND GRAPH'

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.cursor = 0
        self.reason = 'ready'
        # gateを消さない。Rも全操作キーを離すまで再受付しない。

    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 in ('LEFT', 'RIGHT', 'UP', 'DOWN'):
            step = 1 if key_name in ('RIGHT', 'DOWN') else -1
            self.cursor = (self.cursor+step) % len(LEVEL.stations)
            self.reason = 'focus'
        else:
            self.reason = 'unused'

    def snapshot(self):
        station = LEVEL.stations[self.cursor]
        return {'focus': station.id, 'original': list(station.pos), 'reason': self.reason}



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
            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 = False
            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)
    focus = LEVEL.stations[controller.cursor]
    pyxel.text(12, 290, f'FOCUS: {focus.id} / ORIGINAL: {focus.pos}', 11)
    pyxel.text(12, 308, 'GRAPH: Q <-> R only. S and G have no printed rail.', 6)
    pyxel.text(12, 327, 'Inspection only: focus is not a passenger move.', 13)
    pyxel.text(12, 346, 'EVENT: '+controller.reason.upper(), 8 if controller.reason in ('invalid','suspended') else 10)
    pyxel.text(12, 365, 'ARROWS: inspect a station   R: reset   ESC: quit', 7)
    pyxel.text(12, 385, 'No movement, reflection, transfer, or goal check yet.', 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)
STEP 02 / 05

折り目を反射の式にする

元の座標と映した座標、反転、bool、状態の切りかえ。

縦の折り目 V は x=1.5 の位置です。右半分の x=2,3 だけを左に反転するので、x の式は 2×1.5−x=3−x になり、2→1、3→0 です。左半分の0と1は動きません。横の折り目 H は、下半分の y に同じ式を使います。project は表示用の座標を返すだけで、Station.pos(元の位置)は変えません。V と H は「今閉じているか」を表す bool で、X/C をもう一度押すと開きます。駅の ID はそのままで、絵だけが動く段階です。

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

02_reflection.py / 注目する部分
    x, y = pos
    return (3-x if v and x >= 2 else x,
            3-y if h and y >= 2 else y)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_reflection.py
macOS / Linux
.venv/bin/python 02_reflection.py
画面で見ること右で Q を調べて X を押すと、ORIGINAL(元の位置)は (3,0) のまま、PROJECTED(映した位置)は (0,0) になります。S と Q の札は同じ場所に並びますが、切符 TICKET は S のままです。X をもう一度押すと、Q の表示は (3,0) に戻ります。右の図はいつも、元の紙です。
  • 折り目はマス目の点ではなく、1.5 の位置にある
  • 反転するのは右半分・下半分だけ
  • 元の座標を保存して、映した位置は毎回計算する
  • 見た目が重なっても、切符の ID は変わらない
つまずきやすいところ前のフレームで映した座標に 3−x を何度も当てると、開いたときに元の位置に戻せません。いつも元の座標と、今の V/H から描き直します。この段階の線は見るためだけのもので、乗車や運休の判定はまだありません。
少し変えてみよう

右 → X で、Q の元の座標 (3,0) と映した位置 (0,0) を比べます。R → 右 → 右 → 右 → C では、G の元の位置 (1,3) が (1,0) に映ります。X → C と C → X の順番で、最後に映した位置が同じになることも確かめてください。

02_reflection.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(316行)
02_reflection.py / 全文
# 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と図形のみ。字形データは同梱しません。

"""折りたたみ駅・教材2/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)


def transition(level, state, action):
    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')
    return Result(state, False, 'unused')

STAGE = 2
STAGE_TITLE = 'REFLECT THE PAPER'

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.state = initial()
        self.cursor = 0
        self.reason = 'ready'
        # gateを消さない。Rも全操作キーを離すまで再受付しない。

    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 in ('LEFT', 'RIGHT', 'UP', 'DOWN'):
            step = 1 if key_name in ('RIGHT', 'DOWN') else -1
            self.cursor = (self.cursor+step) % len(LEVEL.stations)
            self.reason = 'focus'
        elif key_name in ('X', 'C'):
            result = transition(LEVEL, self.state, 'V' if key_name == 'X' else 'H')
            self.state = result.state
            self.reason = result.reason
        else:
            self.reason = 'unused'

    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['focus'] = LEVEL.stations[self.cursor].id
        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 = False
            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)
    focus = LEVEL.stations[controller.cursor]
    pyxel.text(12, 290, f'FOCUS: {focus.id} / ORIGINAL: {focus.pos}', 11)
    pyxel.text(12, 307, f'PROJECTED: {project(focus.pos, v, h)} / TICKET: S (unchanged)', 10)
    pyxel.text(12, 326, f'V: {v} / H: {h} / rail and transfer rules not active yet', 6)
    pyxel.text(12, 346, 'EVENT: '+controller.reason.upper(), 8 if controller.reason in ('invalid','suspended') else 10)
    pyxel.text(12, 365, 'ARROWS: inspect   X: V fold   C: H fold   R: reset', 7)
    pyxel.text(12, 385, 'Fold from ORIGINAL positions. Unfold restores the view.', 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)
STEP 03 / 05

重なった駅へ明示的に乗り換える

同じ位置に映った駅・駅の ID はそのまま・状態の変化・選ぶことと確定すること。

overlaps は、今いる駅と同じ位置に映っている、別の ID の駅を集めます。乗りかえは「T:Q」のような行動で、はっきり確定したときだけ state.station を変えます。折っただけでは、自動で乗りかえません。矢印で行き先を選び、Space で確定し、確定したら選択を消します。ここから矢印は、調べる駅ではなく、行き先の欄を選ぶようになります。線路は見えていますが、まだ乗車の機能は足していません。

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

03_transfer.py / 注目する部分
    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)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_transfer.py
macOS / Linux
.venv/bin/python 03_transfer.py
画面で見ることX → 右 → Space で、S から Q へ乗りかえます。X で開くと、切符は Q のまま、Q の元の位置 (3,0) に現れます。X の直後に Space だけを押しても UNSELECTED(未選択)で動きません。折っただけでは S のままです。
  • 重なっても、元の ID は消えない
  • 「T:」の行動で、はっきり乗りかえる
  • 確定したら行き先の選択を消す
  • 開いたあとも、切符の ID はそのまま
つまずきやすいところS と Q が重なったからといって、state.station を勝手に Q に変えません。乗りかえの「T:Q」と、列車の「R:Q」は別の種類の行動です。ID を座標に置きかえてしまうと、紙を開いたときに、どちらに戻ればいいか分からなくなります。
少し変えてみよう

X → Space で切符が S のままなことを確かめ、全部のキーを離してから、右 → Space → X で Q に渡って紙を開きます。もう一度 X → 右 → Space → X で、S に戻れます。Space を押す前には、毎回行き先を選んでください。

03_transfer.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(365行)
03_transfer.py / 全文
# 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と図形のみ。字形データは同梱しません。

"""折りたたみ駅・教材3/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 options(level, state):
    # 段階3では重なり乗換のみ。印刷線路の走行は次の段階で追加する。
    return tuple(Option('T:'+target, target, 'transfer')
                 for target in overlaps(level, state))


def transition(level, state, action):
    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) 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)

STAGE = 3
STAGE_TITLE = 'TRANSFER BY ID'

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.state = initial()
        self.cursor = None
        self.reason = 'ready'
        # gateを消さない。Rも全操作キーを離すまで再受付しない。

    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 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
        result = transition(LEVEL, self.state, action)
        self.state = result.state
        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)]
        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 = False
            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)
    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, 'R: reset   ESC: quit / transfer only; rails are next', 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)
STEP 04 / 05

元の線分から運休を判定する

道が使えるか・元の図と映した図・まとめて受け付けない・グラフの線。

列車は、線路の両はしの駅の間だけを動きます。Q-R の元の点の並びは (3,0)→(2,0)→(1,0) です。V の左右、つまり x>=2 と x<2 をまたぐ部分があるので、V を閉じるとこの線路は全線運休します。映したあとの絵でまたぐかを調べるのではありません。運休している行き先も一覧に残して理由を見せ、押しても、駅や回数は変わりません。H だけなら、Q-R は運休しません。

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

04_suspension.py / 注目する部分
    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:])))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_suspension.py
macOS / Linux
.venv/bin/python 04_suspension.py
画面で見ることX → 右 → Space で Q へ行き、右 → 右 で RAIL TO R を選んで Space を押すと、SUSPENDED(運休)です。切符は Q、折った回数1、移動1のままです。X で V を開き、右 → Space で R へ進めます。この段階は道を調べる実験で、G に着いても成功で終わることはありません。
  • 運休の判定には、元の線を使う
  • V を閉じると Q-R は運休、H だけなら運行
  • 受け付けないときは、状態も履歴も変えない
  • 線が見た目で交わっていても、つながりにはならない
つまずきやすいところ線の途中や、線が交わったり重なったりする場所を、乗りかえ駅にしません。元の1マス分の線の両はしが、折り目の反対側どうしかを調べます。またぐ部分が1か所でもあれば、その線路全体が運休します。
少し変えてみよう

X → 右 → Space → 右 → 右 → Space で、運休を試します。そのあと X → 右 → Space で R、C → 右 → Space で G、C で全部開けます。後半の解き方は、次の完成版でも同じです。

04_suspension.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(382行)
04_suspension.py / 全文
# 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と図形のみ。字形データは同梱しません。

"""折りたたみ駅・教材4/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 transition(level, state, action, suspend=True):
    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)

STAGE = 4
STAGE_TITLE = 'SUSPENDED RAILS'

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.state = initial()
        self.cursor = None
        self.reason = 'ready'
        # gateを消さない。Rも全操作キーを離すまで再受付しない。

    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 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
        result = transition(LEVEL, self.state, action)
        self.state = result.state
        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)]
        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)
    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, 'R: reset   ESC: quit / route lab; goal and undo are next', 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)
STEP 05 / 05

切符と展開状態で成功を決める

いくつかの条件を合わせた終わりの判定・書きかえない履歴・Undo・元のゲームとの照合。

成功の条件は、切符が G で、V も H も開いていることです。G と同じ見た目の位置にいるだけでも、G に乗っていても紙が折れたままでも、成功ではありません。受け付けた「折る・開く・乗りかえる・列車に乗る」だけを Timeline.past に保存し、Z で直前の状態と2つの回数を正確に戻します。成功したあとは、移動・折る・選ぶを止め、Z か R で再開できます。元のゲームの状況 B のすべての状態と変化を守った完成版です。

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

05_puzzle.py / 注目する部分
def won(level, state):
    return state.station == level.goal and not state.v and not state.h

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_puzzle.py
macOS / Linux
.venv/bin/python 05_puzzle.py
画面で見ることX → 右 → Space → X → 右 → Space → C → 右 → Space → C で成功です。行動は7つ(折る・開く4回、移動3回)ですが、行き先の選択をふくめたキー入力は10回です。成功したときは、切符 G、V=False、H=False、履歴7です。Z で「G のまま H=True」の未成功に戻り、C でもう一度成功します。
  • 成功には、G という ID と、全部開いていることの両方がいる
  • 受け付けた行動だけを Undo の履歴に入れる
  • 成功したあとも Z と R が使える
  • 受け付けなかった同時押しのあとも、全部離すまで待つ
つまずきやすいところ描いた位置が G と同じなだけで勝ちにしないようにしましょう。また、行き先を選ぶだけの入力や、受け付けなかった移動を履歴に入れると、Undo の意味が変わってしまいます。最短の手数を答えとして書きこまず、今の状態の条件で判定します。
少し変えてみよう

上の解き方を通したあと、X や Space を押しても成功の状態が変わらないことを確かめましょう。Z → C でもう一度成功し、R で最初の状態に戻します。X と C の同時押しが受け付けられたかったあと、片方だけ離しても動かず、全部のキーを離してはじめて入力できることも確かめてください。

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

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

元のゲームの状況 B「折れ目は運休」の、4つの駅 S/Q/R/G と Q-R の線路です。元の駅の ID を守ったまま紙を折り、同じ位置に映った別の ID の駅へ、はっきり乗りかえます。元の線がまたぐ折り目を閉じると、その線路全体が運休します。G に乗って、V と H を全部開けば成功です。

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

比べる点5ステップの教材元の完成版
問題の範囲元のゲームの状況 B(LEVELS[1])だけです。座標・駅の ID・線路・折り目は同じです。05 で16の状態、すべての変化・履歴・成功を照らし合わせています。A・B・C の3つの面があります。C には、V を閉じたまま別の線路を使う判断もあります。
段階的な省略01 は調べるだけ、02 は反転の実験だけ、03 は乗りかえだけ、04 で線路の運休、05 で成功の判定と Undo です。01〜04 は、完成したゲームではありません。始めた直後から、乗りかえ・線路・運休・成功・Undo のすべてのルールが使えます。
入力1回入力するたびに全部のキーを離します。受け付けなかった同時押しも、全部離すまでロックします。R をふくむ同時押しは受け付けず、R でもロックは解けません。成功したあとは Z/R だけです。タイトル・説明・面の選択などに、操作の優先順位があります。同時押しを受け付けなかったあとに、教材と同じ「全部離すまで待つ」処理を必ずするとは限りません。
画面と補助480×424、Pyxel 標準の文字(ASCII)と図形で、元の図も並べて表示します。説明のメニュー、3つの面の切りかえ、折る前の予告、アニメーション、F12、録画は省いています。音はありません。480×492、日本語の文字、元の図、折る前の予告、移動のアニメーション、タイトル・一時停止・面の選択、記録の機能があります。音はありません。
権利と検証Noto/DejaVu の文字データは入れていません。元のゲームの MIT ライセンスと、参照している2つのライセンスの文書を、そのまま残しています。確認は、実際の Pyxel で画面を出さずに描くテストと、作った入力による操作のテストだけです。Noto/DejaVu をもとにした文字を入れています。元のゲームのテストやデモの結果は、教材のテストの数には足していません。

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

  1. 元のゲームの状況 A と C を加えて、面ごとの初期化と、次の面への移動を作ってみましょう。
  2. 元のゲームの状況 C の A-B の線路が H だけをまたぐことを、crosses で確かめてみましょう。
  3. project の結果を使って、折る前の予告の図を描いてみましょう。
  4. 元のゲームの solver.py を読み、状態から回数を取りのぞいて、近い順に調べる探索(幅優先探索)をしてみましょう。
  5. 元のゲームの app.py の日本語の文字と権利の表示を読み、ライセンスを守りながら表示を広げてみましょう。
自分のゲームに変えるなら

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

教材をダウンロードする

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

コードと自作の図形は MIT ライセンス、元の完成版に入っている Noto をもとにした日本語の文字は SIL OFL 1.1 です。この作品の英数字の文字は DejaVu Sans Mono をもとにしたもので、別のフォントのライセンスです。ZIP の中の LICENSE と、assets にあるそれぞれの権利の文書を残しておいてください。出典・権利について

検証について

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

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

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

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