1272 lines
53 KiB
C#
1272 lines
53 KiB
C#
using Ananta.SDK.Serialization;
|
||
using Ananta.Server.ClientData.Client4229938;
|
||
using Ananta.Server.Configuration;
|
||
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
||
|
||
namespace Ananta.Server.Protocol.Client4229938;
|
||
|
||
/// <summary>一件落地的物品。<c>ConfigId</c> 是配置行 id(9993xxxx),下发时才换成 ConsumableId。</summary>
|
||
internal sealed class ExtractionShooterItem
|
||
{
|
||
internal uint ConfigId;
|
||
internal uint StackCount = 1;
|
||
internal int CellX;
|
||
internal int CellY;
|
||
internal bool IsRotated;
|
||
internal bool IsLocked;
|
||
internal ulong BindPid;
|
||
|
||
/// <summary>按旋转换算后的实际占格 (宽, 高)。配置缺失时按 1x1 兜底。</summary>
|
||
internal (int W, int H) Footprint()
|
||
{
|
||
var meta = ExtractionShooterCatalog4229938.Item(ConfigId);
|
||
var vx = meta?.VolumeX ?? 1;
|
||
var vy = meta?.VolumeY ?? 1;
|
||
return ExtractionGrid4229938.Rotate(vx, vy, IsRotated);
|
||
}
|
||
|
||
/// <summary>不旋转换算(打包新的 items 时用)。</summary>
|
||
internal static (int W, int H) FootprintOf(uint configId, bool isRotated)
|
||
{
|
||
var meta = ExtractionShooterCatalog4229938.Item(configId);
|
||
return ExtractionGrid4229938.Rotate(meta?.VolumeX ?? 1, meta?.VolumeY ?? 1, isRotated);
|
||
}
|
||
|
||
internal void PlaceAt(int x, int y, bool rotated)
|
||
{
|
||
CellX = x;
|
||
CellY = y;
|
||
IsRotated = rotated;
|
||
}
|
||
}
|
||
|
||
internal static class ExtractionShooterGrid
|
||
{
|
||
/// <summary>命中 (x,y) 的物品 —— 注意 x,y 可能落在某个大件的覆盖区中间,不是原点。</summary>
|
||
internal static ExtractionShooterItem? ItemAt(IEnumerable<ExtractionShooterItem> items, int x, int y)
|
||
=> items.FirstOrDefault(item =>
|
||
{
|
||
var (w, h) = item.Footprint();
|
||
return x >= item.CellX && x < item.CellX + w && y >= item.CellY && y < item.CellY + h;
|
||
});
|
||
}
|
||
|
||
/// <summary>一个 bag 的服务端镜像:格数 + 占位图 + 物品清单。</summary>
|
||
internal sealed class ExtractionShooterBagState
|
||
{
|
||
internal uint BagId;
|
||
internal ExtractionShooterBagMeta Meta = null!;
|
||
internal List<ExtractionShooterItem> Items = [];
|
||
internal ExtractionGrid4229938 Grid = new(1, 1);
|
||
|
||
internal bool IsSlotGroup => ExtractionShooterCatalog4229938.IsSlotGroup(Meta);
|
||
|
||
/// <summary>物品清单改过之后重建占位图。</summary>
|
||
internal void RebuildOccupancy()
|
||
{
|
||
Grid = new ExtractionGrid4229938(Meta.CapacityX <= 0 ? 1 : Meta.CapacityX,
|
||
Meta.CapacityY <= 0 ? 1 : Meta.CapacityY);
|
||
OccupyAll();
|
||
}
|
||
|
||
internal void OccupyAll()
|
||
{
|
||
foreach (var item in Items)
|
||
Grid.Occupy(item.CellX, item.CellY, item.Footprint().W, item.Footprint().H);
|
||
}
|
||
}
|
||
|
||
/// <summary>一个容器实例。实例 id 由客户端给出(8 字节),服务端只认这一份。</summary>
|
||
internal sealed class ExtractionShooterContainerState
|
||
{
|
||
internal ulong InstanceId;
|
||
internal uint CfgId;
|
||
internal int SizeX = 1;
|
||
internal int SizeY = 1;
|
||
internal List<ExtractionShooterItem> Items = [];
|
||
internal ExtractionGrid4229938 Grid = new(1, 1);
|
||
|
||
/// <summary>pid → 该玩家搜索完成的服务器秒数。<b>空 = 未搜索 = 客户端隐藏图标</b>。</summary>
|
||
internal Dictionary<ulong, uint> SearchFinish = [];
|
||
|
||
/// <summary>
|
||
/// 已经把 <see cref="SearchFinish"/> 翻成过去的 pid。
|
||
///
|
||
/// ⛑️ 这一位是「能不能看见东西」的开关:搜索刚发起时 <see cref="SearchFinish"/> 落在<b>未来</b>
|
||
/// (客户端据此播读条),只有等时间到了、把它翻回过去,物品才会真正显示。
|
||
/// 捐赠端 v1.7 的实机日志(09-24 17:07:04 → 17:07:05 → 17:07:06)逐帧证明了这个顺序:
|
||
/// 推「未来」帧 → 隔 N 秒推「已揭晓」帧 → 玩家才取得到东西。
|
||
/// </summary>
|
||
internal HashSet<ulong> Revealed = [];
|
||
|
||
/// <summary>整箱揭晓的基准延时:箱内最长的那件 SearchTime。</summary>
|
||
internal int RevealDelaySeconds = 1;
|
||
|
||
internal void RebuildOccupancy()
|
||
{
|
||
Grid = new ExtractionGrid4229938(SizeX, SizeY);
|
||
foreach (var item in Items)
|
||
Grid.Occupy(item.CellX, item.CellY, item.Footprint().W, item.Footprint().H);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 每条连接的搜打撤状态,存 <c>session.Items</c>(与 <c>GameRouter.SceneItemGrid</c> 同套路)。
|
||
/// </summary>
|
||
internal sealed class ExtractionShooterSessionState
|
||
{
|
||
internal object SyncRoot { get; } = new();
|
||
internal Dictionary<uint, ExtractionShooterBagState> Bags = [];
|
||
internal Dictionary<ulong, ExtractionShooterContainerState> Containers = [];
|
||
internal HashSet<uint> UnlockedExpansions = [];
|
||
internal HashSet<uint> UnlockedBags = [];
|
||
internal uint TokenCount;
|
||
internal bool Seeded;
|
||
|
||
/// <summary>
|
||
/// 本局开始的时间戳(秒)—— 结算帧 <c>SyncRaidSettlement</c> 里的
|
||
/// <c>EnterRaidTime</c> / <c>BattleTime</c> 就取它。
|
||
/// <see cref="Seed"/> 每次重跑都会刷新(换局),<see cref="Reset"/> 不清它,
|
||
/// 因为退图那一刀要用「退图时间 - 进图时间」算这一局打了多久。
|
||
/// </summary>
|
||
internal uint EnterRaidSeconds;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 搜打撤运行时:编目 → per-session 状态 → wire payload。
|
||
///
|
||
/// 本轮覆盖:登录帧注入、进图、容器刷物与搜索、背包搬运 / 丢弃 / 锁定 / 排序 / 入仓、容器↔背包搬运。
|
||
/// <b>不含</b>武器实体化(轮盘 Tab 切枪)—— 那需要 <c>CombatCatalogRepository</c> 的实例 id
|
||
/// 与 SyncSpiritWeaponDetail 整表,是第二轮的事。
|
||
///
|
||
/// 三条容易漏的:
|
||
/// 1. <b>下发 id 用 ConsumableId</b>(<see cref="ExtractionShooterCatalog4229938.WireIdFor"/>),
|
||
/// 配置行 id 只在服务端内部记。
|
||
/// 2. <b>搜索时间戳必须落在未来</b>:客户端 wait 秒数被钳负为 0 ⇒ 过去时间戳 = wait(0) 空转,
|
||
/// 动画一帧不播。
|
||
/// 3. <b>堆叠数量按服务端规则生成</b>(<see cref="StackCountForLocked"/>)。写 1 会让客户端把角标
|
||
/// 整个藏掉(<c>showCountCtrl = StackCount <= 1</c>),看起来像"子弹只有 1 发"。
|
||
/// </summary>
|
||
internal static class ExtractionShooterRuntime4229938
|
||
{
|
||
private const string SessionKey = "client4229938-extraction-shooter";
|
||
|
||
internal static ExtractionShooterSessionState StateOf(Ananta.SDK.Network.TcpSession session)
|
||
{
|
||
if (session.Items.TryGetValue(SessionKey, out var existing)
|
||
&& existing is ExtractionShooterSessionState typed)
|
||
return typed;
|
||
|
||
var created = new ExtractionShooterSessionState();
|
||
session.Items[SessionKey] = created;
|
||
return created;
|
||
}
|
||
|
||
// ------------------------------------------------------------------ seeding
|
||
|
||
/// <summary>本局开放的包。取自 GamePlayTypeConfig 的 StorageSet / EquipSlotSet。</summary>
|
||
internal static readonly uint[] DefaultBagIds =
|
||
[
|
||
ExtractionShooterCatalog4229938.BackpackBagId, // 99952001 背包 4x4
|
||
ExtractionShooterCatalog4229938.SafeBoxBagId, // 99952002 安全箱 2x1
|
||
ExtractionShooterCatalog4229938.FirstStashBagId, // 99952003 一号仓库 7x15
|
||
ExtractionShooterCatalog4229938.WheelBagId, // 99952004 武器轮盘 8 槽
|
||
];
|
||
|
||
/// <summary>开局建包。幂等 —— <see cref="Reset"/> 之后也能再跑一遍。</summary>
|
||
internal static int Seed(ExtractionShooterSessionState owner)
|
||
{
|
||
lock (owner.SyncRoot)
|
||
{
|
||
owner.Bags.Clear();
|
||
foreach (var id in DefaultBagIds)
|
||
{
|
||
var meta = ExtractionShooterCatalog4229938.Bags.FirstOrDefault(b => b.Id == id);
|
||
if (meta is null)
|
||
continue;
|
||
var bag = new ExtractionShooterBagState { BagId = id, Meta = meta };
|
||
bag.RebuildOccupancy();
|
||
owner.Bags[id] = bag;
|
||
owner.UnlockedBags.Add(id);
|
||
}
|
||
owner.Seeded = true;
|
||
// 换局 ⟹ 重新计时;这一句是上个私有字段 EnterRaidSeconds 的唯一写入点。
|
||
owner.EnterRaidSeconds = (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||
return owner.Bags.Count;
|
||
}
|
||
}
|
||
|
||
/// <summary>换局 / 退图:丢掉容器与全部物品。<b>不重置</b>已解锁的包。</summary>
|
||
internal static void Reset(ExtractionShooterSessionState owner)
|
||
{
|
||
lock (owner.SyncRoot)
|
||
{
|
||
owner.Containers.Clear();
|
||
foreach (var bag in owner.Bags.Values)
|
||
{
|
||
bag.Items.Clear();
|
||
bag.RebuildOccupancy();
|
||
}
|
||
owner.Seeded = false;
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------------ login frame
|
||
|
||
/// <summary>
|
||
/// 把状态镜像写进登录帧的那个 <c>PlayerExtractionShooterInfo</c>。
|
||
///
|
||
/// ⛑ <c>Bags</c> 与 <c>SlotGroups</c> 必须<b>二选一</b>:客户端
|
||
/// <c>GetBagInfoByConfigId = Bags[id] or SlotGroups[id]</c>(Bags 优先),而渲染轮盘必调的
|
||
/// <c>CheckSlotIndexHasUnlocked</c> 读 <c>AddUnlockedCount</c> —— 只有 SlotGroupInfo 有。
|
||
/// 两边都发会让后者直接崩面板。
|
||
/// </summary>
|
||
internal static void FillLoginInfo(ExtractionShooterSessionState owner,
|
||
Ananta.Server.RpcTypes.Client4229938.Auto.PlayerExtractionShooterInfo info)
|
||
{
|
||
lock (owner.SyncRoot)
|
||
{
|
||
foreach (var bag in owner.Bags.Values)
|
||
{
|
||
var items = bag.Items.Select(BuildItemInfo).ToList();
|
||
if (bag.IsSlotGroup)
|
||
{
|
||
info.SlotGroups[bag.BagId] =
|
||
new Ananta.Server.RpcTypes.Client4229938.Auto.ExtractionShooterSlotGroupInfo
|
||
{
|
||
BagId = bag.BagId,
|
||
ItemInfoList = items,
|
||
AddUnlockedCount = (uint)bag.Meta.SlotInitial,
|
||
AddMaxCapacity = (uint)bag.Meta.SlotMax,
|
||
};
|
||
}
|
||
else
|
||
{
|
||
info.Bags[bag.BagId] =
|
||
new Ananta.Server.RpcTypes.Client4229938.Auto.ExtractionShooterBagInfo
|
||
{
|
||
BagId = bag.BagId,
|
||
ItemInfoList = items,
|
||
};
|
||
}
|
||
}
|
||
|
||
foreach (var id in owner.UnlockedBags)
|
||
info.UnlockedBagIds[id] = true;
|
||
foreach (var id in owner.UnlockedExpansions)
|
||
info.UnlockedExpansionIds[id] = true;
|
||
|
||
info.TokenCount = owner.TokenCount;
|
||
}
|
||
}
|
||
|
||
private static Ananta.Server.RpcTypes.Client4229938.Auto.ExtractionShooterItemInfo BuildItemInfo(
|
||
ExtractionShooterItem item)
|
||
=> new()
|
||
{
|
||
Id = ExtractionShooterCatalog4229938.WireIdFor(item.ConfigId),
|
||
StackCount = item.StackCount,
|
||
CellX = (uint)item.CellX,
|
||
CellY = (uint)item.CellY,
|
||
IsRotated = item.IsRotated,
|
||
IsLocked = item.IsLocked,
|
||
BindPid = item.BindPid,
|
||
ShieldValue = 0f,
|
||
// 武器(ReadInfoType=1)带上 WeaponData,客户端才连得上「这一格」和「那件实体化武器」:
|
||
// BackCircleAnantarkovStore.OnRenderPopupItemDetail 传 UniqueId = itemInfo.WeaponData.InstanceId,
|
||
// 而 GetTarkovWeaponByInstanceId 是遍历所有包找 WeaponData.InstanceId == itemUid 的那一件 ——
|
||
// 恒 null 时枪的 tooltip 永远退化成普通物品描述。
|
||
// ⛑ InstanceId 必须来自账号军械库(WeaponInstanceOf),自造 ⇒ 技能全掉回默认武器。
|
||
WeaponData = WheelWeaponData(ExtractionShooterCatalog4229938.WireIdFor(item.ConfigId)),
|
||
CollectionBook = null,
|
||
};
|
||
|
||
// ------------------------------------------------------------------ loot roll
|
||
|
||
/// <summary>
|
||
/// 堆叠数量。客户端 <c>showCountCtrl = StackCount <= 1</c> 会把角标藏掉,所以
|
||
/// "子弹只显示 1 发"其实是这条 —— 不是少发了。
|
||
/// </summary>
|
||
internal static uint StackCountForLocked(int maxStack, Random rng)
|
||
{
|
||
if (maxStack <= 1)
|
||
return 1;
|
||
if (maxStack <= 20)
|
||
return (uint)rng.Next(1, maxStack + 1);
|
||
return (uint)rng.Next(maxStack * 2 / 5, maxStack + 1);
|
||
}
|
||
|
||
private static int WeightedIndex(IReadOnlyList<int> weights, Random rng)
|
||
{
|
||
long total = 0;
|
||
foreach (var w in weights)
|
||
total += w;
|
||
if (total <= 0)
|
||
return weights.Count - 1;
|
||
|
||
var roll = rng.NextInt64(total);
|
||
long acc = 0;
|
||
for (var i = 0; i < weights.Count; i++)
|
||
{
|
||
acc += weights[i];
|
||
if (roll < acc)
|
||
return i;
|
||
}
|
||
return weights.Count - 1;
|
||
}
|
||
|
||
/// <summary>从某个随机组里抽一件物品配置 id。组不存在或为空时返回 0。</summary>
|
||
private static uint RollFromGroup(uint groupId, Random rng)
|
||
{
|
||
var group = ExtractionShooterCatalog4229938.Group(groupId);
|
||
if (group is null || group.Items.Count == 0)
|
||
return 0;
|
||
var idx = WeightedIndex(group.Items.Select(i => i.Weight).ToList(), rng);
|
||
return group.Items[idx].ItemId;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 战利品的确定性 RNG:同一个容器实例在这一局内怎么搜都一样,换一局重新洗
|
||
/// (盐来自 <see cref="ExtractionShooterCatalog4229938.SessionSalt"/>)。
|
||
/// </summary>
|
||
private static Random RngFor(ulong instanceId)
|
||
{
|
||
var mixed = unchecked((long)(instanceId ^ ExtractionShooterCatalog4229938.SessionSalt));
|
||
// Random 的种子是 int —— 把两个 32 位折起来,别让低位差异被高位吃掉。
|
||
return new Random(unchecked((int)(mixed ^ (mixed >> 32))));
|
||
}
|
||
|
||
// ------------------------------------------------------------------ containers
|
||
|
||
/// <summary>
|
||
/// 刷一个容器(已存在就直接返回)。<b>容量选择不加盐</b>(同一个箱子应该一直是那个格子数),
|
||
/// <b>内容加盐</b>(跨局重刷)。返回 null = 候选池为空,这个箱子真是空的。
|
||
/// </summary>
|
||
internal static ExtractionShooterContainerState? EnsureContainer(
|
||
ExtractionShooterSessionState owner, ulong instanceId)
|
||
{
|
||
lock (owner.SyncRoot)
|
||
{
|
||
if (owner.Containers.TryGetValue(instanceId, out var existing))
|
||
return existing;
|
||
|
||
var meta = ExtractionShooterCatalog4229938.PickContainer(instanceId);
|
||
if (meta is null)
|
||
return null;
|
||
|
||
var rng = RngFor(instanceId);
|
||
var state = new ExtractionShooterContainerState
|
||
{
|
||
InstanceId = instanceId,
|
||
CfgId = meta.Id,
|
||
SizeX = meta.CapacityX,
|
||
SizeY = meta.CapacityY,
|
||
};
|
||
state.RebuildOccupancy();
|
||
|
||
var target = meta.QuantityMin;
|
||
if (meta.QuantityMax > meta.QuantityMin)
|
||
target = rng.Next(meta.QuantityMin, meta.QuantityMax + 1);
|
||
target = Math.Max(target, 0);
|
||
var clamp = PrivateServerConfigStore.Current.World.Content.Extraction.MaxRollPer;
|
||
if (clamp > 0 && target > clamp)
|
||
target = clamp;
|
||
|
||
var chosen = new List<uint>();
|
||
|
||
// 必刷优先:这些是关卡设计要保证的钥匙类道具。
|
||
foreach (var certain in meta.CertainItemList)
|
||
if (certain != 0)
|
||
chosen.Add(certain);
|
||
|
||
while (chosen.Count < target && meta.RandomItemGroups.Count > 0)
|
||
{
|
||
var gi = WeightedIndex(meta.RandomItemGroups.Select(g => g.Weight).ToList(), rng);
|
||
var rolled = RollFromGroup(meta.RandomItemGroups[gi].GroupId, rng);
|
||
if (rolled == 0)
|
||
break; // 组是空的,再抽也是空 —— 别死循环。
|
||
chosen.Add(rolled);
|
||
}
|
||
|
||
foreach (var configId in chosen)
|
||
{
|
||
var itemMeta = ExtractionShooterCatalog4229938.Item(configId);
|
||
if (itemMeta is null)
|
||
continue;
|
||
|
||
if (!state.Grid.TryFirstFitWithRotation(itemMeta.VolumeX, itemMeta.VolumeY,
|
||
out var x, out var y, out var rotated))
|
||
continue; // 箱子放不下了 —— 客户端也是这么截断的。
|
||
|
||
var item = new ExtractionShooterItem
|
||
{
|
||
ConfigId = configId,
|
||
StackCount = StackCountForLocked(itemMeta.MaxStackNum, rng),
|
||
};
|
||
item.PlaceAt(x, y, rotated);
|
||
state.Grid.Occupy(x, y, item.Footprint().W, item.Footprint().H);
|
||
state.Items.Add(item);
|
||
state.RevealDelaySeconds = Math.Max(state.RevealDelaySeconds, itemMeta.SearchTime);
|
||
}
|
||
|
||
owner.Containers[instanceId] = state;
|
||
return state;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 搜索一次容器。返回 false = 已经搜过了(或容器不存在)—— 客户端重复搜不该再揭一遍。
|
||
///
|
||
/// ⛑ 时间戳<b>必须落在未来</b>(now + 每件自己的 SearchTime)。
|
||
/// </summary>
|
||
internal static bool TrySearchContainer(ExtractionShooterSessionState owner, ulong instanceId,
|
||
ulong pid, uint nowSeconds)
|
||
{
|
||
var state = EnsureContainer(owner, instanceId);
|
||
if (state is null)
|
||
return false;
|
||
|
||
lock (owner.SyncRoot)
|
||
{
|
||
if (state.SearchFinish.ContainsKey(pid))
|
||
return false;
|
||
|
||
var finish = (uint)(nowSeconds + Math.Max(1, state.RevealDelaySeconds));
|
||
foreach (var item in state.Items)
|
||
{
|
||
var meta = ExtractionShooterCatalog4229938.Item(item.ConfigId);
|
||
if (meta is not null)
|
||
finish = Math.Max(finish, nowSeconds + (uint)Math.Max(1, meta.SearchTime));
|
||
}
|
||
|
||
// 时间戳挂在玩家自己的 pid 上 ⇒ 多人各自进度独立。
|
||
state.SearchFinish[pid] = finish;
|
||
return true;
|
||
}
|
||
}
|
||
|
||
internal static bool IsSearchFinished(ExtractionShooterContainerState container, ulong pid, uint nowSeconds)
|
||
=> container.SearchFinish.TryGetValue(pid, out var until) && nowSeconds >= until;
|
||
|
||
/// <summary>这个 pid 在这个箱子上是否已经揭晓过(揭晓过就不用再补推)。</summary>
|
||
internal static bool IsContainerRevealed(ExtractionShooterSessionState owner, ulong instanceId, ulong pid)
|
||
{
|
||
lock (owner.SyncRoot)
|
||
return owner.Containers.TryGetValue(instanceId, out var state) && state.Revealed.Contains(pid);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 读条时长(秒)。取整箱最长的那件 SearchTime,至少 1 秒 —— 传 0 会立刻揭晓,读条动画一帧不播。
|
||
/// </summary>
|
||
internal static int ContainerSearchDelaySeconds(ExtractionShooterSessionState owner, ulong instanceId)
|
||
{
|
||
lock (owner.SyncRoot)
|
||
return owner.Containers.TryGetValue(instanceId, out var state)
|
||
? Math.Max(1, state.RevealDelaySeconds)
|
||
: 0;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 把一个箱子对某个 pid 标成「已搜完」:把完成时间戳翻到<b>过去</b>(now - 5),
|
||
/// 客户端据此把物品从「读条中」切成「可拿」。
|
||
///
|
||
/// 返回 false = 容器不存在 / 本来就揭过了 —— 调用方据此跳过补推,别白推一帧。
|
||
/// </summary>
|
||
internal static bool TryRevealContainer(ExtractionShooterSessionState owner, ulong instanceId,
|
||
ulong pid, uint nowSeconds)
|
||
{
|
||
lock (owner.SyncRoot)
|
||
{
|
||
if (!owner.Containers.TryGetValue(instanceId, out var state))
|
||
return false;
|
||
if (!state.Revealed.Add(pid))
|
||
return false;
|
||
state.SearchFinish[pid] = nowSeconds > 5 ? nowSeconds - 5 : 0u;
|
||
return true;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 容器的 wire payload。未搜索(<c>SearchFinish</c> 为空)时字典留空 —— 客户端据此隐藏图标。
|
||
/// 每一项都挂同一份「谁搜完了」的字典:客户端是按项分发的。
|
||
/// </summary>
|
||
internal static SceneMethods.ExtractionShooterContainerInfo BuildContainerInfo(
|
||
ExtractionShooterContainerState container)
|
||
{
|
||
var info = new SceneMethods.ExtractionShooterContainerInfo { CfgId = container.CfgId };
|
||
|
||
lock (container.Items)
|
||
{
|
||
foreach (var item in container.Items)
|
||
{
|
||
var row = new SceneMethods.ExtractionShooterContainerItemInfo
|
||
{
|
||
Id = ExtractionShooterCatalog4229938.WireIdFor(item.ConfigId),
|
||
StackCount = item.StackCount,
|
||
CellX = (uint)item.CellX,
|
||
CellY = (uint)item.CellY,
|
||
IsRotated = item.IsRotated,
|
||
IsLocked = item.IsLocked,
|
||
BindPid = item.BindPid,
|
||
ShieldValue = 0f,
|
||
WeaponData = null,
|
||
CollectionBook = null,
|
||
};
|
||
foreach (var kv in container.SearchFinish)
|
||
row.Pid2SearchFinishTimeDict[kv.Key] = kv.Value;
|
||
info.ItemList.Add(row);
|
||
}
|
||
}
|
||
|
||
return info;
|
||
}
|
||
|
||
// ------------------------------------------------------------------ bag ops
|
||
|
||
internal static ExtractionShooterItem? ItemAt(ExtractionShooterBagState bag, int x, int y)
|
||
=> ExtractionShooterGrid.ItemAt(bag.Items, x, y);
|
||
|
||
private static bool TryFit(ExtractionShooterBagState bag, uint configId, bool rotated,
|
||
int atX, int atY)
|
||
{
|
||
var meta = ExtractionShooterCatalog4229938.Item(configId);
|
||
if (meta is null)
|
||
return false;
|
||
var (w, h) = ExtractionGrid4229938.Rotate(meta.VolumeX, meta.VolumeY, rotated);
|
||
return bag.Grid.CanPlace(atX, atY, w, h);
|
||
}
|
||
|
||
private static bool TryFitFree(ExtractionShooterBagState bag, uint configId,
|
||
out int x, out int y, out bool rotated)
|
||
{
|
||
var meta = ExtractionShooterCatalog4229938.Item(configId);
|
||
if (meta is null)
|
||
{
|
||
x = y = 0;
|
||
rotated = false;
|
||
return false;
|
||
}
|
||
return bag.Grid.TryFirstFitWithRotation(meta.VolumeX, meta.VolumeY, out x, out y, out rotated);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 六合一搬运(handoff §6.4「装备 / 卸下 / 放入安全箱 / 携带 / 放回仓库 / 旋转」全走这一条):
|
||
/// 同包同格 = 旋转、同包异格 = 挪、跨包 = 搬。
|
||
/// </summary>
|
||
internal static bool TryMoveBagItem(ExtractionShooterSessionState owner,
|
||
uint srcBag, int fromX, int fromY, uint destBag, int toX, int toY, bool rotated)
|
||
{
|
||
lock (owner.SyncRoot)
|
||
{
|
||
if (!owner.Bags.TryGetValue(srcBag, out var src) || !owner.Bags.TryGetValue(destBag, out var dst))
|
||
return false;
|
||
|
||
var item = ItemAt(src, fromX, fromY);
|
||
if (item is null)
|
||
return false;
|
||
|
||
var meta = ExtractionShooterCatalog4229938.Item(item.ConfigId);
|
||
if (meta is null)
|
||
return false;
|
||
|
||
var (oldW, oldH) = item.Footprint();
|
||
var (newW, newH) = ExtractionGrid4229938.Rotate(meta.VolumeX, meta.VolumeY, rotated);
|
||
|
||
if (ReferenceEquals(src, dst))
|
||
{
|
||
src.Grid.Release(item.CellX, item.CellY, oldW, oldH);
|
||
if (!src.Grid.CanPlace(toX, toY, newW, newH))
|
||
{
|
||
src.Grid.Occupy(item.CellX, item.CellY, oldW, oldH);
|
||
return false;
|
||
}
|
||
src.Grid.Occupy(toX, toY, newW, newH);
|
||
item.PlaceAt(toX, toY, rotated);
|
||
return true;
|
||
}
|
||
|
||
// 跨包:目标包接不下就<b>不动源包</b>,避免物品凭空消失。
|
||
if (dst.Meta.AllowedItemTypes.Count > 0 && !dst.Meta.AllowedItemTypes.Contains((uint)meta.Type))
|
||
return false;
|
||
if (!dst.Grid.CanPlace(toX, toY, newW, newH))
|
||
return false;
|
||
|
||
src.Grid.Release(item.CellX, item.CellY, oldW, oldH);
|
||
src.Items.Remove(item);
|
||
dst.Grid.Occupy(toX, toY, newW, newH);
|
||
item.PlaceAt(toX, toY, rotated);
|
||
dst.Items.Add(item);
|
||
return true;
|
||
}
|
||
}
|
||
|
||
internal static bool TryRemoveBagItem(ExtractionShooterSessionState owner, uint bagId, int x, int y)
|
||
{
|
||
lock (owner.SyncRoot)
|
||
{
|
||
if (!owner.Bags.TryGetValue(bagId, out var bag))
|
||
return false;
|
||
var item = ItemAt(bag, x, y);
|
||
if (item is null)
|
||
return false;
|
||
// 绑定的一律不让扔:那是退还型的任务物品,客户端本来也不发这种请求。
|
||
if (item.BindPid != 0)
|
||
return false;
|
||
|
||
bag.Grid.Release(item.CellX, item.CellY, item.Footprint().W, item.Footprint().H);
|
||
bag.Items.Remove(item);
|
||
return true;
|
||
}
|
||
}
|
||
|
||
internal static bool TryLockBagItem(ExtractionShooterSessionState owner, uint bagId, int x, int y, bool locked)
|
||
{
|
||
lock (owner.SyncRoot)
|
||
{
|
||
if (!owner.Bags.TryGetValue(bagId, out var bag))
|
||
return false;
|
||
var item = ItemAt(bag, x, y);
|
||
if (item is null)
|
||
return false;
|
||
item.IsLocked = locked;
|
||
return true;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 拆分:在原位的邻位再生成一件。
|
||
/// </summary>
|
||
internal static bool TrySplitBagItem(ExtractionShooterSessionState owner, uint bagId, int x, int y,
|
||
uint takeCount, bool rot)
|
||
{
|
||
lock (owner.SyncRoot)
|
||
{
|
||
if (!owner.Bags.TryGetValue(bagId, out var bag))
|
||
return false;
|
||
var item = ItemAt(bag, x, y);
|
||
if (item is null || item.StackCount <= 1)
|
||
return false;
|
||
|
||
takeCount = Math.Min(takeCount == 0 ? item.StackCount - 1 : takeCount, item.StackCount - 1);
|
||
if (takeCount == 0)
|
||
return false;
|
||
|
||
if (!TryFitFree(bag, item.ConfigId, out var nx, out var ny, out var rotated))
|
||
return false;
|
||
|
||
item.StackCount -= takeCount;
|
||
var split = new ExtractionShooterItem
|
||
{
|
||
ConfigId = item.ConfigId,
|
||
StackCount = takeCount,
|
||
};
|
||
split.PlaceAt(nx, ny, rotated);
|
||
bag.Grid.Occupy(nx, ny, split.Footprint().W, split.Footprint().H);
|
||
bag.Items.Add(split);
|
||
return true;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 整理:按占格面积降序重排(大件先塞)—— 这是所有背包 UI 的玩家直觉。
|
||
/// 排完必须整包重推:客户端的渲染序号与服务端的 CellX/CellY 已经不同了。
|
||
/// </summary>
|
||
internal static bool TrySortBag(ExtractionShooterSessionState owner, uint bagId)
|
||
{
|
||
lock (owner.SyncRoot)
|
||
{
|
||
if (!owner.Bags.TryGetValue(bagId, out var bag))
|
||
return false;
|
||
// 轮盘 / 战备 / 扩容是按 CellX 当槽位序号用的,重排会把"第几格"打乱。
|
||
if (bag.IsSlotGroup)
|
||
return false;
|
||
|
||
var ordered = bag.Items
|
||
.OrderByDescending(i => { var f = i.Footprint(); return f.W * f.H; })
|
||
.ThenBy(i => i.ConfigId)
|
||
.ToList();
|
||
|
||
bag.Items.Clear();
|
||
bag.RebuildOccupancy();
|
||
|
||
foreach (var item in ordered)
|
||
{
|
||
if (!TryFitFree(bag, item.ConfigId, out var x, out var y, out var rotated))
|
||
continue; // 理论上不会发生:这些件本来就在包里。
|
||
item.PlaceAt(x, y, rotated);
|
||
bag.Grid.Occupy(x, y, item.Footprint().W, item.Footprint().H);
|
||
bag.Items.Add(item);
|
||
}
|
||
return true;
|
||
}
|
||
}
|
||
|
||
/// <summary>找第一个能放下的空位,生成一件新物品(GM 发物品用)。</summary>
|
||
internal static ExtractionShooterItem? TryAddItem(ExtractionShooterSessionState owner, uint bagId,
|
||
uint configId, uint stackCount)
|
||
{
|
||
var meta = ExtractionShooterCatalog4229938.Item(configId);
|
||
if (meta is null)
|
||
return null;
|
||
|
||
lock (owner.SyncRoot)
|
||
{
|
||
if (!owner.Bags.TryGetValue(bagId, out var bag))
|
||
return null;
|
||
if (bag.Meta.AllowedItemTypes.Count > 0 && !bag.Meta.AllowedItemTypes.Contains((uint)meta.Type))
|
||
return null;
|
||
if (!TryFitFree(bag, configId, out var x, out var y, out var rotated))
|
||
return null;
|
||
|
||
var item = new ExtractionShooterItem
|
||
{
|
||
ConfigId = configId,
|
||
StackCount = stackCount == 0 ? StackCountForLocked(meta.MaxStackNum, Random.Shared) : stackCount,
|
||
};
|
||
item.PlaceAt(x, y, rotated);
|
||
bag.Grid.Occupy(x, y, item.Footprint().W, item.Footprint().H);
|
||
bag.Items.Add(item);
|
||
return item;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 容器 → 背包 / 仓库(67257572)。
|
||
///
|
||
/// ⛑ 参数是客户端发的那一串:<c>container, fromX, fromY, destBag, toX, toY, isRotated</c>。
|
||
/// 第一版把第 4 个字段当成 "源 x",导致 destBag 读到的是 fromX(垃圾值)⇒ 任何搬运都失败
|
||
/// —— 这就是捐赠端实机看到的「容器里的东西搬不到背包 / 双击像复制一份」(§14.8 / §14.10)。
|
||
///
|
||
/// toX / toY 是客户端自己算好的落点。放得下就用它;放不下退回 first-fit ——
|
||
/// 客户端也会自己校验一遍,两边不一致的表现是「拖过去弹回」,所以服务端宽容一点没坏处。
|
||
/// </summary>
|
||
internal static bool TryShiftContainerItemToBag(ExtractionShooterSessionState owner, ulong instanceId,
|
||
int fromX, int fromY, uint destBag, int toX, int toY, bool isRotated)
|
||
{
|
||
var container = EnsureContainer(owner, instanceId);
|
||
if (container is null)
|
||
return false;
|
||
|
||
lock (owner.SyncRoot)
|
||
{
|
||
var srcItem = ExtractionShooterGrid.ItemAt(container.Items, fromX, fromY);
|
||
if (srcItem is null)
|
||
return false;
|
||
if (!owner.Bags.TryGetValue(destBag, out var dst))
|
||
return false;
|
||
// meta 取不到就别放行 —— 后面的 Footprint / 类型过滤都要用它,
|
||
// donor 当年就是这里 NRE 崩在处理器里(§14.8「顺带修的」)。
|
||
if (ExtractionShooterCatalog4229938.Item(srcItem.ConfigId) is null)
|
||
return false;
|
||
if (dst.Meta.AllowedItemTypes.Count > 0
|
||
&& !dst.Meta.AllowedItemTypes.Contains((uint)(
|
||
ExtractionShooterCatalog4229938.Item(srcItem.ConfigId)?.Type ?? -1)))
|
||
return false;
|
||
|
||
if (!TryPlaceIntoBag(dst, srcItem.ConfigId, toX, toY, isRotated, out var dx, out var dy, out var rotated))
|
||
return false; // 背包塞不下 —— 容器里那件保持不动。
|
||
|
||
container.Items.Remove(srcItem);
|
||
container.RebuildOccupancy();
|
||
srcItem.PlaceAt(dx, dy, rotated);
|
||
dst.Grid.Occupy(dx, dy, srcItem.Footprint().W, srcItem.Footprint().H);
|
||
dst.Items.Add(srcItem);
|
||
return true;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 容器 → 轮盘 / 战备槽(67688819):<c>container, fromX, fromY, slotBag, slotIndex</c>。
|
||
///
|
||
/// 槽位组的约定(客户端 <c>AskExtractionShooterShiftSlotItemToContainer(bag, itemInfo.CellX, …)</c> 为证):
|
||
/// <b><c>itemInfo.CellX</c> 就是槽位下标</b>,所以这里 <c>CellX = slotIndex、CellY = 0、不旋转</c>。
|
||
/// </summary>
|
||
internal static bool TryShiftContainerItemToSlot(ExtractionShooterSessionState owner, ulong instanceId,
|
||
int fromX, int fromY, uint slotBag, uint slotIndex)
|
||
{
|
||
lock (owner.SyncRoot)
|
||
{
|
||
var container = EnsureContainer(owner, instanceId);
|
||
if (container is null)
|
||
return false;
|
||
|
||
var srcItem = ExtractionShooterGrid.ItemAt(container.Items, fromX, fromY);
|
||
if (srcItem is null)
|
||
return false;
|
||
if (!owner.Bags.TryGetValue(slotBag, out var dst))
|
||
return false;
|
||
if (ExtractionShooterCatalog4229938.Item(srcItem.ConfigId) is null)
|
||
return false;
|
||
|
||
dst.RebuildOccupancy();
|
||
var (w, h) = ExtractionShooterItem.FootprintOf(srcItem.ConfigId, false);
|
||
if (!dst.Grid.CanPlace((int)slotIndex, 0, w, h))
|
||
return false;
|
||
|
||
container.Items.Remove(srcItem);
|
||
container.RebuildOccupancy();
|
||
srcItem.PlaceAt((int)slotIndex, 0, false);
|
||
dst.Grid.Occupy((int)slotIndex, 0, w, h);
|
||
dst.Items.Add(srcItem);
|
||
return true;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 背包 → 容器(67512840):<c>srcBag, fromX, fromY, container, toX, toY, isRotated</c>。
|
||
/// ⛑ 这条是 <b>bag 在前</b> —— 和 <see cref="TryShiftContainerItemToBag"/> 刚好是镜像。
|
||
/// </summary>
|
||
internal static bool TryShiftBagItemToContainer(ExtractionShooterSessionState owner, uint srcBag,
|
||
int fromX, int fromY, ulong instanceId, int toX, int toY, bool isRotated)
|
||
{
|
||
lock (owner.SyncRoot)
|
||
{
|
||
if (!owner.Bags.TryGetValue(srcBag, out var src))
|
||
return false;
|
||
var item = ItemAt(src, fromX, fromY);
|
||
if (item is null)
|
||
return false;
|
||
|
||
var container = EnsureContainer(owner, instanceId);
|
||
if (container is null)
|
||
return false;
|
||
|
||
container.RebuildOccupancy();
|
||
if (!TryPlaceIntoContainer(container, item, toX, toY, isRotated))
|
||
return false;
|
||
|
||
src.Items.Remove(item);
|
||
src.RebuildOccupancy();
|
||
return true;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 轮盘 / 战备槽 → 容器(67054596):<c>srcBag, fromSlotIndex, container, toX, toY, isRotated</c>。
|
||
/// 源那一侧的坐标只有一个槽位下标(<c>CellX = 槽位下标、CellY = 0</c>,见
|
||
/// <see cref="TryShiftContainerItemToSlot"/> 的说明)—— 没有单独的 y。
|
||
/// </summary>
|
||
internal static bool TryShiftSlotItemToContainer(ExtractionShooterSessionState owner, uint srcBag,
|
||
uint fromSlotIndex, ulong instanceId, int toX, int toY, bool isRotated)
|
||
=> TryShiftBagItemToContainer(owner, srcBag, (int)fromSlotIndex, 0, instanceId, toX, toY, isRotated);
|
||
|
||
/// <summary>
|
||
/// 容器内挪位 / 原地旋转(67007990):<c>container, fromX, fromY, toX, toY, isRotated</c>。
|
||
///
|
||
/// 做法和背包挪位一致:先摘下来重建占位图,再按新位置试放,放不下就<b>放回原位</b>
|
||
/// —— 直接改坐标而不重算占位是「两件物品叠在一起」的经典来源。
|
||
/// </summary>
|
||
internal static bool TryMoveContainerItem(ExtractionShooterSessionState owner, ulong instanceId,
|
||
int fromX, int fromY, int toX, int toY, bool isRotated)
|
||
{
|
||
lock (owner.SyncRoot)
|
||
{
|
||
var container = EnsureContainer(owner, instanceId);
|
||
if (container is null)
|
||
return false;
|
||
|
||
var item = ExtractionShooterGrid.ItemAt(container.Items, fromX, fromY);
|
||
if (item is null)
|
||
return false;
|
||
|
||
container.Items.Remove(item);
|
||
container.RebuildOccupancy();
|
||
|
||
if (TryPlaceIntoContainer(container, item, toX, toY, isRotated))
|
||
return true;
|
||
|
||
// 放不下 —— 按原位恢复,别让东西凭空消失。
|
||
TryPlaceIntoContainer(container, item, fromX, fromY, item.IsRotated);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// <summary>容器内删除(67825663):<c>container, x, y</c>。</summary>
|
||
internal static bool TryRemoveContainerItem(ExtractionShooterSessionState owner, ulong instanceId, int x, int y)
|
||
{
|
||
lock (owner.SyncRoot)
|
||
{
|
||
var container = EnsureContainer(owner, instanceId);
|
||
if (container is null)
|
||
return false;
|
||
|
||
var item = ExtractionShooterGrid.ItemAt(container.Items, x, y);
|
||
if (item is null)
|
||
return false;
|
||
|
||
container.Items.Remove(item);
|
||
container.RebuildOccupancy();
|
||
return true;
|
||
}
|
||
}
|
||
|
||
/// <summary>往容器里放一件:优先用客户端给的格子,放不下退回 first-fit。</summary>
|
||
private static bool TryPlaceIntoContainer(ExtractionShooterContainerState container,
|
||
ExtractionShooterItem item, int toX, int toY, bool isRotated)
|
||
{
|
||
var meta = ExtractionShooterCatalog4229938.Item(item.ConfigId);
|
||
if (meta is null)
|
||
return false;
|
||
|
||
var (w, h) = ExtractionShooterItem.FootprintOf(item.ConfigId, isRotated);
|
||
if (container.Grid.CanPlace(toX, toY, w, h))
|
||
{
|
||
item.PlaceAt(toX, toY, isRotated);
|
||
container.Grid.Occupy(toX, toY, w, h);
|
||
container.Items.Add(item);
|
||
return true;
|
||
}
|
||
|
||
if (!container.Grid.TryFirstFitWithRotation(meta.VolumeX, meta.VolumeY, out var x, out var y, out var rotated))
|
||
return false;
|
||
|
||
item.PlaceAt(x, y, rotated);
|
||
container.Grid.Occupy(x, y, item.Footprint().W, item.Footprint().H);
|
||
container.Items.Add(item);
|
||
return true;
|
||
}
|
||
|
||
/// <summary>往包里放一件:优先用客户端给的格子,放不下退回 first-fit。</summary>
|
||
private static bool TryPlaceIntoBag(ExtractionShooterBagState bag, uint configId,
|
||
int toX, int toY, bool isRotated, out int outX, out int outY, out bool outRotated)
|
||
{
|
||
var meta = ExtractionShooterCatalog4229938.Item(configId);
|
||
if (meta is not null)
|
||
{
|
||
var (w, h) = ExtractionShooterItem.FootprintOf(configId, isRotated);
|
||
if (bag.Grid.CanPlace(toX, toY, w, h))
|
||
{
|
||
outX = toX;
|
||
outY = toY;
|
||
outRotated = isRotated;
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return TryFitFree(bag, configId, out outX, out outY, out outRotated);
|
||
}
|
||
|
||
// ⛑ 旧的 TryPutIntoContainer / TryTakeFromContainer 已被上面这五个取代:
|
||
// 它们少了 fromX / fromY(不知道从哪格拿)和 toX / toY(客户端算好的落点),
|
||
// 而且服务端自己黑箱 first-fit 会和客户端的落点不一致。留着只是等着被误调用。
|
||
|
||
/// <summary>
|
||
/// 「全部入仓」:背包 / 安全箱里所有东西挪进一号仓库,塞不下的原地留住。
|
||
/// </summary>
|
||
internal static int TryTransferAllToStash(ExtractionShooterSessionState owner)
|
||
{
|
||
lock (owner.SyncRoot)
|
||
{
|
||
if (!owner.Bags.TryGetValue(ExtractionShooterCatalog4229938.FirstStashBagId, out var stash))
|
||
return 0;
|
||
|
||
var moved = 0;
|
||
foreach (var bagId in new[]
|
||
{
|
||
ExtractionShooterCatalog4229938.BackpackBagId,
|
||
ExtractionShooterCatalog4229938.SafeBoxBagId,
|
||
})
|
||
{
|
||
if (!owner.Bags.TryGetValue(bagId, out var bag))
|
||
continue;
|
||
|
||
var snapshot = bag.Items.ToList();
|
||
bag.Items.Clear();
|
||
bag.RebuildOccupancy();
|
||
|
||
foreach (var item in snapshot)
|
||
{
|
||
if (!TryFitFree(stash, item.ConfigId, out var x, out var y, out var rotated))
|
||
continue; // 仓库满了,这件留在原地。
|
||
item.PlaceAt(x, y, rotated);
|
||
stash.Grid.Occupy(x, y, item.Footprint().W, item.Footprint().H);
|
||
stash.Items.Add(item);
|
||
moved++;
|
||
}
|
||
}
|
||
return moved;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 扩容。只有 Type=1(给 <c>BelongBagId</c> 加一行)会影响格数;
|
||
/// Type=2 是解锁一个新的仓库包,交给调用方去 <see cref="Seed"/> 之外补一条 UnlockedBags。
|
||
/// 返回受影响的目标包 id,0 = 这条扩容不存在。
|
||
/// </summary>
|
||
internal static (uint BagId, int NewSizeY) TryExpandBag(ExtractionShooterSessionState owner, uint expansionId)
|
||
{
|
||
var meta = ExtractionShooterCatalog4229938.Expansions.FirstOrDefault(e => e.Id == expansionId);
|
||
if (meta is null)
|
||
return (0, 0);
|
||
|
||
lock (owner.SyncRoot)
|
||
{
|
||
owner.UnlockedExpansions.Add(expansionId);
|
||
|
||
if (meta.Type == 2 && meta.BagAddId != 0)
|
||
{
|
||
owner.UnlockedBags.Add(meta.BagAddId);
|
||
return (meta.BagAddId, 0);
|
||
}
|
||
|
||
if (meta.Type != 1 || meta.BelongBagId == 0)
|
||
return (meta.BelongBagId, 0);
|
||
if (!owner.Bags.TryGetValue(meta.BelongBagId, out var bag))
|
||
return (meta.BelongBagId, 0);
|
||
|
||
// 加一行 → Y +1(表里 Type=1 的条目 ExpandGrids 都是 {0,1})。
|
||
var rows = Math.Max(1, meta.ExpandY);
|
||
bag.Meta = bag.Meta with { CapacityY = bag.Meta.CapacityY + rows };
|
||
bag.RebuildOccupancy();
|
||
return (bag.BagId, bag.Meta.CapacityY);
|
||
}
|
||
}
|
||
|
||
/// <summary>背包容量的 wire 参数:[x, y] 两个 i32(不是「每格写一个 1」)。</summary>
|
||
internal static List<int> CapacityOf(ExtractionShooterBagState bag)
|
||
=> [bag.Meta.CapacityX, bag.Meta.CapacityY];
|
||
|
||
// ================================================================== 武器轮盘(第二轮)
|
||
|
||
/// <summary>
|
||
/// CurrWeaponSlots 里轮盘武器之前的固定前导长度:下标 0 = 拳头、下标 1 = 私有武器。
|
||
///
|
||
/// ⛑ 三处独立证据都指向「0 是拳头、1 是私有武器」,不是推的:
|
||
/// · 客户端 WeaponManager.lua 顶部常量 FistWeaponIndex = 1 / PrivateWeaponIndex = 2,
|
||
/// 发送时统一 AskSwitchWeapon(Index - 1) ⇒ 线上 index 0 = 拳头、1 = 私有武器;
|
||
/// · SyncCurrWeaponSlotsAdd(SlotIndex, Weapon) 写的是 CurrWeaponSlots[SlotIndex + 1],同一个口径;
|
||
/// · 服务端自己的 CombatLoadout.Slots 也是 [0] = 拳头、[1] = 私有武器。
|
||
/// 所以轮盘 CellX c 落在下标 c + 2。
|
||
///
|
||
/// ⚠️ 别写成 1:那样轮盘第一格会压在私有武器那一格上,Tab 切「私有武器」时掏出的是轮盘第一把枪,
|
||
/// 而 AskSwitchWeaponToPrivateWeapon 会直接打空返回。
|
||
/// </summary>
|
||
internal const int WheelHomeOffset4229938 = 2;
|
||
|
||
/// <summary>
|
||
/// 轮盘里能放武器的格数(CellX 0..N-1)。
|
||
/// WheelWeaponCfg(99952004)SlotCapacity{initial:8, max:8} —— 8 个槽位里有 1 个是私有武器槽
|
||
/// (客户端 GetCellIndexBySlotIndex 把它扣掉了)⇒ 真正能放轮盘武器的是 7 格。
|
||
/// </summary>
|
||
internal static int WheelSlotCells4229938
|
||
{
|
||
get
|
||
{
|
||
var bag = ExtractionShooterCatalog4229938.Bags
|
||
.FirstOrDefault(b => b.Id == ExtractionShooterCatalog4229938.WheelBagId);
|
||
var slots = bag is null ? 8 : bag.SlotMax;
|
||
return Math.Clamp(slots - 1, 1, 16);
|
||
}
|
||
}
|
||
|
||
/// <summary>轮盘里的一件武器:格子 + 线上物品 id + SceneitemConfig 模板 + 账号军械实例 id。</summary>
|
||
internal readonly record struct WheelWeapon(
|
||
uint CellX, uint WireItemId, uint SceneItemTemplateId, ulong InstanceId);
|
||
|
||
/// <summary>
|
||
/// 轮盘 bag 里当前所有「能实体化成武器」的物品,按 CellX 升序。
|
||
///
|
||
/// 牛奶 / 药剂这类也允许放进轮盘,但它们的 SceneitemConfig 不是军械武器
|
||
/// (FightSkillType = 0 ⇒ AccountInstanceId 返回 0)⇒ 会被过滤掉,
|
||
/// 客户端那边仍然正常显示为轮盘物品,只是不参与 Tab 切枪。
|
||
/// </summary>
|
||
internal static IReadOnlyList<WheelWeapon> WheelWeapons(ExtractionShooterSessionState owner)
|
||
{
|
||
lock (owner.SyncRoot)
|
||
{
|
||
if (!owner.Bags.TryGetValue(ExtractionShooterCatalog4229938.WheelBagId, out var bag)
|
||
|| bag.Items.Count == 0)
|
||
return [];
|
||
|
||
var result = new List<WheelWeapon>(bag.Items.Count);
|
||
foreach (var item in bag.Items)
|
||
{
|
||
var wire = ExtractionShooterCatalog4229938.WireIdFor(item.ConfigId);
|
||
var meta = ExtractionShooterCatalog4229938.Item(item.ConfigId);
|
||
var instanceId = ExtractionShooterCatalog4229938.WeaponInstanceOf(wire);
|
||
if (meta is null || meta.RealSceneItemModelId == 0 || instanceId == 0)
|
||
continue; // 不是武器:不实体化
|
||
result.Add(new WheelWeapon((uint)item.CellX, wire, meta.RealSceneItemModelId, instanceId));
|
||
}
|
||
|
||
result.Sort((a, b) => a.CellX.CompareTo(b.CellX));
|
||
return result;
|
||
}
|
||
}
|
||
|
||
/// <summary>轮盘 bag 里那一格上的物品(null = 空槽)。</summary>
|
||
internal static (uint ItemId, uint StackCount)? WheelItemAt(ExtractionShooterSessionState owner, uint cellX)
|
||
{
|
||
lock (owner.SyncRoot)
|
||
{
|
||
if (!owner.Bags.TryGetValue(ExtractionShooterCatalog4229938.WheelBagId, out var bag))
|
||
return null;
|
||
var slot = bag.Items.FirstOrDefault(i => i.CellX == cellX && i.CellY == 0);
|
||
return slot is null
|
||
? null
|
||
: (ExtractionShooterCatalog4229938.WireIdFor(slot.ConfigId), slot.StackCount);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 一件轮盘武器 → WeaponDetail。实例 id 复用账号军械库那一份(见 <see cref="WheelHomeOffset4229938"/> 注释里的坑 2)。
|
||
/// </summary>
|
||
private static SceneMethods.WeaponDetail? BuildWheelWeaponDetail(WheelWeapon weapon, uint spiritTemplateId)
|
||
{
|
||
var definition = CombatCatalogRepository.Weapon(weapon.InstanceId)
|
||
?? CombatCatalogRepository.Weapon(spiritTemplateId, weapon.InstanceId);
|
||
if (definition is null)
|
||
return null;
|
||
|
||
var detail = CombatCodec.WeaponDetail(definition);
|
||
detail.TemplateId = weapon.SceneItemTemplateId;
|
||
detail.InstanceId = weapon.InstanceId;
|
||
return detail;
|
||
}
|
||
|
||
/// <summary>轮盘里的全部武器实体化详情,按 CurrWeaponSlots 下标排好(下标 = CellX + 2)。</summary>
|
||
internal static Dictionary<int, SceneMethods.WeaponDetail> WheelWeaponDetails(
|
||
ExtractionShooterSessionState owner, uint spiritTemplateId)
|
||
{
|
||
var result = new Dictionary<int, SceneMethods.WeaponDetail>();
|
||
foreach (var weapon in WheelWeapons(owner))
|
||
{
|
||
if (BuildWheelWeaponDetail(weapon, spiritTemplateId) is { } detail)
|
||
result[(int)weapon.CellX + WheelHomeOffset4229938] = detail;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// SyncSpiritWeaponDetail(68255545) 的负载 —— 一次给出整张实体化武器表。
|
||
///
|
||
/// 表长固定为 2 + <see cref="WheelSlotCells4229938"/>:
|
||
/// · 下标 0 = 拳头。<b>必须给且非 null</b> —— 客户端 AskSwitchWeaponToFistWeapon 会先判
|
||
/// weapons[FistWeaponIndex] == nil 然后静默返回,空着就等于「切不回拳头」。
|
||
/// · 下标 1 = 私有武器。
|
||
/// · 下标 2..N+1 = 轮盘 CellX 0..N-1。空槽写 null。
|
||
/// 长度固定而不是按实际件数裁剪:Tab / 滚轮切枪只按 CurrWeaponSlots 的<b>位置</b>走,
|
||
/// 位置一抖轮盘顺序就跟着抖。
|
||
/// </summary>
|
||
/// <param name="prepare">
|
||
/// 每件非空武器在序列化前的最后一道加工。弹药 / 耐久 / 流派活在 WorldEntryState(Handler 层),
|
||
/// 本类不该知道,所以由调用方回调盖上去。
|
||
/// </param>
|
||
internal static SceneMethods.SpiritWeaponDetail BuildSpiritWeaponDetailBody(
|
||
ExtractionShooterSessionState owner,
|
||
uint spiritTemplateId,
|
||
ulong unitId,
|
||
ulong currentWeaponInstanceId,
|
||
SceneMethods.WeaponDetail? fistWeapon,
|
||
SceneMethods.WeaponDetail? privateWeapon,
|
||
Action<SceneMethods.WeaponDetail>? prepare = null)
|
||
{
|
||
var slots = new List<SceneMethods.WeaponDetail?>(WheelHomeOffset4229938 + WheelSlotCells4229938)
|
||
{
|
||
fistWeapon,
|
||
privateWeapon,
|
||
};
|
||
|
||
var byIndex = WheelWeaponDetails(owner, spiritTemplateId);
|
||
for (var cell = 0; cell < WheelSlotCells4229938; cell++)
|
||
{
|
||
var index = cell + WheelHomeOffset4229938;
|
||
slots.Add(byIndex.TryGetValue(index, out var detail) ? detail : null);
|
||
}
|
||
|
||
// 当前武器必须落在表里,否则客户端 OnSwitchCurrentWeapon 找不到它会打
|
||
// "Not Find Current Weapon"。落在轮盘武器上就保持,否则退回私有武器。
|
||
var current = currentWeaponInstanceId;
|
||
if (!slots.Any(s => s is not null && s.InstanceId == current))
|
||
current = privateWeapon?.InstanceId ?? fistWeapon?.InstanceId ?? 0UL;
|
||
|
||
var body = new SceneMethods.SpiritWeaponDetail
|
||
{
|
||
SpiritTid = spiritTemplateId,
|
||
SpiritUid = unitId,
|
||
CurrentWeaponUid = current,
|
||
WeaponSlots = slots,
|
||
CurrentTempWeapon = null,
|
||
TempWeaponSlots = null,
|
||
VirtualWeaponSlots = [],
|
||
};
|
||
|
||
if (prepare is not null)
|
||
{
|
||
foreach (var slot in body.WeaponSlots)
|
||
{
|
||
if (slot is not null)
|
||
prepare(slot);
|
||
}
|
||
}
|
||
|
||
return body;
|
||
}
|
||
|
||
/// <summary>
|
||
/// SyncSpiritAddWeaponAction(68055102) 的负载 —— 单件增量。
|
||
/// SlotIndex 是 <b>0 基</b>下标,轮盘 CellX c 对应 c + 2(0/1 是拳头和私有武器,不该被增量覆盖)。
|
||
/// </summary>
|
||
internal static byte[] BuildAddWheelWeaponPayload(
|
||
uint spiritTemplateId, ulong unitId, WheelWeapon weapon,
|
||
Action<SceneMethods.WeaponDetail>? prepare = null)
|
||
{
|
||
var detail = BuildWheelWeaponDetail(weapon, spiritTemplateId)
|
||
?? throw new InvalidOperationException($"轮盘武器 {weapon.WireItemId} 无法解析成军械武器");
|
||
prepare?.Invoke(detail);
|
||
|
||
return UxSerializer.Serialize(new SceneMethods.SpiritAddWeaponAction
|
||
{
|
||
SpiritTid = spiritTemplateId,
|
||
SpiritUid = unitId,
|
||
SlotIndex = (int)weapon.CellX + WheelHomeOffset4229938,
|
||
Weapon = detail,
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// SyncSpiritRemoveWeaponAction(68663624) 的负载 —— 单件增量移除。
|
||
/// Key 是 WeaponUid(InstanceId),Reason = Remove(5) —— 只卸下,不改语义为「丢到地上」。
|
||
/// </summary>
|
||
internal static byte[] BuildRemoveWheelWeaponPayload(uint spiritTemplateId, ulong unitId, ulong instanceId)
|
||
=> UxSerializer.Serialize(CombatCodec.SpiritRemoveWeapon(spiritTemplateId, unitId, instanceId));
|
||
|
||
/// <summary>
|
||
/// 轮盘物品要不要带 <c>WeaponData</c> 的<b>运行时值</b>。
|
||
///
|
||
/// 面板改这个比改配置省事(不用改 json、不用重启),GM 面板那条 checkbox 走的就是这里的 setter。
|
||
/// 没动过时 null ⇒ 回落到 <c>world.content.extraction.wheelWeaponData</c>,重启就恢复配置值。
|
||
/// 关掉它专门用于回退验证:枪的 tooltip 会退化成普通物品描述,能一眼看出 tooltip 那条链路
|
||
/// 到底走的是 <c>ItemInfo.WeaponData</c> 还是虚构出来的东西。
|
||
/// </summary>
|
||
private static bool? s_wheelWeaponDataOverride;
|
||
|
||
internal static bool WheelWeaponDataEnabled
|
||
{
|
||
get => s_wheelWeaponDataOverride
|
||
?? PrivateServerConfigStore.Current.World.Content.Extraction.WheelWeaponData;
|
||
set => s_wheelWeaponDataOverride = value;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 轮盘物品要带的 ItemInfo.WeaponData(可为 null)。只对 ReadInfoType == 1 的类型填
|
||
/// (实测就是武器与医疗道具),且必须能解析出军械实例 id。
|
||
/// </summary>
|
||
internal static Ananta.Server.RpcTypes.Client4229938.Auto.WeaponData? WheelWeaponData(uint wireId)
|
||
{
|
||
if (!WheelWeaponDataEnabled)
|
||
return null;
|
||
|
||
var meta = ExtractionShooterCatalog4229938.ItemByWire(wireId);
|
||
if (meta is null || !ExtractionShooterCatalog4229938.CarriesInfo(meta))
|
||
return null;
|
||
|
||
var instanceId = ExtractionShooterCatalog4229938.WeaponInstanceOf(wireId);
|
||
if (instanceId == 0)
|
||
return null;
|
||
|
||
var definition = CombatCatalogRepository.Weapon(instanceId);
|
||
return definition is null ? null : RuntimePayloadFactory.WeaponData(definition);
|
||
}
|
||
}
|