baseline: OpenMU upstream b5a0961 (fresh source)

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

View File

@@ -0,0 +1,462 @@
// <copyright file="AttackableNpcBase.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
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;
/// <summary>
/// An abstract base class for an <see cref="IAttackable"/> <see cref="NonPlayerCharacter"/>.
/// </summary>
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<IDisposable> _registrations = new();
private int _health;
/// <summary>
/// Initializes a new instance of the <see cref="AttackableNpcBase" /> class.
/// </summary>
/// <param name="spawnInfo">The spawn information.</param>
/// <param name="stats">The stats.</param>
/// <param name="map">The map.</param>
/// <param name="eventStateProvider">The event state provider.</param>
/// <param name="dropGenerator">The drop generator.</param>
/// <param name="plugInManager">The plug in manager.</param>
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);
}
/// <summary>
/// Occurs when this instance died.
/// </summary>
public event EventHandler<DeathInformation>? Died;
/// <inheritdoc />
public IAttributeSystem Attributes { get; }
/// <inheritdoc />
public MagicEffectsList MagicEffectList { get; }
/// <inheritdoc />
public bool IsAlive { get; private set; }
/// <summary>
/// Gets a value indicating whether this <see cref="IAttackable" /> 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.
/// </summary>
/// <value>
/// <c>true</c> if teleporting; otherwise, <c>false</c>.
/// </value>
/// <remarks>Teleporting for monsters or npcs is not implemented yet.</remarks>
public bool IsTeleporting => false;
/// <inheritdoc />
public DeathInformation? LastDeath { get; protected set; }
/// <inheritdoc/>
public override Point Position
{
get => base.Position;
set
{
if (base.Position != value)
{
base.Position = value;
this._plugInManager?.GetPlugInPoint<IAttackableMovedPlugIn>()?.AttackableMoved(this);
}
}
}
/// <summary>
/// Gets or sets the current health.
/// </summary>
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));
/// <inheritdoc />
public async ValueTask<HitInfo?> 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;
}
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);
}
}
if (attacker as IPlayerSurrogate is { } playerSurrogate)
{
await playerSurrogate.Owner.AfterHitTargetAsync().ConfigureAwait(false);
}
}
return hitInfo;
}
/// <inheritdoc />
public abstract ValueTask ReflectDamageAsync(IAttacker reflector, uint damage);
/// <inheritdoc />
public abstract ValueTask ApplyPoisonDamageAsync(IAttacker initialAttacker, uint damage);
/// <inheritdoc />
public abstract ValueTask ApplyBleedingDamageAsync(IAttacker initialAttacker, uint damage);
/// <inheritdoc/>
public ValueTask KillInstantlyAsync()
{
throw new NotImplementedException();
}
/// <inheritdoc/>
public override void Initialize()
{
base.Initialize();
this.Health = this.SpawnArea.MaximumHealthOverride ?? (int)this.Attributes[Stats.MaximumHealth];
this.IsAlive = true;
}
/// <summary>
/// Reloads the attributes from the <see cref="NonPlayerCharacter.Definition"/>, so that changes
/// to the monster definition take effect on this already spawned instance.
/// </summary>
public void ReloadAttributes()
{
(this.Attributes as MonsterAttributeHolder)?.ApplyChanges();
}
/// <summary>
/// Registers a disposable (e.g. a configuration change registration) to be disposed
/// together with this instance.
/// </summary>
/// <param name="disposable">The disposable.</param>
public void RegisterDisposable(IDisposable disposable)
{
this._registrations.Add(disposable);
}
/// <inheritdoc/>
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);
}
/// <summary>
/// Called when the object is removed from the map.
/// </summary>
protected virtual void OnRemoveFromMap()
{
// can be overwritten to do additional stuff.
}
/// <summary>
/// Hits this instance with the specified hit information.
/// </summary>
/// <param name="hitInfo">The hit information.</param>
/// <param name="attacker">The attacker.</param>
/// <param name="skill">The skill.</param>
/// <param name="isFinalStreakHit">
/// Not <c>null</c> when it's a rage fighter multiple hit skill:
/// <c>true</c>, if it's the final hit;
/// <c>false</c>, for other hits.
/// </param>
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<IShowHitPlugIn>(p => p.ShowHitAsync(this, hitInfo)).ConfigureAwait(false);
player.GameContext.PlugInManager.GetPlugInPoint<IAttackableGotHitPlugIn>()?.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);
}
}
}
/// <summary>
/// Gets the target of a hit notification.
/// </summary>
/// <param name="attacker">The attacker.</param>
/// <returns>The target player of a hit notification.</returns>
protected virtual Player? GetHitNotificationTarget(IAttacker attacker)
{
return attacker as Player ?? (attacker as IPlayerSurrogate)?.Owner;
}
/// <summary>
/// Registers the hit.
/// </summary>
/// <param name="attacker">The attacker.</param>
protected virtual void RegisterHit(IAttacker attacker)
{
// can be overwritten
}
/// <summary>
/// Called when this instance died.
/// </summary>
/// <param name="attacker">The attacker which killed this instance.</param>
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<IObjectGotKilledPlugIn>(p => p.ObjectGotKilledAsync(this, attacker), true).ConfigureAwait(false);
var player = this.GetHitNotificationTarget(attacker);
if (player is { })
{
int exp = await (player.Party?.DistributeExperienceAfterKillAsync(this, player) ?? player.AddExpAfterKillAsync(this)).ConfigureAwait(false);
if (attacker == player)
{
await player.AfterKilledMonsterAsync().ConfigureAwait(false);
}
if (player.GameContext.PlugInManager.GetPlugInPoint<IAttackableGotKilledPlugIn>() 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, exp); // don't wait for completion.
}
}
}
private async ValueTask RemoveFromMapAndDisposeAsync()
{
await this.CurrentMap.RemoveAsync(this).ConfigureAwait(false);
this.Dispose();
this.OnRemoveFromMap();
}
/// <summary>
/// Respawns this instance on the map.
/// </summary>
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)
{
// We don't drop money in Devil Square, etc.
var shouldDropMoney = killer.GameContext.Configuration.ShouldDropMoney && killer.CurrentMiniGame is null;
if (!shouldDropMoney)
{
var party = killer.Party;
if (party is null)
{
killer.TryAddMoney((int)amount);
}
else
{
await party.DistributeMoneyAfterKillAsync(this, killer, amount).ConfigureAwait(false);
}
return;
}
var droppedMoney = new DroppedMoney((uint)(amount * (killer.Attributes?[Stats.MoneyAmountRate] ?? 1.0f)), this.Position, this.CurrentMap);
await this.CurrentMap.AddAsync(droppedMoney).ConfigureAwait(false);
}
private async ValueTask DropItemAsync(int exp, Player killer)
{
// When the killer is in a party, DistributeExperienceAfterKillAsync returns a
// total party experience that does NOT include game rate (ExperienceRate) or
// personal experience rate multipliers. Since the money drop amount is
// derived from this experience value, party money drops were dramatically
// lower than solo drops. We recalculate the experience for money purposes
// using the solo formula so money is consistent regardless of party state.
if (killer.Party is not null)
{
exp = killer.CalculateExpAfterKill(this);
}
var (generatedItems, droppedMoney) = await this._dropGenerator.GenerateItemDropsAsync(this.Definition, exp, killer).ConfigureAwait(false);
if (droppedMoney > 0)
{
await this.HandleMoneyDropAsync(droppedMoney.Value, killer).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, int gainedExp)
{
try
{
await Task.Delay(1000).ConfigureAwait(false);
await this.DropItemAsync(gainedExp, player).ConfigureAwait(false);
}
catch (Exception ex)
{
player.Logger.LogDebug(ex, "Dropping an item failed after killing '{this}': {ex}", this, ex);
}
}
}