fix(combat): preserve authored meter child geometry
Carry the consumed dark-range child's own retail edge policy into the procedural scrollbar, widening it to the authored post-reflow interval without covering the rendered labels. Restore the bright layer to live attack-charge feedback across the full bar from the left. Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
parent
f8c174d707
commit
e671b8a5c4
10 changed files with 66 additions and 105 deletions
|
|
@ -58,7 +58,7 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
| IA-17 | Toolbar chrome is toolkit-supplied through the central `RetailWindowFrame` mount (`UiCollapsibleFrame` 8-piece bevel) because LayoutDesc `0x21000016` carries no baked frame. It also supports a toolkit-defined collapse-to-one-row (bottom-edge resize snapping between a row-1-only and a two-row height, row-2 visibility tied to the stop) — retail's real collapse is keystone.dll (no decomp) and the DAT stacks both rows always. | `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/UiCollapsibleFrame.cs`; toolbar policy in `GameWindow.cs`; spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md` | The central mount now owns wrapper geometry/registration uniformly; border-over-content prevents the row-2 right cap from poking through | The collapse stops remain a toolkit reconstruction rather than a byte-port of Keystone behavior | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) |
|
| IA-17 | Toolbar chrome is toolkit-supplied through the central `RetailWindowFrame` mount (`UiCollapsibleFrame` 8-piece bevel) because LayoutDesc `0x21000016` carries no baked frame. It also supports a toolkit-defined collapse-to-one-row (bottom-edge resize snapping between a row-1-only and a two-row height, row-2 visibility tied to the stop) — retail's real collapse is keystone.dll (no decomp) and the DAT stacks both rows always. | `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/UiCollapsibleFrame.cs`; toolbar policy in `GameWindow.cs`; spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md` | The central mount now owns wrapper geometry/registration uniformly; border-over-content prevents the row-2 right cap from poking through | The collapse stops remain a toolkit reconstruction rather than a byte-port of Keystone behavior | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) |
|
||||||
| IA-18 | Effect overlay tile (enum 0x10000005) is a `ReplaceColor` SURFACE SOURCE — pure-white pixels in the composited drag icon are replaced PER-PIXEL with the same (x,y) pixel of the effect tile (the SURFACE overload `SurfaceWindow::ReplaceColor` 0x004415b0), preserving the tile's texture/gradient; the tile itself is NOT blitted as an additional layer. This IS faithful retail behavior. **Anti-regression: do NOT re-implement this as a blit layer NOR as a flat-color replace (it is a per-pixel surface copy).** | `src/AcDream.App/UI/IconComposer.cs` (`ReplaceWhiteFromSurface`) | Faithful port of `IconData::RenderIcons` @407614 → the SURFACE overload `ReplaceColor` 0x004415b0 (`dst[x,y]=src[x,y]` where `dst==white`); confirmed via clean Ghidra decompile + named decomp + visual (the Energy Crystal's blue is a gradient, 2026-06-17). | A blit-layer or flat-color re-implementation would show the wrong effect look (no gradient) — the visual-verification regression that retired the mean-color approximation | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407524; `ReplaceColor` SURFACE overload 0x004415b0:71656; `docs/research/2026-06-17-stateful-icon-RESOLVED.md` |
|
| IA-18 | Effect overlay tile (enum 0x10000005) is a `ReplaceColor` SURFACE SOURCE — pure-white pixels in the composited drag icon are replaced PER-PIXEL with the same (x,y) pixel of the effect tile (the SURFACE overload `SurfaceWindow::ReplaceColor` 0x004415b0), preserving the tile's texture/gradient; the tile itself is NOT blitted as an additional layer. This IS faithful retail behavior. **Anti-regression: do NOT re-implement this as a blit layer NOR as a flat-color replace (it is a per-pixel surface copy).** | `src/AcDream.App/UI/IconComposer.cs` (`ReplaceWhiteFromSurface`) | Faithful port of `IconData::RenderIcons` @407614 → the SURFACE overload `ReplaceColor` 0x004415b0 (`dst[x,y]=src[x,y]` where `dst==white`); confirmed via clean Ghidra decompile + named decomp + visual (the Energy Crystal's blue is a gradient, 2026-06-17). | A blit-layer or flat-color re-implementation would show the wrong effect look (no gradient) — the visual-verification regression that retired the mean-color approximation | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407524; `ReplaceColor` SURFACE overload 0x004415b0:71656; `docs/research/2026-06-17-stateful-icon-RESOLVED.md` |
|
||||||
| IA-19 | Automatic combat acquisition is narrowed to attackable non-player monsters. Retail `AutoTarget` falls back to `SelectNext(SELECTION_TYPE_COMPASS_ITEM)`, whose combat filter can also admit attackable enemy players in compatible PK states. | `src/AcDream.Core/Combat/CombatTargetPolicy.cs`; consumer `src/AcDream.App/Rendering/GameWindow.cs` | Explicit product direction: Auto Target must never select NPCs, players, pets, or other objects; manual player-selection commands remain available | In PK play, Auto Target will not acquire an otherwise valid hostile player as retail would; the player must be selected manually | `ClientCombatSystem::AutoTarget @ 0x0056BC80`; `CPlayerSystem::SelectNext @ 0x0055F9A0`; `ClientCombatSystem::ObjectIsAttackable @ 0x0056A600` |
|
| IA-19 | Automatic combat acquisition is narrowed to attackable non-player monsters. Retail `AutoTarget` falls back to `SelectNext(SELECTION_TYPE_COMPASS_ITEM)`, whose combat filter can also admit attackable enemy players in compatible PK states. | `src/AcDream.Core/Combat/CombatTargetPolicy.cs`; consumer `src/AcDream.App/Rendering/GameWindow.cs` | Explicit product direction: Auto Target must never select NPCs, players, pets, or other objects; manual player-selection commands remain available | In PK play, Auto Target will not acquire an otherwise valid hostile player as retail would; the player must be selected manually | `ClientCombatSystem::AutoTarget @ 0x0056BC80`; `CPlayerSystem::SelectNext @ 0x0055F9A0`; `ClientCombatSystem::ObjectIsAttackable @ 0x0056A600` |
|
||||||
| IA-20 | The basic combat bar keeps dark-red media `0x0600715E` visible as the centered middle baseline and renders bright `0x06001200` from the absolute left edge to the desired-power thumb. Retail skill-gates field `0x100005EF` to trained Recklessness and drives meter child `0x10000050` from live `SetPowerbarLevel` notices independently of the thumb. | `src/AcDream.App/UI/UiScrollbar.cs`; binding geometry in `src/AcDream.App/UI/Layout/CombatUiController.cs` | Explicit connected visual direction: dark must remain centered between Speed/Power and bright must visualize the selected delay from the bar's left edge to the green slider; the exact skill-gated treatment remains tracked by AP-112 | Untrained characters retain the dark baseline and the bar shows desired power rather than retail's transient live charge level | `gmCombatUI::RecvNotice_SetPowerbarLevel @ 0x004CC0E0`; `RecvNotice_DesiredAttackPowerChanged @ 0x004CC110`; `gmCombatUI::ListenToElementMessage @ 0x004CC430`; LayoutDesc `0x21000073` |
|
| IA-20 | The basic combat bar keeps dark-red media `0x0600715E` visible as the centered middle baseline. Retail skill-gates field `0x100005EF` to trained Recklessness; the separate bright child remains faithful live `SetPowerbarLevel` feedback from the absolute left edge. | `src/AcDream.App/UI/UiScrollbar.cs`; child-policy extraction in `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` | Explicit connected visual direction: the dark middle track remains present behind live attack charge; the exact skill-gated treatment remains tracked by AP-112 | Untrained characters retain the dark-red baseline where retail may leave only the gray track; trained/untrained Recklessness presentation is not distinguishable | `gmCombatUI::RecvNotice_SetPowerbarLevel @ 0x004CC0E0`; `gmCombatUI::ListenToElementMessage @ 0x004CC430`; LayoutDesc `0x21000073` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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; corrective live visual gate pending.** Production mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, and routes mouse plus keyboard through one `CombatAttackController`. Corrections include the post-reflow centered dark-red baseline plus absolute-left-to-thumb bright desired-power band (accepted IA-20), exact Speed-left/Power-right justification, silent control-only `AttackDone(ActionCancelled)`, target-frame Keep in View with manual orbit, and persistent corpse motion: the AP-80 velocity-only NPC adaptation can now replace only Ready/Walk/Run, never authoritative Dead/actions. `CombatTargetController` ports the selection-cleared AutoTarget consumer, so a selected creature's authoritative Dead motion clears it and selects the nearest eligible creature when enabled. The 2026-07-12 replacement-corpse correction unifies both multi-frame and static/reactive spawns behind retail's CreateObject lifecycle: apply the wire's Dead state while detached, then `MotionTableManager::HandleEnterWorld` strips Ready→Dead links before the first in-world tick; multiple corpses remaining fallen passed the live user gate that day. Follow-up #205 makes the toolbar read the final canonical selection after a reentrant Auto Target notice and restricts automatic candidates to hostile non-player monsters (intentional PK-edge divergence IA-19); that live gate also passed 2026-07-12. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`, `docs/research/2026-07-11-combat-target-camera-pseudocode.md`, `docs/research/2026-07-12-death-and-auto-target-pseudocode.md`; AP-24/AP-95 retired, AP-80 narrowed, AP-110 narrowed, AP-112 records the remaining attack-start and exact trained-Recklessness seams.
|
- **M2 basic retail combat bar implemented 2026-07-11; corrective live visual gate pending.** Production mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, and routes mouse plus keyboard through one `CombatAttackController`. Corrections include the authored wider centered dark-red child range (accepted IA-20), full-width left-to-right bright live attack-charge feedback, exact Speed-left/Power-right justification, silent control-only `AttackDone(ActionCancelled)`, target-frame Keep in View with manual orbit, and persistent corpse motion: the AP-80 velocity-only NPC adaptation can now replace only Ready/Walk/Run, never authoritative Dead/actions. `CombatTargetController` ports the selection-cleared AutoTarget consumer, so a selected creature's authoritative Dead motion clears it and selects the nearest eligible creature when enabled. The 2026-07-12 replacement-corpse correction unifies both multi-frame and static/reactive spawns behind retail's CreateObject lifecycle: apply the wire's Dead state while detached, then `MotionTableManager::HandleEnterWorld` strips Ready→Dead links before the first in-world tick; multiple corpses remaining fallen passed the live user gate that day. Follow-up #205 makes the toolbar read the final canonical selection after a reentrant Auto Target notice and restricts automatic candidates to hostile non-player monsters (intentional PK-edge divergence IA-19); that live gate also passed 2026-07-12. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`, `docs/research/2026-07-11-combat-target-camera-pseudocode.md`, `docs/research/2026-07-12-death-and-auto-target-pseudocode.md`; AP-24/AP-95 retired, AP-80 narrowed, AP-110 narrowed, AP-112 records the remaining attack-start and exact trained-Recklessness 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.
|
||||||
|
|
|
||||||
|
|
@ -185,11 +185,11 @@ The three visual layers remain separate. The gray scrollbar track spans the
|
||||||
whole authored control. Retail skill-gates element `0x100005EF` to Recklessness
|
whole authored control. Retail skill-gates element `0x100005EF` to Recklessness
|
||||||
advancement class `Trained` or higher when scalar message `0x0A` is received.
|
advancement class `Trained` or higher when scalar message `0x0A` is received.
|
||||||
Per the connected visual gate, acdream uses that dark-red media as the always-
|
Per the connected visual gate, acdream uses that dark-red media as the always-
|
||||||
visible middle baseline and renders the bright texture as a desired-power band
|
visible middle baseline (intentional divergence IA-20); the remaining exact
|
||||||
from the absolute left edge to the green thumb (intentional divergence IA-20);
|
skill-gated treatment stays in AP-112. The bright child retains retail's live
|
||||||
the remaining exact skill-gated treatment stays in AP-112. Retail instead drives
|
`RecvNotice_SetPowerbarLevel` input and spans the entire scrollbar from the
|
||||||
the meter child independently through `RecvNotice_SetPowerbarLevel`, while the
|
absolute left edge, independently of the dark child's inset geometry. The green
|
||||||
green thumb is driven by `RecvNotice_DesiredAttackPowerChanged`.
|
thumb remains driven by `RecvNotice_DesiredAttackPowerChanged`.
|
||||||
|
|
||||||
The meter authors attribute `0x6F = 1`. `UIElement_Meter` constructor and
|
The meter authors attribute `0x6F = 1`. `UIElement_Meter` constructor and
|
||||||
`DrawChildren @ 0x0046FBD0` make direction 1 the forward horizontal clip, so
|
`DrawChildren @ 0x0046FBD0` make direction 1 the forward horizontal clip, so
|
||||||
|
|
@ -205,10 +205,10 @@ value `1`, right-aligns values `3` and `5`, and treats every other value as left
|
||||||
Power uses right justification.
|
Power uses right justification.
|
||||||
|
|
||||||
The combat subtree inherits from an 800-pixel prototype and reflows into its
|
The combat subtree inherits from an 800-pixel prototype and reflows into its
|
||||||
610-pixel mounted root on the first layout traversal. Therefore the controller
|
610-pixel mounted root on the first layout traversal. Scrollbars consume their
|
||||||
must not snapshot sibling positions during `Bind`: before reflow the bar is
|
DAT children procedurally, so the dark child must retain its own four-edge
|
||||||
`x=8,w=707` and Power is `x=611`; after retail anchoring they are `x=8,w=507`
|
`UiLayoutPolicy`; copying only its sprite loses the child geometry. The authored
|
||||||
and `x=411`. `ScalarRangeProvider` reads the live sibling rectangles during
|
child starts at `x=70,w=567` inside the inherited 707-pixel meter and its
|
||||||
drawing, producing the centered local interval `x=104,w=299`. Freezing the
|
proportional edge modes reflow to `x=50,w=407` inside the mounted 507-pixel bar.
|
||||||
pre-reflow width clamps that range to the bar's right edge and visibly shifts
|
This is the wider centered interval that clears the rendered Speed/Power words;
|
||||||
the dark texture toward Power.
|
using the full 100-pixel text element boxes produced an overly narrow range.
|
||||||
|
|
|
||||||
|
|
@ -93,8 +93,7 @@ public sealed class CombatUiController : IRetainedPanelController
|
||||||
|
|
||||||
_powerControl.SetScalarPosition(_attacks.DesiredPower);
|
_powerControl.SetScalarPosition(_attacks.DesiredPower);
|
||||||
_powerControl.ScalarChanged = _attacks.SetDesiredPower;
|
_powerControl.ScalarChanged = _attacks.SetDesiredPower;
|
||||||
_powerControl.ScalarFill = () => null;
|
_powerControl.ScalarFill = () => _attacks.PowerBarLevel;
|
||||||
_powerControl.ScalarFillFollowsThumb = true;
|
|
||||||
|
|
||||||
BindAttackButton(_high, AttackHeight.High);
|
BindAttackButton(_high, AttackHeight.High);
|
||||||
BindAttackButton(_medium, AttackHeight.Medium);
|
BindAttackButton(_medium, AttackHeight.Medium);
|
||||||
|
|
@ -110,7 +109,6 @@ public sealed class CombatUiController : IRetainedPanelController
|
||||||
UiText? powerLabel = layout.FindElement(PowerLabelId) as UiText;
|
UiText? powerLabel = layout.FindElement(PowerLabelId) as UiText;
|
||||||
SetStaticText(speedLabel, labels.Speed, rightAligned: false);
|
SetStaticText(speedLabel, labels.Speed, rightAligned: false);
|
||||||
SetStaticText(powerLabel, labels.Power, rightAligned: true);
|
SetStaticText(powerLabel, labels.Power, rightAligned: true);
|
||||||
ConfigurePowerRange(speedLabel, powerLabel);
|
|
||||||
|
|
||||||
_repeatAttacks.OnClick = () =>
|
_repeatAttacks.OnClick = () =>
|
||||||
_setGameplay(_gameplay() with { AutoRepeatAttack = _repeatAttacks.Selected });
|
_setGameplay(_gameplay() with { AutoRepeatAttack = _repeatAttacks.Selected });
|
||||||
|
|
@ -194,24 +192,6 @@ public sealed class CombatUiController : IRetainedPanelController
|
||||||
_keepInView.Selected = gameplay.ViewCombatTarget;
|
_keepInView.Selected = gameplay.ViewCombatTarget;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ConfigurePowerRange(UiText? speedLabel, UiText? powerLabel)
|
|
||||||
{
|
|
||||||
if (speedLabel is null || powerLabel is null) return;
|
|
||||||
|
|
||||||
// All three elements are siblings in gmCombatUI's basic page. Keeping
|
|
||||||
// the meter between the authored text rectangles leaves the base gray
|
|
||||||
// track behind Speed/Power while the dark/live red textures occupy the
|
|
||||||
// semantic power range between them.
|
|
||||||
_powerControl.ScalarRangeProvider = () =>
|
|
||||||
{
|
|
||||||
float left = speedLabel.Left + speedLabel.Width - _powerControl.Left;
|
|
||||||
float right = powerLabel.Left - _powerControl.Left;
|
|
||||||
float clampedLeft = Math.Clamp(left, 0f, _powerControl.Width);
|
|
||||||
return (clampedLeft, Math.Max(
|
|
||||||
0f, Math.Clamp(right, 0f, _powerControl.Width) - clampedLeft));
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void SetStaticText(UiText? text, string value, bool rightAligned)
|
private static void SetStaticText(UiText? text, string value, bool rightAligned)
|
||||||
{
|
{
|
||||||
if (text is null) return;
|
if (text is null) return;
|
||||||
|
|
@ -236,8 +216,6 @@ public sealed class CombatUiController : IRetainedPanelController
|
||||||
_attacks.StateChanged -= OnAttackStateChanged;
|
_attacks.StateChanged -= OnAttackStateChanged;
|
||||||
_powerControl.ScalarChanged = null;
|
_powerControl.ScalarChanged = null;
|
||||||
_powerControl.ScalarFill = () => null;
|
_powerControl.ScalarFill = () => null;
|
||||||
_powerControl.ScalarFillFollowsThumb = false;
|
|
||||||
_powerControl.ScalarRangeProvider = null;
|
|
||||||
_high.OnPressed = null;
|
_high.OnPressed = null;
|
||||||
_high.OnReleased = null;
|
_high.OnReleased = null;
|
||||||
_medium.OnPressed = null;
|
_medium.OnPressed = null;
|
||||||
|
|
|
||||||
|
|
@ -110,23 +110,7 @@ public static class DatWidgetFactory
|
||||||
// design parent and intentionally remain on the compatibility path until a
|
// design parent and intentionally remain on the compatibility path until a
|
||||||
// window mount assigns its own outer-frame policy.
|
// window mount assigns its own outer-frame policy.
|
||||||
if (info.HasOriginalParentSize)
|
if (info.HasOriginalParentSize)
|
||||||
{
|
e.LayoutPolicy = CreateLayoutPolicy(info);
|
||||||
e.LayoutPolicy = new UiLayoutPolicy(
|
|
||||||
info.Left,
|
|
||||||
info.Top,
|
|
||||||
info.Right,
|
|
||||||
info.Bottom,
|
|
||||||
UiPixelRect.FromPositionAndSize(
|
|
||||||
(int)info.X,
|
|
||||||
(int)info.Y,
|
|
||||||
(int)info.Width,
|
|
||||||
(int)info.Height),
|
|
||||||
UiPixelRect.FromPositionAndSize(
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
(int)info.OriginalParentWidth,
|
|
||||||
(int)info.OriginalParentHeight));
|
|
||||||
}
|
|
||||||
|
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
@ -186,6 +170,9 @@ public static class DatWidgetFactory
|
||||||
bar.ScalarRangeSprite = scalarRange is null
|
bar.ScalarRangeSprite = scalarRange is null
|
||||||
? 0u
|
? 0u
|
||||||
: DefaultImage(scalarRange);
|
: DefaultImage(scalarRange);
|
||||||
|
bar.ScalarRangeLayoutPolicy = scalarRange is null
|
||||||
|
? null
|
||||||
|
: CreateLayoutPolicy(scalarRange);
|
||||||
// UIElement_Meter::UIElement_Meter @ 0x0046F4C0 defaults direction
|
// UIElement_Meter::UIElement_Meter @ 0x0046F4C0 defaults direction
|
||||||
// 1; DrawChildren @ 0x0046FBD0 clips that direction left-to-right.
|
// 1; DrawChildren @ 0x0046FBD0 clips that direction left-to-right.
|
||||||
// Direction 3 is the horizontal reverse. Read authored attribute
|
// Direction 3 is the horizontal reverse. Read authored attribute
|
||||||
|
|
@ -221,6 +208,26 @@ public static class DatWidgetFactory
|
||||||
return bar;
|
return bar;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static UiLayoutPolicy? CreateLayoutPolicy(ElementInfo info)
|
||||||
|
{
|
||||||
|
if (!info.HasOriginalParentSize) return null;
|
||||||
|
return new UiLayoutPolicy(
|
||||||
|
info.Left,
|
||||||
|
info.Top,
|
||||||
|
info.Right,
|
||||||
|
info.Bottom,
|
||||||
|
UiPixelRect.FromPositionAndSize(
|
||||||
|
(int)info.X,
|
||||||
|
(int)info.Y,
|
||||||
|
(int)info.Width,
|
||||||
|
(int)info.Height),
|
||||||
|
UiPixelRect.FromPositionAndSize(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
(int)info.OriginalParentWidth,
|
||||||
|
(int)info.OriginalParentHeight));
|
||||||
|
}
|
||||||
|
|
||||||
private static uint ReferencedElementId(ElementInfo info, uint propertyId)
|
private static uint ReferencedElementId(ElementInfo info, uint propertyId)
|
||||||
{
|
{
|
||||||
if (!info.TryGetEffectiveProperty(propertyId, out var property))
|
if (!info.TryGetEffectiveProperty(propertyId, out var property))
|
||||||
|
|
|
||||||
|
|
@ -49,13 +49,10 @@ public sealed class UiScrollbar : UiElement
|
||||||
public float ScalarRangeLeft { get; set; }
|
public float ScalarRangeLeft { get; set; }
|
||||||
public float ScalarRangeWidth { get; set; } = float.PositiveInfinity;
|
public float ScalarRangeWidth { get; set; } = float.PositiveInfinity;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Optional live range geometry. Imported sibling controls reflow immediately
|
/// Authored layout policy for a consumed range child. This preserves the
|
||||||
/// before drawing, so combat derives its centered range from their current
|
/// child's own DAT edge modes even though the scrollbar draws it procedurally.
|
||||||
/// rectangles instead of freezing pre-reflow prototype coordinates.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Func<(float Left, float Width)>? ScalarRangeProvider { get; set; }
|
public UiLayoutPolicy? ScalarRangeLayoutPolicy { get; set; }
|
||||||
/// <summary>Draw the scalar fill from the absolute left edge to the thumb center.</summary>
|
|
||||||
public bool ScalarFillFollowsThumb { get; set; }
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Draw the scalar meter from the power end (right) toward the speed end
|
/// Draw the scalar meter from the power end (right) toward the speed end
|
||||||
/// (left). This is the authored gmCombatUI power meter direction.
|
/// (left). This is the authored gmCombatUI power meter direction.
|
||||||
|
|
@ -150,20 +147,15 @@ public sealed class UiScrollbar : UiElement
|
||||||
DrawTiled(ctx, resolve, ScalarRangeSprite,
|
DrawTiled(ctx, resolve, ScalarRangeSprite,
|
||||||
rangeLeft, 0f, rangeWidth, Height);
|
rangeLeft, 0f, rangeWidth, Height);
|
||||||
float thumbWidth = ScalarThumbWidth(resolve);
|
float thumbWidth = ScalarThumbWidth(resolve);
|
||||||
if (ScalarFillSprite != 0 && ScalarFillFollowsThumb)
|
if (ScalarFill() is float fill && ScalarFillSprite != 0)
|
||||||
{
|
{
|
||||||
var (fillX, visibleWidth) = ScalarThumbFillRect(
|
// The authored charge child spans the entire scrollbar; it does
|
||||||
Width, thumbWidth, ScalarPosition);
|
// not share the dark range child's inset geometry.
|
||||||
|
var (fillX, visibleWidth) = ScalarFillRect(
|
||||||
|
Width, fill, ScalarFillFromRight);
|
||||||
DrawTiledClipped(ctx, resolve, ScalarFillSprite,
|
DrawTiledClipped(ctx, resolve, ScalarFillSprite,
|
||||||
0f, fillX, visibleWidth, Height);
|
0f, fillX, visibleWidth, Height);
|
||||||
}
|
}
|
||||||
else if (ScalarFill() is float fill && ScalarFillSprite != 0)
|
|
||||||
{
|
|
||||||
var (fillX, visibleWidth) = ScalarFillRect(
|
|
||||||
rangeLeft, rangeWidth, fill, ScalarFillFromRight);
|
|
||||||
DrawTiledClipped(ctx, resolve, ScalarFillSprite,
|
|
||||||
rangeLeft, fillX, visibleWidth, Height);
|
|
||||||
}
|
|
||||||
float travel = MathF.Max(0f, Width - thumbWidth);
|
float travel = MathF.Max(0f, Width - thumbWidth);
|
||||||
float x = travel * ScalarPosition;
|
float x = travel * ScalarPosition;
|
||||||
DrawSprite(ctx, resolve, ThumbSprite, x, 0f, thumbWidth, Height);
|
DrawSprite(ctx, resolve, ThumbSprite, x, 0f, thumbWidth, Height);
|
||||||
|
|
@ -246,10 +238,18 @@ public sealed class UiScrollbar : UiElement
|
||||||
ctx.DrawSprite(tex, x, 0f, w, h, u0, 0f, u1, h / th, Vector4.One);
|
ctx.DrawSprite(tex, x, 0f, w, h, u0, 0f, u1, h / th, Vector4.One);
|
||||||
}
|
}
|
||||||
|
|
||||||
private (float left, float width) ScalarRangeRect()
|
internal (float left, float width) ScalarRangeRect()
|
||||||
{
|
{
|
||||||
(float configuredLeft, float configuredWidth) = ScalarRangeProvider?.Invoke()
|
float configuredLeft = ScalarRangeLeft;
|
||||||
?? (ScalarRangeLeft, ScalarRangeWidth);
|
float configuredWidth = ScalarRangeWidth;
|
||||||
|
if (ScalarRangeLayoutPolicy is { } policy)
|
||||||
|
{
|
||||||
|
UiPixelRect currentParent = UiPixelRect.FromPositionAndSize(
|
||||||
|
0, 0, (int)Width, (int)Height);
|
||||||
|
UiPixelRect range = policy.Apply(policy.OriginalChild, currentParent);
|
||||||
|
configuredLeft = range.X0;
|
||||||
|
configuredWidth = range.Width;
|
||||||
|
}
|
||||||
float left = Math.Clamp(configuredLeft, 0f, Width);
|
float left = Math.Clamp(configuredLeft, 0f, Width);
|
||||||
float requestedWidth = float.IsPositiveInfinity(configuredWidth)
|
float requestedWidth = float.IsPositiveInfinity(configuredWidth)
|
||||||
? Width - left
|
? Width - left
|
||||||
|
|
@ -258,17 +258,6 @@ public sealed class UiScrollbar : UiElement
|
||||||
return (left, width);
|
return (left, width);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Returns the absolute-left fill ending at the scalar thumb center.</summary>
|
|
||||||
public static (float x, float width) ScalarThumbFillRect(
|
|
||||||
float totalWidth, float thumbWidth, float position)
|
|
||||||
{
|
|
||||||
float safeWidth = MathF.Max(0f, totalWidth);
|
|
||||||
float safeThumb = Math.Clamp(thumbWidth, 0f, safeWidth);
|
|
||||||
float travel = safeWidth - safeThumb;
|
|
||||||
float center = travel * Math.Clamp(position, 0f, 1f) + safeThumb * 0.5f;
|
|
||||||
return (0f, center);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override bool OnEvent(in UiEvent e)
|
public override bool OnEvent(in UiEvent e)
|
||||||
{
|
{
|
||||||
if (Horizontal && ScalarChanged is not null)
|
if (Horizontal && ScalarChanged is not null)
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ public sealed class CombatLayoutConformanceTests
|
||||||
Assert.Equal(0x06001923u, power.ThumbSprite);
|
Assert.Equal(0x06001923u, power.ThumbSprite);
|
||||||
Assert.Equal(0x06001200u, power.ScalarFillSprite);
|
Assert.Equal(0x06001200u, power.ScalarFillSprite);
|
||||||
Assert.Equal(0x0600715Eu, power.ScalarRangeSprite);
|
Assert.Equal(0x0600715Eu, power.ScalarRangeSprite);
|
||||||
|
Assert.NotNull(power.ScalarRangeLayoutPolicy);
|
||||||
Assert.False(power.ScalarFillFromRight);
|
Assert.False(power.ScalarFillFromRight);
|
||||||
|
|
||||||
var speed = Assert.IsType<UiText>(
|
var speed = Assert.IsType<UiText>(
|
||||||
|
|
@ -39,6 +40,7 @@ public sealed class CombatLayoutConformanceTests
|
||||||
(8f, 507f, 12f, 100f, 411f, 100f),
|
(8f, 507f, 12f, 100f, 411f, 100f),
|
||||||
(power.Left, power.Width, speed.Left, speed.Width,
|
(power.Left, power.Width, speed.Left, speed.Width,
|
||||||
powerLabel.Left, powerLabel.Width));
|
powerLabel.Left, powerLabel.Width));
|
||||||
|
Assert.Equal((50f, 407f), power.ScalarRangeRect());
|
||||||
|
|
||||||
var repeat = Assert.IsType<UiButton>(
|
var repeat = Assert.IsType<UiButton>(
|
||||||
layout.FindElement(CombatUiController.RepeatAttacksId));
|
layout.FindElement(CombatUiController.RepeatAttacksId));
|
||||||
|
|
|
||||||
|
|
@ -50,9 +50,7 @@ public sealed class CombatUiControllerTests
|
||||||
|
|
||||||
high.OnEvent(new UiEvent(0, high, UiEventType.MouseDown, Data1: 2, Data2: 2));
|
high.OnEvent(new UiEvent(0, high, UiEventType.MouseDown, Data1: 2, Data2: 2));
|
||||||
now = 4.5d;
|
now = 4.5d;
|
||||||
Assert.True(power.ScalarFillFollowsThumb);
|
Assert.Equal(0.5f, power.ScalarFill()!.Value, 3);
|
||||||
var range = Assert.IsType<Func<(float Left, float Width)>>(power.ScalarRangeProvider);
|
|
||||||
Assert.Equal((104f, 299f), range());
|
|
||||||
high.OnEvent(new UiEvent(0, high, UiEventType.MouseUp, Data1: 2, Data2: 2));
|
high.OnEvent(new UiEvent(0, high, UiEventType.MouseUp, Data1: 2, Data2: 2));
|
||||||
|
|
||||||
var attack = Assert.Single(sent);
|
var attack = Assert.Single(sent);
|
||||||
|
|
@ -98,10 +96,6 @@ public sealed class CombatUiControllerTests
|
||||||
Assert.False(speed.RightAligned);
|
Assert.False(speed.RightAligned);
|
||||||
Assert.False(powerLabel.Centered);
|
Assert.False(powerLabel.Centered);
|
||||||
Assert.True(powerLabel.RightAligned);
|
Assert.True(powerLabel.RightAligned);
|
||||||
var range = Assert.IsType<Func<(float Left, float Width)>>(power.ScalarRangeProvider);
|
|
||||||
var (left, width) = range();
|
|
||||||
Assert.Equal(speed.Left + speed.Width - power.Left, left, 3);
|
|
||||||
Assert.Equal(powerLabel.Left - power.Left - left, width, 3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -120,8 +114,7 @@ public sealed class CombatUiControllerTests
|
||||||
|
|
||||||
var power = Assert.IsType<UiScrollbar>(
|
var power = Assert.IsType<UiScrollbar>(
|
||||||
layout.FindElement(CombatUiController.PowerControlId));
|
layout.FindElement(CombatUiController.PowerControlId));
|
||||||
var range = Assert.IsType<Func<(float Left, float Width)>>(power.ScalarRangeProvider);
|
Assert.Equal((50f, 407f), power.ScalarRangeRect());
|
||||||
Assert.Equal((104f, 299f), range());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static readonly CombatUiLabels Labels = new(
|
private static readonly CombatUiLabels Labels = new(
|
||||||
|
|
|
||||||
|
|
@ -473,6 +473,9 @@ public class DatWidgetFactoryTests
|
||||||
X = 63,
|
X = 63,
|
||||||
Width = 407,
|
Width = 407,
|
||||||
Height = 14,
|
Height = 14,
|
||||||
|
OriginalParentWidth = 507,
|
||||||
|
OriginalParentHeight = 14,
|
||||||
|
HasOriginalParentSize = true,
|
||||||
};
|
};
|
||||||
range.StateMedia[""] = (0x0600715Eu, 1);
|
range.StateMedia[""] = (0x0600715Eu, 1);
|
||||||
range.States[UiStateInfo.DirectStateId] = new UiStateInfo
|
range.States[UiStateInfo.DirectStateId] = new UiStateInfo
|
||||||
|
|
@ -506,6 +509,7 @@ public class DatWidgetFactoryTests
|
||||||
Assert.Equal(0x06001923u, bar.ThumbSprite);
|
Assert.Equal(0x06001923u, bar.ThumbSprite);
|
||||||
Assert.Equal(0x06001200u, bar.ScalarFillSprite);
|
Assert.Equal(0x06001200u, bar.ScalarFillSprite);
|
||||||
Assert.Equal(0x0600715Eu, bar.ScalarRangeSprite);
|
Assert.Equal(0x0600715Eu, bar.ScalarRangeSprite);
|
||||||
|
Assert.NotNull(bar.ScalarRangeLayoutPolicy);
|
||||||
Assert.False(bar.ScalarFillFromRight);
|
Assert.False(bar.ScalarFillFromRight);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -128,16 +128,4 @@ public class UiScrollbarTests
|
||||||
Assert.Equal(expectedWidth, width, 3);
|
Assert.Equal(expectedWidth, width, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData(0f, 6f)]
|
|
||||||
[InlineData(0.5f, 253.5f)]
|
|
||||||
[InlineData(1f, 501f)]
|
|
||||||
public void ScalarThumbFillRect_StartsAtAbsoluteLeftAndEndsAtThumbCenter(
|
|
||||||
float position, float expectedWidth)
|
|
||||||
{
|
|
||||||
var (x, width) = UiScrollbar.ScalarThumbFillRect(
|
|
||||||
totalWidth: 507f, thumbWidth: 12f, position);
|
|
||||||
Assert.Equal(0f, x, 3);
|
|
||||||
Assert.Equal(expectedWidth, width, 3);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue