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>
298 lines
12 KiB
C#
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;
|
|
}
|
|
}
|