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

Ночью по 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
+5
View File
@@ -164,3 +164,8 @@ necroVersionGuardTitle,UI,XUi MainMenu,x,,,Necromancer's Tome: incompatible game
necroVersionGuardText,UI,XUi MainMenu,x,,,"Necromancer's Tome {0} is made for 7 Days to Die {1} and is not compatible with your game version ({2}). Playing with it would damage your saves. Please update the mod to the version made for your game, or remove it. The game will now close.",,Necromancer's Tome {0} ist für 7 Days to Die {1} gemacht und nicht mit deiner Spielversion ({2}) kompatibel. Damit zu spielen würde deine Spielstände beschädigen. Bitte aktualisiere die Mod auf die Version für dein Spiel oder entferne sie. Das Spiel wird jetzt beendet.,Necromancer's Tome {0} está hecho para 7 Days to Die {1} y no es compatible con tu versión del juego ({2}). Jugar con él dañaría tus partidas guardadas. Actualiza el mod a la versión para tu juego o elimínalo. El juego se cerrará ahora.,"Necromancer's Tome {0} est conçu pour 7 Days to Die {1} et n'est pas compatible avec ta version du jeu ({2}). Jouer avec endommagerait tes sauvegardes. Mets le mod à jour vers la version prévue pour ton jeu, ou retire-le. Le jeu va maintenant se fermer.",Necromancer's Tome {0} è fatto per 7 Days to Die {1} e non è compatibile con la tua versione del gioco ({2}). Giocarci danneggerebbe i tuoi salvataggi. Aggiorna la mod alla versione per il tuo gioco oppure rimuovila. Il gioco ora verrà chiuso.,Necromancer's Tome {0} は 7 Days to Die {1} 用に作られており、お使いのゲームバージョン({2})とは互換性がありません。このままプレイするとセーブデータが損傷します。お使いのゲーム用のバージョンにMODを更新するか、MODを削除してください。ゲームを終了します。,Necromancer's Tome {0}은(는) 7 Days to Die {1}용으로 만들어졌으며 현재 게임 버전({2})과 호환되지 않습니다. 이대로 플레이하면 저장 파일이 손상됩니다. 게임에 맞는 버전으로 모드를 업데이트하거나 모드를 제거하세요. 이제 게임이 종료됩니다.,Necromancer's Tome {0} jest przeznaczony dla 7 Days to Die {1} i nie jest zgodny z twoją wersją gry ({2}). Gra z nim uszkodziłaby twoje zapisy. Zaktualizuj moda do wersji dla twojej gry albo go usuń. Gra zostanie teraz zamknięta.,Necromancer's Tome {0} foi feito para 7 Days to Die {1} e não é compatível com a sua versão do jogo ({2}). Jogar com ele danificaria seus saves. Atualize o mod para a versão feita para o seu jogo ou remova-o. O jogo será fechado agora.,"Мод «Книга некроманта» {0} сделан для 7 Days to Die {1} и несовместим с вашей версией игры ({2}). Игра с ним повредит ваши сохранения. Пожалуйста, обновите мод до версии для вашей игры или удалите его. Сейчас игра закроется.","Necromancer's Tome {0}, 7 Days to Die {1} için yapıldı ve oyun sürümünüzle ({2}) uyumlu değil. Onunla oynamak kayıtlarınıza zarar verir. Lütfen modu oyununuza uygun sürüme güncelleyin veya kaldırın. Oyun şimdi kapanacak.",Necromancer's Tome {0} 为 7 Days to Die {1} 制作,与你的游戏版本({2})不兼容。继续使用会损坏你的存档。请将模组更新到适合你游戏的版本,或将其移除。游戏现在将关闭。,Necromancer's Tome {0} 為 7 Days to Die {1} 製作,與你的遊戲版本({2})不相容。繼續使用會損壞你的存檔。請將模組更新到適合你遊戲的版本,或將其移除。遊戲現在將關閉。
necroVersionGuardQuit,UI,XUi MainMenu,x,,,Quit game,,Spiel beenden,Salir del juego,Quitter le jeu,Esci dal gioco,ゲームを終了,게임 종료,Wyjdź z gry,Sair do jogo,Выйти из игры,Oyundan çık,退出游戏,退出遊戲
braceletSpatialVaultUnreadable,items,Item,,,,This storage rift was saved by another version of the mod. It is kept untouched until you install the mod made for your game version.,,"Dieser Lagerriss wurde von einer anderen Version der Mod gespeichert. Er bleibt unangetastet, bis du die Mod für deine Spielversion installierst.",Esta grieta de almacenamiento se guardó con otra versión del mod. Se conserva intacta hasta que instales el mod para tu versión del juego.,Cette faille de stockage a été enregistrée par une autre version du mod. Elle reste intacte jusqu'à ce que tu installes le mod prévu pour ta version du jeu.,Questa fenditura di deposito è stata salvata da un'altra versione della mod. Resta intatta finché non installi la mod per la tua versione del gioco.,この収納の裂け目は別のバージョンのMODで保存されました。お使いのゲームバージョン用のMODを入れるまで、手を付けずに保持されます。,이 보관 균열은 다른 버전의 모드로 저장되었습니다. 게임 버전에 맞는 모드를 설치할 때까지 그대로 보존됩니다.,"Ta szczelina magazynowa została zapisana przez inną wersję moda. Pozostaje nietknięta, dopóki nie zainstalujesz moda dla twojej wersji gry.",Esta fenda de armazenamento foi salva por outra versão do mod. Ela fica intacta até você instalar o mod feito para a sua versão do jogo.,"Этот разлом хранилища сохранён другой версией мода. Он останется нетронутым, пока вы не поставите мод для вашей версии игры.",Bu depolama yarığı modun başka bir sürümüyle kaydedildi. Oyun sürümünüze uygun modu kurana kadar dokunulmadan saklanır.,这个储物裂隙由另一个版本的模组保存。在你安装适合你游戏版本的模组之前,它会原样保留。,這個儲物裂隙由另一個版本的模組儲存。在你安裝適合你遊戲版本的模組之前,它會原樣保留。
necroSnakeModeHuman,items,Item,,,,Human,,Mensch,Humano,Humain,Umano,人間,인간,Człowiek,Humano,Человек,İnsan,人类,人類
necroSnakeModeSnake,items,Item,,,,Snake,,Schlange,Serpiente,Serpent,Serpente,蛇,뱀,Wąż,Cobra,Змея,Yılan,蛇,蛇
necroSnakeBodyDanger,UI,Label,,,,Enemy approaching your body,,Ein Feind nähert sich deinem Körper,Un enemigo se acerca a tu cuerpo,Un ennemi approche de ton corps,Un nemico si avvicina al tuo corpo,敵が体に近づいている,적이 몸에 다가오고 있다,Wróg zbliża się do twojego ciała,Um inimigo se aproxima do seu corpo,Враг приближается к телу,Bir düşman bedenine yaklaşıyor,敌人正在接近你的身体,敵人正在接近你的身體
necroSnakeModeDay,items,Item,,,,It is daytime,,Es ist Tag,Es de día,Il fait jour,È giorno,今は昼だ,지금은 낮이다,Jest dzień,Agora é dia,Сейчас день,Şu an gündüz,现在是白天,現在是白天
necroSnakeSpirit,entityclasses,Entity,,,,Snake Spirit,,Schlangengeist,Espíritu de serpiente,Esprit du serpent,Spirito del serpente,蛇の霊,뱀의 영혼,Duch węża,Espírito da serpente,Дух змеи,Yılan Ruhu,蛇灵,蛇靈
1 Key File Type UsedInMainMenu NoTranslate KeepLoaded english Context / Alternate Text german spanish french italian japanese koreana polish brazilian russian turkish schinese tchinese
164 necroVersionGuardText UI XUi MainMenu x Necromancer's Tome {0} is made for 7 Days to Die {1} and is not compatible with your game version ({2}). Playing with it would damage your saves. Please update the mod to the version made for your game, or remove it. The game will now close. Necromancer's Tome {0} ist für 7 Days to Die {1} gemacht und nicht mit deiner Spielversion ({2}) kompatibel. Damit zu spielen würde deine Spielstände beschädigen. Bitte aktualisiere die Mod auf die Version für dein Spiel oder entferne sie. Das Spiel wird jetzt beendet. Necromancer's Tome {0} está hecho para 7 Days to Die {1} y no es compatible con tu versión del juego ({2}). Jugar con él dañaría tus partidas guardadas. Actualiza el mod a la versión para tu juego o elimínalo. El juego se cerrará ahora. Necromancer's Tome {0} est conçu pour 7 Days to Die {1} et n'est pas compatible avec ta version du jeu ({2}). Jouer avec endommagerait tes sauvegardes. Mets le mod à jour vers la version prévue pour ton jeu, ou retire-le. Le jeu va maintenant se fermer. Necromancer's Tome {0} è fatto per 7 Days to Die {1} e non è compatibile con la tua versione del gioco ({2}). Giocarci danneggerebbe i tuoi salvataggi. Aggiorna la mod alla versione per il tuo gioco oppure rimuovila. Il gioco ora verrà chiuso. Necromancer's Tome {0} は 7 Days to Die {1} 用に作られており、お使いのゲームバージョン({2})とは互換性がありません。このままプレイするとセーブデータが損傷します。お使いのゲーム用のバージョンにMODを更新するか、MODを削除してください。ゲームを終了します。 Necromancer's Tome {0}은(는) 7 Days to Die {1}용으로 만들어졌으며 현재 게임 버전({2})과 호환되지 않습니다. 이대로 플레이하면 저장 파일이 손상됩니다. 게임에 맞는 버전으로 모드를 업데이트하거나 모드를 제거하세요. 이제 게임이 종료됩니다. Necromancer's Tome {0} jest przeznaczony dla 7 Days to Die {1} i nie jest zgodny z twoją wersją gry ({2}). Gra z nim uszkodziłaby twoje zapisy. Zaktualizuj moda do wersji dla twojej gry albo go usuń. Gra zostanie teraz zamknięta. Necromancer's Tome {0} foi feito para 7 Days to Die {1} e não é compatível com a sua versão do jogo ({2}). Jogar com ele danificaria seus saves. Atualize o mod para a versão feita para o seu jogo ou remova-o. O jogo será fechado agora. Мод «Книга некроманта» {0} сделан для 7 Days to Die {1} и несовместим с вашей версией игры ({2}). Игра с ним повредит ваши сохранения. Пожалуйста, обновите мод до версии для вашей игры или удалите его. Сейчас игра закроется. Necromancer's Tome {0}, 7 Days to Die {1} için yapıldı ve oyun sürümünüzle ({2}) uyumlu değil. Onunla oynamak kayıtlarınıza zarar verir. Lütfen modu oyununuza uygun sürüme güncelleyin veya kaldırın. Oyun şimdi kapanacak. Necromancer's Tome {0} 为 7 Days to Die {1} 制作,与你的游戏版本({2})不兼容。继续使用会损坏你的存档。请将模组更新到适合你游戏的版本,或将其移除。游戏现在将关闭。 Necromancer's Tome {0} 為 7 Days to Die {1} 製作,與你的遊戲版本({2})不相容。繼續使用會損壞你的存檔。請將模組更新到適合你遊戲的版本,或將其移除。遊戲現在將關閉。
165 necroVersionGuardQuit UI XUi MainMenu x Quit game Spiel beenden Salir del juego Quitter le jeu Esci dal gioco ゲームを終了 게임 종료 Wyjdź z gry Sair do jogo Выйти из игры Oyundan çık 退出游戏 退出遊戲
166 braceletSpatialVaultUnreadable items Item This storage rift was saved by another version of the mod. It is kept untouched until you install the mod made for your game version. Dieser Lagerriss wurde von einer anderen Version der Mod gespeichert. Er bleibt unangetastet, bis du die Mod für deine Spielversion installierst. Esta grieta de almacenamiento se guardó con otra versión del mod. Se conserva intacta hasta que instales el mod para tu versión del juego. Cette faille de stockage a été enregistrée par une autre version du mod. Elle reste intacte jusqu'à ce que tu installes le mod prévu pour ta version du jeu. Questa fenditura di deposito è stata salvata da un'altra versione della mod. Resta intatta finché non installi la mod per la tua versione del gioco. この収納の裂け目は別のバージョンのMODで保存されました。お使いのゲームバージョン用のMODを入れるまで、手を付けずに保持されます。 이 보관 균열은 다른 버전의 모드로 저장되었습니다. 게임 버전에 맞는 모드를 설치할 때까지 그대로 보존됩니다. Ta szczelina magazynowa została zapisana przez inną wersję moda. Pozostaje nietknięta, dopóki nie zainstalujesz moda dla twojej wersji gry. Esta fenda de armazenamento foi salva por outra versão do mod. Ela fica intacta até você instalar o mod feito para a sua versão do jogo. Этот разлом хранилища сохранён другой версией мода. Он останется нетронутым, пока вы не поставите мод для вашей версии игры. Bu depolama yarığı modun başka bir sürümüyle kaydedildi. Oyun sürümünüze uygun modu kurana kadar dokunulmadan saklanır. 这个储物裂隙由另一个版本的模组保存。在你安装适合你游戏版本的模组之前,它会原样保留。 這個儲物裂隙由另一個版本的模組儲存。在你安裝適合你遊戲版本的模組之前,它會原樣保留。
167 necroSnakeModeHuman items Item Human Mensch Humano Humain Umano 人間 인간 Człowiek Humano Человек İnsan 人类 人類
168 necroSnakeModeSnake items Item Snake Schlange Serpiente Serpent Serpente 蛇 뱀 Wąż Cobra Змея Yılan 蛇 蛇
169 necroSnakeBodyDanger UI Label Enemy approaching your body Ein Feind nähert sich deinem Körper Un enemigo se acerca a tu cuerpo Un ennemi approche de ton corps Un nemico si avvicina al tuo corpo 敵が体に近づいている 적이 몸에 다가오고 있다 Wróg zbliża się do twojego ciała Um inimigo se aproxima do seu corpo Враг приближается к телу Bir düşman bedenine yaklaşıyor 敌人正在接近你的身体 敵人正在接近你的身體
170 necroSnakeModeDay items Item It is daytime Es ist Tag Es de día Il fait jour È giorno 今は昼だ 지금은 낮이다 Jest dzień Agora é dia Сейчас день Şu an gündüz 现在是白天 現在是白天
171 necroSnakeSpirit entityclasses Entity Snake Spirit Schlangengeist Espíritu de serpiente Esprit du serpent Spirito del serpente 蛇の霊 뱀의 영혼 Duch węża Espírito da serpente Дух змеи Yılan Ruhu 蛇灵 蛇靈
+15
View File
@@ -116,6 +116,21 @@
It reads green/energy rather than "necromantic purple", but it's a real full-body
glow, not just a floating cloud; RemoveParticleEffectFromEntity on death/removal so it
doesn't linger on the corpse. -->
<!-- Зрение змеи: враг рядом со змеёй светится по контуру гнилостно-зелёным (указание
2026-09-28: «подсвети, а не перекрась», «по контуру, особенно ярким»). То же свечение
RadiatedParticlesOnMesh, что у девиантов и Жертвы; цвет, размер и яркость задаёт
HarmonySrc/ParticlePatch.cs (SnakeSightGlow). Вешает и снимает SnakeControl.Sense;
оставшиеся после сейва снимает SnakeMode при следующей загрузке. -->
<buff name="buffNecroSnakeSight" hidden="true">
<stack_type value="ignore"/>
<duration value="0"/>
<effect_group>
<triggered_effect trigger="onSelfBuffStart" action="AttachParticleEffectToEntity" particle="RadiatedParticlesOnMesh" local_offset="0,0.75,0"/>
<triggered_effect trigger="onSelfBuffRemove" action="RemoveParticleEffectFromEntity" particle="RadiatedParticlesOnMesh"/>
<triggered_effect trigger="onSelfDied" action="RemoveParticleEffectFromEntity" particle="RadiatedParticlesOnMesh"/>
</effect_group>
</buff>
<buff name="buffNecroDeviatorCharm" hidden="true">
<stack_type value="ignore"/>
<duration value="0"/>
+21
View File
@@ -409,4 +409,25 @@
<property name="Faction" value="none"/>
</entity_class>
</append>
<!-- Призрачная змея режима змеи Ножа некроманта (HarmonySrc/SnakeMode.cs, продиктовано 2026-09-28).
Ванильная змея вдвое больше, без ИИ: ею управляет игрок. Бессмертие, призрачность и
уборка после загрузки сейва - в коде. Faction undead от animalSnake: зомби её не трогают. -->
<append xpath="/entity_classes">
<entity_class name="necroSnakeSpirit" extends="animalSnake">
<property name="UserSpawnType" value="None"/>
<property name="Tags" value="entity,animal,snake,necroSnakeSpirit"/>
<property name="SizeScale" value="1.4"/>
<property name="AITask-1" value=""/>
<property name="AITask-2" value=""/>
<property name="AITask-3" value=""/>
<property name="AITask-4" value=""/>
<property name="AITask-5" value=""/>
<property name="AITask-6" value=""/>
<property name="AITarget-1" value=""/>
<property name="AITarget-2" value=""/>
<property name="AITarget-3" value=""/>
<property name="ExperienceGain" value="0"/>
</entity_class>
</append>
</config>
+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));
}
}
}
Binary file not shown.
Binary file not shown.