Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.IntersectionSignals.cs

294 lines
12 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.Serialization;
using Ananta.Server.ClientData.Client4229938;
using Ananta.Server.Configuration;
using Ananta.Server.Gameplay;
using Ananta.Server.Protocol.Client4229938;
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
namespace Ananta.Server.Handlers.Game;
/// <summary>
/// ★★ 路口相位 / 红绿灯(2026-10-02 从捐赠端 PrivateServerPor 移植)—— **让灯柱真的亮起来**。
///
/// 背景(详见 <c>modules/红绿灯与路口相位.md</c>):
/// 主线以前 init 的 <c>Intersections</c> 恒为 <c>[]</c>、运行期相位包
/// <c>SyncAetherAIIntersectionUpdateDa</c>(68819947) **一次都没发过** ⇒ 客户端
/// <c>AetherIntersectionManager</c> 建不出 <c>IntersectionDataEntity</c> ⇒ 不
/// <c>RegisterTrafficLight</c> ⇒ 灯柱从来没被接管。<c>trafficSignalEnabled</c> 那套
/// 只是服务端自己压车速,跟灯柱没关系。
///
/// 捐赠端的三条腿(我们照搬):
/// ① init 带真路口表 → <c>GameAdminBridge</c> 的 <c>-donor</c> 变体(另见该文件)
/// ② 登录后全量推一次 → <see cref="PushIntersectionUpdatesAsync"/>
/// ③ 之后周期切档 → <see cref="TickIntersectionSignalsAsync"/>(本文件的后台循环)
///
/// ⛑⛑ 循环**不依赖车流开关**:用户 2026-10-02 19:38 把 <c>trafficEnabled</c> 关了,
/// 灯仍然要亮 ⇒ 这里自己起 loop,不挂在 TrafficLoop 里。
/// </summary>
internal sealed partial class GameRouter
{
private const string IntersectionSignalsKey = "drmk.intersection.signals";
private sealed class IntersectionSignalState
{
/// <summary>每个路口当前处在第几相位(下标与 <c>IntersectionTable.All</c> 对齐)。</summary>
internal byte[] PeriodIndex = [];
/// <summary>每个路口下一次切档的墙钟秒(<c>TickCount64/1000</c>);0 = 还没排。</summary>
internal double[] NextSwitchAt = [];
/// <summary>这个路口是否已经推过至少一次(玩家走进半径后要补推)。</summary>
internal bool[] Pushed = [];
internal CancellationTokenSource? Cts;
internal Task? Loop;
}
private static IntersectionSignalState SignalsOf(TcpSession session)
{
lock (session.Items)
{
if (session.Items.TryGetValue(IntersectionSignalsKey, out var raw)
&& raw is IntersectionSignalState s)
return s;
var count = IntersectionTable.Count;
var state = new IntersectionSignalState
{
PeriodIndex = new byte[count],
NextSwitchAt = new double[count],
Pushed = new bool[count],
};
var all = IntersectionTable.All;
for (var i = 0; i < count; i++)
state.PeriodIndex[i] = all[i].CurrentPeriodIndex;
session.Items[IntersectionSignalsKey] = state;
return state;
}
}
private static bool UseZoneIndex()
{
var src = PrivateServerConfigStore.Current.Gameplay.Aether.IntersectionIndexSource;
return !string.Equals(src?.Trim(), "listIndex", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// 登录后给每个路口推一次 68819947(初始相位)。
/// ★ 这一步是「灯从灭到亮」的开关 —— 只发 init 不发它,客户端只知道有路口、不知道当前相位。
/// </summary>
internal static async Task<int> PushIntersectionUpdatesAsync(TcpSession session, CancellationToken token)
{
var cfg = PrivateServerConfigStore.Current.Gameplay.Aether;
if (!cfg.IntersectionUpdateEnabled)
return 0;
var all = IntersectionTable.All;
if (all.Count == 0)
return 0;
var state = SignalsOf(session);
var useZone = UseZoneIndex();
float px = 0f, pz = 0f;
var w = GetStateIfExists(session);
if (w is not null)
{
Vec3 player;
lock (w.SyncRoot)
player = w.LastReportedPlayerPosition;
px = player.X;
pz = player.Z;
}
var targets = IntersectionTable.OrderByDistance(px, pz, cfg.IntersectionRadiusMeters, cfg.IntersectionMaxCount);
var sent = 0;
var nowSec = Environment.TickCount64 / 1000.0;
var period = Math.Clamp(cfg.IntersectionPeriodSeconds, 1.0, 120.0);
foreach (var i in targets)
{
// ★ 幂等:只推**还没推过**的路口。这样多处调用(Vehicle init / static-NPC 批次)
// 都安全,也天然支持「玩家后来走进半径 ⇒ 补推新路口」。
if (state.Pushed[i])
continue;
var it = all[i];
var cur = state.PeriodIndex[i];
var count = Math.Max(1, it.PeriodCount);
await session.NotifyAsync(MethodId.SyncAetherAIIntersectionUpdateDa, UxSerializer.Serialize(
new GameMethods.SyncAetherAIIntersectionUpdateData4229938
{
data = new GameMethods.ClientTrafficIntersectionPeriodUpdateInfo4229938
{
IntersectionIndex = useZone ? (ulong)it.ZoneIndex : (ulong)i,
CurrentState = GameMethods.MassTrafficIntersectionState4229938.Start,
CurrentPeriodIndex = cur,
NextPeriodIndex = (byte)((cur + 1) % count),
RailPeriodIndex = it.RailPeriodIndex,
},
}), token);
state.Pushed[i] = true;
// ★ 错开各路口的切档时刻:否则 171 个包每周期同一瞬间全发(尖峰)。
state.NextSwitchAt[i] = nowSec + period * (0.5 + 0.5 * ((i * 37 % 100) / 100.0));
sent++;
// 规则 225/238:大批量 S2C 必须后台分批,不能同步连发。
if (sent % 16 == 0)
await Task.Delay(8, token).ConfigureAwait(false);
}
session.Log.Info($"[SIGNAL] 路口相位初始推送 {sent}/{all.Count} 个" +
$"(68819947,index 取自 {(useZone ? "zoneIndex" : "listIndex")},周期 {period:F0}s)");
return sent;
}
/// <summary>启动(或确认已在跑)路口相位循环。与车流开关无关。</summary>
internal static void StartIntersectionSignals(TcpSession session)
{
var cfg = PrivateServerConfigStore.Current.Gameplay.Aether;
if (!cfg.IntersectionUpdateEnabled)
return;
if (IntersectionTable.Count == 0)
{
session.Log.Warn("[SIGNAL] 路口表为空 —— 检查 paths.intersections(灯不会亮)");
return;
}
var state = SignalsOf(session);
lock (session.Items)
{
if (state.Loop is { IsCompleted: false })
return;
state.Cts?.Dispose();
state.Cts = new CancellationTokenSource();
state.Loop = Task.Run(() => RunIntersectionSignalLoopAsync(session, state.Cts.Token));
}
}
/// <summary>断开 / 切场景时停掉循环。</summary>
internal static void StopIntersectionSignals(TcpSession session)
{
lock (session.Items)
{
if (!session.Items.TryGetValue(IntersectionSignalsKey, out var raw)
|| raw is not IntersectionSignalState state)
return;
state.Cts?.Cancel();
state.Cts?.Dispose();
state.Cts = null;
}
}
private static async Task RunIntersectionSignalLoopAsync(TcpSession session, CancellationToken token)
{
var period = Math.Clamp(
PrivateServerConfigStore.Current.Gameplay.Aether.IntersectionPeriodSeconds, 1.0, 120.0);
var state = SignalsOf(session);
var useZone = UseZoneIndex();
// 会话销毁后(切角色 / 断线)循环要自己退 —— 主线没有统一的 session 级 CancellationToken
// (`StopTraffic` 同样没人调),所以这里用「连续取不到 world state」当退出信号。
var miss = 0;
session.Log.Info($"[SIGNAL] 相位循环启动 路口={IntersectionTable.Count} 周期={period:F0}s");
try
{
while (!token.IsCancellationRequested)
{
// 每 2 秒扫一次:既检查「该不该切档」,也给「玩家新走近的路口」补推。
await Task.Delay(2000, token).ConfigureAwait(false);
if (IsBackgroundPushPaused(session))
continue;
var cfg = PrivateServerConfigStore.Current.Gameplay.Aether;
if (!cfg.IntersectionUpdateEnabled)
continue;
var w = GetStateIfExists(session);
if (w is null)
{
if (++miss >= 5)
{
session.Log.Info("[SIGNAL] 相位循环退出(会话已销毁)");
return;
}
continue;
}
miss = 0;
if (!w.Ready || !w.HasLastReportedPlayerTransform)
continue;
Vec3 player;
lock (w.SyncRoot)
player = w.LastReportedPlayerPosition;
var all = IntersectionTable.All;
var targets = IntersectionTable.OrderByDistance(
player.X, player.Z, cfg.IntersectionRadiusMeters, cfg.IntersectionMaxCount);
var nowSec = Environment.TickCount64 / 1000.0;
var sent = 0;
foreach (var i in targets)
{
var it = all[i];
var count = Math.Max(1, it.PeriodCount);
// 还没推过(玩家刚走近)⇒ 先推当前相位。
if (!state.Pushed[i])
{
state.NextSwitchAt[i] = nowSec + period;
}
else if (nowSec < state.NextSwitchAt[i])
{
continue;
}
else
{
// 切档
state.PeriodIndex[i] = (byte)((state.PeriodIndex[i] + 1) % count);
state.NextSwitchAt[i] = nowSec + period;
}
var cur = state.PeriodIndex[i];
await session.NotifyAsync(MethodId.SyncAetherAIIntersectionUpdateDa,
UxSerializer.Serialize(new GameMethods.SyncAetherAIIntersectionUpdateData4229938
{
data = new GameMethods.ClientTrafficIntersectionPeriodUpdateInfo4229938
{
IntersectionIndex = useZone ? (ulong)it.ZoneIndex : (ulong)i,
CurrentState = GameMethods.MassTrafficIntersectionState4229938.Start,
CurrentPeriodIndex = cur,
NextPeriodIndex = (byte)((cur + 1) % count),
RailPeriodIndex = it.RailPeriodIndex,
},
}), token);
state.Pushed[i] = true;
sent++;
if (sent % 16 == 0)
await Task.Delay(8, token).ConfigureAwait(false);
}
if (sent > 0)
session.Log.Info($"[SIGNAL] 相位推进 {sent} 个路口(周期 {period:F0}s)");
}
}
catch (OperationCanceledException)
{
// 正常退出
}
catch (Exception ex)
{
session.Log.Warn($"[SIGNAL] 相位循环停止: {ex.Message}");
}
}
/// <summary>
/// 体检用:路口数 / 多相位路口数 / 涉及车道数 / 首行摘要(启动时打一行,便于对账)。
/// </summary>
internal static (int Intersections, int MultiPeriod, int Lanes, string Sample) ProbeIntersections()
=> IntersectionTable.Probe();
}