diff --git a/docs/README.md b/docs/README.md index 270aca9..6c399dd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) diff --git a/docs/development/ghost-replay.md b/docs/development/ghost-replay.md new file mode 100644 index 0000000..0f7ec8f --- /dev/null +++ b/docs/development/ghost-replay.md @@ -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. diff --git a/src/HitTheKit.Core/PerformanceGhostReplay.cs b/src/HitTheKit.Core/PerformanceGhostReplay.cs new file mode 100644 index 0000000..29e5fb5 --- /dev/null +++ b/src/HitTheKit.Core/PerformanceGhostReplay.cs @@ -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 current = new List(); + private IReadOnlyList ghost = Array.Empty(); + + public IReadOnlyList 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(); + + private static int CompareHits(GhostReplayHit left, GhostReplayHit right) + { + int byTime = left.TimeSeconds.CompareTo(right.TimeSeconds); + return byTime != 0 ? byTime : left.Pad.CompareTo(right.Pad); + } + } +} diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayHighwayController.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayHighwayController.cs index 2a3da35..fcea386 100644 --- a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayHighwayController.cs +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayHighwayController.cs @@ -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; @@ -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; @@ -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; @@ -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 GhostHits => ghostReplay.Ghost; + public int CurrentGhostTakeHitCount => ghostReplay.CurrentHitCount; private void Awake() { @@ -304,6 +309,7 @@ private void BindView() environmentTitleLabel = root.Q