diff --git a/Framework/Intersect.Framework.Core/Entities/SpellEffect.cs b/Framework/Intersect.Framework.Core/Entities/SpellEffect.cs index 5445e44340..77b8be1ce8 100644 --- a/Framework/Intersect.Framework.Core/Entities/SpellEffect.cs +++ b/Framework/Intersect.Framework.Core/Entities/SpellEffect.cs @@ -29,4 +29,8 @@ public enum SpellEffect Taunt = 12, Knockback = 13, + + HealingReduction = 14, + + HealingBoost = 15, } diff --git a/Framework/Intersect.Framework.Core/GameObjects/Spells/SpellCombatDescriptor.cs b/Framework/Intersect.Framework.Core/GameObjects/Spells/SpellCombatDescriptor.cs index 8325bc031c..c05f3e50a0 100644 --- a/Framework/Intersect.Framework.Core/GameObjects/Spells/SpellCombatDescriptor.cs +++ b/Framework/Intersect.Framework.Core/GameObjects/Spells/SpellCombatDescriptor.cs @@ -83,6 +83,13 @@ public string PercentageStatDiffJson public SpellEffect Effect { get; set; } + /// + /// Number of tiles to push the target when Knockback effect is applied. + /// + public int KnockbackTiles { get; set; } = 1; + + public int? PercentageEffect { get; set; } + public string TransformSprite { get; set; } [Column("OnHit")] diff --git a/Intersect.Client.Core/Localization/Strings.cs b/Intersect.Client.Core/Localization/Strings.cs index ea8fb4106c..67c3f493c5 100644 --- a/Intersect.Client.Core/Localization/Strings.cs +++ b/Intersect.Client.Core/Localization/Strings.cs @@ -2652,6 +2652,9 @@ public partial struct SpellDescription {10, @"Sleep"}, {11, @"On-Hit"}, {12, @"Taunt"}, + {13, @"Knockback"}, + {14, @"Grievous Wounds"}, + {15, @"Healing Boost"}, }; [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] diff --git a/Intersect.Editor/Forms/Editors/frmSpell.Designer.cs b/Intersect.Editor/Forms/Editors/frmSpell.Designer.cs index cae7914140..5a56f69388 100644 --- a/Intersect.Editor/Forms/Editors/frmSpell.Designer.cs +++ b/Intersect.Editor/Forms/Editors/frmSpell.Designer.cs @@ -121,6 +121,10 @@ private void InitializeComponent() this.picSprite = new System.Windows.Forms.PictureBox(); this.cmbTransform = new DarkUI.Controls.DarkComboBox(); this.lblSprite = new System.Windows.Forms.Label(); + this.lblKnockbackTiles = new System.Windows.Forms.Label(); + this.nudKnockbackTiles = new DarkUI.Controls.DarkNumericUpDown(); + this.lblPercentageEffect = new System.Windows.Forms.Label(); + this.nudPercentageEffect = new DarkUI.Controls.DarkNumericUpDown(); this.grpEffectDuration = new DarkUI.Controls.DarkGroupBox(); this.nudBuffDuration = new DarkUI.Controls.DarkNumericUpDown(); this.lblBuffDuration = new System.Windows.Forms.Label(); @@ -206,6 +210,8 @@ private void InitializeComponent() ((System.ComponentModel.ISupportInitialize)(this.nudTick)).BeginInit(); this.grpEffect.SuspendLayout(); ((System.ComponentModel.ISupportInitialize)(this.picSprite)).BeginInit(); + ((System.ComponentModel.ISupportInitialize)(this.nudKnockbackTiles)).BeginInit(); + ((System.ComponentModel.ISupportInitialize)(this.nudPercentageEffect)).BeginInit(); this.grpEffectDuration.SuspendLayout(); ((System.ComponentModel.ISupportInitialize)(this.nudBuffDuration)).BeginInit(); this.grpDamage.SuspendLayout(); @@ -1511,6 +1517,10 @@ private void InitializeComponent() this.grpEffect.Controls.Add(this.picSprite); this.grpEffect.Controls.Add(this.cmbTransform); this.grpEffect.Controls.Add(this.lblSprite); + this.grpEffect.Controls.Add(this.lblKnockbackTiles); + this.grpEffect.Controls.Add(this.nudKnockbackTiles); + this.grpEffect.Controls.Add(this.lblPercentageEffect); + this.grpEffect.Controls.Add(this.nudPercentageEffect); this.grpEffect.ForeColor = System.Drawing.Color.Gainsboro; this.grpEffect.Location = new System.Drawing.Point(224, 243); this.grpEffect.Name = "grpEffect"; @@ -1554,7 +1564,10 @@ private void InitializeComponent() "Shield", "Sleep", "On Hit", - "Taunt"}); + "Taunt", + "Knockback", + "Grievous Wounds", + "Healing Boost"}); this.cmbExtraEffect.Location = new System.Drawing.Point(5, 31); this.cmbExtraEffect.Name = "cmbExtraEffect"; this.cmbExtraEffect.Size = new System.Drawing.Size(80, 21); @@ -1605,6 +1618,56 @@ private void InitializeComponent() this.lblSprite.TabIndex = 40; this.lblSprite.Text = "Sprite:"; // + // lblKnockbackTiles + // + + this.lblKnockbackTiles.AutoSize = true; + this.lblKnockbackTiles.Location = new System.Drawing.Point(91, 34); + this.lblKnockbackTiles.Name = "lblKnockbackTiles"; + this.lblKnockbackTiles.Size = new System.Drawing.Size(32, 13); + this.lblKnockbackTiles.TabIndex = 45; + this.lblKnockbackTiles.Text = "Tiles:"; + this.lblKnockbackTiles.Visible = false; + // + // nudKnockbackTiles + // + + this.nudKnockbackTiles.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(69)))), ((int)(((byte)(73)))), ((int)(((byte)(74))))); + this.nudKnockbackTiles.ForeColor = System.Drawing.Color.Gainsboro; + this.nudKnockbackTiles.Location = new System.Drawing.Point(129, 31); + this.nudKnockbackTiles.Maximum = new decimal(new int[] { 10, 0, 0, 0 }); + this.nudKnockbackTiles.Minimum = new decimal(new int[] { 1, 0, 0, 0 }); + this.nudKnockbackTiles.Name = "nudKnockbackTiles"; + this.nudKnockbackTiles.Size = new System.Drawing.Size(50, 20); + this.nudKnockbackTiles.TabIndex = 46; + this.nudKnockbackTiles.Value = new decimal(new int[] { 1, 0, 0, 0 }); + this.nudKnockbackTiles.Visible = false; + this.nudKnockbackTiles.ValueChanged += new System.EventHandler(this.nudKnockbackTiles_ValueChanged); + // + // lblPercentageEffect + // + this.lblPercentageEffect.AutoSize = true; + this.lblPercentageEffect.Location = new System.Drawing.Point(91, 34); + this.lblPercentageEffect.Name = "lblPercentageEffect"; + this.lblPercentageEffect.Size = new System.Drawing.Size(32, 13); + this.lblPercentageEffect.TabIndex = 47; + this.lblPercentageEffect.Text = "%:"; + this.lblPercentageEffect.Visible = false; + // + // nudPercentageEffect + // + this.nudPercentageEffect.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(69)))), ((int)(((byte)(73)))), ((int)(((byte)(74))))); + this.nudPercentageEffect.ForeColor = System.Drawing.Color.Gainsboro; + this.nudPercentageEffect.Location = new System.Drawing.Point(129, 31); + this.nudPercentageEffect.Maximum = new decimal(new int[] { 1000, 0, 0, 0 }); + this.nudPercentageEffect.Minimum = new decimal(new int[] { 0, 0, 0, 0 }); + this.nudPercentageEffect.Name = "nudPercentageEffect"; + this.nudPercentageEffect.Size = new System.Drawing.Size(50, 20); + this.nudPercentageEffect.TabIndex = 48; + this.nudPercentageEffect.Value = new decimal(new int[] { 0, 0, 0, 0 }); + this.nudPercentageEffect.Visible = false; + this.nudPercentageEffect.ValueChanged += new System.EventHandler(this.nudPercentageEffect_ValueChanged); + // // grpEffectDuration // this.grpEffectDuration.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(45)))), ((int)(((byte)(45)))), ((int)(((byte)(48))))); @@ -2415,6 +2478,8 @@ private void InitializeComponent() ((System.ComponentModel.ISupportInitialize)(this.nudHitRadius)).EndInit(); ((System.ComponentModel.ISupportInitialize)(this.nudCastRange)).EndInit(); this.grpCombat.ResumeLayout(false); + ((System.ComponentModel.ISupportInitialize)(this.nudKnockbackTiles)).EndInit(); + ((System.ComponentModel.ISupportInitialize)(this.nudPercentageEffect)).EndInit(); this.grpStats.ResumeLayout(false); this.grpStats.PerformLayout(); ((System.ComponentModel.ISupportInitialize)(this.nudSpdPercentage)).EndInit(); @@ -2607,5 +2672,9 @@ private void InitializeComponent() private DarkComboBox cmbTickAnimation; private System.Windows.Forms.Label lblSpriteCastAnimation; private DarkComboBox cmbCastSprite; + private System.Windows.Forms.Label lblKnockbackTiles; + private DarkNumericUpDown nudKnockbackTiles; + private System.Windows.Forms.Label lblPercentageEffect; + private DarkNumericUpDown nudPercentageEffect; } } diff --git a/Intersect.Editor/Forms/Editors/frmSpell.cs b/Intersect.Editor/Forms/Editors/frmSpell.cs index 714070b114..9e763d73bb 100644 --- a/Intersect.Editor/Forms/Editors/frmSpell.cs +++ b/Intersect.Editor/Forms/Editors/frmSpell.cs @@ -378,6 +378,7 @@ private void UpdateSpellTypePanels() chkHOTDOT.Checked = mEditorItem.Combat.HoTDoT; nudBuffDuration.Value = mEditorItem.Combat.Duration; nudTick.Value = mEditorItem.Combat.HotDotInterval; + nudPercentageEffect.Value = mEditorItem.Combat.PercentageEffect ?? 0; cmbExtraEffect.SelectedIndex = (int)mEditorItem.Combat.Effect; cmbExtraEffect_SelectedIndexChanged(null, null); } @@ -535,6 +536,10 @@ private void cmbExtraEffect_SelectedIndexChanged(object sender, EventArgs e) lblSprite.Visible = false; cmbTransform.Visible = false; picSprite.Visible = false; + lblKnockbackTiles.Visible = false; + nudKnockbackTiles.Visible = false; + lblPercentageEffect.Visible = false; + nudPercentageEffect.Visible = false; if (cmbExtraEffect.SelectedIndex == 6) //Transform { @@ -567,6 +572,19 @@ private void cmbExtraEffect_SelectedIndexChanged(object sender, EventArgs e) picSprite.BackgroundImage = null; } } + + if (cmbExtraEffect.SelectedIndex == (int)SpellEffect.Knockback) // Knockback + { + lblKnockbackTiles.Visible = true; + nudKnockbackTiles.Visible = true; + nudKnockbackTiles.Value = Math.Max(1, mEditorItem.Combat.KnockbackTiles); + } + + if (cmbExtraEffect.SelectedIndex == (int)SpellEffect.HealingReduction || cmbExtraEffect.SelectedIndex == (int)SpellEffect.HealingBoost) + { + lblPercentageEffect.Visible = true; + nudPercentageEffect.Visible = true; + } } private void frmSpell_FormClosed(object sender, FormClosedEventArgs e) @@ -1100,4 +1118,14 @@ private void cmbTickAnimation_SelectedIndexChanged(object sender, EventArgs e) Guid animationId = AnimationDescriptor.IdFromList(cmbTickAnimation.SelectedIndex - 1); mEditorItem.TickAnimation = AnimationDescriptor.Get(animationId); } + + private void nudKnockbackTiles_ValueChanged(object sender, EventArgs e) + { + mEditorItem.Combat.KnockbackTiles = (int)nudKnockbackTiles.Value; + } + + private void nudPercentageEffect_ValueChanged(object sender, EventArgs e) + { + mEditorItem.Combat.PercentageEffect = (int)nudPercentageEffect.Value; + } } diff --git a/Intersect.Editor/Localization/Strings.cs b/Intersect.Editor/Localization/Strings.cs index 9321a0d29d..7635823f63 100644 --- a/Intersect.Editor/Localization/Strings.cs +++ b/Intersect.Editor/Localization/Strings.cs @@ -5312,6 +5312,9 @@ public partial struct SpellEditor {10, @"Sleep"}, {11, @"OnHit"}, {12, @"Taunt"}, + {13, @"Knockback"}, + {14, @"Grievous Wounds"}, + {15, @"Healing Boost"}, }; public static LocalizedString effectgroup = @"Effect"; @@ -5361,6 +5364,9 @@ public partial struct SpellEditor [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public static LocalizedString TickAnimation = @"Tick Animation:"; + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public static LocalizedString knockbacktiles = @"Tiles:"; + public static LocalizedString magicresist = @"Magic Resist:"; public static LocalizedString manacost = @"Mana Cost:"; diff --git a/Intersect.Server.Core/Database/DbInterface.cs b/Intersect.Server.Core/Database/DbInterface.cs index f02da4f30f..fae07ff317 100644 --- a/Intersect.Server.Core/Database/DbInterface.cs +++ b/Intersect.Server.Core/Database/DbInterface.cs @@ -220,25 +220,28 @@ private static void ProcessMigrations(TContext context) { if (!context.HasPendingMigrations) { - ApplicationContext.Context.Value?.Logger.LogDebug($"No pending migrations for {context.GetType().GetName(qualified: true)}, skipping..."); + ApplicationContext.Context.Value?.Logger.LogInformation($"No pending migrations for {context.GetType().GetName(qualified: true)}, skipping..."); return; } - ApplicationContext.Context.Value?.Logger.LogDebug($"Pending schema migrations for {typeof(TContext).Name}:\n\t{string.Join("\n\t", context.PendingSchemaMigrations)}"); - ApplicationContext.Context.Value?.Logger.LogDebug($"Pending data migrations for {typeof(TContext).Name}:\n\t{string.Join("\n\t", context.PendingDataMigrationNames)}"); + ApplicationContext.Context.Value?.Logger.LogInformation($"Pending schema migrations for {typeof(TContext).Name}:\n\t{string.Join("\n\t", context.PendingSchemaMigrations)}"); + ApplicationContext.Context.Value?.Logger.LogInformation($"Pending data migrations for {typeof(TContext).Name}:\n\t{string.Join("\n\t", context.PendingDataMigrationNames)}"); var migrationScheduler = new MigrationScheduler(context); - ApplicationContext.Context.Value?.Logger.LogDebug("Scheduling pending migrations..."); + ApplicationContext.Context.Value?.Logger.LogInformation("Scheduling pending migrations..."); migrationScheduler.SchedulePendingMigrations(); - ApplicationContext.Context.Value?.Logger.LogDebug("Applying scheduled migrations..."); + ApplicationContext.Context.Value?.Logger.LogInformation("Applying scheduled migrations..."); migrationScheduler.ApplyScheduledMigrations(); + ApplicationContext.Context.Value?.Logger.LogInformation("Scheduled migrations applied."); var remainingPendingSchemaMigrations = context.PendingSchemaMigrations.ToList(); var processedSchemaMigrations = context.PendingSchemaMigrations.Where(migration => !remainingPendingSchemaMigrations.Contains(migration)); + ApplicationContext.Context.Value?.Logger.LogInformation("Notifying context of processed schema migrations..."); context.OnSchemaMigrationsProcessed(processedSchemaMigrations.ToArray()); + ApplicationContext.Context.Value?.Logger.LogInformation("Migration processing complete."); } internal static ILoggerFactory CreateLoggerFactory(DatabaseOptions databaseOptions) @@ -302,6 +305,7 @@ private static bool EnsureUpdated(IServerContext serverContext) EnableSensitiveDataLogging = true, LoggerFactory = CreateLoggerFactory(loggingDatabaseOptions), }); + ApplicationContext.Context.Value?.Logger.LogInformation("Logging context created."); // We don't want anyone running the old migration tool accidentally try @@ -326,6 +330,7 @@ private static bool EnsureUpdated(IServerContext serverContext) // ignored } + ApplicationContext.Context.Value?.Logger.LogInformation("Checking pending migrations..."); var gameContextPendingMigrations = gameContext.PendingSchemaMigrations; var playerContextPendingMigrations = playerContext.PendingSchemaMigrations; var loggingContextPendingMigrations = loggingContext.PendingSchemaMigrations; @@ -340,6 +345,8 @@ private static bool EnsureUpdated(IServerContext serverContext) !loggingContextPendingMigrations.Contains("20191118024649_RequestLogs") ); + ApplicationContext.Context.Value?.Logger.LogInformation($"Show migration warning: {showMigrationWarning}"); + if (showMigrationWarning) { if (serverContext.StartupOptions.MigrateAutomatically) @@ -394,11 +401,14 @@ private static bool EnsureUpdated(IServerContext serverContext) Console.WriteLine("No migrations pending that require user acceptance, skipping prompt..."); } + ApplicationContext.Context.Value?.Logger.LogInformation("Processing migrations..."); var contexts = new List { gameContext, playerContext, loggingContext }; foreach (var context in contexts) { var contextType = context.GetType().FindGenericTypeParameters(typeof(IntersectDbContext<>)).First(); + ApplicationContext.Context.Value?.Logger.LogInformation($"Processing migration for context: {contextType.Name}"); _methodInfoProcessMigrations.MakeGenericMethod(contextType).Invoke(null, new object[] { context }); + ApplicationContext.Context.Value?.Logger.LogInformation($"Finished migration for context: {contextType.Name}"); } return true; diff --git a/Intersect.Server.Core/Database/MigrationScheduler.cs b/Intersect.Server.Core/Database/MigrationScheduler.cs index 394ada1a33..0f415552de 100644 --- a/Intersect.Server.Core/Database/MigrationScheduler.cs +++ b/Intersect.Server.Core/Database/MigrationScheduler.cs @@ -63,13 +63,16 @@ public void ApplyScheduledMigrations() while (scheduleSegment.Any()) { + ApplicationContext.Context.Value?.Logger.LogInformation($"Schedule segment count: {scheduleSegment.Count}"); var targetMigration = scheduleSegment .TakeWhile(metadata => metadata is SchemaMigrationMetadata) .LastOrDefault(); if (targetMigration is SchemaMigrationMetadata) { + ApplicationContext.Context.Value?.Logger.LogInformation($"Migrating schema via EF Core to: {targetMigration.Name}"); migrator.Migrate(targetMigration.Name); + ApplicationContext.Context.Value?.Logger.LogInformation($"EF Core migration to {targetMigration.Name} finished."); scheduleSegment = scheduleSegment .SkipWhile(metadata => metadata is SchemaMigrationMetadata) @@ -94,7 +97,9 @@ public void ApplyScheduledMigrations() throw new InvalidOperationException($"Failed to create instance of data migration: {scheduledDataMigration.MigratorType.FullName}"); } + ApplicationContext.Context.Value?.Logger.LogInformation($"Executing data migration: {scheduledDataMigration.Name}"); dataMigration.Up(_context.ContextOptions); + ApplicationContext.Context.Value?.Logger.LogInformation($"Data migration {scheduledDataMigration.Name} finished."); scheduleSegment = scheduleSegment .Skip(1) diff --git a/Intersect.Server.Core/Entities/Entity.cs b/Intersect.Server.Core/Entities/Entity.cs index ed1f6f6a3a..5f91b90665 100644 --- a/Intersect.Server.Core/Entities/Entity.cs +++ b/Intersect.Server.Core/Entities/Entity.cs @@ -1551,12 +1551,62 @@ public void AddVital(Vital vital, long amount) return; } + // Apply healing modifiers for Health only (positive amounts = healing) + if (vital == Vital.Health && amount > 0) + { + amount = ApplyHealingModifiers(amount); + } + var vitalId = (int)vital; var maxVitalValue = GetMaxVital(vitalId); var safeAmount = Math.Min(amount, long.MaxValue - maxVitalValue); SetVital(vital, GetVital(vital) + safeAmount); } + /// + /// Applies Healing Reduction and Healing Boost modifiers to healing amount. + /// Formula: Healing * (1 + boost) OR Healing * (1 - reduction) + /// Only the latest applied effect is considered. + /// + protected long ApplyHealingModifiers(long healAmount) + { + if (healAmount <= 0) + { + return healAmount; + } + + // Multiplicative Stacking Logic + // 1. Calculate the strongest (highest %) Healing Boost currently active. + var maxBoost = 0; + var maxReduction = 0; + + foreach (var status in CachedStatuses) + { + if (status.Type == SpellEffect.HealingBoost) + { + var val = status.Spell.Combat.PercentageEffect ?? 0; + if (val > maxBoost) maxBoost = val; + } + else if (status.Type == SpellEffect.HealingReduction) + { + var val = status.Spell.Combat.PercentageEffect ?? 0; + if (val > maxReduction) maxReduction = val; + } + } + + // 2. Apply Boost Multiplier (e.g. 50% boost -> 1.5x) + double boostMultiplier = 1.0 + (Math.Clamp(maxBoost, 0, 1000) / 100.0); + + // 3. Apply Reduction Multiplier (e.g. 40% reduction -> 0.6x) + // Clamp reduction to max 100% (multiplier 0.0) to prevents negative healing (damage) + double reductionMultiplier = 1.0 - (Math.Clamp(maxReduction, 0, 100) / 100.0); + + // 4. Final Calculation + // Result = Base * Boost * Reduction + // If reduction is 100%, multiplier is 0, result is 0. + return (long)(healAmount * boostMultiplier * reductionMultiplier); + } + public void SubVital(Vital vital, long amount) { if (!Enum.IsDefined(vital)) @@ -1615,7 +1665,8 @@ public virtual void TryAttack(Entity target, if (parentSpell != null) { - TryAttack(target, parentSpell); + var willKnockback = projectile != null && projectile.Knockback > 0; + TryAttack(target, parentSpell, false, false, willKnockback); } var targetPlayer = target as Player; @@ -1663,7 +1714,8 @@ public virtual void TryAttack(Entity target, var s = projectile.Spell; if (s != null) { - HandleAoESpell(projectile.SpellId, s.Combat.HitRadius, target.MapId, target.X, target.Y, null); + var willKnockback = projectile.Knockback > 0; + HandleAoESpell(projectile.SpellId, s.Combat.HitRadius, target.MapId, target.X, target.Y, null, willKnockback ? target : null); } //Check that the npc has not been destroyed by the splash spell @@ -1679,79 +1731,9 @@ public virtual void TryAttack(Entity target, return; } - if (projectile.HomingBehavior || projectile.DirectShotBehavior) - { - // we need to get the direction based on the shooter's position and the target's position and angle between them - double angle = 0; - if (target.MapId == MapId) - { - angle = Math.Atan2(target.Y - Y, target.X - X); - } - else - { - var grid = DbInterface.GetGrid(Map.MapGrid); - bool angleFound = false; - - for (var y = Map.MapGridY - 1; y <= Map.MapGridY + 1; y++) - { - for (var x = Map.MapGridX - 1; x <= Map.MapGridX + 1; x++) - { - if (x < 0 || x >= grid.MapIdGrid.GetLength(0) || y < 0 || y >= grid.MapIdGrid.GetLength(1)) - { - continue; - } - - if (grid.MapIdGrid[x, y] == target.MapId) - { - int targetAbsoluteX = target.X + (x * Options.Instance.Map.MapWidth); - int targetAbsoluteY = target.Y + (y * Options.Instance.Map.MapHeight); - int playerAbsoluteX = X + (Map.MapGridX * Options.Instance.Map.MapWidth); - int playerAbsoluteY = Y + (Map.MapGridY * Options.Instance.Map.MapHeight); - - angle = Math.Atan2(targetAbsoluteY - playerAbsoluteY, targetAbsoluteX - playerAbsoluteX); - angleFound = true; - break; - } - } - - if (angleFound) break; - } - } - - var angleDegrees = angle * (180 / Math.PI); - if (angleDegrees >= -30 && angleDegrees <= 30) - { - projectileDir = Direction.Right; - } - else if (angleDegrees >= 30 && angleDegrees <= 60) - { - projectileDir = Direction.DownRight; - } - else if (angleDegrees >= 60 && angleDegrees <= 120) - { - projectileDir = Direction.Down; - } - else if (angleDegrees >= 120 && angleDegrees <= 150) - { - projectileDir = Direction.DownLeft; - } - else if (angleDegrees >= 150 || angleDegrees <= -150) - { - projectileDir = Direction.Left; - } - else if (angleDegrees >= -150 && angleDegrees <= -120) - { - projectileDir = Direction.UpLeft; - } - else if (angleDegrees >= -120 && angleDegrees <= -60) - { - projectileDir = Direction.Up; - } - else if (angleDegrees >= -60 && angleDegrees <= -30) - { - projectileDir = Direction.UpRight; - } - } + // Logic removed: Trust the projectileDir passed from ProjectileSpawn, which knows the impact angle. + // Previously, this recalculated direction based on Shooter <-> Target, which caused incorrect knockback + // if the shooter moved or if the projectile curved (homing). // If there is knock-back: knock them backwards. if (projectile.Knockback > 0 && ((int)projectileDir < Options.Instance.Map.MovementDirections) && !target.Immunities.Contains(SpellEffect.Knockback)) @@ -1765,7 +1747,8 @@ public virtual void TryAttack( Entity target, SpellDescriptor spellDescriptor, bool onHitTrigger = false, - bool trapTrigger = false + bool trapTrigger = false, + bool ignoreKnockback = false ) { if (target is Resource || target is EventPageInstance) @@ -1931,6 +1914,21 @@ public virtual void TryAttack( { new Status(target, this, spellDescriptor, spellDescriptor.Combat.Effect, statBuffTime, ""); + // Handle Knockback (moved here from Status.cs to prevent double-application) + if (spellDescriptor.Combat.Effect == SpellEffect.Knockback && !ignoreKnockback && !target.Immunities.Contains(SpellEffect.Knockback)) + { + var knockbackDirection = GetDirectionTo(target); + if (knockbackDirection != Direction.None) + { + // Use Spell's KnockbackTiles configuration + var tiles = spellDescriptor.Combat.KnockbackTiles; + if (tiles > 0) + { + new Dash(target, tiles, knockbackDirection, false, false, false, false); + } + } + } + if (target is Npc npc) { npc.AssignTarget(this); @@ -2211,10 +2209,17 @@ public void Attack( } else if (baseDamage < 0 && !enemy.IsFullVital(Vital.Health)) { - enemy.AddVital(Vital.Health, -baseDamage); - PacketSender.SendActionMsg( - enemy, Strings.Combat.AddSymbol + Math.Abs(baseDamage), CustomColors.Combat.Heal - ); + var healAmount = -baseDamage; + var previousHealth = enemy.GetVital(Vital.Health); + enemy.AddVital(Vital.Health, healAmount); + var actualHealed = enemy.GetVital(Vital.Health) - previousHealth; + + if (actualHealed > 0) + { + PacketSender.SendActionMsg( + enemy, Strings.Combat.AddSymbol + actualHealed, CustomColors.Combat.Heal + ); + } } } @@ -2694,7 +2699,8 @@ private void HandleAoESpell( Guid startMapId, int startX, int startY, - Entity spellTarget + Entity spellTarget, + Entity knockbackExclusionTarget = null ) { var spellBase = SpellDescriptor.Get(spellId); @@ -2723,7 +2729,7 @@ Entity spellTarget } } - TryAttack(entity, spellBase); //Handle damage + TryAttack(entity, spellBase, false, false, entity == knockbackExclusionTarget); //Handle damage } } } diff --git a/Intersect.Server.Core/Entities/Npc.cs b/Intersect.Server.Core/Entities/Npc.cs index 118855cdbb..a7c4955f8d 100644 --- a/Intersect.Server.Core/Entities/Npc.cs +++ b/Intersect.Server.Core/Entities/Npc.cs @@ -503,6 +503,9 @@ private static bool PredicateStunnedOrSleeping(Status status) case SpellEffect.Shield: case SpellEffect.OnHit: case SpellEffect.Taunt: + case SpellEffect.Knockback: + case SpellEffect.HealingReduction: + case SpellEffect.HealingBoost: case null: return false; @@ -530,6 +533,9 @@ private static bool PredicateUnableToCastSpells(Status status) case SpellEffect.Shield: case SpellEffect.OnHit: case SpellEffect.Taunt: + case SpellEffect.Knockback: + case SpellEffect.HealingReduction: + case SpellEffect.HealingBoost: case null: return false; diff --git a/Intersect.Server.Core/Entities/Player.cs b/Intersect.Server.Core/Entities/Player.cs index 54c789b863..e640883780 100644 --- a/Intersect.Server.Core/Entities/Player.cs +++ b/Intersect.Server.Core/Entities/Player.cs @@ -1668,7 +1668,8 @@ public override void TryAttack( Entity target, SpellDescriptor spellDescriptor, bool onHitTrigger = false, - bool trapTrigger = false + bool trapTrigger = false, + bool ignoreKnockback = false ) { if (!trapTrigger && !ValidTauntTarget(target)) //Traps ignore taunts. @@ -1676,7 +1677,7 @@ public override void TryAttack( return; } - base.TryAttack(target, spellDescriptor, onHitTrigger, trapTrigger); + base.TryAttack(target, spellDescriptor, onHitTrigger, trapTrigger, ignoreKnockback); } /// diff --git a/Intersect.Server.Core/Localization/Strings.cs b/Intersect.Server.Core/Localization/Strings.cs index bf0b8a7099..66d3dc261f 100644 --- a/Intersect.Server.Core/Localization/Strings.cs +++ b/Intersect.Server.Core/Localization/Strings.cs @@ -334,6 +334,9 @@ public sealed partial class CombatNamespace : LocaleNamespace {10, @"SLEEP!"}, {11, @"ON HIT!"}, {12, @"TAUNT!"}, + {13, @"KNOCKBACK!"}, + {14, @"GRIEVOUS WOUNDS!"}, + {15, @"HEALING BOOST!"}, } ); diff --git a/Intersect.Server.Core/Migrations/Sqlite/Game/20251212000000_AddCombatEffects.Designer.cs b/Intersect.Server.Core/Migrations/Sqlite/Game/20251212000000_AddCombatEffects.Designer.cs new file mode 100644 index 0000000000..393236da37 --- /dev/null +++ b/Intersect.Server.Core/Migrations/Sqlite/Game/20251212000000_AddCombatEffects.Designer.cs @@ -0,0 +1,26 @@ +// +using Intersect.Server.Database.GameData; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Intersect.Server.Migrations.Sqlite.Game +{ + [DbContext(typeof(SqliteGameContext))] + [Migration("20251212000000_AddCombatEffects")] + partial class AddCombatEffects + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.11"); + // Model snapshot skipped for manual migration to avoid massive file duplication. + // Runtime only needs Up/Down. +#pragma warning restore 612, 618 + } + } +} diff --git a/Intersect.Server.Core/Migrations/Sqlite/Game/20251212000000_AddCombatEffects.cs b/Intersect.Server.Core/Migrations/Sqlite/Game/20251212000000_AddCombatEffects.cs new file mode 100644 index 0000000000..682ff09b17 --- /dev/null +++ b/Intersect.Server.Core/Migrations/Sqlite/Game/20251212000000_AddCombatEffects.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Intersect.Server.Migrations.Sqlite.Game +{ + /// + public partial class AddCombatEffects : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Combat_KnockbackTiles", + table: "Spells", + type: "INTEGER", + nullable: false, + defaultValue: 1); + + migrationBuilder.AddColumn( + name: "Combat_PercentageEffect", + table: "Spells", + type: "INTEGER", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Combat_KnockbackTiles", + table: "Spells"); + + migrationBuilder.DropColumn( + name: "Combat_PercentageEffect", + table: "Spells"); + } + } +} diff --git a/Intersect.Server.Core/Migrations/Sqlite/Game/SqliteGameContextModelSnapshot.cs b/Intersect.Server.Core/Migrations/Sqlite/Game/SqliteGameContextModelSnapshot.cs index 90d1d8d58d..f2a6f3952c 100644 --- a/Intersect.Server.Core/Migrations/Sqlite/Game/SqliteGameContextModelSnapshot.cs +++ b/Intersect.Server.Core/Migrations/Sqlite/Game/SqliteGameContextModelSnapshot.cs @@ -1535,6 +1535,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b1.Property("HotDotInterval") .HasColumnType("INTEGER"); + b1.Property("KnockbackTiles") + .HasColumnType("INTEGER"); + b1.Property("OnHitDuration") .HasColumnType("INTEGER") .HasColumnName("OnHit"); diff --git a/Intersect.Server/Migrations/MySql/Game/20251212000000_AddCombatEffects.Designer.cs b/Intersect.Server/Migrations/MySql/Game/20251212000000_AddCombatEffects.Designer.cs new file mode 100644 index 0000000000..b84cff153c --- /dev/null +++ b/Intersect.Server/Migrations/MySql/Game/20251212000000_AddCombatEffects.Designer.cs @@ -0,0 +1,44 @@ +// +using Intersect.Server.Database.GameData; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Intersect.Server.Migrations.MySql.Game +{ + [DbContext(typeof(MySqlGameContext))] + [Migration("20251212000000_AddCombatEffects")] + partial class AddCombatEffects + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11"); + + modelBuilder.Entity("Intersect.Framework.Core.GameObjects.Spells.SpellDescriptor", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("Combat_KnockbackTiles") + .HasColumnType("int"); + + b.Property("Combat_PercentageEffect") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Spells"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Intersect.Server/Migrations/MySql/Game/20251212000000_AddCombatEffects.cs b/Intersect.Server/Migrations/MySql/Game/20251212000000_AddCombatEffects.cs new file mode 100644 index 0000000000..0274bd21d8 --- /dev/null +++ b/Intersect.Server/Migrations/MySql/Game/20251212000000_AddCombatEffects.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Intersect.Server.Migrations.MySql.Game +{ + /// + public partial class AddCombatEffects : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Combat_KnockbackTiles", + table: "Spells", + type: "int", + nullable: false, + defaultValue: 1); + + migrationBuilder.AddColumn( + name: "Combat_PercentageEffect", + table: "Spells", + type: "int", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Combat_KnockbackTiles", + table: "Spells"); + + migrationBuilder.DropColumn( + name: "Combat_PercentageEffect", + table: "Spells"); + } + } +}