feat(ui): port retail jump power bar
Import gmFloatyPowerBarUI LayoutDesc 0x21000072, teach the meter factory its stateful single-image shape, and project the movement-owned retail jump charge through a focused retained controller. Preserve authored resize constraints and state-managed visibility, with named-decomp pseudocode plus controller, movement, and production-DAT conformance coverage. Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
parent
27619b2328
commit
db03b4bda8
18 changed files with 3045 additions and 10 deletions
|
|
@ -46,6 +46,36 @@ Copy this block when adding a new issue:
|
|||
|
||||
---
|
||||
|
||||
## #209 — Retail jump power bar missing
|
||||
|
||||
**Status:** IN-PROGRESS — implementation complete 2026-07-13, pending live visual gate
|
||||
**Severity:** MEDIUM
|
||||
**Component:** retained UI / movement
|
||||
|
||||
**Description:** Holding the jump key charged the movement system correctly,
|
||||
but the separate retail floating power bar did not appear or show jump power.
|
||||
|
||||
**Root cause:** The production retained runtime had not mounted
|
||||
`gmFloatyPowerBarUI` LayoutDesc `0x21000072`. The generic meter importer also
|
||||
mistook its stateful fill child plus hidden Recklessness child for a vitals-style
|
||||
three-slice meter, leaving all slice ids empty.
|
||||
|
||||
**Resolution:** The importer now recognizes the stateful single-image meter
|
||||
shape: the authored direct image is the empty track and the selected numeric
|
||||
child state supplies the fill. `JumpPowerbarController` selects JumpMode, reads
|
||||
the movement controller's existing retail-timed charge snapshot, shows the
|
||||
state-managed window on charge begin, fills left-to-right, and resets/hides it
|
||||
on release. Geometry and resize limits come from the authored DAT layout.
|
||||
|
||||
**Research:** `docs/research/2026-07-13-retail-jump-powerbar-pseudocode.md`
|
||||
|
||||
**Acceptance:** In the connected Release client, hold Jump: the 610×25 retail
|
||||
bar appears and fills smoothly from its left edge to its right edge in about
|
||||
one second. Keep holding at full, then release: the bar resets and disappears as
|
||||
the jump starts. Repeat after moving/resizing the bar and after relogging.
|
||||
|
||||
---
|
||||
|
||||
## #208 — Combat bar appears after logging in peacefully
|
||||
|
||||
**Status:** IN-PROGRESS — fix shipped 2026-07-12, pending live gate
|
||||
|
|
|
|||
|
|
@ -506,7 +506,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar.
|
|||
- **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.
|
||||
- **✓ D.5.3 — Toolbar selected-object display (issue #141) SHIPPED.** Exact health/player/pet/Free-PK policy, selected-item mana (`0x100001A2`), stack entry (`0x100001A3`), stack slider (`0x100001A4`), formatted count/name, shared split quantity, click-to-use, peace/war, health/name, and paperdoll self/upper-item selection all passed live by 2026-07-11. Exact raw `ShortCutData` preservation is already shipped in Wave 4.2; the eight `favorite_spells_` lists belong to the separate spellbook/spell-bar phase.
|
||||
- **☐ D.5.5+ — Core panels.** Inventory (`gmInventoryUI`/`gmBackpackUI`), equipment/paperdoll (`gmPaperDollUI`/`gm3DItemsUI` + the `UiViewport` 3D doll), vendor, trade, spellbook. Research drop done (`docs/research/2026-06-16-*`). Depends on **D.5.4** (data model) + the item-slot/list/icon spine (D.5.1/D.5.2) + the **window manager** (Plan 2: open/close/z-order/persist + faithful grip/dragbar drag-resize) + the drag-drop spine wired (`UiRoot` has the chain; the per-cell accept/drop hooks are still stubs in `UiField`). Also deferred from D.5.1: drag/reorder + the `AddShortcut`/`RemoveShortcut` mutate wire.
|
||||
- **D.6 — HUD.** **Radar/compass IMPLEMENTED 2026-07-10; live-world visual gate pending.** It is the retained retail `gmRadarUI` (`LayoutDesc 0x21000074`): a 120×140 circular radar (`0x06004CC1`) with N/E/S/W token sprites orbiting the face, a fixed bright-green player marker, and the coordinate footer (`0x06004CC0`). Projection is `radarPixelRadius / radarRange` with 75 m outdoor / 25 m indoor ranges — there is no 1.18 factor or scrolling compass strip. It lives in `AcDream.App/UI` beside the other imported retail panels. Remaining D.6: target name plate, damage floaters, and other world-space HUD elements through the raw `TextRenderer` path. See slice 06 §A.2, `docs/research/2026-07-10-retail-radar-pseudocode.md`, and the named `gmRadarUI` decomp.
|
||||
- **D.6 — HUD.** **Radar/compass IMPLEMENTED 2026-07-10; jump power bar IMPLEMENTED 2026-07-13; live-world visual gates pending.** The radar is retained retail `gmRadarUI` (`LayoutDesc 0x21000074`): a 120×140 circular face (`0x06004CC1`) with orbiting N/E/S/W tokens, a fixed bright-green player marker, and coordinate footer (`0x06004CC0`). Projection is `radarPixelRadius / radarRange` with 75 m outdoor / 25 m indoor ranges — no 1.18 factor or scrolling strip. The separate `gmFloatyPowerBarUI` (`LayoutDesc 0x21000072`) now appears only while jump is pending and clips the authored JumpMode fill left-to-right from the movement controller's shared 0–1 charge. Both live in `AcDream.App/UI` behind focused controllers; `GameWindow` supplies state delegates only. Remaining D.6: target name plate, damage floaters, and other world-space HUD elements through the raw `TextRenderer` path. See slice 06 §A.2, `docs/research/2026-07-10-retail-radar-pseudocode.md`, `docs/research/2026-07-13-retail-jump-powerbar-pseudocode.md`, and the named `gmRadarUI`/`gmPowerbarUI` decomp.
|
||||
- **✓ SHIPPED — D.7 — Cursor manager (user-confirmed 2026-07-11).** All reachable `ClientUISystem::UpdateCursorState @ 0x00564630` branches resolve through production DAT EnumIDMap table 6, with event-ordered global-default versus captured/hovered `MediaDescCursor` layering from `UIElementManager::CheckCursor @ 0x0045ABF0`. OS fallback remains only AP-72 and emits a visible diagnostic. The live gate covered world/object, combat, targeted-use, text, move/resize, and drag contexts; combat-indicator mouse clicks and the quick key share the same toggle command.
|
||||
- ~~**D.8 — Sound.**~~ **Superseded — shipped as Phase E.2** (`SoundTable`/`Sound` dat decode, OpenAL 16-voice engine, per-entity 3D positional audio via `AudioHookSink`). Entry kept here for history; see the shipped table.
|
||||
|
||||
|
|
|
|||
|
|
@ -566,11 +566,13 @@ Open the inventory panel — retail-skinned with the right font, icons, and
|
|||
- **C.3** — Palette range tuning (skin/hair/eye colors match retail).
|
||||
- **C.4** — Double-sided translucent polys.
|
||||
- **D.2b** — Custom retail-look UI backend. **In progress:** vitals + chat + toolbar + inventory (window manager, B-Grid, B-Controller, B-Wire, drag-drop, container-switching) + paperdoll (equip slots, 3-D doll UiViewport, Slots toggle) + **Character window** (Attributes tab, visually confirmed 2026-06-26) + **UI Studio** (standalone panel previewer + 26-window retail dump + interactive click-routing + headless screenshot) + **importer dat-fidelity** (Fix A/B/C: justification, FontColor, per-element FontDid resolver [studio-only; #157 tracks live-game path]) are shipped. Current campaign: shared retained-widget/window architecture, then selected-object mana/stack completion. **Retail correction:** the main toolbar is object-only; the eight favorite-spell lists drive separate spell bars. Execution plan: `docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md`.
|
||||
- **D.3–D.7** — AcFont + dat sprites + core panels reskinned + HUD orbs +
|
||||
cursor manager. **D.6 radar/compass shipped 2026-07-10; D.7 complete retail
|
||||
- **D.3–D.7** — AcFont + dat sprites + core panels reskinned + HUD +
|
||||
cursor manager. **D.6 radar/compass shipped 2026-07-10 and the separate retail
|
||||
jump power bar was implemented 2026-07-13; D.7 complete retail
|
||||
cursor catalog/precedence shipped and user-confirmed 2026-07-11.** The radar uses the retained
|
||||
UI architecture using the retail DAT layout and recovered runtime behavior;
|
||||
live-game visual confirmation remains the user gate.
|
||||
the jump bar uses the same retained composition with movement-owned charge timing.
|
||||
Live-game visual confirmation remains the user gate.
|
||||
- **L.1f** — NPC/monster + item-use animation coverage.
|
||||
|
||||
**Freeze on landing:**
|
||||
|
|
|
|||
135
docs/research/2026-07-13-retail-jump-powerbar-pseudocode.md
Normal file
135
docs/research/2026-07-13-retail-jump-powerbar-pseudocode.md
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
# Retail jump power bar — pseudocode and port map
|
||||
|
||||
Date: 2026-07-13
|
||||
|
||||
Binary: Sept. 2013 EoR `acclient.exe` v11.4186
|
||||
|
||||
Scope: the floating `gmPowerbarUI` presentation used while the player charges a jump
|
||||
|
||||
## Named retail sources
|
||||
|
||||
- `gmPowerbarUI::PostInit @ 0x004DA4E0`
|
||||
- `gmPowerbarUI::RecvNotice_SetPowerbarLevel @ 0x004DA550`
|
||||
- `gmPowerbarUI::RecvNotice_FinishPowerbar @ 0x004DA580`
|
||||
- `gmPowerbarUI::RecvNotice_BeginPowerbar @ 0x004DA730`
|
||||
- `gmFloatyPowerBarUI::PostInit @ 0x004D0F00`
|
||||
- `gmFloatyPowerBarUI::UpdateFromPlayerModule @ 0x004D1200`
|
||||
- `gmFloatyPowerBarUI::ResizeTo @ 0x004D1460`
|
||||
- `gmFloatyPowerBarUI::MoveTo @ 0x004D15D0`
|
||||
- `gmFloatyPowerBarUI::SetVisible @ 0x004D17D0`
|
||||
- `ClientCombatSystem::FinishJump @ 0x0056A9B0`
|
||||
- `ClientCombatSystem::StartPowerBarBuild @ 0x0056ADB0`
|
||||
- `ClientCombatSystem::GetPowerBarLevel @ 0x0056ADE0`
|
||||
- `ClientCombatSystem::CommenceJump @ 0x0056AF90`
|
||||
- `ClientCombatSystem::DoJump @ 0x0056B110`
|
||||
|
||||
The symbols and addresses above are present in
|
||||
`docs/research/named-retail/symbols.json`; the bodies are in
|
||||
`docs/research/named-retail/acclient_2013_pseudo_c.txt`.
|
||||
|
||||
## Authored DAT layout
|
||||
|
||||
Portal DAT `LayoutDesc 0x21000072` is a `gmFloatyPowerBarUI` window:
|
||||
|
||||
| Element | ID | Geometry | Media/state |
|
||||
|---|---:|---:|---|
|
||||
| Root | `0x10000612` | 610×25 px | custom type `0x10000053` |
|
||||
| Meter | `0x10000034` | x=5, y=5, 600×15 px | empty track `0x06004D0B` |
|
||||
| Fill field | `0x00000002` | 600×15 px | JumpMode `0x10000042` → `0x06001354`; MeleeMode `0x10000043` and MissileMode `0x10000044` → `0x06001200`; DDDMode `0x10000045` → `0x0600195B` |
|
||||
| Recklessness field | `0x100005EE` | x=60, 480×15 px | `0x0600715E`; hidden by `PostInit` |
|
||||
|
||||
The meter's fill is clipped horizontally. A level of 0.0 shows no fill; 0.5
|
||||
shows the left 300 px; 1.0 shows the full 600 px. It does not stretch from the
|
||||
center or from the right.
|
||||
|
||||
## Retail lifecycle pseudocode
|
||||
|
||||
```text
|
||||
CommenceJump():
|
||||
if jump_pending:
|
||||
return
|
||||
error = motion_interpreter.charge_jump()
|
||||
if error != success:
|
||||
report the matching jump error
|
||||
return
|
||||
cancel repeat attack if active
|
||||
latest_powerbar_level = 0
|
||||
jump_pending = true
|
||||
powerbar_mode = JUMP
|
||||
notify BeginPowerbar(JUMP)
|
||||
StartPowerBarBuild()
|
||||
|
||||
StartPowerBarBuild():
|
||||
build_in_progress = true
|
||||
build_start_time = current_time
|
||||
notify SetPowerbarLevel(powerbar_mode, 0)
|
||||
latest_powerbar_level = 0
|
||||
|
||||
GetPowerBarLevel():
|
||||
if not build_in_progress:
|
||||
return 0
|
||||
duration = 0.8 seconds in dual-wield style, otherwise 1.0 second
|
||||
return clamp((current_time - build_start_time) / duration, 0, 1)
|
||||
|
||||
gmPowerbarUI.BeginPowerbar(JUMP):
|
||||
set root state to JumpMode (0x10000042)
|
||||
current_mode = JUMP
|
||||
set meter 0x10000034 level to 0
|
||||
show window
|
||||
|
||||
gmPowerbarUI.SetPowerbarLevel(mode, level):
|
||||
if mode == current_mode:
|
||||
set meter 0x10000034 level to level
|
||||
|
||||
DoJump(on release):
|
||||
extent = max(GetPowerBarLevel(), MIN_JUMP_EXTENT)
|
||||
FinishJump()
|
||||
motion_interpreter.jump(extent)
|
||||
send JumpPack(extent, velocity, position/update sequences)
|
||||
|
||||
FinishJump():
|
||||
if jump_pending:
|
||||
stop build and clear its time
|
||||
notify FinishPowerbar(JUMP)
|
||||
powerbar_mode = UNDEFINED
|
||||
clear standing_longjump
|
||||
jump_pending = false
|
||||
|
||||
gmPowerbarUI.FinishPowerbar(JUMP):
|
||||
if JUMP == current_mode:
|
||||
current_mode = UNDEFINED
|
||||
set meter level to 0
|
||||
hide window
|
||||
```
|
||||
|
||||
`gmPowerbarUI::PostInit` also hides the recklessness field. The floaty subclass
|
||||
restores and persists its position, size, lock chrome, and visibility through
|
||||
the normal retail window preferences.
|
||||
|
||||
## Reference cross-checks
|
||||
|
||||
- ACE `ACE.Server/Network/Structure/JumpPack.cs` documents `Extent` as jump
|
||||
power 0–1. `ACE.Server/WorldObjects/Player.cs::HandleActionJump` clamps the
|
||||
received extent to that same interval before applying jump stamina/height.
|
||||
- holtburger `crates/holtburger-core/src/client/commands.rs` carries the chosen
|
||||
extent unchanged into the `JumpData` action. Its vendored ACViewer movement
|
||||
implementation likewise stores jump extent as the power input to the jump
|
||||
calculation.
|
||||
|
||||
These references corroborate the data range and its handoff. The named retail
|
||||
decomp remains authoritative for charge timing, notices, visibility, and UI
|
||||
state selection.
|
||||
|
||||
## acdream port map
|
||||
|
||||
- `PlayerMovementController` already owns the exact shared retail charge clock
|
||||
and release transition. Expose it as a read-only `(IsCharging, Power)`
|
||||
snapshot; do not add a UI timer.
|
||||
- Teach `UiMeter`/`DatWidgetFactory` the DAT's stateful single-image meter shape:
|
||||
meter direct media is the track, child state media is the selected fill.
|
||||
- `JumpPowerbarController` selects JumpMode, binds fill to the movement snapshot,
|
||||
and performs the begin/show and finish/reset/hide transitions.
|
||||
- `RetailUiRuntime` imports and mounts `0x21000072` as a state-managed retained
|
||||
window. `GameWindow` only supplies the movement snapshot delegate.
|
||||
|
||||
This port introduces no intentional retail divergence.
|
||||
|
|
@ -18,6 +18,13 @@ public readonly record struct MovementInput(
|
|||
float MouseDeltaX = 0f,
|
||||
bool Jump = false);
|
||||
|
||||
/// <summary>
|
||||
/// Read-only presentation snapshot of retail's pending jump build. The movement
|
||||
/// controller remains the sole owner of charge timing; retained UI only projects
|
||||
/// this state into <c>gmPowerbarUI</c>.
|
||||
/// </summary>
|
||||
public readonly record struct JumpChargeSnapshot(bool IsCharging, float Power);
|
||||
|
||||
/// <summary>
|
||||
/// Result of a single frame's movement update.
|
||||
///
|
||||
|
|
@ -165,6 +172,13 @@ public sealed class PlayerMovementController
|
|||
// Jump charge state.
|
||||
private bool _jumpCharging;
|
||||
private float _jumpExtent;
|
||||
|
||||
/// <summary>
|
||||
/// Current retail jump-powerbar state. Power is always zero when no jump is
|
||||
/// pending and otherwise lies in [0,1].
|
||||
/// </summary>
|
||||
public JumpChargeSnapshot JumpCharge
|
||||
=> new(_jumpCharging, _jumpCharging ? _jumpExtent : 0f);
|
||||
// Matching v11.4186 x86 resolves GetPowerBarLevel's collapsed x87
|
||||
// operands: ATTACK_POWERUP_TIME=1.0 s, DUAL_WIELD_POWERUP_TIME=0.8 s.
|
||||
// Jump uses the same shared powerbar function, so its normal fill rate is
|
||||
|
|
|
|||
|
|
@ -2142,6 +2142,8 @@ public sealed class GameWindow : IDisposable
|
|||
_combatAttackController,
|
||||
() => _persistedGameplay,
|
||||
SetRetailCombatGameplay),
|
||||
JumpPowerbar: new AcDream.App.UI.JumpPowerbarRuntimeBindings(
|
||||
() => _playerController?.JumpCharge ?? default),
|
||||
Toolbar: new AcDream.App.UI.ToolbarRuntimeBindings(
|
||||
Objects,
|
||||
() => Shortcuts,
|
||||
|
|
|
|||
|
|
@ -311,7 +311,9 @@ public static class DatWidgetFactory
|
|||
.OrderBy(c => c.ReadOrder)
|
||||
.ToList();
|
||||
|
||||
if (containers.Count >= 2)
|
||||
if (containers.Count >= 2
|
||||
&& HasThreeSliceShape(containers[0])
|
||||
&& HasThreeSliceShape(containers[1]))
|
||||
{
|
||||
// Vitals 3-slice shape: two Type-3 containers each holding 3 grandchild images
|
||||
// (left-cap / center-tile / right-cap). Back is the lower ReadOrder; front is higher.
|
||||
|
|
@ -325,7 +327,7 @@ public static class DatWidgetFactory
|
|||
m.FrontTile = ft;
|
||||
m.FrontRight = fr;
|
||||
}
|
||||
else if (containers.Count == 1)
|
||||
else if (containers.Count == 1 && containers[0].StateMedia.ContainsKey(""))
|
||||
{
|
||||
// Single-image shape used by the toolbar selected-object meters
|
||||
// (health 0x100001A1, mana 0x100001A2).
|
||||
|
|
@ -354,15 +356,50 @@ public static class DatWidgetFactory
|
|||
m.FrontTile = containers[0].StateMedia.TryGetValue("", out var fm) ? fm.File : 0u;
|
||||
m.FrontRight = 0;
|
||||
}
|
||||
else if (containers.Any(HasStatefulFill))
|
||||
{
|
||||
// Stateful single-image shape used by gmPowerbarUI (LayoutDesc
|
||||
// 0x21000072). The meter's DirectState is the empty track. One
|
||||
// Type-3 child supplies named Jump/Melee/Missile/DDD state media;
|
||||
// another carries the optional recklessness range as DirectState.
|
||||
// gmPowerbarUI::PostInit @ 0x004DA4E0 hides that range, so it is not
|
||||
// promoted into a meter slice here.
|
||||
m.BackLeft = 0;
|
||||
m.BackTile = info.StateMedia.TryGetValue("", out var track) ? track.File : 0u;
|
||||
m.BackRight = 0;
|
||||
m.FrontLeft = 0;
|
||||
m.FrontTile = 0;
|
||||
m.FrontRight = 0;
|
||||
|
||||
foreach (ElementInfo container in containers)
|
||||
{
|
||||
foreach (var (stateId, state) in container.States)
|
||||
{
|
||||
if (stateId == UiStateInfo.DirectStateId
|
||||
|| !container.StateMedia.TryGetValue(state.Name, out var media))
|
||||
continue;
|
||||
m.ConfigureStateFill(stateId, media.File);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Count == 0: no Type-3 containers at all — genuinely malformed meter dat.
|
||||
Console.WriteLine($"[D.2b] meter 0x{info.Id:X8}: {containers.Count} Type-3 slice containers (expected 1 or 2) — bars may render as solid-color fallback.");
|
||||
Console.WriteLine($"[D.2b] meter 0x{info.Id:X8}: {containers.Count} Type-3 containers but no recognized 3-slice, direct-fill, or stateful-fill shape — bar may render as solid-color fallback.");
|
||||
}
|
||||
|
||||
return m;
|
||||
}
|
||||
|
||||
private static bool HasThreeSliceShape(ElementInfo container)
|
||||
=> container.Children.Count(c =>
|
||||
c.StateMedia.TryGetValue("", out var media) && media.File != 0) >= 3;
|
||||
|
||||
private static bool HasStatefulFill(ElementInfo container)
|
||||
=> container.States.Any(pair =>
|
||||
pair.Key != UiStateInfo.DirectStateId
|
||||
&& container.StateMedia.TryGetValue(pair.Value.Name, out var media)
|
||||
&& media.File != 0);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the (left, tile, right) sprite ids for a 3-slice container,
|
||||
/// extracting them from the container's image children that carry a DirectState
|
||||
|
|
|
|||
91
src/AcDream.App/UI/Layout/JumpPowerbarController.cs
Normal file
91
src/AcDream.App/UI/Layout/JumpPowerbarController.cs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
using AcDream.App.Input;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Projects <see cref="PlayerMovementController.JumpCharge"/> into the imported
|
||||
/// retail <c>gmFloatyPowerBarUI</c>. The movement controller owns the clock; this
|
||||
/// controller owns only DAT state, fill, and window visibility.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Retail references: <c>gmPowerbarUI::RecvNotice_BeginPowerbar</c>
|
||||
/// (0x004DA730), <c>RecvNotice_SetPowerbarLevel</c> (0x004DA550),
|
||||
/// <c>RecvNotice_FinishPowerbar</c> (0x004DA580), and
|
||||
/// <c>ClientCombatSystem::CommenceJump</c> (0x0056AF90).
|
||||
/// </remarks>
|
||||
public sealed class JumpPowerbarController : IRetainedPanelController
|
||||
{
|
||||
public const uint LayoutId = 0x21000072u;
|
||||
public const uint MeterId = 0x10000034u;
|
||||
public const uint JumpModeState = 0x10000042u;
|
||||
|
||||
private readonly UiMeter _meter;
|
||||
private readonly Func<JumpChargeSnapshot> _snapshot;
|
||||
private readonly Action<bool> _setWindowVisible;
|
||||
private float _power;
|
||||
private bool _isCharging;
|
||||
private bool _hasSynced;
|
||||
private bool _disposed;
|
||||
|
||||
private JumpPowerbarController(
|
||||
UiMeter meter,
|
||||
Func<JumpChargeSnapshot> snapshot,
|
||||
Action<bool> setWindowVisible)
|
||||
{
|
||||
_meter = meter;
|
||||
_snapshot = snapshot;
|
||||
_setWindowVisible = setWindowVisible;
|
||||
_meter.Fill = () => _power;
|
||||
SyncVisibility(force: true);
|
||||
}
|
||||
|
||||
public static JumpPowerbarController? Bind(
|
||||
ImportedLayout layout,
|
||||
Func<JumpChargeSnapshot> snapshot,
|
||||
Action<bool> setWindowVisible)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(layout);
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
ArgumentNullException.ThrowIfNull(setWindowVisible);
|
||||
|
||||
if (layout.FindElement(MeterId) is not UiMeter meter
|
||||
|| !meter.TrySetRetailState(JumpModeState))
|
||||
return null;
|
||||
|
||||
return new JumpPowerbarController(meter, snapshot, setWindowVisible);
|
||||
}
|
||||
|
||||
/// <summary>Applies the latest movement-owned charge snapshot.</summary>
|
||||
public void Tick() => SyncVisibility(force: false);
|
||||
|
||||
/// <summary>
|
||||
/// Re-applies the state-driven visibility after the window has been mounted.
|
||||
/// This covers a jump key already held during retained-UI construction.
|
||||
/// </summary>
|
||||
public void SyncVisibility() => SyncVisibility(force: true);
|
||||
|
||||
private void SyncVisibility(bool force)
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
JumpChargeSnapshot state = _snapshot();
|
||||
_power = state.IsCharging ? Math.Clamp(state.Power, 0f, 1f) : 0f;
|
||||
|
||||
if (force || !_hasSynced || state.IsCharging != _isCharging)
|
||||
{
|
||||
_isCharging = state.IsCharging;
|
||||
_hasSynced = true;
|
||||
_setWindowVisible(_isCharging);
|
||||
}
|
||||
}
|
||||
|
||||
public void OnShown() => SyncVisibility(force: false);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_power = 0f;
|
||||
_meter.Fill = () => null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
using System.Collections.Generic;
|
||||
using AcDream.App.Plugins;
|
||||
using AcDream.App.Combat;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.App.UI.Testing;
|
||||
|
|
@ -42,6 +43,8 @@ public sealed record CombatRuntimeBindings(
|
|||
Func<GameplaySettings> Gameplay,
|
||||
Action<GameplaySettings> SetGameplay);
|
||||
|
||||
public sealed record JumpPowerbarRuntimeBindings(Func<JumpChargeSnapshot> Snapshot);
|
||||
|
||||
public sealed record ToolbarRuntimeBindings(
|
||||
ClientObjectTable Objects,
|
||||
Func<IReadOnlyList<ShortcutEntry>> Shortcuts,
|
||||
|
|
@ -106,6 +109,7 @@ public sealed record RetailUiRuntimeBindings(
|
|||
ChatRuntimeBindings Chat,
|
||||
RadarRuntimeBindings Radar,
|
||||
CombatRuntimeBindings Combat,
|
||||
JumpPowerbarRuntimeBindings JumpPowerbar,
|
||||
ToolbarRuntimeBindings Toolbar,
|
||||
CharacterRuntimeBindings Character,
|
||||
InventoryRuntimeBindings Inventory,
|
||||
|
|
@ -136,6 +140,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
MountChat();
|
||||
MountToolbar();
|
||||
MountCombat();
|
||||
MountJumpPowerbar();
|
||||
MountCharacter();
|
||||
MountPlugins();
|
||||
MountInventory();
|
||||
|
|
@ -150,7 +155,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
persistence.Store,
|
||||
persistence.CharacterKey,
|
||||
persistence.ScreenSize,
|
||||
stateManagedVisibilityWindows: [WindowNames.Combat]);
|
||||
stateManagedVisibilityWindows: [WindowNames.Combat, WindowNames.JumpPowerbar]);
|
||||
}
|
||||
|
||||
if (bindings.Probe.Enabled)
|
||||
|
|
@ -173,6 +178,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
public ToolbarController? ToolbarController { get; private set; }
|
||||
public ToolbarInputController? ToolbarInputController { get; private set; }
|
||||
public CombatUiController? CombatUiController { get; private set; }
|
||||
public JumpPowerbarController? JumpPowerbarController { get; private set; }
|
||||
public SelectedObjectController? SelectedObjectController { get; private set; }
|
||||
public UiViewport? PaperdollViewportWidget { get; private set; }
|
||||
public UiNineSlicePanel? InventoryFrame { get; private set; }
|
||||
|
|
@ -193,6 +199,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
|
||||
public void Tick(double deltaSeconds)
|
||||
{
|
||||
JumpPowerbarController?.Tick();
|
||||
SelectedObjectController?.Tick(deltaSeconds);
|
||||
Host.Tick(deltaSeconds);
|
||||
_automation?.Tick(deltaSeconds);
|
||||
|
|
@ -520,6 +527,65 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
Console.WriteLine("[M2] retail combat bar from gmCombatUI LayoutDesc 0x21000073.");
|
||||
}
|
||||
|
||||
private void MountJumpPowerbar()
|
||||
{
|
||||
ElementInfo? info;
|
||||
ImportedLayout? layout;
|
||||
lock (_bindings.Assets.DatLock)
|
||||
{
|
||||
info = LayoutImporter.ImportInfos(
|
||||
_bindings.Assets.Dats, JumpPowerbarController.LayoutId);
|
||||
layout = info is null ? null : LayoutImporter.Build(
|
||||
info,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
_bindings.Assets.DefaultFont,
|
||||
_bindings.Assets.ResolveFont);
|
||||
}
|
||||
if (layout is null)
|
||||
{
|
||||
Console.WriteLine("[D.6] jump powerbar: LayoutDesc 0x21000072 not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
JumpPowerbarController? controller = Layout.JumpPowerbarController.Bind(
|
||||
layout,
|
||||
_bindings.JumpPowerbar.Snapshot,
|
||||
visible =>
|
||||
{
|
||||
if (visible) Host.ShowWindow(WindowNames.JumpPowerbar);
|
||||
else Host.HideWindow(WindowNames.JumpPowerbar);
|
||||
});
|
||||
if (controller is null)
|
||||
{
|
||||
Console.WriteLine("[D.6] jump powerbar: required JumpMode meter missing in LayoutDesc 0x21000072.");
|
||||
return;
|
||||
}
|
||||
|
||||
JumpPowerbarController = controller;
|
||||
UiElement root = layout.Root;
|
||||
RetailWindowFrame.Mount(
|
||||
Host.Root,
|
||||
root,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
new RetailWindowFrame.Options
|
||||
{
|
||||
WindowName = WindowNames.JumpPowerbar,
|
||||
Chrome = RetailWindowChrome.Imported,
|
||||
Left = Math.Max(0f, (Host.Root.Width - root.Width) * 0.5f),
|
||||
Top = Math.Max(0f, Host.Root.Height - root.Height - 105f),
|
||||
Visible = false,
|
||||
DatConstraintSource = info,
|
||||
Resizable = true,
|
||||
ResizeX = true,
|
||||
ResizeY = true,
|
||||
ConstrainDragToParent = true,
|
||||
ContentClickThrough = false,
|
||||
Controller = controller,
|
||||
});
|
||||
controller.SyncVisibility();
|
||||
Console.WriteLine("[D.6] retail jump bar from gmFloatyPowerBarUI LayoutDesc 0x21000072.");
|
||||
}
|
||||
|
||||
private (uint[] Peace, uint[] War, uint[]? Empty) LoadToolbarDigits()
|
||||
{
|
||||
uint[]? peace = null, war = null, empty = null;
|
||||
|
|
|
|||
|
|
@ -14,8 +14,10 @@ namespace AcDream.App.UI;
|
|||
/// retail's vitals are bars exactly like this, just sprited.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class UiMeter : UiElement
|
||||
public sealed class UiMeter : UiElement, IUiDatStateful
|
||||
{
|
||||
private readonly Dictionary<uint, uint> _stateFillSprites = new();
|
||||
|
||||
/// <summary>Dat element id, set by the layout importer so duplicated page copies can be scoped.</summary>
|
||||
public uint ElementId { get; set; }
|
||||
|
||||
|
|
@ -57,6 +59,32 @@ public sealed class UiMeter : UiElement
|
|||
/// <summary>Coloured-fill right-cap RenderSurface id.</summary>
|
||||
public uint FrontRight { get; set; }
|
||||
|
||||
/// <summary>The active numeric DAT state for a stateful fill meter.</summary>
|
||||
public uint ActiveRetailStateId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Registers a fill sprite carried by a child state of the imported meter.
|
||||
/// Used by the powerbar shape in LayoutDesc 0x21000072, whose track is on
|
||||
/// the meter and whose Jump/Melee/Missile/DDD fills are states of one child.
|
||||
/// </summary>
|
||||
internal void ConfigureStateFill(uint stateId, uint spriteId)
|
||||
{
|
||||
if (stateId != 0 && spriteId != 0)
|
||||
_stateFillSprites[stateId] = spriteId;
|
||||
}
|
||||
|
||||
public bool TrySetRetailState(uint stateId)
|
||||
{
|
||||
if (!_stateFillSprites.TryGetValue(stateId, out uint spriteId))
|
||||
return false;
|
||||
|
||||
ActiveRetailStateId = stateId;
|
||||
FrontLeft = 0;
|
||||
FrontTile = spriteId;
|
||||
FrontRight = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Vertical orientation (retail <c>m_eDirection</c> 2/4). Default false =
|
||||
/// horizontal (direction 1). The burden bar is the only vertical meter today.</summary>
|
||||
public bool Vertical { get; set; }
|
||||
|
|
|
|||
|
|
@ -11,4 +11,5 @@ public static class WindowNames
|
|||
public const string Chat = "chat";
|
||||
public const string Radar = "radar";
|
||||
public const string Combat = "combat";
|
||||
public const string JumpPowerbar = "jump-powerbar";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -313,6 +313,37 @@ public class DatWidgetFactoryTests
|
|||
Assert.Equal(0u, m.FrontRight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildMeter_StatefulSingleImageShape_MapsNumericFillStatesAndHidesDirectRange()
|
||||
{
|
||||
const uint TrackFile = 0x06004D0Bu;
|
||||
const uint JumpState = 0x10000042u;
|
||||
const uint JumpFile = 0x06001354u;
|
||||
const uint RangeFile = 0x0600715Eu;
|
||||
|
||||
var meter = new ElementInfo { Type = 7, Id = 0x10000034u, Width = 600, Height = 15 };
|
||||
meter.StateMedia[""] = (TrackFile, 1);
|
||||
|
||||
var fills = new ElementInfo { Type = 3, Id = 2u, ReadOrder = 2 };
|
||||
fills.States[JumpState] = new UiStateInfo { Id = JumpState, Name = "JumpMode" };
|
||||
fills.StateMedia["JumpMode"] = (JumpFile, 1);
|
||||
meter.Children.Add(fills);
|
||||
|
||||
var range = new ElementInfo { Type = 3, Id = 0x100005EEu, ReadOrder = 1 };
|
||||
range.StateMedia[""] = (RangeFile, 1);
|
||||
meter.Children.Add(range);
|
||||
|
||||
var result = Assert.IsType<UiMeter>(DatWidgetFactory.Create(meter, NoTex, null));
|
||||
|
||||
Assert.Equal(TrackFile, result.BackTile);
|
||||
Assert.Equal(0u, result.FrontTile);
|
||||
Assert.True(result.TrySetRetailState(JumpState));
|
||||
Assert.Equal(JumpState, result.ActiveRetailStateId);
|
||||
Assert.Equal(JumpFile, result.FrontTile);
|
||||
Assert.NotEqual(RangeFile, result.BackTile);
|
||||
Assert.NotEqual(RangeFile, result.FrontTile);
|
||||
}
|
||||
|
||||
// ── Test 6: Meter slice extraction (the important one) ───────────────────
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -95,6 +95,12 @@ public static class FixtureLoader
|
|||
public static ElementInfo LoadCombatInfos()
|
||||
=> LoadInfos("combat_21000073.json");
|
||||
|
||||
public static ImportedLayout LoadPowerbar()
|
||||
=> LayoutImporter.Build(LoadPowerbarInfos(), _ => (0u, 0, 0), null);
|
||||
|
||||
public static ElementInfo LoadPowerbarInfos()
|
||||
=> LoadInfos("powerbar_21000072.json");
|
||||
|
||||
// ── Shared loader ────────────────────────────────────────────────────────
|
||||
|
||||
private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
using AcDream.App.Input;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public sealed class JumpPowerbarControllerTests
|
||||
{
|
||||
private const uint JumpFill = 0x06001354u;
|
||||
|
||||
[Fact]
|
||||
public void MovementCharge_ShowsUpdatesAndHidesAuthoredJumpMeter()
|
||||
{
|
||||
JumpChargeSnapshot state = default;
|
||||
ImportedLayout layout = BuildLayout();
|
||||
var visibility = new List<bool>();
|
||||
using var controller = JumpPowerbarController.Bind(
|
||||
layout, () => state, visibility.Add)!;
|
||||
var meter = Assert.IsType<UiMeter>(layout.FindElement(JumpPowerbarController.MeterId));
|
||||
|
||||
Assert.Equal([false], visibility);
|
||||
Assert.Equal(0f, meter.Fill());
|
||||
Assert.Equal(JumpPowerbarController.JumpModeState, meter.ActiveRetailStateId);
|
||||
Assert.Equal(JumpFill, meter.FrontTile);
|
||||
|
||||
state = new JumpChargeSnapshot(true, 0.35f);
|
||||
controller.Tick();
|
||||
Assert.Equal([false, true], visibility);
|
||||
Assert.Equal(0.35f, meter.Fill());
|
||||
|
||||
state = new JumpChargeSnapshot(true, 1.4f);
|
||||
controller.Tick();
|
||||
Assert.Equal([false, true], visibility);
|
||||
Assert.Equal(1f, meter.Fill());
|
||||
|
||||
state = default;
|
||||
controller.Tick();
|
||||
Assert.Equal([false, true, false], visibility);
|
||||
Assert.Equal(0f, meter.Fill());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_RejectsLayoutWithoutJumpModeMeter()
|
||||
{
|
||||
var root = new UiPanel();
|
||||
var meter = new UiMeter { ElementId = JumpPowerbarController.MeterId };
|
||||
var layout = new ImportedLayout(root, new Dictionary<uint, UiElement>
|
||||
{
|
||||
[JumpPowerbarController.MeterId] = meter,
|
||||
});
|
||||
|
||||
Assert.Null(JumpPowerbarController.Bind(layout, () => default, _ => { }));
|
||||
}
|
||||
|
||||
private static ImportedLayout BuildLayout()
|
||||
{
|
||||
var root = new UiPanel { Width = 610, Height = 25 };
|
||||
var meter = new UiMeter
|
||||
{
|
||||
ElementId = JumpPowerbarController.MeterId,
|
||||
Left = 5,
|
||||
Top = 5,
|
||||
Width = 600,
|
||||
Height = 15,
|
||||
};
|
||||
meter.ConfigureStateFill(JumpPowerbarController.JumpModeState, JumpFill);
|
||||
root.AddChild(meter);
|
||||
return new ImportedLayout(root, new Dictionary<uint, UiElement>
|
||||
{
|
||||
[JumpPowerbarController.MeterId] = meter,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public sealed class JumpPowerbarLayoutConformanceTests
|
||||
{
|
||||
[Fact]
|
||||
public void RetailFixture_UsesExactFloatyGeometryTrackAndJumpFill()
|
||||
{
|
||||
ElementInfo info = FixtureLoader.LoadPowerbarInfos();
|
||||
ImportedLayout layout = LayoutImporter.Build(info, _ => (0u, 0, 0), null);
|
||||
var meter = Assert.IsType<UiMeter>(
|
||||
layout.FindElement(JumpPowerbarController.MeterId));
|
||||
|
||||
Assert.Equal((610f, 25f), (layout.Root.Width, layout.Root.Height));
|
||||
Assert.Equal((5f, 5f, 600f, 15f),
|
||||
(meter.Left, meter.Top, meter.Width, meter.Height));
|
||||
Assert.Equal(0x06004D0Bu, meter.BackTile);
|
||||
Assert.True(meter.TrySetRetailState(JumpPowerbarController.JumpModeState));
|
||||
Assert.Equal(0x06001354u, meter.FrontTile);
|
||||
|
||||
Assert.Equal((0f, 0f, 300f, 15f),
|
||||
UiMeter.ComputeFillRect(0.5f, meter.Width, meter.Height));
|
||||
|
||||
Assert.True(info.TryGetEffectiveInteger(0x3Fu, out int minWidth));
|
||||
Assert.True(info.TryGetEffectiveInteger(0x3Du, out int maxWidth));
|
||||
Assert.True(info.TryGetEffectiveInteger(0x3Eu, out int minHeight));
|
||||
Assert.True(info.TryGetEffectiveInteger(0x3Cu, out int maxHeight));
|
||||
Assert.Equal((50, 810, 25, 25),
|
||||
(minWidth, maxWidth, minHeight, maxHeight));
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ public sealed class RetailLayoutFixtureGenerator
|
|||
(0x21000024u, "paperdoll_21000024.json"),
|
||||
(0x2100002Eu, "character_2100002E.json"),
|
||||
(0x2100006Cu, "vitals_2100006C.json"),
|
||||
(0x21000072u, "powerbar_21000072.json"),
|
||||
(0x21000073u, "combat_21000073.json"),
|
||||
(0x21000074u, "radar_21000074.json"),
|
||||
};
|
||||
|
|
|
|||
2467
tests/AcDream.App.Tests/UI/Layout/fixtures/powerbar_21000072.json
Normal file
2467
tests/AcDream.App.Tests/UI/Layout/fixtures/powerbar_21000072.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -293,6 +293,24 @@ public class PlayerMovementControllerTests
|
|||
Assert.True(controller.VerticalVelocity > 0f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JumpChargeSnapshot_TracksHeldChargeAndResetsOnRelease()
|
||||
{
|
||||
var engine = MakeFlatEngine();
|
||||
var controller = new PlayerMovementController(engine);
|
||||
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
||||
|
||||
Assert.Equal(default, controller.JumpCharge);
|
||||
|
||||
controller.Update(0.25f, new MovementInput(Jump: true));
|
||||
Assert.True(controller.JumpCharge.IsCharging);
|
||||
Assert.Equal(0.25f, controller.JumpCharge.Power, precision: 3);
|
||||
|
||||
controller.Update(0.016f, new MovementInput(Jump: false));
|
||||
Assert.False(controller.JumpCharge.IsCharging);
|
||||
Assert.Equal(0f, controller.JumpCharge.Power);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_AirborneFrames_ZRiseThenFalls()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue