feat(ui): port retail dialog factory lifecycle
Replace the single mutable confirmation service with retail's property-backed DialogFactory model: fresh DAT roots, context ids, queue groups, priority preemption, callback/close-notice ordering, and context cancellation. Route /die, server confirmation aborts, and guarded item use through focused semantic owners. Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
parent
43f7c7807c
commit
66bdae7a83
22 changed files with 1442 additions and 220 deletions
323
src/AcDream.App/UI/Layout/RetailDialogFactory.cs
Normal file
323
src/AcDream.App/UI/Layout/RetailDialogFactory.cs
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Retained-mode port of retail <c>DialogFactory @ 0x004773C0..0x00478470</c>.
|
||||
/// It owns dialog contexts, independent FIFO queue groups, nonqueued dialogs,
|
||||
/// priority preemption, callback delivery, close notices, and fresh catalog roots.
|
||||
/// </summary>
|
||||
public sealed class RetailDialogFactory : IDisposable
|
||||
{
|
||||
public const uint DefaultQueueKey = 2u;
|
||||
public const uint NonQueuedKey = 1u;
|
||||
|
||||
private sealed class DialogInfo
|
||||
{
|
||||
public required RetailDialogData Data { get; init; }
|
||||
public required uint Context { get; init; }
|
||||
public required uint QueueKey { get; init; }
|
||||
public Action<RetailDialogData>? Callback { get; init; }
|
||||
public RetailConfirmationDialogView? View { get; set; }
|
||||
}
|
||||
|
||||
private readonly UiRoot _host;
|
||||
private readonly Func<RetailDialogType, ImportedLayout?> _createLayout;
|
||||
private readonly Dictionary<uint, DialogInfo> _activeQueued = new();
|
||||
private readonly Dictionary<uint, DialogInfo> _activeNonQueued = new();
|
||||
private readonly Dictionary<uint, LinkedList<DialogInfo>> _pending = new();
|
||||
private readonly List<DialogInfo> _openOrder = new();
|
||||
private uint _globalContext;
|
||||
private bool _disposed;
|
||||
|
||||
public RetailDialogFactory(
|
||||
UiRoot host,
|
||||
Func<RetailDialogType, ImportedLayout?> createLayout)
|
||||
{
|
||||
_host = host ?? throw new ArgumentNullException(nameof(host));
|
||||
_createLayout = createLayout ?? throw new ArgumentNullException(nameof(createLayout));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail's global close-dialog notice, raised after the per-context callback and
|
||||
/// before the live root is removed from its parent.
|
||||
/// </summary>
|
||||
public event Action<uint, RetailDialogData>? DialogClosed;
|
||||
|
||||
public event Action<uint>? DialogOpened;
|
||||
|
||||
public bool IsOpen => _activeQueued.Count != 0 || _activeNonQueued.Count != 0;
|
||||
|
||||
public int ActiveCount => _activeQueued.Count + _activeNonQueued.Count;
|
||||
|
||||
public int PendingCount => _pending.Values.Sum(static queue => queue.Count);
|
||||
|
||||
/// <summary>Exact root-element switch from <c>CreateDialog_ @ 0x00477AD0</c>.</summary>
|
||||
public static uint RootElementId(RetailDialogType type)
|
||||
=> type switch
|
||||
{
|
||||
RetailDialogType.Confirmation => 0x15u,
|
||||
RetailDialogType.Wait => 0x31u,
|
||||
RetailDialogType.Message => 0x24u,
|
||||
RetailDialogType.TextInput => 0x28u,
|
||||
RetailDialogType.ConfirmationTextInput => 0x2Cu,
|
||||
RetailDialogType.Menu => 0x1Bu,
|
||||
RetailDialogType.ConfirmationMenu => 0x1Fu,
|
||||
_ => 0u,
|
||||
};
|
||||
|
||||
public uint MakeDialog(RetailDialogData data)
|
||||
=> MakeDialog(data, callback: null);
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>MakeCallbackDialogInCurrentUI @ 0x00478430</c>.
|
||||
/// </summary>
|
||||
public uint MakeDialog(RetailDialogData data, Action<RetailDialogData>? callback)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(data);
|
||||
|
||||
uint context = NextContext();
|
||||
RetailDialogData ownedData = data.Clone();
|
||||
uint queueKey = ownedData.GetUInt32(RetailDialogProperty.QueueKey, DefaultQueueKey);
|
||||
if (queueKey == 0u)
|
||||
queueKey = DefaultQueueKey;
|
||||
|
||||
var info = new DialogInfo
|
||||
{
|
||||
Data = ownedData,
|
||||
Context = context,
|
||||
QueueKey = queueKey,
|
||||
Callback = callback,
|
||||
};
|
||||
|
||||
if (queueKey == NonQueuedKey)
|
||||
{
|
||||
_activeNonQueued.Add(context, info);
|
||||
CreateDialog(info);
|
||||
return context;
|
||||
}
|
||||
|
||||
if (!_activeQueued.TryGetValue(queueKey, out DialogInfo? current))
|
||||
{
|
||||
_activeQueued.Add(queueKey, info);
|
||||
CreateDialog(info);
|
||||
return context;
|
||||
}
|
||||
|
||||
LinkedList<DialogInfo> queue = PendingQueue(queueKey);
|
||||
if (!ownedData.GetBoolean(RetailDialogProperty.Priority))
|
||||
{
|
||||
queue.AddLast(info);
|
||||
UpdatePendingDialogDisplays();
|
||||
return context;
|
||||
}
|
||||
|
||||
// MakeDialog's 0x8D branch removes the active root without completing the
|
||||
// DialogInfo, inserts that current info at the front, then displays the
|
||||
// priority request in its place.
|
||||
Suspend(current);
|
||||
queue.AddFirst(current);
|
||||
_activeQueued[queueKey] = info;
|
||||
CreateDialog(info);
|
||||
return context;
|
||||
}
|
||||
|
||||
public uint MakeConfirmation(
|
||||
string message,
|
||||
Action<RetailDialogData>? callback = null,
|
||||
uint queueKey = DefaultQueueKey,
|
||||
bool priority = false)
|
||||
{
|
||||
RetailDialogData data = RetailDialogData.Confirmation(message)
|
||||
.Set(RetailDialogProperty.QueueKey, queueKey);
|
||||
if (priority)
|
||||
data.Set(RetailDialogProperty.Priority, true);
|
||||
return MakeDialog(data, callback);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>CloseDialog @ 0x00478160</c>. The context can identify an active
|
||||
/// nonqueued dialog, an active queued dialog, or an item still pending in a queue.
|
||||
/// </summary>
|
||||
public bool CloseDialog(uint context)
|
||||
{
|
||||
if (context == 0u)
|
||||
return false;
|
||||
|
||||
if (_activeNonQueued.Remove(context, out DialogInfo? nonQueued))
|
||||
{
|
||||
DialogDone(nonQueued);
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ((uint queueKey, DialogInfo active) in _activeQueued.ToArray())
|
||||
{
|
||||
if (active.Context != context)
|
||||
continue;
|
||||
|
||||
_activeQueued.Remove(queueKey);
|
||||
DialogDone(active);
|
||||
OpenNextDialog(queueKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ((uint queueKey, LinkedList<DialogInfo> queue) in _pending.ToArray())
|
||||
{
|
||||
LinkedListNode<DialogInfo>? node = queue.First;
|
||||
while (node is not null && node.Value.Context != context)
|
||||
node = node.Next;
|
||||
if (node is null)
|
||||
continue;
|
||||
|
||||
DialogInfo pending = node.Value;
|
||||
queue.Remove(node);
|
||||
if (queue.Count == 0)
|
||||
_pending.Remove(queueKey);
|
||||
DialogDone(pending);
|
||||
UpdatePendingDialogDisplays();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Tick()
|
||||
{
|
||||
foreach (DialogInfo info in _openOrder.ToArray())
|
||||
info.View?.Tick();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>DialogFactory::Reset @ 0x00477950</c> completes active and pending
|
||||
/// infos before clearing the factory.
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
DialogInfo[] infos = _activeNonQueued.Values
|
||||
.Concat(_activeQueued.Values)
|
||||
.Concat(_pending.Values.SelectMany(static queue => queue))
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
_activeNonQueued.Clear();
|
||||
_activeQueued.Clear();
|
||||
_pending.Clear();
|
||||
foreach (DialogInfo info in infos)
|
||||
DialogDone(info);
|
||||
RefreshModal();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
Reset();
|
||||
}
|
||||
|
||||
private uint NextContext()
|
||||
{
|
||||
_globalContext++;
|
||||
if (_globalContext == 0u)
|
||||
_globalContext++;
|
||||
return _globalContext;
|
||||
}
|
||||
|
||||
private LinkedList<DialogInfo> PendingQueue(uint queueKey)
|
||||
{
|
||||
if (_pending.TryGetValue(queueKey, out LinkedList<DialogInfo>? queue))
|
||||
return queue;
|
||||
queue = new LinkedList<DialogInfo>();
|
||||
_pending.Add(queueKey, queue);
|
||||
return queue;
|
||||
}
|
||||
|
||||
private void CreateDialog(DialogInfo info)
|
||||
{
|
||||
RetailDialogType type = (RetailDialogType)info.Data.GetUInt32(RetailDialogProperty.Type);
|
||||
if (type != RetailDialogType.Confirmation)
|
||||
throw new NotSupportedException(
|
||||
$"Retail dialog type {(uint)type} does not have a ported presenter yet.");
|
||||
|
||||
ImportedLayout layout = _createLayout(type)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Retail dialog catalog could not create type {(uint)type}.");
|
||||
var view = new RetailConfirmationDialogView(
|
||||
_host, layout, info.Data, info.Context,
|
||||
context => CloseDialog(context));
|
||||
info.View = view;
|
||||
_host.AddChild(view.Root);
|
||||
_host.BringToFront(view.Root);
|
||||
_openOrder.Add(info);
|
||||
_host.Modal = view.Root;
|
||||
UpdatePendingDialogDisplays();
|
||||
DialogOpened?.Invoke(info.Context);
|
||||
}
|
||||
|
||||
private void Suspend(DialogInfo info)
|
||||
{
|
||||
if (info.View is null)
|
||||
return;
|
||||
RetailConfirmationDialogView view = info.View;
|
||||
info.View = null;
|
||||
view.DetachHandlers();
|
||||
_openOrder.Remove(info);
|
||||
_host.RemoveChild(view.Root);
|
||||
RefreshModal();
|
||||
}
|
||||
|
||||
private void DialogDone(DialogInfo info)
|
||||
{
|
||||
// DialogDone @ 0x004773C0 delivers the registered callback first, then
|
||||
// broadcasts the CloseDialog notice, and only then removes the root.
|
||||
try
|
||||
{
|
||||
info.Callback?.Invoke(info.Data);
|
||||
DialogClosed?.Invoke(info.Context, info.Data);
|
||||
}
|
||||
finally
|
||||
{
|
||||
RemoveView(info);
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveView(DialogInfo info)
|
||||
{
|
||||
if (info.View is { } view)
|
||||
{
|
||||
info.View = null;
|
||||
view.DetachHandlers();
|
||||
_openOrder.Remove(info);
|
||||
_host.RemoveChild(view.Root);
|
||||
}
|
||||
RefreshModal();
|
||||
}
|
||||
|
||||
private void OpenNextDialog(uint queueKey)
|
||||
{
|
||||
if (!_pending.TryGetValue(queueKey, out LinkedList<DialogInfo>? queue)
|
||||
|| queue.First is null)
|
||||
return;
|
||||
|
||||
DialogInfo next = queue.First.Value;
|
||||
queue.RemoveFirst();
|
||||
if (queue.Count == 0)
|
||||
_pending.Remove(queueKey);
|
||||
_activeQueued.Add(queueKey, next);
|
||||
CreateDialog(next);
|
||||
}
|
||||
|
||||
private void UpdatePendingDialogDisplays()
|
||||
{
|
||||
foreach ((uint queueKey, DialogInfo active) in _activeQueued)
|
||||
{
|
||||
int count = _pending.TryGetValue(queueKey, out LinkedList<DialogInfo>? queue)
|
||||
? queue.Count
|
||||
: 0;
|
||||
active.View?.SetPendingCount(count);
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshModal()
|
||||
{
|
||||
_host.Modal = _openOrder.Count == 0 ? null : _openOrder[^1].View?.Root;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue