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);
}
}
}