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;
+ }
+ }
+}