Files
necromants-tome-7d2d-3-2/HarmonySrc/MindPalace.cs
T
AlexCubeandClaude Opus 5.5 cb2a4f19b4 Девять новых испытаний некромантии
Вселиться в зомби и в своего питомца, подорвать одержимого копа, 25 убийств
змеёй, войти в чертоги (ChallengeStatAwarded: счёт шлёт NecroChallenges.cs),
найти череп (Gather), сок чёрной розы, мод «Мёртвая буря», гримуар медведя
(Craft). Тексты намёком, 13 языков.

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

1176 lines
49 KiB
C#
Raw Permalink 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.IO;
using HarmonyLib;
using UnityEngine;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// Ментальная форма - «Чертоги разума» (решения пользователя 02-03.10, BACKLOG «Чертоги разума»).
/// Вход - третья строка меню ножа некроманта на R, только ночью, здоровье не тратит. Тело
/// падает рэгдоллом и лежит беззащитным, как в режиме змеи; дух - камера без сущности
/// (MindControl.cs): ходит по полу, видит только своё, с другими игроками не встречается.
/// Выход - R, утро или смерть тела.
///
/// ГДЕ ЧЕРТОГИ. Под землёй, в радиационном поясе у северо-восточного угла карты: сначала от
/// угла по диагонали ищется пояс (biomeProvider.GetRadiationAt &gt; 2.5); пояс шире префаба -
/// чертоги в его середине, уже (Navezgane: 16 блоков) - в 45 блоках от края. Своё место у каждого
/// игрока - вдоль северного края с шагом SlotStep (дальше дальности показа сущностей). Места
/// пишутся в файл в папке сохранения мира, чтобы смена ширины пояса в новой версии игры их не
/// сдвинула.
///
/// КАК СТАВЯТСЯ. Префаб chertogi (PalacePrefab/, нарисован пользователем в редакторе игры)
/// грузится Prefab.Load и ставится поблочно через World.SetBlocksRPC - НЕ Prefab.CopyIntoLocal:
/// тот перед постановкой вычищает весь столб над префабом до неба от не-земляных блоков,
/// и над чертогами вырубило бы деревья (видно сверху). Заодно: внешний слой оболочки
/// (woodShapes:cube) -&gt; terrBedrock (в меню творчества его нет, рисовали деревом),
/// одиночная книга на столе -&gt; necroMemoryBook, на стол с реактивами - нож-декорация,
/// кровать (SleepingBag) -&gt; necroPalaceBed: иначе игра делает её точкой возрождения игрока.
/// Построены ли - сверка с префабом каждой клетки (Mismatches: тип, плотность, покраска) при каждом входе:
/// расхождение - ставим заново (самопочинка; заодно новый, более широкий префаб сам перестраивает старые
/// чертоги в сейвах). Надписи на досках из префаба НЕ переносятся (указание 09.10: «текст будет добавлять мод по событиям»).
///
/// ОХРАНА. Внутри чертогов игра считает место «зоной торговца» (патч IsWithinTraderArea):
/// блоки не ломаются ни ударом, ни взрывом, строить нельзя. Чужие сущности в коробке чертогов
/// раз в секунду выбрасываются: змея развеивается, игрок - на поверхность, прочие - удаляются.
///
/// ОГРАНИЧЕНИЕ: одиночная игра и хост. Клиенту в мультиплеере вход пока не даётся - чертоги ставит
/// сервер, а камера живёт у клиента (решать отдельно).
/// </summary>
public static class MindPalace
{
public const string PrefabName = "chertogi";
public const string BookBlock = "necroMemoryBook";
public const string KnifeBlock = "necroKnifeDecor";
/// <summary>Кровать без класса SleepingBag - ею заменяется кровать префаба (bunkBedMadeWhite).</summary>
public const string BedBlock = "necroPalaceBed";
public const string ShellFrom = "woodShapes:cube";
public const string ShellTo = "terrBedrock";
/// <summary>Межкомнатные двери - их чертоги открывают сами при входе (кроме входной и дверей с номером).</summary>
public const string PassageDoor = "exteriorHouseDoorWhite";
/// <summary>Табличка с номером двери (указание 09.10): ставится в редакторе рядом с дверью или над ней,
/// в ней пишется номер (до двух цифр). В чертоги не ставится (вместо неё воздух) - видна только в редакторе.
/// Дверь с номером закрыта, пока событие не откроет её (SetDoorOpen).</summary>
public const string DoorTagBlock = "necroDoorNumber";
/// <summary>Двери с номером, открытые с самого начала (указание 09.10): 2 туалет, 4 ванная, 5 прихожая,
/// 6 лаборатория. Остальные закрыты до события: 1 входная - бутафорская, не открывается никогда; 3 лестница,
/// 7 шкаф-тайник, 8 за ним, 9 решётка, 10-11 сейфовые, 12, 14-17 второй этаж.</summary>
public static readonly HashSet<int> DefaultOpenDoors = new HashSet<int> { 2, 4, 5, 6 };
/// <summary>Как далеко от таблички искать её дверь (клеток по каждой оси; над дверью - 2).</summary>
public const int DoorTagReach = 2;
/// <summary>Одиночная книга на обеденном столе (cntBookPile09 в префабе).</summary>
public static readonly Vector3i BookAt = new Vector3i(12, 2, 9);
/// <summary>Место ножа на столе с реактивами (указание пользователя).</summary>
public static readonly Vector3i KnifeAt = new Vector3i(4, 2, 11);
/// <summary>Входная дверь чертогов - во внутреннюю оболочку, никуда не ведёт: остаётся закрытой
/// (указание 03.10). Остальные четыре - между комнатами - открываются.</summary>
public static readonly Vector3i EntranceDoorAt = new Vector3i(8, 1, 3);
/// <summary>Где появляется дух: комната с книгой, свободная клетка у стола.</summary>
public static readonly Vector3 SpawnAt = new Vector3(11.5f, 1.05f, 11.5f);
public const float SpawnYaw = 180f;
public const int SlotStep = 300;
public const int FallbackInset = 45;
public const int FloorY = 3;
/// <summary>Рамка вокруг чертогов, которая заливается камнем перед постановкой.</summary>
public const int LooseMargin = 3;
public const float RadiationThreshold = 2.5f;
/// <summary>Поворот ножа-декорации (0-23): 9 - плашмя на столешнице (подобрано пользователем
/// 03.10 командой necropalace knife N).</summary>
public static int KnifeRotation = 0;
public static bool Active;
static Vector3i _origin;
static Prefab _prefab;
static ChunkManager.ChunkObserver _observer;
enum Stage { None, Loading, Settling, Ready }
static Stage _stage;
static float _deadline;
static float _settleUntil;
static float _ragdollAt;
static bool _waitStandUp;
static float _standUpDeadline;
static float _guardCheck;
static readonly Dictionary<string, Vector3i> _slots = new Dictionary<string, Vector3i>();
static string _slotsFor;
public static void Init()
{
ModEvents.UnityUpdate.RegisterHandler(OnUnityUpdate);
MindHud.Create();
}
// ---------------------------------------------------------------- места
static string SlotsFile => Path.Combine(GameIO.GetSaveGameDir(), "NecromancerTome_palaces.txt");
static void LoadSlots()
{
string dir = GameIO.GetSaveGameDir();
if (_slotsFor == dir)
{
return;
}
_slotsFor = dir;
_slots.Clear();
try
{
if (File.Exists(SlotsFile))
{
foreach (string line in File.ReadAllLines(SlotsFile))
{
string[] p = line.Split(' ');
if (p.Length == 4 && int.TryParse(p[1], out int x) && int.TryParse(p[2], out int y) && int.TryParse(p[3], out int z))
{
_slots[p[0]] = new Vector3i(x, y, z);
}
}
}
}
catch (System.Exception ex)
{
Debug.LogWarning("[NecromancerTome] MindPalace: не прочитать места - " + ex.Message);
}
}
static void SaveSlots()
{
try
{
var lines = new List<string>();
foreach (var kv in _slots)
{
lines.Add(kv.Key + " " + kv.Value.x + " " + kv.Value.y + " " + kv.Value.z);
}
File.WriteAllLines(SlotsFile, lines);
}
catch (System.Exception ex)
{
Debug.LogWarning("[NecromancerTome] MindPalace: не записать места - " + ex.Message);
}
}
static string PlayerKey(EntityPlayer _player)
{
string id = GameManager.Instance.persistentLocalPlayer?.PrimaryId?.CombinedString;
return string.IsNullOrEmpty(id) ? "local" : id.Replace(' ', '_');
}
static Vector3i GetSlot(World _world, EntityPlayer _player)
{
LoadSlots();
string key = PlayerKey(_player);
if (_slots.TryGetValue(key, out Vector3i o))
{
return o;
}
o = ComputeSlot(_world, _slots.Count);
_slots[key] = o;
SaveSlots();
Debug.Log("[NecromancerTome] MindPalace: место " + _slots.Count + " для " + key + " - " + o);
return o;
}
/// <summary>Отступ от края: середина радиационного пояса, если он шире префаба, иначе 45.</summary>
static int Inset(World _world, Vector3i _min, Vector3i _max, int _size)
{
IBiomeProvider biomes = _world.ChunkCache?.ChunkProvider?.GetBiomeProvider();
int start = -1, end = -1;
if (biomes != null)
{
for (int t = 0; t < 600; t += 4)
{
bool rad = biomes.GetRadiationAt(_max.x - t, _max.z - t) > RadiationThreshold;
if (rad && start < 0) start = t;
if (!rad && start >= 0) { end = t; break; }
}
}
if (start >= 0 && end > start && end - start >= _size + 20)
{
return Mathf.Max(FallbackInset, (start + end) / 2 - _size / 2);
}
return FallbackInset;
}
static Vector3i ComputeSlot(World _world, int _index)
{
_world.GetWorldExtent(out Vector3i min, out Vector3i max);
Prefab p = LoadPrefab();
int size = p != null ? Mathf.Max(p.size.x, p.size.z) : 17;
int inset = Inset(_world, min, max, size);
int perEdge = Mathf.Max(1, (max.x - min.x - 2 * inset - size) / SlotStep);
int x, z;
if (_index < perEdge)
{
x = max.x - inset - size - _index * SlotStep;
z = max.z - inset - size;
}
else
{
x = max.x - inset - size;
z = max.z - inset - size - (_index - perEdge + 1) * SlotStep;
}
x = Mathf.FloorToInt(x / 16f) * 16;
z = Mathf.FloorToInt(z / 16f) * 16;
return new Vector3i(x, FloorY, z);
}
// ---------------------------------------------------------------- префаб и постройка
static string ModDir => Path.GetDirectoryName(typeof(MindPalace).Assembly.Location);
static Prefab LoadPrefab()
{
if (_prefab != null)
{
return _prefab;
}
string path = Path.Combine(ModDir, "PalacePrefab", PrefabName + ".tts");
var p = new Prefab();
if (!p.Load(new PathAbstractions.AbstractedLocation(PathAbstractions.EAbstractedLocationType.Mods, PrefabName, path, null, false)))
{
Debug.LogError("[NecromancerTome] MindPalace: префаб не загрузился - " + path);
return null;
}
_prefab = p;
Debug.Log("[NecromancerTome] MindPalace: префаб " + PrefabName + " " + p.size);
return _prefab;
}
static bool ChunksLoaded(World _world, Vector3i _o, Vector3i _size)
{
for (int cx = World.toChunkXZ(_o.x); cx <= World.toChunkXZ(_o.x + _size.x - 1); cx++)
{
for (int cz = World.toChunkXZ(_o.z); cz <= World.toChunkXZ(_o.z + _size.z - 1); cz++)
{
// Не просто «есть в памяти», а сгенерирован и украшен: иначе генерация досыпала
// землю поверх поставленного (тест 03.10: «внутри блоки камня»).
Chunk c = _world.GetChunkSync(cx, cz) as Chunk;
if (c == null || !c.IsInitialized || c.NeedsDecoration)
{
return false;
}
}
}
return true;
}
/// <summary>Стоит ли блок уже в самом префабе (03.10: пользователь расставляет книгу и нож в редакторе
/// сам) - тогда старые принудительные замены (книга в BookAt, нож в KnifeAt) не делаем.</summary>
static bool PrefabHas(Prefab _p, string _block)
{
if (_hasCache == null || _hasCacheFor != _p)
{
_hasCache = new Dictionary<string, bool>();
_hasCacheFor = _p;
}
if (_hasCache.TryGetValue(_block, out bool has))
{
return has;
}
int type = Block.GetBlockValue(_block).type;
for (int y = 0; y < _p.size.y && !has; y++)
for (int z = 0; z < _p.size.z && !has; z++)
for (int x = 0; x < _p.size.x && !has; x++)
{
has = _p.GetBlock(x, y, z).type == type;
}
_hasCache[_block] = has;
return has;
}
static Dictionary<string, bool> _hasCache;
static Prefab _hasCacheFor;
/// <summary>Что должно стоять в клетке префаба после постановки (с заменами). False - клетку
/// не трогаем (дочерняя часть многоблочного предмета).</summary>
static bool Expected(Prefab _p, int _x, int _y, int _z, out BlockValue _bv, out sbyte _d)
{
_bv = _p.GetBlock(_x, _y, _z);
_d = _p.GetDensity(_x, _y, _z);
if (_bv.ischild)
{
return false;
}
Vector3i lp = new Vector3i(_x, _y, _z);
// Пол (y 0) - НЕ bedrock: в префабе он же пол комнат, а bedrock - блок-земля, её сглаженная
// поверхность выпирала из пола в комнаты каменными наплывами (тест 03.10: «в помещении
// блоки камня»). Защита снизу - отдельный слой bedrock под префабом (FloorShield).
bool outer = _x == 0 || _z == 0 || _x == _p.size.x - 1 || _z == _p.size.z - 1 || _y == _p.size.y - 1;
if (outer && !_bv.isair && _bv.Block.GetBlockName() == ShellFrom)
{
_bv = Block.GetBlockValue(ShellTo);
_d = MarchingCubes.DensityTerrain;
}
else if (lp == BookAt && !PrefabHas(_p, BookBlock))
{
BlockValue b = Block.GetBlockValue(BookBlock);
b.rotation = _bv.rotation;
_bv = b;
_d = MarchingCubes.DensityAir;
}
else if (lp == KnifeAt && !PrefabHas(_p, KnifeBlock))
{
BlockValue k = Block.GetBlockValue(KnifeBlock);
k.rotation = (byte)KnifeRotation;
_bv = k;
_d = MarchingCubes.DensityAir;
}
else if (!_bv.isair && _bv.Block is BlockSleepingBag)
{
// Кровать префаба - просто мебель (тест 03.10: синий камень портала отправил в чертоги).
// Чертоги ставятся SetBlocksRPC без игрока, а GameManager.ChangeBlocks тогда считает
// ставившим локального игрока и любую SleepingBag-кровать делает его точкой возрождения.
BlockValue bed = Block.GetBlockValue(BedBlock);
bed.rotation = _bv.rotation;
_bv = bed;
_d = MarchingCubes.DensityAir;
}
else if (!_bv.isair && _bv.Block.GetBlockName() == DoorTagBlock)
{
// Табличка номера двери - только для редактора.
_bv = BlockValue.Air;
_d = MarchingCubes.DensityAir;
}
else if (_bv.isair || !_bv.Block.shape.IsTerrain())
{
_d = MarchingCubes.DensityAir;
}
return true;
}
/// <summary>Диагностика (03.10, «нож пропал со стола»): что стоит в клетке и есть ли у блока модель.</summary>
static void LogCell(World _world, Vector3i _wp, string _what)
{
BlockValue bv = _world.GetBlock(_wp);
string s = "[NecromancerTome] MindPalace: " + _what + " " + _wp + " - " + bv.Block.GetBlockName() + " пов. " + bv.rotation;
Chunk c = _world.GetChunkFromWorldPos(_wp) as Chunk;
BlockEntityData be = c?.GetBlockEntity(_wp);
if (be == null)
{
s += ", модели нет (нет BlockEntity)";
}
else if (!be.bHasTransform || be.transform == null)
{
s += ", модель ещё не создана";
}
else
{
Renderer[] rs = be.transform.GetComponentsInChildren<Renderer>(true);
int on = 0;
foreach (Renderer r in rs) if (r.enabled && r.gameObject.activeInHierarchy) on++;
s += ", модель " + be.transform.name + " в " + (be.transform.position + Origin.position) + ", активна " + be.transform.gameObject.activeInHierarchy + ", рендеров " + on + "/" + rs.Length;
}
Debug.Log(s);
}
/// <summary>Сколько клеток в мире не совпадает с префабом по типу блока (0 - чертоги целы).</summary>
public static int Mismatches(World _world, Vector3i _o, List<string> _report = null)
{
Prefab p = LoadPrefab();
if (p == null)
{
return -1;
}
int n = 0;
int shieldType = Block.GetBlockValue(ShellTo).type;
for (int z = 0; z < p.size.z; z++)
for (int x = 0; x < p.size.x; x++)
{
if (_world.GetBlock(_o + new Vector3i(x, -1, z)).type != shieldType)
{
n++;
}
}
for (int y = 0; y < p.size.y; y++)
for (int z = 0; z < p.size.z; z++)
for (int x = 0; x < p.size.x; x++)
{
Vector3i wp = _o + new Vector3i(x, y, z);
if (!Expected(p, x, y, z, out BlockValue want, out sbyte wantD))
{
// Дочерняя клетка: там не должно быть земли/камня.
BlockValue c = _world.GetBlock(wp);
if (c.Block.shape.IsTerrain() || _world.GetDensity(wp.x, wp.y, wp.z) < 0)
{
n++;
if (_report != null && _report.Count < 40)
{
_report.Add("(" + x + "," + y + "," + z + ") дочерняя клетка, стоит " + c.Block.GetBlockName() + " плотн. " + _world.GetDensity(wp.x, wp.y, wp.z));
}
}
continue;
}
BlockValue have = _world.GetBlock(wp);
// Плотность тоже: тип блока - воздух, а плотность земли - и клетка рисуется камнем
// (тест 03.10: «камень под столом, в дверном проёме, под потолком»).
sbyte haveD = _world.GetDensity(wp.x, wp.y, wp.z);
bool densityBad = (wantD < 0) != (haveD < 0);
// И покраска (указание 03.10: «покраску обязательно перенеси из префаба»).
bool paintBad = !want.isair && want.type == p.GetBlock(x, y, z).type &&
_world.GetTextureFullArray(wp.x, wp.y, wp.z) != p.GetTexture(x, y, z);
if (have.type != want.type || densityBad || paintBad)
{
n++;
if (_report != null && _report.Count < 40)
{
_report.Add("(" + x + "," + y + "," + z + ") ждали " + want.Block.GetBlockName() + " плотн. " + wantD +
", стоит " + have.Block.GetBlockName() + " плотн. " + haveD + (paintBad ? ", не та покраска" : ""));
}
}
}
return n;
}
public static bool IsBuilt(World _world, Vector3i _o)
{
return Mismatches(_world, _o) == 0;
}
/// <summary>Номера дверей из табличек префаба: локальная клетка таблички -> номер.</summary>
static Dictionary<Vector3i, int> DoorTags(Prefab _p)
{
var tags = new Dictionary<Vector3i, int>();
foreach (var kv in _p.tileEntities)
{
Vector3i lp = kv.Key;
if (_p.GetBlock(lp.x, lp.y, lp.z).Block?.GetBlockName() != DoorTagBlock ||
!(kv.Value is TileEntityComposite tec) || !tec.TryGetSelfOrFeature(out TEFeatureSignable sign))
{
continue;
}
string text = (sign.GetAuthoredText()?.Text ?? "").Trim();
if (int.TryParse(text, out int n))
{
tags[lp] = n;
}
else
{
Debug.LogWarning("[NecromancerTome] MindPalace: табличка двери " + lp + " без номера: «" + text + "»");
}
}
return tags;
}
/// <summary>Двери с номерами: номер -> локальная клетка двери (ближайшая к табличке дверь в пределах DoorTagReach).</summary>
public static Dictionary<int, Vector3i> NumberedDoors(World _world, Vector3i _o)
{
var doors = new Dictionary<int, Vector3i>();
Prefab p = LoadPrefab();
if (p == null)
{
return doors;
}
foreach (var kv in DoorTags(p))
{
Vector3i best = default;
int bestD = int.MaxValue;
for (int dy = -DoorTagReach; dy <= DoorTagReach; dy++)
for (int dz = -DoorTagReach; dz <= DoorTagReach; dz++)
for (int dx = -DoorTagReach; dx <= DoorTagReach; dx++)
{
Vector3i lp = kv.Key + new Vector3i(dx, dy, dz);
int d = dx * dx + dy * dy + dz * dz;
if (d < bestD && _world.GetTileEntity(_o + lp) is TileEntityComposite tec && tec.GetFeature<TEFeatureDoor>() != null)
{
best = lp;
bestD = d;
}
}
if (bestD == int.MaxValue)
{
Debug.LogWarning("[NecromancerTome] MindPalace: у таблички " + kv.Key + " (дверь " + kv.Value + ") рядом нет двери");
}
else if (doors.ContainsKey(kv.Value))
{
Debug.LogWarning("[NecromancerTome] MindPalace: номер двери " + kv.Value + " повторяется (" + doors[kv.Value] + " и " + best + ")");
}
else
{
doors[kv.Value] = best;
}
}
return doors;
}
/// <summary>Состояние дверей чертогов при каждом входе. Дверь с номером (табличка DoorTagBlock) открыта, только
/// если событие её открыло (SetDoorOpen). Межкомнатные двери без номера (PassageDoor) открыты (в редакторе
/// сохранились закрытыми, а дух дверей не открывает), входная - закрыта. Прочие двери без номера не трогаем.
/// Состояние двери - в её TEFeatureDoor.</summary>
public static void OpenDoors(World _world, Vector3i _o)
{
var numbered = new Dictionary<Vector3i, int>();
foreach (var kv in NumberedDoors(_world, _o))
{
numbered[kv.Value] = kv.Key;
}
HashSet<int> opened = OpenedDoors(_world.GetPrimaryPlayer());
Vector3i s = Size;
int switched = 0;
for (int y = 0; y < s.y; y++)
for (int z = 0; z < s.z; z++)
for (int x = 0; x < s.x; x++)
{
Vector3i lp = new Vector3i(x, y, z);
if (!(_world.GetTileEntity(_o + lp) is TileEntityComposite tec) || !(tec.GetFeature<TEFeatureDoor>() is TEFeatureDoor door))
{
continue;
}
bool wantOpen;
if (numbered.TryGetValue(lp, out int n))
{
// В файле: n - открыта событием, -n - закрыта событием (для открытых с начала).
wantOpen = opened.Contains(n) || DefaultOpenDoors.Contains(n) && !opened.Contains(-n);
}
else if (_world.GetBlock(_o + lp).Block.GetBlockName() == PassageDoor)
{
// Входная - закрыть (могла остаться открытой от прежних входов), остальные - открыть.
wantOpen = lp != EntranceDoorAt;
}
else
{
continue;
}
if (door.IsOpen() != wantOpen)
{
door.SetOpen(wantOpen);
switched++;
}
}
if (switched > 0)
{
Debug.Log("[NecromancerTome] MindPalace: дверей переключено " + switched + ", с номерами " + numbered.Count + ", открыты по событиям: " + string.Join(",", opened));
}
}
// Открытые событиями двери - по игрокам, в файле в папке сохранения мира: «игрок номер номер ...».
static string DoorsFile => Path.Combine(GameIO.GetSaveGameDir(), "NecromancerTome_palaceDoors.txt");
static readonly Dictionary<string, HashSet<int>> _openedDoors = new Dictionary<string, HashSet<int>>();
static string _openedFor;
static HashSet<int> OpenedDoors(EntityPlayer _player)
{
string dir = GameIO.GetSaveGameDir();
if (_openedFor != dir)
{
_openedFor = dir;
_openedDoors.Clear();
try
{
if (File.Exists(DoorsFile))
{
foreach (string line in File.ReadAllLines(DoorsFile))
{
string[] parts = line.Split(' ');
var set = new HashSet<int>();
for (int i = 1; i < parts.Length; i++)
{
if (int.TryParse(parts[i], out int n))
{
set.Add(n);
}
}
_openedDoors[parts[0]] = set;
}
}
}
catch (System.Exception ex)
{
Debug.LogWarning("[NecromancerTome] MindPalace: не прочитать двери - " + ex.Message);
}
}
string key = _player != null ? PlayerKey(_player) : "local";
if (!_openedDoors.TryGetValue(key, out HashSet<int> opened))
{
opened = new HashSet<int>();
_openedDoors[key] = opened;
}
return opened;
}
/// <summary>Открыть/закрыть дверь чертогов с номером (для событий). Запоминается в сейве; если игрок сейчас
/// в чертогах - дверь переключается сразу.</summary>
public static void SetDoorOpen(EntityPlayer _player, int _number, bool _open)
{
HashSet<int> opened = OpenedDoors(_player);
bool changed = _open ? opened.Remove(-_number) | opened.Add(_number) : opened.Remove(_number) | DefaultOpenDoors.Contains(_number) && opened.Add(-_number);
if (!changed)
{
return;
}
try
{
var lines = new List<string>();
foreach (var kv in _openedDoors)
{
lines.Add(kv.Key + (kv.Value.Count > 0 ? " " + string.Join(" ", kv.Value) : ""));
}
File.WriteAllLines(DoorsFile, lines);
}
catch (System.Exception ex)
{
Debug.LogWarning("[NecromancerTome] MindPalace: не записать двери - " + ex.Message);
}
World world = GameManager.Instance.World;
if (_stage == Stage.Ready && world != null && _player == world.GetPrimaryPlayer())
{
OpenDoors(world, _origin);
}
}
/// <summary>Надписи на досках чертогов (указание 09.10). Лор: некроманта бросили в этот мир умирать - раздели,
/// лишили артефактов (и вживлённых тоже) и ментальной магией стёрли память, «закрыв знания, опасные для общества».
/// Доска показывает информацию из подсознания, которую герой пока не помнит (указание 09.10); строки прибавляются по событиям игры. Пока событие -
/// только день: (с какого дня, ключ локализации). Остальные строки - позднее.</summary>
public static readonly (int day, string key)[] BoardLines =
{
(1, "necroPalaceBoardRevenge"),
(5, "necroPalaceBoardTide"), // было «излучение красной луны»; 10.10 - кровавый прилив
};
/// <summary>При каждом входе: на каждой доске (necroChalkboard) чертогов - все наступившие строки, на языке игрока.
/// Текст из префаба в чертоги не идёт (Stamp ставит только блоки) - пишет только мод.</summary>
public static void UpdateBoards(World _world, Vector3i _o)
{
int day = GameUtils.WorldTimeToDays(_world.worldTime);
var lines = new List<string>();
foreach (var l in BoardLines)
{
if (day >= l.day)
{
lines.Add(Localization.Get(l.key));
}
}
string text = string.Join("\n", lines);
Vector3i s = Size;
int n = 0;
for (int y = 0; y < s.y; y++)
for (int z = 0; z < s.z; z++)
for (int x = 0; x < s.x; x++)
{
Vector3i wp = _o + new Vector3i(x, y, z);
if (_world.GetBlock(wp).Block?.GetBlockName() != Chalkboard.BlockName ||
!(_world.GetTileEntity(wp) is TileEntityComposite tec) || !tec.TryGetSelfOrFeature(out TEFeatureSignable sign))
{
continue;
}
if ((sign.GetAuthoredText()?.Text ?? "") != text)
{
sign.SetText(text, true);
n++;
}
}
if (n > 0)
{
Debug.Log("[NecromancerTome] MindPalace: надпись на досках (день " + day + ", строк " + lines.Count + "), досок " + n);
}
}
public static void Stamp(World _world, Vector3i _o)
{
Prefab p = LoadPrefab();
if (p == null)
{
return;
}
// ПОРЯДОК ВАЖЕН (тест 03.10: при очистке всей коробки разом на миг открывалась пустота
// 17x8x17, земля над ней и края теряли опору - обвал, падающие блоки ушли под мир):
// 1) слой bedrock под чертогами;
// 2) оболочка - пол (y 0), стены (края по x/z) и крыша - сразу на место земли, без очистки;
// 3) очистка внутренности (её уже держит оболочка) - в том числе дочерних клеток стола,
// дверей, шкафов, где иначе оставался камень;
// 4) всё внутреннее по префабу.
var list = new List<BlockChangeInfo>();
// 0) Рамка LooseMargin блоков вокруг чертогов, со всех сторон и сверху, - сплошь камнем
// (указание 03.10: «заполни сначала камнем, на 3 блока во все стороны»): земля, песок,
// гравий (осыпаются без опоры) и пустоты пещер -> terrStone. Не земля и не воздух
// (чужие постройки) - не трогаем.
BlockValue stone = Block.GetBlockValue("terrStone");
int loose = 0;
for (int y = -1 - LooseMargin; y < p.size.y + LooseMargin; y++)
for (int z = -LooseMargin; z < p.size.z + LooseMargin; z++)
for (int x = -LooseMargin; x < p.size.x + LooseMargin; x++)
{
bool inside = y >= -1 && y < p.size.y && x >= 0 && x < p.size.x && z >= 0 && z < p.size.z;
if (inside)
{
continue;
}
Vector3i wp = _o + new Vector3i(x, y, z);
if (wp.y < 1)
{
continue; // ниже 1 игра блоки не ставит (дно мира)
}
BlockValue have = _world.GetBlock(wp);
Block b = have.Block;
if (have.type != stone.type && (have.isair || b != null && b.shape.IsTerrain()))
{
list.Add(new BlockChangeInfo(wp, stone, MarchingCubes.DensityTerrain));
loose++;
}
}
if (loose > 0)
{
Debug.Log("[NecromancerTome] MindPalace: рамка вокруг залита камнем - " + loose + " клеток");
}
BlockValue shield = Block.GetBlockValue(ShellTo);
for (int z = 0; z < p.size.z; z++)
for (int x = 0; x < p.size.x; x++)
{
list.Add(new BlockChangeInfo(_o + new Vector3i(x, -1, z), shield, MarchingCubes.DensityTerrain));
}
for (int pass = 0; pass < 3; pass++)
for (int yi = 0; yi < p.size.y; yi++)
for (int z = 0; z < p.size.z; z++)
for (int x = 0; x < p.size.x; x++)
{
// Очистка (проход 1) - СВЕРХУ ВНИЗ: снизу вверх убранный слой оставлял над собой
// песок/гравий без опоры, тот падал и рушил уже поставленные стены (тест 03.10,
// necropalace test: 30 расхождений сразу, 309 - при следующем входе). Постановка -
// снизу вверх, на опору.
int y = pass == 1 ? p.size.y - 1 - yi : yi;
bool shellCell = y == 0 || y == p.size.y - 1 || x == 0 || z == 0 || x == p.size.x - 1 || z == p.size.z - 1;
Vector3i wp = _o + new Vector3i(x, y, z);
if (pass == 0 && shellCell || pass == 2 && !shellCell)
{
if (Expected(p, x, y, z, out BlockValue bv, out sbyte d))
{
// С покраской из префаба (указание 03.10).
list.Add(new BlockChangeInfo(wp, bv, d, p.GetTexture(x, y, z)));
}
}
else if (pass == 1 && !shellCell)
{
list.Add(new BlockChangeInfo(wp, BlockValue.Air, MarchingCubes.DensityAir));
}
}
_world.SetBlocksRPC(list);
Debug.Log("[NecromancerTome] MindPalace: чертоги поставлены в " + _o + ", блоков " + list.Count);
}
static Vector3i Size => _prefab != null ? _prefab.size : new Vector3i(17, 8, 17);
/// <summary>Проверка постановки в нетронутую землю (указание 03.10): залить камнем коробку на
/// 2 блока шире чертогов во все стороны (включая слой bedrock снизу), подождать, поставить
/// чертоги заново, выставить двери. Сразу заливкой, без предварительной очистки - очистка и
/// есть та пустота без опоры, из-за которой был обвал.</summary>
public static System.Collections.IEnumerator StressTest(World _world, Vector3i _o)
{
Vector3i s = Size;
// Гравием - худший случай: сыпучее, падает без опоры (указание 03.10).
BlockValue stone = Block.GetBlockValue("terrGravel");
var list = new List<BlockChangeInfo>();
for (int y = -3; y < s.y + 2; y++)
for (int z = -2; z < s.z + 2; z++)
for (int x = -2; x < s.x + 2; x++)
{
list.Add(new BlockChangeInfo(_o + new Vector3i(x, y, z), stone, MarchingCubes.DensityTerrain));
}
_world.SetBlocksRPC(list);
Debug.Log("[NecromancerTome] necropalace test: залито гравием " + list.Count + " клеток, ждём 2 с");
yield return new WaitForSeconds(2f);
Stamp(_world, _o);
yield return new WaitForSeconds(1f);
OpenDoors(_world, _o);
Debug.Log("[NecromancerTome] necropalace test: готово, расхождений с префабом " + Mismatches(_world, _o));
}
public static bool InAnyPalace(Vector3i _pos, int _margin = 0)
{
if (_slots.Count == 0)
{
return false;
}
Vector3i s = Size;
foreach (Vector3i o in _slots.Values)
{
if (_pos.x >= o.x - _margin && _pos.x < o.x + s.x + _margin &&
_pos.y >= o.y - 1 - _margin && _pos.y < o.y + s.y + _margin &&
_pos.z >= o.z - _margin && _pos.z < o.z + s.z + _margin)
{
return true;
}
}
return false;
}
// ---------------------------------------------------------------- вход и выход
public static void Enter(EntityPlayerLocal _player)
{
if (Active || SnakeMode.Active || _player == null || _player.IsDead() || _player.AttachedToEntity != null)
{
return;
}
if (!SnakeMode.IsNight())
{
Debug.Log("[NecromancerTome] MindPalace: днём чертоги недоступны");
return;
}
if (SnakeMode.IsBloodTide())
{
SnakeMode.BloodTideHint(_player);
Debug.Log("[NecromancerTome] MindPalace: кровавый прилив - дух не покидает тело");
return;
}
World world = GameManager.Instance.World;
if (world.IsRemote())
{
Debug.Log("[NecromancerTome] MindPalace: в мультиплеере у клиента пока недоступно");
return;
}
if (LoadPrefab() == null)
{
return;
}
_origin = GetSlot(world, _player);
Vector3 center = _origin.ToVector3() + new Vector3(Size.x * 0.5f, 0f, Size.z * 0.5f);
// Дальность как у змеи: при радиусе 2 игра не строила коллизии участка (тест 03.10:
// «чертоги не построились вовремя» - ждали коллизий 30 с).
_observer = GameManager.Instance.AddChunkObserver(center, true, GameUtils.GetViewDistance(), -1);
ShrinkBody(_player);
_stage = Stage.Loading;
_deadline = Time.time + 30f;
Active = true;
_player.ScreenEffectManager?.SetScreenEffect("FadeToBlack", 1f, 0.6f);
_player.SetFirstPersonView(false, true);
_ragdollAt = Time.time + SnakeMode.RagdollDelay;
_waitStandUp = false;
Debug.Log("[NecromancerTome] MindPalace: вход, чертоги в " + _origin);
}
public static void Exit(EntityPlayerLocal _player)
{
if (!Active)
{
return;
}
Active = false;
_stage = Stage.None;
MindControl.End(_player);
RemoveObserver();
_player?.ScreenEffectManager?.SetScreenEffect("FadeToBlack", 0f, 1f);
if (_player == null || _player.IsDead())
{
return;
}
_ragdollAt = 0f;
_player.emodel.ragdollTime = _player.emodel.ragdollDuration;
_waitStandUp = true;
_standUpDeadline = Time.time + 5f;
Debug.Log("[NecromancerTome] MindPalace: выход");
}
static void RemoveObserver()
{
if (_observer != null && GameManager.Instance?.World != null)
{
GameManager.Instance.RemoveChunkObserver(_observer);
}
_observer = null;
EntityPlayerLocal p = GameManager.Instance?.World?.GetPrimaryPlayer();
if (_bodyViewDim > 0 && p?.ChunkObserver != null)
{
p.ChunkObserver.viewDim = _bodyViewDim;
}
_bodyViewDim = -1;
}
/// <summary>Область мира у тела до входа (-1 - не сжата). Фризы в чертогах (жалоба 10.10): игра держала две
/// полные области - у чертогов на краю карты и у лежащего тела; подгрузка и пересборка участков - мусор
/// и паузы его сборки. Тело сжимаем до Possession.BodyViewDim, как у змеи и одержимого.</summary>
static int _bodyViewDim = -1;
static void ShrinkBody(EntityPlayerLocal _player)
{
ChunkManager.ChunkObserver body = _player?.ChunkObserver;
if (body != null && _bodyViewDim < 0 && body.viewDim > Possession.BodyViewDim)
{
_bodyViewDim = body.viewDim;
body.viewDim = Possession.BodyViewDim;
}
}
static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data)
{
World world = GameManager.Instance?.World;
if (world == null)
{
Active = false;
_stage = Stage.None;
_observer = null;
MindControl.End(null);
_slotsFor = null;
_bodyViewDim = -1;
return;
}
EntityPlayerLocal player = world.GetPrimaryPlayer();
Guard(world);
if (!Active)
{
if (_waitStandUp && player != null && (!player.emodel.IsRagdollActive || Time.time > _standUpDeadline))
{
_waitStandUp = false;
player.SetFirstPersonView(true, true);
}
return;
}
if (player == null)
{
return;
}
if (player.IsDead())
{
Debug.Log("[NecromancerTome] MindPalace: тело убито");
Exit(player);
return;
}
if (!SnakeMode.IsNight())
{
Debug.Log("[NecromancerTome] MindPalace: наступило утро - обратно в тело");
Exit(player);
return;
}
if (SnakeMode.IsBloodTide())
{
SnakeMode.BloodTideHint(player);
Debug.Log("[NecromancerTome] MindPalace: кровавый прилив - дух выброшен в тело");
Exit(player);
return;
}
Body(player);
switch (_stage)
{
case Stage.Loading:
if (ChunksLoaded(world, _origin, Size))
{
var report = new List<string>();
int bad = Mismatches(world, _origin, report);
if (bad != 0)
{
Debug.Log("[NecromancerTome] MindPalace: расхождений с префабом " + bad + " - ставим заново");
for (int i = 0; i < report.Count && i < 10; i++)
{
Debug.Log("[NecromancerTome] MindPalace: " + report[i]);
}
Stamp(world, _origin);
}
_stage = Stage.Settling;
_settleUntil = Time.time + 1.5f;
}
else if (Time.time > _deadline)
{
Debug.LogWarning("[NecromancerTome] MindPalace: участки чертогов не загрузились");
Exit(player);
}
break;
case Stage.Settling:
Vector3 spawn = _origin.ToVector3() + SpawnAt;
// Коллизии готовы - или прошло 5 с: тогда всё равно пускаем, от провала страхует
// MindControl (возврат на точку появления).
if (Time.time > _settleUntil && (world.IsChunkAreaCollidersLoaded(spawn) || Time.time > _settleUntil + 5f))
{
Debug.Log("[NecromancerTome] MindPalace: коллизии " + (world.IsChunkAreaCollidersLoaded(spawn) ? "готовы" : "НЕ готовы, пускаем по таймеру"));
_stage = Stage.Ready;
OpenDoors(world, _origin);
UpdateBoards(world, _origin);
LogCell(world, _origin + KnifeAt, "нож");
LogCell(world, _origin + BookAt, "книга");
MindControl.Begin(player, spawn, SpawnYaw);
NecroChallenges.Award(NecroChallenges.MindPalace);
player.ScreenEffectManager?.SetScreenEffect("FadeToBlack", 0f, 1.5f);
}
else if (Time.time > _deadline)
{
Debug.LogWarning("[NecromancerTome] MindPalace: чертоги не построились вовремя");
Exit(player);
}
break;
case Stage.Ready:
MindControl.Update(player);
break;
}
// Выход по R - когда тело уже лежит и не открыто окно (как у змеи).
PlayerActionsLocal input = player.playerInput;
bool gui = player.PlayerUI != null && player.PlayerUI.windowManager.IsModalWindowOpen();
if (_ragdollAt <= 0f && player.emodel.IsRagdollActive && input != null && !gui &&
(input.Reload.WasPressed || input.PermanentActions.Reload.WasPressed))
{
Exit(player);
}
}
/// <summary>Тело падает и лежит - как в режиме змеи (SnakeMode.OnUnityUpdate).</summary>
static void Body(EntityPlayerLocal _player)
{
if (_ragdollAt > 0f)
{
if (Time.time >= _ragdollAt)
{
_ragdollAt = 0f;
_player.emodel.DoRagdoll(EModelBase.RagdollMode.Default, 999999f, EnumBodyPartHit.None, Vector3.zero, Vector3.zero, false);
}
}
else if (!_player.emodel.IsRagdollActive)
{
_player.emodel.DoRagdoll(EModelBase.RagdollMode.Default, 999999f, EnumBodyPartHit.None, Vector3.zero, Vector3.zero, false);
}
}
/// <summary>Раз в секунду: всё живое в коробке чертогов - вон. Дух сущности не имеет.</summary>
static void Guard(World _world)
{
_guardCheck -= Time.deltaTime;
if (_guardCheck > 0f || _world.IsRemote())
{
return;
}
_guardCheck = 1f;
LoadSlots();
if (_slots.Count == 0)
{
return;
}
// Игроков в EntityAlives нет (World.SpawnEntityInWorld кладёт туда только не-игроков) - их
// отдельно, по Players.list (тест 03.10: камень портала привёл в чертоги, охрана молчала).
for (int i = _world.Players.list.Count - 1; i >= 0; i--)
{
EntityPlayer pl = _world.Players.list[i];
if (pl == null || pl.IsDead() || !InAnyPalace(World.worldToBlockPos(pl.position)))
{
continue;
}
Vector3i b = World.worldToBlockPos(pl.position);
Vector3 up = new Vector3(pl.position.x, _world.GetTerrainHeight(b.x, b.z) + 2f, pl.position.z);
Debug.Log("[NecromancerTome] MindPalace: игрок " + pl.entityId + " в чертогах - выброшен на " + up);
if (pl is EntityPlayerLocal)
{
// Как камень портала (PortalStonePatch.TeleportToBedroll).
NetPackageManager.GetPackage<NetPackageTeleportPlayer>().Setup(up, null).ProcessPackage(_world, GameManager.Instance);
}
else
{
pl.Teleport(up);
}
}
for (int i = _world.EntityAlives.Count - 1; i >= 0; i--)
{
EntityAlive e = _world.EntityAlives[i];
if (e == null || e.IsDead() || !InAnyPalace(World.worldToBlockPos(e.position)))
{
continue;
}
EntityAlive snake = SnakeMode.Snake(_world);
if (snake != null && e == snake)
{
Debug.Log("[NecromancerTome] MindPalace: змея в чертогах - развеяна");
SnakeMode.Exit(_world.GetPrimaryPlayer());
}
else
{
Debug.Log("[NecromancerTome] MindPalace: " + e.EntityName + " в чертогах - удалён");
_world.RemoveEntity(e.entityId, EnumRemoveEntityReason.Despawned);
}
}
}
}
/// <summary>В чертогах и в рамке камня вокруг ничего не падает (указание 03.10: «почему не
/// отключить просчёт физики в той области»): World.AddFallingBlock - единственная очередь
/// падающих блоков (туда же игра сама не пускает блоки со StabilityIgnore). Тест гравием
/// 03.10: после постановки нижний/верхний ряд внутренней стены всё равно обрушивался.</summary>
[HarmonyPatch(typeof(World), nameof(World.AddFallingBlock))]
public static class Patch_World_AddFallingBlock_MindPalace
{
public static bool Prefix(Vector3i _blockPos)
{
return !MindPalace.InAnyPalace(_blockPos, MindPalace.LooseMargin);
}
}
/// <summary>Чертоги - «зона торговца»: блоки не ломаются ударом и взрывом, строить нельзя
/// (ItemActionAttack, Explosion, Block.DamageBlock проверяют IsWithinTraderArea).</summary>
[HarmonyPatch(typeof(World), nameof(World.IsWithinTraderArea), new[] { typeof(Vector3i) })]
public static class Patch_World_IsWithinTraderArea_MindPalace
{
public static void Postfix(Vector3i _worldBlockPos, ref bool __result)
{
if (!__result && MindPalace.InAnyPalace(_worldBlockPos))
{
__result = true;
}
}
}
/// <summary>
/// necropalace - отладка чертогов: where (где мои), rebuild (поставить заново, если участки
/// загружены), knife N (поворот ножа 0-23 и переставить его).
/// </summary>
[Preserve]
public class ConsoleCmdNecroPalace : ConsoleCmdAbstract
{
public override string[] getCommands() => new[] { "necropalace" };
public override string getDescription() => "NecromancerTome: чертоги разума - where | scan | rebuild | test | knife N | door [N open|close]";
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
World world = GameManager.Instance.World;
EntityPlayerLocal player = world?.GetPrimaryPlayer();
if (player == null)
{
return;
}
Vector3i o = Traverse.Create(typeof(MindPalace)).Method("GetSlot", world, (EntityPlayer)player).GetValue<Vector3i>();
string cmd = _params.Count > 0 ? _params[0] : "where";
if (cmd == "knife" && _params.Count > 1 && int.TryParse(_params[1], out int r))
{
MindPalace.KnifeRotation = Mathf.Clamp(r, 0, 3);
BlockValue k = Block.GetBlockValue(MindPalace.KnifeBlock);
k.rotation = (byte)MindPalace.KnifeRotation;
world.SetBlockRPC(o + MindPalace.KnifeAt, k, MarchingCubes.DensityAir);
SdtdConsole.Instance.Output("нож: поворот " + MindPalace.KnifeRotation);
}
else if (cmd == "scan")
{
var report = new List<string>();
int n = MindPalace.Mismatches(world, o, report);
SdtdConsole.Instance.Output("расхождений с префабом: " + n);
foreach (string line in report)
{
SdtdConsole.Instance.Output(line);
Debug.Log("[NecromancerTome] necropalace scan: " + line);
}
}
else if (cmd == "test")
{
GameManager.Instance.StartCoroutine(MindPalace.StressTest(world, o));
SdtdConsole.Instance.Output("проверка: заливка гравием, через 2 с - постановка (итог - в логе)");
}
else if (cmd == "door" && _params.Count > 2 && int.TryParse(_params[1], out int dn))
{
MindPalace.SetDoorOpen(player, dn, _params[2] == "open");
SdtdConsole.Instance.Output("дверь " + dn + ": " + (_params[2] == "open" ? "открыта" : "закрыта"));
}
else if (cmd == "door")
{
var doors = MindPalace.NumberedDoors(world, o);
SdtdConsole.Instance.Output("дверей с номерами: " + doors.Count);
foreach (var kv in doors)
{
SdtdConsole.Instance.Output(" " + kv.Key + " - " + world.GetBlock(o + kv.Value).Block.GetBlockName() + " " + kv.Value);
}
}
else if (cmd == "rebuild")
{
MindPalace.Stamp(world, o);
SdtdConsole.Instance.Output("чертоги поставлены заново в " + o);
}
else
{
SdtdConsole.Instance.Output("чертоги: " + o + ", построены: " + MindPalace.IsBuilt(world, o));
}
}
}
}