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:
@@ -87,9 +87,15 @@ public class HeykelSavasiContext : MiniGameContext
|
||||
private const short GuardMonsterNumber = 580;
|
||||
|
||||
/// <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>
|
||||
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>
|
||||
/// 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);
|
||||
|
||||
/// <summary>
|
||||
/// PLACEHOLDER coordinates: the Heykel Savasi map terrain is not final yet, so these positions are an
|
||||
/// approximate straight line from mid-map toward the red base (20, 20). Index 0 is the outermost statue
|
||||
/// (attacked first by the blue team), index 6 is the final statue, closest to the red base (win condition
|
||||
/// for blue once broken).
|
||||
/// The FIXED positions of the red team's 7 statues (attacked by the blue team). All 7 are spawned and
|
||||
/// visible at battle start and can be broken in ANY order; the index (0..6) is retained only for the
|
||||
/// per-break buff mapping (see <see cref="StatueBuffEffectNumbers"/>), not for spawn sequencing.
|
||||
/// </summary>
|
||||
private static readonly Point[] RedStatuePositions =
|
||||
{
|
||||
new(42, 51),
|
||||
new(42, 45),
|
||||
new(42, 38),
|
||||
new(42, 31),
|
||||
new(42, 25),
|
||||
new(42, 15),
|
||||
new(42, 8),
|
||||
new(43, 20),
|
||||
new(56, 26),
|
||||
new(72, 43),
|
||||
new(43, 28),
|
||||
new(43, 44),
|
||||
new(29, 26),
|
||||
new(14, 43),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// PLACEHOLDER coordinates: the Heykel Savasi map terrain is not final yet, so these positions are an
|
||||
/// approximate straight line from mid-map toward the blue base (220, 220). Index 0 is the outermost statue
|
||||
/// (attacked first by the red team), index 6 is the final statue, closest to the blue base (win condition
|
||||
/// for red once broken).
|
||||
/// The FIXED positions of the blue team's 7 statues (attacked by the red team). All 7 are spawned and
|
||||
/// visible at battle start and can be broken in ANY order; the index (0..6) is retained only for the
|
||||
/// per-break buff mapping (see <see cref="StatueBuffEffectNumbers"/>), not for spawn sequencing.
|
||||
/// </summary>
|
||||
private static readonly Point[] BlueStatuePositions =
|
||||
{
|
||||
new(42, 53),
|
||||
new(42, 59),
|
||||
new(42, 66),
|
||||
new(42, 73),
|
||||
new(42, 79),
|
||||
new(42, 87),
|
||||
new(42, 94),
|
||||
new(16, 61),
|
||||
new(31, 77),
|
||||
new(43, 72),
|
||||
new(43, 58),
|
||||
new(72, 60),
|
||||
new(57, 76),
|
||||
new(43, 82),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
@@ -180,6 +184,19 @@ public class HeykelSavasiContext : MiniGameContext
|
||||
/// </summary>
|
||||
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>
|
||||
/// 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
|
||||
@@ -247,12 +264,29 @@ public class HeykelSavasiContext : MiniGameContext
|
||||
public HeykelSavasiTeam GetTeam(Player player) => this._teams.TryGetValue(player, out var team) ? team : HeykelSavasiTeam.None;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if <paramref name="npc"/> is a Heykel Savasi statue belonging to the
|
||||
/// same team as <paramref name="attacker"/> — such statues must be immune to friendly damage,
|
||||
/// otherwise a player could destroy their own team's statue and credit the enemy.
|
||||
/// Returns <c>true</c> if a hit on <paramref name="npc"/> by <paramref name="attacker"/> must be blocked.
|
||||
/// A statue is protected (undamageable) while EITHER it belongs to the attacker's own team (a player may
|
||||
/// 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>
|
||||
public bool IsFriendlyStatue(AttackableNpcBase npc, Player attacker)
|
||||
=> this._statues.TryGetValue(npc, out var info) && info.Defender == this.GetTeam(attacker);
|
||||
/// <param name="npc">The NPC being attacked.</param>
|
||||
/// <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>
|
||||
/// 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.
|
||||
await this.BroadcastHudStateAsync().ConfigureAwait(false);
|
||||
|
||||
// Spawn the first (outermost) statue of each team's line; the opposing team attacks it.
|
||||
await this.SpawnStatueAsync(HeykelSavasiTeam.Red, 0).ConfigureAwait(false);
|
||||
await this.SpawnStatueAsync(HeykelSavasiTeam.Blue, 0).ConfigureAwait(false);
|
||||
// Spawn ALL 7 statues (plus each statue's 3 guards) of both teams at their fixed positions, all
|
||||
// visible from battle start; the opposing team may break them in any order (guards first).
|
||||
for (var i = 0; i < StatuesToBreak; i++)
|
||||
{
|
||||
await this.SpawnStatueAsync(HeykelSavasiTeam.Red, i).ConfigureAwait(false);
|
||||
await this.SpawnStatueAsync(HeykelSavasiTeam.Blue, i).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -498,12 +536,38 @@ public class HeykelSavasiContext : MiniGameContext
|
||||
return;
|
||||
}
|
||||
|
||||
var attacker = Opponent(info.Defender);
|
||||
this.HandleStatueDestroyed(info.Defender, info.Index, attacker, spawnNext: true);
|
||||
// The statue is gone; drop its guard bookkeeping too (its guards were already dead - a statue can
|
||||
// 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
|
||||
// finishing the event); the death event handler itself must stay synchronous.
|
||||
_ = this.OnStatueDestroyedSideEffectsAsync(info.Defender, info.Index, attacker);
|
||||
var attacker = Opponent(info.Defender);
|
||||
|
||||
// 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>
|
||||
@@ -599,40 +663,48 @@ public class HeykelSavasiContext : MiniGameContext
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the async side effects of a statue's destruction: applying the destruction buff to the
|
||||
/// attacking team, finishing the event if that statue was the winning one, or spawning the defender's
|
||||
/// next statue (plus guards) otherwise.
|
||||
/// Handles the async side effects of a statue's destruction: announcing the break (running counter +
|
||||
/// killer name) to all event players, applying the count-based destruction buff to the attacking team,
|
||||
/// 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>
|
||||
/// <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>
|
||||
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
|
||||
{
|
||||
// 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
|
||||
// player sees the new progress immediately rather than waiting for the next periodic tick.
|
||||
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)
|
||||
{
|
||||
this.FinishEvent();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.SpawnStatueAsync(defender, index + 1).ConfigureAwait(false);
|
||||
}
|
||||
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>
|
||||
/// 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
|
||||
/// (<see cref="IMapInitializer.InitializeSpawnAsync"/>; see also Castle Siege's
|
||||
/// <c>CastleSiegeEventPlugIn.SpawnCastleDefensesAsync</c> for the reference pattern). The spawned statue
|
||||
@@ -654,10 +726,9 @@ public class HeykelSavasiContext : MiniGameContext
|
||||
return;
|
||||
}
|
||||
|
||||
// Each statue (and its guards) spawns at a RANDOM walkable spot anywhere on the map, so
|
||||
// after breaking one statue players must hunt for the next one across the arena. Falls back
|
||||
// to the fixed line position if the terrain can't yield a random coordinate.
|
||||
var pos = this.Map.Terrain.RandomWalkableCoordinate ?? positions[index];
|
||||
// Statues are placed at FIXED coordinates and all spawned up-front, so both teams' 7 statues
|
||||
// are visible from battle start and can be broken in any order.
|
||||
var pos = positions[index];
|
||||
|
||||
var statueDefinition = this._gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == StatueMonsterNumber);
|
||||
if (statueDefinition is null)
|
||||
@@ -666,8 +737,8 @@ public class HeykelSavasiContext : MiniGameContext
|
||||
return;
|
||||
}
|
||||
|
||||
// Distinct spawn-index ranges per (defender, index), so repeated calls across the event
|
||||
// never collide: e.g. Red index 0 -> 1000..1004, Blue index 3 -> 2030..2034.
|
||||
// Distinct spawn-index ranges per (defender, index), so the up-front spawns never collide:
|
||||
// e.g. Red index 0 -> 1000..1003, Blue index 3 -> 2030..2033.
|
||||
var spawnIndexBase = ((int)defender * 1000) + (index * 10);
|
||||
|
||||
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);
|
||||
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);
|
||||
if (guardDefinition is null)
|
||||
{
|
||||
@@ -710,17 +786,14 @@ public class HeykelSavasiContext : MiniGameContext
|
||||
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
|
||||
// player as a yellow center (GoldenCenter) message — only players inside the event see it.
|
||||
var teamName = defender == HeykelSavasiTeam.Red ? "RED" : "BLUE";
|
||||
var coordMessage = $"{teamName} STATUE appeared! Coordinate: {pos.X} , {pos.Y}";
|
||||
foreach (var announcePlayer in this.PlayersOf(HeykelSavasiTeam.Red).Concat(this.PlayersOf(HeykelSavasiTeam.Blue)).ToList())
|
||||
{
|
||||
await announcePlayer.InvokeViewPlugInAsync<MUnique.OpenMU.GameLogic.Views.IShowMessagePlugIn>(
|
||||
p => p.ShowMessageAsync(coordMessage, MUnique.OpenMU.Interfaces.MessageType.GoldenCenter)).ConfigureAwait(false);
|
||||
// Guards are ordinary Monsters; their Died event is auto-subscribed to OnMonsterDied by
|
||||
// MiniGameContext.OnObjectAddedToMapAsync. We only need to remember which statue each guards.
|
||||
if (guardNpc is AttackableNpcBase guard)
|
||||
{
|
||||
this._guardToStatue[guard] = statue;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -1017,21 +1090,28 @@ public class HeykelSavasiContext : MiniGameContext
|
||||
public HeykelSavasiTeam GetWinner() => this._winner;
|
||||
|
||||
/// <summary>
|
||||
/// Registers that <paramref name="attacker"/> destroyed the statue at <paramref name="index"/> in
|
||||
/// the opponent's line. Once a winner is set, further registrations are ignored.
|
||||
/// Registers that <paramref name="attacker"/> destroyed one of the opponent's statues. Progress is a
|
||||
/// 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>
|
||||
/// <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>
|
||||
public void RegisterDestroyed(int index, HeykelSavasiTeam attacker)
|
||||
{
|
||||
_ = index;
|
||||
if (this._winner != HeykelSavasiTeam.None)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -109,10 +109,11 @@ public abstract class AttackableNpcBase : NonPlayerCharacter, IAttackable
|
||||
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
|
||||
&& heykelStatueAttacker.CurrentMiniGame is MiniGames.HeykelSavasiContext heykelStatueGame
|
||||
&& heykelStatueGame.IsFriendlyStatue(this, heykelStatueAttacker))
|
||||
&& heykelStatueGame.IsStatueAttackBlocked(this, heykelStatueAttacker))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user