Files
necromants-tome-7d2d-3-2/HarmonySrc/CinerariumProspector.cs
T
AlexCubeandClaude Opus 5.5 0999fd3afc Цинерарий-старатель (#64): дух добывает ископаемые
Урна necroCinerariumProspector (и ...Skull): в «Содержимом» череп и образец - один из девяти ресурсов (камень, глина,
песок, древесина, железо, свинец, уголь, селитра, сланец), нить к ящику. Дух отлетает на соседнюю свободную клетку и
уходит на 5 блоков под землю, растворяясь; раз в игровой час поднимается так же, кладёт добычу в ящик и уходит снова.
За сутки: камень/глина/песок/древесина 30000, руды 18000, ходка ±20%. Из руды - шанс на алмаз, серебро, золото по
шансам игры на каждый «блок» руды. Ящик полон - дух ждёт с добычей. Без зоны и вне лимита урн.

Рецепт - кузня: латунь 40, прах 100, кости 10, кровь/сок, железная кирка; открывается с остальными урнами.
Консоль necroprospector: on/off, trip, yield, spread, depth, состояние. Тексты - 13 языков; глава книги
«Цинерарии» - лист Fig. VIII с шестой урной и фраза про prospektisto.

Проверено в игре (test8): ходка железа 625 в ящике, спуск и подъём духа, лист и страница книги помещаются.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:09:12 +03:00

651 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Generic;
using System.Globalization;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Цинерарий-старатель (#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) - ходка не засчитана, отсчёт заново. Ящик полон или
/// открыт игроком - дух ждёт дома с добычей (почернел, если полон) и докладывает, как появится место; новой ходки нет.
/// Добыча в руках духа не сохраняется: урну подняли или чанк выгрузился - ходка пропала.
/// </summary>
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;
}
}
/// <summary>Что добывает старатель: предмет, добыча за игровые сутки, выход с блока руды в игре (0 - находок нет).</summary>
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),
};
/// <summary>Находки с блока руды - как в blocks.xml игры (тег artOfMiningLuckyStrikeHarvest).</summary>
public static readonly (string item, float prob)[] Finds =
{
("resourceRawDiamond", 0.005f),
("resourceSilverNugget", 0.01f),
("resourceGoldNugget", 0.007f),
};
/// <summary>Выключатель (консоль necroprospector off): духи остаются дома, ходок нет.</summary>
public static bool ProspectorOn = true;
/// <summary>Множитель добычи за ходку (консоль, для проверки баланса).</summary>
public static float ProspectorYield = 1f;
public static float ProspectorSpread = 0.2f;
/// <summary>На сколько блоков дух уходит вниз.</summary>
public static int ProspectorDepth = 5;
/// <summary>Ходка - игровой час (1000 единиц worldTime).</summary>
public const ulong TripTicks = 1000UL;
/// <summary>necroprospector trip - номер «внеочередной ходки»: урна, ещё не видевшая его, ходит сразу.</summary>
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<ItemStack> _carry = new List<ItemStack>();
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;
/// <summary>Сколько игровых минут до конца ходки (для консоли), -1 - дух не под землёй.</summary>
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) : "";
}
/// <summary>Работа старателя (сервер, раз в секунду): донести добычу, уйти под землю, ходка по часам игры.</summary>
void ProspectorWork(World _world)
{
if (Time.time < _nextProspect)
{
return;
}
_nextProspect = Time.time + 1f;
CinerariumGenieMotion motion = _genie != null ? _genie.GetComponent<CinerariumGenieMotion>() : 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);
}
/// <summary>Добыча одной ходки - в руки духа (_carry), стопками не больше игровых.</summary>
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;
}
}
/// <summary>Добычу - в ящики по нитям; что не влезло, остаётся у духа.</summary>
void Deliver(World _world)
{
List<TEFeatureStorage> boxes = new List<TEFeatureStorage>();
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);
}
}
}
/// <summary>Куда дух спускается (20.10: «отлетает от урны на ближайший свободный блок, опускается на 5 блоков вниз»): из
/// восьми соседних клеток - первая свободная (воздух или растение), сначала по сторонам; нет - над урной. Низ клетки, мир.</summary>
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);
}
/// <summary>Урну убрали или выгрузили - добыча в руках духа пропадает.</summary>
void ProspectorReset()
{
if (_carry.Count > 0)
{
Debug.Log("[NecromancerTome] Cinerarium: старатель " + ToWorldPos() + " - недонесённая добыча пропала (" + CarryCount + ")");
}
_carry.Clear();
_under = false;
}
}
/// <summary>Спуск духа старателя под землю и подъём (20.10: «опускается на 5 блоков вниз, постепенно становясь полностью
/// прозрачным; обратно - так же»). Яркость - MaterialPropertyBlock на рендерерах духа (материалы общие не трогаются): дух
/// светится сложением, поэтому «прозрачнее» = тусклее цвет и свечение; глаза гаснут на половине.</summary>
public partial class CinerariumGenieMotion
{
/// <summary>0 - нет; 1 - летит к месту и спускается; 2 - под землёй (не виден); 3 - поднимается и летит домой.</summary>
int _digMode;
float _digT;
Vector3 _digFrom, _digHover;
float _digDepth;
public static float SinkSeconds = 2.5f;
List<Renderer> _fadeRenderers;
static readonly MaterialPropertyBlock _fadeBlock = new MaterialPropertyBlock();
static readonly int ColorId = Shader.PropertyToID("_Color");
static readonly int EmissionId = Shader.PropertyToID("_EmissionColor");
float _fade = 1f;
/// <summary>Под землёй или на пути туда/обратно.</summary>
public bool Digging => _digMode != 0;
bool DigHidden => _digMode == 2;
/// <summary>Лететь к клетке _stand (низ клетки, координаты сцены) и уйти на _depth блоков вниз.</summary>
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;
}
/// <summary>Сразу под землёй (дух появился заново, а работа идёт).</summary>
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;
}
/// <summary>Подняться и вернуться к урне; через сколько секунд будет дома.</summary>
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);
}
/// <summary>Шаг спуска/подъёма; false - дух не копает, обычное поведение.</summary>
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;
}
/// <summary>Яркость духа долей _k (1 - как обычно, свойства сняты).</summary>
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<Renderer>(GetComponentsInChildren<Renderer>(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);
}
}
}
/// <summary>Консоль старателя: выключатель, внеочередная ходка, множитель добычи, состояние урн.</summary>
[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 <x> - trip size multiplier (1 = as designed)\n" +
"necroprospector spread <x> - random spread of a trip (0.2 = ±20%)\n" +
"necroprospector depth <n> - how many blocks the spirit sinks";
}
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
SdtdConsole con = SingletonMonoBehaviour<SdtdConsole>.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 : ""));
}
}
}
}