package funkin.play.notes; import flixel.tweens.FlxEase; import flixel.tweens.FlxTween; import funkin.ui.PreferencesMenu; import funkin.play.notes.NoteSprite; import flixel.util.FlxSort; import funkin.play.notes.SustainTrail; import funkin.util.SortUtil; import funkin.play.song.SongData.SongNoteData; import flixel.FlxG; import flixel.group.FlxSpriteGroup; import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup; /** * A group of sprites which handles the receptor, the note splashes, and the notes (with sustains) for a given player. */ class Strumline extends FlxSpriteGroup { public static final DIRECTIONS:Array = [NoteDirection.LEFT, NoteDirection.DOWN, NoteDirection.UP, NoteDirection.RIGHT]; public static final STRUMLINE_SIZE:Int = 112; public static final NOTE_SPACING:Int = STRUMLINE_SIZE + 8; // Positional fixes for new strumline graphics. static final INITIAL_OFFSET = -0.275 * STRUMLINE_SIZE; static final NUDGE:Float = 2.0; static final KEY_COUNT:Int = 4; static final NOTE_SPLASH_CAP:Int = 6; static var RENDER_DISTANCE_MS(get, null):Float; static function get_RENDER_DISTANCE_MS():Float { return FlxG.height / 0.45; } public var isPlayer:Bool; /** * The notes currently being rendered on the strumline. * This group iterates over this every frame to update note positions. * The PlayState also iterates over this to calculate user inputs. */ public var notes:FlxTypedSpriteGroup; public var holdNotes:FlxTypedSpriteGroup; var strumlineNotes:FlxTypedSpriteGroup; var noteSplashes:FlxTypedSpriteGroup; var sustainSplashes:FlxTypedSpriteGroup; var noteData:Array = []; var nextNoteIndex:Int = -1; public function new(isPlayer:Bool) { super(); this.isPlayer = isPlayer; this.strumlineNotes = new FlxTypedSpriteGroup(); this.add(this.strumlineNotes); // Hold notes are added first so they render behind regular notes. this.holdNotes = new FlxTypedSpriteGroup(); this.add(this.holdNotes); this.notes = new FlxTypedSpriteGroup(); this.add(this.notes); this.noteSplashes = new FlxTypedSpriteGroup(0, 0, NOTE_SPLASH_CAP); this.add(this.noteSplashes); for (i in 0...DIRECTIONS.length) { var child:StrumlineNote = new StrumlineNote(isPlayer, DIRECTIONS[i]); child.x = getXPos(DIRECTIONS[i]); child.x += INITIAL_OFFSET; child.y = 0; this.strumlineNotes.add(child); } // This MUST be true for children to update! this.active = true; } override function get_width():Float { return 4 * Strumline.NOTE_SPACING; } public override function update(elapsed:Float):Void { super.update(elapsed); updateNotes(); } /** * Get a list of notes within + or - the given strumtime. * @param strumTime The current time. * @param hitWindow The hit window to check. */ public function getNotesInRange(strumTime:Float, hitWindow:Float):Array { var hitWindowStart:Float = strumTime - hitWindow; var hitWindowEnd:Float = strumTime + hitWindow; return notes.members.filter(function(note:NoteSprite) { return note != null && note.alive && !note.hasBeenHit && note.strumTime >= hitWindowStart && note.strumTime <= hitWindowEnd; }); } public function getHoldNotesInRange(strumTime:Float, hitWindow:Float):Array { var hitWindowStart:Float = strumTime - hitWindow; var hitWindowEnd:Float = strumTime + hitWindow; return holdNotes.members.filter(function(note:SustainTrail) { return note != null && note.alive && note.strumTime >= hitWindowStart && (note.strumTime + note.fullSustainLength) <= hitWindowEnd; }); } public function getNoteSprite(noteData:SongNoteData):NoteSprite { if (noteData == null) return null; for (note in notes.members) { if (note == null) continue; if (note.alive) continue; if (note.noteData == noteData) return note; } return null; } public function getHoldNoteSprite(noteData:SongNoteData):SustainTrail { if (noteData == null || ((noteData.length ?? 0.0) <= 0.0)) return null; for (holdNote in holdNotes.members) { if (holdNote == null) continue; if (holdNote.alive) continue; if (holdNote.noteData == noteData) return holdNote; } return null; } /** * For a note's strumTime, calculate its Y position relative to the strumline. * NOTE: Assumes Conductor and PlayState are both initialized. * @param strumTime * @return Float */ static function calculateNoteYPos(strumTime:Float, ?vwoosh:Bool = true):Float { // Make the note move faster visually as it moves offscreen. var vwoosh:Float = (strumTime < Conductor.songPosition) && vwoosh ? 2.0 : 1.0; var scrollSpeed:Float = PlayState.instance?.currentChart?.scrollSpeed ?? 1.0; return Conductor.PIXELS_PER_MS * (Conductor.songPosition - strumTime) * scrollSpeed * vwoosh * (PreferencesMenu.getPref('downscroll') ? 1 : -1); } function updateNotes():Void { if (noteData.length == 0) return; var renderWindowStart:Float = Conductor.songPosition + RENDER_DISTANCE_MS; for (noteIndex in nextNoteIndex...noteData.length) { var note:Null = noteData[noteIndex]; if (note == null) continue; if (note.time > renderWindowStart) break; buildNoteSprite(note); if (note.length > 0) { buildHoldNoteSprite(note); } nextNoteIndex++; // Increment the nextNoteIndex rather than splicing the array, because splicing is slow. } // Update rendering of notes. for (note in notes.members) { if (note == null || note.hasBeenHit) continue; note.y = this.y - INITIAL_OFFSET + calculateNoteYPos(note.strumTime); // Check if the note is outside the hit window, and if so, mark it as missed. // TODO: Check to make sure this doesn't happen when the note is on screen because it'll probably get deleted. if (Conductor.songPosition > (note.noteData.time + Conductor.HIT_WINDOW_MS)) { note.visible = false; note.hasMissed = true; if (note.holdNoteSprite != null) note.holdNoteSprite.missedNote = true; } else { note.visible = true; note.hasMissed = false; if (note.holdNoteSprite != null) note.holdNoteSprite.missedNote = false; } } // Update rendering of hold notes. for (holdNote in holdNotes.members) { if (holdNote == null || !holdNote.alive) continue; var renderWindowEnd = holdNote.strumTime + holdNote.fullSustainLength + Conductor.HIT_WINDOW_MS + RENDER_DISTANCE_MS / 8; if (holdNote.missedNote && Conductor.songPosition >= renderWindowEnd) { // Hold note is offscreen, kill it. holdNote.visible = false; holdNote.kill(); // Do not destroy! Recycling is faster. } else if (holdNote.hitNote && holdNote.sustainLength <= 0) { // Hold note is completed, kill it. playStatic(holdNote.noteDirection); holdNote.visible = false; holdNote.kill(); } else if (holdNote.hitNote && holdNote.sustainLength <= 10) { // TODO: Better handle the weird edge case where the hold note is almost completed. holdNote.visible = false; } else if (Conductor.songPosition > holdNote.strumTime && holdNote.hitNote) { // Hold note is currently being hit, clip it off. holdConfirm(holdNote.noteDirection); holdNote.visible = true; holdNote.sustainLength = (holdNote.strumTime + holdNote.fullSustainLength) - Conductor.songPosition; if (PreferencesMenu.getPref('downscroll')) { holdNote.y = this.y - holdNote.height + STRUMLINE_SIZE / 2; } else { holdNote.y = this.y - INITIAL_OFFSET + STRUMLINE_SIZE / 2; } } else if (holdNote.missedNote && (holdNote.fullSustainLength > holdNote.sustainLength)) { // Hold note was dropped before completing, keep it in its clipped state. holdNote.visible = true; var yOffset:Float = (holdNote.fullSustainLength - holdNote.sustainLength) * Conductor.PIXELS_PER_MS; trace('yOffset: ' + yOffset); trace('holdNote.fullSustainLength: ' + holdNote.fullSustainLength); trace('holdNote.sustainLength: ' + holdNote.sustainLength); if (PreferencesMenu.getPref('downscroll')) { holdNote.y = this.y + calculateNoteYPos(holdNote.strumTime) - holdNote.height + STRUMLINE_SIZE / 2; } else { holdNote.y = this.y - INITIAL_OFFSET + calculateNoteYPos(holdNote.strumTime) + yOffset + STRUMLINE_SIZE / 2; } } else { // Hold note is new, render it normally. holdNote.visible = true; if (PreferencesMenu.getPref('downscroll')) { holdNote.y = this.y + calculateNoteYPos(holdNote.strumTime, false) - holdNote.height + STRUMLINE_SIZE / 2; } else { holdNote.y = this.y - INITIAL_OFFSET + calculateNoteYPos(holdNote.strumTime, false) + STRUMLINE_SIZE / 2; } } } } public function onBeatHit():Void { if (notes.members.length > 1) notes.members.insertionSort(compareNoteSprites.bind(FlxSort.ASCENDING)); if (holdNotes.members.length > 1) holdNotes.members.insertionSort(compareHoldNoteSprites.bind(FlxSort.ASCENDING)); } public function applyNoteData(data:Array):Void { this.notes.clear(); this.noteData = data.copy(); this.nextNoteIndex = 0; // Sort the notes by strumtime. this.noteData.insertionSort(compareNoteData.bind(FlxSort.ASCENDING)); } public function hitNote(note:NoteSprite):Void { playConfirm(note.direction); note.hasBeenHit = true; killNote(note); if (note.holdNoteSprite != null) { note.holdNoteSprite.hitNote = true; note.holdNoteSprite.missedNote = false; note.holdNoteSprite.alpha = 1.0; } } public function killNote(note:NoteSprite):Void { note.visible = false; notes.remove(note, false); note.kill(); if (note.holdNoteSprite != null) { note.holdNoteSprite.missedNote = true; note.holdNoteSprite.alpha = 0.6; } } public function getByIndex(index:Int):StrumlineNote { return this.strumlineNotes.members[index]; } public function getByDirection(direction:NoteDirection):StrumlineNote { return getByIndex(DIRECTIONS.indexOf(direction)); } public function playStatic(direction:NoteDirection):Void { getByDirection(direction).playStatic(); } public function playPress(direction:NoteDirection):Void { getByDirection(direction).playPress(); } public function playConfirm(direction:NoteDirection):Void { getByDirection(direction).playConfirm(); } public function holdConfirm(direction:NoteDirection):Void { getByDirection(direction).holdConfirm(); } public function isConfirm(direction:NoteDirection):Bool { return getByDirection(direction).isConfirm(); } public function playNoteSplash(direction:NoteDirection):Void { // TODO: Add a setting to disable note splashes. // if (Settings.noSplash) return; var splash:NoteSplash = this.constructNoteSplash(); if (splash != null) { splash.play(direction); splash.x = this.x; splash.x += getXPos(direction); splash.x += INITIAL_OFFSET; splash.y = this.y; splash.y -= INITIAL_OFFSET; splash.y += 0; } } public function buildNoteSprite(note:SongNoteData):Void { var noteSprite:NoteSprite = constructNoteSprite(); if (noteSprite != null) { noteSprite.strumTime = note.time; noteSprite.direction = note.getDirection(); noteSprite.noteData = note; noteSprite.x = this.x; noteSprite.x += getXPos(DIRECTIONS[note.getDirection() % KEY_COUNT]); noteSprite.x -= NUDGE; // noteSprite.x += INITIAL_OFFSET; noteSprite.y = -9999; } } public function buildHoldNoteSprite(note:SongNoteData):Void { var holdNoteSprite:SustainTrail = constructHoldNoteSprite(); if (holdNoteSprite != null) { holdNoteSprite.noteData = note; holdNoteSprite.strumTime = note.time; holdNoteSprite.noteDirection = note.getDirection(); holdNoteSprite.fullSustainLength = note.length; holdNoteSprite.sustainLength = note.length; holdNoteSprite.missedNote = false; holdNoteSprite.hitNote = false; holdNoteSprite.x = this.x; holdNoteSprite.x += getXPos(DIRECTIONS[note.getDirection() % KEY_COUNT]); // holdNoteSprite.x += INITIAL_OFFSET; holdNoteSprite.x += STRUMLINE_SIZE / 2; holdNoteSprite.x -= holdNoteSprite.width / 2; holdNoteSprite.y = -9999; } } /** * Custom recycling behavior. */ function constructNoteSplash():NoteSplash { var result:NoteSplash = null; // If we haven't filled the pool yet... if (noteSplashes.length < noteSplashes.maxSize) { // Create a new note splash. result = new NoteSplash(); this.noteSplashes.add(result); } else { // Else, find a note splash which is inactive so we can revive it. result = this.noteSplashes.getFirstAvailable(); if (result != null) { result.revive(); } else { // The note splash pool is full and all note splashes are active, // so we just pick one at random to destroy and restart. result = FlxG.random.getObject(this.noteSplashes.members); } } return result; } /** * Custom recycling behavior. */ function constructNoteSprite():NoteSprite { var result:NoteSprite = null; // Else, find a note which is inactive so we can revive it. result = this.notes.getFirstAvailable(); if (result != null) { // Revive and reuse the note. result.revive(); } else { // The note sprite pool is full and all note splashes are active. // We have to create a new note. result = new NoteSprite(); this.notes.add(result); } return result; } /** * Custom recycling behavior. */ function constructHoldNoteSprite():SustainTrail { var result:SustainTrail = null; // Else, find a note which is inactive so we can revive it. result = this.holdNotes.getFirstAvailable(); if (result != null) { // Revive and reuse the note. result.revive(); } else { // The note sprite pool is full and all note splashes are active. // We have to create a new note. result = new SustainTrail(0, 100, Paths.image("NOTE_hold_assets")); this.holdNotes.add(result); } return result; } function getXPos(direction:NoteDirection):Float { return switch (direction) { case NoteDirection.LEFT: 0; case NoteDirection.DOWN: 0 + (1 * Strumline.NOTE_SPACING); case NoteDirection.UP: 0 + (2 * Strumline.NOTE_SPACING); case NoteDirection.RIGHT: 0 + (3 * Strumline.NOTE_SPACING); default: 0; } } /** * Apply a small animation which moves the arrow down and fades it in. * Only plays at the start of Free Play songs. * * Note that modifying the offset of the whole strumline won't have the * @param arrow The arrow to animate. * @param index The index of the arrow in the strumline. */ function fadeInArrow(arrow:StrumlineNote):Void { arrow.y -= 10; arrow.alpha = 0; FlxTween.tween(arrow, {y: arrow.y + 10, alpha: 1}, 1, {ease: FlxEase.circOut, startDelay: 0.5 + (0.2 * arrow.ID)}); } public function fadeInArrows():Void { for (arrow in this.strumlineNotes) { fadeInArrow(arrow); } } function compareNoteData(order:Int, a:SongNoteData, b:SongNoteData):Int { return FlxSort.byValues(order, a.time, b.time); } function compareNoteSprites(order:Int, a:NoteSprite, b:NoteSprite):Int { return FlxSort.byValues(order, a?.strumTime, b?.strumTime); } function compareHoldNoteSprites(order:Int, a:SustainTrail, b:SustainTrail):Int { return FlxSort.byValues(order, a?.strumTime, b?.strumTime); } }