using System.Collections.Generic; using System.Globalization; using UnityEngine; using UnityEngine.Scripting; namespace NecromancerTome { /// /// Цинерарий-старатель (#64, решения 20.10 и 22.10.2026): дух добывает ископаемые. В «Содержимом» две ячейки, как у рабочего: /// череп и образец - что добывать (один из Ores). Нить к ящику (любая, как у лутера) - куда класть. Есть всё - дух отлетает на /// ближайшую свободную клетку рядом с урной и уходит на ProspectorDepth блоков вниз, растворяясь; раз в игровой час поднимается /// так же, кладёт добычу в ящик и уходит снова. Без зоны и вне лимита урн, как остальные некротехнологи. /// /// Добыча (20.10: «цена - скоростью, а не ресурсом»): за игровые сутки камень, глина, песок, древесина - 5 стопок по 6000, /// железо, свинец, уголь, селитра, сланец - 3 стопки; ходка - сутки / 24, разброс ±20%. Находки (20.10: «шанс добыть алмаз, /// серебро и золото, как и в игре»; 22.10 - всегда, без книги «Удачный удар», латуни нет): ходка руды = столько блоков, сколько /// даёт её выход с блока в игре (железо 105, прочие 50), на каждый - шансы игры (алмаз 0.5%, серебро 1%, золото 0.7%). /// /// Время - игровое (worldTime), чтобы добыча за сутки не зависела от их длины. Ходка засчитывается, только пока чанк урны /// загружен; время перескочило больше чем на час (сон, прилив, settime) - ходка не засчитана, отсчёт заново. Ящик полон или /// открыт игроком - дух ждёт дома с добычей (почернел, если полон) и докладывает, как появится место; новой ходки нет. /// Добыча в руках духа не сохраняется: урну подняли или чанк выгрузился - ходка пропала. /// public partial class TEFeatureCinerarium { public const string ProspectorBlockName = "necroCinerariumProspector"; public const string ProspectorSkullBlockName = "necroCinerariumProspectorSkull"; public static bool IsProspectorBlock(Block _b) { string n = _b?.GetBlockName(); return n == ProspectorBlockName || n == ProspectorSkullBlockName; } bool _prospector; public bool IsProspector { get { _ = IsLogist; // узнаёт тип блока return _prospector; } } /// Что добывает старатель: предмет, добыча за игровые сутки, выход с блока руды в игре (0 - находок нет). public static readonly (string item, int perDay, int perBlock)[] Ores = { ("resourceRockSmall", 30000, 0), ("resourceClayLump", 30000, 0), ("resourceCrushedSand", 30000, 0), ("resourceWood", 30000, 0), ("resourceScrapIron", 18000, 105), ("resourceScrapLead", 18000, 50), ("resourceCoal", 18000, 50), ("resourcePotassiumNitratePowder", 18000, 50), ("resourceOilShale", 18000, 50), }; /// Находки с блока руды - как в blocks.xml игры (тег artOfMiningLuckyStrikeHarvest). public static readonly (string item, float prob)[] Finds = { ("resourceRawDiamond", 0.005f), ("resourceSilverNugget", 0.01f), ("resourceGoldNugget", 0.007f), }; /// Выключатель (консоль necroprospector off): духи остаются дома, ходок нет. public static bool ProspectorOn = true; /// Множитель добычи за ходку (консоль, для проверки баланса). public static float ProspectorYield = 1f; public static float ProspectorSpread = 0.2f; /// На сколько блоков дух уходит вниз. public static int ProspectorDepth = 5; /// Ходка - игровой час (1000 единиц worldTime). public const ulong TripTicks = 1000UL; /// necroprospector trip - номер «внеочередной ходки»: урна, ещё не видевшая его, ходит сразу. public static int ForceTrip; public enum ProspectorStatus { NoSkull, Tide, Off, NoSample, NoChest, Full, Digging } bool _under; ulong _tripAt; float _nextProspect; float _deliverAt; int _forceSeen; readonly List _carry = new List(); public static int OreIndex(ItemValue _iv) { string n = _iv?.ItemClass?.GetItemName(); if (n == null) { return -1; } for (int i = 0; i < Ores.Length; i++) { if (Ores[i].item == n) { return i; } } return -1; } public static bool IsOreSample(ItemStack _s) => _s != null && !_s.IsEmpty() && OreIndex(_s.itemValue) >= 0; /// Сколько игровых минут до конца ходки (для консоли), -1 - дух не под землёй. public int TripMinutesLeft(World _w) { if (!_under || _w == null) { return -1; } return _tripAt > _w.worldTime ? (int)((_tripAt - _w.worldTime) * 60UL / TripTicks) : 0; } public int CarryCount { get { int n = 0; foreach (ItemStack s in _carry) { n += s.count; } return n; } } public ProspectorStatus ProspectorNow(World _world) { if (!HasSkull) { return ProspectorStatus.NoSkull; } if (CinerariumLoad.Tide) { return ProspectorStatus.Tide; } if (!ProspectorOn) { return ProspectorStatus.Off; } if (!IsOreSample(Sample)) { return ProspectorStatus.NoSample; } if (!HasThreads) { return ProspectorStatus.NoChest; } return Stuck ? ProspectorStatus.Full : ProspectorStatus.Digging; } string ProspectorStateText(World _world) { string key = ProspectorNow(_world) switch { ProspectorStatus.Tide => "necroZoneUrnTide", ProspectorStatus.NoSample => "necroProspectorNoSample", ProspectorStatus.NoChest => "necroProspectorNoChest", ProspectorStatus.Full => "necroZoneUrnFull", ProspectorStatus.Digging => "necroProspectorDigging", _ => null }; return key != null ? ", " + Localization.Get(key) : ""; } /// Работа старателя (сервер, раз в секунду): донести добычу, уйти под землю, ходка по часам игры. void ProspectorWork(World _world) { if (Time.time < _nextProspect) { return; } _nextProspect = Time.time + 1f; CinerariumGenieMotion motion = _genie != null ? _genie.GetComponent() : null; ulong now = _world.worldTime; if (_carry.Count > 0) { if (Time.time < _deliverAt) { return; // дух ещё поднимается } Deliver(_world); bool full = _carry.Count > 0; if (full != Stuck) { Stuck = full; Debug.Log("[NecromancerTome] Cinerarium: старатель " + ToWorldPos() + (full ? " - ящик полон, дух ждёт с добычей" : " - добыча в ящике")); } motion?.SetStuck(full); if (full) { return; } } ProspectorStatus st = ProspectorNow(_world); if (st != ProspectorStatus.Digging) { if (_under) { _under = false; motion?.Rise(); Debug.Log("[NecromancerTome] Cinerarium: дух старателя " + ToWorldPos() + " вернулся без добычи - " + st); } return; } if (!_under) { _under = true; if (_tripAt <= now) { _tripAt = now + TripTicks; } _forceSeen = ForceTrip; motion?.Dive(DigStand(_world) - Origin.position, ProspectorDepth); Debug.Log("[NecromancerTome] Cinerarium: дух старателя " + ToWorldPos() + " ушёл под землю за " + Sample.itemValue.ItemClass?.GetItemName()); return; } if (motion != null && !motion.Digging) { motion.Bury(DigStand(_world) - Origin.position, ProspectorDepth); // дух появился заново (чанк, прилив) - сразу под землёй } bool forced = _forceSeen != ForceTrip; if (now < _tripAt && !forced) { return; } _forceSeen = ForceTrip; if (!forced && now > _tripAt + TripTicks) { _tripAt = now + TripTicks; // время перескочило - ходка не засчитана Debug.Log("[NecromancerTome] Cinerarium: у старателя " + ToWorldPos() + " время перескочило - ходка заново"); return; } DigTrip(OreIndex(Sample.itemValue)); _tripAt = forced ? now + TripTicks : _tripAt + TripTicks; _under = false; // поднимается; донесёт - уйдёт снова _deliverAt = Time.time + (motion != null ? motion.Rise() : 0f); } /// Добыча одной ходки - в руки духа (_carry), стопками не больше игровых. void DigTrip(int _ore) { if (_ore < 0) { return; } (string item, int perDay, int perBlock) o = Ores[_ore]; float mean = o.perDay / 24f * ProspectorYield; int n = Mathf.Max(1, Mathf.RoundToInt(mean * Random.Range(1f - ProspectorSpread, 1f + ProspectorSpread))); Carry(o.item, n); string log = o.item + " x" + n; if (o.perBlock > 0) { int blocks = Mathf.Max(1, Mathf.RoundToInt(n / (float)o.perBlock)); foreach ((string item, float prob) f in Finds) { int k = 0; for (int b = 0; b < blocks; b++) { if (Random.value < f.prob) { k++; } } if (k > 0) { Carry(f.item, k); log += ", " + f.item + " x" + k; } } log += " (блоков руды " + blocks + ")"; } Debug.Log("[NecromancerTome] Cinerarium: старатель " + ToWorldPos() + " - ходка: " + log); } void Carry(string _item, int _n) { ItemValue iv = ItemClass.GetItem(_item); if (iv == null || iv.type <= 0) { Debug.LogWarning("[NecromancerTome] Cinerarium: старатель - нет предмета " + _item); return; } int max = Mathf.Max(1, iv.ItemClass.Stacknumber.Value); while (_n > 0) { int k = Mathf.Min(_n, max); _carry.Add(new ItemStack(iv.Clone(), k)); _n -= k; } } /// Добычу - в ящики по нитям; что не влезло, остаётся у духа. void Deliver(World _world) { List boxes = new List(); void Add(Vector3i _p) { TEFeatureStorage s = CinerariumWork.BoxAt(_world, _p); if (s != null && !boxes.Contains(s)) { boxes.Add(s); } } if (HasThreadTo) { Add(ThreadTo); } foreach (Vector3i p in ThreadOut) { Add(p); } foreach (Vector3i p in ThreadFrom) { Add(p); } if (boxes.Count == 0) { return; // ящик открыт игроком или нитей нет - подождать } for (int i = _carry.Count - 1; i >= 0; i--) { ItemStack s = _carry[i]; CinerariumWork.Place(s, boxes); if (s.count <= 0) { _carry.RemoveAt(i); } } } /// Куда дух спускается (20.10: «отлетает от урны на ближайший свободный блок, опускается на 5 блоков вниз»): из /// восьми соседних клеток - первая свободная (воздух или растение), сначала по сторонам; нет - над урной. Низ клетки, мир. Vector3 DigStand(World _world) { Vector3i urn = ToWorldPos(); Vector3i[] dirs = { Vector3i.forward, Vector3i.right, Vector3i.back, Vector3i.left, new Vector3i(1, 0, 1), new Vector3i(1, 0, -1), new Vector3i(-1, 0, -1), new Vector3i(-1, 0, 1) }; foreach (Vector3i d in dirs) { BlockValue bv = _world.GetBlock(urn + d); if (bv.isair || bv.Block is BlockPlant) { return World.blockToTransformPos(urn + d); } } return World.blockToTransformPos(urn + Vector3i.up); } /// Урну убрали или выгрузили - добыча в руках духа пропадает. void ProspectorReset() { if (_carry.Count > 0) { Debug.Log("[NecromancerTome] Cinerarium: старатель " + ToWorldPos() + " - недонесённая добыча пропала (" + CarryCount + ")"); } _carry.Clear(); _under = false; } } /// Спуск духа старателя под землю и подъём (20.10: «опускается на 5 блоков вниз, постепенно становясь полностью /// прозрачным; обратно - так же»). Яркость - MaterialPropertyBlock на рендерерах духа (материалы общие не трогаются): дух /// светится сложением, поэтому «прозрачнее» = тусклее цвет и свечение; глаза гаснут на половине. public partial class CinerariumGenieMotion { /// 0 - нет; 1 - летит к месту и спускается; 2 - под землёй (не виден); 3 - поднимается и летит домой. int _digMode; float _digT; Vector3 _digFrom, _digHover; float _digDepth; public static float SinkSeconds = 2.5f; List _fadeRenderers; static readonly MaterialPropertyBlock _fadeBlock = new MaterialPropertyBlock(); static readonly int ColorId = Shader.PropertyToID("_Color"); static readonly int EmissionId = Shader.PropertyToID("_EmissionColor"); float _fade = 1f; /// Под землёй или на пути туда/обратно. public bool Digging => _digMode != 0; bool DigHidden => _digMode == 2; /// Лететь к клетке _stand (низ клетки, координаты сцены) и уйти на _depth блоков вниз. public void Dive(Vector3 _stand, int _depth) { if (transform.parent == null) { return; } _t = -1f; Gesture(0f); _digFrom = transform.localPosition; _digHover = DigHover(_stand); _digDepth = _depth; _digMode = 1; _digT = 0f; } /// Сразу под землёй (дух появился заново, а работа идёт). public void Bury(Vector3 _stand, int _depth) { if (transform.parent == null) { return; } _digHover = DigHover(_stand); _digDepth = _depth; _digMode = 2; transform.localPosition = _digHover + Vector3.down * _digDepth; } /// Подняться и вернуться к урне; через сколько секунд будет дома. public float Rise() { if (_digMode == 0) { return 0f; } _digMode = 3; _digT = 0f; return SinkSeconds + Mathf.Max(BackSeconds, Vector3.Distance(_digHover, Home()) / FlySpeed); } Vector3 DigHover(Vector3 _stand) { return transform.parent.InverseTransformPoint(_stand) + new Vector3(0f, CinerariumGenie.OffsetY + Drop + 0.15f, 0f); } /// Шаг спуска/подъёма; false - дух не копает, обычное поведение. bool DigUpdate(bool _asleep) { if (_digMode == 0) { return false; } if (_asleep) { // Уснул - без полёта: спуск сразу закончен, подъём - сразу дома. if (_digMode == 1) { _digMode = 2; } else if (_digMode == 3) { _digMode = 0; SetFade(1f); transform.localPosition = Home(); return false; } return true; } _digT += Time.deltaTime; Vector3 bottom = _digHover + Vector3.down * _digDepth; float fly = Mathf.Max(OutSeconds, Vector3.Distance(_digFrom, _digHover) / FlySpeed); if (_digMode == 1) { if (_digT < fly) { transform.localPosition = Vector3.Lerp(_digFrom, _digHover, Mathf.SmoothStep(0f, 1f, _digT / fly)); Face(_digHover - _digFrom); SetFade(1f); } else if (_digT < fly + SinkSeconds) { float s = (_digT - fly) / SinkSeconds; transform.localPosition = Vector3.Lerp(_digHover, bottom, Mathf.SmoothStep(0f, 1f, s)); SetFade(1f - s); } else { _digMode = 2; transform.localPosition = bottom; } } else if (_digMode == 2) { transform.localPosition = bottom; } else { Vector3 home = Home(); float back = Mathf.Max(BackSeconds, Vector3.Distance(_digHover, home) / FlySpeed); if (_digT < SinkSeconds) { float s = _digT / SinkSeconds; transform.localPosition = Vector3.Lerp(bottom, _digHover, Mathf.SmoothStep(0f, 1f, s)); SetFade(s); } else if (_digT < SinkSeconds + back) { SetFade(1f); transform.localPosition = Vector3.Lerp(_digHover, home, Mathf.SmoothStep(0f, 1f, (_digT - SinkSeconds) / back)); Face(home - _digHover); } else { _digMode = 0; SetFade(1f); return false; } } transform.localRotation = Quaternion.Euler(0f, _yaw, 0f); return true; } /// Яркость духа долей _k (1 - как обычно, свойства сняты). void SetFade(float _k) { _k = Mathf.Clamp01(_k); if (Mathf.Abs(_k - _fade) < 0.01f && (_k < 1f || _fade >= 1f)) { return; } _fade = _k; if (_fadeRenderers == null) { _fadeRenderers = new List(GetComponentsInChildren(true)); } foreach (Renderer r in _fadeRenderers) { if (r == null) { continue; } if (r.name.Contains("eye")) { r.forceRenderingOff = _k < 0.5f; continue; } if (_k >= 1f) { r.SetPropertyBlock(null); continue; } Material m = r.sharedMaterial; if (m == null) { continue; } r.GetPropertyBlock(_fadeBlock); if (m.HasProperty(ColorId)) { _fadeBlock.SetColor(ColorId, m.GetColor(ColorId) * _k); } if (m.HasProperty(EmissionId)) { _fadeBlock.SetColor(EmissionId, m.GetColor(EmissionId) * _k); } r.SetPropertyBlock(_fadeBlock); } } } /// Консоль старателя: выключатель, внеочередная ходка, множитель добычи, состояние урн. [Preserve] public class ConsoleCmdNecroProspector : ConsoleCmdAbstract { public override bool IsExecuteOnClient => true; public override bool AllowedInMainMenu => false; public override string[] getCommands() { return new string[] { "necroprospector" }; } public override string getDescription() { return "Necromancer's Tome: Prospector Cinerarium - switch, extra trip, yield multiplier, status."; } public override string getHelp() { return "necroprospector - status of every loaded prospector urn\n" + "necroprospector on|off - off: spirits stay home, no trips\n" + "necroprospector trip - every digging spirit comes back with a trip right now\n" + "necroprospector yield - trip size multiplier (1 = as designed)\n" + "necroprospector spread - random spread of a trip (0.2 = ±20%)\n" + "necroprospector depth - how many blocks the spirit sinks"; } public override void Execute(List _params, CommandSenderInfo _senderInfo) { SdtdConsole con = SingletonMonoBehaviour.Instance; World w = GameManager.Instance?.World; if (_params.Count >= 1) { string cmd = _params[0].ToLowerInvariant(); float v = 0f; bool num = _params.Count >= 2 && float.TryParse(_params[1], NumberStyles.Float, CultureInfo.InvariantCulture, out v); switch (cmd) { case "on": case "off": TEFeatureCinerarium.ProspectorOn = cmd == "on"; break; case "trip": TEFeatureCinerarium.ForceTrip++; break; case "yield" when num: TEFeatureCinerarium.ProspectorYield = Mathf.Max(0f, v); break; case "spread" when num: TEFeatureCinerarium.ProspectorSpread = Mathf.Clamp01(v); break; case "depth" when num: TEFeatureCinerarium.ProspectorDepth = Mathf.Clamp((int)v, 1, 30); break; default: con.Output("necroprospector: unknown '" + _params[0] + "' - see 'help necroprospector'"); return; } } con.Output("necroprospector: " + (TEFeatureCinerarium.ProspectorOn ? "on" : "OFF") + ", yield x" + TEFeatureCinerarium.ProspectorYield.ToString("F2", CultureInfo.InvariantCulture) + ", spread " + TEFeatureCinerarium.ProspectorSpread.ToString("F2", CultureInfo.InvariantCulture) + ", depth " + TEFeatureCinerarium.ProspectorDepth); if (w == null) { return; } foreach (Vector3i p in CinerariumRegistry.All()) { TEFeatureCinerarium u = CinerariumThreads.UrnAt(w, p); if (u == null || !u.IsProspector) { continue; } int left = u.TripMinutesLeft(w); con.Output(" " + p + ": " + u.ProspectorNow(w) + ", sample " + (u.Sample == null || u.Sample.IsEmpty() ? "-" : u.Sample.itemValue.ItemClass?.GetItemName()) + (left >= 0 ? ", trip in " + left + " game min" : "") + (u.CarryCount > 0 ? ", carrying " + u.CarryCount : "")); } } } }