using System.Globalization; using Ananta.Server.Configuration; namespace Ananta.Server.ClientData.Client4229938; /// /// 一个「过街等待区」里的站位槽。 /// /// ★ 这份数据的真身是客户端 MassAI.MassCrowd.CrowdWaitAreaData /// (dump.cs TypeDefIndex 57264: /// NativeArray<CrowdWaitSlot> Slots + NativeList<int> ConnectedLanes /// + int NumFreeSlots + int CrossLaneHandle),槽位本体 /// CrowdWaitSlot(57263)= Position / Forward / Radius / bOccupied。 /// 行人沿 ConnectedLanes 汇入 → 占一个 Slot 站定(Radius + bOccupied 保证人不重叠) /// → 等路口相位 → 放行后走 CrossLaneHandle(人行横道)过街 → 释放槽位。 /// /// ⛑⛑ 关键边界:整套结构是客户端 C# 原生 ECS(burst 段 RVA 0x17246xxx), /// Lua 侧 grep 零命中 ⇒ **服务端发不进去**。客户端 crowd 散人自己就在用它。 /// 本表唯一用途:让**服务端自建的 NPC**(街区市民 / ambient ped)站在 /// 「官方人群本来会站的地方」,并遵守官方的密度纪律(同一等待区别塞满)。 /// internal sealed record PedSpawnPoint( int WaitAreaIndex, int SlotIndex, float X, float Y, float Z, float ForwardX, float ForwardZ, uint UrbanDiversityConfigId) { /// /// 朝向(度)。ForwardX/ForwardZ 是世界空间的前向量,0° = +Z、顺时针为正 /// (对齐 SyncAetherAICrowdAdd 的写法,见硬规则 27)。 /// internal float FacingDeg { get { var len = MathF.Sqrt(ForwardX * ForwardX + ForwardZ * ForwardZ); return len < 1e-4f ? 0f : MathF.Atan2(ForwardX, ForwardZ) * (180f / MathF.PI); } } } /// /// 官方 ZoneGraph「行人等待区 + 槽位」表(2026-10-02 从捐赠端 PrivateServerPor 搬来)。 /// /// CSV 列:wait_area_index,slot_index,x,y,z,forward_x,forward_z,urban_diversity_config_id /// 数据 4070 行。同一 wait_area_index 下挂多个 slot_index —— 一个路口等待区多个站位。 /// /// ⛑ 与 StaticNpcSpawnTable(捐赠端那套沿车道网等距烘焙的固定站位)**不是一回事**: /// 那边是一维点池(SlotKey = point.Index),这边是二维「区 + 槽」 /// (SlotKey = WaitAreaIndex<<32 | SlotIndex)。 /// internal static class PedSpawnTable4229938 { private static readonly Lazy> Cache = new(Load); internal static IReadOnlyList Points => Cache.Value; internal static int Count => Cache.Value.Count; /// 表可用(文件存在且至少有一条数据)。文件缺失不算错误 —— 相关开关默认不依赖它。 internal static bool Available => Cache.Value.Count > 0; /// /// 取玩家周围最近的点。不去重、不按等待区限额 —— 只做「半径 + 取最近」。 /// internal static IReadOnlyList Near( float x, float z, float radius, int max, Func? accept = null) { if (max <= 0) return []; var r2 = radius * radius; var hits = new List<(float Dist2, PedSpawnPoint Point)>(); foreach (var p in Cache.Value) { if (accept is not null && !accept(p)) continue; var dx = p.X - x; var dz = p.Z - z; var d2 = dx * dx + dz * dz; if (d2 <= r2) hits.Add((d2, p)); } hits.Sort(static (a, b) => a.Dist2.CompareTo(b.Dist2)); var take = Math.Min(max, hits.Count); var result = new List(take); for (var i = 0; i < take; i++) result.Add(hits[i].Point); return result; } /// /// 取玩家周围的点,但**同一个等待区最多 个**(默认 2)。 /// /// ★ 为什么必须限额:一个等待区通常只有 3~5 个槽位,全塞满就成了「几个人叠在路口一角」, /// 而官方的画面是「四角各站两三个」。捐赠端 PedSpawnTable.NearSpread 用的就是默认 2。 /// 第二轮补齐时才不限额(候选点不够时按距离补),保证总能凑够 个点。 /// internal static IReadOnlyList NearSpread( float x, float z, float radius, int max, Func? accept = null, int maxPerArea = 2) { if (max <= 0) return []; var r2 = radius * radius; var hits = new List<(float Dist2, PedSpawnPoint Point)>(); foreach (var p in Cache.Value) { if (accept is not null && !accept(p)) continue; var dx = p.X - x; var dz = p.Z - z; var d2 = dx * dx + dz * dz; if (d2 <= r2) hits.Add((d2, p)); } hits.Sort(static (a, b) => a.Dist2.CompareTo(b.Dist2)); var result = new List(Math.Min(max, hits.Count)); var perArea = new Dictionary(); foreach (var (_, p) in hits) { if (result.Count >= max) break; perArea.TryGetValue(p.WaitAreaIndex, out var n); if (n >= maxPerArea) continue; perArea[p.WaitAreaIndex] = n + 1; result.Add(p); } // 候选点不够:第二轮不再看等待区限额,按 SlotKey 去重补足。 if (result.Count < max) { var taken = new HashSet(result.Select(SlotKey)); foreach (var (_, p) in hits) { if (result.Count >= max) break; if (taken.Add(SlotKey(p))) result.Add(p); } } return result; } /// 槽位全局唯一键:高 32 位等待区 + 低 32 位槽内序号。 internal static long SlotKey(PedSpawnPoint p) => ((long)p.WaitAreaIndex << 32) | (uint)p.SlotIndex; private static IReadOnlyList Load() { try { var configured = PrivateServerConfigStore.Current.Paths.PedSpawns; if (string.IsNullOrWhiteSpace(configured)) return []; var path = PrivateServerConfigStore.ResolveProjectPath(configured); if (!File.Exists(path)) return []; var points = new List(4096); using var reader = new StreamReader(path); // 首行表头:wait_area_index,slot_index,x,y,z,forward_x,forward_z,urban_diversity_config_id reader.ReadLine(); while (reader.ReadLine() is { } line) { if (line.Length == 0) continue; var parts = line.Split(','); if (parts.Length < 8) continue; float F(int i) => float.TryParse(parts[i], NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : 0f; int I(int i) => int.TryParse(parts[i], NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? v : 0; points.Add(new PedSpawnPoint( I(0), I(1), F(2), F(3), F(4), F(5), F(6), uint.TryParse(parts[7], NumberStyles.Integer, CultureInfo.InvariantCulture, out var cid) ? cid : 0u)); } return points; } catch { // 表读不出来不该让服务端起不来 —— 退化为「没有这份数据」,功能照旧。 return []; } } }