Одержимость питомцев: роли медведя, волка, собаки и крысы; ноша стервятника; череп при обыске
- Могильная крыса: своя модель без волчьей шерсти; стоит на земле (центр капсулы); E - осмотр контейнеров без права взять (окно игры, рюкзак скрыт, отказ «инвентарь заполнен», контейнер не разрушается); ПКМ - проход сквозь постройки, полупрозрачная. - Стервятник: курица в когтях - слепок с обвисшими крыльями и шеей, без хитбокса. - Зомбособака: нюх - все существа рядом на компасе и карте. - Медведь: толстая шкура (вдвое меньше урона), рёв-провокация, удар лапой по блокам. - Лютоволк: бросок на цель с опрокидыванием, след крови раненых, сумка на 3 ячейки (лут, выгрузка у тела, выпадает мешком при гибели и отзыве; отдельный файл сейва). - Череп (останки): 2% при обычном обыске красного трупа вместо браслета. - Прицел книги не рисуется в одержимости; подсказки вселения на 13 языках. - Замер EntityLeakProbe: удалённые, но живые в памяти сущности - в лог. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
beab1640b0
commit
31aa99860c
+21
-7
File diff suppressed because one or more lines are too long
@@ -57,6 +57,10 @@
|
||||
<!-- User request 2026-08-28: bite applies buffInjurySlow, same debuff the Insect Swarm
|
||||
originally had before its own rework - see necroMeleeHandZombieDog in items.xml. -->
|
||||
<property name="HandItem" value="necroMeleeHandZombieDog"/>
|
||||
<!-- Зелёная метка питомца на карте и компасе (указание 08.10: «добавь значки всем питомцам») -
|
||||
тот же класс, что у Духа крысы (Config/nav_objects.xml). Один класс вместо ванильного списка
|
||||
(twitch_spawn, animaltracking_*...): из нескольких классов игра показывает какой сама решит. -->
|
||||
<property name="NavObject" value="necroRatSpiritPet"/>
|
||||
|
||||
<!-- ИИ ПЕРЕПИСАН 2026-09-18 ПОД ОБРАЗЕЦ ДУХА КРЫСЫ, прямое указание: "поведение
|
||||
других призванных животных зомби тоже подгони под крысу. По сути это те же
|
||||
@@ -292,6 +296,10 @@
|
||||
<property name="IsEnemyEntity" value="false"/>
|
||||
<property name="Faction" value="none"/>
|
||||
<property name="HandItem" value="necroMeleeHandZombieGriffin"/>
|
||||
<!-- Зелёная метка питомца на карте и компасе (указание 08.10: «добавь значки всем питомцам») -
|
||||
тот же класс, что у Духа крысы (Config/nav_objects.xml). Один класс вместо ванильного списка
|
||||
(twitch_spawn, animaltracking_*...): из нескольких классов игра показывает какой сама решит. -->
|
||||
<property name="NavObject" value="necroRatSpiritPet"/>
|
||||
<effect_group name="Base Effects">
|
||||
<!-- У ванильного зомбостервятника 30 HP - для питомца, которого не отозвать до
|
||||
следующего призыва, это ничто. 750 оставлено с прежней, наземной версии. -->
|
||||
@@ -356,7 +364,7 @@
|
||||
<property name="SoundAttack" value="rabbitpain"/>
|
||||
<property name="SoundStepType" value="animallightstep"/>
|
||||
|
||||
<!-- Жёлтая метка на карте и компасе - указание 2026-09-18. Класс метки наш,
|
||||
<!-- Метка на карте и компасе - указание 2026-09-18 (жёлтая, с 08.10 зелёная, как у всех питомцев). Класс метки наш,
|
||||
Config/nav_objects.xml; ванильные animaltracking_* не подошли, они завязаны
|
||||
на перк следопыта, а не на прямое назначение. -->
|
||||
<property name="NavObject" value="necroRatSpiritPet"/>
|
||||
|
||||
+26
-8
@@ -14,7 +14,8 @@
|
||||
животных (перк следопыта), а не через прямое назначение. -->
|
||||
|
||||
<append xpath="/nav_object_classes">
|
||||
<!-- Сам питомец - ЖЁЛТЫМ, по прямому указанию.
|
||||
<!-- Сам питомец - ЗЕЛЁНЫМ (указание 08.10: «пусть значки питомцев на карте будут зелёными»;
|
||||
до 08.10 был жёлтым 255,220,0 - тоже по указанию).
|
||||
|
||||
СПРАЙТ: "воскрешение" (пробуем, указание 2026-09-18 - "для крысы попробуй иконку
|
||||
воскрешения, проверим как смотрится"). Кроличий, стоявший тут сначала, не подошёл по
|
||||
@@ -22,22 +23,24 @@
|
||||
имён вида ui_game_symbol_* из Data/Config и из самой сборки; ближайшее по названию
|
||||
было specters_grace (бафф "Призрачная грация", перк Ловкости), но у воскрешения
|
||||
смысл к некромантии ближе. Если в игре окажется невнятно, запасные - specters_grace
|
||||
и skull (череп, он же значок самой Некромантии), меняется одна строка. -->
|
||||
и skull (череп, он же значок самой Некромантии), меняется одна строка.
|
||||
С 08.10 - значок дебаффа «Почти смерть» (ui_game_symbol_near_death_trauma, вешается игроку
|
||||
после гибели): «больше похож на смерть с косой» (указание 08.10), как у одержимого. -->
|
||||
<nav_object_class name="necroRatSpiritPet">
|
||||
<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="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"/>
|
||||
</map_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="max_distance" value="1024"/>
|
||||
<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="icon_clamped" value="false"/>
|
||||
</compass_settings>
|
||||
@@ -85,10 +88,25 @@
|
||||
</nav_object_class>
|
||||
</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, просьба пользователя): фиолетовая змея - иконка следа змеи
|
||||
(ui_game_symbol_tracking_snake, есть в игре). Только карта: на компасе метка своей же
|
||||
змеи бессмысленна. С 06.10 этот же класс - метка одержимого (зомби или питомца), значок
|
||||
подставляется кодом. Вешает NecroMapMarker.cs отдельной меткой на Transform модели, как игра
|
||||
змеи бессмысленна. (С 06.10 по 08.10 этот класс носил и одержимый - теперь у него necroPossessed.) Вешает NecroMapMarker.cs отдельной меткой на Transform модели, как игра
|
||||
вешает метку транспорта (через Entity.AddNavObject метка змеи так и не показалась). -->
|
||||
<append xpath="/nav_object_classes">
|
||||
<nav_object_class name="necroSnakeSpirit">
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -646,6 +646,7 @@ namespace NecromancerTome
|
||||
if (!__instance.bag.Touched)
|
||||
{
|
||||
GameManager.Instance.lootManager.LootBagOpened(__instance.bag, __instance, _lockingPlayerID);
|
||||
NecroSkull.Roll(__instance, __instance.bag); // 2% - череп (останки), с 09.10 вместо браслета
|
||||
}
|
||||
__instance.timeStayAfterDeath = (int)(CorpseLoot.OpenedCorpseSecondsAfterDeath * 20f);
|
||||
Debug.Log("[NecromancerTome] CorpseLoot: труп " + __instance.entityId + " открыт");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
// Замер не должен ломать удаление сущности ни при каких условиях.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -310,6 +310,9 @@ namespace NecromancerTome
|
||||
// обесцвечивание к тому же брало не всё - прозрачными выходили только туловище и
|
||||
// голова, что и выглядело жутко.
|
||||
"necroZombieGriffin",
|
||||
// Могильная крыса (08.10.2026): «серой, не полупрозрачной» - у неё теперь своя модель
|
||||
// из угольной глины (RatModel.cs), призрачный вид её бы испортил.
|
||||
"necroRatSpirit",
|
||||
};
|
||||
|
||||
public static bool ShouldBeGhost(EntityAlive _entity)
|
||||
|
||||
@@ -22,11 +22,16 @@ namespace NecromancerTome
|
||||
/// (removedNavObjectPool) и раздаёт заново. Если модель исчезла раньше нашего Hide, игра сама сняла
|
||||
/// метку, и пул мог уже отдать этот же объект другому (метка чутья, транспорт). Поэтому снимаем, только
|
||||
/// если объект ещё в 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>
|
||||
public static class NecroMapMarker
|
||||
{
|
||||
public const string NavClass = "necroSnakeSpirit";
|
||||
public const string SnakeClass = "necroSnakeSpirit";
|
||||
public const string PossessedClass = "necroPossessed";
|
||||
|
||||
/// <summary>Метка и модель, за которой она следит.</summary>
|
||||
public class Marker
|
||||
@@ -35,13 +40,13 @@ namespace NecromancerTome
|
||||
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)
|
||||
{
|
||||
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);
|
||||
return new Marker { Nav = nav, Tracked = _e.transform };
|
||||
}
|
||||
@@ -56,9 +61,5 @@ namespace NecromancerTome
|
||||
}
|
||||
_m = null;
|
||||
}
|
||||
|
||||
/// <summary>Значок одержимого (зомби или питомца) - значок дебаффа «Почти смерть» (buffNearDeathTrauma,
|
||||
/// вешается игроку после гибели), указание 06.10.</summary>
|
||||
public const string PossessedSprite = "ui_game_symbol_near_death_trauma";
|
||||
}
|
||||
}
|
||||
|
||||
+20
-131
@@ -3,155 +3,44 @@ using UnityEngine;
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Череп из красного трупа (BACKLOG «Некротехнолог v2», указание 06.10): тот же жест, что снятие
|
||||
/// блока браслетом, - зажатая обычная атака с браслетом в руке, круговой таймер. Только целью
|
||||
/// здесь не блок, а труп зомби, погибшего под дебаффом жертвы (CorpseLoot.IsSearchable). Труп -
|
||||
/// сущность, а не блок, поэтому SpatialVaultPickup.Begin сперва спрашивает этот класс.
|
||||
/// Череп (останки) из красного трупа - сырьё для цинерария (дух-работник).
|
||||
///
|
||||
/// Шанс 2% («так ресурс станет ценнее»): удача - череп в пространственное хранилище, труп остаётся
|
||||
/// (его можно обыскать), второй попытки на нём нет (cvar без точки - пишется в сейв вместе с трупом);
|
||||
/// неудача - труп уходит в чёрный разрыв (NecroBurst), вместе с недобранной добычей.
|
||||
/// С 09.10 - находка при обычном обыске (указание: «браслет больше не собирает красные трупы,
|
||||
/// выпиливаем; останки дропаются с шансом 2% при обычном лутании красного трупа»). Раньше (06.10) череп
|
||||
/// вырывали пространственным браслетом: зажатая атака по трупу, три секунды канала, при неудаче труп
|
||||
/// уходил в чёрный разрыв. Сняли из-за подозрения на утечку памяти на этом пути (тест 09.10: после
|
||||
/// разрывов в памяти копились экземпляры сущностей - 31 при 7 живых).
|
||||
///
|
||||
/// Цена та же, что у блоков: без сферы или камня в слоте браслет не работает, сфера платит
|
||||
/// прочностью за секунды канала. Разрыв делает сервер - в мультиплеере клиенту пока только удача
|
||||
/// (труп не исчезает); решать вместе с прочим мультиплеером урн.
|
||||
/// Бросок - один раз на труп, при первом открытии его сумки, на сервере, сразу после того, как игра
|
||||
/// наполнила её добычей (Patch_EntityAlive_OnLockRequestServer_Corpse). Шанс прежний - 2%
|
||||
/// («так ресурс станет ценнее»).
|
||||
/// </summary>
|
||||
public static class NecroSkull
|
||||
{
|
||||
public const string ItemName = "necroSkull";
|
||||
/// <summary>На трупе: попытка уже была удачной.</summary>
|
||||
public const string TakenCVar = "necroSkullTaken";
|
||||
public const float Chance = 0.02f;
|
||||
/// <summary>Короче десяти секунд блока: шанс мал, попытка должна быть дешёвой.</summary>
|
||||
public const float ChannelSeconds = 3f;
|
||||
/// <summary>Дальше этого тянуть череп нельзя - как дистанция обыска трупа у игры.</summary>
|
||||
public static float MaxDistance => Constants.cCollectItemDistance;
|
||||
|
||||
public const string MsgChanneling = "necroSkullChanneling";
|
||||
public const string MsgTaken = "necroSkullAlreadyTaken";
|
||||
public const string MsgExtracted = "necroSkullExtracted";
|
||||
public const string MsgFailed = "necroSkullFailed";
|
||||
|
||||
public class SkullJob : SpatialVaultPickup.PickupJob
|
||||
/// <summary>Бросок на череп в только что наполненную сумку трупа. Места нет - сумка на ячейку больше.</summary>
|
||||
public static void Roll(EntityAlive _corpse, Bag _bag)
|
||||
{
|
||||
public int CorpseId;
|
||||
}
|
||||
|
||||
/// <summary>Красный труп под прицелом (так же, как игра ищет сущность для E -
|
||||
/// PlayerMoveController.TryHandleEntity), иначе null.</summary>
|
||||
public static EntityAlive CorpseAtCrosshair(EntityPlayerLocal _player)
|
||||
{
|
||||
WorldRayHitInfo hit = _player.HitInfo;
|
||||
if (hit == null || !hit.bHitValid || hit.tag == null || !hit.tag.StartsWith("E_") ||
|
||||
hit.hit.distanceSq > MaxDistance * MaxDistance)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
Transform root = GameUtils.GetHitRootTransform(hit.tag, hit.transform);
|
||||
EntityAlive corpse = root != null ? root.GetComponent<Entity>() as EntityAlive : null;
|
||||
return corpse != null && corpse.IsDead() && CorpseLoot.IsSearchable(corpse) ? corpse : null;
|
||||
}
|
||||
|
||||
/// <summary>Обычная атака браслетом: true - под прицелом красный труп, нажатие наше.</summary>
|
||||
public static bool TryBegin(EntityPlayerLocal _player, ItemValue _bracelet)
|
||||
{
|
||||
EntityAlive corpse = CorpseAtCrosshair(_player);
|
||||
if (corpse == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (corpse.Buffs.GetCustomVar(TakenCVar) > 0f)
|
||||
{
|
||||
SpatialVaultPickup.Deny(_player, MsgTaken);
|
||||
return true;
|
||||
}
|
||||
Bag bag = SpatialVaultPickup.GetVault(_player);
|
||||
ItemValue skull = ItemClass.GetItem(ItemName);
|
||||
if (bag == null || skull == null || skull.IsEmpty())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (!bag.CanTakeItem(new ItemStack(skull, 1)))
|
||||
{
|
||||
SpatialVaultPickup.Deny(_player, SpatialVaultPickup.MsgVaultFull);
|
||||
return true;
|
||||
}
|
||||
|
||||
TimerEventData timerData = new TimerEventData
|
||||
{
|
||||
Data = new SkullJob
|
||||
{
|
||||
Player = _player,
|
||||
Bracelet = _bracelet,
|
||||
ChannelSeconds = ChannelSeconds,
|
||||
CorpseId = corpse.entityId
|
||||
},
|
||||
CloseOnHit = true,
|
||||
CancelWithActivateButton = true
|
||||
};
|
||||
timerData.FullTimeFinishEvent += OnChannelComplete;
|
||||
timerData.CloseEvent += delegate
|
||||
{
|
||||
ChannelVision.End(_player);
|
||||
};
|
||||
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(_player);
|
||||
XUiC_Timer.OpenTimer(playerUI.xui, ChannelSeconds, timerData, -1f, Localization.Get(MsgChanneling));
|
||||
ChannelVision.Begin(_player);
|
||||
Debug.Log("[NecromancerTome] NecroSkull: " + _player.entityId + " тянет череп из трупа " + corpse.entityId);
|
||||
return true;
|
||||
}
|
||||
|
||||
static void OnChannelComplete(TimerEventData _timerData)
|
||||
{
|
||||
if (!(_timerData.Data is SkullJob job) || job.Player == null)
|
||||
if (_bag == null || skull == null || skull.IsEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
ChannelVision.End(job.Player);
|
||||
World world = GameManager.Instance != null ? GameManager.Instance.World : null;
|
||||
EntityAlive corpse = world?.GetEntity(job.CorpseId) as EntityAlive;
|
||||
// Труп обыскали до дна и он исчез, его разорвали или уже вытянули череп - пока шёл круг.
|
||||
if (corpse == null || !corpse.IsDead() || !CorpseLoot.IsSearchable(corpse))
|
||||
{
|
||||
SpatialVaultPickup.Deny(job.Player, SpatialVaultPickup.MsgBlockMissing);
|
||||
return;
|
||||
}
|
||||
if (corpse.Buffs.GetCustomVar(TakenCVar) > 0f)
|
||||
{
|
||||
SpatialVaultPickup.Deny(job.Player, MsgTaken);
|
||||
return;
|
||||
}
|
||||
Bag bag = SpatialVaultPickup.GetVault(job.Player);
|
||||
ItemValue skull = ItemClass.GetItem(ItemName);
|
||||
if (bag == null || skull == null || skull.IsEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
float roll = Random.value;
|
||||
bool success = roll < Chance;
|
||||
Debug.Log("[NecromancerTome] NecroSkull: труп " + corpse.entityId + " - бросок " +
|
||||
roll.ToString("0.000") + " против " + Chance.ToString("0.00") + " -> " + (success ? "ЧЕРЕП" : "разрыв"));
|
||||
if (success)
|
||||
Debug.Log("[NecromancerTome] NecroSkull: труп " + _corpse.entityId + " - бросок " + roll.ToString("0.000") +
|
||||
" против " + Chance.ToString("0.00") + (success ? " -> ЧЕРЕП" : ""));
|
||||
if (!success)
|
||||
{
|
||||
if (!bag.AddItem(new ItemStack(skull, 1)))
|
||||
{
|
||||
SpatialVaultPickup.Deny(job.Player, SpatialVaultPickup.MsgVaultFull);
|
||||
return;
|
||||
}
|
||||
corpse.Buffs.SetCustomVar(TakenCVar, 1f);
|
||||
GameManager.ShowTooltip(job.Player, MsgExtracted);
|
||||
return;
|
||||
}
|
||||
else
|
||||
ItemStack stack = new ItemStack(skull, 1);
|
||||
if (!_bag.AddItem(stack.Clone()))
|
||||
{
|
||||
GameManager.ShowTooltip(job.Player, MsgFailed);
|
||||
NecroBurst.Burst(corpse, job.Player);
|
||||
}
|
||||
// Попытка оплачена в обоих исходах: канал отработан.
|
||||
SpatialVaultPickup.SpendCharge(job);
|
||||
if (success)
|
||||
{
|
||||
GameManager.Instance.SaveLocalPlayerData();
|
||||
_bag.Resize(new Vector2i(_bag.SlotCount + 1, 1));
|
||||
_bag.AddItem(stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +127,10 @@ namespace NecromancerTome
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
+317
-30
@@ -86,6 +86,29 @@ namespace NecromancerTome
|
||||
/// <summary>Звук попадания рывка - ванильный удар рукой зомби по телу (sounds.xml).</summary>
|
||||
public const string FlyDashHitSound = "organichitorganic";
|
||||
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>
|
||||
public static EntityAlive FlyAim;
|
||||
static float _flyDashUntil;
|
||||
@@ -190,6 +213,12 @@ namespace NecromancerTome
|
||||
static GameObject _hud;
|
||||
/// <summary>Метка одержимого на карте (указание 06.10, как у змеи) - NecroMapMarker.</summary>
|
||||
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()
|
||||
{
|
||||
@@ -204,6 +233,11 @@ namespace NecromancerTome
|
||||
|
||||
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>
|
||||
public static bool CanPossess(EntityAlive _e) => _e is EntityZombie && !_e.IsDead() && HandHolds.IsZombie(_e);
|
||||
|
||||
@@ -273,7 +307,8 @@ namespace NecromancerTome
|
||||
ReadRanged(_zombie);
|
||||
Hint(_player0, _zombie);
|
||||
NecroMapMarker.Hide(ref _mapNav); // старая ссылка после закрытия мира - снимется, только если ещё наша
|
||||
_mapNav = NecroMapMarker.Show(_zombie, NecroMapMarker.PossessedSprite);
|
||||
_mapNav = NecroMapMarker.Show(_zombie, NecroMapMarker.PossessedClass);
|
||||
_mapChunkSet = false;
|
||||
if (Flying)
|
||||
{
|
||||
_zombie.motion = Vector3.zero;
|
||||
@@ -289,6 +324,10 @@ namespace NecromancerTome
|
||||
return;
|
||||
}
|
||||
Active = false;
|
||||
RatLoot.CloseIfViewing();
|
||||
RatPhase.Stop(GameManager.Instance?.World, Zombie, "выход из одержимости");
|
||||
DogSense.Clear();
|
||||
WolfControl.Clear();
|
||||
NecroMapMarker.Hide(ref _mapNav);
|
||||
bool pet = IsPet;
|
||||
IsPet = false;
|
||||
@@ -302,7 +341,10 @@ namespace NecromancerTome
|
||||
_flyDash = null;
|
||||
FlyAim = null;
|
||||
AimTint(false);
|
||||
DropCarry("выход из стервятника");
|
||||
ZoomReset();
|
||||
StopEat(z, null);
|
||||
VultureNeck.Reset();
|
||||
if (_flySitting)
|
||||
{
|
||||
_flySitting = false;
|
||||
@@ -395,6 +437,15 @@ namespace NecromancerTome
|
||||
static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data)
|
||||
{
|
||||
World world = GameManager.Instance?.World;
|
||||
// Замер утечки сущностей (09.10) - не связан с одержимостью, просто ежекадровый крючок мода.
|
||||
if (world == null)
|
||||
{
|
||||
EntityLeakProbe.Reset();
|
||||
}
|
||||
else
|
||||
{
|
||||
EntityLeakProbe.Tick();
|
||||
}
|
||||
if (world == null)
|
||||
{
|
||||
if (Active || Zombie != null || _frozen.Count > 0)
|
||||
@@ -412,6 +463,16 @@ namespace NecromancerTome
|
||||
_frozenSpeed.Clear();
|
||||
_flyDash = 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;
|
||||
_eatCorpse = null;
|
||||
}
|
||||
@@ -434,6 +495,18 @@ namespace NecromancerTome
|
||||
Exit("одержимый погиб");
|
||||
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;
|
||||
bool gui = _player.PlayerUI != null && _player.PlayerUI.windowManager.IsModalWindowOpen();
|
||||
if (input != null && !gui && (input.Reload.WasPressed || input.PermanentActions.Reload.WasPressed))
|
||||
@@ -461,20 +534,8 @@ namespace NecromancerTome
|
||||
_crouchToggled = !_crouchToggled;
|
||||
}
|
||||
Crouching = input != null && !gui && (input.Crouch.IsPressed || _crouchToggled);
|
||||
if (detonate && IsPet)
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
// E у питомца больше не отзыв (указание 08.10: «R - всегда вернуться в тело у всех одержимых,
|
||||
// E - для других целей»). Отозвать питомца - E с книгой в руке, уже из тела.
|
||||
if (detonate && z is EntityZombieCop cop && !cop.isPrimed)
|
||||
{
|
||||
Debug.Log("[NecromancerTome] Possession: подрыв " + z.EntityName);
|
||||
@@ -490,7 +551,15 @@ namespace NecromancerTome
|
||||
_flyUp = input != null && !gui && input.Jump.IsPressed;
|
||||
_flyDown = input != null && !gui && (input.Crouch.IsPressed || _crouchToggled);
|
||||
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);
|
||||
if (attack && Time.time >= _nextAttack && FlyAim is EntityAlive dashTo)
|
||||
{
|
||||
@@ -519,16 +588,20 @@ namespace NecromancerTome
|
||||
_nextAttack = Time.time + FlyAttackDelay;
|
||||
_meleeLookUntil = Time.time + FlyAttackDelay;
|
||||
}
|
||||
// ПКМ - клич-приманка (указание 06.10): крик крикуньи, только голосом паникующей курицы - зомби
|
||||
// в ScreamRange идут на него; с воздуха уводит толпу от базы или от тела.
|
||||
if (ranged)
|
||||
// F - клич-приманка (указание 06.10; кнопка - 08.10, была ПКМ): крик крикуньи, только голосом
|
||||
// паникующей курицы - зомби в ScreamRange идут на него; с воздуха уводит толпу от базы или от тела.
|
||||
// F у игрока - фонарик, но его читает только выключенный PlayerMoveController.Update.
|
||||
bool scream = input != null && !gui && (input.PermanentActions.ToggleFlashlight.WasPressed || input.ToggleFlashlight.WasPressed);
|
||||
if (scream)
|
||||
{
|
||||
Scream(world, z);
|
||||
}
|
||||
// F - питаться падалью (указание 06.10: «сесть на труп - по F стервятник питается»). F у игрока -
|
||||
// фонарик, но его читает только PlayerMoveController.Update, а он на время одержимости выключен.
|
||||
bool eat = input != null && !gui && (input.PermanentActions.ToggleFlashlight.WasPressed || input.ToggleFlashlight.WasPressed);
|
||||
Eat(world, z, eat, attack || ranged);
|
||||
// ПКМ (держать) - «зоркий глаз» (указание 08.10: «приближение как у прицела»), в PlaceCamera.
|
||||
_flyZoomHeld = rangedHeld;
|
||||
// E - питаться падалью (указание 06.10, кнопка - 08.10: «пусть ест по кнопке E» - у игрока E это
|
||||
// взаимодействие, лут; было F). detonate - это и есть нажатие E.
|
||||
bool eat = detonate;
|
||||
Eat(world, z, eat, attack || scream);
|
||||
if (_meleeLookUntil > 0f && Time.time >= _meleeLookUntil)
|
||||
{
|
||||
_meleeLookUntil = 0f;
|
||||
@@ -559,6 +632,32 @@ namespace NecromancerTome
|
||||
z.Jumping = true;
|
||||
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))
|
||||
{
|
||||
Scream(world, z);
|
||||
@@ -569,6 +668,10 @@ namespace NecromancerTome
|
||||
if (z.Attack(false))
|
||||
{
|
||||
z.Attack(true);
|
||||
if (IsPet && BearControl.Is(z))
|
||||
{
|
||||
BearControl.Smash(world, _player, z); // удар лапой и по блоку (решено 09.10)
|
||||
}
|
||||
float timeout = Mathf.Max(0.3f, z.GetAttackTimeoutTicks() / 20f);
|
||||
_nextAttack = Time.time + timeout;
|
||||
_meleeLookUntil = Time.time + Mathf.Max(0.6f, timeout);
|
||||
@@ -975,6 +1078,22 @@ namespace NecromancerTome
|
||||
_v.motion = Vector3.zero;
|
||||
_v.SetLookPosition(head);
|
||||
_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();
|
||||
if (!string.IsNullOrEmpty(snd))
|
||||
{
|
||||
@@ -1001,6 +1120,45 @@ namespace NecromancerTome
|
||||
(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 от него и
|
||||
/// не дальше TargetRadius змеи от луча. Свои (питомцы, игроки, зачарованные) - не цель.</summary>
|
||||
static EntityAlive FlyDashTarget(World _world, EntityAlive _v)
|
||||
@@ -1116,10 +1274,6 @@ namespace NecromancerTome
|
||||
static void Hint(EntityPlayerLocal _p, EntityAlive _z)
|
||||
{
|
||||
string text = Localization.Get("necroPossessHintBase");
|
||||
if (IsPet)
|
||||
{
|
||||
text += ", " + Localization.Get("necroPetRecallHint");
|
||||
}
|
||||
if (Flying)
|
||||
{
|
||||
text += ", " + Localization.Get("necroPossessHintFly");
|
||||
@@ -1128,14 +1282,36 @@ namespace NecromancerTome
|
||||
{
|
||||
text += ", " + Localization.Get("necroPossessHintRanged");
|
||||
}
|
||||
if (IsScreamer(_z) || Flying)
|
||||
if (IsScreamer(_z))
|
||||
{
|
||||
text += ", " + Localization.Get("necroPossessHintScream");
|
||||
}
|
||||
if (Flying)
|
||||
{
|
||||
text += ", " + Localization.Get("necroPossessHintZoom") + ", " + Localization.Get("necroPossessHintScreamF");
|
||||
}
|
||||
if (Flying)
|
||||
{
|
||||
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)
|
||||
{
|
||||
text += ", " + Localization.Get("necroPossessHintLeap");
|
||||
@@ -1144,6 +1320,11 @@ namespace NecromancerTome
|
||||
{
|
||||
text += ", " + Localization.Get("necroPossessHintDetonate");
|
||||
}
|
||||
if (!IsPet)
|
||||
{
|
||||
// Решение 03.10, подтверждено 09.10: после выхода бывший одержимый в леденящем ужасе бежит от тела.
|
||||
text += ", " + Localization.Get("necroPossessHintAfterFear");
|
||||
}
|
||||
GameManager.ShowTooltip(_p, text);
|
||||
}
|
||||
|
||||
@@ -1355,6 +1536,10 @@ namespace NecromancerTome
|
||||
PlayerMoveController pmc = PlayerMoveController.Instance;
|
||||
Vector2 sens = pmc != null ? pmc.mouseLookSensitivity : Vector2.one * 5f;
|
||||
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;
|
||||
_pitch = Mathf.Clamp(_pitch + Input.GetAxis("Mouse Y") * sens.y * invert, -70f, 70f);
|
||||
}
|
||||
@@ -1404,7 +1589,7 @@ namespace NecromancerTome
|
||||
|
||||
/// <summary>Разрешить зомби подход к цели класса одержимого: EntityZombie, у питомцев - их класс
|
||||
/// (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();
|
||||
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>
|
||||
public static void PlaceCamera(Transform _camera)
|
||||
{
|
||||
@@ -1434,6 +1667,12 @@ namespace NecromancerTome
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (Flying)
|
||||
{
|
||||
// Шея - до камеры: та берёт положение головы.
|
||||
VultureNeck.Apply(z, _flySitting, !_eating);
|
||||
}
|
||||
Zoom(Flying && _flyZoomHeld);
|
||||
Transform head = z.emodel != null ? z.emodel.GetHeadTransform() : null;
|
||||
Vector3 anchor = head != null ? head.position : z.position - Origin.position + Vector3.up * 1.6f;
|
||||
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);
|
||||
}
|
||||
if (_zoom > 0f)
|
||||
{
|
||||
// В «зорком глазе» камера уходит к голове (чуть впереди неё): из-за спины при x8 весь кадр
|
||||
// закрыла бы сама птица.
|
||||
eye = Vector3.Lerp(eye, anchor + rot * Vector3.forward * ZoomEyeForward, Mathf.SmoothStep(0f, 1f, _zoom));
|
||||
}
|
||||
_camera.SetPositionAndRotation(eye, rot);
|
||||
if (!_loggedCamera)
|
||||
{
|
||||
@@ -1458,7 +1703,7 @@ namespace NecromancerTome
|
||||
{
|
||||
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>
|
||||
[HarmonyPatch(typeof(PlayerMoveController), nameof(PlayerMoveController.Update))]
|
||||
public static class Patch_PlayerMoveController_Update_Possession
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,7 +182,7 @@ namespace NecromancerTome
|
||||
// Фиолетовая змея на карте (nav_objects.xml, necroSnakeSpirit), указание 02.10. С 06.10 - своя
|
||||
// метка по Transform (NecroMapMarker): через AddNavObject она так и не показалась.
|
||||
NecroMapMarker.Hide(ref _mapNav); // старая ссылка после закрытия мира - снимется, только если ещё наша
|
||||
_mapNav = NecroMapMarker.Show(_snake, "ui_game_symbol_tracking_snake");
|
||||
_mapNav = NecroMapMarker.Show(_snake, NecroMapMarker.SnakeClass);
|
||||
Target = null;
|
||||
_radiationCheck = 0f;
|
||||
BodyDanger = false;
|
||||
@@ -884,7 +884,7 @@ namespace NecromancerTome
|
||||
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))
|
||||
{
|
||||
@@ -905,7 +905,7 @@ namespace NecromancerTome
|
||||
/// точка под ней, то есть внутри. Лучи видят только лицевые стороны, так что пары хватает
|
||||
/// и в пещере, и в доме. _ground - это грунт (блок-земля под той поверхностью или в самой
|
||||
/// точке), а не постройка.</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;
|
||||
Vector3 o = _p - Origin.position;
|
||||
@@ -934,7 +934,7 @@ namespace NecromancerTome
|
||||
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))
|
||||
{
|
||||
|
||||
@@ -238,12 +238,6 @@ namespace NecromancerTome
|
||||
return;
|
||||
}
|
||||
|
||||
// Красный труп под прицелом - не блок: череп для урны (NecroSkull.cs, 06.10).
|
||||
if (NecroSkull.TryBegin(_player, _bracelet))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
WorldRayHitInfo hitInfo = _player.HitInfo;
|
||||
if (hitInfo == null || !hitInfo.bHitValid)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
if (Possession.Active && Possession.Zombie != null && Possession.Zombie.entityId == petEntityId)
|
||||
@@ -494,6 +480,7 @@ namespace NecromancerTome
|
||||
World world = owner.world;
|
||||
if (world != null)
|
||||
{
|
||||
WolfBag.DropFrom(world.GetEntity(petEntityId) as EntityAlive, "отозван"); // сумка волка - мешком на месте
|
||||
world.RemoveEntity(petEntityId, EnumRemoveEntityReason.Killed);
|
||||
}
|
||||
owner.RemoveOwnedEntity(petEntityId);
|
||||
|
||||
@@ -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 <0..1> - доля пути от позы сна к полётной (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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, "погиб");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
Reference in New Issue
Block a user