Книга некроманта 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:
@@ -0,0 +1,680 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// "Пирамида Ереси" (Pyramid of Heresy) - user request 2026-08-31, REWRITTEN 2026-09-01.
|
||||
///
|
||||
/// FIRST VERSION (see BACKLOG.md's original entry) used Harmony patches on plain Block's
|
||||
/// OnBlockAdded/OnBlockRemoved/GetBlockActivationCommands/OnBlockActivated, with all state in a
|
||||
/// static Dictionary keyed by block position. Two real problems came out of actually testing it:
|
||||
/// 1. USER REPORT: "Навожу прицел, но подсказка про E не появляется" - no E-prompt at all,
|
||||
/// pressing E did nothing. Root cause, decompiled: there's a SEPARATE gate method,
|
||||
/// `Block.HasBlockActivationCommands(WorldBase, BlockValue, Vector3i, EntityAlive)`, with
|
||||
/// its OWN independent logic (not calling GetBlockActivationCommands at all) that the
|
||||
/// game's HUD/input layer checks FIRST to decide whether to even show the prompt. It was
|
||||
/// never patched, and for a plain decorative block it always returns false (no
|
||||
/// CanPickup, no CustomCmds) - so the prompt correctly never appeared, and E correctly did
|
||||
/// nothing, regardless of how correct the other three patches were.
|
||||
/// 2. USER REQUEST: "Сделай TileEntity" - wants EffectOn/ZoneShown to actually survive a
|
||||
/// save/reload, which the static-Dictionary version explicitly could not do (documented as
|
||||
/// a known caveat at the time).
|
||||
///
|
||||
/// Rather than patch a fourth Block method, this is a full rewrite onto the real, sanctioned
|
||||
/// extension point for exactly this situation - the same one vanilla's own Land Claim block
|
||||
/// uses: a CompositeTileEntity feature. Confirmed by decompiling the actual chain, not guessed:
|
||||
/// - `TEFeatureAbs` (the real base class - `TEFeatureLandClaim : TEFeatureAbs`, decompiled to
|
||||
/// confirm) already declares virtual OnAdded/OnRemove/UpdateTick/Read/Write/
|
||||
/// InitBlockActivationCommands/AllowBlockActivationCommand/OnBlockActivated - literally
|
||||
/// every hook this feature needs, with NO separate "HasBlockActivationCommands" gap: that
|
||||
/// whole problem belongs to plain Block's activation path, not this one.
|
||||
/// - `BlockCompositeTileEntity` (the Class="CompositeTileEntity" block class - decompiled
|
||||
/// directly) correctly overrides HasBlockActivationCommands/GetBlockActivationCommands/
|
||||
/// OnBlockActivated itself and wires them through `TileEntityComposite`/each feature - this
|
||||
/// is the ALREADY-WORKING pipeline every vanilla composite block (Land Claim included) has
|
||||
/// used for years; not new engineering, just finally the right entry point.
|
||||
/// - Feature discovery is NOT a hardcoded switch (unlike raw `TileEntityType`/
|
||||
/// `TileEntity.InstantiateFromRead`, which genuinely IS a closed hardcoded enum switch with
|
||||
/// no mod slot - checked this first and ruled it out for exactly that reason).
|
||||
/// `TileEntityCompositeData.Init()` (decompiled) calls
|
||||
/// `ReflectionHelpers.FindTypesImplementingBase(typeof(ITileEntityFeature), ...)` and keys
|
||||
/// the result by `_type.Name` (the short type name, NAMESPACE-INDEPENDENT - confirmed by
|
||||
/// reading the exact line) - so `TEFeaturePyramidWard` below is found automatically by the
|
||||
/// engine's own startup scan of every loaded assembly (including this mod's DLL) purely by
|
||||
/// matching that literal class name against blocks.xml's own
|
||||
/// `<property class="TEFeaturePyramidWard" />` - the same mechanism vanilla's own
|
||||
/// TEFeatureLandClaim/TEFeatureStorage/TEFeatureAreaRepair (see keystoneBlock) already rely
|
||||
/// on. Only real requirement (also confirmed by decompile, `TileEntityCompositeData.Init`
|
||||
/// warns and skips otherwise): a public, non-abstract class with a parameterless
|
||||
/// constructor - both true here without writing one explicitly.
|
||||
/// - Activation command TEXT is a real constraint worth noting: `BlockCompositeTileEntity`
|
||||
/// caches its `BlockActivationCommand[]` PER BLOCK TYPE (a field on the Block instance
|
||||
/// itself, shared by every placed pyramid), rebuilt once from InitBlockActivationCommands
|
||||
/// and never again - only `.enabled` gets refreshed per-activation (via
|
||||
/// AllowBlockActivationCommand). So button TEXT can't dynamically say "Enable"/"Disable"
|
||||
/// per-instance; the real vanilla pattern (confirmed in TEFeatureLandClaim's own
|
||||
/// show_bounds/hide_bounds pair) is to register BOTH command variants up front and only
|
||||
/// ENABLE whichever one currently applies - copied exactly here for effect_on/effect_off
|
||||
/// and zone_show/zone_hide.
|
||||
/// - Command display text is resolved via `Localization.Get("blockcommand_" + fullCommandName)`
|
||||
/// (confirmed by finding vanilla's own `blockcommand_show_bounds`/
|
||||
/// `blockcommand_TEFeatureLandClaim:show_bounds` keys in Data/Config/Localization.csv) -
|
||||
/// NOT pre-resolved text passed directly to BlockActivationCommand's constructor (the
|
||||
/// earlier version's mistake). See this mod's own Localization.csv for the
|
||||
/// `blockcommand_TEFeaturePyramidWard:*` keys this relies on.
|
||||
///
|
||||
/// NOT a Harmony patch, despite the filename/this mod's usual convention and despite still
|
||||
/// living in HarmonySrc/ for continuity with the rest of this mod's file layout - nothing here
|
||||
/// patches anything. `Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName` (CharmPatch.cs) is
|
||||
/// still reused as-is for the actual charm effect; that patch is untouched by this rewrite.
|
||||
/// </summary>
|
||||
public class TEFeaturePyramidWard : TEFeatureAbs
|
||||
{
|
||||
/// <summary>How far out (in blocks/meters) the ward reaches. Not specified by the user -
|
||||
/// picked to roughly cover a small base perimeter, same ballpark as vanilla's own land
|
||||
/// claim radius. Easy to retune, just one constant.</summary>
|
||||
public const float EffectRadius = 15f;
|
||||
|
||||
/// <summary>CHANGED 2026-09-02 (user report: "Никаких частиц на включённом состоянии не
|
||||
/// летает" - literally nothing spawned, at all, neither the main glow nor the zone ring).
|
||||
/// Root cause: "RadiatedParticlesOnMesh" is loaded/played through a completely DIFFERENT
|
||||
/// mechanism than the one this file actually calls. Decompiled `ParticleEffect.LoadResources()`
|
||||
/// (the loader behind `GameManager.SpawnBlockParticleEffect`/`new ParticleEffect(string,...)`,
|
||||
/// which this file uses): it bulk-loads addressables from the "particleeffects" group whose
|
||||
/// FIRST FOLDER SEGMENT starts with "p_", then keys each loaded prefab into a dictionary by
|
||||
/// its own filename via `ToId(name)`. "RadiatedParticlesOnMesh" is referenced elsewhere in
|
||||
/// this mod (buffs.xml's `AttachParticleEffectToEntity`) via the literal path
|
||||
/// "ParticleEffects/RadiatedParticlesOnMesh" - no "p_"-prefixed folder anywhere in that path,
|
||||
/// meaning it almost certainly never gets bulk-loaded into that same lookup dictionary at all
|
||||
/// (that XML action resolves its own particle reference through an entirely separate,
|
||||
/// direct-path mechanism, not this bulk-addressables-by-folder-prefix one) - so
|
||||
/// `GetDynamicTransform`/`ToId` lookups for it here would always silently fail (logged as
|
||||
/// "Unknown particle effect", nothing spawned) - exactly matching what got reported. Switched
|
||||
/// to "campfire" instead - confirmed loadable through THIS exact code path already (it's
|
||||
/// vanilla's own `<property name="ParticleName" value="campfire" />` on the real campfire
|
||||
/// block, going through the same GameManager block-particle registry) - and it happens to
|
||||
/// double as the user's other request ("фиолетовое пламя, будто блок горит холодным
|
||||
/// пламенем") almost for free: a real fire effect, tinted purple by ApplyGlowTint below
|
||||
/// instead of its natural orange.</summary>
|
||||
public const string GlowParticleName = "campfire";
|
||||
|
||||
/// <summary>CHANGED 2026-09-02 (user, after seeing the fire-ring in-game: "Границу лучше
|
||||
/// показывать не огнём, а какими-нибудь частицами" - reversed their earlier "оставим так"
|
||||
/// once they'd actually seen it). Only real vanilla `ParticleName` values confirmed to exist
|
||||
/// at all (grepped every one in Data/Config/blocks.xml - the same property this whole
|
||||
/// mechanism is built on): ember_pile/hotembers/campfire/forgeWorkstation/chemistryStation/
|
||||
/// flame_hazard - every single one of them is fire/ember/industrial-themed, there is no
|
||||
/// confirmed "generic sparkle/magic" particle name to fall back on. Picked
|
||||
/// "chemistryStation" specifically because it's the one NOT visually built around an open
|
||||
/// flame (a chemistry set's bubbling/vapor effect) - best available guess from a short list,
|
||||
/// not a confirmed-good look; say if it still reads wrong once seen; it tints purple the
|
||||
/// same way as everything else here regardless of its native color.</summary>
|
||||
public const string ZoneRingParticleName = "chemistryStation";
|
||||
|
||||
/// <summary>Purple, per the user's explicit request ("окрашивается фиолетовым" for the
|
||||
/// zone, "светится фиолетовыми частицами"/"холодным пламенем" for the effect glow) - same
|
||||
/// color used for both.</summary>
|
||||
public static readonly Color WardTint = new Color(0.6f, 0.15f, 0.95f);
|
||||
|
||||
/// <summary>Dropped from 32 - a full-size effect at every ring point would be both visually
|
||||
/// overwhelming and comparatively expensive; 16 small markers still reads clearly as a
|
||||
/// circle at EffectRadius=15.</summary>
|
||||
public const int ZoneRingPointCount = 16;
|
||||
|
||||
/// <summary>ADDED 2026-09-02, direct user request ("Можешь накладывать кроме девиации ещё
|
||||
/// и дебаф горения?"). This is the real vanilla "a zombie is on fire" buff (Data/Config/
|
||||
/// buffs.xml - `damage_type="heat"`, cascades into `buffBurningElement`'s own 10s countdown/
|
||||
/// damage-over-time/AddBuff(buffIsOnFire) chain, the same one torches/molotovs/fire traps
|
||||
/// trigger), not a new buff invented for this mod. Re-applied every scan tick (not
|
||||
/// gated behind "already has it" like the charm below) since `buffBurningElement` itself
|
||||
/// resets its own countdown on every re-trigger (`stack_type="replace"`) - the intent is
|
||||
/// "keeps burning the whole time it's in the zone", not "burns once".
|
||||
///
|
||||
/// Needs a REAL instigator entity id, unlike the charm buff: `EntityBuffs.AddBuff` (decompiled)
|
||||
/// checks `buff.DamageType != None && ... && !FriendlyFireCheck(instigator)` and fails the
|
||||
/// whole call outright if that trips - a buff with a real damage_type (this one has "heat";
|
||||
/// buffNecroDeviatorCharm has none, which is why it never needed this) requires a
|
||||
/// non-null/valid instigator that FriendlyFireCheck accepts, or the call can fail. Passed the
|
||||
/// in-zone player's own entityId (already resolved above for the player-presence gate) -
|
||||
/// matches the fictional framing anyway (the necromancer is the one wielding this ward).</summary>
|
||||
public const string BurnBuffName = "buffBurningZombie";
|
||||
|
||||
/// <summary>Persisted (see Read/Write below) - real per-instance state now, one pyramid's
|
||||
/// toggle no longer affects any other's, and both survive a save/reload.</summary>
|
||||
public bool EffectOn = true;
|
||||
|
||||
public bool ZoneShown;
|
||||
|
||||
/// <summary>Glow/ring particle keys queued via SpawnBlockParticleEffect but not tinted yet -
|
||||
/// GameManager.updateBlockParticles() only processes its spawn queue once per frame
|
||||
/// (decompiled to confirm), so tinting has to be deferred at least one tick rather than done
|
||||
/// inline right after spawning. Instance-level now (was a shared static list in the old
|
||||
/// version) - each pyramid only tracks its own pending keys.</summary>
|
||||
public readonly List<Vector3i> pendingTint = new List<Vector3i>();
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Lifecycle.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
public override void CopyFromInternal(TileEntityComposite _other)
|
||||
{
|
||||
if (_other.TryGetSelfOrFeature<TEFeaturePyramidWard>(out TEFeaturePyramidWard other))
|
||||
{
|
||||
EffectOn = other.EffectOn;
|
||||
ZoneShown = other.ZoneShown;
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnAdded(Vector3i _blockPos, BlockValue _blockValue)
|
||||
{
|
||||
base.OnAdded(_blockPos, _blockValue);
|
||||
if (EffectOn)
|
||||
{
|
||||
SpawnGlow();
|
||||
}
|
||||
if (ZoneShown)
|
||||
{
|
||||
SpawnZoneRing();
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnRemove(World _world)
|
||||
{
|
||||
base.OnRemove(_world);
|
||||
if (ZoneShown)
|
||||
{
|
||||
RemoveZoneRing();
|
||||
}
|
||||
RemoveGlow();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Persistence - real save/load now, per the user's direct request.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>Routed through PyramidWardWriteHelper.Write (TEPersistenceSrc/, a separate
|
||||
/// satellite project+DLL) rather than calling PooledBinaryWriter.Write directly - that call
|
||||
/// does not compile from THIS project at all. Real, decompile/compiler-confirmed reason, not
|
||||
/// a style choice: see NecromancerTEPersistence.csproj's own comment for the full story
|
||||
/// (short version: Assembly-CSharp.dll's Write overload set includes a
|
||||
/// ReadOnlySpan<byte> variant that only resolves against Unity/Mono's own mscorlib,
|
||||
/// which conflicts with this project's UnityEngine-type usage everywhere else if referenced
|
||||
/// directly here - isolating the one call that needs it into its own tiny project was the
|
||||
/// only combination found that keeps both working). No version byte (kept simple per the
|
||||
/// user's "как проще" - this is a brand-new feature, nothing to migrate from yet;
|
||||
/// PooledBinaryReader.ReadBoolean() below has no such compile restriction, confirmed
|
||||
/// separately, so Read() needs no equivalent workaround.</summary>
|
||||
public override void Write(PooledBinaryWriter _bw, TileEntity.StreamModeWrite _eStreamMode)
|
||||
{
|
||||
base.Write(_bw, _eStreamMode);
|
||||
PyramidWardWriteHelper.Write(_bw, EffectOn, ZoneShown);
|
||||
}
|
||||
|
||||
public override void Read(PooledBinaryReader _br, TileEntity.StreamModeRead _eStreamMode)
|
||||
{
|
||||
base.Read(_br, _eStreamMode);
|
||||
EffectOn = _br.ReadBoolean();
|
||||
ZoneShown = _br.ReadBoolean();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// E-menu (activation commands) - see class doc comment for why both states of each
|
||||
// toggle are registered up front rather than swapping text dynamically.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>Icons FIXED 2026-09-02 (user report: "на 'Отключить эффект' нету иконки").
|
||||
/// "ui_game_symbol_zombie"/"hand" (the first version's guesses) were never real
|
||||
/// BlockActivationCommand icon names - that field takes a small closed set of simple
|
||||
/// glyph-font names, NOT full UI sprite-atlas names (confirmed by harvesting every real
|
||||
/// `new BlockActivationCommand(...)` call across every other TEFeature class decompiled so
|
||||
/// far: "frames"/"x" (TEFeatureLandClaim), "door" (TEFeatureDoor), "lock"/"unlock"/"keypad"
|
||||
/// (TEFeatureLockable), "search" (TEFeatureStorage), "wrench" (the trigger command on
|
||||
/// TileEntityComposite itself)). Reused two of those real, confirmed names instead of
|
||||
/// guessing again: "unlock"/"lock" for effect on/off (a locked/unlocked padlock reads fine
|
||||
/// as "active"/"inactive"), and "frames" - literally the SAME icon vanilla's own Land Claim
|
||||
/// uses for its own show_bounds/hide_bounds pair - for our own zone_show/zone_hide, since
|
||||
/// it's the exact same kind of toggle.</summary>
|
||||
public override void InitBlockActivationCommands(Action<BlockActivationCommand, TileEntityComposite.EBlockCommandOrder, TileEntityFeatureData> _addCallback)
|
||||
{
|
||||
base.InitBlockActivationCommands(_addCallback);
|
||||
_addCallback(new BlockActivationCommand("effect_on", "unlock", _enabled: false), TileEntityComposite.EBlockCommandOrder.Normal, FeatureData);
|
||||
_addCallback(new BlockActivationCommand("effect_off", "lock", _enabled: false), TileEntityComposite.EBlockCommandOrder.Normal, FeatureData);
|
||||
_addCallback(new BlockActivationCommand("zone_show", "frames", _enabled: false), TileEntityComposite.EBlockCommandOrder.Normal, FeatureData);
|
||||
_addCallback(new BlockActivationCommand("zone_hide", "frames", _enabled: false), TileEntityComposite.EBlockCommandOrder.Normal, FeatureData);
|
||||
}
|
||||
|
||||
/// <summary>ADDED 2026-09-02 (user report: "при наведении нет никакой надписи-подсказки про
|
||||
/// E"). TEFeatureAbs.GetActivationText was never overridden at all, so it fell through to
|
||||
/// the base's default `return null` - no ReadOnlySpan in this method's signature (confirmed
|
||||
/// by decompile), so unlike AllowBlockActivationCommand/OnBlockActivated below, this one
|
||||
/// overrides cleanly with no workaround needed. Mirrors TEFeatureLandClaim's own
|
||||
/// GetActivationText shape (`_activateHotkeyMarkup` + the block's own localized name) -
|
||||
/// same real, decompiled API, not guessed.</summary>
|
||||
public override string GetActivationText(WorldBase _world, Vector3i _blockPos, BlockValue _blockValue, EntityAlive _entityFocusing, string _activateHotkeyMarkup, string _focusedTileEntityName)
|
||||
{
|
||||
base.GetActivationText(_world, _blockPos, _blockValue, _entityFocusing, _activateHotkeyMarkup, _focusedTileEntityName);
|
||||
return _activateHotkeyMarkup + " " + _blockValue.Block.GetLocalizedBlockName();
|
||||
}
|
||||
|
||||
// AllowBlockActivationCommand/OnBlockActivated deliberately NOT overridden here - see the
|
||||
// long comment block below (right above the two Harmony patches that replace them) for why
|
||||
// this specific pair of TEFeatureAbs virtuals cannot be overridden from this mod's project
|
||||
// at all, and how the same behavior is achieved instead.
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Visuals - same GameManager block-particle registry as the first version, just called on
|
||||
// `ToWorldPos()` (this feature's own position) instead of a dictionary-passed key.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
public void SpawnGlow()
|
||||
{
|
||||
if (GameManager.Instance == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Vector3i pos = ToWorldPos();
|
||||
if (GameManager.Instance.HasBlockParticleEffect(pos))
|
||||
{
|
||||
return;
|
||||
}
|
||||
// FIXED 2026-09-02 (user report: "горение пирамидки почему-то смещено на куб вверх и
|
||||
// вбок"): World.blockToTransformPos(Vector3i) ALREADY returns (x+0.5, y, z+0.5)
|
||||
// (decompiled to confirm - horizontally centered, y left raw/un-centered) - adding
|
||||
// another +0.5 on x/z on top of that (the original bug) double-centered it, landing a
|
||||
// full extra block over on both horizontal axes. Only the vertical lift (how far above
|
||||
// the block the flame sits) is actually ours to add. Height LOWERED again same day
|
||||
// ("сделай ниже не 2/3 куба" - after the sideways offset was gone, still sat too high):
|
||||
// 1.2 (0.2 above the full block top) -> 0.6, under 2/3 (0.667) of a block as asked.
|
||||
// Height lowered twice same day: 1.2 -> 0.6 ("сделай ниже не 2/3 куба"), then -> 0.4
|
||||
// (direct follow-up: "Снизь высоту пламени до +0.4").
|
||||
Vector3 worldPos = World.blockToTransformPos(pos) + new Vector3(0f, 0.4f, 0f);
|
||||
// WardTint (not Color.white) as the ParticleEffect's own _color: ParticleEffect.
|
||||
// SpawnParticleEffect applies this directly to any non-ParticleSystem Renderer on the
|
||||
// prefab (decompiled to confirm) - covers a sub-emitter/glow sprite ApplyGlowTint's own
|
||||
// ParticleSystem-only loop wouldn't reach, belt-and-suspenders alongside it.
|
||||
GameManager.Instance.SpawnBlockParticleEffect(pos, new ParticleEffect(GlowParticleName, worldPos, Quaternion.identity, 0f, WardTint));
|
||||
pendingTint.Add(pos);
|
||||
}
|
||||
|
||||
public void RemoveGlow()
|
||||
{
|
||||
if (GameManager.Instance == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Vector3i pos = ToWorldPos();
|
||||
if (GameManager.Instance.HasBlockParticleEffect(pos))
|
||||
{
|
||||
GameManager.Instance.RemoveBlockParticleEffect(pos);
|
||||
}
|
||||
pendingTint.Remove(pos);
|
||||
}
|
||||
|
||||
/// <summary>Ring of glow points marking EffectRadius, keyed by y = -1000-i (real block
|
||||
/// y-coordinates never go negative that far, so these keys can never collide with an
|
||||
/// actual placed block's own glow key).</summary>
|
||||
public void SpawnZoneRing()
|
||||
{
|
||||
if (GameManager.Instance == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Vector3i basePos = ToWorldPos();
|
||||
// Same double-centering bug as SpawnGlow's own fix above - blockToTransformPos already
|
||||
// centers x/z, only the vertical offset (0.5, mid-block height) is ours to add.
|
||||
Vector3 center = World.blockToTransformPos(basePos) + new Vector3(0f, 0.5f, 0f);
|
||||
for (int i = 0; i < ZoneRingPointCount; i++)
|
||||
{
|
||||
float angle = i * (360f / ZoneRingPointCount) * Mathf.Deg2Rad;
|
||||
Vector3 point = center + new Vector3(Mathf.Cos(angle) * EffectRadius, 0f, Mathf.Sin(angle) * EffectRadius);
|
||||
Vector3i key = new Vector3i(basePos.x, -1000 - i, basePos.z);
|
||||
if (GameManager.Instance.HasBlockParticleEffect(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// ZoneRingParticleName (not GlowParticleName) - see that constant's own comment:
|
||||
// user asked for the boundary to read as "some particles", not literal fire.
|
||||
GameManager.Instance.SpawnBlockParticleEffect(key, new ParticleEffect(ZoneRingParticleName, point, Quaternion.identity, 0f, WardTint));
|
||||
pendingTint.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveZoneRing()
|
||||
{
|
||||
if (GameManager.Instance == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Vector3i basePos = ToWorldPos();
|
||||
for (int i = 0; i < ZoneRingPointCount; i++)
|
||||
{
|
||||
Vector3i key = new Vector3i(basePos.x, -1000 - i, basePos.z);
|
||||
if (GameManager.Instance.HasBlockParticleEffect(key))
|
||||
{
|
||||
GameManager.Instance.RemoveBlockParticleEffect(key);
|
||||
}
|
||||
pendingTint.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Mirrors ParticlePatch.cs's own tint technique (same ParticleSystem.MainModule
|
||||
/// fields, same reasoning: the particle prefab always instantiates at its authored color,
|
||||
/// nothing in XML/the block-particle API can override that) - kept as its own copy here
|
||||
/// rather than refactoring ParticlePatch.cs itself, so this feature can't regress the
|
||||
/// already-working zombie glow if something about this call site needs different handling
|
||||
/// once tested in-game.
|
||||
///
|
||||
/// EXTENDED 2026-09-02 for "campfire" (see GlowParticleName's own comment for why the
|
||||
/// particle changed) with Gradient/TwoGradients handling - ParticlePatch.cs's original
|
||||
/// only ever needed Color/TwoColors (RadiatedParticlesOnMesh's own authored mode) and
|
||||
/// explicitly left Gradient/TwoGradients alone as "no generic way to override". That's not
|
||||
/// actually true - a Gradient's color keys ARE reassignable at runtime via
|
||||
/// `Gradient.SetKeys` - so it's handled here now, since a real fire effect plausibly
|
||||
/// animates through multiple colors (yellow-orange-red-smoke) via an actual Gradient rather
|
||||
/// than one flat color, and the request is specifically "холодным пламенем" (COLD flame) -
|
||||
/// if this branch never actually runs because campfire turns out to use plain Color/
|
||||
/// TwoColors after all, no harm, the two branches above still cover it.</summary>
|
||||
public static void ApplyGlowTint(Transform particleTransform, float sizeFactor, float alphaFactor)
|
||||
{
|
||||
if (particleTransform == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
particleTransform.localScale = Vector3.one * sizeFactor;
|
||||
ParticleSystem[] systems = particleTransform.GetComponentsInChildren<ParticleSystem>(true);
|
||||
foreach (ParticleSystem ps in systems)
|
||||
{
|
||||
ParticleSystem.MainModule main = ps.main;
|
||||
main.startSizeMultiplier *= sizeFactor;
|
||||
ParticleSystem.MinMaxGradient startColor = main.startColor;
|
||||
switch (startColor.mode)
|
||||
{
|
||||
case ParticleSystemGradientMode.Color:
|
||||
{
|
||||
Color c = WardTint;
|
||||
c.a = startColor.color.a * alphaFactor;
|
||||
startColor.color = c;
|
||||
main.startColor = startColor;
|
||||
break;
|
||||
}
|
||||
case ParticleSystemGradientMode.TwoColors:
|
||||
{
|
||||
Color min = WardTint;
|
||||
Color max = WardTint;
|
||||
min.a = startColor.colorMin.a * alphaFactor;
|
||||
max.a = startColor.colorMax.a * alphaFactor;
|
||||
startColor.colorMin = min;
|
||||
startColor.colorMax = max;
|
||||
main.startColor = startColor;
|
||||
break;
|
||||
}
|
||||
case ParticleSystemGradientMode.Gradient:
|
||||
startColor.gradient = TintGradient(startColor.gradient, alphaFactor);
|
||||
main.startColor = startColor;
|
||||
break;
|
||||
case ParticleSystemGradientMode.TwoGradients:
|
||||
startColor.gradientMin = TintGradient(startColor.gradientMin, alphaFactor);
|
||||
startColor.gradientMax = TintGradient(startColor.gradientMax, alphaFactor);
|
||||
main.startColor = startColor;
|
||||
break;
|
||||
}
|
||||
|
||||
// Belt-and-suspenders for any sub-emitter/light-flicker Renderer that isn't a
|
||||
// ParticleSystem itself - SpawnGlow/SpawnZoneRing already pass WardTint as the
|
||||
// ParticleEffect's own _color (which ParticleEffect.SpawnParticleEffect applies to
|
||||
// exactly this kind of non-ParticleSystem Renderer automatically), this loop only
|
||||
// covers the ParticleSystem-driven part. A real-time Light component (if "campfire"
|
||||
// has one for dynamic scene lighting) is NOT touched by either mechanism - if the
|
||||
// flame reads purple but still casts an orange glow on nearby surfaces, that's why,
|
||||
// and would need its own separate fix once actually seen in-game.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Rebuilds a Gradient with every color key replaced by WardTint, keeping the
|
||||
/// original alpha keys (and their timing) intact so the fade-in/fade-out shape of the
|
||||
/// effect is preserved - only the color changes, not the timing/opacity curve.</summary>
|
||||
public static Gradient TintGradient(Gradient original, float alphaFactor)
|
||||
{
|
||||
Gradient g = new Gradient();
|
||||
GradientAlphaKey[] alphaKeys = original != null ? original.alphaKeys : new GradientAlphaKey[] { new GradientAlphaKey(1f, 0f) };
|
||||
for (int i = 0; i < alphaKeys.Length; i++)
|
||||
{
|
||||
alphaKeys[i].alpha *= alphaFactor;
|
||||
}
|
||||
GradientColorKey[] colorKeys = new GradientColorKey[] { new GradientColorKey(WardTint, 0f), new GradientColorKey(WardTint, 1f) };
|
||||
g.SetKeys(colorKeys, alphaKeys);
|
||||
return g;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Per-tick: deferred tint + charm/burn scan. Replaces the first version's global
|
||||
// ModEvents.UnityUpdate handler + static Dictionary loop entirely - each pyramid now ticks
|
||||
// itself via this real per-feature hook, called directly by the engine (no throttle of our
|
||||
// own - see the comment on `center` below for why a self-imposed one is actively wrong here).
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
public override void UpdateTick(World _world)
|
||||
{
|
||||
base.UpdateTick(_world);
|
||||
if (GameManager.Instance == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = pendingTint.Count - 1; i >= 0; i--)
|
||||
{
|
||||
Vector3i key = pendingTint[i];
|
||||
if (!GameManager.Instance.HasBlockParticleEffect(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Transform t = GameManager.Instance.GetBlockParticleEffect(key);
|
||||
bool isRingPoint = key.y <= -1000;
|
||||
// 1.0 = natural "campfire" size for the main glow; ring markers shrunk hard (0.35)
|
||||
// so the 16 of them read as small flame-markers instead of a circle of bonfires.
|
||||
// Near-opaque alpha (0.9) so the purple tint reads clearly.
|
||||
ApplyGlowTint(t, isRingPoint ? 0.35f : 1.0f, 0.9f);
|
||||
pendingTint.RemoveAt(i);
|
||||
}
|
||||
|
||||
if (!EffectOn)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Vector3i posI = ToWorldPos();
|
||||
// World.blockToTransformPos already returns X/Z centered on the block (confirmed by
|
||||
// decompile) - only the vertical lift is ours to add. (A past version double-added the
|
||||
// X/Z centering here, offsetting the whole detection circle by about a block - fixed.)
|
||||
Vector3 center = World.blockToTransformPos(posI) + new Vector3(0f, 0.5f, 0f);
|
||||
// Horizontal-only (X/Z) distance, ignoring Y: the zone-ring visual is a flat disc at one
|
||||
// height, so the real detection area is a matching vertical column, not a shrinking
|
||||
// sphere - also just more useful for a base with any stairs/floors. Bounds query
|
||||
// widened vertically (256 = full world height) since the real filter below doesn't
|
||||
// restrict Y at all.
|
||||
Bounds bounds = new Bounds(center, new Vector3(EffectRadius * 2f, 256f, EffectRadius * 2f));
|
||||
|
||||
// Skip the (more expensive) zombie query/loop entirely unless a player is actually in
|
||||
// range - also doubles as the burning debuff's required instigator id (a buff with a
|
||||
// real damage_type, unlike the charm, needs one or EntityBuffs.AddBuff fails its
|
||||
// FriendlyFireCheck outright - see BurnBuffName's own comment).
|
||||
List<Entity> playersNearby = new List<Entity>();
|
||||
_world.GetEntitiesInBounds(typeof(EntityPlayer), bounds, playersNearby);
|
||||
int playerInstigatorId = -1;
|
||||
foreach (Entity p in playersNearby)
|
||||
{
|
||||
float pdx = p.position.x - center.x;
|
||||
float pdz = p.position.z - center.z;
|
||||
if (pdx * pdx + pdz * pdz <= EffectRadius * EffectRadius)
|
||||
{
|
||||
playerInstigatorId = p.entityId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (playerInstigatorId == -1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
List<Entity> nearby = new List<Entity>();
|
||||
_world.GetEntitiesInBounds(typeof(EntityZombie), bounds, nearby);
|
||||
foreach (Entity e in nearby)
|
||||
{
|
||||
if (!(e is EntityZombie zombie) || zombie.IsDead() || zombie.Buffs == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
float dx = zombie.position.x - center.x;
|
||||
float dz = zombie.position.z - center.z;
|
||||
if (dx * dx + dz * dz > EffectRadius * EffectRadius)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!zombie.Buffs.HasBuff(Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName))
|
||||
{
|
||||
zombie.Buffs.AddBuff(Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName);
|
||||
Debug.Log("[NecromancerTome] TEFeaturePyramidWard: charmed zombie " + zombie.entityId + " near pyramid " + posI);
|
||||
}
|
||||
// Burning re-applied every tick a zombie is in the zone (not gated behind "already
|
||||
// has it" like the charm above) - buffBurningElement resets its own countdown on
|
||||
// every re-trigger, so this keeps it topped up rather than a one-shot.
|
||||
zombie.Buffs.AddBuff(BurnBuffName, playerInstigatorId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Real, verified-by-compiler blocker found while writing TEFeaturePyramidWard above:
|
||||
/// TEFeatureAbs.AllowBlockActivationCommand and TEFeatureAbs.OnBlockActivated cannot be
|
||||
/// overridden from this mod's project AT ALL, on any target framework tried (netstandard2.1
|
||||
/// AND net8.0 both fail identically, confirmed with an isolated throwaway repro project - this
|
||||
/// is not a langversion/TargetFramework setting to tune away). Root cause, found by dumping raw
|
||||
/// IL (`ilspycmd -il`): Assembly-CSharp.dll declares these two methods' shared parameter type as
|
||||
/// `valuetype [mscorlib]System.ReadOnlySpan\`1<char>` - i.e. Unity's own Mono/IL2CPP
|
||||
/// runtime backports Span/ReadOnlySpan INTO mscorlib itself, unlike a normal modern .NET SDK
|
||||
/// project (this mod's own csproj included), where ReadOnlySpan<T> instead lives in
|
||||
/// System.Private.CoreLib/System.Memory. Same type NAME, but the CLR treats a type's identity
|
||||
/// as (name + DECLARING ASSEMBLY) - these are two different types to the compiler, so an
|
||||
/// override that looks byte-for-byte identical in source (confirmed via a live reflection probe
|
||||
/// against the real DLL, not just the decompiled source) still fails to bind as a valid
|
||||
/// override. The only real fix on our side would be adding an explicit reference to the game's
|
||||
/// own Managed/mscorlib.dll so our ReadOnlySpan<char> resolves from the same assembly -
|
||||
/// not attempted, since forcing a second mscorlib into this project risks colliding with every
|
||||
/// other basic type (string, object, List<T>...) the SDK's own implicit framework
|
||||
/// reference already provides, for every file in this mod, not just this one method pair. Not
|
||||
/// worth that blast radius for two methods this patch below covers just as well anyway.
|
||||
///
|
||||
/// WORKAROUND: everything both blocked methods needed to do is instead done one layer up, on
|
||||
/// the STRING/array-based (no ReadOnlySpan anywhere) methods that wrap them:
|
||||
/// - `TileEntityComposite.UpdateBlockActivationCommands(BlockActivationCommand[], ...)` -
|
||||
/// confirmed by decompile to run AFTER every feature's (unoverridden, always-true-by-
|
||||
/// default) AllowBlockActivationCommand, so a Postfix here can simply overwrite `.enabled`
|
||||
/// for our 4 known commands with the real per-instance answer - same end result.
|
||||
/// - `BlockCompositeTileEntity.OnBlockActivated(string _commandName, ...)` - the SAME method
|
||||
/// the pre-rewrite plain-Block version patched, just on the composite block class instead;
|
||||
/// `_commandName` here is still the FULL "TEFeaturePyramidWard:effect_on" form (splitting
|
||||
/// into module+bare command only happens one level deeper, inside TileEntityComposite's own
|
||||
/// OnBlockActivated) - checked with plain string.EndsWith, no ReadOnlySpan needed.
|
||||
/// Both patches guard on TryGetSelfOrFeature<TEFeaturePyramidWard> first and bail
|
||||
/// immediately for every other composite block in the game (doors, Land Claim, etc.) - same
|
||||
/// "patch the shared method, filter by identity" idiom as everywhere else in this mod.
|
||||
/// </summary>
|
||||
[HarmonyPatch(typeof(TileEntityComposite), "UpdateBlockActivationCommands")]
|
||||
public static class Patch_TileEntityComposite_UpdateBlockActivationCommands_PyramidWard
|
||||
{
|
||||
public static void Postfix(TileEntityComposite __instance, BlockActivationCommand[] _commands)
|
||||
{
|
||||
if (__instance == null || _commands == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!__instance.TryGetSelfOrFeature<TEFeaturePyramidWard>(out TEFeaturePyramidWard feature))
|
||||
{
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < _commands.Length; i++)
|
||||
{
|
||||
string text = _commands[i].text;
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (text.EndsWith("effect_on"))
|
||||
{
|
||||
_commands[i].enabled = !feature.EffectOn;
|
||||
}
|
||||
else if (text.EndsWith("effect_off"))
|
||||
{
|
||||
_commands[i].enabled = feature.EffectOn;
|
||||
}
|
||||
else if (text.EndsWith("zone_show"))
|
||||
{
|
||||
_commands[i].enabled = !feature.ZoneShown;
|
||||
}
|
||||
else if (text.EndsWith("zone_hide"))
|
||||
{
|
||||
_commands[i].enabled = feature.ZoneShown;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(BlockCompositeTileEntity), "OnBlockActivated", new Type[] { typeof(string), typeof(WorldBase), typeof(Vector3i), typeof(BlockValue), typeof(EntityPlayerLocal) })]
|
||||
public static class Patch_BlockCompositeTileEntity_OnBlockActivated_PyramidWard
|
||||
{
|
||||
public static bool Prefix(string _commandName, Vector3i _blockPos, ref bool __result)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_commandName))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
bool isEffectCommand = _commandName.EndsWith("effect_on") || _commandName.EndsWith("effect_off");
|
||||
bool isZoneCommand = _commandName.EndsWith("zone_show") || _commandName.EndsWith("zone_hide");
|
||||
if (!isEffectCommand && !isZoneCommand)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
World world = GameManager.Instance != null ? GameManager.Instance.World : null;
|
||||
TileEntity te = world != null ? world.GetTileEntity(_blockPos) : null;
|
||||
if (!(te is TileEntityComposite composite) || !composite.TryGetSelfOrFeature<TEFeaturePyramidWard>(out TEFeaturePyramidWard feature))
|
||||
{
|
||||
__result = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isEffectCommand)
|
||||
{
|
||||
feature.EffectOn = !feature.EffectOn;
|
||||
if (feature.EffectOn)
|
||||
{
|
||||
feature.SpawnGlow();
|
||||
}
|
||||
else
|
||||
{
|
||||
feature.RemoveGlow();
|
||||
}
|
||||
Debug.Log("[NecromancerTome] TEFeaturePyramidWard: effect " + (feature.EffectOn ? "ON" : "OFF") + " at " + _blockPos);
|
||||
}
|
||||
else
|
||||
{
|
||||
feature.ZoneShown = !feature.ZoneShown;
|
||||
if (feature.ZoneShown)
|
||||
{
|
||||
feature.SpawnZoneRing();
|
||||
}
|
||||
else
|
||||
{
|
||||
feature.RemoveZoneRing();
|
||||
}
|
||||
Debug.Log("[NecromancerTome] TEFeaturePyramidWard: zone display " + (feature.ZoneShown ? "ON" : "OFF") + " at " + _blockPos);
|
||||
}
|
||||
feature.SetModified();
|
||||
__result = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user