- Книга: картинка главы при смене главы не удалялась (~2 МБ за смену) - удаляем сами при смене, у главы без картинки и при закрытии окна. - Пирамида: кольцо зоны создавало новый материал при каждой перезагрузке чанка - теперь один общий. - Прилив: не держать стат игрока после выхода из мира; забывать полосы пересобранного HUD. - SpatialVaultPatch: исправлен устаревший комментарий про очистку кэша. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
207 lines
11 KiB
C#
207 lines
11 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. NOT cleared on WorldShuttingDown any more (that event fires before
|
||
/// the final player save and wiped vaults - see SpatialVaultPersistence.cs); a different save cannot
|
||
/// inherit this world's vault because ToPlayer overwrites or removes the entry on load. Leftover entries
|
||
/// are at most one per world loaded in a session - not a growing leak (checked 10.10).</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.
|
||
// The ItemValue goes with it: the pickup refuses outright when this particular
|
||
// bracelet has nothing in its mod slot, and mods live on the instance.
|
||
SpatialVaultPickup.Begin(player, _actionData.invData.itemValue);
|
||
}
|
||
|
||
// Skip ItemActionEat's own logic entirely - the click has been fully handled here.
|
||
return false;
|
||
}
|
||
|
||
public static void OpenVault(EntityPlayerLocal player)
|
||
{
|
||
// Хранилище из сейва не прочитано - открывать нечего, а пустое новое затёрло бы его при
|
||
// сохранении. См. SpatialVaultPersistence.UnreadableByEntity.
|
||
if (SpatialVaultPersistence.IsLocked(player))
|
||
{
|
||
GameManager.ShowTooltip(player, "braceletSpatialVaultUnreadable");
|
||
return;
|
||
}
|
||
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(new Vector2i(slotCount, 1), XUiC_ItemStack.StackLocationTypes.LootContainer, null);
|
||
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. В 3.3 это Bag.Resize:
|
||
// ItemStackGrid.Resize переносит все стопки в новый массив и досоздаёт слоты.
|
||
bag.Resize(new Vector2i(slotCount, 1));
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
}
|