acdream/src/AcDream.App/UI/Layout/CombatUiController.cs
Erik 707add8539 fix(combat): charge power bar from speed
Read the authored meter direction instead of inferring it from the combat element id, so the bright charge texture grows left-to-right from Speed. Keep the user-approved dark-red middle baseline visible independently and record its exact retail Recklessness visibility edge.

Co-Authored-By: Codex <codex@openai.com>
2026-07-12 21:25:24 +02:00

298 lines
12 KiB
C#

using AcDream.App.Combat;
using AcDream.Core.Combat;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Binds the imported retail <c>gmCombatUI</c> layout to the client combat
/// state machine. No panel geometry is synthesized here: every control is the
/// authored element from LayoutDesc <c>0x21000073</c>.
/// </summary>
/// <remarks>
/// Retail references: <c>gmCombatUI::RecvNotice_AttackHeightChanged</c>
/// (0x004CC080), <c>RecvNotice_SetPowerbarLevel</c> (0x004CC0E0),
/// <c>RecvNotice_DesiredAttackPowerChanged</c> (0x004CC110),
/// <c>ListenToElementMessage</c> (0x004CC430), and
/// <c>RecvNotice_SetCombatMode</c> (0x004CC620).
/// </remarks>
public sealed class CombatUiController : IRetainedPanelController
{
public const uint LayoutId = 0x21000073u;
public const uint BasicPanelId = 0x1000005Cu;
public const uint AdvancedPanelId = 0x10000061u;
public const uint PowerControlId = 0x1000004Fu;
public const uint SpeedLabelId = 0x10000051u;
public const uint PowerLabelId = 0x10000052u;
public const uint RepeatAttacksId = 0x10000053u;
public const uint AutoTargetId = 0x10000054u;
public const uint KeepInViewId = 0x10000055u;
public const uint HighButtonId = 0x10000057u;
public const uint MediumButtonId = 0x10000058u;
public const uint LowButtonId = 0x10000059u;
public const uint MeleeState = 0x10000003u;
public const uint MissileState = 0x10000004u;
private readonly UiElement _root;
private readonly UiElement _basicPanel;
private readonly UiElement _advancedPanel;
private readonly UiScrollbar _powerControl;
private readonly UiButton _high;
private readonly UiButton _medium;
private readonly UiButton _low;
private readonly UiButton _repeatAttacks;
private readonly UiButton _autoTarget;
private readonly UiButton _keepInView;
private readonly CombatState _combat;
private readonly CombatAttackController _attacks;
private readonly Func<GameplaySettings> _gameplay;
private readonly Action<GameplaySettings> _setGameplay;
private readonly Action<bool> _setWindowVisible;
private bool _disposed;
private CombatUiController(
ImportedLayout layout,
UiElement basicPanel,
UiElement advancedPanel,
UiScrollbar powerControl,
UiButton high,
UiButton medium,
UiButton low,
UiButton repeatAttacks,
UiButton autoTarget,
UiButton keepInView,
CombatState combat,
CombatAttackController attacks,
Func<GameplaySettings> gameplay,
Action<GameplaySettings> setGameplay,
CombatUiLabels labels,
Action<bool> setWindowVisible)
{
_root = layout.Root;
_basicPanel = basicPanel;
_advancedPanel = advancedPanel;
_powerControl = powerControl;
_high = high;
_medium = medium;
_low = low;
_repeatAttacks = repeatAttacks;
_autoTarget = autoTarget;
_keepInView = keepInView;
_combat = combat;
_attacks = attacks;
_gameplay = gameplay;
_setGameplay = setGameplay;
_setWindowVisible = setWindowVisible;
// PlayerModule::AdvancedCombatUI false selects gmCombatUI's authored
// basic page. The advanced page belongs to the separate advanced-
// combat flow and stays hidden in the basic panel.
_basicPanel.Visible = true;
_advancedPanel.Visible = false;
_powerControl.SetScalarPosition(_attacks.DesiredPower);
_powerControl.ScalarChanged = _attacks.SetDesiredPower;
_powerControl.ScalarFill = () => _attacks.PowerBarLevel;
BindAttackButton(_high, AttackHeight.High);
BindAttackButton(_medium, AttackHeight.Medium);
BindAttackButton(_low, AttackHeight.Low);
_high.Label = labels.High;
_medium.Label = labels.Medium;
_low.Label = labels.Low;
_repeatAttacks.Label = labels.RepeatAttacks;
_autoTarget.Label = labels.AutoTarget;
_keepInView.Label = labels.KeepInView;
UiText? speedLabel = layout.FindElement(SpeedLabelId) as UiText;
UiText? powerLabel = layout.FindElement(PowerLabelId) as UiText;
SetStaticText(speedLabel, labels.Speed, rightAligned: false);
SetStaticText(powerLabel, labels.Power, rightAligned: true);
ConfigurePowerRange(speedLabel, powerLabel);
_repeatAttacks.OnClick = () =>
_setGameplay(_gameplay() with { AutoRepeatAttack = _repeatAttacks.Selected });
_autoTarget.OnClick = () =>
_setGameplay(_gameplay() with { AutoTarget = _autoTarget.Selected });
_keepInView.OnClick = () =>
_setGameplay(_gameplay() with { ViewCombatTarget = _keepInView.Selected });
_combat.CombatModeChanged += OnCombatModeChanged;
_attacks.StateChanged += OnAttackStateChanged;
SyncControls();
}
public static CombatUiController? Bind(
ImportedLayout layout,
CombatState combat,
CombatAttackController attacks,
Func<GameplaySettings> gameplay,
Action<GameplaySettings> setGameplay,
CombatUiLabels labels,
Action<bool> setWindowVisible)
{
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(combat);
ArgumentNullException.ThrowIfNull(attacks);
ArgumentNullException.ThrowIfNull(gameplay);
ArgumentNullException.ThrowIfNull(setGameplay);
ArgumentNullException.ThrowIfNull(labels);
ArgumentNullException.ThrowIfNull(setWindowVisible);
if (layout.FindElement(BasicPanelId) is not { } basic
|| layout.FindElement(AdvancedPanelId) is not { } advanced
|| layout.FindElement(PowerControlId) is not UiScrollbar power
|| layout.FindElement(HighButtonId) is not UiButton high
|| layout.FindElement(MediumButtonId) is not UiButton medium
|| layout.FindElement(LowButtonId) is not UiButton low
|| layout.FindElement(RepeatAttacksId) is not UiButton repeatAttacks
|| layout.FindElement(AutoTargetId) is not UiButton autoTarget
|| layout.FindElement(KeepInViewId) is not UiButton keepInView)
return null;
return new CombatUiController(
layout, basic, advanced, power, high, medium, low,
repeatAttacks, autoTarget, keepInView,
combat, attacks, gameplay, setGameplay, labels, setWindowVisible);
}
public void SyncVisibility() => OnCombatModeChanged(_combat.CurrentMode);
private void BindAttackButton(UiButton button, AttackHeight height)
{
button.OnPressed = () => _attacks.PressAttack(height);
button.OnReleased = _attacks.ReleaseAttack;
}
private void OnCombatModeChanged(CombatMode mode)
{
bool visible = CombatInputPlanner.SupportsTargetedAttack(mode);
if (_root is IUiDatStateful stateful)
{
if (mode == CombatMode.Melee)
stateful.TrySetRetailState(MeleeState);
else if (mode == CombatMode.Missile)
stateful.TrySetRetailState(MissileState);
}
_setWindowVisible(visible);
SyncControls();
}
private void OnAttackStateChanged() => SyncControls();
private void SyncControls()
{
_powerControl.SetScalarPosition(_attacks.DesiredPower);
_high.Selected = _attacks.RequestedHeight == AttackHeight.High;
_medium.Selected = _attacks.RequestedHeight == AttackHeight.Medium;
_low.Selected = _attacks.RequestedHeight == AttackHeight.Low;
GameplaySettings gameplay = _gameplay();
_repeatAttacks.Selected = gameplay.AutoRepeatAttack;
_autoTarget.Selected = gameplay.AutoTarget;
_keepInView.Selected = gameplay.ViewCombatTarget;
}
private 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.
float left = speedLabel.Left + speedLabel.Width - _powerControl.Left;
float right = powerLabel.Left - _powerControl.Left;
_powerControl.ScalarRangeLeft = Math.Clamp(left, 0f, _powerControl.Width);
_powerControl.ScalarRangeWidth = Math.Max(
0f, Math.Clamp(right, 0f, _powerControl.Width)
- _powerControl.ScalarRangeLeft);
}
private static void SetStaticText(UiText? text, string value, bool rightAligned)
{
if (text is null) return;
// Retail UIElement_Text starts with zero margins; these 15px-high
// gmCombatUI captions are single authored lines. The scrollback path's
// generic 4px inset would leave less than one glyph row and clip them all.
text.OneLine = true;
text.Padding = 0f;
text.Centered = false;
text.RightAligned = rightAligned;
UiText.Line[] line = [new UiText.Line(value, text.DefaultColor)];
text.LinesProvider = () => line;
}
public void OnShown() => SyncControls();
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_combat.CombatModeChanged -= OnCombatModeChanged;
_attacks.StateChanged -= OnAttackStateChanged;
_powerControl.ScalarChanged = null;
_powerControl.ScalarFill = () => null;
_high.OnPressed = null;
_high.OnReleased = null;
_medium.OnPressed = null;
_medium.OnReleased = null;
_low.OnPressed = null;
_low.OnReleased = null;
_repeatAttacks.OnClick = null;
_autoTarget.OnClick = null;
_keepInView.OnClick = null;
}
}
/// <summary>Localized labels assigned by retail <c>gmCombatUI::PostInit</c>.</summary>
public sealed record CombatUiLabels(
string Speed,
string Power,
string RepeatAttacks,
string AutoTarget,
string KeepInView,
string High,
string Medium,
string Low)
{
private const uint UiStringTable = 0x23000001u;
public static CombatUiLabels Resolve(ElementInfo root, DatStringResolver strings)
{
ArgumentNullException.ThrowIfNull(root);
ArgumentNullException.ThrowIfNull(strings);
return new CombatUiLabels(
ElementString(CombatUiController.SpeedLabelId, UiStateInfo.DirectStateId, "Speed"),
ElementString(CombatUiController.PowerLabelId, CombatUiController.MeleeState, "Power"),
RuntimeString("ID_CombatPanelOption_AutoRepeatAttack", "Repeat Attacks"),
RuntimeString("ID_CombatPanelOption_AutoTarget", "Auto Target"),
RuntimeString("ID_CombatPanelOption_ViewCombatTarget", "Keep in View"),
ElementString(CombatUiController.HighButtonId, UiStateInfo.DirectStateId, "High"),
ElementString(CombatUiController.MediumButtonId, UiStateInfo.DirectStateId, "Medium"),
ElementString(CombatUiController.LowButtonId, UiStateInfo.DirectStateId, "Low"));
string RuntimeString(string id, string fallback)
=> strings.Resolve(UiStringTable, DatStringResolver.ComputeHash(id)) ?? fallback;
string ElementString(uint elementId, uint stateId, string fallback)
{
ElementInfo? element = Find(root, elementId);
if (element is null
|| !element.TryGetEffectiveProperty(0x17u, out var property, stateId)
|| property.Kind != UiPropertyKind.StringInfo)
return fallback;
return strings.Resolve(property.StringInfoValue) ?? fallback;
}
}
private static ElementInfo? Find(ElementInfo element, uint id)
{
if (element.Id == id) return element;
foreach (ElementInfo child in element.Children)
if (Find(child, id) is { } found)
return found;
return null;
}
}