Files
necromants-tome-7d2d-3-2/HarmonySrc/OssuaryGlow.cs
T
AlexCubeandClaude Opus 5.5 c2610da59a Оссуарий-котёл: одна ячейка образца, хранилище 6, перенос старых ячеек, свечение, огонёк, рецепт в кузне (#66, #69)
- blocks.xml: 6 ячеек хранилища (цикл вдвое дольше - добыча прежняя);
  видно одну ячейку образца и одну топлива, остальные спрятаны (в данных
  их по-прежнему 3 и 6 - сейвы не режутся).
- Перенос: содержимое спрятанных ячеек у уже поставленных оссуариев
  уходит в хранилище (проверено на test8: уголь, камень, сферы).
- Свечение рун и окошек и сфера на крышке - пока работает (OssuaryGlow).
- Огонёк дикого духа: вылет из окошка с уходом в землю и затуханием,
  возврат с другой стороны в противоположное окошко; только пока работает.
  В игре пока не увиден - разобрать (necroossuary wisp on|off).
- Рецепт - кузня, железо вместо дерева и юкки (плавильня / лом).
- Описание на 13 языках под котёл и вольных духов.
- Консоль: necroossuary info / put (отладка ячеек).
- DLL включает и #72, #74.

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

518 lines
18 KiB
C#

using System.Collections.Generic;
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Новый оссуарий-котёл (#67, 22.10.2026): свечение модели и отладочная команда necroossuary.
/// Свечение - руны на котле, знаки на крышке, прах и кости внутри (карта _e, цвет - EmissionColor через MaterialPropertyBlock,
/// общий материал не трогается) и свет glow внутри котла (дочерний объект префаба, выключен) - пока оссуарий исправно работает
/// (22.10: «Когда оссуарий исправно работает, он должен подсвечиваться изнутри. Чтобы сразу было видно если что-то не так»;
/// «Пусть руны тоже загораются»). Сфера в держателе (sphere) видна, пока в топливе есть сфера или камень. Следит компонент
/// OssuaryVisual на модели - поэтому работает и у клиентов сервера. Выключатель - necroossuary off (модель как в префабе).
/// </summary>
public static class OssuaryGlow
{
public static bool Enabled = true;
/// <summary>Дикий дух-огонёк (#69): летает, пока оссуарий работает. Выключатель - necroossuary wisp off.</summary>
public static bool WispEnabled = true;
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<GameObject>(WispPath);
Debug.Log("[NecromancerTome] OssuaryGlow: огонёк " + (_wispPrefab != null ? "загружен" : "НЕ загружен: " + WispPath));
}
return _wispPrefab;
}
public static Color GlowColor = new Color(2.0f, 0.24f, 0.12f, 1f);
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;
}
/// <summary>true - модель найдена и переключена.</summary>
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<Renderer>() : 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;
}
/// <summary>Оссуарии (главные блоки) в кубе вокруг точки.</summary>
public static List<Vector3i> Find(World _w, Vector3i _c, int _r, int _ry)
{
var res = new List<Vector3i>();
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;
}
}
/// <summary>На модели оссуария: два раза в секунду смотрит на коллектор и переключает сферу и свечение, только при смене.
/// anyEnabled - ванильная проверка «есть рабочий выход»: учитывает топливо, заполненный выход, воду и пустые ячейки
/// модификаций (GeoOssuaryPatch.isDisabled).</summary>
public class OssuaryVisual : MonoBehaviour
{
public Vector3i Pos;
float _next;
int _state = -1;
void Update()
{
_wisp.Tick(transform, _state > 0 && (_state & 1) != 0 && OssuaryGlow.WispEnabled); // -1 - ещё не смотрели
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();
}
}
/// <summary>Дикий дух оссуария (#69; 20.10: «Хватит одного огонька. Пусть светится сам. Производительность нам важна»):
/// чистая анимация, без сущностей (22.10: «периодически из окошка в котле вылетает дух и уходит вниз, увеличивая прозрачность
/// до полной, а через какое-то время с другой стороны снизу вылетает полностью прозрачный дух, плавно уменьшая прозрачность,
/// и влетает в противоположное окошко»). Один огонёк на котёл (бандл necrowisp), только пока оссуарий исправно работает
/// и игрок ближе WispViewRange. На добычу не влияет.</summary>
public class OssuaryWisp
{
public const float WispViewRange = 60f;
/// <summary>Окошки крышки (модель #67): шесть, между швами, через 60°; по высоте - на WindowUp от низа крышки,
/// от оси - на WindowR (доли полуширины крышки; в модели 0.14 м и 0.37 м при полуширине 0.418).</summary>
const float WindowUp = 0.14f / 0.836f, WindowR = 0.37f / 0.418f;
const float Out = 0.9f; // насколько дальше от оси котла дух уходит в землю, м
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;
Vector3 _winOut, _winIn, _inOut, _inIn, _down, _up;
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;
}
float k = Mathf.Clamp01((now - _t0) / Mathf.Max(0.01f, _until - _t0));
switch (_phase)
{
case -1:
if (now < _until || !Places(_model))
{
return;
}
Show(_inOut);
Next(0, FlyTime);
return;
case 0: // из окошка наружу и вниз, в землю; тает
Fly(_inOut, _winOut, _down, k);
Alpha(1f - Ease(Mathf.InverseLerp(0.25f, 1f, k)));
if (k >= 1f)
{
Hide();
Next(1, Random.Range(12f, 20f));
}
return;
case 1: // под землёй
if (now >= _until)
{
Alpha(0f);
Show(_up);
Next(2, FlyTime);
}
return;
case 2: // с другой стороны снизу вверх, проявляется, влетает в противоположное окошко
Fly(_up, _winIn, _inIn, k);
Alpha(Ease(Mathf.InverseLerp(0f, 0.75f, 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;
}
/// <summary>Квадратичная кривая _a -> _b -> _c: проходит окошко _b по касательной.</summary>
void Fly(Vector3 _a, Vector3 _b, Vector3 _c, float _k)
{
float k = Ease(_k);
Vector3 p = (1f - k) * (1f - k) * _a + 2f * k * (1f - k) * _b + k * k * _c;
p.y += 0.02f * Mathf.Sin(Time.time * 7f); // лёгкое дрожание огонька
_go.transform.position = p;
}
/// <summary>Окошко вылета - случайное из шести, влёта - напротив; точки в земле - снаружи от них, высота земли - лучом.</summary>
bool Places(Transform _model)
{
Transform lid = _model.Find("lid");
Renderer lr = lid != null ? lid.GetComponent<Renderer>() : null;
if (lr == null)
{
return false;
}
Bounds b = lr.bounds;
Vector3 axis = new Vector3(b.center.x, b.min.y + WindowUp * (b.size.x + b.size.z) * 0.5f, b.center.z);
float half = (b.size.x + b.size.z) * 0.25f;
int w = Random.Range(0, 6);
Vector3 dOut = lid.rotation * Quaternion.Euler(0f, w * 60f, 0f) * Vector3.right;
dOut.y = 0f;
dOut.Normalize();
Vector3 dIn = -dOut;
_winOut = axis + dOut * (half * WindowR);
_winIn = axis + dIn * (half * WindowR);
_inOut = axis + dOut * (half * WindowR * 0.4f);
_inIn = axis + dIn * (half * WindowR * 0.4f);
_down = Ground(axis + dOut * (half + Out)) + Vector3.down * Under;
_up = Ground(axis + dIn * (half + Out)) + Vector3.down * Under;
return 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<TrailRenderer>();
_rs = _go.GetComponentsInChildren<Renderer>(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;
}
/// <summary>Прозрачность - цветом материала через MaterialPropertyBlock (шейдер аддитивный: тусклее = прозрачнее),
/// общий материал не трогается.</summary>
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;
}
}
}
/// <summary>Модель оссуария появилась в мире - повесить на неё OssuaryVisual.</summary>
[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<OssuaryVisual>() ?? _ebcd.transform.gameObject.AddComponent<OssuaryVisual>();
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 info x y z - slots of the ossuary (samples, fuel, storage) and whether it works\n" +
"necroossuary put x y z mod|fuel <slot 1..> <item> [count] - dev: put an item into a slot, hidden ones too";
}
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
SdtdConsole con = SingletonMonoBehaviour<SdtdConsole>.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<Vector3i> 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 "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;
ItemValue iv = ItemClass.GetItem(_params[6]);
if (i < 0 || i >= slots.Length || iv == null || iv.IsEmpty())
{
con.Output("necroossuary: нет ячейки " + (i + 1) + " или предмета " + _params[6]);
break;
}
slots[i] = new ItemStack(iv, _params.Count > 7 ? int.Parse(_params[7]) : 1);
te.SetModified();
}
string S(ItemStack[] _a)
{
var parts = new List<string>();
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 "wisp":
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;
}
}
}
}