feat(ui): port retail appraisal panel
Preserve retail's one-pending-appraisal busy lifetime, parse the complete gated response, and mount the authored examination layout in the shared main-panel host. Keep known 3D preview and inscription-write gaps explicit in AP-110.
This commit is contained in:
parent
6b1ae4fb76
commit
643cdfe66e
24 changed files with 17132 additions and 40 deletions
|
|
@ -35,7 +35,8 @@ internal sealed record LiveInventorySessionBindings(
|
|||
Action<uint>? OnUseDone,
|
||||
ItemManaState? ItemMana,
|
||||
Action<IReadOnlyList<(uint Id, uint Amount)>>? OnDesiredComponents,
|
||||
ExternalContainerState? ExternalContainers);
|
||||
ExternalContainerState? ExternalContainers,
|
||||
Action<AppraiseInfoParser.Parsed>? OnAppraisal = null);
|
||||
|
||||
internal sealed record LiveCharacterSessionBindings(
|
||||
CombatState Combat,
|
||||
|
|
@ -157,6 +158,7 @@ internal sealed class LiveSessionEventRouter : ILiveSessionEventRouting
|
|||
onShortcuts: inventory.OnShortcuts,
|
||||
playerGuid: inventory.PlayerGuid,
|
||||
onUseDone: inventory.OnUseDone,
|
||||
onAppraisal: inventory.OnAppraisal,
|
||||
itemMana: inventory.ItemMana,
|
||||
onConfirmationRequest: character.OnConfirmationRequest,
|
||||
onConfirmationDone: character.OnConfirmationDone,
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
MouseCapture = _interaction.GameplayInput.ResetSession,
|
||||
PlayerPresentation = ResetPlayerPresentation,
|
||||
TeleportTransit = _world.Teleport.ResetSession,
|
||||
SessionDialogs = () => _ui.RetailUi?.ResetSessionDialogs(),
|
||||
SessionDialogs = () => _ui.RetailUi?.ResetSessionTransientUi(),
|
||||
ChatCommandTargets = () => _ui.RetailChat?.ResetSessionTargets(),
|
||||
SettingsCharacterContext =
|
||||
_interaction.Settings.RestoreDefaultCharacterContext,
|
||||
|
|
@ -260,7 +260,14 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
_domain.ItemMana,
|
||||
OnDesiredComponents: components =>
|
||||
_player.DesiredComponents.Items = components,
|
||||
ExternalContainers: _domain.ExternalContainers);
|
||||
ExternalContainers: _domain.ExternalContainers,
|
||||
OnAppraisal: appraisal =>
|
||||
{
|
||||
if (_ui.RetailUi is { } retailUi)
|
||||
retailUi.HandleAppraisal(appraisal);
|
||||
else
|
||||
_interaction.ItemInteraction.AcceptAppraisalResponse(appraisal.Guid);
|
||||
});
|
||||
|
||||
private LiveCharacterSessionBindings CreateCharacterBindings(
|
||||
SkillTable? skillTable)
|
||||
|
|
|
|||
|
|
@ -110,6 +110,8 @@ public sealed class ItemInteractionController : IDisposable
|
|||
private uint _consumedPrimaryClickTarget;
|
||||
private long _consumedPrimaryClickMs = long.MinValue / 2;
|
||||
private int _busyCount;
|
||||
private uint _awaitingAppraisalId;
|
||||
private uint _currentAppraisalId;
|
||||
private ulong _nextInventoryRequestToken;
|
||||
private PendingBackpackPlacement? _pendingBackpackPlacement;
|
||||
private PendingInventoryRequest? _pendingInventoryRequest;
|
||||
|
|
@ -222,6 +224,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
public InteractionState InteractionState => _interactionState;
|
||||
|
||||
public int BusyCount => _busyCount;
|
||||
public uint CurrentAppraisalId => _currentAppraisalId;
|
||||
|
||||
public bool CanMakeInventoryRequest =>
|
||||
BaseCanMakeInventoryRequest && _pendingInventoryRequest is null;
|
||||
|
|
@ -451,7 +454,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
ClearTargetMode();
|
||||
accepted = targetGuid != 0 && _sendExamine is not null;
|
||||
if (accepted)
|
||||
_sendExamine!(targetGuid);
|
||||
RequestAppraisal(targetGuid);
|
||||
break;
|
||||
case InteractionModeKind.UseItemOnTarget:
|
||||
accepted = AcquireTarget(targetGuid);
|
||||
|
|
@ -492,7 +495,61 @@ public sealed class ItemInteractionController : IDisposable
|
|||
return _interactionState.EnterExamine();
|
||||
if (_sendExamine is null)
|
||||
return false;
|
||||
_sendExamine(selectedObjectId);
|
||||
RequestAppraisal(selectedObjectId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>gmExaminationUI::RecvNotice_ExamineObject @ 0x004AB7B0</c>.
|
||||
/// One shared UI-busy reference covers the currently pending appraisal,
|
||||
/// even when another examine request replaces its GUID before a response.
|
||||
/// </summary>
|
||||
private void RequestAppraisal(uint objectId)
|
||||
{
|
||||
if (objectId == 0 || _sendExamine is null)
|
||||
return;
|
||||
|
||||
if (_awaitingAppraisalId == 0)
|
||||
IncrementBusyCount();
|
||||
_awaitingAppraisalId = objectId;
|
||||
_sendExamine(objectId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Accepts only the pending or current appraisal, matching
|
||||
/// <c>gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0</c>.
|
||||
/// </summary>
|
||||
public AppraisalResponseAcceptance AcceptAppraisalResponse(uint objectId)
|
||||
{
|
||||
if (objectId == 0
|
||||
|| (objectId != _awaitingAppraisalId
|
||||
&& objectId != _currentAppraisalId))
|
||||
return default;
|
||||
|
||||
bool firstResponse = objectId == _awaitingAppraisalId;
|
||||
if (firstResponse)
|
||||
{
|
||||
_awaitingAppraisalId = 0;
|
||||
if (_busyCount > 0)
|
||||
_busyCount--;
|
||||
_currentAppraisalId = objectId;
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
return new AppraisalResponseAcceptance(
|
||||
Accepted: true,
|
||||
FirstResponse: firstResponse);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail combat-time creature/player refresh sends CM_Item directly and
|
||||
/// therefore does not acquire a second UI-busy reference.
|
||||
/// </summary>
|
||||
public bool RefreshCurrentAppraisal()
|
||||
{
|
||||
if (_currentAppraisalId == 0 || _sendExamine is null)
|
||||
return false;
|
||||
_sendExamine(_currentAppraisalId);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -1240,6 +1297,8 @@ public sealed class ItemInteractionController : IDisposable
|
|||
_pendingBackpackPlacement = null;
|
||||
_useReservationGeneration++;
|
||||
_busyCount = 0;
|
||||
_awaitingAppraisalId = 0;
|
||||
_currentAppraisalId = 0;
|
||||
_pendingInventoryRequest = null;
|
||||
_lastUseMs = long.MinValue / 2;
|
||||
_consumedPrimaryClickTarget = 0u;
|
||||
|
|
@ -1258,6 +1317,10 @@ public sealed class ItemInteractionController : IDisposable
|
|||
failures);
|
||||
}
|
||||
|
||||
public readonly record struct AppraisalResponseAcceptance(
|
||||
bool Accepted,
|
||||
bool FirstResponse);
|
||||
|
||||
private static void DispatchAll(Action? listeners, List<Exception> failures)
|
||||
{
|
||||
if (listeners is null)
|
||||
|
|
|
|||
712
src/AcDream.App/UI/Layout/AppraisalUiController.cs
Normal file
712
src/AcDream.App/UI/Layout/AppraisalUiController.cs
Normal file
|
|
@ -0,0 +1,712 @@
|
|||
using System.Globalization;
|
||||
using System.Numerics;
|
||||
using System.Text;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Spells;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Retained port of retail <c>gmExaminationUI</c>. The controller owns the
|
||||
/// appraisal response/subview lifecycle; the imported LayoutDesc owns all
|
||||
/// chrome, geometry, fonts, and scrollbars.
|
||||
/// </summary>
|
||||
public sealed class AppraisalUiController : IRetainedPanelController
|
||||
{
|
||||
public const uint LayoutId = 0x2100006Bu;
|
||||
public const uint RootId = 0x100005F2u;
|
||||
public const uint CloseId = 0x100005F3u;
|
||||
public const uint TitleId = 0x1000012Du;
|
||||
public const uint ItemPanelId = 0x1000012Eu;
|
||||
public const uint ItemTextId = 0x1000013Cu;
|
||||
public const uint ItemScrollbarId = 0x1000013Du;
|
||||
public const uint InscriptionTextId = 0x1000013Eu;
|
||||
public const uint SignatureTextId = 0x1000013Fu;
|
||||
public const uint InscriptionScrollbarId = 0x1000046Eu;
|
||||
public const uint CreaturePanelId = 0x10000140u;
|
||||
public const uint SpellPanelId = 0x10000153u;
|
||||
|
||||
private const uint TemplateStringProperty = 5u;
|
||||
private const uint CharacterMarkerIntProperty = 0x105u;
|
||||
private const uint DisplayedNameStringProperty = 0x34u;
|
||||
private const double CreatureRefreshSeconds = 0.75;
|
||||
|
||||
private readonly ImportedLayout _layout;
|
||||
private readonly ClientObjectTable _objects;
|
||||
private readonly ItemInteractionController _interaction;
|
||||
private readonly CombatState _combat;
|
||||
private readonly Spellbook _spellbook;
|
||||
private readonly Action _show;
|
||||
private readonly UiElement _itemPanel;
|
||||
private readonly UiElement _creaturePanel;
|
||||
private readonly UiElement? _spellPanel;
|
||||
private readonly UiText _title;
|
||||
private readonly UiText _itemText;
|
||||
private readonly UiText? _inscriptionText;
|
||||
private readonly UiField? _inscriptionField;
|
||||
private readonly UiText? _signature;
|
||||
private readonly UiButton? _close;
|
||||
private AppraisalView _activeView;
|
||||
private uint _itemObjectId;
|
||||
private uint _creatureObjectId;
|
||||
private uint _characterObjectId;
|
||||
private string _titleValue = string.Empty;
|
||||
private string _itemReport = string.Empty;
|
||||
private string _inscriptionValue = string.Empty;
|
||||
private string _signatureValue = string.Empty;
|
||||
private double _refreshElapsed;
|
||||
private bool _disposed;
|
||||
|
||||
private AppraisalUiController(
|
||||
ImportedLayout layout,
|
||||
ClientObjectTable objects,
|
||||
ItemInteractionController interaction,
|
||||
CombatState combat,
|
||||
Spellbook spellbook,
|
||||
Action show,
|
||||
Action close,
|
||||
UiElement itemPanel,
|
||||
UiElement creaturePanel,
|
||||
UiText title,
|
||||
UiText itemText)
|
||||
{
|
||||
_layout = layout;
|
||||
_objects = objects;
|
||||
_interaction = interaction;
|
||||
_combat = combat;
|
||||
_spellbook = spellbook;
|
||||
_show = show;
|
||||
_itemPanel = itemPanel;
|
||||
_creaturePanel = creaturePanel;
|
||||
_spellPanel = layout.FindElement(SpellPanelId);
|
||||
_title = title;
|
||||
_itemText = itemText;
|
||||
_inscriptionText = layout.FindElement(InscriptionTextId) as UiText;
|
||||
_inscriptionField = layout.FindElement(InscriptionTextId) as UiField;
|
||||
_signature = layout.FindElement(SignatureTextId) as UiText;
|
||||
_close = layout.FindElement(CloseId) as UiButton;
|
||||
|
||||
_title.LinesProvider = () =>
|
||||
[new UiText.Line(_titleValue, _title.DefaultColor)];
|
||||
ConfigureScrollableText(_itemText, ItemScrollbarId, () => _itemReport);
|
||||
if (_inscriptionText is not null)
|
||||
ConfigureScrollableText(
|
||||
_inscriptionText,
|
||||
InscriptionScrollbarId,
|
||||
() => _inscriptionValue);
|
||||
if (_inscriptionField is not null)
|
||||
{
|
||||
_inscriptionField.SetText(string.Empty);
|
||||
// Retail conditionally enables this field only after its ownership
|
||||
// and inscription-permission checks. That write transaction remains
|
||||
// tracked by AP-110; keep the imported field display-only until the
|
||||
// matching SetInscriptionEditableState/submit path is present.
|
||||
_inscriptionField.Enabled = false;
|
||||
}
|
||||
if (_signature is not null)
|
||||
_signature.LinesProvider = () =>
|
||||
[new UiText.Line(_signatureValue, _signature.DefaultColor)];
|
||||
if (_close is not null)
|
||||
_close.OnClick = close;
|
||||
|
||||
SetActiveView(AppraisalView.Item);
|
||||
}
|
||||
|
||||
public AppraisalView ActiveView => _activeView;
|
||||
public uint CurrentObjectId => _interaction.CurrentAppraisalId;
|
||||
|
||||
public static AppraisalUiController? Bind(
|
||||
ImportedLayout layout,
|
||||
ClientObjectTable objects,
|
||||
ItemInteractionController interaction,
|
||||
CombatState combat,
|
||||
Spellbook spellbook,
|
||||
Action show,
|
||||
Action close)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(layout);
|
||||
ArgumentNullException.ThrowIfNull(objects);
|
||||
ArgumentNullException.ThrowIfNull(interaction);
|
||||
ArgumentNullException.ThrowIfNull(combat);
|
||||
ArgumentNullException.ThrowIfNull(spellbook);
|
||||
ArgumentNullException.ThrowIfNull(show);
|
||||
ArgumentNullException.ThrowIfNull(close);
|
||||
|
||||
if (layout.FindElement(ItemPanelId) is not { } itemPanel
|
||||
|| layout.FindElement(CreaturePanelId) is not { } creaturePanel
|
||||
|| layout.FindElement(TitleId) is not UiText title
|
||||
|| layout.FindElement(ItemTextId) is not UiText itemText)
|
||||
return null;
|
||||
|
||||
return new AppraisalUiController(
|
||||
layout,
|
||||
objects,
|
||||
interaction,
|
||||
combat,
|
||||
spellbook,
|
||||
show,
|
||||
close,
|
||||
itemPanel,
|
||||
creaturePanel,
|
||||
title,
|
||||
itemText);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the response ordering and first-response visibility rule from
|
||||
/// <c>gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0</c>.
|
||||
/// </summary>
|
||||
public bool Apply(AppraiseInfoParser.Parsed appraisal)
|
||||
{
|
||||
ItemInteractionController.AppraisalResponseAcceptance acceptance =
|
||||
_interaction.AcceptAppraisalResponse(appraisal.Guid);
|
||||
if (!acceptance.Accepted)
|
||||
return false;
|
||||
|
||||
ClientObject? obj = _objects.Get(appraisal.Guid);
|
||||
if (obj is null)
|
||||
return false;
|
||||
|
||||
_titleValue = BuildTitle(obj, appraisal.Properties);
|
||||
AppraisalView view = SelectView(appraisal);
|
||||
bool newlySelected = view switch
|
||||
{
|
||||
AppraisalView.Item => _itemObjectId != appraisal.Guid,
|
||||
AppraisalView.Creature => _creatureObjectId != appraisal.Guid,
|
||||
AppraisalView.Character => _characterObjectId != appraisal.Guid,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
switch (view)
|
||||
{
|
||||
case AppraisalView.Item:
|
||||
_itemObjectId = appraisal.Guid;
|
||||
ApplyItem(obj, appraisal, newlySelected);
|
||||
break;
|
||||
case AppraisalView.Creature:
|
||||
_creatureObjectId = appraisal.Guid;
|
||||
ApplyCreature(obj, appraisal, character: false, newlySelected);
|
||||
break;
|
||||
case AppraisalView.Character:
|
||||
_characterObjectId = appraisal.Guid;
|
||||
ApplyCreature(obj, appraisal, character: true, newlySelected);
|
||||
break;
|
||||
}
|
||||
|
||||
SetActiveView(view);
|
||||
_refreshElapsed = 0;
|
||||
if (acceptance.FirstResponse)
|
||||
_show();
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Tick(double deltaSeconds)
|
||||
{
|
||||
if (!_layout.Root.Visible
|
||||
|| _activeView is not (AppraisalView.Creature or AppraisalView.Character)
|
||||
|| _combat.CurrentMode == CombatMode.NonCombat
|
||||
|| CurrentObjectId == 0)
|
||||
{
|
||||
_refreshElapsed = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!double.IsFinite(deltaSeconds) || deltaSeconds <= 0)
|
||||
return;
|
||||
_refreshElapsed += deltaSeconds;
|
||||
if (_refreshElapsed < CreatureRefreshSeconds)
|
||||
return;
|
||||
_refreshElapsed %= CreatureRefreshSeconds;
|
||||
_interaction.RefreshCurrentAppraisal();
|
||||
}
|
||||
|
||||
public void ResetSession()
|
||||
{
|
||||
_titleValue = string.Empty;
|
||||
_itemReport = string.Empty;
|
||||
_inscriptionValue = string.Empty;
|
||||
_inscriptionField?.SetText(string.Empty);
|
||||
_signatureValue = string.Empty;
|
||||
_itemObjectId = 0;
|
||||
_creatureObjectId = 0;
|
||||
_characterObjectId = 0;
|
||||
_refreshElapsed = 0;
|
||||
SetActiveView(AppraisalView.Item);
|
||||
ClearCreatureText();
|
||||
}
|
||||
|
||||
private void ApplyItem(
|
||||
ClientObject obj,
|
||||
AppraiseInfoParser.Parsed appraisal,
|
||||
bool newlySelected)
|
||||
{
|
||||
_itemReport = AppraisalTextFormatter.BuildItem(
|
||||
obj,
|
||||
appraisal,
|
||||
ResolveSpellName);
|
||||
_inscriptionValue = GetString(appraisal.Properties, 7u);
|
||||
_inscriptionField?.SetText(_inscriptionValue);
|
||||
string scribe = GetString(appraisal.Properties, 8u);
|
||||
_signatureValue = string.IsNullOrWhiteSpace(scribe)
|
||||
? string.Empty
|
||||
: $"Inscribed by {scribe}";
|
||||
if (newlySelected)
|
||||
{
|
||||
_itemText.Scroll.SetScrollY(0);
|
||||
_inscriptionText?.Scroll.SetScrollY(0);
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyCreature(
|
||||
ClientObject obj,
|
||||
AppraiseInfoParser.Parsed appraisal,
|
||||
bool character,
|
||||
bool newlySelected)
|
||||
{
|
||||
ClearCreatureText();
|
||||
PropertyBundle p = appraisal.Properties;
|
||||
SetText(0x1000014Au, character
|
||||
? GetString(p, 4u)
|
||||
: AppraisalTextFormatter.CreatureTypeName(GetInt(p, 2u)));
|
||||
SetText(0x1000014Bu, GetString(p, 2u));
|
||||
SetText(0x1000014Cu, GetString(p, 14u));
|
||||
|
||||
string report = AppraisalTextFormatter.BuildCreature(
|
||||
obj,
|
||||
appraisal,
|
||||
character);
|
||||
SetText(0x1000014Eu, report, scrollable: true);
|
||||
|
||||
if (character)
|
||||
{
|
||||
SetText(0x10000150u, GetString(p, 4u));
|
||||
SetText(0x10000151u, GetString(p, 47u));
|
||||
SetText(0x10000152u, GetString(p, 11u));
|
||||
}
|
||||
|
||||
SetText(0x1000053Au, appraisal.Success
|
||||
? string.Empty
|
||||
: "Assessment incomplete");
|
||||
if (newlySelected)
|
||||
ResetCreatureScroll();
|
||||
}
|
||||
|
||||
private void ConfigureScrollableText(
|
||||
UiText text,
|
||||
uint scrollbarId,
|
||||
Func<string> value)
|
||||
{
|
||||
text.PreserveEndOnLayout = false;
|
||||
text.WheelScrollEnabled = true;
|
||||
text.ClickThrough = false;
|
||||
text.LinesProvider = () => IndicatorDetailText.Shape(text, value());
|
||||
if (_layout.FindElement(scrollbarId) is UiScrollbar scrollbar)
|
||||
scrollbar.Model = text.Scroll;
|
||||
}
|
||||
|
||||
private void SetText(uint elementId, string value, bool scrollable = false)
|
||||
{
|
||||
if (_layout.FindElement(elementId) is not UiText text)
|
||||
return;
|
||||
text.PreserveEndOnLayout = false;
|
||||
text.WheelScrollEnabled = scrollable;
|
||||
text.ClickThrough = !scrollable;
|
||||
text.LinesProvider = () => IndicatorDetailText.Shape(text, value);
|
||||
if (scrollable)
|
||||
{
|
||||
UiScrollbar? scrollbar = Descendants(text.Parent)
|
||||
.OfType<UiScrollbar>()
|
||||
.FirstOrDefault(candidate => candidate.Visible);
|
||||
if (scrollbar is not null)
|
||||
scrollbar.Model = text.Scroll;
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearCreatureText()
|
||||
{
|
||||
foreach (uint id in new uint[]
|
||||
{
|
||||
0x1000014Au, 0x1000014Bu, 0x1000014Cu, 0x1000014Eu,
|
||||
0x10000150u, 0x10000151u, 0x10000152u, 0x1000053Au,
|
||||
})
|
||||
if (_layout.FindElement(id) is UiText text)
|
||||
text.LinesProvider = static () => Array.Empty<UiText.Line>();
|
||||
}
|
||||
|
||||
private void ResetCreatureScroll()
|
||||
{
|
||||
foreach (UiText text in Descendants(_creaturePanel).OfType<UiText>())
|
||||
text.Scroll.SetScrollY(0);
|
||||
}
|
||||
|
||||
private void SetActiveView(AppraisalView view)
|
||||
{
|
||||
_activeView = view;
|
||||
_itemPanel.Visible = view == AppraisalView.Item;
|
||||
_creaturePanel.Visible = view is AppraisalView.Creature or AppraisalView.Character;
|
||||
if (_spellPanel is not null)
|
||||
_spellPanel.Visible = false;
|
||||
|
||||
UiElement? creatureInfo = _layout.FindElement(0x1000014Du);
|
||||
UiElement? characterInfo = _layout.FindElement(0x1000014Fu);
|
||||
if (creatureInfo is not null)
|
||||
creatureInfo.Visible = view == AppraisalView.Creature;
|
||||
if (characterInfo is not null)
|
||||
characterInfo.Visible = view == AppraisalView.Character;
|
||||
}
|
||||
|
||||
private string? ResolveSpellName(uint spellId)
|
||||
=> _spellbook.TryGetMetadata(spellId & 0x7FFF_FFFFu, out SpellMetadata metadata)
|
||||
? metadata.Name
|
||||
: null;
|
||||
|
||||
private static AppraisalView SelectView(AppraiseInfoParser.Parsed appraisal)
|
||||
{
|
||||
if (appraisal.CreatureProfile is null)
|
||||
return AppraisalView.Item;
|
||||
return appraisal.Properties.Strings.ContainsKey(TemplateStringProperty)
|
||||
|| appraisal.Properties.Ints.ContainsKey(CharacterMarkerIntProperty)
|
||||
? AppraisalView.Character
|
||||
: AppraisalView.Creature;
|
||||
}
|
||||
|
||||
private static string BuildTitle(ClientObject obj, PropertyBundle properties)
|
||||
{
|
||||
string name = GetString(properties, DisplayedNameStringProperty);
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
name = obj.GetAppropriateName();
|
||||
return obj.StackSize > 1
|
||||
? $"{obj.StackSize.ToString(CultureInfo.CurrentCulture)} {name}"
|
||||
: name;
|
||||
}
|
||||
|
||||
private static int GetInt(PropertyBundle properties, uint id)
|
||||
=> properties.Ints.TryGetValue(id, out int value) ? value : 0;
|
||||
|
||||
private static string GetString(PropertyBundle properties, uint id)
|
||||
=> properties.Strings.TryGetValue(id, out string? value) ? value : string.Empty;
|
||||
|
||||
private static IEnumerable<UiElement> Descendants(UiElement? root)
|
||||
{
|
||||
if (root is null)
|
||||
yield break;
|
||||
foreach (UiElement child in root.Children)
|
||||
{
|
||||
yield return child;
|
||||
foreach (UiElement descendant in Descendants(child))
|
||||
yield return descendant;
|
||||
}
|
||||
}
|
||||
|
||||
public void OnShown()
|
||||
{
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_disposed = true;
|
||||
if (_close is not null)
|
||||
_close.OnClick = null;
|
||||
foreach (UiScrollbar scrollbar in Descendants(_layout.Root).OfType<UiScrollbar>())
|
||||
if (ReferenceEquals(scrollbar.Model, _itemText.Scroll)
|
||||
|| (_inscriptionText is not null
|
||||
&& ReferenceEquals(scrollbar.Model, _inscriptionText.Scroll)))
|
||||
scrollbar.Model = null;
|
||||
}
|
||||
}
|
||||
|
||||
public enum AppraisalView
|
||||
{
|
||||
Item,
|
||||
Creature,
|
||||
Character,
|
||||
}
|
||||
|
||||
/// <summary>Pure retail-ordered text projection for the authored examination fields.</summary>
|
||||
public static class AppraisalTextFormatter
|
||||
{
|
||||
private const uint Level = 25u;
|
||||
private const uint ArmorLevel = 28u;
|
||||
private const uint Damage = 44u;
|
||||
private const uint DamageType = 45u;
|
||||
private const uint WeaponTime = 49u;
|
||||
private const uint Structure = 92u;
|
||||
private const uint MaxStructure = 91u;
|
||||
private const uint Workmanship = 105u;
|
||||
private const uint CurrentMana = 107u;
|
||||
private const uint MaximumMana = 108u;
|
||||
private const uint Difficulty = 109u;
|
||||
private const uint WieldRequirement = 158u;
|
||||
private const uint WieldSkill = 159u;
|
||||
private const uint WieldDifficulty = 160u;
|
||||
private const uint RareId = 17u;
|
||||
|
||||
public static string BuildItem(
|
||||
ClientObject obj,
|
||||
AppraiseInfoParser.Parsed appraisal,
|
||||
Func<uint, string?> resolveSpellName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(obj);
|
||||
ArgumentNullException.ThrowIfNull(resolveSpellName);
|
||||
PropertyBundle p = appraisal.Properties;
|
||||
var text = new StringBuilder();
|
||||
|
||||
Append(text, "Value", obj.Value != 0 ? obj.Value : GetInt(p, 19u), "N0");
|
||||
Append(text, "Burden", obj.Burden != 0 ? obj.Burden : GetInt(p, 5u), "N0");
|
||||
|
||||
int workmanship = GetInt(p, Workmanship);
|
||||
if (workmanship != 0 || obj.Workmanship != 0)
|
||||
AppendLine(text, "Workmanship",
|
||||
(workmanship != 0 ? workmanship : obj.Workmanship)
|
||||
.ToString("0.##", CultureInfo.CurrentCulture));
|
||||
|
||||
if (appraisal.WeaponProfile is { } weapon)
|
||||
{
|
||||
AppendLine(text, "Damage",
|
||||
$"{weapon.Damage.ToString(CultureInfo.CurrentCulture)} {DamageTypeName(weapon.DamageType)}");
|
||||
AppendLine(text, "Speed", weapon.WeaponTime.ToString(CultureInfo.CurrentCulture));
|
||||
AppendPercent(text, "Damage Variance", weapon.DamageVariance);
|
||||
AppendPercent(text, "Attack Bonus", weapon.WeaponOffense - 1d);
|
||||
}
|
||||
else
|
||||
{
|
||||
Append(text, "Damage", GetInt(p, Damage));
|
||||
int damageType = GetInt(p, DamageType);
|
||||
if (damageType != 0)
|
||||
AppendLine(text, "Damage Type", DamageTypeName((uint)damageType));
|
||||
Append(text, "Speed", GetInt(p, WeaponTime));
|
||||
}
|
||||
|
||||
if (appraisal.ArmorProfile is { } armor)
|
||||
{
|
||||
Append(text, "Armor Level", GetInt(p, ArmorLevel));
|
||||
AppendProtection(text, "Slashing", armor.SlashingProtection);
|
||||
AppendProtection(text, "Piercing", armor.PiercingProtection);
|
||||
AppendProtection(text, "Bludgeoning", armor.BludgeoningProtection);
|
||||
AppendProtection(text, "Fire", armor.FireProtection);
|
||||
AppendProtection(text, "Cold", armor.ColdProtection);
|
||||
AppendProtection(text, "Acid", armor.AcidProtection);
|
||||
AppendProtection(text, "Electric", armor.LightningProtection);
|
||||
AppendProtection(text, "Nether", armor.NetherProtection);
|
||||
}
|
||||
|
||||
Append(text, "Level", GetInt(p, Level));
|
||||
Append(text, "Difficulty", GetInt(p, Difficulty));
|
||||
int currentMana = GetInt(p, CurrentMana);
|
||||
int maxMana = GetInt(p, MaximumMana);
|
||||
if (currentMana != 0 || maxMana != 0)
|
||||
AppendLine(text, "Mana",
|
||||
$"{currentMana.ToString("N0", CultureInfo.CurrentCulture)} / {maxMana.ToString("N0", CultureInfo.CurrentCulture)}");
|
||||
|
||||
int currentUses = obj.Structure != 0 ? obj.Structure : GetInt(p, Structure);
|
||||
int maxUses = obj.MaxStructure != 0 ? obj.MaxStructure : GetInt(p, MaxStructure);
|
||||
if (currentUses != 0 || maxUses != 0)
|
||||
AppendLine(text, "Uses",
|
||||
maxUses > 0
|
||||
? $"{currentUses.ToString(CultureInfo.CurrentCulture)} / {maxUses.ToString(CultureInfo.CurrentCulture)}"
|
||||
: currentUses.ToString(CultureInfo.CurrentCulture));
|
||||
|
||||
if (GetInt(p, WieldRequirement) != 0)
|
||||
AppendLine(
|
||||
text,
|
||||
"Wield Requirement",
|
||||
$"{SkillName(GetInt(p, WieldSkill))} {GetInt(p, WieldDifficulty).ToString(CultureInfo.CurrentCulture)}");
|
||||
|
||||
if (appraisal.HookProfile is { } hook)
|
||||
{
|
||||
if ((hook.Flags & 0x1u) != 0)
|
||||
AppendFlag(text, "Inscribable");
|
||||
if ((hook.Flags & 0x2u) != 0)
|
||||
AppendFlag(text, "Healer");
|
||||
if ((hook.Flags & 0x4u) != 0)
|
||||
AppendFlag(text, "Food");
|
||||
if ((hook.Flags & 0x8u) != 0)
|
||||
AppendFlag(text, "Lockpick");
|
||||
}
|
||||
|
||||
foreach (uint rawSpellId in appraisal.SpellBook)
|
||||
{
|
||||
uint spellId = rawSpellId & 0x7FFF_FFFFu;
|
||||
AppendFlag(text, resolveSpellName(spellId) ?? $"Spell {spellId}");
|
||||
}
|
||||
|
||||
if (GetInt(p, RareId) != 0)
|
||||
AppendFlag(text, "Rare Item");
|
||||
|
||||
string use = GetString(p, 14u);
|
||||
if (!string.IsNullOrWhiteSpace(use))
|
||||
AppendParagraph(text, use);
|
||||
string shortDescription = GetString(p, 15u);
|
||||
if (!string.IsNullOrWhiteSpace(shortDescription))
|
||||
AppendParagraph(text, shortDescription);
|
||||
string longDescription = GetString(p, 16u);
|
||||
if (!string.IsNullOrWhiteSpace(longDescription)
|
||||
&& !string.Equals(longDescription, shortDescription, StringComparison.Ordinal))
|
||||
AppendParagraph(text, longDescription);
|
||||
|
||||
if (!appraisal.Success && text.Length == 0)
|
||||
text.Append("Assessment incomplete");
|
||||
return text.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
public static string BuildCreature(
|
||||
ClientObject obj,
|
||||
AppraiseInfoParser.Parsed appraisal,
|
||||
bool character)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(obj);
|
||||
PropertyBundle p = appraisal.Properties;
|
||||
var text = new StringBuilder();
|
||||
Append(text, "Level", GetInt(p, Level));
|
||||
if (appraisal.CreatureProfile is { } creature)
|
||||
{
|
||||
AppendLine(text, "Health",
|
||||
$"{creature.Health.ToString("N0", CultureInfo.CurrentCulture)} / {creature.HealthMax.ToString("N0", CultureInfo.CurrentCulture)}");
|
||||
AppendOptional(text, "Stamina", creature.Stamina, creature.StaminaMax);
|
||||
AppendOptional(text, "Mana", creature.Mana, creature.ManaMax);
|
||||
AppendOptional(text, "Strength", creature.Strength);
|
||||
AppendOptional(text, "Endurance", creature.Endurance);
|
||||
AppendOptional(text, "Coordination", creature.Coordination);
|
||||
AppendOptional(text, "Quickness", creature.Quickness);
|
||||
AppendOptional(text, "Focus", creature.Focus);
|
||||
AppendOptional(text, "Self", creature.Self);
|
||||
}
|
||||
|
||||
string title = GetString(p, 2u);
|
||||
if (character && !string.IsNullOrWhiteSpace(title))
|
||||
AppendLine(text, "Title", title);
|
||||
string description = GetString(p, 16u);
|
||||
if (!string.IsNullOrWhiteSpace(description))
|
||||
AppendParagraph(text, description);
|
||||
if (!appraisal.Success && text.Length == 0)
|
||||
text.Append("Assessment incomplete");
|
||||
return text.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
public static string CreatureTypeName(int creatureType)
|
||||
=> creatureType == 0
|
||||
? string.Empty
|
||||
: $"Creature type {creatureType.ToString(CultureInfo.CurrentCulture)}";
|
||||
|
||||
private static void AppendOptional(
|
||||
StringBuilder text,
|
||||
string label,
|
||||
uint? current,
|
||||
uint? maximum = null)
|
||||
{
|
||||
if (current is null)
|
||||
return;
|
||||
AppendLine(
|
||||
text,
|
||||
label,
|
||||
maximum is { } max
|
||||
? $"{current.Value.ToString("N0", CultureInfo.CurrentCulture)} / {max.ToString("N0", CultureInfo.CurrentCulture)}"
|
||||
: current.Value.ToString("N0", CultureInfo.CurrentCulture));
|
||||
}
|
||||
|
||||
private static void Append(
|
||||
StringBuilder text,
|
||||
string label,
|
||||
int value,
|
||||
string? format = null)
|
||||
{
|
||||
if (value == 0)
|
||||
return;
|
||||
AppendLine(
|
||||
text,
|
||||
label,
|
||||
value.ToString(format ?? "0", CultureInfo.CurrentCulture));
|
||||
}
|
||||
|
||||
private static void AppendPercent(StringBuilder text, string label, double value)
|
||||
{
|
||||
if (Math.Abs(value) < 0.000001)
|
||||
return;
|
||||
AppendLine(text, label, value.ToString("+0%;-0%;0%", CultureInfo.CurrentCulture));
|
||||
}
|
||||
|
||||
private static void AppendProtection(StringBuilder text, string label, float value)
|
||||
{
|
||||
if (Math.Abs(value - 1f) < 0.0001f)
|
||||
return;
|
||||
AppendLine(text, label, value.ToString("0.00x", CultureInfo.CurrentCulture));
|
||||
}
|
||||
|
||||
private static void AppendFlag(StringBuilder text, string value)
|
||||
=> AppendParagraph(text, value);
|
||||
|
||||
private static void AppendLine(StringBuilder text, string label, string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return;
|
||||
if (text.Length != 0)
|
||||
text.Append('\n');
|
||||
text.Append(label).Append(": ").Append(value);
|
||||
}
|
||||
|
||||
private static void AppendParagraph(StringBuilder text, string value)
|
||||
{
|
||||
if (text.Length != 0)
|
||||
text.Append("\n\n");
|
||||
text.Append(value);
|
||||
}
|
||||
|
||||
private static int GetInt(PropertyBundle p, uint id)
|
||||
=> p.Ints.TryGetValue(id, out int value) ? value : 0;
|
||||
|
||||
private static string GetString(PropertyBundle p, uint id)
|
||||
=> p.Strings.TryGetValue(id, out string? value) ? value : string.Empty;
|
||||
|
||||
private static string DamageTypeName(uint type) => type switch
|
||||
{
|
||||
1u => "Slashing",
|
||||
2u => "Piercing",
|
||||
4u => "Bludgeoning",
|
||||
8u => "Cold",
|
||||
16u => "Fire",
|
||||
32u => "Acid",
|
||||
64u => "Electric",
|
||||
1024u => "Nether",
|
||||
_ => $"Type {type.ToString(CultureInfo.CurrentCulture)}",
|
||||
};
|
||||
|
||||
private static string SkillName(int skill) => skill switch
|
||||
{
|
||||
1 => "Axe",
|
||||
3 => "Dagger",
|
||||
6 => "Mace",
|
||||
9 => "Spear",
|
||||
10 => "Staff",
|
||||
12 => "Sword",
|
||||
13 => "Thrown Weapons",
|
||||
14 => "Unarmed Combat",
|
||||
15 => "Arcane Lore",
|
||||
18 => "Magic Defense",
|
||||
19 => "Mana Conversion",
|
||||
21 => "Item Tinkering",
|
||||
22 => "Assess Person",
|
||||
23 => "Deception",
|
||||
24 => "Healing",
|
||||
27 => "Lockpick",
|
||||
30 => "Creature Enchantment",
|
||||
31 => "Item Enchantment",
|
||||
32 => "Life Magic",
|
||||
34 => "War Magic",
|
||||
40 => "Light Weapons",
|
||||
41 => "Heavy Weapons",
|
||||
42 => "Finesse Weapons",
|
||||
43 => "Missile Weapons",
|
||||
44 => "Shield",
|
||||
45 => "Dual Wield",
|
||||
46 => "Recklessness",
|
||||
47 => "Sneak Attack",
|
||||
48 => "Dirty Fighting",
|
||||
54 => "Void Magic",
|
||||
55 => "Summoning",
|
||||
_ => $"Skill {skill.ToString(CultureInfo.CurrentCulture)}",
|
||||
};
|
||||
}
|
||||
|
|
@ -16,6 +16,12 @@ public static class RetailPanelCatalog
|
|||
public const uint Character = 11u;
|
||||
public const uint Magic = 13u;
|
||||
public const uint Vitae = 15u;
|
||||
/// <summary>
|
||||
/// Private retained-host identity for retail's floating examination UI.
|
||||
/// The original opens it by notice rather than a gmPanelUI numeric child,
|
||||
/// so this value is deliberately outside the DAT panel-id range.
|
||||
/// </summary>
|
||||
public const uint Examination = 0x8000_0001u;
|
||||
|
||||
private static readonly (uint PanelId, string WindowName)[] Mounted =
|
||||
{
|
||||
|
|
@ -28,6 +34,7 @@ public static class RetailPanelCatalog
|
|||
(Character, WindowNames.Character),
|
||||
(Magic, WindowNames.Spellbook),
|
||||
(Vitae, WindowNames.Vitae),
|
||||
(Examination, WindowNames.Examination),
|
||||
};
|
||||
|
||||
private static readonly (uint PanelId, string WindowName)[] Toolbar =
|
||||
|
|
|
|||
|
|
@ -234,6 +234,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
MountToolbar();
|
||||
MountCombat();
|
||||
MountSpellbook();
|
||||
MountAppraisal();
|
||||
MountEffects();
|
||||
MountIndicatorDetailPanels();
|
||||
MountIndicators();
|
||||
|
|
@ -293,6 +294,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
public CombatUiController? CombatUiController { get; private set; }
|
||||
public SpellcastingUiController? SpellcastingUiController { get; private set; }
|
||||
public SpellbookWindowController? SpellbookWindowController { get; private set; }
|
||||
public AppraisalUiController? AppraisalController { get; private set; }
|
||||
public EffectsUiController? PositiveEffectsController { get; private set; }
|
||||
public EffectsUiController? NegativeEffectsController { get; private set; }
|
||||
public LinkStatusUiController? LinkStatusUiController { get; private set; }
|
||||
|
|
@ -340,6 +342,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
FpsController?.Tick();
|
||||
_vividTargetIndicator?.Tick();
|
||||
SpellbookWindowController?.Tick();
|
||||
AppraisalController?.Tick(deltaSeconds);
|
||||
SpellcastingUiController?.Tick();
|
||||
PositiveEffectsController?.Tick();
|
||||
NegativeEffectsController?.Tick();
|
||||
|
|
@ -391,6 +394,11 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
public bool HandleConfirmationDone(GameEvents.CharacterConfirmationDone done)
|
||||
=> _gameplayConfirmationController?.HandleDone(done) == true;
|
||||
|
||||
public bool HandleAppraisal(AppraiseInfoParser.Parsed appraisal)
|
||||
=> AppraisalController is { } controller
|
||||
? controller.Apply(appraisal)
|
||||
: ItemInteraction.AcceptAppraisalResponse(appraisal.Guid).Accepted;
|
||||
|
||||
public uint ShowConfirmation(string message, Action<bool> completed)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
|
|
@ -419,6 +427,13 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
public void ResetSessionTransientUi()
|
||||
{
|
||||
ResetSessionDialogs();
|
||||
AppraisalController?.ResetSession();
|
||||
_panelUi.SetPanelVisibility(RetailPanelCatalog.Examination, visible: false);
|
||||
}
|
||||
|
||||
public void UpdateCursor(IEnumerable<IMouse> mice)
|
||||
{
|
||||
CursorFeedback feedback = _bindings.Cursor.Feedback.Update(Host.Root);
|
||||
|
|
@ -938,6 +953,67 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
Console.WriteLine("[M3] retail spellbook/component book from LayoutDesc 0x21000034.");
|
||||
}
|
||||
|
||||
private void MountAppraisal()
|
||||
{
|
||||
ImportedLayout? layout = Import(
|
||||
AppraisalUiController.LayoutId,
|
||||
AppraisalUiController.RootId);
|
||||
if (layout is null)
|
||||
{
|
||||
Console.WriteLine(
|
||||
"[M4] examination: LayoutDesc 0x2100006B root 0x100005F2 not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
AppraisalUiController? controller = AppraisalUiController.Bind(
|
||||
layout,
|
||||
_bindings.Inventory.Objects,
|
||||
_bindings.Inventory.ItemInteraction,
|
||||
_bindings.Combat.State,
|
||||
_bindings.Magic.Spellbook,
|
||||
show: () => _panelUi.SetPanelVisibility(
|
||||
RetailPanelCatalog.Examination,
|
||||
visible: true),
|
||||
close: () => CloseWindow(WindowNames.Examination));
|
||||
if (controller is null)
|
||||
{
|
||||
Console.WriteLine(
|
||||
"[M4] examination: required authored controls are missing.");
|
||||
return;
|
||||
}
|
||||
|
||||
AppraisalController = controller;
|
||||
UiElement root = layout.Root;
|
||||
RetailWindowHandle handle = RetailWindowFrame.Mount(
|
||||
Host.Root,
|
||||
root,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
new RetailWindowFrame.Options
|
||||
{
|
||||
WindowName = WindowNames.Examination,
|
||||
Chrome = RetailWindowChrome.Imported,
|
||||
Left = root.Left,
|
||||
Top = root.Top,
|
||||
ContentWidth = root.Width,
|
||||
ContentHeight = root.Height,
|
||||
Visible = false,
|
||||
ResizeX = true,
|
||||
ResizeY = true,
|
||||
MinWidth = 310f,
|
||||
MinHeight = 400f,
|
||||
ConstrainDragToParent = true,
|
||||
ConstrainResizeToParent = true,
|
||||
ContentClickThrough = false,
|
||||
Controller = controller,
|
||||
});
|
||||
_panelUi.RegisterMainPanel(
|
||||
RetailPanelCatalog.Examination,
|
||||
WindowNames.Examination,
|
||||
handle);
|
||||
Console.WriteLine(
|
||||
"[M4] retail examination window from LayoutDesc 0x2100006B.");
|
||||
}
|
||||
|
||||
private void MountEffects()
|
||||
{
|
||||
MountEffectsInstance(positive: true);
|
||||
|
|
|
|||
|
|
@ -21,4 +21,5 @@ public static class WindowNames
|
|||
public const string LinkStatus = "link-status";
|
||||
public const string MiniGame = "mini-game";
|
||||
public const string Vitae = "vitae";
|
||||
public const string Examination = "examination";
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue