diff --git a/src/GameLogic/NPC/Monster.cs b/src/GameLogic/NPC/Monster.cs index 62b5c6558..f65c14475 100644 --- a/src/GameLogic/NPC/Monster.cs +++ b/src/GameLogic/NPC/Monster.cs @@ -18,6 +18,11 @@ namespace MUnique.OpenMU.GameLogic.NPC; /// public sealed class Monster : AttackableNpcBase, IAttackable, IAttacker, ISupportWalk, IMovable, ISummonable { + private const short IcedEffectNumber = 0x38; + private const short BlowOfDestructionEffectNumber = 0x56; + private const double IcedMovementSpeedFactor = 0.5; + private const double BlowOfDestructionMovementSpeedFactor = 0.33; + private readonly AsyncLock _moveLock = new(); private readonly INpcIntelligence _intelligence; private readonly Walker _walker; @@ -59,7 +64,7 @@ public Monster(MonsterSpawnArea spawnInfo, MonsterDefinition stats, GameMap map, : base(spawnInfo, stats, map, eventStateProvider, dropGenerator, plugInManager) { this._pathFinderPool = pathFinderPool; - this._walker = new Walker(this, () => this.StepDelay); + this._walker = new Walker(this, this.GetStepDelay); this._intelligence = npcIntelligence; (this._skillPowerUp, this._skillPowerUpDuration, this._skillPowerUpTarget) = this.CreateMagicEffectPowerUp(); @@ -87,7 +92,7 @@ public Monster(MonsterSpawnArea spawnInfo, MonsterDefinition stats, GameMap map, public Point WalkTarget => this._walker.CurrentTarget; /// - public TimeSpan StepDelay => this.Definition.MoveDelay; + public TimeSpan StepDelay => this.GetStepDelay(null); /// /// Monsters don't do combos. @@ -356,6 +361,30 @@ private static WalkingStep GetStep(PathResultNode node) }; } + private TimeSpan GetStepDelay(WalkingStep? step) + { + var tileDistance = step is { } walkingStep ? walkingStep.From.EuclideanDistanceTo(walkingStep.To) : 1.0; + var delayMilliseconds = this.Definition.MoveDelay.TotalMilliseconds * Math.Max(1.0, tileDistance); + delayMilliseconds /= this.GetMovementSpeedFactor(); + + return TimeSpan.FromMilliseconds(delayMilliseconds); + } + + private double GetMovementSpeedFactor() + { + if (this.MagicEffectList.ActiveEffects.ContainsKey(IcedEffectNumber)) + { + return IcedMovementSpeedFactor; + } + + if (this.MagicEffectList.ActiveEffects.ContainsKey(BlowOfDestructionEffectNumber)) + { + return BlowOfDestructionMovementSpeedFactor; + } + + return 1.0; + } + /// /// Creates the magic effect power up for the given skill of a monster. /// @@ -401,4 +430,4 @@ private void ValidatePath(Memory steps) } } } -} \ No newline at end of file +} diff --git a/src/GameLogic/Player.cs b/src/GameLogic/Player.cs index ed06901e0..044a19bfe 100644 --- a/src/GameLogic/Player.cs +++ b/src/GameLogic/Player.cs @@ -9,6 +9,7 @@ namespace MUnique.OpenMU.GameLogic; using System.Threading; using MUnique.OpenMU.AttributeSystem; using MUnique.OpenMU.DataModel.Attributes; +using MUnique.OpenMU.DataModel.Configuration.Items; using MUnique.OpenMU.GameLogic.Attributes; using MUnique.OpenMU.GameLogic.GuildWar; using MUnique.OpenMU.GameLogic.MiniGames; @@ -41,6 +42,17 @@ namespace MUnique.OpenMU.GameLogic; /// public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacker, ITrader, IPartyMember, IRotatable, IHasBucketInformation, ISupportWalk, IMovable, ILoggerOwner { + private const short IcedEffectNumber = 0x38; + private const short BlowOfDestructionEffectNumber = 0x56; + private const double IcedMovementSpeedFactor = 0.5; + private const double BlowOfDestructionMovementSpeedFactor = 0.33; + private const byte RunningGearMinimumLevel = 5; + private const ushort AtlansMapNumber = 7; + private const ushort Kalima1MapNumber = 24; + private const ushort Kalima6MapNumber = 29; + private const ushort Kalima7MapNumber = 36; + private const ushort Doppelgaenger3MapNumber = 67; + private static readonly MagicEffectDefinition GMEffect = new GMMagicEffectDefinition { InformObservers = true, @@ -148,7 +160,7 @@ public Player(IGameContext gameContext) public bool IsWalking => this._walker.CurrentTarget != default; /// - public TimeSpan StepDelay => this.GetStepDelay(); + public TimeSpan StepDelay => this.GetStepDelay(null); /// public Point WalkTarget => this._walker.CurrentTarget; @@ -715,6 +727,11 @@ public ValueTask ShowBlueMessageAsync(string message) throw new InvalidOperationException("AttributeSystem not set."); } + if (this.IsAttackBlockedBySafezone(attacker)) + { + return null; + } + if (!this.GameContext.PvpEnabled && this.CurrentMap?.Definition.BattleZone == null && this.CurrentMiniGame?.AllowPlayerKilling is false) { @@ -1408,6 +1425,17 @@ public async ValueTask WalkToAsync(Point target, Memory steps) /// public ValueTask StopWalkingAsync() => this._walker.StopAsync(); + private bool IsAttackBlockedBySafezone(IAttacker attacker) + { + if (this.IsAtSafezone()) + { + return true; + } + + var attackerPlayer = attacker as Player ?? (attacker as IPlayerSurrogate)?.Owner; + return attackerPlayer?.IsAtSafezone() is true; + } + /// /// Regenerates the attributes specified in . /// @@ -2107,19 +2135,135 @@ private async ValueTask RegenerateHeroStateAsync() } /// - /// Gets the step delay depending on the equipped items. + /// Gets the step delay depending on the equipped items and current movement effects. /// + /// The walking step for which the delay is calculated. /// The current step delay, depending on equipped items. - private TimeSpan GetStepDelay() + private TimeSpan GetStepDelay(WalkingStep? step) + { + const double referenceFrameTimeMilliseconds = 40.0; + const double terrainScale = 100.0; + + var speed = this.GetClientMovementSpeed(step?.From); + var tileDistance = step is { } walkingStep ? walkingStep.From.EuclideanDistanceTo(walkingStep.To) : 1.0; + var movementMilliseconds = terrainScale * Math.Max(1.0, tileDistance) / speed * referenceFrameTimeMilliseconds; + + return TimeSpan.FromMilliseconds(movementMilliseconds); + } + + private double GetClientMovementSpeed(Point? position = null) { - if (this.Inventory?.EquippedItems.Any(item => item.Definition?.ItemSlot?.ItemSlots.Contains(7) ?? false) ?? false) + const double walkSpeed = 12.0; + if (this.IsInClientSafezone(position)) + { + return this.ApplyMovementEffects(walkSpeed); + } + + return this.ApplyMovementEffects(this.GetMountedOrRunningSpeed(walkSpeed)); + } + + private double ApplyMovementEffects(double speed) + { + if (this.MagicEffectList.ActiveEffects.ContainsKey(IcedEffectNumber)) + { + return speed * IcedMovementSpeedFactor; + } + + if (this.MagicEffectList.ActiveEffects.ContainsKey(BlowOfDestructionEffectNumber)) + { + return speed * BlowOfDestructionMovementSpeedFactor; + } + + return speed; + } + + private double GetMountedOrRunningSpeed(double walkSpeed) + { + const double runSpeed = 15.0; + const double fastWingSpeed = 16.0; + const double horseOrFenrirRunSpeed = 17.0; + const double excellentFenrirRunSpeed = 19.0; + + var pet = this.Inventory?.GetItem(InventoryConstants.PetSlot); + if (this.IsItem(pet, 13, 37)) { - // Wings - return TimeSpan.FromMilliseconds(300); + if (this.HasFenrirMovementOption(pet)) + { + return excellentFenrirRunSpeed; + } + + return horseOrFenrirRunSpeed; + } + + if (this.IsItem(pet, 13, 4)) + { + return horseOrFenrirRunSpeed; + } + + var wings = this.Inventory?.GetItem(InventoryConstants.WingsSlot); + if (this.HasEquippedWings(wings) + || this.IsItem(pet, 13, 2) + || this.IsItem(pet, 13, 3)) + { + return this.GetWingMovementSpeed(wings, runSpeed, fastWingSpeed); } - // TODO: Consider pets etc. - return TimeSpan.FromMilliseconds(500); + return this.HasRunningGear() ? runSpeed : walkSpeed; + } + + private double GetWingMovementSpeed(Item? wings, double runSpeed, double fastWingSpeed) + { + return this.IsFastWing(wings) ? fastWingSpeed : runSpeed; + } + + private bool IsInClientSafezone(Point? position = null) + { + var checkedPosition = position ?? this.Position; + return this.CurrentMap?.Terrain.SafezoneMap[checkedPosition.X, checkedPosition.Y] ?? false; + } + + private bool HasEquippedWings(Item? item) + { + return item is { Durability: > 0.0 } + && item.ItemSlot == InventoryConstants.WingsSlot; + } + + private bool IsFastWing(Item? item) + { + return this.IsItem(item, 12, 5) + || this.IsItem(item, 12, 36); + } + + private bool HasRunningGear() + { + var slot = this.IsSwimmingMovementMap() + ? InventoryConstants.GlovesSlot + : InventoryConstants.BootsSlot; + return this.Inventory?.GetItem(slot) is { Durability: > 0.0, Level: >= RunningGearMinimumLevel }; + } + + private bool IsSwimmingMovementMap() + { + return this.CurrentMap?.MapId is AtlansMapNumber + or >= Kalima1MapNumber and <= Kalima6MapNumber + or Kalima7MapNumber + or Doppelgaenger3MapNumber; + } + + private bool IsItem(Item? item, short group, short number) + { + return item is { Durability: > 0.0 } + && item.Definition is { } definition + && definition.Group == group + && definition.Number == number; + } + + private bool HasFenrirMovementOption(Item? item) + { + return item?.ItemOptions.Any(option => + option.ItemOption?.OptionType == ItemOptionTypes.BlueFenrir + || option.ItemOption?.OptionType == ItemOptionTypes.BlackFenrir + || option.ItemOption?.OptionType == ItemOptionTypes.GoldFenrir) ?? false; } private async ValueTask GetSpawnGateOfCurrentMapAsync() diff --git a/src/GameLogic/Walker.cs b/src/GameLogic/Walker.cs index 1e92989dd..6dc57574c 100644 --- a/src/GameLogic/Walker.cs +++ b/src/GameLogic/Walker.cs @@ -16,7 +16,6 @@ namespace MUnique.OpenMU.GameLogic; public sealed class Walker : IDisposable { private readonly ISupportWalk _walkSupporter; - private readonly Func _stepDelay; private readonly Queue _nextSteps = new(5); /// @@ -39,10 +38,10 @@ public sealed class Walker : IDisposable /// /// The walk supporter. /// The delay between performing a step. - public Walker(ISupportWalk walkSupporter, Func stepDelay) + public Walker(ISupportWalk walkSupporter, Func stepDelay) { this._walkSupporter = walkSupporter; - this._stepDelay = stepDelay; + this.StepDelay = stepDelay; this._walkLock = new AsyncReaderWriterLock(); } @@ -51,6 +50,8 @@ public Walker(ISupportWalk walkSupporter, Func stepDelay) /// public Point CurrentTarget { get; private set; } + private Func StepDelay { get; } + /// /// Initializes a new walk to the specified target with the specified steps. /// @@ -180,15 +181,18 @@ public void Dispose() private async Task WalkLoopAsync(CancellationToken cancellationToken) { - var delay = this._stepDelay().Subtract(TimeSpan.FromMilliseconds(50)); - // Task.Delay might take longer than we specify. We need to compensate that. var lastOffset = TimeSpan.Zero; while (!cancellationToken.IsCancellationRequested) { var sw = Stopwatch.StartNew(); - await this.WalkStepAsync(cancellationToken).ConfigureAwait(false); + var step = await this.WalkStepAsync(cancellationToken).ConfigureAwait(false); + if (step is null) + { + continue; + } + var delay = this.StepDelay(step); var nextDelay = delay - lastOffset; if (nextDelay > TimeSpan.Zero) { @@ -207,14 +211,14 @@ private async Task WalkLoopAsync(CancellationToken cancellationToken) /// /// Performs the next step of a walk. /// - private async ValueTask WalkStepAsync(CancellationToken cancellationToken) + private async ValueTask WalkStepAsync(CancellationToken cancellationToken) { try { if (this._isDisposed) { Debug.WriteLine("walker already disposed"); - return; + return null; } bool stop; @@ -226,13 +230,13 @@ private async ValueTask WalkStepAsync(CancellationToken cancellationToken) if (stop) { await this.StopAsync().ConfigureAwait(false); - return; + return null; } // Update new coords using (await this._walkLock.WriterLockAsync(cancellationToken)) { - this.WalkNextStepIfStepAvailable(); + return this.WalkNextStepIfStepAvailable(); } } catch (OperationCanceledException) @@ -243,13 +247,15 @@ private async ValueTask WalkStepAsync(CancellationToken cancellationToken) { Debug.Fail(ex.Message, ex.StackTrace); } + + return null; } - private void WalkNextStepIfStepAvailable() + private WalkingStep? WalkNextStepIfStepAvailable() { if (this.ShouldWalkerStop()) { - return; + return null; } var nextStep = this._nextSteps.Dequeue(); @@ -259,7 +265,9 @@ private void WalkNextStepIfStepAvailable() { rotatable.Rotation = nextStep.Direction; } + + return nextStep; } private bool ShouldWalkerStop() => !((this._walkSupporter as IAttackable)?.IsActive() ?? false) || this._nextSteps.Count <= 0; -} \ No newline at end of file +} diff --git a/src/GameServer/RemoteView/World/ObjectMovedPlugIn.cs b/src/GameServer/RemoteView/World/ObjectMovedPlugIn.cs index 95072df92..c3361e1d4 100644 --- a/src/GameServer/RemoteView/World/ObjectMovedPlugIn.cs +++ b/src/GameServer/RemoteView/World/ObjectMovedPlugIn.cs @@ -40,7 +40,7 @@ public class ObjectMovedPlugIn : IObjectMovedPlugIn /// Gets or sets a value indicating whether the directions provided by should be send when an object moved. /// This is usually not required, because the game client calculates a proper path anyway and doesn't use the suggested path. /// - public bool SendWalkDirections { get; set; } = true; + public bool SendWalkDirections { get; set; } /// public async ValueTask ObjectMovedAsync(ILocateable obj, MoveType type) @@ -95,7 +95,7 @@ protected virtual async ValueTask SendWalkAsync(IConnection connection, ushort o { int Write() { - var stepsSize = steps.Length == 0 ? 1 : (steps.Length / 2) + 2; + var stepsSize = stepsLength == 0 ? 0 : (stepsLength / 2) + 2; var size = ObjectWalkedRef.GetRequiredSize(stepsSize); var span = connection.Output.GetSpan(size)[..size]; @@ -109,7 +109,7 @@ int Write() StepCount = (byte)stepsLength, }; - this.SetStepData(walkPacket, steps.Span, stepsSize); + this.SetStepData(walkPacket, steps.Span[..stepsLength], stepsSize); return size; } @@ -221,4 +221,4 @@ protected byte GetWalkCode() return (byte)PacketType.Walk; } } -} \ No newline at end of file +} diff --git a/src/GameServer/RemoteView/World/ObjectMovedPlugInExtended.cs b/src/GameServer/RemoteView/World/ObjectMovedPlugInExtended.cs index 85535a1f1..4e550cddd 100644 --- a/src/GameServer/RemoteView/World/ObjectMovedPlugInExtended.cs +++ b/src/GameServer/RemoteView/World/ObjectMovedPlugInExtended.cs @@ -40,7 +40,7 @@ protected override async ValueTask SendWalkAsync(IConnection connection, ushort { int Write() { - var stepsSize = steps.Length == 0 ? 1 : (steps.Length / 2) + 2; + var stepsSize = stepsLength == 0 ? 0 : (stepsLength / 2) + 2; var size = ObjectWalkedExtended.GetRequiredSize(stepsSize); var span = connection.Output.GetSpan(size)[..size]; @@ -56,7 +56,7 @@ int Write() StepCount = (byte)stepsLength, }; - this.SetStepData(walkPacket, steps.Span, stepsSize); + this.SetStepData(walkPacket, steps.Span[..stepsLength], stepsSize); return size; } @@ -79,4 +79,4 @@ private void SetStepData(ObjectWalkedExtendedRef walkPacket, Span ste walkPacket.StepData[index] = (byte)(firstStep << 4 | secondStep); } } -} \ No newline at end of file +} diff --git a/src/Persistence/Initialization/Skills/BlowOfDestructionEffectInitializer.cs b/src/Persistence/Initialization/Skills/BlowOfDestructionEffectInitializer.cs new file mode 100644 index 000000000..21304e419 --- /dev/null +++ b/src/Persistence/Initialization/Skills/BlowOfDestructionEffectInitializer.cs @@ -0,0 +1,38 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence.Initialization.Skills; + +using MUnique.OpenMU.DataModel.Attributes; +using MUnique.OpenMU.DataModel.Configuration; + +/// +/// Initializer for the blow of destruction effect which results from the strike of destruction skill. +/// +public class BlowOfDestructionEffectInitializer : InitializerBase +{ + /// + /// Initializes a new instance of the class. + /// + /// The context. + /// The game configuration. + public BlowOfDestructionEffectInitializer(IContext context, GameConfiguration gameConfiguration) + : base(context, gameConfiguration) + { + } + + /// + public override void Initialize() + { + var magicEffect = this.Context.CreateNew(); + this.GameConfiguration.MagicEffects.Add(magicEffect); + magicEffect.Number = (short)MagicEffectNumber.BlowOfDestruction; + magicEffect.Name = "Blow of Destruction Effect (Strike of Destruction)"; + magicEffect.InformObservers = true; + magicEffect.SendDuration = true; + magicEffect.StopByDeath = true; + magicEffect.Duration = this.Context.CreateNew(); + magicEffect.Duration.ConstantValue.Value = (float)TimeSpan.FromSeconds(10).TotalSeconds; + } +} diff --git a/src/Persistence/Initialization/Skills/MagicEffectNumber.cs b/src/Persistence/Initialization/Skills/MagicEffectNumber.cs index a38d8cb9f..10c99418e 100644 --- a/src/Persistence/Initialization/Skills/MagicEffectNumber.cs +++ b/src/Persistence/Initialization/Skills/MagicEffectNumber.cs @@ -296,6 +296,11 @@ internal enum MagicEffectNumber : short /// WizEnhance = 0x52, + /// + /// The blow of destruction effect. + /// + BlowOfDestruction = 0x56, + /// /// The ignore defense effect of the rage fighter. /// @@ -348,4 +353,4 @@ internal enum MagicEffectNumber : short #endregion -} \ No newline at end of file +} diff --git a/src/Persistence/Initialization/VersionSeasonSix/SkillsInitializer.cs b/src/Persistence/Initialization/VersionSeasonSix/SkillsInitializer.cs index 9f544a025..2de69d60c 100644 --- a/src/Persistence/Initialization/VersionSeasonSix/SkillsInitializer.cs +++ b/src/Persistence/Initialization/VersionSeasonSix/SkillsInitializer.cs @@ -71,6 +71,7 @@ internal class SkillsInitializer : SkillsInitializerBase { SkillNumber.Weakness, MagicEffectNumber.WeaknessSummoner }, { SkillNumber.Innovation, MagicEffectNumber.Innovation }, { SkillNumber.DamageReflection, MagicEffectNumber.Reflection }, + { SkillNumber.StrikeofDestruction, MagicEffectNumber.BlowOfDestruction }, { SkillNumber.BeastUppercut, MagicEffectNumber.DefenseReductionBeastUppercut }, { SkillNumber.PhoenixShot, MagicEffectNumber.DecreaseBlock }, { SkillNumber.Explosion223, MagicEffectNumber.Explosion }, @@ -688,6 +689,7 @@ private void InitializeEffects() new WeaknessSummonerEffectInitializer(this.Context, this.GameConfiguration).Initialize(); new InnovationEffectInitializer(this.Context, this.GameConfiguration).Initialize(); new ReflectionEffectInitializer(this.Context, this.GameConfiguration).Initialize(); + new BlowOfDestructionEffectInitializer(this.Context, this.GameConfiguration).Initialize(); new DefenseReductionBeastUppercutEffectInitializer(this.Context, this.GameConfiguration).Initialize(); new DecreaseBlockEffectInitializer(this.Context, this.GameConfiguration).Initialize(); new ExplosionEffectInitializer(this.Context, this.GameConfiguration).Initialize();