Implement retail character management screen
This commit is contained in:
parent
5535d0adac
commit
6cfab727f1
19 changed files with 2435 additions and 6 deletions
|
|
@ -21,6 +21,7 @@ using AcDream.Core.Selection;
|
|||
using AcDream.Core.Spells;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.Runtime.Session;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using AcDream.UI.Abstractions.Panels.Chat;
|
||||
using AcDream.UI.Abstractions.Panels.Vitals;
|
||||
|
|
@ -937,7 +938,19 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
(action, held) =>
|
||||
d.InputDispatcher?.TrySetAutomationActionHeld(action, held) == true,
|
||||
late.Automation),
|
||||
Keyboard: new KeyboardRuntimeBindings(d.InputDispatcher, d.KeyBindingsFilePath));
|
||||
Keyboard: new KeyboardRuntimeBindings(
|
||||
d.InputDispatcher,
|
||||
d.KeyBindingsFilePath),
|
||||
CharacterSelection: d.Options.LiveCharacterSelector is null
|
||||
? new CharacterSelectionRuntimeBindings(
|
||||
() => late.GameRuntime.CharacterSelection,
|
||||
late.GameRuntime.CharacterSelectionHighlight,
|
||||
late.GameRuntime.CharacterSelectionEnter,
|
||||
late.GameRuntime.CharacterSelectionRequestDelete,
|
||||
late.GameRuntime.CharacterSelectionConfirmDelete,
|
||||
late.GameRuntime.CharacterSelectionRestore,
|
||||
late.GameRuntime.CharacterSelectionCancel)
|
||||
: null);
|
||||
RetailUiRuntime runtime = lease.Mount(
|
||||
() => RetailUiRuntime.CreateUninitialized(bindings));
|
||||
checkpoint(InteractionRetainedUiCompositionPoint.UiRuntimeMounted);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ using AcDream.Core.Items;
|
|||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Session;
|
||||
using AcDream.UI.Abstractions;
|
||||
using Silk.NET.Windowing;
|
||||
|
||||
|
|
@ -37,6 +38,24 @@ internal sealed class DeferredGameRuntimeStateCommands
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Borrows the current adapter's character-selection projection. The
|
||||
/// reference is deliberately not cached here: releasing or displacing the
|
||||
/// exact late binding makes the next read return <see langword="null"/>,
|
||||
/// while <c>CurrentGameRuntimeAdapter</c> keeps an already-borrowed reference
|
||||
/// inert if disposal races the render-thread consumer.
|
||||
/// </summary>
|
||||
public IRuntimeCharacterSelectionView? CharacterSelection
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
return !_deactivated && _view is not null
|
||||
? _view.CharacterSelection
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
public IDisposable Bind(
|
||||
IGameRuntimeView view,
|
||||
IGameRuntimeCommands commands)
|
||||
|
|
@ -118,6 +137,35 @@ internal sealed class DeferredGameRuntimeStateCommands
|
|||
generation,
|
||||
new RuntimeAdvancementCommand(kind, statId, cost)));
|
||||
|
||||
// Campaign LA slice LA8: the retained character-management screen uses
|
||||
// the same generation-capturing late seam as every gameplay panel. The
|
||||
// screen never receives GameRuntime or WorldSession and cannot retain a
|
||||
// stale generation across reconnect.
|
||||
|
||||
public RuntimeCommandResult CharacterSelectionHighlight(uint characterId) =>
|
||||
Invoke((commands, generation) =>
|
||||
commands.CharacterSelection.Highlight(generation, characterId));
|
||||
|
||||
public RuntimeCommandResult CharacterSelectionEnter() =>
|
||||
Invoke((commands, generation) =>
|
||||
commands.CharacterSelection.Enter(generation));
|
||||
|
||||
public RuntimeCommandResult CharacterSelectionRequestDelete() =>
|
||||
Invoke((commands, generation) =>
|
||||
commands.CharacterSelection.RequestDelete(generation));
|
||||
|
||||
public RuntimeCommandResult CharacterSelectionConfirmDelete() =>
|
||||
Invoke((commands, generation) =>
|
||||
commands.CharacterSelection.ConfirmDelete(generation));
|
||||
|
||||
public RuntimeCommandResult CharacterSelectionRestore() =>
|
||||
Invoke((commands, generation) =>
|
||||
commands.CharacterSelection.Restore(generation));
|
||||
|
||||
public RuntimeCommandResult CharacterSelectionCancel() =>
|
||||
Invoke((commands, generation) =>
|
||||
commands.CharacterSelection.Cancel(generation));
|
||||
|
||||
// ── Campaign FA slice FA4: fellowship page commands ─────────────────
|
||||
// Same "capture view+commands under one generation" shape as every
|
||||
// method above — a displaced session (reconnect mid-click) can never
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -122,6 +122,8 @@ public static class DatWidgetFactory
|
|||
11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar (reg :124137)
|
||||
12 => BuildText(info, resolve, elementFont, stringResolve), // UIElement_Text
|
||||
0x13 => new UiDialogRoot(), // ConfirmationDialog
|
||||
0x15 => new UiDialogRoot(), // ConfirmationTextInputDialog
|
||||
0x17 => new UiDialogRoot(), // MessageDialog
|
||||
0x19 => new UiDialogRoot(), // WaitDialog (catalog root 0x31 — OP8 #396)
|
||||
0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots
|
||||
0x10000035u => BuildCheckbox(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,160 @@
|
|||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Retail type-5 <c>ConfirmationTextInputDialog</c> (class type
|
||||
/// <c>0x15</c>, catalog root <c>0x2C</c>). Accept stores the field text under
|
||||
/// property <c>0x9C</c>; reject/Escape stores the empty string. Character
|
||||
/// deletion is the first consumer and performs retail's case-insensitive
|
||||
/// comparison with the localized <c>DELETE</c> response in its callback.
|
||||
/// </summary>
|
||||
internal sealed class RetailConfirmationTextInputDialogView : IRetailDialogView
|
||||
{
|
||||
public const uint RootElementId = 0x2Cu;
|
||||
public const uint InputElementId = 0x2Cu;
|
||||
public const uint AcceptButtonId = 0x2Eu;
|
||||
public const uint RejectButtonId = 0x2Fu;
|
||||
public const uint PopupElementId = 0x3Du;
|
||||
public const uint MessageElementId = 0x3Eu;
|
||||
|
||||
private readonly UiRoot _host;
|
||||
private readonly RetailDialogData _data;
|
||||
private readonly uint _context;
|
||||
private readonly Action<uint> _closeDialog;
|
||||
private readonly UiElement _popup;
|
||||
private readonly UiText _message;
|
||||
private readonly UiField _input;
|
||||
private readonly UiButton _accept;
|
||||
private readonly UiButton _reject;
|
||||
private readonly float _basePopupHeight;
|
||||
private readonly float _baseMessageHeight;
|
||||
private bool _focusPending = true;
|
||||
|
||||
public RetailConfirmationTextInputDialogView(
|
||||
UiRoot host,
|
||||
ImportedLayout layout,
|
||||
RetailDialogData data,
|
||||
uint context,
|
||||
Action<uint> closeDialog)
|
||||
{
|
||||
_host = host ?? throw new ArgumentNullException(nameof(host));
|
||||
ArgumentNullException.ThrowIfNull(layout);
|
||||
_data = data ?? throw new ArgumentNullException(nameof(data));
|
||||
_context = context;
|
||||
_closeDialog = closeDialog ?? throw new ArgumentNullException(nameof(closeDialog));
|
||||
|
||||
Root = layout.Root as UiDialogRoot
|
||||
?? throw new ArgumentException(
|
||||
"Confirmation-text-input layout root is not a UiDialogRoot.",
|
||||
nameof(layout));
|
||||
_popup = layout.FindElement(PopupElementId)
|
||||
?? throw new ArgumentException(
|
||||
"Confirmation-text-input layout is missing popup element 0x3D.",
|
||||
nameof(layout));
|
||||
_message = layout.FindElement(MessageElementId) as UiText
|
||||
?? throw new ArgumentException(
|
||||
"Confirmation-text-input layout is missing text element 0x3E.",
|
||||
nameof(layout));
|
||||
// The field deliberately repeats the root's numeric id. ImportedLayout
|
||||
// registers descendants after ancestors, matching GetChildRecursive's
|
||||
// effective result for this catalog shape.
|
||||
_input = layout.FindElement(InputElementId) as UiField
|
||||
?? throw new ArgumentException(
|
||||
"Confirmation-text-input layout is missing input field 0x2C.",
|
||||
nameof(layout));
|
||||
_accept = layout.FindElement(AcceptButtonId) as UiButton
|
||||
?? throw new ArgumentException(
|
||||
"Confirmation-text-input layout is missing accept button 0x2E.",
|
||||
nameof(layout));
|
||||
_reject = layout.FindElement(RejectButtonId) as UiButton
|
||||
?? throw new ArgumentException(
|
||||
"Confirmation-text-input layout is missing reject button 0x2F.",
|
||||
nameof(layout));
|
||||
|
||||
_basePopupHeight = _popup.Height;
|
||||
_baseMessageHeight = _message.Height;
|
||||
_popup.LayoutPolicy = null;
|
||||
_popup.Anchors = AnchorEdges.None;
|
||||
_message.LayoutPolicy = null;
|
||||
_message.Anchors = AnchorEdges.None;
|
||||
_message.Padding = 0f;
|
||||
_message.Selectable = false;
|
||||
_input.ClearOnSubmit = false;
|
||||
_input.RecordHistory = false;
|
||||
|
||||
if (_data.GetString(RetailDialogProperty.TextInputAcceptLabel) is { } acceptLabel)
|
||||
_accept.Label = acceptLabel;
|
||||
if (_data.GetString(RetailDialogProperty.TextInputRejectLabel) is { } rejectLabel)
|
||||
_reject.Label = rejectLabel;
|
||||
|
||||
Root.Cancel = Reject;
|
||||
_accept.OnClick = Accept;
|
||||
_reject.OnClick = Reject;
|
||||
_input.OnSubmit = _ => Accept();
|
||||
SetMessage(_data.GetString(RetailDialogProperty.Message) ?? string.Empty);
|
||||
SizeAndCenter();
|
||||
}
|
||||
|
||||
public UiDialogRoot Root { get; }
|
||||
|
||||
public void Tick()
|
||||
{
|
||||
SizeAndCenter();
|
||||
if (_focusPending && Root.Parent is not null)
|
||||
{
|
||||
_host.SetKeyboardFocus(_input);
|
||||
_focusPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetPendingCount(int count)
|
||||
{
|
||||
// This catalog root authors no pending-count display.
|
||||
}
|
||||
|
||||
public void DetachHandlers()
|
||||
{
|
||||
Root.Cancel = null;
|
||||
_accept.OnClick = null;
|
||||
_reject.OnClick = null;
|
||||
_input.OnSubmit = null;
|
||||
}
|
||||
|
||||
private void Accept()
|
||||
{
|
||||
_data.Set(RetailDialogProperty.TextInputResult, _input.Text);
|
||||
_closeDialog(_context);
|
||||
}
|
||||
|
||||
private void Reject()
|
||||
{
|
||||
_data.Set(RetailDialogProperty.TextInputResult, string.Empty);
|
||||
_closeDialog(_context);
|
||||
}
|
||||
|
||||
private void SetMessage(string text)
|
||||
{
|
||||
float maximumWidth = Math.Max(1f, _message.Width - 2f * _message.Padding);
|
||||
Func<string, float> measure = _message.DatFont is { } font
|
||||
? font.MeasureWidth
|
||||
: static value => value.Length * 8f;
|
||||
IReadOnlyList<string> wrapped = UiText.WrapWords(text, measure, maximumWidth);
|
||||
var lines = new UiText.Line[wrapped.Count];
|
||||
for (int i = 0; i < wrapped.Count; i++)
|
||||
lines[i] = new UiText.Line(wrapped[i], _message.DefaultColor);
|
||||
_message.LinesProvider = () => lines;
|
||||
|
||||
float lineHeight = _message.DatFont?.LineHeight ?? 16f;
|
||||
_message.Height = Math.Max(_baseMessageHeight, lines.Length * lineHeight);
|
||||
_popup.Height = _basePopupHeight + (_message.Height - _baseMessageHeight);
|
||||
}
|
||||
|
||||
private void SizeAndCenter()
|
||||
{
|
||||
Root.Left = 0f;
|
||||
Root.Top = 0f;
|
||||
Root.Width = _host.Width;
|
||||
Root.Height = _host.Height;
|
||||
_popup.Left = MathF.Round((Root.Width - _popup.Width) * 0.5f);
|
||||
_popup.Top = MathF.Round((Root.Height - _popup.Height) * 0.5f);
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,9 @@ public static class RetailDialogProperty
|
|||
public const uint AcceptLabel = 0x90u;
|
||||
public const uint RejectLabel = 0x91u;
|
||||
public const uint ConfirmationResult = 0x92u;
|
||||
public const uint TextInputAcceptLabel = 0x9Au;
|
||||
public const uint TextInputRejectLabel = 0x9Bu;
|
||||
public const uint TextInputResult = 0x9Cu;
|
||||
/// <summary>
|
||||
/// When true, <c>Dialog::SetData @ 0x00476BE0</c> sets UIElement boolean
|
||||
/// attribute <c>0x40</c>. The Keystone-owned attribute name is unavailable.
|
||||
|
|
@ -123,4 +126,21 @@ public sealed class RetailDialogData
|
|||
.Set(RetailDialogProperty.ElementAttribute40, true)
|
||||
.Set(RetailDialogProperty.Message, message);
|
||||
}
|
||||
|
||||
public static RetailDialogData Message(string message)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
return new RetailDialogData()
|
||||
.Set(RetailDialogProperty.Type, RetailDialogType.Message)
|
||||
.Set(RetailDialogProperty.Message, message);
|
||||
}
|
||||
|
||||
public static RetailDialogData ConfirmationTextInput(string message)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
return new RetailDialogData()
|
||||
.Set(RetailDialogProperty.Type, RetailDialogType.ConfirmationTextInput)
|
||||
.Set(RetailDialogProperty.ElementAttribute40, true)
|
||||
.Set(RetailDialogProperty.Message, message);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -148,6 +148,26 @@ public sealed class RetailDialogFactory : IDisposable
|
|||
return MakeDialog(data, callback: null);
|
||||
}
|
||||
|
||||
public uint MakeMessage(
|
||||
string message,
|
||||
Action<RetailDialogData>? callback = null,
|
||||
uint queueKey = DefaultQueueKey)
|
||||
{
|
||||
RetailDialogData data = RetailDialogData.Message(message)
|
||||
.Set(RetailDialogProperty.QueueKey, queueKey);
|
||||
return MakeDialog(data, callback);
|
||||
}
|
||||
|
||||
public uint MakeConfirmationTextInput(
|
||||
string message,
|
||||
Action<RetailDialogData>? callback = null,
|
||||
uint queueKey = DefaultQueueKey)
|
||||
{
|
||||
RetailDialogData data = RetailDialogData.ConfirmationTextInput(message)
|
||||
.Set(RetailDialogProperty.QueueKey, queueKey);
|
||||
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.
|
||||
|
|
@ -275,7 +295,10 @@ public sealed class RetailDialogFactory : IDisposable
|
|||
private void CreateDialog(DialogInfo info)
|
||||
{
|
||||
RetailDialogType type = (RetailDialogType)info.Data.GetUInt32(RetailDialogProperty.Type);
|
||||
if (type is not (RetailDialogType.Confirmation or RetailDialogType.Wait))
|
||||
if (type is not (RetailDialogType.Confirmation
|
||||
or RetailDialogType.Wait
|
||||
or RetailDialogType.Message
|
||||
or RetailDialogType.ConfirmationTextInput))
|
||||
throw new NotSupportedException(
|
||||
$"Retail dialog type {(uint)type} does not have a ported presenter yet.");
|
||||
|
||||
|
|
@ -285,6 +308,13 @@ public sealed class RetailDialogFactory : IDisposable
|
|||
IRetailDialogView view = type switch
|
||||
{
|
||||
RetailDialogType.Wait => new RetailWaitDialogView(_host, layout, info.Data),
|
||||
RetailDialogType.Message => new RetailMessageDialogView(
|
||||
_host, layout, info.Data, info.Context,
|
||||
context => CloseDialog(context)),
|
||||
RetailDialogType.ConfirmationTextInput =>
|
||||
new RetailConfirmationTextInputDialogView(
|
||||
_host, layout, info.Data, info.Context,
|
||||
context => CloseDialog(context)),
|
||||
_ => new RetailConfirmationDialogView(
|
||||
_host, layout, info.Data, info.Context,
|
||||
context => CloseDialog(context)),
|
||||
|
|
@ -294,6 +324,7 @@ public sealed class RetailDialogFactory : IDisposable
|
|||
_host.BringToFront(view.Root);
|
||||
_openOrder.Add(info);
|
||||
_host.Modal = view.Root;
|
||||
view.Tick();
|
||||
UpdatePendingDialogDisplays();
|
||||
DialogOpened?.Invoke(info.Context);
|
||||
}
|
||||
|
|
|
|||
105
src/AcDream.App/UI/Layout/RetailMessageDialogView.cs
Normal file
105
src/AcDream.App/UI/Layout/RetailMessageDialogView.cs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Retail type-3 <c>MessageDialog</c> (class type <c>0x17</c>, catalog root
|
||||
/// <c>0x24</c>). It shares the dialog catalog's popup/message pair with the
|
||||
/// existing confirmation and wait presenters and closes from its authored OK
|
||||
/// button <c>0x26</c> or Escape.
|
||||
/// </summary>
|
||||
internal sealed class RetailMessageDialogView : IRetailDialogView
|
||||
{
|
||||
public const uint RootElementId = 0x24u;
|
||||
public const uint OkButtonId = 0x26u;
|
||||
public const uint PopupElementId = 0x3Du;
|
||||
public const uint MessageElementId = 0x3Eu;
|
||||
|
||||
private readonly UiRoot _host;
|
||||
private readonly uint _context;
|
||||
private readonly Action<uint> _closeDialog;
|
||||
private readonly UiElement _popup;
|
||||
private readonly UiText _message;
|
||||
private readonly UiButton _ok;
|
||||
private readonly float _basePopupHeight;
|
||||
private readonly float _baseMessageHeight;
|
||||
|
||||
public RetailMessageDialogView(
|
||||
UiRoot host,
|
||||
ImportedLayout layout,
|
||||
RetailDialogData data,
|
||||
uint context,
|
||||
Action<uint> closeDialog)
|
||||
{
|
||||
_host = host ?? throw new ArgumentNullException(nameof(host));
|
||||
ArgumentNullException.ThrowIfNull(layout);
|
||||
ArgumentNullException.ThrowIfNull(data);
|
||||
_context = context;
|
||||
_closeDialog = closeDialog ?? throw new ArgumentNullException(nameof(closeDialog));
|
||||
|
||||
Root = layout.Root as UiDialogRoot
|
||||
?? throw new ArgumentException("Message layout root is not a UiDialogRoot.", nameof(layout));
|
||||
_popup = layout.FindElement(PopupElementId)
|
||||
?? throw new ArgumentException("Message layout is missing popup element 0x3D.", nameof(layout));
|
||||
_message = layout.FindElement(MessageElementId) as UiText
|
||||
?? throw new ArgumentException("Message layout is missing text element 0x3E.", nameof(layout));
|
||||
_ok = layout.FindElement(OkButtonId) as UiButton
|
||||
?? throw new ArgumentException("Message layout is missing OK button 0x26.", nameof(layout));
|
||||
|
||||
_basePopupHeight = _popup.Height;
|
||||
_baseMessageHeight = _message.Height;
|
||||
_popup.LayoutPolicy = null;
|
||||
_popup.Anchors = AnchorEdges.None;
|
||||
_message.LayoutPolicy = null;
|
||||
_message.Anchors = AnchorEdges.None;
|
||||
_message.Padding = 0f;
|
||||
_message.Selectable = false;
|
||||
|
||||
Root.Cancel = Close;
|
||||
_ok.OnClick = Close;
|
||||
SetMessage(data.GetString(RetailDialogProperty.Message) ?? string.Empty);
|
||||
SizeAndCenter();
|
||||
}
|
||||
|
||||
public UiDialogRoot Root { get; }
|
||||
|
||||
public void Tick() => SizeAndCenter();
|
||||
|
||||
public void SetPendingCount(int count)
|
||||
{
|
||||
// MessageDialog has no pending-count subtree in the retail catalog.
|
||||
}
|
||||
|
||||
public void DetachHandlers()
|
||||
{
|
||||
Root.Cancel = null;
|
||||
_ok.OnClick = null;
|
||||
}
|
||||
|
||||
private void Close() => _closeDialog(_context);
|
||||
|
||||
private void SetMessage(string text)
|
||||
{
|
||||
float maximumWidth = Math.Max(1f, _message.Width - 2f * _message.Padding);
|
||||
Func<string, float> measure = _message.DatFont is { } font
|
||||
? font.MeasureWidth
|
||||
: static value => value.Length * 8f;
|
||||
IReadOnlyList<string> wrapped = UiText.WrapWords(text, measure, maximumWidth);
|
||||
var lines = new UiText.Line[wrapped.Count];
|
||||
for (int i = 0; i < wrapped.Count; i++)
|
||||
lines[i] = new UiText.Line(wrapped[i], _message.DefaultColor);
|
||||
_message.LinesProvider = () => lines;
|
||||
|
||||
float lineHeight = _message.DatFont?.LineHeight ?? 16f;
|
||||
_message.Height = Math.Max(_baseMessageHeight, lines.Length * lineHeight);
|
||||
_popup.Height = _basePopupHeight + (_message.Height - _baseMessageHeight);
|
||||
}
|
||||
|
||||
private void SizeAndCenter()
|
||||
{
|
||||
Root.Left = 0f;
|
||||
Root.Top = 0f;
|
||||
Root.Width = _host.Width;
|
||||
Root.Height = _host.Height;
|
||||
_popup.Left = MathF.Round((Root.Width - _popup.Width) * 0.5f);
|
||||
_popup.Top = MathF.Round((Root.Height - _popup.Height) * 0.5f);
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ using AcDream.Core.Selection;
|
|||
using AcDream.Core.Spells;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.Runtime.Session;
|
||||
using AcDream.Content;
|
||||
using AcDream.Core.Input;
|
||||
using AcDream.UI.Abstractions;
|
||||
|
|
@ -369,6 +370,21 @@ public sealed record KeyboardRuntimeBindings(
|
|||
InputDispatcher? Dispatcher,
|
||||
string KeyBindingsFilePath);
|
||||
|
||||
/// <summary>
|
||||
/// Borrowed LA7b character-selection projection and its generation-capturing
|
||||
/// typed command routes. App owns no roster, selection, operation, or error
|
||||
/// mirror; an absent view means the current adapter has not bound (or has
|
||||
/// already been released).
|
||||
/// </summary>
|
||||
public sealed record CharacterSelectionRuntimeBindings(
|
||||
Func<IRuntimeCharacterSelectionView?> View,
|
||||
Func<uint, RuntimeCommandResult> Highlight,
|
||||
Func<RuntimeCommandResult> Enter,
|
||||
Func<RuntimeCommandResult> RequestDelete,
|
||||
Func<RuntimeCommandResult> ConfirmDelete,
|
||||
Func<RuntimeCommandResult> Restore,
|
||||
Func<RuntimeCommandResult> Cancel);
|
||||
|
||||
public sealed record RetailUiRuntimeBindings(
|
||||
UiHost Host,
|
||||
RetailUiAssets Assets,
|
||||
|
|
@ -395,7 +411,8 @@ public sealed record RetailUiRuntimeBindings(
|
|||
BufferedUiRegistry? Plugins,
|
||||
RetailUiPersistenceBindings? Persistence,
|
||||
RetailUiProbeBindings Probe,
|
||||
KeyboardRuntimeBindings? Keyboard = null);
|
||||
KeyboardRuntimeBindings? Keyboard = null,
|
||||
CharacterSelectionRuntimeBindings? CharacterSelection = null);
|
||||
|
||||
/// <summary>
|
||||
/// Composition owner for the production retained gameplay UI. GameWindow supplies
|
||||
|
|
@ -483,6 +500,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
MountVendor();
|
||||
MountSecureTrade();
|
||||
MountItemCooldowns();
|
||||
MountCharacterManagement();
|
||||
Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged;
|
||||
BindToolbarPanelButtons();
|
||||
SyncToolbarWindowButtons();
|
||||
|
|
@ -577,6 +595,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
public VendorUiController? VendorController { get; private set; }
|
||||
public OptionsPanelController? OptionsPanelController { get; private set; }
|
||||
public SocialPanelController? SocialPanelController { get; private set; }
|
||||
internal CharacterManagementUiController? CharacterManagementController { get; private set; }
|
||||
|
||||
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
|
||||
{
|
||||
|
|
@ -622,6 +641,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
ExternalContainerController?.Tick();
|
||||
SocialPanelController?.Tick();
|
||||
_itemCooldownController?.Tick();
|
||||
CharacterManagementController?.Tick();
|
||||
DialogFactory?.Tick();
|
||||
Host.Tick(deltaSeconds);
|
||||
_automation?.Tick(deltaSeconds);
|
||||
|
|
@ -788,6 +808,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
{
|
||||
try
|
||||
{
|
||||
CharacterManagementController?.ResetSession();
|
||||
DialogFactory?.Reset();
|
||||
}
|
||||
finally
|
||||
|
|
@ -3669,6 +3690,144 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
"[M4] retail secure trade panel mounted from LayoutDesc 0x2100000D.");
|
||||
}
|
||||
|
||||
private void MountCharacterManagement()
|
||||
{
|
||||
CharacterSelectionRuntimeBindings? bindings =
|
||||
_bindings.CharacterSelection;
|
||||
if (bindings is null)
|
||||
return;
|
||||
if (DialogFactory is null)
|
||||
{
|
||||
Console.WriteLine(
|
||||
"[UI] character management: retail DialogFactory is unavailable.");
|
||||
return;
|
||||
}
|
||||
|
||||
const uint stringTableId = 0x23000002u;
|
||||
uint layoutId;
|
||||
ImportedLayout? layout;
|
||||
var strings = new DatStringResolver(_bindings.Assets.Dats);
|
||||
lock (_bindings.Assets.DatLock)
|
||||
{
|
||||
// gmCharacterManagementUI's framework call passes enum
|
||||
// 0x10000005 and category/table 5, then selects root 0x1000039A.
|
||||
layoutId = RetailDataIdResolver.Resolve(
|
||||
_bindings.Assets.Dats,
|
||||
CharacterManagementUiController.RootEnum,
|
||||
5u);
|
||||
layout = layoutId == 0u
|
||||
? null
|
||||
: LayoutImporter.Import(
|
||||
_bindings.Assets.Dats,
|
||||
layoutId,
|
||||
CharacterManagementUiController.RootElementId,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
_bindings.Assets.DefaultFont,
|
||||
_bindings.Assets.ResolveFont);
|
||||
}
|
||||
|
||||
if (layout is null)
|
||||
{
|
||||
Console.WriteLine(
|
||||
"[UI] character management: enum-table-5 root could not be imported.");
|
||||
return;
|
||||
}
|
||||
|
||||
string? deleteResponse;
|
||||
string? deleteConfirmationProbe;
|
||||
string? pleaseWait;
|
||||
string? enteringWorld;
|
||||
lock (_bindings.Assets.DatLock)
|
||||
{
|
||||
deleteConfirmationProbe = strings.ResolveTemplate(
|
||||
stringTableId,
|
||||
"ID_CharacterManagement_DeleteCharacterConfirmation",
|
||||
new Dictionary<uint, string>
|
||||
{
|
||||
[DatStringResolver.PlayerVariable] = string.Empty,
|
||||
});
|
||||
deleteResponse = ResolveCharacterManagementString(
|
||||
strings,
|
||||
stringTableId,
|
||||
"ID_CharacterManagement_DeleteCharacterResponse");
|
||||
pleaseWait = ResolveCharacterManagementString(
|
||||
strings,
|
||||
stringTableId,
|
||||
"ID_CharacterManagement_PleaseWait");
|
||||
enteringWorld = ResolveCharacterManagementString(
|
||||
strings,
|
||||
stringTableId,
|
||||
"ID_Character_EnteringWorld");
|
||||
}
|
||||
|
||||
if (deleteConfirmationProbe is null
|
||||
|| deleteResponse is null
|
||||
|| pleaseWait is null
|
||||
|| enteringWorld is null)
|
||||
{
|
||||
Console.WriteLine(
|
||||
"[UI] character management: required retail strings are unavailable.");
|
||||
return;
|
||||
}
|
||||
|
||||
UiElement? ResolveTemplate(uint templateLayoutId, uint templateElementId)
|
||||
{
|
||||
lock (_bindings.Assets.DatLock)
|
||||
{
|
||||
return LayoutImporter.Import(
|
||||
_bindings.Assets.Dats,
|
||||
templateLayoutId,
|
||||
templateElementId,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
_bindings.Assets.DefaultFont,
|
||||
_bindings.Assets.ResolveFont)?.Root;
|
||||
}
|
||||
}
|
||||
|
||||
string ComposeDeleteConfirmation(string characterName)
|
||||
{
|
||||
lock (_bindings.Assets.DatLock)
|
||||
{
|
||||
return NormalizeRetailNewlines(strings.ResolveTemplate(
|
||||
stringTableId,
|
||||
"ID_CharacterManagement_DeleteCharacterConfirmation",
|
||||
new Dictionary<uint, string>
|
||||
{
|
||||
[DatStringResolver.PlayerVariable] = characterName,
|
||||
})!);
|
||||
}
|
||||
}
|
||||
|
||||
CharacterManagementController = CharacterManagementUiController.Bind(
|
||||
Host.Root,
|
||||
layout,
|
||||
ResolveTemplate,
|
||||
DialogFactory,
|
||||
bindings,
|
||||
new CharacterManagementUiController.DialogStrings(
|
||||
ComposeDeleteConfirmation,
|
||||
deleteResponse,
|
||||
pleaseWait,
|
||||
enteringWorld));
|
||||
|
||||
if (CharacterManagementController is null)
|
||||
return;
|
||||
Console.WriteLine(
|
||||
$"[UI] retail character management from enum table 5 "
|
||||
+ $"(0x10000005 -> 0x{layoutId:X8}, root 0x1000039A; flat list, no viewport).");
|
||||
}
|
||||
|
||||
private static string? ResolveCharacterManagementString(
|
||||
DatStringResolver strings,
|
||||
uint tableId,
|
||||
string key) =>
|
||||
strings.Resolve(tableId, DatStringResolver.ComputeHash(key)) is { } value
|
||||
? NormalizeRetailNewlines(value)
|
||||
: null;
|
||||
|
||||
private static string NormalizeRetailNewlines(string value) =>
|
||||
value.Replace("\\n", "\n", StringComparison.Ordinal);
|
||||
|
||||
private void MountItemCooldowns()
|
||||
{
|
||||
ItemCooldownAssets? assets;
|
||||
|
|
@ -3712,7 +3871,11 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
}
|
||||
},
|
||||
() => _itemConfirmationController?.Dispose(),
|
||||
() => _gameplayConfirmationController?.Dispose(),
|
||||
() =>
|
||||
{
|
||||
CharacterManagementController?.Dispose();
|
||||
_gameplayConfirmationController?.Dispose();
|
||||
},
|
||||
() => DialogFactory?.Dispose(),
|
||||
_panelUi.Dispose,
|
||||
Host.Dispose);
|
||||
|
|
|
|||
|
|
@ -49,6 +49,13 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
/// <summary>Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize).</summary>
|
||||
public Action? OnClick { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional left-button double-click handler. Null preserves the existing
|
||||
/// bubbling behavior; character-management row template 0x100003A5 opts in
|
||||
/// for retail's element message 0x1A (activate the selected character).
|
||||
/// </summary>
|
||||
public Action? OnDoubleClick { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional right-click handler (Campaign OP slice OP8's Configure Keyboard
|
||||
/// screen: right-click a bound key button to erase that one binding —
|
||||
|
|
@ -551,6 +558,11 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
OnClick?.Invoke();
|
||||
OnClickAt?.Invoke(e.Data1, e.Data2);
|
||||
return OnClick is not null || OnClickAt is not null;
|
||||
case UiEventType.DoubleClick:
|
||||
if (OnDoubleClick is null) return false;
|
||||
if (!Enabled) return true;
|
||||
OnDoubleClick.Invoke();
|
||||
return true;
|
||||
case UiEventType.RightClick:
|
||||
// S6 (2026-08-11 review): unlike Click (whose swallow-when-
|
||||
// disabled is pre-existing, harmless-by-construction behavior
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue