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

198 lines
7.8 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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&lt;CrowdWaitSlot&gt; Slots</c> + <c>NativeList&lt;int&gt; 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&lt;&lt;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 [];
}
}
}