1910 lines
99 KiB
C#
1910 lines
99 KiB
C#
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;
|
||
}
|
||
}
|