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.STACK_NOTE_THRESHOLD, true);
}

/**
* Return a new array without a certain subset of notes from an array of SongNoteData objects.
* Does not mutate the original array.
Expand Down
193 changes: 193 additions & 0 deletions source/funkin/data/song/SongNoteDataUtils.hx
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
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):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 does not modify the original array.
* @param notesA An array of notes into which `notesB` will be concatenated.
* @param notesB Another array of notes that will be concated into `notesA`.
* @param threshold Threshold in ms
* @param modifyB If `true`, `notesB` will be modified in-place by removing the notes that overlap notes from `notesA`.
* @return Array<SongNoteData>
*/
public static function concatNoOverlap(notesA:Array<SongNoteData>, notesB:Array<SongNoteData>, threshold:Float, modifyB:Bool = false):Array<SongNoteData>
{
if (notesA == null || notesA.length == 0) return notesB;
if (notesB == null || notesB.length == 0) return notesA;

var result:Array<SongNoteData> = notesA.copy();
var overlappingNotes:Array<SongNoteData> = [];

for (noteB in notesB)
{
var hasOverlap:Bool = false;

for (noteA in notesA)
{
if (doNotesStack(noteA, noteB, threshold))
{
hasOverlap = true;
break;
}
}

if (!hasOverlap)
{
result.push(noteB);
}
else if (modifyB)
{
overlappingNotes.push(noteB);
}
}

if (modifyB)
{
for (note in overlappingNotes)
notesB.remove(note);
}

return result;
}

/**
* Concatenates two arrays of notes but overwrites notes in `lhs` that are overlapped by notes from `rhs`.
* This does not modify the fist array but does modify the second.
* @param lhs
* @param rhs
* @param threshold Threshold in ms
* @param overwrittenNotes An array that is modified in-place with the notes in `lhs` that were overwritten.
* @return `lhs` + `rhs`
*/
public static function concatOverwrite(lhs:Array<SongNoteData>, rhs:Array<SongNoteData>, threshold:Float,
?overwrittenNotes:Array<SongNoteData>):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;
// Do not resize array with remove() now to not screw with loop
rhs[i] = null;
}
hasOverlap = true;
break;
}
}

if (!hasOverlap) result.push(noteB);
}

// Now we can safely resize it
rhs = rhs.filterNull();

return result;
}

/**
* @param threshold
* @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):Bool
{
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(noteA:SongNoteData, other:SongNoteData):Bool
{
if (noteA == null) return other == null;
if (other == null) return false;

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

// params check is unreliable and doNotesStack already checks data
return noteA.time == other.time && noteA.length == other.length;
}
}
38 changes: 35 additions & 3 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 @@ -201,6 +202,12 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
*/
public static final NOTE_SELECT_BUTTON_HEIGHT:Int = 24;

/**
* How "close" in milliseconds two notes have to be to be considered as stacked.
* TODO: This should probably be turned into a customizable value
*/
public static final STACK_NOTE_THRESHOLD:Int = 20;

/**
* The amount of padding between the menu bar and the chart grid when fully scrolled up.
*/
Expand Down Expand Up @@ -2010,9 +2017,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 @@ -3728,6 +3736,9 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
member.kill();
}

// Gather stacked notes to render later
var stackedNotes = SongNoteDataUtils.listStackedNotes(currentSongChartNoteData, STACK_NOTE_THRESHOLD);

// Readd selection squares for selected notes.
// Recycle selection squares if possible.
for (noteSprite in renderedNotes.members)
Expand Down Expand Up @@ -3785,10 +3796,25 @@ 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);
}
// TODO: Move this to a function like isNoteSelected does
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 @@ -5654,7 +5680,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
function handleHelpKeybinds():Void
{
// F1 = Open Help
if (FlxG.keys.justPressed.F1 && !isHaxeUIDialogOpen) {
if (FlxG.keys.justPressed.F1 && !isHaxeUIDialogOpen)
{
this.openUserGuideDialog();
}
}
Expand Down Expand Up @@ -6423,6 +6450,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
14 changes: 9 additions & 5 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.data.song.SongNoteDataUtils;

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

public function new(targetTimestamp:Float)
{
Expand All @@ -41,7 +43,7 @@ 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 = SongDataUtils.addNotes(state.currentSongChartNoteData, addedNotes);
state.currentSongChartEventData = state.currentSongChartEventData.concat(addedEvents);
state.currentNoteSelection = addedNotes.copy();
state.currentEventSelection = addedEvents.copy();
Expand All @@ -52,14 +54,16 @@ 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
{
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.currentEventSelection = [];
Expand All @@ -74,7 +78,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
Expand Down
Loading