baseline: OpenMU upstream b5a0961 (fresh source)
This commit is contained in:
398
src/GameLogic/PlayerActions/Skills/AreaSkillAttackAction.cs
Normal file
398
src/GameLogic/PlayerActions/Skills/AreaSkillAttackAction.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
40
src/GameLogic/PlayerActions/Skills/AreaSkillHitAction.cs
Normal file
40
src/GameLogic/PlayerActions/Skills/AreaSkillHitAction.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
94
src/GameLogic/PlayerActions/Skills/DragonRoarSkillPlugIn.cs
Normal file
94
src/GameLogic/PlayerActions/Skills/DragonRoarSkillPlugIn.cs
Normal 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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/GameLogic/PlayerActions/Skills/DrainLifeSkillPlugIn.cs
Normal file
36
src/GameLogic/PlayerActions/Skills/DrainLifeSkillPlugIn.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
57
src/GameLogic/PlayerActions/Skills/EarthShakeSkillPlugIn.cs
Normal file
57
src/GameLogic/PlayerActions/Skills/EarthShakeSkillPlugIn.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
99
src/GameLogic/PlayerActions/Skills/FireScreamSkillPlugIn.cs
Normal file
99
src/GameLogic/PlayerActions/Skills/FireScreamSkillPlugIn.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
85
src/GameLogic/PlayerActions/Skills/ForceSkillAction.cs
Normal file
85
src/GameLogic/PlayerActions/Skills/ForceSkillAction.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
267
src/GameLogic/PlayerActions/Skills/FrustumBasedTargetFilter.cs
Normal file
267
src/GameLogic/PlayerActions/Skills/FrustumBasedTargetFilter.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
191
src/GameLogic/PlayerActions/Skills/NovaSkillStartPlugin.cs
Normal file
191
src/GameLogic/PlayerActions/Skills/NovaSkillStartPlugin.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
26
src/GameLogic/PlayerActions/Skills/NovaSkillStopPlugin.cs
Normal file
26
src/GameLogic/PlayerActions/Skills/NovaSkillStopPlugin.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
23
src/GameLogic/PlayerActions/Skills/PhoenixShotSkillPlugIn.cs
Normal file
23
src/GameLogic/PlayerActions/Skills/PhoenixShotSkillPlugIn.cs
Normal 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;
|
||||
}
|
||||
35
src/GameLogic/PlayerActions/Skills/PlasmaStormSkillPlugIn.cs
Normal file
35
src/GameLogic/PlayerActions/Skills/PlasmaStormSkillPlugIn.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
59
src/GameLogic/PlayerActions/Skills/PollutionSkillPlugIn.cs
Normal file
59
src/GameLogic/PlayerActions/Skills/PollutionSkillPlugIn.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
91
src/GameLogic/PlayerActions/Skills/RageSkillAttackAction.cs
Normal file
91
src/GameLogic/PlayerActions/Skills/RageSkillAttackAction.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
55
src/GameLogic/PlayerActions/Skills/RequiemSkillPlugIn.cs
Normal file
55
src/GameLogic/PlayerActions/Skills/RequiemSkillPlugIn.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
175
src/GameLogic/PlayerActions/Skills/SummonPartySkillPlugin.cs
Normal file
175
src/GameLogic/PlayerActions/Skills/SummonPartySkillPlugin.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
327
src/GameLogic/PlayerActions/Skills/TargetedSkillDefaultPlugin.cs
Normal file
327
src/GameLogic/PlayerActions/Skills/TargetedSkillDefaultPlugin.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user