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
+}