378 lines
18 KiB
C#
378 lines
18 KiB
C#
using Ananta.SDK.Network;
|
||
using Ananta.SDK.Serialization;
|
||
using Ananta.Server.Configuration;
|
||
using Ananta.Server.Protocol.Client4229938;
|
||
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
||
|
||
namespace Ananta.Server.Handlers.Game;
|
||
|
||
/// <summary>
|
||
/// 派对 NPC「驻留行为」—— 让被邀请进派对的角色**自己会动**(2026-09-26 第 46 轮)。
|
||
///
|
||
/// WHY THIS FILE EXISTS
|
||
/// --------------------
|
||
/// 用户原话:「最主要的还是邀请进派对角色的行为问题,**不互动一个也要让角色们自由行动,
|
||
/// 自由做动作,不然还是死气沉沉**」。
|
||
///
|
||
/// 现场事实(第 45/46 轮实测):被邀角色**能被正确认出来(cardId 已修)、会播 POI 预制动作,
|
||
/// 但一动不动地站在原地做完那一套动作**。原因是 `SendCivilianAsync` 只发到「POI 动作」为止,
|
||
/// 少了让客户端接管 locomotion 的那几步。
|
||
///
|
||
/// ★★★★★ 官方口径(全部来自配表/客户端契约,不是猜)
|
||
/// · `SyncMoveWandering`(68745518) 的契约注释原文(CrowdMethods4229938.cs:204-207):
|
||
/// 「**让一个已投放的 NPC/行人"自己走起来"的唯一官方包。**
|
||
/// 它**不带目的地**——只有半径、角度窗口和时间预算,路径由客户端
|
||
/// ZoneGraph/AetherAI 自己挑。⇒ 服务端根本不需要路网来做"行走",
|
||
/// 只需要周期续期(MaxTime 是总时长,走完就永久站住)。」
|
||
/// · 项目自己的 static 路人配方(GameRouter.Ambient.cs:804-834 / 792-794):
|
||
/// `SendCivilianAsync`(建人)+ **`SyncAgentForceGo`(启动行为树)**… 且
|
||
/// 「crowd 通道是客户端自驱游荡,服务端发游荡包反而多余;**static 通道必须发,
|
||
/// 否则只播 idle**」—— 派对 NPC 正好走 static 通道。
|
||
/// · `GameRouter.Ambient.cs:16-27` 的终版配方把 `SyncLogicAgentEnter` 列为第 3 步:
|
||
/// 「把 id 注册进 logic-agent 管理器(**任务从它派发**)—— 不发它,任务可能被静默丢弃」。
|
||
///
|
||
/// ★ 为什么**不能**改用 crowd 通道(`SyncAetherAICrowdAdd` + `TargetLocationReason=1` 自驱游荡):
|
||
/// `ClientCrowdInitData4229938` 的字段只有
|
||
/// `NpcFormworkId / AgentPersonaId / UrbanDiversityConfigId / DesiredSpeed / ActionId /
|
||
/// TargetLocationReason / FashionSuitId` + 基类 `Id/Position/Facing/EulerAngles`
|
||
/// —— **没有 `AIAgentInfo`** ⇒ 带不了 `FightSpiritId` ⇒ `ClientData.cardId` 恒 0
|
||
/// ⇒ 第 45 轮刚修好的「烤肉邀请面板」会**立刻退回空列表**。所以通道必须留在 static。
|
||
///
|
||
/// ★★ 另一条被排除的路(配表级硬判据,别再回头):
|
||
/// 24 个可邀角色的 `AgentConfig.AiSettingId` 指向的行为集
|
||
/// (`AgentAIBehaviorSetsBattleAISBehaviorConfig`)**全部没有游荡行为**:
|
||
/// 40213395 Companion_Taffy / 40215027 Companion_zhuchang_Lin / 40213369 Companion_Battle /
|
||
/// 40215025 Bot_Common_SMG / 40213004 Behavior_EmptyBehavior(无战斗AI)…
|
||
/// 它们的 `Behavior_Wander` 与 `Behavior_StandBy` **都是空字符串**。
|
||
/// 对照:会走路的市民档是 40213001/40213002(`Main_Common_NPC`,
|
||
/// `Behavior_Wander = Behavior_Common_Wander`、`Behavior_StandBy = Behavior_Common_Patrol`)。
|
||
/// ⇒ 这些角色在官方场景里是「**跟着玩家的同伴**」,本来就不游荡;拿它们当驻场 NPC
|
||
/// 就必须由服务端/客户端额外下发行为意图。这正是本文件的职责。
|
||
///
|
||
/// ⚠ 已知边界(诚实标注)
|
||
/// 1. `SyncMoveWandering` 对 **crowd 通道(ECS/AgentEntity 管线)** 已被证伪
|
||
/// (Ambient.cs:33-34:走 WanderingActionModule = BaseUnit 管线 ⇒ 静默失败)。
|
||
/// 本文件只用在 **static 通道** 上(Ambient.cs:792-794 明确说 static 必须发)。
|
||
/// 若实测仍不动 ⇒ 把 `PartyNpcRoamMode` 换成 `"waypoint"`(路点巡游,同一份契约里
|
||
/// 被称作「唯一能产生走路步态的通道」),无需改代码。
|
||
/// 2. 游荡半径取小值(默认 2~10 m)——营地里有家具与碰撞体,绕远会走丢或穿模。
|
||
/// 3. 走完 `MaxTime` 就永久站住 ⇒ 必须**周期续期**,见 `PartyNpcRoamRearmSeconds`。
|
||
///
|
||
/// ★★★ 第 47 轮定案(用户现场):「**停走**」才是正解。
|
||
/// 用户实测 waypoint 档 = 「**可以走了,不过是乱走**」⇒ 走路通道本身已跑通
|
||
/// (`SyncLogicAgentCommand`(68050574) + `TaskWayPointMove`(tag 39) 有效,
|
||
/// `SyncMoveWandering` 无效),但马上要求「把角色**围成一圈**、**坐在地面**」。
|
||
/// ⇒ 默认 `PartyNpcRoamMode = "none"`;`SyncLogicAgentEnter` / `SyncAgentForceGo`
|
||
/// 已**上移到模式门之前**(`none` 档也要发 —— 行为树没起来,连 POI 坐姿都保不住)。
|
||
/// ⛔ 位置与朝向**本来就是对的**(`SpawnPartyNpcsAsync` 圆周排布 + `angle + π` 朝圆心),
|
||
/// 是 walk 任务把人走散、并用行走姿态顶掉了坐姿 ⇒ 不需要改坐标,也不需要另写编排。
|
||
/// </summary>
|
||
internal sealed partial class GameRouter
|
||
{
|
||
/// <summary>本 session 的驻留行为账本(随派对的 NPC 名单一起被重置)。</summary>
|
||
private const string PartyNpcRoamKey = "party.npc.roam";
|
||
|
||
/// <summary>巡游命令序号(只需单调递增;客户端把它回执回来)。</summary>
|
||
private static int s_partyRoamSeq;
|
||
|
||
internal sealed class PartyNpcRoamEntry
|
||
{
|
||
internal ulong EntityId;
|
||
internal float HomeX;
|
||
internal float HomeY;
|
||
internal float HomeZ;
|
||
|
||
/// <summary>上一次「下发/续期」的时刻(<see cref="Environment.TickCount64"/>)。</summary>
|
||
internal long ArmedTicks;
|
||
|
||
/// <summary>waypoint 模式:这一腿预计走完的时刻。</summary>
|
||
internal long EtaTicks;
|
||
|
||
/// <summary>waypoint 模式:下一腿的起点(= 上一腿的终点,服务端自己记账,不依赖客户端上报)。</summary>
|
||
internal float CursorX;
|
||
internal float CursorY;
|
||
internal float CursorZ;
|
||
}
|
||
|
||
internal sealed class PartyNpcRoamState
|
||
{
|
||
internal readonly List<PartyNpcRoamEntry> Entries = [];
|
||
internal readonly object SyncRoot = new();
|
||
internal CancellationTokenSource? Cts;
|
||
internal Task? Loop;
|
||
|
||
/// <summary>★ 第 49 轮:停走档那条日志<b>每批只打一次</b>(原先每人一条 ⇒ 10 条一样的话
|
||
/// 把 <c>[PARTYNPC] 生成汇总</c> 淹掉,而汇总才是真正要看的证据)。</summary>
|
||
internal bool StopWalkLogged;
|
||
}
|
||
|
||
private static PartyNpcRoamState RoamStateOf(TcpSession session)
|
||
{
|
||
if (!session.Items.TryGetValue(PartyNpcRoamKey, out var raw) || raw is not PartyNpcRoamState state)
|
||
{
|
||
state = new PartyNpcRoamState();
|
||
session.Items[PartyNpcRoamKey] = state;
|
||
}
|
||
|
||
return state;
|
||
}
|
||
|
||
/// <summary>重开派对(撤掉上一批人)时调用:停掉旧循环、清空账本。</summary>
|
||
internal static void ResetPartyNpcRoam(TcpSession session)
|
||
{
|
||
if (!session.Items.TryGetValue(PartyNpcRoamKey, out var raw) || raw is not PartyNpcRoamState state)
|
||
return;
|
||
|
||
state.Cts?.Cancel();
|
||
state.Cts?.Dispose();
|
||
state.Cts = null;
|
||
state.Loop = null;
|
||
lock (state.SyncRoot)
|
||
state.Entries.Clear();
|
||
state.StopWalkLogged = false; // ★ 第 49 轮:下一批重新打一次停走档日志
|
||
}
|
||
|
||
private static bool PartyRoamActive()
|
||
{
|
||
var mode = PrivateServerConfigStore.Current.Gameplay.PartyNpcRoamMode;
|
||
return !string.IsNullOrWhiteSpace(mode) && !mode.Equals("none", StringComparison.OrdinalIgnoreCase);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 把一个人登记进驻留行为,并起后台续期循环。
|
||
/// ⛑ 在 <c>SendCivilianAsync</c> **之后**调用:单位必须先存在,任务才有人接。
|
||
/// </summary>
|
||
internal static async Task ArmPartyNpcRoamAsync(
|
||
TcpSession session, ulong entityId, float x, float y, float z, CancellationToken token)
|
||
{
|
||
var cfg = PrivateServerConfigStore.Current.Gameplay;
|
||
var state = RoamStateOf(session);
|
||
|
||
// ① `SyncLogicAgentEnter` —— 把 id 注册进客户端的 logic-agent 管理器。
|
||
// Ambient.cs 原文:「任务/同步都从它派发 —— 不发它,任务可能被静默丢弃」。
|
||
if (cfg.PartyNpcLogicEnter)
|
||
{
|
||
await session.NotifyAsync(MethodId.SyncLogicAgentEnter,
|
||
UxSerializer.Serialize(WorldCodec.LogicAgentEnter(entityId)), token);
|
||
}
|
||
|
||
// ② 独立 `SyncAgentForceGo` —— 启动行为树。
|
||
// static 路人配方里它是单独一个包(add 数据里的 ForceGo 字段不足以替代,
|
||
// 见 Ambient.cs:825-833:SendCivilianAsync 之后还要再发一次)。
|
||
if (cfg.PartyNpcForceGo)
|
||
{
|
||
await session.NotifyAsync(MethodId.IGameSceneToClient_SyncAgentForceGo,
|
||
UxSerializer.Serialize(new SceneMethods.SyncAgentForceGo4229938
|
||
{
|
||
id = entityId,
|
||
isForceGo = true,
|
||
}), token);
|
||
}
|
||
|
||
// ★★★ 第 47 轮:模式门从函数开头**搬到这里**(① Enter / ② ForceGo 之后)。
|
||
// 见 PrivateServerConfig.PartyNpcRoamMode 的 `"none"` 说明 —— 用户要的
|
||
// 「围成一圈、屁股坐在地面」就是停走档,而停走档**必须仍然**发 Enter/ForceGo
|
||
// (行为树没起来 ⇒ 连 POI 坐姿都保不住)⇒ 这两步不能跟着被一起跳过。
|
||
if (!PartyRoamActive())
|
||
{
|
||
// ★★★ 第 49 轮纠正:这条文案原写死「圆周站位 / 朝向圆心」= **露营专属措辞**,
|
||
// 而停走档对舞会同样成立(舞会 = 舞池点横排 + 全员统一朝向,见 PartyNpc.cs 的
|
||
// `unifiedFacingDeg` 分档)⇒ 在舞会里打这句话等于**把露营摆法说成舞会的**。
|
||
// 用户指令:「舞会有舞会的摆法,不要参考露营」⇒ 改成摆放中立的措辞,
|
||
// 站位/朝向一律以本批 `[PARTYNPC] 生成汇总` 那条为准。
|
||
if (!state.StopWalkLogged)
|
||
{
|
||
state.StopWalkLogged = true;
|
||
session.Log.Info(
|
||
$"[PARTYNPC] 驻留行为=停走(mode={cfg.PartyNpcRoamMode}):只发 Enter/ForceGo," +
|
||
"不发任何 locomotion ⇒ 角色留在各自站位上保持坐姿(站位与朝向见本批 " +
|
||
"[PARTYNPC] 生成汇总:露营=圆周+朝圆心,舞会=舞池横排+全员统一朝向)。" +
|
||
"本条每批只打一次。");
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
var entry = new PartyNpcRoamEntry
|
||
{
|
||
EntityId = entityId,
|
||
HomeX = x,
|
||
HomeY = y,
|
||
HomeZ = z,
|
||
CursorX = x,
|
||
CursorY = y,
|
||
CursorZ = z,
|
||
};
|
||
|
||
lock (state.SyncRoot)
|
||
{
|
||
state.Entries.RemoveAll(e => e.EntityId == entityId);
|
||
state.Entries.Add(entry);
|
||
}
|
||
|
||
// ③ 行为意图本体。
|
||
await PushPartyRoamIntentAsync(session, entry, cfg, token);
|
||
|
||
EnsurePartyNpcRoamLoop(session, state);
|
||
}
|
||
|
||
/// <summary>按当前模式下发一次行为意图(并回写 <see cref="PartyNpcRoamEntry.ArmedTicks"/>)。</summary>
|
||
private static async Task PushPartyRoamIntentAsync(
|
||
TcpSession session, PartyNpcRoamEntry entry, GameplaySettings cfg, CancellationToken token)
|
||
{
|
||
if (cfg.PartyNpcRoamMode.Equals("waypoint", StringComparison.OrdinalIgnoreCase))
|
||
await PushPartyWaypointAsync(session, entry, cfg, token);
|
||
else
|
||
await PushPartyWanderAsync(session, entry, cfg, token);
|
||
}
|
||
|
||
/// <summary>
|
||
/// `SyncMoveWandering`(68745518) —— 客户端自驱游荡意图。
|
||
/// 逐字段照抄 `GameRouter.Ambient.PushAmbientWanderAsync`(同一条已验证的 static 配方),
|
||
/// 只有半径/时长换成派对用的保守值。
|
||
/// ⛑ `MoveId` 不是序号,是 `BattleMoveActionType`;填 0(Unknown) 等于「没有 locomotion 状态」。
|
||
/// </summary>
|
||
private static async Task PushPartyWanderAsync(
|
||
TcpSession session, PartyNpcRoamEntry entry, GameplaySettings cfg, CancellationToken token)
|
||
{
|
||
var minDis = MathF.Max(0.5f, cfg.PartyNpcRoamMinDis);
|
||
var maxDis = MathF.Max(minDis + 0.5f, cfg.PartyNpcRoamMaxDis);
|
||
|
||
await session.NotifyAsync(MethodId.SyncMoveWandering,
|
||
UxSerializer.Serialize(new SceneMethods.SyncMoveWandering4229938
|
||
{
|
||
Data = new SceneMethods.MoveWanderingData4229938
|
||
{
|
||
PathTags = cfg.PartyNpcRoamPathTags,
|
||
CloseObstacleAvoidance = true,
|
||
MinDis = minDis,
|
||
MaxDis = maxDis,
|
||
InRangeAngle = -180f,
|
||
OutRangeAngle = 180f,
|
||
MaxTime = cfg.PartyNpcRoamSeconds + 5f,
|
||
MaxOnceWanderTime = cfg.PartyNpcRoamSeconds,
|
||
UnitId = entry.EntityId,
|
||
MoveType = 0,
|
||
MoveId = WorldCodec.MoveAction.WalkFront,
|
||
CloseIK = false,
|
||
InstanceId = 0,
|
||
BlockCloseCapsuleCollision = false,
|
||
},
|
||
}), token);
|
||
|
||
entry.ArmedTicks = Environment.TickCount64;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 对照/退路档:用 `SyncLogicAgentCommand`+`TaskWayPointMove`(tag 39) 让 NPC 沿一条
|
||
/// **绕着营地的小多边形**巡游。`Ambient.cs` 文件头称这条通道是
|
||
/// 「**唯一能产生走路步态的通道**」。
|
||
/// 服务端自己记 `Cursor*`(= 上一腿终点)⇒ 不需要客户端上报位置。
|
||
/// </summary>
|
||
private static async Task PushPartyWaypointAsync(
|
||
TcpSession session, PartyNpcRoamEntry entry, GameplaySettings cfg, CancellationToken token)
|
||
{
|
||
var radius = MathF.Max(0.5f, cfg.PartyNpcRoamMaxDis);
|
||
var angle = Random.Shared.NextSingle() * 2f * MathF.PI;
|
||
var tx = entry.HomeX + MathF.Sin(angle) * radius;
|
||
var tz = entry.HomeZ + MathF.Cos(angle) * radius;
|
||
|
||
// 落点保持与出发地同高(营地是平地;不要凭空造 Y 偏移 —— 铁律 17)。
|
||
var points = new List<SceneMethods.UxVector3>(2)
|
||
{
|
||
new(entry.CursorX, entry.CursorY, entry.CursorZ),
|
||
new(tx, entry.HomeY, tz),
|
||
};
|
||
|
||
var speed = MathF.Max(0.2f, cfg.PartyNpcRoamSpeed);
|
||
var dist = MathF.Sqrt((tx - entry.CursorX) * (tx - entry.CursorX) + (tz - entry.CursorZ) * (tz - entry.CursorZ));
|
||
|
||
await session.NotifyAsync(MethodId.IGameSceneToClient_SyncLogicAgentCommand,
|
||
UxSerializer.Serialize(WorldCodec.LogicAgentCommandWayPoint(
|
||
entry.EntityId, Interlocked.Increment(ref s_partyRoamSeq),
|
||
SceneMethods.AgentMovementMethods.Walk, speed,
|
||
moveActionId: 0u, moveActionGroup: 0u,
|
||
keepMovingAction: true, points, noRootMotion: true)), token);
|
||
|
||
entry.CursorX = tx;
|
||
entry.CursorY = entry.HomeY;
|
||
entry.CursorZ = tz;
|
||
entry.ArmedTicks = Environment.TickCount64;
|
||
entry.EtaTicks = entry.ArmedTicks + (long)(dist / speed * 1000f) + Math.Max(0, cfg.PartyNpcRoamLegSlackMs);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 续期循环。`MaxTime` 走完客户端会**永久站住**,所以必须周期性补发。
|
||
/// 与 `EnsureLeftBehindLoop` 同款:session 断开后写 socket 会抛异常,循环自己退出,
|
||
/// 不需要额外的停止钩子。
|
||
/// </summary>
|
||
private static void EnsurePartyNpcRoamLoop(TcpSession session, PartyNpcRoamState state)
|
||
{
|
||
lock (state.SyncRoot)
|
||
{
|
||
if (state.Loop is { IsCompleted: false })
|
||
return;
|
||
state.Cts?.Dispose();
|
||
var cts = new CancellationTokenSource();
|
||
state.Cts = cts;
|
||
state.Loop = Task.Run(() => RunPartyNpcRoamLoopAsync(session, state, cts.Token));
|
||
}
|
||
}
|
||
|
||
private static async Task RunPartyNpcRoamLoopAsync(
|
||
TcpSession session, PartyNpcRoamState state, CancellationToken token)
|
||
{
|
||
session.Log.Info($"[PARTYNPC] 驻留行为循环启动 mode={PrivateServerConfigStore.Current.Gameplay.PartyNpcRoamMode}");
|
||
var waypointMode = PrivateServerConfigStore.Current.Gameplay.PartyNpcRoamMode
|
||
.Equals("waypoint", StringComparison.OrdinalIgnoreCase);
|
||
|
||
while (!token.IsCancellationRequested)
|
||
{
|
||
try
|
||
{
|
||
var cfg = PrivateServerConfigStore.Current.Gameplay;
|
||
var tick = Math.Clamp(cfg.PartyNpcRoamTickMs, 200, 30_000);
|
||
await Task.Delay(tick, token);
|
||
|
||
// 客户端挂起期间只睡不推 —— 见 GameRouter.IsBackgroundPushPaused 的说明。
|
||
if (IsBackgroundPushPaused(session))
|
||
continue;
|
||
|
||
if (!PartyRoamActive())
|
||
continue;
|
||
|
||
List<PartyNpcRoamEntry> snapshot;
|
||
lock (state.SyncRoot)
|
||
snapshot = [.. state.Entries];
|
||
if (snapshot.Count == 0)
|
||
continue;
|
||
|
||
var now = Environment.TickCount64;
|
||
var rearmMs = (long)(MathF.Max(3f, cfg.PartyNpcRoamRearmSeconds) * 1000f);
|
||
|
||
foreach (var entry in snapshot)
|
||
{
|
||
// wander 档:到点就整条续期;waypoint 档:上一腿预计走完才派下一腿。
|
||
var due = waypointMode
|
||
? now >= entry.EtaTicks
|
||
: now - entry.ArmedTicks >= rearmMs;
|
||
if (!due)
|
||
continue;
|
||
|
||
await PushPartyRoamIntentAsync(session, entry, cfg, token);
|
||
}
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
break;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// session 断了就会走到这里(往死 socket 写)。记一条然后退出,不要刷屏。
|
||
session.Log.Info($"[PARTYNPC] 驻留行为循环退出:{ex.GetType().Name}: {ex.Message}");
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|