シミュレーションLESSON 74路線共有と毎刻の予約を読む配送シミュレーション原作074

渋滞する流れ星の作り方

同じ線路でも、枠は一つずつ。
2本の路線が共有する線路を作る重さ(建設重量)と、1刻(時計の1目盛り)ごとの出発の枠を分けて考えます。星の到着・出現・予約・同時出発を、決まった順番で処理する仕組みを作ります。

完成版の遊び方二本までの路線を設計します。交差駅での待ち時間と星の流れを読みながら、六つの星を届けます。

渋滞する流れ星の元の完成版。自動操作によるPyxelの実描画画面。
元の完成版の状況Cを、自動で描いた画面です。下の5つのステップでは、状況Aの需要(星がいつ・どこからどこへ行きたいか)と、置ける辺を使います。
PLAY / 試験公開

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

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

完成版の主な操作:矢印キーで駅を選ぶ、Zで路線の最後に追加、Tabで路線を切り替える、Backspaceで削除、Enterで試運転、Spaceで1刻進める、Xで戻す

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

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

うまく表示されないとき

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

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

通れる数(容量)を先に予約してから、動きをまとめて決める。

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

1. 駅の並びとしての路線と、共有する実際の線路(物理辺)の集合
2. ゲーム開始からの時刻(絶対時刻)での予約と、1刻先の予告
3. 予約してから同時に出発させる
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
変数・if・for・関数 / tuple・set と、書き換えない dataclass / リストの並べ替えと再帰(本文で補足します)
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
左右:駅の一覧(A・B・X・Y・D・E)から選ぶ / Tab:路線1と路線2を切り替える / Z:編集中に、駅を路線の最後に追加 / Backspace:編集中に、選んでいる路線の最後の駅を削除 / Enter:02から、試運転を始める / Space:02から、1刻進める / X:05だけ、1回の判断を Undo(元に戻す) / R:この段階の最初の路線に戻して、やり直す / Esc:終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_routes.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

二本の路線を、駅の並びとして作る

駅の並びとしての路線と、共有する実際の線路(物理辺)の集合。

駅は A・B・X・Y・D・E です。まず、路線を編集する小さな道具から作ります。左右キーは、画面の上で動く方向ではありません。この駅の一覧を、順番に選ぶキーです。Zで、選んだ駅を今の路線の最後に加えます。Tabで路線1と路線2を切り替え、Backspaceで最後の駅を1つ消します。まだ星を走らせる試運転はありません。

路線は、駅の名前を並べた tuple(タプル:あとから書き換えない並び)です。最初の一駅だけなら、重量(線路を作るのにかかる重さ)は0です。二駅目からは、となり合う駅の組が、置ける辺かどうかを調べます。1本の路線で、同じ駅を二度は使えません。1本の路線は、最大で五駅・四辺です。駅の候補を選んだだけでは、tuple は書き換えません。

2本の路線が同じ実際の線路(物理辺)を使っても、建設重量は1回分だけです。となり合う駅の組を sorted で同じ向きにそろえ、set(集合:同じものを1つにまとめる入れ物)にまとめてから、長さを足します。A-X-Y-D と B-X-Y-E なら、それぞれの路線の長さは3です。でも、X-Y を共有しているので、重量の合計は5です。単純に2本の路線を足して、6にしないようにします。

図の線の長さは、実際の移動時間ではありません。辺に書いてある整数がその辺の長さで、建設重量にも、移動にかかる刻数にもなります。図の上で線が交わっているだけの点は、駅ではありません。2本の路線の両方に入っている駅だけが、CROSS(交差駅)です。

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

01_routes.py / 注目する部分
def route_edges(route):
    return tuple((tuple(sorted((a, b))) for a, b in zip(route, route[1:])))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_routes.py
macOS / Linux
.venv/bin/python 01_routes.py
画面で見ること最初に選ばれているAで、Z→右四回→Zと押すと A-D になります。Tab→左三回→Z→右四回→Zと押すと B-E です。LINE1 と LINE2 の二本が表示され、WEIGHT6 です。Enter を押しても、まだ編集の練習のままです。
  • 路線を、駅の並びとして表す
  • 共有している辺は、集合を使って1回だけ数える
  • 図の見た目と、辺に書いた数値を区別する
つまずきやすいところ共有している辺は、向きをそろえてから集合に入れます。A→D と D→A を別々の建設費として、二重に数えないようにしましょう。
少し変えてみよう

EDGES に1つだけある ('A', 'D', 3) を ('A', 'D', 2) に変えて、同じ2本の路線を作ってみましょう。WEIGHT は6から5になります。まだ時計がないので、移動時間がどう変わるかは、次の段階で読みます。確かめたら3に戻します。

01_routes.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(170行)
01_routes.py / 全文
# MIT License
# 
# Copyright (c) 2026
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""渋滞する流れ星・教材1/5。路線の共有重量と毎刻の出発予約を分ける。"""
from dataclasses import dataclass,replace,asdict
import itertools
STATIONS = ('A', 'B', 'X', 'Y', 'D', 'E')
EDGES = (('A', 'X', 1), ('B', 'X', 1), ('X', 'Y', 1), ('Y', 'D', 1), ('Y', 'E', 1), ('A', 'D', 3), ('B', 'E', 3), ('D', 'E', 1))

@dataclass(frozen=True)
class Level:
    id: str
    title: str
    edges: tuple
    demand: tuple
    capacity: int = 7
    limit: int = 32

LEVELS = (Level('A', '二つの直通', EDGES, ((0, 'A', 'D'), (0, 'B', 'E'), (1, 'A', 'D'), (1, 'B', 'E'), (2, 'A', 'D'), (2, 'B', 'E'))), Level('B', '行先が交じる夜', EDGES, ((0, 'A', 'D'), (0, 'B', 'E'), (1, 'A', 'E'), (1, 'B', 'D'), (2, 'A', 'E'), (2, 'B', 'D'))), Level('C', '一方に寄った便', EDGES + (('B', 'D', 2),), ((0, 'A', 'E'), (0, 'B', 'D'), (1, 'A', 'E'), (1, 'B', 'D'), (2, 'B', 'D'), (3, 'B', 'D'))))

@dataclass(frozen=True)
class State:
    routes: tuple = ((),())
    status: str = 'edit'

def initial(level):
    return State()

def edge_lengths(level):
    return {tuple(sorted((a, b))): d for a, b, d in level.edges}

def route_edges(route):
    return tuple((tuple(sorted((a, b))) for a, b in zip(route, route[1:])))

def weight(level, routes):
    return sum((edge_lengths(level)[e] for e in set(itertools.chain.from_iterable((route_edges(r) for r in routes)))))

def intersections(routes):
    return set(routes[0]) & set(routes[1])

def valid_routes(level, routes):
    return len(routes) == 2 and all((len(r) <= 5 and len(set(r)) == len(r) and all((n in STATIONS for n in r)) and all((e in edge_lengths(level) for e in route_edges(r))) for r in routes))

def edit_reason(level, s, line, node):
    r = s.routes[line]
    if len(r) >= 5:
        return '一本は最大4辺です。'
    if node in r:
        return '同じ駅を二度通れません。'
    if r and tuple(sorted((r[-1], node))) not in edge_lengths(level):
        return 'その駅へ使える辺がありません。'
    return ''

def apply_action(level,state,action,penalty=1):
    if state.status=='edit':
        bits=action.split(':')
        if len(bits)==3 and bits[0]=='add' and bits[1] in ('0','1') and bits[2] in STATIONS:
            line=int(bits[1]);node=bits[2]
            if not edit_reason(level,state,line,node):
                routes=list(state.routes);routes[line]=routes[line]+(node,)
                return replace(state,routes=tuple(routes))
        if len(bits)==2 and bits[0]=='pop' and bits[1] in ('0','1'):
            line=int(bits[1]);routes=list(state.routes);routes[line]=routes[line][:-1]
            return replace(state,routes=tuple(routes))
    return state

STAGE = 1
PENALTY = 0
LEVEL = LEVELS[0]
START_ROUTES = ((), ())
POS = {'A':(60,100),'B':(60,240),'X':(175,170),'Y':(290,170),'D':(405,100),'E':(405,240)}

class App:
    def __init__(self):self.reset()
    def reset(self):
        self.state=replace(initial(LEVEL),routes=START_ROUTES)
        self.station=0
        self.line=0
        self.gated=False
        self.message='READY'

    def commit(self,action):
        before=self.state
        self.state=apply_action(LEVEL,self.state,action,PENALTY)
        self.message=action.upper() if self.state!=before else 'NO CHANGE / REFUSED'

    def control(self,pressed,held):
        if 'R' in pressed:self.reset();self.gated=True;return
        if self.gated:
            if not pressed and not held:self.gated=False
            return
        if len(pressed|held)>1:self.gated=True;self.message='RELEASE ALL KEYS';return
        if len(pressed)!=1:return
        key=next(iter(pressed));self.gated=True
        if self.state.status!='edit':return
        if key in ('LEFT','RIGHT'):self.station=(self.station+(-1 if key=='LEFT' else 1))%len(STATIONS)
        elif key=='TAB':self.line=1-self.line
        elif key=='Z':self.commit('add:'+str(self.line)+':'+STATIONS[self.station])
        elif key=='BACKSPACE':self.commit('pop:'+str(self.line))

    def draw(self,p):
        state=self.state;p.cls(1)
        p.text(12,8,'STAR RAILWAYS / STAGE '+str(STAGE)+' / A',10)
        p.text(12,25,'WEIGHT '+str(weight(LEVEL,state.routes))+' / CAP '+str(LEVEL.capacity)+' / '+state.status.upper(),7)
        for a,b,length in LEVEL.edges:
            x,y=POS[a];xx,yy=POS[b];p.line(x,y,xx,yy,5);p.text((x+xx)//2+4,(y+yy)//2+4,str(length),6)
        for line,route in enumerate(state.routes):
            for a,b in zip(route,route[1:]):
                x,y=POS[a];xx,yy=POS[b];p.line(x,y+line*3,xx,yy+line*3,(10,12)[line])
        cross=intersections(state.routes)
        for i,n in enumerate(STATIONS):
            x,y=POS[n];p.circ(x,y,11,2 if n in cross else 1);p.circb(x,y,11,10 if i==self.station else 7);p.text(x-2,y-2,n,7)
            if n in cross:p.text(x-10,y+17,'CROSS',14)
        p.text(12,274,'LINE '+str(self.line+1)+' / STATION '+STATIONS[self.station],10)
        for i,route in enumerate(state.routes):p.text(12,291+i*15,'L'+str(i+1)+': '+('-'.join(route) or '(empty)'),(10,12)[i])
        p.text(12,330,'PRACTICE: ROUTE EDITING ONLY / NO TRIAL YET',14)
        p.text(12,406,self.message,10)
        p.text(12,430,'LEFT/RIGHT: STATION LIST / TAB: LINE / Z: ADD / BACKSPACE: POP',7)
        p.text(12,447,'R: RESET TO THIS STAGE EXAMPLE / ESC: QUIT',7)

def make_demo_keys():
    return ('Z','RIGHT','RIGHT','RIGHT','RIGHT','Z','TAB','LEFT','LEFT','LEFT','Z','RIGHT','RIGHT','RIGHT','RIGHT','Z')

def main(headless=False,screenshot=None,demo=False):
    import pyxel as p
    p.init(672,492,title='Star railway lesson '+str(STAGE),fps=30,display_scale=2,headless=headless)
    app=App();sequence=make_demo_keys() if demo else ()
    keys={'LEFT':p.KEY_LEFT,'RIGHT':p.KEY_RIGHT,'TAB':p.KEY_TAB,'Z':p.KEY_Z,'BACKSPACE':p.KEY_BACKSPACE,'RETURN':p.KEY_RETURN,'SPACE':p.KEY_SPACE,'X':p.KEY_X,'R':p.KEY_R}
    limit=max(20,2*len(sequence)+8)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:app.draw(p);p.screen.save(screenshot,1)
            p.quit()
        if headless:
            if demo:
                index=p.frame_count//2;pressed={sequence[index]} if p.frame_count%2==0 and index<len(sequence) else set();app.control(pressed,pressed)
        else:app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--headless-smoke',action='store_true');parser.add_argument('--demo-checkpoint',action='store_true');parser.add_argument('--screenshot');args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    if args.screenshot and (Path(args.screenshot).suffix.lower()!='.png' or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):parser.error('use a new .png in an existing directory')
    if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 02 / 05

出現と出発、到着を別の刻へ

ゲーム開始からの時刻(絶対時刻)での予約と、1刻先の予告。

01で作った A-D と B-E を最初の路線にして、1つの星だけを走らせます。Enter で始めて、Space を1回押すと1刻進みます。元のゲームの状況Aから、最初の需要(0刻に出現して、AからDへ行く星)だけを取り出した入門です。待ち列の容量の制限も、交差駅での追加の待ち時間も、まだありません。

Star は、出現した刻 born、次に出発できる刻 ready、辺に出た刻 departed、到着予定の刻 eta を、別々に持ちます。0刻に現れた星はすぐには動かず、ready は1です。1刻に長さ3の辺へ出発すると、eta は4です。「3刻に進んだから三歩目で到着」のような、あいまいな数え方はしません。出発した時刻+辺の長さで、到着する刻を決めます。

path_for は、置かれた路線がつながっている道を探します。そして、費用(辺の長さと、途中の駅での待ち時間の合計)、遷移数(移動の回数)、駅の名前の並び、路線番号の並び、の順に比べて、1つを選びます。星が出現したときに、その経路を決めて変えません。列車が別に走るゲームではありません。星そのものが辺を移動するのが、元のゲームの設計です。

NEXT DEPARTURES(次の出発)は、今の状態を1刻進めたときの予告です。今の State は変えません。目的地に着いた星は、すぐに配達済みになります。この星1つの練習は、4刻で DONE(完了)です。時計を進める Space を長押ししても1回分だけなので、画面を読んでいる間に勝手に進むことはありません。

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

02_single_star.py / 注目する部分
        if z.eta and z.eta == t:
            node = z.path[z.index]

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_single_star.py
macOS / Linux
.venv/bin/python 02_single_star.py
画面で見ること最初の路線のまま Enter を押します。星0の ready は1です。Space を1回押すと MOVING(移動中)、ETA4 になります。合計四回押すと、TICK4・DELIVERED1/1・DONE、TOTAL WAIT0 です。
  • born と ready と eta を、別々に持つ
  • 経路は、星が出現したときに決めて変えない
  • 予告は、状態を読むだけ
つまずきやすいところ出現したのと同じ刻に、出発させてはいけません。ふつうの駅に到着したのと同じ刻に、また出発させるのもまちがいです。それぞれの出来事が起きた時刻(絶対時刻)を、分けて考えます。
少し変えてみよう

LEVEL の demand=LEVELS[0].demand[:1] だけを、demand=((3,'A','D'),) に変えてみましょう。始めたときは星がなく、3刻に出現し、4刻から出発して、7刻に配達されます。確かめたら、元の1つの需要に戻します。

02_single_star.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(319行)
02_single_star.py / 全文
# MIT License
# 
# Copyright (c) 2026
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""渋滞する流れ星・教材2/5。路線の共有重量と毎刻の出発予約を分ける。"""
from dataclasses import dataclass,replace,asdict
import itertools
STATIONS = ('A', 'B', 'X', 'Y', 'D', 'E')
EDGES = (('A', 'X', 1), ('B', 'X', 1), ('X', 'Y', 1), ('Y', 'D', 1), ('Y', 'E', 1), ('A', 'D', 3), ('B', 'E', 3), ('D', 'E', 1))

@dataclass(frozen=True)
class Level:
    id: str
    title: str
    edges: tuple
    demand: tuple
    capacity: int = 7
    limit: int = 32

LEVELS = (Level('A', '二つの直通', EDGES, ((0, 'A', 'D'), (0, 'B', 'E'), (1, 'A', 'D'), (1, 'B', 'E'), (2, 'A', 'D'), (2, 'B', 'E'))), Level('B', '行先が交じる夜', EDGES, ((0, 'A', 'D'), (0, 'B', 'E'), (1, 'A', 'E'), (1, 'B', 'D'), (2, 'A', 'E'), (2, 'B', 'D'))), Level('C', '一方に寄った便', EDGES + (('B', 'D', 2),), ((0, 'A', 'E'), (0, 'B', 'D'), (1, 'A', 'E'), (1, 'B', 'D'), (2, 'B', 'D'), (3, 'B', 'D'))))

@dataclass(frozen=True)
class Star:
    id: int
    born: int
    source: str
    destination: str
    path: tuple = ()
    lines: tuple = ()
    index: int = 0
    eta: int = 0
    departed: int = 0
    arrived: int = 0
    ready: int = 1
    delivered: int = -1
    wait: int = 0
    reason: str = '出発待ち'

@dataclass(frozen=True)
class State:
    routes: tuple = ((), ())
    t: int = 0
    stars: tuple = ()
    status: str = 'edit'
    reason: str = '二本までの路線で、六つの星を届けよう。'
    station_wait: tuple = (0, 0, 0, 0, 0, 0)
    departures: tuple = ()

def initial(level):
    return State()

def edge_lengths(level):
    return {tuple(sorted((a, b))): d for a, b, d in level.edges}

def route_edges(route):
    return tuple((tuple(sorted((a, b))) for a, b in zip(route, route[1:])))

def weight(level, routes):
    return sum((edge_lengths(level)[e] for e in set(itertools.chain.from_iterable((route_edges(r) for r in routes)))))

def intersections(routes):
    return set(routes[0]) & set(routes[1])

def valid_routes(level, routes):
    return len(routes) == 2 and all((len(r) <= 5 and len(set(r)) == len(r) and all((n in STATIONS for n in r)) and all((e in edge_lengths(level) for e in route_edges(r))) for r in routes))

def edit_reason(level, s, line, node):
    r = s.routes[line]
    if len(r) >= 5:
        return '一本は最大4辺です。'
    if node in r:
        return '同じ駅を二度通れません。'
    if r and tuple(sorted((r[-1], node))) not in edge_lengths(level):
        return 'その駅へ使える辺がありません。'
    return ''

def path_for(level, routes, source, dest, penalty=1):
    edges = edge_lengths(level)
    cross = intersections(routes)
    adj = {n: [] for n in STATIONS}
    uses = {}
    for line, r in enumerate(routes):
        for a, b in zip(r, r[1:]):
            e = tuple(sorted((a, b)))
            uses.setdefault(e, []).append(line)
            if b not in adj[a]:
                adj[a].append(b)
                adj[b].append(a)
    candidates = []

    def walk(path):
        a = path[-1]
        if a == dest:
            options = [uses[tuple(sorted(e))] for e in zip(path, path[1:])]
            for lines in itertools.product(*options):
                cost = sum((edges[tuple(sorted(e))] for e in zip(path, path[1:]))) + max(0, len(path) - 2) + penalty * sum((n in cross for n in path[1:-1]))
                transitions = len(lines) + sum((a != b for a, b in zip(lines, lines[1:])))
                candidates.append((cost, transitions, tuple(path), tuple(lines)))
            return
        for b in sorted(adj[a]):
            if b not in path:
                walk(path + [b])
    walk([source])
    return min(candidates) if candidates else None

def planned_arrival(level, routes, z, penalty=1):
    if not z.path:
        return None
    edges = edge_lengths(level)
    cross = intersections(routes)
    return z.born + 1 + sum((edges[tuple(sorted(e))] for e in zip(z.path, z.path[1:]))) + max(0, len(z.path) - 2) + penalty * sum((n in cross for n in z.path[1:-1]))

def spawn(level, s, t, penalty):
    out = list(s.stars)
    for i, (born, source, dest) in enumerate(level.demand):
        if born != t:
            continue
        plan = path_for(level, s.routes, source, dest, penalty)
        out.append(Star(i, born, source, dest, plan[2] if plan else (), plan[3] if plan else (), arrived=born, ready=born + 1, reason='出発待ち' if plan else '接続なし'))
    return tuple(sorted(out, key=lambda z: z.id))

def advance(level, s, penalty=1):
    if s.status != 'run':
        return s
    t = s.t + 1
    cross = set()
    stars = []
    for z in s.stars:
        if z.eta and z.eta == t:
            node = z.path[z.index]
            if node == z.destination:
                z = replace(z, eta=0, arrived=t, delivered=t, reason='配達済み')
            else:
                z = replace(z, eta=0, arrived=t, ready=t + 1 + (penalty if node in cross else 0), reason='一拍待ち' if node in cross and penalty else '出発待ち')
        stars.append(z)
    tmp = replace(s, stars=tuple(stars))
    stars = list(spawn(level, tmp, t, penalty))
    edge_used = set()
    station_used = set()
    chosen = set()
    rejections = {}
    departures = []
    waiting = list(s.station_wait)
    candidates = sorted((z for z in stars if not z.eta and z.delivered < 0 and z.path and (t >= z.ready)), key=lambda z: (z.arrived, z.id))
    edges = edge_lengths(level)
    for z in candidates:
        a, b = z.path[z.index:z.index + 2]
        e = (a, b)
        if a in cross and a in station_used:
            rejections[z.id] = '交差の列待ち'
            continue
        chosen.add(z.id)
        edge_used.add(e)
        if a in cross:
            station_used.add(a)
        departures.append((z.id, a, b))
    out = []
    for z in stars:
        if z.id in chosen:
            a, b = z.path[z.index:z.index + 2]
            z = replace(z, index=z.index + 1, eta=t + edges[tuple(sorted((a, b)))], departed=t, reason='移動中')
        elif not z.eta and z.delivered < 0:
            node = z.path[z.index] if z.path else z.source
            reason = '接続なし' if not z.path else rejections.get(z.id, '一拍待ち' if t < z.ready and z.ready - z.arrived > 1 else '出発待ち')
            if t > z.arrived:
                waiting[STATIONS.index(node)] += 1
                z = replace(z, wait=z.wait + 1)
            z = replace(z, reason=reason)
        out.append(z)
    done = len(out) == len(level.demand) and all((z.delivered >= 0 for z in out))
    status = 'done' if done else 'time' if t >= level.limit else 'run'
    return replace(s, t=t, stars=tuple(out), station_wait=tuple(waiting), departures=tuple(departures), status=status, reason='六つの星が、夜の向こうへ届きました。' if done else '32刻で試運転終了。接続と列を見直そう。' if status == 'time' else '星の札は、実際の待機理由を示します。')

def apply_action(level,state,action,penalty=1):
    if state.status=='edit':
        bits=action.split(':')
        if len(bits)==3 and bits[0]=='add' and bits[1] in ('0','1') and bits[2] in STATIONS:
            line=int(bits[1]);node=bits[2]
            if not edit_reason(level,state,line,node):
                routes=list(state.routes);routes[line]=routes[line]+(node,)
                return replace(state,routes=tuple(routes))
        if len(bits)==2 and bits[0]=='pop' and bits[1] in ('0','1'):
            line=int(bits[1]);routes=list(state.routes);routes[line]=routes[line][:-1]
            return replace(state,routes=tuple(routes))
        if action=='start' and weight(level,state.routes)<=level.capacity:
            started=replace(state,status='run',reason='Spaceで一刻。Pで連続運転・停止。')
            return replace(started,stars=spawn(level,started,0,penalty))
    if action=='tick':return advance(level,state,penalty)
    return state

def forecast(level,state,penalty=1):
    if state.status=='edit':state=apply_action(level,state,'start',penalty)
    return advance(level,state,penalty) if state.status=='run' else None

STAGE = 2
PENALTY = 0
LEVEL = replace(LEVELS[0],demand=LEVELS[0].demand[:1])
START_ROUTES = (('A', 'D'), ('B', 'E'))
POS = {'A':(60,100),'B':(60,240),'X':(175,170),'Y':(290,170),'D':(405,100),'E':(405,240)}

class App:
    def __init__(self):self.reset()
    def reset(self):
        self.state=replace(initial(LEVEL),routes=START_ROUTES)
        self.station=0
        self.line=0
        self.gated=False
        self.message='READY'

    def commit(self,action):
        before=self.state
        self.state=apply_action(LEVEL,self.state,action,PENALTY)
        self.message=action.upper() if self.state!=before else 'NO CHANGE / REFUSED'

    def control(self,pressed,held):
        if 'R' in pressed:self.reset();self.gated=True;return
        if self.gated:
            if not pressed and not held:self.gated=False
            return
        if len(pressed|held)>1:self.gated=True;self.message='RELEASE ALL KEYS';return
        if len(pressed)!=1:return
        key=next(iter(pressed));self.gated=True
        if key=='RETURN':self.commit('start');return
        if key=='SPACE':self.commit('tick');return
        if self.state.status!='edit':return
        if key in ('LEFT','RIGHT'):self.station=(self.station+(-1 if key=='LEFT' else 1))%len(STATIONS)
        elif key=='TAB':self.line=1-self.line
        elif key=='Z':self.commit('add:'+str(self.line)+':'+STATIONS[self.station])
        elif key=='BACKSPACE':self.commit('pop:'+str(self.line))

    def draw(self,p):
        state=self.state;p.cls(1)
        p.text(12,8,'STAR RAILWAYS / STAGE '+str(STAGE)+' / A',10)
        p.text(12,25,'WEIGHT '+str(weight(LEVEL,state.routes))+' / CAP '+str(LEVEL.capacity)+' / '+state.status.upper(),7)
        for a,b,length in LEVEL.edges:
            x,y=POS[a];xx,yy=POS[b];p.line(x,y,xx,yy,5);p.text((x+xx)//2+4,(y+yy)//2+4,str(length),6)
        for line,route in enumerate(state.routes):
            for a,b in zip(route,route[1:]):
                x,y=POS[a];xx,yy=POS[b];p.line(x,y+line*3,xx,yy+line*3,(10,12)[line])
        cross=intersections(state.routes)
        for i,n in enumerate(STATIONS):
            x,y=POS[n];p.circ(x,y,11,2 if n in cross else 1);p.circb(x,y,11,10 if i==self.station else 7);p.text(x-2,y-2,n,7)
            if n in cross:p.text(x-10,y+17,'CROSS',14)
        p.text(12,274,'LINE '+str(self.line+1)+' / STATION '+STATIONS[self.station],10)
        for i,route in enumerate(state.routes):p.text(12,291+i*15,'L'+str(i+1)+': '+('-'.join(route) or '(empty)'),(10,12)[i])
        delivered=sum(z.delivered>=0 for z in state.stars)
        p.text(470,50,'TICK '+str(state.t),10)
        p.text(470,67,'DELIVERED '+str(delivered)+'/'+str(len(LEVEL.demand)),11)
        p.text(470,84,'TOTAL WAIT '+str(sum(state.station_wait)),14)
        for z in state.stars:
            y=110+z.id*25
            p.text(470,y,'#'+str(z.id)+' '+z.source+'>'+z.destination+' '+('DONE '+str(z.delivered) if z.delivered>=0 else 'ETA '+str(z.eta) if z.eta else 'WAIT '+str(z.wait)),7)
            labels={'移動中':'MOVING','配達済み':'DELIVERED','出発待ち':'READY WAIT','接続なし':'NO PATH','辺の列待ち':'EDGE QUEUE','交差の列待ち':'STATION QUEUE','一拍待ち':'EXTRA REST'}
            p.text(478,y+10,labels.get(z.reason,'WAIT'),6)
            if z.path:
                if z.eta:
                    a,b=z.path[z.index-1:z.index+1];x,y0=POS[a];xx,yy=POS[b]
                    duration=z.eta-z.departed;u=(state.t-z.departed)/duration
                    x,y0=x+(xx-x)*u,y0+(yy-y0)*u
                else:x,y0=POS[z.path[z.index]]
                p.circ(x,y0-15-z.id*4,2,(8,10,12,11,9,14)[z.id])
        q=forecast(LEVEL,state,PENALTY)
        p.text(12,330,'NEXT DEPARTURES '+str(q.departures) if q else 'NO NEXT TICK',6)
        p.text(12,347,'NEXT DELIVERED '+str(sum(z.delivered>=0 for z in q.stars)) if q else 'END / OR TOO HEAVY',6)
        p.text(12,367,'PRACTICE: ONE STAR / NO QUEUE LIMIT / NO EXTRA CROSS REST',14)
        p.text(12,406,self.message,10)
        p.text(12,430,'LEFT/RIGHT: STATION LIST / TAB: LINE / Z: ADD / BACKSPACE: POP',7)
        p.text(12,447,'R: RESET TO THIS STAGE EXAMPLE / ESC: QUIT',7)
        p.text(12,464,'ENTER: START / SPACE: TICK'+(' / X: UNDO; DECISIONS STAY' if STAGE==5 else ''),7)

def make_demo_keys():
    return ('RETURN',)+('SPACE',)*4

def main(headless=False,screenshot=None,demo=False):
    import pyxel as p
    p.init(672,492,title='Star railway lesson '+str(STAGE),fps=30,display_scale=2,headless=headless)
    app=App();sequence=make_demo_keys() if demo else ()
    keys={'LEFT':p.KEY_LEFT,'RIGHT':p.KEY_RIGHT,'TAB':p.KEY_TAB,'Z':p.KEY_Z,'BACKSPACE':p.KEY_BACKSPACE,'RETURN':p.KEY_RETURN,'SPACE':p.KEY_SPACE,'X':p.KEY_X,'R':p.KEY_R}
    limit=max(20,2*len(sequence)+8)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:app.draw(p);p.screen.save(screenshot,1)
            p.quit()
        if headless:
            if demo:
                index=p.frame_count//2;pressed={sequence[index]} if p.frame_count%2==0 and index<len(sequence) else set();app.control(pressed,pressed)
        else:app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--headless-smoke',action='store_true');parser.add_argument('--demo-checkpoint',action='store_true');parser.add_argument('--screenshot');args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    if args.screenshot and (Path(args.screenshot).suffix.lower()!='.png' or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):parser.error('use a new .png in an existing directory')
    if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 03 / 05

同じ向きの辺へ、毎刻一星だけ入れる

予約してから同時に出発させる。

六つの星を、元のゲームの状況Aの出現表どおりに戻します。最初の路線は A-X-Y-D と B-X-Y-E にします。両方が X-Y を使うので、同じ刻に同じ向きの辺へ入れる星を、1つだけにします。この段階には、交差駅全体での出発の枠や、追加の1刻(一拍)の待ちはありません。画面にも、ここが簡単にしてあることを表示します。

まず、この刻に到着した星を更新して、新しい星を出現させます。次に、出発できる星を、到着した刻→IDの順に並べます。edge_used に、向きのある (a,b) を予約します。もう使われていれば、EDGE QUEUE(辺の順番待ち)です。予約が済んだ chosen の全員に、あとから移動をまとめて当てはめます。

建設重量では、向きをそろえた物理辺を1つとして数えました。でも、出発の枠は、向きのある辺(有向辺)ごとです。A→X と X→A は別の枠です。さらに、この枠は「この刻に辺へ入る星の数」です。長さ3の辺を1つの星が走っている間、ずっとその辺をふさぐ仕組みではありません。次の刻には、新しい予約の集合になります。

待った数は、到着した刻と出現した刻を除いて、まだ出発できなかった刻を数えます。元のゲームでは、つながっていない、出発待ち、追加の待ち、容量待ちを、すべて同じ「待った合計」にまとめます。待った理由は、別に表示します。ここでは、有向辺ごとの待ち列のちがいを読みながら、すべての星が11刻に着くところまで試します。

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

03_edge_queue.py / 注目する部分
        if e in edge_used:
            rejections[z.id] = '辺の列待ち'
            continue

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_edge_queue.py
macOS / Linux
.venv/bin/python 03_edge_queue.py
画面で見ること最初の路線のまま、Enter→Space を七回押します。TICK7 で、星0は6刻、星1は7刻に配達され、残りはまだ途中です。TOTAL WAIT9 です。合計十一回押すと DONE になり、配達された刻はID順に (6,7,8,9,10,11) です。
  • 向きのある辺ごとに、枠は1つ
  • 到着した刻とIDで、順番を決める
  • 予約と更新を、2つの段階に分ける
つまずきやすいところ星を1つずつ動かして、動かしたあとの状態で次の星の枠を決めてはいけません。候補の順番どおりに予約を決めてから、選ばれた出発をまとめて当てはめます。
少し変えてみよう

advance の key=lambda z: (z.arrived, z.id) を、(z.arrived, -z.id) に変えてみましょう。同じ刻に到着した星なら、IDが大きい星が先になります。配達される刻は、ID順に (7,6,9,8,11,10) です。「先に到着した星を優先する」という1つ目の条件は、変えていません。確かめたら z.id に戻します。

03_edge_queue.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(322行)
03_edge_queue.py / 全文
# MIT License
# 
# Copyright (c) 2026
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""渋滞する流れ星・教材3/5。路線の共有重量と毎刻の出発予約を分ける。"""
from dataclasses import dataclass,replace,asdict
import itertools
STATIONS = ('A', 'B', 'X', 'Y', 'D', 'E')
EDGES = (('A', 'X', 1), ('B', 'X', 1), ('X', 'Y', 1), ('Y', 'D', 1), ('Y', 'E', 1), ('A', 'D', 3), ('B', 'E', 3), ('D', 'E', 1))

@dataclass(frozen=True)
class Level:
    id: str
    title: str
    edges: tuple
    demand: tuple
    capacity: int = 7
    limit: int = 32

LEVELS = (Level('A', '二つの直通', EDGES, ((0, 'A', 'D'), (0, 'B', 'E'), (1, 'A', 'D'), (1, 'B', 'E'), (2, 'A', 'D'), (2, 'B', 'E'))), Level('B', '行先が交じる夜', EDGES, ((0, 'A', 'D'), (0, 'B', 'E'), (1, 'A', 'E'), (1, 'B', 'D'), (2, 'A', 'E'), (2, 'B', 'D'))), Level('C', '一方に寄った便', EDGES + (('B', 'D', 2),), ((0, 'A', 'E'), (0, 'B', 'D'), (1, 'A', 'E'), (1, 'B', 'D'), (2, 'B', 'D'), (3, 'B', 'D'))))

@dataclass(frozen=True)
class Star:
    id: int
    born: int
    source: str
    destination: str
    path: tuple = ()
    lines: tuple = ()
    index: int = 0
    eta: int = 0
    departed: int = 0
    arrived: int = 0
    ready: int = 1
    delivered: int = -1
    wait: int = 0
    reason: str = '出発待ち'

@dataclass(frozen=True)
class State:
    routes: tuple = ((), ())
    t: int = 0
    stars: tuple = ()
    status: str = 'edit'
    reason: str = '二本までの路線で、六つの星を届けよう。'
    station_wait: tuple = (0, 0, 0, 0, 0, 0)
    departures: tuple = ()

def initial(level):
    return State()

def edge_lengths(level):
    return {tuple(sorted((a, b))): d for a, b, d in level.edges}

def route_edges(route):
    return tuple((tuple(sorted((a, b))) for a, b in zip(route, route[1:])))

def weight(level, routes):
    return sum((edge_lengths(level)[e] for e in set(itertools.chain.from_iterable((route_edges(r) for r in routes)))))

def intersections(routes):
    return set(routes[0]) & set(routes[1])

def valid_routes(level, routes):
    return len(routes) == 2 and all((len(r) <= 5 and len(set(r)) == len(r) and all((n in STATIONS for n in r)) and all((e in edge_lengths(level) for e in route_edges(r))) for r in routes))

def edit_reason(level, s, line, node):
    r = s.routes[line]
    if len(r) >= 5:
        return '一本は最大4辺です。'
    if node in r:
        return '同じ駅を二度通れません。'
    if r and tuple(sorted((r[-1], node))) not in edge_lengths(level):
        return 'その駅へ使える辺がありません。'
    return ''

def path_for(level, routes, source, dest, penalty=1):
    edges = edge_lengths(level)
    cross = intersections(routes)
    adj = {n: [] for n in STATIONS}
    uses = {}
    for line, r in enumerate(routes):
        for a, b in zip(r, r[1:]):
            e = tuple(sorted((a, b)))
            uses.setdefault(e, []).append(line)
            if b not in adj[a]:
                adj[a].append(b)
                adj[b].append(a)
    candidates = []

    def walk(path):
        a = path[-1]
        if a == dest:
            options = [uses[tuple(sorted(e))] for e in zip(path, path[1:])]
            for lines in itertools.product(*options):
                cost = sum((edges[tuple(sorted(e))] for e in zip(path, path[1:]))) + max(0, len(path) - 2) + penalty * sum((n in cross for n in path[1:-1]))
                transitions = len(lines) + sum((a != b for a, b in zip(lines, lines[1:])))
                candidates.append((cost, transitions, tuple(path), tuple(lines)))
            return
        for b in sorted(adj[a]):
            if b not in path:
                walk(path + [b])
    walk([source])
    return min(candidates) if candidates else None

def planned_arrival(level, routes, z, penalty=1):
    if not z.path:
        return None
    edges = edge_lengths(level)
    cross = intersections(routes)
    return z.born + 1 + sum((edges[tuple(sorted(e))] for e in zip(z.path, z.path[1:]))) + max(0, len(z.path) - 2) + penalty * sum((n in cross for n in z.path[1:-1]))

def spawn(level, s, t, penalty):
    out = list(s.stars)
    for i, (born, source, dest) in enumerate(level.demand):
        if born != t:
            continue
        plan = path_for(level, s.routes, source, dest, penalty)
        out.append(Star(i, born, source, dest, plan[2] if plan else (), plan[3] if plan else (), arrived=born, ready=born + 1, reason='出発待ち' if plan else '接続なし'))
    return tuple(sorted(out, key=lambda z: z.id))

def advance(level, s, penalty=1):
    if s.status != 'run':
        return s
    t = s.t + 1
    cross = set()
    stars = []
    for z in s.stars:
        if z.eta and z.eta == t:
            node = z.path[z.index]
            if node == z.destination:
                z = replace(z, eta=0, arrived=t, delivered=t, reason='配達済み')
            else:
                z = replace(z, eta=0, arrived=t, ready=t + 1 + (penalty if node in cross else 0), reason='一拍待ち' if node in cross and penalty else '出発待ち')
        stars.append(z)
    tmp = replace(s, stars=tuple(stars))
    stars = list(spawn(level, tmp, t, penalty))
    edge_used = set()
    station_used = set()
    chosen = set()
    rejections = {}
    departures = []
    waiting = list(s.station_wait)
    candidates = sorted((z for z in stars if not z.eta and z.delivered < 0 and z.path and (t >= z.ready)), key=lambda z: (z.arrived, z.id))
    edges = edge_lengths(level)
    for z in candidates:
        a, b = z.path[z.index:z.index + 2]
        e = (a, b)
        if a in cross and a in station_used:
            rejections[z.id] = '交差の列待ち'
            continue
        if e in edge_used:
            rejections[z.id] = '辺の列待ち'
            continue
        chosen.add(z.id)
        edge_used.add(e)
        if a in cross:
            station_used.add(a)
        departures.append((z.id, a, b))
    out = []
    for z in stars:
        if z.id in chosen:
            a, b = z.path[z.index:z.index + 2]
            z = replace(z, index=z.index + 1, eta=t + edges[tuple(sorted((a, b)))], departed=t, reason='移動中')
        elif not z.eta and z.delivered < 0:
            node = z.path[z.index] if z.path else z.source
            reason = '接続なし' if not z.path else rejections.get(z.id, '一拍待ち' if t < z.ready and z.ready - z.arrived > 1 else '出発待ち')
            if t > z.arrived:
                waiting[STATIONS.index(node)] += 1
                z = replace(z, wait=z.wait + 1)
            z = replace(z, reason=reason)
        out.append(z)
    done = len(out) == len(level.demand) and all((z.delivered >= 0 for z in out))
    status = 'done' if done else 'time' if t >= level.limit else 'run'
    return replace(s, t=t, stars=tuple(out), station_wait=tuple(waiting), departures=tuple(departures), status=status, reason='六つの星が、夜の向こうへ届きました。' if done else '32刻で試運転終了。接続と列を見直そう。' if status == 'time' else '星の札は、実際の待機理由を示します。')

def apply_action(level,state,action,penalty=1):
    if state.status=='edit':
        bits=action.split(':')
        if len(bits)==3 and bits[0]=='add' and bits[1] in ('0','1') and bits[2] in STATIONS:
            line=int(bits[1]);node=bits[2]
            if not edit_reason(level,state,line,node):
                routes=list(state.routes);routes[line]=routes[line]+(node,)
                return replace(state,routes=tuple(routes))
        if len(bits)==2 and bits[0]=='pop' and bits[1] in ('0','1'):
            line=int(bits[1]);routes=list(state.routes);routes[line]=routes[line][:-1]
            return replace(state,routes=tuple(routes))
        if action=='start' and weight(level,state.routes)<=level.capacity:
            started=replace(state,status='run',reason='Spaceで一刻。Pで連続運転・停止。')
            return replace(started,stars=spawn(level,started,0,penalty))
    if action=='tick':return advance(level,state,penalty)
    return state

def forecast(level,state,penalty=1):
    if state.status=='edit':state=apply_action(level,state,'start',penalty)
    return advance(level,state,penalty) if state.status=='run' else None

STAGE = 3
PENALTY = 0
LEVEL = LEVELS[0]
START_ROUTES = (('A', 'X', 'Y', 'D'), ('B', 'X', 'Y', 'E'))
POS = {'A':(60,100),'B':(60,240),'X':(175,170),'Y':(290,170),'D':(405,100),'E':(405,240)}

class App:
    def __init__(self):self.reset()
    def reset(self):
        self.state=replace(initial(LEVEL),routes=START_ROUTES)
        self.station=0
        self.line=0
        self.gated=False
        self.message='READY'

    def commit(self,action):
        before=self.state
        self.state=apply_action(LEVEL,self.state,action,PENALTY)
        self.message=action.upper() if self.state!=before else 'NO CHANGE / REFUSED'

    def control(self,pressed,held):
        if 'R' in pressed:self.reset();self.gated=True;return
        if self.gated:
            if not pressed and not held:self.gated=False
            return
        if len(pressed|held)>1:self.gated=True;self.message='RELEASE ALL KEYS';return
        if len(pressed)!=1:return
        key=next(iter(pressed));self.gated=True
        if key=='RETURN':self.commit('start');return
        if key=='SPACE':self.commit('tick');return
        if self.state.status!='edit':return
        if key in ('LEFT','RIGHT'):self.station=(self.station+(-1 if key=='LEFT' else 1))%len(STATIONS)
        elif key=='TAB':self.line=1-self.line
        elif key=='Z':self.commit('add:'+str(self.line)+':'+STATIONS[self.station])
        elif key=='BACKSPACE':self.commit('pop:'+str(self.line))

    def draw(self,p):
        state=self.state;p.cls(1)
        p.text(12,8,'STAR RAILWAYS / STAGE '+str(STAGE)+' / A',10)
        p.text(12,25,'WEIGHT '+str(weight(LEVEL,state.routes))+' / CAP '+str(LEVEL.capacity)+' / '+state.status.upper(),7)
        for a,b,length in LEVEL.edges:
            x,y=POS[a];xx,yy=POS[b];p.line(x,y,xx,yy,5);p.text((x+xx)//2+4,(y+yy)//2+4,str(length),6)
        for line,route in enumerate(state.routes):
            for a,b in zip(route,route[1:]):
                x,y=POS[a];xx,yy=POS[b];p.line(x,y+line*3,xx,yy+line*3,(10,12)[line])
        cross=intersections(state.routes)
        for i,n in enumerate(STATIONS):
            x,y=POS[n];p.circ(x,y,11,2 if n in cross else 1);p.circb(x,y,11,10 if i==self.station else 7);p.text(x-2,y-2,n,7)
            if n in cross:p.text(x-10,y+17,'CROSS',14)
        p.text(12,274,'LINE '+str(self.line+1)+' / STATION '+STATIONS[self.station],10)
        for i,route in enumerate(state.routes):p.text(12,291+i*15,'L'+str(i+1)+': '+('-'.join(route) or '(empty)'),(10,12)[i])
        delivered=sum(z.delivered>=0 for z in state.stars)
        p.text(470,50,'TICK '+str(state.t),10)
        p.text(470,67,'DELIVERED '+str(delivered)+'/'+str(len(LEVEL.demand)),11)
        p.text(470,84,'TOTAL WAIT '+str(sum(state.station_wait)),14)
        for z in state.stars:
            y=110+z.id*25
            p.text(470,y,'#'+str(z.id)+' '+z.source+'>'+z.destination+' '+('DONE '+str(z.delivered) if z.delivered>=0 else 'ETA '+str(z.eta) if z.eta else 'WAIT '+str(z.wait)),7)
            labels={'移動中':'MOVING','配達済み':'DELIVERED','出発待ち':'READY WAIT','接続なし':'NO PATH','辺の列待ち':'EDGE QUEUE','交差の列待ち':'STATION QUEUE','一拍待ち':'EXTRA REST'}
            p.text(478,y+10,labels.get(z.reason,'WAIT'),6)
            if z.path:
                if z.eta:
                    a,b=z.path[z.index-1:z.index+1];x,y0=POS[a];xx,yy=POS[b]
                    duration=z.eta-z.departed;u=(state.t-z.departed)/duration
                    x,y0=x+(xx-x)*u,y0+(yy-y0)*u
                else:x,y0=POS[z.path[z.index]]
                p.circ(x,y0-15-z.id*4,2,(8,10,12,11,9,14)[z.id])
        q=forecast(LEVEL,state,PENALTY)
        p.text(12,330,'NEXT DEPARTURES '+str(q.departures) if q else 'NO NEXT TICK',6)
        p.text(12,347,'NEXT DELIVERED '+str(sum(z.delivered>=0 for z in q.stars)) if q else 'END / OR TOO HEAVY',6)
        p.text(12,367,'PRACTICE: DIRECTED EDGE QUEUE / NO CROSS-STATION LIMIT',14)
        p.text(12,406,self.message,10)
        p.text(12,430,'LEFT/RIGHT: STATION LIST / TAB: LINE / Z: ADD / BACKSPACE: POP',7)
        p.text(12,447,'R: RESET TO THIS STAGE EXAMPLE / ESC: QUIT',7)
        p.text(12,464,'ENTER: START / SPACE: TICK'+(' / X: UNDO; DECISIONS STAY' if STAGE==5 else ''),7)

def make_demo_keys():
    return ('RETURN',)+('SPACE',)*7

def main(headless=False,screenshot=None,demo=False):
    import pyxel as p
    p.init(672,492,title='Star railway lesson '+str(STAGE),fps=30,display_scale=2,headless=headless)
    app=App();sequence=make_demo_keys() if demo else ()
    keys={'LEFT':p.KEY_LEFT,'RIGHT':p.KEY_RIGHT,'TAB':p.KEY_TAB,'Z':p.KEY_Z,'BACKSPACE':p.KEY_BACKSPACE,'RETURN':p.KEY_RETURN,'SPACE':p.KEY_SPACE,'X':p.KEY_X,'R':p.KEY_R}
    limit=max(20,2*len(sequence)+8)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:app.draw(p);p.screen.save(screenshot,1)
            p.quit()
        if headless:
            if demo:
                index=p.frame_count//2;pressed={sequence[index]} if p.frame_count%2==0 and index<len(sequence) else set();app.control(pressed,pressed)
        else:app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--headless-smoke',action='store_true');parser.add_argument('--demo-checkpoint',action='store_true');parser.add_argument('--screenshot');args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    if args.screenshot and (Path(args.screenshot).suffix.lower()!='.png' or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):parser.error('use a new .png in an existing directory')
    if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 04 / 05

交差駅の一枠と、一拍を加える

経路の費用(見積もり)・容量・実際の結果を区別する。

元のゲームの状況Aの、ふつうのルールを完成させます。2本の路線の両方に入っている X と Y は、交差駅です。交差駅に到着すると、ふつうの1刻に追加の1刻を足して、ready を決めます。これは、同じ路線をそのまま進む星にも当てはめます。乗りかえたからさらにもう1回足す、という仕組みではありません。

交差駅から出発できる星は、別々の辺へ向かう場合でも、1刻に1つだけです。station_used を有向辺の予約とは別に持ち、到着した刻→IDの順に選びます。追加の1刻(一拍)と、駅から出発できる数(容量)は、別々の決まりです。元のゲームで追加の待ちを0にして比べるときも、駅の出発の枠1つや、辺の容量は残ります。

この最初の共有路線は、重量5で、13刻ですべて配達し、待った合計は21です。出現したときに計算する到着予定は、辺の長さ、途中の駅での1刻、途中の交差駅での追加の1刻を足した見積もりです。この先の待ち列で待つ分は、入っていません。0刻に出現する星0と星1は、どちらも8刻に着く予定です。でも実際には、星1は9刻に着きます。予定の時刻を、必ず着く時刻として表示しないことが大切です。

NEXT DEPARTURES は、前半の刻ですべての出発を予告します。同じモデルを1刻だけ進めて作るので、実際の容量待ちも入っています。試運転の間は、路線の編集を止めます。32刻までに六つの星が届けば DONE、届かなければ TIME(時間切れ)です。つながっていない路線で始めることもできますが、届いたことにはしません。

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

04_intersections.py / 注目する部分
        if a in cross and a in station_used:
            rejections[z.id] = '交差の列待ち'
            continue

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_intersections.py
macOS / Linux
.venv/bin/python 04_intersections.py
画面で見ること最初の路線のまま、Enter→Space を十三回押します。WEIGHT5、DONE、TOTAL WAIT21 です。それぞれの星が配達された刻は (8,9,10,11,12,13) です。最初の星は2刻にXに着き、次に出発するのは4刻です。
  • 駅の枠と辺の枠を、別々に管理する
  • 追加の待ちを、経路の計算にも入れる
  • 見積もりと実際の結果を混ぜない
つまずきやすいところ追加の待ちを0にしたからといって、交差駅の容量の制限も消してよいわけではありません。休む長さと、同時に出発できる数の上限は、別々のルールです。
少し変えてみよう

START_ROUTES を (('A','D'),('B','E')) に変えて、同じ状況Aの需要で試してみましょう。重量は6で、1つ重くなります。でも、Space 六回ですべて配達でき、待った合計は0です。重量5の共有路線も、正しい成功です。確かめたら、共有路線に戻します。

04_intersections.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(322行)
04_intersections.py / 全文
# MIT License
# 
# Copyright (c) 2026
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""渋滞する流れ星・教材4/5。路線の共有重量と毎刻の出発予約を分ける。"""
from dataclasses import dataclass,replace,asdict
import itertools
STATIONS = ('A', 'B', 'X', 'Y', 'D', 'E')
EDGES = (('A', 'X', 1), ('B', 'X', 1), ('X', 'Y', 1), ('Y', 'D', 1), ('Y', 'E', 1), ('A', 'D', 3), ('B', 'E', 3), ('D', 'E', 1))

@dataclass(frozen=True)
class Level:
    id: str
    title: str
    edges: tuple
    demand: tuple
    capacity: int = 7
    limit: int = 32

LEVELS = (Level('A', '二つの直通', EDGES, ((0, 'A', 'D'), (0, 'B', 'E'), (1, 'A', 'D'), (1, 'B', 'E'), (2, 'A', 'D'), (2, 'B', 'E'))), Level('B', '行先が交じる夜', EDGES, ((0, 'A', 'D'), (0, 'B', 'E'), (1, 'A', 'E'), (1, 'B', 'D'), (2, 'A', 'E'), (2, 'B', 'D'))), Level('C', '一方に寄った便', EDGES + (('B', 'D', 2),), ((0, 'A', 'E'), (0, 'B', 'D'), (1, 'A', 'E'), (1, 'B', 'D'), (2, 'B', 'D'), (3, 'B', 'D'))))

@dataclass(frozen=True)
class Star:
    id: int
    born: int
    source: str
    destination: str
    path: tuple = ()
    lines: tuple = ()
    index: int = 0
    eta: int = 0
    departed: int = 0
    arrived: int = 0
    ready: int = 1
    delivered: int = -1
    wait: int = 0
    reason: str = '出発待ち'

@dataclass(frozen=True)
class State:
    routes: tuple = ((), ())
    t: int = 0
    stars: tuple = ()
    status: str = 'edit'
    reason: str = '二本までの路線で、六つの星を届けよう。'
    station_wait: tuple = (0, 0, 0, 0, 0, 0)
    departures: tuple = ()

def initial(level):
    return State()

def edge_lengths(level):
    return {tuple(sorted((a, b))): d for a, b, d in level.edges}

def route_edges(route):
    return tuple((tuple(sorted((a, b))) for a, b in zip(route, route[1:])))

def weight(level, routes):
    return sum((edge_lengths(level)[e] for e in set(itertools.chain.from_iterable((route_edges(r) for r in routes)))))

def intersections(routes):
    return set(routes[0]) & set(routes[1])

def valid_routes(level, routes):
    return len(routes) == 2 and all((len(r) <= 5 and len(set(r)) == len(r) and all((n in STATIONS for n in r)) and all((e in edge_lengths(level) for e in route_edges(r))) for r in routes))

def edit_reason(level, s, line, node):
    r = s.routes[line]
    if len(r) >= 5:
        return '一本は最大4辺です。'
    if node in r:
        return '同じ駅を二度通れません。'
    if r and tuple(sorted((r[-1], node))) not in edge_lengths(level):
        return 'その駅へ使える辺がありません。'
    return ''

def path_for(level, routes, source, dest, penalty=1):
    edges = edge_lengths(level)
    cross = intersections(routes)
    adj = {n: [] for n in STATIONS}
    uses = {}
    for line, r in enumerate(routes):
        for a, b in zip(r, r[1:]):
            e = tuple(sorted((a, b)))
            uses.setdefault(e, []).append(line)
            if b not in adj[a]:
                adj[a].append(b)
                adj[b].append(a)
    candidates = []

    def walk(path):
        a = path[-1]
        if a == dest:
            options = [uses[tuple(sorted(e))] for e in zip(path, path[1:])]
            for lines in itertools.product(*options):
                cost = sum((edges[tuple(sorted(e))] for e in zip(path, path[1:]))) + max(0, len(path) - 2) + penalty * sum((n in cross for n in path[1:-1]))
                transitions = len(lines) + sum((a != b for a, b in zip(lines, lines[1:])))
                candidates.append((cost, transitions, tuple(path), tuple(lines)))
            return
        for b in sorted(adj[a]):
            if b not in path:
                walk(path + [b])
    walk([source])
    return min(candidates) if candidates else None

def planned_arrival(level, routes, z, penalty=1):
    if not z.path:
        return None
    edges = edge_lengths(level)
    cross = intersections(routes)
    return z.born + 1 + sum((edges[tuple(sorted(e))] for e in zip(z.path, z.path[1:]))) + max(0, len(z.path) - 2) + penalty * sum((n in cross for n in z.path[1:-1]))

def spawn(level, s, t, penalty):
    out = list(s.stars)
    for i, (born, source, dest) in enumerate(level.demand):
        if born != t:
            continue
        plan = path_for(level, s.routes, source, dest, penalty)
        out.append(Star(i, born, source, dest, plan[2] if plan else (), plan[3] if plan else (), arrived=born, ready=born + 1, reason='出発待ち' if plan else '接続なし'))
    return tuple(sorted(out, key=lambda z: z.id))

def advance(level, s, penalty=1):
    if s.status != 'run':
        return s
    t = s.t + 1
    cross = intersections(s.routes)
    stars = []
    for z in s.stars:
        if z.eta and z.eta == t:
            node = z.path[z.index]
            if node == z.destination:
                z = replace(z, eta=0, arrived=t, delivered=t, reason='配達済み')
            else:
                z = replace(z, eta=0, arrived=t, ready=t + 1 + (penalty if node in cross else 0), reason='一拍待ち' if node in cross and penalty else '出発待ち')
        stars.append(z)
    tmp = replace(s, stars=tuple(stars))
    stars = list(spawn(level, tmp, t, penalty))
    edge_used = set()
    station_used = set()
    chosen = set()
    rejections = {}
    departures = []
    waiting = list(s.station_wait)
    candidates = sorted((z for z in stars if not z.eta and z.delivered < 0 and z.path and (t >= z.ready)), key=lambda z: (z.arrived, z.id))
    edges = edge_lengths(level)
    for z in candidates:
        a, b = z.path[z.index:z.index + 2]
        e = (a, b)
        if a in cross and a in station_used:
            rejections[z.id] = '交差の列待ち'
            continue
        if e in edge_used:
            rejections[z.id] = '辺の列待ち'
            continue
        chosen.add(z.id)
        edge_used.add(e)
        if a in cross:
            station_used.add(a)
        departures.append((z.id, a, b))
    out = []
    for z in stars:
        if z.id in chosen:
            a, b = z.path[z.index:z.index + 2]
            z = replace(z, index=z.index + 1, eta=t + edges[tuple(sorted((a, b)))], departed=t, reason='移動中')
        elif not z.eta and z.delivered < 0:
            node = z.path[z.index] if z.path else z.source
            reason = '接続なし' if not z.path else rejections.get(z.id, '一拍待ち' if t < z.ready and z.ready - z.arrived > 1 else '出発待ち')
            if t > z.arrived:
                waiting[STATIONS.index(node)] += 1
                z = replace(z, wait=z.wait + 1)
            z = replace(z, reason=reason)
        out.append(z)
    done = len(out) == len(level.demand) and all((z.delivered >= 0 for z in out))
    status = 'done' if done else 'time' if t >= level.limit else 'run'
    return replace(s, t=t, stars=tuple(out), station_wait=tuple(waiting), departures=tuple(departures), status=status, reason='六つの星が、夜の向こうへ届きました。' if done else '32刻で試運転終了。接続と列を見直そう。' if status == 'time' else '星の札は、実際の待機理由を示します。')

def apply_action(level,state,action,penalty=1):
    if state.status=='edit':
        bits=action.split(':')
        if len(bits)==3 and bits[0]=='add' and bits[1] in ('0','1') and bits[2] in STATIONS:
            line=int(bits[1]);node=bits[2]
            if not edit_reason(level,state,line,node):
                routes=list(state.routes);routes[line]=routes[line]+(node,)
                return replace(state,routes=tuple(routes))
        if len(bits)==2 and bits[0]=='pop' and bits[1] in ('0','1'):
            line=int(bits[1]);routes=list(state.routes);routes[line]=routes[line][:-1]
            return replace(state,routes=tuple(routes))
        if action=='start' and weight(level,state.routes)<=level.capacity:
            started=replace(state,status='run',reason='Spaceで一刻。Pで連続運転・停止。')
            return replace(started,stars=spawn(level,started,0,penalty))
    if action=='tick':return advance(level,state,penalty)
    return state

def forecast(level,state,penalty=1):
    if state.status=='edit':state=apply_action(level,state,'start',penalty)
    return advance(level,state,penalty) if state.status=='run' else None

STAGE = 4
PENALTY = 1
LEVEL = LEVELS[0]
START_ROUTES = (('A', 'X', 'Y', 'D'), ('B', 'X', 'Y', 'E'))
POS = {'A':(60,100),'B':(60,240),'X':(175,170),'Y':(290,170),'D':(405,100),'E':(405,240)}

class App:
    def __init__(self):self.reset()
    def reset(self):
        self.state=replace(initial(LEVEL),routes=START_ROUTES)
        self.station=0
        self.line=0
        self.gated=False
        self.message='READY'

    def commit(self,action):
        before=self.state
        self.state=apply_action(LEVEL,self.state,action,PENALTY)
        self.message=action.upper() if self.state!=before else 'NO CHANGE / REFUSED'

    def control(self,pressed,held):
        if 'R' in pressed:self.reset();self.gated=True;return
        if self.gated:
            if not pressed and not held:self.gated=False
            return
        if len(pressed|held)>1:self.gated=True;self.message='RELEASE ALL KEYS';return
        if len(pressed)!=1:return
        key=next(iter(pressed));self.gated=True
        if key=='RETURN':self.commit('start');return
        if key=='SPACE':self.commit('tick');return
        if self.state.status!='edit':return
        if key in ('LEFT','RIGHT'):self.station=(self.station+(-1 if key=='LEFT' else 1))%len(STATIONS)
        elif key=='TAB':self.line=1-self.line
        elif key=='Z':self.commit('add:'+str(self.line)+':'+STATIONS[self.station])
        elif key=='BACKSPACE':self.commit('pop:'+str(self.line))

    def draw(self,p):
        state=self.state;p.cls(1)
        p.text(12,8,'STAR RAILWAYS / STAGE '+str(STAGE)+' / A',10)
        p.text(12,25,'WEIGHT '+str(weight(LEVEL,state.routes))+' / CAP '+str(LEVEL.capacity)+' / '+state.status.upper(),7)
        for a,b,length in LEVEL.edges:
            x,y=POS[a];xx,yy=POS[b];p.line(x,y,xx,yy,5);p.text((x+xx)//2+4,(y+yy)//2+4,str(length),6)
        for line,route in enumerate(state.routes):
            for a,b in zip(route,route[1:]):
                x,y=POS[a];xx,yy=POS[b];p.line(x,y+line*3,xx,yy+line*3,(10,12)[line])
        cross=intersections(state.routes)
        for i,n in enumerate(STATIONS):
            x,y=POS[n];p.circ(x,y,11,2 if n in cross else 1);p.circb(x,y,11,10 if i==self.station else 7);p.text(x-2,y-2,n,7)
            if n in cross:p.text(x-10,y+17,'CROSS',14)
        p.text(12,274,'LINE '+str(self.line+1)+' / STATION '+STATIONS[self.station],10)
        for i,route in enumerate(state.routes):p.text(12,291+i*15,'L'+str(i+1)+': '+('-'.join(route) or '(empty)'),(10,12)[i])
        delivered=sum(z.delivered>=0 for z in state.stars)
        p.text(470,50,'TICK '+str(state.t),10)
        p.text(470,67,'DELIVERED '+str(delivered)+'/'+str(len(LEVEL.demand)),11)
        p.text(470,84,'TOTAL WAIT '+str(sum(state.station_wait)),14)
        for z in state.stars:
            y=110+z.id*25
            p.text(470,y,'#'+str(z.id)+' '+z.source+'>'+z.destination+' '+('DONE '+str(z.delivered) if z.delivered>=0 else 'ETA '+str(z.eta) if z.eta else 'WAIT '+str(z.wait)),7)
            labels={'移動中':'MOVING','配達済み':'DELIVERED','出発待ち':'READY WAIT','接続なし':'NO PATH','辺の列待ち':'EDGE QUEUE','交差の列待ち':'STATION QUEUE','一拍待ち':'EXTRA REST'}
            p.text(478,y+10,labels.get(z.reason,'WAIT'),6)
            if z.path:
                if z.eta:
                    a,b=z.path[z.index-1:z.index+1];x,y0=POS[a];xx,yy=POS[b]
                    duration=z.eta-z.departed;u=(state.t-z.departed)/duration
                    x,y0=x+(xx-x)*u,y0+(yy-y0)*u
                else:x,y0=POS[z.path[z.index]]
                p.circ(x,y0-15-z.id*4,2,(8,10,12,11,9,14)[z.id])
        q=forecast(LEVEL,state,PENALTY)
        p.text(12,330,'NEXT DEPARTURES '+str(q.departures) if q else 'NO NEXT TICK',6)
        p.text(12,347,'NEXT DELIVERED '+str(sum(z.delivered>=0 for z in q.stars)) if q else 'END / OR TOO HEAVY',6)
        p.text(12,367,'CROSS: ONE DEPARTURE/TICK + EXTRA REST1',14)
        p.text(12,406,self.message,10)
        p.text(12,430,'LEFT/RIGHT: STATION LIST / TAB: LINE / Z: ADD / BACKSPACE: POP',7)
        p.text(12,447,'R: RESET TO THIS STAGE EXAMPLE / ESC: QUIT',7)
        p.text(12,464,'ENTER: START / SPACE: TICK'+(' / X: UNDO; DECISIONS STAY' if STAGE==5 else ''),7)

def make_demo_keys():
    return ('RETURN',)+('SPACE',)*13

def main(headless=False,screenshot=None,demo=False):
    import pyxel as p
    p.init(672,492,title='Star railway lesson '+str(STAGE),fps=30,display_scale=2,headless=headless)
    app=App();sequence=make_demo_keys() if demo else ()
    keys={'LEFT':p.KEY_LEFT,'RIGHT':p.KEY_RIGHT,'TAB':p.KEY_TAB,'Z':p.KEY_Z,'BACKSPACE':p.KEY_BACKSPACE,'RETURN':p.KEY_RETURN,'SPACE':p.KEY_SPACE,'X':p.KEY_X,'R':p.KEY_R}
    limit=max(20,2*len(sequence)+8)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:app.draw(p);p.screen.save(screenshot,1)
            p.quit()
        if headless:
            if demo:
                index=p.frame_count//2;pressed={sequence[index]} if p.frame_count%2==0 and index<len(sequence) else set();app.control(pressed,pressed)
        else:app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--headless-smoke',action='store_true');parser.add_argument('--demo-checkpoint',action='store_true');parser.add_argument('--screenshot');args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    if args.screenshot and (Path(args.screenshot).suffix.lower()!='.png' or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):parser.error('use a new .png in an existing directory')
    if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 05 / 05

全ての星と列を、一刻まとめて戻す

履歴の長さと、これまでの判断の合計数。

Timeline に、今の State、過去の State の tuple、これまでの判断の合計 decisions を持たせます。受け付けられた路線の編集、試運転の開始、1刻進める操作の前の状態を、1件ずつ保存します。カーソルで選ぶだけのとき、同じ駅を受け付けなかったとき、空の路線で最後の駅を削除したとき、結果が出たあとの Space では、状態が変わらないので履歴を増やしません。

X では、星の位置だけでなく、到着予定、もう出現した星の一覧、待っている理由、駅ごとの待ち、この刻の出発、結果まで、まとめて戻します。試運転の開始を戻すと EDIT(編集)になり、路線を直せます。同じ刻にいっしょに動いた星を、別々の Undo に分けないことが大切です。

1回の挑戦でできる判断は、元のゲームと同じく合計256回です。decisions は Undo しても減らしません。Undo そのものは、上限を調べるより先に受け付けます。上限に達したあとも戻すことはできますが、新しい判断はできません。R で最初の共有路線に戻すと、履歴と判断の数は0になります。元のゲームでは空の路線からやり直しますが、教材では、それぞれの段階の例をすぐに試せる最初の値にしています。

この画面は状況Aだけです。05のモデルには、状況BとC、経路の比較、記録の関数も残していて、ほかのデータを読む入口にしています。ただし、ファイルの保存・続けて走らせる運転・比較の切り替えの画面(UI)は加えていません。照らし合わせるテストは、3つの状況それぞれで124組の代表的な路線と、決まった数の入力の並びを使っています。すべての路線の組み合わせや、256回の判断のすべての履歴を調べつくした、という意味ではありません。

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

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

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_undo.py
macOS / Linux
.venv/bin/python 05_undo.py
画面で見ることEnter→Space を十三回押して DONE にしてから、X を押します。TICK12、DELIVERED5/6、HISTORY13、DECISIONS14 になります。Space を押すと、同じ13刻の DONE に戻り、DECISIONS15 です。
  • 1刻で動いたすべての星を、同じ1件の履歴に入れる
  • 出現した星と、待ち列の状態も戻す
  • これまでの判断の合計は、そのまま残す
つまずきやすいところUndo したときに、decisions を履歴の長さに置きかえてはいけません。元のゲームでは、これはこれまでの操作の合計に対する上限で、今の past の長さとは別のものです。
少し変えてみよう

R→Enter→Space→Xと押すと、RUN(試運転中)の0刻、HISTORY1、DECISIONS2 になります。もう一度 X を押すと、EDIT、HISTORY0、DECISIONS2 です。Backspace で路線1の最後のDを消すと (A,X,Y) になり、新しい判断なので DECISIONS3 です。

05_undo.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(398行)
05_undo.py / 全文
# MIT License
# 
# Copyright (c) 2026
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""渋滞する流れ星・教材5/5。路線の共有重量と毎刻の出発予約を分ける。"""
from dataclasses import dataclass, replace, asdict

import hashlib, json, itertools

VERSION = '074-v1.1'

STATIONS = ('A', 'B', 'X', 'Y', 'D', 'E')

EDGES = (('A', 'X', 1), ('B', 'X', 1), ('X', 'Y', 1), ('Y', 'D', 1), ('Y', 'E', 1), ('A', 'D', 3), ('B', 'E', 3), ('D', 'E', 1))

@dataclass(frozen=True)
class Level:
    id: str
    title: str
    edges: tuple
    demand: tuple
    capacity: int = 7
    limit: int = 32

LEVELS = (Level('A', '二つの直通', EDGES, ((0, 'A', 'D'), (0, 'B', 'E'), (1, 'A', 'D'), (1, 'B', 'E'), (2, 'A', 'D'), (2, 'B', 'E'))), Level('B', '行先が交じる夜', EDGES, ((0, 'A', 'D'), (0, 'B', 'E'), (1, 'A', 'E'), (1, 'B', 'D'), (2, 'A', 'E'), (2, 'B', 'D'))), Level('C', '一方に寄った便', EDGES + (('B', 'D', 2),), ((0, 'A', 'E'), (0, 'B', 'D'), (1, 'A', 'E'), (1, 'B', 'D'), (2, 'B', 'D'), (3, 'B', 'D'))))

@dataclass(frozen=True)
class Star:
    id: int
    born: int
    source: str
    destination: str
    path: tuple = ()
    lines: tuple = ()
    index: int = 0
    eta: int = 0
    departed: int = 0
    arrived: int = 0
    ready: int = 1
    delivered: int = -1
    wait: int = 0
    reason: str = '出発待ち'

@dataclass(frozen=True)
class State:
    routes: tuple = ((), ())
    t: int = 0
    stars: tuple = ()
    status: str = 'edit'
    reason: str = '二本までの路線で、六つの星を届けよう。'
    station_wait: tuple = (0, 0, 0, 0, 0, 0)
    departures: tuple = ()

@dataclass(frozen=True)
class Timeline:
    state: State
    past: tuple = ()
    decisions: int = 0

MAX_DECISIONS = 256

def initial(level):
    return State()

def new(level):
    return Timeline(initial(level))

def edge_lengths(level):
    return {tuple(sorted((a, b))): d for a, b, d in level.edges}

def route_edges(route):
    return tuple((tuple(sorted((a, b))) for a, b in zip(route, route[1:])))

def weight(level, routes):
    return sum((edge_lengths(level)[e] for e in set(itertools.chain.from_iterable((route_edges(r) for r in routes)))))

def intersections(routes):
    return set(routes[0]) & set(routes[1])

def valid_routes(level, routes):
    return len(routes) == 2 and all((len(r) <= 5 and len(set(r)) == len(r) and all((n in STATIONS for n in r)) and all((e in edge_lengths(level) for e in route_edges(r))) for r in routes))

def path_for(level, routes, source, dest, penalty=1):
    edges = edge_lengths(level)
    cross = intersections(routes)
    adj = {n: [] for n in STATIONS}
    uses = {}
    for line, r in enumerate(routes):
        for a, b in zip(r, r[1:]):
            e = tuple(sorted((a, b)))
            uses.setdefault(e, []).append(line)
            if b not in adj[a]:
                adj[a].append(b)
                adj[b].append(a)
    candidates = []

    def walk(path):
        a = path[-1]
        if a == dest:
            options = [uses[tuple(sorted(e))] for e in zip(path, path[1:])]
            for lines in itertools.product(*options):
                cost = sum((edges[tuple(sorted(e))] for e in zip(path, path[1:]))) + max(0, len(path) - 2) + penalty * sum((n in cross for n in path[1:-1]))
                transitions = len(lines) + sum((a != b for a, b in zip(lines, lines[1:])))
                candidates.append((cost, transitions, tuple(path), tuple(lines)))
            return
        for b in sorted(adj[a]):
            if b not in path:
                walk(path + [b])
    walk([source])
    return min(candidates) if candidates else None

def planned_arrival(level, routes, z, penalty=1):
    if not z.path:
        return None
    edges = edge_lengths(level)
    cross = intersections(routes)
    return z.born + 1 + sum((edges[tuple(sorted(e))] for e in zip(z.path, z.path[1:]))) + max(0, len(z.path) - 2) + penalty * sum((n in cross for n in z.path[1:-1]))

def spawn(level, s, t, penalty):
    out = list(s.stars)
    for i, (born, source, dest) in enumerate(level.demand):
        if born != t:
            continue
        plan = path_for(level, s.routes, source, dest, penalty)
        out.append(Star(i, born, source, dest, plan[2] if plan else (), plan[3] if plan else (), arrived=born, ready=born + 1, reason='出発待ち' if plan else '接続なし'))
    return tuple(sorted(out, key=lambda z: z.id))

def advance(level, s, penalty=1):
    if s.status != 'run':
        return s
    t = s.t + 1
    cross = intersections(s.routes)
    stars = []
    for z in s.stars:
        if z.eta and z.eta == t:
            node = z.path[z.index]
            if node == z.destination:
                z = replace(z, eta=0, arrived=t, delivered=t, reason='配達済み')
            else:
                z = replace(z, eta=0, arrived=t, ready=t + 1 + (penalty if node in cross else 0), reason='一拍待ち' if node in cross and penalty else '出発待ち')
        stars.append(z)
    tmp = replace(s, stars=tuple(stars))
    stars = list(spawn(level, tmp, t, penalty))
    edge_used = set()
    station_used = set()
    chosen = set()
    rejections = {}
    departures = []
    waiting = list(s.station_wait)
    candidates = sorted((z for z in stars if not z.eta and z.delivered < 0 and z.path and (t >= z.ready)), key=lambda z: (z.arrived, z.id))
    edges = edge_lengths(level)
    for z in candidates:
        a, b = z.path[z.index:z.index + 2]
        e = (a, b)
        if a in cross and a in station_used:
            rejections[z.id] = '交差の列待ち'
            continue
        if e in edge_used:
            rejections[z.id] = '辺の列待ち'
            continue
        chosen.add(z.id)
        edge_used.add(e)
        if a in cross:
            station_used.add(a)
        departures.append((z.id, a, b))
    out = []
    for z in stars:
        if z.id in chosen:
            a, b = z.path[z.index:z.index + 2]
            z = replace(z, index=z.index + 1, eta=t + edges[tuple(sorted((a, b)))], departed=t, reason='移動中')
        elif not z.eta and z.delivered < 0:
            node = z.path[z.index] if z.path else z.source
            reason = '接続なし' if not z.path else rejections.get(z.id, '一拍待ち' if t < z.ready and z.ready - z.arrived > 1 else '出発待ち')
            if t > z.arrived:
                waiting[STATIONS.index(node)] += 1
                z = replace(z, wait=z.wait + 1)
            z = replace(z, reason=reason)
        out.append(z)
    done = len(out) == len(level.demand) and all((z.delivered >= 0 for z in out))
    status = 'done' if done else 'time' if t >= level.limit else 'run'
    return replace(s, t=t, stars=tuple(out), station_wait=tuple(waiting), departures=tuple(departures), status=status, reason='六つの星が、夜の向こうへ届きました。' if done else '32刻で試運転終了。接続と列を見直そう。' if status == 'time' else '星の札は、実際の待機理由を示します。')

def edit_reason(level, s, line, node):
    r = s.routes[line]
    if len(r) >= 5:
        return '一本は最大4辺です。'
    if node in r:
        return '同じ駅を二度通れません。'
    if r and tuple(sorted((r[-1], node))) not in edge_lengths(level):
        return 'その駅へ使える辺がありません。'
    return ''

def act(level, tl, action, penalty=1):
    if action == 'undo':
        return Timeline(tl.past[-1], tl.past[:-1], tl.decisions) if tl.past else tl
    if tl.decisions >= MAX_DECISIONS:
        return tl
    s = tl.state
    n = s
    if action == 'start' and s.status == 'edit' and (weight(level, s.routes) <= level.capacity):
        n = replace(s, status='run', reason='Spaceで一刻。Pで連続運転・停止。')
        n = replace(n, stars=spawn(level, n, 0, penalty))
    elif action == 'tick':
        n = advance(level, s, penalty)
    elif s.status == 'edit':
        p = action.split(':')
        if len(p) == 3 and p[0] == 'add' and (p[1] in ('0', '1')) and (p[2] in STATIONS):
            line = int(p[1])
            node = p[2]
            if not edit_reason(level, s, line, node):
                r = list(s.routes)
                r[line] = r[line] + (node,)
                n = replace(s, routes=tuple(r))
        elif len(p) == 2 and p[0] == 'pop' and (p[1] in ('0', '1')):
            line = int(p[1])
            r = list(s.routes)
            r[line] = r[line][:-1]
            n = replace(s, routes=tuple(r))
    if n == s:
        return tl
    return Timeline(n, tl.past + (s,), tl.decisions + 1)

def simulate(level, routes, penalty=1):
    if not valid_routes(level, routes):
        raise ValueError('invalid routes')
    s = replace(initial(level), routes=routes)
    if weight(level, routes) > level.capacity:
        return replace(s, status='weight')
    tl = act(level, Timeline(s), 'start', penalty)
    s = tl.state
    while s.status == 'run':
        s = advance(level, s, penalty)
    return s

def forecast(level, s, penalty=1):
    if s.status == 'edit':
        s = act(level, Timeline(s), 'start', penalty).state
    return advance(level, s, penalty) if s.status == 'run' else None

def route_candidates(level):
    adj = {n: [] for n in STATIONS}
    for a, b, _ in level.edges:
        adj[a].append(b)
        adj[b].append(a)
    out = {()}

    def walk(r):
        out.add(min(r, tuple(reversed(r))))
        if len(r) == 5:
            return
        for b in adj[r[-1]]:
            if b not in r:
                walk(r + (b,))
    for a in STATIONS:
        walk((a,))
    return tuple(sorted(out, key=lambda p: (len(p), p)))

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

def replay(level, actions, penalty=1):
    tl = new(level)
    rows = []
    for a in actions:
        tl = act(level, tl, a, penalty)
        rows.append({'action': a, 't': tl.state.t, 'sha256': digest(tl.state)})
    return (tl, rows)

STAGE = 5
PENALTY = 1
LEVEL = LEVELS[0]
START_ROUTES = (('A', 'X', 'Y', 'D'), ('B', 'X', 'Y', 'E'))
POS = {'A':(60,100),'B':(60,240),'X':(175,170),'Y':(290,170),'D':(405,100),'E':(405,240)}

class App:
    def __init__(self):self.reset()
    def reset(self):
        self.timeline=Timeline(replace(initial(LEVEL),routes=START_ROUTES))
        self.station=0
        self.line=0
        self.gated=False
        self.message='READY'

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

    def commit(self,action):
        before=self.state
        self.timeline=act(LEVEL,self.timeline,action,PENALTY)
        self.message=action.upper() if self.state!=before else 'NO CHANGE / REFUSED'

    def control(self,pressed,held):
        if 'R' in pressed:self.reset();self.gated=True;return
        if self.gated:
            if not pressed and not held:self.gated=False
            return
        if len(pressed|held)>1:self.gated=True;self.message='RELEASE ALL KEYS';return
        if len(pressed)!=1:return
        key=next(iter(pressed));self.gated=True
        if key=='X':self.commit('undo');return
        if key=='RETURN':self.commit('start');return
        if key=='SPACE':self.commit('tick');return
        if self.state.status!='edit':return
        if key in ('LEFT','RIGHT'):self.station=(self.station+(-1 if key=='LEFT' else 1))%len(STATIONS)
        elif key=='TAB':self.line=1-self.line
        elif key=='Z':self.commit('add:'+str(self.line)+':'+STATIONS[self.station])
        elif key=='BACKSPACE':self.commit('pop:'+str(self.line))

    def draw(self,p):
        state=self.state;p.cls(1)
        p.text(12,8,'STAR RAILWAYS / STAGE '+str(STAGE)+' / A',10)
        p.text(12,25,'WEIGHT '+str(weight(LEVEL,state.routes))+' / CAP '+str(LEVEL.capacity)+' / '+state.status.upper(),7)
        for a,b,length in LEVEL.edges:
            x,y=POS[a];xx,yy=POS[b];p.line(x,y,xx,yy,5);p.text((x+xx)//2+4,(y+yy)//2+4,str(length),6)
        for line,route in enumerate(state.routes):
            for a,b in zip(route,route[1:]):
                x,y=POS[a];xx,yy=POS[b];p.line(x,y+line*3,xx,yy+line*3,(10,12)[line])
        cross=intersections(state.routes)
        for i,n in enumerate(STATIONS):
            x,y=POS[n];p.circ(x,y,11,2 if n in cross else 1);p.circb(x,y,11,10 if i==self.station else 7);p.text(x-2,y-2,n,7)
            if n in cross:p.text(x-10,y+17,'CROSS',14)
        p.text(12,274,'LINE '+str(self.line+1)+' / STATION '+STATIONS[self.station],10)
        for i,route in enumerate(state.routes):p.text(12,291+i*15,'L'+str(i+1)+': '+('-'.join(route) or '(empty)'),(10,12)[i])
        delivered=sum(z.delivered>=0 for z in state.stars)
        p.text(470,50,'TICK '+str(state.t),10)
        p.text(470,67,'DELIVERED '+str(delivered)+'/'+str(len(LEVEL.demand)),11)
        p.text(470,84,'TOTAL WAIT '+str(sum(state.station_wait)),14)
        for z in state.stars:
            y=110+z.id*25
            p.text(470,y,'#'+str(z.id)+' '+z.source+'>'+z.destination+' '+('DONE '+str(z.delivered) if z.delivered>=0 else 'ETA '+str(z.eta) if z.eta else 'WAIT '+str(z.wait)),7)
            labels={'移動中':'MOVING','配達済み':'DELIVERED','出発待ち':'READY WAIT','接続なし':'NO PATH','辺の列待ち':'EDGE QUEUE','交差の列待ち':'STATION QUEUE','一拍待ち':'EXTRA REST'}
            p.text(478,y+10,labels.get(z.reason,'WAIT'),6)
            if z.path:
                if z.eta:
                    a,b=z.path[z.index-1:z.index+1];x,y0=POS[a];xx,yy=POS[b]
                    duration=z.eta-z.departed;u=(state.t-z.departed)/duration
                    x,y0=x+(xx-x)*u,y0+(yy-y0)*u
                else:x,y0=POS[z.path[z.index]]
                p.circ(x,y0-15-z.id*4,2,(8,10,12,11,9,14)[z.id])
        q=forecast(LEVEL,state,PENALTY)
        p.text(12,330,'NEXT DEPARTURES '+str(q.departures) if q else 'NO NEXT TICK',6)
        p.text(12,347,'NEXT DELIVERED '+str(sum(z.delivered>=0 for z in q.stars)) if q else 'END / OR TOO HEAVY',6)
        p.text(12,367,'CROSS: ONE DEPARTURE/TICK + EXTRA REST1',14)
        p.text(12,384,'HISTORY '+str(len(self.timeline.past))+' / DECISIONS '+str(self.timeline.decisions),6)
        p.text(12,406,self.message,10)
        p.text(12,430,'LEFT/RIGHT: STATION LIST / TAB: LINE / Z: ADD / BACKSPACE: POP',7)
        p.text(12,447,'R: RESET TO THIS STAGE EXAMPLE / ESC: QUIT',7)
        p.text(12,464,'ENTER: START / SPACE: TICK'+(' / X: UNDO; DECISIONS STAY' if STAGE==5 else ''),7)

def make_demo_keys():
    return ('RETURN',)+('SPACE',)*13+('X',)

def main(headless=False,screenshot=None,demo=False):
    import pyxel as p
    p.init(672,492,title='Star railway lesson '+str(STAGE),fps=30,display_scale=2,headless=headless)
    app=App();sequence=make_demo_keys() if demo else ()
    keys={'LEFT':p.KEY_LEFT,'RIGHT':p.KEY_RIGHT,'TAB':p.KEY_TAB,'Z':p.KEY_Z,'BACKSPACE':p.KEY_BACKSPACE,'RETURN':p.KEY_RETURN,'SPACE':p.KEY_SPACE,'X':p.KEY_X,'R':p.KEY_R}
    limit=max(20,2*len(sequence)+8)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:app.draw(p);p.screen.save(screenshot,1)
            p.quit()
        if headless:
            if demo:
                index=p.frame_count//2;pressed={sequence[index]} if p.frame_count%2==0 and index<len(sequence) else set();app.control(pressed,pressed)
        else:app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--headless-smoke',action='store_true');parser.add_argument('--demo-checkpoint',action='store_true');parser.add_argument('--screenshot');args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    if args.screenshot and (Path(args.screenshot).suffix.lower()!='.png' or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):parser.error('use a new .png in an existing directory')
    if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
AFTER STEP 05

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

二本の路線が共有する物理辺は、建設重量を1回だけ数えます。星は出現した次の刻から動き、辺の長さの分だけ進むと到着します。途中の駅ではふつう1刻待ち、交差駅では追加でもう1刻待ちます。交差駅から出発できるのは1刻に1つ、向きのある辺に入れるのも1刻に1つです。元のゲームの状況Aの六つの星を、重量7以内・32刻以内に配達します。

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

比べる点5ステップの教材元の完成版
段階01は路線の編集だけ。02は星1つで、容量と追加の待ちはなし。03は星6つで、有向辺の枠だけ。04と05は状況Aのふつうのルール。最初から、すべての需要・交差駅の枠・追加の1刻・Undo を使う。
最初の路線と操作01は空、02は直通の二本、03からは共有路線から始める。英字(ASCII)の表示で、状況Aに固定。左右キーで駅の一覧から選ぶ。Enter ですぐに始まる。空の路線から始める。画面の位置で駅を選ぶ。状況A・B・C、比較、続けて走らせる運転、表紙や説明がある。
記録とモデル05は元のゲームのモデルを全部残している。保存用のファイルの画面は省略し、それぞれの段階だけで動かせる。図の線の長さは、見やすくするための形で、実際の長さではない。そのときのルールの版・入力・最後のハッシュ(状態を短い値にまとめたもの)を保存する。専用の比較と再生、Noto の文字の形(字形)がある。

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

  1. model.py の weight・route_edges:共有する物理辺
  2. model.py の path_for:費用と、同じ順位のときにいつも同じものを選ぶ処理
  3. model.py の advance:到着→出現→枠の予約→同時出発
  4. model.py の planned_arrival と、実際の待ち列での待ち
  5. model.py の act・Timeline と、判断の合計の上限
  6. controller.py・experiments.py:比較モードと、同じ条件での実際の結果
自分のゲームに変えるなら

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

教材をダウンロードする

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

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

検証について

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

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

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

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