diff --git a/source/funkin/data/song/SongDataUtils.hx b/source/funkin/data/song/SongDataUtils.hx index d0e554e01..f9a3c0f71 100644 --- a/source/funkin/data/song/SongDataUtils.hx +++ b/source/funkin/data/song/SongDataUtils.hx @@ -4,6 +4,7 @@ import flixel.util.FlxSort; import funkin.data.song.SongData.SongEventData; import funkin.data.song.SongData.SongNoteData; import funkin.data.song.SongData.SongTimeChange; +import funkin.ui.debug.charting.ChartEditorState; import funkin.util.ClipboardUtil; import funkin.util.SerializerUtil; @@ -81,6 +82,17 @@ class SongDataUtils }); } + /** + * Returns a new array which is a concatenation of two arrays of notes while preventing duplicate notes. + * NOTE: This modifies the `addend` array. + * @param notes The array of notes to be added to. + * @param addend The notes to add to the `notes` array. + */ + public inline static function addNotes(notes:Array, addend:Array):Array + { + return SongNoteDataUtils.concatNoOverlap(notes, addend, ChartEditorState.stackNoteThreshold); + } + /** * Return a new array without a certain subset of notes from an array of SongNoteData objects. * Does not mutate the original array. diff --git a/source/funkin/data/song/SongNoteDataUtils.hx b/source/funkin/data/song/SongNoteDataUtils.hx new file mode 100644 index 000000000..09735dff6 --- /dev/null +++ b/source/funkin/data/song/SongNoteDataUtils.hx @@ -0,0 +1,154 @@ +package funkin.data.song; + +using SongData.SongNoteData; + +/** + * Utility class for extra handling of song notes + */ +class SongNoteDataUtils +{ + static final CHUNK_INTERVAL_MS:Float = 2500; + + /** + * Retrieves all stacked notes + * + * @param notes Sorted notes by time + * @param threshold Threshold in ms + * @return Stacked notes + */ + public static function listStackedNotes(notes:Array, threshold:Float):Array + { + var stackedNotes:Array = []; + + var chunkTime:Float = 0; + var chunks:Array> = [[]]; + + for (note in notes) + { + if (note == null || chunks[chunks.length - 1].contains(note)) + { + continue; + } + + while (note.time >= chunkTime + CHUNK_INTERVAL_MS) + { + chunkTime += CHUNK_INTERVAL_MS; + chunks.push([]); + } + + chunks[chunks.length - 1].push(note); + } + + for (chunk in chunks) + { + for (i in 0...(chunk.length - 1)) + { + for (j in (i + 1)...chunk.length) + { + var noteI:SongNoteData = chunk[i]; + var noteJ:SongNoteData = chunk[j]; + + if (doNotesStack(noteI, noteJ, threshold)) + { + if (!stackedNotes.fastContains(noteI)) + { + stackedNotes.push(noteI); + } + + if (!stackedNotes.fastContains(noteJ)) + { + stackedNotes.push(noteJ); + } + } + } + } + } + + return stackedNotes; + } + + /** + * Tries to concatenate two arrays of notes together but skips notes from `notesB` that overlap notes from `noteA`. + * This operation modifies the second array by removing the overlapped notes + * + * @param notesA An array of notes into which `notesB` will be concatenated. + * @param notesB Another array of notes that will be concatenated into `notesA`. + * @param threshold Threshold in ms. + * @return The unsorted resulting array. + */ + public static function concatNoOverlap(notesA:Array, notesB:Array, threshold:Float):Array + { + if (notesA == null || notesA.length == 0) return notesB; + if (notesB == null || notesB.length == 0) return notesA; + + var addend = notesB.copy(); + addend = addend.filter((noteB) -> { + for (noteA in notesA) + { + if (doNotesStack(noteA, noteB, threshold)) + { + notesB.remove(noteB); + return false; + } + } + return true; + }); + + return notesA.concat(addend); + } + + /** + * Concatenates two arrays of notes but overwrites notes in `lhs` that are overlapped by notes in `rhs`. + * Hold notes are only overwritten by longer hold notes. + * This operation only modifies the second array and `overwrittenNotes`. + * + * @param lhs An array of notes + * @param rhs An array of notes to concatenate into `lhs` + * @param overwrittenNotes An optional array that is modified in-place with the notes in `lhs` that were overwritten. + * @param threshold Threshold in ms. + * @return The unsorted resulting array. + */ + public static function concatOverwrite(lhs:Array, rhs:Array, ?overwrittenNotes:Array, + threshold:Float):Array + { + if (lhs == null || rhs == null || rhs.length == 0) return lhs; + if (lhs.length == 0) return rhs; + + var result = lhs.copy(); + for (i in 0...rhs.length) + { + var noteB:SongNoteData = rhs[i]; + var hasOverlap:Bool = false; + + // TODO: Since notes are generally sorted this could probably benefit of only cycling through notes in a certain range + for (j in 0...lhs.length) + { + var noteA:SongNoteData = lhs[j]; + if (doNotesStack(noteA, noteB, threshold)) + { + if (noteA.length <= noteB.length) + { + overwrittenNotes?.push(result[j].clone()); + result[j] = noteB; + } + hasOverlap = true; + break; + } + } + + if (!hasOverlap) result.push(noteB); + } + + return result; + } + + /** + * @param threshold Time difference in milliseconds. + * @return Returns `true` if both notes are on the same strumline, have the same direction and their time difference is less than `threshold`. + */ + public static function doNotesStack(noteA:SongNoteData, noteB:SongNoteData, threshold:Float = 20):Bool + { + // TODO: Make this function inline again when I'm done debugging. + return noteA.data == noteB.data && Math.ffloor(Math.abs(noteA.time - noteB.time)) <= threshold; + } +} diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx index 44c14be06..66e542a96 100644 --- a/source/funkin/ui/debug/charting/ChartEditorState.hx +++ b/source/funkin/ui/debug/charting/ChartEditorState.hx @@ -37,6 +37,7 @@ import funkin.data.song.SongData.SongNoteData; import funkin.data.song.SongData.SongOffsets; import funkin.data.song.SongData.NoteParamData; import funkin.data.song.SongDataUtils; +import funkin.data.song.SongNoteDataUtils; import funkin.data.song.SongRegistry; import funkin.data.stage.StageData; import funkin.graphics.FunkinCamera; @@ -807,6 +808,12 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState */ var currentLiveInputPlaceNoteData:Array = []; + /** + * How "close" in milliseconds two notes have to be to be considered as stacked. + * For instance, `0` means the notes should be exactly on top of each other + */ + public static var stackNoteThreshold:Int = 20; + // Note Movement /** @@ -1819,6 +1826,11 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState */ var menuBarItemNoteSnapIncrease:MenuItem; + /** + * The `Edit -> Stacked Note Threshold` menu item + */ + var menuBarItemStackedNoteThreshold:MenuItem; + /** * The `View -> Downscroll` menu item. */ @@ -2010,9 +2022,10 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState /** * The IMAGE used for the selection squares. Updated by ChartEditorThemeHandler. - * Used two ways: + * Used three ways: * 1. A sprite is given this bitmap and placed over selected notes. - * 2. The image is split and used for a 9-slice sprite for the selection box. + * 2. Same as above but for notes that are overlapped by another. + * 3. The image is split and used for a 9-slice sprite for the selection box. */ var selectionSquareBitmap:Null = null; @@ -3733,6 +3746,9 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState member.kill(); } + // Gather stacked notes to render later + var stackedNotes = SongNoteDataUtils.listStackedNotes(currentSongChartNoteData, stackNoteThreshold); + // Readd selection squares for selected notes. // Recycle selection squares if possible. for (noteSprite in renderedNotes.members) @@ -3790,10 +3806,24 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState selectionSquare.x = noteSprite.x; selectionSquare.y = noteSprite.y; selectionSquare.width = GRID_SIZE; + selectionSquare.color = FlxColor.WHITE; var stepLength = noteSprite.noteData.getStepLength(); selectionSquare.height = (stepLength <= 0) ? GRID_SIZE : ((stepLength + 1) * GRID_SIZE); } + else if (doesNoteStack(noteSprite.noteData, stackedNotes)) + { + // TODO: Maybe use another way to display these notes + var selectionSquare:ChartEditorSelectionSquareSprite = renderedSelectionSquares.recycle(buildSelectionSquare); + + // Set the position and size (because we might be recycling one with bad values). + selectionSquare.noteData = noteSprite.noteData; + selectionSquare.eventData = null; + selectionSquare.x = noteSprite.x; + selectionSquare.y = noteSprite.y; + selectionSquare.width = selectionSquare.height = GRID_SIZE; + selectionSquare.color = FlxColor.RED; + } } for (eventSprite in renderedEvents.members) @@ -6379,6 +6409,11 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState return note != null && currentNoteSelection.indexOf(note) != -1; } + function doesNoteStack(note:Null, curStackedNotes:Array):Bool + { + return note != null && curStackedNotes.contains(note); + } + override function destroy():Void { super.destroy(); diff --git a/source/funkin/ui/debug/charting/commands/PasteItemsCommand.hx b/source/funkin/ui/debug/charting/commands/PasteItemsCommand.hx index 257db94b4..6f478c358 100644 --- a/source/funkin/ui/debug/charting/commands/PasteItemsCommand.hx +++ b/source/funkin/ui/debug/charting/commands/PasteItemsCommand.hx @@ -4,6 +4,8 @@ import funkin.data.song.SongData.SongEventData; import funkin.data.song.SongData.SongNoteData; import funkin.data.song.SongDataUtils; import funkin.data.song.SongDataUtils.SongClipboardItems; +import funkin.data.song.SongNoteDataUtils; +import funkin.ui.debug.charting.ChartEditorState; /** * A command which inserts the contents of the clipboard into the chart editor. @@ -13,9 +15,10 @@ import funkin.data.song.SongDataUtils.SongClipboardItems; class PasteItemsCommand implements ChartEditorCommand { var targetTimestamp:Float; - // Notes we added with this command, for undo. + // Notes we added and removed with this command, for undo. var addedNotes:Array = []; var addedEvents:Array = []; + var removedNotes:Array = []; public function new(targetTimestamp:Float) { @@ -41,7 +44,8 @@ class PasteItemsCommand implements ChartEditorCommand addedEvents = SongDataUtils.offsetSongEventData(currentClipboard.events, Std.int(targetTimestamp)); addedEvents = SongDataUtils.clampSongEventData(addedEvents, 0.0, msCutoff); - state.currentSongChartNoteData = state.currentSongChartNoteData.concat(addedNotes); + state.currentSongChartNoteData = SongNoteDataUtils.concatOverwrite(state.currentSongChartNoteData, addedNotes, removedNotes, + ChartEditorState.stackNoteThreshold); state.currentSongChartEventData = state.currentSongChartEventData.concat(addedEvents); state.currentNoteSelection = addedNotes.copy(); state.currentEventSelection = addedEvents.copy(); @@ -52,16 +56,19 @@ class PasteItemsCommand implements ChartEditorCommand state.sortChartData(); - state.success('Paste Successful', 'Successfully pasted clipboard contents.'); + // FIXME: execute() is reused as a redo function so these messages show up even when not actually pasting + if (removedNotes.length > 0) state.warning('Paste Successful', 'However overlapped notes were overwritten.'); + else + state.success('Paste Successful', 'Successfully pasted clipboard contents.'); } public function undo(state:ChartEditorState):Void { state.playSound(Paths.sound('chartingSounds/undo')); - state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, addedNotes); + state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, addedNotes).concat(removedNotes); state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, addedEvents); - state.currentNoteSelection = []; + state.currentNoteSelection = removedNotes.copy(); state.currentEventSelection = []; state.saveDataDirty = true; @@ -74,7 +81,7 @@ class PasteItemsCommand implements ChartEditorCommand public function shouldAddToHistory(state:ChartEditorState):Bool { // This command is undoable. Add to the history if we actually performed an action. - return (addedNotes.length > 0 || addedEvents.length > 0); + return (addedNotes.length > 0 || addedEvents.length > 0 || removedNotes.length > 0); } public function toString():String