Files
ProjectQuint/Assets/ProjectQuint/Runtime/Core/Judgement/RhythmJudgementEngine.cs
T

576 lines
23 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using ProjectQuint.Core.Charts;
using ProjectQuint.Core.Timing;
namespace ProjectQuint.Core.Judgement
{
public enum JudgementGrade
{
None,
Kool,
Cool,
Good,
Miss,
}
public enum RuntimeNoteState
{
Pending,
Holding,
BrokenHolding,
Completed,
Failed,
}
public readonly struct JudgementResult
{
public JudgementResult(
int noteId,
JudgementGrade grade,
double offsetSeconds,
bool longNoteBreak = false,
bool isHoldTick = false,
bool suppressGameplayPenalty = false)
{
NoteId = noteId;
Grade = grade;
OffsetSeconds = offsetSeconds;
LongNoteBreak = longNoteBreak;
IsHoldTick = isHoldTick;
SuppressGameplayPenalty = suppressGameplayPenalty;
}
public int NoteId { get; }
public JudgementGrade Grade { get; }
public double OffsetSeconds { get; }
public bool LongNoteBreak { get; }
public bool IsHoldTick { get; }
public bool SuppressGameplayPenalty { get; }
public bool IsEarly => OffsetSeconds < 0;
}
public readonly struct TimingSample
{
public TimingSample(double songTime, double offsetSeconds)
{
SongTime = songTime;
OffsetSeconds = offsetSeconds;
}
public double SongTime { get; }
public double OffsetSeconds { get; }
}
public sealed class JudgementProfile
{
public static JudgementProfile ProjectQuint { get; } = new JudgementProfile(
koolSeconds: 0.042,
coolSeconds: 0.083,
goodSeconds: 0.125);
public JudgementProfile(double koolSeconds, double coolSeconds, double goodSeconds)
{
if (!(koolSeconds > 0 && koolSeconds <= coolSeconds && coolSeconds <= goodSeconds))
throw new ArgumentException("Judgement windows must be positive and ascending.");
KoolSeconds = koolSeconds;
CoolSeconds = coolSeconds;
GoodSeconds = goodSeconds;
}
public double KoolSeconds { get; }
public double CoolSeconds { get; }
public double GoodSeconds { get; }
public JudgementGrade Grade(double absoluteOffset)
{
if (absoluteOffset <= KoolSeconds) return JudgementGrade.Kool;
if (absoluteOffset <= CoolSeconds) return JudgementGrade.Cool;
if (absoluteOffset <= GoodSeconds) return JudgementGrade.Good;
return JudgementGrade.Miss;
}
}
public sealed class RhythmJudgementEngine
{
private const int MaxTimingSamples = 24;
public const int TimingHistogramBinCount = 25;
public const double PerfectTimingNeutralSeconds = 0.022;
public const double LongNoteReleaseGraceSeconds = 0.100;
private readonly JudgementProfile _profile;
private readonly LongNoteReleasePolicy _longNoteReleasePolicy;
private readonly Func<PlayableNote, bool> _suppressMissPenalty;
private readonly List<RuntimeNote> _notes;
private readonly List<RuntimeNote>[] _notesByLane;
private readonly Dictionary<int, RuntimeNote> _notesById;
private readonly List<TimingSample> _recentTimingSamples = new List<TimingSample>(MaxTimingSamples);
private readonly int[] _timingHistogram = new int[TimingHistogramBinCount];
private readonly int[] _lanePendingCursors;
private readonly int[] _judgementCounts = new int[Enum.GetValues(typeof(JudgementGrade)).Length];
private readonly RuntimeNote[] _activeLongNotes;
private readonly List<RuntimeNote> _failedLongNotes;
private readonly int _totalScoringUnitCount;
private int _missCursor;
private int _unfinishedNoteCount;
private int _accuracyJudgedNotes;
private long _accuracyValueTotal;
private int _timingSampleCount;
private int _suppressedMissCount;
private double _timingOffsetTotalSeconds;
public event Action<JudgementResult> Judged;
public RhythmJudgementEngine(
IEnumerable<PlayableNote> notes,
JudgementProfile profile = null,
int laneCount = 0,
TempoMap tempoMap = null,
LongNoteTickPolicy longNoteTickPolicy = null,
LongNoteReleasePolicy longNoteReleasePolicy = null,
Func<PlayableNote, bool> suppressMissPenalty = null)
{
_profile = profile ?? JudgementProfile.ProjectQuint;
var effectiveTempoMap = tempoMap ?? TempoMap.Constant(150.0);
var effectiveTickPolicy = longNoteTickPolicy ?? LongNoteTickPolicy.MusicalSixteenths;
_longNoteReleasePolicy = longNoteReleasePolicy ?? LongNoteReleasePolicy.DjmaxLike;
_suppressMissPenalty = suppressMissPenalty;
_notes = (notes ?? Array.Empty<PlayableNote>())
.Select(note => new RuntimeNote(
note,
effectiveTickPolicy.CreateTickTimes(note, effectiveTempoMap)))
.OrderBy(note => note.Source.StartTime)
.ThenBy(note => note.Source.Lane)
.ToList();
_notesById = _notes.ToDictionary(note => note.Source.Id);
var inferredLaneCount = _notes.Count == 0
? 5
: _notes.Max(note => note.Source.Lane) + 1;
laneCount = laneCount > 0 ? laneCount : inferredLaneCount;
if (laneCount < 1 || laneCount > 8)
throw new ArgumentOutOfRangeException(nameof(laneCount), "Gameplay lane count must be between 1 and 8.");
_notesByLane = new List<RuntimeNote>[laneCount];
_lanePendingCursors = new int[laneCount];
_activeLongNotes = new RuntimeNote[laneCount];
_failedLongNotes = new List<RuntimeNote>(laneCount);
for (var lane = 0; lane < _notesByLane.Length; lane++)
_notesByLane[lane] = new List<RuntimeNote>();
foreach (var note in _notes)
{
if (note.Source.Lane >= 0 && note.Source.Lane < _notesByLane.Length)
_notesByLane[note.Source.Lane].Add(note);
}
_totalScoringUnitCount = _notes.Sum(note => 1 + note.TotalHoldTickCount);
_unfinishedNoteCount = _notes.Count;
}
public int Combo { get; private set; }
public int MaxCombo { get; private set; }
public int Score => _totalScoringUnitCount == 0
? 0
: (int)Math.Round(
_accuracyValueTotal * 1_000_000.0 / (_totalScoringUnitCount * 1000.0),
MidpointRounding.AwayFromZero) + PurePerfectCount;
public int TotalScoringUnitCount => _totalScoringUnitCount;
public int PurePerfectCount { get; private set; }
public int KoolCount => GetJudgementCount(JudgementGrade.Kool);
public int CoolCount => GetJudgementCount(JudgementGrade.Cool);
public int GoodCount => GetJudgementCount(JudgementGrade.Good);
public int MissCount => GetJudgementCount(JudgementGrade.Miss);
public int VisibleMissCount => Math.Max(0, MissCount - _suppressedMissCount);
public int FastCount { get; private set; }
public int LateCount { get; private set; }
public JudgementResult? LastResult { get; private set; }
public double LastResultTime { get; private set; } = double.NegativeInfinity;
public int ResultSequence { get; private set; }
public IReadOnlyList<TimingSample> RecentTimingSamples => _recentTimingSamples;
public IReadOnlyList<int> TimingHistogram => _timingHistogram;
public double PerfectTimingWindowSeconds => _profile.KoolSeconds;
public double TimingWindowSeconds => _profile.GoodSeconds;
public double AverageTimingOffsetMilliseconds => _timingSampleCount == 0
? 0
: _timingOffsetTotalSeconds * 1000.0 / _timingSampleCount;
public int JudgedNoteCount => _accuracyJudgedNotes;
public double AccuracyPercent => _accuracyJudgedNotes == 0
? 100.0
: _accuracyValueTotal * 100.0 / (_accuracyJudgedNotes * 1000.0);
public bool IsFinished => _unfinishedNoteCount == 0;
public void RestoreCourseCombo(int combo, int maxCombo)
{
Combo = Math.Max(0, combo);
MaxCombo = Math.Max(Combo, maxCombo);
}
public static bool IsPerfectTimingNeutral(double offsetSeconds) =>
Math.Abs(offsetSeconds) <= PerfectTimingNeutralSeconds + 0.000000001;
public RuntimeNoteState GetState(int noteId)
{
return _notesById.TryGetValue(noteId, out var note) ? note.State : RuntimeNoteState.Completed;
}
public int GetJudgementCount(JudgementGrade grade)
{
var index = (int)grade;
return index > (int)JudgementGrade.None && index < _judgementCounts.Length
? _judgementCounts[index]
: 0;
}
public JudgementResult? Press(int lane, double songTime, double timingOffsetSeconds = 0)
{
if (lane < 0 || lane >= _notesByLane.Length)
return null;
var judgementTime = songTime + timingOffsetSeconds;
ProcessPendingMisses(judgementTime, songTime);
ProcessLongNoteTail(lane, songTime);
AdvanceLaneCursor(lane);
RuntimeNote candidate = null;
var candidateOffset = 0.0;
var bestAbsoluteOffset = double.PositiveInfinity;
var laneNotes = _notesByLane[lane];
for (var index = _lanePendingCursors[lane]; index < laneNotes.Count; index++)
{
var note = laneNotes[index];
if (note.Source.StartTime > judgementTime + _profile.GoodSeconds)
break;
if (note.State != RuntimeNoteState.Pending)
continue;
var offset = judgementTime - note.Source.StartTime;
var absoluteOffset = Math.Abs(offset);
if (absoluteOffset > _profile.GoodSeconds || absoluteOffset >= bestAbsoluteOffset)
continue;
candidate = note;
candidateOffset = offset;
bestAbsoluteOffset = absoluteOffset;
}
if (candidate == null)
return null;
var grade = _profile.Grade(bestAbsoluteOffset);
if (candidate.Source.IsLong)
{
candidate.State = RuntimeNoteState.Holding;
_activeLongNotes[lane] = candidate;
}
else
{
candidate.State = RuntimeNoteState.Completed;
FinishNote(candidate);
}
ApplyHitScore(grade);
RecordAccuracy(candidate, grade);
if (grade == JudgementGrade.Kool && IsPerfectTimingNeutral(candidateOffset))
PurePerfectCount++;
RecordTiming(candidateOffset, songTime);
AdvanceLaneCursor(lane);
var result = new JudgementResult(candidate.Source.Id, grade, candidateOffset);
SetLastResult(result, songTime);
return result;
}
public JudgementResult? Release(int lane, double songTime)
{
if (lane < 0 || lane >= _activeLongNotes.Length)
return null;
var held = _activeLongNotes[lane];
if (held == null || held.State != RuntimeNoteState.Holding)
return null;
AwardHoldComboTicks(held, Math.Min(songTime, held.Source.EndTime));
if (!_longNoteReleasePolicy.Accepts(songTime, held.Source.EndTime))
{
held.State = RuntimeNoteState.BrokenHolding;
Combo = 0;
FinalizeUnawardedHoldTicks(held);
var failure = new JudgementResult(
held.Source.Id,
JudgementGrade.Miss,
songTime - held.Source.EndTime,
longNoteBreak: true);
SetLastResult(failure, songTime);
return failure;
}
AwardHoldComboTicks(held, held.Source.EndTime);
held.State = RuntimeNoteState.Completed;
_activeLongNotes[lane] = null;
FinishNote(held);
return null;
}
public void Update(double songTime, double timingOffsetSeconds = 0)
{
var judgementTime = songTime + timingOffsetSeconds;
ProcessPendingMisses(judgementTime, songTime);
ProcessFailedLongNoteTails(songTime);
for (var lane = 0; lane < _activeLongNotes.Length; lane++)
ProcessLongNoteTail(lane, songTime);
}
private void ProcessFailedLongNoteTails(double songTime)
{
for (var index = _failedLongNotes.Count - 1; index >= 0; index--)
{
var note = _failedLongNotes[index];
if (songTime < note.Source.EndTime)
continue;
note.State = RuntimeNoteState.Failed;
FinishNote(note);
_failedLongNotes.RemoveAt(index);
}
}
private void ProcessLongNoteTail(int lane, double judgementTime)
{
var note = _activeLongNotes[lane];
if (note == null)
return;
if (note.State == RuntimeNoteState.Holding)
AwardHoldComboTicks(note, Math.Min(judgementTime, note.Source.EndTime));
if (judgementTime < note.Source.EndTime)
return;
note.State = note.State == RuntimeNoteState.Holding
? RuntimeNoteState.Completed
: RuntimeNoteState.Failed;
_activeLongNotes[lane] = null;
FinishNote(note);
}
private void AwardHoldComboTicks(RuntimeNote note, double heldUntilTime)
{
if (note.State != RuntimeNoteState.Holding)
return;
heldUntilTime = Math.Min(heldUntilTime, note.Source.EndTime);
var earnedTickCount = note.AwardedHoldTickCount;
while (earnedTickCount < note.HoldTickTimes.Length &&
note.HoldTickTimes[earnedTickCount] <= heldUntilTime + 0.000001)
earnedTickCount++;
var newTickCount = earnedTickCount - note.AwardedHoldTickCount;
if (newTickCount <= 0)
return;
var firstNewTick = note.AwardedHoldTickCount + 1;
note.AwardedHoldTickCount = earnedTickCount;
Combo += newTickCount;
MaxCombo = Math.Max(MaxCombo, Combo);
// A COOL-or-better head confirms that the player entered the long note
// cleanly. Every subsequently held tick is therefore an exact KOOL/Pure
// unit. Lower head grades stay lower so a late/early entry cannot be
// repaired into a perfect long note merely by continuing to hold it.
var headWasCoolOrBetter = note.FinalGrade == JudgementGrade.Kool ||
note.FinalGrade == JudgementGrade.Cool;
var tickGrade = headWasCoolOrBetter
? JudgementGrade.Kool
: note.FinalGrade;
RecordScoringUnits(tickGrade, newTickCount);
if (headWasCoolOrBetter)
PurePerfectCount += newTickCount;
// Ticks are real score/combo judgements and must retrigger the HUD. They
// are marked separately so presentation can react while the gauge does
// not receive recovery or damage a second time for the same long note.
for (var tick = firstNewTick; tick <= earnedTickCount; tick++)
{
var tickSongTime = note.HoldTickTimes[tick - 1];
SetLastResult(
new JudgementResult(
note.Source.Id,
tickGrade,
offsetSeconds: 0,
isHoldTick: true),
tickSongTime);
}
}
private void ProcessPendingMisses(double judgementTime, double songTime)
{
while (_missCursor < _notes.Count)
{
var note = _notes[_missCursor];
if (judgementTime <= note.Source.StartTime + _profile.GoodSeconds)
break;
_missCursor++;
if (note.State != RuntimeNoteState.Pending)
continue;
var deadLongNote = note.Source.IsLong && songTime < note.Source.EndTime;
note.State = deadLongNote
? RuntimeNoteState.BrokenHolding
: RuntimeNoteState.Failed;
if (deadLongNote)
_failedLongNotes.Add(note);
else
FinishNote(note);
var suppressGameplayPenalty =
_suppressMissPenalty?.Invoke(note.Source) == true;
if (!suppressGameplayPenalty)
Combo = 0;
var remainingHoldTicks = Math.Max(
0,
note.TotalHoldTickCount - note.AwardedHoldTickCount);
RecordAccuracy(note, JudgementGrade.Miss);
FinalizeUnawardedHoldTicks(note);
if (suppressGameplayPenalty)
{
// Assisted scratch misses remain part of the final score and
// result breakdown, but must be completely silent during play:
// no MISS judgement event, no combo-break pulse, and no live
// MISS counter increment.
_suppressedMissCount += 1 + remainingHoldTicks;
if (note.Source.Lane >= 0 && note.Source.Lane < _notesByLane.Length)
AdvanceLaneCursor(note.Source.Lane);
continue;
}
SetLastResult(
new JudgementResult(
note.Source.Id,
JudgementGrade.Miss,
judgementTime - note.Source.StartTime),
songTime);
if (note.Source.Lane >= 0 && note.Source.Lane < _notesByLane.Length)
AdvanceLaneCursor(note.Source.Lane);
}
}
private void AdvanceLaneCursor(int lane)
{
var notes = _notesByLane[lane];
var cursor = _lanePendingCursors[lane];
while (cursor < notes.Count && notes[cursor].State != RuntimeNoteState.Pending)
cursor++;
_lanePendingCursors[lane] = cursor;
}
private void FinishNote(RuntimeNote note)
{
if (note.IsFinished)
return;
note.IsFinished = true;
_unfinishedNoteCount--;
}
private void ApplyHitScore(JudgementGrade grade)
{
Combo++;
MaxCombo = Math.Max(MaxCombo, Combo);
}
private void RecordAccuracy(RuntimeNote note, JudgementGrade grade)
{
if (note.AccuracyValue.HasValue)
{
_accuracyValueTotal -= note.AccuracyValue.Value;
_judgementCounts[(int)note.FinalGrade]--;
}
else
{
_accuracyJudgedNotes++;
}
var value = GradeValue(grade);
note.FinalGrade = grade;
note.AccuracyValue = value;
_accuracyValueTotal += value;
_judgementCounts[(int)grade]++;
}
private void FinalizeUnawardedHoldTicks(RuntimeNote note)
{
if (note == null || note.HoldTicksFinalized)
return;
note.HoldTicksFinalized = true;
var remaining = note.TotalHoldTickCount - note.AwardedHoldTickCount;
if (remaining > 0)
RecordScoringUnits(JudgementGrade.Miss, remaining);
}
private void RecordScoringUnits(JudgementGrade grade, int count)
{
if (count <= 0 || grade == JudgementGrade.None)
return;
_accuracyJudgedNotes += count;
_accuracyValueTotal += (long)GradeValue(grade) * count;
_judgementCounts[(int)grade] += count;
}
private void SetLastResult(JudgementResult result, double songTime)
{
LastResult = result;
LastResultTime = songTime;
ResultSequence++;
Judged?.Invoke(result);
}
private void RecordTiming(double offsetSeconds, double songTime)
{
_timingSampleCount++;
_timingOffsetTotalSeconds += offsetSeconds;
var normalizedOffset = Math.Max(-1.0, Math.Min(1.0, offsetSeconds / _profile.GoodSeconds));
var histogramIndex = (int)Math.Round(
(normalizedOffset + 1.0) * 0.5 * (TimingHistogramBinCount - 1),
MidpointRounding.AwayFromZero);
_timingHistogram[Math.Max(0, Math.Min(TimingHistogramBinCount - 1, histogramIndex))]++;
if (IsPerfectTimingNeutral(offsetSeconds))
return;
if (offsetSeconds < 0)
FastCount++;
else if (offsetSeconds > 0)
LateCount++;
if (_recentTimingSamples.Count == MaxTimingSamples)
_recentTimingSamples.RemoveAt(0);
_recentTimingSamples.Add(new TimingSample(songTime, offsetSeconds));
}
private static int GradeValue(JudgementGrade grade)
{
switch (grade)
{
case JudgementGrade.Kool: return 1000;
case JudgementGrade.Cool: return 700;
case JudgementGrade.Good: return 200;
default: return 0;
}
}
private sealed class RuntimeNote
{
public RuntimeNote(PlayableNote source, double[] holdTickTimes)
{
Source = source;
HoldTickTimes = holdTickTimes ?? Array.Empty<double>();
}
public PlayableNote Source { get; }
public RuntimeNoteState State { get; set; }
public JudgementGrade FinalGrade { get; set; }
public int? AccuracyValue { get; set; }
public bool IsFinished { get; set; }
public int AwardedHoldTickCount { get; set; }
public double[] HoldTickTimes { get; }
public int TotalHoldTickCount => HoldTickTimes.Length;
public bool HoldTicksFinalized { get; set; }
}
}
}