576 lines
23 KiB
C#
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; }
|
|
}
|
|
}
|
|
}
|