Files

796 lines
33 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}