373 lines
18 KiB
C#
373 lines
18 KiB
C#
using System.Text.Json;
|
||||
|
|
using Ananta.SDK.Network;
|
|||
|
|
using Ananta.SDK.Serialization;
|
|||
|
|
using Ananta.Server.Protocol.Client4229938;
|
|||
|
|
using Ananta.Server.RpcTypes.Client4229938;
|
|||
|
|
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
|||
|
|
|
|||
|
|
namespace Ananta.Server.Handlers.Game;
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 场景物的「显式下发」通道 —— 把摊位 / 货箱 / 柴火 之类的烘焙场景物,从
|
|||
|
|
/// 「客户端按 40m 格从 blob 铺出来的占位壳」升级成「真的挂了 GameObject 的物体」。
|
|||
|
|
///
|
|||
|
|
/// ════════════════════════════════════════════════════════════════════════════════════════
|
|||
|
|
/// ★ 为什么必须有这个文件(第 79 轮,2026-09-26)
|
|||
|
|
///
|
|||
|
|
/// 症状:服务端 push 的 40m 格窗口从 49 格一路放到 289 格,在用 hold 数 2498 → 4531 → 5094
|
|||
|
|
/// (数据层面全部成功、日志干净、帧率 73),**但玩家在集市里什么都看不见**。
|
|||
|
|
///
|
|||
|
|
/// frida 实测(只读,`gym_tools/_r79_render.py` / `_r79_ecs.py`):
|
|||
|
|
/// · 5094 个在用 hold 里,只有 157 个的 <c>SceneItemObj</c> 非空(3.1%)⇒ 93.7% 只是占位壳;
|
|||
|
|
/// · 交叉表 <c>_useECSRender × SceneItemObj</c>:
|
|||
|
|
/// ecs=false | go=true 18
|
|||
|
|
/// ecs=true | go=false 4975 ← 98%
|
|||
|
|
/// ⇒ 走 ECS 渲染的 hold **不生成 GameObject**;
|
|||
|
|
/// · 全部 5094 个的 <c>_forceLODLevel</c> 都 = <c>Init</c>,配合硬规则 8 的实测指纹
|
|||
|
|
/// (<c>Lod0</c> = 服务端摆的 / <c>Init</c> = 客户端烘焙物)⇒ **它们全部来自客户端自己的 blob**,
|
|||
|
|
/// 没有任何一个是服务端显式下发的;
|
|||
|
|
/// · 客户端日志里那句 <c>场景不支持ecs的group数量过多,可能会影响性能, levelConfigType:10,
|
|||
|
|
/// count:2725</c>(从进入世界第一秒就有、不是本改动引入)说明本场景的 ECS 渲染被关卡守卫关掉 ⇒
|
|||
|
|
/// 被判给 ECS 的 4975 件**没有任何东西去渲染它们** ⇒ 看不见。
|
|||
|
|
///
|
|||
|
|
/// ⇒ 结论:<c>PushSceneItemGridAsync</c> 只推「哪几格」,客户端就从 blob 铺壳;
|
|||
|
|
/// 要真正物化,必须走 <c>aoiInfo.addInfos</c> 显式逐对象下发。
|
|||
|
|
/// 这一点在仓库里早有明文(`BowlingWireContracts.cs:591`):
|
|||
|
|
/// 「the explicit addInfos inside aoiInfo are what actually materialise an object
|
|||
|
|
/// when the blob has nothing for that cell」
|
|||
|
|
/// 而**能用的健身房通道**(`GameRouter.Gadget.cs:137`)就是同一个模式:显式下发 + <c>ForceLod0 = true</c>
|
|||
|
|
/// ——注释原话「ForceLod0 keeps a freshly spawned prop from being culled to a billboard」,
|
|||
|
|
/// 并且用**客户端 blob 的真实 uid** 当 uniqueId,「so the entity the server places is the same
|
|||
|
|
/// instance the client would have built itself」。
|
|||
|
|
///
|
|||
|
|
/// ════════════════════════════════════════════════════════════════════════════════════════
|
|||
|
|
/// ★ 所以在整个链路里的位置
|
|||
|
|
/// 1) 一次性:从客户端 dump 摆位(`gym_tools/_r79_dump_si_place.py`)→
|
|||
|
|
/// <c>config/scene_item_placements.json</c>(mtime 热重载,改完不用重启服务端);
|
|||
|
|
/// 2) 每次要让东西出现:<c>POST /api/scene-item/push-explicit</c> 或直接调本类的
|
|||
|
|
/// <see cref="PushExplicitSceneItemsAsync"/>;半径 / 条数 / 标记 / 通道全部是参数,
|
|||
|
|
/// **不需要 rebuild、不需要重启**,可以现场 A/B。
|
|||
|
|
///
|
|||
|
|
/// ⛑️ 复用 <c>Ananta.Server.RpcTypes.Client4229938</c> 的保龄球 DTO(`BowlingWireContracts.cs`),
|
|||
|
|
/// 不用 `SceneItemGridMethods4229938.cs` 里那份同名 DTO:两者的元素类型标记不同
|
|||
|
|
/// (保龄球版 <c>TypeMark = 1</c> = 客户端跳转表里的 BASE;SceneItem 版是默认标记),
|
|||
|
|
/// 而保龄球版是**在线上验证过能建出 hold 的**那一份。标记写错的表现是
|
|||
|
|
/// `parameter=DestructibleInfo` 解析失败——donor 第 23B 轮为这个赔过一整个会话。
|
|||
|
|
/// </summary>
|
|||
|
|
internal sealed partial class GameRouter
|
|||
|
|
{
|
|||
|
|
// ---------------------------------------------------------------------------------------
|
|||
|
|
// 摆位文件(热重载)
|
|||
|
|
// ---------------------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
private static readonly object SceneItemPlacementLock = new();
|
|||
|
|
private static string? _siPlacementFile;
|
|||
|
|
private static long _siPlacementStamp = -1;
|
|||
|
|
private static List<SceneItemPlacement> _siPlacementCache = [];
|
|||
|
|
/// <summary>上次探测过的候选路径(诊断用)。未命中时不做长期缓存,故这里每次都会刷新。</summary>
|
|||
|
|
private static List<string> _siPlacementProbe = [];
|
|||
|
|
|
|||
|
|
/// <summary>一条场景物摆位。字段名与客户端 hold 上的同名(cfgId / pathId / uniqueId / objPosition)。</summary>
|
|||
|
|
internal readonly record struct SceneItemPlacement(
|
|||
|
|
ulong UniqueId, uint CfgId, int PathId, float X, float Y, float Z, float Yaw, int Scale);
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 找 <c>config/scene_item_placements.json</c>:从运行目录 / 当前目录各向上找 6 层
|
|||
|
|
/// (与 <c>BowlingWireNow</c> 同款做法)。找不到返回 null。
|
|||
|
|
/// </summary>
|
|||
|
|
private static string? FindSceneItemPlacementFile()
|
|||
|
|
{
|
|||
|
|
// ★ 只有「命中」才长期缓存。★ 「未命中」**绝不缓存**:
|
|||
|
|
// 摆位文件是服务端起来之后才由 _r79_dump_si_place.py 导出的,
|
|||
|
|
// 旧写法把「没找到」缓存成 "" ⇒ 只有重启服务端才能恢复(2026-09-27 踩到)。
|
|||
|
|
if (_siPlacementFile is not null && _siPlacementFile.Length > 0)
|
|||
|
|
return _siPlacementFile;
|
|||
|
|
|
|||
|
|
var candidates = new List<string>();
|
|||
|
|
void AddDir(string? dir)
|
|||
|
|
{
|
|||
|
|
if (string.IsNullOrEmpty(dir)) return;
|
|||
|
|
var d = new DirectoryInfo(dir);
|
|||
|
|
for (var up = 0; up < 6 && d is not null; up++, d = d.Parent)
|
|||
|
|
candidates.Add(Path.Combine(d.FullName, "config", "scene_item_placements.json"));
|
|||
|
|
}
|
|||
|
|
AddDir(AppContext.BaseDirectory);
|
|||
|
|
try { AddDir(Directory.GetCurrentDirectory()); } catch { /* ignore */ }
|
|||
|
|
|
|||
|
|
_siPlacementProbe = candidates;
|
|||
|
|
var hit = candidates.FirstOrDefault(File.Exists);
|
|||
|
|
if (hit is not null)
|
|||
|
|
_siPlacementFile = hit; // 命中才缓存
|
|||
|
|
return hit;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 读摆位表(mtime 缓存)。JSON 键名做了宽松兼容,所以 dump 脚本换写法不用改这边:
|
|||
|
|
/// <code>
|
|||
|
|
/// { "items": [
|
|||
|
|
/// { "id": 123456789, "cfg": 80001921, "path": 1154760923,
|
|||
|
|
/// "x": -4713.9, "y": 176.2, "z": -2821.7, "yaw": 91.5, "scale": 100 },
|
|||
|
|
/// ...
|
|||
|
|
/// ] }
|
|||
|
|
/// </code>
|
|||
|
|
/// 键名兼容:id|uid|uniqueId|unique_id / cfg|cfgId|configId|config_id / path|pathId|path_id /
|
|||
|
|
/// x,y,z / yaw|ry|facing|rotY / scale|iScale|i_scale。缺 yaw / scale 时取 0 / 100。
|
|||
|
|
/// </summary>
|
|||
|
|
internal static List<SceneItemPlacement> LoadSceneItemPlacements()
|
|||
|
|
{
|
|||
|
|
var file = FindSceneItemPlacementFile();
|
|||
|
|
if (file is null)
|
|||
|
|
return [];
|
|||
|
|
|
|||
|
|
long stamp;
|
|||
|
|
try { stamp = new FileInfo(file).LastWriteTimeUtc.Ticks; }
|
|||
|
|
catch { return []; }
|
|||
|
|
|
|||
|
|
lock (SceneItemPlacementLock)
|
|||
|
|
{
|
|||
|
|
if (stamp == _siPlacementStamp)
|
|||
|
|
return _siPlacementCache;
|
|||
|
|
|
|||
|
|
var list = new List<SceneItemPlacement>();
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
using var doc = JsonDocument.Parse(File.ReadAllText(file));
|
|||
|
|
var root = doc.RootElement;
|
|||
|
|
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("items", out var items)
|
|||
|
|
&& items.ValueKind == JsonValueKind.Array)
|
|||
|
|
{
|
|||
|
|
foreach (var it in items.EnumerateArray())
|
|||
|
|
{
|
|||
|
|
if (it.ValueKind != JsonValueKind.Object) continue;
|
|||
|
|
var p = ParsePlacement(it);
|
|||
|
|
if (p is not null) list.Add(p.Value);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
Console.Error.WriteLine($"[SCENE-ITEM-EXPLICIT] placement file unreadable: {ex.Message}");
|
|||
|
|
return _siPlacementCache;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_siPlacementStamp = stamp;
|
|||
|
|
_siPlacementCache = list;
|
|||
|
|
return list;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static SceneItemPlacement? ParsePlacement(JsonElement it)
|
|||
|
|
{
|
|||
|
|
static bool TryNum(JsonElement o, out double v, params string[] keys)
|
|||
|
|
{
|
|||
|
|
foreach (var k in keys)
|
|||
|
|
if (o.TryGetProperty(k, out var e))
|
|||
|
|
{
|
|||
|
|
if (e.ValueKind == JsonValueKind.Number) { v = e.GetDouble(); return true; }
|
|||
|
|
if (e.ValueKind == JsonValueKind.String
|
|||
|
|
&& double.TryParse(e.GetString(), System.Globalization.NumberStyles.Float,
|
|||
|
|
System.Globalization.CultureInfo.InvariantCulture, out var s))
|
|||
|
|
{ v = s; return true; }
|
|||
|
|
}
|
|||
|
|
v = 0;
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!TryNum(it, out var uid, "id", "uid", "uniqueId", "unique_id")) return null;
|
|||
|
|
if (!TryNum(it, out var cfg, "cfg", "cfgId", "configId", "config_id")) return null;
|
|||
|
|
if (!TryNum(it, out var path, "path", "pathId", "path_id")) return null;
|
|||
|
|
if (!TryNum(it, out var x, "x")) return null;
|
|||
|
|
if (!TryNum(it, out var y, "y")) return null;
|
|||
|
|
if (!TryNum(it, out var z, "z")) return null;
|
|||
|
|
TryNum(it, out var yaw, "yaw", "ry", "facing", "rotY");
|
|||
|
|
if (!TryNum(it, out var scale, "scale", "iScale", "i_scale")) scale = 100;
|
|||
|
|
|
|||
|
|
if (cfg <= 0 || path == 0) return null; // pathId 必须是客户端资源清单的键(硬规则 7)
|
|||
|
|
return new SceneItemPlacement((ulong)uid, (uint)cfg, (int)path, (float)x, (float)y, (float)z,
|
|||
|
|
(float)yaw, (int)scale);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------------------
|
|||
|
|
// 下发
|
|||
|
|
// ---------------------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 把玩家周围 <paramref name="radiusMeters"/> 内的场景物显式下发,让客户端**真的**把 GameObject 装出来。
|
|||
|
|
///
|
|||
|
|
/// <paramref name="mode"/>:
|
|||
|
|
/// <c>aoi</c> = 只走 <c>SyncDestructibleGridAOIDecrease(68969593)</c> 的 <c>aoiInfo.addInfos</c>
|
|||
|
|
/// (保龄球用的就是这条,hold 建得出来);
|
|||
|
|
/// <c>addlist</c> = 只走 <c>SyncDestructibleObjectAddList(68963429)</c>;
|
|||
|
|
/// <c>both</c> = 两条都发(保龄球实际是这么干的,默认)。
|
|||
|
|
///
|
|||
|
|
/// <paramref name="forceLod0"/> 默认 true —— 这是**和健身房那条能用的通道唯一对齐的字段**
|
|||
|
|
/// (`GameRouter.Gadget.cs:137`),关掉它只剩「显式下发」一半的效果,留作 A/B 对照组。
|
|||
|
|
///
|
|||
|
|
/// <paramref name="exceptIds"/> = true 时把下发的 uid 塞进 <c>addGridInfo.ExceptIds</c>,
|
|||
|
|
/// 并按它们所在的格填 <c>StandardIndexList</c>,让客户端**别再**从 blob 铺同一批壳
|
|||
|
|
/// (<c>ExceptIds</c> 这个字段就是为这件事存在的)。false = 只加不减,作对照组。
|
|||
|
|
/// </summary>
|
|||
|
|
internal static async Task<(bool Ok, string Message, int Pushed, int Available, double Nearest)>
|
|||
|
|
PushExplicitSceneItemsAsync(TcpSession? session, double radiusMeters = 120.0, int maxItems = 48,
|
|||
|
|
string mode = "both", bool forceLod0 = true, bool exceptIds = true,
|
|||
|
|
byte addListReason = 14)
|
|||
|
|
{
|
|||
|
|
if (session is null)
|
|||
|
|
return (false, "no session", 0, 0, -1);
|
|||
|
|
|
|||
|
|
var all = LoadSceneItemPlacements();
|
|||
|
|
if (all.Count == 0)
|
|||
|
|
return (false,
|
|||
|
|
"config/scene_item_placements.json 没有内容(或文件不存在)——先跑 gym_tools/_r79_dump_si_place.py 导出摆位",
|
|||
|
|
0, 0, -1);
|
|||
|
|
|
|||
|
|
var state = GetStateStatic(session);
|
|||
|
|
float px, pz;
|
|||
|
|
lock (state.SyncRoot)
|
|||
|
|
{
|
|||
|
|
if (!state.HasLastReportedPlayerTransform)
|
|||
|
|
return (false, "还没有玩家坐标(先进入世界、角色出现在游戏里)", 0, all.Count, -1);
|
|||
|
|
px = state.LastReportedPlayerPosition.X;
|
|||
|
|
pz = state.LastReportedPlayerPosition.Z;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var r = Math.Max(1.0, radiusMeters);
|
|||
|
|
var r2 = r * r;
|
|||
|
|
var near = new List<(SceneItemPlacement P, double D)>();
|
|||
|
|
var nearest = double.MaxValue;
|
|||
|
|
foreach (var p in all)
|
|||
|
|
{
|
|||
|
|
var dx = p.X - px;
|
|||
|
|
var dz = p.Z - pz;
|
|||
|
|
var d2 = (double)dx * dx + (double)dz * dz;
|
|||
|
|
if (d2 < nearest) nearest = d2;
|
|||
|
|
if (d2 <= r2) near.Add((p, Math.Sqrt(d2)));
|
|||
|
|
}
|
|||
|
|
nearest = nearest == double.MaxValue ? -1 : Math.Sqrt(nearest);
|
|||
|
|
|
|||
|
|
if (near.Count == 0)
|
|||
|
|
return (false, $"半径 {r:F0} m 内一条摆位都没有(最近的一条在 {nearest:F1} m)", 0, all.Count, nearest);
|
|||
|
|
|
|||
|
|
near.Sort((a, b) => a.D.CompareTo(b.D));
|
|||
|
|
var cap = maxItems <= 0 ? near.Count : Math.Min(maxItems, near.Count);
|
|||
|
|
var picked = new List<SceneItemPlacement>(cap);
|
|||
|
|
for (var i = 0; i < cap; i++) picked.Add(near[i].P);
|
|||
|
|
|
|||
|
|
var cx = (int)MathF.Floor(px / SceneItemCellSize);
|
|||
|
|
var cz = (int)MathF.Floor(pz / SceneItemCellSize);
|
|||
|
|
|
|||
|
|
var infos = new List<DestructibleInfo>(picked.Count);
|
|||
|
|
var ids = new List<ulong>(picked.Count);
|
|||
|
|
var cells = new HashSet<(int, int)>();
|
|||
|
|
foreach (var p in picked)
|
|||
|
|
{
|
|||
|
|
infos.Add(new DestructibleInfo
|
|||
|
|
{
|
|||
|
|
InstanceId = p.UniqueId,
|
|||
|
|
UniqueId = p.UniqueId,
|
|||
|
|
CfgId = p.CfgId,
|
|||
|
|
PathId = p.PathId,
|
|||
|
|
Position = new UXVector3 { X = p.X, Y = p.Y, Z = p.Z },
|
|||
|
|
Facing = new UXVector3 { X = 0f, Y = p.Yaw, Z = 0f },
|
|||
|
|
IScale = p.Scale,
|
|||
|
|
NavId = 0,
|
|||
|
|
// ★ 与健身房通道(GameRouter.Gadget.cs:137)对齐的**唯一一个渲染开关**。
|
|||
|
|
ForceLod0 = forceLod0,
|
|||
|
|
BindPid = 0,
|
|||
|
|
Hp = 1f,
|
|||
|
|
State = 0, // DestructibleState.Active
|
|||
|
|
BreakStage = 0,
|
|||
|
|
OccupantInfos = [],
|
|||
|
|
DropWeaponId = 0,
|
|||
|
|
StackCount = 1,
|
|||
|
|
EffectIds = [],
|
|||
|
|
NoSleep = true,
|
|||
|
|
ReleaserId = 0,
|
|||
|
|
SkillInfo = null,
|
|||
|
|
});
|
|||
|
|
ids.Add(p.UniqueId);
|
|||
|
|
cells.Add(((int)MathF.Floor(p.X / SceneItemCellSize), (int)MathF.Floor(p.Z / SceneItemCellSize)));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var doAoi = mode is "aoi" or "both" or "";
|
|||
|
|
var doList = mode is "addlist" or "both" or "";
|
|||
|
|
var sent = new List<string>();
|
|||
|
|
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
if (doAoi)
|
|||
|
|
{
|
|||
|
|
var indexList = new List<SceneMethods.GridIndex> { new() { X = cx, Z = cz } };
|
|||
|
|
var exceptList = new List<ulong>();
|
|||
|
|
if (exceptIds)
|
|||
|
|
{
|
|||
|
|
foreach (var c in cells) indexList.Add(new SceneMethods.GridIndex { X = c.Item1, Z = c.Item2 });
|
|||
|
|
exceptList.AddRange(ids);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var args = new SyncDestructibleGridAOIDecreaseArgs4229938
|
|||
|
|
{
|
|||
|
|
aoiInfo = new DestructibleGridAOIIncrease
|
|||
|
|
{
|
|||
|
|
PlayerStandardIndex = new SceneMethods.GridIndex { X = cx, Z = cz },
|
|||
|
|
AddInfos = infos,
|
|||
|
|
IndexList = indexList,
|
|||
|
|
AddUniqueIds = new List<ulong>(ids),
|
|||
|
|
RemoveIds = [],
|
|||
|
|
Reason = 1, // AoiAddAndRemoveReason.Loaded
|
|||
|
|
},
|
|||
|
|
addGridInfo = new SceneMethods.GridAOIDecrease
|
|||
|
|
{
|
|||
|
|
SectorIdList = [0],
|
|||
|
|
BuildingId = 0,
|
|||
|
|
StandardIndexList = indexList
|
|||
|
|
.Select(g => new SceneMethods.GridIndex { X = g.X, Z = g.Z }).ToList(),
|
|||
|
|
ExceptIds = exceptList,
|
|||
|
|
},
|
|||
|
|
removeGridInfo = new SceneMethods.GridAOIDecrease(),
|
|||
|
|
};
|
|||
|
|
var body = UxSerializer.Serialize(args);
|
|||
|
|
await session.NotifyAsync(MethodId.SyncDestructibleGridAOIDecrease, body, CancellationToken.None);
|
|||
|
|
sent.Add($"aoi(68969593) bytes={body.Length}");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (doList)
|
|||
|
|
{
|
|||
|
|
var body = UxSerializer.Serialize(new DestructibleObjectAddList
|
|||
|
|
{
|
|||
|
|
AddInfos = infos,
|
|||
|
|
Reason = addListReason,
|
|||
|
|
});
|
|||
|
|
await session.NotifyAsync(MethodId.SyncDestructibleObjectAddList, body, CancellationToken.None);
|
|||
|
|
sent.Add($"addlist(68963429) bytes={body.Length} reason={addListReason}");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
return (false, $"send failed: {ex.Message}", 0, all.Count, nearest);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var msg = $"已显式下发 {picked.Count}/{near.Count} 个(半径 {r:F0} m,共 {all.Count} 条摆位," +
|
|||
|
|
$"最近 {nearest:F1} m,ForceLod0={forceLod0},exceptIds={exceptIds})via {string.Join(" + ", sent)}";
|
|||
|
|
session.Log.Info($"[SCENE-ITEM-EXPLICIT] {msg}");
|
|||
|
|
return (true, msg, picked.Count, all.Count, nearest);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>诊断:摆位文件是否可用、总条数、解析失败的条数。</summary>
|
|||
|
|
internal static object SceneItemPlacementStatus()
|
|||
|
|
{
|
|||
|
|
var file = FindSceneItemPlacementFile();
|
|||
|
|
var list = LoadSceneItemPlacements();
|
|||
|
|
return new
|
|||
|
|
{
|
|||
|
|
file = file ?? "(not found)",
|
|||
|
|
exists = file is not null,
|
|||
|
|
count = list.Count,
|
|||
|
|
probe = _siPlacementProbe.Count == 0 ? null : _siPlacementProbe,
|
|||
|
|
first = list.Count == 0
|
|||
|
|
? null
|
|||
|
|
: new { id = list[0].UniqueId, cfg = list[0].CfgId, path = list[0].PathId,
|
|||
|
|
x = list[0].X, y = list[0].Y, z = list[0].Z, yaw = list[0].Yaw },
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
}
|