diff --git a/docs/PlayerRefactoringPlan.md b/docs/PlayerRefactoringPlan.md index 5d123ee99..4c76de11a 100644 --- a/docs/PlayerRefactoringPlan.md +++ b/docs/PlayerRefactoringPlan.md @@ -1,6 +1,6 @@ # Refactoring plan: breaking up the `Player` class -**Status:** in progress — phases 0b, 1, 2 and 3 are done, see §6 +**Status:** in progress — phases 0b, 1, 2, 3 and 4 are done, see §6 **Subject:** `src/GameLogic/Player.cs` (3,098 lines and ~150 members when this plan was written; 2,614 after phase 1) @@ -220,7 +220,7 @@ All of these live in `src/GameLogic/PlugIns/`, need a fresh `Guid` and a | # | Interface | Signature | Logic that moves there | |---|---|---|---| | P3 | `IPlayerLeavingGamePlugIn` | `ValueTask PlayerLeavingGameAsync(Player player)` | `RemoveFromGameAsync` steps (1840-1856): party leave-temporarily, safezone move, temporary storage restore, `OpenedNpc` reset. **Only if** the `LogoutAction` state bug above is not fixed — with it fixed, `IPlayerStateChangedPlugIn` covers this too, and P3 is dropped | -| P4 | `IPlayerSpawnGateSelectionPlugIn` | `ValueTask SelectSpawnGateAsync(Player player, SpawnGateSelectionArgs args)` | the duel and guild-war-soccer branches of `GetSpawnGateOfCurrentMapAsync` (2409-2428); later also mini games and castle siege | +| P4 | `IPlayerSpawnGateSelectionPlugIn` | `ValueTask SelectSpawnGateAsync(Player player, SpawnGateSelectionArgs args)` | ✔ implemented: the duel and guild-war-soccer branches of `GetSpawnGateOfCurrentMapAsync` are now `DuelSpawnGatePlugIn` and `SoccerSpawnGatePlugIn`; mini games and castle siege can follow | | P5 | `IExperienceCalculationPlugIn` | `ValueTask CalculateExperienceAsync(Player player, ExperienceCalculationArgs args)` | ✔ implemented, but *not* by splitting the multiplier chain of `CalculateExpAfterKill` into competing plugins: without plugin ordering (3.1a) their result would depend on the discovery order, and the random multiplier truncates to `int`, so it has to stay last. The rates and the map multiplier stay in the component, the plugin point runs after them and before the randomization, where factors compose order-independently | | P6 | `IPlayerGainedExperiencePlugIn` | `ValueTask PlayerGainedExperienceAsync(Player player, int experience, IAttackable? killedObject, ExperienceType type)` | `AddPetExperienceAsync` (2953-3020) becomes `PetExperiencePlugIn`; also the natural hook for statistics/events | | P7 | `ICharacterMasterLevelUpPlugIn` | `void CharacterMasterLeveledUp(Player player)` | mirrors the existing `ICharacterLevelUpPlugIn` for the master level branch (2099-2107) | @@ -341,7 +341,7 @@ Each phase is independently mergeable and leaves the build green. | **1** | ✔ done — §5.1 extension-method moves + nested class files | very low | 2,612 | | **2** | ✔ done — `PlayerMovement`, `PlayerPersistence`, `PlayerSummon`, `PlayerStorages` components | low | 2,269 | | **3** | ✔ done — `PlayerExperience` + P5/P6/P7 + `PetExperiencePlugIn` | medium | 2,058 | -| **4** | `PlayerMapTransitions` + P4 spawn gate plugins | medium | ~1,550 | +| **4** | ✔ done — `PlayerMapTransitions` + P4 spawn gate plugins | medium | 1,828 | | **5** | `PlayerCombat` + `AttackableNpcBase` dedup + P8 and the `IAttackableGotHit`/`GotKilled` plugins (PK state, respawn, reflection, durability) | **high** | ~1,150 | | **6** | Enter-world / leave-game moved onto `IPlayerStateChangedPlugIn` (+ P3 if needed) + `PlayerAttributeHost` + P9 regeneration | medium-high | ~900 | | **7** | §5.4 property consolidation, final cleanup of `virtual` members nobody overrides | low | ~700-800 | diff --git a/src/GameLogic/Player.cs b/src/GameLogic/Player.cs index 6d9d6e3a6..9c4d32c35 100644 --- a/src/GameLogic/Player.cs +++ b/src/GameLogic/Player.cs @@ -59,6 +59,8 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke private readonly PlayerSummon _summon; + private readonly PlayerMapTransitions _mapTransitions; + private readonly PlayerStorages _storages; private readonly PlayerAppearanceData _appearanceData; @@ -101,6 +103,7 @@ public Player(IGameContext gameContext) this._movement = new PlayerMovement(this); this._summon = new PlayerSummon(this); this._storages = new PlayerStorages(this); + this._mapTransitions = new PlayerMapTransitions(this, this._movement, this._summon); this.MagicEffectList = new MagicEffectsList(this); this._appearanceData = new PlayerAppearanceData(this); @@ -305,7 +308,7 @@ public GameMap? CurrentMap { get => this._currentMap; - private set + internal set { if (this._currentMap != value) { @@ -388,7 +391,7 @@ private set public bool IsAlive { get; set; } /// - public bool IsTeleporting { get; private set; } + public bool IsTeleporting { get; internal set; } /// public DeathInformation? LastDeath { get; private set; } @@ -610,7 +613,7 @@ public async ValueTask SetSelectedCharacterAsync(Character? character) await duelRoom.CancelDuelAsync().ConfigureAwait(false); if (this.GameContext.Configuration.DuelConfiguration?.Exit is { } exit) { - await this.PlaceAtGateAsync(exit).ConfigureAwait(false); + await this._mapTransitions.PlaceAtGateAsync(exit).ConfigureAwait(false); } } @@ -762,98 +765,14 @@ public ValueTask ApplyBleedingDamageAsync(IAttacker initialAttacker, uint damage /// /// The target. /// The teleport skill. - public async Task TeleportAsync(Point target, Skill teleportSkill) - { - if (!this.IsAlive) - { - return; - } - - this.IsTeleporting = true; - try - { - await (this.SkillCancelTokenSource?.CancelAsync() ?? Task.CompletedTask).ConfigureAwait(false); - - await this._movement.StopWalkingAsync().ConfigureAwait(false); - - if (this.GameContext.PlugInManager.GetPlugInPoint() is { } speedCheck) - { - await speedCheck.ResetMovementStateAsync(this).ConfigureAwait(false); - } - - var previous = this.Position; - this.Position = target; - - await this.ForEachWorldObserverAsync(p => p.ShowSkillAnimationAsync(this, this, teleportSkill, true), true).ConfigureAwait(false); - - await Task.Delay(300).ConfigureAwait(false); - - await this.ForEachWorldObserverAsync(p => p.ObjectsOutOfScopeAsync(this.GetAsEnumerable()), false).ConfigureAwait(false); - - await Task.Delay(1500).ConfigureAwait(false); - - if (this.IsAlive) - { - await this.InvokeViewPlugInAsync(p => p.ShowTeleportedAsync()).ConfigureAwait(false); - - // We need to restore the previous position to make the Moving on the map data structure work correctly. - this.Position = previous; - if (this.CurrentMap is { } map) - { - await this._movement.MoveOnMapAsync(map, target, MoveType.Teleport).ConfigureAwait(false); - } - } - } - catch (Exception e) - { - this.Logger.LogWarning(e, "Error during teleport"); - } - - this.IsTeleporting = false; - } + public Task TeleportAsync(Point target, Skill teleportSkill) => this._mapTransitions.TeleportAsync(target, teleportSkill); /// /// Teleports this player to the specified target map and point. /// /// The target map for teleportation. /// The target coordinate in the target map. - public async Task TeleportToMapAsync(GameMap targetMap, Point targetPoint) - { - if (!this.IsAlive) - { - return; - } - - this.IsTeleporting = true; - try - { - await (this.SkillCancelTokenSource?.CancelAsync() ?? Task.CompletedTask).ConfigureAwait(false); - - await this._movement.StopWalkingAsync().ConfigureAwait(false); - - await this.ForEachWorldObserverAsync(p => p.ObjectsOutOfScopeAsync(this.GetAsEnumerable()), false).ConfigureAwait(false); - - if (this.IsAlive) - { - ExitGate tempGate = new() - { - Map = targetMap.Definition, - X1 = targetPoint.X, - X2 = targetPoint.X, - Y1 = targetPoint.Y, - Y2 = targetPoint.Y, - }; - - await this.WarpToAsync(tempGate).ConfigureAwait(false); - } - } - catch (Exception e) - { - this.Logger.LogWarning(e, "Error during teleport"); - } - - this.IsTeleporting = false; - } + public Task TeleportToMapAsync(GameMap targetMap, Point targetPoint) => this._mapTransitions.TeleportToMapAsync(targetMap, targetPoint); /// /// Is called after the player killed a . @@ -877,73 +796,18 @@ public async ValueTask AfterKilledMonsterAsync() /// Moves the player to the specified gate. /// /// The gate to which the player should be moved. - public async ValueTask WarpToAsync(ExitGate gate) - { - var isRespawnOnSameMap = object.Equals(this.CurrentMap?.Definition, gate.Map); - if (!await this.TryRemoveFromCurrentMapAsync(isRespawnOnSameMap).ConfigureAwait(false)) - { - return; - } - - await this.PlaceAtGateAsync(gate).ConfigureAwait(false); - this.CurrentMap = null; // Will be set again, when the client acknowledged the map change by F3 12 packet. - - if (!this.PlayerState.CurrentState.IsDisconnectedOrFinished()) - { - await this.PlayerState.TryAdvanceToAsync(GameLogic.PlayerState.ChangingMap).ConfigureAwait(false); - await this.InvokeViewPlugInAsync(p => p.MapChangeAsync()).ConfigureAwait(false); - } - - // after this, the Client will send us a F3 12 packet, to tell us it loaded - // the map and is ready to receive the new meet player/monster etc. - // Then ClientReadyAfterMapChange is called. - } + public ValueTask WarpToAsync(ExitGate gate) => this._mapTransitions.WarpToAsync(gate); /// /// Moves the player to the safe zone. /// - public async ValueTask WarpToSafezoneAsync() => await this.WarpToAsync(await this.GetSpawnGateOfCurrentMapAsync().ConfigureAwait(false)).ConfigureAwait(false); + public ValueTask WarpToSafezoneAsync() => this._mapTransitions.WarpToSafezoneAsync(); /// /// Respawns the player to the specified gate. /// /// The gate at which the player should be respawned. - public virtual async ValueTask RespawnAtAsync(ExitGate gate) - { - var isRespawnOnSameMap = object.Equals(this.CurrentMap?.Definition, gate.Map); - - if (!await this.TryRemoveFromCurrentMapAsync(isRespawnOnSameMap).ConfigureAwait(false)) - { - return; - } - - this.ThrowNotInitializedProperty(this.SelectedCharacter is null, nameof(this.SelectedCharacter)); - this.SelectedCharacter.ThrowNotInitializedProperty(this.SelectedCharacter.CurrentMap is null, nameof(this.SelectedCharacter.CurrentMap)); - await this.PlaceAtGateAsync(gate).ConfigureAwait(false); - this._respawnAfterDeathCts?.Dispose(); - this._respawnAfterDeathCts = null; - - if (this.ViewPlugIns.GetPlugIn() is { } respawnPlugIn) - { - // Older clients use a separate packet for the respawn, while newer don't. - // It requires a slightly different logic. - this.CurrentMap = await this.GameContext.GetMapAsync(this.SelectedCharacter!.CurrentMap!.Number.ToUnsigned()).ConfigureAwait(false) ?? throw new InvalidOperationException("Current map not found."); - await respawnPlugIn.RespawnAsync().ConfigureAwait(false); - await this.PlayerState.TryAdvanceToAsync(GameLogic.PlayerState.EnteredWorld).ConfigureAwait(false); - this.IsAlive = true; - await this.CurrentMap!.AddAsync(this).ConfigureAwait(false); - } - else - { - this.CurrentMap = null; // Will be set again, when the client acknowledged the map change by F3 12 packet. - await this.PlayerState.TryAdvanceToAsync(GameLogic.PlayerState.ChangingMap).ConfigureAwait(false); - await this.InvokeViewPlugInAsync(p => p.MapChangeAsync()).ConfigureAwait(false); - - // after this, the Client will send us a F3 12 packet, to tell us it loaded - // the map and is ready to receive the new meet player/monster etc. - // Then ClientReadyAfterMapChange is called. - } - } + public virtual ValueTask RespawnAtAsync(ExitGate gate) => this._mapTransitions.RespawnAtAsync(gate); /// /// Signals that the game client of the player is ready after a map change (data has been loaded etc.). @@ -953,44 +817,7 @@ public virtual async ValueTask RespawnAtAsync(ExitGate gate) /// This method is called after the client sent us the F3 12 packet, or after /// the player entered the game. /// - public async ValueTask ClientReadyAfterMapChangeAsync() - { - this.ThrowNotInitializedProperty(this.SelectedCharacter is null, nameof(this.SelectedCharacter)); - this.SelectedCharacter.ThrowNotInitializedProperty(this.SelectedCharacter.CurrentMap is null, nameof(this.SelectedCharacter.CurrentMap)); - - if (this.CurrentMap is not null) - { - // Guard against a repeated F3 12 (client ready after map change) packet. - // A map change usually leaves CurrentMap null until this handler assigns it, - // so a non-null value means the handler already ran. The exception is the - // IRespawnAfterDeathPlugIn branch of RespawnAtAsync, which assigns CurrentMap - // and adds the player itself; a trailing packet is redundant there as well. - // Without this guard, a duplicate packet adds the player (and its summon) to - // the area of interest a second time, which the bucket does not deduplicate. - this.Logger.LogWarning("Ignoring client-ready packet: player {0} is already on map {1}.", this, this.CurrentMap); - return; - } - - if (this.CurrentMiniGame is { } currentMiniGame) - { - this.CurrentMap = currentMiniGame.Map; - } - else - { - this.CurrentMap = await this.GameContext.GetMapAsync(this.SelectedCharacter!.CurrentMap.Number.ToUnsigned()).ConfigureAwait(false); - } - - await this.PlayerState.TryAdvanceToAsync(GameLogic.PlayerState.EnteredWorld).ConfigureAwait(false); - this.IsAlive = true; - - await this.CurrentMap!.AddAsync(this).ConfigureAwait(false); - if (!this.CurrentMap.Terrain.WalkMap[this.SelectedCharacter.PositionX, this.SelectedCharacter.PositionY]) - { - await this.WarpToSafezoneAsync().ConfigureAwait(false); - } - - await this._summon.AddToMapAsync(this.CurrentMap).ConfigureAwait(false); - } + public ValueTask ClientReadyAfterMapChangeAsync() => this._mapTransitions.ClientReadyAfterMapChangeAsync(); /// /// Adds experience points after killing the target object. @@ -1269,7 +1096,7 @@ public async ValueTask RemoveFromGameAsync() await this.HandleMoveToNextSafezoneAsync().ConfigureAwait(false); - await this.RemoveFromCurrentMapAsync().ConfigureAwait(false); + await this._mapTransitions.RemoveFromCurrentMapAsync().ConfigureAwait(false); await this._storages.RestoreTemporaryStorageItemsAsync().ConfigureAwait(false); @@ -1375,6 +1202,34 @@ internal async ValueTask AfterKilledPlayerAsync(Player killedPlayer) await this.ForEachWorldObserverAsync(o => o.UpdateCharacterHeroStateAsync(this), true).ConfigureAwait(false); } + /// + /// Sets the current map without raising the enter/leave map events, when the player is + /// removed from the game. + /// + /// The map. + internal void SetCurrentMapSilently(GameMap? map) + { + this._currentMap = map; + } + + /// + /// Disposes the cancellation token source of a pending respawn after a death, because the + /// player is respawning now. + /// + internal void ClearRespawnAfterDeathToken() + { + this._respawnAfterDeathCts?.Dispose(); + this._respawnAfterDeathCts = null; + } + + /// + /// Clears the list of the objects which are observed by this player. + /// + internal ValueTask ClearObservingObjectsListAsync() + { + return this._observerToWorldViewAdapter.ClearObservingObjectsListAsync(); + } + /// protected override async ValueTask DisposeAsyncCore() { @@ -1385,7 +1240,7 @@ protected override async ValueTask DisposeAsyncCore() this.LastAttackedTarget.SetTarget(null); this.PersistenceContext.Dispose(); - await this.RemoveFromCurrentMapAsync().ConfigureAwait(false); + await this._mapTransitions.RemoveFromCurrentMapAsync().ConfigureAwait(false); await this._observerToWorldViewAdapter.ClearObservingObjectsListAsync().ConfigureAwait(false); this._observerToWorldViewAdapter.Dispose(); this._movement.Dispose(); @@ -1447,56 +1302,6 @@ private async ValueTask HandleMoveToNextSafezoneAsync() } } - private async ValueTask TryRemoveFromCurrentMapAsync(bool willRespawnOnSameMap) - { - var currentMap = this.CurrentMap; - if (currentMap is null) - { - return true; - } - - if (willRespawnOnSameMap) - { - await currentMap.InitRespawnAsync(this).ConfigureAwait(false); - } - else - { - await currentMap.RemoveAsync(this).ConfigureAwait(false); - } - - this.IsAlive = false; - this.IsTeleporting = false; - await this._movement.StopWalkingAsync().ConfigureAwait(false); - await this._observerToWorldViewAdapter.ClearObservingObjectsListAsync().ConfigureAwait(false); - await this._summon.RemoveFromMapAsync(currentMap).ConfigureAwait(false); - - return true; - } - - private async ValueTask PlaceAtGateAsync(ExitGate gate) - { - this.SelectedCharacter!.PositionX = (byte)Rand.NextInt(gate.X1, gate.X2); - this.SelectedCharacter.PositionY = (byte)Rand.NextInt(gate.Y1, gate.Y2); - this.SelectedCharacter.CurrentMap = gate.Map; - this.Rotation = gate.Direction; - - if (this.GameContext.PlugInManager.GetPlugInPoint() is { } speedCheck) - { - await speedCheck.ResetMovementStateAsync(this).ConfigureAwait(false); - } - - this._summon.PlaceAtGate(gate); - } - - private async ValueTask RemoveFromCurrentMapAsync() - { - if (this._currentMap is { } map) - { - await map.RemoveAsync(this).ConfigureAwait(false); - this._currentMap = null; - } - } - private async ValueTask RegenerateHeroStateAsync() { var currentCharacter = this._selectedCharacter; @@ -1528,41 +1333,6 @@ private async ValueTask RegenerateHeroStateAsync() } } - private async ValueTask GetSpawnGateOfCurrentMapAsync() - { - if (this.CurrentMap is null) - { - throw new InvalidOperationException("CurrentMap is not set. Can't determine spawn gate."); - } - - if (this.DuelRoom is { State: DuelState.DuelAccepted or DuelState.DuelStarted } duelRoom - && duelRoom.GetSpawnGate(this) is { } duelExitGate) - { - return duelExitGate; - } - - if (this.GuildWarContext?.WarType == GuildWarType.Soccer - && this.GuildWarContext.State == GuildWarState.Started - && this.CurrentMap is SoccerGameMap soccerGameMap - && soccerGameMap.Definition.BattleZone?.Ground is { } ground) - { - return new ExitGate - { - Map = soccerGameMap.Definition, - X1 = ground.X1, - X2 = ground.X2, - Y1 = ground.Y1, - Y2 = ground.Y2, - }; - } - - var spawnTargetMapDefinition = this.CurrentMap.Definition.SafezoneMap ?? this.CurrentMap.Definition; - var targetMap = await this.GameContext.GetMapAsync((ushort)spawnTargetMapDefinition.Number, false).ConfigureAwait(false); - return targetMap?.SafeZoneSpawnGate - ?? spawnTargetMapDefinition.GetSafezoneGate() - ?? throw new InvalidOperationException($"Game map {spawnTargetMapDefinition} has no spawn gate."); - } - private async ValueTask HitAsync(HitInfo hitInfo, IAttacker attacker, Skill? skill, bool? isFinalStreakHit = null) { this._summon.RegisterHit(attacker); @@ -1688,7 +1458,7 @@ async Task RespawnAsync(CancellationToken cancellationToken) await this.MagicEffectList.ClearEffectsAfterDeathAsync().ConfigureAwait(false); this.SetReclaimableAttributesToMaximum(); - await this.RespawnAtAsync(await this.GetSpawnGateOfCurrentMapAsync().ConfigureAwait(false)).ConfigureAwait(false); + await this.RespawnAtAsync(await this._mapTransitions.GetSpawnGateOfCurrentMapAsync().ConfigureAwait(false)).ConfigureAwait(false); await this.RespawnOfDuelPartnerIfInDuelAsync().ConfigureAwait(false); } catch (OperationCanceledException) diff --git a/src/GameLogic/PlayerMapTransitions.cs b/src/GameLogic/PlayerMapTransitions.cs new file mode 100644 index 000000000..8948d927c --- /dev/null +++ b/src/GameLogic/PlayerMapTransitions.cs @@ -0,0 +1,345 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic; + +using MUnique.OpenMU.GameLogic.PlayerActions; +using MUnique.OpenMU.GameLogic.PlugIns; +using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.GameLogic.Views.Character; +using MUnique.OpenMU.GameLogic.Views.World; +using MUnique.OpenMU.Pathfinding; + +/// +/// The map transitions of a : teleporting, warping, respawning, and the +/// handshake with the game client which loads the new map. +/// +internal sealed class PlayerMapTransitions +{ + private readonly Player _player; + + private readonly PlayerMovement _movement; + + private readonly PlayerSummon _summon; + + /// + /// Initializes a new instance of the class. + /// + /// The player. + /// The movement of the player. + /// The summon of the player. + public PlayerMapTransitions(Player player, PlayerMovement movement, PlayerSummon summon) + { + this._player = player; + this._movement = movement; + this._summon = summon; + } + + /// + /// Teleports the player to the specified target with the specified skill animation. + /// + /// The target. + /// The teleport skill. + public async Task TeleportAsync(Point target, Skill teleportSkill) + { + var player = this._player; + if (!player.IsAlive) + { + return; + } + + player.IsTeleporting = true; + try + { + await (player.SkillCancelTokenSource?.CancelAsync() ?? Task.CompletedTask).ConfigureAwait(false); + + await this._movement.StopWalkingAsync().ConfigureAwait(false); + await this._movement.ResetMovementStateAsync().ConfigureAwait(false); + + var previous = player.Position; + player.Position = target; + + await player.ForEachWorldObserverAsync(p => p.ShowSkillAnimationAsync(player, player, teleportSkill, true), true).ConfigureAwait(false); + + await Task.Delay(300).ConfigureAwait(false); + + await player.ForEachWorldObserverAsync(p => p.ObjectsOutOfScopeAsync(player.GetAsEnumerable()), false).ConfigureAwait(false); + + await Task.Delay(1500).ConfigureAwait(false); + + if (player.IsAlive) + { + await player.InvokeViewPlugInAsync(p => p.ShowTeleportedAsync()).ConfigureAwait(false); + + // We need to restore the previous position to make the Moving on the map data structure work correctly. + player.Position = previous; + if (player.CurrentMap is { } map) + { + await this._movement.MoveOnMapAsync(map, target, MoveType.Teleport).ConfigureAwait(false); + } + } + } + catch (Exception e) + { + player.Logger.LogWarning(e, "Error during teleport"); + } + + player.IsTeleporting = false; + } + + /// + /// Teleports the player to the specified target map and point. + /// + /// The target map for teleportation. + /// The target coordinate in the target map. + public async Task TeleportToMapAsync(GameMap targetMap, Point targetPoint) + { + var player = this._player; + if (!player.IsAlive) + { + return; + } + + player.IsTeleporting = true; + try + { + await (player.SkillCancelTokenSource?.CancelAsync() ?? Task.CompletedTask).ConfigureAwait(false); + + await this._movement.StopWalkingAsync().ConfigureAwait(false); + + await player.ForEachWorldObserverAsync(p => p.ObjectsOutOfScopeAsync(player.GetAsEnumerable()), false).ConfigureAwait(false); + + if (player.IsAlive) + { + ExitGate tempGate = new() + { + Map = targetMap.Definition, + X1 = targetPoint.X, + X2 = targetPoint.X, + Y1 = targetPoint.Y, + Y2 = targetPoint.Y, + }; + + await this.WarpToAsync(tempGate).ConfigureAwait(false); + } + } + catch (Exception e) + { + player.Logger.LogWarning(e, "Error during teleport"); + } + + player.IsTeleporting = false; + } + + /// + /// Moves the player to the specified gate. + /// + /// The gate to which the player should be moved. + public async ValueTask WarpToAsync(ExitGate gate) + { + var player = this._player; + var isRespawnOnSameMap = object.Equals(player.CurrentMap?.Definition, gate.Map); + if (!await this.TryRemoveFromCurrentMapAsync(isRespawnOnSameMap).ConfigureAwait(false)) + { + return; + } + + await this.PlaceAtGateAsync(gate).ConfigureAwait(false); + player.CurrentMap = null; // Will be set again, when the client acknowledged the map change by F3 12 packet. + + if (!player.PlayerState.CurrentState.IsDisconnectedOrFinished()) + { + await player.PlayerState.TryAdvanceToAsync(GameLogic.PlayerState.ChangingMap).ConfigureAwait(false); + await player.InvokeViewPlugInAsync(p => p.MapChangeAsync()).ConfigureAwait(false); + } + + // after this, the Client will send us a F3 12 packet, to tell us it loaded + // the map and is ready to receive the new meet player/monster etc. + // Then ClientReadyAfterMapChange is called. + } + + /// + /// Moves the player to its spawn gate, usually the safe zone of the current map. + /// + public async ValueTask WarpToSafezoneAsync() + { + await this.WarpToAsync(await this.GetSpawnGateOfCurrentMapAsync().ConfigureAwait(false)).ConfigureAwait(false); + } + + /// + /// Respawns the player at the specified gate. + /// + /// The gate at which the player should be respawned. + public async ValueTask RespawnAtAsync(ExitGate gate) + { + var player = this._player; + var isRespawnOnSameMap = object.Equals(player.CurrentMap?.Definition, gate.Map); + + if (!await this.TryRemoveFromCurrentMapAsync(isRespawnOnSameMap).ConfigureAwait(false)) + { + return; + } + + player.ThrowNotInitializedProperty(player.SelectedCharacter is null, nameof(player.SelectedCharacter)); + player.SelectedCharacter.ThrowNotInitializedProperty(player.SelectedCharacter.CurrentMap is null, nameof(player.SelectedCharacter.CurrentMap)); + await this.PlaceAtGateAsync(gate).ConfigureAwait(false); + player.ClearRespawnAfterDeathToken(); + + if (player.ViewPlugIns.GetPlugIn() is { } respawnPlugIn) + { + // Older clients use a separate packet for the respawn, while newer don't. + // It requires a slightly different logic. + player.CurrentMap = await player.GameContext.GetMapAsync(player.SelectedCharacter!.CurrentMap!.Number.ToUnsigned()).ConfigureAwait(false) ?? throw new InvalidOperationException("Current map not found."); + await respawnPlugIn.RespawnAsync().ConfigureAwait(false); + await player.PlayerState.TryAdvanceToAsync(GameLogic.PlayerState.EnteredWorld).ConfigureAwait(false); + player.IsAlive = true; + await player.CurrentMap!.AddAsync(player).ConfigureAwait(false); + } + else + { + player.CurrentMap = null; // Will be set again, when the client acknowledged the map change by F3 12 packet. + await player.PlayerState.TryAdvanceToAsync(GameLogic.PlayerState.ChangingMap).ConfigureAwait(false); + await player.InvokeViewPlugInAsync(p => p.MapChangeAsync()).ConfigureAwait(false); + + // after this, the Client will send us a F3 12 packet, to tell us it loaded + // the map and is ready to receive the new meet player/monster etc. + // Then ClientReadyAfterMapChange is called. + } + } + + /// + /// Signals that the game client of the player is ready after a map change (data has been loaded etc.). + /// In this event, the player enters the game map on the server side and interacts with the other objects. + /// + public async ValueTask ClientReadyAfterMapChangeAsync() + { + var player = this._player; + player.ThrowNotInitializedProperty(player.SelectedCharacter is null, nameof(player.SelectedCharacter)); + player.SelectedCharacter.ThrowNotInitializedProperty(player.SelectedCharacter.CurrentMap is null, nameof(player.SelectedCharacter.CurrentMap)); + + if (player.CurrentMap is not null) + { + // Guard against a repeated F3 12 (client ready after map change) packet. + // A map change usually leaves CurrentMap null until this handler assigns it, + // so a non-null value means the handler already ran. The exception is the + // IRespawnAfterDeathPlugIn branch of RespawnAtAsync, which assigns CurrentMap + // and adds the player itself; a trailing packet is redundant there as well. + // Without this guard, a duplicate packet adds the player (and its summon) to + // the area of interest a second time, which the bucket does not deduplicate. + player.Logger.LogWarning("Ignoring client-ready packet: player {0} is already on map {1}.", player, player.CurrentMap); + return; + } + + if (player.CurrentMiniGame is { } currentMiniGame) + { + player.CurrentMap = currentMiniGame.Map; + } + else + { + player.CurrentMap = await player.GameContext.GetMapAsync(player.SelectedCharacter!.CurrentMap.Number.ToUnsigned()).ConfigureAwait(false); + } + + await player.PlayerState.TryAdvanceToAsync(GameLogic.PlayerState.EnteredWorld).ConfigureAwait(false); + player.IsAlive = true; + + await player.CurrentMap!.AddAsync(player).ConfigureAwait(false); + if (!player.CurrentMap.Terrain.WalkMap[player.SelectedCharacter.PositionX, player.SelectedCharacter.PositionY]) + { + // The warp starts another map change, which adds the summon again when it completed. + await this.WarpToSafezoneAsync().ConfigureAwait(false); + return; + } + + await this._summon.AddToMapAsync(player.CurrentMap).ConfigureAwait(false); + } + + /// + /// Removes the player from its current map, e.g. when it leaves the game. + /// + public async ValueTask RemoveFromCurrentMapAsync() + { + var player = this._player; + if (player.CurrentMap is { } map) + { + await map.RemoveAsync(player).ConfigureAwait(false); + player.SetCurrentMapSilently(null); + } + } + + /// + /// Gets the gate at which the player is spawned. Plugins of the + /// can provide a gate of their game feature; + /// otherwise, it's the safezone gate of the current map. + /// + /// The spawn gate. + /// The current map is not set, or has no spawn gate. + public async ValueTask GetSpawnGateOfCurrentMapAsync() + { + var player = this._player; + if (player.CurrentMap is null) + { + throw new InvalidOperationException("CurrentMap is not set. Can't determine spawn gate."); + } + + if (player.GameContext.PlugInManager.GetPlugInPoint() is { } plugInPoint) + { + var args = new SpawnGateSelectionArgs(); + await plugInPoint.SelectSpawnGateAsync(player, args).ConfigureAwait(false); + if (args.Gate is { } selectedGate) + { + return selectedGate; + } + } + + var spawnTargetMapDefinition = player.CurrentMap.Definition.SafezoneMap ?? player.CurrentMap.Definition; + var targetMap = await player.GameContext.GetMapAsync((ushort)spawnTargetMapDefinition.Number, false).ConfigureAwait(false); + return targetMap?.SafeZoneSpawnGate + ?? spawnTargetMapDefinition.GetSafezoneGate() + ?? throw new InvalidOperationException($"Game map {spawnTargetMapDefinition} has no spawn gate."); + } + + private async ValueTask TryRemoveFromCurrentMapAsync(bool willRespawnOnSameMap) + { + var player = this._player; + var currentMap = player.CurrentMap; + if (currentMap is null) + { + return true; + } + + if (willRespawnOnSameMap) + { + await currentMap.InitRespawnAsync(player).ConfigureAwait(false); + } + else + { + await currentMap.RemoveAsync(player).ConfigureAwait(false); + } + + player.IsAlive = false; + player.IsTeleporting = false; + await this._movement.StopWalkingAsync().ConfigureAwait(false); + await player.ClearObservingObjectsListAsync().ConfigureAwait(false); + await this._summon.RemoveFromMapAsync(currentMap).ConfigureAwait(false); + + return true; + } + + /// + /// Places the player (and its summon) at the specified gate. + /// + /// The gate. + internal async ValueTask PlaceAtGateAsync(ExitGate gate) + { + var player = this._player; + player.SelectedCharacter!.PositionX = (byte)Rand.NextInt(gate.X1, gate.X2); + player.SelectedCharacter.PositionY = (byte)Rand.NextInt(gate.Y1, gate.Y2); + player.SelectedCharacter.CurrentMap = gate.Map; + player.Rotation = gate.Direction; + + await this._movement.ResetMovementStateAsync().ConfigureAwait(false); + + this._summon.PlaceAtGate(gate); + } +} diff --git a/src/GameLogic/PlugIns/DuelSpawnGatePlugIn.cs b/src/GameLogic/PlugIns/DuelSpawnGatePlugIn.cs new file mode 100644 index 000000000..d49c8ebfd --- /dev/null +++ b/src/GameLogic/PlugIns/DuelSpawnGatePlugIn.cs @@ -0,0 +1,34 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.PlugIns; + +/// +/// A plugin which spawns a duelist at its side of the duel area, instead of the safezone. +/// +[PlugIn] +[Display(Name = nameof(DuelSpawnGatePlugIn), Description = "Spawns a player which is in a duel at its side of the duel area.")] +[Guid("1D2C8B5A-4E7F-4C93-8B21-6A0E5D9F3C74")] +public class DuelSpawnGatePlugIn : IPlayerSpawnGateSelectionPlugIn +{ + /// + public ValueTask SelectSpawnGateAsync(Player player, SpawnGateSelectionArgs args) + { + if (args.Gate is not null) + { + return ValueTask.CompletedTask; + } + + if (player.DuelRoom is { State: DuelState.DuelAccepted or DuelState.DuelStarted } duelRoom + && duelRoom.GetSpawnGate(player) is { } duelExitGate) + { + args.Gate = duelExitGate; + } + + return ValueTask.CompletedTask; + } +} diff --git a/src/GameLogic/PlugIns/IPlayerSpawnGateSelectionPlugIn.cs b/src/GameLogic/PlugIns/IPlayerSpawnGateSelectionPlugIn.cs new file mode 100644 index 000000000..309b1a5b9 --- /dev/null +++ b/src/GameLogic/PlugIns/IPlayerSpawnGateSelectionPlugIn.cs @@ -0,0 +1,29 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.PlugIns; + +/// +/// A plugin interface which is called when the gate is determined at which a player is spawned, +/// e.g. after a death or when it's moved to the safezone. +/// +/// +/// It allows game features which keep the player at a special place - like duels, the guild war +/// soccer match or mini games - to define their own spawn gate, instead of the player being sent +/// to the safezone of its current map. +/// +[Guid("9E5A0B4C-6D71-4E28-9F2A-1D3B7C8E0A56")] +[PlugInPoint("Player spawn gate selection", "Plugins which determine the gate at which a player is spawned.")] +public interface IPlayerSpawnGateSelectionPlugIn +{ + /// + /// Is called when the spawn gate for the player is determined. + /// + /// The player which is spawned. + /// The arguments, which take the selected gate. + ValueTask SelectSpawnGateAsync(Player player, SpawnGateSelectionArgs args); +} diff --git a/src/GameLogic/PlugIns/SoccerSpawnGatePlugIn.cs b/src/GameLogic/PlugIns/SoccerSpawnGatePlugIn.cs new file mode 100644 index 000000000..7f2289b21 --- /dev/null +++ b/src/GameLogic/PlugIns/SoccerSpawnGatePlugIn.cs @@ -0,0 +1,45 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic.GuildWar; +using MUnique.OpenMU.PlugIns; + +/// +/// A plugin which keeps a player on the soccer ground of a running guild war soccer match, +/// instead of spawning it at the safezone. +/// +[PlugIn] +[Display(Name = nameof(SoccerSpawnGatePlugIn), Description = "Spawns a player of a running soccer match on the soccer ground.")] +[Guid("8C4E1F0B-7A93-4D65-B0C8-2E5F9A1D6B37")] +public class SoccerSpawnGatePlugIn : IPlayerSpawnGateSelectionPlugIn +{ + /// + public ValueTask SelectSpawnGateAsync(Player player, SpawnGateSelectionArgs args) + { + if (args.Gate is not null) + { + return ValueTask.CompletedTask; + } + + if (player.GuildWarContext?.WarType == GuildWarType.Soccer + && player.GuildWarContext.State == GuildWarState.Started + && player.CurrentMap is SoccerGameMap soccerGameMap + && soccerGameMap.Definition.BattleZone?.Ground is { } ground) + { + args.Gate = new ExitGate + { + Map = soccerGameMap.Definition, + X1 = ground.X1, + X2 = ground.X2, + Y1 = ground.Y1, + Y2 = ground.Y2, + }; + } + + return ValueTask.CompletedTask; + } +} diff --git a/src/GameLogic/PlugIns/SpawnGateSelectionArgs.cs b/src/GameLogic/PlugIns/SpawnGateSelectionArgs.cs new file mode 100644 index 000000000..718f07a83 --- /dev/null +++ b/src/GameLogic/PlugIns/SpawnGateSelectionArgs.cs @@ -0,0 +1,21 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns; + +/// +/// The arguments of the . Plugin points can't return +/// values, so the selected gate is passed back through this object. +/// +public sealed class SpawnGateSelectionArgs +{ + /// + /// Gets or sets the gate at which the player should be spawned. When it stays , + /// the player is spawned at the safezone gate of its current map. + /// + /// + /// A plugin should not overwrite a gate which another plugin already selected. + /// + public ExitGate? Gate { get; set; } +} diff --git a/tests/MUnique.OpenMU.Tests/PlayerTestHelper.cs b/tests/MUnique.OpenMU.Tests/PlayerTestHelper.cs index 301ca0074..9ac6950d2 100644 --- a/tests/MUnique.OpenMU.Tests/PlayerTestHelper.cs +++ b/tests/MUnique.OpenMU.Tests/PlayerTestHelper.cs @@ -44,6 +44,7 @@ public static async ValueTask CreatePlayerAsync() map.SetupAllProperties(); map.Setup(m => m.DropItemGroups).Returns(new List()); map.Setup(m => m.MonsterSpawns).Returns(new List()); + map.Setup(m => m.ExitGates).Returns(new List()); map.Object.TerrainData = new byte[ushort.MaxValue + 3]; gameConfig.Object.RecoveryInterval = int.MaxValue; gameConfig.Object.Maps.Add(map.Object); diff --git a/tests/MUnique.OpenMU.Tests/SpawnGateSelectionTests.cs b/tests/MUnique.OpenMU.Tests/SpawnGateSelectionTests.cs new file mode 100644 index 000000000..fe73515a8 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/SpawnGateSelectionTests.cs @@ -0,0 +1,159 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.PlugIns; +using MUnique.OpenMU.Pathfinding; +using MonsterAttribute = MUnique.OpenMU.Persistence.BasicModel.MonsterAttribute; +using MonsterDefinition = MUnique.OpenMU.Persistence.BasicModel.MonsterDefinition; + +/// +/// Tests for the selection of the gate at which a player is spawned. +/// +[TestFixture] +public class SpawnGateSelectionTests +{ + /// + /// Tests that a plugin can define the gate at which the player is spawned, which is how + /// duels and the guild war soccer match keep their players in place. + /// + [Test] + public async ValueTask PlugInDefinesSpawnGateAsync() + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + var gate = CreateGate(player, x: 77, y: 88); + player.GameContext.PlugInManager.RegisterPlugInAtPlugInPoint(new FixedSpawnGatePlugIn(gate)); + + await player.WarpToSafezoneAsync().ConfigureAwait(false); + + Assert.That(player.SelectedCharacter!.PositionX, Is.EqualTo(77)); + Assert.That(player.SelectedCharacter.PositionY, Is.EqualTo(88)); + } + + /// + /// Tests that a plugin doesn't overwrite the gate which another plugin selected before. + /// + [Test] + public async ValueTask FirstSelectedSpawnGateWinsAsync() + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + var firstGate = CreateGate(player, x: 10, y: 20); + var secondGate = CreateGate(player, x: 30, y: 40); + player.GameContext.PlugInManager.RegisterPlugInAtPlugInPoint(new FixedSpawnGatePlugIn(firstGate)); + player.GameContext.PlugInManager.RegisterPlugInAtPlugInPoint(new AnotherFixedSpawnGatePlugIn(secondGate)); + + await player.WarpToSafezoneAsync().ConfigureAwait(false); + + Assert.That(player.SelectedCharacter!.PositionX, Is.EqualTo(10)); + Assert.That(player.SelectedCharacter.PositionY, Is.EqualTo(20)); + } + + /// + /// Tests that the safezone gate of the map is used when no plugin selects one. + /// + [Test] + public async ValueTask MapSafezoneGateIsUsedWithoutPlugInAsync() + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + var definition = player.CurrentMap!.Definition; + definition.ExitGates.Add(new ExitGate + { + Map = definition, + X1 = 55, + X2 = 55, + Y1 = 66, + Y2 = 66, + IsSpawnGate = true, + }); + + await player.WarpToSafezoneAsync().ConfigureAwait(false); + + Assert.That(player.SelectedCharacter!.PositionX, Is.EqualTo(55)); + Assert.That(player.SelectedCharacter.PositionY, Is.EqualTo(66)); + } + + /// + /// Tests that a player which is at a blocked position after a map change is moved to its + /// spawn gate, without the map change of that move being disturbed. + /// + [Test] + public async ValueTask BlockedPositionAfterMapChangeWarpsToSpawnGateAsync() + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + var gate = CreateGate(player, x: 44, y: 45); + player.GameContext.PlugInManager.RegisterPlugInAtPlugInPoint(new FixedSpawnGatePlugIn(gate)); + + // A summon makes the map handling after the arrival observable: it's added to the map + // the player actually ends up on. + await player.CreateSummonedMonsterAsync(CreateMonsterDefinition()).ConfigureAwait(false); + + // Block the position at which the player arrives, so that it has to be moved away. + player.CurrentMap!.Terrain.WalkMap[5, 5] = false; + await player.WarpToAsync(CreateGate(player, x: 5, y: 5)).ConfigureAwait(false); + await player.ClientReadyAfterMapChangeAsync().ConfigureAwait(false); + + Assert.That(player.SelectedCharacter!.PositionX, Is.EqualTo(44)); + Assert.That(player.SelectedCharacter.PositionY, Is.EqualTo(45)); + + // The player is changing the map again, so it's not on a map yet. + Assert.That(player.CurrentMap, Is.Null); + Assert.That(player.PlayerState.CurrentState, Is.EqualTo(PlayerState.ChangingMap)); + } + + private static MonsterDefinition CreateMonsterDefinition() + { + var monsterDefinition = new MonsterDefinition + { + ObjectKind = NpcObjectKind.Monster, + }; + monsterDefinition.Attributes.Add(new MonsterAttribute { AttributeDefinition = Stats.MaximumHealth, Value = 1000 }); + return monsterDefinition; + } + + private static ExitGate CreateGate(Player player, byte x, byte y) + { + return new ExitGate + { + Map = player.CurrentMap!.Definition, + X1 = x, + X2 = x, + Y1 = y, + Y2 = y, + Direction = Direction.West, + }; + } + + [Guid("F1A6C3D8-9B24-4E07-8C5F-2A7D0E6B9134")] + private sealed class FixedSpawnGatePlugIn : IPlayerSpawnGateSelectionPlugIn + { + private readonly ExitGate _gate; + + public FixedSpawnGatePlugIn(ExitGate gate) => this._gate = gate; + + public ValueTask SelectSpawnGateAsync(Player player, SpawnGateSelectionArgs args) + { + args.Gate ??= this._gate; + return ValueTask.CompletedTask; + } + } + + [Guid("5B0E7F21-6C48-4A93-B1D6-8E2F0A4C7D95")] + private sealed class AnotherFixedSpawnGatePlugIn : IPlayerSpawnGateSelectionPlugIn + { + private readonly ExitGate _gate; + + public AnotherFixedSpawnGatePlugIn(ExitGate gate) => this._gate = gate; + + public ValueTask SelectSpawnGateAsync(Player player, SpawnGateSelectionArgs args) + { + args.Gate ??= this._gate; + return ValueTask.CompletedTask; + } + } +}