feat(combat): port retail basic combat bar
Mount authored gmCombatUI, share one press/hold/release request state machine across DAT buttons and keybindings, and recover the exact 1.0s/0.8s power timing from matching retail x86. The same timer fixes jump charge, while ready-stance, response queueing, auto-repeat, layout binding, migration, and conformance coverage keep behavior architectural rather than panel-local. Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
parent
9958458318
commit
2215c76c7e
22 changed files with 1265 additions and 46 deletions
147
tests/AcDream.App.Tests/Combat/CombatAttackControllerTests.cs
Normal file
147
tests/AcDream.App.Tests/Combat/CombatAttackControllerTests.cs
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
using AcDream.App.Combat;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
|
||||
namespace AcDream.App.Tests.Combat;
|
||||
|
||||
public sealed class CombatAttackControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public void NormalStance_ChargesLinearlyOverOneSecond_AndReleaseSendsCurrentPower()
|
||||
{
|
||||
double now = 10d;
|
||||
var sent = new List<(AttackHeight Height, float Power)>();
|
||||
var combat = new CombatState();
|
||||
using var controller = Create(combat, () => now, sent);
|
||||
combat.SetCombatMode(CombatMode.Melee);
|
||||
|
||||
controller.PressAttack(AttackHeight.High);
|
||||
now = 10.5d;
|
||||
|
||||
Assert.Equal(0.5f, controller.PowerBarLevel, 3);
|
||||
controller.ReleaseAttack();
|
||||
|
||||
var attack = Assert.Single(sent);
|
||||
Assert.Equal(AttackHeight.High, attack.Height);
|
||||
Assert.Equal(0.5f, attack.Power, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DualWieldStance_UsesRetailPointEightSecondPowerUpTime()
|
||||
{
|
||||
double now = 3d;
|
||||
var combat = new CombatState();
|
||||
using var controller = Create(
|
||||
combat,
|
||||
() => now,
|
||||
[],
|
||||
isDualWield: () => true);
|
||||
combat.SetCombatMode(CombatMode.Melee);
|
||||
|
||||
controller.PressAttack(AttackHeight.Medium);
|
||||
now = 3.4d;
|
||||
|
||||
Assert.Equal(0.5f, controller.PowerBarLevel, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EarlyRelease_CommitsOnUseTimeTickAtReleasedPower()
|
||||
{
|
||||
double now = 0d;
|
||||
var sent = new List<(AttackHeight Height, float Power)>();
|
||||
var combat = new CombatState();
|
||||
using var controller = Create(combat, () => now, sent);
|
||||
combat.SetCombatMode(CombatMode.Missile);
|
||||
controller.SetDesiredPower(1f);
|
||||
|
||||
controller.PressAttack(AttackHeight.Low);
|
||||
now = 0.25d;
|
||||
controller.ReleaseAttack();
|
||||
Assert.Empty(sent);
|
||||
|
||||
now = 0.26d;
|
||||
controller.Tick();
|
||||
|
||||
var attack = Assert.Single(sent);
|
||||
Assert.Equal(AttackHeight.Low, attack.Height);
|
||||
Assert.Equal(0.25f, attack.Power, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HoldBinding_UsesPressAndReleaseTransitions()
|
||||
{
|
||||
double now = 1d;
|
||||
var sent = new List<(AttackHeight Height, float Power)>();
|
||||
var combat = new CombatState();
|
||||
using var controller = Create(combat, () => now, sent);
|
||||
combat.SetCombatMode(CombatMode.Melee);
|
||||
|
||||
Assert.True(controller.HandleInputAction(
|
||||
InputAction.CombatLowAttack, ActivationType.Press));
|
||||
now = 1.5d;
|
||||
Assert.True(controller.HandleInputAction(
|
||||
InputAction.CombatLowAttack, ActivationType.Release));
|
||||
|
||||
Assert.Equal(AttackHeight.Low, Assert.Single(sent).Height);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LeavingTargetedCombat_CancelsAnActiveBuild()
|
||||
{
|
||||
double now = 0d;
|
||||
var combat = new CombatState();
|
||||
using var controller = Create(combat, () => now, []);
|
||||
combat.SetCombatMode(CombatMode.Melee);
|
||||
controller.PressAttack(AttackHeight.Medium);
|
||||
now = 0.4d;
|
||||
|
||||
combat.SetCombatMode(CombatMode.NonCombat);
|
||||
|
||||
Assert.False(controller.BuildInProgress);
|
||||
Assert.False(controller.AttackRequestInProgress);
|
||||
Assert.Equal(0f, controller.PowerBarLevel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequestWaitsUntilPlayerReachesReadyStanceBeforeBuilding()
|
||||
{
|
||||
double now = 0d;
|
||||
bool ready = false;
|
||||
var combat = new CombatState();
|
||||
using var controller = new CombatAttackController(
|
||||
combat,
|
||||
() => true,
|
||||
(_, _) => true,
|
||||
playerReadyForAttack: () => ready,
|
||||
now: () => now);
|
||||
combat.SetCombatMode(CombatMode.Missile);
|
||||
|
||||
controller.PressAttack(AttackHeight.High);
|
||||
Assert.True(controller.AttackRequestInProgress);
|
||||
Assert.False(controller.BuildInProgress);
|
||||
|
||||
ready = true;
|
||||
now = 5d;
|
||||
controller.Tick();
|
||||
|
||||
Assert.True(controller.BuildInProgress);
|
||||
Assert.Equal(0f, controller.PowerBarLevel);
|
||||
}
|
||||
|
||||
private static CombatAttackController Create(
|
||||
CombatState combat,
|
||||
Func<double> now,
|
||||
List<(AttackHeight Height, float Power)> sent,
|
||||
Func<bool>? isDualWield = null)
|
||||
=> new(
|
||||
combat,
|
||||
canStartAttack: () => true,
|
||||
sendAttack: (height, power) =>
|
||||
{
|
||||
sent.Add((height, power));
|
||||
return true;
|
||||
},
|
||||
isDualWield: isDualWield,
|
||||
autoRepeatAttack: () => false,
|
||||
now: now);
|
||||
}
|
||||
106
tests/AcDream.App.Tests/UI/Layout/CombatUiControllerTests.cs
Normal file
106
tests/AcDream.App.Tests/UI/Layout/CombatUiControllerTests.cs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
using AcDream.App.Combat;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Combat;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public sealed class CombatUiControllerTests
|
||||
{
|
||||
private static (uint, int, int) NoTex(uint _) => (0u, 0, 0);
|
||||
|
||||
[Fact]
|
||||
public void CombatMode_ShowsOnlyTargetedModes_AndSelectsMediumByDefault()
|
||||
{
|
||||
double now = 0d;
|
||||
var combat = new CombatState();
|
||||
using var attacks = CreateAttacks(combat, () => now, []);
|
||||
var (layout, _, _, power, high, medium, low) = BuildLayout();
|
||||
var visibility = new List<bool>();
|
||||
using var controller = CombatUiController.Bind(
|
||||
layout, combat, attacks, visibility.Add)!;
|
||||
|
||||
controller.SyncVisibility();
|
||||
combat.SetCombatMode(CombatMode.Melee);
|
||||
combat.SetCombatMode(CombatMode.Magic);
|
||||
|
||||
Assert.Equal([false, true, false], visibility);
|
||||
Assert.False(high.Selected);
|
||||
Assert.True(medium.Selected);
|
||||
Assert.False(low.Selected);
|
||||
Assert.Equal(CombatAttackController.InitialDesiredPower, power.ScalarPosition);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthoredHeightButton_PressChargesAndReleaseSendsThroughSharedController()
|
||||
{
|
||||
double now = 4d;
|
||||
var sent = new List<(AttackHeight Height, float Power)>();
|
||||
var combat = new CombatState();
|
||||
using var attacks = CreateAttacks(combat, () => now, sent);
|
||||
var (layout, basic, advanced, power, high, _, _) = BuildLayout();
|
||||
using var controller = CombatUiController.Bind(
|
||||
layout, combat, attacks, _ => { })!;
|
||||
combat.SetCombatMode(CombatMode.Melee);
|
||||
|
||||
high.OnEvent(new UiEvent(0, high, UiEventType.MouseDown, Data1: 2, Data2: 2));
|
||||
now = 4.5d;
|
||||
Assert.Equal(0.5f, power.ScalarFill()!.Value, 3);
|
||||
high.OnEvent(new UiEvent(0, high, UiEventType.MouseUp, Data1: 2, Data2: 2));
|
||||
|
||||
var attack = Assert.Single(sent);
|
||||
Assert.Equal(AttackHeight.High, attack.Height);
|
||||
Assert.Equal(0.5f, attack.Power, 3);
|
||||
Assert.True(basic.Visible);
|
||||
Assert.False(advanced.Visible);
|
||||
}
|
||||
|
||||
private static CombatAttackController CreateAttacks(
|
||||
CombatState combat,
|
||||
Func<double> now,
|
||||
List<(AttackHeight Height, float Power)> sent)
|
||||
=> new(
|
||||
combat,
|
||||
() => true,
|
||||
(height, power) =>
|
||||
{
|
||||
sent.Add((height, power));
|
||||
return true;
|
||||
},
|
||||
autoRepeatAttack: () => false,
|
||||
now: now);
|
||||
|
||||
private static (
|
||||
ImportedLayout Layout,
|
||||
UiElement Basic,
|
||||
UiElement Advanced,
|
||||
UiScrollbar Power,
|
||||
UiButton High,
|
||||
UiButton Medium,
|
||||
UiButton Low) BuildLayout()
|
||||
{
|
||||
var root = new UiPanel { Width = 610, Height = 90 };
|
||||
var basic = new UiPanel();
|
||||
var advanced = new UiPanel();
|
||||
var power = new UiScrollbar { Width = 507, Height = 14, Horizontal = true };
|
||||
UiButton Button(uint id) => new(
|
||||
new ElementInfo { Id = id, Type = 1, Width = 72, Height = 19 }, NoTex)
|
||||
{
|
||||
Width = 72,
|
||||
Height = 19,
|
||||
};
|
||||
var high = Button(CombatUiController.HighButtonId);
|
||||
var medium = Button(CombatUiController.MediumButtonId);
|
||||
var low = Button(CombatUiController.LowButtonId);
|
||||
var byId = new Dictionary<uint, UiElement>
|
||||
{
|
||||
[CombatUiController.BasicPanelId] = basic,
|
||||
[CombatUiController.AdvancedPanelId] = advanced,
|
||||
[CombatUiController.PowerControlId] = power,
|
||||
[CombatUiController.HighButtonId] = high,
|
||||
[CombatUiController.MediumButtonId] = medium,
|
||||
[CombatUiController.LowButtonId] = low,
|
||||
};
|
||||
return (new ImportedLayout(root, byId), basic, advanced, power, high, medium, low);
|
||||
}
|
||||
}
|
||||
|
|
@ -451,6 +451,47 @@ public class DatWidgetFactoryTests
|
|||
Assert.Equal(gold, t.DefaultColor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HorizontalScrollbar_PreservesNestedCombatMeterFillSprite()
|
||||
{
|
||||
var track = new ElementInfo { Id = 4, Type = 3, Width = 507, Height = 14 };
|
||||
track.StateMedia[""] = (0x06001919u, 1);
|
||||
track.States[UiStateInfo.DirectStateId] = new UiStateInfo
|
||||
{ Id = UiStateInfo.DirectStateId, Image = new UiImageMedia(0x06001919u, 1) };
|
||||
var thumb = new ElementInfo { Id = 1, Type = 1, Width = 12, Height = 14 };
|
||||
thumb.StateMedia[""] = (0x06001923u, 1);
|
||||
thumb.States[UiStateInfo.DirectStateId] = new UiStateInfo
|
||||
{ Id = UiStateInfo.DirectStateId, Image = new UiImageMedia(0x06001923u, 1) };
|
||||
var fill = new ElementInfo { Id = 2, Type = 3, Width = 507, Height = 14 };
|
||||
fill.StateMedia[""] = (0x06001200u, 1);
|
||||
fill.States[UiStateInfo.DirectStateId] = new UiStateInfo
|
||||
{ Id = UiStateInfo.DirectStateId, Image = new UiImageMedia(0x06001200u, 1) };
|
||||
var meter = new ElementInfo
|
||||
{
|
||||
Id = CombatUiController.PowerControlId + 1,
|
||||
Type = 7,
|
||||
Width = 507,
|
||||
Height = 14,
|
||||
Children = [fill],
|
||||
};
|
||||
var info = new ElementInfo
|
||||
{
|
||||
Id = CombatUiController.PowerControlId,
|
||||
Type = 11,
|
||||
Width = 507,
|
||||
Height = 14,
|
||||
Children = [track, meter, thumb],
|
||||
};
|
||||
|
||||
var bar = Assert.IsType<UiScrollbar>(
|
||||
DatWidgetFactory.Create(info, NoTex, null));
|
||||
|
||||
Assert.True(bar.Horizontal);
|
||||
Assert.Equal(0x06001919u, bar.TrackSprite);
|
||||
Assert.Equal(0x06001923u, bar.ThumbSprite);
|
||||
Assert.Equal(0x06001200u, bar.ScalarFillSprite);
|
||||
}
|
||||
|
||||
private static ElementInfo TextInfo(params (uint Id, UiPropertyValue Value)[] properties)
|
||||
{
|
||||
var info = new ElementInfo { Id = 0x10000016u, Type = 12, Width = 100, Height = 20 };
|
||||
|
|
|
|||
|
|
@ -30,6 +30,24 @@ public class UiButtonTests
|
|||
Assert.Equal((17, 9), clicked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PointerDownAndUp_InvokeDistinctTransitionHandlers()
|
||||
{
|
||||
var transitions = new List<string>();
|
||||
var b = new UiButton(new ElementInfo { Type = 1, Width = 20, Height = 20 }, NoTex)
|
||||
{
|
||||
Width = 20,
|
||||
Height = 20,
|
||||
OnPressed = () => transitions.Add("pressed"),
|
||||
OnReleased = () => transitions.Add("released"),
|
||||
};
|
||||
|
||||
b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5));
|
||||
b.OnEvent(new UiEvent(0, b, UiEventType.MouseUp, Data1: 30, Data2: 30));
|
||||
|
||||
Assert.Equal(["pressed", "released"], transitions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotClickThrough_SoItReceivesClicks()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -94,6 +94,24 @@ public sealed class CombatInputPlannerTests
|
|||
Assert.Equal(expected, CombatInputPlanner.SupportsTargetedAttack(mode));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(CombatMode.Melee, 0x8000003Du, 0u, true)]
|
||||
[InlineData(CombatMode.Missile, 0x8000003Fu, CombatInputPlanner.ReadyForwardCommand, true)]
|
||||
[InlineData(CombatMode.Missile, 0x80000041u, CombatInputPlanner.ReadyForwardCommand, true)]
|
||||
[InlineData(CombatMode.Missile, 0x8000003Du, CombatInputPlanner.ReadyForwardCommand, false)]
|
||||
[InlineData(CombatMode.Missile, 0x8000003Fu, 0x41000006u, false)]
|
||||
[InlineData(CombatMode.NonCombat, 0x8000003Du, CombatInputPlanner.ReadyForwardCommand, false)]
|
||||
public void PlayerInReadyPositionForAttack_MatchesRetailAttackBranch(
|
||||
CombatMode mode,
|
||||
uint style,
|
||||
uint forward,
|
||||
bool expected)
|
||||
{
|
||||
Assert.Equal(
|
||||
expected,
|
||||
CombatInputPlanner.PlayerInReadyPositionForAttack(mode, style, forward));
|
||||
}
|
||||
|
||||
private static ClientObject Equipped(
|
||||
EquipMask location,
|
||||
ItemType type,
|
||||
|
|
|
|||
|
|
@ -223,6 +223,40 @@ public class KeyBindingsJsonTests
|
|||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadOrDefault_migratesV2CombatPressBindingToHold()
|
||||
{
|
||||
var path = TempFile();
|
||||
try
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"version": 2,
|
||||
"actions": {
|
||||
"CombatHighAttack": [
|
||||
{ "key": "F7" }
|
||||
]
|
||||
}
|
||||
}
|
||||
""";
|
||||
File.WriteAllText(path, json);
|
||||
|
||||
var loaded = KeyBindings.LoadOrDefault(path);
|
||||
|
||||
Assert.Equal(InputAction.CombatHighAttack,
|
||||
loaded.Find(
|
||||
new KeyChord(Key.F7, ModifierMask.None),
|
||||
ActivationType.Hold)?.Action);
|
||||
Assert.Null(loaded.Find(
|
||||
new KeyChord(Key.F7, ModifierMask.None),
|
||||
ActivationType.Press));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadOrDefault_unknown_action_name_skipped_silently()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -108,6 +108,19 @@ public class KeyBindingsRetailTests
|
|||
Assert.Equal(InputAction.EscapeKey, hit!.Value.Action);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Key.Delete, InputAction.CombatLowAttack)]
|
||||
[InlineData(Key.End, InputAction.CombatMediumAttack)]
|
||||
[InlineData(Key.PageDown, InputAction.CombatHighAttack)]
|
||||
public void CombatAttackHeightBindings_EmitHoldTransitions(Key key, InputAction action)
|
||||
{
|
||||
var b = KeyBindings.RetailDefaults();
|
||||
|
||||
var hit = b.Find(new KeyChord(key, ModifierMask.None), ActivationType.Hold);
|
||||
|
||||
Assert.Equal(action, hit?.Action);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuickSlot_5_bareUsesAndCtrlSelects()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue