Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
30 changes: 30 additions & 0 deletions docs/development/performance-error-map.md
Original file line number Diff line number Diff line change
@@ -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.
184 changes: 184 additions & 0 deletions src/HitTheKit.Core/PracticeErrorMapAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -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<PracticeSectionDefinition> sections;
private readonly Dictionary<CellKey, MutableCell> cells = new Dictionary<CellKey, MutableCell>();

public PracticeErrorMapAnalyzer(IReadOnlyList<PracticeSectionDefinition> 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<PracticeSectionDefinition> 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<PracticeErrorCell> Snapshot()
{
var result = new List<PracticeErrorCell>(cells.Count);
foreach (KeyValuePair<CellKey, MutableCell> 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<PracticeErrorCell> 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<CellKey>
{
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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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()
{
Expand Down Expand Up @@ -307,6 +312,7 @@ private void BindView()
resultRestartButton = root.Q<Button>("result-restart-button");
resultMenuButton = root.Q<Button>("result-menu-button");
resultApplyCalibrationButton = root.Q<Button>("result-apply-calibration");
resultPracticeWeakestButton = root.Q<Button>("result-practice-weakest");
practicePreviousSectionButton = root.Q<Button>("practice-previous-section");
practiceNextSectionButton = root.Q<Button>("practice-next-section");
practiceLoopSectionButton = root.Q<Button>("practice-loop-section");
Expand All @@ -324,6 +330,7 @@ private void BindView()
resultBreakdownLabel = root.Q<Label>("result-breakdown");
resultPracticeLabel = root.Q<Label>("result-practice");
resultCalibrationLabel = root.Q<Label>("result-calibration");
resultErrorMapLabel = root.Q<Label>("result-error-map");
practiceSectionLabel = root.Q<Label>("practice-section-label");
practiceStatusLabel = root.Q<Label>("practice-status");
resultPerformancePanel = root.Q<VisualElement>("result-performance-panel");
Expand Down Expand Up @@ -523,6 +530,7 @@ private void HandleHitResolved(HitResult result)
{
if (result == null) return;
performanceAnalyzer.Record(result.Note.Pad, result.Grade);
errorMapAnalyzer?.Record(result);
scoreTracker.Apply(result);
if (result.Grade == HitGrade.Miss)
{
Expand Down Expand Up @@ -608,6 +616,8 @@ public void RestartRun()
keyboardCalibration.Reset();
midiCalibration.Reset();
performanceAnalyzer.Reset();
errorMapAnalyzer?.Reset();
weakestPracticeError = null;
if (metronomeSource != null) metronomeSource.Stop();
metronomeScheduled = false;
pulseDeadlines.Clear();
Expand Down Expand Up @@ -686,6 +696,17 @@ private void InitializePracticeLab()
{
GameplaySessionDefinition session = CurrentSession;
practiceSections = GameplayPracticeSections.Create(session.Bars, session.BeatsPerBar, session.Bpm);
var analysisSections = new PracticeSectionDefinition[practiceSections.Count];
for (int index = 0; index < practiceSections.Count; index++)
{
GameplayPracticeRange section = practiceSections[index];
analysisSections[index] = new PracticeSectionDefinition(
index,
section.Label,
section.StartSeconds,
section.EndSeconds);
}
errorMapAnalyzer = new PracticeErrorMapAnalyzer(analysisSections);
selectedPracticeSectionIndex = 0;
RefreshPracticeStatus();
}
Expand All @@ -709,6 +730,8 @@ private void RestartPracticePass()
keyboardCalibration.Reset();
midiCalibration.Reset();
performanceAnalyzer.Reset();
errorMapAnalyzer?.Reset();
weakestPracticeError = null;
pulseDeadlines.Clear();
latestPulsePad = null;
matching.RestartSession(range.StartSeconds, range.EndSeconds);
Expand Down Expand Up @@ -842,6 +865,7 @@ private void ShowResults(HitMatchingSnapshot matchingSnapshot)
$"PERFECT {matchingSnapshot.PerfectCount} GOOD {matchingSnapshot.GoodCount} " +
$"EARLY/LATE {matchingSnapshot.EarlyCount + matchingSnapshot.LateCount} MISS {matchingSnapshot.MissCount}";
RenderPracticeRecommendation();
RenderErrorMap();
RenderCalibrationRecommendation();
SetDisplayed(chartCreatorResults, false);
}
Expand Down Expand Up @@ -1066,6 +1090,7 @@ private void BindRunControls()
if (resultRestartButton != null) resultRestartButton.clicked += RestartRun;
if (resultMenuButton != null) resultMenuButton.clicked += ReturnToMainMenu;
if (resultApplyCalibrationButton != null) resultApplyCalibrationButton.clicked += ApplyCalibrationRecommendation;
if (resultPracticeWeakestButton != null) resultPracticeWeakestButton.clicked += PracticeWeakestArea;
if (practicePreviousSectionButton != null) practicePreviousSectionButton.clicked += SelectPreviousPracticeSection;
if (practiceNextSectionButton != null) practiceNextSectionButton.clicked += SelectNextPracticeSection;
if (practiceLoopSectionButton != null) practiceLoopSectionButton.clicked += LoopSelectedPracticeSection;
Expand Down Expand Up @@ -1093,6 +1118,7 @@ private void UnbindRunControls()
if (resultRestartButton != null) resultRestartButton.clicked -= RestartRun;
if (resultMenuButton != null) resultMenuButton.clicked -= ReturnToMainMenu;
if (resultApplyCalibrationButton != null) resultApplyCalibrationButton.clicked -= ApplyCalibrationRecommendation;
if (resultPracticeWeakestButton != null) resultPracticeWeakestButton.clicked -= PracticeWeakestArea;
if (practicePreviousSectionButton != null) practicePreviousSectionButton.clicked -= SelectPreviousPracticeSection;
if (practiceNextSectionButton != null) practiceNextSectionButton.clicked -= SelectNextPracticeSection;
if (practiceLoopSectionButton != null) practiceLoopSectionButton.clicked -= LoopSelectedPracticeSection;
Expand Down Expand Up @@ -1448,6 +1474,45 @@ private void RenderPracticeRecommendation()
$"FOCUS PROSSIMO: {PadLabel(weakest.Pad)} · {weakest.Accuracy:0.0}% · {tendency}";
}

private void RenderErrorMap()
{
if (resultErrorMapLabel == null || resultPracticeWeakestButton == null) return;
weakestPracticeError = errorMapAnalyzer?.Weakest();
if (weakestPracticeError == null)
{
resultErrorMapLabel.text = "NESSUN RISULTATO DISPONIBILE";
resultPracticeWeakestButton.SetEnabled(false);
return;
}

var cells = new List<PracticeErrorCell>(errorMapAnalyzer.Snapshot());
cells.Sort((left, right) =>
{
int byAccuracy = left.Accuracy.CompareTo(right.Accuracy);
if (byAccuracy != 0) return byAccuracy;
int bySection = left.Section.Index.CompareTo(right.Section.Index);
return bySection != 0 ? bySection : left.Pad.CompareTo(right.Pad);
});
int count = Math.Min(4, cells.Count);
var entries = new string[count];
for (int index = 0; index < count; index++)
{
PracticeErrorCell cell = cells[index];
entries[index] = $"{cell.Section.Label}: {PadLabel(cell.Pad)} {cell.Accuracy:0}%";
}
resultErrorMapLabel.text = string.Join(" · ", entries);
resultPracticeWeakestButton.SetEnabled(true);
resultPracticeWeakestButton.text =
$"ALLENA {weakestPracticeError.Section.Label} · {PadLabel(weakestPracticeError.Pad)}";
}

public void PracticeWeakestArea()
{
if (weakestPracticeError == null || weakestPracticeError.Section.Index >= practiceSections.Count) return;
selectedPracticeSectionIndex = weakestPracticeError.Section.Index;
LoopSelectedPracticeSection();
}

private static string PadLabel(DrumPad pad)
{
switch (pad)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ public IEnumerator Gameplay_scene_uses_the_session_theme_and_does_not_expose_an_
Assert.That(document.rootVisualElement.Q<VisualElement>("gameplay-kit-surface"), Is.Not.Null);
Assert.That(document.rootVisualElement.Q<Label>("kit-guidance-label"), Is.Not.Null);
Assert.That(document.rootVisualElement.Q<VisualElement>("results-overlay"), Is.Not.Null);
Assert.That(document.rootVisualElement.Q<Label>("result-error-map"), Is.Not.Null);
Assert.That(document.rootVisualElement.Q<Button>("result-practice-weakest"), Is.Not.Null);
Assert.That(document.rootVisualElement.Q<Button>("pause-button"), Is.Not.Null);
Assert.That(document.rootVisualElement.Q<VisualElement>("countdown-overlay"), Is.Not.Null);
Assert.That(document.rootVisualElement.Q<ChartWaveformView>("chart-waveform"), Is.Not.Null);
Expand Down
Loading
Loading