Книга некроманта 1.0 — первая публичная версия

Мод для 7 Days to Die 3.2: навык «Некромантия», растущий от счётчика убитых
зомби, тёмное оружие с шестью собственными модами, призывная нежить, пирамида
духов и сюжетный финал через Чёрный портал. Локализация на 13 языках.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MaNro5hAGTzcQ7rJNN2tCX
This commit is contained in:
Alex Cube
2026-09-09 21:13:03 +03:00
co-authored by Claude Opus 5
commit e8f064f5ec
102 changed files with 7010 additions and 0 deletions
+340
View File
@@ -0,0 +1,340 @@
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);
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);
}
/// <summary>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(&lt;mod name&gt;):" and substitutes the mod's real
/// install path; "NecromancerTome" here is this mod's own ModInfo.xml Name, not its
/// DisplayName).</summary>
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);
}
/// <summary>ЗАМЕНЕНО 2026-09-09: раньше отсюда сразу стартовало полноэкранное видео
/// (BlackPortalVideoPath), теперь запускается финальная сцена из шести слайдов с текстом
/// - FinalSlides.Begin. Причина в BACKLOG.md ("концовка серией диалоговых окон вместо
/// видео"): видео не локализуется, а текст слайдов идёт обычной строкой через
/// Localization.csv. Пауза и выход в главное меню никуда не делись - и то и другое
/// теперь живёт внутри FinalSlides, а видео осталось финальным аккордом ПОСЛЕ выбора
/// концовки на последнем слайде.
///
/// Всё, что описано в комментарии к BlackPortalVideoPath выше, по-прежнему верно и
/// применяется - просто к двум новым файлам (FinalSlides.StayVideoPath /
/// ReturnVideoPath) вместо одного. Сама константа BlackPortalVideoPath больше не
/// используется и оставлена только как документация к разбору "@modfolder(...)" и
/// XUiC_VideoPlayer.PlayVideo, на который 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>Прежняя концовка "сразу видео, потом главное меню". Больше ниоткуда не
/// вызывается (см. ActivateBlackPortal выше) - оставлена целиком, потому что весь разбор
/// Pause/PlayVideo/Disconnect в её комментариях остаётся актуальным и на неё ссылается
/// FinalSlides. Удалять при следующей уборке, если так и не понадобится.</summary>
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();
});
}
/// <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);
}
}
}
}