Implement retail character management screen
This commit is contained in:
parent
5535d0adac
commit
6cfab727f1
19 changed files with 2435 additions and 6 deletions
578
src/AcDream.App/UI/Layout/CharacterManagementUiController.cs
Normal file
578
src/AcDream.App/UI/Layout/CharacterManagementUiController.cs
Normal file
|
|
@ -0,0 +1,578 @@
|
|||
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 _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))
|
||||
{
|
||||
ApplyHighlight(snapshot.HighlightedCharacterId);
|
||||
rowsReady = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
rowsReady = RebuildRows(
|
||||
roster,
|
||||
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)
|
||||
{
|
||||
if (_rows.Count != roster.Count)
|
||||
return false;
|
||||
|
||||
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)
|
||||
|| row.LabelColor != (character.IsPendingDelete
|
||||
? new Vector4(1f, 0f, 0f, 1f)
|
||||
: Vector4.One))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool RebuildRows(
|
||||
IReadOnlyList<RuntimeCharacterSelectionEntry> roster,
|
||||
uint highlightedCharacterId)
|
||||
{
|
||||
foreach (UiButton row in _rows)
|
||||
{
|
||||
row.OnClick = null;
|
||||
row.OnDoubleClick = null;
|
||||
}
|
||||
_rows.Clear();
|
||||
_rowIds.Clear();
|
||||
_list.Flush();
|
||||
|
||||
bool complete = true;
|
||||
foreach (RuntimeCharacterSelectionEntry character in roster)
|
||||
{
|
||||
if (_list.AddItemFromTemplateList(0) is not UiButton row)
|
||||
{
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
RuntimeCommandResult result = _bindings.Restore();
|
||||
if (result.Accepted)
|
||||
EnsureOperationWait();
|
||||
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 (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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue