merge: Campaign LA LA8 - retail character screen review-closed
This commit is contained in:
commit
fe63ce186a
20 changed files with 3431 additions and 37 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
|
||||
|
|
|
|||
705
src/AcDream.App/UI/Layout/CharacterManagementUiController.cs
Normal file
705
src/AcDream.App/UI/Layout/CharacterManagementUiController.cs
Normal file
|
|
@ -0,0 +1,705 @@
|
|||
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)
|
||||
{
|
||||
CharacterManagementUiController? controller = CreateDetached(
|
||||
host,
|
||||
layout,
|
||||
templateResolver,
|
||||
dialogs,
|
||||
bindings,
|
||||
strings);
|
||||
if (controller is null)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
controller.AttachAndTick();
|
||||
return controller;
|
||||
}
|
||||
catch
|
||||
{
|
||||
controller.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
internal static CharacterManagementUiController? CreateDetached(
|
||||
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;
|
||||
try
|
||||
{
|
||||
return new CharacterManagementUiController(
|
||||
host,
|
||||
layout,
|
||||
list,
|
||||
create,
|
||||
enter,
|
||||
delete,
|
||||
restore,
|
||||
dialogs,
|
||||
bindings,
|
||||
strings);
|
||||
}
|
||||
catch
|
||||
{
|
||||
list.TemplateResolver = null;
|
||||
create.OnClick = null;
|
||||
enter.OnClick = null;
|
||||
delete.OnClick = null;
|
||||
restore.OnClick = null;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
internal void AttachAndTick()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (Root.Parent is null)
|
||||
_host.AddChild(Root);
|
||||
Tick();
|
||||
}
|
||||
|
||||
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;
|
||||
try
|
||||
{
|
||||
CloseAllDialogs(suppressCallbacks: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
internal sealed record CharacterManagementUiMountResources(
|
||||
uint LayoutId,
|
||||
ImportedLayout Layout,
|
||||
Func<uint, uint, UiElement?> TemplateResolver,
|
||||
CharacterManagementUiController.DialogStrings Strings);
|
||||
|
||||
/// <summary>
|
||||
/// Retryable, idempotent composition edge for the pre-world character screen.
|
||||
/// DATs can become readable after the graphical runtime starts (installer copy,
|
||||
/// mapped-file replacement, or a transient catalog miss), so an unavailable
|
||||
/// dialog catalog, root, template, or string must not permanently suppress the
|
||||
/// screen. Once bound, later ticks are no-ops and cannot duplicate the root or
|
||||
/// controller lifetime.
|
||||
/// </summary>
|
||||
internal sealed class CharacterManagementUiMountCoordinator : IDisposable
|
||||
{
|
||||
private readonly UiRoot _host;
|
||||
private readonly CharacterSelectionRuntimeBindings _bindings;
|
||||
private readonly Func<RetailDialogFactory?> _ensureDialogs;
|
||||
private readonly Func<CharacterManagementUiMountResources?> _loadResources;
|
||||
private bool _disposed;
|
||||
|
||||
public CharacterManagementUiMountCoordinator(
|
||||
UiRoot host,
|
||||
CharacterSelectionRuntimeBindings bindings,
|
||||
Func<RetailDialogFactory?> ensureDialogs,
|
||||
Func<CharacterManagementUiMountResources?> loadResources)
|
||||
{
|
||||
_host = host ?? throw new ArgumentNullException(nameof(host));
|
||||
_bindings = bindings ?? throw new ArgumentNullException(nameof(bindings));
|
||||
_ensureDialogs = ensureDialogs
|
||||
?? throw new ArgumentNullException(nameof(ensureDialogs));
|
||||
_loadResources = loadResources
|
||||
?? throw new ArgumentNullException(nameof(loadResources));
|
||||
}
|
||||
|
||||
public CharacterManagementUiController? Controller { get; private set; }
|
||||
|
||||
public void Tick()
|
||||
{
|
||||
if (_disposed || Controller is not null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
RetailDialogFactory? dialogs = _ensureDialogs();
|
||||
if (dialogs is null)
|
||||
return;
|
||||
|
||||
CharacterManagementUiMountResources? resources = _loadResources();
|
||||
if (resources is null)
|
||||
return;
|
||||
|
||||
CharacterManagementUiController? candidate =
|
||||
CharacterManagementUiController.CreateDetached(
|
||||
_host,
|
||||
resources.Layout,
|
||||
resources.TemplateResolver,
|
||||
dialogs,
|
||||
_bindings,
|
||||
resources.Strings);
|
||||
if (candidate is null)
|
||||
return;
|
||||
|
||||
// Take ownership before the first attach/tick. Template resolution
|
||||
// happens inside that tick and can throw after the root and button
|
||||
// handlers are live; the catch below can therefore always retire
|
||||
// the exact partial controller before a later retry.
|
||||
Controller = candidate;
|
||||
candidate.AttachAndTick();
|
||||
Console.WriteLine(
|
||||
$"[UI] retail character management from enum table 5 "
|
||||
+ $"(0x10000005 -> 0x{resources.LayoutId:X8}, "
|
||||
+ "root 0x1000039A; flat list, no viewport).");
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
CharacterManagementUiController? partial = Controller;
|
||||
Controller = null;
|
||||
try
|
||||
{
|
||||
partial?.Dispose();
|
||||
}
|
||||
catch (Exception cleanupError)
|
||||
{
|
||||
Console.WriteLine(
|
||||
"[UI] character management partial-mount cleanup failed: "
|
||||
+ cleanupError.Message);
|
||||
}
|
||||
Console.WriteLine(
|
||||
"[UI] character management mount will retry after resource "
|
||||
+ $"recovery: {error.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_disposed = true;
|
||||
Controller?.Dispose();
|
||||
Controller = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ public sealed class RetailDialogFactory : IDisposable
|
|||
public required RetailDialogData Data { get; init; }
|
||||
public required uint Context { get; init; }
|
||||
public required uint QueueKey { get; init; }
|
||||
public required ulong Sequence { get; init; }
|
||||
public Action<RetailDialogData>? Callback { get; init; }
|
||||
public IRetailDialogView? View { get; set; }
|
||||
}
|
||||
|
|
@ -24,8 +25,10 @@ public sealed class RetailDialogFactory : IDisposable
|
|||
private readonly Dictionary<uint, DialogInfo> _activeQueued = new();
|
||||
private readonly Dictionary<uint, DialogInfo> _activeNonQueued = new();
|
||||
private readonly Dictionary<uint, LinkedList<DialogInfo>> _pending = new();
|
||||
private readonly LinkedList<DialogInfo> _retryable = new();
|
||||
private readonly List<DialogInfo> _openOrder = new();
|
||||
private uint _globalContext;
|
||||
private ulong _globalSequence;
|
||||
private bool _resetting;
|
||||
private bool _disposed;
|
||||
|
||||
|
|
@ -51,6 +54,8 @@ public sealed class RetailDialogFactory : IDisposable
|
|||
|
||||
public int PendingCount => _pending.Values.Sum(static queue => queue.Count);
|
||||
|
||||
internal int RetryCount => _retryable.Count;
|
||||
|
||||
/// <summary>Exact root-element switch from <c>CreateDialog_ @ 0x00477AD0</c>.</summary>
|
||||
public static uint RootElementId(RetailDialogType type)
|
||||
=> type switch
|
||||
|
|
@ -87,25 +92,40 @@ public sealed class RetailDialogFactory : IDisposable
|
|||
Data = ownedData,
|
||||
Context = context,
|
||||
QueueKey = queueKey,
|
||||
Sequence = NextSequence(),
|
||||
Callback = callback,
|
||||
};
|
||||
|
||||
if (queueKey == NonQueuedKey)
|
||||
{
|
||||
_activeNonQueued.Add(context, info);
|
||||
CreateDialog(info);
|
||||
if (!TryCreateDialog(info))
|
||||
{
|
||||
_activeNonQueued.Remove(context);
|
||||
QueueRetry(info);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
if (!_activeQueued.TryGetValue(queueKey, out DialogInfo? current))
|
||||
{
|
||||
if (HasRetry(queueKey) && !IsPriority(info))
|
||||
{
|
||||
PendingQueue(queueKey).AddLast(info);
|
||||
return context;
|
||||
}
|
||||
|
||||
_activeQueued.Add(queueKey, info);
|
||||
CreateDialog(info);
|
||||
if (!TryCreateDialog(info))
|
||||
{
|
||||
_activeQueued.Remove(queueKey);
|
||||
QueueRetry(info);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
LinkedList<DialogInfo> queue = PendingQueue(queueKey);
|
||||
if (!ownedData.GetBoolean(RetailDialogProperty.Priority))
|
||||
if (!IsPriority(info))
|
||||
{
|
||||
queue.AddLast(info);
|
||||
UpdatePendingDialogDisplays();
|
||||
|
|
@ -118,7 +138,15 @@ public sealed class RetailDialogFactory : IDisposable
|
|||
Suspend(current);
|
||||
queue.AddFirst(current);
|
||||
_activeQueued[queueKey] = info;
|
||||
CreateDialog(info);
|
||||
if (!TryCreateDialog(info))
|
||||
{
|
||||
_activeQueued.Remove(queueKey);
|
||||
queue.Remove(current);
|
||||
if (queue.Count == 0)
|
||||
_pending.Remove(queueKey);
|
||||
OpenSpecificDialog(current);
|
||||
QueueRetry(info);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
|
|
@ -148,6 +176,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.
|
||||
|
|
@ -191,11 +239,25 @@ public sealed class RetailDialogFactory : IDisposable
|
|||
return true;
|
||||
}
|
||||
|
||||
LinkedListNode<DialogInfo>? retry = _retryable.First;
|
||||
while (retry is not null && retry.Value.Context != context)
|
||||
retry = retry.Next;
|
||||
if (retry is not null)
|
||||
{
|
||||
DialogInfo failed = retry.Value;
|
||||
_retryable.Remove(retry);
|
||||
DialogDone(failed);
|
||||
if (failed.QueueKey != NonQueuedKey)
|
||||
OpenNextDialog(failed.QueueKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Tick()
|
||||
{
|
||||
RetryFailedDialogs();
|
||||
foreach (DialogInfo info in _openOrder.ToArray())
|
||||
info.View?.Tick();
|
||||
}
|
||||
|
|
@ -218,6 +280,7 @@ public sealed class RetailDialogFactory : IDisposable
|
|||
DialogInfo[] infos = _activeNonQueued.Values
|
||||
.Concat(_activeQueued.Values)
|
||||
.Concat(_pending.Values.SelectMany(static queue => queue))
|
||||
.Concat(_retryable)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
if (infos.Length == 0)
|
||||
|
|
@ -229,6 +292,7 @@ public sealed class RetailDialogFactory : IDisposable
|
|||
_activeNonQueued.Clear();
|
||||
_activeQueued.Clear();
|
||||
_pending.Clear();
|
||||
_retryable.Clear();
|
||||
foreach (DialogInfo info in infos)
|
||||
{
|
||||
try { DialogDone(info); }
|
||||
|
|
@ -263,6 +327,14 @@ public sealed class RetailDialogFactory : IDisposable
|
|||
return _globalContext;
|
||||
}
|
||||
|
||||
private ulong NextSequence()
|
||||
{
|
||||
_globalSequence++;
|
||||
if (_globalSequence == 0uL)
|
||||
_globalSequence++;
|
||||
return _globalSequence;
|
||||
}
|
||||
|
||||
private LinkedList<DialogInfo> PendingQueue(uint queueKey)
|
||||
{
|
||||
if (_pending.TryGetValue(queueKey, out LinkedList<DialogInfo>? queue))
|
||||
|
|
@ -272,30 +344,57 @@ public sealed class RetailDialogFactory : IDisposable
|
|||
return queue;
|
||||
}
|
||||
|
||||
private void CreateDialog(DialogInfo info)
|
||||
private bool TryCreateDialog(DialogInfo info)
|
||||
{
|
||||
RetailDialogType type = (RetailDialogType)info.Data.GetUInt32(RetailDialogProperty.Type);
|
||||
if (type is not (RetailDialogType.Confirmation or RetailDialogType.Wait))
|
||||
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}.");
|
||||
IRetailDialogView view = type switch
|
||||
RetailDialogType type = (RetailDialogType)info.Data.GetUInt32(
|
||||
RetailDialogProperty.Type);
|
||||
try
|
||||
{
|
||||
RetailDialogType.Wait => new RetailWaitDialogView(_host, layout, info.Data),
|
||||
_ => 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);
|
||||
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.");
|
||||
}
|
||||
|
||||
ImportedLayout layout = _createLayout(type)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Retail dialog catalog could not create type {(uint)type}.");
|
||||
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)),
|
||||
};
|
||||
info.View = view;
|
||||
_host.AddChild(view.Root);
|
||||
_host.BringToFront(view.Root);
|
||||
_openOrder.Add(info);
|
||||
_host.Modal = view.Root;
|
||||
view.Tick();
|
||||
UpdatePendingDialogDisplays();
|
||||
DialogOpened?.Invoke(info.Context);
|
||||
return true;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
RemoveView(info);
|
||||
Console.WriteLine(
|
||||
$"[UI] retail dialog type {(uint)type} context {info.Context} "
|
||||
+ $"will retry after catalog recovery: {error.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void Suspend(DialogInfo info)
|
||||
|
|
@ -349,6 +448,9 @@ public sealed class RetailDialogFactory : IDisposable
|
|||
if (_activeQueued.ContainsKey(queueKey))
|
||||
return;
|
||||
|
||||
if (TryActivateRetry(queueKey))
|
||||
return;
|
||||
|
||||
if (!_pending.TryGetValue(queueKey, out LinkedList<DialogInfo>? queue)
|
||||
|| queue.First is null)
|
||||
return;
|
||||
|
|
@ -358,7 +460,115 @@ public sealed class RetailDialogFactory : IDisposable
|
|||
if (queue.Count == 0)
|
||||
_pending.Remove(queueKey);
|
||||
_activeQueued.Add(queueKey, next);
|
||||
CreateDialog(next);
|
||||
if (!TryCreateDialog(next))
|
||||
{
|
||||
_activeQueued.Remove(queueKey);
|
||||
QueueRetry(next);
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenSpecificDialog(DialogInfo info)
|
||||
{
|
||||
_activeQueued.Add(info.QueueKey, info);
|
||||
if (!TryCreateDialog(info))
|
||||
{
|
||||
_activeQueued.Remove(info.QueueKey);
|
||||
QueueRetry(info);
|
||||
}
|
||||
}
|
||||
|
||||
private void RetryFailedDialogs()
|
||||
{
|
||||
foreach (DialogInfo info in _retryable.ToArray())
|
||||
{
|
||||
if (info.QueueKey == NonQueuedKey)
|
||||
{
|
||||
_activeNonQueued.Add(info.Context, info);
|
||||
if (TryCreateDialog(info))
|
||||
_retryable.Remove(info);
|
||||
else
|
||||
_activeNonQueued.Remove(info.Context);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ReferenceEquals(FirstRetry(info.QueueKey), info))
|
||||
continue;
|
||||
|
||||
if (!_activeQueued.TryGetValue(
|
||||
info.QueueKey,
|
||||
out DialogInfo? active))
|
||||
TryActivateRetry(info.QueueKey);
|
||||
else if (IsPriority(info)
|
||||
&& (!IsPriority(active) || info.Sequence > active.Sequence))
|
||||
TryPreemptWithRetry(info, active);
|
||||
}
|
||||
}
|
||||
|
||||
private void TryPreemptWithRetry(DialogInfo priority, DialogInfo current)
|
||||
{
|
||||
LinkedList<DialogInfo> queue = PendingQueue(priority.QueueKey);
|
||||
Suspend(current);
|
||||
queue.AddFirst(current);
|
||||
_activeQueued[priority.QueueKey] = priority;
|
||||
_retryable.Remove(priority);
|
||||
if (TryCreateDialog(priority))
|
||||
return;
|
||||
|
||||
_activeQueued.Remove(priority.QueueKey);
|
||||
queue.Remove(current);
|
||||
if (queue.Count == 0)
|
||||
_pending.Remove(priority.QueueKey);
|
||||
OpenSpecificDialog(current);
|
||||
QueueRetry(priority);
|
||||
}
|
||||
|
||||
private bool TryActivateRetry(uint queueKey)
|
||||
{
|
||||
DialogInfo? info = FirstRetry(queueKey);
|
||||
if (info is null)
|
||||
return false;
|
||||
|
||||
_activeQueued.Add(queueKey, info);
|
||||
if (TryCreateDialog(info))
|
||||
_retryable.Remove(info);
|
||||
else
|
||||
_activeQueued.Remove(queueKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
private DialogInfo? FirstRetry(uint queueKey)
|
||||
{
|
||||
foreach (DialogInfo info in _retryable)
|
||||
if (info.QueueKey == queueKey)
|
||||
return info;
|
||||
return null;
|
||||
}
|
||||
|
||||
private bool HasRetry(uint queueKey) => FirstRetry(queueKey) is not null;
|
||||
|
||||
private static bool IsPriority(DialogInfo info) =>
|
||||
info.Data.GetBoolean(RetailDialogProperty.Priority);
|
||||
|
||||
private void QueueRetry(DialogInfo info)
|
||||
{
|
||||
if (_retryable.Contains(info))
|
||||
return;
|
||||
if (!IsPriority(info))
|
||||
{
|
||||
_retryable.AddLast(info);
|
||||
return;
|
||||
}
|
||||
|
||||
LinkedListNode<DialogInfo>? existing = _retryable.First;
|
||||
while (existing is not null
|
||||
&& existing.Value.QueueKey != info.QueueKey)
|
||||
{
|
||||
existing = existing.Next;
|
||||
}
|
||||
if (existing is null)
|
||||
_retryable.AddLast(info);
|
||||
else
|
||||
_retryable.AddBefore(existing, info);
|
||||
}
|
||||
|
||||
private void UpdatePendingDialogDisplays()
|
||||
|
|
|
|||
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
|
||||
|
|
@ -417,6 +434,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
private UiShortcutDigitGraphics? _shortcutDigitGraphics;
|
||||
private ItemCooldownUiController? _itemCooldownController;
|
||||
private VividTargetIndicatorController? _vividTargetIndicator;
|
||||
private CharacterManagementUiMountCoordinator? _characterManagementMount;
|
||||
private IDisposable? _characterSheetSubscription;
|
||||
private ResourceShutdownTransaction? _shutdown;
|
||||
private bool _disposed;
|
||||
|
|
@ -483,6 +501,8 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
MountVendor();
|
||||
MountSecureTrade();
|
||||
MountItemCooldowns();
|
||||
ConfigureCharacterManagement();
|
||||
_characterManagementMount?.Tick();
|
||||
Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged;
|
||||
BindToolbarPanelButtons();
|
||||
SyncToolbarWindowButtons();
|
||||
|
|
@ -577,6 +597,8 @@ 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 =>
|
||||
_characterManagementMount?.Controller;
|
||||
|
||||
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
|
||||
{
|
||||
|
|
@ -622,6 +644,8 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
ExternalContainerController?.Tick();
|
||||
SocialPanelController?.Tick();
|
||||
_itemCooldownController?.Tick();
|
||||
_characterManagementMount?.Tick();
|
||||
CharacterManagementController?.Tick();
|
||||
DialogFactory?.Tick();
|
||||
Host.Tick(deltaSeconds);
|
||||
_automation?.Tick(deltaSeconds);
|
||||
|
|
@ -788,6 +812,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
{
|
||||
try
|
||||
{
|
||||
CharacterManagementController?.ResetSession();
|
||||
DialogFactory?.Reset();
|
||||
}
|
||||
finally
|
||||
|
|
@ -3030,13 +3055,29 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
|
||||
private void MountDialogFactory()
|
||||
{
|
||||
if (DialogFactory is not null)
|
||||
return;
|
||||
|
||||
uint layoutId;
|
||||
lock (_bindings.Assets.DatLock)
|
||||
try
|
||||
{
|
||||
// DialogFactory::CreateDialog_ @ 0x00477AD0 resolves the shared
|
||||
// catalog through GetDIDByEnum(2, 5). Each shown DialogInfo then
|
||||
// creates a fresh type-specific root from that catalog.
|
||||
layoutId = RetailDataIdResolver.Resolve(_bindings.Assets.Dats, 2u, 5u);
|
||||
lock (_bindings.Assets.DatLock)
|
||||
{
|
||||
// DialogFactory::CreateDialog_ @ 0x00477AD0 resolves the shared
|
||||
// catalog through GetDIDByEnum(2, 5). Each shown DialogInfo then
|
||||
// creates a fresh type-specific root from that catalog.
|
||||
layoutId = RetailDataIdResolver.Resolve(
|
||||
_bindings.Assets.Dats,
|
||||
2u,
|
||||
5u);
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
Console.WriteLine(
|
||||
"[UI] retail dialog catalog will retry after resource "
|
||||
+ $"recovery: {error.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (layoutId == 0u)
|
||||
|
|
@ -3669,6 +3710,145 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
"[M4] retail secure trade panel mounted from LayoutDesc 0x2100000D.");
|
||||
}
|
||||
|
||||
private void ConfigureCharacterManagement()
|
||||
{
|
||||
CharacterSelectionRuntimeBindings? bindings =
|
||||
_bindings.CharacterSelection;
|
||||
if (bindings is null || _characterManagementMount is not null)
|
||||
return;
|
||||
|
||||
_characterManagementMount = new CharacterManagementUiMountCoordinator(
|
||||
Host.Root,
|
||||
bindings,
|
||||
EnsureDialogFactory,
|
||||
LoadCharacterManagementResources);
|
||||
}
|
||||
|
||||
private RetailDialogFactory? EnsureDialogFactory()
|
||||
{
|
||||
MountDialogFactory();
|
||||
return DialogFactory;
|
||||
}
|
||||
|
||||
private CharacterManagementUiMountResources? LoadCharacterManagementResources()
|
||||
{
|
||||
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 null;
|
||||
}
|
||||
|
||||
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 null;
|
||||
}
|
||||
|
||||
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,
|
||||
})!);
|
||||
}
|
||||
}
|
||||
|
||||
return new CharacterManagementUiMountResources(
|
||||
layoutId,
|
||||
layout,
|
||||
ResolveTemplate,
|
||||
new CharacterManagementUiController.DialogStrings(
|
||||
ComposeDeleteConfirmation,
|
||||
deleteResponse,
|
||||
pleaseWait,
|
||||
enteringWorld));
|
||||
}
|
||||
|
||||
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 +3892,11 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
}
|
||||
},
|
||||
() => _itemConfirmationController?.Dispose(),
|
||||
() => _gameplayConfirmationController?.Dispose(),
|
||||
() =>
|
||||
{
|
||||
_characterManagementMount?.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