Merge pull request #111 from FunkinCrew/feature/week-6-dialogue

Week 6 dialogue
This commit is contained in:
Cameron Taylor 2023-07-05 22:11:03 -04:00 committed by GitHub
commit 6a3c32bfa6
29 changed files with 2487 additions and 22 deletions

View file

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

View file

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

View file

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

View file

@ -1,5 +1,6 @@
package funkin; package funkin;
import funkin.modding.IScriptedClass.IEventHandler;
import flixel.FlxState; import flixel.FlxState;
import flixel.FlxSubState; import flixel.FlxSubState;
import flixel.addons.ui.FlxUIState; import flixel.addons.ui.FlxUIState;
@ -15,7 +16,7 @@ import funkin.util.SortUtil;
* MusicBeatState actually represents the core utility FlxState of the game. * MusicBeatState actually represents the core utility FlxState of the game.
* It includes functionality for event handling, as well as maintaining BPM-based update events. * 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; var controls(get, never):Controls;
@ -92,7 +93,7 @@ class MusicBeatState extends FlxUIState
add(rightWatermarkText); add(rightWatermarkText);
} }
function dispatchEvent(event:ScriptEvent) public function dispatchEvent(event:ScriptEvent)
{ {
ModuleHandler.callEvent(event); ModuleHandler.callEvent(event);
} }

View file

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

View file

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

View file

@ -16,6 +16,15 @@ interface IScriptedClass
public function onUpdate(event:UpdateScriptEvent):Void; 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. * 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; public function onCountdownEnd(event:CountdownScriptEvent):Void;
} }
/**
* Defines a set of callbacks activated during a dialogue conversation.
*/
interface IDialogueScriptedClass extends IScriptedClass
{
/**
* Called as the dialogue starts, and before the first dialogue text is displayed.
*/
public function onDialogueStart(event:DialogueScriptEvent):Void;
public function onDialogueCompleteLine(event:DialogueScriptEvent):Void;
public function onDialogueLine(event:DialogueScriptEvent):Void;
public function onDialogueSkip(event:DialogueScriptEvent):Void;
public function onDialogueEnd(event:DialogueScriptEvent):Void;
}

View file

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

View file

@ -3,6 +3,7 @@ package funkin.modding.events;
import flixel.FlxState; import flixel.FlxState;
import flixel.FlxSubState; import flixel.FlxSubState;
import funkin.noteStuff.NoteBasic.NoteDir; import funkin.noteStuff.NoteBasic.NoteDir;
import funkin.play.cutscene.dialogue.Conversation;
import funkin.play.Countdown.CountdownStep; import funkin.play.Countdown.CountdownStep;
import openfl.events.EventType; import openfl.events.EventType;
import openfl.events.KeyboardEvent; import openfl.events.KeyboardEvent;
@ -230,10 +231,42 @@ class ScriptEvent
public static inline final SUBSTATE_CLOSE_END:ScriptEventType = 'SUBSTATE_CLOSE_END'; 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. * 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. * If true, the behavior associated with this event can be prevented.
* For example, cancelling COUNTDOWN_START should prevent the countdown from starting, * 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. * An event that is fired when the player presses a key.
*/ */

View file

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

View file

@ -245,7 +245,7 @@ class GameOverSubState extends MusicBeatSubState
} }
} }
override function dispatchEvent(event:ScriptEvent) public override function dispatchEvent(event:ScriptEvent)
{ {
super.dispatchEvent(event); super.dispatchEvent(event);

View file

@ -1,7 +1,5 @@
package funkin.play; package funkin.play;
import flixel.sound.FlxSound;
import funkin.ui.story.StoryMenuState;
import flixel.addons.display.FlxPieDial; import flixel.addons.display.FlxPieDial;
import flixel.addons.transition.FlxTransitionableState; import flixel.addons.transition.FlxTransitionableState;
import flixel.FlxCamera; import flixel.FlxCamera;
@ -14,6 +12,7 @@ import flixel.input.keyboard.FlxKey;
import flixel.math.FlxMath; import flixel.math.FlxMath;
import flixel.math.FlxPoint; import flixel.math.FlxPoint;
import flixel.math.FlxRect; import flixel.math.FlxRect;
import flixel.sound.FlxSound;
import flixel.text.FlxText; import flixel.text.FlxText;
import flixel.tweens.FlxEase; import flixel.tweens.FlxEase;
import flixel.tweens.FlxTween; import flixel.tweens.FlxTween;
@ -28,6 +27,8 @@ import funkin.modding.events.ScriptEventDispatcher;
import funkin.Note; import funkin.Note;
import funkin.play.character.BaseCharacter; import funkin.play.character.BaseCharacter;
import funkin.play.character.CharacterData.CharacterDataParser; 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.VanillaCutscenes;
import funkin.play.cutscene.VideoCutscene; import funkin.play.cutscene.VideoCutscene;
import funkin.play.event.SongEventData.SongEventParser; import funkin.play.event.SongEventData.SongEventParser;
@ -45,6 +46,7 @@ import funkin.play.Strumline.StrumlineStyle;
import funkin.ui.PopUpStuff; import funkin.ui.PopUpStuff;
import funkin.ui.PreferencesMenu; import funkin.ui.PreferencesMenu;
import funkin.ui.stageBuildShit.StageOffsetSubState; import funkin.ui.stageBuildShit.StageOffsetSubState;
import funkin.ui.story.StoryMenuState;
import funkin.util.Constants; import funkin.util.Constants;
import funkin.util.SerializerUtil; import funkin.util.SerializerUtil;
import funkin.util.SortUtil; import funkin.util.SortUtil;
@ -222,6 +224,11 @@ class PlayState extends MusicBeatState
*/ */
public var disableKeys:Bool = false; public var disableKeys:Bool = false;
/**
* The current dialogue.
*/
public var currentConversation:Conversation;
/** /**
* PRIVATE INSTANCE VARIABLES * PRIVATE INSTANCE VARIABLES
* Private instance variables should be used for information that must be reset or dereferenced * Private instance variables should be used for information that must be reset or dereferenced
@ -1425,6 +1432,7 @@ class PlayState extends MusicBeatState
// Handle keybinds. // Handle keybinds.
if (!isInCutscene && !disableKeys) keyShit(true); if (!isInCutscene && !disableKeys) keyShit(true);
if (!isInCutscene && !disableKeys) debugKeyShit(); if (!isInCutscene && !disableKeys) debugKeyShit();
if (isInCutscene && !disableKeys) handleCutsceneKeys(elapsed);
// Dispatch the onUpdate event to scripted elements. // Dispatch the onUpdate event to scripted elements.
dispatchEvent(new UpdateScriptEvent(elapsed)); dispatchEvent(new UpdateScriptEvent(elapsed));
@ -1432,6 +1440,36 @@ class PlayState extends MusicBeatState
static final CUTSCENE_KEYS:Array<FlxKey> = [SPACE, ESCAPE, ENTER]; static final CUTSCENE_KEYS:Array<FlxKey> = [SPACE, ESCAPE, ENTER];
function handleCutsceneKeys(elapsed:Float):Void
{
if (currentConversation != null)
{
if (controls.CUTSCENE_ADVANCE) currentConversation?.advanceConversation();
if (controls.CUTSCENE_SKIP)
{
currentConversation?.trySkipConversation(elapsed);
}
else
{
currentConversation?.trySkipConversation(-1);
}
}
else if (VideoCutscene.isPlaying())
{
// This is a video cutscene.
if (controls.CUTSCENE_SKIP)
{
trySkipVideoCutscene(elapsed);
}
else
{
trySkipVideoCutscene(-1);
}
}
}
public function trySkipVideoCutscene(elapsed:Float):Void public function trySkipVideoCutscene(elapsed:Float):Void
{ {
if (skipTimer == null || skipTimer.animation == null) return; if (skipTimer == null || skipTimer.animation == null) return;
@ -2369,9 +2407,9 @@ class PlayState extends MusicBeatState
camHUD.visible = true; 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. // Modules should get the first chance to cancel the event.
// super.dispatchEvent(event) dispatches event to module scripts. // super.dispatchEvent(event) dispatches event to module scripts.
@ -2383,11 +2421,55 @@ class PlayState extends MusicBeatState
// Dispatch event to character script(s). // Dispatch event to character script(s).
if (currentStage != null) currentStage.dispatchToCharacters(event); if (currentStage != null) currentStage.dispatchToCharacters(event);
// Dispatch event to song script.
ScriptEventDispatcher.callEvent(currentSong, event); ScriptEventDispatcher.callEvent(currentSong, event);
// Dispatch event to conversation script.
ScriptEventDispatcher.callEvent(currentConversation, event);
// TODO: Dispatch event to note scripts // 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. * Updates the position and contents of the score display.
*/ */

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,7 @@
package funkin.play.song; package funkin.play.song;
import funkin.modding.events.ScriptEvent;
import funkin.modding.events.ScriptEventDispatcher;
import flixel.util.typeLimit.OneOfTwo; import flixel.util.typeLimit.OneOfTwo;
import funkin.play.song.ScriptedSong; import funkin.play.song.ScriptedSong;
import funkin.util.assets.DataAssets; import funkin.util.assets.DataAssets;
@ -95,6 +97,9 @@ class SongDataParser
{ {
var song:Song = songCache.get(songId); var song:Song = songCache.get(songId);
trace('Successfully fetch song: ${songId}'); trace('Successfully fetch song: ${songId}');
var event:ScriptEvent = new ScriptEvent(ScriptEvent.CREATE, false);
ScriptEventDispatcher.callEvent(song, event);
return song; return song;
} }
else else

View file

@ -2546,7 +2546,7 @@ class ChartEditorState extends HaxeUIState
currentOpponentCharacterPlayer = charPlayer; currentOpponentCharacterPlayer = charPlayer;
} }
override function dispatchEvent(event:ScriptEvent):Void public override function dispatchEvent(event:ScriptEvent):Void
{ {
super.dispatchEvent(event); super.dispatchEvent(event);

View file

@ -459,7 +459,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) dispatches event to module scripts.
super.dispatchEvent(event); super.dispatchEvent(event);