Браслет утаскивает блоки в хранилище; обесцвечивание на время каналов

Обычная атака Пространственного браслета до сих пор ничего не делала. Теперь
она наводится на блок, показывает тот же круглый индикатор, что и демонтаж
верстака, и по его заполнении блок исчезает из мира и появляется в хранилище.

ВЕСЬ РЕЦЕПТ ВАНИЛЬНЫЙ. Block.TakeItemWithTimer и TakeItemWithTimerDone - это и
есть демонтаж верстака; взяты как есть, с двумя заменами: длительность и Bag
хранилища вместо рюкзака. Сообщения об отказе тоже ванильные
(ttRepairBeforePickup, ttBlockMissingPickup, ttCantPickupInUse,
ttWorkstationNotEmpty) - уже переведены на все языки игры, и игрок, хоть раз
забиравший верстак, знает, что они значат. Повреждённый блок отсекается первой
строкой, до открытия таймера: сообщение есть, индикатора нет.

Все проверки делаются ДВАЖДЫ, на старте и на финише: за десять секунд блок
можно расстрелять, выкопать, подменить, кто-то может открыть контейнер. Порядок
в финале принципиален - предмет кладётся в хранилище ПЕРВЫМ, и блок сносится,
только если он туда лёг; обратный порядок удалял бы блок из мира, когда
хранилище успело заполниться.

Цель - любой блок под прицелом, а не только то, что ваниль и так разрешает
поднимать. Отсюда две вещи, которых у узкого варианта не было бы: мультиблок
(дверь, кровать) приводится к родительской клетке через
multiBlockPos.GetParentPos, иначе половина модели осталась бы стоять; блоки без
предметной формы (ToItemValue пуст) отсекаются, иначе блок исчезал бы в обмен
на ничто.

СОДЕРЖИМОЕ КОНТЕЙНЕРОВ ПЕРЕЕХАТЬ НЕ МОЖЕТ: ItemStack в этой игре некуда
положить чужой инвентарь. Ваниль решает отказом, тем же и здесь, расширенным на
сундуки - в этой версии игры они моделируются композитным tile entity со
storage-фичей, поэтому вопрос задаётся фиче через
TryGetSelfOrFeature<ITileEntityLootable>.

ОТМЕНА СИЛОВОЙ АТАКОЙ, потому что десять секунд неподвижности после случайного
клика - долго, а ванильные выходы оба плохие: урон игрок не выбирает, а кнопка
активации не та, на которой уже лежит рука. Патч на XUiC_Timer.Update, как у
Синего портала, и вместе с его уроком: семантической PlayerActionsLocal.Secondary
недостаточно (модальное окно таймера держит фокус ввода - это выяснилось
багрепортом 29.08), поэтому рядом стоит сырой Input.GetMouseButtonDown(1).
Отдельная страховка от двойного открытия: отмена ловит НАЖАТИЕ, а обычная
силовая атака - ОТПУСКАНИЕ, и это одно нажатие.

ТЕРРИТОРИЯ ТОРГОВЦА И ДНО МИРА. Оба случая выглядят изнутри игры одинаково
("здесь ничего не ломается") и устроены совершенно по-разному. У торговца блоки
обычные, защищена ТЕРРИТОРИЯ: ваниль просто не зовёт DamageBlock внутри неё,
поэтому кирка не берёт, а браслет брал - он спрашивал про блок, а спрашивать
надо про место. Условие скопировано целиком, вместе с песочничной половиной
(World.SandboxUseTraderArea != Default || !IsWithinTraderArea): защита торговца
- серверная настройка, и сервер, который её выключил, не должен обнаружить, что
мод навязывает её сам. Дно мира - обратный случай: у бедрока CanDestroy=false на
МАТЕРИАЛЕ, и это спрошено как вопрос о материале, а не по имени блока.

Оба отказа с сообщением, хотя ваниль молчит: кирка, которая ничего не делает,
объясняет сама себя, а индикатор, который не появляется, выглядит как поломка
мода.

ДЛИТЕЛЬНОСТЬ РАСТЁТ С РАССТОЯНИЕМ - десять секунд вплотную, плюс секунда за
каждый полный блок. Расстояние не вычисляется заново: HitInfoDetails.distanceSq
- это квадрат длины ТОГО САМОГО луча, которым блок и выбран, а вычислять между
позициями значило бы выбрать точку в игроке (ноги? глаза?) и точку в блоке
(центр? грань?) и ошибиться хотя бы в одной. Пол, а не округление: только так
сходятся обе заданные точки - вплотную ровно 10, в пяти блоках ровно 15.

МИР ОБЕСЦВЕЧИВАЕТСЯ НА ВРЕМЯ ЛЮБОГО КАНАЛА - и утаскивания блока, и обоих
порталов (HarmonySrc/ChannelVision.cs, общий на оба, чтобы вид и время жили в
одном месте). Это штатный ScreenEffects игры: SetScreenEffect(name, intensity,
fadeTime), и плавность досталась даром - три секунды туда и три обратно это
третий аргумент. Эффект "Greyscale" выбран по тому, с кем НЕ придётся драться:
в него пишут только twitch_buffMonochrome и sandbox_blackandwhite, которых в
обычной сессии не бывает. "Dying"/"Dead" - те самые эффекты смерти, но их пишет
EntityPlayerLocal.Update из здоровья игрока при каждом изменении, и любой урон
посреди канала перехватил бы эффект. "Dark" дал бы затемнение, но принадлежит
buffCrouching и срабатывает на каждое приседание - поэтому затемнения нет
сознательно. Возврат красок вызывается на КАЖДОМ пути выхода, а в завершении
утаскивания - первой строкой, до всех проверок: десять секунд кончились и когда
блок забрали, и когда отказали.

Локализация: шесть новых ключей на 13 языков. Описание браслета переписано
(дважды за правку, вслед за механикой) - оно утверждало сначала "обычная атака
ничего не делает", потом "через десять секунд".

В игре проверено: забор блока работает. Отмена, обесцвечивание и рост
длительности - ещё нет.

---

The bracelet pulls blocks into the vault; colour drains during channels

The Spatial Bracelet's regular attack did nothing until now. It aims at a block,
shows the same circular indicator a workbench pickup does, and when it fills the
block leaves the world and appears in the vault.

THE WHOLE RECIPE IS VANILLA'S. Block.TakeItemWithTimer and TakeItemWithTimerDone
are the workbench pickup; taken as they are, with two substitutions - the
duration, and the vault's Bag instead of the backpack. The refusal messages are
vanilla's own keys too (ttRepairBeforePickup, ttBlockMissingPickup,
ttCantPickupInUse, ttWorkstationNotEmpty), already translated into every language
the game ships, and a player who has taken a workbench already knows them. A
damaged block is refused on the first line, before the timer opens: a message,
and no indicator.

Every guard is checked TWICE, once to open and once to finish: in ten seconds a
block can be shot, mined, replaced, or opened by someone else. The order at the
end matters - the item goes into the vault FIRST and the block is only removed if
it got there; the other way round deletes a block out of the world in exchange
for nothing when the vault filled up meanwhile.

The target is any block under the crosshair, not only what vanilla already lets
you take. Two things follow that the narrow version would never have faced: a
multiblock (a door, a bed) is resolved to its parent cell through
multiBlockPos.GetParentPos, or half the model would be left standing; and blocks
with no item form (ToItemValue comes back empty) are refused, or a block would
vanish in exchange for nothing.

CONTENTS CANNOT TRAVEL: an ItemStack in this game has nowhere to put another
container's inventory. Vanilla solves this by refusing, and so does this,
extended to chests - this version of the game models them as a composite tile
entity with a storage feature, so the question is asked of the feature through
TryGetSelfOrFeature<ITileEntityLootable>.

THE POWER ATTACK CANCELS, because ten seconds of standing still after a misclick
is long and vanilla's two escapes are both poor here: getting hit is not a
choice, and the activate key is not the button a hand is already on. The patch
sits on XUiC_Timer.Update like the Blue Portal Stone's, and carries its lesson:
the semantic PlayerActionsLocal.Secondary is not enough, because the modal timer
window holds input focus (found by a bug report on 29 Aug), so a raw
Input.GetMouseButtonDown(1) sits next to it. A separate guard stops one press
opening the vault twice: the cancel catches the button going DOWN, the ordinary
power attack catches it coming UP, and that is one press.

A TRADER'S GROUND AND THE WORLD'S FLOOR look identical from inside the game
("nothing breaks here") and are nothing alike underneath. A trader's blocks are
ordinary; it is the AREA that is protected - vanilla simply skips DamageBlock
inside it, which is why a pickaxe does nothing while the bracelet did not: it was
asking about the block when it had to ask about the place. The condition is
copied whole, sandbox half included (World.SandboxUseTraderArea != Default ||
!IsWithinTraderArea): trader protection is a server setting, and a server that
turned it off should not find this mod enforcing it anyway. The world's floor is
the opposite case - bedrock carries CanDestroy=false on its MATERIAL, and that is
asked as a question about the material rather than by block name.

Both refusals speak, where vanilla stays silent: a pickaxe that does nothing
explains itself, an indicator that never appears looks like this mod is broken.

THE CHANNEL GROWS WITH REACH - ten seconds up close, one more per full block.
The distance is not recomputed: HitInfoDetails.distanceSq is the squared length
of the very ray that chose this block, while measuring between positions would
mean picking a point in the player (feet? eyes?) and a point in the block
(centre? face?) and being wrong about one. Floor rather than round, because only
that makes both given anchors come out right - exactly 10 up close, exactly 15 at
five blocks.

COLOUR DRAINS OUT DURING ANY CHANNEL - the block pull and both portals
(HarmonySrc/ChannelVision.cs, shared so the look and the timing live in one
place). This is the game's own ScreenEffects: SetScreenEffect(name, intensity,
fadeTime), and the smoothness came free - three seconds each way is that third
argument. "Greyscale" was chosen by who else writes to it: only
twitch_buffMonochrome and sandbox_blackandwhite, neither of which happens in an
ordinary session. "Dying"/"Dead" are the death visuals being imitated, but
EntityPlayerLocal.Update writes "Dying" from the player's health on every change,
so any damage mid-channel would take it over. "Dark" would have supplied the
darkening half, but it belongs to buffCrouching and fires on every crouch - so
the darkening is deliberately absent. The colour is restored on EVERY exit path,
and in the pull's completion on the first line, before any check: the ten seconds
are over whether the block was taken or refused.

Localization: six new keys in 13 languages. The bracelet's description was
rewritten (twice in this change, following the mechanics) - it claimed first that
the regular attack does nothing, then that the pull takes ten seconds.

Confirmed in game: taking a block works. The cancel, the desaturation and the
distance scaling are not tested yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XN8J75vnum2qAVrtRUMKf7
This commit is contained in:
AlexCube
2026-09-14 16:22:55 +03:00
co-authored by Claude Opus 5
parent 275a739646
commit a6e19f9b97
7 changed files with 623 additions and 7 deletions
+508
View File
@@ -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);
}
}
}