Чертоги разума, хваты пустой руки, одержимость, книга с оглавлением

- Чертоги разума: дух в префабе chertogi под землёй, книга памяти, нож и плакат;
  кровать префаба - мебель necroPalaceBed (иначе становилась точкой возрождения);
  охрана выбрасывает и игроков (их нет в EntityAlives).
- Хваты пустой руки по R: «отрицание» и «пиф-паф» - поза рук костями поверх аниматора,
  остальные кости заморожены, ЛКМ - заклинание, прицел краснеет на зомби.
- «Отрицание»: зомби в ужасе бежит дальше 40 м и теряет игрока как цель.
- «Пиф-паф»: одержимость зомби-гуманоидом - бег, прыжок, удар, дальняя атака,
  подрыв, крик крикуньи; тело цепенеет, зомби танкуют одержимого; область
  вокруг тела сжата; полоска здоровья; спавн и прорисовка у одержимого.
- Книга с оглавлением (окно necroTome), глава «Некроэнергия».
- Прорисовка дверей у змеи и одержимого (CheckLODs от камеры, а не от тела).
- Почтовый ящик консоли и команды necrobones/necropose/necroshot для разработки.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AlexCube
2026-10-04 00:10:05 +03:00
co-authored by Claude Opus 5.5
parent c828202c66
commit 66a0dcf403
43 changed files with 3798 additions and 22 deletions
+200
View File
@@ -0,0 +1,200 @@
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// «Книга памяти» в чертогах разума (указание 03.10): единственный предмет чертогов, с которым
/// можно взаимодействовать. При наведении - «Нажмите E, чтобы прочитать», по E - окно «Память»
/// (потом доделаем, как окна концовки в FinalSlides.cs). Блок подключается в blocks.xml через
/// Class="NecromancerTome.NecroMemoryBook, NecromancerHarmony": игра (ReflectionHelpers.
/// GetTypeWithPrefix) вставляет приставку Block перед именем после последней точки.
/// Вид - одиночная книга игры (bookInvisibleKillerPrefab, как cntBookPile09 на столе префаба
/// chertogi), но без хранилища и лута.
/// </summary>
[Preserve]
public class BlockNecroMemoryBook : Block
{
public const string ReadCommand = "read";
static readonly BlockActivationCommand[] Commands =
{
new BlockActivationCommand(ReadCommand, "book", true)
};
public override bool HasBlockActivationCommands(WorldBase _world, BlockValue _blockValue, Vector3i _blockPos, EntityAlive _entityFocusing)
{
return true;
}
public override BlockActivationCommand[] GetBlockActivationCommands(WorldBase _world, BlockValue _blockValue, Vector3i _blockPos, EntityAlive _entityFocusing)
{
return Commands;
}
public override string GetActivationText(WorldBase _world, BlockValue _blockValue, Vector3i _blockPos, EntityAlive _entityFocusing)
{
return Localization.Get("necroMemoryBookPrompt");
}
public override bool OnBlockActivated(string _commandName, WorldBase _world, Vector3i _blockPos, BlockValue _blockValue, EntityPlayerLocal _player)
{
return _commandName == ReadCommand && OnBlockActivated(_world, _blockPos, _blockValue, _player);
}
public override bool OnBlockActivated(WorldBase _world, Vector3i _blockPos, BlockValue _blockValue, EntityPlayerLocal _player)
{
Open(_player);
return true;
}
/// <summary>Окно книги (оглавление, главы). Отдельным методом - его же позовёт дух в чертогах (своё E).</summary>
public static void Open(EntityPlayerLocal _player)
{
LocalPlayerUI ui = LocalPlayerUI.GetUIForPlayer(_player);
if (ui == null)
{
return;
}
// 03.10: вместо окна-сообщения - книга с оглавлением (NecroTome.cs).
NecroTomeBook.Open(_player);
}
// ---------------------------------------------------------------- автор на обложке
/// <summary>Шрифт надписи - тот же готический, что у названия в текстуре (UnifrakturMaguntia, OFL).</summary>
const string FontAsset = "#@modfolder(NecromancerTome):Resources/necrobooks?UnifrakturMaguntia-Book.ttf";
const string AuthorChild = "NecroAuthor";
// Место под автора - просвет под рисунком (make_rat_book_textures.py, Mind), в осях корня модели
// necroBookMindPrefab: из Blender (x, y, z) -> Unity (-x, z, -y); книга в модели повёрнута на -65°.
// Подбирается на ходу командой necroauthor (x y z, размер, ширина).
public static Vector3 AuthorPos = new Vector3(0.0944f, 0.0885f, -0.1534f);
public static Vector3 AuthorUp = new Vector3(-0.4226f, 0f, 0.9063f);
public static float AuthorHeight = 0.030f, AuthorMaxWidth = 0.21f;
public static Color AuthorColor = new Color(0.85f, 0.06f, 0.05f, 1f);
static Font _font;
public override void OnBlockEntityTransformAfterActivated(WorldBase _world, Vector3i _blockPos, BlockValue _blockValue, BlockEntityData _ebcd)
{
base.OnBlockEntityTransformAfterActivated(_world, _blockPos, _blockValue, _ebcd);
try
{
if (_ebcd?.transform != null)
{
PlaceAuthor(_ebcd.transform);
}
}
catch (System.Exception e)
{
Debug.LogWarning("[NecromancerTome] книга памяти: автор не встал - " + e.Message);
}
}
/// <summary>Ник игрока на обложке, как подпись на ящике: TextMesh поверх модели (модель общая,
/// а ник у каждого свой, в текстуру его не запечь). Чертоги у каждого свои - пишем ник своего игрока.</summary>
public static void PlaceAuthor(Transform _root)
{
EntityPlayerLocal player = GameManager.Instance?.World?.GetPrimaryPlayer();
string name = player != null ? player.PlayerDisplayName : null;
Transform old = _root.Find(AuthorChild);
if (string.IsNullOrEmpty(name))
{
if (old != null) Object.Destroy(old.gameObject);
return;
}
if (_font == null)
{
_font = DataLoader.LoadAsset<Font>(FontAsset);
}
GameObject go = old != null ? old.gameObject : new GameObject(AuthorChild);
go.layer = _root.gameObject.layer;
Transform t = go.transform;
t.SetParent(_root, false);
t.localPosition = AuthorPos;
// лицом вверх (обложка), низ строки - к нижнему краю обложки
t.localRotation = Quaternion.LookRotation(Vector3.down, AuthorUp.normalized);
t.localScale = Vector3.one;
TextMesh tm = go.GetComponent<TextMesh>() ?? go.AddComponent<TextMesh>();
MeshRenderer mr = go.GetComponent<MeshRenderer>();
if (_font != null)
{
tm.font = _font;
mr.sharedMaterial = _font.material;
if (mr.sharedMaterial != null && (mr.sharedMaterial.shader == null || !mr.sharedMaterial.shader.isSupported))
{
Shader sh = Shader.Find("GUI/Text Shader");
if (sh != null) mr.sharedMaterial.shader = sh;
}
}
tm.text = name;
tm.anchor = TextAnchor.MiddleCenter;
tm.alignment = TextAlignment.Center;
tm.fontSize = 96;
tm.color = AuthorColor;
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
mr.receiveShadows = false;
tm.characterSize = 1f;
// Размер подгоняется кадром позже: меш надписи TextMesh строит не сразу.
NecroAuthorFit fit = go.GetComponent<NecroAuthorFit>() ?? go.AddComponent<NecroAuthorFit>();
fit.enabled = true;
}
}
/// <summary>Подгонка надписи автора: высота строки AuthorHeight, длинный ник ужимается до AuthorMaxWidth.</summary>
public class NecroAuthorFit : MonoBehaviour
{
void LateUpdate()
{
MeshRenderer mr = GetComponent<MeshRenderer>();
Bounds b = mr != null ? mr.localBounds : default;
if (b.size.y < 0.0001f)
{
return; // меш ещё не построен
}
float k = BlockNecroMemoryBook.AuthorHeight / b.size.y;
if (b.size.x * k > BlockNecroMemoryBook.AuthorMaxWidth)
{
k = BlockNecroMemoryBook.AuthorMaxWidth / b.size.x;
}
transform.localScale = new Vector3(k, k, k);
enabled = false;
}
}
/// <summary>necroauthor [x y z] [h N] [w N] - подобрать место и размер ника на книге памяти на ходу.</summary>
[Preserve]
public class ConsoleCmdNecroAuthor : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necroauthor" };
public override string getDescription() => "NecromancerTome: ник на книге памяти - x y z | h N | w N";
public override void Execute(System.Collections.Generic.List<string> _params, CommandSenderInfo _senderInfo)
{
System.Globalization.CultureInfo ci = System.Globalization.CultureInfo.InvariantCulture;
if (_params.Count == 3)
{
BlockNecroMemoryBook.AuthorPos = new Vector3(float.Parse(_params[0], ci), float.Parse(_params[1], ci), float.Parse(_params[2], ci));
}
else if (_params.Count == 2 && _params[0] == "h")
{
BlockNecroMemoryBook.AuthorHeight = float.Parse(_params[1], ci);
}
else if (_params.Count == 2 && _params[0] == "w")
{
BlockNecroMemoryBook.AuthorMaxWidth = float.Parse(_params[1], ci);
}
int n = 0;
foreach (NecroAuthorFit f in Object.FindObjectsOfType<NecroAuthorFit>(true))
{
BlockNecroMemoryBook.PlaceAuthor(f.transform.parent);
n++;
}
Vector3 p = BlockNecroMemoryBook.AuthorPos;
SdtdConsole.Instance.Output(string.Format(ci, "автор: {0:0.####} {1:0.####} {2:0.####}, h {3}, w {4}; надписей {5}",
p.x, p.y, p.z, BlockNecroMemoryBook.AuthorHeight, BlockNecroMemoryBook.AuthorMaxWidth, n));
}
}
}
+5 -2
View File
@@ -21,6 +21,9 @@ namespace NecromancerTome
/// property, quest tracking, anything else keyed off EntityFlags) is ever affected outside
/// this one call. A Stack (not a single field) survives re-entrant DamageEntity calls
/// correctly (e.g. explosions/knockback triggering further damage inside the same callstack).
///
/// ОДЕРЖИМОСТЬ (03.10, тест: «здоровье противника не снижалось»): то же самое, если атакующий -
/// одержимый зомби или цель - одержимый зомби (зомби бьют его, когда он их танкует).
/// </summary>
[HarmonyPatch(typeof(EntityAlive), "DamageEntity")]
public static class Patch_EntityAlive_DamageEntity_CharmedZombieVsZombie
@@ -34,8 +37,8 @@ namespace NecromancerTome
{
EntityAlive attacker = __instance.world.GetEntity(_damageSource.getEntityId()) as EntityAlive;
if (attacker is EntityZombie attackerZombie
&& attackerZombie.Buffs != null
&& attackerZombie.Buffs.HasBuff(Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName)
&& (attackerZombie.Buffs != null && attackerZombie.Buffs.HasBuff(Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName)
|| Possession.IsPossessed(attackerZombie) || Possession.IsPossessed(__instance))
&& (attackerZombie.entityFlags & EntityFlags.Zombie) != EntityFlags.None)
{
attackerZombie.entityFlags &= ~EntityFlags.Zombie;
+44
View File
@@ -0,0 +1,44 @@
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Хитбокс наших декораций (03.10: «Ты не добавляешь к декору хитбокс? А как удалять, если не туда
/// поставил?»). В префабах книги памяти, ножа-декора и плакатов на корне стоит BoxCollider, но игра
/// понимает, что луч попал в блок, только по игровому тегу на объекте с коллайдером. В нашем
/// Unity-проекте такого тега нет (AssetRipper знает его как unknown_20004) - при сборке он стал
/// Untagged. Поэтому при первой загрузке модели корню ставим тег и слой корня ванильной рамки
/// (postersFramedGunSchematicPrefab - у неё коллайдер тоже на корне). Префаб из бандла общий,
/// правка одна на все экземпляры. Тот же приём, что у оссуария (GeoOssuaryPatch.CopyTags).
/// </summary>
[HarmonyPatch(typeof(BlockShapeModelEntity), nameof(BlockShapeModelEntity.getPrefab))]
public static class Patch_BlockShapeModelEntity_getPrefab_DecorColliderTag
{
const string VanillaModel = "@:Entities/Decor/postersFramedGunSchematicPrefab.prefab";
static readonly System.Collections.Generic.HashSet<string> done = new System.Collections.Generic.HashSet<string>();
static bool Ours(string _name)
{
return _name == "necroBookMindPrefab" || _name == "necroKnifeDecorPrefab" || _name.StartsWith("necroPoster");
}
public static void Postfix(Transform __result)
{
if (__result == null || !Ours(__result.name) || !done.Add(__result.name))
{
return;
}
Transform vanilla = DataLoader.LoadAsset<Transform>(VanillaModel);
if (vanilla == null)
{
Debug.LogWarning("[NecromancerTome] DecorCollider: ванильная рамка не загрузилась - тег не поставлен");
return;
}
__result.tag = vanilla.tag;
__result.gameObject.layer = vanilla.gameObject.layer;
Debug.Log("[NecromancerTome] DecorCollider: '" + __result.name + "' тег " + vanilla.tag + ", слой " + vanilla.gameObject.layer +
", коллайдер " + (__result.GetComponent<Collider>() != null ? "есть" : "НЕТ"));
}
}
}
+323
View File
@@ -0,0 +1,323 @@
using GUI_2;
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Хваты пустой руки (03.10, BACKLOG «Заклинания с руки и жесты»): на R - меню из трёх строк:
/// обычная рука, «отрицание», «пиф-паф». Пустая рука - предмет meleeHandPlayer с DynamicMelee,
/// меню на R игра открывает ей так же, как ножу (PlayerMoveController: HasRadial).
///
/// Выбор НЕ сохраняется (указание 03.10: «после перезахода будет снова обычная рука») - просто поле
/// в памяти. Ни cvar, ни предмет не трогаем. На выходе из мира (нет мира или сменился игрок) всё
/// сбрасывается, и ссылки на кости, снимок заморозки и цель отпускаются (HandPose.ResetAll) - чтобы
/// за сессию ничего не копилось (указание: «не допустить утечку памяти на сохранение состояний»).
/// В хвате рука стоит позой (HandPose: кости после аниматора + заморозка остальных), а кнопки мыши
/// не бьют: ExecuteAction у DynamicMelee пустой руки перехвачен.
/// ЛКМ в «отрицании» - зомби под прицелом в леденящем ужасе: бежит от игрока с ускорением и теряет
/// его как цель (NecroFleeSnakeTask.ScareFrom). Звук - как у празднующего убийства Twitch.
/// ЛКМ в «пиф-пафе» - одержимость зомби-гуманоида под прицелом (Possession.cs).
/// ПКМ в хватах - ничего.
/// </summary>
public static class HandHolds
{
public const string HandItem = "meleeHandPlayer";
public const int None = 0, Deny = 1, PifPaf = 2;
/// <summary>Дальность и конус выбора зомби для «отрицания».</summary>
public const float DenyRange = 25f;
public const float DenyConeDeg = 12f;
/// <summary>Бежит, пока игрок не дальше этого: зрение зомби до 30 м, у Чака 40 (entityclasses.xml,
/// SightRange) - указание 03.10: «убегать он должен дальше 40 м, чтобы терять из прицела».</summary>
public const float DenyFarDistance = 40f;
/// <summary>Предел бегства на случай, если застрял и не может уйти на 40 м.</summary>
public const float DenyMaxSeconds = 25f;
/// <summary>Скорость бегства - выше бега зомби-людей (1.5), как у медведя (указание: «дать ему ускорение»).</summary>
public const float DenySpeed = 2.2f;
public const float Cooldown = 0.6f;
public const string DenySound = "twitch_celebrate";
/// <summary>«Пиф-паф» (указание 03.10): не вселился - звон разбитого стекла, вселился - щелчок
/// удавшейся отмычки (sounds.xml: glassdestroy, lockpick_success).</summary>
public const string PifPafFailSound = "glassdestroy";
public const string PifPafPossessSound = "lockpick_success";
/// <summary>Выбранный хват - только на время сессии в мире.</summary>
public static int Hold;
static int _applied = -1;
static int _playerId = -1;
static float _nextCast;
public static bool IsHand(ItemClass _item) => _item != null && _item.Name == HandItem;
public static int Current(EntityPlayerLocal _player)
{
if (_player == null || _player.Buffs == null || !IsHand(_player.inventory?.holdingItem) ||
SnakeMode.Active || MindPalace.Active || Possession.Active || _player.IsDead())
{
return None;
}
return Hold;
}
/// <summary>Из HandPoseDriver.LateUpdate - до HandPose.Apply. Позу трогаем только при смене
/// хвата: ручная настройка командой necropose держится до следующей смены.</summary>
public static void Update()
{
EntityPlayerLocal player = GameManager.Instance?.World?.GetPrimaryPlayer();
int id = player != null ? player.entityId : -1;
if (id != _playerId)
{
// Вышли из мира, зашли в другой или перезашли - обычная рука и пустые ссылки.
_playerId = id;
Hold = None;
_applied = None;
_target = null;
_targetFrame = -1;
HandPose.ResetAll();
return;
}
int want = Current(player);
if (want == _applied)
{
return;
}
_applied = want;
if (want == Deny)
{
HandPose.PresetDeny();
}
else if (want == PifPaf)
{
HandPose.PresetPifPaf();
}
else
{
HandPose.Enabled = false;
}
}
public static void OnRadialCommand(XUiC_Radial _sender, int _commandIndex, XUiC_Radial.RadialContextAbs _context)
{
if (_commandIndex >= None && _commandIndex <= PifPaf)
{
Hold = _commandIndex;
}
}
/// <summary>ЛКМ в хвате. True - нажатие наше, удар не выполнять.</summary>
public static void Cast(EntityPlayerLocal _player, int _hold)
{
if (Time.time < _nextCast)
{
return;
}
_nextCast = Time.time + Cooldown;
if (_hold == PifPaf)
{
// Одержимость (решение 03.10): «пиф-паф» по зомби-гуманоиду - управление им.
EntityAlive victim = Target(_player);
if (Possession.CanPossess(victim) && Possession.Enter(_player, victim))
{
_player.PlayOneShot(PifPafPossessSound);
}
else
{
_player.PlayOneShot(PifPafFailSound);
Debug.Log("[NecromancerTome] HandHolds: пиф-паф - " + (victim == null ? "в прицеле нет зомби" : victim.EntityName + " не гуманоид"));
}
return;
}
_player.PlayOneShot(DenySound);
World world = _player.world;
if (world.IsRemote())
{
return; // ИИ живёт на сервере; клиент в мультиплеере - решать отдельно
}
EntityAlive target = Target(_player);
if (target != null)
{
if (target.HasAnyTags(FlyerTag))
{
NecroFleeSnakeTask.ForgetNow(target, _player); // летает - наземного бегства нет, только теряет цель
Debug.Log("[NecromancerTome] HandHolds: стервятник " + target.entityId + " отрицание - потерял цель");
}
else
{
NecroFleeSnakeTask.ScareFrom(target, _player, DenyMaxSeconds, DenySpeed, DenyFarDistance);
}
}
}
static readonly FastTags<TagGroup.Global> ZombieTag = FastTags<TagGroup.Global>.Parse("zombie");
static readonly FastTags<TagGroup.Global> FlyerTag = FastTags<TagGroup.Global>.Parse("vulture");
/// <summary>Только зомби (указание 03.10: «кабаны и змеи хоть и противник, но не зомби, кроме
/// зомбокабана»; «стервятники тоже зомби, обычных в игре нету») - метка zombie в entityclasses.xml:
/// люди-зомби, зомби-собаки, лютые волки, зомби-медведи, зомби-кабаны, стервятники.</summary>
public static bool IsZombie(EntityAlive _e)
{
return _e != null && _e.HasAnyTags(ZombieTag);
}
static int _targetFrame = -1;
static EntityAlive _target;
/// <summary>Зомби под прицелом (раз за кадр): и для заклинания, и для красного прицела.</summary>
public static EntityAlive Target(EntityPlayerLocal _player)
{
if (_targetFrame != Time.frameCount)
{
_targetFrame = Time.frameCount;
_target = FindTarget(_player.world, _player);
}
return _target;
}
/// <summary>Прямая видимость своим лучом: между глазом и точкой нет блоков и земли (сущности на луче
/// не мешают). Не EntityAlive.CanSee: у игрока нет ИИ, и его кэш видимости (seeCache) не обновляется -
/// тест 03.10: Арлин захватывалась, зомби-коп нет.</summary>
static bool Visible(Vector3 _eye, Vector3 _to)
{
Vector3 d = _to - _eye;
return !Physics.Raycast(_eye - Origin.position, d.normalized, d.magnitude - 0.3f, SnakeControl.WallMask, QueryTriggerInteraction.Ignore);
}
/// <summary>Зомби ближе всех к линии прицела в конусе DenyConeDeg, не дальше DenyRange,
/// с прямой видимостью.</summary>
static EntityAlive FindTarget(World _world, EntityPlayerLocal _player)
{
Transform cam = _player.cameraTransform;
if (cam == null)
{
return null;
}
Vector3 eye = cam.position + Origin.position;
Vector3 fwd = cam.forward;
EntityAlive best = null;
float bestAngle = DenyConeDeg;
for (int i = 0; i < _world.EntityAlives.Count; i++)
{
EntityAlive e = _world.EntityAlives[i];
if (e == null || e.IsDead() || !IsZombie(e))
{
continue;
}
Vector3 to = e.getChestPosition() - eye;
float d = to.magnitude;
if (d > DenyRange || d < 0.1f)
{
continue;
}
float angle = Vector3.Angle(fwd, to);
if (angle < bestAngle && Visible(eye, e.getChestPosition()))
{
bestAngle = angle;
best = e;
}
}
return best;
}
}
/// <summary>Меню на R у пустой руки - как у ножа (Patch_ItemAction_HasRadial_SnakeKnife).</summary>
[HarmonyPatch(typeof(ItemAction), nameof(ItemAction.HasRadial))]
public static class Patch_ItemAction_HasRadial_HandHolds
{
public static void Postfix(ItemAction __instance, ref bool __result)
{
if (!__result && !SnakeMode.Active && !MindPalace.Active && HandHolds.IsHand(__instance.item))
{
__result = true;
}
}
}
[HarmonyPatch(typeof(ItemAction), nameof(ItemAction.SetupRadial))]
public static class Patch_ItemAction_SetupRadial_HandHolds
{
public static bool Prefix(ItemAction __instance, XUiC_Radial _xuiRadialWindow, EntityPlayerLocal _epl)
{
if (!HandHolds.IsHand(__instance.item))
{
return true;
}
int cur = HandHolds.Hold;
_xuiRadialWindow.ResetRadialEntries();
_xuiRadialWindow.CreateRadialEntry(HandHolds.None, "ui_game_symbol_hand", "UIAtlas", "",
Localization.Get("necroHoldNone"), cur == HandHolds.None);
_xuiRadialWindow.CreateRadialEntry(HandHolds.Deny, "ui_game_symbol_run", "UIAtlas", "",
Localization.Get("necroHoldDeny"), cur == HandHolds.Deny);
_xuiRadialWindow.CreateRadialEntry(HandHolds.PifPaf, "ui_game_symbol_pistol", "UIAtlas", "",
Localization.Get("necroHoldPifPaf"), cur == HandHolds.PifPaf);
_xuiRadialWindow.SetCommonData(UIUtils.GetButtonIconForAction(_epl.playerInput.Reload),
HandHolds.OnRadialCommand, null, cur);
return false;
}
}
/// <summary>В хвате кнопки мыши не бьют: ЛКМ - заклинание хвата, ПКМ - ничего (03.10: «руки
/// деформируются, пытаясь совершить старую анимацию»).</summary>
[HarmonyPatch(typeof(ItemActionDynamicMelee), nameof(ItemActionDynamicMelee.ExecuteAction))]
public static class Patch_ItemActionDynamicMelee_ExecuteAction_HandHolds
{
public static bool Prefix(ItemActionData _actionData, bool _bReleased)
{
if (!(_actionData?.invData?.holdingEntity is EntityPlayerLocal player))
{
return true;
}
int hold = HandHolds.Current(player);
if (hold == HandHolds.None || !HandHolds.IsHand(_actionData.invData.item))
{
return true;
}
if (!_bReleased && _actionData.indexInEntityOfAction == 0)
{
HandHolds.Cast(player, hold);
}
return false;
}
}
/// <summary>Прицел краснеет, когда в нём зомби, в обоих хватах (указание 03.10). Цвет прицела игра
/// берёт из EntityPlayerLocal.crosshairColors[настройка] - на время рисования подменяем его красным.</summary>
[HarmonyPatch(typeof(EntityPlayerLocal), nameof(EntityPlayerLocal.guiDrawCrosshair))]
public static class Patch_EntityPlayerLocal_guiDrawCrosshair_HandHolds
{
static readonly Color Red = new Color(1f, 0.15f, 0.1f);
public static void Prefix(EntityPlayerLocal __instance, out Color? __state)
{
__state = null;
int hold = HandHolds.Current(__instance);
EntityAlive target = hold == HandHolds.None ? null : HandHolds.Target(__instance);
// «Пиф-паф» берёт только зомби-гуманоидов - на зверях-зомби прицел не краснеет.
if (target == null || hold == HandHolds.PifPaf && !Possession.CanPossess(target))
{
return;
}
int i = GamePrefs.GetInt(EnumGamePrefs.OptionsCrosshairColor);
if (i < 0 || i >= EntityPlayerLocal.crosshairColors.Count)
{
i = 0;
}
__state = EntityPlayerLocal.crosshairColors[i];
EntityPlayerLocal.crosshairColors[i] = Red;
}
public static void Postfix(Color? __state)
{
if (__state == null)
{
return;
}
int i = GamePrefs.GetInt(EnumGamePrefs.OptionsCrosshairColor);
if (i < 0 || i >= EntityPlayerLocal.crosshairColors.Count)
{
i = 0;
}
EntityPlayerLocal.crosshairColors[i] = __state.Value;
}
}
}
+106
View File
@@ -0,0 +1,106 @@
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// necrobones - разведка к жестам (BACKLOG «Заклинания с руки и жесты», 1.7.0). Свой хват -
/// это неподвижная поза рук от первого лица, как у браслета (кулак к противнику, без движения
/// при действии): кости рига ставятся в нужную комбинацию после аниматора. Сначала надо знать
/// кости: команда пишет в лог дерево рига рук от первого лица (EModelSDCS.baseRigFP, его создаёт
/// SDCSUtils.CreateVizFP под Camera/UFPSRoot) с локальными поворотами и позициями, и параметры
/// аниматора FPPlayerController (WeaponHoldType и прочие) с текущими значениями.
/// </summary>
[Preserve]
public class ConsoleCmdNecroBones : ConsoleCmdAbstract
{
const string Tag = "[NecromancerTome] necrobones: ";
public override string[] getCommands() => new[] { "necrobones" };
public override string getDescription() => "NecromancerTome: дерево костей рук от первого лица и параметры аниматора - в лог";
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
EntityPlayerLocal player = GameManager.Instance.World?.GetPrimaryPlayer();
if (player == null)
{
return;
}
if (!(player.emodel is EModelSDCS sdcs) || sdcs.baseRigFP == null)
{
SdtdConsole.Instance.Output("риг рук от первого лица не найден (EModelSDCS.baseRigFP)");
return;
}
Transform root = sdcs.baseRigFP.transform;
ItemClass held = player.inventory?.holdingItem;
Debug.Log(Tag + "в руке " + (held != null ? held.GetItemName() + ", HoldType " + held.HoldType.Value : "ничего") + ", вид от первого лица " + sdcs.IsFPV);
Debug.Log(Tag + "путь рига: " + PathOf(root));
int count = 0;
var sb = new StringBuilder();
Dump(root, 0, sb, ref count);
foreach (string line in sb.ToString().Split('\n'))
{
if (line.Length > 0)
{
Debug.Log(Tag + line);
}
}
Animator anim = root.GetComponentInChildren<Animator>(true) ?? root.GetComponentInParent<Animator>();
if (anim != null)
{
Debug.Log(Tag + "аниматор на " + anim.name + ", контроллер " + (anim.runtimeAnimatorController != null ? anim.runtimeAnimatorController.name : "нет"));
foreach (AnimatorControllerParameter p in anim.parameters)
{
string v = p.type == AnimatorControllerParameterType.Float ? anim.GetFloat(p.nameHash).ToString("0.###")
: p.type == AnimatorControllerParameterType.Int ? anim.GetInteger(p.nameHash).ToString()
: p.type == AnimatorControllerParameterType.Bool ? anim.GetBool(p.nameHash).ToString()
: "trigger";
Debug.Log(Tag + " параметр " + p.name + " (" + p.type + ") = " + v);
}
for (int l = 0; l < anim.layerCount; l++)
{
AnimatorClipInfo[] clips = anim.GetCurrentAnimatorClipInfo(l);
string names = "";
foreach (AnimatorClipInfo c in clips)
{
names += (names.Length > 0 ? ", " : "") + (c.clip != null ? c.clip.name : "?") + " x" + c.weight.ToString("0.##");
}
Debug.Log(Tag + " слой " + l + " " + anim.GetLayerName(l) + " вес " + anim.GetLayerWeight(l).ToString("0.##") + ": " + names);
}
}
else
{
Debug.Log(Tag + "аниматор не найден");
}
SdtdConsole.Instance.Output("костей рук: " + count + " - дерево и параметры аниматора в логе (строки necrobones)");
}
static void Dump(Transform _t, int _depth, StringBuilder _sb, ref int _count)
{
_count++;
Vector3 e = _t.localEulerAngles;
Vector3 p = _t.localPosition;
_sb.Append(new string(' ', _depth * 2)).Append(_t.name)
.Append(_t.gameObject.activeSelf ? "" : " [выкл]")
.Append(" пов. ").Append(e.x.ToString("0.#")).Append(' ').Append(e.y.ToString("0.#")).Append(' ').Append(e.z.ToString("0.#"))
.Append(" поз. ").Append(p.x.ToString("0.###")).Append(' ').Append(p.y.ToString("0.###")).Append(' ').Append(p.z.ToString("0.###"))
.Append('\n');
for (int i = 0; i < _t.childCount; i++)
{
Dump(_t.GetChild(i), _depth + 1, _sb, ref _count);
}
}
static string PathOf(Transform _t)
{
string s = _t.name;
for (Transform p = _t.parent; p != null; p = p.parent)
{
s = p.name + "/" + s;
}
return s;
}
}
}
+455
View File
@@ -0,0 +1,455 @@
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Свой хват рук от первого лица (BACKLOG «Заклинания с руки и жесты», 1.7.0). Хват - неподвижная
/// поза: после аниматора (LateUpdate) кости рига EModelSDCS.baseRigFP ставятся в заданные локальные
/// повороты. Аниматор игры не трогаем. Снимки necrobones 03.10: кулак браслета (FP_Block_Hold,
/// HoldType 7) - суставы пальцев повёрнуты по локальной Z на 77-92°, у пустой руки 8-38°;
/// направление руки - RightArm, RightArmRoll, RightForeArm (сгиб по Y), RightHand.
/// Пока - инструмент подбора позы (команда necropose); готовую позу потом зашить как хват.
/// </summary>
public static class HandPose
{
public static bool Enabled;
public static readonly Dictionary<string, Vector3> Overrides = new Dictionary<string, Vector3>();
static GameObject _rig;
static readonly Dictionary<string, Transform> _bones = new Dictionary<string, Transform>();
static GameObject _host;
public static void Init()
{
if (_host != null)
{
return;
}
_host = new GameObject("NecroHandPose");
Object.DontDestroyOnLoad(_host);
_host.AddComponent<HandPoseDriver>();
}
public static Transform Bone(string _name)
{
EntityPlayerLocal player = GameManager.Instance?.World?.GetPrimaryPlayer();
GameObject rig = (player?.emodel as EModelSDCS)?.baseRigFP;
if (rig == null)
{
return null;
}
if (rig != _rig)
{
_rig = rig;
_bones.Clear();
foreach (Transform t in rig.GetComponentsInChildren<Transform>(true))
{
if (!_bones.ContainsKey(t.name))
{
_bones[t.name] = t;
}
}
}
if (_bones.TryGetValue(_name, out Transform b) && b == null)
{
_rig = null; // кость уничтожена (риг пересобран) - перечитать в следующий раз
}
return b;
}
/// <summary>Кости, повёрнутые относительно КАМЕРЫ, а не родителя: ставятся после Overrides
/// (03.10: локальные углы кисти «плывут» при каждом изменении локтя - подбор вслепую).</summary>
public static readonly Dictionary<string, Quaternion> CamOverrides = new Dictionary<string, Quaternion>();
public static Transform Camera()
{
return GameManager.Instance?.World?.GetPrimaryPlayer()?.cameraTransform;
}
/// <summary>Заморозка (03.10: «при нажатии на кнопки руки деформируются, пытаясь совершить старую
/// анимацию»). Поза задаёт 20-30 костей, а удар двигает и остальные - позвоночник, ключицы, вспомогательные
/// кости предплечья и кожи (*_sup_*, *Roll*). Поэтому при включении хвата в покое снимается локальное
/// положение ВСЕХ костей под Hips, и каждый кадр они возвращаются на место, а поверх - поза.
/// Снимок только в покое аниматора (ItemActionIndex = -1), иначе застыл бы кадр удара.</summary>
public static bool Freeze = true;
static readonly List<Transform> _frozen = new List<Transform>();
static readonly List<Vector3> _frozenPos = new List<Vector3>();
static readonly List<Quaternion> _frozenRot = new List<Quaternion>();
static GameObject _frozenRig;
static readonly int ItemActionIndexHash = Animator.StringToHash("ItemActionIndex");
/// <summary>Выход из мира: всё в исходное, ссылки на кости рига отпустить (HandHolds.Update).</summary>
public static void ResetAll()
{
Enabled = false;
Overrides.Clear();
CamOverrides.Clear();
Unfreeze();
_bones.Clear();
_rig = null;
}
public static void Unfreeze()
{
_frozen.Clear();
_frozenPos.Clear();
_frozenRot.Clear();
_frozenRig = null;
}
static void FreezeBones()
{
Transform hips = Bone("Hips");
if (hips == null)
{
return;
}
if (_frozenRig != _rig || _frozen.Count == 0 || _frozen[0] == null)
{
Unfreeze();
Animator anim = _rig.GetComponent<Animator>();
if (anim != null && (anim.GetInteger(ItemActionIndexHash) != -1 || anim.IsInTransition(0)))
{
return; // идёт действие - снимок позже, в покое
}
foreach (Transform t in hips.GetComponentsInChildren<Transform>(true))
{
_frozen.Add(t);
_frozenPos.Add(t.localPosition);
_frozenRot.Add(t.localRotation);
}
_frozenRig = _rig;
Debug.Log("[NecromancerTome] HandPose: заморожено костей " + _frozen.Count);
return;
}
for (int i = 0; i < _frozen.Count; i++)
{
Transform t = _frozen[i];
if (t != null)
{
t.localPosition = _frozenPos[i];
t.localRotation = _frozenRot[i];
}
}
}
public static void Apply()
{
if (!Enabled || Overrides.Count == 0 && CamOverrides.Count == 0)
{
if (_frozen.Count > 0)
{
Unfreeze();
}
return;
}
if (Freeze)
{
FreezeBones();
}
foreach (var kv in Overrides)
{
Transform b = Bone(kv.Key);
if (b != null)
{
b.localRotation = Quaternion.Euler(kv.Value);
}
}
Transform cam = CamOverrides.Count > 0 ? Camera() : null;
if (cam != null)
{
foreach (var kv in CamOverrides)
{
Transform b = Bone(kv.Key);
if (b != null)
{
b.rotation = cam.rotation * kv.Value;
}
}
}
}
/// <summary>Кисть в осях камеры. Оси кисти правой руки (лог necrobones 03.10): пальцы - локальная +X
/// (суставы пальцев лежат по +X), ладонь - +Y (сгиб пальцев +Z ведёт +X к +Y), +Z - к мизинцу,
/// большой палец - к -Z. Нулевая поза: пальцы вверх, ладонь от игрока, тыльная сторона к камере,
/// большой палец влево. tilt - наклон пальцев влево (+) вокруг взгляда; back - кисть «от себя»
/// (+, верх пальцев уходит вперёд); turn - разворот кисти вокруг вертикали (+ ладонью вправо).</summary>
public static Quaternion PalmToCamera(float _tilt, float _back, float _turn)
{
Quaternion basis = Quaternion.LookRotation(Vector3.right, Vector3.forward);
return Quaternion.AngleAxis(_turn, Vector3.up) * Quaternion.AngleAxis(_back, Vector3.right) * Quaternion.AngleAxis(_tilt, Vector3.forward) * basis;
}
static readonly string[] Fingers = { "Index", "Middle", "Ring", "Pinky" };
/// <summary>Все суставы четырёх пальцев: сгиб по Z (curl), веер по Y у первого сустава
/// (указательный +spread, средний +spread/3, безымянный -spread/3, мизинец -spread).</summary>
public static void SetFingers(string _side, float _curl, float _spread)
{
float[] fan = { 1f, 1f / 3f, -1f / 3f, -1f };
for (int f = 0; f < Fingers.Length; f++)
{
for (int j = 1; j <= 3; j++)
{
Overrides[_side + "Hand" + Fingers[f] + j] = new Vector3(0f, j == 1 ? _spread * fan[f] : 0f, _curl);
}
}
}
/// <summary>Заготовка «раскрытая ладонь»: рука - как у браслета (снимок 03.10), пальцы прямые.</summary>
public static void PresetOpen()
{
Overrides.Clear();
Overrides["RightArm"] = new Vector3(330.8f, 34.9f, 24.3f);
Overrides["RightArmRoll"] = new Vector3(349.7f, 0f, 0f);
Overrides["RightForeArm"] = new Vector3(0f, 88.4f, 0f);
Overrides["RightHand"] = new Vector3(13.3f, 325.8f, 7.7f);
SetFingers("Right", 3f, 0f);
Overrides["RightHandThumb1"] = new Vector3(55f, 68.6f, 40.6f);
Overrides["RightHandThumb2"] = new Vector3(0f, 0f, 0f);
Overrides["RightHandThumb3"] = new Vector3(0f, 0f, 0f);
Enabled = true;
}
/// <summary>Левая рука вдоль туловища, вне кадра (общая для хватов «отрицание» и «пиф-паф», 03.10).</summary>
static void LeftArmDown()
{
Overrides["LeftArm"] = new Vector3(30f, 13.6f, 57.3f);
}
/// <summary>Хват «отрицание» (утверждён пользователем 03.10): рука вперёд, как у браслета, ниже прицела,
/// кисть в линию с предплечьем, пальцы раскрыты.</summary>
public static void PresetDeny()
{
Overrides.Clear();
CamOverrides.Clear();
Overrides["RightArm"] = new Vector3(345.8f, 34.9f, 24.3f);
Overrides["RightArmRoll"] = new Vector3(349.7f, 0f, 0f);
Overrides["RightForeArm"] = new Vector3(0f, 88.4f, 0f);
Overrides["RightHand"] = new Vector3(45f, 0f, 0f);
SetFingers("Right", 3f, 12f);
Overrides["RightHandThumb1"] = new Vector3(55f, 68.6f, 40.6f);
Overrides["RightHandThumb2"] = new Vector3(0f, 0f, 0f);
Overrides["RightHandThumb3"] = new Vector3(0f, 0f, 0f);
LeftArmDown();
Unfreeze();
Enabled = true;
}
/// <summary>Хват «пиф-паф» (утверждён 03.10, «Стоп. Замри»): пистолет из пальцев - указательный и средний
/// сомкнуты и смотрят к прицелу, безымянный и мизинец поджаты, большой вверх. Кисть - от камеры.</summary>
public static void PresetPifPaf()
{
Overrides.Clear();
CamOverrides.Clear();
Overrides["RightArm"] = new Vector3(330.8f, 34.9f, 24.3f);
Overrides["RightArmRoll"] = new Vector3(349.7f, 0f, 0f);
Overrides["RightForeArm"] = new Vector3(0f, 88.4f, 0f);
CamOverrides["RightHand"] = PalmToCamera(-90f, 0f, -119f);
for (int j = 1; j <= 3; j++)
{
Overrides["RightHandIndex" + j] = new Vector3(0f, j == 1 ? -3f : 0f, 3f);
Overrides["RightHandMiddle" + j] = new Vector3(0f, j == 1 ? 2f : 0f, 3f);
float curl = j == 3 ? 55f : 80f;
Overrides["RightHandRing" + j] = new Vector3(0f, 0f, curl);
Overrides["RightHandPinky" + j] = new Vector3(0f, 0f, curl);
}
Overrides["RightHandThumb1"] = new Vector3(55f, 68.6f, 20f);
Overrides["RightHandThumb2"] = new Vector3(0f, 0f, 0f);
Overrides["RightHandThumb3"] = new Vector3(0f, 0f, 0f);
LeftArmDown();
Unfreeze();
Enabled = true;
}
public static string F(float _v) => _v.ToString("0.#", CultureInfo.InvariantCulture);
public static string F(Vector3 _v) => F(_v.x) + " " + F(_v.y) + " " + F(_v.z);
}
public class HandPoseDriver : MonoBehaviour
{
void LateUpdate()
{
HandHolds.Update();
HandPose.Apply();
}
}
/// <summary>
/// necropose - подбор позы рук от первого лица:
/// on | off | clear | open (заготовка: ладонь, рука как у браслета)
/// &lt;Кость&gt; x y z - локальный поворот кости (градусы)
/// add &lt;Кость&gt; dx dy dz - добавить к текущему (заданному или от аниматора)
/// fingers Right|Left сгиб [веер] - все суставы четырёх пальцев
/// show &lt;Кость&gt; - текущий поворот кости
/// save - вся поза в лог строками кода
/// </summary>
[Preserve]
public class ConsoleCmdNecroPose : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necropose" };
public override string getDescription() => "NecromancerTome: поза рук от первого лица - on|off|clear|deny|pifpaf|freeze [off]|open|<кость> x y z|aim <кость> наклон от_себя разворот|unaim <кость>|add <кость> dx dy dz|fingers Right|Left сгиб [веер]|show <кость>|save";
static bool Num(string _s, out float _v) => float.TryParse(_s.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out _v);
void Out(string _s)
{
SdtdConsole.Instance.Output(_s);
Debug.Log("[NecromancerTome] necropose: " + _s);
}
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
string cmd = _params.Count > 0 ? _params[0] : "";
switch (cmd)
{
case "on":
HandPose.Enabled = true;
Out("поза включена, костей " + HandPose.Overrides.Count);
return;
case "off":
HandPose.Enabled = false;
Out("поза выключена");
return;
case "clear":
HandPose.Overrides.Clear();
HandPose.CamOverrides.Clear();
Out("поза очищена");
return;
case "deny":
HandPose.PresetDeny();
Out("хват «отрицание»");
return;
case "pifpaf":
HandPose.PresetPifPaf();
Out("хват «пиф-паф»");
return;
case "freeze":
HandPose.Freeze = _params.Count < 2 || _params[1] != "off";
HandPose.Unfreeze();
Out("заморозка остальных костей " + (HandPose.Freeze ? "включена (снимок - в покое)" : "выключена"));
return;
case "open":
HandPose.PresetOpen();
Out("заготовка «ладонь» включена, костей " + HandPose.Overrides.Count);
return;
case "save":
{
var sb = new StringBuilder();
foreach (var kv in HandPose.Overrides)
{
Out("Overrides[\"" + kv.Key + "\"] = new Vector3(" + HandPose.F(kv.Value.x) + "f, " + HandPose.F(kv.Value.y) + "f, " + HandPose.F(kv.Value.z) + "f);");
}
foreach (var kv in HandPose.CamOverrides)
{
Transform cb = HandPose.Bone(kv.Key);
if (cb != null)
{
Out("Overrides[\"" + kv.Key + "\"] = new Vector3(" + HandPose.F(cb.localEulerAngles.x) + "f, " + HandPose.F(cb.localEulerAngles.y) + "f, " + HandPose.F(cb.localEulerAngles.z) + "f); // от камеры (aim)");
}
}
Out("всего костей " + HandPose.Overrides.Count + " + от камеры " + HandPose.CamOverrides.Count + ", включена " + HandPose.Enabled);
return;
}
case "show":
{
if (_params.Count < 2)
{
break;
}
Transform b = HandPose.Bone(_params[1]);
Out(b == null ? "нет кости " + _params[1] : _params[1] + " = " + HandPose.F(b.localEulerAngles) + (HandPose.Overrides.ContainsKey(_params[1]) ? " (задана)" : " (от аниматора)"));
return;
}
case "fingers":
{
if (_params.Count >= 3 && (_params[1] == "Right" || _params[1] == "Left") && Num(_params[2], out float curl))
{
float spread = 0f;
if (_params.Count >= 4)
{
Num(_params[3], out spread);
}
HandPose.SetFingers(_params[1], curl, spread);
HandPose.Enabled = true;
Out("пальцы " + _params[1] + ": сгиб " + HandPose.F(curl) + ", веер " + HandPose.F(spread));
return;
}
break;
}
case "aim":
{
// aim <Кость> [наклон] [от_себя] [разворот] - кисть в осях камеры (см. HandPose.PalmToCamera)
if (_params.Count >= 2)
{
float tilt = 0f, back = 0f, turn = 0f;
if (_params.Count >= 3) Num(_params[2], out tilt);
if (_params.Count >= 4) Num(_params[3], out back);
if (_params.Count >= 5) Num(_params[4], out turn);
if (HandPose.Bone(_params[1]) == null)
{
Out("нет кости " + _params[1]);
return;
}
HandPose.Overrides.Remove(_params[1]);
HandPose.CamOverrides[_params[1]] = HandPose.PalmToCamera(tilt, back, turn);
HandPose.Enabled = true;
Out(_params[1] + " от камеры: наклон " + HandPose.F(tilt) + ", от себя " + HandPose.F(back) + ", разворот " + HandPose.F(turn));
return;
}
break;
}
case "unaim":
{
if (_params.Count >= 2 && HandPose.CamOverrides.Remove(_params[1]))
{
Out(_params[1] + " - снова от родителя (от аниматора или заданная)");
return;
}
break;
}
case "add":
{
if (_params.Count >= 5 && Num(_params[2], out float dx) && Num(_params[3], out float dy) && Num(_params[4], out float dz))
{
string name = _params[1];
Transform b = HandPose.Bone(name);
if (b == null)
{
Out("нет кости " + name);
return;
}
Vector3 cur = HandPose.Overrides.TryGetValue(name, out Vector3 v) ? v : b.localEulerAngles;
HandPose.Overrides[name] = cur + new Vector3(dx, dy, dz);
HandPose.Enabled = true;
Out(name + " = " + HandPose.F(HandPose.Overrides[name]));
return;
}
break;
}
default:
{
if (_params.Count >= 4 && Num(_params[1], out float x) && Num(_params[2], out float y) && Num(_params[3], out float z))
{
if (HandPose.Bone(cmd) == null)
{
Out("нет кости " + cmd);
return;
}
HandPose.CamOverrides.Remove(cmd);
HandPose.Overrides[cmd] = new Vector3(x, y, z);
HandPose.Enabled = true;
Out(cmd + " = " + HandPose.F(HandPose.Overrides[cmd]));
return;
}
break;
}
}
Out(getDescription());
}
}
}
+304
View File
@@ -0,0 +1,304 @@
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Дух в чертогах разума - камера без сущности (указание 03.10: «тело можно вообще не делать,
/// нужен только прицел»). Ходит по полу как человек: столкновения - по тем же слоям, что у змеи
/// (SnakeControl.WallMask / TerrainMask), гравитация и ступенька. Взаимодействие одно - книга
/// памяти по E; остальное в чертогах неприкосновенно. Камеру ставят патчи камеры режима змеи
/// (SnakeControl.cs), звук и мир вокруг считаются от духа через SnakeAudioSwap.
///
/// СОН (указание: «тёплый фильтр, это как бы сон», эффекты - на моё усмотрение): готовые эффекты
/// экрана игры - Hot (тёплый оттенок), Blur (мягкое размытие), Drunk (медленное покачивание).
/// Силы крутятся командой necrodream на ходу.
/// </summary>
public static class MindControl
{
public const float EyeHeight = 1.6f;
public const float Radius = 0.3f;
public const float Height = 1.75f;
public const float WalkSpeed = 2.2f;
public const float RunSpeed = 3.6f;
public const float Gravity = 20f;
public const float JumpSpeed = 6f;
public const float StepUp = 0.6f;
public const float ReachDistance = 3f;
public static float Hot = 0.45f;
public static float Blur = 0.25f;
public static float Drunk = 0.15f;
public static bool Controlling;
static Vector3 _feet;
static Vector3 _spawn;
static float _yaw, _pitch, _vy;
static bool _grounded;
public static bool HasFocus;
static EntityPlayerLocal _player;
public static Vector3 FeetPos => _feet;
public static void Begin(EntityPlayerLocal _p, Vector3 _at, float _atYaw)
{
_player = _p;
_feet = _at;
_spawn = _at;
_yaw = _atYaw;
_pitch = 0f;
_vy = 0f;
_grounded = false;
HasFocus = false;
Controlling = true;
_p.PlayerUI?.windowManager.Close(SnakeControl.ToolbeltWindow);
Dream(_p, true);
Debug.Log("[NecromancerTome] MindControl: дух в чертогах, " + _at);
}
public static void End(EntityPlayerLocal _p)
{
if (!Controlling)
{
return;
}
Controlling = false;
HasFocus = false;
Dream(_p ?? _player, false);
_player = null;
}
public static void Dream(EntityPlayerLocal _p, bool _on)
{
ScreenEffects fx = _p?.ScreenEffectManager;
if (fx == null)
{
return;
}
fx.SetScreenEffect("Hot", _on ? Hot : 0f, _on ? 2f : 0.5f);
fx.SetScreenEffect("Blur", _on ? Blur : 0f, _on ? 2f : 0.5f);
fx.SetScreenEffect("Drunk", _on ? Drunk : 0f, _on ? 2f : 0.5f);
}
static bool Blocked(Vector3 _p)
{
Vector3 a = _p + Vector3.up * (Radius + 0.05f) - Origin.position;
Vector3 b = _p + Vector3.up * (Height - Radius) - Origin.position;
return Physics.CheckCapsule(a, b, Radius, SnakeControl.WallMask, QueryTriggerInteraction.Ignore);
}
static bool TryMove(Vector3 _d)
{
Vector3 c = _feet + _d;
if (!Blocked(c))
{
_feet = c;
return true;
}
// Ступенька: на полблока выше свободно - шагаем вверх.
if (_grounded && !Blocked(c + Vector3.up * StepUp))
{
_feet = c + Vector3.up * StepUp;
return true;
}
return false;
}
public static void Update(EntityPlayerLocal _p)
{
if (!Controlling)
{
return;
}
float dt = Time.deltaTime;
bool gui = _p.PlayerUI != null && _p.PlayerUI.windowManager.IsModalWindowOpen();
PlayerActionsLocal input = _p.playerInput;
Vector2 move = Vector2.zero;
bool run = false, jump = false;
if (!gui && input != null)
{
PlayerMoveController pmc = PlayerMoveController.Instance;
Vector2 sens = pmc != null ? pmc.mouseLookSensitivity : Vector2.one * 5f;
int invert = pmc != null && pmc.invertMouse != 0 ? pmc.invertMouse : 1;
_yaw += Input.GetAxis("Mouse X") * sens.x;
_pitch = Mathf.Clamp(_pitch + Input.GetAxis("Mouse Y") * sens.y * invert, -85f, 85f);
move = new Vector2(input.Move.X, input.Move.Y);
run = input.Run.IsPressed;
jump = input.Jump.WasPressed;
if (HasFocus && (input.Activate.WasPressed || input.PermanentActions.Activate.WasPressed))
{
BlockNecroMemoryBook.Open(_p);
}
}
Quaternion yawRot = Quaternion.Euler(0f, _yaw, 0f);
Vector3 dir = yawRot * new Vector3(move.x, 0f, move.y);
if (dir.sqrMagnitude > 1f)
{
dir.Normalize();
}
if (dir.sqrMagnitude > 0.0001f)
{
Vector3 delta = dir * (run ? RunSpeed : WalkSpeed) * dt;
int steps = Mathf.Max(1, Mathf.CeilToInt(delta.magnitude / 0.1f));
Vector3 d = delta / steps;
for (int i = 0; i < steps; i++)
{
if (!TryMove(d) && !TryMove(new Vector3(d.x, 0f, 0f)) && !TryMove(new Vector3(0f, 0f, d.z)))
{
break;
}
}
}
// Падение и прыжок: земля - луч вниз по слою ландшафта и блоков.
if (_grounded && jump)
{
_vy = JumpSpeed;
_grounded = false;
}
_vy -= Gravity * dt;
float dy = _vy * dt;
if (dy > 0f && Blocked(_feet + Vector3.up * dy))
{
_vy = 0f;
dy = 0f;
}
if (Physics.Raycast(_feet + Vector3.up * 0.5f - Origin.position, Vector3.down, out RaycastHit hit, 0.5f - dy + 0.05f, SnakeControl.TerrainMask, QueryTriggerInteraction.Ignore) && dy <= 0f)
{
_feet.y = hit.point.y + Origin.position.y;
_vy = 0f;
_grounded = true;
}
else
{
_feet.y += dy;
_grounded = false;
}
// Провалился (коллизии свежепоставленных блоков ещё не построены) - назад на точку появления.
if (_feet.y < _spawn.y - 3f)
{
Debug.Log("[NecromancerTome] MindControl: дух провалился - назад на точку появления");
_feet = _spawn + Vector3.up * 0.3f;
_vy = 0f;
}
}
/// <summary>Из патчей камеры режима змеи: глаза духа и поиск книги под прицелом.</summary>
public static void PlaceCamera(Transform _camera)
{
Vector3 eye = _feet + Vector3.up * EyeHeight - Origin.position;
Quaternion rot = Quaternion.Euler(-_pitch, _yaw, 0f);
_camera.SetPositionAndRotation(eye, rot);
HasFocus = FindBook(_camera.position + Origin.position, _camera.forward);
}
/// <summary>Шагаем по лучу на ReachDistance: первая встреченная книга памяти - цель; твёрдый
/// блок раньше неё - нет цели (сквозь стены не читаем).</summary>
static bool FindBook(Vector3 _from, Vector3 _dir)
{
World world = GameManager.Instance?.World;
if (world == null)
{
return false;
}
Vector3i last = new Vector3i(int.MinValue, 0, 0);
for (float t = 0.1f; t <= ReachDistance; t += 0.05f)
{
Vector3i b = World.worldToBlockPos(_from + _dir * t);
if (b == last)
{
continue;
}
last = b;
Block block = world.GetBlock(b).Block;
if (block is BlockNecroMemoryBook)
{
return true;
}
if (block != null && block.IsCollideMovement && !block.shape.IsTerrain() && block.shape.IsSolidCube)
{
return false;
}
}
return false;
}
}
/// <summary>Прицел духа и подсказка у книги (OnGUI, как прицел змеи).</summary>
public class MindHud : MonoBehaviour
{
static GUIStyle _style;
public static void Create()
{
var go = new GameObject("NecroMindHud");
DontDestroyOnLoad(go);
go.AddComponent<MindHud>();
}
void OnGUI()
{
if (!MindControl.Controlling || Event.current.type != EventType.Repaint)
{
return;
}
EntityPlayerLocal player = GameManager.Instance?.World?.GetPrimaryPlayer();
if (player == null || (player.PlayerUI != null && player.PlayerUI.windowManager.IsModalWindowOpen()))
{
return;
}
float s = 4f * Screen.height / 1080f;
Color old = GUI.color;
GUI.color = MindControl.HasFocus ? new Color(1f, 0.85f, 0.5f, 1f) : new Color(1f, 0.95f, 0.85f, 0.7f);
GUI.DrawTexture(new Rect(Screen.width * 0.5f - s * 0.5f, Screen.height * 0.5f - s * 0.5f, s, s), Texture2D.whiteTexture);
if (MindControl.HasFocus)
{
if (_style == null)
{
_style = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleCenter, fontStyle = FontStyle.Bold };
}
_style.fontSize = Mathf.RoundToInt(26f * Screen.height / 1080f);
// E в игре привязана к PermanentActions.Activate; у обычного Activate привязок нет - он даёт «Нет».
string key = KeyName(player.playerInput?.PermanentActions?.Activate) ?? KeyName(player.playerInput?.Activate) ?? "E";
string text = string.Format(Localization.Get("necroMemoryBookPrompt"), key);
Rect r = new Rect(0f, Screen.height * 0.5f + 30f * Screen.height / 1080f, Screen.width, 40f * Screen.height / 1080f);
GUI.color = new Color(0f, 0f, 0f, 0.8f);
GUI.Label(new Rect(r.x + 2f, r.y + 2f, r.width, r.height), text, _style);
GUI.color = new Color(1f, 0.9f, 0.7f, 1f);
GUI.Label(r, text, _style);
}
GUI.color = old;
}
private static string KeyName(InControl.PlayerAction action)
{
if (action == null || action.Bindings.Count == 0) return null;
string name = action.GetBindingString(false);
return string.IsNullOrEmpty(name) ? null : name;
}
}
/// <summary>necrodream hot|blur|drunk N - подобрать силу сонных эффектов на ходу.</summary>
[UnityEngine.Scripting.Preserve]
public class ConsoleCmdNecroDream : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necrodream" };
public override string getDescription() => "NecromancerTome: сила эффектов сна - hot|blur|drunk N (0-1)";
public override void Execute(System.Collections.Generic.List<string> _params, CommandSenderInfo _senderInfo)
{
if (_params.Count == 2 && float.TryParse(_params[1], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float v))
{
switch (_params[0])
{
case "hot": MindControl.Hot = v; break;
case "blur": MindControl.Blur = v; break;
case "drunk": MindControl.Drunk = v; break;
}
if (MindControl.Controlling)
{
MindControl.Dream(GameManager.Instance.World.GetPrimaryPlayer(), true);
}
}
SdtdConsole.Instance.Output("сон: hot " + MindControl.Hot + ", blur " + MindControl.Blur + ", drunk " + MindControl.Drunk);
}
}
}
+892
View File
@@ -0,0 +1,892 @@
using System.Collections.Generic;
using System.IO;
using HarmonyLib;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Ментальная форма - «Чертоги разума» (решения пользователя 02-03.10, BACKLOG «Чертоги разума»).
/// Вход - третья строка меню ножа некроманта на R, только ночью, здоровье не тратит. Тело
/// падает рэгдоллом и лежит беззащитным, как в режиме змеи; дух - камера без сущности
/// (MindControl.cs): ходит по полу, видит только своё, с другими игроками не встречается.
/// Выход - R, утро или смерть тела.
///
/// ГДЕ ЧЕРТОГИ. Под землёй, в радиационном поясе у северо-восточного угла карты: сначала от
/// угла по диагонали ищется пояс (biomeProvider.GetRadiationAt &gt; 2.5); пояс шире префаба -
/// чертоги в его середине, уже (Navezgane: 16 блоков) - в 45 блоках от края. Своё место у каждого
/// игрока - вдоль северного края с шагом SlotStep (дальше дальности показа сущностей). Места
/// пишутся в файл в папке сохранения мира, чтобы смена ширины пояса в новой версии игры их не
/// сдвинула.
///
/// КАК СТАВЯТСЯ. Префаб chertogi (PalacePrefab/, нарисован пользователем в редакторе игры)
/// грузится Prefab.Load и ставится поблочно через World.SetBlocksRPC - НЕ Prefab.CopyIntoLocal:
/// тот перед постановкой вычищает весь столб над префабом до неба от не-земляных блоков,
/// и над чертогами вырубило бы деревья (видно сверху). Заодно: внешний слой оболочки
/// (woodShapes:cube) -&gt; terrBedrock (в меню творчества его нет, рисовали деревом),
/// одиночная книга на столе -&gt; necroMemoryBook, на стол с реактивами - нож-декорация,
/// кровать (SleepingBag) -&gt; necroPalaceBed: иначе игра делает её точкой возрождения игрока.
/// Построены ли - по книге: нет её - ставим заново (самопочинка).
///
/// ОХРАНА. Внутри чертогов игра считает место «зоной торговца» (патч IsWithinTraderArea):
/// блоки не ломаются ни ударом, ни взрывом, строить нельзя. Чужие сущности в коробке чертогов
/// раз в секунду выбрасываются: змея развеивается, игрок - на поверхность, прочие - удаляются.
///
/// ОГРАНИЧЕНИЕ: одиночная игра и хост. Клиенту в мультиплеере вход пока не даётся - чертоги ставит
/// сервер, а камера живёт у клиента (решать отдельно).
/// </summary>
public static class MindPalace
{
public const string PrefabName = "chertogi";
public const string BookBlock = "necroMemoryBook";
public const string KnifeBlock = "necroKnifeDecor";
/// <summary>Кровать без класса SleepingBag - ею заменяется кровать префаба (bunkBedMadeWhite).</summary>
public const string BedBlock = "necroPalaceBed";
public const string ShellFrom = "woodShapes:cube";
public const string ShellTo = "terrBedrock";
/// <summary>Одиночная книга на обеденном столе (cntBookPile09 в префабе).</summary>
public static readonly Vector3i BookAt = new Vector3i(12, 2, 9);
/// <summary>Место ножа на столе с реактивами (указание пользователя).</summary>
public static readonly Vector3i KnifeAt = new Vector3i(4, 2, 11);
/// <summary>Входная дверь чертогов - во внутреннюю оболочку, никуда не ведёт: остаётся закрытой
/// (указание 03.10). Остальные четыре - между комнатами - открываются.</summary>
public static readonly Vector3i EntranceDoorAt = new Vector3i(8, 1, 3);
/// <summary>Где появляется дух: комната с книгой, свободная клетка у стола.</summary>
public static readonly Vector3 SpawnAt = new Vector3(11.5f, 1.05f, 11.5f);
public const float SpawnYaw = 180f;
public const int SlotStep = 300;
public const int FallbackInset = 45;
public const int FloorY = 3;
/// <summary>Рамка вокруг чертогов, которая заливается камнем перед постановкой.</summary>
public const int LooseMargin = 3;
public const float RadiationThreshold = 2.5f;
/// <summary>Поворот ножа-декорации (0-23): 9 - плашмя на столешнице (подобрано пользователем
/// 03.10 командой necropalace knife N).</summary>
public static int KnifeRotation = 0;
public static bool Active;
static Vector3i _origin;
static Prefab _prefab;
static ChunkManager.ChunkObserver _observer;
enum Stage { None, Loading, Settling, Ready }
static Stage _stage;
static float _deadline;
static float _settleUntil;
static float _ragdollAt;
static bool _waitStandUp;
static float _standUpDeadline;
static float _guardCheck;
static readonly Dictionary<string, Vector3i> _slots = new Dictionary<string, Vector3i>();
static string _slotsFor;
public static void Init()
{
ModEvents.UnityUpdate.RegisterHandler(OnUnityUpdate);
MindHud.Create();
}
// ---------------------------------------------------------------- места
static string SlotsFile => Path.Combine(GameIO.GetSaveGameDir(), "NecromancerTome_palaces.txt");
static void LoadSlots()
{
string dir = GameIO.GetSaveGameDir();
if (_slotsFor == dir)
{
return;
}
_slotsFor = dir;
_slots.Clear();
try
{
if (File.Exists(SlotsFile))
{
foreach (string line in File.ReadAllLines(SlotsFile))
{
string[] p = line.Split(' ');
if (p.Length == 4 && int.TryParse(p[1], out int x) && int.TryParse(p[2], out int y) && int.TryParse(p[3], out int z))
{
_slots[p[0]] = new Vector3i(x, y, z);
}
}
}
}
catch (System.Exception ex)
{
Debug.LogWarning("[NecromancerTome] MindPalace: не прочитать места - " + ex.Message);
}
}
static void SaveSlots()
{
try
{
var lines = new List<string>();
foreach (var kv in _slots)
{
lines.Add(kv.Key + " " + kv.Value.x + " " + kv.Value.y + " " + kv.Value.z);
}
File.WriteAllLines(SlotsFile, lines);
}
catch (System.Exception ex)
{
Debug.LogWarning("[NecromancerTome] MindPalace: не записать места - " + ex.Message);
}
}
static string PlayerKey(EntityPlayer _player)
{
string id = GameManager.Instance.persistentLocalPlayer?.PrimaryId?.CombinedString;
return string.IsNullOrEmpty(id) ? "local" : id.Replace(' ', '_');
}
static Vector3i GetSlot(World _world, EntityPlayer _player)
{
LoadSlots();
string key = PlayerKey(_player);
if (_slots.TryGetValue(key, out Vector3i o))
{
return o;
}
o = ComputeSlot(_world, _slots.Count);
_slots[key] = o;
SaveSlots();
Debug.Log("[NecromancerTome] MindPalace: место " + _slots.Count + " для " + key + " - " + o);
return o;
}
/// <summary>Отступ от края: середина радиационного пояса, если он шире префаба, иначе 45.</summary>
static int Inset(World _world, Vector3i _min, Vector3i _max, int _size)
{
IBiomeProvider biomes = _world.ChunkCache?.ChunkProvider?.GetBiomeProvider();
int start = -1, end = -1;
if (biomes != null)
{
for (int t = 0; t < 600; t += 4)
{
bool rad = biomes.GetRadiationAt(_max.x - t, _max.z - t) > RadiationThreshold;
if (rad && start < 0) start = t;
if (!rad && start >= 0) { end = t; break; }
}
}
if (start >= 0 && end > start && end - start >= _size + 20)
{
return Mathf.Max(FallbackInset, (start + end) / 2 - _size / 2);
}
return FallbackInset;
}
static Vector3i ComputeSlot(World _world, int _index)
{
_world.GetWorldExtent(out Vector3i min, out Vector3i max);
Prefab p = LoadPrefab();
int size = p != null ? Mathf.Max(p.size.x, p.size.z) : 17;
int inset = Inset(_world, min, max, size);
int perEdge = Mathf.Max(1, (max.x - min.x - 2 * inset - size) / SlotStep);
int x, z;
if (_index < perEdge)
{
x = max.x - inset - size - _index * SlotStep;
z = max.z - inset - size;
}
else
{
x = max.x - inset - size;
z = max.z - inset - size - (_index - perEdge + 1) * SlotStep;
}
x = Mathf.FloorToInt(x / 16f) * 16;
z = Mathf.FloorToInt(z / 16f) * 16;
return new Vector3i(x, FloorY, z);
}
// ---------------------------------------------------------------- префаб и постройка
static string ModDir => Path.GetDirectoryName(typeof(MindPalace).Assembly.Location);
static Prefab LoadPrefab()
{
if (_prefab != null)
{
return _prefab;
}
string path = Path.Combine(ModDir, "PalacePrefab", PrefabName + ".tts");
var p = new Prefab();
if (!p.Load(new PathAbstractions.AbstractedLocation(PathAbstractions.EAbstractedLocationType.Mods, PrefabName, path, null, false)))
{
Debug.LogError("[NecromancerTome] MindPalace: префаб не загрузился - " + path);
return null;
}
_prefab = p;
Debug.Log("[NecromancerTome] MindPalace: префаб " + PrefabName + " " + p.size);
return _prefab;
}
static bool ChunksLoaded(World _world, Vector3i _o, Vector3i _size)
{
for (int cx = World.toChunkXZ(_o.x); cx <= World.toChunkXZ(_o.x + _size.x - 1); cx++)
{
for (int cz = World.toChunkXZ(_o.z); cz <= World.toChunkXZ(_o.z + _size.z - 1); cz++)
{
// Не просто «есть в памяти», а сгенерирован и украшен: иначе генерация досыпала
// землю поверх поставленного (тест 03.10: «внутри блоки камня»).
Chunk c = _world.GetChunkSync(cx, cz) as Chunk;
if (c == null || !c.IsInitialized || c.NeedsDecoration)
{
return false;
}
}
}
return true;
}
/// <summary>Стоит ли блок уже в самом префабе (03.10: пользователь расставляет книгу и нож в редакторе
/// сам) - тогда старые принудительные замены (книга в BookAt, нож в KnifeAt) не делаем.</summary>
static bool PrefabHas(Prefab _p, string _block)
{
if (_hasCache == null || _hasCacheFor != _p)
{
_hasCache = new Dictionary<string, bool>();
_hasCacheFor = _p;
}
if (_hasCache.TryGetValue(_block, out bool has))
{
return has;
}
int type = Block.GetBlockValue(_block).type;
for (int y = 0; y < _p.size.y && !has; y++)
for (int z = 0; z < _p.size.z && !has; z++)
for (int x = 0; x < _p.size.x && !has; x++)
{
has = _p.GetBlock(x, y, z).type == type;
}
_hasCache[_block] = has;
return has;
}
static Dictionary<string, bool> _hasCache;
static Prefab _hasCacheFor;
/// <summary>Что должно стоять в клетке префаба после постановки (с заменами). False - клетку
/// не трогаем (дочерняя часть многоблочного предмета).</summary>
static bool Expected(Prefab _p, int _x, int _y, int _z, out BlockValue _bv, out sbyte _d)
{
_bv = _p.GetBlock(_x, _y, _z);
_d = _p.GetDensity(_x, _y, _z);
if (_bv.ischild)
{
return false;
}
Vector3i lp = new Vector3i(_x, _y, _z);
// Пол (y 0) - НЕ bedrock: в префабе он же пол комнат, а bedrock - блок-земля, её сглаженная
// поверхность выпирала из пола в комнаты каменными наплывами (тест 03.10: «в помещении
// блоки камня»). Защита снизу - отдельный слой bedrock под префабом (FloorShield).
bool outer = _x == 0 || _z == 0 || _x == _p.size.x - 1 || _z == _p.size.z - 1 || _y == _p.size.y - 1;
if (outer && !_bv.isair && _bv.Block.GetBlockName() == ShellFrom)
{
_bv = Block.GetBlockValue(ShellTo);
_d = MarchingCubes.DensityTerrain;
}
else if (lp == BookAt && !PrefabHas(_p, BookBlock))
{
BlockValue b = Block.GetBlockValue(BookBlock);
b.rotation = _bv.rotation;
_bv = b;
_d = MarchingCubes.DensityAir;
}
else if (lp == KnifeAt && !PrefabHas(_p, KnifeBlock))
{
BlockValue k = Block.GetBlockValue(KnifeBlock);
k.rotation = (byte)KnifeRotation;
_bv = k;
_d = MarchingCubes.DensityAir;
}
else if (!_bv.isair && _bv.Block is BlockSleepingBag)
{
// Кровать префаба - просто мебель (тест 03.10: синий камень портала отправил в чертоги).
// Чертоги ставятся SetBlocksRPC без игрока, а GameManager.ChangeBlocks тогда считает
// ставившим локального игрока и любую SleepingBag-кровать делает его точкой возрождения.
BlockValue bed = Block.GetBlockValue(BedBlock);
bed.rotation = _bv.rotation;
_bv = bed;
_d = MarchingCubes.DensityAir;
}
else if (_bv.isair || !_bv.Block.shape.IsTerrain())
{
_d = MarchingCubes.DensityAir;
}
return true;
}
/// <summary>Диагностика (03.10, «нож пропал со стола»): что стоит в клетке и есть ли у блока модель.</summary>
static void LogCell(World _world, Vector3i _wp, string _what)
{
BlockValue bv = _world.GetBlock(_wp);
string s = "[NecromancerTome] MindPalace: " + _what + " " + _wp + " - " + bv.Block.GetBlockName() + " пов. " + bv.rotation;
Chunk c = _world.GetChunkFromWorldPos(_wp) as Chunk;
BlockEntityData be = c?.GetBlockEntity(_wp);
if (be == null)
{
s += ", модели нет (нет BlockEntity)";
}
else if (!be.bHasTransform || be.transform == null)
{
s += ", модель ещё не создана";
}
else
{
Renderer[] rs = be.transform.GetComponentsInChildren<Renderer>(true);
int on = 0;
foreach (Renderer r in rs) if (r.enabled && r.gameObject.activeInHierarchy) on++;
s += ", модель " + be.transform.name + " в " + (be.transform.position + Origin.position) + ", активна " + be.transform.gameObject.activeInHierarchy + ", рендеров " + on + "/" + rs.Length;
}
Debug.Log(s);
}
/// <summary>Сколько клеток в мире не совпадает с префабом по типу блока (0 - чертоги целы).</summary>
public static int Mismatches(World _world, Vector3i _o, List<string> _report = null)
{
Prefab p = LoadPrefab();
if (p == null)
{
return -1;
}
int n = 0;
int shieldType = Block.GetBlockValue(ShellTo).type;
for (int z = 0; z < p.size.z; z++)
for (int x = 0; x < p.size.x; x++)
{
if (_world.GetBlock(_o + new Vector3i(x, -1, z)).type != shieldType)
{
n++;
}
}
for (int y = 0; y < p.size.y; y++)
for (int z = 0; z < p.size.z; z++)
for (int x = 0; x < p.size.x; x++)
{
Vector3i wp = _o + new Vector3i(x, y, z);
if (!Expected(p, x, y, z, out BlockValue want, out sbyte wantD))
{
// Дочерняя клетка: там не должно быть земли/камня.
BlockValue c = _world.GetBlock(wp);
if (c.Block.shape.IsTerrain() || _world.GetDensity(wp.x, wp.y, wp.z) < 0)
{
n++;
if (_report != null && _report.Count < 40)
{
_report.Add("(" + x + "," + y + "," + z + ") дочерняя клетка, стоит " + c.Block.GetBlockName() + " плотн. " + _world.GetDensity(wp.x, wp.y, wp.z));
}
}
continue;
}
BlockValue have = _world.GetBlock(wp);
// Плотность тоже: тип блока - воздух, а плотность земли - и клетка рисуется камнем
// (тест 03.10: «камень под столом, в дверном проёме, под потолком»).
sbyte haveD = _world.GetDensity(wp.x, wp.y, wp.z);
bool densityBad = (wantD < 0) != (haveD < 0);
// И покраска (указание 03.10: «покраску обязательно перенеси из префаба»).
bool paintBad = !want.isair && want.type == p.GetBlock(x, y, z).type &&
_world.GetTextureFullArray(wp.x, wp.y, wp.z) != p.GetTexture(x, y, z);
if (have.type != want.type || densityBad || paintBad)
{
n++;
if (_report != null && _report.Count < 40)
{
_report.Add("(" + x + "," + y + "," + z + ") ждали " + want.Block.GetBlockName() + " плотн. " + wantD +
", стоит " + have.Block.GetBlockName() + " плотн. " + haveD + (paintBad ? ", не та покраска" : ""));
}
}
}
return n;
}
public static bool IsBuilt(World _world, Vector3i _o)
{
return Mismatches(_world, _o) == 0;
}
/// <summary>Все двери чертогов - открыты (в редакторе сохранились закрытыми, а дух дверей не
/// открывает). Состояние двери - в её TEFeatureDoor, не в блоке.</summary>
public static void OpenDoors(World _world, Vector3i _o)
{
Vector3i s = Size;
int opened = 0;
for (int y = 0; y < s.y; y++)
for (int z = 0; z < s.z; z++)
for (int x = 0; x < s.x; x++)
{
if (_world.GetTileEntity(_o + new Vector3i(x, y, z)) is TileEntityComposite tec)
{
TEFeatureDoor door = tec.GetFeature<TEFeatureDoor>();
// Входная - закрыть (могла остаться открытой от прежних входов), остальные - открыть.
bool wantOpen = new Vector3i(x, y, z) != EntranceDoorAt;
if (door != null && door.IsOpen() != wantOpen)
{
door.SetOpen(wantOpen);
opened++;
}
}
}
if (opened > 0)
{
Debug.Log("[NecromancerTome] MindPalace: дверей переключено " + opened);
}
}
public static void Stamp(World _world, Vector3i _o)
{
Prefab p = LoadPrefab();
if (p == null)
{
return;
}
// ПОРЯДОК ВАЖЕН (тест 03.10: при очистке всей коробки разом на миг открывалась пустота
// 17x8x17, земля над ней и края теряли опору - обвал, падающие блоки ушли под мир):
// 1) слой bedrock под чертогами;
// 2) оболочка - пол (y 0), стены (края по x/z) и крыша - сразу на место земли, без очистки;
// 3) очистка внутренности (её уже держит оболочка) - в том числе дочерних клеток стола,
// дверей, шкафов, где иначе оставался камень;
// 4) всё внутреннее по префабу.
var list = new List<BlockChangeInfo>();
// 0) Рамка LooseMargin блоков вокруг чертогов, со всех сторон и сверху, - сплошь камнем
// (указание 03.10: «заполни сначала камнем, на 3 блока во все стороны»): земля, песок,
// гравий (осыпаются без опоры) и пустоты пещер -> terrStone. Не земля и не воздух
// (чужие постройки) - не трогаем.
BlockValue stone = Block.GetBlockValue("terrStone");
int loose = 0;
for (int y = -1 - LooseMargin; y < p.size.y + LooseMargin; y++)
for (int z = -LooseMargin; z < p.size.z + LooseMargin; z++)
for (int x = -LooseMargin; x < p.size.x + LooseMargin; x++)
{
bool inside = y >= -1 && y < p.size.y && x >= 0 && x < p.size.x && z >= 0 && z < p.size.z;
if (inside)
{
continue;
}
Vector3i wp = _o + new Vector3i(x, y, z);
if (wp.y < 1)
{
continue; // ниже 1 игра блоки не ставит (дно мира)
}
BlockValue have = _world.GetBlock(wp);
Block b = have.Block;
if (have.type != stone.type && (have.isair || b != null && b.shape.IsTerrain()))
{
list.Add(new BlockChangeInfo(wp, stone, MarchingCubes.DensityTerrain));
loose++;
}
}
if (loose > 0)
{
Debug.Log("[NecromancerTome] MindPalace: рамка вокруг залита камнем - " + loose + " клеток");
}
BlockValue shield = Block.GetBlockValue(ShellTo);
for (int z = 0; z < p.size.z; z++)
for (int x = 0; x < p.size.x; x++)
{
list.Add(new BlockChangeInfo(_o + new Vector3i(x, -1, z), shield, MarchingCubes.DensityTerrain));
}
for (int pass = 0; pass < 3; pass++)
for (int yi = 0; yi < p.size.y; yi++)
for (int z = 0; z < p.size.z; z++)
for (int x = 0; x < p.size.x; x++)
{
// Очистка (проход 1) - СВЕРХУ ВНИЗ: снизу вверх убранный слой оставлял над собой
// песок/гравий без опоры, тот падал и рушил уже поставленные стены (тест 03.10,
// necropalace test: 30 расхождений сразу, 309 - при следующем входе). Постановка -
// снизу вверх, на опору.
int y = pass == 1 ? p.size.y - 1 - yi : yi;
bool shellCell = y == 0 || y == p.size.y - 1 || x == 0 || z == 0 || x == p.size.x - 1 || z == p.size.z - 1;
Vector3i wp = _o + new Vector3i(x, y, z);
if (pass == 0 && shellCell || pass == 2 && !shellCell)
{
if (Expected(p, x, y, z, out BlockValue bv, out sbyte d))
{
// С покраской из префаба (указание 03.10).
list.Add(new BlockChangeInfo(wp, bv, d, p.GetTexture(x, y, z)));
}
}
else if (pass == 1 && !shellCell)
{
list.Add(new BlockChangeInfo(wp, BlockValue.Air, MarchingCubes.DensityAir));
}
}
_world.SetBlocksRPC(list);
Debug.Log("[NecromancerTome] MindPalace: чертоги поставлены в " + _o + ", блоков " + list.Count);
}
static Vector3i Size => _prefab != null ? _prefab.size : new Vector3i(17, 8, 17);
/// <summary>Проверка постановки в нетронутую землю (указание 03.10): залить камнем коробку на
/// 2 блока шире чертогов во все стороны (включая слой bedrock снизу), подождать, поставить
/// чертоги заново, выставить двери. Сразу заливкой, без предварительной очистки - очистка и
/// есть та пустота без опоры, из-за которой был обвал.</summary>
public static System.Collections.IEnumerator StressTest(World _world, Vector3i _o)
{
Vector3i s = Size;
// Гравием - худший случай: сыпучее, падает без опоры (указание 03.10).
BlockValue stone = Block.GetBlockValue("terrGravel");
var list = new List<BlockChangeInfo>();
for (int y = -3; y < s.y + 2; y++)
for (int z = -2; z < s.z + 2; z++)
for (int x = -2; x < s.x + 2; x++)
{
list.Add(new BlockChangeInfo(_o + new Vector3i(x, y, z), stone, MarchingCubes.DensityTerrain));
}
_world.SetBlocksRPC(list);
Debug.Log("[NecromancerTome] necropalace test: залито гравием " + list.Count + " клеток, ждём 2 с");
yield return new WaitForSeconds(2f);
Stamp(_world, _o);
yield return new WaitForSeconds(1f);
OpenDoors(_world, _o);
Debug.Log("[NecromancerTome] necropalace test: готово, расхождений с префабом " + Mismatches(_world, _o));
}
public static bool InAnyPalace(Vector3i _pos, int _margin = 0)
{
if (_slots.Count == 0)
{
return false;
}
Vector3i s = Size;
foreach (Vector3i o in _slots.Values)
{
if (_pos.x >= o.x - _margin && _pos.x < o.x + s.x + _margin &&
_pos.y >= o.y - 1 - _margin && _pos.y < o.y + s.y + _margin &&
_pos.z >= o.z - _margin && _pos.z < o.z + s.z + _margin)
{
return true;
}
}
return false;
}
// ---------------------------------------------------------------- вход и выход
public static void Enter(EntityPlayerLocal _player)
{
if (Active || SnakeMode.Active || _player == null || _player.IsDead() || _player.AttachedToEntity != null)
{
return;
}
if (!SnakeMode.IsNight())
{
Debug.Log("[NecromancerTome] MindPalace: днём чертоги недоступны");
return;
}
World world = GameManager.Instance.World;
if (world.IsRemote())
{
Debug.Log("[NecromancerTome] MindPalace: в мультиплеере у клиента пока недоступно");
return;
}
if (LoadPrefab() == null)
{
return;
}
_origin = GetSlot(world, _player);
Vector3 center = _origin.ToVector3() + new Vector3(Size.x * 0.5f, 0f, Size.z * 0.5f);
// Дальность как у змеи: при радиусе 2 игра не строила коллизии участка (тест 03.10:
// «чертоги не построились вовремя» - ждали коллизий 30 с).
_observer = GameManager.Instance.AddChunkObserver(center, true, GameUtils.GetViewDistance(), -1);
_stage = Stage.Loading;
_deadline = Time.time + 30f;
Active = true;
_player.ScreenEffectManager?.SetScreenEffect("FadeToBlack", 1f, 0.6f);
_player.SetFirstPersonView(false, true);
_ragdollAt = Time.time + SnakeMode.RagdollDelay;
_waitStandUp = false;
Debug.Log("[NecromancerTome] MindPalace: вход, чертоги в " + _origin);
}
public static void Exit(EntityPlayerLocal _player)
{
if (!Active)
{
return;
}
Active = false;
_stage = Stage.None;
MindControl.End(_player);
RemoveObserver();
_player?.ScreenEffectManager?.SetScreenEffect("FadeToBlack", 0f, 1f);
if (_player == null || _player.IsDead())
{
return;
}
_ragdollAt = 0f;
_player.emodel.ragdollTime = _player.emodel.ragdollDuration;
_waitStandUp = true;
_standUpDeadline = Time.time + 5f;
Debug.Log("[NecromancerTome] MindPalace: выход");
}
static void RemoveObserver()
{
if (_observer != null && GameManager.Instance?.World != null)
{
GameManager.Instance.RemoveChunkObserver(_observer);
}
_observer = null;
}
static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data)
{
World world = GameManager.Instance?.World;
if (world == null)
{
Active = false;
_stage = Stage.None;
_observer = null;
MindControl.End(null);
_slotsFor = null;
return;
}
EntityPlayerLocal player = world.GetPrimaryPlayer();
Guard(world);
if (!Active)
{
if (_waitStandUp && player != null && (!player.emodel.IsRagdollActive || Time.time > _standUpDeadline))
{
_waitStandUp = false;
player.SetFirstPersonView(true, true);
}
return;
}
if (player == null)
{
return;
}
if (player.IsDead())
{
Debug.Log("[NecromancerTome] MindPalace: тело убито");
Exit(player);
return;
}
if (!SnakeMode.IsNight())
{
Debug.Log("[NecromancerTome] MindPalace: наступило утро - обратно в тело");
Exit(player);
return;
}
Body(player);
switch (_stage)
{
case Stage.Loading:
if (ChunksLoaded(world, _origin, Size))
{
var report = new List<string>();
int bad = Mismatches(world, _origin, report);
if (bad != 0)
{
Debug.Log("[NecromancerTome] MindPalace: расхождений с префабом " + bad + " - ставим заново");
for (int i = 0; i < report.Count && i < 10; i++)
{
Debug.Log("[NecromancerTome] MindPalace: " + report[i]);
}
Stamp(world, _origin);
}
_stage = Stage.Settling;
_settleUntil = Time.time + 1.5f;
}
else if (Time.time > _deadline)
{
Debug.LogWarning("[NecromancerTome] MindPalace: участки чертогов не загрузились");
Exit(player);
}
break;
case Stage.Settling:
Vector3 spawn = _origin.ToVector3() + SpawnAt;
// Коллизии готовы - или прошло 5 с: тогда всё равно пускаем, от провала страхует
// MindControl (возврат на точку появления).
if (Time.time > _settleUntil && (world.IsChunkAreaCollidersLoaded(spawn) || Time.time > _settleUntil + 5f))
{
Debug.Log("[NecromancerTome] MindPalace: коллизии " + (world.IsChunkAreaCollidersLoaded(spawn) ? "готовы" : "НЕ готовы, пускаем по таймеру"));
_stage = Stage.Ready;
OpenDoors(world, _origin);
LogCell(world, _origin + KnifeAt, "нож");
LogCell(world, _origin + BookAt, "книга");
MindControl.Begin(player, spawn, SpawnYaw);
player.ScreenEffectManager?.SetScreenEffect("FadeToBlack", 0f, 1.5f);
}
else if (Time.time > _deadline)
{
Debug.LogWarning("[NecromancerTome] MindPalace: чертоги не построились вовремя");
Exit(player);
}
break;
case Stage.Ready:
MindControl.Update(player);
break;
}
// Выход по R - когда тело уже лежит и не открыто окно (как у змеи).
PlayerActionsLocal input = player.playerInput;
bool gui = player.PlayerUI != null && player.PlayerUI.windowManager.IsModalWindowOpen();
if (_ragdollAt <= 0f && player.emodel.IsRagdollActive && input != null && !gui &&
(input.Reload.WasPressed || input.PermanentActions.Reload.WasPressed))
{
Exit(player);
}
}
/// <summary>Тело падает и лежит - как в режиме змеи (SnakeMode.OnUnityUpdate).</summary>
static void Body(EntityPlayerLocal _player)
{
if (_ragdollAt > 0f)
{
if (Time.time >= _ragdollAt)
{
_ragdollAt = 0f;
_player.emodel.DoRagdoll(EModelBase.RagdollMode.Default, 999999f, EnumBodyPartHit.None, Vector3.zero, Vector3.zero, false);
}
}
else if (!_player.emodel.IsRagdollActive)
{
_player.emodel.DoRagdoll(EModelBase.RagdollMode.Default, 999999f, EnumBodyPartHit.None, Vector3.zero, Vector3.zero, false);
}
}
/// <summary>Раз в секунду: всё живое в коробке чертогов - вон. Дух сущности не имеет.</summary>
static void Guard(World _world)
{
_guardCheck -= Time.deltaTime;
if (_guardCheck > 0f || _world.IsRemote())
{
return;
}
_guardCheck = 1f;
LoadSlots();
if (_slots.Count == 0)
{
return;
}
// Игроков в EntityAlives нет (World.SpawnEntityInWorld кладёт туда только не-игроков) - их
// отдельно, по Players.list (тест 03.10: камень портала привёл в чертоги, охрана молчала).
for (int i = _world.Players.list.Count - 1; i >= 0; i--)
{
EntityPlayer pl = _world.Players.list[i];
if (pl == null || pl.IsDead() || !InAnyPalace(World.worldToBlockPos(pl.position)))
{
continue;
}
Vector3i b = World.worldToBlockPos(pl.position);
Vector3 up = new Vector3(pl.position.x, _world.GetTerrainHeight(b.x, b.z) + 2f, pl.position.z);
Debug.Log("[NecromancerTome] MindPalace: игрок " + pl.entityId + " в чертогах - выброшен на " + up);
if (pl is EntityPlayerLocal)
{
// Как камень портала (PortalStonePatch.TeleportToBedroll).
NetPackageManager.GetPackage<NetPackageTeleportPlayer>().Setup(up, null).ProcessPackage(_world, GameManager.Instance);
}
else
{
pl.Teleport(up);
}
}
for (int i = _world.EntityAlives.Count - 1; i >= 0; i--)
{
EntityAlive e = _world.EntityAlives[i];
if (e == null || e.IsDead() || !InAnyPalace(World.worldToBlockPos(e.position)))
{
continue;
}
EntityAlive snake = SnakeMode.Snake(_world);
if (snake != null && e == snake)
{
Debug.Log("[NecromancerTome] MindPalace: змея в чертогах - развеяна");
SnakeMode.Exit(_world.GetPrimaryPlayer());
}
else
{
Debug.Log("[NecromancerTome] MindPalace: " + e.EntityName + " в чертогах - удалён");
_world.RemoveEntity(e.entityId, EnumRemoveEntityReason.Despawned);
}
}
}
}
/// <summary>В чертогах и в рамке камня вокруг ничего не падает (указание 03.10: «почему не
/// отключить просчёт физики в той области»): World.AddFallingBlock - единственная очередь
/// падающих блоков (туда же игра сама не пускает блоки со StabilityIgnore). Тест гравием
/// 03.10: после постановки нижний/верхний ряд внутренней стены всё равно обрушивался.</summary>
[HarmonyPatch(typeof(World), nameof(World.AddFallingBlock))]
public static class Patch_World_AddFallingBlock_MindPalace
{
public static bool Prefix(Vector3i _blockPos)
{
return !MindPalace.InAnyPalace(_blockPos, MindPalace.LooseMargin);
}
}
/// <summary>Чертоги - «зона торговца»: блоки не ломаются ударом и взрывом, строить нельзя
/// (ItemActionAttack, Explosion, Block.DamageBlock проверяют IsWithinTraderArea).</summary>
[HarmonyPatch(typeof(World), nameof(World.IsWithinTraderArea), new[] { typeof(Vector3i) })]
public static class Patch_World_IsWithinTraderArea_MindPalace
{
public static void Postfix(Vector3i _worldBlockPos, ref bool __result)
{
if (!__result && MindPalace.InAnyPalace(_worldBlockPos))
{
__result = true;
}
}
}
/// <summary>
/// necropalace - отладка чертогов: where (где мои), rebuild (поставить заново, если участки
/// загружены), knife N (поворот ножа 0-23 и переставить его).
/// </summary>
[Preserve]
public class ConsoleCmdNecroPalace : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necropalace" };
public override string getDescription() => "NecromancerTome: чертоги разума - where | scan | rebuild | test | knife N";
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
World world = GameManager.Instance.World;
EntityPlayerLocal player = world?.GetPrimaryPlayer();
if (player == null)
{
return;
}
Vector3i o = Traverse.Create(typeof(MindPalace)).Method("GetSlot", world, (EntityPlayer)player).GetValue<Vector3i>();
string cmd = _params.Count > 0 ? _params[0] : "where";
if (cmd == "knife" && _params.Count > 1 && int.TryParse(_params[1], out int r))
{
MindPalace.KnifeRotation = Mathf.Clamp(r, 0, 3);
BlockValue k = Block.GetBlockValue(MindPalace.KnifeBlock);
k.rotation = (byte)MindPalace.KnifeRotation;
world.SetBlockRPC(o + MindPalace.KnifeAt, k, MarchingCubes.DensityAir);
SdtdConsole.Instance.Output("нож: поворот " + MindPalace.KnifeRotation);
}
else if (cmd == "scan")
{
var report = new List<string>();
int n = MindPalace.Mismatches(world, o, report);
SdtdConsole.Instance.Output("расхождений с префабом: " + n);
foreach (string line in report)
{
SdtdConsole.Instance.Output(line);
Debug.Log("[NecromancerTome] necropalace scan: " + line);
}
}
else if (cmd == "test")
{
GameManager.Instance.StartCoroutine(MindPalace.StressTest(world, o));
SdtdConsole.Instance.Output("проверка: заливка гравием, через 2 с - постановка (итог - в логе)");
}
else if (cmd == "rebuild")
{
MindPalace.Stamp(world, o);
SdtdConsole.Instance.Output("чертоги поставлены заново в " + o);
}
else
{
SdtdConsole.Instance.Output("чертоги: " + o + ", построены: " + MindPalace.IsBuilt(world, o));
}
}
}
}
+4
View File
@@ -61,6 +61,10 @@ namespace NecromancerTome
// hooks a spawn event - see that file for why the SDCS-built trader forces it.
GhostTraderPatch.Init();
SnakeMode.Init();
MindPalace.Init();
HandPose.Init();
NecroMailbox.Init();
Possession.Init();
// PyramidWardPatch.cs's TEFeaturePyramidWard needs no Init() call - it's discovered
// automatically by the engine's own TileEntityCompositeData reflection scan (see that
// file's class doc comment), not registered here like PetFollowPatch's UnityUpdate hook.
+72 -5
View File
@@ -37,6 +37,15 @@ namespace NecromancerTome
public static Vector3 SnakePos;
public float Until;
/// <summary>Своя угроза вместо змеи (хват «отрицание», 03.10): бежать от этой точки.</summary>
public bool HasThreat;
public Vector3 Threat;
public float Speed = FleeSpeed;
/// <summary>Кого забыть, когда бегство кончится (хват «отрицание»: «должен терять меня как цель»).
/// Он же угроза: бежать от его ТЕКУЩЕГО места.</summary>
public EntityAlive Forget;
/// <summary>Больше нуля - бежать, пока Forget ближе этого (и не дольше Until).</summary>
public float FarDistance;
Vector3 targetPos;
int retargetTicks;
@@ -49,7 +58,7 @@ namespace NecromancerTome
public override bool CanExecute()
{
return Time.time < Until && FindFleePos();
return Time.time < Until && !FarEnough() && FindFleePos();
}
/// <summary>Сколько криков тревоги уже было в этом кадре: у толпы кричат не все.</summary>
@@ -78,13 +87,18 @@ namespace NecromancerTome
public override void Reset()
{
if (Forget != null)
{
ForgetNow(theEntity, Forget);
Forget = null;
}
theEntity.moveHelper.Stop();
theEntity.SetLookPosition(Vector3.zero);
}
public override bool Continue()
{
return Time.time < Until && !theEntity.IsDead();
return Time.time < Until && !theEntity.IsDead() && !FarEnough();
}
float _diagTime;
@@ -104,7 +118,7 @@ namespace NecromancerTome
Debug.Log("[NecromancerTome] NecroFleeSnakeTask: " + theEntity.entityId + " поз " + theEntity.position.ToString("F0") +
" цель " + targetPos.ToString("F0") + " путь " + (path == null ? "нет" : path.NodeCountRemaining() + " узл.") +
" считается " + PathFinderThread.Instance.IsCalculatingPath(theEntity.entityId) +
" скор " + FleeSpeed.ToString("F2"));
" скор " + Speed.ToString("F2"));
}
if (--retargetTicks > 0 || PathFinderThread.Instance.IsCalculatingPath(theEntity.entityId))
{
@@ -113,7 +127,7 @@ namespace NecromancerTome
retargetTicks = RetargetTicks;
var sw = System.Diagnostics.Stopwatch.StartNew();
FindFleePos();
theEntity.FindPath(targetPos, FleeSpeed, true, this);
theEntity.FindPath(targetPos, Speed, true, this);
if (sw.Elapsed.TotalMilliseconds > 3.0)
{
Debug.Log("[NecromancerTome] NecroFleeSnakeTask: долгий перерасчёт у " + theEntity.entityId + " - " + sw.Elapsed.TotalMilliseconds.ToString("F1") + " мс");
@@ -122,7 +136,11 @@ namespace NecromancerTome
bool FindFleePos()
{
Vector3 away = theEntity.position - SnakePos;
if (Forget != null && FarDistance > 0f)
{
Threat = Forget.position;
}
Vector3 away = theEntity.position - (HasThreat ? Threat : SnakePos);
away.y = 0f;
if (away.sqrMagnitude < 0.01f)
{
@@ -166,9 +184,58 @@ namespace NecromancerTome
_e.aiManager.tasks.AddTask(Priority, task);
Debug.Log("[NecromancerTome] NecroFleeSnakeTask: зомби " + _e.entityId + " в ужасе");
}
task.HasThreat = false;
task.Speed = FleeSpeed;
task.Forget = null;
task.FarDistance = 0f;
task.Until = Time.time + FearSeconds;
}
/// <summary>Хват «отрицание» (03.10): напугать зомби жестом игрока - бежит от игрока быстрее обычного
/// _seconds секунд, сразу и в конце бегства теряет игрока как цель.</summary>
bool FarEnough()
{
return FarDistance > 0f && Forget != null && (theEntity.position - Forget.position).sqrMagnitude > FarDistance * FarDistance;
}
public static void ScareFrom(EntityAlive _e, EntityAlive _threat, float _seconds, float _speed, float _farDistance = 0f)
{
if (_e.aiManager == null)
{
return;
}
_e.ConditionalTriggerSleeperWakeUp();
NecroFleeSnakeTask task = _e.aiManager.tasks.GetTask<NecroFleeSnakeTask>();
if (task == null)
{
task = new NecroFleeSnakeTask();
task.Init(_e);
_e.aiManager.tasks.AddTask(Priority, task);
}
task.HasThreat = true;
task.Threat = _threat.position;
task.Speed = _speed;
task.Forget = _threat;
task.FarDistance = _farDistance;
task.Until = Time.time + _seconds;
ForgetNow(_e, _threat);
// Сетка поиска пути строится вокруг игроков - здесь она есть; но зомби может быть на краю.
AstarManager.Instance?.AddLocation(_e.position, 40);
Debug.Log("[NecromancerTome] NecroFleeSnakeTask: зомби " + _e.entityId + " отрицание - бежит до " + _farDistance + " м (не дольше " + _seconds + " с), скор " + _speed);
}
public static void ForgetNow(EntityAlive _e, EntityAlive _who)
{
if (_e.GetAttackTarget() == _who)
{
_e.SetAttackTarget(null, 0);
}
if (_e.GetRevengeTarget() == _who)
{
_e.SetRevengeTarget(null);
}
}
public override string ToString()
{
return base.ToString() + ", flee to " + targetPos + ", " + (Until - Time.time).ToString("F1") + " s";
+91
View File
@@ -0,0 +1,91 @@
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Почтовый ящик консоли - только для разработки (03.10: ассистент подбирает позу рук сам, без
/// клавиатуры). Работает, лишь если есть папка %APPDATA%\7DaysToDie\NecroMailbox - у игроков её
/// нет. Раз в полсекунды: команды из cmd.txt (по строке) выполняются в консоли игры, как набранные
/// вручную, файл удаляется, ответ - в лог строками «[NecromancerTome] mailbox:».
/// </summary>
public static class NecroMailbox
{
public static string Dir => Path.Combine(GameIO.GetUserGameDataDir(), "NecroMailbox");
static string CmdFile => Path.Combine(Dir, "cmd.txt");
static float _next;
public static void Init()
{
ModEvents.UnityUpdate.RegisterHandler(OnUnityUpdate);
}
static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data)
{
if (Time.unscaledTime < _next)
{
return;
}
_next = Time.unscaledTime + 0.5f;
if (!File.Exists(CmdFile) || SdtdConsole.Instance == null)
{
return;
}
string[] lines;
try
{
lines = File.ReadAllLines(CmdFile);
File.Delete(CmdFile);
}
catch (IOException)
{
return; // файл ещё пишется - в следующий раз
}
foreach (string raw in lines)
{
string line = raw.Trim();
if (line.Length == 0)
{
continue;
}
Debug.Log("[NecromancerTome] mailbox: > " + line);
try
{
List<string> res = SdtdConsole.Instance.ExecuteSync(line, null);
if (res != null)
{
foreach (string r in res)
{
Debug.Log("[NecromancerTome] mailbox: " + r);
}
}
}
catch (System.Exception ex)
{
Debug.LogWarning("[NecromancerTome] mailbox: ошибка - " + ex);
}
}
}
}
/// <summary>necroshot [имя] - снимок экрана игры в %APPDATA%\7DaysToDie\NecroMailbox\shots\имя.png
/// (как F9, только в известное место). Файл появляется в конце кадра.</summary>
[Preserve]
public class ConsoleCmdNecroShot : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necroshot" };
public override string getDescription() => "NecromancerTome: снимок экрана в NecroMailbox/shots/<имя>.png";
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
string dir = Path.Combine(NecroMailbox.Dir, "shots");
Directory.CreateDirectory(dir);
string name = _params.Count > 0 ? _params[0] : System.DateTime.Now.ToString("HHmmss");
string path = Path.Combine(dir, name + ".png");
ScreenCapture.CaptureScreenshot(path);
SdtdConsole.Instance.Output("снимок: " + path);
}
}
}
+189
View File
@@ -0,0 +1,189 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Книга с оглавлением (03.10.2026, BACKLOG «Книга с оглавлением и главами»). Окно necroTome
/// (Config/XUi_InGame/windows.xml): оглавление - кнопки глав (до восьми), глава - страницы
/// с «Назад/Далее», «Оглавление» возвращает к списку, «Закрыть» и Esc закрывают.
///
/// СОДЕРЖАНИЕ - список Chapters ниже: ключ названия, ключи страниц (Localization.csv,
/// necroTome*), условие открытия. Закрытая глава в оглавлении не показывается. Условия брать
/// из того, что игра уже хранит (уровень некромантии, рецепты, испытания), своё хранение - только
/// для чистых событий, по cvar на событие.
/// </summary>
public static class NecroTomeBook
{
public const string WindowGroup = "necroTome";
public class Chapter
{
public string TitleKey;
public string[] PageKeys;
public Func<EntityPlayerLocal, bool> Unlocked;
}
public static readonly List<Chapter> Chapters = new List<Chapter>
{
// Глава 1 (указание 03.10): статья о некроэнергии, сокращённая - без примеров и бытовой речи.
new Chapter
{
TitleKey = "necroTomeCh1Title",
PageKeys = new[] { "necroTomeCh1P1", "necroTomeCh1P2", "necroTomeCh1P3", "necroTomeCh1P4" },
Unlocked = _p => true
},
};
public static void Open(EntityPlayerLocal _player)
{
LocalPlayerUI ui = LocalPlayerUI.GetUIForPlayer(_player);
ui?.windowManager.Open(WindowGroup, true);
}
}
[Preserve]
public class XUiC_NecroTome : XUiController
{
const int MaxButtons = 8;
XUiV_Label _title, _text, _page;
XUiC_SimpleButton _toc, _prev, _next, _close;
readonly XUiC_SimpleButton[] _chapterButtons = new XUiC_SimpleButton[MaxButtons];
/// <summary>Глава по кнопке оглавления (только открытые главы, по порядку).</summary>
readonly int[] _buttonChapter = new int[MaxButtons];
int _chapter = -1, _pageIndex;
public override void Init()
{
base.Init();
_title = GetChildById("lblTitle")?.ViewComponent as XUiV_Label;
_text = GetChildById("lblText")?.ViewComponent as XUiV_Label;
_page = GetChildById("lblPage")?.ViewComponent as XUiV_Label;
_toc = GetChildById("btnToc") as XUiC_SimpleButton;
_prev = GetChildById("btnPrev") as XUiC_SimpleButton;
_next = GetChildById("btnNext") as XUiC_SimpleButton;
_close = GetChildById("btnClose") as XUiC_SimpleButton;
if (_toc != null) _toc.OnPressed += (_s, _m) => ShowToc();
if (_prev != null) _prev.OnPressed += (_s, _m) => Turn(-1);
if (_next != null) _next.OnPressed += (_s, _m) => Turn(1);
if (_close != null) _close.OnPressed += (_s, _m) => xui.playerUI.windowManager.Close(NecroTomeBook.WindowGroup);
for (int i = 0; i < MaxButtons; i++)
{
int idx = i;
_chapterButtons[i] = GetChildById("btnCh" + (i + 1)) as XUiC_SimpleButton;
if (_chapterButtons[i] != null)
{
_chapterButtons[i].OnPressed += (_s, _m) => ShowChapter(_buttonChapter[idx], 0);
}
}
}
public override void OnOpen()
{
base.OnOpen();
ShowToc();
}
void ShowToc()
{
_chapter = -1;
SetLabel(_title, Localization.Get("necroTomeToc"));
SetLabel(_text, "");
SetLabel(_page, "");
EntityPlayerLocal player = xui.playerUI.entityPlayer;
int b = 0;
for (int c = 0; c < NecroTomeBook.Chapters.Count && b < MaxButtons; c++)
{
NecroTomeBook.Chapter ch = NecroTomeBook.Chapters[c];
if (ch.Unlocked != null && !ch.Unlocked(player))
{
continue;
}
_buttonChapter[b] = c;
if (_chapterButtons[b] != null)
{
_chapterButtons[b].Text = Localization.Get(ch.TitleKey);
_chapterButtons[b].ViewComponent.IsVisible = true;
}
b++;
}
for (; b < MaxButtons; b++)
{
if (_chapterButtons[b] != null)
{
_chapterButtons[b].ViewComponent.IsVisible = false;
}
}
SetVisible(_toc, false);
SetVisible(_prev, false);
SetVisible(_next, false);
}
void ShowChapter(int _c, int _p)
{
if (_c < 0 || _c >= NecroTomeBook.Chapters.Count)
{
return;
}
NecroTomeBook.Chapter ch = NecroTomeBook.Chapters[_c];
_chapter = _c;
_pageIndex = Mathf.Clamp(_p, 0, ch.PageKeys.Length - 1);
foreach (XUiC_SimpleButton btn in _chapterButtons)
{
SetVisible(btn, false);
}
SetLabel(_title, Localization.Get(ch.TitleKey));
SetLabel(_text, Localization.Get(ch.PageKeys[_pageIndex]));
SetLabel(_page, (_pageIndex + 1) + " / " + ch.PageKeys.Length);
SetVisible(_toc, true);
SetVisible(_prev, true);
SetVisible(_next, true);
if (_prev != null) _prev.Enabled = _pageIndex > 0;
if (_next != null) _next.Enabled = _pageIndex < ch.PageKeys.Length - 1;
}
void Turn(int _d)
{
if (_chapter >= 0)
{
ShowChapter(_chapter, _pageIndex + _d);
}
}
static void SetLabel(XUiV_Label _l, string _s)
{
if (_l != null)
{
_l.Text = _s;
}
}
static void SetVisible(XUiController _c, bool _v)
{
if (_c?.ViewComponent != null)
{
_c.ViewComponent.IsVisible = _v;
}
}
}
/// <summary>necrotome - открыть книгу (проверка окна без чертогов, в том числе через почтовый ящик).</summary>
[Preserve]
public class ConsoleCmdNecroTome : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necrotome" };
public override string getDescription() => "NecromancerTome: открыть книгу с оглавлением";
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
EntityPlayerLocal player = GameManager.Instance.World?.GetPrimaryPlayer();
if (player != null)
{
NecroTomeBook.Open(player);
}
}
}
}
+4
View File
@@ -28,6 +28,10 @@
<HintPath>..\..\..\7DaysToDie_Data\Managed\Unity.Addressables.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine.ScreenCaptureModule">
<HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.ScreenCaptureModule.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine.CoreModule">
<HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.CoreModule.dll</HintPath>
<Private>false</Private>
+5
View File
@@ -153,6 +153,11 @@ namespace NecromancerTome
{
return;
}
// Одержимый зомби, пока в нём игрок, в некромантию не идёт (решение 03.10).
if (Possession.IsPossessed(killedEntity))
{
return;
}
float kills = AddKillsCVar(__instance);
SyncNecromancyLevel(__instance, kills);
+688
View File
@@ -0,0 +1,688 @@
using GamePath;
using System.Collections.Generic;
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Одержимость - заклинание хвата «пиф-паф» (решения пользователя 03.10, BACKLOG «Одержимость»).
/// ЛКМ по зомби-гуманоиду (EntityZombie и наследники; не звери и не стервятники) - игрок перехватывает
/// управление им: бегает, прыгает, бьёт. Тело игрока не падает, а цепенеет и стоит (аниматор на паузе,
/// ввод снят) - его могут атаковать, это и есть цена. Любое время суток, и в кровавую луну.
/// Остальные зомби, увидев одержимого, бросаются на него, как на игрока, - можно танковать.
/// Выход - R, гибель одержимого или смерть тела. После выхода зомби пугается и убегает, как при
/// «отрицании». Убийства одержимым игроку не засчитываются (урон от его имени), его собственная
/// смерть - тоже (NecromancyKillCreditPatch).
///
/// КАК ВЕДЁМ ЗОМБИ. Его ИИ выключен (EntityAlive.updateTasks пропущен), а движение - родное:
/// SetMoveForwardWithModifiers, как делает сама игра при DebugStopEnemiesMoving и EntityMoveHelper;
/// прыжок - Jumping + SetJumpDistance, как EntityMoveHelper.StartJump; удар - Attack(false/true),
/// как EAIApproachAndAttackTarget. Походка, анимация и урон - самого зомби.
///
/// КАК СТОИТ ТЕЛО. PlayerMoveController.Update пропущен (и сам вызывает stopMoving, как у тела в
/// рэгдолле), вид - от третьего лица, аниматор модели на паузе. Камера - за головой зомби (постфикс
/// к vp_FPCamera.LateUpdate, как у змеи).
///
/// ПРИЁМЫ ЗОМБИ (указание 03.10: «у копа есть плевок и подрыв - чтобы такие спеллы становились доступны»):
/// ПКМ - дальняя атака, если у зомби есть задача ИИ EAIRangedAttackTarget (Ранчер, Чак, коп, мутант):
/// её параметры берутся из задачи захваченного зомби, а шаги повторяют её Start/Update/Reset -
/// замах (StartAnimAction startAnimType+3000), отпуск (ContinueAnimAction +1), выстрел родным
/// оружием (UseHoldingItem itemType), пока держишь кнопку и не вышло attackDuration. Направление -
/// точка под прицелом камеры (SetLookPosition), как у ИИ - голова цели.
/// Пробел - прыжок на собственную дальность зомби (jumpMaxDistance из JumpMaxDistance): у паука 7-9 м,
/// как его EAILeap, у остальных их 1-4 м.
/// ПКМ у крикуньи - её крик (SoundAlert): все зомби в ScreamRange бросаются на неё, даже не видя (её
/// ванильный зов орды - часть разведки AIScoutHordeSpawner, напрямую не вызываем).
/// E - подрыв у EntityZombieCop (коп, мутант, подрывник): HandlePrimingDetonator, через несколько
/// секунд взрыв от имени зомби - он гибнет, игрок возвращается в тело.
///
/// ПАМЯТЬ. Ничего не сохраняется. Ссылки - только на зомби, тело и замороженные аниматоры, на время
/// одержимости; на выходе и при выходе из мира всё обнуляется.
/// </summary>
public static class Possession
{
public static bool Active;
public static EntityAlive Zombie;
/// <summary>Камера - позади и выше головы зомби.</summary>
public const float CamBack = 2.6f;
public const float CamUp = 0.6f;
/// <summary>Как далеко зомби замечают одержимого: зрение зомби - 30 м (entityclasses.xml SightRange).</summary>
public const float TauntRange = 30f;
public const float TauntPeriod = 1f;
public const int TauntTicks = 1200;
/// <summary>Сколько чанков держать вокруг тела, пока игрок в зомби (указание 03.10: «сильно ограничить
/// видимость у тела - ему вообще нет смысла что-либо видеть»). Тест 03.10: две полные области обзора
/// (тело + одержимый) - память росла с каждым шагом зомби, а выход давал подвисание ~50 с. Трёх чанков
/// хватает, чтобы тело стояло на земле и зомби могли к нему подойти.</summary>
public const int BodyViewDim = 3;
static int _bodyViewDim = -1;
/// <summary>Полный радиус области игрока (до сжатия) - для систем, рисующих мир у одержимого
/// (SnakeAudioSwap: дальний ландшафт, Dynamic Mesh, детализация зданий). -1 - не сжата.</summary>
public static int FullViewDim => _bodyViewDim;
public const float ScreamRange = 50f;
public const float ScreamCooldown = 10f;
static float _nextScream;
static EntityPlayerLocal _player;
static float _yaw, _pitch;
/// <summary>Куда повёрнуто тело зомби. Шага вбок и назад у зомби нет (тест 03.10: «не работают A, D и S») -
/// при движении тело разворачивается в сторону шага и идёт вперёд, без движения - смотрит туда же, куда камера.</summary>
static float _bodyYaw;
public const float TurnSpeed = 540f;
static float _nextAttack;
static float _nextTaunt;
static ChunkManager.ChunkObserver _observer;
static readonly List<Animator> _frozen = new List<Animator>();
static readonly List<float> _frozenSpeed = new List<float>();
static bool _loggedCamera;
// Дальняя атака захваченного зомби (копия параметров его EAIRangedAttackTarget).
static bool _hasRanged;
static int _rItem, _rStartAnim;
static float _rRelease, _rDuration, _rCooldown;
static string _rSoundStart, _rSoundRelease;
enum RState { Idle, StartAnim, Release, Fire }
static RState _rState;
static float _rTime, _rNext;
static GameObject _hud;
public static void Init()
{
ModEvents.UnityUpdate.RegisterHandler(OnUnityUpdate);
if (_hud == null)
{
_hud = new GameObject("NecroPossessionHud");
Object.DontDestroyOnLoad(_hud);
_hud.AddComponent<PossessionHud>();
}
}
public static bool IsPossessed(Entity _e) => Active && _e != null && Zombie != null && _e.entityId == Zombie.entityId;
/// <summary>Только зомби-гуманоиды (решение 03.10: «одержимая собака или стервятник - такое себе»).</summary>
public static bool CanPossess(EntityAlive _e) => _e is EntityZombie && !_e.IsDead() && HandHolds.IsZombie(_e);
/// <summary>True - вселились (для звука «пиф-пафа»).</summary>
public static bool Enter(EntityPlayerLocal _player0, EntityAlive _zombie)
{
if (Active || SnakeMode.Active || MindPalace.Active || _player0 == null || _player0.IsDead() ||
_player0.AttachedToEntity != null || !CanPossess(_zombie) || _player0.world.IsRemote())
{
return false;
}
Active = true;
_player = _player0;
Zombie = _zombie;
_yaw = _player0.rotation.y;
_bodyYaw = _zombie.rotation.y;
_pitch = 0f;
_nextAttack = 0f;
_nextTaunt = 0f;
_nextScream = 0f;
_loggedCamera = false;
// Зомби - в руки игрока: цели, путь и шаги ИИ сброшены.
_zombie.SetAttackTarget(null, 0);
_zombie.SetRevengeTarget(null);
PathFinderThread.Instance.RemovePathsFor(_zombie.entityId);
_zombie.navigator?.clearPath();
_zombie.moveHelper?.Stop();
_zombie.ConditionalTriggerSleeperWakeUp();
_zombie.SetRotation(new Vector3(0f, _yaw, 0f));
// Тело цепенеет: вид от третьего лица, аниматоры модели на паузе.
_player0.SetFirstPersonView(false, true);
FreezeBody(_player0, true);
_observer = GameManager.Instance.AddChunkObserver(_zombie.position, true, GameUtils.GetViewDistance(), -1);
ChunkManager.ChunkObserver body = _player0.ChunkObserver;
if (body != null && body.viewDim > BodyViewDim)
{
_bodyViewDim = body.viewDim;
body.viewDim = BodyViewDim;
}
ReadRanged(_zombie);
Hint(_player0, _zombie);
Debug.Log("[NecromancerTome] Possession: вселение в " + _zombie.EntityName + " (" + _zombie.entityId + ")");
return true;
}
public static void Exit(string _why)
{
if (!Active)
{
return;
}
Active = false;
EntityAlive z = Zombie;
EntityPlayerLocal player = _player;
if (_rState != RState.Idle && z != null && !z.IsDead())
{
StopRanged(z);
}
_rState = RState.Idle;
_hasRanged = false;
Zombie = null;
_player = null;
Debug.Log("[NecromancerTome] Possession: выход - " + _why);
FreezeBody(player, false);
if (_bodyViewDim > 0 && player?.ChunkObserver != null)
{
player.ChunkObserver.viewDim = _bodyViewDim;
}
_bodyViewDim = -1;
if (player != null && !player.IsDead())
{
player.SetFirstPersonView(true, true);
}
if (_observer != null && GameManager.Instance?.World != null)
{
GameManager.Instance.RemoveChunkObserver(_observer);
}
_observer = null;
World world = GameManager.Instance?.World;
if (world == null || z == null)
{
return;
}
// Кто гнался за одержимым - отпускает его.
for (int i = 0; i < world.EntityAlives.Count; i++)
{
EntityAlive e = world.EntityAlives[i];
if (e != null && e != z && e.GetAttackTarget() == z)
{
e.SetAttackTarget(null, 0);
}
}
// Сам зомби в ужасе бежит от тела игрока (решение 03.10: «пугается и убегает как при отрицании»).
if (!z.IsDead() && player != null)
{
z.SetMoveForwardWithModifiers(0f, 0f, 0f, false);
NecroFleeSnakeTask.ScareFrom(z, player, HandHolds.DenyMaxSeconds, HandHolds.DenySpeed, HandHolds.DenyFarDistance);
}
}
static void FreezeBody(EntityPlayerLocal _p, bool _freeze)
{
if (_freeze)
{
_frozen.Clear();
_frozenSpeed.Clear();
Transform model = _p?.emodel?.GetModelTransform();
if (model == null)
{
return;
}
foreach (Animator a in model.GetComponentsInChildren<Animator>(true))
{
_frozen.Add(a);
_frozenSpeed.Add(a.speed);
a.speed = 0f;
}
return;
}
for (int i = 0; i < _frozen.Count; i++)
{
if (_frozen[i] != null)
{
_frozen[i].speed = _frozenSpeed[i];
}
}
_frozen.Clear();
_frozenSpeed.Clear();
}
static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data)
{
World world = GameManager.Instance?.World;
if (world == null)
{
if (Active || Zombie != null || _frozen.Count > 0)
{
Active = false;
Zombie = null;
_player = null;
_observer = null;
_bodyViewDim = -1;
_frozen.Clear();
_frozenSpeed.Clear();
}
return;
}
if (!Active)
{
return;
}
EntityAlive z = Zombie;
if (_player == null || _player.IsDead())
{
Exit("тело погибло");
return;
}
if (z == null || z.IsDead() || z.IsMarkedForUnload())
{
Exit("одержимый погиб");
return;
}
PlayerActionsLocal input = _player.playerInput;
bool gui = _player.PlayerUI != null && _player.PlayerUI.windowManager.IsModalWindowOpen();
if (input != null && !gui && (input.Reload.WasPressed || input.PermanentActions.Reload.WasPressed))
{
Exit("R");
return;
}
Vector2 move = Vector2.zero;
bool run = false, jump = false, attack = false, ranged = false, rangedHeld = false, detonate = false;
if (input != null && !gui)
{
Look();
move = new Vector2(input.Move.X, input.Move.Y);
run = input.Run.IsPressed;
jump = input.Jump.WasPressed;
attack = input.Primary.WasPressed;
ranged = input.Secondary.WasPressed;
rangedHeld = input.Secondary.IsPressed;
// E на клавиатуре - в PlayerActionsPermanent.Activate; у PlayerActionsLocal.Activate только
// кнопка геймпада (тест 03.10: «подрыва нету»).
detonate = input.Activate.WasPressed || input.PermanentActions.Activate.WasPressed;
}
if (detonate && z is EntityZombieCop cop && !cop.isPrimed)
{
Debug.Log("[NecromancerTome] Possession: подрыв " + z.EntityName);
cop.HandlePrimingDetonator();
}
Ranged(z, ranged, rangedHeld);
bool moving = move.sqrMagnitude > 0.01f;
float wantYaw = moving ? _yaw + Mathf.Atan2(move.x, move.y) * Mathf.Rad2Deg : _yaw;
_bodyYaw = Mathf.MoveTowardsAngle(_bodyYaw, wantYaw, TurnSpeed * Time.deltaTime);
z.SetRotation(new Vector3(0f, _bodyYaw, 0f));
z.rotation.x = -_pitch; // наклон головы - для луча удара
if (moving)
{
z.SetMoveForwardWithModifiers(run ? z.GetMoveSpeedAggro() : z.GetMoveSpeed(), 1f, 0f, false);
}
else
{
z.SetMoveForwardWithModifiers(0f, 0f, 0f, false);
}
if (jump && z.onGround && !z.Jumping)
{
z.Jumping = true;
z.SetJumpDistance(Mathf.Max(1.5f, z.jumpMaxDistance), 1.2f);
}
if (ranged && IsScreamer(z))
{
Scream(world, z);
}
if (attack && Time.time >= _nextAttack && z.Attack(false))
{
z.Attack(true);
_nextAttack = Time.time + Mathf.Max(0.3f, z.GetAttackTimeoutTicks() / 20f);
}
_observer?.SetPosition(z.position);
Taunt(world, z);
}
static void ReadRanged(EntityAlive _z)
{
_hasRanged = false;
_rState = RState.Idle;
_rNext = 0f;
List<EAIRangedAttackTarget> tasks = _z.aiManager?.GetTasks<EAIRangedAttackTarget>();
if (tasks == null || tasks.Count == 0)
{
return;
}
EAIRangedAttackTarget t = tasks[0];
_hasRanged = true;
_rItem = t.itemActionType;
_rStartAnim = t.startAnimType;
_rRelease = t.releaseDelay;
_rDuration = t.attackDuration > 0f ? t.attackDuration : 3f;
// Вдвое быстрее ИИ (тест 03.10: «перезаряд медленный»).
_rCooldown = Mathf.Max(1f, t.baseCooldown * 0.5f);
_rSoundStart = t.soundStartName;
_rSoundRelease = t.soundReleaseName;
Debug.Log("[NecromancerTome] Possession: дальняя атака - оружие " + _rItem + ", замах " + _rStartAnim + ", длит. " + _rDuration + " с, откат " + _rCooldown + " с");
}
/// <summary>Подсказка при вселении: что умеет этот зомби.</summary>
static void Hint(EntityPlayerLocal _p, EntityAlive _z)
{
string text = Localization.Get("necroPossessHintBase");
if (_hasRanged)
{
text += ", " + Localization.Get("necroPossessHintRanged");
}
if (IsScreamer(_z))
{
text += ", " + Localization.Get("necroPossessHintScream");
}
if (_z.jumpMaxDistance >= 5f)
{
text += ", " + Localization.Get("necroPossessHintLeap");
}
if (_z is EntityZombieCop)
{
text += ", " + Localization.Get("necroPossessHintDetonate");
}
GameManager.ShowTooltip(_p, text);
}
/// <summary>Точка под прицелом камеры (в мировых координатах), до 50 м.</summary>
static Vector3 AimPoint()
{
Transform cam = _player?.cameraTransform;
if (cam == null)
{
return Zombie.position + Quaternion.Euler(-_pitch, _yaw, 0f) * Vector3.forward * 30f;
}
if (Physics.Raycast(cam.position, cam.forward, out RaycastHit hit, 50f, -5, QueryTriggerInteraction.Ignore) &&
hit.distance > CamBack + 0.5f)
{
return hit.point + Origin.position;
}
return cam.position + Origin.position + cam.forward * 50f;
}
/// <summary>ПКМ - дальняя атака зомби по шагам ванильной EAIRangedAttackTarget.</summary>
static void Ranged(EntityAlive _z, bool _pressed, bool _held)
{
if (!_hasRanged)
{
return;
}
float dt = Time.deltaTime;
switch (_rState)
{
case RState.Idle:
if (!_pressed)
{
return;
}
if (Time.time < _rNext || !_z.IsAttackValid())
{
Debug.Log("[NecromancerTome] Possession: дальняя атака не готова - откат " + Mathf.Max(0f, _rNext - Time.time).ToString("F1") + " с, атака " + (_z.IsAttackValid() ? "можно" : "нельзя"));
return;
}
RearmRanged(_z);
_z.emodel.avatarController.hitWeightMax = 0.5f;
_rTime = 0f;
if (!string.IsNullOrEmpty(_rSoundStart))
{
Audio.Manager.BroadcastPlay(_z, _rSoundStart);
}
if (_rStartAnim >= 0)
{
_z.StartAnimAction(_rStartAnim + 3000);
_rState = RState.StartAnim;
}
else
{
_rState = RState.Fire;
}
return;
case RState.StartAnim:
_z.SetLookPosition(AimPoint());
if (_z.GetAnimActionState() != AvatarController.ActionState.Ready)
{
return;
}
_z.ContinueAnimAction(_rStartAnim + 1 + 3000);
if (!string.IsNullOrEmpty(_rSoundRelease))
{
Audio.Manager.BroadcastPlay(_z, _rSoundRelease);
}
_rState = RState.Release;
_rTime = 0f;
return;
case RState.Release:
_z.SetLookPosition(AimPoint());
_rTime += dt;
if (_rTime >= _rRelease)
{
_rState = RState.Fire;
_rTime = 0f;
}
return;
case RState.Fire:
_z.SetLookPosition(AimPoint());
_rTime += dt;
_z.UseHoldingItem(_rItem, false);
// Доводим до конца по одному нажатию: у плевка сначала 1-3 предупреждения по 1.2 с
// (ItemActionVomit), и с отпущенной кнопкой он сбрасывался (тест 03.10: «если зажать
// ПКМ - плюёт»). Конец - оружие закончило или вышла длительность задачи.
if (_rTime > 0.2f && !_z.IsHoldingItemInUse(_rItem) || _rTime >= _rDuration)
{
StopRanged(_z);
}
return;
}
}
/// <summary>Перед новой дальней атакой - оружие в исходное (тест 03.10: «плюёт, но один раз»).
/// У ИИ это делает его собственный цикл; здесь - сброс очереди выстрелов (ResetBurstShot), отпущенный
/// курок, отменённая перезарядка и патрон в магазине (Infinite_ammo, магазин 1).</summary>
static void RearmRanged(EntityAlive _z)
{
ItemInventoryData inv = _z.inventory?.holdingItemData;
if (inv == null || _rItem >= inv.actionData.Count)
{
return;
}
ItemValue iv = _z.inventory.holdingItemItemValue;
string before = "патронов " + (iv != null ? iv.Meta.ToString() : "?");
if (inv.actionData[_rItem] is ItemActionRanged.ItemActionDataRanged d && _z.inventory.holdingItem.Actions[_rItem] is ItemActionRanged ranged)
{
before += ", очередь " + d.curBurstCount + ", перезарядка " + d.isReloading;
ranged.ResetBurstShot(d);
d.bReleased = true;
d.bPressed = false;
d.isReloading = false;
d.isReloadRequested = false;
}
// Плевок: без лишних «позывов» - сразу последнее предупреждение (ItemActionVomit: 1-3 по 1.2 с).
if (inv.actionData[_rItem] is ItemActionVomit.ItemActionDataVomit dv && _z.inventory.holdingItem.Actions[_rItem] is ItemActionVomit vomit)
{
dv.numWarningsPlayed = Mathf.Max(0, Traverse.Create(vomit).Field("warningMax").GetValue<int>() - 1);
dv.warningTime = 0f;
}
if (iv != null && iv.Meta < 1)
{
iv.Meta = 1;
}
Debug.Log("[NecromancerTome] Possession: дальняя атака - взвод (" + before + ")");
}
static void StopRanged(EntityAlive _z)
{
_z.ShowHoldingItem(false);
_z.UseHoldingItem(_rItem, true);
_z.StartAnimAction(9999);
_z.SetLookPosition(Vector3.zero);
_z.emodel.avatarController.hitWeightMax = 1f;
_rState = RState.Idle;
_rNext = Time.time + _rCooldown;
}
static bool IsScreamer(EntityAlive _z)
{
return EntityClass.list.TryGetValue(_z.entityClass, out EntityClass c) && c.entityClassName.StartsWith("zombieScreamer");
}
/// <summary>Крик одержимой крикуньи: её звук тревоги и все зомби в ScreamRange - на неё (танкование).</summary>
static void Scream(World _world, EntityAlive _z)
{
if (Time.time < _nextScream)
{
return;
}
_nextScream = Time.time + ScreamCooldown;
string snd = _z.GetSoundAlert();
if (!string.IsNullOrEmpty(snd))
{
Audio.Manager.BroadcastPlay(_z, snd);
}
int n = 0;
for (int i = 0; i < _world.EntityAlives.Count; i++)
{
EntityAlive e = _world.EntityAlives[i];
if (e == null || e == _z || e.IsDead() || !(e is EntityZombie) || e.aiManager == null ||
PetFollowPatch.FindOwnerOfPet(e.entityId) != null || (e.position - _z.position).sqrMagnitude > ScreamRange * ScreamRange)
{
continue;
}
e.ConditionalTriggerSleeperWakeUp();
AllowZombieTarget(e);
e.SetAttackTarget(_z, TauntTicks);
n++;
}
Debug.Log("[NecromancerTome] Possession: крик крикуньи - на зов идут " + n);
}
static void Look()
{
PlayerMoveController pmc = PlayerMoveController.Instance;
Vector2 sens = pmc != null ? pmc.mouseLookSensitivity : Vector2.one * 5f;
int invert = pmc != null && pmc.invertMouse != 0 ? pmc.invertMouse : 1;
_yaw += Input.GetAxis("Mouse X") * sens.x;
_pitch = Mathf.Clamp(_pitch + Input.GetAxis("Mouse Y") * sens.y * invert, -70f, 70f);
}
/// <summary>Раз в секунду: зомби, которые видят одержимого, бросаются на него (решение 03.10:
/// «зомби начинают считать этого зомби ещё одним игроком»). Ванильный подход к цели принимает
/// только свои классы (EntityPlayer...) - добавляем EntityZombie, как CharmPatch; это безопасно:
/// цель-зомби им ставим только мы (поиск цели EAISetNearestEntityAsTarget не трогаем).</summary>
static void Taunt(World _world, EntityAlive _z)
{
if (Time.time < _nextTaunt)
{
return;
}
_nextTaunt = Time.time + TauntPeriod;
for (int i = 0; i < _world.EntityAlives.Count; i++)
{
EntityAlive e = _world.EntityAlives[i];
if (e == null || e == _z || e.IsDead() || !(e is EntityZombie) || e.GetAttackTarget() == _z ||
e.aiManager == null || PetFollowPatch.FindOwnerOfPet(e.entityId) != null ||
(e.position - _z.position).sqrMagnitude > TauntRange * TauntRange || !e.CanSee(_z))
{
continue;
}
AllowZombieTarget(e);
e.SetAttackTarget(_z, TauntTicks);
}
}
static void AllowZombieTarget(EntityAlive _e)
{
List<EAIApproachAndAttackTarget> tasks = _e.aiManager.GetTasks<EAIApproachAndAttackTarget>();
if (tasks == null)
{
return;
}
foreach (EAIApproachAndAttackTarget t in tasks)
{
bool has = false;
foreach (EAIApproachAndAttackTarget.TargetClass c in t.targetClasses)
{
has |= c.type == typeof(EntityZombie);
}
if (!has)
{
t.targetClasses.Add(new EAIApproachAndAttackTarget.TargetClass { type = typeof(EntityZombie), chaseTimeMax = 0f });
}
}
}
/// <summary>Из постфикса vp_FPCamera.LateUpdate: камера позади и выше головы зомби, не сквозь стены.</summary>
public static void PlaceCamera(Transform _camera)
{
EntityAlive z = Zombie;
if (z == null)
{
return;
}
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);
Vector3 eye = anchor + Vector3.up * CamUp - rot * Vector3.forward * CamBack;
Vector3 to = eye - anchor;
if (Physics.Raycast(anchor, to.normalized, out RaycastHit hit, to.magnitude + 0.2f, SnakeControl.WallMask, QueryTriggerInteraction.Ignore))
{
eye = anchor + to.normalized * Mathf.Max(0.2f, hit.distance - 0.2f);
}
_camera.SetPositionAndRotation(eye, rot);
if (!_loggedCamera)
{
_loggedCamera = true;
Debug.Log("[NecromancerTome] Possession: камера за " + (head != null ? "головой " + head.name : "корнем (головы нет)"));
}
}
}
/// <summary>Одержимый зомби без своего ИИ: шаги ИИ, путь и moveHelper не идут, движение задаём мы.</summary>
[HarmonyPatch(typeof(EntityAlive), nameof(EntityAlive.updateTasks))]
public static class Patch_EntityAlive_updateTasks_Possession
{
public static bool Prefix(EntityAlive __instance)
{
return !Possession.IsPossessed(__instance);
}
}
/// <summary>Тело оцепенело: ввод игрока не идёт (как у тела в рэгдолле - stopMoving и выход).</summary>
[HarmonyPatch(typeof(PlayerMoveController), nameof(PlayerMoveController.Update))]
public static class Patch_PlayerMoveController_Update_Possession
{
public static bool Prefix(PlayerMoveController __instance)
{
if (!Possession.Active)
{
return true;
}
__instance.stopMoving();
return false;
}
}
/// <summary>Полоска здоровья одержимого (тест 03.10: «а индикатор здоровья у одержимого где?») -
/// внизу по центру, над поясом, с именем зомби. Своё здоровье игрок видит как обычно (тело беззащитно).
/// OnGUI, как прицел змеи (SnakeCrosshair), - без своего окна XUi.</summary>
public class PossessionHud : MonoBehaviour
{
const float Width = 420f, Height = 16f, Bottom = 150f;
static readonly Color Back = new Color(0f, 0f, 0f, 0.6f);
static readonly Color Fill = new Color(0.55f, 0.08f, 0.06f, 0.95f);
static GUIStyle _style;
void OnGUI()
{
EntityAlive z = Possession.Active ? Possession.Zombie : null;
if (z == null || Event.current.type != EventType.Repaint)
{
return;
}
float scale = Screen.height / 1080f;
float w = Width * scale, h = Height * scale;
Rect r = new Rect((Screen.width - w) / 2f, Screen.height - Bottom * scale - h, w, h);
float frac = Mathf.Clamp01(z.Health / (float)Mathf.Max(1, z.GetMaxHealth()));
Color prev = GUI.color;
GUI.color = Back;
GUI.DrawTexture(r, Texture2D.whiteTexture);
GUI.color = Fill;
GUI.DrawTexture(new Rect(r.x + 2f, r.y + 2f, (r.width - 4f) * frac, r.height - 4f), Texture2D.whiteTexture);
GUI.color = prev;
if (_style == null)
{
_style = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.LowerCenter };
_style.normal.textColor = new Color(0.9f, 0.85f, 0.8f);
}
_style.fontSize = Mathf.RoundToInt(18f * scale);
string name = Localization.Get(z.EntityName);
GUI.Label(new Rect(r.x, r.y - 26f * scale, r.width, 24f * scale), name + " " + z.Health + " / " + z.GetMaxHealth(), _style);
}
}
}
+106 -9
View File
@@ -876,6 +876,11 @@ namespace NecromancerTome
{
public static bool Prefix(EntityPlayerLocal __instance)
{
if (MindControl.Controlling)
{
MindControl.PlaceCamera(__instance.cameraTransform);
return false;
}
if (!SnakeControl.Controlling)
{
return true;
@@ -923,6 +928,14 @@ namespace NecromancerTome
{
SnakeControl.PlaceCamera(__instance.transform);
}
else if (Possession.Active)
{
Possession.PlaceCamera(__instance.transform);
}
else if (MindControl.Controlling)
{
MindControl.PlaceCamera(__instance.transform);
}
}
}
@@ -1111,13 +1124,10 @@ namespace NecromancerTome
public static void Prefix(out Vector3 __state)
{
__state = Vector3.zero;
if (!SnakeControl.Controlling)
{
return;
}
World world = GameManager.Instance.World;
// И у одержимого зомби (03.10: «если одержимый отошёл подальше, новые не спавнятся возле него»).
EntityAlive snake = SnakeControl.Controlling ? SnakeMode.Snake(world) : Possession.Active ? Possession.Zombie : null;
EntityPlayerLocal player = world?.GetPrimaryPlayer();
EntityAlive snake = SnakeMode.Snake(world);
if (player == null || snake == null)
{
return;
@@ -1150,12 +1160,18 @@ namespace NecromancerTome
{
public static bool Prefix(EntityAlive __instance, ref int ___ticksNoPlayerAdjacent)
{
if (!SnakeControl.Controlling)
EntityAlive snake = SnakeControl.Controlling ? SnakeMode.Snake(__instance.world as World) : Possession.Active ? Possession.Zombie : null;
if (snake == null)
{
return true;
}
EntityAlive snake = SnakeMode.Snake(__instance.world as World);
if (snake == null || snake == __instance || (__instance.position - snake.position).sqrMagnitude > 6400f)
// Одержимый сам не исчезает, как бы далеко от тела ни ушёл.
if (snake == __instance)
{
___ticksNoPlayerAdjacent = 0;
return !Possession.IsPossessed(__instance);
}
if ((__instance.position - snake.position).sqrMagnitude > 6400f)
{
return true;
}
@@ -1172,7 +1188,7 @@ namespace NecromancerTome
{
public static bool Prefix(GUIWindow _w)
{
return !(SnakeControl.Controlling && _w != null && _w.Id == SnakeControl.ToolbeltWindow);
return !((SnakeControl.Controlling || MindControl.Controlling) && _w != null && _w.Id == SnakeControl.ToolbeltWindow);
}
}
@@ -1182,8 +1198,61 @@ namespace NecromancerTome
/// змеи. На время вызова переносим позицию игрока к змее.</summary>
public static class SnakeAudioSwap
{
/// <summary>Вложенность подмены: CheckLODs зовётся и изнутри WorldEnvironment.Update, где позиция уже
/// подменена. Подменяет и возвращает только внешний вызов, вложенные ничего не трогают.</summary>
static int _swapped, _swapFrame;
public static Vector3 Prefix()
{
if (_swapped > 0 && _swapFrame != Time.frameCount)
{
_swapped = 0; // подмена не вернулась (исключение посреди вызова) - не держим её дольше кадра
}
if (_swapped > 0)
{
_swapped++;
return Vector3.zero;
}
Vector3 body = PrefixOuter();
if (body != Vector3.zero)
{
_swapped = 1;
_swapFrame = Time.frameCount;
}
return body;
}
static Vector3 PrefixOuter()
{
if (MindControl.Controlling)
{
// Дух чертогов: звук, мир и компас - от него (MindControl.cs).
EntityPlayerLocal p = GameManager.Instance?.World?.GetPrimaryPlayer();
if (p == null)
{
return Vector3.zero;
}
Vector3 bodyPos = p.position;
p.position = MindControl.FeetPos;
return bodyPos;
}
if (Possession.Active && Possession.Zombie != null)
{
// Одержимость (тест 03.10: «без звуков») - звуки мерятся от одержимого зомби, а не от тела.
EntityPlayerLocal pp = GameManager.Instance?.World?.GetPrimaryPlayer();
if (pp == null)
{
return Vector3.zero;
}
Vector3 bodyAt = pp.position;
pp.position = Possession.Zombie.position;
// Область тела сжата (Possession.BodyViewDim) - этим системам радиус полный, они рисуют у зомби.
if (Possession.FullViewDim > 0 && pp.ChunkObserver != null)
{
pp.ChunkObserver.viewDim = Possession.FullViewDim;
}
return bodyAt;
}
if (!SnakeControl.Controlling)
{
return Vector3.zero;
@@ -1204,16 +1273,44 @@ namespace NecromancerTome
{
if (_body == Vector3.zero)
{
if (_swapped > 1)
{
_swapped--;
}
return;
}
_swapped = 0;
EntityPlayerLocal player = GameManager.Instance?.World?.GetPrimaryPlayer();
if (player != null)
{
player.position = _body;
if (Possession.Active && Possession.FullViewDim > 0 && player.ChunkObserver != null)
{
player.ChunkObserver.viewDim = Possession.BodyViewDim;
}
}
}
}
/// <summary>Полная модель блоков (двери, люки, мебель) ближе 41 м, дальше - упрощённая замена:
/// ChunkGameObject.CheckLODs меряет расстояние до ИГРОКА - тела. Его зовёт не только WorldEnvironment.Update
/// (там позиция уже подменена), но и сам чанк при появлении - тест 03.10: «змея и одержимый видят вместо
/// дверей деревянные блоки». Подменяем позицию и здесь: змея, одержимый, дух чертогов.</summary>
[HarmonyPatch(typeof(ChunkGameObject), nameof(ChunkGameObject.CheckLODs))]
public static class Patch_ChunkGameObject_CheckLODs_Snake
{
public static void Prefix(out Vector3 __state) { __state = SnakeAudioSwap.Prefix(); }
public static void Postfix(Vector3 __state) { SnakeAudioSwap.Postfix(__state); }
}
/// <summary>Детализация зданий (POI LOD) - вокруг одержимого, с полным радиусом (03.10, сжатая область тела).</summary>
[HarmonyPatch(typeof(PrefabLODManager), nameof(PrefabLODManager.FrameUpdate))]
public static class Patch_PrefabLODManager_FrameUpdate_Possession
{
public static void Prefix(out Vector3 __state) { __state = Possession.Active ? SnakeAudioSwap.Prefix() : Vector3.zero; }
public static void Postfix(Vector3 __state) { SnakeAudioSwap.Postfix(__state); }
}
[HarmonyPatch(typeof(Audio.Manager), nameof(Audio.Manager.Play), new[] { typeof(Vector3), typeof(string), typeof(int), typeof(bool), typeof(float) })]
public static class Patch_AudioManager_PlayAt_Snake
{
+20 -2
View File
@@ -72,7 +72,7 @@ namespace NecromancerTome
public static void Enter(EntityPlayerLocal _player)
{
if (Active || _player == null || _player.IsDead() || _player.AttachedToEntity != null)
if (Active || MindPalace.Active || _player == null || _player.IsDead() || _player.AttachedToEntity != null)
{
return;
}
@@ -318,6 +318,10 @@ namespace NecromancerTome
{
Enter(_sender.xui.playerUI.entityPlayer);
}
else if (_commandIndex == 2)
{
MindPalace.Enter(_sender.xui.playerUI.entityPlayer);
}
}
}
@@ -326,7 +330,7 @@ namespace NecromancerTome
{
public static void Postfix(ItemAction __instance, ref bool __result)
{
if (!__result && !SnakeMode.Active && SnakeMode.IsKnife(__instance.item))
if (!__result && !SnakeMode.Active && !MindPalace.Active && SnakeMode.IsKnife(__instance.item))
{
__result = true;
}
@@ -336,6 +340,9 @@ namespace NecromancerTome
[HarmonyPatch(typeof(ItemAction), nameof(ItemAction.SetupRadial))]
public static class Patch_ItemAction_SetupRadial_SnakeKnife
{
/// <summary>Книга (указание 03.10: «может лучше книга?» - с неё всё и начинается).</summary>
public const string MindPalaceIcon = "ui_game_symbol_book";
public static bool Prefix(ItemAction __instance, XUiC_Radial _xuiRadialWindow, EntityPlayerLocal _epl)
{
if (!SnakeMode.IsKnife(__instance.item))
@@ -355,6 +362,17 @@ namespace NecromancerTome
_xuiRadialWindow.CreateRadialEntry(1, SnakeMode.DayIcon, "UIAtlas", "",
Localization.Get("necroSnakeModeDay"), _highlighted: false);
}
// Чертоги разума (указание 03.10: «вход тоже через нож») - третья строка, тоже только ночью.
if (SnakeMode.IsNight())
{
_xuiRadialWindow.CreateRadialEntry(2, MindPalaceIcon, "UIAtlas", "",
Localization.Get("necroMindModePalace"), _highlighted: false);
}
else
{
_xuiRadialWindow.CreateRadialEntry(2, SnakeMode.DayIcon, "UIAtlas", "",
Localization.Get("necroSnakeModeDay"), _highlighted: false);
}
_xuiRadialWindow.SetCommonData(UIUtils.GetButtonIconForAction(_epl.playerInput.Reload),
SnakeMode.OnRadialCommand, null, 0);
return false;