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");
+ }
+ }
+}