293 lines
12 KiB
C#
293 lines
12 KiB
C#
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();
|
|||
|
|
}
|