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.graphics.FunkinSprite; import funkin.play.notes.NoteHoldCover; import funkin.play.notes.NoteSplash; import funkin.play.notes.NoteSprite; import funkin.play.notes.SustainTrail; import funkin.play.notes.NoteVibrationsHandler; import funkin.data.song.SongData.SongNoteData; import funkin.util.SortUtil; import funkin.util.GRhythmUtil; import funkin.play.notes.notekind.NoteKind; import funkin.play.notes.notekind.NoteKindManager; import flixel.math.FlxPoint; #if mobile import funkin.mobile.input.ControlsHandler; import funkin.mobile.ui.FunkinHitbox.FunkinHitboxControlSchemes; #end /** * A group of sprites which handles the receptor, the note splashes, and the notes (with sustains) for a given player. */ class Strumline extends FlxSpriteGroup { /** * The directions of the notes on the strumline, in order. */ public static final DIRECTIONS:Array = [NoteDirection.LEFT, NoteDirection.DOWN, NoteDirection.UP, NoteDirection.RIGHT]; /** * A magic number for the size of the strumline, in pixels. */ public static final STRUMLINE_SIZE:Int = 104; /** * The spacing between notes on the strumline, in pixels. */ public static final NOTE_SPACING:Int = STRUMLINE_SIZE + 8; // Positional fixes for new strumline graphics. static final INITIAL_OFFSET:Float = -0.275 * STRUMLINE_SIZE; static final NUDGE:Float = 2.0; static final KEY_COUNT:Int = 4; static final NOTE_SPLASH_CAP:Int = 6; var renderDistanceMs(get, never):Float; /** * The custom render distance for the strumline. * This should be in miliseconds only! Not pixels. */ public var customRenderDistanceMs:Float = 0.0; /** * Whether to use the custom render distance. * If false, the render distance will be calculated based on the screen height. */ public var useCustomRenderDistance:Bool = false; function get_renderDistanceMs():Float { if (useCustomRenderDistance) return customRenderDistanceMs; // Only divide by lower scroll speeds to fix renderDistance being too short. Dividing by higher scroll speeds breaks the input system by hitting later notes first! return FlxG.height / Constants.PIXELS_PER_MS / (scrollSpeed < 1 ? scrollSpeed : 1); } /** * Whether to play note splashes or not * TODO: Make this a setting! * IE: Settings.noSplash */ public var showNotesplash:Bool = true; /** * 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; /** * Reset the scroll speed to the current chart's scroll speed. */ public function resetScrollSpeed(?newScrollSpeed:Float):Void { scrollSpeed = newScrollSpeed ?? 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; } /** * Whether the game should auto position notes. */ public var customPositionData:Bool = false; /** * 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; /** * The hold notes currently being rendered on the strumline. * This group iterates over this every frame to update hold note positions. * The PlayState also iterates over this to calculate user inputs. */ public var holdNotes:FlxTypedSpriteGroup; /** * A signal that is dispatched when a note is spawned and heading towards the strumline. */ public var onNoteIncoming:FlxTypedSignalVoid>; var background:FunkinSprite; var strumlineNotes:FlxTypedSpriteGroup; var noteSplashes:FlxTypedSpriteGroup; var noteHoldCovers:FlxTypedSpriteGroup; var notesVwoosh:FlxTypedSpriteGroup; var holdNotesVwoosh:FlxTypedSpriteGroup; final noteStyle:NoteStyle; var noteSpacingScale:Float = 1; public var strumlineScale(default, null):FlxPoint; #if FEATURE_GHOST_TAPPING var ghostTapTimer:Float = 0.0; #end public var noteVibrations:NoteVibrationsHandler = new NoteVibrationsHandler(); final inArrowContorlSchemeMode:Bool = #if mobile (Preferences.controlsScheme == FunkinHitboxControlSchemes.Arrows && !ControlsHandler.usingExternalInputDevice) #else false #end; public var isDownscroll:Bool = #if mobile (Preferences.controlsScheme == FunkinHitboxControlSchemes.Arrows && !ControlsHandler.usingExternalInputDevice) || #end Preferences.downscroll; /** * The note data for the song. Should NOT be altered after the song starts (but we alter it in OffsetState :DDD), * so we can easily rewind. */ public var noteData:Array = []; /** * The index of the next note to be rendered. * This is used to avoid splicing the noteData array, which is slow. * It is incremented every time a note is rendered. */ public var nextNoteIndex:Int = -1; var heldKeys:Array = []; static final BACKGROUND_PAD:Int = 16; public function new(noteStyle:NoteStyle, isPlayer:Bool, ?scrollSpeed:Float) { 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); var backgroundWidth:Float = KEY_COUNT * Strumline.NOTE_SPACING + BACKGROUND_PAD * 2; #if mobile if (inArrowContorlSchemeMode && isPlayer) { backgroundWidth = backgroundWidth * 1.84; } #end this.background = new FunkinSprite(0, 0).makeSolidColor(Std.int(backgroundWidth), FlxG.height, 0xFF000000); // Convert the percent to a number between 0 and 1. this.background.alpha = Preferences.strumlineBackgroundOpacity / 100.0; this.background.scrollFactor.set(0, 0); this.background.x = -BACKGROUND_PAD; #if mobile if (inArrowContorlSchemeMode && isPlayer) this.background.x -= 100; #end this.add(this.background); strumlineScale = new FlxCallbackPoint(strumlineScaleCallback); this.refresh(); this.onNoteIncoming = new FlxTypedSignalVoid>(); resetScrollSpeed(scrollSpeed); 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); } strumlineScale.set(1, 1); // This MUST be true for children to update! this.active = true; } override function set_y(value:Float):Float { super.set_y(value); // Keep the background on the screen. if (this.background != null) this.background.y = 0; return value; } override function set_alpha(value:Float):Float { super.set_alpha(value); this.background.alpha = Preferences.strumlineBackgroundOpacity / 100.0 * alpha; return value; } /** * Refresh the strumline, sorting its children by z-index. */ public function refresh():Void { sort(SortUtil.byZIndex, FlxSort.ASCENDING); } override function get_width():Float { if (strumlineScale == null) strumlineScale = new FlxCallbackPoint(strumlineScaleCallback); return KEY_COUNT * Strumline.NOTE_SPACING * noteSpacingScale * strumlineScale.x; } public override function update(elapsed:Float):Void { super.update(elapsed); updateNotes(); #if FEATURE_GHOST_TAPPING updateGhostTapTimer(elapsed); #end } #if FEATURE_GHOST_TAPPING /** * @return `true` if no notes are in range of the strumline and the player can spam without penalty. */ public function mayGhostTap():Bool { // Any notes in range of the strumline. if (getNotesMayHit().length > 0) { return false; } // Any hold notes in range of the strumline. if (getHoldNotesHitOrMissed().length > 0) { return false; } // Note has been hit recently. if (ghostTapTimer > 0.0) return false; // **yippee** return true; } #end /** * 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); }); } /** * Get a note sprite corresponding to the given note data. * @param target The note data for the note sprite. * @return The note sprite. */ public function getNoteSprite(target:SongNoteData):NoteSprite { if (target == null) return null; for (note in notes.members) { if (note == null) continue; if (note.alive) continue; if (note.noteData == target) return note; } return null; } /** * Get a hold note sprite corresponding to the given note data. * @param target The note data for the hold note. * @return The hold note sprite. */ public function getHoldNoteSprite(target:SongNoteData):SustainTrail { if (target == null || ((target.length ?? 0.0) <= 0.0)) return null; for (holdNote in holdNotes.members) { if (holdNote == null) continue; if (holdNote.alive) continue; if (holdNote.noteData == target) return holdNote; } return null; } /** * Call this when resetting the playstate. */ public function vwooshNotes():Void { var vwooshTime:Float = 0.5; 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 (isDownscroll) targetY = 0 - note.height; FlxTween.tween(note, {y: targetY}, vwooshTime, { 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 (isDownscroll) targetY = 0 - holdNote.height; FlxTween.tween(holdNote, {y: targetY}, vwooshTime, { ease: FlxEase.expoIn, onComplete: function(twn) { holdNote.kill(); holdNotesVwoosh.remove(holdNote, true); holdNote.destroy(); } }); } } /** * Enter mini mode, which displays only small strumline notes * @param scale scale of strumline */ public function enterMiniMode(scale:Float = 1) { forEach(function(obj:flixel.FlxObject):Void { if (obj != strumlineNotes) obj.visible = false; }); this.strumlineScale.set(scale, scale); } public function strumlineScaleCallback(Scale:FlxPoint) { strumlineNotes.forEach(function(note:StrumlineNote):Void { var styleScale = noteStyle.getStrumlineScale(); note.scale.set(styleScale * Scale.x, styleScale * Scale.y); }); setNoteSpacing(noteSpacingScale); } /** * Set note spacing scale * @param multiplier multiply x position */ public function setNoteSpacing(multiplier:Float = 1):Void { noteSpacingScale = multiplier; for (i in 0...KEY_COUNT) { var direction = Strumline.DIRECTIONS[i]; var note = getByDirection(direction); note.x = getXPos(DIRECTIONS[i]) + this.strumlineNotes.x; note.x += INITIAL_OFFSET; note.y = this.strumlineNotes.y; noteStyle.applyStrumlineOffsets(note); } } /** * 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 * Reverse of vwooshNotes, we bring the notes IN (by their offsets) */ public function vwooshInNotes():Void { var vwooshTime:Float = 0.5; for (note in notes.members) { if (note == null) continue; if (!note.alive) continue; note.yOffset = 200; if (isDownscroll) { note.yOffset = -200; } FlxTween.tween(note, {yOffset: 0}, vwooshTime, { ease: FlxEase.expoOut, onComplete: function(twn) { note.yOffset = 0; } }); } for (holdNote in holdNotes.members) { if (holdNote == null) continue; if (!holdNote.alive) continue; holdNote.yOffset = 200; if (isDownscroll) { holdNote.yOffset = -200; } FlxTween.tween(holdNote, {yOffset: 0}, vwooshTime, { ease: FlxEase.expoOut, onComplete: function(twn) { holdNote.yOffset = 0; } }); } } public 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 + renderDistanceMs; 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; // trace("Strumline: Skipping note at index " + noteIndex + " with strum time " + note.time); continue; } if (note.time > renderWindowStart) break; // Note is too far ahead to render // trace("Strumline: Rendering note at index " + noteIndex + " with strum time " + note.time); var noteSprite: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; // Set the note's position. if (!customPositionData) note.y = this.y - INITIAL_OFFSET + GRhythmUtil.getNoteY(note.strumTime, scrollSpeed, isDownscroll, conductorInUse) + note.yOffset; // If the note is miss var isOffscreen:Bool = isDownscroll ? 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. } } final magicNumberIGuess:Float = 8; var renderWindowEnd:Float = holdNote.strumTime + holdNote.fullSustainLength + Constants.HIT_WINDOW_MS + (renderDistanceMs / magicNumberIGuess); 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) { if (isPlayer) { // Hold note's final vibration. noteVibrations.tryHoldNoteVibration(true); } // 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; if (!customPositionData) { if (isDownscroll) { holdNote.y = this.y - INITIAL_OFFSET + GRhythmUtil.getNoteY(holdNote.strumTime, scrollSpeed, isDownscroll, conductorInUse) - holdNote.height + STRUMLINE_SIZE / 2 + holdNote.yOffset; } else { holdNote.y = this.y - INITIAL_OFFSET + GRhythmUtil.getNoteY(holdNote.strumTime, scrollSpeed, isDownscroll, conductorInUse) + yOffset + STRUMLINE_SIZE / 2 + holdNote.yOffset; } } // 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 (!customPositionData) { if (isDownscroll) { 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; if (!customPositionData) { if (isDownscroll) { holdNote.y = this.y - INITIAL_OFFSET + GRhythmUtil.getNoteY(holdNote.strumTime, scrollSpeed, isDownscroll, conductorInUse) - holdNote.height + STRUMLINE_SIZE / 2 + holdNote.yOffset; } else { holdNote.y = this.y - INITIAL_OFFSET + GRhythmUtil.getNoteY(holdNote.strumTime, scrollSpeed, isDownscroll, conductorInUse) + STRUMLINE_SIZE / 2 + holdNote.yOffset; } } } } // Update rendering of pressed keys. for (dir in DIRECTIONS) { if (isKeyHeld(dir) && getByDirection(dir).getCurrentAnimation() == "static") { playPress(dir); } // Added this to prevent sustained vibrations not ending issue. if (!isKeyHeld(dir) && isPlayer) noteVibrations.noteStatuses[dir] = NoteStatus.idle; } } /** * Return notes that are within, or way after, `Constants.HIT_WINDOW` ms of the strumline. * @return An array of `NoteSprite` objects. */ public function getNotesOnScreen():Array { return notes.members.filter(function(note:NoteSprite) { return note != null && note.alive && !note.hasBeenHit; }); } #if FEATURE_GHOST_TAPPING function updateGhostTapTimer(elapsed:Float):Void { // If it's still our turn, don't update the ghost tap timer. if (getNotesOnScreen().length > 0) return; ghostTapTimer -= elapsed; if (ghostTapTimer <= 0) { ghostTapTimer = 0; } } #end /** * 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; } /** * Called on each beat of the song. */ public function onBeatHit():Void { // why are we doing this every beat? >:( if (notes.members.length > 1) notes.members.insertionSort(compareNoteSprites.bind(FlxSort.ASCENDING)); if (holdNotes.members.length > 1) holdNotes.members.insertionSort(compareHoldNoteSprites.bind(FlxSort.ASCENDING)); } /** * Called when a key is pressed. * @param dir The direction of the key that was pressed. */ public function pressKey(dir:NoteDirection):Void { heldKeys[dir] = true; } /** * Called when a key is released. * @param dir The direction of the key that was released. */ public function releaseKey(dir:NoteDirection):Void { heldKeys[dir] = false; } /** * Check if a key is held down. * @param dir The direction of the key to check. * @return `true` if the key is held down, `false` otherwise. */ 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(); #if FEATURE_GHOST_TAPPING ghostTapTimer = 0; #end } /** * Apply note data from a chart to this strumline. * Note data should be valid and apply only to this strumline. * @param data The note data to apply. */ 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)); } /** * Add a note data to the strumline. * This will not remove existing notes, so you should call `applyNoteData` if you want to reset the strumline. * @param note The note data to add. * @param sort Whether to sort the note data after adding. */ public function addNoteData(note:SongNoteData, sort:Bool = true):Void { if (note == null) return; this.noteData.push(note); if (sort) this.noteData.sort(compareNoteData.bind(FlxSort.ASCENDING)); } /** * Hit a note. * @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; } #if FEATURE_GHOST_TAPPING ghostTapTimer = Constants.GHOST_TAP_DELAY; #end } /** * Kill a note heading towards the strumline. * @param note The note to kill. Gets recycled and reused for performance. */ public function killNote(note:NoteSprite):Void { if (note == null) return; note.visible = false; note.kill(); if (note.holdNoteSprite != null) { note.holdNoteSprite.missedNote = true; note.holdNoteSprite.visible = false; } } /** * Get a strumline note sprite by its index. * @param index The index of the note to get. * @return The note. */ public function getByIndex(index:Int):StrumlineNote { return this.strumlineNotes.members[index]; } /** * Get a strumline note sprite by its direction. * @param direction The direction of the note to get. * @return The note. */ public function getByDirection(direction:NoteDirection):StrumlineNote { return getByIndex(DIRECTIONS.indexOf(direction)); } /** * Play a static animation for a given direction. * @param direction The direction of the note to play the static animation for. */ public function playStatic(direction:NoteDirection):Void { getByDirection(direction).playStatic(); if (isPlayer) noteVibrations.noteStatuses[direction] = NoteStatus.idle; } /** * Play a press animation for a given direction. * @param direction The direction of the note to play the press animation for. */ public function playPress(direction:NoteDirection):Void { getByDirection(direction).playPress(); if (isPlayer) noteVibrations.noteStatuses[direction] = NoteStatus.pressed; } /** * Play a confirm animation for a given direction. * @param direction The direction of the note to play the confirm animation for. */ public function playConfirm(direction:NoteDirection):Void { getByDirection(direction).playConfirm(); if (isPlayer) noteVibrations.noteStatuses[direction] = NoteStatus.confirm; } /** * Play a confirm animation for a hold note. * @param direction The direction of the note to play the confirm animation for. */ public function holdConfirm(direction:NoteDirection):Void { getByDirection(direction).holdConfirm(); if (isPlayer) noteVibrations.noteStatuses[direction] = NoteStatus.holdConfirm; } /** * Check if a given direction is playing the confirm animation. * @param direction The direction of the note to check. * @return `true` if the note is playing the confirm animation, `false` otherwise. */ public function isConfirm(direction:NoteDirection):Bool { return getByDirection(direction).isConfirm(); } /** * Play a note splash for a given direction. * @param direction The direction of the note to play the splash animation for. */ public function playNoteSplash(direction:NoteDirection):Void { if (!showNotesplash) 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.x += noteStyle.getSplashOffsets()[0] * splash.scale.x; splash.y = this.y; splash.y -= INITIAL_OFFSET; splash.y += noteStyle.getSplashOffsets()[1] * splash.scale.y; } } /** * Play a note hold cover for a given hold note. * @param holdNote The hold note to play the cover animation for. */ public function playNoteHoldCover(holdNote:SustainTrail):Void { if (!showNotesplash) 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 += noteStyle.getHoldCoverOffsets()[0] * cover.scale.x; cover.x += -12; // hardcoded adjustment, because we are evil. cover.y = this.y; cover.y += INITIAL_OFFSET; cover.y += STRUMLINE_SIZE / 2; cover.y += noteStyle.getHoldCoverOffsets()[1] * cover.scale.y; cover.y += -96; // hardcoded adjustment, because we are evil. } } /** * Build a note sprite for a given note data. * @param note The note data to build the note sprite for. * @return The note sprite. Will recycle a note sprite from the pool if available for performance. */ public function buildNoteSprite(note:SongNoteData):NoteSprite { var noteSprite:NoteSprite = constructNoteSprite(); if (noteSprite != null) { var noteKind:NoteKind = NoteKindManager.getNoteKind(note.kind); var noteKindStyle:NoteStyle = NoteKindManager.getNoteStyle(note.kind, this.noteStyle.id) ?? this.noteStyle; noteSprite.setupNoteGraphic(noteKindStyle); var trueScale = new FlxPoint(strumlineScale.x, strumlineScale.y); #if mobile if (inArrowContorlSchemeMode) { final amplification:Float = (FlxG.width / FlxG.height) / (FlxG.initialWidth / FlxG.initialHeight); trueScale.set(strumlineScale.x - ((FlxG.height / FlxG.width) * 0.2) * amplification, strumlineScale.y - ((FlxG.height / FlxG.width) * 0.2) * amplification); } #end noteSprite.scale.scale(trueScale.x, trueScale.y); noteSprite.updateHitbox(); noteSprite.direction = note.getDirection(); noteSprite.noteData = note; noteSprite.x = this.x; noteSprite.x += getXPos(DIRECTIONS[note.getDirection() % KEY_COUNT]); noteSprite.x -= (noteSprite.width - Strumline.STRUMLINE_SIZE) / 2; // Center it noteSprite.x -= NUDGE; noteSprite.y = -9999; if (noteKind != null) noteSprite.scoreable = noteKind.scoreable; } return noteSprite; } /** * Build a hold note sprite for a given note data. * @param note The note data to build the hold note sprite for. * @return The hold note sprite. Will recycle a hold note sprite from the pool if available for performance. */ public function buildHoldNoteSprite(note:SongNoteData):SustainTrail { var holdNoteSprite:SustainTrail = constructHoldNoteSprite(); if (holdNoteSprite != null) { var noteKindStyle:NoteStyle = NoteKindManager.getNoteStyle(note.kind, this.noteStyle.id); if (noteKindStyle == null) noteKindStyle = NoteKindManager.getNoteStyle(note.kind, null); if (noteKindStyle == null) noteKindStyle = 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 for note splashes. */ 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(noteStyle); 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 for note hold covers. */ 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(noteStyle); 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 for note sprites. */ 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 for hold note sprites. */ 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 { var pos:Float = 0; #if mobile if (inArrowContorlSchemeMode && isPlayer) pos = 35 * (FlxG.width / FlxG.height) / (FlxG.initialWidth / FlxG.initialHeight); #end return switch (direction) { case NoteDirection.LEFT: -pos * 2; case NoteDirection.DOWN: -(pos * 2) + (1 * Strumline.NOTE_SPACING) * (noteSpacingScale * strumlineScale.x); case NoteDirection.UP: pos + (2 * Strumline.NOTE_SPACING) * (noteSpacingScale * strumlineScale.x); case NoteDirection.RIGHT: pos + (3 * Strumline.NOTE_SPACING) * (noteSpacingScale * strumlineScale.x); default: -pos * 2; } } /** * 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)}); } /** * Apply a small animation which moves the arrow up and fades it out. * Used when the song ends in Freeplay mode. * * @param index The index of the arrow in the strumline. * @param arrow The arrow to animate. */ public function fadeOutArrow(index:Int, arrow:StrumlineNote):Void { FlxTween.tween(arrow, {y: arrow.y - 10, alpha: 0}, 0.5, {ease: FlxEase.circIn}); } /** * Play a fade in animation on all arrows in the strumline. * Used when starting a song in Freeplay mode. */ public function fadeInArrows():Void { for (index => arrow in this.strumlineNotes.members.keyValueIterator()) { fadeInArrow(index, arrow); } } /** * Play a fade out animation on all arrows in the strumline. * Used when ending a song in Freeplay mode. */ public function fadeOutArrows():Void { for (index => arrow in this.strumlineNotes.members.keyValueIterator()) { fadeOutArrow(index, arrow); } } /** * Compare two note data objects by their strumtime. * @param order The order to sort the notes in. * @param a The first note data object. * @param b The second note data object. * @return The comparison result, based on the time of the notes. */ function compareNoteData(order:Int, a:SongNoteData, b:SongNoteData):Int { return FlxSort.byValues(order, a.time, b.time); } /** * Compare two note sprites by their strumtime. * @param order The order to sort the notes in. * @param a The first note sprite. * @param b The second note sprite. * @return The comparison result, based on the time of the notes. */ function compareNoteSprites(order:Int, a:NoteSprite, b:NoteSprite):Int { return FlxSort.byValues(order, a?.strumTime, b?.strumTime); } /** * Compare two hold note sprites by their strumtime. * @param order The order to sort the notes in. * @param a The first hold note sprite. * @param b The second hold note sprite. * @return The comparison result, based on the time of the notes. */ function compareHoldNoteSprites(order:Int, a:SustainTrail, b:SustainTrail):Int { return FlxSort.byValues(order, a?.strumTime, b?.strumTime); } /** * Find the minimum Y position of the strumline. * Ignores the background to ensure the strumline is positioned correctly. * @return The minimum Y position of the strumline. */ override function findMinYHelper():Float { var value:Float = Math.POSITIVE_INFINITY; for (member in group.members) { if (member == null) continue; // SKIP THE BACKGROUND if (member == this.background) continue; var minY:Float; if (member.flixelType == SPRITEGROUP) { minY = (cast member : FlxSpriteGroup).findMinY(); } else { minY = member.y; } if (minY < value) value = minY; } return value; } /** * Find the maximum Y position of the strumline. * Ignores the background to ensure the strumline is positioned correctly. * @return The maximum Y position of the strumline. */ override function findMaxYHelper():Float { var value:Float = Math.NEGATIVE_INFINITY; for (member in group.members) { if (member == null) continue; // SKIP THE BACKGROUND if (member == this.background) continue; var maxY:Float; if (member.flixelType == SPRITEGROUP) { maxY = (cast member : FlxSpriteGroup).findMaxY(); } else { maxY = member.y + member.height; } if (maxY > value) value = maxY; } return value; } }