feat: Upgrade server with HyNB v2.4 modular architecture, minigames, admin GM web panel, companion summons, and car radio
This commit is contained in:
1 parent
74e2963a12
commit
3e5adf1dc5
1571 files changed
+336468
-6348
No files matched your search
@@ -0,0 +1,419 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user