// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // 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; /// /// Action to attack with a skill which inflicts damage to an area of the current map of the player. /// public class AreaSkillAttackAction { private const int UndefinedTarget = 0xFFFF; private const short ElectricSpikeSkillId = 65; private static readonly ConcurrentDictionary FrustumFilters = new(); /// /// Performs the skill by the player at the specified area. Additionally, to the target area, a target object can be specified. /// /// The player. /// The extra target identifier. /// The skill identifier. /// The coordinates of the center of the target area. /// 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. /// If set to true, hit implicitly for . 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() 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(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 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 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(p => p.ShowComboAnimationAsync(player, extraTarget), true).ConfigureAwait(false); } } private async Task AttackTargetsAsync(Player player, ushort extraTargetId, Point targetAreaCenter, SkillEntry skillEntry, AreaSkillSettings areaSkillSettings, IEnumerable 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().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(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(baseSkill.Number) is { } baseSkillStrategy) { await baseSkillStrategy.AfterTargetGotAttackedAsync(player, target, skillEntry, targetAreaCenter, hitInfo).ConfigureAwait(false); } } }