739 lines
36 KiB
C#
739 lines
36 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 Ananta.Server.RpcTypes.Client4229938;
|
|||
|
|
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
|||
|
|
|
|||
|
|
namespace Ananta.Server.Handlers.Game;
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 场景物品(路灯 / 街边道具 / <b>可破坏物</b> / 念动力目标)的服务端流式下发 —— AnantaWorldMod
|
|||
|
|
/// 的等价实现,不需要 frida 注入器。
|
|||
|
|
///
|
|||
|
|
/// 原理与 <c>GameRouter.GadgetGrid.cs</c> 完全同构:客户端自己有烘焙好的 SceneItemBlob,服务端
|
|||
|
|
/// 只要告诉它「哪几个 40m 网格在视野内」,客户端就从 blob 里把物品铺出来。
|
|||
|
|
/// 注入器每 700ms 调 <c>SceneItemMgr.SyncSceneItemGridAOIAddByIndex(cx, cz, exceptIds, sectorIdList,
|
|||
|
|
/// playerStandardIndex)</c>;服务端等价物 = 推 <b>SyncDestructibleGridAOIDecrease(68969593)</b>,
|
|||
|
|
/// 接收端 <c>GameSceneToClientImpl</c> 三连展开正是 remove → increase → AddDecrease(→AddByIndex)。
|
|||
|
|
///
|
|||
|
|
/// ⛑️⛑️ 必须用 68969593,不是 68934309:68934309 的 args 只装得下 <c>aoiInfo</c> 一个字段
|
|||
|
|
/// (dump.cs TypeDefIndex 35754),它的接收端是记账用的;网格下发只能走 Decrease 孪生
|
|||
|
|
/// (与 gadget 的 68956797 同律)。用错 mid 的表现是「推了、日志干净、一个都不出」。
|
|||
|
|
///
|
|||
|
|
/// 网格→sector 表直接复用 <c>GadgetCellsCatalog4229938</c>(Configs/GadgetCells.json):
|
|||
|
|
/// 那份文件本身就是 AnantaWorldMod 运行时导出的 cell→sector(5116 格,meta 写明
|
|||
|
|
/// "runtime export from 4229938 client"),两个家族用的是同一张烘焙表。
|
|||
|
|
/// </summary>
|
|||
|
|
internal sealed partial class GameRouter
|
|||
|
|
{
|
|||
|
|
private const string SceneItemGridKey = "client4229938-scene-item-grid";
|
|||
|
|
|
|||
|
|
/// <summary>40 m 世界网格边长 —— 与客户端 SceneItemMgr 的 CELL_SIZE 一致。</summary>
|
|||
|
|
private const int SceneItemCellSize = 40;
|
|||
|
|
|
|||
|
|
private const byte SceneItemReasonLoaded = 1;
|
|||
|
|
private const byte SceneItemReasonAoiMove = 2;
|
|||
|
|
|
|||
|
|
internal sealed class SceneItemGridSessionState
|
|||
|
|
{
|
|||
|
|
internal object SyncRoot { get; } = new();
|
|||
|
|
internal CancellationTokenSource? Cts;
|
|||
|
|
internal Task? Loop;
|
|||
|
|
|
|||
|
|
/// <summary>已经下发过的网格;只在刷新 / 心跳 / 跨格重声明时重发。</summary>
|
|||
|
|
internal HashSet<(int, int)> Sent { get; } = new();
|
|||
|
|
|
|||
|
|
internal int LastCellX;
|
|||
|
|
internal int LastCellZ;
|
|||
|
|
internal bool HasCell;
|
|||
|
|
internal bool LoadedSent;
|
|||
|
|
/// <summary>可破坏物总开关 SyncDestructibleSceneAOIActive(68418115) 本会话是否已推。</summary>
|
|||
|
|
internal bool ActiveSwitchSent;
|
|||
|
|
|
|||
|
|
/// <summary>念动力测试盒本会话是否已摆过(只摆一次,见 SpawnTestBox)。</summary>
|
|||
|
|
internal bool TestBoxSpawned;
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// ★★★ R93(自 donor v2.1「打气球」移植)—— 本会话「脚本自建、但不在客户端烘焙
|
|||
|
|
/// SceneItemBlob 里」的可破坏物保护表(气球的 12/16 颗球就属于这一类)。
|
|||
|
|
///
|
|||
|
|
/// 病因(donor 真机实测):心跳 / 刷新每 5 s 重发一次 SyncDestructibleGridAOIDecrease
|
|||
|
|
/// 声明 AOI 窗口;该报文 aoiInfo.addInfos 为空 + addGridInfo.ExceptIds 为空 ⇒
|
|||
|
|
/// 客户端语义是「这些格子改为从你自己的 SceneItemBlob 重新解析」⇒ 服务端合成的球
|
|||
|
|
/// (blob 里没有)每次心跳都被整片抹掉。
|
|||
|
|
///
|
|||
|
|
/// 修法:把这类 id 记在这里,心跳时 ① 塞进 addGridInfo.ExceptIds(明确告诉客户端
|
|||
|
|
/// 「别删这些」),② 并把其 DestructibleInfo 重新塞回 aoiInfo.addInfos(保证还在)。
|
|||
|
|
/// ⛑ Info 是 object:登记方可能持有 BowlingWire 那份 DestructibleInfo(真机验证过
|
|||
|
|
/// 能建出 hold 的标注),由 <see cref="ToGridDestructibleInfo"/> 归一。
|
|||
|
|
///
|
|||
|
|
/// 🆕 2026-09-30:本通道改**严格增量**后,这张表实际上**退化为空转** —— 因为「重推整窗
|
|||
|
|
/// ⇒ addInfos 空 ⇒ 客户端按 blob 重建 ⇒ 抹掉服务端合成物」这条链路的**前半段不再发生**
|
|||
|
|
/// (已推过的格子根本不会再发)。气球本体本来就是 GameRouter.Balloon.cs 用
|
|||
|
|
/// SyncDestructibleObjectAddList(68963429) + 自带的 68969593 报文建的,不依赖这里。
|
|||
|
|
/// 表**保留不删**:一旦把 heartbeatMs / refreshMs / resendWindowOnMove 回滚成整窗重推,
|
|||
|
|
/// 它立刻重新生效(否则气球会被抹掉,R93 的病会原样复发)。
|
|||
|
|
/// </summary>
|
|||
|
|
internal Dictionary<ulong, (float X, float Z, object Info)> Protected { get; } = new();
|
|||
|
|
|
|||
|
|
internal long LastPushMs;
|
|||
|
|
internal long ReadyAtMs;
|
|||
|
|
internal int Pushes;
|
|||
|
|
internal int CellsSent;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static SceneItemGridSessionState SceneItemGridStateOf(TcpSession session)
|
|||
|
|
{
|
|||
|
|
if (session.Items.TryGetValue(SceneItemGridKey, out var existing)
|
|||
|
|
&& existing is SceneItemGridSessionState typed)
|
|||
|
|
return typed;
|
|||
|
|
|
|||
|
|
var created = new SceneItemGridSessionState();
|
|||
|
|
session.Items[SceneItemGridKey] = created;
|
|||
|
|
return created;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// ★★★ R93(自 donor v2.1「打气球」移植)—— 把「脚本自建的可破坏物」登记为受保护,
|
|||
|
|
/// 直到显式撤下。心跳 / 刷新重声明 AOI 窗口时,这些 id 会进 <c>ExceptIds</c> 并随
|
|||
|
|
/// <c>addInfos</c> 一起重发,从而不被客户端按烘焙 blob 覆盖掉。
|
|||
|
|
/// </summary>
|
|||
|
|
internal static void ProtectDestructibles(TcpSession session, IEnumerable<(ulong Id, float X, float Z, object Info)> items)
|
|||
|
|
{
|
|||
|
|
var owner = SceneItemGridStateOf(session);
|
|||
|
|
lock (owner.SyncRoot)
|
|||
|
|
foreach (var it in items)
|
|||
|
|
owner.Protected[it.Id] = (it.X, it.Z, it.Info);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>★ R93 — 撤下保护(离开气球区 / 小游戏结束时用)。</summary>
|
|||
|
|
internal static void UnprotectDestructibles(TcpSession session, IEnumerable<ulong> ids)
|
|||
|
|
{
|
|||
|
|
var owner = SceneItemGridStateOf(session);
|
|||
|
|
lock (owner.SyncRoot)
|
|||
|
|
foreach (var id in ids)
|
|||
|
|
owner.Protected.Remove(id);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>世界 ready 钩子:启动场景物品流。幂等;配置关闭时 no-op。</summary>
|
|||
|
|
internal static void EnsureSceneItemGrid(TcpSession session)
|
|||
|
|
{
|
|||
|
|
var cfg = PrivateServerConfigStore.Current.World.Content.SceneItems;
|
|||
|
|
if (!cfg.Enabled)
|
|||
|
|
return;
|
|||
|
|
|
|||
|
|
var owner = SceneItemGridStateOf(session);
|
|||
|
|
lock (owner.SyncRoot)
|
|||
|
|
{
|
|||
|
|
owner.ReadyAtMs = Environment.TickCount64;
|
|||
|
|
owner.Sent.Clear();
|
|||
|
|
owner.HasCell = false;
|
|||
|
|
owner.LoadedSent = false;
|
|||
|
|
owner.ActiveSwitchSent = false;
|
|||
|
|
owner.TestBoxSpawned = false;
|
|||
|
|
owner.LastPushMs = 0;
|
|||
|
|
if (owner.Loop is { IsCompleted: false })
|
|||
|
|
return;
|
|||
|
|
owner.Cts?.Dispose();
|
|||
|
|
owner.Cts = new CancellationTokenSource();
|
|||
|
|
owner.Loop = Task.Run(() => RunSceneItemGridLoopAsync(session, owner, owner.Cts.Token));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>断线 / 切场景:停循环。已经铺出来的物品留在原地。</summary>
|
|||
|
|
internal static void StopSceneItemGrid(TcpSession session)
|
|||
|
|
{
|
|||
|
|
if (!session.Items.TryGetValue(SceneItemGridKey, out var existing)
|
|||
|
|
|| existing is not SceneItemGridSessionState owner)
|
|||
|
|
return;
|
|||
|
|
lock (owner.SyncRoot)
|
|||
|
|
{
|
|||
|
|
owner.Cts?.Cancel();
|
|||
|
|
owner.Cts?.Dispose();
|
|||
|
|
owner.Cts = null;
|
|||
|
|
owner.Sent.Clear();
|
|||
|
|
owner.HasCell = false;
|
|||
|
|
owner.LoadedSent = false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>已下发网格数(面板 / 调试用)。</summary>
|
|||
|
|
internal static int SceneItemGridSentCount(TcpSession session)
|
|||
|
|
{
|
|||
|
|
var owner = SceneItemGridStateOf(session);
|
|||
|
|
lock (owner.SyncRoot)
|
|||
|
|
return owner.Sent.Count;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static async Task RunSceneItemGridLoopAsync(
|
|||
|
|
TcpSession session, SceneItemGridSessionState owner, CancellationToken token)
|
|||
|
|
{
|
|||
|
|
var cfg = PrivateServerConfigStore.Current.World.Content.SceneItems;
|
|||
|
|
var tick = Math.Max(250, cfg.TickMs);
|
|||
|
|
// 🆕 2026-09-30:把「本会话跑的是哪种推送模式」打进日志 —— 排 5 秒闪烁时第一眼就要看这个。
|
|||
|
|
// STRICT-INCREMENTAL = 每格只 Add 一次(等价 AnantaWorldMod),站桩零推送;
|
|||
|
|
// WINDOW-RESEND = 心跳 / 刷新 / 跨格整窗重推(旧行为,会让物件每 5 s 闪一次)。
|
|||
|
|
var mode = (cfg.HeartbeatMs > 0 || cfg.RefreshMs > 0 || cfg.ResendWindowOnMove)
|
|||
|
|
? "WINDOW-RESEND"
|
|||
|
|
: "STRICT-INCREMENTAL";
|
|||
|
|
session.Log.Info($"[SCENE-ITEM-GRID] loop started mode={mode} radius={cfg.RadiusCells} " +
|
|||
|
|
$"radiusInterior={cfg.RadiusInteriorCells} firstDelayMs={cfg.FirstDelayMs} " +
|
|||
|
|
$"heartbeatMs={cfg.HeartbeatMs} refreshMs={cfg.RefreshMs} " +
|
|||
|
|
$"resendWindowOnMove={cfg.ResendWindowOnMove} " +
|
|||
|
|
$"maxCellsPerPush={cfg.MaxCellsPerPush} " +
|
|||
|
|
$"armActive={cfg.ArmActiveSwitch} cells={GadgetCellsCatalog4229938.CellCount}");
|
|||
|
|
|
|||
|
|
while (!token.IsCancellationRequested)
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
// 可破坏物家族的总开关:SyncDestructibleSceneAOIActive(68418115) = 一个裸 bool 字节。
|
|||
|
|
// ⛑️ 它属于 DESTRUCTIBLE 家族,不是 gadget 网格的开关:硬规则 31 记的「68418115 会
|
|||
|
|
// 破坏室内」是当年把它当 gadget 场景 AOI 开关来推踩出来的坑。这里默认关
|
|||
|
|
// (armActiveSwitch=false);若首轮实测一个物品都不出,这是第一顺位要开的开关。
|
|||
|
|
if (cfg.ArmActiveSwitch && !owner.ActiveSwitchSent)
|
|||
|
|
{
|
|||
|
|
owner.ActiveSwitchSent = true;
|
|||
|
|
await session.NotifyAsync(MethodId.SyncDestructibleSceneAOIActive,
|
|||
|
|
new UxWriter().Bool(true).ToArray(), token);
|
|||
|
|
session.Log.Info("[SCENE-ITEM-GRID] active switch (68418115) = true");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ⛑️ 客户端挂起期间只睡不推(硬规则 37),否则 TCP 灌满 ⇒ 回前台判定断线。
|
|||
|
|
if (!IsBackgroundPushPaused(session))
|
|||
|
|
{
|
|||
|
|
await TickSceneItemGridAsync(session, owner);
|
|||
|
|
}
|
|||
|
|
else
|
|||
|
|
{
|
|||
|
|
// 挂起期间把心跳计时器往前推,否则恢复瞬间 now - LastPushMs 早就越过
|
|||
|
|
// heartbeat/refresh ⇒ 立刻 fullResend,把一整个 burst 砸向刚恢复的客户端。
|
|||
|
|
// ⛑ 2026-09-28 订正:这里原来写「retainVisitedCells=true 时会是玩家走过的全部格子」
|
|||
|
|
// —— <b>错的</b>。PushSceneItemGridAsync 只遍历 cx/cz ± radius 那个窗口,
|
|||
|
|
// 跟 owner.Sent(走过的格子)无关;Sent 只在 fullResend=false 时当去重表用。
|
|||
|
|
// 实测(客户端会话 13:57–14:11)整局每轮恒为 130 格(sceneItems 49 + gadgets 81),
|
|||
|
|
// 玩家走了 29 个 center 格也没让窗口变大过。
|
|||
|
|
lock (owner.SyncRoot)
|
|||
|
|
owner.LastPushMs = Environment.TickCount64;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
catch (OperationCanceledException)
|
|||
|
|
{
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
catch (ObjectDisposedException)
|
|||
|
|
{
|
|||
|
|
session.Log.Info("[SCENE-ITEM-GRID] loop stopped: session disposed");
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
session.Log.Info($"[SCENE-ITEM-GRID] tick failed: {ex.Message}");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
await Task.Delay(tick, token);
|
|||
|
|
}
|
|||
|
|
catch (OperationCanceledException)
|
|||
|
|
{
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static async Task TickSceneItemGridAsync(TcpSession session, SceneItemGridSessionState owner)
|
|||
|
|
{
|
|||
|
|
var cfg = PrivateServerConfigStore.Current.World.Content.SceneItems;
|
|||
|
|
var state = GetStateStatic(session);
|
|||
|
|
|
|||
|
|
float px;
|
|||
|
|
float py;
|
|||
|
|
float pz;
|
|||
|
|
lock (state.SyncRoot)
|
|||
|
|
{
|
|||
|
|
if (!state.Ready || !state.HasLastReportedPlayerTransform)
|
|||
|
|
return;
|
|||
|
|
px = state.LastReportedPlayerPosition.X;
|
|||
|
|
py = state.LastReportedPlayerPosition.Y;
|
|||
|
|
pz = state.LastReportedPlayerPosition.Z;
|
|||
|
|
}
|
|||
|
|
if (!float.IsFinite(px) || !float.IsFinite(pz))
|
|||
|
|
return;
|
|||
|
|
|
|||
|
|
var cx = (int)MathF.Floor(px / SceneItemCellSize);
|
|||
|
|
var cz = (int)MathF.Floor(pz / SceneItemCellSize);
|
|||
|
|
var now = Environment.TickCount64;
|
|||
|
|
|
|||
|
|
// ★ donor《content-streaming-implementation》§1 —— 传送 / 远跳后新旧窗口不相交
|
|||
|
|
// ⇒ 清空声明过的格子按「首次进窗口」重铺(旧窗口留着既占客户端内存,diff 也删不掉)。
|
|||
|
|
// 必须在下面的 firstPush 判定**之前**执行,reason 才会回到 Loaded。
|
|||
|
|
// TeleportPlayerInstantAsync / TeleportPlayerWithLoadingAsync 都会乐观更新
|
|||
|
|
// LastReportedPlayerTransform,所以这里自动覆盖 GM 传送与 /api/teleport。
|
|||
|
|
if (cfg.ReseedOnFarJump)
|
|||
|
|
{
|
|||
|
|
bool reseeded = false;
|
|||
|
|
lock (owner.SyncRoot)
|
|||
|
|
{
|
|||
|
|
if (owner.HasCell && IsFarGridJump(owner.LastCellX, owner.LastCellZ, cx, cz, cfg.UnloadRadiusCells))
|
|||
|
|
{
|
|||
|
|
owner.Sent.Clear();
|
|||
|
|
owner.HasCell = false;
|
|||
|
|
owner.LoadedSent = false;
|
|||
|
|
owner.LastPushMs = 0;
|
|||
|
|
reseeded = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (reseeded)
|
|||
|
|
session.Log.Info($"[SCENE-ITEM-GRID] far-jump reseed -> ({cx},{cz})");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
bool firstPush;
|
|||
|
|
bool cellMoved;
|
|||
|
|
bool refresh;
|
|||
|
|
bool heartbeat;
|
|||
|
|
lock (owner.SyncRoot)
|
|||
|
|
{
|
|||
|
|
firstPush = !owner.LoadedSent;
|
|||
|
|
cellMoved = owner.HasCell && (owner.LastCellX != cx || owner.LastCellZ != cz);
|
|||
|
|
// ⛑⛑ RefreshMs <= 0 = **关闭**(与 HeartbeatMs 同律)。
|
|||
|
|
// 2026-09-30 修:原来这一行没有 `> 0` 守卫 ⇒ 配成 0 时变成
|
|||
|
|
// `now - LastPushMs >= 0`(恒真)⇒ **每一轮都 fullResend**,正好是「整窗重推」
|
|||
|
|
// 最隐蔽的一个入口(比 heartbeat 还狠:tick 多少毫秒就重推多少次)。
|
|||
|
|
refresh = cfg.RefreshMs > 0 && owner.LastPushMs != 0 && now - owner.LastPushMs >= cfg.RefreshMs;
|
|||
|
|
// ⛔⛔ 2026-09-30 用户定性 + 实证推翻(原注释「这条心跳是必需的,不要砍掉」**作废**):
|
|||
|
|
// ① 用户实机症状:**可念力吸取 / 可破坏的东西每 5 秒闪一次、抖动一次、恢复原位**。
|
|||
|
|
// 机制 = 客户端对已加载的格子再次 AddByIndex = **重新实例化** ⇒ 位移 / 破坏
|
|||
|
|
// 状态 / 念力 hold 全部被重置。站桩不动时整窗每 5 s 重推一次 ⇒ 每 5 s 闪一次。
|
|||
|
|
// ② 参考实现 AnantaWorldMod(new-worldmod/world_mod.js:3525-3550)**从不重推**:
|
|||
|
|
// 每 700ms 遍历 LOAD_R 窗口,`if (loaded.has(key)) continue;` ⇒ **每格只 Add 一次**;
|
|||
|
|
// 只有超出 UNLOAD_R(6) 的格子才 RemoveByIndex + 从 loaded 摘除。
|
|||
|
|
// ③ 原先那条「heartbeatMs=60000 ⇒ 道具闪没一瞬间又回来」的实证,现在解释为
|
|||
|
|
// **反过来的因果**:重推周期被拉长,反而把「重推 = 重新实例化」这件事暴露成
|
|||
|
|
// 肉眼可见的一次闪;并不是「不推就消失」。
|
|||
|
|
// ⇒ 本通道改**严格增量**(等价 AnantaWorldMod):格子进 owner.Sent 后永不重推。
|
|||
|
|
// ⛑ 回滚:sceneItems.heartbeatMs=5000 / refreshMs=120000 / resendWindowOnMove=true。
|
|||
|
|
heartbeat = cfg.HeartbeatMs > 0 && !firstPush
|
|||
|
|
&& owner.LastPushMs != 0 && now - owner.LastPushMs >= cfg.HeartbeatMs;
|
|||
|
|
if (firstPush && owner.ReadyAtMs != 0 && now - owner.ReadyAtMs < cfg.FirstDelayMs)
|
|||
|
|
return;
|
|||
|
|
if (!firstPush && !cellMoved && !refresh && !heartbeat)
|
|||
|
|
return;
|
|||
|
|
owner.LastCellX = cx;
|
|||
|
|
owner.LastCellZ = cz;
|
|||
|
|
owner.HasCell = true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var reason = firstPush ? SceneItemReasonLoaded : SceneItemReasonAoiMove;
|
|||
|
|
|
|||
|
|
// ⚠ 旧注释「addGridInfo 是『当前 AOI 快照』而不是增量:只带新进入的格子会让先前铺出来的
|
|||
|
|
// 物品整片消失」—— 这条**假设已被 AnantaWorldMod 反证**(它只带新格子,物品不消失),
|
|||
|
|
// 真正的因果是反的:**重推已加载的格子 = 客户端重新实例化 = 每 5 秒闪一次、状态被重置**。
|
|||
|
|
// 2026-09-30 起本通道走**严格增量**(等价 AnantaWorldMod):
|
|||
|
|
// heartbeatMs=0 / refreshMs=0 / resendWindowOnMove=false ⇒ resendWindow 恒 false
|
|||
|
|
// ⇒ PushSceneItemGridAsync 里 `owner.Sent.Contains` 永久生效 ⇒ 每格只 Add 一次。
|
|||
|
|
// ⛑ 想回到「整窗重推」:把上面三个键改回 5000 / 120000 / true 即可,无需改代码。
|
|||
|
|
var resendWindow = refresh || heartbeat || (cellMoved && cfg.ResendWindowOnMove);
|
|||
|
|
var spawns = BuildTestBoxSpawns(session, owner, px, py, pz);
|
|||
|
|
var sent = await PushSceneItemGridAsync(session, owner, cx, cz, reason,
|
|||
|
|
fullResend: resendWindow, spawns: spawns);
|
|||
|
|
|
|||
|
|
lock (owner.SyncRoot)
|
|||
|
|
{
|
|||
|
|
owner.LoadedSent = true;
|
|||
|
|
owner.LastPushMs = Environment.TickCount64;
|
|||
|
|
owner.Pushes++;
|
|||
|
|
owner.CellsSent += sent;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 把 (cx,cz) 周围还没发过的网格按 sector 列表分组下发,一组一条消息。返回发出的格子数。
|
|||
|
|
/// </summary>
|
|||
|
|
private static async Task<int> PushSceneItemGridAsync(
|
|||
|
|
TcpSession session, SceneItemGridSessionState owner, int cx, int cz, byte reason, bool fullResend,
|
|||
|
|
List<SceneMethods.DestructibleInfo4229938>? spawns = null)
|
|||
|
|
{
|
|||
|
|
var cfg = PrivateServerConfigStore.Current.World.Content.SceneItems;
|
|||
|
|
var radius = Math.Max(0, cfg.RadiusCells);
|
|||
|
|
var radiusInterior = Math.Max(0, cfg.RadiusInteriorCells);
|
|||
|
|
var budget = Math.Max(1, cfg.MaxCellsPerPush);
|
|||
|
|
|
|||
|
|
// ① 本轮窗口(正方形;室内格按 radiusInterior 收窄)
|
|||
|
|
var window = new HashSet<(int, int)>();
|
|||
|
|
for (var dx = -radius; dx <= radius; dx++)
|
|||
|
|
{
|
|||
|
|
for (var dz = -radius; dz <= radius; dz++)
|
|||
|
|
{
|
|||
|
|
var gx = cx + dx;
|
|||
|
|
var gz = cz + dz;
|
|||
|
|
|
|||
|
|
// 室内格收窄:注入器在室内也只铺一两格深,宽窗纯属浪费预算。
|
|||
|
|
var interior = GadgetCellsCatalog4229938.IsInteriorCell(gx, gz);
|
|||
|
|
if (interior && (Math.Abs(dx) > radiusInterior || Math.Abs(dz) > radiusInterior))
|
|||
|
|
continue;
|
|||
|
|
|
|||
|
|
window.Add((gx, gz));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ② donor §3 差分:added / keep(保留环) / removed
|
|||
|
|
HashSet<(int, int)> toAdd;
|
|||
|
|
HashSet<(int, int)> toRemove;
|
|||
|
|
HashSet<(int, int)> nextDeclared;
|
|||
|
|
lock (owner.SyncRoot)
|
|||
|
|
{
|
|||
|
|
var prev = new HashSet<(int, int)>(owner.Sent);
|
|||
|
|
ComputeGridWindowDiff(prev, window, cx, cz,
|
|||
|
|
cfg.UnloadRadiusCells, cfg.RetainVisitedCells,
|
|||
|
|
out toAdd, out toRemove, out nextDeclared);
|
|||
|
|
|
|||
|
|
if (fullResend)
|
|||
|
|
toAdd = new HashSet<(int, int)>(window);
|
|||
|
|
|
|||
|
|
// ⛑ owner.Sent 是只读属性 ⇒ 只能原地改。
|
|||
|
|
owner.Sent.Clear();
|
|||
|
|
if (cfg.RealUnload && !cfg.RealUnloadLogOnly)
|
|||
|
|
owner.Sent.UnionWith(nextDeclared);
|
|||
|
|
else
|
|||
|
|
owner.Sent.UnionWith(prev.Union(window));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ③ 真·卸载(donor §4:removeGridInfo 只放格子,不放 per-object 数据)
|
|||
|
|
if (cfg.RealUnload && toRemove.Count > 0)
|
|||
|
|
{
|
|||
|
|
if (cfg.RealUnloadLogOnly)
|
|||
|
|
{
|
|||
|
|
session.Log.Info($"[SCENE-ITEM-GRID] unload LOG-ONLY center=({cx},{cz}) " +
|
|||
|
|
$"wouldRemove={toRemove.Count} declared={owner.Sent.Count}");
|
|||
|
|
}
|
|||
|
|
else
|
|||
|
|
{
|
|||
|
|
await PushSceneItemGridRemovalAsync(session, toRemove, cx, cz, reason);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// sector 列表 -> 格子。一条消息只能带一个 sector 列表。
|
|||
|
|
var groups = new Dictionary<string, (int[] Sectors, List<(int, int)> Cells)>();
|
|||
|
|
foreach (var cell in toAdd)
|
|||
|
|
{
|
|||
|
|
var sectors = cfg.LoadAllSectors
|
|||
|
|
? [0]
|
|||
|
|
: GadgetCellsCatalog4229938.SectorsOf(cell.Item1, cell.Item2);
|
|||
|
|
var key = string.Join(',', sectors);
|
|||
|
|
if (!groups.TryGetValue(key, out var group))
|
|||
|
|
{
|
|||
|
|
group = (sectors, new List<(int, int)>());
|
|||
|
|
groups[key] = group;
|
|||
|
|
}
|
|||
|
|
group.Cells.Add(cell);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (groups.Count == 0)
|
|||
|
|
return 0;
|
|||
|
|
|
|||
|
|
// 室外优先:走路时看得见的比没进过的房间重要,顺带让日志顺序稳定。
|
|||
|
|
var ordered = groups.Values
|
|||
|
|
.OrderBy(g => g.Sectors.Any(s => s != 0) ? 1 : 0)
|
|||
|
|
.ThenByDescending(g => g.Cells.Count)
|
|||
|
|
.ToList();
|
|||
|
|
|
|||
|
|
var total = 0;
|
|||
|
|
var groupIndex = 0;
|
|||
|
|
foreach (var group in ordered)
|
|||
|
|
{
|
|||
|
|
for (var offset = 0; offset < group.Cells.Count; offset += budget)
|
|||
|
|
{
|
|||
|
|
var slice = group.Cells.Skip(offset).Take(budget).ToList();
|
|||
|
|
if (slice.Count == 0)
|
|||
|
|
continue;
|
|||
|
|
|
|||
|
|
// ★★★ R93 —— 受保护物(服务端合成、不在客户端烘焙 blob 里的可破坏物,如气球)
|
|||
|
|
// 每窗都要重新塞回 aoiInfo.addInfos,否则「addInfos 空 = 从你自己的 blob 重建」
|
|||
|
|
// 这条快照语义会把它们从客户端撤掉(球生成后一秒内就消失)。
|
|||
|
|
// 只挑落在本 slice 格子里的那几件。
|
|||
|
|
var sliceSet = slice.ToHashSet();
|
|||
|
|
List<SceneMethods.DestructibleInfo4229938>? protectedInfos = null;
|
|||
|
|
List<ulong>? protectedIds = null;
|
|||
|
|
lock (owner.SyncRoot)
|
|||
|
|
{
|
|||
|
|
if (owner.Protected.Count > 0)
|
|||
|
|
{
|
|||
|
|
foreach (var kv in owner.Protected)
|
|||
|
|
{
|
|||
|
|
var pcell = ((int)MathF.Floor(kv.Value.X / SceneItemCellSize),
|
|||
|
|
(int)MathF.Floor(kv.Value.Z / SceneItemCellSize));
|
|||
|
|
if (!sliceSet.Contains(pcell))
|
|||
|
|
continue;
|
|||
|
|
(protectedIds ??= []).Add(kv.Key);
|
|||
|
|
if (ToGridDestructibleInfo(kv.Value.Info) is { } di)
|
|||
|
|
(protectedInfos ??= []).Add(di);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 显式摆放(念动力测试盒)只挂在本轮的第一条消息上:一次推送可能拆成好几条
|
|||
|
|
// (每 64 格一条),每条都带一遍就是同一批箱子摆好几遍。
|
|||
|
|
var addInfos = protectedInfos ?? [];
|
|||
|
|
var addUniqueIds = protectedIds ?? new List<ulong>();
|
|||
|
|
if (groupIndex == 0 && spawns is { Count: > 0 })
|
|||
|
|
{
|
|||
|
|
addInfos = [.. addInfos, .. spawns];
|
|||
|
|
addUniqueIds = [.. addUniqueIds, .. spawns.ConvertAll(s => s.InstanceId)];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var args = new SceneMethods.SyncDestructibleGridAOIDecreaseArgs4229938
|
|||
|
|
{
|
|||
|
|
aoiInfo = new SceneMethods.DestructibleGridAOIIncrease
|
|||
|
|
{
|
|||
|
|
PlayerStandardIndex = new SceneMethods.GridIndex { X = cx, Z = cz },
|
|||
|
|
addInfos = addInfos,
|
|||
|
|
addUniqueIds = addUniqueIds,
|
|||
|
|
reason = reason,
|
|||
|
|
},
|
|||
|
|
addGridInfo = new SceneMethods.GridAOIDecrease
|
|||
|
|
{
|
|||
|
|
SectorIdList = group.Sectors.ToList(),
|
|||
|
|
BuildingId = 0,
|
|||
|
|
StandardIndexList = slice
|
|||
|
|
.Select(c => new SceneMethods.GridIndex { X = c.Item1, Z = c.Item2 })
|
|||
|
|
.ToList(),
|
|||
|
|
// ★ R93:受保护物的 id 也进 ExceptIds —— 明确告诉客户端「别删这些」。
|
|||
|
|
ExceptIds = protectedIds ?? [],
|
|||
|
|
},
|
|||
|
|
// ★ 2026-10-01:不再恒空 —— 真·卸载改由 <c>PushSceneItemGridRemovalAsync</c>
|
|||
|
|
// 单独一条报文下发(先卸后加载)。硬规则 212("从不发真移除")就此作废,
|
|||
|
|
// unloadRadiusCells 也不再是空头支票。
|
|||
|
|
removeGridInfo = new SceneMethods.GridAOIDecrease(),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
var body = UxSerializer.Serialize(args);
|
|||
|
|
await session.NotifyAsync(MethodId.SyncDestructibleGridAOIDecrease, body, CancellationToken.None);
|
|||
|
|
|
|||
|
|
total += slice.Count;
|
|||
|
|
groupIndex++;
|
|||
|
|
|
|||
|
|
int sentTotal;
|
|||
|
|
lock (owner.SyncRoot)
|
|||
|
|
sentTotal = owner.Sent.Count;
|
|||
|
|
session.Log.Info($"[SCENE-ITEM-GRID] push group {groupIndex}/{ordered.Count} " +
|
|||
|
|
$"center=({cx},{cz}) reason={reason} fullResend={fullResend} " +
|
|||
|
|
$"sectors=[{string.Join(",", group.Sectors)}] " +
|
|||
|
|
$"cells={slice.Count} bytes={body.Length} sentTotal={sentTotal}");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return total;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// ★ donor《content-streaming-implementation》§4 —— 显式的「请卸载这批格子」。
|
|||
|
|
/// 单独一条 68969593:<c>addGridInfo</c> 空表、<c>removeGridInfo</c> 装出界的格子。
|
|||
|
|
/// ⛑ donor §4 提醒 family 差异:68969593 <b>没有</b> extraInfo / activeIds / inactiveIds
|
|||
|
|
/// 三个字段(那是 gadget 侧才有的),别照 GadgetGrid 那份填。
|
|||
|
|
/// ⛑ <c>BuildingId</c> 必须写 int32(值 0),漏写会让整条 payload 位移。
|
|||
|
|
/// </summary>
|
|||
|
|
private static async Task PushSceneItemGridRemovalAsync(
|
|||
|
|
TcpSession session, HashSet<(int, int)> removed, int cx, int cz, byte reason)
|
|||
|
|
{
|
|||
|
|
var cfg = PrivateServerConfigStore.Current.World.Content.SceneItems;
|
|||
|
|
var budget = Math.Max(1, cfg.MaxCellsPerPush);
|
|||
|
|
|
|||
|
|
var groups = new Dictionary<string, (int[] Sectors, List<(int, int)> Cells)>();
|
|||
|
|
foreach (var cell in removed)
|
|||
|
|
{
|
|||
|
|
var sectors = cfg.LoadAllSectors
|
|||
|
|
? [0]
|
|||
|
|
: GadgetCellsCatalog4229938.SectorsOf(cell.Item1, cell.Item2);
|
|||
|
|
var key = string.Join(',', sectors);
|
|||
|
|
if (!groups.TryGetValue(key, out var group))
|
|||
|
|
{
|
|||
|
|
group = (sectors, new List<(int, int)>());
|
|||
|
|
groups[key] = group;
|
|||
|
|
}
|
|||
|
|
group.Cells.Add(cell);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
foreach (var group in groups.Values)
|
|||
|
|
{
|
|||
|
|
for (var offset = 0; offset < group.Cells.Count; offset += budget)
|
|||
|
|
{
|
|||
|
|
var slice = group.Cells.Skip(offset).Take(budget).ToList();
|
|||
|
|
if (slice.Count == 0)
|
|||
|
|
continue;
|
|||
|
|
|
|||
|
|
var args = new SceneMethods.SyncDestructibleGridAOIDecreaseArgs4229938
|
|||
|
|
{
|
|||
|
|
aoiInfo = new SceneMethods.DestructibleGridAOIIncrease
|
|||
|
|
{
|
|||
|
|
PlayerStandardIndex = new SceneMethods.GridIndex { X = cx, Z = cz },
|
|||
|
|
reason = reason,
|
|||
|
|
},
|
|||
|
|
addGridInfo = new SceneMethods.GridAOIDecrease(),
|
|||
|
|
removeGridInfo = new SceneMethods.GridAOIDecrease
|
|||
|
|
{
|
|||
|
|
SectorIdList = group.Sectors.ToList(),
|
|||
|
|
BuildingId = 0,
|
|||
|
|
StandardIndexList = slice
|
|||
|
|
.Select(c => new SceneMethods.GridIndex { X = c.Item1, Z = c.Item2 })
|
|||
|
|
.ToList(),
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
var body = UxSerializer.Serialize(args);
|
|||
|
|
await session.NotifyAsync(MethodId.SyncDestructibleGridAOIDecrease, body, CancellationToken.None);
|
|||
|
|
|
|||
|
|
session.Log.Info($"[SCENE-ITEM-GRID] unload center=({cx},{cz}) reason={reason} " +
|
|||
|
|
$"sectors=[{string.Join(",", group.Sectors)}] " +
|
|||
|
|
$"cells={slice.Count} bytes={body.Length}");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>测试盒 InstanceId 的自增源。高位起跳,避免撞上客户端烘焙出来的真实 id。</summary>
|
|||
|
|
private static long _testBoxSeq = 0x4000000000000000;
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 念动力探针(硬规则 28):<c>DestructibleGridAOIIncrease.addInfos</c> 可以凭空造一个可抓的
|
|||
|
|
/// SceneItem —— 给 <c>CfgId</c>(SceneitemConfig 里 <c>CanQInteraction=true</c> 的条目)+
|
|||
|
|
/// <c>Position</c> 就够,客户端会实例化成真物件并被 MindPowerMgr 认成 Q 目标。
|
|||
|
|
///
|
|||
|
|
/// 这是 T1-22 里唯一能直接验证「服务端显式摆放」这条路的手段:箱子出现 ⇒ 路通,家具不出
|
|||
|
|
/// 只是缺坐标;箱子都不出 ⇒ 先修通道。
|
|||
|
|
///
|
|||
|
|
/// ⛑️ 只在本会话第一次推送时摆:心跳 / 刷新 / 跨格重声明都会重发整窗,摆第二次会叠箱子。
|
|||
|
|
/// ⛑️ 序列化的坑:<c>DestructibleInfo</c> 的客户端 reader 是多态的
|
|||
|
|
/// (<c>ReadStaticDestructibleInfo</c> 跳转表:0=null / 1=BASE / 2=Dynamic / 3=Static / 4=Task)。
|
|||
|
|
/// 这里先走 <c>UxSerializer</c> 的常规写法;若客户端日志出现
|
|||
|
|
/// <c>deserialize failed ... parameter=DestructibleInfo</c>,就说明缺那个 1=BASE 的标签字节,
|
|||
|
|
/// 需要改成手写裸字节(见 SceneItemGridMethods4229938.cs 里 DestructibleInfo4229938 的注释)。
|
|||
|
|
/// 失败的包只会被客户端丢弃,不会污染场景,所以这个探针可以放心试。
|
|||
|
|
/// </summary>
|
|||
|
|
/// <summary>
|
|||
|
|
/// ★ R93 —— 把登记进 <c>Protected</c> 的可破坏物 DTO 归一到本管线使用的
|
|||
|
|
/// <c>SceneMethods.DestructibleInfo4229938</c>。
|
|||
|
|
///
|
|||
|
|
/// 气球(GameRouter.Balloon.cs)持有的是 <c>BowlingWire</c> 那份 <c>DestructibleInfo</c>
|
|||
|
|
/// —— 它是**真机验证过能建出 hold** 的标注(Marker=1 / BASE),而网格通道这份是同构双胞胎
|
|||
|
|
/// (<c>SceneMethods</c>,字段一致但 UXVector3 用的是 Auto 版)。两份绝不能混用(会静默串位),
|
|||
|
|
/// 所以登记时存 object、心跳时在这里逐字段映射。认不出的类型返回 null(该件不摆)。
|
|||
|
|
/// </summary>
|
|||
|
|
private static SceneMethods.DestructibleInfo4229938? ToGridDestructibleInfo(object? info)
|
|||
|
|
{
|
|||
|
|
switch (info)
|
|||
|
|
{
|
|||
|
|
case SceneMethods.DestructibleInfo4229938 same:
|
|||
|
|
return same;
|
|||
|
|
|
|||
|
|
case DestructibleInfo bw:
|
|||
|
|
return new SceneMethods.DestructibleInfo4229938
|
|||
|
|
{
|
|||
|
|
InstanceId = bw.InstanceId,
|
|||
|
|
UniqueId = bw.UniqueId,
|
|||
|
|
CfgId = bw.CfgId,
|
|||
|
|
PathId = bw.PathId,
|
|||
|
|
Position = new global::Ananta.Server.RpcTypes.Client4229938.Auto.UXVector3
|
|||
|
|
{
|
|||
|
|
X = bw.Position.X, Y = bw.Position.Y, Z = bw.Position.Z,
|
|||
|
|
},
|
|||
|
|
Facing = new global::Ananta.Server.RpcTypes.Client4229938.Auto.UXVector3
|
|||
|
|
{
|
|||
|
|
X = bw.Facing.X, Y = bw.Facing.Y, Z = bw.Facing.Z,
|
|||
|
|
},
|
|||
|
|
iScale = bw.IScale,
|
|||
|
|
NavId = bw.NavId,
|
|||
|
|
ForceLod0 = bw.ForceLod0,
|
|||
|
|
BindPid = bw.BindPid,
|
|||
|
|
Hp = bw.Hp,
|
|||
|
|
State = bw.State,
|
|||
|
|
BreakStage = bw.BreakStage,
|
|||
|
|
// ⛔ 两套 SceneDeviceOccupantInfo 是**不同类型**(BowlingWire 版 SourceType 是 byte,
|
|||
|
|
// SceneMethods 版是 int),必须逐项转,不能直接赋值。
|
|||
|
|
OccupantInfos = bw.OccupantInfos is { Count: > 0 }
|
|||
|
|
? bw.OccupantInfos.ConvertAll(o => new SceneMethods.SceneDeviceOccupantInfo
|
|||
|
|
{
|
|||
|
|
Pid = o.Pid,
|
|||
|
|
FightSpiritId = o.FightSpiritId,
|
|||
|
|
AttractNpcPid = o.AttractNpcPid,
|
|||
|
|
Index = o.Index,
|
|||
|
|
IsState = o.IsState,
|
|||
|
|
SourceType = o.SourceType,
|
|||
|
|
})
|
|||
|
|
: null,
|
|||
|
|
DropWeaponId = bw.DropWeaponId,
|
|||
|
|
StackCount = bw.StackCount,
|
|||
|
|
EffectIds = bw.EffectIds,
|
|||
|
|
NoSleep = bw.NoSleep,
|
|||
|
|
ReleaserId = bw.ReleaserId,
|
|||
|
|
// SkillInfo 不为 null 时逐字段转(气球目前不带,保留通路)
|
|||
|
|
SkillInfo = bw.SkillInfo is null
|
|||
|
|
? null
|
|||
|
|
: new SceneMethods.SkillDestructibleInfo4229938
|
|||
|
|
{
|
|||
|
|
CreateSkillInstanceId = bw.SkillInfo.CreateSkillInstanceId,
|
|||
|
|
CreateIndex = bw.SkillInfo.CreateIndex,
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
default:
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static List<SceneMethods.DestructibleInfo4229938> BuildTestBoxSpawns(
|
|||
|
|
TcpSession session, SceneItemGridSessionState owner, float px, float py, float pz)
|
|||
|
|
{
|
|||
|
|
var cfg = PrivateServerConfigStore.Current.World.Content.SceneItems;
|
|||
|
|
if (!cfg.SpawnTestBox)
|
|||
|
|
return [];
|
|||
|
|
var cfgIds = cfg.SpawnTestBoxCfgIds ?? [];
|
|||
|
|
if (cfgIds.Length == 0)
|
|||
|
|
return [];
|
|||
|
|
|
|||
|
|
lock (owner.SyncRoot)
|
|||
|
|
{
|
|||
|
|
if (owner.TestBoxSpawned)
|
|||
|
|
return [];
|
|||
|
|
owner.TestBoxSpawned = true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var spawns = new List<SceneMethods.DestructibleInfo4229938>(cfgIds.Length);
|
|||
|
|
foreach (var cfgId in cfgIds)
|
|||
|
|
{
|
|||
|
|
var uid = (ulong)System.Threading.Interlocked.Increment(ref _testBoxSeq);
|
|||
|
|
spawns.Add(new SceneMethods.DestructibleInfo4229938
|
|||
|
|
{
|
|||
|
|
InstanceId = uid,
|
|||
|
|
UniqueId = uid,
|
|||
|
|
CfgId = (uint)cfgId,
|
|||
|
|
PathId = 0,
|
|||
|
|
Position = new() { X = px + 3.5f, Y = py + 0.5f, Z = pz },
|
|||
|
|
Facing = new(),
|
|||
|
|
iScale = 1000,
|
|||
|
|
NavId = 0,
|
|||
|
|
ForceLod0 = false,
|
|||
|
|
BindPid = 0,
|
|||
|
|
Hp = 100f,
|
|||
|
|
State = 0,
|
|||
|
|
BreakStage = 0,
|
|||
|
|
OccupantInfos = [],
|
|||
|
|
DropWeaponId = 0,
|
|||
|
|
StackCount = 0,
|
|||
|
|
EffectIds = [],
|
|||
|
|
NoSleep = false,
|
|||
|
|
ReleaserId = 0,
|
|||
|
|
SkillInfo = new SceneMethods.SkillDestructibleInfo4229938
|
|||
|
|
{
|
|||
|
|
CreateSkillInstanceId = 0,
|
|||
|
|
CreateIndex = 0,
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
session.Log.Info($"[SCENE-ITEM-GRID] test-box spawn cfgIds=[{string.Join(",", cfgIds)}] " +
|
|||
|
|
$"first=0x{spawns[0].InstanceId:X} @({px:0.##},{py:0.##},{pz:0.##})");
|
|||
|
|
return spawns;
|
|||
|
|
}
|
|||
|
|
}
|