Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7172681353 | ||
|
|
a6e19f9b97 | ||
|
|
275a739646 |
File diff suppressed because one or more lines are too long
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -89,14 +89,24 @@ namespace NecromancerTome
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_actionData.indexInEntityOfAction == 1)
|
if (_actionData.indexInEntityOfAction == 1)
|
||||||
|
{
|
||||||
|
// 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);
|
OpenVault(player);
|
||||||
}
|
}
|
||||||
// else: regular attack (index 0) deliberately does nothing, per direct user request
|
}
|
||||||
// 2026-08-30 ("пусть тогда обычная атака у пространственного браслета не делает
|
else
|
||||||
// ничего") after the knockback+slow version didn't visibly do anything in testing -
|
{
|
||||||
// rather than debug ShoveZombieAtCrosshair blind (kept below, unused, in case this
|
// The regular attack takes the block under the crosshair into the vault from
|
||||||
// gets revisited), just absorb the click silently.
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
// 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;
|
||||||
|
|||||||
@@ -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="1.0.1" />
|
<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.
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,8 @@
|
|||||||
# Книга некроманта / Necromancer's Tome (NecromancerTome)
|
# Книга некроманта / Necromancer's Tome (NecromancerTome)
|
||||||
|
|
||||||
**Версия 1.0.1** — для 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,9 +129,166 @@
|
|||||||
|
|
||||||
## Статус
|
## Статус
|
||||||
|
|
||||||
Версия 1.0.1 — исправление по первому баг-репорту с Nexus: содержимое Пространственного
|
Версия 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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user