933 lines
48 KiB
C#
933 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}");
|
|||
|
|
}
|
|||
|
|
}
|