Files
AdamuSw/src/GameLogic/PlayerActions/Skills/AreaSkillAttackAction.cs
ze-dom 960664c1c3 Reverted parenthesis, updated update plugin date
(cherry picked from commit b7df2defd31d3ee5eeca13490fd47956f1c06bb4)
2026-07-27 01:03:46 +03:00

408 lines
18 KiB
C#

// <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())
.Where(a => a.IsActive())
?? [];
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;
var orderedTargets = targets.ToList();
FrustumBasedTargetFilter? filter = null;
var extraProjectiles = 0;
var projectileCount = 1;
var attackRounds = areaSkillSettings.MaximumNumberOfHitsPerTarget;
if (areaSkillSettings is { UseFrustumFilter: true, ProjectileCount: > 1 })
{
// Order targets by distance to process nearest targets first
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));
extraProjectiles += (int)player.Attributes![Stats.ExtraProjectiles];
projectileCount = areaSkillSettings.ProjectileCount + extraProjectiles;
attackRounds = 1; // One attack round per projectile
maxAttacks = projectileCount;
minAttacks = projectileCount;
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, extraProjectiles))
{
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);
}
});
}
if (filter != null)
{
break; // This projectile has hit, so we move on to the next projectile
}
}
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);
}
}
}