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. */