""";
internal const string Script = """
// ---------------------------------------------------------------- metro / light rail (round 54)
// The card's own strings are bilingual here rather than through the DOM i18n pass: most of them carry
// live numbers, and the pass only rewrites text it can look up verbatim.
function metroEn() { return !!(window.PanelI18n && window.PanelI18n.lang === 'en'); }
function metroT(zh, en) { return metroEn() ? en : zh; }
var metroLines = []; // catalog the server reported last
var metroPicked = null; // Set of ticked line ids; null until the first status arrives
var metroLineSig = ''; // rebuild the line table only when this changes, so ticks survive a refresh
function metroClock(sec) {
if (typeof sec !== 'number' || !isFinite(sec)) return '—';
const s = Math.max(0, Math.round(sec));
return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
}
function metroStationName(stop) {
if (!stop) return '—';
const zh = stop.chinese || stop.name || '';
const en = stop.name || zh;
return metroEn() ? en : zh;
}
function metroTrainStation(row, chineseKey, englishKey) {
const zh = row[chineseKey] || row[englishKey] || '';
const en = row[englishKey] || zh;
return (metroEn() ? en : zh) || '—';
}
async function refreshMetro() {
let d;
try {
d = await api('/api/metro/status');
} catch (e) {
return;
}
if (!d) return;
const swEl = document.getElementById('metroSwitchText');
const stEl = document.getElementById('metroStatus');
const trEl = document.getElementById('metroTrains');
const resEl = document.getElementById('metroResult');
if (!stEl) return;
if (!d.online) {
if (swEl) { swEl.textContent = metroT('离线', 'offline'); swEl.style.color = '#f44336'; }
stEl.textContent = metroT('客户端未进入游戏(当前没有在线会话)', 'the client is not in the game (no online session)');
stEl.style.color = '#f44336';
if (trEl) trEl.innerHTML = '';
metroMapSync([]); // no session: keep the lines as they are, only drop the carriages
return;
}
if (!d.started) {
if (swEl) { swEl.textContent = metroT('未启动', 'not started'); swEl.style.color = '#f44336'; }
stEl.textContent = metroT(
'场景还没有请求过地铁列表(AskGetAllMetroInfos)。进入游戏世界并等到场景加载完,服务端收到请求后这一页才会有数据。',
'the scene has not asked for the metro list yet (AskGetAllMetroInfos). Enter the game world and let the scene finish loading; this page fills in once the request arrives.');
stEl.style.color = '#ff9800';
if (trEl) trEl.innerHTML = '';
// The catalog is readable before the scene asks for anything, so still show the lines - that is
// where you look if you want to know whether the server found the Rail*Config files at all.
metroLines = Array.isArray(d.lines) ? d.lines : [];
if (metroPicked === null) metroPicked = new Set();
if (metroLineSig !== 'idle') { metroLineSig = 'idle'; metroRenderLines(); }
metroMapSync([]); // the diagram is readable straight from the catalog, before the scene asks for it
return;
}
const pushEl = document.getElementById('metroPushMs');
if (pushEl && document.activeElement !== pushEl && typeof d.pushMs === 'number') pushEl.value = d.pushMs;
const factorEl = document.getElementById('metroTimeFactor');
if (factorEl && document.activeElement !== factorEl && typeof d.timeFactor === 'number') factorEl.value = String(d.timeFactor);
const enabled = !!(d.enabled && d.running);
if (swEl) {
swEl.textContent = d.enabled
? (d.running ? metroT('已开启', 'running') : metroT('已开启(循环待启动)', 'enabled (loop pending)'))
: metroT('已关闭', 'off');
swEl.style.color = d.enabled ? '#00e676' : '#f44336';
}
const parts = [
metroT('场景已请求', 'scene requested') + ': ' + (d.requested ? (metroEn() ? 'yes' : '是') : (metroEn() ? 'no' : '否')),
metroT('推送', 'pushes') + ' ' + (d.pushes || 0) + ' ' + metroT('次 / 列车', 'x / carriages') + ' ' + (d.trainsSent || 0),
d.lastPushUtc ? metroT('最近', 'last') + ' ' + d.lastPushUtc : null,
metroT('地区', 'raid') + ' ' + (d.raidId || 0)
+ (d.autoRaidLines ? (metroEn() ? ' · auto' : ' · 自动选线') : (metroEn() ? ' · manual' : ' · 手动选线')),
metroT('服务时间', 'service time') + ' ' + (d.serviceSeconds || 0) + 's',
metroT('周期', 'period') + ' ' + (d.pushMs || 0) + 'ms',
metroT('时间倍率', 'time factor') + ' ×' + (typeof d.timeFactor === 'number' ? d.timeFactor : '?'),
metroT('心跳', 'ticks') + ' ' + (d.ticks || 0),
d.lastError ? '⚠ ' + d.lastError : null,
].filter(Boolean);
stEl.textContent = parts.join(' · ');
stEl.style.color = d.lastError ? '#f44336' : '#4dd0e1';
if (resEl && d.lastError) resEl.textContent = '⚠ ' + d.lastError;
metroLines = Array.isArray(d.lines) ? d.lines : [];
if (metroPicked === null) metroPicked = new Set(metroLines.filter(l => l.enabled).map(l => l.id));
const sig = metroLines.map(l => l.id + (l.enabled ? '1' : '0')).join(',');
if (sig !== metroLineSig) {
metroLineSig = sig;
metroPicked = new Set(metroLines.filter(l => l.enabled).map(l => l.id));
metroRenderLines();
}
const trainList = Array.isArray(d.trains) ? d.trains : [];
metroRenderTrains(trainList);
metroMapSync(trainList);
}
function metroRenderLines() {
const box = document.getElementById('metroLines');
if (!box) return;
if (!metroLines.length) { box.textContent = metroT('服务端没有读到 RailLineConfig(配置没复制过去?)', 'the server read no RailLineConfig (was the config copied over?)'); return; }
const head = '
';
box.onchange = function (ev) {
const id = parseInt(ev.target.getAttribute('data-metro-line') || '0', 10);
if (!id) return;
if (!metroPicked) metroPicked = new Set();
if (ev.target.checked) metroPicked.add(id); else metroPicked.delete(id);
};
}
function metroRenderTrains(trains) {
const box = document.getElementById('metroTrains');
if (!box) return;
if (!trains.length) {
box.textContent = metroT('当前没有列车(勾选的线路都是空的,或者总开关关着)', 'no carriages right now (the ticked lines are empty, or the master switch is off)');
return;
}
const head = '
';
}
async function metroPost(path, body) {
const resEl = document.getElementById('metroResult');
try {
const d = await api(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {}),
});
if (resEl) resEl.textContent = d && d.ok === false
? '❌ ' + (d.error || metroT('失败', 'failed'))
: '✅ ' + metroT('已下发', 'sent');
metroLineSig = '';
await refreshMetro();
} catch (e) {
if (resEl) resEl.textContent = '❌ ' + e;
}
}
function metroToggle(on) {
return metroPost('/api/metro/toggle', { enabled: !!on });
}
function metroPushMs() {
const el = document.getElementById('metroPushMs');
const v = parseInt((el && el.value) || '250', 10);
return Number.isFinite(v) ? Math.min(2000, Math.max(50, v)) : 250;
}
// The multiplier between the timetable second and the ElapsedTime the client expects: the client divides
// what we send by 15 (SERVER_TIME_FACTOR), the schedule is in scene seconds, so 15 is the correct value.
// Kept editable so a future client change can be absorbed without a rebuild.
function metroTimeFactor() {
const el = document.getElementById('metroTimeFactor');
const v = parseFloat((el && el.value) || '15');
return Number.isFinite(v) && v > 0 ? Math.min(1000, Math.max(0.0001, v)) : 15;
}
function metroSetFactor(v) {
const el = document.getElementById('metroTimeFactor');
if (el) el.value = String(v);
return metroApplyTuning();
}
// Retune the push period / time factor without touching the ticked lines (the router keeps the selection
// when the body has no "lines" key).
function metroApplyTuning() {
return metroPost('/api/metro/lines', { pushMs: metroPushMs(), timeFactor: metroTimeFactor() });
}
function metroApplyLines() {
return metroPost('/api/metro/lines', { lines: Array.from(metroPicked || []), pushMs: metroPushMs(), timeFactor: metroTimeFactor() });
}
async function metroPreset(name) {
if (name === 'auto') {
// 交回「跟随当前地区」:不带 lines,只带 auto:true —— 服务端按当前 raid 的
// RaidConfig.LightRailLineIds 重选一次(新启 1-4 / 凌云 9-14+15)。
// 结果由 metroPost 的 refresh 回读,勾选框会跟着变。
return metroPost('/api/metro/lines', { auto: true, pushMs: metroPushMs(), timeFactor: metroTimeFactor() });
}
let ids = [];
if (name === 'main') ids = [1, 2, 3, 4];
else if (name === 'chongxiao') ids = [9, 10, 11, 12, 13, 14];
else if (name === 'cruise') ids = [15];
else if (name === 'all') ids = metroLines.map(l => l.id);
else if (name === 'none') ids = [];
metroPicked = new Set(ids);
const boxes = document.querySelectorAll('#metroLines input[data-metro-line]');
for (let i = 0; i < boxes.length; i++) boxes[i].checked = metroPicked.has(parseInt(boxes[i].getAttribute('data-metro-line'), 10));
await metroPost('/api/metro/lines', { lines: ids, pushMs: metroPushMs(), timeFactor: metroTimeFactor() });
}
// ---------------------------------------------------------------- round 58: metro run diagram
// The diagram is rebuilt by hand (its container is marked data-nol10n) because the arrows carry live
// numbers; it speaks both languages through metroT, so the DOM translation pass never touches it.
var METRO_MAP_LINES = [1, 2, 3, 4]; // the four services of the main city; the table below lists all 15
var METRO_MAP_MIRROR = { 2: true }; // the outer ring is the same track run the other way round, so its row
// prints the inner ring's stop order and its carriages travel right to left
var METRO_MAP_COLORS = {
1: { c: '#4dd0e1', cd: '#4dd0e166', g: 'rgba(77, 208, 225, .32)' },
2: { c: '#7e57c2', cd: '#7e57c266', g: 'rgba(126, 87, 194, .32)' },
3: { c: '#66bb6a', cd: '#66bb6a66', g: 'rgba(102, 187, 106, .32)' },
4: { c: '#ffa726', cd: '#ffa72666', g: 'rgba(255, 167, 38, .32)' },
};
var METRO_MAP_NAMES = {
1: { zh: '湾岸线 · 内环', en: 'Bayshore Line (Inner Ring)', dir: '→', subZh: '双向 · 往返', subEn: 'two-way · out-and-back' },
2: { zh: '湾岸线 · 外环', en: 'Bayshore Line (Outer Ring)', dir: '←', subZh: '双向 · 往返', subEn: 'two-way · out-and-back' },
3: { zh: '中央线', en: 'Central Line', dir: '→', subZh: '单向 · 环线', subEn: 'one-way · ring' },
4: { zh: '东城线', en: 'East Line', dir: '→', subZh: '单向 · 环线', subEn: 'one-way · ring' },
};
// The two Bayshore directions are one service, so only crossing to another line makes an interchange -
// that keeps 白帆湾 / 狂悖街区 / 晴海(湾岸)from being marked as one.
var METRO_MAP_GROUP = { 1: 'bayshore', 2: 'bayshore', 3: 'central', 4: 'east' };
var METRO_MAP_GROUP_ZH = { bayshore: '湾岸线', central: '中央线', east: '东城线' };
var METRO_MAP_GROUP_EN = { bayshore: 'Bayshore', central: 'Central', east: 'East' };
var METRO_MAP_DEPOT_EN = 'Ivory Cove Depot'; // this stop has no RailStationConfig row, so the server echoes
// the Chinese name in both name and chinese
var METRO_MAP_PITCH = 92; // px between two neighbouring stops
var METRO_MAP_STOP_WINDOW = 0.03; // 3% of a leg counts as standing at the platform (see metroMapUpdate)
var metroMapSig = '';
var metroMapGeom = {};
function metroMapColor(id) {
return METRO_MAP_COLORS[id] || { c: '#90a4ae', cd: '#90a4ae66', g: 'rgba(144, 164, 174, .32)' };
}
function metroMapEsc(s) {
return String(s === null || s === undefined ? '' : s).replace(/[&<>"]/g, function (m) {
return m === '&' ? '&' : m === '<' ? '<' : m === '>' ? '>' : '"';
});
}
function metroMapBuild() {
const box = document.getElementById('metroMap');
if (!box) return;
const drawn = METRO_MAP_LINES.map(function (id) {
return metroLines.find(function (l) { return l.id === id; });
}).filter(function (l) { return !!(l && Array.isArray(l.stations) && l.stations.length >= 2); });
const sig = (metroEn() ? 'en' : 'zh') + '|' + drawn.map(function (l) {
return l.id + ':' + l.stations.length + ':' + (l.enabled ? '1' : '0');
}).join(',');
if (sig === metroMapSig) return; // static layout: only the language or the line set rebuilds it
metroMapSig = sig;
metroMapGeom = {};
if (!drawn.length) {
box.innerHTML = '
' + metroMapEsc(metroT(
'还没有线路数据:服务端没读到 RailLineConfig,或者场景还没请求过地铁列表(进入游戏世界并等场景加载完)。',
'no line data yet: the server read no RailLineConfig, or the scene has not asked for the metro list (enter the game world and let the scene finish loading).')) + '
';
return;
}
// Interchange = one station id shared by two different services; a name that owns two different station
// ids (晴海 sits on the Bayshore line and again on the Central line) needs a suffix to tell them apart.
const idsByName = {}, groupsById = {};
drawn.forEach(function (line) {
const grp = METRO_MAP_GROUP[line.id] || ('line' + line.id);
line.stations.forEach(function (s) {
if (!s.stationId) return;
const key = s.chinese || s.name || '';
(idsByName[key] = idsByName[key] || {})[s.stationId] = 1;
(groupsById[s.stationId] = groupsById[s.stationId] || {})[grp] = 1;
});
});
const dupName = {};
Object.keys(idsByName).forEach(function (k) {
if (Object.keys(idsByName[k]).length > 1) dupName[k] = 1;
});
const rows = drawn.map(function (line) {
const id = line.id, col = metroMapColor(id);
const info = METRO_MAP_NAMES[id] || {
zh: line.name || ('线路 ' + id), en: line.name || ('Line ' + id), dir: '→', subZh: '', subEn: '',
};
const n = line.stations.length;
const mirror = !!METRO_MAP_MIRROR[id];
const stops = mirror ? line.stations.slice().reverse() : line.stations;
const arrivals = line.stations.map(function (s) {
return typeof s.arrivalSeconds === 'number' && isFinite(s.arrivalSeconds) ? s.arrivalSeconds : 0;
});
const total = typeof line.totalTravelSeconds === 'number' && line.totalTravelSeconds > 0
? line.totalTravelSeconds : arrivals[n - 1];
const geom = { n: n, mirror: mirror, arrivals: arrivals, total: total, dots: [], lbls: [], arrows: null, nodes: {} };
metroMapGeom[id] = geom;
const cells = stops.map(function (s, slot) {
const depot = !s.stationId;
const grp = METRO_MAP_GROUP[id] || ('line' + id);
const transfer = !depot && Object.keys(groupsById[s.stationId] || {}).length >= 2;
const dotCls = 'mm-dot' + (depot ? ' dp' : (transfer ? ' tr' : ''));
const lblCls = 'mm-lbl' + (depot ? ' dp' : (transfer ? ' tr' : ''));
const zh = s.chinese || s.name || '';
let label;
if (depot) {
label = metroEn() ? METRO_MAP_DEPOT_EN : (zh || '车库');
} else {
label = metroEn() ? (s.name || zh) : (zh || s.name);
if (dupName[zh]) label += ' · ' + (metroEn() ? (METRO_MAP_GROUP_EN[grp] || grp) : (METRO_MAP_GROUP_ZH[grp] || grp));
}
// "Into the run" always counts from the left end of the row, so the mirrored row flips its clock.
const arr = arrivals[mirror ? (n - 1 - slot) : slot];
let tip = label + ' · ' + metroT('行程', 'into the run') + ' '
+ metroClock(mirror ? Math.max(0, total - arr) : arr);
tip += ' · ' + (typeof s.dwellSeconds === 'number' && s.dwellSeconds > 0
? metroT('停站', 'dwells') + ' ' + Math.round(s.dwellSeconds) + 's'
: metroT('不停站', 'no dwell'));
if (transfer) tip += ' · ' + metroT('换乘站', 'interchange');
if (depot) tip += ' · ' + metroT('车库(客户端里没有这个停靠点的站台数据)', 'depot (the client has no platform row for this stop)');
const t = metroMapEsc(tip);
return '
'
+ metroT('换乘站', 'interchange') + ''
+ metroT('在站', 'at stop') + ''
+ metroT('即将到站', 'arriving') + ''
+ metroT('车库', 'depot') + ''
+ ' ' + metroT('▼ 一列车,数字是列车编号', '▼ a carriage, the number is its id')
+ '
';
box.innerHTML = legend + rows;
drawn.forEach(function (line) {
const geom = metroMapGeom[line.id];
const row = box.querySelector('[data-mm-rid="' + line.id + '"]');
if (!geom || !row) return;
geom.dots = Array.prototype.slice.call(row.querySelectorAll('.mm-dot'));
geom.lbls = Array.prototype.slice.call(row.querySelectorAll('.mm-lbl'));
geom.arrows = row.querySelector('.mm-trains');
geom.nodes = {}; // the row markup was just rebuilt, so every carriage node is gone
});
}
// Round 59: the carriage nodes are REUSED, not rebuilt.
//
// Until now every poll replaced the whole .mm-trains innerHTML. That kills any chance of a
// smooth ride: a brand new element starts at its final position, so a CSS transition can
// never run, and the diagram could only ever move in one-second jumps (blink-teleport-blink).
// Now one is created per carriage, kept in geom.nodes and only re-positioned, so the
// 1s linear transition on .mm-train has something to animate.
//
// The one case a transition must NOT cover is a jump: a ring service that wraps from the
// last stop back to the first would otherwise slide the whole row backwards. Anything that
// moves more than a quarter of the row in one poll is placed with the transition suppressed
// for that single frame (.mm-snap + forced reflow).
function metroMapUpdate(trains) {
METRO_MAP_LINES.forEach(function (id) {
const geom = metroMapGeom[id];
if (!geom || !geom.arrows) return;
geom.dots.forEach(function (d) { d.className = d.getAttribute('data-mm-cls') || 'mm-dot'; });
geom.lbls.forEach(function (l) { l.className = l.getAttribute('data-mm-cls') || 'mm-lbl'; });
const list = (trains || []).filter(function (t) { return t && t.lineId === id; });
const n = geom.n;
const seen = {};
list.forEach(function (t) {
const i = t.stopIndex - 1;
if (!(i >= 0 && i < n)) return;
let target = (typeof t.nextStopIndex === 'number' && t.nextStopIndex > 0) ? t.nextStopIndex - 1 : -1;
const secs = (typeof t.secondsToNext === 'number' && isFinite(t.secondsToNext)) ? t.secondsToNext : 0;
let leg = 0;
if (target >= 0) {
leg = geom.arrivals[target] - geom.arrivals[i];
if (leg <= 0.001) { leg = 0; target = -1; } // ring wrap-around or a stale row: keep it on the dot
}
let frac = 0;
if (leg > 0) {
frac = 1 - secs / leg;
if (!(frac >= 0)) frac = 0;
if (frac > 1) frac = 1;
// "standing" comes from the server, so pin the carriage to its dot whatever the clock says.
if (t.atStation) frac = 0;
}
// Most stops have no dwell at all in the config (0 s), far too short to catch on a 1 s poll, so a
// carriage within the last few percent of a leg already counts as standing at the platform.
const at = !!t.atStation || target < 0 || (leg > 0 && frac <= METRO_MAP_STOP_WINDOW);
const near = !at && target >= 0 && leg > 0 && frac >= 1 - METRO_MAP_STOP_WINDOW;
const slot = geom.mirror ? (n - 1 - i) : i;
const pos = geom.mirror ? (slot - frac) : (slot + frac);
const left = (n > 1 ? pos / (n - 1) : 0) * 100;
const fill = at ? ' at' : (near ? ' near' : '');
if (fill) {
// "at" marks the stop the carriage stands on, "arriving" the one it is closing in on.
const fs = at ? slot : (geom.mirror ? n - 1 - target : target);
if (fs >= 0 && fs < n) {
const d = geom.dots[fs], l = geom.lbls[fs];
if (d) d.className = (d.getAttribute('data-mm-cls') || 'mm-dot') + fill;
if (l) l.className = (l.getAttribute('data-mm-cls') || 'mm-lbl') + fill;
}
}
const num = (typeof t.id === 'number' ? t.id : (id * 100 + (t.index || 0)));
const key = 't' + num;
seen[key] = 1;
const tip = metroMapEsc(metroT('列车 ', 'carriage ') + num + ' · ' + (t.stopName || '—')
+ (at ? metroT('(在站)', ' (at the stop)') : '')
+ (target >= 0 ? ' → ' + (t.nextStopName || '—') + ' · ' + metroT('还有 ', 'in ') + metroClock(secs) : '')
+ (typeof t.metresToNext === 'number' && t.metresToNext > 0 ? ' · ' + Math.round(t.metresToNext) + 'm' : ''));
let node = geom.nodes[key];
if (!node) {
node = document.createElement('span');
node.className = 'mm-train mm-snap';
node.innerHTML = '';
node.style.left = left.toFixed(4) + '%';
geom.arrows.appendChild(node);
geom.nodes[key] = node;
node.setAttribute('data-mm-left', String(left));
void node.offsetWidth; // flush the first placement without a slide
node.className = 'mm-train';
} else {
const prev = parseFloat(node.getAttribute('data-mm-left'));
const jump = !isFinite(prev) || Math.abs(left - prev) > 25;
if (jump) node.classList.add('mm-snap');
node.style.left = left.toFixed(4) + '%';
if (jump) {
void node.offsetWidth; // commit the jump, then hand control back to the transition
node.classList.remove('mm-snap');
}
node.setAttribute('data-mm-left', String(left));
}
const arrow = node.firstElementChild, tid = node.lastElementChild;
if (arrow) arrow.setAttribute('title', tip);
if (tid) {
tid.textContent = num;
tid.className = 'mm-tid' + fill.replace(' near', '');
}
node.className = 'mm-train' + fill.replace(' near', '');
});
Object.keys(geom.nodes).forEach(function (key) {
if (seen[key]) return;
const node = geom.nodes[key];
if (node && node.parentNode) node.parentNode.removeChild(node);
delete geom.nodes[key];
});
});
}
function metroMapSync(trains) {
metroMapBuild();
metroMapUpdate(trains || []);
}
setTimeout(refreshMetro, 0);
setInterval(refreshMetro, 1000);
""";
}