diff --git a/Config/Localization.csv b/Config/Localization.csv
index 44ddb7a..61036fd 100644
--- a/Config/Localization.csv
+++ b/Config/Localization.csv
@@ -324,6 +324,7 @@ Dört modifikasyon yuvasına takılan kan küreleriyle çalışır: küreler sı
necroBoneSteedNeedBones,UI,Tooltip,,,,You need {0} bones to repair the Bone Steed.,,"Du brauchst {0} Knochen, um das Knochenross zu reparieren.",Necesitas {0} huesos para reparar el Corcel de hueso.,Il te faut {0} os pour réparer le Destrier d'os.,Servono {0} ossa per riparare il Destriero d'ossa.,骨の駿馬の修理には骨が{0}個必要だ。,뼈 군마를 수리하려면 뼈 {0}개가 필요하다.,Do naprawy Kościanego rumaka potrzeba {0} kości.,Você precisa de {0} ossos para reparar o Corcel de osso.,Для ремонта Костяного скакуна нужно {0} костей.,Kemik Atı onarmak için {0} kemik gerekir.,修理骨骸坐骑需要{0}根骨头。,修理骨骸坐騎需要{0}根骨頭。
necroBoneSteed,entityclasses,Entity,,,,Bone Steed,,Knochenross,Corcel de hueso,Destrier d'os,Destriero d'ossa,骨の駿馬,뼈 군마,Kościany rumak,Corcel de osso,Костяной скакун,Kemik At,骨骸坐骑,骨骸坐騎
zombieEraser,entityclasses,Entity,,,,Eraser,,Radierer,Borrador,Effaceur,Cancellatore,イレイサー,이레이저,Wymazywacz,Apagador,Стиратель,Silici,抹除者,抹除者
+necroRemains,entityclasses,Entity,,,,Remains,,Überreste,Restos,Restes,Resti,遺骸,유해,Szczątki,Restos,Останки,Kalıntılar,遗骸,遺骸
necroMemoryBook,blocks,Block,,,,Book of Memory,,Buch der Erinnerung,Libro de la memoria,Livre de la mémoire,Libro della memoria,記憶の書,기억의 책,Księga pamięci,Livro da memória,Книга памяти,Hafıza Kitabı,记忆之书,記憶之書
necroMemoryBookPrompt,blocks,Block,,,,Press {0} to read,,Drücke {0} zum Lesen,Pulsa {0} para leer,Appuyez sur {0} pour lire,Premi {0} per leggere,{0} で読む,{0} 키를 눌러 읽기,"Naciśnij {0}, aby przeczytać",Pressione {0} para ler,"Нажмите {0}, чтобы прочитать",Okumak için {0} tuşuna basın,按 {0} 阅读,按 {0} 閱讀
necroMemoryTitle,XUi,Menu,,,,Memory,,Erinnerung,Memoria,Mémoire,Memoria,記憶,기억,Pamięć,Memória,Память,Hafıza,记忆,記憶
diff --git a/Config/entityclasses.xml b/Config/entityclasses.xml
index 22de7c9..4fb9c21 100644
--- a/Config/entityclasses.xml
+++ b/Config/entityclasses.xml
@@ -482,5 +482,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Config/loot.xml b/Config/loot.xml
index 60f66b2..46e6ada 100644
--- a/Config/loot.xml
+++ b/Config/loot.xml
@@ -39,5 +39,10 @@
+
+
+
+
diff --git a/HarmonySrc/ModEntry.cs b/HarmonySrc/ModEntry.cs
index 71c898d..b0429bc 100644
--- a/HarmonySrc/ModEntry.cs
+++ b/HarmonySrc/ModEntry.cs
@@ -67,6 +67,7 @@ namespace NecromancerTome
Possession.Init();
BloodTide.Init();
FreezeProbe.Init();
+ RemainsDecay.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.
diff --git a/HarmonySrc/RemainsDecay.cs b/HarmonySrc/RemainsDecay.cs
new file mode 100644
index 0000000..6cab293
--- /dev/null
+++ b/HarmonySrc/RemainsDecay.cs
@@ -0,0 +1,198 @@
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace NecromancerTome
+{
+ ///
+ /// Истлевание красного трупа в останки (указание 10.10.2026). Красный труп «Жертвы» - полноценный мёртвый зомби
+ /// (модель со скелетом, физика, перекрашенные текстуры): много таких трупов в загруженной зоне - много памяти, а при
+ /// выгрузке участка игра трупы не сохраняет (лут пропадал). Поэтому труп истлевает в лёгкий контейнер necroRemains
+ /// (entityclasses.xml, своя модель: остов, обтянутый обугленной плёнкой; бандл necroremains):
+ /// - через DecaySeconds после того, как стал красным трупом (пользователь: «2 минуты будет более чем достаточно»);
+ /// - раньше, если до ближайшего игрока дальше FarDistance - участок скоро выгрузится;
+ /// - раньше, если красных трупов больше MaxCorpses - сначала самые старые.
+ /// Лут переходит как у ванильного DropBagServer (Entity.cs): нетронутый - список zPackVictimRemains (наполнится при
+ /// первом открытии), открытый - предметы и отметка «трогали». Остаток срока трупа переходит в срок останков.
+ /// Звук - разбитое пятно крови (блоки bloodDecor из материала Morganic: organicdestroy). Только сервер/одиночная игра.
+ ///
+ public static class RemainsDecay
+ {
+ public const string EntityClassName = "necroRemains";
+ public const string RemainsLootList = "zPackVictimRemains";
+ public const float DecaySeconds = 120f;
+ public const float FarDistance = 80f;
+ public const int MaxCorpses = 30;
+ public const string Sound = "organicdestroy";
+
+ /// Когда мод впервые увидел красный труп (Time.time).
+ static readonly Dictionary _since = new Dictionary();
+ static float _next;
+ static readonly List _tmp = new List();
+
+ public static void Init()
+ {
+ ModEvents.UnityUpdate.RegisterHandler(OnUnityUpdate);
+ }
+
+ static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data)
+ {
+ World world = GameManager.Instance?.World;
+ if (world == null)
+ {
+ _since.Clear();
+ return;
+ }
+ if (Time.time < _next || !SingletonMonoBehaviour.Instance.IsServer)
+ {
+ return;
+ }
+ _next = Time.time + 1f;
+ // забыть ушедшие
+ _tmp.Clear();
+ foreach (int id in _since.Keys)
+ {
+ if (!CorpseLoot.SearchableCorpses.Contains(id))
+ {
+ _tmp.Add(id);
+ }
+ }
+ foreach (int id in _tmp)
+ {
+ _since.Remove(id);
+ }
+ foreach (int id in CorpseLoot.SearchableCorpses)
+ {
+ if (!_since.ContainsKey(id))
+ {
+ _since[id] = Time.time;
+ }
+ }
+ // кого истлеть
+ _tmp.Clear();
+ foreach (var kv in _since)
+ {
+ if (!(world.GetEntity(kv.Key) is EntityAlive c) || !c.IsDead())
+ {
+ continue;
+ }
+ if (Time.time - kv.Value >= DecaySeconds || FarFromPlayers(world, c.position))
+ {
+ _tmp.Add(kv.Key);
+ }
+ }
+ if (_since.Count - _tmp.Count > MaxCorpses)
+ {
+ var rest = new List>();
+ foreach (var kv in _since)
+ {
+ if (!_tmp.Contains(kv.Key))
+ {
+ rest.Add(kv);
+ }
+ }
+ rest.Sort((a, b) => a.Value.CompareTo(b.Value));
+ for (int i = 0; i < rest.Count - MaxCorpses; i++)
+ {
+ _tmp.Add(rest[i].Key);
+ }
+ }
+ foreach (int id in _tmp)
+ {
+ if (world.GetEntity(id) is EntityAlive c)
+ {
+ Convert(world, c);
+ }
+ _since.Remove(id);
+ }
+ }
+
+ static bool FarFromPlayers(World _world, Vector3 _p)
+ {
+ float far2 = FarDistance * FarDistance;
+ for (int i = 0; i < _world.Players.list.Count; i++)
+ {
+ EntityPlayer pl = _world.Players.list[i];
+ if (pl != null && (pl.position - _p).sqrMagnitude < far2)
+ {
+ return false;
+ }
+ }
+ return _world.Players.list.Count > 0;
+ }
+
+ /// Красный труп -> останки. True - получилось (труп убран).
+ public static bool Convert(World _world, EntityAlive _corpse)
+ {
+ int cls = EntityClass.FromString(EntityClassName);
+ if (cls == -1 || _corpse == null || _corpse.IsMarkedForUnload())
+ {
+ return false;
+ }
+ // где и как лежит тело: таз и направление таз -> голова (у модели останков голова - в сторону -Z)
+ Vector3 pos = _corpse.emodel != null ? _corpse.emodel.GetHipPosition() : _corpse.position;
+ Vector3 head = _corpse.emodel?.GetHeadTransform() != null ? _corpse.emodel.GetHeadTransform().position + Origin.position : pos + _corpse.transform.forward;
+ Vector3 dir = head - pos; dir.y = 0f;
+ float yaw = dir.sqrMagnitude > 1e-4f ? Mathf.Atan2(-dir.x, -dir.z) * Mathf.Rad2Deg : _corpse.rotation.y;
+ // на землю: луч вниз от таза
+ Vector3 g = pos;
+ if (Physics.Raycast(pos + Vector3.up * 0.6f - Origin.position, Vector3.down, out RaycastHit hit, 3f, SnakeControl.TerrainMask, QueryTriggerInteraction.Ignore))
+ {
+ g.y = hit.point.y + Origin.position.y;
+ }
+ // центр модели останков сдвинут от таза к груди: отодвинуть назад на полкорпуса, чтобы таз остался на месте
+ Vector3 back = Quaternion.Euler(0f, yaw, 0f) * Vector3.back;
+ g += back * 0.16f;
+
+ if (!(EntityFactory.CreateEntity(cls, g, new Vector3(0f, yaw, 0f)) is EntityLootContainer rem))
+ {
+ return false;
+ }
+ rem.OverrideLootList = RemainsLootList;
+ Bag bag = _corpse.bag;
+ bool touched = bag != null && bag.Touched;
+ if (touched)
+ {
+ rem.bag.Resize(new Vector2i(bag.SlotCount, 1));
+ rem.bag.ItemGrid.CopyItemsFrom(bag.ItemGrid);
+ rem.bag.ItemGrid.WorldTimeTouched = bag.ItemGrid.WorldTimeTouched;
+ }
+ _world.SpawnEntityInWorld(rem);
+ int left = Mathf.Max(20 * 30, _corpse.timeStayAfterDeath - _corpse.deathUpdateTime);
+ rem.timeStayAfterDeath = left;
+ rem.deathUpdateTime = 0;
+ Audio.Manager.BroadcastPlay(g, Sound);
+ CorpseLoot.SearchableCorpses.Remove(_corpse.entityId);
+ _world.RemoveEntity(_corpse.entityId, EnumRemoveEntityReason.Despawned);
+ Debug.Log("[NecromancerTome] RemainsDecay: труп " + _corpse.entityId + " -> останки " + rem.entityId +
+ (touched ? " (лут перенесён)" : " (нетронутый)") + ", срок " + left / 20 + " с");
+ return true;
+ }
+ }
+
+ /// necroremains - мгновенно истлеть все красные трупы рядом (проверка без ожидания 2 минут).
+ [UnityEngine.Scripting.Preserve]
+ public class ConsoleCmdNecroRemains : ConsoleCmdAbstract
+ {
+ public override string[] getCommands() => new[] { "necroremains" };
+ public override string getDescription() => "NecromancerTome: красные трупы рядом сразу истлевают в останки";
+
+ public override void Execute(List _params, CommandSenderInfo _senderInfo)
+ {
+ World world = GameManager.Instance?.World;
+ if (world == null)
+ {
+ return;
+ }
+ var ids = new List(CorpseLoot.SearchableCorpses);
+ int n = 0;
+ foreach (int id in ids)
+ {
+ if (world.GetEntity(id) is EntityAlive c && c.IsDead() && RemainsDecay.Convert(world, c))
+ {
+ n++;
+ }
+ }
+ SdtdConsole.Instance.Output("истлело трупов: " + n);
+ }
+ }
+}
diff --git a/NecromancerHarmony.dll b/NecromancerHarmony.dll
index 42c1096..720becf 100644
Binary files a/NecromancerHarmony.dll and b/NecromancerHarmony.dll differ
diff --git a/NecromancerHarmony.pdb b/NecromancerHarmony.pdb
index 6441013..7309122 100644
Binary files a/NecromancerHarmony.pdb and b/NecromancerHarmony.pdb differ
diff --git a/Resources/necroremains b/Resources/necroremains
new file mode 100644
index 0000000..4cf445c
Binary files /dev/null and b/Resources/necroremains differ