fix(combat): restore retail combat bar controls
Resolve authored StringInfo labels from local.dat, port gmCombatUI's runtime option captions and checkbox widgets, and bind horizontal scrollbar media by retail structural roles so the green power jewel is a thumb instead of a tiled track. Persist the three combat options and make Auto Target govern target acquisition. Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
parent
2215c76c7e
commit
927fa7881a
19 changed files with 24591 additions and 48 deletions
|
|
@ -497,7 +497,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar.
|
|||
- **Wave 4.4e implemented — exact missile ammo number (live gate pending).** Pure Core reproduces retail's thrown-weapon-vs-separate-ammo resolution over the ordered player equipment list and its zero-to-one count normalization. Relevant equipment/stack events update authored missile indicator `0x10000194` with the DAT font. Warning-free Release build and 4,754-pass / 5-skip suite are green; AP-101 is retired.
|
||||
- **M2 held-object parenting shipped and live-gated 2026-07-11.** The combat toggle now ports `GetDefaultCombatMode` over ordered equipped contents, so a bow requests Missile instead of the old hardcoded Melee. CreateObject preserves parent/placement/timestamp fields, `0xF749` ParentEvent is handled, and `EquippedChildRenderController` renders the weapon as a separate child composed from the animated hand part + holding frame + child placement frame. Live gate passed: bow selected missile stance, rendered in-hand, followed animation, unequipped cleanly, and melee remained correct. App Release builds with zero warnings; the full 4,765-pass / 5-skip suite is green. AP-111 is retired; research: `docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md`.
|
||||
- **M2 local attack receive funnel implemented 2026-07-11; live gate pending.** Retail `ExecuteAttack` only sends the request; ACE chooses the concrete melee/missile action and returns it in a non-autonomous mt-0 `UpdateMotion`. The local branch now runs that state through the same constructor-defaulted `MoveToInterpretedState` funnel and 15-bit action-stamp gate as remotes, then applies sticky/long-jump tails. The old local-only direct `Commands[]` replay is deleted. Shared conversion lives in `InboundInterpretedMotionFactory`; research: `docs/research/2026-07-11-local-combat-motion-pseudocode.md`.
|
||||
- **M2 basic retail combat bar implemented 2026-07-11; live visual gate pending.** Production now mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, binds authored high/medium/low buttons and desired-power slider, and routes mouse plus keyboard through one `CombatAttackController`. Matching retail x86 recovered exact 1.0-second normal and 0.8-second dual-wield power-up times; the same constants retire the guessed jump charge. Keybinding schema v3 migrates attack actions from one-shot Press to transition-emitting Hold. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`; AP-24/AP-95 retired, AP-110 narrowed, AP-112 records the remaining advanced/Recklessness and command-interpreter seams.
|
||||
- **M2 basic retail combat bar implemented 2026-07-11; corrective live visual gate pending.** Production now mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, binds authored high/medium/low buttons and desired-power slider, and routes mouse plus keyboard through one `CombatAttackController`. The corrective pass ports local.dat StringInfo labels, `gmCombatUI::PostInit` runtime option captions, interactive `UIOption_Checkbox` controls, and structural horizontal-scrollbar track/thumb roles. Matching retail x86 recovered exact 1.0-second normal and 0.8-second dual-wield power-up times; the same constants retire the guessed jump charge. Keybinding schema v3 migrates attack actions from one-shot Press to transition-emitting Hold. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`; AP-24/AP-95 retired, AP-110 narrowed, AP-112 records the remaining advanced/Recklessness and command-interpreter seams.
|
||||
- **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158.
|
||||
- **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository`→`ClientObjectTable` / `ItemInstance`→`ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green.
|
||||
- **Roadmap correction (2026-07-10):** the completion order is now the architecture-first campaign in `docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md`. Retail `gmToolbarUI` is object-only: preserve `ShortCutData.index_`, `objectID_`, and `spellID_`, but do not invent spell glyphs on this bar. `PlayerModule::favorite_spells_[8]` feeds separate spell bars.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
- `gmCombatUI::RecvNotice_AttackHeightChanged` `0x004CC080`
|
||||
- `gmCombatUI::RecvNotice_SetPowerbarLevel` `0x004CC0E0`
|
||||
- `gmCombatUI::RecvNotice_DesiredAttackPowerChanged` `0x004CC110`
|
||||
- `gmCombatUI::PostInit` `0x004CC1A0`
|
||||
- `gmCombatUI::ListenToElementMessage` `0x004CC430`
|
||||
- `gmCombatUI::RecvNotice_SetCombatMode` `0x004CC620`
|
||||
- `ClientCombatSystem::Begin` `0x0056A460`
|
||||
|
|
@ -16,6 +17,9 @@
|
|||
- `ClientCombatSystem::UseTime` `0x0056C1F0`
|
||||
- `ClientCombatSystem::HandleAttackDoneEvent` `0x0056C500`
|
||||
- `ClientCombatSystem::SetRequestedAttackHeight` `0x0056C8F0`
|
||||
- `UIOption_Checkbox::UIOption_Checkbox` `0x00486A50`
|
||||
- `UIOption_Checkbox::ListenToElementMessage` `0x004869A0`
|
||||
- `compute_str_hash` `0x00413110`
|
||||
- Matching retail executable disassembly (`acclient.exe` v11.4186 + PDB):
|
||||
`ATTACK_POWERUP_TIME = 1.0` seconds and
|
||||
`DUAL_WIELD_POWERUP_TIME = 0.8` seconds. The named pseudo-C's x87
|
||||
|
|
@ -133,3 +137,44 @@ attack power increase/decrease action:
|
|||
Both keyboard and retained buttons therefore use one transition-based attack
|
||||
controller. A one-shot click/keypress is not sufficient: down begins charging,
|
||||
up ends the request.
|
||||
|
||||
## Authored labels and option checkboxes
|
||||
|
||||
```text
|
||||
PostInit():
|
||||
repeat = child 0x10000053 as UIOption_Checkbox
|
||||
repeat.playerOption = AutoRepeatAttack
|
||||
repeat.label = StringInfo(hash("ID_CombatPanelOption_AutoRepeatAttack"))
|
||||
|
||||
autoTarget = child 0x10000054 as UIOption_Checkbox
|
||||
autoTarget.playerOption = AutoTarget
|
||||
autoTarget.label = StringInfo(hash("ID_CombatPanelOption_AutoTarget"))
|
||||
|
||||
keepInView = child 0x10000055 as UIOption_Checkbox
|
||||
keepInView.playerOption = ViewCombatTarget
|
||||
keepInView.label = StringInfo(hash("ID_CombatPanelOption_ViewCombatTarget"))
|
||||
```
|
||||
|
||||
The first two labels are deliberately not authored in LayoutDesc. They are
|
||||
assigned by `gmCombatUI::PostInit`, so a layout-only renderer must perform that
|
||||
controller binding. Ordinary Type-12 captions and High/Medium/Low use StringInfo
|
||||
property `0x17` and resolve through local.dat table `0x23000001`.
|
||||
|
||||
`UIOption_Checkbox` is a toggle `UIElement_Button`. Its visible 13x13 face is
|
||||
the authored child `0x10000328`; the label is embedded in the option object and
|
||||
starts after that face. Mouse release toggles attribute `0x0E` and immediately
|
||||
applies the associated player option.
|
||||
|
||||
## Horizontal scrollbar media roles
|
||||
|
||||
```text
|
||||
track = scrollbar DirectState image
|
||||
thumb = structural child element 1
|
||||
live power fill = nested meter child 0x10000050 / image child 2
|
||||
```
|
||||
|
||||
These roles are identified by authored element ids, never by resolved width.
|
||||
The combat thumb is a 12x14 green jewel; inheritance reflow can make its
|
||||
effective child geometry wide enough that a width heuristic incorrectly tiles
|
||||
the jewel as the background. The base track is `0x060074CA`, thumb
|
||||
`0x06001923`, and live red fill `0x06001200`.
|
||||
|
|
|
|||
|
|
@ -2127,7 +2127,9 @@ public sealed class GameWindow : IDisposable
|
|||
SetRetailUiLocked),
|
||||
Combat: new AcDream.App.UI.CombatRuntimeBindings(
|
||||
Combat,
|
||||
_combatAttackController),
|
||||
_combatAttackController,
|
||||
() => _persistedGameplay,
|
||||
SetRetailCombatGameplay),
|
||||
Toolbar: new AcDream.App.UI.ToolbarRuntimeBindings(
|
||||
Objects,
|
||||
() => Shortcuts,
|
||||
|
|
@ -11379,6 +11381,22 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
private void SetRetailCombatGameplay(
|
||||
AcDream.UI.Abstractions.Panels.Settings.GameplaySettings gameplay)
|
||||
{
|
||||
_persistedGameplay = gameplay;
|
||||
_settingsVm?.SetGameplay(gameplay);
|
||||
|
||||
try
|
||||
{
|
||||
_settingsStore?.SaveGameplay(gameplay);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"settings: combat option save failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnUiDragReleasedOutside(object payload, int x, int y)
|
||||
{
|
||||
if (payload is AcDream.App.UI.ItemDragPayload itemPayload)
|
||||
|
|
@ -11997,6 +12015,9 @@ public sealed class GameWindow : IDisposable
|
|||
if (_selection.SelectedObjectId is { } selected && IsLiveCreatureTarget(selected))
|
||||
return selected;
|
||||
|
||||
if (!_persistedGameplay.AutoTarget)
|
||||
return null;
|
||||
|
||||
return SelectClosestCombatTarget(showToast: false);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Combat;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.UI.Abstractions.Panels.Settings;
|
||||
using DatReaderWriter;
|
||||
|
||||
namespace AcDream.App.Studio;
|
||||
|
|
@ -87,6 +90,29 @@ public static class FixtureProvider
|
|||
datFont: stack.VitalsDatFont);
|
||||
break;
|
||||
|
||||
case CombatUiController.LayoutId:
|
||||
{
|
||||
var combat = new CombatState();
|
||||
var attacks = new CombatAttackController(
|
||||
combat,
|
||||
canStartAttack: () => true,
|
||||
sendAttack: (_, _) => true,
|
||||
autoRepeatAttack: () => false);
|
||||
GameplaySettings gameplay = GameplaySettings.Default;
|
||||
CombatUiController.Bind(
|
||||
layout,
|
||||
combat,
|
||||
attacks,
|
||||
() => gameplay,
|
||||
value => gameplay = value,
|
||||
new CombatUiLabels(
|
||||
"Speed", "Power", "Repeat Attacks", "Auto Target", "Keep in View",
|
||||
"High", "Medium", "Low"),
|
||||
visible => layout.Root.Visible = visible);
|
||||
combat.SetCombatMode(CombatMode.Melee);
|
||||
break;
|
||||
}
|
||||
|
||||
case 0x21000016u: // toolbar
|
||||
ToolbarController.Bind(
|
||||
layout,
|
||||
|
|
|
|||
|
|
@ -216,8 +216,10 @@ internal static class MockupDesktop
|
|||
var rootInfo = LayoutImporter.ImportInfos(dats, ChatWindowController.LayoutId);
|
||||
if (rootInfo is null) return null;
|
||||
|
||||
var strings = new DatStringResolver(dats);
|
||||
var layout = LayoutImporter.Build(rootInfo, stack.ResolveChrome, stack.VitalsDatFont,
|
||||
fontResolve: stack.ResolveDatFont);
|
||||
fontResolve: stack.ResolveDatFont,
|
||||
stringResolve: strings.Resolve);
|
||||
|
||||
var chatLog = new ChatLog();
|
||||
chatLog.SetLocalPlayerGuid(SampleData.PlayerGuid);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using AcDream.App.Combat;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.UI.Abstractions.Panels.Settings;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
|
|
@ -21,12 +22,17 @@ public sealed class CombatUiController : IRetainedPanelController
|
|||
public const uint BasicPanelId = 0x1000005Cu;
|
||||
public const uint AdvancedPanelId = 0x10000061u;
|
||||
public const uint PowerControlId = 0x1000004Fu;
|
||||
public const uint SpeedLabelId = 0x10000051u;
|
||||
public const uint PowerLabelId = 0x10000052u;
|
||||
public const uint RepeatAttacksId = 0x10000053u;
|
||||
public const uint AutoTargetId = 0x10000054u;
|
||||
public const uint KeepInViewId = 0x10000055u;
|
||||
public const uint HighButtonId = 0x10000057u;
|
||||
public const uint MediumButtonId = 0x10000058u;
|
||||
public const uint LowButtonId = 0x10000059u;
|
||||
|
||||
private const uint MeleeState = 0x10000003u;
|
||||
private const uint MissileState = 0x10000004u;
|
||||
public const uint MeleeState = 0x10000003u;
|
||||
public const uint MissileState = 0x10000004u;
|
||||
|
||||
private readonly UiElement _root;
|
||||
private readonly UiElement _basicPanel;
|
||||
|
|
@ -35,8 +41,13 @@ public sealed class CombatUiController : IRetainedPanelController
|
|||
private readonly UiButton _high;
|
||||
private readonly UiButton _medium;
|
||||
private readonly UiButton _low;
|
||||
private readonly UiButton _repeatAttacks;
|
||||
private readonly UiButton _autoTarget;
|
||||
private readonly UiButton _keepInView;
|
||||
private readonly CombatState _combat;
|
||||
private readonly CombatAttackController _attacks;
|
||||
private readonly Func<GameplaySettings> _gameplay;
|
||||
private readonly Action<GameplaySettings> _setGameplay;
|
||||
private readonly Action<bool> _setWindowVisible;
|
||||
private bool _disposed;
|
||||
|
||||
|
|
@ -48,8 +59,14 @@ public sealed class CombatUiController : IRetainedPanelController
|
|||
UiButton high,
|
||||
UiButton medium,
|
||||
UiButton low,
|
||||
UiButton repeatAttacks,
|
||||
UiButton autoTarget,
|
||||
UiButton keepInView,
|
||||
CombatState combat,
|
||||
CombatAttackController attacks,
|
||||
Func<GameplaySettings> gameplay,
|
||||
Action<GameplaySettings> setGameplay,
|
||||
CombatUiLabels labels,
|
||||
Action<bool> setWindowVisible)
|
||||
{
|
||||
_root = layout.Root;
|
||||
|
|
@ -59,8 +76,13 @@ public sealed class CombatUiController : IRetainedPanelController
|
|||
_high = high;
|
||||
_medium = medium;
|
||||
_low = low;
|
||||
_repeatAttacks = repeatAttacks;
|
||||
_autoTarget = autoTarget;
|
||||
_keepInView = keepInView;
|
||||
_combat = combat;
|
||||
_attacks = attacks;
|
||||
_gameplay = gameplay;
|
||||
_setGameplay = setGameplay;
|
||||
_setWindowVisible = setWindowVisible;
|
||||
|
||||
// PlayerModule::AdvancedCombatUI false selects gmCombatUI's authored
|
||||
|
|
@ -77,6 +99,22 @@ public sealed class CombatUiController : IRetainedPanelController
|
|||
BindAttackButton(_medium, AttackHeight.Medium);
|
||||
BindAttackButton(_low, AttackHeight.Low);
|
||||
|
||||
_high.Label = labels.High;
|
||||
_medium.Label = labels.Medium;
|
||||
_low.Label = labels.Low;
|
||||
_repeatAttacks.Label = labels.RepeatAttacks;
|
||||
_autoTarget.Label = labels.AutoTarget;
|
||||
_keepInView.Label = labels.KeepInView;
|
||||
SetStaticText(layout.FindElement(SpeedLabelId) as UiText, labels.Speed);
|
||||
SetStaticText(layout.FindElement(PowerLabelId) as UiText, labels.Power);
|
||||
|
||||
_repeatAttacks.OnClick = () =>
|
||||
_setGameplay(_gameplay() with { AutoRepeatAttack = _repeatAttacks.Selected });
|
||||
_autoTarget.OnClick = () =>
|
||||
_setGameplay(_gameplay() with { AutoTarget = _autoTarget.Selected });
|
||||
_keepInView.OnClick = () =>
|
||||
_setGameplay(_gameplay() with { ViewCombatTarget = _keepInView.Selected });
|
||||
|
||||
_combat.CombatModeChanged += OnCombatModeChanged;
|
||||
_attacks.StateChanged += OnAttackStateChanged;
|
||||
SyncControls();
|
||||
|
|
@ -86,11 +124,17 @@ public sealed class CombatUiController : IRetainedPanelController
|
|||
ImportedLayout layout,
|
||||
CombatState combat,
|
||||
CombatAttackController attacks,
|
||||
Func<GameplaySettings> gameplay,
|
||||
Action<GameplaySettings> setGameplay,
|
||||
CombatUiLabels labels,
|
||||
Action<bool> setWindowVisible)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(layout);
|
||||
ArgumentNullException.ThrowIfNull(combat);
|
||||
ArgumentNullException.ThrowIfNull(attacks);
|
||||
ArgumentNullException.ThrowIfNull(gameplay);
|
||||
ArgumentNullException.ThrowIfNull(setGameplay);
|
||||
ArgumentNullException.ThrowIfNull(labels);
|
||||
ArgumentNullException.ThrowIfNull(setWindowVisible);
|
||||
|
||||
if (layout.FindElement(BasicPanelId) is not { } basic
|
||||
|
|
@ -98,12 +142,16 @@ public sealed class CombatUiController : IRetainedPanelController
|
|||
|| layout.FindElement(PowerControlId) is not UiScrollbar power
|
||||
|| layout.FindElement(HighButtonId) is not UiButton high
|
||||
|| layout.FindElement(MediumButtonId) is not UiButton medium
|
||||
|| layout.FindElement(LowButtonId) is not UiButton low)
|
||||
|| layout.FindElement(LowButtonId) is not UiButton low
|
||||
|| layout.FindElement(RepeatAttacksId) is not UiButton repeatAttacks
|
||||
|| layout.FindElement(AutoTargetId) is not UiButton autoTarget
|
||||
|| layout.FindElement(KeepInViewId) is not UiButton keepInView)
|
||||
return null;
|
||||
|
||||
return new CombatUiController(
|
||||
layout, basic, advanced, power, high, medium, low,
|
||||
combat, attacks, setWindowVisible);
|
||||
repeatAttacks, autoTarget, keepInView,
|
||||
combat, attacks, gameplay, setGameplay, labels, setWindowVisible);
|
||||
}
|
||||
|
||||
public void SyncVisibility() => OnCombatModeChanged(_combat.CurrentMode);
|
||||
|
|
@ -136,6 +184,22 @@ public sealed class CombatUiController : IRetainedPanelController
|
|||
_high.Selected = _attacks.RequestedHeight == AttackHeight.High;
|
||||
_medium.Selected = _attacks.RequestedHeight == AttackHeight.Medium;
|
||||
_low.Selected = _attacks.RequestedHeight == AttackHeight.Low;
|
||||
GameplaySettings gameplay = _gameplay();
|
||||
_repeatAttacks.Selected = gameplay.AutoRepeatAttack;
|
||||
_autoTarget.Selected = gameplay.AutoTarget;
|
||||
_keepInView.Selected = gameplay.ViewCombatTarget;
|
||||
}
|
||||
|
||||
private static void SetStaticText(UiText? text, string value)
|
||||
{
|
||||
if (text is null) return;
|
||||
// Retail UIElement_Text starts with zero margins; these 15px-high
|
||||
// gmCombatUI captions are single authored lines. The scrollback path's
|
||||
// generic 4px inset would leave less than one glyph row and clip them all.
|
||||
text.OneLine = true;
|
||||
text.Padding = 0f;
|
||||
UiText.Line[] line = [new UiText.Line(value, text.DefaultColor)];
|
||||
text.LinesProvider = () => line;
|
||||
}
|
||||
|
||||
public void OnShown() => SyncControls();
|
||||
|
|
@ -154,5 +218,60 @@ public sealed class CombatUiController : IRetainedPanelController
|
|||
_medium.OnReleased = null;
|
||||
_low.OnPressed = null;
|
||||
_low.OnReleased = null;
|
||||
_repeatAttacks.OnClick = null;
|
||||
_autoTarget.OnClick = null;
|
||||
_keepInView.OnClick = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Localized labels assigned by retail <c>gmCombatUI::PostInit</c>.</summary>
|
||||
public sealed record CombatUiLabels(
|
||||
string Speed,
|
||||
string Power,
|
||||
string RepeatAttacks,
|
||||
string AutoTarget,
|
||||
string KeepInView,
|
||||
string High,
|
||||
string Medium,
|
||||
string Low)
|
||||
{
|
||||
private const uint UiStringTable = 0x23000001u;
|
||||
|
||||
public static CombatUiLabels Resolve(ElementInfo root, DatStringResolver strings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(root);
|
||||
ArgumentNullException.ThrowIfNull(strings);
|
||||
|
||||
return new CombatUiLabels(
|
||||
ElementString(CombatUiController.SpeedLabelId, UiStateInfo.DirectStateId, "Speed"),
|
||||
ElementString(CombatUiController.PowerLabelId, CombatUiController.MeleeState, "Power"),
|
||||
RuntimeString("ID_CombatPanelOption_AutoRepeatAttack", "Repeat Attacks"),
|
||||
RuntimeString("ID_CombatPanelOption_AutoTarget", "Auto Target"),
|
||||
RuntimeString("ID_CombatPanelOption_ViewCombatTarget", "Keep in View"),
|
||||
ElementString(CombatUiController.HighButtonId, UiStateInfo.DirectStateId, "High"),
|
||||
ElementString(CombatUiController.MediumButtonId, UiStateInfo.DirectStateId, "Medium"),
|
||||
ElementString(CombatUiController.LowButtonId, UiStateInfo.DirectStateId, "Low"));
|
||||
|
||||
string RuntimeString(string id, string fallback)
|
||||
=> strings.Resolve(UiStringTable, DatStringResolver.ComputeHash(id)) ?? fallback;
|
||||
|
||||
string ElementString(uint elementId, uint stateId, string fallback)
|
||||
{
|
||||
ElementInfo? element = Find(root, elementId);
|
||||
if (element is null
|
||||
|| !element.TryGetEffectiveProperty(0x17u, out var property, stateId)
|
||||
|| property.Kind != UiPropertyKind.StringInfo)
|
||||
return fallback;
|
||||
return strings.Resolve(property.StringInfoValue) ?? fallback;
|
||||
}
|
||||
}
|
||||
|
||||
private static ElementInfo? Find(ElementInfo element, uint id)
|
||||
{
|
||||
if (element.Id == id) return element;
|
||||
foreach (ElementInfo child in element.Children)
|
||||
if (Find(child, id) is { } found)
|
||||
return found;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
65
src/AcDream.App/UI/Layout/DatStringResolver.cs
Normal file
65
src/AcDream.App/UI/Layout/DatStringResolver.cs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves retail <c>StringInfo</c> values through local.dat string tables.
|
||||
/// The caller owns synchronization around <see cref="DatCollection"/> reads.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Retail reference: <c>StringInfo::GetString</c> and
|
||||
/// <c>compute_str_hash @ 0x00413110</c>. A StringInfo's token selects one
|
||||
/// localized string variant; ordinary UI labels use token zero.
|
||||
/// </remarks>
|
||||
public sealed class DatStringResolver
|
||||
{
|
||||
private readonly DatCollection _dats;
|
||||
private readonly Dictionary<uint, StringTable?> _tables = new();
|
||||
|
||||
public DatStringResolver(DatCollection dats)
|
||||
=> _dats = dats ?? throw new ArgumentNullException(nameof(dats));
|
||||
|
||||
public string? Resolve(UiStringInfoValue info)
|
||||
=> Resolve(info.TableId, info.StringId, info.Token);
|
||||
|
||||
public string? Resolve(uint tableId, uint stringId, int token = 0)
|
||||
{
|
||||
if (tableId == 0u || stringId == 0u)
|
||||
return null;
|
||||
|
||||
if (!_tables.TryGetValue(tableId, out StringTable? table))
|
||||
{
|
||||
table = _dats.Get<StringTable>(tableId);
|
||||
_tables[tableId] = table;
|
||||
}
|
||||
|
||||
if (table is null
|
||||
|| !table.Strings.TryGetValue(stringId, out var entry)
|
||||
|| entry.Strings.Count == 0)
|
||||
return null;
|
||||
|
||||
int index = token >= 0 && token < entry.Strings.Count ? token : 0;
|
||||
return entry.Strings[index].Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exact retail ELF-style string hash used for StringInfo keys.
|
||||
/// Ported line-for-line from <c>compute_str_hash @ 0x00413110</c>.
|
||||
/// </summary>
|
||||
public static uint ComputeHash(string value)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
|
||||
uint result = 0u;
|
||||
foreach (char c in value)
|
||||
{
|
||||
result = unchecked((result << 4) + (byte)c);
|
||||
uint high = result & 0xF0000000u;
|
||||
if (high != 0u)
|
||||
result = ((high >> 24) ^ result) & 0x0FFFFFFFu;
|
||||
}
|
||||
|
||||
return result == uint.MaxValue ? uint.MaxValue - 1u : result;
|
||||
}
|
||||
}
|
||||
|
|
@ -52,7 +52,8 @@ public static class DatWidgetFactory
|
|||
/// <returns>The widget for this element. Never null — every type produces a widget.</returns>
|
||||
public static UiElement? Create(ElementInfo info,
|
||||
Func<uint, (uint, int, int)> resolve, UiDatFont? datFont,
|
||||
Func<uint, UiDatFont?>? fontResolve = null)
|
||||
Func<uint, UiDatFont?>? fontResolve = null,
|
||||
Func<UiStringInfoValue, string?>? stringResolve = null)
|
||||
{
|
||||
// Retail Type 3 = UIElement_Field (reg :126190), but in acdream's CURRENT layouts
|
||||
// (vitals 0x2100006C / chat 0x21000006) Type-3 elements are sprite-bearing chrome +
|
||||
|
|
@ -75,13 +76,14 @@ public static class DatWidgetFactory
|
|||
UiElement e = info.Type switch
|
||||
{
|
||||
UiRadar.RetailClassId => new UiRadar(), // gmRadarUI (Register 0x004D8B80)
|
||||
1 => new UiButton(info, resolve), // UIElement_Button (reg :125828)
|
||||
1 => BuildButton(info, resolve, elementFont, stringResolve), // UIElement_Button
|
||||
6 => new UiMenu(), // UIElement_Menu (reg :120163)
|
||||
7 => BuildMeter(info, resolve, elementFont), // UIElement_Meter
|
||||
0xD => new UiViewport(), // UIElement_Viewport — 3-D mini-scene blit leaf
|
||||
11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar (reg :124137)
|
||||
12 => BuildText(info, resolve, elementFont), // UIElement_Text (reg :115655)
|
||||
12 => BuildText(info, resolve, elementFont, stringResolve), // UIElement_Text
|
||||
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)
|
||||
};
|
||||
|
||||
|
|
@ -147,20 +149,22 @@ public static class DatWidgetFactory
|
|||
|
||||
if (bar.Horizontal)
|
||||
{
|
||||
// gmToolbarUI stack slider (0x100001A4) has two direct Type-3 media
|
||||
// children: a full-width 90x14 track (element 4) and a 16x14 thumb
|
||||
// (element 1). Their geometry, not a button class, defines the roles.
|
||||
ElementInfo? track = info.Children
|
||||
.Where(child => DefaultImage(child) != 0u)
|
||||
.OrderByDescending(child => child.Width)
|
||||
.FirstOrDefault();
|
||||
ElementInfo? scalarThumb = info.Children
|
||||
.Where(child => !ReferenceEquals(child, track) && DefaultImage(child) != 0u)
|
||||
.OrderBy(child => child.Width)
|
||||
.FirstOrDefault();
|
||||
bar.TrackSprite = track is null ? 0u : DefaultImage(track);
|
||||
// Retail horizontal scrollbars use structural child ids: element 1 is
|
||||
// the thumb and element 4 is the optional child-authored track.
|
||||
ElementInfo? scalarThumb = info.Children.FirstOrDefault(child => child.Id == 1u);
|
||||
bar.TrackSprite = DefaultImage(info);
|
||||
bar.ThumbSprite = scalarThumb is null ? 0u : DefaultImage(scalarThumb);
|
||||
|
||||
// The toolbar stack slider authors its track on structural child 4,
|
||||
// while gmCombatUI authors it on the scrollbar's DirectState. Geometry
|
||||
// is never the role discriminator: inheritance can reflow child 1 and
|
||||
// otherwise turn the 12px combat jewel into a tiled background.
|
||||
if (bar.TrackSprite == 0u)
|
||||
{
|
||||
ElementInfo? authoredTrack = info.Children.FirstOrDefault(child => child.Id == 4u);
|
||||
bar.TrackSprite = authoredTrack is null ? 0u : DefaultImage(authoredTrack);
|
||||
}
|
||||
|
||||
// gmCombatUI's desired-power slider (0x1000004F) authors the
|
||||
// live charge as a nested Type-7 meter. UiScrollbar consumes its
|
||||
// DAT children, so retain the meter's fill image on the scalar
|
||||
|
|
@ -390,7 +394,8 @@ public static class DatWidgetFactory
|
|||
/// <see cref="ImportedLayout.FindElement"/> and set <see cref="UiText.DatFont"/> afterward
|
||||
/// still override this — the build-time value is just the starting point.</param>
|
||||
private static UiElement BuildText(ElementInfo info, Func<uint, (uint, int, int)> resolve,
|
||||
UiDatFont? elementFont = null)
|
||||
UiDatFont? elementFont = null,
|
||||
Func<UiStringInfoValue, string?>? stringResolve = null)
|
||||
{
|
||||
uint bg = info.StateMedia.TryGetValue(
|
||||
!string.IsNullOrEmpty(info.DefaultStateName) ? info.DefaultStateName
|
||||
|
|
@ -464,6 +469,67 @@ public static class DatWidgetFactory
|
|||
if (info.FontColor.HasValue)
|
||||
t.DefaultColor = info.FontColor.Value;
|
||||
|
||||
if (ResolveAuthoredString(info, stringResolve) is { Length: > 0 } authored)
|
||||
{
|
||||
UiText.Line[] line = [new UiText.Line(authored, t.DefaultColor)];
|
||||
t.LinesProvider = () => line;
|
||||
}
|
||||
|
||||
return t;
|
||||
}
|
||||
|
||||
private static UiButton BuildButton(
|
||||
ElementInfo info,
|
||||
Func<uint, (uint, int, int)> resolve,
|
||||
UiDatFont? elementFont,
|
||||
Func<UiStringInfoValue, string?>? stringResolve)
|
||||
=> new(info, resolve)
|
||||
{
|
||||
Label = ResolveAuthoredString(info, stringResolve),
|
||||
LabelFont = elementFont,
|
||||
LabelColor = info.FontColor ?? System.Numerics.Vector4.One,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Retail UIOption_Checkbox is a UIElement_Button whose visible face is its
|
||||
/// authored indicator child. Its label lives on the option object rather than
|
||||
/// in a child UIElement_Text.
|
||||
/// </summary>
|
||||
private static UiButton BuildCheckbox(
|
||||
ElementInfo info,
|
||||
Func<uint, (uint, int, int)> resolve,
|
||||
UiDatFont? elementFont,
|
||||
Func<UiStringInfoValue, string?>? stringResolve)
|
||||
{
|
||||
ElementInfo? indicator = info.Children.FirstOrDefault(child => DefaultImage(child) != 0u);
|
||||
var button = new UiButton(info, resolve, indicator)
|
||||
{
|
||||
Label = ResolveAuthoredString(info, stringResolve),
|
||||
LabelFont = elementFont,
|
||||
LabelColor = info.FontColor ?? System.Numerics.Vector4.One,
|
||||
LabelAlign = UiButton.LabelAlignment.Left,
|
||||
};
|
||||
|
||||
if (indicator is not null)
|
||||
{
|
||||
button.FaceLeft = indicator.X;
|
||||
button.FaceTop = indicator.Y;
|
||||
button.FaceWidth = indicator.Width;
|
||||
button.FaceHeight = indicator.Height;
|
||||
button.LabelOffsetX = indicator.X + indicator.Width + 4f;
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
private static string? ResolveAuthoredString(
|
||||
ElementInfo info,
|
||||
Func<UiStringInfoValue, string?>? stringResolve)
|
||||
{
|
||||
if (stringResolve is null
|
||||
|| !info.TryGetEffectiveProperty(0x17u, out var property)
|
||||
|| property.Kind != UiPropertyKind.StringInfo)
|
||||
return null;
|
||||
return stringResolve(property.StringInfoValue);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,10 +69,11 @@ public static class LayoutImporter
|
|||
IEnumerable<ElementInfo> children,
|
||||
Func<uint, (uint, int, int)> resolve,
|
||||
UiDatFont? datFont,
|
||||
Func<uint, UiDatFont?>? fontResolve = null)
|
||||
Func<uint, UiDatFont?>? fontResolve = null,
|
||||
Func<UiStringInfoValue, string?>? stringResolve = null)
|
||||
{
|
||||
rootInfo.Children = new List<ElementInfo>(children);
|
||||
return Build(rootInfo, resolve, datFont, fontResolve);
|
||||
return Build(rootInfo, resolve, datFont, fontResolve, stringResolve);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -89,12 +90,13 @@ public static class LayoutImporter
|
|||
ElementInfo rootInfo,
|
||||
Func<uint, (uint, int, int)> resolve,
|
||||
UiDatFont? datFont,
|
||||
Func<uint, UiDatFont?>? fontResolve = null)
|
||||
Func<uint, UiDatFont?>? fontResolve = null,
|
||||
Func<UiStringInfoValue, string?>? stringResolve = null)
|
||||
{
|
||||
var byId = new Dictionary<uint, UiElement>();
|
||||
// Root is never a Type-12 prototype in practice; fall back to a generic
|
||||
// container if the factory returns null for an exotic root type.
|
||||
var root = BuildWidget(rootInfo, resolve, datFont, fontResolve, byId);
|
||||
var root = BuildWidget(rootInfo, resolve, datFont, fontResolve, stringResolve, byId);
|
||||
if (root is null)
|
||||
{
|
||||
Console.WriteLine($"[D.2b] LayoutImporter: root element 0x{rootInfo.Id:X8} (type {rootInfo.Type}) produced no widget — using empty container fallback.");
|
||||
|
|
@ -108,9 +110,10 @@ public static class LayoutImporter
|
|||
Func<uint, (uint, int, int)> resolve,
|
||||
UiDatFont? datFont,
|
||||
Func<uint, UiDatFont?>? fontResolve,
|
||||
Func<UiStringInfoValue, string?>? stringResolve,
|
||||
Dictionary<uint, UiElement> byId)
|
||||
{
|
||||
var w = DatWidgetFactory.Create(info, resolve, datFont, fontResolve);
|
||||
var w = DatWidgetFactory.Create(info, resolve, datFont, fontResolve, stringResolve);
|
||||
if (w is null) return null; // Type-12 style prototype — skip
|
||||
|
||||
if (info.Id != 0) byId[info.Id] = w;
|
||||
|
|
@ -126,7 +129,7 @@ public static class LayoutImporter
|
|||
{
|
||||
foreach (var child in info.Children)
|
||||
{
|
||||
var cw = BuildWidget(child, resolve, datFont, fontResolve, byId);
|
||||
var cw = BuildWidget(child, resolve, datFont, fontResolve, stringResolve, byId);
|
||||
if (cw is not null) w.AddChild(cw);
|
||||
}
|
||||
}
|
||||
|
|
@ -152,7 +155,7 @@ public static class LayoutImporter
|
|||
foreach (var child in info.Children)
|
||||
{
|
||||
if (child.Type == 3) continue; // slice containers: already consumed by BuildMeter
|
||||
var cw = BuildWidget(child, resolve, datFont, fontResolve, byId);
|
||||
var cw = BuildWidget(child, resolve, datFont, fontResolve, stringResolve, byId);
|
||||
if (cw is not null) w.AddChild(cw);
|
||||
}
|
||||
}
|
||||
|
|
@ -253,7 +256,8 @@ public static class LayoutImporter
|
|||
{
|
||||
var rootInfo = ImportInfos(dats, layoutId);
|
||||
if (rootInfo is null) return null;
|
||||
return Build(rootInfo, resolve, datFont, fontResolve);
|
||||
var strings = new DatStringResolver(dats);
|
||||
return Build(rootInfo, resolve, datFont, fontResolve, strings.Resolve);
|
||||
}
|
||||
|
||||
// ── Inheritance resolution ────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -38,7 +38,9 @@ public sealed record RadarRuntimeBindings(
|
|||
|
||||
public sealed record CombatRuntimeBindings(
|
||||
CombatState State,
|
||||
CombatAttackController Attacks);
|
||||
CombatAttackController Attacks,
|
||||
Func<GameplaySettings> Gameplay,
|
||||
Action<GameplaySettings> SetGameplay);
|
||||
|
||||
public sealed record ToolbarRuntimeBindings(
|
||||
ClientObjectTable Objects,
|
||||
|
|
@ -325,11 +327,13 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
lock (_bindings.Assets.DatLock)
|
||||
{
|
||||
info = LayoutImporter.ImportInfos(_bindings.Assets.Dats, ChatWindowController.LayoutId);
|
||||
var strings = new DatStringResolver(_bindings.Assets.Dats);
|
||||
layout = info is null ? null : LayoutImporter.Build(
|
||||
info,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
_bindings.Assets.DefaultFont,
|
||||
_bindings.Assets.ResolveFont);
|
||||
_bindings.Assets.ResolveFont,
|
||||
strings.Resolve);
|
||||
}
|
||||
if (info is null || layout is null)
|
||||
{
|
||||
|
|
@ -449,8 +453,22 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
|
||||
private void MountCombat()
|
||||
{
|
||||
ImportedLayout? layout = Import(CombatUiController.LayoutId);
|
||||
if (layout is null)
|
||||
ElementInfo? info;
|
||||
ImportedLayout? layout;
|
||||
CombatUiLabels? labels;
|
||||
lock (_bindings.Assets.DatLock)
|
||||
{
|
||||
info = LayoutImporter.ImportInfos(_bindings.Assets.Dats, CombatUiController.LayoutId);
|
||||
var strings = new DatStringResolver(_bindings.Assets.Dats);
|
||||
layout = info is null ? null : LayoutImporter.Build(
|
||||
info,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
_bindings.Assets.DefaultFont,
|
||||
_bindings.Assets.ResolveFont,
|
||||
strings.Resolve);
|
||||
labels = info is null ? null : CombatUiLabels.Resolve(info, strings);
|
||||
}
|
||||
if (layout is null || labels is null)
|
||||
{
|
||||
Console.WriteLine("[M2] combat: LayoutDesc 0x21000073 not found.");
|
||||
return;
|
||||
|
|
@ -460,6 +478,9 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
layout,
|
||||
_bindings.Combat.State,
|
||||
_bindings.Combat.Attacks,
|
||||
_bindings.Combat.Gameplay,
|
||||
_bindings.Combat.SetGameplay,
|
||||
labels,
|
||||
visible =>
|
||||
{
|
||||
if (visible) Host.ShowWindow(WindowNames.Combat);
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ namespace AcDream.App.UI;
|
|||
public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
||||
{
|
||||
private readonly ElementInfo _info;
|
||||
private readonly ElementInfo _mediaInfo;
|
||||
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
|
||||
private readonly HashSet<uint> _availableStates = new();
|
||||
private bool _pressed;
|
||||
|
|
@ -86,6 +87,16 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
/// <summary>Label color (default white).</summary>
|
||||
public Vector4 LabelColor { get; set; } = Vector4.One;
|
||||
|
||||
/// <summary>Optional authored face rectangle. Full-button by default; retail
|
||||
/// UIOption_Checkbox uses its 13x13 indicator child as the button face.</summary>
|
||||
public float FaceLeft { get; set; }
|
||||
public float FaceTop { get; set; }
|
||||
public float FaceWidth { get; set; }
|
||||
public float FaceHeight { get; set; }
|
||||
|
||||
/// <summary>Additional left inset for left-aligned labels.</summary>
|
||||
public float LabelOffsetX { get; set; } = 3f;
|
||||
|
||||
/// <summary>Horizontal alignment of <see cref="Label"/>. Center (default) for normal buttons;
|
||||
/// Left for the paperdoll "Slots" caption that sits at the left edge, before the slots.</summary>
|
||||
public LabelAlignment LabelAlign { get; set; } = LabelAlignment.Center;
|
||||
|
|
@ -122,7 +133,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
{
|
||||
if (string.IsNullOrEmpty(ActiveState))
|
||||
return UiStateInfo.DirectStateId;
|
||||
foreach (var (id, state) in _info.States)
|
||||
foreach (var (id, state) in _mediaInfo.States)
|
||||
if (string.Equals(state.Name, ActiveState, StringComparison.Ordinal))
|
||||
return id;
|
||||
return UiButtonStateMachine.TryStateId(ActiveState, out uint standard)
|
||||
|
|
@ -164,12 +175,12 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
|
||||
if (stateId == UiStateInfo.DirectStateId)
|
||||
{
|
||||
if (!_info.States.ContainsKey(stateId) && !_info.StateMedia.ContainsKey(""))
|
||||
if (!_mediaInfo.States.ContainsKey(stateId) && !_mediaInfo.StateMedia.ContainsKey(""))
|
||||
return false;
|
||||
ActiveState = "";
|
||||
return true;
|
||||
}
|
||||
if (_info.States.TryGetValue(stateId, out var state))
|
||||
if (_mediaInfo.States.TryGetValue(stateId, out var state))
|
||||
{
|
||||
ActiveState = state.Name;
|
||||
return true;
|
||||
|
|
@ -177,7 +188,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
string stateName = UiButtonStateMachine.StateName(stateId);
|
||||
if (string.IsNullOrEmpty(stateName))
|
||||
stateName = RetailUiStateIds.StateName(stateId);
|
||||
if (!string.IsNullOrEmpty(stateName) && _info.StateMedia.ContainsKey(stateName))
|
||||
if (!string.IsNullOrEmpty(stateName) && _mediaInfo.StateMedia.ContainsKey(stateName))
|
||||
{
|
||||
ActiveState = stateName;
|
||||
return true;
|
||||
|
|
@ -188,9 +199,13 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
/// <param name="info">Merged <see cref="ElementInfo"/> for this element.</param>
|
||||
/// <param name="resolve">Dat file-id → (GL texture handle, native px width, native px height).
|
||||
/// Returns (0,0,0) when the texture is not yet uploaded.</param>
|
||||
public UiButton(ElementInfo info, Func<uint, (uint tex, int w, int h)> resolve)
|
||||
public UiButton(
|
||||
ElementInfo info,
|
||||
Func<uint, (uint tex, int w, int h)> resolve,
|
||||
ElementInfo? mediaInfo = null)
|
||||
{
|
||||
_info = info;
|
||||
_mediaInfo = mediaInfo ?? info;
|
||||
_resolve = resolve;
|
||||
ClickThrough = false; // buttons are interactive — opt OUT of click-through
|
||||
|
||||
|
|
@ -198,7 +213,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
// Retail layouts commonly declare an empty Normal_pressed descriptor while
|
||||
// supplying art only for Normal/Highlight. Treating that property-only state
|
||||
// as drawable briefly blanks the button during mouse-down.
|
||||
foreach (string stateName in info.StateMedia.Keys)
|
||||
foreach (string stateName in _mediaInfo.StateMedia.Keys)
|
||||
if (UiButtonStateMachine.TryStateId(stateName, out uint stateId))
|
||||
_availableStates.Add(stateId);
|
||||
|
||||
|
|
@ -218,11 +233,13 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
// DefaultStateName wins; else "Normal" if that state has a sprite; else DirectState ("").
|
||||
if (!string.IsNullOrEmpty(info.DefaultStateName))
|
||||
ActiveState = info.DefaultStateName;
|
||||
else if (info.StateMedia.ContainsKey("Normal"))
|
||||
else if (_mediaInfo.StateMedia.ContainsKey("Normal"))
|
||||
ActiveState = "Normal";
|
||||
// else ActiveState stays "" (DirectState)
|
||||
|
||||
Enabled = !disabled;
|
||||
FaceWidth = mediaInfo?.Width ?? info.Width;
|
||||
FaceHeight = mediaInfo?.Height ?? info.Height;
|
||||
UpdateVisualState();
|
||||
}
|
||||
|
||||
|
|
@ -242,8 +259,8 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
/// Mirrors <see cref="UiDatElement.ActiveMedia()"/>.
|
||||
/// </summary>
|
||||
private uint ActiveFile()
|
||||
=> _info.StateMedia.TryGetValue(ActiveState, out var m) ? m.File
|
||||
: _info.StateMedia.TryGetValue("", out var d) ? d.File : 0u;
|
||||
=> _mediaInfo.StateMedia.TryGetValue(ActiveState, out var m) ? m.File
|
||||
: _mediaInfo.StateMedia.TryGetValue("", out var d) ? d.File : 0u;
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
|
|
@ -255,14 +272,17 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
{
|
||||
// Tiled draw — same call shape as UiDatElement.OnDraw (UV-repeat; GL_REPEAT-wrapped
|
||||
// UI texture). Matches ImgTex::TileCSI; no Stretch mode exists.
|
||||
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
|
||||
float faceWidth = FaceWidth > 0f ? FaceWidth : Width;
|
||||
float faceHeight = FaceHeight > 0f ? FaceHeight : Height;
|
||||
ctx.DrawSprite(tex, FaceLeft, FaceTop, faceWidth, faceHeight,
|
||||
0, 0, faceWidth / tw, faceHeight / th, Vector4.One);
|
||||
}
|
||||
}
|
||||
|
||||
if (Label is { Length: > 0 } label && LabelFont is { } lf)
|
||||
{
|
||||
float tx = LabelAlign == LabelAlignment.Left
|
||||
? 3f // small left pad (room for a future checkbox box)
|
||||
? LabelOffsetX
|
||||
: (Width - lf.MeasureWidth(label)) * 0.5f; // centered (default)
|
||||
float ty = (Height - lf.LineHeight) * 0.5f;
|
||||
ctx.DrawStringDat(lf, label, tx, ty, LabelColor);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ public sealed record GameplaySettings(
|
|||
// CharacterOption (32-bit) subset — most-used gameplay toggles.
|
||||
bool AutoTarget, // 0x2000 — combat: auto-acquire target on attack
|
||||
bool AutoRepeatAttack, // 0x2 — combat: keep attacking after first hit
|
||||
bool ViewCombatTarget, // 0x80 — keep the current combat target in view
|
||||
bool ToggleRun, // 0x400 — run-mode is tap-once vs hold-to-run
|
||||
bool AdvancedCombatUI, // 0x1000 — show extra combat tooltips/panels
|
||||
bool ShowTooltips, // 0x100 — show item tooltips on hover
|
||||
|
|
@ -46,6 +47,7 @@ public sealed record GameplaySettings(
|
|||
public static GameplaySettings Default { get; } = new(
|
||||
AutoTarget: true,
|
||||
AutoRepeatAttack: true,
|
||||
ViewCombatTarget: true,
|
||||
ToggleRun: true,
|
||||
AdvancedCombatUI: false,
|
||||
ShowTooltips: true,
|
||||
|
|
|
|||
|
|
@ -306,6 +306,10 @@ public sealed class SettingsPanel : IPanel
|
|||
if (renderer.Checkbox("Auto-repeat attacks", ref autoRepeat))
|
||||
_vm.SetGameplay(g with { AutoRepeatAttack = autoRepeat });
|
||||
|
||||
bool viewCombatTarget = g.ViewCombatTarget;
|
||||
if (renderer.Checkbox("Keep combat target in view", ref viewCombatTarget))
|
||||
_vm.SetGameplay(g with { ViewCombatTarget = viewCombatTarget });
|
||||
|
||||
bool toggleRun = g.ToggleRun;
|
||||
if (renderer.Checkbox("Run mode is toggle (vs hold)", ref toggleRun))
|
||||
_vm.SetGameplay(g with { ToggleRun = toggleRun });
|
||||
|
|
|
|||
|
|
@ -150,6 +150,7 @@ public sealed class SettingsStore
|
|||
return new GameplaySettings(
|
||||
AutoTarget: ReadBool(gp, "autoTarget", d.AutoTarget),
|
||||
AutoRepeatAttack: ReadBool(gp, "autoRepeatAttack", d.AutoRepeatAttack),
|
||||
ViewCombatTarget: ReadBool(gp, "viewCombatTarget", d.ViewCombatTarget),
|
||||
ToggleRun: ReadBool(gp, "toggleRun", d.ToggleRun),
|
||||
AdvancedCombatUI: ReadBool(gp, "advancedCombatUI", d.AdvancedCombatUI),
|
||||
ShowTooltips: ReadBool(gp, "showTooltips", d.ShowTooltips),
|
||||
|
|
@ -483,6 +484,7 @@ public sealed class SettingsStore
|
|||
["allowGive"] = g.AllowGive,
|
||||
["autoRepeatAttack"] = g.AutoRepeatAttack,
|
||||
["autoTarget"] = g.AutoTarget,
|
||||
["viewCombatTarget"] = g.ViewCombatTarget,
|
||||
["coordinatesOnRadar"] = g.CoordinatesOnRadar,
|
||||
["lockUI"] = g.LockUI,
|
||||
["showCloak"] = g.ShowCloak,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public sealed class CombatLayoutConformanceTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("ID_CombatPanelOption_AutoRepeatAttack", 0x0718F89Bu)]
|
||||
[InlineData("ID_CombatPanelOption_AutoTarget", 0x0FB7B944u)]
|
||||
[InlineData("ID_CombatPanelOption_ViewCombatTarget", 0x0936C254u)]
|
||||
public void RetailStringHash_MatchesNamedDecompGolden(string value, uint expected)
|
||||
=> Assert.Equal(expected, DatStringResolver.ComputeHash(value));
|
||||
|
||||
[Fact]
|
||||
public void RetailFixture_UsesAuthoredScrollbarRolesAndCheckboxWidgets()
|
||||
{
|
||||
ElementInfo info = FixtureLoader.LoadCombatInfos();
|
||||
ImportedLayout layout = LayoutImporter.Build(
|
||||
info,
|
||||
_ => (0u, 0, 0),
|
||||
datFont: null,
|
||||
stringResolve: _ => "localized");
|
||||
|
||||
var power = Assert.IsType<UiScrollbar>(
|
||||
layout.FindElement(CombatUiController.PowerControlId));
|
||||
Assert.Equal(0x060074CAu, power.TrackSprite);
|
||||
Assert.Equal(0x06001923u, power.ThumbSprite);
|
||||
Assert.Equal(0x06001200u, power.ScalarFillSprite);
|
||||
|
||||
var repeat = Assert.IsType<UiButton>(
|
||||
layout.FindElement(CombatUiController.RepeatAttacksId));
|
||||
Assert.Equal(13f, repeat.FaceWidth);
|
||||
Assert.Equal(13f, repeat.FaceHeight);
|
||||
Assert.Equal(17f, repeat.LabelOffsetX);
|
||||
Assert.True(repeat.ToggleBehavior);
|
||||
|
||||
var high = Assert.IsType<UiButton>(
|
||||
layout.FindElement(CombatUiController.HighButtonId));
|
||||
Assert.Equal("localized", high.Label);
|
||||
Assert.IsType<UiText>(layout.FindElement(CombatUiController.SpeedLabelId));
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ using AcDream.App.Combat;
|
|||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.UI.Abstractions.Panels.Settings;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
|
|
@ -17,8 +18,10 @@ public sealed class CombatUiControllerTests
|
|||
using var attacks = CreateAttacks(combat, () => now, []);
|
||||
var (layout, _, _, power, high, medium, low) = BuildLayout();
|
||||
var visibility = new List<bool>();
|
||||
GameplaySettings gameplay = GameplaySettings.Default;
|
||||
using var controller = CombatUiController.Bind(
|
||||
layout, combat, attacks, visibility.Add)!;
|
||||
layout, combat, attacks, () => gameplay, value => gameplay = value,
|
||||
Labels, visibility.Add)!;
|
||||
|
||||
controller.SyncVisibility();
|
||||
combat.SetCombatMode(CombatMode.Melee);
|
||||
|
|
@ -39,8 +42,10 @@ public sealed class CombatUiControllerTests
|
|||
var combat = new CombatState();
|
||||
using var attacks = CreateAttacks(combat, () => now, sent);
|
||||
var (layout, basic, advanced, power, high, _, _) = BuildLayout();
|
||||
GameplaySettings gameplay = GameplaySettings.Default;
|
||||
using var controller = CombatUiController.Bind(
|
||||
layout, combat, attacks, _ => { })!;
|
||||
layout, combat, attacks, () => gameplay, value => gameplay = value,
|
||||
Labels, _ => { })!;
|
||||
combat.SetCombatMode(CombatMode.Melee);
|
||||
|
||||
high.OnEvent(new UiEvent(0, high, UiEventType.MouseDown, Data1: 2, Data2: 2));
|
||||
|
|
@ -55,6 +60,29 @@ public sealed class CombatUiControllerTests
|
|||
Assert.False(advanced.Visible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthoredOptionCheckbox_TogglesPersistedGameplayValue()
|
||||
{
|
||||
var combat = new CombatState();
|
||||
using var attacks = CreateAttacks(combat, () => 0d, []);
|
||||
var (layout, _, _, _, _, _, _) = BuildLayout();
|
||||
GameplaySettings gameplay = GameplaySettings.Default;
|
||||
using var controller = CombatUiController.Bind(
|
||||
layout, combat, attacks, () => gameplay, value => gameplay = value,
|
||||
Labels, _ => { })!;
|
||||
var autoTarget = Assert.IsType<UiButton>(layout.FindElement(CombatUiController.AutoTargetId));
|
||||
|
||||
autoTarget.OnEvent(new UiEvent(0, autoTarget, UiEventType.MouseDown, Data1: 3, Data2: 3));
|
||||
autoTarget.OnEvent(new UiEvent(0, autoTarget, UiEventType.MouseUp, Data1: 3, Data2: 3));
|
||||
autoTarget.OnEvent(new UiEvent(0, autoTarget, UiEventType.Click, Data1: 3, Data2: 3));
|
||||
|
||||
Assert.False(gameplay.AutoTarget);
|
||||
}
|
||||
|
||||
private static readonly CombatUiLabels Labels = new(
|
||||
"Speed", "Power", "Repeat Attacks", "Auto Target", "Keep in View",
|
||||
"High", "Medium", "Low");
|
||||
|
||||
private static CombatAttackController CreateAttacks(
|
||||
CombatState combat,
|
||||
Func<double> now,
|
||||
|
|
@ -89,9 +117,21 @@ public sealed class CombatUiControllerTests
|
|||
Width = 72,
|
||||
Height = 19,
|
||||
};
|
||||
UiButton ToggleButton(uint id)
|
||||
{
|
||||
var info = new ElementInfo { Id = id, Type = 0x10000035u, Width = 100, Height = 14 };
|
||||
var state = new UiStateInfo { Id = UiStateInfo.DirectStateId };
|
||||
state.Properties.Values[0x0Bu] = new UiPropertyValue
|
||||
{ Kind = UiPropertyKind.Bool, BoolValue = true };
|
||||
info.States[UiStateInfo.DirectStateId] = state;
|
||||
return new UiButton(info, NoTex) { Width = 100, Height = 14 };
|
||||
}
|
||||
var high = Button(CombatUiController.HighButtonId);
|
||||
var medium = Button(CombatUiController.MediumButtonId);
|
||||
var low = Button(CombatUiController.LowButtonId);
|
||||
var repeat = ToggleButton(CombatUiController.RepeatAttacksId);
|
||||
var autoTarget = ToggleButton(CombatUiController.AutoTargetId);
|
||||
var keepInView = ToggleButton(CombatUiController.KeepInViewId);
|
||||
var byId = new Dictionary<uint, UiElement>
|
||||
{
|
||||
[CombatUiController.BasicPanelId] = basic,
|
||||
|
|
@ -100,6 +140,9 @@ public sealed class CombatUiControllerTests
|
|||
[CombatUiController.HighButtonId] = high,
|
||||
[CombatUiController.MediumButtonId] = medium,
|
||||
[CombatUiController.LowButtonId] = low,
|
||||
[CombatUiController.RepeatAttacksId] = repeat,
|
||||
[CombatUiController.AutoTargetId] = autoTarget,
|
||||
[CombatUiController.KeepInViewId] = keepInView,
|
||||
};
|
||||
return (new ImportedLayout(root, byId), basic, advanced, power, high, medium, low);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,6 +89,12 @@ public static class FixtureLoader
|
|||
public static ElementInfo LoadCharacterInfos()
|
||||
=> LoadInfos("character_2100002E.json");
|
||||
|
||||
public static ImportedLayout LoadCombat()
|
||||
=> LayoutImporter.Build(LoadCombatInfos(), _ => (0u, 0, 0), null);
|
||||
|
||||
public static ElementInfo LoadCombatInfos()
|
||||
=> LoadInfos("combat_21000073.json");
|
||||
|
||||
// ── Shared loader ────────────────────────────────────────────────────────
|
||||
|
||||
private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ public sealed class RetailLayoutFixtureGenerator
|
|||
(0x21000024u, "paperdoll_21000024.json"),
|
||||
(0x2100002Eu, "character_2100002E.json"),
|
||||
(0x2100006Cu, "vitals_2100006C.json"),
|
||||
(0x21000073u, "combat_21000073.json"),
|
||||
(0x21000074u, "radar_21000074.json"),
|
||||
};
|
||||
|
||||
|
|
@ -46,6 +47,18 @@ public sealed class RetailLayoutFixtureGenerator
|
|||
{
|
||||
var info = LayoutImporter.ImportInfos(dats, layoutId);
|
||||
Assert.NotNull(info);
|
||||
if (layoutId == CombatUiController.LayoutId)
|
||||
{
|
||||
var labels = CombatUiLabels.Resolve(info, new DatStringResolver(dats));
|
||||
Assert.Equal("Speed", labels.Speed);
|
||||
Assert.Equal("Power", labels.Power);
|
||||
Assert.Equal("Repeat Attacks", labels.RepeatAttacks);
|
||||
Assert.Equal("Auto Target", labels.AutoTarget);
|
||||
Assert.Equal("Keep in View", labels.KeepInView);
|
||||
Assert.Equal("High", labels.High);
|
||||
Assert.Equal("Medium", labels.Medium);
|
||||
Assert.Equal("Low", labels.Low);
|
||||
}
|
||||
var json = JsonSerializer.Serialize(info, new JsonSerializerOptions
|
||||
{
|
||||
IncludeFields = true,
|
||||
|
|
|
|||
24041
tests/AcDream.App.Tests/UI/Layout/fixtures/combat_21000073.json
Normal file
24041
tests/AcDream.App.Tests/UI/Layout/fixtures/combat_21000073.json
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue