ModernUO/Projects/UOContent/Mobiles/Townfolk/TownCrier.cs
Kamron Batman 50599e0453
perf: Migrate NPC and Skill UI gumps to DynamicGump/StaticGump (#2414)
## Summary

Migrates five legacy `Gump`-derived UI dialogs in the NPC and Skill domains to the modern `DynamicGump` builder pipeline. All five gumps were chosen as `DynamicGump` rather than `StaticGump<T>` because their layout shape varies per instance, and several of them carry per-instance localization numbers (cliloc IDs) that the static cache cannot bake (see CLAUDE.md gump-system rule and `dev-docs/gump-system.md`).

Per-gump rationale:

- **`TownCrierGump` (`Mobiles/Townfolk/TownCrier.cs`)** - DynamicGump. Announcement count varies, expiration text is rebuilt per render via `ValueStringBuilder`, and one button per entry is emitted in a loop.
- **`ClaimListGump` (`Mobiles/Vendors/NPC/AnimalTrainer.cs`)** - DynamicGump. The pet list and resulting background/alpha-region heights vary per stabling player.
- **`AnimalLoreGump` (`Skills/AnimalLore.cs`)** - DynamicGump. Page count itself varies (3 pages pre-AOS, 5 pages on AOS) and several `AddHtmlLocalized` calls use cliloc IDs computed from per-creature data (loyalty rating `1049595 + c.Loyalty / 10`, food preference, pack instinct), which violates the StaticGump cliloc-bake rule.
- **`DisguiseGump` (`Items/Skill Items/Thief/DisguiseKit.cs`)** - DynamicGump. Page count and entry order shift on `from.Female`, `Body.IsFemale`, and `startAtHair`.
- **`CommentsGump` (`Gumps/CommentsGump.cs`)** - DynamicGump. Comment list and pagination depend on `Account.Comments` size; the title label encodes the variable account username string.

All five are now `Singleton => true`, have `private` constructors, and expose static `DisplayTo(...)` entry points that validate prerequisites before constructing - guaranteeing no empty gumps (CLAUDE.md Sec.13). All internal refresh paths (prompts, `OnDoubleClick`, command handlers, target callbacks) were updated to call `DisplayTo` rather than `new XGump(...)`. Legacy `m_`-prefixed fields renamed to `_camelCase` (CLAUDE.md Sec.12), and `DisguiseEntry`'s `m_`-prefixed public readonly fields converted to PascalCase auto-properties. `OnResponse` signatures updated to `in RelayInfo info`. No external callers needed updating - all `new XGump(...)` sites lived inside the same files.
2026-04-25 19:51:42 -07:00

522 lines
13 KiB
C#

using System;
using System.Collections.Generic;
using ModernUO.Serialization;
using Server.Text;
using Server.Gumps;
using Server.Items;
using Server.Network;
using Server.Prompts;
namespace Server.Mobiles;
public interface ITownCrierEntryList
{
List<TownCrierEntry> Entries { get; }
TownCrierEntry GetRandomEntry();
TownCrierEntry AddEntry(string[] lines, TimeSpan duration);
void RemoveEntry(TownCrierEntry entry);
}
public class GlobalTownCrierEntryList : ITownCrierEntryList
{
private static GlobalTownCrierEntryList m_Instance;
public static GlobalTownCrierEntryList Instance => m_Instance ??= new GlobalTownCrierEntryList();
public bool IsEmpty => Entries == null || Entries.Count == 0;
public List<TownCrierEntry> Entries { get; private set; }
public TownCrierEntry GetRandomEntry()
{
for (var i = (Entries?.Count ?? 0) - 1; i >= 0; --i)
{
if (i >= Entries!.Count)
{
continue;
}
var tce = Entries[i];
if (tce.Expired)
{
RemoveEntry(tce);
}
}
return Entries?.RandomElement();
}
public TownCrierEntry AddEntry(string[] lines, TimeSpan duration)
{
Entries ??= new List<TownCrierEntry>();
var tce = new TownCrierEntry(lines, duration);
Entries.Add(tce);
var instances = TownCrier.Instances;
for (var i = 0; i < instances.Count; ++i)
{
instances[i].ForceBeginAutoShout();
}
return tce;
}
public void RemoveEntry(TownCrierEntry tce)
{
if (Entries == null)
{
return;
}
Entries.Remove(tce);
if (Entries.Count == 0)
{
Entries = null;
}
}
public static void Configure()
{
CommandSystem.Register("TownCriers", AccessLevel.GameMaster, TownCriers_OnCommand);
}
[Usage("TownCriers"), Description("Manages the global town crier list.")]
public static void TownCriers_OnCommand(CommandEventArgs e)
{
TownCrierGump.DisplayTo(e.Mobile, Instance);
}
}
public class TownCrierEntry
{
public TownCrierEntry(string[] lines, TimeSpan duration)
{
Lines = lines;
ExpireTime = Core.Now + duration.Clamp(TimeSpan.Zero, TimeSpan.FromDays(365));
}
public string[] Lines { get; }
public DateTime ExpireTime { get; }
public bool Expired => Core.Now >= ExpireTime;
}
public class TownCrierDurationPrompt : Prompt
{
private readonly ITownCrierEntryList m_Owner;
public TownCrierDurationPrompt(ITownCrierEntryList owner) => m_Owner = owner;
public override void OnResponse(Mobile from, string text)
{
if (!TimeSpan.TryParse(text, out var ts))
{
from.SendMessage("Value was not properly formatted. Use: <hours:minutes:seconds, 00:00:00>");
TownCrierGump.DisplayTo(from, m_Owner);
return;
}
if (ts < TimeSpan.Zero)
{
ts = TimeSpan.Zero;
}
from.SendMessage($"Duration set to: {ts}");
from.SendMessage("Enter the first line to shout:");
from.Prompt = new TownCrierLinesPrompt(m_Owner, null, new List<string>(), ts);
}
public override void OnCancel(Mobile from)
{
from.SendLocalizedMessage(502980); // Message entry cancelled.
TownCrierGump.DisplayTo(from, m_Owner);
}
}
public class TownCrierLinesPrompt : Prompt
{
private readonly TimeSpan m_Duration;
private readonly TownCrierEntry m_Entry;
private readonly List<string> m_Lines;
private readonly ITownCrierEntryList m_Owner;
public TownCrierLinesPrompt(ITownCrierEntryList owner, TownCrierEntry entry, List<string> lines, TimeSpan duration)
{
m_Owner = owner;
m_Entry = entry;
m_Lines = lines;
m_Duration = duration;
}
public override void OnResponse(Mobile from, string text)
{
m_Lines.Add(text);
from.SendMessage("Enter the next line to shout, or press <ESC> if the message is finished.");
from.Prompt = new TownCrierLinesPrompt(m_Owner, m_Entry, m_Lines, m_Duration);
}
public override void OnCancel(Mobile from)
{
if (m_Entry != null)
{
m_Owner.RemoveEntry(m_Entry);
}
if (m_Lines.Count > 0)
{
m_Owner.AddEntry(m_Lines.ToArray(), m_Duration);
from.SendMessage("Message has been set.");
}
else
{
if (m_Entry != null)
{
from.SendMessage("Message deleted.");
}
else
{
from.SendLocalizedMessage(502980); // Message entry cancelled.
}
}
TownCrierGump.DisplayTo(from, m_Owner);
}
}
public class TownCrierGump : DynamicGump
{
private readonly ITownCrierEntryList _owner;
public override bool Singleton => true;
private TownCrierGump(ITownCrierEntryList owner) : base(50, 50) => _owner = owner;
public static void DisplayTo(Mobile from, ITownCrierEntryList owner)
{
if (from?.NetState == null || owner == null)
{
return;
}
from.SendGump(new TownCrierGump(owner));
}
protected override void BuildLayout(ref DynamicGumpBuilder builder)
{
builder.AddPage();
_owner.GetRandomEntry(); // force expiration checks
var entries = _owner.Entries;
var count = entries?.Count ?? 0;
builder.AddImageTiled(0, 0, 300, 38 + (count == 0 ? 20 : count * 85), 0xA40);
builder.AddAlphaRegion(1, 1, 298, 36 + (count == 0 ? 20 : count * 85));
builder.AddHtml(8, 8, 300 - 8 - 30, 20, "<basefont color=#FFFFFF><center>TOWN CRIER MESSAGES</center></basefont>");
builder.AddButton(300 - 8 - 30, 8, 0xFAB, 0xFAD, 1);
if (count == 0)
{
builder.AddHtml(8, 30, 284, 20, "<basefont color=#FFFFFF>The crier has no news.</basefont>");
return;
}
for (var i = 0; i < entries!.Count; ++i)
{
var tce = entries[i];
var toExpire = Utility.Max(tce.ExpireTime - Core.Now, TimeSpan.Zero);
using var sb = ValueStringBuilder.Create(512);
sb.Append("[Expires: ");
if (toExpire.TotalHours >= 1)
{
sb.Append((int)toExpire.TotalHours);
sb.Append(':');
sb.Append(toExpire.Minutes.ToString("D2"));
}
else
{
sb.Append(toExpire.Minutes);
}
sb.Append(':');
sb.Append(toExpire.Seconds.ToString("D2"));
sb.Append("] ");
for (var j = 0; j < tce.Lines.Length; ++j)
{
if (j > 0)
{
sb.Append("<br>");
}
sb.Append(tce.Lines[j]);
}
builder.AddHtml(8, 35 + i * 85, 254, 80, sb.ToString(), background: true, scrollbar: true);
builder.AddButton(300 - 8 - 26, 35 + i * 85, 0x15E1, 0x15E5, 2 + i);
}
}
public override void OnResponse(NetState sender, in RelayInfo info)
{
var from = sender.Mobile;
if (info.ButtonID == 1)
{
from.SendMessage("Enter the duration for the new message. Format: <hours:minutes:seconds, 00:00:00>");
from.Prompt = new TownCrierDurationPrompt(_owner);
}
else if (info.ButtonID > 1)
{
var entries = _owner.Entries;
var index = info.ButtonID - 2;
if (index < entries?.Count)
{
var tce = entries[index];
var ts = Utility.Max(tce.ExpireTime - Core.Now, TimeSpan.Zero);
from.SendMessage($"Editing entry #{index + 1}.");
from.SendMessage("Enter the first line to shout:");
from.Prompt = new TownCrierLinesPrompt(_owner, tce, new List<string>(), ts);
}
}
}
}
[SerializationGenerator(0, false)]
public partial class TownCrier : Mobile, ITownCrierEntryList
{
private Timer _autoShoutTimer;
private Timer _newsTimer;
[Constructible]
public TownCrier()
{
Instances.Add(this);
InitStats(100, 100, 25);
Title = "the town crier";
Hue = Race.Human.RandomSkinHue();
if (!Core.AOS)
{
NameHue = 0x35;
}
if (Female = Utility.RandomBool())
{
Body = 0x191;
Name = NameList.RandomName("female");
}
else
{
Body = 0x190;
Name = NameList.RandomName("male");
}
AddItem(new FancyShirt(Utility.RandomBlueHue()));
var skirt = Utility.Random(2) switch
{
0 => (Item)new Skirt(),
1 => new Kilt(),
_ => new Kilt()
};
skirt.Hue = Utility.RandomGreenHue();
AddItem(skirt);
AddItem(new FeatheredHat(Utility.RandomGreenHue()));
var boots = Utility.Random(2) switch
{
0 => (Item)new Boots(),
1 => new ThighBoots(),
_ => new ThighBoots()
};
AddItem(boots);
Utility.AssignRandomHair(this);
}
public static List<TownCrier> Instances { get; } = new();
public List<TownCrierEntry> Entries { get; private set; }
public TownCrierEntry GetRandomEntry()
{
var count = Entries?.Count ?? 0;
for (var i = count - 1; i >= 0; --i)
{
if (i >= Entries!.Count)
{
continue;
}
var tce = Entries[i];
if (tce.Expired)
{
RemoveEntry(tce);
}
}
var entry = GlobalTownCrierEntryList.Instance.GetRandomEntry();
if (count > 0 && entry != null && Utility.RandomBool())
{
return entry;
}
return Entries?.RandomElement();
}
public TownCrierEntry AddEntry(string[] lines, TimeSpan duration)
{
Entries ??= new List<TownCrierEntry>();
var tce = new TownCrierEntry(lines, duration);
Entries.Add(tce);
ForceBeginAutoShout();
return tce;
}
public void RemoveEntry(TownCrierEntry tce)
{
if (Entries == null)
{
return;
}
Entries.Remove(tce);
if (Entries.Count == 0)
{
Entries = null;
}
if (Entries == null && GlobalTownCrierEntryList.Instance.IsEmpty)
{
_autoShoutTimer?.Stop();
_autoShoutTimer = null;
}
}
public void ForceBeginAutoShout()
{
_autoShoutTimer ??= Timer.DelayCall(TimeSpan.FromSeconds(5.0), TimeSpan.FromMinutes(1.0), AutoShout_Callback);
}
private void AutoShout_Callback()
{
var tce = GetRandomEntry();
if (tce == null)
{
_autoShoutTimer?.Stop();
_autoShoutTimer = null;
}
else if (_newsTimer == null)
{
_newsTimer = Timer.DelayCall(
TimeSpan.FromSeconds(1.0),
TimeSpan.FromSeconds(3.0),
tce.Lines.Length,
() => ShoutNews_Callback(tce)
);
PublicOverheadMessage(MessageType.Regular, 0x3B2, 502976); // Hear ye! Hear ye!
}
}
private void ShoutNews_Callback(TownCrierEntry tce)
{
var index = _newsTimer.Index;
if (index >= tce.Lines.Length)
{
_newsTimer?.Stop();
_newsTimer = null;
}
else
{
PublicOverheadMessage(MessageType.Regular, 0x3B2, false, tce.Lines[index]);
}
}
public override void OnDoubleClick(Mobile from)
{
if (from.AccessLevel >= AccessLevel.GameMaster)
{
TownCrierGump.DisplayTo(from, this);
}
else
{
base.OnDoubleClick(from);
}
}
public override bool HandlesOnSpeech(Mobile from) => _newsTimer == null && from.Alive && InRange(from, 12);
public override void OnSpeech(SpeechEventArgs e)
{
if (_newsTimer == null && e.HasKeyword(0x30) && e.Mobile.Alive && InRange(e.Mobile, 12)) // *news*
{
Direction = GetDirectionTo(e.Mobile);
var tce = GetRandomEntry();
if (tce == null)
{
PublicOverheadMessage(MessageType.Regular, 0x3B2, 1005643); // I have no news at this time.
}
else
{
_newsTimer = Timer.DelayCall(
TimeSpan.FromSeconds(1.0),
TimeSpan.FromSeconds(3.0),
tce.Lines.Length,
() => ShoutNews_Callback(tce)
);
PublicOverheadMessage(MessageType.Regular, 0x3B2, 502978); // Some of the latest news!
}
}
}
public override bool CanBeDamaged() => false;
public override void OnDelete()
{
Instances.Remove(this);
base.OnDelete();
}
[AfterDeserialization]
private void AfterDeserialization()
{
Instances.Add(this);
}
}