diff --git a/Wobble.Tests/Localization/Strings.bg.resx b/Wobble.Tests/Localization/Strings.bg.resx index 6742b957..8acb58b4 100644 --- a/Wobble.Tests/Localization/Strings.bg.resx +++ b/Wobble.Tests/Localization/Strings.bg.resx @@ -57,6 +57,9 @@ Анимации за ускоряване + + Време на анимациите + Imgui diff --git a/Wobble.Tests/Localization/Strings.resx b/Wobble.Tests/Localization/Strings.resx index 21966b29..4f9a1437 100644 --- a/Wobble.Tests/Localization/Strings.resx +++ b/Wobble.Tests/Localization/Strings.resx @@ -84,6 +84,9 @@ Easing Animations + + Animation Timing + Imgui diff --git a/Wobble.Tests/Screens/Selection/TestScreenRegistry.cs b/Wobble.Tests/Screens/Selection/TestScreenRegistry.cs index 9b44263d..c60a9d04 100644 --- a/Wobble.Tests/Screens/Selection/TestScreenRegistry.cs +++ b/Wobble.Tests/Screens/Selection/TestScreenRegistry.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using Wobble.Screens; +using Wobble.Tests.Screens.Tests.AnimationTiming; using Wobble.Tests.Screens.Tests.Audio; using Wobble.Tests.Screens.Tests.Background; using Wobble.Tests.Screens.Tests.BlurContainer; @@ -89,6 +90,7 @@ internal static class TestScreenRegistry new TestScreenDescriptor(LayoutMotion, "Screen_DrawableScaling", () => new TestDrawableScalingScreen()), new TestScreenDescriptor(LayoutMotion, "Screen_Scaling", () => new TestScalingScreen()), new TestScreenDescriptor(LayoutMotion, "Screen_EasingAnimations", () => new TestEasingAnimationsScreen()), + new TestScreenDescriptor(LayoutMotion, "Screen_AnimationTiming", () => new TestAnimationTimingScreen()), new TestScreenDescriptor(LayoutMotion, "Screen_Scrolling", () => new TestScrollContainerScreen()), new TestScreenDescriptor(LayoutMotion, "Screen_MarqueeSpriteText", () => new TestMarqueeSpriteTextScreen()), new TestScreenDescriptor(LayoutMotion, "Screen_FlexContainer", () => new TestFlexContainerScreen()), diff --git a/Wobble.Tests/Screens/Tests/AnimationTiming/TestAnimationTimingScreen.cs b/Wobble.Tests/Screens/Tests/AnimationTiming/TestAnimationTimingScreen.cs new file mode 100644 index 00000000..689967a3 --- /dev/null +++ b/Wobble.Tests/Screens/Tests/AnimationTiming/TestAnimationTimingScreen.cs @@ -0,0 +1,13 @@ +using Wobble.Screens; + +namespace Wobble.Tests.Screens.Tests.AnimationTiming +{ + public class TestAnimationTimingScreen : Screen + { + public sealed override ScreenView View { get; protected set; } + + public TestAnimationTimingScreen() => View = new TestAnimationTimingScreenView(this); + + public override void OnActivated() => ((TestAnimationTimingScreenView) View).Activate(); + } +} diff --git a/Wobble.Tests/Screens/Tests/AnimationTiming/TestAnimationTimingScreenView.cs b/Wobble.Tests/Screens/Tests/AnimationTiming/TestAnimationTimingScreenView.cs new file mode 100644 index 00000000..72dbde47 --- /dev/null +++ b/Wobble.Tests/Screens/Tests/AnimationTiming/TestAnimationTimingScreenView.cs @@ -0,0 +1,442 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using Microsoft.Xna.Framework; +using Wobble.Assets; +using Wobble.Bindables; +using Wobble.Graphics; +using Wobble.Graphics.Animations; +using Wobble.Graphics.Buttons; +using Wobble.Graphics.Sprites; +using Wobble.Graphics.Sprites.Text; +using Wobble.Graphics.UI; +using Wobble.Graphics.UI.Form; +using Wobble.Managers; +using Wobble.Screens; +using Wobble.Tests.Assets; + +namespace Wobble.Tests.Screens.Tests.AnimationTiming +{ + public class TestAnimationTimingScreenView : ScreenView + { + private const int MovementDuration = 1000; + private const double TargetChangeInterval = 1000; + private const float TintTimeConstant = 120; + private const double FrameTime = 1000f / 60f; + private const float MovementLeft = -420; + private const float MovementRight = 420; + + private static readonly Color BackgroundColor = new Color(17, 24, 32); + private static readonly Color PanelColor = new Color(31, 41, 51); + private static readonly Color MutedColor = new Color(143, 155, 166); + private static readonly Color AccentColor = new Color(15, 186, 229); + private static readonly Color PurpleColor = new Color(117, 92, 222); + private static readonly Color SuccessColor = new Color(105, 230, 166); + private static readonly Color FailureColor = new Color(255, 119, 119); + private static readonly Color TintStart = new Color(40, 80, 120); + private static readonly Color TintEnd = new Color(220, 130, 30); + + private static readonly List RateOptions = new List + { + "Unlimited", "1", "5", "15", "30", "60", "120", "180", "240", "500", "1000" + }; + + private WobbleTestsGame TestGame => GameBase.Game as WobbleTestsGame; + + private HorizontalSelector FpsSelector { get; } + private HorizontalSelector UpsSelector { get; } + private SpriteTextPlus MeasuredRatesText { get; } + private SpriteTextPlus MovementStatusText { get; } + private SpriteTextPlus DiagnosticsText { get; } + private Sprite MotionSprite { get; } + private Sprite TintSprite { get; set; } + private ProgressBar ProgressBar { get; set; } + private BindableDouble ProgressValue { get; set; } + private AnimatableSprite FrameSprite { get; set; } + + private Vector3 _expectedTint = TintStart.ToVector3(); + private Color _tintTarget = TintEnd; + private double _targetChangeTimer; + private double _expectedFrameRemainder; + private int _expectedFrame; + private int _maximumTintError; + private bool _framePositionMatches = true; + + private long _rateSampleTimestamp; + private int _sampledUpdates; + private int _sampledDraws; + private int _measuredUps; + private int _measuredFps; + + private long _movementStartTimestamp; + private bool _movementToRight = true; + private bool _movementResultAvailable; + private bool _movementPassed; + private double _lastMovementDuration; + private double _diagnosticRefreshTimer; + + public TestAnimationTimingScreenView(Screen screen) : base(screen) + { + CreateHeader(); + + FpsSelector = CreateRateSelector("TARGET FPS", -260, 180, (_, __) => ApplySelectedRates()); + UpsSelector = CreateRateSelector("TARGET UPS", 260, 1000, (_, __) => ApplySelectedRates()); + + CreatePresetButton("180 / 1000", -260, () => ApplyPreset(180, 1000)); + CreatePresetButton("60 / 60", 0, () => ApplyPreset(60, 60)); + CreatePresetButton("UNLIMITED", 260, () => ApplyPreset(null, null)); + + MeasuredRatesText = CreateText("Waiting for a one-second sample…", 18, 188, Color.White); + + CreateText("1000 MS QUEUED MOVEMENT", 14, 226, MutedColor); + MotionSprite = new Sprite + { + Parent = Container, + Alignment = Alignment.TopCenter, + Position = new ScalableVector2(MovementLeft, 254), + Size = new ScalableVector2(44, 28), + Tint = AccentColor + }; + MovementStatusText = CreateText("RUNNING", 14, 286, MutedColor); + + CreateDiagnosticPanels(); + + DiagnosticsText = CreateText("Diagnostics are warming up…", 15, 648, Color.White); + } + + public void Activate() + { + ApplyPreset(180, 1000); + _rateSampleTimestamp = Stopwatch.GetTimestamp(); + _sampledUpdates = 0; + _sampledDraws = 0; + StartMovement(); + } + + private void CreateHeader() + { + CreateText("ANIMATION TIMING", 26, 18, Color.White, "inter-bold"); + CreateText("Change the real draw and logic rates, then compare motion, smoothing, hover, and sprite frames.", + 16, 54, MutedColor); + } + + private HorizontalSelector CreateRateSelector(string label, float x, int selectedRate, + Action onChange) + { + CreateText(label, 13, 92, MutedColor).X = x; + + var selectedIndex = RateOptions.IndexOf(selectedRate.ToString()); + var selector = new HorizontalSelector(RateOptions, new ScalableVector2(210, 36), + FontManager.GetWobbleFont("inter-semibold"), 15, Textures.LeftButtonSquare, + Textures.RightButtonSquare, new ScalableVector2(34, 34), 8, onChange, + selectedIndex, true) + { + Parent = Container, + Alignment = Alignment.TopCenter, + X = x, + Y = 116, + Tint = PanelColor + }; + + selector.SelectedItemText.Tint = Color.White; + StyleSelectorButton(selector.RoundedButtonSelectLeft); + StyleSelectorButton(selector.RoundedButtonSelectRight); + return selector; + } + + private static void StyleSelectorButton(RoundedButton button) + { + button.CornerRadius = 7; + button.Tint = PurpleColor; + button.Label.Tint = Color.White; + } + + private void CreatePresetButton(string text, float x, Action action) + { + var button = new RoundedButton((sender, args) => action()) + { + Parent = Container, + Alignment = Alignment.TopCenter, + Position = new ScalableVector2(x, 158), + Size = new ScalableVector2(160, 30), + CornerRadius = 6, + Tint = PanelColor + }; + button.SetLabel(FontManager.GetWobbleFont("inter-semibold"), text, 13, Color.White); + } + + private void CreateDiagnosticPanels() + { + var tintPanel = CreatePanel(-410, 324, "TINT DAMPING"); + TintSprite = new Sprite + { + Parent = tintPanel, + Alignment = Alignment.MidCenter, + Size = new ScalableVector2(150, 54), + Tint = TintStart + }; + + var progressPanel = CreatePanel(0, 324, "PROGRESS DAMPING"); + ProgressValue = new BindableDouble(100, 0, 100); + ProgressBar = new ProgressBar(new Vector2(260, 22), ProgressValue, new Color(53, 63, 74), + AccentColor, false) + { + Parent = progressPanel, + Alignment = Alignment.MidCenter + }; + + var framePanel = CreatePanel(410, 324, "60 FPS SPRITE LOOP"); + FrameSprite = new AnimatableSprite(Textures.TestSpriteSheet) + { + Parent = framePanel, + Alignment = Alignment.MidCenter, + Size = new ScalableVector2(76, 76) + }; + FrameSprite.StartLoop(Direction.Forward, 60); + + CreateText("INTERACTIVE CONTROLS", 14, 474, MutedColor); + + var hoverButton = new RoundedButton + { + Parent = Container, + Alignment = Alignment.TopCenter, + Position = new ScalableVector2(-240, 508), + Size = new ScalableVector2(54, 44), + CornerRadius = 9, + Tint = PurpleColor, + ExpandLabelOnHover = true, + HoverExpansionDuration = 150 + }; + hoverButton.SetIcon(WobbleAssets.WhiteBox, new Vector2(16, 16)); + hoverButton.SetLabel(FontManager.GetWobbleFont("inter-semibold"), "HOVER TIMING", 15, Color.White); + + var textbox = new Textbox(new ScalableVector2(320, 42), + FontManager.GetWobbleFont("inter-medium"), 16, string.Empty, "Type text, then press Ctrl+A") + { + Parent = Container, + Alignment = Alignment.TopCenter, + Position = new ScalableVector2(210, 508), + Tint = PanelColor + }; + + CreateText("Hover the button; use the textbox selection to compare feedback at each rate.", + 14, 568, MutedColor); + } + + private Container CreatePanel(float x, float y, string title) + { + var panel = new Container + { + Parent = Container, + Alignment = Alignment.TopCenter, + Position = new ScalableVector2(x, y), + Size = new ScalableVector2(330, 126) + }; + + new Sprite + { + Parent = panel, + Size = panel.Size, + Tint = PanelColor + }; + + new SpriteTextPlus(FontManager.GetWobbleFont("inter-semibold"), title, 13) + { + Parent = panel, + Alignment = Alignment.TopCenter, + Y = 12, + Tint = MutedColor + }; + + return panel; + } + + private SpriteTextPlus CreateText(string text, int size, float y, Color color, + string font = "inter-medium") => new SpriteTextPlus(FontManager.GetWobbleFont(font), text, size) + { + Parent = Container, + Alignment = Alignment.TopCenter, + Y = y, + Tint = color + }; + + private void ApplyPreset(int? fps, int? ups) + { + SetSelectorRate(FpsSelector, fps); + SetSelectorRate(UpsSelector, ups); + ApplySelectedRates(); + } + + private static void SetSelectorRate(HorizontalSelector selector, int? rate) + { + var value = rate?.ToString() ?? "Unlimited"; + var index = selector.Options.IndexOf(value); + + if (index < 0) + throw new ArgumentOutOfRangeException(nameof(rate), rate, "Unsupported timing-test rate."); + + selector.SelectedIndex = index; + selector.SelectedItemText.Text = value; + } + + private void ApplySelectedRates() + { + if (FpsSelector == null || UpsSelector == null) + return; + + TestGame?.SetTestFrameRates(ParseRate(FpsSelector), ParseRate(UpsSelector)); + _rateSampleTimestamp = Stopwatch.GetTimestamp(); + _sampledUpdates = 0; + _sampledDraws = 0; + } + + private static int? ParseRate(HorizontalSelector selector) => + selector.Options[selector.SelectedIndex] == "Unlimited" + ? (int?) null + : int.Parse(selector.Options[selector.SelectedIndex]); + + public override void Update(GameTime gameTime) + { + _sampledUpdates++; + var elapsedMilliseconds = gameTime.ElapsedGameTime.TotalMilliseconds; + + UpdateAutomaticTargets(elapsedMilliseconds); + UpdateExpectedTint(elapsedMilliseconds); + UpdateExpectedFrame(elapsedMilliseconds); + + TintSprite.FadeToColor(_tintTarget, elapsedMilliseconds, TintTimeConstant); + Container?.Update(gameTime); + + CheckMovementCompletion(); + CheckTintResult(); + _framePositionMatches = FrameSprite.CurrentFrame == _expectedFrame; + UpdateMeasuredRates(); + + _diagnosticRefreshTimer += elapsedMilliseconds; + if (_diagnosticRefreshTimer >= 100) + { + _diagnosticRefreshTimer %= 100; + RefreshDiagnostics(); + } + } + + private void UpdateAutomaticTargets(double elapsedMilliseconds) + { + _targetChangeTimer += elapsedMilliseconds; + + while (_targetChangeTimer >= TargetChangeInterval) + { + _targetChangeTimer -= TargetChangeInterval; + _tintTarget = _tintTarget == TintEnd ? TintStart : TintEnd; + ProgressValue.Value = ProgressValue.Value > 0 ? 0 : 100; + _maximumTintError = 0; + } + } + + private void UpdateExpectedTint(double elapsedMilliseconds) + { + var amount = elapsedMilliseconds <= 0 + ? 0 + : (float) (1 - Math.Exp(-elapsedMilliseconds / TintTimeConstant)); + _expectedTint = Vector3.Lerp(_expectedTint, _tintTarget.ToVector3(), amount); + } + + private void UpdateExpectedFrame(double elapsedMilliseconds) + { + _expectedFrameRemainder += elapsedMilliseconds; + + while (_expectedFrameRemainder >= FrameTime) + { + _expectedFrameRemainder -= FrameTime; + _expectedFrame = (_expectedFrame + 1) % FrameSprite.Frames.Count; + } + } + + private void CheckTintResult() + { + var expected = new Color( + (int) Math.Round(_expectedTint.X * byte.MaxValue), + (int) Math.Round(_expectedTint.Y * byte.MaxValue), + (int) Math.Round(_expectedTint.Z * byte.MaxValue)); + var error = Math.Max(Math.Abs(expected.R - TintSprite.Tint.R), + Math.Max(Math.Abs(expected.G - TintSprite.Tint.G), + Math.Abs(expected.B - TintSprite.Tint.B))); + _maximumTintError = Math.Max(_maximumTintError, error); + } + + private void StartMovement() + { + MotionSprite.ClearAnimations(); + MotionSprite.MoveToX(_movementToRight ? MovementRight : MovementLeft, Easing.Linear, + MovementDuration); + _movementStartTimestamp = Stopwatch.GetTimestamp(); + } + + private void CheckMovementCompletion() + { + if (MotionSprite.Animations.Count == 0 || !MotionSprite.Animations[0].Done) + return; + + _lastMovementDuration = Stopwatch.GetElapsedTime(_movementStartTimestamp).TotalMilliseconds; + var updateTolerance = TestGame?.TestTargetUps is int ups ? 1000d / ups : 0; + _movementPassed = Math.Abs(_lastMovementDuration - MovementDuration) <= 35 + updateTolerance; + _movementResultAvailable = true; + _movementToRight = !_movementToRight; + StartMovement(); + } + + private void UpdateMeasuredRates() + { + if (_rateSampleTimestamp == 0) + _rateSampleTimestamp = Stopwatch.GetTimestamp(); + + var elapsed = Stopwatch.GetElapsedTime(_rateSampleTimestamp).TotalSeconds; + if (elapsed < 1) + return; + + _measuredUps = (int) Math.Round(_sampledUpdates / elapsed); + _measuredFps = (int) Math.Round(_sampledDraws / elapsed); + _sampledUpdates = 0; + _sampledDraws = 0; + _rateSampleTimestamp = Stopwatch.GetTimestamp(); + + var targetFps = TestGame?.TestTargetFps?.ToString() ?? "Unlimited"; + var targetUps = TestGame?.TestTargetUps?.ToString() ?? "Unlimited"; + MeasuredRatesText.Text = + $"Requested: {targetFps} FPS / {targetUps} UPS Measured: {_measuredFps} FPS / {_measuredUps} UPS"; + } + + private void RefreshDiagnostics() + { + var movementResult = !_movementResultAvailable + ? "MOVEMENT: WARMING UP" + : $"MOVEMENT: {(_movementPassed ? "PASS" : "FAIL")} ({_lastMovementDuration:0.0} ms)"; + var tintPassed = _maximumTintError <= 1; + var frameResult = _framePositionMatches ? "PASS" : "FAIL"; + + MovementStatusText.Text = movementResult; + MovementStatusText.Tint = !_movementResultAvailable + ? MutedColor + : _movementPassed ? SuccessColor : FailureColor; + + DiagnosticsText.Text = + $"TINT: {(tintPassed ? "PASS" : "FAIL")} (max error {_maximumTintError}) " + + $"SPRITE FRAME: {frameResult} ({FrameSprite.CurrentFrame}/{_expectedFrame})"; + DiagnosticsText.Tint = tintPassed && _framePositionMatches ? SuccessColor : FailureColor; + } + + public override void Draw(GameTime gameTime) + { + _sampledDraws++; + GameBase.Game.GraphicsDevice.Clear(BackgroundColor); + Container?.Draw(gameTime); + } + + public override void Destroy() + { + TestGame?.ResetTestFrameRates(); + ProgressValue?.Dispose(); + Container?.Destroy(); + } + } +} diff --git a/Wobble.Tests/Wobble.Tests.csproj b/Wobble.Tests/Wobble.Tests.csproj index 2edcc8dc..4aa87331 100644 --- a/Wobble.Tests/Wobble.Tests.csproj +++ b/Wobble.Tests/Wobble.Tests.csproj @@ -19,4 +19,27 @@ $(RootNamespace).Localization.%(Filename).resources + + + ..\MonoGame.Framework.dll + + + + + + false + $(RootNamespace).Localization.%(Filename).resources + + + + + + + + + + + + + diff --git a/Wobble.Tests/WobbleTestsGame.cs b/Wobble.Tests/WobbleTestsGame.cs index 2b61e6fe..cd2309c3 100644 --- a/Wobble.Tests/WobbleTestsGame.cs +++ b/Wobble.Tests/WobbleTestsGame.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Resources; +using System.Diagnostics; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Input; using Wobble.Graphics; @@ -35,6 +36,16 @@ public class WobbleTestsGame : WobbleGame private double _gcLogTimer; private readonly int[] _lastGcCounts = new int[3]; + private int? _testTargetFps; + private int? _testTargetUps; + private double _nextTestDrawTimestamp; + private double _nextTestUpdateTimestamp; + private TimeSpan _pendingTestUpdateElapsed; + + internal int? TestTargetFps => _testTargetFps; + + internal int? TestTargetUps => _testTargetUps; + #if DEBUG private PerformanceSweep _performanceSweep; #endif @@ -150,10 +161,13 @@ protected override void Update(GameTime gameTime) if (!IsReadyToUpdate) return; - base.Update(gameTime); + if (!TryCreateTestUpdateTime(gameTime, out var updateTime)) + return; + + base.Update(updateTime); #if DEBUG - _performanceSweep?.Update(gameTime); + _performanceSweep?.Update(updateTime); #endif // TODO: Your global update logic goes here. @@ -184,7 +198,7 @@ protected override void Update(GameTime gameTime) if (_logGc) { - _gcLogTimer += gameTime.ElapsedGameTime.TotalMilliseconds; + _gcLogTimer += updateTime.ElapsedGameTime.TotalMilliseconds; if (_gcLogTimer >= 1000) { _gcLogTimer = 0; @@ -193,6 +207,14 @@ protected override void Update(GameTime gameTime) } } + protected override bool BeginDraw() + { + if (!ShouldRunTestDraw()) + return false; + + return base.BeginDraw(); + } + protected override void Draw(GameTime gameTime) { if (!IsReadyToUpdate) @@ -201,6 +223,87 @@ protected override void Draw(GameTime gameTime) base.Draw(gameTime); } + /// + /// Applies independent update and draw limits for the animation timing test screen. + /// A null value leaves that side of the loop unlimited. + /// + internal void SetTestFrameRates(int? targetFps, int? targetUps) + { + ValidateTestFrameRate(targetFps, nameof(targetFps)); + ValidateTestFrameRate(targetUps, nameof(targetUps)); + + _testTargetFps = targetFps; + _testTargetUps = targetUps; + _pendingTestUpdateElapsed = TimeSpan.Zero; + + var now = Stopwatch.GetTimestamp(); + _nextTestDrawTimestamp = targetFps.HasValue + ? now + Stopwatch.Frequency / (double) targetFps.Value + : 0; + _nextTestUpdateTimestamp = targetUps.HasValue + ? now + Stopwatch.Frequency / (double) targetUps.Value + : 0; + } + + internal void ResetTestFrameRates() => SetTestFrameRates(null, null); + + private bool TryCreateTestUpdateTime(GameTime gameTime, out GameTime updateTime) + { + if (WaylandVsync) + { + updateTime = gameTime; + return true; + } + if (!_testTargetUps.HasValue) + { + _pendingTestUpdateElapsed = TimeSpan.Zero; + updateTime = gameTime; + return true; + } + + _pendingTestUpdateElapsed += gameTime.ElapsedGameTime; + + var now = Stopwatch.GetTimestamp(); + if (now < _nextTestUpdateTimestamp) + { + updateTime = null; + return false; + } + + AdvanceTestDeadline(ref _nextTestUpdateTimestamp, _testTargetUps.Value, now); + updateTime = new GameTime(gameTime.TotalGameTime, _pendingTestUpdateElapsed, + gameTime.IsRunningSlowly); + _pendingTestUpdateElapsed = TimeSpan.Zero; + return true; + } + + private bool ShouldRunTestDraw() + { + if (WaylandVsync || !_testTargetFps.HasValue) + return true; + + var now = Stopwatch.GetTimestamp(); + if (now < _nextTestDrawTimestamp) + return false; + + AdvanceTestDeadline(ref _nextTestDrawTimestamp, _testTargetFps.Value, now); + return true; + } + + private static void AdvanceTestDeadline(ref double deadline, int rate, long now) + { + var interval = Stopwatch.Frequency / (double) rate; + var elapsedIntervals = Math.Floor((now - deadline) / interval) + 1; + deadline += Math.Max(1, elapsedIntervals) * interval; + } + + private static void ValidateTestFrameRate(int? rate, string parameterName) + { + if (rate <= 0) + throw new ArgumentOutOfRangeException(parameterName, rate, + "A test frame rate must be greater than zero or null for unlimited."); + } + private void LogGc(string tag) { var totalBytes = GC.GetTotalMemory(false); diff --git a/Wobble/Graphics/Animations/Animation.cs b/Wobble/Graphics/Animations/Animation.cs index 9e3ead62..fbdad6be 100644 --- a/Wobble/Graphics/Animations/Animation.cs +++ b/Wobble/Graphics/Animations/Animation.cs @@ -87,6 +87,12 @@ public Animation(Easing easingType, Color start, Color end, float time) /// public float PerformInterpolation(GameTime gameTime) { + if (Time <= 0) + { + Done = true; + return End; + } + CurrentAnimationTime += gameTime.ElapsedGameTime.TotalMilliseconds; if (CurrentAnimationTime > Time) @@ -110,6 +116,12 @@ public float PerformInterpolation(GameTime gameTime) /// public Color PerformColorInterpolation(GameTime gameTime) { + if (Time <= 0) + { + Done = true; + return EndColor; + } + CurrentAnimationTime += gameTime.ElapsedGameTime.TotalMilliseconds; if (CurrentAnimationTime > Time) diff --git a/Wobble/Graphics/Animations/AnimationMath.cs b/Wobble/Graphics/Animations/AnimationMath.cs new file mode 100644 index 00000000..7a684780 --- /dev/null +++ b/Wobble/Graphics/Animations/AnimationMath.cs @@ -0,0 +1,32 @@ +using System; +using Microsoft.Xna.Framework; + +namespace Wobble.Graphics.Animations +{ + /// + /// Helpers for visual smoothing that must remain stable across different update rates. + /// + internal static class AnimationMath + { + /// + /// Returns the interpolation amount for exponential smoothing over an elapsed time. + /// + internal static float SmoothingAmount(double elapsedMilliseconds, float timeConstantMilliseconds) + { + if (elapsedMilliseconds <= 0) + return 0; + + if (timeConstantMilliseconds <= 0) + return 1; + + return (float) (1 - Math.Exp(-elapsedMilliseconds / timeConstantMilliseconds)); + } + + /// + /// Smooths a value towards a target independently of update frequency. + /// + internal static float Damp(float current, float target, double elapsedMilliseconds, + float timeConstantMilliseconds) => MathHelper.Lerp(current, target, + SmoothingAmount(elapsedMilliseconds, timeConstantMilliseconds)); + } +} diff --git a/Wobble/Graphics/Buttons/RoundedButton.cs b/Wobble/Graphics/Buttons/RoundedButton.cs index f365b4fb..48fc33bc 100644 --- a/Wobble/Graphics/Buttons/RoundedButton.cs +++ b/Wobble/Graphics/Buttons/RoundedButton.cs @@ -1,6 +1,7 @@ using System; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; +using Wobble.Graphics.Animations; using Wobble.Graphics.Shaders; using Wobble.Graphics.Sprites; using Wobble.Graphics.Sprites.Text; @@ -329,20 +330,21 @@ private void UpdateBackgroundTexture() /// public override void Update(GameTime gameTime) { - if (PerformHoverFade) + base.Update(gameTime); + + if (PerformHoverFade && + !Animations.Exists(animation => animation.Properties == AnimationProperty.Alpha)) { var dt = gameTime.ElapsedGameTime.TotalMilliseconds; var targetAlpha = IsHovered ? 0.75f : 1f; if (Alpha != targetAlpha) { - var alpha = MathHelper.Lerp(Alpha, targetAlpha, (float) Math.Min(dt / 60, 1)); + var alpha = AnimationMath.Damp(Alpha, targetAlpha, dt, 60); Alpha = Math.Abs(alpha - targetAlpha) < 0.001f ? targetAlpha : alpha; } } - base.Update(gameTime); - var contentSize = new Vector2( (Icon?.Width ?? 0) + (Label?.Width ?? 0), Math.Max(Icon?.Height ?? 0, Label?.Height ?? 0)); diff --git a/Wobble/Graphics/Sprites/AnimatableSprite.cs b/Wobble/Graphics/Sprites/AnimatableSprite.cs index afc5bec5..21edff34 100644 --- a/Wobble/Graphics/Sprites/AnimatableSprite.cs +++ b/Wobble/Graphics/Sprites/AnimatableSprite.cs @@ -13,6 +13,12 @@ namespace Wobble.Graphics.Sprites /// public class AnimatableSprite : Sprite { + /// + /// Prevents a large elapsed time or an unreasonable frame rate from monopolizing an update. + /// Any remaining elapsed time is retained and processed by later updates. + /// + private const int MaximumFrameAdvancesPerUpdate = 256; + /// /// The animation frames /// @@ -217,38 +223,50 @@ public void ReplaceFrames(List newFrames) /// private void PerformLoopAnimation(GameTime gameTime) { - if (!IsLooping || Frames.Count <= 1) + if (!IsLooping || Frames.Count <= 1 || LoopFramesPerSecond <= 0) return; TimeSinceLastAnimFrame += gameTime.ElapsedGameTime.TotalMilliseconds; + var frameTime = 1000f / LoopFramesPerSecond; + var framesAdvanced = 0; - if (!(TimeSinceLastAnimFrame >= 1000f / LoopFramesPerSecond)) - return; - - switch (Direction) + while (IsLooping && Frames.Count > 1 && LoopFramesPerSecond > 0 && + TimeSinceLastAnimFrame >= frameTime && framesAdvanced < MaximumFrameAdvancesPerUpdate) { - case Direction.Forward: - ChangeToNext(); - break; - case Direction.Backward: - ChangeToPrevious(); - break; - default: - throw new ArgumentOutOfRangeException(); + TimeSinceLastAnimFrame -= frameTime; + framesAdvanced++; + + switch (Direction) + { + case Direction.Forward: + ChangeToNext(); + break; + case Direction.Backward: + ChangeToPrevious(); + break; + default: + throw new ArgumentOutOfRangeException(); + } + + // If we're back on the frame we've started on, then we need to increment our counter. + if (FrameLoopStartedOn != CurrentFrame) + continue; + + TimesLooped++; + FinishedLooping?.Invoke(this, null); + + // Automatically stop the loop if we've looped the specified amount of times. + if (TimesToLoop != 0 && TimesLooped == TimesToLoop) + { + // Elapsed time after a finite loop's end must not carry into a later StartLoop call. + TimeSinceLastAnimFrame = 0; + StopLoop(); + continue; + } + + // FinishedLooping handlers may restart the animation with a different frame rate. + frameTime = 1000f / LoopFramesPerSecond; } - - TimeSinceLastAnimFrame = 0; - - // If we're back on the frame we've started on, then we need to increment our counter. - if (FrameLoopStartedOn != CurrentFrame) - return; - - TimesLooped++; - FinishedLooping?.Invoke(this, null); - - // Automatically stop the loop if we've looped the specified amount of times. - if (TimesToLoop != 0 && TimesLooped == TimesToLoop) - StopLoop(); } } -} \ No newline at end of file +} diff --git a/Wobble/Graphics/Sprites/Sprite.cs b/Wobble/Graphics/Sprites/Sprite.cs index 71bc1c69..9f9aae02 100644 --- a/Wobble/Graphics/Sprites/Sprite.cs +++ b/Wobble/Graphics/Sprites/Sprite.cs @@ -118,6 +118,8 @@ public TextureRegion? Region /// The tint this QuaverSprite will inherit. /// private Color _tint = Color.White; + private Vector3 _preciseTint = Vector3.One; + private bool _isApplyingPreciseTint; public Color _color = Color.White; public virtual Color Tint { @@ -125,6 +127,10 @@ public virtual Color Tint set { _tint = value; + + if (!_isApplyingPreciseTint) + _preciseTint = value.ToVector3(); + _color = _tint * _alpha; } } @@ -322,11 +328,27 @@ protected override void OnRectangleRecalculated() /// public virtual void FadeToColor(Color color, double dt, float scale) { - var r = MathHelper.Lerp(Tint.R, color.R, (float)Math.Min(dt / scale, 1)); - var g = MathHelper.Lerp(Tint.G, color.G, (float)Math.Min(dt / scale, 1)); - var b = MathHelper.Lerp(Tint.B, color.B, (float)Math.Min(dt / scale, 1)); + var target = color.ToVector3(); + + _preciseTint.X = AnimationMath.Damp(_preciseTint.X, target.X, dt, scale); + _preciseTint.Y = AnimationMath.Damp(_preciseTint.Y, target.Y, dt, scale); + _preciseTint.Z = AnimationMath.Damp(_preciseTint.Z, target.Z, dt, scale); + + var renderedTint = new Color( + (int) Math.Round(_preciseTint.X * byte.MaxValue), + (int) Math.Round(_preciseTint.Y * byte.MaxValue), + (int) Math.Round(_preciseTint.Z * byte.MaxValue)); + + _isApplyingPreciseTint = true; - Tint = new Color((int)r, (int)g, (int)b); + try + { + Tint = renderedTint; + } + finally + { + _isApplyingPreciseTint = false; + } } /// diff --git a/Wobble/Graphics/UI/Cursor.cs b/Wobble/Graphics/UI/Cursor.cs index d833af0a..5c9edbef 100644 --- a/Wobble/Graphics/UI/Cursor.cs +++ b/Wobble/Graphics/UI/Cursor.cs @@ -6,6 +6,7 @@ using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Input; +using Wobble.Graphics.Animations; using Wobble.Graphics.Sprites; using Wobble.Input; @@ -61,6 +62,10 @@ public float SizeScale /// private double CurrentAnimationTime { get; set; } + private float AnimationStartAlpha { get; set; } + + private bool IsVisibilityAnimationActive { get; set; } + /// /// /// @@ -83,19 +88,12 @@ public Cursor(Texture2D image, sbyte size, float expandScale = 1.2f) public override void Update(GameTime gameTime) { var baseSize = OriginalSize * SizeScale; - - if (MouseManager.CurrentState.LeftButton == ButtonState.Pressed) - { - // Calculate the new size that the cursor will be when pressed. - var newSize = MathHelper.Lerp(Width, baseSize * ExpandScale, (float)Math.Min(GameBase.Game.TimeSinceLastFrame / 60, 1)); - Size = new ScalableVector2(newSize, newSize); - } - else - { - // Calculate new size when not pressed. - var newSize = MathHelper.Lerp(Width, baseSize, (float)Math.Min(GameBase.Game.TimeSinceLastFrame / 60, 1)); - Size = new ScalableVector2(newSize, newSize); - } + var targetSize = MouseManager.CurrentState.LeftButton == ButtonState.Pressed + ? baseSize * ExpandScale + : baseSize; + var newSize = AnimationMath.Damp(Width, targetSize, + gameTime.ElapsedGameTime.TotalMilliseconds, 60); + Size = new ScalableVector2(newSize, newSize); X = MouseManager.CurrentState.X; Y = MouseManager.CurrentState.Y; @@ -118,8 +116,7 @@ public override void Update(GameTime gameTime) public void Show(int time) { IsShown = true; - AnimationCompletionTime = time; - CurrentAnimationTime = 0; + StartVisibilityAnimation(time); } /// @@ -129,16 +126,37 @@ public void Show(int time) public void Hide(int time) { IsShown = false; - AnimationCompletionTime = time; - CurrentAnimationTime = 0; + StartVisibilityAnimation(time); } private void PerformShowAndHideAnimations(GameTime gameTime) { + if (!IsVisibilityAnimationActive) + return; + CurrentAnimationTime += gameTime.ElapsedGameTime.TotalMilliseconds; - var lerpTime = CurrentAnimationTime / AnimationCompletionTime; + var progress = MathHelper.Clamp((float) (CurrentAnimationTime / AnimationCompletionTime), 0, 1); + + Alpha = MathHelper.Lerp(AnimationStartAlpha, IsShown ? 1 : 0, progress); + + if (progress >= 1) + IsVisibilityAnimationActive = false; + } + + private void StartVisibilityAnimation(int time) + { + AnimationCompletionTime = Math.Max(0, time); + CurrentAnimationTime = 0; + AnimationStartAlpha = Alpha; + + if (AnimationCompletionTime == 0) + { + Alpha = IsShown ? 1 : 0; + IsVisibilityAnimationActive = false; + return; + } - Alpha = MathHelper.Lerp(Alpha, IsShown ? 1 : 0, (float)lerpTime); + IsVisibilityAnimationActive = true; } } } diff --git a/Wobble/Graphics/UI/Form/Textbox.cs b/Wobble/Graphics/UI/Form/Textbox.cs index db93dc7d..40b3f15b 100644 --- a/Wobble/Graphics/UI/Form/Textbox.cs +++ b/Wobble/Graphics/UI/Form/Textbox.cs @@ -6,6 +6,7 @@ using Microsoft.Xna.Framework; using Wobble.Assets; using Wobble.Audio.Samples; +using Wobble.Graphics.Animations; using Wobble.Graphics.Sprites; using Wobble.Graphics.Sprites.Text; using Wobble.Graphics.UI.Buttons; @@ -396,8 +397,8 @@ public override void Update(GameTime gameTime) CalculateContainerX(); // Change the alpha of the selected sprite depending on whether text is selected. - SelectedSprite.Alpha = MathHelper.Lerp(SelectedSprite.Alpha, Selected ? 0.5f : 0, - (float)Math.Min(gameTime.ElapsedGameTime.TotalMilliseconds / 60, 1)); + SelectedSprite.Alpha = AnimationMath.Damp(SelectedSprite.Alpha, Selected ? 0.5f : 0, + gameTime.ElapsedGameTime.TotalMilliseconds, 60); PerformCursorBlinking(gameTime); UpdateTextInputState(); diff --git a/Wobble/Graphics/UI/ProgressBar.cs b/Wobble/Graphics/UI/ProgressBar.cs index 2fca5221..7d10ea26 100644 --- a/Wobble/Graphics/UI/ProgressBar.cs +++ b/Wobble/Graphics/UI/ProgressBar.cs @@ -1,6 +1,7 @@ using System; using Microsoft.Xna.Framework; using Wobble.Bindables; +using Wobble.Graphics.Animations; using Wobble.Graphics.Sprites; namespace Wobble.Graphics.UI @@ -94,7 +95,8 @@ private void InitializeSprites(Vector2 size, Color inactiveColor, Color activeCo public override void Update(GameTime gameTime) { var dt = gameTime.ElapsedGameTime.TotalMilliseconds; - ActiveBar.Width = MathHelper.Lerp((float)(Width * (Percentage / 100f)), ActiveBar.Width, (float)Math.Min(dt / 30, 1)); + var targetWidth = (float) (Width * (Percentage / 100f)); + ActiveBar.Width = AnimationMath.Damp(ActiveBar.Width, targetWidth, dt, 30); base.Update(gameTime); } @@ -110,4 +112,4 @@ public override void Destroy() base.Destroy(); } } -} \ No newline at end of file +}