鉄道シミュレーターにおいて、プレイヤーが最も触れる部分は「マスコン(マスターコントローラー)」です。
実車のような専用コントローラーを使えないブラウザゲームだからこそ、マウスやタッチ操作でも「重み」や「質感」を感じられるUIを目指しました。
CSSで描くリアルな質感
今回のマスコンUIには、画像素材を一切使用していません。すべてCSSの linear-gradient(グラデーション)と box-shadow(影)を組み合わせて描画しています。
特にこだわったのは、レバー部分のプラスチック感です。光の当たり方を計算し、ハイライトとシャドウを多重にかけることで、立体的な「握り」の部分を表現しました。
/* レバーハンドルのCSS例 */
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 24px; height: 50px;
background: linear-gradient(to right, #444, #888, #444);
border: 1px solid #000;
border-radius: 4px;
box-shadow: 0 5px 10px rgba(0,0,0,0.5); /* 落ち影で浮遊感を出す */
margin-top: -20px;
position: relative; z-index: 10;
}
操作感へのこだわり
実装にはHTML標準の <input type="range"> を使用していますが、そのままではスルスルと動いてしまい、電車のノッチ特有の「カチッ、カチッ」というクリック感がありません。
そこで、step 属性を活用しつつ、JavaScriptで現在の値を監視し、ノッチが切り替わった瞬間に効果音を鳴らす等のフィードバックを入れることで、擬似的なクリック感を演出しています。
また、ブレーキ(B1〜EB)と力行(P1〜P5)の間に「N(ニュートラル)」を広めに設けることで、誤操作を防ぐ工夫もしています。
スマホとPCのUI最適化
最も苦労したのはレスポンシブ対応です。PCでは画面左下に配置したリッチなパネルで操作できますが、スマートフォンの狭い画面では地図が見えなくなってしまいます。
そこで、モバイル端末(幅600px以下)ではPC用のパネルを非表示にし、画面下部に固定した「モバイル専用コントローラー」を表示するように切り替えています。
スマホ版ではスライダー操作は指で隠れてしまい難しいため、あえて「加速ボタン」「減速ボタン」という物理ボタン風のレイアウトに変更し、親指だけで運転できるUXを採用しました。
速度メーターの実装
マスコンと並んで重要なUI要素が「速度メーター」です。運転中、現在速度を一目で把握できることはゲームプレイの快適さに直結します。
デザインは、実際の電車の運転台にあるデジタル速度計を参考にしました。Courier Newフォントを使い、黒背景にオレンジ色の数字が光るような見た目にしています。CSSの text-shadow で発光効果を加えることで、LEDディスプレイのような雰囲気を出しました。
/* 速度メーターのCSS */
.speed-meter {
font-family: 'Courier New', monospace;
color: #ff9900;
font-size: 32px;
font-weight: bold;
text-shadow: 0 0 10px rgba(255, 153, 0, 0.5);
background: #000;
padding: 8px;
border-radius: 4px;
}
ランプインジケーター
速度メーターの横には、車両の状態を示すランプインジケーターを配置しています。「力行」「惰行」「制動」の3つのランプが状態に応じて点灯・消灯します。
これは実車の運転台にある表示灯をモチーフにしたもので、CSSの box-shadow で発光エフェクトを表現しています。状態が変わった瞬間にJavaScriptで active-green や active-red クラスを付け替えることで、ランプの色が切り替わります。
タッチイベントの精密な制御
スマートフォンでのスライダー操作には、いくつかの技術的な落とし穴がありました。
touchmoveイベントとスクロールの競合
スライダーを左右にドラッグしようとすると、ブラウザがページのスクロールと解釈してしまい、画面全体が動いてしまうことがあります。これを防ぐため、スライダー要素の touchmove イベントで preventDefault() を呼んでいます。
ただし、preventDefault() を無条件に呼ぶと、スライダー外の領域でもスクロールが効かなくなります。そこで、タッチ開始位置がスライダー要素内かどうかを判定し、スライダー操作中のみスクロールを抑制するロジックにしています。
アクセシビリティへの配慮
見た目にこだわるあまり、キーボード操作やスクリーンリーダーでの利用が困難にならないよう、以下の対策を入れています。
aria-label属性でスライダーの役割を明示(「マスコン: 加速と減速の操作」)aria-valuemin/aria-valuemax/aria-valuenowで現在のノッチ位置を通知- キーボードの矢印キーでもノッチを切り替えられるイベントハンドラの追加
すべてのユーザーが同等に楽しめるシミュレーターを目指すうえで、アクセシビリティは今後もさらに改善していきたい領域です。
レバーの握り心地をCSSだけで再現する
「マスコンらしさ」を出すうえで最も時間をかけたのが、レバー(スライダーのつまみ)の見た目です。実は初期の実装では、ただの灰色の長方形が付いているだけの極めてショボいスライダーで、自分でも「これはマスコンじゃなくて音量調整だな」と思うレベルでした。
linear-gradientを何層も重ねる
プラスチックの立体感を出すために採用したのが、linear-gradient を複数方向から重ねがけする手法です。1本だけだと「平らな面に光が当たっている」程度ですが、縦方向のグラデーションと横方向のグラデーションを組み合わせることで、球面のような丸みのある光沢感が一気に出ます。
/* レバー本体のグラデーション重ねがけ */
.mascon-lever {
background:
linear-gradient(180deg, rgba(255,255,255,0.15) 0%, transparent 40%),
linear-gradient(90deg, rgba(0,0,0,0.25) 0%, transparent 20%, transparent 80%, rgba(0,0,0,0.25) 100%),
linear-gradient(180deg, #4a4a4a 0%, #2a2a2a 50%, #1a1a1a 100%);
border-radius: 8px 8px 10px 10px; /* 下側をわずかに楕円に */
box-shadow:
inset 0 2px 3px rgba(255,255,255,0.2), /* 上端のハイライト */
inset 0 -3px 4px rgba(0,0,0,0.5), /* 下端の影 */
0 6px 12px rgba(0,0,0,0.6); /* 落ち影 */
}
::before / ::afterで反射を演出
さらに立体感を増すため、疑似要素 ::before と ::after を使って、レバー上端の反射光とツヤを追加しました。::before は上端の白いハイライト、::after は側面の反射を模したわずかな明るいライン、という役割分担です。これを加えた瞬間、画面上のレバーが「プラスチックっぽい」から「プラスチックだ」に昇格しました。
最後の微調整として、border-radius を真円ではなく 8px 8px 10px 10px のようにわずかに楕円にすることで、指で握り込んだときの潰れ感を表現しています。この数ピクセルの違いが、質感として馬鹿にならない差を生みました。
ノッチ感を出すアニメーション
マスコンの本質は「連続可変」ではなく、「カチッ、カチッ」とノッチ(段)ごとに止まる動きです。ここをソフトウェアで再現するのが意外と難しく、いくつかの失敗を経て最終的な実装に辿り着きました。
5段階でピタッと止める
力行側はP1〜P5の5段階、ブレーキ側もB1〜B5とEB(非常)という離散値を取ります。<input type="range"> の step 属性で段階化するだけでは、視覚的には動きが硬くてそっけないので、CSSの transition でわずかなバウンス感を乗せました。timing-function にバネっぽいカーブの cubic-bezier(0.68, -0.55, 0.27, 1.55) を指定するのがポイントです。
/* ノッチ移動時のバウンスアニメーション */
.mascon-lever {
transition: transform 140ms cubic-bezier(0.68, -0.55, 0.27, 1.55);
}
/* 各ノッチ位置 */
.mascon-lever[data-notch="N"] { transform: translateY(0); }
.mascon-lever[data-notch="P1"] { transform: translateY(-28px); }
.mascon-lever[data-notch="P2"] { transform: translateY(-56px); }
.mascon-lever[data-notch="P3"] { transform: translateY(-84px); }
.mascon-lever[data-notch="P4"] { transform: translateY(-112px); }
.mascon-lever[data-notch="P5"] { transform: translateY(-140px); }
音との同期ポイント
ビジュアルのバウンスと「カチッ」という効果音を同期させるのも地味に難関でした。CSSアニメーションは「完了イベント」で音を鳴らすとノッチ到達のタイミングより微妙に遅れ、逆に値変化の瞬間に鳴らすと早すぎて違和感が出ます。試行錯誤の末、値が変わった瞬間から50ms遅延して再生するのが一番自然に感じられるポイントでした。人間の脳は映像と音の同時認知にわずかな許容誤差があるらしく、その誤差にピタリと乗せる形です。
押し間違い対策のUX設計
見た目と操作感が整ってくると、今度は「誤操作」との戦いが始まりました。マスコンは鉄道シミュレーターの心臓なので、ここでミスをすると即ゲームオーバーに繋がります。
ドラッグ途中でマウスが離れると勝手にノッチが戻る
初期実装の最大の問題が、レバーをドラッグしている最中にマウスポインタがレバー要素から外れると、ドラッグ判定が切れてノッチがゼロに戻る現象でした。特に激しく操作するプレイヤーほど起きやすく、運転中にいきなり非常停止状態になる危険な挙動です。
// pointermoveはwindowに、pointerupで解除
let dragging = false;
lever.addEventListener('pointerdown', e => {
dragging = true;
lever.setPointerCapture(e.pointerId); // ポインタを捕捉
});
window.addEventListener('pointermove', e => {
if (!dragging) return;
updateNotchByY(e.clientY);
});
window.addEventListener('pointerup', () => { dragging = false; });
解決策は、pointerdown 時に setPointerCapture でポインタを明示的に捕捉し、pointermove は window に対してリッスンすることです。これで、マウスがレバーの外に飛び出してもドラッグ状態が維持され、ユーザーの意図通りに操作できるようになりました。
非常ブレーキの誤操作防止
非常ブレーキ(EB)は1段飛ばしで急激に減速する強い操作なので、通常のノッチと同列に並べておくと、うっかり通過されやすい位置にあります。そこで、EBに到達する直前で300msのホールド判定を入れ、意図的に下まで押し込まない限り発動しない仕様にしました。視覚的にも、EB境界を跨ぐ瞬間にレバー全体が赤くハイライトされるフィードバックを入れています。
アクセシビリティで学んだこと
アクセシビリティは章のまとめで軽く触れましたが、実装中に学んだことが多かったので、もう少し掘り下げます。
tabindexとキーボード操作
最初、マスコンはマウス・タッチ専用で作っていて、キーボードでの操作はまったく考慮されていませんでした。Twitterで「キーボードでプレイしたい」という声をいただき、慌てて対応したのがきっかけです。<input type="range"> はデフォルトで矢印キーに反応しますが、カスタムUIで上書きしていたのでその挙動が失われており、明示的に tabindex="0" を付けて、keydown イベントで上下矢印キーをノッチ操作にマッピングしました。
// キーボード操作対応
lever.setAttribute('tabindex', '0');
lever.addEventListener('keydown', e => {
if (e.key === 'ArrowUp') { incrementNotch(); e.preventDefault(); }
if (e.key === 'ArrowDown') { decrementNotch(); e.preventDefault(); }
if (e.key === 'Home') { setNotch('N'); e.preventDefault(); }
});
ARIA属性で状態を読み上げる
スクリーンリーダーユーザーのために、ARIA属性も整備しました。role="slider" に加え、現在のノッチを aria-valuetext="力行3段" のような日本語文字列で提供することで、単なる数値ではなく「今どの状態なのか」が音声で伝わるようになります。
色覚多様性への配慮
もう一つの学びが色設計です。当初、力行を緑、ブレーキを赤で色分けしていたのですが、これは赤緑色覚異常の方にとって最も判別しにくい組み合わせです。鉄道の信号機は歴史的な理由で赤緑を採用していますが、UIでそれを踏襲する理由はありません。最終的には、力行は青、ブレーキは黄色という配色に変更しました。加えて、色だけに頼らず、力行側には上向きの三角アイコン、ブレーキ側には下向きアイコンを併記することで、色情報を失っても状態が分かるようにしています。
メーター表示の実装
速度メーターはCSSのデジタル表示で作った話を書きましたが、実はこれとは別に「運転台風」のアナログ円形メーターも試作しています。実運用では画面占有の都合でデジタル表示を採用しましたが、円形メーターはSVGとrAFを組み合わせた学びの多い実装だったので、ここで紹介します。
SVGで円形メーターを描く
円形メーターは <svg> の <circle> と <path> で構成しています。目盛りは三角関数で座標を計算し、0km/hを左下(角度225度)、最大速度を右下(角度315度)に配置する270度スケールです。
// 円形メーターの目盛り座標計算
function meterTickPos(speed, maxSpeed, radius, cx, cy) {
const ratio = speed / maxSpeed; // 0〜1
const angleDeg = 225 + ratio * 270; // 225度〜495度(=135度)
const angleRad = angleDeg * Math.PI / 180;
return {
x: cx + Math.cos(angleRad) * radius,
y: cy + Math.sin(angleRad) * radius
};
}
stroke-dasharrayで針を動かす
針の動きには stroke-dasharray を使っています。針自体ではなく、目盛り円弧の塗り量を現在速度に連動させる方式です。stroke-dasharray: <速度比例値> <残り> という形で描画することで、進行に応じて弧が徐々に埋まっていく表現が実現できます。CSSの transition も効くので、滑らかな追従がほとんどノーコードで得られました。
60fpsで滑らかに見せるrAFループ
CSSの transition だけだと、速度の更新頻度が粗い場合にカクついて見えます。そこで、requestAnimationFrame で毎フレーム現在速度を取得し、前フレームとの差分をイージング補間してから描画する方式に切り替えました。これによりスムーズな針の動きが維持でき、かつ60fps以下の環境でも見た目のカクつきが目立たなくなります。
// rAFで速度メーターを60fps更新
let displayedSpeed = 0;
function renderMeterLoop() {
const target = train.speedKmh;
// イージング補間(1フレームで8%追いつく)
displayedSpeed += (target - displayedSpeed) * 0.08;
updateMeterStroke(displayedSpeed);
requestAnimationFrame(renderMeterLoop);
}
requestAnimationFrame(renderMeterLoop);
アナログメーターは結局メインUIからは外しましたが、「どうせなら」と試行錯誤したこの実装過程で、SVGとrAFの組み合わせがどれだけ柔軟かを実感できました。今後、別モードの計器類などで活かしていきたいと思います。
まとめ
UIは単なる飾りではなく、体験そのものです。今後も、より直感的で、かつ「鉄道模型をいじっている」感覚になれるようなインターフェースを追求していきたいと思います。