fix(tvt): count event kills, respawn on the game's own map, end-ceremony countdown
- Kills now credited from OnDeathAsync (the AfterKilledPlayerAsync/PK path is skipped when AllowPlayerKilling is true, so event kills never reached the scoreboard). - Respawn after death now stays on the mini-game's OWN map instance (older-client path fetched the shared world map of the same number -> respawned player saw no statues/participants). - On game end: announce the winner + run a 60s ceremony broadcast loop sending phase-3 HUD countdown and the final scoreboard until the Lorencia teleport.
This commit is contained in:
@@ -260,6 +260,20 @@ public class HeykelSavasiContext : MiniGameContext
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private const int ScoreboardMaxEntries = 8;
|
private const int ScoreboardMaxEntries = 8;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How long the post-battle "event over" ceremony lasts before players are teleported to Lorencia.
|
||||||
|
/// Matches <c>MiniGameContext.RunGameAsync</c>'s own exit timing for our config (ExitDuration 15s):
|
||||||
|
/// <c>max(ExitDuration - 30s, 30s) + 30s countdown = 60s</c>. The client shows this as a countdown and
|
||||||
|
/// moves the scoreboard to the screen centre during it (phase 3).
|
||||||
|
/// </summary>
|
||||||
|
private static readonly TimeSpan EndCeremonyDuration = TimeSpan.FromSeconds(60);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wall-clock instant at which the post-battle ceremony ends and players are teleported out; set in
|
||||||
|
/// <see cref="GameEndedAsync"/>. Drives the phase-3 countdown in <see cref="ComputeHudPhaseAndRemaining"/>.
|
||||||
|
/// </summary>
|
||||||
|
private DateTime? _endCeremonyDeadlineUtc;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Synchronizes the check-then-act sequence of <see cref="TryJoinTeamAsync"/>, so that concurrent
|
/// Synchronizes the check-then-act sequence of <see cref="TryJoinTeamAsync"/>, so that concurrent
|
||||||
/// join attempts cannot both pass the balance check before either of them reserves a slot.
|
/// join attempts cannot both pass the balance check before either of them reserves a slot.
|
||||||
@@ -615,15 +629,58 @@ public class HeykelSavasiContext : MiniGameContext
|
|||||||
await this.RemoveTransformAsync(player).ConfigureAwait(false);
|
await this.RemoveTransformAsync(player).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Announce the result + start the post-battle countdown to the Lorencia teleport.
|
||||||
|
this._endCeremonyDeadlineUtc = DateTime.UtcNow.Add(EndCeremonyDuration);
|
||||||
|
|
||||||
|
var resultMessage = winner switch
|
||||||
|
{
|
||||||
|
HeykelSavasiTeam.Red => "RED team wins the TvT Event!",
|
||||||
|
HeykelSavasiTeam.Blue => "BLUE team wins the TvT Event!",
|
||||||
|
_ => "TvT Event is over - it's a draw!",
|
||||||
|
};
|
||||||
|
foreach (var player in finishers)
|
||||||
|
{
|
||||||
|
await player.InvokeViewPlugInAsync<MUnique.OpenMU.GameLogic.Views.IShowMessagePlugIn>(
|
||||||
|
p => p.ShowMessageAsync(resultMessage, MUnique.OpenMU.Interfaces.MessageType.GoldenCenter)).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
// Final HUD broadcast: by now MiniGameContext.State is already MiniGameState.Ended (StopAsync sets it
|
// Final HUD broadcast: by now MiniGameContext.State is already MiniGameState.Ended (StopAsync sets it
|
||||||
// before calling this method), so ComputeHudPhaseAndRemaining naturally reports phase 3/0 seconds. This
|
// before calling this method), so ComputeHudPhaseAndRemaining reports phase 3 with the ceremony countdown.
|
||||||
// is needed because the periodic loop (HudBroadcastLoopAsync) stops exactly at this same state
|
// The periodic HUD loop (HudBroadcastLoopAsync) stopped at this same transition, so a dedicated ceremony
|
||||||
// transition (it runs off GameEndedToken, which StopAsync cancels right before setting State to Ended).
|
// loop keeps the phase-3 countdown and the final scoreboard flowing until the teleport.
|
||||||
await this.BroadcastHudStateAsync().ConfigureAwait(false);
|
await this.BroadcastHudStateAsync().ConfigureAwait(false);
|
||||||
|
await this.BroadcastScoreboardAsync().ConfigureAwait(false);
|
||||||
|
_ = Task.Run(() => this.EndCeremonyBroadcastLoopAsync(this._endCeremonyDeadlineUtc.Value));
|
||||||
|
|
||||||
await base.GameEndedAsync(finishers).ConfigureAwait(false);
|
await base.GameEndedAsync(finishers).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// After the battle ends, keeps broadcasting the phase-3 HUD state (with the countdown to the Lorencia
|
||||||
|
/// teleport) and the final scoreboard about once per second until the ceremony deadline. Runs independently
|
||||||
|
/// of <see cref="HudBroadcastLoopAsync"/>, which has already stopped (its <see cref="MiniGameContext.GameEndedToken"/>
|
||||||
|
/// was cancelled when the game ended).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="deadlineUtc">The instant the ceremony ends (players get teleported out).</param>
|
||||||
|
private async ValueTask EndCeremonyBroadcastLoopAsync(DateTime deadlineUtc)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
|
||||||
|
while (DateTime.UtcNow < deadlineUtc
|
||||||
|
&& this.State == MiniGameState.Ended
|
||||||
|
&& await timer.WaitForNextTickAsync().ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
await this.BroadcastHudStateAsync().ConfigureAwait(false);
|
||||||
|
await this.BroadcastScoreboardAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
this.Logger.LogError(ex, "{context}: Error in the end-of-event ceremony broadcast loop.", this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the players which would receive the winner's Zen reward if the event ended right now, for unit
|
/// Gets the players which would receive the winner's Zen reward if the event ended right now, for unit
|
||||||
/// tests only (a full <see cref="GameEndedAsync"/> run needs a live <see cref="Player"/> with a working
|
/// tests only (a full <see cref="GameEndedAsync"/> run needs a live <see cref="Player"/> with a working
|
||||||
@@ -1262,8 +1319,12 @@ public class HeykelSavasiContext : MiniGameContext
|
|||||||
return (2, ClampToUInt16Seconds(this.Definition.GameDuration - (now - this._playingAtUtc.Value)));
|
return (2, ClampToUInt16Seconds(this.Definition.GameDuration - (now - this._playingAtUtc.Value)));
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Undefined, Ended, or Disposed: the event is over (or hasn't been set up yet).
|
// Undefined, Ended, or Disposed: the event is over (or hasn't been set up yet). During the
|
||||||
return (3, 0);
|
// post-battle ceremony (State == Ended, deadline set) report the seconds left until the
|
||||||
|
// Lorencia teleport so the client can show a countdown and centre the final scoreboard.
|
||||||
|
return (3, this._endCeremonyDeadlineUtc is { } deadline
|
||||||
|
? ClampToUInt16Seconds(deadline - now)
|
||||||
|
: (ushort)0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1161,7 +1161,13 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
|
|||||||
{
|
{
|
||||||
// Older clients use a separate packet for the respawn, while newer don't.
|
// Older clients use a separate packet for the respawn, while newer don't.
|
||||||
// It requires a slightly different logic.
|
// 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.");
|
// ADAMU-CUSTOM: when respawning inside a mini game (e.g. the TvT Event), stay on that game's OWN
|
||||||
|
// map instance - not the shared world map of the same number - otherwise the respawned player lands
|
||||||
|
// on an empty copy and can't see the statues or the other participants. Mirrors the newer-client
|
||||||
|
// path in ClientReadyAfterMapChangeAsync.
|
||||||
|
this.CurrentMap = this.CurrentMiniGame is { } respawnMiniGame
|
||||||
|
? respawnMiniGame.Map
|
||||||
|
: (await this.GameContext.GetMapAsync(this.SelectedCharacter!.CurrentMap!.Number.ToUnsigned()).ConfigureAwait(false) ?? throw new InvalidOperationException("Current map not found."));
|
||||||
await respawnPlugIn.RespawnAsync().ConfigureAwait(false);
|
await respawnPlugIn.RespawnAsync().ConfigureAwait(false);
|
||||||
await this.PlayerState.TryAdvanceToAsync(GameLogic.PlayerState.EnteredWorld).ConfigureAwait(false);
|
await this.PlayerState.TryAdvanceToAsync(GameLogic.PlayerState.EnteredWorld).ConfigureAwait(false);
|
||||||
this.IsAlive = true;
|
this.IsAlive = true;
|
||||||
@@ -1890,21 +1896,6 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
|
|||||||
/// <param name="killedPlayer">The player killed.</param>
|
/// <param name="killedPlayer">The player killed.</param>
|
||||||
internal async ValueTask AfterKilledPlayerAsync(Player killedPlayer)
|
internal async ValueTask AfterKilledPlayerAsync(Player killedPlayer)
|
||||||
{
|
{
|
||||||
// ADAMU-CUSTOM: TvT Event -> credit the killer with an enemy-team kill for the event scoreboard.
|
|
||||||
// Done up front, before the PK-state early-returns below, so it counts regardless of hero state.
|
|
||||||
if (this.CurrentMiniGame is MiniGames.HeykelSavasiContext heykelKillGame
|
|
||||||
&& ReferenceEquals(killedPlayer.CurrentMiniGame, this.CurrentMiniGame))
|
|
||||||
{
|
|
||||||
var killerTeam = heykelKillGame.GetTeam(this);
|
|
||||||
var victimTeam = heykelKillGame.GetTeam(killedPlayer);
|
|
||||||
if (killerTeam != victimTeam
|
|
||||||
&& killerTeam != MiniGames.HeykelSavasiTeam.None
|
|
||||||
&& victimTeam != MiniGames.HeykelSavasiTeam.None)
|
|
||||||
{
|
|
||||||
heykelKillGame.RecordKill(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.DuelRoom?.State == DuelState.DuelStarted)
|
if (this.DuelRoom?.State == DuelState.DuelStarted)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -2474,6 +2465,23 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
|
|||||||
this._respawnAfterDeathCts = new CancellationTokenSource();
|
this._respawnAfterDeathCts = new CancellationTokenSource();
|
||||||
await this.ForEachWorldObserverAsync<IObjectGotKilledPlugIn>(p => p.ObjectGotKilledAsync(this, killer), true).ConfigureAwait(false);
|
await this.ForEachWorldObserverAsync<IObjectGotKilledPlugIn>(p => p.ObjectGotKilledAsync(this, killer), true).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// ADAMU-CUSTOM: TvT Event -> credit the killer with an enemy-team kill for the scoreboard. Done here
|
||||||
|
// (not in AfterKilledPlayerAsync) because that PK-penalty path is intentionally skipped for mini-game
|
||||||
|
// PvP (AllowPlayerKilling == true), which would otherwise mean event kills never count.
|
||||||
|
if (killer is Player heykelKiller
|
||||||
|
&& heykelKiller.CurrentMiniGame is MiniGames.HeykelSavasiContext heykelKillGame
|
||||||
|
&& ReferenceEquals(this.CurrentMiniGame, heykelKiller.CurrentMiniGame))
|
||||||
|
{
|
||||||
|
var killerTeam = heykelKillGame.GetTeam(heykelKiller);
|
||||||
|
var victimTeam = heykelKillGame.GetTeam(this);
|
||||||
|
if (killerTeam != victimTeam
|
||||||
|
&& killerTeam != MiniGames.HeykelSavasiTeam.None
|
||||||
|
&& victimTeam != MiniGames.HeykelSavasiTeam.None)
|
||||||
|
{
|
||||||
|
heykelKillGame.RecordKill(heykelKiller);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (killer is Player killerAfterKilled
|
if (killer is Player killerAfterKilled
|
||||||
&& !(killerAfterKilled.GuildWarContext?.Score is { } score && score == this.GuildWarContext?.Score)
|
&& !(killerAfterKilled.GuildWarContext?.Score is { } score && score == this.GuildWarContext?.Score)
|
||||||
&& this.CurrentMiniGame?.AllowPlayerKilling is not true)
|
&& this.CurrentMiniGame?.AllowPlayerKilling is not true)
|
||||||
|
|||||||
Reference in New Issue
Block a user