From 0f9db37a92a1670261811f4dda854e352ece681f Mon Sep 17 00:00:00 2001 From: Acentech Dev Date: Tue, 21 Jul 2026 14:54:43 +0300 Subject: [PATCH] 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) --- .../MiniGames/HeykelSavasiContext.cs | 226 ++++++++++++------ src/GameLogic/NPC/AttackableNpcBase.cs | 5 +- 2 files changed, 156 insertions(+), 75 deletions(-) diff --git a/src/GameLogic/MiniGames/HeykelSavasiContext.cs b/src/GameLogic/MiniGames/HeykelSavasiContext.cs index 8823b76..3c3789d 100644 --- a/src/GameLogic/MiniGames/HeykelSavasiContext.cs +++ b/src/GameLogic/MiniGames/HeykelSavasiContext.cs @@ -87,9 +87,15 @@ public class HeykelSavasiContext : MiniGameContext private const short GuardMonsterNumber = 580; /// - /// 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 ). /// - 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) }; + + /// + /// The number of guard mobs which spawn next to (and protect) each statue. + /// + private const int GuardsPerStatue = 3; /// /// Maps a destroyed statue's index (0..5) to the @@ -108,37 +114,35 @@ public class HeykelSavasiContext : MiniGameContext private static readonly TimeSpan BuffDuration = TimeSpan.FromMinutes(6); /// - /// 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 ), not for spawn sequencing. /// 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), }; /// - /// 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 ), not for spawn sequencing. /// 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), }; /// @@ -180,6 +184,19 @@ public class HeykelSavasiContext : MiniGameContext /// private readonly ConcurrentDictionary _statues = new(); + /// + /// Maps each spawned statue NPC to the number of its guard mobs which are still alive. A statue starts at + /// and can only be damaged once this reaches 0 (see + /// ); decremented by as guards die. + /// + private readonly ConcurrentDictionary _statueGuardsAlive = new(); + + /// + /// Maps each spawned guard mob to the statue it protects, so can decrement the + /// right statue's living-guard count when a guard dies. The guard is removed once its death is processed. + /// + private readonly ConcurrentDictionary _guardToStatue = new(); + /// /// Pure statue-break progress/winner state, extracted into its own dependency-free type so it can be /// unit-tested without constructing a full (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; /// - /// Returns true if is a Heykel Savasi statue belonging to the - /// same team as — such statues must be immune to friendly damage, - /// otherwise a player could destroy their own team's statue and credit the enemy. + /// Returns true if a hit on by 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 is not a tracked statue, the hit is never + /// blocked here (normal damage). Called from AttackableNpcBase.AttackByAsync. /// - public bool IsFriendlyStatue(AttackableNpcBase npc, Player attacker) - => this._statues.TryGetValue(npc, out var info) && info.Defender == this.GetTeam(attacker); + /// The NPC being attacked. + /// The attacking player. + /// true if the hit should be blocked; otherwise, false. + 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; + } /// /// 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); + } } /// @@ -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); + } + + /// + /// + /// ADAMU-CUSTOM (Heykel Savasi): guards spawned by are ordinary + /// s, so their Died event is auto-subscribed to this handler by + /// . When a guard dies we decrement the living-guard + /// count of the statue it protected; only once that count reaches 0 does + /// let players damage the statue. + /// + 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); + } } /// @@ -599,40 +663,48 @@ public class HeykelSavasiContext : MiniGameContext } /// - /// 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 of the + /// opponent's statues. Statues are no longer spawned here - all of them are spawned up-front at battle + /// start (see ). /// - /// The team whose statue was destroyed. - /// The index (0..6) of the destroyed statue in 's line. /// The team which destroyed the statue. - private async ValueTask OnStatueDestroyedSideEffectsAsync(HeykelSavasiTeam defender, int index, HeykelSavasiTeam attacker) + /// The attacker's running number of statues broken so far (1..). + /// The character name of the player who dealt the killing blow. + 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( + 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); } } /// - /// Spawns 's statue at in its line, plus 4 guard + /// Spawns 's statue at in its line, plus 3 guard /// mobs around it, using the same runtime-spawn API the map initializer itself uses /// (; see also Castle Siege's /// CastleSiegeEventPlugIn.SpawnCastleDefensesAsync 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( - 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; /// - /// Registers that destroyed the statue at in - /// the opponent's line. Once a winner is set, further registrations are ignored. + /// Registers that 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 the attacker is recorded as the winner. + /// Once a winner is set, further registrations are ignored. /// - /// The index (0..6) of the destroyed statue. + /// + /// 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. + /// /// The team which destroyed the statue. 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; } diff --git a/src/GameLogic/NPC/AttackableNpcBase.cs b/src/GameLogic/NPC/AttackableNpcBase.cs index bc3f6bf..80c8979 100644 --- a/src/GameLogic/NPC/AttackableNpcBase.cs +++ b/src/GameLogic/NPC/AttackableNpcBase.cs @@ -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; }