Compare commits
10
Commits
ab1b3eadaf
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7172681353 | ||
|
|
a6e19f9b97 | ||
|
|
275a739646 | ||
|
|
7768541f12 | ||
|
|
9ac575075a | ||
|
|
4801341676 | ||
|
|
29431990f6 | ||
|
|
a5f8592903 | ||
|
|
ccf58a2ec8 | ||
|
|
a645c53ff3 |
File diff suppressed because one or more lines are too long
@@ -131,11 +131,21 @@
|
|||||||
Найденные параметры - HypothermalResist (холод) и HyperthermalResist (жара). Живой
|
Найденные параметры - HypothermalResist (холод) и HyperthermalResist (жара). Живой
|
||||||
ванильный образец: modArmorInsulatedLinerT1/T2/T3 (Data/Config/item_modifiers.xml
|
ванильный образец: modArmorInsulatedLinerT1/T2/T3 (Data/Config/item_modifiers.xml
|
||||||
~1873), они ставят ровно эту пару. Величина у них по тирам: T1 1->2.5, T2 2.8->4.3,
|
~1873), они ставят ровно эту пару. Величина у них по тирам: T1 1->2.5, T2 2.8->4.3,
|
||||||
T3 4.6->6 на ОДИН элемент брони, а элементов четыре. Взято 5 - примерно уровень
|
T3 4.6->6 на ОДИН элемент брони, а элементов четыре.
|
||||||
одной детали брони с T3-подкладкой, и ровно то число, которое ваниль использовала во
|
|
||||||
вкомментированных modArmorInsulatedLiner/modArmorCoolingMesh (там 5 на холод и 5 на
|
ЗНАЧЕНИЕ 5 -> 50, 2026-09-13, прямое указание пользователя ("по факту она поднимает
|
||||||
жару, но двумя РАЗНЫМИ модами; здесь оба в одном, что щедрее - но это стоит слота из
|
сопротивление всего на 5, а надо на 50"). Изначально стояло 5 - примерно уровень одной
|
||||||
четырёх и работает только с ножом в руках, см. ниже). Крутить это число - одна правка.
|
детали брони с T3-подкладкой, и ровно то число, которым ваниль пользуется во
|
||||||
|
вкомментированных modArmorInsulatedLiner/modArmorCoolingMesh. Это было осознанно
|
||||||
|
скромно; пользователь хочет иначе, и его решение тут главнее моей балансной оценки.
|
||||||
|
|
||||||
|
ЧТО 50 ОЗНАЧАЕТ НА САМОМ ДЕЛЕ, раз единица - градусы, а не проценты (формула ниже):
|
||||||
|
любая уличная температура в пределах 50 градусов от комфортных 70 подтягивается К 70
|
||||||
|
ЦЕЛИКОМ, потому что там стоит min/max-ограничение. То есть от 20 до 120 по шкале игры
|
||||||
|
это не "сильная защита", а полный иммунитет: и снежная вершина, и пустынный полдень
|
||||||
|
перестают быть угрозой. Это примерно в 10 раз больше, чем даёт набор брони с
|
||||||
|
T3-подкладками на всех четырёх деталях. Записано не в укор, а чтобы через месяц не
|
||||||
|
пришлось гадать, почему термометр перестал что-либо значить.
|
||||||
|
|
||||||
ЕДИНИЦА ИЗМЕРЕНИЯ - градусы, на которые сдвигается уличная температура в сторону
|
ЕДИНИЦА ИЗМЕРЕНИЯ - градусы, на которые сдвигается уличная температура в сторону
|
||||||
комфортной, а не проценты (PlayerEntityStats, декомпиляция):
|
комфортной, а не проценты (PlayerEntityStats, декомпиляция):
|
||||||
@@ -173,8 +183,8 @@
|
|||||||
<property name="SellableToTrader" value="false"/>
|
<property name="SellableToTrader" value="false"/>
|
||||||
|
|
||||||
<effect_group tiered="false">
|
<effect_group tiered="false">
|
||||||
<passive_effect name="HypothermalResist" operation="base_add" value="5"/>
|
<passive_effect name="HypothermalResist" operation="base_add" value="50"/>
|
||||||
<passive_effect name="HyperthermalResist" operation="base_add" value="5"/>
|
<passive_effect name="HyperthermalResist" operation="base_add" value="50"/>
|
||||||
</effect_group>
|
</effect_group>
|
||||||
</item_modifier>
|
</item_modifier>
|
||||||
|
|
||||||
|
|||||||
+131
-12
@@ -214,7 +214,31 @@
|
|||||||
<triggered_effect trigger="onProjectileImpact" action="AddBuff" target="positionAOE" range="2" buff="buffNecroDeviatorCharm">
|
<triggered_effect trigger="onProjectileImpact" action="AddBuff" target="positionAOE" range="2" buff="buffNecroDeviatorCharm">
|
||||||
<requirement name="EntityTagCompare" target="other" tags="zombie"/>
|
<requirement name="EntityTagCompare" target="other" tags="zombie"/>
|
||||||
</triggered_effect>
|
</triggered_effect>
|
||||||
|
<!-- Generic stone-on-flesh thud, deliberately left UNCONDITIONAL: it fires on every
|
||||||
|
impact (ground, wall, zombie) so a miss still sounds like something landed. -->
|
||||||
<triggered_effect trigger="onProjectileImpact" action="PlaySound" sound="stonehitorganic"/>
|
<triggered_effect trigger="onProjectileImpact" action="PlaySound" sound="stonehitorganic"/>
|
||||||
|
<!-- The charm's OWN cue - fires only on a hit that actually charms, gated by the exact
|
||||||
|
same "other is a zombie" requirement the AddBuff above uses, so it can never fire on
|
||||||
|
a miss. Requirements on a PlaySound effect are the vanilla stun baton pattern
|
||||||
|
(Data/Config/items.xml:4005 - IsAlive/EntityTagCompare on target="other"), not
|
||||||
|
invented here; onProjectileImpact populating "other" with the hit entity is proven
|
||||||
|
by the AddBuff right above, which already works in game.
|
||||||
|
|
||||||
|
target="other" is meant to play the clip FROM the zombie, i.e. positional at the
|
||||||
|
point of impact instead of at the thrower's head (the attribute itself is vanilla -
|
||||||
|
items.xml:5514 uses target="self"). If it turns out silent in game, drop just the
|
||||||
|
attribute: the effect then plays on self and the gating still holds.
|
||||||
|
|
||||||
|
THE MOD'S OWN SOUND, the first one in the whole mod that is not a borrowed vanilla
|
||||||
|
id: "necroSpiritStoneHit" is defined in this mod's Config/sounds.xml and its clip
|
||||||
|
lives in Resources/necrosounds, built from the Unity project (see that file's header
|
||||||
|
for why a plain wav next to the XML cannot work). If the sound is missing in game,
|
||||||
|
the failure is SILENT - look for "AudioManager LoadAudio failed to load audio clip"
|
||||||
|
in the game log, and note that the generic stonehitorganic above will still play, so
|
||||||
|
"I heard something" is not proof this one fired. -->
|
||||||
|
<triggered_effect trigger="onProjectileImpact" action="PlaySound" target="other" sound="necroSpiritStoneHit">
|
||||||
|
<requirement name="EntityTagCompare" target="other" tags="zombie"/>
|
||||||
|
</triggered_effect>
|
||||||
</effect_group>
|
</effect_group>
|
||||||
</item>
|
</item>
|
||||||
</append>
|
</append>
|
||||||
@@ -1373,7 +1397,29 @@
|
|||||||
rest of the mod's hand-drawn icons). -->
|
rest of the mod's hand-drawn icons). -->
|
||||||
<append xpath="/items">
|
<append xpath="/items">
|
||||||
<item name="braceletSpatialVault">
|
<item name="braceletSpatialVault">
|
||||||
<property name="Tags" value="T0,weapon,attPerception"/>
|
<!-- MOD SLOTS ADDED 2026-09-13 ("добавь хранилищу 4 слота под модификации. Сами
|
||||||
|
модификации реализуем потом"). Two tags, exactly the scheme necroWpnBladeNecroKnife
|
||||||
|
already proved on 2026-09-07 - see that item's own comment for the full
|
||||||
|
decompiled reasoning:
|
||||||
|
|
||||||
|
noMods - blocks every vanilla mod. All 87 vanilla item_modifiers that
|
||||||
|
declare blocked_tags at all list noMods among them; the
|
||||||
|
remaining 24 cannot reach this item anyway (10 dyes and 7 drone
|
||||||
|
mods need a cosmetic slot or the drone tag, 2 are quest items,
|
||||||
|
1 needs perkArchery, 3 are CreativeMode Test/Dev). "noMods"
|
||||||
|
means nothing in code - it is purely a naming convention used
|
||||||
|
inside other items' blocked_tags.
|
||||||
|
necroBracelet - the positive half, reserved for the mods that come later. Every
|
||||||
|
future bracelet mod MUST declare
|
||||||
|
installable_tags="necroBracelet": a modifier with no
|
||||||
|
installable_tags at all fits ANYTHING (XUiM_AssembleItem short-
|
||||||
|
circuits on InstallableTags.IsEmpty), so forgetting it produces
|
||||||
|
the exact opposite of what is wanted.
|
||||||
|
|
||||||
|
Deliberately NOT adding canHaveCosmetic: that tag alone is what creates the paint
|
||||||
|
slot (ItemValue's constructor sizes CosmeticMods by it), and the knife had to have
|
||||||
|
it removed for precisely this reason. No tag, no slot, no dyes. -->
|
||||||
|
<property name="Tags" value="T0,weapon,attPerception,noMods,necroBracelet"/>
|
||||||
<!-- ItemTypeIcon="melee" REMOVED 2026-09-07 (user report: "поверх пиктограмм некоторых
|
<!-- ItemTypeIcon="melee" REMOVED 2026-09-07 (user report: "поверх пиктограмм некоторых
|
||||||
рецептов стоят странные пиктограммы... то ли факел, то ли спичка"). This was the
|
рецептов стоят странные пиктограммы... то ли факел, то ли спичка"). This was the
|
||||||
small badge drawn in the TOP-LEFT corner over the item's own icon in the recipe
|
small badge drawn in the TOP-LEFT corner over the item's own icon in the recipe
|
||||||
@@ -1401,19 +1447,64 @@
|
|||||||
(bundle, computer, forge, explosion, campfire, gunsmithing, book). -->
|
(bundle, computer, forge, explosion, campfire, gunsmithing, book). -->
|
||||||
<property name="DescriptionKey" value="braceletSpatialVaultDesc"/>
|
<property name="DescriptionKey" value="braceletSpatialVaultDesc"/>
|
||||||
<property name="CustomIcon" value="ProstranstvennoeHranilische"/>
|
<property name="CustomIcon" value="ProstranstvennoeHranilische"/>
|
||||||
<!-- Same "seed"-style grip as braceletThiefLoop originally had (see that item's own
|
<!-- МЕШ И ХВАТ, 2026-09-13. Просьба в два захода: сперва "пусть будет камень, а
|
||||||
comment for the full history) - foodCropYuccaFruit's own HoldType="31" +
|
хват давай сделаем как когда пытаешься ставить какой-нибудь блок", затем
|
||||||
parcelGenericPrefab.prefab. Unlike Thief's Loop, this item never touches
|
уточнение - "браслет это браслет... в идеале меш камня вообще убрать". Было:
|
||||||
Class="Zoom" (both its actions are Class="Eat"), so it never hit the
|
свёрток-«семечко» parcelGenericPrefab.prefab (коробочка, перевязанная бечёвкой -
|
||||||
"Attachments" transform error that forced Thief's Loop onto a real weapon mesh -
|
для браслета нелепо) + HoldType="31", и то и другое унаследовано от Петли вора.
|
||||||
no reason to change this one's mesh too. TintColor changed to green 2026-08-30
|
|
||||||
per direct request. -->
|
ИТОГ: в руке НЕТ НИЧЕГО, только кулак. Пустой префаб собирать не пришлось - в
|
||||||
|
движке есть готовое свойство, и вся связка целиком списана с ванильного
|
||||||
|
vehicleMinibikePlaceable (items.xml:13385), у которого стоят ровно те же две
|
||||||
|
строки подряд: HoldType="7" + HoldingItemHidden="true".
|
||||||
|
|
||||||
|
HoldType="7" - это и есть блочный хват ("кулак вниз, как будто держишь руль"),
|
||||||
|
не угаданный номер. Декомпилировано Mono.Cecil'ом из Assembly-CSharp 3.2.0
|
||||||
|
(сам Mono.Cecil.dll лежит в Mods/0_TFP_Harmony, отдельный декомпилятор не нужен):
|
||||||
|
|
||||||
|
ItemClassBlock..ctor -> HoldType = new DataItem<int>(7)
|
||||||
|
AnimationDelayData.AnimationDelay[7] =
|
||||||
|
new AnimationDelays(0, 0f, 0f, .31f, .31f, true) <- последний флаг TwoHanded
|
||||||
|
|
||||||
|
В blocks.xml свойства HoldType нет ни разу (0 вхождений), то есть КАЖДЫЙ блок в
|
||||||
|
игре держится именно семёркой из этого конструктора. Костет
|
||||||
|
(meleeWpnKnucklesT0LeatherKnuckles) - это HoldType="70", запасной вариант не
|
||||||
|
понадобился.
|
||||||
|
|
||||||
|
HoldingItemHidden="true" - штатное свойство ItemClass, а не трюк:
|
||||||
|
ItemClass..cctor заводит PropHoldingItemHidden = "HoldingItemHidden",
|
||||||
|
ItemClass.Init читает его через StringParsers.ParseBool, а
|
||||||
|
Inventory.setHoldingItemTransform в самом конце делает
|
||||||
|
holdingItemTransform.gameObject.SetActive(!HoldingItemHidden). Гасится ТОЛЬКО
|
||||||
|
модель в руке: иконка в инвентаре (своя рисованная ProstranstvennoeHranilische)
|
||||||
|
и мешок на земле не трогаются, действия предмета живут в ItemActionEat и от
|
||||||
|
этого GameObject не зависят.
|
||||||
|
|
||||||
|
Пустой меш поставить было НЕЛЬЗЯ, и это проверено, а не предположено:
|
||||||
|
ItemClass.CloneModel, если имя меша пустое и ассет не загрузился, подставляет
|
||||||
|
заглушку "@:Other/Items/Crafting/leather.fbx" - в руке оказался бы кусок кожи.
|
||||||
|
Единственный ванильный предмет вообще без Meshfile - meleeHandMaster (голые
|
||||||
|
руки), и он выкручивается через Canhold="false", что нам не подходит: браслет
|
||||||
|
надо держать, чтобы им пользоваться.
|
||||||
|
|
||||||
|
Meshfile оставлен камнем как безобидная затычка (в руке он скрыт, а для
|
||||||
|
MeshPurpose World/Local/Preview что-то иметь надо), DropMeshfile - ванильный
|
||||||
|
мешок sack_droppedPrefab, ровно тем же приёмом и по той же причине, что у
|
||||||
|
vehicleMinibikePlaceable: выброшенный предмет должно быть видно на земле, а
|
||||||
|
своей модели у него нет. HandMeshfile убран за ненадобностью.
|
||||||
|
|
||||||
|
Про HoldType и действия: единственное место, где ItemActionEat вообще читает
|
||||||
|
HoldType, - AnimationDelay[HoldType].RayCast (в PercentDone и IsActionRunning),
|
||||||
|
и он равен 0f и у старого 31, и у нового 7 (InitStatic заполняет все 100 слотов
|
||||||
|
нулями, ItemClassBlock переписывает слот 7, оставляя RayCast нулём).
|
||||||
|
ExecuteAction, за которую держится SpatialVaultPatch.cs, HoldType не читает
|
||||||
|
вовсе - проверено сканом IL по всей сборке. -->
|
||||||
<property name="Material" value="Morganic"/>
|
<property name="Material" value="Morganic"/>
|
||||||
<property name="Meshfile" value="@:Other/Items/Food/parcelGenericPrefab.prefab"/>
|
<property name="Meshfile" value="@:Other/Items/Crafting/rock_smallPrefab.prefab"/>
|
||||||
<property name="HandMeshfile" value="@:Other/Items/Food/parcelGenericPrefab.prefab"/>
|
<property name="DropMeshfile" value="@:Other/Items/Misc/sack_droppedPrefab.prefab"/>
|
||||||
<property name="DropMeshfile" value="@:Other/Items/Food/parcelGenericPrefab.prefab"/>
|
|
||||||
<property name="TintColor" value="30, 200, 60"/>
|
<property name="TintColor" value="30, 200, 60"/>
|
||||||
<property name="HoldType" value="31"/>
|
<property name="HoldType" value="7"/>
|
||||||
|
<property name="HoldingItemHidden" value="true"/>
|
||||||
<property name="Weight" value="0"/>
|
<property name="Weight" value="0"/>
|
||||||
<property name="Stacknumber" value="1"/>
|
<property name="Stacknumber" value="1"/>
|
||||||
<property name="EconomicValue" value="0"/>
|
<property name="EconomicValue" value="0"/>
|
||||||
@@ -1426,6 +1517,34 @@
|
|||||||
<property name="Class" value="Eat"/>
|
<property name="Class" value="Eat"/>
|
||||||
<property name="Delay" value="0.3"/>
|
<property name="Delay" value="0.3"/>
|
||||||
</property>
|
</property>
|
||||||
|
|
||||||
|
<!-- FOUR MOD SLOTS. The count is a passive_effect, not an item property - same shape
|
||||||
|
the knife uses, and flat rather than a per-quality list because quality means
|
||||||
|
nothing on this item.
|
||||||
|
|
||||||
|
NOTE THE MISSING ATTRIBUTE: this effect_group has NO tiered="false", and that is
|
||||||
|
the entire point. ItemClass.HasQuality is literally Effects.IsOwnerTiered(), and
|
||||||
|
ItemValue.FireEvent bails out with `if (!HasQuality) return;` BEFORE it walks
|
||||||
|
Modifications[] - so on an untiered item the slots still appear and still accept
|
||||||
|
mods, and not one triggered_effect inside them ever fires. That silent failure
|
||||||
|
cost a whole debugging session on the knife on 2026-09-07; it is not repeated
|
||||||
|
here. The slots themselves would work either way (Modifications is allocated
|
||||||
|
unconditionally, earlier), which is exactly what makes the failure so quiet.
|
||||||
|
|
||||||
|
Quality is not SHOWN, though: ShowQuality is a separate property that defaults to
|
||||||
|
false (vanilla sets it to true explicitly on the ~80 items that want a quality
|
||||||
|
bar), and it is deliberately left unset here. The item behaves as tiered for the
|
||||||
|
mod system and still reads as a plain bracelet in the UI.
|
||||||
|
|
||||||
|
FOR THE MODS THEMSELVES, WHEN THEY GET WRITTEN: give each one its OWN
|
||||||
|
modifier_tags. XUiC_ItemPartStack.CanSwap counts already-installed mods whose
|
||||||
|
modifier_tags intersect the one being installed and refuses at
|
||||||
|
num >= ItemClass.MaxModsAllowed, which defaults to 1 - so a shared tag like
|
||||||
|
"necroBraceletMod" across all four would leave exactly one of these four slots
|
||||||
|
usable. -->
|
||||||
|
<effect_group name="braceletSpatialVault">
|
||||||
|
<passive_effect name="ModSlots" operation="base_set" value="4"/>
|
||||||
|
</effect_group>
|
||||||
</item>
|
</item>
|
||||||
</append>
|
</append>
|
||||||
</config>
|
</config>
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace NecromancerTome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Drains the colour out of the world while the necromancer is channelling something, and
|
||||||
|
/// lets it back in when he stops (user request 2026-09-14: "эффект, когда мир становится
|
||||||
|
/// тёмным и чёрнобелым... повесить его на момент ожидания применения порталов и на момент
|
||||||
|
/// ожидания утаскивания блока", with "желательно плавно... секунды за 3" in both directions).
|
||||||
|
/// Shared by both channels so the look, the timing and the name of the effect live in one
|
||||||
|
/// place rather than drifting apart in two files.
|
||||||
|
///
|
||||||
|
/// IT IS THE GAME'S OWN POST-PROCESS, not a reimplementation. EntityPlayerLocal carries a
|
||||||
|
/// ScreenEffects component - ScreenEffectManager - whose SetScreenEffect(name, intensity,
|
||||||
|
/// fadeTime) is what the engine itself calls for dying ("Dying"), for spawning in
|
||||||
|
/// ("VibrantDeSat") and for every buff in the game that tints the screen. THE FADE IS THAT
|
||||||
|
/// THIRD ARGUMENT: three seconds in and three seconds out cost nothing to implement, because
|
||||||
|
/// the ramp is the effect system's own.
|
||||||
|
///
|
||||||
|
/// THE EFFECT IS "Greyscale", AND THE CHOICE IS ABOUT WHO ELSE TOUCHES IT. These effects are
|
||||||
|
/// a flat namespace of materials loaded from Resources/ScreenEffects - anyone writing to a
|
||||||
|
/// name overwrites whatever was there, so picking one is mostly picking a fight to avoid:
|
||||||
|
///
|
||||||
|
/// - "Greyscale" is written by exactly two things in the whole game, twitch_buffMonochrome
|
||||||
|
/// and sandbox_blackandwhite - a Twitch-integration reward and a game-mode toggle. Neither
|
||||||
|
/// happens in an ordinary session, so the channel owns it in practice.
|
||||||
|
/// - "Dying" and "Dead" are the death visuals the user was describing, and they are exactly
|
||||||
|
/// the ones NOT to borrow: EntityPlayerLocal.Update writes "Dying" from the player's own
|
||||||
|
/// health every time it changes, so any damage mid-channel would take the effect over -
|
||||||
|
/// and being hit mid-channel is a thing that happens.
|
||||||
|
/// - "Dark" would have supplied the darkening half. It belongs to buffCrouching, which
|
||||||
|
/// fires on every crouch with a 0.2s fade and would stamp on this one.
|
||||||
|
///
|
||||||
|
/// SO THE DARKENING HALF IS DELIBERATELY NOT DONE. Both effects that dim the screen are owned
|
||||||
|
/// by something that fights for them - crouching, and dying - and losing that fight looks like
|
||||||
|
/// a bug in this mod rather than in the effect system. Greyscale alone reads as the world
|
||||||
|
/// going wrong, which is what was actually asked for; if it wants to be darker too, the list
|
||||||
|
/// below takes a second entry and nothing else changes.
|
||||||
|
///
|
||||||
|
/// NOTHING HERE TOUCHES INPUT. The effect is a camera post-process and outlives the timer
|
||||||
|
/// window on purpose: the three-second fade back keeps running while the player walks away,
|
||||||
|
/// which is the point of asking for a fade rather than a switch.
|
||||||
|
/// </summary>
|
||||||
|
public static class ChannelVision
|
||||||
|
{
|
||||||
|
/// <summary>Seconds to fade in, and to fade back out.</summary>
|
||||||
|
public const float FadeSeconds = 3f;
|
||||||
|
|
||||||
|
/// <summary>What to fade, and how far. A list rather than a single name so a second layer
|
||||||
|
/// is one entry and not a rewrite - see the class comment on the darkening half.</summary>
|
||||||
|
public static readonly string[] EffectNames = { "Greyscale" };
|
||||||
|
|
||||||
|
/// <summary>Full strength per effect, in the same order as EffectNames.</summary>
|
||||||
|
public static readonly float[] EffectIntensities = { 1f };
|
||||||
|
|
||||||
|
/// <summary>Colour drains out over FadeSeconds.</summary>
|
||||||
|
public static void Begin(EntityPlayerLocal _player)
|
||||||
|
{
|
||||||
|
Apply(_player, _fullStrength: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Colour comes back over FadeSeconds. Safe to call when nothing is running -
|
||||||
|
/// fading an effect that is already at zero to zero does nothing - which is what lets
|
||||||
|
/// every exit path call it without first working out whether it is the one that has to.
|
||||||
|
/// </summary>
|
||||||
|
public static void End(EntityPlayerLocal _player)
|
||||||
|
{
|
||||||
|
Apply(_player, _fullStrength: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Apply(EntityPlayerLocal _player, bool _fullStrength)
|
||||||
|
{
|
||||||
|
if (_player == null || _player.ScreenEffectManager == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < EffectNames.Length; i++)
|
||||||
|
{
|
||||||
|
float intensity = _fullStrength ? EffectIntensities[i] : 0f;
|
||||||
|
_player.ScreenEffectManager.SetScreenEffect(EffectNames[i], intensity, FadeSeconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using UnityEngine.Scripting;
|
||||||
|
|
||||||
|
namespace NecromancerTome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// `necroghost [percent|reset]` - turns the traders' transparency live, without a rebuild
|
||||||
|
/// (user request 2026-09-14, right after the alpha went from 1% to 10%: "Сделай консольную
|
||||||
|
/// команду на альфу, чтобы крутить в игре"). The number being hunted - "a ghost, not a broken
|
||||||
|
/// model" - can only be judged by looking at him, and every step of that hunt otherwise costs
|
||||||
|
/// an edit, a `dotnet build`, a restart and the four-minute walk back to a trader, because
|
||||||
|
/// traders are streamed in on approach. This collapses the loop to one line in the console.
|
||||||
|
///
|
||||||
|
/// IT TAKES PERCENT OF TRANSPARENCY, NOT ALPHA, and that is deliberate: percent is the unit
|
||||||
|
/// the request has been made in twice ("буквально 1%", "пусть будет 10%"), while alpha is the
|
||||||
|
/// unit the renderer wants, and they run in opposite directions - 10% transparent is alpha
|
||||||
|
/// 0.9. Guessing which one a typed "10" meant would be a coin flip, so the command fixes the
|
||||||
|
/// unit and prints both back every time.
|
||||||
|
///
|
||||||
|
/// NOTHING IS PERSISTED. The value lives for the session; the one that turns out to be right
|
||||||
|
/// gets written into GhostTraderPatch.DefaultGhostAlpha, which is the line a release ships.
|
||||||
|
/// A settings file would just be a second place for the answer to hide.
|
||||||
|
///
|
||||||
|
/// WHY THE GAME FINDS THIS CLASS WITHOUT ANY REGISTRATION. SdtdConsole.RegisterCommands goes
|
||||||
|
/// through ReflectionHelpers.FindTypesImplementingBase(typeof(IConsoleCommand)), and that
|
||||||
|
/// walks ModManager.GetLoadedAssemblies() alongside the game's own - so a ConsoleCmdAbstract
|
||||||
|
/// in a mod DLL is picked up like any vanilla one. Ordering is not a gamble either:
|
||||||
|
/// GameManager calls ModManager.LoadMods() three lines before RegisterCommands().
|
||||||
|
///
|
||||||
|
/// IsExecuteOnClient IS true BECAUSE THIS CHANGES PIXELS. On a server the command would
|
||||||
|
/// otherwise run where there is nothing to look at; the flag makes the server bounce it back
|
||||||
|
/// to the client that typed it (ConnectionManager.ServerConsoleCommand), which is where the
|
||||||
|
/// materials and the eyes are. In single player it changes nothing.
|
||||||
|
/// </summary>
|
||||||
|
[Preserve]
|
||||||
|
public class ConsoleCmdNecroGhost : ConsoleCmdAbstract
|
||||||
|
{
|
||||||
|
public override bool IsExecuteOnClient => true;
|
||||||
|
|
||||||
|
public override bool AllowedInMainMenu => false;
|
||||||
|
|
||||||
|
public override string[] getCommands()
|
||||||
|
{
|
||||||
|
return new string[] { "necroghost", "necrotrader" };
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string getDescription()
|
||||||
|
{
|
||||||
|
return "Necromancer's Tome: how transparent the ghost traders are, in percent.";
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string getHelp()
|
||||||
|
{
|
||||||
|
return "necroghost - show the current value and mode\n" +
|
||||||
|
"necroghost <0-100> - set transparency in percent (10 = the default, barely there;\n" +
|
||||||
|
" 30 = clearly a ghost; past ~30 he stops reading as a body)\n" +
|
||||||
|
"necroghost blend - fade the body by blending (re-shades it; smooth)\n" +
|
||||||
|
"necroghost dither - fade the body by dithering (the game's own _Fade; grainy)\n" +
|
||||||
|
"necroghost reset - back to the built-in default value and mode\n" +
|
||||||
|
"\n" +
|
||||||
|
"Applies to traders already in the world, immediately - walk up to one first and\n" +
|
||||||
|
"watch him while you type. Not saved: tell the mod author what you settled on.\n" +
|
||||||
|
"\n" +
|
||||||
|
"THE MODES ARE NOT DEGREES OF ONE THING. The body's own shader cannot blend, so the\n" +
|
||||||
|
"game fades it by throwing pixels away in a pattern - that is the fine grid. Blend\n" +
|
||||||
|
"re-shades the body onto the hair's shader, which has a transparent pass, at the\n" +
|
||||||
|
"cost of the character shader's own lighting. The hair fades the same way either\n" +
|
||||||
|
"way, so it is the body you compare.\n" +
|
||||||
|
"\n" +
|
||||||
|
"If he comes apart instead of fading - teeth through the cheek, an arm through the\n" +
|
||||||
|
"chest - that is not this number, that is depth writing, and no value here will fix it.";
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
|
||||||
|
{
|
||||||
|
if (_params.Count == 0)
|
||||||
|
{
|
||||||
|
Report("Ghost traders");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string argument = _params[0].Trim();
|
||||||
|
if (argument.EqualsCaseInsensitive("reset"))
|
||||||
|
{
|
||||||
|
GhostTraderPatch.GhostAlpha = GhostTraderPatch.DefaultGhostAlpha;
|
||||||
|
SetMode(GhostTraderPatch.BodyOpacityMode.Blend, "Reset");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (argument.EqualsCaseInsensitive("blend"))
|
||||||
|
{
|
||||||
|
SetMode(GhostTraderPatch.BodyOpacityMode.Blend, "Body mode");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (argument.EqualsCaseInsensitive("dither"))
|
||||||
|
{
|
||||||
|
SetMode(GhostTraderPatch.BodyOpacityMode.Dither, "Body mode");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryParsePercent(argument, out float percent))
|
||||||
|
{
|
||||||
|
SingletonMonoBehaviour<SdtdConsole>.Instance.Output(
|
||||||
|
"necroghost: '" + argument + "' is neither a percentage nor blend/dither/reset. " +
|
||||||
|
"Try 'necroghost 10', or 'help necroghost'.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (percent < 0f || percent > 100f)
|
||||||
|
{
|
||||||
|
SingletonMonoBehaviour<SdtdConsole>.Instance.Output(
|
||||||
|
"necroghost: " + percent.ToString("0.#") + "% is outside 0-100. 0 = solid, 100 = invisible.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
GhostTraderPatch.GhostAlpha = 1f - percent / 100f;
|
||||||
|
Report("Set");
|
||||||
|
|
||||||
|
// Said only when asked for, and only once the value is actually past the point where
|
||||||
|
// the two failure modes stop looking different - see GhostTraderPatch.GhostAlpha.
|
||||||
|
if (GhostTraderPatch.GhostAlpha < 0.7f)
|
||||||
|
{
|
||||||
|
SingletonMonoBehaviour<SdtdConsole>.Instance.Output(
|
||||||
|
" (past ~30% the silhouette stops reading as a solid body at all, which looks like " +
|
||||||
|
"a broken model for a different reason than depth writing does)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Switches how the body is faded and rebuilds the traders already standing, which
|
||||||
|
/// is the expensive path - the materials have to be built again from the originals, since
|
||||||
|
/// a re-shaded material cannot be un-re-shaded. Changing only the number never comes here.
|
||||||
|
/// Saying so out loud matters: this is the one thing in the command that is not free, and
|
||||||
|
/// flipping modes back and forth while hunting a value is the obvious way to use it.</summary>
|
||||||
|
public static void SetMode(GhostTraderPatch.BodyOpacityMode _mode, string _prefix)
|
||||||
|
{
|
||||||
|
bool changed = GhostTraderPatch.BodyMode != _mode;
|
||||||
|
GhostTraderPatch.BodyMode = _mode;
|
||||||
|
int rebuilt = changed ? GhostTraderPatch.Reapply() : 0;
|
||||||
|
Report(_prefix);
|
||||||
|
if (changed && rebuilt > 0)
|
||||||
|
{
|
||||||
|
SingletonMonoBehaviour<SdtdConsole>.Instance.Output(
|
||||||
|
" (" + rebuilt + " renderer(s) rebuilt from their original materials)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Current value plus what it actually reached, in both units, and which way the
|
||||||
|
/// body is being faded. The count is the half that answers "did it do anything": 0
|
||||||
|
/// materials means no trader has been converted yet - they stream in on approach - not
|
||||||
|
/// that the number was refused.</summary>
|
||||||
|
public static void Report(string _prefix)
|
||||||
|
{
|
||||||
|
float alpha = GhostTraderPatch.GhostAlpha;
|
||||||
|
int applied = GhostTraderPatch.Retint();
|
||||||
|
SingletonMonoBehaviour<SdtdConsole>.Instance.Output(
|
||||||
|
_prefix + ": " + ((1f - alpha) * 100f).ToString("0.#") + "% transparent (alpha " +
|
||||||
|
alpha.ToString("0.###") + "), body mode " + GhostTraderPatch.BodyMode +
|
||||||
|
", applied to " + applied + " live material(s) across " +
|
||||||
|
GhostTraderPatch.Ghosted.Count + " trader(s) converted this session.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Percent out of what the user typed. StringParsers is the game's own parser and
|
||||||
|
/// is culture-independent, which matters here - but it reads ',' as a THOUSANDS separator,
|
||||||
|
/// so on a keyboard where the decimal key produces a comma "12,5" would silently parse as
|
||||||
|
/// 125 and the trader would vanish. The comma is turned into a point before it gets there.
|
||||||
|
/// A trailing '%' is accepted because it is the obvious thing to type.</summary>
|
||||||
|
public static bool TryParsePercent(string _argument, out float _percent)
|
||||||
|
{
|
||||||
|
string text = _argument.Replace(',', '.').TrimEnd('%').Trim();
|
||||||
|
return StringParsers.TryParseFloat(text, out _percent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,896 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.Rendering;
|
||||||
|
|
||||||
|
namespace NecromancerTome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Renders every trader in black and white (user request 2026-09-13: "сделать модельки всех
|
||||||
|
/// торговцев полупрозрачными и чёрнобелыми", then after two in-game looks: "прозрачность у
|
||||||
|
/// торговцев убираем совсем"). Fits the mod - the necromancer deals with the dead, and the
|
||||||
|
/// only people still trading are not quite alive.
|
||||||
|
///
|
||||||
|
/// TRANSPARENCY WAS DROPPED, THEN ASKED BACK FOR AT A SLIVER, THEN RAISED TO 10%. The user
|
||||||
|
/// first said "убираем совсем", confirmed the result ("торговец стал непрозрачным и
|
||||||
|
/// полностью чёрно-белым, как и требовалось"), then asked for "лёгкую прозрачность,
|
||||||
|
/// буквально 1%", and on 2026-09-14 for 10% - "чтобы он выглядел как призрак, а не как
|
||||||
|
/// сломанная модель". Dropping it was still the release this effect needed, because it
|
||||||
|
/// is what allowed the shader to stay put - see below; the alpha is a separate, optional layer
|
||||||
|
/// on top (ApplyTransparency) that cannot break the greyscale if the shaders refuse it.
|
||||||
|
///
|
||||||
|
/// The two failed attempts are worth keeping written down, because neither could have been
|
||||||
|
/// predicted from the decompiler and each was settled by one log line:
|
||||||
|
///
|
||||||
|
/// 1. "Unlit/Transparent Greyscale" (what GameManager uses for greyed-out item icons) has
|
||||||
|
/// NO _Color - nowhere to put an alpha. Traders came out opaque, and because that shader
|
||||||
|
/// is built for NGUI atlases rather than skinned meshes, the user saw them "в негативе".
|
||||||
|
/// 2. "Unlit/Transparent Colored" has no _Color either: `has _Color: False` in the log.
|
||||||
|
/// NGUI tints through VERTEX colours, not a material property, and a character mesh has
|
||||||
|
/// none - so that whole family of shaders was always a dead end here.
|
||||||
|
///
|
||||||
|
/// WITHOUT THE ALPHA REQUIREMENT THE SHADER DOES NOT HAVE TO BE REPLACED AT ALL, and that is
|
||||||
|
/// strictly better than anything above: the material is cloned with its own shader intact and
|
||||||
|
/// only its albedo texture is swapped for a desaturated copy. Lighting, normal maps, specular,
|
||||||
|
/// skinning - all still the game's own. The trader looks exactly like himself, in black and
|
||||||
|
/// white. Nothing can go "negative", because nothing but the pixels changes.
|
||||||
|
///
|
||||||
|
/// THE ALBEDO IS FOUND, NOT ASSUMED - this is what the earlier runs bought us. The first
|
||||||
|
/// attempt reached for _MainTex and produced an untextured silhouette, because traders are
|
||||||
|
/// drawn by TWO different shaders and only one of them uses that name. The probe below walks
|
||||||
|
/// the shader's declared properties instead, and the log then said exactly what they are:
|
||||||
|
///
|
||||||
|
/// shader 'Game/Character' texture properties: _Albedo=set, _Normal=set, _RMOE=set,
|
||||||
|
/// _texcoord=empty; chosen albedo: HD_Rekt 4096x4096
|
||||||
|
/// shader 'Game/Autodesk' texture properties: _MainTex=set, _BumpMap=set, ...;
|
||||||
|
/// chosen albedo: HD_Rekt_Hair 2048x2048
|
||||||
|
///
|
||||||
|
/// So the body uses _Albedo and the hair uses _MainTex - which is precisely why the name is
|
||||||
|
/// discovered rather than hard-coded, and why the probe stays in: Jen is assembled by a
|
||||||
|
/// different character system than Rekt (AvatarSDCSController vs AvatarNpcController in
|
||||||
|
/// entityclasses.xml) and may well introduce a third shader.
|
||||||
|
///
|
||||||
|
/// GREYSCALE IS DONE TO THE TEXTURE, via a RenderTexture round trip. The round trip is the
|
||||||
|
/// point: game textures are compressed with isReadable=false, so GetPixels on the original
|
||||||
|
/// throws - blitting into an ARGB32 RenderTexture and reading THAT back is the standard way to
|
||||||
|
/// reach pixels the CPU was never handed. Luma weights 0.299/0.587/0.114 rather than a flat
|
||||||
|
/// average, so it reads like a black-and-white photograph instead of a muddy one. Cached per
|
||||||
|
/// source texture: these are 4096x4096, and a readback per renderer per sweep would be
|
||||||
|
/// indefensible.
|
||||||
|
///
|
||||||
|
/// THE ALPHA IS TURNED FROM THE CONSOLE, not from this file: `necroghost <percent>`, in
|
||||||
|
/// GhostTraderCommand.cs. A rendering balance can only be judged by looking at it, and a
|
||||||
|
/// rebuild-restart-walk-to-a-trader cycle per step is not a way to look at anything.
|
||||||
|
///
|
||||||
|
/// WHY A TICK AND NOT A SPAWN HOOK. Traders are streamed in on approach ("force spawning
|
||||||
|
/// pending entity npcTraderRekt" appeared ~4 minutes after the world loaded), and Jen is built
|
||||||
|
/// at runtime, so her renderers do not all exist when the entity is added to the world.
|
||||||
|
/// Polling with ModEvents.UnityUpdate - the same approach PetFollowPatch.cs already uses here -
|
||||||
|
/// avoids guessing at the right moment inside someone else's character pipeline. A trader with
|
||||||
|
/// no renderers yet is simply not marked done and is picked up on the next sweep.
|
||||||
|
/// </summary>
|
||||||
|
public static class GhostTraderPatch
|
||||||
|
{
|
||||||
|
/// <summary>Seconds between sweeps.</summary>
|
||||||
|
public const float SweepInterval = 2f;
|
||||||
|
|
||||||
|
/// <summary>What a fresh game boots with. 1 = solid; 0.3 is what the hunt actually landed
|
||||||
|
/// on - the user set 70% transparency in game on 2026-09-14 once the body was blending
|
||||||
|
/// instead of dithering, and kept it. THE VALUE FOUND IN GAME BELONGS HERE: the console
|
||||||
|
/// command turns GhostAlpha for the session only and deliberately persists nothing, so a
|
||||||
|
/// number worth keeping has to be written into this line.
|
||||||
|
///
|
||||||
|
/// The road here was 1% -> 10% -> 70%, and the last jump was not a change of taste: at 10%
|
||||||
|
/// the body was still being dithered, and a dither is only bearable while it is faint. Once
|
||||||
|
/// it became real blending, far more of it turned out to look right.</summary>
|
||||||
|
public const float DefaultGhostAlpha = 0.3f;
|
||||||
|
|
||||||
|
/// <summary>The opacity actually in use. THIS IS THE ONE NUMBER TO TURN when hunting the
|
||||||
|
/// balance between "ghost" and "broken model" - everything else in this file is about
|
||||||
|
/// making the number mean what it says - and `necroghost <percent>` turns it live
|
||||||
|
/// (GhostTraderCommand.cs), because every step of that hunt otherwise costs a rebuild, a
|
||||||
|
/// restart and the four-minute walk to a trader.
|
||||||
|
///
|
||||||
|
/// Which way to turn it is decided by WHICH failure you are looking at, and the two look
|
||||||
|
/// nothing alike:
|
||||||
|
/// - reads as a solid person, no ghost at all -> lower it (0.85, 0.8).
|
||||||
|
/// - the world shows through him but he still reads as one body -> this is the target.
|
||||||
|
/// - you can see his teeth through his cheek, eyes through eyelids, an arm through the
|
||||||
|
/// chest -> that is the "broken model", and it is NOT this number's fault. It means
|
||||||
|
/// depth writing came off somewhere; see _ZWrite in ApplyTransparency. Dropping the
|
||||||
|
/// alpha further only makes it worse.
|
||||||
|
/// THE OLD "nothing below ~0.7" NOTE WAS WRONG, and it is worth saying why rather than
|
||||||
|
/// quietly deleting: it was written while the body was still dithering, where a low value
|
||||||
|
/// means a coarse pattern and the silhouette falls apart early. With the body blending,
|
||||||
|
/// 0.3 reads as a ghost and holds together - the limit belonged to the technique, not to
|
||||||
|
/// the eye. Depth writing is what keeps him one body, and it does not care how low the
|
||||||
|
/// number goes.</summary>
|
||||||
|
public static float GhostAlpha = DefaultGhostAlpha;
|
||||||
|
|
||||||
|
/// <summary>One opacity lever this patch installed on a trader, remembered so the alpha
|
||||||
|
/// can be turned again WITHOUT rebuilding anything. Keeping the base value is the whole
|
||||||
|
/// point: the live value is always base * GhostAlpha, never "multiply what is there now by
|
||||||
|
/// the new number" - that would compound (0.9 then 0.8 would land on 0.72) and the second
|
||||||
|
/// turn of the dial would lie about where it put you.
|
||||||
|
///
|
||||||
|
/// IsColour says which of the two kinds this is - the alpha channel of a colour, or a
|
||||||
|
/// plain float - because a trader needs BOTH and they are not interchangeable: the body
|
||||||
|
/// only has the float (_Fade) and the hair only has the colour (_Color). One material can
|
||||||
|
/// therefore contribute one entry, and one trader contributes several of both kinds.</summary>
|
||||||
|
public struct GhostMaterial
|
||||||
|
{
|
||||||
|
public Material Material;
|
||||||
|
public string Property;
|
||||||
|
public bool IsColour;
|
||||||
|
public float BaseValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A renderer this patch has taken over, with the materials it had before. The
|
||||||
|
/// originals are the reason a mode can be switched at all: after the first pass
|
||||||
|
/// renderer.sharedMaterials hands back OUR clones, so rebuilding from what is currently
|
||||||
|
/// on the renderer would re-shade an already re-shaded material and there would be no way
|
||||||
|
/// back to the body's real one. Rebuilding always starts here instead.</summary>
|
||||||
|
public struct GhostRenderer
|
||||||
|
{
|
||||||
|
public Renderer Renderer;
|
||||||
|
public Material[] Originals;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Every renderer taken over, in the order it was found. Pruned of destroyed
|
||||||
|
/// renderers as they are walked; dropped wholesale when the world unloads.</summary>
|
||||||
|
public static readonly List<GhostRenderer> Converted = new List<GhostRenderer>();
|
||||||
|
|
||||||
|
/// <summary>Every material installed on a live trader that has somewhere to put an alpha.
|
||||||
|
/// Retint walks this instead of redoing the work: re-running the sweep would re-clone the
|
||||||
|
/// materials and, far worse, hand the 4096x4096 desaturation another readback per trader.
|
||||||
|
/// Entries go stale when the game destroys the material with its trader - Retint prunes
|
||||||
|
/// those, and WorldShuttingDown drops the lot.</summary>
|
||||||
|
public static readonly List<GhostMaterial> TintedMaterials = new List<GhostMaterial>();
|
||||||
|
|
||||||
|
/// <summary>Colour properties that might carry an alpha, best first. Confirmed in game:
|
||||||
|
/// the hair/beard shader "Game/Autodesk" has _Color; the BODY shader "Game/Character" has
|
||||||
|
/// no colour property at all - see FadeNameHints.</summary>
|
||||||
|
public static readonly string[] TintNameHints = { "_Color", "_BaseColor", "_TintColor", "_Tint" };
|
||||||
|
|
||||||
|
/// <summary>How the BODY is made see-through. The two are not two settings of one thing,
|
||||||
|
/// they are two different renderers' worth of behaviour, and only a look in game can pick
|
||||||
|
/// between them - which is why both stay reachable from the console.
|
||||||
|
///
|
||||||
|
/// Dither is the game's own _Fade, and on 2026-09-14 the user reported what it actually
|
||||||
|
/// looks like: "тело гаснет, но идёт мелкой сеточкой". That is screen-door transparency -
|
||||||
|
/// the shader is not blending anything, it is THROWING PIXELS AWAY in a fixed pattern.
|
||||||
|
/// It is what an opaque-queue character shader can do without a transparent pass, and no
|
||||||
|
/// value of GhostAlpha turns a dropped pixel into a translucent one.
|
||||||
|
///
|
||||||
|
/// Blend re-shades the body onto the shader the HAIR is already drawn with, which does
|
||||||
|
/// have a transparent pass. The existence proof is on the same model in the same frame:
|
||||||
|
/// the beard has been fading smoothly this whole time while the body was dithering.</summary>
|
||||||
|
public enum BodyOpacityMode
|
||||||
|
{
|
||||||
|
Dither,
|
||||||
|
Blend
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Blend by default: dither has been looked at and rejected. `necroghost blend`
|
||||||
|
/// and `necroghost dither` switch it live - see GhostTraderCommand.cs.</summary>
|
||||||
|
public static BodyOpacityMode BodyMode = BodyOpacityMode.Blend;
|
||||||
|
|
||||||
|
/// <summary>The hair's shader, kept once it is found on a real trader. Taken off the model
|
||||||
|
/// rather than through Shader.Find so it is the exact shader already proven to work in
|
||||||
|
/// this scene - name lookups can miss a stripped or renamed shader and hand back null,
|
||||||
|
/// and the mod has been down that road twice already with the Unlit family.</summary>
|
||||||
|
public static Shader BlendShader;
|
||||||
|
|
||||||
|
/// <summary>Normal-map properties, best first. "Game/Character" calls it _Normal, the hair
|
||||||
|
/// shader wants _BumpMap - the same disagreement the albedo has.</summary>
|
||||||
|
public static readonly string[] NormalNameHints = { "_BumpMap", "_Normal", "_NormalMap", "_NormalTex" };
|
||||||
|
|
||||||
|
/// <summary>Float properties that fade the whole material out, best first. This is the
|
||||||
|
/// body's only lever and the game's own: EntityModel.SetFade writes exactly "_Fade", and
|
||||||
|
/// guards it with `material.HasProperty("_Fade") && shader.name.Contains("Game/Character")`
|
||||||
|
/// - the same shader our traders' bodies are drawn with. EntityAlive.Update drives it from
|
||||||
|
/// renderFade, and SetVisible(renderFade > 0.01f) right next to it settles the direction
|
||||||
|
/// beyond doubt: 1 = solid, 0 = gone, exactly like an alpha.</summary>
|
||||||
|
public static readonly string[] FadeNameHints = { "_Fade" };
|
||||||
|
|
||||||
|
/// <summary>Entity ids already converted. Cleared when the world unloads.</summary>
|
||||||
|
public static readonly HashSet<int> Ghosted = new HashSet<int>();
|
||||||
|
|
||||||
|
/// <summary>Source shader names already described in the log, so the probe says each
|
||||||
|
/// distinct thing once rather than once per trader per part.</summary>
|
||||||
|
public static readonly HashSet<string> ProbedShaders = new HashSet<string>();
|
||||||
|
|
||||||
|
/// <summary>Desaturated copies, keyed by the texture they came from.</summary>
|
||||||
|
public static readonly Dictionary<Texture, Texture2D> GreyTextures = new Dictionary<Texture, Texture2D>();
|
||||||
|
|
||||||
|
/// <summary>Property names that look like an albedo, best first. Confirmed in game:
|
||||||
|
/// "Game/Character" uses _Albedo, "Game/Autodesk" uses _MainTex. Anything else falls
|
||||||
|
/// through to "the first texture property that has something in it".</summary>
|
||||||
|
public static readonly string[] AlbedoNameHints =
|
||||||
|
{
|
||||||
|
"_MainTex", "_Albedo", "_BaseMap", "_BaseColorMap", "_AlbedoMap", "_DiffuseMap",
|
||||||
|
"_Diffuse", "_ColorMap", "_MainTexture", "_Texture"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static float timer;
|
||||||
|
|
||||||
|
/// <summary>Called from ModEntry.InitMod.</summary>
|
||||||
|
public static void Init()
|
||||||
|
{
|
||||||
|
ModEvents.UnityUpdate.RegisterHandler(OnUnityUpdate);
|
||||||
|
ModEvents.WorldShuttingDown.RegisterHandler(OnWorldShuttingDown);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void OnWorldShuttingDown(ref ModEvents.SWorldShuttingDownData _data)
|
||||||
|
{
|
||||||
|
Ghosted.Clear();
|
||||||
|
GreyTextures.Clear();
|
||||||
|
TintedMaterials.Clear();
|
||||||
|
Converted.Clear();
|
||||||
|
BlendShader = null;
|
||||||
|
timer = 0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data)
|
||||||
|
{
|
||||||
|
timer += Time.deltaTime;
|
||||||
|
if (timer < SweepInterval)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timer = 0f;
|
||||||
|
|
||||||
|
World world = GameManager.Instance != null ? GameManager.Instance.World : null;
|
||||||
|
if (world == null || world.EntityAlives == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < world.EntityAlives.Count; i++)
|
||||||
|
{
|
||||||
|
EntityAlive entity = world.EntityAlives[i];
|
||||||
|
if (!(entity is EntityTrader trader) || trader.IsDead())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Ghosted.Contains(trader.entityId))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ApplyGreyscale(trader))
|
||||||
|
{
|
||||||
|
Ghosted.Add(trader.entityId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>False when there is nothing to work on yet (model not built), so the caller
|
||||||
|
/// leaves this trader unmarked and tries again on the next sweep.</summary>
|
||||||
|
public static bool ApplyGreyscale(EntityTrader _trader)
|
||||||
|
{
|
||||||
|
Renderer[] renderers = _trader.GetComponentsInChildren<Renderer>(true);
|
||||||
|
if (renderers == null || renderers.Length == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Before anything is touched, because the body's replacement shader is found on the
|
||||||
|
// trader's OWN hair and the hair is not guaranteed to come first in this array.
|
||||||
|
FindBlendShader(renderers);
|
||||||
|
|
||||||
|
int converted = 0;
|
||||||
|
int leversBefore = TintedMaterials.Count;
|
||||||
|
foreach (Renderer renderer in renderers)
|
||||||
|
{
|
||||||
|
if (renderer == null || renderer is ParticleSystemRenderer)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Material[] sources = renderer.sharedMaterials;
|
||||||
|
if (sources == null || sources.Length == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Converted.Add(new GhostRenderer { Renderer = renderer, Originals = sources });
|
||||||
|
if (Convert(renderer, sources))
|
||||||
|
{
|
||||||
|
converted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The lever count is the half that answers "will the console command reach him":
|
||||||
|
// desaturation and opacity come from different properties, and the body had the first
|
||||||
|
// without the second until 2026-09-14. Fewer levers than converted renderers means
|
||||||
|
// some part of this trader can only ever be black-and-white, never transparent.
|
||||||
|
Debug.Log("[NecromancerTome] GhostTraderPatch: " + _trader.EntityClass.entityClassName +
|
||||||
|
" (entity " + _trader.entityId + ") - " + converted + " of " + renderers.Length +
|
||||||
|
" renderer(s) desaturated, " + (TintedMaterials.Count - leversBefore) + " opacity lever(s) installed");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Builds and installs this renderer's ghost materials from the ORIGINALS it was
|
||||||
|
/// handed. True when anything was actually changed - a renderer nothing could be done to
|
||||||
|
/// keeps the materials it has rather than being handed a half-built array.</summary>
|
||||||
|
public static bool Convert(Renderer _renderer, Material[] _sources)
|
||||||
|
{
|
||||||
|
Material[] ghosts = new Material[_sources.Length];
|
||||||
|
bool anyChanged = false;
|
||||||
|
for (int i = 0; i < _sources.Length; i++)
|
||||||
|
{
|
||||||
|
ghosts[i] = MakeGreyMaterial(_sources[i], ref anyChanged);
|
||||||
|
}
|
||||||
|
if (anyChanged)
|
||||||
|
{
|
||||||
|
_renderer.materials = ghosts;
|
||||||
|
}
|
||||||
|
return anyChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Rebuilds every trader already converted, from their original materials, under
|
||||||
|
/// whatever BodyMode is set now. This is the expensive path and exists only for the mode
|
||||||
|
/// switch; changing the alpha alone goes through Retint, which touches no materials at
|
||||||
|
/// all. Even here the 4096x4096 desaturation is not redone - GreyTextures is keyed by the
|
||||||
|
/// source texture, and the source is the same original every time.</summary>
|
||||||
|
public static int Reapply()
|
||||||
|
{
|
||||||
|
TintedMaterials.Clear();
|
||||||
|
int rebuilt = 0;
|
||||||
|
for (int i = Converted.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
GhostRenderer entry = Converted[i];
|
||||||
|
if (entry.Renderer == null)
|
||||||
|
{
|
||||||
|
Converted.RemoveAt(i);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Convert(entry.Renderer, entry.Originals))
|
||||||
|
{
|
||||||
|
rebuilt++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rebuilt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Remembers the first shader on this model that can actually blend - it needs
|
||||||
|
/// both a colour to put an alpha in and a blend mode to honour it. On a trader that is the
|
||||||
|
/// hair's shader. Falls back to looking the name up only if the model somehow has no such
|
||||||
|
/// material, and says so either way, because a null here silently disables the whole Blend
|
||||||
|
/// mode and leaves the body dithering with no explanation.</summary>
|
||||||
|
public static Shader FindBlendShader(Renderer[] _renderers)
|
||||||
|
{
|
||||||
|
if (BlendShader != null)
|
||||||
|
{
|
||||||
|
return BlendShader;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (Renderer renderer in _renderers)
|
||||||
|
{
|
||||||
|
if (renderer == null || renderer is ParticleSystemRenderer)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Material[] materials = renderer.sharedMaterials;
|
||||||
|
if (materials == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach (Material material in materials)
|
||||||
|
{
|
||||||
|
if (material == null || material.shader == null || !CanBlendInPlace(material))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
BlendShader = material.shader;
|
||||||
|
Debug.Log("[NecromancerTome] GhostTraderPatch: blend shader taken off the model: '" +
|
||||||
|
BlendShader.name + "'");
|
||||||
|
return BlendShader;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BlendShader = Shader.Find("Game/Autodesk");
|
||||||
|
Debug.Log("[NecromancerTome] GhostTraderPatch: no blend-capable material on this model; " +
|
||||||
|
"the shader was looked up by name instead and came back " +
|
||||||
|
(BlendShader != null ? BlendShader.name : "NULL - the body stays dithered"));
|
||||||
|
return BlendShader;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether this material can be made see-through where it stands: somewhere to
|
||||||
|
/// put an alpha, and a blend mode to make the alpha mean something. The body fails both
|
||||||
|
/// halves and the hair passes both - which is the whole difference between them, and the
|
||||||
|
/// reason this is a capability test rather than a check on the shader's name.</summary>
|
||||||
|
public static bool CanBlendInPlace(Material _material)
|
||||||
|
{
|
||||||
|
if (!_material.HasProperty("_SrcBlend") || !_material.HasProperty("_DstBlend"))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
foreach (string hint in TintNameHints)
|
||||||
|
{
|
||||||
|
if (_material.HasProperty(hint))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The body, re-shaded onto the hair's shader so it can blend the way the hair does, with
|
||||||
|
/// the black-and-white albedo it would have had anyway.
|
||||||
|
///
|
||||||
|
/// ONLY WHAT IS UNDERSTOOD IS CARRIED OVER - albedo and normal map. The body's third map,
|
||||||
|
/// _RMOE, is a packed roughness/metallic/occlusion/emission texture, and the destination
|
||||||
|
/// shader's _MetallicGlossMap expects metallic in R and smoothness in A: the channels do
|
||||||
|
/// not line up, and wiring them by hope is exactly how a character comes out looking like
|
||||||
|
/// wet plastic or a negative. It is left empty and the two floats are set by hand instead
|
||||||
|
/// - no metal, barely any gloss, which is what a dead man in black and white should be.
|
||||||
|
///
|
||||||
|
/// This is a real trade and worth stating plainly: the body loses the character shader's
|
||||||
|
/// own lighting response and gets standard lighting instead. In exchange it can actually
|
||||||
|
/// be translucent rather than dithered. If it reads wrong in game, `necroghost dither` is
|
||||||
|
/// one line away and nothing has to be rebuilt.
|
||||||
|
/// </summary>
|
||||||
|
public static Material MakeBlendMaterial(Material _source, Texture2D _albedo)
|
||||||
|
{
|
||||||
|
Material blend = new Material(BlendShader);
|
||||||
|
blend.SetTexture("_MainTex", _albedo);
|
||||||
|
|
||||||
|
Texture normal = FindTexture(_source, NormalNameHints);
|
||||||
|
if (normal != null && blend.HasProperty("_BumpMap"))
|
||||||
|
{
|
||||||
|
blend.SetTexture("_BumpMap", normal);
|
||||||
|
}
|
||||||
|
MakeMatte(blend);
|
||||||
|
// Standard's own "Fade" setting. The shader branches on keywords rather than on this
|
||||||
|
// float, and ApplyTransparency sets those - but a material inspected later with its
|
||||||
|
// mode still reading "Opaque" is a trap for whoever looks next.
|
||||||
|
if (blend.HasProperty("_Mode"))
|
||||||
|
{
|
||||||
|
blend.SetFloat("_Mode", 2f);
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyTransparency(blend);
|
||||||
|
return blend;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Takes the shine off (user request 2026-09-14, once the body was blending properly:
|
||||||
|
/// "слишком оно бликует, я бы вообще сделал матовым"). Applied to EVERY ghost material, not just the
|
||||||
|
/// re-shaded body: the hair is drawn by the same shader and was keeping its own shine, and
|
||||||
|
/// a matte body under a glossy beard would have read worse than either.
|
||||||
|
///
|
||||||
|
/// THREE THINGS, BECAUSE THEY FAIL DIFFERENTLY:
|
||||||
|
///
|
||||||
|
/// - _Glossiness (smoothness) to 0. This one is plain shader maths and always works -
|
||||||
|
/// the surface becomes maximally rough, and a rough surface has no tight highlight.
|
||||||
|
/// It is the load-bearing half of this method.
|
||||||
|
/// - _SpecularHighlights / _GlossyReflections to 0 WITH their keywords. These are the
|
||||||
|
/// Standard shader's [ToggleOff] pair: the float is only what the inspector shows, the
|
||||||
|
/// shader branches on _SPECULARHIGHLIGHTS_OFF / _GLOSSYREFLECTIONS_OFF, so setting the
|
||||||
|
/// float alone does nothing at all. They kill the direct highlight and the reflection
|
||||||
|
/// probe contribution outright.
|
||||||
|
/// - The gloss MAPS, cleared if anything is in them. A map wins over the float: leave
|
||||||
|
/// one in place and _Glossiness is ignored, which is the silent way for all of this to
|
||||||
|
/// appear to do nothing. They are empty on these traders today - this is for the next
|
||||||
|
/// model that is not.
|
||||||
|
///
|
||||||
|
/// HONEST LIMIT: those two keywords are `shader_feature`, which Unity strips at build time
|
||||||
|
/// if no material in the game ships with them set. If they were stripped, EnableKeyword is
|
||||||
|
/// a no-op and only the roughness half lands. That is why the roughness half is not
|
||||||
|
/// treated as a fallback but as the main lever - and why this is not attempted at all on
|
||||||
|
/// the body's own "Game/Character" shader, which has none of these properties: its shine
|
||||||
|
/// lives inside the packed _RMOE texture and would have to be repainted, not switched off.
|
||||||
|
/// </summary>
|
||||||
|
public static void MakeMatte(Material _material)
|
||||||
|
{
|
||||||
|
if (_material.HasProperty("_Glossiness"))
|
||||||
|
{
|
||||||
|
_material.SetFloat("_Glossiness", 0f);
|
||||||
|
}
|
||||||
|
if (_material.HasProperty("_GlossMapScale"))
|
||||||
|
{
|
||||||
|
_material.SetFloat("_GlossMapScale", 0f);
|
||||||
|
}
|
||||||
|
if (_material.HasProperty("_Metallic"))
|
||||||
|
{
|
||||||
|
_material.SetFloat("_Metallic", 0f);
|
||||||
|
}
|
||||||
|
if (_material.HasProperty("_SpecularHighlights"))
|
||||||
|
{
|
||||||
|
_material.SetFloat("_SpecularHighlights", 0f);
|
||||||
|
_material.EnableKeyword("_SPECULARHIGHLIGHTS_OFF");
|
||||||
|
}
|
||||||
|
if (_material.HasProperty("_GlossyReflections"))
|
||||||
|
{
|
||||||
|
_material.SetFloat("_GlossyReflections", 0f);
|
||||||
|
_material.EnableKeyword("_GLOSSYREFLECTIONS_OFF");
|
||||||
|
}
|
||||||
|
ClearTexture(_material, "_SpecGlossMap");
|
||||||
|
ClearTexture(_material, "_MetallicGlossMap");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Empties a texture slot if this material has one and it is not already empty.
|
||||||
|
/// </summary>
|
||||||
|
public static void ClearTexture(Material _material, string _property)
|
||||||
|
{
|
||||||
|
if (_material.HasProperty(_property) && _material.GetTexture(_property) != null)
|
||||||
|
{
|
||||||
|
_material.SetTexture(_property, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>First texture among these property names that this material actually has
|
||||||
|
/// something in.</summary>
|
||||||
|
public static Texture FindTexture(Material _source, string[] _hints)
|
||||||
|
{
|
||||||
|
foreach (string hint in _hints)
|
||||||
|
{
|
||||||
|
if (_source.HasProperty(hint))
|
||||||
|
{
|
||||||
|
Texture texture = _source.GetTexture(hint);
|
||||||
|
if (texture != null)
|
||||||
|
{
|
||||||
|
return texture;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// This material's ghost version. Two shapes, and which one it takes is decided by what
|
||||||
|
/// the material can do rather than by what it is called:
|
||||||
|
///
|
||||||
|
/// - CAN be made see-through where it stands (the hair) -> a clone with the SAME shader
|
||||||
|
/// and only its albedo swapped for a black-and-white copy. Lighting, normal maps,
|
||||||
|
/// specular, skinning - all still the game's own.
|
||||||
|
/// - CANNOT (the body: no colour, no blend mode) -> under BodyMode.Blend it is re-shaded
|
||||||
|
/// onto the shader the hair uses, which can. Under BodyMode.Dither it takes the clone
|
||||||
|
/// path anyway and gets the _Fade lever, which works but throws pixels away.
|
||||||
|
///
|
||||||
|
/// The albedo is desaturated first either way, because both shapes need it and the result
|
||||||
|
/// is cached per source texture - the re-shaded body is not paying for a second readback.
|
||||||
|
/// </summary>
|
||||||
|
public static Material MakeGreyMaterial(Material _source, ref bool _changed)
|
||||||
|
{
|
||||||
|
if (_source == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ProbeShaderOnce(_source);
|
||||||
|
|
||||||
|
string albedoProperty = FindAlbedoProperty(_source);
|
||||||
|
Texture2D desaturated = albedoProperty != null
|
||||||
|
? Desaturate(_source.GetTexture(albedoProperty))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Re-shading without an albedo to hand over would produce an untextured silhouette -
|
||||||
|
// the exact failure this patch already shipped once, on 2026-09-13. If the texture
|
||||||
|
// could not be read, the body keeps its own shader and stays dithered instead, and
|
||||||
|
// the lever count in the log is what says so.
|
||||||
|
if (BodyMode == BodyOpacityMode.Blend && BlendShader != null && desaturated != null &&
|
||||||
|
!CanBlendInPlace(_source))
|
||||||
|
{
|
||||||
|
_changed = true;
|
||||||
|
return MakeBlendMaterial(_source, desaturated);
|
||||||
|
}
|
||||||
|
|
||||||
|
Material grey = new Material(_source);
|
||||||
|
MakeMatte(grey);
|
||||||
|
if (ApplyTransparency(grey))
|
||||||
|
{
|
||||||
|
_changed = true;
|
||||||
|
}
|
||||||
|
if (desaturated == null)
|
||||||
|
{
|
||||||
|
// Nothing to desaturate on this material; hand back the clone unchanged rather
|
||||||
|
// than dropping the renderer's material entirely.
|
||||||
|
return grey;
|
||||||
|
}
|
||||||
|
|
||||||
|
grey.SetTexture(albedoProperty, desaturated);
|
||||||
|
_changed = true;
|
||||||
|
return grey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dials this material's opacity down to GhostAlpha. THREE levers, each conditional,
|
||||||
|
/// because a trader is drawn by two different shaders that have almost nothing in common -
|
||||||
|
/// and the first version of this method only had the levers the hair happens to own, which
|
||||||
|
/// is why the user reported on 2026-09-14 that the console command "управляет только
|
||||||
|
/// прозрачностью бороды". The probe had already printed the reason, one line each:
|
||||||
|
///
|
||||||
|
/// shader 'Game/Character' - albedo: _Albedo -> HD_Rekt 4096x4096;
|
||||||
|
/// tint property: <none>; blend-mode properties present: False
|
||||||
|
/// shader 'Game/Autodesk' - albedo: _MainTex -> HD_Rekt_Hair 2048x2048;
|
||||||
|
/// tint property: _Color (alpha 1); blend-mode properties present: True
|
||||||
|
///
|
||||||
|
/// The BODY has no colour property and no blend mode at all. Two of the three levers below
|
||||||
|
/// simply do not exist on it, and no value of GhostAlpha was ever going to reach it.
|
||||||
|
///
|
||||||
|
/// - A COLOUR with an alpha channel (_Color and friends). The hair's lever; the body
|
||||||
|
/// does not have one.
|
||||||
|
/// - THE FADE FLOAT (_Fade). The body's lever, and the game's own: EntityModel.SetFade
|
||||||
|
/// writes this exact property and guards it with a check for the "Game/Character"
|
||||||
|
/// shader by name, so this is not a property we found and hoped about - it is the one
|
||||||
|
/// the engine itself fades these very models with. Nothing else in the body's property
|
||||||
|
/// list can carry an opacity: the rest is _Albedo/_Normal/_RMOE, an alpha-CUTOUT
|
||||||
|
/// cluster (_AlphaCut, _AlphaCutoff, _SoftAlphaCutoff, _AlphaSoftness - a dissolve,
|
||||||
|
/// which punches holes rather than making glass), _EmissiveColor, and flags.
|
||||||
|
/// - THE BLEND MODE (_SrcBlend/_DstBlend). An opaque shader ignores any alpha it is
|
||||||
|
/// handed, so without this the COLOUR lever does nothing visible - the same wall the
|
||||||
|
/// mod's first transparency attempt hit back on 2026-08-28 with the summoned pets.
|
||||||
|
/// The recipe is the game's own: MeshDescription.SetupMaterialWithBlendMode writes
|
||||||
|
/// exactly these properties plus _ZWrite and the _ALPHABLEND_ON keyword. The _Fade
|
||||||
|
/// lever needs none of it - the shader does its own fading internally, which is
|
||||||
|
/// exactly why it exists.
|
||||||
|
///
|
||||||
|
/// _ZWrite IS LEFT ALONE ON PURPOSE, AND AT 10% THAT IS THE WHOLE BALANCE. The usual
|
||||||
|
/// transparency recipe switches depth writing off, which is right for glass and wrong for
|
||||||
|
/// a person: without it every surface of the model blends over every other one, and the
|
||||||
|
/// trader becomes teeth through cheeks, eyeballs through eyelids, the far arm through the
|
||||||
|
/// chest - exactly the "сломанная модель" the user does not want. With it on, the depth
|
||||||
|
/// test keeps only the nearest surface and that one surface blends with the WORLD behind
|
||||||
|
/// him. So he goes see-through without coming apart: a ghost, not a mess.
|
||||||
|
///
|
||||||
|
/// At 99% this cost nothing because there was nothing to see through; at 90% it is the
|
||||||
|
/// reason the effect is usable at all. Note that it is not set here either way - these
|
||||||
|
/// shaders write depth by default, and leaving the property untouched is what keeps it.
|
||||||
|
///
|
||||||
|
/// Whatever is missing is reported by the probe rather than silently skipped - if neither
|
||||||
|
/// lever exists on these shaders, the traders stay solid black-and-white and the log says
|
||||||
|
/// why.
|
||||||
|
/// </summary>
|
||||||
|
public static bool ApplyTransparency(Material _material)
|
||||||
|
{
|
||||||
|
bool touched = false;
|
||||||
|
|
||||||
|
foreach (string hint in TintNameHints)
|
||||||
|
{
|
||||||
|
if (!_material.HasProperty(hint))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Color tint = _material.GetColor(hint);
|
||||||
|
float baseAlpha = tint.a;
|
||||||
|
tint.a = baseAlpha * GhostAlpha;
|
||||||
|
_material.SetColor(hint, tint);
|
||||||
|
TintedMaterials.Add(new GhostMaterial
|
||||||
|
{
|
||||||
|
Material = _material,
|
||||||
|
Property = hint,
|
||||||
|
IsColour = true,
|
||||||
|
BaseValue = baseAlpha
|
||||||
|
});
|
||||||
|
touched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (string hint in FadeNameHints)
|
||||||
|
{
|
||||||
|
if (!_material.HasProperty(hint))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// A base of 0 would mean the material is already fully faded out, which no
|
||||||
|
// standing trader is - it means the property is sitting at a default nobody set.
|
||||||
|
// Multiplying by it would make him vanish outright and no value of GhostAlpha
|
||||||
|
// could bring him back, so it is read as "solid" instead.
|
||||||
|
float baseFade = _material.GetFloat(hint);
|
||||||
|
if (baseFade <= 0f)
|
||||||
|
{
|
||||||
|
baseFade = 1f;
|
||||||
|
}
|
||||||
|
_material.SetFloat(hint, baseFade * GhostAlpha);
|
||||||
|
TintedMaterials.Add(new GhostMaterial
|
||||||
|
{
|
||||||
|
Material = _material,
|
||||||
|
Property = hint,
|
||||||
|
IsColour = false,
|
||||||
|
BaseValue = baseFade
|
||||||
|
});
|
||||||
|
touched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_material.HasProperty("_SrcBlend") && _material.HasProperty("_DstBlend"))
|
||||||
|
{
|
||||||
|
_material.SetFloat("_SrcBlend", (float)BlendMode.SrcAlpha);
|
||||||
|
_material.SetFloat("_DstBlend", (float)BlendMode.OneMinusSrcAlpha);
|
||||||
|
_material.EnableKeyword("_ALPHABLEND_ON");
|
||||||
|
_material.renderQueue = (int)RenderQueue.Transparent;
|
||||||
|
touched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return touched;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Pushes the current GhostAlpha onto every trader already standing in the world,
|
||||||
|
/// and returns how many materials took it. Traders that spawn later need nothing from this
|
||||||
|
/// - ApplyTransparency reads the same field on the way past.
|
||||||
|
///
|
||||||
|
/// Unity's fake null is the reason for the rebuild-in-place rather than a simple loop: a
|
||||||
|
/// destroyed Material compares equal to null but is still a live list entry, and touching
|
||||||
|
/// it throws. Walking backwards and dropping those as we go keeps the list from growing
|
||||||
|
/// across a session of traders streaming in and out.</summary>
|
||||||
|
public static int Retint()
|
||||||
|
{
|
||||||
|
int applied = 0;
|
||||||
|
for (int i = TintedMaterials.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
GhostMaterial entry = TintedMaterials[i];
|
||||||
|
if (entry.Material == null)
|
||||||
|
{
|
||||||
|
TintedMaterials.RemoveAt(i);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry.IsColour)
|
||||||
|
{
|
||||||
|
Color tint = entry.Material.GetColor(entry.Property);
|
||||||
|
tint.a = entry.BaseValue * GhostAlpha;
|
||||||
|
entry.Material.SetColor(entry.Property, tint);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
entry.Material.SetFloat(entry.Property, entry.BaseValue * GhostAlpha);
|
||||||
|
}
|
||||||
|
applied++;
|
||||||
|
}
|
||||||
|
return applied;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Name of the texture property holding this material's albedo, or null. Walks
|
||||||
|
/// the shader's declared properties rather than assuming a name - the body and the hair of
|
||||||
|
/// the same trader disagree about it.</summary>
|
||||||
|
public static string FindAlbedoProperty(Material _source)
|
||||||
|
{
|
||||||
|
Shader shader = _source.shader;
|
||||||
|
if (shader == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (string hint in AlbedoNameHints)
|
||||||
|
{
|
||||||
|
if (_source.HasProperty(hint) && _source.GetTexture(hint) != null)
|
||||||
|
{
|
||||||
|
return hint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int count = shader.GetPropertyCount();
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
if (shader.GetPropertyType(i) != ShaderPropertyType.Texture)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
string name = shader.GetPropertyName(i);
|
||||||
|
if (_source.GetTexture(name) != null)
|
||||||
|
{
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Black-and-white copy of a texture. See the class comment for why this goes
|
||||||
|
/// through a RenderTexture instead of reading the source directly.</summary>
|
||||||
|
public static Texture2D Desaturate(Texture _source)
|
||||||
|
{
|
||||||
|
if (_source == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (GreyTextures.TryGetValue(_source, out Texture2D cached))
|
||||||
|
{
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
Texture2D grey = null;
|
||||||
|
RenderTexture rt = null;
|
||||||
|
RenderTexture previous = RenderTexture.active;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
rt = RenderTexture.GetTemporary(_source.width, _source.height, 0,
|
||||||
|
RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB);
|
||||||
|
Graphics.Blit(_source, rt);
|
||||||
|
RenderTexture.active = rt;
|
||||||
|
|
||||||
|
grey = new Texture2D(_source.width, _source.height, TextureFormat.RGBA32, false);
|
||||||
|
grey.ReadPixels(new Rect(0f, 0f, _source.width, _source.height), 0, 0);
|
||||||
|
|
||||||
|
Color32[] pixels = grey.GetPixels32();
|
||||||
|
for (int i = 0; i < pixels.Length; i++)
|
||||||
|
{
|
||||||
|
Color32 p = pixels[i];
|
||||||
|
byte luma = (byte)((p.r * 299 + p.g * 587 + p.b * 114) / 1000);
|
||||||
|
p.r = luma;
|
||||||
|
p.g = luma;
|
||||||
|
p.b = luma;
|
||||||
|
pixels[i] = p;
|
||||||
|
}
|
||||||
|
grey.SetPixels32(pixels);
|
||||||
|
grey.Apply(false, false);
|
||||||
|
}
|
||||||
|
catch (System.Exception e)
|
||||||
|
{
|
||||||
|
Debug.LogError("[NecromancerTome] GhostTraderPatch: could not desaturate '" + _source.name + "': " + e.Message);
|
||||||
|
grey = null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
RenderTexture.active = previous;
|
||||||
|
if (rt != null)
|
||||||
|
{
|
||||||
|
RenderTexture.ReleaseTemporary(rt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cached even on failure (as null) so an unreadable texture is not retried per trader.
|
||||||
|
GreyTextures[_source] = grey;
|
||||||
|
return grey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Says, once per distinct source shader, which texture properties it has and
|
||||||
|
/// which carry anything. This is what told us the body uses _Albedo and the hair _MainTex;
|
||||||
|
/// it stays in because the next trader built by a different character system will announce
|
||||||
|
/// itself the same way.</summary>
|
||||||
|
public static void ProbeShaderOnce(Material _source)
|
||||||
|
{
|
||||||
|
Shader shader = _source.shader;
|
||||||
|
string shaderName = shader != null ? shader.name : "<null shader>";
|
||||||
|
if (!ProbedShaders.Add(shaderName) || shader == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// EVERY property, not just the textures. The texture-only version answered the
|
||||||
|
// "where is the albedo" question; this one has to answer "is there anything here that
|
||||||
|
// can make it transparent at all", and that lives among the floats and colours.
|
||||||
|
System.Text.StringBuilder sb = new System.Text.StringBuilder();
|
||||||
|
int count = shader.GetPropertyCount();
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
string name = shader.GetPropertyName(i);
|
||||||
|
ShaderPropertyType type = shader.GetPropertyType(i);
|
||||||
|
sb.Append(sb.Length > 0 ? ", " : "").Append(name).Append(':').Append(type);
|
||||||
|
if (type == ShaderPropertyType.Texture)
|
||||||
|
{
|
||||||
|
sb.Append(_source.GetTexture(name) != null ? "=set" : "=empty");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
string chosen = FindAlbedoProperty(_source);
|
||||||
|
Texture chosenTexture = chosen != null ? _source.GetTexture(chosen) : null;
|
||||||
|
string tint = "<none>";
|
||||||
|
foreach (string hint in TintNameHints)
|
||||||
|
{
|
||||||
|
if (_source.HasProperty(hint))
|
||||||
|
{
|
||||||
|
tint = hint + " (alpha " + _source.GetColor(hint).a.ToString("0.###") + ")";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
string fade = "<none>";
|
||||||
|
foreach (string hint in FadeNameHints)
|
||||||
|
{
|
||||||
|
if (_source.HasProperty(hint))
|
||||||
|
{
|
||||||
|
fade = hint + " (" + _source.GetFloat(hint).ToString("0.###") + ")";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bool canBlend = _source.HasProperty("_SrcBlend") && _source.HasProperty("_DstBlend");
|
||||||
|
|
||||||
|
Debug.Log("[NecromancerTome] GhostTraderPatch: shader '" + shaderName + "' properties: " +
|
||||||
|
(sb.Length > 0 ? sb.ToString() : "<none>"));
|
||||||
|
Debug.Log("[NecromancerTome] GhostTraderPatch: shader '" + shaderName + "' - albedo: " + (chosen ?? "<none>") +
|
||||||
|
" -> " + (chosenTexture != null ? chosenTexture.name + " " + chosenTexture.width + "x" + chosenTexture.height : "<none>") +
|
||||||
|
"; tint property: " + tint + "; fade property: " + fade +
|
||||||
|
"; blend-mode properties present: " + canBlend);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,15 @@ namespace NecromancerTome
|
|||||||
var harmony = new Harmony("necromancertome.harmony");
|
var harmony = new Harmony("necromancertome.harmony");
|
||||||
harmony.PatchAll(Assembly.GetExecutingAssembly());
|
harmony.PatchAll(Assembly.GetExecutingAssembly());
|
||||||
PetFollowPatch.Init();
|
PetFollowPatch.Init();
|
||||||
|
// SpatialVaultPersistence needs NO Init(): it is four Harmony postfixes that PatchAll
|
||||||
|
// above already attached. It used to register a WorldShuttingDown handler to clear its
|
||||||
|
// cache - that handler is exactly what wiped the vault on every clean exit, because
|
||||||
|
// that event fires BEFORE the final player save (GameManager.SaveAndCleanupWorld:
|
||||||
|
// event at IL_0026, SaveLocalPlayerData at IL_00c4). Freshness is decided by what was
|
||||||
|
// read instead; see that file.
|
||||||
|
// Traders rendered as washed-out ghosts (request 2026-09-13). Polls rather than
|
||||||
|
// hooks a spawn event - see that file for why the SDCS-built trader forces it.
|
||||||
|
GhostTraderPatch.Init();
|
||||||
// PyramidWardPatch.cs's TEFeaturePyramidWard needs no Init() call - it's discovered
|
// PyramidWardPatch.cs's TEFeaturePyramidWard needs no Init() call - it's discovered
|
||||||
// automatically by the engine's own TileEntityCompositeData reflection scan (see that
|
// automatically by the engine's own TileEntityCompositeData reflection scan (see that
|
||||||
// file's class doc comment), not registered here like PetFollowPatch's UnityUpdate hook.
|
// file's class doc comment), not registered here like PetFollowPatch's UnityUpdate hook.
|
||||||
|
|||||||
@@ -114,6 +114,10 @@ namespace NecromancerTome
|
|||||||
// buff-trigger vocabulary, since there's no "for as long as this XUiC_Timer is open"
|
// buff-trigger vocabulary, since there's no "for as long as this XUiC_Timer is open"
|
||||||
// trigger to hang it off - this IS that lifecycle.
|
// trigger to hang it off - this IS that lifecycle.
|
||||||
player.Buffs.AddBuff(ChannelBuffName);
|
player.Buffs.AddBuff(ChannelBuffName);
|
||||||
|
// The world drains to black and white for the length of the channel - see
|
||||||
|
// ChannelVision.cs. Started here rather than from the buff so both of this mod's
|
||||||
|
// channels share one definition of what channelling looks like.
|
||||||
|
ChannelVision.Begin(player);
|
||||||
|
|
||||||
TimerEventData timerData = new TimerEventData
|
TimerEventData timerData = new TimerEventData
|
||||||
{
|
{
|
||||||
@@ -136,6 +140,7 @@ namespace NecromancerTome
|
|||||||
{
|
{
|
||||||
Debug.Log("[NecromancerTome] PortalStonePatch: channel cancelled for " + itemName + ", owner=" + player.entityId);
|
Debug.Log("[NecromancerTome] PortalStonePatch: channel cancelled for " + itemName + ", owner=" + player.entityId);
|
||||||
player.Buffs.RemoveBuff(ChannelBuffName);
|
player.Buffs.RemoveBuff(ChannelBuffName);
|
||||||
|
ChannelVision.End(player);
|
||||||
};
|
};
|
||||||
|
|
||||||
string labelKey = (itemName == BlueStoneName) ? "thrownStonePortalBlueChanneling" : "thrownStonePortalBlackChanneling";
|
string labelKey = (itemName == BlueStoneName) ? "thrownStonePortalBlueChanneling" : "thrownStonePortalBlackChanneling";
|
||||||
@@ -151,6 +156,9 @@ namespace NecromancerTome
|
|||||||
{
|
{
|
||||||
Debug.Log("[NecromancerTome] PortalStonePatch: channel completed for " + itemName + ", owner=" + player.entityId);
|
Debug.Log("[NecromancerTome] PortalStonePatch: channel completed for " + itemName + ", owner=" + player.entityId);
|
||||||
player.Buffs.RemoveBuff(ChannelBuffName);
|
player.Buffs.RemoveBuff(ChannelBuffName);
|
||||||
|
// Before the teleport rather than after: the colour is already on its way back while
|
||||||
|
// the player arrives, instead of starting to return only once he is standing there.
|
||||||
|
ChannelVision.End(player);
|
||||||
if (itemName == BlackStoneName)
|
if (itemName == BlackStoneName)
|
||||||
{
|
{
|
||||||
ShowBlackPortalConfirmation(player);
|
ShowBlackPortalConfirmation(player);
|
||||||
|
|||||||
@@ -30,19 +30,15 @@ namespace NecromancerTome
|
|||||||
/// this rounds to 0 - deliberately left as-is, not special-cased away, matching the
|
/// this rounds to 0 - deliberately left as-is, not special-cased away, matching the
|
||||||
/// Knife's own "0 at 0 kills is a feature, not a bug" precedent - a tooltip explains it
|
/// Knife's own "0 at 0 kills is a feature, not a bug" precedent - a tooltip explains it
|
||||||
/// instead of silently opening a useless empty window.
|
/// instead of silently opening a useless empty window.
|
||||||
/// - PERSISTENCE - the one thing NOT fully solved here, flagged rather than silently
|
/// - PERSISTENCE - solved 2026-09-13, see SpatialVaultPersistence.cs. It was NOT solved
|
||||||
/// assumed: the Bag backing each player's vault lives in a plain in-memory
|
/// when this item shipped, and that shortfall is exactly what became the mod's first
|
||||||
/// Dictionary<int, Bag> in this file (PlayerVaults below), keyed by entityId. This
|
/// Nexus bug report (youkia96581, 11 Sep 2026: "Items stored in the space bracelet will
|
||||||
/// is reliable for as long as the game process keeps running (survives death/respawn/
|
/// disappear after leaving the game and going online again"). PlayerVaults below is still
|
||||||
/// relogging within one play session, confirmed by how a static field behaves) but has
|
/// the in-memory, entityId-keyed Dictionary it always was, but it is now only the session
|
||||||
/// NOT been wired into any save/load system - closing the game entirely and reloading the
|
/// cache: the durable copy is written into the player's own PlayerDataFile, alongside the
|
||||||
/// save later will NOT bring the vault's contents back (no persistence file, no hook into
|
/// backpack, by four postfixes on FromPlayer/ToPlayer/Write/Read. Read that file's comment
|
||||||
/// PersistentPlayerData or a world-save event). Building real cross-session persistence
|
/// for why there ("почему не сделать принцип как у ящика?" - because a chest's items live
|
||||||
/// (a custom save file + ModEvents.GameSave/Load hooks, or piggybacking on an owned
|
/// in a chunk, and the bracelet's closest equivalent home is its owner's save data).
|
||||||
/// world entity the way the summoned pets do - unconfirmed whether THOSE actually survive
|
|
||||||
/// a full restart either) is real, separate follow-up work, not attempted here. Treat
|
|
||||||
/// this like a session-scoped stash until that's built and confirmed - don't rely on it
|
|
||||||
/// across game restarts yet.
|
|
||||||
///
|
///
|
||||||
/// REGULAR ATTACK (index 0) - knock back + slow whatever zombie the crosshair is aimed at:
|
/// REGULAR ATTACK (index 0) - knock back + slow whatever zombie the crosshair is aimed at:
|
||||||
/// - Same raycast mechanism HarmonySrc/ThiefLoopPatch.cs already established for
|
/// - Same raycast mechanism HarmonySrc/ThiefLoopPatch.cs already established for
|
||||||
@@ -71,8 +67,9 @@ namespace NecromancerTome
|
|||||||
public const float MaxRange = 50f;
|
public const float MaxRange = 50f;
|
||||||
public const float ShoveDistance = 6f;
|
public const float ShoveDistance = 6f;
|
||||||
|
|
||||||
/// <summary>See the class-level comment above for exactly what this does and doesn't
|
/// <summary>Session cache only - the durable copy lives on disk, see
|
||||||
/// guarantee - session-scoped only, not yet saved/loaded across game restarts.</summary>
|
/// SpatialVaultPersistence.cs. Cleared on WorldShuttingDown so a different save loaded
|
||||||
|
/// afterwards cannot inherit this world's vault through a recycled entityId.</summary>
|
||||||
public static readonly Dictionary<int, Bag> PlayerVaults = new Dictionary<int, Bag>();
|
public static readonly Dictionary<int, Bag> PlayerVaults = new Dictionary<int, Bag>();
|
||||||
|
|
||||||
public static bool Prefix(ItemActionData _actionData, bool _bReleased)
|
public static bool Prefix(ItemActionData _actionData, bool _bReleased)
|
||||||
@@ -93,13 +90,23 @@ namespace NecromancerTome
|
|||||||
|
|
||||||
if (_actionData.indexInEntityOfAction == 1)
|
if (_actionData.indexInEntityOfAction == 1)
|
||||||
{
|
{
|
||||||
OpenVault(player);
|
// Not when this very press just cancelled a block pickup and opened the vault on
|
||||||
|
// the way - see SpatialVaultPickup.ConsumeCancelOpen. Down and up are one press.
|
||||||
|
if (!SpatialVaultPickup.ConsumeCancelOpen())
|
||||||
|
{
|
||||||
|
OpenVault(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// The regular attack takes the block under the crosshair into the vault from
|
||||||
|
// 2026-09-14 - see SpatialVaultPickupPatch.cs. Before that it did nothing at all,
|
||||||
|
// by direct user request of 2026-08-30 ("пусть тогда обычная атака у
|
||||||
|
// пространственного браслета не делает ничего"), after the knockback+slow version did not
|
||||||
|
// visibly do anything in testing. ShoveZombieAtCrosshair is kept below, unused,
|
||||||
|
// because that abandoned version was never shown to be WRONG - only invisible.
|
||||||
|
SpatialVaultPickup.Begin(player);
|
||||||
}
|
}
|
||||||
// else: regular attack (index 0) deliberately does nothing, per direct user request
|
|
||||||
// 2026-08-30 ("пусть тогда обычная атака у пространственного браслета не делает
|
|
||||||
// ничего") after the knockback+slow version didn't visibly do anything in testing -
|
|
||||||
// rather than debug ShoveZombieAtCrosshair blind (kept below, unused, in case this
|
|
||||||
// gets revisited), just absorb the click silently.
|
|
||||||
|
|
||||||
// Skip ItemActionEat's own logic entirely - the click has been fully handled here.
|
// Skip ItemActionEat's own logic entirely - the click has been fully handled here.
|
||||||
return false;
|
return false;
|
||||||
@@ -118,7 +125,17 @@ namespace NecromancerTome
|
|||||||
|
|
||||||
if (!PlayerVaults.TryGetValue(player.entityId, out Bag bag))
|
if (!PlayerVaults.TryGetValue(player.entityId, out Bag bag))
|
||||||
{
|
{
|
||||||
bag = new Bag(slotCount);
|
// Normally a restored vault is already here - the ToPlayer postfix puts it in
|
||||||
|
// when the game applies the save file to the spawning player. LastLoadedVault is
|
||||||
|
// the safety net for when that chain does not complete: opening the bracelet must
|
||||||
|
// never be what silently starts an empty vault over a saved one. Only then is a
|
||||||
|
// genuinely new bag created.
|
||||||
|
bag = SpatialVaultPersistence.LastLoadedVault ?? new Bag(slotCount);
|
||||||
|
if (bag == SpatialVaultPersistence.LastLoadedVault)
|
||||||
|
{
|
||||||
|
Debug.Log("[NecromancerTome] SpatialVaultPatch: session cache was empty, adopted the last loaded vault (" +
|
||||||
|
bag.SlotCount + " slots, " + bag.GetUsedSlotCount() + " used)");
|
||||||
|
}
|
||||||
PlayerVaults[player.entityId] = bag;
|
PlayerVaults[player.entityId] = bag;
|
||||||
}
|
}
|
||||||
else if (bag.SlotCount < slotCount)
|
else if (bag.SlotCount < slotCount)
|
||||||
@@ -134,7 +151,21 @@ namespace NecromancerTome
|
|||||||
|
|
||||||
Debug.Log("[NecromancerTome] SpatialVaultPatch: owner=" + player.entityId + " opened vault, " + slotCount + " slots (Necromancy level " + level + ")");
|
Debug.Log("[NecromancerTome] SpatialVaultPatch: owner=" + player.entityId + " opened vault, " + slotCount + " slots (Necromancy level " + level + ")");
|
||||||
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player);
|
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player);
|
||||||
XUiC_BagStorageWindowGroup.Open(playerUI.xui, player, bag, LootContainer.GetLootContainer("roboticDrone"), Localization.Get("braceletSpatialVaultWindowTitle"));
|
// The trailing callbacks are vanilla's own optional parameters (_onModified, _onClose).
|
||||||
|
// _onModified is not needed: the vault lives in PlayerVaults, and PlayerDataFile's
|
||||||
|
// FromPlayer postfix reads it fresh every time the game saves the player, so there is
|
||||||
|
// nothing to flush per item move. _onClose asks for a player-data save right away, so
|
||||||
|
// closing the window is a commit point rather than waiting for the next autosave -
|
||||||
|
// SaveLocalPlayerData is the game's own routine call and no-ops when saving is not
|
||||||
|
// active (which is the correct behaviour on a client, where the server owns the file).
|
||||||
|
XUiC_BagStorageWindowGroup.Open(
|
||||||
|
playerUI.xui,
|
||||||
|
player,
|
||||||
|
bag,
|
||||||
|
LootContainer.GetLootContainer("roboticDrone"),
|
||||||
|
Localization.Get("braceletSpatialVaultWindowTitle"),
|
||||||
|
null,
|
||||||
|
() => GameManager.Instance.SaveLocalPlayerData());
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void ShoveZombieAtCrosshair(EntityPlayerLocal player)
|
public static void ShoveZombieAtCrosshair(EntityPlayerLocal player)
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Text;
|
||||||
|
using HarmonyLib;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace NecromancerTome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Cross-restart persistence for the Spatial Bracelet's vault - the fix for the first bug
|
||||||
|
/// report the mod ever got on Nexus (youkia96581, 11 Sep 2026: "Items stored in the space
|
||||||
|
/// bracelet will disappear after leaving the game and going online again").
|
||||||
|
///
|
||||||
|
/// WHY IT LIVES IN THE PLAYER'S SAVE FILE - "почему не сделать принцип как у ящика?" (user,
|
||||||
|
/// 13.09.2026). Right question, and it decided the design. A chest keeps its items because
|
||||||
|
/// they live in a TileEntity, and a TileEntity belongs to a CHUNK: decompiled, `TileEntity`
|
||||||
|
/// has chunkPos and chunk fields and its ONLY constructor is TileEntity(Chunk). The game saves
|
||||||
|
/// and syncs the chunk; the container rides along. That is the whole trick - not a "storage
|
||||||
|
/// system" one can call, but a home in something the engine already persists. The bracelet has
|
||||||
|
/// no position and no chunk, so it got the closest equivalent for something personal: the
|
||||||
|
/// player's own save data, written right after everything vanilla writes, in the same file and
|
||||||
|
/// the same moment as the backpack.
|
||||||
|
///
|
||||||
|
/// THAT ALSO ANSWERS THE ID QUESTION ("у браслета, как и у ящика, наверняка есть id"). A
|
||||||
|
/// chest's id IS its position. An item has no per-instance id by default - ItemValue.type is
|
||||||
|
/// the item CLASS, identical on every bracelet - but ItemValue.Metadata would hold one and
|
||||||
|
/// genuinely round-trips through saves (ItemValue.Write writes it, ItemValue.ReadData reads it
|
||||||
|
/// back; both checked). Per-bracelet vaults are therefore buildable and deliberately not built:
|
||||||
|
/// keying by the item means losing the bracelet locks the items away forever even though they
|
||||||
|
/// are still in the save file, and it would let ten bracelets be ten warehouses. Keying by the
|
||||||
|
/// player - which storing them IN the player's file does for free - has neither problem.
|
||||||
|
///
|
||||||
|
/// THE FOUR HOOKS:
|
||||||
|
/// FromPlayer - live player -> file object: attach that player's vault to the file.
|
||||||
|
/// Write - file object -> bytes (Save to disk, or WriteNetwork to the wire, which is
|
||||||
|
/// literally Write + PlayerMetaInfo): append the vault blob.
|
||||||
|
/// Read - bytes -> file object: pull the vault back off the stream.
|
||||||
|
/// ToPlayer - file object -> live player: hand the vault back.
|
||||||
|
/// FromPlayer always reads the CURRENT vault, so there is no dirty flag and no save scheduling
|
||||||
|
/// to get wrong: whenever the game saves the player, it saves the vault.
|
||||||
|
///
|
||||||
|
/// ================================================================================
|
||||||
|
/// THE BUG THAT COST TWO TEST RUNS, AND WHY IT IS WORTH A BIG COMMENT
|
||||||
|
/// ================================================================================
|
||||||
|
/// Earlier versions cleared the session cache from a ModEvents.WorldShuttingDown handler, to
|
||||||
|
/// stop one save's vault leaking into the next. The user reported the vault kept losing its
|
||||||
|
/// contents, and the diagnostics printed the murder weapon in order:
|
||||||
|
///
|
||||||
|
/// INF SaveAndCleanupWorld
|
||||||
|
/// [NecromancerTome] world shutting down, dropped 1 in-memory vault(s)
|
||||||
|
/// [NecromancerTome] FromPlayer entity 171 - vault NONE
|
||||||
|
/// [NecromancerTome] Write - no vault attached (writes an EMPTY marker)
|
||||||
|
///
|
||||||
|
/// **WorldShuttingDown fires BEFORE the final player save, not after.** Confirmed in
|
||||||
|
/// GameManager.SaveAndCleanupWorld by decompilation rather than inferred from the log: the
|
||||||
|
/// event is invoked at IL_0026 and SaveLocalPlayerData() is called at IL_00c4, a hundred-odd
|
||||||
|
/// instructions later. So the handler emptied the cache, and the save that followed
|
||||||
|
/// faithfully recorded "this player has no vault" over the real one. Every clean exit wiped
|
||||||
|
/// the vault - which is exactly the symptom the Nexus report described, reintroduced by the
|
||||||
|
/// fix for it.
|
||||||
|
///
|
||||||
|
/// There is no documentation to have checked first: the community consensus is that the
|
||||||
|
/// official ModAPI is barebones and has no reference for event ordering, so the decompiler is
|
||||||
|
/// the only authority. Treat every ModEvent's position in the shutdown sequence as unknown
|
||||||
|
/// until read out of the method that invokes it.
|
||||||
|
///
|
||||||
|
/// TWO RULES CAME OUT OF IT, and both are load-bearing here:
|
||||||
|
///
|
||||||
|
/// 1. A RESTORE PATH MAY FAIL; IT MAY NEVER DELETE. An empty session cache is not evidence
|
||||||
|
/// that the player has no vault - it is the absence of evidence. LastLoadedVault below is
|
||||||
|
/// the safety net, so a broken restore chain costs a restore, not the data.
|
||||||
|
/// 2. FRESHNESS IS DECIDED BY WHAT WAS READ, NOT BY A TIMER. Cross-save leaking is now
|
||||||
|
/// prevented by ToPlayer being authoritative: a player file that was read and explicitly
|
||||||
|
/// carried no vault CLEARS the cache. Nothing has to be cleared "at the right moment"
|
||||||
|
/// any more, which is what made the old approach fragile in the first place.
|
||||||
|
/// </summary>
|
||||||
|
public static class SpatialVaultPersistence
|
||||||
|
{
|
||||||
|
/// <summary>Payload layout version, independent of the blob framing in
|
||||||
|
/// SpatialVaultBlobIO. An unknown version is skipped, not guessed at - the framing's
|
||||||
|
/// explicit length means we can always step over a payload we do not understand.</summary>
|
||||||
|
public const byte PayloadVersion = 1;
|
||||||
|
|
||||||
|
/// <summary>What a PlayerDataFile carries. A class rather than a bare Bag because its mere
|
||||||
|
/// PRESENCE is information: "this file has been read/filled, and the answer - including a
|
||||||
|
/// null Bag - is authoritative". ConditionalWeakTable cannot store null, so a null Bag
|
||||||
|
/// needs a wrapper to be expressible at all.</summary>
|
||||||
|
public class VaultSlot
|
||||||
|
{
|
||||||
|
public Bag Bag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Vault attached to a PlayerDataFile while it is being written, read or
|
||||||
|
/// converted. Weak, because PlayerDataFile objects are created fresh for every save and
|
||||||
|
/// every network packet and nothing here should keep one alive.</summary>
|
||||||
|
public static readonly ConditionalWeakTable<PlayerDataFile, VaultSlot> AttachedVaults =
|
||||||
|
new ConditionalWeakTable<PlayerDataFile, VaultSlot>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Last vault seen this session, kept outside the weak table. This is rule 1 above made
|
||||||
|
/// concrete: if the Read -> ToPlayer -> PlayerVaults chain ever fails to complete, the bag
|
||||||
|
/// is still here, so the next save writes the real contents instead of an empty marker.
|
||||||
|
///
|
||||||
|
/// SINGLE LOCAL PLAYER ONLY. There is one of these per process, so on a dedicated server
|
||||||
|
/// it would be one player's vault handed to whoever asked next. Every use is gated on the
|
||||||
|
/// player being an EntityPlayerLocal - which a dedicated server does not have, and a host
|
||||||
|
/// or single-player game has exactly one of.
|
||||||
|
/// </summary>
|
||||||
|
public static Bag LastLoadedVault;
|
||||||
|
|
||||||
|
/// <summary>Last line printed by the save path, so an unchanged vault saved over and over
|
||||||
|
/// does not repeat itself in the log. Kept 2026-09-13 when the fix was confirmed: the
|
||||||
|
/// save pair fires on every autosave, and a player's log should not carry two lines of
|
||||||
|
/// inventory listing every few minutes - but the moment anything CHANGES it still says so,
|
||||||
|
/// which is the part that had diagnostic value.</summary>
|
||||||
|
public static string lastSaveLogged;
|
||||||
|
|
||||||
|
/// <summary>Builds the opaque payload SpatialVaultBlobIO wraps. Uses netstandard's own
|
||||||
|
/// BinaryWriter over a MemoryStream, which is why Bag serialization can stay in this
|
||||||
|
/// project instead of the satellite assembly.</summary>
|
||||||
|
public static byte[] BuildPayload(Bag _bag)
|
||||||
|
{
|
||||||
|
using (MemoryStream ms = new MemoryStream())
|
||||||
|
using (BinaryWriter bw = new BinaryWriter(ms))
|
||||||
|
{
|
||||||
|
bw.Write(PayloadVersion);
|
||||||
|
bool hasBag = _bag != null;
|
||||||
|
bw.Write(hasBag);
|
||||||
|
if (hasBag)
|
||||||
|
{
|
||||||
|
// Plain BinaryWriter is enough: Bag.Write only demands a PooledBinaryWriter
|
||||||
|
// when bag.preferences != null, and vault bags come from `new Bag(int)`, whose
|
||||||
|
// constructor sets nothing but the item array.
|
||||||
|
_bag.Write(bw);
|
||||||
|
}
|
||||||
|
bw.Flush();
|
||||||
|
return ms.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Null when the payload holds no vault or is a version we do not know.</summary>
|
||||||
|
public static Bag ParsePayload(byte[] _payload)
|
||||||
|
{
|
||||||
|
if (_payload == null || _payload.Length == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
using (MemoryStream ms = new MemoryStream(_payload, false))
|
||||||
|
using (BinaryReader br = new BinaryReader(ms))
|
||||||
|
{
|
||||||
|
byte version = br.ReadByte();
|
||||||
|
if (version != PayloadVersion)
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[NecromancerTome] SpatialVaultPersistence: vault payload version " + version + ", expected " + PayloadVersion + " - skipped");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!br.ReadBoolean())
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Bag.Read is the STATIC one and returns a new Bag; ReadInto is the instance
|
||||||
|
// version. Symmetric with BuildPayload: preferences were written as absent, so no
|
||||||
|
// PooledBinaryReader is needed here either.
|
||||||
|
return Bag.Read(br);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Attach(PlayerDataFile _file, Bag _bag)
|
||||||
|
{
|
||||||
|
AttachedVaults.Remove(_file);
|
||||||
|
AttachedVaults.Add(_file, new VaultSlot { Bag = _bag });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Contents of a bag, for the log. Item names rather than just a count, because
|
||||||
|
/// "2 slots, 0 used" was true and useless three test runs in a row - what was needed was
|
||||||
|
/// whether the items the user put in had actually reached this object.</summary>
|
||||||
|
public static string Describe(Bag _bag)
|
||||||
|
{
|
||||||
|
if (_bag == null)
|
||||||
|
{
|
||||||
|
return "NONE";
|
||||||
|
}
|
||||||
|
ItemStack[] slots = _bag.GetSlots();
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.Append(_bag.SlotCount).Append(" slots, ").Append(_bag.GetUsedSlotCount()).Append(" used");
|
||||||
|
if (slots != null)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < slots.Length; i++)
|
||||||
|
{
|
||||||
|
ItemStack stack = slots[i];
|
||||||
|
if (stack == null || stack.IsEmpty())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
string name = stack.itemValue != null && stack.itemValue.ItemClass != null
|
||||||
|
? stack.itemValue.ItemClass.GetItemName()
|
||||||
|
: "?";
|
||||||
|
sb.Append(" [").Append(i).Append("]=").Append(name).Append("x").Append(stack.count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Live player -> save file: take the vault along.</summary>
|
||||||
|
[HarmonyPatch(typeof(PlayerDataFile), "FromPlayer")]
|
||||||
|
public static class Patch_PlayerDataFile_FromPlayer_SpatialVault
|
||||||
|
{
|
||||||
|
public static void Postfix(PlayerDataFile __instance, EntityPlayer _player)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_player == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Patch_ItemActionEat_ExecuteAction_SpatialVault.PlayerVaults.TryGetValue(_player.entityId, out Bag bag);
|
||||||
|
string source = bag != null ? "session cache" : null;
|
||||||
|
if (bag == null && _player is EntityPlayerLocal && SpatialVaultPersistence.LastLoadedVault != null)
|
||||||
|
{
|
||||||
|
// Rule 1: never write "no vault" over a vault we know exists.
|
||||||
|
bag = SpatialVaultPersistence.LastLoadedVault;
|
||||||
|
source = "last loaded (session cache was empty)";
|
||||||
|
}
|
||||||
|
SpatialVaultPersistence.Attach(__instance, bag);
|
||||||
|
string line = "FromPlayer entity " + _player.entityId + " - " + SpatialVaultPersistence.Describe(bag) +
|
||||||
|
(source != null ? ", from " + source : "");
|
||||||
|
if (line != SpatialVaultPersistence.lastSaveLogged)
|
||||||
|
{
|
||||||
|
SpatialVaultPersistence.lastSaveLogged = line;
|
||||||
|
Debug.Log("[NecromancerTome] SpatialVaultPersistence: " + line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Debug.LogError("[NecromancerTome] SpatialVaultPersistence: FromPlayer postfix failed: " + e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Save file -> live player: hand the vault back. This is also where freshness is decided
|
||||||
|
/// (rule 2): a file that WAS read and explicitly carried no vault clears the cache, so loading
|
||||||
|
/// a different save cannot inherit the previous world's vault. Only a file that was never read
|
||||||
|
/// at all falls back to LastLoadedVault, which is the broken-chain safety net.
|
||||||
|
/// </summary>
|
||||||
|
[HarmonyPatch(typeof(PlayerDataFile), "ToPlayer")]
|
||||||
|
public static class Patch_PlayerDataFile_ToPlayer_SpatialVault
|
||||||
|
{
|
||||||
|
public static void Postfix(PlayerDataFile __instance, EntityPlayer _player)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_player == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bool isLocal = _player is EntityPlayerLocal;
|
||||||
|
string note;
|
||||||
|
Bag bag;
|
||||||
|
|
||||||
|
if (SpatialVaultPersistence.AttachedVaults.TryGetValue(__instance, out SpatialVaultPersistence.VaultSlot slot))
|
||||||
|
{
|
||||||
|
bag = slot.Bag;
|
||||||
|
note = bag != null ? "from this player file" : "this player file says there is no vault";
|
||||||
|
}
|
||||||
|
else if (isLocal && SpatialVaultPersistence.LastLoadedVault != null)
|
||||||
|
{
|
||||||
|
bag = SpatialVaultPersistence.LastLoadedVault;
|
||||||
|
note = "nothing attached to this file - fell back to the last loaded vault";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
bag = null;
|
||||||
|
note = "nothing attached and nothing loaded";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bag != null)
|
||||||
|
{
|
||||||
|
Patch_ItemActionEat_ExecuteAction_SpatialVault.PlayerVaults[_player.entityId] = bag;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Patch_ItemActionEat_ExecuteAction_SpatialVault.PlayerVaults.Remove(_player.entityId);
|
||||||
|
}
|
||||||
|
if (isLocal)
|
||||||
|
{
|
||||||
|
SpatialVaultPersistence.LastLoadedVault = bag;
|
||||||
|
}
|
||||||
|
|
||||||
|
Debug.Log("[NecromancerTome] SpatialVaultPersistence: ToPlayer entity " + _player.entityId +
|
||||||
|
" - " + SpatialVaultPersistence.Describe(bag) + " (" + note + ")");
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Debug.LogError("[NecromancerTome] SpatialVaultPersistence: ToPlayer postfix failed: " + e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Appends the vault after everything vanilla wrote - to disk via Save, or to the
|
||||||
|
/// wire via WriteNetwork.</summary>
|
||||||
|
[HarmonyPatch(typeof(PlayerDataFile), "Write")]
|
||||||
|
public static class Patch_PlayerDataFile_Write_SpatialVault
|
||||||
|
{
|
||||||
|
public static void Postfix(PlayerDataFile __instance, PooledBinaryWriter _bw)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
SpatialVaultPersistence.AttachedVaults.TryGetValue(__instance, out SpatialVaultPersistence.VaultSlot slot);
|
||||||
|
Bag bag = slot != null ? slot.Bag : null;
|
||||||
|
SpatialVaultBlobIO.Write(_bw, SpatialVaultPersistence.BuildPayload(bag));
|
||||||
|
if (bag == null)
|
||||||
|
{
|
||||||
|
// Always shouted: writing an empty marker is how the vault got destroyed twice,
|
||||||
|
// so it must never again scroll past unnoticed.
|
||||||
|
Debug.LogWarning("[NecromancerTome] SpatialVaultPersistence: Write - no vault attached (writes an EMPTY marker)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Debug.LogError("[NecromancerTome] SpatialVaultPersistence: Write postfix failed: " + e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads the vault back off the stream. Must never throw: PlayerDataFile.Load treats
|
||||||
|
/// any exception out of Read as "this save is broken, fall back to the .bak".</summary>
|
||||||
|
[HarmonyPatch(typeof(PlayerDataFile), "Read")]
|
||||||
|
public static class Patch_PlayerDataFile_Read_SpatialVault
|
||||||
|
{
|
||||||
|
public static void Postfix(PlayerDataFile __instance, PooledBinaryReader _br)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
byte[] payload = SpatialVaultBlobIO.TryRead(_br);
|
||||||
|
if (payload == null)
|
||||||
|
{
|
||||||
|
// No vault block: a save from before this feature existed, or player data from
|
||||||
|
// somebody without the mod. Deliberately NOT recorded as an authoritative
|
||||||
|
// "no vault" - an absent block is silence, not a denial, and ToPlayer's
|
||||||
|
// fallback is what should handle it. SpatialVaultBlobIO has already put the
|
||||||
|
// stream position back.
|
||||||
|
Debug.Log("[NecromancerTome] SpatialVaultPersistence: Read - no vault block on this stream");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Bag bag = SpatialVaultPersistence.ParsePayload(payload);
|
||||||
|
// Attached even when null: a blob that says "no vault" IS an answer, and ToPlayer
|
||||||
|
// uses it to clear a stale cache when a different save is loaded.
|
||||||
|
SpatialVaultPersistence.Attach(__instance, bag);
|
||||||
|
if (bag != null)
|
||||||
|
{
|
||||||
|
SpatialVaultPersistence.LastLoadedVault = bag;
|
||||||
|
}
|
||||||
|
Debug.Log("[NecromancerTome] SpatialVaultPersistence: Read - blob of " + payload.Length +
|
||||||
|
" byte(s), " + SpatialVaultPersistence.Describe(bag));
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Debug.LogError("[NecromancerTome] SpatialVaultPersistence: Read postfix failed: " + e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,508 @@
|
|||||||
|
using HarmonyLib;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace NecromancerTome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Holding the Spatial Bracelet's REGULAR attack on a block takes that block into the vault
|
||||||
|
/// after a ten-second timer - the same circular indicator a workbench shows when you take it
|
||||||
|
/// (user request 2026-09-14: "при зажатии обычной атаки игрок видел индикатор как при
|
||||||
|
/// демонтаже верстака... блок должен исчезнуть и появиться в пространственном хранилище").
|
||||||
|
/// Entry point is SpatialVaultPatch's existing Prefix, index 0, which until now deliberately
|
||||||
|
/// swallowed that click and did nothing.
|
||||||
|
///
|
||||||
|
/// THE WHOLE RECIPE IS VANILLA'S, not an imitation of it. Block.TakeItemWithTimer and its
|
||||||
|
/// TakeItemWithTimerDone are short enough to read in one sitting, and they are the workbench
|
||||||
|
/// pickup; what follows is the same sequence with two substitutions - ten seconds instead of
|
||||||
|
/// the block's own TakeDelay, and the vault's Bag instead of the player's backpack. Even the
|
||||||
|
/// refusal messages are vanilla's own keys, which means they are already translated into every
|
||||||
|
/// language the game ships, and a player who has ever taken a workbench has already been
|
||||||
|
/// taught what they mean.
|
||||||
|
///
|
||||||
|
/// A DAMAGED BLOCK IS REFUSED BEFORE THE TIMER EVER OPENS. That is vanilla's first line:
|
||||||
|
///
|
||||||
|
/// if (_blockValue.damage > 0)
|
||||||
|
/// GameManager.ShowTooltip(_player, Localization.Get("ttRepairBeforePickup"), "", "ui_denied");
|
||||||
|
/// else if (canTake(...))
|
||||||
|
/// XUiC_Timer.OpenTimer(...);
|
||||||
|
///
|
||||||
|
/// - and it is exactly what the user asked for: a message, and no indicator at all.
|
||||||
|
///
|
||||||
|
/// EVERY GUARD IS CHECKED TWICE, ONCE TO OPEN THE TIMER AND ONCE TO FINISH IT, because ten
|
||||||
|
/// seconds is a long time in this game. Vanilla does the same for its own two seconds: the
|
||||||
|
/// block can be shot, mined, replaced, or opened by someone else while the circle fills, and
|
||||||
|
/// each of those has its own message rather than a silent failure or, worse, a block quietly
|
||||||
|
/// deleted from the world with nothing to show for it.
|
||||||
|
///
|
||||||
|
/// THE TARGET IS ANY BLOCK UNDER THE CROSSHAIR (user's choice of 2026-09-14, over the
|
||||||
|
/// narrower "only what vanilla already lets you take"). That is a wider promise than vanilla
|
||||||
|
/// ever makes, and two things follow from it that the narrow version would never have had to
|
||||||
|
/// face:
|
||||||
|
///
|
||||||
|
/// - MULTIBLOCKS. A door or a bed occupies several cells, and the crosshair usually lands on
|
||||||
|
/// a child rather than on the parent. Setting that one cell to air would leave the other
|
||||||
|
/// half standing as debris. The child is resolved to its parent first, with the engine's
|
||||||
|
/// own idiom - `isMultiBlock && ischild -> multiBlockPos.GetParentPos(...)` - which is
|
||||||
|
/// what Block's own methods do a dozen times over, and the parent is what gets removed.
|
||||||
|
/// - BLOCKS WITH NO ITEM FORM. Not everything placed in the world converts to something a
|
||||||
|
/// player can hold; ToItemValue comes back empty for those. They are refused up front,
|
||||||
|
/// because the alternative is deleting a block and handing back nothing.
|
||||||
|
///
|
||||||
|
/// THE CHANNEL GETS LONGER WITH REACH - ten seconds against the block, one more per full
|
||||||
|
/// block of distance. The measurement is not computed from the player's position and the
|
||||||
|
/// block's position, which would mean picking a point in the player (feet? eyes?) and a point
|
||||||
|
/// in the block (centre? face?) and being wrong about one of them: the engine already fills in
|
||||||
|
/// HitInfoDetails.distanceSq for the very ray that chose this block, so the number used is the
|
||||||
|
/// length of that ray. It is also the honest one - it measures to the surface being looked at,
|
||||||
|
/// which is what "вплотную" means to a player standing against a wall.
|
||||||
|
///
|
||||||
|
/// FLOOR, NOT ROUND, and that is what makes the two anchors in the request both come out
|
||||||
|
/// right: flush against a block the ray is well under a metre, floors to zero, and the channel
|
||||||
|
/// is the plain ten seconds; a block five away floors to five and costs fifteen.
|
||||||
|
///
|
||||||
|
/// THE POWER ATTACK CANCELS THE CHANNEL AND OPENS THE VAULT (user request 2026-09-14, after
|
||||||
|
/// the feature was confirmed working: "можно случайно нажать и не иметь возможности прервать").
|
||||||
|
/// Ten seconds of standing still after a misclick is a long time, and the vanilla escapes are
|
||||||
|
/// both poor here: getting hit is not something the player chooses, and the activate key is
|
||||||
|
/// not the button a hand is already on. The bracelet's other button is - and it lands on the
|
||||||
|
/// thing the player most likely wanted in the first place.
|
||||||
|
///
|
||||||
|
/// WHAT A PICKAXE CANNOT BREAK, THE BRACELET CANNOT TAKE (user report 2026-09-14: it would
|
||||||
|
/// happily take a trader's compound apart, and bedrock with it). TWO SEPARATE ENGINE RULES
|
||||||
|
/// stand behind that one sentence, and they are worth keeping apart because they look
|
||||||
|
/// identical from inside the game and are nothing alike in the code:
|
||||||
|
///
|
||||||
|
/// - A TRADER'S GROUND. The blocks there are ordinary; it is the AREA that is protected.
|
||||||
|
/// Vanilla simply skips DamageBlock inside it, which is why a pickaxe does nothing while
|
||||||
|
/// this bracelet - asking about the block rather than about the place - saw nothing wrong.
|
||||||
|
/// The test is the same predicate that suppression uses, with its condition copied whole:
|
||||||
|
///
|
||||||
|
/// World.SandboxUseTraderArea != TraderAreaStates.Default || !world.IsWithinTraderArea(pos)
|
||||||
|
///
|
||||||
|
/// The sandbox half is not padding. Trader protection is a server setting, and a server
|
||||||
|
/// that turned it off should not find this mod enforcing it anyway: where vanilla
|
||||||
|
/// protects, so does the bracelet; where it does not, neither does this.
|
||||||
|
/// - INDESTRUCTIBLE MATERIAL. The world's floor is the opposite case - nothing special about
|
||||||
|
/// the place, everything special about the block. Bedrock's material carries
|
||||||
|
/// CanDestroy=false (Data/Config/materials.xml, Mbedrock), and the engine reads exactly
|
||||||
|
/// `blockValue.Block.blockMaterial.CanDestroy` wherever it must not break something. Asked
|
||||||
|
/// as a material question rather than by block name, so it covers whatever else in this
|
||||||
|
/// game - or in another mod - is declared unbreakable.
|
||||||
|
///
|
||||||
|
/// Both say so out loud, where vanilla stays silent. Vanilla can afford silence because a
|
||||||
|
/// pickaxe that does nothing is its own explanation - the block visibly refuses to break. An
|
||||||
|
/// indicator that simply never appears looks like this mod is broken instead, so these
|
||||||
|
/// refusals get a message like every other one in this file.
|
||||||
|
///
|
||||||
|
/// CONTENTS CANNOT TRAVEL, AND THAT IS NOT A SHORTCUT. "In the state the original block was
|
||||||
|
/// in" holds for the block's identity and its integrity, but an ItemStack in this game has
|
||||||
|
/// nowhere to put another container's inventory - ToItemValue maps a block to an item and
|
||||||
|
/// stops there. Vanilla solves this by refusing: a workstation with anything in it cannot be
|
||||||
|
/// taken, and says so through ttWorkstationNotEmpty. The same refusal is used here, extended
|
||||||
|
/// to composite storage (chests) through ITileEntityLootable, which is how this version of the
|
||||||
|
/// game models a container's contents.
|
||||||
|
/// </summary>
|
||||||
|
public static class SpatialVaultPickup
|
||||||
|
{
|
||||||
|
/// <summary>The floor: what it costs to take a block you are standing against. Vanilla's
|
||||||
|
/// workbench is two; the Blue Portal Stone's channel in this mod is also ten, and this
|
||||||
|
/// reads as the same kind of deliberate act.</summary>
|
||||||
|
public const float BaseChannelSeconds = 10f;
|
||||||
|
|
||||||
|
/// <summary>Added per full block of reach (user request 2026-09-14: "вплотную 10 сек,
|
||||||
|
/// если объект от персонажа в пяти блоках то 15 сек"). Distance is a cost, so pulling
|
||||||
|
/// something out of a wall across the room is a commitment rather than a trick.</summary>
|
||||||
|
public const float SecondsPerBlock = 1f;
|
||||||
|
|
||||||
|
/// <summary>Vanilla's own refusal messages, already translated into every shipped
|
||||||
|
/// language. Reused rather than re-worded: a player who has taken a workbench has already
|
||||||
|
/// learned what these mean, and a second vocabulary for the same refusal would be worse
|
||||||
|
/// than no message.</summary>
|
||||||
|
public const string MsgRepairFirst = "ttRepairBeforePickup";
|
||||||
|
public const string MsgBlockMissing = "ttBlockMissingPickup";
|
||||||
|
public const string MsgInUse = "ttCantPickupInUse";
|
||||||
|
public const string MsgNotEmpty = "ttWorkstationNotEmpty";
|
||||||
|
|
||||||
|
/// <summary>This mod's own, added with this feature - see Config/Localization.csv.</summary>
|
||||||
|
public const string MsgNoBlock = "braceletSpatialVaultNoBlock";
|
||||||
|
public const string MsgNoItemForm = "braceletSpatialVaultNoItemForm";
|
||||||
|
public const string MsgVaultFull = "braceletSpatialVaultFull";
|
||||||
|
public const string MsgChanneling = "braceletSpatialVaultPickupChanneling";
|
||||||
|
public const string MsgTraderArea = "braceletSpatialVaultTraderArea";
|
||||||
|
public const string MsgIndestructible = "braceletSpatialVaultIndestructible";
|
||||||
|
|
||||||
|
/// <summary>The denial sound vanilla plays with these tooltips.</summary>
|
||||||
|
public const string DeniedSound = "ui_denied";
|
||||||
|
|
||||||
|
/// <summary>Unscaled time at which a cancel last opened the vault, or -1. Exists to stop
|
||||||
|
/// ONE press from opening the vault TWICE: the cancel reacts to the button going down,
|
||||||
|
/// while the bracelet's ordinary power attack reacts to it coming back up, and those are
|
||||||
|
/// the same press. Whether the release even reaches the item action through the modal
|
||||||
|
/// window is unknown - it is exactly the input suppression that forced the raw mouse read
|
||||||
|
/// below - so this guards the case rather than assuming either answer.</summary>
|
||||||
|
public static float CancelOpenedVaultAt = -1f;
|
||||||
|
|
||||||
|
/// <summary>How long after a cancel a power-attack release is treated as the tail of that
|
||||||
|
/// same press. Long enough to cover a slow finger, far short of a deliberate second
|
||||||
|
/// click.</summary>
|
||||||
|
public const float CancelSwallowSeconds = 0.5f;
|
||||||
|
|
||||||
|
/// <summary>True once, if the vault was just opened by cancelling a channel. Consuming it
|
||||||
|
/// rather than only reading it means a genuine second press right afterwards still
|
||||||
|
/// works.</summary>
|
||||||
|
public static bool ConsumeCancelOpen()
|
||||||
|
{
|
||||||
|
if (CancelOpenedVaultAt < 0f || Time.unscaledTime - CancelOpenedVaultAt > CancelSwallowSeconds)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
CancelOpenedVaultAt = -1f;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What the timer is working on, handed through TimerEventData.Data - the same
|
||||||
|
/// use vanilla makes of that field (it packs a BlockValue, a position and the player into
|
||||||
|
/// an object[] there). A small class instead of an array because this one is read back in
|
||||||
|
/// a method that has to be right about which field is which.</summary>
|
||||||
|
public class PickupJob
|
||||||
|
{
|
||||||
|
public EntityPlayerLocal Player;
|
||||||
|
public Vector3i Position;
|
||||||
|
public BlockValue Expected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Regular attack on the bracelet. Every refusal happens here, before the player
|
||||||
|
/// is asked to stand still for ten seconds.</summary>
|
||||||
|
public static void Begin(EntityPlayerLocal _player)
|
||||||
|
{
|
||||||
|
World world = GameManager.Instance != null ? GameManager.Instance.World : null;
|
||||||
|
if (world == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
WorldRayHitInfo hitInfo = _player.HitInfo;
|
||||||
|
if (hitInfo == null || !hitInfo.bHitValid)
|
||||||
|
{
|
||||||
|
Deny(_player, MsgNoBlock);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Vector3i position = hitInfo.hit.blockPos;
|
||||||
|
BlockValue blockValue = world.GetBlock(position);
|
||||||
|
if (blockValue.isair || blockValue.Block == null)
|
||||||
|
{
|
||||||
|
Deny(_player, MsgNoBlock);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A door or a bed is several cells and the crosshair lands on whichever one is
|
||||||
|
// nearest; removing that cell alone would leave the rest of the model standing.
|
||||||
|
if (blockValue.Block.isMultiBlock && blockValue.ischild)
|
||||||
|
{
|
||||||
|
position = blockValue.Block.multiBlockPos.GetParentPos(position, blockValue);
|
||||||
|
blockValue = world.GetBlock(position);
|
||||||
|
if (blockValue.isair || blockValue.Block == null)
|
||||||
|
{
|
||||||
|
Deny(_player, MsgNoBlock);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Before anything else about the block is considered: whether it may be touched at
|
||||||
|
// all outranks what state it happens to be in.
|
||||||
|
if (!CanTakeHere(world, position, blockValue, _player))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vanilla's first line, and the user's explicit requirement: a damaged block gets the
|
||||||
|
// message and no indicator whatsoever.
|
||||||
|
if (blockValue.damage > 0)
|
||||||
|
{
|
||||||
|
Deny(_player, MsgRepairFirst);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ItemValue itemValue = blockValue.ToItemValue();
|
||||||
|
if (itemValue == null || itemValue.IsEmpty())
|
||||||
|
{
|
||||||
|
Deny(_player, MsgNoItemForm);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CanTakeTileEntity(world, position, _player))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asked before the timer rather than after it, because ten seconds spent to be told
|
||||||
|
// the vault was full the whole time is the worst version of this feature.
|
||||||
|
Bag bag = GetVault(_player);
|
||||||
|
if (bag == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!bag.CanTakeItem(new ItemStack(itemValue, 1)))
|
||||||
|
{
|
||||||
|
Deny(_player, MsgVaultFull);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
TimerEventData timerData = new TimerEventData
|
||||||
|
{
|
||||||
|
Data = new PickupJob { Player = _player, Position = position, Expected = blockValue },
|
||||||
|
// Vanilla's own two escapes: taking a hit stops the channel, and so does the
|
||||||
|
// activate key. Neither is built here - both are fields XUiC_Timer.Update reads.
|
||||||
|
CloseOnHit = true,
|
||||||
|
CancelWithActivateButton = true
|
||||||
|
};
|
||||||
|
timerData.FullTimeFinishEvent += OnChannelComplete;
|
||||||
|
// Every way this ends that is NOT completion: damage, the activate key, the power
|
||||||
|
// attack. XUiC_Timer sets skipCloseEvent around the completion path specifically so
|
||||||
|
// the two are mutually exclusive, which is why the colour is restored in both places
|
||||||
|
// and not only here.
|
||||||
|
timerData.CloseEvent += delegate
|
||||||
|
{
|
||||||
|
ChannelVision.End(_player);
|
||||||
|
};
|
||||||
|
|
||||||
|
float channelSeconds = ChannelSecondsFor(hitInfo);
|
||||||
|
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(_player);
|
||||||
|
XUiC_Timer.OpenTimer(playerUI.xui, channelSeconds, timerData, -1f, Localization.Get(MsgChanneling));
|
||||||
|
// After the window is up, so a channel that somehow fails to open never leaves the
|
||||||
|
// world grey with nothing running.
|
||||||
|
ChannelVision.Begin(_player);
|
||||||
|
|
||||||
|
Debug.Log("[NecromancerTome] SpatialVaultPickup: owner=" + _player.entityId + " started taking " +
|
||||||
|
blockValue.Block.GetBlockName() + " at " + position + " - " +
|
||||||
|
Mathf.Sqrt(hitInfo.hit.distanceSq).ToString("0.##") + " blocks away, " +
|
||||||
|
channelSeconds.ToString("0.#") + "s channel");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ten seconds later. Everything is checked again from the live world rather than
|
||||||
|
/// trusted from the job, because the block that was there when the circle started filling
|
||||||
|
/// is not necessarily the block that is there now.</summary>
|
||||||
|
public static void OnChannelComplete(TimerEventData _timerData)
|
||||||
|
{
|
||||||
|
if (!(_timerData.Data is PickupJob job) || job.Player == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// FIRST, before any of the checks below can take an early exit: the ten seconds are
|
||||||
|
// over however this turns out, so the colour comes back whether the block is taken or
|
||||||
|
// refused.
|
||||||
|
ChannelVision.End(job.Player);
|
||||||
|
|
||||||
|
World world = GameManager.Instance != null ? GameManager.Instance.World : null;
|
||||||
|
if (world == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
BlockValue blockValue = world.GetBlock(job.Position);
|
||||||
|
if (!CanTakeHere(world, job.Position, blockValue, job.Player))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (blockValue.damage > 0)
|
||||||
|
{
|
||||||
|
Deny(job.Player, MsgRepairFirst);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Shot out, mined, or replaced while the circle was filling.
|
||||||
|
if (blockValue.isair || blockValue.Block == null || blockValue.type != job.Expected.type)
|
||||||
|
{
|
||||||
|
Deny(job.Player, MsgBlockMissing);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!CanTakeTileEntity(world, job.Position, job.Player))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ItemValue itemValue = blockValue.ToItemValue();
|
||||||
|
if (itemValue == null || itemValue.IsEmpty())
|
||||||
|
{
|
||||||
|
Deny(job.Player, MsgNoItemForm);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Bag bag = GetVault(job.Player);
|
||||||
|
if (bag == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ORDER MATTERS: the item goes in first, and the block is only removed if it got
|
||||||
|
// there. The other way round is how a block gets deleted out of the world in exchange
|
||||||
|
// for nothing when the vault filled up during those ten seconds.
|
||||||
|
if (!bag.AddItem(new ItemStack(itemValue, 1)))
|
||||||
|
{
|
||||||
|
Deny(job.Player, MsgVaultFull);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
world.SetBlockRPC(job.Position, BlockValue.Air);
|
||||||
|
// The vault lives in memory and is written out with the player's own save data; this
|
||||||
|
// is the same commit point closing the vault window uses, so a block taken and then
|
||||||
|
// left alone is not waiting on the next autosave to become real.
|
||||||
|
GameManager.Instance.SaveLocalPlayerData();
|
||||||
|
|
||||||
|
Debug.Log("[NecromancerTome] SpatialVaultPickup: owner=" + job.Player.entityId + " took " +
|
||||||
|
blockValue.Block.GetBlockName() + " at " + job.Position + " into the vault");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>How long this particular pull takes. See the class comment for why the ray's
|
||||||
|
/// own length is the measurement and why it is floored rather than rounded.</summary>
|
||||||
|
public static float ChannelSecondsFor(WorldRayHitInfo _hitInfo)
|
||||||
|
{
|
||||||
|
float distance = Mathf.Sqrt(_hitInfo.hit.distanceSq);
|
||||||
|
int blocks = Mathf.Max(0, Mathf.FloorToInt(distance));
|
||||||
|
return BaseChannelSeconds + blocks * SecondsPerBlock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>False (with the reason already shown) when this block is one the game itself
|
||||||
|
/// would not let a player break - because of where it stands, or because of what it is
|
||||||
|
/// made of. Split out because, like every other guard here, it is asked twice: once to
|
||||||
|
/// open the timer and once to finish it.</summary>
|
||||||
|
public static bool CanTakeHere(World _world, Vector3i _position, BlockValue _blockValue, EntityPlayerLocal _player)
|
||||||
|
{
|
||||||
|
if (World.SandboxUseTraderArea == TraderAreaStates.Default && _world.IsWithinTraderArea(_position))
|
||||||
|
{
|
||||||
|
Deny(_player, MsgTraderArea);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (_blockValue.Block != null && _blockValue.Block.blockMaterial != null &&
|
||||||
|
!_blockValue.Block.blockMaterial.CanDestroy)
|
||||||
|
{
|
||||||
|
Deny(_player, MsgIndestructible);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>False (with the reason already shown) when a tile entity at this position
|
||||||
|
/// stands in the way: someone has it open, or it has something inside it. Contents cannot
|
||||||
|
/// travel inside an ItemStack, so a container has to be emptied first - vanilla's own rule
|
||||||
|
/// for its workstations, applied here to chests as well.</summary>
|
||||||
|
public static bool CanTakeTileEntity(World _world, Vector3i _position, EntityPlayerLocal _player)
|
||||||
|
{
|
||||||
|
TileEntity tileEntity = _world.GetTileEntity(_position);
|
||||||
|
if (tileEntity == null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (tileEntity.IsUserAccessing())
|
||||||
|
{
|
||||||
|
Deny(_player, MsgInUse);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (tileEntity is TileEntityWorkstation workstation && !workstation.IsEmpty)
|
||||||
|
{
|
||||||
|
Deny(_player, MsgNotEmpty);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (tileEntity is TileEntityCollector collector && !collector.IsEmpty())
|
||||||
|
{
|
||||||
|
Deny(_player, MsgNotEmpty);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Chests and everything else that holds loot: this version of the game models them as
|
||||||
|
// a composite tile entity with a storage FEATURE rather than as their own class, so
|
||||||
|
// the question has to be asked of the feature - the same TryGetSelfOrFeature call the
|
||||||
|
// engine's own storage code uses.
|
||||||
|
if (tileEntity.TryGetSelfOrFeature(out ITileEntityLootable lootable) && !lootable.IsEmpty())
|
||||||
|
{
|
||||||
|
Deny(_player, MsgNotEmpty);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The player's vault, or null with the reason already shown. Deliberately the
|
||||||
|
/// SAME bag the bracelet's power attack opens, reached through the same cache - a block
|
||||||
|
/// taken here has to be in the window that opens there, and the level gate has to answer
|
||||||
|
/// the same way in both places.</summary>
|
||||||
|
public static Bag GetVault(EntityPlayerLocal _player)
|
||||||
|
{
|
||||||
|
ProgressionValue progressionValue = _player.Progression?.GetProgressionValue(
|
||||||
|
Patch_ItemActionEat_ExecuteAction_SpatialVault.NecromancySkillName);
|
||||||
|
int level = progressionValue != null ? progressionValue.Level : 0;
|
||||||
|
int slotCount = Mathf.RoundToInt(level / 10f);
|
||||||
|
if (slotCount <= 0)
|
||||||
|
{
|
||||||
|
Deny(_player, "braceletSpatialVaultTooWeak");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Patch_ItemActionEat_ExecuteAction_SpatialVault.PlayerVaults.TryGetValue(_player.entityId, out Bag bag))
|
||||||
|
{
|
||||||
|
bag = SpatialVaultPersistence.LastLoadedVault ?? new Bag(slotCount);
|
||||||
|
Patch_ItemActionEat_ExecuteAction_SpatialVault.PlayerVaults[_player.entityId] = bag;
|
||||||
|
}
|
||||||
|
if (bag.SlotCount < slotCount)
|
||||||
|
{
|
||||||
|
ItemStack[] oldSlots = bag.GetSlots();
|
||||||
|
ItemStack[] newSlots = ItemStack.CreateArray(slotCount);
|
||||||
|
System.Array.Copy(oldSlots, newSlots, oldSlots.Length);
|
||||||
|
bag.SetSlots(newSlots);
|
||||||
|
}
|
||||||
|
return bag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A refusal, in vanilla's shape: the tooltip plus the denial sound. One method
|
||||||
|
/// so that no refusal in this file can accidentally go out silent.</summary>
|
||||||
|
public static void Deny(EntityPlayerLocal _player, string _localizationKey)
|
||||||
|
{
|
||||||
|
GameManager.ShowTooltip(_player, Localization.Get(_localizationKey), string.Empty, DeniedSound);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Lets the power attack cancel a block pickup in progress and open the vault
|
||||||
|
/// instead. A separate patch class on XUiC_Timer.Update, not on the item action, because this
|
||||||
|
/// has to be asked every frame WHILE the timer is open rather than once at click time - the
|
||||||
|
/// same shape Patch_XUiC_Timer_Update_PortalStoneCancel already uses for the Blue Portal
|
||||||
|
/// Stone's channel.
|
||||||
|
///
|
||||||
|
/// BOTH INPUT CHECKS ARE DELIBERATE, AND THE RAW ONE IS THE ONE THAT WORKS. The portal stone
|
||||||
|
/// shipped with only the semantic PlayerActionsLocal.Secondary check and the user reported
|
||||||
|
/// that cancelling did not work at all: the modal timer window has input focus, and the press
|
||||||
|
/// never reached PlayerAction's polling layer. The fix there was a second, independent read of
|
||||||
|
/// Unity's raw Input.GetMouseButtonDown(1) - right mouse, confirmed as Secondary's real
|
||||||
|
/// default KBM binding by decompiling PlayerActionsLocal.CreateActions - which reads hardware
|
||||||
|
/// state directly and bypasses whatever swallows the other one. That lesson is reused here
|
||||||
|
/// rather than re-learned: the semantic check is kept because it costs nothing and would cover
|
||||||
|
/// a gamepad's Secondary if that one does get through, and the raw check is what is actually
|
||||||
|
/// expected to fire. A gamepad-only player still has no cancel - the same open gap the portal
|
||||||
|
/// stone has, and the same fix would close both.
|
||||||
|
///
|
||||||
|
/// THE TIMER IS CLOSED BEFORE THE VAULT IS OPENED, not after: closing runs OnClose, which is
|
||||||
|
/// what hands control back to the player and drops the event data. Opening a window on top of
|
||||||
|
/// one that is still closing is how two windows end up fighting over the same input.</summary>
|
||||||
|
[HarmonyPatch(typeof(XUiC_Timer), "Update")]
|
||||||
|
public static class Patch_XUiC_Timer_Update_VaultPickupCancel
|
||||||
|
{
|
||||||
|
public static void Postfix(XUiC_Timer __instance)
|
||||||
|
{
|
||||||
|
if (__instance == null || __instance.eventData == null ||
|
||||||
|
!(__instance.eventData.Data is SpatialVaultPickup.PickupJob job) || job.Player == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayerActionsLocal input = __instance.xui?.playerUI?.playerInput;
|
||||||
|
bool cancelPressed = (input != null && input.Secondary.WasPressed) || Input.GetMouseButtonDown(1);
|
||||||
|
if (!cancelPressed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
EntityPlayerLocal player = job.Player;
|
||||||
|
Debug.Log("[NecromancerTome] SpatialVaultPickup: pickup cancelled via power attack by owner=" + player.entityId);
|
||||||
|
__instance.xui.playerUI.windowManager.Close(__instance.windowGroup);
|
||||||
|
SpatialVaultPickup.CancelOpenedVaultAt = Time.unscaledTime;
|
||||||
|
Patch_ItemActionEat_ExecuteAction_SpatialVault.OpenVault(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -4,6 +4,6 @@
|
|||||||
<DisplayName value="Necromancer's Tome" />
|
<DisplayName value="Necromancer's Tome" />
|
||||||
<Description value="A dark necromancy progression for 7 Days to Die 3.2: a kill-count-driven skill tree with cursed weapons, charm/deviation magic, summonable undead pets, base-defence wards, and a story-ending Black Portal ritual. Fully localized into 13 languages. Single-player; requires EAC off." />
|
<Description value="A dark necromancy progression for 7 Days to Die 3.2: a kill-count-driven skill tree with cursed weapons, charm/deviation magic, summonable undead pets, base-defence wards, and a story-ending Black Portal ritual. Fully localized into 13 languages. Single-player; requires EAC off." />
|
||||||
<Author value="Alex Cube" />
|
<Author value="Alex Cube" />
|
||||||
<Version value="2.0.0" />
|
<Version value="1.1.0" />
|
||||||
<Website value="https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/" />
|
<Website value="https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/" />
|
||||||
</xml>
|
</xml>
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ To uninstall, delete the folder. The mod adds items and one block, so a save tha
|
|||||||
|
|
||||||
[b]Base defence that converts instead of killing.[/b] The [b]Pyramid of Spirits[/b] is a deployable block. While you stand in its radius it charms any uncharmed zombie nearby on its own and sets it alight with cold purple flame, turning it against the rest instead of your walls. Its block menu toggles the effect and shows the radius.
|
[b]Base defence that converts instead of killing.[/b] The [b]Pyramid of Spirits[/b] is a deployable block. While you stand in its radius it charms any uncharmed zombie nearby on its own and sets it alight with cold purple flame, turning it against the rest instead of your walls. Its block menu toggles the effect and shows the radius.
|
||||||
|
|
||||||
[b]Necromancer's tools.[/b] The [b]Blue Portal Stone[/b] teleports you to your bedroll after a ten-second channel that any damage interrupts, and is never consumed. The [b]Spatial Bracelet[/b] opens a personal storage rift that grows with your Necromancy level. [b]Necromancer's Blood[/b] is paid for in your own health. [b]Tin cans[/b] add a reusable water cycle - fill, boil on a campfire without a pot, drink, keep the can.
|
[b]Necromancer's tools.[/b] The [b]Blue Portal Stone[/b] teleports you to your bedroll after a ten-second channel that any damage interrupts, and is never consumed. The [b]Spatial Bracelet[/b] opens a personal storage rift that grows with your Necromancy level - and holding its regular attack on a block pulls that block straight into the rift, ten seconds up close and one more per block of distance, while the world drains to black and white around you. [b]Necromancer's Blood[/b] is paid for in your own health. [b]Tin cans[/b] add a reusable water cycle - fill, boil on a campfire without a pot, drink, keep the can.
|
||||||
|
|
||||||
|
[b]The dead keep shop.[/b] Every trader in the world is rendered in black and white, half-transparent and matte. Nothing about the trade changes - but the necromancer deals with the dead, and the only people still doing business out here are not quite alive.
|
||||||
|
|
||||||
[b]A real ending.[/b] The [b]Black Portal Stone[/b] is the last thing the skill tree gives you, and it is the end of the mod's story - a full-screen finale that stops the game and closes on a choice of two. One of them ends the run and returns you to the main menu; the other lets you come back and keep playing. What waits on the far side is better seen than described.
|
[b]A real ending.[/b] The [b]Black Portal Stone[/b] is the last thing the skill tree gives you, and it is the end of the mod's story - a full-screen finale that stops the game and closes on a choice of two. One of them ends the run and returns you to the main menu; the other lets you come back and keep playing. What waits on the far side is better seen than described.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
[size=5][b]Necromancer's Tome 1.0.1[/b][/size]
|
||||||
|
|
||||||
|
A bug-fix release on top of 1.0. Same content, one real fix and a few refinements. Drop-in replacement: delete the old [b]NecromancerTome[/b] folder, unpack this one in its place. Your save is fine.
|
||||||
|
|
||||||
|
[size=5][b]Fixed[/b][/size]
|
||||||
|
|
||||||
|
[b]The Spatial Bracelet no longer loses what you put in it.[/b] Reported by [b]youkia96581[/b] - thank you, this was a real hole and a fair catch. The storage rift was only ever held in memory: it survived death, respawn and relogging, but quitting the game threw it away.
|
||||||
|
|
||||||
|
It is now saved the way the game saves your backpack - inside your own player data, written and read in the same moment and the same file. Put things in, quit, come back days later: they are still there. Verified end to end, including a clean exit to the main menu, which was the exact moment things used to disappear.
|
||||||
|
|
||||||
|
[b]One honest caveat:[/b] this cannot bring back items lost in 1.0. There was no data on disk to recover - the vault simply was never written anywhere. Anything already lost is gone, and I am sorry about that.
|
||||||
|
|
||||||
|
[size=5][b]Changed[/b][/size]
|
||||||
|
|
||||||
|
[list]
|
||||||
|
[*][b]Traders are drawn in black and white.[/b] The necromancer deals with the dead, and the people still willing to trade with him have clearly seen too much. Their own shaders and lighting are untouched - only the colour is gone, plus a sliver of transparency.
|
||||||
|
[*][b]The Spatial Bracelet has four mod slots.[/b] Empty for now: the mods that fit them come in a later version. Ordinary weapon mods will not go in, and nothing else will take these.
|
||||||
|
[*][b]The Spatial Bracelet is no longer a parcel in your hand.[/b] It used to borrow a food-crop prefab and looked like a boxed lunch tied with string. Now nothing is drawn at all - just your fist, held the way you hold a block you are about to place.
|
||||||
|
[*][b]Grave's Repose is much stronger[/b] - cold and heat resistance raised from 5 to 50. With the knife in hand, weather stops being a problem rather than merely being survivable.
|
||||||
|
[/list]
|
||||||
|
|
||||||
|
[size=5][b]Note for existing saves[/b][/size]
|
||||||
|
|
||||||
|
A Spatial Bracelet you already own will not gain the four mod slots. The game fixes an item's slot count when the item is created, so an old one keeps the zero it was made with. [b]Craft a new bracelet[/b] and it will have them. Everything else in this release applies to your existing save immediately.
|
||||||
|
|
||||||
|
[size=5][b]Requirements[/b][/size]
|
||||||
|
|
||||||
|
Unchanged from 1.0: [b]7 Days to Die 3.2[/b], [b]EasyAntiCheat off[/b] (the mod uses Harmony patches), no other mods needed, built and tested single-player, installed per client.
|
||||||
|
|
||||||
|
[size=5][b]Shout outs[/b][/size]
|
||||||
|
|
||||||
|
[b]The Fun Pimps[/b] - for 7 Days to Die itself, and for the vanilla models, icons and UI templates this mod reuses (the thrown-stone prefab, the book icon behind the summoning tomes, the video player and confirmation dialog the finale is built on).
|
||||||
|
|
||||||
|
[b]Andreas Pardeike[/b] - for Harmony. Every runtime mechanic in this mod, this release's storage fix included, is a Harmony patch.
|
||||||
|
|
||||||
|
[b]The 7 Days to Die modding community[/b] - for forum posts and open-source mods that answer the questions the XML documentation does not. The official modding API has no reference for the order its events fire in; this release's fix came down to reading that order out of the game's own code, and knowing that was the only way to find out is community knowledge.
|
||||||
|
|
||||||
|
[b]youkia96581[/b] - for the bug report that made this version exist.
|
||||||
|
|
||||||
|
[b]AI disclosure:[/b] the item icons and the finale artwork are AI-generated. Everything else - the code, the design, the mechanics and the writing - is my own.
|
||||||
|
|
||||||
|
Source code and full change history: [url=https://git.08h.ru/alex/necromants-tome-7d2d-3-2]git.08h.ru/alex/necromants-tome-7d2d-3-2[/url]
|
||||||
|
Mod page on my site: [url=https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/]alexcube.ru[/url]
|
||||||
|
My YouTube channel: [url=https://www.youtube.com/@alexcube]@alexcube[/url]
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,8 @@
|
|||||||
# Книга некроманта / Necromancer's Tome (NecromancerTome)
|
# Книга некроманта / Necromancer's Tome (NecromancerTome)
|
||||||
|
|
||||||
**Версия 1.0** — для 7 Days to Die 3.2. Автор: Alex Cube.
|
*English version below — scroll past the Russian half.*
|
||||||
|
|
||||||
|
**Версия 1.1.0** — для 7 Days to Die 3.2. Автор: Alex Cube.
|
||||||
|
|
||||||
- Страница мода: https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/
|
- Страница мода: https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/
|
||||||
- Nexus Mods: https://www.nexusmods.com/7daystodie/mods/12547
|
- Nexus Mods: https://www.nexusmods.com/7daystodie/mods/12547
|
||||||
@@ -60,7 +62,11 @@
|
|||||||
своему спальному мешку. Прерывается любым уроном или силовой атакой раньше времени. Не
|
своему спальному мешку. Прерывается любым уроном или силовой атакой раньше времени. Не
|
||||||
расходуется.
|
расходуется.
|
||||||
- **Пространственный браслет** — силовая атака открывает личный разлом-хранилище, чей размер
|
- **Пространственный браслет** — силовая атака открывает личный разлом-хранилище, чей размер
|
||||||
растёт вместе с уровнем Некромантии. Обычная атака пока ничего не делает.
|
растёт вместе с уровнем Некромантии. Обычная атака, зажатая на блоке, утаскивает этот блок
|
||||||
|
прямо в хранилище: десять секунд вплотную и ещё по секунде за каждый блок расстояния, с тем же
|
||||||
|
круглым индикатором, что и у разбора верстака. Мир на это время обесцвечивается. Не поддаются
|
||||||
|
повреждённые блоки, контейнеры с содержимым, территория торговца и неразрушимое вроде дна мира —
|
||||||
|
каждый отказ со своим сообщением. Силовая атака прерывает утаскивание и открывает хранилище.
|
||||||
- **Консервные банки** (пустая / с речной водой / с кипячёной) — расходный цикл вместо
|
- **Консервные банки** (пустая / с речной водой / с кипячёной) — расходный цикл вместо
|
||||||
одноразовых банок: наполняются водой, кипятятся прямо на костре без кастрюли, выпиваются, банка
|
одноразовых банок: наполняются водой, кипятятся прямо на костре без кастрюли, выпиваются, банка
|
||||||
возвращается пустой. Речная вода из банки может вызвать дизентерию, как обычная мутная вода;
|
возвращается пустой. Речная вода из банки может вызвать дизентерию, как обычная мутная вода;
|
||||||
@@ -104,12 +110,14 @@
|
|||||||
- Стартовая записка при открытии тоже ставит игру на паузу и проигрывает короткий флэшбек.
|
- Стартовая записка при открытии тоже ставит игру на паузу и проигрывает короткий флэшбек.
|
||||||
- Некоторые декоративные блоки (кровати, кулеры, картонные коробки) можно разобрать удержанием,
|
- Некоторые декоративные блоки (кровати, кулеры, картонные коробки) можно разобрать удержанием,
|
||||||
как верстак.
|
как верстак.
|
||||||
|
- Все торговцы выглядят иначе: чёрно-белые, полупрозрачные и матовые. Некромант имеет дело с
|
||||||
|
мёртвыми, и торгуют с ним те, кто уже не совсем жив.
|
||||||
|
|
||||||
## Локализация
|
## Локализация
|
||||||
|
|
||||||
**13 языков полностью:** русский, английский, немецкий, испанский, французский, итальянский,
|
**13 языков полностью:** русский, английский, немецкий, испанский, французский, итальянский,
|
||||||
японский, корейский, польский, португальский (Бразилия), турецкий, китайский упрощённый и
|
японский, корейский, польский, португальский (Бразилия), турецкий, китайский упрощённый и
|
||||||
традиционный. Все 123 ключа `Config/Localization.csv` заполнены, пустых ячеек нет.
|
традиционный. Все 130 ключей `Config/Localization.csv` заполнены, пустых ячеек нет.
|
||||||
|
|
||||||
## Установка
|
## Установка
|
||||||
|
|
||||||
@@ -121,7 +129,166 @@
|
|||||||
|
|
||||||
## Статус
|
## Статус
|
||||||
|
|
||||||
Версия 1.0 — весь заявленный контент реализован и проходит тесты в игре. Из запланированного не
|
Версия 1.1.0 — Пространственный браслет научился забирать блоки прямо в хранилище: зажатая на
|
||||||
сделана только часть фирменных звуков. Текст описания для сайта (RU + EN) — в
|
блоке обычная атака утаскивает его туда через десять секунд плюс секунда за каждый блок
|
||||||
|
расстояния, с обесцвечиванием мира на время ожидания (оно же теперь висит и на каналах обоих
|
||||||
|
порталов). Не поддаются повреждённые блоки, контейнеры с содержимым, территория торговца и
|
||||||
|
неразрушимое вроде дна мира. Плюс все торговцы стали чёрно-белыми, полупрозрачными и матовыми.
|
||||||
|
|
||||||
|
Предыдущая версия 1.0.1 закрывала первый баг-репорт с Nexus: содержимое браслета больше не
|
||||||
|
пропадает после выхода из игры (хранилище сохраняется в файле игрока, рядом с рюкзаком).
|
||||||
|
|
||||||
|
Весь заявленный контент реализован и проходит тесты в игре. Из запланированного не сделана
|
||||||
|
только часть фирменных звуков. Текст описания для сайта (RU + EN) — в
|
||||||
`SITE_DESCRIPTION.html` (разметка блоков WordPress). Полная техническая история разработки и текст финала лежат рядом с модом
|
`SITE_DESCRIPTION.html` (разметка блоков WordPress). Полная техническая история разработки и текст финала лежат рядом с модом
|
||||||
в `BACKLOG.md` и `FINAL_TEXT.md` — в репозиторий они не входят (спойлеры и внутренняя кухня).
|
в `BACKLOG.md` и `FINAL_TEXT.md` — в репозиторий они не входят (спойлеры и внутренняя кухня).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Necromancer's Tome (English)
|
||||||
|
|
||||||
|
**Version 1.1.0** - for 7 Days to Die 3.2. By Alex Cube.
|
||||||
|
|
||||||
|
- Mod page: https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/
|
||||||
|
- Nexus Mods: https://www.nexusmods.com/7daystodie/mods/12547
|
||||||
|
- Repository: https://git.08h.ru/alex/necromants-tome-7d2d-3-2
|
||||||
|
- The author's YouTube channel: https://www.youtube.com/@alexcube
|
||||||
|
|
||||||
|
A 7 Days to Die mod about the road from ordinary survivor to necromancer - with its own
|
||||||
|
progression tree, cursed weapons, summonable creatures and a story ending.
|
||||||
|
|
||||||
|
## Premise
|
||||||
|
|
||||||
|
It all starts with a note, and reading it shows the character a blurred flashback.
|
||||||
|
|
||||||
|
Necromancy in this mod is an answer to a curse, not a side branch of crafting. Instead of falling
|
||||||
|
before the horde one day and joining it, the player learns to bend the dead to their will: to
|
||||||
|
infect zombies with madness, turn them on each other, raise their own creatures against them. Not
|
||||||
|
survival in spite of death, but power over it.
|
||||||
|
|
||||||
|
## Progression
|
||||||
|
|
||||||
|
A dedicated **"Necromancy"** skill grows not from experience but from a count of zombies put to
|
||||||
|
rest - its own counter, its own mechanic. Five tiers, each opening part of the arsenal:
|
||||||
|
|
||||||
|
| Tier | Threshold | What unlocks |
|
||||||
|
|---|---|---|
|
||||||
|
| Adept | from the start | Spirit Stone, Necromancer's Knife, Blue Portal Stone, Pyramid of Spirits |
|
||||||
|
| Adept (extra) | 20 zombies | Spatial Bracelet |
|
||||||
|
| Adept (extra) | 30 / 60 / 100 / 300 zombies | Knife mods: Tears of the Dead, Scavenger's Feast, Grave's Repose, Dark Sense |
|
||||||
|
| Journeyman | 500 zombies | Scroll of Deviation |
|
||||||
|
| Journeyman (extra) | 1400 / 1700 zombies | Knife mods: Dead Man's Grip, Dead Storm |
|
||||||
|
| Apprentice | 2000 zombies | Summon Zombie Dog, Beetles of the Lord, Summon Zombie Griffin |
|
||||||
|
| Necromancer | 3000 zombies | Summon Zombie Bear, Summon Zombie Wolf, Banshee's Scroll |
|
||||||
|
| Master | 5000 zombies | Black Portal Stone |
|
||||||
|
|
||||||
|
## Arsenal
|
||||||
|
|
||||||
|
- **Spirit Stone** - a thrown stone lit with necromantic energy. It catches a single zombie: that
|
||||||
|
one switches to your side and starts attacking other zombies instead of you.
|
||||||
|
- **Scroll of Deviation** - the same effect, but stronger: on impact it wins over every zombie in
|
||||||
|
the blast area at once rather than one. Consumed on use.
|
||||||
|
- **Necromancer's Knife** - a blackened bone blade. Its damage grows with the Necromancy skill:
|
||||||
|
nearly useless in unskilled hands, lethal for a levelled player. Heals the wielder for half the
|
||||||
|
damage dealt and marks the wounded zombie as a Victim - on death it is guaranteed to leave a
|
||||||
|
special bag of ingredients.
|
||||||
|
- **Six mods for the Necromancer's Knife only** - ordinary knife mods will not fit this weapon,
|
||||||
|
and these will not fit any other: **Tears of the Dead** (2 water from every zombie killed with
|
||||||
|
the blade), **Scavenger's Feast** (2 food per corpse), **Grave's Repose** (protection from heat
|
||||||
|
and cold while the knife is held), **Dead Man's Grip** (a zombie wounded by the blade is
|
||||||
|
slowed), **Dead Storm** (the power attack hits an area and causes bleeding, for 10 health
|
||||||
|
instead of 5 and double the stamina), **Dark Sense** (every nearby zombie is marked on the
|
||||||
|
compass and map while the knife is held).
|
||||||
|
- **Necromancer's Blood** - a ritual resource: a jar takes an empty jar, any knife in hand and 90%
|
||||||
|
of your current health per portion. An ingredient for the darkest recipes - the Black Portal and
|
||||||
|
the Knife itself.
|
||||||
|
- **Victim's Skin** and **Zombie Ash** - left by a zombie marked as a Victim by the Necromancer's
|
||||||
|
Knife. Ingredients for the summoning books and for most necromantic recipes respectively.
|
||||||
|
- **Blue Portal Stone** - hold the use button for 10 seconds to teleport to your bedroll.
|
||||||
|
Interrupted by any damage, or by a power attack before the time is up. Not consumed.
|
||||||
|
- **Spatial Bracelet** - a power attack opens a personal storage rift whose size grows with your
|
||||||
|
Necromancy level. Hold the regular attack on a block and that block is pulled straight into the
|
||||||
|
rift: ten seconds up close, one more per block of distance, behind the same circular indicator a
|
||||||
|
workbench pickup uses. The world drains to black and white while it runs. Damaged blocks,
|
||||||
|
containers with anything inside, a trader's ground and indestructible things like the world's
|
||||||
|
floor all refuse, each with its own message. The power attack interrupts the pull and opens the
|
||||||
|
rift instead.
|
||||||
|
- **Tin cans** (empty / with river water / with boiled water) - a reusable cycle instead of
|
||||||
|
single-use jars: fill them with water, boil it right on a campfire without a pot, drink, and the
|
||||||
|
can comes back empty. River water from a can can cause dysentery, like any murky water; boiled
|
||||||
|
water is safe. They hold less water than glass jars.
|
||||||
|
|
||||||
|
## Pets
|
||||||
|
|
||||||
|
Summoning books raise allied creatures that fight zombies rather than the player. A pet does not
|
||||||
|
"follow" in any strict sense - it wanders on its own, and if it strays further than 32 blocks
|
||||||
|
while not in combat, it is teleported back to its owner:
|
||||||
|
|
||||||
|
- **Zombie Dog**, **Zombie Bear**, **Zombie Wolf**, **Zombie Griffin** - permanent companions. One
|
||||||
|
of each kind can be kept at a time; a power attack recalls them into the book.
|
||||||
|
- **Beetles of the Lord** - a one-shot scroll releasing a swarm. The beetles scatter over a wide
|
||||||
|
radius on their own and sting zombies; a stung zombie switches to your side just like with the
|
||||||
|
Spirit Stone. The swarm cannot be recalled and only one can be active. Consumed on use.
|
||||||
|
- **Banshee's Scroll** - single use: on opening it screams with a banshee's voice and raises a
|
||||||
|
small hostile horde next to the player. These are not allies - they are as dangerous to you as
|
||||||
|
any other zombies.
|
||||||
|
|
||||||
|
## Base defence
|
||||||
|
|
||||||
|
- **Pyramid of Spirits** - a deployable block, not a held item. While you stand in its radius it
|
||||||
|
charms any uncharmed zombie nearby on its own and sets it alight with cold purple flame, turning
|
||||||
|
it against the other zombies instead of you or your base. Its block menu toggles the effect and
|
||||||
|
shows the edge of the radius.
|
||||||
|
|
||||||
|
## The Black Portal - the story's ending
|
||||||
|
|
||||||
|
**The Black Portal Stone unlocks at the top of the progression (5000 zombies) and is the mod's
|
||||||
|
ending.** Activating it opens a confirmation dialogue, stops the game and unfolds a full-screen
|
||||||
|
finale - it finishes the story the note started on day one. The scene closes on a choice of two:
|
||||||
|
one ends the story and returns to the main menu, the other puts the player back into the world to
|
||||||
|
keep playing.
|
||||||
|
|
||||||
|
The story texts live in `Config/Localization.csv` under the `necroFinal*` keys. They are
|
||||||
|
deliberately not retold here: a README gets read before the playthrough.
|
||||||
|
|
||||||
|
## Small things
|
||||||
|
|
||||||
|
- The opening note also pauses the game and plays a short flashback.
|
||||||
|
- Some decorative blocks (beds, water coolers, cardboard boxes) can be disassembled by holding the
|
||||||
|
key, like a workbench.
|
||||||
|
- Every trader looks different: black and white, half-transparent and matte. The necromancer deals
|
||||||
|
with the dead, and the only people still trading are not quite alive.
|
||||||
|
|
||||||
|
## Localization
|
||||||
|
|
||||||
|
**13 languages, complete:** Russian, English, German, Spanish, French, Italian, Japanese, Korean,
|
||||||
|
Polish, Brazilian Portuguese, Turkish, Simplified and Traditional Chinese. All 130 keys in
|
||||||
|
`Config/Localization.csv` are filled in, with no empty cells.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Unpack the `NecromancerTome` folder into `<game folder>/Mods/` (or into
|
||||||
|
`%APPDATA%/7DaysToDie/Mods/`) and launch the game. The mod ships Harmony libraries, so **EAC must
|
||||||
|
be turned off**.
|
||||||
|
|
||||||
|
Built for single-player: the vanilla pause only works in single-player, so in multiplayer the
|
||||||
|
story scenes play without stopping time.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Version 1.1.0 - the Spatial Bracelet learned to take blocks straight into the vault: hold its
|
||||||
|
regular attack on a block and it is pulled in after ten seconds, plus one more per block of
|
||||||
|
distance, with the world draining to black and white for the wait (which now also covers both
|
||||||
|
portal channels). Damaged blocks, containers with anything inside, a trader's ground and
|
||||||
|
indestructible things like the world's floor all refuse. Every trader is now rendered in black and
|
||||||
|
white, half-transparent and matte.
|
||||||
|
|
||||||
|
The previous version, 1.0.1, closed the first bug report from Nexus: the bracelet's contents no
|
||||||
|
longer disappear after leaving the game (the storage is saved in the player's own file, next to
|
||||||
|
the backpack).
|
||||||
|
|
||||||
|
All the announced content is implemented and passes testing in game. Of what was planned, only
|
||||||
|
part of the mod's own sound effects is missing. The description text for the website
|
||||||
|
(RU + EN) is in `SITE_DESCRIPTION.html` (WordPress block markup). The full technical history of
|
||||||
|
development and the text of the finale sit next to the mod in `BACKLOG.md` and `FINAL_TEXT.md` -
|
||||||
|
they are not part of the repository (spoilers and back-of-house).
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<li><strong>Кровь некроманта</strong> — ритуальный ресурс: пустая банка, любой нож в руках и 90% текущего здоровья за одну порцию. Ингредиент для самых тёмных рецептов.</li>
|
<li><strong>Кровь некроманта</strong> — ритуальный ресурс: пустая банка, любой нож в руках и 90% текущего здоровья за одну порцию. Ингредиент для самых тёмных рецептов.</li>
|
||||||
<li><strong>Кожа жертвы</strong> и <strong>Прах зомби</strong> — падают с зомби, помеченного ножом как Жертва. Основа книг призыва и большинства некромантских рецептов.</li>
|
<li><strong>Кожа жертвы</strong> и <strong>Прах зомби</strong> — падают с зомби, помеченного ножом как Жертва. Основа книг призыва и большинства некромантских рецептов.</li>
|
||||||
<li><strong>Синий портальный камень</strong> — держите кнопку использования 10 секунд, чтобы телепортироваться к своему спальнику. Любой урон прерывает переход. Не расходуется.</li>
|
<li><strong>Синий портальный камень</strong> — держите кнопку использования 10 секунд, чтобы телепортироваться к своему спальнику. Любой урон прерывает переход. Не расходуется.</li>
|
||||||
<li><strong>Пространственный браслет</strong> — силовая атака открывает личный разлом-хранилище, размер которого растёт вместе с уровнем Некромантии.</li>
|
<li><strong>Пространственный браслет</strong> — силовая атака открывает личный разлом-хранилище, размер которого растёт вместе с уровнем Некромантии. Обычная атака, зажатая на блоке, утаскивает этот блок прямо в хранилище: десять секунд вплотную и ещё по секунде за каждый блок расстояния, мир на это время обесцвечивается. Повреждённые блоки, контейнеры с содержимым, территория торговца и неразрушимое вроде дна мира не поддаются. Силовая атака прерывает утаскивание и открывает хранилище.</li>
|
||||||
<li><strong>Консервные банки</strong> — расходный цикл вместо одноразовых: наполнить водой, вскипятить прямо на костре без кастрюли, выпить, банка остаётся. Речная вода из банки может вызвать дизентерию, кипячёная безопасна. Вмещают меньше стеклянных.</li>
|
<li><strong>Консервные банки</strong> — расходный цикл вместо одноразовых: наполнить водой, вскипятить прямо на костре без кастрюли, выпить, банка остаётся. Речная вода из банки может вызвать дизентерию, кипячёная безопасна. Вмещают меньше стеклянных.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -49,11 +49,12 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<li>Стартовая записка при открытии тоже ставит игру на паузу и проигрывает короткий флэшбек.</li>
|
<li>Стартовая записка при открытии тоже ставит игру на паузу и проигрывает короткий флэшбек.</li>
|
||||||
<li>Часть декоративных блоков (кровати, кулеры, картонные коробки) разбирается удержанием, как верстак.</li>
|
<li>Часть декоративных блоков (кровати, кулеры, картонные коробки) разбирается удержанием, как верстак.</li>
|
||||||
|
<li>Все торговцы выглядят иначе: чёрно-белые, полупрозрачные и матовые. Некромант имеет дело с мёртвыми, и торгуют с ним те, кто уже не совсем жив.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>Локализация</h2>
|
<h2>Локализация</h2>
|
||||||
|
|
||||||
<strong>13 языков полностью:</strong> русский, английский, немецкий, испанский, французский, итальянский, японский, корейский, польский, португальский (Бразилия), турецкий, китайский упрощённый и традиционный. Все 123 строки переведены, пустых ячеек нет.
|
<strong>13 языков полностью:</strong> русский, английский, немецкий, испанский, французский, итальянский, японский, корейский, польский, португальский (Бразилия), турецкий, китайский упрощённый и традиционный. Все 130 строк переведены, пустых ячеек нет.
|
||||||
|
|
||||||
<h2>Установка</h2>
|
<h2>Установка</h2>
|
||||||
|
|
||||||
@@ -95,7 +96,7 @@ A dedicated <strong>Necromancy</strong> skill levels not from perk points and no
|
|||||||
<li><strong>Necromancer's Blood</strong> — a ritual resource: an empty jar, any knife in hand and 90% of your current health per portion. An ingredient for the darkest recipes.</li>
|
<li><strong>Necromancer's Blood</strong> — a ritual resource: an empty jar, any knife in hand and 90% of your current health per portion. An ingredient for the darkest recipes.</li>
|
||||||
<li><strong>Victim's Skin</strong> and <strong>Zombie Ash</strong> — dropped by a zombie marked as a Victim. The basis of the summoning books and most necromantic recipes.</li>
|
<li><strong>Victim's Skin</strong> and <strong>Zombie Ash</strong> — dropped by a zombie marked as a Victim. The basis of the summoning books and most necromantic recipes.</li>
|
||||||
<li><strong>Blue Portal Stone</strong> — hold the use button for 10 seconds to teleport to your bedroll. Any damage interrupts the channel. Not consumed.</li>
|
<li><strong>Blue Portal Stone</strong> — hold the use button for 10 seconds to teleport to your bedroll. Any damage interrupts the channel. Not consumed.</li>
|
||||||
<li><strong>Spatial Bracelet</strong> — a power attack opens a personal storage rift whose size grows with your Necromancy level.</li>
|
<li><strong>Spatial Bracelet</strong> — a power attack opens a personal storage rift whose size grows with your Necromancy level. Hold the regular attack on a block and it is pulled straight into the rift: ten seconds up close, one more per block of distance, with the world draining to black and white while it runs. Damaged blocks, containers with anything inside, a trader's ground and indestructible things like the world's floor all refuse. The power attack interrupts the pull and opens the rift instead.</li>
|
||||||
<li><strong>Tin cans</strong> — a reusable cycle instead of single-use jars: fill with water, boil it right on a campfire without a pot, drink, keep the can. River water from a can can cause dysentery, boiled water is safe. They hold less than glass jars.</li>
|
<li><strong>Tin cans</strong> — a reusable cycle instead of single-use jars: fill with water, boil it right on a campfire without a pot, drink, keep the can. River water from a can can cause dysentery, boiled water is safe. They hold less than glass jars.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -124,11 +125,12 @@ Activating it asks for confirmation, stops the game and unfolds a full-screen fi
|
|||||||
<ul>
|
<ul>
|
||||||
<li>The opening note also pauses the game and plays a short flashback.</li>
|
<li>The opening note also pauses the game and plays a short flashback.</li>
|
||||||
<li>Some decorative blocks (beds, water coolers, cardboard boxes) can be disassembled by holding the pick-up key, like a workbench.</li>
|
<li>Some decorative blocks (beds, water coolers, cardboard boxes) can be disassembled by holding the pick-up key, like a workbench.</li>
|
||||||
|
<li>Every trader looks different: black and white, half-transparent and matte. The necromancer deals with the dead, and the only people still trading are not quite alive.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3>Localization</h3>
|
<h3>Localization</h3>
|
||||||
|
|
||||||
<strong>13 languages, complete:</strong> English, German, Spanish, French, Italian, Japanese, Korean, Polish, Brazilian Portuguese, Russian, Turkish, Simplified and Traditional Chinese. All 123 strings are translated, with no empty cells.
|
<strong>13 languages, complete:</strong> English, German, Spanish, French, Italian, Japanese, Korean, Polish, Brazilian Portuguese, Russian, Turkish, Simplified and Traditional Chinese. All 130 strings are translated, with no empty cells.
|
||||||
|
|
||||||
<h3>Installation</h3>
|
<h3>Installation</h3>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace NecromancerTome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Raw byte-level half of the Spatial Bracelet's vault persistence. Lives in this satellite
|
||||||
|
/// assembly for exactly the reason PyramidWardWriteHelper.cs documents: PooledBinaryWriter's
|
||||||
|
/// Write overload set cannot be resolved from the main project at all (CS7069), so anything
|
||||||
|
/// that actually touches a PooledBinaryWriter/PooledBinaryReader has to be compiled here,
|
||||||
|
/// against the game's own mscorlib.
|
||||||
|
///
|
||||||
|
/// The split is deliberately drawn so that ONLY primitives cross it: this file knows about
|
||||||
|
/// byte arrays and stream positions, nothing else. Bag/ItemStack serialization stays in the
|
||||||
|
/// main project, where `Bag.Write(BinaryWriter)` against netstandard's own BinaryWriter
|
||||||
|
/// already compiles fine (proven - that is how the vault blob is built). Keeping Bag out of
|
||||||
|
/// here also keeps UnityEngine out of here, which this project's reference setup (NoStdLib +
|
||||||
|
/// the game's mscorlib, no UnityEngine at all) cannot tolerate.
|
||||||
|
///
|
||||||
|
/// BLOB LAYOUT, appended after everything vanilla PlayerDataFile.Write produces:
|
||||||
|
///
|
||||||
|
/// int64 Magic "NECROVLT"
|
||||||
|
/// int32 payloadLength
|
||||||
|
/// byte[] payload (opaque here; the main project builds and parses it)
|
||||||
|
///
|
||||||
|
/// The magic plus the explicit length is what makes this safe to append to somebody else's
|
||||||
|
/// format. On read we remember the stream position first: if the magic is not there (an old
|
||||||
|
/// save written before this feature, or a player-data packet from a party that does not have
|
||||||
|
/// the mod) the position is put back exactly where it was and the caller is told "no vault" -
|
||||||
|
/// so whatever the game reads next still reads the right bytes. That matters concretely:
|
||||||
|
/// PlayerDataFile.ReadNetwork calls Read and then goes on to read PlayerMetaInfo from the
|
||||||
|
/// same stream, and PlayerDataFile.Load treats ANY exception out of Read as "file is broken,
|
||||||
|
/// roll back to the .bak". Neither may be disturbed, so nothing here throws.
|
||||||
|
/// </summary>
|
||||||
|
public static class SpatialVaultBlobIO
|
||||||
|
{
|
||||||
|
/// <summary>ASCII "NECROVLT" as one int64 - distinctive enough that stray bytes will not
|
||||||
|
/// be mistaken for our block.</summary>
|
||||||
|
public const long Magic = 0x4E4543524F564C54L;
|
||||||
|
|
||||||
|
/// <summary>Magic (8) + length (4).</summary>
|
||||||
|
public const int HeaderSize = 12;
|
||||||
|
|
||||||
|
public static void Write(PooledBinaryWriter _bw, byte[] _payload)
|
||||||
|
{
|
||||||
|
if (_bw == null || _payload == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_bw.Write(Magic);
|
||||||
|
_bw.Write(_payload.Length);
|
||||||
|
_bw.Write(_payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the payload, or null when this stream carries no vault block. Never
|
||||||
|
/// throws, and never leaves the stream anywhere the caller did not expect: either just
|
||||||
|
/// past our whole block, or exactly back where it started.</summary>
|
||||||
|
public static byte[] TryRead(PooledBinaryReader _br)
|
||||||
|
{
|
||||||
|
if (_br == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream stream = _br.BaseStream;
|
||||||
|
if (stream == null || !stream.CanSeek)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
long startPosition = stream.Position;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (stream.Length - startPosition < HeaderSize)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (_br.ReadInt64() != Magic)
|
||||||
|
{
|
||||||
|
stream.Position = startPosition;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int length = _br.ReadInt32();
|
||||||
|
if (length < 0 || stream.Length - stream.Position < length)
|
||||||
|
{
|
||||||
|
stream.Position = startPosition;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _br.ReadBytes(length);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
stream.Position = startPosition;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user