934 lines
48 KiB
C#
934 lines
48 KiB
C#
using System.Collections.Concurrent;
|
||
using System.Globalization;
|
||
using Ananta.SDK.Network;
|
||
using Ananta.SDK.Serialization;
|
||
using Auto = Ananta.Server.RpcTypes.Client4229938.Auto;
|
||
using Ananta.Server.ClientData.Client4229938;
|
||
using Ananta.Server.Configuration;
|
||
using Ananta.Server.Protocol.Client4229938;
|
||
using Ananta.Server.RpcTypes.Client4229938;
|
||
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
|
||
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
||
|
||
namespace Ananta.Server.Handlers.Game;
|
||
|
||
/// <summary>One citizen to place: formwork (AgentConfig id) + the POI action it plays.</summary>
|
||
internal sealed record CivilianSpawnItem(uint NpcFormworkId, uint PoiActionId);
|
||
|
||
/// <summary>
|
||
/// 让一个走市民通道刷出来的 NPC 穿上「某个可玩角色身上那套搭配」(切人留场用)。
|
||
///
|
||
/// 官方原生写法就在 NPC 出场包里,没有第二处:
|
||
/// <c>AgentSyncClientInfo.AIAgentInfo</c>(<c>LinkAIAgentInfo</c>)→
|
||
/// <c>Fashion</c>(<c>LinkAIFashionInfo</c> = <c>SuitId</c> + <c>WearInfo</c>)→
|
||
/// <c>WearInfo</c>(<c>OtherPlayerSpiritWearFashionsInfo</c>,带<b>单件</b> <c>WearFashionInfoList</c>)。
|
||
/// 这就是「把别人的角色放进大世界」那条路 —— 麻将/保龄球的 AI 对手读的也是
|
||
/// <c>unit.ClientData.AIAgentInfo</c>(<c>MajiangGame_UI.lua:181</c> / <c>BowlingCharacter.lua:414</c>)。
|
||
///
|
||
/// ⛑ 市民通道顶层的 <c>FashionSuitId</c> 只能填<b>套装</b> id,而玩家存档
|
||
/// <c>config/player-progress.json</c> 里 9/9 个角色 <c>FunctionSuitId</c> 全为 0
|
||
/// (纯散件搭配,4~10 件)⇒ 走套装那条路对「自定义搭配」根本没用,必须走这里。
|
||
///
|
||
/// ⛑ wire 顺序(<c>Drmk.Proxy/proxy/lua/RPCSerializeAuto.lua:4706</c>):
|
||
/// Uid(u64) → FightSpiritId(u32) → Fashion(complex,nilable) → Nickname(string,nilable)
|
||
/// → NameId(u32) → AvatarImageId(u32) → VehicleId(u32);
|
||
/// Fashion = SuitId(u32) → WearInfo(complex,<b>不可空</b>)。与
|
||
/// <c>DonorNpcLinkAIAgentInfo4229938</c> 字段顺序一一对应,别改顺序。
|
||
/// </summary>
|
||
internal sealed record CivilianLinkDress(
|
||
ulong Uid,
|
||
uint FightSpiritId,
|
||
uint SuitId,
|
||
IReadOnlyList<uint> FashionIds,
|
||
byte HiddenParts = 0,
|
||
string? Nickname = null);
|
||
|
||
/// <summary>
|
||
/// Fourth entity channel: citizens owned by the client's Aether crowd simulation.
|
||
///
|
||
/// The server's whole job here is to announce these entities once and then remember that it did:
|
||
/// walking, obstacle avoidance and hit reactions are entirely client-side (that is why the donor
|
||
/// build gets full pedestrian behaviour without simulating anything). Nothing in this file ticks,
|
||
/// moves or fights an entity — and we deliberately do NOT re-send anything periodically, because
|
||
/// replaying the POI would cancel the client's own reactions.
|
||
///
|
||
/// Aether init (SyncAetherAIInitDatas) IS re-sent before every batch — the donor does that too,
|
||
/// and dropping it once cost us a full day of "panel says ok, world stays empty".
|
||
/// </summary>
|
||
internal sealed partial class GameRouter
|
||
{
|
||
|
||
/// <summary>
|
||
/// The preset crowd groups, read from Configs/CivilianGroups.json (see
|
||
/// CivilianGroupCatalog4229938). 199 groups of 20 — together they cover every POI action the
|
||
/// client knows, with a humanoid faction-2 NPC matched to each action's body type.
|
||
/// </summary>
|
||
internal static IReadOnlyList<CivilianGroup4229938> CivilianGroups => CivilianGroupCatalog4229938.Groups;
|
||
|
||
internal static CivilianGroup4229938? CivilianGroup(int id) => CivilianGroupCatalog4229938.Find(id);
|
||
|
||
/// <summary>
|
||
/// T0-28:**这个 AgentConfig 模板能不能当路人放出去**。三道独立筛子全过才算市民,
|
||
/// 行人池(<c>PedFormworkPool</c>)和地块市民池(<c>BuildItemPool</c>)共用它,
|
||
/// 这样两条线不会再出现"街上刷的是市民、地块刷的是店员"的不一致。
|
||
///
|
||
/// ⛑️ 为什么是三道而不是一道(`CivilianGroups.json` 本身不够用):
|
||
/// 那张表是按"给每个 POI 动作配一个**体型匹配的人形**"建的 —— 只保证是人形 + 阵营 2,
|
||
/// 不保证是路人,也不保证模型的脸不一样。所以要在它之上再筛。
|
||
/// 三道:① id 段白名单(4013)② **GeneralModelId 黑名单**(通用临时模型,最关键的一道)
|
||
/// ③ 名字关键词黑名单(店员 / 刷怪 / 剧情职能)。
|
||
/// </summary>
|
||
internal static bool IsCivilianFormwork(uint id)
|
||
{
|
||
if (id == 0)
|
||
return false;
|
||
|
||
// ⛑️ 这些开关在 `AetherSettings` 上(不在 PrivateServerConfig 根上),别写错层级。
|
||
var cfg = PrivateServerConfigStore.Current.Gameplay.Aether;
|
||
var prefixes = cfg.AmbientPedsFormworkIdPrefixes ?? [];
|
||
var blockModels = cfg.AmbientPedsFormworkModelBlocklist ?? [];
|
||
var blockNames = cfg.AmbientPedsFormworkNameBlocklist ?? [];
|
||
|
||
var text = id.ToString(CultureInfo.InvariantCulture);
|
||
if (prefixes.Length > 0
|
||
&& !Array.Exists(prefixes, p => !string.IsNullOrEmpty(p) && text.StartsWith(p, StringComparison.Ordinal)))
|
||
return false;
|
||
|
||
// 查不到表就只剩①②可用(不该发生:NpcCatalog 覆盖了池里 100% 的 id)。
|
||
if (!DonorNpcCatalog4229938.TryGet(id, out var entry))
|
||
return true;
|
||
|
||
// 通用临时模型:多个行共用同一具模特 86160001~04 ⇒ 刷出来是全城 522 张同一张脸。
|
||
if (blockModels.Length > 0 && Array.IndexOf(blockModels, entry.GeneralModelId) >= 0)
|
||
return false;
|
||
|
||
if (blockNames.Length > 0
|
||
&& Array.Exists(blockNames, w => !string.IsNullOrEmpty(w)
|
||
&& entry.Name.Contains(w, StringComparison.OrdinalIgnoreCase)))
|
||
return false;
|
||
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Place citizens in front of the player on a jittered grid: the slots are shuffled so the
|
||
/// group does not read as a parade formation, and each slot is offset by at most a quarter of
|
||
/// the spacing so two citizens can never land on the same spot.
|
||
/// Returns every generated entity id.
|
||
/// </summary>
|
||
internal static async Task<(bool Ok, string Message, ulong[] Ids)> SpawnCiviliansNearPlayerAsync(
|
||
TcpSession session, IReadOnlyList<CivilianSpawnItem> items, float spacing = 2.6f)
|
||
{
|
||
if (items.Count < 1)
|
||
return (false, "至少要有一个市民", []);
|
||
if (!float.IsFinite(spacing) || spacing < 1f || spacing > 20f)
|
||
return (false, "间距必须在 1~20 米之间", []);
|
||
|
||
foreach (var item in items)
|
||
{
|
||
if (item is null || item.NpcFormworkId > int.MaxValue || !DonorNpcCatalog4229938.TryGet(item.NpcFormworkId, out _))
|
||
return (false, $"未知的 NPC 模板 {item?.NpcFormworkId}(应为 AgentConfig Id)", []);
|
||
if (item.PoiActionId != 0 && !DonorPoiActionCatalog4229938.TryGet(item.PoiActionId, out _))
|
||
return (false, $"未知的 POI 动作 {item.PoiActionId}", []);
|
||
}
|
||
|
||
var state = GetStateIfExists(session);
|
||
if (state is null)
|
||
return (false, "没有玩家世界状态", []);
|
||
|
||
Vec3 near;
|
||
float facing;
|
||
lock (state.SyncRoot)
|
||
{
|
||
if (!state.Ready || !state.HasLastReportedPlayerTransform || state.PendingTeleportId != 0)
|
||
return (false, "等玩家在场景里站稳(已知道坐标)再生成", []);
|
||
near = state.LastReportedPlayerPosition;
|
||
facing = state.LastReportedPlayerRotation.Y;
|
||
}
|
||
if (!float.IsFinite(near.X) || !float.IsFinite(near.Y) || !float.IsFinite(near.Z) || !float.IsFinite(facing))
|
||
return (false, "玩家坐标无效", []);
|
||
|
||
var yaw = facing * MathF.PI / 180f;
|
||
var npcFacing = (facing + 180f) % 360f;
|
||
if (npcFacing > 180f)
|
||
npcFacing -= 360f;
|
||
|
||
// Wide rectangle of slots in front of the player, then shuffled: nobody can predict where a
|
||
// given citizen ends up. Capped at 6 rows on purpose — a sqrt() patch would put a 60-head
|
||
// group 8 rows deep (~18 m at the default spacing), well past where the crowd is readable.
|
||
const int maxRows = 6;
|
||
var cols = Math.Max(1, (items.Count + maxRows - 1) / maxRows);
|
||
var slots = Enumerable.Range(0, items.Count).ToArray();
|
||
for (var i = slots.Length - 1; i > 0; i--)
|
||
{
|
||
var j = Random.Shared.Next(i + 1);
|
||
(slots[i], slots[j]) = (slots[j], slots[i]);
|
||
}
|
||
// Half a slot of jitter keeps neighbours at least half a spacing apart, so they never
|
||
// overlap even when two animations both shuffle their feet.
|
||
var jitter = spacing * 0.25f;
|
||
|
||
var ids = new List<ulong>();
|
||
try
|
||
{
|
||
// The crowd manager will not materialise a pedestrian until it has seen Aether init.
|
||
// The donor re-sends it on every batch, not once per scene; see SendDonorAetherInitAsync.
|
||
await SendDonorAetherInitAsync(session);
|
||
for (var i = 0; i < items.Count; i++)
|
||
{
|
||
var item = items[i];
|
||
var slot = slots[i];
|
||
var forward = 3f + (slot / cols) * spacing + (Random.Shared.NextSingle() - 0.5f) * 2f * jitter;
|
||
var right = ((slot % cols) - (cols - 1) / 2f) * spacing + (Random.Shared.NextSingle() - 0.5f) * 2f * jitter;
|
||
var x = near.X + MathF.Sin(yaw) * forward + MathF.Cos(yaw) * right;
|
||
var z = near.Z + MathF.Cos(yaw) * forward - MathF.Sin(yaw) * right;
|
||
// Shares the donor's entity-id counter on purpose. Keeping our own would restart the
|
||
// sequence at the same base and hand out ids already in use by the NPC-spawn card,
|
||
// which is how "spawn one" once produced a duplicate-looking crowd.
|
||
var id = unchecked((ulong)IdCounterStore.Next(StaticNpcIdCounter, StaticNpcIdFloor));
|
||
await SendCivilianAsync(session, item, id, x, near.Y, z, npcFacing);
|
||
ids.Add(id);
|
||
// Same bookkeeping the donor does per NPC: the static-NPC lifecycle expects an entry
|
||
// in the prepared-plot-event map before it will advance the pedestrian.
|
||
lock (state.SyncRoot)
|
||
{
|
||
state.StaticNpcPreparedPlotEvents[id] = 0;
|
||
state.StaticNpcPoses[id] = new StaticNpcPose(x, near.Y, z, npcFacing);
|
||
}
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
return (false, $"已送出 {ids.Count}/{items.Count} 个后失败:{ex.Message}", [.. ids]);
|
||
}
|
||
|
||
// Nothing persisted: citizens are thrown away when the scene unloads, same as the donor card.
|
||
return (true, $"已在身前生成 {ids.Count} 个市民", [.. ids]);
|
||
}
|
||
|
||
/// <summary>Position pushes per second. The donor's own NPC driver runs at 8 Hz.</summary>
|
||
private const float WalkHz = 8f;
|
||
|
||
// ---- Seeding citizens onto the real land plots (T0-8) ------------------------------------
|
||
// Same four-step announce sequence as SpawnCiviliansNearPlayerAsync; the only difference is
|
||
// where the positions come from. Instead of a grid in front of the player we ask
|
||
// CivilianDistrictCatalog4229938 for the actual plots around them, then let each plot's own
|
||
// 24 h pedestrian curve decide how many of its pavement spots get someone on them.
|
||
//
|
||
// The catalog is data produced by _gen_civilian_districts.py from the client's own urban
|
||
// diversity settings + pedestrian lane network, so a citizen ends up on pavement with a real
|
||
// ground height rather than floating at the player's Y.
|
||
|
||
/// <summary>
|
||
/// Fill every plot around the player with citizens, how many decided by each plot's pedestrian
|
||
/// flow at <hour>. Hour -1 means "use the world clock the client is currently showing".
|
||
/// </summary>
|
||
internal static async Task<(bool Ok, string Message, ulong[] Ids, string Detail)> SpawnCiviliansByDistrictAsync(
|
||
TcpSession session, float radius, int maxTotal, int hour = -1)
|
||
{
|
||
if (!float.IsFinite(radius) || radius < 20f || radius > 600f)
|
||
return (false, "半径必须在 20~600 米之间", [], "");
|
||
if (maxTotal < 1 || maxTotal > 600)
|
||
return (false, "总数上限必须在 1~600 之间", [], "");
|
||
|
||
var state = GetStateIfExists(session);
|
||
if (state is null)
|
||
return (false, "没有玩家世界状态", [], "");
|
||
|
||
Vec3 near;
|
||
int hourNow;
|
||
lock (state.SyncRoot)
|
||
{
|
||
if (!state.Ready || !state.HasLastReportedPlayerTransform || state.PendingTeleportId != 0)
|
||
return (false, "等玩家在场景里站稳(已知道坐标)再生成", [], "");
|
||
near = state.LastReportedPlayerPosition;
|
||
hourNow = hour >= 0 ? Math.Clamp(hour, 0, 23) : (int)Math.Clamp(state.TimeHour, 0u, 23u);
|
||
}
|
||
if (!float.IsFinite(near.X) || !float.IsFinite(near.Z))
|
||
return (false, "玩家坐标无效", [], "");
|
||
|
||
var candidates = CivilianDistrictCatalog4229938.WithinRadius(near.X, near.Z, radius);
|
||
if (candidates.Count == 0)
|
||
return (false, $"半径 {radius:0} 米内没有地块站位(把半径调大,或走到有街道的地方再点)", [], "");
|
||
|
||
// Every plot asks for `spots × flow at this hour × density`. Plots are served nearest-first
|
||
// and the whole batch stops once the budget is gone, so what the player can actually see is
|
||
// always populated first. DENSITY < 1 because filling every pavement spot reads as a queue,
|
||
// not a street — even at the plot's own peak hour.
|
||
const float density = 0.55f;
|
||
const float flowFloor = 0.05f; // below this a plot is genuinely empty at this hour
|
||
var available = 0;
|
||
var plan = new List<(CivilianDistrict4229938 District, List<Vec3> Spots, int Count)>();
|
||
var budget = maxTotal;
|
||
foreach (var (district, spots) in candidates)
|
||
{
|
||
if (budget <= 0)
|
||
break;
|
||
available += spots.Count;
|
||
var flow = district.FlowAt(hourNow);
|
||
if (flow < flowFloor)
|
||
continue;
|
||
var want = (int)MathF.Round(spots.Count * (float)flow * density, MidpointRounding.AwayFromZero);
|
||
want = Math.Clamp(want, 1, Math.Min(spots.Count, budget));
|
||
plan.Add((district, spots, want));
|
||
budget -= want;
|
||
}
|
||
if (plan.Count == 0)
|
||
return (false,
|
||
$"{hourNow} 点时这些地块的人流都接近 0(深夜),没人可放。" +
|
||
$"把「时段」换成 8 或 18 再点,或者先到面板上把游戏时间调亮。", [], "");
|
||
|
||
var ids = new List<ulong>();
|
||
var perType = new List<(string Type, int Plots, int Count)>();
|
||
string? failure = null;
|
||
try
|
||
{
|
||
await SendDonorAetherInitAsync(session);
|
||
var sent = 0;
|
||
foreach (var (district, spots, count) in plan)
|
||
{
|
||
var pool = DistrictItemPool(district.Categories);
|
||
if (pool.Length == 0)
|
||
continue;
|
||
// Spread across the plot instead of bunching on its nearest edge: spots arrive
|
||
// sorted by distance to the player, so taking a strided walk through the list keeps
|
||
// them visible while still covering the street rather than one corner of it.
|
||
var stride = spots.Count / (float)count;
|
||
for (var k = 0; k < count; k++)
|
||
{
|
||
var spot = spots[Math.Min(spots.Count - 1, (int)(k * stride))];
|
||
var facing = NormaliseYaw(Random.Shared.NextSingle() * 360f);
|
||
var id = unchecked((ulong)IdCounterStore.Next(StaticNpcIdCounter, StaticNpcIdFloor));
|
||
await SendCivilianAsync(session, pool[k % pool.Length], id,
|
||
spot.X, spot.Y, spot.Z, facing);
|
||
ids.Add(id);
|
||
lock (state.SyncRoot)
|
||
{
|
||
state.StaticNpcPreparedPlotEvents[id] = 0;
|
||
state.StaticNpcPoses[id] = new StaticNpcPose(spot.X, spot.Y, spot.Z, facing);
|
||
}
|
||
|
||
// Hundreds of entities arrive as hundreds of packets; without a breather the
|
||
// client's own processing of the previous ones falls behind and the tail of the
|
||
// batch gets dropped.
|
||
if (++sent % 20 == 0)
|
||
await Task.Delay(10);
|
||
}
|
||
|
||
var type = string.IsNullOrWhiteSpace(district.LandPlotType) ? "(未分类)" : district.LandPlotType;
|
||
var hit = perType.FindIndex(t => t.Type == type);
|
||
if (hit >= 0)
|
||
perType[hit] = (type, perType[hit].Plots + 1, perType[hit].Count + count);
|
||
else
|
||
perType.Add((type, 1, count));
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
failure = ex.Message;
|
||
}
|
||
|
||
var detail = string.Join("\n", perType.OrderByDescending(t => t.Count)
|
||
.Select(t => $" {t.Type}:{t.Plots} 个地块 → {t.Count} 人"));
|
||
var head = $"{hourNow} 点人流 · 半径 {radius:0}m 内 {plan.Count}/{candidates.Count} 个地块有人" +
|
||
$"(可用站位 {available}),实际生成 {ids.Count} 人\n";
|
||
return failure is null
|
||
? (true, $"已按地块生成 {ids.Count} 个市民", [.. ids], head + detail)
|
||
: (false, $"已送出 {ids.Count} 个后失败:{failure}", [.. ids], head + detail);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 在**官方过街等待区**的槽位上铺市民(2026-10-02 新增,配置 <c>gameplay.civilianWaitArea</c>)。
|
||
///
|
||
/// ★ 点位来源 = <c>paths.pedSpawns</c>(<c>zonegraph_ped_spawns.csv</c>,4070 槽),
|
||
/// 真身是客户端 <c>MassAI.MassCrowd.CrowdWaitAreaData</c>:行人沿 ConnectedLanes 汇入、
|
||
/// 占一个槽位站定(Radius + bOccupied 防重叠)、等相位、走 CrossLaneHandle 过街。
|
||
/// ⛑ 那套是客户端原生 ECS,<b>服务端驱动不了</b>;我们只是把自建 NPC 放到同一批坐标上,
|
||
/// 让画面上像「这撮人也在路口等灯」。
|
||
///
|
||
/// ★ 与 <c>SpawnCiviliansByDistrictAsync</c> 的分工:那边按「生态区 + 时段人流」铺出一整条街的人,
|
||
/// 这边只在路口四角补一小撮;两者叠加才像真城市。两边都有关开关,互不干扰。
|
||
/// </summary>
|
||
internal static async Task<(bool Ok, string Message, ulong[] Ids, string Detail)> SpawnCiviliansAtWaitAreasAsync(
|
||
TcpSession session, float radius, int maxTotal, int hour = -1)
|
||
{
|
||
var wa = PrivateServerConfigStore.Current.Gameplay.CivilianWaitArea;
|
||
if (!wa.Enabled)
|
||
return (false, "未启用:先把 gameplay.civilianWaitArea.enabled 设为 true", [], "");
|
||
if (!PedSpawnTable4229938.Available)
|
||
return (false, "没有等待区数据:paths.pedSpawns 指向的 CSV 不存在或是空的", [], "");
|
||
if (!float.IsFinite(radius) || radius < 20f || radius > 600f)
|
||
return (false, "半径必须在 20~600 米之间", [], "");
|
||
if (maxTotal < 1 || maxTotal > 600)
|
||
return (false, "总数上限必须在 1~600 之间", [], "");
|
||
|
||
var state = GetStateIfExists(session);
|
||
if (state is null)
|
||
return (false, "没有玩家世界状态", [], "");
|
||
|
||
Vec3 near;
|
||
int hourNow;
|
||
lock (state.SyncRoot)
|
||
{
|
||
if (!state.Ready || !state.HasLastReportedPlayerTransform || state.PendingTeleportId != 0)
|
||
return (false, "等玩家在场景里站稳(已知道坐标)再生成", [], "");
|
||
near = state.LastReportedPlayerPosition;
|
||
hourNow = hour >= 0 ? Math.Clamp(hour, 0, 23) : (int)Math.Clamp(state.TimeHour, 0u, 23u);
|
||
}
|
||
if (!float.IsFinite(near.X) || !float.IsFinite(near.Z))
|
||
return (false, "玩家坐标无效", [], "");
|
||
|
||
var perArea = Math.Max(1, wa.MaxPerArea);
|
||
// 白天高峰才有人在路口等灯;夜里等待区应当是空的(与地块的 HourlyFlow 同一套直觉)。
|
||
// 0~5 点几乎没人,7~9 / 17~19 是通勤双峰。写成一张 24 小时权重表,避免深夜刷出一堆人。
|
||
var flow = WaitAreaFlowAt(hourNow);
|
||
if (flow < 0.05f)
|
||
return (false, $"{hourNow} 点路口几乎没人(夜深),换个时段再试", [], "");
|
||
|
||
var want = Math.Clamp((int)MathF.Round(maxTotal * flow, MidpointRounding.AwayFromZero), 1, maxTotal);
|
||
|
||
var points = PedSpawnTable4229938.NearSpread(near.X, near.Z, radius, want,
|
||
accept: null, maxPerArea: perArea);
|
||
if (points.Count == 0)
|
||
return (false, $"半径 {radius:0} 米内没有等待区槽位(走远一点,或把半径调大)", [], "");
|
||
|
||
var pool = DistrictItemPool(WaitAreaCategories);
|
||
if (pool.Length == 0)
|
||
return (false, "没有可用的市民模板( CivilianGroups 里的通用站位组是空的)", [], "");
|
||
|
||
var ids = new List<ulong>();
|
||
string? failure = null;
|
||
var areas = new HashSet<int>();
|
||
try
|
||
{
|
||
await SendDonorAetherInitAsync(session);
|
||
var sent = 0;
|
||
foreach (var p in points)
|
||
{
|
||
var id = unchecked((ulong)IdCounterStore.Next(StaticNpcIdCounter, StaticNpcIdFloor));
|
||
// ★★ Y 必须补:等待区 CSV 的 y 恒为 0(客户端那边靠 ZoneGraph 自己贴地,
|
||
// 我们发出生包时**不贴地**)。而城市有地形起伏(我方地块站位 y 跨 -25.7~155.7),
|
||
// 直接填 0 ⇒ 人要么埋在地里、要么浮在半空。
|
||
// 补法:借水平最近的那个地块站位的 y(存量 13786 个点,本来就有真高度)。
|
||
var y = GroundYAt(p.X, p.Z, near.Y);
|
||
// ★ 朝向用槽位自带的 Forward —— 这就是「等灯的人面朝马路」的来源,
|
||
// 比随机 yaw 更像回事(对应 CrowdWaitSlot.Forward)。
|
||
var facing = NormaliseYaw(p.FacingDeg);
|
||
|
||
await SendCivilianAsync(session, pool[sent % pool.Length], id, p.X, y, p.Z, facing);
|
||
ids.Add(id);
|
||
areas.Add(p.WaitAreaIndex);
|
||
lock (state.SyncRoot)
|
||
{
|
||
state.StaticNpcPreparedPlotEvents[id] = 0;
|
||
state.StaticNpcPoses[id] = new StaticNpcPose(p.X, y, p.Z, facing);
|
||
}
|
||
|
||
if (++sent % 20 == 0)
|
||
await Task.Delay(10);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
failure = ex.Message;
|
||
}
|
||
|
||
var detail = $" {hourNow} 点人流系数 {flow:0.00} · 半径 {radius:0}m 内 {areas.Count} 个等待区" +
|
||
$",每区最多 {perArea} 人 → 实际生成 {ids.Count} 人(候选槽位 {points.Count})";
|
||
var head = "等待区铺人:\n";
|
||
return failure is null
|
||
? (true, $"已在 {areas.Count} 个路口等待区生成 {ids.Count} 个市民", [.. ids], head + detail)
|
||
: (false, $"已送出 {ids.Count} 个后失败:{failure}", [.. ids], head + detail);
|
||
}
|
||
|
||
/// <summary>等待区用的市民类别:路口是「通用站姿」,不按店铺业态细分。</summary>
|
||
private static readonly string[] WaitAreaCategories = ["站姿·通用"];
|
||
|
||
/// <summary>
|
||
/// 路口等待区的 24 小时人流权重(0~1)。深夜几乎没人,早晚通勤双峰最高。
|
||
/// ★ 与地块那边的 <c>HourlyFlow</c> 是两套数据:等待区 CSV 不带人流维度,只能按常识给一条曲线。
|
||
/// </summary>
|
||
private static float WaitAreaFlowAt(int hour)
|
||
{
|
||
// 0,1,...,23 —— 深夜 0.02、早高峰 8 点 1.0、午间 0.7、晚高峰 18 点 1.0、夜间回落。
|
||
float[] curve =
|
||
[
|
||
0.02f, 0.02f, 0.02f, 0.02f, 0.05f, 0.15f, // 00-05
|
||
0.35f, 0.70f, 1.00f, 0.75f, 0.60f, 0.65f, // 06-11
|
||
0.70f, 0.65f, 0.60f, 0.65f, 0.80f, 0.95f, // 12-17
|
||
1.00f, 0.80f, 0.55f, 0.35f, 0.20f, 0.08f, // 18-23
|
||
];
|
||
return curve[Math.Clamp(hour, 0, 23)];
|
||
}
|
||
|
||
/// <summary>
|
||
/// 用水平最近的**地块站位**的 Y 当高度采样(等待区 CSV 的 y 恒为 0,必须补)。
|
||
///
|
||
/// ★ 两级查找:先按区中心 + 半径粗筛(681 个区),再在命中区里逐点比。
|
||
/// 全表 13786 点逐点扫也能出结果,但每个等待区点都扫一遍会到千万次量级。
|
||
/// ⛑ 找不到就退回 <paramref name="fallbackY"/>(通常是玩家当前 Y),不硬填 0。
|
||
/// </summary>
|
||
private static float GroundYAt(float x, float z, float fallbackY)
|
||
{
|
||
var bestY = float.NaN;
|
||
var bestD2 = float.MaxValue;
|
||
foreach (var d in CivilianDistrictCatalog4229938.Districts)
|
||
{
|
||
var reach = d.Radius + 80f;
|
||
var dcx = d.CenterX - x;
|
||
var dcz = d.CenterZ - z;
|
||
if (dcx * dcx + dcz * dcz > reach * reach)
|
||
continue;
|
||
|
||
foreach (var s in d.Spots)
|
||
{
|
||
var dx = s.X - x;
|
||
var dz = s.Z - z;
|
||
var d2 = dx * dx + dz * dz;
|
||
if (d2 >= bestD2)
|
||
continue;
|
||
bestD2 = d2;
|
||
bestY = s.Y;
|
||
}
|
||
}
|
||
return float.IsNaN(bestY) ? fallbackY : bestY;
|
||
}
|
||
|
||
private static float NormaliseYaw(float degrees)
|
||
{
|
||
var wrapped = degrees % 360f;
|
||
if (wrapped > 180f)
|
||
wrapped -= 360f;
|
||
else if (wrapped < -180f)
|
||
wrapped += 360f;
|
||
return wrapped;
|
||
}
|
||
|
||
/// <summary>
|
||
/// The citizens a plot may draw from: the first preferred category that exists in the preset
|
||
/// groups wins, falling back to every non-system group. ★ 系统行为 groups are never used — they
|
||
/// are companion / dialogue drivers and their owners sit still looking switched off.
|
||
/// </summary>
|
||
private static readonly ConcurrentDictionary<string, CivilianSpawnItem[]> DistrictItemPools = new();
|
||
|
||
private static CivilianSpawnItem[] DistrictItemPool(IReadOnlyList<string> categories)
|
||
{
|
||
var key = string.Join("|", categories);
|
||
// ConcurrentDictionary only has the keyed factory overload — there is no Func<TValue> one.
|
||
return DistrictItemPools.GetOrAdd(key, _ => BuildItemPool(categories));
|
||
}
|
||
|
||
private static CivilianSpawnItem[] BuildItemPool(IReadOnlyList<string> categories)
|
||
{
|
||
var usable = CivilianGroups.Where(g => !g.SystemOnly && g.Items.Count > 0).ToList();
|
||
List<CivilianGroup4229938> chosen = usable;
|
||
foreach (var category in categories)
|
||
{
|
||
var matched = usable.Where(g => string.Equals(g.Category, category, StringComparison.Ordinal)).ToList();
|
||
if (matched.Count == 0)
|
||
continue;
|
||
chosen = matched;
|
||
break;
|
||
}
|
||
// T0-28:过滤后还要保证这个地块的对应类别**还有人可用**(每个类别实测剩 13~46 个,
|
||
// 不会被抽空)。真抽空了就退回未过滤 —— 宁可放个模型普通的市民,也不要整块地没人。
|
||
var filtered = chosen
|
||
.SelectMany(g => g.Items)
|
||
.Where(i => IsCivilianFormwork(i.NpcFormworkId))
|
||
.Select(i => new CivilianSpawnItem(i.NpcFormworkId, i.PoiActionId))
|
||
.ToArray();
|
||
if (filtered.Length == 0)
|
||
filtered = chosen
|
||
.SelectMany(g => g.Items)
|
||
.Select(i => new CivilianSpawnItem(i.NpcFormworkId, i.PoiActionId))
|
||
.ToArray();
|
||
|
||
// ★ 2026-10-02 新增:POI 二次筛选(用 UrbanDiversityPOIActionConfig 全字段表)。
|
||
// 两个开关**默认全 false** ⇒ 不打开就与今天逐条一致。见 CivilianPoiSettings。
|
||
// ⛑ 这里没有 session 可打日志(池是按类别缓存的静态方法),效果只能在游戏里看;
|
||
// 想确认有没有生效,直接对比打开前后同一地块铺出来的人。
|
||
var poiCfg = PrivateServerConfigStore.Current.Gameplay.CivilianPoi;
|
||
if (poiCfg.ExcludeRootMotion || poiCfg.ExcludeProps)
|
||
{
|
||
var kept = filtered.Where(i => CivilianPoiAllowed4229938(i.PoiActionId, poiCfg)).ToArray();
|
||
// 与上面同一条兜底原则:宁可放个没筛过的市民,也不要整块地没人。
|
||
if (kept.Length > 0)
|
||
filtered = kept;
|
||
}
|
||
|
||
return Shuffle(filtered);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 这条 POI 动作在当前筛选配置下能不能用。查不到(表里没有这个 id)一律<b>放行</b> ——
|
||
/// 宁可放过也不要因为表缺失把整个池清空。
|
||
/// </summary>
|
||
private static bool CivilianPoiAllowed4229938(uint poiActionId, CivilianPoiSettings cfg)
|
||
{
|
||
if (poiActionId == 0)
|
||
return true;
|
||
if (!UrbanDiversityPoiActionCatalog4229938.TryGet(poiActionId, out var poi))
|
||
return true;
|
||
if (cfg.ExcludeRootMotion && poi.ApplyRootMotion)
|
||
return false;
|
||
if (cfg.ExcludeProps && poi.HasProp)
|
||
return false;
|
||
return true;
|
||
}
|
||
|
||
private static T[] Shuffle<T>(T[] source)
|
||
{
|
||
for (var i = source.Length - 1; i > 0; i--)
|
||
{
|
||
var j = Random.Shared.Next(i + 1);
|
||
(source[i], source[j]) = (source[j], source[i]);
|
||
}
|
||
return source;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Server-driven walk for the citizens spawned this session.
|
||
///
|
||
/// This exists to settle one specific question: whether the client plays a gait for movement the
|
||
/// server pushes, or only interpolates between points. SyncUnitPositionAndFacing carries a moveId
|
||
/// (UX.Game.BattleMoveActionType) and every call site in this build used to send 0 = Unknown,
|
||
/// which is exactly what "sliding" looks like from the outside. Pushing the same trajectory with
|
||
/// moveId=0 and moveId=2 (WalkFront) and watching both is the only way to tell.
|
||
///
|
||
/// Citizens are the test vehicle because their ids are ours and their channel is verified. This
|
||
/// does not take movement away from the client's Aether crowd — it only pushes positions on top,
|
||
/// and nothing here runs unless the operator asks for it.
|
||
/// </summary>
|
||
internal static async Task<(bool Ok, string Message, int Count)> WalkCiviliansAsync(
|
||
TcpSession session, byte moveId, float distance, float durationSeconds)
|
||
{
|
||
var state = GetStateIfExists(session);
|
||
if (state is null)
|
||
return (false, "no live game session (is the client in the world?)", 0);
|
||
|
||
List<(ulong Id, StaticNpcPose Pose)> walkers;
|
||
lock (state.SyncRoot)
|
||
walkers = state.StaticNpcPoses.Select(kv => (kv.Key, kv.Value)).OrderBy(x => x.Key).ToList();
|
||
|
||
if (walkers.Count == 0)
|
||
return (false, "还没有市民 —— 先用上面的按钮生成一组", 0);
|
||
|
||
var steps = Math.Max(1, (int)MathF.Round(durationSeconds * WalkHz));
|
||
var stepDistance = distance / steps;
|
||
var delayMs = (int)MathF.Round(1000f / WalkHz);
|
||
|
||
for (var step = 1; step <= steps; step++)
|
||
{
|
||
var isLast = step == steps;
|
||
foreach (var (id, pose) in walkers)
|
||
{
|
||
var travelled = stepDistance * step;
|
||
var x = pose.X + MathF.Sin(pose.Facing) * travelled;
|
||
var z = pose.Z + MathF.Cos(pose.Facing) * travelled;
|
||
|
||
// The final push stops the gait (Idle) and clears continueMove; without that the
|
||
// client keeps the locomotion state alive forever and the unit walks on the spot.
|
||
await session.NotifyAsync(
|
||
Ananta.Server.Protocol.Client4229938.MethodId.SyncUnitPositionAndFacing,
|
||
UxSerializer.Serialize(WorldCodec.Walking(
|
||
id, new Vec3(x, pose.Y, z), pose.Facing,
|
||
continueMove: !isLast,
|
||
moveId: isLast ? WorldCodec.MoveAction.Idle : moveId)),
|
||
CancellationToken.None);
|
||
}
|
||
|
||
if (!isLast) await Task.Delay(delayMs);
|
||
}
|
||
|
||
// Remember where they ended up, so a second walk continues from here instead of snapping
|
||
// back to the spawn point.
|
||
lock (state.SyncRoot)
|
||
{
|
||
foreach (var (id, pose) in walkers)
|
||
state.StaticNpcPoses[id] = pose with
|
||
{
|
||
X = pose.X + MathF.Sin(pose.Facing) * distance,
|
||
Z = pose.Z + MathF.Cos(pose.Facing) * distance,
|
||
};
|
||
}
|
||
|
||
return (true,
|
||
$"已推动 {walkers.Count} 个市民走 {distance:F1}m:moveId={moveId} " +
|
||
$"({WorldCodec.MoveAction.NameOf(moveId)}),{steps} 步 @ {WalkHz:0}Hz",
|
||
walkers.Count);
|
||
}
|
||
|
||
/// <summary>
|
||
/// The four-step announce sequence for one citizen. Copied field-by-field from the donor build
|
||
/// that was verified in-game; do not "simplify" it — dropping SyncManagedLogicAgent leaves the
|
||
/// unit without a simulation owner and the full-model state machine gets disabled right after
|
||
/// the ECS→GO handoff (which is exactly what T-pose looks like from the outside).
|
||
/// </summary>
|
||
internal static async Task SendCivilianAsync(
|
||
TcpSession session, CivilianSpawnItem item, ulong entityId,
|
||
float x, float y, float z, float facing,
|
||
CivilianLinkDress? dress = null, uint activityCfgId = 0, bool skipPhone = false,
|
||
// ★★ 第 38/46 轮(自 v2.1 捐赠端移植,2026-09-29):skipHackPhone = 跳过「给手机 + usage 6/1」
|
||
// 那一步 —— 它会**覆盖** POI 刚推下去的 belongings/usage(把"吃烧烤/喝可乐"改成"玩手机")
|
||
// ⇒ 靠 POI 表达场景行为的 NPC(派对/露营)必须跳过它,否则预制动作等于白配。
|
||
// 仍然保留 MarkNpcHackableOnSpawnAsync 本体 ⇒ 可骇标记照打(面板显示名字、没有按钮)。
|
||
// aiSettingOverride / treeNameOverride = 可选覆盖(0/"" = 原行为)。
|
||
bool skipHackPhone = false,
|
||
uint aiSettingOverride = 0,
|
||
string? treeNameOverride = null)
|
||
{
|
||
DonorNpcCatalog4229938.TryGet(item.NpcFormworkId, out var npc);
|
||
DonorPoiActionCatalog4229938.TryGet(item.PoiActionId, out var poi);
|
||
var mainActionId = poi?.SelectMainAction(entityId) ?? 0;
|
||
|
||
// Same nested contract shape as the donor path, built by the donor's own factory so the two
|
||
// can never drift apart again. See the note in GameRouter.DonorNpc.cs.
|
||
var syncInfo = CreateDonorStaticNpcSyncInfo(
|
||
new DonorNpcSpawnItem(item.NpcFormworkId, item.PoiActionId), npc, poi, mainActionId, dress,
|
||
activityCfgId, aiSettingOverride, treeNameOverride);
|
||
|
||
await session.NotifyAsync(Ananta.Server.Protocol.Client4229938.MethodId.SyncAetherAIStaticNpcAddData,
|
||
UxSerializer.Serialize(new GameMethods.DonorClientStaticNpcInitData4229938
|
||
{
|
||
StaticNpcInfoId = item.NpcFormworkId,
|
||
NpcFormworkId = item.NpcFormworkId,
|
||
AgentPersonaId = npc?.AgentPersonaId ?? 0,
|
||
SPoiActionId = 0,
|
||
CPoiActionId = item.PoiActionId,
|
||
UrbanDiversityId = 88888000,
|
||
IgnoreAllStim = false,
|
||
TaskRelated = false,
|
||
NpcPid = (int)item.NpcFormworkId,
|
||
AgentSyncClientInfo = syncInfo,
|
||
ForceGo = true,
|
||
SourceType = 1, // UX.Game.StaticNpcSourceType.Spoon
|
||
Id = entityId,
|
||
Position = new() { X = x, Y = y, Z = z },
|
||
Facing = facing,
|
||
EulerAngles = new() { X = 0f, Y = facing, Z = 0f },
|
||
}), CancellationToken.None);
|
||
|
||
await session.NotifyAsync(Ananta.Server.Protocol.Client4229938.MethodId.SyncManagedLogicAgent,
|
||
UxSerializer.Serialize(WorldCodec.ManagedLogicAgent(entityId, Profile.PlayerPid, 0)), CancellationToken.None);
|
||
|
||
if (poi is { UsageId: not 0 })
|
||
{
|
||
// The usage loader needs owned items before it can bind a prop (phone, drink, burger).
|
||
await session.NotifyAsync(Ananta.Server.Protocol.Client4229938.MethodId.SyncAgentBelongings,
|
||
UxSerializer.Serialize(new GameMethods.DonorSyncNpcBelongings4229938
|
||
{
|
||
Id = entityId,
|
||
Items = poi.BelongingSceneItems.Select(configId => new GameMethods.DonorNpcBelongingItem4229938
|
||
{
|
||
InstanceId = unchecked((ulong)IdCounterStore.Next(NpcBelongingIdCounter, NpcBelongingIdFloor)),
|
||
ConfigId = configId,
|
||
}).ToList(),
|
||
DebugAgentId = item.NpcFormworkId,
|
||
}), CancellationToken.None);
|
||
|
||
await session.NotifyAsync(Ananta.Server.Protocol.Client4229938.MethodId.SyncAgentUseBelongingChanged,
|
||
UxSerializer.Serialize(new GameMethods.DonorSyncNpcBelongingUsage4229938
|
||
{
|
||
Id = entityId,
|
||
UsageIds = [poi.UsageId],
|
||
}), CancellationToken.None);
|
||
}
|
||
|
||
if (item.PoiActionId != 0)
|
||
{
|
||
// Stored once in the logical POI module at normal speed. Never replay it: reactions
|
||
// (a car passing, the player bumping into them) have to be able to interrupt it.
|
||
await session.NotifyAsync(Ananta.Server.Protocol.Client4229938.MethodId.IGameSceneToClient_SyncAgentPlayPOIAction,
|
||
UxSerializer.Serialize(new GameMethods.DonorClientNpcPoiActionData4229938
|
||
{
|
||
Id = entityId,
|
||
CPoiActionId = item.PoiActionId,
|
||
IsActive = true,
|
||
PlayPoiSpeed = 1f,
|
||
}), CancellationToken.None);
|
||
}
|
||
|
||
// T2-32 路人骇入:单位已经建好,现在才标可骇(C# 侧要能 GetUnit(unitId) 查到它)。
|
||
// POI 已经在拿手机且正处在可骇 usage(1/2/6)时不必再插一手 —— 它本来就该有按钮;
|
||
// 其余情况(POI 是拍照/打字/吃东西,或压根没有 usage)换成手机 + 可骇 usage。
|
||
var poiUsageAlreadyHackable = poi is { UsageId: not 0 } p && HackableUsageIds.Contains(p.UsageId);
|
||
await MarkNpcHackableOnSpawnAsync(session, entityId, HackNpcChannel.Static,
|
||
skipPhone: skipPhone || skipHackPhone || poiUsageAlreadyHackable);
|
||
|
||
session.Log.Info($"[CIVILIAN] spawn formwork={item.NpcFormworkId} model={npc?.GeneralModelId ?? 0} " +
|
||
$"persona={npc?.AgentPersonaId ?? 0} poi={item.PoiActionId} action={mainActionId} " +
|
||
$"entity={entityId} at=({x:F2},{y:F2},{z:F2}) facing={facing:F2} " +
|
||
$"skipHackPhone={skipHackPhone} " +
|
||
$"dress={(dress is null
|
||
? "none"
|
||
: $"uid={dress.Uid} spirit={dress.FightSpiritId} suit={dress.SuitId} " +
|
||
$"pieces={string.Join(',', dress.FashionIds)} hidden={dress.HiddenParts}")}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 切人留场专用:走 <c>SyncRaidBattleUnitAgent</c>(68402349) 通道把老角色刷成 NPC。
|
||
///
|
||
/// ⛑⛑ 为什么不用 <c>SendCivilianAsync</c>(Aether 静态 NPC 通道):那条线<b>没有地方放单件衣服</b> ——
|
||
/// 顶层只有 <c>FashionSuitId</c>(套装 id),而 <c>AIAgentInfo.Fashion.WearInfo</c>
|
||
/// 客户端根本不读(09-25 两次实测:包发出去了、6 件衣服都在,但模型依旧默认服装)。
|
||
/// <c>RaidBattleUnitAgent</c> 同时带 <c>SuitId</c> + <b><c>FashionIdList</c></b>(单件列表)
|
||
/// + 完整 46 字段 <c>AgentSyncClientInfo</c>,是唯一能指定单件的原生通道。
|
||
///
|
||
/// 序列照抄 <c>GameAdminBridge.SpawnAgentAsync</c>(dbnb-0.2 实机验证过的那条):
|
||
/// SyncLogicAgentEnter → SyncManagedLogicAgent → SyncRaidBattleUnitAgent →
|
||
/// SyncUnitOccur → SyncAgentCampInfo(camp=0) → SyncAttachBattleModule → POI。
|
||
/// ⛑ 友方三件套(规则 132):<b>不发</b> SyncForceEnemyRelation、camp 填 0、
|
||
/// 也不注册进服务端敌人账本(这里压根不走那条路)。
|
||
/// </summary>
|
||
internal static async Task SendLeftBehindAgentAsync(
|
||
TcpSession session, uint agentId, ulong unitId,
|
||
float x, float y, float z, float facing,
|
||
uint poiActionId, CivilianLinkDress? dress, bool forceGo, uint activityCfgId = 0)
|
||
{
|
||
DonorNpcCatalog4229938.TryGet(agentId, out var npc);
|
||
DonorPoiActionCatalog4229938.TryGet(poiActionId, out var poi);
|
||
var mainActionId = poi?.SelectMainAction(unitId) ?? 0;
|
||
|
||
// ⛑ 46 字段版本(NpcSpawnMethods.AgentSyncClientInfo),与 43 字段的
|
||
// DonorNpcAgentSyncClientInfo4229938 是**两个不同的 wire**,别混用。
|
||
var syncInfo = new SceneMethods.AgentSyncClientInfo
|
||
{
|
||
isApproachNpc = true,
|
||
TriggerLeaveEvent = true,
|
||
approachDistance = 10000,
|
||
LeaveDistance = 10000,
|
||
stimIDList = [],
|
||
spawnEffectId = [],
|
||
indoorList = [],
|
||
roomIds = [],
|
||
returnPoiActionIds = poiActionId == 0 ? [] : [poiActionId],
|
||
sitIndex = -1,
|
||
SpoonAgentId = -1,
|
||
// 平民走 Main_Common_NPC(SpawnAgentAsync 里 hostile=false 用的就是它);
|
||
// 空 treeName ⇒ 客户端解不出行为树 ⇒ T-pose。
|
||
treeName = "Main_Common_NPC",
|
||
AISetting = npc?.AiSettingId ?? 0,
|
||
InteractId = npc?.InteractSettingId ?? 0,
|
||
actionId = mainActionId != 0 ? mainActionId : DefaultNpcActionId,
|
||
actionGroupId = poi?.MainActionGroup ?? DefaultNpcActionGroupId,
|
||
initPoiActionId = poiActionId,
|
||
useDefaultPoiOnReturn = poiActionId != 0,
|
||
// 不进战斗:beHitType=1(挨打有反应但掉血走战斗结算的那套关掉)
|
||
beHitType = 1,
|
||
isAttackInSafeMode = true,
|
||
forbidAetherAI = true,
|
||
agentStimType = 0,
|
||
forbidStimulateType = 0,
|
||
// ★ 过日子接线点:非 0 = 客户端这个 NPC 走「生活日程」状态(去播那条活动的表演),
|
||
// 会盖掉上面的 ambient POI;0 = 老样子(只会站着待机)。
|
||
// 值是 AgentDataSetsActivityConfig.Id,来自 NpcActivityCatalog.json。
|
||
AgentDataSetsActivityCfgId = activityCfgId,
|
||
FashionSuitId = dress is { SuitId: not 0 } ? dress.SuitId : 11190001,
|
||
AIAgentInfo = new SceneMethods.LinkAIAgentInfo
|
||
{
|
||
Uid = unitId,
|
||
FightSpiritId = dress?.FightSpiritId ?? 0,
|
||
Fashion = dress is null ? null : new SceneMethods.LinkAIFashionInfo
|
||
{
|
||
SuitId = dress.SuitId,
|
||
WearInfo = new Auto.OtherPlayerSpiritWearFashionsInfo
|
||
{
|
||
WearFashionColoringInfoDict = new Dictionary<uint, Auto.FashionColoringInfo>(),
|
||
WearFashionInfoList = dress.FashionIds.Where(id => id != 0)
|
||
.Select(id => new Auto.WearFashionInfo { FashionId = id }).ToList(),
|
||
WearFashionEditInfoList = [],
|
||
HiddenParts = dress.HiddenParts,
|
||
EditedHiddenParts = 0,
|
||
},
|
||
},
|
||
Nickname = dress?.Nickname,
|
||
},
|
||
};
|
||
|
||
await session.NotifyAsync(Ananta.Server.Protocol.Client4229938.MethodId.SyncLogicAgentEnter,
|
||
UxSerializer.Serialize(WorldCodec.LogicAgentEnter(unitId)), CancellationToken.None);
|
||
await session.NotifyAsync(Ananta.Server.Protocol.Client4229938.MethodId.SyncManagedLogicAgent,
|
||
UxSerializer.Serialize(WorldCodec.ManagedLogicAgent(unitId, Profile.PlayerPid, 0)), CancellationToken.None);
|
||
|
||
await session.NotifyAsync(Ananta.Server.Protocol.Client4229938.MethodId.SyncRaidBattleUnitAgent,
|
||
UxSerializer.Serialize(new SceneMethods.RaidBattleUnitAgent
|
||
{
|
||
SkillId = -1,
|
||
HSummonIndex = -1,
|
||
SpoonAgentId = -1,
|
||
// ★★★ 单件衣服就在这里 —— 市民通道没有这个字段。
|
||
SuitId = dress?.SuitId ?? 0,
|
||
FashionIdList = (dress?.FashionIds ?? []).Where(id => id != 0).ToList(),
|
||
VehicleIndex = -1,
|
||
IsBorn = true,
|
||
BattleAiS = false,
|
||
agentSyncClientInfo = syncInfo,
|
||
WeaponId = 0,
|
||
SpawnType = 1, // AgentSpawnType.Gm
|
||
AIAgentInfo = syncInfo.AIAgentInfo,
|
||
Id = unitId,
|
||
TemplateId = agentId,
|
||
Position = new Auto.UXVector3 { X = x, Y = y, Z = z },
|
||
FacingDirection = facing,
|
||
ManagedPid = Profile.PlayerPid,
|
||
GroundData = new Auto.MoveActionGroundData
|
||
{
|
||
MoveGroundType = 0,
|
||
MoveGroundId = 0,
|
||
MetaInfo = 0,
|
||
LocalPos = new Auto.UXVector3(),
|
||
},
|
||
}), CancellationToken.None);
|
||
|
||
await session.NotifyAsync(Ananta.Server.Protocol.Client4229938.MethodId.SyncUnitOccur,
|
||
UxSerializer.Serialize(new SceneMethods.SyncUnitOccur
|
||
{
|
||
enemyId = unitId,
|
||
occurId = 0,
|
||
dullSecs = 0f,
|
||
position = new Auto.UXVector3 { X = x, Y = y, Z = z },
|
||
rotateY = facing,
|
||
}), CancellationToken.None);
|
||
|
||
// camp 0 = 平民/未知(4 = AllEnemy)。规则 132:友方 NPC 必须 0,
|
||
// 否则玩家的本地战斗 AI 会把"自己人"当可攻击目标。
|
||
await session.NotifyAsync(Ananta.Server.Protocol.Client4229938.MethodId.SyncAgentCampInfo,
|
||
UxSerializer.Serialize(new SceneMethods.SyncAgentCampInfo { pid = unitId, camp = 0 }),
|
||
CancellationToken.None);
|
||
|
||
// 骨骼绑定:少了它单位就是 T-pose。
|
||
await session.NotifyAsync(Ananta.Server.Protocol.Client4229938.MethodId.SyncAttachBattleModule,
|
||
UxSerializer.Serialize(new SceneMethods.SyncAttachBattleModule { unitId = unitId }),
|
||
CancellationToken.None);
|
||
|
||
if (forceGo)
|
||
{
|
||
await session.NotifyAsync(Ananta.Server.Protocol.Client4229938.MethodId.IGameSceneToClient_SyncAgentForceGo,
|
||
UxSerializer.Serialize(new SceneMethods.SyncAgentForceGo4229938
|
||
{
|
||
id = unitId,
|
||
isForceGo = true,
|
||
}), CancellationToken.None);
|
||
}
|
||
|
||
if (poiActionId != 0)
|
||
{
|
||
await session.NotifyAsync(Ananta.Server.Protocol.Client4229938.MethodId.IGameSceneToClient_SyncAgentPlayPOIAction,
|
||
UxSerializer.Serialize(new GameMethods.DonorClientNpcPoiActionData4229938
|
||
{
|
||
Id = unitId,
|
||
CPoiActionId = poiActionId,
|
||
IsActive = true,
|
||
PlayPoiSpeed = 1f,
|
||
}), CancellationToken.None);
|
||
}
|
||
|
||
session.Log.Info($"[LEFTBEHIND-AGENT] formwork={agentId} model={npc?.GeneralModelId ?? 0} " +
|
||
$"persona={npc?.AgentPersonaId ?? 0} ai={npc?.AiSettingId ?? 0} unit={unitId} " +
|
||
$"poi={poiActionId} tree=Main_Common_NPC camp=0 forceGo={forceGo} " +
|
||
$"activity={activityCfgId} " +
|
||
$"suit={dress?.SuitId ?? 0} pieces={(dress is null ? "none" : string.Join(',', dress.FashionIds))} " +
|
||
$"at=({x:F2},{y:F2},{z:F2}) facing={facing:F2}");
|
||
}
|
||
}
|