feat(hs): all statues visible at fixed coords, guard-gated, any-order break with counter+killer message

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Acentech Dev
2026-07-21 14:54:43 +03:00
parent a76708c899
commit 0f9db37a92
2 changed files with 156 additions and 75 deletions

View File

@@ -87,9 +87,15 @@ public class HeykelSavasiContext : MiniGameContext
private const short GuardMonsterNumber = 580; private const short GuardMonsterNumber = 580;
/// <summary> /// <summary>
/// The (dx, dy) offsets, relative to a statue's position, at which its 4 guards spawn. /// The (dx, dy) offsets, relative to a statue's position, at which its 3 guards spawn. A statue
/// cannot be damaged until all 3 of these guards are dead (see <see cref="IsStatueAttackBlocked"/>).
/// </summary> /// </summary>
private static readonly (int Dx, int Dy)[] GuardOffsets = { (-2, 0), (2, 0), (0, -2), (0, 2) }; private static readonly (int Dx, int Dy)[] GuardOffsets = { (-2, 0), (2, 0), (0, 2) };
/// <summary>
/// The number of guard mobs which spawn next to (and protect) each statue.
/// </summary>
private const int GuardsPerStatue = 3;
/// <summary> /// <summary>
/// Maps a destroyed statue's index (0..5) to the <see cref="MUnique.OpenMU.DataModel.Configuration.MagicEffectDefinition.Number"/> /// Maps a destroyed statue's index (0..5) to the <see cref="MUnique.OpenMU.DataModel.Configuration.MagicEffectDefinition.Number"/>
@@ -108,37 +114,35 @@ public class HeykelSavasiContext : MiniGameContext
private static readonly TimeSpan BuffDuration = TimeSpan.FromMinutes(6); private static readonly TimeSpan BuffDuration = TimeSpan.FromMinutes(6);
/// <summary> /// <summary>
/// PLACEHOLDER coordinates: the Heykel Savasi map terrain is not final yet, so these positions are an /// The FIXED positions of the red team's 7 statues (attacked by the blue team). All 7 are spawned and
/// approximate straight line from mid-map toward the red base (20, 20). Index 0 is the outermost statue /// visible at battle start and can be broken in ANY order; the index (0..6) is retained only for the
/// (attacked first by the blue team), index 6 is the final statue, closest to the red base (win condition /// per-break buff mapping (see <see cref="StatueBuffEffectNumbers"/>), not for spawn sequencing.
/// for blue once broken).
/// </summary> /// </summary>
private static readonly Point[] RedStatuePositions = private static readonly Point[] RedStatuePositions =
{ {
new(42, 51), new(43, 20),
new(42, 45), new(56, 26),
new(42, 38), new(72, 43),
new(42, 31), new(43, 28),
new(42, 25), new(43, 44),
new(42, 15), new(29, 26),
new(42, 8), new(14, 43),
}; };
/// <summary> /// <summary>
/// PLACEHOLDER coordinates: the Heykel Savasi map terrain is not final yet, so these positions are an /// The FIXED positions of the blue team's 7 statues (attacked by the red team). All 7 are spawned and
/// approximate straight line from mid-map toward the blue base (220, 220). Index 0 is the outermost statue /// visible at battle start and can be broken in ANY order; the index (0..6) is retained only for the
/// (attacked first by the red team), index 6 is the final statue, closest to the blue base (win condition /// per-break buff mapping (see <see cref="StatueBuffEffectNumbers"/>), not for spawn sequencing.
/// for red once broken).
/// </summary> /// </summary>
private static readonly Point[] BlueStatuePositions = private static readonly Point[] BlueStatuePositions =
{ {
new(42, 53), new(16, 61),
new(42, 59), new(31, 77),
new(42, 66), new(43, 72),
new(42, 73), new(43, 58),
new(42, 79), new(72, 60),
new(42, 87), new(57, 76),
new(42, 94), new(43, 82),
}; };
/// <summary> /// <summary>
@@ -180,6 +184,19 @@ public class HeykelSavasiContext : MiniGameContext
/// </summary> /// </summary>
private readonly ConcurrentDictionary<AttackableNpcBase, (HeykelSavasiTeam Defender, int Index)> _statues = new(); private readonly ConcurrentDictionary<AttackableNpcBase, (HeykelSavasiTeam Defender, int Index)> _statues = new();
/// <summary>
/// Maps each spawned statue NPC to the number of its guard mobs which are still alive. A statue starts at
/// <see cref="GuardsPerStatue"/> and can only be damaged once this reaches 0 (see
/// <see cref="IsStatueAttackBlocked"/>); decremented by <see cref="OnMonsterDied"/> as guards die.
/// </summary>
private readonly ConcurrentDictionary<AttackableNpcBase, int> _statueGuardsAlive = new();
/// <summary>
/// Maps each spawned guard mob to the statue it protects, so <see cref="OnMonsterDied"/> can decrement the
/// right statue's living-guard count when a guard dies. The guard is removed once its death is processed.
/// </summary>
private readonly ConcurrentDictionary<AttackableNpcBase, AttackableNpcBase> _guardToStatue = new();
/// <summary> /// <summary>
/// Pure statue-break progress/winner state, extracted into its own dependency-free type so it can be /// Pure statue-break progress/winner state, extracted into its own dependency-free type so it can be
/// unit-tested without constructing a full <see cref="HeykelSavasiContext"/> (whose constructor needs a /// unit-tested without constructing a full <see cref="HeykelSavasiContext"/> (whose constructor needs a
@@ -247,12 +264,29 @@ public class HeykelSavasiContext : MiniGameContext
public HeykelSavasiTeam GetTeam(Player player) => this._teams.TryGetValue(player, out var team) ? team : HeykelSavasiTeam.None; public HeykelSavasiTeam GetTeam(Player player) => this._teams.TryGetValue(player, out var team) ? team : HeykelSavasiTeam.None;
/// <summary> /// <summary>
/// Returns true if <paramref name="npc"/> is a Heykel Savasi statue belonging to the /// Returns <c>true</c> if a hit on <paramref name="npc"/> by <paramref name="attacker"/> must be blocked.
/// same team as <paramref name="attacker"/> — such statues must be immune to friendly damage, /// A statue is protected (undamageable) while EITHER it belongs to the attacker's own team (a player may
/// otherwise a player could destroy their own team's statue and credit the enemy. /// never break their own team's statue and credit the enemy) OR any of its 3 guard mobs are still alive
/// (the guards must be cleared first). If <paramref name="npc"/> is not a tracked statue, the hit is never
/// blocked here (normal damage). Called from <c>AttackableNpcBase.AttackByAsync</c>.
/// </summary> /// </summary>
public bool IsFriendlyStatue(AttackableNpcBase npc, Player attacker) /// <param name="npc">The NPC being attacked.</param>
=> this._statues.TryGetValue(npc, out var info) && info.Defender == this.GetTeam(attacker); /// <param name="attacker">The attacking player.</param>
/// <returns><c>true</c> if the hit should be blocked; otherwise, <c>false</c>.</returns>
public bool IsStatueAttackBlocked(AttackableNpcBase npc, Player attacker)
{
if (!this._statues.TryGetValue(npc, out var info))
{
return false;
}
if (info.Defender == this.GetTeam(attacker))
{
return true;
}
return this._statueGuardsAlive.TryGetValue(npc, out var aliveGuards) && aliveGuards > 0;
}
/// <summary> /// <summary>
/// Gets the base spawn gate for the given team. /// Gets the base spawn gate for the given team.
@@ -421,9 +455,13 @@ public class HeykelSavasiContext : MiniGameContext
// Re-show the HUD panel promptly after the battle-start map change. // Re-show the HUD panel promptly after the battle-start map change.
await this.BroadcastHudStateAsync().ConfigureAwait(false); await this.BroadcastHudStateAsync().ConfigureAwait(false);
// Spawn the first (outermost) statue of each team's line; the opposing team attacks it. // Spawn ALL 7 statues (plus each statue's 3 guards) of both teams at their fixed positions, all
await this.SpawnStatueAsync(HeykelSavasiTeam.Red, 0).ConfigureAwait(false); // visible from battle start; the opposing team may break them in any order (guards first).
await this.SpawnStatueAsync(HeykelSavasiTeam.Blue, 0).ConfigureAwait(false); for (var i = 0; i < StatuesToBreak; i++)
{
await this.SpawnStatueAsync(HeykelSavasiTeam.Red, i).ConfigureAwait(false);
await this.SpawnStatueAsync(HeykelSavasiTeam.Blue, i).ConfigureAwait(false);
}
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -498,12 +536,38 @@ public class HeykelSavasiContext : MiniGameContext
return; return;
} }
var attacker = Opponent(info.Defender); // The statue is gone; drop its guard bookkeeping too (its guards were already dead - a statue can
this.HandleStatueDestroyed(info.Defender, info.Index, attacker, spawnNext: true); // only be hit once its 3 guards fall - but this keeps the maps from leaking a stale entry).
this._statueGuardsAlive.TryRemove(npc, out _);
// Fire-and-forget the async side effects (buff application, spawning the next statue, or var attacker = Opponent(info.Defender);
// finishing the event); the death event handler itself must stay synchronous.
_ = this.OnStatueDestroyedSideEffectsAsync(info.Defender, info.Index, attacker); // Any-order, count-based progress: this break increments the attacker's running total (0..7),
// independent of which statue index fell.
this.HandleStatueDestroyed(info.Defender, info.Index, attacker, spawnNext: false);
var count = this.GetProgress(attacker);
// 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);
}
/// <inheritdoc />
/// <remarks>
/// ADAMU-CUSTOM (Heykel Savasi): guards spawned by <see cref="SpawnStatueAsync"/> are ordinary
/// <see cref="Monster"/>s, so their <c>Died</c> event is auto-subscribed to this handler by
/// <see cref="MiniGameContext.OnObjectAddedToMapAsync"/>. When a guard dies we decrement the living-guard
/// count of the statue it protected; only once that count reaches 0 does <see cref="IsStatueAttackBlocked"/>
/// let players damage the statue.
/// </remarks>
protected override void OnMonsterDied(object? sender, DeathInformation e)
{
base.OnMonsterDied(sender, e);
if (sender is AttackableNpcBase guard && this._guardToStatue.TryRemove(guard, out var statue))
{
this._statueGuardsAlive.AddOrUpdate(statue, 0, (_, alive) => alive > 0 ? alive - 1 : 0);
}
} }
/// <summary> /// <summary>
@@ -599,40 +663,48 @@ public class HeykelSavasiContext : MiniGameContext
} }
/// <summary> /// <summary>
/// Handles the async side effects of a statue's destruction: applying the destruction buff to the /// Handles the async side effects of a statue's destruction: announcing the break (running counter +
/// attacking team, finishing the event if that statue was the winning one, or spawning the defender's /// killer name) to all event players, applying the count-based destruction buff to the attacking team,
/// next statue (plus guards) otherwise. /// and finishing the event once the attacker has broken all <see cref="StatuesToBreak"/> of the
/// opponent's statues. Statues are no longer spawned here - all of them are spawned up-front at battle
/// start (see <see cref="OnGameStartAsync"/>).
/// </summary> /// </summary>
/// <param name="defender">The team whose statue was destroyed.</param>
/// <param name="index">The index (0..6) of the destroyed statue in <paramref name="defender"/>'s line.</param>
/// <param name="attacker">The team which destroyed the statue.</param> /// <param name="attacker">The team which destroyed the statue.</param>
private async ValueTask OnStatueDestroyedSideEffectsAsync(HeykelSavasiTeam defender, int index, HeykelSavasiTeam attacker) /// <param name="count">The attacker's running number of statues broken so far (1..<see cref="StatuesToBreak"/>).</param>
/// <param name="killerName">The character name of the player who dealt the killing blow.</param>
private async ValueTask OnStatueDestroyedSideEffectsAsync(HeykelSavasiTeam attacker, int count, string killerName)
{ {
try try
{ {
// One-shot HUD broadcast: the statue's progress counter was already updated synchronously by // One-shot HUD broadcast: the progress counter was already updated synchronously by
// HandleStatueDestroyed (called from OnDestructibleDied before this method was scheduled), so every // HandleStatueDestroyed (called from OnDestructibleDied before this method was scheduled), so every
// player sees the new progress immediately rather than waiting for the next periodic tick. // player sees the new progress immediately rather than waiting for the next periodic tick.
await this.BroadcastHudStateAsync().ConfigureAwait(false); await this.BroadcastHudStateAsync().ConfigureAwait(false);
await this.ApplyStatueBuffToTeamAsync(attacker, index).ConfigureAwait(false); // Yellow center announcement to every event player: running counter + the destroyer's name.
var message = $"{count} Heykel {killerName} tarafindan yok edildi";
foreach (var player in this.PlayersOf(HeykelSavasiTeam.Red).Concat(this.PlayersOf(HeykelSavasiTeam.Blue)).ToList())
{
await player.InvokeViewPlugInAsync<MUnique.OpenMU.GameLogic.Views.IShowMessagePlugIn>(
p => p.ShowMessageAsync(message, MUnique.OpenMU.Interfaces.MessageType.GoldenCenter)).ConfigureAwait(false);
}
// Count-based buff: 1st break -> buff index 0, ... 6th -> index 5, 7th -> none (bounds guard).
await this.ApplyStatueBuffToTeamAsync(attacker, count - 1).ConfigureAwait(false);
if (this.GetWinner() == attacker) if (this.GetWinner() == attacker)
{ {
this.FinishEvent(); this.FinishEvent();
return;
} }
await this.SpawnStatueAsync(defender, index + 1).ConfigureAwait(false);
} }
catch (Exception ex) catch (Exception ex)
{ {
this.Logger.LogError(ex, "{context}: Error handling statue-destroyed side effects for defender {defender}, index {index}.", this, defender, index); this.Logger.LogError(ex, "{context}: Error handling statue-destroyed side effects for attacker {attacker}, count {count}.", this, attacker, count);
} }
} }
/// <summary> /// <summary>
/// Spawns <paramref name="defender"/>'s statue at <paramref name="index"/> in its line, plus 4 guard /// Spawns <paramref name="defender"/>'s statue at <paramref name="index"/> in its line, plus 3 guard
/// mobs around it, using the same runtime-spawn API the map initializer itself uses /// mobs around it, using the same runtime-spawn API the map initializer itself uses
/// (<see cref="IMapInitializer.InitializeSpawnAsync"/>; see also Castle Siege's /// (<see cref="IMapInitializer.InitializeSpawnAsync"/>; see also Castle Siege's
/// <c>CastleSiegeEventPlugIn.SpawnCastleDefensesAsync</c> for the reference pattern). The spawned statue /// <c>CastleSiegeEventPlugIn.SpawnCastleDefensesAsync</c> for the reference pattern). The spawned statue
@@ -654,10 +726,9 @@ public class HeykelSavasiContext : MiniGameContext
return; return;
} }
// Each statue (and its guards) spawns at a RANDOM walkable spot anywhere on the map, so // Statues are placed at FIXED coordinates and all spawned up-front, so both teams' 7 statues
// after breaking one statue players must hunt for the next one across the arena. Falls back // are visible from battle start and can be broken in any order.
// to the fixed line position if the terrain can't yield a random coordinate. var pos = positions[index];
var pos = this.Map.Terrain.RandomWalkableCoordinate ?? positions[index];
var statueDefinition = this._gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == StatueMonsterNumber); var statueDefinition = this._gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == StatueMonsterNumber);
if (statueDefinition is null) if (statueDefinition is null)
@@ -666,8 +737,8 @@ public class HeykelSavasiContext : MiniGameContext
return; return;
} }
// Distinct spawn-index ranges per (defender, index), so repeated calls across the event // Distinct spawn-index ranges per (defender, index), so the up-front spawns never collide:
// never collide: e.g. Red index 0 -> 1000..1004, Blue index 3 -> 2030..2034. // e.g. Red index 0 -> 1000..1003, Blue index 3 -> 2030..2033.
var spawnIndexBase = ((int)defender * 1000) + (index * 10); var spawnIndexBase = ((int)defender * 1000) + (index * 10);
var statueSpawnArea = new MonsterSpawnArea var statueSpawnArea = new MonsterSpawnArea
@@ -683,11 +754,16 @@ public class HeykelSavasiContext : MiniGameContext
}; };
var statueNpc = await this._mapInitializer.InitializeSpawnAsync(spawnIndexBase, this.Map, statueSpawnArea, this).ConfigureAwait(false); var statueNpc = await this._mapInitializer.InitializeSpawnAsync(spawnIndexBase, this.Map, statueSpawnArea, this).ConfigureAwait(false);
if (statueNpc is AttackableNpcBase attackable) if (statueNpc is not AttackableNpcBase statue)
{ {
this._statues[attackable] = (defender, index); return;
} }
this._statues[statue] = (defender, index);
// This statue starts fully guarded; it becomes damageable only once all 3 guards are dead.
this._statueGuardsAlive[statue] = GuardsPerStatue;
var guardDefinition = this._gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == GuardMonsterNumber); var guardDefinition = this._gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == GuardMonsterNumber);
if (guardDefinition is null) if (guardDefinition is null)
{ {
@@ -710,17 +786,14 @@ public class HeykelSavasiContext : MiniGameContext
SpawnTrigger = SpawnTrigger.OnceAtEventStart, SpawnTrigger = SpawnTrigger.OnceAtEventStart,
}; };
await this._mapInitializer.InitializeSpawnAsync(spawnIndexBase + 1 + i, this.Map, guardSpawnArea, this).ConfigureAwait(false); var guardNpc = await this._mapInitializer.InitializeSpawnAsync(spawnIndexBase + 1 + i, this.Map, guardSpawnArea, this).ConfigureAwait(false);
}
// Statues spawn at random spots, so announce the new statue's coordinates to every event // Guards are ordinary Monsters; their Died event is auto-subscribed to OnMonsterDied by
// player as a yellow center (GoldenCenter) message — only players inside the event see it. // MiniGameContext.OnObjectAddedToMapAsync. We only need to remember which statue each guards.
var teamName = defender == HeykelSavasiTeam.Red ? "RED" : "BLUE"; if (guardNpc is AttackableNpcBase guard)
var coordMessage = $"{teamName} STATUE appeared! Coordinate: {pos.X} , {pos.Y}"; {
foreach (var announcePlayer in this.PlayersOf(HeykelSavasiTeam.Red).Concat(this.PlayersOf(HeykelSavasiTeam.Blue)).ToList()) this._guardToStatue[guard] = statue;
{ }
await announcePlayer.InvokeViewPlugInAsync<MUnique.OpenMU.GameLogic.Views.IShowMessagePlugIn>(
p => p.ShowMessageAsync(coordMessage, MUnique.OpenMU.Interfaces.MessageType.GoldenCenter)).ConfigureAwait(false);
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -1017,21 +1090,28 @@ public class HeykelSavasiContext : MiniGameContext
public HeykelSavasiTeam GetWinner() => this._winner; public HeykelSavasiTeam GetWinner() => this._winner;
/// <summary> /// <summary>
/// Registers that <paramref name="attacker"/> destroyed the statue at <paramref name="index"/> in /// Registers that <paramref name="attacker"/> destroyed one of the opponent's statues. Progress is a
/// the opponent's line. Once a winner is set, further registrations are ignored. /// running COUNT (statues can be broken in any order), so this simply increments the attacker's total
/// by one; once the count reaches <see cref="StatuesToBreak"/> the attacker is recorded as the winner.
/// Once a winner is set, further registrations are ignored.
/// </summary> /// </summary>
/// <param name="index">The index (0..6) of the destroyed statue.</param> /// <param name="index">
/// The index of the destroyed statue in the defender's line. No longer used for sequencing (breaks are
/// order-independent); retained for signature/call-site compatibility only.
/// </param>
/// <param name="attacker">The team which destroyed the statue.</param> /// <param name="attacker">The team which destroyed the statue.</param>
public void RegisterDestroyed(int index, HeykelSavasiTeam attacker) public void RegisterDestroyed(int index, HeykelSavasiTeam attacker)
{ {
_ = index;
if (this._winner != HeykelSavasiTeam.None) if (this._winner != HeykelSavasiTeam.None)
{ {
return; return;
} }
this._progress[attacker] = index + 1; var newCount = this.GetProgress(attacker) + 1;
this._progress[attacker] = newCount;
if (index + 1 >= StatuesToBreak) if (newCount >= StatuesToBreak)
{ {
this._winner = attacker; this._winner = attacker;
} }

View File

@@ -109,10 +109,11 @@ public abstract class AttackableNpcBase : NonPlayerCharacter, IAttackable
return null; return null;
} }
// ADAMU-CUSTOM: Heykel Savasi -> a statue is immune to damage from its own team (only the enemy may break it). // ADAMU-CUSTOM: Heykel Savasi -> a statue is immune to damage from its own team (only the enemy may
// break it) AND while any of its 3 guard mobs are still alive (guards must be cleared first).
if (attacker is Player heykelStatueAttacker if (attacker is Player heykelStatueAttacker
&& heykelStatueAttacker.CurrentMiniGame is MiniGames.HeykelSavasiContext heykelStatueGame && heykelStatueAttacker.CurrentMiniGame is MiniGames.HeykelSavasiContext heykelStatueGame
&& heykelStatueGame.IsFriendlyStatue(this, heykelStatueAttacker)) && heykelStatueGame.IsStatueAttackBlocked(this, heykelStatueAttacker))
{ {
return null; return null;
} }