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
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue