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