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

- Могильная крыса: своя модель без волчьей шерсти; стоит на земле (центр капсулы);
  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
File diff suppressed because one or more lines are too long
+9 -1
View File
@@ -57,6 +57,10 @@
<!-- User request 2026-08-28: bite applies buffInjurySlow, same debuff the Insect Swarm <!-- User request 2026-08-28: bite applies buffInjurySlow, same debuff the Insect Swarm
originally had before its own rework - see necroMeleeHandZombieDog in items.xml. --> originally had before its own rework - see necroMeleeHandZombieDog in items.xml. -->
<property name="HandItem" value="necroMeleeHandZombieDog"/> <property name="HandItem" value="necroMeleeHandZombieDog"/>
<!-- Зелёная метка питомца на карте и компасе (указание 08.10: «добавь значки всем питомцам») -
тот же класс, что у Духа крысы (Config/nav_objects.xml). Один класс вместо ванильного списка
(twitch_spawn, animaltracking_*...): из нескольких классов игра показывает какой сама решит. -->
<property name="NavObject" value="necroRatSpiritPet"/>
<!-- ИИ ПЕРЕПИСАН 2026-09-18 ПОД ОБРАЗЕЦ ДУХА КРЫСЫ, прямое указание: "поведение <!-- ИИ ПЕРЕПИСАН 2026-09-18 ПОД ОБРАЗЕЦ ДУХА КРЫСЫ, прямое указание: "поведение
других призванных животных зомби тоже подгони под крысу. По сути это те же других призванных животных зомби тоже подгони под крысу. По сути это те же
@@ -292,6 +296,10 @@
<property name="IsEnemyEntity" value="false"/> <property name="IsEnemyEntity" value="false"/>
<property name="Faction" value="none"/> <property name="Faction" value="none"/>
<property name="HandItem" value="necroMeleeHandZombieGriffin"/> <property name="HandItem" value="necroMeleeHandZombieGriffin"/>
<!-- Зелёная метка питомца на карте и компасе (указание 08.10: «добавь значки всем питомцам») -
тот же класс, что у Духа крысы (Config/nav_objects.xml). Один класс вместо ванильного списка
(twitch_spawn, animaltracking_*...): из нескольких классов игра показывает какой сама решит. -->
<property name="NavObject" value="necroRatSpiritPet"/>
<effect_group name="Base Effects"> <effect_group name="Base Effects">
<!-- У ванильного зомбостервятника 30 HP - для питомца, которого не отозвать до <!-- У ванильного зомбостервятника 30 HP - для питомца, которого не отозвать до
следующего призыва, это ничто. 750 оставлено с прежней, наземной версии. --> следующего призыва, это ничто. 750 оставлено с прежней, наземной версии. -->
@@ -356,7 +364,7 @@
<property name="SoundAttack" value="rabbitpain"/> <property name="SoundAttack" value="rabbitpain"/>
<property name="SoundStepType" value="animallightstep"/> <property name="SoundStepType" value="animallightstep"/>
<!-- Жёлтая метка на карте и компасе - указание 2026-09-18. Класс метки наш, <!-- Метка на карте и компасе - указание 2026-09-18 (жёлтая, с 08.10 зелёная, как у всех питомцев). Класс метки наш,
Config/nav_objects.xml; ванильные animaltracking_* не подошли, они завязаны Config/nav_objects.xml; ванильные animaltracking_* не подошли, они завязаны
на перк следопыта, а не на прямое назначение. --> на перк следопыта, а не на прямое назначение. -->
<property name="NavObject" value="necroRatSpiritPet"/> <property name="NavObject" value="necroRatSpiritPet"/>
+26 -8
View File
@@ -14,7 +14,8 @@
животных (перк следопыта), а не через прямое назначение. --> животных (перк следопыта), а не через прямое назначение. -->
<append xpath="/nav_object_classes"> <append xpath="/nav_object_classes">
<!-- Сам питомец - ЖЁЛТЫМ, по прямому указанию. <!-- Сам питомец - ЗЕЛЁНЫМ (указание 08.10: «пусть значки питомцев на карте будут зелёными»;
до 08.10 был жёлтым 255,220,0 - тоже по указанию).
СПРАЙТ: "воскрешение" (пробуем, указание 2026-09-18 - "для крысы попробуй иконку СПРАЙТ: "воскрешение" (пробуем, указание 2026-09-18 - "для крысы попробуй иконку
воскрешения, проверим как смотрится"). Кроличий, стоявший тут сначала, не подошёл по воскрешения, проверим как смотрится"). Кроличий, стоявший тут сначала, не подошёл по
@@ -22,22 +23,24 @@
имён вида ui_game_symbol_* из Data/Config и из самой сборки; ближайшее по названию имён вида ui_game_symbol_* из Data/Config и из самой сборки; ближайшее по названию
было specters_grace (бафф "Призрачная грация", перк Ловкости), но у воскрешения было specters_grace (бафф "Призрачная грация", перк Ловкости), но у воскрешения
смысл к некромантии ближе. Если в игре окажется невнятно, запасные - specters_grace смысл к некромантии ближе. Если в игре окажется невнятно, запасные - specters_grace
и skull (череп, он же значок самой Некромантии), меняется одна строка. --> и skull (череп, он же значок самой Некромантии), меняется одна строка.
С 08.10 - значок дебаффа «Почти смерть» (ui_game_symbol_near_death_trauma, вешается игроку
после гибели): «больше похож на смерть с косой» (указание 08.10), как у одержимого. -->
<nav_object_class name="necroRatSpiritPet"> <nav_object_class name="necroRatSpiritPet">
<map_settings> <map_settings>
<property name="sprite_name" value="ui_game_symbol_twitch_resurrect"/> <property name="sprite_name" value="ui_game_symbol_near_death_trauma"/>
<property name="min_distance" value="0"/> <property name="min_distance" value="0"/>
<property name="max_distance" value="-1"/> <property name="max_distance" value="-1"/>
<property name="color" value="255,220,0,255"/> <property name="color" value="70,220,70,255"/>
<property name="has_pulse" value="false"/> <property name="has_pulse" value="false"/>
</map_settings> </map_settings>
<compass_settings> <compass_settings>
<property name="sprite_name" value="ui_game_symbol_twitch_resurrect"/> <property name="sprite_name" value="ui_game_symbol_near_death_trauma"/>
<property name="min_distance" value="0"/> <property name="min_distance" value="0"/>
<property name="max_distance" value="1024"/> <property name="max_distance" value="1024"/>
<property name="max_scale_distance" value="32"/> <property name="max_scale_distance" value="32"/>
<property name="color" value="255,220,0,255"/> <property name="color" value="70,220,70,255"/>
<property name="has_pulse" value="false"/> <property name="has_pulse" value="false"/>
<property name="icon_clamped" value="false"/> <property name="icon_clamped" value="false"/>
</compass_settings> </compass_settings>
@@ -85,10 +88,25 @@
</nav_object_class> </nav_object_class>
</append> </append>
<!-- Одержимый на карте (08.10): питомец или зомби, в которого вселился игрок, - ЗЕЛЁНЫЙ значок
«Почти смерть» (ui_game_symbol_near_death_trauma, выбран 06.10). Раньше одержимый носил класс
змеи с подменой значка, а подмена у класса не была включена (нужен use_override_icon) - на карте
была змея. Своему классу подмена не нужна: значок прямо здесь. Только карта, как у змеи. -->
<append xpath="/nav_object_classes">
<nav_object_class name="necroPossessed">
<map_settings>
<property name="sprite_name" value="ui_game_symbol_near_death_trauma"/>
<property name="min_distance" value="0"/>
<property name="max_distance" value="-1"/>
<property name="color" value="70,220,70,255"/>
<property name="has_pulse" value="false"/>
</map_settings>
</nav_object_class>
</append>
<!-- Дух змеи на карте (02.10.2026, просьба пользователя): фиолетовая змея - иконка следа змеи <!-- Дух змеи на карте (02.10.2026, просьба пользователя): фиолетовая змея - иконка следа змеи
(ui_game_symbol_tracking_snake, есть в игре). Только карта: на компасе метка своей же (ui_game_symbol_tracking_snake, есть в игре). Только карта: на компасе метка своей же
змеи бессмысленна. С 06.10 этот же класс - метка одержимого (зомби или питомца), значок змеи бессмысленна. (С 06.10 по 08.10 этот класс носил и одержимый - теперь у него necroPossessed.) Вешает NecroMapMarker.cs отдельной меткой на Transform модели, как игра
подставляется кодом. Вешает NecroMapMarker.cs отдельной меткой на Transform модели, как игра
вешает метку транспорта (через Entity.AddNavObject метка змеи так и не показалась). --> вешает метку транспорта (через Entity.AddNavObject метка змеи так и не показалась). -->
<append xpath="/nav_object_classes"> <append xpath="/nav_object_classes">
<nav_object_class name="necroSnakeSpirit"> <nav_object_class name="necroSnakeSpirit">
+113
View File
@@ -0,0 +1,113 @@
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Одержимый медведь - танк (решено 09.10, BACKLOG «Медведь и лютоволк в одержимости»):
/// - толстая шкура: пока в нём игрок, урон по нему вдвое меньше (вне одержимости медведь прежний);
/// - рёв на ПКМ: зомби ближе RoarRange бросают свои цели и идут на медведя, спящие просыпаются -
/// увести толпу от тела или базы. Та же провокация, что каждую секунду делает одержимость
/// (Possession.Taunt), только шире, без «видит ли» и на 20 с;
/// - удар лапой по блоку: ЛКМ по блоку под прицелом бьёт его на SmashDamage (дерево и двери за 2-3
/// удара, бетон дольше).
/// </summary>
public static class BearControl
{
public const string BearClass = "necroZombieBear";
public const float RoarRange = 15f;
public const float RoarCooldown = 8f;
public const int RoarTicks = 20 * 20;
public const string RoarSound = "bearalert";
/// <summary>Урон блоку за удар лапой. Для сравнения: удар ванильного зомби-медведя по блокам - десятки.</summary>
public const int SmashDamage = 300;
/// <summary>Досягаемость лапы от головы медведя, м.</summary>
public const float SmashReach = 2.6f;
public const float SkinFactor = 0.5f;
static float _nextRoar;
public static bool Is(EntityAlive _e)
{
return _e != null && EntityClass.list.TryGetValue(_e.entityClass, out EntityClass cls) && cls != null &&
cls.entityClassName == BearClass;
}
public static void Roar(World _world, EntityAlive _bear)
{
if (Time.time < _nextRoar)
{
return;
}
_nextRoar = Time.time + RoarCooldown;
_bear.PlayOneShot(RoarSound);
_bear.emodel?.avatarController?.StartAnimationAttack();
int n = 0;
for (int i = 0; i < _world.EntityAlives.Count; i++)
{
EntityAlive e = _world.EntityAlives[i];
if (e == null || e == _bear || e.IsDead() || !(e is EntityZombie) || e.aiManager == null ||
PetFollowPatch.FindOwnerOfPet(e.entityId) != null || (e.position - _bear.position).sqrMagnitude > RoarRange * RoarRange)
{
continue;
}
if (e.IsSleeping)
{
e.ConditionalTriggerSleeperWakeUp();
}
Possession.AllowZombieTarget(e, _bear);
e.SetAttackTarget(_bear, RoarTicks);
n++;
}
Debug.Log("[NecromancerTome] BearControl: рёв - на медведя идут " + n);
}
/// <summary>ЛКМ: блок под прицелом в досягаемости лапы (не существо) - урон SmashDamage.</summary>
public static void Smash(World _world, EntityPlayerLocal _player, EntityAlive _bear)
{
Camera cam = _player.playerCamera;
if (cam == null)
{
return;
}
Vector3 head = _bear.getHeadPosition();
Vector3 from = cam.transform.position + Origin.position;
Ray ray = new Ray(from, cam.transform.forward);
// С существами: если первым на луче стоит существо - это удар по нему, а не по блоку.
if (!Voxel.Raycast(_world, ray, Vector3.Distance(from, head) + SmashReach + 1f, -555528221, 69, 0f))
{
return;
}
WorldRayHitInfo hit = Voxel.voxelRayHitInfo;
if (hit.transform != null && GameUtils.GetHitRootTransform(hit.tag, hit.transform)?.GetComponent<Entity>() != null)
{
return;
}
if ((hit.hit.pos - head).sqrMagnitude > SmashReach * SmashReach)
{
return;
}
Vector3i pos = hit.hit.blockPos;
BlockValue bv = _world.GetBlock(pos);
if (bv.isair || bv.Block == null)
{
return;
}
bv.Block.DamageBlock(_world, new BlockValueRef(pos), bv, SmashDamage, _bear.entityId);
Debug.Log("[NecromancerTome] BearControl: лапой по " + bv.Block.GetBlockName() + " " + pos + " - " + SmashDamage +
" (прочность " + (bv.Block.MaxDamage - bv.damage) + " из " + bv.Block.MaxDamage + ")");
}
}
/// <summary>Толстая шкура одержимого медведя - вдвое меньше урона.</summary>
[HarmonyPatch(typeof(EntityAlive), nameof(EntityAlive.DamageEntity))]
public static class Patch_EntityAlive_DamageEntity_BearSkin
{
public static void Prefix(EntityAlive __instance, ref int _strength)
{
if (_strength > 1 && Possession.IsPossessed(__instance) && BearControl.Is(__instance))
{
_strength = Mathf.Max(1, Mathf.RoundToInt(_strength * BearControl.SkinFactor));
}
}
}
}
+339
View File
@@ -0,0 +1,339 @@
using System.Collections.Generic;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Ноша в когтях одержимого стервятника (указание 08.10: «сделать курицу в когтях стервятника без хитбокса
/// и отдельным элементом, чтобы крылья и шея свисали вниз»).
///
/// Раньше под лапы каждый кадр переставлялась сама курица, и было две беды. Рывки: модель сущности
/// догоняет её позицию с задержкой (Entity.updateTransform - Lerp), а позиция стервятника меняется
/// 20 раз в секунду - курица отставала и прыгала. Твёрдость: её коллайдеры мешали спускаться, и
/// стервятник садился на неё.
///
/// Теперь при захвате с курицы снимается слепок: кости крыльев и шеи развёрнуты вниз, сетки запечены
/// (BakeMesh) в неподвижные MeshRenderer без коллайдеров. Слепок висит под стервятником по его
/// видимой (сглаженной) позиции. Сама курица спрятана (forceRenderingOff - его не трогает
/// avatarController.SetVisible), коллайдеры выключены, ИИ стоит; её позиция идёт за слепком,
/// и когда стервятник её отпускает, она появляется ровно там, где висел слепок.
/// </summary>
public static class CarryVisual
{
static GameObject _holder;
static readonly List<Mesh> _meshes = new List<Mesh>();
static readonly List<Renderer> _hidden = new List<Renderer>();
static readonly List<Collider> _off = new List<Collider>();
/// <summary>Верх и низ слепка относительно его опоры, м.</summary>
static float _top, _bottom;
/// <summary>Сколько крыло отводится в сторону от вертикали и шея - вперёд.</summary>
const float WingOut = 0.35f;
const float NeckForward = 0.35f;
static bool _loggedBones;
public static void Begin(EntityAlive _c)
{
Clear();
Transform model = _c.emodel != null ? _c.emodel.GetModelTransform() : null;
if (model == null)
{
Debug.LogWarning("[NecromancerTome] CarryVisual: у " + _c.EntityName + " нет модели - несём как есть");
return;
}
// Не только свои: физическое тело для толкания (physicsRBT) игра выносит из иерархии сущности
// (Entity.PhysicsInit - SetParent наружу), и на нём стервятник «сидел» в воздухе (тест 09.10).
foreach (Transform root in new[] { _c.transform, _c.PhysicsTransform, _c.physicsRBT })
{
if (root == null)
{
continue;
}
foreach (Collider col in root.GetComponentsInChildren<Collider>(true))
{
if (col != null && col.enabled)
{
col.enabled = false;
_off.Add(col);
}
}
}
Limp(_c, model);
_holder = new GameObject("necroCarry_" + _c.entityId);
_holder.transform.SetPositionAndRotation(_c.transform.position, Quaternion.Euler(0f, _c.transform.eulerAngles.y, 0f));
bool any = false;
_top = float.MinValue;
_bottom = float.MaxValue;
foreach (Renderer r in model.GetComponentsInChildren<Renderer>(false))
{
if (r == null || !r.enabled || r.forceRenderingOff)
{
continue;
}
Mesh mesh;
if (r is SkinnedMeshRenderer smr)
{
if (smr.sharedMesh == null)
{
continue;
}
mesh = new Mesh();
smr.BakeMesh(mesh, true);
_meshes.Add(mesh);
}
else if (r is MeshRenderer && r.TryGetComponent(out MeshFilter mf) && mf.sharedMesh != null)
{
mesh = mf.sharedMesh;
}
else
{
continue;
}
GameObject part = new GameObject(r.name);
part.transform.SetPositionAndRotation(r.transform.position, r.transform.rotation);
part.transform.SetParent(_holder.transform, true);
if (!(r is SkinnedMeshRenderer))
{
part.transform.localScale = r.transform.lossyScale;
}
part.AddComponent<MeshFilter>().sharedMesh = mesh;
MeshRenderer mr = part.AddComponent<MeshRenderer>();
mr.sharedMaterials = r.sharedMaterials;
mr.shadowCastingMode = r.shadowCastingMode;
// Высота - по самой запечённой сетке: r.bounds у SkinnedMeshRenderer - рамка с запасом, по ней
// курица висела далеко под лапами (тест 09.10, «высота 0.99 м»).
foreach (Vector3 v in mesh.vertices)
{
float y = part.transform.TransformPoint(v).y - _holder.transform.position.y;
_top = Mathf.Max(_top, y);
_bottom = Mathf.Min(_bottom, y);
}
any = true;
}
if (!any)
{
_top = 0.3f;
_bottom = 0f;
}
foreach (Renderer r in _c.GetComponentsInChildren<Renderer>(true))
{
if (r != null && !r.forceRenderingOff)
{
r.forceRenderingOff = true;
_hidden.Add(r);
}
}
Debug.Log("[NecromancerTome] CarryVisual: слепок " + _c.EntityName + " - частей " + _holder.transform.childCount +
", высота " + (_top - _bottom).ToString("F2") + " м, выключено коллайдеров " + _off.Count);
}
/// <summary>Каждый кадр: слепок под лапами стервятника по его видимой позиции; у земли - стоит на ней.</summary>
public static void Place(EntityAlive _v, EntityAlive _c, float _gap)
{
Vector3 feet = Claws(_v);
Quaternion yaw = Quaternion.Euler(0f, _v.transform.eulerAngles.y, 0f);
float y = feet.y - _top - _gap;
if (Physics.Raycast(feet + Vector3.up * 0.1f, Vector3.down, out RaycastHit hit, _top - _bottom + _gap + 0.1f,
SnakeControl.WallMask, QueryTriggerInteraction.Ignore) && hit.point.y > y + _bottom)
{
y = hit.point.y - _bottom;
}
Vector3 at = new Vector3(feet.x, y, feet.z);
if (_holder != null)
{
_holder.transform.SetPositionAndRotation(at, yaw);
}
_c.SetPosition(at + Origin.position);
}
static int _clawsOf = -1;
static readonly List<Transform> _claws = new List<Transform>();
/// <summary>Точка хвата - середина между лапами стервятника (кости с «toe»/«foot» в имени, а без них - «leg»),
/// по анимации, каждый кадр (тест 08.10: «курица висит под стервятником, а не в его когтях»). Костей нет -
/// низ его модели.</summary>
static Vector3 Claws(EntityAlive _v)
{
if (_clawsOf != _v.entityId)
{
_clawsOf = _v.entityId;
_claws.Clear();
Transform model = _v.emodel != null ? _v.emodel.GetModelTransform() : null;
if (model != null)
{
List<string> all = new List<string>();
foreach (string key in new[] { "toe", "foot", "leg" })
{
foreach (Transform t in model.GetComponentsInChildren<Transform>(true))
{
string n = t.name.ToLowerInvariant();
if (key == "toe" || key == "foot" ? n.Contains(key) : n.Contains(key) && !n.Contains("up") && !n.Contains("thigh"))
{
_claws.Add(t);
}
}
if (_claws.Count > 0)
{
break;
}
}
foreach (Transform t in model.GetComponentsInChildren<Transform>(true))
{
all.Add(t.name);
}
Debug.Log("[NecromancerTome] CarryVisual: лапы " + _v.EntityName + ": " +
(_claws.Count > 0 ? string.Join(", ", _claws.ConvertAll(t => t.name)) : "не найдены") +
"; все кости: " + string.Join(", ", all));
}
}
if (_claws.Count == 0)
{
return _v.transform.position;
}
Vector3 sum = Vector3.zero;
float low = float.MaxValue;
foreach (Transform t in _claws)
{
sum += t.position;
low = Mathf.Min(low, t.position.y);
}
Vector3 c = sum / _claws.Count;
c.y = low;
return c;
}
/// <summary>Отпустить: курица снова видна и тверда там, где висел слепок.</summary>
public static void End(EntityAlive _c)
{
if (_c != null && _holder != null && !_c.IsDead())
{
Vector3 at = _holder.transform.position;
_c.SetPosition(at + Origin.position);
_c.transform.position = at;
}
foreach (Renderer r in _hidden)
{
if (r != null)
{
r.forceRenderingOff = false;
}
}
foreach (Collider col in _off)
{
if (col != null)
{
col.enabled = true;
}
}
_hidden.Clear();
_off.Clear();
Clear();
}
/// <summary>Убрать слепок, ничего не возвращая (выход из мира).</summary>
public static void Clear()
{
if (_holder != null)
{
Object.Destroy(_holder);
}
_holder = null;
foreach (Mesh m in _meshes)
{
if (m != null)
{
Object.Destroy(m);
}
}
_meshes.Clear();
}
/// <summary>Обвисшая поза: каждая кость крыла и шеи (от корня к концу) разворачивается так, чтобы её
/// дочерняя кость оказалась под ней - крылья свисают чуть в стороны, шея с головой - чуть вперёд.
/// Аниматор следующим кадром позу вернёт, но слепок уже запечён, а сама курица спрятана.</summary>
static void Limp(EntityAlive _c, Transform _model)
{
HashSet<Transform> bones = new HashSet<Transform>();
foreach (SkinnedMeshRenderer smr in _model.GetComponentsInChildren<SkinnedMeshRenderer>(true))
{
if (smr.bones == null)
{
continue;
}
foreach (Transform b in smr.bones)
{
if (b != null)
{
bones.Add(b);
}
}
}
List<Transform> order = new List<Transform>(bones);
order.Sort((a, b) => Depth(a).CompareTo(Depth(b)));
if (!_loggedBones)
{
_loggedBones = true;
List<string> names = new List<string>();
foreach (Transform b in order)
{
names.Add(b.name);
}
Debug.Log("[NecromancerTome] CarryVisual: кости " + _c.EntityName + ": " + string.Join(", ", names));
}
Vector3 fwd = _c.transform.forward;
Vector3 right = _c.transform.right;
Vector3 center = _c.transform.position;
int wings = 0, necks = 0;
foreach (Transform b in order)
{
string n = b.name.ToLowerInvariant();
Vector3 want;
if (n.Contains("wing"))
{
float side = Mathf.Sign(Vector3.Dot(b.position - center, right));
want = (Vector3.down + right * side * WingOut).normalized;
wings++;
}
else if (n.Contains("neck") || n.Contains("head"))
{
want = (Vector3.down + fwd * NeckForward).normalized;
necks++;
}
else
{
continue;
}
Transform child = null;
for (int i = 0; i < b.childCount; i++)
{
if (bones.Contains(b.GetChild(i)))
{
child = b.GetChild(i);
break;
}
}
if (child == null)
{
continue;
}
Vector3 dir = child.position - b.position;
if (dir.sqrMagnitude < 1e-6f)
{
continue;
}
b.rotation = Quaternion.FromToRotation(dir, want) * b.rotation;
}
Debug.Log("[NecromancerTome] CarryVisual: обвисли костей крыльев " + wings + ", шеи/головы " + necks);
}
static int Depth(Transform _t)
{
int d = 0;
for (Transform p = _t.parent; p != null; p = p.parent)
{
d++;
}
return d;
}
}
}
+1
View File
@@ -646,6 +646,7 @@ namespace NecromancerTome
if (!__instance.bag.Touched) if (!__instance.bag.Touched)
{ {
GameManager.Instance.lootManager.LootBagOpened(__instance.bag, __instance, _lockingPlayerID); GameManager.Instance.lootManager.LootBagOpened(__instance.bag, __instance, _lockingPlayerID);
NecroSkull.Roll(__instance, __instance.bag); // 2% - череп (останки), с 09.10 вместо браслета
} }
__instance.timeStayAfterDeath = (int)(CorpseLoot.OpenedCorpseSecondsAfterDeath * 20f); __instance.timeStayAfterDeath = (int)(CorpseLoot.OpenedCorpseSecondsAfterDeath * 20f);
Debug.Log("[NecromancerTome] CorpseLoot: труп " + __instance.entityId + " открыт"); Debug.Log("[NecromancerTome] CorpseLoot: труп " + __instance.entityId + " открыт");
+80
View File
@@ -0,0 +1,80 @@
using System.Collections.Generic;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Нюх одержимой зомбособаки (указание 09.10: «добавь зомбособаке отображение противников, как в Тёмном чутье.
/// Типа у собаки нюх»; решено: только в одержимости, чует всех существ). Как Тёмное чутьё змеи
/// (SnakeControl.Sense): раз в секунду существа ближе SenseRange к собаке получают метку на компасе и карте,
/// дальше SenseDropRange, мёртвые и выгруженные её теряют. В отличие от чутья - не только нежить: звери,
/// бандиты, зомби. Не метятся: тело игрока и другие игроки, питомцы мода (призрачные), торговцы.
/// </summary>
public static class DogSense
{
public const string DogClass = "necroZombieDog";
static readonly HashSet<int> _sensed = new HashSet<int>();
static float _check;
public static bool Is(EntityAlive _e)
{
return _e != null && EntityClass.list.TryGetValue(_e.entityClass, out EntityClass cls) && cls != null &&
cls.entityClassName == DogClass;
}
static bool Smells(EntityAlive _e, EntityAlive _dog)
{
return _e != null && _e != _dog && !_e.IsDead() && !(_e is EntityPlayer) && !(_e is EntityTrader) &&
!SnakeControl.IsNecroEntity(_e) && !GhostTraderPatch.ShouldBeGhost(_e);
}
public static void Tick(World _world, EntityAlive _dog, float _dt)
{
_check -= _dt;
if (_check > 0f)
{
return;
}
_check = 1f;
Vector3 at = _dog.position;
for (int i = 0; i < _world.EntityAlives.Count; i++)
{
EntityAlive e = _world.EntityAlives[i];
if (e == null)
{
continue;
}
float d2 = (e.position - at).sqrMagnitude;
bool smell = Smells(e, _dog);
if (smell && d2 < SnakeControl.SenseRange * SnakeControl.SenseRange)
{
if (_sensed.Add(e.entityId))
{
e.AddNavObject(SnakeControl.SenseNavObject, null, null);
}
}
else if (_sensed.Contains(e.entityId) && (!smell || d2 > SnakeControl.SenseDropRange * SnakeControl.SenseDropRange))
{
e.RemoveNavObject(SnakeControl.SenseNavObject);
_sensed.Remove(e.entityId);
}
}
_sensed.RemoveWhere(id => _world.GetEntity(id) == null);
}
/// <summary>Выход из собаки - снять все метки.</summary>
public static void Clear()
{
World world = GameManager.Instance?.World;
if (world != null)
{
foreach (int id in _sensed)
{
(world.GetEntity(id) as EntityAlive)?.RemoveNavObject(SnakeControl.SenseNavObject);
}
}
_sensed.Clear();
_check = 0f;
}
}
}
+138
View File
@@ -0,0 +1,138 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Text;
using HarmonyLib;
using Debug = UnityEngine.Debug;
namespace NecromancerTome
{
/// <summary>
/// Замер утечки сущностей (указание 09.10: «добавь замер сразу; если не будет тормозить, пригодится для
/// анализа»). Повод - лог 09.10: в строке игры «Ent: 7 (31)» живых сущностей 7, а экземпляров в памяти 31
/// (Entity.InstanceCount уменьшается только в финализаторе), и разрыв рос за сессию.
///
/// Как работает. Каждое удаление сущности из мира проходит через World.unloadEntity (смерть, отзыв,
/// выгрузка чанка, чёрный разрыв NecroBurst). Там удалённая сущность запоминается СЛАБОЙ ссылкой
/// (WeakReference сама не держит объект), с классом, причиной и источником - первым методом мода в стеке
/// вызова (или «игра»). Раз в минуту: если с удаления прошло не меньше двух сборок мусора (GC.CollectionCount),
/// а объект всё ещё жив, - его кто-то держит. В лог идёт строка «EntityLeakProbe» со сводкой по классу и
/// источнику; ничего не держится - строки нет.
///
/// Нагрузка: удаления редки (единицы в минуту), стек снимается только в этот момент; раз в минуту - проход
/// по короткому списку. GC принудительно НЕ вызывается (дал бы рывок). Вышедшие из памяти записи
/// выбрасываются, список не растёт.
/// </summary>
public static class EntityLeakProbe
{
class Rec
{
public WeakReference Ref;
public string What;
public int Gc;
}
static readonly List<Rec> _recs = new List<Rec>();
static readonly Dictionary<string, int> _sum = new Dictionary<string, int>();
static float _next;
const float Period = 60f;
public static void OnUnload(Entity _e, EnumRemoveEntityReason _reason)
{
if (_e == null || _e is EntityPlayerLocal)
{
return;
}
string cls = EntityClass.list.TryGetValue(_e.entityClass, out EntityClass ec) && ec != null ? ec.entityClassName : _e.GetType().Name;
_recs.Add(new Rec
{
Ref = new WeakReference(_e),
What = cls + " / " + _reason + " / " + Origin(),
Gc = GC.CollectionCount(0)
});
}
/// <summary>Первый метод мода в стеке (кроме этого класса и патчей Harmony) - кто удалил; иначе «игра».</summary>
static string Origin()
{
StackTrace st = new StackTrace(2, false);
for (int i = 0; i < st.FrameCount && i < 24; i++)
{
System.Reflection.MethodBase m = st.GetFrame(i)?.GetMethod();
Type t = m?.DeclaringType;
if (t == null || t == typeof(EntityLeakProbe) || t.Namespace != nameof(NecromancerTome) || t.Name.StartsWith("Patch_World_unloadEntity"))
{
continue;
}
return (t.DeclaringType != null ? t.DeclaringType.Name + "." : "") + t.Name + "." + m.Name;
}
return "игра";
}
/// <summary>Раз в минуту (из ModEvents.UnityUpdate мода).</summary>
public static void Tick()
{
float now = UnityEngine.Time.time;
if (now < _next)
{
return;
}
_next = now + Period;
int gc = GC.CollectionCount(0);
_sum.Clear();
int held = 0;
for (int i = _recs.Count - 1; i >= 0; i--)
{
Rec r = _recs[i];
object o = r.Ref.Target;
if (o == null)
{
_recs.RemoveAt(i);
continue;
}
if (gc - r.Gc < 2)
{
continue; // сборок мусора ещё не было - рано судить
}
held++;
_sum.TryGetValue(r.What, out int n);
_sum[r.What] = n + 1;
}
if (held == 0)
{
return;
}
StringBuilder sb = new StringBuilder();
sb.Append("[NecromancerTome] EntityLeakProbe: удалённых, но живых в памяти ").Append(held)
.Append(" (Entity.InstanceCount ").Append(Entity.InstanceCount).Append(", сборок мусора ").Append(gc).Append("):");
foreach (KeyValuePair<string, int> kv in _sum)
{
sb.Append("\n ").Append(kv.Value).Append(" x ").Append(kv.Key);
}
Debug.Log(sb.ToString());
}
/// <summary>Выход из мира - забыть всё.</summary>
public static void Reset()
{
_recs.Clear();
_next = 0f;
}
}
[HarmonyPatch(typeof(World), nameof(World.unloadEntity))]
public static class Patch_World_unloadEntity_LeakProbe
{
public static void Prefix(Entity _e, EnumRemoveEntityReason _reason)
{
try
{
EntityLeakProbe.OnUnload(_e, _reason);
}
catch (Exception)
{
// Замер не должен ломать удаление сущности ни при каких условиях.
}
}
}
}
+3
View File
@@ -310,6 +310,9 @@ namespace NecromancerTome
// обесцвечивание к тому же брало не всё - прозрачными выходили только туловище и // обесцвечивание к тому же брало не всё - прозрачными выходили только туловище и
// голова, что и выглядело жутко. // голова, что и выглядело жутко.
"necroZombieGriffin", "necroZombieGriffin",
// Могильная крыса (08.10.2026): «серой, не полупрозрачной» - у неё теперь своя модель
// из угольной глины (RatModel.cs), призрачный вид её бы испортил.
"necroRatSpirit",
}; };
public static bool ShouldBeGhost(EntityAlive _entity) public static bool ShouldBeGhost(EntityAlive _entity)
+9 -8
View File
@@ -22,11 +22,16 @@ namespace NecromancerTome
/// (removedNavObjectPool) и раздаёт заново. Если модель исчезла раньше нашего Hide, игра сама сняла /// (removedNavObjectPool) и раздаёт заново. Если модель исчезла раньше нашего Hide, игра сама сняла
/// метку, и пул мог уже отдать этот же объект другому (метка чутья, транспорт). Поэтому снимаем, только /// метку, и пул мог уже отдать этот же объект другому (метка чутья, транспорт). Поэтому снимаем, только
/// если объект ещё в NavObjectList и следит за НАШЕЙ моделью; иначе просто забываем ссылку. /// если объект ещё в NavObjectList и следит за НАШЕЙ моделью; иначе просто забываем ссылку.
/// Класс necroSnakeSpirit (nav_objects.xml) - только карта, фиолетовый; значок - OverrideSpriteName. /// Классы (nav_objects.xml), только карта: necroSnakeSpirit - фиолетовая змея, necroPossessed - зелёный
/// значок одержимого. Значок задан в классе: OverrideSpriteName игра берёт, только если у класса
/// use_override_icon (NavObject.GetSpriteName) - без него у одержимого была змея (тест 08.10).
/// hiddenOnCompass не ставим: карта красит такие метки серым (XUiC_MapArea.updateNavObjects), а на
/// компас эти классы и так не попадают - compass_settings у них нет.
/// </summary> /// </summary>
public static class NecroMapMarker public static class NecroMapMarker
{ {
public const string NavClass = "necroSnakeSpirit"; public const string SnakeClass = "necroSnakeSpirit";
public const string PossessedClass = "necroPossessed";
/// <summary>Метка и модель, за которой она следит.</summary> /// <summary>Метка и модель, за которой она следит.</summary>
public class Marker public class Marker
@@ -35,13 +40,13 @@ namespace NecromancerTome
public Transform Tracked; public Transform Tracked;
} }
public static Marker Show(EntityAlive _e, string _sprite) public static Marker Show(EntityAlive _e, string _navClass)
{ {
if (_e == null || _e.transform == null || NavObjectManager.Instance == null) if (_e == null || _e.transform == null || NavObjectManager.Instance == null)
{ {
return null; return null;
} }
NavObject nav = NavObjectManager.Instance.RegisterNavObject(NavClass, _e.transform, _sprite, true); NavObject nav = NavObjectManager.Instance.RegisterNavObject(_navClass, _e.transform);
Debug.Log("[NecromancerTome] NecroMapMarker: метка на карте для " + _e.EntityName + " (" + _e.entityId + ") - " + nav); Debug.Log("[NecromancerTome] NecroMapMarker: метка на карте для " + _e.EntityName + " (" + _e.entityId + ") - " + nav);
return new Marker { Nav = nav, Tracked = _e.transform }; return new Marker { Nav = nav, Tracked = _e.transform };
} }
@@ -56,9 +61,5 @@ namespace NecromancerTome
} }
_m = null; _m = null;
} }
/// <summary>Значок одержимого (зомби или питомца) - значок дебаффа «Почти смерть» (buffNearDeathTrauma,
/// вешается игроку после гибели), указание 06.10.</summary>
public const string PossessedSprite = "ui_game_symbol_near_death_trauma";
} }
} }
+20 -131
View File
@@ -3,155 +3,44 @@ using UnityEngine;
namespace NecromancerTome namespace NecromancerTome
{ {
/// <summary> /// <summary>
/// Череп из красного трупа (BACKLOG «Некротехнолог v2», указание 06.10): тот же жест, что снятие /// Череп (останки) из красного трупа - сырьё для цинерария (дух-работник).
/// блока браслетом, - зажатая обычная атака с браслетом в руке, круговой таймер. Только целью
/// здесь не блок, а труп зомби, погибшего под дебаффом жертвы (CorpseLoot.IsSearchable). Труп -
/// сущность, а не блок, поэтому SpatialVaultPickup.Begin сперва спрашивает этот класс.
/// ///
/// Шанс 2% («так ресурс станет ценнее»): удача - череп в пространственное хранилище, труп остаётся /// С 09.10 - находка при обычном обыске (указание: «браслет больше не собирает красные трупы,
/// (его можно обыскать), второй попытки на нём нет (cvar без точки - пишется в сейв вместе с трупом); /// выпиливаем; останки дропаются с шансом 2% при обычном лутании красного трупа»). Раньше (06.10) череп
/// неудача - труп уходит в чёрный разрыв (NecroBurst), вместе с недобранной добычей. /// вырывали пространственным браслетом: зажатая атака по трупу, три секунды канала, при неудаче труп
/// уходил в чёрный разрыв. Сняли из-за подозрения на утечку памяти на этом пути (тест 09.10: после
/// разрывов в памяти копились экземпляры сущностей - 31 при 7 живых).
/// ///
/// Цена та же, что у блоков: без сферы или камня в слоте браслет не работает, сфера платит /// Бросок - один раз на труп, при первом открытии его сумки, на сервере, сразу после того, как игра
/// прочностью за секунды канала. Разрыв делает сервер - в мультиплеере клиенту пока только удача /// наполнила её добычей (Patch_EntityAlive_OnLockRequestServer_Corpse). Шанс прежний - 2%
/// (труп не исчезает); решать вместе с прочим мультиплеером урн. /// («так ресурс станет ценнее»).
/// </summary> /// </summary>
public static class NecroSkull public static class NecroSkull
{ {
public const string ItemName = "necroSkull"; public const string ItemName = "necroSkull";
/// <summary>На трупе: попытка уже была удачной.</summary>
public const string TakenCVar = "necroSkullTaken";
public const float Chance = 0.02f; 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"; /// <summary>Бросок на череп в только что наполненную сумку трупа. Места нет - сумка на ячейку больше.</summary>
public const string MsgTaken = "necroSkullAlreadyTaken"; public static void Roll(EntityAlive _corpse, Bag _bag)
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); ItemValue skull = ItemClass.GetItem(ItemName);
if (bag == null || skull == null || skull.IsEmpty()) 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; 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; float roll = Random.value;
bool success = roll < Chance; bool success = roll < Chance;
Debug.Log("[NecromancerTome] NecroSkull: труп " + corpse.entityId + " - бросок " + Debug.Log("[NecromancerTome] NecroSkull: труп " + _corpse.entityId + " - бросок " + roll.ToString("0.000") +
roll.ToString("0.000") + " против " + Chance.ToString("0.00") + " -> " + (success ? "ЧЕРЕП" : "разрыв")); " против " + Chance.ToString("0.00") + (success ? " -> ЧЕРЕП" : ""));
if (success) if (!success)
{ {
if (!bag.AddItem(new ItemStack(skull, 1))) return;
{
SpatialVaultPickup.Deny(job.Player, SpatialVaultPickup.MsgVaultFull);
return;
}
corpse.Buffs.SetCustomVar(TakenCVar, 1f);
GameManager.ShowTooltip(job.Player, MsgExtracted);
} }
else ItemStack stack = new ItemStack(skull, 1);
if (!_bag.AddItem(stack.Clone()))
{ {
GameManager.ShowTooltip(job.Player, MsgFailed); _bag.Resize(new Vector2i(_bag.SlotCount + 1, 1));
NecroBurst.Burst(corpse, job.Player); _bag.AddItem(stack);
}
// Попытка оплачена в обоих исходах: канал отработан.
SpatialVaultPickup.SpendCharge(job);
if (success)
{
GameManager.Instance.SaveLocalPlayerData();
} }
} }
} }
+4 -1
View File
@@ -127,7 +127,10 @@ namespace NecromancerTome
public static bool Prefix(EntityPlayerLocal __instance, NGuiWdwInGameHUD _guiInGame, bool bModalWindowOpen) public static bool Prefix(EntityPlayerLocal __instance, NGuiWdwInGameHUD _guiInGame, bool bModalWindowOpen)
{ {
if (__instance.inventory == null) // В одержимости тело держит книгу питомца, но цель под прицелом тела не обновляется (управление тела
// выключено) - прицел книги застывал красным (тест 09.10: «у стервятника прицел всё время красный»).
// Прицел одержимого - обычный, со своей подсветкой (Possession.AimTint).
if (__instance.inventory == null || Possession.Active)
{ {
return true; return true;
} }
+317 -30
View File
@@ -86,6 +86,29 @@ namespace NecromancerTome
/// <summary>Звук попадания рывка - ванильный удар рукой зомби по телу (sounds.xml).</summary> /// <summary>Звук попадания рывка - ванильный удар рукой зомби по телу (sounds.xml).</summary>
public const string FlyDashHitSound = "organichitorganic"; public const string FlyDashHitSound = "organichitorganic";
static EntityAlive _flyDash; static EntityAlive _flyDash;
/// <summary>Ноша стервятника (указание 08.10): рывок на курицу или кролика (EntityAnimalRabbit - только
/// они; злая курица - другой класс) не клюёт, а хватает. Несёт под лапами, его ИИ стоит; повторная ЛКМ -
/// отпускает (падает сам; у земли - стоит на ней). Курицу так приносят в загон, где игрок её ловит
/// для курятника (wildChicken).</summary>
static EntityAlive _carry;
/// <summary>«Зоркий глаз» (указание 08.10): пока держишь ПКМ, камера уходит к голове, а угол обзора сужается в ZoomFactor раз
/// за ZoomTime, как у прицела; мышь во столько же раз медленнее. Свой угол игра ставит только на
/// своём приближении (vp_FPCamera), поэтому исходный запоминаем и возвращаем сами.</summary>
public const float ZoomFactor = 8f; // x8 - указание 08.10 (было x4)
public const float ZoomTime = 0.2f;
/// <summary>Камера в приближении - на столько впереди головы, м (вне собственного клюва).</summary>
public const float ZoomEyeForward = 0.3f;
static bool _flyZoomHeld;
static float _zoom;
static float _zoomBaseFov = -1f;
static Camera _zoomCam;
/// <summary>Угол обзора «зоркого глаза», -1 - нет. Ставит Patch_EntityPlayerLocal_LateUpdate_Possession:
/// игра каждый кадр задаёт свой угол в EntityPlayerLocal.LateUpdate (тест 08.10: «x8 не работает» -
/// угол из PlaceCamera затирался).</summary>
public static float ZoomFov = -1f;
/// <summary>Зазор между когтями стервятника и спиной ноши, м; минус - когти впиваются в спину
/// (тест 08.10: «курица висит под стервятником, а не в его когтях»).</summary>
public const float CarryGap = -0.06f;
/// <summary>Цель рывка под прицелом сейчас (каждый кадр) - обычный прицел игры краснеет (AimTint).</summary> /// <summary>Цель рывка под прицелом сейчас (каждый кадр) - обычный прицел игры краснеет (AimTint).</summary>
public static EntityAlive FlyAim; public static EntityAlive FlyAim;
static float _flyDashUntil; static float _flyDashUntil;
@@ -190,6 +213,12 @@ namespace NecromancerTome
static GameObject _hud; static GameObject _hud;
/// <summary>Метка одержимого на карте (указание 06.10, как у змеи) - NecroMapMarker.</summary> /// <summary>Метка одержимого на карте (указание 06.10, как у змеи) - NecroMapMarker.</summary>
static NecroMapMarker.Marker _mapNav; static NecroMapMarker.Marker _mapNav;
/// <summary>Разведка стервятника (указание 08.10: «карта следует за ним как за игроком»; только он, не
/// другие одержимые): туман карты открывается вокруг него так же, как вокруг игрока (EntityPlayer.OnUpdateLive: при смене чанка
/// ChunkObserver.mapDatabase.Add - 4 чанка во все стороны), а карта открывается на нём
/// (Patch_XUiC_MapArea_*_Possession). Это обычная карта игрока - сохраняется как у него.</summary>
static Vector3i _mapChunk;
static bool _mapChunkSet;
public static void Init() public static void Init()
{ {
@@ -204,6 +233,11 @@ namespace NecromancerTome
public static bool IsPossessed(Entity _e) => Active && _e != null && Zombie != null && _e.entityId == Zombie.entityId; public static bool IsPossessed(Entity _e) => Active && _e != null && Zombie != null && _e.entityId == Zombie.entityId;
/// <summary>Несёт ли стервятник это существо (его ИИ стоит).</summary>
public static bool IsCarried(Entity _e) => _carry != null && _e != null && _e.entityId == _carry.entityId;
static bool Carryable(EntityAlive _e) => _e is EntityAnimalRabbit;
/// <summary>Только зомби-гуманоиды (решение 03.10: «одержимая собака или стервятник - такое себе»).</summary> /// <summary>Только зомби-гуманоиды (решение 03.10: «одержимая собака или стервятник - такое себе»).</summary>
public static bool CanPossess(EntityAlive _e) => _e is EntityZombie && !_e.IsDead() && HandHolds.IsZombie(_e); public static bool CanPossess(EntityAlive _e) => _e is EntityZombie && !_e.IsDead() && HandHolds.IsZombie(_e);
@@ -273,7 +307,8 @@ namespace NecromancerTome
ReadRanged(_zombie); ReadRanged(_zombie);
Hint(_player0, _zombie); Hint(_player0, _zombie);
NecroMapMarker.Hide(ref _mapNav); // старая ссылка после закрытия мира - снимется, только если ещё наша NecroMapMarker.Hide(ref _mapNav); // старая ссылка после закрытия мира - снимется, только если ещё наша
_mapNav = NecroMapMarker.Show(_zombie, NecroMapMarker.PossessedSprite); _mapNav = NecroMapMarker.Show(_zombie, NecroMapMarker.PossessedClass);
_mapChunkSet = false;
if (Flying) if (Flying)
{ {
_zombie.motion = Vector3.zero; _zombie.motion = Vector3.zero;
@@ -289,6 +324,10 @@ namespace NecromancerTome
return; return;
} }
Active = false; Active = false;
RatLoot.CloseIfViewing();
RatPhase.Stop(GameManager.Instance?.World, Zombie, "выход из одержимости");
DogSense.Clear();
WolfControl.Clear();
NecroMapMarker.Hide(ref _mapNav); NecroMapMarker.Hide(ref _mapNav);
bool pet = IsPet; bool pet = IsPet;
IsPet = false; IsPet = false;
@@ -302,7 +341,10 @@ namespace NecromancerTome
_flyDash = null; _flyDash = null;
FlyAim = null; FlyAim = null;
AimTint(false); AimTint(false);
DropCarry("выход из стервятника");
ZoomReset();
StopEat(z, null); StopEat(z, null);
VultureNeck.Reset();
if (_flySitting) if (_flySitting)
{ {
_flySitting = false; _flySitting = false;
@@ -395,6 +437,15 @@ namespace NecromancerTome
static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data) static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data)
{ {
World world = GameManager.Instance?.World; World world = GameManager.Instance?.World;
// Замер утечки сущностей (09.10) - не связан с одержимостью, просто ежекадровый крючок мода.
if (world == null)
{
EntityLeakProbe.Reset();
}
else
{
EntityLeakProbe.Tick();
}
if (world == null) if (world == null)
{ {
if (Active || Zombie != null || _frozen.Count > 0) if (Active || Zombie != null || _frozen.Count > 0)
@@ -412,6 +463,16 @@ namespace NecromancerTome
_frozenSpeed.Clear(); _frozenSpeed.Clear();
_flyDash = null; _flyDash = null;
FlyAim = null; FlyAim = null;
_carry = null;
CarryVisual.Clear();
RatPhase.On = false;
DogSense.Clear();
WolfControl.Clear();
WolfBag.Forget();
_zoomCam = null;
_zoomBaseFov = -1f;
_zoom = 0f;
ZoomFov = -1f;
_eating = false; _eating = false;
_eatCorpse = null; _eatCorpse = null;
} }
@@ -434,6 +495,18 @@ namespace NecromancerTome
Exit("одержимый погиб"); Exit("одержимый погиб");
return; return;
} }
if (Flying)
{
RevealMap(world, z); // разведка - только стервятник (указание 08.10)
}
// Подсказка тела (тест 08.10: «стоял перед дверью - подсказка осталась в стервятнике»): её гасит
// PlayerMoveController.Update, а он на время одержимости выключен. Прочие сообщения (голод и т.п.) -
// другие окна, их не трогаем.
LocalPlayerUI ui = _player.PlayerUI;
if (ui != null && ui.windowManager.IsWindowOpen(XUiC_InteractionPrompt.ID))
{
XUiC_InteractionPrompt.SetText(ui, null);
}
PlayerActionsLocal input = _player.playerInput; PlayerActionsLocal input = _player.playerInput;
bool gui = _player.PlayerUI != null && _player.PlayerUI.windowManager.IsModalWindowOpen(); bool gui = _player.PlayerUI != null && _player.PlayerUI.windowManager.IsModalWindowOpen();
if (input != null && !gui && (input.Reload.WasPressed || input.PermanentActions.Reload.WasPressed)) if (input != null && !gui && (input.Reload.WasPressed || input.PermanentActions.Reload.WasPressed))
@@ -461,20 +534,8 @@ namespace NecromancerTome
_crouchToggled = !_crouchToggled; _crouchToggled = !_crouchToggled;
} }
Crouching = input != null && !gui && (input.Crouch.IsPressed || _crouchToggled); Crouching = input != null && !gui && (input.Crouch.IsPressed || _crouchToggled);
if (detonate && IsPet) // E у питомца больше не отзыв (указание 08.10: «R - всегда вернуться в тело у всех одержимых,
{ // E - для других целей»). Отозвать питомца - E с книгой в руке, уже из тела.
// E у питомца - отзыв (как E с книгой в руке); выход из тела - внутри RecallPet.
EntityPlayerLocal owner = _player;
if (Patch_ItemActionSpawnEntity_Spawn_PetLimit.RecallPossessedPet(owner, z))
{
// Звук отзыва - как у E с книгой (soundWarn книги в руке; вселялись с ней же).
if (owner.inventory?.holdingItem?.Actions?.Length > 0 && owner.inventory.holdingItem.Actions[0] is ItemActionSpawnEntity spawn)
{
owner.PlayOneShot(spawn.soundWarn);
}
return;
}
}
if (detonate && z is EntityZombieCop cop && !cop.isPrimed) if (detonate && z is EntityZombieCop cop && !cop.isPrimed)
{ {
Debug.Log("[NecromancerTome] Possession: подрыв " + z.EntityName); Debug.Log("[NecromancerTome] Possession: подрыв " + z.EntityName);
@@ -490,7 +551,15 @@ namespace NecromancerTome
_flyUp = input != null && !gui && input.Jump.IsPressed; _flyUp = input != null && !gui && input.Jump.IsPressed;
_flyDown = input != null && !gui && (input.Crouch.IsPressed || _crouchToggled); _flyDown = input != null && !gui && (input.Crouch.IsPressed || _crouchToggled);
Crouching = false; Crouching = false;
FlyAim = _flyDash == null ? FlyDashTarget(world, z) : null; if (attack && _carry != null)
{
// Повторная ЛКМ с ношей - отпустить (не клевок и не новый рывок).
DropCarry("ЛКМ");
attack = false;
_nextAttack = Time.time + FlyAttackDelay;
}
HoldCarry(z);
FlyAim = _flyDash == null && _carry == null ? FlyDashTarget(world, z) : null;
AimTint(FlyAim != null); AimTint(FlyAim != null);
if (attack && Time.time >= _nextAttack && FlyAim is EntityAlive dashTo) if (attack && Time.time >= _nextAttack && FlyAim is EntityAlive dashTo)
{ {
@@ -519,16 +588,20 @@ namespace NecromancerTome
_nextAttack = Time.time + FlyAttackDelay; _nextAttack = Time.time + FlyAttackDelay;
_meleeLookUntil = Time.time + FlyAttackDelay; _meleeLookUntil = Time.time + FlyAttackDelay;
} }
// ПКМ - клич-приманка (указание 06.10): крик крикуньи, только голосом паникующей курицы - зомби // F - клич-приманка (указание 06.10; кнопка - 08.10, была ПКМ): крик крикуньи, только голосом
// в ScreamRange идут на него; с воздуха уводит толпу от базы или от тела. // паникующей курицы - зомби в ScreamRange идут на него; с воздуха уводит толпу от базы или от тела.
if (ranged) // F у игрока - фонарик, но его читает только выключенный PlayerMoveController.Update.
bool scream = input != null && !gui && (input.PermanentActions.ToggleFlashlight.WasPressed || input.ToggleFlashlight.WasPressed);
if (scream)
{ {
Scream(world, z); Scream(world, z);
} }
// F - питаться падалью (указание 06.10: «сесть на труп - по F стервятник питается»). F у игрока - // ПКМ (держать) - «зоркий глаз» (указание 08.10: «приближение как у прицела»), в PlaceCamera.
// фонарик, но его читает только PlayerMoveController.Update, а он на время одержимости выключен. _flyZoomHeld = rangedHeld;
bool eat = input != null && !gui && (input.PermanentActions.ToggleFlashlight.WasPressed || input.ToggleFlashlight.WasPressed); // E - питаться падалью (указание 06.10, кнопка - 08.10: «пусть ест по кнопке E» - у игрока E это
Eat(world, z, eat, attack || ranged); // взаимодействие, лут; было F). detonate - это и есть нажатие E.
bool eat = detonate;
Eat(world, z, eat, attack || scream);
if (_meleeLookUntil > 0f && Time.time >= _meleeLookUntil) if (_meleeLookUntil > 0f && Time.time >= _meleeLookUntil)
{ {
_meleeLookUntil = 0f; _meleeLookUntil = 0f;
@@ -559,6 +632,32 @@ namespace NecromancerTome
z.Jumping = true; z.Jumping = true;
z.SetJumpDistance(Mathf.Max(1.5f, z.jumpMaxDistance), 1.2f); z.SetJumpDistance(Mathf.Max(1.5f, z.jumpMaxDistance), 1.2f);
} }
if (IsPet && RatModel.Is(z))
{
// E - осмотреть контейнер без права взять (указание 08.10: крыса - разведчик).
RatLoot.Tick(world, _player, z, detonate);
// ПКМ (держать) - сквозь постройки, без полёта (BACKLOG, «Могильная крыса» п.4).
RatPhase.Tick(world, z, rangedHeld);
}
if (IsPet && WolfControl.Is(z))
{
// ПКМ - бросок на цель, след крови раненых (решено 09.10).
WolfControl.Tick(world, _player, z, ranged, ref _bodyYaw);
// E: у тела - выгрузить сумку в рюкзак, у контейнера - лутать в сумку (3 ячейки).
if (!(detonate && WolfBag.TryUnload(_player, z)))
{
RatLoot.Tick(world, _player, z, detonate, true);
}
}
if (ranged && IsPet && BearControl.Is(z))
{
BearControl.Roar(world, z); // ПКМ - рёв-провокация (решено 09.10)
}
if (IsPet && DogSense.Is(z))
{
// Нюх (указание 09.10): все существа рядом с собакой - на компасе и карте.
DogSense.Tick(world, z, Time.deltaTime);
}
if (ranged && IsScreamer(z)) if (ranged && IsScreamer(z))
{ {
Scream(world, z); Scream(world, z);
@@ -569,6 +668,10 @@ namespace NecromancerTome
if (z.Attack(false)) if (z.Attack(false))
{ {
z.Attack(true); z.Attack(true);
if (IsPet && BearControl.Is(z))
{
BearControl.Smash(world, _player, z); // удар лапой и по блоку (решено 09.10)
}
float timeout = Mathf.Max(0.3f, z.GetAttackTimeoutTicks() / 20f); float timeout = Mathf.Max(0.3f, z.GetAttackTimeoutTicks() / 20f);
_nextAttack = Time.time + timeout; _nextAttack = Time.time + timeout;
_meleeLookUntil = Time.time + Mathf.Max(0.6f, timeout); _meleeLookUntil = Time.time + Mathf.Max(0.6f, timeout);
@@ -975,6 +1078,22 @@ namespace NecromancerTome
_v.motion = Vector3.zero; _v.motion = Vector3.zero;
_v.SetLookPosition(head); _v.SetLookPosition(head);
_v.emodel?.avatarController?.StartAnimationAttack(); _v.emodel?.avatarController?.StartAnimationAttack();
if (Carryable(t))
{
// Курица или кролик - не клевок, а захват: ИИ встаёт (updateTasks пропущен), путь сброшен.
_carry = t;
t.SetAttackTarget(null, 0);
t.SetRevengeTarget(null);
PathFinderThread.Instance.RemovePathsFor(t.entityId);
t.navigator?.clearPath();
t.moveHelper?.Stop();
t.SetRotation(new Vector3(0f, _bodyYaw, 0f));
t.transform.rotation = Quaternion.Euler(0f, _bodyYaw, 0f);
CarryVisual.Begin(t);
HoldCarry(_v);
Debug.Log("[NecromancerTome] Possession: стервятник схватил " + t.EntityName + " (" + t.entityId + ")");
return;
}
string snd = _v.GetSoundAttack(); string snd = _v.GetSoundAttack();
if (!string.IsNullOrEmpty(snd)) if (!string.IsNullOrEmpty(snd))
{ {
@@ -1001,6 +1120,45 @@ namespace NecromancerTome
(t.IsDead() ? ", убит" : ", здоровье " + t.Health)); (t.IsDead() ? ", убит" : ", здоровье " + t.Health));
} }
/// <summary>Ноша - под лапами стервятника, каждый кадр: верх слепка ноши на CarryGap ниже его ног
/// (CarryVisual). Ниже уже земля (схватил у земли, сел) - ноша стоит на земле под ним, но не отпущена.</summary>
static void HoldCarry(EntityAlive _v)
{
EntityAlive c = _carry;
if (c == null)
{
return;
}
if (c.IsDead() || c.IsMarkedForUnload())
{
_carry = null;
CarryVisual.End(c);
Debug.Log("[NecromancerTome] Possession: ноша погибла");
return;
}
CarryVisual.Place(_v, c, CarryGap);
c.SetRotation(new Vector3(0f, _bodyYaw, 0f));
c.motion = Vector3.zero;
c.fallDistance = 0f;
}
/// <summary>Отпустить ношу: ИИ снова идёт, падает она сама (с высоты - может разбиться).</summary>
static void DropCarry(string _why)
{
EntityAlive c = _carry;
if (c == null)
{
return;
}
_carry = null;
CarryVisual.End(c);
if (!c.IsDead())
{
c.motion = Vector3.zero;
}
Debug.Log("[NecromancerTome] Possession: стервятник отпустил " + c.EntityName + " - " + _why);
}
/// <summary>Цель рывка: ближайшая к стервятнику по лучу прицела, не дальше FlyDashRange от него и /// <summary>Цель рывка: ближайшая к стервятнику по лучу прицела, не дальше FlyDashRange от него и
/// не дальше TargetRadius змеи от луча. Свои (питомцы, игроки, зачарованные) - не цель.</summary> /// не дальше TargetRadius змеи от луча. Свои (питомцы, игроки, зачарованные) - не цель.</summary>
static EntityAlive FlyDashTarget(World _world, EntityAlive _v) static EntityAlive FlyDashTarget(World _world, EntityAlive _v)
@@ -1116,10 +1274,6 @@ namespace NecromancerTome
static void Hint(EntityPlayerLocal _p, EntityAlive _z) static void Hint(EntityPlayerLocal _p, EntityAlive _z)
{ {
string text = Localization.Get("necroPossessHintBase"); string text = Localization.Get("necroPossessHintBase");
if (IsPet)
{
text += ", " + Localization.Get("necroPetRecallHint");
}
if (Flying) if (Flying)
{ {
text += ", " + Localization.Get("necroPossessHintFly"); text += ", " + Localization.Get("necroPossessHintFly");
@@ -1128,14 +1282,36 @@ namespace NecromancerTome
{ {
text += ", " + Localization.Get("necroPossessHintRanged"); text += ", " + Localization.Get("necroPossessHintRanged");
} }
if (IsScreamer(_z) || Flying) if (IsScreamer(_z))
{ {
text += ", " + Localization.Get("necroPossessHintScream"); text += ", " + Localization.Get("necroPossessHintScream");
} }
if (Flying) if (Flying)
{
text += ", " + Localization.Get("necroPossessHintZoom") + ", " + Localization.Get("necroPossessHintScreamF");
}
if (Flying)
{ {
text += ", " + Localization.Get("necroPossessHintEat"); text += ", " + Localization.Get("necroPossessHintEat");
} }
if (IsPet && RatModel.Is(_z))
{
text += ", " + Localization.Get("necroPossessHintLook") + ", " + Localization.Get("necroPossessHintPhase");
}
if (IsPet && DogSense.Is(_z))
{
text += ", " + Localization.Get("necroPossessHintSmell");
}
if (IsPet && WolfControl.Is(_z))
{
text += ", " + Localization.Get("necroPossessHintPounce") + ", " + Localization.Get("necroPossessHintBlood") + ", " +
Localization.Get("necroPossessHintWolfBag");
}
if (IsPet && BearControl.Is(_z))
{
text += ", " + Localization.Get("necroPossessHintRoar") + ", " + Localization.Get("necroPossessHintSmash") + ", " +
Localization.Get("necroPossessHintSkin");
}
if (_z.jumpMaxDistance >= 5f) if (_z.jumpMaxDistance >= 5f)
{ {
text += ", " + Localization.Get("necroPossessHintLeap"); text += ", " + Localization.Get("necroPossessHintLeap");
@@ -1144,6 +1320,11 @@ namespace NecromancerTome
{ {
text += ", " + Localization.Get("necroPossessHintDetonate"); text += ", " + Localization.Get("necroPossessHintDetonate");
} }
if (!IsPet)
{
// Решение 03.10, подтверждено 09.10: после выхода бывший одержимый в леденящем ужасе бежит от тела.
text += ", " + Localization.Get("necroPossessHintAfterFear");
}
GameManager.ShowTooltip(_p, text); GameManager.ShowTooltip(_p, text);
} }
@@ -1355,6 +1536,10 @@ namespace NecromancerTome
PlayerMoveController pmc = PlayerMoveController.Instance; PlayerMoveController pmc = PlayerMoveController.Instance;
Vector2 sens = pmc != null ? pmc.mouseLookSensitivity : Vector2.one * 5f; Vector2 sens = pmc != null ? pmc.mouseLookSensitivity : Vector2.one * 5f;
int invert = pmc != null && pmc.invertMouse != 0 ? pmc.invertMouse : 1; int invert = pmc != null && pmc.invertMouse != 0 ? pmc.invertMouse : 1;
if (_zoomCam != null && _zoomBaseFov > 0f)
{
sens *= _zoomCam.fieldOfView / _zoomBaseFov; // в «зорком глазе» мышь медленнее, как в прицеле
}
_yaw += Input.GetAxis("Mouse X") * sens.x; _yaw += Input.GetAxis("Mouse X") * sens.x;
_pitch = Mathf.Clamp(_pitch + Input.GetAxis("Mouse Y") * sens.y * invert, -70f, 70f); _pitch = Mathf.Clamp(_pitch + Input.GetAxis("Mouse Y") * sens.y * invert, -70f, 70f);
} }
@@ -1404,7 +1589,7 @@ namespace NecromancerTome
/// <summary>Разрешить зомби подход к цели класса одержимого: EntityZombie, у питомцев - их класс /// <summary>Разрешить зомби подход к цели класса одержимого: EntityZombie, у питомцев - их класс
/// (EntityZombieDog, EntityVulture).</summary> /// (EntityZombieDog, EntityVulture).</summary>
static void AllowZombieTarget(EntityAlive _e, EntityAlive _target) internal static void AllowZombieTarget(EntityAlive _e, EntityAlive _target)
{ {
System.Type type = _target is EntityZombie ? typeof(EntityZombie) : _target.GetType(); System.Type type = _target is EntityZombie ? typeof(EntityZombie) : _target.GetType();
List<EAIApproachAndAttackTarget> tasks = _e.aiManager.GetTasks<EAIApproachAndAttackTarget>(); List<EAIApproachAndAttackTarget> tasks = _e.aiManager.GetTasks<EAIApproachAndAttackTarget>();
@@ -1426,6 +1611,54 @@ namespace NecromancerTome
} }
} }
/// <summary>Открыть карту вокруг одержимого - как игра открывает её вокруг игрока, при смене чанка.</summary>
static void RevealMap(World _world, EntityAlive _z)
{
IMapChunkDatabase map = _player.ChunkObserver?.mapDatabase;
if (map == null || !_z.IsSpawned() || _mapChunkSet && _z.chunkPosAddedEntityTo == _mapChunk)
{
return;
}
_mapChunk = _z.chunkPosAddedEntityTo;
_mapChunkSet = true;
map.Add(_mapChunk, _world);
}
/// <summary>Шаг «зоркого глаза» за кадр; отпустил - угол плавно возвращается и восстанавливается точно.</summary>
static void Zoom(bool _held)
{
Camera cam = _player != null ? _player.playerCamera : null;
if (_held && cam != null && _zoomBaseFov < 0f)
{
_zoomCam = cam;
_zoomBaseFov = cam.fieldOfView;
}
if (_zoomBaseFov < 0f)
{
return;
}
_zoom = Mathf.MoveTowards(_zoom, _held ? 1f : 0f, Time.deltaTime / ZoomTime);
if (_zoom <= 0f)
{
ZoomReset();
return;
}
ZoomFov = Mathf.Lerp(_zoomBaseFov, _zoomBaseFov / ZoomFactor, Mathf.SmoothStep(0f, 1f, _zoom));
}
static void ZoomReset()
{
if (_zoomCam != null && _zoomBaseFov > 0f)
{
_zoomCam.fieldOfView = _zoomBaseFov;
}
_zoomCam = null;
_zoomBaseFov = -1f;
_zoom = 0f;
ZoomFov = -1f;
_flyZoomHeld = false;
}
/// <summary>Из постфикса vp_FPCamera.LateUpdate: камера позади и выше головы зомби, не сквозь стены.</summary> /// <summary>Из постфикса vp_FPCamera.LateUpdate: камера позади и выше головы зомби, не сквозь стены.</summary>
public static void PlaceCamera(Transform _camera) public static void PlaceCamera(Transform _camera)
{ {
@@ -1434,6 +1667,12 @@ namespace NecromancerTome
{ {
return; return;
} }
if (Flying)
{
// Шея - до камеры: та берёт положение головы.
VultureNeck.Apply(z, _flySitting, !_eating);
}
Zoom(Flying && _flyZoomHeld);
Transform head = z.emodel != null ? z.emodel.GetHeadTransform() : null; Transform head = z.emodel != null ? z.emodel.GetHeadTransform() : null;
Vector3 anchor = head != null ? head.position : z.position - Origin.position + Vector3.up * 1.6f; Vector3 anchor = head != null ? head.position : z.position - Origin.position + Vector3.up * 1.6f;
Quaternion rot = Quaternion.Euler(-_pitch, _yaw, 0f); Quaternion rot = Quaternion.Euler(-_pitch, _yaw, 0f);
@@ -1443,6 +1682,12 @@ namespace NecromancerTome
{ {
eye = anchor + to.normalized * Mathf.Max(0.2f, hit.distance - 0.2f); eye = anchor + to.normalized * Mathf.Max(0.2f, hit.distance - 0.2f);
} }
if (_zoom > 0f)
{
// В «зорком глазе» камера уходит к голове (чуть впереди неё): из-за спины при x8 весь кадр
// закрыла бы сама птица.
eye = Vector3.Lerp(eye, anchor + rot * Vector3.forward * ZoomEyeForward, Mathf.SmoothStep(0f, 1f, _zoom));
}
_camera.SetPositionAndRotation(eye, rot); _camera.SetPositionAndRotation(eye, rot);
if (!_loggedCamera) if (!_loggedCamera)
{ {
@@ -1458,7 +1703,7 @@ namespace NecromancerTome
{ {
public static bool Prefix(EntityAlive __instance) public static bool Prefix(EntityAlive __instance)
{ {
return !Possession.IsPossessed(__instance); return !Possession.IsPossessed(__instance) && !Possession.IsCarried(__instance);
} }
} }
@@ -1478,6 +1723,48 @@ namespace NecromancerTome
} }
} }
/// <summary>Карта в одержимом стервятнике открывается на нём, а не на теле (указание 08.10: разведка -
/// только у стервятника).</summary>
[HarmonyPatch(typeof(XUiC_MapArea), nameof(XUiC_MapArea.OnOpen))]
public static class Patch_XUiC_MapArea_OnOpen_Possession
{
public static void Postfix(XUiC_MapArea __instance)
{
if (Possession.Active && Possession.Flying && Possession.Zombie != null)
{
__instance.PositionMapAt(Possession.Zombie.GetPosition());
}
}
}
/// <summary>Кнопка «к игроку» на карте при одержимости - к одержимому.</summary>
[HarmonyPatch(typeof(XUiC_MapArea), nameof(XUiC_MapArea.onPlayerIconPressed))]
public static class Patch_XUiC_MapArea_onPlayerIconPressed_Possession
{
public static bool Prefix(XUiC_MapArea __instance)
{
if (!Possession.Active || !Possession.Flying || Possession.Zombie == null)
{
return true;
}
__instance.PositionMapAt(Possession.Zombie.GetPosition());
return false;
}
}
/// <summary>«Зоркий глаз» стервятника: свой угол обзора - после того, как игра поставила свой.</summary>
[HarmonyPatch(typeof(EntityPlayerLocal), "LateUpdate")]
public static class Patch_EntityPlayerLocal_LateUpdate_Possession
{
public static void Postfix(EntityPlayerLocal __instance)
{
if (Possession.ZoomFov > 0f && __instance == Possession.Player && __instance.playerCamera != null)
{
__instance.playerCamera.fieldOfView = Possession.ZoomFov;
}
}
}
/// <summary>Тело оцепенело: ввод игрока не идёт (как у тела в рэгдолле - stopMoving и выход).</summary> /// <summary>Тело оцепенело: ввод игрока не идёт (как у тела в рэгдолле - stopMoving и выход).</summary>
[HarmonyPatch(typeof(PlayerMoveController), nameof(PlayerMoveController.Update))] [HarmonyPatch(typeof(PlayerMoveController), nameof(PlayerMoveController.Update))]
public static class Patch_PlayerMoveController_Update_Possession public static class Patch_PlayerMoveController_Update_Possession
+326
View File
@@ -0,0 +1,326 @@
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Обыск одержимой крысой (указание 08.10). Крыса - разведчик: «она может осмотреть ящики, и если там
/// что-то интересное, персонаж придёт и сам залутает». Поэтому - только смотреть.
///
/// Окно ванильное, ровно как у персонажа (таймер первого обыска, сетка, звуки): контейнер открывается
/// той же цепочкой LockManager.LockRequestLocal -> OnLockResponseLocal -> XUiC_LootWindowGroup.OpenLooting.
/// Отличия на время такого просмотра (Viewing):
/// - рюкзак персонажа не показывается («сумку персонажа вообще не показывать») - openContainer
/// открывает его сам, закрываем сразу же;
/// - ячейки контейнера заперты (XUiC_ItemStack.IsLocked), «Взять всё» (R) и сортировка не работают;
/// попытка взять - отказ, как при переполненном рюкзаке (указание: «делал бы вид, что сумки
/// переполнены»): звук ui_denied и xuiInventoryFullForPickup;
/// - окно не закрывается из-за дальности до ТЕЛА: XUiC_LootWindow.Update меряет её от персонажа.
/// Подсказку «E: обыскать» рисует управление игрока, а оно в одержимости выключено - выводим сами.
/// </summary>
public static class RatLoot
{
/// <summary>Открыто (или открывается) окно просмотра крысой.</summary>
public static bool Viewing;
/// <summary>Окно открыл волк: щелчок и «Взять всё» кладут в его сумку (WolfBag), а не отказ.</summary>
public static bool Taking;
/// <summary>Контейнер, который осматривает крыса, и до какого времени после закрытия его не разрушать.</summary>
static Vector3i _sparePos;
static float _spareUntil;
/// <summary>Досягаемость контейнера от крысы, м.</summary>
const float Reach = 2.5f;
/// <summary>Контейнер перед крысой по лучу камеры: блок с хранилищем ближе Reach к крысе; иначе null.</summary>
public static TEFeatureStorage Find(World _world, EntityPlayerLocal _player, EntityAlive _rat, out Vector3i _pos, out BlockValue _bv)
{
_pos = Vector3i.zero;
_bv = BlockValue.Air;
Camera cam = _player.playerCamera;
if (cam == null)
{
return null;
}
Vector3 eye = _rat.getHeadPosition();
Vector3 from = cam.transform.position + Origin.position;
float dist = Vector3.Distance(from, eye) + Reach + 1f;
Ray ray = new Ray(from, cam.transform.forward);
if (!Voxel.Raycast(_world, ray, dist, -555266077, 69, 0f))
{
return null;
}
WorldRayHitInfo hit = Voxel.voxelRayHitInfo;
if ((hit.hit.pos - eye).sqrMagnitude > Reach * Reach)
{
return null;
}
Vector3i pos = hit.hit.blockPos;
BlockValue bv = hit.hit.blockValue;
Block block = bv.Block;
if (block == null || bv.isair)
{
return null;
}
if (bv.ischild && block.multiBlockPos != null)
{
pos = block.multiBlockPos.GetParentPos(pos, bv);
bv = _world.GetBlock(pos);
}
TileEntity te = _world.GetTileEntity(pos);
TEFeatureStorage st = te != null ? te.GetSelfOrFeature<TEFeatureStorage>() : null;
if (st == null)
{
return null;
}
_pos = pos;
_bv = bv;
return st;
}
/// <summary>Каждый кадр одержимости крысой: подсказка и E - открыть.</summary>
public static void Tick(World _world, EntityPlayerLocal _player, EntityAlive _rat, bool _activate, bool _take = false)
{
LocalPlayerUI ui = _player.PlayerUI;
if (ui == null || Viewing || ui.windowManager.IsModalWindowOpen())
{
return;
}
TEFeatureStorage st = Find(_world, _player, _rat, out Vector3i pos, out BlockValue bv);
if (st == null)
{
return;
}
PlayerActionsLocal input = _player.playerInput;
string key = input.Activate.GetBindingXuiMarkupString() + input.PermanentActions.Activate.GetBindingXuiMarkupString();
string text = st.GetActivationText(_world, pos, bv, _rat, key, bv.Block.GetLocalizedBlockName());
if (!string.IsNullOrEmpty(text))
{
XUiC_InteractionPrompt.SetText(ui, text);
}
if (!_activate)
{
return;
}
bool locked = st.lockpickFeature != null && st.lockpickFeature.NeedsLockpicking() ||
st.lockFeature != null && st.lockFeature.IsLocked() && !st.lockFeature.IsUserAllowed(Platform.PlatformManager.InternalLocalUserIdentifier) ||
st.isJammed;
if (locked)
{
Audio.Manager.BroadcastPlayByLocalPlayer(pos.ToVector3() + Vector3.one * 0.5f, "Misc/locked");
return;
}
Viewing = true;
Taking = _take;
_sparePos = pos;
_spareUntil = float.MaxValue;
XUiC_InteractionPrompt.SetText(ui, null);
// Через отражение: у LockRequestLocal есть перегрузка с ReadOnlySpan, которой нет в справочных сборках проекта.
_lockRequest = _lockRequest ?? AccessTools.Method(typeof(LockManager), nameof(LockManager.LockRequestLocal), new[] { typeof(ILockTarget), typeof(ushort) });
_lockRequest.Invoke(LockManager.Instance, new object[] { st, (ushort)0 });
Debug.Log("[NecromancerTome] RatLoot: крыса осматривает " + bv.Block.GetBlockName() + " " + pos);
}
static System.Reflection.MethodInfo _lockRequest;
/// <summary>Отказ «рюкзак полон».</summary>
public static void Deny()
{
LocalPlayerUI ui = LocalPlayerUI.GetUIForPrimaryPlayer();
if (ui?.entityPlayer != null)
{
GameManager.ShowTooltip(ui.entityPlayer, Localization.Get("xuiInventoryFullForPickup"), string.Empty, "ui_denied");
}
}
/// <summary>Выход из одержимости при открытом окне - закрыть его.</summary>
public static void CloseIfViewing()
{
if (!Viewing)
{
return;
}
LocalPlayerUI ui = LocalPlayerUI.GetUIForPrimaryPlayer();
ui?.windowManager.CloseAllOpenModalWindows();
Viewing = false;
_spareUntil = Time.time + 5f;
}
static void LockSlots(XUiC_LootWindow _w, bool _lock)
{
XUiC_LootContainer grid = _w?.lootContainer;
if (grid?.itemControllers == null)
{
return;
}
foreach (XUiC_ItemStack s in grid.itemControllers)
{
if (s != null)
{
s.IsLocked = _lock;
}
}
}
[HarmonyPatch(typeof(XUiC_LootWindowGroup), nameof(XUiC_LootWindowGroup.openContainer))]
public static class Patch_openContainer
{
public static void Postfix(XUiC_LootWindowGroup __instance)
{
if (!Viewing)
{
return;
}
__instance.xui.playerUI.windowManager.Close("backpack");
LockSlots(__instance.lootWindow, true);
}
}
/// <summary>Контейнеры с destroy_on_close (loot.xml: «empty» - 94 вида, мусор, кучи, холодильники, рюкзаки...;
/// «true» - аэродроп и коробки Twitch, это сущности, крыса их не открывает) разрушаются при снятии замка
/// после закрытия окна. Осмотр крысой их не разрушает (указание 08.10).</summary>
[HarmonyPatch(typeof(GameManager), nameof(GameManager.CheckDestroyTileEntity))]
public static class Patch_CheckDestroyTileEntity
{
public static bool Prefix(Vector3i _blockPos)
{
if (_blockPos != _sparePos || Time.time > _spareUntil)
{
return true;
}
_spareUntil = 0f;
Debug.Log("[NecromancerTome] RatLoot: " + _blockPos + " после осмотра крысой не разрушен");
return false;
}
}
/// <summary>Таймер первого обыска прерван - просмотра нет.</summary>
[HarmonyPatch(typeof(XUiC_LootWindowGroup), nameof(XUiC_LootWindowGroup.openTimerClosedManually))]
public static class Patch_openTimerClosedManually
{
public static void Postfix()
{
if (Viewing)
{
Viewing = false;
_spareUntil = Time.time + 5f;
}
}
}
/// <summary>Отказ замка (контейнер занят другим) - просмотра нет.</summary>
[HarmonyPatch(typeof(TEFeatureStorage), nameof(TEFeatureStorage.OnLockResponseLocal))]
public static class Patch_OnLockResponseLocal
{
public static void Postfix(bool _success)
{
if (!_success && Viewing)
{
Viewing = false;
_spareUntil = 0f;
}
}
}
[HarmonyPatch(typeof(XUiC_LootWindowGroup), nameof(XUiC_LootWindowGroup.OnClose))]
public static class Patch_OnClose
{
public static void Postfix(XUiC_LootWindowGroup __instance)
{
LockSlots(__instance.lootWindow, false); // всегда: запертые ячейки не должны достаться персонажу
if (Viewing)
{
Viewing = false;
_spareUntil = Time.time + 5f; // снятие замка (и проверка разрушения) - следом, может прийти пакетом
}
}
}
/// <summary>Дальность окна - от крысы, не от тела: XUiC_LootWindow.Update меряет её от entityPlayer.position,
/// поэтому на время его Update персонаж «стоит» на месте крысы (поле, ничего больше не трогается), а
/// Finalizer возвращает позицию при любом исходе. (Тест 08.10: прежний вариант обнулял te - и закрытие окна
/// по E/ESC падало в XUiC_LootWindow.OnClose, окно застревало и после выхода из одержимости.)</summary>
[HarmonyPatch(typeof(XUiC_LootWindow), nameof(XUiC_LootWindow.Update))]
public static class Patch_LootWindow_Update
{
public static void Prefix(XUiC_LootWindow __instance, out Vector3? __state)
{
__state = null;
EntityPlayerLocal p = __instance.xui.playerUI.entityPlayer;
EntityAlive rat = Possession.Zombie;
if (!Viewing || p == null || rat == null)
{
return;
}
__state = p.position;
p.position = rat.position;
}
public static void Finalizer(XUiC_LootWindow __instance, Vector3? __state)
{
if (__state.HasValue)
{
__instance.xui.playerUI.entityPlayer.position = __state.Value;
}
}
}
[HarmonyPatch(typeof(XUiC_ContainerStandardControls), nameof(XUiC_ContainerStandardControls.MoveAll))]
public static class Patch_MoveAll
{
public static bool Prefix(XUiC_ContainerStandardControls __instance)
{
if (!Viewing)
{
return true;
}
TEFeatureStorage te = __instance.GetParentByType<XUiC_LootWindow>()?.te;
if (Taking && te != null)
{
WolfBag.TakeAll(te);
}
else
{
Deny();
}
return false;
}
}
[HarmonyPatch(typeof(XUiC_ContainerStandardControls), nameof(XUiC_ContainerStandardControls.Sort))]
public static class Patch_Sort
{
public static bool Prefix()
{
return !Viewing;
}
}
/// <summary>Щелчок по запертой ячейке контейнера - отказ «рюкзак полон».</summary>
[HarmonyPatch(typeof(XUiC_ItemStack), nameof(XUiC_ItemStack.Update))]
public static class Patch_ItemStack_Update
{
public static void Postfix(XUiC_ItemStack __instance)
{
if (!Viewing || !__instance.isOver || __instance.ItemStack == null || __instance.ItemStack.IsEmpty() ||
__instance.GetParentByType<XUiC_LootContainer>() == null)
{
return;
}
CursorControllerAbs cur = __instance.xui.playerUI.CursorController;
if (cur.GetMouseButtonDown(UICamera.MouseButton.LeftButton) || cur.GetMouseButtonDown(UICamera.MouseButton.RightButton))
{
XUiC_LootContainer grid = __instance.GetParentByType<XUiC_LootContainer>();
TEFeatureStorage te = __instance.GetParentByType<XUiC_LootWindow>()?.te;
int idx = grid?.itemControllers != null ? System.Array.IndexOf(grid.itemControllers, __instance) : -1;
if (Taking && te != null && idx >= 0)
{
WolfBag.TakeFrom(te, idx);
}
else
{
Deny();
}
}
}
}
}
}
+250
View File
@@ -0,0 +1,250 @@
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Своя модель Могильной крысы (08.10.2026). Сущность necroRatSpirit по-прежнему стоит на префабе
/// лютоволка (Prefab в entityclasses.xml): его кости, аниматор, рэгдолл и коллайдеры остаются
/// ванильными. Меняется только то, что видно: в рендерер DireWolf кладём нашу сетку из бандла
/// Resources/necrorat, шерсть (DireWolfHair) прячем.
///
/// Сетка сделана с нуля в Blender, веса - по костям волка; привязку (bindposes) и номера костей
/// посчитал редактор Unity по самому префабу волка (NecroRatSkin.cs в DonorUnityProject),
/// поэтому smr.bones менять не нужно - порядок костей тот же. Проверка ниже на число костей
/// ловит случай, если игра однажды поменяет скелет волка: тогда остаётся волчья шкура, а не
/// изломанная крыса.
///
/// Шейдер - Standard (тот же, что у тела зомбопса). Свечение глаз - _EmissionMap и
/// _EmissionColor в материале necroRat_eye, фиолетовое, как у PetEyeGlow.
/// </summary>
public static class RatModel
{
public const string ClassName = "necroRatSpirit";
const string BundleFile = "Resources/necrorat";
static readonly string[] MatNames = { "necroRat_body", "necroRat_jaw", "necroRat_eye", "necroRat_incisor", "necroRat_claw" };
static bool _tried;
static Mesh _mesh;
static Material[] _mats;
public static bool Is(Entity _e)
{
return _e != null && EntityClass.list.TryGetValue(_e.entityClass, out EntityClass cls) &&
cls != null && cls.entityClassName == ClassName;
}
static bool Load()
{
if (_tried)
{
return _mesh != null;
}
_tried = true;
if (ModEntry.Instance == null)
{
return false;
}
string path = System.IO.Path.Combine(ModEntry.Instance.Path, BundleFile);
AssetBundle b = System.IO.File.Exists(path) ? AssetBundle.LoadFromFile(path) : null;
if (b == null)
{
Debug.LogWarning("[NecromancerTome] RatModel: бандл не загружен: " + path);
return false;
}
_mesh = b.LoadAsset<Mesh>("necroRatMesh");
_mats = new Material[MatNames.Length];
Shader std = Shader.Find("Standard");
for (int i = 0; i < MatNames.Length; i++)
{
_mats[i] = b.LoadAsset<Material>(MatNames[i]);
if (_mats[i] != null && std != null && _mats[i].shader != std)
{
_mats[i].shader = std;
}
}
Debug.Log("[NecromancerTome] RatModel: сетка " + (_mesh != null ? _mesh.vertexCount + " вершин, костей " + _mesh.bindposes.Length : "НЕТ") +
", шейдер " + (std != null ? std.name : "нет Standard"));
return _mesh != null;
}
/// <summary>Прозрачность крысы во время прохода сквозь постройки (указание 09.10: «пока зажата ПКМ, делать
/// крысу полупрозрачной»).</summary>
public const float GhostAlpha = 0.35f;
static Material[] _ghostMats;
/// <summary>Полупрозрачные копии материалов (Standard в режиме Fade) - один раз на игру; глаза
/// (necroRat_eye) остаются непрозрачными и светятся. Переключение - подмена sharedMaterials у рендерера
/// DireWolf, без пересоздания материалов.</summary>
public static void SetGhost(EntityAlive _rat, bool _ghost)
{
if (_rat?.emodel == null || _mats == null)
{
return;
}
if (_ghost && _ghostMats == null)
{
_ghostMats = new Material[_mats.Length];
for (int i = 0; i < _mats.Length; i++)
{
Material src = _mats[i];
if (src == null || MatNames[i] == "necroRat_eye")
{
_ghostMats[i] = src;
continue;
}
Material m = new Material(src) { name = src.name + "_ghost" };
m.SetFloat("_Mode", 2f);
m.SetOverrideTag("RenderType", "Transparent");
m.SetInt("_SrcBlend", (int)UnityEngine.Rendering.BlendMode.SrcAlpha);
m.SetInt("_DstBlend", (int)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha);
m.SetInt("_ZWrite", 0);
m.DisableKeyword("_ALPHATEST_ON");
m.EnableKeyword("_ALPHABLEND_ON");
m.DisableKeyword("_ALPHAPREMULTIPLY_ON");
m.renderQueue = (int)UnityEngine.Rendering.RenderQueue.Transparent;
if (m.HasProperty("_Color"))
{
Color c = m.color;
c.a = GhostAlpha;
m.color = c;
}
_ghostMats[i] = m;
}
}
foreach (SkinnedMeshRenderer r in _rat.emodel.GetComponentsInChildren<SkinnedMeshRenderer>(true))
{
if (r.name == "DireWolf" && r.sharedMesh == _mesh)
{
r.sharedMaterials = _ghost ? _ghostMats : _mats;
}
}
}
public static void Apply(EModelBase _model)
{
if (!Load())
{
return;
}
bool done = false;
foreach (SkinnedMeshRenderer r in _model.GetComponentsInChildren<SkinnedMeshRenderer>(true))
{
if (r.name == "DireWolf")
{
if (r.bones == null || r.bones.Length != _mesh.bindposes.Length)
{
Debug.LogWarning("[NecromancerTome] RatModel: у волка " + (r.bones?.Length ?? 0) + " костей, у крысы " +
_mesh.bindposes.Length + " - модель не поставлена");
return;
}
r.sharedMesh = _mesh;
r.sharedMaterials = _mats;
r.localBounds = _mesh.bounds;
done = true;
}
else if (r.name == "DireWolfHair")
{
// Не enabled = false: при появлении питомца (renderFade) игра через avatarController.SetVisible
// включает все рендереры заново, и шерсть ложилась поверх крысы (тест 08.10: «модель крысы
// применилась, но почему-то в шерсти»). forceRenderingOff игра не трогает.
r.enabled = false;
r.forceRenderingOff = true;
}
}
Debug.Log("[NecromancerTome] RatModel: " + (done ? "модель крысы поставлена на " : "рендерер DireWolf не найден у ") + _model.entity?.entityId);
}
}
/// <summary>Крыса бегала, не касаясь земли (тест 08.10). Entity.SetCCScale уменьшает высоту капсулы движения
/// в SizeScale раз, а радиус - только увеличивает (FastMax(scale, 1)). У крысы (0.28 от волка) высота
/// стала меньше двух радиусов, капсула выродилась в шар волчьего радиуса, и его низ ушёл под ноги -
/// контроллер поднимал крысу над землёй. Радиус ужимаем так же, как высоту.</summary>
[HarmonyPatch(typeof(Entity), nameof(Entity.SetScale))]
public static class Patch_Entity_SetScale_RatModel
{
public static void Postfix(Entity __instance, float scale)
{
CharacterControllerAbstract cc = __instance.m_characterController;
if (cc == null || scale >= 1f || !RatModel.Is(__instance))
{
return;
}
float h = cc.GetHeight();
float r = Mathf.Min(cc.GetRadius() * scale, h * 0.5f);
// Центр - ровно половина высоты, иначе низ капсулы уходит под ноги. Замер 08.10 (RatProbe): центр 0.16
// при высоте 0.48 - низ на 8 см ниже точки сущности, земля ровно на -0.084, крыса висит. Волку игра
// центр выравнивает сама (SetCCScale: высота 1.89-2.2 -> 1.89, центр = половина), крысе - нет.
Vector3 c = cc.GetCenter();
c.y = h * 0.5f;
cc.SetSize(c, h, r);
Debug.Log("[NecromancerTome] RatModel: капсула крысы " + __instance.entityId + " - высота " + h.ToString("F2") +
", радиус " + r.ToString("F2") + ", центр " + c.y.ToString("F2"));
}
}
/// <summary>ЗАМЕР (08.10, временный): крыса всё ещё «не достаёт до земли» после правки капсулы - раз в 3 с пишем,
/// где сущность, где её видимая модель, физическое тело, низ костей и земля под ней. По логу видно, что
/// именно приподнято. Убрать после починки.</summary>
[HarmonyPatch(typeof(EntityAlive), nameof(EntityAlive.LateUpdate))]
public static class Patch_EntityAlive_LateUpdate_RatProbe
{
static int _ratClass = int.MinValue;
static float _next;
public static void Postfix(EntityAlive __instance)
{
if (_ratClass == int.MinValue)
{
_ratClass = EntityClass.FromString(RatModel.ClassName);
}
if (__instance.entityClass != _ratClass || Time.time < _next || __instance.IsDead())
{
return;
}
_next = Time.time + 3f;
Vector3 p = __instance.position - Origin.position;
string ground = Physics.Raycast(p + Vector3.up, Vector3.down, out RaycastHit hit, 4f, SnakeControl.WallMask, QueryTriggerInteraction.Ignore)
? (hit.point.y - p.y).ToString("F3") : "нет";
float low = float.MaxValue, rmin = float.MaxValue;
string lowName = "";
foreach (SkinnedMeshRenderer r in __instance.GetComponentsInChildren<SkinnedMeshRenderer>(false))
{
if (r.forceRenderingOff || r.bones == null)
{
continue;
}
rmin = Mathf.Min(rmin, r.bounds.min.y - p.y);
foreach (Transform b in r.bones)
{
if (b != null && b.position.y - p.y < low)
{
low = b.position.y - p.y;
lowName = b.name;
}
}
}
CharacterControllerAbstract cc = __instance.m_characterController;
Transform mt = __instance.emodel != null ? __instance.emodel.GetModelTransform() : null;
Debug.Log("[NecromancerTome] RatProbe " + __instance.entityId + ": земля " + ground +
", transform " + (__instance.transform.position.y - p.y).ToString("F3") +
", model " + (mt != null ? (mt.position.y - p.y).ToString("F3") + " scale " + mt.lossyScale.y.ToString("F2") : "нет") +
", низ кости " + low.ToString("F3") + " (" + lowName + "), низ сетки " + rmin.ToString("F3") +
", physicsRB " + (__instance.physicsRBT != null ? (__instance.physicsRBT.position.y - __instance.physicsBasePos.y - p.y).ToString("F3") : "нет") +
", onGround " + __instance.onGround +
(cc != null ? ", cc c" + cc.GetCenter().y.ToString("F2") + " h" + cc.GetHeight().ToString("F2") + " r" + cc.GetRadius().ToString("F2") : ""));
}
}
[HarmonyPatch(typeof(EModelBase), nameof(EModelBase.createModel))]
public static class Patch_EModelBase_createModel_RatModel
{
public static void Postfix(EModelBase __instance)
{
if (RatModel.Is(__instance.entity))
{
RatModel.Apply(__instance);
}
}
}
}
+173
View File
@@ -0,0 +1,173 @@
using System.Collections.Generic;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Проход одержимой крысы сквозь постройки (BACKLOG, «Могильная крыса» п.4; тест 09.10: «почему не срабатывает
/// проход сквозь текстуры по ПКМ»). Пока держишь ПКМ - как дух змеи, но без полёта:
/// - стены, двери, мебель не держат: поднят Entity.IsStuck, и её собственный шаг (тот же бег по WASD)
/// идёт мимо контроллера столкновений (Entity.ccEntityCollisionStart);
/// - высоту ведём сами: IsStuck снимает и пол, поэтому каждый кадр крыса стоит на поверхности под собой
/// (луч вниз с полуметра - ступенька по пути, как у змеи), а над пустотой падает с ускорением на неё;
/// - грунт (земля, песок, камень) не пускает - как у змеи: шаг в грунт отменяется;
/// - отпустил ПКМ внутри построек - выталкивает на ближайшее свободное место (вбок, вверх, вниз - дороже).
/// </summary>
public static class RatPhase
{
public static bool On;
static Vector3 _last;
static float _vy;
const float Gravity = 20f;
/// <summary>Точка тела крысы для проверки «внутри» и радиус шара свободного места.</summary>
const float BodyUp = 0.2f;
const float BodyRadius = 0.12f;
/// <summary>С какой высоты над ногами ищем пол: всё ниже - ступенька, на которую она взбегает.</summary>
const float StepUp = 0.5f;
static Vector3[] _ejectOffsets;
public static void Tick(World _world, EntityAlive _rat, bool _held)
{
if (_held && !On)
{
On = true;
_vy = 0f;
_last = _rat.position;
_rat.IsStuck = true;
RatModel.SetGhost(_rat, true);
Debug.Log("[NecromancerTome] RatPhase: проход включён");
}
else if (!_held && On)
{
Stop(_world, _rat, "ПКМ отпущена");
return;
}
if (!On)
{
return;
}
_rat.IsStuck = true;
_rat.fallDistance = 0f;
Vector3 p = _rat.position;
if (!_world.IsChunkAreaCollidersLoaded(p))
{
_rat.SetPosition(_last);
return;
}
// В грунт - нельзя: шаг вбок отменяем.
if (SnakeControl.Inside(_world, p + Vector3.up * BodyUp, SnakeControl.TerrainMask, out bool ground) && ground)
{
p.x = _last.x;
p.z = _last.z;
}
// Высота - только наша: собственная гравитация сущности с IsStuck пронесла бы её сквозь пол.
float dt = Time.deltaTime;
p.y = _last.y;
if (SnakeControl.Ground(p + Vector3.up * StepUp, StepUp + 300f, out float floor))
{
if (floor >= p.y - 0.02f)
{
p.y = floor;
_vy = 0f;
}
else
{
_vy -= Gravity * dt;
p.y = Mathf.Max(floor, p.y + _vy * dt);
if (p.y <= floor)
{
_vy = 0f;
}
}
}
Vector3 m = _rat.motion;
m.y = 0f;
_rat.motion = m;
_rat.SetPosition(p);
_last = p;
}
/// <summary>Выключить проход; внутри построек - вытолкнуть наружу.</summary>
public static void Stop(World _world, EntityAlive _rat, string _why)
{
if (!On)
{
return;
}
On = false;
if (_rat == null)
{
return;
}
_rat.IsStuck = false;
_rat.motion = Vector3.zero;
RatModel.SetGhost(_rat, false);
Vector3 p = _rat.position;
if (_world != null && !Free(_world, p))
{
Vector3? to = Eject(_world, p);
if (to.HasValue)
{
p = to.Value;
Debug.Log("[NecromancerTome] RatPhase: вытолкнута из построек, сдвиг " + (p - _rat.position).magnitude.ToString("F1") + " м");
}
else
{
p = _world.FindSupportingBlockPos(p);
Debug.Log("[NecromancerTome] RatPhase: свободного места рядом нет - на опорный блок");
}
_rat.SetPosition(p);
}
Debug.Log("[NecromancerTome] RatPhase: проход выключен - " + _why);
}
static bool Free(World _world, Vector3 _p)
{
Vector3 body = _p + Vector3.up * BodyUp;
return _world.IsChunkAreaCollidersLoaded(_p) &&
!SnakeControl.Inside(_world, body, SnakeControl.WallMask, out _) &&
!Physics.CheckSphere(body - Origin.position, BodyRadius, SnakeControl.WallMask, QueryTriggerInteraction.Ignore);
}
/// <summary>Ближайшее свободное место с опорой: до 4 м вбок, до 1 м вниз, до 4 м вверх; вниз дороже.</summary>
static Vector3? Eject(World _world, Vector3 _p)
{
if (_ejectOffsets == null)
{
List<Vector3> list = new List<Vector3>();
for (float r = 0f; r <= 4f; r += 0.25f)
{
int dirs = r == 0f ? 1 : 16;
for (float h = -1f; h <= 4f; h += 0.5f)
{
for (int i = 0; i < dirs; i++)
{
list.Add(Quaternion.Euler(0f, i * 22.5f, 0f) * Vector3.forward * r + Vector3.up * h);
}
}
}
list.Sort((x, y) => (x.magnitude + (x.y < 0f ? -x.y : 0f)).CompareTo(y.magnitude + (y.y < 0f ? -y.y : 0f)));
_ejectOffsets = list.ToArray();
}
foreach (Vector3 o in _ejectOffsets)
{
Vector3 c = _p + o;
if (!Free(_world, c))
{
continue;
}
// Посадить на пол под этим местом; если пол сам внутри построек - место не годится.
if (SnakeControl.Ground(c + Vector3.up * 0.3f, 6f, out float y))
{
c.y = y;
if (!Free(_world, c))
{
continue;
}
}
return c;
}
return null;
}
}
}
+4 -4
View File
@@ -182,7 +182,7 @@ namespace NecromancerTome
// Фиолетовая змея на карте (nav_objects.xml, necroSnakeSpirit), указание 02.10. С 06.10 - своя // Фиолетовая змея на карте (nav_objects.xml, necroSnakeSpirit), указание 02.10. С 06.10 - своя
// метка по Transform (NecroMapMarker): через AddNavObject она так и не показалась. // метка по Transform (NecroMapMarker): через AddNavObject она так и не показалась.
NecroMapMarker.Hide(ref _mapNav); // старая ссылка после закрытия мира - снимется, только если ещё наша NecroMapMarker.Hide(ref _mapNav); // старая ссылка после закрытия мира - снимется, только если ещё наша
_mapNav = NecroMapMarker.Show(_snake, "ui_game_symbol_tracking_snake"); _mapNav = NecroMapMarker.Show(_snake, NecroMapMarker.SnakeClass);
Target = null; Target = null;
_radiationCheck = 0f; _radiationCheck = 0f;
BodyDanger = false; BodyDanger = false;
@@ -884,7 +884,7 @@ namespace NecromancerTome
return _o.magnitude + (_o.y < 0f ? -_o.y : 0f); return _o.magnitude + (_o.y < 0f ? -_o.y : 0f);
} }
static bool Free(World _world, Vector3 _p, bool _groundOnly) public static bool Free(World _world, Vector3 _p, bool _groundOnly)
{ {
if (!_world.IsChunkAreaCollidersLoaded(_p)) if (!_world.IsChunkAreaCollidersLoaded(_p))
{ {
@@ -905,7 +905,7 @@ namespace NecromancerTome
/// точка под ней, то есть внутри. Лучи видят только лицевые стороны, так что пары хватает /// точка под ней, то есть внутри. Лучи видят только лицевые стороны, так что пары хватает
/// и в пещере, и в доме. _ground - это грунт (блок-земля под той поверхностью или в самой /// и в пещере, и в доме. _ground - это грунт (блок-земля под той поверхностью или в самой
/// точке), а не постройка.</summary> /// точке), а не постройка.</summary>
static bool Inside(World _world, Vector3 _p, int _mask, out bool _ground) public static bool Inside(World _world, Vector3 _p, int _mask, out bool _ground)
{ {
_ground = false; _ground = false;
Vector3 o = _p - Origin.position; Vector3 o = _p - Origin.position;
@@ -934,7 +934,7 @@ namespace NecromancerTome
return _world.GetBlock(World.worldToBlockPos(_p)).Block.IsCollideMovement; return _world.GetBlock(World.worldToBlockPos(_p)).Block.IsCollideMovement;
} }
static bool Ground(Vector3 _from, float _dist, out float _y) public static bool Ground(Vector3 _from, float _dist, out float _y)
{ {
if (Physics.Raycast(_from - Origin.position, Vector3.down, out RaycastHit hit, _dist, TerrainMask)) if (Physics.Raycast(_from - Origin.position, Vector3.down, out RaycastHit hit, _dist, TerrainMask))
{ {
-6
View File
@@ -238,12 +238,6 @@ namespace NecromancerTome
return; return;
} }
// Красный труп под прицелом - не блок: череп для урны (NecroSkull.cs, 06.10).
if (NecroSkull.TryBegin(_player, _bracelet))
{
return;
}
WorldRayHitInfo hitInfo = _player.HitInfo; WorldRayHitInfo hitInfo = _player.HitInfo;
if (hitInfo == null || !hitInfo.bHitValid) if (hitInfo == null || !hitInfo.bHitValid)
{ {
+1 -14
View File
@@ -471,20 +471,6 @@ namespace NecromancerTome
} }
} }
/// <summary>E в одержимом питомце (тест 06.10: «E не сработал, срабатывает R») - отзыв его самого:
/// игрок возвращается в тело, питомец уходит в книгу. HandleInteraction во время одержимости не
/// идёт (PlayerMoveController.Update выключен), поэтому зовёт Possession. True - отозван.</summary>
public static bool RecallPossessedPet(EntityAlive owner, EntityAlive pet)
{
if (owner == null || pet == null || !EntityClass.list.TryGetValue(pet.entityClass, out EntityClass ec) ||
!LimitedPets.TryGetValue(ec.entityClassName, out PetInfo info) || info.RecalledKey == null)
{
return false;
}
RecallPet(owner, pet.entityId, info.RecalledKey, info.ConsumesBook ? info.SummonItemName : null);
return true;
}
public static void RecallPet(EntityAlive owner, int petEntityId, string recalledTooltipKey, string summonItemName) public static void RecallPet(EntityAlive owner, int petEntityId, string recalledTooltipKey, string summonItemName)
{ {
if (Possession.Active && Possession.Zombie != null && Possession.Zombie.entityId == petEntityId) if (Possession.Active && Possession.Zombie != null && Possession.Zombie.entityId == petEntityId)
@@ -494,6 +480,7 @@ namespace NecromancerTome
World world = owner.world; World world = owner.world;
if (world != null) if (world != null)
{ {
WolfBag.DropFrom(world.GetEntity(petEntityId) as EntityAlive, "отозван"); // сумка волка - мешком на месте
world.RemoveEntity(petEntityId, EnumRemoveEntityReason.Killed); world.RemoveEntity(petEntityId, EnumRemoveEntityReason.Killed);
} }
owner.RemoveOwnedEntity(petEntityId); owner.RemoveOwnedEntity(petEntityId);
+197
View File
@@ -0,0 +1,197 @@
using System.Collections.Generic;
using System.Globalization;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Шея одержимого стервятника (указание 08.10). Сидя он стоит в позе 0 ванильного спящего
/// (sleeperSit) - голова опущена, как во сне. Это верно для еды, а бодрствующему нужна голова
/// вперёд: шея и голова - посередине между полётом и сном.
///
/// КАК. Модель и анимации игры не трогаем (в мод не идёт ни одного ассета игры): после аниматора
/// доворачиваем кости шеи и головы. В полёте запоминаем их локальные повороты (сглаженно - крылья
/// машут), сидя - каждый кадр берём то, что поставил аниматор (поза сна), и ставим долю Mix пути к
/// полётным. Вход и выход - плавно за BlendTime. Зовётся из Possession.PlaceCamera (постфикс
/// vp_FPCamera.LateUpdate) - после аниматора и до того, как камера читает положение головы.
///
/// ПАМЯТЬ. Ничего не сохраняется; ссылки на кости - только пока стервятник одержим (Reset на выходе).
/// </summary>
public static class VultureNeck
{
/// <summary>Доля пути от позы сна к полётной (0.5 - посередине). Подбор - necroneck mix.</summary>
public static float Mix = 0.5f;
public static bool Enabled = true;
public const float BlendTime = 0.3f;
/// <summary>Полётную позу берём, когда стервятник летит хотя бы столько (без переходов анимации).</summary>
public const float FlightSettle = 0.5f;
/// <summary>Если костей с «neck» в имени нет - голова и столько её родителей.</summary>
public const int FallbackChain = 3;
static EntityAlive _v;
static Transform[] _chain;
static Quaternion[] _flight;
static bool _hasFlight;
static float _flyTime;
static float _weight;
public static void Reset()
{
_v = null;
_chain = null;
_flight = null;
_hasFlight = false;
_flyTime = 0f;
_weight = 0f;
}
/// <summary>Каждый кадр одержимости стервятником, после аниматора. _awake - сидит, не ест.</summary>
public static void Apply(EntityAlive _vulture, bool _sitting, bool _awake)
{
if (_vulture != _v)
{
Reset();
_v = _vulture;
}
if (_chain == null && !FindChain())
{
return;
}
if (!_sitting)
{
_flyTime += Time.deltaTime;
if (_flyTime >= FlightSettle)
{
float k = _hasFlight ? Mathf.Min(1f, Time.deltaTime * 4f) : 1f;
for (int i = 0; i < _chain.Length; i++)
{
if (_chain[i] == null)
{
_chain = null; // модель пересобрана - найти кости заново
return;
}
_flight[i] = Quaternion.Slerp(_flight[i], _chain[i].localRotation, k);
}
_hasFlight = true;
}
}
else
{
_flyTime = 0f;
}
float want = Enabled && _sitting && _awake && _hasFlight ? 1f : 0f;
_weight = Mathf.MoveTowards(_weight, want, Time.deltaTime / BlendTime);
if (_weight <= 0f || !_sitting)
{
return;
}
float t = Mix * Mathf.SmoothStep(0f, 1f, _weight);
for (int i = 0; i < _chain.Length; i++)
{
if (_chain[i] == null)
{
_chain = null;
return;
}
_chain[i].localRotation = Quaternion.Slerp(_chain[i].localRotation, _flight[i], t);
}
}
/// <summary>Цепочка от головы вверх: все кости с «neck» в имени (без них - FallbackChain родителей).</summary>
static bool FindChain()
{
Transform head = _v?.emodel != null ? _v.emodel.GetHeadTransform() : null;
if (head == null)
{
return false;
}
var list = new List<Transform> { head };
for (Transform p = head.parent; p != null && p.name.ToLowerInvariant().Contains("neck"); p = p.parent)
{
list.Add(p);
}
if (list.Count == 1)
{
for (Transform p = head.parent; p != null && list.Count <= FallbackChain; p = p.parent)
{
list.Add(p);
}
}
_chain = list.ToArray();
if (_flight == null || _flight.Length != _chain.Length)
{
_flight = new Quaternion[_chain.Length];
_hasFlight = false;
}
string names = "";
foreach (Transform t in _chain)
{
names += (names.Length > 0 ? " < " : "") + t.name;
}
Debug.Log("[NecromancerTome] VultureNeck: кости " + names);
return true;
}
public static string Dump()
{
if (_chain == null)
{
return "костей нет - вселитесь в стервятника";
}
string s = "mix " + F(Mix) + (Enabled ? "" : " (выкл)") + ", вес " + F(_weight) + ", полёт " + (_hasFlight ? "снят" : "не снят");
for (int i = 0; i < _chain.Length; i++)
{
if (_chain[i] != null)
{
s += "\n " + _chain[i].name + " сейчас " + HandPose.F(_chain[i].localEulerAngles) + ", в полёте " + HandPose.F(_flight[i].eulerAngles);
}
}
return s;
}
static string F(float _f) => _f.ToString("0.##", CultureInfo.InvariantCulture);
}
/// <summary>
/// necroneck - подбор шеи сидящего одержимого стервятника:
/// mix &lt;0..1&gt; - доля пути от позы сна к полётной (0 - как спит, 1 - как в полёте)
/// on | off - включить / выключить доворот
/// show - кости и их повороты (в консоль и лог)
/// </summary>
[Preserve]
public class ConsoleCmdNecroNeck : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necroneck" };
public override string getDescription() => "NecromancerTome: шея сидящего одержимого стервятника - mix <0..1>|on|off|show";
void Out(string _s)
{
SdtdConsole.Instance.Output(_s);
Debug.Log("[NecromancerTome] necroneck: " + _s);
}
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
string cmd = _params.Count > 0 ? _params[0] : "show";
switch (cmd)
{
case "on":
case "off":
VultureNeck.Enabled = cmd == "on";
Out("доворот шеи " + (VultureNeck.Enabled ? "включён" : "выключен"));
return;
case "mix":
if (_params.Count > 1 && float.TryParse(_params[1].Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out float m))
{
VultureNeck.Mix = Mathf.Clamp(m, -0.5f, 1.5f);
}
Out("mix " + VultureNeck.Mix.ToString("0.##", CultureInfo.InvariantCulture));
return;
default:
Out(VultureNeck.Dump());
return;
}
}
}
}
+258
View File
@@ -0,0 +1,258 @@
using System.Collections.Generic;
using System.IO;
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Сумка одержимого лютоволка на 3 ячейки (решено 09.10, BACKLOG «Медведь и лютоволк в одержимости»):
/// - волк лутает: окно контейнера то же, что у крысы (RatLoot, рюкзак персонажа не показывается),
/// но щелчок по предмету (и «Взять всё») кладёт его в сумку волка; места нет - «Сумка полна»;
/// - выгрузка - принести к телу: рядом с телом E перекладывает сумку в рюкзак персонажа, что не
/// влезло - мешком на землю рядом;
/// - волк погиб или отозван - сумка выпадает мешком на месте.
///
/// ХРАНЕНИЕ. Сумку сущности игра не сохраняет (Entity.bag нет в Entity.Write), поэтому она своя.
/// Упаковка - та же, что у пространственного хранилища (SpatialVaultPersistence.BuildPayload/ParsePayload,
/// проверена на сейвах 3.2 и 3.3), но в ОТДЕЛЬНОМ файле папки сейва, как у Чертогов разума
/// (NecromancerTome_palaces.txt): файл игрока не меняется (сейвы - главный риск мода). Пишется сразу
/// при каждом изменении - переживает и сохранение, и вылет. Волк у игрока один (один спутник за раз),
/// поэтому сумка одна, без привязки к номеру сущности.
/// </summary>
public static class WolfBag
{
public const int Slots = 3;
const string FileName = "NecromancerTome_wolfbag.bin";
public const float UnloadRange = 3f;
static Bag _bag;
static string _dir;
static string FilePath => Path.Combine(GameIO.GetSaveGameDir(), FileName);
/// <summary>Сумка текущего сейва (из файла при первом обращении в этом сейве).</summary>
public static Bag Get()
{
string dir = GameIO.GetSaveGameDir();
if (_bag != null && _dir == dir)
{
return _bag;
}
_dir = dir;
_bag = null;
try
{
if (File.Exists(FilePath))
{
_bag = SpatialVaultPersistence.ParsePayload(File.ReadAllBytes(FilePath));
}
}
catch (System.Exception e)
{
Debug.LogWarning("[NecromancerTome] WolfBag: файл сумки не прочитан (" + e.Message + ") - оставлен как есть, сумка пустая");
_bag = new Bag(new Vector2i(Slots, 1), XUiC_ItemStack.StackLocationTypes.LootContainer, null);
return _bag;
}
if (_bag == null)
{
_bag = new Bag(new Vector2i(Slots, 1), XUiC_ItemStack.StackLocationTypes.LootContainer, null);
}
else if (_bag.SlotCount < Slots)
{
_bag.Resize(new Vector2i(Slots, 1));
}
Debug.Log("[NecromancerTome] WolfBag: сумка загружена - " + Describe());
return _bag;
}
static void Save()
{
try
{
if (_bag == null || _bag.IsEmpty())
{
if (File.Exists(FilePath))
{
File.Delete(FilePath);
}
return;
}
File.WriteAllBytes(FilePath, SpatialVaultPersistence.BuildPayload(_bag));
}
catch (System.Exception e)
{
Debug.LogWarning("[NecromancerTome] WolfBag: сумка не записана - " + e.Message);
}
}
public static string Describe()
{
Bag b = Get();
List<string> parts = new List<string>();
foreach (ItemStack s in b.ItemGrid.items)
{
if (s != null && !s.IsEmpty())
{
parts.Add(s.itemValue.ItemClass.GetLocalizedItemName() + (s.count > 1 ? " x" + s.count : ""));
}
}
return parts.Count == 0 ? Localization.Get("necroWolfBagEmpty") : string.Join(", ", parts);
}
static void Show(string _key)
{
EntityPlayerLocal p = GameManager.Instance?.World?.GetPrimaryPlayer();
if (p != null)
{
GameManager.ShowTooltip(p, string.Format(Localization.Get(_key), Describe(), Get().GetUsedSlotCount(), Slots));
}
}
/// <summary>Переложить ячейку контейнера в сумку. Без раздвоения: сперва в неполные стопки, остаток -
/// в пустую ячейку; в контейнере остаётся ровно то, что не влезло.</summary>
public static bool TakeFrom(TEFeatureStorage _te, int _idx)
{
ItemStack src = _te.ItemGrid.GetItem(_idx);
if (src == null || src.IsEmpty())
{
return false;
}
Bag b = Get();
ItemStack moving = src.Clone();
int before = moving.count;
b.TryStackItem(0, moving);
if (moving.count > 0 && b.AddItem(moving.Clone()))
{
moving.count = 0;
}
int moved = before - moving.count;
if (moved <= 0)
{
Audio.Manager.PlayInsidePlayerHead("ui_denied");
Show("necroWolfBagFull");
return false;
}
ItemStack rest = src.Clone();
rest.count = moving.count;
_te.UpdateSlot(_idx, rest.count > 0 ? rest : ItemStack.Empty.Clone());
_te.SetModified();
_te.Parent?.NotifyListeners(); // окно контейнера перерисует ячейку (как ванильная сортировка)
Save();
Audio.Manager.PlayInsidePlayerHead("UseActions/takeall1");
Show("necroWolfBagTook");
return true;
}
/// <summary>«Взять всё» - по ячейкам, пока есть место.</summary>
public static void TakeAll(TEFeatureStorage _te)
{
bool any = false;
for (int i = 0; i < _te.ItemGrid.items.Length; i++)
{
if (!_te.ItemGrid.items[i].IsEmpty() && Get().CanTakeItem(_te.ItemGrid.items[i]))
{
any |= TakeFrom(_te, i);
}
}
if (!any)
{
Audio.Manager.PlayInsidePlayerHead("ui_denied");
Show("necroWolfBagFull");
}
}
/// <summary>E рядом с телом: сумка - в рюкзак, что не влезло - мешком у тела.</summary>
public static bool TryUnload(EntityPlayerLocal _body, EntityAlive _wolf)
{
if (_body == null || (_body.position - _wolf.position).sqrMagnitude > UnloadRange * UnloadRange)
{
return false;
}
Bag b = Get();
if (b.IsEmpty())
{
Show("necroWolfBagShow");
return true;
}
List<ItemStack> left = new List<ItemStack>();
ItemStack[] slots = b.ItemGrid.items;
for (int i = 0; i < slots.Length; i++)
{
ItemStack s = slots[i]?.Clone();
if (s == null || s.IsEmpty())
{
continue;
}
_body.bag.TryStackItem(0, s);
if (s.count > 0 && _body.bag.AddItem(s.Clone()))
{
s.count = 0;
}
if (s.count > 0)
{
left.Add(s);
}
}
b.Clear();
Save();
if (left.Count > 0)
{
Drop(_body.position + _body.GetForwardVector() * 0.8f, left);
}
Audio.Manager.PlayInsidePlayerHead("UseActions/takeall1");
GameManager.ShowTooltip(_body, Localization.Get(left.Count > 0 ? "necroWolfBagUnloadedPart" : "necroWolfBagUnloaded"));
Debug.Log("[NecromancerTome] WolfBag: выгружена у тела" + (left.Count > 0 ? ", не влезло " + left.Count + " - мешком" : ""));
return true;
}
/// <summary>Волк погиб или отозван - сумка мешком на месте.</summary>
public static void DropFrom(EntityAlive _wolf, string _why)
{
if (_wolf == null || !WolfControl.Is(_wolf))
{
return;
}
Bag b = Get();
if (b.IsEmpty())
{
return;
}
List<ItemStack> items = new List<ItemStack>();
foreach (ItemStack s in b.ItemGrid.items)
{
if (s != null && !s.IsEmpty())
{
items.Add(s.Clone());
}
}
b.Clear();
Save();
Drop(_wolf.position, items);
Debug.Log("[NecromancerTome] WolfBag: волк " + _why + " - сумка выпала мешком (" + items.Count + ")");
}
static void Drop(Vector3 _at, List<ItemStack> _items)
{
GameManager.Instance.DropContentInLootContainerServer(-1, "DroppedLootContainer", _at + Vector3.up * 0.5f, _items.ToArray());
}
/// <summary>Выход из мира - забыть (следующий сейв прочитает свой файл).</summary>
public static void Forget()
{
_bag = null;
_dir = null;
}
}
[HarmonyPatch(typeof(EntityAlive), nameof(EntityAlive.OnEntityDeath))]
public static class Patch_EntityAlive_OnEntityDeath_WolfBag
{
public static void Prefix(EntityAlive __instance)
{
if (!__instance.world.IsRemote() && WolfControl.Is(__instance))
{
WolfBag.DropFrom(__instance, "погиб");
}
}
}
}
+172
View File
@@ -0,0 +1,172 @@
using System.Collections.Generic;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Одержимый лютоволк - охотник (решено 09.10, BACKLOG «Медведь и лютоволк в одержимости»):
/// - бросок на ПКМ: враг под прицелом не дальше LeapRange - волк разворачивается и прыгает прямо на
/// него (прыжок самой игры: SetJumpDistance на точную дальность и перепад высоты); приземлился рядом -
/// цель падает на LeapStun секунд;
/// - след крови: раненые враги (здоровье ниже BloodHealth) отмечены на компасе и карте до BloodRange,
/// пока не умрут или не залечатся, - отличие от нюха собаки: та чует всех рядом, волк - подранков издалека.
/// </summary>
public static class WolfControl
{
public const string WolfClass = "necroZombieWolf";
public const float LeapRange = 10f;
public const float LeapMin = 2f;
public const float LeapConeDeg = 10f;
public const float LeapCooldown = 3f;
public const float LeapHit = 2.5f;
public const float LeapStun = 2.5f;
public const float BloodHealth = 0.5f;
public const float BloodRange = 150f;
public const float BloodDropRange = 200f;
static EntityAlive _leapTarget;
static float _leapUntil, _leapStart, _nextLeap;
static readonly HashSet<int> _blood = new HashSet<int>();
static float _bloodCheck;
public static bool Is(EntityAlive _e)
{
return _e != null && EntityClass.list.TryGetValue(_e.entityClass, out EntityClass cls) && cls != null &&
cls.entityClassName == WolfClass;
}
/// <summary>Каждый кадр одержимости волком.</summary>
public static void Tick(World _world, EntityPlayerLocal _player, EntityAlive _wolf, bool _leap, ref float _bodyYaw)
{
if (_leap && _leapTarget == null && Time.time >= _nextLeap && _wolf.onGround && !_wolf.Jumping)
{
EntityAlive t = Target(_world, _player, _wolf);
if (t != null)
{
Vector3 to = t.position - _wolf.position;
Vector3 flat = new Vector3(to.x, 0f, to.z);
_bodyYaw = Mathf.Atan2(flat.x, flat.z) * Mathf.Rad2Deg;
_wolf.SetRotation(new Vector3(0f, _bodyYaw, 0f));
_wolf.Jumping = true;
_wolf.SetJumpDistance(flat.magnitude, Mathf.Max(0.5f, to.y + 0.5f));
_leapTarget = t;
_leapStart = Time.time;
_leapUntil = Time.time + 2f;
_nextLeap = Time.time + LeapCooldown;
Debug.Log("[NecromancerTome] WolfControl: бросок на " + t.EntityName + " (" + t.entityId + "), " + flat.magnitude.ToString("F1") + " м");
}
}
if (_leapTarget != null)
{
bool landed = Time.time - _leapStart > 0.3f && _wolf.onGround && !_wolf.Jumping;
if (_leapTarget.IsDead() || Time.time > _leapUntil)
{
_leapTarget = null;
}
else if (landed || (_leapTarget.getChestPosition() - _wolf.getHeadPosition()).sqrMagnitude < LeapHit * LeapHit * 0.5f)
{
if ((_leapTarget.position - _wolf.position).sqrMagnitude < LeapHit * LeapHit)
{
KnockDown(_leapTarget, _wolf);
}
_leapTarget = null;
}
}
BloodTick(_world, _wolf);
}
static void KnockDown(EntityAlive _t, EntityAlive _wolf)
{
_wolf.emodel?.avatarController?.StartAnimationAttack();
if (_t.bodyDamage.CurrentStun == EnumEntityStunType.None && _t.emodel?.avatarController != null)
{
_t.emodel.avatarController.BeginStun(EnumEntityStunType.Prone, EnumBodyPartHit.Torso, Utils.EnumHitDirection.Front, false, 0.5f);
_t.SetStun(EnumEntityStunType.Prone);
_t.bodyDamage.StunDuration = LeapStun;
}
Debug.Log("[NecromancerTome] WolfControl: " + _t.EntityName + " сбит с ног");
}
/// <summary>Враг ближе всех к лучу прицела: не дальше LeapRange от волка и в конусе LeapConeDeg.</summary>
static EntityAlive Target(World _world, EntityPlayerLocal _player, EntityAlive _wolf)
{
Transform cam = _player.cameraTransform;
if (cam == null)
{
return null;
}
Vector3 eye = cam.position + Origin.position;
EntityAlive best = null;
float bestAngle = LeapConeDeg;
for (int i = 0; i < _world.EntityAlives.Count; i++)
{
EntityAlive e = _world.EntityAlives[i];
if (!SnakeControl.IsFoe(e, _wolf))
{
continue;
}
float d = (e.position - _wolf.position).magnitude;
if (d > LeapRange || d < LeapMin)
{
continue;
}
float angle = Vector3.Angle(cam.forward, e.getChestPosition() - eye);
if (angle < bestAngle)
{
bestAngle = angle;
best = e;
}
}
return best;
}
static void BloodTick(World _world, EntityAlive _wolf)
{
_bloodCheck -= Time.deltaTime;
if (_bloodCheck > 0f)
{
return;
}
_bloodCheck = 1f;
for (int i = 0; i < _world.EntityAlives.Count; i++)
{
EntityAlive e = _world.EntityAlives[i];
if (e == null)
{
continue;
}
float d2 = (e.position - _wolf.position).sqrMagnitude;
bool bleeding = SnakeControl.IsFoe(e, _wolf) && e.Health < e.GetMaxHealth() * BloodHealth;
if (bleeding && d2 < BloodRange * BloodRange)
{
if (_blood.Add(e.entityId))
{
e.AddNavObject(SnakeControl.SenseNavObject, null, null);
}
}
else if (_blood.Contains(e.entityId) && (!bleeding || d2 > BloodDropRange * BloodDropRange))
{
e.RemoveNavObject(SnakeControl.SenseNavObject);
_blood.Remove(e.entityId);
}
}
_blood.RemoveWhere(id => _world.GetEntity(id) == null);
}
/// <summary>Выход из волка - снять метки, забыть бросок.</summary>
public static void Clear()
{
World world = GameManager.Instance?.World;
if (world != null)
{
foreach (int id in _blood)
{
(world.GetEntity(id) as EntityAlive)?.RemoveNavObject(SnakeControl.SenseNavObject);
}
}
_blood.Clear();
_bloodCheck = 0f;
_leapTarget = null;
}
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.