Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/PlayerRefactoringPlan.md
Original file line number Diff line number Diff line change
@@ -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)

Expand Down Expand Up @@ -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) |
Expand Down Expand Up @@ -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 |
Expand Down
316 changes: 43 additions & 273 deletions src/GameLogic/Player.cs

Large diffs are not rendered by default.

345 changes: 345 additions & 0 deletions src/GameLogic/PlayerMapTransitions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,345 @@
// <copyright file="PlayerMapTransitions.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>

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;

/// <summary>
/// The map transitions of a <see cref="Player"/>: teleporting, warping, respawning, and the
/// handshake with the game client which loads the new map.
/// </summary>
internal sealed class PlayerMapTransitions
{
private readonly Player _player;

private readonly PlayerMovement _movement;

private readonly PlayerSummon _summon;

/// <summary>
/// Initializes a new instance of the <see cref="PlayerMapTransitions"/> class.
/// </summary>
/// <param name="player">The player.</param>
/// <param name="movement">The movement of the player.</param>
/// <param name="summon">The summon of the player.</param>
public PlayerMapTransitions(Player player, PlayerMovement movement, PlayerSummon summon)
{
this._player = player;
this._movement = movement;
this._summon = summon;
}

/// <summary>
/// Teleports the player to the specified target with the specified skill animation.
/// </summary>
/// <param name="target">The target.</param>
/// <param name="teleportSkill">The teleport skill.</param>
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<IShowSkillAnimationPlugIn>(p => p.ShowSkillAnimationAsync(player, player, teleportSkill, true), true).ConfigureAwait(false);

await Task.Delay(300).ConfigureAwait(false);

await player.ForEachWorldObserverAsync<IObjectsOutOfScopePlugIn>(p => p.ObjectsOutOfScopeAsync(player.GetAsEnumerable()), false).ConfigureAwait(false);

await Task.Delay(1500).ConfigureAwait(false);

if (player.IsAlive)
{
await player.InvokeViewPlugInAsync<ITeleportPlugIn>(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;
}

/// <summary>
/// Teleports the player to the specified target map and point.
/// </summary>
/// <param name="targetMap">The target map for teleportation.</param>
/// <param name="targetPoint">The target coordinate in the target map.</param>
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<IObjectsOutOfScopePlugIn>(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;
}

/// <summary>
/// Moves the player to the specified gate.
/// </summary>
/// <param name="gate">The gate to which the player should be moved.</param>
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<IMapChangePlugIn>(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.
}

/// <summary>
/// Moves the player to its spawn gate, usually the safe zone of the current map.
/// </summary>
public async ValueTask WarpToSafezoneAsync()
{
await this.WarpToAsync(await this.GetSpawnGateOfCurrentMapAsync().ConfigureAwait(false)).ConfigureAwait(false);
}

/// <summary>
/// Respawns the player at the specified gate.
/// </summary>
/// <param name="gate">The gate at which the player should be respawned.</param>
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<IRespawnAfterDeathPlugIn>() 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<IMapChangePlugIn>(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.
}
}

/// <summary>
/// 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.
/// </summary>
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);
}

/// <summary>
/// Removes the player from its current map, e.g. when it leaves the game.
/// </summary>
public async ValueTask RemoveFromCurrentMapAsync()
{
var player = this._player;
if (player.CurrentMap is { } map)
{
await map.RemoveAsync(player).ConfigureAwait(false);
player.SetCurrentMapSilently(null);
}
}

/// <summary>
/// Gets the gate at which the player is spawned. Plugins of the
/// <see cref="IPlayerSpawnGateSelectionPlugIn"/> can provide a gate of their game feature;
/// otherwise, it's the safezone gate of the current map.
/// </summary>
/// <returns>The spawn gate.</returns>
/// <exception cref="InvalidOperationException">The current map is not set, or has no spawn gate.</exception>
public async ValueTask<ExitGate> 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<IPlayerSpawnGateSelectionPlugIn>() 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<bool> 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;
}

/// <summary>
/// Places the player (and its summon) at the specified gate.
/// </summary>
/// <param name="gate">The gate.</param>
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);
}
}
Loading
Loading