//
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
//
namespace MUnique.OpenMU.GameLogic.NPC;
using System.Diagnostics;
using System.Threading;
using MUnique.OpenMU.AttributeSystem;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.GameLogic.Views.World;
using MUnique.OpenMU.Pathfinding;
using MUnique.OpenMU.Persistence;
using MUnique.OpenMU.PlugIns;
///
/// An abstract base class for an .
///
public abstract class AttackableNpcBase : NonPlayerCharacter, IAttackable
{
private const byte MaximumDropDistance = 2;
private readonly IEventStateProvider? _eventStateProvider;
private readonly IDropGenerator _dropGenerator;
private readonly PlugInManager _plugInManager;
private readonly List _registrations = new();
private int _health;
///
/// Initializes a new instance of the class.
///
/// The spawn information.
/// The stats.
/// The map.
/// The event state provider.
/// The drop generator.
/// The plug in manager.
protected AttackableNpcBase(MonsterSpawnArea spawnInfo, MonsterDefinition stats, GameMap map, IEventStateProvider? eventStateProvider, IDropGenerator dropGenerator, PlugInManager plugInManager)
: base(spawnInfo, stats, map)
{
this._eventStateProvider = eventStateProvider;
this._dropGenerator = dropGenerator;
this._plugInManager = plugInManager;
this.MagicEffectList = new MagicEffectsList(this);
this.Attributes = new MonsterAttributeHolder(this);
}
///
/// Occurs when this instance died.
///
public event EventHandler? Died;
///
public IAttributeSystem Attributes { get; }
///
public MagicEffectsList MagicEffectList { get; }
///
public bool IsAlive { get; private set; }
///
/// Gets a value indicating whether this is currently teleporting and can't be directly targeted.
/// It can still receive damage, if the teleport target coordinates are within an target skill area for area attacks.
///
///
/// true if teleporting; otherwise, false.
///
/// Teleporting for monsters or npcs is not implemented yet.
public bool IsTeleporting => false;
///
public DeathInformation? LastDeath { get; protected set; }
///
public override Point Position
{
get => base.Position;
set
{
if (base.Position != value)
{
base.Position = value;
this._plugInManager?.GetPlugInPoint()?.AttackableMoved(this);
}
}
}
///
/// Gets or sets the current health.
///
public int Health
{
get => Math.Max(this._health, 0);
set => this._health = value;
}
private bool ShouldRespawn => this.SpawnArea.SpawnTrigger == SpawnTrigger.Automatic
|| (this.SpawnArea.SpawnTrigger == SpawnTrigger.AutomaticDuringEvent && (this._eventStateProvider?.IsEventRunning ?? false))
|| (this.SpawnArea.SpawnTrigger == SpawnTrigger.AutomaticDuringWave && (this._eventStateProvider?.IsSpawnWaveActive(this.SpawnArea.WaveNumber) ?? false));
///
public async ValueTask AttackByAsync(IAttacker attacker, SkillEntry? skill, bool isCombo, double damageFactor = 1.0, bool? isFinalStreakHit = null)
{
if (this.Definition.ObjectKind == NpcObjectKind.Guard || this.IsAttackBlockedBySafezone(attacker))
{
return null;
}
// ADAMU-CUSTOM: Heykel Savasi -> a statue is immune to damage from its own team (only the enemy may
// break it) AND while any of its 3 guard mobs are still alive (guards must be cleared first).
if (attacker is Player heykelStatueAttacker
&& heykelStatueAttacker.CurrentMiniGame is MiniGames.HeykelSavasiContext heykelStatueGame
&& heykelStatueGame.IsStatueAttackBlocked(this, heykelStatueAttacker))
{
return null;
}
var hitInfo = await attacker.CalculateDamageAsync(this, skill, isCombo, damageFactor).ConfigureAwait(false);
if (skill?.Skill is not { } attackSkill || attackSkill.DamageType != DamageType.Fenrir)
{
attacker.ApplyAmmunitionConsumption(hitInfo);
}
await this.HitAsync(hitInfo, attacker, skill?.Skill, isFinalStreakHit).ConfigureAwait(false);
if (hitInfo.HealthDamage > 0)
{
if (this.Attributes[Stats.IsAsleep] > 0)
{
await this.MagicEffectList.ClearAllEffectsProducingSpecificStatAsync(Stats.IsAsleep).ConfigureAwait(false);
}
if (attacker is Player player)
{
await player.AfterHitTargetAsync().ConfigureAwait(false);
if (this.IsAlive && Rand.NextRandomBool(player.Attributes![Stats.MaceMasteryStunChance]))
{
await player.ApplyMaceMasteryStunEffectAsync(this).ConfigureAwait(false);
}
// ADAMU-CUSTOM: TvT Event -> record per-character statue damage for the event scoreboard.
if (this.Definition.ObjectKind == NpcObjectKind.Destructible
&& player.CurrentMiniGame is MiniGames.HeykelSavasiContext heykelDamageGame)
{
heykelDamageGame.RecordStatueDamage(player, this, (uint)hitInfo.HealthDamage);
}
}
if (attacker as IPlayerSurrogate is { } playerSurrogate)
{
await playerSurrogate.Owner.AfterHitTargetAsync().ConfigureAwait(false);
}
}
return hitInfo;
}
///
public abstract ValueTask ReflectDamageAsync(IAttacker reflector, uint damage);
///
public abstract ValueTask ApplyPoisonDamageAsync(IAttacker initialAttacker, uint damage);
///
public abstract ValueTask ApplyBleedingDamageAsync(IAttacker initialAttacker, uint damage);
///
public ValueTask KillInstantlyAsync()
{
throw new NotImplementedException();
}
///
public override void Initialize()
{
base.Initialize();
this.Health = this.SpawnArea.MaximumHealthOverride ?? (int)this.Attributes[Stats.MaximumHealth];
this.IsAlive = true;
}
///
/// Reloads the attributes from the , so that changes
/// to the monster definition take effect on this already spawned instance.
///
public void ReloadAttributes()
{
(this.Attributes as MonsterAttributeHolder)?.ApplyChanges();
}
///
/// Registers a disposable (e.g. a configuration change registration) to be disposed
/// together with this instance.
///
/// The disposable.
public void RegisterDisposable(IDisposable disposable)
{
this._registrations.Add(disposable);
}
///
protected override void Dispose(bool managed)
{
if (managed)
{
this.Died = null;
this.IsAlive = false;
foreach (var registration in this._registrations)
{
registration.Dispose();
}
this._registrations.Clear();
}
base.Dispose(managed);
}
///
/// Called when the object is removed from the map.
///
protected virtual void OnRemoveFromMap()
{
// can be overwritten to do additional stuff.
}
///
/// Hits this instance with the specified hit information.
///
/// The hit information.
/// The attacker.
/// The skill.
///
/// Not null when it's a rage fighter multiple hit skill:
/// true, if it's the final hit;
/// false, for other hits.
///
protected async ValueTask HitAsync(HitInfo hitInfo, IAttacker attacker, Skill? skill, bool? isFinalStreakHit = null)
{
if (!this.IsAlive)
{
return;
}
var killed = this.TryHit(hitInfo.HealthDamage + hitInfo.ShieldDamage, attacker);
var player = this.GetHitNotificationTarget(attacker);
if (player is not null)
{
if (isFinalStreakHit.HasValue)
{
hitInfo.Attributes |= DamageAttributes.RageFighterStreakHit;
if (isFinalStreakHit.Value || killed)
{
hitInfo.Attributes |= DamageAttributes.RageFighterStreakFinalHit;
}
}
await player.InvokeViewPlugInAsync(p => p.ShowHitAsync(this, hitInfo)).ConfigureAwait(false);
player.GameContext.PlugInManager.GetPlugInPoint()?.AttackableGotHit(this, attacker, hitInfo);
}
if (killed)
{
this.LastDeath = new DeathInformation(attacker.Id, attacker.GetName(), hitInfo, skill?.Number ?? 0);
await this.OnDeathAsync(attacker).ConfigureAwait(false);
this.Died?.Invoke(this, this.LastDeath);
if (!this.ShouldRespawn)
{
await this.RemoveFromMapAndDisposeAsync().ConfigureAwait(false);
}
}
}
///
/// Gets the target of a hit notification.
///
/// The attacker.
/// The target player of a hit notification.
protected virtual Player? GetHitNotificationTarget(IAttacker attacker)
{
return attacker as Player ?? (attacker as IPlayerSurrogate)?.Owner;
}
///
/// Registers the hit.
///
/// The attacker.
protected virtual void RegisterHit(IAttacker attacker)
{
// can be overwritten
}
///
/// Called when this instance died.
///
/// The attacker which killed this instance.
protected virtual async ValueTask OnDeathAsync(IAttacker attacker)
{
if (this.ShouldRespawn)
{
_ = Task.Run(async () =>
{
try
{
await Task.Delay(this.Definition.RespawnDelay).ConfigureAwait(false);
await this.RespawnAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
Debug.Fail($"Unexpected error during respawning the attackable npc {this}: {ex}", ex.StackTrace);
}
});
}
await this.ForEachWorldObserverAsync(p => p.ObjectGotKilledAsync(this, attacker), true).ConfigureAwait(false);
var player = this.GetHitNotificationTarget(attacker);
if (player is { })
{
var experienceShares = player.Party is { } party
? await party.DistributeExperienceAfterKillAsync(this, player).ConfigureAwait(false)
: [new ExperienceShare(player, await player.AddExpAfterKillAsync(this).ConfigureAwait(false))];
if (attacker == player)
{
await player.AfterKilledMonsterAsync().ConfigureAwait(false);
}
if (player.GameContext.PlugInManager.GetPlugInPoint() is { } plugInPoint)
{
await plugInPoint.AttackableGotKilledAsync(this, attacker).ConfigureAwait(false);
}
if (!this.IsSummonedMonster && player.SelectedCharacter is { } selectedCharacter)
{
if (selectedCharacter.State > HeroState.Normal)
{
selectedCharacter.StateRemainingSeconds -= (int)this.Attributes[Stats.Level];
}
_ = this.DropItemDelayedAsync(player, experienceShares); // don't wait for completion.
}
}
}
private async ValueTask RemoveFromMapAndDisposeAsync()
{
await this.CurrentMap.RemoveAsync(this).ConfigureAwait(false);
this.Dispose();
this.OnRemoveFromMap();
}
///
/// Respawns this instance on the map.
///
private async ValueTask RespawnAsync()
{
try
{
if (!this.ShouldRespawn)
{
await this.RemoveFromMapAndDisposeAsync().ConfigureAwait(false);
return;
}
this.Initialize();
await this.CurrentMap.RespawnAsync(this).ConfigureAwait(false);
this.OnSpawn();
}
catch (Exception ex)
{
Debug.Fail(ex.Message, ex.StackTrace);
}
}
private bool TryHit(uint damage, IAttacker attacker)
{
if (damage > 0)
{
this.RegisterHit(attacker);
}
if (damage >= this.Health)
{
this.IsAlive = false;
this.Health = 0;
return true;
}
try
{
Interlocked.Add(ref this._health, -(int)damage);
return false;
}
catch
{
return false;
}
}
private async ValueTask HandleMoneyDropAsync(uint amount, Player killer, IReadOnlyList experienceShares)
{
// Each player gets the part of the money which matches the experience they gained from the kill,
// so that money follows the same distribution as the experience it is derived from.
var shares = MoneyDistribution.CreateShares(amount, experienceShares);
// We don't drop money in Devil Square, etc.
var shouldDropMoney = killer.GameContext.Configuration.ShouldDropMoney && killer.CurrentMiniGame is null;
if (!shouldDropMoney)
{
if (killer.Party is { } party)
{
await party.DistributeMoneyAfterKillAsync(this, killer, shares).ConfigureAwait(false);
}
else
{
_ = MoneyDistribution.TryPay(killer, amount);
}
return;
}
var droppedMoney = new DroppedMoney(amount, this.Position, this.CurrentMap, shares);
await this.CurrentMap.AddAsync(droppedMoney).ConfigureAwait(false);
}
private async ValueTask DropItemAsync(IReadOnlyList experienceShares, Player killer)
{
var exp = 0;
foreach (var share in experienceShares)
{
exp += share.Experience;
}
var (generatedItems, droppedMoney) = await this._dropGenerator.GenerateItemDropsAsync(this.Definition, exp, killer).ConfigureAwait(false);
if (droppedMoney > 0)
{
await this.HandleMoneyDropAsync(droppedMoney.Value, killer, experienceShares).ConfigureAwait(false);
}
var firstItem = !droppedMoney.HasValue;
foreach (var item in generatedItems)
{
Point dropCoordinates;
if (firstItem)
{
dropCoordinates = this.Position;
firstItem = false;
}
else
{
dropCoordinates = this.CurrentMap.Terrain.GetRandomCoordinate(this.Position, MaximumDropDistance);
}
var owners = killer.Party?.PartyList.AsEnumerable() ?? killer.GetAsEnumerable();
var droppedItem = new DroppedItem(item, dropCoordinates, this.CurrentMap, null, owners);
await this.CurrentMap.AddAsync(droppedItem).ConfigureAwait(false);
}
}
private async ValueTask DropItemDelayedAsync(Player player, IReadOnlyList experienceShares)
{
try
{
await Task.Delay(1000).ConfigureAwait(false);
await this.DropItemAsync(experienceShares, player).ConfigureAwait(false);
}
catch (Exception ex)
{
player.Logger.LogDebug(ex, "Dropping an item failed after killing '{this}': {ex}", this, ex);
}
}
}