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