using HarmonyLib; using UnityEngine; namespace NecromancerTome { /// /// 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). /// [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; /// 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. public static readonly object ChannelMarker = new object(); /// See buffs.xml - a marker/particle-carrier buff, added/removed directly by /// this file rather than by any buff-trigger vocabulary. 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); 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); }; 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); if (itemName == BlackStoneName) { ShowBlackPortalConfirmation(player); return; } TeleportToBedroll(player); } /// Black portal confirmation + fullscreen video, user request 2026-08-30 /// ("диалоговое окно... вы уверены... Если Да, то игра останавливается и проигрывается /// видео"). Real APIs, both decompiled directly: /// - XUiC_MessageBoxWindowGroup.ShowCustom(xui, title, text, icon, setupCallback, ...) - /// the same generic Yes/No popup vanilla itself uses (its own delete-item/disconnect /// confirmations, etc). 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. /// - GameManager.Instance.Pause(bool) - decompiled GameManager.updatePauseState: sets /// Time.timeScale=0 for real, but ONLY takes effect in singleplayer (an SP-only check /// baked into vanilla itself, not a limitation added by this mod) - a deliberate, /// documented no-op in multiplayer rather than something silently broken. /// - XUiC_VideoPlayer.PlayVideo(xui, VideoData, skippable, onFinished) - opens the same /// fullscreen "VideoPlayer" window vanilla's own TFP intro/menu-background videos use. /// Decompiled XUiV_Video confirms video playback isn't gated by Time.timeScale, so it /// keeps playing correctly while paused. skippable=true (Cancel key) so a broken/ /// missing video file can't soft-lock the player - XUiV_Video.OnVideoErrorReceived /// already auto-closes on a bad file on its own, this is just a second, player-facing /// way out. /// /// VIDEO FILE: per direct user instruction 2026-08-30 ("Пока файл видео замени /// заглушкой. Потом поставим нормальный"), Video/BlackPortal.webm is currently a COPY OF /// VANILLA'S OWN TFP_Intro.webm (from 7DaysToDie_Data/StreamingAssets/Video/), not real /// mod content - purely so the full dialog -> pause -> video -> unpause pipeline is /// genuinely testable end-to-end right now. Swap that one file for the real video later; /// nothing else needs to change (reuse the same filename, or update BlackPortalVideoPath /// below if the real file gets a different name). /// "@modfolder(NecromancerTome):..." is the exact mod-relative path syntax /// XUiV_Video.startVideo resolves via ModManager.TryPatchModPathString (decompiled to /// confirm - looks for "@modfolder(<mod name>):" and substitutes the mod's real /// install path; "NecromancerTome" here is this mod's own ModInfo.xml Name, not its /// DisplayName). public const string BlackPortalVideoPath = "@modfolder(NecromancerTome):Video/BlackPortal.webm"; 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); } /// ЗАМЕНЕНО 2026-09-09: раньше отсюда сразу стартовало полноэкранное видео /// (BlackPortalVideoPath), теперь запускается финальная сцена из шести слайдов с текстом /// - FinalSlides.Begin. Причина в BACKLOG.md ("концовка серией диалоговых окон вместо /// видео"): видео не локализуется, а текст слайдов идёт обычной строкой через /// Localization.csv. Пауза и выход в главное меню никуда не делись - и то и другое /// теперь живёт внутри FinalSlides, а видео осталось финальным аккордом ПОСЛЕ выбора /// концовки на последнем слайде. /// /// Всё, что описано в комментарии к BlackPortalVideoPath выше, по-прежнему верно и /// применяется - просто к двум новым файлам (FinalSlides.StayVideoPath / /// ReturnVideoPath) вместо одного. Сама константа BlackPortalVideoPath больше не /// используется и оставлена только как документация к разбору "@modfolder(...)" и /// XUiC_VideoPlayer.PlayVideo, на который FinalSlides ссылается. public static void ActivateBlackPortal(EntityPlayerLocal player) { Debug.Log("[NecromancerTome] PortalStonePatch: black portal confirmed by owner=" + player.entityId + ", handing over to FinalSlides"); FinalSlides.Begin(player); } /// Прежняя концовка "сразу видео, потом главное меню". Больше ниоткуда не /// вызывается (см. ActivateBlackPortal выше) - оставлена целиком, потому что весь разбор /// Pause/PlayVideo/Disconnect в её комментариях остаётся актуальным и на неё ссылается /// FinalSlides. Удалять при следующей уборке, если так и не понадобится. public static void PlayBlackPortalVideoLegacy(EntityPlayerLocal player) { Debug.Log("[NecromancerTome] PortalStonePatch: black portal confirmed by owner=" + player.entityId + ", pausing + playing video"); GameManager.Instance.Pause(true); LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player); VideoData videoData = new VideoData { url = BlackPortalVideoPath }; XUiC_VideoPlayer.PlayVideo(playerUI.xui, videoData, true, delegate(bool skipped) { // EXIT TO MAIN MENU after the video, user request 2026-08-30 ("После видео нужно // выходить из игры в главное меню") - fires whether the video played to the end // or was skipped (Cancel key / a bad file), same as any other "the video is over" // outcome. GameManager.Instance.Disconnect() is not a guess - it's the EXACT same // call the real in-game ESC menu's own "Exit to Main Menu" button uses // (decompiled XUiC_InGameMenuWindow.exitGame/BtnExit_OnPressed to confirm: it's a // thin wrapper straight to this method). Handles everything a clean exit needs by // itself - closes modal windows, un-pauses (calls Pause(false) internally, so no // separate unpause call needed here), saves/shuts down the local server, and // returns to XUiC_MainMenu - not reinventing any of that by hand. Replaces the // earlier "thrownStonePortalBlackNotBound" tooltip placeholder entirely: with a // real exit-to-menu ending, staying in-game and showing a tooltip no longer makes // sense (BACKLOG.md item 6's "destination not decided" placeholder is now this // exit itself, not a tooltip). Debug.Log("[NecromancerTome] PortalStonePatch: black portal video finished (skipped=" + skipped + "), exiting to main menu"); GameManager.Instance.Disconnect(); }); } /// 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. 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().Setup(destination, null); package.ProcessPackage(GameManager.Instance.World, GameManager.Instance); player.PlayOneShot("spawnInStinger"); Debug.Log("[NecromancerTome] PortalStonePatch: owner=" + player.entityId + " teleported to bedroll " + bedrollPos); } } /// 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). [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); } } } }