using Ananta.Server.Configuration;
namespace Ananta.Server.Handlers.Game;
///
/// 车辆路网(服务端侧 ZoneGraph 简化版)—— 给 T3-7 车流提供「车道 handle + 沿车道里程」。
///
/// 数据来自捐赠端 `zonegraph_lanes.csv`(WorldMap_Release,14171 条车道),
/// 由 `_gen_vehicle_lanes.py` 压成两份运行时表:
/// `Ananta.Server/Ananta.App/data/vehicle_lanes.csv` = handle;bx;by;bz;ex;ey;ez;length;speed
/// `Ananta.Server/Ananta.App/data/vehicle_lane_links.csv` = handle;to1;to2;...
///
/// ★ 为什么用捐赠端那份而不是 `_ref/lanes/vehicle-lane-links.csv`:
/// 后者 Type==1 前向边只有 10911 条、只覆盖 56.9% 的车道,且是捐赠端 next 的**真子集**;
/// 捐赠端 next 是多值后继,覆盖 13910/14171 = 98.2%,换道接缝中位 0.00m。
///
/// ★ 与 PedLaneGraph 的关键差别:客户端按 DistanceAlongLane 在**它本地的真实车道曲线**上
/// 插值(MassTrafficInterpolationUtils),所以服务端不需要车道折线,只要里程单调推进。
/// 这里的 bx/ez 只用于「玩家附近挑车道」和 despawn 判定。
///
internal static class VehicleLaneGraph
{
internal struct DriveState
{
internal int Lane;
internal float Distance;
}
private sealed class Lane
{
internal float Bx, By, Bz, Ex, Ey, Ez;
internal float Length;
internal float Speed;
internal int[] Next = [];
///
/// 这条车道能不能用于导航求解(2026-09-30)=它是否属于车道图的
/// 最大强连通分量(既能被开进来、也能开出去)。见 。
/// ⛑ 只有 TrySnapLane(导航)在意它;TryPickSpawn(车流出生)不看,
/// 后者用的是更宽松的「有后继」判据,改了会动摇 T3-7 已经调好的出生分布。
///
internal bool Routable;
///
/// ★★ 2026-10-03:「弦中点偏离真实车道」的弓高(米)。
///
/// CSV 只给了车道弦的两个端点(bx/bz → ex/ez),真实车道是曲线,客户端按
/// DistanceAlongLane 在它本地的曲线上插值。我们沿直线弦取点,弯道时
/// 弦中点会落到路面之外 —— 召唤车「卡在人行道 / 花坛里」就是这个(实测一条 70m 弯车道
/// 弦长 62.96m、弧长 70.02m,弦中点偏离真实路面约 10m)。
///
/// 推导(把车道近似成圆弧):
/// 弧弦比 r = Length / chord ≈ 1 + θ²/24 ⇒ 圆心角 θ ≈ sqrt(24(r-1))
/// 弓高 h ≈ chord · θ / 8
/// 弦上参数 t 处的横向偏离 δ(t) ≈ 4·h·t·(1-t)(t=0/1 为 0,t=0.5 最大为 h)。
///
internal float Sagitta;
}
///
/// 算一条车道的弓高(弦中点相对真实曲线的最大横向偏离,米)。见 。
/// 直车道(弧长≈弦长)返回 0。
///
private static float SagittaOf(Lane lane)
{
var dx = lane.Ex - lane.Bx;
var dz = lane.Ez - lane.Bz;
var chord = MathF.Sqrt(dx * dx + dz * dz);
if (chord < 1e-3f || lane.Length <= chord)
return 0f;
// 弧弦比 r ≈ 1 + θ²/24 ⇒ θ ≈ sqrt(24(r-1));弓高 h ≈ chord·θ/8
var ratio = lane.Length / chord;
var theta = MathF.Sqrt(24f * (ratio - 1f));
return chord * theta / 8f;
}
///
/// 弦上参数 t(0..1)处的横向偏离(米)。t=0/1 落在端点 ⇒ 偏离 0;t=0.5 最大 ⇒ 等于弓高。
///
private static float LateralDeviationAt(float sagitta, float t)
=> sagitta <= 0f ? 0f : 4f * sagitta * t * (1f - t);
private const float CellSize = 100f;
private sealed class Table
{
internal Dictionary Lanes = [];
internal Dictionary<(int, int), List> Cells = [];
internal bool Ok;
internal int RoutableLanes;
internal int RoutableComponents;
}
private static readonly Lazy Cache = new(Load);
internal static bool Available => Cache.Value.Ok;
internal static int LaneCount => Cache.Value.Lanes.Count;
/// 最大强连通分量里的车道数(导航只会吸附到这些车道上)。排障用。
internal static int RoutableLaneCount => Cache.Value.RoutableLanes;
/// 强连通分量个数。理想值 1;2026-09-30 实测 286(见 )。
internal static int ComponentCount => Cache.Value.RoutableComponents;
private static Table Load()
{
var table = new Table();
var lanePath = PrivateServerConfigStore.ResolveProjectPath(
"Ananta.Server/Ananta.App/data/vehicle_lanes.csv");
var linkPath = PrivateServerConfigStore.ResolveProjectPath(
"Ananta.Server/Ananta.App/data/vehicle_lane_links.csv");
if (!File.Exists(lanePath) || !File.Exists(linkPath))
return table;
foreach (var line in File.ReadLines(lanePath))
{
if (line.Length == 0)
continue;
var parts = line.Split(';');
if (parts.Length < 8 || !int.TryParse(parts[0], out var handle))
continue;
var lane = new Lane
{
Bx = Num(parts[1]), By = Num(parts[2]), Bz = Num(parts[3]),
Ex = Num(parts[4]), Ey = Num(parts[5]), Ez = Num(parts[6]),
Length = Num(parts[7]),
Speed = parts.Length >= 9 ? Num(parts[8]) : 9.72f,
};
if (lane.Length <= 0.5f)
{
// 退化车道:按端点距离补一个下限,避免除零。
var dx = lane.Ex - lane.Bx;
var dz = lane.Ez - lane.Bz;
lane.Length = MathF.Max(1f, MathF.Sqrt(dx * dx + dz * dz));
}
lane.Sagitta = SagittaOf(lane);
table.Lanes[handle] = lane;
var cell = CellOf(lane.Bx, lane.Bz);
if (!table.Cells.TryGetValue(cell, out var bucket))
table.Cells[cell] = bucket = [];
bucket.Add(handle);
}
foreach (var line in File.ReadLines(linkPath))
{
if (line.Length == 0)
continue;
var parts = line.Split(';');
if (parts.Length < 2 || !int.TryParse(parts[0], out var handle))
continue;
if (!table.Lanes.TryGetValue(handle, out var lane))
continue;
var outs = new List(parts.Length - 1);
for (var i = 1; i < parts.Length; i++)
if (int.TryParse(parts[i], out var to) && table.Lanes.ContainsKey(to) && to != handle)
outs.Add(to);
lane.Next = outs.ToArray();
}
MarkRoutableLanes(table);
table.Ok = table.Lanes.Count > 0;
return table;
}
// -----------------------------------------------------------------------------------------
// ⛑⛑ 2026-09-30:车道图有 **285 条孤岛车道**,这是「自动驾驶开不到自定义标点」的真因。
//
// 体检脚本 `_vehicle_lane_graph_check.py` 的结果(可直接复跑):
// 车道 14171 条、链接表只覆盖 13910 条 ⇒ **261 条没有任何后继、255 条没有任何前驱**,
// 两者的样例 id 完全重合(89/93/135/140/232/236/410/417…)⇒ 它们是双向断开的孤儿车道。
// Tarjan 的结果更狠:全图 **286 个强连通分量**,最大的那个有 13886 条(97%),
// 剩下 285 条各自成形单影只的分量。
//
// 后果:日志里 `车道图不通 start=1795 target=1972` 反复出现且**永远不解开** —— 因为 lane 1972 /
// 6901 / 13108 的「能到它的车道数」= 1(只有它自己)。而 SolveRoute 的 Dijkstra 只走前向
// 后继,玩家把目的地标点落到这种车道附近时,**永远解不出路** ⇒ Start 之后根本没有 cruise 下发
// ⇒ 现象就是「按了自动驾驶没反应」。
// 商店/POI 之所以大多没事:它们的 endPos 是走 `GpsAreaConnectMgr` 算出来的**路口/出入口点**,
// 天然落在主干道上;自定义标点给的是玩家自己点的原始坐标,撞上孤儿车道就是纯运气。
//
// ⇒ 治法:**导航吸附时只认最大强连通分量里的车道**。孤儿车道本来就没法参与路径,
// 拿它当起点/终点必然失败;跳过它去找次近的正常车道,代价是终点可能偏移几米。
// (真要根治得重跑 `_gen_vehicle_lanes.py` 把这 261 条接上去,本次不动数据。)
// -----------------------------------------------------------------------------------------
private static void MarkRoutableLanes(Table table)
{
// 迭代版 Tarjan(14171 个点,递归会爆栈)。
var index = new Dictionary();
var low = new Dictionary();
var onStack = new HashSet();
var stack = new List();
var comp = new Dictionary();
var counter = 0;
var compCount = 0;
foreach (var root in table.Lanes.Keys)
{
if (index.ContainsKey(root))
continue;
var work = new List<(int Node, IEnumerator It)> { (root, Next(table, root)) };
index[root] = low[root] = counter++;
stack.Add(root);
onStack.Add(root);
while (work.Count > 0)
{
var (v, it) = work[^1];
var advanced = false;
while (it.MoveNext())
{
var w = it.Current;
if (!table.Lanes.ContainsKey(w))
continue;
if (!index.ContainsKey(w))
{
index[w] = low[w] = counter++;
stack.Add(w);
onStack.Add(w);
work.Add((w, Next(table, w)));
advanced = true;
break;
}
if (onStack.Contains(w) && index[w] < low[v])
low[v] = index[w];
}
if (advanced)
continue;
work.RemoveAt(work.Count - 1);
if (low[v] == index[v])
{
var id = compCount++;
int popped;
do
{
popped = stack[^1];
stack.RemoveAt(stack.Count - 1);
onStack.Remove(popped);
comp[popped] = id;
}
while (popped != v);
}
if (work.Count > 0)
{
var parent = work[^1].Node;
if (low[v] < low[parent])
low[parent] = low[v];
}
}
}
// 取规模最大的分量作为「可用路网」。
var sizes = new Dictionary();
foreach (var c in comp.Values)
sizes[c] = sizes.TryGetValue(c, out var n) ? n + 1 : 1;
var best = -1;
var bestSize = 0;
foreach (var (id, size) in sizes)
{
if (size <= bestSize)
continue;
bestSize = size;
best = id;
}
var routable = 0;
foreach (var (handle, lane) in table.Lanes)
{
lane.Routable = comp.TryGetValue(handle, out var c) && c == best;
if (lane.Routable)
routable++;
}
if (routable == 0)
{
// 兜底:一旦上面的分量算挂了(退化成全 false)导航会**彻底不可用**,
// 宁可退回老行为(全都可用)也不要让自动驾驶直接死掉。
foreach (var lane in table.Lanes.Values)
lane.Routable = true;
routable = table.Lanes.Count;
}
table.RoutableLanes = routable;
table.RoutableComponents = sizes.Count;
}
private static IEnumerator Next(Table table, int handle)
=> table.Lanes.TryGetValue(handle, out var lane)
? ((IEnumerable)lane.Next).GetEnumerator()
: Enumerable.Empty().GetEnumerator();
private static float Num(string s) => float.TryParse(s, out var v) ? v : 0f;
private static (int, int) CellOf(float x, float z)
=> ((int)MathF.Floor(x / CellSize), (int)MathF.Floor(z / CellSize));
///
/// 在 (px,pz) 的**环形**区间内随机挑一条车道当作出生点:距玩家
/// [minDistance, radius]。
///
/// ⛑ 为什么必须有 minDistance(2026-09-29 修):原来只筛「≤ radius」,
/// 于是车会直接刷在玩家脚边几米处;而且出生点固定取车道起点(Distance=0),
/// 而车道起点大量落在路口里 ⇒ 观感就是「车刷在我身边,还不在车道上」。
/// 更糟的是客户端 AetherVehicleViewManager 会在
/// CANDIDATE_GO_DISTANCE_SQ=400(20m)内把车流车提升成 GO(完整载具实体),
/// 提升后不再吃我们的 Aether 车道插值 ⇒ 冻在原地,于是玩家身边越积越多不动的车。
///
internal static bool TryPickSpawn(float px, float pz, float radius, float minDistance, Random rng,
out DriveState state, out float x, out float y, out float z, out float speed)
{
state = default;
x = y = z = 0f;
speed = 9.72f;
var table = Cache.Value;
if (!table.Ok)
return false;
var reach = (int)MathF.Ceiling(radius / CellSize);
var baseCell = CellOf(px, pz);
List pool = [];
for (var dx = -reach; dx <= reach; dx++)
for (var dz = -reach; dz <= reach; dz++)
{
if (!table.Cells.TryGetValue((baseCell.Item1 + dx, baseCell.Item2 + dz), out var bucket))
continue;
foreach (var h in bucket)
{
if (!table.Lanes.TryGetValue(h, out var lane))
continue;
// ⛑ 2026-09-29:不要生在**没有后继**的车道上。
// 链接表只覆盖 13910/14171,剩下 261 条是无后继的真死路;
// 生在那种车道上 ⇒ 车开到尽头就被回收,实机表现为「刚刷出来就没了」
// (日志里 alive=3~20s、hops=0、after=13~211m 全是这批)。
if (lane.Next.Length == 0)
continue;
var ddx = lane.Bx - px;
var ddz = lane.Bz - pz;
var dSq = ddx * ddx + ddz * ddz;
if (dSq <= radius * radius && dSq >= minDistance * minDistance)
pool.Add(h);
}
}
if (pool.Count == 0)
{
// 环太窄(min 太大 / 附近没路)⇒ 退回「只要不在玩家脸上」,宁可近一点也别刷不出来。
var minSq = minDistance * minDistance;
for (var dx = -reach; dx <= reach; dx++)
for (var dz = -reach; dz <= reach; dz++)
{
if (!table.Cells.TryGetValue((baseCell.Item1 + dx, baseCell.Item2 + dz), out var bucket))
continue;
foreach (var h in bucket)
{
if (!table.Lanes.TryGetValue(h, out var lane))
continue;
var ddx = lane.Bx - px;
var ddz = lane.Bz - pz;
if (ddx * ddx + ddz * ddz <= radius * radius)
pool.Add(h);
}
if (pool.Count > 0)
break;
}
if (pool.Count == 0)
return false;
}
var picked = pool[rng.Next(pool.Count)];
var start = table.Lanes[picked];
state = new DriveState { Lane = picked, Distance = 0f };
x = start.Bx;
y = start.By;
z = start.Bz;
speed = start.Speed;
return true;
}
///
/// 「召唤载具从车道开过来」的生成点(2026-10-03):在 (px,pz) 的环形区间
/// [minDistance, maxDistance] 内,挑一条车道上距离合适的点,并返回车道朝向。
///
/// ⛑⛑ 与 (T3-7 车流出生)的三点差别,别混用:
/// ① 只挑 的车道。吸附到那 285 条孤岛车道上 ⇒
/// Dijkstra 解不出路 ⇒ 一条 Cruise 都不发 ⇒ 「叫了车但没反应」(2026-09-30 血泪)。
/// ② 不只取车道起点:沿弦采样 5 个 t,用落在环内的那一段 —— 车道起点大量落在路口里,
/// 刷在路口的车开出来会横穿。
/// ③ 返回车道朝向( 同一套角度约定),车头顺着路,
/// 不然车要先原地掉头,观感是「车卡了一下才走」。
///
/// :允许的横向偏离(米),0 = 不过滤(旧行为)。
/// 车道 CSV 只有弦端点,弯道时弦中点在真实路面之外 ⇒ 车会刷到人行道/花坛里。
/// 给 1.5 之类的值后,弯车道只保留靠近端点的那些采样点(偏离随 t 两端趋 0)。
/// :多台车连叫时不想每次都刷在同一个位置,交给调用方给随机源。
///
internal static bool TryPickDriveInSpawn(float px, float pz, float minDistance, float maxDistance,
float maxLateralMeters, Random rng, out float x, out float y, out float z, out float facingDegrees)
{
x = y = z = 0f;
facingDegrees = 0f;
var table = Cache.Value;
if (!table.Ok)
return false;
var minSq = minDistance * minDistance;
var maxSq = maxDistance * maxDistance;
var reach = (int)MathF.Ceiling(maxDistance / CellSize) + 1;
var baseCell = CellOf(px, pz);
List<(float X, float Y, float Z, float Facing)> pool = [];
for (var dx = -reach; dx <= reach; dx++)
for (var dz = -reach; dz <= reach; dz++)
{
if (!table.Cells.TryGetValue((baseCell.Item1 + dx, baseCell.Item2 + dz), out var bucket))
continue;
foreach (var h in bucket)
{
if (!table.Lanes.TryGetValue(h, out var lane))
continue;
// ⛑⛑ 孤岛车道(无后继 / 不在最大强连通分量内)一律不要。
if (!lane.Routable || lane.Next.Length == 0)
continue;
// 角度约定与 SpawnDirectAsync 的偏移一致:
// yaw = facing * PI/180;偏移 = (+cos(yaw), -sin(yaw))
// ⇒ 方向向量 (dx,dz) 对应 facing = Atan2(-dz, dx)。
var sdx = lane.Ex - lane.Bx;
var sdz = lane.Ez - lane.Bz;
var facing = MathF.Atan2(-sdz, sdx) * (180f / MathF.PI);
// ⛑⛑ 采样密度从 5 提到 21:加了横向偏离过滤之后,弯车道只剩端点附近可用,
// 采样太稀会「明明有合格车道却一个点都挑不到」⇒ 白白退回旧路径。
const int Samples = 20;
for (var k = 0; k <= Samples; k++)
{
var t = (float)k / Samples;
// ⛑⛑ 弦上 t 处偏离真实车道太远 ⇒ 这个点在人行道 / 花坛 / 建筑里,不要。
if (maxLateralMeters > 0f && LateralDeviationAt(lane.Sagitta, t) > maxLateralMeters)
continue;
var cx = lane.Bx + sdx * t;
var cy = lane.By + (lane.Ey - lane.By) * t;
var cz = lane.Bz + sdz * t;
var ddx = cx - px;
var ddz = cz - pz;
var dSq = ddx * ddx + ddz * ddz;
if (dSq < minSq || dSq > maxSq)
continue;
pool.Add((cx, cy, cz, facing));
}
}
}
if (pool.Count == 0)
return false;
var picked = pool[rng.Next(pool.Count)];
x = picked.X; y = picked.Y; z = picked.Z; facingDegrees = picked.Facing;
return true;
}
// ---------------------------------------------------------------------------------------------
// 路线规划(GameRouter.VehicleNav.cs)用的取值口。数据仍然只有这一份:
// 只为「最近车道」另建了一份索引(按起点/中点/终点三个关键 cell 挂号),因为 try 9 是长车道
// 只按起点挂号会漏掉「从路中间上车」的情形,而我们不能动 TryPickSpawn 那张 begin-only 索引
// (改了会改变 T3-7 车流的随机出生分布)。
// ---------------------------------------------------------------------------------------------
private sealed class NavIndex
{
internal Dictionary<(int, int), List> Cells = [];
}
private static readonly Lazy NavCache = new(BuildNavIndex);
private static NavIndex BuildNavIndex()
{
var index = new NavIndex();
foreach (var (handle, lane) in Cache.Value.Lanes)
{
Index(handle, lane.Bx, lane.Bz);
Index(handle, (lane.Bx + lane.Ex) * 0.5f, (lane.Bz + lane.Ez) * 0.5f);
Index(handle, lane.Ex, lane.Ez);
}
void Index(int h, float x, float z)
{
var cell = CellOf(x, z);
if (!index.Cells.TryGetValue(cell, out var bucket))
index.Cells[cell] = bucket = [];
if (!bucket.Contains(h))
bucket.Add(h);
}
return index;
}
/// 一次 TrySnapLane 的结果。
internal readonly record struct LaneSnap(int Handle, float T, float Distance, float X, float Y, float Z);
///
/// 找 (px,pz) 半径内最近的车道,并把点投影到它的弦上(T = 归一化里程)。
/// 只看 XZ 平面:竖直方向(y) 对「哪条路最近」没有意义,而我们本来也只有端点。
///
/// ⛑⛑ 2026-09-30:只返回 的车道(最大强连通分量内的)。
/// 吸附到孤儿车道上 ⇒ Dijkstra 永远解不出路 ⇒ 按自动驾驶没反应。详见 。
/// 车流的 TryPickSpawn 不受影响(那条路径不走这里)。
///
internal static bool TrySnapLane(float px, float pz, float maxRadius, out LaneSnap snap)
=> TrySnapLane(px, pz, maxRadius, 0f, out snap);
///
/// 同 ,多一个横向偏离过滤。
///
/// ⛑⛑ 召唤车「停在人行道 / 花坛里」的另一半根因:停车点是玩家坐标投影到最近车道弦上
/// 得到的,弯车道时这个点同样在路面之外。给 (如 1.5)
/// 即可只接受贴近真实路面的投影位置。
///
/// ⛑ 传 0(= 上面那个重载)保持旧行为,自动驾驶导航走的就是那条,别动它。
///
internal static bool TrySnapLane(float px, float pz, float maxRadius, float maxLateralMeters,
out LaneSnap snap)
{
snap = default;
var table = Cache.Value;
if (!table.Ok)
return false;
var reach = (int)MathF.Ceiling(maxRadius / CellSize) + 1;
var baseCell = CellOf(px, pz);
var best = float.MaxValue;
var found = false;
for (var dx = -reach; dx <= reach; dx++)
for (var dz = -reach; dz <= reach; dz++)
{
if (!NavCache.Value.Cells.TryGetValue((baseCell.Item1 + dx, baseCell.Item2 + dz), out var bucket))
continue;
foreach (var h in bucket)
{
if (!table.Lanes.TryGetValue(h, out var lane))
continue;
// ⛑⛑ 跳过孤岛车道:它们参与不了任何路径(见 MarkRoutableLanes 的注释)。
if (!lane.Routable)
continue;
var sx = lane.Ex - lane.Bx;
var sz = lane.Ez - lane.Bz;
var denom = sx * sx + sz * sz;
var t = denom > 1e-6f
? ((px - lane.Bx) * sx + (pz - lane.Bz) * sz) / denom
: 0f;
t = Math.Clamp(t, 0f, 1f);
// ⛑⛑ 弦上 t 处偏离真实车道太远 ⇒ 停车点会落在人行道 / 花坛里,换一条。
if (maxLateralMeters > 0f && LateralDeviationAt(lane.Sagitta, t) > maxLateralMeters)
continue;
var cx = lane.Bx + sx * t;
var cz = lane.Bz + sz * t;
var ddx = cx - px;
var ddz = cz - pz;
var dist = MathF.Sqrt(ddx * ddx + ddz * ddz);
if (dist > maxRadius || dist >= best)
continue;
best = dist;
snap = new LaneSnap(h, t, dist, cx, lane.By + (lane.Ey - lane.By) * t, cz);
found = true;
}
}
return found;
}
/// 一条车道的弦与长度。找不到返回 false。
internal static bool TryLaneGeometry(int handle, out float bx, out float by, out float bz,
out float ex, out float ey, out float ez, out float length)
{
bx = by = bz = ex = ey = ez = length = 0f;
if (!Cache.Value.Lanes.TryGetValue(handle, out var lane))
return false;
bx = lane.Bx; by = lane.By; bz = lane.Bz;
ex = lane.Ex; ey = lane.Ey; ez = lane.Ez;
length = lane.Length;
return true;
}
/// 后继车道(多值)。没有后继返回空数组。
internal static int[] NextOf(int handle)
=> Cache.Value.Lanes.TryGetValue(handle, out var lane) ? lane.Next : [];
///
/// 规划一条**直行优先**的路径:从起始车道出发,每一步都挑「方向最贴合当前朝向」的后继。
/// 返回接下来要走的车道序列(不含起始车道本身)。
///
/// ★★ 为什么需要它(2026-09-28):车流原来是每到一条车道就**随机挑后继**(PickNext),
/// 于是车会乱拐、掉头、钻死路、很快被回收 —— 用户看到的就是「开很短距离就停下/消失」。
/// 有了预规划路径,车沿道路连续跑 1~2 段甚至更远,观感从"随机乱窜"变成"正常行驶"。
///
/// ★ 不直接用导航那套 Dijkstra:Dijkstra 求的是**到指定目标**的最短路,
/// 而车流没有目的地,要的是"顺着路往前开"。直行优先恰好表达这个意图,
/// 且天然排除掉头(dot 为负的后继排最后)。
///
/// ★ 接缝超过 的后继直接跳过 —— 与 Advance 的硬上限一致,
/// 否则规划出来的路径里会混进几百米的瞬移跳。
///
internal static List PlanRoute(int startLane, int maxHops, float maxHop)
{
var route = new List(Math.Max(0, maxHops));
var table = Cache.Value;
if (!table.Ok)
return route;
var cur = startLane;
for (var k = 0; k < maxHops; k++)
{
if (!table.Lanes.TryGetValue(cur, out var lane))
break;
var fdx = lane.Ex - lane.Bx;
var fdz = lane.Ez - lane.Bz;
var flen = MathF.Sqrt(fdx * fdx + fdz * fdz);
if (flen < 1e-3f)
break;
fdx /= flen;
fdz /= flen;
var best = -1;
var bestDot = -2f;
foreach (var h in lane.Next)
{
if (!table.Lanes.TryGetValue(h, out var cand))
continue;
var gx = cand.Bx - lane.Ex;
var gz = cand.Bz - lane.Ez;
if (gx * gx + gz * gz > maxHop * maxHop)
continue; // 会瞬移的后继不用
var cdx = cand.Ex - cand.Bx;
var cdz = cand.Ez - cand.Bz;
var clen = MathF.Sqrt(cdx * cdx + cdz * cdz);
if (clen < 1e-3f)
continue;
var dot = fdx * (cdx / clen) + fdz * (cdz / clen);
if (dot > bestDot)
{
bestDot = dot;
best = h;
}
}
if (best < 0)
break; // 没有合格后继 = 到头了
route.Add(best);
cur = best;
}
return route;
}
/// 一次车道衔接的细节(诊断用)。
internal struct LaneHop
{
internal int FromLane;
internal int ToLane;
/// 旧车道终点 → 新车道起点的接缝距离(米)。越大越像瞬移。
internal float Gap;
}
///
/// 沿车道推进指定距离(米)。换道时把 切到下一条并把里程归零。
/// 返回 false = 走到路网死路,调用方应当回收这辆车。
///
/// ★ :只在接缝 ≤ 该值的后继里挑。捐赠端 next 是多值后继,
/// 实测 5.4% 的后继接缝 > 12m(最大 340m),直接随机挑会让车「瞬移一段甚至倒退」。
/// 找不到合格后继时退回「接缝最小」的那条,并如实把 Gap 报给调用方。
///
///
/// 换道接缝的**绝对硬上限**(米)。超过就判定"开到尽头",返回 false 让调用方回收。
/// 为什么需要它:PickNext 在所有后继都被"方向反折"过滤掉时会退回 fallback
/// (全局最小接缝)—— 那条可能远在天边。实测日志里出现过 worstGap=394.1m,
/// 而车道数据本身 p99 接缝就有 347m、max 2965m(>30m 的占 13.92%)。
/// 跳过去就是几百米的瞬移,比车在路口消失难看得多 —— 宁可回收。
///
internal static bool Advance(ref DriveState s, float meters, Random rng, float maxGap,
out LaneHop? hop, float maxHop = float.MaxValue, int preferredNext = -1)
{
hop = null;
var table = Cache.Value;
if (!table.Ok)
return false;
var guard = 0;
while (guard++ < 32)
{
if (!table.Lanes.TryGetValue(s.Lane, out var lane))
return false;
var remain = lane.Length - s.Distance;
if (meters < remain)
{
s.Distance += meters;
return true;
}
if (lane.Next.Length == 0)
{
s.Distance = lane.Length;
return false;
}
meters -= remain;
var from = s.Lane;
// ★ 有预规划路径且这一跳就用它;否则退回「最小接缝 + 不反折」的随机挑法。
int next;
float gap;
if (preferredNext >= 0 && Array.IndexOf(lane.Next, preferredNext) >= 0)
{
next = preferredNext;
gap = 0f;
if (table.Lanes.TryGetValue(next, out var cand))
{
var ax = cand.Bx - lane.Ex;
var az = cand.Bz - lane.Ez;
gap = MathF.Sqrt(ax * ax + az * az);
}
}
else
{
next = PickNext(lane, table, rng, maxGap, out gap);
}
if (next < 0 || gap > maxHop)
{
// 接缝太远 ⇒ 不跳,当作开到尽头,交给调用方回收。
// 硬跳会造成数百米的瞬移(见 maxHop 的注释)。
s.Distance = lane.Length;
return false;
}
s.Lane = next;
s.Distance = 0f;
hop = new LaneHop { FromLane = from, ToLane = next, Gap = gap };
}
return true;
}
///
/// 挑下一条车道:在「接缝最小」的基础上放宽 米的容差再随机选,
/// 并排除方向反折的后继(那会让车掉头,视觉上像倒退瞬移)。
///
/// ★ 为什么是**相对容差**而不是「接缝 ≤ N 米」的绝对阈值:
/// 实测 400 起点 × 600 跳,绝对阈值 8m 会让 15.05% 的衔接 > 12m、p99 达 607m;
/// 而「总选最小缝」只有 2.55% > 12m、p99 50m。原因是绝对阈值一旦命中就随机选,
/// 容易拐进后继质量差的车道;贪心取最近则倾向留在连贯的主干道上。
/// 所以 maxGap=0 = 严格取接缝最小(并列最小里随机),是实测最优。
///
private static int PickNext(Lane from, Table table, Random rng, float maxGap, out float gap)
{
var fex = from.Ex;
var fez = from.Ez;
var fdx = fex - from.Bx;
var fdz = fez - from.Bz;
List good = [];
List forward = [];
var minGap = float.MaxValue;
var fallback = from.Next[0];
foreach (var h in from.Next)
{
if (!table.Lanes.TryGetValue(h, out var cand))
continue;
var vx = cand.Bx - fex;
var vz = cand.Bz - fez;
var d = MathF.Sqrt(vx * vx + vz * vz);
if (d < minGap)
{
minGap = d;
fallback = h;
}
// 方向反折:新车道朝向与旧车道相反 → 车会「掉头」,视觉上像倒退瞬移。
if (fdx * (cand.Ex - cand.Bx) + fdz * (cand.Ez - cand.Bz) < 0f)
continue;
forward.Add(h);
if (d <= minGap + maxGap)
good.Add(h);
}
gap = minGap;
if (good.Count > 0)
return good[rng.Next(good.Count)];
if (forward.Count > 0 && maxGap > 0f)
return forward[rng.Next(forward.Count)];
return fallback;
}
/// 服务端侧的线性插值的粗略世界坐标(只用于 AOI 判定 / 日志,客户端自己有真实曲线)。
internal static void PositionOf(in DriveState s, out float x, out float y, out float z)
{
var table = Cache.Value;
if (!table.Lanes.TryGetValue(s.Lane, out var lane))
{
x = y = z = 0f;
return;
}
var t = Math.Clamp(s.Distance / lane.Length, 0f, 1f);
x = lane.Bx + (lane.Ex - lane.Bx) * t;
y = lane.By + (lane.Ey - lane.By) * t;
z = lane.Bz + (lane.Ez - lane.Bz) * t;
}
/// 距当前车道末端还有多少米(用于「接近路口就减速」)。
internal static float RemainOf(in DriveState s)
{
var table = Cache.Value;
if (!table.Lanes.TryGetValue(s.Lane, out var lane))
return 0f;
return MathF.Max(0f, lane.Length - s.Distance);
}
/// 当前车道的限速(m/s)。车换道后会变,所以每 tick 都要重取。
internal static float LimitOf(in DriveState s)
{
var table = Cache.Value;
return table.Lanes.TryGetValue(s.Lane, out var lane) ? lane.Speed : 9.72f;
}
///
/// 朝向 弧度(0 = +Z,顺时针为正),由车道端点方向推出。
/// ★ 2026-09-21 修正:原先这里返回「度」却被直接填进 ClientVehicleInitData.Facing,
/// 实测线上包里 Facing = -88.5 —— 那是度数当弧度用了。Facing 是弧度,
/// EulerAngles 才是度(调用处各自换算)。
///
internal static float FacingOf(in DriveState s)
{
var table = Cache.Value;
if (!table.Lanes.TryGetValue(s.Lane, out var lane))
return 0f;
var dx = lane.Ex - lane.Bx;
var dz = lane.Ez - lane.Bz;
if (MathF.Abs(dx) < 1e-4f && MathF.Abs(dz) < 1e-4f)
return 0f;
// ★ 单位 = 度,与已生效的 SyncAetherAICrowdAdd 一致(那里 Facing 与 EulerAngles.y
// 填的是同一个值)。曾误改为弧度,导致下发 -88.5 被当成弧度(-5070°)。
return MathF.Atan2(dx, dz) * 180f / MathF.PI;
}
}