diff --git a/.vscode/settings.json b/.vscode/settings.json
index cefbadcf6..3d1f488f7 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -110,6 +110,11 @@
"target": "windows",
"args": ["-debug", "-DSONG=bopeebo -DDIFFICULTY=normal"]
},
+ {
+ "label": "Windows / Debug (Conversation Test)",
+ "target": "windows",
+ "args": ["-debug", "-DDIALOGUE"]
+ },
{
"label": "Windows / Debug (Straight to Chart Editor)",
"target": "windows",
@@ -125,6 +130,11 @@
"target": "windows",
"args": ["-debug", "-DLATENCY"]
},
+ {
+ "label": "Windows / Debug (Waveform Test)",
+ "target": "windows",
+ "args": ["-debug", "-DWAVEFORM"]
+ },
{
"label": "HTML5 / Debug",
"target": "html5",
diff --git a/Project.xml b/Project.xml
index f5d506688..40f309e1f 100644
--- a/Project.xml
+++ b/Project.xml
@@ -52,6 +52,7 @@
+
@@ -82,14 +83,15 @@
If we can exclude the `mods` folder from the manifest, we can re-enable this line.
-->
-
-
+
+
+
@@ -108,7 +110,6 @@
-
diff --git a/assets b/assets
index 30ad76c15..594853037 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 30ad76c15d88bdff2b41ec017e3f3089ede52411
+Subproject commit 594853037cbea06caa5c141b0d9ed3736818e592
diff --git a/hmm.json b/hmm.json
index 542feaf01..15206ba7d 100644
--- a/hmm.json
+++ b/hmm.json
@@ -18,7 +18,7 @@
"name": "flixel-addons",
"type": "git",
"dir": null,
- "ref": "fd3aecdeb5635fa0428dffee204fc78fc26b5885",
+ "ref": "a523c3b56622f0640933944171efed46929e360e",
"url": "https://github.com/FunkinCrew/flixel-addons"
},
{
@@ -54,7 +54,7 @@
"name": "haxeui-core",
"type": "git",
"dir": null,
- "ref": "a551159",
+ "ref": "8a7846b",
"url": "https://github.com/haxeui/haxeui-core"
},
{
@@ -149,18 +149,13 @@
"name": "polymod",
"type": "git",
"dir": null,
- "ref": "cb11a95d0159271eb3587428cf4b9602e46dc469",
+ "ref": "6cec79e4f322fbb262170594ed67ab72b4714810",
"url": "https://github.com/larsiusprime/polymod"
},
{
"name": "thx.semver",
"type": "haxelib",
"version": "0.2.2"
- },
- {
- "name": "tink_json",
- "type": "haxelib",
- "version": "0.11.0"
}
]
}
diff --git a/source/Main.hx b/source/Main.hx
index 86e520e69..754d0732f 100644
--- a/source/Main.hx
+++ b/source/Main.hx
@@ -33,6 +33,8 @@ class Main extends Sprite
public static function main():Void
{
+ haxe.Log.trace = funkin.util.logging.AnsiTrace.trace;
+ funkin.util.logging.AnsiTrace.traceBF();
Lib.current.addChild(new Main());
}
diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx
index c9198c3d4..625a33ad7 100644
--- a/source/funkin/InitState.hx
+++ b/source/funkin/InitState.hx
@@ -21,9 +21,9 @@ import funkin.data.level.LevelRegistry;
import funkin.data.notestyle.NoteStyleRegistry;
import funkin.data.event.SongEventRegistry;
import funkin.data.stage.StageRegistry;
-import funkin.play.cutscene.dialogue.ConversationDataParser;
-import funkin.play.cutscene.dialogue.DialogueBoxDataParser;
-import funkin.play.cutscene.dialogue.SpeakerDataParser;
+import funkin.data.dialogue.ConversationRegistry;
+import funkin.data.dialogue.DialogueBoxRegistry;
+import funkin.data.dialogue.SpeakerRegistry;
import funkin.data.song.SongRegistry;
import funkin.play.character.CharacterData.CharacterDataParser;
import funkin.modding.module.ModuleHandler;
@@ -208,22 +208,29 @@ class InitState extends FlxState
// GAME DATA PARSING
//
- // NOTE: Registries and data parsers must be imported and not referenced with fully qualified names,
+ // NOTE: Registries must be imported and not referenced with fully qualified names,
// to ensure build macros work properly.
+ trace('Parsing game data...');
+ var perfStart = haxe.Timer.stamp();
+ SongEventRegistry.loadEventCache(); // SongEventRegistry is structured differently so it's not a BaseRegistry.
SongRegistry.instance.loadEntries();
LevelRegistry.instance.loadEntries();
NoteStyleRegistry.instance.loadEntries();
- SongEventRegistry.loadEventCache();
- ConversationDataParser.loadConversationCache();
- DialogueBoxDataParser.loadDialogueBoxCache();
- SpeakerDataParser.loadSpeakerCache();
+ ConversationRegistry.instance.loadEntries();
+ DialogueBoxRegistry.instance.loadEntries();
+ SpeakerRegistry.instance.loadEntries();
StageRegistry.instance.loadEntries();
- CharacterDataParser.loadCharacterCache();
+
+ // TODO: CharacterDataParser doesn't use json2object, so it's way slower than the other parsers.
+ CharacterDataParser.loadCharacterCache(); // TODO: Migrate characters to BaseRegistry.
ModuleHandler.buildModuleCallbacks();
ModuleHandler.loadModuleCache();
-
ModuleHandler.callOnCreate();
+
+ var perfEnd = haxe.Timer.stamp();
+
+ trace('Parsing game data took ${Math.floor((perfEnd - perfStart) * 1000)}ms.');
}
/**
@@ -241,8 +248,12 @@ class InitState extends FlxState
startLevel(defineLevel(), defineDifficulty());
#elseif FREEPLAY // -DFREEPLAY
FlxG.switchState(new FreeplayState());
+ #elseif DIALOGUE // -DDIALOGUE
+ FlxG.switchState(new funkin.ui.debug.dialogue.ConversationDebugState());
#elseif ANIMATE // -DANIMATE
FlxG.switchState(new funkin.ui.debug.anim.FlxAnimateTest());
+ #elseif WAVEFORM // -DWAVEFORM
+ FlxG.switchState(new funkin.ui.debug.WaveformTestState());
#elseif CHARTING // -DCHARTING
FlxG.switchState(new funkin.ui.debug.charting.ChartEditorState());
#elseif STAGEBUILD // -DSTAGEBUILD
diff --git a/source/funkin/audio/FunkinSound.hx b/source/funkin/audio/FunkinSound.hx
index 278578fb3..e7ce68d08 100644
--- a/source/funkin/audio/FunkinSound.hx
+++ b/source/funkin/audio/FunkinSound.hx
@@ -7,6 +7,10 @@ import flash.utils.ByteArray;
import flixel.sound.FlxSound;
import flixel.group.FlxGroup.FlxTypedGroup;
import flixel.system.FlxAssets.FlxSoundAsset;
+import funkin.util.tools.ICloneable;
+import funkin.audio.waveform.WaveformData;
+import funkin.audio.waveform.WaveformDataParser;
+import flixel.math.FlxMath;
import openfl.Assets;
#if (openfl >= "8.0.0")
import openfl.utils.AssetType;
@@ -17,10 +21,38 @@ import openfl.utils.AssetType;
* - Delayed playback via negative song position.
*/
@:nullSafety
-class FunkinSound extends FlxSound
+class FunkinSound extends FlxSound implements ICloneable
{
+ static final MAX_VOLUME:Float = 2.0;
+
static var cache(default, null):FlxTypedGroup = new FlxTypedGroup();
+ public var muted(default, set):Bool = false;
+
+ function set_muted(value:Bool):Bool
+ {
+ if (value == muted) return value;
+ muted = value;
+ updateTransform();
+ return value;
+ }
+
+ override function set_volume(value:Float):Float
+ {
+ // Uncap the volume.
+ fixMaxVolume();
+ _volume = FlxMath.bound(value, 0.0, MAX_VOLUME);
+ updateTransform();
+ return _volume;
+ }
+
+ public var paused(get, never):Bool;
+
+ function get_paused():Bool
+ {
+ return this._paused;
+ }
+
public var isPlaying(get, never):Bool;
function get_isPlaying():Bool
@@ -28,6 +60,24 @@ class FunkinSound extends FlxSound
return this.playing || this._shouldPlay;
}
+ /**
+ * Waveform data for this sound.
+ * This is lazily loaded, so it will be built the first time it is accessed.
+ */
+ public var waveformData(get, never):WaveformData;
+
+ var _waveformData:Null = null;
+
+ function get_waveformData():WaveformData
+ {
+ if (_waveformData == null)
+ {
+ _waveformData = WaveformDataParser.interpretFlxSound(this);
+ if (_waveformData == null) throw 'Could not interpret waveform data!';
+ }
+ return _waveformData;
+ }
+
/**
* Are we in a state where the song should play but time is negative?
*/
@@ -63,6 +113,30 @@ class FunkinSound extends FlxSound
}
}
+ public function togglePlayback():FunkinSound
+ {
+ if (playing)
+ {
+ pause();
+ }
+ else
+ {
+ resume();
+ }
+ return this;
+ }
+
+ function fixMaxVolume():Void
+ {
+ #if lime_openal
+ // This code is pretty fragile, it reaches through 5 layers of private access.
+ @:privateAccess
+ var handle = this?._channel?.__source?.__backend?.handle;
+ if (handle == null) return;
+ lime.media.openal.AL.sourcef(handle, lime.media.openal.AL.MAX_GAIN, MAX_VOLUME);
+ #end
+ }
+
public override function play(forceRestart:Bool = false, startTime:Float = 0, ?endTime:Float):FunkinSound
{
if (!exists) return this;
@@ -140,6 +214,37 @@ class FunkinSound extends FlxSound
return this;
}
+ /**
+ * Call after adjusting the volume to update the sound channel's settings.
+ */
+ @:allow(flixel.sound.FlxSoundGroup)
+ override function updateTransform():Void
+ {
+ _transform.volume = #if FLX_SOUND_SYSTEM ((FlxG.sound.muted || this.muted) ? 0 : 1) * FlxG.sound.volume * #end
+ (group != null ? group.volume : 1) * _volume * _volumeAdjust;
+
+ if (_channel != null) _channel.soundTransform = _transform;
+ }
+
+ public function clone():FunkinSound
+ {
+ var sound:FunkinSound = new FunkinSound();
+
+ // Clone the sound by creating one with the same data buffer.
+ // Reusing the `Sound` object directly causes issues with playback.
+ @:privateAccess
+ sound._sound = openfl.media.Sound.fromAudioBuffer(this._sound.__buffer);
+
+ // Call init to ensure the FlxSound is properly initialized.
+ sound.init(this.looped, this.autoDestroy, this.onComplete);
+
+ // Oh yeah, the waveform data is the same too!
+ @:privateAccess
+ sound._waveformData = this._waveformData;
+
+ return sound;
+ }
+
/**
* Creates a new `FunkinSound` object.
*
diff --git a/source/funkin/audio/SoundGroup.hx b/source/funkin/audio/SoundGroup.hx
index 528aaa80c..df3a67ae1 100644
--- a/source/funkin/audio/SoundGroup.hx
+++ b/source/funkin/audio/SoundGroup.hx
@@ -3,6 +3,7 @@ package funkin.audio;
import flixel.group.FlxGroup.FlxTypedGroup;
import flixel.sound.FlxSound;
import funkin.audio.FunkinSound;
+import flixel.tweens.FlxTween;
/**
* A group of FunkinSounds that are all synced together.
@@ -14,8 +15,12 @@ class SoundGroup extends FlxTypedGroup
public var volume(get, set):Float;
+ public var muted(get, set):Bool;
+
public var pitch(get, set):Float;
+ public var playing(get, never):Bool;
+
public function new()
{
super();
@@ -122,6 +127,26 @@ class SoundGroup extends FlxTypedGroup
});
}
+ /**
+ * Fade in all the sounds in the group.
+ */
+ public function fadeIn(duration:Float, ?from:Float = 0.0, ?to:Float = 1.0, ?onComplete:FlxTween->Void):Void
+ {
+ forEachAlive(function(sound:FunkinSound) {
+ sound.fadeIn(duration, from, to, onComplete);
+ });
+ }
+
+ /**
+ * Fade out all the sounds in the group.
+ */
+ public function fadeOut(duration:Float, ?to:Float = 0.0, ?onComplete:FlxTween->Void):Void
+ {
+ forEachAlive(function(sound:FunkinSound) {
+ sound.fadeOut(duration, to, onComplete);
+ });
+ }
+
/**
* Stop all the sounds in the group.
*/
@@ -165,6 +190,13 @@ class SoundGroup extends FlxTypedGroup
return time;
}
+ function get_playing():Bool
+ {
+ if (getFirstAlive != null) return getFirstAlive().playing;
+ else
+ return false;
+ }
+
function get_volume():Float
{
if (getFirstAlive() != null) return getFirstAlive().volume;
@@ -182,6 +214,22 @@ class SoundGroup extends FlxTypedGroup
return volume;
}
+ function get_muted():Bool
+ {
+ if (getFirstAlive() != null) return getFirstAlive().muted;
+ else
+ return false;
+ }
+
+ function set_muted(muted:Bool):Bool
+ {
+ forEachAlive(function(snd:FunkinSound) {
+ snd.muted = muted;
+ });
+
+ return muted;
+ }
+
function get_pitch():Float
{
#if FLX_PITCH
diff --git a/source/funkin/audio/VoicesGroup.hx b/source/funkin/audio/VoicesGroup.hx
index 42f31af70..5daebc89d 100644
--- a/source/funkin/audio/VoicesGroup.hx
+++ b/source/funkin/audio/VoicesGroup.hx
@@ -2,6 +2,8 @@ package funkin.audio;
import funkin.audio.FunkinSound;
import flixel.group.FlxGroup.FlxTypedGroup;
+import funkin.audio.waveform.WaveformData;
+import funkin.audio.waveform.WaveformDataParser;
class VoicesGroup extends SoundGroup
{
@@ -104,6 +106,50 @@ class VoicesGroup extends SoundGroup
return opponentVolume = volume;
}
+ public function getPlayerVoice(index:Int = 0):Null
+ {
+ return playerVoices.members[index];
+ }
+
+ public function getOpponentVoice(index:Int = 0):Null
+ {
+ return opponentVoices.members[index];
+ }
+
+ public function getPlayerVoiceWaveform():Null
+ {
+ if (playerVoices.members.length == 0) return null;
+
+ return playerVoices.members[0].waveformData;
+ }
+
+ public function getOpponentVoiceWaveform():Null
+ {
+ if (opponentVoices.members.length == 0) return null;
+
+ return opponentVoices.members[0].waveformData;
+ }
+
+ /**
+ * The length of the player's vocal track, in milliseconds.
+ */
+ public function getPlayerVoiceLength():Float
+ {
+ if (playerVoices.members.length == 0) return 0.0;
+
+ return playerVoices.members[0].length;
+ }
+
+ /**
+ * The length of the opponent's vocal track, in milliseconds.
+ */
+ public function getOpponentVoiceLength():Float
+ {
+ if (opponentVoices.members.length == 0) return 0.0;
+
+ return opponentVoices.members[0].length;
+ }
+
public override function clear():Void
{
playerVoices.clear();
diff --git a/source/funkin/audio/visualize/PolygonSpectogram.hx b/source/funkin/audio/visualize/PolygonSpectogram.hx
index 37a6c15d1..948027a8d 100644
--- a/source/funkin/audio/visualize/PolygonSpectogram.hx
+++ b/source/funkin/audio/visualize/PolygonSpectogram.hx
@@ -102,7 +102,7 @@ class PolygonSpectogram extends MeshRender
coolPoint.x = (curAud.balanced * waveAmplitude);
coolPoint.y = (i / funnyPixels * daHeight);
- add_quad(prevPoint.x, prevPoint.y, prevPoint.x
+ build_quad(prevPoint.x, prevPoint.y, prevPoint.x
+ thickness, prevPoint.y, coolPoint.x, coolPoint.y, coolPoint.x
+ thickness, coolPoint.y
+ thickness);
diff --git a/source/funkin/audio/waveform/WaveformData.hx b/source/funkin/audio/waveform/WaveformData.hx
new file mode 100644
index 000000000..b82d141e7
--- /dev/null
+++ b/source/funkin/audio/waveform/WaveformData.hx
@@ -0,0 +1,336 @@
+package funkin.audio.waveform;
+
+import funkin.util.MathUtil;
+
+@:nullSafety
+class WaveformData
+{
+ static final DEFAULT_VERSION:Int = 2;
+
+ /**
+ * The version of the waveform data format.
+ * @default `2` (-1 if not specified/invalid)
+ */
+ public var version(default, null):Int = -1;
+
+ /**
+ * The number of channels in the waveform.
+ */
+ public var channels(default, null):Int = 1;
+
+ @:alias('sample_rate')
+ public var sampleRate(default, null):Int = 44100;
+
+ /**
+ * Number of input audio samples per output waveform data point.
+ * At base zoom level this is number of samples per pixel.
+ * Lower values can more accurately represent the waveform when zoomed in, but take more data.
+ */
+ @:alias('samples_per_pixel')
+ public var samplesPerPoint(default, null):Int = 256;
+
+ /**
+ * Number of bits to use for each sample value. Valid values are `8` and `16`.
+ */
+ public var bits(default, null):Int = 16;
+
+ /**
+ * The length of the data array, in points.
+ */
+ public var length(default, null):Int = 0;
+
+ /**
+ * Array of Int16 values representing the waveform.
+ * TODO: Use an `openfl.Vector` for performance.
+ */
+ public var data(default, null):Array = [];
+
+ @:jignored
+ var channelData:Null> = null;
+
+ public function new(?version:Int, channels:Int, sampleRate:Int, samplesPerPoint:Int, bits:Int, length:Int, data:Array)
+ {
+ this.version = version ?? DEFAULT_VERSION;
+ this.channels = channels;
+ this.sampleRate = sampleRate;
+ this.samplesPerPoint = samplesPerPoint;
+ this.bits = bits;
+ this.length = length;
+ this.data = data;
+ }
+
+ function buildChannelData():Array
+ {
+ channelData = [];
+ for (i in 0...channels)
+ {
+ channelData.push(new WaveformDataChannel(this, i));
+ }
+ return channelData;
+ }
+
+ public function channel(index:Int)
+ {
+ return (channelData == null) ? buildChannelData()[index] : channelData[index];
+ }
+
+ public function get(index:Int):Int
+ {
+ return data[index] ?? 0;
+ }
+
+ public function set(index:Int, value:Int)
+ {
+ data[index] = value;
+ }
+
+ /**
+ * Maximum possible value for a waveform data point.
+ * The minimum possible value is (-1 * maxSampleValue)
+ */
+ public function maxSampleValue():Int
+ {
+ if (_maxSampleValue != 0) return _maxSampleValue;
+ return _maxSampleValue = Std.int(Math.pow(2, bits));
+ }
+
+ /**
+ * Cache the value because `Math.pow` is expensive and the value gets used a lot.
+ */
+ @:jignored
+ var _maxSampleValue:Int = 0;
+
+ /**
+ * @return The length of the waveform in samples.
+ */
+ public function lenSamples():Int
+ {
+ return length * samplesPerPoint;
+ }
+
+ /**
+ * @return The length of the waveform in seconds.
+ */
+ public function lenSeconds():Float
+ {
+ return inline lenSamples() / sampleRate;
+ }
+
+ /**
+ * Given the time in seconds, return the waveform data point index.
+ */
+ public function secondsToIndex(seconds:Float):Int
+ {
+ return Std.int(seconds * inline pointsPerSecond());
+ }
+
+ /**
+ * Given a waveform data point index, return the time in seconds.
+ */
+ public function indexToSeconds(index:Int):Float
+ {
+ return index / inline pointsPerSecond();
+ }
+
+ /**
+ * The number of data points this waveform data provides per second of audio.
+ */
+ public inline function pointsPerSecond():Float
+ {
+ return sampleRate / samplesPerPoint;
+ }
+
+ /**
+ * Given the percentage progress through the waveform, return the waveform data point index.
+ */
+ public function percentToIndex(percent:Float):Int
+ {
+ return Std.int(percent * length);
+ }
+
+ /**
+ * Given a waveform data point index, return the percentage progress through the waveform.
+ */
+ public function indexToPercent(index:Int):Float
+ {
+ return index / length;
+ }
+
+ /**
+ * Resample the waveform data to create a new WaveformData object matching the desired `samplesPerPoint` value.
+ * This is useful for zooming in/out of the waveform in a performant manner.
+ *
+ * @param newSamplesPerPoint The new value for `samplesPerPoint`.
+ */
+ public function resample(newSamplesPerPoint:Int):WaveformData
+ {
+ var result = this.clone();
+
+ var ratio = newSamplesPerPoint / samplesPerPoint;
+ if (ratio == 1) return result;
+ if (ratio < 1) trace('[WARNING] Downsampling will result in a low precision.');
+
+ var inputSampleCount = this.lenSamples();
+ var outputSampleCount = Std.int(inputSampleCount * ratio);
+
+ var inputPointCount = this.length;
+ var outputPointCount = Std.int(inputPointCount / ratio);
+ var outputChannelCount = this.channels;
+
+ // TODO: Actually figure out the dumbass logic for this.
+
+ return result;
+ }
+
+ /**
+ * Create a new WaveformData whose data represents the two waveforms overlayed.
+ */
+ public function merge(that:WaveformData):WaveformData
+ {
+ var result = this.clone([]);
+
+ for (channelIndex in 0...this.channels)
+ {
+ var thisChannel = this.channel(channelIndex);
+ var thatChannel = that.channel(channelIndex);
+ var resultChannel = result.channel(channelIndex);
+
+ for (index in 0...this.length)
+ {
+ var thisMinSample = thisChannel.minSample(index);
+ var thatMinSample = thatChannel.minSample(index);
+
+ var thisMaxSample = thisChannel.maxSample(index);
+ var thatMaxSample = thatChannel.maxSample(index);
+
+ resultChannel.setMinSample(index, Std.int(Math.min(thisMinSample, thatMinSample)));
+ resultChannel.setMaxSample(index, Std.int(Math.max(thisMaxSample, thatMaxSample)));
+ }
+ }
+
+ @:privateAccess
+ result.length = this.length;
+
+ return result;
+ }
+
+ /**
+ * Create a new WaveformData whose parameters match the current object.
+ */
+ public function clone(?newData:Array = null):WaveformData
+ {
+ if (newData == null)
+ {
+ newData = this.data.clone();
+ }
+
+ var clone = new WaveformData(this.version, this.channels, this.sampleRate, this.samplesPerPoint, this.bits, newData.length, newData);
+
+ return clone;
+ }
+}
+
+@:nullSafety
+class WaveformDataChannel
+{
+ var parent:WaveformData;
+ var channelId:Int;
+
+ public function new(parent:WaveformData, channelId:Int)
+ {
+ this.parent = parent;
+ this.channelId = channelId;
+ }
+
+ /**
+ * Retrieve a given minimum point at an index.
+ */
+ public function minSample(i:Int)
+ {
+ var offset = (i * parent.channels + this.channelId) * 2;
+ return inline parent.get(offset);
+ }
+
+ /**
+ * Mapped to a value between 0 and 1.
+ */
+ public function minSampleMapped(i:Int)
+ {
+ return inline minSample(i) / inline parent.maxSampleValue();
+ }
+
+ /**
+ * Minimum value within the range of samples.
+ * NOTE: Inefficient for large ranges. Use `WaveformData.remap` instead.
+ */
+ public function minSampleRange(start:Int, end:Int)
+ {
+ var min = inline parent.maxSampleValue();
+ for (i in start...end)
+ {
+ var sample = inline minSample(i);
+ if (sample < min) min = sample;
+ }
+ return min;
+ }
+
+ /**
+ * Maximum value within the range of samples, mapped to a value between 0 and 1.
+ */
+ public function minSampleRangeMapped(start:Int, end:Int)
+ {
+ return inline minSampleRange(start, end) / inline parent.maxSampleValue();
+ }
+
+ /**
+ * Retrieve a given maximum point at an index.
+ */
+ public function maxSample(i:Int)
+ {
+ var offset = (i * parent.channels + this.channelId) * 2 + 1;
+ return inline parent.get(offset);
+ }
+
+ /**
+ * Mapped to a value between 0 and 1.
+ */
+ public function maxSampleMapped(i:Int)
+ {
+ return inline maxSample(i) / inline parent.maxSampleValue();
+ }
+
+ /**
+ * Maximum value within the range of samples.
+ * NOTE: Inefficient for large ranges. Use `WaveformData.remap` instead.
+ */
+ public function maxSampleRange(start:Int, end:Int)
+ {
+ var max = -(inline parent.maxSampleValue());
+ for (i in start...end)
+ {
+ var sample = inline maxSample(i);
+ if (sample > max) max = sample;
+ }
+ return max;
+ }
+
+ /**
+ * Maximum value within the range of samples, mapped to a value between 0 and 1.
+ */
+ public function maxSampleRangeMapped(start:Int, end:Int)
+ {
+ return inline maxSampleRange(start, end) / inline parent.maxSampleValue();
+ }
+
+ public function setMinSample(i:Int, value:Int)
+ {
+ var offset = (i * parent.channels + this.channelId) * 2;
+ inline parent.set(offset, value);
+ }
+
+ public function setMaxSample(i:Int, value:Int)
+ {
+ var offset = (i * parent.channels + this.channelId) * 2 + 1;
+ inline parent.set(offset, value);
+ }
+}
diff --git a/source/funkin/audio/waveform/WaveformDataParser.hx b/source/funkin/audio/waveform/WaveformDataParser.hx
new file mode 100644
index 000000000..54a142f6a
--- /dev/null
+++ b/source/funkin/audio/waveform/WaveformDataParser.hx
@@ -0,0 +1,145 @@
+package funkin.audio.waveform;
+
+class WaveformDataParser
+{
+ static final INT16_MAX:Int = 32767;
+ static final INT16_MIN:Int = -32768;
+
+ static final INT8_MAX:Int = 127;
+ static final INT8_MIN:Int = -128;
+
+ public static function interpretFlxSound(sound:flixel.sound.FlxSound):Null
+ {
+ if (sound == null) return null;
+
+ // Method 1. This only works if the sound has been played before.
+ @:privateAccess
+ var soundBuffer:Null = sound?._channel?.__source?.buffer;
+
+ if (soundBuffer == null)
+ {
+ // Method 2. This works if the sound has not been played before.
+ @:privateAccess
+ soundBuffer = sound?._sound?.__buffer;
+
+ if (soundBuffer == null)
+ {
+ trace('[WAVEFORM] Failed to interpret FlxSound: ${sound}');
+ return null;
+ }
+ else
+ {
+ // trace('[WAVEFORM] Method 2 worked.');
+ }
+ }
+ else
+ {
+ // trace('[WAVEFORM] Method 1 worked.');
+ }
+
+ return interpretAudioBuffer(soundBuffer);
+ }
+
+ public static function interpretAudioBuffer(soundBuffer:lime.media.AudioBuffer):Null
+ {
+ var sampleRate = soundBuffer.sampleRate;
+ var channels = soundBuffer.channels;
+ var bitsPerSample = soundBuffer.bitsPerSample;
+ var samplesPerPoint:Int = 256; // I don't think we need to configure this.
+ var pointsPerSecond:Float = sampleRate / samplesPerPoint; // 172 samples per second for most songs is plenty precise while still being performant..
+
+ // TODO: Make this work better on HTML5.
+ var soundData:lime.utils.Int16Array = cast soundBuffer.data;
+
+ var soundDataRawLength:Int = soundData.length;
+ var soundDataSampleCount:Int = Std.int(Math.ceil(soundDataRawLength / channels / (bitsPerSample == 16 ? 2 : 1)));
+ var outputPointCount:Int = Std.int(Math.ceil(soundDataSampleCount / samplesPerPoint));
+
+ // trace('Interpreting audio buffer:');
+ // trace(' sampleRate: ${sampleRate}');
+ // trace(' channels: ${channels}');
+ // trace(' bitsPerSample: ${bitsPerSample}');
+ // trace(' samplesPerPoint: ${samplesPerPoint}');
+ // trace(' pointsPerSecond: ${pointsPerSecond}');
+ // trace(' soundDataRawLength: ${soundDataRawLength}');
+ // trace(' soundDataSampleCount: ${soundDataSampleCount}');
+ // trace(' soundDataRawLength/4: ${soundDataRawLength / 4}');
+ // trace(' outputPointCount: ${outputPointCount}');
+
+ var minSampleValue:Int = bitsPerSample == 16 ? INT16_MIN : INT8_MIN;
+ var maxSampleValue:Int = bitsPerSample == 16 ? INT16_MAX : INT8_MAX;
+
+ var outputData:Array = [];
+
+ var perfStart = haxe.Timer.stamp();
+
+ for (pointIndex in 0...outputPointCount)
+ {
+ // minChannel1, maxChannel1, minChannel2, maxChannel2, ...
+ var values:Array = [];
+
+ for (i in 0...channels)
+ {
+ values.push(bitsPerSample == 16 ? INT16_MAX : INT8_MAX);
+ values.push(bitsPerSample == 16 ? INT16_MIN : INT8_MIN);
+ }
+
+ var rangeStart = pointIndex * samplesPerPoint;
+ var rangeEnd = rangeStart + samplesPerPoint;
+ if (rangeEnd > soundDataSampleCount) rangeEnd = soundDataSampleCount;
+
+ for (sampleIndex in rangeStart...rangeEnd)
+ {
+ for (channelIndex in 0...channels)
+ {
+ var sampleIndex:Int = sampleIndex * channels + channelIndex;
+ var sampleValue = soundData[sampleIndex];
+
+ if (sampleValue < values[channelIndex * 2]) values[(channelIndex * 2)] = sampleValue;
+ if (sampleValue > values[channelIndex * 2 + 1]) values[(channelIndex * 2) + 1] = sampleValue;
+ }
+ }
+
+ // We now have the min and max values for the range.
+ for (value in values)
+ outputData.push(value);
+ }
+
+ var outputDataLength:Int = Std.int(outputData.length / channels / 2);
+ var result = new WaveformData(null, channels, sampleRate, samplesPerPoint, bitsPerSample, outputPointCount, outputData);
+
+ var perfEnd = haxe.Timer.stamp();
+ trace('[WAVEFORM] Interpreted audio buffer in ${perfEnd - perfStart} seconds.');
+
+ return result;
+ }
+
+ public static function parseWaveformData(path:String):Null
+ {
+ var rawJson:String = openfl.Assets.getText(path).trim();
+ return parseWaveformDataString(rawJson, path);
+ }
+
+ public static function parseWaveformDataString(contents:String, ?fileName:String):Null
+ {
+ var parser = new json2object.JsonParser();
+ parser.ignoreUnknownVariables = false;
+ trace('[WAVEFORM] Parsing waveform data: ${contents}');
+ parser.fromJson(contents, fileName);
+
+ if (parser.errors.length > 0)
+ {
+ printErrors(parser.errors, fileName);
+ return null;
+ }
+ return parser.value;
+ }
+
+ static function printErrors(errors:Array, id:String = ''):Void
+ {
+ trace('[WAVEFORM] Failed to parse waveform data: ${id}');
+
+ for (error in errors)
+ funkin.data.DataError.printError(error);
+ }
+}
diff --git a/source/funkin/audio/waveform/WaveformSprite.hx b/source/funkin/audio/waveform/WaveformSprite.hx
new file mode 100644
index 000000000..32ced2fbd
--- /dev/null
+++ b/source/funkin/audio/waveform/WaveformSprite.hx
@@ -0,0 +1,449 @@
+package funkin.audio.waveform;
+
+import funkin.audio.waveform.WaveformData;
+import funkin.audio.waveform.WaveformDataParser;
+import funkin.graphics.rendering.MeshRender;
+import flixel.util.FlxColor;
+
+class WaveformSprite extends MeshRender
+{
+ static final DEFAULT_COLOR:FlxColor = FlxColor.WHITE;
+ static final DEFAULT_DURATION:Float = 5.0;
+ static final DEFAULT_ORIENTATION:WaveformOrientation = HORIZONTAL;
+ static final DEFAULT_X:Float = 0.0;
+ static final DEFAULT_Y:Float = 0.0;
+ static final DEFAULT_WIDTH:Float = 100.0;
+ static final DEFAULT_HEIGHT:Float = 100.0;
+
+ /**
+ * Set this to true to tell the waveform to rebuild itself.
+ * Do this any time the data or drawable area of the waveform changes.
+ * This often (but not always) needs to be done every frame.
+ */
+ var isWaveformDirty:Bool = true;
+
+ /**
+ * If true, force the waveform to redraw every frame.
+ * Useful if the waveform's clipRect is constantly changing.
+ */
+ public var forceUpdate:Bool = false;
+
+ public var waveformData(default, set):Null;
+
+ function set_waveformData(value:Null):Null
+ {
+ if (waveformData == value) return value;
+
+ waveformData = value;
+ isWaveformDirty = true;
+ return waveformData;
+ }
+
+ /**
+ * The color to render the waveform with.
+ */
+ public var waveformColor(default, set):FlxColor;
+
+ function set_waveformColor(value:FlxColor):FlxColor
+ {
+ if (waveformColor == value) return value;
+
+ waveformColor = value;
+ // We don't need to dirty the waveform geometry, just rebuild the texture.
+ rebuildGraphic();
+ return waveformColor;
+ }
+
+ public var orientation(default, set):WaveformOrientation;
+
+ function set_orientation(value:WaveformOrientation):WaveformOrientation
+ {
+ if (orientation == value) return value;
+
+ orientation = value;
+ isWaveformDirty = true;
+ return orientation;
+ }
+
+ /**
+ * Time, in seconds, at which the waveform starts.
+ */
+ public var time(default, set):Float;
+
+ function set_time(value:Float)
+ {
+ if (time == value) return value;
+
+ time = value;
+ isWaveformDirty = true;
+ return time;
+ }
+
+ /**
+ * The duration, in seconds, that the waveform represents.
+ * The section of waveform from `time` to `time + duration` and `width` are used to determine how many samples each pixel represents.
+ */
+ public var duration(default, set):Float;
+
+ function set_duration(value:Float)
+ {
+ if (duration == value) return value;
+
+ duration = value;
+ isWaveformDirty = true;
+ return duration;
+ }
+
+ /**
+ * Set the physical size of the waveform with `this.height = value`.
+ */
+ override function set_height(value:Float):Float
+ {
+ if (height == value) return super.set_height(value);
+
+ isWaveformDirty = true;
+ return super.set_height(value);
+ }
+
+ /**
+ * Set the physical size of the waveform with `this.width = value`.
+ */
+ override function set_width(value:Float):Float
+ {
+ if (width == value) return super.set_width(value);
+
+ isWaveformDirty = true;
+ return super.set_width(value);
+ }
+
+ /**
+ * The minimum size, in pixels, that a waveform will display with.
+ * Useful for preventing the waveform from becoming too small to see.
+ *
+ * NOTE: This is technically doubled since it's applied above and below the center of the waveform.
+ */
+ public var minWaveformSize:Int = 1;
+
+ /**
+ * A multiplier on the size of the waveform.
+ * Still capped at the width and height set for the sprite.
+ */
+ public var amplitude:Float = 1.0;
+
+ public function new(?waveformData:WaveformData, ?orientation:WaveformOrientation, ?color:FlxColor, ?duration:Float)
+ {
+ super(DEFAULT_X, DEFAULT_Y, DEFAULT_COLOR);
+ this.waveformColor = color ?? DEFAULT_COLOR;
+ this.width = DEFAULT_WIDTH;
+ this.height = DEFAULT_HEIGHT;
+
+ this.waveformData = waveformData;
+ this.orientation = orientation ?? DEFAULT_ORIENTATION;
+ this.time = 0.0;
+ this.duration = duration ?? DEFAULT_DURATION;
+
+ this.forceUpdate = false;
+ }
+
+ /**
+ * Manually tell the waveform to rebuild itself, even if none of its properties have changed.
+ */
+ public function markDirty():Void
+ {
+ isWaveformDirty = true;
+ }
+
+ public override function update(elapsed:Float)
+ {
+ super.update(elapsed);
+
+ if (forceUpdate || isWaveformDirty)
+ {
+ // Recalculate the waveform vertices.
+ drawWaveform();
+ isWaveformDirty = false;
+ }
+ }
+
+ function rebuildGraphic():Void
+ {
+ // The waveform is rendered using a single colored pixel as a texture.
+ // If you want something more elaborate, make sure to modify `build_vertex` below to use the UVs you want.
+ makeGraphic(1, 1, this.waveformColor);
+ }
+
+ /**
+ * @param offsetX Horizontal offset to draw the waveform at, in samples.
+ */
+ function drawWaveform():Void
+ {
+ // For each sample in the waveform...
+ // Add a MAX vertex and a MIN vertex.
+ // If previous MAX/MIN is empty, store.
+ // If previous MAX/MIN is not empty, draw a quad using current and previous MAX/MIN. Then store current MAX/MIN.
+ // Continue until end of waveform.
+
+ this.clear();
+
+ if (waveformData == null) return;
+
+ // Center point of the waveform. When horizontal this is half the height, when vertical this is half the width.
+ var waveformCenterPos:Int = orientation == HORIZONTAL ? Std.int(this.height / 2) : Std.int(this.width / 2);
+
+ var oneSecondInIndices:Int = waveformData.secondsToIndex(1);
+
+ var startTime:Float = time;
+ var endTime:Float = time + duration;
+
+ var startIndex:Int = waveformData.secondsToIndex(startTime);
+ var endIndex:Int = waveformData.secondsToIndex(endTime);
+
+ var pixelsPerIndex:Float = (orientation == HORIZONTAL ? this.width : this.height) / (endIndex - startIndex);
+ var indexesPerPixel:Float = 1 / pixelsPerIndex;
+
+ var topLeftVertexIndex:Int = -1;
+ var topRightVertexIndex:Int = -1;
+ var bottomLeftVertexIndex:Int = -1;
+ var bottomRightVertexIndex:Int = -1;
+
+ if (clipRect != null)
+ {
+ topLeftVertexIndex = this.build_vertex(clipRect.x, clipRect.y);
+ topRightVertexIndex = this.build_vertex(clipRect.x + clipRect.width, clipRect.y);
+ bottomLeftVertexIndex = this.build_vertex(clipRect.x, clipRect.y + clipRect.height);
+ bottomRightVertexIndex = this.build_vertex(clipRect.x + clipRect.width, clipRect.y + clipRect.height);
+ }
+
+ if (pixelsPerIndex >= 1.0)
+ {
+ // Each index is at least one pixel wide, so we render each index.
+ var prevVertexTopIndex:Int = -1;
+ var prevVertexBottomIndex:Int = -1;
+ for (i in startIndex...endIndex)
+ {
+ var pixelPos:Int = Std.int((i - startIndex) * pixelsPerIndex);
+
+ var isBeforeClipRect:Bool = (clipRect != null) && ((orientation == HORIZONTAL) ? pixelPos < clipRect.x : pixelPos < clipRect.y);
+
+ if (isBeforeClipRect) continue;
+
+ var isAfterClipRect:Bool = (clipRect != null)
+ && ((orientation == HORIZONTAL) ? pixelPos > (clipRect.x + clipRect.width) : pixelPos > (clipRect.y + clipRect.height));
+
+ if (isAfterClipRect)
+ {
+ break;
+ };
+
+ var sampleMax:Float = Math.min(waveformData.channel(0).maxSampleMapped(i) * amplitude, 1.0);
+ var sampleMin:Float = Math.max(waveformData.channel(0).minSampleMapped(i) * amplitude, -1.0);
+ var sampleMaxSize:Float = sampleMax * (orientation == HORIZONTAL ? this.height : this.width) / 2;
+ if (sampleMaxSize < minWaveformSize) sampleMaxSize = minWaveformSize;
+ var sampleMinSize:Float = sampleMin * (orientation == HORIZONTAL ? this.height : this.width) / 2;
+ if (sampleMinSize > -minWaveformSize) sampleMinSize = -minWaveformSize;
+ var vertexTopY:Int = Std.int(waveformCenterPos - sampleMaxSize);
+ var vertexBottomY:Int = Std.int(waveformCenterPos - sampleMinSize);
+
+ if (vertexBottomY - vertexTopY < minWaveformSize) vertexTopY = vertexBottomY - minWaveformSize;
+
+ var vertexTopIndex:Int = -1;
+ var vertexBottomIndex:Int = -1;
+
+ if (clipRect != null)
+ {
+ if (orientation == HORIZONTAL)
+ {
+ vertexTopIndex = buildClippedVertex(pixelPos, vertexTopY, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex, bottomRightVertexIndex);
+ vertexBottomIndex = buildClippedVertex(pixelPos, vertexBottomY, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex,
+ bottomRightVertexIndex);
+ }
+ else
+ {
+ vertexTopIndex = buildClippedVertex(vertexTopY, pixelPos, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex, bottomRightVertexIndex);
+ vertexBottomIndex = buildClippedVertex(vertexBottomY, pixelPos, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex,
+ bottomRightVertexIndex);
+ }
+ }
+ else
+ {
+ if (orientation == HORIZONTAL)
+ {
+ vertexTopIndex = this.build_vertex(pixelPos, vertexTopY);
+ vertexBottomIndex = this.build_vertex(pixelPos, vertexBottomY);
+ }
+ else
+ {
+ vertexTopIndex = this.build_vertex(vertexTopY, pixelPos);
+ vertexBottomIndex = this.build_vertex(vertexBottomY, pixelPos);
+ }
+ }
+
+ // Don't render if we don't have a previous different set of vertices to create a quad from.
+ if (prevVertexTopIndex != -1
+ && prevVertexBottomIndex != -1
+ && prevVertexTopIndex != vertexTopIndex
+ && prevVertexBottomIndex != vertexBottomIndex)
+ {
+ switch (orientation) // the line of code that makes you gay
+ {
+ case HORIZONTAL:
+ this.add_quad(prevVertexTopIndex, vertexTopIndex, vertexBottomIndex, prevVertexBottomIndex);
+ case VERTICAL:
+ this.add_quad(prevVertexBottomIndex, prevVertexTopIndex, vertexTopIndex, vertexBottomIndex);
+ }
+ }
+
+ prevVertexTopIndex = vertexTopIndex;
+ prevVertexBottomIndex = vertexBottomIndex;
+ }
+ }
+ else
+ {
+ // Indexes are less than one pixel wide, so for each pixel we render the maximum of the samples that fall within it.
+ var prevVertexTopIndex:Int = -1;
+ var prevVertexBottomIndex:Int = -1;
+ var waveformLengthPixels:Int = orientation == HORIZONTAL ? Std.int(this.width) : Std.int(this.height);
+ for (i in 0...waveformLengthPixels)
+ {
+ var pixelPos:Int = i;
+
+ var isBeforeClipRect:Bool = (clipRect != null) && ((orientation == HORIZONTAL) ? pixelPos < clipRect.x : pixelPos < clipRect.y);
+
+ if (isBeforeClipRect) continue;
+
+ var isAfterClipRect:Bool = (clipRect != null)
+ && ((orientation == HORIZONTAL) ? pixelPos > (clipRect.x + clipRect.width) : pixelPos > (clipRect.y + clipRect.height));
+
+ if (isAfterClipRect)
+ {
+ break;
+ };
+
+ // Wrap Std.int around the whole range calculation, not just indexesPerPixel, otherwise you get weird issues with zooming.
+ var rangeStart:Int = Std.int(i * indexesPerPixel + startIndex);
+ var rangeEnd:Int = Std.int((i + 1) * indexesPerPixel + startIndex);
+
+ var sampleMax:Float = Math.min(waveformData.channel(0).maxSampleRangeMapped(rangeStart, rangeEnd) * amplitude, 1.0);
+ var sampleMin:Float = Math.max(waveformData.channel(0).minSampleRangeMapped(rangeStart, rangeEnd) * amplitude, -1.0);
+ var sampleMaxSize:Float = sampleMax * (orientation == HORIZONTAL ? this.height : this.width) / 2;
+ if (sampleMaxSize < minWaveformSize) sampleMaxSize = minWaveformSize;
+ var sampleMinSize:Float = sampleMin * (orientation == HORIZONTAL ? this.height : this.width) / 2;
+ if (sampleMinSize > -minWaveformSize) sampleMinSize = -minWaveformSize;
+ var vertexTopY:Int = Std.int(waveformCenterPos - sampleMaxSize);
+ var vertexBottomY:Int = Std.int(waveformCenterPos - sampleMinSize);
+
+ var vertexTopIndex:Int = -1;
+ var vertexBottomIndex:Int = -1;
+
+ if (clipRect != null)
+ {
+ if (orientation == HORIZONTAL)
+ {
+ vertexTopIndex = buildClippedVertex(pixelPos, vertexTopY, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex, bottomRightVertexIndex);
+ vertexBottomIndex = buildClippedVertex(pixelPos, vertexBottomY, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex,
+ bottomRightVertexIndex);
+ }
+ else
+ {
+ vertexTopIndex = buildClippedVertex(vertexTopY, pixelPos, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex, bottomRightVertexIndex);
+ vertexBottomIndex = buildClippedVertex(vertexBottomY, pixelPos, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex,
+ bottomRightVertexIndex);
+ }
+ }
+ else
+ {
+ if (orientation == HORIZONTAL)
+ {
+ vertexTopIndex = this.build_vertex(pixelPos, vertexTopY);
+ vertexBottomIndex = this.build_vertex(pixelPos, vertexBottomY);
+ }
+ else
+ {
+ vertexTopIndex = this.build_vertex(vertexTopY, pixelPos);
+ vertexBottomIndex = this.build_vertex(vertexBottomY, pixelPos);
+ }
+ }
+
+ if (prevVertexTopIndex != -1 && prevVertexBottomIndex != -1)
+ {
+ switch (orientation)
+ {
+ case HORIZONTAL:
+ this.add_quad(prevVertexTopIndex, vertexTopIndex, vertexBottomIndex, prevVertexBottomIndex);
+ case VERTICAL:
+ this.add_quad(prevVertexBottomIndex, prevVertexTopIndex, vertexTopIndex, vertexBottomIndex);
+ }
+ }
+ prevVertexTopIndex = vertexTopIndex;
+ prevVertexBottomIndex = vertexBottomIndex;
+ }
+ }
+ }
+
+ function buildClippedVertex(x:Int, y:Int, topLeftVertexIndex:Int, topRightVertexIndex:Int, bottomLeftVertexIndex:Int, bottomRightVertexIndex:Int):Int
+ {
+ var shouldClipXLeft = x < clipRect.x;
+ var shouldClipXRight = x > (clipRect.x + clipRect.width);
+ var shouldClipYTop = y < clipRect.y;
+ var shouldClipYBottom = y > (clipRect.y + clipRect.height);
+
+ // If the vertex is fully outside the clipRect, use a pre-existing vertex.
+ // Else, if the vertex is outside the clipRect on one axis, create a new vertex constrained on that axis.
+ // Else, create a whole new vertex.
+ if (shouldClipXLeft && shouldClipYTop)
+ {
+ return topLeftVertexIndex;
+ }
+ else if (shouldClipXRight && shouldClipYTop)
+ {
+ return topRightVertexIndex;
+ }
+ else if (shouldClipXLeft && shouldClipYBottom)
+ {
+ return bottomLeftVertexIndex;
+ }
+ else if (shouldClipXRight && shouldClipYBottom)
+ {
+ return bottomRightVertexIndex;
+ }
+ else if (shouldClipXLeft)
+ {
+ return this.build_vertex(clipRect.x, y);
+ }
+ else if (shouldClipXRight)
+ {
+ return this.build_vertex(clipRect.x + clipRect.width, y);
+ }
+ else if (shouldClipYTop)
+ {
+ return this.build_vertex(x, clipRect.y);
+ }
+ else if (shouldClipYBottom)
+ {
+ return this.build_vertex(x, clipRect.y + clipRect.height);
+ }
+ else
+ {
+ return this.build_vertex(x, y);
+ }
+ }
+
+ public static function buildFromWaveformData(data:WaveformData, ?orientation:WaveformOrientation, ?color:FlxColor, ?duration:Float)
+ {
+ return new WaveformSprite(data, orientation, color, duration);
+ }
+
+ public static function buildFromFunkinSound(sound:FunkinSound, ?orientation:WaveformOrientation, ?color:FlxColor, ?duration:Float)
+ {
+ // TODO: Build waveform data from FunkinSound.
+ var data = null;
+
+ return buildFromWaveformData(data, orientation, color, duration);
+ }
+}
+
+enum WaveformOrientation
+{
+ HORIZONTAL;
+ VERTICAL;
+}
diff --git a/source/funkin/data/BaseRegistry.hx b/source/funkin/data/BaseRegistry.hx
index 70615069b..0ccbe2f18 100644
--- a/source/funkin/data/BaseRegistry.hx
+++ b/source/funkin/data/BaseRegistry.hx
@@ -46,6 +46,9 @@ abstract class BaseRegistry & Constructible();
}
+ /**
+ * TODO: Create a `loadEntriesAsync()` function.
+ */
public function loadEntries():Void
{
clearEntries();
@@ -54,7 +57,7 @@ abstract class BaseRegistry & Constructible = getScriptedClassNames();
- log('Registering ${scriptedEntryClassNames.length} scripted entries...');
+ log('Parsing ${scriptedEntryClassNames.length} scripted entries...');
for (entryCls in scriptedEntryClassNames)
{
@@ -78,7 +81,7 @@ abstract class BaseRegistry & Constructible = entryIdList.filter(function(entryId:String):Bool {
return !entries.exists(entryId);
});
- log('Fetching data for ${unscriptedEntryIds.length} unscripted entries...');
+ log('Parsing ${unscriptedEntryIds.length} unscripted entries...');
for (entryId in unscriptedEntryIds)
{
try
diff --git a/source/funkin/data/DataParse.hx b/source/funkin/data/DataParse.hx
index 49dde0198..244d41132 100644
--- a/source/funkin/data/DataParse.hx
+++ b/source/funkin/data/DataParse.hx
@@ -120,6 +120,71 @@ class DataParse
}
}
+ public static function backdropData(json:Json, name:String):funkin.data.dialogue.ConversationData.BackdropData
+ {
+ switch (json.value)
+ {
+ case JObject(fields):
+ var result:Dynamic = {};
+ var backdropType:String = '';
+
+ for (field in fields)
+ {
+ switch (field.name)
+ {
+ case 'type':
+ backdropType = Tools.getValue(field.value);
+ }
+ Reflect.setField(result, field.name, Tools.getValue(field.value));
+ }
+
+ switch (backdropType)
+ {
+ case 'solid':
+ return SOLID(result);
+ default:
+ throw 'Expected Backdrop property $name to be specify a valid "type", but it was "${backdropType}".';
+ }
+
+ return null;
+ default:
+ throw 'Expected property $name to be an object, but it was ${json.value}.';
+ }
+ }
+
+ public static function outroData(json:Json, name:String):Null
+ {
+ switch (json.value)
+ {
+ case JObject(fields):
+ var result:Dynamic = {};
+ var outroType:String = '';
+
+ for (field in fields)
+ {
+ switch (field.name)
+ {
+ case 'type':
+ outroType = Tools.getValue(field.value);
+ }
+ Reflect.setField(result, field.name, Tools.getValue(field.value));
+ }
+
+ switch (outroType)
+ {
+ case 'none':
+ return NONE(result);
+ case 'fade':
+ return FADE(result);
+ default:
+ throw 'Expected Outro property $name to be specify a valid "type", but it was "${outroType}".';
+ }
+ return null;
+ default:
+ throw 'Expected property $name to be an object, but it was ${json.value}.';
+ }
+ }
+
/**
* Parser which outputs a `Either`.
* Used by the FNF legacy JSON importer.
diff --git a/source/funkin/data/dialogue/ConversationData.hx b/source/funkin/data/dialogue/ConversationData.hx
new file mode 100644
index 000000000..795ddae9a
--- /dev/null
+++ b/source/funkin/data/dialogue/ConversationData.hx
@@ -0,0 +1,168 @@
+package funkin.data.dialogue;
+
+import funkin.data.animation.AnimationData;
+
+/**
+ * A type definition for the data for a specific conversation.
+ * It includes things like what dialogue boxes to use, what text to display, and what animations to play.
+ * @see https://lib.haxe.org/p/json2object/
+ */
+typedef ConversationData =
+{
+ /**
+ * Semantic version for conversation data.
+ */
+ public var version:String;
+
+ /**
+ * Data on the backdrop for the conversation.
+ */
+ @:jcustomparse(funkin.data.DataParse.backdropData)
+ public var backdrop:BackdropData;
+
+ /**
+ * Data on the outro for the conversation.
+ */
+ @:jcustomparse(funkin.data.DataParse.outroData)
+ @:optional
+ public var outro:Null;
+
+ /**
+ * Data on the music for the conversation.
+ */
+ @:optional
+ public var music:Null;
+
+ /**
+ * Data for each line of dialogue in the conversation.
+ */
+ public var dialogue:Array;
+}
+
+/**
+ * Data on the backdrop for the conversation, behind the dialogue box.
+ * A custom parser distinguishes between backdrop types based on the `type` field.
+ */
+enum BackdropData
+{
+ SOLID(data:BackdropData_Solid); // 'solid'
+}
+
+/**
+ * Data for a Solid color backdrop.
+ */
+typedef BackdropData_Solid =
+{
+ /**
+ * Used to distinguish between backdrop types. Should always be `solid` for this type.
+ */
+ var type:String;
+
+ /**
+ * The color of the backdrop.
+ */
+ var color:String;
+
+ /**
+ * Fade-in time for the backdrop.
+ * @default No fade-in
+ */
+ @:optional
+ @:default(0.0)
+ var fadeTime:Float;
+};
+
+enum OutroData
+{
+ NONE(data:OutroData_None); // 'none'
+ FADE(data:OutroData_Fade); // 'fade'
+}
+
+typedef OutroData_None =
+{
+ /**
+ * Used to distinguish between outro types. Should always be `none` for this type.
+ */
+ var type:String;
+}
+
+typedef OutroData_Fade =
+{
+ /**
+ * Used to distinguish between outro types. Should always be `fade` for this type.
+ */
+ var type:String;
+
+ /**
+ * The time to fade out the conversation.
+ * @default 1 second
+ */
+ @:optional
+ @:default(1.0)
+ var fadeTime:Float;
+}
+
+typedef MusicData =
+{
+ /**
+ * The asset to play for the music.
+ */
+ var asset:String;
+
+ /**
+ * The time to fade in the music.
+ */
+ @:optional
+ @:default(0.0)
+ var fadeTime:Float;
+
+ @:optional
+ @:default(false)
+ var looped:Bool;
+};
+
+/**
+ * Data on a single line of dialogue in a conversation.
+ */
+typedef DialogueEntryData =
+{
+ /**
+ * Which speaker is speaking.
+ * @see `SpeakerData.hx`
+ */
+ public var speaker:String;
+
+ /**
+ * The animation the speaker should play for this line of dialogue.
+ */
+ public var speakerAnimation:String;
+
+ /**
+ * Which dialogue box to use for this line of dialogue.
+ * @see `DialogueBoxData.hx`
+ */
+ public var box:String;
+
+ /**
+ * Which animation to play for the dialogue box.
+ */
+ public var boxAnimation:String;
+
+ /**
+ * The text that will display for this line of dialogue.
+ * Text will automatically wrap.
+ * When the user advances the dialogue, the next entry in the array will concatenate on.
+ * Advancing when the last entry is displayed will move to the next `DialogueEntryData`,
+ * or end the conversation if there are no more.
+ */
+ public var text:Array;
+
+ /**
+ * The relative speed at which text gets "typed out".
+ * Setting `speed` to `1.5` would make it look like the character is speaking quickly,
+ * and setting `speed` to `0.5` would make it look like the character is emphasizing each word.
+ */
+ @:optional
+ @:default(1.0)
+ public var speed:Float;
+};
diff --git a/source/funkin/data/dialogue/ConversationRegistry.hx b/source/funkin/data/dialogue/ConversationRegistry.hx
new file mode 100644
index 000000000..9186ef786
--- /dev/null
+++ b/source/funkin/data/dialogue/ConversationRegistry.hx
@@ -0,0 +1,81 @@
+package funkin.data.dialogue;
+
+import funkin.play.cutscene.dialogue.Conversation;
+import funkin.data.dialogue.ConversationData;
+import funkin.play.cutscene.dialogue.ScriptedConversation;
+
+class ConversationRegistry extends BaseRegistry
+{
+ /**
+ * The current version string for the dialogue box data format.
+ * Handle breaking changes by incrementing this value
+ * and adding migration to the `migrateConversationData()` function.
+ */
+ public static final CONVERSATION_DATA_VERSION:thx.semver.Version = "1.0.0";
+
+ public static final CONVERSATION_DATA_VERSION_RULE:thx.semver.VersionRule = "1.0.x";
+
+ public static final instance:ConversationRegistry = new ConversationRegistry();
+
+ public function new()
+ {
+ super('CONVERSATION', 'dialogue/conversations', CONVERSATION_DATA_VERSION_RULE);
+ }
+
+ /**
+ * Read, parse, and validate the JSON data and produce the corresponding data object.
+ */
+ public function parseEntryData(id:String):Null
+ {
+ // JsonParser does not take type parameters,
+ // otherwise this function would be in BaseRegistry.
+ var parser = new json2object.JsonParser();
+ parser.ignoreUnknownVariables = false;
+
+ switch (loadEntryFile(id))
+ {
+ case {fileName: fileName, contents: contents}:
+ parser.fromJson(contents, fileName);
+ default:
+ return null;
+ }
+
+ if (parser.errors.length > 0)
+ {
+ printErrors(parser.errors, id);
+ return null;
+ }
+ return parser.value;
+ }
+
+ /**
+ * Parse and validate the JSON data and produce the corresponding data object.
+ *
+ * NOTE: Must be implemented on the implementation class.
+ * @param contents The JSON as a string.
+ * @param fileName An optional file name for error reporting.
+ */
+ public function parseEntryDataRaw(contents:String, ?fileName:String):Null
+ {
+ var parser = new json2object.JsonParser();
+ parser.ignoreUnknownVariables = false;
+ parser.fromJson(contents, fileName);
+
+ if (parser.errors.length > 0)
+ {
+ printErrors(parser.errors, fileName);
+ return null;
+ }
+ return parser.value;
+ }
+
+ function createScriptedEntry(clsName:String):Conversation
+ {
+ return ScriptedConversation.init(clsName, "unknown");
+ }
+
+ function getScriptedClassNames():Array
+ {
+ return ScriptedConversation.listScriptClasses();
+ }
+}
diff --git a/source/funkin/data/dialogue/DialogueBoxData.hx b/source/funkin/data/dialogue/DialogueBoxData.hx
new file mode 100644
index 000000000..a75a5595a
--- /dev/null
+++ b/source/funkin/data/dialogue/DialogueBoxData.hx
@@ -0,0 +1,128 @@
+package funkin.data.dialogue;
+
+import funkin.data.animation.AnimationData;
+
+/**
+ * A type definition for the data for a conversation text box.
+ * It includes things like the sprite to use, and the font and color for the text.
+ * The actual text is included in the ConversationData.
+ * @see https://lib.haxe.org/p/json2object/
+ */
+typedef DialogueBoxData =
+{
+ /**
+ * Semantic version for dialogue box data.
+ */
+ public var version:String;
+
+ /**
+ * A human readable name for the dialogue box type.
+ */
+ public var name:String;
+
+ /**
+ * The asset path for the sprite to use for the dialogue box.
+ * Takes a static sprite or a sprite sheet.
+ */
+ public var assetPath:String;
+
+ /**
+ * Whether to horizontally flip the dialogue box sprite.
+ */
+ @:optional
+ @:default(false)
+ public var flipX:Bool;
+
+ /**
+ * Whether to vertically flip the dialogue box sprite.
+ */
+ @:optional
+ @:default(false)
+ public var flipY:Bool;
+
+ /**
+ * Whether to disable anti-aliasing for the dialogue box sprite.
+ */
+ @:optional
+ @:default(false)
+ public var isPixel:Bool;
+
+ /**
+ * The relative horizontal and vertical offsets for the dialogue box sprite.
+ */
+ @:optional
+ @:default([0, 0])
+ public var offsets:Array;
+
+ /**
+ * Info about how to display text in the dialogue box.
+ */
+ public var text:DialogueBoxTextData;
+
+ /**
+ * Multiply the size of the dialogue box sprite.
+ */
+ @:optional
+ @:default(1)
+ public var scale:Float;
+
+ /**
+ * If using a spritesheet for the dialogue box, the animations to use.
+ */
+ @:optional
+ @:default([])
+ public var animations:Array;
+}
+
+typedef DialogueBoxTextData =
+{
+ /**
+ * The position of the text in teh box.
+ */
+ @:optional
+ @:default([0, 0])
+ var offsets:Array;
+
+ /**
+ * The width of the
+ */
+ @:optional
+ @:default(300)
+ var width:Int;
+
+ /**
+ * The font size to use for the text.
+ */
+ @:optional
+ @:default(32)
+ var size:Int;
+
+ /**
+ * The color to use for the text.
+ * Use a string that can be translated to a color, like `#FF0000` for red.
+ */
+ @:optional
+ @:default("#000000")
+ var color:String;
+
+ /**
+ * The font to use for the text.
+ * @since v1.1.0
+ * @default `Arial`, make sure to switch this!
+ */
+ @:optional
+ @:default("Arial")
+ var fontFamily:String;
+
+ /**
+ * The color to use for the shadow of the text. Use transparent to disable.
+ */
+ var shadowColor:String;
+
+ /**
+ * The width of the shadow of the text.
+ */
+ @:optional
+ @:default(0)
+ var shadowWidth:Int;
+};
diff --git a/source/funkin/data/dialogue/DialogueBoxRegistry.hx b/source/funkin/data/dialogue/DialogueBoxRegistry.hx
new file mode 100644
index 000000000..87205d96c
--- /dev/null
+++ b/source/funkin/data/dialogue/DialogueBoxRegistry.hx
@@ -0,0 +1,81 @@
+package funkin.data.dialogue;
+
+import funkin.play.cutscene.dialogue.DialogueBox;
+import funkin.data.dialogue.DialogueBoxData;
+import funkin.play.cutscene.dialogue.ScriptedDialogueBox;
+
+class DialogueBoxRegistry extends BaseRegistry
+{
+ /**
+ * The current version string for the dialogue box data format.
+ * Handle breaking changes by incrementing this value
+ * and adding migration to the `migrateDialogueBoxData()` function.
+ */
+ public static final DIALOGUEBOX_DATA_VERSION:thx.semver.Version = "1.1.0";
+
+ public static final DIALOGUEBOX_DATA_VERSION_RULE:thx.semver.VersionRule = "1.1.x";
+
+ public static final instance:DialogueBoxRegistry = new DialogueBoxRegistry();
+
+ public function new()
+ {
+ super('DIALOGUEBOX', 'dialogue/boxes', DIALOGUEBOX_DATA_VERSION_RULE);
+ }
+
+ /**
+ * Read, parse, and validate the JSON data and produce the corresponding data object.
+ */
+ public function parseEntryData(id:String):Null
+ {
+ // JsonParser does not take type parameters,
+ // otherwise this function would be in BaseRegistry.
+ var parser = new json2object.JsonParser();
+ parser.ignoreUnknownVariables = false;
+
+ switch (loadEntryFile(id))
+ {
+ case {fileName: fileName, contents: contents}:
+ parser.fromJson(contents, fileName);
+ default:
+ return null;
+ }
+
+ if (parser.errors.length > 0)
+ {
+ printErrors(parser.errors, id);
+ return null;
+ }
+ return parser.value;
+ }
+
+ /**
+ * Parse and validate the JSON data and produce the corresponding data object.
+ *
+ * NOTE: Must be implemented on the implementation class.
+ * @param contents The JSON as a string.
+ * @param fileName An optional file name for error reporting.
+ */
+ public function parseEntryDataRaw(contents:String, ?fileName:String):Null
+ {
+ var parser = new json2object.JsonParser();
+ parser.ignoreUnknownVariables = false;
+ parser.fromJson(contents, fileName);
+
+ if (parser.errors.length > 0)
+ {
+ printErrors(parser.errors, fileName);
+ return null;
+ }
+ return parser.value;
+ }
+
+ function createScriptedEntry(clsName:String):DialogueBox
+ {
+ return ScriptedDialogueBox.init(clsName, "unknown");
+ }
+
+ function getScriptedClassNames():Array
+ {
+ return ScriptedDialogueBox.listScriptClasses();
+ }
+}
diff --git a/source/funkin/data/dialogue/SpeakerData.hx b/source/funkin/data/dialogue/SpeakerData.hx
new file mode 100644
index 000000000..e8a2eacf0
--- /dev/null
+++ b/source/funkin/data/dialogue/SpeakerData.hx
@@ -0,0 +1,68 @@
+package funkin.data.dialogue;
+
+import funkin.data.animation.AnimationData;
+
+/**
+ * A type definition for a specific speaker in a conversation.
+ * It includes things like what sprite to use and its available animations.
+ * @see https://lib.haxe.org/p/json2object/
+ */
+typedef SpeakerData =
+{
+ /**
+ * Semantic version of the speaker data.
+ */
+ public var version:String;
+
+ /**
+ * A human-readable name for the speaker.
+ */
+ public var name:String;
+
+ /**
+ * The path to the asset to use for the speaker's sprite.
+ */
+ public var assetPath:String;
+
+ /**
+ * Whether the sprite should be flipped horizontally.
+ */
+ @:optional
+ @:default(false)
+ public var flipX:Bool;
+
+ /**
+ * Whether the sprite should be flipped vertically.
+ */
+ @:optional
+ @:default(false)
+ public var flipY:Bool;
+
+ /**
+ * Whether to disable anti-aliasing for the dialogue box sprite.
+ */
+ @:optional
+ @:default(false)
+ public var isPixel:Bool;
+
+ /**
+ * The offsets to apply to the sprite's position.
+ */
+ @:optional
+ @:default([0, 0])
+ public var offsets:Array;
+
+ /**
+ * The scale to apply to the sprite.
+ */
+ @:optional
+ @:default(1.0)
+ public var scale:Float;
+
+ /**
+ * The available animations for the speaker.
+ */
+ @:optional
+ @:default([])
+ public var animations:Array;
+}
diff --git a/source/funkin/data/dialogue/SpeakerRegistry.hx b/source/funkin/data/dialogue/SpeakerRegistry.hx
new file mode 100644
index 000000000..6bd301dd7
--- /dev/null
+++ b/source/funkin/data/dialogue/SpeakerRegistry.hx
@@ -0,0 +1,81 @@
+package funkin.data.dialogue;
+
+import funkin.play.cutscene.dialogue.Speaker;
+import funkin.data.dialogue.SpeakerData;
+import funkin.play.cutscene.dialogue.ScriptedSpeaker;
+
+class SpeakerRegistry extends BaseRegistry
+{
+ /**
+ * The current version string for the speaker data format.
+ * Handle breaking changes by incrementing this value
+ * and adding migration to the `migrateSpeakerData()` function.
+ */
+ public static final SPEAKER_DATA_VERSION:thx.semver.Version = "1.0.0";
+
+ public static final SPEAKER_DATA_VERSION_RULE:thx.semver.VersionRule = "1.0.x";
+
+ public static final instance:SpeakerRegistry = new SpeakerRegistry();
+
+ public function new()
+ {
+ super('SPEAKER', 'dialogue/speakers', SPEAKER_DATA_VERSION_RULE);
+ }
+
+ /**
+ * Read, parse, and validate the JSON data and produce the corresponding data object.
+ */
+ public function parseEntryData(id:String):Null
+ {
+ // JsonParser does not take type parameters,
+ // otherwise this function would be in BaseRegistry.
+ var parser = new json2object.JsonParser();
+ parser.ignoreUnknownVariables = false;
+
+ switch (loadEntryFile(id))
+ {
+ case {fileName: fileName, contents: contents}:
+ parser.fromJson(contents, fileName);
+ default:
+ return null;
+ }
+
+ if (parser.errors.length > 0)
+ {
+ printErrors(parser.errors, id);
+ return null;
+ }
+ return parser.value;
+ }
+
+ /**
+ * Parse and validate the JSON data and produce the corresponding data object.
+ *
+ * NOTE: Must be implemented on the implementation class.
+ * @param contents The JSON as a string.
+ * @param fileName An optional file name for error reporting.
+ */
+ public function parseEntryDataRaw(contents:String, ?fileName:String):Null
+ {
+ var parser = new json2object.JsonParser();
+ parser.ignoreUnknownVariables = false;
+ parser.fromJson(contents, fileName);
+
+ if (parser.errors.length > 0)
+ {
+ printErrors(parser.errors, fileName);
+ return null;
+ }
+ return parser.value;
+ }
+
+ function createScriptedEntry(clsName:String):Speaker
+ {
+ return ScriptedSpeaker.init(clsName, "unknown");
+ }
+
+ function getScriptedClassNames():Array
+ {
+ return ScriptedSpeaker.listScriptClasses();
+ }
+}
diff --git a/source/funkin/data/event/SongEventSchema.hx b/source/funkin/data/event/SongEventSchema.hx
index 7ebaa5ae1..9591e601e 100644
--- a/source/funkin/data/event/SongEventSchema.hx
+++ b/source/funkin/data/event/SongEventSchema.hx
@@ -6,9 +6,14 @@ import funkin.data.song.SongData.SongEventData;
import funkin.util.macro.ClassMacro;
import funkin.play.event.ScriptedSongEvent;
-@:forward(name, tittlte, type, keys, min, max, step, defaultValue, iterator)
+@:forward(name, title, type, keys, min, max, step, units, defaultValue, iterator)
abstract SongEventSchema(SongEventSchemaRaw)
{
+ /**
+ * These units look better when placed immediately next to the value, rather than after a space.
+ */
+ static final NO_SPACE_UNITS:Array = ['x', '°', '%'];
+
public function new(?fields:Array)
{
this = fields;
@@ -42,7 +47,7 @@ abstract SongEventSchema(SongEventSchemaRaw)
return this[k] = v;
}
- public function stringifyFieldValue(name:String, value:Dynamic):String
+ public function stringifyFieldValue(name:String, value:Dynamic, addUnits:Bool = true):String
{
var field:SongEventSchemaField = getByName(name);
if (field == null) return 'Unknown';
@@ -52,21 +57,36 @@ abstract SongEventSchema(SongEventSchemaRaw)
case SongEventFieldType.STRING:
return Std.string(value);
case SongEventFieldType.INTEGER:
- return Std.string(value);
+ var returnValue:String = Std.string(value);
+ if (addUnits) return addUnitsToString(returnValue, field);
+ return returnValue;
case SongEventFieldType.FLOAT:
- return Std.string(value);
+ var returnValue:String = Std.string(value);
+ if (addUnits) return addUnitsToString(returnValue, field);
+ return returnValue;
case SongEventFieldType.BOOL:
return Std.string(value);
case SongEventFieldType.ENUM:
+ var valueString:String = Std.string(value);
for (key in field.keys.keys())
{
- if (field.keys.get(key) == value) return key;
+ // Comparing these values as strings because comparing Dynamic variables is jank.
+ if (Std.string(field.keys.get(key)) == valueString) return key;
}
- return Std.string(value);
+ return valueString;
default:
return 'Unknown';
}
}
+
+ function addUnitsToString(value:String, field:SongEventSchemaField)
+ {
+ if (field.units == null || field.units == '') return value;
+
+ var unit:String = field.units;
+
+ return value + (NO_SPACE_UNITS.contains(unit) ? '' : ' ') + '${unit}';
+ }
}
typedef SongEventSchemaRaw = Array;
@@ -115,6 +135,12 @@ typedef SongEventSchemaField =
*/
?step:Float,
+ /**
+ * Used for INTEGER and FLOAT values.
+ * The units that the value is expressed in (pixels, percent, etc).
+ */
+ ?units:String,
+
/**
* An optional default value for the field.
*/
diff --git a/source/funkin/data/song/SongData.hx b/source/funkin/data/song/SongData.hx
index 52b9c19d6..95ee117ab 100644
--- a/source/funkin/data/song/SongData.hx
+++ b/source/funkin/data/song/SongData.hx
@@ -418,10 +418,10 @@ class SongPlayData implements ICloneable
/**
* The difficulty ratings for this song as displayed in Freeplay.
- * Key is a difficulty ID or `default`.
+ * Key is a difficulty ID.
*/
@:optional
- @:default(['default' => 1])
+ @:default(['normal' => 0])
public var ratings:Map;
/**
@@ -431,6 +431,24 @@ class SongPlayData implements ICloneable
@:optional
public var album:Null;
+ /**
+ * The start time for the audio preview in Freeplay.
+ * Defaults to 0 seconds in.
+ * @since `2.2.2`
+ */
+ @:optional
+ @:default(0)
+ public var previewStart:Int;
+
+ /**
+ * The end time for the audio preview in Freeplay.
+ * Defaults to 15 seconds in.
+ * @since `2.2.2`
+ */
+ @:optional
+ @:default(15000)
+ public var previewEnd:Int;
+
public function new()
{
ratings = new Map();
@@ -438,6 +456,7 @@ class SongPlayData implements ICloneable
public function clone():SongPlayData
{
+ // TODO: This sucks! If you forget to update this you get weird behavior.
var result:SongPlayData = new SongPlayData();
result.songVariations = this.songVariations.clone();
result.difficulties = this.difficulties.clone();
@@ -446,6 +465,8 @@ class SongPlayData implements ICloneable
result.noteStyle = this.noteStyle;
result.ratings = this.ratings.clone();
result.album = this.album;
+ result.previewStart = this.previewStart;
+ result.previewEnd = this.previewEnd;
return result;
}
@@ -777,7 +798,7 @@ abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataR
var title = eventSchema.getByName(key)?.title ?? 'UnknownField';
- if (eventSchema.stringifyFieldValue(key, value) != null) trace(eventSchema.stringifyFieldValue(key, value));
+ // if (eventSchema.stringifyFieldValue(key, value) != null) trace(eventSchema.stringifyFieldValue(key, value));
var valueStr = eventSchema.stringifyFieldValue(key, value) ?? 'UnknownValue';
result += '\n- ${title}: ${valueStr}';
diff --git a/source/funkin/data/song/SongDataUtils.hx b/source/funkin/data/song/SongDataUtils.hx
index 275106f3a..01ea2da32 100644
--- a/source/funkin/data/song/SongDataUtils.hx
+++ b/source/funkin/data/song/SongDataUtils.hx
@@ -153,8 +153,8 @@ class SongDataUtils
public static function buildNoteClipboard(notes:Array, ?timeOffset:Int = null):Array
{
if (notes.length == 0) return notes;
- if (timeOffset == null) timeOffset = -Std.int(notes[0].time);
- return offsetSongNoteData(sortNotes(notes), timeOffset);
+ if (timeOffset == null) timeOffset = Std.int(notes[0].time);
+ return offsetSongNoteData(sortNotes(notes), -timeOffset);
}
/**
@@ -165,8 +165,8 @@ class SongDataUtils
public static function buildEventClipboard(events:Array, ?timeOffset:Int = null):Array
{
if (events.length == 0) return events;
- if (timeOffset == null) timeOffset = -Std.int(events[0].time);
- return offsetSongEventData(sortEvents(events), timeOffset);
+ if (timeOffset == null) timeOffset = Std.int(events[0].time);
+ return offsetSongEventData(sortEvents(events), -timeOffset);
}
/**
diff --git a/source/funkin/data/song/SongRegistry.hx b/source/funkin/data/song/SongRegistry.hx
index b772349bc..dad287e82 100644
--- a/source/funkin/data/song/SongRegistry.hx
+++ b/source/funkin/data/song/SongRegistry.hx
@@ -20,7 +20,7 @@ class SongRegistry extends BaseRegistry
* Handle breaking changes by incrementing this value
* and adding migration to the `migrateStageData()` function.
*/
- public static final SONG_METADATA_VERSION:thx.semver.Version = "2.2.1";
+ public static final SONG_METADATA_VERSION:thx.semver.Version = "2.2.2";
public static final SONG_METADATA_VERSION_RULE:thx.semver.VersionRule = "2.2.x";
@@ -58,7 +58,7 @@ class SongRegistry extends BaseRegistry
// SCRIPTED ENTRIES
//
var scriptedEntryClassNames:Array = getScriptedClassNames();
- log('Registering ${scriptedEntryClassNames.length} scripted entries...');
+ log('Parsing ${scriptedEntryClassNames.length} scripted entries...');
for (entryCls in scriptedEntryClassNames)
{
@@ -84,7 +84,7 @@ class SongRegistry extends BaseRegistry
var unscriptedEntryIds:Array = entryIdList.filter(function(entryId:String):Bool {
return !entries.exists(entryId);
});
- log('Fetching data for ${unscriptedEntryIds.length} unscripted entries...');
+ log('Parsing ${unscriptedEntryIds.length} unscripted entries...');
for (entryId in unscriptedEntryIds)
{
try
diff --git a/source/funkin/graphics/rendering/MeshRender.hx b/source/funkin/graphics/rendering/MeshRender.hx
index 39402808a..a06d53337 100644
--- a/source/funkin/graphics/rendering/MeshRender.hx
+++ b/source/funkin/graphics/rendering/MeshRender.hx
@@ -12,22 +12,19 @@ class MeshRender extends FlxStrip
public var vertex_count(default, null):Int = 0;
public var index_count(default, null):Int = 0;
- var tri_offset:Int = 0;
-
public function new(x, y, ?col:FlxColor = FlxColor.WHITE)
{
super(x, y);
makeGraphic(1, 1, col);
}
- public inline function start()
+ /**
+ * Add a vertex.
+ */
+ public inline function build_vertex(x:Float, y:Float, u:Float = 0, v:Float = 0):Int
{
- tri_offset = vertex_count;
- }
-
- public inline function add_vertex(x:Float, y:Float, u:Float = 0, v:Float = 0)
- {
- final pos = vertex_count << 1;
+ final index = vertex_count;
+ final pos = index << 1;
vertices[pos] = x;
vertices[pos + 1] = y;
@@ -36,48 +33,72 @@ class MeshRender extends FlxStrip
uvtData[pos + 1] = v;
vertex_count++;
+ return index;
}
- public function add_tri(a:Int, b:Int, c:Int)
+ /**
+ * Build a triangle from three vertex indexes.
+ * @param a
+ * @param b
+ * @param c
+ */
+ public function add_tri(a:Int, b:Int, c:Int):Void
{
- indices[index_count] = a + tri_offset;
- indices[index_count + 1] = b + tri_offset;
- indices[index_count + 2] = c + tri_offset;
+ indices[index_count] = a;
+ indices[index_count + 1] = b;
+ indices[index_count + 2] = c;
index_count += 3;
}
- /**
- *
- * top left - a
- *
- * top right - b
- *
- * bottom left - c
- *
- * bottom right - d
- */
- public function add_quad(ax:Float, ay:Float, bx:Float, by:Float, cx:Float, cy:Float, dx:Float, dy:Float, au:Float = 0, av:Float = 0, bu:Float = 0,
- bv:Float = 0, cu:Float = 0, cv:Float = 0, du:Float = 0, dv:Float = 0)
+ public function build_tri(ax:Float, ay:Float, bx:Float, by:Float, cx:Float, cy:Float, au:Float = 0, av:Float = 0, bu:Float = 0, bv:Float = 0, cu:Float = 0,
+ cv:Float = 0):Void
{
- start();
- // top left
- add_vertex(bx, by, bu, bv);
- // top right
- add_vertex(ax, ay, au, av);
- // bottom left
- add_vertex(cx, cy, cu, cv);
- // bottom right
- add_vertex(dx, dy, du, dv);
+ add_tri(build_vertex(ax, ay, au, av), build_vertex(bx, by, bu, bv), build_vertex(cx, cy, cu, cv));
+ }
- add_tri(0, 1, 2);
- add_tri(0, 2, 3);
+ /**
+ * @param a top left vertex
+ * @param b top right vertex
+ * @param c bottom right vertex
+ * @param d bottom left vertex
+ */
+ public function add_quad(a:Int, b:Int, c:Int, d:Int):Void
+ {
+ add_tri(a, b, c);
+ add_tri(a, c, d);
+ }
+
+ /**
+ * Build a quad from four points.
+ *
+ * top right - a
+ * top left - b
+ * bottom right - c
+ * bottom left - d
+ */
+ public function build_quad(ax:Float, ay:Float, bx:Float, by:Float, cx:Float, cy:Float, dx:Float, dy:Float, au:Float = 0, av:Float = 0, bu:Float = 0,
+ bv:Float = 0, cu:Float = 0, cv:Float = 0, du:Float = 0, dv:Float = 0):Void
+ {
+ // top left
+ var b = build_vertex(bx, by, bu, bv);
+ // top right
+ var a = build_vertex(ax, ay, au, av);
+ // bottom left
+ var c = build_vertex(cx, cy, cu, cv);
+ // bottom right
+ var d = build_vertex(dx, dy, du, dv);
+
+ add_tri(a, b, c);
+ add_tri(a, c, d);
}
public function clear()
{
vertices.length = 0;
indices.length = 0;
+ uvtData.length = 0;
+ colors.length = 0;
vertex_count = 0;
index_count = 0;
}
diff --git a/source/funkin/modding/PolymodHandler.hx b/source/funkin/modding/PolymodHandler.hx
index 151e658b4..f1e82aee9 100644
--- a/source/funkin/modding/PolymodHandler.hx
+++ b/source/funkin/modding/PolymodHandler.hx
@@ -2,7 +2,6 @@ package funkin.modding;
import funkin.util.macro.ClassMacro;
import funkin.modding.module.ModuleHandler;
-import funkin.play.character.CharacterData.CharacterDataParser;
import funkin.data.song.SongData;
import funkin.data.stage.StageData;
import polymod.Polymod;
@@ -13,10 +12,11 @@ import funkin.data.stage.StageRegistry;
import funkin.util.FileUtil;
import funkin.data.level.LevelRegistry;
import funkin.data.notestyle.NoteStyleRegistry;
-import funkin.play.cutscene.dialogue.ConversationDataParser;
-import funkin.play.cutscene.dialogue.DialogueBoxDataParser;
+import funkin.data.dialogue.ConversationRegistry;
+import funkin.data.dialogue.DialogueBoxRegistry;
+import funkin.data.dialogue.SpeakerRegistry;
+import funkin.play.character.CharacterData.CharacterDataParser;
import funkin.save.Save;
-import funkin.play.cutscene.dialogue.SpeakerDataParser;
import funkin.data.song.SongRegistry;
class PolymodHandler
@@ -208,8 +208,8 @@ class PolymodHandler
{
return {
assetLibraryPaths: [
- "default" => "preload", "shared" => "", "songs" => "songs", "tutorial" => "tutorial", "week1" => "week1", "week2" => "week2", "week3" => "week3",
- "week4" => "week4", "week5" => "week5", "week6" => "week6", "week7" => "week7", "weekend1" => "weekend1",
+ "default" => "preload", "shared" => "shared", "songs" => "songs", "tutorial" => "tutorial", "week1" => "week1", "week2" => "week2",
+ "week3" => "week3", "week4" => "week4", "week5" => "week5", "week6" => "week6", "week7" => "week7", "weekend1" => "weekend1",
],
coreAssetRedirect: CORE_FOLDER,
}
@@ -273,11 +273,11 @@ class PolymodHandler
LevelRegistry.instance.loadEntries();
NoteStyleRegistry.instance.loadEntries();
SongEventRegistry.loadEventCache();
- ConversationDataParser.loadConversationCache();
- DialogueBoxDataParser.loadDialogueBoxCache();
- SpeakerDataParser.loadSpeakerCache();
+ ConversationRegistry.instance.loadEntries();
+ DialogueBoxRegistry.instance.loadEntries();
+ SpeakerRegistry.instance.loadEntries();
StageRegistry.instance.loadEntries();
- CharacterDataParser.loadCharacterCache();
+ CharacterDataParser.loadCharacterCache(); // TODO: Migrate characters to BaseRegistry.
ModuleHandler.loadModuleCache();
}
}
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index cc9debf13..aee9f2210 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -39,7 +39,7 @@ import funkin.modding.events.ScriptEventDispatcher;
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.data.dialogue.ConversationRegistry;
import funkin.play.cutscene.VanillaCutscenes;
import funkin.play.cutscene.VideoCutscene;
import funkin.data.event.SongEventRegistry;
@@ -1662,7 +1662,7 @@ class PlayState extends MusicBeatSubState
{
isInCutscene = true;
- currentConversation = ConversationDataParser.fetchConversation(conversationId);
+ currentConversation = ConversationRegistry.instance.fetchEntry(conversationId);
if (currentConversation == null) return;
currentConversation.completeCallback = onConversationComplete;
diff --git a/source/funkin/play/character/CharacterData.hx b/source/funkin/play/character/CharacterData.hx
index 16cc8b299..69e3ca48e 100644
--- a/source/funkin/play/character/CharacterData.hx
+++ b/source/funkin/play/character/CharacterData.hx
@@ -43,7 +43,7 @@ class CharacterDataParser
{
// Clear any stages that are cached if there were any.
clearCharacterCache();
- trace('Loading character cache...');
+ trace('[CHARACTER] Parsing all entries...');
//
// UNSCRIPTED CHARACTERS
diff --git a/source/funkin/play/cutscene/dialogue/Conversation.hx b/source/funkin/play/cutscene/dialogue/Conversation.hx
index b2361c795..dc3fd8c8a 100644
--- a/source/funkin/play/cutscene/dialogue/Conversation.hx
+++ b/source/funkin/play/cutscene/dialogue/Conversation.hx
@@ -1,8 +1,10 @@
package funkin.play.cutscene.dialogue;
+import funkin.data.IRegistryEntry;
import flixel.FlxSprite;
import flixel.group.FlxSpriteGroup;
import flixel.util.FlxColor;
+import funkin.graphics.FunkinSprite;
import flixel.tweens.FlxTween;
import flixel.tweens.FlxEase;
import flixel.sound.FlxSound;
@@ -13,27 +15,30 @@ 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;
+import funkin.data.dialogue.ConversationData;
+import funkin.data.dialogue.ConversationData.DialogueEntryData;
+import funkin.data.dialogue.ConversationRegistry;
+import funkin.data.dialogue.SpeakerData;
+import funkin.data.dialogue.SpeakerRegistry;
+import funkin.data.dialogue.DialogueBoxData;
+import funkin.data.dialogue.DialogueBoxRegistry;
/**
* 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
+class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass implements IRegistryEntry
{
static final CONVERSATION_SKIP_TIMER:Float = 1.5;
var skipHeldTimer:Float = 0.0;
/**
- * DATA
+ * The ID of the conversation.
*/
- /**
- * The ID of the associated dialogue.
- */
- public final conversationId:String;
+ public final id:String;
/**
* The current state of the conversation.
@@ -41,9 +46,9 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
var state:ConversationState = ConversationState.Start;
/**
- * The data for the associated dialogue.
+ * Conversation data as parsed from the JSON file.
*/
- var conversationData:ConversationData;
+ public final _data:ConversationData;
/**
* The current entry in the dialogue.
@@ -54,7 +59,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
function get_currentDialogueEntryCount():Int
{
- return conversationData.dialogue.length;
+ return _data.dialogue.length;
}
/**
@@ -73,10 +78,10 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
function get_currentDialogueEntryData():DialogueEntryData
{
- if (conversationData == null || conversationData.dialogue == null) return null;
- if (currentDialogueEntry < 0 || currentDialogueEntry >= conversationData.dialogue.length) return null;
+ if (_data == null || _data.dialogue == null) return null;
+ if (currentDialogueEntry < 0 || currentDialogueEntry >= _data.dialogue.length) return null;
- return conversationData.dialogue[currentDialogueEntry];
+ return _data.dialogue[currentDialogueEntry];
}
var currentDialogueLineString(get, never):String;
@@ -94,7 +99,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
/**
* GRAPHICS
*/
- var backdrop:FlxSprite;
+ var backdrop:FunkinSprite;
var currentSpeaker:Speaker;
@@ -102,14 +107,17 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
var skipTimer:FlxPieDial;
- public function new(conversationId:String)
+ public function new(id:String)
{
super();
- this.conversationId = conversationId;
- this.conversationData = ConversationDataParser.parseConversationData(this.conversationId);
+ this.id = id;
+ this._data = _fetchData(id);
- if (conversationData == null) throw 'Could not load conversation data for conversation ID "$conversationId"';
+ if (_data == null)
+ {
+ throw 'Could not parse conversation data for id: $id';
+ }
}
public function onCreate(event:ScriptEvent):Void
@@ -125,14 +133,14 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
function setupMusic():Void
{
- if (conversationData.music == null) return;
+ if (_data.music == null) return;
- music = new FlxSound().loadEmbedded(Paths.music(conversationData.music.asset), true, true);
+ music = new FlxSound().loadEmbedded(Paths.music(_data.music.asset), true, true);
music.volume = 0;
- if (conversationData.music.fadeTime > 0.0)
+ if (_data.music.fadeTime > 0.0)
{
- FlxTween.tween(music, {volume: 1.0}, conversationData.music.fadeTime, {ease: FlxEase.linear});
+ FlxTween.tween(music, {volume: 1.0}, _data.music.fadeTime, {ease: FlxEase.linear});
}
else
{
@@ -145,19 +153,20 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
function setupBackdrop():Void
{
- backdrop = new FlxSprite(0, 0);
+ backdrop = new FunkinSprite(0, 0);
- if (conversationData.backdrop == null) return;
+ if (_data.backdrop == null) return;
// Play intro
- switch (conversationData?.backdrop.type)
+ switch (_data.backdrop)
{
- 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)
+ case SOLID(backdropData):
+ var targetColor:FlxColor = FlxColor.fromString(backdropData.color);
+ backdrop.makeSolidColor(Std.int(FlxG.width), Std.int(FlxG.height), targetColor);
+ if (backdropData.fadeTime > 0.0)
{
backdrop.alpha = 0.0;
- FlxTween.tween(backdrop, {alpha: 1.0}, conversationData.backdrop.data.fadeTime, {ease: FlxEase.linear});
+ FlxTween.tween(backdrop, {alpha: 1.0}, backdropData.fadeTime, {ease: FlxEase.linear});
}
else
{
@@ -190,9 +199,9 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
var nextSpeakerId:String = currentDialogueEntryData.speaker;
// Skip the next steps if the current speaker is already displayed.
- if (currentSpeaker != null && nextSpeakerId == currentSpeaker.speakerId) return;
+ if (currentSpeaker != null && nextSpeakerId == currentSpeaker.id) return;
- var nextSpeaker:Speaker = SpeakerDataParser.fetchSpeaker(nextSpeakerId);
+ var nextSpeaker:Speaker = SpeakerRegistry.instance.fetchEntry(nextSpeakerId);
if (currentSpeaker != null)
{
@@ -241,7 +250,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
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 && nextDialogueBoxId == currentDialogueBox.id) return;
if (currentDialogueBox != null)
{
@@ -250,7 +259,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
currentDialogueBox = null;
}
- var nextDialogueBox:DialogueBox = DialogueBoxDataParser.fetchDialogueBox(nextDialogueBoxId);
+ var nextDialogueBox:DialogueBox = DialogueBoxRegistry.instance.fetchEntry(nextDialogueBoxId);
if (nextDialogueBox == null)
{
@@ -378,20 +387,18 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
public function startOutro():Void
{
- switch (conversationData?.outro?.type)
+ switch (_data?.outro)
{
- case FADE:
- var fadeTime:Float = conversationData?.outro.data.fadeTime ?? 1.0;
-
- outroTween = FlxTween.tween(this, {alpha: 0.0}, fadeTime,
+ case FADE(outroData):
+ outroTween = FlxTween.tween(this, {alpha: 0.0}, outroData.fadeTime,
{
type: ONESHOT, // holy shit like the game no way
startDelay: 0,
onComplete: (_) -> endOutro(),
});
- FlxTween.tween(this.music, {volume: 0.0}, fadeTime);
- case NONE:
+ FlxTween.tween(this.music, {volume: 0.0}, outroData.fadeTime);
+ case NONE(_):
// Immediately clean up.
endOutro();
default:
@@ -400,7 +407,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
}
}
- public var completeCallback:Void->Void;
+ public var completeCallback:() -> Void;
public function endOutro():Void
{
@@ -596,7 +603,12 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
public override function toString():String
{
- return 'Conversation($conversationId)';
+ return 'Conversation($id)';
+ }
+
+ static function _fetchData(id:String):Null
+ {
+ return ConversationRegistry.instance.parseEntryDataWithMigration(id, ConversationRegistry.instance.fetchEntryVersion(id));
}
}
diff --git a/source/funkin/play/cutscene/dialogue/ConversationData.hx b/source/funkin/play/cutscene/dialogue/ConversationData.hx
deleted file mode 100644
index 8c4aa9684..000000000
--- a/source/funkin/play/cutscene/dialogue/ConversationData.hx
+++ /dev/null
@@ -1,240 +0,0 @@
-package funkin.play.cutscene.dialogue;
-
-import funkin.util.SerializerUtil;
-
-/**
- * Data about a conversation.
- * Includes what speakers are in the conversation, and what phrases they say.
- */
-@:jsonParse(j -> funkin.play.cutscene.dialogue.ConversationData.fromJson(j))
-@:jsonStringify(v -> v.toJson())
-class ConversationData
-{
- public var version:String;
- public var backdrop:BackdropData;
- public var outro:OutroData;
- public var music:MusicData;
- public var dialogue:Array;
-
- public function new(version:String, backdrop:BackdropData, outro:OutroData, music:MusicData, dialogue:Array)
- {
- this.version = version;
- this.backdrop = backdrop;
- this.outro = outro;
- this.music = music;
- this.dialogue = dialogue;
- }
-
- public static function fromString(i:String):ConversationData
- {
- if (i == null || i == '') return null;
- var data:
- {
- version:String,
- backdrop:Dynamic, // TODO: tink.Json doesn't like when these are typed
- ?outro:Dynamic, // TODO: tink.Json doesn't like when these are typed
- ?music:Dynamic, // TODO: tink.Json doesn't like when these are typed
- dialogue:Array // TODO: tink.Json doesn't like when these are typed
- } = tink.Json.parse(i);
- return fromJson(data);
- }
-
- public static function fromJson(j:Dynamic):ConversationData
- {
- // TODO: Check version and perform migrations if necessary.
- if (j == null) return null;
- return new ConversationData(j.version, BackdropData.fromJson(j.backdrop), OutroData.fromJson(j.outro), MusicData.fromJson(j.music),
- j.dialogue.map(d -> DialogueEntryData.fromJson(d)));
- }
-
- public function toJson():Dynamic
- {
- return {
- version: this.version,
- backdrop: this.backdrop.toJson(),
- dialogue: this.dialogue.map(d -> d.toJson())
- };
- }
-}
-
-/**
- * Data about a single dialogue entry.
- */
-@:jsonParse(j -> funkin.play.cutscene.dialogue.ConversationData.DialogueEntryData.fromJson(j))
-@:jsonStringify(v -> v.toJson())
-class DialogueEntryData
-{
- /**
- * The speaker who says this phrase.
- */
- public var speaker:String;
-
- /**
- * The animation the speaker will play.
- */
- public var speakerAnimation:String;
-
- /**
- * The text box that will appear.
- */
- public var box:String;
-
- /**
- * The animation the dialogue box will play.
- */
- public var boxAnimation:String;
-
- /**
- * The lines of text that will appear in the text box.
- */
- public var text:Array;
-
- /**
- * The relative speed at which the text will scroll.
- * @default 1.0
- */
- public var speed:Float = 1.0;
-
- public function new(speaker:String, speakerAnimation:String, box:String, boxAnimation:String, text:Array, speed:Float = null)
- {
- this.speaker = speaker;
- this.speakerAnimation = speakerAnimation;
- this.box = box;
- this.boxAnimation = boxAnimation;
- this.text = text;
- if (speed != null) this.speed = speed;
- }
-
- public static function fromJson(j:Dynamic):DialogueEntryData
- {
- if (j == null) return null;
- return new DialogueEntryData(j.speaker, j.speakerAnimation, j.box, j.boxAnimation, j.text, j.speed);
- }
-
- public function toJson():Dynamic
- {
- var result:Dynamic =
- {
- speaker: this.speaker,
- speakerAnimation: this.speakerAnimation,
- box: this.box,
- boxAnimation: this.boxAnimation,
- text: this.text,
- };
-
- if (this.speed != 1.0) result.speed = this.speed;
-
- return result;
- }
-}
-
-/**
- * Data about a backdrop.
- */
-@:jsonParse(j -> funkin.play.cutscene.dialogue.ConversationData.BackdropData.fromJson(j))
-@:jsonStringify(v -> v.toJson())
-class BackdropData
-{
- public var type:BackdropType;
- public var data:Dynamic;
-
- public function new(typeStr:String, data:Dynamic)
- {
- this.type = typeStr;
- this.data = data;
- }
-
- public static function fromJson(j:Dynamic):BackdropData
- {
- if (j == null) return null;
- return new BackdropData(j.type, j.data);
- }
-
- public function toJson():Dynamic
- {
- return {
- type: this.type,
- data: this.data
- };
- }
-}
-
-enum abstract BackdropType(String) from String to String
-{
- public var SOLID:BackdropType = 'solid';
-}
-
-/**
- * Data about a music track.
- */
-@:jsonParse(j -> funkin.play.cutscene.dialogue.ConversationData.MusicData.fromJson(j))
-@:jsonStringify(v -> v.toJson())
-class MusicData
-{
- public var asset:String;
-
- public var fadeTime:Float;
-
- @:optional
- @:default(false)
- public var looped:Bool;
-
- 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: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';
-}
diff --git a/source/funkin/play/cutscene/dialogue/ConversationDataParser.hx b/source/funkin/play/cutscene/dialogue/ConversationDataParser.hx
deleted file mode 100644
index 9f80f8f9b..000000000
--- a/source/funkin/play/cutscene/dialogue/ConversationDataParser.hx
+++ /dev/null
@@ -1,163 +0,0 @@
-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.
- * TODO: Refactor to use the json2object + BaseRegistry system that actually validates things for you.
- */
-class ConversationDataParser
-{
- public static final CONVERSATION_DATA_VERSION:String = '1.0.0';
- public static final CONVERSATION_DATA_VERSION_RULE:String = '1.0.x';
-
- static final conversationCache:Map = new Map();
- static final conversationScriptedClass:Map = new Map();
-
- static final DEFAULT_CONVERSATION_ID:String = 'UNKNOWN';
-
- /**
- * Parses and preloads the game's conversation data and scripts when the game starts.
- *
- * If you want to force conversations to be reloaded, you can just call this function again.
- */
- public static function loadConversationCache():Void
- {
- clearConversationCache();
- trace('Loading dialogue conversation cache...');
-
- //
- // SCRIPTED CONVERSATIONS
- //
- var scriptedConversationClassNames:Array = ScriptedConversation.listScriptClasses();
- trace(' Instantiating ${scriptedConversationClassNames.length} scripted conversations...');
- for (conversationCls in scriptedConversationClassNames)
- {
- var conversation:Conversation = ScriptedConversation.init(conversationCls, DEFAULT_CONVERSATION_ID);
- if (conversation != null)
- {
- trace(' Loaded scripted conversation: ${conversationCls}');
- // Disable the rendering logic for conversation until it's loaded.
- // Note that kill() =/= destroy()
- conversation.kill();
-
- // Then store it.
- conversationCache.set(conversation.conversationId, conversation);
- }
- else
- {
- trace(' Failed to instantiate scripted conversation class: ${conversationCls}');
- }
- }
-
- //
- // UNSCRIPTED CONVERSATIONS
- //
- // Scripts refers to code here, not the actual dialogue.
- var conversationIdList:Array = DataAssets.listDataFilesInPath('dialogue/conversations/');
- // Filter out conversations that are scripted.
- var unscriptedConversationIds:Array = conversationIdList.filter(function(conversationId:String):Bool {
- return !conversationCache.exists(conversationId);
- });
- trace(' Fetching data for ${unscriptedConversationIds.length} conversations...');
- for (conversationId in unscriptedConversationIds)
- {
- try
- {
- var conversation:Conversation = new Conversation(conversationId);
- // Say something offensive to kill the conversation.
- // We will revive it later.
- conversation.kill();
- if (conversation != null)
- {
- trace(' Loaded conversation data: ${conversation.conversationId}');
- conversationCache.set(conversation.conversationId, conversation);
- }
- }
- catch (e)
- {
- trace(e);
- continue;
- }
- }
- }
-
- /**
- * Fetches data for a conversation and returns a Conversation instance,
- * ready to be displayed.
- * @param conversationId The ID of the conversation to fetch.
- * @return The conversation instance, or null if the conversation was not found.
- */
- public static function fetchConversation(conversationId:String):Null
- {
- if (conversationId != null && conversationId != '' && conversationCache.exists(conversationId))
- {
- trace('Successfully fetched conversation: ${conversationId}');
- var conversation:Conversation = conversationCache.get(conversationId);
- // ...ANYway...
- conversation.revive();
- return conversation;
- }
- else
- {
- trace('Failed to fetch conversation, not found in cache: ${conversationId}');
- return null;
- }
- }
-
- static function clearConversationCache():Void
- {
- if (conversationCache != null)
- {
- for (conversation in conversationCache)
- {
- conversation.destroy();
- }
- conversationCache.clear();
- }
- }
-
- public static function listConversationIds():Array
- {
- return conversationCache.keys().array();
- }
-
- /**
- * Load a conversation's JSON file, parse its data, and return it.
- *
- * @param conversationId The conversation to load.
- * @return The conversation data, or null if validation failed.
- */
- public static function parseConversationData(conversationId:String):Null
- {
- trace('Parsing conversation data: ${conversationId}');
- var rawJson:String = loadConversationFile(conversationId);
-
- try
- {
- var conversationData:ConversationData = ConversationData.fromString(rawJson);
- return conversationData;
- }
- catch (e)
- {
- trace('Failed to parse conversation ($conversationId).');
- trace(e);
- return null;
- }
- }
-
- static function loadConversationFile(conversationPath:String):String
- {
- var conversationFilePath:String = Paths.json('dialogue/conversations/${conversationPath}');
- var rawJson:String = Assets.getText(conversationFilePath).trim();
-
- while (!rawJson.endsWith('}') && rawJson.length > 0)
- {
- rawJson = rawJson.substr(0, rawJson.length - 1);
- }
-
- return rawJson;
- }
-}
diff --git a/source/funkin/play/cutscene/dialogue/DialogueBox.hx b/source/funkin/play/cutscene/dialogue/DialogueBox.hx
index cdac3c233..6f8a0086a 100644
--- a/source/funkin/play/cutscene/dialogue/DialogueBox.hx
+++ b/source/funkin/play/cutscene/dialogue/DialogueBox.hx
@@ -1,6 +1,7 @@
package funkin.play.cutscene.dialogue;
import flixel.FlxSprite;
+import funkin.data.IRegistryEntry;
import flixel.group.FlxSpriteGroup;
import flixel.graphics.frames.FlxFramesCollection;
import flixel.text.FlxText;
@@ -9,18 +10,21 @@ import funkin.util.assets.FlxAnimationUtil;
import funkin.modding.events.ScriptEvent;
import funkin.modding.IScriptedClass.IDialogueScriptedClass;
import flixel.util.FlxColor;
+import funkin.data.dialogue.DialogueBoxData;
+import funkin.data.dialogue.DialogueBoxRegistry;
-class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass
+class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass implements IRegistryEntry
{
- public final dialogueBoxId:String;
+ public final id:String;
+
public var dialogueBoxName(get, never):String;
function get_dialogueBoxName():String
{
- return boxData?.name ?? 'UNKNOWN';
+ return _data.name ?? 'UNKNOWN';
}
- var boxData:DialogueBoxData;
+ public final _data:DialogueBoxData;
/**
* Offset the speaker's sprite by this much when playing each animation.
@@ -88,13 +92,16 @@ class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass
return this.speed;
}
- public function new(dialogueBoxId:String)
+ public function new(id:String)
{
super();
- this.dialogueBoxId = dialogueBoxId;
- this.boxData = DialogueBoxDataParser.parseDialogueBoxData(this.dialogueBoxId);
+ this.id = id;
+ this._data = _fetchData(id);
- if (boxData == null) throw 'Could not load dialogue box data for box ID "$dialogueBoxId"';
+ if (_data == null)
+ {
+ throw 'Could not parse dialogue box data for id: $id';
+ }
}
public function onCreate(event:ScriptEvent):Void
@@ -115,18 +122,18 @@ class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass
function loadSpritesheet():Void
{
- trace('[DIALOGUE BOX] Loading spritesheet ${boxData.assetPath} for ${dialogueBoxId}');
+ trace('[DIALOGUE BOX] Loading spritesheet ${_data.assetPath} for ${id}');
- var tex:FlxFramesCollection = Paths.getSparrowAtlas(boxData.assetPath);
+ var tex:FlxFramesCollection = Paths.getSparrowAtlas(_data.assetPath);
if (tex == null)
{
- trace('Could not load Sparrow sprite: ${boxData.assetPath}');
+ trace('Could not load Sparrow sprite: ${_data.assetPath}');
return;
}
this.boxSprite.frames = tex;
- if (boxData.isPixel)
+ if (_data.isPixel)
{
this.boxSprite.antialiasing = false;
}
@@ -135,9 +142,10 @@ class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass
this.boxSprite.antialiasing = true;
}
- this.flipX = boxData.flipX;
- this.globalOffsets = boxData.offsets;
- this.setScale(boxData.scale);
+ this.flipX = _data.flipX;
+ this.flipY = _data.flipY;
+ this.globalOffsets = _data.offsets;
+ this.setScale(_data.scale);
}
public function setText(newText:String):Void
@@ -184,11 +192,11 @@ class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass
function loadAnimations():Void
{
- trace('[DIALOGUE BOX] Loading ${boxData.animations.length} animations for ${dialogueBoxId}');
+ trace('[DIALOGUE BOX] Loading ${_data.animations.length} animations for ${id}');
- FlxAnimationUtil.addAtlasAnimations(this.boxSprite, boxData.animations);
+ FlxAnimationUtil.addAtlasAnimations(this.boxSprite, _data.animations);
- for (anim in boxData.animations)
+ for (anim in _data.animations)
{
if (anim.offsets == null)
{
@@ -201,7 +209,7 @@ class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass
}
var animNames:Array = this.boxSprite?.animation?.getNameList() ?? [];
- trace('[DIALOGUE BOX] Successfully loaded ${animNames.length} animations for ${dialogueBoxId}');
+ trace('[DIALOGUE BOX] Successfully loaded ${animNames.length} animations for ${id}');
boxSprite.animation.callback = this.onAnimationFrame;
boxSprite.animation.finishCallback = this.onAnimationFinished;
@@ -234,16 +242,16 @@ class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass
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.fieldWidth = _data.text.width;
+ textDisplay.setFormat(_data.text.fontFamily, _data.text.size, FlxColor.fromString(_data.text.color), LEFT, SHADOW,
+ FlxColor.fromString(_data.text.shadowColor ?? '#00000000'), false);
+ textDisplay.borderSize = _data.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];
+ textDisplay.x += _data.text.offsets[0];
+ textDisplay.y += _data.text.offsets[1];
add(textDisplay);
}
@@ -374,4 +382,14 @@ class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass
}
public function onScriptEvent(event:ScriptEvent):Void {}
+
+ public override function toString():String
+ {
+ return 'DialogueBox($id)';
+ }
+
+ static function _fetchData(id:String):Null
+ {
+ return DialogueBoxRegistry.instance.parseEntryDataWithMigration(id, DialogueBoxRegistry.instance.fetchEntryVersion(id));
+ }
}
diff --git a/source/funkin/play/cutscene/dialogue/DialogueBoxData.hx b/source/funkin/play/cutscene/dialogue/DialogueBoxData.hx
deleted file mode 100644
index 801a01dd7..000000000
--- a/source/funkin/play/cutscene/dialogue/DialogueBoxData.hx
+++ /dev/null
@@ -1,124 +0,0 @@
-package funkin.play.cutscene.dialogue;
-
-import funkin.data.animation.AnimationData;
-import funkin.util.SerializerUtil;
-
-/**
- * Data about a text box.
- */
-@:jsonParse(j -> funkin.play.cutscene.dialogue.DialogueBoxData.fromJson(j))
-@:jsonStringify(v -> v.toJson())
-class DialogueBoxData
-{
- public var version:String;
- public var name:String;
- public var assetPath:String;
- public var flipX:Bool;
- public var flipY:Bool;
- public var isPixel:Bool;
- public var offsets:Array;
- public var text:DialogueBoxTextData;
- public var scale:Float;
- public var animations:Array;
-
- public function new(version:String, name:String, assetPath:String, flipX:Bool = false, flipY:Bool = false, isPixel:Bool = false, offsets:Null>,
- text:DialogueBoxTextData, scale:Float = 1.0, animations:Array)
- {
- this.version = version;
- this.name = name;
- this.assetPath = assetPath;
- this.flipX = flipX;
- this.flipY = flipY;
- this.isPixel = isPixel;
- this.offsets = offsets ?? [0, 0];
- this.text = text;
- this.scale = scale;
- this.animations = animations;
- }
-
- public static function fromString(i:String):DialogueBoxData
- {
- if (i == null || i == '') return null;
- var data:
- {
- version:String,
- name:String,
- assetPath:String,
- flipX:Bool,
- flipY:Bool,
- isPixel:Bool,
- ?offsets:Array,
- text:Dynamic,
- scale:Float,
- animations:Array
- } = tink.Json.parse(i);
- return fromJson(data);
- }
-
- public static function fromJson(j:Dynamic):DialogueBoxData
- {
- // TODO: Check version and perform migrations if necessary.
- if (j == null) return null;
- return new DialogueBoxData(j.version, j.name, j.assetPath, j.flipX, j.flipY, j.isPixel, j.offsets, DialogueBoxTextData.fromJson(j.text), j.scale,
- j.animations);
- }
-
- public function toJson():Dynamic
- {
- return {
- version: this.version,
- name: this.name,
- assetPath: this.assetPath,
- flipX: this.flipX,
- flipY: this.flipY,
- isPixel: this.isPixel,
- offsets: this.offsets,
- scale: this.scale,
- animations: this.animations
- };
- }
-}
-
-/**
- * Data about text in a text box.
- */
-@:jsonParse(j -> funkin.play.cutscene.dialogue.DialogueBoxTextData.fromJson(j))
-@:jsonStringify(v -> v.toJson())
-class DialogueBoxTextData
-{
- public var offsets:Array;
- public var width:Int;
- public var size:Int;
- public var color:String;
- public var shadowColor:Null;
- public var shadowWidth:Null;
-
- public function new(offsets:Null>, ?width:Int, ?size:Int, color:String, ?shadowColor:String, shadowWidth:Null)
- {
- this.offsets = offsets ?? [0, 0];
- this.width = width ?? 300;
- this.size = size ?? 32;
- this.color = color;
- this.shadowColor = shadowColor;
- this.shadowWidth = shadowWidth;
- }
-
- public static function fromJson(j:Dynamic):DialogueBoxTextData
- {
- // TODO: Check version and perform migrations if necessary.
- if (j == null) return null;
- return new DialogueBoxTextData(j.offsets, j.width, j.size, j.color, j.shadowColor, j.shadowWidth);
- }
-
- public function toJson():Dynamic
- {
- return {
- offsets: this.offsets,
- width: this.width,
- size: this.size,
- color: this.color,
- shadowColor: this.shadowColor,
- shadowWidth: this.shadowWidth,
- };
- }
-}
diff --git a/source/funkin/play/cutscene/dialogue/DialogueBoxDataParser.hx b/source/funkin/play/cutscene/dialogue/DialogueBoxDataParser.hx
deleted file mode 100644
index cb00dd80d..000000000
--- a/source/funkin/play/cutscene/dialogue/DialogueBoxDataParser.hx
+++ /dev/null
@@ -1,159 +0,0 @@
-package funkin.play.cutscene.dialogue;
-
-import openfl.Assets;
-import funkin.util.assets.DataAssets;
-import funkin.play.cutscene.dialogue.DialogueBox;
-import funkin.play.cutscene.dialogue.ScriptedDialogueBox;
-
-/**
- * Contains utilities for loading and parsing dialogueBox data.
- */
-class DialogueBoxDataParser
-{
- public static final DIALOGUE_BOX_DATA_VERSION:String = '1.0.0';
- public static final DIALOGUE_BOX_DATA_VERSION_RULE:String = '1.0.x';
-
- static final dialogueBoxCache:Map = new Map();
-
- static final dialogueBoxScriptedClass:Map = new Map();
-
- static final DEFAULT_DIALOGUE_BOX_ID:String = 'UNKNOWN';
-
- /**
- * Parses and preloads the game's dialogueBox data and scripts when the game starts.
- *
- * If you want to force dialogue boxes to be reloaded, you can just call this function again.
- */
- public static function loadDialogueBoxCache():Void
- {
- clearDialogueBoxCache();
- trace('Loading dialogue box cache...');
-
- //
- // SCRIPTED CONVERSATIONS
- //
- var scriptedDialogueBoxClassNames:Array = ScriptedDialogueBox.listScriptClasses();
- trace(' Instantiating ${scriptedDialogueBoxClassNames.length} scripted dialogue boxes...');
- for (dialogueBoxCls in scriptedDialogueBoxClassNames)
- {
- var dialogueBox:DialogueBox = ScriptedDialogueBox.init(dialogueBoxCls, DEFAULT_DIALOGUE_BOX_ID);
- if (dialogueBox != null)
- {
- trace(' Loaded scripted dialogue box: ${dialogueBox.dialogueBoxName}');
- // Disable the rendering logic for dialogueBox until it's loaded.
- // Note that kill() =/= destroy()
- dialogueBox.kill();
-
- // Then store it.
- dialogueBoxCache.set(dialogueBox.dialogueBoxId, dialogueBox);
- }
- else
- {
- trace(' Failed to instantiate scripted dialogueBox class: ${dialogueBoxCls}');
- }
- }
-
- //
- // UNSCRIPTED CONVERSATIONS
- //
- // Scripts refers to code here, not the actual dialogue.
- var dialogueBoxIdList:Array = DataAssets.listDataFilesInPath('dialogue/boxes/');
- // Filter out dialogue boxes that are scripted.
- var unscriptedDialogueBoxIds:Array = dialogueBoxIdList.filter(function(dialogueBoxId:String):Bool {
- return !dialogueBoxCache.exists(dialogueBoxId);
- });
- trace(' Fetching data for ${unscriptedDialogueBoxIds.length} dialogue boxes...');
- for (dialogueBoxId in unscriptedDialogueBoxIds)
- {
- try
- {
- var dialogueBox:DialogueBox = new DialogueBox(dialogueBoxId);
- if (dialogueBox != null)
- {
- trace(' Loaded dialogueBox data: ${dialogueBox.dialogueBoxName}');
- dialogueBoxCache.set(dialogueBox.dialogueBoxId, dialogueBox);
- }
- }
- catch (e)
- {
- trace(e);
- continue;
- }
- }
- }
-
- /**
- * Fetches data for a dialogueBox and returns a DialogueBox instance,
- * ready to be displayed.
- * @param dialogueBoxId The ID of the dialogueBox to fetch.
- * @return The dialogueBox instance, or null if the dialogueBox was not found.
- */
- public static function fetchDialogueBox(dialogueBoxId:String):Null
- {
- if (dialogueBoxId != null && dialogueBoxId != '' && dialogueBoxCache.exists(dialogueBoxId))
- {
- trace('Successfully fetched dialogueBox: ${dialogueBoxId}');
- var dialogueBox:DialogueBox = dialogueBoxCache.get(dialogueBoxId);
- dialogueBox.revive();
- return dialogueBox;
- }
- else
- {
- trace('Failed to fetch dialogueBox, not found in cache: ${dialogueBoxId}');
- return null;
- }
- }
-
- static function clearDialogueBoxCache():Void
- {
- if (dialogueBoxCache != null)
- {
- for (dialogueBox in dialogueBoxCache)
- {
- dialogueBox.destroy();
- }
- dialogueBoxCache.clear();
- }
- }
-
- public static function listDialogueBoxIds():Array
- {
- return dialogueBoxCache.keys().array();
- }
-
- /**
- * Load a dialogueBox's JSON file, parse its data, and return it.
- *
- * @param dialogueBoxId The dialogueBox to load.
- * @return The dialogueBox data, or null if validation failed.
- */
- public static function parseDialogueBoxData(dialogueBoxId:String):Null
- {
- var rawJson:String = loadDialogueBoxFile(dialogueBoxId);
-
- try
- {
- var dialogueBoxData:DialogueBoxData = DialogueBoxData.fromString(rawJson);
- return dialogueBoxData;
- }
- catch (e)
- {
- trace('Failed to parse dialogueBox ($dialogueBoxId).');
- trace(e);
- return null;
- }
- }
-
- static function loadDialogueBoxFile(dialogueBoxPath:String):String
- {
- var dialogueBoxFilePath:String = Paths.json('dialogue/boxes/${dialogueBoxPath}');
- var rawJson:String = Assets.getText(dialogueBoxFilePath).trim();
-
- while (!rawJson.endsWith('}') && rawJson.length > 0)
- {
- rawJson = rawJson.substr(0, rawJson.length - 1);
- }
-
- return rawJson;
- }
-}
diff --git a/source/funkin/play/cutscene/dialogue/ScriptedConversation.hx b/source/funkin/play/cutscene/dialogue/ScriptedConversation.hx
index 4fe383a5e..cb7344273 100644
--- a/source/funkin/play/cutscene/dialogue/ScriptedConversation.hx
+++ b/source/funkin/play/cutscene/dialogue/ScriptedConversation.hx
@@ -1,4 +1,10 @@
package funkin.play.cutscene.dialogue;
+/**
+ * A script that can be tied to a Conversation.
+ * Create a scripted class that extends Conversation to use this.
+ * This allows you to customize how a specific conversation appears and behaves.
+ * Someone clever could use this to add branching dialogue I think.
+ */
@:hscriptClass
class ScriptedConversation extends Conversation implements polymod.hscript.HScriptedClass {}
diff --git a/source/funkin/play/cutscene/dialogue/ScriptedDialogueBox.hx b/source/funkin/play/cutscene/dialogue/ScriptedDialogueBox.hx
index a1b36c7c2..7689fc0d9 100644
--- a/source/funkin/play/cutscene/dialogue/ScriptedDialogueBox.hx
+++ b/source/funkin/play/cutscene/dialogue/ScriptedDialogueBox.hx
@@ -1,4 +1,9 @@
package funkin.play.cutscene.dialogue;
+/**
+ * A script that can be tied to a DialogueBox.
+ * Create a scripted class that extends DialogueBox to use this.
+ * This allows you to customize how a specific dialogue box appears.
+ */
@:hscriptClass
class ScriptedDialogueBox extends DialogueBox implements polymod.hscript.HScriptedClass {}
diff --git a/source/funkin/play/cutscene/dialogue/Speaker.hx b/source/funkin/play/cutscene/dialogue/Speaker.hx
index d7ed004f1..d5bffd7b0 100644
--- a/source/funkin/play/cutscene/dialogue/Speaker.hx
+++ b/source/funkin/play/cutscene/dialogue/Speaker.hx
@@ -1,27 +1,30 @@
package funkin.play.cutscene.dialogue;
import flixel.FlxSprite;
+import funkin.data.IRegistryEntry;
import funkin.modding.events.ScriptEvent;
import flixel.graphics.frames.FlxFramesCollection;
import funkin.util.assets.FlxAnimationUtil;
import funkin.modding.IScriptedClass.IDialogueScriptedClass;
+import funkin.data.dialogue.SpeakerData;
+import funkin.data.dialogue.SpeakerRegistry;
/**
* The character sprite which displays during dialogue.
*
* Most conversations have two speakers, with one being flipped.
*/
-class Speaker extends FlxSprite implements IDialogueScriptedClass
+class Speaker extends FlxSprite implements IDialogueScriptedClass implements IRegistryEntry
{
/**
* The internal ID for this speaker.
*/
- public final speakerId:String;
+ public final id:String;
/**
* The full data for a speaker.
*/
- var speakerData:SpeakerData;
+ public final _data:SpeakerData;
/**
* A readable name for this speaker.
@@ -30,7 +33,7 @@ class Speaker extends FlxSprite implements IDialogueScriptedClass
function get_speakerName():String
{
- return speakerData.name;
+ return _data.name;
}
/**
@@ -75,14 +78,17 @@ class Speaker extends FlxSprite implements IDialogueScriptedClass
return globalOffsets = value;
}
- public function new(speakerId:String)
+ public function new(id:String)
{
super();
- this.speakerId = speakerId;
- this.speakerData = SpeakerDataParser.parseSpeakerData(this.speakerId);
+ this.id = id;
+ this._data = _fetchData(id);
- if (speakerData == null) throw 'Could not load speaker data for speaker ID "$speakerId"';
+ if (_data == null)
+ {
+ throw 'Could not parse speaker data for id: $id';
+ }
}
/**
@@ -102,18 +108,18 @@ class Speaker extends FlxSprite implements IDialogueScriptedClass
function loadSpritesheet():Void
{
- trace('[SPEAKER] Loading spritesheet ${speakerData.assetPath} for ${speakerId}');
+ trace('[SPEAKER] Loading spritesheet ${_data.assetPath} for ${id}');
- var tex:FlxFramesCollection = Paths.getSparrowAtlas(speakerData.assetPath);
+ var tex:FlxFramesCollection = Paths.getSparrowAtlas(_data.assetPath);
if (tex == null)
{
- trace('Could not load Sparrow sprite: ${speakerData.assetPath}');
+ trace('Could not load Sparrow sprite: ${_data.assetPath}');
return;
}
this.frames = tex;
- if (speakerData.isPixel)
+ if (_data.isPixel)
{
this.antialiasing = false;
}
@@ -122,9 +128,10 @@ class Speaker extends FlxSprite implements IDialogueScriptedClass
this.antialiasing = true;
}
- this.flipX = speakerData.flipX;
- this.globalOffsets = speakerData.offsets;
- this.setScale(speakerData.scale);
+ this.flipX = _data.flipX;
+ this.flipY = _data.flipY;
+ this.globalOffsets = _data.offsets;
+ this.setScale(_data.scale);
}
/**
@@ -141,11 +148,11 @@ class Speaker extends FlxSprite implements IDialogueScriptedClass
function loadAnimations():Void
{
- trace('[SPEAKER] Loading ${speakerData.animations.length} animations for ${speakerId}');
+ trace('[SPEAKER] Loading ${_data.animations.length} animations for ${id}');
- FlxAnimationUtil.addAtlasAnimations(this, speakerData.animations);
+ FlxAnimationUtil.addAtlasAnimations(this, _data.animations);
- for (anim in speakerData.animations)
+ for (anim in _data.animations)
{
if (anim.offsets == null)
{
@@ -158,7 +165,7 @@ class Speaker extends FlxSprite implements IDialogueScriptedClass
}
var animNames:Array = this.animation.getNameList();
- trace('[SPEAKER] Successfully loaded ${animNames.length} animations for ${speakerId}');
+ trace('[SPEAKER] Successfully loaded ${animNames.length} animations for ${id}');
}
/**
@@ -271,4 +278,14 @@ class Speaker extends FlxSprite implements IDialogueScriptedClass
}
public function onScriptEvent(event:ScriptEvent):Void {}
+
+ public override function toString():String
+ {
+ return 'Speaker($id)';
+ }
+
+ static function _fetchData(id:String):Null
+ {
+ return SpeakerRegistry.instance.parseEntryDataWithMigration(id, SpeakerRegistry.instance.fetchEntryVersion(id));
+ }
}
diff --git a/source/funkin/play/cutscene/dialogue/SpeakerData.hx b/source/funkin/play/cutscene/dialogue/SpeakerData.hx
deleted file mode 100644
index 88883ead8..000000000
--- a/source/funkin/play/cutscene/dialogue/SpeakerData.hx
+++ /dev/null
@@ -1,78 +0,0 @@
-package funkin.play.cutscene.dialogue;
-
-import funkin.data.animation.AnimationData;
-
-/**
- * Data about a conversation.
- * Includes what speakers are in the conversation, and what phrases they say.
- */
-@:jsonParse(j -> funkin.play.cutscene.dialogue.SpeakerData.fromJson(j))
-@:jsonStringify(v -> v.toJson())
-class SpeakerData
-{
- public var version:String;
- public var name:String;
- public var assetPath:String;
- public var flipX:Bool;
- public var isPixel:Bool;
- public var offsets:Array;
- public var scale:Float;
- public var animations:Array;
-
- public function new(version:String, name:String, assetPath:String, animations:Array, ?offsets:Array, flipX:Bool = false,
- isPixel:Bool = false, ?scale:Float = 1.0)
- {
- this.version = version;
- this.name = name;
- this.assetPath = assetPath;
- this.animations = animations;
-
- this.offsets = offsets;
- if (this.offsets == null || this.offsets == []) this.offsets = [0, 0];
-
- this.flipX = flipX;
- this.isPixel = isPixel;
- this.scale = scale;
- }
-
- public static function fromString(i:String):SpeakerData
- {
- if (i == null || i == '') return null;
- var data:
- {
- version:String,
- name:String,
- assetPath:String,
- animations:Array,
- ?offsets:Array,
- ?flipX:Bool,
- ?isPixel:Bool,
- ?scale:Float
- } = tink.Json.parse(i);
- return fromJson(data);
- }
-
- public static function fromJson(j:Dynamic):SpeakerData
- {
- // TODO: Check version and perform migrations if necessary.
- if (j == null) return null;
- return new SpeakerData(j.version, j.name, j.assetPath, j.animations, j.offsets, j.flipX, j.isPixel, j.scale);
- }
-
- public function toJson():Dynamic
- {
- var result:Dynamic =
- {
- version: this.version,
- name: this.name,
- assetPath: this.assetPath,
- animations: this.animations,
- flipX: this.flipX,
- isPixel: this.isPixel
- };
-
- if (this.scale != 1.0) result.scale = this.scale;
-
- return result;
- }
-}
diff --git a/source/funkin/play/cutscene/dialogue/SpeakerDataParser.hx b/source/funkin/play/cutscene/dialogue/SpeakerDataParser.hx
deleted file mode 100644
index f7ddb099f..000000000
--- a/source/funkin/play/cutscene/dialogue/SpeakerDataParser.hx
+++ /dev/null
@@ -1,159 +0,0 @@
-package funkin.play.cutscene.dialogue;
-
-import openfl.Assets;
-import funkin.util.assets.DataAssets;
-import funkin.play.cutscene.dialogue.Speaker;
-import funkin.play.cutscene.dialogue.ScriptedSpeaker;
-
-/**
- * Contains utilities for loading and parsing speaker data.
- */
-class SpeakerDataParser
-{
- public static final SPEAKER_DATA_VERSION:String = '1.0.0';
- public static final SPEAKER_DATA_VERSION_RULE:String = '1.0.x';
-
- static final speakerCache:Map = new Map();
-
- static final speakerScriptedClass:Map = new Map();
-
- static final DEFAULT_SPEAKER_ID:String = 'UNKNOWN';
-
- /**
- * Parses and preloads the game's speaker data and scripts when the game starts.
- *
- * If you want to force speakers to be reloaded, you can just call this function again.
- */
- public static function loadSpeakerCache():Void
- {
- clearSpeakerCache();
- trace('Loading dialogue speaker cache...');
-
- //
- // SCRIPTED CONVERSATIONS
- //
- var scriptedSpeakerClassNames:Array = ScriptedSpeaker.listScriptClasses();
- trace(' Instantiating ${scriptedSpeakerClassNames.length} scripted speakers...');
- for (speakerCls in scriptedSpeakerClassNames)
- {
- var speaker:Speaker = ScriptedSpeaker.init(speakerCls, DEFAULT_SPEAKER_ID);
- if (speaker != null)
- {
- trace(' Loaded scripted speaker: ${speaker.speakerName}');
- // Disable the rendering logic for speaker until it's loaded.
- // Note that kill() =/= destroy()
- speaker.kill();
-
- // Then store it.
- speakerCache.set(speaker.speakerId, speaker);
- }
- else
- {
- trace(' Failed to instantiate scripted speaker class: ${speakerCls}');
- }
- }
-
- //
- // UNSCRIPTED CONVERSATIONS
- //
- // Scripts refers to code here, not the actual dialogue.
- var speakerIdList:Array = DataAssets.listDataFilesInPath('dialogue/speakers/');
- // Filter out speakers that are scripted.
- var unscriptedSpeakerIds:Array = speakerIdList.filter(function(speakerId:String):Bool {
- return !speakerCache.exists(speakerId);
- });
- trace(' Fetching data for ${unscriptedSpeakerIds.length} speakers...');
- for (speakerId in unscriptedSpeakerIds)
- {
- try
- {
- var speaker:Speaker = new Speaker(speakerId);
- if (speaker != null)
- {
- trace(' Loaded speaker data: ${speaker.speakerName}');
- speakerCache.set(speaker.speakerId, speaker);
- }
- }
- catch (e)
- {
- trace(e);
- continue;
- }
- }
- }
-
- /**
- * Fetches data for a speaker and returns a Speaker instance,
- * ready to be displayed.
- * @param speakerId The ID of the speaker to fetch.
- * @return The speaker instance, or null if the speaker was not found.
- */
- public static function fetchSpeaker(speakerId:String):Null
- {
- if (speakerId != null && speakerId != '' && speakerCache.exists(speakerId))
- {
- trace('Successfully fetched speaker: ${speakerId}');
- var speaker:Speaker = speakerCache.get(speakerId);
- speaker.revive();
- return speaker;
- }
- else
- {
- trace('Failed to fetch speaker, not found in cache: ${speakerId}');
- return null;
- }
- }
-
- static function clearSpeakerCache():Void
- {
- if (speakerCache != null)
- {
- for (speaker in speakerCache)
- {
- speaker.destroy();
- }
- speakerCache.clear();
- }
- }
-
- public static function listSpeakerIds():Array
- {
- return speakerCache.keys().array();
- }
-
- /**
- * Load a speaker's JSON file, parse its data, and return it.
- *
- * @param speakerId The speaker to load.
- * @return The speaker data, or null if validation failed.
- */
- public static function parseSpeakerData(speakerId:String):Null
- {
- var rawJson:String = loadSpeakerFile(speakerId);
-
- try
- {
- var speakerData:SpeakerData = SpeakerData.fromString(rawJson);
- return speakerData;
- }
- catch (e)
- {
- trace('Failed to parse speaker ($speakerId).');
- trace(e);
- return null;
- }
- }
-
- static function loadSpeakerFile(speakerPath:String):String
- {
- var speakerFilePath:String = Paths.json('dialogue/speakers/${speakerPath}');
- var rawJson:String = Assets.getText(speakerFilePath).trim();
-
- while (!rawJson.endsWith('}') && rawJson.length > 0)
- {
- rawJson = rawJson.substr(0, rawJson.length - 1);
- }
-
- return rawJson;
- }
-}
diff --git a/source/funkin/play/event/FocusCameraSongEvent.hx b/source/funkin/play/event/FocusCameraSongEvent.hx
index 83c978ba8..847df4a60 100644
--- a/source/funkin/play/event/FocusCameraSongEvent.hx
+++ b/source/funkin/play/event/FocusCameraSongEvent.hx
@@ -135,10 +135,10 @@ class FocusCameraSongEvent extends SongEvent
return new SongEventSchema([
{
name: "char",
- title: "Character",
+ title: "Target",
defaultValue: 0,
type: SongEventFieldType.ENUM,
- keys: ["Position" => -1, "Boyfriend" => 0, "Dad" => 1, "Girlfriend" => 2]
+ keys: ["Position" => -1, "Player" => 0, "Opponent" => 1, "Girlfriend" => 2]
},
{
name: "x",
@@ -146,6 +146,7 @@ class FocusCameraSongEvent extends SongEvent
defaultValue: 0,
step: 10.0,
type: SongEventFieldType.FLOAT,
+ units: "px"
},
{
name: "y",
@@ -153,6 +154,7 @@ class FocusCameraSongEvent extends SongEvent
defaultValue: 0,
step: 10.0,
type: SongEventFieldType.FLOAT,
+ units: "px"
}
]);
}
diff --git a/source/funkin/play/event/SetCameraBopSongEvent.hx b/source/funkin/play/event/SetCameraBopSongEvent.hx
index d0e01346f..a82577a5f 100644
--- a/source/funkin/play/event/SetCameraBopSongEvent.hx
+++ b/source/funkin/play/event/SetCameraBopSongEvent.hx
@@ -78,14 +78,16 @@ class SetCameraBopSongEvent extends SongEvent
title: 'Intensity',
defaultValue: 1.0,
step: 0.1,
- type: SongEventFieldType.FLOAT
+ type: SongEventFieldType.FLOAT,
+ units: 'x'
},
{
name: 'rate',
- title: 'Rate (beats/zoom)',
+ title: 'Rate',
defaultValue: 4,
step: 1,
type: SongEventFieldType.INTEGER,
+ units: 'beats/zoom'
}
]);
}
diff --git a/source/funkin/play/event/ZoomCameraSongEvent.hx b/source/funkin/play/event/ZoomCameraSongEvent.hx
index a35a12e1e..809130499 100644
--- a/source/funkin/play/event/ZoomCameraSongEvent.hx
+++ b/source/funkin/play/event/ZoomCameraSongEvent.hx
@@ -106,14 +106,16 @@ class ZoomCameraSongEvent extends SongEvent
title: 'Zoom Level',
defaultValue: 1.0,
step: 0.1,
- type: SongEventFieldType.FLOAT
+ type: SongEventFieldType.FLOAT,
+ units: 'x'
},
{
name: 'duration',
- title: 'Duration (in steps)',
+ title: 'Duration',
defaultValue: 4.0,
step: 0.5,
type: SongEventFieldType.FLOAT,
+ units: 'steps'
},
{
name: 'ease',
diff --git a/source/funkin/ui/debug/WaveformTestState.hx b/source/funkin/ui/debug/WaveformTestState.hx
new file mode 100644
index 000000000..f3566db85
--- /dev/null
+++ b/source/funkin/ui/debug/WaveformTestState.hx
@@ -0,0 +1,191 @@
+package funkin.ui.debug;
+
+import flixel.math.FlxRect;
+import flixel.FlxSprite;
+import flixel.util.FlxColor;
+import funkin.audio.FunkinSound;
+import funkin.audio.waveform.WaveformData;
+import funkin.audio.waveform.WaveformSprite;
+import funkin.audio.waveform.WaveformDataParser;
+import funkin.graphics.rendering.MeshRender;
+
+class WaveformTestState extends MusicBeatState
+{
+ public function new()
+ {
+ super();
+ }
+
+ var waveformData:WaveformData;
+ var waveformData2:WaveformData;
+
+ var waveformAudio:FunkinSound;
+
+ // var waveformSprite:WaveformSprite;
+ // var waveformSprite2:WaveformSprite;
+ var timeMarker:FlxSprite;
+
+ var polygonSprite:MeshRender;
+ var vertexCount:Int = 3;
+
+ public override function create():Void
+ {
+ super.create();
+
+ var testSprite = new FlxSprite(0, 0);
+ testSprite.loadGraphic(Paths.image('funkay'));
+ testSprite.updateHitbox();
+ testSprite.clipRect = new FlxRect(0, 0, FlxG.width, FlxG.height);
+ // add(testSprite);
+
+ waveformAudio = FunkinSound.load(Paths.inst('bopeebo', '-erect'));
+
+ waveformData = WaveformDataParser.interpretFlxSound(waveformAudio);
+
+ polygonSprite = new MeshRender(FlxG.width / 2, FlxG.height / 2, FlxColor.WHITE);
+
+ setPolygonVertices(vertexCount);
+ add(polygonSprite);
+
+ // waveformSprite = WaveformSprite.buildFromWaveformData(waveformData, HORIZONTAL, FlxColor.fromString("#ADD8E6"));
+ // waveformSprite.duration = 5.0 * 160;
+ // waveformSprite.width = FlxG.width * 160;
+ // waveformSprite.height = FlxG.height; // / 2;
+ // waveformSprite.amplitude = 2.0;
+ // waveformSprite.minWaveformSize = 25;
+ // waveformSprite.clipRect = new FlxRect(0, 0, FlxG.width, FlxG.height);
+ // add(waveformSprite);
+ //
+ // waveformSprite2 = WaveformSprite.buildFromWaveformData(waveformData2, HORIZONTAL, FlxColor.fromString("#FF0000"), 5.0);
+ // waveformSprite2.width = FlxG.width;
+ // waveformSprite2.height = FlxG.height / 2;
+ // waveformSprite2.y = FlxG.height / 2;
+ // add(waveformSprite2);
+
+ timeMarker = new FlxSprite(0, FlxG.height * 1 / 6);
+ timeMarker.makeGraphic(1, Std.int(FlxG.height * 2 / 3), FlxColor.RED);
+ add(timeMarker);
+
+ // drawWaveform(time, duration);
+ }
+
+ public override function update(elapsed:Float):Void
+ {
+ super.update(elapsed);
+
+ if (FlxG.keys.justPressed.SPACE)
+ {
+ if (waveformAudio.isPlaying)
+ {
+ waveformAudio.stop();
+ }
+ else
+ {
+ waveformAudio.play();
+ }
+ }
+
+ if (FlxG.keys.justPressed.ENTER)
+ {
+ // if (waveformSprite.orientation == HORIZONTAL)
+ // {
+ // // waveformSprite.orientation = VERTICAL;
+ // // waveformSprite2.orientation = VERTICAL;
+ // }
+ // else
+ // {
+ // // waveformSprite.orientation = HORIZONTAL;
+ // // waveformSprite2.orientation = HORIZONTAL;
+ // }
+ }
+
+ if (waveformAudio.isPlaying)
+ {
+ // waveformSprite takes a time in fractional seconds, not milliseconds.
+ var timeSeconds = waveformAudio.time / 1000;
+ // waveformSprite.time = timeSeconds;
+ // waveformSprite2.time = timeSeconds;
+ }
+
+ if (FlxG.keys.justPressed.UP)
+ {
+ vertexCount += 1;
+ setPolygonVertices(vertexCount);
+ // waveformSprite.duration += 1.0;
+ // waveformSprite2.duration += 1.0;
+ }
+ if (FlxG.keys.justPressed.DOWN)
+ {
+ vertexCount -= 1;
+ setPolygonVertices(vertexCount);
+ // waveformSprite.duration -= 1.0;
+ // waveformSprite2.duration -= 1.0;
+ }
+ if (FlxG.keys.justPressed.LEFT)
+ {
+ // waveformSprite.time -= 1.0;
+ // waveformSprite2.time -= 1.0;
+ }
+ if (FlxG.keys.justPressed.RIGHT)
+ {
+ // waveformSprite.time += 1.0;
+ // waveformSprite2.time += 1.0;
+ }
+ }
+
+ function setPolygonVertices(count:Int)
+ {
+ polygonSprite.clear();
+
+ var size = 100.0;
+
+ // Build a polygon with count vertices.
+
+ var vertices:Array> = [];
+
+ var angle = 0.0;
+
+ for (i in 0...count)
+ {
+ var x = Math.cos(angle) * size;
+ var y = Math.sin(angle) * size;
+
+ vertices.push([x, y]);
+
+ angle += 2 * Math.PI / count;
+ }
+
+ trace('vertices: ${vertices}');
+
+ var centerVertex = polygonSprite.build_vertex(0, 0);
+ var firstVertex = -1;
+ var lastVertex = -1;
+
+ for (vertex in vertices)
+ {
+ var x = vertex[0];
+ var y = vertex[1];
+
+ var newVertex = polygonSprite.build_vertex(x, y);
+
+ if (firstVertex == -1)
+ {
+ firstVertex = newVertex;
+ }
+
+ if (lastVertex != -1)
+ {
+ polygonSprite.add_tri(centerVertex, lastVertex, newVertex);
+ }
+
+ lastVertex = newVertex;
+ }
+
+ polygonSprite.add_tri(centerVertex, lastVertex, firstVertex);
+ }
+
+ public override function destroy():Void
+ {
+ super.destroy();
+ }
+}
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 5f526a364..dab79a21c 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -1,45 +1,52 @@
package funkin.ui.debug.charting;
-import funkin.util.logging.CrashHandler;
-import haxe.ui.containers.HBox;
-import haxe.ui.containers.Grid;
-import haxe.ui.containers.ScrollView;
-import haxe.ui.containers.menus.MenuBar;
import flixel.addons.display.FlxSliceSprite;
import flixel.addons.display.FlxTiledSprite;
import flixel.addons.transition.FlxTransitionableState;
import flixel.FlxCamera;
import flixel.FlxSprite;
import flixel.FlxSubState;
+import flixel.graphics.FlxGraphic;
+import flixel.group.FlxGroup.FlxTypedGroup;
import flixel.group.FlxSpriteGroup;
-import funkin.graphics.FunkinSprite;
import flixel.input.keyboard.FlxKey;
+import flixel.input.mouse.FlxMouseEvent;
import flixel.math.FlxMath;
import flixel.math.FlxPoint;
-import flixel.graphics.FlxGraphic;
import flixel.math.FlxRect;
import flixel.sound.FlxSound;
+import flixel.system.debug.log.LogStyle;
import flixel.system.FlxAssets.FlxSoundAsset;
+import flixel.text.FlxText;
import flixel.tweens.FlxEase;
import flixel.tweens.FlxTween;
import flixel.tweens.misc.VarTween;
-import haxe.ui.Toolkit;
import flixel.util.FlxColor;
import flixel.util.FlxSort;
import flixel.util.FlxTimer;
+import funkin.audio.FunkinSound;
+import funkin.audio.visualize.PolygonSpectogram;
import funkin.audio.visualize.PolygonSpectogram;
import funkin.audio.VoicesGroup;
-import funkin.audio.FunkinSound;
+import funkin.audio.waveform.WaveformSprite;
import funkin.data.notestyle.NoteStyleRegistry;
import funkin.data.song.SongData.SongCharacterData;
+import funkin.data.song.SongData.SongCharacterData;
+import funkin.data.song.SongData.SongChartData;
import funkin.data.song.SongData.SongChartData;
import funkin.data.song.SongData.SongEventData;
+import funkin.data.song.SongData.SongEventData;
import funkin.data.song.SongData.SongMetadata;
-import funkin.ui.debug.charting.toolboxes.ChartEditorDifficultyToolbox;
+import funkin.data.song.SongData.SongMetadata;
+import funkin.data.song.SongData.SongNoteData;
import funkin.data.song.SongData.SongNoteData;
import funkin.data.song.SongData.SongOffsets;
import funkin.data.song.SongDataUtils;
+import funkin.data.song.SongDataUtils;
import funkin.data.song.SongRegistry;
+import funkin.data.song.SongRegistry;
+import funkin.data.stage.StageData;
+import funkin.graphics.FunkinSprite;
import funkin.input.Cursor;
import funkin.input.TurboKeyHandler;
import funkin.modding.events.ScriptEvent;
@@ -50,22 +57,14 @@ import funkin.play.components.HealthIcon;
import funkin.play.notes.NoteSprite;
import funkin.play.PlayState;
import funkin.play.song.Song;
-import funkin.data.song.SongData.SongChartData;
-import funkin.data.song.SongRegistry;
-import funkin.data.song.SongData.SongEventData;
-import funkin.data.song.SongData.SongMetadata;
-import funkin.data.song.SongData.SongNoteData;
-import funkin.data.song.SongData.SongCharacterData;
-import funkin.data.song.SongDataUtils;
-import funkin.ui.debug.charting.commands.ChartEditorCommand;
-import funkin.data.stage.StageData;
import funkin.save.Save;
import funkin.ui.debug.charting.commands.AddEventsCommand;
import funkin.ui.debug.charting.commands.AddNotesCommand;
import funkin.ui.debug.charting.commands.ChartEditorCommand;
import funkin.ui.debug.charting.commands.ChartEditorCommand;
-import funkin.ui.debug.charting.commands.CutItemsCommand;
+import funkin.ui.debug.charting.commands.ChartEditorCommand;
import funkin.ui.debug.charting.commands.CopyItemsCommand;
+import funkin.ui.debug.charting.commands.CutItemsCommand;
import funkin.ui.debug.charting.commands.DeselectAllItemsCommand;
import funkin.ui.debug.charting.commands.DeselectItemsCommand;
import funkin.ui.debug.charting.commands.ExtendNoteLengthCommand;
@@ -83,17 +82,22 @@ import funkin.ui.debug.charting.commands.SelectItemsCommand;
import funkin.ui.debug.charting.commands.SetItemSelectionCommand;
import funkin.ui.debug.charting.components.ChartEditorEventSprite;
import funkin.ui.debug.charting.components.ChartEditorHoldNoteSprite;
+import funkin.ui.debug.charting.components.ChartEditorMeasureTicks;
import funkin.ui.debug.charting.components.ChartEditorNotePreview;
import funkin.ui.debug.charting.components.ChartEditorNoteSprite;
-import funkin.ui.debug.charting.components.ChartEditorMeasureTicks;
import funkin.ui.debug.charting.components.ChartEditorPlaybarHead;
import funkin.ui.debug.charting.components.ChartEditorSelectionSquareSprite;
import funkin.ui.debug.charting.handlers.ChartEditorShortcutHandler;
+import funkin.ui.debug.charting.toolboxes.ChartEditorBaseToolbox;
+import funkin.ui.debug.charting.toolboxes.ChartEditorDifficultyToolbox;
+import funkin.ui.debug.charting.toolboxes.ChartEditorFreeplayToolbox;
+import funkin.ui.debug.charting.toolboxes.ChartEditorOffsetsToolbox;
import funkin.ui.haxeui.components.CharacterPlayer;
import funkin.ui.haxeui.HaxeUIState;
import funkin.ui.mainmenu.MainMenuState;
import funkin.util.Constants;
import funkin.util.FileUtil;
+import funkin.util.logging.CrashHandler;
import funkin.util.SortUtil;
import funkin.util.WindowUtil;
import haxe.DynamicAccess;
@@ -101,23 +105,26 @@ import haxe.io.Bytes;
import haxe.io.Path;
import haxe.ui.backend.flixel.UIRuntimeState;
import haxe.ui.backend.flixel.UIState;
-import haxe.ui.components.DropDown;
-import haxe.ui.components.Label;
import haxe.ui.components.Button;
+import haxe.ui.components.DropDown;
+import haxe.ui.components.Image;
+import haxe.ui.components.Label;
import haxe.ui.components.NumberStepper;
import haxe.ui.components.Slider;
import haxe.ui.components.TextField;
+import haxe.ui.containers.Box;
import haxe.ui.containers.dialogs.CollapsibleDialog;
import haxe.ui.containers.Frame;
-import haxe.ui.containers.Box;
+import haxe.ui.containers.Grid;
+import haxe.ui.containers.HBox;
import haxe.ui.containers.menus.Menu;
import haxe.ui.containers.menus.MenuBar;
-import haxe.ui.containers.menus.MenuItem;
+import haxe.ui.containers.menus.MenuBar;
import haxe.ui.containers.menus.MenuCheckBox;
+import haxe.ui.containers.menus.MenuItem;
+import haxe.ui.containers.ScrollView;
import haxe.ui.containers.TreeView;
import haxe.ui.containers.TreeViewNode;
-import haxe.ui.components.Image;
-import funkin.ui.debug.charting.toolboxes.ChartEditorBaseToolbox;
import haxe.ui.core.Component;
import haxe.ui.core.Screen;
import haxe.ui.events.DragEvent;
@@ -125,13 +132,8 @@ import haxe.ui.events.MouseEvent;
import haxe.ui.events.UIEvent;
import haxe.ui.events.UIEvent;
import haxe.ui.focus.FocusManager;
+import haxe.ui.Toolkit;
import openfl.display.BitmapData;
-import funkin.audio.visualize.PolygonSpectogram;
-import flixel.group.FlxGroup.FlxTypedGroup;
-import funkin.audio.visualize.PolygonVisGroup;
-import flixel.input.mouse.FlxMouseEvent;
-import flixel.text.FlxText;
-import flixel.system.debug.log.LogStyle;
using Lambda;
@@ -153,17 +155,19 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
*/
// ==============================
// Layouts
- public static final CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/notedata');
-
- public static final CHART_EDITOR_TOOLBOX_EVENT_DATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/eventdata');
- public static final CHART_EDITOR_TOOLBOX_PLAYTEST_PROPERTIES_LAYOUT:String = Paths.ui('chart-editor/toolbox/playtest-properties');
- public static final CHART_EDITOR_TOOLBOX_METADATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/metadata');
public static final CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT:String = Paths.ui('chart-editor/toolbox/difficulty');
+
public static final CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT:String = Paths.ui('chart-editor/toolbox/player-preview');
public static final CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT:String = Paths.ui('chart-editor/toolbox/opponent-preview');
+ public static final CHART_EDITOR_TOOLBOX_METADATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/metadata');
+ public static final CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT:String = Paths.ui('chart-editor/toolbox/offsets');
+ public static final CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/notedata');
+ public static final CHART_EDITOR_TOOLBOX_EVENT_DATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/eventdata');
+ public static final CHART_EDITOR_TOOLBOX_FREEPLAY_LAYOUT:String = Paths.ui('chart-editor/toolbox/freeplay');
+ public static final CHART_EDITOR_TOOLBOX_PLAYTEST_PROPERTIES_LAYOUT:String = Paths.ui('chart-editor/toolbox/playtest-properties');
// Validation
- public static final SUPPORTED_MUSIC_FORMATS:Array = ['ogg'];
+ public static final SUPPORTED_MUSIC_FORMATS:Array = #if sys ['ogg'] #else ['mp3'] #end;
// Layout
@@ -393,13 +397,12 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
gridTiledSprite.y = -scrollPositionInPixels + (GRID_INITIAL_Y_POS);
measureTicks.y = gridTiledSprite.y;
- if (audioVisGroup != null && audioVisGroup.playerVis != null)
+ for (member in audioWaveforms.members)
{
- audioVisGroup.playerVis.y = Math.max(gridTiledSprite.y, GRID_INITIAL_Y_POS - GRID_TOP_PAD);
- }
- if (audioVisGroup != null && audioVisGroup.opponentVis != null)
- {
- audioVisGroup.opponentVis.y = Math.max(gridTiledSprite.y, GRID_INITIAL_Y_POS - GRID_TOP_PAD);
+ member.time = scrollPositionInMs / Constants.MS_PER_SEC;
+
+ // Doing this desyncs the waveforms from the grid.
+ // member.y = Math.max(this.gridTiledSprite?.y ?? 0.0, ChartEditorState.GRID_INITIAL_Y_POS - ChartEditorState.GRID_TOP_PAD);
}
}
}
@@ -501,8 +504,6 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
function get_playheadPositionInMs():Float
{
- if (audioVisGroup != null && audioVisGroup.playerVis != null)
- audioVisGroup.playerVis.realtimeStartOffset = -Conductor.instance.getStepTimeInMs(playheadPositionInSteps);
return Conductor.instance.getStepTimeInMs(playheadPositionInSteps);
}
@@ -510,7 +511,6 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
{
playheadPositionInSteps = Conductor.instance.getTimeInSteps(value);
- if (audioVisGroup != null && audioVisGroup.playerVis != null) audioVisGroup.playerVis.realtimeStartOffset = -value;
return value;
}
@@ -690,6 +690,11 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
*/
var activeToolboxes:Map = new Map();
+ /**
+ * The camera component we're using for this state.
+ */
+ var uiCamera:FlxCamera;
+
// Audio
/**
@@ -1101,14 +1106,14 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
* `null` until vocal track(s) are loaded.
* When switching characters, the elements of the VoicesGroup will be swapped to match the new character.
*/
- var audioVocalTrackGroup:Null = null;
+ var audioVocalTrackGroup:VoicesGroup = new VoicesGroup();
/**
- * The audio vis for the inst/vocals.
+ * The audio waveform visualization for the inst/vocals.
* `null` until vocal track(s) are loaded.
- * When switching characters, the elements of the PolygonVisGroup will be swapped to match the new character.
+ * When switching characters, the elements will be swapped to match the new character.
*/
- var audioVisGroup:Null = null;
+ var audioWaveforms:FlxTypedSpriteGroup = new FlxTypedSpriteGroup();
/**
* A map of the audio tracks for each character's vocals.
@@ -1291,6 +1296,29 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
return currentSongChartData.events = value;
}
+ /**
+ * Convenience property to get the rating for this difficulty in the Freeplay menu.
+ */
+ var currentSongChartDifficultyRating(get, set):Int;
+
+ function get_currentSongChartDifficultyRating():Int
+ {
+ var result:Null = currentSongMetadata.playData.ratings.get(selectedDifficulty);
+ if (result == null)
+ {
+ // Initialize to the default value if not set.
+ currentSongMetadata.playData.ratings.set(selectedDifficulty, 0);
+ return 0;
+ }
+ return result;
+ }
+
+ function set_currentSongChartDifficultyRating(value:Int):Int
+ {
+ currentSongMetadata.playData.ratings.set(selectedDifficulty, value);
+ return value;
+ }
+
var currentSongNoteStyle(get, set):String;
function get_currentSongNoteStyle():String
@@ -1308,6 +1336,30 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
return currentSongMetadata.playData.noteStyle = value;
}
+ var currentSongFreeplayPreviewStart(get, set):Int;
+
+ function get_currentSongFreeplayPreviewStart():Int
+ {
+ return currentSongMetadata.playData.previewStart;
+ }
+
+ function set_currentSongFreeplayPreviewStart(value:Int):Int
+ {
+ return currentSongMetadata.playData.previewStart = value;
+ }
+
+ var currentSongFreeplayPreviewEnd(get, set):Int;
+
+ function get_currentSongFreeplayPreviewEnd():Int
+ {
+ return currentSongMetadata.playData.previewEnd;
+ }
+
+ function set_currentSongFreeplayPreviewEnd(value:Int):Int
+ {
+ return currentSongMetadata.playData.previewEnd = value;
+ }
+
var currentSongStage(get, set):String;
function get_currentSongStage():String
@@ -1441,19 +1493,28 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
return value;
}
- var currentVocalOffset(get, set):Float;
+ var currentVocalOffsetPlayer(get, set):Float;
- function get_currentVocalOffset():Float
+ function get_currentVocalOffsetPlayer():Float
{
- // Currently there's only one vocal offset, so we just grab the player's offset since both should be the same.
- // Should probably make it so we can set offsets for player + opponent individually, though.
return currentSongOffsets.getVocalOffset(currentPlayerChar);
}
- function set_currentVocalOffset(value:Float):Float
+ function set_currentVocalOffsetPlayer(value:Float):Float
{
- // Currently there's only one vocal offset, so we just apply it to both characters.
currentSongOffsets.setVocalOffset(currentPlayerChar, value);
+ return value;
+ }
+
+ var currentVocalOffsetOpponent(get, set):Float;
+
+ function get_currentVocalOffsetOpponent():Float
+ {
+ return currentSongOffsets.getVocalOffset(currentOpponentChar);
+ }
+
+ function set_currentVocalOffsetOpponent(value:Float):Float
+ {
currentSongOffsets.setVocalOffset(currentOpponentChar, value);
return value;
}
@@ -1858,11 +1919,16 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
*/
var notePreviewViewportBitmap:Null = null;
- /**r
+ /**
* The IMAGE used for the measure ticks. Updated by ChartEditorThemeHandler.
*/
var measureTickBitmap:Null = null;
+ /**
+ * The IMAGE used for the offset ticks. Updated by ChartEditorThemeHandler.
+ */
+ var offsetTickBitmap:Null = null;
+
/**
* The tiled sprite used to display the grid.
* The height is the length of the song, and scrolling is done by simply the sprite.
@@ -2028,7 +2094,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
loadPreferences();
- fixCamera();
+ uiCamera = new FlxCamera();
+ FlxG.cameras.reset(uiCamera);
buildDefaultSongData();
@@ -2246,8 +2313,6 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
// Initialize the song chart data.
songChartData = new Map();
-
- audioVocalTrackGroup = new VoicesGroup();
}
/**
@@ -2334,8 +2399,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
add(healthIconBF);
healthIconBF.zIndex = 30;
- audioVisGroup = new PolygonVisGroup();
- add(audioVisGroup);
+ add(audioWaveforms);
}
function buildMeasureTicks():Void
@@ -2879,13 +2943,13 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
menubarItemVolumeVocalsPlayer.onChange = event -> {
var volume:Float = event.value.toFloat() / 100.0;
- if (audioVocalTrackGroup != null) audioVocalTrackGroup.playerVolume = volume;
+ audioVocalTrackGroup.playerVolume = volume;
menubarLabelVolumeVocalsPlayer.text = 'Player - ${Std.int(event.value)}%';
};
menubarItemVolumeVocalsOpponent.onChange = event -> {
var volume:Float = event.value.toFloat() / 100.0;
- if (audioVocalTrackGroup != null) audioVocalTrackGroup.opponentVolume = volume;
+ audioVocalTrackGroup.opponentVolume = volume;
menubarLabelVolumeVocalsOpponent.text = 'Enemy - ${Std.int(event.value)}%';
};
@@ -2894,7 +2958,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
pitch = Math.floor(pitch / 0.25) * 0.25; // Round to nearest 0.25.
#if FLX_PITCH
if (audioInstTrack != null) audioInstTrack.pitch = pitch;
- if (audioVocalTrackGroup != null) audioVocalTrackGroup.pitch = pitch;
+ audioVocalTrackGroup.pitch = pitch;
#end
var pitchDisplay:Float = Std.int(pitch * 100) / 100; // Round to 2 decimal places.
menubarLabelPlaybackSpeed.text = 'Playback Speed - ${pitchDisplay}x';
@@ -2902,8 +2966,10 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
menubarItemToggleToolboxDifficulty.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT, event.value);
menubarItemToggleToolboxMetadata.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT, event.value);
+ menubarItemToggleToolboxOffsets.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT, event.value);
menubarItemToggleToolboxNotes.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT, event.value);
menubarItemToggleToolboxEventData.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_EVENT_DATA_LAYOUT, event.value);
+ menubarItemToggleToolboxFreeplay.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_FREEPLAY_LAYOUT, event.value);
menubarItemToggleToolboxPlaytestProperties.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_PLAYTEST_PROPERTIES_LAYOUT, event.value);
menubarItemToggleToolboxPlayerPreview.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT, event.value);
menubarItemToggleToolboxOpponentPreview.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT, event.value);
@@ -3204,7 +3270,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
Conductor.instance.update(audioInstTrack.time);
handleHitsounds(oldSongPosition, Conductor.instance.songPosition + Conductor.instance.instrumentalOffset);
// Resync vocals.
- if (audioVocalTrackGroup != null && Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100)
+ if (Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100)
{
audioVocalTrackGroup.time = audioInstTrack.time;
}
@@ -3222,7 +3288,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
Conductor.instance.update(audioInstTrack.time);
handleHitsounds(oldSongPosition, Conductor.instance.songPosition + Conductor.instance.instrumentalOffset);
// Resync vocals.
- if (audioVocalTrackGroup != null && Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100)
+ if (Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100)
{
audioVocalTrackGroup.time = audioInstTrack.time;
}
@@ -5287,7 +5353,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
Paths.setCurrentLevel('weekend1');
}
- subStateClosed.add(fixCamera);
+ subStateClosed.add(reviveUICamera);
subStateClosed.add(resetConductorAfterTest);
FlxTransitionableState.skipNextTransIn = false;
@@ -5310,7 +5376,12 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
{
FlxG.sound.music = audioInstTrack;
}
- if (audioVocalTrackGroup != null) targetState.vocals = audioVocalTrackGroup;
+ targetState.vocals = audioVocalTrackGroup;
+
+ // Kill and replace the UI camera so it doesn't get destroyed during the state transition.
+ uiCamera.kill();
+ FlxG.cameras.remove(uiCamera, false);
+ FlxG.cameras.reset(new FlxCamera());
this.persistentUpdate = false;
this.persistentDraw = false;
@@ -5401,13 +5472,12 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
}
/**
- * Fix a camera issue caused when closing the PlayState used when testing.
+ * Revive the UI camera and re-establish it as the main camera so UI elements depending on it don't explode.
*/
- function fixCamera(_:FlxSubState = null):Void
+ function reviveUICamera(_:FlxSubState = null):Void
{
- FlxG.cameras.reset(new FlxCamera());
- FlxG.camera.focusOn(new FlxPoint(FlxG.width / 2, FlxG.height / 2));
- FlxG.camera.zoom = 1.0;
+ uiCamera.revive();
+ FlxG.cameras.reset(uiCamera);
add(this.root);
}
@@ -5422,7 +5492,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
if (audioInstTrack != null)
{
audioInstTrack.play(false, audioInstTrack.time);
- if (audioVocalTrackGroup != null) audioVocalTrackGroup.play(false, audioInstTrack.time);
+ audioVocalTrackGroup.play(false, audioInstTrack.time);
}
playbarPlay.text = '||'; // Pause
@@ -5660,7 +5730,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
audioInstTrack.time = scrollPositionInMs + playheadPositionInMs - Conductor.instance.instrumentalOffset;
// Update the songPosition in the Conductor.
Conductor.instance.update(audioInstTrack.time);
- if (audioVocalTrackGroup != null) audioVocalTrackGroup.time = audioInstTrack.time;
+ audioVocalTrackGroup.time = audioInstTrack.time;
}
// We need to update the note sprites because we changed the scroll position.
@@ -5881,7 +5951,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
function stopAudioPlayback():Void
{
if (audioInstTrack != null) audioInstTrack.pause();
- if (audioVocalTrackGroup != null) audioVocalTrackGroup.pause();
+ audioVocalTrackGroup.pause();
playbarPlay.text = '>';
}
@@ -5916,7 +5986,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
// Keep the track at the end.
audioInstTrack.time = audioInstTrack.length;
}
- if (audioVocalTrackGroup != null) audioVocalTrackGroup.pause();
+ audioVocalTrackGroup.pause();
};
}
else
@@ -5930,12 +6000,32 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
healthIconsDirty = true;
}
+ function hardRefreshOffsetsToolbox():Void
+ {
+ var offsetsToolbox:ChartEditorOffsetsToolbox = cast this.getToolbox(CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT);
+ if (offsetsToolbox != null)
+ {
+ offsetsToolbox.refreshAudioPreview();
+ offsetsToolbox.refresh();
+ }
+ }
+
+ function hardRefreshFreeplayToolbox():Void
+ {
+ var freeplayToolbox:ChartEditorFreeplayToolbox = cast this.getToolbox(CHART_EDITOR_TOOLBOX_FREEPLAY_LAYOUT);
+ if (freeplayToolbox != null)
+ {
+ freeplayToolbox.refreshAudioPreview();
+ freeplayToolbox.refresh();
+ }
+ }
+
/**
* Clear the voices group.
*/
public function clearVocals():Void
{
- if (audioVocalTrackGroup != null) audioVocalTrackGroup.clear();
+ audioVocalTrackGroup.clear();
}
function isNoteSelected(note:Null):Bool
@@ -5960,7 +6050,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
// Stop the music.
if (welcomeMusic != null) welcomeMusic.destroy();
if (audioInstTrack != null) audioInstTrack.destroy();
- if (audioVocalTrackGroup != null) audioVocalTrackGroup.destroy();
+ audioVocalTrackGroup.destroy();
}
function applyCanQuickSave():Void
diff --git a/source/funkin/ui/debug/charting/commands/CopyItemsCommand.hx b/source/funkin/ui/debug/charting/commands/CopyItemsCommand.hx
index 4361f867f..6c5152a29 100644
--- a/source/funkin/ui/debug/charting/commands/CopyItemsCommand.hx
+++ b/source/funkin/ui/debug/charting/commands/CopyItemsCommand.hx
@@ -46,26 +46,14 @@ class CopyItemsCommand implements ChartEditorCommand
function performVisuals(state:ChartEditorState):Void
{
+ var hasNotes:Bool = false;
+ var hasEvents:Bool = false;
+
+ // Wiggle copied notes.
if (state.currentNoteSelection.length > 0)
{
- // Display the "Copied Notes" text.
- if (state.txtCopyNotif != null)
- {
- state.txtCopyNotif.visible = true;
- state.txtCopyNotif.text = "Copied " + state.currentNoteSelection.length + " notes to clipboard";
- state.txtCopyNotif.x = FlxG.mouse.x - (state.txtCopyNotif.width / 2);
- state.txtCopyNotif.y = FlxG.mouse.y - 16;
- FlxTween.tween(state.txtCopyNotif, {y: state.txtCopyNotif.y - 32}, 0.5,
- {
- type: FlxTween.ONESHOT,
- ease: FlxEase.quadOut,
- onComplete: function(_) {
- state.txtCopyNotif.visible = false;
- }
- });
- }
+ hasNotes = true;
- // Wiggle the notes.
for (note in state.renderedNotes.members)
{
if (state.isNoteSelected(note.noteData))
@@ -91,8 +79,13 @@ class CopyItemsCommand implements ChartEditorCommand
});
}
}
+ }
+
+ // Wiggle copied events.
+ if (state.currentEventSelection.length > 0)
+ {
+ hasEvents = true;
- // Wiggle the events.
for (event in state.renderedEvents.members)
{
if (state.isEventSelected(event.eventData))
@@ -119,6 +112,39 @@ class CopyItemsCommand implements ChartEditorCommand
}
}
}
+
+ // Display the "Copied Notes" text.
+ if ((hasNotes || hasEvents) && state.txtCopyNotif != null)
+ {
+ var copiedString:String = '';
+ if (hasNotes)
+ {
+ var copiedNotes:Int = state.currentNoteSelection.length;
+ copiedString += '${copiedNotes} note';
+ if (copiedNotes > 1) copiedString += 's';
+
+ if (hasEvents) copiedString += ' and ';
+ }
+ if (hasEvents)
+ {
+ var copiedEvents:Int = state.currentEventSelection.length;
+ copiedString += '${state.currentEventSelection.length} event';
+ if (copiedEvents > 1) copiedString += 's';
+ }
+
+ state.txtCopyNotif.visible = true;
+ state.txtCopyNotif.text = 'Copied ${copiedString} to clipboard';
+ state.txtCopyNotif.x = FlxG.mouse.x - (state.txtCopyNotif.width / 2);
+ state.txtCopyNotif.y = FlxG.mouse.y - 16;
+ FlxTween.tween(state.txtCopyNotif, {y: state.txtCopyNotif.y - 32}, 0.5,
+ {
+ type: FlxTween.ONESHOT,
+ ease: FlxEase.quadOut,
+ onComplete: function(_) {
+ state.txtCopyNotif.visible = false;
+ }
+ });
+ }
}
public function undo(state:ChartEditorState):Void
diff --git a/source/funkin/ui/debug/charting/commands/SetAudioOffsetCommand.hx b/source/funkin/ui/debug/charting/commands/SetAudioOffsetCommand.hx
new file mode 100644
index 000000000..ca1fda6b9
--- /dev/null
+++ b/source/funkin/ui/debug/charting/commands/SetAudioOffsetCommand.hx
@@ -0,0 +1,113 @@
+package funkin.ui.debug.charting.commands;
+
+import funkin.data.song.SongData.SongNoteData;
+import funkin.data.song.SongData.SongEventData;
+import funkin.data.song.SongDataUtils;
+import flixel.tweens.FlxEase;
+import flixel.tweens.FlxTween;
+
+/**
+ * Command that copies a given set of notes and song events to the clipboard,
+ * without deleting them from the chart editor.
+ */
+@:nullSafety
+@:access(funkin.ui.debug.charting.ChartEditorState)
+class SetAudioOffsetCommand implements ChartEditorCommand
+{
+ var type:AudioOffsetType;
+ var oldOffset:Float = 0;
+ var newOffset:Float;
+ var refreshOffsetsToolbox:Bool;
+
+ public function new(type:AudioOffsetType, newOffset:Float, refreshOffsetsToolbox:Bool = true)
+ {
+ this.type = type;
+ this.newOffset = newOffset;
+ this.refreshOffsetsToolbox = refreshOffsetsToolbox;
+ }
+
+ public function execute(state:ChartEditorState):Void
+ {
+ switch (type)
+ {
+ case INSTRUMENTAL:
+ oldOffset = state.currentInstrumentalOffset;
+ state.currentInstrumentalOffset = newOffset;
+
+ // Update rendering.
+ Conductor.instance.instrumentalOffset = state.currentInstrumentalOffset;
+ state.songLengthInMs = (state.audioInstTrack?.length ?? 1000.0) + Conductor.instance.instrumentalOffset;
+ case PLAYER:
+ oldOffset = state.currentVocalOffsetPlayer;
+ state.currentVocalOffsetPlayer = newOffset;
+
+ // Update rendering.
+ state.audioVocalTrackGroup.playerVoicesOffset = state.currentVocalOffsetPlayer;
+ case OPPONENT:
+ oldOffset = state.currentVocalOffsetOpponent;
+ state.currentVocalOffsetOpponent = newOffset;
+
+ // Update rendering.
+ state.audioVocalTrackGroup.opponentVoicesOffset = state.currentVocalOffsetOpponent;
+ }
+
+ // Update the offsets toolbox.
+ if (refreshOffsetsToolbox)
+ {
+ state.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT);
+ state.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_FREEPLAY_LAYOUT);
+ }
+ }
+
+ public function undo(state:ChartEditorState):Void
+ {
+ switch (type)
+ {
+ case INSTRUMENTAL:
+ state.currentInstrumentalOffset = oldOffset;
+
+ // Update rendering.
+ Conductor.instance.instrumentalOffset = state.currentInstrumentalOffset;
+ state.songLengthInMs = (state.audioInstTrack?.length ?? 1000.0) + Conductor.instance.instrumentalOffset;
+ case PLAYER:
+ state.currentVocalOffsetPlayer = oldOffset;
+
+ // Update rendering.
+ state.audioVocalTrackGroup.playerVoicesOffset = state.currentVocalOffsetPlayer;
+ case OPPONENT:
+ state.currentVocalOffsetOpponent = oldOffset;
+
+ // Update rendering.
+ state.audioVocalTrackGroup.opponentVoicesOffset = state.currentVocalOffsetOpponent;
+ }
+
+ // Update the offsets toolbox.
+ state.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT);
+ }
+
+ public function shouldAddToHistory(state:ChartEditorState):Bool
+ {
+ // This command is undoable. Add to the history if we actually performed an action.
+ return (newOffset != oldOffset);
+ }
+
+ public function toString():String
+ {
+ switch (type)
+ {
+ case INSTRUMENTAL:
+ return 'Set Inst. Audio Offset to $newOffset';
+ case PLAYER:
+ return 'Set Player Audio Offset to $newOffset';
+ case OPPONENT:
+ return 'Set Opponent Audio Offset to $newOffset';
+ }
+ }
+}
+
+enum AudioOffsetType
+{
+ INSTRUMENTAL;
+ PLAYER;
+ OPPONENT;
+}
diff --git a/source/funkin/ui/debug/charting/commands/SetFreeplayPreviewCommand.hx b/source/funkin/ui/debug/charting/commands/SetFreeplayPreviewCommand.hx
new file mode 100644
index 000000000..232768904
--- /dev/null
+++ b/source/funkin/ui/debug/charting/commands/SetFreeplayPreviewCommand.hx
@@ -0,0 +1,62 @@
+package funkin.ui.debug.charting.commands;
+
+import funkin.data.song.SongData.SongNoteData;
+import funkin.data.song.SongDataUtils;
+
+/**
+ * Command that sets the start time or end time of the Freeplay preview.
+ */
+@:nullSafety
+@:access(funkin.ui.debug.charting.ChartEditorState)
+class SetFreeplayPreviewCommand implements ChartEditorCommand
+{
+ var previousStartTime:Int = 0;
+ var previousEndTime:Int = 0;
+ var newStartTime:Null = null;
+ var newEndTime:Null = null;
+
+ public function new(newStartTime:Null, newEndTime:Null)
+ {
+ this.newStartTime = newStartTime;
+ this.newEndTime = newEndTime;
+ }
+
+ public function execute(state:ChartEditorState):Void
+ {
+ this.previousStartTime = state.currentSongFreeplayPreviewStart;
+ this.previousEndTime = state.currentSongFreeplayPreviewEnd;
+
+ if (newStartTime != null) state.currentSongFreeplayPreviewStart = newStartTime;
+ if (newEndTime != null) state.currentSongFreeplayPreviewEnd = newEndTime;
+ }
+
+ public function undo(state:ChartEditorState):Void
+ {
+ state.currentSongFreeplayPreviewStart = previousStartTime;
+ state.currentSongFreeplayPreviewEnd = previousEndTime;
+ }
+
+ public function shouldAddToHistory(state:ChartEditorState):Bool
+ {
+ return (newStartTime != null && newStartTime != previousStartTime) || (newEndTime != null && newEndTime != previousEndTime);
+ }
+
+ public function toString():String
+ {
+ var setStart = newStartTime != null && newStartTime != previousStartTime;
+ var setEnd = newEndTime != null && newEndTime != previousEndTime;
+
+ if (setStart && !setEnd)
+ {
+ return "Set Freeplay Preview Start Time";
+ }
+ else if (setEnd && !setStart)
+ {
+ return "Set Freeplay Preview End Time";
+ }
+ else
+ {
+ return "Set Freeplay Preview Start and End Times";
+ }
+ }
+}
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx
index e1fcd1cb0..76b2a388e 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx
@@ -3,11 +3,14 @@ package funkin.ui.debug.charting.handlers;
import flixel.system.FlxAssets.FlxSoundAsset;
import flixel.system.FlxSound;
import funkin.audio.VoicesGroup;
-import funkin.audio.visualize.PolygonVisGroup;
import funkin.audio.FunkinSound;
import funkin.play.character.BaseCharacter.CharacterType;
import funkin.util.FileUtil;
import funkin.util.assets.SoundUtil;
+import funkin.audio.waveform.WaveformData;
+import funkin.audio.waveform.WaveformDataParser;
+import funkin.audio.waveform.WaveformSprite;
+import flixel.util.FlxColor;
import haxe.io.Bytes;
import haxe.io.Path;
import openfl.utils.Assets;
@@ -125,15 +128,42 @@ class ChartEditorAudioHandler
public static function switchToInstrumental(state:ChartEditorState, instId:String = '', playerId:String, opponentId:String):Bool
{
+ var perfA = haxe.Timer.stamp();
+
var result:Bool = playInstrumental(state, instId);
if (!result) return false;
+ var perfB = haxe.Timer.stamp();
+
stopExistingVocals(state);
+
+ var perfC = haxe.Timer.stamp();
+
result = playVocals(state, BF, playerId, instId);
+
+ var perfD = haxe.Timer.stamp();
+
// if (!result) return false;
result = playVocals(state, DAD, opponentId, instId);
// if (!result) return false;
+ var perfE = haxe.Timer.stamp();
+
+ state.hardRefreshOffsetsToolbox();
+
+ var perfF = haxe.Timer.stamp();
+
+ state.hardRefreshFreeplayToolbox();
+
+ var perfG = haxe.Timer.stamp();
+
+ trace('Switched to instrumental in ${perfB - perfA} seconds.');
+ trace('Stopped existing vocals in ${perfC - perfB} seconds.');
+ trace('Played BF vocals in ${perfD - perfC} seconds.');
+ trace('Played DAD vocals in ${perfE - perfD} seconds.');
+ trace('Hard refreshed offsets toolbox in ${perfF - perfE} seconds.');
+ trace('Hard refreshed freeplay toolbox in ${perfG - perfF} seconds.');
+
return true;
}
@@ -144,7 +174,10 @@ class ChartEditorAudioHandler
{
if (instId == '') instId = 'default';
var instTrackData:Null = state.audioInstTrackData.get(instId);
+ var perfA = haxe.Timer.stamp();
var instTrack:Null = SoundUtil.buildSoundFromBytes(instTrackData);
+ var perfB = haxe.Timer.stamp();
+ trace('Built instrumental track in ${perfB - perfA} seconds.');
if (instTrack == null) return false;
stopExistingInstrumental(state);
@@ -172,10 +205,12 @@ class ChartEditorAudioHandler
{
var trackId:String = '${charId}${instId == '' ? '' : '-${instId}'}';
var vocalTrackData:Null = state.audioVocalTrackData.get(trackId);
+ var perfStart = haxe.Timer.stamp();
var vocalTrack:Null = SoundUtil.buildSoundFromBytes(vocalTrackData);
+ var perfEnd = haxe.Timer.stamp();
+ trace('Built vocal track in ${perfEnd - perfStart} seconds.');
if (state.audioVocalTrackGroup == null) state.audioVocalTrackGroup = new VoicesGroup();
- if (state.audioVisGroup == null) state.audioVisGroup = new PolygonVisGroup();
if (vocalTrack != null)
{
@@ -183,26 +218,57 @@ class ChartEditorAudioHandler
{
case BF:
state.audioVocalTrackGroup.addPlayerVoice(vocalTrack);
- state.audioVisGroup.addPlayerVis(vocalTrack);
- state.audioVisGroup.playerVis.x = 885;
- state.audioVisGroup.playerVis.realtimeVisLenght = Conductor.instance.getStepTimeInMs(16) * 0.00195;
- state.audioVisGroup.playerVis.daHeight = (ChartEditorState.GRID_SIZE) * 16;
- state.audioVisGroup.playerVis.detail = 1;
- state.audioVisGroup.playerVis.y = Math.max(state.gridTiledSprite?.y ?? 0.0, ChartEditorState.GRID_INITIAL_Y_POS - ChartEditorState.GRID_TOP_PAD);
- state.audioVocalTrackGroup.playerVoicesOffset = state.currentVocalOffset;
+ var perfStart = haxe.Timer.stamp();
+ var waveformData:Null = vocalTrack.waveformData;
+ var perfEnd = haxe.Timer.stamp();
+ trace('Interpreted waveform data in ${perfEnd - perfStart} seconds.');
+
+ if (waveformData != null)
+ {
+ var duration:Float = Conductor.instance.getStepTimeInMs(16) * 0.001;
+ var waveformSprite:WaveformSprite = new WaveformSprite(waveformData, VERTICAL, FlxColor.WHITE);
+ waveformSprite.x = 840;
+ waveformSprite.y = Math.max(state.gridTiledSprite?.y ?? 0.0, ChartEditorState.GRID_INITIAL_Y_POS - ChartEditorState.GRID_TOP_PAD);
+ waveformSprite.height = (ChartEditorState.GRID_SIZE) * 16;
+ waveformSprite.width = (ChartEditorState.GRID_SIZE) * 2;
+ waveformSprite.time = 0;
+ waveformSprite.duration = duration;
+ state.audioWaveforms.add(waveformSprite);
+ }
+ else
+ {
+ trace('[WARN] Failed to parse waveform data for vocal track.');
+ }
+
+ state.audioVocalTrackGroup.playerVoicesOffset = state.currentVocalOffsetPlayer;
return true;
case DAD:
state.audioVocalTrackGroup.addOpponentVoice(vocalTrack);
- state.audioVisGroup.addOpponentVis(vocalTrack);
- state.audioVisGroup.opponentVis.x = 405;
- state.audioVisGroup.opponentVis.realtimeVisLenght = Conductor.instance.getStepTimeInMs(16) * 0.00195;
- state.audioVisGroup.opponentVis.daHeight = (ChartEditorState.GRID_SIZE) * 16;
- state.audioVisGroup.opponentVis.detail = 1;
- state.audioVisGroup.opponentVis.y = Math.max(state.gridTiledSprite?.y ?? 0.0, ChartEditorState.GRID_INITIAL_Y_POS - ChartEditorState.GRID_TOP_PAD);
+ var perfStart = haxe.Timer.stamp();
+ var waveformData:Null = vocalTrack.waveformData;
+ var perfEnd = haxe.Timer.stamp();
+ trace('Interpreted waveform data in ${perfEnd - perfStart} seconds.');
- state.audioVocalTrackGroup.opponentVoicesOffset = state.currentVocalOffset;
+ if (waveformData != null)
+ {
+ var duration:Float = Conductor.instance.getStepTimeInMs(16) * 0.001;
+ var waveformSprite:WaveformSprite = new WaveformSprite(waveformData, VERTICAL, FlxColor.WHITE);
+ waveformSprite.x = 360;
+ waveformSprite.y = Math.max(state.gridTiledSprite?.y ?? 0.0, ChartEditorState.GRID_INITIAL_Y_POS - ChartEditorState.GRID_TOP_PAD);
+ waveformSprite.height = (ChartEditorState.GRID_SIZE) * 16;
+ waveformSprite.width = (ChartEditorState.GRID_SIZE) * 2;
+ waveformSprite.time = 0;
+ waveformSprite.duration = duration;
+ state.audioWaveforms.add(waveformSprite);
+ }
+ else
+ {
+ trace('[WARN] Failed to parse waveform data for vocal track.');
+ }
+
+ state.audioVocalTrackGroup.opponentVoicesOffset = state.currentVocalOffsetOpponent;
return true;
case OTHER:
@@ -219,13 +285,10 @@ class ChartEditorAudioHandler
public static function stopExistingVocals(state:ChartEditorState):Void
{
- if (state.audioVocalTrackGroup != null)
+ state.audioVocalTrackGroup.clear();
+ if (state.audioWaveforms != null)
{
- state.audioVocalTrackGroup.clear();
- }
- if (state.audioVisGroup != null)
- {
- state.audioVisGroup.clearAllVis();
+ state.audioWaveforms.clear();
}
}
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx
index 9c86269e8..0318bf296 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx
@@ -28,6 +28,8 @@ class ChartEditorImportExportHandler
*/
public static function loadSongAsTemplate(state:ChartEditorState, songId:String):Void
{
+ trace('===============START');
+
var song:Null = SongRegistry.instance.fetchEntry(songId);
if (song == null) return;
@@ -98,11 +100,14 @@ class ChartEditorImportExportHandler
state.isHaxeUIDialogOpen = false;
state.currentWorkingFilePath = null; // New file, so no path.
state.switchToCurrentInstrumental();
+
state.postLoadInstrumental();
state.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT);
state.success('Success', 'Loaded song (${rawSongMetadata[0].songName})');
+
+ trace('===============END');
}
/**
@@ -132,11 +137,8 @@ class ChartEditorImportExportHandler
state.audioInstTrack.stop();
state.audioInstTrack = null;
}
- if (state.audioVocalTrackGroup != null)
- {
- state.audioVocalTrackGroup.stop();
- state.audioVocalTrackGroup.clear();
- }
+ state.audioVocalTrackGroup.stop();
+ state.audioVocalTrackGroup.clear();
}
/**
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorThemeHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorThemeHandler.hx
index 89fd4d5d3..b1af0ce4c 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorThemeHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorThemeHandler.hx
@@ -82,6 +82,7 @@ class ChartEditorThemeHandler
updateBackground(state);
updateGridBitmap(state);
updateMeasureTicks(state);
+ updateOffsetTicks(state);
updateSelectionSquare(state);
updateNotePreview(state);
}
@@ -231,6 +232,9 @@ class ChartEditorThemeHandler
// Else, gridTiledSprite will be built later.
}
+ /**
+ * Vertical measure ticks.
+ */
static function updateMeasureTicks(state:ChartEditorState):Void
{
var measureTickWidth:Int = 6;
@@ -286,6 +290,59 @@ class ChartEditorThemeHandler
state.measureTickBitmap.fillRect(new Rectangle(0, stepTick16Y, stepTickLength, stepTickWidth), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
}
+ /**
+ * Horizontal offset ticks.
+ */
+ static function updateOffsetTicks(state:ChartEditorState):Void
+ {
+ var majorTickWidth:Int = 6;
+ var minorTickWidth:Int = 3;
+
+ var ticksWidth:Int = Std.int(ChartEditorState.GRID_SIZE * Conductor.instance.stepsPerMeasure); // 10 minor ticks wide.
+ var ticksHeight:Int = Std.int(ChartEditorState.GRID_SIZE); // 1 grid squares tall.
+ state.offsetTickBitmap = new BitmapData(ticksWidth, ticksHeight, true);
+ state.offsetTickBitmap.fillRect(new Rectangle(0, 0, ticksWidth, ticksHeight), GRID_BEAT_DIVIDER_COLOR_DARK);
+
+ // Draw the major ticks.
+ var leftTickX:Float = 0;
+ var middleTickX:Float = state.offsetTickBitmap.width / 2 - (majorTickWidth / 2);
+ var rightTickX:Float = state.offsetTickBitmap.width - (majorTickWidth / 2);
+ var majorTickLength:Float = state.offsetTickBitmap.height;
+ state.offsetTickBitmap.fillRect(new Rectangle(leftTickX, 0, majorTickWidth / 2, majorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+ state.offsetTickBitmap.fillRect(new Rectangle(middleTickX, 0, majorTickWidth, majorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+ state.offsetTickBitmap.fillRect(new Rectangle(rightTickX, 0, majorTickWidth / 2, majorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+
+ // Draw the minor ticks.
+ var minorTick2X:Float = state.offsetTickBitmap.width * 1 / 10 - (minorTickWidth / 2);
+ var minorTick3X:Float = state.offsetTickBitmap.width * 2 / 10 - (minorTickWidth / 2);
+ var minorTick4X:Float = state.offsetTickBitmap.width * 3 / 10 - (minorTickWidth / 2);
+ var minorTick5X:Float = state.offsetTickBitmap.width * 4 / 10 - (minorTickWidth / 2);
+ var minorTick7X:Float = state.offsetTickBitmap.width * 6 / 10 - (minorTickWidth / 2);
+ var minorTick8X:Float = state.offsetTickBitmap.width * 7 / 10 - (minorTickWidth / 2);
+ var minorTick9X:Float = state.offsetTickBitmap.width * 8 / 10 - (minorTickWidth / 2);
+ var minorTick10X:Float = state.offsetTickBitmap.width * 9 / 10 - (minorTickWidth / 2);
+ var minorTickLength:Float = state.offsetTickBitmap.height * 1 / 3;
+ state.offsetTickBitmap.fillRect(new Rectangle(minorTick2X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+ state.offsetTickBitmap.fillRect(new Rectangle(minorTick3X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+ state.offsetTickBitmap.fillRect(new Rectangle(minorTick4X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+ state.offsetTickBitmap.fillRect(new Rectangle(minorTick5X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+ state.offsetTickBitmap.fillRect(new Rectangle(minorTick7X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+ state.offsetTickBitmap.fillRect(new Rectangle(minorTick8X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+ state.offsetTickBitmap.fillRect(new Rectangle(minorTick9X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+ state.offsetTickBitmap.fillRect(new Rectangle(minorTick10X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+
+ // Draw the offset ticks.
+ // var ticksWidth:Int = Std.int(ChartEditorState.GRID_SIZE * TOTAL_COLUMN_COUNT); // 1 grid squares wide.
+ // var ticksHeight:Int = Std.int(ChartEditorState.GRID_SIZE); // 1 measure tall.
+ // state.offsetTickBitmap = new BitmapData(ticksWidth, ticksHeight, true);
+ // state.offsetTickBitmap.fillRect(new Rectangle(0, 0, ticksWidth, ticksHeight), GRID_BEAT_DIVIDER_COLOR_DARK);
+ //
+ //// Draw the offset ticks.
+ // state.offsetTickBitmap.fillRect(new Rectangle(0, 0, offsetTickWidth / 2, state.offsetTickBitmap.height), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+ // var rightTickX:Float = state.offsetTickBitmap.width - (offsetTickWidth / 2);
+ // state.offsetTickBitmap.fillRect(new Rectangle(rightTickX, 0, offsetTickWidth / 2, state.offsetTickBitmap.height), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
+ }
+
static function updateSelectionSquare(state:ChartEditorState):Void
{
var selectionSquareBorderColor:FlxColor = switch (state.currentTheme)
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx
index f97b26933..9e22ba833 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx
@@ -35,6 +35,8 @@ import haxe.ui.containers.dialogs.Dialog.DialogButton;
import haxe.ui.containers.dialogs.Dialog.DialogEvent;
import funkin.ui.debug.charting.toolboxes.ChartEditorBaseToolbox;
import funkin.ui.debug.charting.toolboxes.ChartEditorMetadataToolbox;
+import funkin.ui.debug.charting.toolboxes.ChartEditorOffsetsToolbox;
+import funkin.ui.debug.charting.toolboxes.ChartEditorFreeplayToolbox;
import funkin.ui.debug.charting.toolboxes.ChartEditorEventDataToolbox;
import funkin.ui.debug.charting.toolboxes.ChartEditorDifficultyToolbox;
import haxe.ui.containers.Frame;
@@ -89,6 +91,10 @@ class ChartEditorToolboxHandler
case ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT:
// TODO: Fix this.
cast(toolbox, ChartEditorBaseToolbox).refresh();
+ case ChartEditorState.CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT:
+ cast(toolbox, ChartEditorBaseToolbox).refresh();
+ case ChartEditorState.CHART_EDITOR_TOOLBOX_FREEPLAY_LAYOUT:
+ cast(toolbox, ChartEditorBaseToolbox).refresh();
case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT:
onShowToolboxPlayerPreview(state, toolbox);
case ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT:
@@ -124,8 +130,6 @@ class ChartEditorToolboxHandler
onHideToolboxEventData(state, toolbox);
case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYTEST_PROPERTIES_LAYOUT:
onHideToolboxPlaytestProperties(state, toolbox);
- case ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT:
- onHideToolboxMetadata(state, toolbox);
case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT:
onHideToolboxPlayerPreview(state, toolbox);
case ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT:
@@ -202,6 +206,10 @@ class ChartEditorToolboxHandler
toolbox = buildToolboxDifficultyLayout(state);
case ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT:
toolbox = buildToolboxMetadataLayout(state);
+ case ChartEditorState.CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT:
+ toolbox = buildToolboxOffsetsLayout(state);
+ case ChartEditorState.CHART_EDITOR_TOOLBOX_FREEPLAY_LAYOUT:
+ toolbox = buildToolboxFreeplayLayout(state);
case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT:
toolbox = buildToolboxPlayerPreviewLayout(state);
case ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT:
@@ -304,8 +312,6 @@ class ChartEditorToolboxHandler
static function onHideToolboxNoteData(state:ChartEditorState, toolbox:CollapsibleDialog):Void {}
- static function onHideToolboxMetadata(state:ChartEditorState, toolbox:CollapsibleDialog):Void {}
-
static function onHideToolboxEventData(state:ChartEditorState, toolbox:CollapsibleDialog):Void {}
static function onShowToolboxPlaytestProperties(state:ChartEditorState, toolbox:CollapsibleDialog):Void {}
@@ -373,6 +379,24 @@ class ChartEditorToolboxHandler
return toolbox;
}
+ static function buildToolboxOffsetsLayout(state:ChartEditorState):Null
+ {
+ var toolbox:ChartEditorBaseToolbox = ChartEditorOffsetsToolbox.build(state);
+
+ if (toolbox == null) return null;
+
+ return toolbox;
+ }
+
+ static function buildToolboxFreeplayLayout(state:ChartEditorState):Null
+ {
+ var toolbox:ChartEditorBaseToolbox = ChartEditorFreeplayToolbox.build(state);
+
+ if (toolbox == null) return null;
+
+ return toolbox;
+ }
+
static function buildToolboxEventDataLayout(state:ChartEditorState):Null
{
var toolbox:ChartEditorBaseToolbox = ChartEditorEventDataToolbox.build(state);
diff --git a/source/funkin/ui/debug/charting/toolboxes/ChartEditorEventDataToolbox.hx b/source/funkin/ui/debug/charting/toolboxes/ChartEditorEventDataToolbox.hx
index fbd1562b4..7b163ad3d 100644
--- a/source/funkin/ui/debug/charting/toolboxes/ChartEditorEventDataToolbox.hx
+++ b/source/funkin/ui/debug/charting/toolboxes/ChartEditorEventDataToolbox.hx
@@ -18,6 +18,7 @@ import haxe.ui.core.Component;
import funkin.data.event.SongEventRegistry;
import haxe.ui.components.TextField;
import haxe.ui.containers.Box;
+import haxe.ui.containers.HBox;
import haxe.ui.containers.Frame;
import haxe.ui.events.UIEvent;
import haxe.ui.data.ArrayDataSource;
@@ -214,7 +215,20 @@ class ChartEditorEventDataToolbox extends ChartEditorBaseToolbox
input.text = field.type;
}
- target.addComponent(input);
+ // Putting in a box so we can add a unit label easily if there is one.
+ var inputBox:HBox = new HBox();
+ inputBox.addComponent(input);
+
+ // Add a unit label if applicable.
+ if (field.units != null && field.units != "")
+ {
+ var units:Label = new Label();
+ units.text = field.units;
+ units.verticalAlign = "center";
+ inputBox.addComponent(units);
+ }
+
+ target.addComponent(inputBox);
// Update the value of the event data.
input.onChange = function(event:UIEvent) {
diff --git a/source/funkin/ui/debug/charting/toolboxes/ChartEditorFreeplayToolbox.hx b/source/funkin/ui/debug/charting/toolboxes/ChartEditorFreeplayToolbox.hx
new file mode 100644
index 000000000..8d3554a08
--- /dev/null
+++ b/source/funkin/ui/debug/charting/toolboxes/ChartEditorFreeplayToolbox.hx
@@ -0,0 +1,693 @@
+package funkin.ui.debug.charting.toolboxes;
+
+import funkin.audio.SoundGroup;
+import haxe.ui.components.Button;
+import haxe.ui.components.HorizontalSlider;
+import haxe.ui.components.Label;
+import flixel.addons.display.FlxTiledSprite;
+import flixel.math.FlxMath;
+import haxe.ui.components.NumberStepper;
+import haxe.ui.components.Slider;
+import haxe.ui.backend.flixel.components.SpriteWrapper;
+import funkin.ui.debug.charting.commands.SetFreeplayPreviewCommand;
+import funkin.ui.haxeui.components.WaveformPlayer;
+import funkin.audio.waveform.WaveformDataParser;
+import haxe.ui.containers.VBox;
+import haxe.ui.containers.Absolute;
+import haxe.ui.containers.ScrollView;
+import funkin.ui.freeplay.FreeplayState;
+import haxe.ui.containers.Frame;
+import haxe.ui.core.Screen;
+import haxe.ui.events.DragEvent;
+import haxe.ui.events.MouseEvent;
+import haxe.ui.events.UIEvent;
+
+/**
+ * The toolbox which allows modifying information like Song Title, Scroll Speed, Characters/Stages, and starting BPM.
+ */
+// @:nullSafety // TODO: Fix null safety when used with HaxeUI build macros.
+@:access(funkin.ui.debug.charting.ChartEditorState)
+@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/toolboxes/freeplay.xml"))
+class ChartEditorFreeplayToolbox extends ChartEditorBaseToolbox
+{
+ var waveformContainer:Absolute;
+ var waveformScrollview:ScrollView;
+ var waveformMusic:WaveformPlayer;
+ var freeplayButtonZoomIn:Button;
+ var freeplayButtonZoomOut:Button;
+ var freeplayButtonPause:Button;
+ var freeplayButtonPlay:Button;
+ var freeplayButtonStop:Button;
+ var freeplayPreviewStart:NumberStepper;
+ var freeplayPreviewEnd:NumberStepper;
+ var freeplayTicksContainer:Absolute;
+ var playheadSprite:SpriteWrapper;
+ var previewSelectionSprite:SpriteWrapper;
+
+ static final TICK_LABEL_X_OFFSET:Float = 4.0;
+
+ static final PLAYHEAD_RIGHT_PAD:Float = 10.0;
+
+ static final BASE_SCALE:Float = 64.0;
+ static final STARTING_SCALE:Float = 1024.0;
+ static final MIN_SCALE:Float = 4.0;
+ static final WAVEFORM_ZOOM_MULT:Float = 1.5;
+
+ static final MAGIC_SCALE_BASE_TIME:Float = 5.0;
+
+ var waveformScale:Float = STARTING_SCALE;
+
+ var playheadAbsolutePos(get, set):Float;
+
+ function get_playheadAbsolutePos():Float
+ {
+ return playheadSprite.left;
+ }
+
+ function set_playheadAbsolutePos(value:Float):Float
+ {
+ return playheadSprite.left = value;
+ }
+
+ var playheadRelativePos(get, set):Float;
+
+ function get_playheadRelativePos():Float
+ {
+ return playheadSprite.left - waveformScrollview.hscrollPos;
+ }
+
+ function set_playheadRelativePos(value:Float):Float
+ {
+ return playheadSprite.left = waveformScrollview.hscrollPos + value;
+ }
+
+ var previewBoxStartPosAbsolute(get, set):Float;
+
+ function get_previewBoxStartPosAbsolute():Float
+ {
+ return previewSelectionSprite.left;
+ }
+
+ function set_previewBoxStartPosAbsolute(value:Float):Float
+ {
+ return previewSelectionSprite.left = value;
+ }
+
+ var previewBoxEndPosAbsolute(get, set):Float;
+
+ function get_previewBoxEndPosAbsolute():Float
+ {
+ return previewSelectionSprite.left + previewSelectionSprite.width;
+ }
+
+ function set_previewBoxEndPosAbsolute(value:Float):Float
+ {
+ if (value < previewBoxStartPosAbsolute) return previewSelectionSprite.left = previewBoxStartPosAbsolute;
+ return previewSelectionSprite.width = value - previewBoxStartPosAbsolute;
+ }
+
+ var previewBoxStartPosRelative(get, set):Float;
+
+ function get_previewBoxStartPosRelative():Float
+ {
+ return previewSelectionSprite.left - waveformScrollview.hscrollPos;
+ }
+
+ function set_previewBoxStartPosRelative(value:Float):Float
+ {
+ return previewSelectionSprite.left = waveformScrollview.hscrollPos + value;
+ }
+
+ var previewBoxEndPosRelative(get, set):Float;
+
+ function get_previewBoxEndPosRelative():Float
+ {
+ return previewSelectionSprite.left + previewSelectionSprite.width - waveformScrollview.hscrollPos;
+ }
+
+ function set_previewBoxEndPosRelative(value:Float):Float
+ {
+ if (value < previewBoxStartPosRelative) return previewSelectionSprite.left = previewBoxStartPosRelative;
+ return previewSelectionSprite.width = value - previewBoxStartPosRelative;
+ }
+
+ /**
+ * The amount you need to multiply the zoom by such that, at the base zoom level, one tick is equal to `MAGIC_SCALE_BASE_TIME` seconds.
+ */
+ var waveformMagicFactor:Float = 1.0;
+
+ var audioPreviewTracks:SoundGroup;
+
+ var tickTiledSprite:FlxTiledSprite;
+
+ var freeplayPreviewVolume(get, null):Float;
+
+ function get_freeplayPreviewVolume():Float
+ {
+ return freeplayMusicVolume.value * 2 / 100;
+ }
+
+ var tickLabels:Array