Files
necromants-tome-7d2d-3-2/HarmonySrc/PortalStonePatch.cs
T
AlexCubeandClaude Opus 5 a6e19f9b97 Браслет утаскивает блоки в хранилище; обесцвечивание на время каналов
Обычная атака Пространственного браслета до сих пор ничего не делала. Теперь
она наводится на блок, показывает тот же круглый индикатор, что и демонтаж
верстака, и по его заполнении блок исчезает из мира и появляется в хранилище.

ВЕСЬ РЕЦЕПТ ВАНИЛЬНЫЙ. 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
2026-09-14 16:22:55 +03:00

290 lines
17 KiB
C#

using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Portal stones - BACKLOG.md item 6. See items.xml (thrownStonePortalBlue/
/// thrownStonePortalBlack) for the item definitions.
///
/// REWRITTEN 2026-08-29 after the first version's core assumption turned out wrong, confirmed
/// by the user testing it ("срабатывает мгновенно" - fires instantly, no 10s indicator). The
/// original version used Class="Eat"/Delay="10" on the assumption that Delay was a HELD-hold
/// duration (like the workbench's TakeDelay). Re-decompiling ItemActionEat more carefully
/// shows that's wrong: ExecuteAction only runs once per click, ON RELEASE
/// (`if (!_bReleased || ...) return;`), and for UseAnimation items the actual "eating in
/// progress" duration comes from `AnimationDelayData.AnimationDelay[HoldType].RayCast` - a
/// fixed-per-HoldType table that isn't exposed anywhere in Data/Config's XML at all (checked
/// directly - no matches for "RayCast"/"AnimationDelay" in any vanilla XML), so `Delay` on a
/// Class="Eat" item is really just a re-click COOLDOWN (how soon it can fire again), not a
/// channel length. That's why it looked instant - the real channel was whatever HoldType 40's
/// (the rock's) built-in eating-animation length happens to be, a couple seconds at most, not
/// our intended 10.
///
/// Fix: stop trying to make Class="Eat" do a long channel at all. Instead, Prefix
/// ItemActionEat.ExecuteAction itself (the exact click-release entry point, still using
/// Class="Eat" in XML purely as "a clickable item action", nothing about its own timing is
/// used any more) and, for our two stones, skip the original method entirely and open the
/// game's own generic countdown-timer UI directly - XUiC_Timer.OpenTimer(xui, seconds,
/// TimerEventData, ...), the exact same low-level primitive Block.TakeItemWithTimer itself
/// calls for the workbench pickup timer (decompiled both to confirm - TakeItemWithTimer is
/// just a block-flavored wrapper around this same generic UI system, nothing block-specific
/// about the timer itself). This gives a REAL visible progress bar/percent-fill UI (confirmed
/// via XUiC_Timer's own "percent"/"timeleft" bindings) for the full 10 seconds, and
/// TimerEventData.CloseOnHit=true makes it cancel automatically if the player takes damage
/// mid-channel (a real engine feature, not something built by hand) - matching "прерывается
/// при получении урона" without any extra code. The actual teleport only runs from
/// FullTimeFinishEvent, i.e. only if the timer runs all the way to completion.
///
/// Local-player-only, like every other UI-touching thing in this mod (SummonPatch.cs's
/// tooltips, etc.) - the underlying XUi/LocalPlayerUI system this timer needs is a
/// client-side-only concept, not something that makes sense for a remote player in this mod's
/// existing (single-player-focused) design.
///
/// POWER-ATTACK CANCEL added 2026-08-29 (user request: "прервать кнопкой силовой атаки" - a
/// zombie could jump the player mid-channel and they want an explicit escape, not just
/// CloseOnHit's "already got hit" reaction). Confirmed a cancelled channel never teleports
/// either way - closing the timer window early (Escape/CloseOnHit/this) fires
/// TimerEventData.CloseEvent, not FullTimeFinishEvent, and TeleportToBedroll only ever runs
/// from the latter (see OnChannelComplete below) - so "cancel = no teleport" was already true
/// by construction, just needed a new way to trigger a cancel.
/// vanilla's own TimerEventData.CancelWithActivateButton (already set true above) only checks
/// PlayerActionsPermanent.Activate (decompiled XUiC_Timer.Update to confirm) - a small
/// always-live action set TFP built specifically to stay readable during modal UI, which does
/// NOT include Secondary/power-attack at all (checked its full field list). Rather than
/// hijack Activate (the same key that STARTS the channel) or Cancel (Escape, not the button
/// asked for), Patch_XUiC_Timer_Update_PortalStoneCancel below Postfixes XUiC_Timer.Update
/// itself and polls PlayerActionsLocal.Secondary.WasPressed directly (the same underlying
/// action already used elsewhere in this mod as "power attack", e.g. summon books' Action1)
/// - NOT decompiled-confirmed whether this action still registers while the timer's modal
/// window has input focus (SetControllable(false) fires on open, decompiled from XUiC_Timer,
/// but that's a character/gameplay-layer flag, separate from the InControl input-polling
/// layer PlayerAction reads from - the two are believed independent, not proven end-to-end).
/// Test in-game; if power attack doesn't register while the bar is up, that gap is the first
/// thing to dig into (possibly needs reading raw InControl device state instead of the
/// semantic PlayerAction).
/// </summary>
[HarmonyPatch(typeof(ItemActionEat), "ExecuteAction")]
public static class Patch_ItemActionEat_ExecuteAction_PortalStones
{
public const string BlueStoneName = "thrownStonePortalBlue";
public const string BlackStoneName = "thrownStonePortalBlack";
public const float ChannelSeconds = 10f;
/// <summary>Sentinel stashed in TimerEventData.Data (an unused generic object field on
/// vanilla's own class) purely so Patch_XUiC_Timer_Update_PortalStoneCancel below can
/// tell "this is one of our portal-stone timers" apart from any other TimerEventData the
/// engine or another mod might have open (e.g. a workstation pickup timer, BACKLOG.md
/// item 9) - a reference-equality check on a private static object, nothing exposed or
/// read by vanilla code.</summary>
public static readonly object ChannelMarker = new object();
/// <summary>See buffs.xml - a marker/particle-carrier buff, added/removed directly by
/// this file rather than by any buff-trigger vocabulary.</summary>
public const string ChannelBuffName = "buffNecroPortalChannel";
public static bool Prefix(ItemActionData _actionData, bool _bReleased)
{
if (!_bReleased)
{
return true;
}
string itemName = _actionData?.invData?.itemValue?.ItemClass?.Name;
if (itemName != BlueStoneName && itemName != BlackStoneName)
{
return true;
}
if (!(_actionData.invData.holdingEntity is EntityPlayerLocal player))
{
// Not the local player (e.g. an AI or remote entity somehow holding this) - let
// vanilla Eat behavior run rather than silently doing nothing, same fallback
// shape used elsewhere in this mod for the local-player-only simplification.
return true;
}
Debug.Log("[NecromancerTome] PortalStonePatch: channel started for " + itemName + ", owner=" + player.entityId);
// Played directly here since ItemActionEat's own Sound_start handling is skipped
// entirely along with the rest of its ExecuteAction (see class comment) - a plain
// XML Sound_start property on this item would never fire otherwise.
player.PlayOneShot("swoosh");
// Black/smoke particle swirl for the duration of the channel (user request
// 2026-08-29) - see buffs.xml's buffNecroPortalChannel + ParticlePatch.cs (generalized
// to handle a player-targeted buff, not just the two zombie-facing ones it already
// had). A plain marker buff, added/removed directly here rather than through any
// 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.
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
{
CloseOnHit = true,
CancelWithActivateButton = true,
Data = ChannelMarker,
};
timerData.FullTimeFinishEvent += delegate
{
OnChannelComplete(player, itemName);
};
// CloseEvent fires when the timer window closes WITHOUT completing (cancelled by
// damage/power-attack/Cancel) - confirmed by decompiling XUiC_Timer.OnClose/
// timeReachedNull: timeReachedNull sets skipCloseEvent=true around the completion
// path specifically so CloseEvent does NOT also fire on a successful finish, only on
// every other way the window can close. FullTimeFinishEvent and CloseEvent are
// therefore mutually exclusive per channel - exactly "however it ends" from the
// class-level comment.
timerData.CloseEvent += delegate
{
Debug.Log("[NecromancerTome] PortalStonePatch: channel cancelled for " + itemName + ", owner=" + player.entityId);
player.Buffs.RemoveBuff(ChannelBuffName);
ChannelVision.End(player);
};
string labelKey = (itemName == BlueStoneName) ? "thrownStonePortalBlueChanneling" : "thrownStonePortalBlackChanneling";
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player);
XUiC_Timer.OpenTimer(playerUI.xui, ChannelSeconds, timerData, -1f, Localization.Get(labelKey));
// Skip ItemActionEat's own logic entirely for these two items - the click has been
// fully handled by opening our own timer instead.
return false;
}
public static void OnChannelComplete(EntityPlayerLocal player, string itemName)
{
Debug.Log("[NecromancerTome] PortalStonePatch: channel completed for " + itemName + ", owner=" + player.entityId);
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)
{
ShowBlackPortalConfirmation(player);
return;
}
TeleportToBedroll(player);
}
/// <summary>Black portal confirmation dialog, user request 2026-08-30
/// ("диалоговое окно... вы уверены"). XUiC_MessageBoxWindowGroup.ShowCustom(xui, title,
/// text, icon, setupCallback, ...) - the same generic Yes/No popup vanilla itself uses (its
/// own delete-item/disconnect confirmations, etc), decompiled directly. ShowOkCancel/
/// ShowConfirmCancel exist too but hardcode their button caption keys ("xuiOk"/"xuiCancel"/
/// "btnConfirm") - ShowCustom's _setupCallback is the only variant that lets the two buttons
/// be captioned "xuiYes"/"xuiNo" directly (both are real, already-localized vanilla keys,
/// confirmed against Data/Config/Localization.csv), matching the user's literal "да/нет"
/// wording. Buttons[0]/[2] (not [1]) is the same slot pairing ShowOkCancel/ShowConfirmCancel
/// themselves use internally - Buttons[1] is left unused, same as vanilla's own 2-button
/// dialogs.
///
/// Второй половины прежнего сценария - паузы и полноэкранного видео - здесь больше нет:
/// после "Да" управление уходит в FinalSlides (см. ActivateBlackPortal ниже), пауза живёт
/// там, а видео из финала убрано 2026-09-09. Разбор GameManager.Instance.Pause и
/// XUiC_VideoPlayer.PlayVideo переехал в NoteFlashbackPatch.cs - единственное место в моде,
/// где обе эти ванильные API ещё вызываются.</summary>
public static void ShowBlackPortalConfirmation(EntityPlayerLocal player)
{
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player);
XUiC_MessageBoxWindowGroup.ShowCustom(
playerUI.xui,
Localization.Get("thrownStonePortalBlackConfirmTitle"),
Localization.Get("thrownStonePortalBlackConfirmText"),
"",
delegate(XUiC_MessageBoxWindowGroup mb)
{
mb.Buttons[0].DefaultConfirm("xuiYes", delegate { ActivateBlackPortal(player); });
mb.Buttons[2].DefaultCancel("xuiNo", null);
},
_openMainMenuOnClose: false,
_modal: true,
_buttonOnOutsideClick: -1,
// Esc/outside-close counts as "No" - same convention ShowOkCancel/
// ShowConfirmCancel themselves use for their own Cancel slot (index 2).
_buttonOnExternalClose: 2);
}
/// <summary>ЗАМЕНЕНО 2026-09-09: раньше отсюда сразу стартовало полноэкранное видео
/// (Video/BlackPortal.webm), теперь запускается финальная сцена из шести слайдов с текстом
/// - FinalSlides.Begin. Причина в BACKLOG.md ("концовка серией диалоговых окон вместо
/// видео"): видео не локализуется, а текст слайдов идёт обычной строкой через
/// Localization.csv. Пауза и выход в главное меню никуда не делись - и то и другое
/// теперь живёт внутри FinalSlides. Видео из концовки убрано целиком 2026-09-09 -
/// ни здесь, ни в FinalSlides его больше нет.</summary>
public static void ActivateBlackPortal(EntityPlayerLocal player)
{
Debug.Log("[NecromancerTome] PortalStonePatch: black portal confirmed by owner=" + player.entityId + ", handing over to FinalSlides");
FinalSlides.Begin(player);
}
/// <summary>BedrollPos comes from EntityPlayer.PersistentPlayerData (decompiled - reads
/// GameManager.Instance.persistentPlayers.GetPlayerDataFromEntityID(entityId)), the same
/// field the game's own respawn-at-bedroll flow reads (PersistentPlayerData.BedrollPos /
/// HasBedrollPos, confirmed by decompiling that class directly). +0.5 on x/z centers the
/// block, +1 on y lifts the destination clear of the bedroll block itself - a reasonable
/// guess at a safe landing offset, not a decompiled/confirmed "correct" one (the
/// respawn-specific code that actually places a resurrected player likely does more
/// ground-safety checking than this; worth revisiting if the stone ever drops the player
/// inside a block). Teleport itself uses NetPackageTeleportPlayer, the exact same package
/// ConsoleCmdTeleportsAbs.ExecuteTeleport (the real "teleportplayer" console command)
/// uses - decompiled to confirm, not invented.</summary>
public static void TeleportToBedroll(EntityPlayerLocal player)
{
PersistentPlayerData data = player.PersistentPlayerData;
if (data == null || !data.HasBedrollPos)
{
GameManager.ShowTooltip(player, "thrownStonePortalBlueNoBedroll");
Debug.LogWarning("[NecromancerTome] PortalStonePatch: owner=" + player.entityId + " has no bedroll set, can't teleport");
return;
}
Vector3i bedrollPos = data.BedrollPos;
Vector3 destination = new Vector3(bedrollPos.x + 0.5f, bedrollPos.y + 1f, bedrollPos.z + 0.5f);
NetPackageTeleportPlayer package = NetPackageManager.GetPackage<NetPackageTeleportPlayer>().Setup(destination, null);
package.ProcessPackage(GameManager.Instance.World, GameManager.Instance);
player.PlayOneShot("spawnInStinger");
Debug.Log("[NecromancerTome] PortalStonePatch: owner=" + player.entityId + " teleported to bedroll " + bedrollPos);
}
}
/// <summary>Lets the power-attack ("Secondary") input cancel an in-progress portal-stone
/// channel - see the long comment on Patch_ItemActionEat_ExecuteAction_PortalStones above for
/// the full reasoning. Separate patch class/target method (XUiC_Timer.Update, not
/// ItemActionEat.ExecuteAction) since this has to run every frame WHILE the timer is open, not
/// once at click time.
///
/// FIXED 2026-08-29 (user report: cancel didn't work at all) - the semantic
/// PlayerActionsLocal.Secondary check alone (first version) apparently never registered while
/// the timer's modal window has input focus, confirming the exact risk flagged when this was
/// first written. Root cause not fully pinned down by decompilation (XUiC_Timer.OnOpen sets
/// SetControllable(false) on the player, and nothing found ties that flag directly to
/// PlayerAction's own InControl polling layer - the two are presumed independent but the
/// actual suppression point wasn't located). Rather than keep guessing which exact system
/// swallows it, added a SECOND, independent check straight to Unity's raw
/// Input.GetMouseButtonDown(1) (right mouse button - confirmed as Secondary's real default
/// KBM binding by decompiling PlayerActionsLocal.CreateActions) - raw Input polling reads
/// hardware state directly, bypassing InControl/PlayerAction and whatever gates it, so this
/// should fire regardless of modal-window suppression. Either check firing cancels the
/// channel; keeping the semantic one too costs nothing and covers gamepad Secondary
/// (LeftTrigger) if that one turns out to work. KBM-only fallback - if a gamepad player still
/// can't cancel, that's the next gap to close (would need the equivalent raw axis read).</summary>
[HarmonyPatch(typeof(XUiC_Timer), "Update")]
public static class Patch_XUiC_Timer_Update_PortalStoneCancel
{
public static void Postfix(XUiC_Timer __instance)
{
if (__instance == null || __instance.eventData == null || __instance.eventData.Data != Patch_ItemActionEat_ExecuteAction_PortalStones.ChannelMarker)
{
return;
}
PlayerActionsLocal input = __instance.xui?.playerUI?.playerInput;
bool cancelPressed = (input != null && input.Secondary.WasPressed) || Input.GetMouseButtonDown(1);
if (cancelPressed)
{
Debug.Log("[NecromancerTome] PortalStonePatch: channel cancelled via power attack");
__instance.xui.playerUI.windowManager.Close(__instance.windowGroup);
}
}
}
}