Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Enhancement] Prevent Stacked Notes #3574

Draft
wants to merge 15 commits into
base: develop
Choose a base branch
from
12 changes: 12 additions & 0 deletions source/funkin/data/song/SongDataUtils.hx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<SongNoteData>, addend:Array<SongNoteData>):Array<SongNoteData>
{
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.
Expand Down
174 changes: 174 additions & 0 deletions source/funkin/data/song/SongNoteDataUtils.hx
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
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<SongNoteData>, threshold:Float = 20):Array<SongNoteData>
{
var stackedNotes:Array<SongNoteData> = [];

var chunkTime:Float = 0;
var chunks:Array<Array<SongNoteData>> = [[]];

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<SongNoteData>, notesB:Array<SongNoteData>, threshold:Float = 20):Array<SongNoteData>
{
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 from `rhs`.
* 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 resulting array, note that the added notes are placed at the end of the array.
*/
public static function concatOverwrite(lhs:Array<SongNoteData>, rhs:Array<SongNoteData>, ?overwrittenNotes:Array<SongNoteData>,
threshold:Float = 20):Array<SongNoteData>
{
if (lhs == null || rhs == null || rhs.length == 0) return lhs;

var result = lhs.copy();
for (i in 0...rhs.length)
{
if (rhs[i] == null) continue;

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))
{
if (noteA.length < noteB.length || !noteEquals(noteA, noteB))
{
overwrittenNotes?.push(result[j].clone());
result[j] = noteB;
rhs[i] = null;
}
hasOverlap = true;
break;
}
}

if (!hasOverlap) result.push(noteB);
}
rhs = rhs.filterNull();

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;
}

// This is replacing SongNoteData's equals operator because for some reason its params check is unreliable.
static function noteEquals(note:SongNoteData, other:SongNoteData):Bool
{
if (note == null) return other == null;
if (other == null) return false;

// TESTME: These checks seem redundant when get_kind already returns null if it's an empty string.
/*if (noteA.kind == null)
{
if (other.kind != null) return false;
}
else
{
if (other.kind == null) return false;
}*/

// params check is unreliable and doNotesStack already checks data
return note.time == other.time && note.length == other.length && note.kind == other.kind;
}
}
39 changes: 37 additions & 2 deletions source/funkin/ui/debug/charting/ChartEditorState.hx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -807,6 +808,12 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
*/
var currentLiveInputPlaceNoteData:Array<SongNoteData> = [];

/**
* 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

/**
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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<BitmapData> = null;

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -6379,6 +6409,11 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
return note != null && currentNoteSelection.indexOf(note) != -1;
}

function doesNoteStack(note:Null<SongNoteData>, curStackedNotes:Array<SongNoteData>):Bool
{
return note != null && curStackedNotes.contains(note);
}

override function destroy():Void
{
super.destroy();
Expand Down
17 changes: 13 additions & 4 deletions source/funkin/ui/debug/charting/commands/PasteItemsCommand.hx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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.ui.debug.charting.ChartEditorState;

/**
* A command which inserts the contents of the clipboard into the chart editor.
Expand All @@ -13,7 +14,7 @@ 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<SongNoteData> = [];
var addedEvents:Array<SongEventData> = [];

Expand All @@ -40,19 +41,27 @@ class PasteItemsCommand implements ChartEditorCommand
addedNotes = SongDataUtils.clampSongNoteData(addedNotes, 0.0, msCutoff);
addedEvents = SongDataUtils.offsetSongEventData(currentClipboard.events, Std.int(targetTimestamp));
addedEvents = SongDataUtils.clampSongEventData(addedEvents, 0.0, msCutoff);
var removedNotes = addedNotes.copy();

state.currentSongChartNoteData = state.currentSongChartNoteData.concat(addedNotes);
state.currentSongChartNoteData = SongDataUtils.addNotes(state.currentSongChartNoteData, addedNotes);
// SongNoteDataUtils.concatOverwrite(state.currentSongChartNoteData, addedNotes, removedNotes,
// ChartEditorState.stackNoteThreshold);
state.currentSongChartEventData = state.currentSongChartEventData.concat(addedEvents);
state.currentNoteSelection = addedNotes.copy();
state.currentNoteSelection = removedNotes.copy();
state.currentEventSelection = addedEvents.copy();
removedNotes = SongDataUtils.subtractNotes(removedNotes, addedNotes);

state.saveDataDirty = true;
state.noteDisplayDirty = true;
state.notePreviewDirty = true;

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 (addedNotes.length == 0) state.error('Paste Failed', 'All notes would overlap already placed notes.')
else if (removedNotes.length > 0) state.warning('Paste Successful', 'However overlapping notes were ignored.');
else
state.success('Paste Successful', 'Successfully pasted clipboard contents.');
}

public function undo(state:ChartEditorState):Void
Expand Down
Loading