using HarmonyLib; using UnityEngine; namespace NecromancerTome { /// /// Топливо костяного скакуна - кровавые сферы в ячейках модификаций (указание 01.10.2026: /// «4 ячейки под кровавые сферы, которые будут постепенно по очереди тратиться; заправку /// через канистру убрать»). Ванильный бак (VPFuelTank) остаётся частью машины - двигатель, /// HUD и глохнутие читают его, - но для скакуна его уровень не хранится, а СЧИТАЕТСЯ по /// сферам: каждая сфера - FuelPerSphere единиц, пропорционально остатку прочности. /// /// Расход: двигатель шлёт событие FuelRemove; его префикс переводит количество в прочность /// первой сферы с остатком (ячейки по порядку); выработанная сфера исчезает из ячейки, как в /// браслете и оссуарии. Прямые записи уровня (SetFuelLevel: загрузка, синхронизация, /// заправка) игнорируются - кровь приходит только сферами. /// /// Ячейки: у ванили в слот модификации идёт только модификация с подходящими тегами и не /// больше MaxModsAllowed одного вида. Сфера - модификация браслета, поэтому для скакуна /// CanSwap решает сам: только сфера с остатком прочности, прочие моды не ставятся. /// /// Здоровье: Extends не наследует effect_group (ItemClassesFromXml парсит эффекты только /// со своего узла), поэтому у necroBoneSteedPlaceable свой DegradationMax в items.xml. Но /// эффекты установленных модификаций применяются к хозяину, а у сферы DegradationMax /// base_set 500 - со сферами здоровье скакуна упало бы до 500 и прыгало бы при их смене. /// Поэтому MaxUseTimesBase скакуна закреплён кодом. /// public static class BoneSteedFuel { public const int Slots = 4; public const float FuelPerSphere = 120f; public const float MaxFuel = Slots * FuelPerSphere; public const int MaxHealth = 4000; static int steedItemType = -1; public static int SteedItemType { get { if (steedItemType < 0) { ItemValue iv = ItemClass.GetItem(BoneSteed.ItemName); steedItemType = iv != null ? iv.type : 0; } return steedItemType; } } public static bool IsSteedItem(ItemValue iv) => iv != null && iv.type != 0 && iv.type == SteedItemType; public static bool IsSphere(ItemValue iv) => iv != null && !iv.IsEmpty() && iv.ItemClass != null && iv.ItemClass.Name == BoneSteed.FuelItem; /// Кровавый камень - неиссякаемая сфера (01.10: «полная замена сферы»): один камень /// в любой ячейке - вечный полный бак, сферы рядом с ним не тратятся. public const string StoneItem = "resourceBloodStone"; public static bool IsStone(ItemValue iv) => iv != null && !iv.IsEmpty() && iv.ItemClass != null && iv.ItemClass.Name == StoneItem; public static bool HasStone(ItemValue item) { if (item == null) return false; for (int i = 0; i < item.ModificationCount; i++) if (IsStone(item.GetModification(i))) return true; return false; } /// Остаток сферы 0..1. static float Left(ItemValue sphere) { int max = sphere.MaxUseTimes; if (max <= 0) return 1f; return Mathf.Clamp01(1f - sphere.UseTimes / max); } public static float FuelOf(Vehicle v) { ItemValue item = v?.itemValue; if (item == null) return 0f; if (HasStone(item)) return MaxFuel; float fuel = 0f; for (int i = 0; i < item.ModificationCount; i++) { ItemValue m = item.GetModification(i); if (IsSphere(m)) fuel += Left(m) * FuelPerSphere; } return fuel; } /// Снимает топливо со сфер по очереди. Возвращает остаток топлива. public static float Burn(Vehicle v, float amount) { ItemValue item = v.itemValue; if (HasStone(item)) return MaxFuel; for (int i = 0; i < item.ModificationCount && amount > 0f; i++) { ItemValue m = item.GetModification(i); if (!IsSphere(m)) continue; int max = m.MaxUseTimes; if (max <= 0) { item.SetModification(i, ItemValue.None.Clone()); amount -= FuelPerSphere; continue; } float have = Left(m) * FuelPerSphere; float take = Mathf.Min(have, amount); m.UseTimes += take / FuelPerSphere * max; amount -= take; if (m.UseTimes >= max - 0.001f) { item.SetModification(i, ItemValue.None.Clone()); Debug.Log("[NecromancerTome] BoneSteed: сфера в ячейке " + i + " выработана и исчезла"); } } return FuelOf(v); } public static bool IsSteedTank(VPFuelTank tank) => tank?.vehicle != null && BoneSteed.Is(tank.vehicle.entity); } [HarmonyPatch(typeof(VPFuelTank), nameof(VPFuelTank.GetFuelLevel))] public static class Patch_VPFuelTank_GetFuelLevel_BoneSteed { public static bool Prefix(VPFuelTank __instance, ref float __result) { if (!BoneSteedFuel.IsSteedTank(__instance)) return true; __result = BoneSteedFuel.FuelOf(__instance.vehicle); return false; } } [HarmonyPatch(typeof(VPFuelTank), nameof(VPFuelTank.GetMaxFuelLevel))] public static class Patch_VPFuelTank_GetMaxFuelLevel_BoneSteed { public static bool Prefix(VPFuelTank __instance, ref float __result) { if (!BoneSteedFuel.IsSteedTank(__instance)) return true; __result = BoneSteedFuel.MaxFuel; return false; } } [HarmonyPatch(typeof(VPFuelTank), nameof(VPFuelTank.GetFuelLevelPercent))] public static class Patch_VPFuelTank_GetFuelLevelPercent_BoneSteed { public static bool Prefix(VPFuelTank __instance, ref float __result) { if (!BoneSteedFuel.IsSteedTank(__instance)) return true; __result = Mathf.Clamp01(BoneSteedFuel.FuelOf(__instance.vehicle) / BoneSteedFuel.MaxFuel); return false; } } /// Любая прямая запись уровня бака (загрузка из Meta, сетевая синхронизация, /// ванильная заправка) для скакуна ничего не меняет: уровень - производная от сфер. Тест 01.10: /// после установки сферы игра записала в бак прежний 0, и сфера сгорала целиком. [HarmonyPatch(typeof(VPFuelTank), nameof(VPFuelTank.SetFuelLevel))] public static class Patch_VPFuelTank_SetFuelLevel_BoneSteed { public static bool Prefix(VPFuelTank __instance) { if (!BoneSteedFuel.IsSteedTank(__instance)) return true; __instance.fuelLevel = BoneSteedFuel.FuelOf(__instance.vehicle); return false; } } /// Расход - только событием двигателя FuelRemove: снимается прочность сфер. [HarmonyPatch(typeof(VPFuelTank), nameof(VPFuelTank.HandleEvent))] public static class Patch_VPFuelTank_HandleEvent_BoneSteed { public static bool Prefix(VPFuelTank __instance, VehiclePart.Event _event, float _amount) { if (_event != VehiclePart.Event.FuelRemove || !BoneSteedFuel.IsSteedTank(__instance)) return true; Vehicle v = __instance.vehicle; bool had = BoneSteedFuel.FuelOf(v) > 0f; float left = _amount > 0f ? BoneSteedFuel.Burn(v, _amount) : BoneSteedFuel.FuelOf(v); __instance.fuelLevel = left; if (had && left <= 0f && EntityVehicle.VehicleFuelUsageModifier > 0f) v.FireEvent(VehiclePart.Event.FuelEmpty, __instance, 0f); return false; } } [HarmonyPatch(typeof(Vehicle), nameof(Vehicle.SetItemValue))] public static class Patch_Vehicle_SetItemValue_BoneSteed { public static bool Loading; /// Скакун, сделанный до появления ячеек (или выданный без них), получает свои /// 4 ячейки здесь: ItemValue размечает ячейки один раз при создании и сам не дорастает. public static void Prefix(ItemValue _itemValue) { Loading = true; if (!BoneSteedFuel.IsSteedItem(_itemValue) || _itemValue.ModificationCount >= BoneSteedFuel.Slots) return; var mods = new ItemValue[BoneSteedFuel.Slots]; for (int i = 0; i < mods.Length; i++) mods[i] = i < _itemValue.ModificationCount && _itemValue.modifications[i] != null ? _itemValue.modifications[i] : ItemValue.None.Clone(); _itemValue.modifications = mods; } public static void Finalizer(Vehicle __instance) { Loading = false; if (!BoneSteed.Is(__instance.entity)) return; // Слота покраски у скакуна нет (01.10: в нём сам собой появлялся коричневый краситель, // вынимался и появлялся снова). Ваниль в SetItemValue заводит его заново, если пусто, // поэтому снимаем каждый раз после неё. Что там лежало - в лог, для поиска источника. ItemValue iv = __instance.itemValue; if (iv.CosmeticModCount > 0) { for (int i = 0; i < iv.CosmeticModCount; i++) { ItemValue c = iv.GetCosmeticMod(i); if (c != null && !c.IsEmpty()) Debug.Log("[NecromancerTome] BoneSteed: в слоте покраски было '" + c.ItemClass?.Name + "' - снято"); } iv.ResizeCosmeticMods(0); } Debug.Log("[NecromancerTome] BoneSteed: ячеек " + __instance.itemValue.ModificationCount + ", топливо " + BoneSteedFuel.FuelOf(__instance).ToString("0.#") + "/" + BoneSteedFuel.MaxFuel + ", здоровье " + __instance.entity.Health + "/" + __instance.GetMaxHealth() + ", расход x" + __instance.EffectFuelUsePer.ToString("0.##")); } } /// В ячейки скакуна - только кровавые сферы с остатком прочности и кровавые камни. [HarmonyPatch(typeof(XUiC_ItemPartStack), "CanSwap")] public static class Patch_XUiC_ItemPartStack_CanSwap_BoneSteed { public static bool Prefix(XUiC_ItemPartStack __instance, ItemStack stack, ref bool __result) { ItemStack current = __instance.xui?.AssembleItem?.CurrentItem; if (current == null || !BoneSteedFuel.IsSteedItem(current.itemValue)) return true; ItemValue here = __instance.ItemValue; ItemValue add = stack.itemValue; bool blood = BoneSteedFuel.IsStone(add) || BoneSteedFuel.IsSphere(add) && add.UseTimes < add.MaxUseTimes; __result = blood && (here == null || here.IsEmpty() || BoneSteedFuel.IsSphere(here) || BoneSteedFuel.IsStone(here)); return false; } } /// Здоровье скакуна не зависит от сфер в ячейках (у сферы DegradationMax 500). [HarmonyPatch(typeof(ItemValue), nameof(ItemValue.MaxUseTimesBase), MethodType.Getter)] public static class Patch_ItemValue_MaxUseTimesBase_BoneSteed { public static void Postfix(ItemValue __instance, ref int __result) { if (BoneSteedFuel.IsSteedItem(__instance)) __result = BoneSteedFuel.MaxHealth; } } /// Ремонт скакуна - костями вместо ремкомплекта (указание 01.10): BonesPerRepair /// костей за ход, как ванильный ремкомплект (1000 здоровья + 10% за уровень «Механика»). [HarmonyPatch(typeof(XUiM_Vehicle), nameof(XUiM_Vehicle.RepairVehicle))] public static class Patch_XUiM_Vehicle_RepairVehicle_BoneSteed { public const string BoneItem = "resourceBone"; public const int BonesPerRepair = 50; public static bool Prefix(XUi _xui, Vehicle _vehicle, ref bool __result) { Vehicle v = _vehicle ?? _xui.Vehicle.CurrentVehicle?.GetVehicle(); if (v == null || !BoneSteed.Is(v.entity)) return true; __result = false; EntityPlayerLocal player = _xui.playerUI.entityPlayer; ItemValue bone = ItemClass.GetItem(BoneItem); if (bone == null || bone.IsEmpty() || v.GetRepairAmountNeeded() <= 0) return false; int have = player.bag.GetItemCount(bone) + player.inventory.GetItemCount(bone); if (have < BonesPerRepair) { Audio.Manager.PlayInsidePlayerHead("misc/missingitemtorepair"); GameManager.ShowTooltip(player, string.Format(Localization.Get("necroBoneSteedNeedBones"), BonesPerRepair), string.Empty, "ui_denied"); return false; } float perk = 0f; ProgressionValue pv = player.Progression.GetProgressionValue("perkGreaseMonkey"); if (pv != null) perk += pv.Level * 0.1f; v.RepairParts(1000, perk); int left = BonesPerRepair; left -= player.inventory.DecItem(bone, left); if (left > 0) player.bag.DecItem(bone, left); _xui.CollectedItemList.RemoveItemStack(new ItemStack(bone, BonesPerRepair)); Audio.Manager.PlayInsidePlayerHead("craft_complete_item"); __result = true; return false; } } /// Кнопка «Заправить» в окне обслуживания: у скакуна выключена, текст - «нельзя». [HarmonyPatch(typeof(XUiC_VehicleFrameWindow), nameof(XUiC_VehicleFrameWindow.Update))] public static class Patch_XUiC_VehicleFrameWindow_Update_BoneSteed { public static void Postfix(XUiC_VehicleFrameWindow __instance) { if (__instance.btnRefuel_Background != null && __instance.Vehicle != null && BoneSteed.Is(__instance.Vehicle)) __instance.btnRefuel_Background.Enabled = false; } } [HarmonyPatch(typeof(XUiC_VehicleFrameWindow), "GetBindingValueInternal")] public static class Patch_XUiC_VehicleFrameWindow_Binding_BoneSteed { public static void Postfix(XUiC_VehicleFrameWindow __instance, ref string value, string bindingName) { if (bindingName == "refueltext" && __instance.Vehicle != null && BoneSteed.Is(__instance.Vehicle)) value = Localization.Get("xuiRefuelNotAllowed"); } } /// Страховка: любая ванильная заправка скакуна (кнопка, меню) ничего не делает. [HarmonyPatch(typeof(EntityVehicle), nameof(EntityVehicle.AddFuelFromInventory))] public static class Patch_EntityVehicle_AddFuelFromInventory_BoneSteed { public static bool Prefix(EntityVehicle __instance, ref bool __result) { if (!BoneSteed.Is(__instance)) return true; __result = false; return false; } } }