diff --git a/Project.xml b/Project.xml
index 2d9dd802b..4ffb0355c 100644
--- a/Project.xml
+++ b/Project.xml
@@ -106,7 +106,10 @@
+
+
+
diff --git a/hmm.json b/hmm.json
index dc403a5ab..a1e3817e0 100644
--- a/hmm.json
+++ b/hmm.json
@@ -116,6 +116,11 @@
"name": "thx.semver",
"type": "haxelib",
"version": "0.2.2"
+ },
+ {
+ "name": "tink_json",
+ "type": "haxelib",
+ "version": null
}
]
-}
+}
\ No newline at end of file
diff --git a/source/funkin/Conductor.hx b/source/funkin/Conductor.hx
index 7f7e2b356..4b1261d4b 100644
--- a/source/funkin/Conductor.hx
+++ b/source/funkin/Conductor.hx
@@ -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 = [];
+
+ /**
+ * 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 = 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 = [];
-
- /**
- * 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):Void
+ public static function mapTimeChanges(songTimeChanges:Array)
{
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);
+ }
}
diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx
index 8d7d2d550..0ebe7871a 100644
--- a/source/funkin/InitState.hx
+++ b/source/funkin/InitState.hx
@@ -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
diff --git a/source/funkin/MusicBeatState.hx b/source/funkin/MusicBeatState.hx
index 2b97951f9..6e383c8c1 100644
--- a/source/funkin/MusicBeatState.hx
+++ b/source/funkin/MusicBeatState.hx
@@ -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);
}
diff --git a/source/funkin/MusicBeatSubState.hx b/source/funkin/MusicBeatSubState.hx
index 5c6635a02..244d2ceea 100644
--- a/source/funkin/MusicBeatSubState.hx
+++ b/source/funkin/MusicBeatSubState.hx
@@ -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);
}
diff --git a/source/funkin/Paths.hx b/source/funkin/Paths.hx
index 60dcfad38..ee2dfe5fd 100644
--- a/source/funkin/Paths.hx
+++ b/source/funkin/Paths.hx
@@ -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';
}
diff --git a/source/funkin/PauseSubState.hx b/source/funkin/PauseSubState.hx
index 77fdfabf1..d5584fbc7 100644
--- a/source/funkin/PauseSubState.hx
+++ b/source/funkin/PauseSubState.hx
@@ -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;
diff --git a/source/funkin/TitleState.hx b/source/funkin/TitleState.hx
index bc6ef571d..e0a08731b 100644
--- a/source/funkin/TitleState.hx
+++ b/source/funkin/TitleState.hx
@@ -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>
{
var fullText:String = Assets.getText(Paths.txt('introText'));
diff --git a/source/funkin/import.hx b/source/funkin/import.hx
index 397758103..f54ccea86 100644
--- a/source/funkin/import.hx
+++ b/source/funkin/import.hx
@@ -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
diff --git a/source/funkin/modding/IScriptedClass.hx b/source/funkin/modding/IScriptedClass.hx
index abcce483f..b009aea41 100644
--- a/source/funkin/modding/IScriptedClass.hx
+++ b/source/funkin/modding/IScriptedClass.hx
@@ -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;
+}
diff --git a/source/funkin/modding/base/ScriptedFlxSpriteGroup.hx b/source/funkin/modding/base/ScriptedFlxSpriteGroup.hx
index e3acc6348..1391cb44a 100644
--- a/source/funkin/modding/base/ScriptedFlxSpriteGroup.hx
+++ b/source/funkin/modding/base/ScriptedFlxSpriteGroup.hx
@@ -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 implements HScriptedClass {}
diff --git a/source/funkin/modding/events/ScriptEvent.hx b/source/funkin/modding/events/ScriptEvent.hx
index ef67ba64a..95922ded1 100644
--- a/source/funkin/modding/events/ScriptEvent.hx
+++ b/source/funkin/modding/events/ScriptEvent.hx
@@ -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.
*/
diff --git a/source/funkin/modding/events/ScriptEventDispatcher.hx b/source/funkin/modding/events/ScriptEventDispatcher.hx
index a816d748a..5e3e32a46 100644
--- a/source/funkin/modding/events/ScriptEventDispatcher.hx
+++ b/source/funkin/modding/events/ScriptEventDispatcher.hx
@@ -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, event:ScriptEvent):Void
diff --git a/source/funkin/play/GameOverSubState.hx b/source/funkin/play/GameOverSubState.hx
index 7c39cef56..f38dabea4 100644
--- a/source/funkin/play/GameOverSubState.hx
+++ b/source/funkin/play/GameOverSubState.hx
@@ -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);
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index f39000633..911bf5491 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -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 = [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.
*/
diff --git a/source/funkin/play/character/SparrowCharacter.hx b/source/funkin/play/character/SparrowCharacter.hx
index 4bd17e7e6..a36aed84d 100644
--- a/source/funkin/play/character/SparrowCharacter.hx
+++ b/source/funkin/play/character/SparrowCharacter.hx
@@ -44,10 +44,12 @@ class SparrowCharacter extends BaseCharacter
if (_data.isPixel)
{
+ this.isPixel = true;
this.antialiasing = false;
}
else
{
+ this.isPixel = false;
this.antialiasing = true;
}
diff --git a/source/funkin/play/cutscene/VideoCutscene.hx b/source/funkin/play/cutscene/VideoCutscene.hx
index 652ca0287..24cf78c2a 100644
--- a/source/funkin/play/cutscene/VideoCutscene.hx
+++ b/source/funkin/play/cutscene/VideoCutscene.hx
@@ -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;
}
diff --git a/source/funkin/play/cutscene/dialogue/Conversation.hx b/source/funkin/play/cutscene/dialogue/Conversation.hx
new file mode 100644
index 000000000..0816e1d25
--- /dev/null
+++ b/source/funkin/play/cutscene/dialogue/Conversation.hx
@@ -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;
+}
diff --git a/source/funkin/play/cutscene/dialogue/ConversationData.hx b/source/funkin/play/cutscene/dialogue/ConversationData.hx
new file mode 100644
index 000000000..d2e3b74cf
--- /dev/null
+++ b/source/funkin/play/cutscene/dialogue/ConversationData.hx
@@ -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;
+
+ public function new(version:String, backdrop:BackdropData, outro:OutroData, music:MusicData, dialogue:Array)
+ {
+ 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 // 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;
+
+ /**
+ * 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, 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, 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';
+}
diff --git a/source/funkin/play/cutscene/dialogue/ConversationDataParser.hx b/source/funkin/play/cutscene/dialogue/ConversationDataParser.hx
new file mode 100644
index 000000000..c25b3e87f
--- /dev/null
+++ b/source/funkin/play/cutscene/dialogue/ConversationDataParser.hx
@@ -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 = new Map();
+ static final conversationScriptedClass:Map = new Map();
+
+ 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 = 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 = DataAssets.listDataFilesInPath('dialogue/conversations/');
+ // Filter out conversations that are scripted.
+ var unscriptedConversationIds:Array = 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
+ {
+ 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
+ {
+ 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
+ {
+ 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;
+ }
+}
diff --git a/source/funkin/play/cutscene/dialogue/ConversationDebugState.hx b/source/funkin/play/cutscene/dialogue/ConversationDebugState.hx
new file mode 100644
index 000000000..5f2b98f8b
--- /dev/null
+++ b/source/funkin/play/cutscene/dialogue/ConversationDebugState.hx
@@ -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);
+ }
+ }
+ }
+}
diff --git a/source/funkin/play/cutscene/dialogue/DialogueBox.hx b/source/funkin/play/cutscene/dialogue/DialogueBox.hx
new file mode 100644
index 000000000..52564010a
--- /dev/null
+++ b/source/funkin/play/cutscene/dialogue/DialogueBox.hx
@@ -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> = new Map>();
+
+ /**
+ * The current animation offset being used.
+ */
+ var animOffsets(default, set):Array = [0, 0];
+
+ function set_animOffsets(value:Array):Array
+ {
+ 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 = [0, 0];
+
+ function set_globalOffsets(value:Array):Array
+ {
+ 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):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 = 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 = 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 {}
+}
diff --git a/source/funkin/play/cutscene/dialogue/DialogueBoxData.hx b/source/funkin/play/cutscene/dialogue/DialogueBoxData.hx
new file mode 100644
index 000000000..2ae79f8d8
--- /dev/null
+++ b/source/funkin/play/cutscene/dialogue/DialogueBoxData.hx
@@ -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;
+ public var text:DialogueBoxTextData;
+ public var scale:Float;
+ public var animations:Array;
+
+ public function new(version:String, name:String, assetPath:String, flipX:Bool = false, flipY:Bool = false, isPixel:Bool = false, offsets:Null>,
+ text:DialogueBoxTextData, scale:Float = 1.0, animations:Array)
+ {
+ 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,
+ text:Dynamic,
+ scale:Float,
+ animations:Array
+ } = 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;
+ public var width:Int;
+ public var size:Int;
+ public var color:String;
+ public var shadowColor:Null;
+ public var shadowWidth:Null;
+
+ public function new(offsets:Null>, width:Null, size:Null, color:String, shadowColor:Null, shadowWidth:Null)
+ {
+ 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,
+ };
+ }
+}
diff --git a/source/funkin/play/cutscene/dialogue/DialogueBoxDataParser.hx b/source/funkin/play/cutscene/dialogue/DialogueBoxDataParser.hx
new file mode 100644
index 000000000..7bac9cf38
--- /dev/null
+++ b/source/funkin/play/cutscene/dialogue/DialogueBoxDataParser.hx
@@ -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 = new Map();
+
+ static final dialogueBoxScriptedClass:Map = new Map();
+
+ 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 = 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 = DataAssets.listDataFilesInPath('dialogue/boxes/');
+ // Filter out dialogue boxes that are scripted.
+ var unscriptedDialogueBoxIds:Array = 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
+ {
+ 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
+ {
+ 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
+ {
+ 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;
+ }
+}
diff --git a/source/funkin/play/cutscene/dialogue/ScriptedConversation.hx b/source/funkin/play/cutscene/dialogue/ScriptedConversation.hx
new file mode 100644
index 000000000..4fe383a5e
--- /dev/null
+++ b/source/funkin/play/cutscene/dialogue/ScriptedConversation.hx
@@ -0,0 +1,4 @@
+package funkin.play.cutscene.dialogue;
+
+@:hscriptClass
+class ScriptedConversation extends Conversation implements polymod.hscript.HScriptedClass {}
diff --git a/source/funkin/play/cutscene/dialogue/ScriptedDialogueBox.hx b/source/funkin/play/cutscene/dialogue/ScriptedDialogueBox.hx
new file mode 100644
index 000000000..a1b36c7c2
--- /dev/null
+++ b/source/funkin/play/cutscene/dialogue/ScriptedDialogueBox.hx
@@ -0,0 +1,4 @@
+package funkin.play.cutscene.dialogue;
+
+@:hscriptClass
+class ScriptedDialogueBox extends DialogueBox implements polymod.hscript.HScriptedClass {}
diff --git a/source/funkin/play/cutscene/dialogue/ScriptedSpeaker.hx b/source/funkin/play/cutscene/dialogue/ScriptedSpeaker.hx
new file mode 100644
index 000000000..03846eb42
--- /dev/null
+++ b/source/funkin/play/cutscene/dialogue/ScriptedSpeaker.hx
@@ -0,0 +1,4 @@
+package funkin.play.cutscene.dialogue;
+
+@:hscriptClass
+class ScriptedSpeaker extends Speaker implements polymod.hscript.HScriptedClass {}
diff --git a/source/funkin/play/cutscene/dialogue/Speaker.hx b/source/funkin/play/cutscene/dialogue/Speaker.hx
new file mode 100644
index 000000000..1fb341009
--- /dev/null
+++ b/source/funkin/play/cutscene/dialogue/Speaker.hx
@@ -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> = new Map>();
+
+ /**
+ * The current animation offset being used.
+ */
+ var animOffsets(default, set):Array = [0, 0];
+
+ function set_animOffsets(value:Array):Array
+ {
+ 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 = [0, 0];
+
+ function set_globalOffsets(value:Array):Array
+ {
+ 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):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 = 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 = 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 {}
+}
diff --git a/source/funkin/play/cutscene/dialogue/SpeakerData.hx b/source/funkin/play/cutscene/dialogue/SpeakerData.hx
new file mode 100644
index 000000000..44e13b025
--- /dev/null
+++ b/source/funkin/play/cutscene/dialogue/SpeakerData.hx
@@ -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;
+ public var scale:Float;
+ public var animations:Array;
+
+ public function new(version:String, name:String, assetPath:String, animations:Array, ?offsets:Array, ?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,
+ ?offsets:Array,
+ ?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;
+ }
+}
diff --git a/source/funkin/play/cutscene/dialogue/SpeakerDataParser.hx b/source/funkin/play/cutscene/dialogue/SpeakerDataParser.hx
new file mode 100644
index 000000000..62a8a105b
--- /dev/null
+++ b/source/funkin/play/cutscene/dialogue/SpeakerDataParser.hx
@@ -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 = new Map();
+
+ static final speakerScriptedClass:Map = new Map();
+
+ 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 = 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 = DataAssets.listDataFilesInPath('dialogue/speakers/');
+ // Filter out speakers that are scripted.
+ var unscriptedSpeakerIds:Array = 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
+ {
+ 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
+ {
+ 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
+ {
+ 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;
+ }
+}
diff --git a/source/funkin/play/song/SongData.hx b/source/funkin/play/song/SongData.hx
index 2ae38156a..4ec90d3ec 100644
--- a/source/funkin/play/song/SongData.hx
+++ b/source/funkin/play/song/SongData.hx
@@ -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;
/**
* 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)
+ public function new(timeStamp:Float, beatTime:Null, bpm:Float, timeSignatureNum:Int = 4, timeSignatureDen:Int = 4, beatTuplets:Array)
{
this =
{
@@ -862,7 +893,7 @@ abstract SongTimeChange(RawSongTimeChange)
return this.t = value;
}
- public var beatTime(get, set):Int;
+ public var beatTime(get, set):Null;
function get_beatTime():Int
{
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index f5a87ceb1..cacea684a 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -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);
diff --git a/source/funkin/ui/story/StoryMenuState.hx b/source/funkin/ui/story/StoryMenuState.hx
index 1dc59f3ec..bf395c808 100644
--- a/source/funkin/ui/story/StoryMenuState.hx
+++ b/source/funkin/ui/story/StoryMenuState.hx
@@ -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);
diff --git a/source/funkin/util/Constants.hx b/source/funkin/util/Constants.hx
index c1bac76c4..c5f9d1689 100644
--- a/source/funkin/util/Constants.hx
+++ b/source/funkin/util/Constants.hx
@@ -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.
*/