feat(chat): port retail client command families
Expand the typed client-command boundary across travel, character queries, local UI and layout controls, AFK and consent, emotes, friends, squelch and filters, and fill-components. Preserve retail packet layouts and queue ownership, import the confirmation dialog, and keep authoritative social state in Core. Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
parent
5a45a7ac7f
commit
9ea579bdd0
47 changed files with 6659 additions and 99 deletions
|
|
@ -136,6 +136,7 @@ public sealed class PlayerMovementController
|
|||
public Vector3 Position => _body.Position;
|
||||
public Vector3 RenderPosition => ComputeRenderPosition();
|
||||
public uint CellId { get; private set; }
|
||||
public AcDream.Core.Physics.Position CellPosition => _body.CellPosition;
|
||||
|
||||
/// <summary>
|
||||
/// Local-player entity id used to skip self-collision in the
|
||||
|
|
|
|||
|
|
@ -730,6 +730,10 @@ public sealed class GameWindow : IDisposable
|
|||
public readonly AcDream.Core.Chat.TurbineChatState TurbineChat = new();
|
||||
public readonly AcDream.Core.Combat.CombatState Combat = new();
|
||||
public readonly AcDream.Core.Items.ItemManaState ItemMana = new();
|
||||
public readonly AcDream.Core.Social.FriendsState Friends = new();
|
||||
public readonly AcDream.Core.Social.SquelchState Squelch = new();
|
||||
public IReadOnlyList<(uint Id, uint Amount)> DesiredComponents { get; private set; }
|
||||
= System.Array.Empty<(uint Id, uint Amount)>();
|
||||
// Issue #11 — load static spell metadata from data/spells.csv at startup.
|
||||
// Provides Family for buff stacking (issue #6) + names + icons + tooltips
|
||||
// for the future Spellbook panel. The CSV is copied to bin/<config>/net10.0/data/
|
||||
|
|
@ -757,6 +761,7 @@ public sealed class GameWindow : IDisposable
|
|||
// when no selection. Spec: docs/superpowers/specs/2026-05-15-phase-b7-target-indicator-design.md
|
||||
private AcDream.App.UI.TargetIndicatorPanel? _targetIndicator;
|
||||
private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm;
|
||||
private AcDream.UI.Abstractions.Panels.Chat.ChatVM? _retailChatVm;
|
||||
// Phase D.2b — retained host + composition runtime. Null unless ACDREAM_RETAIL_UI=1.
|
||||
private AcDream.App.UI.UiHost? _uiHost;
|
||||
private AcDream.App.UI.RetailUiRuntime? _retailUiRuntime;
|
||||
|
|
@ -2114,6 +2119,7 @@ public sealed class GameWindow : IDisposable
|
|||
coordinatesOnRadar: () => _persistedGameplay.CoordinatesOnRadar,
|
||||
uiLocked: () => _persistedGameplay.LockUI);
|
||||
var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200);
|
||||
_retailChatVm = retailChatVm;
|
||||
AcDream.App.UI.RetailUiPersistenceBindings? persistence = _settingsStore is null
|
||||
? null
|
||||
: new AcDream.App.UI.RetailUiPersistenceBindings(
|
||||
|
|
@ -2642,7 +2648,17 @@ public sealed class GameWindow : IDisposable
|
|||
onShortcuts: list => Shortcuts = list,
|
||||
playerGuid: () => _playerServerGuid,
|
||||
onUseDone: error => _itemInteractionController?.CompleteUse(error),
|
||||
itemMana: ItemMana);
|
||||
itemMana: ItemMana,
|
||||
onConfirmationRequest: request =>
|
||||
_retailUiRuntime?.ConfirmationDialogs?.Show(
|
||||
request.Message,
|
||||
accepted => session.SendConfirmationResponse(
|
||||
request.Type,
|
||||
request.ContextId,
|
||||
accepted)),
|
||||
friends: Friends,
|
||||
squelch: Squelch,
|
||||
onDesiredComponents: components => DesiredComponents = components);
|
||||
|
||||
// Phase I.7: subscribe to CombatState events and emit
|
||||
// retail-faithful "You hit X for Y damage" chat lines into
|
||||
|
|
@ -2717,7 +2733,62 @@ public sealed class GameWindow : IDisposable
|
|||
var turbineChat = TurbineChat;
|
||||
uint playerGuid = _playerServerGuid;
|
||||
var clientCommandController = new AcDream.App.UI.ClientCommandController(
|
||||
liveSession.SendTeleportToLifestone);
|
||||
new AcDream.App.UI.ClientCommandController.Bindings(
|
||||
TeleportToLifestone: liveSession.SendTeleportToLifestone,
|
||||
TeleportToMarketplace: liveSession.SendTeleportToMarketplace,
|
||||
TeleportToPkArena: liveSession.SendTeleportToPkArena,
|
||||
TeleportToPkLiteArena: liveSession.SendTeleportToPkLiteArena,
|
||||
TeleportToHouse: liveSession.SendTeleportToHouse,
|
||||
TeleportToMansion: liveSession.SendTeleportToMansion,
|
||||
QueryAge: liveSession.SendQueryAge,
|
||||
QueryBirth: liveSession.SendQueryBirth,
|
||||
ToggleFrameRate: ToggleRetailFrameRate,
|
||||
ToggleUiLock: () => SetRetailUiLocked(!_persistedGameplay.LockUI),
|
||||
ShowSystemMessage: text => chat.OnSystemMessage(text, 0u),
|
||||
ShowWeenieError: code => chat.OnWeenieError(code, null),
|
||||
PlayerPublicWeenieBitfield: () =>
|
||||
Objects.Get(_playerServerGuid)?.PublicWeenieBitfield,
|
||||
ClientVersion: () =>
|
||||
typeof(GameWindow).Assembly.GetName().Version?.ToString(3)
|
||||
?? "unknown",
|
||||
CurrentPosition: () => _playerController?.CellPosition,
|
||||
LastOutsideCorpsePosition: () =>
|
||||
LocalPlayer.GetPosition(0x0Eu),
|
||||
ShowConfirmation: (message, completed) =>
|
||||
_retailUiRuntime?.ConfirmationDialogs?.Show(message, completed),
|
||||
Suicide: liveSession.SendSuicide,
|
||||
ClearChat: _ => chat.Clear(),
|
||||
SaveUi: name => _retailUiRuntime?.SaveNamedLayout(name),
|
||||
LoadUi: name => _retailUiRuntime?.RestoreNamedLayout(name),
|
||||
SaveAutoUi: () => _retailUiRuntime?.SaveLayout(),
|
||||
LoadAutoUi: () => _retailUiRuntime?.RestoreLayout(),
|
||||
IsAway: () =>
|
||||
Objects.Get(_playerServerGuid)?.Properties.GetBool(0x6Eu) == true,
|
||||
SetAway: away => SetRetailAway(liveSession, away),
|
||||
SetAwayMessage: liveSession.SendSetAfkMessage,
|
||||
AcceptLootPermits: () => _persistedGameplay.AcceptLootPermits,
|
||||
SetAcceptLootPermits: SetRetailAcceptLootPermits,
|
||||
DisplayConsent: liveSession.SendDisplayConsent,
|
||||
ClearConsent: liveSession.SendClearConsent,
|
||||
RemoveConsent: liveSession.SendRemoveConsent,
|
||||
SendEmote: liveSession.SendEmote,
|
||||
Friends: Friends,
|
||||
AddFriend: liveSession.SendAddFriend,
|
||||
RemoveFriend: liveSession.SendRemoveFriend,
|
||||
ClearFriends: liveSession.SendClearFriends,
|
||||
RequestLegacyFriends: liveSession.SendLegacyFriendsListRequest,
|
||||
Squelch: Squelch,
|
||||
ModifyCharacterSquelch: liveSession.SendModifyCharacterSquelch,
|
||||
ModifyAccountSquelch: liveSession.SendModifyAccountSquelch,
|
||||
ModifyGlobalSquelch: liveSession.SendModifyGlobalSquelch,
|
||||
LastTeller: () => _retailChatVm?.LastIncomingTellSender,
|
||||
ClearDesiredComponents: () =>
|
||||
{
|
||||
liveSession.SendClearDesiredComponents();
|
||||
DesiredComponents = System.Array.Empty<(uint Id, uint Amount)>();
|
||||
},
|
||||
HasOpenVendor: () => false,
|
||||
FillComponentBuyList: (_, _) => { }));
|
||||
_commandBus.Register<AcDream.UI.Abstractions.ExecuteClientCommandCmd>(
|
||||
clientCommandController.Execute);
|
||||
_commandBus.Register<AcDream.UI.Abstractions.SendServerCommandCmd>(cmd =>
|
||||
|
|
@ -11386,6 +11457,46 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
private void SetRetailAway(AcDream.Core.Net.WorldSession session, bool away)
|
||||
{
|
||||
session.SendSetAfkMode(away);
|
||||
}
|
||||
|
||||
private void SetRetailAcceptLootPermits(bool enabled)
|
||||
{
|
||||
_persistedGameplay = _persistedGameplay with { AcceptLootPermits = enabled };
|
||||
_settingsVm?.SetGameplay(
|
||||
_settingsVm.GameplayDraft with { AcceptLootPermits = enabled });
|
||||
_settingsStore?.SaveGameplay(_persistedGameplay);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>ClientCommunicationSystem::DoFrameRate @ 0x005707D0</c>:
|
||||
/// flip the live display flag and persist it through the same settings
|
||||
/// path used by the Display panel.
|
||||
/// </summary>
|
||||
private void ToggleRetailFrameRate()
|
||||
{
|
||||
_persistedDisplay = _persistedDisplay with
|
||||
{
|
||||
ShowFps = !_persistedDisplay.ShowFps,
|
||||
};
|
||||
if (_settingsVm is not null)
|
||||
_settingsVm.SetDisplay(_settingsVm.DisplayDraft with
|
||||
{
|
||||
ShowFps = _persistedDisplay.ShowFps,
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
_settingsStore?.SaveDisplay(_persistedDisplay);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"settings: framerate display save failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void SetRetailCombatGameplay(
|
||||
AcDream.UI.Abstractions.Panels.Settings.GameplaySettings gameplay)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,20 +1,69 @@
|
|||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Ui;
|
||||
using AcDream.Core.Social;
|
||||
using AcDream.UI.Abstractions;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Application-layer executor for typed retail client commands. Chat panels
|
||||
/// remain backend- and network-agnostic; this controller translates their
|
||||
/// intent to the live-session action supplied by the composition root.
|
||||
/// remain backend- and network-agnostic; this controller owns the boundary
|
||||
/// between verified command behavior and live session/UI services.
|
||||
/// </summary>
|
||||
public sealed class ClientCommandController
|
||||
{
|
||||
private readonly Action _teleportToLifestone;
|
||||
public sealed record Bindings(
|
||||
Action TeleportToLifestone,
|
||||
Action TeleportToMarketplace,
|
||||
Action TeleportToPkArena,
|
||||
Action TeleportToPkLiteArena,
|
||||
Action TeleportToHouse,
|
||||
Action TeleportToMansion,
|
||||
Action QueryAge,
|
||||
Action QueryBirth,
|
||||
Action ToggleFrameRate,
|
||||
Action ToggleUiLock,
|
||||
Action<string> ShowSystemMessage,
|
||||
Action<uint> ShowWeenieError,
|
||||
Func<uint?> PlayerPublicWeenieBitfield,
|
||||
Func<string> ClientVersion,
|
||||
Func<Position?> CurrentPosition,
|
||||
Func<Position?> LastOutsideCorpsePosition,
|
||||
Action<string, Action<bool>> ShowConfirmation,
|
||||
Action Suicide,
|
||||
Action<bool> ClearChat,
|
||||
Action<string> SaveUi,
|
||||
Action<string> LoadUi,
|
||||
Action SaveAutoUi,
|
||||
Action LoadAutoUi,
|
||||
Func<bool> IsAway,
|
||||
Action<bool> SetAway,
|
||||
Action<string> SetAwayMessage,
|
||||
Func<bool> AcceptLootPermits,
|
||||
Action<bool> SetAcceptLootPermits,
|
||||
Action DisplayConsent,
|
||||
Action ClearConsent,
|
||||
Action<string> RemoveConsent,
|
||||
Action<string> SendEmote,
|
||||
FriendsState Friends,
|
||||
Action<string> AddFriend,
|
||||
Action<uint> RemoveFriend,
|
||||
Action ClearFriends,
|
||||
Action RequestLegacyFriends,
|
||||
SquelchState Squelch,
|
||||
Action<bool, uint, string, uint> ModifyCharacterSquelch,
|
||||
Action<bool, string> ModifyAccountSquelch,
|
||||
Action<bool, uint> ModifyGlobalSquelch,
|
||||
Func<string?> LastTeller,
|
||||
Action ClearDesiredComponents,
|
||||
Func<bool> HasOpenVendor,
|
||||
Action<uint?, uint> FillComponentBuyList);
|
||||
|
||||
public ClientCommandController(Action teleportToLifestone)
|
||||
private readonly Bindings _bindings;
|
||||
|
||||
public ClientCommandController(Bindings bindings)
|
||||
{
|
||||
_teleportToLifestone = teleportToLifestone
|
||||
?? throw new ArgumentNullException(nameof(teleportToLifestone));
|
||||
_bindings = bindings ?? throw new ArgumentNullException(nameof(bindings));
|
||||
}
|
||||
|
||||
public void Execute(ExecuteClientCommandCmd command)
|
||||
|
|
@ -23,14 +72,595 @@ public sealed class ClientCommandController
|
|||
|
||||
switch (command.Command)
|
||||
{
|
||||
// Retail: ClientCommunicationSystem::DoLifestone @ 0x0056FC70
|
||||
// -> CM_Character::Event_TeleToLifestone @ 0x006A1B90.
|
||||
// ClientCommunicationSystem::DoLifestone @ 0x0056FC70.
|
||||
case ClientCommandId.LifestoneRecall:
|
||||
_teleportToLifestone();
|
||||
_bindings.TeleportToLifestone();
|
||||
break;
|
||||
// ClientCommunicationSystem::DoMarketplace @ 0x0056FCE0.
|
||||
case ClientCommandId.MarketplaceRecall:
|
||||
_bindings.TeleportToMarketplace();
|
||||
break;
|
||||
// DoPKArena @ 0x005788D0 gates on ACCWeenieObject::IsPK.
|
||||
case ClientCommandId.PkArenaRecall:
|
||||
if (HasPlayerFlag(EntityCollisionFlags.IsPK) == false)
|
||||
_bindings.ShowWeenieError(0x055Fu);
|
||||
else
|
||||
_bindings.TeleportToPkArena();
|
||||
break;
|
||||
// DoPKLArena @ 0x005789D0 gates on ACCWeenieObject::IsPKLite.
|
||||
case ClientCommandId.PkLiteArenaRecall:
|
||||
if (HasPlayerFlag(EntityCollisionFlags.IsPKLite) == false)
|
||||
_bindings.ShowWeenieError(0x0560u);
|
||||
else
|
||||
_bindings.TeleportToPkLiteArena();
|
||||
break;
|
||||
case ClientCommandId.HouseRecall:
|
||||
_bindings.TeleportToHouse();
|
||||
break;
|
||||
case ClientCommandId.MansionRecall:
|
||||
_bindings.TeleportToMansion();
|
||||
break;
|
||||
case ClientCommandId.QueryAge:
|
||||
_bindings.QueryAge();
|
||||
break;
|
||||
case ClientCommandId.QueryBirth:
|
||||
_bindings.QueryBirth();
|
||||
break;
|
||||
// DoFrameRate @ 0x005707D0 toggles the display flag; it does
|
||||
// not print a one-shot FPS sample into chat.
|
||||
case ClientCommandId.ToggleFrameRate:
|
||||
_bindings.ToggleFrameRate();
|
||||
break;
|
||||
// DoLockUI @ 0x005703B0 toggles PlayerModule::LockUI and
|
||||
// broadcasts the new state to every UI element.
|
||||
case ClientCommandId.ToggleUiLock:
|
||||
_bindings.ToggleUiLock();
|
||||
break;
|
||||
case ClientCommandId.ShowVersion:
|
||||
_bindings.ShowSystemMessage($"Client version {_bindings.ClientVersion()}");
|
||||
break;
|
||||
case ClientCommandId.ShowLocation:
|
||||
Position? position = _bindings.CurrentPosition();
|
||||
_bindings.ShowSystemMessage(position is { ObjCellId: not 0u }
|
||||
? $"Your location is: {RetailPositionFormatter.Format(position.Value)}"
|
||||
: "Not in valid cell!");
|
||||
break;
|
||||
case ClientCommandId.ShowLastCorpseLocation:
|
||||
Position? corpse = _bindings.LastOutsideCorpsePosition();
|
||||
string? coordinates = corpse is null
|
||||
? null
|
||||
: RetailPositionFormatter.FormatOutdoorCell(corpse.Value.ObjCellId);
|
||||
_bindings.ShowSystemMessage(coordinates is null
|
||||
? "We're sorry, but we have no record of your last outside corpse location."
|
||||
: $"The last time you died outside, your corpse was located at ({coordinates}).");
|
||||
break;
|
||||
// ClientCommunicationSystem::DoDie @ 0x00580050 and
|
||||
// DieDialogCallback @ 0x0057BA70.
|
||||
case ClientCommandId.Die:
|
||||
_bindings.ShowConfirmation(
|
||||
"Do you really want to kill your character? You may drop items and accrue a vitae penalty.",
|
||||
accepted =>
|
||||
{
|
||||
if (accepted)
|
||||
_bindings.Suicide();
|
||||
});
|
||||
break;
|
||||
case ClientCommandId.ClearChat:
|
||||
_bindings.ClearChat(FirstArgument(command.Arguments)
|
||||
.Equals("all", StringComparison.OrdinalIgnoreCase));
|
||||
break;
|
||||
case ClientCommandId.SaveUi:
|
||||
ExecuteUiProfile(command.Arguments, save: true);
|
||||
break;
|
||||
case ClientCommandId.LoadUi:
|
||||
ExecuteUiProfile(command.Arguments, save: false);
|
||||
break;
|
||||
case ClientCommandId.SaveAutoUi:
|
||||
if (RequireNoArguments(command.Arguments, "/saveautoui"))
|
||||
_bindings.SaveAutoUi();
|
||||
break;
|
||||
case ClientCommandId.LoadAutoUi:
|
||||
if (RequireNoArguments(command.Arguments, "/loadautoui"))
|
||||
_bindings.LoadAutoUi();
|
||||
break;
|
||||
case ClientCommandId.Away:
|
||||
ExecuteAway(command.Arguments);
|
||||
break;
|
||||
case ClientCommandId.Consent:
|
||||
ExecuteConsent(command.Arguments);
|
||||
break;
|
||||
case ClientCommandId.Emote:
|
||||
if (!string.IsNullOrWhiteSpace(command.Arguments))
|
||||
_bindings.SendEmote(command.Arguments.Trim());
|
||||
break;
|
||||
case ClientCommandId.ListEmotes:
|
||||
_bindings.ShowSystemMessage(StandardEmotes);
|
||||
break;
|
||||
case ClientCommandId.Friends:
|
||||
ExecuteFriends(command.Arguments);
|
||||
break;
|
||||
case ClientCommandId.FriendsAdd:
|
||||
AddFriend(command.Arguments);
|
||||
break;
|
||||
case ClientCommandId.FriendsRemove:
|
||||
RemoveFriend(command.Arguments);
|
||||
break;
|
||||
case ClientCommandId.Squelch:
|
||||
ExecuteSquelch(command.Arguments, add: true);
|
||||
break;
|
||||
case ClientCommandId.Unsquelch:
|
||||
ExecuteSquelch(command.Arguments, add: false);
|
||||
break;
|
||||
case ClientCommandId.Filter:
|
||||
ExecuteGlobalFilter(command.Arguments, add: true);
|
||||
break;
|
||||
case ClientCommandId.Unfilter:
|
||||
ExecuteGlobalFilter(command.Arguments, add: false);
|
||||
break;
|
||||
case ClientCommandId.ListMessageTypes:
|
||||
_bindings.ShowSystemMessage(
|
||||
"Squelch channels are as follows:\n "
|
||||
+ string.Join(", ", MessageTypes.Values));
|
||||
break;
|
||||
case ClientCommandId.FillComponents:
|
||||
ExecuteFillComponents(command.Arguments);
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(command), command.Command, "Unknown retail client command.");
|
||||
}
|
||||
}
|
||||
|
||||
private void ExecuteUiProfile(string arguments, bool save)
|
||||
{
|
||||
string[] parts = SplitArguments(arguments);
|
||||
string command = save ? "saveui" : "loadui";
|
||||
if (parts.Length > 1)
|
||||
{
|
||||
_bindings.ShowSystemMessage($"Please use @help {command} for proper usage.");
|
||||
return;
|
||||
}
|
||||
|
||||
string name = parts.Length == 0 ? string.Empty : parts[0];
|
||||
if (name.Length > 16)
|
||||
{
|
||||
_bindings.ShowSystemMessage("The file name must be 16 characters or less.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (save) _bindings.SaveUi(name);
|
||||
else _bindings.LoadUi(name);
|
||||
}
|
||||
|
||||
private bool RequireNoArguments(string arguments, string usage)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(arguments)) return true;
|
||||
_bindings.ShowSystemMessage($"Usage: {usage}");
|
||||
return false;
|
||||
}
|
||||
|
||||
private void ExecuteAway(string arguments)
|
||||
{
|
||||
string first = FirstArgument(arguments);
|
||||
if (first.Length == 0 || first.Equals("on", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!_bindings.IsAway()) _bindings.SetAway(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (first.Equals("off", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (_bindings.IsAway()) _bindings.SetAway(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (first.Equals("msg", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string message = RemainderAfterFirstArgument(arguments).Trim(' ');
|
||||
if (message.Length > 191) message = message[..191];
|
||||
if (message.Length > 0 && !message.Contains('\n')) message += "\n";
|
||||
_bindings.SetAwayMessage(message);
|
||||
_bindings.ShowSystemMessage(message.Length == 0
|
||||
? "New AFK message set: I am currently away from the keyboard."
|
||||
: $"New AFK message set: {message}");
|
||||
return;
|
||||
}
|
||||
|
||||
_bindings.ShowSystemMessage(AwayHelp);
|
||||
}
|
||||
|
||||
private void ExecuteConsent(string arguments)
|
||||
{
|
||||
string first = FirstArgument(arguments);
|
||||
if (first.Equals("on", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!_bindings.AcceptLootPermits()) _bindings.SetAcceptLootPermits(true);
|
||||
_bindings.ShowSystemMessage(
|
||||
"You can now accept corpse looting permissions from other players.");
|
||||
}
|
||||
else if (first.Equals("off", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (_bindings.AcceptLootPermits()) _bindings.SetAcceptLootPermits(false);
|
||||
_bindings.ShowSystemMessage(
|
||||
"You are no longer accepting corpse looting permissions from other players.");
|
||||
}
|
||||
else if (first.Equals("who", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_bindings.DisplayConsent();
|
||||
}
|
||||
else if (first.Equals("clear", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_bindings.ClearConsent();
|
||||
}
|
||||
else if (first.Equals("remove", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string name = RemainderAfterFirstArgument(arguments).Trim();
|
||||
if (name.Length == 0)
|
||||
_bindings.ShowSystemMessage(
|
||||
"Please specify a person to remove from your consent list.");
|
||||
else
|
||||
_bindings.RemoveConsent(name);
|
||||
}
|
||||
else
|
||||
{
|
||||
_bindings.ShowSystemMessage("Please specify a valid consent command.");
|
||||
}
|
||||
}
|
||||
|
||||
private void ExecuteFriends(string arguments)
|
||||
{
|
||||
string operation = FirstArgument(arguments);
|
||||
if (operation.Length == 0)
|
||||
{
|
||||
DisplayFriends(onlineOnly: false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation.Equals("online", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
DisplayFriends(onlineOnly: true);
|
||||
return;
|
||||
}
|
||||
|
||||
string remainder = RemainderAfterFirstArgument(arguments);
|
||||
if (operation.Equals("add", StringComparison.OrdinalIgnoreCase))
|
||||
AddFriend(remainder);
|
||||
else if (operation.Equals("remove", StringComparison.OrdinalIgnoreCase))
|
||||
RemoveFriend(remainder);
|
||||
else if (operation.Equals("old", StringComparison.OrdinalIgnoreCase)
|
||||
&& string.IsNullOrWhiteSpace(remainder))
|
||||
_bindings.RequestLegacyFriends();
|
||||
else
|
||||
_bindings.ShowSystemMessage("Invalid friends command specified.");
|
||||
}
|
||||
|
||||
private void AddFriend(string arguments)
|
||||
{
|
||||
string name = arguments.Trim();
|
||||
if (name.Length == 0)
|
||||
{
|
||||
_bindings.ShowSystemMessage(
|
||||
"You must specify the name of the friend you wish to add.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_bindings.Friends.Snapshot().Count >= 50)
|
||||
{
|
||||
_bindings.ShowWeenieError(0x0561u);
|
||||
return;
|
||||
}
|
||||
|
||||
_bindings.AddFriend(name);
|
||||
}
|
||||
|
||||
private void RemoveFriend(string arguments)
|
||||
{
|
||||
string name = arguments.Trim();
|
||||
if (name.Length == 0)
|
||||
{
|
||||
_bindings.ShowSystemMessage(
|
||||
"You must specify the name of the friend you wish to remove.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (name.Equals("-all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_bindings.ClearFriends();
|
||||
_bindings.Friends.Clear();
|
||||
_bindings.ShowSystemMessage("Your friends list has been cleared.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
FriendEntry? friend = _bindings.Friends.Snapshot().FirstOrDefault(
|
||||
entry => entry.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
if (friend is null)
|
||||
_bindings.ShowWeenieError(0x0563u);
|
||||
else
|
||||
_bindings.RemoveFriend(friend.Id);
|
||||
}
|
||||
|
||||
private void DisplayFriends(bool onlineOnly)
|
||||
{
|
||||
IReadOnlyList<FriendEntry> entries = _bindings.Friends.Snapshot();
|
||||
if (entries.Count == 0)
|
||||
{
|
||||
_bindings.ShowSystemMessage("Your friends list is empty!\n");
|
||||
return;
|
||||
}
|
||||
|
||||
var lines = entries
|
||||
.Where(entry => !onlineOnly || entry.Online)
|
||||
.Select(entry => $" {entry.Name}{(entry.Online ? " (Online)" : string.Empty)}")
|
||||
.ToArray();
|
||||
_bindings.ShowSystemMessage(lines.Length == 0
|
||||
? "Your friends:\n You have no friends that are online.\n"
|
||||
: "Your friends:\n" + string.Join("\n", lines) + "\n");
|
||||
}
|
||||
|
||||
private void ExecuteSquelch(string arguments, bool add)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(arguments))
|
||||
{
|
||||
DisplayCharacterSquelches();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryParseSquelch(arguments, out SquelchArguments parsed, out string error))
|
||||
{
|
||||
_bindings.ShowSystemMessage(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.AccountWide)
|
||||
_bindings.ModifyAccountSquelch(add, parsed.Name);
|
||||
else
|
||||
_bindings.ModifyCharacterSquelch(add, 0u, parsed.Name, parsed.MessageType);
|
||||
}
|
||||
|
||||
private void ExecuteGlobalFilter(string arguments, bool add)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(arguments))
|
||||
{
|
||||
DisplayGlobalFilters();
|
||||
return;
|
||||
}
|
||||
|
||||
string[] parts = SplitArguments(arguments);
|
||||
if (parts.Length != 1 || !parts[0].StartsWith('-'))
|
||||
{
|
||||
_bindings.ShowSystemMessage("Incorrect usage, use @help for proper usage.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryGetMessageType(parts[0][1..], out uint type) || type == 1u)
|
||||
{
|
||||
_bindings.ShowSystemMessage("You must specify a valid message type.");
|
||||
return;
|
||||
}
|
||||
|
||||
_bindings.ModifyGlobalSquelch(add, type);
|
||||
}
|
||||
|
||||
private bool TryParseSquelch(
|
||||
string arguments,
|
||||
out SquelchArguments parsed,
|
||||
out string error)
|
||||
{
|
||||
parsed = default;
|
||||
error = string.Empty;
|
||||
string[] parts = SplitArguments(arguments);
|
||||
bool account = false;
|
||||
uint messageType = 1u;
|
||||
string? replyName = null;
|
||||
int index = 0;
|
||||
for (; index < parts.Length && parts[index].StartsWith('-'); index++)
|
||||
{
|
||||
string option = parts[index][1..];
|
||||
if (option.Equals("account", StringComparison.OrdinalIgnoreCase))
|
||||
account = true;
|
||||
else if (option.Equals("reply", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
replyName = _bindings.LastTeller();
|
||||
if (string.IsNullOrWhiteSpace(replyName))
|
||||
{
|
||||
error = "A player must @tell you before you can use the -reply option.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else if (!TryGetMessageType(option, out messageType))
|
||||
{
|
||||
error = $"\"{option}\" is not a valid squelch category.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
string name = replyName ?? string.Join(' ', parts.Skip(index));
|
||||
if (name.Length == 0)
|
||||
{
|
||||
error = "You have not specified a squelch target.";
|
||||
return false;
|
||||
}
|
||||
|
||||
parsed = new SquelchArguments(account, messageType, name);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void DisplayCharacterSquelches()
|
||||
{
|
||||
SquelchDatabase database = _bindings.Squelch.Snapshot();
|
||||
var lines = database.Characters.Values
|
||||
.Where(info => info.MessageTypes.Count > 0)
|
||||
.Select(FormatSquelchInfo)
|
||||
.ToArray();
|
||||
_bindings.ShowSystemMessage(
|
||||
"(account) denotes a character whose account has also been squelched.\n"
|
||||
+ "Format: Name : List of squelched message types.\n--------\n"
|
||||
+ (lines.Length == 0 ? "none\n" : string.Join("\n", lines) + "\n"));
|
||||
}
|
||||
|
||||
private void DisplayGlobalFilters()
|
||||
{
|
||||
SquelchInfo global = _bindings.Squelch.Snapshot().Global;
|
||||
string list = global.MessageTypes.Count == 0
|
||||
? "none"
|
||||
: FormatMessageTypes(global.MessageTypes);
|
||||
_bindings.ShowSystemMessage(
|
||||
"The following types of messages are currently being filtered globally:\n"
|
||||
+ list + "\n(For a list of filter options, type @help filter)\n");
|
||||
}
|
||||
|
||||
private static string FormatSquelchInfo(SquelchInfo info) =>
|
||||
$"Name: {info.Name}{(info.AccountWide ? " (account) " : " ")}"
|
||||
+ FormatMessageTypes(info.MessageTypes);
|
||||
|
||||
private static string FormatMessageTypes(IReadOnlySet<uint> types)
|
||||
{
|
||||
if (types.Contains(1u)) return "All message types";
|
||||
return string.Join(", ", MessageTypes
|
||||
.Where(pair => pair.Key != 1u && types.Contains(pair.Key))
|
||||
.Select(pair => pair.Value));
|
||||
}
|
||||
|
||||
private void ExecuteFillComponents(string arguments)
|
||||
{
|
||||
string[] parts = SplitArguments(arguments);
|
||||
if (parts.Length > 2)
|
||||
{
|
||||
_bindings.ShowSystemMessage("Please use @help fillcomps for proper usage.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (parts.Length > 0 && parts[0].Equals("clear", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_bindings.ClearDesiredComponents();
|
||||
_bindings.ShowSystemMessage("Component list cleared.");
|
||||
return;
|
||||
}
|
||||
|
||||
uint? category = null;
|
||||
uint maximumPrice = 0u;
|
||||
foreach (string part in parts)
|
||||
{
|
||||
if (uint.TryParse(part, out uint price))
|
||||
{
|
||||
if (price == 0)
|
||||
{
|
||||
_bindings.ShowSystemMessage("Please specify a value greater than zero.");
|
||||
return;
|
||||
}
|
||||
maximumPrice = price;
|
||||
}
|
||||
else if (TryGetComponentCategory(part, out uint parsedCategory))
|
||||
{
|
||||
category = parsedCategory;
|
||||
}
|
||||
else
|
||||
{
|
||||
_bindings.ShowSystemMessage("Invalid component type specified.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!_bindings.HasOpenVendor())
|
||||
{
|
||||
_bindings.ShowSystemMessage("You need an open vendor.");
|
||||
return;
|
||||
}
|
||||
|
||||
_bindings.FillComponentBuyList(category, maximumPrice);
|
||||
}
|
||||
|
||||
private static bool TryGetComponentCategory(string value, out uint category)
|
||||
{
|
||||
category = value.ToLowerInvariant() switch
|
||||
{
|
||||
"scarab" or "scarabs" => 0u,
|
||||
"herb" or "herbs" => 1u,
|
||||
"powderedgem" or "powderedgems" or "powder" or "powders" => 2u,
|
||||
"alchemicalsubstance" or "alchemicalsubstances" or "potion" or "potions" => 3u,
|
||||
"talisman" or "talismans" => 4u,
|
||||
"taper" or "tapers" => 5u,
|
||||
"pea" or "peas" => 6u,
|
||||
_ => uint.MaxValue,
|
||||
};
|
||||
return category != uint.MaxValue;
|
||||
}
|
||||
|
||||
private static bool TryGetMessageType(string value, out uint type)
|
||||
{
|
||||
foreach ((uint key, string name) in MessageTypes)
|
||||
{
|
||||
if (name.Equals(value, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
type = key;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
type = 0u;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string FirstArgument(string arguments)
|
||||
{
|
||||
string trimmed = arguments.Trim();
|
||||
int separator = trimmed.IndexOfAny([' ', '\t', '\r', '\n']);
|
||||
return separator < 0 ? trimmed : trimmed[..separator];
|
||||
}
|
||||
|
||||
private static string RemainderAfterFirstArgument(string arguments)
|
||||
{
|
||||
string trimmed = arguments.Trim();
|
||||
int separator = trimmed.IndexOfAny([' ', '\t', '\r', '\n']);
|
||||
return separator < 0 ? string.Empty : trimmed[(separator + 1)..].TrimStart();
|
||||
}
|
||||
|
||||
private static string[] SplitArguments(string arguments) =>
|
||||
arguments.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
private readonly record struct SquelchArguments(
|
||||
bool AccountWide, uint MessageType, string Name);
|
||||
|
||||
private static readonly IReadOnlyDictionary<uint, string> MessageTypes =
|
||||
new Dictionary<uint, string>
|
||||
{
|
||||
[1] = "All",
|
||||
[2] = "Speech",
|
||||
[3] = "Tell",
|
||||
[6] = "Combat",
|
||||
[7] = "Magic",
|
||||
[12] = "Emote",
|
||||
[16] = "Appraisal",
|
||||
[17] = "Spellcasting",
|
||||
[18] = "Allegiance",
|
||||
[19] = "Fellowship",
|
||||
[21] = "Combat_Enemy",
|
||||
[22] = "Combat_Self",
|
||||
[23] = "Recall",
|
||||
[24] = "Craft",
|
||||
[25] = "Salvaging",
|
||||
};
|
||||
|
||||
private const string StandardEmotes =
|
||||
"Standard Emotes:\n"
|
||||
+ "Note: These commands should be bound on either side by asterisks. (Example: *wave*)\n"
|
||||
+ "ShakeFist; Beckon; BeSeeingYou; BlowKiss; BowDeep; ClapHands; Cry; Laugh; Nod; Point; Shrug; Wave; Akimbo; HeartyLaugh; Salute; TapFoot; WaveHigh; WaveLow; Yawn; Stretch; Cringe; Kneel; Plead; Shiver; Shoo; Slouch; Spit; Surrender; Woah; Winded; YMCA; Eat; Drink; Teapot; Pray; Mock; Cheer; Helper; Warm Hands; Scratch Head; Shake Head\n\n";
|
||||
|
||||
private const string AwayHelp =
|
||||
"@afk - Turns on AFK (away-from-keyboard) mode. When set to AFK, other players that send you directed chatyou will receive a customizable message that your are not currently at the keyboard.\n"
|
||||
+ "@afk on - Turns on AFK mode. When set to AFK, other players that send you directed chatyou will receive a customizable message that your are not currently at the keyboard.\n"
|
||||
+ "@afk off - Turn off AFK mode.\n"
|
||||
+ "@afk msg <message> - Set the message that will be sent to players that send you directed chat while you are in AFK mode. Issuing \"@afk msg\" with no message will set your AFK message back to the default. Your custom AFK message is limited to 192 characters.\n";
|
||||
|
||||
/// <summary>
|
||||
/// Null means the local PublicWeenieDesc has not arrived yet. Retail only
|
||||
/// rejects when it has a player object and the required bit is absent, so
|
||||
/// unknown follows the same send-and-let-the-server-decide path.
|
||||
/// </summary>
|
||||
private bool? HasPlayerFlag(EntityCollisionFlags flag)
|
||||
{
|
||||
uint? bitfield = _bindings.PlayerPublicWeenieBitfield();
|
||||
return bitfield is null
|
||||
? null
|
||||
: (EntityCollisionFlagsExt.FromPwdBitfield(bitfield.Value) & flag) != 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ public static class DatWidgetFactory
|
|||
0xD => new UiViewport(), // UIElement_Viewport — 3-D mini-scene blit leaf
|
||||
11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar (reg :124137)
|
||||
12 => BuildText(info, resolve, elementFont, stringResolve), // UIElement_Text
|
||||
0x13 => new UiDialogRoot(), // ConfirmationDialog
|
||||
0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots
|
||||
0x10000035u => BuildCheckbox(info, resolve, elementFont, stringResolve), // UIOption_Checkbox
|
||||
_ => new UiDatElement(info, resolve), // generic fallback (incl. Type 3 chrome/containers)
|
||||
|
|
|
|||
|
|
@ -221,6 +221,24 @@ public static class LayoutImporter
|
|||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>UIElementManager::CreateRootElementByDataID</c> counterpart: resolve one
|
||||
/// authored root from a catalog-style LayoutDesc instead of instantiating every
|
||||
/// top-level template. DialogFactory uses this path for the shared dialog catalog.
|
||||
/// </summary>
|
||||
public static ElementInfo? ImportInfos(
|
||||
DatCollection dats,
|
||||
uint layoutId,
|
||||
uint rootElementId)
|
||||
{
|
||||
var ld = dats.Get<LayoutDesc>(layoutId);
|
||||
if (ld is null) return null;
|
||||
ElementDesc? root = FindDesc(ld, rootElementId);
|
||||
return root is null
|
||||
? null
|
||||
: Resolve(dats, root, new HashSet<(uint, uint)>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dat shell: load the LayoutDesc, resolve inheritance for every top-level
|
||||
/// element, and build the widget tree. Returns null if the layout is absent
|
||||
|
|
@ -260,6 +278,21 @@ public static class LayoutImporter
|
|||
return Build(rootInfo, resolve, datFont, fontResolve, strings.Resolve);
|
||||
}
|
||||
|
||||
/// <summary>Import one selected root from a catalog-style LayoutDesc.</summary>
|
||||
public static ImportedLayout? Import(
|
||||
DatCollection dats,
|
||||
uint layoutId,
|
||||
uint rootElementId,
|
||||
Func<uint, (uint, int, int)> resolve,
|
||||
UiDatFont? datFont,
|
||||
Func<uint, UiDatFont?>? fontResolve = null)
|
||||
{
|
||||
var rootInfo = ImportInfos(dats, layoutId, rootElementId);
|
||||
if (rootInfo is null) return null;
|
||||
var strings = new DatStringResolver(dats);
|
||||
return Build(rootInfo, resolve, datFont, fontResolve, strings.Resolve);
|
||||
}
|
||||
|
||||
// ── Inheritance resolution ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>True when a pure-container leaf should inherit its base's subtree (the
|
||||
|
|
|
|||
122
src/AcDream.App/UI/Layout/RetailConfirmationDialogService.cs
Normal file
122
src/AcDream.App/UI/Layout/RetailConfirmationDialogService.cs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Retained implementation of retail <c>DialogFactory</c>'s confirmation queue.
|
||||
/// The visual tree is element <c>0x15</c> from the shared dialog catalog resolved by
|
||||
/// <c>GetDIDByEnum(2, 5)</c>; callbacks mirror property <c>0x92</c> from
|
||||
/// <c>ConfirmationDialog::ListenToElementMessage @ 0x00476670</c>.
|
||||
/// </summary>
|
||||
public sealed class RetailConfirmationDialogService
|
||||
{
|
||||
public const uint RootElementId = 0x15u;
|
||||
public const uint PopupElementId = 0x3Du;
|
||||
public const uint MessageElementId = 0x3Eu;
|
||||
public const uint AcceptButtonId = 0x17u;
|
||||
public const uint RejectButtonId = 0x19u;
|
||||
|
||||
private sealed record Request(string Message, Action<bool> Completed);
|
||||
|
||||
private readonly UiRoot _host;
|
||||
private readonly UiDialogRoot _dialog;
|
||||
private readonly UiElement _popup;
|
||||
private readonly UiText _message;
|
||||
private readonly UiButton _accept;
|
||||
private readonly UiButton _reject;
|
||||
private readonly Queue<Request> _queue = new();
|
||||
private readonly float _basePopupHeight;
|
||||
private readonly float _baseMessageHeight;
|
||||
private Request? _current;
|
||||
|
||||
public RetailConfirmationDialogService(UiRoot host, ImportedLayout layout)
|
||||
{
|
||||
_host = host ?? throw new ArgumentNullException(nameof(host));
|
||||
ArgumentNullException.ThrowIfNull(layout);
|
||||
_dialog = layout.Root as UiDialogRoot
|
||||
?? throw new ArgumentException("Confirmation layout root is not a UiDialogRoot.", nameof(layout));
|
||||
_popup = layout.FindElement(PopupElementId)
|
||||
?? throw new ArgumentException("Confirmation layout is missing popup element 0x3D.", nameof(layout));
|
||||
_message = layout.FindElement(MessageElementId) as UiText
|
||||
?? throw new ArgumentException("Confirmation layout is missing text element 0x3E.", nameof(layout));
|
||||
_accept = layout.FindElement(AcceptButtonId) as UiButton
|
||||
?? throw new ArgumentException("Confirmation layout is missing accept button 0x17.", nameof(layout));
|
||||
_reject = layout.FindElement(RejectButtonId) as UiButton
|
||||
?? throw new ArgumentException("Confirmation layout is missing reject button 0x19.", 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;
|
||||
_dialog.Cancel = () => Complete(false);
|
||||
_accept.OnClick = () => Complete(true);
|
||||
_reject.OnClick = () => Complete(false);
|
||||
}
|
||||
|
||||
public bool IsOpen => _current is not null;
|
||||
public int PendingCount => _queue.Count;
|
||||
|
||||
public void Show(string message, Action<bool> completed)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
ArgumentNullException.ThrowIfNull(completed);
|
||||
_queue.Enqueue(new Request(message, completed));
|
||||
if (_current is null)
|
||||
ShowNext();
|
||||
}
|
||||
|
||||
public void Tick()
|
||||
{
|
||||
if (_current is null) return;
|
||||
SizeAndCenter();
|
||||
}
|
||||
|
||||
private void ShowNext()
|
||||
{
|
||||
if (_queue.Count == 0) return;
|
||||
_current = _queue.Dequeue();
|
||||
|
||||
float maximumWidth = Math.Max(1f, _message.Width - 2f * _message.Padding);
|
||||
Func<string, float> measure = _message.DatFont is { } font
|
||||
? font.MeasureWidth
|
||||
: static text => text.Length * 8f;
|
||||
IReadOnlyList<string> wrapped = UiText.WrapWords(
|
||||
_current.Message,
|
||||
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);
|
||||
|
||||
SizeAndCenter();
|
||||
_host.AddChild(_dialog);
|
||||
_host.Modal = _dialog;
|
||||
}
|
||||
|
||||
private void SizeAndCenter()
|
||||
{
|
||||
_dialog.Left = 0f;
|
||||
_dialog.Top = 0f;
|
||||
_dialog.Width = _host.Width;
|
||||
_dialog.Height = _host.Height;
|
||||
_popup.Left = MathF.Round((_dialog.Width - _popup.Width) * 0.5f);
|
||||
_popup.Top = MathF.Round((_dialog.Height - _popup.Height) * 0.5f);
|
||||
}
|
||||
|
||||
private void Complete(bool accepted)
|
||||
{
|
||||
Request? completed = _current;
|
||||
if (completed is null) return;
|
||||
_current = null;
|
||||
_host.RemoveChild(_dialog);
|
||||
completed.Completed(accepted);
|
||||
ShowNext();
|
||||
}
|
||||
}
|
||||
28
src/AcDream.App/UI/RetailDataIdResolver.cs
Normal file
28
src/AcDream.App/UI/RetailDataIdResolver.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Ports <c>DBCache::GetDIDFromEnumStatic @ 0x00413940</c>: the portal master map
|
||||
/// selects an enum category, then that category map resolves the client enum value.
|
||||
/// </summary>
|
||||
public static class RetailDataIdResolver
|
||||
{
|
||||
public static uint Resolve(DatCollection dats, uint enumValue, uint enumCategory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dats);
|
||||
|
||||
uint masterDid = (uint)dats.Portal.Header.MasterMapId;
|
||||
if (masterDid == 0
|
||||
|| !dats.Portal.TryGet<EnumIDMap>(masterDid, out var master)
|
||||
|| master is null
|
||||
|| !master.ClientEnumToID.TryGetValue(enumCategory, out uint subMapDid)
|
||||
|| !dats.Portal.TryGet<EnumIDMap>(subMapDid, out var subMap)
|
||||
|| subMap is null
|
||||
|| !subMap.ClientEnumToID.TryGetValue(enumValue, out uint did))
|
||||
return 0u;
|
||||
|
||||
return did;
|
||||
}
|
||||
}
|
||||
|
|
@ -141,6 +141,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
MountToolbar();
|
||||
MountCombat();
|
||||
MountJumpPowerbar();
|
||||
MountConfirmationDialogs();
|
||||
MountCharacter();
|
||||
MountPlugins();
|
||||
MountInventory();
|
||||
|
|
@ -182,6 +183,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
public SelectedObjectController? SelectedObjectController { get; private set; }
|
||||
public UiViewport? PaperdollViewportWidget { get; private set; }
|
||||
public UiNineSlicePanel? InventoryFrame { get; private set; }
|
||||
public RetailConfirmationDialogService? ConfirmationDialogs { get; private set; }
|
||||
|
||||
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
|
||||
{
|
||||
|
|
@ -201,6 +203,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
{
|
||||
JumpPowerbarController?.Tick();
|
||||
SelectedObjectController?.Tick(deltaSeconds);
|
||||
ConfirmationDialogs?.Tick();
|
||||
Host.Tick(deltaSeconds);
|
||||
_automation?.Tick(deltaSeconds);
|
||||
}
|
||||
|
|
@ -218,6 +221,12 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
|
||||
public void RestoreLayout() => _persistence?.RestoreAll();
|
||||
|
||||
public void SaveLayout() => _persistence?.SaveAll();
|
||||
|
||||
public void SaveNamedLayout(string profileName) => _persistence?.SaveNamed(profileName);
|
||||
|
||||
public void RestoreNamedLayout(string profileName) => _persistence?.RestoreNamed(profileName);
|
||||
|
||||
public bool ToggleWindow(string name)
|
||||
=> Host.ToggleWindow(name);
|
||||
|
||||
|
|
@ -586,6 +595,36 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
Console.WriteLine("[D.6] retail jump bar from gmFloatyPowerBarUI LayoutDesc 0x21000072.");
|
||||
}
|
||||
|
||||
private void MountConfirmationDialogs()
|
||||
{
|
||||
ImportedLayout? layout;
|
||||
uint layoutId;
|
||||
lock (_bindings.Assets.DatLock)
|
||||
{
|
||||
// DialogFactory::CreateDialog_ @ 0x00477AD0 calls
|
||||
// GetDIDByEnum(2, 5), then creates root element 0x15.
|
||||
layoutId = RetailDataIdResolver.Resolve(_bindings.Assets.Dats, 2u, 5u);
|
||||
layout = layoutId == 0u
|
||||
? null
|
||||
: LayoutImporter.Import(
|
||||
_bindings.Assets.Dats,
|
||||
layoutId,
|
||||
RetailConfirmationDialogService.RootElementId,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
_bindings.Assets.DefaultFont,
|
||||
_bindings.Assets.ResolveFont);
|
||||
}
|
||||
|
||||
if (layout is null)
|
||||
{
|
||||
Console.WriteLine("[UI] confirmation dialog catalog could not be resolved.");
|
||||
return;
|
||||
}
|
||||
|
||||
ConfirmationDialogs = new RetailConfirmationDialogService(Host.Root, layout);
|
||||
Console.WriteLine($"[UI] retail confirmation dialogs from LayoutDesc 0x{layoutId:X8} element 0x15.");
|
||||
}
|
||||
|
||||
private (uint[] Regular, uint[] Ghosted, uint[]? Empty) LoadToolbarDigits()
|
||||
{
|
||||
uint[]? regular = null, ghosted = null, empty = null;
|
||||
|
|
|
|||
|
|
@ -74,6 +74,55 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>Force-save every attached window to the current automatic profile.</summary>
|
||||
public void SaveAll()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
string character = _characterKey();
|
||||
if (!CanPersist(character)) return;
|
||||
var screen = ValidScreenSize();
|
||||
string resolution = ResolutionKey(screen);
|
||||
foreach (RetailWindowHandle handle in _attached)
|
||||
_store.SaveWindowLayout(character, resolution, handle.Name, Capture(handle));
|
||||
}
|
||||
|
||||
/// <summary>Save every attached window to a portable named retail UI profile.</summary>
|
||||
public void SaveNamed(string profileName)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(profileName);
|
||||
foreach (RetailWindowHandle handle in _attached)
|
||||
_store.SaveNamedWindowLayout(profileName, handle.Name, Capture(handle));
|
||||
}
|
||||
|
||||
/// <summary>Restore every window found in a portable named retail UI profile.</summary>
|
||||
public void RestoreNamed(string profileName)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(profileName);
|
||||
var screen = ValidScreenSize();
|
||||
|
||||
_restoring = true;
|
||||
try
|
||||
{
|
||||
foreach (RetailWindowHandle handle in _attached)
|
||||
{
|
||||
UiWindowLayout? saved = _store.LoadNamedWindowLayout(
|
||||
profileName, handle.Name, Capture(handle));
|
||||
if (saved is not { } layout) continue;
|
||||
Apply(
|
||||
handle,
|
||||
layout,
|
||||
screen,
|
||||
restoreVisibility: !_stateManagedVisibilityWindows.Contains(handle.Name));
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_restoring = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void Attach(RetailWindowHandle handle)
|
||||
{
|
||||
_attached.Add(handle);
|
||||
|
|
|
|||
32
src/AcDream.App/UI/UiDialogRoot.cs
Normal file
32
src/AcDream.App/UI/UiDialogRoot.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Full-device modal root for retail dialog element classes. The authored child with id
|
||||
/// <c>0x3D</c> supplies the visible popup art; this root only owns modality and input capture.
|
||||
/// </summary>
|
||||
public sealed class UiDialogRoot : UiPanel
|
||||
{
|
||||
public Action? Cancel { get; set; }
|
||||
|
||||
public UiDialogRoot()
|
||||
{
|
||||
BackgroundColor = Vector4.Zero;
|
||||
BorderColor = Vector4.Zero;
|
||||
ClickThrough = false;
|
||||
}
|
||||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
if (e.Type == UiEventType.KeyDown
|
||||
&& e.Data0 == (int)Silk.NET.Input.Key.Escape)
|
||||
{
|
||||
Cancel?.Invoke();
|
||||
}
|
||||
|
||||
// A dialog root is full-screen and modal. Unhandled clicks/keys must not
|
||||
// fall through into movement, combat, or the default chat input.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -311,6 +311,7 @@ public sealed class UiText : UiElement
|
|||
if (y < top || y + lh > bottom) continue; // whole-line vertical clip (no scissor yet)
|
||||
|
||||
string text = lines[i].Text;
|
||||
float lineX = HorizontalOffset(text, datFont, bitmapFont);
|
||||
|
||||
// Selection highlight behind this line's selected character span.
|
||||
if (hasSel && i >= selStart.Line && i <= selEnd.Line)
|
||||
|
|
@ -324,12 +325,12 @@ public sealed class UiText : UiElement
|
|||
float hx, hw;
|
||||
if (datFont is not null)
|
||||
{
|
||||
hx = Padding + datFont.MeasureWidth(text.Substring(0, c0));
|
||||
hx = lineX + datFont.MeasureWidth(text.Substring(0, c0));
|
||||
hw = datFont.MeasureWidth(text.Substring(c0, c1 - c0));
|
||||
}
|
||||
else
|
||||
{
|
||||
hx = Padding + bitmapFont!.MeasureWidth(text.Substring(0, c0));
|
||||
hx = lineX + bitmapFont!.MeasureWidth(text.Substring(0, c0));
|
||||
hw = bitmapFont.MeasureWidth(text.Substring(c0, c1 - c0));
|
||||
}
|
||||
// Highlight sits BEHIND the line's text → sprite bucket, submitted
|
||||
|
|
@ -339,12 +340,24 @@ public sealed class UiText : UiElement
|
|||
}
|
||||
|
||||
if (datFont is not null)
|
||||
ctx.DrawStringDat(datFont, text, Padding, y, lines[i].Color);
|
||||
ctx.DrawStringDat(datFont, text, lineX, y, lines[i].Color);
|
||||
else
|
||||
ctx.DrawString(text, Padding, y, lines[i].Color, bitmapFont);
|
||||
ctx.DrawString(text, lineX, y, lines[i].Color, bitmapFont);
|
||||
}
|
||||
}
|
||||
|
||||
private float HorizontalOffset(string text, UiDatFont? datFont, BitmapFont? bitmapFont)
|
||||
{
|
||||
float width = datFont is not null
|
||||
? datFont.MeasureWidth(text)
|
||||
: bitmapFont?.MeasureWidth(text) ?? 0f;
|
||||
if (Centered)
|
||||
return Math.Max(Padding, (Width - width) * 0.5f);
|
||||
if (RightAligned)
|
||||
return Math.Max(Padding, Width - Padding - width);
|
||||
return Padding;
|
||||
}
|
||||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
switch (e.Type)
|
||||
|
|
@ -545,16 +558,80 @@ public sealed class UiText : UiElement
|
|||
line = Math.Clamp(line, 0, lines.Count - 1);
|
||||
|
||||
string text = lines[line].Text;
|
||||
float lineX = HorizontalOffset(text, _lastDatFont, _lastFont);
|
||||
int col = _lastDatFont is { } df
|
||||
? CharIndexAt(text, ch => df.TryGetGlyph(ch, out var g) ? UiDatFont.GlyphAdvance(g) : 0f,
|
||||
localX - _lastPadding)
|
||||
localX - lineX)
|
||||
: (_lastFont is { } bf
|
||||
? CharIndexAt(text, ch => bf.TryGetGlyph(ch, out var bg) ? bg.Advance : 0f,
|
||||
localX - _lastPadding)
|
||||
localX - lineX)
|
||||
: 0);
|
||||
return new Pos(line, col);
|
||||
}
|
||||
|
||||
/// <summary>Word-wrap text to a measured pixel width, preserving explicit newlines.</summary>
|
||||
public static IReadOnlyList<string> WrapWords(
|
||||
string text,
|
||||
Func<string, float> measureWidth,
|
||||
float maximumWidth)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(text);
|
||||
ArgumentNullException.ThrowIfNull(measureWidth);
|
||||
if (maximumWidth <= 0f) throw new ArgumentOutOfRangeException(nameof(maximumWidth));
|
||||
|
||||
var result = new List<string>();
|
||||
string[] paragraphs = text.Replace("\r", string.Empty).Split('\n');
|
||||
foreach (string paragraph in paragraphs)
|
||||
{
|
||||
if (paragraph.Length == 0)
|
||||
{
|
||||
result.Add(string.Empty);
|
||||
continue;
|
||||
}
|
||||
|
||||
string[] words = paragraph.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var line = new StringBuilder();
|
||||
foreach (string word in words)
|
||||
{
|
||||
string candidate = line.Length == 0 ? word : $"{line} {word}";
|
||||
if (measureWidth(candidate) <= maximumWidth)
|
||||
{
|
||||
if (line.Length != 0) line.Append(' ');
|
||||
line.Append(word);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.Length != 0 && measureWidth(word) <= maximumWidth)
|
||||
{
|
||||
result.Add(line.ToString());
|
||||
line.Clear();
|
||||
line.Append(word);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Retail GlyphList wrapping can split an over-width glyph run.
|
||||
// Pack as much of the long token as possible onto the current
|
||||
// line, then continue at character boundaries without hyphens.
|
||||
for (int i = 0; i < word.Length; i++)
|
||||
{
|
||||
string prefix = i == 0 && line.Length != 0 ? " " : string.Empty;
|
||||
if (line.Length != 0
|
||||
&& measureWidth(line + prefix + word[i]) > maximumWidth)
|
||||
{
|
||||
result.Add(line.ToString());
|
||||
line.Clear();
|
||||
prefix = string.Empty;
|
||||
}
|
||||
line.Append(prefix).Append(word[i]);
|
||||
}
|
||||
}
|
||||
|
||||
result.Add(line.ToString());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The caret column for a horizontal position <paramref name="x"/> (already
|
||||
/// adjusted for the left padding, so x=0 is the start of the text). Walks the
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue