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.
|
- **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 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 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 — 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.
|
- **✓ 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.
|
- **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_AttackHeightChanged` `0x004CC080`
|
||||||
- `gmCombatUI::RecvNotice_SetPowerbarLevel` `0x004CC0E0`
|
- `gmCombatUI::RecvNotice_SetPowerbarLevel` `0x004CC0E0`
|
||||||
- `gmCombatUI::RecvNotice_DesiredAttackPowerChanged` `0x004CC110`
|
- `gmCombatUI::RecvNotice_DesiredAttackPowerChanged` `0x004CC110`
|
||||||
|
- `gmCombatUI::PostInit` `0x004CC1A0`
|
||||||
- `gmCombatUI::ListenToElementMessage` `0x004CC430`
|
- `gmCombatUI::ListenToElementMessage` `0x004CC430`
|
||||||
- `gmCombatUI::RecvNotice_SetCombatMode` `0x004CC620`
|
- `gmCombatUI::RecvNotice_SetCombatMode` `0x004CC620`
|
||||||
- `ClientCombatSystem::Begin` `0x0056A460`
|
- `ClientCombatSystem::Begin` `0x0056A460`
|
||||||
|
|
@ -16,6 +17,9 @@
|
||||||
- `ClientCombatSystem::UseTime` `0x0056C1F0`
|
- `ClientCombatSystem::UseTime` `0x0056C1F0`
|
||||||
- `ClientCombatSystem::HandleAttackDoneEvent` `0x0056C500`
|
- `ClientCombatSystem::HandleAttackDoneEvent` `0x0056C500`
|
||||||
- `ClientCombatSystem::SetRequestedAttackHeight` `0x0056C8F0`
|
- `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):
|
- Matching retail executable disassembly (`acclient.exe` v11.4186 + PDB):
|
||||||
`ATTACK_POWERUP_TIME = 1.0` seconds and
|
`ATTACK_POWERUP_TIME = 1.0` seconds and
|
||||||
`DUAL_WIELD_POWERUP_TIME = 0.8` seconds. The named pseudo-C's x87
|
`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
|
Both keyboard and retained buttons therefore use one transition-based attack
|
||||||
controller. A one-shot click/keypress is not sufficient: down begins charging,
|
controller. A one-shot click/keypress is not sufficient: down begins charging,
|
||||||
up ends the request.
|
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),
|
SetRetailUiLocked),
|
||||||
Combat: new AcDream.App.UI.CombatRuntimeBindings(
|
Combat: new AcDream.App.UI.CombatRuntimeBindings(
|
||||||
Combat,
|
Combat,
|
||||||
_combatAttackController),
|
_combatAttackController,
|
||||||
|
() => _persistedGameplay,
|
||||||
|
SetRetailCombatGameplay),
|
||||||
Toolbar: new AcDream.App.UI.ToolbarRuntimeBindings(
|
Toolbar: new AcDream.App.UI.ToolbarRuntimeBindings(
|
||||||
Objects,
|
Objects,
|
||||||
() => Shortcuts,
|
() => 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)
|
private void OnUiDragReleasedOutside(object payload, int x, int y)
|
||||||
{
|
{
|
||||||
if (payload is AcDream.App.UI.ItemDragPayload itemPayload)
|
if (payload is AcDream.App.UI.ItemDragPayload itemPayload)
|
||||||
|
|
@ -11997,6 +12015,9 @@ public sealed class GameWindow : IDisposable
|
||||||
if (_selection.SelectedObjectId is { } selected && IsLiveCreatureTarget(selected))
|
if (_selection.SelectedObjectId is { } selected && IsLiveCreatureTarget(selected))
|
||||||
return selected;
|
return selected;
|
||||||
|
|
||||||
|
if (!_persistedGameplay.AutoTarget)
|
||||||
|
return null;
|
||||||
|
|
||||||
return SelectClosestCombatTarget(showToast: false);
|
return SelectClosestCombatTarget(showToast: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
using AcDream.App.Rendering;
|
using AcDream.App.Rendering;
|
||||||
|
using AcDream.App.Combat;
|
||||||
using AcDream.App.UI;
|
using AcDream.App.UI;
|
||||||
using AcDream.App.UI.Layout;
|
using AcDream.App.UI.Layout;
|
||||||
|
using AcDream.Core.Combat;
|
||||||
using AcDream.Core.Items;
|
using AcDream.Core.Items;
|
||||||
|
using AcDream.UI.Abstractions.Panels.Settings;
|
||||||
using DatReaderWriter;
|
using DatReaderWriter;
|
||||||
|
|
||||||
namespace AcDream.App.Studio;
|
namespace AcDream.App.Studio;
|
||||||
|
|
@ -87,6 +90,29 @@ public static class FixtureProvider
|
||||||
datFont: stack.VitalsDatFont);
|
datFont: stack.VitalsDatFont);
|
||||||
break;
|
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
|
case 0x21000016u: // toolbar
|
||||||
ToolbarController.Bind(
|
ToolbarController.Bind(
|
||||||
layout,
|
layout,
|
||||||
|
|
|
||||||
|
|
@ -216,8 +216,10 @@ internal static class MockupDesktop
|
||||||
var rootInfo = LayoutImporter.ImportInfos(dats, ChatWindowController.LayoutId);
|
var rootInfo = LayoutImporter.ImportInfos(dats, ChatWindowController.LayoutId);
|
||||||
if (rootInfo is null) return null;
|
if (rootInfo is null) return null;
|
||||||
|
|
||||||
|
var strings = new DatStringResolver(dats);
|
||||||
var layout = LayoutImporter.Build(rootInfo, stack.ResolveChrome, stack.VitalsDatFont,
|
var layout = LayoutImporter.Build(rootInfo, stack.ResolveChrome, stack.VitalsDatFont,
|
||||||
fontResolve: stack.ResolveDatFont);
|
fontResolve: stack.ResolveDatFont,
|
||||||
|
stringResolve: strings.Resolve);
|
||||||
|
|
||||||
var chatLog = new ChatLog();
|
var chatLog = new ChatLog();
|
||||||
chatLog.SetLocalPlayerGuid(SampleData.PlayerGuid);
|
chatLog.SetLocalPlayerGuid(SampleData.PlayerGuid);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using AcDream.App.Combat;
|
using AcDream.App.Combat;
|
||||||
using AcDream.Core.Combat;
|
using AcDream.Core.Combat;
|
||||||
|
using AcDream.UI.Abstractions.Panels.Settings;
|
||||||
|
|
||||||
namespace AcDream.App.UI.Layout;
|
namespace AcDream.App.UI.Layout;
|
||||||
|
|
||||||
|
|
@ -21,12 +22,17 @@ public sealed class CombatUiController : IRetainedPanelController
|
||||||
public const uint BasicPanelId = 0x1000005Cu;
|
public const uint BasicPanelId = 0x1000005Cu;
|
||||||
public const uint AdvancedPanelId = 0x10000061u;
|
public const uint AdvancedPanelId = 0x10000061u;
|
||||||
public const uint PowerControlId = 0x1000004Fu;
|
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 HighButtonId = 0x10000057u;
|
||||||
public const uint MediumButtonId = 0x10000058u;
|
public const uint MediumButtonId = 0x10000058u;
|
||||||
public const uint LowButtonId = 0x10000059u;
|
public const uint LowButtonId = 0x10000059u;
|
||||||
|
|
||||||
private const uint MeleeState = 0x10000003u;
|
public const uint MeleeState = 0x10000003u;
|
||||||
private const uint MissileState = 0x10000004u;
|
public const uint MissileState = 0x10000004u;
|
||||||
|
|
||||||
private readonly UiElement _root;
|
private readonly UiElement _root;
|
||||||
private readonly UiElement _basicPanel;
|
private readonly UiElement _basicPanel;
|
||||||
|
|
@ -35,8 +41,13 @@ public sealed class CombatUiController : IRetainedPanelController
|
||||||
private readonly UiButton _high;
|
private readonly UiButton _high;
|
||||||
private readonly UiButton _medium;
|
private readonly UiButton _medium;
|
||||||
private readonly UiButton _low;
|
private readonly UiButton _low;
|
||||||
|
private readonly UiButton _repeatAttacks;
|
||||||
|
private readonly UiButton _autoTarget;
|
||||||
|
private readonly UiButton _keepInView;
|
||||||
private readonly CombatState _combat;
|
private readonly CombatState _combat;
|
||||||
private readonly CombatAttackController _attacks;
|
private readonly CombatAttackController _attacks;
|
||||||
|
private readonly Func<GameplaySettings> _gameplay;
|
||||||
|
private readonly Action<GameplaySettings> _setGameplay;
|
||||||
private readonly Action<bool> _setWindowVisible;
|
private readonly Action<bool> _setWindowVisible;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
|
|
@ -48,8 +59,14 @@ public sealed class CombatUiController : IRetainedPanelController
|
||||||
UiButton high,
|
UiButton high,
|
||||||
UiButton medium,
|
UiButton medium,
|
||||||
UiButton low,
|
UiButton low,
|
||||||
|
UiButton repeatAttacks,
|
||||||
|
UiButton autoTarget,
|
||||||
|
UiButton keepInView,
|
||||||
CombatState combat,
|
CombatState combat,
|
||||||
CombatAttackController attacks,
|
CombatAttackController attacks,
|
||||||
|
Func<GameplaySettings> gameplay,
|
||||||
|
Action<GameplaySettings> setGameplay,
|
||||||
|
CombatUiLabels labels,
|
||||||
Action<bool> setWindowVisible)
|
Action<bool> setWindowVisible)
|
||||||
{
|
{
|
||||||
_root = layout.Root;
|
_root = layout.Root;
|
||||||
|
|
@ -59,8 +76,13 @@ public sealed class CombatUiController : IRetainedPanelController
|
||||||
_high = high;
|
_high = high;
|
||||||
_medium = medium;
|
_medium = medium;
|
||||||
_low = low;
|
_low = low;
|
||||||
|
_repeatAttacks = repeatAttacks;
|
||||||
|
_autoTarget = autoTarget;
|
||||||
|
_keepInView = keepInView;
|
||||||
_combat = combat;
|
_combat = combat;
|
||||||
_attacks = attacks;
|
_attacks = attacks;
|
||||||
|
_gameplay = gameplay;
|
||||||
|
_setGameplay = setGameplay;
|
||||||
_setWindowVisible = setWindowVisible;
|
_setWindowVisible = setWindowVisible;
|
||||||
|
|
||||||
// PlayerModule::AdvancedCombatUI false selects gmCombatUI's authored
|
// PlayerModule::AdvancedCombatUI false selects gmCombatUI's authored
|
||||||
|
|
@ -77,6 +99,22 @@ public sealed class CombatUiController : IRetainedPanelController
|
||||||
BindAttackButton(_medium, AttackHeight.Medium);
|
BindAttackButton(_medium, AttackHeight.Medium);
|
||||||
BindAttackButton(_low, AttackHeight.Low);
|
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;
|
_combat.CombatModeChanged += OnCombatModeChanged;
|
||||||
_attacks.StateChanged += OnAttackStateChanged;
|
_attacks.StateChanged += OnAttackStateChanged;
|
||||||
SyncControls();
|
SyncControls();
|
||||||
|
|
@ -86,11 +124,17 @@ public sealed class CombatUiController : IRetainedPanelController
|
||||||
ImportedLayout layout,
|
ImportedLayout layout,
|
||||||
CombatState combat,
|
CombatState combat,
|
||||||
CombatAttackController attacks,
|
CombatAttackController attacks,
|
||||||
|
Func<GameplaySettings> gameplay,
|
||||||
|
Action<GameplaySettings> setGameplay,
|
||||||
|
CombatUiLabels labels,
|
||||||
Action<bool> setWindowVisible)
|
Action<bool> setWindowVisible)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(layout);
|
ArgumentNullException.ThrowIfNull(layout);
|
||||||
ArgumentNullException.ThrowIfNull(combat);
|
ArgumentNullException.ThrowIfNull(combat);
|
||||||
ArgumentNullException.ThrowIfNull(attacks);
|
ArgumentNullException.ThrowIfNull(attacks);
|
||||||
|
ArgumentNullException.ThrowIfNull(gameplay);
|
||||||
|
ArgumentNullException.ThrowIfNull(setGameplay);
|
||||||
|
ArgumentNullException.ThrowIfNull(labels);
|
||||||
ArgumentNullException.ThrowIfNull(setWindowVisible);
|
ArgumentNullException.ThrowIfNull(setWindowVisible);
|
||||||
|
|
||||||
if (layout.FindElement(BasicPanelId) is not { } basic
|
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(PowerControlId) is not UiScrollbar power
|
||||||
|| layout.FindElement(HighButtonId) is not UiButton high
|
|| layout.FindElement(HighButtonId) is not UiButton high
|
||||||
|| layout.FindElement(MediumButtonId) is not UiButton medium
|
|| 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 null;
|
||||||
|
|
||||||
return new CombatUiController(
|
return new CombatUiController(
|
||||||
layout, basic, advanced, power, high, medium, low,
|
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);
|
public void SyncVisibility() => OnCombatModeChanged(_combat.CurrentMode);
|
||||||
|
|
@ -136,6 +184,22 @@ public sealed class CombatUiController : IRetainedPanelController
|
||||||
_high.Selected = _attacks.RequestedHeight == AttackHeight.High;
|
_high.Selected = _attacks.RequestedHeight == AttackHeight.High;
|
||||||
_medium.Selected = _attacks.RequestedHeight == AttackHeight.Medium;
|
_medium.Selected = _attacks.RequestedHeight == AttackHeight.Medium;
|
||||||
_low.Selected = _attacks.RequestedHeight == AttackHeight.Low;
|
_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();
|
public void OnShown() => SyncControls();
|
||||||
|
|
@ -154,5 +218,60 @@ public sealed class CombatUiController : IRetainedPanelController
|
||||||
_medium.OnReleased = null;
|
_medium.OnReleased = null;
|
||||||
_low.OnPressed = null;
|
_low.OnPressed = null;
|
||||||
_low.OnReleased = 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>
|
/// <returns>The widget for this element. Never null — every type produces a widget.</returns>
|
||||||
public static UiElement? Create(ElementInfo info,
|
public static UiElement? Create(ElementInfo info,
|
||||||
Func<uint, (uint, int, int)> resolve, UiDatFont? datFont,
|
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
|
// Retail Type 3 = UIElement_Field (reg :126190), but in acdream's CURRENT layouts
|
||||||
// (vitals 0x2100006C / chat 0x21000006) Type-3 elements are sprite-bearing chrome +
|
// (vitals 0x2100006C / chat 0x21000006) Type-3 elements are sprite-bearing chrome +
|
||||||
|
|
@ -75,13 +76,14 @@ public static class DatWidgetFactory
|
||||||
UiElement e = info.Type switch
|
UiElement e = info.Type switch
|
||||||
{
|
{
|
||||||
UiRadar.RetailClassId => new UiRadar(), // gmRadarUI (Register 0x004D8B80)
|
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)
|
6 => new UiMenu(), // UIElement_Menu (reg :120163)
|
||||||
7 => BuildMeter(info, resolve, elementFont), // UIElement_Meter
|
7 => BuildMeter(info, resolve, elementFont), // UIElement_Meter
|
||||||
0xD => new UiViewport(), // UIElement_Viewport — 3-D mini-scene blit leaf
|
0xD => new UiViewport(), // UIElement_Viewport — 3-D mini-scene blit leaf
|
||||||
11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar (reg :124137)
|
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
|
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)
|
_ => new UiDatElement(info, resolve), // generic fallback (incl. Type 3 chrome/containers)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -147,20 +149,22 @@ public static class DatWidgetFactory
|
||||||
|
|
||||||
if (bar.Horizontal)
|
if (bar.Horizontal)
|
||||||
{
|
{
|
||||||
// gmToolbarUI stack slider (0x100001A4) has two direct Type-3 media
|
// Retail horizontal scrollbars use structural child ids: element 1 is
|
||||||
// children: a full-width 90x14 track (element 4) and a 16x14 thumb
|
// the thumb and element 4 is the optional child-authored track.
|
||||||
// (element 1). Their geometry, not a button class, defines the roles.
|
ElementInfo? scalarThumb = info.Children.FirstOrDefault(child => child.Id == 1u);
|
||||||
ElementInfo? track = info.Children
|
bar.TrackSprite = DefaultImage(info);
|
||||||
.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);
|
|
||||||
bar.ThumbSprite = scalarThumb is null ? 0u : DefaultImage(scalarThumb);
|
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
|
// gmCombatUI's desired-power slider (0x1000004F) authors the
|
||||||
// live charge as a nested Type-7 meter. UiScrollbar consumes its
|
// live charge as a nested Type-7 meter. UiScrollbar consumes its
|
||||||
// DAT children, so retain the meter's fill image on the scalar
|
// 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
|
/// <see cref="ImportedLayout.FindElement"/> and set <see cref="UiText.DatFont"/> afterward
|
||||||
/// still override this — the build-time value is just the starting point.</param>
|
/// 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,
|
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(
|
uint bg = info.StateMedia.TryGetValue(
|
||||||
!string.IsNullOrEmpty(info.DefaultStateName) ? info.DefaultStateName
|
!string.IsNullOrEmpty(info.DefaultStateName) ? info.DefaultStateName
|
||||||
|
|
@ -464,6 +469,67 @@ public static class DatWidgetFactory
|
||||||
if (info.FontColor.HasValue)
|
if (info.FontColor.HasValue)
|
||||||
t.DefaultColor = info.FontColor.Value;
|
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;
|
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,
|
IEnumerable<ElementInfo> children,
|
||||||
Func<uint, (uint, int, int)> resolve,
|
Func<uint, (uint, int, int)> resolve,
|
||||||
UiDatFont? datFont,
|
UiDatFont? datFont,
|
||||||
Func<uint, UiDatFont?>? fontResolve = null)
|
Func<uint, UiDatFont?>? fontResolve = null,
|
||||||
|
Func<UiStringInfoValue, string?>? stringResolve = null)
|
||||||
{
|
{
|
||||||
rootInfo.Children = new List<ElementInfo>(children);
|
rootInfo.Children = new List<ElementInfo>(children);
|
||||||
return Build(rootInfo, resolve, datFont, fontResolve);
|
return Build(rootInfo, resolve, datFont, fontResolve, stringResolve);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -89,12 +90,13 @@ public static class LayoutImporter
|
||||||
ElementInfo rootInfo,
|
ElementInfo rootInfo,
|
||||||
Func<uint, (uint, int, int)> resolve,
|
Func<uint, (uint, int, int)> resolve,
|
||||||
UiDatFont? datFont,
|
UiDatFont? datFont,
|
||||||
Func<uint, UiDatFont?>? fontResolve = null)
|
Func<uint, UiDatFont?>? fontResolve = null,
|
||||||
|
Func<UiStringInfoValue, string?>? stringResolve = null)
|
||||||
{
|
{
|
||||||
var byId = new Dictionary<uint, UiElement>();
|
var byId = new Dictionary<uint, UiElement>();
|
||||||
// Root is never a Type-12 prototype in practice; fall back to a generic
|
// 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.
|
// 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)
|
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.");
|
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,
|
Func<uint, (uint, int, int)> resolve,
|
||||||
UiDatFont? datFont,
|
UiDatFont? datFont,
|
||||||
Func<uint, UiDatFont?>? fontResolve,
|
Func<uint, UiDatFont?>? fontResolve,
|
||||||
|
Func<UiStringInfoValue, string?>? stringResolve,
|
||||||
Dictionary<uint, UiElement> byId)
|
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 (w is null) return null; // Type-12 style prototype — skip
|
||||||
|
|
||||||
if (info.Id != 0) byId[info.Id] = w;
|
if (info.Id != 0) byId[info.Id] = w;
|
||||||
|
|
@ -126,7 +129,7 @@ public static class LayoutImporter
|
||||||
{
|
{
|
||||||
foreach (var child in info.Children)
|
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);
|
if (cw is not null) w.AddChild(cw);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -152,7 +155,7 @@ public static class LayoutImporter
|
||||||
foreach (var child in info.Children)
|
foreach (var child in info.Children)
|
||||||
{
|
{
|
||||||
if (child.Type == 3) continue; // slice containers: already consumed by BuildMeter
|
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);
|
if (cw is not null) w.AddChild(cw);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -253,7 +256,8 @@ public static class LayoutImporter
|
||||||
{
|
{
|
||||||
var rootInfo = ImportInfos(dats, layoutId);
|
var rootInfo = ImportInfos(dats, layoutId);
|
||||||
if (rootInfo is null) return null;
|
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 ────────────────────────────────────────────────
|
// ── Inheritance resolution ────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,9 @@ public sealed record RadarRuntimeBindings(
|
||||||
|
|
||||||
public sealed record CombatRuntimeBindings(
|
public sealed record CombatRuntimeBindings(
|
||||||
CombatState State,
|
CombatState State,
|
||||||
CombatAttackController Attacks);
|
CombatAttackController Attacks,
|
||||||
|
Func<GameplaySettings> Gameplay,
|
||||||
|
Action<GameplaySettings> SetGameplay);
|
||||||
|
|
||||||
public sealed record ToolbarRuntimeBindings(
|
public sealed record ToolbarRuntimeBindings(
|
||||||
ClientObjectTable Objects,
|
ClientObjectTable Objects,
|
||||||
|
|
@ -325,11 +327,13 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
lock (_bindings.Assets.DatLock)
|
lock (_bindings.Assets.DatLock)
|
||||||
{
|
{
|
||||||
info = LayoutImporter.ImportInfos(_bindings.Assets.Dats, ChatWindowController.LayoutId);
|
info = LayoutImporter.ImportInfos(_bindings.Assets.Dats, ChatWindowController.LayoutId);
|
||||||
|
var strings = new DatStringResolver(_bindings.Assets.Dats);
|
||||||
layout = info is null ? null : LayoutImporter.Build(
|
layout = info is null ? null : LayoutImporter.Build(
|
||||||
info,
|
info,
|
||||||
_bindings.Assets.ResolveSprite,
|
_bindings.Assets.ResolveSprite,
|
||||||
_bindings.Assets.DefaultFont,
|
_bindings.Assets.DefaultFont,
|
||||||
_bindings.Assets.ResolveFont);
|
_bindings.Assets.ResolveFont,
|
||||||
|
strings.Resolve);
|
||||||
}
|
}
|
||||||
if (info is null || layout is null)
|
if (info is null || layout is null)
|
||||||
{
|
{
|
||||||
|
|
@ -449,8 +453,22 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
|
|
||||||
private void MountCombat()
|
private void MountCombat()
|
||||||
{
|
{
|
||||||
ImportedLayout? layout = Import(CombatUiController.LayoutId);
|
ElementInfo? info;
|
||||||
if (layout is null)
|
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.");
|
Console.WriteLine("[M2] combat: LayoutDesc 0x21000073 not found.");
|
||||||
return;
|
return;
|
||||||
|
|
@ -460,6 +478,9 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
layout,
|
layout,
|
||||||
_bindings.Combat.State,
|
_bindings.Combat.State,
|
||||||
_bindings.Combat.Attacks,
|
_bindings.Combat.Attacks,
|
||||||
|
_bindings.Combat.Gameplay,
|
||||||
|
_bindings.Combat.SetGameplay,
|
||||||
|
labels,
|
||||||
visible =>
|
visible =>
|
||||||
{
|
{
|
||||||
if (visible) Host.ShowWindow(WindowNames.Combat);
|
if (visible) Host.ShowWindow(WindowNames.Combat);
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ namespace AcDream.App.UI;
|
||||||
public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
||||||
{
|
{
|
||||||
private readonly ElementInfo _info;
|
private readonly ElementInfo _info;
|
||||||
|
private readonly ElementInfo _mediaInfo;
|
||||||
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
|
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
|
||||||
private readonly HashSet<uint> _availableStates = new();
|
private readonly HashSet<uint> _availableStates = new();
|
||||||
private bool _pressed;
|
private bool _pressed;
|
||||||
|
|
@ -86,6 +87,16 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
||||||
/// <summary>Label color (default white).</summary>
|
/// <summary>Label color (default white).</summary>
|
||||||
public Vector4 LabelColor { get; set; } = Vector4.One;
|
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;
|
/// <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>
|
/// Left for the paperdoll "Slots" caption that sits at the left edge, before the slots.</summary>
|
||||||
public LabelAlignment LabelAlign { get; set; } = LabelAlignment.Center;
|
public LabelAlignment LabelAlign { get; set; } = LabelAlignment.Center;
|
||||||
|
|
@ -122,7 +133,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(ActiveState))
|
if (string.IsNullOrEmpty(ActiveState))
|
||||||
return UiStateInfo.DirectStateId;
|
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))
|
if (string.Equals(state.Name, ActiveState, StringComparison.Ordinal))
|
||||||
return id;
|
return id;
|
||||||
return UiButtonStateMachine.TryStateId(ActiveState, out uint standard)
|
return UiButtonStateMachine.TryStateId(ActiveState, out uint standard)
|
||||||
|
|
@ -164,12 +175,12 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
||||||
|
|
||||||
if (stateId == UiStateInfo.DirectStateId)
|
if (stateId == UiStateInfo.DirectStateId)
|
||||||
{
|
{
|
||||||
if (!_info.States.ContainsKey(stateId) && !_info.StateMedia.ContainsKey(""))
|
if (!_mediaInfo.States.ContainsKey(stateId) && !_mediaInfo.StateMedia.ContainsKey(""))
|
||||||
return false;
|
return false;
|
||||||
ActiveState = "";
|
ActiveState = "";
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (_info.States.TryGetValue(stateId, out var state))
|
if (_mediaInfo.States.TryGetValue(stateId, out var state))
|
||||||
{
|
{
|
||||||
ActiveState = state.Name;
|
ActiveState = state.Name;
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -177,7 +188,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
||||||
string stateName = UiButtonStateMachine.StateName(stateId);
|
string stateName = UiButtonStateMachine.StateName(stateId);
|
||||||
if (string.IsNullOrEmpty(stateName))
|
if (string.IsNullOrEmpty(stateName))
|
||||||
stateName = RetailUiStateIds.StateName(stateId);
|
stateName = RetailUiStateIds.StateName(stateId);
|
||||||
if (!string.IsNullOrEmpty(stateName) && _info.StateMedia.ContainsKey(stateName))
|
if (!string.IsNullOrEmpty(stateName) && _mediaInfo.StateMedia.ContainsKey(stateName))
|
||||||
{
|
{
|
||||||
ActiveState = stateName;
|
ActiveState = stateName;
|
||||||
return true;
|
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="info">Merged <see cref="ElementInfo"/> for this element.</param>
|
||||||
/// <param name="resolve">Dat file-id → (GL texture handle, native px width, native px height).
|
/// <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>
|
/// 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;
|
_info = info;
|
||||||
|
_mediaInfo = mediaInfo ?? info;
|
||||||
_resolve = resolve;
|
_resolve = resolve;
|
||||||
ClickThrough = false; // buttons are interactive — opt OUT of click-through
|
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
|
// Retail layouts commonly declare an empty Normal_pressed descriptor while
|
||||||
// supplying art only for Normal/Highlight. Treating that property-only state
|
// supplying art only for Normal/Highlight. Treating that property-only state
|
||||||
// as drawable briefly blanks the button during mouse-down.
|
// 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))
|
if (UiButtonStateMachine.TryStateId(stateName, out uint stateId))
|
||||||
_availableStates.Add(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 ("").
|
// DefaultStateName wins; else "Normal" if that state has a sprite; else DirectState ("").
|
||||||
if (!string.IsNullOrEmpty(info.DefaultStateName))
|
if (!string.IsNullOrEmpty(info.DefaultStateName))
|
||||||
ActiveState = info.DefaultStateName;
|
ActiveState = info.DefaultStateName;
|
||||||
else if (info.StateMedia.ContainsKey("Normal"))
|
else if (_mediaInfo.StateMedia.ContainsKey("Normal"))
|
||||||
ActiveState = "Normal";
|
ActiveState = "Normal";
|
||||||
// else ActiveState stays "" (DirectState)
|
// else ActiveState stays "" (DirectState)
|
||||||
|
|
||||||
Enabled = !disabled;
|
Enabled = !disabled;
|
||||||
|
FaceWidth = mediaInfo?.Width ?? info.Width;
|
||||||
|
FaceHeight = mediaInfo?.Height ?? info.Height;
|
||||||
UpdateVisualState();
|
UpdateVisualState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -242,8 +259,8 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
||||||
/// Mirrors <see cref="UiDatElement.ActiveMedia()"/>.
|
/// Mirrors <see cref="UiDatElement.ActiveMedia()"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private uint ActiveFile()
|
private uint ActiveFile()
|
||||||
=> _info.StateMedia.TryGetValue(ActiveState, out var m) ? m.File
|
=> _mediaInfo.StateMedia.TryGetValue(ActiveState, out var m) ? m.File
|
||||||
: _info.StateMedia.TryGetValue("", out var d) ? d.File : 0u;
|
: _mediaInfo.StateMedia.TryGetValue("", out var d) ? d.File : 0u;
|
||||||
|
|
||||||
protected override void OnDraw(UiRenderContext ctx)
|
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
|
// Tiled draw — same call shape as UiDatElement.OnDraw (UV-repeat; GL_REPEAT-wrapped
|
||||||
// UI texture). Matches ImgTex::TileCSI; no Stretch mode exists.
|
// 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)
|
if (Label is { Length: > 0 } label && LabelFont is { } lf)
|
||||||
{
|
{
|
||||||
float tx = LabelAlign == LabelAlignment.Left
|
float tx = LabelAlign == LabelAlignment.Left
|
||||||
? 3f // small left pad (room for a future checkbox box)
|
? LabelOffsetX
|
||||||
: (Width - lf.MeasureWidth(label)) * 0.5f; // centered (default)
|
: (Width - lf.MeasureWidth(label)) * 0.5f; // centered (default)
|
||||||
float ty = (Height - lf.LineHeight) * 0.5f;
|
float ty = (Height - lf.LineHeight) * 0.5f;
|
||||||
ctx.DrawStringDat(lf, label, tx, ty, LabelColor);
|
ctx.DrawStringDat(lf, label, tx, ty, LabelColor);
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ public sealed record GameplaySettings(
|
||||||
// CharacterOption (32-bit) subset — most-used gameplay toggles.
|
// CharacterOption (32-bit) subset — most-used gameplay toggles.
|
||||||
bool AutoTarget, // 0x2000 — combat: auto-acquire target on attack
|
bool AutoTarget, // 0x2000 — combat: auto-acquire target on attack
|
||||||
bool AutoRepeatAttack, // 0x2 — combat: keep attacking after first hit
|
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 ToggleRun, // 0x400 — run-mode is tap-once vs hold-to-run
|
||||||
bool AdvancedCombatUI, // 0x1000 — show extra combat tooltips/panels
|
bool AdvancedCombatUI, // 0x1000 — show extra combat tooltips/panels
|
||||||
bool ShowTooltips, // 0x100 — show item tooltips on hover
|
bool ShowTooltips, // 0x100 — show item tooltips on hover
|
||||||
|
|
@ -46,6 +47,7 @@ public sealed record GameplaySettings(
|
||||||
public static GameplaySettings Default { get; } = new(
|
public static GameplaySettings Default { get; } = new(
|
||||||
AutoTarget: true,
|
AutoTarget: true,
|
||||||
AutoRepeatAttack: true,
|
AutoRepeatAttack: true,
|
||||||
|
ViewCombatTarget: true,
|
||||||
ToggleRun: true,
|
ToggleRun: true,
|
||||||
AdvancedCombatUI: false,
|
AdvancedCombatUI: false,
|
||||||
ShowTooltips: true,
|
ShowTooltips: true,
|
||||||
|
|
|
||||||
|
|
@ -306,6 +306,10 @@ public sealed class SettingsPanel : IPanel
|
||||||
if (renderer.Checkbox("Auto-repeat attacks", ref autoRepeat))
|
if (renderer.Checkbox("Auto-repeat attacks", ref autoRepeat))
|
||||||
_vm.SetGameplay(g with { AutoRepeatAttack = 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;
|
bool toggleRun = g.ToggleRun;
|
||||||
if (renderer.Checkbox("Run mode is toggle (vs hold)", ref toggleRun))
|
if (renderer.Checkbox("Run mode is toggle (vs hold)", ref toggleRun))
|
||||||
_vm.SetGameplay(g with { ToggleRun = toggleRun });
|
_vm.SetGameplay(g with { ToggleRun = toggleRun });
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,7 @@ public sealed class SettingsStore
|
||||||
return new GameplaySettings(
|
return new GameplaySettings(
|
||||||
AutoTarget: ReadBool(gp, "autoTarget", d.AutoTarget),
|
AutoTarget: ReadBool(gp, "autoTarget", d.AutoTarget),
|
||||||
AutoRepeatAttack: ReadBool(gp, "autoRepeatAttack", d.AutoRepeatAttack),
|
AutoRepeatAttack: ReadBool(gp, "autoRepeatAttack", d.AutoRepeatAttack),
|
||||||
|
ViewCombatTarget: ReadBool(gp, "viewCombatTarget", d.ViewCombatTarget),
|
||||||
ToggleRun: ReadBool(gp, "toggleRun", d.ToggleRun),
|
ToggleRun: ReadBool(gp, "toggleRun", d.ToggleRun),
|
||||||
AdvancedCombatUI: ReadBool(gp, "advancedCombatUI", d.AdvancedCombatUI),
|
AdvancedCombatUI: ReadBool(gp, "advancedCombatUI", d.AdvancedCombatUI),
|
||||||
ShowTooltips: ReadBool(gp, "showTooltips", d.ShowTooltips),
|
ShowTooltips: ReadBool(gp, "showTooltips", d.ShowTooltips),
|
||||||
|
|
@ -483,6 +484,7 @@ public sealed class SettingsStore
|
||||||
["allowGive"] = g.AllowGive,
|
["allowGive"] = g.AllowGive,
|
||||||
["autoRepeatAttack"] = g.AutoRepeatAttack,
|
["autoRepeatAttack"] = g.AutoRepeatAttack,
|
||||||
["autoTarget"] = g.AutoTarget,
|
["autoTarget"] = g.AutoTarget,
|
||||||
|
["viewCombatTarget"] = g.ViewCombatTarget,
|
||||||
["coordinatesOnRadar"] = g.CoordinatesOnRadar,
|
["coordinatesOnRadar"] = g.CoordinatesOnRadar,
|
||||||
["lockUI"] = g.LockUI,
|
["lockUI"] = g.LockUI,
|
||||||
["showCloak"] = g.ShowCloak,
|
["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;
|
||||||
using AcDream.App.UI.Layout;
|
using AcDream.App.UI.Layout;
|
||||||
using AcDream.Core.Combat;
|
using AcDream.Core.Combat;
|
||||||
|
using AcDream.UI.Abstractions.Panels.Settings;
|
||||||
|
|
||||||
namespace AcDream.App.Tests.UI.Layout;
|
namespace AcDream.App.Tests.UI.Layout;
|
||||||
|
|
||||||
|
|
@ -17,8 +18,10 @@ public sealed class CombatUiControllerTests
|
||||||
using var attacks = CreateAttacks(combat, () => now, []);
|
using var attacks = CreateAttacks(combat, () => now, []);
|
||||||
var (layout, _, _, power, high, medium, low) = BuildLayout();
|
var (layout, _, _, power, high, medium, low) = BuildLayout();
|
||||||
var visibility = new List<bool>();
|
var visibility = new List<bool>();
|
||||||
|
GameplaySettings gameplay = GameplaySettings.Default;
|
||||||
using var controller = CombatUiController.Bind(
|
using var controller = CombatUiController.Bind(
|
||||||
layout, combat, attacks, visibility.Add)!;
|
layout, combat, attacks, () => gameplay, value => gameplay = value,
|
||||||
|
Labels, visibility.Add)!;
|
||||||
|
|
||||||
controller.SyncVisibility();
|
controller.SyncVisibility();
|
||||||
combat.SetCombatMode(CombatMode.Melee);
|
combat.SetCombatMode(CombatMode.Melee);
|
||||||
|
|
@ -39,8 +42,10 @@ public sealed class CombatUiControllerTests
|
||||||
var combat = new CombatState();
|
var combat = new CombatState();
|
||||||
using var attacks = CreateAttacks(combat, () => now, sent);
|
using var attacks = CreateAttacks(combat, () => now, sent);
|
||||||
var (layout, basic, advanced, power, high, _, _) = BuildLayout();
|
var (layout, basic, advanced, power, high, _, _) = BuildLayout();
|
||||||
|
GameplaySettings gameplay = GameplaySettings.Default;
|
||||||
using var controller = CombatUiController.Bind(
|
using var controller = CombatUiController.Bind(
|
||||||
layout, combat, attacks, _ => { })!;
|
layout, combat, attacks, () => gameplay, value => gameplay = value,
|
||||||
|
Labels, _ => { })!;
|
||||||
combat.SetCombatMode(CombatMode.Melee);
|
combat.SetCombatMode(CombatMode.Melee);
|
||||||
|
|
||||||
high.OnEvent(new UiEvent(0, high, UiEventType.MouseDown, Data1: 2, Data2: 2));
|
high.OnEvent(new UiEvent(0, high, UiEventType.MouseDown, Data1: 2, Data2: 2));
|
||||||
|
|
@ -55,6 +60,29 @@ public sealed class CombatUiControllerTests
|
||||||
Assert.False(advanced.Visible);
|
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(
|
private static CombatAttackController CreateAttacks(
|
||||||
CombatState combat,
|
CombatState combat,
|
||||||
Func<double> now,
|
Func<double> now,
|
||||||
|
|
@ -89,9 +117,21 @@ public sealed class CombatUiControllerTests
|
||||||
Width = 72,
|
Width = 72,
|
||||||
Height = 19,
|
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 high = Button(CombatUiController.HighButtonId);
|
||||||
var medium = Button(CombatUiController.MediumButtonId);
|
var medium = Button(CombatUiController.MediumButtonId);
|
||||||
var low = Button(CombatUiController.LowButtonId);
|
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>
|
var byId = new Dictionary<uint, UiElement>
|
||||||
{
|
{
|
||||||
[CombatUiController.BasicPanelId] = basic,
|
[CombatUiController.BasicPanelId] = basic,
|
||||||
|
|
@ -100,6 +140,9 @@ public sealed class CombatUiControllerTests
|
||||||
[CombatUiController.HighButtonId] = high,
|
[CombatUiController.HighButtonId] = high,
|
||||||
[CombatUiController.MediumButtonId] = medium,
|
[CombatUiController.MediumButtonId] = medium,
|
||||||
[CombatUiController.LowButtonId] = low,
|
[CombatUiController.LowButtonId] = low,
|
||||||
|
[CombatUiController.RepeatAttacksId] = repeat,
|
||||||
|
[CombatUiController.AutoTargetId] = autoTarget,
|
||||||
|
[CombatUiController.KeepInViewId] = keepInView,
|
||||||
};
|
};
|
||||||
return (new ImportedLayout(root, byId), basic, advanced, power, high, medium, low);
|
return (new ImportedLayout(root, byId), basic, advanced, power, high, medium, low);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,12 @@ public static class FixtureLoader
|
||||||
public static ElementInfo LoadCharacterInfos()
|
public static ElementInfo LoadCharacterInfos()
|
||||||
=> LoadInfos("character_2100002E.json");
|
=> 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 ────────────────────────────────────────────────────────
|
// ── Shared loader ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName)
|
private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName)
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ public sealed class RetailLayoutFixtureGenerator
|
||||||
(0x21000024u, "paperdoll_21000024.json"),
|
(0x21000024u, "paperdoll_21000024.json"),
|
||||||
(0x2100002Eu, "character_2100002E.json"),
|
(0x2100002Eu, "character_2100002E.json"),
|
||||||
(0x2100006Cu, "vitals_2100006C.json"),
|
(0x2100006Cu, "vitals_2100006C.json"),
|
||||||
|
(0x21000073u, "combat_21000073.json"),
|
||||||
(0x21000074u, "radar_21000074.json"),
|
(0x21000074u, "radar_21000074.json"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -46,6 +47,18 @@ public sealed class RetailLayoutFixtureGenerator
|
||||||
{
|
{
|
||||||
var info = LayoutImporter.ImportInfos(dats, layoutId);
|
var info = LayoutImporter.ImportInfos(dats, layoutId);
|
||||||
Assert.NotNull(info);
|
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
|
var json = JsonSerializer.Serialize(info, new JsonSerializerOptions
|
||||||
{
|
{
|
||||||
IncludeFields = true,
|
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