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 [];
}
}
}