Одержимость питомцев: роли медведя, волка, собаки и крысы; ноша стервятника; череп при обыске

- Могильная крыса: своя модель без волчьей шерсти; стоит на земле (центр капсулы);
  E - осмотр контейнеров без права взять (окно игры, рюкзак скрыт, отказ «инвентарь
  заполнен», контейнер не разрушается); ПКМ - проход сквозь постройки, полупрозрачная.
- Стервятник: курица в когтях - слепок с обвисшими крыльями и шеей, без хитбокса.
- Зомбособака: нюх - все существа рядом на компасе и карте.
- Медведь: толстая шкура (вдвое меньше урона), рёв-провокация, удар лапой по блокам.
- Лютоволк: бросок на цель с опрокидыванием, след крови раненых, сумка на 3 ячейки
  (лут, выгрузка у тела, выпадает мешком при гибели и отзыве; отдельный файл сейва).
- Череп (останки): 2% при обычном обыске красного трупа вместо браслета.
- Прицел книги не рисуется в одержимости; подсказки вселения на 13 языках.
- Замер EntityLeakProbe: удалённые, но живые в памяти сущности - в лог.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AlexCube
2026-10-09 00:58:40 +03:00
co-authored by Claude Opus 5.5
parent beab1640b0
commit 31aa99860c
25 changed files with 2461 additions and 210 deletions
+20 -131
View File
@@ -3,155 +3,44 @@ using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Череп из красного трупа (BACKLOG «Некротехнолог v2», указание 06.10): тот же жест, что снятие
/// блока браслетом, - зажатая обычная атака с браслетом в руке, круговой таймер. Только целью
/// здесь не блок, а труп зомби, погибшего под дебаффом жертвы (CorpseLoot.IsSearchable). Труп -
/// сущность, а не блок, поэтому SpatialVaultPickup.Begin сперва спрашивает этот класс.
/// Череп (останки) из красного трупа - сырьё для цинерария (дух-работник).
///
/// Шанс 2% («так ресурс станет ценнее»): удача - череп в пространственное хранилище, труп остаётся
/// (его можно обыскать), второй попытки на нём нет (cvar без точки - пишется в сейв вместе с трупом);
/// неудача - труп уходит в чёрный разрыв (NecroBurst), вместе с недобранной добычей.
/// С 09.10 - находка при обычном обыске (указание: «браслет больше не собирает красные трупы,
/// выпиливаем; останки дропаются с шансом 2% при обычном лутании красного трупа»). Раньше (06.10) череп
/// вырывали пространственным браслетом: зажатая атака по трупу, три секунды канала, при неудаче труп
/// уходил в чёрный разрыв. Сняли из-за подозрения на утечку памяти на этом пути (тест 09.10: после
/// разрывов в памяти копились экземпляры сущностей - 31 при 7 живых).
///
/// Цена та же, что у блоков: без сферы или камня в слоте браслет не работает, сфера платит
/// прочностью за секунды канала. Разрыв делает сервер - в мультиплеере клиенту пока только удача
/// (труп не исчезает); решать вместе с прочим мультиплеером урн.
/// Бросок - один раз на труп, при первом открытии его сумки, на сервере, сразу после того, как игра
/// наполнила её добычей (Patch_EntityAlive_OnLockRequestServer_Corpse). Шанс прежний - 2%
/// («так ресурс станет ценнее»).
/// </summary>
public static class NecroSkull
{
public const string ItemName = "necroSkull";
/// <summary>На трупе: попытка уже была удачной.</summary>
public const string TakenCVar = "necroSkullTaken";
public const float Chance = 0.02f;
/// <summary>Короче десяти секунд блока: шанс мал, попытка должна быть дешёвой.</summary>
public const float ChannelSeconds = 3f;
/// <summary>Дальше этого тянуть череп нельзя - как дистанция обыска трупа у игры.</summary>
public static float MaxDistance => Constants.cCollectItemDistance;
public const string MsgChanneling = "necroSkullChanneling";
public const string MsgTaken = "necroSkullAlreadyTaken";
public const string MsgExtracted = "necroSkullExtracted";
public const string MsgFailed = "necroSkullFailed";
public class SkullJob : SpatialVaultPickup.PickupJob
/// <summary>Бросок на череп в только что наполненную сумку трупа. Места нет - сумка на ячейку больше.</summary>
public static void Roll(EntityAlive _corpse, Bag _bag)
{
public int CorpseId;
}
/// <summary>Красный труп под прицелом (так же, как игра ищет сущность для E -
/// PlayerMoveController.TryHandleEntity), иначе null.</summary>
public static EntityAlive CorpseAtCrosshair(EntityPlayerLocal _player)
{
WorldRayHitInfo hit = _player.HitInfo;
if (hit == null || !hit.bHitValid || hit.tag == null || !hit.tag.StartsWith("E_") ||
hit.hit.distanceSq > MaxDistance * MaxDistance)
{
return null;
}
Transform root = GameUtils.GetHitRootTransform(hit.tag, hit.transform);
EntityAlive corpse = root != null ? root.GetComponent<Entity>() as EntityAlive : null;
return corpse != null && corpse.IsDead() && CorpseLoot.IsSearchable(corpse) ? corpse : null;
}
/// <summary>Обычная атака браслетом: true - под прицелом красный труп, нажатие наше.</summary>
public static bool TryBegin(EntityPlayerLocal _player, ItemValue _bracelet)
{
EntityAlive corpse = CorpseAtCrosshair(_player);
if (corpse == null)
{
return false;
}
if (corpse.Buffs.GetCustomVar(TakenCVar) > 0f)
{
SpatialVaultPickup.Deny(_player, MsgTaken);
return true;
}
Bag bag = SpatialVaultPickup.GetVault(_player);
ItemValue skull = ItemClass.GetItem(ItemName);
if (bag == null || skull == null || skull.IsEmpty())
{
return true;
}
if (!bag.CanTakeItem(new ItemStack(skull, 1)))
{
SpatialVaultPickup.Deny(_player, SpatialVaultPickup.MsgVaultFull);
return true;
}
TimerEventData timerData = new TimerEventData
{
Data = new SkullJob
{
Player = _player,
Bracelet = _bracelet,
ChannelSeconds = ChannelSeconds,
CorpseId = corpse.entityId
},
CloseOnHit = true,
CancelWithActivateButton = true
};
timerData.FullTimeFinishEvent += OnChannelComplete;
timerData.CloseEvent += delegate
{
ChannelVision.End(_player);
};
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(_player);
XUiC_Timer.OpenTimer(playerUI.xui, ChannelSeconds, timerData, -1f, Localization.Get(MsgChanneling));
ChannelVision.Begin(_player);
Debug.Log("[NecromancerTome] NecroSkull: " + _player.entityId + " тянет череп из трупа " + corpse.entityId);
return true;
}
static void OnChannelComplete(TimerEventData _timerData)
{
if (!(_timerData.Data is SkullJob job) || job.Player == null)
if (_bag == null || skull == null || skull.IsEmpty())
{
return;
}
ChannelVision.End(job.Player);
World world = GameManager.Instance != null ? GameManager.Instance.World : null;
EntityAlive corpse = world?.GetEntity(job.CorpseId) as EntityAlive;
// Труп обыскали до дна и он исчез, его разорвали или уже вытянули череп - пока шёл круг.
if (corpse == null || !corpse.IsDead() || !CorpseLoot.IsSearchable(corpse))
{
SpatialVaultPickup.Deny(job.Player, SpatialVaultPickup.MsgBlockMissing);
return;
}
if (corpse.Buffs.GetCustomVar(TakenCVar) > 0f)
{
SpatialVaultPickup.Deny(job.Player, MsgTaken);
return;
}
Bag bag = SpatialVaultPickup.GetVault(job.Player);
ItemValue skull = ItemClass.GetItem(ItemName);
if (bag == null || skull == null || skull.IsEmpty())
{
return;
}
float roll = Random.value;
bool success = roll < Chance;
Debug.Log("[NecromancerTome] NecroSkull: труп " + corpse.entityId + " - бросок " +
roll.ToString("0.000") + " против " + Chance.ToString("0.00") + " -> " + (success ? "ЧЕРЕП" : "разрыв"));
if (success)
Debug.Log("[NecromancerTome] NecroSkull: труп " + _corpse.entityId + " - бросок " + roll.ToString("0.000") +
" против " + Chance.ToString("0.00") + (success ? " -> ЧЕРЕП" : ""));
if (!success)
{
if (!bag.AddItem(new ItemStack(skull, 1)))
{
SpatialVaultPickup.Deny(job.Player, SpatialVaultPickup.MsgVaultFull);
return;
}
corpse.Buffs.SetCustomVar(TakenCVar, 1f);
GameManager.ShowTooltip(job.Player, MsgExtracted);
return;
}
else
ItemStack stack = new ItemStack(skull, 1);
if (!_bag.AddItem(stack.Clone()))
{
GameManager.ShowTooltip(job.Player, MsgFailed);
NecroBurst.Burst(corpse, job.Player);
}
// Попытка оплачена в обоих исходах: канал отработан.
SpatialVaultPickup.SpendCharge(job);
if (success)
{
GameManager.Instance.SaveLocalPlayerData();
_bag.Resize(new Vector2i(_bag.SlotCount + 1, 1));
_bag.AddItem(stack);
}
}
}