1.7.0: охрана питомцами, один питомец за раз, прицел книг, гео-оссуарий

Питомцы:
- охрана хозяина: враг ближе 20 м (в режиме змеи - к телу) атакуется сам,
  поводок 40 м, охраняют все кроме крысы; приказ прицелом охрана не перебивает
- IsFoe исключает существ мода (necro*): питомец выбирал целью себя
- призыв нового спутника отзывает прежнего
- прицел книг призыва - как у арбалета, краснеет на годной цели

Гео-оссуарий (necroGeoOssuary): станция добычи на кровавой сфере
- основа - коллектор росы; в 3 ячейки модов любой из 5 ресурсов
  (камень, уголь, селитра, железо, сланец), 1 сфера = стак 6000 за 5-8 ч
- работает без открытого неба; верстак, Подмастерье (ур. 25)
- своя модель (бандл necroossuary): кости, корни, сосуды, клыки, черепа
  с глазами-углями; шейдер и теги коллайдера берутся у ванильного коллектора

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AlexCube
2026-10-01 00:36:45 +03:00
co-authored by Claude Opus 5.5
parent b737dd43d0
commit f2dfeed126
15 changed files with 657 additions and 26 deletions
File diff suppressed because one or more lines are too long
+47
View File
@@ -179,5 +179,52 @@
<property name="SellableToTrader" value="false" /> <property name="SellableToTrader" value="false" />
<drop event="Destroy" name="resourceScrapIron" count="10,20" /> <drop event="Destroy" name="resourceScrapIron" count="10,20" />
</block> </block>
<!-- Гео-оссуарий (1.7.0, 2026-09-30): станция добычи ресурсов на кровавой сфере.
Тело - ванильный коллектор росы (Class=Collector): окно, топливо, таймеры, работа без
игрока рядом - всё его. Своего кода два места, оба в HarmonySrc/GeoOssuaryPatch.cs:
- каждая из трёх ячеек модификаций принимает любой из пяти ресурсов (ваниль даёт
ячейке ровно один предмет из RequiredMods);
- ячейка продукции i выдаёт полный стак того ресурса, что лежит в ячейке
модификации i (ваниль смотрит только "занята/пуста", а не на предмет), пустая
модификация = ячейка стоит и сферу не ест; проверка неба снята.
Отсюда три OutputType os1..os3 на три ячейки продукции: у каждого свой таймер и своя
ячейка. OutputItem в них - заглушка, настоящий ресурс подставляет патч.
Топливо общее: 1 сфера = 1 стак (FuelCost 1, AdditionalFuelCost 0). Время как у росы.
ModTypes=Modify - флаг, который ваниль ни на что не тратит: Speed/Count/Expand
изменили бы скорость, выход или размер сетки.
Модель своя (Blender: кости, корни, сосуды, клыки, черепа с глазами-углями), бандл
necroossuary, собрана из префаба коллектора росы: LODGroup, слои и коллайдер ванильные.
Шейдер материалу ставит GeoOssuaryPatch (шейдер ванильного коллектора). -->
<block name="necroGeoOssuary">
<property name="Extends" value="cntDewCollector" />
<property name="UnlockedBy" value="craftingNecroNecromancy" />
<property name="DescriptionKey" value="necroGeoOssuaryDesc" />
<property name="LootLabelKey" value="xuiNecroGeoOssuary" />
<property name="ItemIconBackdrop" value="ui_game_symbol_mining" />
<property name="ItemBackgroundColor" value="140,20,20,255" />
<property name="FuelTypes" value="{blood,resourceBloodSphere}" />
<property name="OutputTypes" value="{os1,blood,1,0,1,resourceRockSmall,resourceRockSmall,1,1,6000,10000,collector_complete_item}{os2,blood,1,0,1,resourceRockSmall,resourceRockSmall,1,1,6000,10000,collector_complete_item}{os3,blood,1,0,1,resourceRockSmall,resourceRockSmall,1,1,6000,10000,collector_complete_item}" />
<property name="Outputs" value="os1,os2,os3" />
<property name="OutputGridHeight" value="1" />
<property name="FuelGridHeight" value="2" />
<property name="RequiredMods" value="resourceRockSmall,resourceRockSmall,resourceRockSmall" />
<property name="RequiredModsOnly" value="true" />
<property name="ModTypes" value="Modify,Modify,Modify" />
<property name="ModTransformEnableNames" value=",," />
<property name="EmptyText" value="necroGeoOssuaryEmpty" />
<property name="HasItem1Text" value="necroGeoOssuaryHasItems" />
<property name="HasItem2Text" value="necroGeoOssuaryHasItems" />
<property name="FuelTitleText" value="necroGeoOssuaryFuel" />
<property name="FuelTitleSprite" value="ui_game_symbol_skull" />
<property name="FuelTitleSpriteAtlas" value="UIAtlas" />
<property name="FuelTypesSprites" value="BloodSphere,ItemIconAtlasGreyscale" />
<property name="Model" value="#@modfolder(NecromancerTome):Resources/necroossuary?necroOssuaryPrefab.prefab" />
<property name="CustomIcon" value="necroGeoOssuary" />
<property name="EconomicValue" value="0" />
<property name="SellableToTrader" value="false" />
<property class="RepairItems">
<property name="resourceBone" value="50" />
</property>
</block>
</append> </append>
</config> </config>
+1 -1
View File
@@ -235,7 +235,7 @@
ОБЯЗАНЫ совпадать. display_entry рисует замок сам по себе, по unlock_tier, и про ОБЯЗАНЫ совпадать. display_entry рисует замок сам по себе, по unlock_tier, и про
теги не знает - разойдутся, и на панели будет замок на доступном рецепте. --> теги не знает - разойдутся, и на панели будет замок на доступном рецепте. -->
<display_entry icon="ScrollOfDeviation" name_key="craftingNecroNecromancyTier2Name" has_quality="false" unlock_level="25,65,70,85"> <display_entry icon="ScrollOfDeviation" name_key="craftingNecroNecromancyTier2Name" has_quality="false" unlock_level="25,65,70,85">
<unlock_entry item="thrownBookGrimoireDeviation,bookSummonZombieGriffin" unlock_tier="1"/> <unlock_entry item="thrownBookGrimoireDeviation,bookSummonZombieGriffin,necroGeoOssuary" unlock_tier="1"/>
<!-- Зомбогриф, 18.09: "гриф пусть тоже будет на подмастерье, но 1300 (65 уровень <!-- Зомбогриф, 18.09: "гриф пусть тоже будет на подмастерье, но 1300 (65 уровень
некромантии)". 65 вставлен в unlock_level МЕЖДУ 25 и 70, поэтому unlock_tier некромантии)". 65 вставлен в unlock_level МЕЖДУ 25 и 70, поэтому unlock_tier
у двух модов ножа ниже сдвинулись на единицу (были 2 и 3, стали 3 и 4). у двух модов ножа ниже сдвинулись на единицу (были 2 и 3, стали 3 и 4).
+15
View File
@@ -473,4 +473,19 @@
<ingredient name="resourceNecromancerBlood" count="1"/> <ingredient name="resourceNecromancerBlood" count="1"/>
</recipe> </recipe>
</append> </append>
<!-- Гео-оссуарий (1.7.0, 2026-09-30). Рецепт придуман по указанию «туда явно нужен флакон
крови некроманта и много костей»; дерево и волокна - корни, которыми
оплетены кости (добавлено тем же вечером). Порог - «Подмастерье» (уровень 25, как Гримуар
девиации): к этому времени игрок строит базу и кузницу, и камень с железом ему нужны
именно тогда (правило «порог по нужде, а не по силе»). Тег и unlock_level группы 2 в
progression.xml обязаны совпадать. -->
<append xpath="/recipes">
<recipe name="necroGeoOssuary" count="1" craft_area="workbench" tags="learnable,workbenchCrafting,necroNecromancyJourneyman">
<ingredient name="resourceBone" count="400"/>
<ingredient name="resourceZombieAsh" count="200"/>
<ingredient name="resourceNecromancerBlood" count="1"/>
<ingredient name="resourceWood" count="100"/>
<ingredient name="resourceYuccaFibers" count="50"/>
</recipe>
</append>
</config> </config>
+202
View File
@@ -0,0 +1,202 @@
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Гео-оссуарий (1.7.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).
/// </summary>
public static class GeoOssuary
{
public const string BlockName = "necroGeoOssuary";
/// <summary>Камень, уголь, селитра, железо (его дают железные жилы), сланец.</summary>
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);
/// <summary>os1 -> 0, os2 -> 1, os3 -> 2; иначе -1.</summary>
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';
}
/// <summary>Ресурс в ячейке модификации, если он из списка; иначе null.</summary>
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);
foreach (XUiC_ItemStack slot in __instance.itemControllers)
{
if (!(slot is XUiC_RequiredItemStack required))
continue;
required.OverrideStackCount = ossuary ? 1 : -1;
if (ossuary)
{
required.SetAllowedItemClasses(GeoOssuary.ResourceClasses);
required.RequiredItemOnly = true;
}
}
}
}
[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());
}
}
/// <summary>
/// Шейдер модели. Своя модель оссуария лежит в нашем бандле с запасным шейдером
/// NecromancerTome/OssuaryProxy (Standard-поверхность), а у пирамиды 10.09 именно
/// Standard из бандла на расстоянии рисовался маджентой. Поэтому при первой загрузке модели
/// материалу ставится тот шейдер, которым игра рисует ванильный коллектор росы: имена текстур
/// у них общие (_MainTex, _Normal, _RMOM, _Emissive), модель выросла из его префаба. Префаб из
/// бандла - общий ассет, так что правка одна на все экземпляры.
/// </summary>
[HarmonyPatch(typeof(BlockShapeModelEntity), nameof(BlockShapeModelEntity.getPrefab))]
public static class Patch_BlockShapeModelEntity_getPrefab_GeoOssuaryShader
{
const string ModelName = "necroOssuaryPrefab";
const string VanillaModel = "@:Entities/Furniture/collectorDewPrefab.prefab";
static bool done;
public static void Postfix(Transform __result)
{
if (done || __result == null || __result.name != ModelName)
return;
done = true;
Transform vanilla = DataLoader.LoadAsset<Transform>(VanillaModel);
Renderer src = vanilla != null ? vanilla.GetComponentInChildren<Renderer>(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<Renderer>(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);
}
/// <summary>Теги коллайдера. У ванильного префаба корень и объект collision помечены
/// игровым тегом (AssetRipper записал его как unknown_20004 - имени он не знал), и именно
/// по тегу игра понимает, что луч попал в блок, с которым можно взаимодействовать. В нашем
/// Unity-проекте такого тега нет, при сборке он стал Untagged - и оссуарий не реагировал
/// на наведение (тест 30.09). Берём теги у ванильного коллектора по именам объектов.</summary>
static void CopyTags(Transform vanilla, Transform ours)
{
var byName = new System.Collections.Generic.Dictionary<string, Transform>();
foreach (Transform t in vanilla.GetComponentsInChildren<Transform>(true))
byName[t == vanilla ? "<root>" : t.name] = t;
foreach (Transform t in ours.GetComponentsInChildren<Transform>(true))
{
Transform src;
if (!byName.TryGetValue(t == ours ? "<root>" : 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;
}
}
}
}
[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;
}
}
}
+5
View File
@@ -23,6 +23,11 @@
<HintPath>..\..\..\7DaysToDie_Data\Managed\Assembly-CSharp.dll</HintPath> <HintPath>..\..\..\7DaysToDie_Data\Managed\Assembly-CSharp.dll</HintPath>
<Private>false</Private> <Private>false</Private>
</Reference> </Reference>
<!-- DataLoader.LoadAsset: у метода есть перегрузка с AssetReference (гео-оссуарий, 30.09) -->
<Reference Include="Unity.Addressables">
<HintPath>..\..\..\7DaysToDie_Data\Managed\Unity.Addressables.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine.CoreModule"> <Reference Include="UnityEngine.CoreModule">
<HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.CoreModule.dll</HintPath> <HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.CoreModule.dll</HintPath>
<Private>false</Private> <Private>false</Private>
+129 -17
View File
@@ -42,6 +42,32 @@ namespace NecromancerTome
/// смысл не в таймере, а в том, чтобы приказ не висел вечно, если цель уйдёт из мира.</summary> /// смысл не в таймере, а в том, чтобы приказ не висел вечно, если цель уйдёт из мира.</summary>
public const int OrderTicks = 6000; public const int OrderTicks = 6000;
/// <summary>Кого игрок сейчас держит в прицеле и кого можно назначить целью. null - некого.
/// Общее для приказа и для покраснения прицела (PetCrosshair): прицел краснеет ровно тогда,
/// когда приказ будет принят. Свои питомцы целью не бывают.</summary>
public static EntityAlive TargetUnderCrosshair(EntityPlayerLocal _local)
{
WorldRayHitInfo hit = _local.HitInfo;
if (hit == null || !hit.bHitValid || hit.transform == null)
{
return null;
}
EntityAlive target = ItemActionAttack.GetEntityFromHit(hit) as EntityAlive;
if (target == null || target.IsDead() || target == _local)
{
return null;
}
if (target is EntityPlayer || target is EntityTrader)
{
return null;
}
if (PetFollowPatch.FindOwnerOfPet(target.entityId) != null)
{
return null;
}
return target;
}
/// <summary>Отдаёт питомцу приказ атаковать то, на что смотрит игрок. false - приказа не /// <summary>Отдаёт питомцу приказ атаковать то, на что смотрит игрок. false - приказа не
/// вышло, и вызывающая сторона показывает "Нет цели для атаки".</summary> /// вышло, и вызывающая сторона показывает "Нет цели для атаки".</summary>
public static bool TryOrderAttack(EntityAlive _owner, EntityAlive _pet) public static bool TryOrderAttack(EntityAlive _owner, EntityAlive _pet)
@@ -59,23 +85,8 @@ namespace NecromancerTome
return false; return false;
} }
WorldRayHitInfo hit = local.HitInfo; EntityAlive target = TargetUnderCrosshair(local);
if (hit == null || !hit.bHitValid || hit.transform == null) if (target == null || target == _pet)
{
return false;
}
Entity hitEntity = ItemActionAttack.GetEntityFromHit(hit);
EntityAlive target = hitEntity as EntityAlive;
if (target == null || target.IsDead())
{
return false;
}
if (target == _pet || target == _owner)
{
return false;
}
if (target is EntityPlayer || target is EntityTrader)
{ {
return false; return false;
} }
@@ -84,9 +95,110 @@ namespace NecromancerTome
// следующем тике перебить наш приказ тем, кто ударил крысу последним. // следующем тике перебить наш приказ тем, кто ударил крысу последним.
_pet.SetRevengeTarget(null); _pet.SetRevengeTarget(null);
_pet.SetAttackTarget(target, OrderTicks); _pet.SetAttackTarget(target, OrderTicks);
// Охрана (PetFollowPatch.Guard) сама раздаёт цели; приказ игрока она перебивать не должна.
PetFollowPatch.NoteOrder(_pet.entityId, target.entityId);
Debug.Log("[NecromancerTome] PetAttackCommand: pet " + _pet.entityId + " ordered to attack " + Debug.Log("[NecromancerTome] PetAttackCommand: pet " + _pet.entityId + " ordered to attack " +
target.entityId + " (" + target.EntityClass.entityClassName + ")"); target.entityId + " (" + target.EntityClass.entityClassName + ")");
return true; return true;
} }
} }
/// <summary>
/// ПРИЦЕЛ КНИГ И СВИТКОВ ПРИЗЫВА - указание 30.09.2026: «сделать другой прицел (более явный, из
/// уже имеющихся в игре) и чтобы он краснел при попадании в него врага. Как у змеи краснеет».
///
/// У ItemActionSpawnEntity своего прицела нет, он получал общий маленький «плюс». Игра рисует
/// прицел одним методом, EntityPlayerLocal.guiDrawCrosshair, и цвет там берётся из настроек,
/// так что подменить один лишь ВИД прицела мало - покраснеть он не сможет. Поэтому для наших
/// книг метод заменяется целиком: те же условия показа, тот же размер и та же готовая
/// прицел арбалета (DrawDynamicCrosshair, 4 штриха), а цвет - наш.
///
/// Красный = приказ будет принят: питомец этой книги призван и под прицелом годная цель
/// (PetAttackCommand.TargetUnderCrosshair - тот же разбор, что у самого приказа). Пока питомец
/// не призван, основное действие - это призыв, и красить прицел незачем.
///
/// Только у книг с приказом (PetInfo.CommandsAttack). Свиток Роя оставлен как был.
/// </summary>
[HarmonyLib.HarmonyPatch(typeof(EntityPlayerLocal), "guiDrawCrosshair")]
public static class Patch_EntityPlayerLocal_guiDrawCrosshair_PetCrosshair
{
/// <summary>Тот же красный, что у прицела змеи (SnakeCrosshair.OnTarget).</summary>
static readonly Color OnTarget = new Color(1f, 0.15f, 0.1f, 1f);
public static bool Prefix(EntityPlayerLocal __instance, NGuiWdwInGameHUD _guiInGame, bool bModalWindowOpen)
{
if (__instance.inventory == null)
{
return true;
}
ItemClass holding = __instance.inventory.holdingItem;
ItemActionSpawnEntity action = holding != null && holding.Actions != null && holding.Actions.Length > 0
? holding.Actions[0] as ItemActionSpawnEntity
: null;
if (action == null ||
!Patch_ItemActionSpawnEntity_Spawn_PetLimit.LimitedPets.TryGetValue(action.entityToSpawn,
out Patch_ItemActionSpawnEntity_Spawn_PetLimit.PetInfo petInfo) ||
!petInfo.CommandsAttack)
{
return true;
}
// Дальше - условия показа из самого guiDrawCrosshair, в том же порядке.
if (!_guiInGame.showCrosshair || Event.current.type != EventType.Repaint || __instance.IsDead() ||
__instance.emodel.IsRagdollActive || __instance.AttachedToEntity != null ||
!GamePrefs.GetBool(EnumGamePrefs.OptionsCrosshairEnabled) || bModalWindowOpen ||
_guiInGame.CrosshairSquare == null)
{
return false;
}
float opacity = GamePrefs.GetFloat(EnumGamePrefs.OptionsCrosshairOpacity);
int colorIndex = GamePrefs.GetInt(EnumGamePrefs.OptionsCrosshairColor);
if (colorIndex < 0 || colorIndex >= EntityPlayerLocal.crosshairColors.Count)
{
colorIndex = 0;
}
Color color = EntityPlayerLocal.crosshairColors[colorIndex];
color.a = opacity;
if (PetIsOut(__instance, action.entityToSpawn) && PetAttackCommand.TargetUnderCrosshair(__instance) != null)
{
color = OnTarget;
}
Vector2 center = __instance.GetCrosshairPosition2D();
center.y = Screen.height - center.y;
float size = Screen.height * 0.059f * GamePrefs.GetFloat(EnumGamePrefs.OptionsCrosshairScale);
// Прицел арбалета (тест 30.09: CrosshairDamage выглядел «кружком со стрелкой вниз»):
// четыре штриха игрового DrawDynamicCrosshair, длина та же, что у стрелкового оружия
// (size / 3); разброс у книги не меняется, поэтому зазор постоянный.
EntityPlayerLocal.DrawDynamicCrosshair(center.x, center.y, size * 0.22f, size * 0.22f, size / 3f,
GamePrefs.GetFloat(EnumGamePrefs.OptionsCrosshairThickness), color.a, color);
return false;
}
/// <summary>Призван ли сейчас питомец этой книги у этого игрока. По списку PetFollowPatch, а не
/// GetOwnedEntities: тот на каждый вызов заводит новый список, а мы здесь каждый кадр.</summary>
static bool PetIsOut(EntityPlayerLocal _player, string _entityClassName)
{
int classId = EntityClass.GetId(_entityClassName);
World world = _player.world;
if (classId == -1 || world == null)
{
return false;
}
for (int i = 0; i < PetFollowPatch.TrackedPets.Count; i++)
{
PetFollowPatch.TrackedPet tracked = PetFollowPatch.TrackedPets[i];
if (tracked.OwnerEntityId != _player.entityId)
{
continue;
}
Entity pet = world.GetEntity(tracked.PetEntityId);
if (pet != null && pet.entityClass == classId && !pet.IsDead())
{
return true;
}
}
return false;
}
}
} }
+135 -1
View File
@@ -74,6 +74,17 @@ namespace NecromancerTome
/// <summary>Радиус "дома" для летающих (см. PetInfo.FlyingHomeRadius). Ноль - /// <summary>Радиус "дома" для летающих (см. PetInfo.FlyingHomeRadius). Ноль -
/// питомец не летающий, дом ему не переставляем.</summary> /// питомец не летающий, дом ему не переставляем.</summary>
public int FlyingHomeRadius; public int FlyingHomeRadius;
/// <summary>Охраняет ли хозяина сам - копия PetInfo.Guards.</summary>
public bool Guards;
/// <summary>На кого игрок указал прицелом (PetAttackCommand). Пока эта цель жива и питомец
/// занят именно ею, охрана его не перенацеливает. -1 - приказа нет.</summary>
public int OrderedTargetId = -1;
/// <summary>Питомец ушёл дальше GuardLeash и возвращается: пока он не подойдёт к хозяину
/// на GuardRange, любые цели с него снимаются - иначе укус в спину разворачивал бы его.</summary>
public bool Returning;
} }
public static readonly List<TrackedPet> TrackedPets = new List<TrackedPet>(); public static readonly List<TrackedPet> TrackedPets = new List<TrackedPet>();
@@ -106,7 +117,7 @@ namespace NecromancerTome
/// <summary>Called from SummonPatch.cs right after a pet is created and owned.</summary> /// <summary>Called from SummonPatch.cs right after a pet is created and owned.</summary>
public static void Register(EntityAlive owner, Entity pet, bool ownFollowTask, bool collisionIgnored, public static void Register(EntityAlive owner, Entity pet, bool ownFollowTask, bool collisionIgnored,
bool wantsLitEyes, int flyingHomeRadius) bool wantsLitEyes, int flyingHomeRadius, bool guards)
{ {
TrackedPets.Add(new TrackedPet TrackedPets.Add(new TrackedPet
{ {
@@ -116,6 +127,7 @@ namespace NecromancerTome
CollisionIgnored = collisionIgnored, CollisionIgnored = collisionIgnored,
WantsLitEyes = wantsLitEyes, WantsLitEyes = wantsLitEyes,
FlyingHomeRadius = flyingHomeRadius, FlyingHomeRadius = flyingHomeRadius,
Guards = guards,
}); });
} }
@@ -190,6 +202,121 @@ namespace NecromancerTome
} }
} }
/// <summary>ОХРАНА ХОЗЯИНА - указание 30.09.2026. Образ: «ночь, мы в виде змеи ползаем на
/// другом конце карты, а тело охраняет стервятник». До этого питомец дрался только по приказу
/// (PetAttackCommand) - отзыв sanovskiy 23.09: «приказ нужен на каждую цель».
///
/// Решения пользователя по пунктам:
/// - радиус охраны 20 м от хозяина (GuardRange); в режиме змеи хозяин - это лежащее тело,
/// позиция игрока там и остаётся;
/// - охраняют все, кроме крысы (PetInfo.Guards);
/// - отходить можно до 40 м (GuardLeash), дальше - возвращается;
/// - бить ближайшего к телу, если не было приказа атаковать конкретного.
///
/// Враг - тот же, что для тревоги у тела в режиме змеи (SnakeControl.IsFoe): живой EntityEnemy,
/// не питомец и не девиант. СПЯЩИЕ НЕ В СЧЁТ: иначе питомец, проходящий сквозь стены, будил бы
/// всё здание, пока игрок крадётся, - к спящему слово «приближается» не относится.
///
/// Уже начатую цель охрана не бросает, пока та в радиусе: выбор «ближайшего» заново каждую
/// секунду заставил бы питомца метаться между двумя зомби и не добить ни одного.</summary>
public const float GuardRange = 20f;
public const float GuardLeash = 40f;
public static void NoteOrder(int petEntityId, int targetEntityId)
{
for (int i = 0; i < TrackedPets.Count; i++)
{
if (TrackedPets[i].PetEntityId == petEntityId)
{
TrackedPets[i].OrderedTargetId = targetEntityId;
TrackedPets[i].Returning = false;
}
}
}
public static EntityAlive NearestFoe(World world, Vector3 center, float range)
{
EntityAlive nearest = null;
float best = range * range;
for (int i = 0; i < world.EntityAlives.Count; i++)
{
EntityAlive e = world.EntityAlives[i];
if (!SnakeControl.IsFoe(e, null) || e.IsSleeping)
{
continue;
}
float distSq = (e.position - center).sqrMagnitude;
if (distSq < best)
{
best = distSq;
nearest = e;
}
}
return nearest;
}
public static void Guard(TrackedPet tracked, EntityAlive pet, EntityAlive owner, World world)
{
if (owner.IsDead())
{
return;
}
float fromOwnerSq = (pet.position - owner.position).sqrMagnitude;
EntityAlive target = pet.GetAttackTarget();
// Поводок 40 м - для всех спутников, и для приказа игрока тоже.
if (tracked.Returning)
{
if (fromOwnerSq > GuardRange * GuardRange)
{
if (target != null)
{
pet.SetRevengeTarget(null);
pet.SetAttackTarget(null, 0);
}
return;
}
tracked.Returning = false;
Debug.Log("[NecromancerTome] PetFollowPatch: pet " + pet.entityId + " вернулся к хозяину");
}
else if (fromOwnerSq > GuardLeash * GuardLeash)
{
tracked.Returning = true;
tracked.OrderedTargetId = -1;
pet.SetRevengeTarget(null);
pet.SetAttackTarget(null, 0);
Debug.Log("[NecromancerTome] PetFollowPatch: pet " + pet.entityId + " ушёл на " +
Mathf.Sqrt(fromOwnerSq).ToCultureInvariantString("0") + " м от хозяина - цель снята, возвращается");
return;
}
if (!tracked.Guards)
{
return;
}
bool live = target != null && !target.IsDead();
if (live && target.entityId == tracked.OrderedTargetId)
{
return;
}
tracked.OrderedTargetId = -1;
if (live && SnakeControl.IsFoe(target, null) &&
(target.position - owner.position).sqrMagnitude <= GuardRange * GuardRange)
{
return;
}
EntityAlive foe = NearestFoe(world, owner.position, GuardRange);
if (foe == null || foe == target)
{
return;
}
pet.SetRevengeTarget(null);
pet.SetAttackTarget(foe, PetAttackCommand.OrderTicks);
Debug.Log("[NecromancerTome] PetFollowPatch: охрана - pet " + pet.entityId + " идёт на " + foe.entityId +
" (" + foe.EntityClass.entityClassName + "), до хозяина " +
(foe.position - owner.position).magnitude.ToCultureInvariantString("0.0") + " м");
}
/// <summary>ПИТАНИЕ ПАДАЛЬЮ, 2026-09-18. Наблюдение пользователя: "Дух крысы после того как /// <summary>ПИТАНИЕ ПАДАЛЬЮ, 2026-09-18. Наблюдение пользователя: "Дух крысы после того как
/// зомби погибает, начинает есть труп. Пусть тогда этот процесс восстанавливает ей здоровье". /// зомби погибает, начинает есть труп. Пусть тогда этот процесс восстанавливает ей здоровье".
/// ///
@@ -392,6 +519,13 @@ namespace NecromancerTome
tracked.CollisionIgnored = TryIgnoreCollisionWithOwner(owner, pet); tracked.CollisionIgnored = TryIgnoreCollisionWithOwner(owner, pet);
} }
// Охрана и поводок 40 м - до блока про «дом» летающих: тот в этом же тике должен
// увидеть уже назначенную или снятую цель. Рой (не спутник) сюда не попадает.
if (tracked.Guards || tracked.OwnFollowTask || tracked.FlyingHomeRadius > 0)
{
Guard(tracked, pet, owner, world);
}
// СЛЕДОВАНИЕ ДЛЯ ЛЕТАЮЩИХ, 2026-09-18. Дом переставляется на хозяина каждую // СЛЕДОВАНИЕ ДЛЯ ЛЕТАЮЩИХ, 2026-09-18. Дом переставляется на хозяина каждую
// секунду, а возвращается питомец САМ, своим полётом: у EntityVulture проверка // секунду, а возвращается питомец САМ, своим полётом: у EntityVulture проверка
// "не ушёл ли из дома" встроена и работает каждые 60 тиков. Ни телепорта, ни // "не ушёл ли из дома" встроена и работает каждые 60 тиков. Ни телепорта, ни
+12 -1
View File
@@ -495,12 +495,23 @@ namespace NecromancerTome
/// <summary>Враг для змеи: живой EntityEnemy, не питомец (призрачный) и не девиант - зомби под /// <summary>Враг для змеи: живой EntityEnemy, не питомец (призрачный) и не девиант - зомби под
/// баффом Девиатора на стороне некроманта (указание 28.09: «если под бафом девианта, то это /// баффом Девиатора на стороне некроманта (указание 28.09: «если под бафом девианта, то это
/// не враг»). Общее для тревоги у тела и для прицела.</summary> /// не враг»). Общее для тревоги у тела и для прицела.</summary>
static bool IsFoe(EntityAlive _e, EntityAlive _snake) public static bool IsFoe(EntityAlive _e, EntityAlive _snake)
{ {
return _e != null && _e != _snake && _e is EntityEnemy && !_e.IsDead() && return _e != null && _e != _snake && _e is EntityEnemy && !_e.IsDead() &&
!IsNecroEntity(_e) &&
!GhostTraderPatch.ShouldBeGhost(_e) && !NecroCharmSide.IsCharmed(_e); !GhostTraderPatch.ShouldBeGhost(_e) && !NecroCharmSide.IsCharmed(_e);
} }
/// <summary>Питомцы и змея мода - тоже EntityEnemy (зомби-основа), и без этой проверки
/// охрана выбирала целью самого питомца: он ближе всех к хозяину (тест 30.09, лог
/// «pet 3838 идёт на 3838»). Все наши классы сущностей начинаются с necro.</summary>
public static bool IsNecroEntity(EntityAlive _e)
{
EntityClass cls;
return EntityClass.list.TryGetValue(_e.entityClass, out cls) && cls != null &&
cls.entityClassName != null && cls.entityClassName.StartsWith("necro");
}
/// <summary>Раз в полсекунды: есть ли живой враг ближе BodyDangerRange к телу игрока.</summary> /// <summary>Раз в полсекунды: есть ли живой враг ближе BodyDangerRange к телу игрока.</summary>
static void CheckBody(World _world, EntityPlayerLocal _player, EntityAlive _snake, float _dt) static void CheckBody(World _world, EntityPlayerLocal _player, EntityAlive _snake, float _dt)
{ {
+48 -1
View File
@@ -133,6 +133,11 @@ namespace NecromancerTome
/// таких питомцев выключается - две системы на одно и то же дёргали бы питомца в /// таких питомцев выключается - две системы на одно и то же дёргали бы питомца в
/// разные стороны.</summary> /// разные стороны.</summary>
public bool UsesFollowTask; public bool UsesFollowTask;
/// <summary>Охраняет ли питомец хозяина сам (PetFollowPatch.Guard) - указание 30.09.2026:
/// «охраняют все, кроме крысы». Рой сюда не входит: он не спутник, а разовое заклинание
/// со своим поиском целей.</summary>
public bool Guards;
} }
public static readonly Dictionary<string, PetInfo> LimitedPets = new Dictionary<string, PetInfo> public static readonly Dictionary<string, PetInfo> LimitedPets = new Dictionary<string, PetInfo>
@@ -153,6 +158,7 @@ namespace NecromancerTome
// AlreadyActiveKey у всех четверых теперь не читается никогда. // AlreadyActiveKey у всех четверых теперь не читается никогда.
CommandsAttack = true, CommandsAttack = true,
NoTargetKey = "necroPetNoTarget", NoTargetKey = "necroPetNoTarget",
Guards = true,
UsesFollowTask = true, UsesFollowTask = true,
} }
}, },
@@ -199,6 +205,7 @@ namespace NecromancerTome
// AlreadyActiveKey у всех четверых теперь не читается никогда. // AlreadyActiveKey у всех четверых теперь не читается никогда.
CommandsAttack = true, CommandsAttack = true,
NoTargetKey = "necroPetNoTarget", NoTargetKey = "necroPetNoTarget",
Guards = true,
} }
}, },
{ {
@@ -217,6 +224,7 @@ namespace NecromancerTome
// AlreadyActiveKey у всех четверых теперь не читается никогда. // AlreadyActiveKey у всех четверых теперь не читается никогда.
CommandsAttack = true, CommandsAttack = true,
NoTargetKey = "necroPetNoTarget", NoTargetKey = "necroPetNoTarget",
Guards = true,
UsesFollowTask = true, UsesFollowTask = true,
} }
}, },
@@ -236,6 +244,7 @@ namespace NecromancerTome
// AlreadyActiveKey у всех четверых теперь не читается никогда. // AlreadyActiveKey у всех четверых теперь не читается никогда.
CommandsAttack = true, CommandsAttack = true,
NoTargetKey = "necroPetNoTarget", NoTargetKey = "necroPetNoTarget",
Guards = true,
UsesFollowTask = true, UsesFollowTask = true,
} }
}, },
@@ -340,10 +349,48 @@ namespace NecromancerTome
} }
return false; return false;
} }
RecallOtherCompanions(holdingEntity, __instance.entityToSpawn, tooltips);
summonAllowedThisCall = true; summonAllowedThisCall = true;
return true; return true;
} }
/// <summary>ОДИН ПИТОМЕЦ ЗА РАЗ - указание 30.09.2026: «за один раз можно было призвать только
/// одного питомца. А то слишком жирно сразу и собаку, и крысу, и стервятника, и медведя».
/// Раньше предел был по одному НА ВИД, и все пятеро могли ходить вместе.
///
/// Сделано заменой, а не отказом: призыв нового сам отзывает прежнего. Игроку не надо
/// искать в поясе другую книгу, чтобы отозвать, и новых строк перевода не нужно - о прежнем
/// сообщает его же готовая надпись «… отозван».
///
/// Спутник - это вид с отзывом (RecalledKey). Рой Жуков не спутник: он разовое заклинание
/// без отзыва, сам никого не вытесняет, и его не вытесняют.</summary>
public static void RecallOtherCompanions(EntityAlive _owner, string _summoned, PetInfo _summonedInfo)
{
if (_summonedInfo.RecalledKey == null)
{
return;
}
foreach (KeyValuePair<string, PetInfo> entry in LimitedPets)
{
if (entry.Key == _summoned || entry.Value.RecalledKey == null)
{
continue;
}
int classId = EntityClass.GetId(entry.Key);
if (classId == -1)
{
continue;
}
List<OwnedEntityData> others = _owner.GetOwnedEntities(classId);
for (int i = others.Count - 1; i >= 0; i--)
{
Debug.Log("[NecromancerTome] SummonPatch: один питомец за раз - " + entry.Key + " " + others[i].Id +
" отозван ради " + _summoned);
RecallPet(_owner, others[i].Id, entry.Value.RecalledKey, null);
}
}
}
/// <summary>ДОБАВЛЕНО 2026-09-18, после чтения ItemActionSpawnEntity.Spawn целиком. /// <summary>ДОБАВЛЕНО 2026-09-18, после чтения ItemActionSpawnEntity.Spawn целиком.
/// Последней строкой ваниль делает вот что: /// Последней строкой ваниль делает вот что:
/// ///
@@ -634,7 +681,7 @@ namespace NecromancerTome
// Поэтому результат передаётся в Register, и тик повторяет попытку до успеха. // Поэтому результат передаётся в Register, и тик повторяет попытку до успеха.
bool collisionIgnored = PetFollowPatch.TryIgnoreCollisionWithOwner(owner, __result); bool collisionIgnored = PetFollowPatch.TryIgnoreCollisionWithOwner(owner, __result);
PetFollowPatch.Register(owner, __result, petInfo.UsesFollowTask, collisionIgnored, petInfo.LitEyes, PetFollowPatch.Register(owner, __result, petInfo.UsesFollowTask, collisionIgnored, petInfo.LitEyes,
petInfo.FlyingHomeRadius); petInfo.FlyingHomeRadius, petInfo.Guards);
AttachFollowTask(owner, __result, petInfo); AttachFollowTask(owner, __result, petInfo);
// Полупрозрачность больше НЕ делается здесь. Бывший ApplyGhostlyTransparency ставил // Полупрозрачность больше НЕ делается здесь. Бывший ApplyGhostlyTransparency ставил
// альфу в material.color и не работал ни разу: шейдеры моделей мобов альфу не // альфу в material.color и не работал ни разу: шейдеры моделей мобов альфу не
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB