diff --git a/source/funkin/data/song/SongData.hx b/source/funkin/data/song/SongData.hx index 7886ada4f..fe4e66032 100644 --- a/source/funkin/data/song/SongData.hx +++ b/source/funkin/data/song/SongData.hx @@ -2,6 +2,7 @@ package funkin.data.song; import funkin.data.song.SongRegistry; import thx.semver.Version; +import funkin.util.tools.ICloneable; /** * Data containing information about a song. @@ -9,7 +10,7 @@ import thx.semver.Version; * Data which is only necessary in-game should be stored in the SongChartData. */ @:nullSafety -class SongMetadata +class SongMetadata implements ICloneable { /** * A semantic versioning string for the song data format. @@ -84,16 +85,16 @@ class SongMetadata * @param newVariation Set to a new variation ID to change the new metadata. * @return The cloned SongMetadata */ - public function clone(?newVariation:String = null):SongMetadata + public function clone():SongMetadata { - var result:SongMetadata = new SongMetadata(this.songName, this.artist, newVariation == null ? this.variation : newVariation); + var result:SongMetadata = new SongMetadata(this.songName, this.artist, this.variation); result.version = this.version; result.timeFormat = this.timeFormat; result.divisions = this.divisions; - result.offsets = this.offsets; - result.timeChanges = this.timeChanges; + result.offsets = this.offsets.clone(); + result.timeChanges = this.timeChanges.deepClone(); result.looped = this.looped; - result.playData = this.playData; + result.playData = this.playData.clone(); result.generatedBy = this.generatedBy; return result; @@ -128,7 +129,7 @@ enum abstract SongTimeFormat(String) from String to String var MILLISECONDS = 'ms'; } -class SongTimeChange +class SongTimeChange implements ICloneable { public static final DEFAULT_SONGTIMECHANGE:SongTimeChange = new SongTimeChange(0, 100); @@ -195,6 +196,11 @@ class SongTimeChange this.beatTuplets = beatTuplets == null ? DEFAULT_BEAT_TUPLETS : beatTuplets; } + public function clone():SongTimeChange + { + return new SongTimeChange(this.timeStamp, this.bpm, this.timeSignatureNum, this.timeSignatureDen, this.beatTime, this.beatTuplets); + } + /** * Produces a string representation suitable for debugging. */ @@ -209,7 +215,7 @@ class SongTimeChange * These are intended to correct for issues with the chart, or with the song's audio (for example a 10ms delay before the song starts). * This is independent of the offsets applied in the user's settings, which are applied after these offsets and intended to correct for the user's hardware. */ -class SongOffsets +class SongOffsets implements ICloneable { /** * The offset, in milliseconds, to apply to the song's instrumental relative to the chart. @@ -279,6 +285,15 @@ class SongOffsets return value; } + public function clone():SongOffsets + { + var result:SongOffsets = new SongOffsets(this.instrumental); + result.altInstrumentals = this.altInstrumentals.clone(); + result.vocals = this.vocals.clone(); + + return result; + } + /** * Produces a string representation suitable for debugging. */ @@ -292,7 +307,7 @@ class SongOffsets * Metadata for a song only used for the music. * For example, the menu music. */ -class SongMusicData +class SongMusicData implements ICloneable { /** * A semantic versioning string for the song data format. @@ -346,13 +361,13 @@ class SongMusicData this.variation = variation == null ? Constants.DEFAULT_VARIATION : variation; } - public function clone(?newVariation:String = null):SongMusicData + public function clone():SongMusicData { - var result:SongMusicData = new SongMusicData(this.songName, this.artist, newVariation == null ? this.variation : newVariation); + var result:SongMusicData = new SongMusicData(this.songName, this.artist, this.variation); result.version = this.version; result.timeFormat = this.timeFormat; result.divisions = this.divisions; - result.timeChanges = this.timeChanges; + result.timeChanges = this.timeChanges.clone(); result.looped = this.looped; result.generatedBy = this.generatedBy; @@ -368,7 +383,7 @@ class SongMusicData } } -class SongPlayData +class SongPlayData implements ICloneable { /** * The variations this song has. The associated metadata files should exist. @@ -417,6 +432,20 @@ class SongPlayData ratings = new Map(); } + public function clone():SongPlayData + { + var result:SongPlayData = new SongPlayData(); + result.songVariations = this.songVariations.clone(); + result.difficulties = this.difficulties.clone(); + result.characters = this.characters.clone(); + result.stage = this.stage; + result.noteStyle = this.noteStyle; + result.ratings = this.ratings.clone(); + result.album = this.album; + + return result; + } + /** * Produces a string representation suitable for debugging. */ @@ -430,7 +459,7 @@ class SongPlayData * Information about the characters used in this variation of the song. * Create a new variation if you want to change the characters. */ -class SongCharacterData +class SongCharacterData implements ICloneable { @:optional @:default('') @@ -460,6 +489,14 @@ class SongCharacterData this.instrumental = instrumental; } + public function clone():SongCharacterData + { + var result:SongCharacterData = new SongCharacterData(this.player, this.girlfriend, this.opponent, this.instrumental); + result.altInstrumentals = this.altInstrumentals.clone(); + + return result; + } + /** * Produces a string representation suitable for debugging. */ @@ -469,7 +506,7 @@ class SongCharacterData } } -class SongChartData +class SongChartData implements ICloneable { @:default(funkin.data.song.SongRegistry.SONG_CHART_DATA_VERSION) @:jcustomparse(funkin.data.DataParse.semverVersion) @@ -539,6 +576,24 @@ class SongChartData return writer.write(this, pretty ? ' ' : null); } + public function clone():SongChartData + { + // We have to manually perform the deep clone here because Map.deepClone() doesn't work. + var noteDataClone:Map> = new Map>(); + for (key in this.notes.keys()) + { + noteDataClone.set(key, this.notes.get(key).deepClone()); + } + var eventDataClone:Array = this.events.deepClone(); + + var result:SongChartData = new SongChartData(this.scrollSpeed.clone(), eventDataClone, noteDataClone); + result.version = this.version; + result.generatedBy = this.generatedBy; + result.variation = this.variation; + + return result; + } + /** * Produces a string representation suitable for debugging. */ @@ -548,7 +603,7 @@ class SongChartData } } -class SongEventDataRaw +class SongEventDataRaw implements ICloneable { /** * The timestamp of the event. The timestamp is in the format of the song's time format. @@ -604,12 +659,17 @@ class SongEventDataRaw return _stepTime = Conductor.getTimeInSteps(this.time); } + + public function clone():SongEventDataRaw + { + return new SongEventDataRaw(this.time, this.event, this.value); + } } /** * Wrap SongEventData in an abstract so we can overload operators. */ -@:forward(time, event, value, activated, getStepTime) +@:forward(time, event, value, activated, getStepTime, clone) abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataRaw { public function new(time:Float, event:String, value:Dynamic = null) @@ -662,11 +722,6 @@ abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataR return this.value == null ? null : cast Reflect.field(this.value, key); } - public function clone():SongEventData - { - return new SongEventData(this.time, this.event, this.value); - } - @:op(A == B) public function op_equals(other:SongEventData):Bool { @@ -712,7 +767,7 @@ abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataR } } -class SongNoteDataRaw +class SongNoteDataRaw implements ICloneable { /** * The timestamp of the note. The timestamp is in the format of the song's time format. @@ -828,6 +883,11 @@ class SongNoteDataRaw } _stepLength = null; } + + public function clone():SongNoteDataRaw + { + return new SongNoteDataRaw(this.time, this.data, this.length, this.kind); + } } /** diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx index 78de08fdf..c120bb9e4 100644 --- a/source/funkin/ui/debug/charting/ChartEditorState.hx +++ b/source/funkin/ui/debug/charting/ChartEditorState.hx @@ -860,6 +860,70 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState return Save.get().chartEditorHasBackup = value; } + /** + * A list of previous working file paths. + * Also known as the "recent files" list. + * The first element is [null] if the current working file has not been saved anywhere yet. + */ + public var previousWorkingFilePaths(default, set):Array> = [null]; + + function set_previousWorkingFilePaths(value:Array>):Array> + { + // Called only when the WHOLE LIST is overridden. + previousWorkingFilePaths = value; + applyWindowTitle(); + populateOpenRecentMenu(); + applyCanQuickSave(); + return value; + } + + /** + * The current file path which the chart editor is working with. + * If `null`, the current chart has not been saved yet. + */ + public var currentWorkingFilePath(get, set):Null; + + function get_currentWorkingFilePath():Null + { + return previousWorkingFilePaths[0]; + } + + function set_currentWorkingFilePath(value:Null):Null + { + if (value == previousWorkingFilePaths[0]) return value; + + if (previousWorkingFilePaths.contains(null)) + { + // Filter all instances of `null` from the array. + previousWorkingFilePaths = previousWorkingFilePaths.filter(function(x:Null):Bool { + return x != null; + }); + } + + if (previousWorkingFilePaths.contains(value)) + { + // Move the path to the front of the list. + previousWorkingFilePaths.remove(value); + previousWorkingFilePaths.unshift(value); + } + else + { + // Add the path to the front of the list. + previousWorkingFilePaths.unshift(value); + } + + while (previousWorkingFilePaths.length > Constants.MAX_PREVIOUS_WORKING_FILES) + { + // Remove the last path in the list. + previousWorkingFilePaths.pop(); + } + + populateOpenRecentMenu(); + applyWindowTitle(); + + return value; + } + /** * Whether the difficulty tree view in the toolbox has been modified and needs to be updated. * This happens when we add/remove difficulties. @@ -889,6 +953,12 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState */ var commandHistoryDirty:Bool = true; + /** + * If true, we are currently in the process of quitting the chart editor. + * Skip any update functions as most of them will call a crash. + */ + var criticalFailure:Bool = false; + // Input /** @@ -1717,70 +1787,6 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState */ var params:Null; - /** - * A list of previous working file paths. - * Also known as the "recent files" list. - * The first element is [null] if the current working file has not been saved anywhere yet. - */ - public var previousWorkingFilePaths(default, set):Array> = [null]; - - function set_previousWorkingFilePaths(value:Array>):Array> - { - // Called only when the WHOLE LIST is overridden. - previousWorkingFilePaths = value; - applyWindowTitle(); - populateOpenRecentMenu(); - applyCanQuickSave(); - return value; - } - - /** - * The current file path which the chart editor is working with. - * If `null`, the current chart has not been saved yet. - */ - public var currentWorkingFilePath(get, set):Null; - - function get_currentWorkingFilePath():Null - { - return previousWorkingFilePaths[0]; - } - - function set_currentWorkingFilePath(value:Null):Null - { - if (value == previousWorkingFilePaths[0]) return value; - - if (previousWorkingFilePaths.contains(null)) - { - // Filter all instances of `null` from the array. - previousWorkingFilePaths = previousWorkingFilePaths.filter(function(x:Null):Bool { - return x != null; - }); - } - - if (previousWorkingFilePaths.contains(value)) - { - // Move the path to the front of the list. - previousWorkingFilePaths.remove(value); - previousWorkingFilePaths.unshift(value); - } - else - { - // Add the path to the front of the list. - previousWorkingFilePaths.unshift(value); - } - - while (previousWorkingFilePaths.length > Constants.MAX_PREVIOUS_WORKING_FILES) - { - // Remove the last path in the list. - previousWorkingFilePaths.pop(); - } - - populateOpenRecentMenu(); - applyWindowTitle(); - - return value; - } - public function new(?params:ChartEditorParams) { super(); @@ -2732,7 +2738,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState public override function update(elapsed:Float):Void { // Override F4 behavior to include the autosave. - if (FlxG.keys.justPressed.F4) + if (FlxG.keys.justPressed.F4 && !criticalFailure) { quitChartEditor(); return; @@ -2741,6 +2747,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState // dispatchEvent gets called here. super.update(elapsed); + if (criticalFailure) return; + // These ones happen even if the modal dialog is open. handleMusicPlayback(elapsed); handleNoteDisplay(); @@ -4516,6 +4524,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState FlxG.switchState(new MainMenuState()); resetWindowTitle(); + + criticalFailure = true; } /** diff --git a/source/funkin/ui/debug/charting/commands/ChangeStartingBPMCommand.hx b/source/funkin/ui/debug/charting/commands/ChangeStartingBPMCommand.hx index 3c45c1168..d80dd7c38 100644 --- a/source/funkin/ui/debug/charting/commands/ChangeStartingBPMCommand.hx +++ b/source/funkin/ui/debug/charting/commands/ChangeStartingBPMCommand.hx @@ -34,6 +34,11 @@ class ChangeStartingBPMCommand implements ChartEditorCommand state.currentSongMetadata.timeChanges = timeChanges; + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + state.notePreviewViewportBoundsDirty = true; + state.scrollPositionInPixels = 0; + Conductor.mapTimeChanges(state.currentSongMetadata.timeChanges); } @@ -51,6 +56,11 @@ class ChangeStartingBPMCommand implements ChartEditorCommand state.currentSongMetadata.timeChanges = timeChanges; + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + state.notePreviewViewportBoundsDirty = true; + state.scrollPositionInPixels = 0; + Conductor.mapTimeChanges(state.currentSongMetadata.timeChanges); } diff --git a/source/funkin/ui/debug/charting/commands/MoveItemsCommand.hx b/source/funkin/ui/debug/charting/commands/MoveItemsCommand.hx index 2eedbbf03..53090a6cc 100644 --- a/source/funkin/ui/debug/charting/commands/MoveItemsCommand.hx +++ b/source/funkin/ui/debug/charting/commands/MoveItemsCommand.hx @@ -21,8 +21,8 @@ class MoveItemsCommand implements ChartEditorCommand public function new(notes:Array, events:Array, offset:Float, columns:Int) { // Clone the notes to prevent editing from affecting the history. - this.notes = [for (note in notes) note.clone()]; - this.events = [for (event in events) event.clone()]; + this.notes = notes.clone(); + this.events = events.clone(); this.offset = offset; this.columns = columns; this.movedNotes = []; diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx index 0c8d6a205..d8c893d4d 100644 --- a/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx +++ b/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx @@ -43,7 +43,8 @@ class ChartEditorImportExportHandler var variation = (metadata.variation == null || metadata.variation == '') ? Constants.DEFAULT_VARIATION : metadata.variation; // Clone to prevent modifying the original. - var metadataClone:SongMetadata = metadata.clone(variation); + var metadataClone:SongMetadata = metadata.clone(); + metadataClone.variation = variation; if (metadataClone != null) songMetadata.set(variation, metadataClone); var chartData:Null = SongRegistry.instance.parseEntryChartData(songId, metadata.variation); diff --git a/source/funkin/util/tools/ArrayTools.hx b/source/funkin/util/tools/ArrayTools.hx index a88f8a861..0209cfc19 100644 --- a/source/funkin/util/tools/ArrayTools.hx +++ b/source/funkin/util/tools/ArrayTools.hx @@ -76,4 +76,72 @@ class ArrayTools while (array.length > 0) array.pop(); } + + /** + * Create a new array with all elements of the given array, to prevent modifying the original. + */ + public static function clone(array:Array):Array + { + return [for (element in array) element]; + } + + /** + * Create a new array with clones of all elements of the given array, to prevent modifying the original. + */ + public static function deepClone>(array:Array):Array + { + return [for (element in array) element.clone()]; + } + + /** + * Return true only if both arrays contain the same elements (possibly in a different order). + * @param a The first array to compare. + * @param b The second array to compare. + * @return Weather both arrays contain the same elements. + */ + public static function isEqualUnordered(a:Array, b:Array):Bool + { + if (a.length != b.length) return false; + for (element in a) + { + if (!b.contains(element)) return false; + } + for (element in b) + { + if (!a.contains(element)) return false; + } + return true; + } + + /** + * Returns true if `superset` contains all elements of `subset`. + * @param superset The array to query for each element. + * @param subset The array containing the elements to query for. + * @return Weather `superset` contains all elements of `subset`. + */ + public static function isSuperset(superset:Array, subset:Array):Bool + { + // Shortcuts. + if (subset.length == 0) return true; + if (subset.length > superset.length) return false; + + // Check each element. + for (element in subset) + { + if (!superset.contains(element)) return false; + } + return true; + } + + /** + * Returns true if `superset` contains all elements of `subset`. + * @param subset The array containing the elements to query for. + * @param superset The array to query for each element. + * @return Weather `superset` contains all elements of `subset`. + */ + public static function isSubset(subset:Array, superset:Array):Bool + { + // Switch it around. + return isSuperset(superset, subset); + } } diff --git a/source/funkin/util/tools/ICloneable.hx b/source/funkin/util/tools/ICloneable.hx new file mode 100644 index 000000000..33f19f167 --- /dev/null +++ b/source/funkin/util/tools/ICloneable.hx @@ -0,0 +1,10 @@ +package funkin.util.tools; + +/** + * Implement this on a class to enable `Array.deepClone()` to work on it. + * NOTE: T should be the type of the class that implements this interface. + */ +interface ICloneable +{ + public function clone():T; +} diff --git a/source/funkin/util/tools/MapTools.hx b/source/funkin/util/tools/MapTools.hx index 739c5efdb..1399fb791 100644 --- a/source/funkin/util/tools/MapTools.hx +++ b/source/funkin/util/tools/MapTools.hx @@ -25,6 +25,33 @@ class MapTools return [for (i in map.iterator()) i]; } + /** + * Create a new array with all elements of the given array, to prevent modifying the original. + */ + public static function clone(map:Map):Map + { + return map.copy(); + } + + /** + * Create a new array with clones of all elements of the given array, to prevent modifying the original. + */ + public static function deepClone>(map:Map):Map + { + // TODO: This function does NOT work. + throw "Not implemented"; + + /* + var newMap:Map = []; + // Replace each value with a clone of itself. + for (key in newMap.keys()) + { + newMap.set(key, newMap.get(key).clone()); + } + return newMap; + */ + } + /** * Return a list of keys from the map (as an array, rather than an iterator). * TODO: Rename this?