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

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

Expand Down
123 changes: 123 additions & 0 deletions source/funkin/data/song/SongNoteDataUtils.hx
Original file line number Diff line number Diff line change
@@ -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<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;
}

/**
* 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<SongNoteData>, rhs:Array<SongNoteData>, ?overwrittenNotes:Array<SongNoteData>,
threshold:Float):Array<SongNoteData>
{
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;
}
}
73 changes: 71 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 = 10;

// Note Movement

/**
Expand Down Expand Up @@ -879,6 +886,32 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
return currentNoteSelection;
}

var currentOverlappingNotes(default, set):Array<SongNoteData> = [];

function set_currentOverlappingNotes(value:Array<SongNoteData>):Array<SongNoteData>
{
// 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.
*/
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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<BitmapData> = null;

Expand Down Expand Up @@ -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;
};
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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));
}

Expand Down Expand Up @@ -6379,6 +6443,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
19 changes: 14 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,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.
Expand All @@ -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<SongNoteData> = [];
var addedEvents:Array<SongEventData> = [];
var removedNotes:Array<SongNoteData> = [];

public function new(targetTimestamp:Float)
{
Expand All @@ -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<SongNoteData> = 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();
Expand All @@ -52,16 +58,19 @@ 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 = 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;
Expand All @@ -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
Expand Down
Loading