package funkin.play.notes; import flixel.util.FlxSignal.FlxTypedSignal; import flixel.FlxG; import funkin.play.notes.notestyle.NoteStyle; import flixel.group.FlxSpriteGroup; import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup; import flixel.tweens.FlxEase; import flixel.tweens.FlxTween; import flixel.util.FlxSort; import funkin.play.notes.NoteHoldCover; import funkin.play.notes.NoteSplash; import funkin.play.notes.NoteSprite; import funkin.play.notes.SustainTrail; import funkin.data.song.SongData.SongNoteData; import funkin.ui.options.PreferencesMenu; import funkin.util.SortUtil; import funkin.modding.events.ScriptEvent; import funkin.play.notes.notekind.NoteKindManager; /** * 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 = 104; 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, never):Float; static function get_RENDER_DISTANCE_MS():Float { return FlxG.height / Constants.PIXELS_PER_MS; } /** * Whether this strumline is controlled by the player's inputs. * False means it's controlled by the opponent or Bot Play. */ public var isPlayer:Bool; /** * Usually you want to keep this as is, but if you are using a Strumline and * playing a sound that has it's own conductor, set this (LatencyState for example) */ public var conductorInUse(get, set):Conductor; // Used in-game to control the scroll speed within a song public var scrollSpeed:Float = 1.0; public function resetScrollSpeed():Void { scrollSpeed = PlayState.instance?.currentChart?.scrollSpeed ?? 1.0; } var _conductorInUse:Null; function get_conductorInUse():Conductor { if (_conductorInUse == null) return Conductor.instance; return _conductorInUse; } function set_conductorInUse(value:Conductor):Conductor { return _conductorInUse = value; } /** * 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; public var onNoteIncoming:FlxTypedSignalVoid>; var strumlineNotes:FlxTypedSpriteGroup; var noteSplashes:FlxTypedSpriteGroup; var noteHoldCovers:FlxTypedSpriteGroup; var notesVwoosh:FlxTypedSpriteGroup; var holdNotesVwoosh:FlxTypedSpriteGroup; final noteStyle:NoteStyle; /** * The note data for the song. Should NOT be altered after the song starts, * so we can easily rewind. */ var noteData:Array = []; var nextNoteIndex:Int = -1; var heldKeys:Array = []; public function new(noteStyle:NoteStyle, isPlayer:Bool) { super(); this.isPlayer = isPlayer; this.noteStyle = noteStyle; this.strumlineNotes = new FlxTypedSpriteGroup(); this.strumlineNotes.zIndex = 10; this.add(this.strumlineNotes); // Hold notes are added first so they render behind regular notes. this.holdNotes = new FlxTypedSpriteGroup(); this.holdNotes.zIndex = 20; this.add(this.holdNotes); this.holdNotesVwoosh = new FlxTypedSpriteGroup(); this.holdNotesVwoosh.zIndex = 21; this.add(this.holdNotesVwoosh); this.notes = new FlxTypedSpriteGroup(); this.notes.zIndex = 30; this.add(this.notes); this.notesVwoosh = new FlxTypedSpriteGroup(); this.notesVwoosh.zIndex = 31; this.add(this.notesVwoosh); this.noteHoldCovers = new FlxTypedSpriteGroup(0, 0, 4); this.noteHoldCovers.zIndex = 40; this.add(this.noteHoldCovers); this.noteSplashes = new FlxTypedSpriteGroup(0, 0, NOTE_SPLASH_CAP); this.noteSplashes.zIndex = 50; this.add(this.noteSplashes); this.refresh(); this.onNoteIncoming = new FlxTypedSignalVoid>(); resetScrollSpeed(); for (i in 0...KEY_COUNT) { var child:StrumlineNote = new StrumlineNote(noteStyle, isPlayer, DIRECTIONS[i]); child.x = getXPos(DIRECTIONS[i]); child.x += INITIAL_OFFSET; child.y = 0; noteStyle.applyStrumlineOffsets(child); this.strumlineNotes.add(child); } for (i in 0...KEY_COUNT) { heldKeys.push(false); } // This MUST be true for children to update! this.active = true; } public function refresh():Void { sort(SortUtil.byZIndex, FlxSort.ASCENDING); } override function get_width():Float { return KEY_COUNT * Strumline.NOTE_SPACING; } public override function update(elapsed:Float):Void { super.update(elapsed); updateNotes(); } /** * Returns `true` if no notes are in range of the strumline and the player can spam without penalty. */ public function mayGhostTap():Bool { // TODO: Refine this. Only querying "can be hit" is too tight but "is being rendered" is too loose. // Also, if you just hit a note, there should be a (short) period where this is off so you can't spam. // If there are any notes on screen, we can't ghost tap. return notes.members.filter(function(note:NoteSprite) { return note != null && note.alive && !note.hasBeenHit; }).length == 0; } /** * Return notes that are within `Constants.HIT_WINDOW` ms of the strumline. * @return An array of `NoteSprite` objects. */ public function getNotesMayHit():Array { return notes.members.filter(function(note:NoteSprite) { return note != null && note.alive && !note.hasBeenHit && note.mayHit; }); } /** * Return hold notes that are within `Constants.HIT_WINDOW` ms of the strumline. * @return An array of `SustainTrail` objects. */ public function getHoldNotesHitOrMissed():Array { return holdNotes.members.filter(function(holdNote:SustainTrail) { return holdNote != null && holdNote.alive && (holdNote.hitNote || holdNote.missedNote); }); } 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; } /** * Call this when resetting the playstate. */ public function vwooshNotes():Void { for (note in notes.members) { if (note == null) continue; if (!note.alive) continue; notes.remove(note); notesVwoosh.add(note); var targetY:Float = FlxG.height + note.y; if (Preferences.downscroll) targetY = 0 - note.height; FlxTween.tween(note, {y: targetY}, 0.5, { ease: FlxEase.expoIn, onComplete: function(twn) { note.kill(); notesVwoosh.remove(note, true); note.destroy(); } }); } for (holdNote in holdNotes.members) { if (holdNote == null) continue; if (!holdNote.alive) continue; holdNotes.remove(holdNote); holdNotesVwoosh.add(holdNote); var targetY:Float = FlxG.height + holdNote.y; if (Preferences.downscroll) targetY = 0 - holdNote.height; FlxTween.tween(holdNote, {y: targetY}, 0.5, { ease: FlxEase.expoIn, onComplete: function(twn) { holdNote.kill(); holdNotesVwoosh.remove(holdNote, true); holdNote.destroy(); } }); } } /** * 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 */ public 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; // ^^^ commented this out... do NOT make it move faster as it moves offscreen! var vwoosh:Float = 1.0; return Constants.PIXELS_PER_MS * (conductorInUse.songPosition - strumTime - Conductor.instance.inputOffset) * scrollSpeed * vwoosh * (Preferences.downscroll ? 1 : -1); } function updateNotes():Void { if (noteData.length == 0) return; // Ensure note data gets reset if the song happens to loop. // NOTE: I had to remove this line because it was causing notes visible during the countdown to be placed multiple times. // I don't remember what bug I was trying to fix by adding this. // if (conductorInUse.currentStep == 0) nextNoteIndex = 0; var songStart:Float = PlayState.instance?.startTimestamp ?? 0.0; var hitWindowStart:Float = conductorInUse.songPosition - Constants.HIT_WINDOW_MS; var renderWindowStart:Float = conductorInUse.songPosition + RENDER_DISTANCE_MS; for (noteIndex in nextNoteIndex...noteData.length) { var note:Null = noteData[noteIndex]; if (note == null) continue; // Note is blank if (note.time < songStart || note.time < hitWindowStart) { // Note is in the past, skip it. nextNoteIndex = noteIndex + 1; continue; } if (note.time > renderWindowStart) break; // Note is too far ahead to render var noteSprite = buildNoteSprite(note); if (note.length > 0) { noteSprite.holdNoteSprite = buildHoldNoteSprite(note); } nextNoteIndex = noteIndex + 1; // Increment the nextNoteIndex rather than splicing the array, because splicing is slow. onNoteIncoming.dispatch(noteSprite); } // Update rendering of notes. for (note in notes.members) { if (note == null || !note.alive) continue; var vwoosh:Bool = note.holdNoteSprite == null; // Set the note's position. note.y = this.y - INITIAL_OFFSET + calculateNoteYPos(note.strumTime, vwoosh); // If the note is miss var isOffscreen = Preferences.downscroll ? note.y > FlxG.height : note.y < -note.height; if (note.handledMiss && isOffscreen) { killNote(note); } } // Update rendering of hold notes. for (holdNote in holdNotes.members) { if (holdNote == null || !holdNote.alive) continue; if (conductorInUse.songPosition > holdNote.strumTime && holdNote.hitNote && !holdNote.missedNote) { if (isPlayer && !isKeyHeld(holdNote.noteDirection)) { // Stopped pressing the hold note. playStatic(holdNote.noteDirection); holdNote.missedNote = true; holdNote.visible = true; holdNote.alpha = 0.0; // Completely hide the dropped hold note. } } var renderWindowEnd = holdNote.strumTime + holdNote.fullSustainLength + Constants.HIT_WINDOW_MS + RENDER_DISTANCE_MS / 8; if (holdNote.missedNote && conductorInUse.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. if (isKeyHeld(holdNote.noteDirection)) { playPress(holdNote.noteDirection); } else { playStatic(holdNote.noteDirection); } if (holdNote.cover != null && isPlayer) { holdNote.cover.playEnd(); } else if (holdNote.cover != null) { // *lightning* *zap* *crackle* holdNote.cover.visible = false; holdNote.cover.kill(); } holdNote.visible = false; holdNote.kill(); } 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) * Constants.PIXELS_PER_MS; var vwoosh:Bool = false; if (Preferences.downscroll) { holdNote.y = this.y - INITIAL_OFFSET + calculateNoteYPos(holdNote.strumTime, vwoosh) - holdNote.height + STRUMLINE_SIZE / 2; } else { holdNote.y = this.y - INITIAL_OFFSET + calculateNoteYPos(holdNote.strumTime, vwoosh) + yOffset + STRUMLINE_SIZE / 2; } // Clean up the cover. if (holdNote.cover != null) { holdNote.cover.visible = false; holdNote.cover.kill(); } } else if (conductorInUse.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) - conductorInUse.songPosition; if (holdNote.sustainLength <= 10) { holdNote.visible = false; } if (Preferences.downscroll) { holdNote.y = this.y - INITIAL_OFFSET - holdNote.height + STRUMLINE_SIZE / 2; } else { holdNote.y = this.y - INITIAL_OFFSET + STRUMLINE_SIZE / 2; } } else { // Hold note is new, render it normally. holdNote.visible = true; var vwoosh:Bool = false; if (Preferences.downscroll) { holdNote.y = this.y - INITIAL_OFFSET + calculateNoteYPos(holdNote.strumTime, vwoosh) - holdNote.height + STRUMLINE_SIZE / 2; } else { holdNote.y = this.y - INITIAL_OFFSET + calculateNoteYPos(holdNote.strumTime, vwoosh) + STRUMLINE_SIZE / 2; } } } // Update rendering of pressed keys. for (dir in DIRECTIONS) { if (isKeyHeld(dir) && getByDirection(dir).getCurrentAnimation() == "static") { playPress(dir); } } } /** * Called when the PlayState skips a large amount of time forward or backward. */ public function handleSkippedNotes():Void { // By calling clean(), we remove all existing notes so they can be re-added. clean(); // By setting noteIndex to 0, the next update will skip past all the notes that are in the past. nextNoteIndex = 0; } 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 pressKey(dir:NoteDirection):Void { heldKeys[dir] = true; } public function releaseKey(dir:NoteDirection):Void { heldKeys[dir] = false; } public function isKeyHeld(dir:NoteDirection):Bool { return heldKeys[dir]; } /** * Called when the song is reset. * Removes any special animations and the like. * Doesn't reset the notes from the chart, that's handled by the PlayState. */ public function clean():Void { for (note in notes.members) { if (note == null) continue; killNote(note); } for (holdNote in holdNotes.members) { if (holdNote == null) continue; holdNote.kill(); } for (splash in noteSplashes) { if (splash == null) continue; splash.kill(); } for (cover in noteHoldCovers) { if (cover == null) continue; cover.kill(); } heldKeys = [false, false, false, false]; for (dir in DIRECTIONS) { playStatic(dir); } resetScrollSpeed(); } 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)); } /** * @param note The note to hit. * @param removeNote True to remove the note immediately, false to make it transparent and let it move offscreen. */ public function hitNote(note:NoteSprite, removeNote:Bool = true):Void { playConfirm(note.direction); note.hasBeenHit = true; if (removeNote) { killNote(note); } else { note.alpha = 0.5; note.desaturate(); } if (note.holdNoteSprite != null) { note.holdNoteSprite.hitNote = true; note.holdNoteSprite.missedNote = false; note.holdNoteSprite.sustainLength = (note.holdNoteSprite.strumTime + note.holdNoteSprite.fullSustainLength) - conductorInUse.songPosition; } } public function killNote(note:NoteSprite):Void { if (note == null) return; note.visible = false; notes.remove(note, false); note.kill(); if (note.holdNoteSprite != null) { note.holdNoteSprite.missedNote = true; note.holdNoteSprite.visible = false; } } 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; if (!noteStyle.isNoteSplashEnabled()) 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 playNoteHoldCover(holdNote:SustainTrail):Void { // TODO: Add a setting to disable note splashes. // if (Settings.noSplash) return; if (!noteStyle.isHoldNoteCoverEnabled()) return; var cover:NoteHoldCover = this.constructNoteHoldCover(); if (cover != null) { cover.holdNote = holdNote; holdNote.cover = cover; cover.visible = true; cover.playStart(); cover.x = this.x; cover.x += getXPos(holdNote.noteDirection); cover.x += STRUMLINE_SIZE / 2; cover.x -= cover.width / 2; cover.x += -12; // Manual tweaking because fuck. cover.y = this.y; cover.y += INITIAL_OFFSET; cover.y += STRUMLINE_SIZE / 2; cover.y += -96; // Manual tweaking because fuck. } } public function buildNoteSprite(note:SongNoteData):NoteSprite { var noteSprite:NoteSprite = constructNoteSprite(); if (noteSprite != null) { var noteKindStyle:NoteStyle = NoteKindManager.getNoteStyle(note.kind, this.noteStyle.isHoldNotePixel()) ?? this.noteStyle; noteSprite.setupNoteGraphic(noteKindStyle); 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; } return noteSprite; } public function buildHoldNoteSprite(note:SongNoteData):SustainTrail { var holdNoteSprite:SustainTrail = constructHoldNoteSprite(); if (holdNoteSprite != null) { var noteKindStyle:NoteStyle = NoteKindManager.getNoteStyle(note.kind, this.noteStyle.isHoldNotePixel()) ?? this.noteStyle; holdNoteSprite.setupHoldNoteGraphic(noteKindStyle); holdNoteSprite.parentStrumline = this; 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.visible = true; holdNoteSprite.alpha = 1.0; holdNoteSprite.x = this.x; holdNoteSprite.x += getXPos(DIRECTIONS[note.getDirection() % KEY_COUNT]); holdNoteSprite.x += STRUMLINE_SIZE / 2; holdNoteSprite.x -= holdNoteSprite.width / 2; holdNoteSprite.y = -9999; } return holdNoteSprite; } /** * 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 constructNoteHoldCover():NoteHoldCover { var result:NoteHoldCover = null; // If we haven't filled the pool yet... if (noteHoldCovers.length < noteHoldCovers.maxSize) { // Create a new note hold cover. result = new NoteHoldCover(); this.noteHoldCovers.add(result); } else { // Else, find a note splash which is inactive so we can revive it. result = this.noteHoldCovers.getFirstAvailable(); if (result != null) { result.revive(); } else { // The note hold cover pool is full and all note hold covers are active, // so we just pick one at random to destroy and restart. result = FlxG.random.getObject(this.noteHoldCovers.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(noteStyle); 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, 0, noteStyle); 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(index:Int, arrow:StrumlineNote):Void { arrow.y -= 10; arrow.alpha = 0.0; FlxTween.tween(arrow, {y: arrow.y + 10, alpha: 1}, 1, {ease: FlxEase.circOut, startDelay: 0.5 + (0.2 * index)}); } public function fadeInArrows():Void { for (index => arrow in this.strumlineNotes.members.keyValueIterator()) { fadeInArrow(index, 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); } }