796 lines
33 KiB
C#
796 lines
33 KiB
C#
using Ananta.SDK.Network;
|
||
using Ananta.SDK.Rpc;
|
||
using Ananta.SDK.Serialization;
|
||
using Ananta.Server.ClientData.Client4229938;
|
||
using Ananta.Server.Protocol.Client4229938;
|
||
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
||
|
||
namespace Ananta.Server.Handlers.Game;
|
||
|
||
/// <summary>
|
||
/// Light rail / metro scheduling.
|
||
///
|
||
/// The client owns the whole carriage simulation: MetroManager builds the carriages, drives them
|
||
/// along the baked MetroPath of a line and plays the gate/door animation. All the server has to do is
|
||
/// answer AskGetAllMetroInfos and then keep telling the client "train X of line Y has been running for
|
||
/// Z seconds" through SyncRunningMetroInfos - MetroClientInfo has no position field at all
|
||
/// (Id, LineId, ElapsedTime, IsFinalTrain). That is why this feature needs no client patch.
|
||
///
|
||
/// The schedule comes straight out of the client's own Rail*Config tables (RailLineConfig /
|
||
/// RailStopConfig / RailStationConfig), which the panel exposes as "where is the train, which stop is
|
||
/// it at, how long / how far until the next one".
|
||
///
|
||
/// Round 54 got the scheduling right but not the clock: see MetroDefaultTimeFactor for the round-55 fix.
|
||
/// </summary>
|
||
internal sealed partial class GameRouter
|
||
{
|
||
private const string MetroKey = "client4229938-metro";
|
||
|
||
/// <summary>
|
||
/// 玩家在轨道附近时的推送周期。250 ms(4 Hz)原本是全程恒定值,实测
|
||
/// `SyncRunningMetroInfos` 占了 S2C 总字节的 54%(11 分钟 1312 包 / 682 KB,每包 37 列车)。
|
||
/// 现在 1000 ms 是基准,远离轨道时再降到 <see cref="MetroIdlePushMs"/>。
|
||
/// ⛑️ 安全前提:列车位置由**客户端**自己沿烘焙好的 MetroPath 推进
|
||
/// (MetroManager 负责,见本文件顶部说明),服务端推的 ElapsedTime 只是对时锚点。
|
||
/// 所以降频不会让车"卡住",只影响纠偏粒度。若观感变差,把面板的推送周期调回 250 即可。
|
||
/// </summary>
|
||
private const int MetroDefaultPushMs = 1000;
|
||
|
||
/// <summary>玩家远离轨道时的推送周期。列车看不见,慢一点没有代价。</summary>
|
||
private const int MetroIdlePushMs = 3000;
|
||
|
||
/// <summary>
|
||
/// "看得见列车"的半径(世界单位 ≈ 米):玩家到**最近轨道段**的距离小于它就用
|
||
/// <see cref="MetroDefaultPushMs"/>,否则用 <see cref="MetroIdlePushMs"/>。
|
||
/// 按轨道段而不是车站算,因为相邻车站间距可达 1 km,只按车站判会把大半条线漏掉。
|
||
/// </summary>
|
||
private const double MetroNearTrackRadius = 300.0;
|
||
|
||
/// <summary>
|
||
/// ElapsedTime is a reading of the *server* clock, not a scene second count. The client converts it
|
||
/// with Metro.GetClientTimeFromServerTime(t) = t * INV_SERVER_TIME_FACTOR = t / 15, because the
|
||
/// in-game clock the server reports runs SERVER_TIME_FACTOR = 15 times faster than real time.
|
||
/// The baked MetroPath timeline - and therefore the RailStopConfig arrival seconds we schedule with -
|
||
/// is real seconds (the Bayshore ring takes 1123 s for roughly 12 m/s between its stations), so what
|
||
/// we push has to be the timetable second multiplied by 15.
|
||
/// Pushing the raw timetable second made every carriage sit around the beginning of its own line
|
||
/// crawling at 1/15 speed, which is exactly what round 55 was called in to fix. The factor is exposed
|
||
/// on the panel so the two other candidates (1 and 1/15) can be tested without a rebuild.
|
||
/// </summary>
|
||
private const double MetroDefaultTimeFactor = 15.0;
|
||
|
||
/// <summary>
|
||
/// 兜底线路:只在「当前 raid 没配 <c>LightRailLineIds</c>」或「还没进世界、拿不到 raid」时用。
|
||
/// 正常情况下推的是 <see cref="MetroLinesForRaid"/> 的结果。
|
||
///
|
||
/// 为什么不能全局固定这四条:它们是新启(raid 23300888)的环线。凌云/重霄(23300999)配的是
|
||
/// 9–14(+15 画舫),客户端为那六条线烘焙的走行路径是另一套(<c>LightRailPath_cx*_upline/downline</c>)。
|
||
/// 在凌云里推 1–4,客户端按新启的路径跑车 ⇒ 车不在凌云的铁轨上。
|
||
/// </summary>
|
||
private static readonly uint[] MetroDefaultLines = [1, 2, 3, 4];
|
||
|
||
/// <summary>
|
||
/// 当前 raid 该推哪几条线。取 <c>RaidConfig.LightRailLineIds</c>
|
||
/// (23300888 = 1–4,23300999 = 9–14 + 15,23301227 = 5–8,23301271 = 1–4),
|
||
/// 取不到就落回 <see cref="MetroDefaultLines"/>。
|
||
/// </summary>
|
||
private static uint[] MetroLinesForRaid(TcpSession session)
|
||
{
|
||
var raidId = GetStateIfExists(session)?.ActiveRaidId ?? 0u;
|
||
var lines = RailCatalog4229938.LinesForRaid(raidId);
|
||
return lines.Count > 0 ? lines.ToArray() : MetroDefaultLines;
|
||
}
|
||
|
||
/// <summary>
|
||
/// raid 变了就把自动选的线换掉。手动模式(<see cref="MetroSessionState.AutoRaidLines"/> = false,
|
||
/// 即面板显式下发过 lines)**一个字都不动**,免得和用户手选打架。
|
||
/// 返回 true = 这次真的换过了,调用方负责打日志。
|
||
/// </summary>
|
||
private static bool SyncMetroLinesToRaid(TcpSession session, MetroSessionState owner, bool force = false)
|
||
{
|
||
var raidId = GetStateIfExists(session)?.ActiveRaidId ?? 0u;
|
||
lock (owner.SyncRoot)
|
||
{
|
||
if (!owner.AutoRaidLines)
|
||
return false;
|
||
// 已经有线且 raid 没变 ⇒ 什么都不做(避免每次 tick 都重建集合)。
|
||
if (!force && owner.AutoRaidId == raidId && owner.Lines.Count > 0)
|
||
return false;
|
||
|
||
var lines = RailCatalog4229938.LinesForRaid(raidId);
|
||
owner.Lines = lines.Count > 0 ? [.. lines] : [.. MetroDefaultLines];
|
||
owner.AutoRaidId = raidId;
|
||
return true;
|
||
}
|
||
}
|
||
|
||
internal sealed class MetroSessionState
|
||
{
|
||
internal object SyncRoot { get; } = new();
|
||
|
||
/// <summary>Master switch. When false the loop idles without stopping, so nothing has to restart.</summary>
|
||
internal bool Enabled = true;
|
||
|
||
/// <summary>Push cadence in milliseconds.</summary>
|
||
internal int PushMs = MetroDefaultPushMs;
|
||
|
||
/// <summary>Multiplier applied to the timetable second before it goes out as ElapsedTime (see MetroDefaultTimeFactor).</summary>
|
||
internal double TimeFactor = MetroDefaultTimeFactor;
|
||
|
||
/// <summary>Lines currently being pushed.</summary>
|
||
internal HashSet<uint> Lines = [];
|
||
|
||
/// <summary>
|
||
/// 线路跟随当前 raid(<c>RaidConfig.LightRailLineIds</c>)自动切换。
|
||
/// 面板一旦显式下发 <c>lines</c> 就置 false —— 之后不再自动改,避免和用户手选打架;
|
||
/// 要切回自动,面板走 <c>auto: true</c>。
|
||
/// </summary>
|
||
internal bool AutoRaidLines = true;
|
||
|
||
/// <summary>上次自动选线时用的 raid。只在它变化时才重建 <see cref="Lines"/>。</summary>
|
||
internal uint AutoRaidId;
|
||
|
||
/// <summary>
|
||
/// The scene has actually asked for the metro list at least once. Pushes stay silent until then:
|
||
/// the client only has its MetroPaths after it has loaded the scene, so an early
|
||
/// SyncRunningMetroInfos could reach a half-built MetroManager.
|
||
/// </summary>
|
||
internal bool Requested;
|
||
|
||
/// <summary>
|
||
/// 上一拍玩家是否处在轨道附近。只用来在切换时打一条日志,避免在控制台刷屏。
|
||
/// 初值 true = 还没拿到玩家坐标时按"近处"处理,不牺牲画面。
|
||
/// </summary>
|
||
internal bool NearTrack = true;
|
||
|
||
/// <summary>Seconds of service folded away by a previous stop, so a restart does not jump the trains.</summary>
|
||
internal double OffsetSeconds;
|
||
|
||
internal DateTime StartedUtc = DateTime.UtcNow;
|
||
internal CancellationTokenSource? Cts;
|
||
internal Task? Loop;
|
||
internal long Ticks;
|
||
internal long Pushes;
|
||
internal long TrainsSent;
|
||
internal DateTime LastPushUtc = DateTime.MinValue;
|
||
internal string? LastError;
|
||
|
||
internal double ElapsedSeconds => OffsetSeconds + (DateTime.UtcNow - StartedUtc).TotalSeconds;
|
||
}
|
||
|
||
// ---------------------------------------------------------------- state
|
||
|
||
private static MetroSessionState? TryGetMetroState(TcpSession session)
|
||
=> session.Items.TryGetValue(MetroKey, out var raw) && raw is MetroSessionState state ? state : null;
|
||
|
||
private static MetroSessionState GetMetroState(TcpSession session)
|
||
{
|
||
lock (session.Items)
|
||
{
|
||
if (session.Items.TryGetValue(MetroKey, out var raw) && raw is MetroSessionState existing)
|
||
return existing;
|
||
|
||
var created = new MetroSessionState();
|
||
created.AutoRaidId = GetStateIfExists(session)?.ActiveRaidId ?? 0u;
|
||
foreach (var id in MetroLinesForRaid(session))
|
||
created.Lines.Add(id);
|
||
session.Items[MetroKey] = created;
|
||
return created;
|
||
}
|
||
}
|
||
|
||
private static void EnsureMetroLoop(TcpSession session, MetroSessionState owner)
|
||
{
|
||
lock (owner.SyncRoot)
|
||
{
|
||
if (owner.Loop is { IsCompleted: false })
|
||
return;
|
||
|
||
// Carry the service time over so a restart continues the same timetable instead of resetting.
|
||
owner.OffsetSeconds += (DateTime.UtcNow - owner.StartedUtc).TotalSeconds;
|
||
owner.StartedUtc = DateTime.UtcNow;
|
||
owner.Cts?.Dispose();
|
||
owner.Cts = new CancellationTokenSource();
|
||
owner.Loop = Task.Run(() => RunMetroLoopAsync(session, owner, owner.Cts.Token));
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- protocol
|
||
|
||
/// <summary>
|
||
/// The scene asks once per load, with no arguments, and expects the list of metros currently
|
||
/// running. Everything before round 54 answered with the typed-default empty list, which is why no
|
||
/// carriage ever appeared.
|
||
/// </summary>
|
||
[Handler(MethodId.AskGetAllMetroInfos)]
|
||
private async Task AskGetAllMetroInfos(Connection conn, UxRpcMessage msg)
|
||
{
|
||
var owner = GetMetroState(msg.Context.Session);
|
||
// 场景刚加载完,raid 可能是新的(切地区 / 传送)⇒ 先按新 raid 重选一次线路,
|
||
// 再回答"现在有哪些车",否则会把上一个 raid 的线一路推下去。
|
||
if (SyncMetroLinesToRaid(msg.Context.Session, owner))
|
||
conn.Log.Info($"[METRO] raid -> {owner.AutoRaidId}, lines=[{MetroLineSummary(owner)}]");
|
||
lock (owner.SyncRoot)
|
||
owner.Requested = true;
|
||
|
||
var infos = BuildMetroInfos(owner);
|
||
conn.Log.Info($"[METRO] AskGetAllMetroInfos -> {infos.Count} train(s) lines=[{MetroLineSummary(owner)}] raid={owner.AutoRaidId}");
|
||
await conn.ReturnAsync(msg, infos);
|
||
EnsureMetroLoop(msg.Context.Session, owner);
|
||
}
|
||
|
||
private static List<SceneMethods.MetroClientInfo> BuildMetroInfos(MetroSessionState owner)
|
||
{
|
||
List<uint> lines;
|
||
double elapsed;
|
||
double timeFactor;
|
||
lock (owner.SyncRoot)
|
||
{
|
||
lines = owner.Lines.OrderBy(id => id).ToList();
|
||
elapsed = owner.ElapsedSeconds;
|
||
timeFactor = owner.TimeFactor;
|
||
}
|
||
|
||
var result = new List<SceneMethods.MetroClientInfo>();
|
||
foreach (var lineId in lines)
|
||
{
|
||
if (!RailCatalog4229938.TryGetLine(lineId, out var line))
|
||
continue;
|
||
|
||
// Line 2 is the same track as line 1 but counter-clockwise, so both are pushed and the
|
||
// client draws the two directions of the ring.
|
||
var count = line.EffectiveTrainCount;
|
||
for (var i = 0; i < count; i++)
|
||
{
|
||
result.Add(new SceneMethods.MetroClientInfo
|
||
{
|
||
Id = (int)((lineId * 100) + (uint)i),
|
||
LineId = lineId,
|
||
ElapsedTime = (float)(RailCatalog4229938.ElapsedForTrain(line, i, elapsed) * timeFactor),
|
||
IsFinalTrain = false,
|
||
});
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
// ------------------------------------------------- 轨道邻近度(决定推送频率)
|
||
|
||
/// <summary>
|
||
/// 所有线路的轨道段(相邻车站连线,环线额外补一条首尾段),只按到这些线段的距离判断
|
||
/// 玩家是否看得见列车。车站只有 34 个、线路 4 条,缓存一次即可。
|
||
/// </summary>
|
||
private static readonly Lazy<List<(RailStation4229938 A, RailStation4229938 B)>> MetroTrackSegments =
|
||
new(BuildMetroTrackSegments);
|
||
|
||
private static List<(RailStation4229938 A, RailStation4229938 B)> BuildMetroTrackSegments()
|
||
{
|
||
var segments = new List<(RailStation4229938, RailStation4229938)>();
|
||
foreach (var line in RailCatalog4229938.AllLines)
|
||
{
|
||
if (!RailCatalog4229938.TryGetRoute(line.Id, out var route))
|
||
continue;
|
||
|
||
var pts = new List<RailStation4229938>();
|
||
foreach (var stop in route.Stops)
|
||
{
|
||
if (RailCatalog4229938.TryGetStation(stop.StationId, out var station))
|
||
pts.Add(station);
|
||
}
|
||
if (pts.Count < 2)
|
||
continue;
|
||
|
||
for (var i = 0; i + 1 < pts.Count; i++)
|
||
segments.Add((pts[i], pts[i + 1]));
|
||
if (line.RingLine && pts.Count >= 3)
|
||
segments.Add((pts[^1], pts[0]));
|
||
}
|
||
return segments;
|
||
}
|
||
|
||
/// <summary>点到轨道折线的最近距离(米)。段数为几十,每拍算一次开销可忽略。</summary>
|
||
private static double DistanceToMetroTrack(double px, double py, double pz)
|
||
{
|
||
var best = double.MaxValue;
|
||
foreach (var (a, b) in MetroTrackSegments.Value)
|
||
{
|
||
var abx = b.X - a.X;
|
||
var aby = b.Y - a.Y;
|
||
var abz = b.Z - a.Z;
|
||
var apx = px - a.X;
|
||
var apy = py - a.Y;
|
||
var apz = pz - a.Z;
|
||
|
||
var denom = abx * abx + aby * aby + abz * abz;
|
||
var t = denom > 0 ? (apx * abx + apy * aby + apz * abz) / denom : 0.0;
|
||
if (t < 0) t = 0; else if (t > 1) t = 1;
|
||
|
||
var dx = apx - abx * t;
|
||
var dy = apy - aby * t;
|
||
var dz = apz - abz * t;
|
||
var d = Math.Sqrt(dx * dx + dy * dy + dz * dz);
|
||
if (d < best)
|
||
best = d;
|
||
}
|
||
return best;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 玩家是否看得见列车。拿不到坐标时返回 true(按近处处理),宁可多推也不牺牲画面。
|
||
/// </summary>
|
||
private static bool IsPlayerNearMetroTrack(TcpSession session, out double metres)
|
||
{
|
||
metres = -1;
|
||
// 轨道表没加载出来就无从判断,按近处处理(保持原频率),不要悄悄把地铁停掉。
|
||
if (MetroTrackSegments.Value.Count == 0)
|
||
return true;
|
||
|
||
var state = GetStateIfExists(session);
|
||
if (state is null || !state.HasLastReportedPlayerTransform)
|
||
return true;
|
||
|
||
double px, py, pz;
|
||
lock (state.SyncRoot)
|
||
{
|
||
px = state.LastReportedPlayerPosition.X;
|
||
py = state.LastReportedPlayerPosition.Y;
|
||
pz = state.LastReportedPlayerPosition.Z;
|
||
}
|
||
|
||
metres = DistanceToMetroTrack(px, py, pz);
|
||
return metres <= MetroNearTrackRadius;
|
||
}
|
||
|
||
private static string MetroLineSummary(MetroSessionState owner)
|
||
{
|
||
lock (owner.SyncRoot)
|
||
return string.Join(",", owner.Lines.OrderBy(id => id));
|
||
}
|
||
|
||
// ---------------------------------------------------------------- the loop
|
||
|
||
private static async Task RunMetroLoopAsync(TcpSession session, MetroSessionState owner, CancellationToken token)
|
||
{
|
||
session.Log.Info("[METRO] loop started");
|
||
while (!token.IsCancellationRequested)
|
||
{
|
||
try
|
||
{
|
||
// 客户端挂起期间只睡不推 —— 见 GameRouter.IsBackgroundPushPaused 的说明。
|
||
if (!IsBackgroundPushPaused(session))
|
||
await TickMetroAsync(session, owner, token);
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
break;
|
||
}
|
||
catch (ObjectDisposedException)
|
||
{
|
||
session.Log.Info("[METRO] loop stopped: the session was disposed");
|
||
break;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
lock (owner.SyncRoot)
|
||
owner.LastError = ex.Message;
|
||
session.Log.Warn($"[METRO] tick failed: {ex.Message}");
|
||
}
|
||
|
||
int period;
|
||
lock (owner.SyncRoot)
|
||
period = owner.PushMs;
|
||
|
||
// 远离轨道时降频。列车位置由客户端自己推进,ElapsedTime 只是对时锚点,
|
||
// 所以慢推不会让车卡住;玩家一旦靠近,下一拍立刻恢复常速并把时间校准回来。
|
||
var nearTrack = IsPlayerNearMetroTrack(session, out var metres);
|
||
bool modeChanged;
|
||
lock (owner.SyncRoot)
|
||
{
|
||
modeChanged = owner.NearTrack != nearTrack;
|
||
owner.NearTrack = nearTrack;
|
||
}
|
||
if (modeChanged)
|
||
session.Log.Info($"[METRO] cadence -> {(nearTrack ? "normal" : "idle")} " +
|
||
$"({period}ms / {MetroIdlePushMs}ms, nearest track " +
|
||
(metres < 0 ? "unknown" : $"{metres:F0}m") + ")");
|
||
|
||
var sleep = nearTrack ? period : Math.Max(period, MetroIdlePushMs);
|
||
|
||
try
|
||
{
|
||
await Task.Delay(Math.Clamp(sleep, 50, 10000), token);
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
break;
|
||
}
|
||
}
|
||
session.Log.Info("[METRO] loop exit");
|
||
}
|
||
|
||
private static async Task TickMetroAsync(TcpSession session, MetroSessionState owner, CancellationToken token)
|
||
{
|
||
// raid 也可能在**没有**重新 Ask 的情况下变掉(GM 传送 / 跨地区切场景不打招呼),
|
||
// 每拍顺手核一次。AutoRaidLines=false(面板手选过)时这里是 no-op。
|
||
if (SyncMetroLinesToRaid(session, owner))
|
||
session.Log.Info($"[METRO] raid -> {owner.AutoRaidId}, lines=[{MetroLineSummary(owner)}]");
|
||
|
||
lock (owner.SyncRoot)
|
||
{
|
||
owner.Ticks++;
|
||
if (!owner.Enabled || !owner.Requested)
|
||
return;
|
||
}
|
||
|
||
var infos = BuildMetroInfos(owner);
|
||
if (infos.Count == 0)
|
||
return;
|
||
|
||
await session.NotifyAsync(MethodId.SyncRunningMetroInfos, UxSerializer.Serialize(infos), token);
|
||
|
||
long pushes;
|
||
lock (owner.SyncRoot)
|
||
{
|
||
owner.Pushes++;
|
||
owner.TrainsSent += infos.Count;
|
||
owner.LastPushUtc = DateTime.UtcNow;
|
||
pushes = owner.Pushes;
|
||
}
|
||
|
||
if (pushes <= 3 || pushes % 400 == 0)
|
||
session.Log.Info($"[METRO] push #{pushes} trains={infos.Count} lines=[{MetroLineSummary(owner)}]");
|
||
}
|
||
|
||
// ---------------------------------------------------------------- panel API
|
||
|
||
/// <summary>
|
||
/// Read-only snapshot for the web panel: the whole catalog plus, for every running train, the stop
|
||
/// it is calling at, the next stop, and the remaining time / straight-line distance to it.
|
||
/// </summary>
|
||
internal static object MetroStatus(TcpSession? session)
|
||
{
|
||
if (session is null)
|
||
return new { ok = false, online = false, error = "session offline" };
|
||
|
||
var owner = TryGetMetroState(session);
|
||
if (owner is null)
|
||
{
|
||
return new
|
||
{
|
||
ok = true,
|
||
online = true,
|
||
started = false,
|
||
enabled = false,
|
||
running = false,
|
||
requested = false,
|
||
autoRaidLines = true,
|
||
raidId = 0u,
|
||
lines = MetroCatalogView([]),
|
||
trains = Array.Empty<object>(),
|
||
message = "the scene has not requested the metro list yet",
|
||
};
|
||
}
|
||
|
||
bool enabled;
|
||
int pushMs;
|
||
double timeFactor;
|
||
bool requested;
|
||
bool autoRaidLines;
|
||
uint raidId;
|
||
long ticks, pushes, trainsSent;
|
||
string? lastError;
|
||
double elapsed;
|
||
HashSet<uint> lines;
|
||
bool running;
|
||
lock (owner.SyncRoot)
|
||
{
|
||
enabled = owner.Enabled;
|
||
pushMs = owner.PushMs;
|
||
timeFactor = owner.TimeFactor;
|
||
requested = owner.Requested;
|
||
autoRaidLines = owner.AutoRaidLines;
|
||
raidId = owner.AutoRaidId;
|
||
ticks = owner.Ticks;
|
||
pushes = owner.Pushes;
|
||
trainsSent = owner.TrainsSent;
|
||
lastError = owner.LastError;
|
||
elapsed = owner.ElapsedSeconds;
|
||
lines = [.. owner.Lines];
|
||
running = owner.Loop is { IsCompleted: false };
|
||
}
|
||
|
||
var trains = MetroTrainView(owner, out var stoppedSeconds);
|
||
return new
|
||
{
|
||
ok = true,
|
||
online = true,
|
||
started = true,
|
||
enabled,
|
||
running,
|
||
requested,
|
||
pushMs,
|
||
autoRaidLines,
|
||
raidId,
|
||
timeFactor = Math.Round(timeFactor, 4),
|
||
ticks,
|
||
pushes,
|
||
trainsSent,
|
||
lastPushUtc = owner.LastPushUtc == DateTime.MinValue ? null : owner.LastPushUtc.ToString("HH:mm:ss"),
|
||
lastError,
|
||
serviceSeconds = Math.Round(elapsed, 1),
|
||
lines = MetroCatalogView(lines),
|
||
trains,
|
||
stoppedSeconds,
|
||
};
|
||
}
|
||
|
||
private static object[] MetroCatalogView(HashSet<uint> enabled)
|
||
{
|
||
var result = new List<object>();
|
||
foreach (var line in RailCatalog4229938.AllLines)
|
||
{
|
||
var stops = RailCatalog4229938.TryGetRoute(line.Id, out var route)
|
||
? route.Stops
|
||
: Array.Empty<RailStop4229938>();
|
||
var stationList = stops
|
||
.Select(stop =>
|
||
{
|
||
var name = RailCatalog4229938.TryGetStation(stop.StationId, out var station)
|
||
? station.DisplayName(false)
|
||
: stop.StopName;
|
||
return new
|
||
{
|
||
stopId = stop.Id,
|
||
name,
|
||
chinese = RailCatalog4229938.TryGetStation(stop.StationId, out var Chinese)
|
||
? Chinese.DisplayName(true)
|
||
: stop.StopName,
|
||
arrivalSeconds = Math.Round(stop.ArrivalTime, 1),
|
||
dwellSeconds = Math.Round(stop.InsertTime, 1),
|
||
defaultExit = stop.DefaultExitStop,
|
||
stationId = stop.StationId,
|
||
};
|
||
})
|
||
.ToArray();
|
||
|
||
result.Add(new
|
||
{
|
||
id = line.Id,
|
||
name = line.LineName,
|
||
prefab = line.BlobName,
|
||
ring = line.RingLine,
|
||
isTestPath = line.IsTestPath,
|
||
trainTemplateId = line.TrainTemplateId,
|
||
totalTravelSeconds = Math.Round(line.TotalTravelDuration, 1),
|
||
departureIntervalSeconds = line.DepartureInterval,
|
||
maxTrainAmount = line.MaxTrainAmount,
|
||
trainCount = line.EffectiveTrainCount,
|
||
firstTrain = $"{line.FirstTrainHour:D2}:{line.FirstTrainMinute:D2}",
|
||
lastTrain = $"{line.LastTrainHour:D2}:{line.LastTrainMinute:D2}",
|
||
defaultEnabled = MetroDefaultLines.Contains(line.Id),
|
||
enabled = enabled.Contains(line.Id),
|
||
stopCount = stops.Count,
|
||
stations = stationList,
|
||
});
|
||
}
|
||
return result.ToArray();
|
||
}
|
||
|
||
private static object[] MetroTrainView(MetroSessionState owner, out int stoppedSeconds)
|
||
{
|
||
List<uint> lines;
|
||
double elapsed;
|
||
lock (owner.SyncRoot)
|
||
{
|
||
lines = owner.Lines.OrderBy(id => id).ToList();
|
||
elapsed = owner.ElapsedSeconds;
|
||
}
|
||
|
||
var result = new List<object>();
|
||
stoppedSeconds = 0;
|
||
foreach (var lineId in lines)
|
||
{
|
||
if (!RailCatalog4229938.TryGetLine(lineId, out var line) || !RailCatalog4229938.TryGetRoute(lineId, out var route))
|
||
continue;
|
||
|
||
var count = line.EffectiveTrainCount;
|
||
for (var i = 0; i < count; i++)
|
||
{
|
||
var trainElapsed = RailCatalog4229938.ElapsedForTrain(line, i, elapsed);
|
||
var progress = RailCatalog4229938.ProgressAt(line, trainElapsed);
|
||
if (progress is null)
|
||
continue;
|
||
|
||
if (progress.AtStation) stoppedSeconds++;
|
||
|
||
var position = MetroInterpolate(progress);
|
||
result.Add(new
|
||
{
|
||
id = (int)((lineId * 100) + (uint)i),
|
||
index = i,
|
||
lineId,
|
||
lineName = line.LineName,
|
||
prefab = line.BlobName,
|
||
elapsedSeconds = Math.Round(trainElapsed, 1),
|
||
atStation = progress.AtStation,
|
||
// Index along the line (1-based for humans), which is what the timetable uses.
|
||
stopIndex = progress.StopIndex + 1,
|
||
stopName = StopName(progress.CurrentStop),
|
||
stopStation = progress.CurrentStation?.EnglishName ?? string.Empty,
|
||
stopStationChinese = progress.CurrentStation?.ChineseName ?? string.Empty,
|
||
nextStopIndex = progress.NextStopIndex >= 0 ? progress.NextStopIndex + 1 : 0,
|
||
nextStopName = progress.NextStop is null ? string.Empty : StopName(progress.NextStop),
|
||
nextStation = progress.NextStation?.EnglishName ?? string.Empty,
|
||
nextStationChinese = progress.NextStation?.ChineseName ?? string.Empty,
|
||
secondsToNext = Math.Round(progress.SecondsToNext, 1),
|
||
metresToNext = Math.Round(progress.MetresToNext, 1),
|
||
isLastStop = progress.NextStop is null,
|
||
x = position?.X,
|
||
y = position?.Y,
|
||
z = position?.Z,
|
||
});
|
||
}
|
||
}
|
||
return result.ToArray();
|
||
}
|
||
|
||
private static string StopName(RailStop4229938 stop)
|
||
{
|
||
if (RailCatalog4229938.TryGetStation(stop.StationId, out var station))
|
||
return station.DisplayName(false);
|
||
return string.IsNullOrWhiteSpace(stop.StopName) ? $"Stop {stop.Id}" : stop.StopName;
|
||
}
|
||
|
||
private readonly record struct MetroPoint(double X, double Y, double Z);
|
||
|
||
/// <summary>
|
||
/// Where the train is between two platforms. The stop tables only carry arrival times, so the fill
|
||
/// is a straight line between the two station anchors scaled by the remaining fraction of the leg -
|
||
/// good enough for a status readout, and it is the same information the client itself animates from.
|
||
/// Null when the line has no station anchors (the depot ends of the ring, or the test paths).
|
||
/// </summary>
|
||
private static MetroPoint? MetroInterpolate(RailTrainProgress progress)
|
||
{
|
||
var current = progress.CurrentStation;
|
||
if (current is null)
|
||
return null;
|
||
|
||
var next = progress.NextStation;
|
||
if (next is null || progress.AtStation || progress.NextStop is null)
|
||
return new MetroPoint(Math.Round(current.X, 1), Math.Round(current.Y, 1), Math.Round(current.Z, 1));
|
||
|
||
var leg = progress.NextStop.ArrivalTime - progress.CurrentStop.ArrivalTime;
|
||
if (leg <= 0.001)
|
||
return new MetroPoint(Math.Round(current.X, 1), Math.Round(current.Y, 1), Math.Round(current.Z, 1));
|
||
|
||
var fraction = Math.Clamp(progress.SecondsToNext / leg, 0, 1);
|
||
return new MetroPoint(
|
||
Math.Round(next.X + ((current.X - next.X) * fraction), 1),
|
||
Math.Round(next.Y + ((current.Y - next.Y) * fraction), 1),
|
||
Math.Round(next.Z + ((current.Z - next.Z) * fraction), 1));
|
||
}
|
||
|
||
internal static object SetMetroEnabled(TcpSession? session, bool enabled)
|
||
{
|
||
if (session is null)
|
||
return new { ok = false, error = "session offline" };
|
||
|
||
var owner = GetMetroState(session);
|
||
lock (owner.SyncRoot)
|
||
owner.Enabled = enabled;
|
||
|
||
session.Log.Info($"[METRO] master switch -> {(enabled ? "on" : "off")}");
|
||
EnsureMetroLoop(session, owner);
|
||
return MetroStatus(session);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Panel update of the dispatcher. <paramref name="lines"/> null keeps the current selection (that is
|
||
/// how the panel changes only the push period or the time factor), an empty list turns every line off.
|
||
/// </summary>
|
||
internal static object ConfigureMetroLines(TcpSession? session, IReadOnlyList<uint>? lines, int? pushMs, double? timeFactor, bool? auto = null)
|
||
{
|
||
if (session is null)
|
||
return new { ok = false, error = "session offline" };
|
||
|
||
var owner = GetMetroState(session);
|
||
var known = new List<uint>();
|
||
var unknown = new List<uint>();
|
||
var seen = new HashSet<uint>();
|
||
foreach (var id in lines ?? [])
|
||
{
|
||
if (!seen.Add(id))
|
||
continue;
|
||
if (RailCatalog4229938.TryGetLine(id, out _))
|
||
known.Add(id);
|
||
else
|
||
unknown.Add(id);
|
||
}
|
||
|
||
string lineLog;
|
||
|
||
// 先把「按 raid 重选」做完再进 lock:SyncMetroLinesToRaid 自己会锁 SyncRoot 并去读
|
||
// session.Items,嵌套进来会和 tick 线程(先 session.Items 后 SyncRoot)撞成 ABBA。
|
||
if (auto == true)
|
||
{
|
||
lock (owner.SyncRoot)
|
||
owner.AutoRaidLines = true;
|
||
SyncMetroLinesToRaid(session, owner, force: true);
|
||
}
|
||
|
||
lock (owner.SyncRoot)
|
||
{
|
||
if (auto == true)
|
||
lineLog = $"auto -> [{string.Join(",", owner.Lines.OrderBy(x => x))}] (raid {owner.AutoRaidId})";
|
||
else if (lines is not null)
|
||
{
|
||
// 显式下发过 lines ⇒ 转手动,之后 raid 变了也不再自动改,免得和用户手选打架。
|
||
owner.AutoRaidLines = false;
|
||
owner.Lines = [.. known];
|
||
lineLog = $"-> [{string.Join(",", known)}] (manual, raid-follow off)";
|
||
}
|
||
else
|
||
{
|
||
lineLog = $"kept [{string.Join(",", owner.Lines.OrderBy(x => x))}]";
|
||
}
|
||
|
||
if (pushMs is > 0)
|
||
owner.PushMs = Math.Clamp(pushMs.Value, 50, 2000);
|
||
if (timeFactor is > 0)
|
||
owner.TimeFactor = Math.Clamp(timeFactor.Value, 0.0001, 1000);
|
||
}
|
||
session.Log.Info($"[METRO] lines {lineLog}" +
|
||
(unknown.Count > 0 ? $" (unknown ignored: {string.Join(",", unknown)})" : string.Empty) +
|
||
$" push={owner.PushMs}ms timeFactor={owner.TimeFactor:0.####}");
|
||
EnsureMetroLoop(session, owner);
|
||
return MetroStatus(session);
|
||
}
|
||
|
||
internal static object StopMetro(TcpSession? session)
|
||
{
|
||
if (session is null)
|
||
return new { ok = false, error = "session offline" };
|
||
|
||
var owner = TryGetMetroState(session);
|
||
if (owner is null)
|
||
return new { ok = true, stopped = false, message = "not running" };
|
||
|
||
CancellationTokenSource? cts;
|
||
lock (owner.SyncRoot)
|
||
{
|
||
owner.OffsetSeconds += (DateTime.UtcNow - owner.StartedUtc).TotalSeconds;
|
||
owner.StartedUtc = DateTime.UtcNow;
|
||
owner.Requested = false;
|
||
cts = owner.Cts;
|
||
owner.Cts = null;
|
||
owner.Loop = null;
|
||
}
|
||
cts?.Cancel();
|
||
cts?.Dispose();
|
||
|
||
session.Log.Info("[METRO] stopped by the panel (the client keeps the carriages it already has)");
|
||
return new { ok = true, stopped = true, status = MetroStatus(session) };
|
||
}
|
||
|
||
internal static List<SceneMethods.ClientTrafficIntersectionInitInfo4229938> BuildAetherIntersectionInitList()
|
||
{
|
||
var result = new List<SceneMethods.ClientTrafficIntersectionInitInfo4229938>();
|
||
if (!Ananta.Server.Gameplay.Traffic.CityTrafficEngine.Instance.AmbientIntersectionInit)
|
||
return result;
|
||
foreach (var zonePeriod in Ananta.Server.Gameplay.Traffic.CityTrafficEngine.Instance
|
||
.GetAetherIntersectionZonePeriods(DateTimeOffset.UtcNow.ToUnixTimeSeconds()))
|
||
{
|
||
result.Add(new SceneMethods.ClientTrafficIntersectionInitInfo4229938
|
||
{
|
||
InstanceId = zonePeriod.InstanceId,
|
||
ZoneIndex = zonePeriod.ZoneIndex,
|
||
CurrentState = 0,
|
||
CurrentPeriodIndex = zonePeriod.CurrentPeriod,
|
||
NextPeriodIndex = zonePeriod.NextPeriod,
|
||
RailPeriodIndex = 0,
|
||
});
|
||
}
|
||
return result;
|
||
}
|
||
}
|