-
Notifications
You must be signed in to change notification settings - Fork 348
Expand file tree
/
Copy pathVocalsPlayer.cs
More file actions
667 lines (546 loc) · 23.2 KB
/
Copy pathVocalsPlayer.cs
File metadata and controls
667 lines (546 loc) · 23.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEngine.AddressableAssets;
using YARG.Core;
using YARG.Core.Audio;
using YARG.Core.Chart;
using YARG.Core.Engine;
using YARG.Core.Engine.Vocals;
using YARG.Core.Engine.Vocals.Engines;
using YARG.Core.Input;
using YARG.Core.Replays;
using YARG.Gameplay.HUD;
using YARG.Helpers;
using YARG.Input;
using YARG.Player;
using YARG.Settings;
namespace YARG.Gameplay.Player
{
public class VocalsPlayer : BasePlayer
{
public VocalsEngineParameters EngineParams { get; private set; }
public VocalsEngine Engine { get; private set; }
public override BaseEngine BaseEngine => Engine;
[SerializeField]
private GameObject _needleVisualContainer;
[SerializeField]
private MeshRenderer _needleRenderer;
[SerializeField]
private Transform _needleTransform;
[SerializeField]
private ParticleGroup _hittingParticleGroup;
public override bool ShouldUpdateInputsOnResume => false;
protected override float[] StarMultiplierThresholds { get; set; } =
{
0.05f, 0.11f, 0.19f, 0.46f, 0.77f, 1.06f
};
private InstrumentDifficulty<VocalNote> NoteTrack { get; set; }
private InstrumentDifficulty<VocalNote> OriginalNoteTrack { get; set; }
private MicInputContext _inputContext;
private VocalNote _lastTargetNote;
private double? _lastHitTime;
private double? _lastSingTime;
private double _previousStarPowerPercent;
private bool _hotStartChecked;
private bool _newHighScoreShown;
private VocalsPlayerHUD _hud;
private VocalPercussionTrack _percussionTrack;
private bool _shouldHideNeedle;
private bool _handlesCountdown;
private List<VocalsPart> _allVocalParts;
private int _phraseIndex = -1;
private const int NEEDLES_COUNT = 7;
private SongChart _chart;
public void Initialize(int index, int vocalIndex, YargPlayer player, SongChart chart,
VocalsPlayerHUD hud, VocalPercussionTrack percussionTrack, int? lastHighScore, float trackSpeed)
{
if (IsInitialized)
{
return;
}
base.Initialize(index, player, chart, lastHighScore);
// Save the chart
_chart = chart;
// Needle materials have names starting from 1.
var needleIndex = (vocalIndex % NEEDLES_COUNT) + 1;
var materialPath = $"VocalNeedle/{needleIndex}";
_needleRenderer.material = Addressables.LoadAssetAsync<Material>(materialPath).WaitForCompletion();
// Get the notes from the specific harmony or solo part
var multiTrack = chart.GetVocalsTrack(Player.Profile.CurrentInstrument);
_allVocalParts = multiTrack.Parts;
_handlesCountdown = vocalIndex == 0;
var track = multiTrack.Parts[Player.Profile.HarmonyIndex];
player.Profile.ApplyVocalModifiers(track);
OriginalNoteTrack = track.CloneAsInstrumentDifficulty();
NoteTrack = OriginalNoteTrack;
_phraseIndex = -1;
_previousStarPowerPercent = 0.0;
// Update speed of particles
var particles = _hittingParticleGroup.GetComponentsInChildren<ParticleSystem>();
foreach (var system in particles)
{
// This interface is weird lol, `.main` is readonly but
// doesn't need to be re-assigned, changes are forwarded automatically
var main = system.main;
var startSpeed = main.startSpeed;
startSpeed.constant *= trackSpeed;
main.startSpeed = startSpeed;
main.startColor = VocalTrack.Colors[Player.Profile.HarmonyIndex];
}
// Initialize player specific vocal visuals
hud.Initialize(player.EnginePreset);
_hud = hud;
percussionTrack.Initialize(NoteTrack.Notes);
_percussionTrack = percussionTrack;
_hud.ShowPlayerName(player, needleIndex);
// Create and start an input context for the mic
if (!Player.IsReplay && player.Bindings.Microphone != null)
{
_inputContext = new MicInputContext(player.Bindings.Microphone, GameManager);
_inputContext.Start();
}
Engine = CreateEngine();
if (GameManager.IsPractice)
{
Engine.SetSpeed(GameManager.SongSpeed >= 1 ? GameManager.SongSpeed : 1);
}
else
{
Engine.SetSpeed(GameManager.SongSpeed);
}
}
protected override void FinishDestruction()
{
_inputContext?.Stop();
}
protected VocalsEngine CreateEngine()
{
if (!Player.IsReplay)
{
var singToActivateStarPower = SettingsManager.Settings.VoiceActivatedVocalStarPower.Value;
// Create the engine params from the engine preset
EngineParams = Player.EnginePreset.Vocals.Create(StarMultiplierThresholds, SoloBonusStarMultiplierThresholds,
Player.Profile.CurrentDifficulty, MicDevice.UPDATES_PER_SECOND, singToActivateStarPower);
}
else
{
// Otherwise, get from the replay
EngineParams = (VocalsEngineParameters) Player.EngineParameterOverride;
}
// The hit window can just be taken from the params
HitWindow = EngineParams.HitWindow;
var engine = new YargVocalsEngine(NoteTrack, SyncTrack, EngineParams, Player.Profile.IsBot);
EngineContainer = GameManager.EngineManager.Register(engine, NoteTrack, Player.Profile.HarmonyIndex, _chart, Player.RockMeterPreset);
engine.OnComboIncrement += OnComboIncrement;
engine.OnComboReset += OnComboReset;
engine.OnStarPowerPhraseHit += _ => OnStarPowerPhraseHit();
engine.OnStarPowerStatus += OnStarPowerStatus;
engine.OnStarPowerReady += OnStarPowerReady;
engine.OnTargetNoteChanged += (note) =>
{
_lastTargetNote = note;
};
engine.OnPhraseHit += (percent, fullPoints, isLastPhrase) =>
{
if (!fullPoints)
{
IsFc = false;
}
LastCombo = Combo;
ShowTextNotifications(isLastPhrase);
// Order is important here. ShowVocalPhraseResult() will skip showing AWESOME! if other, more important notifications are already showing.
_hud.ShowPhraseHit(percent, Combo);
};
engine.OnNoteHit += (_, note) =>
{
if (note.IsPercussion)
{
_percussionTrack.HitPercussionNote(note);
}
};
engine.OnNoteMissed += (_, _) =>
{
if (LastCombo >= 2)
{
GlobalAudioHandler.PlaySoundEffect(SfxSample.NoteMiss);
}
LastCombo = Combo;
};
engine.OnSing += (singing) =>
{
_lastSingTime = singing
? GameManager.InputTime
: null;
};
engine.OnHit += (hitting) =>
{
_lastHitTime = hitting
? GameManager.InputTime
: null;
};
if (_handlesCountdown)
{
if (Player.Profile.CurrentInstrument == Instrument.Vocals)
{
engine.BuildCountdownsFromSelectedPart();
}
else
{
engine.BuildCountdownsFromAllParts(_allVocalParts);
}
engine.OnCountdownChange += (countdownLength, endTime) =>
{
GameManager.VocalTrack.UpdateCountdown(countdownLength, endTime);
};
}
return engine;
}
protected override void ResetVisuals()
{
_lastTargetNote = null;
}
public override void ResetPracticeSection()
{
Engine.Reset(true);
if (NoteTrack.Notes.Count > 0)
{
NoteTrack.Notes[0].OverridePreviousNote();
NoteTrack.Notes[^1].OverrideNextNote();
}
_phraseIndex = -1;
_percussionTrack.Initialize(NoteTrack.Notes);
base.ResetPracticeSection();
}
public override void Rewind(double visualTime)
{
_hittingParticleGroup.Stop();
}
public override void PostRewind(double visualTime)
{
ResetVisuals();
UpdateVisuals(visualTime);
}
protected override void UpdateInputs(double time)
{
// Push all inputs from mic
if (!Player.IsReplay && _inputContext != null)
{
foreach (var input in _inputContext.GetInputsFromMic())
{
var i = input;
OnGameInput(ref i);
}
}
base.UpdateInputs(time);
}
private bool IsInThreshold(double currentTime, double? lastTime)
{
if (lastTime is null)
{
return false;
}
return currentTime - lastTime.Value <= 1f / EngineParams.ApproximateVocalFps + 0.05;
}
protected override void UpdateVisuals(double visualTime)
{
UpdatePercussionPhrase(visualTime);
UpdateSingNeedle();
// Get combo meter fill
float fill = 0f;
if (Engine.PhraseTicksTotal != null && Engine.PhraseTicksTotal.Value != 0)
{
fill = (float) (Engine.PhraseTicksHit / Engine.PhraseTicksTotal.Value);
fill /= (float) EngineParams.PhraseHitPercent;
}
// In multiplayer, don't double the score multiplier in the strikeline element
// Otherwise, it looks like the band multiplier applies on top of the score multiplier
var engineStats = Engine.EngineStats;
int displayMultiplier = GameManager.TotalPlayers > 1 && engineStats.IsStarPowerActive
? engineStats.ScoreMultiplier / 2
: engineStats.ScoreMultiplier;
// Update HUD
_hud.UpdateInfo(fill, displayMultiplier,
(float) Engine.GetStarPowerBarAmount(), Engine.EngineStats.IsStarPowerActive);
}
protected override void OnStarPowerReady()
{
base.OnStarPowerReady();
_hud.ShowNotification(TextNotificationType.StarPowerReady);
}
private void ShowTextNotifications(bool isLastPhrase)
{
if (SettingsManager.Settings.DisableTextNotifications.Value)
{
return;
}
var isStarPowerActive = Engine.EngineStats.IsStarPowerActive;
_previousStarPowerPercent = Engine.GetStarPowerBarAmount();
var isMaxMultiplier = Engine.EngineStats.ScoreMultiplier == (isStarPowerActive ? 8 : 4);
if (!_hotStartChecked && isMaxMultiplier && IsFc)
{
_hud.ShowNotification(TextNotificationType.HotStart);
_hotStartChecked = true;
}
if (LastHighScore != null && !_newHighScoreShown && Score > LastHighScore)
{
_hud.ShowNotification(TextNotificationType.NewHighScore);
_newHighScoreShown = true;
}
if (!isLastPhrase)
{
return;
}
if (IsFc)
{
_hud.ShowNotification(TextNotificationType.FullCombo);
}
else if (isMaxMultiplier)
{
_hud.ShowNotification(TextNotificationType.StrongFinish);
}
}
private float GetNeedleRotation(float pitchDist)
{
const float NEEDLE_ROT_MAX = 12f;
// Reduce the provided distance by applying a dead zone. This will prevent oversteer if the player's current pitch is well within the "Perfect" window.
var deadzoneInSemitones = EngineParams.PitchWindowPerfect / 2;
var adjustedPitchDist = ApplyPitchDeadZone(pitchDist, deadzoneInSemitones);
// Determine how off that is compared to the hit window
float distPercent = Mathf.Clamp(adjustedPitchDist / (EngineParams.PitchWindow - deadzoneInSemitones), -1f, 1f);
// Use that to get the target rotation
return distPercent * NEEDLE_ROT_MAX;
}
private float ApplyPitchDeadZone(float pitchDist, float deadZoneInSemitones)
{
if (pitchDist >= 0.0f)
{
return Mathf.Max(0.0f, pitchDist - deadZoneInSemitones);
}
return Mathf.Min(0.0f, pitchDist + deadZoneInSemitones);
}
private void UpdateSingNeedle()
{
const float NEEDLE_POS_LERP = 30f;
const float NEEDLE_POS_SNAP_MULTIPLIER = 10f;
const float NEEDLE_ROT_LERP = 25f;
// Get the appropriate sing time
var singTime = GameManager.InputTime;
// Get whether or not the player has sang within the time threshold.
// We gotta use a threshold here because microphone inputs are passed every X seconds,
// not in a constant stream.
if (!IsInThreshold(singTime, _lastSingTime) || _shouldHideNeedle)
{
// Hide the needle if there's no singing
if (_needleVisualContainer.activeSelf)
{
_needleVisualContainer.SetActive(false);
_hittingParticleGroup.Stop();
}
}
else
{
float lerpRate = NEEDLE_POS_LERP;
// Show needle
if (!_needleVisualContainer.activeSelf)
{
_needleVisualContainer.SetActive(true);
// Lerp X times faster if we've just started showing the needle
lerpRate *= NEEDLE_POS_SNAP_MULTIPLIER;
}
var transformCache = transform;
float lastNotePitch = _lastTargetNote?.PitchAtSongTime(GameManager.SongTime) ?? -1f;
if (_lastTargetNote is not null && IsInThreshold(singTime, _lastHitTime))
{
// Show particles if hitting (as long as we aren't rewinding)
if (!GameManager.Rewinding)
{
_hittingParticleGroup.Play();
}
float pitch;
float targetRotation = 0f;
if (!_lastTargetNote.IsNonPitched)
{
// If the player is hitting, just set the needle position to the note
pitch = lastNotePitch;
// Rotate the needle a little bit depending on how off it is (unless it's non-pitched)
// Get how off the player is
(float pitchDist, _) = GetPitchDistanceIgnoringOctave(lastNotePitch, Engine.PitchSang);
targetRotation = GetNeedleRotation(pitchDist);
}
else
{
// If the note is non-pitched, just use the singing position
pitch = Engine.PitchSang + 12f;
}
// Transform!
float z = GameManager.VocalTrack.GetPosForPitch(pitch);
var lerp = Mathf.Lerp(transformCache.localPosition.z, z, Time.deltaTime * lerpRate);
transformCache.localPosition = new Vector3(0f, 0f, lerp);
_needleTransform.rotation = Quaternion.Lerp(_needleTransform.rotation,
Quaternion.Euler(0f, targetRotation + 90f, 0f), Time.deltaTime * NEEDLE_ROT_LERP);
}
else
{
// Stop particles if not hitting
_hittingParticleGroup.Stop();
// Since the player is not hitting the note here, we need to offset it correctly.
// Get the pitch, and move to the correct octave.
float pitch = Engine.PitchSang;
if (_lastTargetNote is not null && !_lastTargetNote.IsNonPitched)
{
(_, int octaveShift) = GetPitchDistanceIgnoringOctave(lastNotePitch, pitch);
int lastNoteOctave = (int) (lastNotePitch / 12f);
// Set the pitch's octave to the target one
pitch = Engine.PitchSang % 12f;
pitch += 12f * (lastNoteOctave + octaveShift);
}
else
{
// Hard code a value of one octave up to
// make the needle sit more in the middle
pitch += 12f;
}
// Set the position of the needle
var z = GameManager.VocalTrack.GetPosForPitch(pitch);
var lerp = Mathf.Lerp(transformCache.localPosition.z, z, Time.deltaTime * lerpRate);
transformCache.localPosition = new Vector3(0f, 0f, lerp);
// Lerp the rotation to none
_needleTransform.rotation = Quaternion.Lerp(_needleTransform.rotation,
Quaternion.Euler(0f, 90f, 0f), Time.deltaTime * NEEDLE_ROT_LERP);
}
}
}
private void UpdatePercussionPhrase(double time)
{
// Prevent the HUD from hiding too quickly
if (time < 0)
{
return;
}
while (ShouldAdvancePhraseIndex(time))
{
_phraseIndex++;
// We've reached the end. No need to continue.
if (_phraseIndex >= NoteTrack.Notes.Count)
{
SetPercussionMode(false);
return;
}
var phrase = NoteTrack.Notes[_phraseIndex];
SetPercussionMode(HasPercussion(phrase));
}
}
private bool ShouldAdvancePhraseIndex(double time)
{
// Since phrases start at the note, and not sometime before it, use
// the end times of phrases instead (where the phrase lines are). Problem
// with this is that we still gotta account for the first phrase, so use
// an index of -1 for that.
bool beforeFirstPhrase = _phraseIndex == -1;
if (beforeFirstPhrase)
{
// Track has no notes. Bail early.
if (NoteTrack.Notes.Count <= 0)
{
return false;
}
var firstPhrase = NoteTrack.Notes[0];
var firstPhraseHasStarted = firstPhrase.Time <= time;
return firstPhraseHasStarted || HasPercussion(firstPhrase);
}
bool atTheEndOfTrack = _phraseIndex >= NoteTrack.Notes.Count;
if (atTheEndOfTrack)
{
return false;
}
var currentPhrase = NoteTrack.Notes[_phraseIndex];
return currentPhrase.TimeEnd <= time;
}
private void SetPercussionMode(bool show)
{
_hud.SetHUDShowing(!show);
_percussionTrack.ShowPercussionFret(show);
_shouldHideNeedle = show;
}
private static bool HasPercussion(VocalNote phrase)
{
foreach (var note in phrase.ChildNotes)
{
if (note.IsPercussion)
{
return true;
}
}
return false;
}
public override void SetPracticeSection(uint start, uint end)
{
var practiceNotes = OriginalNoteTrack.Notes.Where(n => IsVocalPhraseInPracticeRange(n, start, end)).ToList();
NoteTrack = new InstrumentDifficulty<VocalNote>(
OriginalNoteTrack.Instrument,
OriginalNoteTrack.Difficulty,
practiceNotes,
OriginalNoteTrack.Phrases,
OriginalNoteTrack.TextEvents);
_phraseIndex = -1;
Engine = CreateEngine();
Engine.SetSpeed(GameManager.SongSpeed >= 1 ? GameManager.SongSpeed : 1);
ResetPracticeSection();
}
private static bool IsVocalPhraseInPracticeRange(VocalNote note, uint start, uint end)
{
if (note.Tick >= start && note.Tick < end)
{
return true;
}
return note.ChildNotes.Count > 0 &&
note.ChildNotes.All(child => child.Tick >= start && child.TotalTickEnd <= end);
}
public override void SetStemMuteState(bool muted)
{
// Vocals has no stem muting
}
protected override bool InterceptInput(ref GameInput input)
{
return false;
}
/// <returns>
/// The first value in the pair (<c>Distance</c>) is the distance between <paramref name="target"/> and '
/// <paramref name="other"/> ignoring the octave.<br/>
/// The second value in the pair (<c>OctaveShift</c>) is how much the <paramref name="target"/> octave
/// had to be shifted in order for the closest distance to be found.
/// </returns>
/// <param name="target">The target note (as MIDI pitch).</param>
/// <param name="other">The other note (as MIDI pitch).</param>
private static (float Distance, int OctaveShift) GetPitchDistanceIgnoringOctave(float target, float other)
{
// Normalize the parameters
target %= 12f;
other %= 12f;
// Start off with the current octave
float closest = other - target;
int octaveShift = 0;
// Upper octave
float upperDist = (other + 12f) - target;
if (Mathf.Abs(upperDist) < Mathf.Abs(closest))
{
closest = upperDist;
octaveShift = 1;
}
// Lower octave
float lowerDist = (other - 12f) - target;
if (Mathf.Abs(lowerDist) < Mathf.Abs(closest))
{
closest = lowerDist;
octaveShift = -1;
}
return (closest, octaveShift);
}
public override (ReplayFrame Frame, ReplayStats Stats) ConstructReplayData()
{
var frame = new ReplayFrame(Player.Profile, EngineParams, Engine.EngineStats, ReplayInputs.ToArray());
return (frame, Engine.EngineStats.ConstructReplayStats(Player.Profile.Name, Player.IsReplay));
}
}
}