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();