Files
necromants-tome-7d2d-3-2/HarmonySrc/PetCommandPatch.cs
T
AlexCubeandClaude Opus 5.5 31aa99860c Одержимость питомцев: роли медведя, волка, собаки и крысы; ноша стервятника; череп при обыске
- Могильная крыса: своя модель без волчьей шерсти; стоит на земле (центр капсулы);
  E - осмотр контейнеров без права взять (окно игры, рюкзак скрыт, отказ «инвентарь
  заполнен», контейнер не разрушается); ПКМ - проход сквозь постройки, полупрозрачная.
- Стервятник: курица в когтях - слепок с обвисшими крыльями и шеей, без хитбокса.
- Зомбособака: нюх - все существа рядом на компасе и карте.
- Медведь: толстая шкура (вдвое меньше урона), рёв-провокация, удар лапой по блокам.
- Лютоволк: бросок на цель с опрокидыванием, след крови раненых, сумка на 3 ячейки
  (лут, выгрузка у тела, выпадает мешком при гибели и отзыве; отдельный файл сейва).
- Череп (останки): 2% при обычном обыске красного трупа вместо браслета.
- Прицел книги не рисуется в одержимости; подсказки вселения на 13 языках.
- Замер EntityLeakProbe: удалённые, но живые в памяти сущности - в лог.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:58:40 +03:00

208 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// КОМАНДА "АТАКУЙ ТО, НА ЧТО Я СМОТРЮ" - второй режим свитка Духа крысы, указание
/// 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 - это разрешение задаче подхватить любую цель, которую ей дали;
/// решение "кого дать" целиком принимается тут.
/// </summary>
public static class PetAttackCommand
{
/// <summary>Сколько тиков держится приказ. attackTargetTime уменьшается на 1 за тик ИИ и
/// на нуле сбрасывает цель (EntityAlive: "if (attackTargetTime > 0) { attackTargetTime--;
/// ... }"), так что это по сути "пока не убьёт". 6000 - заведомо больше любой драки;
/// смысл не в таймере, а в том, чтобы приказ не висел вечно, если цель уйдёт из мира.</summary>
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>
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;
}
}
/// <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)
{
// В одержимости тело держит книгу питомца, но цель под прицелом тела не обновляется (управление тела
// выключено) - прицел книги застывал красным (тест 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;
}
/// <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;
}
}
}