diff --git a/HarmonySrc/NecromancerBloodPatch.cs b/HarmonySrc/NecromancerBloodPatch.cs index b9eb006..d0841ee 100644 --- a/HarmonySrc/NecromancerBloodPatch.cs +++ b/HarmonySrc/NecromancerBloodPatch.cs @@ -4,13 +4,20 @@ using UnityEngine; namespace NecromancerTome { /// - /// "Кровь некроманта" (Necromancer's Blood) - dictated 2026-08-30. See item_modifiers.xml - /// (resourceNecromancerBlood - it lives THERE, not in items.xml, since 2026-09-15: it had to - /// become an ItemClassModifier to be installable in the Spatial Bracelet, and that class is - /// only created from . Nothing here changed - the lookup is by name, and both - /// files register into the same ItemClass.nameToItem) for the item, recipes.xml for the base - /// recipe (an empty jar, - /// like any other resource conversion). Two rules the user asked for have NO vanilla XML + /// "Кровь некроманта" (Necromancer's Blood) - dictated 2026-08-30. See items.xml + /// (resourceNecromancerBlood) for the item and recipes.xml for the base recipe (an empty jar, + /// like any other resource conversion). + /// + /// IT LIVES IN items.xml, AND THAT IS NOT AN ACCIDENT. On 2026-09-15 it was moved into + /// item_modifiers.xml so it could be installed in the Spatial Bracelet, and that move + /// DESTROYED a character in a save: ItemValue.Read/Write gate the modification block on + /// !(itemClass is ItemClassModifier), so the item's CLASS decides the byte layout of every + /// stack of it in the save, and an existing save read one byte off from the first blood stack + /// onward. It was rolled back the same day, the bracelet's charge became a brand-new item + /// (resourceBloodSphere, and later resourceBloodStone) instead, and the rule stands: an item + /// that could already be in someone's inventory must not change class in either direction. + /// The full account is in BACKLOG.md; the earlier wording of this comment claimed the modifier + /// home as current and outlived the code by a day. Two rules the user asked for have NO vanilla XML /// equivalent at all, so both are enforced here instead: /// 1. "нужна... наличие любого ножа" - a knife must be present (in the toolbelt or /// backpack) to craft this, but is NOT consumed. recipes.xml has no "required but not @@ -54,6 +61,73 @@ namespace NecromancerTome public const string BloodItemName = "resourceNecromancerBlood"; public const float HealthCostFraction = 0.9f; + /// Damage above which the player's BIG pain grunt is used instead of the small + /// one. Not invented: it is vanilla's own threshold, read out of + /// EntityPlayer.GetSoundHurt(DamageSource, int) - the override that every spike and every + /// strand of barbed wire goes through. Its IL is + /// + /// ldarg.2 // _damageStrength + /// ldc.i4.s 15 + /// bgt.s -> GetSoundHurt() // strictly MORE than 15 -> soundHurt (…painlg) + /// call GetSoundHurtSmall() // 15 or less -> soundHurtSmall (…painsm) + /// + /// so the comparison is strictly greater-than, and 15 exactly still counts as small. The + /// same method has an earlier branch for damage type 16 that returns GetSoundDrownPain(); + /// that one is the drowning case and has nothing to do with us. + public const int PainSoundBigDamage = 15; + + /// Plays the player's own pain grunt, picking the big or the small one by the + /// same rule vanilla uses for spikes and barbed wire (user request 2026-09-16: "в игре + /// есть звук боли (когда персонаж напарывается на колья или на колючую проволоку). Пусть + /// этот звук воспроизводится при создании крови некроманта"). + /// + /// WHY THE SOUND NAME IS ASKED FOR AND NOT SPELLED OUT. The clip differs by gender - + /// playerMale carries SoundHurt="player1painlg"/SoundHurtSmall="player1painsm" and + /// playerFemale overrides both to player2pain* (Data/Config/entityclasses.xml). Hardcoding + /// "player1painlg" would have given every female character a male grunt. GetSoundHurt() and + /// GetSoundHurtSmall() are public on EntityAlive and are plain field reads (verified: each + /// one's whole body is "ldarg.0; ldfld soundHurt|soundHurtSmall; ret"), so they return + /// whatever this entity's own class declared and cost nothing. + /// + /// WHY NOT GetSoundHurt(DamageSource, int), which would pick for us: it needs a + /// DamageSource, and this is not damage from a source - the HP here is spent by AddHealth, + /// deliberately (see the comment at the call site). Its selection rule is three lines, so + /// it is reproduced instead of faked with a synthetic DamageSource. + /// + /// The null guard is vanilla's too: EntityAlive.OnUpdateEntity stores the result and skips + /// the call on null (brfalse right after the stloc) rather than handing PlayOneShot a null + /// clip name. The fallback to the big grunt covers an entity that declares SoundHurt but + /// not SoundHurtSmall - again exactly what vanilla's override does when + /// GetSoundHurtSmall() comes back empty. + /// + /// PlayOneShot(name) with no further arguments is byte-for-byte what vanilla passes here: + /// its optional parameters default to sound_in_head:false, serverSignalOnly:false, + /// isUnique:false, _animEvent:null, volumeScale:1f, and OnUpdateEntity's own call pushes + /// exactly those five constants. So the grunt comes out of the character, not "in the + /// head", same as being spiked. + public static void PlayPainSound(EntityPlayerLocal _player, int _damage) + { + if (_player == null) + { + return; + } + + string clip = _damage > PainSoundBigDamage + ? _player.GetSoundHurt() + : _player.GetSoundHurtSmall(); + + if (string.IsNullOrEmpty(clip)) + { + clip = _player.GetSoundHurt(); + } + if (string.IsNullOrEmpty(clip)) + { + return; + } + + _player.PlayOneShot(clip); + } + public static bool HasAnyKnife(EntityPlayerLocal player) { return ContainsKnife(player.inventory?.GetSlots()) || ContainsKnife(player.bag?.GetSlots()); @@ -135,6 +209,13 @@ namespace NecromancerTome return; } __state.AddHealth(-amount); + + // The grunt goes AFTER the HP is actually gone, so a craft that somehow bailed out + // above never makes a sound the player cannot account for. At the 90% cost this is the + // big pain clip in every normal case (amount > 15 unless the player is already down to + // about 17 HP), and drops to the small one exactly where vanilla would drop it too. + NecromancerBloodPatch.PlayPainSound(__state, amount); + Debug.Log("[NecromancerTome] NecromancerBloodPatch: crafted blood, deducted " + amount + " HP from owner=" + __state.entityId); } } diff --git a/NecromancerHarmony.dll b/NecromancerHarmony.dll index f0429ee..b92d04e 100644 Binary files a/NecromancerHarmony.dll and b/NecromancerHarmony.dll differ diff --git a/NecromancerHarmony.pdb b/NecromancerHarmony.pdb index 0d1f5c0..85e73de 100644 Binary files a/NecromancerHarmony.pdb and b/NecromancerHarmony.pdb differ