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; /// /// 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,否则客户端不做车道模拟。 /// internal sealed partial class GameRouter { private const string TrafficKey = "client4229938-traffic"; /// /// ★★ 进程级「已下发但还没移除」的车流车 id 全集(2026-09-29 新增)。 /// /// 为什么需要它:车的实体是客户端侧的(SyncSpawnVehicle 建的), /// 会话结束 / 切场景 / 切角色时**服务端这边循环一死就没人再管它们**, /// 而 StopTraffic ①从来没被调用过 ②即使调用也只清本地链表、不发移除包。 /// ⇒ 上一批车会原封不动留在客户端世界里、再也没有里程数据 ⇒ 冻成「杵着不动的车」。 /// 用户看到的就是「切到另一个角色,他身边停着好几辆不动的车」。 /// 有了这个全集,新会话起来时就能把**不属于自己**的残留 id 逐个发 Remove + Destroy 清掉。 /// private static readonly HashSet LiveTrafficIds = []; private static readonly object LiveTrafficLock = new(); /// /// SyncAetherAIVehicleLaneDatas 的最小下发间隔(毫秒)。 /// 物理仍按 trafficTickMs(默认 50ms)推进,只有"把里程告诉客户端"这一步降频。 /// 50 ⇒ 20 Hz(原行为);150 ⇒ ≈6.7 Hz,实测可砍掉约 2/3 的字节。 /// ⛑️ 调大到 300 以上会让远处的车出现可见的"一顿一顿",因为客户端插值窗口被拉长。 /// private const int TrafficLanePushIntervalMs = 150; /// 车道数据节流闸门。到点才返回 true 并记账(见 )。 // 批量移除的分块(照抄 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 算出的目标速度(限速 - 跟车 - 转弯) /// 速度接近 0 的累计秒数(用于僵局解除)。 internal float StallSeconds; // --- 寿命计量(2026-09-29):用来量化「车开多远就停/消失」,别再靠肉眼猜 ------- /// 累计行驶里程(米)。回收/超距时打进日志。 internal float TraveledMeters; /// 累计换道次数。 internal int Hops; /// 存活秒数。 internal float AliveSeconds; /// 出生时刻(Environment.TickCount64)。Cruise 车不进 tick 推进,寿命只能这么算。 internal long SpawnTicks; /// 僵局解除后的「硬冲」剩余秒数:> 0 时无视让行约束,低速强行通过。 internal float BoostSeconds; /// /// 红灯前已经等了的秒数(只在真正被红灯刹停时累加)。 /// 超过 TrafficSignalMaxWaitSec 就放行 —— 给「等红灯」一个统一上界, /// 否则腿数多的路口(4 组周期 44s)会让人觉得车杵在那儿不动。 /// internal float RedWaitSeconds; /// /// 在 VehicleRuntimeState.Vehicles 里对应的登记记录(null = 未登记)。 /// 有了它车才是「可骇入」的:ExecuteVehicleControlAsync 第一步就是 /// runtime.Vehicles.TryGetValue(vehicleId),找不到直接拒绝。 /// 每 tick 会把位置/朝向同步进去(骇入的 30m 距离判定和 AI 任务包都读它)。 /// internal VehicleRecord? Record; /// /// 预规划路径(接下来要走的车道序列,不含当前车道)。null/空 = 退回随机挑后继。 /// 由 VehicleLaneGraph.PlanRoute 生成,走完会自动重新规划。 /// internal List? Route; /// 当前走到 的第几段。 internal int RouteIdx; // --- 2026-10-01 方案 A:Cruise(客户端本地 AI 驾驶)------------------------------------ /// /// true = 这台车交给客户端本地 AI 开(SyncAssignVehicleAITask + Cruise 参数), /// 不参与服务端的车道推进与 LaneDatas 批次(两套驱动源会打架: /// 「刚加速就被拽回原地」)。见配置 TrafficCruiseDrive。 /// internal bool Cruise; /// 当前 Cruise 任务的 token(刷新时要先拿它发 Abort(4))。0 = 还没下发过。 internal ulong CruiseToken; /// 上次真正下发 Cruise 目标的时刻(Environment.TickCount64),提前量判定用。 internal long CruiseAssignedTicks; /// 下次重发目标点的时刻(Environment.TickCount64)。 internal long CruiseRefreshTicks; /// /// ★ 估算位置:朝当前目标按速度推算(dead reckoning)出来的坐标。 /// Cruise 车的位置客户端不上报,服务端只能这么估(回收距离判定 / /// 出生避撞判定 / 骇入距离判定都读它)。推进逻辑见 。 /// ⛑⛑ 别直接把它设成目标点 —— 目标一旦超出回收半径,车会「刚出生就被判出界回收」 /// (2026-10-01 23:2x 实机:`despawn id=… (out of range) after=0m alive=0s`)。 /// internal float CruiseX; internal float CruiseZ; /// 当前目标点(估算位置朝它推进,到达即停住等下一次下发)。 internal float CruiseTargetX; internal float CruiseTargetZ; /// 估算位置所在的车道(= 上一个目标点所属车道),下一次规划从这里往后接。 internal int CruiseLane = -1; /// /// 客户端最近一次回报的 AI 任务状态 /// (0 Pending / 1 Running / 2 Success / 3 Pause / 4 Abort / 5 Override)。 /// 收到 2/3/4/5 就把 归零 ⇒ 立刻续发目标点, /// 不用干等 trafficCruiseRefreshSec。 /// internal byte CruiseStatus; /// /// 这台车**固定**的巡航速度倍率(出生时按 trafficCruiseSpeedJitter 摇一次,全程不变)。 /// ⛑ 为什么每台车要不一样:全都同一速度 ⇒ 两台车一旦挨上就永远并排贴着开 /// (相对速度 0,谁也甩不开谁)。给了差值,快车自然拉开、慢车自然落后。 /// 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 Cars { get; } = []; internal long LastReseedTicks; internal long LastTickTicks; /// 车流健康度诊断([TRAFFIC-STAT])的上次输出时刻。 internal long LastStatTicks; /// 推送节拍采样:[TRAFFIC-STAT] 输出 dt 均值/峰值用,输出后清零。 internal int TickSamples; internal double TickSumMs; internal double TickMaxMs; /// 上次真正下发 SyncAetherAIVehicleLaneDatas 的时刻(节流用,见 TrafficLanePushIntervalMs)。 internal long LastLanePushMs; /// /// 上一包 LaneDatas 的时间戳(秒,Unix)。 /// ★ 客户端的 TimedSpringInterpolationMethod 是「定时弹簧」:每个点带 TargetTimeStamp, /// 客户端在 currentServerTime 走到该时刻之前把 DeltaDistance 平滑排空。 /// 所以时间戳必须①指向未来(+lead)②单调递增③增量稳定 —— 三条缺一条就会出现 /// 「冲一下就停 / 停很久突然补距离」。 /// internal double LastTimestampSeconds; /// /// 玩家最近一次的位置(XZ)。Cruise 车掉进死路口袋时要换一个「离玩家不远的新目的地」, /// 得知道玩家在哪( 用它挑候选车道)。 /// internal float PlayerX; internal float PlayerZ; /// /// 最近发过的目标车道(最多 24 条)。挑新目标时避开它们 ⇒ 不会好几台车挤向同一个点 /// (2026-10-01 23:2x 实机:四台车同时被分到 `target=(872,1874)`,后车顶上前车、 /// 客户端 AI 倒车脱困后又停)。 /// internal List 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; } /// /// 世界就绪后调用一次;幂等。配置关掉或车道表缺失就直接返回。 /// /// ⛑ 2026-09-29:改为 async —— 起循环前先清掉**上一个会话残留**的车 /// (见 的注释)。不清的话切场景 / 切角色后 /// 上一批车会原封不动留在客户端世界里冻着。 /// 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)); } } /// 断开 / 切场景时停掉循环。 internal static void StopTraffic(TcpSession session) { if (!session.Items.TryGetValue(TrafficKey, out var existing) || existing is not TrafficSessionState owner) return; List? 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}"); } } /// /// 一台车的服务端视角位置(XZ)。 /// /// ⛑ Cruise 车()没有真实坐标 —— 客户端不上报 AI 车的 /// 移动,而我们也不再推车道插值 ⇒ 只能用「上次下发的目标点」当估算值。 /// 回收距离判定、出生避撞判定都读这里,别直接调 VehicleLaneGraph.PositionOf /// (对 Cruise 车那是冻结在出生点的假坐标,会导致永远回收不掉)。 /// 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); } /// /// 这条车道上已经有车了吗(按各车的出生车道比)。见 TrafficSpawnAvoidSameLane。 /// ⛑ 近似判据:车的 Drive.Lane 是出生车道、之后不再更新,所以这是 /// 「这条车道最近被占着」的保守判断(会占到那台车被回收为止),方向正是我们要的「错开」。 /// 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; } /// /// 这条车道的前进方向是不是朝着玩家(玩家在车头前方 ⇒ 车会开过来,形成迎面车流)。 /// 见 。 /// 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; } /// 候选出生点是否与现有车辆离得足够远。 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; } /// 按玩家位置把车辆数维持在 target 台;跑出路网或跑太远的车回收。 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? dead = null; List? 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"); } } /// /// 建出 BaseVehicle 实体本身(捐赠端 GameRouter.Vehicles.SpawnDirectAsync 的 proven 序列): /// SyncLogicVehicleEnter → SyncSpawnVehicle → SyncChangeVehicleInteractable。 /// Aether 的 AddData 只接管**已存在**的车,所以这一步必须在它之前。 /// 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 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); } /// /// ★★ 方案 A(2026-10-01):给一台车流车下发 SyncAssignVehicleAITask(68549522) + /// VehicleCruiseParameters,让客户端本地 AI 把它开走。 /// /// 为什么换这条管线(两轮证伪后的结论): /// ① 服务端没有「别把车提升成 GO」的有效通道(ForceGo 实机无效); /// ② 客户端那个疑似负责 GO 切换的类 AetherVehicleViewManager,8 个探针 24 分钟 /// 全 0 命中 ⇒ 根本没在用,按它设计的注入器 hook 方案整体作废。 /// ⇒ 与其继续猜,不如用我们已实机验证过的驱动方式:出租车 / 玩家自驾都走 /// SyncAssignVehicleAITask,车真的会自己开、会避障、会等红灯。 /// /// ⛑ 前置条件(由调用方保证):这台车的 AddData 必须是 /// ControlType=2 + LaneHandle=-1,否则客户端的车道模拟会和本地 AI 抢方向盘。 /// /// ⛑ 已知局限:客户端不上报 AI 车的坐标 ⇒ 服务端的 car.CruiseX/Z 只是 /// 「上次下发的目标点」,回收/避撞判定全是近似值。 /// /// /// 挑一串「能往前开」的车道当 Cruise 目标点。 /// /// ⛑⛑ 为什么不能只从「上次目标所在车道」往后规划(2026-10-01 23:2x 实机根因): /// 车开完第一次目标后,`CruiseLane` 落在那条路径的最后一段上 —— 而我们路网里有 /// 大量死路车道(286 个孤岛分量、很多车道没有合格后继)⇒ `PlanRoute` 只给出 1 段, /// 日志刷成一串 `points=1/1`,每台车每次都拿到同一个目标点 /// (`target=(901,1775) lane=6817` 反复出现)⇒ 车到点就停,再也不走。 /// /// ⇒ 判据:规划出来的段数 < trafficCruiseMinHops 就判「死路口袋」, /// 改从玩家附近随机挑一条车道重新规划(车本来就在玩家周围 ⇒ 新目标一定可达)。 /// 同时避开 RecentTargetLanes ⇒ 不会好几台车挤向同一个点互撞。 /// /// 车道终点串;空 = 实在规划不出来(调用方应跳过这次下发)。 private static (List 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; } /// 快照场上其它 Cruise 车的当前目标点(用于新目标的空间去重)。 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; } /// 把这条目标车道记进「最近用过」的滑动窗口(最多 24 条)。 private static void NoteTarget(TrafficSessionState owner, int lane) { owner.RecentTargetLanes.Add(lane); if (owner.RecentTargetLanes.Count > 24) owner.RecentTargetLanes.RemoveAt(0); } /// 从一条车道往后规划,把每段终点收集成点串。起点拿不到几何 ⇒ 返回空串。 private static (List Chain, int Lane, float X, float Z) BuildChain( int startLane, AetherSettings cfg) { List 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 { 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}"); } /// /// Cruise 车的推算位置推进(dead reckoning):按当前速度朝目标点走,到点即停。 /// /// ⛑ 为什么必须推:客户端不上报 AI 车的坐标,而回收/避撞/骇入判定全靠这个估算值。 /// · 不推(一直停在出生点)⇒ 车开走了也永远判成「在身边」,老车占着名额; /// · 直接跳到目标 ⇒ 目标一超出回收半径,车刚出生就被判出界回收(23:2x 实机踩过)。 /// 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; } } } /// /// 客户端回报 AI 任务状态时(AskUpdateVehicleAITaskStatus)在车流这边认领一下。 /// /// ★ 为什么要接:车开到目标点会自己停住,而服务端收不到到达通知 ⇒ 只能干等 /// trafficCruiseRefreshSec(默认 30s)。日志里其实早就有这些包,只是被 /// `[VEHICLE-HACK] ignored stale/duplicate/unowned AI status … status=2` 丢掉了。 /// ⇒ 这里认领后把刷新时刻归零,下一 tick 立刻续发目标点,车不会在原地杵半分钟。 /// /// ⛑ 只置标志、不在 handler 里发包(热路径上不能同步 await 一堆 Notify)。 /// 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; } /// /// 方案 A 的续驶:给到期(TrafficCruiseRefreshSec)的 Cruise 车重发目标点。 /// /// ⛑ 为什么还要定时重发兜底:客户端不一定回状态(被顶住/卡住时可能什么都不发), /// 所以「定时器」和「收到终止状态立刻续发」是双保险 —— /// 前者兜住「客户端沉默」,后者消灭「到了还要干等 30 秒」的空窗。 /// 重发对两种情形都成立:没到 ⇒ 新目标仍在它前方,等价续驶;到了 ⇒ 从脚下继续往前。 /// private static async Task RefreshTrafficCruiseAsync( TcpSession session, TrafficSessionState owner, AetherSettings cfg, Random rng, CancellationToken token) { if (!cfg.TrafficCruiseDrive) return; List? 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); } /// /// 把一台车流车从车道流里摘出来,交还给玩家(骇入 / 上车)。幂等:非车流车直接返回。 /// /// ★★ 为什么必须摘:车道流每 50ms 用 SyncAetherAIVehicleLaneDatas 覆盖里程, /// 而骇入下发的是 SyncAssignVehicleAITask(RaidVehicle 通道)—— 两套驱动源。 /// 同时作用时车会「刚加速就被拽回原地」,表现为骇入「能显示但没效果」。 /// 所以玩家接管的第一件事就是停掉车道流。 /// /// ★ 摘出的手段:重发一条 SyncAetherAIVehicleAddData, /// ControlType=2(RaidVehicle) + LaneHandle=-1。 /// 这与 RegisterHackVehicleAsync() 的做法一致,其注释原文: /// "ControlType=RaidVehicle avoids activating lane simulation"。 /// 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"); } /// /// 每 tick 计算纵向动力学(跟车 / 交叉让行 / 加减速)→ 推进里程 → 批量下发。 /// 走到死路的车直接回收。 /// /// ★★ 为什么服务端必须自己做跟车:客户端的 AetherVehicleDetector(前方 trigger) /// 只对**近距离 GO 车**生效,实测它只避让玩家/行人(AetherVehicleStopReason.Block), /// 车与车之间照样对撞。客户端那边没有「远程车流互避」逻辑 —— 它只按服务端给的点插值。 /// 所以纵向决策只能放服务端:这里是简化 IDM + 最近接近点(TTC)让行。 /// private static async Task PushTrafficLaneDataAsync( TcpSession session, TrafficSessionState owner, float dt, double stamp, AetherSettings cfg, Random rng, CancellationToken token) { List batch = []; List? 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 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(); // 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"); } /// 一台车的寿命摘要(回收日志用)—— 用来量化「车开多远就没了」。 private static string CarLife(TrafficCar car) => $"after={car.TraveledMeters:F0}m hops={car.Hops} alive={car.AliveSeconds:F0}s"; /// /// 回收一台车流车 —— **必须连 SyncDestroyVehicle 一起发**。 /// /// ★★ 只发 SyncAetherAIVehicleRemove 是不够的(2026-09-28 实测): /// 那个包只把车从 Aether 车流系统里摘掉,**BaseVehicle 实体仍留在客户端场景里**。 /// 服务端这边已经把它从 Cars 移除、不再推里程,所以 [TRAFFIC-STAT] 看着一切正常 /// (stop=0、avg 10m/s),但客户端那台车就那么杵在原地 —— /// 用户看到的就是「大部分车停在十字路口」,以及「有些车开着开着变色然后停下」 /// (实体脱离 Aether 驱动后渲染状态异常)。 /// ⇒ 补发 SyncDestroyVehicle 才是真正销毁,与 DestroyVehicleAsync 的做法一致。 /// 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); } /// 本会话当前持有的车流车 id。 private static HashSet OwnedIds(TrafficSessionState owner) { var set = new HashSet(); lock (owner.SyncRoot) foreach (var c in owner.Cars) set.Add(c.Id); return set; } /// /// 把本会话的车流车**真回收**:逐个发 SyncAetherAIVehicleRemove + SyncDestroyVehicle。 /// 照抄 RetireAmbientPedsAsync 的做法(分块 + 间隔,别一次性连发打爆 socket)。 /// internal static async Task RetireTrafficAsync(TcpSession session) { if (!session.Items.TryGetValue(TrafficKey, out var existing) || existing is not TrafficSessionState owner) return; List 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}"); } } } } /// /// 清掉**上一个会话**留下的残留车(本会话不知道它们、但客户端世界里还在)。 /// 判据:进程级 里不属于本会话的那些 id。 /// private static async Task RetireStrayTrafficAsync(TcpSession session, TrafficSessionState owner) { List 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}"); } } } } /// /// 挑一台「能上路」的车。 /// /// ★ 第一优先是 (客户端 MassAI.MassTraffic 的 /// VehicleSpawnConfig 表,InMass+CanDrive 共 54 个车型)。只有在这张表里的 cfgId 才能 /// 建出 VehicleDataEntity —— 否则客户端打 /// VehicleInit_Drop_NoConfig ... not found in VehicleSpawn 并丢弃,车看得到但不会动。 /// fleet 列表只作为该表缺失时的兜底。 /// 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; } }