Compare commits
7
Commits
f4470b784f
...
4b54b48d68
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b54b48d68 | ||
|
|
e71f1aef35 | ||
|
|
6ff76d174e | ||
|
|
4cdd315b98 | ||
|
|
e25544521d | ||
|
|
1c2e3f2cdf | ||
|
|
e8e21d5a58 |
File diff suppressed because one or more lines are too long
@@ -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"/>
|
||||
|
||||
@@ -388,7 +388,10 @@
|
||||
uses this one directly or one of the three OTHER confirmed variants, blue/red/gold) per
|
||||
the fallback plan agreed on before this was ever guessed at - not visually distinct from a
|
||||
normal zombie drop, but a real, working bag with the right guaranteed contents.
|
||||
Force-spawned by HarmonySrc/VictimPatch.cs when a zombie with buffNecroVictim dies -
|
||||
С 1.5.0 (2026-09-26) НЕ СОЗДАЁТСЯ: труп «Жертвы» сам открывается как контейнер
|
||||
(HarmonySrc/CorpseLootPatch.cs). Класс оставлен ради сейвов - в мирах игроков могут
|
||||
лежать уже выпавшие мешки, и без класса они не загрузятся. Не удалять.
|
||||
Был: force-spawned by HarmonySrc/VictimPatch.cs when a zombie with buffNecroVictim dies -
|
||||
never rolled by the normal LootDropProb/LootDropEntityClass system (not referenced from
|
||||
any zombie's own entity_class), so its own IsEnemyEntity/Faction/etc. just mirror the
|
||||
other loot containers for consistency, not because anything reads them via that path. -->
|
||||
@@ -406,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>
|
||||
|
||||
+22
-9
@@ -1361,8 +1361,23 @@
|
||||
fast. 120 matches that same reference point. -->
|
||||
<passive_effect name="AttacksPerMinute" operation="base_set" value="120"/>
|
||||
|
||||
<triggered_effect trigger="onSelfAttackedOther" action="AddBuff" target="other" buff="buffNecroVictim">
|
||||
<!-- МЕТКА ЖЕРТВЫ - ДО УРОНА, 2026-09-26 (1.5.0; отзыв sanovskiy на Nexus: «убил ножом с
|
||||
одного удара - Жертвы нет, приходится бить дважды»). Раньше метка висела на
|
||||
onSelfAttackedOther, а оно приходит ПОСЛЕ DamageEntity (ItemActionAttack.cs:838 в
|
||||
3.3). На смертельном ударе внутри DamageEntity успевают пройти Kill ->
|
||||
OnEntityDeath -> dropItemOnDeath (EntityAlive.cs:3050, 5470), и VictimPatch
|
||||
разыгрывал добычу ещё без метки; метка потом ложилась уже на труп.
|
||||
*ActionRayHit приходят ДО урона (ItemActionDynamic.cs:541, Hit - строка 598), как
|
||||
у лечения ниже, поэтому метка оказывается на зомби раньше его смерти. Два триггера:
|
||||
обычный и силовой удар, как было и с onSelfAttackedOther. Гейт Health GT 0 теперь
|
||||
безопасен (здоровье до урона) и не даёт метить трупы при разделке. -->
|
||||
<triggered_effect trigger="onSelfPrimaryActionRayHit" action="AddBuff" target="other" buff="buffNecroVictim">
|
||||
<requirement name="EntityTagCompare" target="other" tags="zombie"/>
|
||||
<requirement name="StatCompareCurrent" target="other" stat="Health" operation="GT" value="0"/>
|
||||
</triggered_effect>
|
||||
<triggered_effect trigger="onSelfSecondaryActionRayHit" action="AddBuff" target="other" buff="buffNecroVictim">
|
||||
<requirement name="EntityTagCompare" target="other" tags="zombie"/>
|
||||
<requirement name="StatCompareCurrent" target="other" stat="Health" operation="GT" value="0"/>
|
||||
</triggered_effect>
|
||||
<!-- ЛЕЧЕНИЕ СНЯТО С СИЛОВОГО УДАРА 2026-09-09 (пользователь: "лучше вообще не
|
||||
лечить на силовом ударе"). Триггер сменён с onSelfAttackedOther на
|
||||
@@ -1399,8 +1414,8 @@
|
||||
бафф в buffs.xml, а не из фактически нанесённого урона.
|
||||
- Событие летит и при попадании по блоку, но там Other пуст, и
|
||||
EntityTagCompare tags="zombie" такой вызов отсекает - как и раньше.
|
||||
buffNecroVictim НЕ трогали: он остался на onSelfAttackedOther, то есть метка
|
||||
Жертвы по-прежнему вешается и обычным, и силовым ударом.
|
||||
buffNecroVictim тогда НЕ трогали (остался на onSelfAttackedOther). С 1.5.0 он
|
||||
тоже на *ActionRayHit - см. комментарий у самой метки выше.
|
||||
|
||||
LIFESTEAL BUG FIXED 2026-09-07 (user report: "нож не подлечивает"). The old
|
||||
version did only "ModifyCVar medicalRegHealthAmount add @$necroKnifeHealCVar",
|
||||
@@ -1439,12 +1454,10 @@
|
||||
Все они висят на *ActionRayHit, а это событие уходит ДО применения урона
|
||||
(ItemActionDynamic.cs:541, ItemActionAttack.Hit вызывается в конце того же
|
||||
метода) - значит у живой цели здоровье в этот момент ещё больше нуля, включая
|
||||
смертельный удар. А buffNecroVictim сидит на onSelfAttackedOther, которое
|
||||
уходит ПОСЛЕ DamageEntity (ItemActionAttack.cs:838, сразу за строкой 827): на
|
||||
смертельном ударе зомби там уже мёртв и его здоровье 0, так что такой же гейт
|
||||
снял бы метку ровно с добивающего удара - то есть отобрал бы мешок Жертвы у
|
||||
всех, кого убили с одного удара. Поэтому метка оставлена без гейта; повесить
|
||||
её на уже мёртвого зомби безвредно - мешок для него всё равно уже разыгран.
|
||||
смертельный удар. (До 1.5.0 buffNecroVictim сидел на onSelfAttackedOther, после
|
||||
DamageEntity, и потому был без гейта - на добивающем ударе здоровье там уже 0.
|
||||
Эта же поздняя метка и была причиной «Жертвы нет при убийстве с одного удара»:
|
||||
добыча разыгрывалась до неё. С 1.5.0 метка тоже на *ActionRayHit и с гейтом.)
|
||||
|
||||
AOE-эффекты "Мёртвой бури" гейтить не нужно: World/Chunk.GetLivingEntitiesInBounds
|
||||
(Chunk.cs:2920) сама отсеивает цели по !IsDead(), так что трупы в выборку не
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
<config>
|
||||
<!-- "Жертва" (Victim) green loot bag - BACKLOG.md item 5, Necromancer's Knife. Guaranteed
|
||||
drop off a zombie killed while carrying buffNecroVictim (see HarmonySrc/VictimPatch.cs,
|
||||
which force-spawns EntityLootContainerVictim from entityclasses.xml on death, bypassing
|
||||
the normal LootDropProb roll entirely).
|
||||
<!-- "Жертва" (Victim) loot - BACKLOG.md item 5, Necromancer's Knife. С 1.5.0 это список
|
||||
добычи САМОГО ТРУПА зомби, умершего с buffNecroVictim: труп получает сумку с этим
|
||||
списком и открывается как ящик (HarmonySrc/CorpseLootPatch.cs, CorpseLoot.VictimLootList).
|
||||
До 1.5.0 список наполнял мешок EntityLootContainerVictim, который появлялся при обыске.
|
||||
|
||||
Contents per user: random hides (usually one or none), rotten flesh, bones, and a chance
|
||||
at "Кожа жертвы" (resourceVictimSkin, items.xml) - a crafting resource for summon books.
|
||||
|
||||
+202
-54
@@ -33,15 +33,16 @@ namespace NecromancerTome
|
||||
/// подпись на экране, и значок берутся готовые, локализованные на все языки игры, и
|
||||
/// игроку это выглядит ровно как обыск мешка, потому что это и есть он.
|
||||
///
|
||||
/// 3. ВНУТРИ ЛУТ. А вот здесь сознательный обход. Само хранилище живёт внутри
|
||||
/// `EntityLootContainer`: его `bag`, его `Read`/`Write` для сейва, его сетевая
|
||||
/// синхронизация. Приделать всё это трупу зомби — работа масштаба Пространственного
|
||||
/// хранилища, с выходом на сейвы, то есть на риск номер один этого проекта.
|
||||
///
|
||||
/// Поэтому обыск НЕ открывает труп, а ПРЕВРАЩАЕТ его в мешок «Жертва»: мешок спавнится
|
||||
/// на месте трупа, труп убирается. Снаружи читается как «обыскал и нашёл», внутри —
|
||||
/// обычный ванильный контейнер, который сам себя сохраняет, синхронизирует и открывает.
|
||||
/// Ни одной новой сущности с состоянием.
|
||||
/// 3. ВНУТРИ ЛУТ, И ОТКРЫВАЕТСЯ САМ ТРУП (1.5.0, 2026-09-26; пользователь: «труп сам
|
||||
/// становится обыскиваемым объектом», как машина или ящик). До 1.5.0 обыск превращал
|
||||
/// труп в мешок «Жертва» - оказалось, что это лишнее: сумка есть у ЛЮБОЙ сущности.
|
||||
/// `Entity.bag` + `Entity.lootList`, первое открытие наполняет сумку по списку
|
||||
/// (LootManager.LootBagOpened -> GetLootList()), окно - XUiC_BagStorageWindowGroup, тот
|
||||
/// же путь, что у мешков, машин и дронов. Сумка сущности пишется в сейв
|
||||
/// (EntityCreationData.bagData), а мёртвые зомби сохраняются (EntityEnemy.IsSavedToFile).
|
||||
/// Единственный запрет - EntityAlive.OnLockRequestServer/OnLockResponseLocal не пускают
|
||||
/// мёртвых (`if (!IsDead())`); для помеченных трупов его снимают два префикса ниже.
|
||||
/// Таймер обыска - игровой, окна добычи (runOpenTimer при первом открытии).
|
||||
///
|
||||
/// ОБЫЧНЫЙ МЕШОК ЭТО НЕ ОТМЕНЯЕТ. Бросок вероятности в VictimPatch.cs остаётся как был: выпал
|
||||
/// — обычный мешок лежит рядом, не выпал — только труп. «Жертва» переехала из «падает сразу» в
|
||||
@@ -71,12 +72,21 @@ namespace NecromancerTome
|
||||
public const string SearchCommand = "search";
|
||||
public const string SearchIcon = "hand";
|
||||
|
||||
/// <summary>Секунды удержания. Круговой индикатор рисует сама игра - XUiC_Timer, тот же,
|
||||
/// что крутится при снятии верстака.</summary>
|
||||
public const float SearchSeconds = 3f;
|
||||
/// <summary>Тег, по которому после загрузки отличаем наш труп (RestoreLoadedCorpses).</summary>
|
||||
public static readonly FastTags<TagGroup.Global> ZombieTag = FastTags<TagGroup.Global>.Parse("zombie");
|
||||
|
||||
/// <summary>Трупы, которые ещё не обысканы. Ключ — entityId. Чистится сама: запись
|
||||
/// снимается при обыске, а остальные уходят вместе с миром.</summary>
|
||||
/// <summary>Список добычи трупа - тот же, что был у мешка «Жертва» (Config/loot.xml).</summary>
|
||||
public const string VictimLootList = "zPackVictim";
|
||||
|
||||
/// <summary>Сколько лежит труп, который уже открывали, пока в нём что-то осталось. Столько
|
||||
/// же лежал мешок «Жертва» (TimeStayAfterDeath 3600): добыча не должна пропадать через
|
||||
/// 5 минут только потому, что игрок отошёл. Таких трупов единицы, а опустошённый убирается
|
||||
/// сразу (RemoveEmptiedCorpses), так что 5-минутное правило 1.4.1 про FPS не нарушается.</summary>
|
||||
public const float OpenedCorpseSecondsAfterDeath = 3600f;
|
||||
|
||||
/// <summary>Трупы, которые можно открыть. Ключ — entityId. Запись снимается, когда труп
|
||||
/// исчезает (ReleaseGoneCorpses, RemoveEmptiedCorpses); после загрузки сейва
|
||||
/// восстанавливается по метке «Жертвы» (RestoreLoadedCorpses).</summary>
|
||||
public static readonly HashSet<int> SearchableCorpses = new HashSet<int>();
|
||||
|
||||
/// <summary>Трупы, которые ещё не удалось перекрасить: модель бывает не собрана на том
|
||||
@@ -118,6 +128,11 @@ namespace NecromancerTome
|
||||
/// <summary>Повторная попытка перекраски. Зовётся из общего тика раз в секунду.</summary>
|
||||
public static void RetryPendingTints(World _world)
|
||||
{
|
||||
if (_world != null)
|
||||
{
|
||||
RestoreLoadedCorpses(_world);
|
||||
RemoveEmptiedCorpses(_world);
|
||||
}
|
||||
ReleaseGoneCorpses(_world);
|
||||
if (PendingTint.Count == 0 || _world == null)
|
||||
{
|
||||
@@ -237,6 +252,7 @@ namespace NecromancerTome
|
||||
return;
|
||||
}
|
||||
SearchableCorpses.Add(_victim.entityId);
|
||||
GiveBag(_victim);
|
||||
|
||||
// timeStayAfterDeath хранится в ТИКАХ: игра заполняет его как TimeStayAfterDeath * 20
|
||||
// (EntityAlive.CopyPropertiesFromEntityClass), то есть двадцать тиков на секунду.
|
||||
@@ -413,15 +429,101 @@ namespace NecromancerTome
|
||||
return painted;
|
||||
}
|
||||
|
||||
/// <summary>Обыск: труп превращается в мешок «Жертва» на своём же месте.</summary>
|
||||
public static void SearchCorpse(EntityAlive _corpse)
|
||||
/// <summary>Сумка и список добычи трупа. Сумка пустая и нетронутая: наполнит её сама игра
|
||||
/// при первом открытии. Уже существующую (труп из сейва) не трогаем - в ней то, что игрок
|
||||
/// не забрал.</summary>
|
||||
public static void GiveBag(EntityAlive _corpse)
|
||||
{
|
||||
SearchableCorpses.Remove(_corpse.entityId);
|
||||
Patch_EntityAlive_dropItemOnDeath_VictimBag.SpawnVictimBag(_corpse, false);
|
||||
Debug.Log("[NecromancerTome] CorpseLoot: труп " + _corpse.entityId + " обыскан, заменён мешком");
|
||||
if (_corpse.world != null)
|
||||
_corpse.lootList = VictimLootList;
|
||||
if (_corpse.bag != null && _corpse.bag.SlotCount > 0)
|
||||
{
|
||||
_corpse.world.RemoveEntity(_corpse.entityId, EnumRemoveEntityReason.Despawned);
|
||||
return;
|
||||
}
|
||||
LootContainer container = LootContainer.GetLootContainer(VictimLootList, false);
|
||||
if (container == null)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] CorpseLoot: нет списка добычи '" + VictimLootList +
|
||||
"' - труп " + _corpse.entityId + " откроется пустым");
|
||||
return;
|
||||
}
|
||||
if (_corpse.bag == null)
|
||||
{
|
||||
_corpse.bag = new Bag(container.size, XUiC_ItemStack.StackLocationTypes.LootContainer, _corpse);
|
||||
}
|
||||
else
|
||||
{
|
||||
_corpse.bag.Resize(container.size);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Трупы из сейва. Наша пометка живёт только в памяти, а срок трупа при загрузке
|
||||
/// снова берётся из entity_class (30 с у зомби) - без этого труп с добычей исчез бы через
|
||||
/// полминуты после загрузки. Тик раз в секунду.
|
||||
///
|
||||
/// УЗНАЁМ ПО СУМКЕ, А НЕ ПО МЕТКЕ «ЖЕРТВЫ» (проверено в игре 2026-09-26: по метке труп после
|
||||
/// загрузки не узнавался и становился обычным). Баффы зомби в сейв НЕ пишутся:
|
||||
/// EntityAlive.Write кладёт только deathHealth и StressAmount, EntityCreationData - stats и
|
||||
/// bagData. Сумка же сохраняется (bagData), а своей сумки у зомби не бывает: ни у одного из
|
||||
/// 154 классов с тегом zombie в Data/Config/entityclasses.xml нет LootList (сверено 3.3).
|
||||
/// Значит, мёртвый зомби с непустой по размеру сумкой - это наш труп.</summary>
|
||||
public static void RestoreLoadedCorpses(World _world)
|
||||
{
|
||||
List<Entity> list = _world.Entities.list;
|
||||
for (int i = 0; i < list.Count; i++)
|
||||
{
|
||||
if (!(list[i] is EntityAlive alive) || !alive.IsDead() || SearchableCorpses.Contains(alive.entityId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (alive.bag == null || alive.bag.SlotCount == 0 || !alive.HasAnyTags(ZombieTag))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
MarkCorpse(alive);
|
||||
bool opened = alive.bag != null && alive.bag.Touched;
|
||||
if (opened)
|
||||
{
|
||||
alive.timeStayAfterDeath = (int)(OpenedCorpseSecondsAfterDeath * 20f);
|
||||
}
|
||||
Debug.Log("[NecromancerTome] CorpseLoot: труп " + alive.entityId + " восстановлен после загрузки" +
|
||||
(opened ? " (уже открывали)" : ""));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Опустошённый и закрытый труп убирается сразу - как игра убирает пустой мешок
|
||||
/// (EntityContainerAbs.OnUpdateEntity: `bag.Touched && bag.IsEmpty()`). Только сервер.</summary>
|
||||
public static void RemoveEmptiedCorpses(World _world)
|
||||
{
|
||||
if (SearchableCorpses.Count == 0 || !SingletonMonoBehaviour<ConnectionManager>.Instance.IsServer)
|
||||
{
|
||||
return;
|
||||
}
|
||||
List<int> emptied = null;
|
||||
foreach (int id in SearchableCorpses)
|
||||
{
|
||||
if (!(_world.GetEntity(id) is EntityAlive corpse) || corpse.bag == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!corpse.bag.Touched || !corpse.bag.IsEmpty() || LockManager.Instance.IsLockedServer(corpse, 0))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (emptied == null)
|
||||
{
|
||||
emptied = new List<int>();
|
||||
}
|
||||
emptied.Add(id);
|
||||
}
|
||||
if (emptied == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
foreach (int id in emptied)
|
||||
{
|
||||
SearchableCorpses.Remove(id);
|
||||
_world.RemoveEntity(id, EnumRemoveEntityReason.Despawned);
|
||||
Debug.Log("[NecromancerTome] CorpseLoot: труп " + id + " опустошён и убран");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -496,56 +598,102 @@ namespace NecromancerTome
|
||||
PlayerActionsLocal input = player.playerInput;
|
||||
string key = input.Activate.GetBindingXuiMarkupString() +
|
||||
input.PermanentActions.Activate.GetBindingXuiMarkupString();
|
||||
__result = string.Format(Localization.Get("lootTooltipNew"), key,
|
||||
// Как у мешка (EntityItem.GetActivationText): нетронуто / пусто / обыскано.
|
||||
Bag bag = __instance.bag;
|
||||
string tooltip = bag == null || !bag.Touched ? "lootTooltipNew"
|
||||
: bag.IsEmpty() ? "lootTooltipEmpty" : "lootTooltipTouched";
|
||||
__result = string.Format(Localization.Get(tooltip), key,
|
||||
Localization.Get(__instance.LocalizedEntityName));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Запускает обыск - НЕ мгновенно, а с трёхсекундным круговым индикатором (указание
|
||||
/// 2026-09-18). Индикатор рисует сама игра: XUiC_Timer.OpenTimer, тот же механизм, что крутится
|
||||
/// при снятии верстака, и знакомый игроку без объяснений.
|
||||
///
|
||||
/// ПОЧЕМУ НЕ ЧЕРЕЗ activateTime У КОМАНДЫ. У EntityActivationCommand есть поле activateTime, и
|
||||
/// Entity.ActivateEntityCommand действительно открывает по нему таймер - но по завершении он
|
||||
/// дёргает ИГРОВОЕ СОБЫТИЕ из entity_class (`onActivateEvent`), а не наш код, и запускается
|
||||
/// только если у команды заполнено eventName. Нам нужен свой обработчик в конце, поэтому таймер
|
||||
/// открывается вручную, а не описанием команды.
|
||||
///
|
||||
/// CloseOnHit=true: если по игроку ударили посреди обыска, полоса закрывается и труп остаётся
|
||||
/// нетронутым. Обыскивать труп посреди боя не должно быть бесплатно.</summary>
|
||||
/// <summary>Открывает труп так же, как игра открывает мешок: EntityItem.OnEntityActivated по
|
||||
/// команде "search" делает ровно `LockManager.Instance.LockRequestLocal(this, 0)` (у нас - через
|
||||
/// LockRequestHelper в TEPersistenceSrc, иначе не компилируется). Дальше всё
|
||||
/// игровое: сервер наполняет сумку при первом открытии, окно добычи показывает свой таймер
|
||||
/// обыска. До 1.5.0 здесь был свой 3-секундный таймер и превращение трупа в мешок.</summary>
|
||||
[HarmonyPatch(typeof(EntityAlive), "OnEntityActivated")]
|
||||
public static class Patch_EntityAlive_OnEntityActivated_Corpse
|
||||
{
|
||||
public static void Postfix(EntityAlive __instance, EntityActivationCommand _command,
|
||||
EntityPlayerLocal _playerFocusing)
|
||||
public static void Postfix(EntityAlive __instance, EntityActivationCommand _command)
|
||||
{
|
||||
if (_command.commandId != CorpseLoot.SearchCommand || !CorpseLoot.IsSearchable(__instance))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (_playerFocusing == null || _playerFocusing.PlayerUI == null)
|
||||
{
|
||||
CorpseLoot.SearchCorpse(__instance);
|
||||
return;
|
||||
}
|
||||
LockRequestHelper.RequestLocal(__instance);
|
||||
}
|
||||
}
|
||||
|
||||
EntityAlive corpse = __instance;
|
||||
TimerEventData timer = new TimerEventData
|
||||
/// <summary>Сервер: пускает к сумке помеченного трупа. У EntityAlive тут `if (!IsDead())`, и
|
||||
/// мёртвому отказано всегда; для нашего трупа выполняется тело базового Entity.OnLockRequestServer
|
||||
/// (без веток Twitch-ящиков - зомби не бывает «чужим» ящиком). Заодно продлевает жизнь трупа:
|
||||
/// открытый, но не опустошённый лежит как мешок.</summary>
|
||||
[HarmonyPatch(typeof(EntityAlive), "OnLockRequestServer")]
|
||||
public static class Patch_EntityAlive_OnLockRequestServer_Corpse
|
||||
{
|
||||
public static bool Prefix(EntityAlive __instance, int _lockingPlayerID, ref bool __result)
|
||||
{
|
||||
if (!__instance.IsDead() || !CorpseLoot.IsSearchable(__instance))
|
||||
{
|
||||
Data = corpse,
|
||||
CloseOnHit = true,
|
||||
};
|
||||
timer.FullTimeFinishEvent += delegate
|
||||
return true;
|
||||
}
|
||||
if (__instance.bag == null)
|
||||
{
|
||||
// Повторная проверка на финише: за три секунды труп мог истлеть, а игрок - уйти.
|
||||
if (CorpseLoot.IsSearchable(corpse))
|
||||
__result = false;
|
||||
return false;
|
||||
}
|
||||
if (!__instance.bag.Touched)
|
||||
{
|
||||
GameManager.Instance.lootManager.LootBagOpened(__instance.bag, __instance, _lockingPlayerID);
|
||||
}
|
||||
__instance.timeStayAfterDeath = (int)(CorpseLoot.OpenedCorpseSecondsAfterDeath * 20f);
|
||||
Debug.Log("[NecromancerTome] CorpseLoot: труп " + __instance.entityId + " открыт");
|
||||
__result = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Клиент: показывает окно добычи трупа. Повтор тела Entity.OnLockResponseLocal
|
||||
/// (у EntityAlive снова `if (!IsDead())`). OnLockResponseServer у EntityAlive не переопределён -
|
||||
/// базовый сам пишет, открывали ли сумку раньше (это и включает игровой таймер обыска), и её
|
||||
/// содержимое.</summary>
|
||||
[HarmonyPatch(typeof(EntityAlive), "OnLockResponseLocal")]
|
||||
public static class Patch_EntityAlive_OnLockResponseLocal_Corpse
|
||||
{
|
||||
public static bool Prefix(EntityAlive __instance, bool _success, PooledBinaryReader _brServerContext, ref bool __result)
|
||||
{
|
||||
if (!__instance.IsDead() || !CorpseLoot.IsSearchable(__instance))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
__result = false;
|
||||
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPrimaryPlayer();
|
||||
Bag bag = __instance.bag;
|
||||
if (bag == null || !_success)
|
||||
{
|
||||
if (playerUI != null)
|
||||
{
|
||||
CorpseLoot.SearchCorpse(corpse);
|
||||
GameManager.ShowTooltip(playerUI.entityPlayer, Localization.Get("ttNoInteractItem"), string.Empty, "ui_denied");
|
||||
}
|
||||
};
|
||||
XUiC_Timer.OpenTimer(_playerFocusing.PlayerUI.xui, CorpseLoot.SearchSeconds, timer);
|
||||
Debug.Log("[NecromancerTome] CorpseLoot: начат обыск трупа " + corpse.entityId +
|
||||
", " + CorpseLoot.SearchSeconds + " с");
|
||||
return false;
|
||||
}
|
||||
bool runOpenTimer = _brServerContext.ReadBoolean();
|
||||
bag.ItemGrid.ReadInto(_brServerContext, StreamModeRead.FromServer);
|
||||
LootContainer lootContainer = LootContainer.GetLootContainer(__instance.GetLootList());
|
||||
EntityAlive corpse = __instance;
|
||||
XUiC_BagStorageWindowGroup.Open(playerUI.xui, corpse, bag, lootContainer,
|
||||
Localization.Get(corpse.LocalizedEntityName), corpse.OnBagModified,
|
||||
() => LockManager.Instance.UnlockRequestLocal(),
|
||||
() => corpse.GetDistanceSq(playerUI.entityPlayer) <= Constants.cPlayerInteractDistance * Constants.cPlayerInteractDistance,
|
||||
runOpenTimer);
|
||||
if (lootContainer != null && playerUI.entityPlayer != null)
|
||||
{
|
||||
lootContainer.ExecuteBuffActions(corpse.entityId, playerUI.entityPlayer);
|
||||
}
|
||||
bag.ItemGrid.Touch();
|
||||
__result = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ namespace NecromancerTome
|
||||
///
|
||||
/// ПОЧЕМУ Minor / 10. Игра хранит версию двузначным числом: 3.2.0 = 20, 3.2.1 = 21, 3.3.0 = 30
|
||||
/// (VersionInformation печатает его как {Minor/10}.{Minor%10}). Проверка по десяткам пропускает
|
||||
/// любые хотфиксы 3.2.x и не пропускает 3.3 и всё, что будет после.
|
||||
/// любые хотфиксы 3.3.x и не пропускает ни 3.2, ни 3.4 и всё, что будет после.
|
||||
///
|
||||
/// ВСЁ, ЧЕМ ПОЛЬЗУЕТСЯ ЭТОТ ФАЙЛ, СВЕРЕНО С ДЕКОМПИЛЯЦИЕЙ 3.3.0 (build 25485021): Constants,
|
||||
/// VersionInformation, XUiC_MainMenu.OnOpen, XUiC_MessageBoxWindowGroup (класс совпадает целиком),
|
||||
@@ -29,10 +29,10 @@ namespace NecromancerTome
|
||||
/// </summary>
|
||||
public static class GameVersionGuard
|
||||
{
|
||||
/// <summary>Под какую версию собран мод: Major и десяток Minor (3.2.x = 3 и 2).</summary>
|
||||
/// <summary>Под какую версию собран мод: Major и десяток Minor (3.3.x = 3 и 3; сборка 1.4.x была под 3.2).</summary>
|
||||
public const int SupportedMajor = 3;
|
||||
public const int SupportedMinorTens = 2;
|
||||
public const string SupportedText = "3.2";
|
||||
public const int SupportedMinorTens = 3;
|
||||
public const string SupportedText = "3.3";
|
||||
|
||||
private static bool? supported;
|
||||
|
||||
|
||||
@@ -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 + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,17 +130,19 @@ namespace NecromancerTome
|
||||
|
||||
public static bool HasAnyKnife(EntityPlayerLocal player)
|
||||
{
|
||||
return ContainsKnife(player.inventory?.GetSlots()) || ContainsKnife(player.bag?.GetSlots());
|
||||
return ContainsKnife(player.inventory?.ItemGrid) || ContainsKnife(player.bag?.ItemGrid);
|
||||
}
|
||||
|
||||
private static bool ContainsKnife(ItemStack[] slots)
|
||||
// В 3.3 у пояса и рюкзака нет GetSlots: предметы лежат в ItemStackGrid.
|
||||
private static bool ContainsKnife(ItemStackGrid grid)
|
||||
{
|
||||
if (slots == null)
|
||||
if (grid == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
foreach (ItemStack stack in slots)
|
||||
for (int i = 0; i < grid.Length; i++)
|
||||
{
|
||||
ItemStack stack = grid.GetItem(i);
|
||||
if (stack == null || stack.IsEmpty())
|
||||
{
|
||||
continue;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -207,13 +207,13 @@ namespace NecromancerTome
|
||||
/// user's "как проще" - this is a brand-new feature, nothing to migrate from yet;
|
||||
/// PooledBinaryReader.ReadBoolean() below has no such compile restriction, confirmed
|
||||
/// separately, so Read() needs no equivalent workaround.</summary>
|
||||
public override void Write(PooledBinaryWriter _bw, TileEntity.StreamModeWrite _eStreamMode)
|
||||
public override void Write(PooledBinaryWriter _bw, StreamModeWrite _eStreamMode)
|
||||
{
|
||||
base.Write(_bw, _eStreamMode);
|
||||
PyramidWardWriteHelper.Write(_bw, EffectOn, ZoneShown);
|
||||
}
|
||||
|
||||
public override void Read(PooledBinaryReader _br, TileEntity.StreamModeRead _eStreamMode)
|
||||
public override void Read(PooledBinaryReader _br, StreamModeRead _eStreamMode)
|
||||
{
|
||||
base.Read(_br, _eStreamMode);
|
||||
EffectOn = _br.ReadBoolean();
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,7 +139,7 @@ namespace NecromancerTome
|
||||
// the safety net for when that chain does not complete: opening the bracelet must
|
||||
// never be what silently starts an empty vault over a saved one. Only then is a
|
||||
// genuinely new bag created.
|
||||
bag = SpatialVaultPersistence.LastLoadedVault ?? new Bag(slotCount);
|
||||
bag = SpatialVaultPersistence.LastLoadedVault ?? new Bag(new Vector2i(slotCount, 1), XUiC_ItemStack.StackLocationTypes.LootContainer, null);
|
||||
if (bag == SpatialVaultPersistence.LastLoadedVault)
|
||||
{
|
||||
Debug.Log("[NecromancerTome] SpatialVaultPatch: session cache was empty, adopted the last loaded vault (" +
|
||||
@@ -149,13 +149,9 @@ namespace NecromancerTome
|
||||
}
|
||||
else if (bag.SlotCount < slotCount)
|
||||
{
|
||||
// Grow, never shrink - the skill level only ever goes up, so this only ever
|
||||
// copies existing stacks into a bigger array, same shape
|
||||
// EntityLootContainer.SetContent itself uses when it needs to resize a bag.
|
||||
ItemStack[] oldSlots = bag.GetSlots();
|
||||
ItemStack[] newSlots = ItemStack.CreateArray(slotCount);
|
||||
Array.Copy(oldSlots, newSlots, oldSlots.Length);
|
||||
bag.SetSlots(newSlots);
|
||||
// Grow, never shrink - the skill level only ever goes up. В 3.3 это Bag.Resize:
|
||||
// ItemStackGrid.Resize переносит все стопки в новый массив и досоздаёт слоты.
|
||||
bag.Resize(new Vector2i(slotCount, 1));
|
||||
}
|
||||
|
||||
Debug.Log("[NecromancerTome] SpatialVaultPatch: owner=" + player.entityId + " opened vault, " + slotCount + " slots (Necromancy level " + level + ")");
|
||||
|
||||
@@ -79,8 +79,16 @@ namespace NecromancerTome
|
||||
{
|
||||
/// <summary>Payload layout version, independent of the blob framing in
|
||||
/// SpatialVaultBlobIO. An unknown version is skipped, not guessed at - the framing's
|
||||
/// explicit length means we can always step over a payload we do not understand.</summary>
|
||||
public const byte PayloadVersion = 1;
|
||||
/// explicit length means we can always step over a payload we do not understand.
|
||||
///
|
||||
/// 2 = игра 3.3 (мод 1.5.0): сумка пишется новым Bag.Write (свой байт версии 2 и
|
||||
/// ItemStackGrid). 1 = игра 3.2, старый формат сумки; 3.3 читает его сам (Bag.readInto,
|
||||
/// ветка версии < 2), поэтому хранилище из сейва 3.2 переезжает без потерь.
|
||||
/// Номер поднят, чтобы сборка под 3.2 не приняла новый формат за свой.</summary>
|
||||
public const byte PayloadVersion = 2;
|
||||
|
||||
/// <summary>Старейший номер, который эта сборка ещё читает.</summary>
|
||||
public const byte OldestReadablePayloadVersion = 1;
|
||||
|
||||
/// <summary>What a PlayerDataFile carries. A class rather than a bare Bag because its mere
|
||||
/// PRESENCE is information: "this file has been read/filled, and the answer - including a
|
||||
@@ -147,30 +155,34 @@ namespace NecromancerTome
|
||||
/// which is the part that had diagnostic value.</summary>
|
||||
public static string lastSaveLogged;
|
||||
|
||||
/// <summary>Builds the opaque payload SpatialVaultBlobIO wraps. Uses netstandard's own
|
||||
/// BinaryWriter over a MemoryStream, which is why Bag serialization can stay in this
|
||||
/// project instead of the satellite assembly.</summary>
|
||||
/// <summary>Builds the opaque payload SpatialVaultBlobIO wraps. Заголовок (версия и
|
||||
/// «есть ли сумка») пишется в MemoryStream напрямую, а сама сумка - через
|
||||
/// PooledBinaryWriter поверх того же потока: в 3.3 Bag.Write принимает только его.
|
||||
/// Сами Write-перегрузки PooledBinaryWriter из этого проекта не вызываются (см.
|
||||
/// NecromancerTEPersistence.csproj), поэтому заголовок и не идёт через него.</summary>
|
||||
public static byte[] BuildPayload(Bag _bag)
|
||||
{
|
||||
using (MemoryStream ms = new MemoryStream())
|
||||
using (BinaryWriter bw = new BinaryWriter(ms))
|
||||
{
|
||||
bw.Write(PayloadVersion);
|
||||
ms.WriteByte(PayloadVersion);
|
||||
bool hasBag = _bag != null;
|
||||
bw.Write(hasBag);
|
||||
ms.WriteByte(hasBag ? (byte)1 : (byte)0);
|
||||
if (hasBag)
|
||||
{
|
||||
// Plain BinaryWriter is enough: Bag.Write only demands a PooledBinaryWriter
|
||||
// when bag.preferences != null, and vault bags come from `new Bag(int)`, whose
|
||||
// constructor sets nothing but the item array.
|
||||
_bag.Write(bw);
|
||||
using (PooledBinaryWriter pw = MemoryPools.poolBinaryWriter.AllocSync(_bReset: false))
|
||||
{
|
||||
pw.SetBaseStream(ms);
|
||||
_bag.Write(pw, StreamModeWrite.Persistency);
|
||||
pw.Flush();
|
||||
}
|
||||
}
|
||||
bw.Flush();
|
||||
return ms.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Null when the payload holds no vault or is a version we do not know.</summary>
|
||||
/// <summary>Null when the payload holds no vault. Незнакомый номер версии - исключение,
|
||||
/// а НЕ null: null значит «хранилища нет», и следующее сохранение стёрло бы вещи.
|
||||
/// Исключение ловит Read-постфикс и сохраняет байты нетронутыми (AttachRaw).</summary>
|
||||
public static Bag ParsePayload(byte[] _payload)
|
||||
{
|
||||
if (_payload == null || _payload.Length == 0)
|
||||
@@ -178,22 +190,25 @@ namespace NecromancerTome
|
||||
return null;
|
||||
}
|
||||
using (MemoryStream ms = new MemoryStream(_payload, false))
|
||||
using (BinaryReader br = new BinaryReader(ms))
|
||||
{
|
||||
byte version = br.ReadByte();
|
||||
if (version != PayloadVersion)
|
||||
int version = ms.ReadByte();
|
||||
if (version < OldestReadablePayloadVersion || version > PayloadVersion)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] SpatialVaultPersistence: vault payload version " + version + ", expected " + PayloadVersion + " - skipped");
|
||||
return null;
|
||||
throw new InvalidDataException("vault payload version " + version + ", this build reads " +
|
||||
OldestReadablePayloadVersion + ".." + PayloadVersion);
|
||||
}
|
||||
if (!br.ReadBoolean())
|
||||
if (ms.ReadByte() != 1)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
// Bag.Read is the STATIC one and returns a new Bag; ReadInto is the instance
|
||||
// version. Symmetric with BuildPayload: preferences were written as absent, so no
|
||||
// PooledBinaryReader is needed here either.
|
||||
return Bag.Read(br);
|
||||
// Сумка сама пишет свой номер формата, и Bag.readInto 3.3 понимает оба: и старый
|
||||
// (из сейва 3.2), и новый. Место хранения - как у сумки дрона (то же окно), владельца нет:
|
||||
// так же игра сама создаёт сумку из старого сейва (PlayerDataFile, legacyBag).
|
||||
using (PooledBinaryReader pr = MemoryPools.poolBinaryReader.AllocSync(_bReset: false))
|
||||
{
|
||||
pr.SetBaseStream(ms);
|
||||
return Bag.Read(pr, XUiC_ItemStack.StackLocationTypes.LootContainer, StreamModeRead.Persistency, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,14 +227,14 @@ namespace NecromancerTome
|
||||
{
|
||||
return "NONE";
|
||||
}
|
||||
ItemStack[] slots = _bag.GetSlots();
|
||||
ItemStackGrid slots = _bag.ItemGrid;
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.Append(_bag.SlotCount).Append(" slots, ").Append(_bag.GetUsedSlotCount()).Append(" used");
|
||||
if (slots != null)
|
||||
{
|
||||
for (int i = 0; i < slots.Length; i++)
|
||||
{
|
||||
ItemStack stack = slots[i];
|
||||
ItemStack stack = slots.GetItem(i);
|
||||
if (stack == null || stack.IsEmpty())
|
||||
{
|
||||
continue;
|
||||
|
||||
@@ -457,16 +457,16 @@ namespace NecromancerTome
|
||||
public static float SpendCharge(PickupJob _job)
|
||||
{
|
||||
ItemValue bracelet = _job.Bracelet;
|
||||
if (bracelet == null || bracelet.Modifications == null || _job.ChannelSeconds <= 0f)
|
||||
if (bracelet == null || bracelet.ModificationCount == 0 || _job.ChannelSeconds <= 0f)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
float spent = 0f;
|
||||
bool emptied = false;
|
||||
for (int i = 0; i < bracelet.Modifications.Length; i++)
|
||||
for (int i = 0; i < bracelet.ModificationCount; i++)
|
||||
{
|
||||
ItemValue mod = bracelet.Modifications[i];
|
||||
ItemValue mod = bracelet.GetModification(i);
|
||||
if (mod == null || mod.IsEmpty())
|
||||
{
|
||||
continue;
|
||||
@@ -497,7 +497,7 @@ namespace NecromancerTome
|
||||
// ItemValue.None is what an empty mod slot holds - type 0, which is exactly
|
||||
// what IsEmpty() tests for, so the slot reads as free to every other piece of
|
||||
// code including the gate in Begin.
|
||||
bracelet.Modifications[i] = ItemValue.None;
|
||||
bracelet.SetModification(i, ItemValue.None);
|
||||
emptied = true;
|
||||
Debug.Log("[NecromancerTome] SpatialVaultPickup: sphere in slot " + i +
|
||||
" ran out (" + mod.UseTimes.ToString("0.#") + "/" + max +
|
||||
@@ -585,7 +585,7 @@ namespace NecromancerTome
|
||||
// a composite tile entity with a storage FEATURE rather than as their own class, so
|
||||
// the question has to be asked of the feature - the same TryGetSelfOrFeature call the
|
||||
// engine's own storage code uses.
|
||||
if (tileEntity.TryGetSelfOrFeature(out ITileEntityLootable lootable) && !lootable.IsEmpty())
|
||||
if (tileEntity.TryGetSelfOrFeature(out TEFeatureStorage storage) && !storage.IsEmpty())
|
||||
{
|
||||
Deny(_player, MsgNotEmpty);
|
||||
return false;
|
||||
@@ -616,15 +616,12 @@ namespace NecromancerTome
|
||||
|
||||
if (!Patch_ItemActionEat_ExecuteAction_SpatialVault.PlayerVaults.TryGetValue(_player.entityId, out Bag bag))
|
||||
{
|
||||
bag = SpatialVaultPersistence.LastLoadedVault ?? new Bag(slotCount);
|
||||
bag = SpatialVaultPersistence.LastLoadedVault ?? new Bag(new Vector2i(slotCount, 1), XUiC_ItemStack.StackLocationTypes.LootContainer, null);
|
||||
Patch_ItemActionEat_ExecuteAction_SpatialVault.PlayerVaults[_player.entityId] = bag;
|
||||
}
|
||||
if (bag.SlotCount < slotCount)
|
||||
{
|
||||
ItemStack[] oldSlots = bag.GetSlots();
|
||||
ItemStack[] newSlots = ItemStack.CreateArray(slotCount);
|
||||
System.Array.Copy(oldSlots, newSlots, oldSlots.Length);
|
||||
bag.SetSlots(newSlots);
|
||||
bag.Resize(new Vector2i(slotCount, 1));
|
||||
}
|
||||
return bag;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Нож некроманта (BACKLOG.md, п.5): зомби, задетый ножом, носит buffNecroVictim (buffs.xml) до
|
||||
/// самой смерти. На смерти он роняет мешок «Жертва» (EntityLootContainerVictim в
|
||||
/// entityclasses.xml).
|
||||
/// самой смерти. На смерти его труп становится обыскиваемым контейнером с добычей «Жертвы»
|
||||
/// (CorpseLootPatch.cs; до 1.5.0 обыск превращал труп в мешок EntityLootContainerVictim).
|
||||
///
|
||||
/// ПОВЕДЕНИЕ ИСПРАВЛЕНО 2026-09-18, И ЭТО ВОЗВРАТ К ТОМУ, О ЧЁМ ДОГОВАРИВАЛИСЬ. Баг-репорт
|
||||
/// пользователя: «мы договаривались, что он выпадает только если не должен был выпасть мешок с
|
||||
@@ -41,8 +41,11 @@ namespace NecromancerTome
|
||||
/// от этого не изменились: они по-прежнему решают судьбу ОБЫЧНОГО мешка.
|
||||
///
|
||||
/// Второй префикс (на DropBagServer) УДАЛЁН ЦЕЛИКОМ - он теперь вреден: подменял бы и тот
|
||||
/// обычный мешок, который мы как раз хотим оставить. Спавн «Жертвы» переехал сюда, в
|
||||
/// SpawnVictimBag.
|
||||
/// обычный мешок, который мы как раз хотим оставить.
|
||||
///
|
||||
/// С 1.5.0 мешка «Жертва» нет совсем: сам труп открывается как ящик (CorpseLootPatch.cs), и
|
||||
/// метод SpawnVictimBag удалён. Класс EntityLootContainerVictim в entityclasses.xml ОСТАВЛЕН
|
||||
/// ради сейвов: в мирах игроков могут лежать уже выпавшие мешки, и без класса они не загрузятся.
|
||||
///
|
||||
/// ПОЧЕМУ ВООБЩЕ НУЖЕН ПРЕФИКС, А НЕ ПОСТФИКС. Узнать «выпал ли мешок» после оригинала нечем:
|
||||
/// dropItemOnDeath ничего не возвращает и следов не оставляет. Поэтому бросок повторяется
|
||||
@@ -57,7 +60,6 @@ namespace NecromancerTome
|
||||
public static class Patch_EntityAlive_dropItemOnDeath_VictimBag
|
||||
{
|
||||
public const string VictimBuffName = "buffNecroVictim";
|
||||
public const string VictimContainerClassName = "EntityLootContainerVictim";
|
||||
|
||||
public static bool Prefix(EntityAlive __instance)
|
||||
{
|
||||
@@ -122,43 +124,5 @@ namespace NecromancerTome
|
||||
CorpseLoot.MarkCorpse(__instance);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Спавнит мешок «Жертва». Раньше это жило в префиксе на Entity.DropBagServer и
|
||||
/// подменяло собой обычный мешок; теперь ничего не подменяет, поэтому тот патч снят.
|
||||
///
|
||||
/// _offsetAside - сдвинуть в сторону, потому что рядом уже лёг обычный мешок. Ваниль
|
||||
/// кладёт свой ровно в GetPosition() + 0.9 по Y (декомпилировано, Entity.DropBagServer),
|
||||
/// то есть без сдвига два мешка встали бы в одну точку и выглядели как один.</summary>
|
||||
public static void SpawnVictimBag(EntityAlive _victim, bool _offsetAside)
|
||||
{
|
||||
int containerClassId = EntityClass.GetId(VictimContainerClassName);
|
||||
if (containerClassId == -1)
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] VictimPatch: класс '" + VictimContainerClassName +
|
||||
"' не найден, мешок не выпадет");
|
||||
return;
|
||||
}
|
||||
|
||||
Vector3 pos = _victim.GetPosition();
|
||||
pos.y += 0.9f;
|
||||
if (_offsetAside)
|
||||
{
|
||||
// Полметра вбок - достаточно, чтобы мешки читались как два и оба брались
|
||||
// прицелом, и мало, чтобы «Жертва» уехала за стену или в соседнюю комнату.
|
||||
pos += _victim.qrotation * new Vector3(0.6f, 0f, 0f);
|
||||
}
|
||||
Entity spawned = EntityFactory.CreateEntity(containerClassId, pos, Vector3.zero);
|
||||
if (spawned is EntityLootContainer lootContainer)
|
||||
{
|
||||
GameManager.Instance.World.SpawnEntityInWorld(lootContainer);
|
||||
Debug.Log("[NecromancerTome] VictimPatch: " + _victim.entityId + " уронил «Жертву» " +
|
||||
lootContainer.entityId + (_offsetAside ? " (вторым мешком, со сдвигом)" : " (единственным мешком)"));
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning("[NecromancerTome] VictimPatch: созданная сущность для '" +
|
||||
VictimContainerClassName + "' не оказалась EntityLootContainer");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -2,8 +2,8 @@
|
||||
<xml>
|
||||
<Name value="NecromancerTome" />
|
||||
<DisplayName value="Necromancer's Tome" />
|
||||
<Description value="A dark necromancy progression for 7 Days to Die 3.2: a kill-count-driven skill tree with cursed weapons, charm/deviation magic, undead companions that keep station with you and attack what you point at, base-defence wards, and a story-ending Black Portal ritual. Fully localized into 13 languages. Single-player; requires EAC off." />
|
||||
<Description value="A dark necromancy progression for 7 Days to Die 3.3: a kill-count-driven skill tree with cursed weapons, charm/deviation magic, undead companions that keep station with you and attack what you point at, base-defence wards, and a story-ending Black Portal ritual. Fully localized into 13 languages. Single-player; requires EAC off." />
|
||||
<Author value="Alex Cube" />
|
||||
<Version value="1.4.2" />
|
||||
<Version value="1.5.0" />
|
||||
<Website value="https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/" />
|
||||
</xml>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
NEW: this build is for 7 Days to Die 3.3 (experimental branch; tested on V 3.3 b14). For game 3.2 keep using 1.4.2 - it stays on the Files tab. Code built for 3.3 does not run on 3.2 and vice versa; each build shows an "incompatible" window in the main menu if started on the wrong game version.
|
||||
The Spatial Vault carries over from 3.2 saves: its items are read and from then on saved in the new format. Back up your save before switching the game to 3.3.
|
||||
The Pyramid of Heresy works on 3.3 again (on 1.4.x it failed to load there).
|
||||
FIX: the Necromancer's Knife now marks a Victim even with a single killing blow. Before, a zombie killed in one hit left no Victim loot (reported by sanovskiy).
|
||||
CHANGED: the red Victim corpse no longer turns into a bag when searched. It opens by itself like a container, with the game's usual search timer, and the ingredients are taken straight from it.
|
||||
An untouched Victim corpse stays for 5 minutes, an opened but not emptied one for an hour, an emptied one vanishes as soon as you close it. An opened corpse survives saving and loading.
|
||||
Drop-in over 1.4.2 once the game is on 3.3; saves carry over. Single-player. The mod ships Harmony libraries, so EasyAntiCheat must be off.
|
||||
@@ -30,7 +30,7 @@ To uninstall, delete the folder. The mod adds items and one block, so a save tha
|
||||
|
||||
[b]Turn the horde on itself.[/b] The [b]Spirit Stone[/b] is thrown; the zombie it touches switches sides and starts tearing into the others instead of you. The [b]Scroll of Deviation[/b] does the same to everything in the blast radius at once.
|
||||
|
||||
[b]A blade that grows with you.[/b] The [b]Necromancer's Knife[/b] is carved from a thighbone - a straight amputation-knife edge, a bone guard, a rope-wrapped grip and runes that glow. It scales its damage with your Necromancy level - nearly useless in unskilled hands, lethal once you are levelled. It heals you for half the damage dealt and marks the wounded zombie as a Victim: its corpse turns red and stays for five minutes, and searching it leaves a Victim's bag of necromantic ingredients. [b]Six mods fit this knife and nothing else[/b]: water and food drawn straight out of corpses, protection from heat and cold, slowed victims, an area power-attack storm bought with your own health, and every nearby zombie marked on your compass and map.
|
||||
[b]A blade that grows with you.[/b] The [b]Necromancer's Knife[/b] is carved from a thighbone - a straight amputation-knife edge, a bone guard, a rope-wrapped grip and runes that glow. It scales its damage with your Necromancy level - nearly useless in unskilled hands, lethal once you are levelled. It heals you for half the damage dealt and marks the wounded zombie as a Victim, even with a single killing blow: its corpse turns red and opens like a container full of necromantic ingredients. An untouched corpse stays for five minutes, an opened one for an hour, an emptied one vanishes at once. [b]Six mods fit this knife and nothing else[/b]: water and food drawn straight out of corpses, protection from heat and cold, slowed victims, an area power-attack storm bought with your own health, and every nearby zombie marked on your compass and map.
|
||||
|
||||
[b]Undead that fight for you.[/b] Summoning books raise a [b]zombie dog, bear, wolf or griffin[/b] - permanent companions, one of each kind at a time, recalled into the book with a power attack. [b]Pharaoh’s Swarm[/b] release a swarm that scatters wide and stings zombies onto your side. The [b]Banshee's Scroll[/b] screams up a small horde that is [i]not[/i] on your side at all - use it on purpose.
|
||||
|
||||
@@ -47,10 +47,10 @@ To uninstall, delete the folder. The mod adds items and one block, so a save tha
|
||||
[size=5][b]Requirements[/b][/size]
|
||||
|
||||
[list]
|
||||
[*][b]7 Days to Die 3.2.[/b] Other versions are untested.
|
||||
[*][b]7 Days to Die 3.3 (experimental): version 1.5.0. 7 Days to Die 3.2: version 1.4.2.[/b] Both are on the Files tab. Each build tells you in the main menu if it is started on the wrong game version.
|
||||
[*][b]EasyAntiCheat must be off[/b] - the mod uses Harmony patches.
|
||||
[*][b]No other mods required.[/b] Harmony itself ships with the game.
|
||||
[*][b]Single-player.[/b] The story scenes rely on the game's own pause, which vanilla only applies in single-player; in multiplayer they play without stopping time. Everything else works, but the mod was built and tested solo.
|
||||
[*][b]Single-player.[/b] The mod was made for single-player from the start. It reworks a number of the game's own mechanics - corpses, loot, zombie behaviour - and a server isn't necessarily built for that, so in multiplayer some things may not work (the Victim corpses, for one). The story scenes also play there without stopping time, since vanilla pauses only in single-player.
|
||||
[*]Client-side install: every player who wants it installs it themselves.
|
||||
[/list]
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
For game 3.3 (experimental). Spatial Vault from 3.2 saves carries over, Pyramid of Heresy works. Knife marks a Victim even with a one-hit kill; the red corpse opens like a container, no bag. For game 3.2 stay on 1.4.2.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -2,7 +2,7 @@
|
||||
|
||||
*English version below — scroll past the Russian half.*
|
||||
|
||||
**Версия 1.4.2** — для 7 Days to Die 3.2 (с 3.3 experimental не совместима и сама об этом скажет). Автор: Alex Cube.
|
||||
**Версия 1.5.0** — для 7 Days to Die 3.3 (experimental). Для игры 3.2 — версия 1.4.2. Каждая сборка на чужой версии игры сама скажет, что несовместима. Автор: Alex Cube.
|
||||
|
||||
- Страница мода: https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/
|
||||
- Nexus Mods: https://www.nexusmods.com/7daystodie/mods/12547
|
||||
@@ -57,8 +57,10 @@ HUD за этим следят два индикатора: череп в ста
|
||||
- **Нож некроманта** — клинок, выточенный из бедренной кости: прямое лезвие, как у ампутационного
|
||||
ножа, костяная гарда, рукоять в верёвочной обмотке и светящиеся руны. Урон растёт вместе с
|
||||
навыком Некромантии: почти бесполезен в неопытных руках, смертоносен у прокачанного игрока.
|
||||
Лечит владельца на половину нанесённого урона и метит раненого зомби как Жертву — труп такого
|
||||
зомби краснеет и лежит час; при обыске он исчезает, оставляя мешок Жертвы с ингредиентами.
|
||||
Лечит владельца на половину нанесённого урона и метит раненого зомби как Жертву (даже убитого
|
||||
с одного удара) — труп такого
|
||||
зомби краснеет, и его можно обыскать, как ящик: внутри ингредиенты Жертвы. Нетронутый труп
|
||||
лежит 5 минут, открытый, но не опустошённый, — час, опустошённый исчезает сразу.
|
||||
- **Шесть модов только для Ножа некроманта** — обычные моды для ножей в это оружие не встают, а
|
||||
эти не встают ни во что другое: **Слёзы мертвеца** (2 воды с каждого убитого клинком зомби),
|
||||
**Пир падальщика** (2 еды за труп), **Могильный покой** (защита от перегрева и переохлаждения,
|
||||
@@ -157,16 +159,22 @@ HUD за этим следят два индикатора: череп в ста
|
||||
Распакуйте папку `NecromancerTome` в `<папка игры>/Mods/` (или в `%APPDATA%/7DaysToDie/Mods/`) и
|
||||
запустите игру. Мод содержит Harmony-библиотеки, поэтому **EAC должен быть выключен**.
|
||||
|
||||
Рассчитан на одиночную игру: ванильная пауза работает только в сингле, поэтому в мультиплеере
|
||||
сюжетные сцены проиграются без остановки времени.
|
||||
Версия 1.5.0 — для игры 3.3; для игры 3.2 скачайте 1.4.2 (обе лежат на Nexus во вкладке Files).
|
||||
|
||||
Рассчитан на одиночную игру. Мод переделывает ряд собственных механик игры (трупы, добычу,
|
||||
поведение зомби), а сервер не обязательно на такое рассчитан, поэтому в мультиплеере часть вещей
|
||||
может не работать — например, трупы «Жертвы». Сюжетные сцены там проиграются без остановки
|
||||
времени: ванильная пауза срабатывает только в сингле.
|
||||
|
||||
## Статус
|
||||
|
||||
Версия 1.4.2 — **защита от несовместимой версии игры**. Если мод запущен не на той версии игры, под которую собран (эта сборка — для 3.2.x), в главном меню появляется окно «мод несовместим, обновите» с единственной кнопкой «Выйти из игры». Если игрок всё же откроет мир, сохранённое Пространственное хранилище не разбирается и записывается обратно байт в байт — вещи дождутся правильной сборки мода.
|
||||
Версия 1.5.0 — **сборка под игру 3.3** (experimental, проверена на V 3.3 b14). Для игры 3.2 остаётся версия 1.4.2: код под 3.3 на 3.2 не работает и наоборот, и каждая сборка на чужой версии игры сама скажет, что несовместима. Пространственное хранилище из сейва 3.2 переносится: вещи читаются и дальше сохраняются в новом формате. Пирамида Ереси на 3.3 снова работает. **Нож некроманта метит Жертву и при убийстве с одного удара** — раньше такой зомби оставался без добычи. **Красный труп Жертвы больше не превращается в мешок**: он сам открывается, как ящик, с обычным таймером обыска игры. Нетронутый лежит 5 минут, открытый, но не опустошённый, — час, опустошённый исчезает сразу; открытый труп переживает сохранение и загрузку.
|
||||
|
||||
Версия 1.4.2 — **защита от несовместимой версии игры**. Если мод запущен не на той версии игры, под которую собран (1.4.2 собрана для 3.2.x, 1.5.0 — для 3.3.x), в главном меню появляется окно «мод несовместим, обновите» с единственной кнопкой «Выйти из игры». Если игрок всё же откроет мир, сохранённое Пространственное хранилище не разбирается и записывается обратно байт в байт — вещи дождутся правильной сборки мода.
|
||||
|
||||
Версия 1.4.1 — **исправлено падение FPS**. Красный труп «Жертвы» оставлял в памяти свои текстуры и материалы и не отдавал их до перезапуска игры: за пару часов набегали гигабайты видеопамяти, и FPS падал в разы. Теперь всё убирается вместе с трупом, а сам труп лежит 5 минут, а не час. Урон ножа пересчитывается раз в секунду, а не 10 раз.
|
||||
|
||||
**Мод рассчитан на игру 3.2 и НЕ совместим с 3.3 experimental**: там перестаёт сохраняться содержимое Пространственного хранилища и ломается Пирамида Ереси. Версия под 3.3 выйдет отдельно.
|
||||
**Версии 1.4.x рассчитаны на игру 3.2 и НЕ совместимы с 3.3 experimental**: там перестаёт сохраняться содержимое Пространственного хранилища и ломается Пирамида Ереси. Для 3.3 — версия 1.5.0.
|
||||
|
||||
Версия 1.4.0 — **питомцы переписаны заново**, и первый из них теперь доступен рано: **Дух крысы**
|
||||
(Некромантия 3, 60 упокоенных зомби). Все питомцы идут за вами, сами ни на кого не нападают и
|
||||
@@ -228,7 +236,7 @@ HUD за этим следят два индикатора: череп в ста
|
||||
|
||||
Весь заявленный контент реализован. Основное проверено в игре: призыв, отзыв и следование всех
|
||||
питомцев, приказ атаковать, зачёт убийств питомцами, новая модель ножа в руке, красный труп
|
||||
Жертвы и мешок при его обыске, подобранная и заново поставленная кровать как точка возрождения,
|
||||
Жертвы, который открывается как ящик (и после загрузки сейва), подобранная и заново поставленная кровать как точка возрождения,
|
||||
заряд браслета и
|
||||
расход Кровавой сферы, счёт убийств Некромантии (включая зомби-зверей, кровотечение и огонь),
|
||||
призрачность торговцев, крик при создании Крови некроманта.
|
||||
@@ -250,7 +258,7 @@ HUD за этим следят два индикатора: череп в ста
|
||||
|
||||
# Necromancer's Tome (English)
|
||||
|
||||
**Version 1.4.2** - for 7 Days to Die 3.2 (not compatible with 3.3 experimental, and says so itself). By Alex Cube.
|
||||
**Version 1.5.0** - for 7 Days to Die 3.3 (experimental). For game 3.2 use version 1.4.2. Each build tells you itself if it is started on the wrong game version. By Alex Cube.
|
||||
|
||||
- Mod page: https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/
|
||||
- Nexus Mods: https://www.nexusmods.com/7daystodie/mods/12547
|
||||
@@ -304,8 +312,10 @@ Five tiers, each opening part of the arsenal (the table gives both the zombie co
|
||||
- **Necromancer's Knife** - a blade carved from a thighbone: a straight edge like an amputation
|
||||
knife, a bone guard, a rope-wrapped grip and glowing runes. Its damage grows with the Necromancy
|
||||
skill: nearly useless in unskilled hands, lethal for a levelled player. Heals the wielder for
|
||||
half the damage dealt and marks the wounded zombie as a Victim - its corpse turns red and stays
|
||||
for five minutes; searching it makes it vanish and leave a Victim's bag of ingredients.
|
||||
half the damage dealt and marks the wounded zombie as a Victim (even one killed with a single
|
||||
blow) - its corpse turns red and opens like a container, with the Victim's ingredients inside.
|
||||
An untouched corpse stays for five minutes, an opened but not emptied one for an hour, and an
|
||||
emptied one vanishes at once.
|
||||
- **Six mods for the Necromancer's Knife only** - ordinary knife mods will not fit this weapon,
|
||||
and these will not fit any other: **Tears of the Dead** (2 water from every zombie killed with
|
||||
the blade), **Scavenger's Feast** (2 food per corpse), **Grave's Repose** (protection from heat
|
||||
@@ -407,16 +417,22 @@ Unpack the `NecromancerTome` folder into `<game folder>/Mods/` (or into
|
||||
`%APPDATA%/7DaysToDie/Mods/`) and launch the game. The mod ships Harmony libraries, so **EAC must
|
||||
be turned off**.
|
||||
|
||||
Built for single-player: the vanilla pause only works in single-player, so in multiplayer the
|
||||
story scenes play without stopping time.
|
||||
Version 1.5.0 is for game 3.3; for game 3.2 download 1.4.2 (both are on the Nexus Files tab).
|
||||
|
||||
Built for single-player. The mod reworks a number of the game's own mechanics (corpses, loot,
|
||||
zombie behaviour), and a server isn't necessarily built for that, so in multiplayer some things may
|
||||
not work - the Victim corpses, for one. The story scenes also play there without stopping time,
|
||||
since the vanilla pause only applies in single-player.
|
||||
|
||||
## Status
|
||||
|
||||
Version 1.4.2 - **protection against an incompatible game version**. If the mod runs on a game version it was not built for (this build is for 3.2.x), the main menu shows a "mod is incompatible, please update" window with a single "Quit game" button. And if a world does get opened, the saved Spatial Vault is not parsed but written back byte for byte - its items wait for the right build of the mod.
|
||||
Version 1.5.0 - **the build for game 3.3** (experimental, tested on V 3.3 b14). Version 1.4.2 stays for game 3.2: code built for 3.3 does not run on 3.2 and vice versa, and each build tells you itself if it is started on the wrong game version. The Spatial Vault from a 3.2 save carries over: its items are read and from then on saved in the new format. The Pyramid of Heresy works again on 3.3. **The Necromancer's Knife marks a Victim even with a single killing blow** - before, such a zombie left no loot. **The red Victim corpse no longer turns into a bag**: it opens by itself like a container, with the game's usual search timer. An untouched corpse stays for 5 minutes, an opened but not emptied one for an hour, an emptied one vanishes at once; an opened corpse survives saving and loading.
|
||||
|
||||
Version 1.4.2 - **protection against an incompatible game version**. If the mod runs on a game version it was not built for (1.4.2 is built for 3.2.x, 1.5.0 for 3.3.x), the main menu shows a "mod is incompatible, please update" window with a single "Quit game" button. And if a world does get opened, the saved Spatial Vault is not parsed but written back byte for byte - its items wait for the right build of the mod.
|
||||
|
||||
Version 1.4.1 - **the FPS drop is fixed**. A red Victim corpse left its textures and materials in memory and never gave them back until the game was restarted: within a couple of hours that was gigabytes of video memory, and the frame rate fell several times over. Everything is now cleaned up with the corpse, and the corpse itself stays for 5 minutes instead of an hour. The knife's damage is refreshed once a second instead of 10 times.
|
||||
|
||||
**The mod is built for game version 3.2 and is NOT compatible with 3.3 experimental**: there the Spatial Vault stops saving its contents and the Pyramid of Heresy breaks. A 3.3 version will come separately.
|
||||
**Versions 1.4.x are built for game version 3.2 and are NOT compatible with 3.3 experimental**: there the Spatial Vault stops saving its contents and the Pyramid of Heresy breaks. For 3.3 use version 1.5.0.
|
||||
|
||||
Version 1.4.0 - **pets have been rewritten**, and the first of them now comes early: the **Rat
|
||||
Spirit** (Necromancy 3, 60 zombies put to rest). Every pet follows you, attacks nobody on its own
|
||||
@@ -481,7 +497,7 @@ after leaving the game (the storage is saved in the player's own file, next to t
|
||||
|
||||
All the announced content is implemented. The core of it is confirmed in game: summoning,
|
||||
dismissing and following for every pet, the attack order, pets' kills counting as yours, the new
|
||||
knife model in hand, the red Victim corpse and the bag it leaves when searched, a bed picked up
|
||||
knife model in hand, the red Victim corpse that opens like a container (also after loading a save), a bed picked up
|
||||
and placed again becoming a respawn point, the bracelet's charge and the Blood Sphere's spending, Necromancy's kill count
|
||||
(zombie animals, bleeding and fire included), the traders' ghostly look, and the cry when
|
||||
Necromancer's Blood is made.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<strong>Мод для 7 Days to Die 3.2: путь от обычного выжившего до некроманта — со своей веткой прогрессии, тёмным оружием, призывной нежитью и сюжетной концовкой.</strong>
|
||||
<strong>Мод для 7 Days to Die 3.3: путь от обычного выжившего до некроманта — со своей веткой прогрессии, тёмным оружием, призывной нежитью и сюжетной концовкой.</strong>
|
||||
|
||||
Всё начинается с записки. Читая её, персонаж видит неясный флэшбек — обрывок того, что с ним случилось до пробуждения в этом мире.
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<ul>
|
||||
<li><strong>Камень духов</strong> — брошенный камень, светящийся некромантской энергией. Задетый зомби переходит на вашу сторону и начинает рвать других зомби вместо вас.</li>
|
||||
<li><strong>Свиток девиации</strong> — то же самое, но по площади: переманивает сразу всех зомби в радиусе взрыва. Расходуется при использовании.</li>
|
||||
<li><strong>Нож некроманта</strong> — клинок, выточенный из бедренной кости: прямое лезвие, как у ампутационного ножа, костяная гарда, рукоять в верёвочной обмотке и светящиеся руны. Урон растёт вместе с навыком Некромантии: почти бесполезен в неопытных руках, смертоносен у прокачанного игрока. Лечит владельца на половину нанесённого урона и метит раненого зомби как Жертву — труп такого зомби краснеет и лежит пять минут; при обыске он исчезает, оставляя мешок Жертвы с ингредиентами.</li>
|
||||
<li><strong>Нож некроманта</strong> — клинок, выточенный из бедренной кости: прямое лезвие, как у ампутационного ножа, костяная гарда, рукоять в верёвочной обмотке и светящиеся руны. Урон растёт вместе с навыком Некромантии: почти бесполезен в неопытных руках, смертоносен у прокачанного игрока. Лечит владельца на половину нанесённого урона и метит раненого зомби как Жертву (даже убитого с одного удара) — труп такого зомби краснеет и открывается, как ящик: внутри ингредиенты Жертвы. Нетронутый труп лежит пять минут, открытый, но не опустошённый, — час, опустошённый исчезает сразу.</li>
|
||||
<li><strong>Шесть модов только для этого ножа</strong> — вода и еда прямо из трупов (Слёзы мертвеца, Пир падальщика), защита от жары и холода (Могильный покой), замедление раненых (Хватка мертвеца), силовая атака-буря по площади за двойную цену здоровья и выносливости (Мёртвая буря) и подсветка всех зомби поблизости на карте и компасе (Тёмное чутьё). Обычные моды в этот нож не встают, а эти не встают ни во что другое.</li>
|
||||
<li><strong>Кровь некроманта</strong> — ритуальный ресурс: пустая банка, любой нож в руках и 90% текущего здоровья за одну порцию. Ингредиент для самых тёмных рецептов.</li>
|
||||
<li><strong>Кожа жертвы</strong> и <strong>Прах зомби</strong> — падают с зомби, помеченного ножом как Жертва. Основа книг призыва и большинства некромантских рецептов.</li>
|
||||
@@ -60,9 +60,9 @@
|
||||
|
||||
<h2>Установка</h2>
|
||||
|
||||
Требуется <strong>7 Days to Die 3.2</strong>. Распакуйте папку <code>NecromancerTome</code> в <code><папка игры>/Mods/</code> (или в <code>%APPDATA%/7DaysToDie/Mods/</code>) и запустите игру. Мод содержит Harmony-библиотеки, поэтому <strong>EAC должен быть выключен</strong>.
|
||||
Требуется <strong>7 Days to Die 3.3</strong> (для игры 3.2 — версия мода 1.4.2). Распакуйте папку <code>NecromancerTome</code> в <code><папка игры>/Mods/</code> (или в <code>%APPDATA%/7DaysToDie/Mods/</code>) и запустите игру. Мод содержит Harmony-библиотеки, поэтому <strong>EAC должен быть выключен</strong>.
|
||||
|
||||
Рассчитан на одиночную игру. В мультиплеере ванильная пауза не срабатывает — сюжетные сцены проиграются без остановки времени.
|
||||
Рассчитан на одиночную игру. Мод переделывает ряд собственных механик игры (трупы, добычу, поведение зомби), а сервер не обязательно на такое рассчитан, поэтому в мультиплеере часть вещей может не работать — например, трупы «Жертвы». Сюжетные сцены там проиграются без остановки времени: ванильная пауза срабатывает только в сингле.
|
||||
|
||||
<h2>Ссылки</h2>
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
|
||||
<h2>Necromancer's Tome — English description</h2>
|
||||
|
||||
<strong>A 7 Days to Die 3.2 mod: the road from ordinary survivor to necromancer — its own progression tree, cursed weapons, summonable undead and a story ending.</strong>
|
||||
<strong>A 7 Days to Die 3.3 mod: the road from ordinary survivor to necromancer — its own progression tree, cursed weapons, summonable undead and a story ending.</strong>
|
||||
|
||||
It starts with a note. Reading it, your character sees a blurred flashback — a fragment of whatever happened to them before they woke up in this world.
|
||||
|
||||
@@ -93,7 +93,7 @@ A dedicated <strong>Necromancy</strong> skill levels not from perk points and no
|
||||
<ul>
|
||||
<li><strong>Spirit Stone</strong> — a thrown stone lit with necromantic energy. The zombie it touches switches to your side and starts tearing into other zombies instead of you.</li>
|
||||
<li><strong>Scroll of Deviation</strong> — the same effect, but area-wide: it charms every zombie in the blast radius at once. Consumed on use.</li>
|
||||
<li><strong>Necromancer's Knife</strong> — a blade carved from a thighbone: a straight edge like an amputation knife, a bone guard, a rope-wrapped grip and glowing runes. Its damage scales with the Necromancy skill: nearly useless in unskilled hands, lethal for a levelled player. Heals the wielder for half the damage dealt and marks the wounded zombie as a Victim — its corpse turns red and stays for five minutes; searching it makes it vanish and leave a Victim's bag of ingredients.</li>
|
||||
<li><strong>Necromancer's Knife</strong> — a blade carved from a thighbone: a straight edge like an amputation knife, a bone guard, a rope-wrapped grip and glowing runes. Its damage scales with the Necromancy skill: nearly useless in unskilled hands, lethal for a levelled player. Heals the wielder for half the damage dealt and marks the wounded zombie as a Victim (even one killed with a single blow) — its corpse turns red and opens like a container, with the Victim's ingredients inside. An untouched corpse stays for five minutes, an opened but not emptied one for an hour, an emptied one vanishes at once.</li>
|
||||
<li><strong>Six mods for this knife only</strong> — water and food straight out of corpses (Tears of the Dead, Scavenger's Feast), protection from heat and cold (Grave's Repose), slowed victims (Dead Man's Grip), an area power-attack storm at double the health and stamina cost (Dead Storm), and every nearby zombie marked on your map and compass (Dark Sense). Ordinary knife mods will not fit this weapon, and these will not fit any other.</li>
|
||||
<li><strong>Necromancer's Blood</strong> — a ritual resource: an empty jar, any knife in hand and 90% of your current health per portion. An ingredient for the darkest recipes.</li>
|
||||
<li><strong>Victim's Skin</strong> and <strong>Zombie Ash</strong> — dropped by a zombie marked as a Victim. The basis of the summoning books and most necromantic recipes.</li>
|
||||
@@ -138,9 +138,9 @@ Activating it asks for confirmation, stops the game and unfolds a full-screen fi
|
||||
|
||||
<h3>Installation</h3>
|
||||
|
||||
Requires <strong>7 Days to Die 3.2</strong>. Unpack the <code>NecromancerTome</code> folder into <code><game folder>/Mods/</code> (or <code>%APPDATA%/7DaysToDie/Mods/</code>) and launch the game. The mod ships Harmony libraries, so <strong>EAC must be turned off</strong>.
|
||||
Requires <strong>7 Days to Die 3.3</strong> (for game 3.2 use mod version 1.4.2). Unpack the <code>NecromancerTome</code> folder into <code><game folder>/Mods/</code> (or <code>%APPDATA%/7DaysToDie/Mods/</code>) and launch the game. The mod ships Harmony libraries, so <strong>EAC must be turned off</strong>.
|
||||
|
||||
Built for single-player. In multiplayer the vanilla pause does not apply, so the story scenes play without stopping time.
|
||||
Built for single-player. The mod reworks a number of the game's own mechanics (corpses, loot, zombie behaviour), and a server isn't necessarily built for that, so in multiplayer some things may not work - the Victim corpses, for one. The story scenes also play there without stopping time, since the vanilla pause only applies in single-player.
|
||||
|
||||
<h3>Links</h3>
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Та же причина, что у PyramidWardWriteHelper: среди перегрузок LockManager.LockRequestLocal
|
||||
/// есть вариант с ReadOnlySpan<ILockTarget>, и основной проект (netstandard2.1) не может
|
||||
/// разобрать набор перегрузок целиком - CS7069 «ReadOnlySpan<> требует определения в
|
||||
/// mscorlib». Отсюда вызов собирается.
|
||||
///
|
||||
/// Зовётся из HarmonySrc/CorpseLootPatch.cs: открытие трупа «Жертвы» тем же запросом, которым
|
||||
/// игра открывает мешок (EntityItem.OnEntityActivated, команда "search").
|
||||
/// </summary>
|
||||
public static class LockRequestHelper
|
||||
{
|
||||
public static void RequestLocal(ILockTarget _target)
|
||||
{
|
||||
LockManager.Instance.LockRequestLocal(_target, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user