Funkin/source/funkin/Conductor.hx
Eric 42d8d55067 Unit Test Suite (#119)
* Initial test suite

* Fix some build warnings

* Implemented working unit tests with coverage

* Reduced some warnings

* Fix a mac-specific issue

* Add 2 additional unit test classes.

* Multiple new unit tests

* Some fixins

* Remove auto-generated file

* WIP on hiding ignored tests

* Added list of debug hotkeys

* Remove old website

* Remove empty file

* Add more unit tests

* Fix bug where arrows would nudge BF

* Fix bug where ctrl/alt would flash capsules

* Fixed bug where bf-old easter egg broke

* Remove duplicate lines

* More test-related stuff

* Some code cleanup

* Add mocking and a test assets folder

* More TESTS!

* Update Hmm...

* Update artist on Monster

* More minor fixes to individual functions

* 1.38% unit test coverage!

* Even more tests? :O

* More unit test work

* Rework migration for BaseRegistry

* gameover fix

* Fix an issue with Lime

* Fix issues with version parsing on data files

* 100 total unit tests!

* Added even MORE unit tests!

* Additional test tweaks :3

* Fixed tests on windows by updating libraries.

* Set versions for flixel-ui and hamcrest

---------

Co-authored-by: Cameron Taylor <cameron.taylor.ninja@gmail.com>
2023-08-22 04:27:30 -04:00

418 lines
12 KiB
Haxe

package funkin;
import funkin.util.Constants;
import flixel.util.FlxSignal;
import flixel.math.FlxMath;
import funkin.play.song.Song.SongDifficulty;
import funkin.play.song.SongData.SongTimeChange;
/**
* A core class which handles musical timing throughout the game,
* both in gameplay and in menus.
*/
class Conductor
{
// onBeatHit is called every quarter note
// onStepHit is called every sixteenth note
// 4/4 = 4 beats per measure = 16 steps per measure
// 120 BPM = 120 quarter notes per minute = 2 onBeatHit per second
// 120 BPM = 480 sixteenth notes per minute = 8 onStepHit per second
// 60 BPM = 60 quarter notes per minute = 1 onBeatHit per second
// 60 BPM = 240 sixteenth notes per minute = 4 onStepHit per second
// 3/4 = 3 beats per measure = 12 steps per measure
// (IDENTICAL TO 4/4 but shorter measure length)
// 120 BPM = 120 quarter notes per minute = 2 onBeatHit per second
// 120 BPM = 480 sixteenth notes per minute = 8 onStepHit per second
// 60 BPM = 60 quarter notes per minute = 1 onBeatHit per second
// 60 BPM = 240 sixteenth notes per minute = 4 onStepHit per second
// 7/8 = 3.5 beats per measure = 14 steps per measure
/**
* The list of time changes in the song.
* There should be at least one time change (at the beginning of the song) to define the BPM.
*/
static var timeChanges:Array<SongTimeChange> = [];
/**
* The current time change.
*/
static var currentTimeChange:SongTimeChange;
/**
* The current position in the song in milliseconds.
* Updated every frame based on the audio position.
*/
public static var songPosition:Float = 0;
/**
* Beats per minute of the current song at the current time.
*/
public static var bpm(get, null):Float;
static function get_bpm():Float
{
if (bpmOverride != null) return bpmOverride;
if (currentTimeChange == null) return Constants.DEFAULT_BPM;
return currentTimeChange.bpm;
}
/**
* The current value set by `forceBPM`.
* If false, BPM is determined by time changes.
*/
static var bpmOverride:Null<Float> = null;
/**
* Duration of a measure in milliseconds. Calculated based on bpm.
*/
public static var measureLengthMs(get, null):Float;
static function get_measureLengthMs():Float
{
return beatLengthMs * timeSignatureNumerator;
}
/**
* Duration of a beat (quarter note) in milliseconds. Calculated based on bpm.
*/
public static var beatLengthMs(get, null):Float;
static function get_beatLengthMs():Float
{
// Tied directly to BPM.
return ((Constants.SECS_PER_MIN / bpm) * Constants.MS_PER_SEC);
}
/**
* Duration of a step (sixtennth note) in milliseconds. Calculated based on bpm.
*/
public static var stepLengthMs(get, null):Float;
static function get_stepLengthMs():Float
{
return beatLengthMs / timeSignatureNumerator;
}
public static var timeSignatureNumerator(get, null):Int;
static function get_timeSignatureNumerator():Int
{
if (currentTimeChange == null) return Constants.DEFAULT_TIME_SIGNATURE_NUM;
return currentTimeChange.timeSignatureNum;
}
public static var timeSignatureDenominator(get, null):Int;
static function get_timeSignatureDenominator():Int
{
if (currentTimeChange == null) return Constants.DEFAULT_TIME_SIGNATURE_DEN;
return currentTimeChange.timeSignatureDen;
}
/**
* Current position in the song, in measures.
*/
public static var currentMeasure(default, null):Int;
/**
* Current position in the song, in beats.
*/
public static var currentBeat(default, null):Int;
/**
* Current position in the song, in steps.
*/
public static var currentStep(default, null):Int;
/**
* Current position in the song, in measures and fractions of a measure.
*/
public static var currentMeasureTime(default, null):Float;
/**
* Current position in the song, in beats and fractions of a measure.
*/
public static var currentBeatTime(default, null):Float;
/**
* Current position in the song, in steps and fractions of a step.
*/
public static var currentStepTime(default, null):Float;
public static var beatHit(default, null):FlxSignal = new FlxSignal();
public static var stepHit(default, null):FlxSignal = new FlxSignal();
public static var lastSongPos:Float;
public static var visualOffset:Float = 0;
public static var audioOffset:Float = 0;
public static var offset:Float = 0;
public static var beatsPerMeasure(get, null):Float;
static function get_beatsPerMeasure():Float
{
// NOTE: Not always an integer, for example 7/8 is 3.5 beats per measure
return stepsPerMeasure / Constants.STEPS_PER_BEAT;
}
public static var stepsPerMeasure(get, null):Int;
static function get_stepsPerMeasure():Int
{
// TODO: Is this always an integer?
return Std.int(timeSignatureNumerator / timeSignatureDenominator * Constants.STEPS_PER_BEAT * Constants.STEPS_PER_BEAT);
}
function new() {}
/**
* Forcibly defines the current BPM of the song.
* Useful for things like the chart editor that need to manipulate BPM in real time.
*
* Set to null to reset to the BPM defined by the timeChanges.
*
* WARNING: Avoid this for things like setting the BPM of the title screen music,
* you should have a metadata file for it instead.
*/
public static function forceBPM(?bpm:Float = null)
{
if (bpm != null)
{
trace('[CONDUCTOR] Forcing BPM to ${bpm}');
}
else
{
// trace('[CONDUCTOR] Resetting BPM to default');
}
Conductor.bpmOverride = bpm;
}
/**
* Update the conductor with the current song position.
* BPM, current step, etc. will be re-calculated based on the song position.
*
* @param songPosition The current position in the song in milliseconds.
* Leave blank to use the FlxG.sound.music position.
*/
public static function update(songPosition:Float = null)
{
if (songPosition == null) songPosition = (FlxG.sound.music != null) ? FlxG.sound.music.time + Conductor.offset : 0.0;
var oldBeat = currentBeat;
var oldStep = currentStep;
Conductor.songPosition = songPosition;
currentTimeChange = timeChanges[0];
for (i in 0...timeChanges.length)
{
if (songPosition >= timeChanges[i].timeStamp) currentTimeChange = timeChanges[i];
if (songPosition < timeChanges[i].timeStamp) break;
}
if (currentTimeChange == null && bpmOverride == null && FlxG.sound.music != null)
{
trace('WARNING: Conductor is broken, timeChanges is empty.');
}
else if (currentTimeChange != null)
{
// roundDecimal prevents representing 8 as 7.9999999
currentStepTime = FlxMath.roundDecimal((currentTimeChange.beatTime * 4) + (songPosition - currentTimeChange.timeStamp) / stepLengthMs, 6);
currentBeatTime = currentStepTime / Constants.STEPS_PER_BEAT;
currentMeasureTime = currentStepTime / stepsPerMeasure;
currentStep = Math.floor(currentStepTime);
currentBeat = Math.floor(currentBeatTime);
currentMeasure = Math.floor(currentMeasureTime);
}
else
{
// Assume a constant BPM equal to the forced value.
currentStepTime = FlxMath.roundDecimal((songPosition / stepLengthMs), 4);
currentBeatTime = currentStepTime / Constants.STEPS_PER_BEAT;
currentMeasureTime = currentStepTime / stepsPerMeasure;
currentStep = Math.floor(currentStepTime);
currentBeat = Math.floor(currentBeatTime);
currentMeasure = Math.floor(currentMeasureTime);
}
// FlxSignals are really cool.
if (currentStep != oldStep)
{
stepHit.dispatch();
}
if (currentBeat != oldBeat)
{
beatHit.dispatch();
}
}
public static function mapTimeChanges(songTimeChanges:Array<SongTimeChange>)
{
timeChanges = [];
for (currentTimeChange in songTimeChanges)
{
// TODO: Maybe handle this different?
// Do we care about BPM at negative timestamps?
// Without any custom handling, `currentStepTime` becomes non-zero at `songPosition = 0`.
if (currentTimeChange.timeStamp < 0.0) currentTimeChange.timeStamp = 0.0;
if (currentTimeChange.beatTime == null)
{
if (currentTimeChange.timeStamp <= 0.0)
{
currentTimeChange.beatTime = 0.0;
}
else
{
// Calculate the beat time of this timestamp.
currentTimeChange.beatTime = 0.0;
if (currentTimeChange.timeStamp > 0.0 && timeChanges.length > 0)
{
var prevTimeChange:SongTimeChange = timeChanges[timeChanges.length - 1];
currentTimeChange.beatTime = FlxMath.roundDecimal(prevTimeChange.beatTime
+ ((currentTimeChange.timeStamp - prevTimeChange.timeStamp) * prevTimeChange.bpm / Constants.SECS_PER_MIN / Constants.MS_PER_SEC),
4);
}
}
}
timeChanges.push(currentTimeChange);
}
if (timeChanges.length > 0)
{
trace('Done mapping time changes: ${timeChanges}' + timeChanges);
}
// Update currentStepTime
Conductor.update(Conductor.songPosition);
}
/**
* Given a time in milliseconds, return a time in steps.
*/
public static function getTimeInSteps(ms:Float):Float
{
if (timeChanges.length == 0)
{
// Assume a constant BPM equal to the forced value.
return Math.floor(ms / stepLengthMs);
}
else
{
var resultStep:Float = 0;
var lastTimeChange:SongTimeChange = timeChanges[0];
for (timeChange in timeChanges)
{
if (ms >= timeChange.timeStamp)
{
lastTimeChange = timeChange;
resultStep = lastTimeChange.beatTime * 4;
}
else
{
// This time change is after the requested time.
break;
}
}
var lastStepLengthMs:Float = ((Constants.SECS_PER_MIN / lastTimeChange.bpm) * Constants.MS_PER_SEC) / timeSignatureNumerator;
var resultFractionalStep:Float = (ms - lastTimeChange.timeStamp) / lastStepLengthMs;
resultStep += resultFractionalStep; // Math.floor();
return resultStep;
}
}
/**
* Given a time in steps and fractional steps, return a time in milliseconds.
*/
public static function getStepTimeInMs(stepTime:Float):Float
{
if (timeChanges.length == 0)
{
// Assume a constant BPM equal to the forced value.
return stepTime * stepLengthMs;
}
else
{
var resultMs:Float = 0;
var lastTimeChange:SongTimeChange = timeChanges[0];
for (timeChange in timeChanges)
{
if (stepTime >= timeChange.beatTime * 4)
{
lastTimeChange = timeChange;
resultMs = lastTimeChange.timeStamp;
}
else
{
// This time change is after the requested time.
break;
}
}
var lastStepLengthMs:Float = ((Constants.SECS_PER_MIN / lastTimeChange.bpm) * Constants.MS_PER_SEC) / timeSignatureNumerator;
resultMs += (stepTime - lastTimeChange.beatTime * 4) * lastStepLengthMs;
return resultMs;
}
}
/**
* Given a time in beats and fractional beats, return a time in milliseconds.
*/
public static function getBeatTimeInMs(beatTime:Float):Float
{
if (timeChanges.length == 0)
{
// Assume a constant BPM equal to the forced value.
return beatTime * stepLengthMs * Constants.STEPS_PER_BEAT;
}
else
{
var resultMs:Float = 0;
var lastTimeChange:SongTimeChange = timeChanges[0];
for (timeChange in timeChanges)
{
if (beatTime >= timeChange.beatTime)
{
lastTimeChange = timeChange;
resultMs = lastTimeChange.timeStamp;
}
else
{
// This time change is after the requested time.
break;
}
}
var lastStepLengthMs:Float = ((Constants.SECS_PER_MIN / lastTimeChange.bpm) * Constants.MS_PER_SEC) / timeSignatureNumerator;
resultMs += (beatTime - lastTimeChange.beatTime) * lastStepLengthMs * Constants.STEPS_PER_BEAT;
return resultMs;
}
}
public static function reset():Void
{
beatHit.removeAll();
stepHit.removeAll();
mapTimeChanges([]);
forceBPM(null);
update(0);
}
}