Одержимость питомцев, стервятник-разведчик, метки на карте, череп, глава письма

- Одержимость питомца: ПКМ с книгой - вселиться, E - отозвать (и изнутри питомца),
  пояс скрыт, надпись о враге у тела.
- Одержимый стервятник: полёт, рывок на ЛКМ с 10 м с прямым уроном, звуком и
  кровотечением, обычный прицел краснеет на цели; посадка в позу 0 и взлёт на пробел;
  ПКМ - клич-приманка (паникующая курица, откат 0.5 с); F - питаться трупами и
  блоками-падалью, доеденное исчезает.
- Дух змеи атакует только нежить; фиолетовая метка на карте у змеи и одержимого.
- Череп из красного трупа (этап 1 цинерария); глава книги «Письмо архимага» с видео.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AlexCube
2026-10-06 22:09:28 +03:00
co-authored by Claude Opus 5.5
parent a79bb085ab
commit beab1640b0
16 changed files with 1287 additions and 57 deletions
+158
View File
@@ -0,0 +1,158 @@
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Череп из красного трупа (BACKLOG «Некротехнолог v2», указание 06.10): тот же жест, что снятие
/// блока браслетом, - зажатая обычная атака с браслетом в руке, круговой таймер. Только целью
/// здесь не блок, а труп зомби, погибшего под дебаффом жертвы (CorpseLoot.IsSearchable). Труп -
/// сущность, а не блок, поэтому SpatialVaultPickup.Begin сперва спрашивает этот класс.
///
/// Шанс 2% («так ресурс станет ценнее»): удача - череп в пространственное хранилище, труп остаётся
/// (его можно обыскать), второй попытки на нём нет (cvar без точки - пишется в сейв вместе с трупом);
/// неудача - труп уходит в чёрный разрыв (NecroBurst), вместе с недобранной добычей.
///
/// Цена та же, что у блоков: без сферы или камня в слоте браслет не работает, сфера платит
/// прочностью за секунды канала. Разрыв делает сервер - в мультиплеере клиенту пока только удача
/// (труп не исчезает); решать вместе с прочим мультиплеером урн.
/// </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
{
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)
{
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)
{
if (!bag.AddItem(new ItemStack(skull, 1)))
{
SpatialVaultPickup.Deny(job.Player, SpatialVaultPickup.MsgVaultFull);
return;
}
corpse.Buffs.SetCustomVar(TakenCVar, 1f);
GameManager.ShowTooltip(job.Player, MsgExtracted);
}
else
{
GameManager.ShowTooltip(job.Player, MsgFailed);
NecroBurst.Burst(corpse, job.Player);
}
// Попытка оплачена в обоих исходах: канал отработан.
SpatialVaultPickup.SpendCharge(job);
if (success)
{
GameManager.Instance.SaveLocalPlayerData();
}
}
}
}