feat(tvt): 4 arena bosses at 3 statues, robust guard/boss spawn, reliable GM restart, English strings
- Bosses: 306/309/357/459 spawn once at (55,42)/(32,42)/(31,59)/(56,58) after 3 total statues broken. - Guards/bosses spawn in a walkable BOX (retry) instead of a fixed point; several statue-guard offsets landed on non-walkable terrain and silently failed, leaving statues unbreakable. - Guard-alive gate uses the ACTUAL spawned count so a statue can always reach 0 -> breakable. - HeykelSavasiStartPlugIn.OnStartedAsync force-disposes a lingering finished context so GM /starths restarts the event immediately (no server restart). - Statue-break + join messages translated to English.
This commit is contained in:
@@ -87,16 +87,39 @@ public class HeykelSavasiContext : MiniGameContext
|
||||
private const short GuardMonsterNumber = 580;
|
||||
|
||||
/// <summary>
|
||||
/// 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"/>).
|
||||
/// The radius (in tiles) of the box around a statue in which its guards spawn. A BOX (rather than fixed
|
||||
/// offsets) lets the spawn logic retry to find a WALKABLE tile: several statues sit next to
|
||||
/// non-walkable terrain (water/arena edges) where a fixed offset would land off-map, the guard would
|
||||
/// silently fail to spawn, and the statue would stay permanently unbreakable (its guard-alive count
|
||||
/// could never reach 0). See <see cref="SpawnStatueAsync"/>.
|
||||
/// </summary>
|
||||
private static readonly (int Dx, int Dy)[] GuardOffsets = { (-2, 0), (2, 0), (0, 2) };
|
||||
private const int GuardSpawnRadius = 2;
|
||||
|
||||
/// <summary>
|
||||
/// The number of guard mobs which spawn next to (and protect) each statue.
|
||||
/// </summary>
|
||||
private const int GuardsPerStatue = 3;
|
||||
|
||||
/// <summary>
|
||||
/// The total number of statues (both teams combined) that must be broken before the 4 arena bosses
|
||||
/// spawn (see <see cref="SpawnBossesAsync"/>). Requested by design: "these bosses spawn when a total
|
||||
/// of 3 statues have been broken".
|
||||
/// </summary>
|
||||
private const int BossSpawnAtTotalDestroyed = 3;
|
||||
|
||||
/// <summary>
|
||||
/// The 4 arena bosses (existing S6 boss monster numbers, so the client already has their models) and
|
||||
/// the fixed coordinates at which they spawn once <see cref="BossSpawnAtTotalDestroyed"/> statues have
|
||||
/// fallen: 306=Death Rider, 309=Hell Maine, 357=Genocider, 459=Selupan.
|
||||
/// </summary>
|
||||
private static readonly (short Number, byte X, byte Y)[] BossSpawns =
|
||||
{
|
||||
(306, 55, 42),
|
||||
(309, 32, 42),
|
||||
(357, 31, 59),
|
||||
(459, 56, 58),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Maps a destroyed statue's index (0..5) to the <see cref="MUnique.OpenMU.DataModel.Configuration.MagicEffectDefinition.Number"/>
|
||||
/// of the class buff granted to the whole attacking team. All of these effects already exist in
|
||||
@@ -204,6 +227,12 @@ public class HeykelSavasiContext : MiniGameContext
|
||||
/// </summary>
|
||||
private readonly StatueProgressState _progress = new();
|
||||
|
||||
/// <summary>
|
||||
/// Guards the one-shot arena boss spawn (see <see cref="SpawnBossesAsync"/>): set to 1 the first time
|
||||
/// <see cref="BossSpawnAtTotalDestroyed"/> statues have fallen, so the 4 bosses spawn exactly once.
|
||||
/// </summary>
|
||||
private int _bossesSpawned;
|
||||
|
||||
/// <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.
|
||||
@@ -682,7 +711,7 @@ public class HeykelSavasiContext : MiniGameContext
|
||||
await this.BroadcastHudStateAsync().ConfigureAwait(false);
|
||||
|
||||
// Yellow center announcement to every event player: running counter + the destroyer's name.
|
||||
var message = $"{count} Heykel {killerName} tarafindan yok edildi";
|
||||
var message = $"Statue {count} destroyed by {killerName}!";
|
||||
foreach (var player in this.PlayersOf(HeykelSavasiTeam.Red).Concat(this.PlayersOf(HeykelSavasiTeam.Blue)).ToList())
|
||||
{
|
||||
await player.InvokeViewPlugInAsync<MUnique.OpenMU.GameLogic.Views.IShowMessagePlugIn>(
|
||||
@@ -692,6 +721,14 @@ public class HeykelSavasiContext : MiniGameContext
|
||||
// Count-based buff: 1st break -> buff index 0, ... 6th -> index 5, 7th -> none (bounds guard).
|
||||
await this.ApplyStatueBuffToTeamAsync(attacker, count - 1).ConfigureAwait(false);
|
||||
|
||||
// Once BossSpawnAtTotalDestroyed statues (both teams combined) have fallen, spawn the 4 arena
|
||||
// bosses exactly once. Interlocked guards against two near-simultaneous breaks both triggering.
|
||||
if (this._progress.TotalDestroyed >= BossSpawnAtTotalDestroyed
|
||||
&& Interlocked.CompareExchange(ref this._bossesSpawned, 1, 0) == 0)
|
||||
{
|
||||
await this.SpawnBossesAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (this.GetWinner() == attacker)
|
||||
{
|
||||
this.FinishEvent();
|
||||
@@ -761,9 +798,6 @@ public class HeykelSavasiContext : MiniGameContext
|
||||
|
||||
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)
|
||||
{
|
||||
@@ -771,17 +805,20 @@ public class HeykelSavasiContext : MiniGameContext
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < GuardOffsets.Length; i++)
|
||||
var spawnedGuards = 0;
|
||||
for (var i = 0; i < GuardsPerStatue; i++)
|
||||
{
|
||||
var (dx, dy) = GuardOffsets[i];
|
||||
// Guards spawn on a WALKABLE tile in a small box around the statue. A box (not a fixed point)
|
||||
// lets InitializeSpawnAsync retry to find a walkable tile near statues that sit against
|
||||
// non-walkable terrain (see GuardSpawnRadius).
|
||||
var guardSpawnArea = new MonsterSpawnArea
|
||||
{
|
||||
MonsterDefinition = guardDefinition,
|
||||
Quantity = 1,
|
||||
X1 = (byte)(pos.X + dx),
|
||||
X2 = (byte)(pos.X + dx),
|
||||
Y1 = (byte)(pos.Y + dy),
|
||||
Y2 = (byte)(pos.Y + dy),
|
||||
X1 = (byte)Math.Clamp(pos.X - GuardSpawnRadius, 1, 254),
|
||||
X2 = (byte)Math.Clamp(pos.X + GuardSpawnRadius, 1, 254),
|
||||
Y1 = (byte)Math.Clamp(pos.Y - GuardSpawnRadius, 1, 254),
|
||||
Y2 = (byte)Math.Clamp(pos.Y + GuardSpawnRadius, 1, 254),
|
||||
Direction = Direction.South,
|
||||
SpawnTrigger = SpawnTrigger.OnceAtEventStart,
|
||||
};
|
||||
@@ -793,8 +830,14 @@ public class HeykelSavasiContext : MiniGameContext
|
||||
if (guardNpc is AttackableNpcBase guard)
|
||||
{
|
||||
this._guardToStatue[guard] = statue;
|
||||
spawnedGuards++;
|
||||
}
|
||||
}
|
||||
|
||||
// The statue becomes damageable only once all of its ACTUALLY-SPAWNED guards are dead. Using the
|
||||
// real spawned count (not the constant) guarantees the gate can always reach 0 even if a guard
|
||||
// still failed to spawn - otherwise that statue would be permanently unbreakable.
|
||||
this._statueGuardsAlive[statue] = spawnedGuards;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -802,6 +845,58 @@ public class HeykelSavasiContext : MiniGameContext
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spawns the 4 arena bosses (see <see cref="BossSpawns"/>) at their fixed coordinates. Triggered once,
|
||||
/// from <see cref="OnStatueDestroyedSideEffectsAsync"/>, after <see cref="BossSpawnAtTotalDestroyed"/>
|
||||
/// statues (both teams combined) have been broken. Each boss spawns on a walkable tile in a small box
|
||||
/// around its coordinate (same retry-tolerant approach as the guards). Drops are the bosses' own default
|
||||
/// loot for now (to be tuned later).
|
||||
/// </summary>
|
||||
private async ValueTask SpawnBossesAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Broadcast the boss arrival to every event player (yellow center message).
|
||||
const string bossMessage = "The arena bosses have appeared!";
|
||||
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(bossMessage, MUnique.OpenMU.Interfaces.MessageType.GoldenCenter)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
for (var i = 0; i < BossSpawns.Length; i++)
|
||||
{
|
||||
var (number, x, y) = BossSpawns[i];
|
||||
var bossDefinition = this._gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == number);
|
||||
if (bossDefinition is null)
|
||||
{
|
||||
this.Logger.LogWarning("{context}: Boss monster definition {number} not found.", this, number);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Bosses are Monsters -> need a walkable tile; use a small box so the spawn logic can retry.
|
||||
var bossSpawnArea = new MonsterSpawnArea
|
||||
{
|
||||
MonsterDefinition = bossDefinition,
|
||||
Quantity = 1,
|
||||
X1 = (byte)Math.Clamp(x - GuardSpawnRadius, 1, 254),
|
||||
X2 = (byte)Math.Clamp(x + GuardSpawnRadius, 1, 254),
|
||||
Y1 = (byte)Math.Clamp(y - GuardSpawnRadius, 1, 254),
|
||||
Y2 = (byte)Math.Clamp(y + GuardSpawnRadius, 1, 254),
|
||||
Direction = Direction.South,
|
||||
SpawnTrigger = SpawnTrigger.OnceAtEventStart,
|
||||
};
|
||||
|
||||
// Distinct spawn-index range for bosses (9000+), so they never collide with statues/guards.
|
||||
await this._mapInitializer.InitializeSpawnAsync(9000 + i, this.Map, bossSpawnArea, this).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.Logger.LogError(ex, "{context}: Error spawning arena bosses.", this);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the class buff mapped to <paramref name="statueIndex"/> (see <see cref="StatueBuffEffectNumbers"/>)
|
||||
/// to every player currently on <paramref name="team"/>. A no-op for the 7th statue (index 6, out of
|
||||
@@ -1084,6 +1179,12 @@ public class HeykelSavasiContext : MiniGameContext
|
||||
/// <param name="attacker">The attacking team.</param>
|
||||
public int GetProgress(HeykelSavasiTeam attacker) => this._progress.TryGetValue(attacker, out var value) ? value : 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of statues destroyed so far by both teams combined (used to trigger the
|
||||
/// arena boss spawn once <see cref="BossSpawnAtTotalDestroyed"/> is reached).
|
||||
/// </summary>
|
||||
public int TotalDestroyed => this.GetProgress(HeykelSavasiTeam.Red) + this.GetProgress(HeykelSavasiTeam.Blue);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the winning team, or <see cref="HeykelSavasiTeam.None"/> if undecided.
|
||||
/// </summary>
|
||||
|
||||
@@ -29,7 +29,7 @@ public class HeykelSavasiJoinAction
|
||||
else
|
||||
{
|
||||
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p =>
|
||||
p.ShowMessageAsync("Takima simdi katilamadin (denge/durum).", MessageType.BlueNormal)).ConfigureAwait(false);
|
||||
p.ShowMessageAsync("You can't join a team right now (team balance/status).", MessageType.BlueNormal)).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,4 +30,31 @@ public sealed class HeykelSavasiStartPlugIn : MiniGameStartBasePlugIn<HeykelSava
|
||||
{
|
||||
return new HeykelSavasiGameServerState(gameContext);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask OnStartedAsync(HeykelSavasiGameServerState state)
|
||||
{
|
||||
// A GM /starths must work even immediately after a previous run ended. When the event finishes, its
|
||||
// MiniGameContext lingers in a terminal state (Ended/Closed) for its exit ceremony (~60s) BEFORE it
|
||||
// disposes; during that window GetMiniGameAsync (which only skips disposed/disposing contexts) would
|
||||
// return that stale, already-finished context instead of opening a fresh event - so nothing starts
|
||||
// until a server restart clears it. Force-dispose any lingering non-running context here first, so the
|
||||
// base implementation then creates a brand-new event. A genuinely running game (Open/Playing) is left
|
||||
// untouched (the base call just reuses it - no double start).
|
||||
var definitions = state.Context.Configuration.MiniGameDefinitions
|
||||
.Where(d => d.Type == this.Key && d.MapCreationPolicy == MiniGameMapCreationPolicy.Shared)
|
||||
.ToList();
|
||||
|
||||
foreach (var definition in definitions)
|
||||
{
|
||||
var existing = await state.Context.GetMiniGameAsync(definition, null!).ConfigureAwait(false);
|
||||
if (existing is { IsDisposed: false, IsDisposing: false }
|
||||
&& existing.State is not MiniGameState.Open and not MiniGameState.Playing)
|
||||
{
|
||||
await existing.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
await base.OnStartedAsync(state).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user