baseline: OpenMU upstream b5a0961 (fresh source)

This commit is contained in:
Acentech Dev
2026-07-14 19:00:35 +03:00
parent 450402e47d
commit 36fc125d5c
11968 changed files with 748705 additions and 0 deletions

View File

@@ -0,0 +1,398 @@
// <copyright file="AreaSkillAttackAction.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlayerActions.Skills;
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.GameLogic.Views.World;
using MUnique.OpenMU.Pathfinding;
/// <summary>
/// Action to attack with a skill which inflicts damage to an area of the current map of the player.
/// </summary>
public class AreaSkillAttackAction
{
private const int UndefinedTarget = 0xFFFF;
private const short ElectricSpikeSkillId = 65;
private static readonly ConcurrentDictionary<AreaSkillSettings, FrustumBasedTargetFilter> FrustumFilters = new();
/// <summary>
/// Performs the skill by the player at the specified area. Additionally, to the target area, a target object can be specified.
/// </summary>
/// <param name="player">The player.</param>
/// <param name="extraTargetId">The extra target identifier.</param>
/// <param name="skillId">The skill identifier.</param>
/// <param name="targetAreaCenter">The coordinates of the center of the target area.</param>
/// <param name="rotation">The rotation in which the player is looking. It's not really relevant for the hitted objects yet, but for some directed skills in the future it might be.</param>
/// <param name="hitImplicitlyForExplicitSkill">If set to <c>true</c>, hit implicitly for <see cref="SkillType.AreaSkillExplicitHits"/>.</param>
public async ValueTask AttackAsync(Player player, ushort extraTargetId, ushort skillId, Point targetAreaCenter, byte rotation, bool hitImplicitlyForExplicitSkill = false)
{
var skillEntry = player.SkillList?.GetSkill(skillId);
var skill = skillEntry?.Skill;
if (skill is null || skill.SkillType == SkillType.PassiveBoost)
{
return;
}
if (skill.SkillType != SkillType.Buff && skill.SkillType != SkillType.Regeneration)
{
if (player.GameContext.PlugInManager.GetPlugInPoint<ISpeedHackCheatCheckPlugIn>() is { } speedCheck)
{
var eventArgs = new SpeedHackCheckEventArgs();
await speedCheck.AttackCheatCheckAsync(player, eventArgs).ConfigureAwait(false);
if (eventArgs.IsCheatDetected)
{
return;
}
}
}
if (!await player.TryConsumeForSkillAsync(skill).ConfigureAwait(false))
{
return;
}
if (skill.SkillType is SkillType.AreaSkillAutomaticHits or SkillType.AreaSkillExplicitTarget or SkillType.Buff
|| (skill.SkillType is SkillType.AreaSkillExplicitHits && hitImplicitlyForExplicitSkill))
{
// todo: delayed automatic hits, like evil spirit, flame, triple shot... when hitImplicitlyForExplicitSkill = true.
await this.PerformAutomaticHitsAsync(player, extraTargetId, targetAreaCenter, skillEntry!, skill, rotation).ConfigureAwait(false);
}
await player.ForEachWorldObserverAsync<IShowAreaSkillAnimationPlugIn>(p => p.ShowAreaSkillAnimationAsync(player, skill, targetAreaCenter, rotation), true).ConfigureAwait(false);
}
private static bool AreaSkillSettingsAreDefault([NotNullWhen(true)] AreaSkillSettings? settings)
{
if (settings is null)
{
return true;
}
return !settings.UseDeferredHits
&& settings.DelayPerOneDistance <= TimeSpan.Zero
&& settings.MinimumNumberOfHitsPerTarget == 1
&& settings.MaximumNumberOfHitsPerTarget == 1
&& settings.MinimumNumberOfHitsPerAttack == 0
&& settings.MaximumNumberOfHitsPerAttack == 0
&& Math.Abs(settings.HitChancePerDistanceMultiplier - 1.0) <= 0.00001f;
}
private static IEnumerable<IAttackable> GetTargets(Player player, Point targetAreaCenter, Skill skill, byte rotation, ushort extraTargetId)
{
var isExtraTargetDefined = extraTargetId != UndefinedTarget;
var extraTarget = isExtraTargetDefined ? player.GetObject(extraTargetId) as IAttackable : null;
player.Logger.LogDebug(
"GetTargets: skill={Skill}, extraTargetId={ExtraTargetId}, extraTarget={ExtraTarget}", skill.Name, extraTargetId, extraTarget?.ToString() ?? "null");
if (skill.SkillType == SkillType.AreaSkillExplicitTarget)
{
if (extraTarget?.CheckSkillTargetRestrictions(player, skill) is true
&& player.IsInRange(extraTarget.Position, skill.Range + 2)
&& !extraTarget.IsAtSafezone())
{
yield return extraTarget;
}
yield break;
}
// Include the explicit extra target if it's valid and in range
if (extraTarget is not null
&& extraTarget.CheckSkillTargetRestrictions(player, skill)
&& player.IsInRange(extraTarget.Position, skill.Range + 2)
&& !extraTarget.IsAtSafezone())
{
yield return extraTarget;
}
foreach (var target in GetTargetsInRange(player, targetAreaCenter, skill, rotation))
{
// Skip the extra target if we already yielded it
if (target.Id == extraTargetId)
{
continue;
}
yield return target;
}
}
private static IEnumerable<IAttackable> GetTargetsInRange(Player player, Point targetAreaCenter, Skill skill, byte rotation)
{
var range = skill.AreaSkillSettings?.EffectRange > 0 ? skill.AreaSkillSettings.EffectRange : skill.Range;
var targetsInRange = player.CurrentMap?
.GetAttackablesInRange(targetAreaCenter, range)
.Where(a => a != player)
.Where(a => !a.IsAtSafezone())
?? [];
if (skill.AreaSkillSettings is { UseFrustumFilter: true } areaSkillSettings)
{
var filter = FrustumFilters.GetOrAdd(areaSkillSettings, static s => new FrustumBasedTargetFilter(s.FrustumStartWidth, s.FrustumEndWidth, s.FrustumDistance, s.ProjectileCount > 0 ? s.ProjectileCount : 1));
targetsInRange = targetsInRange.Where(a => filter.IsTargetWithinBounds(player, a, rotation));
}
if (skill.AreaSkillSettings is { UseTargetAreaFilter: true })
{
targetsInRange = targetsInRange.Where(a => a.GetDistanceTo(targetAreaCenter) < skill.AreaSkillSettings.TargetAreaDiameter * 0.5f);
}
if (!player.GameContext.Configuration.AreaSkillHitsPlayer)
{
targetsInRange = targetsInRange.Where(a => a is not Player);
}
// Exclude summoned monsters from implicit area attacks
targetsInRange = targetsInRange.Where(target => target is not Monster { SummonedBy: not null });
targetsInRange = targetsInRange.Where(target => target.CheckSkillTargetRestrictions(player, skill));
return targetsInRange;
}
private async ValueTask PerformAutomaticHitsAsync(Player player, ushort extraTargetId, Point targetAreaCenter, SkillEntry skillEntry, Skill skill, byte rotation)
{
if (player.Attributes is not { } attributes)
{
return;
}
if (attributes[Stats.IsStunned] > 0)
{
player.Logger.LogWarning("Probably Hacker - player {player} is attacking in stunned state", player);
return;
}
if (attributes[Stats.IsAsleep] > 0)
{
player.Logger.LogWarning("Probably Hacker - player {player} is attacking in sleep state", player);
return;
}
if (player.IsAtSafezone())
{
player.Logger.LogWarning("Probably Hacker - player {player} is attacking from safezone", player);
return;
}
// Skills that move attacker to target (e.g., Twisting Slash, Death Stab) require a weapon
if (skill.MovesToTarget && player.Attributes[Stats.EquippedWeaponCount] == 0)
{
return;
}
if (player.Attributes[Stats.AmmunitionConsumptionRate] > player.Attributes[Stats.AmmunitionAmount])
{
return;
}
bool isCombo = false;
if (player.ComboState is { } comboState)
{
isCombo = await comboState.RegisterSkillAsync(skill).ConfigureAwait(false);
}
IAttackable? extraTarget = null;
var targets = GetTargets(player, targetAreaCenter, skill, rotation, extraTargetId);
if (skill.AreaSkillSettings is not { } areaSkillSettings
|| AreaSkillSettingsAreDefault(areaSkillSettings))
{
// Just hit all targets once.
foreach (var target in targets)
{
if (target.Id == extraTargetId)
{
extraTarget = target;
}
await this.ApplySkillAsync(player, skillEntry, target, targetAreaCenter, isCombo).ConfigureAwait(false);
}
}
else
{
extraTarget = await this.AttackTargetsAsync(player, extraTargetId, targetAreaCenter, skillEntry, areaSkillSettings, targets, rotation, isCombo).ConfigureAwait(false);
}
if (isCombo)
{
await player.ForEachWorldObserverAsync<IShowSkillAnimationPlugIn>(p => p.ShowComboAnimationAsync(player, extraTarget), true).ConfigureAwait(false);
}
}
private async Task<IAttackable?> AttackTargetsAsync(Player player, ushort extraTargetId, Point targetAreaCenter, SkillEntry skillEntry, AreaSkillSettings areaSkillSettings, IEnumerable<IAttackable> targets, byte rotation, bool isCombo)
{
IAttackable? extraTarget = null;
var attackCount = 0;
var maxAttacks = areaSkillSettings.MaximumNumberOfHitsPerAttack == 0 ? int.MaxValue : areaSkillSettings.MaximumNumberOfHitsPerAttack;
var minAttacks = areaSkillSettings.MinimumNumberOfHitsPerAttack == 0 ? maxAttacks : areaSkillSettings.MinimumNumberOfHitsPerAttack;
var currentDelay = TimeSpan.Zero;
// Order targets by distance to process nearest targets first
var orderedTargets = targets.ToList();
FrustumBasedTargetFilter? filter = null;
var projectileCount = 1;
var attackRounds = areaSkillSettings.MaximumNumberOfHitsPerTarget;
if (areaSkillSettings is { UseFrustumFilter: true, ProjectileCount: > 1 })
{
orderedTargets.Sort((a, b) => player.GetDistanceTo(a).CompareTo(player.GetDistanceTo(b)));
filter = FrustumFilters.GetOrAdd(areaSkillSettings, static s => new FrustumBasedTargetFilter(s.FrustumStartWidth, s.FrustumEndWidth, s.FrustumDistance, s.ProjectileCount));
projectileCount = areaSkillSettings.ProjectileCount;
attackRounds = 1; // One attack round per projectile
extraTarget = orderedTargets.FirstOrDefault(t => t.Id == extraTargetId);
if (extraTarget is not null)
{
// In this case we just calculate the angle on server side, so that lags
// or desynced positions may not have such a big impact
var angle = (float)player.Position.GetAngleDegreeTo(extraTarget.Position);
rotation = (byte)((angle / 360.0f) * 256.0f);
}
}
// Process each projectile separately
for (int projectileIndex = 0; projectileIndex < projectileCount; projectileIndex++)
{
if (attackCount >= maxAttacks)
{
break;
}
for (int attackRound = 0; attackRound < attackRounds; attackRound++)
{
if (attackCount >= maxAttacks)
{
break;
}
foreach (var target in orderedTargets)
{
if (attackCount >= maxAttacks)
{
break;
}
if (target.Id == extraTargetId)
{
extraTarget = target;
}
// Skip targets that have died in previous rounds
if (!target.IsAlive)
{
continue;
}
// For multiple projectiles, check if this specific projectile can hit the target
if (filter != null && !filter.IsTargetWithinBounds(player, target, rotation, projectileIndex))
{
continue; // This projectile cannot hit this target
}
double hitChance;
if (attackRound >= areaSkillSettings.MinimumNumberOfHitsPerTarget)
{
hitChance = Math.Pow(areaSkillSettings.HitChancePerDistanceMultiplier, player.GetDistanceTo(target));
}
else if (attackCount >= minAttacks)
{
hitChance = 0.5;
}
else
{
hitChance = 1.0;
}
if (hitChance < 1.0 && !Rand.NextRandomBool(hitChance))
{
continue;
}
var distanceDelay = areaSkillSettings.DelayPerOneDistance * player.GetDistanceTo(target);
var attackDelay = currentDelay + distanceDelay;
attackCount++;
if (attackDelay == TimeSpan.Zero)
{
// Check if target is still alive and in valid state before attacking
if (!target.IsAtSafezone() && target.IsActive())
{
await this.ApplySkillAsync(player, skillEntry, target, targetAreaCenter, isCombo).ConfigureAwait(false);
}
}
else
{
// The most pragmatic approach is just spawning a Task for each hit.
// We have to see, how this works out in terms of performance.
_ = Task.Run(async () =>
{
await Task.Delay(attackDelay).ConfigureAwait(false);
if (!target.IsAtSafezone() && target.IsActive())
{
await this.ApplySkillAsync(player, skillEntry, target, targetAreaCenter, isCombo).ConfigureAwait(false);
}
});
}
}
currentDelay += areaSkillSettings.DelayBetweenHits;
}
}
if (skillEntry.Skill?.Number == ElectricSpikeSkillId && attackCount > 0 && player.Attributes![Stats.NearbyPartyMemberCount] > 0)
{
foreach (var partyMember in player.Party?.PartyList.OfType<Player>().Where(m => m.Observers.Contains(player)) ?? [])
{
if (partyMember.Attributes is { } memberAttributes)
{
memberAttributes[Stats.CurrentHealth] *= 0.8f;
memberAttributes[Stats.CurrentMana] *= 0.95f;
}
}
}
return extraTarget;
}
private async ValueTask ApplySkillAsync(Player player, SkillEntry skillEntry, IAttackable target, Point targetAreaCenter, bool isCombo)
{
skillEntry.ThrowNotInitializedProperty(skillEntry.Skill is null, nameof(skillEntry.Skill));
var skill = skillEntry.Skill;
if (skill.SkillType == SkillType.Buff)
{
await target.ApplyMagicEffectAsync(player, skillEntry).ConfigureAwait(false);
return;
}
var hitInfo = await target.AttackByAsync(player, skillEntry, isCombo, 1, skill.NumberOfHitsPerAttack > 1 ? false : null).ConfigureAwait(false);
await target.TryApplyElementalEffectsAsync(player, skillEntry, hitInfo).ConfigureAwait(false);
for (int hit = 2; hit <= skill.NumberOfHitsPerAttack; hit++)
{
await target.AttackByAsync(player, skillEntry, isCombo, 1, hit == skill.NumberOfHitsPerAttack).ConfigureAwait(false);
}
if (player.GameContext.PlugInManager.GetStrategy<short, IAreaSkillPlugIn>(skillEntry.Skill.Number) is { } strategy)
{
await strategy.AfterTargetGotAttackedAsync(player, target, skillEntry, targetAreaCenter, hitInfo).ConfigureAwait(false);
return;
}
var baseSkill = skillEntry.GetBaseSkill();
if (player.GameContext.PlugInManager.GetStrategy<short, IAreaSkillPlugIn>(baseSkill.Number) is { } baseSkillStrategy)
{
await baseSkillStrategy.AfterTargetGotAttackedAsync(player, target, skillEntry, targetAreaCenter, hitInfo).ConfigureAwait(false);
}
}
}

View File

@@ -0,0 +1,40 @@
// <copyright file="AreaSkillHitAction.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlayerActions.Skills;
/// <summary>
/// Action to hit targets with an area skill, which requires explicit hits <seealso cref="SkillType.AreaSkillExplicitHits"/>.
/// </summary>
public class AreaSkillHitAction
{
/// <summary>
/// Attacks the target by the player with the specified skill.
/// </summary>
/// <param name="player">The player who is performing the skill.</param>
/// <param name="target">The target.</param>
/// <param name="skill">The skill.</param>
public async ValueTask AttackTargetAsync(Player player, IAttackable target, SkillEntry skill)
{
if (skill.Skill?.SkillType != SkillType.AreaSkillExplicitHits
|| !target.IsAlive
|| target.IsAtSafezone()
|| (target is Player && !player.GameContext.Configuration.AreaSkillHitsPlayer))
{
return;
}
if (player.IsAtSafezone())
{
// It's possible, when the player did some area skill (Evil Spirit), and walked into the safezone.
// We don't log it as hacker attempt, since the AreaSkillAttackAction already does handle this.
}
if (target.CheckSkillTargetRestrictions(player, skill.Skill))
{
var hitInfo = await target.AttackByAsync(player, skill, false).ConfigureAwait(false);
await target.TryApplyElementalEffectsAsync(player, skill, hitInfo).ConfigureAwait(false);
}
}
}

View File

@@ -0,0 +1,91 @@
// <copyright file="ChainLightningSkillPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlayerActions.Skills;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.GuildWar;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.GameLogic.Views.World;
using MUnique.OpenMU.Pathfinding;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Handles the chain lightning skill of the summoner class. Additionally to the attacked target, it will hit up to two additional targets.
/// </summary>
[PlugIn]
[Display(Name = nameof(PlugInResources.ChainLightningSkillPlugIn_Name), Description = nameof(PlugInResources.ChainLightningSkillPlugIn_Description), ResourceType = typeof(PlugInResources))]
[Guid("298C5FF8-03A2-476B-B064-A59E73DFCEB9")]
public class ChainLightningSkillPlugIn : IAreaSkillPlugIn
{
/// <inheritdoc />
public short Key => 215;
/// <inheritdoc />
public async ValueTask AfterTargetGotAttackedAsync(IAttacker attacker, IAttackable target, SkillEntry skillEntry, Point targetAreaCenter, HitInfo? hitInfo)
{
bool FilterTarget(IAttackable attackable)
{
if (!attackable.IsAlive)
{
return false;
}
if (attackable is Monster { SummonedBy: null } or Destructible)
{
return true;
}
if (attackable is Monster { SummonedBy: not null } summoned)
{
return FilterTarget(summoned.SummonedBy);
}
if (attackable is Player { DuelRoom.State: DuelState.DuelStarted } targetPlayer
&& attacker is Player { DuelRoom.State: DuelState.DuelStarted } duelPlayer
&& targetPlayer.DuelRoom == duelPlayer.DuelRoom
&& targetPlayer.DuelRoom.IsDuelist(targetPlayer) && targetPlayer.DuelRoom.IsDuelist(duelPlayer))
{
return true;
}
if (attackable is Player { GuildWarContext.State: GuildWarState.Started } guildWarTarget
&& attacker is Player { GuildWarContext.State: GuildWarState.Started } guildWarAttacker
&& guildWarTarget.GuildWarContext == guildWarAttacker.GuildWarContext)
{
return true;
}
return false;
}
var secondTarget = target.CurrentMap?.GetAttackablesInRange(target.Position, 2).FirstOrDefault(FilterTarget) ?? target;
var thirdTarget = secondTarget.CurrentMap?.GetAttackablesInRange(secondTarget.Position, 2).Where(FilterTarget).FirstOrDefault(t => t != secondTarget && t != target) ?? secondTarget;
var observable = attacker as IObservable;
if (observable is null)
{
return;
}
await observable.ForEachWorldObserverAsync<IShowChainLightningPlugIn>(o => o.ShowLightningChainAnimationAsync(attacker, skillEntry.Skill!, [target, secondTarget, thirdTarget]), true).ConfigureAwait(false);
_ = Task.Run(async () =>
{
await Task.Delay(300).ConfigureAwait(false);
// first attack 70 %
var hit2Info = await secondTarget.AttackByAsync(attacker, skillEntry, false, 0.7).ConfigureAwait(false);
await secondTarget.TryApplyElementalEffectsAsync(attacker, skillEntry, hit2Info).ConfigureAwait(false);
await Task.Delay(300).ConfigureAwait(false);
// second attack 50%
var hit3Info = await thirdTarget.AttackByAsync(attacker, skillEntry, false, 0.5).ConfigureAwait(false);
await thirdTarget.TryApplyElementalEffectsAsync(attacker, skillEntry, hit3Info).ConfigureAwait(false);
});
}
}

View File

@@ -0,0 +1,94 @@
// <copyright file="DragonRoarSkillPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlayerActions.Skills;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.GuildWar;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.Pathfinding;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Handles the dragon roar skill of the rage fighter class. Additionally to the attacked target, it will hit up to seven additional targets.
/// </summary>
[PlugIn]
[Display(Name = nameof(PlugInResources.DragonRoarSkillPlugIn_Name), Description = nameof(PlugInResources.DragonRoarSkillPlugIn_Description), ResourceType = typeof(PlugInResources))]
[Guid("A797A6AD-AC92-4731-A0FB-D46D4C1DD0DF")]
public class DragonRoarSkillPlugIn : IAreaSkillPlugIn
{
/// <inheritdoc />
public virtual short Key => 264;
/// <summary>
/// Gets the range around the target, in which additional targets are searched.
/// </summary>
protected virtual short Range => 3;
/// <inheritdoc />
public async ValueTask AfterTargetGotAttackedAsync(IAttacker attacker, IAttackable target, SkillEntry skillEntry, Point targetAreaCenter, HitInfo? hitInfo)
{
bool FilterTarget(IAttackable attackable)
{
if (attackable == target)
{
return false;
}
if (attackable is Monster { SummonedBy: null } or Destructible)
{
return true;
}
if (attackable is Monster { SummonedBy: not null } summoned)
{
return FilterTarget(summoned.SummonedBy);
}
if (attackable is Player { DuelRoom.State: DuelState.DuelStarted } targetPlayer
&& attacker is Player { DuelRoom.State: DuelState.DuelStarted } duelPlayer
&& targetPlayer.DuelRoom == duelPlayer.DuelRoom
&& targetPlayer.DuelRoom.IsDuelist(targetPlayer) && targetPlayer.DuelRoom.IsDuelist(duelPlayer))
{
return true;
}
if (attackable is Player { GuildWarContext.State: GuildWarState.Started } guildWarTarget
&& attacker is Player { GuildWarContext.State: GuildWarState.Started } guildWarAttacker
&& guildWarTarget.GuildWarContext == guildWarAttacker.GuildWarContext)
{
return true;
}
return false;
}
var extraTargets = target.CurrentMap?.GetAttackablesInRange(target.Position, this.Range).Where(FilterTarget).Take(7);
if (extraTargets is null)
{
return;
}
int i = 1;
var skill = skillEntry.Skill!;
foreach (var extraTarget in extraTargets)
{
if (i <= 3 || Rand.NextRandomBool())
{
// first three 100% chance, others 50% chance
await extraTarget.AttackByAsync(attacker, skillEntry, false, 1, false).ConfigureAwait(false);
await extraTarget.TryApplyElementalEffectsAsync(attacker, skillEntry).ConfigureAwait(false);
for (int hit = 2; hit <= skill.NumberOfHitsPerAttack; hit++)
{
await extraTarget.AttackByAsync(attacker, skillEntry, false, 1, hit == skill.NumberOfHitsPerAttack).ConfigureAwait(false);
}
}
i++;
}
}
}

View File

@@ -0,0 +1,36 @@
// <copyright file="DrainLifeSkillPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlayerActions.Skills;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.Pathfinding;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Handles the drain life skill of the summoner class. Additionally to the attacked target, it regains life for damage dealt.
/// </summary>
[PlugIn]
[Display(Name = nameof(PlugInResources.DrainLifeSkillPlugIn_Name), Description = nameof(PlugInResources.DrainLifeSkillPlugIn_Description), ResourceType = typeof(PlugInResources))]
[Guid("9A5A5671-3A8C-4C01-984F-1A8F8E0E7BDA")]
public class DrainLifeSkillPlugIn : IAreaSkillPlugIn
{
/// <inheritdoc/>
public short Key => 214;
/// <inheritdoc/>
public async ValueTask AfterTargetGotAttackedAsync(IAttacker attacker, IAttackable target, SkillEntry skillEntry, Point targetAreaCenter, HitInfo? hitInfo)
{
if (attacker is not Player attackerPlayer
|| hitInfo is not { HealthDamage: > 0 }
|| attackerPlayer.Attributes is not { } playerAttributes)
{
return;
}
playerAttributes[Stats.CurrentHealth] = (uint)Math.Min(playerAttributes[Stats.MaximumHealth], playerAttributes[Stats.CurrentHealth] + hitInfo.Value.HealthDamage);
}
}

View File

@@ -0,0 +1,57 @@
// <copyright file="EarthShakeSkillPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlayerActions.Skills;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.Pathfinding;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Handles the earth shake skill of the dark horse. Pushes the targets away from the attacker.
/// </summary>
[Guid("5D00F012-B0A3-41D6-B2FD-66D37A81615C")]
[PlugIn]
[Display(Name = nameof(PlugInResources.EarthShakeSkillPlugIn_Name), Description = nameof(PlugInResources.EarthShakeSkillPlugIn_Description), ResourceType = typeof(PlugInResources))]
public class EarthShakeSkillPlugIn : IAreaSkillPlugIn
{
/// <inheritdoc />
public short Key => 62;
/// <inheritdoc />
public async ValueTask AfterTargetGotAttackedAsync(IAttacker attacker, IAttackable target, SkillEntry skillEntry, Point targetAreaCenter, HitInfo? hitInfo)
{
if (!target.IsAlive || target is not IMovable movableTarget || target.CurrentMap is not { } currentMap)
{
return;
}
skillEntry.ThrowNotInitializedProperty(skillEntry.Skill is null, nameof(skillEntry.Skill));
var startingPoint = attacker.Position;
var currentTarget = target.Position;
var direction = startingPoint.GetDirectionTo(currentTarget);
if (direction == Direction.Undefined)
{
direction = (Direction)Rand.NextInt(1, 9);
}
for (int i = 0; i < 3; i++)
{
var nextTarget = currentTarget.CalculateTargetPoint(direction);
if (!currentMap.Terrain.WalkMap[nextTarget.X, nextTarget.Y]
|| (target is NonPlayerCharacter && target.CurrentMap.Terrain.SafezoneMap[nextTarget.X, nextTarget.Y]))
{
// we don't want to push the target into a non-reachable area, through walls or monsters into the safe zone.
break;
}
currentTarget = nextTarget;
}
await movableTarget.MoveAsync(currentTarget).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,99 @@
// <copyright file="FireScreamSkillPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlayerActions.Skills;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.GuildWar;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.Pathfinding;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Handles the fire scream skill of the dark lord class. Based on a chance, it does an additional damage (explosion) to any targets in a radius which origin is the target itself.
/// </summary>
[PlugIn]
[Display(Name = nameof(PlugInResources.FireScreamSkillPlugIn_Name), Description = nameof(PlugInResources.FireScreamSkillPlugIn_Description), ResourceType = typeof(PlugInResources))]
[Guid("7E2F9B4A-D6C1-4F8E-A3B5-21E8C7F9D541")]
public class FireScreamSkillPlugIn : IAreaSkillPlugIn
{
/// <inheritdoc />
public short Key => 78;
private short Radius => 2;
/// <inheritdoc />
public async ValueTask AfterTargetGotAttackedAsync(IAttacker attacker, IAttackable target, SkillEntry skillEntry, Point targetAreaCenter, HitInfo? hitInfo)
{
if (hitInfo is not { } hit || !Rand.NextRandomBool(0.3))
{
return;
}
var attackDamage = hit.HealthDamage + hit.ShieldDamage;
var explosionDamage = attackDamage / 10;
if (explosionDamage < 1)
{
return;
}
bool FilterTarget(IAttackable attackable)
{
if (attackable == attacker)
{
return false;
}
if (attackable is Monster { SummonedBy: null } or Destructible)
{
return true;
}
if (attackable is Monster { SummonedBy: not null } summoned)
{
return FilterTarget(summoned.SummonedBy);
}
if (attackable is Player { DuelRoom.State: DuelState.DuelStarted } targetPlayer
&& attacker is Player { DuelRoom.State: DuelState.DuelStarted } duelPlayer
&& targetPlayer.DuelRoom == duelPlayer.DuelRoom
&& targetPlayer.DuelRoom.IsDuelist(targetPlayer) && targetPlayer.DuelRoom.IsDuelist(duelPlayer))
{
return true;
}
if (attackable is Player { GuildWarContext.State: GuildWarState.Started } guildWarTarget
&& attacker is Player { GuildWarContext.State: GuildWarState.Started } guildWarAttacker
&& guildWarTarget.GuildWarContext == guildWarAttacker.GuildWarContext)
{
return true;
}
return false;
}
var explosionTargets = target.CurrentMap?
.GetAttackablesInRange(target.Position, this.Radius)
.Where(a => a.GetDistanceTo(target) <= this.Radius)
.Where(FilterTarget) ?? [];
if (!explosionTargets.Any())
{
return;
}
// Delay the explosion a little bit, so the client can show the hit values staggered
await Task.Delay(100).ConfigureAwait(false);
foreach (var explosionTarget in explosionTargets)
{
if (explosionTarget.IsActive())
{
// We just need to apply the damage, so we can resort to the bleeding damage method which has DamageAttributes.Undefined
await explosionTarget.ApplyBleedingDamageAsync(attacker, explosionDamage).ConfigureAwait(false);
}
}
}
}

View File

@@ -0,0 +1,85 @@
// <copyright file="ForceSkillAction.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlayerActions.Skills;
using System.Runtime.InteropServices;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// The Force skill action.
/// </summary>
[PlugIn]
[Display(Name = nameof(PlugInResources.ForceSkillAction_Name), Description = nameof(PlugInResources.ForceSkillAction_Description), ResourceType = typeof(PlugInResources))]
[Guid("552e4e3d-8215-44f4-bee3-b006da049eb2")]
public class ForceSkillAction : TargetedSkillDefaultPlugin
{
/// <summary>
/// The skill id of the force wave skill.
/// </summary>
protected const ushort ForceWaveSkillId = 66;
private const ushort ForceWaveStrengSkillId = 509;
private static FrustumBasedTargetFilter? frustumFilter;
/// <inheritdoc/>
public override short Key => 60;
/// <inheritdoc/>
public override async ValueTask PerformSkillAsync(Player player, IAttackable target, ushort skillId)
{
// Special handling of force (wave) skill. The client might send skill id 60 (force),
// even though it's performing force wave.
if (skillId != ForceWaveStrengSkillId && player.SkillList?.ContainsSkill(ForceWaveSkillId) is true)
{
await base.PerformSkillAsync(player, target, ForceWaveSkillId).ConfigureAwait(false);
}
else
{
await base.PerformSkillAsync(player, target, skillId).ConfigureAwait(false);
}
}
/// <inheritdoc/>
protected override IEnumerable<IAttackable> DetermineTargets(Player player, IAttackable targetedTarget, Skill skill)
{
if (skill.Number != ForceWaveSkillId && skill.Number != ForceWaveStrengSkillId)
{
return targetedTarget.GetAsEnumerable();
}
var targetsInRange = player.CurrentMap?
.GetAttackablesInRange(player.Position, skill.Range + 4)
.Where(a => a != player)
.Where(a => !a.IsAtSafezone()).ToList()
?? [];
if (skill.AreaSkillSettings is { UseFrustumFilter: true } areaSkillSettings)
{
if (frustumFilter is null)
{
CreateFrustumFilter(areaSkillSettings);
}
var rotationToTarget = (byte)(player.Position.GetAngleDegreeTo(targetedTarget.Position) / 360.0 * 255.0);
targetsInRange = targetsInRange.Where(a => frustumFilter!.IsTargetWithinBounds(player, a, rotationToTarget)).ToList();
}
else
{
targetsInRange = [];
}
if (!targetsInRange.Contains(targetedTarget))
{
targetsInRange.Add(targetedTarget);
}
return targetsInRange;
}
private static void CreateFrustumFilter(AreaSkillSettings areaSkillSettings)
{
frustumFilter ??= new FrustumBasedTargetFilter(areaSkillSettings.FrustumStartWidth, areaSkillSettings.FrustumEndWidth, areaSkillSettings.FrustumDistance);
}
}

View File

@@ -0,0 +1,34 @@
// <copyright file="ForceWaveStrengSkillAction.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlayerActions.Skills;
using System.Runtime.InteropServices;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// The Force Wave Strengthener skill action.
/// </summary>
[PlugIn]
[Display(Name = nameof(PlugInResources.ForceWaveStrengSkillAction_Name), Description = nameof(PlugInResources.ForceWaveStrengSkillAction_Description), ResourceType = typeof(PlugInResources))]
[Guid("9072ce9c-1482-4838-ba0a-4c312062e090")]
public class ForceWaveStrengSkillAction : ForceSkillAction
{
/// <inheritdoc/>
public override short Key => 509;
/// <inheritdoc/>
public override async ValueTask PerformSkillAsync(Player player, IAttackable target, ushort skillId)
{
// Originally, force wave strengthener could be used on top of force or force wave skills.
// In OpenMU, you can only use it with force wave (which means a scepter with skill must be equipped).
// This simplifies code at the expense of not allowing usage of FWS with any weapon (or fists).
// Since a Lord Emperor will likely be wearing a skilled scepter as a weapon, this is acceptable.
// It also makes the skill name more accurate :-).
if (player.SkillList?.ContainsSkill(ForceWaveSkillId) is true)
{
await base.PerformSkillAsync(player, target, skillId).ConfigureAwait(false);
}
}
}

View File

@@ -0,0 +1,267 @@
// <copyright file="FrustumBasedTargetFilter.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlayerActions.Skills;
using System.Numerics;
using MUnique.OpenMU.Pathfinding;
/// <summary>
/// A target filter which will be executed when an area skill is about to hit its targets.
/// It allows to filter out targets which are out of range.
/// </summary>
public record FrustumBasedTargetFilter
{
private const double DistanceEpsilon = 0.001;
private readonly Vector2[][] _rotationVectors;
/// <summary>
/// Initializes a new instance of the <see cref="FrustumBasedTargetFilter"/> class.
/// </summary>
/// <param name="startWidth">The width of the frustum at the start.</param>
/// <param name="endWidth">The width of the frustum at the end.</param>
/// <param name="distance">The distance.</param>
/// <param name="projectileCount">The number of projectiles. Default is 1.</param>
public FrustumBasedTargetFilter(float startWidth, float endWidth, float distance, int projectileCount = 1)
{
this.EndWidth = endWidth;
this.Distance = distance;
this.StartWidth = startWidth;
this.ProjectileCount = projectileCount;
this._rotationVectors = this.CalculateRotationVectors();
}
/// <summary>
/// Gets the end width.
/// </summary>
public float EndWidth { get; }
/// <summary>
/// Gets the distance.
/// </summary>
public float Distance { get; }
/// <summary>
/// Gets the start width.
/// </summary>
public float StartWidth { get; }
/// <summary>
/// Gets the number of projectiles/arrows.
/// </summary>
public int ProjectileCount { get; }
/// <summary>
/// Determines whether the target is within the hit bounds.
/// </summary>
/// <param name="attacker">The attacker.</param>
/// <param name="target">The target.</param>
/// <param name="rotation">The rotation.</param>
/// <returns><c>true</c> if the target is within hit bounds; otherwise, <c>false</c>.</returns>
public bool IsTargetWithinBounds(ILocateable attacker, ILocateable target, byte rotation)
{
if (attacker.Position == target.Position)
{
return true;
}
var frustum = this.GetFrustum(attacker.Position, rotation);
return IsWithinFrustum(frustum, target.Position);
}
/// <summary>
/// Determines whether the target is within the hit bounds for a specific projectile.
/// When multiple projectiles are used, they are evenly distributed within the frustum.
/// </summary>
/// <param name="attacker">The attacker.</param>
/// <param name="target">The target.</param>
/// <param name="rotation">The rotation.</param>
/// <param name="projectileIndex">The zero-based index of the projectile (0 to ProjectileCount-1).</param>
/// <returns><c>true</c> if the target is within hit bounds for the specified projectile; otherwise, <c>false</c>.</returns>
public bool IsTargetWithinBounds(ILocateable attacker, ILocateable target, byte rotation, int projectileIndex)
{
if (this.ProjectileCount <= 1)
{
// For single projectile, use the simple frustum check
return this.IsTargetWithinBounds(attacker, target, rotation);
}
if (projectileIndex < 0 || projectileIndex >= this.ProjectileCount)
{
return false;
}
// First check if target is within the overall frustum
if (!this.IsTargetWithinBounds(attacker, target, rotation))
{
return false;
}
// Calculate the relative position of the target within the frustum
var relativePosition = this.CalculateRelativePositionInFrustum(attacker.Position, target.Position, rotation);
// Divide the frustum into sections (-1 to 1 range)
// For 3 projectiles: left (-1 to -0.33), center (-0.33 to 0.33), right (0.33 to 1)
var sectionWidth = 2.0 / this.ProjectileCount;
var sectionStart = -1.0 + (projectileIndex * sectionWidth);
var sectionEnd = sectionStart + sectionWidth;
// Add overlap so targets near boundaries can be hit by adjacent projectiles
var overlap = this.GetOverlap(attacker.Position, target.Position);
sectionStart -= overlap;
sectionEnd += overlap;
return relativePosition >= sectionStart && relativePosition <= sectionEnd;
}
private double GetOverlap(Point attackerPos, Point targetPos)
{
var distance = attackerPos.EuclideanDistanceTo(targetPos);
if (distance == 0)
{
return 1;
}
// The overlap decreases over higher distance
var overlap = (1.0 / Math.Floor(distance)) / this.ProjectileCount;
overlap += 0.001; // Adding a small epsilon to make it slightly more tolerant in the comparisons
return overlap;
}
private double CalculateRelativePositionInFrustum(Point attackerPos, Point targetPos, byte rotation)
{
// Calculate the vector from attacker to target
var dx = targetPos.X - attackerPos.X;
var dy = targetPos.Y - attackerPos.Y;
// Calculate the rotation angle (same logic as in CalculateRotationVectors)
var rotationAngle = (rotation * 360.0 / 256.0) + 180; // Add 180 offset as in the frustum calculation
var rotationRad = rotationAngle * Math.PI / 180.0;
// Rotate the vector to align with the frustum's coordinate system
// The frustum has Y pointing forward and X pointing right
var cos = Math.Cos(-rotationRad);
var sin = Math.Sin(-rotationRad);
var rotatedX = (dx * cos) - (dy * sin);
var rotatedY = (dx * sin) + (dy * cos);
// If target is behind us or too close, return 0
if (rotatedY <= 0)
{
return 0;
}
// Calculate the frustum width at the target's distance
// Linear interpolation between start and end width
var distanceRatio = Math.Min(rotatedY / this.Distance, 1.0);
var frustumWidthAtDistance = this.StartWidth + ((this.EndWidth - this.StartWidth) * distanceRatio);
// Normalize the X position by the frustum width at that distance
// Result will be in range [-1, 1] where -1 is left edge, 0 is center, 1 is right edge
if (Math.Abs(frustumWidthAtDistance) < DistanceEpsilon)
{
return 0;
}
var normalizedX = rotatedX / frustumWidthAtDistance;
return Math.Clamp(normalizedX, -1.0, 1.0);
}
private static bool IsWithinFrustum((Vector4 X, Vector4 Y) frustum, Point target)
{
var isOutOfRange = (((frustum.X.X - target.X) * (frustum.Y.W - target.Y)) - ((frustum.X.W - target.X) * (frustum.Y.X - target.Y))) < 0.0f
|| (((frustum.X.Y - target.X) * (frustum.Y.X - target.Y)) - ((frustum.X.X - target.X) * (frustum.Y.Y - target.Y))) < 0.0f
|| (((frustum.X.Z - target.X) * (frustum.Y.Y - target.Y)) - ((frustum.X.Y - target.X) * (frustum.Y.Z - target.Y))) < 0.0f
|| (((frustum.X.W - target.X) * (frustum.Y.Z - target.Y)) - ((frustum.X.Z - target.X) * (frustum.Y.W - target.Y))) < 0.0f;
return !isOutOfRange;
}
private static Vector2 VectorRotate(Vector3 angleVector, Matrix4x4 angleMatrix)
{
return new Vector2(
Vector3.Dot(angleVector, new Vector3(angleMatrix.M11, angleMatrix.M12, angleMatrix.M13)),
Vector3.Dot(angleVector, new Vector3(angleMatrix.M21, angleMatrix.M22, angleMatrix.M23)));
}
private static Matrix4x4 CreateAngleMatrix(double angleInDegrees)
{
var radian = angleInDegrees * (Math.PI / 180);
var sy = (float)Math.Sin(radian);
var cy = (float)Math.Cos(radian);
var sp = 0.0f;
var cp = 1.0f;
var sr = 0.0f;
var cr = 1.0f;
Matrix4x4 matrix = default;
matrix.M11 = cp * cy;
matrix.M21 = cp * sy;
matrix.M31 = -sp;
matrix.M12 = (sr * sp * cy) + (cr * -sy);
matrix.M22 = (sr * sp * sy) + (cr * cy);
matrix.M32 = sr * cp;
matrix.M13 = (cr * sp * cy) + (-sr * -sy);
matrix.M23 = (cr * sp * sy) + (-sr * cy);
matrix.M33 = cr * cp;
return matrix;
}
private Vector2[][] CalculateRotationVectors()
{
const int degreeOffset = 180;
const float distanceOffset = 0.99f; // we always start in front of the characters coordinates.
var result = new Vector2[byte.MaxValue + 1][];
var temp = new Vector3[4];
temp[0] = new Vector3(-this.EndWidth, this.Distance, 0);
temp[1] = new Vector3(this.EndWidth, this.Distance, 0);
temp[2] = new Vector3(this.StartWidth, distanceOffset, 0);
temp[3] = new Vector3(-this.StartWidth, distanceOffset, 0);
for (int rotation = 0; rotation <= byte.MaxValue; rotation++)
{
var degrees = (rotation * 360.0) / byte.MaxValue;
degrees = (degrees + degreeOffset) % 360;
var angleMatrix = CreateAngleMatrix(degrees);
var vectorsOfAngle = new Vector2[4];
for (int i = 0; i < temp.Length; i++)
{
vectorsOfAngle[i] = VectorRotate(temp[i], angleMatrix);
}
result[rotation] = vectorsOfAngle;
}
return result;
}
private (Vector4 X, Vector4 Y) GetFrustum(Point attackerPosition, byte rotation)
{
var rotationVectors = this._rotationVectors[rotation];
Vector4 resultX = default;
Vector4 resultY = default;
resultX.X = (int)rotationVectors[0].X + attackerPosition.X;
resultY.X = (int)rotationVectors[0].Y + attackerPosition.Y;
resultX.Y = (int)rotationVectors[1].X + attackerPosition.X;
resultY.Y = (int)rotationVectors[1].Y + attackerPosition.Y;
resultX.Z = (int)rotationVectors[2].X + attackerPosition.X;
resultY.Z = (int)rotationVectors[2].Y + attackerPosition.Y;
resultX.W = (int)rotationVectors[3].X + attackerPosition.X;
resultY.W = (int)rotationVectors[3].Y + attackerPosition.Y;
return (resultX, resultY);
}
}

View File

@@ -0,0 +1,191 @@
// <copyright file="NovaSkillStartPlugin.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlayerActions.Skills;
using System.Runtime.InteropServices;
using System.Threading;
using MUnique.OpenMU.AttributeSystem;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.Views.World;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// The start nova skill action.
/// </summary>
[PlugIn]
[Display(Name = nameof(PlugInResources.NovaSkillStartPlugin_Name), Description = nameof(PlugInResources.NovaSkillStartPlugin_Description), ResourceType = typeof(PlugInResources))]
[Guid("e966e7eb-58b8-4356-8725-5da9f43c1fa4")]
public class NovaSkillStartPlugin : TargetedSkillPluginBase
{
private const ushort NovaEndSkillId = 40;
private static readonly TimeSpan NovaStepDelay = TimeSpan.FromMilliseconds(500);
/// <summary>
/// The nova damage per stage, which is still hardcoded. May be configurable later.
/// </summary>
private static readonly int[] NovaDamageTable = { 0, 20, 50, 99, 160, 225, 325, 425, 550, 700, 880, 1090, 1320 };
/// <inheritdoc/>
public override short Key => 58;
/// <inheritdoc />
public override async ValueTask PerformSkillAsync(Player player, IAttackable target, ushort skillId)
{
if (player.SkillCancelTokenSource is not null)
{
// A nova is already ongoing.
return;
}
var skillEntry = player.SkillList?.GetSkill(NovaEndSkillId);
if (skillEntry?.Skill is null)
{
return;
}
// Consume full ability points first...
var novaStart = player.GameContext.Configuration.Skills.First(s => s.Number == skillId);
if (!await player.TryConsumeForSkillAsync(novaStart).ConfigureAwait(false))
{
return;
}
await player.ForEachWorldObserverAsync<IShowSkillAnimationPlugIn>(p => p.ShowNovaStartAsync(player), true).ConfigureAwait(false);
var cancellationTokenSource = new SkillCancellationTokenSource();
player.SkillCancelTokenSource = cancellationTokenSource;
_ = Task.Run(() => this.RunNovaAsync(player, skillEntry, cancellationTokenSource));
}
private async ValueTask RunNovaAsync(Player player, SkillEntry skillEntry, SkillCancellationTokenSource cancellationTokenSource)
{
var cancellationToken = cancellationTokenSource.Token;
if (player.Attributes is not { } playerAttributes
|| skillEntry.Skill is not { } skill)
{
return;
}
byte completedSteps = 0;
var stepDamageElement = new SimpleElement(0, AggregateType.AddRaw);
playerAttributes.AddElement(stepDamageElement, Stats.NovaStageDamage);
try
{
try
{
while (completedSteps < NovaDamageTable.Length - 1)
{
cancellationToken.ThrowIfCancellationRequested();
if (!await player.TryConsumeForSkillAsync(skill).ConfigureAwait(false))
{
break;
}
completedSteps++;
stepDamageElement.Value = NovaDamageTable[completedSteps];
var steps = completedSteps;
await player.ForEachWorldObserverAsync<IShowSkillStageUpdatePlugIn>(p => p.UpdateSkillStageAsync(player, this.Key, steps), true).ConfigureAwait(false);
await Task.Delay(NovaStepDelay, cancellationToken).ConfigureAwait(false); // Hint: Player could cancel the nova 500 ms before end without damage loss - if he is good
}
}
catch (OperationCanceledException)
{
// This is expected when the player stopped nova before.
}
await this.AttackTargetsAsync(player, skillEntry, cancellationTokenSource).ConfigureAwait(false);
}
catch (Exception ex)
{
player.Logger.LogError(ex, "Unexpected error during performing nova skill");
player.SkillCancelTokenSource?.Dispose();
player.SkillCancelTokenSource = null;
}
finally
{
player.Attributes?.RemoveElement(stepDamageElement, Stats.NovaStageDamage);
}
}
private async ValueTask AttackTargetsAsync(Player player, SkillEntry skillEntry, SkillCancellationTokenSource cancellationTokenSource)
{
if (!player.IsAlive || player.IsAtSafezone() || skillEntry.Skill is not { } skill)
{
return;
}
await player.ForEachWorldObserverAsync<IShowSkillAnimationPlugIn>(p => p.ShowSkillAnimationAsync(player, player, skill.Number, true), true).ConfigureAwait(false);
var explicitTargetId = cancellationTokenSource.ExplicitTargetId;
var targets = this.DetermineTargets(player, skill, explicitTargetId);
// Set cancellation token source to null, so that the next nova can be started.
player.SkillCancelTokenSource?.Dispose();
player.SkillCancelTokenSource = null;
await Task.Delay(500, CancellationToken.None).ConfigureAwait(false);
foreach (var target in targets)
{
await target.AttackByAsync(player, skillEntry, false).ConfigureAwait(false);
}
}
private IEnumerable<IAttackable> DetermineTargets(Player player, Skill skill, ushort? explicitTargetId)
{
bool FilterPlayer(Player attackable)
{
if (attackable == player)
{
// Don't attack yourself
return false;
}
if (attackable.Id == explicitTargetId)
{
return true;
}
if (player.GuildWarContext is { } attackerContext
&& attackable.GuildWarContext is { } defenderContext
&& attackerContext.Team != defenderContext.Team
&& attackerContext.Score == defenderContext.Score)
{
return true;
}
// todo: during castle siege, nova attacks everyone
// todo: during duel, it always attacks the opponent
return false;
}
bool FilterMonster(Monster monster)
{
if (monster.Id == explicitTargetId)
{
return true;
}
if (monster.SummonedBy is not { } summonedBy)
{
// attack all monsters which are not summoned by a player.
return true;
}
return FilterPlayer(summonedBy);
}
var targets = player.CurrentMap!
.GetAttackablesInRange(player.Position, skill.Range)
.Where(a => a.IsAlive)
.Where(a => a is not Monster || FilterMonster((Monster)a))
.Where(a => a is not Player || FilterPlayer((Player)a));
return targets;
}
}

View File

@@ -0,0 +1,26 @@
// <copyright file="NovaSkillStopPlugin.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlayerActions.Skills;
using System.Runtime.InteropServices;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// The stop nova skill action.
/// </summary>
[PlugIn]
[Display(Name = nameof(PlugInResources.NovaSkillStopPlugin_Name), Description = nameof(PlugInResources.NovaSkillStopPlugin_Description), ResourceType = typeof(PlugInResources))]
[Guid("3cb98892-b3ce-42de-8956-5ed5625c6285")]
public class NovaSkillStopPlugin : TargetedSkillPluginBase
{
/// <inheritdoc/>
public override short Key => 40;
/// <inheritdoc />
public override async ValueTask PerformSkillAsync(Player player, IAttackable target, ushort skillId)
{
player.SkillCancelTokenSource?.CancelWithExtraTarget(target.Id);
}
}

View File

@@ -0,0 +1,23 @@
// <copyright file="PhoenixShotSkillPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlayerActions.Skills;
using System.Runtime.InteropServices;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Handles the phoenix shot weapon skill of the rage fighter class. Additionally to the attacked target, it will hit up to seven additional targets.
/// </summary>
[PlugIn]
[Display(Name = nameof(PlugInResources.PhoenixShotSkillPlugIn_Name), Description = nameof(PlugInResources.PhoenixShotSkillPlugIn_Description), ResourceType = typeof(PlugInResources))]
[Guid("2C78E3DB-DDC7-4BC6-8539-707F81638ABF")]
public class PhoenixShotSkillPlugIn : DragonRoarSkillPlugIn
{
/// <inheritdoc />
public override short Key => 270;
/// <inheritdoc />
protected override short Range => 2;
}

View File

@@ -0,0 +1,35 @@
// <copyright file="PlasmaStormSkillPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlayerActions.Skills;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.GameLogic.Views.Inventory;
using MUnique.OpenMU.Pathfinding;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Handles the plasma storm skill of the fenrir pet. It randomly halves the durability of a target's equipped item.
/// </summary>
[PlugIn]
[Display(Name = nameof(PlugInResources.PlasmaStormSkillPlugIn_Name), Description = nameof(PlugInResources.PlasmaStormSkillPlugIn_Description), ResourceType = typeof(PlugInResources))]
[Guid("5EF7C564-B32B-4630-9380-0233BECFA663")]
public class PlasmaStormSkillPlugIn : IAreaSkillPlugIn
{
/// <inheritdoc />
public short Key => 76;
/// <inheritdoc />
public async ValueTask AfterTargetGotAttackedAsync(IAttacker attacker, IAttackable target, SkillEntry skillEntry, Point targetAreaCenter, HitInfo? hitInfo)
{
if (target is Player targetPlayer
&& Rand.NextRandomBool(25)
&& targetPlayer.Inventory?.EquippedItems.SelectRandom() is { } randomItem)
{
randomItem.Durability /= 2;
await targetPlayer.InvokeViewPlugInAsync<IItemDurabilityChangedPlugIn>(p => p.ItemDurabilityChangedAsync(randomItem, false)).ConfigureAwait(false);
}
}
}

View File

@@ -0,0 +1,59 @@
// <copyright file="PollutionSkillPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlayerActions.Skills;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.Pathfinding;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Handles the pollution skill (book of lagle) of the summoner class. Based on a chance, it may push the targets 2 squares away from the attacker.
/// </summary>
[PlugIn]
[Display(Name = nameof(PlugInResources.PollutionSkillPlugIn_Name), Description = nameof(PlugInResources.PollutionSkillPlugIn_Description), ResourceType = typeof(PlugInResources))]
[Guid("97BE0BFD-C55C-4E68-9B2D-12156825481A")]
public class PollutionSkillPlugIn : IAreaSkillPlugIn
{
/// <inheritdoc />
public short Key => 225;
/// <inheritdoc />
public async ValueTask AfterTargetGotAttackedAsync(IAttacker attacker, IAttackable target, SkillEntry skillEntry, Point targetAreaCenter, HitInfo? hitInfo)
{
if (!target.IsAlive
|| target is not IMovable movableTarget
|| target.CurrentMap is not { } currentMap
|| !Rand.NextRandomBool(attacker.Attributes[Stats.MasteryMoveTargetChance]))
{
return;
}
var startingPoint = attacker.Position;
var currentTarget = target.Position;
var direction = startingPoint.GetDirectionTo(currentTarget);
if (direction == Direction.Undefined)
{
direction = (Direction)Rand.NextInt(1, 9);
}
for (int i = 0; i < 2; i++)
{
var nextTarget = currentTarget.CalculateTargetPoint(direction);
if (!currentMap.Terrain.WalkMap[nextTarget.X, nextTarget.Y]
|| (target is NonPlayerCharacter && target.CurrentMap.Terrain.SafezoneMap[nextTarget.X, nextTarget.Y]))
{
// we don't want to push the target into a non-reachable area, through walls or monsters into the safe zone.
break;
}
currentTarget = nextTarget;
}
await movableTarget.MoveAsync(currentTarget).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,91 @@
// <copyright file="RageSkillAttackAction.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlayerActions.Skills;
using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.GameLogic.Views.World;
/// <summary>
/// Action for rage attacks.
/// </summary>
public class RageSkillAttackAction
{
private const int MaximumTargetsPerAttack = 5;
private const int HitsPerTarget = 2;
/// <summary>
/// Attacks the target with a rage skill.
/// </summary>
/// <param name="player">The player.</param>
/// <param name="explicitTargetId">The explicit target identifier.</param>
/// <param name="skillId">The skill identifier.</param>
public async ValueTask AttackAsync(Player player, ushort explicitTargetId, ushort skillId)
{
var explicitTarget = player.GetObject(explicitTargetId) as IAttackable;
if (player.SkillList is null || player.SkillList.GetSkill(skillId) is not { } skill
|| (explicitTarget is not null && !explicitTarget.IsInRange(player.Position, skill.Skill!.Range)))
{
return;
}
var targets = new List<IAttackable>(MaximumTargetsPerAttack);
if (explicitTarget is not null)
{
targets.Add(explicitTarget);
}
targets.AddRange(
player.CurrentMap!
.GetAttackablesInRange(player.Position, skill.Skill!.Range)
.Where(t => t != explicitTarget && t != player)
.Where(t => t is not Player)
.OrderBy(t => t.GetDistanceTo(player))
.Take(MaximumTargetsPerAttack - targets.Count));
await player.InvokeViewPlugInAsync<IShowRageAttackRangePlugIn>(p => p.ShowRageAttackRangeAsync(skillId, targets)).ConfigureAwait(false);
if (!targets.Any())
{
return;
}
if (explicitTarget is null)
{
await player.ForEachWorldObserverAsync<IShowRageAttackPlugIn>(p => p.ShowAttackAsync(player, targets.First(), skillId), true).ConfigureAwait(false);
}
bool isCombo = false;
if (player.ComboState is { } comboState)
{
isCombo = await comboState.RegisterSkillAsync(skill.Skill).ConfigureAwait(false);
if (isCombo)
{
await player.ForEachWorldObserverAsync<IShowSkillAnimationPlugIn>(p => p.ShowComboAnimationAsync(player, explicitTarget), true).ConfigureAwait(false);
}
}
_ = this.RunAttacksAsync(player, skill, targets, isCombo);
}
private async ValueTask RunAttacksAsync(Player player, SkillEntry skill, List<IAttackable> targets, bool isCombo)
{
try
{
for (int i = 0; i < HitsPerTarget; i++)
{
await Task.Delay(200).ConfigureAwait(false);
foreach (var target in targets)
{
await target.AttackByAsync(player, skill, isCombo).ConfigureAwait(false);
}
}
}
catch (Exception ex)
{
player.Logger.LogError(ex, "Error running rage attack.");
}
}
}

View File

@@ -0,0 +1,55 @@
// <copyright file="RequiemSkillPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlayerActions.Skills;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.GameLogic.Views.World;
using MUnique.OpenMU.Pathfinding;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Handles the requiem skill (book of neil) of the summoner class. Based on a chance, it may stun the target.
/// </summary>
[PlugIn]
[Display(Name = nameof(PlugInResources.RequiemSkillPlugIn_Name), Description = nameof(PlugInResources.RequiemSkillPlugIn_Description), ResourceType = typeof(PlugInResources))]
[Guid("3F1A4C8E-9B72-4D5F-A1B3-C6E7D8F90123")]
public class RequiemSkillPlugIn : IAreaSkillPlugIn
{
private const int StunnedMagicEffectNumber = 61; // 0x3D
private MagicEffectDefinition? _stunEffectDefinition;
/// <inheritdoc />
public short Key => 224;
/// <inheritdoc />
public async ValueTask AfterTargetGotAttackedAsync(IAttacker attacker, IAttackable target, SkillEntry skillEntry, Point targetAreaCenter, HitInfo? hitInfo)
{
this._stunEffectDefinition ??= ((Player)attacker).GameContext.Configuration.MagicEffects.First(m => m.Number == StunnedMagicEffectNumber);
if (!target.IsAlive || !Rand.NextRandomBool(attacker.Attributes[Stats.MasteryStunChance]))
{
return;
}
var powerUp = attacker.Attributes.CreateElement(this._stunEffectDefinition.PowerUpDefinitions.First(pu => pu.TargetAttribute == Stats.IsStunned));
var magicEffect = new MagicEffect(TimeSpan.FromSeconds(3), this._stunEffectDefinition, [new MagicEffect.ElementWithTarget(powerUp, Stats.IsStunned)]);
await target.MagicEffectList.AddEffectAsync(magicEffect).ConfigureAwait(false);
if (target is ISupportWalk walkSupporter && walkSupporter.IsWalking)
{
await walkSupporter.StopWalkingAsync().ConfigureAwait(false);
// Since the actual coordinates could be out of sync with the client
// coordinates, we simply update the position on the client side.
if (walkSupporter is IObservable observable)
{
await observable.ForEachWorldObserverAsync<IObjectMovedPlugIn>(p => p.ObjectMovedAsync(walkSupporter, MoveType.Instant), true).ConfigureAwait(false);
}
}
}
}

View File

@@ -0,0 +1,29 @@
// <copyright file="SkillCancellationTokenSource.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlayerActions.Skills;
using System.Threading;
/// <summary>
/// A <see cref="CancellationTokenSource"/> which allows to specify an explicit target when cancelling the nova skill.
/// </summary>
/// <seealso cref="CancellationTokenSource" />
public class SkillCancellationTokenSource : CancellationTokenSource
{
/// <summary>
/// Gets the explicit target identifier.
/// </summary>
public ushort? ExplicitTargetId { get; private set; }
/// <summary>
/// Communicates a request for cancellation, <see cref="CancellationTokenSource.Cancel()"/>.
/// </summary>
/// <param name="extraTarget">The extra target.</param>
public void CancelWithExtraTarget(ushort? extraTarget)
{
this.ExplicitTargetId = extraTarget;
this.Cancel();
}
}

View File

@@ -0,0 +1,20 @@
// <copyright file="SoulBarrierProficieSkillAction.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlayerActions.Skills;
using System.Runtime.InteropServices;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// The Soul Barrier Proficiency skill action.
/// </summary>
[PlugIn]
[Display(Name = nameof(PlugInResources.SoulBarrierProficieSkillAction_Name), Description = nameof(PlugInResources.SoulBarrierProficieSkillAction_Description), ResourceType = typeof(PlugInResources))]
[Guid("9c56073f-1719-423c-8397-30d94793f929")]
public class SoulBarrierProficieSkillAction : SoulBarrierStrengSkillAction
{
/// <inheritdoc/>
public override short Key => 404;
}

View File

@@ -0,0 +1,94 @@
// <copyright file="SoulBarrierStrengSkillAction.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlayerActions.Skills;
using System.Runtime.InteropServices;
using MUnique.OpenMU.AttributeSystem;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.Views.World;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// The Soul Barrier Strengthener skill action.
/// </summary>
[PlugIn]
[Display(Name = nameof(PlugInResources.SoulBarrierStrengSkillAction_Name), Description = nameof(PlugInResources.SoulBarrierStrengSkillAction_Description), ResourceType = typeof(PlugInResources))]
[Guid("05fdea2a-ac92-4b2c-8305-001e97ec26a8")]
public class SoulBarrierStrengSkillAction : TargetedSkillDefaultPlugin
{
private const ushort SoulBarrierStrengSkilId = 403;
private const ushort SoulBarrierProficieSkilId = 404;
/// <inheritdoc/>
public override short Key => 403;
/// <inheritdoc/>
public override async ValueTask PerformSkillAsync(Player player, IAttackable target, ushort skillId)
{
var skillEntry = player.SkillList!.GetSkill(skillId);
var skill = skillEntry?.Skill;
if (skill is null)
{
return;
}
var miniGame = player.CurrentMiniGame;
var inMiniGame = miniGame is { };
var isBuff = skill.SkillType is SkillType.Buff or SkillType.Regeneration;
if (player.IsAtSafezone() && !(inMiniGame && isBuff))
{
return;
}
if (inMiniGame && !miniGame!.IsSkillAllowed(skill, player, target))
{
return;
}
if (!target.IsActive())
{
return;
}
if (!target.CheckSkillTargetRestrictions(player, skill))
{
return;
}
if (!player.IsInRange(target.Position, skill.Range + 2))
{
// target position might be out of sync so we send the current coordinates to the client again.
if (!(target is ISupportWalk { IsWalking: true }))
{
await player.InvokeViewPlugInAsync<IObjectMovedPlugIn>(p => p.ObjectMovedAsync(target, MoveType.Instant)).ConfigureAwait(false);
}
return;
}
if (!await player.TryConsumeForSkillAsync(skill).ConfigureAwait(false))
{
return;
}
if (skillEntry!.PowerUps is null)
{
player.CreateMagicEffectPowerUp(skillEntry);
var strengSkillLevel = skillId == SoulBarrierProficieSkilId ? player.SkillList!.GetSkill(SoulBarrierStrengSkilId) : skillEntry;
skillEntry.PowerUps =
[
.. skillEntry.PowerUps!,
(Stats.SoulBarrierManaTollPerHit, new AttributeRelationshipElement(
[player.Attributes!.GetOrCreateAttribute(Stats.MaximumMana)],
new ConstantElement(strengSkillLevel!.Level * 0.001f), // extra 0.1% per streng skill level
InputOperator.Multiply))
];
}
await AttackableExtensions.ApplyMagicEffectAsync(target, player, skillEntry).ConfigureAwait(false);
await player.ForEachWorldObserverAsync<IShowSkillAnimationPlugIn>(p => p.ShowSkillAnimationAsync(player, target, skill, true), true).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,175 @@
// <copyright file="SummonPartySkillPlugin.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlayerActions.Skills;
using System.Runtime.InteropServices;
using System.Threading;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.GameLogic.Views.World;
using MUnique.OpenMU.Pathfinding;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// The summon skill action for Dark Lord.
/// </summary>
[PlugIn]
[Display(Name = nameof(PlugInResources.SummonPartySkillPlugin_Name), Description = nameof(PlugInResources.SummonPartySkillPlugin_Description), ResourceType = typeof(PlugInResources))]
[Guid("44e34497-c9e1-4c15-9388-589dfa3fa820")]
public class SummonPartySkillPlugin : TargetedSkillPluginBase
{
private static readonly int CountdownSeconds = 5;
/// <inheritdoc />
public override short Key => 63;
/// <inheritdoc />
public override async ValueTask PerformSkillAsync(Player player, IAttackable target, ushort skillId)
{
var skillEntry = player.SkillList?.GetSkill(skillId);
if (skillEntry?.Skill is null || !this.CanPlayerSummon(player))
{
return;
}
if (!await player.TryConsumeForSkillAsync(skillEntry.Skill).ConfigureAwait(false))
{
return;
}
await player.ForEachWorldObserverAsync<IShowSkillAnimationPlugIn>(p => p.ShowSkillAnimationAsync(player, player, skillEntry.Skill.Number, true), true).ConfigureAwait(false);
var cancellationTokenSource = new SkillCancellationTokenSource();
player.SkillCancelTokenSource = cancellationTokenSource;
_ = Task.Run(() => this.RunSummonPartyAsync(player, cancellationTokenSource));
}
private async ValueTask RunSummonPartyAsync(Player player, CancellationTokenSource cancellationTokenSource)
{
var cancellationToken = cancellationTokenSource.Token;
var partyList = player.Party!.PartyList;
var targetPlayers = partyList.OfType<Player>().Where(p => p != player).ToList();
try
{
for (var count = CountdownSeconds; count > 0; count--)
{
cancellationToken.ThrowIfCancellationRequested();
targetPlayers.RemoveAll(target => !this.CanPlayerSummonTarget(player, target));
foreach (var targetPlayer in targetPlayers)
{
await targetPlayer.InvokeViewPlugInAsync<IChatViewPlugIn>(
p => p.ChatMessageAsync($"Summoning in {count} second(s)...", player.Name, ChatMessageType.Party)).ConfigureAwait(false);
}
if (!player.IsAlive || player.IsAtSafezone())
{
await player.Party.SendChatMessageAsync("Summoning canceled.", player.Name).ConfigureAwait(false);
return;
}
await Task.Delay(1000, cancellationToken).ConfigureAwait(false);
}
await this.SummonTargetsAsync(player, targetPlayers).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Handle cancellation (if needed)
await player.Party.SendChatMessageAsync("Summoning canceled.", player.Name).ConfigureAwait(false);
}
catch (Exception ex)
{
// Log unexpected exceptions
player.Logger.LogWarning(ex, "Error during countdown");
}
finally
{
player.SkillCancelTokenSource?.Dispose();
player.SkillCancelTokenSource = null;
}
}
private async ValueTask SummonTargetsAsync(Player player, IEnumerable<Player> targetPlayers)
{
foreach (var targetPlayer in targetPlayers)
{
Point targetPoint = player.Position;
bool foundValidPoint = false;
int maxAttempts = 10;
int attempts = 0;
while (!foundValidPoint && attempts < maxAttempts)
{
attempts++;
var offsetX = Rand.NextInt(-2, 3);
var offsetY = Rand.NextInt(-2, 3);
Point testPoint = new((byte)(player.Position.X + offsetX), (byte)(player.Position.Y + offsetY));
if (player.CurrentMap!.Terrain.WalkMap[testPoint.X, testPoint.Y]
&& player.Position.EuclideanDistanceTo(targetPoint) < 6)
{
targetPoint = testPoint;
foundValidPoint = true;
}
}
if (player.CurrentMap!.Definition.TryGetRequirementError(targetPlayer, out var errorMessage))
{
await targetPlayer.ShowBlueMessageAsync(errorMessage).ConfigureAwait(false);
continue;
}
await targetPlayer.TeleportToMapAsync(player.CurrentMap!, targetPoint).ConfigureAwait(false);
}
}
private bool CanPlayerSummon(Player player)
{
return player.Party is not null
&& player.OpenedNpc is null
&& player.CurrentMiniGame is null;
// todo, not in:
// * Kalima
// * kanturu boss (39)
// * raklion boss when state is RAKLION_STATE_CLOSE_DOOR, RAKLION_STATE_ALL_USER_DIE, RAKLION_STATE_NOTIFY_4, RAKLION_STATE_END
// * not during pandora box event
// -> we could add some kind of flag in the map definition to check for this
}
private bool CanPlayerSummonTarget(Player player, Player target)
{
var allowedByState = target.IsActive()
&& target.Party?.Equals(player.Party) is true
&& target.OpenedNpc is null
&& target.TradingPartner is null
&& target.CurrentMiniGame is null;
// todo: not during castle siege for players which are not in the same ally
if (!allowedByState)
{
return false;
}
var warpInfo = player.GameContext.Configuration.WarpList.Where(w => w.Gate?.Map == player.CurrentMap?.Definition).OrderBy(w => w.LevelRequirement).FirstOrDefault();
if (warpInfo is null)
{
return true;
}
if (target.Attributes?[Stats.TotalLevel] is not { } totalLevel)
{
return false;
}
return warpInfo.LevelRequirement <= totalLevel;
}
}

View File

@@ -0,0 +1,327 @@
// <copyright file="TargetedSkillDefaultPlugin.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlayerActions.Skills;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.GameLogic.Views.World;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Action to perform a skill which is explicitly aimed to a target.
/// </summary>
[PlugIn]
[Display(Name = nameof(PlugInResources.TargetedSkillDefaultPlugin_Name), Description = nameof(PlugInResources.TargetedSkillDefaultPlugin_Description), ResourceType = typeof(PlugInResources))]
[Guid("eb2949fb-5ed2-407e-a4e8-e3015ed5692b")]
public class TargetedSkillDefaultPlugin : TargetedSkillPluginBase
{
private static readonly Dictionary<short, short> SummonSkillToMonsterMapping = new()
{
{ 30, 26 }, // Goblin
{ 31, 32 }, // Stone Golem
{ 32, 21 }, // Assassin
{ 33, 20 }, // Elite Yeti
{ 34, 10 }, // Dark Knight
{ 35, 150 }, // Bali
{ 36, 151 }, // Soldier
};
/// <inheritdoc/>
public override short Key => 0;
/// <summary>
/// Performs the skill.
/// </summary>
/// <param name="player">The player.</param>
/// <param name="target">The target.</param>
/// <param name="skillId">The skill identifier.</param>
public override async ValueTask PerformSkillAsync(Player player, IAttackable target, ushort skillId)
{
using var loggerScope = player.Logger.BeginScope(this.GetType());
if (target is null)
{
return;
}
if (player.Attributes is not { } attributes)
{
return;
}
if (attributes[Stats.IsStunned] > 0)
{
player.Logger.LogWarning("Probably Hacker - player {Player} is attacking in stunned state", player);
return;
}
if (attributes[Stats.IsAsleep] > 0)
{
player.Logger.LogWarning("Probably Hacker - player {Player} is attacking in asleep state", player);
return;
}
var skillEntry = player.SkillList?.GetSkill(skillId);
var skill = skillEntry?.Skill;
if (skill is null || skill.SkillType == SkillType.PassiveBoost)
{
return;
}
if (skill.SkillType != SkillType.Buff && skill.SkillType != SkillType.Regeneration && skill.SkillType != SkillType.SummonMonster)
{
if (player.GameContext.PlugInManager.GetPlugInPoint<ISpeedHackCheatCheckPlugIn>() is { } speedCheck)
{
var eventArgs = new SpeedHackCheckEventArgs();
await speedCheck.AttackCheatCheckAsync(player, eventArgs).ConfigureAwait(false);
if (eventArgs.IsCheatDetected)
{
return;
}
}
}
var miniGame = player.CurrentMiniGame;
var inMiniGame = miniGame is { };
var isBuff = skill.SkillType is SkillType.Buff or SkillType.Regeneration;
if (player.IsAtSafezone() && !(inMiniGame && isBuff))
{
return;
}
if (inMiniGame && !miniGame!.IsSkillAllowed(skill, player, target))
{
return;
}
if (!target.IsActive())
{
return;
}
if (!target.CheckSkillTargetRestrictions(player, skill))
{
return;
}
if (!player.IsInRange(target.Position, skill.Range + 2))
{
// target position might be out of sync so we send the current coordinates to the client again.
if (!(target is ISupportWalk { IsWalking: true }))
{
await player.InvokeViewPlugInAsync<IObjectMovedPlugIn>(p => p.ObjectMovedAsync(target, MoveType.Instant)).ConfigureAwait(false);
}
return;
}
if (skill.SkillType == SkillType.SummonMonster && player.Summon is { })
{
await player.RemoveSummonAsync().ConfigureAwait(false);
return;
}
// enough mana, ag etc?
if (!await player.TryConsumeForSkillAsync(skill).ConfigureAwait(false))
{
return;
}
if (skill.MovesToTarget)
{
await player.MoveAsync(target.Position).ConfigureAwait(false);
}
if (skill.MovesTarget)
{
await target.MoveRandomlyAsync().ConfigureAwait(false);
}
var effectApplied = false;
if (skill.SkillType == SkillType.SummonMonster)
{
if (SummonSkillToMonsterMapping.TryGetValue(skill.Number, out var monsterNumber)
&& player.GameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == monsterNumber) is { } monsterDefinition)
{
await player.CreateSummonedMonsterAsync(monsterDefinition).ConfigureAwait(false);
}
}
else
{
effectApplied = await this.ApplySkillAsync(player, target, skillEntry!).ConfigureAwait(false);
}
await player.ForEachWorldObserverAsync<IShowSkillAnimationPlugIn>(p => p.ShowSkillAnimationAsync(player, target, skill, effectApplied), true).ConfigureAwait(false);
}
/// <summary>
/// Determines the targets of the skill. It can be overridden by derived classes to provide custom target selection logic.
/// </summary>
/// <param name="player">The player.</param>
/// <param name="targetedTarget">The skill target.</param>
/// <param name="skill">The skill.</param>
/// <returns>The list of targets.</returns>
protected virtual IEnumerable<IAttackable> DetermineTargets(Player player, IAttackable targetedTarget, Skill skill)
{
if (skill.Target == SkillTarget.ImplicitPlayer)
{
return player.GetAsEnumerable();
}
if (skill.Target == SkillTarget.ImplicitParty)
{
if (player.Party != null)
{
return player.Party.PartyList.OfType<IAttackable>().Where(p => player.Observers.Contains((IWorldObserver)p));
}
return player.GetAsEnumerable();
}
if (skill.Target == SkillTarget.Explicit)
{
return targetedTarget.GetAsEnumerable();
}
if (skill.Target == SkillTarget.ExplicitWithImplicitInRange)
{
if (player.GameContext.PlugInManager.GetStrategy<short, IAreaSkillTargetFilter>(skill.Number) is { } filterPlugin)
{
var rotationToTarget = (byte)(player.Position.GetAngleDegreeTo(targetedTarget.Position) / 360.0 * 255.0);
var attackablesInRange =
player.CurrentMap?
.GetAttackablesInRange(player.Position, skill.Range)
.Where(a => a != player)
.Where(a => player.GameContext.Configuration.AreaSkillHitsPlayer || a is NonPlayerCharacter)
.Where(a => !a.IsAtSafezone())
.Where(a => filterPlugin.IsTargetWithinBounds(player, a, player.Position, rotationToTarget))
.ToList();
if (attackablesInRange is not null)
{
if (!attackablesInRange.Contains(targetedTarget))
{
attackablesInRange.Add(targetedTarget);
}
return attackablesInRange;
}
}
else if (skill.ImplicitTargetRange > 0)
{
var targetsOfTarget = targetedTarget.CurrentMap?.GetAttackablesInRange(targetedTarget.Position, skill.ImplicitTargetRange) ?? Enumerable.Empty<IAttackable>();
if (!player.GameContext.Configuration.AreaSkillHitsPlayer && targetedTarget is Monster)
{
return targetsOfTarget.OfType<Monster>();
}
return targetsOfTarget;
}
else
{
// do nothing.
}
return targetedTarget.GetAsEnumerable();
}
var targets = player.CurrentMap?.GetAttackablesInRange(player.Position, skill.ImplicitTargetRange) ?? Enumerable.Empty<IAttackable>();
if (skill.Target == SkillTarget.ImplicitAllInRange)
{
return targets;
}
if (skill.Target == SkillTarget.ImplicitPlayersInRange)
{
return targets.OfType<Player>();
}
if (skill.Target == SkillTarget.ImplicitNpcsInRange)
{
return targets.OfType<Monster>();
}
return Enumerable.Empty<IAttackable>();
}
private async ValueTask<bool> ApplySkillAsync(Player player, IAttackable targetedTarget, SkillEntry skillEntry)
{
skillEntry.ThrowNotInitializedProperty(skillEntry.Skill is null, nameof(skillEntry.Skill));
var skill = skillEntry.Skill;
var success = false;
var targets = this.DetermineTargets(player, targetedTarget, skill);
bool isCombo = false;
if (skill.SkillType is SkillType.DirectHit or SkillType.CastleSiegeSkill
&& player.ComboState is { } comboState
&& !targetedTarget.IsAtSafezone()
&& !player.IsAtSafezone()
&& targetedTarget.IsActive()
&& player.IsActive())
{
isCombo = await comboState.RegisterSkillAsync(skill).ConfigureAwait(false);
}
foreach (var target in targets)
{
if (skill.SkillType == SkillType.DirectHit || skill.SkillType == SkillType.CastleSiegeSkill)
{
if (player.Attributes![Stats.AmmunitionConsumptionRate] > player.Attributes[Stats.AmmunitionAmount])
{
break;
}
if (!target.IsAtSafezone() && !player.IsAtSafezone() && target != player)
{
var hitInfo = await target.AttackByAsync(player, skillEntry, isCombo, 1, skill.NumberOfHitsPerAttack > 1 ? false : null).ConfigureAwait(false);
player.LastAttackedTarget.SetTarget(target);
success = await target.TryApplyElementalEffectsAsync(player, skillEntry, hitInfo).ConfigureAwait(false) || success;
for (int hit = 2; hit <= skill.NumberOfHitsPerAttack; hit++)
{
await target.AttackByAsync(player, skillEntry, isCombo, 1, hit == skill.NumberOfHitsPerAttack).ConfigureAwait(false);
}
}
}
else if (skill.MagicEffectDef != null)
{
// Buffs are allowed in the Safezone of Blood Castle.
var canDoBuff = !player.IsAtSafezone() || player.CurrentMiniGame is { };
if (!canDoBuff)
{
player.Logger.LogWarning("Can't apply magic effect when being in the safe-zone. skill: {SkillName} ({SkillNumber}), skillType: {SkillType}.", skill.Name, skill.Number, skill.SkillType);
break;
}
if (skill.SkillType == SkillType.Buff)
{
await target.ApplyMagicEffectAsync(player, skillEntry).ConfigureAwait(false);
success = true;
}
else if (skill.SkillType == SkillType.Regeneration)
{
await target.ApplyRegenerationAsync(player, skillEntry).ConfigureAwait(false);
success = true;
}
else
{
player.Logger.LogWarning("Skill.MagicEffectDef isn't null, but it's not a buff or regeneration skill. skill: {SkillName} ({SkillNumber}), skillType: {SkillType}.", skill.Name, skill.Number, skill.SkillType);
}
}
else
{
player.Logger.LogWarning("Skill.MagicEffectDef is null, skill: {SkillName} ({SkillNumber}), skillType: {SkillType}.", skill.Name, skill.Number, skill.SkillType);
}
}
if (isCombo)
{
await player.ForEachWorldObserverAsync<IShowSkillAnimationPlugIn>(p => p.ShowComboAnimationAsync(player, targetedTarget), true).ConfigureAwait(false);
}
return success;
}
}

View File

@@ -0,0 +1,23 @@
// <copyright file="TargetedSkillPluginBase.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlayerActions.Skills;
using MUnique.OpenMU.GameLogic.PlugIns;
/// <summary>
/// Action to perform a skill which is explicitly aimed to a target.
/// </summary>
public abstract class TargetedSkillPluginBase : ITargetedSkillPlugin
{
/// <inheritdoc />
public virtual short Key => 0;
/// <summary>
/// Performs the skill.
/// </summary>
/// <param name="player">The player.</param>
/// <param name="target">The target.</param>
/// <param name="skillId">The skill identifier.</param>
public abstract ValueTask PerformSkillAsync(Player player, IAttackable target, ushort skillId);
}

View File

@@ -0,0 +1,59 @@
// <copyright file="TwistingSlashMasterySkillPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlayerActions.Skills;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.Pathfinding;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Handles the twisting slash mastery skill of the dark knight class. Based on a chance, it may push the targets 2 squares away from the attacker.
/// </summary>
[PlugIn]
[Display(Name = nameof(PlugInResources.TwistingSlashMasterySkillPlugIn_Name), Description = nameof(PlugInResources.TwistingSlashMasterySkillPlugIn_Description), ResourceType = typeof(PlugInResources))]
[Guid("9F4B2C1D-E7A6-4B3C-8D9E-0FAB12C3D4E5")]
public class TwistingSlashMasterySkillPlugIn : IAreaSkillPlugIn
{
/// <inheritdoc />
public short Key => 332;
/// <inheritdoc />
public async ValueTask AfterTargetGotAttackedAsync(IAttacker attacker, IAttackable target, SkillEntry skillEntry, Point targetAreaCenter, HitInfo? hitInfo)
{
if (!target.IsAlive
|| target is not IMovable movableTarget
|| target.CurrentMap is not { } currentMap
|| !Rand.NextRandomBool(attacker.Attributes[Stats.MasteryMoveTargetChance]))
{
return;
}
var startingPoint = attacker.Position;
var currentTarget = target.Position;
var direction = startingPoint.GetDirectionTo(currentTarget);
if (direction == Direction.Undefined)
{
direction = (Direction)Rand.NextInt(1, 9);
}
for (int i = 0; i < 2; i++)
{
var nextTarget = currentTarget.CalculateTargetPoint(direction);
if (!currentMap.Terrain.WalkMap[nextTarget.X, nextTarget.Y]
|| (target is NonPlayerCharacter && target.CurrentMap.Terrain.SafezoneMap[nextTarget.X, nextTarget.Y]))
{
// we don't want to push the target into a non-reachable area, through walls or monsters into the safe zone.
break;
}
currentTarget = nextTarget;
}
await movableTarget.MoveAsync(currentTarget).ConfigureAwait(false);
}
}