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). Только сервер/одиночная игра. /// Последние BurnSeconds труп горит (ванильное пламя p_onFire по форме тела, как у горящего зомби) и обжигает /// стоящих вплотную (ванильный buffBurningEnvironment - как костёр). Ради нагрузки горят только трупы ближе BurnNear /// к игроку и не больше MaxBurning сразу (ближайшие); ушёл игрок - пламя гаснет. Пламя видно у хоста (сервер). /// 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"; public const float BurnSeconds = 30f; public const float BurnNear = 25f; public const int MaxBurning = 6; public const float BurnRadius = 1.3f; const string FireParticle = "p_onFire"; const string FireName = "Ptl_" + FireParticle; const string BurnBuff = "buffBurningEnvironment"; /// Когда мод впервые увидел красный труп (Time.time). static readonly Dictionary _since = new Dictionary(); static float _next; static readonly List _tmp = new List(); static readonly HashSet _burning = new HashSet(); static readonly List> _cand = new List>(); static GameObject _fire; 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(); _burning.Clear(); BundleShaderFix.Forget(); 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); _burning.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); _burning.Remove(id); } Burn(world); } /// Тест: все красные трупы сразу в последние BurnSeconds (necroremains burn). public static int IgniteAll() { _tmp.Clear(); _tmp.AddRange(_since.Keys); foreach (int id in _tmp) { _since[id] = Mathf.Min(_since[id], Time.time - (DecaySeconds - BurnSeconds)); } _next = 0f; return _tmp.Count; } static void Burn(World _world) { _cand.Clear(); foreach (var kv in _since) { if (Time.time - kv.Value < DecaySeconds - BurnSeconds || !(_world.GetEntity(kv.Key) is EntityAlive c) || !c.IsDead()) { continue; } float d = NearestPlayer(_world, c.position); if (d < BurnNear) { _cand.Add(new KeyValuePair(d, c)); } } _cand.Sort((a, b) => a.Key.CompareTo(b.Key)); _tmp.Clear(); for (int i = 0; i < _cand.Count && i < MaxBurning; i++) { _tmp.Add(_cand[i].Value.entityId); } // погасить лишние foreach (int id in new List(_burning)) { if (!_tmp.Contains(id)) { (_world.GetEntity(id) as EntityAlive)?.RemoveParticle(FireName); _burning.Remove(id); } } for (int i = 0; i < _cand.Count && i < MaxBurning; i++) { EntityAlive c = _cand[i].Value; if (!_burning.Contains(c.entityId) && Ignite(c)) { _burning.Add(c.entityId); } if (!_burning.Contains(c.entityId)) { continue; } // обжечь вплотную стоящих (как костёр: от 3 касаний подряд - горит сам) Vector3 hip = c.emodel != null ? c.emodel.GetHipPosition() : c.position; float r2 = BurnRadius * BurnRadius; foreach (EntityPlayer pl in _world.Players.list) { if (pl != null && !pl.IsDead() && (pl.position - hip).sqrMagnitude < r2) { pl.Buffs.AddBuff(BurnBuff); } } } } static float NearestPlayer(World _world, Vector3 _p) { float best = float.MaxValue; foreach (EntityPlayer pl in _world.Players.list) { if (pl != null) { best = Mathf.Min(best, (pl.position - _p).magnitude); } } return best; } /// Пламя по форме тела - как MinEventActionAttachParticleEffectToEntity (parent_transform LOD0, shape_mesh). static bool Ignite(EntityAlive _c) { Transform parent = _c.emodel?.meshTransform; if (parent == null) { return false; } if (_fire == null) { _fire = LoadManager.LoadAssetFromAddressables("ParticleEffects/" + FireParticle + ".prefab", null, null, _deferLoading: false, _loadSync: true).Asset; if (_fire == null) { return false; } } if (parent.Find(FireName) != null) { return true; } GameObject go = Object.Instantiate(_fire); go.name = FireName; Utils.SetLayerRecursively(go, parent.gameObject.layer); Transform t = go.transform; t.SetParent(parent, false); t.SetLocalPositionAndRotation(Vector3.zero, Quaternion.identity); _c.AddParticle(FireName, t); SkinnedMeshRenderer smr = parent.GetComponentInChildren(); foreach (ParticleSystem ps in go.GetComponentsInChildren()) { ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); ParticleSystem.ShapeModule sh = ps.shape; if (sh.shapeType == ParticleSystemShapeType.SkinnedMeshRenderer || sh.shapeType == ParticleSystemShapeType.Mesh) { if (smr != null) { sh.skinnedMeshRenderer = smr; } else { sh.shapeType = ParticleSystemShapeType.Sphere; } } ParticleSystem.MainModule main = ps.main; main.duration = 900000f; ps.Play(); } return true; } 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.19f; // модель x1.2 (NecroRemainsSetup.Scale) 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: красные трупы сразу истлевают в останки; burn - сразу последние 30 с (горят)"; public override void Execute(List _params, CommandSenderInfo _senderInfo) { World world = GameManager.Instance?.World; if (world == null) { return; } if (_params.Count > 0 && _params[0] == "burn") { SdtdConsole.Instance.Output("подожжено трупов (последние " + RemainsDecay.BurnSeconds + " с): " + RemainsDecay.IgniteAll()); 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); } } }