- Пирамида: StreamModeWrite/Read теперь глобальные перечисления (в 3.2 были внутри TileEntity). - Хранилище: Bag поверх ItemStackGrid (конструктор с местом хранения, Resize, ItemGrid), запись и чтение через PooledBinaryWriter/Reader. Формат хранилища 2; формат 1 из сейвов 3.2 читается. Незнакомый номер формата больше не считается «хранилища нет» - байты сохраняются. - Сфера на браслете: ModificationCount/GetModification/SetModification. - Непустой сундук: TEFeatureStorage.IsEmpty (ITileEntityLootable в 3.3 нет). - Поиск ножа в инвентаре через ItemGrid. - GameVersionGuard пускает только 3.3. Проверено в игре на V 3.3.0 b14: хранилище из сейва 3.2 (24 стопки) прочиталось, сохранилось и перечиталось; Пирамида включается; «Жертва», обыск трупа и мешки работают. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
684 lines
32 KiB
C#
684 lines
32 KiB
C#
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";
|
|
public const string MsgNoMod = "braceletSpatialVaultNoMod";
|
|
|
|
/// <summary>The denial sound vanilla plays with these tooltips.</summary>
|
|
public const string DeniedSound = "ui_denied";
|
|
|
|
/// <summary>What the bracelet burns to pull a block: the Кровавая сфера, dictated
|
|
/// 2026-09-15. Its own definition is in Config/item_modifiers.xml.
|
|
///
|
|
/// WHY A SEPARATE ITEM AND NOT THE BLOOD ITSELF - this is the scar of the 15.09 accident
|
|
/// and the reason not to "simplify" it back. The blood was moved into item_modifiers.xml
|
|
/// so it could be installed here, and that destroyed a character's save along with its
|
|
/// backup: an item's CLASS decides the byte layout of every stack of it
|
|
/// (ItemValue.Read:1094 / Write:1228), so a save written before the move became
|
|
/// unreadable. The sphere is a NEW name that no old save contains, which is what makes it
|
|
/// safe. Full write-up in BACKLOG.md.
|
|
///
|
|
/// The Кровавый камень, when it exists, goes in the same slot and is NOT charged here -
|
|
/// it is the infinite one. Nothing to add for it: this check names the sphere, so
|
|
/// anything else in the slot simply pays nothing.</summary>
|
|
public const string ChargeItemName = "resourceBloodSphere";
|
|
|
|
/// <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>The bracelet this pull was started with, so the blood that pays for it is
|
|
/// taken from the flask in THAT bracelet. Kept as the live ItemValue rather than
|
|
/// looked up again at the end: mods live on the instance, and ten seconds is long
|
|
/// enough for the player to have scrolled to another slot.</summary>
|
|
public ItemValue Bracelet;
|
|
|
|
/// <summary>What this particular pull costs, in seconds - computed once when the
|
|
/// channel starts (user request 2026-09-15: "пусть количество секунд требуемое для
|
|
/// поглощения блока записывается в отдельную переменную"). It is the same number the
|
|
/// timer counts down and the same number the flask pays, and that is the point of
|
|
/// storing it instead of recomputing: by the time the channel ends the player may
|
|
/// have turned away, the ray is gone, and a second call to ChannelSecondsFor would
|
|
/// quietly charge for a different block than the one that was taken.</summary>
|
|
public float ChannelSeconds;
|
|
}
|
|
|
|
/// <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, ItemValue _bracelet)
|
|
{
|
|
// AN EMPTY MOD SLOT REFUSES THE WHOLE ACTION (user request 2026-09-15: "пусть обычная
|
|
// атака (поглощение блока) у пространственного хранилища не работает, если у хранилища
|
|
// в слоте модификаций пусто"). FIRST, deliberately, ahead of every other check in this
|
|
// method: the others are about the TARGET (no block, wrong kind of block, vault full),
|
|
// and telling the player "no block there" when the real problem is his empty bracelet
|
|
// would send him looking in the wrong place. This one is about the tool, so it is
|
|
// answered before the tool is even pointed at anything.
|
|
//
|
|
// ItemValue.HasMods() is the game's own test and the right one: it walks Modifications
|
|
// only, skipping both nulls and IsEmpty() slots, and does NOT count CosmeticMods - a
|
|
// dye would otherwise have read as "the bracelet is loaded". The bracelet has no
|
|
// cosmetic slot anyway (canHaveCosmetic is deliberately absent from its Tags, see
|
|
// items.xml), so this is belt and braces rather than a live case - but the next item
|
|
// that reuses this pattern may well have one.
|
|
//
|
|
// The ItemValue is handed in rather than read from the player, because the caller
|
|
// already holds the exact instance the click came from (_actionData.invData.itemValue)
|
|
// and mods live on the INSTANCE, not on the ItemClass. Two bracelets in the same
|
|
// inventory can legitimately disagree about whether they are loaded.
|
|
if (_bracelet == null || !_bracelet.HasMods())
|
|
{
|
|
Deny(_player, MsgNoMod);
|
|
return;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// Computed HERE, while the ray still exists, and carried in the job from this point
|
|
// on - see PickupJob.ChannelSeconds.
|
|
float channelSeconds = ChannelSecondsFor(hitInfo);
|
|
|
|
TimerEventData timerData = new TimerEventData
|
|
{
|
|
Data = new PickupJob
|
|
{
|
|
Player = _player,
|
|
Position = position,
|
|
Expected = blockValue,
|
|
Bracelet = _bracelet,
|
|
ChannelSeconds = channelSeconds
|
|
},
|
|
// 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);
|
|
};
|
|
|
|
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);
|
|
|
|
// AFTER the block is gone and the item is in the vault, never before: every refusal
|
|
// above returns early, and blood paid for a pull that was then refused would be blood
|
|
// charged for nothing. This is the only place the flask is spent.
|
|
float spent = SpendCharge(job);
|
|
|
|
// 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. The flask's UseTimes
|
|
// rides along in the same save - it lives on the bracelet in the player's inventory.
|
|
GameManager.Instance.SaveLocalPlayerData();
|
|
|
|
Debug.Log("[NecromancerTome] SpatialVaultPickup: owner=" + job.Player.entityId + " took " +
|
|
blockValue.Block.GetBlockName() + " at " + job.Position + " into the vault for " +
|
|
spent.ToString("0.#") + " of charge");
|
|
}
|
|
|
|
/// <summary>Charges this pull to the Кровавая сфера in the bracelet's mod slot, one point
|
|
/// of durability per second of channel (user request 2026-09-15). Returns what was
|
|
/// actually taken.
|
|
///
|
|
/// WHY THE NAME CHECK AND NOT "whatever is in the slot". The slot is meant to take the
|
|
/// Кровавый камень too, and that one is explicitly the infinite version - it must pay
|
|
/// nothing. Naming the sphere here gets that for free: anything else installed is simply
|
|
/// not charged, and the pull still happens.
|
|
///
|
|
/// NOT ENOUGH BLOOD IS NOT A REFUSAL (user request 2026-09-15: "пусть поглощение всё-равно
|
|
/// сработает, но флакон крови некроманта после этого пусть исчезнет из слота", clarified to
|
|
/// "если прочность 0 или меньше, пусть флакон исчезнет из слота"). So the last pull is
|
|
/// always free of charge in the sense that matters - it completes - and the flask simply
|
|
/// does not survive it. The charge is therefore NOT clamped: UseTimes is allowed to go past
|
|
/// MaxUseTimes, because the only thing that then reads it is the emptiness test right
|
|
/// below, and a clamp would have made "spent exactly to zero" and "overdrawn" look the
|
|
/// same at the moment the difference stopped mattering anyway.
|
|
///
|
|
/// The test is "durability 0 or less", not "could not cover the cost", and those are not
|
|
/// the same rule: a flask with exactly enough left is also gone afterwards. That is the
|
|
/// user's own correction and it closes the hole the first version would have left - a
|
|
/// flask sitting at 0/1000 in the slot, counting as "something is installed" for the empty-
|
|
/// slot gate in Begin, and pulling blocks for free forever.
|
|
///
|
|
/// WHY THE NAME CHECK AND NOT "whatever is in the slot" - see above; a mod that is not
|
|
/// blood pays nothing, is not emptied, and the pull still happens.</summary>
|
|
public static float SpendCharge(PickupJob _job)
|
|
{
|
|
ItemValue bracelet = _job.Bracelet;
|
|
if (bracelet == null || bracelet.ModificationCount == 0 || _job.ChannelSeconds <= 0f)
|
|
{
|
|
return 0f;
|
|
}
|
|
|
|
float spent = 0f;
|
|
bool emptied = false;
|
|
for (int i = 0; i < bracelet.ModificationCount; i++)
|
|
{
|
|
ItemValue mod = bracelet.GetModification(i);
|
|
if (mod == null || mod.IsEmpty())
|
|
{
|
|
continue;
|
|
}
|
|
if (mod.ItemClass == null || mod.ItemClass.Name != ChargeItemName)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
int max = mod.MaxUseTimes;
|
|
mod.UseTimes += _job.ChannelSeconds;
|
|
spent += _job.ChannelSeconds;
|
|
|
|
// max <= 0 means this flask has no durability at all - a DegradationMax that did
|
|
// not resolve. Draining something with no capacity would delete it on the first
|
|
// pull, which is a config bug eating the player's item, so it is left alone and
|
|
// said out loud instead.
|
|
if (max <= 0)
|
|
{
|
|
Debug.LogWarning("[NecromancerTome] SpatialVaultPickup: sphere in slot " + i +
|
|
" has MaxUseTimes 0 - nothing to spend, flask kept. Check the " +
|
|
"DegradationMax passive_effect in Config/item_modifiers.xml");
|
|
continue;
|
|
}
|
|
|
|
if (mod.UseTimes >= max)
|
|
{
|
|
// ItemValue.None is what an empty mod slot holds - type 0, which is exactly
|
|
// what IsEmpty() tests for, so the slot reads as free to every other piece of
|
|
// code including the gate in Begin.
|
|
bracelet.SetModification(i, ItemValue.None);
|
|
emptied = true;
|
|
Debug.Log("[NecromancerTome] SpatialVaultPickup: sphere in slot " + i +
|
|
" ran out (" + mod.UseTimes.ToString("0.#") + "/" + max +
|
|
") - sphere removed from the bracelet");
|
|
}
|
|
else
|
|
{
|
|
Debug.Log("[NecromancerTome] SpatialVaultPickup: sphere in slot " + i + " now " +
|
|
mod.UseTimes.ToString("0.#") + "/" + max + " used");
|
|
}
|
|
}
|
|
|
|
if (emptied)
|
|
{
|
|
// Vanilla's own answer to "the thing you were using is gone" - the same cue
|
|
// ItemAction.HandleItemBreak plays. An item vanishing out of a slot in silence is
|
|
// the one outcome here the player could miss entirely.
|
|
_job.Player.PlayOneShot("itembreak");
|
|
}
|
|
if (spent > 0f && _job.Player.inventory != null)
|
|
{
|
|
// Without this the number is right and the bar on the toolbelt icon is stale
|
|
// until something else happens to redraw it.
|
|
_job.Player.inventory.CallOnToolbeltChangedInternal();
|
|
}
|
|
return spent;
|
|
}
|
|
|
|
/// <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 TEFeatureStorage storage) && !storage.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)
|
|
{
|
|
if (SpatialVaultPersistence.IsLocked(_player))
|
|
{
|
|
Deny(_player, "braceletSpatialVaultUnreadable");
|
|
return null;
|
|
}
|
|
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(new Vector2i(slotCount, 1), XUiC_ItemStack.StackLocationTypes.LootContainer, null);
|
|
Patch_ItemActionEat_ExecuteAction_SpatialVault.PlayerVaults[_player.entityId] = bag;
|
|
}
|
|
if (bag.SlotCount < slotCount)
|
|
{
|
|
bag.Resize(new Vector2i(slotCount, 1));
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
}
|