Режим змеи для Ножа некроманта и чёрный разрыв
Ночью по 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:
co-authored by
Claude Opus 5.5
parent
4cdd315b98
commit
6ff76d174e
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user