diff --git a/docs/README.md b/docs/README.md index 017e426..e6bab7b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -46,6 +46,7 @@ without creating an unreviewed second source of truth. - [Chart timeline](development/chart-timeline.md) - [Practice Lab](development/practice-lab.md) - [Chart Creator foundation](development/chart-creator.md) +- [Performance error map](development/performance-error-map.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/performance-error-map.md b/docs/development/performance-error-map.md new file mode 100644 index 0000000..c1f6695 --- /dev/null +++ b/docs/development/performance-error-map.md @@ -0,0 +1,30 @@ +# Performance error map + +The results screen groups real `HitResult` values by four-bar Practice Lab +section and drum piece. It highlights where the player struggled, rather than +showing only a whole-song average. + +## Analysis + +`PracticeErrorMapAnalyzer` lives in Core and consumes the same resolved notes +used by scoring. Each section/piece cell records Perfect, Good, Early, Late and +Miss counts. Its displayed accuracy uses the existing gameplay weights: + +- Perfect: 100%; +- Good: 75%; +- Early or Late: 50%; +- Miss: 0%. + +The map is deterministic and its section boundaries are start-inclusive and +end-exclusive. Results outside the declared song sections are ignored rather +than assigned to a misleading bucket. + +## Targeted practice + +The results screen lists the four weakest cells and exposes **Train weakest +area**. That action selects the corresponding Practice Lab range, filters the +existing matcher and seeks the existing DSP/audio transport with the normal +two-beat preparation window. It does not build a second chart or matcher. + +Error-map state is per attempt and in-memory. Long-term history is deliberately +left to a later schema change in the existing progress persistence service. diff --git a/src/HitTheKit.Core/PracticeErrorMapAnalyzer.cs b/src/HitTheKit.Core/PracticeErrorMapAnalyzer.cs new file mode 100644 index 0000000..473ebb4 --- /dev/null +++ b/src/HitTheKit.Core/PracticeErrorMapAnalyzer.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; + +namespace HitTheKit.Core +{ + public sealed class PracticeSectionDefinition + { + public PracticeSectionDefinition(int index, string label, double startSeconds, double endSeconds) + { + if (index < 0) throw new ArgumentOutOfRangeException(nameof(index)); + if (string.IsNullOrWhiteSpace(label)) throw new ArgumentException("Section label is required.", nameof(label)); + if (!IsFinite(startSeconds) || startSeconds < 0) throw new ArgumentOutOfRangeException(nameof(startSeconds)); + if (!IsFinite(endSeconds) || endSeconds <= startSeconds) throw new ArgumentOutOfRangeException(nameof(endSeconds)); + Index = index; + Label = label; + StartSeconds = startSeconds; + EndSeconds = endSeconds; + } + + public int Index { get; } + public string Label { get; } + public double StartSeconds { get; } + public double EndSeconds { get; } + + public bool Contains(double noteTimeSeconds) => + noteTimeSeconds >= StartSeconds && noteTimeSeconds < EndSeconds; + + private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value); + } + + public sealed class PracticeErrorCell + { + internal PracticeErrorCell( + PracticeSectionDefinition section, + DrumPad pad, + int perfect, + int good, + int early, + int late, + int miss) + { + Section = section; + Pad = pad; + Perfect = perfect; + Good = good; + Early = early; + Late = late; + Miss = miss; + } + + public PracticeSectionDefinition Section { get; } + public DrumPad Pad { get; } + public int Perfect { get; } + public int Good { get; } + public int Early { get; } + public int Late { get; } + public int Miss { get; } + public int Resolved => Perfect + Good + Early + Late + Miss; + public double Accuracy => Resolved == 0 + ? 0 + : (Perfect + Good * 0.75 + (Early + Late) * 0.5) * 100.0 / Resolved; + } + + public sealed class PracticeErrorMapAnalyzer + { + private readonly IReadOnlyList sections; + private readonly Dictionary cells = new Dictionary(); + + public PracticeErrorMapAnalyzer(IReadOnlyList sections) + { + if (sections == null) throw new ArgumentNullException(nameof(sections)); + if (sections.Count == 0) throw new ArgumentException("At least one practice section is required.", nameof(sections)); + var copy = new PracticeSectionDefinition[sections.Count]; + for (int index = 0; index < sections.Count; index++) + { + PracticeSectionDefinition section = sections[index] + ?? throw new ArgumentException("Practice sections cannot contain null entries.", nameof(sections)); + if (section.Index != index) + throw new ArgumentException("Practice section indices must be contiguous and ordered.", nameof(sections)); + if (index > 0 && Math.Abs(copy[index - 1].EndSeconds - section.StartSeconds) > 0.000001) + throw new ArgumentException("Practice sections must be contiguous.", nameof(sections)); + copy[index] = section; + } + this.sections = Array.AsReadOnly(copy); + } + + public IReadOnlyList Sections => sections; + + public void Record(HitResult result) + { + if (result == null) throw new ArgumentNullException(nameof(result)); + PracticeSectionDefinition? section = FindSection(result.Note.TimeSeconds); + if (section == null) return; + var key = new CellKey(section.Index, result.Note.Pad); + if (!cells.TryGetValue(key, out MutableCell cell)) + { + cell = new MutableCell(); + cells.Add(key, cell); + } + switch (result.Grade) + { + case HitGrade.Perfect: cell.Perfect++; break; + case HitGrade.Good: cell.Good++; break; + case HitGrade.Early: cell.Early++; break; + case HitGrade.Late: cell.Late++; break; + case HitGrade.Miss: cell.Miss++; break; + default: throw new ArgumentOutOfRangeException(); + } + } + + public IReadOnlyList Snapshot() + { + var result = new List(cells.Count); + foreach (KeyValuePair pair in cells) + { + MutableCell value = pair.Value; + result.Add(new PracticeErrorCell( + sections[pair.Key.SectionIndex], + pair.Key.Pad, + value.Perfect, + value.Good, + value.Early, + value.Late, + value.Miss)); + } + result.Sort(CompareCells); + return result.AsReadOnly(); + } + + public PracticeErrorCell? Weakest() + { + IReadOnlyList snapshot = Snapshot(); + if (snapshot.Count == 0) return null; + PracticeErrorCell weakest = snapshot[0]; + for (int index = 1; index < snapshot.Count; index++) + { + PracticeErrorCell candidate = snapshot[index]; + if (candidate.Accuracy < weakest.Accuracy || + (Math.Abs(candidate.Accuracy - weakest.Accuracy) < 0.0001 && candidate.Resolved > weakest.Resolved)) + weakest = candidate; + } + return weakest; + } + + public void Reset() => cells.Clear(); + + private PracticeSectionDefinition? FindSection(double noteTimeSeconds) + { + for (int index = 0; index < sections.Count; index++) + if (sections[index].Contains(noteTimeSeconds)) return sections[index]; + return null; + } + + private static int CompareCells(PracticeErrorCell left, PracticeErrorCell right) + { + int bySection = left.Section.Index.CompareTo(right.Section.Index); + return bySection != 0 ? bySection : left.Pad.CompareTo(right.Pad); + } + + private readonly struct CellKey : IEquatable + { + public CellKey(int sectionIndex, DrumPad pad) + { + SectionIndex = sectionIndex; + Pad = pad; + } + + public int SectionIndex { get; } + public DrumPad Pad { get; } + public bool Equals(CellKey other) => SectionIndex == other.SectionIndex && Pad == other.Pad; + public override bool Equals(object obj) => obj is CellKey other && Equals(other); + public override int GetHashCode() => (SectionIndex * 397) ^ (int)Pad; + } + + private sealed class MutableCell + { + public int Perfect; + public int Good; + public int Early; + public int Late; + public int Miss; + } + } +} diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayHighwayController.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayHighwayController.cs index 4e53baa..aca56d9 100644 --- a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayHighwayController.cs +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayHighwayController.cs @@ -67,6 +67,7 @@ public sealed class GameplayHighwayController : MonoBehaviour private Button resultRestartButton; private Button resultMenuButton; private Button resultApplyCalibrationButton; + private Button resultPracticeWeakestButton; private Button practicePreviousSectionButton; private Button practiceNextSectionButton; private Button practiceLoopSectionButton; @@ -84,6 +85,7 @@ public sealed class GameplayHighwayController : MonoBehaviour private Label resultBreakdownLabel; private Label resultPracticeLabel; private Label resultCalibrationLabel; + private Label resultErrorMapLabel; private Label practiceSectionLabel; private Label practiceStatusLabel; private VisualElement resultPerformancePanel; @@ -121,6 +123,8 @@ public sealed class GameplayHighwayController : MonoBehaviour private readonly TimingCalibrationAdvisor keyboardCalibration = new TimingCalibrationAdvisor(); private readonly TimingCalibrationAdvisor midiCalibration = new TimingCalibrationAdvisor(); private readonly PracticePerformanceAnalyzer performanceAnalyzer = new PracticePerformanceAnalyzer(); + private PracticeErrorMapAnalyzer errorMapAnalyzer; + private PracticeErrorCell weakestPracticeError; private DrumInputSource lastCalibrationSource = DrumInputSource.Keyboard; private DrumPad? latestPulsePad; private bool invalidConfigurationLogged; @@ -164,6 +168,7 @@ public sealed class GameplayHighwayController : MonoBehaviour public string LastChartExportPath { get; private set; } public string LastChartPackagePath { get; private set; } public int EditableChartNoteCount => chartDraftEditor?.Notes.Count ?? 0; + public PracticeErrorCell WeakestPracticeError => weakestPracticeError; private void Awake() { @@ -307,6 +312,7 @@ private void BindView() resultRestartButton = root.Q