420 lines
26 KiB
C#
420 lines
26 KiB
C#
using Ananta.SDK.Network;
|
||
using Ananta.SDK.Serialization;
|
||
using Ananta.Server.ClientData.Client4229938;
|
||
using Ananta.Server.Configuration;
|
||
using Ananta.Server.Protocol.Client4229938;
|
||
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
||
|
||
namespace Ananta.Server.Handlers.Game;
|
||
|
||
/// <summary>
|
||
/// 派对 NPC 落地 —— 把「被邀请的养成 NPC」真正放进场景(2026-09-26 第 30 轮)。
|
||
///
|
||
/// WHY THIS FILE EXISTS
|
||
/// --------------------
|
||
/// 用户实测「可以选 NPC 了,但邀请的 NPC 看不见」。逐层查证后定位到:**派对分支根本不生成实体**。
|
||
///
|
||
/// 客户端 NpcChatManager.lua:180-223(收到 SyncNpcChatLeaveGameplay 后的分发):
|
||
/// · 餐厅 → gRestaurantManager:InviteClientNpc(npcId, gameplay)
|
||
/// → RestaurantManager.lua:789
|
||
/// gNpcInviteManager:InviteClientNpcForGamePlayWithPos(
|
||
/// cfg.RestaurantNpcid, gameplay, self, self.invitePos, ...) ← ★ 生成实体
|
||
/// · 蹦迪 → BengdiActionManager.lua:380 同上(用 cfg.BengdiNpcid) ← ★ 生成实体
|
||
/// · 派对 → gPartyManager:StartSingleParty(singlePartyId, multiNpcTidList)
|
||
/// → 只写 partyInviteNpcIdList + CheckShow(PARTY_HUD_PANEL) ← ✗ 没有实体
|
||
/// 全量 grep(_lua_allgrep.py,含 212 个含 NUL 的文件)确认 InviteClientNpc* 的调用者
|
||
/// 只有 RestaurantManager 与 BengdiActionManager 两处 —— **派对一次都没有**。
|
||
///
|
||
/// 佐证:PartyNpcInvitePanelStore.lua:113-135(派对专用 NPC 面板)是**扫场景实体**挑人的:
|
||
/// gCS.SceneDataMgr.UnitsManager:LuaGetAllUnits(units)
|
||
/// for _, unit in ipairs(units) do
|
||
/// local npcId = self.GetNpcCultivationId(self, unit) -- :180
|
||
/// ⇒ 场景里没有这个 unit,面板就是空的、场景里也看不见人。
|
||
/// GetNpcCultivationId 的判据(:180-188):
|
||
/// local spiritCfg = LTConfig.FightSpiritConfig.GetConfig(unit.ClientData.cardId)
|
||
/// return spiritCfg and spiritCfg.NpcCultivationRelatedId or 0
|
||
/// ⇒ 反查用的是 `unit.ClientData.cardId`,而 cardId 属于
|
||
/// **FightSpiritConfig 的 Id 空间(15xxxxxx)**。
|
||
/// 🛑 **第 45 轮更正**:本节初稿写的「取值链 = NpcCultivationConfig.FightSpiritID →
|
||
/// FightSpiritConfig.AgentId,cardId 由 AgentConfig 推出」**是错的**。证据:
|
||
/// ① NpcCatalog[40140035](Taffy 的 AgentConfig 行)里**没有**
|
||
/// cardId/FightSpiritId 字段,只有 GeneralModelId/AgentPersonaId/
|
||
/// BehaviorActionConfigId/AiSettingId —— 推不出 cardId;
|
||
/// ② CoreHudUIManager_Decisions.lua:557 把 `ClientData.cardId` 直接和
|
||
/// `LTConfig.FightSpiritConfig.Taffy`(=15020992) 比较。
|
||
/// ⇒ cardId 只能来自 **AIAgentInfo.FightSpiritId**(下方 dress 参数)。
|
||
/// AgentId 那张表仍然要(它管 NpcFormworkId=实体模板),
|
||
/// 但**光有它不够** —— 缺了 FightSpiritId,客户端就不把它当成可玩角色。
|
||
///
|
||
/// 服务端本来就有「把 NPC 放进场景」的通道(不是新增机制,是复用已验证的):
|
||
/// GameRouter.Civilians.cs SendCivilianAsync 的**四步序列**
|
||
/// (SyncAetherAIStaticNpcAddData → SyncManagedLogicAgent → 可选 belongings → 可选 POI),
|
||
/// GameRouter.FunctionalNpc.cs 的 PlaceFunctionalNpcAsync 就用它铺城市里的店员/售票员。
|
||
/// 本文件只做一件事:把**派对邀请名单**喂给同一个通道,位置放在玩家身边。
|
||
/// ⇒ 不发明新的 spawn 路径(对照 FunctionalNpc.cs 开头那句 "this file does not invent a spawn path")。
|
||
///
|
||
/// ⚠ 已知边界(诚实标注,未验证的部分不要当结论)
|
||
/// 1. 「能不能被 PartyNpcInvitePanelStore 认成养成 NPC」取决于客户端 unit.ClientData.cardId
|
||
/// 是否由 AgentConfig 推出 15020992(Taffy)/ 对应 FightSpiritID。本实现只保证
|
||
/// **实体生成且可见**;识别问题要等实测反馈。
|
||
/// 2. 落点用「玩家身边环绕一圈、半径 2.5m」,理由见铁律 17(落点必须实测站得住)——
|
||
/// 2.5m 是保守值,小于这个容易和玩家胶囊互顶。若发现半空抽搐/被顶飞,先把半径调大。
|
||
/// 3. ~~不做驻留 AI:POI Action 传 0(无闲置动作),NPC 会站着。~~ **★ 第 38 轮已接上**
|
||
/// —— 改为由 `GameplaySettings.PartyNpcPoi*` 提供「预制动作」表,按人轮转传给
|
||
/// `CivilianSpawnItem.PoiActionId`(露营 = 地面野餐/围坐,舞会 = 鼓掌/围观)。
|
||
/// 仍然**不做**驻留巡逻 AI(不推移动任务),但「一动不动站着」这一条已经不成立。
|
||
/// </summary>
|
||
internal sealed partial class GameRouter
|
||
{
|
||
/// <summary>本 session 本次派对邀请到的 NpcCultivationId(InviteMultiNpcChat 写入,StartSingleParty 消费)。</summary>
|
||
private const string PartyNpcInvitedKey = "party.npc.invited";
|
||
|
||
/// <summary>本 session 已经放进场里的 NpcCultivationId → entityId,用于重开派对时先撤掉旧的一批。</summary>
|
||
private const string PartyNpcPlacedKey = "party.npc.placed";
|
||
|
||
/// <summary>
|
||
/// NpcCultivationId → AgentConfig(NpcCatalog 的 Id,即实体模板)id。
|
||
///
|
||
/// 取值链(两张表 join,24/24 命中,非猜):
|
||
/// ConfigDump_v3\NpcCultivationConfig.json :: FightSpiritID
|
||
/// → ConfigDump_v3\FightSpiritConfig.json :: AgentId
|
||
/// 例:91050014(Taffy) → FightSpiritID 15020992 → FightSpiritConfig.AgentId 40140035
|
||
/// 而 AgentConfig[40140035].Description = "唯一塔菲"、Name = "Taffy" —— 自洽。
|
||
/// 24 个目标 agentId 全部存在于服务端 ClientData\4229938\Configs\NpcCatalog.json(已验证,0 缺失)。
|
||
///
|
||
/// ⚠ 这是配表数据的内联快照(与 PartyDurationByPartyId 同一种做法)。若客户端换版本、
|
||
/// 或这 24 条之外的 NPC 也要能邀,请从上面两张表重新生成整段。
|
||
/// 末尾三名(91059xxx)在 FightSpiritConfig 里没有 AgentId(值为 0),属"不可落地",故不入表。
|
||
/// </summary>
|
||
private static readonly Dictionary<uint, uint> PartyNpcAgentByCultivationId = new()
|
||
{
|
||
[91050013] = 40140033, // Rin
|
||
[91050014] = 40140035, // Taffy(唯一塔菲)
|
||
[91050015] = 40140032, // Garm
|
||
[91050016] = 40140034, // Bansy
|
||
[91050018] = 40140039, // Tina
|
||
[91050019] = 40140038, // Mechanika
|
||
[91050020] = 40140040, // Richie
|
||
[91050021] = 40140037, // Alan
|
||
[91050022] = 40140044, // Lykaia
|
||
[91050023] = 40140041, // Ringo
|
||
[91050024] = 40140036, // Dila
|
||
[91050025] = 40140030, // Chenxiu
|
||
[91050026] = 40140031, // Zhaozhao
|
||
[91050027] = 40140043, // Aileen
|
||
[91050028] = 40140053, // Anonym
|
||
[91050029] = 40140049, // Bernard
|
||
[91050030] = 40140048, // Mimiko
|
||
[91050031] = 40140056, // Shi Ye
|
||
[91050032] = 40140042, // Seymour
|
||
[91050033] = 40140054, // Bai Jing
|
||
[91050034] = 40140055, // Yinglong
|
||
[91050035] = 40140077, // You Zhi
|
||
[91050036] = 40155013, // Li Fufeng
|
||
[91050037] = 40155001, // Cheng Niang
|
||
};
|
||
|
||
/// <summary>
|
||
/// <summary>
|
||
/// 撤掉当前场上这一批派对 NPC,返回撤掉的人数。
|
||
///
|
||
/// ★ 第 46 轮新增:抽出来给两处复用 —— ①重开派对(`SpawnPartyNpcsAsync` 开头);
|
||
/// ②**派对结束**(`PartyOver4229938`)。在此之前只有①有撤人逻辑,派对结束后
|
||
/// 那批 NPC 会**永久留在原地**(用户反馈「派对结束后角色一直不走」)。
|
||
///
|
||
/// ⛑⛑ **双包并用**(2026-09-26 现场实测后修正):
|
||
/// · `SyncAetherAINpcRemove`(68332284) 的契约注释写着
|
||
/// 「`pid` = 当初 **`ClientCrowdInitData.Id`**」⇒ 它是 **crowd 通道**的撤人包。
|
||
/// 派对 NPC 走的是 **static 通道**(`SyncAetherAIStaticNpcAddData`)
|
||
/// ⇒ 单独发它**撤不掉**(packets 日志 5 条 body=8b 都在、客户端毫无反应)。
|
||
/// · 真正「**销毁一个单位**」的是 `SyncLogicAgentLeave`(68281244),它是
|
||
/// `SyncLogicAgentEnter`(68719890) 的另一半(注册 / 注销进 logic-agent 管理器)。
|
||
/// 依据:`GameRouter.Switching.cs` 的**反向**用法已实测 —— 切人留场时**不发**它,
|
||
/// 单位就留在大世界里;发了它单位才消失(迁移期「Leave 之后模型才重建」那条记录)。
|
||
/// ⇒ 两个都发:Leave 负责 static 通道,Remove 兜住 crowd 通道,互不冲突。
|
||
/// (⚠ `GameRouter.EnemyTerritory.cs:313` 那句「Enter(68281243)」是**错注释** ——
|
||
/// 权威 id 表 `MethodId.dump.cs` 里没有 68281243。)
|
||
///
|
||
/// ⛑ 撤完就把 `placed` 清空 ⇒ 重复调用是安全的(第二次进不来)。
|
||
/// </summary>
|
||
internal static async Task<int> RemovePlacedPartyNpcsAsync(TcpSession session)
|
||
{
|
||
if (!session.Items.TryGetValue(PartyNpcPlacedKey, out var raw) || raw is not Dictionary<uint, ulong> placed
|
||
|| placed.Count == 0)
|
||
return 0;
|
||
|
||
var ids = placed.Values.ToList();
|
||
placed.Clear();
|
||
foreach (var id in ids)
|
||
{
|
||
// ① static 通道:注销 logic-agent ⇒ 客户端销毁这个单位。
|
||
await session.NotifyAsync(MethodId.SyncLogicAgentLeave,
|
||
UxSerializer.Serialize(WorldCodec.LogicAgentLeave(id)), CancellationToken.None);
|
||
|
||
// ② crowd 通道:Aether 撤人(对 static 单位可能是 no-op,但不冲突)。
|
||
await session.NotifyAsync(MethodId.SyncAetherAINpcRemove,
|
||
UxSerializer.Serialize(new SceneMethods.SyncAetherAINpcRemove4229938 { pid = id }),
|
||
CancellationToken.None);
|
||
}
|
||
|
||
return ids.Count;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 记下「本次派对邀请了谁」。由 InviteMultiNpcChat handler 在选定 NPC 之后调用。
|
||
/// 服务端是**自己选**名单的(GameRouter.NpcInvite.cs 的 candidates[cursor]),
|
||
/// 所以这里记的就是真话,不需要去解客户端那份 List7Bit。
|
||
/// </summary>
|
||
private static void RememberPartyInvitedNpc(TcpSession session, uint npcCultivationId)
|
||
{
|
||
if (npcCultivationId == 0)
|
||
return;
|
||
|
||
if (!session.Items.TryGetValue(PartyNpcInvitedKey, out var raw) || raw is not List<uint> list)
|
||
{
|
||
list = [];
|
||
session.Items[PartyNpcInvitedKey] = list;
|
||
}
|
||
|
||
if (!list.Contains(npcCultivationId))
|
||
list.Add(npcCultivationId);
|
||
}
|
||
|
||
/// <summary>StartSingleParty 用:取走本次邀请名单并清空(一次派对消费一次)。</summary>
|
||
private static List<uint> TakePartyInvitedNpcs(TcpSession session)
|
||
{
|
||
if (session.Items.TryGetValue(PartyNpcInvitedKey, out var raw) && raw is List<uint> list)
|
||
{
|
||
session.Items[PartyNpcInvitedKey] = new List<uint>();
|
||
return list;
|
||
}
|
||
|
||
return [];
|
||
}
|
||
|
||
/// <summary>
|
||
/// 把名单里的养成 NPC 放到**调用方指定的圆心**周围。返回一行人类可读的日志摘要(调用方直接写日志)。
|
||
///
|
||
/// 位置策略:**按玩法分两档**(2026-09-26 第 49 轮起)——
|
||
/// <list type="bullet">
|
||
/// <item><b>露营档</b>(<paramref name="unifiedFacingDeg"/> = <c>null</c>,默认):
|
||
/// 以 (x,y,z) 为圆心、半径 = <c>PartyNpcSpawnRadius</c>(默认 2 m),从
|
||
/// <paramref name="baseAngle"/> 起均匀环绕;NPC 一律**面向圆心**。</item>
|
||
/// <item><b>舞会档</b>(<paramref name="unifiedFacingDeg"/> 有值):以 (x,y,z) 为**舞池点**、
|
||
/// 沿「舞池正面」的**横排**铺开(与朝向垂直,间距 = 同一个 <c>PartyNpcSpawnRadius</c>),
|
||
/// 全员**统一朝 <paramref name="unifiedFacingDeg"/>**(官方 <c>DanceRotation</c>)——
|
||
/// **不是**朝圆心。舞池点的来源见 <c>PartyDanceSpotCatalog4229938</c>。</item>
|
||
/// </list>
|
||
///
|
||
/// ★ 第 34 轮:**圆心与基准角由调用方给**,本函数不再自己决定「圆心 = 玩家」。
|
||
/// 调用方优先传配表的 `PartySpot` + `PartyRotation`(官方规定场地,
|
||
/// 见 `ClientData\Client4229938\PartySpotCatalog4229938.cs`),
|
||
/// 拿不到才回退玩家位置 —— 那只是兜底,不是设计意图。
|
||
///
|
||
/// ★★ 第 38 轮:新增 <paramref name="poiActionIds"/> = **预制动作**表(按人轮转)。
|
||
/// 在此之前这里写死 `new CivilianSpawnItem(agentId, 0)` ⇒ 第 4 步被跳过
|
||
/// ⇒ 实测 `poi=0 action=0` ⇒ NPC 只有兜底 idle。空数组 = 保持旧行为(不发 POI)。
|
||
/// 动作 id 的语义与来源见 `GameplaySettings.PartyNpcPoi*` 的注释。
|
||
///
|
||
/// ★★★ 第 49 轮(舞会「走官方路径」):新增 <paramref name="unifiedFacingDeg"/> ——
|
||
/// **舞会摆法**的开关。<c>null</c>(默认)= 露营摆法(圆周 + 朝圆心,一字未改);
|
||
/// 有值 = 舞会摆法(横排 + 全员统一朝这个角度,值 = 官方
|
||
/// <c>PartyDanceSpotConfig.DanceRotation</c>)。
|
||
/// ⚠ 两档在同一个循环里**互斥**,不是把露营的圆周参数套到舞会上 —— 这正是用户那句
|
||
/// 「舞会有舞会的摆法,不要参考露营」的落点。
|
||
/// </summary>
|
||
internal static async Task<string> SpawnPartyNpcsAsync(
|
||
TcpSession session, IReadOnlyList<uint> npcCultivationIds, float x, float y, float z,
|
||
float baseAngle = 0f, string anchorNote = "玩家锚点",
|
||
IReadOnlyList<uint>? poiActionIds = null,
|
||
float? unifiedFacingDeg = null,
|
||
// ★★★ 第 53 轮(派对 NPC 服装):NpcCultivationId → FashionSuitConfig.Id。
|
||
// null / 查不到该人 ⇒ SuitId = 0 ⇒ 客户端回落 11190001(= 改动前行为,零差异)。
|
||
// 数据来源与优先级见 GameRouter.NpcInvite.cs 的 ResolvePartyNpcSuits。
|
||
IReadOnlyDictionary<uint, uint>? suitByNpcId = null,
|
||
// ★ 2026-09-30:就地散布。非 null 且 > 0 ⇒ 以 (x,z) 为圆心随机撒开(角度+距离都随机),
|
||
// 每人朝圆心。null = 保持原摆法(舞会横排 / 露营圆周),零改动。
|
||
float? scatterRadiusMeters = null)
|
||
{
|
||
var placed = session.Items.TryGetValue(PartyNpcPlacedKey, out var rawPlaced)
|
||
&& rawPlaced is Dictionary<uint, ulong> existing
|
||
? existing
|
||
: null;
|
||
if (placed is null)
|
||
{
|
||
placed = new Dictionary<uint, ulong>();
|
||
session.Items[PartyNpcPlacedKey] = placed;
|
||
}
|
||
|
||
// 重开派对:先把上一批撤掉,否则会原地叠人。
|
||
if (placed.Count > 0)
|
||
{
|
||
await RemovePlacedPartyNpcsAsync(session);
|
||
session.Log.Info($"[PARTYNPC] 撤掉上一批 {placed.Count} 人(同一 session 重开派对)");
|
||
}
|
||
|
||
// ★ 第 46 轮:上一批人的驻留行为循环也要停掉,否则它会继续给已经删掉的 id 派任务。
|
||
ResetPartyNpcRoam(session);
|
||
|
||
if (npcCultivationIds.Count == 0)
|
||
return "[PARTYNPC] 邀请名单为空 ⇒ 不生成(先走「NPC 群聊邀请面板」勾人再提交)";
|
||
|
||
// crowd 通道在收到 Aether init 之前不会把 NPC 实体化 —— 与 Civilians/FunctionalNpc 同一前置。
|
||
await SendDonorAetherInitAsync(session);
|
||
|
||
// ★ 第 47 轮:半径改为可配。默认 **2.0 m**(原硬编码 2.5;用户现场定为 2)。
|
||
var radius = MathF.Max(0.3f, PrivateServerConfigStore.Current.Gameplay.PartyNpcSpawnRadius);
|
||
var n = npcCultivationIds.Count;
|
||
var done = new List<string>();
|
||
var skipped = new List<string>();
|
||
|
||
for (var i = 0; i < n; i++)
|
||
{
|
||
var npcId = npcCultivationIds[i];
|
||
if (!PartyNpcAgentByCultivationId.TryGetValue(npcId, out var agentId) || agentId == 0)
|
||
{
|
||
skipped.Add($"{npcId}(无实体模板)");
|
||
continue;
|
||
}
|
||
|
||
// 环绕圆心一圈;单人时放在 baseAngle 方向,多人时从 baseAngle 起按角度均分。
|
||
// ★ 第 34 轮加 baseAngle 偏移:官方配表的 `PartyRotation` 就是「派对场地朝向」,
|
||
// 拿它当基准角,NPC 的排布才和场地朝向对得上(而不是恒从正前方开始)。
|
||
var angle = baseAngle + (n == 1
|
||
? 0f
|
||
: (2f * MathF.PI * i) / n);
|
||
|
||
// ★★★ 2026-09-30 就地散布:角度随机 + 距离随机(都只在散布模式下改)。
|
||
// 与原「圆周均分」的唯一差别就是这两个量不再均分;
|
||
// 朝向仍是 `angle + π`(朝圆心 = 朝玩家),所以散布的人全都面朝你。
|
||
// ⛑ 2026-09-30 修编译错误 CS0165:原来写成
|
||
// `var scatter = scatterRadiusMeters is { } sr && sr > 0f;`
|
||
// 然后在 if(scatter) 里用 sr —— C# 的确定赋值分析**不跟踪**布尔变量与
|
||
// 模式匹配变量之间的关系,编译器认为 sr 可能未赋值。
|
||
// ⇒ 改成先 `?? 0f` 落成一个确定有值的局部变量,语义逐字等价。
|
||
var scatterRadius = scatterRadiusMeters ?? 0f;
|
||
var scatter = scatterRadius > 0f;
|
||
var dist = radius;
|
||
if (scatter)
|
||
{
|
||
angle = (float)(Random.Shared.NextDouble() * 2.0 * Math.PI);
|
||
// 距离 ∈ [0.45R, 1.0R]:不至于全叠在圆心,也不会贴脸。
|
||
dist = scatterRadius * (0.45f + 0.55f * (float)Random.Shared.NextDouble());
|
||
}
|
||
|
||
// ★★★ 第 49 轮:**舞会有舞会的摆法**。
|
||
// 官方 `PartyDanceSpotConfig.DanceRotation` 是**舞池朝向** ⇒ 舞池里所有人
|
||
// **面向同一个方向**(朝舞台/DJ),而不是各自朝圆心。所以舞会档:
|
||
// · 朝向单位向量 f = (sin θ, cos θ) (与 Civilians.cs 同一约定:
|
||
// `pos = ref + (sinθ, cosθ)·d` 且 `facing = θ_度`)
|
||
// · 横排方向 s = f 绕 y 轴转 90° = (cos θ, −sin θ) ← 与朝向**垂直**
|
||
// · 第 i 人的偏移 = (i − (n−1)/2) · radius ← 以舞池点为中心左右摊开
|
||
// ⇒ 露营档(unifiedFacingDeg = null)走原来的圆周,**零改动**。
|
||
// ⚠ 舞会档里 `radius` 的含义变成「**横排间距**」(同一个配置键,语义随玩法变)。
|
||
float px, pz;
|
||
if (unifiedFacingDeg is not null)
|
||
{
|
||
var frad = unifiedFacingDeg.Value * (MathF.PI / 180f);
|
||
var offset = (i - (n - 1) * 0.5f) * radius;
|
||
px = x + MathF.Cos(frad) * offset;
|
||
pz = z - MathF.Sin(frad) * offset;
|
||
}
|
||
else
|
||
{
|
||
px = x + MathF.Sin(angle) * dist;
|
||
pz = z + MathF.Cos(angle) * dist;
|
||
}
|
||
|
||
// 实体 id 与市民/功能 NPC 共用同一个计数器 —— 另起一套会从同一个基数重启,
|
||
// 发出已被占用的 id(Civilians.cs 里对这件事有专门的踩坑注释)。
|
||
var entityId = unchecked((ulong)IdCounterStore.Next(StaticNpcIdCounter, StaticNpcIdFloor));
|
||
|
||
// ★★ 第 38 轮:预制动作按人轮转(第 i 个人拿第 i 个 id)。
|
||
// `poi=0` ⇒ SendCivilianAsync 的第 4 步整段跳过 ⇒ 客户端只有兜底 idle。
|
||
// ⚠ 这里**不做** id 白名单校验 —— SendCivilianAsync 内部(Civilians.cs:87/432)
|
||
// 已经会 `DonorPoiActionCatalog4229938.TryGet` 并在查不到时静默降级为「无 POI」,
|
||
// 重复校验只会多一处会写错的地方。
|
||
var poiActionId = poiActionIds is { Count: > 0 }
|
||
? poiActionIds[i % poiActionIds.Count]
|
||
: 0u;
|
||
|
||
// ★★★ 第 45 轮:必须把这个 NPC 标成「某个可玩角色」,
|
||
// 否则客户端根本认不出它是谁。
|
||
// 判据链(客户端 PartyNpcInvitePanelStore.lua:180-188):
|
||
// spiritCfg = LTConfig.FightSpiritConfig.GetConfig(unit.ClientData.cardId)
|
||
// return spiritCfg and spiritCfg.NpcCultivationRelatedId or 0
|
||
// 而 `ClientData.cardId` 的唯一来源 = **本通道的**
|
||
// AIAgentInfo.FightSpiritId(wire 顺序见 Civilians.cs:30-36:
|
||
// Uid → FightSpiritId → Fashion → Nickname → NameId → AvatarImageId)。
|
||
//
|
||
// ★ 一直传 `dress: null` ⇒ `AIAgentInfo` 是**空对象** ⇒ 没有 FightSpiritId
|
||
// ⇒ cardId=0 ⇒ 反查恒 0 ⇒ PartyNpcInvitePanelStore 扫完整个场景
|
||
// **一个人都挑不出来** ⇒ 「烤肉邀请」面板恒空。
|
||
// 这就是第 45 轮现场反馈的正解。
|
||
//
|
||
// ⚠ `Uid` 取 `Profile.PlayerPid`:与 GameRouter.Switching.cs:684(切人留场)
|
||
// 同一取法 —— 那里已经走通了同一条型号的 dress。
|
||
// ⚠ `SuitId=0` / `FashionIds=[]` ⇒ DonorNpc.cs 的
|
||
// `FashionSuitId = dress is { SuitId: not 0 } ? dress.SuitId : 11190001`
|
||
// 落回 11190001 —— 与传 null 时**同值**,不会改衣服。
|
||
var spiritId = NpcCultivationCatalog4229938.Cultivations
|
||
.FirstOrDefault(e => e.Id == npcId)?.FightSpiritId ?? 0u;
|
||
|
||
// ★★★ 第 47 轮修正:**朝向必须换算成「度」** —— 这就是「5 个人朝同一个方向」的根因。
|
||
// 既有约定三处一致(`GameRouter.Civilians.cs:107-110` / `GameRouter.DonorNpc.cs:84-87` /
|
||
// `GameRouter.Ambient.cs:769`,且路人线是实机跑通的):
|
||
// · `SendCivilianAsync` 的 `facing` 形参 = **角度制(度)**
|
||
// · 摆位用 `yaw = facing * PI / 180`(度→弧度)再做 sin/cos
|
||
// · 站在「参考点 + (sin θ, cos θ)·d」上的 NPC ⇒ `facing = 归一化(θ_度 + 180)`
|
||
// 之前这里传的是 `angle + MathF.PI`(**弧度**,0~6.28)⇒ 被当成「度」,
|
||
// 等于**全部朝 ≈0°(正北)** ⇒ 现场就是「所有人朝同一个方向」。
|
||
// 本意(朝圆心)本来就是对的,错的只有单位。
|
||
// ★★★ 第 49 轮:舞会档 = 全员统一朝官方 `DanceRotation`(**不朝圆心**);
|
||
// 露营档 = 原来的「朝圆心」(`angle + π`),一行未改。
|
||
var facingDeg = unifiedFacingDeg is { } danceFacingDeg
|
||
? MathF.IEEERemainder(danceFacingDeg, 360f)
|
||
: MathF.IEEERemainder((angle + MathF.PI) * (180f / MathF.PI), 360f);
|
||
|
||
// ★★★ 第 53 轮(派对 NPC 服装):把算好的套装喂进 dress。
|
||
// 传 0 = 不指定 ⇒ DonorNpc.cs:248 的
|
||
// `FashionSuitId = dress is { SuitId: not 0 } ? dress.SuitId : 11190001`
|
||
// 落回 11190001 —— 这正是用户报的「显示的还是默认服装」。
|
||
// 之前这里硬编码 0,等于**所有角色都穿同一套 Bansy 涂鸦装**。
|
||
var suitId = suitByNpcId is not null && suitByNpcId.TryGetValue(npcId, out var resolvedSuit)
|
||
? resolvedSuit
|
||
: 0u;
|
||
|
||
await SendCivilianAsync(session, new CivilianSpawnItem(agentId, poiActionId), entityId, px, y, pz, facingDeg,
|
||
dress: spiritId == 0
|
||
? null
|
||
: new CivilianLinkDress(Profile.PlayerPid, spiritId, suitId, []),
|
||
skipHackPhone: PrivateServerConfigStore.Current.Gameplay.PartyNpcPoiSkipHackPhone && poiActionId != 0,
|
||
aiSettingOverride: PrivateServerConfigStore.Current.Gameplay.PartyNpcAISettingOverride,
|
||
treeNameOverride: PrivateServerConfigStore.Current.Gameplay.PartyNpcTreeNameOverride);
|
||
|
||
// ★★★ 第 46 轮(用户诉求「不互动也要让角色自由行动」):补上让客户端接管 locomotion 的
|
||
// 三步 —— SyncLogicAgentEnter(任务从它派发)+ SyncAgentForceGo(启动行为树)
|
||
// + SyncMoveWandering(「让已投放 NPC 自己走起来的唯一官方包」)。
|
||
// 在此之前 SendCivilianAsync 只发到「存一个 POI 动作」为止 ⇒ 角色做完那套动作就站桩。
|
||
await ArmPartyNpcRoamAsync(session, entityId, px, y, pz, CancellationToken.None);
|
||
|
||
placed[npcId] = entityId;
|
||
done.Add($"{npcId}->{agentId}@({px:F1},{y:F1},{pz:F1}) facing={facingDeg:F1}deg #e{entityId} poi={poiActionId} spirit={spiritId} suit={suitId}");
|
||
}
|
||
|
||
var summary = $"[PARTYNPC] 生成 {done.Count}/{n} 人(" +
|
||
(unifiedFacingDeg is { } uf49
|
||
? $"★舞会摆法:舞池点={anchorNote},横排间距 {radius}m," +
|
||
$"全员统一朝向 {MathF.IEEERemainder(uf49, 360f):F1}°(= 官方 DanceRotation,不朝圆心)"
|
||
: $"露营摆法:圆心={anchorNote},半径 {radius}m,全员朝圆心") + ")" +
|
||
(poiActionIds is { Count: > 0 }
|
||
? $",预制动作={string.Join('/', poiActionIds)}"
|
||
: ",预制动作=无(poi=0,只有兜底 idle)") +
|
||
$",驻留行为={PrivateServerConfigStore.Current.Gameplay.PartyNpcRoamMode}" +
|
||
(done.Count > 0 ? ":" + string.Join(",", done) : "") +
|
||
(skipped.Count > 0 ? $";跳过 {string.Join(",", skipped)}" : "");
|
||
session.Log.Info(summary);
|
||
return summary;
|
||
}
|
||
}
|