Обычная атака Пространственного браслета до сих пор ничего не делала. Теперь
она наводится на блок, показывает тот же круглый индикатор, что и демонтаж
верстака, и по его заполнении блок исчезает из мира и появляется в хранилище.
ВЕСЬ РЕЦЕПТ ВАНИЛЬНЫЙ. 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
200 lines
10 KiB
C#
200 lines
10 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using HarmonyLib;
|
||
using UnityEngine;
|
||
|
||
namespace NecromancerTome
|
||
{
|
||
/// <summary>
|
||
/// "Пространственный браслет" (Spatial Bracelet) - dictated 2026-08-30, implemented same
|
||
/// day. See items.xml (braceletSpatialVault) for the item - both Action0 and Action1 use
|
||
/// Class="Eat" purely as a click-catcher (same trick as every other Harmony-driven item this
|
||
/// mod already has), distinguished here by ItemActionData.indexInEntityOfAction (0/1), the
|
||
/// same field SummonPatch.cs already uses to tell a summon book's summon-click from its
|
||
/// recall-click.
|
||
///
|
||
/// POWER ATTACK (index 1) - personal storage, size scaling with Necromancy skill level:
|
||
/// - XUiC_BagStorageWindowGroup.Open(xui, entity, bag, lootContainer, title, ...) is the
|
||
/// REAL API EntityDrone.openStorageWindow() itself calls to show the drone's own cargo
|
||
/// window (decompiled EntityDrone directly to find this, not guessed) - reused directly
|
||
/// rather than reinventing a storage UI. LootContainer.GetLootContainer("roboticDrone")
|
||
/// is the same display/behavior template the drone's own window uses too - "как у дрона"
|
||
/// taken literally, not just as a vague size comparison.
|
||
/// - Slot count = Mathf.RoundToInt(necromancyLevel / 10f), per the user's own exact formula
|
||
/// ("1*скилл_некроманта/10 округлённый до целого") - read live from
|
||
/// player.Progression.GetProgressionValue("craftingNecroNecromancy").Level (decompiled
|
||
/// EntityAlive/Progression/ProgressionValue directly to confirm this exact call shape,
|
||
/// not guessed) - the SAME skill the Knife's own damage already scales with (capped at
|
||
/// level 5000, one level per zombie kill - see buffs.xml/progression.xml), so this grows
|
||
/// at the same pace as every other kill-count-tied payoff in this mod. Below level 10
|
||
/// this rounds to 0 - deliberately left as-is, not special-cased away, matching the
|
||
/// Knife's own "0 at 0 kills is a feature, not a bug" precedent - a tooltip explains it
|
||
/// instead of silently opening a useless empty window.
|
||
/// - PERSISTENCE - solved 2026-09-13, see SpatialVaultPersistence.cs. It was NOT solved
|
||
/// when this item shipped, and that shortfall is exactly what became the mod's first
|
||
/// Nexus bug report (youkia96581, 11 Sep 2026: "Items stored in the space bracelet will
|
||
/// disappear after leaving the game and going online again"). PlayerVaults below is still
|
||
/// the in-memory, entityId-keyed Dictionary it always was, but it is now only the session
|
||
/// cache: the durable copy is written into the player's own PlayerDataFile, alongside the
|
||
/// backpack, by four postfixes on FromPlayer/ToPlayer/Write/Read. Read that file's comment
|
||
/// for why there ("почему не сделать принцип как у ящика?" - because a chest's items live
|
||
/// in a chunk, and the bracelet's closest equivalent home is its owner's save data).
|
||
///
|
||
/// REGULAR ATTACK (index 0) - knock back + slow whatever zombie the crosshair is aimed at:
|
||
/// - Same raycast mechanism HarmonySrc/ThiefLoopPatch.cs already established for
|
||
/// braceletThiefLoop (GetLookRay + Physics.Raycast + RootTransformRefEntity.
|
||
/// FindEntityUpwards) - reused verbatim, just resolving to EntityZombie instead of
|
||
/// EntityLootContainer.
|
||
/// - Slow: zombie.Buffs.AddBuff("buffInjurySlow") - the exact same vanilla debuff already
|
||
/// reused elsewhere in this mod (the Dog's own bite, necroMeleeHandZombieDog).
|
||
/// - Knockback: DELIBERATELY a straight Entity.SetPosition "shove" (same API
|
||
/// PetFollowPatch.cs already uses to reposition pets), NOT a physics/ragdoll impulse.
|
||
/// Found real candidates for "proper" knockback while researching this
|
||
/// (EntityAlive.DoRagdoll(in DamageResponse), DamageResponse.ImpulseScale/HitDirection),
|
||
/// but fully reverse-engineering how a real DamageResponse gets built and fed into that
|
||
/// during normal combat - all its other fields (Source, Strength, Stun, ArmorSlot, etc.)
|
||
/// - would have taken real additional decompilation with no guarantee of getting all the
|
||
/// coordinate/enum conventions right on the first try. A direct position shove is cruder
|
||
/// (no animation, the zombie just appears further away) but uses an API this exact file's
|
||
/// own family already relies on successfully - chosen for certainty over polish. Revisit
|
||
/// with DoRagdoll if the teleport-shove feels too crude in testing.
|
||
/// </summary>
|
||
[HarmonyPatch(typeof(ItemActionEat), "ExecuteAction")]
|
||
public static class Patch_ItemActionEat_ExecuteAction_SpatialVault
|
||
{
|
||
public const string ItemName = "braceletSpatialVault";
|
||
public const string NecromancySkillName = "craftingNecroNecromancy";
|
||
public const float MaxRange = 50f;
|
||
public const float ShoveDistance = 6f;
|
||
|
||
/// <summary>Session cache only - the durable copy lives on disk, see
|
||
/// SpatialVaultPersistence.cs. Cleared on WorldShuttingDown so a different save loaded
|
||
/// afterwards cannot inherit this world's vault through a recycled entityId.</summary>
|
||
public static readonly Dictionary<int, Bag> PlayerVaults = new Dictionary<int, Bag>();
|
||
|
||
public static bool Prefix(ItemActionData _actionData, bool _bReleased)
|
||
{
|
||
if (!_bReleased)
|
||
{
|
||
return true;
|
||
}
|
||
string itemName = _actionData?.invData?.itemValue?.ItemClass?.Name;
|
||
if (itemName != ItemName)
|
||
{
|
||
return true;
|
||
}
|
||
if (!(_actionData.invData.holdingEntity is EntityPlayerLocal player))
|
||
{
|
||
return true;
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// The regular attack takes the block under the crosshair into the vault from
|
||
// 2026-09-14 - see SpatialVaultPickupPatch.cs. Before that it did nothing at all,
|
||
// by direct user request of 2026-08-30 ("пусть тогда обычная атака у
|
||
// пространственного браслета не делает ничего"), after the knockback+slow version did not
|
||
// visibly do anything in testing. ShoveZombieAtCrosshair is kept below, unused,
|
||
// because that abandoned version was never shown to be WRONG - only invisible.
|
||
SpatialVaultPickup.Begin(player);
|
||
}
|
||
|
||
// Skip ItemActionEat's own logic entirely - the click has been fully handled here.
|
||
return false;
|
||
}
|
||
|
||
public static void OpenVault(EntityPlayerLocal player)
|
||
{
|
||
ProgressionValue progressionValue = player.Progression?.GetProgressionValue(NecromancySkillName);
|
||
int level = progressionValue != null ? progressionValue.Level : 0;
|
||
int slotCount = Mathf.RoundToInt(level / 10f);
|
||
if (slotCount <= 0)
|
||
{
|
||
GameManager.ShowTooltip(player, "braceletSpatialVaultTooWeak");
|
||
return;
|
||
}
|
||
|
||
if (!PlayerVaults.TryGetValue(player.entityId, out Bag bag))
|
||
{
|
||
// Normally a restored vault is already here - the ToPlayer postfix puts it in
|
||
// when the game applies the save file to the spawning player. LastLoadedVault is
|
||
// the safety net for when that chain does not complete: opening the bracelet must
|
||
// never be what silently starts an empty vault over a saved one. Only then is a
|
||
// genuinely new bag created.
|
||
bag = SpatialVaultPersistence.LastLoadedVault ?? new Bag(slotCount);
|
||
if (bag == SpatialVaultPersistence.LastLoadedVault)
|
||
{
|
||
Debug.Log("[NecromancerTome] SpatialVaultPatch: session cache was empty, adopted the last loaded vault (" +
|
||
bag.SlotCount + " slots, " + bag.GetUsedSlotCount() + " used)");
|
||
}
|
||
PlayerVaults[player.entityId] = bag;
|
||
}
|
||
else if (bag.SlotCount < slotCount)
|
||
{
|
||
// Grow, never shrink - the skill level only ever goes up, so this only ever
|
||
// copies existing stacks into a bigger array, same shape
|
||
// EntityLootContainer.SetContent itself uses when it needs to resize a bag.
|
||
ItemStack[] oldSlots = bag.GetSlots();
|
||
ItemStack[] newSlots = ItemStack.CreateArray(slotCount);
|
||
Array.Copy(oldSlots, newSlots, oldSlots.Length);
|
||
bag.SetSlots(newSlots);
|
||
}
|
||
|
||
Debug.Log("[NecromancerTome] SpatialVaultPatch: owner=" + player.entityId + " opened vault, " + slotCount + " slots (Necromancy level " + level + ")");
|
||
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player);
|
||
// The trailing callbacks are vanilla's own optional parameters (_onModified, _onClose).
|
||
// _onModified is not needed: the vault lives in PlayerVaults, and PlayerDataFile's
|
||
// FromPlayer postfix reads it fresh every time the game saves the player, so there is
|
||
// nothing to flush per item move. _onClose asks for a player-data save right away, so
|
||
// closing the window is a commit point rather than waiting for the next autosave -
|
||
// SaveLocalPlayerData is the game's own routine call and no-ops when saving is not
|
||
// active (which is the correct behaviour on a client, where the server owns the file).
|
||
XUiC_BagStorageWindowGroup.Open(
|
||
playerUI.xui,
|
||
player,
|
||
bag,
|
||
LootContainer.GetLootContainer("roboticDrone"),
|
||
Localization.Get("braceletSpatialVaultWindowTitle"),
|
||
null,
|
||
() => GameManager.Instance.SaveLocalPlayerData());
|
||
}
|
||
|
||
public static void ShoveZombieAtCrosshair(EntityPlayerLocal player)
|
||
{
|
||
Ray ray = player.GetLookRay();
|
||
if (!Physics.Raycast(ray, out RaycastHit hit, MaxRange))
|
||
{
|
||
GameManager.ShowTooltip(player, "braceletSpatialVaultNoTarget");
|
||
return;
|
||
}
|
||
Transform entityTransform = RootTransformRefEntity.FindEntityUpwards(hit.collider.transform);
|
||
Entity entity = entityTransform != null ? entityTransform.GetComponent<Entity>() : null;
|
||
if (!(entity is EntityZombie zombie) || zombie.IsDead())
|
||
{
|
||
GameManager.ShowTooltip(player, "braceletSpatialVaultNoTarget");
|
||
return;
|
||
}
|
||
|
||
zombie.Buffs?.AddBuff("buffInjurySlow");
|
||
|
||
Vector3 shoveDir = zombie.position - player.position;
|
||
shoveDir.y = 0f;
|
||
shoveDir = shoveDir.sqrMagnitude > 0.01f ? shoveDir.normalized : player.transform.forward;
|
||
Vector3 destination = zombie.position + shoveDir * ShoveDistance + Vector3.up * 1f;
|
||
zombie.SetPosition(destination, true);
|
||
|
||
player.PlayOneShot("swoosh");
|
||
Debug.Log("[NecromancerTome] SpatialVaultPatch: owner=" + player.entityId + " shoved zombie " + zombie.entityId);
|
||
}
|
||
}
|
||
}
|