Режим змеи для Ножа некроманта и чёрный разрыв

Ночью по R с ножом - дух змеи: тело падает (-95% HP), игрок управляет
призрачной змеёй от первого лица. Свободный ход по миру (свой наблюдатель
чанков, отрисовка, звук, спавн и спящие считаются от змеи), прыжок,
атака на ЛКМ - рывок сквозь препятствия и чёрный разрыв, ПКМ - выбраться
из текстур. Тёмное чутьё, тревога у тела, отсчёт в радиации края карты,
пурпурный фильтр зрения (necrovision). Утром змея развеивается.

Чёрный разрыв (NecroBurst.cs): клон частиц confetti в чёрном, команда
necroburst. Проверено в игре 28.09.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AlexCube
2026-09-28 15:31:37 +03:00
co-authored by Claude Opus 5.5
parent 4cdd315b98
commit 6ff76d174e
13 changed files with 2289 additions and 1 deletions
+113
View File
@@ -318,6 +318,11 @@ namespace NecromancerTome
{
return false;
}
// Призрачная змея режима змеи (SnakeMode.cs, 2026-09-28): «текстуры как у торговцев».
if (entityClass.entityClassName == SnakeMode.SnakeClassName)
{
return true;
}
return Patch_ItemActionSpawnEntity_Spawn_PetLimit.LimitedPets.ContainsKey(entityClass.entityClassName);
}
@@ -429,7 +434,15 @@ namespace NecromancerTome
// Before anything is touched, because the body's replacement shader is found on the
// trader's OWN hair and the hair is not guaranteed to come first in this array.
// Змея не должна становиться источником BlendShader: он кэшируется на всю сессию, и
// торговцы после неё перекрашивались бы шейдером змеи, а не своих волос.
bool isSnake = _trader.EntityClass.entityClassName == SnakeMode.SnakeClassName;
Shader keptBlendShader = BlendShader;
FindBlendShader(renderers);
if (isSnake)
{
BlendShader = keptBlendShader;
}
int converted = 0;
int leversBefore = TintedMaterials.Count;
@@ -456,6 +469,17 @@ namespace NecromancerTome
}
_body.Renderers = taken.ToArray();
// Змея режима змеи идёт по ветке «блендим на месте» (Autodesk Interactive), и альфа там
// не видна даже с полной шестёркой настроек (_Mode 2, _ZWrite 0, ключевые слова, очередь)
// - проверено 28.09 дважды. Вероятная причина: копия шейдера из сборки змеи собрана без
// варианта _ALPHABLEND_ON (Unity выкидывает неиспользуемые варианты при сборке). Поэтому
// материалы змеи пересобираются на шейдер, который этот вариант ТОЧНО имеет - его берём
// у любого загруженного материала с включённым _ALPHABLEND_ON.
if (isSnake)
{
MakeSnakeFade(taken);
}
// The lever count is the half that answers "will the console command reach him":
// desaturation and opacity come from different properties, and the body had the first
// without the second until 2026-09-14. Fewer levers than converted renderers means
@@ -466,6 +490,95 @@ namespace NecromancerTome
return true;
}
static Shader _fadeShader;
/// <summary>Шейдер, у которого точно есть прозрачный вариант: берётся с любого загруженного
/// материала, где _ALPHABLEND_ON включён. Предпочтение - тем же шейдерам, что у змеи.</summary>
public static Shader FindFadeShader()
{
if (_fadeShader != null)
{
return _fadeShader;
}
Shader best = null;
int bestRank = int.MaxValue;
HashSet<string> seen = new HashSet<string>();
foreach (Material m in Resources.FindObjectsOfTypeAll<Material>())
{
if (m == null || m.shader == null || !m.IsKeywordEnabled("_ALPHABLEND_ON") ||
!m.HasProperty("_MainTex") || !m.HasProperty("_Color") || !m.HasProperty("_SrcBlend"))
{
continue;
}
string name = m.shader.name;
seen.Add(name + " (" + m.name + ")");
int rank = name == "Autodesk Interactive" ? 0 : name == "Standard" ? 1 : 2;
if (rank < bestRank)
{
best = m.shader;
bestRank = rank;
}
}
Debug.Log("[NecromancerTome] GhostTraderPatch: fade shaders loaded: " +
(seen.Count == 0 ? "<none>" : string.Join(", ", seen)) +
" -> " + (best != null ? best.name : "NONE"));
_fadeShader = best;
return best;
}
/// <summary>Пересобирает материалы змеи на шейдер из FindFadeShader: серая текстура и карта
/// нормалей переносятся, прозрачность - полной шестёркой.</summary>
public static void MakeSnakeFade(List<Renderer> _renderers)
{
Shader fade = FindFadeShader();
if (fade == null)
{
return;
}
int done = 0;
foreach (Renderer renderer in _renderers)
{
Material[] current = renderer.sharedMaterials;
Material[] rebuilt = new Material[current.Length];
for (int i = 0; i < current.Length; i++)
{
Material src = current[i];
if (src == null)
{
continue;
}
Material m = new Material(fade);
string albedo = FindAlbedoProperty(src);
if (albedo != null)
{
m.SetTexture("_MainTex", src.GetTexture(albedo));
}
Texture normal = FindTexture(src, NormalNameHints);
if (normal != null && m.HasProperty("_BumpMap"))
{
m.SetTexture("_BumpMap", normal);
m.EnableKeyword("_NORMALMAP");
}
MakeMatte(m);
if (m.HasProperty("_Mode"))
{
m.SetFloat("_Mode", 2f);
}
if (m.HasProperty("_ZWrite"))
{
m.SetFloat("_ZWrite", 0f);
}
m.DisableKeyword("_ALPHATEST_ON");
m.DisableKeyword("_ALPHAPREMULTIPLY_ON");
ApplyTransparency(m);
rebuilt[i] = m;
done++;
}
renderer.materials = rebuilt;
}
Debug.Log("[NecromancerTome] GhostTraderPatch: snake re-shaded onto '" + fade.name + "', " + done + " material(s)");
}
/// <summary>Builds and installs this renderer's ghost materials from the ORIGINALS it was
/// handed. True when anything was actually changed - a renderer nothing could be done to
/// keeps the materials it has rather than being handed a half-built array.</summary>
+1
View File
@@ -60,6 +60,7 @@ namespace NecromancerTome
// Traders rendered as washed-out ghosts (request 2026-09-13). Polls rather than
// hooks a spawn event - see that file for why the SDCS-built trader forces it.
GhostTraderPatch.Init();
SnakeMode.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.
+221
View File
@@ -0,0 +1,221 @@
using System.Collections.Generic;
using HarmonyLib;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Чёрный разрыв: зомби на смерти рассыпается облаком чёрных частиц со звуком «gore_head», и
/// трупа не остаётся. Свой аналог ванильного Twitch-эффекта «Celebrate» (конфетти), продиктовано
/// 28.09.2026 как заготовка под дистанционную атаку (BACKLOG: «рывок и взрыв зомби»).
///
/// КАК УСТРОЕН ОРИГИНАЛ (EntityAlive.ClientKill, игра 3.3). Уже после выпадения добычи
/// (OnEntityDeath) и начисления опыта (AwardKillXPServer), если убийца - игрок, а на нём
/// пассивка CelebrationKill, сервер делает три вещи: SpawnParticleEffectServer("confetti"),
/// Manager.BroadcastPlay("twitch_celebrate") и World.RemoveEntity(Killed) - вместо рэгдолла.
/// Имена частиц и звука там зашиты строками, поэтому ванильный путь не переиспользуется: мы
/// повторяем те же три вызова своим постфиксом на ClientKill для помеченных зомби.
///
/// ПОЧЕМУ ЧАСТИЦЫ - КОПИЯ, А НЕ ПЕРЕКРАСКА. Цвет из ParticleEffect игра применяет только к
/// рендерерам БЕЗ ParticleSystem (ParticleEffect.SpawnParticleEffect), то есть конфетти он
/// не красит. Поэтому при первом обращении клонируем префаб «confetti», красим в клоне сами
/// системы частиц и кладём его в реестр игры (ParticleEffect.loadedTs) под своим именем.
/// Ванильные конфетти остаются цветными. Клон строится лениво в GetDynamicTransform - это та
/// точка, через которую идёт любой спавн частиц и на сервере, и на клиенте.
///
/// Счёт Некромантии и добыча не страдают: к моменту постфикса они уже отработали. А вот
/// красный труп Жертвы с разрывом несовместим - тело удаляется (решать при дистанционной атаке).
/// </summary>
public static class NecroBurst
{
public const string ParticleName = "necroblackburst";
public const string SourceParticleName = "confetti";
public const string SoundName = "gore_head";
/// <summary>Почти чёрный, а не 0,0,0: у чистого чёрного пропадает объём частиц.</summary>
public static readonly Color Tint = new Color(0.04f, 0.035f, 0.045f, 1f);
/// <summary>Зомби, которые на ближайшей смерти должны разорваться. Сервер.</summary>
public static readonly HashSet<int> Pending = new HashSet<int>();
/// <summary>Разрыв случился: жертва и Transform частиц (может быть null).</summary>
public static System.Action<EntityAlive, Transform> OnBurst;
static GameObject _holder;
public static int ParticleId => ParticleEffect.ToId(ParticleName);
/// <summary>Строит чёрный клон конфетти и регистрирует его. true - клон есть.</summary>
public static bool EnsureRegistered()
{
int id = ParticleId;
if (ParticleEffect.loadedTs.ContainsKey(id))
{
return true;
}
if (!ParticleEffect.loadedTs.TryGetValue(ParticleEffect.ToId(SourceParticleName), out Transform src) || src == null)
{
Debug.LogWarning("[NecromancerTome] NecroBurst: ванильный эффект '" + SourceParticleName + "' не найден");
return false;
}
// Неактивный держатель: клон под ним не играет сам, а копии, которые игра делает из
// клона через Instantiate, выходят активными (активность берётся у самого клона).
if (_holder == null)
{
_holder = new GameObject("NecroBurstPrefabs");
_holder.SetActive(false);
Object.DontDestroyOnLoad(_holder);
}
Transform clone = Object.Instantiate(src, _holder.transform);
clone.name = ParticleEffect.prefix + ParticleName;
foreach (ParticleSystem ps in clone.GetComponentsInChildren<ParticleSystem>(true))
{
ParticleSystem.MainModule main = ps.main;
main.startColor = new ParticleSystem.MinMaxGradient(Tint);
ParticleSystem.ColorOverLifetimeModule col = ps.colorOverLifetime;
if (col.enabled)
{
// Прозрачность по времени жизни оставляем как есть, цвет - в чёрный.
Gradient g = new Gradient();
GradientAlphaKey[] alpha = col.color.gradient != null
? col.color.gradient.alphaKeys
: new[] { new GradientAlphaKey(1f, 0f), new GradientAlphaKey(1f, 1f) };
g.SetKeys(new[] { new GradientColorKey(Tint, 0f), new GradientColorKey(Tint, 1f) }, alpha);
col.color = new ParticleSystem.MinMaxGradient(g);
}
ParticleSystemRenderer r = ps.GetComponent<ParticleSystemRenderer>();
if (r != null && r.sharedMaterial != null)
{
// Печатаем шейдер: если он аддитивный, чёрный на нём невидим, и это сразу видно
// в логе, а не гаданием по пустому месту на экране.
Debug.Log("[NecromancerTome] NecroBurst: система '" + ps.name + "', шейдер '" +
r.sharedMaterial.shader.name + "', материал '" + r.sharedMaterial.name + "'");
}
}
ParticleEffect.loadedTs[id] = clone;
Debug.Log("[NecromancerTome] NecroBurst: зарегистрирован эффект '" + ParticleName + "'");
return true;
}
/// <summary>Разорвать уже мёртвого зомби: частицы, звук, удаление тела. Только сервер.</summary>
public static void Burst(EntityAlive _victim, Entity _soundSource)
{
if (!SingletonMonoBehaviour<ConnectionManager>.Instance.IsServer)
{
return;
}
World world = GameManager.Instance.World;
Vector3 pos = _victim.position;
Transform effect = null;
if (EnsureRegistered())
{
float light = world.GetLightBrightness(_victim.GetBlockPosition());
ParticleEffect pe = new ParticleEffect(ParticleName, pos, light, Color.white, null, null, _OLDCreateColliders: false);
// Как GameManager.SpawnParticleEffectServer, но с Transform на руках - режиму змеи
// нужно вести частицы за собой (OnBurst).
effect = ParticleEffect.SpawnParticleEffect(pe, _victim.entityId, false, true);
SingletonMonoBehaviour<ConnectionManager>.Instance.SendPackage(
NetPackageManager.GetPackage<NetPackageParticleEffect>().Setup(pe, _victim.entityId, false, true),
_onlyClientsAttachedToAnEntity: false, -1, _victim.entityId);
}
OnBurst?.Invoke(_victim, effect);
Audio.Manager.BroadcastPlay(_soundSource, pos, SoundName);
world.RemoveEntity(_victim.entityId, EnumRemoveEntityReason.Killed);
}
}
/// <summary>Ленивая регистрация клона: любой спавн по нашему id сначала строит его.</summary>
[HarmonyPatch(typeof(ParticleEffect), nameof(ParticleEffect.GetDynamicTransform))]
public static class Patch_ParticleEffect_GetDynamicTransform_NecroBurst
{
public static void Prefix(int _particleId)
{
if (_particleId == NecroBurst.ParticleId)
{
NecroBurst.EnsureRegistered();
}
}
}
/// <summary>Помеченный зомби умер - разрываем. Постфикс: добыча и опыт уже выданы.</summary>
[HarmonyPatch(typeof(EntityAlive), nameof(EntityAlive.ClientKill))]
public static class Patch_EntityAlive_ClientKill_NecroBurst
{
public static void Postfix(EntityAlive __instance)
{
if (!NecroBurst.Pending.Remove(__instance.entityId) || !__instance.IsDead())
{
return;
}
NecroBurst.Burst(__instance, __instance.entityThatKilledMe != null ? (Entity)__instance.entityThatKilledMe : __instance);
}
}
/// <summary>
/// necroburst - убить чёрным разрывом зомби, на которого смотришь (до 30 м). Проверка эффекта,
/// пока дистанционной атаки нет. Убийство засчитывается игроку как обычное.
/// </summary>
[Preserve]
public class ConsoleCmdNecroBurst : ConsoleCmdAbstract
{
public override bool IsExecuteOnClient => true;
public override bool AllowedInMainMenu => false;
public override string[] getCommands()
{
return new string[] { "necroburst" };
}
public override string getDescription()
{
return "Necromancer's Tome: kill the zombie you look at with the black burst (effect test).";
}
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
World world = GameManager.Instance.World;
EntityPlayerLocal player = world?.GetPrimaryPlayer();
if (player == null)
{
return;
}
Ray look = player.GetLookRay();
EntityAlive best = null;
float bestAngle = 15f;
foreach (Entity e in world.Entities.list)
{
if (!(e is EntityZombie z) || z.IsDead())
{
continue;
}
Vector3 to = z.getChestPosition() - look.origin - Origin.position;
if (to.magnitude > 30f)
{
continue;
}
float angle = Vector3.Angle(look.direction, to);
if (angle < bestAngle)
{
bestAngle = angle;
best = z;
}
}
if (best == null)
{
SingletonMonoBehaviour<SdtdConsole>.Instance.Output("necroburst: не вижу зомби перед собой (до 30 м)");
return;
}
NecroBurst.Pending.Add(best.entityId);
best.DamageEntity(new DamageSourceEntity(EnumDamageSource.External, EnumDamageTypes.Piercing, player.entityId),
99999, false);
SingletonMonoBehaviour<SdtdConsole>.Instance.Output("necroburst: " + best.EntityName + " (" + best.entityId + ")");
}
}
}
+26
View File
@@ -69,6 +69,32 @@
own comment for why that call can't compile in THIS project at all. Private=false: it's
built and deployed separately (its own dotnet build + copy to the mod root), same as
0Harmony above - not something this project's own build should try to copy/rebuild. -->
<!-- SnakeControl.cs (2026-09-28): the snake's crosshair is drawn with OnGUI (IMGUIModule)
from a sprite in the game's atlas (INGUIAtlas/UISpriteData live in NGUI.dll). -->
<Reference Include="NGUI">
<HintPath>..\..\..\7DaysToDie_Data\Managed\NGUI.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine.IMGUIModule">
<HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.IMGUIModule.dll</HintPath>
<Private>false</Private>
</Reference>
<!-- The radiation countdown text (TextAnchor/FontStyle for its GUIStyle). -->
<Reference Include="UnityEngine.TextRenderingModule">
<HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.TextRenderingModule.dll</HintPath>
<Private>false</Private>
</Reference>
<!-- SnakeCrosshair: the crosshair sprite exported to PNG / a custom PNG loaded (ImageConversion). -->
<Reference Include="UnityEngine.ImageConversionModule">
<HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.ImageConversionModule.dll</HintPath>
<Private>false</Private>
</Reference>
<!-- SnakeVision.cs: ScreenEffects.ScreenEffect derives from the post-processing stack's
PostProcessEffectSubRenderer. -->
<Reference Include="Unity.Postprocessing.Runtime">
<HintPath>..\..\..\7DaysToDie_Data\Managed\Unity.Postprocessing.Runtime.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="NecromancerTEPersistence">
<HintPath>..\TEPersistenceSrc\bin\NecromancerTEPersistence.dll</HintPath>
<Private>false</Private>
+10 -1
View File
@@ -73,6 +73,10 @@ namespace NecromancerTome
/// the same "obscures the view" reason - the zombie glow's own 0.5 was deliberately subtle,
/// this one shouldn't be. DensityFactor=5 - the actual fix for "редкие-редкие", multiplies
/// both emission rate and maxParticles together (see GlowConfig's own doc on why both).</summary>
/// <summary>Зрение змеи: яркий гнилостный зелёно-бурый. Цвет больше 1 - HDR, частицы
/// светятся сильнее обычного; плотнее и крупнее, чем у девиантов.</summary>
public static readonly GlowConfig SnakeSightGlow = new GlowConfig { Tint = new Color(1.1f, 1.5f, 0.15f), SizeFactor = 0.4f, AlphaFactor = 1f, DensityFactor = 3f };
public const string SnakeSightBuffName = "buffNecroSnakeSight";
public static readonly GlowConfig PortalChannelGlow = new GlowConfig { Tint = new Color(0.1f, 0.85f, 0.8f), SizeFactor = 0.9f, AlphaFactor = 0.9f, DensityFactor = 5f };
public static void Postfix(MinEventActionAttachParticleEffectToEntity __instance, MinEventParams _params)
@@ -92,7 +96,8 @@ namespace NecromancerTome
bool isPortalChannel = entity.Buffs.HasBuff(Patch_ItemActionEat_ExecuteAction_PortalStones.ChannelBuffName);
bool isVictim = entity.Buffs.HasBuff(Patch_EntityAlive_dropItemOnDeath_VictimBag.VictimBuffName);
bool isCharm = entity.Buffs.HasBuff(Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName);
if (!isPortalChannel && !isVictim && !isCharm)
bool isSnakeSight = entity.Buffs.HasBuff(SnakeSightBuffName);
if (!isPortalChannel && !isVictim && !isCharm && !isSnakeSight)
{
return;
}
@@ -104,6 +109,10 @@ namespace NecromancerTome
{
config = PortalChannelGlow;
}
else if (isSnakeSight)
{
config = SnakeSightGlow;
}
else if (isVictim && isCharm)
{
config = new GlowConfig { Tint = Color.Lerp(CharmGlow.Tint, VictimGlow.Tint, 0.5f), SizeFactor = CharmGlow.SizeFactor, AlphaFactor = CharmGlow.AlphaFactor, DensityFactor = 1f };
File diff suppressed because it is too large Load Diff
+360
View File
@@ -0,0 +1,360 @@
using GUI_2;
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Режим змеи Ножа некроманта (продиктовано 2026-09-28, BACKLOG «Замысел: режим змеи»).
/// Зажать R с ножом в руке - меню, как у выбора патронов: человек (по умолчанию) и змея. Выбор
/// змеи отнимает 95% здоровья (назад не возвращается), тело игрока падает и остаётся лежать
/// беззащитным, рядом появляется призрачная змея в 1,4 раза больше обычной (было 2 - «удав получился», 28.09). Выход - снова R.
/// Если тело убили, змея исчезает, и игрок видит обычную смерть.
///
/// ЭТАП 1 (этот файл): меню, вход, выход, смерть тела. ЭТАП 2а (SnakeControl.cs): камера,
/// управление, прицел. Рывок на F и скрытый пояс с надписью «Press R» - дальше.
///
/// ПОЧЕМУ МЕНЮ ОТКРЫВАЕТСЯ. PlayerMoveController открывает радиальное меню на R для огнестрела
/// И для DynamicMelee, если GetHoldingPrimary().HasRadial(). Наш нож - DynamicMelee
/// (наследник костяного ножа), а ItemActionDynamic унаследован прямо от ItemAction и
/// HasRadial/SetupRadial не переопределяет - значит, патчим пустые базовые методы ItemAction.
/// Выбор в предмете НЕ хранится: меню служит только для входа, а выход идёт по R, пока тело
/// лежит. Так сейв ножа не меняется вовсе.
///
/// ПОЧЕМУ ТЕЛО - РЭГДОЛЛ. PlayerMoveController.Update первым делом проверяет
/// emodel.IsRagdollActive и, если тело лежит, ничего не делает: ни ходьбы, ни E, ни R, ни меню.
/// То есть упавшее тело само запрещает лутать и пользоваться поясом. Лежит оно, пока идёт
/// ragdollDuration (ставим почти бесконечную); на выходе дотягиваем ragdollTime до конца, и
/// игра сама поднимает персонажа.
/// </summary>
public static class SnakeMode
{
public const string KnifeName = "necroWpnBladeNecroKnife";
public const string SnakeClassName = "necroSnakeSpirit";
public const float HealthCost = 0.95f;
/// <summary>Зазор между телом игрока и змеёй, метры (указание 28.09: «в 1 метре»).</summary>
public const float Gap = 1f;
public const float PlayerRadius = 0.4f;
/// <summary>Сколько ждать после перехода на третье лицо, прежде чем ронять тело.</summary>
public const float RagdollDelay = 0.25f;
public static bool Active;
static int _snakeId = -1;
static float _sweepTimer;
static float _ragdollAt;
static bool _placeSnake;
static Vector3 _snakeDir;
static bool _waitStandUp;
static float _standUpDeadline;
public static void Init()
{
ModEvents.UnityUpdate.RegisterHandler(OnUnityUpdate);
SnakeCrosshair.Create();
NecroBurst.OnBurst += SnakeControl.OnBurst;
}
/// <summary>Дух змеи - только ночью (указание 28.09): днём в меню вместо змеи циферблат и
/// «Сейчас день», выбрать нельзя; наступило утро - змея развеивается, игрок в теле. «Ночь» -
/// та же, что у игры (World.IsDark: часы из настроек длины дня, плюс вечный день/ночь).</summary>
public const string DayIcon = "ui_game_symbol_clock";
public static bool IsNight()
{
World world = GameManager.Instance?.World;
return world != null && world.IsDark();
}
public static bool IsKnife(ItemClass _item)
{
return _item != null && _item.Name == KnifeName;
}
public static void Enter(EntityPlayerLocal _player)
{
if (Active || _player == null || _player.IsDead() || _player.AttachedToEntity != null)
{
return;
}
if (!IsNight())
{
Debug.Log("[NecromancerTome] SnakeMode: днём дух змеи недоступен");
return;
}
World world = GameManager.Instance.World;
EntityClass snakeClass = EntityClass.list[EntityClass.FromString(SnakeClassName)];
if (snakeClass == null)
{
Debug.LogWarning("[NecromancerTome] SnakeMode: класс '" + SnakeClassName + "' не найден");
return;
}
int cost = Mathf.CeilToInt(_player.GetMaxHealth() * HealthCost);
_player.Health = Mathf.Max(1, _player.Health - cost);
Vector3 pos = FindFreeSpot(world, _player, out _snakeDir);
float yaw = Quaternion.LookRotation(_snakeDir).eulerAngles.y;
Entity snake = EntityFactory.CreateEntity(EntityClass.FromString(SnakeClassName), pos, new Vector3(0f, yaw, 0f));
world.SpawnEntityInWorld(snake);
_snakeId = snake.entityId;
_placeSnake = true;
// Сначала третье лицо, потом падение. switchModelView меняет модель игрока, и рэгдолл,
// включённый до переключения, остаётся на старой модели - видимое тело стоит (проверено
// в игре 28.09: «персонаж остался стоять», хотя IsRagdollActive был true).
_player.SetFirstPersonView(false, true);
_ragdollAt = Time.time + RagdollDelay;
_waitStandUp = false;
Active = true;
Debug.Log("[NecromancerTome] SnakeMode: вход, здоровье " + _player.Health + ", змея " + _snakeId);
}
/// <summary>Свободное место для змеи: по кругу, начиная с направления взгляда, шагами по
/// 45 градусов; две клетки высоты без коллизии. Точное расстояние доводит PlaceSnake.</summary>
static Vector3 FindFreeSpot(World _world, EntityPlayerLocal _player, out Vector3 _dir)
{
float yaw = _player.rotation.y;
Vector3 origin = _player.position;
for (int step = 0; step < 8; step++)
{
int k = (step + 1) / 2 * (step % 2 == 0 ? -1 : 1);
Vector3 dir = Quaternion.Euler(0f, yaw + k * 45f, 0f) * Vector3.forward;
for (float dist = 1.5f; dist <= 3.5f; dist += 1f)
{
Vector3 p = origin + dir * dist;
if (IsFree(_world, p) && IsFree(_world, (origin + p) * 0.5f))
{
_dir = dir;
return p;
}
}
}
_dir = Quaternion.Euler(0f, yaw, 0f) * Vector3.forward;
return origin + _dir * 1.5f;
}
static bool IsFree(World _world, Vector3 _pos)
{
Vector3i b = World.worldToBlockPos(_pos);
return !_world.GetBlock(b).Block.IsCollideMovement && !_world.GetBlock(b + Vector3i.up).Block.IsCollideMovement;
}
/// <summary>Когда модель змеи уже есть, сдвигает её вдоль _snakeDir так, чтобы от тела
/// игрока до ближайшей точки змеи было Gap метров (по горизонтали).</summary>
static bool PlaceSnake(EntityPlayerLocal _player, EntityAlive _snake)
{
Transform model = _snake.emodel != null ? _snake.emodel.GetModelTransform() : null;
Renderer[] rs = model != null ? model.GetComponentsInChildren<Renderer>() : null;
if (rs == null || rs.Length == 0)
{
return false;
}
Bounds bounds = rs[0].bounds;
for (int i = 1; i < rs.Length; i++)
{
bounds.Encapsulate(rs[i].bounds);
}
Vector3 center = _player.position - Origin.position;
Vector3 closest = bounds.ClosestPoint(new Vector3(center.x, bounds.center.y, center.z));
float gap = new Vector2(closest.x - center.x, closest.z - center.z).magnitude - PlayerRadius;
Vector3 target = _snake.position + _snakeDir * (Gap - gap);
if (IsFree(GameManager.Instance.World, target))
{
_snake.SetPosition(target);
}
Debug.Log("[NecromancerTome] SnakeMode: змея " + bounds.size.ToString("F2") + ", зазор был " +
gap.ToString("F2") + " м, сдвиг " + (Gap - gap).ToString("F2") + " м");
return true;
}
public static void Exit(EntityPlayerLocal _player)
{
RemoveSnake();
Active = false;
if (_player == null || _player.IsDead())
{
return;
}
// Сначала встать, потом первое лицо: переключение вида меняет модель, и посреди
// рэгдолла это оставило бы тело лежать. Переключаем в OnUnityUpdate, когда встал.
_ragdollAt = 0f;
_player.emodel.ragdollTime = _player.emodel.ragdollDuration;
_waitStandUp = true;
_standUpDeadline = Time.time + 5f;
Debug.Log("[NecromancerTome] SnakeMode: выход");
}
public static EntityAlive Snake(World _world)
{
return _snakeId != -1 && _world != null ? _world.GetEntity(_snakeId) as EntityAlive : null;
}
static void RemoveSnake()
{
SnakeControl.End();
World world = GameManager.Instance.World;
if (_snakeId != -1 && world != null && world.GetEntity(_snakeId) != null)
{
world.RemoveEntity(_snakeId, EnumRemoveEntityReason.Despawned);
}
_snakeId = -1;
}
static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data)
{
World world = GameManager.Instance?.World;
if (world == null)
{
// Выход в меню посреди режима (тест 28.09: «прицел оставался на экране»).
Active = false;
_snakeId = -1;
SnakeControl.End();
return;
}
EntityPlayerLocal player = world.GetPrimaryPlayer();
if (!Active)
{
if (_waitStandUp && player != null && (!player.emodel.IsRagdollActive || Time.time > _standUpDeadline))
{
_waitStandUp = false;
player.SetFirstPersonView(true, true);
}
SweepStraySnakes(world);
return;
}
if (player == null)
{
return;
}
if (player.IsDead())
{
// Тело убито: змея исчезает, камеру и смерть ведёт игра.
RemoveSnake();
Active = false;
Debug.Log("[NecromancerTome] SnakeMode: тело убито, режим снят");
return;
}
EntityAlive snake = world.GetEntity(_snakeId) as EntityAlive;
if (snake == null)
{
Exit(player);
return;
}
snake.Health = snake.GetMaxHealth();
if (!IsNight())
{
Debug.Log("[NecromancerTome] SnakeMode: наступило утро - обратно в тело");
Exit(player);
return;
}
if (_placeSnake && PlaceSnake(player, snake))
{
_placeSnake = false;
SnakeControl.Begin(player, snake);
}
if (SnakeControl.Update(player, snake))
{
// Развеялась в радиации - обратно в тело.
Exit(player);
return;
}
if (_ragdollAt > 0f)
{
if (Time.time >= _ragdollAt)
{
_ragdollAt = 0f;
player.emodel.DoRagdoll(EModelBase.RagdollMode.Default, 999999f, EnumBodyPartHit.None, Vector3.zero, Vector3.zero, false);
Debug.Log("[NecromancerTome] SnakeMode: тело упало, рэгдолл " + player.emodel.IsRagdollActive);
}
}
else if (!player.emodel.IsRagdollActive)
{
player.emodel.DoRagdoll(EModelBase.RagdollMode.Default, 999999f, EnumBodyPartHit.None, Vector3.zero, Vector3.zero, false);
}
PlayerActionsLocal input = player.playerInput;
if (input != null && (input.Reload.WasPressed || input.PermanentActions.Reload.WasPressed))
{
Exit(player);
}
}
/// <summary>Змея не должна пережить сейв: после загрузки режима нет, а сущность в мире
/// осталась бы. Раз в пару секунд убираем все такие, если режим не активен.</summary>
static void SweepStraySnakes(World _world)
{
_sweepTimer += Time.deltaTime;
if (_sweepTimer < 2f)
{
return;
}
_sweepTimer = 0f;
if (!SingletonMonoBehaviour<ConnectionManager>.Instance.IsServer)
{
return;
}
int classId = EntityClass.FromString(SnakeClassName);
for (int i = _world.Entities.list.Count - 1; i >= 0; i--)
{
Entity e = _world.Entities.list[i];
// Подсветка зрения змеи, пережившая сейв: баффы зомби сохраняются.
if (e is EntityAlive alive && alive.Buffs != null &&
alive.Buffs.HasBuff(Patch_AttachParticleEffectToEntity_ShrinkCharmGlow.SnakeSightBuffName))
{
alive.Buffs.RemoveBuff(Patch_AttachParticleEffectToEntity_ShrinkCharmGlow.SnakeSightBuffName);
}
if (e != null && e.entityClass == classId)
{
_world.RemoveEntity(e.entityId, EnumRemoveEntityReason.Despawned);
}
}
}
public static void OnRadialCommand(XUiC_Radial _sender, int _commandIndex, XUiC_Radial.RadialContextAbs _context)
{
if (_commandIndex == 1)
{
Enter(_sender.xui.playerUI.entityPlayer);
}
}
}
[HarmonyPatch(typeof(ItemAction), nameof(ItemAction.HasRadial))]
public static class Patch_ItemAction_HasRadial_SnakeKnife
{
public static void Postfix(ItemAction __instance, ref bool __result)
{
if (!__result && !SnakeMode.Active && SnakeMode.IsKnife(__instance.item))
{
__result = true;
}
}
}
[HarmonyPatch(typeof(ItemAction), nameof(ItemAction.SetupRadial))]
public static class Patch_ItemAction_SetupRadial_SnakeKnife
{
public static bool Prefix(ItemAction __instance, XUiC_Radial _xuiRadialWindow, EntityPlayerLocal _epl)
{
if (!SnakeMode.IsKnife(__instance.item))
{
return true;
}
_xuiRadialWindow.ResetRadialEntries();
_xuiRadialWindow.CreateRadialEntry(0, "ui_game_symbol_player", "UIAtlas", "",
Localization.Get("necroSnakeModeHuman"), _highlighted: true);
if (SnakeMode.IsNight())
{
_xuiRadialWindow.CreateRadialEntry(1, "ui_game_symbol_tracking_snake", "UIAtlas", "",
Localization.Get("necroSnakeModeSnake"), _highlighted: false);
}
else
{
_xuiRadialWindow.CreateRadialEntry(1, SnakeMode.DayIcon, "UIAtlas", "",
Localization.Get("necroSnakeModeDay"), _highlighted: false);
}
_xuiRadialWindow.SetCommonData(UIUtils.GetButtonIconForAction(_epl.playerInput.Reload),
SnakeMode.OnRadialCommand, null, 0);
return false;
}
}
}
+305
View File
@@ -0,0 +1,305 @@
using System.Collections.Generic;
using System.Globalization;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Зрение змеи (указание 28.09: «всё в более контрастном виде и в красных тонах», «как комикс»).
/// Своих фильтров не заводим: у игры в Resources/ScreenEffects есть готовые, которыми она сама не
/// пользуется (проверено по коду и XML):
/// - "Vibrant" - цветокоррекция Code/ColorCorrection_Favale: _color, contrast, saturation,
/// lows/mids/highs, whitepoint, _Gamma. Её материал перенастраиваем под красный и возвращаем
/// исходные значения при выходе;
/// - "Posterize" - ступени цвета (_steps), плоский «комиксный» вид.
/// Что именно значит каждое свойство в шейдере Favale - видно только в игре, поэтому всё крутится
/// командой `necrovision` на ходу; найденные значения потом переносятся в Default*.
/// </summary>
public static class SnakeVision
{
public const string GradeEffect = "Vibrant";
public const string PosterEffect = "Posterize";
/// <summary>Первая проверка 28.09 ночью (03:04): «слишком темно, ничего не разглядеть» -
/// контраст и ступени загнали всё тёмное в чёрный. Под красное кладём игровое ночное
/// зрение (осветляет), контраст обычный, ступеней больше.</summary>
public const string NightEffect = "NightVision";
/// Проверка 28.09: «фильтр не пропускает другие цвета», «наложи в пол-силы, как бы
/// полупрозрачным». Сила эффекта у игры - шейдерное свойство Intensity (ScreenEffect.Render:
/// Material.SetFloat("Intensity", Clamp01)), а оно есть только у Vibrant и Bright. У
/// NightVision и Posterize его нет - они всегда во всю силу, и ночное зрение делает картинку
/// одноцветной. Поэтому по умолчанию они выключены, а оттенок и осветление - в половину.
public static float DefaultNight = 0f;
/// <summary>«Яркость можно и повысить» (28.09): эффект "Bright" - та же цветокоррекция Favale,
/// настроенная игрой на осветление, и игрой не используемый.</summary>
public const string BrightEffect = "Bright";
public static float DefaultBright = 0.5f;
public static float DefaultStrength = 0.5f;
/// <summary>Фиолетово-пурпурный оттенок, а не перекраска всего (указание 28.09: «эффект потустороннего,
/// но чтобы был оттенок»): светлый пурпур и насыщенность 0.6 - свои цвета мира остаются.</summary>
public static Color DefaultColor = new Color(0.8f, 0.55f, 1f, 1f);
public static float DefaultContrast = 1f;
public static float DefaultSaturation = 0.6f;
public static int DefaultSteps = 8;
static readonly string[] FloatProps = { "contrast", "saturation", "lows", "mids", "highs", "whitepoint", "_Gamma", "Intensity" };
static bool _on;
static bool _posterize = false;
static float? _strength;
static Material _grade;
static readonly Dictionary<string, float> _savedFloats = new Dictionary<string, float>();
static Color _savedColor;
static readonly Dictionary<string, float> _tuned = new Dictionary<string, float>();
static Color? _tunedColor;
static int _steps;
static float? _night;
static float? _bright;
public static void On(EntityPlayerLocal _player)
{
ScreenEffects fx = _player?.ScreenEffectManager;
if (fx == null || _on)
{
return;
}
_grade = Find(fx, GradeEffect)?.Material;
if (_grade == null)
{
Debug.LogWarning("[NecromancerTome] SnakeVision: эффекта '" + GradeEffect + "' нет");
return;
}
_savedFloats.Clear();
foreach (string p in FloatProps)
{
if (_grade.HasProperty(p))
{
_savedFloats[p] = _grade.GetFloat(p);
}
}
_savedColor = _grade.GetColor("_color");
_on = true;
Apply(fx);
Debug.Log("[NecromancerTome] SnakeVision: включено");
}
public static void Off(EntityPlayerLocal _player)
{
if (!_on)
{
return;
}
_on = false;
ScreenEffects fx = _player?.ScreenEffectManager;
fx?.SetScreenEffect(GradeEffect, 0f, 0f);
fx?.SetScreenEffect(PosterEffect, 0f, 0f);
fx?.SetScreenEffect(NightEffect, 0f, 0f);
fx?.SetScreenEffect(BrightEffect, 0f, 0f);
if (_grade != null)
{
foreach (KeyValuePair<string, float> kv in _savedFloats)
{
_grade.SetFloat(kv.Key, kv.Value);
}
_grade.SetColor("_color", _savedColor);
}
Debug.Log("[NecromancerTome] SnakeVision: выключено");
}
static void Apply(ScreenEffects _fx)
{
_grade.SetColor("_color", _tunedColor ?? DefaultColor);
SetF("contrast", DefaultContrast);
SetF("saturation", DefaultSaturation);
foreach (KeyValuePair<string, float> kv in _tuned)
{
_grade.SetFloat(kv.Key, kv.Value);
}
_fx.SetScreenEffect(GradeEffect, _strength ?? DefaultStrength, 0.3f);
Material poster = Find(_fx, PosterEffect)?.Material;
if (poster != null)
{
poster.SetFloat("_steps", _steps > 0 ? _steps : DefaultSteps);
}
_fx.SetScreenEffect(PosterEffect, _posterize ? 1f : 0f, 0.3f);
_fx.SetScreenEffect(NightEffect, _night ?? DefaultNight, 0.3f);
_fx.SetScreenEffect(BrightEffect, _bright ?? DefaultBright, 0.3f);
}
static void SetF(string _p, float _v)
{
if (_grade.HasProperty(_p))
{
_grade.SetFloat(_p, _v);
}
}
static ScreenEffects.ScreenEffect Find(ScreenEffects _fx, string _name)
{
foreach (ScreenEffects.ScreenEffect e in _fx.loadedEffects)
{
if (e != null && e.Name == _name)
{
return e;
}
}
return null;
}
static string Queue(string _name)
{
ScreenEffects fx = GameManager.Instance?.World?.GetPrimaryPlayer()?.ScreenEffectManager;
ScreenEffects.ScreenEffect e = fx != null ? Find(fx, _name) : null;
return e?.Material != null ? e.Material.renderQueue.ToString() : "?";
}
public static string Describe()
{
if (_grade == null)
{
return "necrovision: not initialised yet (enter snake mode or 'necrovision on')";
}
string s = "necrovision: " + (_on ? "ON" : "off") + ", posterize " + (_posterize ? (_steps > 0 ? _steps : DefaultSteps).ToString() : "off") +
", nv " + (_night ?? DefaultNight).ToString("0.##", CultureInfo.InvariantCulture) +
", bright " + (_bright ?? DefaultBright).ToString("0.##", CultureInfo.InvariantCulture) +
", strength " + (_strength ?? DefaultStrength).ToString("0.##", CultureInfo.InvariantCulture) +
", queue grade/poster/nv " + Queue(GradeEffect) + "/" + Queue(PosterEffect) + "/" + Queue(NightEffect) +
"\n color " + _grade.GetColor("_color").ToString("F2");
foreach (string p in FloatProps)
{
if (_grade.HasProperty(p))
{
s += "\n " + p + " " + _grade.GetFloat(p).ToString("0.###", CultureInfo.InvariantCulture);
}
}
return s;
}
/// <summary>Консоль: подстройка на ходу. Значения живут до конца сессии.</summary>
public static string Command(List<string> _params, EntityPlayerLocal _player)
{
if (_params.Count == 0)
{
return Describe();
}
string cmd = _params[0].ToLowerInvariant();
if (cmd == "on")
{
On(_player);
return Describe();
}
if (cmd == "off")
{
Off(_player);
return "necrovision: off";
}
if (cmd == "reset")
{
_tuned.Clear();
_tunedColor = null;
_steps = 0;
_posterize = false;
_night = null;
_strength = null;
_bright = null;
}
else if (cmd == "color" && _params.Count >= 4 &&
float.TryParse(_params[1], NumberStyles.Float, CultureInfo.InvariantCulture, out float r) &&
float.TryParse(_params[2], NumberStyles.Float, CultureInfo.InvariantCulture, out float g) &&
float.TryParse(_params[3], NumberStyles.Float, CultureInfo.InvariantCulture, out float b))
{
_tunedColor = new Color(r, g, b, 1f);
}
else if (cmd == "nv" && _params.Count >= 2 &&
float.TryParse(_params[1], NumberStyles.Float, CultureInfo.InvariantCulture, out float nv))
{
_night = nv;
}
else if (cmd == "strength" && _params.Count >= 2 &&
float.TryParse(_params[1], NumberStyles.Float, CultureInfo.InvariantCulture, out float st))
{
_strength = st;
}
else if (cmd == "bright" && _params.Count >= 2 &&
float.TryParse(_params[1], NumberStyles.Float, CultureInfo.InvariantCulture, out float br))
{
_bright = br;
}
else if (cmd == "posterize" && _params.Count >= 2)
{
if (_params[1] == "off")
{
_posterize = false;
}
else if (int.TryParse(_params[1], out int steps))
{
_posterize = true;
_steps = steps;
}
}
else if (_params.Count >= 2 && float.TryParse(_params[1], NumberStyles.Float, CultureInfo.InvariantCulture, out float v))
{
string prop = null;
foreach (string p in FloatProps)
{
if (p.ToLowerInvariant() == cmd || p.TrimStart('_').ToLowerInvariant() == cmd)
{
prop = p;
}
}
if (prop == null)
{
return "necrovision: unknown '" + cmd + "'. Try: help necrovision";
}
_tuned[prop] = v;
}
else
{
return "necrovision: bad arguments. Try: help necrovision";
}
if (_on && _player?.ScreenEffectManager != null)
{
Apply(_player.ScreenEffectManager);
}
return Describe();
}
}
[Preserve]
public class ConsoleCmdNecroVision : ConsoleCmdAbstract
{
public override bool IsExecuteOnClient => true;
public override bool AllowedInMainMenu => false;
public override string[] getCommands()
{
return new string[] { "necrovision" };
}
public override string getDescription()
{
return "Necromancer's Tome: tune the snake's vision filter live.";
}
public override string getHelp()
{
return "necrovision - show current values\n" +
"necrovision on | off - switch the filter (snake mode switches it itself)\n" +
"necrovision color R G B - tint, 0..1 each (default 0.8 0.55 1)\n" +
"necrovision contrast V - also: saturation, lows, mids, highs, whitepoint, gamma, intensity\n" +
"necrovision strength V - how strongly the tint lies over the picture, 0..1 (default 0.5)\n" +
"necrovision posterize N|off - flat colour steps, always full strength (default off)\n" +
"necrovision nv V - game night vision under the red, 0..1 (default 0 = off; full strength only, kills colour)\n" +
"necrovision bright V - game 'Bright' grade, 0 = off 0..1 (default 0.5)\n" +
"necrovision reset - back to defaults";
}
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
EntityPlayerLocal player = GameManager.Instance.World?.GetPrimaryPlayer();
SingletonMonoBehaviour<SdtdConsole>.Instance.Output(SnakeVision.Command(_params, player));
}
}
}