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/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 ef01e4d12..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; @@ -94,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 b9a2d0b81..244d2ceea 100644 --- a/source/funkin/MusicBeatSubState.hx +++ b/source/funkin/MusicBeatSubState.hx @@ -1,6 +1,7 @@ package funkin; import flixel.FlxSubState; +import funkin.modding.IScriptedClass.IEventHandler; import flixel.util.FlxColor; import funkin.modding.events.ScriptEvent; import funkin.modding.module.ModuleHandler; @@ -10,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; @@ -98,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/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 1631fc0d7..f38dabea4 100644 --- a/source/funkin/play/GameOverSubState.hx +++ b/source/funkin/play/GameOverSubState.hx @@ -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 2506c2433..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 @@ -1432,7 +1439,20 @@ class PlayState extends MusicBeatState function handleCutsceneKeys(elapsed:Float):Void { - if (VideoCutscene.isPlaying()) + 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. @@ -2384,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. @@ -2398,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/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 41fc4515e..5015f1f93 100644 --- a/source/funkin/play/song/SongData.hx +++ b/source/funkin/play/song/SongData.hx @@ -1,5 +1,7 @@ package funkin.play.song; +import funkin.modding.events.ScriptEvent; +import funkin.modding.events.ScriptEventDispatcher; import flixel.util.typeLimit.OneOfTwo; import funkin.modding.events.ScriptEvent; import funkin.modding.events.ScriptEventDispatcher; diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx index 8b50c29cf..d89af7d6e 100644 --- a/source/funkin/ui/debug/charting/ChartEditorState.hx +++ b/source/funkin/ui/debug/charting/ChartEditorState.hx @@ -2546,7 +2546,7 @@ class ChartEditorState extends HaxeUIState currentOpponentCharacterPlayer = charPlayer; } - 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 927bdc431..bf395c808 100644 --- a/source/funkin/ui/story/StoryMenuState.hx +++ b/source/funkin/ui/story/StoryMenuState.hx @@ -465,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);