Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion src/DataModel/Configuration/AreaSkillSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ public partial class AreaSkillSettings
/// </summary>
public int MaximumNumberOfHitsPerTarget { get; set; }

/// <summary>
/// Gets or sets the minimum number of hits per attack, after which subsequent hits have a reduced chance to hit.
/// </summary>
public int MinimumNumberOfHitsPerAttack { get; set; }

/// <summary>
/// Gets or sets the maximum number of hits per attack.
/// </summary>
Expand All @@ -76,7 +81,7 @@ public partial class AreaSkillSettings
/// <summary>
/// Gets or sets the hit chance per distance multiplier.
/// E.g. when set to 0.9 and the target is 5 steps away,
/// the chance to hit is 5^0.9 = 0.59.
/// the chance to hit is 0.9^5 = 0.59.
/// </summary>
public float HitChancePerDistanceMultiplier { get; set; }

Expand All @@ -88,6 +93,11 @@ public partial class AreaSkillSettings
/// </summary>
public int ProjectileCount { get; set; }

/// <summary>
/// Gets or sets the effect range of the skill, which is the maximum distance from the target area center.
/// </summary>
public int EffectRange { get; set; }

/// <inheritdoc />
public override string ToString()
{
Expand Down
11 changes: 11 additions & 0 deletions src/DataModel/Configuration/Skill.cs
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,17 @@ public partial class Skill
/// </summary>
public virtual AttributeDefinition? ElementalModifierTarget { get; set; }

/// <summary>
/// Gets or sets a value indicating whether the elemental modifier resistance should be ignored, which means the skill uses a specific logic.
/// </summary>
/// <remarks>
/// Not all skills have magic effects corresponding to their element,
/// e.g. Pollution (book of lagle, lightning) has a 100% chance iceing effect.
/// Other skills have magic effects which may or may not be related to their element, but which apply regardless of resistance,
/// e.g. Explosion (book of samut, fire) and Requiem (book of neil, wind) have 100% bleeding effects.
/// </remarks>
public bool SkipElementalModifier { get; set; }

/// <summary>
/// Gets or sets the magic effect definition. It will be applied for buff skills.
/// </summary>
Expand Down
64 changes: 42 additions & 22 deletions src/GameLogic/AttackableExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ namespace MUnique.OpenMU.GameLogic;
/// </summary>
public static class AttackableExtensions
{
private const short ExplosionMagicEffectNumber = 75; // 0x4B

private static readonly IDictionary<AttributeDefinition, AttributeDefinition> ReductionModifiers =
new Dictionary<AttributeDefinition, AttributeDefinition>
{
Expand Down Expand Up @@ -314,7 +316,8 @@ public static HitInfo GetHitInfo(this IAttackable defender, uint damage, DamageA
/// <param name="target">The target.</param>
/// <param name="attacker">The attacker.</param>
/// <param name="skillEntry">The skill entry.</param>
public static async ValueTask ApplyMagicEffectAsync(this IAttackable target, IAttacker attacker, SkillEntry skillEntry)
/// <param name="hitInfo">The hit information.</param>
public static async ValueTask ApplyMagicEffectAsync(this IAttackable target, IAttacker attacker, SkillEntry skillEntry, HitInfo? hitInfo = null)
{
if (skillEntry.PowerUps is null && attacker is Player player)
{
Expand All @@ -329,7 +332,7 @@ public static async ValueTask ApplyMagicEffectAsync(this IAttackable target, IAt

var duration = target is Player ? skillEntry.PowerUpDurationPvp! : skillEntry.PowerUpDuration!;
var powerUps = target is Player ? skillEntry.PowerUpsPvp! : skillEntry.PowerUps!;
await target.ApplyMagicEffectAsync(attacker, skillEntry.Skill!.MagicEffectDef!, duration, powerUps).ConfigureAwait(false);
await target.ApplyMagicEffectAsync(attacker, skillEntry.Skill!.MagicEffectDef!, duration, hitInfo, powerUps).ConfigureAwait(false);
}

/// <summary>
Expand Down Expand Up @@ -373,8 +376,9 @@ public static async ValueTask ApplyRegenerationAsync(this IAttackable target, Pl
/// <param name="target">The target.</param>
/// <param name="attacker">The attacker.</param>
/// <param name="skillEntry">The skill entry.</param>
/// <param name="hitInfo">The hit information.</param>
/// <returns>The success of the appliance.</returns>
public static async ValueTask<bool> TryApplyElementalEffectsAsync(this IAttackable target, IAttacker attacker, SkillEntry skillEntry)
public static async ValueTask<bool> TryApplyElementalEffectsAsync(this IAttackable target, IAttacker attacker, SkillEntry skillEntry, HitInfo? hitInfo = null)
{
if (!target.IsAlive)
{
Expand All @@ -383,15 +387,15 @@ public static async ValueTask<bool> TryApplyElementalEffectsAsync(this IAttackab

skillEntry.ThrowNotInitializedProperty(skillEntry.Skill is null, nameof(skillEntry.Skill));
var modifier = skillEntry.Skill.ElementalModifierTarget;
if (modifier is null)
{
return false;
}
var skipModifier = skillEntry.Skill.SkipElementalModifier;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only the early seasons skills (S1, S2) use the elemental resistance checks:
emu, zTeamS6.3


var resistance = target.Attributes[modifier];
if (resistance >= 255 || !Rand.NextRandomBool(1 / (resistance + 1)))
if (modifier is not null && !skipModifier)
{
return false;
var resistance = target.Attributes[modifier];
if (resistance >= 255 || !Rand.NextRandomBool(1 / (resistance + 1)))
{
return false;
}
}

var applied = false;
Expand All @@ -400,11 +404,11 @@ public static async ValueTask<bool> TryApplyElementalEffectsAsync(this IAttackab
&& !target.MagicEffectList.ActiveEffects.ContainsKey(effectDefinition.Number))
{
// power-up is the wrong term here... it's more like a power-down ;-)
await target.ApplyMagicEffectAsync(attacker, skillEntry).ConfigureAwait(false);
await target.ApplyMagicEffectAsync(attacker, skillEntry, hitInfo).ConfigureAwait(false);
applied = true;
}

if (modifier == Stats.LightningResistance)
if (modifier == Stats.LightningResistance && !skipModifier)
{
await target.MoveRandomlyAsync().ConfigureAwait(false);
applied = true;
Expand All @@ -422,10 +426,9 @@ public static async ValueTask<bool> TryApplyElementalEffectsAsync(this IAttackab
/// <param name="powerUp">The power up.</param>
/// <param name="duration">The duration.</param>
/// <param name="targetAttribute">The target attribute.</param>
/// <returns>
/// The success of the appliance.
/// </returns>
public static async ValueTask<bool> TryApplyElementalEffectsAsync(this IAttackable target, IAttacker attacker, Skill skill, IElement? powerUp, IElement? duration, AttributeDefinition? targetAttribute)
/// <param name="hitInfo">The hit information.</param>
/// <returns>The success of the appliance.</returns>
public static async ValueTask<bool> TryApplyElementalEffectsAsync(this IAttackable target, IAttacker attacker, Skill skill, IElement? powerUp, IElement? duration, AttributeDefinition? targetAttribute, HitInfo? hitInfo)
{
if (!target.IsAlive)
{
Expand Down Expand Up @@ -453,7 +456,7 @@ public static async ValueTask<bool> TryApplyElementalEffectsAsync(this IAttackab
&& targetAttribute is not null)
{
// power-up is the wrong term here... it's more like a power-down ;-)
await target.ApplyMagicEffectAsync(attacker, effectDefinition, duration, (targetAttribute, powerUp)).ConfigureAwait(false);
await target.ApplyMagicEffectAsync(attacker, effectDefinition, duration, hitInfo, (targetAttribute, powerUp)).ConfigureAwait(false);
applied = true;
}

Expand Down Expand Up @@ -819,8 +822,9 @@ private static void GetBaseDmg(this IAttacker attacker, SkillEntry? skill, out i
/// <param name="attacker">The attacker.</param>
/// <param name="magicEffectDefinition">The magic effect definition.</param>
/// <param name="duration">The duration.</param>
/// <param name="hitInfo">The hit information.</param>
/// <param name="powerUps">The power ups of the effect.</param>
private static async ValueTask ApplyMagicEffectAsync(this IAttackable target, IAttacker attacker, MagicEffectDefinition magicEffectDefinition, IElement duration, params (AttributeDefinition Target, IElement Boost)[] powerUps)
private static async ValueTask ApplyMagicEffectAsync(this IAttackable target, IAttacker attacker, MagicEffectDefinition magicEffectDefinition, IElement duration, HitInfo? hitInfo, params (AttributeDefinition Target, IElement Boost)[] powerUps)
{
float finalDuration = duration.Value;

Expand All @@ -839,10 +843,26 @@ private static async ValueTask ApplyMagicEffectAsync(this IAttackable target, IA
return;
}

var isPoisonEffect = magicEffectDefinition.PowerUpDefinitions.Any(e => e.TargetAttribute == Stats.IsPoisoned);
var magicEffect = isPoisonEffect
? new PoisonMagicEffect(powerUps[0].Boost, magicEffectDefinition, durationSpan, attacker, target)
: new MagicEffect(durationSpan, magicEffectDefinition, powerUps.Select(p => new MagicEffect.ElementWithTarget(p.Boost, p.Target)).ToArray());
MagicEffect magicEffect;
if (magicEffectDefinition.PowerUpDefinitions.Any(e => e.TargetAttribute == Stats.IsPoisoned))
{
magicEffect = new PoisonMagicEffect(powerUps[0].Boost, magicEffectDefinition, durationSpan, attacker, target);
}
else if (magicEffectDefinition.PowerUpDefinitions.Any(e => e.TargetAttribute == Stats.IsBleeding))
{
if (hitInfo is not { } hit || hit.HealthDamage + hit.ShieldDamage < 1)
{
return;
}

var multiplier = magicEffectDefinition.Number == ExplosionMagicEffectNumber ? attacker.Attributes[Stats.BleedingDamageMultiplier] : 0.6f;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

emu, zTeamS6.3
Because the FireTomeMastery (Explosion skill) is a passive, we need to hardcode 0.6f here, otherwise you can use Requiem skill and also get the FireTomeMastery multiplier bonus.

var damage = (hit.HealthDamage + hit.ShieldDamage) * multiplier;
magicEffect = new BleedingMagicEffect(powerUps[0].Boost, magicEffectDefinition, durationSpan, attacker, target, damage);
}
else
{
magicEffect = new MagicEffect(durationSpan, magicEffectDefinition, powerUps.Select(p => new MagicEffect.ElementWithTarget(p.Boost, p.Target)).ToArray());
}

await target.MagicEffectList.AddEffectAsync(magicEffect).ConfigureAwait(false);
if (target is ISupportWalk walkSupporter
Expand Down
21 changes: 21 additions & 0 deletions src/GameLogic/Attributes/Stats.cs
Original file line number Diff line number Diff line change
Expand Up @@ -957,6 +957,12 @@ public class Stats
/// </summary>
public static AttributeDefinition IsAsleep { get; } = new(new Guid("0518F532-7A8F-4491-8A23-98B620608CB3"), "Is asleep", "The player is asleep and can't move until hit.");

/// <summary>
/// Gets the attribute definition, which defines if a player has explosion effect applied.
/// </summary>
/// <remarks>This can be caused by Explosion (book of samut) and Requiem (book of neil) skills.</remarks>
public static AttributeDefinition IsBleeding { get; } = new(new Guid("BD5C685D-C360-4CC5-A43E-46644AD61F09"), "Is bleeding", "The player is damaged every second for a while.");

/// <summary>
/// Gets the ice resistance attribute definition. Value range from 0 to 1.
/// </summary>
Expand Down Expand Up @@ -1057,6 +1063,11 @@ public class Stats
/// </summary>
public static AttributeDefinition PoisonDamageMultiplier { get; } = new(new Guid("8581CD4D-C6AE-4C35-9147-9642DE7CC013"), "Poison Damage Multiplier", string.Empty);

/// <summary>
/// Gets the bleeding damage multiplier attribute definition.
/// </summary>
public static AttributeDefinition BleedingDamageMultiplier { get; } = new(new Guid("12C20F28-F219-4044-899D-E9277D251515"), "Bleeding Damage Multiplier", string.Empty);

/// <summary>
/// Gets the mana recovery absolute attribute definition.
/// </summary>
Expand Down Expand Up @@ -1118,6 +1129,16 @@ public class Stats
/// </summary>
public static AttributeDefinition DoubleDamageChance { get; } = new(new Guid("2B8A03E6-1CC2-48A0-8633-3F36E17050F4"), "Double Damage Chance", string.Empty);

/// <summary>
/// Gets the stun chance attribute definition.
/// </summary>
public static AttributeDefinition StunChance { get; } = new(new Guid("610D3259-1158-424A-8738-9EB7A71DE600"), "Stun Chance", string.Empty);

/// <summary>
/// Gets the pollution skill MST target move chance, which rises with lightning tome mastery.
/// </summary>
public static AttributeDefinition PollutionMoveTargetChance { get; } = new(new Guid("6F9619FF-8B86-D011-B42D-00C04FC964FF"), "Pollution Move Target Chance (MST)", "The pollution skill (book of lagle) move chance, which rises with lightning tome mastery.");

/// <summary>
/// Gets the mana after monster kill attribute definition.
/// </summary>
Expand Down
73 changes: 73 additions & 0 deletions src/GameLogic/BleedingMagicEffect.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// <copyright file="BleedingMagicEffect.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>

namespace MUnique.OpenMU.GameLogic;

using System.Timers;
using MUnique.OpenMU.AttributeSystem;

/// <summary>
/// The magic effect for bleeding, which will damage the character every second until the effect ends.
/// </summary>
public sealed class BleedingMagicEffect : MagicEffect
{
private readonly Timer _damageTimer;
private readonly float _damage;

/// <summary>
/// Initializes a new instance of the <see cref="BleedingMagicEffect"/> class.
/// </summary>
/// <param name="powerUp">The power up.</param>
/// <param name="definition">The definition.</param>
/// <param name="duration">The duration.</param>
/// <param name="attacker">The attacker.</param>
/// <param name="owner">The owner.</param>
/// <param name="damage">The bleeding damage.</param>
public BleedingMagicEffect(IElement powerUp, MagicEffectDefinition definition, TimeSpan duration, IAttacker attacker, IAttackable owner, float damage)
: base(powerUp, definition, duration)
{
this.Attacker = attacker;
this.Owner = owner;
this._damage = damage;
this._damageTimer = new Timer(1000);
Copy link
Copy Markdown
Contributor Author

@ze-dom ze-dom Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

emu, zTeamS6.3
While poison is every 3s, this one seems to be every second. Check the skill videos at muonlinefanz.com (they are cut short, but you can see the 2nd dmg coming up).

this._damageTimer.Elapsed += this.OnDamageTimerElapsed;
this._damageTimer.Start();
}

/// <summary>
/// Gets the owner of the effect.
/// </summary>
public IAttackable Owner { get; }

/// <summary>
/// Gets the attacker which applied the effect.
/// </summary>
public IAttacker Attacker { get; }

/// <inheritdoc />
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
this._damageTimer.Stop();
this._damageTimer.Dispose();
}

[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", Justification = "Catching all Exceptions.")]
private async void OnDamageTimerElapsed(object? sender, ElapsedEventArgs e)
{
try
{
if (!this.Owner.IsAlive || this.IsDisposed || this.IsDisposing || this._damage <= 0)
{
return;
}

await this.Owner.ApplyBleedingDamageAsync(this.Attacker, (uint)this._damage).ConfigureAwait(false);
}
catch (Exception ex)
{
(this.Owner as ILoggerOwner)?.Logger.LogError(ex, "Error when applying bleeding damage");
}
}
}
7 changes: 7 additions & 0 deletions src/GameLogic/IAttackable.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,13 @@ public interface IAttackable : IIdentifiable, ILocateable
/// <param name="damage">The damage.</param>
ValueTask ApplyPoisonDamageAsync(IAttacker initialAttacker, uint damage);

/// <summary>
/// Applies the bleeding damage.
/// </summary>
/// <param name="initialAttacker">The initial attacker.</param>
/// <param name="damage">The damage.</param>
ValueTask ApplyBleedingDamageAsync(IAttacker initialAttacker, uint damage);

/// <summary>
/// Kills the attackable instantly.
/// </summary>
Expand Down
2 changes: 1 addition & 1 deletion src/GameLogic/MagicEffectsList.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ public async ValueTask ClearAllEffectsAsync()
/// <summary>
/// Clear the effects that produce a specific stat.
/// </summary>
/// <param name="stat">The stat produced by effect</param>
/// <param name="stat">The stat produced by effect.</param>
public async ValueTask ClearAllEffectsProducingSpecificStatAsync(AttributeDefinition stat)
{
var effects = this.ActiveEffects.Values.ToArray();
Expand Down
3 changes: 3 additions & 0 deletions src/GameLogic/NPC/AttackableNpcBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@ public int Health
/// <inheritdoc />
public abstract ValueTask ApplyPoisonDamageAsync(IAttacker initialAttacker, uint damage);

/// <inheritdoc />
public abstract ValueTask ApplyBleedingDamageAsync(IAttacker initialAttacker, uint damage);

/// <inheritdoc/>
public ValueTask KillInstantlyAsync()
{
Expand Down
7 changes: 7 additions & 0 deletions src/GameLogic/NPC/Destructible.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,11 @@ public override ValueTask ApplyPoisonDamageAsync(IAttacker initialAttacker, uint
// A destructible is not an organism which can be poisoned.
return ValueTask.CompletedTask;
}

/// <inheritdoc />
public override ValueTask ApplyBleedingDamageAsync(IAttacker initialAttacker, uint damage)
{
// A destructible is not an organism which can bleed.
return ValueTask.CompletedTask;
}
}
10 changes: 8 additions & 2 deletions src/GameLogic/NPC/Monster.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,12 @@ public Monster(MonsterSpawnArea spawnInfo, MonsterDefinition stats, GameMap map,
/// <param name="target">The target.</param>
public async ValueTask AttackAsync(IAttackable target)
{
await target.AttackByAsync(this, null, false).ConfigureAwait(false);
var hitInfo = await target.AttackByAsync(this, null, false).ConfigureAwait(false);

await this.ForEachWorldObserverAsync<IShowAnimationPlugIn>(p => p.ShowMonsterAttackAnimationAsync(this, target, this.GetDirectionTo(target)), true).ConfigureAwait(false);
if (this.Definition.AttackSkill is { } attackSkill)
{
await target.TryApplyElementalEffectsAsync(this, attackSkill, this._skillPowerUp, this._skillPowerUpDuration, this._skillPowerUpTarget).ConfigureAwait(false);
await target.TryApplyElementalEffectsAsync(this, attackSkill, this._skillPowerUp, this._skillPowerUpDuration, this._skillPowerUpTarget, hitInfo).ConfigureAwait(false);

await this.ForEachWorldObserverAsync<IShowSkillAnimationPlugIn>(p => p.ShowSkillAnimationAsync(this, target, attackSkill, true), true).ConfigureAwait(false);
}
Expand Down Expand Up @@ -226,6 +226,12 @@ public override ValueTask ApplyPoisonDamageAsync(IAttacker initialAttacker, uint
return this.HitAsync(new HitInfo(damage, 0, DamageAttributes.Poison), initialAttacker, null);
}

/// <inheritdoc />
public override ValueTask ApplyBleedingDamageAsync(IAttacker initialAttacker, uint damage)
{
return this.HitAsync(new HitInfo(damage, 0, DamageAttributes.Undefined), initialAttacker, null);
}

/// <inheritdoc/>
public ValueTask MoveAsync(Point target)
{
Expand Down
7 changes: 7 additions & 0 deletions src/GameLogic/NPC/SoccerBall.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,13 @@ public ValueTask ApplyPoisonDamageAsync(IAttacker initialAttacker, uint damage)
return ValueTask.CompletedTask;
}

/// <inheritdoc />
public ValueTask ApplyBleedingDamageAsync(IAttacker initialAttacker, uint damage)
{
// A ball doesn't take any damage
return ValueTask.CompletedTask;
}

/// <inheritdoc />
public ValueTask KillInstantlyAsync()
{
Expand Down
Loading