Files

1910 lines
99 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 System.Diagnostics;
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 Ananta.Server.RpcTypes.Client4229938;
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
namespace Ananta.Server.Handlers.Game;
/// <summary>
/// T3-7 真实车流 —— 服务端驱动车辆沿车道跑。
///
/// ★ 为什么必须服务端驱动:客户端侧没有任何「车流密度解锁」S2C 包。
/// 人流有 SyncSetCrowdDensity(68996045) + SyncCrowdSpecifiedDensityRefresh(68195167),
/// 车流只有 C2S 的 GmChangeAetherVehicleDensity(69493525)。实测:服务端一个 vehicle 包都不发
/// 时,街上一台车都没有(含路边停放)。所以车必须逐台下发并持续推进。
///
/// ★ 推进方式:服务端只发「车道 handle + DistanceAlongLane」,客户端 AetherCity 侧的
/// MassTrafficInterpolationUtils 按本地 ZoneGraph 曲线插值出位置与朝向。
/// 这是捐赠端 §88「车歪 + 走一点就停」的正解 —— 那条路靠服务端算世界坐标,这里不算。
///
/// ★ ControlType:VehicleControlType { Invalid=0, AetherVehicle=1, RaidVehicle=2 }。
/// T1-16 骇入用 2(不接管驾驶),T3-7 必须用 1,否则客户端不做车道模拟。
/// </summary>
internal sealed partial class GameRouter
{
private const string TrafficKey = "client4229938-traffic";
/// <summary>
/// ★★ 进程级「已下发但还没移除」的车流车 id 全集(2026-09-29 新增)。
///
/// 为什么需要它:车的实体是客户端侧的(<c>SyncSpawnVehicle</c> 建的),
/// 会话结束 / 切场景 / 切角色时**服务端这边循环一死就没人再管它们**,
/// 而 <c>StopTraffic</c> ①从来没被调用过 ②即使调用也只清本地链表、不发移除包。
/// ⇒ 上一批车会原封不动留在客户端世界里、再也没有里程数据 ⇒ 冻成「杵着不动的车」。
/// 用户看到的就是「切到另一个角色,他身边停着好几辆不动的车」。
/// 有了这个全集,新会话起来时就能把**不属于自己**的残留 id 逐个发 Remove + Destroy 清掉。
/// </summary>
private static readonly HashSet<ulong> LiveTrafficIds = [];
private static readonly object LiveTrafficLock = new();
/// <summary>
/// <c>SyncAetherAIVehicleLaneDatas</c> 的最小下发间隔(毫秒)。
/// 物理仍按 <c>trafficTickMs</c>(默认 50ms)推进,只有"把里程告诉客户端"这一步降频。
/// 50 ⇒ 20 Hz(原行为);150 ⇒ ≈6.7 Hz,实测可砍掉约 2/3 的字节。
/// ⛑️ 调大到 300 以上会让远处的车出现可见的"一顿一顿",因为客户端插值窗口被拉长。
/// </summary>
private const int TrafficLanePushIntervalMs = 150;
/// <summary>车道数据节流闸门。到点才返回 true 并记账(见 <see cref="TrafficLanePushIntervalMs"/>)。</summary>
// 批量移除的分块(照抄 GameRouter.Ambient.cs:45 的老规矩:一次性连发会打爆 socket)。
private const int TrafficRemoveBatchSize = 48;
private const int TrafficRemoveBatchDelayMs = 12;
private static bool ShouldPushLaneData(TrafficSessionState owner)
{
var now = Environment.TickCount64;
lock (owner.SyncRoot)
{
if (owner.LastLanePushMs != 0 && now - owner.LastLanePushMs < TrafficLanePushIntervalMs)
return false;
owner.LastLanePushMs = now;
return true;
}
}
// ★ 车辆实体必须用 8_200_000_000_000_000_000 号段(与 GameRouter.Vehicle.cs:25 同一分配器)。
// 2026-09-21 实测:用 7_000_000 这种小整数时客户端完全不建实体 —— 街上没车,
// 而服务端日志一切正常(AddData / LaneDatas 都在发)。号段不对 = 实体识别失败。
// 参照:行人 8_300_000_000_000_000_000(DonorNpc.cs:32)、NPC 归属 8_310_...、token 7_100_...
internal sealed class TrafficCar
{
internal ulong Id;
internal uint ConfigId;
internal VehicleLaneGraph.DriveState Drive;
internal float Speed; // m/s,当前速度
internal float TargetSpeed; // m/s,本 tick 算出的目标速度(限速 - 跟车 - 转弯)
/// <summary>速度接近 0 的累计秒数(用于僵局解除)。</summary>
internal float StallSeconds;
// --- 寿命计量(2026-09-29):用来量化「车开多远就停/消失」,别再靠肉眼猜 -------
/// <summary>累计行驶里程(米)。回收/超距时打进日志。</summary>
internal float TraveledMeters;
/// <summary>累计换道次数。</summary>
internal int Hops;
/// <summary>存活秒数。</summary>
internal float AliveSeconds;
/// <summary>出生时刻(<c>Environment.TickCount64</c>)。Cruise 车不进 tick 推进,寿命只能这么算。</summary>
internal long SpawnTicks;
/// <summary>僵局解除后的「硬冲」剩余秒数:> 0 时无视让行约束,低速强行通过。</summary>
internal float BoostSeconds;
/// <summary>
/// 红灯前已经等了的秒数(只在真正被红灯刹停时累加)。
/// 超过 <c>TrafficSignalMaxWaitSec</c> 就放行 —— 给「等红灯」一个统一上界,
/// 否则腿数多的路口(4 组周期 44s)会让人觉得车杵在那儿不动。
/// </summary>
internal float RedWaitSeconds;
/// <summary>
/// 在 <c>VehicleRuntimeState.Vehicles</c> 里对应的登记记录(<c>null</c> = 未登记)。
/// 有了它车才是「可骇入」的:<c>ExecuteVehicleControlAsync</c> 第一步就是
/// <c>runtime.Vehicles.TryGetValue(vehicleId)</c>,找不到直接拒绝。
/// 每 tick 会把位置/朝向同步进去(骇入的 30m 距离判定和 AI 任务包都读它)。
/// </summary>
internal VehicleRecord? Record;
/// <summary>
/// 预规划路径(接下来要走的车道序列,不含当前车道)。<c>null</c>/空 = 退回随机挑后继。
/// 由 <c>VehicleLaneGraph.PlanRoute</c> 生成,走完会自动重新规划。
/// </summary>
internal List<int>? Route;
/// <summary>当前走到 <see cref="Route"/> 的第几段。</summary>
internal int RouteIdx;
// --- 2026-10-01 方案 A:Cruise(客户端本地 AI 驾驶)------------------------------------
/// <summary>
/// true = 这台车交给客户端本地 AI 开(<c>SyncAssignVehicleAITask</c> + Cruise 参数),
/// <b>不参与</b>服务端的车道推进与 <c>LaneDatas</c> 批次(两套驱动源会打架:
/// 「刚加速就被拽回原地」)。见配置 <c>TrafficCruiseDrive</c>。
/// </summary>
internal bool Cruise;
/// <summary>当前 Cruise 任务的 token(刷新时要先拿它发 Abort(4))。0 = 还没下发过。</summary>
internal ulong CruiseToken;
/// <summary>上次真正下发 Cruise 目标的时刻(<c>Environment.TickCount64</c>),提前量判定用。</summary>
internal long CruiseAssignedTicks;
/// <summary>下次重发目标点的时刻(<c>Environment.TickCount64</c>)。</summary>
internal long CruiseRefreshTicks;
/// <summary>
/// ★ 估算位置:朝当前目标<b>按速度推算</b>(dead reckoning)出来的坐标。
/// Cruise 车的位置客户端<b>不上报</b>,服务端只能这么估(回收距离判定 /
/// 出生避撞判定 / 骇入距离判定都读它)。推进逻辑见 <see cref="AdvanceCruiseEstimates"/>。
/// ⛑⛑ <b>别直接把它设成目标点</b> —— 目标一旦超出回收半径,车会「刚出生就被判出界回收」
/// (2026-10-01 23:2x 实机:`despawn id=… (out of range) after=0m alive=0s`)。
/// </summary>
internal float CruiseX;
internal float CruiseZ;
/// <summary>当前目标点(估算位置朝它推进,到达即停住等下一次下发)。</summary>
internal float CruiseTargetX;
internal float CruiseTargetZ;
/// <summary>估算位置所在的车道(= 上一个目标点所属车道),下一次规划从这里往后接。</summary>
internal int CruiseLane = -1;
/// <summary>
/// 客户端最近一次回报的 AI 任务状态
/// (0 Pending / 1 Running / 2 Success / 3 Pause / 4 Abort / 5 Override)。
/// 收到 2/3/4/5 就把 <see cref="CruiseRefreshTicks"/> 归零 ⇒ 立刻续发目标点,
/// 不用干等 <c>trafficCruiseRefreshSec</c>。
/// </summary>
internal byte CruiseStatus;
/// <summary>
/// 这台车**固定**的巡航速度倍率(出生时按 <c>trafficCruiseSpeedJitter</c> 摇一次,全程不变)。
/// ⛑ 为什么每台车要不一样:全都同一速度 ⇒ 两台车一旦挨上就永远并排贴着开
/// (相对速度 0,谁也甩不开谁)。给了差值,快车自然拉开、慢车自然落后。
/// </summary>
internal float CruiseSpeedScale = 1f;
// ★ 司机不做(2026-09-21 评估):ClientVehicleInitData 没有 driver 字段,
// 只能走 SyncSpawnVehicle.SeatInfos[0].EntityId + SeatState=Occupy 绑一个 static NPC,
// 但 Aether 车流管线根本不读座位(AetherVehicleCreateContext 无 driver 字段),
// 收益只是观感、成本是每台车多一个 NPC 实体 + 生命周期管理,性价比低,先搁置。
}
internal sealed class TrafficSessionState
{
internal object SyncRoot { get; } = new();
internal CancellationTokenSource? Cts;
internal Task? Loop;
internal List<TrafficCar> Cars { get; } = [];
internal long LastReseedTicks;
internal long LastTickTicks;
/// <summary>车流健康度诊断([TRAFFIC-STAT])的上次输出时刻。</summary>
internal long LastStatTicks;
/// <summary>推送节拍采样:[TRAFFIC-STAT] 输出 dt 均值/峰值用,输出后清零。</summary>
internal int TickSamples;
internal double TickSumMs;
internal double TickMaxMs;
/// <summary>上次真正下发 <c>SyncAetherAIVehicleLaneDatas</c> 的时刻(节流用,见 TrafficLanePushIntervalMs)。</summary>
internal long LastLanePushMs;
/// <summary>
/// 上一包 LaneDatas 的时间戳(秒,Unix)。
/// ★ 客户端的 TimedSpringInterpolationMethod 是「定时弹簧」:每个点带 TargetTimeStamp,
/// 客户端在 currentServerTime 走到该时刻之前把 DeltaDistance 平滑排空。
/// 所以时间戳必须①指向未来(+lead)②单调递增③增量稳定 —— 三条缺一条就会出现
/// 「冲一下就停 / 停很久突然补距离」。
/// </summary>
internal double LastTimestampSeconds;
/// <summary>
/// 玩家最近一次的位置(XZ)。Cruise 车掉进死路口袋时要换一个「离玩家不远的新目的地」,
/// 得知道玩家在哪(<see cref="PickCruiseRoute"/> 用它挑候选车道)。
/// </summary>
internal float PlayerX;
internal float PlayerZ;
/// <summary>
/// 最近发过的目标车道(最多 24 条)。挑新目标时避开它们 ⇒ 不会好几台车挤向同一个点
/// (2026-10-01 23:2x 实机:四台车同时被分到 `target=(872,1874)`,后车顶上前车、
/// 客户端 AI 倒车脱困后又停)。
/// </summary>
internal List<int> RecentTargetLanes { get; } = [];
}
private static TrafficSessionState TrafficState(TcpSession session)
{
if (session.Items.TryGetValue(TrafficKey, out var existing) && existing is TrafficSessionState typed)
return typed;
var created = new TrafficSessionState();
session.Items[TrafficKey] = created;
return created;
}
/// <summary>
/// 世界就绪后调用一次;幂等。配置关掉或车道表缺失就直接返回。
///
/// ⛑ 2026-09-29:改为 async —— 起循环前先清掉**上一个会话残留**的车
/// (见 <see cref="LiveTrafficIds"/> 的注释)。不清的话切场景 / 切角色后
/// 上一批车会原封不动留在客户端世界里冻着。
/// </summary>
internal static async Task EnsureTrafficAsync(TcpSession session)
{
var cfg = PrivateServerConfigStore.Current.Gameplay.Aether;
var owner = TrafficState(session);
// 先清残留(无论本次开不开车流都要清:关掉时更应该把上一批撤干净)。
await RetireStrayTrafficAsync(session, owner).ConfigureAwait(false);
if (!cfg.TrafficEnabled)
{
// 面板把车流关掉 ⇒ 当场把本会话已下发的车回收(不用等重进场景)。
await RetireTrafficAsync(session).ConfigureAwait(false);
return;
}
if (!VehicleLaneGraph.Available)
{
session.Log.Warn("[TRAFFIC] disabled: vehicle lane table missing (run _gen_vehicle_lanes.py)");
return;
}
session.Log.Info($"[TRAFFIC] signals enabled={cfg.TrafficSignalEnabled} " +
$"junctions={JunctionSignals.JunctionCount} stopLanes={JunctionSignals.LaneCount}");
lock (owner.SyncRoot)
{
if (owner.Loop is { IsCompleted: false })
return;
owner.Cts?.Dispose();
owner.Cts = new CancellationTokenSource();
owner.Loop = Task.Run(() => RunTrafficLoopAsync(session, owner, owner.Cts.Token));
}
}
/// <summary>断开 / 切场景时停掉循环。</summary>
internal static void StopTraffic(TcpSession session)
{
if (!session.Items.TryGetValue(TrafficKey, out var existing) || existing is not TrafficSessionState owner)
return;
List<ulong>? owned = null;
lock (owner.SyncRoot)
{
owner.Cts?.Cancel();
owner.Cts?.Dispose();
owner.Cts = null;
foreach (var car in owner.Cars)
(owned ??= []).Add(car.Id);
owner.Cars.Clear();
}
// ★ 注销这批车的登记:它们是车流生成的,实体随会话一起没了。
// 不清理的话 runtime.Vehicles 会留下幽灵车(骇入 UI 能选中、下发必然失败)。
if (owned is not null)
{
var runtime = GetVehicleRuntimeState(session);
lock (runtime.SyncRoot)
{
foreach (var id in owned)
runtime.Vehicles.Remove(id);
}
}
}
private static async Task RunTrafficLoopAsync(TcpSession session, TrafficSessionState owner, CancellationToken token)
{
var cfg = PrivateServerConfigStore.Current.Gameplay.Aether;
// 下限放宽到 30ms:客户端按 20Hz(50ms) 设计,允许更密但不能无限密。
var tickMs = Math.Max(30, cfg.TrafficTickMs);
var tickSec = tickMs / 1000.0;
var reseedMs = Math.Max(200, cfg.TrafficReseedMs);
var rng = new Random(session.GetHashCode());
owner.LastTickTicks = Environment.TickCount64;
session.Log.Info($"[TRAFFIC] loop started target={cfg.TrafficTarget} tick={tickMs}ms " +
$"lead={cfg.TrafficTimestampLeadMs:F0}ms " +
$"spawnRadius={cfg.TrafficSpawnRadiusMeters:F0}m despawnRadius={cfg.TrafficDespawnRadiusMeters:F0}m " +
$"speedScale={cfg.TrafficSpeedScale:F2} controlType={cfg.TrafficControlType} " +
$"lanes={VehicleLaneGraph.LaneCount} " +
// 2026-09-30:这两个数是「孤岛车道」修复的自证 —— 期望 routable≈13886、
// scc 从 286 降不到 0 说明 Tarjan 生效;routable 突然等于 lanes ⇒ 兜底触发了。
$"routable={VehicleLaneGraph.RoutableLaneCount} scc={VehicleLaneGraph.ComponentCount} " +
$"pool={VehicleSpawnCatalog4229938.TrafficVehicleIds.Length} " +
// ★ 玻璃浮空三件套:让「喂进去的值」本身肉眼可验(硬规则 106)。
$"sourceType={cfg.TrafficCreateSourceType} " +
$"disableNav={cfg.TrafficDisableNavigation} " +
$"brokenCollision={cfg.TrafficSendBrokenCollision} " +
$"emptySeats={cfg.TrafficEmptySeatInfos} " +
// ★ 方案 A(2026-10-01):开哪条驱动管线必须一眼能从日志看出来
$"cruise={cfg.TrafficCruiseDrive} hops={cfg.TrafficCruiseHops} " +
$"singleTarget={cfg.TrafficCruiseSingleTarget} " +
$"refresh={cfg.TrafficCruiseRefreshSec:F0}s life={cfg.TrafficCruiseMaxLifeSec:F0}s");
var sw = Stopwatch.StartNew();
var nextAtMs = (double)tickMs;
// 节奏诊断([TRAFFIC-STAT] 打 dt 均值/峰值):采**真实墙钟**推送间隔,
// 不是时间戳增量 —— 后者被 clamp 到 ≤3 tick,真卡 500ms 也只显示 150ms,看不出抖动。
var lastBeatMs = 0.0;
try
{
while (!token.IsCancellationRequested)
{
// ★★ 高精度节拍。
// 原来直接 `Task.Delay(tickMs)`:Windows 默认定时器精度约 15.6ms,
// 实际间隔是 50~65ms 而不是 50ms。而客户端插值器按
// DELTA_TIME_PER_SERVER_FRAME = 0.05(20Hz)假设服务端节奏,
// 节拍对不上 ⇒ 车一顿一顿地跳(用户反馈「不像在开,像一点点瞬移」)。
// 先粗等到接近目标,再自旋补齐,把抖动压到 1~2ms。
var remainMs = nextAtMs - sw.Elapsed.TotalMilliseconds;
if (remainMs > 2)
await Task.Delay((int)(remainMs - 1), token).ConfigureAwait(false);
while (sw.Elapsed.TotalMilliseconds < nextAtMs)
Thread.SpinWait(20);
// 这一轮本身耗时超过一个 tick 时(GC / 大批量),别让节拍越追越滞后。
if (nextAtMs < sw.Elapsed.TotalMilliseconds)
nextAtMs = sw.Elapsed.TotalMilliseconds;
nextAtMs += tickMs;
var state = GetStateIfExists(session);
if (state is null || !state.Ready || !state.HasLastReportedPlayerTransform)
continue;
// 客户端挂起期间只睡不推 —— 见 GameRouter.IsBackgroundPushPaused 的说明。
if (IsBackgroundPushPaused(session))
{
// 同上:把补给计时器往前推,恢复后不要一次性补一整批车。
owner.LastReseedTicks = Environment.TickCount64;
continue;
}
Vec3 player;
lock (state.SyncRoot)
player = state.LastReportedPlayerPosition;
// ★ Cruise 车掉进死路口袋时要按玩家位置挑新目的地(见 PickCruiseRoute)。
lock (owner.SyncRoot)
{
owner.PlayerX = player.X;
owner.PlayerZ = player.Z;
}
var now = Environment.TickCount64;
owner.LastTickTicks = now;
// ★ 时间戳时间轴:now + lead,但严格单调递增且单次增量 ≤ 3 tick。
// 服务端偶发卡顿(GC / await 抖动)时不会把「攒下来的几百毫秒」一次性丢给
// 客户端排空 —— 那是「停下很久然后突然高速补一段」的另一半成因。
var nowStamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000.0
+ cfg.TrafficTimestampLeadMs / 1000.0;
var minStamp = owner.LastTimestampSeconds + 0.001;
var maxStamp = owner.LastTimestampSeconds + tickSec * 3;
var stamp = owner.LastTimestampSeconds <= 0
? nowStamp
: Math.Clamp(nowStamp, minStamp, maxStamp);
var stampDelta = owner.LastTimestampSeconds <= 0
? tickSec
: Math.Min(stamp - owner.LastTimestampSeconds, tickSec * 3);
owner.LastTimestampSeconds = stamp;
// 节奏诊断采样(Push 里的 [TRAFFIC-STAT] 会打出 dt 均值/峰值后清零)
var beatMs = sw.Elapsed.TotalMilliseconds;
if (lastBeatMs > 0)
{
var dtMs = beatMs - lastBeatMs;
owner.TickSamples++;
owner.TickSumMs += dtMs;
if (dtMs > owner.TickMaxMs)
owner.TickMaxMs = dtMs;
}
lastBeatMs = beatMs;
// ① 补给 / 回收(低频,避免每帧扫表)
if (now - owner.LastReseedTicks >= reseedMs)
{
owner.LastReseedTicks = now;
await ReconcileTrafficAsync(session, owner, player, cfg, rng, stamp, token).ConfigureAwait(false);
}
// ② 推进 + 批量下发车道数据
// 里程按**时间戳增量**推进(不是真实 dt),这样客户端看到的位置/速度严格自洽。
await PushTrafficLaneDataAsync(session, owner, (float)stampDelta, stamp, cfg, rng, token)
.ConfigureAwait(false);
AdvanceCruiseEstimates(owner, (float)stampDelta);
// ②b 方案 A:给到期的 Cruise 车重发目标点(内部按 trafficCruiseRefreshSec 闸门,
// 或客户端刚回报任务结束 ⇒ 立刻续发;没到期直接返回,不发包)。
await RefreshTrafficCruiseAsync(session, owner, cfg, rng, token).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// 正常退出
}
catch (Exception ex)
{
session.Log.Warn($"[TRAFFIC] loop stopped: {ex.Message}");
}
}
/// <summary>
/// 一台车的<b>服务端视角</b>位置(XZ)。
///
/// ⛑ Cruise 车(<see cref="TrafficCar.Cruise"/>)没有真实坐标 —— 客户端不上报 AI 车的
/// 移动,而我们也不再推车道插值 ⇒ 只能用「上次下发的目标点」当估算值。
/// 回收距离判定、出生避撞判定都读这里,别直接调 <c>VehicleLaneGraph.PositionOf</c>
/// (对 Cruise 车那是冻结在出生点的假坐标,会导致永远回收不掉)。
/// </summary>
private static void TrafficCarPos(TrafficCar car, out float x, out float z)
{
if (car.Cruise)
{
x = car.CruiseX;
z = car.CruiseZ;
return;
}
VehicleLaneGraph.PositionOf(car.Drive, out x, out _, out z);
}
/// <summary>
/// 这条车道上<b>已经有车</b>了吗(按各车的出生车道比)。见 <c>TrafficSpawnAvoidSameLane</c>。
/// ⛑ 近似判据:车的 <c>Drive.Lane</c> 是出生车道、之后不再更新,所以这是
/// 「这条车道最近被占着」的保守判断(会占到那台车被回收为止),方向正是我们要的「错开」。
/// </summary>
private static bool LaneAlreadyUsed(TrafficSessionState owner, int lane)
{
lock (owner.SyncRoot)
{
foreach (var c in owner.Cars)
if (c.Drive.Lane == lane)
return true;
}
return false;
}
/// <summary>
/// 这条车道的前进方向是不是<b>朝着玩家</b>(玩家在车头前方 ⇒ 车会开过来,形成迎面车流)。
/// 见 <see cref="AetherSettings.TrafficSpawnFacePlayerRatio"/>。
/// </summary>
private static bool HeadsTowardPlayer(int lane, float sx, float sz, Vec3 player)
{
if (!VehicleLaneGraph.TryLaneGeometry(lane, out _, out _, out _,
out var ex, out _, out var ez, out _))
return false;
var fx = ex - sx;
var fz = ez - sz;
var len = MathF.Sqrt(fx * fx + fz * fz);
if (len < 1e-3f)
return false;
return (fx / len) * (player.X - sx) + (fz / len) * (player.Z - sz) > 0f;
}
/// <summary>候选出生点是否与现有车辆离得足够远。</summary>
private static bool FarFromOtherCars(TrafficSessionState owner, float x, float z, float minGap)
{
var sq = minGap * minGap;
lock (owner.SyncRoot)
{
foreach (var other in owner.Cars)
{
TrafficCarPos(other, out var ox, out var oz);
var dx = ox - x;
var dz = oz - z;
if (dx * dx + dz * dz < sq)
return false;
}
}
return true;
}
/// <summary>按玩家位置把车辆数维持在 target 台;跑出路网或跑太远的车回收。</summary>
private static async Task ReconcileTrafficAsync(
TcpSession session, TrafficSessionState owner, Vec3 player,
AetherSettings cfg, Random rng, double stamp, CancellationToken token)
{
var despawnSq = cfg.TrafficDespawnRadiusMeters * cfg.TrafficDespawnRadiusMeters;
// ★ Cruise 车的寿命上限(秒):位置是估算的,按距离收不准 ⇒ 用寿命兜底,
// 否则老车永远达不到回收条件、新车补不进来。见配置 trafficCruiseMaxLifeSec。
var maxLifeMs = cfg.TrafficCruiseMaxLifeSec > 0f ? cfg.TrafficCruiseMaxLifeSec * 1000f : 0f;
var nowTicks = Environment.TickCount64;
List<TrafficCar>? dead = null;
List<TrafficCar>? deadOld = null;
lock (owner.SyncRoot)
{
for (var i = owner.Cars.Count - 1; i >= 0; i--)
{
var car = owner.Cars[i];
TrafficCarPos(car, out var cx, out var cz);
var dx = cx - player.X;
var dz = cz - player.Z;
if (dx * dx + dz * dz > despawnSq)
{
(dead ??= []).Add(car);
owner.Cars.RemoveAt(i);
continue;
}
if (car.Cruise && maxLifeMs > 0f && nowTicks - car.SpawnTicks > maxLifeMs)
{
(deadOld ??= []).Add(car);
owner.Cars.RemoveAt(i);
}
}
}
if (deadOld is not null)
{
var vehRuntime0 = GetVehicleRuntimeState(session);
foreach (var car in deadOld)
{
await RemoveTrafficVehicleAsync(session, vehRuntime0, car.Id, token).ConfigureAwait(false);
session.Log.Info($"[TRAFFIC] recycle id={car.Id} (cruise life >" +
$"{cfg.TrafficCruiseMaxLifeSec:F0}s) {CarLife(car)}");
}
}
if (dead is not null)
{
var vehRuntime = GetVehicleRuntimeState(session);
foreach (var car in dead)
{
await RemoveTrafficVehicleAsync(session, vehRuntime, car.Id, token).ConfigureAwait(false);
session.Log.Info($"[TRAFFIC] despawn id={car.Id} (out of range) {CarLife(car)}");
}
}
int missing;
lock (owner.SyncRoot)
missing = cfg.TrafficTarget - owner.Cars.Count;
if (missing <= 0)
return;
var spawned = 0;
var lastId = 0UL;
var lastLane = -1;
var lastFacing = 0f;
var lastCfg = 0u;
for (var i = 0; i < missing; i++)
{
// ★ 出生点要躲开已有的车:否则「刚生成就贴在一台车上」→ 立刻触发跟车急停,
// 后面整条路都会被这台车堵住。最多换 6 个候选点。
VehicleLaneGraph.DriveState drive = default;
var sx = 0f; var sy = 0f; var sz = 0f; var laneSpeed = 9.72f;
var placed = false;
// ★ 这一台要不要「朝玩家开」(见 TrafficSpawnFacePlayerRatio):
// 车流按「距玩家 620m 内」存活 ⇒ 迎面车一趟就穿出去了、同向车一直待着
// ⇒ 稳态下几乎看不到逆向车。所以显式让一半的车从玩家前方开过来。
var wantToward = rng.NextSingle() < Math.Clamp(cfg.TrafficSpawnFacePlayerRatio, 0f, 1f);
for (var attempt = 0; attempt < 6; attempt++)
{
if (!VehicleLaneGraph.TryPickSpawn(player.X, player.Z, cfg.TrafficSpawnRadiusMeters,
cfg.TrafficSpawnMinPlayerDistanceMeters, rng,
out var cand, out var cx, out var cy, out var cz, out var cs))
break;
if (wantToward && !HeadsTowardPlayer(cand.Lane, cx, cz, player))
continue; // 这台要迎面来 ⇒ 换一条朝玩家的车道
// ★ 车道级错开(见 TrafficSpawnAvoidSameLane):这条车道上已经有车 ⇒ 换一条。
// 只靠「距离」挡不住并排贴着开 —— 距离读的是推算位置,车实际可能慢得多。
if (cfg.TrafficSpawnAvoidSameLane && LaneAlreadyUsed(owner, cand.Lane))
continue;
if (FarFromOtherCars(owner, cx, cz, cfg.TrafficSpawnMinGapMeters))
{
drive = cand; sx = cx; sy = cy; sz = cz; laneSpeed = cs;
placed = true;
break;
}
}
if (!placed && (wantToward || cfg.TrafficSpawnAvoidSameLane))
{
// 方向 / 车道约束没满足(附近挑不出合适的车道)⇒ 别因此少刷车,去掉约束再来一轮。
for (var attempt = 0; attempt < 3; attempt++)
{
if (!VehicleLaneGraph.TryPickSpawn(player.X, player.Z, cfg.TrafficSpawnRadiusMeters,
cfg.TrafficSpawnMinPlayerDistanceMeters, rng,
out var cand, out var cx, out var cy, out var cz, out var cs))
break;
if (FarFromOtherCars(owner, cx, cz, cfg.TrafficSpawnMinGapMeters))
{
drive = cand; sx = cx; sy = cy; sz = cz; laneSpeed = cs;
placed = true;
break;
}
}
}
if (!placed)
continue;
var configId = PickTrafficConfigId(rng);
if (configId is null)
break;
var car = new TrafficCar
{
Id = unchecked((ulong)IdCounterStore.Next(VehicleEntityCounter, VehicleEntityFloor)),
ConfigId = configId.Value,
Drive = drive,
// ★ 起步速度为 0,靠 ④ 的加速度爬到限速 —— 不然车「凭空直接起步」。
Speed = 0f,
TargetSpeed = laneSpeed * cfg.TrafficSpeedScale,
// ★ 方案 A(2026-10-01):出生就决定这台车走哪条驱动管线,全程不变
// (中途切换会留下两套驱动源的半截状态)。见配置 trafficCruiseDrive。
Cruise = cfg.TrafficCruiseDrive,
SpawnTicks = Environment.TickCount64,
CruiseLane = drive.Lane,
CruiseX = sx,
CruiseZ = sz,
// ★ 每台车一个固定速度倍率(±trafficCruiseSpeedJitter)⇒ 挨上的两台车会自然拉开。
CruiseSpeedScale = 1f + (rng.NextSingle() * 2f - 1f)
* Math.Clamp(cfg.TrafficCruiseSpeedJitter, 0f, 0.5f),
};
var facing = VehicleLaneGraph.FacingOf(drive);
// ★★ 必须先有 BaseVehicle,Aether 才能接管。
// 客户端实测(Player.log):只发 SyncAetherAIVehicleAddData 时每台车都报
// [AetherVehicleLifecycle] Flush dropped missing BaseVehicle id=... type=NormalVehicle
// 因为 AddData 只把车放进 pending 队列等一个**已存在**的 BaseVehicle,
// 它自己不建实体。建实体要走捐赠端已验证的三步序列:
// SyncLogicVehicleEnter + SyncSpawnVehicle (+ SyncChangeVehicleInteractable)
await SpawnTrafficVehicleBodyAsync(session, car.Id, car.ConfigId,
sx, sy, sz, facing, 0f, cfg, token).ConfigureAwait(false);
await session.NotifyAsync(MethodId.SyncAetherAIVehicleAddData,
UxSerializer.Serialize(new GameMethods.ClientVehicleInitData4229938
{
VehicleConfigId = car.ConfigId,
// ★ 必须是这台车自己的合法配色件(VehicleSpawnConfig.ColorMod),
// 填 0 客户端拿不到配色、个别车型会退化成「只剩玻璃」的残模。
VehicleColorPartId = VehicleSpawnCatalog4229938.PickColorPartId(car.ConfigId, rng),
Id = car.Id,
Position = new SceneMethods.UxVector3(sx, sy, sz),
// ★ 度,两者同值(对齐 SyncAetherAICrowdAdd 的写法)。
Facing = facing,
EulerAngles = new SceneMethods.UxVector3(0f, facing, 0f),
// ★ 与 LaneDatas 同一条时间轴(含 lead),否则车一出生就落后/超前。
Timestamp = stamp,
// ★ 方案 A:Cruise 车必须 LaneHandle=-1 + ControlType=2(RaidVehicle)
// ⇒ 关掉客户端的车道模拟(与 DetachTrafficVehicleFromLaneAsync 同款写法)。
// 留着车道模拟的话,它会和 Cruise 的本地 AI 抢方向盘 ——
// 表现为「刚加速就被拽回原地」。
LaneHandle = car.Cruise ? -1 : drive.Lane,
DistanceAlongLane = 0f,
ControlType = (byte)(car.Cruise
? 2
: Math.Clamp(cfg.TrafficControlType, (byte)1, (byte)2)),
Speed = 0f, // 与起步状态一致;之后由 LaneDatas 的增量驱动
RandomFraction = rng.NextSingle(),
}), token).ConfigureAwait(false);
// ★★ 让车流车「可骇入」(2026-09-22)。
// 实测对照:面板「生成载具」刷出的车能骇入加速,车流车不能。
// 差别不在客户端,而在这三步 —— Vehicle.cs 的召唤流程全做了,车流车一步没做:
// ① 登记进 runtime.Vehicles(ExecuteVehicleControlAsync 第一步就查这张表,
// 查不到直接返回「车辆不在当前服务端场景记录中」)
// ② 补 HP(CanHack() 要求 CurrentHp > 0,而 SyncSpawnVehicle 不带 HP 字段)
// ③ 下发 isHackable(HackTargetSelector 的准入标记)
var vehRuntime = GetVehicleRuntimeState(session);
var seatCount = 2;
if (VehicleCatalog4229938.TryGet(car.ConfigId, out var vc) && vc.SeatCount is >= 1 and <= 16)
seatCount = vc.SeatCount;
var rec = new VehicleRecord
{
EntityId = car.Id,
ConfigId = car.ConfigId,
SeatCount = seatCount,
Position = new Vec3(sx, sy, sz),
Facing = facing,
ControllerPid = 0,
Interactable = true,
TaskToken = unchecked((ulong)IdCounterStore.Next(VehicleTokenCounter, VehicleTokenFloor)),
// 上面那条 AddData 已经让 Aether 接管,HackTargetSelector 找得到它。
// 注意:这里**不能**改走 RegisterHackVehicleAsync()——它会发 ControlType=2
// 的 AddData,那会关掉车道模拟,车就不跑了。
AetherRegistered = true,
IsTraffic = true,
};
lock (vehRuntime.SyncRoot)
vehRuntime.Vehicles[car.Id] = rec;
car.Record = rec;
// ★ 预规划路径:车沿「直行优先」的路连续跑,而不是每到一个车道就随机挑后继。
// 这是「车开很短距离就停下/消失」的结构性解 —— 随机游走会乱拐、掉头、钻死路。
if (cfg.TrafficRouteHops > 0)
car.Route = VehicleLaneGraph.PlanRoute(car.Drive.Lane, cfg.TrafficRouteHops,
cfg.TrafficMaxLaneHopMeters);
// ★ 补 HP 只为「可骇入」(CanHack() 要求 CurrentHp > 0,SpawnVehicle 不带 HP 字段)。
// 它同时喂车辆损伤系统 —— 与玻璃挂件同一条管线,故做成开关(trafficSendBrokenCollision)
// 便于二分「玻璃浮空」是不是它引起的。关掉 ⇒ 车流车不能骇入。
if (cfg.TrafficSendBrokenCollision)
{
await session.NotifyAsync(MethodId.SyncVehicleBrokenCollision,
UxSerializer.Serialize(new GameMethods.VehicleBrokenCollisionInfo4229938
{
VehicleEntityId = car.Id, CurrentHp = 10_000f, MaxHp = 10_000f,
}), token).ConfigureAwait(false);
}
await session.NotifyAsync(MethodId.IGameSceneToClient_SyncVehicleHackableState,
UxSerializer.Serialize(new SceneMethods.SyncVehicleHackableState4229938
{
vehicleEntityId = car.Id, isHackable = true,
}), token).ConfigureAwait(false);
// ★ 2026-09-29:告诉客户端「这台车别提升成 GO」(SyncAetherAIVehicleForceGo 68832908)。
// 不发的话客户端会在 20m 内把车列为 GO 候选、8m 内强制提升成完整载具实体,
// 提升后不再吃我们推的 Aether 车道插值、改由客户端本地接管;
// 而我们的车是服务端模拟的、不在客户端本地车流里 ⇒ 没人开 ⇒ 玩家一走近车就冻住。
// 这就是「我一靠近车,车就停了 / 追上去它又停」。见配置 TrafficSuppressGoRender。
if (cfg.TrafficSuppressGoRender)
{
await session.NotifyAsync(MethodId.SyncAetherAIVehicleForceGo,
UxSerializer.Serialize(new GameMethods.SyncAetherAIVehicleForceGo4229938
{
InstanceId = car.Id,
ForceGo = false,
}), token).ConfigureAwait(false);
}
// ★ 2026-10-01 实验 ①:让**客户端本地驾驶系统**接管这台车(SyncVehicleAutonomousDrivingState 68349264)。
// 与上一条思路相反:那条求「别被提升成 GO」(已证伪),这条是**给它一个本地司机**。
// 客户端自带驾驶/避障/交通规则,且本地驾驶本来就面向 GO 完整载具
// ⇒ 车被按距离提升成 GO 之后照样会开,「走近就停」应当消失。
// ⛑ 客户端有实现(GameSceneToClientImpl override → BaseVehicle.SetAutonomousDrivingState),
// 且 VehicleAutonomousDrivingModule 是每台车的模块、非玩家车专属。
// ⛑ 没有设目标点的 S2C ⇒ 开了之后车往哪开由客户端决定,**这是本实验要测的未知量**。
// 可能结果:① 车自己开起来 ⇒ 车流可整体换管线;② 原地不动(缺目标)⇒ 转注入器方案。
// 见配置 TrafficAutonomousDrive。
if (cfg.TrafficAutonomousDrive)
{
// ⛑ 契约用 **GameScene 那一份**(VehicleNavigationMethods4229938.cs:105,2026-09-27 就有的
// u64+bool Inline,已过 lua reader + _ux_wire_check 9B 验证)—— 别在这里复制第二份。
await session.NotifyAsync(MethodId.IGameSceneToClient_SyncVehicleAutonomousDrivingState,
UxSerializer.Serialize(new SceneMethods.SyncVehicleAutonomousDrivingState4229938
{
vehicleEntityId = car.Id,
isStart = true,
}), token).ConfigureAwait(false);
}
lock (owner.SyncRoot)
owner.Cars.Add(car);
lock (LiveTrafficLock)
LiveTrafficIds.Add(car.Id);
// ★ 方案 A(2026-10-01):把车交给**客户端本地 AI** 开。
// 放在最后:前面所有状态包(Spawn / AddData / Hackable / ForceGo / 自驾态)
// 都必须在 AI 任务之前下发,否则任务起来时车还是个空壳。
if (car.Cruise)
await AssignTrafficCruiseAsync(session, owner, car, cfg, rng, token).ConfigureAwait(false);
spawned++;
lastId = car.Id;
lastLane = drive.Lane;
lastFacing = facing;
lastCfg = car.ConfigId;
}
if (spawned > 0)
{
int live;
lock (owner.SyncRoot)
live = owner.Cars.Count;
session.Log.Info($"[TRAFFIC] spawn +{spawned} live={live}/{cfg.TrafficTarget} " +
$"at ({player.X:F0},{player.Y:F0},{player.Z:F0}) lastId={lastId} " +
$"cfg={lastCfg} lane={lastLane} facing={lastFacing:F1}deg");
}
}
/// <summary>
/// 建出 BaseVehicle 实体本身(捐赠端 GameRouter.Vehicles.SpawnDirectAsync 的 proven 序列):
/// SyncLogicVehicleEnter → SyncSpawnVehicle → SyncChangeVehicleInteractable。
/// Aether 的 AddData 只接管**已存在**的车,所以这一步必须在它之前。
/// </summary>
private static async Task SpawnTrafficVehicleBodyAsync(
TcpSession session, ulong entityId, uint configId,
float x, float y, float z, float facingDeg, float speed, AetherSettings cfg, CancellationToken token)
{
var pos = new SceneMethods.UxVector3(x, y, z);
var euler = new SceneMethods.UxVector3(0f, facingDeg, 0f);
// ★★ VehicleCreateSourceType:0=Invalid / 1=Task / 2=Summon / 5=AetherAIBorrowVehicle。
// 默认 0(对齐捐赠端 PrivateServerPor):车流车不是「玩家召唤的车」。
// 填 2(Summon) 会让客户端对它走完整装配管线(含 VehicleGlassDamageModule 的可破坏玻璃挂件),
// 而这些挂件建好后不再被 Aether 车道模拟更新 ⇒ 表现为「车玻璃浮在车身上方」。
var sourceType = (byte)Math.Clamp(cfg.TrafficCreateSourceType, 0, 8);
// 座位数按车型取。写死 2 是错的:客户端上车时会带 seats=[1,2,3] 这类索引,
// BeginVehicleEnter 里 `seat >= vehicle.SeatCount` 会把后排座直接拒掉
// (表现为「有些车按 F 没反应 / 上不去」)。
// ⛑ 定位开关 trafficEmptySeatInfos=true 时留空(对齐捐赠端),排除座位挂点作为浮空源。
var seatCount = 2;
if (VehicleCatalog4229938.TryGet(configId, out var vc) && vc.SeatCount is >= 1 and <= 16)
seatCount = vc.SeatCount;
List<GameMethods.RaidVehicleSeatInfo4229938> seats = [];
if (!cfg.TrafficEmptySeatInfos)
{
for (var i = 0; i < seatCount; i++)
seats.Add(new GameMethods.RaidVehicleSeatInfo4229938
{ EntityId = 0, SeatIndex = (byte)i, SeatState = 0, DestroyRelated = false });
}
await session.NotifyAsync(MethodId.SyncLogicVehicleEnter,
UxSerializer.Serialize(new GameMethods.LogicVehicleClientInfo4229938
{
EntityId = entityId,
VehicleConfigId = configId,
CreateSourceType = sourceType,
Parts = [],
SuitId = 0,
LicensePlate = string.Empty,
// ★ 2026-09-22 改为 true:之前关掉是为了让车专心跑路网,
// 代价是客户端根本不把它放进 F 的目标列表(用户反馈「全部不能交互」)。
// CanRushVehicle() 也要求这个字段为 true。
Interactable = true,
MoveToken = 0,
Position = pos,
EulerAngles = euler,
VehicleSpoonName = null,
}), token).ConfigureAwait(false);
await session.NotifyAsync(MethodId.SyncSpawnVehicle,
UxSerializer.Serialize(new GameMethods.VehicleClientInfo4229938
{
ControllerPid = 0,
CreateSourceType = sourceType,
EntityId = entityId,
VehicleConfigId = configId,
Parts = [],
SuitId = 0,
Position = pos,
Facing = facingDeg,
EulerAngles = euler,
Velocity = speed,
IsStatic = false,
DeformStatus = 0,
SeatInfos = seats,
SpoonId = 0,
IsDynamicGo = false,
VehicleEnemyId = 0,
// ★ 默认 false(对齐捐赠端)。true 关掉客户端自己的导航/贴地,
// 可能与车道模拟给的 root 高度不一致 ⇒ 车身与玻璃分家。
DisableNavigation = cfg.TrafficDisableNavigation,
Interactable = true, // ★ 同上:可交互才能按 F / 被骇入选中
MoveToken = 0,
LicensePlate = string.Empty,
}), token).ConfigureAwait(false);
// ★ 第三步(此前一直漏发):显式打开可交互。
// 前两步的 Interactable 只作用于建车那一刻;客户端的可交互列表靠这个包刷新。
await session.NotifyAsync(MethodId.SyncChangeVehicleInteractable,
UxSerializer.Serialize(new GameMethods.SyncChangeVehicleInteractable4229938
{
vehicleInstanceId = entityId,
interactable = true,
}), token).ConfigureAwait(false);
}
/// <summary>
/// ★★ 方案 A(2026-10-01):给一台车流车下发 <c>SyncAssignVehicleAITask(68549522)</c> +
/// <c>VehicleCruiseParameters</c>,让<b>客户端本地 AI</b> 把它开走。
///
/// 为什么换这条管线(两轮证伪后的结论):
/// ① 服务端没有「别把车提升成 GO」的有效通道(<c>ForceGo</c> 实机无效);
/// ② 客户端那个疑似负责 GO 切换的类 <c>AetherVehicleViewManager</c>,8 个探针 24 分钟
/// 全 0 命中 ⇒ 根本没在用,按它设计的注入器 hook 方案整体作废。
/// ⇒ 与其继续猜,不如用<b>我们已实机验证过</b>的驱动方式:出租车 / 玩家自驾都走
/// <c>SyncAssignVehicleAITask</c>,车真的会自己开、会避障、会等红灯。
///
/// ⛑ 前置条件(由调用方保证):这台车的 <c>AddData</c> 必须是
/// <c>ControlType=2 + LaneHandle=-1</c>,否则客户端的车道模拟会和本地 AI 抢方向盘。
///
/// ⛑ 已知局限:客户端<b>不上报</b> AI 车的坐标 ⇒ 服务端的 <c>car.CruiseX/Z</c> 只是
/// 「上次下发的目标点」,回收/避撞判定全是近似值。
/// </summary>
/// <summary>
/// 挑一串「能往前开」的车道当 Cruise 目标点。
///
/// ⛑⛑ 为什么不能只从「上次目标所在车道」往后规划(2026-10-01 23:2x 实机根因):
/// 车开完第一次目标后,`CruiseLane` 落在那条路径的最后一段上 —— 而我们路网里有
/// <b>大量死路车道</b>(286 个孤岛分量、很多车道没有合格后继)⇒ `PlanRoute` 只给出 1 段,
/// 日志刷成一串 `points=1/1`,<b>每台车每次都拿到同一个目标点</b>
/// (`target=(901,1775) lane=6817` 反复出现)⇒ 车到点就停,再也不走。
///
/// ⇒ 判据:规划出来的段数 < <c>trafficCruiseMinHops</c> 就判「死路口袋」,
/// 改从<b>玩家附近随机挑一条车道</b>重新规划(车本来就在玩家周围 ⇒ 新目标一定可达)。
/// 同时避开 <c>RecentTargetLanes</c> ⇒ 不会好几台车挤向同一个点互撞。
/// </summary>
/// <returns>车道终点串;空 = 实在规划不出来(调用方应跳过这次下发)。</returns>
private static (List<SceneMethods.UxVector3> Chain, int Lane, float X, float Z) PickCruiseRoute(
TrafficSessionState owner, TrafficCar car, AetherSettings cfg, Random rng)
{
var minHops = Math.Max(1, cfg.TrafficCruiseMinHops);
var startLane = car.CruiseLane >= 0 ? car.CruiseLane : car.Drive.Lane;
// ★ 场上其它车的目标点:新目标要跟它们拉开距离,否则两台车会被指到同一个路口挤在一起。
var others = SnapshotOtherTargets(owner, car);
var gapSq = cfg.TrafficCruiseTargetMinGapMeters * cfg.TrafficCruiseTargetMinGapMeters;
bool Spread(float x, float z)
{
if (gapSq <= 0f)
return true;
foreach (var (ox, oz) in others)
{
var dx = ox - x;
var dz = oz - z;
if (dx * dx + dz * dz < gapSq)
return false;
}
return true;
}
var primary = BuildChain(startLane, cfg);
var best = primary;
var upgraded = false;
if (primary.Chain.Count >= minHops
&& !owner.RecentTargetLanes.Contains(primary.Lane)
&& Spread(primary.X, primary.Z))
{
NoteTarget(owner, primary.Lane);
return primary;
}
// —— 死路口袋 / 目标重复:从玩家附近随机挑车道重来,最多试 6 次 ——
for (var attempt = 0; attempt < 6; attempt++)
{
if (!VehicleLaneGraph.TryPickSpawn(owner.PlayerX, owner.PlayerZ, cfg.TrafficSpawnRadiusMeters,
cfg.TrafficSpawnMinPlayerDistanceMeters, rng,
out var cand, out _, out _, out _, out _))
break;
var alt = BuildChain(cand.Lane, cfg);
if (alt.Chain.Count == 0)
continue;
if (alt.Chain.Count > best.Chain.Count)
{
best = alt;
upgraded = true;
}
if (alt.Chain.Count >= minHops
&& !owner.RecentTargetLanes.Contains(alt.Lane)
&& Spread(alt.X, alt.Z))
{
NoteTarget(owner, alt.Lane);
return alt;
}
}
if (upgraded)
NoteTarget(owner, best.Lane);
return best;
}
/// <summary>快照场上其它 Cruise 车的<b>当前目标点</b>(用于新目标的空间去重)。</summary>
private static List<(float X, float Z)> SnapshotOtherTargets(TrafficSessionState owner, TrafficCar self)
{
List<(float X, float Z)> list = [];
lock (owner.SyncRoot)
{
foreach (var other in owner.Cars)
{
if (ReferenceEquals(other, self) || !other.Cruise)
continue;
list.Add((other.CruiseTargetX, other.CruiseTargetZ));
}
}
return list;
}
/// <summary>把这条目标车道记进「最近用过」的滑动窗口(最多 24 条)。</summary>
private static void NoteTarget(TrafficSessionState owner, int lane)
{
owner.RecentTargetLanes.Add(lane);
if (owner.RecentTargetLanes.Count > 24)
owner.RecentTargetLanes.RemoveAt(0);
}
/// <summary>从一条车道往后规划,把每段终点收集成点串。起点拿不到几何 ⇒ 返回空串。</summary>
private static (List<SceneMethods.UxVector3> Chain, int Lane, float X, float Z) BuildChain(
int startLane, AetherSettings cfg)
{
List<SceneMethods.UxVector3> chain = [];
if (!VehicleLaneGraph.TryLaneGeometry(startLane, out _, out _, out _,
out var tx, out _, out var tz, out _))
return (chain, startLane, 0f, 0f);
var lastLane = startLane;
foreach (var lane in VehicleLaneGraph.PlanRoute(startLane, Math.Max(1, cfg.TrafficCruiseHops),
cfg.TrafficMaxLaneHopMeters))
{
if (!VehicleLaneGraph.TryLaneGeometry(lane, out _, out _, out _,
out var ex, out var ey, out var ez, out _))
continue;
chain.Add(new SceneMethods.UxVector3(ex, ey, ez));
lastLane = lane;
tx = ex;
tz = ez;
}
return (chain, lastLane, tx, tz);
}
private static async Task AssignTrafficCruiseAsync(
TcpSession session, TrafficSessionState owner, TrafficCar car, AetherSettings cfg,
Random rng, CancellationToken token)
{
// ① 旧任务必须先 Abort(4):不撤的话客户端同时挂两个任务,车会照旧目标跑完再换
// (观感就是「拖了半天才改道」)。第一次下发时 token=0,跳过。
if (car.CruiseToken != 0)
{
await AcknowledgeAutoDriveTaskAsync(session, car.Id, car.CruiseToken, 4);
// ★ 顺手把位置账本推进到「上一个目标点」:车这时应该已经开到那儿(或接近)。
// Cruise 车没有位置上报,这是服务端能拿到的最好估算 ——
// 骇入的 30m 判定和摘出时的定格位置都读它。不更新的话 record 会一直停在出生点。
if (car.Record is not null)
car.Record.Position = new Vec3(car.CruiseX, car.Record.Position.Y, car.CruiseZ);
}
// ② 规划目标点(带死路兜底 + 目标去重,见 PickCruiseRoute)。
var (chain, lastLane, tx, tz) = PickCruiseRoute(owner, car, cfg, rng);
if (chain.Count == 0)
{
// 实在规划不出来 ⇒ 放弃这次下发:车停在原地,等 trafficCruiseMaxLifeSec 回收。
// (不退回服务端车道模拟:那台车的 AddData 已经是 ControlType=2,车道模拟已被关掉。)
session.Log.Warn($"[TRAFFIC] cruise skipped id={car.Id}: no route from lane={car.CruiseLane}");
return;
}
// ③ 只给终点(默认)⇒ 客户端自己沿真实路网寻路;给整串 ⇒ 逐点直线(会切内弯)。
var points = chain;
if (cfg.TrafficCruiseSingleTarget)
points = [chain[^1]]; // 只给终点 ⇒ 客户端自己沿真实路网寻路
// ★ 每台车的固定速度倍率(见 TrafficCruiseSpeedJitter):同速会永远并排,异速会自己拉开。
var speed = Math.Max(1f, car.TargetSpeed * (car.CruiseSpeedScale > 0f ? car.CruiseSpeedScale : 1f));
// ★ 绝对上限(见 TrafficCruiseMaxSpeedKmh):主干道限速 60km/h 那 4.5% 会把车拉到 69km/h,
// 压回城市速度。0 = 不限。
if (cfg.TrafficCruiseMaxSpeedKmh > 0f)
speed = Math.Min(speed, cfg.TrafficCruiseMaxSpeedKmh / 3.6f);
var taskToken = unchecked((ulong)IdCounterStore.Next(VehicleTokenCounter, VehicleTokenFloor));
var packet = UxSerializer.Serialize(
new GameMethods.AssignVehicleControl4229938<GameMethods.VehicleCruiseParameters4229938>
{
VehicleUId = car.Id,
Parameters = new GameMethods.VehicleCruiseParameters4229938
{
TargetPointList = points,
CruiseType = 0, // GoToOneTarget
Count = points.Count,
ConfigFlags = 4, // CF_SlowDownWhenArriveTarget
PathFindFlags = 0, // 正向,按道路方向
MaxSpeed = speed,
ArrivalDistance = VehicleNavCfg.CruiseArrivalMeters,
// 捐赠端显式 false:true 会让车按「离终点还有多远」压速度,MaxSpeed 就不准了。
AdaptSpeedToTargetDistance = false,
Base = new GameMethods.VehicleTaskTail4229938
{
Token = taskToken,
DefaultSpeed = speed,
// 与自驾同一套:1|2|8|16|32|128|16384(会绕开静止车辆/行人/物件、会等红灯)。
DrivingFlags = VehicleNavCfg.CruiseDrivingFlags,
},
},
});
// ④ 主控权交给服务端:不给的话玩家的方向盘输入会盖掉 AI 任务。
await session.NotifyAsync(MethodId.SyncChangeVehicleController, UxSerializer.Serialize(
new GameMethods.SyncChangeVehicleController4229938
{
vehicleInstanceId = car.Id,
controllerPid = Profile.PlayerPid,
}), token).ConfigureAwait(false);
await session.NotifyAsync(MethodId.SyncAssignVehicleAITask, packet, token).ConfigureAwait(false);
// ⑤ ★★ 主动确认一次 Running(1)。
// 客户端「keeps its task Pending until SyncChangeVehicleAITaskState acknowledges the
// SAME vehicle and task token」⇒ 不确认 = 任务永远 Pending = 车一动不动
// (09-28 自驾踩过同一个坑)。
await AcknowledgeAutoDriveTaskAsync(session, car.Id, taskToken, 1);
car.CruiseToken = taskToken;
car.CruiseLane = lastLane;
// ⛑⛑ 只更新**目标**,不更新估算位置 —— 估算位置由 AdvanceCruiseEstimates 按速度朝目标推进。
// 直接把估算位置设成目标点的话,目标一旦超出回收半径,车会「刚出生就被判出界回收」
// (2026-10-01 23:2x 实机:despawn … after=0m alive=0s)。
car.CruiseTargetX = tx;
car.CruiseTargetZ = tz;
car.CruiseStatus = 0;
car.CruiseAssignedTicks = Environment.TickCount64;
car.CruiseRefreshTicks = Environment.TickCount64
+ (long)Math.Max(1000f, cfg.TrafficCruiseRefreshSec * 1000f);
session.Log.Info($"[TRAFFIC] cruise assigned id={car.Id} token={taskToken} " +
$"points={points.Count}/{chain.Count} target=({tx:F0},{tz:F0}) lane={lastLane} " +
$"est=({car.CruiseX:F0},{car.CruiseZ:F0}) " +
$"speed={speed * 3.6f:0.#}km/h bytes={packet.Length}");
}
/// <summary>
/// Cruise 车的<b>推算位置</b>推进(dead reckoning):按当前速度朝目标点走,到点即停。
///
/// ⛑ 为什么必须推:客户端不上报 AI 车的坐标,而回收/避撞/骇入判定全靠这个估算值。
/// · 不推(一直停在出生点)⇒ 车开走了也永远判成「在身边」,老车占着名额;
/// · 直接跳到目标 ⇒ 目标一超出回收半径,车刚出生就被判出界回收(23:2x 实机踩过)。
/// </summary>
private static void AdvanceCruiseEstimates(TrafficSessionState owner, float dt)
{
lock (owner.SyncRoot)
{
foreach (var car in owner.Cars)
{
if (!car.Cruise)
continue;
var dx = car.CruiseTargetX - car.CruiseX;
var dz = car.CruiseTargetZ - car.CruiseZ;
var d2 = dx * dx + dz * dz;
if (d2 < 0.25f)
continue;
var d = MathF.Sqrt(d2);
var step = Math.Max(1f, car.TargetSpeed) * dt;
if (step >= d)
{
car.CruiseX = car.CruiseTargetX;
car.CruiseZ = car.CruiseTargetZ;
continue;
}
car.CruiseX += dx / d * step;
car.CruiseZ += dz / d * step;
}
}
}
/// <summary>
/// 客户端回报 AI 任务状态时(<c>AskUpdateVehicleAITaskStatus</c>)在车流这边认领一下。
///
/// ★ 为什么要接:车开到目标点会自己停住,而服务端<b>收不到到达通知</b> ⇒ 只能干等
/// <c>trafficCruiseRefreshSec</c>(默认 30s)。日志里其实早就有这些包,只是被
/// `[VEHICLE-HACK] ignored stale/duplicate/unowned AI status … status=2` 丢掉了。
/// ⇒ 这里认领后把刷新时刻归零,下一 tick 立刻续发目标点,车不会在原地杵半分钟。
///
/// ⛑ 只置标志、不在 handler 里发包(热路径上不能同步 await 一堆 Notify)。
/// </summary>
internal static bool TryApplyTrafficCruiseStatus(TcpSession session, ulong token, byte status)
{
if (token == 0 || !session.Items.TryGetValue(TrafficKey, out var boxed)
|| boxed is not TrafficSessionState owner)
return false;
lock (owner.SyncRoot)
{
foreach (var car in owner.Cars)
{
if (!car.Cruise || car.CruiseToken != token)
continue;
car.CruiseStatus = status;
// 2 Success / 3 Pause / 4 Abort / 5 Override = 这条任务已经不再推进了 ⇒ 立刻续发。
if (status is 2 or 3 or 4 or 5)
car.CruiseRefreshTicks = 0;
return true;
}
}
return false;
}
/// <summary>
/// 方案 A 的续驶:给到期(<c>TrafficCruiseRefreshSec</c>)的 Cruise 车重发目标点。
///
/// ⛑ 为什么还要定时重发兜底:客户端不一定回状态(被顶住/卡住时可能什么都不发),
/// 所以「定时器」和「收到终止状态立刻续发」是双保险 ——
/// 前者兜住「客户端沉默」,后者消灭「到了还要干等 30 秒」的空窗。
/// 重发对两种情形都成立:没到 ⇒ 新目标仍在它前方,等价续驶;到了 ⇒ 从脚下继续往前。
/// </summary>
private static async Task RefreshTrafficCruiseAsync(
TcpSession session, TrafficSessionState owner, AetherSettings cfg, Random rng,
CancellationToken token)
{
if (!cfg.TrafficCruiseDrive)
return;
List<TrafficCar>? due = null;
var now = Environment.TickCount64;
var leadSq = cfg.TrafficCruiseLeadMeters * cfg.TrafficCruiseLeadMeters;
lock (owner.SyncRoot)
{
foreach (var car in owner.Cars)
{
if (!car.Cruise)
continue;
// ⛑ CruiseRefreshTicks <= 0 = 「客户端刚回报任务结束」⇒ 立刻到期。
var isDue = car.CruiseRefreshTicks <= 0 || now >= car.CruiseRefreshTicks;
// ★ 快到终点就提前续发(见 TrafficCruiseLeadMeters):
// 车到点是会**刹停**的,停在大马路上 ⇒ 后车顶上来 ⇒ 挤成一坨。
// 所以别等它停,离终点还有 lead 米时就把下一个目标发下去。
// ⛑ 只在上一次下发 3 秒之后才判,避免「刚发就又到期」刷包。
if (!isDue && car.CruiseStatus == 1 && leadSq > 0f
&& now - car.CruiseAssignedTicks > 3000)
{
var dx = car.CruiseTargetX - car.CruiseX;
var dz = car.CruiseTargetZ - car.CruiseZ;
if (dx * dx + dz * dz <= leadSq)
isDue = true;
}
if (!isDue)
continue;
(due ??= []).Add(car);
}
}
if (due is null)
return;
foreach (var car in due)
await AssignTrafficCruiseAsync(session, owner, car, cfg, rng, token).ConfigureAwait(false);
}
/// <summary>
/// 把一台车流车从车道流里摘出来,交还给玩家(骇入 / 上车)。幂等:非车流车直接返回。
///
/// ★★ 为什么必须摘:车道流每 50ms 用 <c>SyncAetherAIVehicleLaneDatas</c> 覆盖里程,
/// 而骇入下发的是 <c>SyncAssignVehicleAITask</c>(RaidVehicle 通道)—— 两套驱动源。
/// 同时作用时车会「刚加速就被拽回原地」,表现为骇入「能显示但没效果」。
/// 所以玩家接管的第一件事就是停掉车道流。
///
/// ★ 摘出的手段:重发一条 <c>SyncAetherAIVehicleAddData</c>,
/// <c>ControlType=2(RaidVehicle)</c> + <c>LaneHandle=-1</c>。
/// 这与 <c>RegisterHackVehicleAsync()</c> 的做法一致,其注释原文:
/// "ControlType=RaidVehicle avoids activating lane simulation"。
/// </summary>
internal static async Task DetachTrafficVehicleFromLaneAsync(TcpSession session, VehicleRecord vehicle)
{
if (!vehicle.IsTraffic)
return;
// ① 从车流列表移除 ⇒ 后续 tick 不再给它推 LaneDatas。
// 只从 Cars 移除、不注销 runtime.Vehicles —— 摘出后它仍是台可骇的车。
ulong cruiseToken = 0;
if (session.Items.TryGetValue(TrafficKey, out var boxed) && boxed is TrafficSessionState owner)
{
lock (owner.SyncRoot)
{
for (var i = owner.Cars.Count - 1; i >= 0; i--)
{
if (owner.Cars[i].Id != vehicle.EntityId)
continue;
// ★ Cruise 车还挂着一个客户端本地 AI 任务:不撤掉的话玩家上车后
// 两套驱动源会同时打方向盘(09-30 骇入同一个坑)。
cruiseToken = owner.Cars[i].CruiseToken;
owner.Cars[i].Record = null;
owner.Cars.RemoveAt(i);
}
}
}
// ② 切到 RaidVehicle:关掉车道模拟,位置定格在当前这一刻。
await session.NotifyAsync(MethodId.SyncAetherAIVehicleAddData,
UxSerializer.Serialize(new GameMethods.ClientVehicleInitData4229938
{
VehicleConfigId = vehicle.ConfigId,
VehicleColorPartId = VehicleSpawnCatalog4229938.PickColorPartId(vehicle.ConfigId, Random.Shared),
Id = vehicle.EntityId,
Position = new SceneMethods.UxVector3(vehicle.Position.X, vehicle.Position.Y, vehicle.Position.Z),
Facing = vehicle.Facing,
EulerAngles = new SceneMethods.UxVector3(0f, vehicle.Facing, 0f),
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000.0,
LaneHandle = -1,
DistanceAlongLane = 0f,
ControlType = 2,
Speed = 0f,
RandomFraction = Random.Shared.NextSingle(),
}), CancellationToken.None);
if (cruiseToken != 0)
{
await AcknowledgeAutoDriveTaskAsync(session, vehicle.EntityId, cruiseToken, 4);
session.Log.Info($"[TRAFFIC] cruise task aborted on handover entity={vehicle.EntityId} token={cruiseToken}");
}
vehicle.IsTraffic = false;
session.Log.Info($"[TRAFFIC] detached from lane flow entity={vehicle.EntityId} " +
$"at ({vehicle.Position.X:F1},{vehicle.Position.Y:F1},{vehicle.Position.Z:F1}) controlType=2 lane=-1");
}
/// <summary>
/// 每 tick 计算纵向动力学(跟车 / 交叉让行 / 加减速)→ 推进里程 → 批量下发。
/// 走到死路的车直接回收。
///
/// ★★ 为什么服务端必须自己做跟车:客户端的 <c>AetherVehicleDetector</c>(前方 trigger)
/// 只对**近距离 GO 车**生效,实测它只避让玩家/行人(<c>AetherVehicleStopReason.Block</c>),
/// 车与车之间照样对撞。客户端那边没有「远程车流互避」逻辑 —— 它只按服务端给的点插值。
/// 所以纵向决策只能放服务端:这里是简化 IDM + 最近接近点(TTC)让行。
/// </summary>
private static async Task PushTrafficLaneDataAsync(
TcpSession session, TrafficSessionState owner, float dt, double stamp,
AetherSettings cfg, Random rng, CancellationToken token)
{
List<GameMethods.ClientVehicleLaneData4229938> batch = [];
List<TrafficCar>? dead = null;
var hops = 0;
var replans = 0;
var wideGap = 0;
var worstGap = 0f;
lock (owner.SyncRoot)
{
// ★ 方案 A(2026-10-01):走 Cruise 的车由客户端本地 AI 开,**不参与**服务端车道模拟
// (两套驱动源会互相拽 —— 「刚加速就被拽回原地」)。
// 它们仍留在 owner.Cars 里,靠估算位置 + trafficCruiseMaxLifeSec 回收。
List<TrafficCar> sim = [];
foreach (var c in owner.Cars)
if (!c.Cruise)
sim.Add(c);
var n = sim.Count;
if (n == 0)
return;
// ① 快照:位置 / 朝向单位向量 / 当前速度 / 本车道限速
var px = new float[n];
var pz = new float[n];
var fx = new float[n];
var fz = new float[n];
var sp = new float[n];
var tgt = new float[n];
var remain = new float[n];
var boost = new bool[n];
// ★ 诊断计数(2026-09-28):定量回答「车为什么慢下来」。
// 没有这个就只能靠猜 —— 之前连续猜错两次(先是"坐在车里",再是"距离30m")。
var followHits = 0; // 被前车限制的 (i,j) 次数
var yieldHits = 0; // 被交叉让行限制的次数
var statRedGiveUp = 0; // 等红灯超过上限被放行的次数
var statRedConflict = 0; // 真有冲突方、走相位钟的次数(感应式放行下应该很小)
for (var i = 0; i < n; i++)
{
var car = sim[i];
VehicleLaneGraph.PositionOf(car.Drive, out px[i], out _, out pz[i]);
// FacingOf 是度:0 = +Z,顺时针为正 ⇒ 单位向量 (sin, cos)。
var rad = VehicleLaneGraph.FacingOf(car.Drive) * MathF.PI / 180f;
fx[i] = MathF.Sin(rad);
fz[i] = MathF.Cos(rad);
sp[i] = car.Speed;
tgt[i] = VehicleLaneGraph.LimitOf(car.Drive) * cfg.TrafficSpeedScale;
remain[i] = VehicleLaneGraph.RemainOf(car.Drive);
boost[i] = car.BoostSeconds > 0f;
}
// ①b 红绿灯相位(服务端自建)。
// ⛑️ 客户端**不会**替我们拦车:放行关系(哪个相位放哪条车道)在它自己的
// StaticIntersectionDataBlob 里,我们给的 ZoneStorageDataHandle 读不出内容。
// ⇒ 服务端查表算放行,红灯方向的车把目标速度压到 0。
// 代价:灯柱不亮(客户端 GPU 蒙皮资源),但让行行为是原生的。
var sigOn = cfg.TrafficSignalEnabled && JunctionSignals.Available;
var signalMs = Environment.TickCount64;
var redLevel = new byte[n]; // 0=无 1=预减速 2=刹停
// ★★ 感应式放行(2026-09-29):只有「真的有别的方向来车」时才按相位让行。
// 背景:我们把路网 6861/14171 ≈ 48% 的车道都装了灯,而场上只有 8 台车撒在 620m 半径里
// ⇒ 绝大多数路口**根本没有冲突方**,却照样把车按一半概率拦下
// (实测 spd avg 掉到 1.9、stop=5/8、8 秒上限一次都没触发)。
// 判据:某路口「有车所在的相位组」只有一组 ⇒ 无冲突 ⇒ 全绿;≥2 组才走相位钟。
var groupsAt = new Dictionary<int, int>(); // junctionId -> 相位组位掩码
var demandOnly = cfg.TrafficSignalYieldToTrafficOnly;
if (sigOn && demandOnly)
{
for (var i = 0; i < n; i++)
{
if (!JunctionSignals.TryGet(sim[i].Drive.Lane, out var ln))
continue;
groupsAt.TryGetValue(ln.JunctionId, out var mask);
if (ln.GroupIndex < 32)
groupsAt[ln.JunctionId] = mask | (1 << ln.GroupIndex);
}
}
for (var i = 0; i < n; i++)
{
if (!sigOn || boost[i])
continue;
if (!JunctionSignals.TryGet(sim[i].Drive.Lane, out var line))
continue;
if (JunctionSignals.IsGreen(line, signalMs, cfg.TrafficSignalGreenSec,
cfg.TrafficSignalClearSec))
continue;
if (demandOnly)
{
groupsAt.TryGetValue(line.JunctionId, out var mask);
// 除我这一组以外没有别人 ⇒ 没冲突,放行(不再白等一个空相位)。
if ((mask & ~(1 << line.GroupIndex)) == 0)
continue;
statRedConflict++;
}
// ⛑ 等太久就放行。周期 = 组数 × (绿 + 清空),腿多的路口周期长(4 组 44s),
// 单靠调 green/clear 给不出统一上界 ⇒ 用单车等待时间兜底。
if (sim[i].RedWaitSeconds >= cfg.TrafficSignalMaxWaitSec)
{
statRedGiveUp++;
continue;
}
// 还有 1.2 秒内就变绿 ⇒ 不值得刹停(避免「停一下马上又起步」的抽搐),只压速。
var waitMs = JunctionSignals.MsToNextGreen(line, signalMs,
cfg.TrafficSignalGreenSec, cfg.TrafficSignalClearSec);
// ⛑️ 必须给 brake 一个下限,否则「停一下又蠕行」:
// 减速过程中 remain 与 brake 的下降速率都是 sp(d(brake)/dt = sp/decel·(−decel) = −sp),
// 二者差值恒定 ⇒ 车恰好停在 remain≈margin 处;此时 brake=margin,
// `remain < brake` 变成 false ⇒ 起步 ⇒ 又刹 ⇒ 抽搐。加下限就锁死了。
var decelNow = MathF.Max(0.5f, cfg.TrafficDecelMps2);
var brake = MathF.Max(sp[i] * sp[i] / (2f * decelNow) + cfg.TrafficSignalStopMarginMeters,
cfg.TrafficSignalStopMarginMeters + 3.5f);
if (remain[i] < brake)
redLevel[i] = waitMs <= 1200 ? (byte)1 : (byte)2;
else if (remain[i] < brake + 12f)
redLevel[i] = 1;
}
// ② 跟车:同向 + 同一条「管道」里的最近前车 → 简化 IDM
var rangeSq = cfg.TrafficFollowRangeMeters * cfg.TrafficFollowRangeMeters;
var minGap = cfg.TrafficFollowMinGapMeters;
var halfWidth = cfg.TrafficFollowWidthMeters;
const float carLength = 4.5f;
for (var i = 0; i < n; i++)
{
if (boost[i])
continue; // 僵局解除中:硬冲,不再让任何人
for (var j = 0; j < n; j++)
{
if (i == j)
continue;
var dx = px[j] - px[i];
var dz = pz[j] - pz[i];
var d2 = dx * dx + dz * dz;
if (d2 > rangeSq || d2 < 1e-4f)
continue;
var fwd = dx * fx[i] + dz * fz[i]; // 前车在我前方多远
if (fwd <= 0f)
continue;
var lat = MathF.Abs(dz * fx[i] - dx * fz[i]); // 侧向偏移
if (lat > halfWidth)
continue; // 不在同一条管道里
if (fx[i] * fx[j] + fz[i] * fz[j] < 0.5f)
continue; // 夹角 > 60°:交叉/对向,交给 ③
var gap = fwd - carLength;
if (gap <= 0.2f)
{
tgt[i] = 0f; // 贴上了
break;
}
var allowed = sp[j] + (gap - minGap) * 0.8f;
if (allowed < 0f)
allowed = 0f;
if (allowed < tgt[i])
{
tgt[i] = allowed;
followHits++;
}
}
}
// ③ 交叉让行:最近接近点(closest point of approach)落在车宽内 → 离冲突点远的一方让
var horizon = cfg.TrafficConflictHorizonSec;
var radius = cfg.TrafficConflictRadiusMeters;
for (var i = 0; i < n; i++)
for (var j = i + 1; j < n; j++)
{
// 两台都停死了就别再判让行,否则互等成死锁(各自重新起步后会再判一次)。
if (sp[i] < 0.3f && sp[j] < 0.3f)
continue;
var rx = px[j] - px[i];
var rz = pz[j] - pz[i];
// ★★ 横向过滤(关键):只有两车在**彼此的行进方向上都是正对着**才算会撞。
// 少了这一步,双向相邻车道(横向间距约 3.5m)会被误判成冲突,
// 于是对向车流互相让行 —— 就是「车开到路口停、对面车也停、然后都不走」。
var latI = MathF.Abs(rz * fx[i] - rx * fz[i]); // j 相对 i 的横向偏移
var latJ = MathF.Abs(rx * fz[j] - rz * fx[j]); // i 相对 j 的横向偏移
if (latI > cfg.TrafficConflictWidthMeters && latJ > cfg.TrafficConflictWidthMeters)
continue; // 只是并行经过
var vx = fx[j] * sp[j] - fx[i] * sp[i];
var vz = fz[j] * sp[j] - fz[i] * sp[i];
var vv = vx * vx + vz * vz;
if (vv < 0.25f)
continue; // 几乎相对静止
var t = -(rx * vx + rz * vz) / vv;
if (t < 0.15f || t > horizon)
continue;
var cx = rx + vx * t;
var cz = rz + vz * t;
if (cx * cx + cz * cz > radius * radius)
continue; // 最近点还隔得开
// 冲突点用 i 的未来位置近似;谁离它远,谁让。
var hx = px[i] + fx[i] * sp[i] * t;
var hz = pz[i] + fz[i] * sp[i] * t;
var di = MathF.Sqrt((hx - px[i]) * (hx - px[i]) + (hz - pz[i]) * (hz - pz[i]));
var dj = MathF.Sqrt((hx - px[j]) * (hx - px[j]) + (hz - pz[j]) * (hz - pz[j]));
var yieldIdx = di >= dj ? i : j;
if (!boost[yieldIdx])
{
// 让到**低速**而不是归零:归零会让对面也进入让行判定,来回抖动成死锁。
tgt[yieldIdx] = MathF.Min(tgt[yieldIdx], cfg.TrafficYieldSpeedMps);
yieldHits++;
}
}
// ④ 加减速限制 → 推进里程
var accel = cfg.TrafficAccelMps2;
var decel = cfg.TrafficDecelMps2;
var statSum = 0f;
var statMin = float.MaxValue;
var statMax = 0f;
var statStop = 0;
var statBoost = 0;
var statJunc = 0;
var statRedStop = 0;
var statRedSlow = 0;
var statLimited = 0;
var statTravel = 0.0;
for (var i = n - 1; i >= 0; i--)
{
var car = sim[i];
statTravel += car.TraveledMeters;
// ★ 僵局解除:连续堵住超过阈值就进入「硬冲」几秒(低速强行通过)。
// 没有这个的话,路口互相让行会形成环形等待 —— A 让 B、B 让 C、C 让 A,
// 全场速度归零后再也起不来,就是你看到的「路口堵死不动」。
// ★ 滞回:只有**真的跑起来了**(> 1.5 m/s)才清零。
// 之前写成「> 0.4 就清零」,结果路口两车在 0~0.5 之间反复抖动
// (减速→0→重新起步→又被让行→再减速),计数永远攒不满 2 秒,
// 硬冲一次都没触发过 —— 车就那么一直杵着。
if (sp[i] < 0.4f)
{
// ⛑️ 红灯前排队**不算僵局**:否则等灯 2 秒就触发硬冲,红灯直接被绕过。
if (redLevel[i] != 2)
car.StallSeconds += dt;
else
car.StallSeconds = 0f;
}
else if (sp[i] > 1.5f)
car.StallSeconds = 0f;
if (car.StallSeconds >= cfg.TrafficStallReleaseSec)
{
car.StallSeconds = 0f;
car.BoostSeconds = cfg.TrafficStallBoostSec;
}
var boosting = car.BoostSeconds > 0f;
if (boosting)
car.BoostSeconds = MathF.Max(0f, car.BoostSeconds - dt);
var limit = VehicleLaneGraph.LimitOf(car.Drive) * cfg.TrafficSpeedScale;
var desired = boosting
? MathF.Min(limit, cfg.TrafficStallBoostSpeedMps)
: MathF.Max(0f, tgt[i]);
// 接近车道末端(= 路口/转弯)提前减速,给让行判定留出反应时间。
if (!boosting && remain[i] < cfg.TrafficJunctionSlowMeters)
desired = MathF.Min(desired, limit * cfg.TrafficJunctionSpeedFactor);
// 红灯:刹停 / 预减速(压过上面的普通路口减速)。
// remain 是「到本车道末端还有多远」,而末端就落在路口停止线上 ⇒ 用不着额外的停止线数据。
if (redLevel[i] == 2)
{
desired = 0f;
statRedStop++;
car.RedWaitSeconds += dt;
}
else
{
car.RedWaitSeconds = 0f;
if (redLevel[i] == 1)
{
desired = MathF.Min(desired, limit * 0.45f);
statRedSlow++;
}
}
var dv = desired - sp[i];
var maxDv = (dv >= 0 ? accel : decel) * dt;
if (MathF.Abs(dv) > maxDv)
dv = dv >= 0 ? maxDv : -maxDv;
sp[i] = MathF.Max(0f, sp[i] + dv);
car.Speed = sp[i];
car.TargetSpeed = desired;
// ★ 健康度采样
statSum += sp[i];
if (sp[i] < statMin) statMin = sp[i];
if (sp[i] > statMax) statMax = sp[i];
if (sp[i] < 0.4f) statStop++;
if (boosting) statBoost++;
if (remain[i] < cfg.TrafficJunctionSlowMeters) statJunc++;
if (desired < limit * 0.95f) statLimited++;
var meters = sp[i] * dt;
car.TraveledMeters += meters;
car.AliveSeconds += dt;
// ★ 按预规划路径走(有路径时),否则退回随机挑后继。
var preferred = -1;
if (car.Route is { Count: > 0 } rr && car.RouteIdx < rr.Count)
preferred = rr[car.RouteIdx];
if (!VehicleLaneGraph.Advance(ref car.Drive, meters, rng, cfg.TrafficMaxLaneGapMeters,
out var hop, cfg.TrafficMaxLaneHopMeters, preferred))
{
// 开到头了(路网死路,或下一条车道接缝超过硬上限 —— 跳过去就是瞬移),
// 回收后由 Reconcile 补新的。
(dead ??= []).Add(car);
owner.Cars.Remove(car);
continue;
}
if (hop is { } h)
{
hops++;
car.Hops++;
if (h.Gap > cfg.TrafficMaxLaneGapMeters)
wideGap++;
if (h.Gap > worstGap)
worstGap = h.Gap;
// ★ 路径推进(带自愈)。
// ⛑️ Advance 内部是 while 循环,一次调用可能连走好几段车道,
// 而这里每次换道只 ++ 一次 ⇒ RouteIdx 会和车的实际车道脱节,
// preferred 不再命中 ⇒ 退回随机挑后继 ⇒ 撞死路就在近处停下。
// 所以这里拿 Drive.Lane 去比对:在路径上就前进一格,
// 脱节或走到末尾就立刻重新规划。
if (car.Route is { Count: > 0 } route)
{
var onRoute = car.RouteIdx < route.Count && car.Drive.Lane == route[car.RouteIdx];
if (onRoute)
car.RouteIdx++;
if (!onRoute || car.RouteIdx >= route.Count)
{
car.Route = cfg.TrafficRouteHops > 0
? VehicleLaneGraph.PlanRoute(car.Drive.Lane, cfg.TrafficRouteHops,
cfg.TrafficMaxLaneHopMeters)
: null;
car.RouteIdx = 0;
replans++;
}
}
// 过弯掉速:接缝大 = 真转弯/路口,下一帧从低速重新加速(起步/减速过程)。
if (h.Gap > 2f)
car.Speed = MathF.Max(car.Speed * 0.8f, desired * 0.3f);
}
// ★ Status 恒为 Normal(0)。
// 曾经在换道那一帧发 Teleport(4),那是错的:沿 ZoneGraph 后继前进是**正常的
// 车道衔接**(首尾相接,客户端 ZoneGraph 里本来就连着),不是瞬移。
// Teleport 会让客户端硬跳到新位置并中断插值 —— 正是「瞬移一段然后停下」的来源。
batch.Add(new GameMethods.ClientVehicleLaneData4229938
{
Id = car.Id,
LaneHandle = car.Drive.Lane,
DistanceAlongLane = car.Drive.Distance,
Status = 0,
});
// ★ 把当前位置同步进 VehicleRecord(车流车是移动的,record 不能停在出生点)。
// 两处强依赖:CanRushVehicle() 用它算 30m 距离判定,
// BuildVehicleControlPacket() 用它和 Facing 组装 SyncAssignVehicleAITask 包。
// 不更新的话,车开走之后骇入会一直判「超出30米」而失败。
var r = car.Record;
if (r is not null)
{
VehicleLaneGraph.PositionOf(car.Drive, out var vx, out var vy, out var vz);
r.Position = new Vec3(vx, vy, vz);
r.Facing = VehicleLaneGraph.FacingOf(car.Drive);
}
}
// ★ 车流健康度诊断(每 2 秒一行):定量回答「车为什么慢下来」,别再靠猜。
// stop 高 = 有车停死;follow 高 = 被前车压制;yield 高 = 交叉让行触发过多;
// juncSlow 高 = 车道太短、车长期处于路口半速段;limited 高 = 目标速度被普遍压低。
var nowStat = Environment.TickCount64;
if (n > 0 && nowStat - owner.LastStatTicks >= 2000)
{
owner.LastStatTicks = nowStat;
var dtAvg = owner.TickSamples > 0 ? owner.TickSumMs / owner.TickSamples : 0.0;
session.Log.Info($"[TRAFFIC-STAT] n={n} spd avg={statSum / n:F1} min={statMin:F1} max={statMax:F1} " +
$"stop={statStop} boost={statBoost} juncSlow={statJunc} limited={statLimited} " +
$"follow={followHits} yield={yieldHits} " +
// redStop = 正刹在红灯前;redSlow = 红灯预减速(含「马上变绿不值得停」)
// redGive = 等超过 trafficSignalMaxWaitSec 直接放行(红灯等待的统一上界)
$"red(stop={statRedStop} slow={statRedSlow} give={statRedGiveUp} conflict={statRedConflict}) " +
// 场上这些车平均各自行驶了多少米 —— 「开多远就没了」的直接读数。
$"travel avg={statTravel / n:F0}m " +
$"dt avg={dtAvg:F1}ms max={owner.TickMaxMs:F1}ms samples={owner.TickSamples}");
owner.TickSamples = 0;
owner.TickSumMs = 0.0;
owner.TickMaxMs = 0.0;
}
}
if (batch.Count == 0)
return;
// 2026-09-25 节流:车道数据原本**每 tick 一包**(trafficTickMs=50 ⇒ 20 Hz),
// 实测 14-16 包/秒、全会话 13991 包 / 2.07 MB —— S2C 总字节的 41%,是最大单项。
// 客户端拿到 (lane, distance, timestamp) 后自己沿车道插值推进,服务端这一路只是**对时**,
// 所以把"推进里程"和"下发"解耦:物理照旧每 tick 走,网络包降到每
// TrafficLanePushIntervalMs 一次。⛑️ 只包住 NotifyAsync —— 下面的 dead 回收
// (SyncAetherAIVehicleRemove)必须照常跑,节流不能顺手把车漏删了。
if (ShouldPushLaneData(owner))
await session.NotifyAsync(MethodId.SyncAetherAIVehicleLaneDatas,
UxSerializer.Serialize(new GameMethods.SyncAetherAIVehicleLaneDatas4229938
{
Data = batch,
LaneChangeData = [],
TimeStamp = stamp,
}), token).ConfigureAwait(false);
if (dead is not null)
{
var vehRuntime = GetVehicleRuntimeState(session);
foreach (var car in dead)
{
await RemoveTrafficVehicleAsync(session, vehRuntime, car.Id, token).ConfigureAwait(false);
// ★ 区分两种「开到头」(2026-09-29):
// ① 链接表里真没有后继(Next.Length==0)= 真死路;
// ② 有后继但接缝 > trafficMaxLaneHopMeters = 宽接缝,我们不敢硬跳(会瞬移)。
// 以前两种都打成 "lane dead-end",把 ② 误报成 ①,误导排查方向。
var hasNext = VehicleLaneGraph.NextOf(car.Drive.Lane).Length > 0;
var reason = hasNext
? $"wide seam >{cfg.TrafficMaxLaneHopMeters:F0}m"
: "dead-end (no successor)";
session.Log.Info($"[TRAFFIC] recycle id={car.Id} ({reason}, lane={car.Drive.Lane}) {CarLife(car)}");
}
}
if (hops > 0)
session.Log.Info($"[TRAFFIC] lane-hop {hops}/{batch.Count} replan={replans} " +
$"wideGap={wideGap} worstGap={worstGap:F1}m");
}
/// <summary>一台车的寿命摘要(回收日志用)—— 用来量化「车开多远就没了」。</summary>
private static string CarLife(TrafficCar car)
=> $"after={car.TraveledMeters:F0}m hops={car.Hops} alive={car.AliveSeconds:F0}s";
/// <summary>
/// 回收一台车流车 —— **必须连 SyncDestroyVehicle 一起发**。
///
/// ★★ 只发 <c>SyncAetherAIVehicleRemove</c> 是不够的(2026-09-28 实测):
/// 那个包只把车从 Aether 车流系统里摘掉,**BaseVehicle 实体仍留在客户端场景里**。
/// 服务端这边已经把它从 <c>Cars</c> 移除、不再推里程,所以 [TRAFFIC-STAT] 看着一切正常
/// (stop=0、avg 10m/s),但客户端那台车就那么杵在原地 ——
/// 用户看到的就是「大部分车停在十字路口」,以及「有些车开着开着变色然后停下」
/// (实体脱离 Aether 驱动后渲染状态异常)。
/// ⇒ 补发 SyncDestroyVehicle 才是真正销毁,与 DestroyVehicleAsync 的做法一致。
/// </summary>
private static async Task RemoveTrafficVehicleAsync(
TcpSession session, VehicleRuntimeState runtime, ulong id, CancellationToken token)
{
await session.NotifyAsync(MethodId.SyncAetherAIVehicleRemove,
UxSerializer.Serialize(new GameMethods.AetherVehicleRemove4229938 { Pid = id }), token)
.ConfigureAwait(false);
await session.NotifyAsync(MethodId.SyncDestroyVehicle,
UxSerializer.Serialize(new GameMethods.DestroyVehicle4229938
{
VehicleEntityId = id,
VehicleDestroyType = 0,
Distance = 0,
DynamicGoId = 0,
}), token).ConfigureAwait(false);
// 注销登记,否则 runtime.Vehicles 会累积幽灵车(骇入 UI 还能选中、下发必然失败)。
lock (runtime.SyncRoot)
runtime.Vehicles.Remove(id);
lock (LiveTrafficLock)
LiveTrafficIds.Remove(id);
}
/// <summary>本会话当前持有的车流车 id。</summary>
private static HashSet<ulong> OwnedIds(TrafficSessionState owner)
{
var set = new HashSet<ulong>();
lock (owner.SyncRoot)
foreach (var c in owner.Cars)
set.Add(c.Id);
return set;
}
/// <summary>
/// 把本会话的车流车**真回收**:逐个发 <c>SyncAetherAIVehicleRemove</c> + <c>SyncDestroyVehicle</c>。
/// 照抄 <c>RetireAmbientPedsAsync</c> 的做法(分块 + 间隔,别一次性连发打爆 socket)。
/// </summary>
internal static async Task RetireTrafficAsync(TcpSession session)
{
if (!session.Items.TryGetValue(TrafficKey, out var existing) || existing is not TrafficSessionState owner)
return;
List<TrafficCar> all;
lock (owner.SyncRoot)
{
all = [.. owner.Cars];
owner.Cars.Clear();
owner.Cts?.Cancel();
}
if (all.Count == 0)
return;
session.Log.Info($"[TRAFFIC] retire all cars count={all.Count}");
var runtime = GetVehicleRuntimeState(session);
for (var i = 0; i < all.Count; i += TrafficRemoveBatchSize)
{
if (i > 0)
await Task.Delay(TrafficRemoveBatchDelayMs, CancellationToken.None);
var end = Math.Min(all.Count, i + TrafficRemoveBatchSize);
for (var j = i; j < end; j++)
{
try
{
await RemoveTrafficVehicleAsync(session, runtime, all[j].Id, CancellationToken.None)
.ConfigureAwait(false);
}
catch (Exception ex)
{
session.Log.Warn($"[TRAFFIC] retire failed id={all[j].Id}: {ex.Message}");
}
}
}
}
/// <summary>
/// 清掉**上一个会话**留下的残留车(本会话不知道它们、但客户端世界里还在)。
/// 判据:进程级 <see cref="LiveTrafficIds"/> 里不属于本会话的那些 id。
/// </summary>
private static async Task RetireStrayTrafficAsync(TcpSession session, TrafficSessionState owner)
{
List<ulong> stray;
// ⛑ 先在外面把本会话的 id 算出来再进 LiveTrafficLock:绝不在持有一把锁时去拿
// 另一把(owner.SyncRoot ⇄ LiveTrafficLock 交叉 = ABBA 死锁)。
var mine = OwnedIds(owner);
lock (LiveTrafficLock)
{
stray = [.. LiveTrafficIds.Where(id => !mine.Contains(id))];
foreach (var id in stray)
LiveTrafficIds.Remove(id);
}
if (stray.Count == 0)
return;
session.Log.Info($"[TRAFFIC] retire stray cars from previous session count={stray.Count}");
var runtime = GetVehicleRuntimeState(session);
for (var i = 0; i < stray.Count; i += TrafficRemoveBatchSize)
{
if (i > 0)
await Task.Delay(TrafficRemoveBatchDelayMs, CancellationToken.None);
var end = Math.Min(stray.Count, i + TrafficRemoveBatchSize);
for (var j = i; j < end; j++)
{
try
{
await RemoveTrafficVehicleAsync(session, runtime, stray[j], CancellationToken.None)
.ConfigureAwait(false);
}
catch (Exception ex)
{
session.Log.Warn($"[TRAFFIC] stray retire failed id={stray[j]}: {ex.Message}");
}
}
}
}
/// <summary>
/// 挑一台「能上路」的车。
///
/// ★ 第一优先是 <see cref="VehicleSpawnCatalog4229938"/>(客户端 MassAI.MassTraffic 的
/// VehicleSpawnConfig 表,InMass+CanDrive 共 54 个车型)。只有在这张表里的 cfgId 才能
/// 建出 VehicleDataEntity —— 否则客户端打
/// <c>VehicleInit_Drop_NoConfig ... not found in VehicleSpawn</c> 并丢弃,车看得到但不会动。
/// fleet 列表只作为该表缺失时的兜底。
/// </summary>
private static uint? PickTrafficConfigId(Random rng)
{
var trafficIds = VehicleSpawnCatalog4229938.TrafficVehicleIds;
if (trafficIds.Length > 0)
return trafficIds[rng.Next(trafficIds.Length)];
var fleet = PrivateServerConfigStore.Current.Gameplay.Vehicles.FleetIds;
if (fleet.Length == 0)
return null;
// 兜底:排除飞机 / 船 / 无模型的测试道具,以及 MilkCar(VehicleType 13)。
// 这个过滤**不足以**筛出合法交通车型(实测 81001030 也会被拒),只在表缺失时顶一下。
for (var attempt = 0; attempt < 8; attempt++)
{
var id = fleet[rng.Next(fleet.Length)];
if (VehicleCatalog4229938.TryGet(id, out var config)
&& config.VehicleType is >= 2 and <= 18
&& config.VehicleType != 13
&& !string.IsNullOrWhiteSpace(config.Model))
return id;
}
return null;
}
}