using System.Collections.Generic; using HarmonyLib; using UnityEngine; namespace NecromancerTome { /// /// Новый оссуарий-котёл (#67, 22.10.2026): свечение модели и отладочная команда necroossuary. /// Свечение - руны на котле, знаки на крышке, прах и кости внутри (карта _e, цвет - EmissionColor через MaterialPropertyBlock, /// общий материал не трогается) и свет glow внутри котла (дочерний объект префаба, выключен) - пока оссуарий исправно работает /// (22.10: «Когда оссуарий исправно работает, он должен подсвечиваться изнутри. Чтобы сразу было видно если что-то не так»; /// «Пусть руны тоже загораются»). Сфера в держателе (sphere) видна, пока в топливе есть сфера или камень. Следит компонент /// OssuaryVisual на модели - поэтому работает и у клиентов сервера. Выключатель - necroossuary off (модель как в префабе). /// public static class OssuaryGlow { public static bool Enabled = true; /// Дикий дух-огонёк (#69): летает, пока оссуарий работает. Выключатель - necroossuary wisp off. public static bool WispEnabled = true; /// necroossuary wisp log - писать в лог вылет и влёт (чтобы успеть снять). public static bool WispLog; /// necroossuary wisp trace - трассер: линии обоих путей огонька (вылет и влёт) у каждого котла (22.10: «А не проще /// для отладки нарисовать трассер, по которому будет лететь огонёк?»). public static bool WispTrace; public const string WispPath = "#@modfolder(NecromancerTome):Resources/necrowisp?necroWispPrefab.prefab"; static GameObject _wispPrefab; static bool _wispTried; public static GameObject WispPrefab() { if (!_wispTried) { _wispTried = true; _wispPrefab = DataLoader.LoadAsset(WispPath); Debug.Log("[NecromancerTome] OssuaryGlow: огонёк " + (_wispPrefab != null ? "загружен" : "НЕ загружен: " + WispPath)); } return _wispPrefab; } /// Цвет свечения (HDR). 22.10: было (2, 0.24, 0.12) - «Руны нарисованы красным. Это не выглядит как свечение»: /// днём смотрелось краской. Теперь ярче и теплее, как раскалённый уголь, с пульсацией. Подбор - necroossuary color / pulse. public static Color GlowColor = new Color(6f, 1.4f, 0.35f, 1f); /// Размах пульсации свечения (доля яркости) и её скорость (радиан в секунду). public static float GlowPulse = 0.3f, GlowPulseSpeed = 1.6f; static readonly MaterialPropertyBlock _block = new MaterialPropertyBlock(); static readonly int EmissionColorId = Shader.PropertyToID("_EmissionColor"); public static Transform Model(World _w, Vector3i _pos) { Chunk chunk = _w.GetChunkFromWorldPos(_pos) as Chunk; BlockEntityData be = chunk?.GetBlockEntity(_pos); return be != null && be.bHasTransform ? be.transform : null; } /// true - модель найдена и переключена. public static bool Set(Transform _model, bool _on, bool _sphere = true) { if (_model == null) { return false; } foreach (string part in new[] { "body", "lid" }) { Transform t = _model.Find(part); Renderer r = t != null ? t.GetComponent() : null; if (r == null) { continue; } r.GetPropertyBlock(_block); _block.SetColor(EmissionColorId, _on ? GlowColor : Color.black); r.SetPropertyBlock(_block); } Transform glow = _model.Find("glow"); if (glow != null) { glow.gameObject.SetActive(_on); } Transform sphere = _model.Find("sphere"); if (sphere != null) { sphere.gameObject.SetActive(_sphere); } return true; } /// Пульсация: свечение рун и нутра - GlowColor с множителем _k (каждый кадр, пока светится). public static void Pulse(Transform _model, float _k) { foreach (string part in new[] { "body", "lid" }) { Transform t = _model.Find(part); Renderer r = t != null ? t.GetComponent() : null; if (r == null) { continue; } r.GetPropertyBlock(_block); _block.SetColor(EmissionColorId, GlowColor * _k); r.SetPropertyBlock(_block); } } /// Оссуарии (главные блоки) в кубе вокруг точки. public static List Find(World _w, Vector3i _c, int _r, int _ry) { var res = new List(); for (int x = _c.x - _r; x <= _c.x + _r; x++) { for (int z = _c.z - _r; z <= _c.z + _r; z++) { for (int y = Mathf.Max(1, _c.y - _ry); y <= Mathf.Min(254, _c.y + _ry); y++) { var p = new Vector3i(x, y, z); BlockValue bv = _w.GetBlock(p); if (!bv.ischild && GeoOssuary.Is(bv.Block)) { res.Add(p); } } } } return res; } } /// На модели оссуария: два раза в секунду смотрит на коллектор и переключает сферу и свечение, только при смене. /// anyEnabled - ванильная проверка «есть рабочий выход»: учитывает топливо, заполненный выход, воду и пустые ячейки /// модификаций (GeoOssuaryPatch.isDisabled). public class OssuaryVisual : MonoBehaviour { public Vector3i Pos; float _next; int _state = -1; readonly float _phase = Random.Range(0f, 6.28f); // соседние котлы пульсируют не в такт void Update() { bool lit = _state > 0 && (_state & 1) != 0 && OssuaryGlow.Enabled; // -1 - ещё не смотрели _wisp.Tick(transform, lit && OssuaryGlow.WispEnabled); if (lit && OssuaryGlow.GlowPulse > 0f) { OssuaryGlow.Pulse(transform, 1f + OssuaryGlow.GlowPulse * Mathf.Sin(Time.time * OssuaryGlow.GlowPulseSpeed + _phase)); } if (Time.time < _next) { return; } _next = Time.time + 0.5f; World w = GameManager.Instance?.World; if (w == null) { return; } int st; // бит 1 - светится, бит 2 - сфера видна; выключатель - как в префабе (2) if (OssuaryGlow.Enabled) { st = 0; if (w.GetTileEntity(Pos) is TileEntityCollector te) { bool fuel = false; foreach (ItemStack s in te.FuelSlots) { if (s != null && !s.IsEmpty()) { fuel = true; break; } } st = (fuel ? 2 : 0) + (fuel && te.anyEnabled() ? 1 : 0); } } else { st = 2; } if (st == _state) { return; } _state = st; OssuaryGlow.Set(transform, (st & 1) != 0, (st & 2) != 0); } readonly OssuaryWisp _wisp = new OssuaryWisp(); void OnDestroy() { _wisp.Destroy(); } } /// Дикий дух оссуария (#69; 20.10: «Хватит одного огонька. Пусть светится сам. Производительность нам важна»): /// чистая анимация, без сущностей (22.10: «периодически из окошка в котле вылетает дух и уходит вниз, увеличивая прозрачность /// до полной, а через какое-то время с другой стороны снизу вылетает полностью прозрачный дух, плавно уменьшая прозрачность, /// и влетает в противоположное окошко»). Один огонёк на котёл (бандл necrowisp), только пока оссуарий исправно работает /// и игрок ближе WispViewRange. На добычу не влияет. public class OssuaryWisp { public const float WispViewRange = 60f; /// Окошки крышки (модель #67): шесть, через 60°, со сдвигом WindowAngle от оси X модели (рамки окошек в сетке - /// на 30°, 90°...; на 0°, 60°... - металлические полосы: 22.10 «Светлячок заходит в полосы металла, и игнорирует окошки»). /// По высоте - на WindowUp от низа крышки, от оси - на WindowR (доли размеров крышки; в модели окошко 0.46-0.56 м /// при низе крышки 0.382, радиус 0.37 при полуширине 0.418). const float WindowAngle = 30f; const float WindowUp = 0.13f / 0.836f, WindowR = 0.37f / 0.418f; /// Подъём и спуск - вертикально снаружи, на Clear дальше самого широкого места котла (22.10: «Огонёк летит сквозь /// котёл. А надо чтобы поднимался почти на уровень окошка, и по дуге летел прямо через его центр»). const float Clear = 0.25f; const float BelowWindow = 0.1f; // вертикаль кончается на столько ниже окошка, м; дальше дуга const float Under = 0.35f; // насколько под землю, м const float FlyTime = 2.4f; static readonly int TintId = Shader.PropertyToID("_TintColor"); static readonly int ColorId = Shader.PropertyToID("_Color"); static readonly MaterialPropertyBlock _mpb = new MaterialPropertyBlock(); GameObject _go; Renderer[] _rs; Color[] _base; TrailRenderer _trail; int _phase = -1; // -1 дома, 0 вылет и уход в землю, 1 под землёй, 2 подъём и влёт float _t0, _until; LineRenderer _traceOut, _traceIn; Vector3[] _pathOut, _pathIn; // вылет: нутро -> окошко -> дуга -> вниз в землю; влёт - обратный порядок с другой стороны static float Ease(float _k) => _k * _k * (3f - 2f * _k); public void Tick(Transform _model, bool _work) { EntityPlayerLocal pl = GameManager.Instance?.World?.GetPrimaryPlayer(); bool near = pl != null && (pl.position - Origin.position - _model.position).sqrMagnitude < WispViewRange * WispViewRange; float now = Time.time; if (!_work || !near) { if (_phase >= 0) { Hide(); _phase = -1; } _until = Mathf.Max(_until, now + Random.Range(1f, 4f)); return; } if (_go == null && !Spawn(_model)) { return; } if (!OssuaryGlow.WispTrace && _traceOut != null && _traceOut.enabled) { _traceOut.enabled = _traceIn.enabled = false; } else if (OssuaryGlow.WispTrace && _pathOut == null) { Places(_model); } float k = Mathf.Clamp01((now - _t0) / Mathf.Max(0.01f, _until - _t0)); switch (_phase) { case -1: if (now < _until || !Places(_model)) { return; } Show(_pathOut[0]); if (OssuaryGlow.WispLog) { Debug.Log("[NecromancerTome] OssuaryWisp: вылет " + _pathOut[1]); } Next(0, FlyTime); return; case 0: // из окошка наружу и вниз, в землю; тает Fly(_pathOut, k); Alpha(1f - Ease(Mathf.InverseLerp(0.45f, 1f, k))); if (k >= 1f) { Hide(); Next(1, Random.Range(12f, 20f)); } return; case 1: // под землёй if (now >= _until) { Alpha(0f); Show(_pathIn[0]); if (OssuaryGlow.WispLog) { Debug.Log("[NecromancerTome] OssuaryWisp: влёт " + _pathIn[_pathIn.Length - 2]); } Next(2, FlyTime); } return; case 2: // с другой стороны снизу вверх, проявляется, влетает в противоположное окошко Fly(_pathIn, k); Alpha(Ease(Mathf.InverseLerp(0f, 0.55f, k))); if (k >= 1f) { Hide(); _phase = -1; _until = now + Random.Range(3f, 6f); } return; } } void Next(int _p, float _dur) { _phase = _p; _t0 = Time.time; _until = _t0 + _dur; } /// Гладкая кривая (Catmull-Rom) точно через точки пути; скорость по пути ровная (время - по длинам отрезков). void Fly(Vector3[] _path, float _k) { Vector3 p = At(_path, Ease(_k)); p.y += 0.015f * Mathf.Sin(Time.time * 7f); // лёгкое дрожание огонька _go.transform.position = p; } /// Точка пути на доле длины _s (0..1). static Vector3 At(Vector3[] _path, float _s01) { float total = 0f; for (int i = 1; i < _path.Length; i++) { total += Vector3.Distance(_path[i - 1], _path[i]); } float s = _s01 * total; int seg = 0; float len = 0f; for (; seg < _path.Length - 2; seg++) { len = Vector3.Distance(_path[seg], _path[seg + 1]); if (s <= len) { break; } s -= len; } len = Vector3.Distance(_path[seg], _path[seg + 1]); float u = len > 0.0001f ? Mathf.Clamp01(s / len) : 1f; Vector3 p0 = _path[Mathf.Max(0, seg - 1)], p1 = _path[seg], p2 = _path[seg + 1], p3 = _path[Mathf.Min(_path.Length - 1, seg + 2)]; return 0.5f * (2f * p1 + (p2 - p0) * u + (2f * p0 - 5f * p1 + 4f * p2 - p3) * u * u + (3f * p1 - p0 - 3f * p2 + p3) * u * u * u); } /// Окошко вылета - случайное из шести, влёта - напротив. Путь: нутро, центр окошка, точка дуги снаружи на высоте /// окошка, вертикаль снаружи котла чуть ниже окошка, та же вертикаль под землёй (высота земли - лучом). bool Places(Transform _model) { Transform lid = _model.Find("lid"); Transform body = _model.Find("body"); Renderer lr = lid != null ? lid.GetComponent() : null; Renderer br = body != null ? body.GetComponent() : null; if (lr == null) { return false; } Bounds b = lr.bounds; float half = (b.size.x + b.size.z) * 0.25f; float bodyHalf = br != null ? (br.bounds.size.x + br.bounds.size.z) * 0.25f : half * 1.1f; float winY = b.min.y + WindowUp * half * 2f; Vector3 axis = new Vector3(b.center.x, winY, b.center.z); int w = Random.Range(0, 6); Vector3 dOut = lid.rotation * Quaternion.Euler(0f, WindowAngle + w * 60f, 0f) * Vector3.right; dOut.y = 0f; dOut.Normalize(); _pathOut = Path(axis, dOut, half, Mathf.Max(bodyHalf, half) + Clear); _pathIn = Path(axis, -dOut, half, Mathf.Max(bodyHalf, half) + Clear); System.Array.Reverse(_pathIn); Trace(_model); return true; } Vector3[] Path(Vector3 _axis, Vector3 _dir, float _half, float _rOut) { float rWin = _half * WindowR; Vector3 low = _axis + _dir * _rOut + Vector3.down * BelowWindow; Vector3 under = Ground(_axis + _dir * _rOut) + Vector3.down * Under; return new[] { _axis + _dir * (rWin * 0.45f), // внутри котла _axis + _dir * rWin, // центр окошка _axis + _dir * (rWin + (_rOut - rWin) * 0.55f), // дуга: снаружи на высоте окошка low, // вертикаль снаружи, чуть ниже окошка new Vector3(low.x, Mathf.Lerp(low.y, under.y, 0.5f), low.z), under // в земле }; } /// Трассер: путь вылета и путь влёта линиями, точками той же кривой, по которой летит огонёк. void Trace(Transform _model) { if (!OssuaryGlow.WispTrace || _trail == null) { return; } if (_traceOut == null) { _traceOut = Line(_model, "NecroWispTraceOut", new Color(1f, 0.6f, 0.1f)); _traceIn = Line(_model, "NecroWispTraceIn", new Color(0.2f, 0.8f, 1f)); } Fill(_traceOut, _pathOut); Fill(_traceIn, _pathIn); } LineRenderer Line(Transform _model, string _name, Color _c) { var g = new GameObject(_name); g.transform.SetParent(_model, false); LineRenderer l = g.AddComponent(); l.sharedMaterial = _trail.sharedMaterial; l.useWorldSpace = true; l.widthMultiplier = 0.015f; l.startColor = l.endColor = _c; l.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; l.receiveShadows = false; return l; } void Fill(LineRenderer _l, Vector3[] _path) { const int n = 60; var pts = new Vector3[n + 1]; for (int i = 0; i <= n; i++) { pts[i] = At(_path, (float)i / n); } _l.positionCount = pts.Length; _l.SetPositions(pts); _l.enabled = true; } Vector3 Ground(Vector3 _p) { if (Physics.Raycast(_p + Vector3.up * 1.5f, Vector3.down, out RaycastHit hit, 8f, SnakeControl.WallMask, QueryTriggerInteraction.Ignore)) { _p.y = hit.point.y; } else { _p.y -= 1f; } return _p; } bool Spawn(Transform _model) { GameObject prefab = OssuaryGlow.WispPrefab(); if (prefab == null) { return false; } _go = Object.Instantiate(prefab, _model, false); _go.name = "NecroOssuaryWisp"; _trail = _go.GetComponent(); _rs = _go.GetComponentsInChildren(true); _base = new Color[_rs.Length]; for (int i = 0; i < _rs.Length; i++) { Material m = _rs[i].sharedMaterial; _base[i] = m == null ? Color.white : m.HasProperty(TintId) ? m.GetColor(TintId) : m.HasProperty(ColorId) ? m.GetColor(ColorId) : Color.white; } _go.SetActive(false); return true; } /// Прозрачность - цветом материала через MaterialPropertyBlock (шейдер аддитивный: тусклее = прозрачнее), /// общий материал не трогается. void Alpha(float _a) { for (int i = 0; i < _rs.Length; i++) { Material m = _rs[i].sharedMaterial; if (m == null) { continue; } Color c = _base[i] * _a; _rs[i].GetPropertyBlock(_mpb); if (m.HasProperty(TintId)) { _mpb.SetColor(TintId, c); } if (m.HasProperty(ColorId)) { _mpb.SetColor(ColorId, c); } _rs[i].SetPropertyBlock(_mpb); } } void Show(Vector3 _at) { _go.transform.position = _at; if (_trail != null) { _trail.Clear(); } _go.SetActive(true); if (_phase != 1) { Alpha(1f); } } void Hide() { if (_go != null) { _go.SetActive(false); } } public void Destroy() { if (_go != null) { Object.Destroy(_go); _go = null; } foreach (LineRenderer l in new[] { _traceOut, _traceIn }) { if (l != null) { Object.Destroy(l.gameObject); } } _traceOut = _traceIn = null; } } /// Модель оссуария появилась в мире - повесить на неё OssuaryVisual. [HarmonyPatch(typeof(BlockCollector), nameof(BlockCollector.OnBlockEntityTransformBeforeActivated))] public static class Patch_BlockCollector_OnBlockEntityTransformBeforeActivated_OssuaryVisual { public static void Postfix(Vector3i _blockPos, BlockValue _blockValue, BlockEntityData _ebcd) { if (!GeoOssuary.Is(_blockValue.Block) || _ebcd?.transform == null || _ebcd.transform.Find("body") == null) { return; } OssuaryVisual v = _ebcd.transform.GetComponent() ?? _ebcd.transform.gameObject.AddComponent(); v.Pos = _blockPos; } } public class ConsoleCmdNecroOssuary : ConsoleCmdAbstract { public override bool IsExecuteOnClient => true; public override bool AllowedInMainMenu => false; public override string[] getCommands() { return new string[] { "necroossuary" }; } public override string getDescription() { return "Necromancer's Tome: cauldron ossuary - find, remove, sphere/glow switch."; } public override string getHelp() { return "necroossuary find [r] - ossuaries around the player (r blocks, default 30)\n" + "necroossuary remove x y z - remove the ossuary at x y z (only if it is an ossuary)\n" + "necroossuary on|off - off: sphere and glow no longer follow the ossuary state\n" + "necroossuary wisp on|off - wild spirit wisp flying while the ossuary works\n" + "necroossuary wisp trace|log - dev: draw the wisp paths / log take-off and return\n" + "necroossuary open x y z - dev: open the ossuary window as with E\n" + "necroossuary info x y z - slots of the ossuary (samples, fuel, storage) and whether it works\n" + "necroossuary put x y z mod|fuel [count] - dev: put an item into a slot (none - empty it)\n" + "necroossuary color r g b - glow color (HDR, e.g. 6 1.4 0.35)\n" + "necroossuary pulse a [speed] - glow pulse amplitude (0 - steady) and speed"; } public override void Execute(List _params, CommandSenderInfo _senderInfo) { SdtdConsole con = SingletonMonoBehaviour.Instance; World w = GameManager.Instance?.World; EntityPlayerLocal pl = w?.GetPrimaryPlayer(); if (w == null || pl == null || _params.Count == 0) { con.Output(getHelp()); return; } Vector3i me = World.worldToBlockPos(pl.position); switch (_params[0]) { case "find": { int r = _params.Count > 1 ? int.Parse(_params[1]) : 30; List found = OssuaryGlow.Find(w, me, r, 20); con.Output("necroossuary: найдено " + found.Count + (found.Count > 0 ? ": " + string.Join("; ", found) : "")); break; } case "remove": { var p = new Vector3i(int.Parse(_params[1]), int.Parse(_params[2]), int.Parse(_params[3])); BlockValue bv = w.GetBlock(p); if (!GeoOssuary.Is(bv.Block)) { con.Output("necroossuary: в " + p + " не оссуарий (" + bv.Block?.GetBlockName() + ") - не трогаю"); break; } if (bv.ischild) { p = p + bv.parent; } w.SetBlockRPC(p, BlockValue.Air); con.Output("necroossuary: убран оссуарий в " + p); break; } case "open": { var p = new Vector3i(int.Parse(_params[1]), int.Parse(_params[2]), int.Parse(_params[3])); BlockValue bv = w.GetBlock(p); if (bv.ischild) { p = p + bv.parent; bv = w.GetBlock(p); } con.Output("necroossuary: открыть " + p + " - " + (GeoOssuary.Is(bv.Block) && bv.Block.OnBlockActivated(w, p, bv, pl))); break; } case "info": case "put": { var p = new Vector3i(int.Parse(_params[1]), int.Parse(_params[2]), int.Parse(_params[3])); BlockValue bv = w.GetBlock(p); if (bv.ischild) { p = p + bv.parent; } if (!(w.GetTileEntity(p) is TileEntityCollector te) || !GeoOssuary.Is(te)) { con.Output("necroossuary: в " + p + " не оссуарий"); break; } if (_params[0] == "put") { ItemStack[] slots = _params[4] == "fuel" ? te.FuelSlots : te.ModSlots; int i = int.Parse(_params[5]) - 1; bool none = _params[6] == "none"; ItemValue iv = none ? ItemValue.None : ItemClass.GetItem(_params[6]); if (i < 0 || i >= slots.Length || iv == null || (!none && iv.IsEmpty())) { con.Output("necroossuary: нет ячейки " + (i + 1) + " или предмета " + _params[6]); break; } slots[i] = none ? ItemStack.Empty.Clone() : new ItemStack(iv, _params.Count > 7 ? int.Parse(_params[7]) : 1); te.SetModified(); } string S(ItemStack[] _a) { var parts = new List(); for (int i = 0; i < _a.Length; i++) { parts.Add(_a[i] == null || _a[i].IsEmpty() ? "-" : _a[i].itemValue.ItemClass?.Name + " x" + _a[i].count); } return string.Join(", ", parts); } con.Output("necroossuary " + p + ": образцы [" + S(te.ModSlots) + "]; топливо [" + S(te.FuelSlots) + "]; хранилище [" + S(te.Items) + "]; работает " + te.anyEnabled()); break; } case "color": if (_params.Count > 3) { OssuaryGlow.GlowColor = new Color(float.Parse(_params[1], System.Globalization.CultureInfo.InvariantCulture), float.Parse(_params[2], System.Globalization.CultureInfo.InvariantCulture), float.Parse(_params[3], System.Globalization.CultureInfo.InvariantCulture), 1f); } con.Output("necroossuary: цвет свечения " + OssuaryGlow.GlowColor); break; case "pulse": if (_params.Count > 1) { OssuaryGlow.GlowPulse = float.Parse(_params[1], System.Globalization.CultureInfo.InvariantCulture); } if (_params.Count > 2) { OssuaryGlow.GlowPulseSpeed = float.Parse(_params[2], System.Globalization.CultureInfo.InvariantCulture); } con.Output("necroossuary: пульсация " + OssuaryGlow.GlowPulse + ", скорость " + OssuaryGlow.GlowPulseSpeed); break; case "wisp": if (_params.Count > 1 && _params[1] == "trace") { OssuaryGlow.WispTrace = !OssuaryGlow.WispTrace; con.Output("necroossuary: трассер огонька " + (OssuaryGlow.WispTrace ? "включён (оранжевый - вылет, голубой - влёт)" : "выключен")); break; } if (_params.Count > 1 && _params[1] == "log") { OssuaryGlow.WispLog = !OssuaryGlow.WispLog; con.Output("necroossuary: лог огонька " + (OssuaryGlow.WispLog ? "включён" : "выключен")); break; } if (_params.Count > 1) { OssuaryGlow.WispEnabled = _params[1] == "on"; } con.Output("necroossuary: огонёк " + (OssuaryGlow.WispEnabled ? "летает" : "выключен")); break; case "on": case "off": OssuaryGlow.Enabled = _params[0] == "on"; con.Output("necroossuary: сфера и свечение по работе " + (OssuaryGlow.Enabled ? "включены" : "выключены (модель как в префабе)")); break; default: con.Output(getHelp()); break; } } } }