This repository has been archived on 2026-10-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files

983 lines
44 KiB
C#
Raw Permalink Normal View History

using System.Globalization;
using Ananta.Server.Configuration;
namespace Ananta.Server.Handlers.Game;
/// <summary>
/// 行人路网(服务端侧 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 调 <see cref="Advance"/> 推进固定距离,
/// 把结果塞进 `SyncLogicAgentCommand`(TaskWayPointMove)发给客户端。走到车道尽头就顺着 link 换下一条,
/// 没有合格出边就原地掉头(Dir 取反),所以行人永远不会"走出路网消失"。
/// </summary>
internal static class PedLaneGraph
{
/// <summary>PedestrianBanned:这类车道官方禁止行人通行(实测 474 条)。</summary>
private const int TagPedestrianBanned = 131072;
/// <summary>换道允许的最大间隙(米)。超过就是"抄近路穿广场/绿化带"——宁可掉头。
/// 依据:99.9% 的 lane link 间隙 ≤ 16 m。</summary>
private const float MaxHopGapMetres = 16f;
/// <summary>规划路径的搜索半径(米)。Dijkstra 按距离出队,一旦出队距离超过它就放弃 ——
/// 一是不给行人规划出几百米的超长行程,二是起终点不在同一连通块时不用遍历整个路网
/// (88k 车道 / 263k 边)才肯返回 null。</summary>
private const float MaxRouteSearchMetres = 400f;
/// <summary>
/// 横向偏移的绝对上限(米),来自 `ambientPedsLateralMaxMetres`(配置,默认 0.4)。
/// 即使车道被反查到很大的宽度也不超过它 —— `ped_lane_meta.csv` 的 width 是 1 m 网格空间哈希
/// 反查出来的,窄路有可能被安上宽路的宽度,这是第二道保险。
/// ⛑️ **设成 0 就完全不偏移(全员走车道中线)** —— 排查"行人卡墙"时先用它排除嫌疑。
/// </summary>
private static float MaxLateralMetres
=> MathF.Max(0f, PrivateServerConfigStore.Current.Gameplay.Aether.AmbientPedsLateralMaxMetres);
/// <summary>
/// 横向可走半径的**安全余量**(米),来自 `ambientPedsLateralMarginMetres`(配置,默认 0.9)。
/// <c>HalfWalk = max(0, width/2 - margin)</c> —— 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`(全员中线)确认卡护栏确实是偏移造成,再回到本项微调。
/// </summary>
private static float LateralMarginMetres
=> MathF.Max(0f, PrivateServerConfigStore.Current.Gameplay.Aether.AmbientPedsLateralMarginMetres);
/// <summary>
/// 出生点的**最小车道宽度**(米),来自 `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 = 不过滤(回到旧行为)。
/// </summary>
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 掉头
/// <summary>横向站位系数(-1..1),一次分配终身不变。
/// 同一个 walker 锁一侧,否则会看到行人在人行道左右横跳(donor round 64 同款结论)。</summary>
internal float Offset;
/// <summary>上一条走过的车道 handle(-1 = 没有)。换道时禁止立刻退回去 —— 见 <see cref="TryHop"/>。</summary>
internal int Prev;
/// <summary>
/// 最近走过的车道环形缓冲(元素 -1 = 空)。长度 = `AmbientPedsRouteMemory`;
/// 换道时优先躲开这里面的车道,行人就不会在几条短车道之间绕圈。
/// </summary>
internal int[]? Recent;
}
private sealed class Lane
{
internal float[] Xs = [];
internal float[] Ys = [];
internal float[] Zs = [];
internal float[] SegLen = []; // 每段长度,长度 = 点数-1
internal int[] Next = [];
/// <summary>车道总长(各段之和,米)。Dijkstra 的边权要用,加载时算一次。</summary>
internal float Length;
/// <summary>横向可走半径 = max(0, width/2 - 安全余量)。width 未知时为 0(走中线)。</summary>
internal float HalfWalk;
/// <summary>人行道真实宽度(米,`ped_lane_meta.csv`)。0 = 未知。出生点过滤要用。</summary>
internal float Width;
internal bool Banned;
internal int Count => Xs.Length;
}
private const float CellSize = 50f;
private sealed class Table
{
internal Dictionary<int, Lane> Lanes = [];
internal Dictionary<(int, int), List<int>> Cells = [];
internal bool Ok;
}
private static readonly Lazy<Table> 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<int>(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));
/// <summary>在玩家附近随机挑一条车道的起点,用来当行人的出生点。禁行车道不参与。</summary>
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<int> 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;
}
/// <summary>沿车道推进指定距离(米)。返回当前世界坐标与朝向(度,0 = +Z)。</summary>
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;
}
/// <summary>
/// 到尽头换下一条车道。
///
/// ⛑️ 2026-09-22 两处修正(原来是"随便挑一条出边 + 永远从起点进"):
/// ① **就近入口**:比较当前出口到下一条车道**两端**的距离,从近的一端进(可能要反向走),
/// 否则行人会"瞬移到另一条车道起点",看起来就是突然横穿 / 回头。
/// ② **间隙闸门**:入口距离 &gt; 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%,行人能走出死胡同。
/// </summary>
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;
}
/// <summary>
/// 在出边里挑"离当前出口最近"的一条,返回 handle(-1 = 没有合格候选)。
/// </summary>
/// <param name="useMemory">true = 连最近走过的几条一起躲开(第一级),false = 只挡紧邻上一条(第二级)。</param>
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;
}
/// <summary>把刚走过的车道记进环形缓冲(新的在头部)。memory ≤ 1 时不建数组。</summary>
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;
}
/// <summary>
/// 沿路网规划一条 from → to 的折线 —— `AskClientNpcFindPath`(67974714) 的真路径应答。
///
/// 客户端只在它自己要走路时才来问(我们的人是被服务端 TaskWayPointMove 驱动着走的,
/// 但 NPC / 某些单位会走客户端寻路)。现在默认回的是**空路径**(`0xFF01FF01`),
/// 等于告诉客户端"没有路" ⇒ 那条单位就站着。
///
/// ⛑️ 规划不出来(路网没覆盖 / 不在同一连通块 / 距离太远)一律返回 null,
/// 调用方照旧回空路径 —— 宁可不动,也不要给一条穿墙的直线。
/// </summary>
/// <param name="lateralCap">
/// 可选输出:每个路径点所属车道的 <see cref="Lane.HalfWalk"/>(可横向偏移的上限,米)。
/// 调用方要按它夹紧横向偏移 —— 窄路上限接近 0,就不该把行人往路边推(会推到墙里)。
/// </param>
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<float>? 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<float>(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;
}
/// <summary>
/// 目标导向行程:从 (fromX,fromZ) 走向"以 (centerX,centerZ) 为中心的环形区域内随机一点"。
///
/// ⛑️ 为什么要有这个:随机游走(<see cref="Advance"/> + <see cref="TryHop"/>)在我们的路网上
/// 只在**局部邻域**打转 —— 86.6% 的车道只有 2 个采样点、长度中位数 3.58 m,行人走几步就到
/// 端点、换道、再回来。2026-09-23 用户实测:"行人只是从一条路的一头走到另一头,路很短就很奇怪"。
/// 给它一个几十米外的目标,用已经实机验证 100% 可用的 <see cref="PlanRoute"/> 规划整条路径,
/// 行人就会穿过多个路口、像在赶路。
///
/// 终点取环形区域(而非行人自身附近的随机方向)是为了让行程始终留在玩家活动圈内 ——
/// 否则行人一路走出 <c>ambientPedsRecycleMeters</c> 被回收,看起来就是"路人凭空消失"。
/// </summary>
/// <param name="lateralRatio">
/// 横向偏移**比例**(-1..1,带符号,终身锁定一侧)。实际偏移量 = 该点车道的
/// <see cref="Lane.HalfWalk"/> × 这个值,再受 <see cref="MaxLateralMetres"/> 夹住。
///
/// ⛑️ 这里必须是**比例而不是米数**:2026-09-23 用户实测"窄路上行人卡在街边的墙壁里" ——
/// 老代码传的是固定的 0.6 m,跟车道多宽完全无关。窄路的 HalfWalk 常常就是 0(路宽 ≤ 0.8 m),
/// 该一点都不偏,结果照样把人往外推 0.21~0.54 m ⇒ 推进墙里。
/// 随机游走的 <see cref="Sample"/> 一直用 `HalfWalk × Offset` 是对的,只有目标导向这条路径抄错了。
/// </param>
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<float> 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;
}
/// <summary>候选终点偏离最近人行道的容忍值(米)。超过就再摇一次方向。</summary>
private const float MaxGoalSnapMetres = 8f;
/// <summary>一次行程最多摇几个候选终点。实测 6 次里基本都能摇出 ≤ 8 m 的(到处都是人行道)。</summary>
private const int GoalTries = 6;
/// <summary>
/// 在玩家周围环形区域里挑一个**已经在人行道上**的行程终点。
///
/// 直接拿环形随机点当终点是错的 —— 那个点在建筑/江里的概率极高(实测 92% 偏离 ≥ 2 m),
/// 而 <see cref="PlanRoute"/> 尾部会把路径最后一点硬拉到 <c>toX/toZ</c>,
/// 于是行人的最后一段任务终点在墙里,客户端到不了就一直往墙里推。
/// </summary>
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;
}
/// <summary>
/// 把路网漫游状态吸附回 (x,z) 所在的车道 —— 从"目标导向"退回随机游走之前必须调一次。
/// 不吸附的话 <see cref="WalkState.Lane"/> 还停在生成时那条老车道上,下一拍 <see cref="Advance"/>
/// 会从那里出点 ⇒ 行人瞬移几十米。
/// </summary>
/// <summary>
/// 出生朝向 = 该点在车道上的走向(度,0 = +Z),`Dir=-1` 时自动反向。
/// ⛑️ 2026-09-26:出生朝向原来是 <c>_random * 360 - 180</c>,跟车道走向毫无关系,
/// 而位移由 `TaskWayPointMove` 的路径点决定 ⇒ 身体朝 A、位移朝 B = 看起来在倒退走路。
/// 与 <see cref="Sample"/> 共用同一套 facing 定义,保证朝向和采样点永远一致。
/// </summary>
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;
}
/// <summary>
/// 「脱困 / 起步」点 —— 规划不出路(或起终点重合)时给客户端一个能走的点,而不是回空路径。
///
/// ⛑️⛑️ 2026-09-23 实测:单位 spawn 后第一个问路包就是 <c>From == To</c>(packets 日志 DECODED 行证实),
/// 我们回 4 字节空路径 <c>FF 01 FF 01</c> ⇒ 它拿不到方向,就一直杵到客户端自驱 wander 重新武装(140 s)。
/// 卡在花坛 / 门框里的单位同样只会反复问 <c>from ≈ to</c>,回空 ⇒ 永远挪不出来。
/// 这里给的是「最近车道上、朝车道远端走 metres 米」的一个点 —— 只要还落在人行道上就不会穿墙。
/// </summary>
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);
}
/// <summary>找离 (x,z) 最近的车道,并给出最近的点下标。</summary>
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;
}
/// <summary>车道图上的 BFS。限制访问预算,别为一次问路把 88k 条车道全走一遍。</summary>
/// <summary>
/// 按**实际距离**的最短路(Dijkstra),而不是按跳数。
///
/// ⛑️⛑️ 2026-09-23 两个真 bug,都是离线量化逼出来的(模拟器 `_pedgate_sim.py`,250 条真实行程):
///
/// ① **没有间隙闸门**。`TryHop`(随机游走)有 `MaxHopGapMetres` 闸门,这里原来**完全没有** ——
/// 而 link 表里有 **20.6% 的边端点间隙 &gt; 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 的绕行是正常的绕街区,可以接受。
/// </summary>
private static List<int>? ShortestChain(Table table, int start, int goal)
{
if (start == goal)
return [start];
var prev = new Dictionary<int, int> { [start] = -1 };
var dist = new Dictionary<int, float> { [start] = 0f };
var settled = new HashSet<int>();
var queue = new PriorityQueue<int, float>();
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<int>();
for (var n = goal; n != -1; n = prev[n])
chain.Add(n);
chain.Reverse();
return chain;
}
/// <summary>诊断用:路网里有多少条禁行车道、多少条带宽度。给 GM 面板 / 启动日志看。</summary>
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);
}
}