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)
{
// В одержимости тело держит книгу питомца, но цель под прицелом тела не обновляется (управление тела
// выключено) - прицел книги застывал красным (тест 09.10: «у стервятника прицел всё время красный»).
// Прицел одержимого - обычный, со своей подсветкой (Possession.AimTint).
if (__instance.inventory == null || Possession.Active)
{
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;
}
}
}