diff --git a/src/GameLogic/LocateableExtensions.cs b/src/GameLogic/LocateableExtensions.cs index cf4602500..09aba8097 100644 --- a/src/GameLogic/LocateableExtensions.cs +++ b/src/GameLogic/LocateableExtensions.cs @@ -163,6 +163,7 @@ public static bool IsAtSafezone(this ILocateable obj) return true; } - return map.Terrain.SafezoneMap[obj.Position.X, obj.Position.Y]; + var position = obj.Position; + return map.Terrain.SafezoneMap[position.X, position.Y]; } -} \ No newline at end of file +} diff --git a/src/GameLogic/Player.cs b/src/GameLogic/Player.cs index ed06901e0..a94e4e9d7 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; @@ -52,7 +53,7 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke private readonly AsyncLock _moveLock = new(); private readonly AsyncLock _experienceLock = new(); - private readonly Walker _walker; + private readonly ProjectedWalker _projectedWalker; private readonly AppearanceDataAdapter _appearanceData; @@ -89,7 +90,11 @@ public Player(IGameContext gameContext) this.GameContext = gameContext; this.Logger = gameContext.LoggerFactory.CreateLogger(); this.PersistenceContext = this.GameContext.PersistenceContextProvider.CreateNewPlayerContext(gameContext.Configuration); - this._walker = new Walker(this, this.GetStepDelay); + this._projectedWalker = new ProjectedWalker( + this.GetStoredPosition, + this.SetStoredPosition, + this.GetStepDuration, + this.RaiseAttackableMoved); this.MagicEffectList = new MagicEffectsList(this); this._appearanceData = new AppearanceDataAdapter(this); @@ -145,13 +150,13 @@ public Player(IGameContext gameContext) public bool CanWalkOnSafezone => true; /// - public bool IsWalking => this._walker.CurrentTarget != default; + public bool IsWalking => this._projectedWalker.IsWalking; /// - public TimeSpan StepDelay => this.GetStepDelay(); + public TimeSpan StepDelay => this.GetStepDuration(null); /// - public Point WalkTarget => this._walker.CurrentTarget; + public Point WalkTarget => this._projectedWalker.CurrentTarget; /// /// Gets a value indicating whether this instance is invisible to other players. @@ -380,17 +385,9 @@ private set /// public Point Position { - get => new(this.SelectedCharacter?.PositionX ?? 0, this.SelectedCharacter?.PositionY ?? 0); + get => this._projectedWalker.Position; - set - { - if (this.Position != value && this.SelectedCharacter is { } character) - { - character.PositionX = value.X; - character.PositionY = value.Y; - this.GameContext.PlugInManager?.GetPlugInPoint()?.AttackableMoved(this); - } - } + set => this._projectedWalker.Position = value; } /// @@ -715,6 +712,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) { @@ -816,7 +818,7 @@ public async Task TeleportAsync(Point target, Skill teleportSkill) { await (this.SkillCancelTokenSource?.CancelAsync() ?? Task.CompletedTask).ConfigureAwait(false); - await this._walker.StopAsync().ConfigureAwait(false); + this.StopProjectedWalk(); var previous = this.Position; this.Position = target; @@ -866,7 +868,7 @@ public async Task TeleportToMapAsync(GameMap targetMap, Point targetPoint) { await (this.SkillCancelTokenSource?.CancelAsync() ?? Task.CompletedTask).ConfigureAwait(false); - await this._walker.StopAsync().ConfigureAwait(false); + this.StopProjectedWalk(); await this.ForEachWorldObserverAsync(p => p.ObjectsOutOfScopeAsync(this.GetAsEnumerable()), false).ConfigureAwait(false); @@ -1331,7 +1333,7 @@ private async ValueTask AddExperienceCoreAsync(int experience, IAttackable? kill public async ValueTask MoveAsync(Point target) { this.Logger.LogDebug("MoveAsync: Player is moving to {0}", target); - await this._walker.StopAsync().ConfigureAwait(false); + this.StopProjectedWalk(); await this.CurrentMap!.MoveAsync(this, target, this._moveLock, MoveType.Instant).ConfigureAwait(false); this.Logger.LogDebug("MoveAsync: Observer Count: {0}", this.Observers.Count); } @@ -1364,7 +1366,7 @@ public async ValueTask WalkToAsync(Point target, Memory steps) return; } - await this._walker.StopAsync().ConfigureAwait(false); + this.StopProjectedWalk(); var startPoint = steps.Span[0].From; var currentPosition = this.Position; @@ -1379,20 +1381,19 @@ public async ValueTask WalkToAsync(Point target, Memory steps) return; } - var canWalkToTarget = currentMap.Terrain.WalkMap[target.X, target.Y]; - if (canWalkToTarget) + if (ProjectedWalker.TryValidateWalkSteps(currentMap.Terrain, steps, target, out var invalidPoint)) { this.Logger.LogDebug("WalkToAsync: Player is walking to {0}", target); - var token = await this._walker.InitializeWalkToAsync(target, steps).ConfigureAwait(false); + this.Position = startPoint; + this.InitializeProjectedWalk(target, steps); await currentMap.MoveAsync(this, target, this._moveLock, MoveType.Walk).ConfigureAwait(false); - await this._walker.StartWalkAsync(token).ConfigureAwait(false); this.Logger.LogDebug("WalkToAsync: Observer Count: {0}", this.Observers.Count); } else { - this.Logger.LogWarning("WalkToAsync: Player requested to walk to {0}, but it's not an allowed target", target); + this.Logger.LogWarning("WalkToAsync: Player requested an invalid walk to {0}. Invalid point: {1}", target, invalidPoint); // We'll send the current coordinates back to the client, so it doesn't appear in the invalid coordinates. await this.InvokeViewPlugInAsync(p => p.ObjectMovedAsync(this, MoveType.Instant)).ConfigureAwait(false); @@ -1400,13 +1401,65 @@ public async ValueTask WalkToAsync(Point target, Memory steps) } /// - public ValueTask GetDirectionsAsync(Memory directions) => this._walker.GetDirectionsAsync(directions); + public ValueTask GetDirectionsAsync(Memory directions) => this._projectedWalker.GetDirectionsAsync(directions); /// - public ValueTask GetStepsAsync(Memory steps) => this._walker.GetStepsAsync(steps); + public ValueTask GetStepsAsync(Memory steps) => this._projectedWalker.GetStepsAsync(steps); /// - public ValueTask StopWalkingAsync() => this._walker.StopAsync(); + public ValueTask StopWalkingAsync() + { + this.StopProjectedWalk(); + return ValueTask.CompletedTask; + } + + private void InitializeProjectedWalk(Point target, Memory steps) + { + this._projectedWalker.Initialize(target, steps); + } + + private void StopProjectedWalk() + { + this._projectedWalker.Stop(); + } + + private Point GetStoredPosition() + { + return new(this.SelectedCharacter?.PositionX ?? 0, this.SelectedCharacter?.PositionY ?? 0); + } + + private bool SetStoredPosition(Point position) + { + if (this.SelectedCharacter is not { } character) + { + return false; + } + + if (character.PositionX == position.X && character.PositionY == position.Y) + { + return false; + } + + character.PositionX = position.X; + character.PositionY = position.Y; + return true; + } + + private void RaiseAttackableMoved() + { + this.GameContext.PlugInManager?.GetPlugInPoint()?.AttackableMoved(this); + } + + 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 . @@ -1884,7 +1937,7 @@ protected override async ValueTask DisposeAsyncCore() await this.RemoveFromCurrentMapAsync().ConfigureAwait(false); await this._observerToWorldViewAdapter.ClearObservingObjectsListAsync().ConfigureAwait(false); this._observerToWorldViewAdapter.Dispose(); - this._walker.Dispose(); + this.StopProjectedWalk(); await this.MagicEffectList.DisposeAsync().ConfigureAwait(false); this._respawnAfterDeathCts?.Dispose(); (this._viewPlugIns as IDisposable)?.Dispose(); @@ -1962,7 +2015,7 @@ private async ValueTask TryRemoveFromCurrentMapAsync(bool willRespawnOnSam this.IsAlive = false; this.IsTeleporting = false; - await this._walker.StopAsync().ConfigureAwait(false); + this.StopProjectedWalk(); await this._observerToWorldViewAdapter.ClearObservingObjectsListAsync().ConfigureAwait(false); if (this.Summon?.Item1 is { IsAlive: true } summon) { @@ -2110,16 +2163,83 @@ private async ValueTask RegenerateHeroStateAsync() /// Gets the step delay depending on the equipped items. /// /// The current step delay, depending on equipped items. - private TimeSpan GetStepDelay() + private TimeSpan GetStepDuration(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; + const double runSpeed = 15.0; + const double fastWingSpeed = 16.0; + const double horseOrFenrirRunSpeed = 17.0; + const double excellentFenrirRunSpeed = 19.0; + + if (this.IsInClientSafezone(position)) + { + return walkSpeed; + } + + var pet = this.Inventory?.GetItem(InventoryConstants.PetSlot); + if (IsItem(pet, 13, 37)) + { + return HasFenrirMovementOption(pet) ? excellentFenrirRunSpeed : horseOrFenrirRunSpeed; + } + + if (IsItem(pet, 13, 4)) { - // Wings - return TimeSpan.FromMilliseconds(300); + return horseOrFenrirRunSpeed; } - // TODO: Consider pets etc. - return TimeSpan.FromMilliseconds(500); + var wings = this.Inventory?.GetItem(InventoryConstants.WingsSlot); + if (HasEquippedWings(wings) || IsItem(pet, 13, 2) || IsItem(pet, 13, 3)) + { + return IsFastWing(wings) ? fastWingSpeed : runSpeed; + } + + return runSpeed; + } + + private bool IsInClientSafezone(Point? position = null) + { + var checkedPosition = position ?? this.Position; + return this.CurrentMap?.Terrain.SafezoneMap[checkedPosition.X, checkedPosition.Y] ?? false; + } + + private static bool HasEquippedWings(Item? item) + { + return item is { Durability: > 0.0 } + && item.ItemSlot == InventoryConstants.WingsSlot; + } + + private static bool IsFastWing(Item? item) + { + return IsItem(item, 12, 5) // Wings of Dragon + || IsItem(item, 12, 36); // Wing of Storm + } + + private static 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 static 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() @@ -2254,7 +2374,7 @@ private async ValueTask OnDeathAsync(IAttacker? killer) return; } - await this._walker.StopAsync().ConfigureAwait(false); + this.StopProjectedWalk(); this.IsAlive = false; this._respawnAfterDeathCts = new CancellationTokenSource(); await this.ForEachWorldObserverAsync(p => p.ObjectGotKilledAsync(this, killer), true).ConfigureAwait(false); diff --git a/src/GameLogic/ProjectedWalker.cs b/src/GameLogic/ProjectedWalker.cs new file mode 100644 index 000000000..741c2ccd0 --- /dev/null +++ b/src/GameLogic/ProjectedWalker.cs @@ -0,0 +1,305 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic; + +using System; +using System.Diagnostics; +using MUnique.OpenMU.Pathfinding; + +/// +/// Projects walking state based on the elapsed time since a walk started. +/// +internal sealed class ProjectedWalker +{ + private readonly object _walkStateLock = new(); + private readonly WalkingStep[] _currentWalkSteps = new WalkingStep[16]; + private readonly TimeSpan[] _currentWalkStepEndTimes = new TimeSpan[16]; + private readonly Func _getStoredPosition; + private readonly Func _setStoredPosition; + private readonly Func _getStepDuration; + private readonly Action _positionChanged; + private int _currentWalkStepCount; + private long _walkStartedTimestamp; + private Point _walkTarget; + private Point _lastNotifiedPosition; + + /// + /// Initializes a new instance of the class. + /// + /// Gets the persisted position of the walk supporter. + /// Sets the persisted position of the walk supporter and returns if it changed. + /// Gets the duration of the specified walking step. + /// Called when the persisted position changed. + public ProjectedWalker( + Func getStoredPosition, + Func setStoredPosition, + Func getStepDuration, + Action positionChanged) + { + this._getStoredPosition = getStoredPosition; + this._setStoredPosition = setStoredPosition; + this._getStepDuration = getStepDuration; + this._positionChanged = positionChanged; + } + + /// + /// Gets a value indicating whether a walk is currently projected. + /// + public bool IsWalking + { + get + { + this.Refresh(); + lock (this._walkStateLock) + { + return this._currentWalkStepCount > 0; + } + } + } + + /// + /// Gets the current walk target coordinate. + /// + public Point CurrentTarget + { + get + { + this.Refresh(); + lock (this._walkStateLock) + { + return this._walkTarget; + } + } + } + + /// + /// Gets or sets the projected position. + /// + public Point Position + { + get + { + this.Refresh(); + lock (this._walkStateLock) + { + return this.GetProjectedPositionNoLock(out _); + } + } + + set + { + bool positionChanged; + lock (this._walkStateLock) + { + this.ClearNoLock(); + this._lastNotifiedPosition = value; + positionChanged = this._setStoredPosition(value); + } + + this.NotifyPositionChanged(positionChanged); + } + } + + /// + /// Initializes a projected walk to the specified target. + /// + /// The target coordinate. + /// The walk steps. + public void Initialize(Point target, Memory steps) + { + if (steps.Length > this._currentWalkSteps.Length) + { + throw new ArgumentException("Maximum number of steps (16) exceeded.", nameof(steps)); + } + + lock (this._walkStateLock) + { + var elapsed = TimeSpan.Zero; + this.ClearNoLock(); + this._lastNotifiedPosition = this._getStoredPosition(); + + for (var i = 0; i < steps.Length; i++) + { + var step = steps.Span[i]; + this._currentWalkSteps[i] = step; + elapsed += this._getStepDuration(step); + this._currentWalkStepEndTimes[i] = elapsed; + } + + this._walkTarget = target; + this._walkStartedTimestamp = Stopwatch.GetTimestamp(); + this._currentWalkStepCount = steps.Length; + } + } + + /// + /// Stops the projected walk and persists the current projected position. + /// + public void Stop() + { + bool positionChanged; + lock (this._walkStateLock) + { + var currentPosition = this.GetProjectedPositionNoLock(out _); + positionChanged = this.MarkProjectedPositionNoLock(currentPosition); + this.ClearNoLock(); + positionChanged |= this._setStoredPosition(currentPosition); + } + + this.NotifyPositionChanged(positionChanged); + } + + /// + /// Gets the directions of the steps which are about to happen next by writing them into the given span. + /// + /// The directions. + /// The number of written directions. + public ValueTask GetDirectionsAsync(Memory directions) + { + this.Refresh(); + var count = 0; + lock (this._walkStateLock) + { + foreach (var step in this._currentWalkSteps[..this._currentWalkStepCount]) + { + directions.Span[count] = step.Direction; + count++; + } + } + + return ValueTask.FromResult(count); + } + + /// + /// Gets the steps which are about to happen next by writing them into the given span. + /// + /// The steps. + /// The number of written steps. + public ValueTask GetStepsAsync(Memory steps) + { + this.Refresh(); + var count = 0; + lock (this._walkStateLock) + { + foreach (var step in this._currentWalkSteps[..this._currentWalkStepCount]) + { + steps.Span[count] = step; + count++; + } + } + + return ValueTask.FromResult(count); + } + + /// + /// Validates that the specified steps represent a coherent walk over walkable terrain. + /// + /// The terrain. + /// The steps. + /// The expected target. + /// The invalid point. + /// true, if the steps are valid; otherwise, false. + public static bool TryValidateWalkSteps(GameMapTerrain terrain, Memory steps, Point target, out Point invalidPoint) + { + var span = steps.Span; + var expectedFrom = span[0].From; + invalidPoint = expectedFrom; + + if (!terrain.WalkMap[expectedFrom.X, expectedFrom.Y]) + { + return false; + } + + foreach (var step in span) + { + if (step.From != expectedFrom || step.Direction == Direction.Undefined) + { + invalidPoint = step.From; + return false; + } + + var calculatedTarget = step.From.CalculateTargetPoint(step.Direction); + if (step.To != calculatedTarget || !terrain.WalkMap[step.To.X, step.To.Y]) + { + invalidPoint = step.To; + return false; + } + + expectedFrom = step.To; + } + + invalidPoint = expectedFrom; + return expectedFrom == target; + } + + private void Refresh() + { + bool positionChanged; + lock (this._walkStateLock) + { + if (this._currentWalkStepCount == 0) + { + return; + } + + var currentPosition = this.GetProjectedPositionNoLock(out var completed); + positionChanged = this.MarkProjectedPositionNoLock(currentPosition); + if (completed) + { + this.ClearNoLock(); + positionChanged |= this._setStoredPosition(currentPosition); + } + } + + this.NotifyPositionChanged(positionChanged); + } + + private Point GetProjectedPositionNoLock(out bool completed) + { + completed = false; + if (this._currentWalkStepCount == 0) + { + return this._getStoredPosition(); + } + + var elapsed = Stopwatch.GetElapsedTime(this._walkStartedTimestamp); + for (var i = 0; i < this._currentWalkStepCount; i++) + { + if (elapsed < this._currentWalkStepEndTimes[i]) + { + return this._currentWalkSteps[i].From; + } + } + + completed = true; + return this._walkTarget; + } + + private void ClearNoLock() + { + this._currentWalkStepCount = 0; + this._walkStartedTimestamp = 0; + this._walkTarget = default; + } + + private bool MarkProjectedPositionNoLock(Point position) + { + if (this._lastNotifiedPosition == position) + { + return false; + } + + this._lastNotifiedPosition = position; + return true; + } + + private void NotifyPositionChanged(bool positionChanged) + { + if (positionChanged) + { + this._positionChanged(); + } + } +} diff --git a/src/GameServer/RemoteView/World/ObjectMovedPlugIn.cs b/src/GameServer/RemoteView/World/ObjectMovedPlugIn.cs index 95072df92..d7e5a8af0 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 : (steps.Length / 2) + 2; var size = ObjectWalkedRef.GetRequiredSize(stepsSize); var span = connection.Output.GetSpan(size)[..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..4b9882b08 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 : (steps.Length / 2) + 2; var size = ObjectWalkedExtended.GetRequiredSize(stepsSize); var span = connection.Output.GetSpan(size)[..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/tests/MUnique.OpenMU.Tests/CharacterMoveTest.cs b/tests/MUnique.OpenMU.Tests/CharacterMoveTest.cs index da671b80b..8d083050f 100644 --- a/tests/MUnique.OpenMU.Tests/CharacterMoveTest.cs +++ b/tests/MUnique.OpenMU.Tests/CharacterMoveTest.cs @@ -42,7 +42,6 @@ public async ValueTask TestWalkStepsAreCorrectAsync() Assert.That(count, Is.EqualTo(4)); steps = steps.Slice(0, count); - steps.Span.Reverse(); Assert.That(steps.Span[0].From, Is.EqualTo(StartPoint)); Assert.That(steps.Span[steps.Length - 1].To, Is.EqualTo(EndPoint)); for (var index = 0; index < steps.Span.Length; index++) @@ -69,4 +68,4 @@ private async ValueTask DoTheWalkAsync() return player; } -} \ No newline at end of file +}