feat(quest): QT5/QT6 — the Journal panel, and the button that was already there
The quest log is on screen. Rows come from the live tracker joined to the authored catalog, the Status column runs QT4's port of FillProgressString, and the detail pane shows contact, locations, description and the other timer. Two things measured rather than assumed, each now pinned by an installed-DAT test rather than left to the commit message: The tab pairing is read from the authored 0x2E table, not inferred from x-order — the FA campaign had to correct exactly that mistake, and Contracts turns out to be the authored DEFAULT tab (0x32 = True), so opening on the wrong one would have looked like an empty panel. The open path needed no keybind at all. Toolbar button 0x1000055A authors 0x10000029 = 0x19 and has been sitting in ToolbarController.PanelButtonIds since the toolbar was ported — it just had no panel behind it, so clicking it did nothing. Registering slot 25 finished a wiring that was already three-quarters present. The list rebuild is revision-gated while the repeat countdown is not: nothing on the wire changes as a cooldown runs down, so a rebuild-gated timer would freeze on screen, and a per-frame rebuild would reset the player's scroll under them. Both directions have a test. Deliberately inert: the Abandon button (retail's abandon path is a contract-registry command this campaign did not port — authored and visible, but wiring a no-op handler would look responsive and lie), and the Journal notes and Page List tabs, which are their own feature. Campaign QT slices 5 and 6 of 6 — code-complete, connected gate owed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
fac2dc7248
commit
ec6eeb120d
12 changed files with 1109 additions and 5 deletions
|
|
@ -682,6 +682,18 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
lock (d.DatLock)
|
||||
chargenSkillTable = d.Dats.Get<SkillTable>(0x0E000004u);
|
||||
var chargenSkillScoreResolver = new ChargenSkillScoreResolver(chargenSkillTable);
|
||||
// Campaign QT slice QT5: lazily loaded on first open (the panel
|
||||
// is hidden at mount), then held for the session.
|
||||
AcDream.Core.Quests.ContractCatalog? contractCatalog = null;
|
||||
AcDream.Core.Quests.ContractCatalog questCatalog()
|
||||
{
|
||||
if (contractCatalog is not null)
|
||||
return contractCatalog;
|
||||
lock (d.DatLock)
|
||||
contractCatalog = AcDream.Content.ContractTableReader.Load(d.Dats);
|
||||
return contractCatalog;
|
||||
}
|
||||
|
||||
var bindings = new RetailUiRuntimeBindings(
|
||||
Host: host,
|
||||
Assets: assets,
|
||||
|
|
@ -1007,6 +1019,13 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
CurrentCalendar: d.CurrentCalendar,
|
||||
PlayerCellId: () => d.PlayerController.Controller?.CellId ?? 0u,
|
||||
HouseLines: () => d.Runtime.HouseOwner.Lines),
|
||||
// Campaign QT slice QT5. The catalog is read from the dats
|
||||
// ONCE and cached: it is immutable installed content, and the
|
||||
// panel would otherwise re-read a 322-entry table on every
|
||||
// refresh under the shared DatLock.
|
||||
Quests: new QuestRuntimeBindings(
|
||||
Contracts: d.Runtime.ContractsOwner.View,
|
||||
Catalog: questCatalog),
|
||||
StackSplitQuantity: d.StackSplitQuantity,
|
||||
Plugins: d.UiRegistry,
|
||||
Persistence: persistence,
|
||||
|
|
|
|||
228
src/AcDream.App/UI/Layout/JournalContractsPageController.cs
Normal file
228
src/AcDream.App/UI/Layout/JournalContractsPageController.cs
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Quests;
|
||||
using AcDream.Core.Ui;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// The Journal panel's Contracts page — retail <c>gmContractsUI</c>
|
||||
/// (element type <c>0x1000004B</c>, page <c>0x100005D4</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A two-column list (Contract / Status) over a detail pane. Rows come from
|
||||
/// <see cref="IRuntimeContractView"/> — live server state — joined to the
|
||||
/// authored <see cref="ContractCatalog"/> for every word the player reads;
|
||||
/// the wire itself carries only an id, a stage and two timers.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Rebuilds are revision-gated. The tracker changes rarely (accepting or
|
||||
/// advancing a quest) while the panel ticks every frame, so polling
|
||||
/// <see cref="RuntimeContractsSnapshot.Revision"/> is what keeps this from
|
||||
/// rebuilding a template list continuously.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class JournalContractsPageController
|
||||
{
|
||||
/// <summary>The layout the row template lives in — authored property
|
||||
/// <c>0x63</c> on the list's template entry.</summary>
|
||||
public const uint RowTemplateLayoutId = 0x21000069u;
|
||||
|
||||
/// <summary>The row template element — authored <c>0x62</c>, and the id
|
||||
/// retail passes to <c>AddItemFromTemplateListByID @0x00499747</c>.</summary>
|
||||
public const uint RowTemplateElementId = 0x100005D7u;
|
||||
|
||||
private const uint ListId = 0x100005CFu;
|
||||
private const uint RowNameId = 0x100005D1u;
|
||||
private const uint RowStatusId = 0x100005D2u;
|
||||
|
||||
private const uint StatusValueId = 0x100005DFu;
|
||||
private const uint ContactValueId = 0x100005E0u;
|
||||
private const uint ContactLocationValueId = 0x100005E1u;
|
||||
private const uint QuestLocationValueId = 0x100005E2u;
|
||||
private const uint DescriptionId = 0x100005DEu;
|
||||
private const uint TimedValueId = 0x100005E3u;
|
||||
private const uint AbandonButtonId = 0x100005DCu;
|
||||
|
||||
/// <summary>Live state and services the page reads.</summary>
|
||||
/// <param name="Contracts">The canonical tracker view.</param>
|
||||
/// <param name="Catalog">The authored contract text.</param>
|
||||
/// <param name="Now">
|
||||
/// The clock the repeat countdown is measured against. Injected rather
|
||||
/// than read from <see cref="DateTime.UtcNow"/> so the countdown is
|
||||
/// testable without waiting for it.
|
||||
/// </param>
|
||||
/// <param name="TemplateResolver">Builds one row from the authored template.</param>
|
||||
public sealed record Bindings(
|
||||
IRuntimeContractView Contracts,
|
||||
Func<ContractCatalog> Catalog,
|
||||
Func<DateTime> Now,
|
||||
Func<uint, uint, UiElement?> TemplateResolver);
|
||||
|
||||
private readonly Bindings _bindings;
|
||||
private readonly UiTemplateListBox? _list;
|
||||
private readonly UiText? _statusValue;
|
||||
private readonly UiText? _contactValue;
|
||||
private readonly UiText? _contactLocationValue;
|
||||
private readonly UiText? _questLocationValue;
|
||||
private readonly UiText? _description;
|
||||
private readonly UiText? _timedValue;
|
||||
|
||||
private readonly List<uint> _rowContractIds = [];
|
||||
|
||||
private long _renderedRevision = -1;
|
||||
private uint _selectedContractId;
|
||||
|
||||
public JournalContractsPageController(UiElement page, Bindings bindings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(page);
|
||||
_bindings = bindings ?? throw new ArgumentNullException(nameof(bindings));
|
||||
|
||||
_list = UiElement.FindDescendant(page, ListId) as UiTemplateListBox;
|
||||
if (_list is not null)
|
||||
_list.TemplateResolver = bindings.TemplateResolver;
|
||||
|
||||
_statusValue = UiElement.FindDescendant(page, StatusValueId) as UiText;
|
||||
_contactValue = UiElement.FindDescendant(page, ContactValueId) as UiText;
|
||||
_contactLocationValue =
|
||||
UiElement.FindDescendant(page, ContactLocationValueId) as UiText;
|
||||
_questLocationValue =
|
||||
UiElement.FindDescendant(page, QuestLocationValueId) as UiText;
|
||||
_description = UiElement.FindDescendant(page, DescriptionId) as UiText;
|
||||
_timedValue = UiElement.FindDescendant(page, TimedValueId) as UiText;
|
||||
|
||||
// The Abandon button has no wire message in this campaign's scope —
|
||||
// retail's own abandon path is a contract-registry command we have not
|
||||
// ported. Left unwired rather than given a no-op handler that would
|
||||
// look responsive and do nothing.
|
||||
_ = AbandonButtonId;
|
||||
|
||||
Refresh();
|
||||
}
|
||||
|
||||
/// <summary>The contract the detail pane is showing, or 0.</summary>
|
||||
public uint SelectedContractId => _selectedContractId;
|
||||
|
||||
/// <summary>Contract ids in list order, for tests.</summary>
|
||||
public IReadOnlyList<uint> RowContractIds => _rowContractIds;
|
||||
|
||||
public void Tick()
|
||||
{
|
||||
// Cheap every frame; a rebuild only when the tracker actually moved.
|
||||
if (_bindings.Contracts.Snapshot.Revision != _renderedRevision)
|
||||
Refresh();
|
||||
else
|
||||
RefreshDetail(); // the repeat countdown ticks without a rebuild
|
||||
}
|
||||
|
||||
public void Refresh()
|
||||
{
|
||||
RuntimeContractsSnapshot snapshot = _bindings.Contracts.Snapshot;
|
||||
_renderedRevision = snapshot.Revision;
|
||||
|
||||
IReadOnlyList<ContractTracker> contracts = _bindings.Contracts.GetContracts();
|
||||
ContractCatalog catalog = _bindings.Catalog();
|
||||
DateTime now = _bindings.Now();
|
||||
|
||||
// Retail's own default: the server nominates a display contract, and
|
||||
// otherwise the first row stands.
|
||||
if (snapshot.DisplayContractId != 0u)
|
||||
_selectedContractId = snapshot.DisplayContractId;
|
||||
if (_selectedContractId == 0u && contracts.Count != 0)
|
||||
_selectedContractId = contracts[0].ContractId;
|
||||
if (contracts.Count == 0)
|
||||
_selectedContractId = 0u;
|
||||
|
||||
_rowContractIds.Clear();
|
||||
_list?.FlushPreservingScroll();
|
||||
|
||||
foreach (ContractTracker tracker in contracts)
|
||||
{
|
||||
_rowContractIds.Add(tracker.ContractId);
|
||||
if (_list is null)
|
||||
continue;
|
||||
|
||||
UiElement? row = _list.AddItemFromTemplateList(0);
|
||||
if (row is null)
|
||||
continue;
|
||||
|
||||
ContractEntry entry = catalog.Lookup(tracker.ContractId);
|
||||
|
||||
if (UiElement.FindDescendant(row, RowNameId) is UiText name)
|
||||
SetText(name, entry.ContractName);
|
||||
if (UiElement.FindDescendant(row, RowStatusId) is UiText status)
|
||||
{
|
||||
SetText(status, ContractProgressText.Build(
|
||||
(uint)tracker.Stage, tracker.TimeWhenRepeats,
|
||||
tracker.ReceivedAt, entry, now));
|
||||
}
|
||||
|
||||
uint captured = tracker.ContractId;
|
||||
if (row is UiDatElement clickable)
|
||||
clickable.OnClick = () => Select(captured);
|
||||
}
|
||||
|
||||
RefreshDetail();
|
||||
}
|
||||
|
||||
/// <summary>Points the detail pane at one contract.</summary>
|
||||
public void Select(uint contractId)
|
||||
{
|
||||
_selectedContractId = contractId;
|
||||
RefreshDetail();
|
||||
}
|
||||
|
||||
private void RefreshDetail()
|
||||
{
|
||||
ContractCatalog catalog = _bindings.Catalog();
|
||||
DateTime now = _bindings.Now();
|
||||
|
||||
if (_selectedContractId == 0u
|
||||
|| !_bindings.Contracts.TryGetContract(_selectedContractId, out ContractTracker tracker))
|
||||
{
|
||||
SetText(_statusValue, string.Empty);
|
||||
SetText(_contactValue, string.Empty);
|
||||
SetText(_contactLocationValue, string.Empty);
|
||||
SetText(_questLocationValue, string.Empty);
|
||||
SetText(_description, string.Empty);
|
||||
SetText(_timedValue, string.Empty);
|
||||
return;
|
||||
}
|
||||
|
||||
ContractEntry entry = catalog.Lookup(_selectedContractId);
|
||||
|
||||
SetText(_statusValue, ContractProgressText.Build(
|
||||
(uint)tracker.Stage, tracker.TimeWhenRepeats, tracker.ReceivedAt, entry, now));
|
||||
SetText(_contactValue, entry.NameNpcStart);
|
||||
SetText(_contactLocationValue, LocationText(entry.LocationNpcStartCell));
|
||||
SetText(_questLocationValue, LocationText(entry.LocationQuestAreaCell));
|
||||
SetText(_description, entry.Description);
|
||||
|
||||
// "Timed:" is the other wire timer — the one FillProgressString never
|
||||
// reads. It belongs here, not in the Status column.
|
||||
SetText(_timedValue, tracker.TimeWhenDone > 0d
|
||||
? ContractProgressText.DeltaTimeToString(
|
||||
Math.Max(0d, tracker.TimeWhenDone - (now - tracker.ReceivedAt).TotalSeconds))
|
||||
: string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Coordinates, or retail's literal <c>"Indoors"</c> when the cell has none
|
||||
/// (<c>LandDefs::gid_to_lcoord</c> failing, <c>@0x0049937F</c>).
|
||||
/// </summary>
|
||||
private static string LocationText(uint cellId)
|
||||
{
|
||||
if (cellId == 0u) return string.Empty;
|
||||
return RetailPositionFormatter.FormatOutdoorCell(cellId) ?? "Indoors";
|
||||
}
|
||||
|
||||
private static void SetText(UiText? text, string value)
|
||||
{
|
||||
if (text is null) return;
|
||||
text.LinesProvider = () => [new UiText.Line(value, text.DefaultColor)];
|
||||
}
|
||||
}
|
||||
120
src/AcDream.App/UI/Layout/JournalPanelController.cs
Normal file
120
src/AcDream.App/UI/Layout/JournalPanelController.cs
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
using System;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Mounts retail's three-tab <b>Journal</b> panel — LayoutDesc
|
||||
/// <c>0x2100006E</c> slot <c>0x10000559</c>,
|
||||
/// <see cref="AcDream.App.UI.RetailPanelCatalog"/> id <b>25</b>. Campaign QT
|
||||
/// slice QT5, built on the OP3/FA3 tab-host recipe.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The contract tracker is not a panel of its own.</b> `gmContractsUI` is
|
||||
/// tab 1 of this panel, which is why the campaign mounts a Journal rather than
|
||||
/// a Contracts window. The authored tab table (property <c>0x2E</c>, read from
|
||||
/// the installed dats rather than inferred from x-order — the mistake Campaign
|
||||
/// FA had to correct):
|
||||
/// </para>
|
||||
/// <code>
|
||||
/// button 0x100005D3 ("Contracts") -> page 0x100005D4 DEFAULT (0x32 = True)
|
||||
/// button 0x10000560 ("Journal") -> page 0x10000563
|
||||
/// button 0x10000561 ("Page List") -> page 0x10000564
|
||||
/// </code>
|
||||
/// <para>
|
||||
/// Only the Contracts page is in scope for Campaign QT. The Journal notes page
|
||||
/// and the Page List are their own feature; mounting the panel with those two
|
||||
/// tabs inert is the intended state, not a defect.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class JournalPanelController : IRetainedPanelController
|
||||
{
|
||||
/// <summary>The floating host LayoutDesc the tab panel is resolved through.</summary>
|
||||
public const uint HostLayoutId = 0x2100006Eu;
|
||||
|
||||
/// <summary>
|
||||
/// The Journal panel's slot within <see cref="HostLayoutId"/>'s shared
|
||||
/// <c>gmPanelUI</c> page stack. Its own authored <c>0x10000029</c> is
|
||||
/// <c>0x19</c> = 25 — the same byte-verified slot-key recipe Options (10),
|
||||
/// the social panel (12) and Map/House (16) already use.
|
||||
/// </summary>
|
||||
public const uint SlotElementId = 0x10000559u;
|
||||
|
||||
/// <summary>The Contracts page — <c>gmContractsUI</c>, element type
|
||||
/// <c>0x1000004B</c>.</summary>
|
||||
public const uint ContractsPageId = 0x100005D4u;
|
||||
|
||||
/// <summary>The panel's own corner button.</summary>
|
||||
private const uint CloseButtonId = 0x10000562u;
|
||||
|
||||
private readonly UiTabPanel _tabPanel;
|
||||
private readonly JournalContractsPageController? _contracts;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Root element of the imported panel — the tab host itself.</summary>
|
||||
public UiElement Root => _tabPanel;
|
||||
|
||||
public UiTabPanel TabPanel => _tabPanel;
|
||||
|
||||
public JournalContractsPageController? Contracts => _contracts;
|
||||
|
||||
private JournalPanelController(
|
||||
UiTabPanel tabPanel,
|
||||
JournalContractsPageController? contracts)
|
||||
{
|
||||
_tabPanel = tabPanel;
|
||||
_contracts = contracts;
|
||||
}
|
||||
|
||||
public sealed record Callbacks(
|
||||
Action Toggle,
|
||||
JournalContractsPageController.Bindings Contracts);
|
||||
|
||||
/// <summary>
|
||||
/// Binds an imported <see cref="HostLayoutId"/>/<see cref="SlotElementId"/>
|
||||
/// layout to live behavior — the same "import via the host slot, then
|
||||
/// Build+Bind" shape <see cref="OptionsPanelController.Bind"/> and
|
||||
/// <see cref="SocialPanelController.Bind"/> use.
|
||||
/// </summary>
|
||||
public static JournalPanelController? Bind(ImportedLayout layout, Callbacks callbacks)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(layout);
|
||||
ArgumentNullException.ThrowIfNull(callbacks);
|
||||
|
||||
if (layout.Root is not UiTabPanel tabPanel)
|
||||
{
|
||||
Console.WriteLine(
|
||||
"[D.2b] JournalPanelController.Bind: root did not build as UiTabPanel "
|
||||
+ $"(actual type {layout.Root.GetType().Name}) — journal panel will not open.");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (layout.FindElement(CloseButtonId) is UiButton close)
|
||||
close.OnClick = callbacks.Toggle;
|
||||
|
||||
JournalContractsPageController? contracts = null;
|
||||
if (layout.FindElement(ContractsPageId) is { } page)
|
||||
contracts = new JournalContractsPageController(page, callbacks.Contracts);
|
||||
else
|
||||
Console.WriteLine("[D.2b] JournalPanelController: contracts page not found.");
|
||||
|
||||
return new JournalPanelController(tabPanel, contracts);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the authored tab table, which activates the default entry —
|
||||
/// Contracts (<c>0x32 = True</c>).
|
||||
/// </summary>
|
||||
public void ActivateTabs() => _tabPanel.ActivateTabBehavior();
|
||||
|
||||
/// <summary>Switches to the Contracts tab.</summary>
|
||||
public void ShowContracts() => _tabPanel.SwitchTo(ContractsPageId);
|
||||
|
||||
public void Tick()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_contracts?.Tick();
|
||||
}
|
||||
|
||||
public void Dispose() => _disposed = true;
|
||||
}
|
||||
|
|
@ -57,6 +57,17 @@ public static class RetailPanelCatalog
|
|||
/// </summary>
|
||||
public const uint MapHouse = 16u;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign QT slice QT5: the three-tab Journal panel (Contracts /
|
||||
/// Journal / Page List) — <c>gmPanelUI</c> slot key byte-verified from the
|
||||
/// live installed DATs (host <c>0x2100006E</c> slot <c>0x10000559</c>'s own
|
||||
/// authored <c>0x10000029 = 0x19</c>). Toolbar button <c>0x1000055A</c>
|
||||
/// authors the same id, so this one is in BOTH <see cref="Mounted"/> and
|
||||
/// <see cref="Toolbar"/> — like <see cref="MapHouse"/>, unlike
|
||||
/// <see cref="SocialPanel"/>.
|
||||
/// </summary>
|
||||
public const uint Journal = 25u;
|
||||
|
||||
private static readonly (uint PanelId, string WindowName)[] Mounted =
|
||||
{
|
||||
(CharacterInformation, WindowNames.CharacterInformation),
|
||||
|
|
@ -71,6 +82,7 @@ public static class RetailPanelCatalog
|
|||
(Options, WindowNames.Options),
|
||||
(SocialPanel, WindowNames.SocialPanel),
|
||||
(MapHouse, WindowNames.MapHouse),
|
||||
(Journal, WindowNames.Journal),
|
||||
};
|
||||
|
||||
private static readonly (uint PanelId, string WindowName)[] Toolbar =
|
||||
|
|
@ -80,6 +92,11 @@ public static class RetailPanelCatalog
|
|||
(Magic, WindowNames.Spellbook),
|
||||
(Options, WindowNames.Options),
|
||||
(MapHouse, WindowNames.MapHouse),
|
||||
// Campaign QT slice QT5: toolbar button 0x1000055A authors
|
||||
// 0x10000029 = 0x19 and has been in ToolbarController.PanelButtonIds
|
||||
// all along — it simply had no panel behind it, so clicking it did
|
||||
// nothing.
|
||||
(Journal, WindowNames.Journal),
|
||||
};
|
||||
|
||||
public static IReadOnlyList<(uint PanelId, string WindowName)> MountedPanels => Mounted;
|
||||
|
|
|
|||
|
|
@ -311,6 +311,14 @@ public sealed record MapHouseRuntimeBindings(
|
|||
Func<IReadOnlyList<string>>? HouseLines = null,
|
||||
Action? HouseShown = null);
|
||||
|
||||
/// <summary>
|
||||
/// Campaign QT slice QT5: what the Journal panel's Contracts page reads —
|
||||
/// the canonical tracker view plus the authored contract text.
|
||||
/// </summary>
|
||||
public sealed record QuestRuntimeBindings(
|
||||
AcDream.Runtime.Gameplay.IRuntimeContractView Contracts,
|
||||
Func<AcDream.Core.Quests.ContractCatalog> Catalog);
|
||||
|
||||
public sealed record InventoryRuntimeBindings(
|
||||
ClientObjectTable Objects,
|
||||
Func<uint> PlayerGuid,
|
||||
|
|
@ -474,6 +482,7 @@ public sealed record RetailUiRuntimeBindings(
|
|||
OptionsRuntimeBindings Options,
|
||||
SocialRuntimeBindings Social,
|
||||
MapHouseRuntimeBindings MapHouse,
|
||||
QuestRuntimeBindings Quests,
|
||||
StackSplitQuantityState StackSplitQuantity,
|
||||
BufferedUiRegistry? Plugins,
|
||||
RetailUiPersistenceBindings? Persistence,
|
||||
|
|
@ -569,6 +578,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
MountTooltipPresenter();
|
||||
MountSocialPanel();
|
||||
MountMapHousePanel();
|
||||
MountJournalPanel();
|
||||
MountCharacter();
|
||||
MountPlugins();
|
||||
MountInventory();
|
||||
|
|
@ -692,6 +702,9 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
public VendorUiController? VendorController { get; private set; }
|
||||
public OptionsPanelController? OptionsPanelController { get; private set; }
|
||||
public SocialPanelController? SocialPanelController { get; private set; }
|
||||
|
||||
/// <summary>Campaign QT slice QT5 — the three-tab Journal panel.</summary>
|
||||
public Layout.JournalPanelController? JournalPanelController { get; private set; }
|
||||
public MapHousePanelController? MapHousePanelController { get; private set; }
|
||||
internal CharacterManagementUiController? CharacterManagementController =>
|
||||
_characterManagementMount?.Controller;
|
||||
|
|
@ -873,6 +886,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
SelectedObjectController?.Tick(deltaSeconds);
|
||||
ExternalContainerController?.Tick();
|
||||
SocialPanelController?.Tick();
|
||||
JournalPanelController?.Tick();
|
||||
MapHousePanelController?.Tick(deltaSeconds);
|
||||
_itemCooldownController?.Tick();
|
||||
_characterManagementMount?.Tick();
|
||||
|
|
@ -3445,6 +3459,115 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
Console.WriteLine("[UI] retail social panel from LayoutDesc importer (0x2100006E slot 0x1000018F).");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign QT slice QT5: retail's three-tab Journal panel — host
|
||||
/// <c>0x2100006E</c> slot <c>0x10000559</c>,
|
||||
/// <see cref="RetailPanelCatalog.Journal"/> id 25. Same import/Build/Bind
|
||||
/// recipe as <see cref="MountSocialPanel"/>. The Contracts page's list has
|
||||
/// ONE authored row template resolved out of a DIFFERENT layout
|
||||
/// (<c>0x21000069</c>) than the panel itself, which is why it needs the
|
||||
/// caching <see cref="Layout.RowTemplateResolver"/> rather than
|
||||
/// <see cref="UiTemplateListBox.AddItemFromTemplateList"/>'s own path.
|
||||
/// </summary>
|
||||
private void MountJournalPanel()
|
||||
{
|
||||
ElementInfo? rootInfo;
|
||||
ImportedLayout? layout;
|
||||
lock (_bindings.Assets.DatLock)
|
||||
{
|
||||
rootInfo = LayoutImporter.ImportInfos(
|
||||
_bindings.Assets.Dats,
|
||||
Layout.JournalPanelController.HostLayoutId,
|
||||
Layout.JournalPanelController.SlotElementId);
|
||||
var resolver = new DatStringResolver(_bindings.Assets.Dats);
|
||||
layout = rootInfo is null
|
||||
? null
|
||||
: LayoutImporter.Build(
|
||||
rootInfo,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
_bindings.Assets.DefaultFont,
|
||||
_bindings.Assets.ResolveFont,
|
||||
resolver.Resolve);
|
||||
}
|
||||
if (rootInfo is null || layout is null)
|
||||
{
|
||||
Console.WriteLine(
|
||||
"[UI] journal panel: LayoutDesc 0x2100006E slot 0x10000559 not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
var rowTemplates = new Layout.RowTemplateResolver(
|
||||
(layoutId, elementId) => LayoutImporter.ImportInfos(
|
||||
_bindings.Assets.Dats, layoutId, elementId),
|
||||
info =>
|
||||
{
|
||||
var strings = new DatStringResolver(_bindings.Assets.Dats);
|
||||
return LayoutImporter.Build(
|
||||
info,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
_bindings.Assets.DefaultFont,
|
||||
_bindings.Assets.ResolveFont,
|
||||
strings.Resolve).Root;
|
||||
});
|
||||
|
||||
var callbacks = new Layout.JournalPanelController.Callbacks(
|
||||
Toggle: () => ToggleWindow(WindowNames.Journal),
|
||||
Contracts: new Layout.JournalContractsPageController.Bindings(
|
||||
Contracts: _bindings.Quests.Contracts,
|
||||
Catalog: _bindings.Quests.Catalog,
|
||||
Now: () => DateTime.UtcNow,
|
||||
TemplateResolver: (templateLayoutId, templateElementId) =>
|
||||
{
|
||||
lock (_bindings.Assets.DatLock)
|
||||
return rowTemplates.Resolve(templateLayoutId, templateElementId);
|
||||
}));
|
||||
|
||||
Layout.JournalPanelController? controller;
|
||||
lock (_bindings.Assets.DatLock)
|
||||
controller = Layout.JournalPanelController.Bind(layout, callbacks);
|
||||
if (controller is null)
|
||||
{
|
||||
Console.WriteLine("[UI] journal panel: required root did not build as UiTabPanel.");
|
||||
return;
|
||||
}
|
||||
|
||||
controller.ActivateTabs();
|
||||
JournalPanelController = controller;
|
||||
|
||||
RetailWindowHandle handle = RetailWindowFrame.Mount(
|
||||
Host.Root,
|
||||
controller.Root,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
new RetailWindowFrame.Options
|
||||
{
|
||||
WindowName = WindowNames.Journal,
|
||||
Chrome = RetailWindowChrome.NineSlice,
|
||||
Left = 230f,
|
||||
Top = 160f,
|
||||
Visible = false,
|
||||
ResizeX = false,
|
||||
ResizeY = true,
|
||||
ResizableEdges = ResizeEdges.Bottom,
|
||||
ConstrainDragToParent = true,
|
||||
ConstrainResizeToParent = true,
|
||||
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top
|
||||
| AnchorEdges.Right | AnchorEdges.Bottom,
|
||||
ContentClickThrough = false,
|
||||
DrawChromeCenter = !AuthorsFullPanelCenter(rootInfo),
|
||||
Controller = controller,
|
||||
});
|
||||
_panelUi.RegisterMainPanel(
|
||||
RetailPanelCatalog.Journal,
|
||||
WindowNames.Journal,
|
||||
handle,
|
||||
rootInfo.TryGetEffectiveBool(
|
||||
RetailPanelUiController.RestorePreviousPropertyId,
|
||||
out bool restorePrevious)
|
||||
&& restorePrevious);
|
||||
Console.WriteLine(
|
||||
"[UI] retail journal panel from LayoutDesc importer (0x2100006E slot 0x10000559).");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch C (overnight hover/UI round, 2026-08-17): the two-tab Map/House
|
||||
/// panel — host <c>0x2100006E</c> slot <c>0x1000018C</c>,
|
||||
|
|
|
|||
|
|
@ -44,4 +44,8 @@ public static class WindowNames
|
|||
/// <summary>Batch C (overnight hover/UI round): the two-tab Map/House
|
||||
/// panel (<see cref="RetailPanelCatalog.MapHouse"/>).</summary>
|
||||
public const string MapHouse = "map-house";
|
||||
|
||||
/// <summary>Campaign QT slice QT5: the three-tab Contracts/Journal/Page
|
||||
/// List panel (<see cref="RetailPanelCatalog.Journal"/>).</summary>
|
||||
public const string Journal = "journal";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,7 +61,10 @@ public static class ContractTableReader
|
|||
contract.QuestflagFinished ?? string.Empty,
|
||||
contract.QuestflagProgress ?? string.Empty,
|
||||
contract.QuestflagTimer ?? string.Empty,
|
||||
contract.QuestflagRepeatTime ?? string.Empty);
|
||||
contract.QuestflagRepeatTime ?? string.Empty,
|
||||
contract.LocationNPCStart?.CellId ?? 0u,
|
||||
contract.LocationNPCEnd?.CellId ?? 0u,
|
||||
contract.LocationQuestArea?.CellId ?? 0u);
|
||||
}
|
||||
|
||||
return new ContractCatalog(projected.ToFrozenDictionary());
|
||||
|
|
|
|||
|
|
@ -37,7 +37,17 @@ public sealed record ContractEntry(
|
|||
string QuestflagFinished,
|
||||
string QuestflagProgress,
|
||||
string QuestflagTimer,
|
||||
string QuestflagRepeatTime)
|
||||
string QuestflagRepeatTime,
|
||||
/// <summary>
|
||||
/// Landcell of the NPC who offers the contract. The panel's "Contact
|
||||
/// Location" row shows this as coordinates, or "Indoors" when the cell has
|
||||
/// no outdoor coordinates
|
||||
/// (<c>LandDefs::gid_to_lcoord</c> failing, <c>@0x0049937F</c>).
|
||||
/// </summary>
|
||||
uint LocationNpcStartCell = 0u,
|
||||
uint LocationNpcEndCell = 0u,
|
||||
/// <summary>Landcell of the quest area — the "Quest Location" row.</summary>
|
||||
uint LocationQuestAreaCell = 0u)
|
||||
{
|
||||
public static readonly ContractEntry Unknown = new(
|
||||
0u, 0u,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue