diff --git a/source/funkin/data/song/SongDataUtils.hx b/source/funkin/data/song/SongDataUtils.hx index d0e554e015..1445864a7a 100644 --- a/source/funkin/data/song/SongDataUtils.hx +++ b/source/funkin/data/song/SongDataUtils.hx @@ -5,7 +5,6 @@ import funkin.data.song.SongData.SongEventData; import funkin.data.song.SongData.SongNoteData; import funkin.data.song.SongData.SongTimeChange; import funkin.util.ClipboardUtil; -import funkin.util.SerializerUtil; using Lambda; diff --git a/source/funkin/data/song/SongNoteDataUtils.hx b/source/funkin/data/song/SongNoteDataUtils.hx new file mode 100644 index 0000000000..44d320267d --- /dev/null +++ b/source/funkin/data/song/SongNoteDataUtils.hx @@ -0,0 +1,123 @@ +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; + } + + /** + * 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; + + for (j in 0...lhs.length) + { + var noteA:SongNoteData = lhs[j]; + if (doNotesStack(noteA, noteB, threshold)) + { + // Longer hold notes should have priority over shorter hold notes + 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 inline function doNotesStack(noteA:SongNoteData, noteB:SongNoteData, threshold:Float = 20):Bool + { + 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 44c14be06e..ac7f053ed7 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 = 10; + // Note Movement /** @@ -879,6 +886,32 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState return currentNoteSelection; } + var currentOverlappingNotes(default, set):Array = []; + + function set_currentOverlappingNotes(value:Array):Array + { + // This value is true if all elements of the current overlapping array are also in the new array. + var isSuperset:Bool = currentOverlappingNotes.isSubset(value); + var isEqual:Bool = currentOverlappingNotes.isEqualUnordered(value); + + currentOverlappingNotes = value; + + if (!isEqual) + { + if (currentOverlappingNotes.length > 0 && isSuperset) + { + notePreview.addOverlappingNotes(currentOverlappingNotes, Std.int(songLengthInMs)); + } + else + { + // The new array might add or remove elements from the old array, so we have to redraw the note preview. + notePreviewDirty = true; + } + } + + return currentOverlappingNotes; + } + /** * The events which are currently in the user's selection. */ @@ -1819,6 +1852,11 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState */ var menuBarItemNoteSnapIncrease:MenuItem; + /** + * The `Edit -> Stacked Note Threshold` number stepper + */ + var menuBarItemStackedNoteThreshold:NumberStepper; + /** * The `View -> Downscroll` menu item. */ @@ -2010,9 +2048,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; @@ -2990,6 +3029,13 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState if (noteSnapQuantIndex >= SNAP_QUANTS.length) noteSnapQuantIndex = 0; }; + menuBarItemStackedNoteThreshold.pos = stackNoteThreshold; + menuBarItemStackedNoteThreshold.autoCorrect = true; + menuBarItemStackedNoteThreshold.onChange = event -> { + noteDisplayDirty = true; + stackNoteThreshold = event.value; + } + menuBarItemInputStyleNone.onClick = function(event:UIEvent) { currentLiveInputStyle = None; }; @@ -3733,6 +3779,9 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState member.kill(); } + // Gather stacked notes to render later + currentOverlappingNotes = SongNoteDataUtils.listStackedNotes(currentSongChartNoteData, stackNoteThreshold); + // Readd selection squares for selected notes. // Recycle selection squares if possible. for (noteSprite in renderedNotes.members) @@ -3790,10 +3839,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, currentOverlappingNotes)) + { + // 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) @@ -6200,6 +6263,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState notePreview.erase(); notePreview.addNotes(currentSongChartNoteData, Std.int(songLengthInMs)); notePreview.addSelectedNotes(currentNoteSelection, Std.int(songLengthInMs)); + notePreview.addOverlappingNotes(currentOverlappingNotes, Std.int(songLengthInMs)); notePreview.addEvents(currentSongChartEventData, Std.int(songLengthInMs)); } @@ -6379,6 +6443,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 257db94b45..23bee6071d 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,10 @@ 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); + var mergedNotes:Array = SongNoteDataUtils.concatOverwrite(state.currentSongChartNoteData, addedNotes, removedNotes, + ChartEditorState.stackNoteThreshold); + + state.currentSongChartNoteData = mergedNotes; state.currentSongChartEventData = state.currentSongChartEventData.concat(addedEvents); state.currentNoteSelection = addedNotes.copy(); state.currentEventSelection = addedEvents.copy(); @@ -52,7 +58,9 @@ class PasteItemsCommand implements ChartEditorCommand state.sortChartData(); - state.success('Paste Successful', 'Successfully pasted clipboard contents.'); + 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 @@ -60,8 +68,9 @@ class PasteItemsCommand implements ChartEditorCommand state.playSound(Paths.sound('chartingSounds/undo')); state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, addedNotes); + state.currentSongChartNoteData = SongNoteDataUtils.concatOverwrite(state.currentSongChartNoteData, removedNotes, ChartEditorState.stackNoteThreshold); state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, addedEvents); - state.currentNoteSelection = []; + state.currentNoteSelection = removedNotes.copy(); state.currentEventSelection = []; state.saveDataDirty = true; @@ -74,7 +83,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 diff --git a/source/funkin/ui/debug/charting/components/ChartEditorNotePreview.hx b/source/funkin/ui/debug/charting/components/ChartEditorNotePreview.hx index 8d9ec67437..4e4e5b3133 100644 --- a/source/funkin/ui/debug/charting/components/ChartEditorNotePreview.hx +++ b/source/funkin/ui/debug/charting/components/ChartEditorNotePreview.hx @@ -27,6 +27,7 @@ class ChartEditorNotePreview extends FlxSprite static final RIGHT_COLOR:FlxColor = 0xFFCC1111; static final EVENT_COLOR:FlxColor = 0xFF111111; static final SELECTED_COLOR:FlxColor = 0xFFFFFF00; + static final OVERLAPPING_COLOR:FlxColor = 0xFF640000; var previewHeight:Int; @@ -58,21 +59,22 @@ class ChartEditorNotePreview extends FlxSprite * @param note The data for the note. * @param songLengthInMs The total length of the song in milliseconds. */ - public function addNote(note:SongNoteData, songLengthInMs:Int, ?isSelection:Bool = false):Void + public function addNote(note:SongNoteData, songLengthInMs:Int, previewType:NotePreviewType = None):Void { var noteDir:Int = note.getDirection(); var mustHit:Bool = note.getStrumlineIndex() == 0; - drawNote(noteDir, mustHit, Std.int(note.time), songLengthInMs, isSelection); + drawNote(noteDir, mustHit, Std.int(note.time), songLengthInMs, previewType); } /** * Add a song event to the preview. * @param event The data for the event. * @param songLengthInMs The total length of the song in milliseconds. + * @param isSelection If current event is selected, which then it's forced to be yellow. */ - public function addEvent(event:SongEventData, songLengthInMs:Int, ?isSelection:Bool = false):Void + public function addEvent(event:SongEventData, songLengthInMs:Int, isSelection:Bool = false):Void { - drawNote(-1, false, Std.int(event.time), songLengthInMs, isSelection); + drawNote(-1, false, Std.int(event.time), songLengthInMs, isSelection ? Selection : None); } /** @@ -84,7 +86,7 @@ class ChartEditorNotePreview extends FlxSprite { for (note in notes) { - addNote(note, songLengthInMs, false); + addNote(note, songLengthInMs, None); } } @@ -97,7 +99,20 @@ class ChartEditorNotePreview extends FlxSprite { for (note in notes) { - addNote(note, songLengthInMs, true); + addNote(note, songLengthInMs, Selection); + } + } + + /** + * Add an array of overlapping notes to the preview. + * @param notes The data for the notes + * @param songLengthInMs The total length of the song in milliseconds. + */ + public function addOverlappingNotes(notes:Array, songLengthInMs:Int):Void + { + for (note in notes) + { + addNote(note, songLengthInMs, Overlapping); } } @@ -133,9 +148,9 @@ class ChartEditorNotePreview extends FlxSprite * @param mustHit False if opponent, true if player. * @param strumTimeInMs Time in milliseconds to strum the note. * @param songLengthInMs Length of the song in milliseconds. - * @param isSelection If current note is selected note, which then it's forced to be green + * @param previewType If the note should forcibly be colored as selected or overlapping. */ - public function drawNote(dir:Int, mustHit:Bool, strumTimeInMs:Int, songLengthInMs:Int, ?isSelection:Bool = false):Void + public function drawNote(dir:Int, mustHit:Bool, strumTimeInMs:Int, songLengthInMs:Int, previewType:NotePreviewType = None):Void { var color:FlxColor = switch (dir) { @@ -148,10 +163,15 @@ class ChartEditorNotePreview extends FlxSprite var noteHeight:Int = NOTE_HEIGHT; - if (isSelection != null && isSelection) + switch (previewType) { - color = SELECTED_COLOR; - noteHeight += 1; + case Selection: + color = SELECTED_COLOR; + noteHeight += 1; + case Overlapping: + color = OVERLAPPING_COLOR; + noteHeight += 2; + default: } var noteX:Float = NOTE_WIDTH * dir; @@ -178,3 +198,10 @@ class ChartEditorNotePreview extends FlxSprite FlxSpriteUtil.drawRect(this, noteX, noteY, width, height, color); } } + +enum NotePreviewType +{ + None; + Selection; + Overlapping; +}