1.7.0: скакун на сферах в ячейках, ремонт костями; правки моделей скакуна и книги крысы

- Топливо скакуна - кровавые сферы в 4 ячейках модификаций, тратятся по очереди
  (одна сфера = 120 ед., бак 480); уровень бака считается по прочности сфер,
  выработанная сфера исчезает. Заправка канистрой убрана. В ячейки - только сферы.
- Причина «не едет» и «-2147483648%»: Extends не наследует effect_group, у предмета
  скакуна не было ни ячеек, ни прочности. Своя группа: ModSlots 4, DegradationMax 4000;
  здоровье закреплено кодом (DegradationMax сфер в ячейках его перебивал бы).
- Ремонт - 50 костей за ход вместо ремкомплекта; подсказка при нехватке, 13 языков.
- Модель скакуна: сиденье и рама прогнуты по ванили, рога по оси руля,
  череп меньше и короче, свет фары в глазнице, крыло над передним колесом,
  зубы под черепом убраны.
- Книга крысы: повёрнута как ванильная (палец на корешке), без металлических уголков.

В игре не проверено.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AlexCube
2026-10-01 10:51:03 +03:00
co-authored by Claude Opus 5.5
parent 11db1116b0
commit 24d6e9f675
9 changed files with 295 additions and 50 deletions
+260
View File
@@ -0,0 +1,260 @@
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Топливо костяного скакуна - кровавые сферы в ячейках модификаций (указание 01.10.2026:
/// «4 ячейки под кровавые сферы, которые будут постепенно по очереди тратиться; заправку
/// через канистру убрать»). Ванильный бак (VPFuelTank) остаётся частью машины - двигатель,
/// HUD и глохнутие читают его, - но для скакуна его уровень не хранится, а СЧИТАЕТСЯ по
/// сферам: каждая сфера - FuelPerSphere единиц, пропорционально остатку прочности.
///
/// Расход: двигатель снимает топливо событием FuelRemove -> VPFuelTank.AddFuel ->
/// SetFuelLevel(меньше). Префикс SetFuelLevel переводит разницу в прочность первой сферы
/// с остатком (ячейки по порядку); выработанная сфера исчезает из ячейки, как в браслете и
/// оссуарии. Рост уровня (ванильная заправка, загрузка из Meta) игнорируется - кровь
/// приходит только сферами.
///
/// Ячейки: у ванили в слот модификации идёт только модификация с подходящими тегами и не
/// больше MaxModsAllowed одного вида. Сфера - модификация браслета, поэтому для скакуна
/// CanSwap решает сам: только сфера с остатком прочности, прочие моды не ставятся.
///
/// Здоровье: Extends не наследует effect_group (ItemClassesFromXml парсит эффекты только
/// со своего узла), поэтому у necroBoneSteedPlaceable свой DegradationMax в items.xml. Но
/// эффекты установленных модификаций применяются к хозяину, а у сферы DegradationMax
/// base_set 500 - со сферами здоровье скакуна упало бы до 500 и прыгало бы при их смене.
/// Поэтому MaxUseTimesBase скакуна закреплён кодом.
/// </summary>
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;
/// <summary>Остаток сферы 0..1.</summary>
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;
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;
}
/// <summary>Снимает топливо со сфер по очереди. Возвращает остаток топлива.</summary>
public static float Burn(Vehicle v, float amount)
{
ItemValue item = v.itemValue;
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;
}
}
[HarmonyPatch(typeof(VPFuelTank), nameof(VPFuelTank.SetFuelLevel))]
public static class Patch_VPFuelTank_SetFuelLevel_BoneSteed
{
public static bool Prefix(VPFuelTank __instance, float _fuelLevel)
{
if (!BoneSteedFuel.IsSteedTank(__instance))
return true;
Vehicle v = __instance.vehicle;
float have = BoneSteedFuel.FuelOf(v);
// Загрузка (SetItemValue -> Meta/50) приходит, пока LoadingItem; её не трогаем.
if (Patch_Vehicle_SetItemValue_BoneSteed.Loading || _fuelLevel >= have)
{
__instance.fuelLevel = have;
return false;
}
float left = BoneSteedFuel.Burn(v, have - Mathf.Max(0f, _fuelLevel));
__instance.fuelLevel = left;
if (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;
public static void Prefix() => Loading = true;
public static void Finalizer(Vehicle __instance)
{
Loading = false;
if (BoneSteed.Is(__instance.entity))
Debug.Log("[NecromancerTome] BoneSteed: ячеек " + __instance.itemValue.ModificationCount +
", топливо " + BoneSteedFuel.FuelOf(__instance).ToString("0.#") + "/" + BoneSteedFuel.MaxFuel +
", здоровье " + __instance.entity.Health + "/" + __instance.GetMaxHealth() +
", расход x" + __instance.EffectFuelUsePer.ToString("0.##"));
}
}
/// <summary>В ячейки скакуна - только кровавые сферы с остатком прочности.</summary>
[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;
__result = BoneSteedFuel.IsSphere(stack.itemValue) &&
stack.itemValue.UseTimes < stack.itemValue.MaxUseTimes &&
(here == null || here.IsEmpty() || BoneSteedFuel.IsSphere(here));
return false;
}
}
/// <summary>Здоровье скакуна не зависит от сфер в ячейках (у сферы DegradationMax 500).</summary>
[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;
}
}
/// <summary>Ремонт скакуна - костями вместо ремкомплекта (указание 01.10): BonesPerRepair
/// костей за ход, как ванильный ремкомплект (1000 здоровья + 10% за уровень «Механика»).</summary>
[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;
}
}
}
+7 -32
View File
@@ -9,10 +9,8 @@ namespace NecromancerTome
/// кровавых сферах. Сущность necroBoneSteed в entityclasses.xml - наследник vehicleMotorcycle
/// со своей моделью (бандл necrobonesteed, собран из копии ванильного VMotorcycleP.prefab).
///
/// Топливо у транспорта игры в XML не задаётся: Vehicle.GetFuelItem() возвращает "ammoGasCan"
/// строкой в коде. Отсюда два патча: предмет топлива - сфера, и заправка - одна сфера на полный
/// бак (ваниль берёт по канистре на 1/25 единицы бака - 120 сфер на бак было бы абсурдом).
/// Без сфер скакун глохнет как обычный транспорт (решение 01.10) - это ваниль, кода не нужно.
/// Топливо - кровавые сферы в четырёх ячейках модификаций, ремонт - костями: всё это в
/// BoneSteedFuelPatch. Здесь только запрет заправки канистрой (GetFuelItem).
///
/// Модель выросла из рипнутого префаба, и у неё те же две беды, что у оссуария: игровые теги
/// коллайдеров потеряны (без них на скакуна не сесть и по нему не попасть) и шейдер-заглушка.
@@ -22,6 +20,7 @@ namespace NecromancerTome
{
public const string EntityClassName = "necroBoneSteed";
public const string FuelItem = "resourceBloodSphere";
public const string ItemName = "necroBoneSteedPlaceable";
const string VanillaPrefab = "@:Entities/Vehicles/VMotorcycle/VMotorcycleP.prefab";
public static bool Is(Entity e)
@@ -110,39 +109,15 @@ namespace NecromancerTome
}
}
/// <summary>Канистрой и вообще из рюкзака скакуна не заправить (указание 01.10): кровь
/// вставляется только в ячейки. Пустой предмет топлива прячет пункт «Заправить» в меню.</summary>
[HarmonyPatch(typeof(Vehicle), nameof(Vehicle.GetFuelItem))]
public static class Patch_Vehicle_GetFuelItem_BoneSteed
{
public static void Postfix(Vehicle __instance, ref string __result)
{
if (__result != "" && BoneSteed.Is(__instance.entity))
__result = BoneSteed.FuelItem;
}
}
/// <summary>Одна сфера - полный бак.</summary>
[HarmonyPatch(typeof(EntityVehicle), nameof(EntityVehicle.AddFuelFromInventory))]
public static class Patch_EntityVehicle_AddFuelFromInventory_BoneSteed
{
public static bool Prefix(EntityVehicle __instance, EntityAlive entity, ref bool __result)
{
if (!BoneSteed.Is(__instance))
return true;
__result = false;
Vehicle v = __instance.GetVehicle();
if (v.GetFuelPercent() >= 1f || !(entity is EntityPlayer player))
return false;
ItemValue sphere = ItemClass.GetItem(BoneSteed.FuelItem);
int taken = player.inventory.DecItem(sphere, 1);
if (taken == 0)
taken = player.bag.DecItem(sphere, 1);
if (taken == 0)
return false;
v.AddFuel(v.GetMaxFuelLevel() - v.GetFuelLevel());
__instance.PlayOneShot("useactions/gas_refill");
Debug.Log("[NecromancerTome] BoneSteed: сфера залита - полный бак");
__result = true;
return false;
if (BoneSteed.Is(__instance.entity))
__result = "";
}
}
}