using UnityEngine; namespace NecromancerTome { /// /// КОМАНДА "АТАКУЙ ТО, НА ЧТО Я СМОТРЮ" - второй режим свитка Духа крысы, указание /// 2026-09-18: "у свитка призыва есть два режима (призыв и отзыв). Если крыса уже призвана, /// то призыв становится атакой. И крыса берёт целью атаки того, на кого указал прицел /// игрока. Если в прицеле нету целей для атаки, то пишется надпись "Нет цели для атаки"". /// /// Это не Harmony-заплатка, а helper: точка входа одна и та же для всех питомцев - /// префикс на ItemActionSpawnEntity.Spawn в SummonPatch.cs. Второй патч на тот же метод /// только запутал бы порядок исполнения. /// /// ОТКУДА БЕРЁТСЯ ЦЕЛЬ ПОД ПРИЦЕЛОМ. EntityPlayerLocal.HitInfo - публичное поле типа /// WorldRayHitInfo, которое PlayerMoveController перезаписывает КАЖДЫЙ КАДР результатом луча /// из прицела (Voxel.Raycast по GetLookRay, длина Utils.FastMax(cDigAndBuildDistance, /// cCollectItemDistance, 30f), то есть не меньше 30 метров). Ничего своего лучить не надо - /// игра уже посчитала это для интерфейса. Из попадания сущность достаётся собственным /// публичным методом игры ItemActionAttack.GetEntityFromHit(hitInfo), который внутри зовёт /// GameUtils.GetHitRootEntity(tag, transform) - то же самое, чем пользуется обычный удар /// оружием, а значит и попадание по конечности/голове разрешится в саму сущность. /// /// Луч упирается в блоки - и это правильно: натравить крысу сквозь стену на то, чего игрок /// не видит, нельзя. /// /// КОГО НЕЛЬЗЯ НАЗНАЧИТЬ ЦЕЛЬЮ (указание "пусть атакует всех существ кроме NPC торговцев"): /// - EntityTrader - прямо по указанию; /// - EntityPlayer - сам хозяин и вообще игроки: крыса не оружие против людей; /// - собственный хозяин и она сама - на всякий случай, отдельной проверкой; /// - мёртвые. /// Отсев сделан ЗДЕСЬ, а не в entityclasses.xml, потому что targetClasses у /// EAIApproachAndAttackTarget умеет только РАЗРЕШАТЬ тип, запретить им нельзя. В XML у крысы /// стоит class=EntityAlive - это разрешение задаче подхватить любую цель, которую ей дали; /// решение "кого дать" целиком принимается тут. /// public static class PetAttackCommand { /// Сколько тиков держится приказ. attackTargetTime уменьшается на 1 за тик ИИ и /// на нуле сбрасывает цель (EntityAlive: "if (attackTargetTime > 0) { attackTargetTime--; /// ... }"), так что это по сути "пока не убьёт". 6000 - заведомо больше любой драки; /// смысл не в таймере, а в том, чтобы приказ не висел вечно, если цель уйдёт из мира. public const int OrderTicks = 6000; /// Кого игрок сейчас держит в прицеле и кого можно назначить целью. null - некого. /// Общее для приказа и для покраснения прицела (PetCrosshair): прицел краснеет ровно тогда, /// когда приказ будет принят. Свои питомцы целью не бывают. 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; } /// Отдаёт питомцу приказ атаковать то, на что смотрит игрок. false - приказа не /// вышло, и вызывающая сторона показывает "Нет цели для атаки". public static bool TryOrderAttack(EntityAlive _owner, EntityAlive _pet) { if (_owner == null || _pet == null || _pet.IsDead()) { return false; } EntityPlayerLocal local = _owner as EntityPlayerLocal; if (local == null) { // Не локальный игрок - HitInfo просто неоткуда взять (у мода нет сетевого слоя // нигде, см. SummonPatch.cs). Молча отказываем, как будто цели нет. Debug.Log("[NecromancerTome] PetAttackCommand: owner is not the local player, no crosshair to read"); return false; } EntityAlive target = TargetUnderCrosshair(local); if (target == null || target == _pet) { return false; } // Месть сбрасывается вместе с приказом: иначе задача SetAsTargetIfHurt могла бы на // следующем тике перебить наш приказ тем, кто ударил крысу последним. _pet.SetRevengeTarget(null); _pet.SetAttackTarget(target, OrderTicks); // Охрана (PetFollowPatch.Guard) сама раздаёт цели; приказ игрока она перебивать не должна. PetFollowPatch.NoteOrder(_pet.entityId, target.entityId); Debug.Log("[NecromancerTome] PetAttackCommand: pet " + _pet.entityId + " ordered to attack " + target.entityId + " (" + target.EntityClass.entityClassName + ")"); return true; } } /// /// ПРИЦЕЛ КНИГ И СВИТКОВ ПРИЗЫВА - указание 30.09.2026: «сделать другой прицел (более явный, из /// уже имеющихся в игре) и чтобы он краснел при попадании в него врага. Как у змеи краснеет». /// /// У ItemActionSpawnEntity своего прицела нет, он получал общий маленький «плюс». Игра рисует /// прицел одним методом, EntityPlayerLocal.guiDrawCrosshair, и цвет там берётся из настроек, /// так что подменить один лишь ВИД прицела мало - покраснеть он не сможет. Поэтому для наших /// книг метод заменяется целиком: те же условия показа, тот же размер и та же готовая /// прицел арбалета (DrawDynamicCrosshair, 4 штриха), а цвет - наш. /// /// Красный = приказ будет принят: питомец этой книги призван и под прицелом годная цель /// (PetAttackCommand.TargetUnderCrosshair - тот же разбор, что у самого приказа). Пока питомец /// не призван, основное действие - это призыв, и красить прицел незачем. /// /// Только у книг с приказом (PetInfo.CommandsAttack). Свиток Роя оставлен как был. /// [HarmonyLib.HarmonyPatch(typeof(EntityPlayerLocal), "guiDrawCrosshair")] public static class Patch_EntityPlayerLocal_guiDrawCrosshair_PetCrosshair { /// Тот же красный, что у прицела змеи (SnakeCrosshair.OnTarget). 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; } /// Призван ли сейчас питомец этой книги у этого игрока. По списку PetFollowPatch, а не /// GetOwnedEntities: тот на каждый вызов заводит новый список, а мы здесь каждый кадр. 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; } } }