using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
///
/// Гео-оссуарий (1.6.0, 2026-09-30): станция добычи ресурсов на кровавой сфере. Блок -
/// necroGeoOssuary в blocks.xml, наследник ванильного коллектора росы; всё, что коллектор
/// умеет сам (окно, топливо, таймеры, работа без игрока рядом), остаётся его. Здесь только то,
/// чего ваниль не умеет, - разобрано по декомпилятору 3.3:
///
/// - ЯЧЕЙКА МОДИФИКАЦИИ ПРИНИМАЕТ ОДИН ПРЕДМЕТ. XUiC_DewCollectorModGrid.SetTileEntity
/// зовёт SetAllowedItemClassSingle(RequiredMods[i]). У оссуария в любую из трёх ячеек идёт
/// любой из пяти ресурсов, по одной штуке (OverrideStackCount = 1). Окно у всех
/// коллекторов одно и то же, поэтому для чужих коллекторов предел штук сбрасывается
/// обратно - иначе коллектор росы после оссуария тоже брал бы по одной.
/// - ВАНИЛЬ НЕ СМОТРИТ, ЧТО ЛЕЖИТ В ЯЧЕЙКЕ. ModTypes=Type - просто флаг HasModConvert
/// (OutputItem -> OutputItemModded), одна замена на весь блок. У оссуария три OutputType
/// os1..os3, по одному на ячейку продукции; os(i+1) выдаёт полный стак ресурса из ячейки
/// модификации i. Пустая или чужая модификация - этот OutputType выключен (isDisabled),
/// значит таймер стоит и сферу он не ест.
/// - ПРОВЕРКА НЕБА. HandleSkyCheck (7 блоков вверх) у ванили безусловна для всех
/// коллекторов; оссуарий работает где угодно, в том числе под землёй (решение 30.09).
///
public static class GeoOssuary
{
public const string BlockName = "necroGeoOssuary";
/// Камень, уголь, селитра, железо (его дают железные жилы), сланец.
public static readonly string[] ResourceNames =
{
"resourceRockSmall", "resourceCoal", "resourcePotassiumNitratePowder",
"resourceScrapIron", "resourceOilShale",
};
static ItemClass[] resourceClasses;
public static ItemClass[] ResourceClasses
{
get
{
if (resourceClasses == null)
{
resourceClasses = new ItemClass[ResourceNames.Length];
for (int i = 0; i < ResourceNames.Length; i++)
resourceClasses[i] = ItemClass.GetItemClass(ResourceNames[i]);
}
return resourceClasses;
}
}
public static bool Is(Block block) => block != null && block.GetBlockName() == BlockName;
public static bool Is(TileEntityCollector te) => te != null && Is(te.blockValue.Block);
/// Ячейка образца для выхода: хранилище 2 ряда по 3, столбец - образец над ним (os1 и os4 - образец 1, os2 и
/// os5 - 2, os3 и os6 - 3). 22.10 сначала решили «ячейка под образец - одна», но спрятать две лишние не вышло с первого
/// раза, и пользователь: «Пусть можно будет положить 3 разных образца, и тогда в ячейки под ними пойдут такие же ресурсы.
/// Примерно так оссуарий и работал». Не наш выход - -1.
public static int ModSlotOf(BlockCollector.OutputType outputType)
{
string name = outputType?.Name;
if (name == null || name.Length != 3 || !name.StartsWith("os"))
return -1;
return (name[2] - '1') % 3;
}
/// Видимых в окне ячеек образца и топлива (#66, 22.10: «Ячейка под образец - одна; ячейка под кровавую
/// сферу (или камень) - одна»). В данных их по-прежнему 3 и 6: длины пишутся в сейв, а при загрузке игра обрезает
/// лишнее по конфигу - уменьшение молча выбросило бы содержимое. Лишние прячутся в окне, их старое содержимое
/// переносит в хранилище Patch_TileEntityCollector_UpdateTick_GeoOssuaryMigrate.
public const int VisibleModSlots = 3; // все три (см. ModSlotOf); прятать нечего, перенос образцов не нужен
public const int VisibleFuelSlots = 1;
/// Ресурс в ячейке модификации, если он из списка; иначе null.
public static ItemClass ResourceIn(TileEntityCollector te, int modSlot)
{
ItemStack[] mods = te.ModSlots;
if (modSlot < 0 || mods == null || modSlot >= mods.Length)
return null;
ItemStack stack = mods[modSlot];
if (stack == null || stack.IsEmpty())
return null;
ItemClass cls = stack.itemValue.ItemClass;
return cls != null && System.Array.IndexOf(ResourceNames, cls.Name) >= 0 ? cls : null;
}
}
[HarmonyPatch(typeof(XUiC_DewCollectorModGrid), nameof(XUiC_DewCollectorModGrid.SetTileEntity))]
public static class Patch_XUiC_DewCollectorModGrid_SetTileEntity_GeoOssuary
{
public static void Postfix(XUiC_DewCollectorModGrid __instance, TileEntityCollector te)
{
bool ossuary = GeoOssuary.Is(te);
for (int i = 0; i < __instance.itemControllers.Length; i++)
{
XUiC_ItemStack slot = __instance.itemControllers[i];
// окно у всех коллекторов общее - чужим возвращаем все ячейки
slot.ViewComponent.IsVisible = !ossuary || i < GeoOssuary.VisibleModSlots;
if (!(slot is XUiC_RequiredItemStack required))
continue;
required.OverrideStackCount = ossuary ? 1 : -1;
if (ossuary)
{
required.SetAllowedItemClasses(GeoOssuary.ResourceClasses);
required.RequiredItemOnly = true;
}
}
}
}
/// Лишние ячейки держать скрытыми каждый кадр, пока окно открыто (22.10: «почему-то ячеек под образец 3, а мы
/// планировали сделать 1»): сетка топлива сама выставляет видимость по высоте в своём Update (updateGridHeight), ячейки
/// образца тоже показывались снова после SetTileEntity. Сеттер IsVisible при том же значении ничего не делает.
[HarmonyPatch(typeof(XUiC_ItemStackGrid), nameof(XUiC_ItemStackGrid.Update))]
public static class Patch_XUiC_ItemStackGrid_Update_GeoOssuary
{
public static void Postfix(XUiC_ItemStackGrid __instance)
{
if (__instance is XUiC_DewCollectorModGrid mods)
{
if (!GeoOssuary.Is(mods.tileEntity))
return;
for (int i = GeoOssuary.VisibleModSlots; i < mods.itemControllers.Length; i++)
mods.itemControllers[i].ViewComponent.IsVisible = false;
}
else if (__instance is XUiC_CollectorFuelGrid fuel && fuel.fuelBehavior == XUiC_CollectorFuelGrid.FuelBehavior.Fuel &&
GeoOssuary.Is(fuel.tileEntity))
{
GeoOssuaryFuelGrid.Apply(fuel);
}
}
}
/// Сетка топлива меняет видимость после базового Update (updateGridHeight) - прятать и после него.
[HarmonyPatch(typeof(XUiC_VariableHeightGrid), nameof(XUiC_VariableHeightGrid.Update))]
public static class Patch_XUiC_VariableHeightGrid_Update_GeoOssuary
{
public static void Postfix(XUiC_VariableHeightGrid __instance)
{
if (__instance is XUiC_CollectorFuelGrid fuel && fuel.fuelBehavior == XUiC_CollectorFuelGrid.FuelBehavior.Fuel &&
GeoOssuary.Is(fuel.tileEntity))
GeoOssuaryFuelGrid.Apply(fuel);
}
}
/// Заголовок над ячейками образцов (22.10: «Переименуй»): у коллекторов игры - «Инструменты» (xuiTools), у котла -
/// «Образцы». Окно общее, поэтому чужим возвращаем родное.
[HarmonyPatch(typeof(XUiC_DewCollectorModGrid), nameof(XUiC_DewCollectorModGrid.OnOpen))]
public static class Patch_XUiC_DewCollectorModGrid_OnOpen_GeoOssuaryHeader
{
public static void Postfix(XUiC_DewCollectorModGrid __instance)
{
XUiController window = __instance;
while (window.Parent != null && !(window.ViewComponent is XUiV_Window))
window = window.Parent;
XUiV_Label header = FindHeader(window);
if (header != null)
header.Text = Localization.Get(GeoOssuary.Is(__instance.tileEntity) ? "necroGeoOssuarySamples" : "xuiTools");
}
static XUiV_Label FindHeader(XUiController _c)
{
if (_c.ViewComponent is XUiV_Label l && (l.Text == Localization.Get("xuiTools") || l.Text == Localization.Get("necroGeoOssuarySamples")))
return l;
foreach (XUiController child in _c.Children)
{
XUiV_Label found = FindHeader(child);
if (found != null)
return found;
}
return null;
}
}
[HarmonyPatch(typeof(TileEntityCollector), nameof(TileEntityCollector.isDisabled))]
public static class Patch_TileEntityCollector_isDisabled_GeoOssuary
{
public static void Postfix(TileEntityCollector __instance, BlockCollector.OutputType _outputType, ref bool __result)
{
if (__result || !GeoOssuary.Is(__instance))
return;
if (GeoOssuary.ResourceIn(__instance, GeoOssuary.ModSlotOf(_outputType)) == null)
__result = true;
}
}
[HarmonyPatch(typeof(TileEntityCollector), nameof(TileEntityCollector.newItem))]
public static class Patch_TileEntityCollector_newItem_GeoOssuary
{
public static void Postfix(TileEntityCollector __instance, BlockCollector.OutputType outputType, ref ItemStack __result)
{
if (__result == null || !GeoOssuary.Is(__instance))
return;
ItemClass resource = GeoOssuary.ResourceIn(__instance, GeoOssuary.ModSlotOf(outputType));
if (resource == null)
{
// isDisabled не пускает сюда без ресурса; если всё же дошло - сфера уже списана,
// и отдать хотя бы заглушку честнее, чем молча съесть топливо.
Debug.LogWarning("[NecromancerTome] GeoOssuary: " + outputType.Name + " сработал без ресурса в ячейке модификации");
return;
}
__result = new ItemStack(new ItemValue(resource.Id), resource.MaxCount);
Debug.Log("[NecromancerTome] GeoOssuary: " + outputType.Name + " -> " + resource.Name + " x" + resource.MaxCount + " в " + __instance.ToWorldPos());
}
}
///
/// Шейдер модели. Своя модель оссуария лежит в нашем бандле с запасным шейдером
/// NecromancerTome/OssuaryProxy (Standard-поверхность), а у пирамиды 10.09 именно
/// Standard из бандла на расстоянии рисовался маджентой. Поэтому при первой загрузке модели
/// материалу ставится тот шейдер, которым игра рисует ванильный коллектор росы: имена текстур
/// у них общие (_MainTex, _Normal, _RMOM, _Emissive), модель выросла из его префаба. Префаб из
/// бандла - общий ассет, так что правка одна на все экземпляры.
///
[HarmonyPatch(typeof(BlockShapeModelEntity), nameof(BlockShapeModelEntity.getPrefab))]
public static class Patch_BlockShapeModelEntity_getPrefab_GeoOssuaryShader
{
const string VanillaModel = "@:Entities/Furniture/collectorDewPrefab.prefab";
// Пирамида отрицания (01.10) собрана из копии префаба оссуария - те же имена объектов,
// тот же запасной шейдер, поэтому лечится тем же постфиксом.
static readonly System.Collections.Generic.HashSet models =
new System.Collections.Generic.HashSet { "necroWardPrefab" };
static readonly System.Collections.Generic.HashSet done = new System.Collections.Generic.HashSet();
public static void Postfix(Transform __result)
{
if (__result == null || !models.Contains(__result.name) || !done.Add(__result.name))
return;
Transform vanilla = DataLoader.LoadAsset(VanillaModel);
Renderer src = vanilla != null ? vanilla.GetComponentInChildren(true) : null;
Shader shader = src != null && src.sharedMaterial != null ? src.sharedMaterial.shader : null;
if (shader == null)
{
Debug.LogWarning("[NecromancerTome] GeoOssuary: шейдер коллектора росы не найден - остаётся запасной");
return;
}
int n = 0;
foreach (Renderer r in __result.GetComponentsInChildren(true))
foreach (Material m in r.sharedMaterials)
if (m != null && m.shader != shader) { m.shader = shader; m.enableInstancing = true; n++; }
Debug.Log("[NecromancerTome] GeoOssuary: материалам модели (" + n + ") поставлен шейдер '" + shader.name + "'");
CopyTags(vanilla, __result);
}
/// Теги коллайдера. У ванильного префаба корень и объект collision помечены
/// игровым тегом (AssetRipper записал его как unknown_20004 - имени он не знал), и именно
/// по тегу игра понимает, что луч попал в блок, с которым можно взаимодействовать. В нашем
/// Unity-проекте такого тега нет, при сборке он стал Untagged - и оссуарий не реагировал
/// на наведение (тест 30.09). Берём теги у ванильного коллектора по именам объектов.
static void CopyTags(Transform vanilla, Transform ours)
{
var byName = new System.Collections.Generic.Dictionary();
foreach (Transform t in vanilla.GetComponentsInChildren(true))
byName[t == vanilla ? "" : t.name] = t;
foreach (Transform t in ours.GetComponentsInChildren(true))
{
Transform src;
if (!byName.TryGetValue(t == ours ? "" : t.name, out src))
continue;
if (t.tag != src.tag || t.gameObject.layer != src.gameObject.layer)
{
Debug.Log("[NecromancerTome] GeoOssuary: '" + t.name + "' тег " + t.tag + " -> " + src.tag +
", слой " + t.gameObject.layer + " -> " + src.gameObject.layer);
t.tag = src.tag;
t.gameObject.layer = src.gameObject.layer;
}
}
}
}
///
/// Расход сферы - по прочности, как в пространственном хранилище (указание 01.10: «одна
/// сфера на стак - это жирно; 1/10 прочности на стак»). Ваниль в removeFuel вычитает ШТУКИ
/// топлива, то есть съедала бы сферу целиком за каждый стак. Здесь вместо этого у первой
/// сферы в отсеке списывается ChargePerStack прочности (DegradationMax сферы 500 -> 10 стаков
/// со сферы); выработанная до нуля сфера исчезает из ячейки, как из браслета. Наличие топлива
/// ваниль по-прежнему считает штуками (getFuelCount) - любая сфера с остатком годится.
///
[HarmonyPatch(typeof(TileEntityCollector), nameof(TileEntityCollector.removeFuel))]
public static class Patch_TileEntityCollector_removeFuel_GeoOssuary
{
public const int ChargePerStack = 50;
public static bool Prefix(TileEntityCollector __instance)
{
if (!GeoOssuary.Is(__instance))
return true;
ItemStack[] fuel = __instance.FuelSlots;
// Кровавый камень в отсеке - вечное топливо (01.10: «полная замена сферы»): не тратится
// ни он, ни сферы рядом.
for (int i = 0; i < fuel.Length; i++)
if (fuel[i] != null && !fuel[i].IsEmpty() && fuel[i].itemValue.ItemClass != null &&
fuel[i].itemValue.ItemClass.Name == BoneSteedFuel.StoneItem)
return false;
for (int i = 0; i < fuel.Length; i++)
{
ItemStack stack = fuel[i];
if (stack == null || stack.IsEmpty() || stack.itemValue.ItemClass == null ||
stack.itemValue.ItemClass.Name != SpatialVaultPickup.ChargeItemName)
continue;
int max = stack.itemValue.MaxUseTimes;
if (max <= 0)
{
// Прочность не задалась (DegradationMax) - ведём себя как ваниль: сфера за стак.
fuel[i] = ItemStack.Empty;
Debug.LogWarning("[NecromancerTome] GeoOssuary: у сферы MaxUseTimes 0 - списана целиком");
return false;
}
stack.itemValue.UseTimes += ChargePerStack;
if (stack.itemValue.UseTimes >= max)
{
fuel[i] = ItemStack.Empty;
Debug.Log("[NecromancerTome] GeoOssuary: сфера в ячейке " + i + " выработана и исчезла");
}
return false;
}
return false;
}
}
[HarmonyPatch(typeof(TileEntityCollector), nameof(TileEntityCollector.HandleSkyCheck))]
public static class Patch_TileEntityCollector_HandleSkyCheck_GeoOssuary
{
public static void Postfix(TileEntityCollector __instance, ref bool __result)
{
if (__result && GeoOssuary.Is(__instance))
__result = false;
}
}
/// Одна ячейка топлива в окне (GeoOssuary.VisibleFuelSlots): остальные ячейки сетки и их подсказки-картинки
/// спрятаны. Сетка общая для всех коллекторов (и для катализатора) - чужим всё показываем обратно.
public static class GeoOssuaryFuelGrid
{
public static void Apply(XUiC_CollectorFuelGrid _grid)
{
if (_grid?.tileEntity == null || _grid.fuelBehavior != XUiC_CollectorFuelGrid.FuelBehavior.Fuel)
return;
// 23.10: окно ещё слушает урну, а блока коллектора под ней уже нет (сняли / поставили заново) - FuelGridLength
// игры тогда падает NullReference.
if (!(_grid.tileEntity.blockValue.Block is BlockCollector))
return;
bool ossuary = GeoOssuary.Is(_grid.tileEntity);
int n = _grid.tileEntity.FuelGridLength;
for (int i = 0; i < _grid.itemControllers.Length; i++)
{
bool show = i < n && (!ossuary || i < GeoOssuary.VisibleFuelSlots);
if (_grid.itemControllers[i].ViewComponent.IsVisible != show && (ossuary || i < n))
_grid.itemControllers[i].ViewComponent.IsVisible = show;
if (_grid.previewSprites != null && i < _grid.previewSprites.Length && _grid.previewSprites[i] != null && ossuary && i >= GeoOssuary.VisibleFuelSlots)
_grid.previewSprites[i].IsVisible = false;
}
}
}
[HarmonyPatch(typeof(XUiC_CollectorFuelGrid), nameof(XUiC_CollectorFuelGrid.SetTileEntity))]
public static class Patch_XUiC_CollectorFuelGrid_SetTileEntity_GeoOssuary
{
public static void Postfix(XUiC_CollectorFuelGrid __instance) => GeoOssuaryFuelGrid.Apply(__instance);
}
[HarmonyPatch(typeof(XUiC_CollectorFuelGrid), nameof(XUiC_CollectorFuelGrid.OnTileEntityChanged))]
public static class Patch_XUiC_CollectorFuelGrid_OnTileEntityChanged_GeoOssuary
{
public static void Postfix(XUiC_CollectorFuelGrid __instance) => GeoOssuaryFuelGrid.Apply(__instance);
}
/// Shift-клик топливом: у оссуария - только в видимую ячейку (ваниль раскладывает по всей сетке).
[HarmonyPatch(typeof(XUiC_CollectorFuelGrid), nameof(XUiC_CollectorFuelGrid.TryAddFuel))]
public static class Patch_XUiC_CollectorFuelGrid_TryAddFuel_GeoOssuary
{
public static bool Prefix(XUiC_CollectorFuelGrid __instance, ItemClass newItemClass, ItemStack newItemStack, ref bool __result)
{
if (__instance.fuelBehavior != XUiC_CollectorFuelGrid.FuelBehavior.Fuel || !GeoOssuary.Is(__instance.tileEntity))
return true;
if (System.Array.IndexOf(__instance.tileEntity.GetFuelTypes(), newItemClass.Name) >= 0)
{
XUiC_ItemStack slot = __instance.itemControllers[0];
if (slot.ItemStack.IsEmpty())
{
slot.ItemStack = newItemStack.Clone();
newItemStack.count = 0;
}
else
{
int n = slot.ItemStack.StackTransferCount(newItemStack);
slot.ItemStack.count += n;
newItemStack.count -= n;
}
__instance.UpdateBackend(__instance.getUISlots());
}
__result = newItemStack.count == 0;
return false;
}
}
/// Переход на котёл (#66, 22.10: «у уже поставленных оссуариев при первой загрузке новой версии всё лишнее из
/// старых ячеек образцов и топлива переложить в новое хранилище - ничего не теряется»). Сервер на каждом тике смотрит
/// спрятанные ячейки (образцы 2-3, топливо 2-6) и перекладывает их содержимое в свободные ячейки хранилища; нет места -
/// ждёт, пока игрок заберёт добычу.
[HarmonyPatch(typeof(TileEntityCollector), nameof(TileEntityCollector.UpdateTick))]
public static class Patch_TileEntityCollector_UpdateTick_GeoOssuaryMigrate
{
public static void Postfix(TileEntityCollector __instance)
{
if (!GeoOssuary.Is(__instance) || !SingletonMonoBehaviour.Instance.IsServer)
return;
bool moved = Move(__instance, __instance.ModSlots, GeoOssuary.VisibleModSlots, "образца");
moved |= Move(__instance, __instance.FuelSlots, GeoOssuary.VisibleFuelSlots, "топлива");
if (moved)
__instance.SetModified();
}
static bool Move(TileEntityCollector _te, ItemStack[] _from, int _keep, string _what)
{
bool moved = false;
ItemStack[] store = _te.Items;
for (int i = _keep; i < _from.Length; i++)
{
if (_from[i] == null || _from[i].IsEmpty())
continue;
int free = System.Array.FindIndex(store, s => s == null || s.IsEmpty());
if (free < 0)
return moved;
store[free] = _from[i].Clone();
_from[i] = ItemStack.Empty.Clone();
moved = true;
Debug.Log("[NecromancerTome] GeoOssuary: " + store[free].itemValue.ItemClass?.Name + " x" + store[free].count +
" из ячейки " + _what + " " + (i + 1) + " -> хранилище " + (free + 1) + " в " + _te.ToWorldPos());
}
return moved;
}
}
}