diff --git a/docs/README.md b/docs/README.md index e6bab7b..2d41d62 100644 --- a/docs/README.md +++ b/docs/README.md @@ -47,6 +47,7 @@ without creating an unreviewed second source of truth. - [Practice Lab](development/practice-lab.md) - [Chart Creator foundation](development/chart-creator.md) - [Performance error map](development/performance-error-map.md) +- [Auto Tempo Coach](development/auto-tempo-coach.md) - [Demo-song vertical slice](development/demo-song-vertical-slice.md) - [Keyboard hit matching](development/keyboard-hit-matching.md) - [Pad visuals](development/pad-visuals.md) diff --git a/docs/development/auto-tempo-coach.md b/docs/development/auto-tempo-coach.md new file mode 100644 index 0000000..2114204 --- /dev/null +++ b/docs/development/auto-tempo-coach.md @@ -0,0 +1,28 @@ +# Auto Tempo Coach + +Auto Tempo Coach turns a completed lesson or Song Library attempt into a safe +next-speed recommendation. It uses the existing score and hit-matching results; +it does not introduce a second scoring model. + +## Progression + +Song Library sessions follow the already supported song speeds. Lessons follow +the existing study progression (`0.5x`, `0.75x`, `1.0x`). A step unlocks when: + +- accuracy is at least 85%; +- misses are no more than 10% of chart notes; +- unmatched hits are no more than 10% of chart notes. + +Below those guardrails the coach asks the player to repeat the current speed. +At the final supported speed it reports the target as mastered. + +## Applying a recommendation + +The results screen displays the recommendation. The player explicitly confirms +the next speed; the gameplay scene then reloads the same lesson or song with a +new immutable `GameplaySessionDefinition`. Original BPM is recovered from the +current effective BPM and multiplier, then chart timing, audio pitch, count-in +and display metadata are rebuilt together. + +This avoids changing playback speed halfway through a scored attempt and keeps +the DSP clock, chart timeline and external audio on one timing contract. diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayAutoTempoCoach.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayAutoTempoCoach.cs new file mode 100644 index 0000000..410aad7 --- /dev/null +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayAutoTempoCoach.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using HitTheKit.Unity.Matching; + +namespace HitTheKit.Unity.Gameplay +{ + public enum GameplayAutoTempoStatus + { + Unavailable, + Repeat, + Advance, + Mastered + } + + public sealed class GameplayAutoTempoRecommendation + { + internal GameplayAutoTempoRecommendation( + GameplayAutoTempoStatus status, + double currentSpeed, + double nextSpeed, + string message) + { + Status = status; + CurrentSpeed = currentSpeed; + NextSpeed = nextSpeed; + Message = message ?? throw new ArgumentNullException(nameof(message)); + } + + public GameplayAutoTempoStatus Status { get; } + public double CurrentSpeed { get; } + public double NextSpeed { get; } + public string Message { get; } + public bool CanAdvance => Status == GameplayAutoTempoStatus.Advance; + } + + public static class GameplayAutoTempoCoach + { + public const double MinimumAccuracy = 85.0; + public const double MaximumMissRatio = 0.10; + public const double MaximumNoMatchRatio = 0.10; + + public static GameplayAutoTempoRecommendation Evaluate( + GameplaySessionDefinition session, + GameplayScoreSnapshot score, + HitMatchingSnapshot matching) + { + if (score == null) throw new ArgumentNullException(nameof(score)); + if (matching == null) throw new ArgumentNullException(nameof(matching)); + return Evaluate( + session, + score.Accuracy, + matching.MissCount, + matching.NoMatchCount, + matching.TotalNoteCount); + } + + public static GameplayAutoTempoRecommendation Evaluate( + GameplaySessionDefinition session, + double accuracy, + int misses, + int noMatches, + int totalNotes) + { + if (session == null) throw new ArgumentNullException(nameof(session)); + if (double.IsNaN(accuracy) || double.IsInfinity(accuracy) || accuracy < 0 || accuracy > 100) + throw new ArgumentOutOfRangeException(nameof(accuracy)); + if (misses < 0) throw new ArgumentOutOfRangeException(nameof(misses)); + if (noMatches < 0) throw new ArgumentOutOfRangeException(nameof(noMatches)); + if (totalNotes <= 0) throw new ArgumentOutOfRangeException(nameof(totalNotes)); + + IReadOnlyList speeds = SpeedsFor(session); + if (speeds == null) + { + return new GameplayAutoTempoRecommendation( + GameplayAutoTempoStatus.Unavailable, + session.SpeedMultiplier, + session.SpeedMultiplier, + "AUTO TEMPO DISPONIBILE PER LEZIONI E BRANI DELLA LIBRERIA"); + } + + int currentIndex = FindSpeed(speeds, session.SpeedMultiplier); + if (currentIndex < 0) + throw new InvalidOperationException("The current session speed is outside the Auto Tempo progression."); + + double missRatio = misses / (double)totalNotes; + double noMatchRatio = noMatches / (double)totalNotes; + bool passed = accuracy >= MinimumAccuracy && + missRatio <= MaximumMissRatio && + noMatchRatio <= MaximumNoMatchRatio; + if (!passed) + { + return new GameplayAutoTempoRecommendation( + GameplayAutoTempoStatus.Repeat, + session.SpeedMultiplier, + session.SpeedMultiplier, + $"RIPETI {session.SpeedMultiplier:0.##}× · SERVONO {MinimumAccuracy:0}% E POCHI ERRORI"); + } + + if (currentIndex == speeds.Count - 1) + { + return new GameplayAutoTempoRecommendation( + GameplayAutoTempoStatus.Mastered, + session.SpeedMultiplier, + session.SpeedMultiplier, + "TEMPO OBIETTIVO RAGGIUNTO · 100%"); + } + + double next = speeds[currentIndex + 1]; + return new GameplayAutoTempoRecommendation( + GameplayAutoTempoStatus.Advance, + session.SpeedMultiplier, + next, + $"PRONTO PER {next:0.##}× · PRECISIONE {accuracy:0.0}%"); + } + + private static IReadOnlyList SpeedsFor(GameplaySessionDefinition session) + { + if (session.IsChartCreator) return null; + if (session.Kind == GameplaySessionKind.Lesson) return GameplayStudySpeeds.All; + if (!string.IsNullOrWhiteSpace(session.SongId)) return GameplaySongSpeeds.All; + return null; + } + + private static int FindSpeed(IReadOnlyList speeds, double value) + { + for (int index = 0; index < speeds.Count; index++) + if (Math.Abs(speeds[index] - value) < 0.0001) return index; + return -1; + } + } +} diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayAutoTempoCoach.cs.meta b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayAutoTempoCoach.cs.meta new file mode 100644 index 0000000..5e9a41d --- /dev/null +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayAutoTempoCoach.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b31ad33e30d04494a1ad4c9ff350b94e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayHighwayController.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayHighwayController.cs index aca56d9..2a3da35 100644 --- a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayHighwayController.cs +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayHighwayController.cs @@ -68,6 +68,7 @@ public sealed class GameplayHighwayController : MonoBehaviour private Button resultMenuButton; private Button resultApplyCalibrationButton; private Button resultPracticeWeakestButton; + private Button autoTempoAdvanceButton; private Button practicePreviousSectionButton; private Button practiceNextSectionButton; private Button practiceLoopSectionButton; @@ -86,6 +87,7 @@ public sealed class GameplayHighwayController : MonoBehaviour private Label resultPracticeLabel; private Label resultCalibrationLabel; private Label resultErrorMapLabel; + private Label autoTempoStatusLabel; private Label practiceSectionLabel; private Label practiceStatusLabel; private VisualElement resultPerformancePanel; @@ -135,6 +137,8 @@ public sealed class GameplayHighwayController : MonoBehaviour private bool metronomeScheduled; private bool metronomeSeekedWhilePaused; private bool resultRecorded; + private GameplayAutoTempoRecommendation autoTempoRecommendation; + private bool isChangingTempo; private readonly GameplayPracticeLoop practiceLoop = new GameplayPracticeLoop(); private IReadOnlyList practiceSections = Array.Empty(); private int selectedPracticeSectionIndex; @@ -313,6 +317,7 @@ private void BindView() resultMenuButton = root.Q