mirror of
https://github.com/FunkinCrew/Funkin.git
synced 2024-11-30 03:25:47 -05:00
Merge branch 'master' into feature/chart-editor-importer
This commit is contained in:
commit
85af96d654
35 changed files with 2742 additions and 184 deletions
|
@ -106,7 +106,10 @@
|
|||
<haxelib name="polymod" /> <!-- Modding framework -->
|
||||
<haxelib name="flxanimate" /> <!-- Texture atlas rendering -->
|
||||
<haxelib name="hxCodec" /> <!-- Video playback -->
|
||||
|
||||
<haxelib name="json2object" /> <!-- JSON parsing -->
|
||||
<haxelib name="tink_json" /> <!-- JSON parsing -->
|
||||
|
||||
<haxelib name="thx.semver" />
|
||||
<haxelib name="hxcpp-debug-server" if="desktop debug" />
|
||||
<!--Disable the Flixel core focus lost screen-->
|
||||
|
|
7
hmm.json
7
hmm.json
|
@ -116,6 +116,11 @@
|
|||
"name": "thx.semver",
|
||||
"type": "haxelib",
|
||||
"version": "0.2.2"
|
||||
},
|
||||
{
|
||||
"name": "tink_json",
|
||||
"type": "haxelib",
|
||||
"version": null
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
|
@ -1,23 +1,18 @@
|
|||
package funkin;
|
||||
|
||||
import funkin.play.song.SongData.SongTimeChange;
|
||||
import funkin.util.Constants;
|
||||
import flixel.util.FlxSignal;
|
||||
import flixel.math.FlxMath;
|
||||
import funkin.SongLoad.SwagSong;
|
||||
import funkin.play.song.Song.SongDifficulty;
|
||||
|
||||
typedef BPMChangeEvent =
|
||||
{
|
||||
var stepTime:Int;
|
||||
var songTime:Float;
|
||||
var bpm:Float;
|
||||
}
|
||||
import funkin.play.song.SongData.SongTimeChange;
|
||||
|
||||
/**
|
||||
* A global source of truth for timing information.
|
||||
* A core class which handles musical timing throughout the game,
|
||||
* both in gameplay and in menus.
|
||||
*/
|
||||
class Conductor
|
||||
{
|
||||
static final STEPS_PER_BEAT:Int = 4;
|
||||
|
||||
// onBeatHit is called every quarter note
|
||||
// onStepHit is called every sixteenth note
|
||||
// 4/4 = 4 beats per measure = 16 steps per measure
|
||||
|
@ -33,11 +28,22 @@ class Conductor
|
|||
// 60 BPM = 240 sixteenth notes per minute = 4 onStepHit per second
|
||||
// 7/8 = 3.5 beats per measure = 14 steps per measure
|
||||
|
||||
/**
|
||||
* The list of time changes in the song.
|
||||
* There should be at least one time change (at the beginning of the song) to define the BPM.
|
||||
*/
|
||||
static var timeChanges:Array<SongTimeChange> = [];
|
||||
|
||||
/**
|
||||
* The current time change.
|
||||
*/
|
||||
static var currentTimeChange:SongTimeChange;
|
||||
|
||||
/**
|
||||
* The current position in the song in milliseconds.
|
||||
* Updated every frame based on the audio position.
|
||||
*/
|
||||
public static var songPosition:Float;
|
||||
public static var songPosition:Float = 0;
|
||||
|
||||
/**
|
||||
* Beats per minute of the current song at the current time.
|
||||
|
@ -48,33 +54,17 @@ class Conductor
|
|||
{
|
||||
if (bpmOverride != null) return bpmOverride;
|
||||
|
||||
if (currentTimeChange == null) return 100;
|
||||
if (currentTimeChange == null) return Constants.DEFAULT_BPM;
|
||||
|
||||
return currentTimeChange.bpm;
|
||||
}
|
||||
|
||||
/**
|
||||
* The current value set by `forceBPM`.
|
||||
* If false, BPM is determined by time changes.
|
||||
*/
|
||||
static var bpmOverride:Null<Float> = null;
|
||||
|
||||
/**
|
||||
* Current position in the song, in whole measures.
|
||||
*/
|
||||
public static var currentMeasure(default, null):Int;
|
||||
|
||||
/**
|
||||
* Current position in the song, in whole beats.
|
||||
**/
|
||||
public static var currentBeat(default, null):Int;
|
||||
|
||||
/**
|
||||
* Current position in the song, in whole steps.
|
||||
*/
|
||||
public static var currentStep(default, null):Int;
|
||||
|
||||
/**
|
||||
* Current position in the song, in steps and fractions of a step.
|
||||
*/
|
||||
public static var currentStepTime(default, null):Float;
|
||||
|
||||
/**
|
||||
* Duration of a measure in milliseconds. Calculated based on bpm.
|
||||
*/
|
||||
|
@ -86,119 +76,99 @@ class Conductor
|
|||
}
|
||||
|
||||
/**
|
||||
* Duration of a beat (quarter note) in milliseconds. Calculated based on bpm.
|
||||
* Duration of a beat in milliseconds. Calculated based on bpm.
|
||||
*/
|
||||
public static var beatLengthMs(get, null):Float;
|
||||
|
||||
static function get_beatLengthMs():Float
|
||||
{
|
||||
// Tied directly to BPM.
|
||||
return ((60 / bpm) * 1000);
|
||||
return ((Constants.SECS_PER_MIN / bpm) * Constants.MS_PER_SEC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Duration of a step (sixteenth) in milliseconds. Calculated based on bpm.
|
||||
* Duration of a step (quarter) in milliseconds. Calculated based on bpm.
|
||||
*/
|
||||
public static var stepLengthMs(get, null):Float;
|
||||
|
||||
static function get_stepLengthMs():Float
|
||||
{
|
||||
return beatLengthMs / STEPS_PER_BEAT;
|
||||
return beatLengthMs / timeSignatureNumerator;
|
||||
}
|
||||
|
||||
/**
|
||||
* The numerator of the current time signature (number of notes in a measure)
|
||||
*/
|
||||
public static var timeSignatureNumerator(get, null):Int;
|
||||
|
||||
static function get_timeSignatureNumerator():Int
|
||||
{
|
||||
if (currentTimeChange == null) return 4;
|
||||
if (currentTimeChange == null) return Constants.DEFAULT_TIME_SIGNATURE_NUM;
|
||||
|
||||
return currentTimeChange.timeSignatureNum;
|
||||
}
|
||||
|
||||
/**
|
||||
* The numerator of the current time signature (length of notes in a measure)
|
||||
*/
|
||||
public static var timeSignatureDenominator(get, null):Int;
|
||||
|
||||
static function get_timeSignatureDenominator():Int
|
||||
{
|
||||
if (currentTimeChange == null) return 4;
|
||||
if (currentTimeChange == null) return Constants.DEFAULT_TIME_SIGNATURE_DEN;
|
||||
|
||||
return currentTimeChange.timeSignatureDen;
|
||||
}
|
||||
|
||||
public static var offset:Float = 0;
|
||||
|
||||
// TODO: What's the difference between visualOffset and audioOffset?
|
||||
public static var visualOffset:Float = 0;
|
||||
public static var audioOffset:Float = 0;
|
||||
|
||||
//
|
||||
// Signals
|
||||
//
|
||||
/**
|
||||
* Current position in the song, in measures.
|
||||
*/
|
||||
public static var currentMeasure(default, null):Int;
|
||||
|
||||
/**
|
||||
* Signal that is dispatched every measure.
|
||||
* At 120 BPM 4/4, this is dispatched every 2 seconds.
|
||||
* At 120 BPM 3/4, this is dispatched every 1.5 seconds.
|
||||
* Current position in the song, in beats.
|
||||
*/
|
||||
public static var measureHit(default, null):FlxSignal = new FlxSignal();
|
||||
public static var currentBeat(default, null):Int;
|
||||
|
||||
/**
|
||||
* Signal that is dispatched every beat.
|
||||
* At 120 BPM 4/4, this is dispatched every 0.5 seconds.
|
||||
* At 120 BPM 3/4, this is dispatched every 0.5 seconds.
|
||||
* Current position in the song, in steps.
|
||||
*/
|
||||
public static var currentStep(default, null):Int;
|
||||
|
||||
/**
|
||||
* Current position in the song, in measures and fractions of a measure.
|
||||
*/
|
||||
public static var currentMeasureTime(default, null):Float;
|
||||
|
||||
/**
|
||||
* Current position in the song, in beats and fractions of a measure.
|
||||
*/
|
||||
public static var currentBeatTime(default, null):Float;
|
||||
|
||||
/**
|
||||
* Current position in the song, in steps and fractions of a step.
|
||||
*/
|
||||
public static var currentStepTime(default, null):Float;
|
||||
|
||||
public static var beatHit(default, null):FlxSignal = new FlxSignal();
|
||||
|
||||
/**
|
||||
* Signal that is dispatched when a step is hit.
|
||||
* At 120 BPM 4/4, this is dispatched every 0.125 seconds.
|
||||
* At 120 BPM 3/4, this is dispatched every 0.125 seconds.
|
||||
*/
|
||||
public static var stepHit(default, null):FlxSignal = new FlxSignal();
|
||||
|
||||
//
|
||||
// Internal Variables
|
||||
//
|
||||
|
||||
/**
|
||||
* The list of time changes in the song.
|
||||
* There should be at least one time change (at the beginning of the song) to define the BPM.
|
||||
*/
|
||||
static var timeChanges:Array<SongTimeChange> = [];
|
||||
|
||||
/**
|
||||
* The current time change.
|
||||
*/
|
||||
static var currentTimeChange:SongTimeChange;
|
||||
|
||||
public static var lastSongPos:Float;
|
||||
public static var visualOffset:Float = 0;
|
||||
public static var audioOffset:Float = 0;
|
||||
public static var offset:Float = 0;
|
||||
|
||||
/**
|
||||
* The number of beats (whole notes) in a measure.
|
||||
*/
|
||||
public static var beatsPerMeasure(get, null):Int;
|
||||
public static var beatsPerMeasure(get, null):Float;
|
||||
|
||||
static function get_beatsPerMeasure():Int
|
||||
static function get_beatsPerMeasure():Float
|
||||
{
|
||||
return timeSignatureNumerator;
|
||||
// NOTE: Not always an integer, for example 7/8 is 3.5 beats per measure
|
||||
return stepsPerMeasure / Constants.STEPS_PER_BEAT;
|
||||
}
|
||||
|
||||
/**
|
||||
* The number of steps (quarter-notes) in a measure.
|
||||
*/
|
||||
public static var stepsPerMeasure(get, null):Int;
|
||||
|
||||
static function get_stepsPerMeasure():Int
|
||||
{
|
||||
// This is always 4, b
|
||||
return timeSignatureNumerator * 4;
|
||||
// TODO: Is this always an integer?
|
||||
return Std.int(timeSignatureNumerator / timeSignatureDenominator * Constants.STEPS_PER_BEAT * Constants.STEPS_PER_BEAT);
|
||||
}
|
||||
|
||||
function new() {}
|
||||
|
||||
/**
|
||||
* Forcibly defines the current BPM of the song.
|
||||
* Useful for things like the chart editor that need to manipulate BPM in real time.
|
||||
|
@ -208,16 +178,11 @@ class Conductor
|
|||
* WARNING: Avoid this for things like setting the BPM of the title screen music,
|
||||
* you should have a metadata file for it instead.
|
||||
*/
|
||||
public static function forceBPM(?bpm:Float = null):Void
|
||||
public static function forceBPM(?bpm:Float = null)
|
||||
{
|
||||
if (bpm != null)
|
||||
{
|
||||
trace('[CONDUCTOR] Forcing BPM to ' + bpm);
|
||||
}
|
||||
if (bpm != null) trace('[CONDUCTOR] Forcing BPM to ' + bpm);
|
||||
else
|
||||
{
|
||||
trace('[CONDUCTOR] Resetting BPM to default');
|
||||
}
|
||||
Conductor.bpmOverride = bpm;
|
||||
}
|
||||
|
||||
|
@ -228,13 +193,12 @@ class Conductor
|
|||
* @param songPosition The current position in the song in milliseconds.
|
||||
* Leave blank to use the FlxG.sound.music position.
|
||||
*/
|
||||
public static function update(songPosition:Float = null):Void
|
||||
public static function update(songPosition:Float = null)
|
||||
{
|
||||
if (songPosition == null) songPosition = (FlxG.sound.music != null) ? FlxG.sound.music.time + Conductor.offset : 0.0;
|
||||
|
||||
var oldMeasure:Int = currentMeasure;
|
||||
var oldBeat:Int = currentBeat;
|
||||
var oldStep:Int = currentStep;
|
||||
var oldBeat = currentBeat;
|
||||
var oldStep = currentStep;
|
||||
|
||||
Conductor.songPosition = songPosition;
|
||||
|
||||
|
@ -252,16 +216,23 @@ class Conductor
|
|||
}
|
||||
else if (currentTimeChange != null)
|
||||
{
|
||||
currentStepTime = (currentTimeChange.beatTime * 4) + (songPosition - currentTimeChange.timeStamp) / stepLengthMs;
|
||||
// roundDecimal prevents representing 8 as 7.9999999
|
||||
currentStepTime = FlxMath.roundDecimal((currentTimeChange.beatTime * 4) + (songPosition - currentTimeChange.timeStamp) / stepLengthMs, 6);
|
||||
currentBeatTime = currentStepTime / Constants.STEPS_PER_BEAT;
|
||||
currentMeasureTime = currentStepTime / stepsPerMeasure;
|
||||
currentStep = Math.floor(currentStepTime);
|
||||
currentBeat = Math.floor(currentStep / 4);
|
||||
currentBeat = Math.floor(currentBeatTime);
|
||||
currentMeasure = Math.floor(currentMeasureTime);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Assume a constant BPM equal to the forced value.
|
||||
currentStepTime = (songPosition / stepLengthMs);
|
||||
currentStepTime = FlxMath.roundDecimal((songPosition / stepLengthMs), 4);
|
||||
currentBeatTime = currentStepTime / Constants.STEPS_PER_BEAT;
|
||||
currentMeasureTime = currentStepTime / stepsPerMeasure;
|
||||
currentStep = Math.floor(currentStepTime);
|
||||
currentBeat = Math.floor(currentStep / 4);
|
||||
currentBeat = Math.floor(currentBeatTime);
|
||||
currentMeasure = Math.floor(currentMeasureTime);
|
||||
}
|
||||
|
||||
// FlxSignals are really cool.
|
||||
|
@ -274,31 +245,52 @@ class Conductor
|
|||
{
|
||||
beatHit.dispatch();
|
||||
}
|
||||
|
||||
if (currentMeasure != oldMeasure)
|
||||
{
|
||||
measureHit.dispatch();
|
||||
}
|
||||
}
|
||||
|
||||
public static function mapTimeChanges(songTimeChanges:Array<SongTimeChange>):Void
|
||||
public static function mapTimeChanges(songTimeChanges:Array<SongTimeChange>)
|
||||
{
|
||||
timeChanges = [];
|
||||
|
||||
for (currentTimeChange in songTimeChanges)
|
||||
{
|
||||
// TODO: Maybe handle this different?
|
||||
// Do we care about BPM at negative timestamps?
|
||||
// Without any custom handling, `currentStepTime` becomes non-zero at `songPosition = 0`.
|
||||
if (currentTimeChange.timeStamp < 0.0) currentTimeChange.timeStamp = 0.0;
|
||||
|
||||
if (currentTimeChange.beatTime == null)
|
||||
{
|
||||
if (currentTimeChange.timeStamp <= 0.0)
|
||||
{
|
||||
currentTimeChange.beatTime = 0.0;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Calculate the beat time of this timestamp.
|
||||
currentTimeChange.beatTime = 0.0;
|
||||
|
||||
if (currentTimeChange.timeStamp > 0.0 && timeChanges.length > 0)
|
||||
{
|
||||
var prevTimeChange:SongTimeChange = timeChanges[timeChanges.length - 1];
|
||||
currentTimeChange.beatTime = prevTimeChange.beatTime
|
||||
+ ((currentTimeChange.timeStamp - prevTimeChange.timeStamp) * prevTimeChange.bpm / Constants.SECS_PER_MIN / Constants.MS_PER_SEC);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
timeChanges.push(currentTimeChange);
|
||||
}
|
||||
|
||||
trace('Done mapping time changes: ' + timeChanges);
|
||||
|
||||
// Done.
|
||||
// Update currentStepTime
|
||||
Conductor.update(Conductor.songPosition);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a time in milliseconds, return a time in steps.
|
||||
*/
|
||||
public static function getTimeInSteps(ms:Float):Int
|
||||
public static function getTimeInSteps(ms:Float):Float
|
||||
{
|
||||
if (timeChanges.length == 0)
|
||||
{
|
||||
|
@ -307,7 +299,7 @@ class Conductor
|
|||
}
|
||||
else
|
||||
{
|
||||
var resultStep:Int = 0;
|
||||
var resultStep:Float = 0;
|
||||
|
||||
var lastTimeChange:SongTimeChange = timeChanges[0];
|
||||
for (timeChange in timeChanges)
|
||||
|
@ -329,4 +321,14 @@ class Conductor
|
|||
return resultStep;
|
||||
}
|
||||
}
|
||||
|
||||
public static function reset():Void
|
||||
{
|
||||
beatHit.removeAll();
|
||||
stepHit.removeAll();
|
||||
|
||||
mapTimeChanges([]);
|
||||
forceBPM(null);
|
||||
update(0);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
package funkin;
|
||||
|
||||
import funkin.play.stage.StageData.StageDataParser;
|
||||
import flixel.addons.transition.FlxTransitionSprite.GraphicTransTileDiamond;
|
||||
import flixel.addons.transition.FlxTransitionableState;
|
||||
import flixel.addons.transition.FlxTransitionSprite.GraphicTransTileDiamond;
|
||||
import flixel.addons.transition.TransitionData;
|
||||
import flixel.graphics.FlxGraphic;
|
||||
import flixel.math.FlxPoint;
|
||||
|
@ -10,13 +9,17 @@ import flixel.math.FlxRect;
|
|||
import flixel.system.debug.log.LogStyle;
|
||||
import flixel.util.FlxColor;
|
||||
import funkin.modding.module.ModuleHandler;
|
||||
import funkin.play.PlayState;
|
||||
import funkin.play.character.CharacterData.CharacterDataParser;
|
||||
import funkin.play.cutscene.dialogue.ConversationDataParser;
|
||||
import funkin.play.cutscene.dialogue.DialogueBoxDataParser;
|
||||
import funkin.play.cutscene.dialogue.SpeakerDataParser;
|
||||
import funkin.play.event.SongEventData.SongEventParser;
|
||||
import funkin.play.PlayState;
|
||||
import funkin.play.song.SongData.SongDataParser;
|
||||
import funkin.play.stage.StageData.StageDataParser;
|
||||
import funkin.ui.PreferencesMenu;
|
||||
import funkin.util.WindowUtil;
|
||||
import funkin.util.macro.MacroUtil;
|
||||
import funkin.util.WindowUtil;
|
||||
import openfl.display.BitmapData;
|
||||
#if discord_rpc
|
||||
import Discord.DiscordClient;
|
||||
|
@ -157,6 +160,9 @@ class InitState extends FlxTransitionableState
|
|||
|
||||
funkin.data.level.LevelRegistry.instance.loadEntries();
|
||||
SongEventParser.loadEventCache();
|
||||
ConversationDataParser.loadConversationCache();
|
||||
DialogueBoxDataParser.loadDialogueBoxCache();
|
||||
SpeakerDataParser.loadSpeakerCache();
|
||||
SongDataParser.loadSongCache();
|
||||
StageDataParser.loadStageCache();
|
||||
CharacterDataParser.loadCharacterCache();
|
||||
|
@ -216,7 +222,7 @@ class InitState extends FlxTransitionableState
|
|||
#elseif ANIMATE
|
||||
FlxG.switchState(new funkin.ui.animDebugShit.FlxAnimateTest());
|
||||
#elseif CHARTING
|
||||
FlxG.switchState(new ChartingState());
|
||||
FlxG.switchState(new funkin.ui.debug.charting.ChartEditorState());
|
||||
#elseif STAGEBUILD
|
||||
FlxG.switchState(new StageBuilderState());
|
||||
#elseif FIGHT
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package funkin;
|
||||
|
||||
import funkin.modding.IScriptedClass.IEventHandler;
|
||||
import flixel.FlxState;
|
||||
import flixel.FlxSubState;
|
||||
import flixel.addons.ui.FlxUIState;
|
||||
|
@ -15,7 +16,7 @@ import funkin.util.SortUtil;
|
|||
* MusicBeatState actually represents the core utility FlxState of the game.
|
||||
* It includes functionality for event handling, as well as maintaining BPM-based update events.
|
||||
*/
|
||||
class MusicBeatState extends FlxUIState
|
||||
class MusicBeatState extends FlxUIState implements IEventHandler
|
||||
{
|
||||
var controls(get, never):Controls;
|
||||
|
||||
|
@ -66,9 +67,11 @@ class MusicBeatState extends FlxUIState
|
|||
if (FlxG.keys.justPressed.F5) debug_refreshModules();
|
||||
|
||||
// Display Conductor info in the watch window.
|
||||
FlxG.watch.addQuick("songPos", Conductor.songPosition);
|
||||
FlxG.watch.addQuick("currentStepTime", Conductor.currentStepTime);
|
||||
FlxG.watch.addQuick("songPosition", Conductor.songPosition);
|
||||
FlxG.watch.addQuick("bpm", Conductor.bpm);
|
||||
FlxG.watch.addQuick("currentMeasureTime", Conductor.currentBeatTime);
|
||||
FlxG.watch.addQuick("currentBeatTime", Conductor.currentBeatTime);
|
||||
FlxG.watch.addQuick("currentStepTime", Conductor.currentStepTime);
|
||||
|
||||
dispatchEvent(new UpdateScriptEvent(elapsed));
|
||||
}
|
||||
|
@ -92,7 +95,7 @@ class MusicBeatState extends FlxUIState
|
|||
add(rightWatermarkText);
|
||||
}
|
||||
|
||||
function dispatchEvent(event:ScriptEvent)
|
||||
public function dispatchEvent(event:ScriptEvent)
|
||||
{
|
||||
ModuleHandler.callEvent(event);
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
package funkin;
|
||||
|
||||
import flixel.FlxSubState;
|
||||
import funkin.modding.IScriptedClass.IEventHandler;
|
||||
import flixel.util.FlxColor;
|
||||
import funkin.Conductor.BPMChangeEvent;
|
||||
import funkin.modding.events.ScriptEvent;
|
||||
import funkin.modding.module.ModuleHandler;
|
||||
import flixel.text.FlxText;
|
||||
|
@ -11,7 +11,7 @@ import funkin.modding.PolymodHandler;
|
|||
/**
|
||||
* MusicBeatSubState reincorporates the functionality of MusicBeatState into an FlxSubState.
|
||||
*/
|
||||
class MusicBeatSubState extends FlxSubState
|
||||
class MusicBeatSubState extends FlxSubState implements IEventHandler
|
||||
{
|
||||
public var leftWatermarkText:FlxText = null;
|
||||
public var rightWatermarkText:FlxText = null;
|
||||
|
@ -99,7 +99,7 @@ class MusicBeatSubState extends FlxSubState
|
|||
return true;
|
||||
}
|
||||
|
||||
function dispatchEvent(event:ScriptEvent)
|
||||
public function dispatchEvent(event:ScriptEvent)
|
||||
{
|
||||
ModuleHandler.callEvent(event);
|
||||
}
|
||||
|
|
|
@ -6,7 +6,8 @@ import openfl.utils.Assets as OpenFlAssets;
|
|||
|
||||
class Paths
|
||||
{
|
||||
inline public static var SOUND_EXT = #if web "mp3" #else "ogg" #end;
|
||||
public static var SOUND_EXT = #if web "mp3" #else "ogg" #end;
|
||||
public static var VIDEO_EXT = "mp4";
|
||||
|
||||
static var currentLevel:String;
|
||||
|
||||
|
@ -96,14 +97,19 @@ class Paths
|
|||
return getPath('music/$key.$SOUND_EXT', MUSIC, library);
|
||||
}
|
||||
|
||||
inline static public function voices(song:String, ?suffix:String)
|
||||
inline static public function videos(key:String, ?library:String)
|
||||
{
|
||||
return getPath('videos/$key.$VIDEO_EXT', BINARY, library);
|
||||
}
|
||||
|
||||
inline static public function voices(song:String, ?suffix:String = '')
|
||||
{
|
||||
if (suffix == null) suffix = ""; // no suffix, for a sorta backwards compatibility with older-ish voice files
|
||||
|
||||
return 'songs:assets/songs/${song.toLowerCase()}/Voices$suffix.$SOUND_EXT';
|
||||
}
|
||||
|
||||
inline static public function inst(song:String, ?suffix:String)
|
||||
inline static public function inst(song:String, ?suffix:String = '')
|
||||
{
|
||||
return 'songs:assets/songs/${song.toLowerCase()}/Inst$suffix.$SOUND_EXT';
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import funkin.play.PlayStatePlaylist;
|
|||
import flixel.FlxSprite;
|
||||
import flixel.addons.transition.FlxTransitionableState;
|
||||
import flixel.group.FlxGroup.FlxTypedGroup;
|
||||
import flixel.system.FlxSound;
|
||||
import flixel.sound.FlxSound;
|
||||
import flixel.text.FlxText;
|
||||
import flixel.tweens.FlxEase;
|
||||
import flixel.tweens.FlxTween;
|
||||
|
|
|
@ -12,6 +12,8 @@ import flixel.util.FlxTimer;
|
|||
import funkin.audiovis.SpectogramSprite;
|
||||
import funkin.shaderslmfao.ColorSwap;
|
||||
import funkin.shaderslmfao.LeftMaskShader;
|
||||
import funkin.play.song.SongData.SongDataParser;
|
||||
import funkin.play.song.SongData.SongMetadata;
|
||||
import funkin.shaderslmfao.TitleOutline;
|
||||
import funkin.ui.AtlasText;
|
||||
import funkin.util.Constants;
|
||||
|
@ -135,12 +137,7 @@ class TitleState extends MusicBeatState
|
|||
|
||||
function startIntro()
|
||||
{
|
||||
if (FlxG.sound.music == null || !FlxG.sound.music.playing)
|
||||
{
|
||||
FlxG.sound.playMusic(Paths.music('freakyMenu'), 0);
|
||||
FlxG.sound.music.fadeIn(4, 0, 0.7);
|
||||
Conductor.forceBPM(Constants.FREAKY_MENU_BPM);
|
||||
}
|
||||
playMenuMusic();
|
||||
|
||||
persistentUpdate = true;
|
||||
|
||||
|
@ -234,6 +231,18 @@ class TitleState extends MusicBeatState
|
|||
if (FlxG.sound.music != null) FlxG.sound.music.onComplete = function() FlxG.switchState(new VideoState());
|
||||
}
|
||||
|
||||
function playMenuMusic():Void
|
||||
{
|
||||
if (FlxG.sound.music == null || !FlxG.sound.music.playing)
|
||||
{
|
||||
var freakyMenuMetadata:SongMetadata = SongDataParser.parseMusicMetadata('freakyMenu');
|
||||
Conductor.mapTimeChanges(freakyMenuMetadata.timeChanges);
|
||||
|
||||
FlxG.sound.playMusic(Paths.music('freakyMenu/freakyMenu'), 0);
|
||||
FlxG.sound.music.fadeIn(4, 0, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
function getIntroTextShit():Array<Array<String>>
|
||||
{
|
||||
var fullText:String = Assets.getText(Paths.txt('introText'));
|
||||
|
|
|
@ -8,7 +8,8 @@ import flixel.FlxG; // This one in particular causes a compile error if you're u
|
|||
// These are great.
|
||||
using Lambda;
|
||||
using StringTools;
|
||||
using funkin.util.tools.MapTools;
|
||||
using funkin.util.tools.ArrayTools;
|
||||
using funkin.util.tools.IteratorTools;
|
||||
using funkin.util.tools.MapTools;
|
||||
using funkin.util.tools.StringTools;
|
||||
#end
|
||||
|
|
|
@ -16,6 +16,15 @@ interface IScriptedClass
|
|||
public function onUpdate(event:UpdateScriptEvent):Void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines an element which can receive script events.
|
||||
* For example, the PlayState dispatches the event to all its child elements.
|
||||
*/
|
||||
interface IEventHandler
|
||||
{
|
||||
public function dispatchEvent(event:ScriptEvent):Void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines a set of callbacks available to scripted classes which can follow the game between states.
|
||||
*/
|
||||
|
@ -150,3 +159,19 @@ interface IPlayStateScriptedClass extends IScriptedClass
|
|||
*/
|
||||
public function onCountdownEnd(event:CountdownScriptEvent):Void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines a set of callbacks activated during a dialogue conversation.
|
||||
*/
|
||||
interface IDialogueScriptedClass extends IScriptedClass
|
||||
{
|
||||
/**
|
||||
* Called as the dialogue starts, and before the first dialogue text is displayed.
|
||||
*/
|
||||
public function onDialogueStart(event:DialogueScriptEvent):Void;
|
||||
|
||||
public function onDialogueCompleteLine(event:DialogueScriptEvent):Void;
|
||||
public function onDialogueLine(event:DialogueScriptEvent):Void;
|
||||
public function onDialogueSkip(event:DialogueScriptEvent):Void;
|
||||
public function onDialogueEnd(event:DialogueScriptEvent):Void;
|
||||
}
|
||||
|
|
|
@ -5,4 +5,4 @@ package funkin.modding.base;
|
|||
* Create a scripted class that extends FlxSpriteGroup to use this.
|
||||
*/
|
||||
@:hscriptClass
|
||||
class ScriptedFlxSpriteGroup extends flixel.group.FlxSpriteGroup implements HScriptedClass {}
|
||||
class ScriptedFlxSpriteGroup extends flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup<flixel.FlxSprite> implements HScriptedClass {}
|
||||
|
|
|
@ -3,6 +3,7 @@ package funkin.modding.events;
|
|||
import flixel.FlxState;
|
||||
import flixel.FlxSubState;
|
||||
import funkin.noteStuff.NoteBasic.NoteDir;
|
||||
import funkin.play.cutscene.dialogue.Conversation;
|
||||
import funkin.play.Countdown.CountdownStep;
|
||||
import openfl.events.EventType;
|
||||
import openfl.events.KeyboardEvent;
|
||||
|
@ -230,10 +231,42 @@ class ScriptEvent
|
|||
public static inline final SUBSTATE_CLOSE_END:ScriptEventType = 'SUBSTATE_CLOSE_END';
|
||||
|
||||
/**
|
||||
* Called when the game is exiting the current FlxState.
|
||||
* Called when the game starts a conversation.
|
||||
*
|
||||
* This event is not cancelable.
|
||||
*/
|
||||
public static inline final DIALOGUE_START:ScriptEventType = 'DIALOGUE_START';
|
||||
|
||||
/**
|
||||
* Called to display the next line of conversation.
|
||||
*
|
||||
* This event IS cancelable! Canceling this event will prevent the conversation from moving to the next line.
|
||||
* - This event is called when the conversation starts, or when the user presses ACCEPT to advance the conversation.
|
||||
*/
|
||||
public static inline final DIALOGUE_LINE:ScriptEventType = 'DIALOGUE_LINE';
|
||||
|
||||
/**
|
||||
* Called to skip scrolling the current line of conversation.
|
||||
*
|
||||
* This event IS cancelable! Canceling this event will prevent the conversation from skipping to the next line.
|
||||
* - This event is called when the user presses ACCEPT to advance the conversation while it is already advancing.
|
||||
*/
|
||||
public static inline final DIALOGUE_COMPLETE_LINE:ScriptEventType = 'DIALOGUE_COMPLETE_LINE';
|
||||
|
||||
/**
|
||||
* Called to skip the conversation.
|
||||
*
|
||||
* This event IS cancelable! Canceling this event will prevent the conversation from skipping.
|
||||
*/
|
||||
public static inline final DIALOGUE_SKIP:ScriptEventType = 'DIALOGUE_SKIP';
|
||||
|
||||
/**
|
||||
* Called when the game ends a conversation.
|
||||
*
|
||||
* This event is not cancelable.
|
||||
*/
|
||||
public static inline final DIALOGUE_END:ScriptEventType = 'DIALOGUE_END';
|
||||
|
||||
/**
|
||||
* If true, the behavior associated with this event can be prevented.
|
||||
* For example, cancelling COUNTDOWN_START should prevent the countdown from starting,
|
||||
|
@ -489,6 +522,28 @@ class CountdownScriptEvent extends ScriptEvent
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An event that is fired during a dialogue.
|
||||
*/
|
||||
class DialogueScriptEvent extends ScriptEvent
|
||||
{
|
||||
/**
|
||||
* The dialogue being referenced by the event.
|
||||
*/
|
||||
public var conversation(default, null):Conversation;
|
||||
|
||||
public function new(type:ScriptEventType, conversation:Conversation, cancelable:Bool = true):Void
|
||||
{
|
||||
super(type, cancelable);
|
||||
this.conversation = conversation;
|
||||
}
|
||||
|
||||
public override function toString():String
|
||||
{
|
||||
return 'DialogueScriptEvent(type=$type, conversation=$conversation)';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An event that is fired when the player presses a key.
|
||||
*/
|
||||
|
|
|
@ -45,9 +45,32 @@ class ScriptEventDispatcher
|
|||
}
|
||||
}
|
||||
|
||||
if (Std.isOfType(target, IDialogueScriptedClass))
|
||||
{
|
||||
var t:IDialogueScriptedClass = cast(target, IDialogueScriptedClass);
|
||||
switch (event.type)
|
||||
{
|
||||
case ScriptEvent.DIALOGUE_START:
|
||||
t.onDialogueStart(cast event);
|
||||
return;
|
||||
case ScriptEvent.DIALOGUE_LINE:
|
||||
t.onDialogueLine(cast event);
|
||||
return;
|
||||
case ScriptEvent.DIALOGUE_COMPLETE_LINE:
|
||||
t.onDialogueCompleteLine(cast event);
|
||||
return;
|
||||
case ScriptEvent.DIALOGUE_SKIP:
|
||||
t.onDialogueSkip(cast event);
|
||||
return;
|
||||
case ScriptEvent.DIALOGUE_END:
|
||||
t.onDialogueEnd(cast event);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (Std.isOfType(target, IPlayStateScriptedClass))
|
||||
{
|
||||
var t = cast(target, IPlayStateScriptedClass);
|
||||
var t:IPlayStateScriptedClass = cast(target, IPlayStateScriptedClass);
|
||||
switch (event.type)
|
||||
{
|
||||
case ScriptEvent.NOTE_HIT:
|
||||
|
@ -133,7 +156,7 @@ class ScriptEventDispatcher
|
|||
}
|
||||
|
||||
// If you get a crash on this line, that means ERIC FUCKED UP!
|
||||
throw 'No function called for event type: ${event.type}';
|
||||
// throw 'No function called for event type: ${event.type}';
|
||||
}
|
||||
|
||||
public static function callEventOnAllTargets(targets:Iterator<IScriptedClass>, event:ScriptEvent):Void
|
||||
|
|
|
@ -3,7 +3,7 @@ package funkin.play;
|
|||
import flixel.FlxG;
|
||||
import flixel.FlxObject;
|
||||
import flixel.FlxSprite;
|
||||
import flixel.system.FlxSound;
|
||||
import flixel.sound.FlxSound;
|
||||
import funkin.ui.story.StoryMenuState;
|
||||
import flixel.util.FlxColor;
|
||||
import flixel.util.FlxTimer;
|
||||
|
@ -245,7 +245,7 @@ class GameOverSubState extends MusicBeatSubState
|
|||
}
|
||||
}
|
||||
|
||||
override function dispatchEvent(event:ScriptEvent)
|
||||
public override function dispatchEvent(event:ScriptEvent)
|
||||
{
|
||||
super.dispatchEvent(event);
|
||||
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
package funkin.play;
|
||||
|
||||
import flixel.sound.FlxSound;
|
||||
import funkin.ui.story.StoryMenuState;
|
||||
import flixel.addons.display.FlxPieDial;
|
||||
import flixel.addons.transition.FlxTransitionableState;
|
||||
import flixel.FlxCamera;
|
||||
|
@ -14,6 +12,7 @@ import flixel.input.keyboard.FlxKey;
|
|||
import flixel.math.FlxMath;
|
||||
import flixel.math.FlxPoint;
|
||||
import flixel.math.FlxRect;
|
||||
import flixel.sound.FlxSound;
|
||||
import flixel.text.FlxText;
|
||||
import flixel.tweens.FlxEase;
|
||||
import flixel.tweens.FlxTween;
|
||||
|
@ -28,6 +27,8 @@ import funkin.modding.events.ScriptEventDispatcher;
|
|||
import funkin.Note;
|
||||
import funkin.play.character.BaseCharacter;
|
||||
import funkin.play.character.CharacterData.CharacterDataParser;
|
||||
import funkin.play.cutscene.dialogue.Conversation;
|
||||
import funkin.play.cutscene.dialogue.ConversationDataParser;
|
||||
import funkin.play.cutscene.VanillaCutscenes;
|
||||
import funkin.play.cutscene.VideoCutscene;
|
||||
import funkin.play.event.SongEventData.SongEventParser;
|
||||
|
@ -45,6 +46,7 @@ import funkin.play.Strumline.StrumlineStyle;
|
|||
import funkin.ui.PopUpStuff;
|
||||
import funkin.ui.PreferencesMenu;
|
||||
import funkin.ui.stageBuildShit.StageOffsetSubState;
|
||||
import funkin.ui.story.StoryMenuState;
|
||||
import funkin.util.Constants;
|
||||
import funkin.util.SerializerUtil;
|
||||
import funkin.util.SortUtil;
|
||||
|
@ -222,6 +224,11 @@ class PlayState extends MusicBeatState
|
|||
*/
|
||||
public var disableKeys:Bool = false;
|
||||
|
||||
/**
|
||||
* The current dialogue.
|
||||
*/
|
||||
public var currentConversation:Conversation;
|
||||
|
||||
/**
|
||||
* PRIVATE INSTANCE VARIABLES
|
||||
* Private instance variables should be used for information that must be reset or dereferenced
|
||||
|
@ -887,7 +894,7 @@ class PlayState extends MusicBeatState
|
|||
trace('Song difficulty could not be loaded.');
|
||||
}
|
||||
|
||||
Conductor.forceBPM(currentChart.getStartingBPM());
|
||||
// Conductor.forceBPM(currentChart.getStartingBPM());
|
||||
|
||||
vocals = currentChart.buildVocals(currentPlayerId);
|
||||
if (vocals.members.length == 0)
|
||||
|
@ -1201,13 +1208,10 @@ class PlayState extends MusicBeatState
|
|||
camHUD.zoom = FlxMath.lerp(defaultHUDCameraZoom, camHUD.zoom, 0.95);
|
||||
}
|
||||
|
||||
FlxG.watch.addQuick('beatShit', Conductor.currentBeat);
|
||||
FlxG.watch.addQuick('stepShit', Conductor.currentStep);
|
||||
if (currentStage != null)
|
||||
{
|
||||
FlxG.watch.addQuick('bfAnim', currentStage.getBoyfriend().getCurrentAnimation());
|
||||
}
|
||||
FlxG.watch.addQuick('songPos', Conductor.songPosition);
|
||||
|
||||
// Handle GF dance speed.
|
||||
// TODO: Add a song event for this.
|
||||
|
@ -1425,6 +1429,7 @@ class PlayState extends MusicBeatState
|
|||
// Handle keybinds.
|
||||
if (!isInCutscene && !disableKeys) keyShit(true);
|
||||
if (!isInCutscene && !disableKeys) debugKeyShit();
|
||||
if (isInCutscene && !disableKeys) handleCutsceneKeys(elapsed);
|
||||
|
||||
// Dispatch the onUpdate event to scripted elements.
|
||||
dispatchEvent(new UpdateScriptEvent(elapsed));
|
||||
|
@ -1432,6 +1437,36 @@ class PlayState extends MusicBeatState
|
|||
|
||||
static final CUTSCENE_KEYS:Array<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
|
||||
{
|
||||
if (skipTimer == null || skipTimer.animation == null) return;
|
||||
|
@ -2369,9 +2404,9 @@ class PlayState extends MusicBeatState
|
|||
camHUD.visible = true;
|
||||
}
|
||||
|
||||
override function dispatchEvent(event:ScriptEvent):Void
|
||||
public override function dispatchEvent(event:ScriptEvent):Void
|
||||
{
|
||||
// ORDER: Module, Stage, Character, Song, Note
|
||||
// ORDER: Module, Stage, Character, Song, Conversation, Note
|
||||
// Modules should get the first chance to cancel the event.
|
||||
|
||||
// super.dispatchEvent(event) dispatches event to module scripts.
|
||||
|
@ -2383,11 +2418,55 @@ class PlayState extends MusicBeatState
|
|||
// Dispatch event to character script(s).
|
||||
if (currentStage != null) currentStage.dispatchToCharacters(event);
|
||||
|
||||
// Dispatch event to song script.
|
||||
ScriptEventDispatcher.callEvent(currentSong, event);
|
||||
|
||||
// Dispatch event to conversation script.
|
||||
ScriptEventDispatcher.callEvent(currentConversation, event);
|
||||
|
||||
// TODO: Dispatch event to note scripts
|
||||
}
|
||||
|
||||
public function startConversation(conversationId:String):Void
|
||||
{
|
||||
isInCutscene = true;
|
||||
|
||||
currentConversation = ConversationDataParser.fetchConversation(conversationId);
|
||||
if (currentConversation == null) return;
|
||||
|
||||
currentConversation.completeCallback = onConversationComplete;
|
||||
currentConversation.cameras = [camCutscene];
|
||||
currentConversation.zIndex = 1000;
|
||||
add(currentConversation);
|
||||
refresh();
|
||||
|
||||
var event:ScriptEvent = new ScriptEvent(ScriptEvent.CREATE, false);
|
||||
ScriptEventDispatcher.callEvent(currentConversation, event);
|
||||
}
|
||||
|
||||
function onConversationComplete():Void
|
||||
{
|
||||
isInCutscene = true;
|
||||
remove(currentConversation);
|
||||
currentConversation = null;
|
||||
|
||||
if (startingSong && !isInCountdown)
|
||||
{
|
||||
startCountdown();
|
||||
}
|
||||
}
|
||||
|
||||
override function destroy():Void
|
||||
{
|
||||
if (currentConversation != null)
|
||||
{
|
||||
remove(currentConversation);
|
||||
currentConversation.kill();
|
||||
}
|
||||
|
||||
super.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the position and contents of the score display.
|
||||
*/
|
||||
|
|
|
@ -44,10 +44,12 @@ class SparrowCharacter extends BaseCharacter
|
|||
|
||||
if (_data.isPixel)
|
||||
{
|
||||
this.isPixel = true;
|
||||
this.antialiasing = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
this.isPixel = false;
|
||||
this.antialiasing = true;
|
||||
}
|
||||
|
||||
|
|
|
@ -30,7 +30,8 @@ class VideoCutscene
|
|||
|
||||
if (!openfl.Assets.exists(filePath))
|
||||
{
|
||||
trace('ERROR: Video file does not exist: ${filePath}');
|
||||
// Display a popup.
|
||||
lime.app.Application.current.window.alert('Video file does not exist: ${filePath}', 'Error playing video');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
617
source/funkin/play/cutscene/dialogue/Conversation.hx
Normal file
617
source/funkin/play/cutscene/dialogue/Conversation.hx
Normal 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;
|
||||
}
|
236
source/funkin/play/cutscene/dialogue/ConversationData.hx
Normal file
236
source/funkin/play/cutscene/dialogue/ConversationData.hx
Normal 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';
|
||||
}
|
162
source/funkin/play/cutscene/dialogue/ConversationDataParser.hx
Normal file
162
source/funkin/play/cutscene/dialogue/ConversationDataParser.hx
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
377
source/funkin/play/cutscene/dialogue/DialogueBox.hx
Normal file
377
source/funkin/play/cutscene/dialogue/DialogueBox.hx
Normal 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 {}
|
||||
}
|
123
source/funkin/play/cutscene/dialogue/DialogueBoxData.hx
Normal file
123
source/funkin/play/cutscene/dialogue/DialogueBoxData.hx
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
159
source/funkin/play/cutscene/dialogue/DialogueBoxDataParser.hx
Normal file
159
source/funkin/play/cutscene/dialogue/DialogueBoxDataParser.hx
Normal 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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
package funkin.play.cutscene.dialogue;
|
||||
|
||||
@:hscriptClass
|
||||
class ScriptedConversation extends Conversation implements polymod.hscript.HScriptedClass {}
|
|
@ -0,0 +1,4 @@
|
|||
package funkin.play.cutscene.dialogue;
|
||||
|
||||
@:hscriptClass
|
||||
class ScriptedDialogueBox extends DialogueBox implements polymod.hscript.HScriptedClass {}
|
4
source/funkin/play/cutscene/dialogue/ScriptedSpeaker.hx
Normal file
4
source/funkin/play/cutscene/dialogue/ScriptedSpeaker.hx
Normal file
|
@ -0,0 +1,4 @@
|
|||
package funkin.play.cutscene.dialogue;
|
||||
|
||||
@:hscriptClass
|
||||
class ScriptedSpeaker extends Speaker implements polymod.hscript.HScriptedClass {}
|
274
source/funkin/play/cutscene/dialogue/Speaker.hx
Normal file
274
source/funkin/play/cutscene/dialogue/Speaker.hx
Normal 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 {}
|
||||
}
|
76
source/funkin/play/cutscene/dialogue/SpeakerData.hx
Normal file
76
source/funkin/play/cutscene/dialogue/SpeakerData.hx
Normal 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;
|
||||
}
|
||||
}
|
159
source/funkin/play/cutscene/dialogue/SpeakerDataParser.hx
Normal file
159
source/funkin/play/cutscene/dialogue/SpeakerDataParser.hx
Normal 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;
|
||||
}
|
||||
}
|
|
@ -3,6 +3,8 @@ package funkin.play.song;
|
|||
import funkin.modding.events.ScriptEventDispatcher;
|
||||
import funkin.modding.events.ScriptEvent;
|
||||
import flixel.util.typeLimit.OneOfTwo;
|
||||
import funkin.modding.events.ScriptEvent;
|
||||
import funkin.modding.events.ScriptEventDispatcher;
|
||||
import funkin.play.song.ScriptedSong;
|
||||
import funkin.util.assets.DataAssets;
|
||||
import haxe.DynamicAccess;
|
||||
|
@ -22,6 +24,7 @@ class SongDataParser
|
|||
|
||||
static final DEFAULT_SONG_ID:String = 'UNKNOWN';
|
||||
static final SONG_DATA_PATH:String = 'songs/';
|
||||
static final MUSIC_DATA_PATH:String = 'music/';
|
||||
static final SONG_DATA_SUFFIX:String = '-metadata.json';
|
||||
|
||||
/**
|
||||
|
@ -184,6 +187,36 @@ class SongDataParser
|
|||
return rawJson;
|
||||
}
|
||||
|
||||
public static function parseMusicMetadata(musicId:String):SongMetadata
|
||||
{
|
||||
var rawJson:String = loadMusicMetadataFile(musicId);
|
||||
var jsonData:Dynamic = null;
|
||||
try
|
||||
{
|
||||
jsonData = Json.parse(rawJson);
|
||||
}
|
||||
catch (e) {}
|
||||
|
||||
var musicMetadata:SongMetadata = SongMigrator.migrateSongMetadata(jsonData, musicId);
|
||||
musicMetadata = SongValidator.validateSongMetadata(musicMetadata, musicId);
|
||||
|
||||
return musicMetadata;
|
||||
}
|
||||
|
||||
static function loadMusicMetadataFile(musicPath:String, variation:String = ''):String
|
||||
{
|
||||
var musicMetadataFilePath:String = (variation != '') ? Paths.file('$MUSIC_DATA_PATH$musicPath/$musicPath-metadata-$variation.json') : Paths.file('$MUSIC_DATA_PATH$musicPath/$musicPath-metadata.json');
|
||||
|
||||
var rawJson:String = Assets.getText(musicMetadataFilePath).trim();
|
||||
|
||||
while (!rawJson.endsWith("}"))
|
||||
{
|
||||
rawJson = rawJson.substr(0, rawJson.length - 1);
|
||||
}
|
||||
|
||||
return rawJson;
|
||||
}
|
||||
|
||||
public static function parseSongChartData(songId:String, variation:String = ''):SongChartData
|
||||
{
|
||||
var rawJson:String = loadSongChartDataFile(songId, variation);
|
||||
|
@ -376,8 +409,7 @@ abstract SongNoteData(RawSongNoteData)
|
|||
|
||||
function get_stepTime():Float
|
||||
{
|
||||
// TODO: Account for changes in BPM.
|
||||
return this.t / Conductor.stepLengthMs;
|
||||
return Conductor.getTimeInSteps(this.t);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -562,8 +594,7 @@ abstract SongEventData(RawSongEventData)
|
|||
|
||||
function get_stepTime():Float
|
||||
{
|
||||
// TODO: Account for changes in BPM.
|
||||
return this.t / Conductor.stepLengthMs;
|
||||
return Conductor.getTimeInSteps(this.t);
|
||||
}
|
||||
|
||||
public var event(get, set):String;
|
||||
|
@ -805,7 +836,7 @@ typedef RawSongTimeChange =
|
|||
* Time in beats (int). The game will calculate further beat values based on this one,
|
||||
* so it can do it in a simple linear fashion.
|
||||
*/
|
||||
var b:Int;
|
||||
var b:Null<Float>;
|
||||
|
||||
/**
|
||||
* Quarter notes per minute (float). Cannot be empty in the first element of the list,
|
||||
|
@ -835,9 +866,9 @@ typedef RawSongTimeChange =
|
|||
* Add aliases to the minimalized property names of the typedef,
|
||||
* to improve readability.
|
||||
*/
|
||||
abstract SongTimeChange(RawSongTimeChange)
|
||||
abstract SongTimeChange(RawSongTimeChange) from RawSongTimeChange
|
||||
{
|
||||
public function new(timeStamp:Float, beatTime:Int, bpm:Float, timeSignatureNum:Int = 4, timeSignatureDen:Int = 4, beatTuplets:Array<Int>)
|
||||
public function new(timeStamp:Float, beatTime:Null<Float>, bpm:Float, timeSignatureNum:Int = 4, timeSignatureDen:Int = 4, beatTuplets:Array<Int>)
|
||||
{
|
||||
this =
|
||||
{
|
||||
|
@ -862,7 +893,7 @@ abstract SongTimeChange(RawSongTimeChange)
|
|||
return this.t = value;
|
||||
}
|
||||
|
||||
public var beatTime(get, set):Int;
|
||||
public var beatTime(get, set):Null<Float>;
|
||||
|
||||
function get_beatTime():Int
|
||||
{
|
||||
|
|
|
@ -2057,7 +2057,7 @@ class ChartEditorState extends HaxeUIState
|
|||
{
|
||||
// Handle extending the note as you drag.
|
||||
|
||||
// Since use Math.floor and stepCrochet here, the hold notes will be beat snapped.
|
||||
// Since use Math.floor and stepLengthMs here, the hold notes will be beat snapped.
|
||||
var dragLengthSteps:Float = Math.floor((cursorMs - currentPlaceNoteData.time) / Conductor.stepLengthMs);
|
||||
|
||||
// Without this, the newly placed note feels too short compared to the user's input.
|
||||
|
@ -2770,7 +2770,7 @@ class ChartEditorState extends HaxeUIState
|
|||
}
|
||||
}
|
||||
|
||||
override function dispatchEvent(event:ScriptEvent):Void
|
||||
public override function dispatchEvent(event:ScriptEvent):Void
|
||||
{
|
||||
super.dispatchEvent(event);
|
||||
|
||||
|
|
|
@ -15,6 +15,7 @@ import funkin.modding.events.ScriptEventDispatcher;
|
|||
import funkin.play.PlayState;
|
||||
import funkin.play.PlayStatePlaylist;
|
||||
import funkin.play.song.Song;
|
||||
import funkin.play.song.SongData.SongMetadata;
|
||||
import funkin.play.song.SongData.SongDataParser;
|
||||
import funkin.util.Constants;
|
||||
|
||||
|
@ -115,12 +116,7 @@ class StoryMenuState extends MusicBeatState
|
|||
transIn = FlxTransitionableState.defaultTransIn;
|
||||
transOut = FlxTransitionableState.defaultTransOut;
|
||||
|
||||
if (!FlxG.sound.music.playing)
|
||||
{
|
||||
FlxG.sound.playMusic(Paths.music('freakyMenu'));
|
||||
FlxG.sound.music.fadeIn(4, 0, 0.7);
|
||||
}
|
||||
Conductor.forceBPM(Constants.FREAKY_MENU_BPM);
|
||||
playMenuMusic();
|
||||
|
||||
if (stickerSubState != null)
|
||||
{
|
||||
|
@ -129,8 +125,6 @@ class StoryMenuState extends MusicBeatState
|
|||
|
||||
openSubState(stickerSubState);
|
||||
stickerSubState.degenStickers();
|
||||
|
||||
// resetSubState();
|
||||
}
|
||||
|
||||
persistentUpdate = persistentDraw = true;
|
||||
|
@ -203,6 +197,18 @@ class StoryMenuState extends MusicBeatState
|
|||
#end
|
||||
}
|
||||
|
||||
function playMenuMusic():Void
|
||||
{
|
||||
if (FlxG.sound.music == null || !FlxG.sound.music.playing)
|
||||
{
|
||||
var freakyMenuMetadata:SongMetadata = SongDataParser.parseMusicMetadata('freakyMenu');
|
||||
Conductor.mapTimeChanges(freakyMenuMetadata.timeChanges);
|
||||
|
||||
FlxG.sound.playMusic(Paths.music('freakyMenu/freakyMenu'), 0);
|
||||
FlxG.sound.music.fadeIn(4, 0, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
function updateData():Void
|
||||
{
|
||||
currentLevel = LevelRegistry.instance.fetchEntry(currentLevelId);
|
||||
|
@ -459,7 +465,7 @@ class StoryMenuState extends MusicBeatState
|
|||
}
|
||||
}
|
||||
|
||||
override function dispatchEvent(event:ScriptEvent):Void
|
||||
public override function dispatchEvent(event:ScriptEvent):Void
|
||||
{
|
||||
// super.dispatchEvent(event) dispatches event to module scripts.
|
||||
super.dispatchEvent(event);
|
||||
|
|
|
@ -118,27 +118,72 @@ class Constants
|
|||
public static final DEFAULT_SONG:String = 'tutorial';
|
||||
|
||||
/**
|
||||
* OTHER
|
||||
* TIMING
|
||||
*/
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* The number of seconds in a minute.
|
||||
*/
|
||||
public static final SECS_PER_MIN:Int = 60;
|
||||
|
||||
/**
|
||||
* The number of milliseconds in a second.
|
||||
*/
|
||||
public static final MS_PER_SEC:Int = 1000;
|
||||
|
||||
/**
|
||||
* The number of microseconds in a millisecond.
|
||||
*/
|
||||
public static final US_PER_MS:Int = 1000;
|
||||
|
||||
/**
|
||||
* The number of microseconds in a second.
|
||||
*/
|
||||
public static final US_PER_SEC:Int = US_PER_MS * MS_PER_SEC;
|
||||
|
||||
/**
|
||||
* The number of nanoseconds in a microsecond.
|
||||
*/
|
||||
public static final NS_PER_US:Int = 1000;
|
||||
|
||||
/**
|
||||
* The number of nanoseconds in a millisecond.
|
||||
*/
|
||||
public static final NS_PER_MS:Int = NS_PER_US * US_PER_MS;
|
||||
|
||||
/**
|
||||
* The number of nanoseconds in a second.
|
||||
*/
|
||||
public static final NS_PER_SEC:Int = NS_PER_US * US_PER_MS * MS_PER_SEC;
|
||||
|
||||
/**
|
||||
* All MP3 decoders introduce a playback delay of `528` samples,
|
||||
* which at 44,100 Hz (samples per second) is ~12 ms.
|
||||
*/
|
||||
public static final MP3_DELAY_MS:Float = 528 / 44100 * 1000;
|
||||
public static final MP3_DELAY_MS:Float = 528 / 44100 * MS_PER_SEC;
|
||||
|
||||
/**
|
||||
* The default BPM of the conductor.
|
||||
*/
|
||||
public static final DEFAULT_BPM:Float = 100.0;
|
||||
|
||||
public static final DEFAULT_TIME_SIGNATURE_NUM:Int = 4;
|
||||
|
||||
public static final DEFAULT_TIME_SIGNATURE_DEN:Int = 4;
|
||||
|
||||
public static final STEPS_PER_BEAT:Int = 4;
|
||||
|
||||
/**
|
||||
* OTHER
|
||||
*/
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* The scale factor to use when increasing the size of pixel art graphics.
|
||||
*/
|
||||
public static final PIXEL_ART_SCALE:Float = 6;
|
||||
|
||||
/**
|
||||
* The BPM of the title screen and menu music.
|
||||
* TODO: Move to metadata file.
|
||||
*/
|
||||
public static final FREAKY_MENU_BPM:Float = 102;
|
||||
|
||||
/**
|
||||
* The volume at which to play the countdown before the song starts.
|
||||
*/
|
||||
|
|
Loading…
Reference in a new issue