山手線3Dジオラマでは、画面上で10編成の電車を同時に走らせることを目標にしました。1編成だけなら「等速で一周する」を実装すればよいのですが、複数編成となると前後の車間を保ちながら、駅で一定時間停車し、互いに追突しない制御が必要になります。いわば簡易ダイヤの自動生成と、ミニチュアATC(自動列車制御)の実装です。

この記事では、私がこのシミュレーターで採用している疑似ダイヤ生成の考え方と、ハマった点を正直に書いておこうと思います。

実在ダイヤを写すのか、それっぽく作るのか

最初は「JR東日本の公開ダイヤを取り込めばいいのでは」と安直に考えていました。ですが、山手線の場合はダイヤそのものが公開データとして整った形で取得できるわけでもなく、取れたとしても時間帯ごとに編成数が変わるので、シミュレーターの画面と噛み合わせるのが難しい。

そこで割り切って、「実際の山手線の運行感覚に似た疑似ダイヤをその場で生成する」方針にしました。具体的なパラメータは以下のとおりです。

基本パラメータ

山手線は実際には約4〜5分間隔で運行しており、1周の所要時間は60〜65分程度です。これをもとに、シミュレーター内部では次のような値を採用しました。

const LINE_LENGTH_M   = 34500;  // 山手線の全長(m)
const LOOP_TIME_SEC   = 3900;   // 1周65分
const DWELL_TIME_SEC  = 30;     // 停車時間(全駅共通)
const STATION_COUNT   = 30;     // 駅数
const RUN_TIME_SEC    = LOOP_TIME_SEC - DWELL_TIME_SEC * STATION_COUNT; // 走行合計
const AVG_SPEED_MPS   = LINE_LENGTH_M / RUN_TIME_SEC;
const HEADWAY_SEC     = Math.floor(LOOP_TIME_SEC / TRAIN_COUNT); // 編成間隔

シミュレーターで10編成走らせるなら、1編成あたりの間隔は 3900 / 10 = 390秒、つまり6分30秒ごとに次の編成が発車するイメージになります。実際の山手線よりやや粗めですが、画面上の密度としてはこれくらいが見栄えがよく、ユーザーが全車両を追いきれる範囲に収まります。

駅間所要時間を個別に配分する

単純に「全体の走行時間を駅数で割る」だけだと、駅間距離が違うのに所要時間が一律になり、駅間が長い田町〜品川などで電車がやけに遅く見えてしまいます。そこで、駅間距離に比例して所要時間を配分するように直しました。

// 駅間所要時間を距離に比例して配分
function buildSectionTimes(stations, runTimeSec, totalLenM) {
    const times = [];
    for (let i = 0; i < stations.length; i++) {
        const from = stations[i].dist;
        const to   = stations[(i + 1) % stations.length].dist;
        const len  = ((to - from) + totalLenM) % totalLenM;
        times.push(runTimeSec * (len / totalLenM));
    }
    return times;
}

最初これを入れ忘れていたせいで、電車が駒込〜田端の短い区間でノロノロ走って、上野〜御徒町〜秋葉原の連続短区間で急にワープするような動きになっていました。ユーザーから「なんか動きが気持ち悪い」と指摘されて原因に気づき、半日溶かして修正した記憶があります。

駅ごとのダイヤテーブル

駅間所要時間が決まったら、編成ごとに「いつ、どの駅に着くか」の時刻表を出発時刻から積み上げで作成します。

function buildSchedule(trainIdx, departureSec, sectionTimes) {
    let t = departureSec;
    const schedule = [];
    for (let i = 0; i < STATION_COUNT; i++) {
        schedule.push({ stationIdx: i, arrive: t, depart: t + DWELL_TIME_SEC });
        t += DWELL_TIME_SEC + sectionTimes[i];
    }
    return schedule;
}

これを10編成分作ると、各駅に対して「次にどの編成が何秒後に来るか」が予測できます。後述する衝突判定にも使います。

衝突回避:遺伝的アルゴリズムっぽい何か

単純に等間隔で10編成を配置するだけなら衝突は起きません。ですが、ユーザーが特定の編成だけ手動で運転できるモードを用意しているので、「手動編成が停車位置を間違えると、後続が追いつく」という状況が普通に起きます。

最初に書いたバージョンは、単純な「車間5m以下になったら後続を強制停止」でした。これだと前が急ブレーキ、後ろも急ブレーキ、その後ろも……と連鎖して全編成が山手線上で数珠つなぎに停まるという、見たことのないディストピアが誕生しました。実際のATCはもっと先読みして速度を落とすので、似たようなロジックが必要です。

試行ベースの初期配置探索

同時に全10編成を線路上に配置する初期状態は、ランダムに発車時刻をずらしてから「1周分シミュレーションしたときの総衝突回数」が最小になる組み合わせを探しています。完全な遺伝的アルゴリズムではなく、交叉も突然変異も入れていない素朴な試行ベースですが、10試行もすれば衝突なしの配置が見つかります。

function searchInitialOffsets(trials = 50) {
    let best = null;
    for (let k = 0; k < trials; k++) {
        const offsets = Array.from({ length: TRAIN_COUNT }, (_, i) => {
            return Math.round(HEADWAY_SEC * i + (Math.random() - 0.5) * 30);
        });
        const conflicts = simulateOneLoop(offsets);
        if (!best || conflicts < best.conflicts) {
            best = { offsets, conflicts };
            if (conflicts === 0) break;
        }
    }
    return best.offsets;
}

この関数、実は最初は trials = 10 にしていて、そこそこの確率で「衝突1回」の結果を返してきました。本番で「いきなり渋谷で電車が詰まる」という苦情が来てから50に上げました。動的計画法で厳密に解くべきだろうとは思いつつ、ブラウザで一発で動く限りこれで十分と割り切っています。

ATC風の簡易車間制御

ランタイムの制御ロジックも簡易版ATCを意識しています。各フレームで、自分の前を走っている編成との距離(車間)を計算し、一定以下になったら徐々に減速する、という単純なものです。

const SAFE_DIST_M    = 500;   // これ以下で減速開始
const EMERG_DIST_M   = 150;   // これ以下で最大減速
const MAX_ACCEL_MPS2 = 0.6;
const MAX_BRAKE_MPS2 = 1.1;

function updateTrain(self, leader, dtSec) {
    const gap = (leader.dist - self.dist + LINE_LENGTH_M) % LINE_LENGTH_M;
    let targetSpeed = AVG_SPEED_MPS;

    if (gap < EMERG_DIST_M) {
        targetSpeed = 0;
    } else if (gap < SAFE_DIST_M) {
        const t = (gap - EMERG_DIST_M) / (SAFE_DIST_M - EMERG_DIST_M);
        targetSpeed = AVG_SPEED_MPS * t;
    }

    const diff = targetSpeed - self.speed;
    const accel = Math.sign(diff) * Math.min(Math.abs(diff / dtSec),
        diff > 0 ? MAX_ACCEL_MPS2 : MAX_BRAKE_MPS2);
    self.speed = Math.max(0, self.speed + accel * dtSec);
    self.dist  = (self.dist + self.speed * dtSec) % LINE_LENGTH_M;
}

このシンプルな3レンジ(通常/警告/非常)で、10編成の同時走行はだいたい破綻しなくなりました。完全なATC-P(パターン制御)ではなく、距離と速度の線形補間で作ったなんちゃってパターンです。

ラッシュ時の渋滞を再現してしまった話

パラメータを調整している途中、SAFE_DIST_M を大きくしすぎた(800mまで伸ばした)ことがありました。そうしたら、現実の山手線のラッシュ時のように、内回り全体がじわじわ遅くなって一周の所要時間が75分を超える状態になりました。偶然「団子運転」を再現してしまったわけで、笑いながら画面を見ていましたが、シミュレーターとしては使い物にならないので500mまで縮めました。

ちなみにこの現象が起きると、後続編成が追いついた結果、さらに後ろの編成にも玉突きで減速要求が伝播して、ダイヤ全体が「綻びる」挙動になります。簡単なBKM(Back Propagation of Kinematic Messages)的な現象で、数値モデル上もはっきり観察できたのは個人的に面白い発見でした。

駅停車と出発タイミングのずれ

もう一つ苦労したのが、駅での停車時間と出発判定の同期です。当初は「駅の距離値に到達したら30秒止まって、30秒経ったら出発」と書いていました。これが、車間制御との組み合わせで不具合を引き起こします。

具体的には、前の編成がまだ駅に止まっているのに、後続が「もう30秒経ったから」と出発してしまい、駅構内の手前で急停車する状況。現実世界ならATC側が「先行列車在線」で発車を抑止しますが、私のロジックにはそれがなかったので、ホームに入る直前で急減速するみっともない動きになっていました。

function canDepart(self, leaderGap) {
    // 停車時間を満たしつつ、先行列車との車間も確認
    const timeOk = (now - self.arrivedAt) >= DWELL_TIME_SEC;
    const spaceOk = leaderGap > SAFE_DIST_M;
    return timeOk && spaceOk;
}

この canDepart を入れてから、「駅での待ち時間が30秒±α」になり、ラッシュ時の駅出発の遅れも自然に表現できるようになりました。時間と空間の両方で出発を判定するのは、シンプルですが効果絶大でした。

手動編成と自動編成の混在

ユーザーが1編成を手動操作するモードでは、残り9編成は自動運転のままです。手動編成も車間制御の対象にするかどうかで挙動が変わるので、「手動編成は自動編成にとっての先行列車として扱うが、自動編成は手動編成を追い越さない」という片方向の制約だけ入れています。

ただしユーザーが駅で30秒以上停車したりすると、後続の自動編成が無限に詰まっていくので、その場合は自動編成側が「追越可能な退避線があることにして」手動編成を飛び越す、という簡易ロジックを入れました。厳密な鉄道ファンから見ると違和感があるかもしれませんが、シミュレーターの体験を守るためには必要な妥協です。

まとめ

実在ダイヤを完全に再現するのは個人開発の範疇を超えますが、「駅間距離に応じた所要時間配分」「車間による簡易ATC」「初期配置の試行探索」という3つの要素だけで、視覚的には十分に「山手線っぽい」ダイヤが生成できました。

複数編成を同時に走らせると、単独では見えてこなかった問題(団子運転、追突、駅の発車抑止)が次々に出てきます。これらをひとつずつ潰していく過程で、鉄道会社の運行管理システムが何をしているのかを、少しだけ体感できた気がします。今後は線形補間ではなく加減速プロファイルを駅ごとにカスタマイズして、もう一段リアリティを上げていく予定です。