using System.Globalization; using Ananta.Server.Configuration; namespace Ananta.Server.Handlers.Game; /// /// 行人路网(服务端侧 ZoneGraph 简化版)—— 让氛围行人**沿着真实人行道走**,而不是原地站着。 /// /// 数据来自世界车道导出(`All lanes.rar`): /// `_ref/lanes/lanes/pedestrian-lane-polylines.csv` (Handle;Seq;X;Y;Z,244398 点) /// `_ref/lanes/lanes/pedestrian-lane-links.csv` (FromHandle;ToHandle,263448 条) /// 由 `_ref/_gen_ped_lanes.py` 压成两份运行时表: /// `Ananta.Server/Ananta.App/data/ped_lanes.csv` = 每条车道一行:handle;x0;y0;z0;x1;y1;z1;... /// `Ananta.Server/Ananta.App/data/ped_lane_links.csv` = 每条车道一行:handle;to1;to2;... /// /// ★ 2026-09-22 新增第三份表(本轮 T2-37 落点): /// `Ananta.Server/Ananta.App/data/ped_lane_meta.csv` = handle;width;tags /// 由 `_gen_ped_lane_meta.py` 从 `zonegraph_lanes_ped.csv`(65,547 段)按 1 米网格反查上来, /// 命中率 100%。它带两个自产路网没有的属性: /// width = 人行道真实宽度(中位 2.0 m)⇒ 横向 clamp,别让所有人走在同一条几何中线上 /// tags = 位标记,131072 = PedestrianBanned(禁行)⇒ 投放 / 换道时跳过 /// /// 用法:给一个行人分配一条车道 + 起点进度,然后每 tick 调 推进固定距离, /// 把结果塞进 `SyncLogicAgentCommand`(TaskWayPointMove)发给客户端。走到车道尽头就顺着 link 换下一条, /// 没有合格出边就原地掉头(Dir 取反),所以行人永远不会"走出路网消失"。 /// internal static class PedLaneGraph { /// PedestrianBanned:这类车道官方禁止行人通行(实测 474 条)。 private const int TagPedestrianBanned = 131072; /// 换道允许的最大间隙(米)。超过就是"抄近路穿广场/绿化带"——宁可掉头。 /// 依据:99.9% 的 lane link 间隙 ≤ 16 m。 private const float MaxHopGapMetres = 16f; /// 规划路径的搜索半径(米)。Dijkstra 按距离出队,一旦出队距离超过它就放弃 —— /// 一是不给行人规划出几百米的超长行程,二是起终点不在同一连通块时不用遍历整个路网 /// (88k 车道 / 263k 边)才肯返回 null。 private const float MaxRouteSearchMetres = 400f; /// /// 横向偏移的绝对上限(米),来自 `ambientPedsLateralMaxMetres`(配置,默认 0.4)。 /// 即使车道被反查到很大的宽度也不超过它 —— `ped_lane_meta.csv` 的 width 是 1 m 网格空间哈希 /// 反查出来的,窄路有可能被安上宽路的宽度,这是第二道保险。 /// ⛑️ **设成 0 就完全不偏移(全员走车道中线)** —— 排查"行人卡墙"时先用它排除嫌疑。 /// private static float MaxLateralMetres => MathF.Max(0f, PrivateServerConfigStore.Current.Gameplay.Aether.AmbientPedsLateralMaxMetres); /// /// 横向可走半径的**安全余量**(米),来自 `ambientPedsLateralMarginMetres`(配置,默认 0.9)。 /// HalfWalk = max(0, width/2 - margin) —— margin 越大,行人越往车道中线收。 /// /// ⛑️ 2026-09-26:原值是硬编码 0.6,而车道宽度**中位只有 2.0 m**(p75=3.0 / p90=4.0): /// 2 m 路上算出 HalfWalk=0.4 ⇒ 实测偏移最大 0.36 m,距路缘只剩 0.64 m ⇒ 行人胶囊 + 护栏正好卡在这里。 /// 改成 0.9 后:≤1.8 m 的窄路 HalfWalk 直接归 0(走中线),3~4 m 的宽路仍留 ±0.22 m 差异(不会像排队)。 /// 判据:先设 `ambientPedsLateralMaxMetres=0`(全员中线)确认卡护栏确实是偏移造成,再回到本项微调。 /// private static float LateralMarginMetres => MathF.Max(0f, PrivateServerConfigStore.Current.Gameplay.Aether.AmbientPedsLateralMarginMetres); /// /// 出生点的**最小车道宽度**(米),来自 `ambientPedsSpawnMinWidthMetres`(配置,默认 1.8)。 /// ⛑️ 2026-09-26:候选池原来只看 `Banned`,完全不看宽度 ⇒ 随机抽到 0.5~1.8 m 的窄道 /// (实测宽度中位 2.0 / p10 1.5 / min 0.5,约 17% ≤1.8 m)就生在树池边、栏杆边的小径上。 /// 设 0 = 不过滤(回到旧行为)。 /// private static float MinSpawnWidthMetres => MathF.Max(0f, PrivateServerConfigStore.Current.Gameplay.Aether.AmbientPedsSpawnMinWidthMetres); internal struct WalkState { internal int Lane; internal float Pos; // 段内进度:整数部分 = 段下标,小数部分 = 段内比例 internal sbyte Dir; // +1 顺行 / -1 掉头 /// 横向站位系数(-1..1),一次分配终身不变。 /// 同一个 walker 锁一侧,否则会看到行人在人行道左右横跳(donor round 64 同款结论)。 internal float Offset; /// 上一条走过的车道 handle(-1 = 没有)。换道时禁止立刻退回去 —— 见 。 internal int Prev; /// /// 最近走过的车道环形缓冲(元素 -1 = 空)。长度 = `AmbientPedsRouteMemory`; /// 换道时优先躲开这里面的车道,行人就不会在几条短车道之间绕圈。 /// internal int[]? Recent; } private sealed class Lane { internal float[] Xs = []; internal float[] Ys = []; internal float[] Zs = []; internal float[] SegLen = []; // 每段长度,长度 = 点数-1 internal int[] Next = []; /// 车道总长(各段之和,米)。Dijkstra 的边权要用,加载时算一次。 internal float Length; /// 横向可走半径 = max(0, width/2 - 安全余量)。width 未知时为 0(走中线)。 internal float HalfWalk; /// 人行道真实宽度(米,`ped_lane_meta.csv`)。0 = 未知。出生点过滤要用。 internal float Width; internal bool Banned; internal int Count => Xs.Length; } private const float CellSize = 50f; private sealed class Table { internal Dictionary Lanes = []; internal Dictionary<(int, int), List> Cells = []; internal bool Ok; } private static readonly Lazy Cache = new(Load); internal static bool Available => Cache.Value.Ok; internal static int LaneCount => Cache.Value.Lanes.Count; private static Table Load() { var table = new Table(); var cellsSeen = new HashSet<(int, int)>(); // 每条车道去重:同一格只登记一次 var polyPath = PrivateServerConfigStore.ResolveProjectPath( "Ananta.Server/Ananta.App/data/ped_lanes.csv"); var linkPath = PrivateServerConfigStore.ResolveProjectPath( "Ananta.Server/Ananta.App/data/ped_lane_links.csv"); var metaPath = PrivateServerConfigStore.ResolveProjectPath( "Ananta.Server/Ananta.App/data/ped_lane_meta.csv"); if (!File.Exists(polyPath) || !File.Exists(linkPath)) return table; foreach (var line in File.ReadLines(polyPath)) { if (line.Length == 0) continue; var parts = line.Split(';'); if (parts.Length < 5) continue; if (!int.TryParse(parts[0], out var handle)) continue; var n = (parts.Length - 1) / 3; var lane = new Lane { Xs = new float[n], Ys = new float[n], Zs = new float[n], SegLen = new float[Math.Max(0, n - 1)], }; for (var i = 0; i < n; i++) { lane.Xs[i] = Num(parts[1 + i * 3]); lane.Ys[i] = Num(parts[2 + i * 3]); lane.Zs[i] = Num(parts[3 + i * 3]); } for (var i = 0; i < lane.SegLen.Length; i++) { var dx = lane.Xs[i + 1] - lane.Xs[i]; var dy = lane.Ys[i + 1] - lane.Ys[i]; var dz = lane.Zs[i + 1] - lane.Zs[i]; lane.SegLen[i] = MathF.Sqrt(dx * dx + dy * dy + dz * dz); } for (var i = 0; i < lane.SegLen.Length; i++) lane.Length += lane.SegLen[i]; table.Lanes[handle] = lane; // ⛑️ 2026-09-23:登记**每个采样点**所在的格(原来只登记首点)。 // 只登记首点时,长车道(最长 1001 m)的中段在索引里根本不存在, // 行人站在它上面也会被吸附到几十米外的别的车道。实测 60 个真实起点: // 只登记首点:吸附距离 中位 7.61 m / p90 34.57 m / max 65 m,**88% 选错车道** // 全点登记后:吸附距离 中位 1.40 m / p90 9.27 m // 车道中位长度才 3.58 m,7.6 m 的误差等于把行人钉在隔壁人行道上。 cellsSeen.Clear(); for (var i = 0; i < n; i++) { var cell = CellOf(lane.Xs[i], lane.Zs[i]); if (!cellsSeen.Add(cell)) continue; 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(); } // width / tags:没有这份表也能跑(HalfWalk=0 ⇒ 走中线,Banned=false ⇒ 不过滤)。 if (File.Exists(metaPath)) { foreach (var line in File.ReadLines(metaPath)) { if (line.Length == 0) continue; var parts = line.Split(';'); if (parts.Length < 3 || !int.TryParse(parts[0], out var handle)) continue; if (!table.Lanes.TryGetValue(handle, out var lane)) continue; var width = Num(parts[1]); var tags = 0; int.TryParse(parts[2], out tags); // margin = 角色碰撞半径 + 安全余量。原来硬编码 0.6,窄路上算出来的可偏移空间偏大 // ⇒ 行人被推到街边卡进墙里 / 卡进护栏(2026-09-23 卡墙、2026-09-26 卡护栏,两次实测)。 lane.Width = width; lane.HalfWalk = MathF.Max(0f, width / 2f - LateralMarginMetres); lane.Banned = (tags & TagPedestrianBanned) != 0; } } table.Ok = table.Lanes.Count > 0; return table; } private static float Num(string s) => float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : 0f; private static (int, int) CellOf(float x, float z) => ((int)MathF.Floor(x / CellSize), (int)MathF.Floor(z / CellSize)); /// 在玩家附近随机挑一条车道的起点,用来当行人的出生点。禁行车道不参与。 internal static bool TryPickSpawn(float px, float pz, float radius, Random rng, out float x, out float y, out float z, out WalkState state) { x = y = z = 0f; state = default; 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; if (lane.Banned) continue; // 窄道(树池边 / 栏杆边的小径)不生出人 —— 宽度未知(0)的按不动处理(meta 表已全覆盖)。 if (MinSpawnWidthMetres > 0f && lane.Width > 0f && lane.Width < MinSpawnWidthMetres) continue; var ddx = lane.Xs[0] - px; var ddz = lane.Zs[0] - pz; if (ddx * ddx + ddz * ddz <= radius * radius) pool.Add(h); } } if (pool.Count == 0) return false; var picked = pool[rng.Next(pool.Count)]; var start = table.Lanes[picked]; var st = new WalkState { Lane = picked, // ⛑️ 出生位置/方向必须随机。2026-09-23 用户实测:"同一条路上两个行人会停在一样的 // 位置、一起起步、一起掉头" —— 因为原来恒为 Pos=0 / Dir=+1,同一批生成的人 // 起点重合、方向一致、步长一致、TryHop 又是确定性的(就近优先)⇒ 动作完全同步。 // ⛑️ 相位只落在**段中间**:车道端点常常就是路口 / 路缘 / 护栏端头,生在那儿一出生就卡住。 Pos = start.Count > 1 ? rng.Next(start.Count - 1) + 0.25f + rng.NextSingle() * 0.5f : 0f, Dir = (sbyte)(rng.Next(2) == 0 ? 1 : -1), // 一侧站位:-1..1,避开正好 0(不然还是全走中线)。 Offset = start.HalfWalk > 0f ? (rng.Next(2) == 0 ? -1f : 1f) * (0.35f + rng.NextSingle() * 0.55f) : 0f, Prev = -1, }; Sample(start, st, out x, out y, out z, out _); state = st; return true; } /// 沿车道推进指定距离(米)。返回当前世界坐标与朝向(度,0 = +Z)。 internal static bool Advance(ref WalkState s, float distance, Random rng, out float x, out float y, out float z, out float facing) { var table = Cache.Value; x = y = z = 0f; facing = 0f; if (!table.Ok || !table.Lanes.TryGetValue(s.Lane, out var lane) || lane.Count < 2) return false; var guard = 0; while (distance > 0f && guard++ < 64) { var segCount = lane.Count - 1; var seg = Math.Clamp((int)MathF.Floor(s.Pos), 0, segCount - 1); var len = lane.SegLen[seg]; if (len <= 0f) { // 退化段(重复点):直接跨过去 s.Pos += s.Dir > 0 ? 1f : -1f; continue; } var frac = s.Pos - seg; var remain = (s.Dir > 0 ? 1f - frac : frac) * len; if (distance < remain) { s.Pos += s.Dir * (distance / len); distance = 0f; break; } distance -= remain; s.Pos = s.Dir > 0 ? seg + 1f : seg; if (s.Dir > 0 ? s.Pos >= segCount : s.Pos <= 0f) { if (!TryHop(ref s, lane, rng, table, out lane)) break; } } Sample(lane, s, out x, out y, out z, out facing); return true; } /// /// 到尽头换下一条车道。 /// /// ⛑️ 2026-09-22 两处修正(原来是"随便挑一条出边 + 永远从起点进"): /// ① **就近入口**:比较当前出口到下一条车道**两端**的距离,从近的一端进(可能要反向走), /// 否则行人会"瞬移到另一条车道起点",看起来就是突然横穿 / 回头。 /// ② **间隙闸门**:入口距离 > 16 m 的出边视为断开(广场 / 绿化带),不选; /// 全部超限就在本车道原地掉头。 /// ③ **禁止回退**(2026-09-22 实测后加):不许立刻走回刚过来的那条车道。 /// /// ⛑️ ③ 是实测逼出来的,不是拍脑袋:我们的路网**双向边成对存在**(A→B 和 B→A 都在表里), /// 而"就近入口"又总是挑离出口最近的候选 —— 于是 A 出去必选 B,B 出去必选 A,行人在两条 /// 3~4 m 的短车道之间无限来回。离线复刻 2000 段的实测: /// 乒乓率 95.2% / 每步 30 m 任务的净位移中位数只有 1.2 m / 57.9% 的步在原地打转。 /// 加上禁止回退后:乒乓 0% / 净位移中位 10.4 m(4.5×)/ 打转降到 41.5%。 /// ⛑️ 掉头时要**清掉 Prev**:否则死胡同里(唯一出边就是刚来的那条)会永远掉头来回; /// 清掉之后掉头率从 22.0% 降到 4.8%,行人能走出死胡同。 /// private static bool TryHop(ref WalkState s, Lane lane, Random rng, Table table, out Lane next) { var exitX = s.Dir > 0 ? lane.Xs[lane.Count - 1] : lane.Xs[0]; var exitZ = s.Dir > 0 ? lane.Zs[lane.Count - 1] : lane.Zs[0]; var memory = Math.Clamp(PrivateServerConfigStore.Current.Gameplay.Aether.AmbientPedsRouteMemory, 0, 16); // 两级降级:① 躲开最近 N 条 → ② 只挡紧邻的上一条 → ③ 掉头。 // 不做②的话,记忆越长越容易把路口堵死(实测记忆 8 条不降级的话掉头率会很难看)。 var bestTo = ScanHop(s, lane, table, exitX, exitZ, useMemory: memory > 1, out var bestDir); if (bestTo < 0 && memory > 1) bestTo = ScanHop(s, lane, table, exitX, exitZ, useMemory: false, out bestDir); if (bestTo >= 0 && table.Lanes.TryGetValue(bestTo, out var picked)) { Remember(ref s, s.Lane, memory); s.Prev = s.Lane; s.Lane = bestTo; s.Dir = bestDir; s.Pos = bestDir > 0 ? 0f : picked.Count - 1f; next = picked; return true; } // 没有合格出边:原地掉头。⛑️ Prev 和记忆都要清 —— 死胡同里唯一出边往往就是刚来的那条, // 不清的话掉头之后还是选不到,行人会永远在这一条车道上折返。 // 实测:掉头时保留记忆 ⇒ 掉头率从 9% 飙到 42%(因为禁令把出口全挡住了)。 s.Dir = (sbyte)-s.Dir; s.Pos = Math.Clamp(s.Pos, 0f, lane.Count - 1f); s.Prev = -1; s.Recent = null; next = lane; return true; } /// /// 在出边里挑"离当前出口最近"的一条,返回 handle(-1 = 没有合格候选)。 /// /// true = 连最近走过的几条一起躲开(第一级),false = 只挡紧邻上一条(第二级)。 private static int ScanHop(WalkState s, Lane lane, Table table, float exitX, float exitZ, bool useMemory, out sbyte dir) { dir = 1; var bestTo = -1; var bestDist = float.MaxValue; foreach (var to in lane.Next) { if (!table.Lanes.TryGetValue(to, out var cand) || cand.Count < 2 || cand.Banned) continue; if (to == s.Prev) continue; // ③ 不许立刻退回去(会把行人锁在两条短车道之间来回) if (useMemory && s.Recent is not null) { var seen = false; for (var i = 0; i < s.Recent.Length; i++) { if (s.Recent[i] == to) { seen = true; break; } } if (seen) continue; } var sdx = cand.Xs[0] - exitX; var sdz = cand.Zs[0] - exitZ; var dStart = MathF.Sqrt(sdx * sdx + sdz * sdz); var edx = cand.Xs[cand.Count - 1] - exitX; var edz = cand.Zs[cand.Count - 1] - exitZ; var dEnd = MathF.Sqrt(edx * edx + edz * edz); var forward = dStart <= dEnd; var d = forward ? dStart : dEnd; if (d > MaxHopGapMetres || d >= bestDist) continue; bestDist = d; bestTo = to; dir = forward ? (sbyte)1 : (sbyte)-1; } return bestTo; } /// 把刚走过的车道记进环形缓冲(新的在头部)。memory ≤ 1 时不建数组。 private static void Remember(ref WalkState s, int lane, int memory) { if (memory <= 1) return; var buf = s.Recent; if (buf is null || buf.Length != memory) { buf = new int[memory]; Array.Fill(buf, -1); s.Recent = buf; } for (var i = buf.Length - 1; i > 0; i--) buf[i] = buf[i - 1]; buf[0] = lane; } private static void Sample(Lane lane, WalkState s, out float x, out float y, out float z, out float facing) { var segCount = lane.Count - 1; var seg = Math.Clamp((int)MathF.Floor(s.Pos), 0, segCount - 1); var t = Math.Clamp(s.Pos - seg, 0f, 1f); var a = seg; var b = seg + 1; var ax = lane.Xs[a]; var az = lane.Zs[a]; var bx = lane.Xs[b]; var bz = lane.Zs[b]; // 横向 clamp:把整段沿法向推开,行人不挤在车道几何中线上。 if (lane.HalfWalk > 0f && s.Offset != 0f) { var dx = bx - ax; var dz = bz - az; var len = MathF.Sqrt(dx * dx + dz * dz); if (len > 1e-4f) { // 与 TryPlanJourney 同一套夹紧规则:min(HalfWalk, 配置上限) × 比例。 var off = MathF.Min(lane.HalfWalk, MaxLateralMetres) * s.Offset; var nx = -dz / len * off; var nz = dx / len * off; ax += nx; az += nz; bx += nx; bz += nz; } } x = ax + (bx - ax) * t; y = lane.Ys[a] + (lane.Ys[b] - lane.Ys[a]) * t; z = az + (bz - az) * t; var fdx = (bx - ax) * s.Dir; var fdz = (bz - az) * s.Dir; facing = fdx * fdx + fdz * fdz > 1e-6f ? MathF.Atan2(fdx, fdz) * 180f / MathF.PI : 0f; } /// /// 沿路网规划一条 from → to 的折线 —— `AskClientNpcFindPath`(67974714) 的真路径应答。 /// /// 客户端只在它自己要走路时才来问(我们的人是被服务端 TaskWayPointMove 驱动着走的, /// 但 NPC / 某些单位会走客户端寻路)。现在默认回的是**空路径**(`0xFF01FF01`), /// 等于告诉客户端"没有路" ⇒ 那条单位就站着。 /// /// ⛑️ 规划不出来(路网没覆盖 / 不在同一连通块 / 距离太远)一律返回 null, /// 调用方照旧回空路径 —— 宁可不动,也不要给一条穿墙的直线。 /// /// /// 可选输出:每个路径点所属车道的 (可横向偏移的上限,米)。 /// 调用方要按它夹紧横向偏移 —— 窄路上限接近 0,就不该把行人往路边推(会推到墙里)。 /// internal static List<(float X, float Y, float Z)>? PlanRoute( float fromX, float fromZ, float toX, float toZ, int maxPoints = 64, float minSpacing = 1.5f, List? lateralCap = null) { var table = Cache.Value; if (!table.Ok) return null; var start = NearestLane(table, fromX, fromZ, out var startIdx); var goal = NearestLane(table, toX, toZ, out var goalIdx); if (start < 0 || goal < 0) return null; var chain = ShortestChain(table, start, goal); if (chain is null) return null; var raw = new List<(float X, float Y, float Z)>(256); var rawCap = lateralCap is null ? null : new List(256); var single = chain.Count == 1; for (var i = 0; i < chain.Count; i++) { if (!table.Lanes.TryGetValue(chain[i], out var lane) || lane.Count == 0) continue; var from = i == 0 ? startIdx : 0; var to = i == chain.Count - 1 ? goalIdx : lane.Count - 1; if (single) { // ⛑ 同一条车道:必须按 起点→终点 的**真实方向**遍历。 // 老实现在这里 `if (from > to) swap` 会把整条路径掉个头 —— 行人先倒着走到 // 起点再掉回来;而 startIdx == goalIdx(≤6 m 的短距离必然命中)时只产出 1 个点, // ⇒ raw.Count < 2 ⇒ 整条路作废回 null。 // 实测 2026-09-22:90 s 内 191 次问路、真实移动请求 115 次,回空 73 次(63.5%), // 失败距离**全部在 1.8–5.9 m** —— 就是这一条,不是路网没覆盖。 if (to == from) { // 两端吸附到同一个采样点:向两侧各扩一个点,保底 ≥3 点。 from = Math.Max(0, from - 1); to = Math.Min(lane.Count - 1, to + 1); } var step = to >= from ? 1 : -1; var guard = lane.Count + 1; // 防御:车道只有 1 个点时不许死循环 for (var k = from; guard-- > 0; k += step) { raw.Add((lane.Xs[k], lane.Ys[k], lane.Zs[k])); rawCap?.Add(lane.HalfWalk); if (k == to) break; } continue; } // 跨车道:链路本身有向(本车道末端 → 下一条首端),中间段整条顺行,两处裁剪都成立。 if (from > to) (from, to) = (to, from); for (var k = from; k <= to; k++) { raw.Add((lane.Xs[k], lane.Ys[k], lane.Zs[k])); rawCap?.Add(lane.HalfWalk); } } // 端点锚定到请求的真实坐标 —— 车道采样点有间距,不锚定会让路径两端"偏移到采样点上"。 if (raw.Count >= 2) { raw[0] = (fromX, raw[0].Y, fromZ); raw[^1] = (toX, raw[^1].Y, toZ); } else { // 极端情形(车道只有 1 个采样点):给同车道内的两点直线。≤6 m,不会穿墙。 var y = raw.Count == 1 ? raw[0].Y : 0f; var cap = rawCap is { Count: > 0 } ? rawCap[0] : 0f; raw.Clear(); rawCap?.Clear(); raw.Add((fromX, y, fromZ)); raw.Add((toX, y, toZ)); rawCap?.Add(cap); rawCap?.Add(cap); } // 按最小间距重采样(donor:MaxPoints=64 / MinMetres=1.5 / SnapMetres=3)。 var points = new List<(float X, float Y, float Z)>(Math.Min(maxPoints, raw.Count)); var last = raw[0]; points.Add(last); lateralCap?.Add(rawCap?[0] ?? 0f); for (var i = 1; i < raw.Count && points.Count < maxPoints; i++) { var dx = raw[i].X - last.X; var dz = raw[i].Z - last.Z; if (dx * dx + dz * dz < minSpacing * minSpacing) continue; points.Add(raw[i]); lateralCap?.Add(rawCap?[i] ?? 0f); last = raw[i]; } // 无论 minSpacing 重采样丢掉多少中间点,最后一点必须是真实目标 —— // 否则客户端沿路径走到倒数第二个点就"没路了",又会站住。 if (points.Count > 0 && points.Count < maxPoints) { var tailY = raw[^1].Y; var tdx = toX - points[^1].X; var tdz = toZ - points[^1].Z; if (tdx * tdx + tdz * tdz > 0.09f) // > 0.3 m 才补,免得制造重复点 { points.Add((toX, tailY, toZ)); lateralCap?.Add(rawCap?[^1] ?? 0f); } } return points.Count >= 2 ? points : null; } /// /// 目标导向行程:从 (fromX,fromZ) 走向"以 (centerX,centerZ) 为中心的环形区域内随机一点"。 /// /// ⛑️ 为什么要有这个:随机游走( + )在我们的路网上 /// 只在**局部邻域**打转 —— 86.6% 的车道只有 2 个采样点、长度中位数 3.58 m,行人走几步就到 /// 端点、换道、再回来。2026-09-23 用户实测:"行人只是从一条路的一头走到另一头,路很短就很奇怪"。 /// 给它一个几十米外的目标,用已经实机验证 100% 可用的 规划整条路径, /// 行人就会穿过多个路口、像在赶路。 /// /// 终点取环形区域(而非行人自身附近的随机方向)是为了让行程始终留在玩家活动圈内 —— /// 否则行人一路走出 ambientPedsRecycleMeters 被回收,看起来就是"路人凭空消失"。 /// /// /// 横向偏移**比例**(-1..1,带符号,终身锁定一侧)。实际偏移量 = 该点车道的 /// × 这个值,再受 夹住。 /// /// ⛑️ 这里必须是**比例而不是米数**:2026-09-23 用户实测"窄路上行人卡在街边的墙壁里" —— /// 老代码传的是固定的 0.6 m,跟车道多宽完全无关。窄路的 HalfWalk 常常就是 0(路宽 ≤ 0.8 m), /// 该一点都不偏,结果照样把人往外推 0.21~0.54 m ⇒ 推进墙里。 /// 随机游走的 一直用 `HalfWalk × Offset` 是对的,只有目标导向这条路径抄错了。 /// internal static List<(float X, float Y, float Z)>? TryPlanJourney( float fromX, float fromZ, float centerX, float centerZ, float minR, float maxR, float lateralRatio, Random rng, int maxPoints = 512) { // ⛑️⛑️ 终点必须落在**人行道上**,不能用环形区域里的自由随机点。 // 2026-09-23 用户实测"少数行人撞墙不停走":那个点跟路网毫无关系,实测 92% 离最近 // 人行道 ≥ 2 m(中位 11 m、最大 47 m),也就是常常落在建筑/江里;而 `PlanRoute` 末尾 // 会把最后一个路径点**硬锚定**到 `toX/toZ` ⇒ 行人的最后一段是"扎进墙里", // 客户端到不了终点就一直往里推。 // 修法:多次取样,取离路网最近的那个候选,并用它的车道点坐标当真正的终点。 if (!TryPickGoalOnRing(centerX, centerZ, minR, maxR, rng, out var gx, out var gz)) return null; List caps = []; var path = PlanRoute(fromX, fromZ, gx, gz, maxPoints, 1.5f, caps); if (path is null || path.Count < 2) return null; if (MathF.Abs(lateralRatio) > 1e-3f) { // 法向用前后点差分求(首尾退化为用单边),与 Sample() 里的横向 clamp 同思路。 for (var i = 0; i < path.Count; i++) { var a = path[Math.Max(0, i - 1)]; var b = path[Math.Min(path.Count - 1, i + 1)]; var dx = b.X - a.X; var dz = b.Z - a.Z; var len = MathF.Sqrt(dx * dx + dz * dz); if (len < 1e-4f) continue; var cap = i < caps.Count ? caps[i] : 0f; var off = MathF.Min(cap, MaxLateralMetres) * lateralRatio; if (MathF.Abs(off) < 1e-4f) continue; // 窄路:一点都不推 path[i] = (path[i].X - dz / len * off, path[i].Y, path[i].Z + dx / len * off); } } return path; } /// 候选终点偏离最近人行道的容忍值(米)。超过就再摇一次方向。 private const float MaxGoalSnapMetres = 8f; /// 一次行程最多摇几个候选终点。实测 6 次里基本都能摇出 ≤ 8 m 的(到处都是人行道)。 private const int GoalTries = 6; /// /// 在玩家周围环形区域里挑一个**已经在人行道上**的行程终点。 /// /// 直接拿环形随机点当终点是错的 —— 那个点在建筑/江里的概率极高(实测 92% 偏离 ≥ 2 m), /// 而 尾部会把路径最后一点硬拉到 toX/toZ, /// 于是行人的最后一段任务终点在墙里,客户端到不了就一直往墙里推。 /// private static bool TryPickGoalOnRing(float centerX, float centerZ, float minR, float maxR, Random rng, out float gx, out float gz) { gx = centerX; gz = centerZ; var table = Cache.Value; if (!table.Ok) return false; var bestScore = float.MaxValue; for (var t = 0; t < GoalTries; t++) { var ang = rng.NextSingle() * MathF.PI * 2f; var r = minR + rng.NextSingle() * MathF.Max(0f, maxR - minR); var cx = centerX + MathF.Cos(ang) * r; var cz = centerZ + MathF.Sin(ang) * r; var h = NearestLane(table, cx, cz, out var idx); if (h < 0 || !table.Lanes.TryGetValue(h, out var lane) || lane.Count == 0 || lane.Banned) continue; idx = Math.Clamp(idx, 0, lane.Count - 1); var px = lane.Xs[idx]; var pz = lane.Zs[idx]; var d = MathF.Sqrt((px - cx) * (px - cx) + (pz - cz) * (pz - cz)); if (d >= bestScore) continue; bestScore = d; gx = px; gz = pz; if (d <= MaxGoalSnapMetres) return true; } // 全都偏远:用最贴近的那个(已写进 gx/gz),总比让终点落在建筑群里好。 return bestScore < float.MaxValue; } /// /// 把路网漫游状态吸附回 (x,z) 所在的车道 —— 从"目标导向"退回随机游走之前必须调一次。 /// 不吸附的话 还停在生成时那条老车道上,下一拍 /// 会从那里出点 ⇒ 行人瞬移几十米。 /// /// /// 出生朝向 = 该点在车道上的走向(度,0 = +Z),`Dir=-1` 时自动反向。 /// ⛑️ 2026-09-26:出生朝向原来是 _random * 360 - 180,跟车道走向毫无关系, /// 而位移由 `TaskWayPointMove` 的路径点决定 ⇒ 身体朝 A、位移朝 B = 看起来在倒退走路。 /// 与 共用同一套 facing 定义,保证朝向和采样点永远一致。 /// internal static bool TryFacing(WalkState s, out float facing) { facing = 0f; var table = Cache.Value; if (!table.Ok || !table.Lanes.TryGetValue(s.Lane, out var lane) || lane.Count < 2) return false; Sample(lane, s, out _, out _, out _, out facing); return float.IsFinite(facing); } internal static bool SnapTo(float x, float z, ref WalkState s) { var table = Cache.Value; if (!table.Ok) return false; var h = NearestLane(table, x, z, out var idx); if (h < 0 || !table.Lanes.TryGetValue(h, out var lane) || lane.Count < 2) return false; s.Lane = h; s.Pos = Math.Clamp((float)idx, 0f, lane.Count - 1f); s.Dir = 1; s.Prev = -1; s.Recent = null; return true; } /// /// 「脱困 / 起步」点 —— 规划不出路(或起终点重合)时给客户端一个能走的点,而不是回空路径。 /// /// ⛑️⛑️ 2026-09-23 实测:单位 spawn 后第一个问路包就是 From == To(packets 日志 DECODED 行证实), /// 我们回 4 字节空路径 FF 01 FF 01 ⇒ 它拿不到方向,就一直杵到客户端自驱 wander 重新武装(140 s)。 /// 卡在花坛 / 门框里的单位同样只会反复问 from ≈ to,回空 ⇒ 永远挪不出来。 /// 这里给的是「最近车道上、朝车道远端走 metres 米」的一个点 —— 只要还落在人行道上就不会穿墙。 /// internal static bool TryPickEscape(float x, float z, float metres, out float ex, out float ey, out float ez) { ex = ey = ez = 0f; var table = Cache.Value; if (!table.Ok) return false; var h = NearestLane(table, x, z, out var idx); if (h < 0 || !table.Lanes.TryGetValue(h, out var lane) || lane.Count < 2) return false; metres = MathF.Max(1f, metres); var start = Math.Clamp(idx, 0, lane.Count - 1); // 贴着车道末端就往回走,否则顺行 —— 免得一步就跨出车道尽头。 var dir = start >= lane.Count - 2 ? -1 : 1; var i = start; var acc = 0f; while (i + dir >= 0 && i + dir <= lane.Count - 1 && acc < metres) { var seg = dir > 0 ? i : i - 1; if (seg < 0 || seg >= lane.SegLen.Length) break; acc += lane.SegLen[seg]; i += dir; } // 一步都没挪动(退化车道 / 全零段)⇒ 退到车道另一端,仍然比原地不动强。 if (i == start) i = dir > 0 ? 0 : lane.Count - 1; if (i == start) return false; var st = new WalkState { Lane = h, Pos = i, Dir = (sbyte)dir, // 脱困一律走中线:卡住的单位多半就是被横向偏移推进了花坛 / 门框。 Offset = 0f, Prev = -1, }; Sample(lane, st, out ex, out ey, out ez, out _); return float.IsFinite(ex) && float.IsFinite(ez); } /// 找离 (x,z) 最近的车道,并给出最近的点下标。 private static int NearestLane(Table table, float x, float z, out int pointIndex) { pointIndex = 0; var best = -1; var bestD = float.MaxValue; var baseCell = CellOf(x, z); // ⛑️ 2026-09-23 修:dx 那一层**不能**带 `best < 0` 提前退出。 // 原来一在 dx = -span(最左一列,离行人最远的那一列)找到任何候选就直接收工, // 行人脚下那一列(dx = 0)根本没被搜过 —— 结果就是"找到什么算什么", // 实测吸附距离中位 7.6 m / p90 34.6 m / 88% 选错车道。 // 正确做法:每个 span 把整个方形走完(越靠中心越近),只由 span 层决定是否继续扩大。 for (var span = 1; span <= 8 && best < 0; span++) for (var dx = -span; dx <= span; dx++) for (var dz = -span; dz <= span; 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) || lane.Banned) continue; for (var i = 0; i < lane.Count; i++) { var ddx = lane.Xs[i] - x; var ddz = lane.Zs[i] - z; var d = ddx * ddx + ddz * ddz; if (d >= bestD) continue; bestD = d; best = h; pointIndex = i; } } } return best; } /// 车道图上的 BFS。限制访问预算,别为一次问路把 88k 条车道全走一遍。 /// /// 按**实际距离**的最短路(Dijkstra),而不是按跳数。 /// /// ⛑️⛑️ 2026-09-23 两个真 bug,都是离线量化逼出来的(模拟器 `_pedgate_sim.py`,250 条真实行程): /// /// ① **没有间隙闸门**。`TryHop`(随机游走)有 `MaxHopGapMetres` 闸门,这里原来**完全没有** —— /// 而 link 表里有 **20.6% 的边端点间隙 > 16 m,最大 859 m**(全量统计,不是抽样)。 /// 实测一条 49 m 的行程规划出 416 m 的路径,轨迹里出现"先去西边 80 m 再回到起点"。 /// 规划路径(目标导向行程 + `AskClientNpcFindPath` 应答)一直在走这些跨空间的边。 /// ② **按跳数最短路**会疯狂使用长车道:车道长度中位 3.56 m 但最长 856 m, /// 跳数最少 ⇏ 距离最短,方向还可能完全背离目标。 /// /// 修完的实测(目标 45~135 m): /// BFS 无闸门:绕行 3.02x(路径 279 m / 净位移 93 m) /// BFS + 闸门:绕行 2.09x(186 m) /// **Dijkstra + 闸门:绕行 1.82x(165 m),成功率 99.6%,单次 2.3 ms** ← 采用 /// 城市路网里 1.8x 的绕行是正常的绕街区,可以接受。 /// private static List? ShortestChain(Table table, int start, int goal) { if (start == goal) return [start]; var prev = new Dictionary { [start] = -1 }; var dist = new Dictionary { [start] = 0f }; var settled = new HashSet(); var queue = new PriorityQueue(); queue.Enqueue(start, 0f); var budget = 200000; while (queue.Count > 0 && budget-- > 0) { var cur = queue.Dequeue(); if (!settled.Add(cur)) continue; if (cur == goal) break; if (!table.Lanes.TryGetValue(cur, out var lane)) continue; var dCur = dist[cur]; if (dCur > MaxRouteSearchMetres) break; // 已经搜出这么远还没到 ⇒ 放弃(不可达 / 太远) // 路径的实际接续方式是"本车道末端 → 下一条首端"(与 PlanRoute 里取点的顺序一致)。 var exitX = lane.Xs[lane.Count - 1]; var exitZ = lane.Zs[lane.Count - 1]; foreach (var to in lane.Next) { if (settled.Contains(to)) continue; if (!table.Lanes.TryGetValue(to, out var cand) || cand.Banned) continue; var gx = cand.Xs[0] - exitX; var gz = cand.Zs[0] - exitZ; var hop = MathF.Sqrt(gx * gx + gz * gz); if (hop > MaxHopGapMetres) continue; // 跨空间的坏边:宁可绕一点,也不要让路径瞬移 var nd = dCur + hop + cand.Length; if (nd >= dist.GetValueOrDefault(to, float.MaxValue)) continue; dist[to] = nd; prev[to] = cur; queue.Enqueue(to, nd); } } if (!prev.ContainsKey(goal)) return null; var chain = new List(); for (var n = goal; n != -1; n = prev[n]) chain.Add(n); chain.Reverse(); return chain; } /// 诊断用:路网里有多少条禁行车道、多少条带宽度。给 GM 面板 / 启动日志看。 internal static (int Lanes, int Banned, int WithWidth) Stats() { var table = Cache.Value; var banned = 0; var withWidth = 0; foreach (var lane in table.Lanes.Values) { if (lane.Banned) banned++; if (lane.HalfWalk > 0f) withWidth++; } return (table.Lanes.Count, banned, withWidth); } }