diff --git a/CHANGELOG.md b/CHANGELOG.md index c8ae7b4..4274cd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 0.1.84 + +- Add Adrenaline Echo, a Unique trinket shardstone available at Epic, Legendary, Mythic, and Ancient tiers. +- When the trinket activates its full-adrenaline effect, trigger the equipped weapon's projectile area impact at the player's position. Staff of Embers creates an explosion around the player with its Epic Loot damage effects. +- Require a weapon whose own projectile has area damage. The effect costs no durability, stamina, health, or eitr. + ## 0.1.82 - Save the builder's character name on newly built ships and carts so their owner labels work when the builder is offline or far away, including after a server restart. diff --git a/Properties/AssemblyInfo.cs b/Properties/AssemblyInfo.cs index f58648f..a3b5f8b 100644 --- a/Properties/AssemblyInfo.cs +++ b/Properties/AssemblyInfo.cs @@ -31,5 +31,5 @@ // You can specify all the values or you can default the Build and Revision Numbers // by using the '*' as shown below: // [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("0.1.83")] -[assembly: AssemblyFileVersion("0.1.83")] +[assembly: AssemblyVersion("0.1.84")] +[assembly: AssemblyFileVersion("0.1.84")] diff --git a/Source/Core/Plugin.cs b/Source/Core/Plugin.cs index 36739b8..c3b3274 100644 --- a/Source/Core/Plugin.cs +++ b/Source/Core/Plugin.cs @@ -29,7 +29,7 @@ namespace PraetorisClient public class PraetorisClientPlugin : BaseUnityPlugin { private const string ModName = "PraetorisClient"; - private const string ModVersion = "0.1.83"; + private const string ModVersion = "0.1.84"; private const string Author = "warpalicious"; private const string ModGUID = Author + "." + ModName; private const string EpicLootGuid = "randyknapp.mods.epicloot"; diff --git a/Source/EpicLoot/AdrenalineEchoRuntime.cs b/Source/EpicLoot/AdrenalineEchoRuntime.cs new file mode 100644 index 0000000..b074fe2 --- /dev/null +++ b/Source/EpicLoot/AdrenalineEchoRuntime.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Reflection; +using System.Reflection.Emit; +using EpicLoot.MagicItemEffects; +using HarmonyLib; +using UnityEngine; + +namespace PraetorisClient +{ + internal static class AdrenalineEchoRuntime + { + private static Attack? activeEcho; + private static Vector3 echoPosition; + private static bool collectingProjectiles; + private static readonly List EchoProjectiles = new List(); + private static readonly FieldInfo PendingShot = AccessTools.Field(typeof(MultiShot), "_pendingShot"); + private static readonly AccessTools.FieldRef Trinket = AccessTools.FieldRefAccess("m_trinketItem"); + private static readonly Action FireProjectileBurst = + AccessTools.MethodDelegate>(AccessTools.Method(typeof(Attack), "FireProjectileBurst")); + + private static void OnTrinketActivated(Player player) + { + if (player != Player.m_localPlayer || player.IsDead() || activeEcho != null || + Trinket(player) is not { m_equipped: true } trinket || + trinket.m_shared.m_fullAdrenalineSE == null || + EpicLootApiBridge.GetTotalActiveMagicEffectValue(player, trinket, PraetorisMagicEffects.AdrenalineEcho, 1f) <= 0f) + { + return; + } + + ItemDrop.ItemData weapon = player.GetCurrentWeapon(); + if (weapon?.m_shared.m_attack?.m_attackType != Attack.AttackType.Projectile || + weapon.m_shared.m_itemType == ItemDrop.ItemData.ItemType.Bow || + weapon.m_shared.m_skillType == Skills.SkillType.Bows || + weapon.m_shared.m_skillType == Skills.SkillType.Crossbows || + !string.IsNullOrEmpty(weapon.m_shared.m_ammoType)) + { + return; + } + + // Leave the attack that filled adrenaline before entering another attack's Harmony hooks. + // Epic Loot temporarily changes shared weapon damage while a projectile burst runs. + PraetorisClientPlugin.Instance?.StartCoroutine(FireAfterAttack(player, weapon)); + } + + private static IEnumerator FireAfterAttack(Player player, ItemDrop.ItemData weapon) + { + yield return null; + if (player == null || player != Player.m_localPlayer || player.IsDead() || + player.GetCurrentWeapon() != weapon || !weapon.m_equipped) + { + yield break; + } + + // Use only the weapon's own area-impact projectile, never arrows or bolts. + Projectile? projectile = weapon.m_shared.m_attack.m_attackProjectile?.GetComponent(); + if (projectile == null || projectile.m_aoe <= 0f) + { + yield break; + } + + Attack echo = weapon.m_shared.m_attack.Clone(); + echo.m_projectiles = 1; + echo.m_projectileBursts = 1; + echo.m_perBurstResourceUsage = false; + echo.m_attackStamina = 0f; + echo.m_attackHealth = 0f; + echo.m_attackHealthPercentage = 0f; + echo.m_attackUseAdrenaline = 0f; + echo.m_blockReloadTime = 0f; + echo.m_requiresReload = false; + echo.m_destroyPreviousProjectile = false; + echo.m_consumeItem = false; + echo.m_toggleFlying = false; + echo.m_recoilPushback = 0f; + echo.m_selfDamage = 0; + + // MultiShot stores its pending roll globally, including between a staff's bursts. + // Give the echo its own normal roll, then restore the attack already in progress. + object pendingShot = PendingShot.GetValue(null); + bool tripleShot = MultiShot.IsTripleShotActive; + int shotProjectiles = MultiShot.ShotProjectiles; + GameObject previousProjectile = weapon.m_lastProjectile; + float previousAttackTime = weapon.m_lastAttackTime; + activeEcho = echo; + echoPosition = player.transform.position; + collectingProjectiles = true; + try + { + echo.StartWithoutAnimation(player, player.GetComponent(), player.GetVisEquipment(), weapon, 1f); + collectingProjectiles = false; + // Finish all setup and burst hooks before impact. Epic Loot adds some impact + // effects after Projectile.Setup, including Explosive Arrows. + foreach (Projectile spawnedProjectile in EchoProjectiles) + { + if (spawnedProjectile != null) + { + spawnedProjectile.OnHit(null, echoPosition, false, Vector3.up); + } + } + } + catch (Exception exception) + { + PraetorisClientPlugin.Log.LogError("Adrenaline Echo failed: " + exception); + } + finally + { + activeEcho = null; + collectingProjectiles = false; + EchoProjectiles.Clear(); + PendingShot.SetValue(null, pendingShot); + MultiShot.IsTripleShotActive = tripleShot; + MultiShot.ShotProjectiles = shotProjectiles; + weapon.m_lastProjectile = previousProjectile; + weapon.m_lastAttackTime = previousAttackTime; + } + } + + [HarmonyPatch(typeof(Attack), nameof(Attack.GetProjectileSpawnPoint))] + private static class EchoPositionPatch + { + private static void Postfix(Attack __instance, ref Vector3 spawnPoint) + { + if (__instance == activeEcho) spawnPoint = echoPosition; + } + } + + [HarmonyPatch(typeof(Projectile), nameof(Projectile.Setup))] + private static class EchoSetupPatch + { + [HarmonyPriority(Priority.Last)] + private static void Prefix(Projectile __instance, Character owner, ref Vector3 velocity) + { + if (!collectingProjectiles || owner != Player.m_localPlayer) return; + __instance.transform.position = echoPosition; + velocity = Vector3.zero; + __instance.m_hitOwner = false; + __instance.m_bounce = false; + __instance.m_onlyStopOnTerrain = false; + __instance.m_respawnItemOnHit = false; + EchoProjectiles.Add(__instance); + } + } + + [HarmonyPatch(typeof(Player), nameof(Player.AddAdrenaline))] + private static class ActivationPatch + { + private static IEnumerable Transpiler(IEnumerable instructions) + { + // This branch runs only when full adrenaline activates equipment, including a + // refresh of an existing status effect and a fill from zero in one hit. + return new CodeMatcher(instructions) + .MatchStartForward(new CodeMatch(OpCodes.Ldfld, + AccessTools.Field(typeof(Player), nameof(Player.m_adrenalinePopEffects)))) + .ThrowIfNotMatch("Cannot locate trinket activation for Adrenaline Echo.") + .Insert(new CodeInstruction(OpCodes.Ldarg_0), + new CodeInstruction(OpCodes.Call, AccessTools.Method(typeof(AdrenalineEchoRuntime), nameof(OnTrinketActivated)))) + .InstructionEnumeration(); + } + } + + [HarmonyPatch(typeof(Attack), nameof(Attack.OnAttackTrigger))] + private static class EchoStaggerPatch + { + private static bool BlocksAttack(bool staggering, Attack attack) => staggering && attack != activeEcho; + + private static IEnumerable Transpiler(IEnumerable instructions) + { + return new CodeMatcher(instructions) + .MatchStartForward(new CodeMatch(instruction => instruction.Calls( + AccessTools.Method(typeof(Character), nameof(Character.IsStaggering))))) + .ThrowIfNotMatch("Cannot locate attack stagger check for Adrenaline Echo.") + .Advance(1) + .Insert(new CodeInstruction(OpCodes.Ldarg_0), + new CodeInstruction(OpCodes.Call, AccessTools.Method(typeof(EchoStaggerPatch), nameof(BlocksAttack)))) + .InstructionEnumeration(); + } + } + + [HarmonyPatch(typeof(Attack), nameof(Attack.GetAttackEitr), new Type[] { })] + private static class EchoEitrPatch + { + private static bool Prefix(Attack __instance, ref float __result) + { + if (__instance != activeEcho) return true; + // Vanilla reads eitr cost from the weapon's shared template, not this clone. + __result = 0f; + return false; + } + } + + [HarmonyPatch(typeof(Attack), nameof(Attack.ProjectileAttackTriggered))] + private static class EchoProjectilePatch + { + private static bool Prefix(Attack __instance) + { + if (__instance != activeEcho) return true; + // Use the normal patched burst with the real ItemData. This preserves Epic Loot + // damage, speed, multishot, projectile markers and weapon attribution on impact. + // The burst supplies the projectile effects without durability or reload changes. + FireProjectileBurst(__instance); + return false; + } + } + } +} diff --git a/Source/EpicLoot/PraetorisMagicEffects.cs b/Source/EpicLoot/PraetorisMagicEffects.cs index da207cf..6cfae99 100644 --- a/Source/EpicLoot/PraetorisMagicEffects.cs +++ b/Source/EpicLoot/PraetorisMagicEffects.cs @@ -21,6 +21,7 @@ internal static class PraetorisMagicEffects internal const string PointBlank = "PointBlank"; internal const string ReloadOnKill = "ReloadOnKill"; internal const string ArrowRain = "ArrowRain"; + internal const string AdrenalineEcho = "AdrenalineEcho"; internal static readonly float[] PointBlankValues = { 5, 7, 10, 15, 20, 25 }; internal static readonly float[] PiercingShotValues = { 2, 3, 3, 4 }; @@ -31,6 +32,15 @@ internal static class PraetorisMagicEffects private static readonly string[] MagicEffectDefinitionJson = { @"{ + ""Type"": ""AdrenalineEcho"", + ""CanBeAugmented"": false, + ""CanBeDisenchanted"": false, + ""CanBeRunified"": false, + ""DisplayText"": ""Adrenaline Echo: Full adrenaline triggers your weapon's projectile area impact at your position."", + ""Description"": ""When this trinket activates its full-adrenaline effect, trigger your equipped weapon's projectile area impact at your position, without travel. Uses the projectile's area damage and your weapon's enchantments. Requires a weapon whose own projectile has area damage, such as Staff of Embers. Costs no durability, stamina, health, or eitr."", + ""Requirements"": { ""NoRoll"": true, ""AllowedItemTypes"": [ ""Trinket"" ] } +}", + @"{ ""Type"": ""IncreaseEffectDuration"", ""DisplayText"": ""Effect Duration +{0:0.#}%"", ""Description"": ""Increase the duration of timed status effects applied by this item by +X%. Also affects supported timed status effects from equipped gear."", diff --git a/Source/EpicLoot/PraetorisShardstones.cs b/Source/EpicLoot/PraetorisShardstones.cs index dd98dc1..238ec56 100644 --- a/Source/EpicLoot/PraetorisShardstones.cs +++ b/Source/EpicLoot/PraetorisShardstones.cs @@ -26,7 +26,9 @@ internal static class PraetorisShardstones new ShardSpec((ShardType)0x50520003, "Piercing Shot", "Stormcaller", PraetorisMagicEffects.PiercingShot, ShardCategory.Unique, ItemRarity.Epic, PraetorisMagicEffects.PiercingShotValues), new ShardSpec((ShardType)0x50520005, "Arrow Rain", "Stormcaller", PraetorisMagicEffects.ArrowRain, - ShardCategory.Unique, ItemRarity.Epic, new float[] { 1, 1, 1, 1 }) + ShardCategory.Unique, ItemRarity.Epic, new float[] { 1, 1, 1, 1 }), + new ShardSpec((ShardType)0x50520006, "Adrenaline Echo", "Stormcaller", PraetorisMagicEffects.AdrenalineEcho, + ShardCategory.Unique, ItemRarity.Epic, new float[] { 1, 1, 1, 1 }, ShardSlotCategory.Trinket) }; internal static void Initialize() @@ -105,22 +107,36 @@ internal static void AddDefinitions(ShardStonesConfig config) { if (config.Shards.TryGetValue(spec.Id, out ShardDefinition existing)) { - if (existing?.UniformEffect?.EffectType != spec.Effect) + ShardEffectDefinition? existingEffect = existing?.UniformEffect; + if (spec.Slot.HasValue && existing?.TypeEffects != null) + { + existing.TypeEffects.TryGetValue(spec.Slot.Value, out existingEffect); + } + if (existingEffect?.EffectType != spec.Effect) { throw new InvalidOperationException($"Shardstone ID {spec.Id} is already in use."); } continue; } - config.Shards.Add(spec.Id, new ShardDefinition + ShardEffectDefinition effect = new ShardEffectDefinition + { + EffectType = spec.Effect, + ValuesPerRarity = new Dictionary(spec.Values) + }; + ShardDefinition definition = new ShardDefinition { Category = spec.Category, - Rarities = spec.Values.Keys.ToList(), - UniformEffect = new ShardEffectDefinition - { - EffectType = spec.Effect, - ValuesPerRarity = new Dictionary(spec.Values) - } - }); + Rarities = spec.Values.Keys.ToList() + }; + if (spec.Slot.HasValue) + { + definition.TypeEffects.Add(spec.Slot.Value, effect); + } + else + { + definition.UniformEffect = effect; + } + config.Shards.Add(spec.Id, definition); } } @@ -175,16 +191,18 @@ private sealed class ShardSpec internal readonly string Name; internal readonly string Template; internal readonly string Effect; + internal readonly ShardSlotCategory? Slot; internal readonly ShardCategory Category; internal readonly Dictionary Values = new Dictionary(); internal ShardSpec(ShardType id, string name, string template, string effect, - ShardCategory category, ItemRarity firstRarity, float[] values) + ShardCategory category, ItemRarity firstRarity, float[] values, ShardSlotCategory? slot = null) { Id = id; Name = name; Template = template; Effect = effect; + Slot = slot; Category = category; for (int index = 0; index < values.Length; index++) { diff --git a/manifest.json b/manifest.json index 7a4e575..556b06a 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "name": "PraetorisClient", "description": "Client-side Praetoris tools for Discord linking, creative server bridges, RPC tracing, socket metrics, and RPC probes.", - "version_number": "0.1.83", + "version_number": "0.1.84", "website_url": "https://discord.gg/3aaru2VyHJ", "dependencies": [ "denikson-BepInExPack_Valheim-5.4.2333", diff --git a/thunderstore.toml b/thunderstore.toml index 4846424..6efbc67 100644 --- a/thunderstore.toml +++ b/thunderstore.toml @@ -4,7 +4,7 @@ schemaVersion = "0.0.1" [package] namespace = "praetoris" name = "PraetorisClient" -versionNumber = "0.1.83" +versionNumber = "0.1.84" description = "Client-side Praetoris tools for Discord linking, creative server bridges, RPC tracing, socket metrics, and RPC probes." websiteUrl = "https://discord.gg/3aaru2VyHJ" containsNsfwContent = false