acdream/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs

652 lines
20 KiB
C#

using System.Numerics;
using AcDream.Runtime;
using AcDream.Runtime.Session;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Projects Runtime's one borrowed pre-world character-selection owner through
/// retail <c>gmCharacterManagementUI</c>'s authored retained layout. The list is
/// intentionally flat: the retail class owns no viewport or model preview.
/// </summary>
internal sealed class CharacterManagementUiController : IDisposable
{
internal const uint RootEnum = 0x10000005u;
internal const uint RootElementId = 0x1000039Au;
internal const uint ListElementId = 0x1000039Du;
internal const uint CreateElementId = 0x100003A0u;
internal const uint EnterElementId = 0x100003A2u;
internal const uint DeleteElementId = 0x1000039Fu;
internal const uint RestoreElementId = 0x1000039Eu;
internal sealed record DialogStrings(
Func<string, string> DeleteConfirmation,
string DeleteResponse,
string PleaseWait,
string EnteringWorld);
private readonly UiRoot _host;
private readonly ImportedLayout _layout;
private readonly UiTemplateListBox _list;
private readonly UiButton _create;
private readonly UiButton _enter;
private readonly UiButton _delete;
private readonly UiButton _restore;
private readonly RetailDialogFactory _dialogs;
private readonly CharacterSelectionRuntimeBindings _bindings;
private readonly DialogStrings _strings;
private readonly List<UiButton> _rows = [];
private readonly Dictionary<UiButton, uint> _rowIds = [];
private RuntimeGenerationToken _lastGeneration;
private long _lastRevision = long.MinValue;
private uint _deleteDialogContext;
private uint _operationWaitContext;
private uint _enterWaitContext;
private uint _errorDialogContext;
private bool _active;
private bool _restoreCommandInFlight;
private bool _suppressDialogCallbacks;
private bool _disposed;
private CharacterManagementUiController(
UiRoot host,
ImportedLayout layout,
UiTemplateListBox list,
UiButton create,
UiButton enter,
UiButton delete,
UiButton restore,
RetailDialogFactory dialogs,
CharacterSelectionRuntimeBindings bindings,
DialogStrings strings)
{
_host = host;
_layout = layout;
_list = list;
_create = create;
_enter = enter;
_delete = delete;
_restore = restore;
_dialogs = dialogs;
_bindings = bindings;
_strings = strings;
Root.Left = 0f;
Root.Top = 0f;
Root.Anchors = AnchorEdges.Left | AnchorEdges.Top
| AnchorEdges.Right | AnchorEdges.Bottom;
if (host.Width > 0f)
Root.Width = host.Width;
if (host.Height > 0f)
Root.Height = host.Height;
Root.ClickThrough = false;
Root.Visible = false;
// Create Character belongs to a future campaign. Keep retail's
// authored control in place and visibly ghosted; do not hide it or
// invent an action.
_create.Visible = true;
_create.Enabled = false;
_create.OnClick = null;
_enter.OnClick = EnterSelected;
_delete.OnClick = RequestDelete;
_restore.OnClick = RestoreSelected;
}
internal UiElement Root => _layout.Root;
internal IReadOnlyList<UiButton> Rows => _rows;
internal uint DeleteDialogContext => _deleteDialogContext;
internal uint OperationWaitContext => _operationWaitContext;
internal uint EnterWaitContext => _enterWaitContext;
internal uint ErrorDialogContext => _errorDialogContext;
internal void ResetSession()
{
if (_disposed)
return;
Deactivate();
_lastRevision = long.MinValue;
}
internal static CharacterManagementUiController? Bind(
UiRoot host,
ImportedLayout layout,
Func<uint, uint, UiElement?> templateResolver,
RetailDialogFactory dialogs,
CharacterSelectionRuntimeBindings bindings,
DialogStrings strings)
{
ArgumentNullException.ThrowIfNull(host);
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(templateResolver);
ArgumentNullException.ThrowIfNull(dialogs);
ArgumentNullException.ThrowIfNull(bindings);
ArgumentNullException.ThrowIfNull(strings);
if (ContainsViewport(layout.Root))
{
Console.WriteLine(
"[UI] character management: refusing an unapproved model-preview viewport.");
return null;
}
if (layout.Root.DatElementId != RootElementId
|| layout.FindElement(ListElementId) is not UiTemplateListBox list
|| layout.FindElement(CreateElementId) is not UiButton create
|| layout.FindElement(EnterElementId) is not UiButton enter
|| layout.FindElement(DeleteElementId) is not UiButton delete
|| layout.FindElement(RestoreElementId) is not UiButton restore)
{
Console.WriteLine(
"[UI] character management: the authored root/list/button contract is incomplete.");
return null;
}
list.TemplateResolver = templateResolver;
var controller = new CharacterManagementUiController(
host,
layout,
list,
create,
enter,
delete,
restore,
dialogs,
bindings,
strings);
host.AddChild(controller.Root);
controller.Tick();
return controller;
}
private static bool ContainsViewport(UiElement element)
{
if (element is UiViewport)
return true;
foreach (UiElement child in element.Children)
if (ContainsViewport(child))
return true;
return false;
}
internal void Tick()
{
if (_disposed)
return;
IRuntimeCharacterSelectionView? view = _bindings.View();
RuntimeCharacterSelectionSnapshot snapshot = view?.Snapshot ?? default;
if (view is null || !snapshot.IsActive)
{
Deactivate();
_lastGeneration = snapshot.Generation;
_lastRevision = snapshot.Revision;
return;
}
if (!_active)
{
_active = true;
Root.Visible = true;
_host.BringToFront(Root);
}
if (_lastGeneration != snapshot.Generation
|| _lastRevision != snapshot.Revision)
{
if (TryCaptureRoster(view, snapshot, out RuntimeCharacterSelectionEntry[] roster))
{
bool rowsReady;
if (RowsMatchRoster(roster, snapshot.SlotCount))
{
ApplyHighlight(snapshot.HighlightedCharacterId);
rowsReady = true;
}
else
{
rowsReady = RebuildRows(
roster,
snapshot.SlotCount,
snapshot.HighlightedCharacterId);
}
if (rowsReady)
{
_lastGeneration = snapshot.Generation;
_lastRevision = snapshot.Revision;
}
}
else
{
// A receive-thread roster/reset raced the borrowed snapshot.
// Leave the revision unconsumed so the next frame retries from
// one coherent view; never present a partially mixed roster.
_lastRevision = long.MinValue;
snapshot = view.Snapshot;
if (!snapshot.IsActive)
{
Deactivate();
_lastGeneration = snapshot.Generation;
_lastRevision = snapshot.Revision;
return;
}
}
}
else
{
ApplyHighlight(snapshot.HighlightedCharacterId);
}
ApplyButtons(snapshot.Buttons);
ReconcileDialogs(view, snapshot);
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
CloseAllDialogs(suppressCallbacks: true);
_enter.OnClick = null;
_delete.OnClick = null;
_restore.OnClick = null;
foreach (UiButton row in _rows)
{
row.OnClick = null;
row.OnDoubleClick = null;
}
_rows.Clear();
_rowIds.Clear();
_list.Flush();
_list.TemplateResolver = null;
_host.RemoveChild(Root);
}
private static bool TryCaptureRoster(
IRuntimeCharacterSelectionView view,
RuntimeCharacterSelectionSnapshot expected,
out RuntimeCharacterSelectionEntry[] roster)
{
roster = new RuntimeCharacterSelectionEntry[expected.RosterCount];
for (int i = 0; i < roster.Length; i++)
{
if (!view.TryGetAt(i, out roster[i]))
return false;
}
RuntimeCharacterSelectionSnapshot after = view.Snapshot;
return after.Generation == expected.Generation
&& after.Revision == expected.Revision
&& after.RosterCount == expected.RosterCount;
}
private bool RowsMatchRoster(
IReadOnlyList<RuntimeCharacterSelectionEntry> roster,
int allowedSlotCount)
{
if (_rows.Count != roster.Count)
return false;
int rowHeight = ComputeRowHeight(
_list.Height,
roster.Count,
allowedSlotCount);
for (int i = 0; i < roster.Count; i++)
{
UiButton row = _rows[i];
RuntimeCharacterSelectionEntry character = roster[i];
if (!_rowIds.TryGetValue(row, out uint characterId)
|| characterId != character.CharacterId
|| !string.Equals(row.Label, character.Name, StringComparison.Ordinal)
|| (int)row.Height != rowHeight
|| row.LabelColor != (character.IsPendingDelete
? new Vector4(1f, 0f, 0f, 1f)
: Vector4.One))
{
return false;
}
}
return true;
}
private bool RebuildRows(
IReadOnlyList<RuntimeCharacterSelectionEntry> roster,
int allowedSlotCount,
uint highlightedCharacterId)
{
foreach (UiButton row in _rows)
{
row.OnClick = null;
row.OnDoubleClick = null;
}
_rows.Clear();
_rowIds.Clear();
_list.Flush();
int rowHeight = ComputeRowHeight(
_list.Height,
roster.Count,
allowedSlotCount);
_list.LineHeight = rowHeight;
bool complete = _list.Templates.Count > 0
&& _list.TemplateResolver is not null;
foreach (RuntimeCharacterSelectionEntry character in roster)
{
if (!complete)
break;
UiTemplateListEntry template = _list.Templates[0];
if (_list.TemplateResolver!(
template.TemplateLayoutId,
template.TemplateElementId) is not UiButton row)
{
complete = false;
break;
}
// AddItemFromTemplateList creates the same template, but its
// retained viewport stacks at the template's authored 16px
// height. Retail establishes the computed size on every row; our
// list fixes Top during insertion, so build and resize first to
// make every subsequent Top exact.
row.Height = rowHeight;
_list.AddPrebuiltRow(row);
uint characterId = character.CharacterId;
row.Label = character.Name;
row.LabelColor = character.IsPendingDelete
? new Vector4(1f, 0f, 0f, 1f)
: Vector4.One;
row.Enabled = true;
row.SuppressSelfToggle = true;
row.Selected = characterId == highlightedCharacterId;
row.OnClick = () => Highlight(characterId);
row.OnDoubleClick = EnterSelected;
_rows.Add(row);
_rowIds.Add(row, characterId);
}
if (complete)
return true;
foreach (UiButton row in _rows)
{
row.OnClick = null;
row.OnDoubleClick = null;
}
_rows.Clear();
_rowIds.Clear();
_list.Flush();
_lastRevision = long.MinValue;
return false;
}
internal static int ComputeRowHeight(
float listHeight,
int rosterCount,
int allowedSlotCount)
{
// RebuildCharacterList @ 0x004EC3A0 uses integer UIRegion height and
// signed integer division for both terms. The 0x66666667 multiply/
// shift sequence is compiler output for height / 10.
int height = (int)MathF.Truncate(listHeight);
int denominator = Math.Max(rosterCount, allowedSlotCount);
if (denominator <= 0)
return height / 10;
return Math.Max(height / denominator, height / 10);
}
private void ApplyHighlight(uint highlightedCharacterId)
{
foreach (UiButton row in _rows)
row.Selected = _rowIds.TryGetValue(row, out uint characterId)
&& characterId == highlightedCharacterId;
}
private void ApplyButtons(RuntimeCharacterSelectionButtons buttons)
{
_create.Visible = true;
_create.Enabled = false;
_enter.Enabled = buttons.CanEnter;
_delete.Visible = buttons.DeleteVisible;
_delete.Enabled = buttons.CanDelete;
_restore.Visible = buttons.RestoreVisible;
_restore.Enabled = buttons.CanRestore;
}
private void Highlight(uint characterId)
{
if (_disposed)
return;
_bindings.Highlight(characterId);
InvalidateAndTick();
}
private void EnterSelected()
{
if (_disposed)
return;
// Open retail's wait context before the synchronous Runtime command
// starts its existing ServerReady transaction. The state projection
// remains authoritative and closes it on InWorld/error/reset.
EnsureEnterWait();
RuntimeCommandResult result = _bindings.Enter();
if (!result.Accepted)
CloseContext(ref _enterWaitContext, suppressCallback: true);
InvalidateAndTick();
}
private void RequestDelete()
{
if (_disposed)
return;
_bindings.RequestDelete();
InvalidateAndTick();
}
private void RestoreSelected()
{
if (_disposed)
return;
// ListenToElementMessage @ 0x004ED5A0 opens Please Wait before it
// calls CPlayerSystem::RestoreCharacter. Keep it modal even if a
// synchronous command callback re-enters Tick before Runtime has
// returned its accepted projection.
EnsureOperationWait();
RuntimeCommandResult result = default;
Exception? failure = null;
_restoreCommandInFlight = true;
try
{
result = _bindings.Restore();
}
catch (Exception error)
{
failure = error;
}
finally
{
_restoreCommandInFlight = false;
}
if (failure is not null)
{
Console.WriteLine(
$"[UI] character restore command failed: {failure.Message}");
CloseContext(ref _operationWaitContext, suppressCallback: true);
InvalidateAndTick();
return;
}
if (!result.Accepted)
CloseContext(ref _operationWaitContext, suppressCallback: true);
InvalidateAndTick();
}
private void ReconcileDialogs(
IRuntimeCharacterSelectionView view,
RuntimeCharacterSelectionSnapshot snapshot)
{
if (snapshot.Error is { } error)
{
CloseContext(ref _deleteDialogContext, suppressCallback: true);
CloseContext(ref _operationWaitContext, suppressCallback: true);
CloseContext(ref _enterWaitContext, suppressCallback: true);
EnsureError(error.Message);
return;
}
CloseContext(ref _errorDialogContext, suppressCallback: true);
if (snapshot.Lifecycle == RuntimeCharacterSelectionLifecycle.EnteringWorld)
{
CloseContext(ref _deleteDialogContext, suppressCallback: true);
CloseContext(ref _operationWaitContext, suppressCallback: true);
EnsureEnterWait();
return;
}
CloseContext(ref _enterWaitContext, suppressCallback: true);
if (snapshot.PendingDeleteCharacterId != 0u
&& view.TryGet(snapshot.PendingDeleteCharacterId, out RuntimeCharacterSelectionEntry pending))
{
EnsureDeleteConfirmation(pending.Name);
}
else
{
CloseContext(ref _deleteDialogContext, suppressCallback: true);
}
if (_restoreCommandInFlight
|| snapshot.Operation is RuntimeCharacterSelectionOperation.DeleteRequested
or RuntimeCharacterSelectionOperation.DeleteAcknowledged
or RuntimeCharacterSelectionOperation.RestoreRequested)
{
EnsureOperationWait();
}
else
{
CloseContext(ref _operationWaitContext, suppressCallback: true);
}
}
private void EnsureDeleteConfirmation(string characterName)
{
if (_deleteDialogContext != 0u)
return;
_deleteDialogContext = _dialogs.MakeConfirmationTextInput(
_strings.DeleteConfirmation(characterName),
data =>
{
_deleteDialogContext = 0u;
if (_disposed || _suppressDialogCallbacks)
return;
string response = data.GetString(
RetailDialogProperty.TextInputResult) ?? string.Empty;
if (string.Equals(
response,
_strings.DeleteResponse,
StringComparison.OrdinalIgnoreCase))
{
_bindings.ConfirmDelete();
}
else
{
_bindings.Cancel();
}
InvalidateAndTick();
});
}
private void EnsureOperationWait()
{
if (_operationWaitContext == 0u)
_operationWaitContext = _dialogs.MakeWait(_strings.PleaseWait);
}
private void EnsureEnterWait()
{
if (_enterWaitContext == 0u)
_enterWaitContext = _dialogs.MakeWait(_strings.EnteringWorld);
}
private void EnsureError(string message)
{
if (_errorDialogContext != 0u)
return;
_errorDialogContext = _dialogs.MakeMessage(
message,
_ =>
{
_errorDialogContext = 0u;
if (_disposed || _suppressDialogCallbacks)
return;
_bindings.Cancel();
InvalidateAndTick();
});
}
private void InvalidateAndTick()
{
_lastRevision = long.MinValue;
Tick();
}
private void Deactivate()
{
if (_active)
{
_active = false;
Root.Visible = false;
}
foreach (UiButton row in _rows)
{
row.OnClick = null;
row.OnDoubleClick = null;
}
_rows.Clear();
_rowIds.Clear();
_list.Flush();
CloseAllDialogs(suppressCallbacks: true);
}
private void CloseAllDialogs(bool suppressCallbacks)
{
bool previous = _suppressDialogCallbacks;
_suppressDialogCallbacks |= suppressCallbacks;
try
{
CloseContext(ref _deleteDialogContext, suppressCallback: false);
CloseContext(ref _operationWaitContext, suppressCallback: false);
CloseContext(ref _enterWaitContext, suppressCallback: false);
CloseContext(ref _errorDialogContext, suppressCallback: false);
}
finally
{
_suppressDialogCallbacks = previous;
}
}
private void CloseContext(ref uint context, bool suppressCallback)
{
uint closing = context;
if (closing == 0u)
return;
context = 0u;
bool previous = _suppressDialogCallbacks;
_suppressDialogCallbacks |= suppressCallback;
try
{
_dialogs.CloseDialog(closing);
}
finally
{
_suppressDialogCallbacks = previous;
}
}
}