Skip to content

Commit 6b08e85

Browse files
committed
feat(gameplay): add section performance error map
1 parent 41622c9 commit 6b08e85

8 files changed

Lines changed: 393 additions & 0 deletions

File tree

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ without creating an unreviewed second source of truth.
4646
- [Chart timeline](development/chart-timeline.md)
4747
- [Practice Lab](development/practice-lab.md)
4848
- [Chart Creator foundation](development/chart-creator.md)
49+
- [Performance error map](development/performance-error-map.md)
4950
- [Demo-song vertical slice](development/demo-song-vertical-slice.md)
5051
- [Keyboard hit matching](development/keyboard-hit-matching.md)
5152
- [Pad visuals](development/pad-visuals.md)
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Performance error map
2+
3+
The results screen groups real `HitResult` values by four-bar Practice Lab
4+
section and drum piece. It highlights where the player struggled, rather than
5+
showing only a whole-song average.
6+
7+
## Analysis
8+
9+
`PracticeErrorMapAnalyzer` lives in Core and consumes the same resolved notes
10+
used by scoring. Each section/piece cell records Perfect, Good, Early, Late and
11+
Miss counts. Its displayed accuracy uses the existing gameplay weights:
12+
13+
- Perfect: 100%;
14+
- Good: 75%;
15+
- Early or Late: 50%;
16+
- Miss: 0%.
17+
18+
The map is deterministic and its section boundaries are start-inclusive and
19+
end-exclusive. Results outside the declared song sections are ignored rather
20+
than assigned to a misleading bucket.
21+
22+
## Targeted practice
23+
24+
The results screen lists the four weakest cells and exposes **Train weakest
25+
area**. That action selects the corresponding Practice Lab range, filters the
26+
existing matcher and seeks the existing DSP/audio transport with the normal
27+
two-beat preparation window. It does not build a second chart or matcher.
28+
29+
Error-map state is per attempt and in-memory. Long-term history is deliberately
30+
left to a later schema change in the existing progress persistence service.
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
using System;
2+
using System.Collections.Generic;
3+
4+
namespace HitTheKit.Core
5+
{
6+
public sealed class PracticeSectionDefinition
7+
{
8+
public PracticeSectionDefinition(int index, string label, double startSeconds, double endSeconds)
9+
{
10+
if (index < 0) throw new ArgumentOutOfRangeException(nameof(index));
11+
if (string.IsNullOrWhiteSpace(label)) throw new ArgumentException("Section label is required.", nameof(label));
12+
if (!IsFinite(startSeconds) || startSeconds < 0) throw new ArgumentOutOfRangeException(nameof(startSeconds));
13+
if (!IsFinite(endSeconds) || endSeconds <= startSeconds) throw new ArgumentOutOfRangeException(nameof(endSeconds));
14+
Index = index;
15+
Label = label;
16+
StartSeconds = startSeconds;
17+
EndSeconds = endSeconds;
18+
}
19+
20+
public int Index { get; }
21+
public string Label { get; }
22+
public double StartSeconds { get; }
23+
public double EndSeconds { get; }
24+
25+
public bool Contains(double noteTimeSeconds) =>
26+
noteTimeSeconds >= StartSeconds && noteTimeSeconds < EndSeconds;
27+
28+
private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value);
29+
}
30+
31+
public sealed class PracticeErrorCell
32+
{
33+
internal PracticeErrorCell(
34+
PracticeSectionDefinition section,
35+
DrumPad pad,
36+
int perfect,
37+
int good,
38+
int early,
39+
int late,
40+
int miss)
41+
{
42+
Section = section;
43+
Pad = pad;
44+
Perfect = perfect;
45+
Good = good;
46+
Early = early;
47+
Late = late;
48+
Miss = miss;
49+
}
50+
51+
public PracticeSectionDefinition Section { get; }
52+
public DrumPad Pad { get; }
53+
public int Perfect { get; }
54+
public int Good { get; }
55+
public int Early { get; }
56+
public int Late { get; }
57+
public int Miss { get; }
58+
public int Resolved => Perfect + Good + Early + Late + Miss;
59+
public double Accuracy => Resolved == 0
60+
? 0
61+
: (Perfect + Good * 0.75 + (Early + Late) * 0.5) * 100.0 / Resolved;
62+
}
63+
64+
public sealed class PracticeErrorMapAnalyzer
65+
{
66+
private readonly IReadOnlyList<PracticeSectionDefinition> sections;
67+
private readonly Dictionary<CellKey, MutableCell> cells = new Dictionary<CellKey, MutableCell>();
68+
69+
public PracticeErrorMapAnalyzer(IReadOnlyList<PracticeSectionDefinition> sections)
70+
{
71+
if (sections == null) throw new ArgumentNullException(nameof(sections));
72+
if (sections.Count == 0) throw new ArgumentException("At least one practice section is required.", nameof(sections));
73+
var copy = new PracticeSectionDefinition[sections.Count];
74+
for (int index = 0; index < sections.Count; index++)
75+
{
76+
PracticeSectionDefinition section = sections[index]
77+
?? throw new ArgumentException("Practice sections cannot contain null entries.", nameof(sections));
78+
if (section.Index != index)
79+
throw new ArgumentException("Practice section indices must be contiguous and ordered.", nameof(sections));
80+
if (index > 0 && Math.Abs(copy[index - 1].EndSeconds - section.StartSeconds) > 0.000001)
81+
throw new ArgumentException("Practice sections must be contiguous.", nameof(sections));
82+
copy[index] = section;
83+
}
84+
this.sections = Array.AsReadOnly(copy);
85+
}
86+
87+
public IReadOnlyList<PracticeSectionDefinition> Sections => sections;
88+
89+
public void Record(HitResult result)
90+
{
91+
if (result == null) throw new ArgumentNullException(nameof(result));
92+
PracticeSectionDefinition? section = FindSection(result.Note.TimeSeconds);
93+
if (section == null) return;
94+
var key = new CellKey(section.Index, result.Note.Pad);
95+
if (!cells.TryGetValue(key, out MutableCell cell))
96+
{
97+
cell = new MutableCell();
98+
cells.Add(key, cell);
99+
}
100+
switch (result.Grade)
101+
{
102+
case HitGrade.Perfect: cell.Perfect++; break;
103+
case HitGrade.Good: cell.Good++; break;
104+
case HitGrade.Early: cell.Early++; break;
105+
case HitGrade.Late: cell.Late++; break;
106+
case HitGrade.Miss: cell.Miss++; break;
107+
default: throw new ArgumentOutOfRangeException();
108+
}
109+
}
110+
111+
public IReadOnlyList<PracticeErrorCell> Snapshot()
112+
{
113+
var result = new List<PracticeErrorCell>(cells.Count);
114+
foreach (KeyValuePair<CellKey, MutableCell> pair in cells)
115+
{
116+
MutableCell value = pair.Value;
117+
result.Add(new PracticeErrorCell(
118+
sections[pair.Key.SectionIndex],
119+
pair.Key.Pad,
120+
value.Perfect,
121+
value.Good,
122+
value.Early,
123+
value.Late,
124+
value.Miss));
125+
}
126+
result.Sort(CompareCells);
127+
return result.AsReadOnly();
128+
}
129+
130+
public PracticeErrorCell? Weakest()
131+
{
132+
IReadOnlyList<PracticeErrorCell> snapshot = Snapshot();
133+
if (snapshot.Count == 0) return null;
134+
PracticeErrorCell weakest = snapshot[0];
135+
for (int index = 1; index < snapshot.Count; index++)
136+
{
137+
PracticeErrorCell candidate = snapshot[index];
138+
if (candidate.Accuracy < weakest.Accuracy ||
139+
(Math.Abs(candidate.Accuracy - weakest.Accuracy) < 0.0001 && candidate.Resolved > weakest.Resolved))
140+
weakest = candidate;
141+
}
142+
return weakest;
143+
}
144+
145+
public void Reset() => cells.Clear();
146+
147+
private PracticeSectionDefinition? FindSection(double noteTimeSeconds)
148+
{
149+
for (int index = 0; index < sections.Count; index++)
150+
if (sections[index].Contains(noteTimeSeconds)) return sections[index];
151+
return null;
152+
}
153+
154+
private static int CompareCells(PracticeErrorCell left, PracticeErrorCell right)
155+
{
156+
int bySection = left.Section.Index.CompareTo(right.Section.Index);
157+
return bySection != 0 ? bySection : left.Pad.CompareTo(right.Pad);
158+
}
159+
160+
private readonly struct CellKey : IEquatable<CellKey>
161+
{
162+
public CellKey(int sectionIndex, DrumPad pad)
163+
{
164+
SectionIndex = sectionIndex;
165+
Pad = pad;
166+
}
167+
168+
public int SectionIndex { get; }
169+
public DrumPad Pad { get; }
170+
public bool Equals(CellKey other) => SectionIndex == other.SectionIndex && Pad == other.Pad;
171+
public override bool Equals(object obj) => obj is CellKey other && Equals(other);
172+
public override int GetHashCode() => (SectionIndex * 397) ^ (int)Pad;
173+
}
174+
175+
private sealed class MutableCell
176+
{
177+
public int Perfect;
178+
public int Good;
179+
public int Early;
180+
public int Late;
181+
public int Miss;
182+
}
183+
}
184+
}

src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayHighwayController.cs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ public sealed class GameplayHighwayController : MonoBehaviour
6767
private Button resultRestartButton;
6868
private Button resultMenuButton;
6969
private Button resultApplyCalibrationButton;
70+
private Button resultPracticeWeakestButton;
7071
private Button practicePreviousSectionButton;
7172
private Button practiceNextSectionButton;
7273
private Button practiceLoopSectionButton;
@@ -84,6 +85,7 @@ public sealed class GameplayHighwayController : MonoBehaviour
8485
private Label resultBreakdownLabel;
8586
private Label resultPracticeLabel;
8687
private Label resultCalibrationLabel;
88+
private Label resultErrorMapLabel;
8789
private Label practiceSectionLabel;
8890
private Label practiceStatusLabel;
8991
private VisualElement resultPerformancePanel;
@@ -121,6 +123,8 @@ public sealed class GameplayHighwayController : MonoBehaviour
121123
private readonly TimingCalibrationAdvisor keyboardCalibration = new TimingCalibrationAdvisor();
122124
private readonly TimingCalibrationAdvisor midiCalibration = new TimingCalibrationAdvisor();
123125
private readonly PracticePerformanceAnalyzer performanceAnalyzer = new PracticePerformanceAnalyzer();
126+
private PracticeErrorMapAnalyzer errorMapAnalyzer;
127+
private PracticeErrorCell weakestPracticeError;
124128
private DrumInputSource lastCalibrationSource = DrumInputSource.Keyboard;
125129
private DrumPad? latestPulsePad;
126130
private bool invalidConfigurationLogged;
@@ -164,6 +168,7 @@ public sealed class GameplayHighwayController : MonoBehaviour
164168
public string LastChartExportPath { get; private set; }
165169
public string LastChartPackagePath { get; private set; }
166170
public int EditableChartNoteCount => chartDraftEditor?.Notes.Count ?? 0;
171+
public PracticeErrorCell WeakestPracticeError => weakestPracticeError;
167172

168173
private void Awake()
169174
{
@@ -307,6 +312,7 @@ private void BindView()
307312
resultRestartButton = root.Q<Button>("result-restart-button");
308313
resultMenuButton = root.Q<Button>("result-menu-button");
309314
resultApplyCalibrationButton = root.Q<Button>("result-apply-calibration");
315+
resultPracticeWeakestButton = root.Q<Button>("result-practice-weakest");
310316
practicePreviousSectionButton = root.Q<Button>("practice-previous-section");
311317
practiceNextSectionButton = root.Q<Button>("practice-next-section");
312318
practiceLoopSectionButton = root.Q<Button>("practice-loop-section");
@@ -324,6 +330,7 @@ private void BindView()
324330
resultBreakdownLabel = root.Q<Label>("result-breakdown");
325331
resultPracticeLabel = root.Q<Label>("result-practice");
326332
resultCalibrationLabel = root.Q<Label>("result-calibration");
333+
resultErrorMapLabel = root.Q<Label>("result-error-map");
327334
practiceSectionLabel = root.Q<Label>("practice-section-label");
328335
practiceStatusLabel = root.Q<Label>("practice-status");
329336
resultPerformancePanel = root.Q<VisualElement>("result-performance-panel");
@@ -523,6 +530,7 @@ private void HandleHitResolved(HitResult result)
523530
{
524531
if (result == null) return;
525532
performanceAnalyzer.Record(result.Note.Pad, result.Grade);
533+
errorMapAnalyzer?.Record(result);
526534
scoreTracker.Apply(result);
527535
if (result.Grade == HitGrade.Miss)
528536
{
@@ -608,6 +616,8 @@ public void RestartRun()
608616
keyboardCalibration.Reset();
609617
midiCalibration.Reset();
610618
performanceAnalyzer.Reset();
619+
errorMapAnalyzer?.Reset();
620+
weakestPracticeError = null;
611621
if (metronomeSource != null) metronomeSource.Stop();
612622
metronomeScheduled = false;
613623
pulseDeadlines.Clear();
@@ -686,6 +696,17 @@ private void InitializePracticeLab()
686696
{
687697
GameplaySessionDefinition session = CurrentSession;
688698
practiceSections = GameplayPracticeSections.Create(session.Bars, session.BeatsPerBar, session.Bpm);
699+
var analysisSections = new PracticeSectionDefinition[practiceSections.Count];
700+
for (int index = 0; index < practiceSections.Count; index++)
701+
{
702+
GameplayPracticeRange section = practiceSections[index];
703+
analysisSections[index] = new PracticeSectionDefinition(
704+
index,
705+
section.Label,
706+
section.StartSeconds,
707+
section.EndSeconds);
708+
}
709+
errorMapAnalyzer = new PracticeErrorMapAnalyzer(analysisSections);
689710
selectedPracticeSectionIndex = 0;
690711
RefreshPracticeStatus();
691712
}
@@ -709,6 +730,8 @@ private void RestartPracticePass()
709730
keyboardCalibration.Reset();
710731
midiCalibration.Reset();
711732
performanceAnalyzer.Reset();
733+
errorMapAnalyzer?.Reset();
734+
weakestPracticeError = null;
712735
pulseDeadlines.Clear();
713736
latestPulsePad = null;
714737
matching.RestartSession(range.StartSeconds, range.EndSeconds);
@@ -842,6 +865,7 @@ private void ShowResults(HitMatchingSnapshot matchingSnapshot)
842865
$"PERFECT {matchingSnapshot.PerfectCount} GOOD {matchingSnapshot.GoodCount} " +
843866
$"EARLY/LATE {matchingSnapshot.EarlyCount + matchingSnapshot.LateCount} MISS {matchingSnapshot.MissCount}";
844867
RenderPracticeRecommendation();
868+
RenderErrorMap();
845869
RenderCalibrationRecommendation();
846870
SetDisplayed(chartCreatorResults, false);
847871
}
@@ -1066,6 +1090,7 @@ private void BindRunControls()
10661090
if (resultRestartButton != null) resultRestartButton.clicked += RestartRun;
10671091
if (resultMenuButton != null) resultMenuButton.clicked += ReturnToMainMenu;
10681092
if (resultApplyCalibrationButton != null) resultApplyCalibrationButton.clicked += ApplyCalibrationRecommendation;
1093+
if (resultPracticeWeakestButton != null) resultPracticeWeakestButton.clicked += PracticeWeakestArea;
10691094
if (practicePreviousSectionButton != null) practicePreviousSectionButton.clicked += SelectPreviousPracticeSection;
10701095
if (practiceNextSectionButton != null) practiceNextSectionButton.clicked += SelectNextPracticeSection;
10711096
if (practiceLoopSectionButton != null) practiceLoopSectionButton.clicked += LoopSelectedPracticeSection;
@@ -1093,6 +1118,7 @@ private void UnbindRunControls()
10931118
if (resultRestartButton != null) resultRestartButton.clicked -= RestartRun;
10941119
if (resultMenuButton != null) resultMenuButton.clicked -= ReturnToMainMenu;
10951120
if (resultApplyCalibrationButton != null) resultApplyCalibrationButton.clicked -= ApplyCalibrationRecommendation;
1121+
if (resultPracticeWeakestButton != null) resultPracticeWeakestButton.clicked -= PracticeWeakestArea;
10961122
if (practicePreviousSectionButton != null) practicePreviousSectionButton.clicked -= SelectPreviousPracticeSection;
10971123
if (practiceNextSectionButton != null) practiceNextSectionButton.clicked -= SelectNextPracticeSection;
10981124
if (practiceLoopSectionButton != null) practiceLoopSectionButton.clicked -= LoopSelectedPracticeSection;
@@ -1448,6 +1474,45 @@ private void RenderPracticeRecommendation()
14481474
$"FOCUS PROSSIMO: {PadLabel(weakest.Pad)} · {weakest.Accuracy:0.0}% · {tendency}";
14491475
}
14501476

1477+
private void RenderErrorMap()
1478+
{
1479+
if (resultErrorMapLabel == null || resultPracticeWeakestButton == null) return;
1480+
weakestPracticeError = errorMapAnalyzer?.Weakest();
1481+
if (weakestPracticeError == null)
1482+
{
1483+
resultErrorMapLabel.text = "NESSUN RISULTATO DISPONIBILE";
1484+
resultPracticeWeakestButton.SetEnabled(false);
1485+
return;
1486+
}
1487+
1488+
var cells = new List<PracticeErrorCell>(errorMapAnalyzer.Snapshot());
1489+
cells.Sort((left, right) =>
1490+
{
1491+
int byAccuracy = left.Accuracy.CompareTo(right.Accuracy);
1492+
if (byAccuracy != 0) return byAccuracy;
1493+
int bySection = left.Section.Index.CompareTo(right.Section.Index);
1494+
return bySection != 0 ? bySection : left.Pad.CompareTo(right.Pad);
1495+
});
1496+
int count = Math.Min(4, cells.Count);
1497+
var entries = new string[count];
1498+
for (int index = 0; index < count; index++)
1499+
{
1500+
PracticeErrorCell cell = cells[index];
1501+
entries[index] = $"{cell.Section.Label}: {PadLabel(cell.Pad)} {cell.Accuracy:0}%";
1502+
}
1503+
resultErrorMapLabel.text = string.Join(" · ", entries);
1504+
resultPracticeWeakestButton.SetEnabled(true);
1505+
resultPracticeWeakestButton.text =
1506+
$"ALLENA {weakestPracticeError.Section.Label} · {PadLabel(weakestPracticeError.Pad)}";
1507+
}
1508+
1509+
public void PracticeWeakestArea()
1510+
{
1511+
if (weakestPracticeError == null || weakestPracticeError.Section.Index >= practiceSections.Count) return;
1512+
selectedPracticeSectionIndex = weakestPracticeError.Section.Index;
1513+
LoopSelectedPracticeSection();
1514+
}
1515+
14511516
private static string PadLabel(DrumPad pad)
14521517
{
14531518
switch (pad)

src/HitTheKit.Unity/Assets/HitTheKit/Tests/PlayMode/GameplayHighwayPlayModeTests.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ public IEnumerator Gameplay_scene_uses_the_session_theme_and_does_not_expose_an_
5757
Assert.That(document.rootVisualElement.Q<VisualElement>("gameplay-kit-surface"), Is.Not.Null);
5858
Assert.That(document.rootVisualElement.Q<Label>("kit-guidance-label"), Is.Not.Null);
5959
Assert.That(document.rootVisualElement.Q<VisualElement>("results-overlay"), Is.Not.Null);
60+
Assert.That(document.rootVisualElement.Q<Label>("result-error-map"), Is.Not.Null);
61+
Assert.That(document.rootVisualElement.Q<Button>("result-practice-weakest"), Is.Not.Null);
6062
Assert.That(document.rootVisualElement.Q<Button>("pause-button"), Is.Not.Null);
6163
Assert.That(document.rootVisualElement.Q<VisualElement>("countdown-overlay"), Is.Not.Null);
6264
Assert.That(document.rootVisualElement.Q<ChartWaveformView>("chart-waveform"), Is.Not.Null);

0 commit comments

Comments
 (0)