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 @@ -49,6 +49,7 @@ without creating an unreviewed second source of truth.
- [Performance error map](development/performance-error-map.md)
- [Auto Tempo Coach](development/auto-tempo-coach.md)
- [Audio and latency sound check](development/audio-latency-sound-check.md)
- [Ghost Replay](development/ghost-replay.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
21 changes: 21 additions & 0 deletions docs/development/ghost-replay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Ghost Replay

Ghost Replay is a local visual comparison against the immediately preceding
take.

After a run, **Riprova con Ghost** freezes the player's evaluated input times
and restarts the same session. The previous hits appear as white outlined
cross-markers on the existing DSP-driven highway. Their position is derived
from absolute song time, exactly like chart notes.

The ghost is deliberately isolated from gameplay rules:

- it never enters `HitMatchingSession`;
- it never changes score, combo, accuracy, misses, or audio;
- count-in hits are ignored;
- a take is bounded to 8,192 hits;
- it is kept in memory only and is discarded with the gameplay scene.

Normal restart discards the unfinished current take while preserving an
already committed ghost. This keeps pause/restart deterministic and makes the
comparison an explicit player choice.
67 changes: 67 additions & 0 deletions src/HitTheKit.Core/PerformanceGhostReplay.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
using System;
using System.Collections.Generic;

namespace HitTheKit.Core
{
public sealed class GhostReplayHit
{
public GhostReplayHit(double timeSeconds, DrumPad pad, int velocity, HitGrade? grade)
{
if (!IsFinite(timeSeconds) || timeSeconds < 0) throw new ArgumentOutOfRangeException(nameof(timeSeconds));
if (!Enum.IsDefined(typeof(DrumPad), pad)) throw new ArgumentOutOfRangeException(nameof(pad));
if (velocity < 0 || velocity > 127) throw new ArgumentOutOfRangeException(nameof(velocity));
if (grade.HasValue && !Enum.IsDefined(typeof(HitGrade), grade.Value))
throw new ArgumentOutOfRangeException(nameof(grade));
TimeSeconds = timeSeconds;
Pad = pad;
Velocity = velocity;
Grade = grade;
}

public double TimeSeconds { get; }
public DrumPad Pad { get; }
public int Velocity { get; }
public HitGrade? Grade { get; }

private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value);
}

public sealed class PerformanceGhostReplay
{
public const int MaximumHitsPerTake = 8192;

private readonly List<GhostReplayHit> current = new List<GhostReplayHit>();
private IReadOnlyList<GhostReplayHit> ghost = Array.Empty<GhostReplayHit>();

public IReadOnlyList<GhostReplayHit> Ghost => ghost;
public int CurrentHitCount => current.Count;
public bool HasGhost => ghost.Count > 0;

public bool Record(double timeSeconds, DrumPad pad, int velocity, HitGrade? grade)
{
if (timeSeconds < 0) return false;
if (current.Count >= MaximumHitsPerTake) return false;
current.Add(new GhostReplayHit(timeSeconds, pad, velocity, grade));
return true;
}

public bool CommitCurrentTake()
{
if (current.Count == 0) return false;
var copy = current.ToArray();
Array.Sort(copy, CompareHits);
ghost = Array.AsReadOnly(copy);
current.Clear();
return true;
}

public void ResetCurrent() => current.Clear();
public void ClearGhost() => ghost = Array.Empty<GhostReplayHit>();

private static int CompareHits(GhostReplayHit left, GhostReplayHit right)
{
int byTime = left.TimeSeconds.CompareTo(right.TimeSeconds);
return byTime != 0 ? byTime : left.Pad.CompareTo(right.Pad);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ public sealed class GameplayHighwayController : MonoBehaviour
private Label environmentTitleLabel;
private Label environmentSubtitleLabel;
private Label currentInputLabel;
private Label ghostStatusLabel;
private Label deviceLabel;
private Label keyGuideCymbalsLabel;
private Label keyGuideTomsLabel;
Expand All @@ -67,6 +68,7 @@ public sealed class GameplayHighwayController : MonoBehaviour
private Button resultRestartButton;
private Button resultMenuButton;
private Button resultApplyCalibrationButton;
private Button resultGhostButton;
private Button resultPracticeWeakestButton;
private Button autoTempoAdvanceButton;
private Button practicePreviousSectionButton;
Expand Down Expand Up @@ -125,6 +127,7 @@ public sealed class GameplayHighwayController : MonoBehaviour
private readonly TimingCalibrationAdvisor keyboardCalibration = new TimingCalibrationAdvisor();
private readonly TimingCalibrationAdvisor midiCalibration = new TimingCalibrationAdvisor();
private readonly PracticePerformanceAnalyzer performanceAnalyzer = new PracticePerformanceAnalyzer();
private readonly PerformanceGhostReplay ghostReplay = new PerformanceGhostReplay();
private PracticeErrorMapAnalyzer errorMapAnalyzer;
private PracticeErrorCell weakestPracticeError;
private DrumInputSource lastCalibrationSource = DrumInputSource.Keyboard;
Expand Down Expand Up @@ -173,6 +176,8 @@ public sealed class GameplayHighwayController : MonoBehaviour
public string LastChartPackagePath { get; private set; }
public int EditableChartNoteCount => chartDraftEditor?.Notes.Count ?? 0;
public PracticeErrorCell WeakestPracticeError => weakestPracticeError;
public IReadOnlyList<GhostReplayHit> GhostHits => ghostReplay.Ghost;
public int CurrentGhostTakeHitCount => ghostReplay.CurrentHitCount;

private void Awake()
{
Expand Down Expand Up @@ -304,6 +309,7 @@ private void BindView()
environmentTitleLabel = root.Q<Label>("environment-title");
environmentSubtitleLabel = root.Q<Label>("environment-subtitle");
currentInputLabel = root.Q<Label>("current-input");
ghostStatusLabel = root.Q<Label>("ghost-status");
deviceLabel = root.Q<Label>("device-status");
keyGuideCymbalsLabel = root.Q<Label>("key-guide-cymbals");
keyGuideTomsLabel = root.Q<Label>("key-guide-toms");
Expand All @@ -316,6 +322,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");
resultGhostButton = root.Q<Button>("result-ghost-restart");
resultPracticeWeakestButton = root.Q<Button>("result-practice-weakest");
autoTempoAdvanceButton = root.Q<Button>("auto-tempo-advance");
practicePreviousSectionButton = root.Q<Button>("practice-previous-section");
Expand Down Expand Up @@ -466,7 +473,11 @@ private void RefreshPresentation()
float pulse = latestPulsePad.HasValue && pulseDeadlines.TryGetValue(latestPulsePad.Value, out float deadline)
? Mathf.Clamp01((deadline - Time.unscaledTime) / PulseDurationSeconds)
: 0;
surface.SetFrame(upcoming, position, HighwayLookAheadSeconds, latestPulsePad, pulse);
surface.SetFrame(upcoming, position, HighwayLookAheadSeconds, latestPulsePad, pulse, ghostReplay.Ghost);
if (ghostStatusLabel != null)
ghostStatusLabel.text = ghostReplay.HasGhost
? $"GHOST ATTIVO · {ghostReplay.Ghost.Count} COLPI"
: "GHOST · COMPLETA UN TENTATIVO";
if (showInstructionalKit)
kitSurface?.SetFrame(upcoming, position, KitPreparationSeconds, latestPulsePad, pulse);
if (showInstructionalKit && kitGuidanceLabel != null && kitSurface != null)
Expand Down Expand Up @@ -494,6 +505,7 @@ private void RefreshPresentation()
private void HandleInputProcessed(DrumInputEvent input, HitResult result)
{
if (CurrentSession.IsChartCreator) chartRecording?.Record(input);
else CaptureGhostHit(input, result);
latestPulsePad = input.Pad;
pulseDeadlines[input.Pad] = Time.unscaledTime + PulseDurationSeconds;
lastCalibrationSource = input.Source == DrumInputSource.Midi
Expand Down Expand Up @@ -532,6 +544,9 @@ private void HandleInputProcessed(DrumInputEvent input, HitResult result)
SetJudgment(judgment, $"judgment--{result.Grade.ToString().ToLowerInvariant()}");
}

public bool CaptureGhostHit(DrumInputEvent input, HitResult result) =>
ghostReplay.Record(input.SongTimeSeconds, input.Pad, input.Velocity, result?.Grade);

private void HandleHitResolved(HitResult result)
{
if (result == null) return;
Expand Down Expand Up @@ -624,6 +639,7 @@ public void RestartRun()
performanceAnalyzer.Reset();
errorMapAnalyzer?.Reset();
weakestPracticeError = null;
ghostReplay.ResetCurrent();
if (metronomeSource != null) metronomeSource.Stop();
metronomeScheduled = false;
pulseDeadlines.Clear();
Expand Down Expand Up @@ -874,6 +890,13 @@ private void ShowResults(HitMatchingSnapshot matchingSnapshot)
RenderErrorMap();
RenderAutoTempoRecommendation(matchingSnapshot, score);
RenderCalibrationRecommendation();
if (resultGhostButton != null)
{
resultGhostButton.SetEnabled(ghostReplay.CurrentHitCount > 0);
resultGhostButton.text = ghostReplay.CurrentHitCount > 0
? $"RIPROVA CON GHOST · {ghostReplay.CurrentHitCount} COLPI"
: "GHOST NON DISPONIBILE";
}
SetDisplayed(chartCreatorResults, false);
}
SetDisplayed(resultsOverlay, true);
Expand Down Expand Up @@ -1097,6 +1120,7 @@ private void BindRunControls()
if (resultRestartButton != null) resultRestartButton.clicked += RestartRun;
if (resultMenuButton != null) resultMenuButton.clicked += ReturnToMainMenu;
if (resultApplyCalibrationButton != null) resultApplyCalibrationButton.clicked += ApplyCalibrationRecommendation;
if (resultGhostButton != null) resultGhostButton.clicked += BeginGhostReplayFromButton;
if (resultPracticeWeakestButton != null) resultPracticeWeakestButton.clicked += PracticeWeakestArea;
if (autoTempoAdvanceButton != null) autoTempoAdvanceButton.clicked += ApplyAutoTempoRecommendation;
if (practicePreviousSectionButton != null) practicePreviousSectionButton.clicked += SelectPreviousPracticeSection;
Expand Down Expand Up @@ -1126,6 +1150,7 @@ private void UnbindRunControls()
if (resultRestartButton != null) resultRestartButton.clicked -= RestartRun;
if (resultMenuButton != null) resultMenuButton.clicked -= ReturnToMainMenu;
if (resultApplyCalibrationButton != null) resultApplyCalibrationButton.clicked -= ApplyCalibrationRecommendation;
if (resultGhostButton != null) resultGhostButton.clicked -= BeginGhostReplayFromButton;
if (resultPracticeWeakestButton != null) resultPracticeWeakestButton.clicked -= PracticeWeakestArea;
if (autoTempoAdvanceButton != null) autoTempoAdvanceButton.clicked -= ApplyAutoTempoRecommendation;
if (practicePreviousSectionButton != null) practicePreviousSectionButton.clicked -= SelectPreviousPracticeSection;
Expand All @@ -1147,6 +1172,15 @@ private void UnbindRunControls()
if (chartWaveformStopButton != null) chartWaveformStopButton.clicked -= StopChartWaveformPreview;
}

public bool BeginGhostReplay()
{
if (CurrentSession.IsChartCreator || !ghostReplay.CommitCurrentTake()) return false;
RestartRun();
return true;
}

private void BeginGhostReplayFromButton() => BeginGhostReplay();

private void ConfigureChartNoteEditorView()
{
if (chartNoteList == null || chartNotePadField == null || chartNoteArticulationField == null) return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public sealed class GameplayHighwaySurface : VisualElement
public const float ImpactZoneHalfHeight = 9f;
public const int NoteGlowPasses = 3;
private IReadOnlyList<TimelineNote> notes = Array.Empty<TimelineNote>();
private IReadOnlyList<GhostReplayHit> ghostHits = Array.Empty<GhostReplayHit>();
private double songPositionSeconds;
private double lookAheadSeconds = 4;
private GameplayPresentationTheme theme;
Expand All @@ -26,6 +27,7 @@ public GameplayHighwaySurface()

public GameplayPresentationTheme Theme => theme;
public IReadOnlyList<TimelineNote> Notes => notes;
public IReadOnlyList<GhostReplayHit> GhostHits => ghostHits;
public double SongPositionSeconds => songPositionSeconds;

public void SetTheme(GameplayPresentationTheme value)
Expand All @@ -39,9 +41,11 @@ public void SetFrame(
double positionSeconds,
double lookAhead,
DrumPad? highlightedPad,
float highlightedIntensity)
float highlightedIntensity,
IReadOnlyList<GhostReplayHit> replayHits = null)
{
notes = upcomingNotes ?? Array.Empty<TimelineNote>();
ghostHits = replayHits ?? Array.Empty<GhostReplayHit>();
songPositionSeconds = IsFinite(positionSeconds) ? positionSeconds : 0;
lookAheadSeconds = IsFinite(lookAhead) && lookAhead > 0 ? lookAhead : 4;
pulsePad = highlightedPad;
Expand Down Expand Up @@ -106,6 +110,16 @@ private void Draw(MeshGenerationContext context)
new Color(palette.Grid.r, palette.Grid.g, palette.Grid.b, palette.Grid.a * 0.34f), 0.8f);
}

for (int index = 0; index < ghostHits.Count; index++)
{
GhostReplayHit hit = ghostHits[index];
if (hit == null) continue;
double delta = hit.TimeSeconds - songPositionSeconds;
if (delta < -0.16 || delta > lookAheadSeconds) continue;
DrawGhost(painter, hit.Pad, delta,
horizonY, strikeY, topLeft, topRight, bottomLeft, bottomRight);
}

for (int index = 0; index < notes.Count; index++)
{
TimelineNote note = notes[index];
Expand All @@ -131,6 +145,46 @@ private void Draw(MeshGenerationContext context)
pulseIntensity > 0.01f ? 5.5f : 3.6f);
}

private void DrawGhost(
Painter2D painter,
DrumPad pad,
double delta,
float horizonY,
float strikeY,
float topLeft,
float topRight,
float bottomLeft,
float bottomRight)
{
float normalized = 1f - Mathf.Clamp01((float)(delta / lookAheadSeconds));
float eased = normalized * normalized;
float y = Mathf.Lerp(horizonY, strikeY, eased);
float x;
if (pad == DrumPad.Kick)
{
x = Mathf.Lerp((topLeft + topRight) * 0.5f, (bottomLeft + bottomRight) * 0.5f, eased);
}
else
{
int laneIndex = GameplayHighwayLanes.HighwayIndex(pad);
if (laneIndex < 0) return;
float ratio = (laneIndex + 0.5f) / GameplayHighwayLanes.HighwayLaneCount;
x = Mathf.Lerp(Mathf.Lerp(topLeft, topRight, ratio), Mathf.Lerp(bottomLeft, bottomRight, ratio), eased);
}

float radiusX = Mathf.Lerp(4f, pad == DrumPad.Kick ? 27f : 18f, eased);
float radiusY = Mathf.Lerp(3f, 11f, eased);
var center = new Vector2(x, y);
Color outline = new Color(1f, 1f, 1f, 0.54f);
StrokeRegularPolygon(painter, center, radiusX, radiusY, 10, outline, 1.8f);
StrokeLine(painter,
new Vector2(center.x - radiusX * 0.45f, center.y - radiusY * 0.45f),
new Vector2(center.x + radiusX * 0.45f, center.y + radiusY * 0.45f), outline, 1.4f);
StrokeLine(painter,
new Vector2(center.x - radiusX * 0.45f, center.y + radiusY * 0.45f),
new Vector2(center.x + radiusX * 0.45f, center.y - radiusY * 0.45f), outline, 1.4f);
}

private void DrawNote(
Painter2D painter,
GameplayNoteShape shape,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,13 @@ public void Surface_accepts_canonical_chart_notes_for_every_lane()
var surface = new GameplayHighwaySurface();

surface.SetTheme(GameplayPresentationTheme.PrecisionGrid);
surface.SetFrame(timeline.Notes, 0, 4, DrumPad.Kick, 1);
var ghost = new[] { new GhostReplayHit(1.05, DrumPad.Snare, 96, HitGrade.Late) };
surface.SetFrame(timeline.Notes, 0, 4, DrumPad.Kick, 1, ghost);

Assert.That(surface.Theme, Is.EqualTo(GameplayPresentationTheme.PrecisionGrid));
Assert.That(surface.Notes.Select(note => note.Note.Pad),
Is.EquivalentTo(Enum.GetValues(typeof(DrumPad)).Cast<DrumPad>()));
Assert.That(surface.GhostHits, Is.EqualTo(ghost));
}

[Test]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ public IEnumerator Gameplay_scene_uses_the_session_theme_and_does_not_expose_an_
Assert.That(document.rootVisualElement.Q<Button>("result-practice-weakest"), Is.Not.Null);
Assert.That(document.rootVisualElement.Q<Label>("auto-tempo-status"), Is.Not.Null);
Assert.That(document.rootVisualElement.Q<Button>("auto-tempo-advance"), Is.Not.Null);
Assert.That(document.rootVisualElement.Q<Label>("ghost-status"), Is.Not.Null);
Assert.That(document.rootVisualElement.Q<Button>("result-ghost-restart"), 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 All @@ -78,6 +80,30 @@ public IEnumerator Gameplay_scene_uses_the_session_theme_and_does_not_expose_an_
Assert.That(document.rootVisualElement.Q<Button>("theme-precision"), Is.Null);
}

[UnityTest]
public IEnumerator Ghost_replay_is_local_visual_only_and_does_not_change_score()
{
AsyncOperation load = SceneManager.LoadSceneAsync("GameplayPrototype", LoadSceneMode.Single);
while (!load.isDone) yield return null;
yield return null;

GameplayHighwayController controller = Object.FindAnyObjectByType<GameplayHighwayController>();
Assert.That(controller, Is.Not.Null);
Assert.That(controller.CaptureGhostHit(
new HitTheKit.Unity.Input.DrumInputEvent(
DrumPad.Kick, 108, 1.25, HitTheKit.Unity.Input.DrumInputSource.Test), null), Is.True);
Assert.That(controller.CurrentGhostTakeHitCount, Is.EqualTo(1));
Assert.That(controller.BeginGhostReplay(), Is.True);
yield return null;

Assert.That(controller.GhostHits, Has.Count.EqualTo(1));
Assert.That(controller.Surface.GhostHits, Has.Count.EqualTo(1));
Assert.That(controller.GhostHits[0].Pad, Is.EqualTo(DrumPad.Kick));
Assert.That(controller.ScoreSnapshot.Score, Is.Zero,
"Ghost markers must never feed the matcher or scoring engine.");
Assert.That(controller.CurrentGhostTakeHitCount, Is.Zero);
}

[UnityTest]
public IEnumerator Precision_grid_enables_the_colored_instructional_kit_from_settings()
{
Expand Down
Loading
Loading