mirror of
https://github.com/FunkinCrew/Funkin.git
synced 2024-12-05 13:51:01 -05:00
673 lines
17 KiB
Haxe
673 lines
17 KiB
Haxe
package funkin.play.cutscene.dialogue;
|
|
|
|
import flixel.addons.display.FlxPieDial;
|
|
import flixel.FlxSprite;
|
|
import flixel.group.FlxSpriteGroup;
|
|
import flixel.tweens.FlxEase;
|
|
import flixel.tweens.FlxTween;
|
|
import flixel.util.FlxColor;
|
|
import flixel.util.FlxSort;
|
|
import funkin.audio.FunkinSound;
|
|
import funkin.data.dialogue.conversation.ConversationData;
|
|
import funkin.data.dialogue.conversation.ConversationData.DialogueEntryData;
|
|
import funkin.data.dialogue.conversation.ConversationRegistry;
|
|
import funkin.data.dialogue.dialoguebox.DialogueBoxData;
|
|
import funkin.data.dialogue.dialoguebox.DialogueBoxRegistry;
|
|
import funkin.data.dialogue.speaker.SpeakerData;
|
|
import funkin.data.dialogue.speaker.SpeakerRegistry;
|
|
import funkin.data.IRegistryEntry;
|
|
import funkin.graphics.FunkinSprite;
|
|
import funkin.modding.events.ScriptEvent;
|
|
import funkin.modding.events.ScriptEventDispatcher;
|
|
import funkin.modding.IScriptedClass.IDialogueScriptedClass;
|
|
import funkin.modding.IScriptedClass.IEventHandler;
|
|
import funkin.play.cutscene.dialogue.DialogueBox;
|
|
import funkin.util.SortUtil;
|
|
import funkin.util.EaseUtil;
|
|
|
|
/**
|
|
* 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 implements IRegistryEntry<ConversationData>
|
|
{
|
|
/**
|
|
* The ID of the conversation.
|
|
*/
|
|
public final id:String;
|
|
|
|
/**
|
|
* The current state of the conversation.
|
|
*/
|
|
var state:ConversationState = ConversationState.Start;
|
|
|
|
/**
|
|
* Conversation data as parsed from the JSON file.
|
|
*/
|
|
public final _data:ConversationData;
|
|
|
|
/**
|
|
* The current entry in the dialogue.
|
|
*/
|
|
var currentDialogueEntry:Int = 0;
|
|
|
|
var currentDialogueEntryCount(get, never):Int;
|
|
|
|
function get_currentDialogueEntryCount():Int
|
|
{
|
|
return _data.dialogue.length;
|
|
}
|
|
|
|
/**
|
|
* The current line in the current entry in the dialogue.
|
|
* **/
|
|
var currentDialogueLine:Int = 0;
|
|
|
|
var currentDialogueLineCount(get, never):Int;
|
|
|
|
function get_currentDialogueLineCount():Int
|
|
{
|
|
return currentDialogueEntryData.text.length;
|
|
}
|
|
|
|
var currentDialogueEntryData(get, never):DialogueEntryData;
|
|
|
|
function get_currentDialogueEntryData():DialogueEntryData
|
|
{
|
|
if (_data == null || _data.dialogue == null) return null;
|
|
if (currentDialogueEntry < 0 || currentDialogueEntry >= _data.dialogue.length) return null;
|
|
|
|
return _data.dialogue[currentDialogueEntry];
|
|
}
|
|
|
|
var currentDialogueLineString(get, never):String;
|
|
|
|
function get_currentDialogueLineString():String
|
|
{
|
|
return currentDialogueEntryData?.text[currentDialogueLine];
|
|
}
|
|
|
|
/**
|
|
* AUDIO
|
|
*/
|
|
var music:FunkinSound;
|
|
|
|
/**
|
|
* GRAPHICS
|
|
*/
|
|
var backdrop:FunkinSprite;
|
|
|
|
var currentSpeaker:Speaker;
|
|
|
|
var currentDialogueBox:DialogueBox;
|
|
|
|
public function new(id:String)
|
|
{
|
|
super();
|
|
|
|
this.id = id;
|
|
this._data = _fetchData(id);
|
|
|
|
if (_data == null)
|
|
{
|
|
throw 'Could not parse conversation data for id: $id';
|
|
}
|
|
}
|
|
|
|
public function onCreate(event:ScriptEvent):Void
|
|
{
|
|
// Reset the progress in the dialogue.
|
|
currentDialogueEntry = 0;
|
|
currentDialogueLine = 0;
|
|
this.state = ConversationState.Start;
|
|
|
|
// Start the dialogue.
|
|
dispatchEvent(new DialogueScriptEvent(DIALOGUE_START, this, false));
|
|
}
|
|
|
|
function setupMusic():Void
|
|
{
|
|
if (_data.music == null) return;
|
|
|
|
music = FunkinSound.load(Paths.music(_data.music.asset), 0.0, true, true, true);
|
|
|
|
if (_data.music.fadeTime > 0.0)
|
|
{
|
|
FlxTween.tween(music, {volume: 1.0}, _data.music.fadeTime, {ease: FlxEase.linear});
|
|
}
|
|
else
|
|
{
|
|
music.volume = 1.0;
|
|
}
|
|
}
|
|
|
|
public function pauseMusic():Void
|
|
{
|
|
if (music != null)
|
|
{
|
|
music.pause();
|
|
}
|
|
}
|
|
|
|
public function resumeMusic():Void
|
|
{
|
|
if (music != null)
|
|
{
|
|
music.resume();
|
|
}
|
|
}
|
|
|
|
function setupBackdrop():Void
|
|
{
|
|
if (backdrop != null)
|
|
{
|
|
backdrop.destroy();
|
|
remove(backdrop);
|
|
backdrop = null;
|
|
}
|
|
|
|
backdrop = new FunkinSprite(0, 0);
|
|
|
|
if (_data.backdrop == null) return;
|
|
|
|
// Play intro
|
|
switch (_data.backdrop)
|
|
{
|
|
case SOLID(backdropData):
|
|
var targetColor:FlxColor = FlxColor.fromString(backdropData.color);
|
|
backdrop.makeSolidColor(Std.int(FlxG.width), Std.int(FlxG.height), targetColor);
|
|
if (backdropData.fadeTime > 0.0)
|
|
{
|
|
backdrop.alpha = 0.0;
|
|
FlxTween.tween(backdrop, {alpha: 1.0}, backdropData.fadeTime, {ease: EaseUtil.stepped(10)});
|
|
}
|
|
else
|
|
{
|
|
backdrop.alpha = 1.0;
|
|
}
|
|
default:
|
|
return;
|
|
}
|
|
|
|
backdrop.zIndex = 10;
|
|
add(backdrop);
|
|
refresh();
|
|
}
|
|
|
|
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 && currentSpeaker.alive) && nextSpeakerId == currentSpeaker.id) return;
|
|
|
|
if (currentSpeaker != null)
|
|
{
|
|
remove(currentSpeaker);
|
|
currentSpeaker.kill(); // Kill, don't destroy! We want to revive it later.
|
|
currentSpeaker = null;
|
|
}
|
|
|
|
var nextSpeaker:Speaker = SpeakerRegistry.instance.fetchEntry(nextSpeakerId);
|
|
|
|
if (nextSpeaker == null)
|
|
{
|
|
if (nextSpeakerId == null)
|
|
{
|
|
trace('Dialogue entry has no speaker.');
|
|
}
|
|
else
|
|
{
|
|
trace('Speaker could not be retrieved.');
|
|
}
|
|
return;
|
|
}
|
|
if (!nextSpeaker.alive) nextSpeaker.revive();
|
|
|
|
ScriptEventDispatcher.callEvent(nextSpeaker, new 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 dialogue box is already displayed.
|
|
if ((currentDialogueBox != null && currentDialogueBox.alive) && nextDialogueBoxId == currentDialogueBox.id) return;
|
|
|
|
if (currentDialogueBox != null)
|
|
{
|
|
remove(currentDialogueBox);
|
|
currentDialogueBox.kill(); // Kill, don't destroy! We want to revive it later.
|
|
currentDialogueBox = null;
|
|
}
|
|
|
|
var nextDialogueBox:DialogueBox = DialogueBoxRegistry.instance.fetchEntry(nextDialogueBoxId);
|
|
|
|
if (nextDialogueBox == null)
|
|
{
|
|
trace('Dialogue box could not be retrieved.');
|
|
return;
|
|
}
|
|
if (!nextDialogueBox.alive) nextDialogueBox.revive();
|
|
|
|
ScriptEventDispatcher.callEvent(nextDialogueBox, new 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(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(DIALOGUE_START, this, true));
|
|
case ConversationState.Opening:
|
|
dispatchEvent(new DialogueScriptEvent(DIALOGUE_COMPLETE_LINE, this, true));
|
|
case ConversationState.Speaking:
|
|
dispatchEvent(new DialogueScriptEvent(DIALOGUE_COMPLETE_LINE, this, true));
|
|
case ConversationState.Idle:
|
|
dispatchEvent(new DialogueScriptEvent(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;
|
|
|
|
if (outroTween != null)
|
|
{
|
|
outroTween.cancel();
|
|
}
|
|
outroTween = null;
|
|
|
|
if (this.music != null) this.music.stop();
|
|
this.music = null;
|
|
|
|
if (currentSpeaker != null) currentSpeaker.kill();
|
|
remove(currentSpeaker);
|
|
currentSpeaker = null;
|
|
|
|
if (currentDialogueBox != null) currentDialogueBox.kill();
|
|
remove(currentDialogueBox);
|
|
currentDialogueBox = null;
|
|
|
|
if (backdrop != null) backdrop.destroy();
|
|
remove(backdrop);
|
|
backdrop = null;
|
|
|
|
startConversation();
|
|
}
|
|
|
|
/**
|
|
* 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(DIALOGUE_SKIP, this, true));
|
|
}
|
|
|
|
var outroTween:FlxTween;
|
|
|
|
public function startOutro():Void
|
|
{
|
|
switch (_data?.outro)
|
|
{
|
|
case FADE(outroData):
|
|
outroTween = FlxTween.tween(this, {alpha: 0.0}, outroData.fadeTime,
|
|
{
|
|
type: ONESHOT, // holy shit like the game no way
|
|
startDelay: 0,
|
|
onComplete: (_) -> endOutro(),
|
|
ease: EaseUtil.stepped(8)
|
|
});
|
|
|
|
FlxTween.tween(this.music, {volume: 0.0}, outroData.fadeTime);
|
|
case NONE(_):
|
|
// Immediately clean up.
|
|
endOutro();
|
|
default:
|
|
// Immediately clean up.
|
|
endOutro();
|
|
}
|
|
}
|
|
|
|
public var completeCallback:() -> Void;
|
|
|
|
public function endOutro():Void
|
|
{
|
|
ScriptEventDispatcher.callEvent(this, new ScriptEvent(DESTROY, false));
|
|
}
|
|
|
|
/**
|
|
* Performed as the conversation starts.
|
|
*/
|
|
public function onDialogueStart(event:DialogueScriptEvent):Void
|
|
{
|
|
propagateEvent(event);
|
|
|
|
// Fade in the music and backdrop.
|
|
setupMusic();
|
|
setupBackdrop();
|
|
|
|
// 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(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(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();
|
|
}
|
|
outroTween = null;
|
|
|
|
if (this.music != null) this.music.stop();
|
|
this.music = null;
|
|
|
|
if (currentSpeaker != null) currentSpeaker.kill();
|
|
remove(currentSpeaker);
|
|
currentSpeaker = null;
|
|
|
|
if (currentDialogueBox != null) currentDialogueBox.kill();
|
|
remove(currentDialogueBox);
|
|
currentDialogueBox = null;
|
|
|
|
if (backdrop != null) backdrop.destroy();
|
|
remove(backdrop);
|
|
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 && this.currentDialogueBox.exists)
|
|
{
|
|
ScriptEventDispatcher.callEvent(this.currentDialogueBox, event);
|
|
}
|
|
if (this.currentSpeaker != null && this.currentSpeaker.exists)
|
|
{
|
|
ScriptEventDispatcher.callEvent(this.currentSpeaker, event);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calls `kill()` on the group's members and then on the group itself.
|
|
* You can revive this group later via `revive()` after this.
|
|
*/
|
|
public override function revive():Void
|
|
{
|
|
super.revive();
|
|
this.alpha = 1;
|
|
this.visible = true;
|
|
}
|
|
|
|
/**
|
|
* Calls `kill()` on the group's members and then on the group itself.
|
|
* You can revive this group later via `revive()` after this.
|
|
*/
|
|
public override function kill():Void
|
|
{
|
|
_skipTransformChildren = true;
|
|
alive = false;
|
|
exists = false;
|
|
_skipTransformChildren = false;
|
|
if (group != null) group.kill();
|
|
|
|
if (outroTween != null)
|
|
{
|
|
outroTween.cancel();
|
|
outroTween = null;
|
|
}
|
|
}
|
|
|
|
public override function toString():String
|
|
{
|
|
return 'Conversation($id)';
|
|
}
|
|
|
|
static function _fetchData(id:String):Null<ConversationData>
|
|
{
|
|
return ConversationRegistry.instance.parseEntryDataWithMigration(id, ConversationRegistry.instance.fetchEntryVersion(id));
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|