|
|
|
|
@@ -248,6 +248,18 @@ public class HeykelSavasiContext : MiniGameContext
|
|
|
|
|
/// </summary>
|
|
|
|
|
private int _bossesSpawned;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Per-character contribution stats for the scoreboard (<see cref="IHeykelSavasiScoreboardPlugIn"/>):
|
|
|
|
|
/// enemy kills, statues broken, and total damage dealt to statues. Keyed by the participating player;
|
|
|
|
|
/// fed by <see cref="RecordKill"/>, <see cref="RecordStatueBreak"/> and <see cref="RecordStatueDamage"/>.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private readonly ConcurrentDictionary<Player, PlayerScore> _scores = new();
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// The maximum number of characters shown on the scoreboard (top contributors by statue damage).
|
|
|
|
|
/// </summary>
|
|
|
|
|
private const int ScoreboardMaxEntries = 8;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// 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.
|
|
|
|
|
@@ -448,6 +460,64 @@ public class HeykelSavasiContext : MiniGameContext
|
|
|
|
|
/// <param name="player">The player.</param>
|
|
|
|
|
private void RemoveTeam(Player player) => this._teams.TryRemove(player, out _);
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Records that <paramref name="killer"/> killed an enemy-team player (for the scoreboard kill count).
|
|
|
|
|
/// Called from <c>Player.AfterKilledPlayerAsync</c> when both players are in this event on opposing teams.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <param name="killer">The killing player.</param>
|
|
|
|
|
public void RecordKill(Player killer)
|
|
|
|
|
{
|
|
|
|
|
if (killer is null)
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var score = this._scores.GetOrAdd(killer, _ => new PlayerScore());
|
|
|
|
|
Interlocked.Increment(ref score.Kills);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Records that the character named <paramref name="killerName"/> broke a statue (dealt the killing blow),
|
|
|
|
|
/// for the scoreboard statue count. Called from <see cref="OnDestructibleDied"/>, which only knows the
|
|
|
|
|
/// killer's name; it is matched to the participating player of that name.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <param name="killerName">The character name of the player who broke the statue.</param>
|
|
|
|
|
public void RecordStatueBreak(string killerName)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrEmpty(killerName))
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var player = this._teams.Keys.FirstOrDefault(p => string.Equals(p.Name, killerName, StringComparison.Ordinal));
|
|
|
|
|
if (player is null)
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var score = this._scores.GetOrAdd(player, _ => new PlayerScore());
|
|
|
|
|
Interlocked.Increment(ref score.Statues);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Records <paramref name="damage"/> dealt by <paramref name="player"/> to <paramref name="statue"/>, for the
|
|
|
|
|
/// scoreboard damage ranking. Called from <c>AttackableNpcBase.AttackByAsync</c> for every hit that lands on a
|
|
|
|
|
/// statue of this event.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <param name="player">The attacking player.</param>
|
|
|
|
|
/// <param name="statue">The statue NPC that was hit.</param>
|
|
|
|
|
/// <param name="damage">The health damage dealt by this hit.</param>
|
|
|
|
|
public void RecordStatueDamage(Player player, AttackableNpcBase statue, uint damage)
|
|
|
|
|
{
|
|
|
|
|
if (player is null || damage == 0 || !this._statues.ContainsKey(statue))
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var score = this._scores.GetOrAdd(player, _ => new PlayerScore());
|
|
|
|
|
Interlocked.Add(ref score.Damage, damage);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Removes a player from their team when they leave the event map for good (disconnect, or warp
|
|
|
|
|
/// to another map such as Lorencia). A same-map death-respawn does NOT trigger this (RespawnAtAsync
|
|
|
|
|
@@ -591,6 +661,9 @@ public class HeykelSavasiContext : MiniGameContext
|
|
|
|
|
this.HandleStatueDestroyed(info.Defender, info.Index, attacker, spawnNext: false);
|
|
|
|
|
var count = this.GetProgress(attacker);
|
|
|
|
|
|
|
|
|
|
// Credit the statue break to the destroyer's character for the scoreboard.
|
|
|
|
|
this.RecordStatueBreak(e.KillerName);
|
|
|
|
|
|
|
|
|
|
// Fire-and-forget the async side effects (break announcement, buff application, or finishing the
|
|
|
|
|
// event); the death event handler itself must stay synchronous.
|
|
|
|
|
_ = this.OnStatueDestroyedSideEffectsAsync(attacker, count, e.KillerName);
|
|
|
|
|
@@ -1083,6 +1156,7 @@ public class HeykelSavasiContext : MiniGameContext
|
|
|
|
|
{
|
|
|
|
|
await this.BroadcastHudStateAsync().ConfigureAwait(false);
|
|
|
|
|
await this.BroadcastTeamRosterAsync().ConfigureAwait(false);
|
|
|
|
|
await this.BroadcastScoreboardAsync().ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false));
|
|
|
|
|
}
|
|
|
|
|
@@ -1133,6 +1207,32 @@ public class HeykelSavasiContext : MiniGameContext
|
|
|
|
|
.AsTask()).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Sends the per-character scoreboard to every entered player via <see cref="IHeykelSavasiScoreboardPlugIn"/>:
|
|
|
|
|
/// the top <see cref="ScoreboardMaxEntries"/> contributors ordered by total statue damage (descending).
|
|
|
|
|
/// Characters that dealt no statue damage are omitted. Broadcast on the same ~1s cadence as the HUD state.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private async ValueTask BroadcastScoreboardAsync()
|
|
|
|
|
{
|
|
|
|
|
var entries = this._scores
|
|
|
|
|
.Select(kv => (kv.Key.Name, Score: kv.Value))
|
|
|
|
|
.Where(x => x.Score.Damage > 0)
|
|
|
|
|
.OrderByDescending(x => x.Score.Damage)
|
|
|
|
|
.ThenByDescending(x => x.Score.Statues)
|
|
|
|
|
.ThenByDescending(x => x.Score.Kills)
|
|
|
|
|
.Take(ScoreboardMaxEntries)
|
|
|
|
|
.Select(x => (
|
|
|
|
|
Name: x.Name ?? string.Empty,
|
|
|
|
|
Kills: (ushort)Math.Min(x.Score.Kills, ushort.MaxValue),
|
|
|
|
|
Statues: (byte)Math.Min(x.Score.Statues, byte.MaxValue),
|
|
|
|
|
Damage: (uint)Math.Min(Interlocked.Read(ref x.Score.Damage), uint.MaxValue)))
|
|
|
|
|
.ToList();
|
|
|
|
|
|
|
|
|
|
await this.ForEachPlayerAsync(player => player.InvokeViewPlugInAsync<IHeykelSavasiScoreboardPlugIn>(p =>
|
|
|
|
|
p.UpdateScoreboardAsync(entries))
|
|
|
|
|
.AsTask()).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Determines the current HUD phase and the estimated number of seconds remaining in it, purely from
|
|
|
|
|
/// <see cref="MiniGameContext.State"/> and wall-clock timestamps recorded (lazily, on first observation) at
|
|
|
|
|
@@ -1191,6 +1291,22 @@ public class HeykelSavasiContext : MiniGameContext
|
|
|
|
|
/// <see cref="IsJoinAllowed"/>, extracted for the same reason). Kept <c>internal</c> (rather than
|
|
|
|
|
/// <c>private</c>) specifically so <c>MUnique.OpenMU.Tests</c> can drive it directly.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Mutable per-character contribution counters for the scoreboard. Fields (not properties) so they can be
|
|
|
|
|
/// updated atomically with <see cref="Interlocked"/> from concurrent hit/kill handlers.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private sealed class PlayerScore
|
|
|
|
|
{
|
|
|
|
|
/// <summary>Number of enemy-team players killed.</summary>
|
|
|
|
|
public int Kills;
|
|
|
|
|
|
|
|
|
|
/// <summary>Number of statues broken (killing blow).</summary>
|
|
|
|
|
public int Statues;
|
|
|
|
|
|
|
|
|
|
/// <summary>Total damage dealt to statues.</summary>
|
|
|
|
|
public long Damage;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
internal sealed class StatueProgressState
|
|
|
|
|
{
|
|
|
|
|
private readonly ConcurrentDictionary<HeykelSavasiTeam, int> _progress = new()
|
|
|
|
|
|