Merge branch 'master' into feature/chart-editor-importer

This commit is contained in:
Cameron Taylor 2023-07-22 14:43:05 -04:00
commit 85af96d654
35 changed files with 2742 additions and 184 deletions

View file

@ -106,7 +106,10 @@
<haxelib name="polymod" /> <!-- Modding framework -->
<haxelib name="flxanimate" /> <!-- Texture atlas rendering -->
<haxelib name="hxCodec" /> <!-- Video playback -->
<haxelib name="json2object" /> <!-- JSON parsing -->
<haxelib name="tink_json" /> <!-- JSON parsing -->
<haxelib name="thx.semver" />
<haxelib name="hxcpp-debug-server" if="desktop debug" />
<!--Disable the Flixel core focus lost screen-->

View file

@ -116,6 +116,11 @@
"name": "thx.semver",
"type": "haxelib",
"version": "0.2.2"
},
{
"name": "tink_json",
"type": "haxelib",
"version": null
}
]
}
}

View file

@ -1,23 +1,18 @@
package funkin;
import funkin.play.song.SongData.SongTimeChange;
import funkin.util.Constants;
import flixel.util.FlxSignal;
import flixel.math.FlxMath;
import funkin.SongLoad.SwagSong;
import funkin.play.song.Song.SongDifficulty;
typedef BPMChangeEvent =
{
var stepTime:Int;
var songTime:Float;
var bpm:Float;
}
import funkin.play.song.SongData.SongTimeChange;
/**
* A global source of truth for timing information.
* A core class which handles musical timing throughout the game,
* both in gameplay and in menus.
*/
class Conductor
{
static final STEPS_PER_BEAT:Int = 4;
// onBeatHit is called every quarter note
// onStepHit is called every sixteenth note
// 4/4 = 4 beats per measure = 16 steps per measure
@ -33,11 +28,22 @@ class Conductor
// 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;
public static var songPosition:Float = 0;
/**
* Beats per minute of the current song at the current time.
@ -48,33 +54,17 @@ class Conductor
{
if (bpmOverride != null) return bpmOverride;
if (currentTimeChange == null) return 100;
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;
/**
* Current position in the song, in whole measures.
*/
public static var currentMeasure(default, null):Int;
/**
* Current position in the song, in whole beats.
**/
public static var currentBeat(default, null):Int;
/**
* Current position in the song, in whole steps.
*/
public static var currentStep(default, null):Int;
/**
* Current position in the song, in steps and fractions of a step.
*/
public static var currentStepTime(default, null):Float;
/**
* Duration of a measure in milliseconds. Calculated based on bpm.
*/
@ -86,119 +76,99 @@ class Conductor
}
/**
* Duration of a beat (quarter note) in milliseconds. Calculated based on bpm.
* Duration of a beat in milliseconds. Calculated based on bpm.
*/
public static var beatLengthMs(get, null):Float;
static function get_beatLengthMs():Float
{
// Tied directly to BPM.
return ((60 / bpm) * 1000);
return ((Constants.SECS_PER_MIN / bpm) * Constants.MS_PER_SEC);
}
/**
* Duration of a step (sixteenth) in milliseconds. Calculated based on bpm.
* Duration of a step (quarter) in milliseconds. Calculated based on bpm.
*/
public static var stepLengthMs(get, null):Float;
static function get_stepLengthMs():Float
{
return beatLengthMs / STEPS_PER_BEAT;
return beatLengthMs / timeSignatureNumerator;
}
/**
* The numerator of the current time signature (number of notes in a measure)
*/
public static var timeSignatureNumerator(get, null):Int;
static function get_timeSignatureNumerator():Int
{
if (currentTimeChange == null) return 4;
if (currentTimeChange == null) return Constants.DEFAULT_TIME_SIGNATURE_NUM;
return currentTimeChange.timeSignatureNum;
}
/**
* The numerator of the current time signature (length of notes in a measure)
*/
public static var timeSignatureDenominator(get, null):Int;
static function get_timeSignatureDenominator():Int
{
if (currentTimeChange == null) return 4;
if (currentTimeChange == null) return Constants.DEFAULT_TIME_SIGNATURE_DEN;
return currentTimeChange.timeSignatureDen;
}
public static var offset:Float = 0;
// TODO: What's the difference between visualOffset and audioOffset?
public static var visualOffset:Float = 0;
public static var audioOffset:Float = 0;
//
// Signals
//
/**
* Current position in the song, in measures.
*/
public static var currentMeasure(default, null):Int;
/**
* Signal that is dispatched every measure.
* At 120 BPM 4/4, this is dispatched every 2 seconds.
* At 120 BPM 3/4, this is dispatched every 1.5 seconds.
* Current position in the song, in beats.
*/
public static var measureHit(default, null):FlxSignal = new FlxSignal();
public static var currentBeat(default, null):Int;
/**
* Signal that is dispatched every beat.
* At 120 BPM 4/4, this is dispatched every 0.5 seconds.
* At 120 BPM 3/4, this is dispatched every 0.5 seconds.
* 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();
/**
* Signal that is dispatched when a step is hit.
* At 120 BPM 4/4, this is dispatched every 0.125 seconds.
* At 120 BPM 3/4, this is dispatched every 0.125 seconds.
*/
public static var stepHit(default, null):FlxSignal = new FlxSignal();
//
// Internal Variables
//
/**
* 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;
public static var lastSongPos:Float;
public static var visualOffset:Float = 0;
public static var audioOffset:Float = 0;
public static var offset:Float = 0;
/**
* The number of beats (whole notes) in a measure.
*/
public static var beatsPerMeasure(get, null):Int;
public static var beatsPerMeasure(get, null):Float;
static function get_beatsPerMeasure():Int
static function get_beatsPerMeasure():Float
{
return timeSignatureNumerator;
// NOTE: Not always an integer, for example 7/8 is 3.5 beats per measure
return stepsPerMeasure / Constants.STEPS_PER_BEAT;
}
/**
* The number of steps (quarter-notes) in a measure.
*/
public static var stepsPerMeasure(get, null):Int;
static function get_stepsPerMeasure():Int
{
// This is always 4, b
return timeSignatureNumerator * 4;
// 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.
@ -208,16 +178,11 @@ class Conductor
* 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):Void
public static function forceBPM(?bpm:Float = null)
{
if (bpm != null)
{
trace('[CONDUCTOR] Forcing BPM to ' + bpm);
}
if (bpm != null) trace('[CONDUCTOR] Forcing BPM to ' + bpm);
else
{
trace('[CONDUCTOR] Resetting BPM to default');
}
Conductor.bpmOverride = bpm;
}
@ -228,13 +193,12 @@ class Conductor
* @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):Void
public static function update(songPosition:Float = null)
{
if (songPosition == null) songPosition = (FlxG.sound.music != null) ? FlxG.sound.music.time + Conductor.offset : 0.0;
var oldMeasure:Int = currentMeasure;
var oldBeat:Int = currentBeat;
var oldStep:Int = currentStep;
var oldBeat = currentBeat;
var oldStep = currentStep;
Conductor.songPosition = songPosition;
@ -252,16 +216,23 @@ class Conductor
}
else if (currentTimeChange != null)
{
currentStepTime = (currentTimeChange.beatTime * 4) + (songPosition - currentTimeChange.timeStamp) / stepLengthMs;
// 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(currentStep / 4);
currentBeat = Math.floor(currentBeatTime);
currentMeasure = Math.floor(currentMeasureTime);
}
else
{
// Assume a constant BPM equal to the forced value.
currentStepTime = (songPosition / stepLengthMs);
currentStepTime = FlxMath.roundDecimal((songPosition / stepLengthMs), 4);
currentBeatTime = currentStepTime / Constants.STEPS_PER_BEAT;
currentMeasureTime = currentStepTime / stepsPerMeasure;
currentStep = Math.floor(currentStepTime);
currentBeat = Math.floor(currentStep / 4);
currentBeat = Math.floor(currentBeatTime);
currentMeasure = Math.floor(currentMeasureTime);
}
// FlxSignals are really cool.
@ -274,31 +245,52 @@ class Conductor
{
beatHit.dispatch();
}
if (currentMeasure != oldMeasure)
{
measureHit.dispatch();
}
}
public static function mapTimeChanges(songTimeChanges:Array<SongTimeChange>):Void
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 = prevTimeChange.beatTime
+ ((currentTimeChange.timeStamp - prevTimeChange.timeStamp) * prevTimeChange.bpm / Constants.SECS_PER_MIN / Constants.MS_PER_SEC);
}
}
}
timeChanges.push(currentTimeChange);
}
trace('Done mapping time changes: ' + timeChanges);
// Done.
// Update currentStepTime
Conductor.update(Conductor.songPosition);
}
/**
* Given a time in milliseconds, return a time in steps.
*/
public static function getTimeInSteps(ms:Float):Int
public static function getTimeInSteps(ms:Float):Float
{
if (timeChanges.length == 0)
{
@ -307,7 +299,7 @@ class Conductor
}
else
{
var resultStep:Int = 0;
var resultStep:Float = 0;
var lastTimeChange:SongTimeChange = timeChanges[0];
for (timeChange in timeChanges)
@ -329,4 +321,14 @@ class Conductor
return resultStep;
}
}
public static function reset():Void
{
beatHit.removeAll();
stepHit.removeAll();
mapTimeChanges([]);
forceBPM(null);
update(0);
}
}

View file

@ -1,8 +1,7 @@
package funkin;
import funkin.play.stage.StageData.StageDataParser;
import flixel.addons.transition.FlxTransitionSprite.GraphicTransTileDiamond;
import flixel.addons.transition.FlxTransitionableState;
import flixel.addons.transition.FlxTransitionSprite.GraphicTransTileDiamond;
import flixel.addons.transition.TransitionData;
import flixel.graphics.FlxGraphic;
import flixel.math.FlxPoint;
@ -10,13 +9,17 @@ import flixel.math.FlxRect;
import flixel.system.debug.log.LogStyle;
import flixel.util.FlxColor;
import funkin.modding.module.ModuleHandler;
import funkin.play.PlayState;
import funkin.play.character.CharacterData.CharacterDataParser;
import funkin.play.cutscene.dialogue.ConversationDataParser;
import funkin.play.cutscene.dialogue.DialogueBoxDataParser;
import funkin.play.cutscene.dialogue.SpeakerDataParser;
import funkin.play.event.SongEventData.SongEventParser;
import funkin.play.PlayState;
import funkin.play.song.SongData.SongDataParser;
import funkin.play.stage.StageData.StageDataParser;
import funkin.ui.PreferencesMenu;
import funkin.util.WindowUtil;
import funkin.util.macro.MacroUtil;
import funkin.util.WindowUtil;
import openfl.display.BitmapData;
#if discord_rpc
import Discord.DiscordClient;
@ -157,6 +160,9 @@ class InitState extends FlxTransitionableState
funkin.data.level.LevelRegistry.instance.loadEntries();
SongEventParser.loadEventCache();
ConversationDataParser.loadConversationCache();
DialogueBoxDataParser.loadDialogueBoxCache();
SpeakerDataParser.loadSpeakerCache();
SongDataParser.loadSongCache();
StageDataParser.loadStageCache();
CharacterDataParser.loadCharacterCache();
@ -216,7 +222,7 @@ class InitState extends FlxTransitionableState
#elseif ANIMATE
FlxG.switchState(new funkin.ui.animDebugShit.FlxAnimateTest());
#elseif CHARTING
FlxG.switchState(new ChartingState());
FlxG.switchState(new funkin.ui.debug.charting.ChartEditorState());
#elseif STAGEBUILD
FlxG.switchState(new StageBuilderState());
#elseif FIGHT

View file

@ -1,5 +1,6 @@
package funkin;
import funkin.modding.IScriptedClass.IEventHandler;
import flixel.FlxState;
import flixel.FlxSubState;
import flixel.addons.ui.FlxUIState;
@ -15,7 +16,7 @@ import funkin.util.SortUtil;
* MusicBeatState actually represents the core utility FlxState of the game.
* It includes functionality for event handling, as well as maintaining BPM-based update events.
*/
class MusicBeatState extends FlxUIState
class MusicBeatState extends FlxUIState implements IEventHandler
{
var controls(get, never):Controls;
@ -66,9 +67,11 @@ class MusicBeatState extends FlxUIState
if (FlxG.keys.justPressed.F5) debug_refreshModules();
// Display Conductor info in the watch window.
FlxG.watch.addQuick("songPos", Conductor.songPosition);
FlxG.watch.addQuick("currentStepTime", Conductor.currentStepTime);
FlxG.watch.addQuick("songPosition", Conductor.songPosition);
FlxG.watch.addQuick("bpm", Conductor.bpm);
FlxG.watch.addQuick("currentMeasureTime", Conductor.currentBeatTime);
FlxG.watch.addQuick("currentBeatTime", Conductor.currentBeatTime);
FlxG.watch.addQuick("currentStepTime", Conductor.currentStepTime);
dispatchEvent(new UpdateScriptEvent(elapsed));
}
@ -92,7 +95,7 @@ class MusicBeatState extends FlxUIState
add(rightWatermarkText);
}
function dispatchEvent(event:ScriptEvent)
public function dispatchEvent(event:ScriptEvent)
{
ModuleHandler.callEvent(event);
}

View file

@ -1,8 +1,8 @@
package funkin;
import flixel.FlxSubState;
import funkin.modding.IScriptedClass.IEventHandler;
import flixel.util.FlxColor;
import funkin.Conductor.BPMChangeEvent;
import funkin.modding.events.ScriptEvent;
import funkin.modding.module.ModuleHandler;
import flixel.text.FlxText;
@ -11,7 +11,7 @@ import funkin.modding.PolymodHandler;
/**
* MusicBeatSubState reincorporates the functionality of MusicBeatState into an FlxSubState.
*/
class MusicBeatSubState extends FlxSubState
class MusicBeatSubState extends FlxSubState implements IEventHandler
{
public var leftWatermarkText:FlxText = null;
public var rightWatermarkText:FlxText = null;
@ -99,7 +99,7 @@ class MusicBeatSubState extends FlxSubState
return true;
}
function dispatchEvent(event:ScriptEvent)
public function dispatchEvent(event:ScriptEvent)
{
ModuleHandler.callEvent(event);
}

View file

@ -6,7 +6,8 @@ import openfl.utils.Assets as OpenFlAssets;
class Paths
{
inline public static var SOUND_EXT = #if web "mp3" #else "ogg" #end;
public static var SOUND_EXT = #if web "mp3" #else "ogg" #end;
public static var VIDEO_EXT = "mp4";
static var currentLevel:String;
@ -96,14 +97,19 @@ class Paths
return getPath('music/$key.$SOUND_EXT', MUSIC, library);
}
inline static public function voices(song:String, ?suffix:String)
inline static public function videos(key:String, ?library:String)
{
return getPath('videos/$key.$VIDEO_EXT', BINARY, library);
}
inline static public function voices(song:String, ?suffix:String = '')
{
if (suffix == null) suffix = ""; // no suffix, for a sorta backwards compatibility with older-ish voice files
return 'songs:assets/songs/${song.toLowerCase()}/Voices$suffix.$SOUND_EXT';
}
inline static public function inst(song:String, ?suffix:String)
inline static public function inst(song:String, ?suffix:String = '')
{
return 'songs:assets/songs/${song.toLowerCase()}/Inst$suffix.$SOUND_EXT';
}

View file

@ -4,7 +4,7 @@ import funkin.play.PlayStatePlaylist;
import flixel.FlxSprite;
import flixel.addons.transition.FlxTransitionableState;
import flixel.group.FlxGroup.FlxTypedGroup;
import flixel.system.FlxSound;
import flixel.sound.FlxSound;
import flixel.text.FlxText;
import flixel.tweens.FlxEase;
import flixel.tweens.FlxTween;

View file

@ -12,6 +12,8 @@ import flixel.util.FlxTimer;
import funkin.audiovis.SpectogramSprite;
import funkin.shaderslmfao.ColorSwap;
import funkin.shaderslmfao.LeftMaskShader;
import funkin.play.song.SongData.SongDataParser;
import funkin.play.song.SongData.SongMetadata;
import funkin.shaderslmfao.TitleOutline;
import funkin.ui.AtlasText;
import funkin.util.Constants;
@ -135,12 +137,7 @@ class TitleState extends MusicBeatState
function startIntro()
{
if (FlxG.sound.music == null || !FlxG.sound.music.playing)
{
FlxG.sound.playMusic(Paths.music('freakyMenu'), 0);
FlxG.sound.music.fadeIn(4, 0, 0.7);
Conductor.forceBPM(Constants.FREAKY_MENU_BPM);
}
playMenuMusic();
persistentUpdate = true;
@ -234,6 +231,18 @@ class TitleState extends MusicBeatState
if (FlxG.sound.music != null) FlxG.sound.music.onComplete = function() FlxG.switchState(new VideoState());
}
function playMenuMusic():Void
{
if (FlxG.sound.music == null || !FlxG.sound.music.playing)
{
var freakyMenuMetadata:SongMetadata = SongDataParser.parseMusicMetadata('freakyMenu');
Conductor.mapTimeChanges(freakyMenuMetadata.timeChanges);
FlxG.sound.playMusic(Paths.music('freakyMenu/freakyMenu'), 0);
FlxG.sound.music.fadeIn(4, 0, 0.7);
}
}
function getIntroTextShit():Array<Array<String>>
{
var fullText:String = Assets.getText(Paths.txt('introText'));

View file

@ -8,7 +8,8 @@ import flixel.FlxG; // This one in particular causes a compile error if you're u
// These are great.
using Lambda;
using StringTools;
using funkin.util.tools.MapTools;
using funkin.util.tools.ArrayTools;
using funkin.util.tools.IteratorTools;
using funkin.util.tools.MapTools;
using funkin.util.tools.StringTools;
#end

View file

@ -16,6 +16,15 @@ interface IScriptedClass
public function onUpdate(event:UpdateScriptEvent):Void;
}
/**
* Defines an element which can receive script events.
* For example, the PlayState dispatches the event to all its child elements.
*/
interface IEventHandler
{
public function dispatchEvent(event:ScriptEvent):Void;
}
/**
* Defines a set of callbacks available to scripted classes which can follow the game between states.
*/
@ -150,3 +159,19 @@ interface IPlayStateScriptedClass extends IScriptedClass
*/
public function onCountdownEnd(event:CountdownScriptEvent):Void;
}
/**
* Defines a set of callbacks activated during a dialogue conversation.
*/
interface IDialogueScriptedClass extends IScriptedClass
{
/**
* Called as the dialogue starts, and before the first dialogue text is displayed.
*/
public function onDialogueStart(event:DialogueScriptEvent):Void;
public function onDialogueCompleteLine(event:DialogueScriptEvent):Void;
public function onDialogueLine(event:DialogueScriptEvent):Void;
public function onDialogueSkip(event:DialogueScriptEvent):Void;
public function onDialogueEnd(event:DialogueScriptEvent):Void;
}

View file

@ -5,4 +5,4 @@ package funkin.modding.base;
* Create a scripted class that extends FlxSpriteGroup to use this.
*/
@:hscriptClass
class ScriptedFlxSpriteGroup extends flixel.group.FlxSpriteGroup implements HScriptedClass {}
class ScriptedFlxSpriteGroup extends flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup<flixel.FlxSprite> implements HScriptedClass {}

View file

@ -3,6 +3,7 @@ package funkin.modding.events;
import flixel.FlxState;
import flixel.FlxSubState;
import funkin.noteStuff.NoteBasic.NoteDir;
import funkin.play.cutscene.dialogue.Conversation;
import funkin.play.Countdown.CountdownStep;
import openfl.events.EventType;
import openfl.events.KeyboardEvent;
@ -230,10 +231,42 @@ class ScriptEvent
public static inline final SUBSTATE_CLOSE_END:ScriptEventType = 'SUBSTATE_CLOSE_END';
/**
* Called when the game is exiting the current FlxState.
* Called when the game starts a conversation.
*
* This event is not cancelable.
*/
public static inline final DIALOGUE_START:ScriptEventType = 'DIALOGUE_START';
/**
* Called to display the next line of conversation.
*
* This event IS cancelable! Canceling this event will prevent the conversation from moving to the next line.
* - This event is called when the conversation starts, or when the user presses ACCEPT to advance the conversation.
*/
public static inline final DIALOGUE_LINE:ScriptEventType = 'DIALOGUE_LINE';
/**
* Called to skip scrolling the current line of conversation.
*
* This event IS cancelable! Canceling this event will prevent the conversation from skipping to the next line.
* - This event is called when the user presses ACCEPT to advance the conversation while it is already advancing.
*/
public static inline final DIALOGUE_COMPLETE_LINE:ScriptEventType = 'DIALOGUE_COMPLETE_LINE';
/**
* Called to skip the conversation.
*
* This event IS cancelable! Canceling this event will prevent the conversation from skipping.
*/
public static inline final DIALOGUE_SKIP:ScriptEventType = 'DIALOGUE_SKIP';
/**
* Called when the game ends a conversation.
*
* This event is not cancelable.
*/
public static inline final DIALOGUE_END:ScriptEventType = 'DIALOGUE_END';
/**
* If true, the behavior associated with this event can be prevented.
* For example, cancelling COUNTDOWN_START should prevent the countdown from starting,
@ -489,6 +522,28 @@ class CountdownScriptEvent extends ScriptEvent
}
}
/**
* An event that is fired during a dialogue.
*/
class DialogueScriptEvent extends ScriptEvent
{
/**
* The dialogue being referenced by the event.
*/
public var conversation(default, null):Conversation;
public function new(type:ScriptEventType, conversation:Conversation, cancelable:Bool = true):Void
{
super(type, cancelable);
this.conversation = conversation;
}
public override function toString():String
{
return 'DialogueScriptEvent(type=$type, conversation=$conversation)';
}
}
/**
* An event that is fired when the player presses a key.
*/

View file

@ -45,9 +45,32 @@ class ScriptEventDispatcher
}
}
if (Std.isOfType(target, IDialogueScriptedClass))
{
var t:IDialogueScriptedClass = cast(target, IDialogueScriptedClass);
switch (event.type)
{
case ScriptEvent.DIALOGUE_START:
t.onDialogueStart(cast event);
return;
case ScriptEvent.DIALOGUE_LINE:
t.onDialogueLine(cast event);
return;
case ScriptEvent.DIALOGUE_COMPLETE_LINE:
t.onDialogueCompleteLine(cast event);
return;
case ScriptEvent.DIALOGUE_SKIP:
t.onDialogueSkip(cast event);
return;
case ScriptEvent.DIALOGUE_END:
t.onDialogueEnd(cast event);
return;
}
}
if (Std.isOfType(target, IPlayStateScriptedClass))
{
var t = cast(target, IPlayStateScriptedClass);
var t:IPlayStateScriptedClass = cast(target, IPlayStateScriptedClass);
switch (event.type)
{
case ScriptEvent.NOTE_HIT:
@ -133,7 +156,7 @@ class ScriptEventDispatcher
}
// If you get a crash on this line, that means ERIC FUCKED UP!
throw 'No function called for event type: ${event.type}';
// throw 'No function called for event type: ${event.type}';
}
public static function callEventOnAllTargets(targets:Iterator<IScriptedClass>, event:ScriptEvent):Void

View file

@ -3,7 +3,7 @@ package funkin.play;
import flixel.FlxG;
import flixel.FlxObject;
import flixel.FlxSprite;
import flixel.system.FlxSound;
import flixel.sound.FlxSound;
import funkin.ui.story.StoryMenuState;
import flixel.util.FlxColor;
import flixel.util.FlxTimer;
@ -245,7 +245,7 @@ class GameOverSubState extends MusicBeatSubState
}
}
override function dispatchEvent(event:ScriptEvent)
public override function dispatchEvent(event:ScriptEvent)
{
super.dispatchEvent(event);

View file

@ -1,7 +1,5 @@
package funkin.play;
import flixel.sound.FlxSound;
import funkin.ui.story.StoryMenuState;
import flixel.addons.display.FlxPieDial;
import flixel.addons.transition.FlxTransitionableState;
import flixel.FlxCamera;
@ -14,6 +12,7 @@ import flixel.input.keyboard.FlxKey;
import flixel.math.FlxMath;
import flixel.math.FlxPoint;
import flixel.math.FlxRect;
import flixel.sound.FlxSound;
import flixel.text.FlxText;
import flixel.tweens.FlxEase;
import flixel.tweens.FlxTween;
@ -28,6 +27,8 @@ import funkin.modding.events.ScriptEventDispatcher;
import funkin.Note;
import funkin.play.character.BaseCharacter;
import funkin.play.character.CharacterData.CharacterDataParser;
import funkin.play.cutscene.dialogue.Conversation;
import funkin.play.cutscene.dialogue.ConversationDataParser;
import funkin.play.cutscene.VanillaCutscenes;
import funkin.play.cutscene.VideoCutscene;
import funkin.play.event.SongEventData.SongEventParser;
@ -45,6 +46,7 @@ import funkin.play.Strumline.StrumlineStyle;
import funkin.ui.PopUpStuff;
import funkin.ui.PreferencesMenu;
import funkin.ui.stageBuildShit.StageOffsetSubState;
import funkin.ui.story.StoryMenuState;
import funkin.util.Constants;
import funkin.util.SerializerUtil;
import funkin.util.SortUtil;
@ -222,6 +224,11 @@ class PlayState extends MusicBeatState
*/
public var disableKeys:Bool = false;
/**
* The current dialogue.
*/
public var currentConversation:Conversation;
/**
* PRIVATE INSTANCE VARIABLES
* Private instance variables should be used for information that must be reset or dereferenced
@ -887,7 +894,7 @@ class PlayState extends MusicBeatState
trace('Song difficulty could not be loaded.');
}
Conductor.forceBPM(currentChart.getStartingBPM());
// Conductor.forceBPM(currentChart.getStartingBPM());
vocals = currentChart.buildVocals(currentPlayerId);
if (vocals.members.length == 0)
@ -1201,13 +1208,10 @@ class PlayState extends MusicBeatState
camHUD.zoom = FlxMath.lerp(defaultHUDCameraZoom, camHUD.zoom, 0.95);
}
FlxG.watch.addQuick('beatShit', Conductor.currentBeat);
FlxG.watch.addQuick('stepShit', Conductor.currentStep);
if (currentStage != null)
{
FlxG.watch.addQuick('bfAnim', currentStage.getBoyfriend().getCurrentAnimation());
}
FlxG.watch.addQuick('songPos', Conductor.songPosition);
// Handle GF dance speed.
// TODO: Add a song event for this.
@ -1425,6 +1429,7 @@ class PlayState extends MusicBeatState
// Handle keybinds.
if (!isInCutscene && !disableKeys) keyShit(true);
if (!isInCutscene && !disableKeys) debugKeyShit();
if (isInCutscene && !disableKeys) handleCutsceneKeys(elapsed);
// Dispatch the onUpdate event to scripted elements.
dispatchEvent(new UpdateScriptEvent(elapsed));
@ -1432,6 +1437,36 @@ class PlayState extends MusicBeatState
static final CUTSCENE_KEYS:Array<FlxKey> = [SPACE, ESCAPE, ENTER];
function handleCutsceneKeys(elapsed:Float):Void
{
if (currentConversation != null)
{
if (controls.CUTSCENE_ADVANCE) currentConversation?.advanceConversation();
if (controls.CUTSCENE_SKIP)
{
currentConversation?.trySkipConversation(elapsed);
}
else
{
currentConversation?.trySkipConversation(-1);
}
}
else if (VideoCutscene.isPlaying())
{
// This is a video cutscene.
if (controls.CUTSCENE_SKIP)
{
trySkipVideoCutscene(elapsed);
}
else
{
trySkipVideoCutscene(-1);
}
}
}
public function trySkipVideoCutscene(elapsed:Float):Void
{
if (skipTimer == null || skipTimer.animation == null) return;
@ -2369,9 +2404,9 @@ class PlayState extends MusicBeatState
camHUD.visible = true;
}
override function dispatchEvent(event:ScriptEvent):Void
public override function dispatchEvent(event:ScriptEvent):Void
{
// ORDER: Module, Stage, Character, Song, Note
// ORDER: Module, Stage, Character, Song, Conversation, Note
// Modules should get the first chance to cancel the event.
// super.dispatchEvent(event) dispatches event to module scripts.
@ -2383,11 +2418,55 @@ class PlayState extends MusicBeatState
// Dispatch event to character script(s).
if (currentStage != null) currentStage.dispatchToCharacters(event);
// Dispatch event to song script.
ScriptEventDispatcher.callEvent(currentSong, event);
// Dispatch event to conversation script.
ScriptEventDispatcher.callEvent(currentConversation, event);
// TODO: Dispatch event to note scripts
}
public function startConversation(conversationId:String):Void
{
isInCutscene = true;
currentConversation = ConversationDataParser.fetchConversation(conversationId);
if (currentConversation == null) return;
currentConversation.completeCallback = onConversationComplete;
currentConversation.cameras = [camCutscene];
currentConversation.zIndex = 1000;
add(currentConversation);
refresh();
var event:ScriptEvent = new ScriptEvent(ScriptEvent.CREATE, false);
ScriptEventDispatcher.callEvent(currentConversation, event);
}
function onConversationComplete():Void
{
isInCutscene = true;
remove(currentConversation);
currentConversation = null;
if (startingSong && !isInCountdown)
{
startCountdown();
}
}
override function destroy():Void
{
if (currentConversation != null)
{
remove(currentConversation);
currentConversation.kill();
}
super.destroy();
}
/**
* Updates the position and contents of the score display.
*/

View file

@ -44,10 +44,12 @@ class SparrowCharacter extends BaseCharacter
if (_data.isPixel)
{
this.isPixel = true;
this.antialiasing = false;
}
else
{
this.isPixel = false;
this.antialiasing = true;
}

View file

@ -30,7 +30,8 @@ class VideoCutscene
if (!openfl.Assets.exists(filePath))
{
trace('ERROR: Video file does not exist: ${filePath}');
// Display a popup.
lime.app.Application.current.window.alert('Video file does not exist: ${filePath}', 'Error playing video');
return;
}

View file

@ -0,0 +1,617 @@
package funkin.play.cutscene.dialogue;
import flixel.FlxSprite;
import flixel.group.FlxSpriteGroup;
import flixel.util.FlxColor;
import flixel.tweens.FlxTween;
import flixel.tweens.FlxEase;
import flixel.system.FlxSound;
import funkin.util.SortUtil;
import flixel.util.FlxSort;
import funkin.modding.events.ScriptEvent;
import funkin.modding.IScriptedClass.IEventHandler;
import funkin.play.cutscene.dialogue.DialogueBox;
import funkin.modding.IScriptedClass.IDialogueScriptedClass;
import funkin.modding.events.ScriptEventDispatcher;
import funkin.play.cutscene.dialogue.ConversationData.DialogueEntryData;
import flixel.addons.display.FlxPieDial;
/**
* A high-level handler for dialogue.
*
* This shit is great for modders but it's pretty elaborate for how much it'll actually be used, lolol. -Eric
*/
class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
{
static final CONVERSATION_SKIP_TIMER:Float = 1.5;
var skipHeldTimer:Float = 0.0;
/**
* DATA
*/
/**
* The ID of the associated dialogue.
*/
public final conversationId:String;
/**
* The current state of the conversation.
*/
var state:ConversationState = ConversationState.Start;
/**
* The data for the associated dialogue.
*/
var conversationData:ConversationData;
/**
* The current entry in the dialogue.
*/
var currentDialogueEntry:Int = 0;
var currentDialogueEntryCount(get, null):Int;
function get_currentDialogueEntryCount():Int
{
return conversationData.dialogue.length;
}
/**
* The current line in the current entry in the dialogue.
* **/
var currentDialogueLine:Int = 0;
var currentDialogueLineCount(get, null):Int;
function get_currentDialogueLineCount():Int
{
return currentDialogueEntryData.text.length;
}
var currentDialogueEntryData(get, null):DialogueEntryData;
function get_currentDialogueEntryData():DialogueEntryData
{
if (conversationData == null || conversationData.dialogue == null) return null;
if (currentDialogueEntry < 0 || currentDialogueEntry >= conversationData.dialogue.length) return null;
return conversationData.dialogue[currentDialogueEntry];
}
var currentDialogueLineString(get, null):String;
function get_currentDialogueLineString():String
{
return currentDialogueEntryData?.text[currentDialogueLine];
}
/**
* AUDIO
*/
var music:FlxSound;
/**
* GRAPHICS
*/
var backdrop:FlxSprite;
var currentSpeaker:Speaker;
var currentDialogueBox:DialogueBox;
var skipTimer:FlxPieDial;
public function new(conversationId:String)
{
super();
this.conversationId = conversationId;
this.conversationData = ConversationDataParser.parseConversationData(this.conversationId);
if (conversationData == null) throw 'Could not load conversation data for conversation ID "$conversationId"';
}
public function onCreate(event:ScriptEvent):Void
{
// Reset the progress in the dialogue.
currentDialogueEntry = 0;
this.state = ConversationState.Start;
this.alpha = 1.0;
// Start the dialogue.
dispatchEvent(new DialogueScriptEvent(ScriptEvent.DIALOGUE_START, this, false));
}
function setupMusic():Void
{
if (conversationData.music == null) return;
music = new FlxSound().loadEmbedded(Paths.music(conversationData.music.asset), true, true);
music.volume = 0;
if (conversationData.music.fadeTime > 0.0)
{
FlxTween.tween(music, {volume: 1.0}, conversationData.music.fadeTime, {ease: FlxEase.linear});
}
else
{
music.volume = 1.0;
}
FlxG.sound.list.add(music);
music.play();
}
function setupBackdrop():Void
{
backdrop = new FlxSprite(0, 0);
if (conversationData.backdrop == null) return;
// Play intro
switch (conversationData?.backdrop.type)
{
case SOLID:
backdrop.makeGraphic(Std.int(FlxG.width), Std.int(FlxG.height), FlxColor.fromString(conversationData.backdrop.data.color));
if (conversationData.backdrop.data.fadeTime > 0.0)
{
backdrop.alpha = 0.0;
FlxTween.tween(backdrop, {alpha: 1.0}, conversationData.backdrop.data.fadeTime, {ease: FlxEase.linear});
}
else
{
backdrop.alpha = 1.0;
}
default:
return;
}
backdrop.zIndex = 10;
add(backdrop);
refresh();
}
function setupSkipTimer():Void
{
add(skipTimer = new FlxPieDial(16, 16, 32, FlxColor.WHITE, 36, CIRCLE, true, 24));
skipTimer.amount = 0;
}
public override function update(elapsed:Float):Void
{
super.update(elapsed);
dispatchEvent(new UpdateScriptEvent(elapsed));
}
function showCurrentSpeaker():Void
{
var nextSpeakerId:String = currentDialogueEntryData.speaker;
// Skip the next steps if the current speaker is already displayed.
if (currentSpeaker != null && nextSpeakerId == currentSpeaker.speakerId) return;
var nextSpeaker:Speaker = SpeakerDataParser.fetchSpeaker(nextSpeakerId);
if (currentSpeaker != null)
{
remove(currentSpeaker);
currentSpeaker.kill(); // Kill, don't destroy! We want to revive it later.
currentSpeaker = null;
}
if (nextSpeaker == null)
{
if (nextSpeakerId == null)
{
trace('Dialogue entry has no speaker.');
}
else
{
trace('Speaker could not be retrieved.');
}
return;
}
ScriptEventDispatcher.callEvent(nextSpeaker, new ScriptEvent(ScriptEvent.CREATE, true));
currentSpeaker = nextSpeaker;
currentSpeaker.zIndex = 200;
add(currentSpeaker);
refresh();
}
function playSpeakerAnimation():Void
{
var nextSpeakerAnimation:String = currentDialogueEntryData.speakerAnimation;
if (nextSpeakerAnimation == null) return;
currentSpeaker.playAnimation(nextSpeakerAnimation);
}
public function refresh():Void
{
sort(SortUtil.byZIndex, FlxSort.ASCENDING);
}
function showCurrentDialogueBox():Void
{
var nextDialogueBoxId:String = currentDialogueEntryData?.box;
// Skip the next steps if the current speaker is already displayed.
if (currentDialogueBox != null && nextDialogueBoxId == currentDialogueBox.dialogueBoxId) return;
if (currentDialogueBox != null)
{
remove(currentDialogueBox);
currentDialogueBox.kill(); // Kill, don't destroy! We want to revive it later.
currentDialogueBox = null;
}
var nextDialogueBox:DialogueBox = DialogueBoxDataParser.fetchDialogueBox(nextDialogueBoxId);
if (nextDialogueBox == null)
{
trace('Dialogue box could not be retrieved.');
return;
}
ScriptEventDispatcher.callEvent(nextDialogueBox, new ScriptEvent(ScriptEvent.CREATE, true));
currentDialogueBox = nextDialogueBox;
currentDialogueBox.zIndex = 300;
currentDialogueBox.typingCompleteCallback = this.onTypingComplete;
add(currentDialogueBox);
refresh();
}
function playDialogueBoxAnimation():Void
{
var nextDialogueBoxAnimation:String = currentDialogueEntryData?.boxAnimation;
if (nextDialogueBoxAnimation == null) return;
currentDialogueBox.playAnimation(nextDialogueBoxAnimation);
}
function onTypingComplete():Void
{
if (this.state == ConversationState.Speaking)
{
this.state = ConversationState.Idle;
}
else
{
trace('[WARNING] Unexpected state transition from ${this.state}');
this.state = ConversationState.Idle;
}
}
public function startConversation():Void
{
dispatchEvent(new DialogueScriptEvent(ScriptEvent.DIALOGUE_START, this, true));
}
/**
* Dispatch an event to attempt to advance the conversation.
* This is done once at the start of the conversation, and once whenever the user presses CONFIRM to advance the conversation.
*
* The broadcast event may be cancelled by modules or ScriptedConversations. This will prevent the conversation from actually advancing.
* This is useful if you want to manually play an animation or something.
*/
public function advanceConversation():Void
{
switch (state)
{
case ConversationState.Start:
dispatchEvent(new DialogueScriptEvent(ScriptEvent.DIALOGUE_START, this, true));
case ConversationState.Opening:
dispatchEvent(new DialogueScriptEvent(ScriptEvent.DIALOGUE_COMPLETE_LINE, this, true));
case ConversationState.Speaking:
dispatchEvent(new DialogueScriptEvent(ScriptEvent.DIALOGUE_COMPLETE_LINE, this, true));
case ConversationState.Idle:
dispatchEvent(new DialogueScriptEvent(ScriptEvent.DIALOGUE_LINE, this, true));
case ConversationState.Ending:
// Skip the outro.
endOutro();
default:
// Do nothing.
}
}
public function dispatchEvent(event:ScriptEvent):Void
{
var currentState:IEventHandler = cast FlxG.state;
currentState.dispatchEvent(event);
}
/**
* Reset the conversation back to the start.
*/
public function resetConversation():Void
{
// Reset the progress in the dialogue.
currentDialogueEntry = 0;
this.state = ConversationState.Start;
advanceConversation();
}
public function trySkipConversation(elapsed:Float):Void
{
if (skipTimer == null || skipTimer.animation == null) return;
if (elapsed < 0)
{
skipHeldTimer = 0.0;
}
else
{
skipHeldTimer += elapsed;
}
skipTimer.visible = skipHeldTimer >= 0.05;
skipTimer.amount = Math.min(skipHeldTimer / CONVERSATION_SKIP_TIMER, 1.0);
if (skipHeldTimer >= CONVERSATION_SKIP_TIMER)
{
skipConversation();
}
}
/**
* Dispatch an event to attempt to immediately end the conversation.
*
* The broadcast event may be cancelled by modules or ScriptedConversations. This will prevent the conversation from being cancelled.
* This is useful if you want to prevent an animation from being skipped or something.
*/
public function skipConversation():Void
{
dispatchEvent(new DialogueScriptEvent(ScriptEvent.DIALOGUE_SKIP, this, true));
}
static var outroTween:FlxTween;
public function startOutro():Void
{
switch (conversationData?.outro?.type)
{
case FADE:
var fadeTime:Float = conversationData?.outro.data.fadeTime ?? 1.0;
outroTween = FlxTween.tween(this, {alpha: 0.0}, fadeTime,
{
type: ONESHOT, // holy shit like the game no way
startDelay: 0,
onComplete: (_) -> endOutro(),
});
FlxTween.tween(this.music, {volume: 0.0}, fadeTime);
case NONE:
// Immediately clean up.
endOutro();
default:
// Immediately clean up.
endOutro();
}
}
public var completeCallback:Void->Void;
public function endOutro():Void
{
outroTween = null;
ScriptEventDispatcher.callEvent(this, new ScriptEvent(ScriptEvent.DESTROY, false));
}
/**
* Performed as the conversation starts.
*/
public function onDialogueStart(event:DialogueScriptEvent):Void
{
propagateEvent(event);
// Fade in the music and backdrop.
setupMusic();
setupBackdrop();
setupSkipTimer();
// Advance the conversation.
state = ConversationState.Opening;
showCurrentDialogueBox();
playDialogueBoxAnimation();
}
/**
* Display the next line of conversation.
*/
public function onDialogueLine(event:DialogueScriptEvent):Void
{
propagateEvent(event);
if (event.eventCanceled) return;
// Perform the actual logic to advance the conversation.
currentDialogueLine += 1;
if (currentDialogueLine >= currentDialogueLineCount)
{
// Open the next entry.
currentDialogueLine = 0;
currentDialogueEntry += 1;
if (currentDialogueEntry >= currentDialogueEntryCount)
{
dispatchEvent(new DialogueScriptEvent(ScriptEvent.DIALOGUE_END, this, false));
}
else
{
if (state == Idle)
{
showCurrentDialogueBox();
playDialogueBoxAnimation();
state = Opening;
}
}
}
else
{
// Continue the dialog with more lines.
state = Speaking;
currentDialogueBox.appendText(currentDialogueLineString);
}
}
/**
* Skip the scrolling of the next line of conversation.
*/
public function onDialogueCompleteLine(event:DialogueScriptEvent):Void
{
propagateEvent(event);
if (event.eventCanceled) return;
currentDialogueBox.skip();
}
/**
* Skip to the end of the conversation, immediately triggering the DIALOGUE_END event.
*/
public function onDialogueSkip(event:DialogueScriptEvent):Void
{
propagateEvent(event);
if (event.eventCanceled) return;
dispatchEvent(new DialogueScriptEvent(ScriptEvent.DIALOGUE_END, this, false));
}
public function onDialogueEnd(event:DialogueScriptEvent):Void
{
propagateEvent(event);
state = Ending;
}
// Only used for events/scripts.
public function onUpdate(event:UpdateScriptEvent):Void
{
propagateEvent(event);
if (event.eventCanceled) return;
switch (state)
{
case ConversationState.Start:
// Wait for advance() to be called and DIALOGUE_LINE to be dispatched.
return;
case ConversationState.Opening:
// Backdrop animation should have started.
// Box animations should have started.
if (currentDialogueBox != null
&& (currentDialogueBox.isAnimationFinished()
|| currentDialogueBox.getCurrentAnimation() != currentDialogueEntryData?.boxAnimation))
{
// Box animations have finished.
// Start playing the speaker animation.
state = ConversationState.Speaking;
showCurrentSpeaker();
playSpeakerAnimation();
currentDialogueBox.setText(currentDialogueLineString);
}
return;
case ConversationState.Speaking:
// Speaker animation should be playing.
return;
case ConversationState.Idle:
// Waiting for user input via `advanceConversation()`.
return;
case ConversationState.Ending:
if (outroTween == null) startOutro();
return;
}
}
public function onDestroy(event:ScriptEvent):Void
{
propagateEvent(event);
if (outroTween != null) outroTween.cancel(); // Canc
outroTween = null;
this.alpha = 0.0;
if (this.music != null) this.music.stop();
this.music = null;
this.skipTimer = null;
if (currentSpeaker != null) currentSpeaker.kill();
currentSpeaker = null;
if (currentDialogueBox != null) currentDialogueBox.kill();
currentDialogueBox = null;
if (backdrop != null) backdrop.kill();
backdrop = null;
this.clear();
if (completeCallback != null) completeCallback();
}
public function onScriptEvent(event:ScriptEvent):Void
{
propagateEvent(event);
}
/**
* As this event is dispatched to the Conversation, it is also dispatched to the active speaker.
* @param event
*/
function propagateEvent(event:ScriptEvent):Void
{
if (this.currentDialogueBox != null)
{
ScriptEventDispatcher.callEvent(this.currentDialogueBox, event);
}
if (this.currentSpeaker != null)
{
ScriptEventDispatcher.callEvent(this.currentSpeaker, event);
}
}
public override function toString():String
{
return 'Conversation($conversationId)';
}
}
// Managing things with a single enum is a lot easier than a multitude of flags.
enum ConversationState
{
/**
* State hasn't been initialized yet.
*/
Start;
/**
* A dialog is animating. If the dialog is static, this may only last for one frame.
*/
Opening;
/**
* Text is scrolling and audio is playing. Speaker portrait is probably animating too.
*/
Speaking;
/**
* Text is done scrolling and game is waiting for user to open another dialog.
*/
Idle;
/**
* Fade out and leave conversation.
*/
Ending;
}

View file

@ -0,0 +1,236 @@
package funkin.play.cutscene.dialogue;
import funkin.util.SerializerUtil;
/**
* Data about a conversation.
* Includes what speakers are in the conversation, and what phrases they say.
*/
@:jsonParse(j -> funkin.play.cutscene.dialogue.ConversationData.fromJson(j))
@:jsonStringify(v -> v.toJson())
class ConversationData
{
public var version:String;
public var backdrop:BackdropData;
public var outro:OutroData;
public var music:MusicData;
public var dialogue:Array<DialogueEntryData>;
public function new(version:String, backdrop:BackdropData, outro:OutroData, music:MusicData, dialogue:Array<DialogueEntryData>)
{
this.version = version;
this.backdrop = backdrop;
this.outro = outro;
this.music = music;
this.dialogue = dialogue;
}
public static function fromString(i:String):ConversationData
{
if (i == null || i == '') return null;
var data:
{
version:String,
backdrop:Dynamic, // TODO: tink.Json doesn't like when these are typed
?outro:Dynamic, // TODO: tink.Json doesn't like when these are typed
?music:Dynamic, // TODO: tink.Json doesn't like when these are typed
dialogue:Array<Dynamic> // TODO: tink.Json doesn't like when these are typed
} = tink.Json.parse(i);
return fromJson(data);
}
public static function fromJson(j:Dynamic):ConversationData
{
// TODO: Check version and perform migrations if necessary.
if (j == null) return null;
return new ConversationData(j.version, BackdropData.fromJson(j.backdrop), OutroData.fromJson(j.outro), MusicData.fromJson(j.music),
j.dialogue.map(d -> DialogueEntryData.fromJson(d)));
}
public function toJson():Dynamic
{
return {
version: this.version,
backdrop: this.backdrop.toJson(),
dialogue: this.dialogue.map(d -> d.toJson())
};
}
}
/**
* Data about a single dialogue entry.
*/
@:jsonParse(j -> funkin.play.cutscene.dialogue.ConversationData.DialogueEntryData.fromJson(j))
@:jsonStringify(v -> v.toJson())
class DialogueEntryData
{
/**
* The speaker who says this phrase.
*/
public var speaker:String;
/**
* The animation the speaker will play.
*/
public var speakerAnimation:String;
/**
* The text box that will appear.
*/
public var box:String;
/**
* The animation the dialogue box will play.
*/
public var boxAnimation:String;
/**
* The lines of text that will appear in the text box.
*/
public var text:Array<String>;
/**
* The relative speed at which the text will scroll.
* @default 1.0
*/
public var speed:Float = 1.0;
public function new(speaker:String, speakerAnimation:String, box:String, boxAnimation:String, text:Array<String>, speed:Float = null)
{
this.speaker = speaker;
this.speakerAnimation = speakerAnimation;
this.box = box;
this.boxAnimation = boxAnimation;
this.text = text;
if (speed != null) this.speed = speed;
}
public static function fromJson(j:Dynamic):DialogueEntryData
{
if (j == null) return null;
return new DialogueEntryData(j.speaker, j.speakerAnimation, j.box, j.boxAnimation, j.text, j.speed);
}
public function toJson():Dynamic
{
var result:Dynamic =
{
speaker: this.speaker,
speakerAnimation: this.speakerAnimation,
box: this.box,
boxAnimation: this.boxAnimation,
text: this.text,
};
if (this.speed != 1.0) result.speed = this.speed;
return result;
}
}
/**
* Data about a backdrop.
*/
@:jsonParse(j -> funkin.play.cutscene.dialogue.ConversationData.BackdropData.fromJson(j))
@:jsonStringify(v -> v.toJson())
class BackdropData
{
public var type:BackdropType;
public var data:Dynamic;
public function new(typeStr:String, data:Dynamic)
{
this.type = typeStr;
this.data = data;
}
public static function fromJson(j:Dynamic):BackdropData
{
if (j == null) return null;
return new BackdropData(j.type, j.data);
}
public function toJson():Dynamic
{
return {
type: this.type,
data: this.data
};
}
}
enum abstract BackdropType(String) from String to String
{
public var SOLID:BackdropType = 'solid';
}
/**
* Data about a music track.
*/
@:jsonParse(j -> funkin.play.cutscene.dialogue.ConversationData.MusicData.fromJson(j))
@:jsonStringify(v -> v.toJson())
class MusicData
{
public var asset:String;
public var looped:Bool;
public var fadeTime:Float;
public function new(asset:String, looped:Bool, fadeTime:Float = 0.0)
{
this.asset = asset;
this.looped = looped;
this.fadeTime = fadeTime;
}
public static function fromJson(j:Dynamic):MusicData
{
if (j == null) return null;
return new MusicData(j.asset, j.looped, j.fadeTime);
}
public function toJson():Dynamic
{
return {
asset: this.asset,
looped: this.looped,
fadeTime: this.fadeTime
};
}
}
/**
* Data about an outro.
*/
@:jsonParse(j -> funkin.play.cutscene.dialogue.ConversationData.OutroData.fromJson(j))
@:jsonStringify(v -> v.toJson())
class OutroData
{
public var type:OutroType;
public var data:Dynamic;
public function new(typeStr:Null<String>, data:Dynamic)
{
this.type = typeStr ?? OutroType.NONE;
this.data = data;
}
public static function fromJson(j:Dynamic):OutroData
{
if (j == null) return null;
return new OutroData(j.type, j.data);
}
public function toJson():Dynamic
{
return {
type: this.type,
data: this.data
};
}
}
enum abstract OutroType(String) from String to String
{
public var NONE:OutroType = 'none';
public var FADE:OutroType = 'fade';
}

View file

@ -0,0 +1,162 @@
package funkin.play.cutscene.dialogue;
import openfl.Assets;
import funkin.util.assets.DataAssets;
import funkin.play.cutscene.dialogue.ScriptedConversation;
/**
* Contains utilities for loading and parsing conversation data.
*/
class ConversationDataParser
{
public static final CONVERSATION_DATA_VERSION:String = '1.0.0';
public static final CONVERSATION_DATA_VERSION_RULE:String = '1.0.x';
static final conversationCache:Map<String, Conversation> = new Map<String, Conversation>();
static final conversationScriptedClass:Map<String, String> = new Map<String, String>();
static final DEFAULT_CONVERSATION_ID:String = 'UNKNOWN';
/**
* Parses and preloads the game's conversation data and scripts when the game starts.
*
* If you want to force conversations to be reloaded, you can just call this function again.
*/
public static function loadConversationCache():Void
{
clearConversationCache();
trace('Loading dialogue conversation cache...');
//
// SCRIPTED CONVERSATIONS
//
var scriptedConversationClassNames:Array<String> = ScriptedConversation.listScriptClasses();
trace(' Instantiating ${scriptedConversationClassNames.length} scripted conversations...');
for (conversationCls in scriptedConversationClassNames)
{
var conversation:Conversation = ScriptedConversation.init(conversationCls, DEFAULT_CONVERSATION_ID);
if (conversation != null)
{
trace(' Loaded scripted conversation: ${conversationCls}');
// Disable the rendering logic for conversation until it's loaded.
// Note that kill() =/= destroy()
conversation.kill();
// Then store it.
conversationCache.set(conversation.conversationId, conversation);
}
else
{
trace(' Failed to instantiate scripted conversation class: ${conversationCls}');
}
}
//
// UNSCRIPTED CONVERSATIONS
//
// Scripts refers to code here, not the actual dialogue.
var conversationIdList:Array<String> = DataAssets.listDataFilesInPath('dialogue/conversations/');
// Filter out conversations that are scripted.
var unscriptedConversationIds:Array<String> = conversationIdList.filter(function(conversationId:String):Bool {
return !conversationCache.exists(conversationId);
});
trace(' Fetching data for ${unscriptedConversationIds.length} conversations...');
for (conversationId in unscriptedConversationIds)
{
try
{
var conversation:Conversation = new Conversation(conversationId);
// Say something offensive to kill the conversation.
// We will revive it later.
conversation.kill();
if (conversation != null)
{
trace(' Loaded conversation data: ${conversation.conversationId}');
conversationCache.set(conversation.conversationId, conversation);
}
}
catch (e)
{
trace(e);
continue;
}
}
}
/**
* Fetches data for a conversation and returns a Conversation instance,
* ready to be displayed.
* @param conversationId The ID of the conversation to fetch.
* @return The conversation instance, or null if the conversation was not found.
*/
public static function fetchConversation(conversationId:String):Null<Conversation>
{
if (conversationId != null && conversationId != '' && conversationCache.exists(conversationId))
{
trace('Successfully fetched conversation: ${conversationId}');
var conversation:Conversation = conversationCache.get(conversationId);
// ...ANYway...
conversation.revive();
return conversation;
}
else
{
trace('Failed to fetch conversation, not found in cache: ${conversationId}');
return null;
}
}
static function clearConversationCache():Void
{
if (conversationCache != null)
{
for (conversation in conversationCache)
{
conversation.destroy();
}
conversationCache.clear();
}
}
public static function listConversationIds():Array<String>
{
return conversationCache.keys().array();
}
/**
* Load a conversation's JSON file, parse its data, and return it.
*
* @param conversationId The conversation to load.
* @return The conversation data, or null if validation failed.
*/
public static function parseConversationData(conversationId:String):Null<ConversationData>
{
trace('Parsing conversation data: ${conversationId}');
var rawJson:String = loadConversationFile(conversationId);
try
{
var conversationData:ConversationData = ConversationData.fromString(rawJson);
return conversationData;
}
catch (e)
{
trace('Failed to parse conversation ($conversationId).');
trace(e);
return null;
}
}
static function loadConversationFile(conversationPath:String):String
{
var conversationFilePath:String = Paths.json('dialogue/conversations/${conversationPath}');
var rawJson:String = Assets.getText(conversationFilePath).trim();
while (!rawJson.endsWith('}') && rawJson.length > 0)
{
rawJson = rawJson.substr(0, rawJson.length - 1);
}
return rawJson;
}
}

View file

@ -0,0 +1,61 @@
package funkin.play.cutscene.dialogue;
import flixel.FlxState;
import funkin.modding.events.ScriptEventDispatcher;
import funkin.modding.events.ScriptEvent;
import flixel.util.FlxColor;
import funkin.Paths;
/**
* A state with displays a conversation with no background.
* Used for testing.
* @param conversationId The conversation to display.
*/
class ConversationDebugState extends MusicBeatState
{
final conversationId:String = 'senpai';
var conversation:Conversation;
public function new()
{
super();
// TODO: Fix this BS
Paths.setCurrentLevel('week6');
}
public override function create():Void
{
conversation = ConversationDataParser.fetchConversation(conversationId);
conversation.completeCallback = onConversationComplete;
add(conversation);
ScriptEventDispatcher.callEvent(conversation, new ScriptEvent(ScriptEvent.CREATE, false));
}
function onConversationComplete():Void
{
remove(conversation);
conversation = null;
}
public override function update(elapsed:Float):Void
{
super.update(elapsed);
if (conversation != null)
{
if (controls.CUTSCENE_ADVANCE) conversation.advanceConversation();
if (controls.CUTSCENE_SKIP)
{
conversation.trySkipConversation(elapsed);
}
else
{
conversation.trySkipConversation(-1);
}
}
}
}

View file

@ -0,0 +1,377 @@
package funkin.play.cutscene.dialogue;
import flixel.FlxSprite;
import flixel.group.FlxSpriteGroup;
import flixel.graphics.frames.FlxFramesCollection;
import flixel.text.FlxText;
import flixel.addons.text.FlxTypeText;
import funkin.util.assets.FlxAnimationUtil;
import funkin.modding.events.ScriptEvent;
import funkin.modding.IScriptedClass.IDialogueScriptedClass;
import flixel.util.FlxColor;
class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass
{
public final dialogueBoxId:String;
public var dialogueBoxName(get, null):String;
function get_dialogueBoxName():String
{
return boxData?.name ?? 'UNKNOWN';
}
var boxData:DialogueBoxData;
/**
* Offset the speaker's sprite by this much when playing each animation.
*/
var animationOffsets:Map<String, Array<Float>> = new Map<String, Array<Float>>();
/**
* The current animation offset being used.
*/
var animOffsets(default, set):Array<Float> = [0, 0];
function set_animOffsets(value:Array<Float>):Array<Float>
{
if (animOffsets == null) animOffsets = [0, 0];
if ((animOffsets[0] == value[0]) && (animOffsets[1] == value[1])) return value;
var xDiff:Float = value[0] - animOffsets[0];
var yDiff:Float = value[1] - animOffsets[1];
this.x += xDiff;
this.y += yDiff;
return animOffsets = value;
}
/**
* The offset of the speaker overall.
*/
public var globalOffsets(default, set):Array<Float> = [0, 0];
function set_globalOffsets(value:Array<Float>):Array<Float>
{
if (globalOffsets == null) globalOffsets = [0, 0];
if (globalOffsets == value) return value;
var xDiff:Float = value[0] - globalOffsets[0];
var yDiff:Float = value[1] - globalOffsets[1];
this.x += xDiff;
this.y += yDiff;
return globalOffsets = value;
}
var boxSprite:FlxSprite;
var textDisplay:FlxTypeText;
var text(default, set):String;
function set_text(value:String):String
{
this.text = value;
textDisplay.resetText(this.text);
textDisplay.start();
return this.text;
}
public var speed(default, set):Float;
function set_speed(value:Float):Float
{
this.speed = value;
textDisplay.delay = this.speed * 0.05; // 1.0 x 0.05
return this.speed;
}
public function new(dialogueBoxId:String)
{
super();
this.dialogueBoxId = dialogueBoxId;
this.boxData = DialogueBoxDataParser.parseDialogueBoxData(this.dialogueBoxId);
if (boxData == null) throw 'Could not load dialogue box data for box ID "$dialogueBoxId"';
}
public function onCreate(event:ScriptEvent):Void
{
this.globalOffsets = [0, 0];
this.x = 0;
this.y = 0;
this.alpha = 1;
this.boxSprite = new FlxSprite(0, 0);
add(this.boxSprite);
loadSpritesheet();
loadAnimations();
loadText();
}
function loadSpritesheet():Void
{
trace('[DIALOGUE BOX] Loading spritesheet ${boxData.assetPath} for ${dialogueBoxId}');
var tex:FlxFramesCollection = Paths.getSparrowAtlas(boxData.assetPath);
if (tex == null)
{
trace('Could not load Sparrow sprite: ${boxData.assetPath}');
return;
}
this.boxSprite.frames = tex;
if (boxData.isPixel)
{
this.boxSprite.antialiasing = false;
}
else
{
this.boxSprite.antialiasing = true;
}
this.flipX = boxData.flipX;
this.globalOffsets = boxData.offsets;
this.setScale(boxData.scale);
}
public function setText(newText:String):Void
{
textDisplay.prefix = '';
textDisplay.resetText(newText);
textDisplay.start();
}
public function appendText(newText:String):Void
{
textDisplay.prefix = this.textDisplay.text;
textDisplay.resetText(newText);
textDisplay.start();
}
public function skip():Void
{
textDisplay.skip();
}
/**
* Reassign this to set a callback.
*/
function onTypingComplete():Void
{
// No save navigation? :(
if (typingCompleteCallback != null) typingCompleteCallback();
}
public var typingCompleteCallback:() -> Void;
/**
* Set the sprite scale to the appropriate value.
* @param scale
*/
public function setScale(scale:Null<Float>):Void
{
if (scale == null) scale = 1.0;
this.boxSprite.scale.x = scale;
this.boxSprite.scale.y = scale;
this.boxSprite.updateHitbox();
}
function loadAnimations():Void
{
trace('[DIALOGUE BOX] Loading ${boxData.animations.length} animations for ${dialogueBoxId}');
FlxAnimationUtil.addAtlasAnimations(this.boxSprite, boxData.animations);
for (anim in boxData.animations)
{
if (anim.offsets == null)
{
setAnimationOffsets(anim.name, 0, 0);
}
else
{
setAnimationOffsets(anim.name, anim.offsets[0], anim.offsets[1]);
}
}
var animNames:Array<String> = this.boxSprite?.animation?.getNameList() ?? [];
trace('[DIALOGUE BOX] Successfully loaded ${animNames.length} animations for ${dialogueBoxId}');
boxSprite.animation.callback = this.onAnimationFrame;
boxSprite.animation.finishCallback = this.onAnimationFinished;
}
/**
* Called when an animation finishes.
* @param name The name of the animation that just finished.
*/
function onAnimationFinished(name:String):Void {}
/**
* Called when the current animation's frame changes.
* @param name The name of the current animation.
* @param frameNumber The number of the current frame.
* @param frameIndex The index of the current frame.
*
* For example, if an animation was defined as having the indexes [3, 0, 1, 2],
* then the first callback would have frameNumber = 0 and frameIndex = 3.
*/
function onAnimationFrame(name:String = "", frameNumber:Int = -1, frameIndex:Int = -1):Void
{
// Do nothing by default.
// This can be overridden by, for example, scripts,
// or by calling `animationFrame.add()`.
// Try not to do anything expensive here, it runs many times a second.
}
function loadText():Void
{
textDisplay = new FlxTypeText(0, 0, 300, '', 32);
textDisplay.fieldWidth = boxData.text.width;
textDisplay.setFormat('Pixel Arial 11 Bold', boxData.text.size, FlxColor.fromString(boxData.text.color), LEFT, SHADOW,
FlxColor.fromString(boxData.text.shadowColor ?? '#00000000'), false);
textDisplay.borderSize = boxData.text.shadowWidth ?? 2;
textDisplay.sounds = [FlxG.sound.load(Paths.sound('pixelText'), 0.6)];
textDisplay.completeCallback = onTypingComplete;
textDisplay.x += boxData.text.offsets[0];
textDisplay.y += boxData.text.offsets[1];
add(textDisplay);
}
/**
* @param name The name of the animation to play.
* @param restart Whether to restart the animation if it is already playing.
* @param reversed If true, play the animation backwards, from the last frame to the first.
*/
public function playAnimation(name:String, restart:Bool = false, ?reversed:Bool = false):Void
{
var correctName:String = correctAnimationName(name);
if (correctName == null) return;
this.boxSprite.animation.play(correctName, restart, false, 0);
applyAnimationOffsets(correctName);
}
/**
* Ensure that a given animation exists before playing it.
* Will gracefully check for name, then name with stripped suffixes, then 'idle', then fail to play.
* @param name
*/
function correctAnimationName(name:String):String
{
// If the animation exists, we're good.
if (hasAnimation(name)) return name;
trace('[DIALOGUE BOX] Animation "$name" does not exist!');
// Attempt to strip a `-alt` suffix, if it exists.
if (name.lastIndexOf('-') != -1)
{
var correctName = name.substring(0, name.lastIndexOf('-'));
trace('[DIALOGUE BOX] Attempting to fallback to "$correctName"');
return correctAnimationName(correctName);
}
else
{
if (name != 'idle')
{
trace('[DIALOGUE BOX] Attempting to fallback to "idle"');
return correctAnimationName('idle');
}
else
{
trace('[DIALOGUE BOX] Failing animation playback.');
return null;
}
}
}
public function hasAnimation(id:String):Bool
{
if (this.boxSprite.animation == null) return false;
return this.boxSprite.animation.getByName(id) != null;
}
/**
* Returns the name of the animation that is currently playing.
* If no animation is playing (usually this means the character is BROKEN!),
* returns an empty string to prevent NPEs.
*/
public function getCurrentAnimation():String
{
if (this.animation == null || this.animation.curAnim == null) return "";
return this.animation.curAnim.name;
}
/**
* Define the animation offsets for a specific animation.
*/
public function setAnimationOffsets(name:String, xOffset:Float, yOffset:Float):Void
{
animationOffsets.set(name, [xOffset, yOffset]);
}
/**
* Retrieve an apply the animation offsets for a specific animation.
*/
function applyAnimationOffsets(name:String):Void
{
var offsets:Array<Float> = animationOffsets.get(name);
if (offsets != null && !(offsets[0] == 0 && offsets[1] == 0))
{
this.animOffsets = offsets;
}
else
{
this.animOffsets = [0, 0];
}
}
public function isAnimationFinished():Bool
{
return this.boxSprite?.animation?.finished ?? false;
}
public function onDialogueStart(event:DialogueScriptEvent):Void {}
public function onDialogueCompleteLine(event:DialogueScriptEvent):Void {}
public function onDialogueLine(event:DialogueScriptEvent):Void {}
public function onDialogueSkip(event:DialogueScriptEvent):Void {}
public function onDialogueEnd(event:DialogueScriptEvent):Void {}
public function onUpdate(event:UpdateScriptEvent):Void {}
public function onDestroy(event:ScriptEvent):Void
{
if (boxSprite != null) remove(boxSprite);
boxSprite = null;
if (textDisplay != null) remove(textDisplay);
textDisplay = null;
this.clear();
this.x = 0;
this.y = 0;
this.globalOffsets = [0, 0];
this.alpha = 0;
this.kill();
}
public function onScriptEvent(event:ScriptEvent):Void {}
}

View file

@ -0,0 +1,123 @@
package funkin.play.cutscene.dialogue;
import funkin.util.SerializerUtil;
/**
* Data about a text box.
*/
@:jsonParse(j -> funkin.play.cutscene.dialogue.DialogueBoxData.fromJson(j))
@:jsonStringify(v -> v.toJson())
class DialogueBoxData
{
public var version:String;
public var name:String;
public var assetPath:String;
public var flipX:Bool;
public var flipY:Bool;
public var isPixel:Bool;
public var offsets:Array<Float>;
public var text:DialogueBoxTextData;
public var scale:Float;
public var animations:Array<AnimationData>;
public function new(version:String, name:String, assetPath:String, flipX:Bool = false, flipY:Bool = false, isPixel:Bool = false, offsets:Null<Array<Float>>,
text:DialogueBoxTextData, scale:Float = 1.0, animations:Array<AnimationData>)
{
this.version = version;
this.name = name;
this.assetPath = assetPath;
this.flipX = flipX;
this.flipY = flipY;
this.isPixel = isPixel;
this.offsets = offsets ?? [0, 0];
this.text = text;
this.scale = scale;
this.animations = animations;
}
public static function fromString(i:String):DialogueBoxData
{
if (i == null || i == '') return null;
var data:
{
version:String,
name:String,
assetPath:String,
flipX:Bool,
flipY:Bool,
isPixel:Bool,
?offsets:Array<Float>,
text:Dynamic,
scale:Float,
animations:Array<AnimationData>
} = tink.Json.parse(i);
return fromJson(data);
}
public static function fromJson(j:Dynamic):DialogueBoxData
{
// TODO: Check version and perform migrations if necessary.
if (j == null) return null;
return new DialogueBoxData(j.version, j.name, j.assetPath, j.flipX, j.flipY, j.isPixel, j.offsets, DialogueBoxTextData.fromJson(j.text), j.scale,
j.animations);
}
public function toJson():Dynamic
{
return {
version: this.version,
name: this.name,
assetPath: this.assetPath,
flipX: this.flipX,
flipY: this.flipY,
isPixel: this.isPixel,
offsets: this.offsets,
scale: this.scale,
animations: this.animations
};
}
}
/**
* Data about text in a text box.
*/
@:jsonParse(j -> funkin.play.cutscene.dialogue.DialogueBoxTextData.fromJson(j))
@:jsonStringify(v -> v.toJson())
class DialogueBoxTextData
{
public var offsets:Array<Float>;
public var width:Int;
public var size:Int;
public var color:String;
public var shadowColor:Null<String>;
public var shadowWidth:Null<Int>;
public function new(offsets:Null<Array<Float>>, width:Null<Int>, size:Null<Int>, color:String, shadowColor:Null<String>, shadowWidth:Null<Int>)
{
this.offsets = offsets ?? [0, 0];
this.width = width ?? 300;
this.size = size ?? 32;
this.color = color;
this.shadowColor = shadowColor;
this.shadowWidth = shadowWidth;
}
public static function fromJson(j:Dynamic):DialogueBoxTextData
{
// TODO: Check version and perform migrations if necessary.
if (j == null) return null;
return new DialogueBoxTextData(j.offsets, j.width, j.size, j.color, j.shadowColor, j.shadowWidth);
}
public function toJson():Dynamic
{
return {
offsets: this.offsets,
width: this.width,
size: this.size,
color: this.color,
shadowColor: this.shadowColor,
shadowWidth: this.shadowWidth,
};
}
}

View file

@ -0,0 +1,159 @@
package funkin.play.cutscene.dialogue;
import openfl.Assets;
import funkin.util.assets.DataAssets;
import funkin.play.cutscene.dialogue.DialogueBox;
import funkin.play.cutscene.dialogue.ScriptedDialogueBox;
/**
* Contains utilities for loading and parsing dialogueBox data.
*/
class DialogueBoxDataParser
{
public static final DIALOGUE_BOX_DATA_VERSION:String = '1.0.0';
public static final DIALOGUE_BOX_DATA_VERSION_RULE:String = '1.0.x';
static final dialogueBoxCache:Map<String, DialogueBox> = new Map<String, DialogueBox>();
static final dialogueBoxScriptedClass:Map<String, String> = new Map<String, String>();
static final DEFAULT_DIALOGUE_BOX_ID:String = 'UNKNOWN';
/**
* Parses and preloads the game's dialogueBox data and scripts when the game starts.
*
* If you want to force dialogue boxes to be reloaded, you can just call this function again.
*/
public static function loadDialogueBoxCache():Void
{
clearDialogueBoxCache();
trace('Loading dialogue box cache...');
//
// SCRIPTED CONVERSATIONS
//
var scriptedDialogueBoxClassNames:Array<String> = ScriptedDialogueBox.listScriptClasses();
trace(' Instantiating ${scriptedDialogueBoxClassNames.length} scripted dialogue boxes...');
for (dialogueBoxCls in scriptedDialogueBoxClassNames)
{
var dialogueBox:DialogueBox = ScriptedDialogueBox.init(dialogueBoxCls, DEFAULT_DIALOGUE_BOX_ID);
if (dialogueBox != null)
{
trace(' Loaded scripted dialogue box: ${dialogueBox.dialogueBoxName}');
// Disable the rendering logic for dialogueBox until it's loaded.
// Note that kill() =/= destroy()
dialogueBox.kill();
// Then store it.
dialogueBoxCache.set(dialogueBox.dialogueBoxId, dialogueBox);
}
else
{
trace(' Failed to instantiate scripted dialogueBox class: ${dialogueBoxCls}');
}
}
//
// UNSCRIPTED CONVERSATIONS
//
// Scripts refers to code here, not the actual dialogue.
var dialogueBoxIdList:Array<String> = DataAssets.listDataFilesInPath('dialogue/boxes/');
// Filter out dialogue boxes that are scripted.
var unscriptedDialogueBoxIds:Array<String> = dialogueBoxIdList.filter(function(dialogueBoxId:String):Bool {
return !dialogueBoxCache.exists(dialogueBoxId);
});
trace(' Fetching data for ${unscriptedDialogueBoxIds.length} dialogue boxes...');
for (dialogueBoxId in unscriptedDialogueBoxIds)
{
try
{
var dialogueBox:DialogueBox = new DialogueBox(dialogueBoxId);
if (dialogueBox != null)
{
trace(' Loaded dialogueBox data: ${dialogueBox.dialogueBoxName}');
dialogueBoxCache.set(dialogueBox.dialogueBoxId, dialogueBox);
}
}
catch (e)
{
trace(e);
continue;
}
}
}
/**
* Fetches data for a dialogueBox and returns a DialogueBox instance,
* ready to be displayed.
* @param dialogueBoxId The ID of the dialogueBox to fetch.
* @return The dialogueBox instance, or null if the dialogueBox was not found.
*/
public static function fetchDialogueBox(dialogueBoxId:String):Null<DialogueBox>
{
if (dialogueBoxId != null && dialogueBoxId != '' && dialogueBoxCache.exists(dialogueBoxId))
{
trace('Successfully fetched dialogueBox: ${dialogueBoxId}');
var dialogueBox:DialogueBox = dialogueBoxCache.get(dialogueBoxId);
dialogueBox.revive();
return dialogueBox;
}
else
{
trace('Failed to fetch dialogueBox, not found in cache: ${dialogueBoxId}');
return null;
}
}
static function clearDialogueBoxCache():Void
{
if (dialogueBoxCache != null)
{
for (dialogueBox in dialogueBoxCache)
{
dialogueBox.destroy();
}
dialogueBoxCache.clear();
}
}
public static function listDialogueBoxIds():Array<String>
{
return dialogueBoxCache.keys().array();
}
/**
* Load a dialogueBox's JSON file, parse its data, and return it.
*
* @param dialogueBoxId The dialogueBox to load.
* @return The dialogueBox data, or null if validation failed.
*/
public static function parseDialogueBoxData(dialogueBoxId:String):Null<DialogueBoxData>
{
var rawJson:String = loadDialogueBoxFile(dialogueBoxId);
try
{
var dialogueBoxData:DialogueBoxData = DialogueBoxData.fromString(rawJson);
return dialogueBoxData;
}
catch (e)
{
trace('Failed to parse dialogueBox ($dialogueBoxId).');
trace(e);
return null;
}
}
static function loadDialogueBoxFile(dialogueBoxPath:String):String
{
var dialogueBoxFilePath:String = Paths.json('dialogue/boxes/${dialogueBoxPath}');
var rawJson:String = Assets.getText(dialogueBoxFilePath).trim();
while (!rawJson.endsWith('}') && rawJson.length > 0)
{
rawJson = rawJson.substr(0, rawJson.length - 1);
}
return rawJson;
}
}

View file

@ -0,0 +1,4 @@
package funkin.play.cutscene.dialogue;
@:hscriptClass
class ScriptedConversation extends Conversation implements polymod.hscript.HScriptedClass {}

View file

@ -0,0 +1,4 @@
package funkin.play.cutscene.dialogue;
@:hscriptClass
class ScriptedDialogueBox extends DialogueBox implements polymod.hscript.HScriptedClass {}

View file

@ -0,0 +1,4 @@
package funkin.play.cutscene.dialogue;
@:hscriptClass
class ScriptedSpeaker extends Speaker implements polymod.hscript.HScriptedClass {}

View file

@ -0,0 +1,274 @@
package funkin.play.cutscene.dialogue;
import flixel.FlxSprite;
import funkin.modding.events.ScriptEvent;
import flixel.graphics.frames.FlxFramesCollection;
import funkin.util.assets.FlxAnimationUtil;
import funkin.modding.IScriptedClass.IDialogueScriptedClass;
/**
* The character sprite which displays during dialogue.
*
* Most conversations have two speakers, with one being flipped.
*/
class Speaker extends FlxSprite implements IDialogueScriptedClass
{
/**
* The internal ID for this speaker.
*/
public final speakerId:String;
/**
* The full data for a speaker.
*/
var speakerData:SpeakerData;
/**
* A readable name for this speaker.
*/
public var speakerName(get, null):String;
function get_speakerName():String
{
return speakerData.name;
}
/**
* Offset the speaker's sprite by this much when playing each animation.
*/
var animationOffsets:Map<String, Array<Float>> = new Map<String, Array<Float>>();
/**
* The current animation offset being used.
*/
var animOffsets(default, set):Array<Float> = [0, 0];
function set_animOffsets(value:Array<Float>):Array<Float>
{
if (animOffsets == null) animOffsets = [0, 0];
if ((animOffsets[0] == value[0]) && (animOffsets[1] == value[1])) return value;
var xDiff:Float = value[0] - animOffsets[0];
var yDiff:Float = value[1] - animOffsets[1];
this.x += xDiff;
this.y += yDiff;
return animOffsets = value;
}
/**
* The offset of the speaker overall.
*/
public var globalOffsets(default, set):Array<Float> = [0, 0];
function set_globalOffsets(value:Array<Float>):Array<Float>
{
if (globalOffsets == null) globalOffsets = [0, 0];
if (globalOffsets == value) return value;
var xDiff:Float = value[0] - globalOffsets[0];
var yDiff:Float = value[1] - globalOffsets[1];
this.x += xDiff;
this.y += yDiff;
return globalOffsets = value;
}
public function new(speakerId:String)
{
super();
this.speakerId = speakerId;
this.speakerData = SpeakerDataParser.parseSpeakerData(this.speakerId);
if (speakerData == null) throw 'Could not load speaker data for speaker ID "$speakerId"';
}
/**
* Called when speaker is being created.
* @param event The script event.
*/
public function onCreate(event:ScriptEvent):Void
{
this.globalOffsets = [0, 0];
this.x = 0;
this.y = 0;
this.alpha = 1;
loadSpritesheet();
loadAnimations();
}
function loadSpritesheet():Void
{
trace('[SPEAKER] Loading spritesheet ${speakerData.assetPath} for ${speakerId}');
var tex:FlxFramesCollection = Paths.getSparrowAtlas(speakerData.assetPath);
if (tex == null)
{
trace('Could not load Sparrow sprite: ${speakerData.assetPath}');
return;
}
this.frames = tex;
if (speakerData.isPixel)
{
this.antialiasing = false;
}
else
{
this.antialiasing = true;
}
this.flipX = speakerData.flipX;
this.globalOffsets = speakerData.offsets;
this.setScale(speakerData.scale);
}
/**
* Set the sprite scale to the appropriate value.
* @param scale
*/
public function setScale(scale:Null<Float>):Void
{
if (scale == null) scale = 1.0;
this.scale.x = scale;
this.scale.y = scale;
this.updateHitbox();
}
function loadAnimations():Void
{
trace('[SPEAKER] Loading ${speakerData.animations.length} animations for ${speakerId}');
FlxAnimationUtil.addAtlasAnimations(this, speakerData.animations);
for (anim in speakerData.animations)
{
if (anim.offsets == null)
{
setAnimationOffsets(anim.name, 0, 0);
}
else
{
setAnimationOffsets(anim.name, anim.offsets[0], anim.offsets[1]);
}
}
var animNames:Array<String> = this.animation.getNameList();
trace('[SPEAKER] Successfully loaded ${animNames.length} animations for ${speakerId}');
}
/**
* @param name The name of the animation to play.
* @param restart Whether to restart the animation if it is already playing.
*/
public function playAnimation(name:String, restart:Bool = false):Void
{
var correctName:String = correctAnimationName(name);
if (correctName == null) return;
this.animation.play(correctName, restart, false, 0);
applyAnimationOffsets(correctName);
}
public function getCurrentAnimation():String
{
if (this.animation == null || this.animation.curAnim == null) return "";
return this.animation.curAnim.name;
}
/**
* Ensure that a given animation exists before playing it.
* Will gracefully check for name, then name with stripped suffixes, then 'idle', then fail to play.
* @param name
*/
function correctAnimationName(name:String):String
{
// If the animation exists, we're good.
if (hasAnimation(name)) return name;
trace('[BOPPER] Animation "$name" does not exist!');
// Attempt to strip a `-alt` suffix, if it exists.
if (name.lastIndexOf('-') != -1)
{
var correctName = name.substring(0, name.lastIndexOf('-'));
trace('[BOPPER] Attempting to fallback to "$correctName"');
return correctAnimationName(correctName);
}
else
{
if (name != 'idle')
{
trace('[BOPPER] Attempting to fallback to "idle"');
return correctAnimationName('idle');
}
else
{
trace('[BOPPER] Failing animation playback.');
return null;
}
}
}
public function hasAnimation(id:String):Bool
{
if (this.animation == null) return false;
return this.animation.getByName(id) != null;
}
/**
* Define the animation offsets for a specific animation.
*/
public function setAnimationOffsets(name:String, xOffset:Float, yOffset:Float):Void
{
animationOffsets.set(name, [xOffset, yOffset]);
}
/**
* Retrieve an apply the animation offsets for a specific animation.
*/
function applyAnimationOffsets(name:String):Void
{
var offsets:Array<Float> = animationOffsets.get(name);
if (offsets != null && !(offsets[0] == 0 && offsets[1] == 0))
{
this.animOffsets = offsets;
}
else
{
this.animOffsets = [0, 0];
}
}
public function onDialogueStart(event:DialogueScriptEvent):Void {}
public function onDialogueCompleteLine(event:DialogueScriptEvent):Void {}
public function onDialogueLine(event:DialogueScriptEvent):Void {}
public function onDialogueSkip(event:DialogueScriptEvent):Void {}
public function onDialogueEnd(event:DialogueScriptEvent):Void {}
public function onUpdate(event:UpdateScriptEvent):Void {}
public function onDestroy(event:ScriptEvent):Void
{
frames = null;
this.x = 0;
this.y = 0;
this.globalOffsets = [0, 0];
this.alpha = 0;
this.kill();
}
public function onScriptEvent(event:ScriptEvent):Void {}
}

View file

@ -0,0 +1,76 @@
package funkin.play.cutscene.dialogue;
/**
* Data about a conversation.
* Includes what speakers are in the conversation, and what phrases they say.
*/
@:jsonParse(j -> funkin.play.cutscene.dialogue.SpeakerData.fromJson(j))
@:jsonStringify(v -> v.toJson())
class SpeakerData
{
public var version:String;
public var name:String;
public var assetPath:String;
public var flipX:Bool;
public var isPixel:Bool;
public var offsets:Array<Float>;
public var scale:Float;
public var animations:Array<AnimationData>;
public function new(version:String, name:String, assetPath:String, animations:Array<AnimationData>, ?offsets:Array<Float>, ?flipX:Bool = false,
?isPixel:Bool = false, ?scale:Float = 1.0)
{
this.version = version;
this.name = name;
this.assetPath = assetPath;
this.animations = animations;
this.offsets = offsets;
if (this.offsets == null || this.offsets == []) this.offsets = [0, 0];
this.flipX = flipX;
this.isPixel = isPixel;
this.scale = scale;
}
public static function fromString(i:String):SpeakerData
{
if (i == null || i == '') return null;
var data:
{
version:String,
name:String,
assetPath:String,
animations:Array<AnimationData>,
?offsets:Array<Float>,
?flipX:Bool,
?isPixel:Bool,
?scale:Float
} = tink.Json.parse(i);
return fromJson(data);
}
public static function fromJson(j:Dynamic):SpeakerData
{
// TODO: Check version and perform migrations if necessary.
if (j == null) return null;
return new SpeakerData(j.version, j.name, j.assetPath, j.animations, j.offsets, j.flipX, j.isPixel, j.scale);
}
public function toJson():Dynamic
{
var result:Dynamic =
{
version: this.version,
name: this.name,
assetPath: this.assetPath,
animations: this.animations,
flipX: this.flipX,
isPixel: this.isPixel
};
if (this.scale != 1.0) result.scale = this.scale;
return result;
}
}

View file

@ -0,0 +1,159 @@
package funkin.play.cutscene.dialogue;
import openfl.Assets;
import funkin.util.assets.DataAssets;
import funkin.play.cutscene.dialogue.Speaker;
import funkin.play.cutscene.dialogue.ScriptedSpeaker;
/**
* Contains utilities for loading and parsing speaker data.
*/
class SpeakerDataParser
{
public static final SPEAKER_DATA_VERSION:String = '1.0.0';
public static final SPEAKER_DATA_VERSION_RULE:String = '1.0.x';
static final speakerCache:Map<String, Speaker> = new Map<String, Speaker>();
static final speakerScriptedClass:Map<String, String> = new Map<String, String>();
static final DEFAULT_SPEAKER_ID:String = 'UNKNOWN';
/**
* Parses and preloads the game's speaker data and scripts when the game starts.
*
* If you want to force speakers to be reloaded, you can just call this function again.
*/
public static function loadSpeakerCache():Void
{
clearSpeakerCache();
trace('Loading dialogue speaker cache...');
//
// SCRIPTED CONVERSATIONS
//
var scriptedSpeakerClassNames:Array<String> = ScriptedSpeaker.listScriptClasses();
trace(' Instantiating ${scriptedSpeakerClassNames.length} scripted speakers...');
for (speakerCls in scriptedSpeakerClassNames)
{
var speaker:Speaker = ScriptedSpeaker.init(speakerCls, DEFAULT_SPEAKER_ID);
if (speaker != null)
{
trace(' Loaded scripted speaker: ${speaker.speakerName}');
// Disable the rendering logic for speaker until it's loaded.
// Note that kill() =/= destroy()
speaker.kill();
// Then store it.
speakerCache.set(speaker.speakerId, speaker);
}
else
{
trace(' Failed to instantiate scripted speaker class: ${speakerCls}');
}
}
//
// UNSCRIPTED CONVERSATIONS
//
// Scripts refers to code here, not the actual dialogue.
var speakerIdList:Array<String> = DataAssets.listDataFilesInPath('dialogue/speakers/');
// Filter out speakers that are scripted.
var unscriptedSpeakerIds:Array<String> = speakerIdList.filter(function(speakerId:String):Bool {
return !speakerCache.exists(speakerId);
});
trace(' Fetching data for ${unscriptedSpeakerIds.length} speakers...');
for (speakerId in unscriptedSpeakerIds)
{
try
{
var speaker:Speaker = new Speaker(speakerId);
if (speaker != null)
{
trace(' Loaded speaker data: ${speaker.speakerName}');
speakerCache.set(speaker.speakerId, speaker);
}
}
catch (e)
{
trace(e);
continue;
}
}
}
/**
* Fetches data for a speaker and returns a Speaker instance,
* ready to be displayed.
* @param speakerId The ID of the speaker to fetch.
* @return The speaker instance, or null if the speaker was not found.
*/
public static function fetchSpeaker(speakerId:String):Null<Speaker>
{
if (speakerId != null && speakerId != '' && speakerCache.exists(speakerId))
{
trace('Successfully fetched speaker: ${speakerId}');
var speaker:Speaker = speakerCache.get(speakerId);
speaker.revive();
return speaker;
}
else
{
trace('Failed to fetch speaker, not found in cache: ${speakerId}');
return null;
}
}
static function clearSpeakerCache():Void
{
if (speakerCache != null)
{
for (speaker in speakerCache)
{
speaker.destroy();
}
speakerCache.clear();
}
}
public static function listSpeakerIds():Array<String>
{
return speakerCache.keys().array();
}
/**
* Load a speaker's JSON file, parse its data, and return it.
*
* @param speakerId The speaker to load.
* @return The speaker data, or null if validation failed.
*/
public static function parseSpeakerData(speakerId:String):Null<SpeakerData>
{
var rawJson:String = loadSpeakerFile(speakerId);
try
{
var speakerData:SpeakerData = SpeakerData.fromString(rawJson);
return speakerData;
}
catch (e)
{
trace('Failed to parse speaker ($speakerId).');
trace(e);
return null;
}
}
static function loadSpeakerFile(speakerPath:String):String
{
var speakerFilePath:String = Paths.json('dialogue/speakers/${speakerPath}');
var rawJson:String = Assets.getText(speakerFilePath).trim();
while (!rawJson.endsWith('}') && rawJson.length > 0)
{
rawJson = rawJson.substr(0, rawJson.length - 1);
}
return rawJson;
}
}

View file

@ -3,6 +3,8 @@ package funkin.play.song;
import funkin.modding.events.ScriptEventDispatcher;
import funkin.modding.events.ScriptEvent;
import flixel.util.typeLimit.OneOfTwo;
import funkin.modding.events.ScriptEvent;
import funkin.modding.events.ScriptEventDispatcher;
import funkin.play.song.ScriptedSong;
import funkin.util.assets.DataAssets;
import haxe.DynamicAccess;
@ -22,6 +24,7 @@ class SongDataParser
static final DEFAULT_SONG_ID:String = 'UNKNOWN';
static final SONG_DATA_PATH:String = 'songs/';
static final MUSIC_DATA_PATH:String = 'music/';
static final SONG_DATA_SUFFIX:String = '-metadata.json';
/**
@ -184,6 +187,36 @@ class SongDataParser
return rawJson;
}
public static function parseMusicMetadata(musicId:String):SongMetadata
{
var rawJson:String = loadMusicMetadataFile(musicId);
var jsonData:Dynamic = null;
try
{
jsonData = Json.parse(rawJson);
}
catch (e) {}
var musicMetadata:SongMetadata = SongMigrator.migrateSongMetadata(jsonData, musicId);
musicMetadata = SongValidator.validateSongMetadata(musicMetadata, musicId);
return musicMetadata;
}
static function loadMusicMetadataFile(musicPath:String, variation:String = ''):String
{
var musicMetadataFilePath:String = (variation != '') ? Paths.file('$MUSIC_DATA_PATH$musicPath/$musicPath-metadata-$variation.json') : Paths.file('$MUSIC_DATA_PATH$musicPath/$musicPath-metadata.json');
var rawJson:String = Assets.getText(musicMetadataFilePath).trim();
while (!rawJson.endsWith("}"))
{
rawJson = rawJson.substr(0, rawJson.length - 1);
}
return rawJson;
}
public static function parseSongChartData(songId:String, variation:String = ''):SongChartData
{
var rawJson:String = loadSongChartDataFile(songId, variation);
@ -376,8 +409,7 @@ abstract SongNoteData(RawSongNoteData)
function get_stepTime():Float
{
// TODO: Account for changes in BPM.
return this.t / Conductor.stepLengthMs;
return Conductor.getTimeInSteps(this.t);
}
/**
@ -562,8 +594,7 @@ abstract SongEventData(RawSongEventData)
function get_stepTime():Float
{
// TODO: Account for changes in BPM.
return this.t / Conductor.stepLengthMs;
return Conductor.getTimeInSteps(this.t);
}
public var event(get, set):String;
@ -805,7 +836,7 @@ typedef RawSongTimeChange =
* Time in beats (int). The game will calculate further beat values based on this one,
* so it can do it in a simple linear fashion.
*/
var b:Int;
var b:Null<Float>;
/**
* Quarter notes per minute (float). Cannot be empty in the first element of the list,
@ -835,9 +866,9 @@ typedef RawSongTimeChange =
* Add aliases to the minimalized property names of the typedef,
* to improve readability.
*/
abstract SongTimeChange(RawSongTimeChange)
abstract SongTimeChange(RawSongTimeChange) from RawSongTimeChange
{
public function new(timeStamp:Float, beatTime:Int, bpm:Float, timeSignatureNum:Int = 4, timeSignatureDen:Int = 4, beatTuplets:Array<Int>)
public function new(timeStamp:Float, beatTime:Null<Float>, bpm:Float, timeSignatureNum:Int = 4, timeSignatureDen:Int = 4, beatTuplets:Array<Int>)
{
this =
{
@ -862,7 +893,7 @@ abstract SongTimeChange(RawSongTimeChange)
return this.t = value;
}
public var beatTime(get, set):Int;
public var beatTime(get, set):Null<Float>;
function get_beatTime():Int
{

View file

@ -2057,7 +2057,7 @@ class ChartEditorState extends HaxeUIState
{
// Handle extending the note as you drag.
// Since use Math.floor and stepCrochet here, the hold notes will be beat snapped.
// Since use Math.floor and stepLengthMs here, the hold notes will be beat snapped.
var dragLengthSteps:Float = Math.floor((cursorMs - currentPlaceNoteData.time) / Conductor.stepLengthMs);
// Without this, the newly placed note feels too short compared to the user's input.
@ -2770,7 +2770,7 @@ class ChartEditorState extends HaxeUIState
}
}
override function dispatchEvent(event:ScriptEvent):Void
public override function dispatchEvent(event:ScriptEvent):Void
{
super.dispatchEvent(event);

View file

@ -15,6 +15,7 @@ import funkin.modding.events.ScriptEventDispatcher;
import funkin.play.PlayState;
import funkin.play.PlayStatePlaylist;
import funkin.play.song.Song;
import funkin.play.song.SongData.SongMetadata;
import funkin.play.song.SongData.SongDataParser;
import funkin.util.Constants;
@ -115,12 +116,7 @@ class StoryMenuState extends MusicBeatState
transIn = FlxTransitionableState.defaultTransIn;
transOut = FlxTransitionableState.defaultTransOut;
if (!FlxG.sound.music.playing)
{
FlxG.sound.playMusic(Paths.music('freakyMenu'));
FlxG.sound.music.fadeIn(4, 0, 0.7);
}
Conductor.forceBPM(Constants.FREAKY_MENU_BPM);
playMenuMusic();
if (stickerSubState != null)
{
@ -129,8 +125,6 @@ class StoryMenuState extends MusicBeatState
openSubState(stickerSubState);
stickerSubState.degenStickers();
// resetSubState();
}
persistentUpdate = persistentDraw = true;
@ -203,6 +197,18 @@ class StoryMenuState extends MusicBeatState
#end
}
function playMenuMusic():Void
{
if (FlxG.sound.music == null || !FlxG.sound.music.playing)
{
var freakyMenuMetadata:SongMetadata = SongDataParser.parseMusicMetadata('freakyMenu');
Conductor.mapTimeChanges(freakyMenuMetadata.timeChanges);
FlxG.sound.playMusic(Paths.music('freakyMenu/freakyMenu'), 0);
FlxG.sound.music.fadeIn(4, 0, 0.7);
}
}
function updateData():Void
{
currentLevel = LevelRegistry.instance.fetchEntry(currentLevelId);
@ -459,7 +465,7 @@ class StoryMenuState extends MusicBeatState
}
}
override function dispatchEvent(event:ScriptEvent):Void
public override function dispatchEvent(event:ScriptEvent):Void
{
// super.dispatchEvent(event) dispatches event to module scripts.
super.dispatchEvent(event);

View file

@ -118,27 +118,72 @@ class Constants
public static final DEFAULT_SONG:String = 'tutorial';
/**
* OTHER
* TIMING
*/
// ==============================
/**
* The number of seconds in a minute.
*/
public static final SECS_PER_MIN:Int = 60;
/**
* The number of milliseconds in a second.
*/
public static final MS_PER_SEC:Int = 1000;
/**
* The number of microseconds in a millisecond.
*/
public static final US_PER_MS:Int = 1000;
/**
* The number of microseconds in a second.
*/
public static final US_PER_SEC:Int = US_PER_MS * MS_PER_SEC;
/**
* The number of nanoseconds in a microsecond.
*/
public static final NS_PER_US:Int = 1000;
/**
* The number of nanoseconds in a millisecond.
*/
public static final NS_PER_MS:Int = NS_PER_US * US_PER_MS;
/**
* The number of nanoseconds in a second.
*/
public static final NS_PER_SEC:Int = NS_PER_US * US_PER_MS * MS_PER_SEC;
/**
* All MP3 decoders introduce a playback delay of `528` samples,
* which at 44,100 Hz (samples per second) is ~12 ms.
*/
public static final MP3_DELAY_MS:Float = 528 / 44100 * 1000;
public static final MP3_DELAY_MS:Float = 528 / 44100 * MS_PER_SEC;
/**
* The default BPM of the conductor.
*/
public static final DEFAULT_BPM:Float = 100.0;
public static final DEFAULT_TIME_SIGNATURE_NUM:Int = 4;
public static final DEFAULT_TIME_SIGNATURE_DEN:Int = 4;
public static final STEPS_PER_BEAT:Int = 4;
/**
* OTHER
*/
// ==============================
/**
* The scale factor to use when increasing the size of pixel art graphics.
*/
public static final PIXEL_ART_SCALE:Float = 6;
/**
* The BPM of the title screen and menu music.
* TODO: Move to metadata file.
*/
public static final FREAKY_MENU_BPM:Float = 102;
/**
* The volume at which to play the countdown before the song starts.
*/