diff --git a/assets b/assets index 8573b2039b..2e169b80e4 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit 8573b2039b9fb37551a41f5195aaa2abdc63770e +Subproject commit 2e169b80e4dfa2df722390de4ff8c78b7281d813 diff --git a/hmm.json b/hmm.json index c6fb67eba4..b49e871569 100644 --- a/hmm.json +++ b/hmm.json @@ -59,9 +59,7 @@ "type": "git", "dir": null, "ref": "22b1ce089dd924f15cdc4632397ef3504d464e90", - "url": "https://github.com/FunkinCrew/funkVis" - }, { "name": "grig.audio", diff --git a/source/Main.hx b/source/Main.hx index 2426fa0d99..475fa9234d 100644 --- a/source/Main.hx +++ b/source/Main.hx @@ -26,7 +26,7 @@ class Main extends Sprite var framerate:Int = 60; // How many frames per second the game should run at. #else // TODO: This should probably be in the options menu? - var framerate:Int = 144; // How many frames per second the game should run at. + var framerate:Int = 60; // How many frames per second the game should run at. #end var skipSplash:Bool = true; // Whether to skip the flixel splash screen that appears in release mode. var startFullscreen:Bool = false; // Whether to start the game in fullscreen on desktop targets diff --git a/source/funkin/Preferences.hx b/source/funkin/Preferences.hx index b2050c6a24..6624a7f34a 100644 --- a/source/funkin/Preferences.hx +++ b/source/funkin/Preferences.hx @@ -128,6 +128,26 @@ class Preferences return value; } + /** + * If >0, the game will display a semi-opaque background under the notes. + * `0` for no background, `100` for solid black if you're freaky like that + * @default `0` + */ + public static var strumlineBackgroundOpacity(get, set):Int; + + static function get_strumlineBackgroundOpacity():Int + { + return (Save?.instance?.options?.strumlineBackgroundOpacity ?? 0); + } + + static function set_strumlineBackgroundOpacity(value:Int):Int + { + var save:Save = Save.instance; + save.options.strumlineBackgroundOpacity = value; + save.flush(); + return value; + } + /** * Loads the user's preferences from the save data and apply them. */ diff --git a/source/funkin/data/freeplay/player/PlayerData.hx b/source/funkin/data/freeplay/player/PlayerData.hx index f6c0850180..fd5a9ebc39 100644 --- a/source/funkin/data/freeplay/player/PlayerData.hx +++ b/source/funkin/data/freeplay/player/PlayerData.hx @@ -38,6 +38,13 @@ class PlayerData @:optional public var freeplayDJ:Null = null; + /** + * Data for displaying this character in the Character Select menu. + * If null, exclude from Character Select. + */ + @:optional + public var charSelect:Null = null; + public var results:Null = null; /** @@ -97,6 +104,9 @@ class PlayerFreeplayDJData @:optional var cartoon:Null; + @:optional + var fistPump:Null; + public function new() { animationMap = new Map(); @@ -183,6 +193,58 @@ class PlayerFreeplayDJData { return cartoon?.channelChangeFrame ?? 60; } + + public function getFistPumpIntroStartFrame():Int + { + return fistPump?.introStartFrame ?? 0; + } + + public function getFistPumpIntroEndFrame():Int + { + return fistPump?.introEndFrame ?? 0; + } + + public function getFistPumpLoopStartFrame():Int + { + return fistPump?.loopStartFrame ?? 0; + } + + public function getFistPumpLoopEndFrame():Int + { + return fistPump?.loopEndFrame ?? 0; + } + + public function getFistPumpIntroBadStartFrame():Int + { + return fistPump?.introBadStartFrame ?? 0; + } + + public function getFistPumpIntroBadEndFrame():Int + { + return fistPump?.introBadEndFrame ?? 0; + } + + public function getFistPumpLoopBadStartFrame():Int + { + return fistPump?.loopBadStartFrame ?? 0; + } + + public function getFistPumpLoopBadEndFrame():Int + { + return fistPump?.loopBadEndFrame ?? 0; + } +} + +class PlayerCharSelectData +{ + /** + * A zero-indexed number for the character's preferred position in the grid. + * 0 = top left, 4 = center, 8 = bottom right + * In the event of a conflict, the first character alphabetically gets it, + * and others get shifted over. + */ + @:optional + public var position:Null; } typedef PlayerResultsData = @@ -242,3 +304,30 @@ typedef PlayerFreeplayDJCartoonData = var loopFrame:Int; var channelChangeFrame:Int; } + +typedef PlayerFreeplayDJFistPumpData = +{ + @:default(0) + var introStartFrame:Int; // ":0, + + @:default(4) + var introEndFrame:Int; // ":4, + + @:default(4) + var loopStartFrame:Int; // ":4, + + @:default(-1) + var loopEndFrame:Int; // ":-1, + + @:default(0) + var introBadStartFrame:Int; // ":0, + + @:default(4) + var introBadEndFrame:Int; // ":4, + + @:default(4) + var loopBadStartFrame:Int; // ":4, + + @:default(-1) + var loopBadEndFrame:Int; // ":-1 +}; diff --git a/source/funkin/data/freeplay/player/PlayerRegistry.hx b/source/funkin/data/freeplay/player/PlayerRegistry.hx index be8730ccd2..c0a15ed1c3 100644 --- a/source/funkin/data/freeplay/player/PlayerRegistry.hx +++ b/source/funkin/data/freeplay/player/PlayerRegistry.hx @@ -3,6 +3,7 @@ package funkin.data.freeplay.player; import funkin.data.freeplay.player.PlayerData; import funkin.ui.freeplay.charselect.PlayableCharacter; import funkin.ui.freeplay.charselect.ScriptedPlayableCharacter; +import funkin.save.Save; class PlayerRegistry extends BaseRegistry { @@ -53,6 +54,41 @@ class PlayerRegistry extends BaseRegistry log('Loaded ${countEntries()} playable characters with ${ownedCharacterIds.size()} associations.'); } + public function countUnlockedCharacters():Int + { + var count = 0; + + for (charId in listEntryIds()) + { + var player = fetchEntry(charId); + if (player == null) continue; + + if (player.isUnlocked()) count++; + } + + return count; + } + + public function hasNewCharacter():Bool + { + var characters = Save.instance.charactersSeen.clone(); + + for (charId in listEntryIds()) + { + var player = fetchEntry(charId); + if (player == null) continue; + + if (!player.isUnlocked()) continue; + if (characters.contains(charId)) continue; + + // This character is unlocked but we haven't seen them in Freeplay yet. + return true; + } + + // Fallthrough case. + return false; + } + /** * Get the playable character associated with a given stage character. * @param characterId The stage character ID. diff --git a/source/funkin/data/song/CHANGELOG.md b/source/funkin/data/song/CHANGELOG.md index ca36a1d6dd..17f44b5421 100644 --- a/source/funkin/data/song/CHANGELOG.md +++ b/source/funkin/data/song/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [2.2.4] ### Added +- Added `offsets.altVocals` field to apply vocal offsets when alternate instrumentals are used. - Added `playData.characters.opponentVocals` to specify which vocal track(s) to play for the opponent. - If the value isn't present, it will use the `playData.characters.opponent`, but if it is present, it will be used (even if it's empty, in which case no vocals will be used for the opponent) - Added `playData.characters.playerVocals` to specify which vocal track(s) to play for the player. diff --git a/source/funkin/data/song/SongData.hx b/source/funkin/data/song/SongData.hx index f487eb54dc..074ed0b440 100644 --- a/source/funkin/data/song/SongData.hx +++ b/source/funkin/data/song/SongData.hx @@ -257,18 +257,27 @@ class SongOffsets implements ICloneable public var altInstrumentals:Map; /** - * The offset, in milliseconds, to apply to the song's vocals, relative to the chart. + * The offset, in milliseconds, to apply to the song's vocals, relative to the song's base instrumental. * These are applied ON TOP OF the instrumental offset. */ @:optional @:default([]) public var vocals:Map; - public function new(instrumental:Float = 0.0, ?altInstrumentals:Map, ?vocals:Map) + /** + * The offset, in milliseconds, to apply to the songs vocals, relative to each alternate instrumental. + * This is useful for the circumstance where, for example, an alt instrumental has a few seconds of lead in before the song starts. + */ + @:optional + @:default([]) + public var altVocals:Map>; + + public function new(instrumental:Float = 0.0, ?altInstrumentals:Map, ?vocals:Map, ?altVocals:Map>) { this.instrumental = instrumental; this.altInstrumentals = altInstrumentals == null ? new Map() : altInstrumentals; this.vocals = vocals == null ? new Map() : vocals; + this.altVocals = altVocals == null ? new Map>() : altVocals; } public function getInstrumentalOffset(?instrumental:String):Float @@ -293,11 +302,19 @@ class SongOffsets implements ICloneable return value; } - public function getVocalOffset(charId:String):Float + public function getVocalOffset(charId:String, ?instrumental:String):Float { - if (!this.vocals.exists(charId)) return 0.0; - - return this.vocals.get(charId); + if (instrumental == null) + { + if (!this.vocals.exists(charId)) return 0.0; + return this.vocals.get(charId); + } + else + { + if (!this.altVocals.exists(instrumental)) return 0.0; + if (!this.altVocals.get(instrumental).exists(charId)) return 0.0; + return this.altVocals.get(instrumental).get(charId); + } } public function setVocalOffset(charId:String, value:Float):Float @@ -320,7 +337,7 @@ class SongOffsets implements ICloneable */ public function toString():String { - return 'SongOffsets(${this.instrumental}ms, ${this.altInstrumentals}, ${this.vocals})'; + return 'SongOffsets(${this.instrumental}ms, ${this.altInstrumentals}, ${this.vocals}, ${this.altVocals})'; } } diff --git a/source/funkin/data/song/SongRegistry.hx b/source/funkin/data/song/SongRegistry.hx index a3305c4ecc..e7cab246c6 100644 --- a/source/funkin/data/song/SongRegistry.hx +++ b/source/funkin/data/song/SongRegistry.hx @@ -20,7 +20,7 @@ class SongRegistry extends BaseRegistry * Handle breaking changes by incrementing this value * and adding migration to the `migrateStageData()` function. */ - public static final SONG_METADATA_VERSION:thx.semver.Version = "2.2.3"; + public static final SONG_METADATA_VERSION:thx.semver.Version = "2.2.4"; public static final SONG_METADATA_VERSION_RULE:thx.semver.VersionRule = "2.2.x"; diff --git a/source/funkin/graphics/adobeanimate/FlxAtlasSprite.hx b/source/funkin/graphics/adobeanimate/FlxAtlasSprite.hx index 194584992f..f03d9e8b50 100644 --- a/source/funkin/graphics/adobeanimate/FlxAtlasSprite.hx +++ b/source/funkin/graphics/adobeanimate/FlxAtlasSprite.hx @@ -4,6 +4,7 @@ import flixel.util.FlxSignal.FlxTypedSignal; import flxanimate.FlxAnimate; import flxanimate.FlxAnimate.Settings; import flxanimate.frames.FlxAnimateFrames; +import haxe.extern.EitherType; import flixel.graphics.frames.FlxFrame; import flixel.system.FlxAssets.FlxGraphicAsset; import openfl.display.BitmapData; @@ -104,23 +105,6 @@ class FlxAtlasSprite extends FlxAnimate return this.currentAnimation; } - /** - * `anim.finished` always returns false on looping animations, - * but this function will return true if we are on the last frame of the looping animation. - */ - public function isLoopFinished():Bool - { - if (this.anim == null) return false; - if (!this.anim.isPlaying) return false; - - // Reverse animation finished. - if (this.anim.reversed && this.anim.curFrame == 0) return true; - // Forward animation finished. - if (!this.anim.reversed && this.anim.curFrame >= (this.anim.length - 1)) return true; - - return false; - } - /** * Plays an animation. * @param id A string ID of the animation to play. @@ -133,7 +117,7 @@ class FlxAtlasSprite extends FlxAnimate public function playAnimation(id:String, restart:Bool = false, ignoreOther:Bool = false, loop:Bool = false, startFrame:Int = 0):Void { // Skip if not allowed to play animations. - if ((!canPlayOtherAnims && !ignoreOther)) return; + if ((!canPlayOtherAnims && !ignoreOther) || (anim == null)) return; if (id == null || id == '') id = this.currentAnimation; @@ -178,10 +162,17 @@ class FlxAtlasSprite extends FlxAnimate if (ignoreOther) canPlayOtherAnims = false; // Move to the first frame of the animation. - // goToFrameLabel(id); trace('Playing animation $id'); - this.anim.play(id, restart, false, startFrame); - goToFrameLabel(id); + // FlxG.log.notice('Playing animation $id'); + if (this.anim.symbolDictionary.exists(id) || (this.anim.getByName(id) != null)) + { + this.anim.play(id, restart, false, startFrame); + } + if (getFrameLabelNames().indexOf(id) != -1) + { + goToFrameLabel(id); + } + anim.curFrame += startFrame; this.currentAnimation = id; } @@ -205,6 +196,8 @@ class FlxAtlasSprite extends FlxAnimate */ public function isLoopComplete():Bool { + if (this.anim == null) return false; + if (!this.anim.isPlaying) return false; return (anim.reversed && anim.curFrame == 0 || !(anim.reversed) && (anim.curFrame) >= (anim.length - 1)); } @@ -231,6 +224,18 @@ class FlxAtlasSprite extends FlxAnimate this.anim.goToFrameLabel(label); } + function getFrameLabelNames(?layer:EitherType = null) + { + var labels = this.anim.getFrameLabels(layer); + var array = []; + for (label in labels) + { + array.push(label.name); + } + + return array; + } + function getNextFrameLabel(label:String):String { return listAnimations()[(getLabelIndex(label) + 1) % listAnimations().length]; @@ -301,7 +306,7 @@ class FlxAtlasSprite extends FlxAnimate public function getBasePosition():Null { - var stagePos = new FlxPoint(anim.stageInstance.matrix.tx, anim.stageInstance.matrix.ty); + // var stagePos = new FlxPoint(anim.stageInstance.matrix.tx, anim.stageInstance.matrix.ty); var instancePos = new FlxPoint(anim.curInstance.matrix.tx, anim.curInstance.matrix.ty); var firstElement = anim.curSymbol.timeline?.get(0)?.get(0)?.get(0); if (firstElement == null) return instancePos; diff --git a/source/funkin/modding/PolymodHandler.hx b/source/funkin/modding/PolymodHandler.hx index 52d4624cbf..b94b93e74e 100644 --- a/source/funkin/modding/PolymodHandler.hx +++ b/source/funkin/modding/PolymodHandler.hx @@ -273,6 +273,8 @@ class PolymodHandler // Literally just has a private `resolveClass` function for some reason? Polymod.blacklistImport('lime.utils.Assets'); Polymod.blacklistImport('openfl.utils.Assets'); + Polymod.blacklistImport('openfl.Lib'); + Polymod.blacklistImport('openfl.system.ApplicationDomain'); // `openfl.desktop.NativeProcess` // Can load native processes on the host operating system. diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx index 928f392e5a..3b51f08a9f 100644 --- a/source/funkin/play/PlayState.hx +++ b/source/funkin/play/PlayState.hx @@ -668,7 +668,7 @@ class PlayState extends MusicBeatSubState // Prepare the current song's instrumental and vocals to be played. if (!overrideMusic && currentChart != null) { - currentChart.cacheInst(); + currentChart.cacheInst(currentInstrumental); currentChart.cacheVocals(); } @@ -677,7 +677,7 @@ class PlayState extends MusicBeatSubState if (currentChart.offsets != null) { - Conductor.instance.instrumentalOffset = currentChart.offsets.getInstrumentalOffset(); + Conductor.instance.instrumentalOffset = currentChart.offsets.getInstrumentalOffset(currentInstrumental); } Conductor.instance.mapTimeChanges(currentChart.timeChanges); @@ -860,7 +860,7 @@ class PlayState extends MusicBeatSubState { // Stop the vocals if they already exist. if (vocals != null) vocals.stop(); - vocals = currentChart.buildVocals(); + vocals = currentChart.buildVocals(currentInstrumental); if (vocals.members.length == 0) { @@ -1791,7 +1791,7 @@ class PlayState extends MusicBeatSubState { // Stop the vocals if they already exist. if (vocals != null) vocals.stop(); - vocals = currentChart.buildVocals(); + vocals = currentChart.buildVocals(currentInstrumental); if (vocals.members.length == 0) { @@ -1979,7 +1979,7 @@ class PlayState extends MusicBeatSubState if (vocals == null) return; // Skip this if the music is paused (GameOver, Pause menu, start-of-song offset, etc.) - if (!FlxG.sound.music.playing) return; + if (!(FlxG.sound.music?.playing ?? false)) return; var timeToPlayAt:Float = Conductor.instance.songPosition - Conductor.instance.instrumentalOffset; FlxG.sound.music.pause(); vocals.pause(); @@ -2223,8 +2223,13 @@ class PlayState extends MusicBeatSubState // Judge the miss. // NOTE: This is what handles the scoring. - trace('Missed note! ${note.noteData}'); - onNoteMiss(note, event.playSound, event.healthChange); + + // Skip handling the miss in botplay! + if (!isBotPlayMode) + { + trace('Missed note! ${note.noteData}'); + onNoteMiss(note, event.playSound, event.healthChange); + } note.handledMiss = true; } @@ -2321,9 +2326,16 @@ class PlayState extends MusicBeatSubState playerStrumline.pressKey(input.noteDirection); + // Don't credit or penalize inputs in Bot Play. + if (isBotPlayMode) continue; + var notesInDirection:Array = notesByDirection[input.noteDirection]; - if (!Constants.GHOST_TAPPING && notesInDirection.length == 0) + #if FEATURE_GHOST_TAPPING + if ((!playerStrumline.mayGhostTap()) && notesInDirection.length == 0) + #else + if (notesInDirection.length == 0) + #end { // Pressed a wrong key with no notes nearby. // Perform a ghost miss (anti-spam). @@ -2333,41 +2345,31 @@ class PlayState extends MusicBeatSubState playerStrumline.playPress(input.noteDirection); trace('PENALTY Score: ${songScore}'); } - else if (Constants.GHOST_TAPPING && (!playerStrumline.mayGhostTap()) && notesInDirection.length == 0) - { - // Pressed a wrong key with notes visible on-screen. - // Perform a ghost miss (anti-spam). - ghostNoteMiss(input.noteDirection, notesInRange.length > 0); - - // Play the strumline animation. - playerStrumline.playPress(input.noteDirection); - trace('PENALTY Score: ${songScore}'); - } - else if (notesInDirection.length == 0) - { - // Press a key with no penalty. + else if (notesInDirection.length == 0) + { + // Press a key with no penalty. - // Play the strumline animation. - playerStrumline.playPress(input.noteDirection); - trace('NO PENALTY Score: ${songScore}'); - } - else - { - // Choose the first note, deprioritizing low priority notes. - var targetNote:Null = notesInDirection.find((note) -> !note.lowPriority); - if (targetNote == null) targetNote = notesInDirection[0]; - if (targetNote == null) continue; + // Play the strumline animation. + playerStrumline.playPress(input.noteDirection); + trace('NO PENALTY Score: ${songScore}'); + } + else + { + // Choose the first note, deprioritizing low priority notes. + var targetNote:Null = notesInDirection.find((note) -> !note.lowPriority); + if (targetNote == null) targetNote = notesInDirection[0]; + if (targetNote == null) continue; - // Judge and hit the note. - trace('Hit note! ${targetNote.noteData}'); - goodNoteHit(targetNote, input); - trace('Score: ${songScore}'); + // Judge and hit the note. + trace('Hit note! ${targetNote.noteData}'); + goodNoteHit(targetNote, input); + trace('Score: ${songScore}'); - notesInDirection.remove(targetNote); + notesInDirection.remove(targetNote); - // Play the strumline animation. - playerStrumline.playConfirm(input.noteDirection); - } + // Play the strumline animation. + playerStrumline.playConfirm(input.noteDirection); + } } while (inputReleaseQueue.length > 0) diff --git a/source/funkin/play/character/BaseCharacter.hx b/source/funkin/play/character/BaseCharacter.hx index 55b849bcbc..dc456ed50e 100644 --- a/source/funkin/play/character/BaseCharacter.hx +++ b/source/funkin/play/character/BaseCharacter.hx @@ -366,12 +366,19 @@ class BaseCharacter extends Bopper // and Darnell (this keeps the flame on his lighter flickering). // Works for idle, singLEFT/RIGHT/UP/DOWN, alt singing animations, and anything else really. - if (!getCurrentAnimation().endsWith(Constants.ANIMATION_HOLD_SUFFIX) - && hasAnimation(getCurrentAnimation() + Constants.ANIMATION_HOLD_SUFFIX) - && isAnimationFinished()) + if (isAnimationFinished() + && !getCurrentAnimation().endsWith(Constants.ANIMATION_HOLD_SUFFIX) + && hasAnimation(getCurrentAnimation() + Constants.ANIMATION_HOLD_SUFFIX)) { playAnimation(getCurrentAnimation() + Constants.ANIMATION_HOLD_SUFFIX); } + else + { + if (isAnimationFinished()) + { + // trace('Not playing hold (${getCurrentAnimation()}) (${isAnimationFinished()}, ${getCurrentAnimation().endsWith(Constants.ANIMATION_HOLD_SUFFIX)}, ${hasAnimation(getCurrentAnimation() + Constants.ANIMATION_HOLD_SUFFIX)})'); + } + } // Handle character note hold time. if (isSinging()) @@ -627,6 +634,7 @@ class BaseCharacter extends Bopper var anim:String = 'sing${dir.nameUpper}${miss ? 'miss' : ''}${suffix != '' ? '-${suffix}' : ''}'; // restart even if already playing, because the character might sing the same note twice. + trace('Playing ${anim}...'); playAnimation(anim, true); } diff --git a/source/funkin/play/character/CharacterData.hx b/source/funkin/play/character/CharacterData.hx index d447eb97f2..bac2c7141e 100644 --- a/source/funkin/play/character/CharacterData.hx +++ b/source/funkin/play/character/CharacterData.hx @@ -305,6 +305,8 @@ class CharacterDataParser icon = "darnell"; case "senpai-angry": icon = "senpai"; + case "spooky-dark": + icon = "spooky"; case "tankman-atlas": icon = "tankman"; } diff --git a/source/funkin/play/event/ZoomCameraSongEvent.hx b/source/funkin/play/event/ZoomCameraSongEvent.hx index 748abda198..ee2eea8ad5 100644 --- a/source/funkin/play/event/ZoomCameraSongEvent.hx +++ b/source/funkin/play/event/ZoomCameraSongEvent.hx @@ -114,7 +114,7 @@ class ZoomCameraSongEvent extends SongEvent name: 'zoom', title: 'Zoom Level', defaultValue: 1.0, - step: 0.1, + step: 0.05, type: SongEventFieldType.FLOAT, units: 'x' }, diff --git a/source/funkin/play/notes/Strumline.hx b/source/funkin/play/notes/Strumline.hx index 1e5782ad2f..fd342cce1e 100644 --- a/source/funkin/play/notes/Strumline.hx +++ b/source/funkin/play/notes/Strumline.hx @@ -8,6 +8,7 @@ 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; @@ -85,6 +86,8 @@ class Strumline extends FlxSpriteGroup public var onNoteIncoming:FlxTypedSignalVoid>; + var background:FunkinSprite; + var strumlineNotes:FlxTypedSpriteGroup; var noteSplashes:FlxTypedSpriteGroup; var noteHoldCovers:FlxTypedSpriteGroup; @@ -104,6 +107,10 @@ class Strumline extends FlxSpriteGroup var heldKeys:Array = []; + static final BACKGROUND_PAD:Int = 16; + + var ghostTapTimer:Float = 0.0; + public function new(noteStyle:NoteStyle, isPlayer:Bool) { super(); @@ -140,6 +147,13 @@ class Strumline extends FlxSpriteGroup this.noteSplashes.zIndex = 50; this.add(this.noteSplashes); + this.background = new FunkinSprite(0, 0).makeSolidColor(Std.int(this.width + BACKGROUND_PAD * 2), 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; + this.add(this.background); + this.refresh(); this.onNoteIncoming = new FlxTypedSignalVoid>(); @@ -164,6 +178,16 @@ class Strumline extends FlxSpriteGroup 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; + } + public function refresh():Void { sort(SortUtil.byZIndex, FlxSort.ASCENDING); @@ -179,6 +203,8 @@ class Strumline extends FlxSpriteGroup super.update(elapsed); updateNotes(); + + updateGhostTapTimer(elapsed); } /** @@ -189,10 +215,22 @@ class Strumline extends FlxSpriteGroup // 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; + // Any notes in range. + if (getNotesMayHit().length > 0) + { + return false; + } + // Any hold notes in range. + if (getHoldNotesHitOrMissed().length > 0) + { + return false; + } + + // We hit a note recently. + if (ghostTapTimer > 0.0) return false; + + // **yippee** + return true; } /** @@ -206,6 +244,17 @@ class Strumline extends FlxSpriteGroup }); } + /** + * 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; + }); + } + /** * Return hold notes that are within `Constants.HIT_WINDOW` ms of the strumline. * @return An array of `SustainTrail` objects. @@ -492,6 +541,21 @@ class Strumline extends FlxSpriteGroup } } + function updateGhostTapTimer(elapsed:Float):Void + { + #if FEATURE_GHOST_TAPPING + // 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. */ @@ -563,6 +627,8 @@ class Strumline extends FlxSpriteGroup playStatic(dir); } resetScrollSpeed(); + + ghostTapTimer = 0; } public function applyNoteData(data:Array):Void @@ -602,6 +668,10 @@ class Strumline extends FlxSpriteGroup note.holdNoteSprite.sustainLength = (note.holdNoteSprite.strumTime + note.holdNoteSprite.fullSustainLength) - conductorInUse.songPosition; } + + #if FEATURE_GHOST_TAPPING + ghostTapTimer = Constants.GHOST_TAP_DELAY; + #end } public function killNote(note:NoteSprite):Void @@ -925,4 +995,42 @@ class Strumline extends FlxSpriteGroup { return FlxSort.byValues(order, a?.strumTime, b?.strumTime); } + + override function findMinYHelper() + { + var value = 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; + } + + override function findMaxYHelper() + { + var value = 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; + } } diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx index 8416008485..9d35902b01 100644 --- a/source/funkin/play/song/Song.hx +++ b/source/funkin/play/song/Song.hx @@ -533,6 +533,28 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry + { + var targetDifficulty:Null = getDifficulty(difficultyId, variationId); + if (targetDifficulty == null) return []; + + return targetDifficulty?.characters?.altInstrumentals ?? []; + } + + public function getBaseInstrumentalId(difficultyId:String, variationId:String):String + { + var targetDifficulty:Null = getDifficulty(difficultyId, variationId); + if (targetDifficulty == null) return ''; + + return targetDifficulty?.characters?.instrumental ?? ''; + } + /** * Purge the cached chart data for each difficulty of this song. */ @@ -851,7 +873,7 @@ class SongDifficulty * @param charId The player ID. * @return The generated vocal group. */ - public function buildVocals():VoicesGroup + public function buildVocals(?instId:String = ''):VoicesGroup { var result:VoicesGroup = new VoicesGroup(); @@ -870,8 +892,8 @@ class SongDifficulty result.addOpponentVoice(FunkinSound.load(opponentVoice)); } - result.playerVoicesOffset = offsets.getVocalOffset(characters.player); - result.opponentVoicesOffset = offsets.getVocalOffset(characters.opponent); + result.playerVoicesOffset = offsets.getVocalOffset(characters.player, instId); + result.opponentVoicesOffset = offsets.getVocalOffset(characters.opponent, instId); return result; } diff --git a/source/funkin/play/stage/Bopper.hx b/source/funkin/play/stage/Bopper.hx index de19c51b4d..5597879aed 100644 --- a/source/funkin/play/stage/Bopper.hx +++ b/source/funkin/play/stage/Bopper.hx @@ -226,13 +226,13 @@ class Bopper extends StageProp implements IPlayStateScriptedClass // If the animation exists, we're good. if (hasAnimation(name)) return name; - FlxG.log.notice('Bopper tried to play animation "$name" that does not exist, stripping suffixes...'); + FlxG.log.notice('Bopper(${name}) tried to play animation "$name" that does not exist, stripping suffixes...'); // Attempt to strip a `-alt` suffix, if it exists. if (name.lastIndexOf('-') != -1) { var correctName = name.substring(0, name.lastIndexOf('-')); - FlxG.log.notice('Bopper tried to play animation "$name" that does not exist, stripping suffixes...'); + FlxG.log.notice('Bopper(${name}) tried to play animation "$name" that does not exist, stripping suffixes...'); return correctAnimationName(correctName); } else @@ -241,18 +241,18 @@ class Bopper extends StageProp implements IPlayStateScriptedClass { if (fallback == name) { - FlxG.log.error('Bopper tried to play animation "$name" that does not exist! This is bad!'); + FlxG.log.error('Bopper(${name}) tried to play animation "$name" that does not exist! This is bad!'); return null; } else { - FlxG.log.warn('Bopper tried to play animation "$name" that does not exist, fallback to idle...'); + FlxG.log.warn('Bopper(${name}) tried to play animation "$name" that does not exist, fallback to idle...'); return correctAnimationName('idle'); } } else { - FlxG.log.error('Bopper tried to play animation "$name" that does not exist! This is bad!'); + FlxG.log.error('Bopper(${name}) tried to play animation "$name" that does not exist! This is bad!'); return null; } } @@ -315,7 +315,7 @@ class Bopper extends StageProp implements IPlayStateScriptedClass public function isAnimationFinished():Bool { - return this.animation.finished; + return this.animation?.finished ?? false; } public function setAnimationOffsets(name:String, xOffset:Float, yOffset:Float):Void @@ -338,6 +338,7 @@ class Bopper extends StageProp implements IPlayStateScriptedClass override function getScreenPosition(?result:FlxPoint, ?camera:FlxCamera):FlxPoint { var output:FlxPoint = super.getScreenPosition(result, camera); + // Apply animation offsets and global offsets. output.x -= (animOffsets[0] - globalOffsets[0]) * this.scale.x; output.y -= (animOffsets[1] - globalOffsets[1]) * this.scale.y; return output; diff --git a/source/funkin/play/stage/Stage.hx b/source/funkin/play/stage/Stage.hx index c547b9f5f0..56c668a1e7 100644 --- a/source/funkin/play/stage/Stage.hx +++ b/source/funkin/play/stage/Stage.hx @@ -435,9 +435,9 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements // Start with the per-stage character position. // Subtracting the origin ensures characters are positioned relative to their feet. - // Subtracting the global offset allows positioning on a per-character basis. - character.x = stageCharData.position[0] - character.characterOrigin.x + character.globalOffsets[0]; - character.y = stageCharData.position[1] - character.characterOrigin.y + character.globalOffsets[1]; + // We previously applied the global offset here but that is now done elsewhere. + character.x = stageCharData.position[0] - character.characterOrigin.x; + character.y = stageCharData.position[1] - character.characterOrigin.y; @:privateAccess(funkin.play.stage.Bopper) { diff --git a/source/funkin/save/Save.hx b/source/funkin/save/Save.hx index 2900ce2be6..42585b61de 100644 --- a/source/funkin/save/Save.hx +++ b/source/funkin/save/Save.hx @@ -95,6 +95,7 @@ class Save zoomCamera: true, debugDisplay: false, autoPause: true, + strumlineBackgroundOpacity: 0, inputOffset: 0, audioVisualOffset: 0, @@ -121,6 +122,12 @@ class Save modOptions: [], }, + unlocks: + { + // Default to having seen the default character. + charactersSeen: ["bf"], + }, + optionsChartEditor: { // Reasonable defaults. @@ -393,6 +400,22 @@ class Save return data.optionsChartEditor.playbackSpeed; } + public var charactersSeen(get, never):Array; + + function get_charactersSeen():Array + { + return data.unlocks.charactersSeen; + } + + /** + * When we've seen a character unlock, add it to the list of characters seen. + * @param character + */ + public function addCharacterSeen(character:String):Void + { + data.unlocks.charactersSeen.push(character); + } + /** * Return the score the user achieved for a given level on a given difficulty. * @@ -471,10 +494,18 @@ class Save for (difficulty in difficultyList) { var score:Null = getLevelScore(levelId, difficulty); - // TODO: Do we need to check accuracy/score here? if (score != null) { - return true; + if (score.score > 0) + { + // Level has score data, which means we cleared it! + return true; + } + else + { + // Level has score data, but the score is 0. + return false; + } } } return false; @@ -630,10 +661,18 @@ class Save for (difficulty in difficultyList) { var score:Null = getSongScore(songId, difficulty); - // TODO: Do we need to check accuracy/score here? if (score != null) { - return true; + if (score.score > 0) + { + // Level has score data, which means we cleared it! + return true; + } + else + { + // Level has score data, but the score is 0. + return false; + } } } return false; @@ -956,6 +995,8 @@ typedef RawSaveData = */ var options:SaveDataOptions; + var unlocks:SaveDataUnlocks; + /** * The user's favorited songs in the Freeplay menu, * as a list of song IDs. @@ -980,6 +1021,15 @@ typedef SaveApiNewgroundsData = var sessionId:Null; } +typedef SaveDataUnlocks = +{ + /** + * Every time we see the unlock animation for a character, + * add it to this list so that we don't show it again. + */ + var charactersSeen:Array; +} + /** * An anoymous structure containing options about the user's high scores. */ @@ -1091,6 +1141,13 @@ typedef SaveDataOptions = */ var autoPause:Bool; + /** + * If >0, the game will display a semi-opaque background under the notes. + * `0` for no background, `100` for solid black if you're freaky like that + * @default `0` + */ + var strumlineBackgroundOpacity:Int; + /** * Offset the users inputs by this many ms. * @default `0` diff --git a/source/funkin/ui/PixelatedIcon.hx b/source/funkin/ui/PixelatedIcon.hx index 8d9b97d9ca..d1ea652c3b 100644 --- a/source/funkin/ui/PixelatedIcon.hx +++ b/source/funkin/ui/PixelatedIcon.hx @@ -22,14 +22,26 @@ class PixelatedIcon extends FlxSprite switch (char) { - case 'monster-christmas': - charPath += 'monsterpixel'; - case 'mom-car': - charPath += 'mommypixel'; - case 'darnell-blazin': - charPath += 'darnellpixel'; - case 'senpai-angry': - charPath += 'senpaipixel'; + case "bf-christmas" | "bf-car" | "bf-pixel" | "bf-holding-gf": + charPath += "bfpixel"; + case "monster-christmas": + charPath += "monsterpixel"; + case "mom" | "mom-car": + charPath += "mommypixel"; + case "pico-blazin" | "pico-playable" | "pico-speaker": + charPath += "picopixel"; + case "gf-christmas" | "gf-car" | "gf-pixel" | "gf-tankmen": + charPath += "gfpixel"; + case "dad": + charPath += "dadpixel"; + case "darnell-blazin": + charPath += "darnellpixel"; + case "senpai-angry": + charPath += "senpaipixel"; + case "spooky-dark": + charPath += "spookypixel"; + case "tankman-atlas": + charPath += "tankmanpixel"; default: charPath += '${char}pixel'; } diff --git a/source/funkin/ui/charSelect/CharSelectGF.hx b/source/funkin/ui/charSelect/CharSelectGF.hx index 6d8e3e657b..44a9a6088b 100644 --- a/source/funkin/ui/charSelect/CharSelectGF.hx +++ b/source/funkin/ui/charSelect/CharSelectGF.hx @@ -8,6 +8,7 @@ import funkin.util.FramesJSFLParser; import funkin.util.FramesJSFLParser.FramesJSFLInfo; import funkin.util.FramesJSFLParser.FramesJSFLFrame; import flixel.math.FlxMath; +import funkin.vis.dsp.SpectralAnalyzer; class CharSelectGF extends FlxAtlasSprite { @@ -20,14 +21,47 @@ class CharSelectGF extends FlxAtlasSprite var intendedYPos:Float = 0; var intendedAlpha:Float = 0; + var list:Array = []; + var char:String = "gf"; + + var analyzer:SpectralAnalyzer; public function new() { super(0, 0, Paths.animateAtlas("charSelect/gfChill")); anim.play(""); + list = anim.curSymbol.getFrameLabelNames(); + switchGF("bf"); } + var _addedCallback:String = ""; + + override public function playAnimation(id:String, restart:Bool = false, ignoreOther:Bool = false, loop:Bool = false, startFrame:Int = 0):Void + { + if (id == null) id = "idle"; + // var fr = anim.getFrameLabel("confirm"); + // fr.removeCallbacks(); + // fr.add(() -> trace("HEY")); + + if (id != _addedCallback) + { + var next = list[list.indexOf(_addedCallback) + 1]; + if (next != null) anim.getFrameLabel(next).removeCallbacks(); + + var index:Int = list.indexOf(id); + + _addedCallback = list[index]; + if (index != -1 && index + 1 < list.length) + { + var lb = anim.getFrameLabel(list[index + 1]); + @:privateAccess + lb.add(() -> playAnimation(list[index], true, false, false)); + } + } + super.playAnimation(id, restart, ignoreOther, loop, startFrame); + } + override public function update(elapsed:Float) { super.update(elapsed); @@ -59,6 +93,39 @@ class CharSelectGF extends FlxAtlasSprite } } + override public function draw() + { + if (analyzer != null) drawFFT(); + super.draw(); + } + + function drawFFT() + { + if (char == "nene") + { + var levels = analyzer.getLevels(); + var frame = anim.curSymbol.timeline.get("VIZ_bars").get(anim.curFrame); + var elements = frame.getList(); + var len:Int = cast Math.min(elements.length, 7); + + for (i in 0...len) + { + var animFrame:Int = Math.round(levels[i].value * 12); + + #if desktop + animFrame = Math.round(animFrame * FlxG.sound.volume); + #end + + animFrame = Math.floor(Math.min(12, animFrame)); + animFrame = Math.floor(Math.max(0, animFrame)); + + animFrame = Std.int(Math.abs(animFrame - 12)); // shitty dumbass flip, cuz dave got da shit backwards lol! + + elements[i].symbol.firstFrame = animFrame; + } + } + } + /** * @param animInfo Should not be confused with animInInfo! * This is merely a local var for the function! @@ -113,6 +180,7 @@ class CharSelectGF extends FlxAtlasSprite "gf"; } + char = str; switch str { default: @@ -123,7 +191,8 @@ class CharSelectGF extends FlxAtlasSprite animOutInfo = FramesJSFLParser.parse(Paths.file("images/charSelect/" + str + "AnimInfo/" + str + "Out.txt")); anim.play(""); - playAnimation("idle", true, false, true); + playAnimation("idle", true, false, false); + addFrameCallback(getNextFrameLabel("idle"), () -> playAnimation("idle", true, false, false)); updateHitbox(); } diff --git a/source/funkin/ui/charSelect/CharSelectPlayer.hx b/source/funkin/ui/charSelect/CharSelectPlayer.hx index 9052c60e90..8d4ce2373c 100644 --- a/source/funkin/ui/charSelect/CharSelectPlayer.hx +++ b/source/funkin/ui/charSelect/CharSelectPlayer.hx @@ -9,23 +9,87 @@ class CharSelectPlayer extends FlxAtlasSprite { super(x, y, Paths.animateAtlas("charSelect/bfChill")); + trace('Initialized CharSelectPlayer'); + + trace(listAnimations()); + onAnimationComplete.add(function(animLabel:String) { - switch (animLabel) + trace('Completed CharSelectPlayer animation: ' + animLabel); + if (hasAnimation("slidein idle point")) { - case "slidein": - if (hasAnimation("slidein idle point")) playAnimation("slidein idle point", true, false, false); - else - playAnimation("idle", true, false, true); - case "slidein idle point": - playAnimation("idle", true, false, true); - case "select": - anim.pause(); - case "deselect": - playAnimation("deselect loop start", true, false, true); + playAnimation("slidein idle point", true, false, false); + } + else + { + playAnimation("idle"); } }); } + var _addedCall = false; + + override public function playAnimation(id:String, restart:Bool = false, ignoreOther:Bool = false, loop:Bool = false, startFrame:Int = 0):Void + { + if (id == null || id == "") id = "idle"; + switch (id) + { + case "idle", "slidein idle point": + if (!_addedCall) + { + var fr = anim.getFrameLabel("idle end"); + if (fr != null) fr.add(() -> { + playAnimation("idle", true, false, false); + }); + } + _addedCall = true; + + case "select": + if (_addedCall) + { + anim.getFrameLabel("idle end").removeCallbacks(); + _addedCall = false; + } + + var fr = anim.getFrameLabel("deselect"); + + fr.add(() -> { + anim.pause(); + anim.curFrame--; + }); + + _addedCall = true; + + case "deselect": + var og = anim.getFrameLabel("deselect"); + if (_addedCall) + { + og.removeCallbacks(); + _addedCall = false; + } + + var fr = anim.getFrameLabel("deselect loop end"); + + fr.removeCallbacks(); + fr.add(() -> playAnimation("deselect loop start", true, false, false)); + + _addedCall = true; + + case "slidein", "slideout": + if (_addedCall) + { + anim.getFrameLabel("deselect loop end").removeCallbacks(); + _addedCall = false; + } + default: + if (_addedCall) + { + anim.getFrameLabel("idle end").removeCallbacks(); + _addedCall = false; + } + } + super.playAnimation(id, restart, ignoreOther, loop, startFrame); + } + public function updatePosition(str:String) { switch (str) @@ -42,13 +106,12 @@ class CharSelectPlayer extends FlxAtlasSprite public function switchChar(str:String) { - switch str + switch (str) { default: - loadAtlas(Paths.animateAtlas("charSelect/" + str + "Chill")); + loadAtlas(Paths.animateAtlas('charSelect/${str}Chill')); } - anim.play(""); playAnimation("slidein", true, false, false); updateHitbox(); diff --git a/source/funkin/ui/charSelect/CharSelectSubState.hx b/source/funkin/ui/charSelect/CharSelectSubState.hx index 8b1f050f54..42efc76afd 100644 --- a/source/funkin/ui/charSelect/CharSelectSubState.hx +++ b/source/funkin/ui/charSelect/CharSelectSubState.hx @@ -1,27 +1,31 @@ package funkin.ui.charSelect; -import funkin.ui.freeplay.FreeplayState; -import flixel.text.FlxText; -import funkin.ui.PixelatedIcon; -import flixel.system.debug.watch.Tracker.TrackerProfile; -import flixel.math.FlxPoint; -import flixel.tweens.FlxTween; -import openfl.display.BlendMode; -import flixel.group.FlxGroup.FlxTypedGroup; +import flixel.FlxObject; import flixel.FlxSprite; +import flixel.group.FlxGroup; +import flixel.group.FlxGroup.FlxTypedGroup; import flixel.group.FlxSpriteGroup; -import funkin.play.stage.Stage; +import flixel.math.FlxPoint; +import flixel.sound.FlxSound; +import flixel.system.debug.watch.Tracker.TrackerProfile; +import flixel.text.FlxText; +import flixel.tweens.FlxEase; +import flixel.tweens.FlxTween; +import flixel.util.FlxTimer; +import funkin.audio.FunkinSound; +import funkin.data.freeplay.player.PlayerData; +import funkin.data.freeplay.player.PlayerRegistry; +import funkin.graphics.adobeanimate.FlxAtlasSprite; +import funkin.graphics.FunkinCamera; import funkin.modding.events.ScriptEvent; import funkin.modding.events.ScriptEventDispatcher; -import funkin.graphics.adobeanimate.FlxAtlasSprite; -import flixel.FlxObject; -import openfl.display.BlendMode; -import flixel.group.FlxGroup; +import funkin.play.stage.Stage; +import funkin.ui.freeplay.charselect.PlayableCharacter; +import funkin.ui.freeplay.FreeplayState; +import funkin.ui.PixelatedIcon; import funkin.util.MathUtil; -import flixel.util.FlxTimer; -import flixel.tweens.FlxEase; -import flixel.sound.FlxSound; -import funkin.audio.FunkinSound; +import funkin.vis.dsp.SpectralAnalyzer; +import openfl.display.BlendMode; class CharSelectSubState extends MusicBeatSubState { @@ -63,18 +67,45 @@ class CharSelectSubState extends MusicBeatSubState var selectTimer:FlxTimer = new FlxTimer(); var selectSound:FunkinSound; + var charSelectCam:FunkinCamera; + + var introM:FunkinSound = null; + public function new() { super(); - availableChars.set(4, "bf"); - availableChars.set(3, "pico"); + loadAvailableCharacters(); + } + + function loadAvailableCharacters():Void + { + var playerIds:Array = PlayerRegistry.instance.listEntryIds(); + + for (playerId in playerIds) + { + var player:Null = PlayerRegistry.instance.fetchEntry(playerId); + if (player == null) continue; + var playerData = player.getCharSelectData(); + if (playerData == null) continue; + + var targetPosition:Int = playerData.position ?? 0; + while (availableChars.exists(targetPosition)) + { + targetPosition += 1; + } + + trace('Placing player ${playerId} at position ${targetPosition}'); + availableChars.set(targetPosition, playerId); + } } override public function create():Void { super.create(); + FlxG.cameras.reset(new FunkinCamera('charSelectCam')); + selectSound = new FunkinSound(); selectSound.loadEmbedded(Paths.sound('CS_select')); selectSound.pitch = 1; @@ -90,13 +121,21 @@ class CharSelectSubState extends MusicBeatSubState restartTrack: true }); var introMusic:String = Paths.music('stayFunky/stayFunky-intro'); - FunkinSound.load(introMusic, 1.0, false, true, true, () -> { + introM = FunkinSound.load(introMusic, 1.0, false, true, true, () -> { FunkinSound.playMusic('stayFunky', { startingVolume: 1, overrideExisting: true, restartTrack: true }); + @:privateAccess + gfChill.analyzer = new SpectralAnalyzer(FlxG.sound.music._channel.__audioSource, 7, 0.1); + #if desktop + // On desktop it uses FFT stuff that isn't as optimized as the direct browser stuff we use on HTML5 + // So we want to manually change it! + @:privateAccess + gfChill.analyzer.fftN = 512; + #end }); var bg:FlxSprite = new FlxSprite(-153, -140); @@ -111,7 +150,7 @@ class CharSelectSubState extends MusicBeatSubState var stageSpr:FlxSprite = new FlxSprite(-40, 391); stageSpr.frames = Paths.getSparrowAtlas("charSelect/charSelectStage"); - stageSpr.animation.addByPrefix("idle", "stage", 24, true); + stageSpr.animation.addByPrefix("idle", "stage full instance 1", 24, true); stageSpr.animation.play("idle"); add(stageSpr); @@ -137,7 +176,7 @@ class CharSelectSubState extends MusicBeatSubState gfChill = new CharSelectGF(); gfChill.switchGF("bf"); add(gfChill); - + @:privateAccess playerChill = new CharSelectPlayer(0, 0); playerChill.switchChar("bf"); add(playerChill); @@ -158,14 +197,14 @@ class CharSelectSubState extends MusicBeatSubState var dipshitBlur:FlxSprite = new FlxSprite(419, -65); dipshitBlur.frames = Paths.getSparrowAtlas("charSelect/dipshitBlur"); - dipshitBlur.animation.addByPrefix('idle', "CHOOSE vertical", 24, true); + dipshitBlur.animation.addByPrefix('idle', "CHOOSE vertical offset instance 1", 24, true); dipshitBlur.blend = BlendMode.ADD; dipshitBlur.animation.play("idle"); add(dipshitBlur); var dipshitBacking:FlxSprite = new FlxSprite(423, -17); dipshitBacking.frames = Paths.getSparrowAtlas("charSelect/dipshitBacking"); - dipshitBacking.animation.addByPrefix('idle', "CHOOSE horizontal", 24, true); + dipshitBacking.animation.addByPrefix('idle', "CHOOSE horizontal offset instance 1", 24, true); dipshitBacking.blend = BlendMode.ADD; dipshitBacking.animation.play("idle"); add(dipshitBacking); @@ -192,7 +231,6 @@ class CharSelectSubState extends MusicBeatSubState // FlxG.debugger.track(bfChill, "bf chill"); // FlxG.debugger.track(playerChill, "player"); // FlxG.debugger.track(nametag, "nametag"); - FlxG.debugger.track(selectSound, "selectSound"); // FlxG.debugger.track(chooseDipshit, "choose dipshit"); // FlxG.debugger.track(barthing, "barthing"); // FlxG.debugger.track(fgBlur, "fgBlur"); @@ -224,14 +262,14 @@ class CharSelectSubState extends MusicBeatSubState cursorConfirmed = new FlxSprite(0, 0); cursorConfirmed.scrollFactor.set(); cursorConfirmed.frames = Paths.getSparrowAtlas("charSelect/charSelectorConfirm"); - cursorConfirmed.animation.addByPrefix("idle", "cursor ACCEPTED", 24, true); + cursorConfirmed.animation.addByPrefix("idle", "cursor ACCEPTED instance 1", 24, true); cursorConfirmed.visible = false; add(cursorConfirmed); cursorDenied = new FlxSprite(0, 0); cursorDenied.scrollFactor.set(); cursorDenied.frames = Paths.getSparrowAtlas("charSelect/charSelectorDenied"); - cursorDenied.animation.addByPrefix("idle", "cursor DENIED", 24, false); + cursorDenied.animation.addByPrefix("idle", "cursor DENIED instance 1", 24, false); cursorDenied.visible = false; add(cursorDenied); @@ -252,8 +290,6 @@ class CharSelectSubState extends MusicBeatSubState FlxG.debugger.addTrackerProfile(new TrackerProfile(CharSelectSubState, ["curChar", "grpXSpread", "grpYSpread"])); FlxG.debugger.track(this); - FlxG.sound.playMusic(Paths.music('charSelect/charSelectMusic')); - camFollow = new FlxObject(0, 0, 1, 1); add(camFollow); camFollow.screenCenter(); @@ -265,7 +301,6 @@ class CharSelectSubState extends MusicBeatSubState add(temp); temp.alpha = 0.0; Conductor.stepHit.add(spamOnStep); - // FlxG.debugger.track(temp, "tempBG"); } var grpIcons:FlxSpriteGroup; @@ -349,6 +384,17 @@ class CharSelectSubState extends MusicBeatSubState override public function update(elapsed:Float):Void { super.update(elapsed); + @:privateAccess + if (introM != null && !introM.paused && gfChill.analyzer == null) + { + gfChill.analyzer = new SpectralAnalyzer(introM._channel.__audioSource, 7, 0.1); + #if desktop + // On desktop it uses FFT stuff that isn't as optimized as the direct browser stuff we use on HTML5 + // So we want to manually change it! + @:privateAccess + gfChill.analyzer.fftN = 512; + #end + } Conductor.instance.update(); @@ -449,6 +495,7 @@ class CharSelectSubState extends MusicBeatSubState FlxTween.tween(FlxG.sound.music, {pitch: 0.1}, 1.5, {ease: FlxEase.quadInOut}); playerChill.playAnimation("select"); + gfChill.playAnimation("confirm"); pressedSelect = true; selectTimer.start(1.5, (_) -> { pressedSelect = false; @@ -461,14 +508,36 @@ class CharSelectSubState extends MusicBeatSubState }); } + // Put this BEFORE the other check, because pressedStart will get turned off if it's on! + if (!pressedSelect && controls.BACK) + { + // Return to the Freeplay state without changing character. + FlxG.switchState(FreeplayState.build( + { + {} + })); + } + if (pressedSelect && controls.BACK) { cursorConfirmed.visible = false; grpCursors.visible = true; FlxTween.globalManager.cancelTweensOf(FlxG.sound.music); - FlxTween.tween(FlxG.sound.music, {pitch: 1.0}, 1, {ease: FlxEase.quartInOut}); playerChill.playAnimation("deselect"); + gfChill.playAnimation("deselect"); + FlxTween.tween(FlxG.sound.music, {pitch: 1.0}, 1, + { + ease: FlxEase.quartInOut, + onComplete: (_) -> { + var fr = playerChill.anim.getFrameLabel("deselect loop end"); + if (fr != null) fr.removeCallbacks(); + @:privateAccess + playerChill._addedCall = false; + playerChill.playAnimation("idle"); + gfChill.playAnimation("idle"); + } + }); pressedSelect = false; selectTimer.cancel(); } @@ -509,6 +578,9 @@ class CharSelectSubState extends MusicBeatSubState cursorDarkBlue.x = MathUtil.coolLerp(cursorDarkBlue.x, cursorLocIntended.x, lerpAmnt * 0.2); cursorDarkBlue.y = MathUtil.coolLerp(cursorDarkBlue.y, cursorLocIntended.y, lerpAmnt * 0.2); + + FlxG.watch.addQuick("charSelect-player-anim", playerChill.getCurrentAnimation()); + FlxG.watch.addQuick("charSelect-gf-anim", gfChill.getCurrentAnimation()); } function spamOnStep():Void @@ -572,6 +644,14 @@ class CharSelectSubState extends MusicBeatSubState memb.scale.set(2.6, 2.6); if (controls.ACCEPT) memb.animation.play("confirm"); + if (controls.BACK) + { + memb.animation.play("confirm", false, true); + member.animation.finishCallback = (_) -> { + member.animation.play("idle"); + member.animation.finishCallback = null; + }; + } } else { @@ -601,13 +681,19 @@ class CharSelectSubState extends MusicBeatSubState playerChillOut.visible = true; playerChillOut.anim.goToFrameLabel("slideout"); playerChillOut.onAnimationFrame.add((_, frame:Int) -> { - if (frame == playerChillOut.anim.getFrameLabel("slideout").index + 1) + var targetIndex = playerChillOut?.anim?.getFrameLabel("slideout")?.index; + if (targetIndex == null) return; + if (frame == targetIndex) + { + playerChillOut.anim.pause(); + } + if (frame == targetIndex + 1) { playerChill.visible = true; playerChill.switchChar(value); gfChill.switchGF(value); } - if (frame == playerChillOut.anim.getFrameLabel("slideout").index + 2) + if (frame == targetIndex + 2) { playerChillOut.switchChar(value); playerChillOut.visible = false; diff --git a/source/funkin/ui/debug/anim/FlxAnimateTest.hx b/source/funkin/ui/debug/anim/FlxAnimateTest.hx index c83d2c370c..44917e2a5c 100644 --- a/source/funkin/ui/debug/anim/FlxAnimateTest.hx +++ b/source/funkin/ui/debug/anim/FlxAnimateTest.hx @@ -22,28 +22,18 @@ class FlxAnimateTest extends MusicBeatState { super.create(); - sprite = new FlxAtlasSprite(0, 0, 'shared:assets/shared/images/characters/tankman'); + sprite = new FlxAtlasSprite(0, 0, 'assets/images/charSelect/maskTest'); add(sprite); - - sprite.playAnimation('idle'); + sprite.playAnimation(null, false, false, true); } public override function update(elapsed:Float):Void { super.update(elapsed); - if (FlxG.keys.justPressed.SPACE) sprite.playAnimation('idle'); - - if (FlxG.keys.justPressed.W) sprite.playAnimation('singUP'); - - if (FlxG.keys.justPressed.A) sprite.playAnimation('singLEFT'); - - if (FlxG.keys.justPressed.S) sprite.playAnimation('singDOWN'); - - if (FlxG.keys.justPressed.D) sprite.playAnimation('singRIGHT'); - - if (FlxG.keys.justPressed.J) sprite.playAnimation('hehPrettyGood'); + if (FlxG.keys.justPressed.SPACE) ((sprite.anim.isPlaying) ? sprite.anim.pause() : sprite.playAnimation(null, false, false, true)); - if (FlxG.keys.justPressed.K) sprite.playAnimation('ugh'); + if (FlxG.keys.anyJustPressed([A, LEFT])) sprite.anim.curFrame--; + if (FlxG.keys.anyJustPressed([D, RIGHT])) sprite.anim.curFrame++; } } diff --git a/source/funkin/ui/debug/charting/toolboxes/ChartEditorEventDataToolbox.hx b/source/funkin/ui/debug/charting/toolboxes/ChartEditorEventDataToolbox.hx index 8f021840ac..6534153633 100644 --- a/source/funkin/ui/debug/charting/toolboxes/ChartEditorEventDataToolbox.hx +++ b/source/funkin/ui/debug/charting/toolboxes/ChartEditorEventDataToolbox.hx @@ -190,8 +190,8 @@ class ChartEditorEventDataToolbox extends ChartEditorBaseToolbox var numberStepper:NumberStepper = new NumberStepper(); numberStepper.id = field.name; numberStepper.step = field.step ?? 1.0; - numberStepper.min = field.min ?? 0.0; - numberStepper.max = field.max ?? 10.0; + if (field.min != null) numberStepper.min = field.min; + if (field.max != null) numberStepper.max = field.max; if (field.defaultValue != null) numberStepper.value = field.defaultValue; input = numberStepper; case FLOAT: diff --git a/source/funkin/ui/freeplay/AlbumRoll.hx b/source/funkin/ui/freeplay/AlbumRoll.hx index 36dba0054a..3ae21ccfcf 100644 --- a/source/funkin/ui/freeplay/AlbumRoll.hx +++ b/source/funkin/ui/freeplay/AlbumRoll.hx @@ -61,6 +61,8 @@ class AlbumRoll extends FlxSpriteGroup super(); newAlbumArt = new FlxAtlasSprite(640, 350, Paths.animateAtlas("freeplay/albumRoll/freeplayAlbum")); + trace('Album art animations: ' + newAlbumArt.listAnimations()); + newAlbumArt.visible = false; newAlbumArt.onAnimationComplete.add(onAlbumFinish); diff --git a/source/funkin/ui/freeplay/CapsuleOptionsMenu.hx b/source/funkin/ui/freeplay/CapsuleOptionsMenu.hx new file mode 100644 index 0000000000..cb0fa7b289 --- /dev/null +++ b/source/funkin/ui/freeplay/CapsuleOptionsMenu.hx @@ -0,0 +1,176 @@ +package funkin.ui.freeplay; + +import funkin.graphics.shaders.PureColor; +import funkin.input.Controls; +import flixel.group.FlxSpriteGroup; +import funkin.graphics.FunkinSprite; +import flixel.util.FlxColor; +import flixel.util.FlxTimer; +import flixel.text.FlxText; +import flixel.text.FlxText.FlxTextAlign; + +@:nullSafety +class CapsuleOptionsMenu extends FlxSpriteGroup +{ + var capsuleMenuBG:FunkinSprite; + var parent:FreeplayState; + + var queueDestroy:Bool = false; + + var instrumentalIds:Array = ['']; + var currentInstrumentalIndex:Int = 0; + + var currentInstrumental:FlxText; + + public function new(parent:FreeplayState, x:Float = 0, y:Float = 0, instIds:Array):Void + { + super(x, y); + + this.parent = parent; + this.instrumentalIds = instIds; + + capsuleMenuBG = FunkinSprite.createSparrow(0, 0, 'freeplay/instBox/instBox'); + + capsuleMenuBG.animation.addByPrefix('open', 'open0', 24, false); + capsuleMenuBG.animation.addByPrefix('idle', 'idle0', 24, true); + capsuleMenuBG.animation.addByPrefix('open', 'open0', 24, false); + + currentInstrumental = new FlxText(0, 36, capsuleMenuBG.width, ''); + currentInstrumental.setFormat('VCR OSD Mono', 40, FlxTextAlign.CENTER, true); + + final PAD = 4; + var leftArrow = new InstrumentalSelector(parent, PAD, 30, false, parent.getControls()); + var rightArrow = new InstrumentalSelector(parent, capsuleMenuBG.width - leftArrow.width - PAD, 30, true, parent.getControls()); + + var label:FlxText = new FlxText(0, 5, capsuleMenuBG.width, 'INSTRUMENTAL'); + label.setFormat('VCR OSD Mono', 24, FlxTextAlign.CENTER, true); + + add(capsuleMenuBG); + add(leftArrow); + add(rightArrow); + add(label); + add(currentInstrumental); + + capsuleMenuBG.animation.finishCallback = function(_) { + capsuleMenuBG.animation.play('idle', true); + }; + capsuleMenuBG.animation.play('open', true); + } + + public override function update(elapsed:Float):Void + { + super.update(elapsed); + + if (queueDestroy) + { + destroy(); + return; + } + @:privateAccess + if (parent.controls.BACK) + { + close(); + return; + } + + var changedInst = false; + if (parent.getControls().UI_LEFT_P) + { + currentInstrumentalIndex = (currentInstrumentalIndex + 1) % instrumentalIds.length; + changedInst = true; + } + if (parent.getControls().UI_RIGHT_P) + { + currentInstrumentalIndex = (currentInstrumentalIndex - 1 + instrumentalIds.length) % instrumentalIds.length; + changedInst = true; + } + if (!changedInst && currentInstrumental.text == '') changedInst = true; + + if (changedInst) + { + currentInstrumental.text = instrumentalIds[currentInstrumentalIndex].toTitleCase() ?? ''; + if (currentInstrumental.text == '') currentInstrumental.text = 'Default'; + } + + if (parent.getControls().ACCEPT) + { + onConfirm(instrumentalIds[currentInstrumentalIndex] ?? ''); + } + } + + public function close():Void + { + // Play in reverse. + capsuleMenuBG.animation.play('open', true, true); + capsuleMenuBG.animation.finishCallback = function(_) { + parent.cleanupCapsuleOptionsMenu(); + queueDestroy = true; + }; + } + + /** + * Override this with `capsuleOptionsMenu.onConfirm = myFunction;` + */ + public dynamic function onConfirm(targetInstId:String):Void + { + throw 'onConfirm not implemented!'; + } +} + +/** + * The difficulty selector arrows to the left and right of the difficulty. + */ +class InstrumentalSelector extends FunkinSprite +{ + var controls:Controls; + var whiteShader:PureColor; + + var parent:FreeplayState; + + var baseScale:Float = 0.6; + + public function new(parent:FreeplayState, x:Float, y:Float, flipped:Bool, controls:Controls) + { + super(x, y); + + this.parent = parent; + + this.controls = controls; + + frames = Paths.getSparrowAtlas('freeplay/freeplaySelector'); + animation.addByPrefix('shine', 'arrow pointer loop', 24); + animation.play('shine'); + + whiteShader = new PureColor(FlxColor.WHITE); + + shader = whiteShader; + + flipX = flipped; + + scale.x = scale.y = 1 * baseScale; + updateHitbox(); + } + + override function update(elapsed:Float):Void + { + if (flipX && controls.UI_RIGHT_P) moveShitDown(); + if (!flipX && controls.UI_LEFT_P) moveShitDown(); + + super.update(elapsed); + } + + function moveShitDown():Void + { + offset.y -= 5; + + whiteShader.colorSet = true; + + scale.x = scale.y = 0.5 * baseScale; + + new FlxTimer().start(2 / 24, function(tmr) { + scale.x = scale.y = 1 * baseScale; + whiteShader.colorSet = false; + updateHitbox(); + }); + } +} diff --git a/source/funkin/ui/freeplay/FreeplayDJ.hx b/source/funkin/ui/freeplay/FreeplayDJ.hx index fd00a95493..58d20f9ec7 100644 --- a/source/funkin/ui/freeplay/FreeplayDJ.hx +++ b/source/funkin/ui/freeplay/FreeplayDJ.hx @@ -15,7 +15,7 @@ class FreeplayDJ extends FlxAtlasSprite { // Represents the sprite's current status. // Without state machines I would have driven myself crazy years ago. - public var currentState:DJBoyfriendState = Intro; + public var currentState:FreeplayDJState = Intro; // A callback activated when the intro animation finishes. public var onIntroDone:FlxSignal = new FlxSignal(); @@ -65,6 +65,8 @@ class FreeplayDJ extends FlxAtlasSprite FlxG.console.registerFunction("freeplayCartoon", function() { currentState = Cartoon; }); + + trace('Freeplay DJ animations: ${listAnimations()}'); } override public function listAnimations():Array @@ -99,7 +101,7 @@ class FreeplayDJ extends FlxAtlasSprite playFlashAnimation(animPrefix, true, false, true); } - if (getCurrentAnimation() == animPrefix && this.isLoopFinished()) + if (getCurrentAnimation() == animPrefix && this.isLoopComplete()) { if (timeIdling >= IDLE_EGG_PERIOD && !seenIdleEasterEgg) { @@ -111,18 +113,72 @@ class FreeplayDJ extends FlxAtlasSprite } } timeIdling += elapsed; + case NewUnlock: + var animPrefix = playableCharData.getAnimationPrefix('newUnlock'); + if (!hasAnimation(animPrefix)) + { + currentState = Idle; + } + if (getCurrentAnimation() != animPrefix) + { + playFlashAnimation(animPrefix, true, false, true); + } + + // No IdleEasterEgg or Cartoon here! + case Confirm: var animPrefix = playableCharData.getAnimationPrefix('confirm'); if (getCurrentAnimation() != animPrefix) playFlashAnimation(animPrefix, false); timeIdling = 0; case FistPumpIntro: - var animPrefix = playableCharData.getAnimationPrefix('fistPump'); - if (getCurrentAnimation() != animPrefix) playFlashAnimation('Boyfriend DJ fist pump', false); - if (getCurrentAnimation() == animPrefix && anim.curFrame >= 4) + var animPrefixA = playableCharData.getAnimationPrefix('fistPump'); + var animPrefixB = playableCharData.getAnimationPrefix('loss'); + + if (getCurrentAnimation() == animPrefixA) + { + var endFrame = playableCharData.getFistPumpIntroEndFrame(); + if (endFrame > -1 && anim.curFrame >= endFrame) + { + playFlashAnimation(animPrefixA, true, false, false, playableCharData.getFistPumpIntroStartFrame()); + } + } + else if (getCurrentAnimation() == animPrefixB) + { + var endFrame = playableCharData.getFistPumpIntroBadEndFrame(); + if (endFrame > -1 && anim.curFrame >= endFrame) + { + playFlashAnimation(animPrefixB, true, false, false, playableCharData.getFistPumpIntroBadStartFrame()); + } + } + else { - playAnimation("Boyfriend DJ fist pump", true, false, false, 0); + FlxG.log.warn("Unrecognized animation in FistPumpIntro: " + getCurrentAnimation()); } + case FistPump: + var animPrefixA = playableCharData.getAnimationPrefix('fistPump'); + var animPrefixB = playableCharData.getAnimationPrefix('loss'); + + if (getCurrentAnimation() == animPrefixA) + { + var endFrame = playableCharData.getFistPumpLoopEndFrame(); + if (endFrame > -1 && anim.curFrame >= endFrame) + { + playFlashAnimation(animPrefixA, true, false, false, playableCharData.getFistPumpLoopStartFrame()); + } + } + else if (getCurrentAnimation() == animPrefixB) + { + var endFrame = playableCharData.getFistPumpLoopBadEndFrame(); + if (endFrame > -1 && anim.curFrame >= endFrame) + { + playFlashAnimation(animPrefixB, true, false, false, playableCharData.getFistPumpLoopBadStartFrame()); + } + } + else + { + FlxG.log.warn("Unrecognized animation in FistPump: " + getCurrentAnimation()); + } case IdleEasterEgg: var animPrefix = playableCharData.getAnimationPrefix('idleEasterEgg'); @@ -185,7 +241,14 @@ class FreeplayDJ extends FlxAtlasSprite if (name == playableCharData.getAnimationPrefix('intro')) { - currentState = Idle; + if (PlayerRegistry.instance.hasNewCharacter()) + { + currentState = NewUnlock; + } + else + { + currentState = Idle; + } onIntroDone.dispatch(); } else if (name == playableCharData.getAnimationPrefix('idle')) @@ -225,9 +288,17 @@ class FreeplayDJ extends FlxAtlasSprite // runTvLogic(); } trace('Replay idle: ${frame}'); - playAnimation(playableCharData.getAnimationPrefix('cartoon'), true, false, false, frame); + playFlashAnimation(playableCharData.getAnimationPrefix('cartoon'), true, false, false, frame); // trace('Finished confirm'); } + else if (name == playableCharData.getAnimationPrefix('newUnlock')) + { + // Animation should loop. + } + else if (name == playableCharData.getAnimationPrefix('charSelect')) + { + onCharSelectComplete(); + } else { trace('Finished ${name}'); @@ -240,6 +311,15 @@ class FreeplayDJ extends FlxAtlasSprite seenIdleEasterEgg = false; } + /** + * Dynamic function, it's actually a variable you can reassign! + * `dj.onCharSelectComplete = function() {};` + */ + public dynamic function onCharSelectComplete():Void + { + trace('onCharSelectComplete()'); + } + var offsetX:Float = 0.0; var offsetY:Float = 0.0; @@ -271,7 +351,7 @@ class FreeplayDJ extends FlxAtlasSprite function loadCartoon() { cartoonSnd = FunkinSound.load(Paths.sound(getRandomFlashToon()), 1.0, false, true, true, function() { - playAnimation("Boyfriend DJ watchin tv OG", true, false, false, 60); + playFlashAnimation(playableCharData.getAnimationPrefix('cartoon'), true, false, false, 60); }); // Fade out music to 40% volume over 1 second. @@ -301,21 +381,48 @@ class FreeplayDJ extends FlxAtlasSprite currentState = Confirm; } - public function fistPump():Void + public function toCharSelect():Void + { + if (hasAnimation('charSelect')) + { + currentState = CharSelect; + var animPrefix = playableCharData.getAnimationPrefix('charSelect'); + playFlashAnimation(animPrefix, true, false, false, 0); + } + else + { + currentState = Confirm; + // Call this immediately; otherwise, we get locked out of Character Select. + onCharSelectComplete(); + } + } + + public function fistPumpIntro():Void { currentState = FistPumpIntro; + var animPrefix = playableCharData.getAnimationPrefix('fistPump'); + playFlashAnimation(animPrefix, true, false, false, playableCharData.getFistPumpIntroStartFrame()); } - public function pumpFist():Void + public function fistPump():Void { currentState = FistPump; - playAnimation("Boyfriend DJ fist pump", true, false, false, 4); + var animPrefix = playableCharData.getAnimationPrefix('fistPump'); + playFlashAnimation(animPrefix, true, false, false, playableCharData.getFistPumpLoopStartFrame()); } - public function pumpFistBad():Void + public function fistPumpLossIntro():Void + { + currentState = FistPumpIntro; + var animPrefix = playableCharData.getAnimationPrefix('loss'); + playFlashAnimation(animPrefix, true, false, false, playableCharData.getFistPumpIntroBadStartFrame()); + } + + public function fistPumpLoss():Void { currentState = FistPump; - playAnimation("Boyfriend DJ loss reaction 1", true, false, false, 4); + var animPrefix = playableCharData.getAnimationPrefix('loss'); + playFlashAnimation(animPrefix, true, false, false, playableCharData.getFistPumpLoopBadStartFrame()); } override public function getCurrentAnimation():String @@ -338,11 +445,6 @@ class FreeplayDJ extends FlxAtlasSprite { var xValue = daOffset[0]; var yValue = daOffset[1]; - if (AnimName == "Boyfriend DJ watchin tv OG") - { - xValue += offsetX; - yValue += offsetY; - } trace('Successfully applied offset ($AnimName): ' + xValue + ', ' + yValue); offset.set(xValue, yValue); @@ -366,13 +468,53 @@ class FreeplayDJ extends FlxAtlasSprite } } -enum DJBoyfriendState +enum FreeplayDJState { + /** + * Character enters the frame and transitions to Idle. + */ Intro; + + /** + * Character loops in idle. + */ Idle; + + /** + * Plays an easter egg animation after a period in Idle, then reverts to Idle. + */ + IdleEasterEgg; + + /** + * Plays an elaborate easter egg animation. Does not revert until another animation is triggered. + */ + Cartoon; + + /** + * Player has selected a song. + */ Confirm; + + /** + * Character preps to play the fist pump animation; plays after the Results screen. + * The actual frame label that gets played may vary based on the player's success. + */ FistPumpIntro; + + /** + * Character plays the fist pump animation. + * The actual frame label that gets played may vary based on the player's success. + */ FistPump; - IdleEasterEgg; - Cartoon; + + /** + * Plays an animation to indicate that the player has a new unlock in Character Select. + * Overrides all idle animations as well as the fist pump. Only Confirm and CharSelect will override this. + */ + NewUnlock; + + /** + * Plays an animation to transition to the Character Select screen. + */ + CharSelect; } diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx index cffb0b5a43..2997dad065 100644 --- a/source/funkin/ui/freeplay/FreeplayState.hx +++ b/source/funkin/ui/freeplay/FreeplayState.hx @@ -177,9 +177,22 @@ class FreeplayState extends MusicBeatSubState var stickerSubState:Null = null; - public static var rememberedDifficulty:Null = Constants.DEFAULT_DIFFICULTY; + /** + * The difficulty we were on when this menu was last accessed. + */ + public static var rememberedDifficulty:String = Constants.DEFAULT_DIFFICULTY; + + /** + * The song we were on when this menu was last accessed. + * NOTE: `null` if the last song was `Random`. + */ public static var rememberedSongId:Null = 'tutorial'; + /** + * The character we were on when this menu was last accessed. + */ + public static var rememberedCharacterId:String = Constants.DEFAULT_CHARACTER; + var funnyCam:FunkinCamera; var rankCamera:FunkinCamera; var rankBg:FunkinSprite; @@ -209,14 +222,16 @@ class FreeplayState extends MusicBeatSubState public function new(?params:FreeplayStateParams, ?stickers:StickerSubState) { - currentCharacterId = params?.character ?? Constants.DEFAULT_CHARACTER; + currentCharacterId = params?.character ?? rememberedCharacterId; var fetchPlayableCharacter = function():PlayableCharacter { - var result = PlayerRegistry.instance.fetchEntry(params?.character ?? Constants.DEFAULT_CHARACTER); + var result = PlayerRegistry.instance.fetchEntry(params?.character ?? rememberedCharacterId); if (result == null) throw 'No valid playable character with id ${params?.character}'; return result; }; currentCharacter = fetchPlayableCharacter(); + rememberedCharacterId = currentCharacter?.id ?? Constants.DEFAULT_CHARACTER; + fromResultsParams = params?.fromResults; if (fromResultsParams?.playRankAnim == true) @@ -629,8 +644,8 @@ class FreeplayState extends MusicBeatSubState speed: 0.3 }); - var diffSelLeft:DifficultySelector = new DifficultySelector(20, grpDifficulties.y - 10, false, controls); - var diffSelRight:DifficultySelector = new DifficultySelector(325, grpDifficulties.y - 10, true, controls); + var diffSelLeft:DifficultySelector = new DifficultySelector(this, 20, grpDifficulties.y - 10, false, controls); + var diffSelRight:DifficultySelector = new DifficultySelector(this, 325, grpDifficulties.y - 10, true, controls); diffSelLeft.visible = false; diffSelRight.visible = false; add(diffSelLeft); @@ -743,10 +758,7 @@ class FreeplayState extends MusicBeatSubState var tempSongs:Array> = songs; // Remember just the difficulty because it's important for song sorting. - if (rememberedDifficulty != null) - { - currentDifficulty = rememberedDifficulty; - } + currentDifficulty = rememberedDifficulty; if (filterStuff != null) tempSongs = sortSongs(tempSongs, filterStuff); @@ -809,7 +821,7 @@ class FreeplayState extends MusicBeatSubState funnyMenu.init(FlxG.width, 0, tempSong); funnyMenu.onConfirm = function() { - capsuleOnConfirmDefault(funnyMenu); + capsuleOnOpenDefault(funnyMenu); }; funnyMenu.y = funnyMenu.intendedY(i + 1) + 10; funnyMenu.targetPos.x = funnyMenu.x; @@ -901,7 +913,15 @@ class FreeplayState extends MusicBeatSubState changeSelection(); changeDiff(); - if (dj != null) dj.fistPump(); + if (fromResultsParams?.newRank == SHIT) + { + if (dj != null) dj.fistPumpLossIntro(); + } + else + { + if (dj != null) dj.fistPumpIntro(); + } + // rankCamera.fade(FlxColor.BLACK, 0.5, true); rankCamera.fade(0xFF000000, 0.5, true, null, true); if (FlxG.sound.music != null) FlxG.sound.music.volume = 0; @@ -1083,11 +1103,11 @@ class FreeplayState extends MusicBeatSubState if (fromResultsParams?.newRank == SHIT) { - if (dj != null) dj.pumpFistBad(); + if (dj != null) dj.fistPumpLoss(); } else { - if (dj != null) dj.pumpFist(); + if (dj != null) dj.fistPump(); } rankCamera.zoom = 0.8; @@ -1190,7 +1210,7 @@ class FreeplayState extends MusicBeatSubState /** * If true, disable interaction with the interface. */ - var busy:Bool = false; + public var busy:Bool = false; var originalPos:FlxPoint = new FlxPoint(); @@ -1210,16 +1230,6 @@ class FreeplayState extends MusicBeatSubState }); } - if (FlxG.keys.justPressed.P) - { - FlxG.switchState(FreeplayState.build( - { - { - character: currentCharacterId == "pico" ? "bf" : "pico", - } - })); - } - // if (FlxG.keys.justPressed.H) // { // rankDisplayNew(fromResultsParams); @@ -1233,7 +1243,32 @@ class FreeplayState extends MusicBeatSubState if (controls.FREEPLAY_CHAR_SELECT && !busy) { - FlxG.switchState(new funkin.ui.charSelect.CharSelectSubState()); + // Check if we have ACCESS to character select! + trace('Is Pico unlocked? ${PlayerRegistry.instance.fetchEntry('pico')?.isUnlocked()}'); + trace('Number of characters: ${PlayerRegistry.instance.countUnlockedCharacters()}'); + + if (PlayerRegistry.instance.countUnlockedCharacters() > 1) + { + if (dj != null) + { + busy = true; + // Transition to character select after animation + dj.onCharSelectComplete = function() { + FlxG.switchState(new funkin.ui.charSelect.CharSelectSubState()); + } + dj.toCharSelect(); + } + else + { + // Transition to character select immediately + FlxG.switchState(new funkin.ui.charSelect.CharSelectSubState()); + } + } + else + { + trace('Not enough characters unlocked to open character select!'); + FunkinSound.playOnce(Paths.sound('cancelMenu')); + } } if (controls.FREEPLAY_FAVORITE && !busy) @@ -1326,6 +1361,8 @@ class FreeplayState extends MusicBeatSubState } handleInputs(elapsed); + + if (dj != null) FlxG.watch.addQuick('dj-anim', dj.getCurrentAnimation()); } function handleInputs(elapsed:Float):Void @@ -1483,7 +1520,7 @@ class FreeplayState extends MusicBeatSubState generateSongList(currentFilter, true); } - if (controls.BACK) + if (controls.BACK && !busy) { busy = true; FlxTween.globalManager.clear(); @@ -1739,7 +1776,86 @@ class FreeplayState extends MusicBeatSubState capsuleOnConfirmDefault(targetSong); } - function capsuleOnConfirmDefault(cap:SongMenuItem):Void + /** + * Called when hitting ENTER to open the instrumental list. + */ + function capsuleOnOpenDefault(cap:SongMenuItem):Void + { + var targetSongId:String = cap?.songData?.songId ?? 'unknown'; + var targetSongNullable:Null = SongRegistry.instance.fetchEntry(targetSongId); + if (targetSongNullable == null) + { + FlxG.log.warn('WARN: could not find song with id (${targetSongId})'); + return; + } + var targetSong:Song = targetSongNullable; + var targetDifficultyId:String = currentDifficulty; + var targetVariation:Null = targetSong.getFirstValidVariation(targetDifficultyId, currentCharacter); + var targetLevelId:Null = cap?.songData?.levelId; + PlayStatePlaylist.campaignId = targetLevelId ?? null; + + var targetDifficulty:Null = targetSong.getDifficulty(targetDifficultyId, targetVariation); + if (targetDifficulty == null) + { + FlxG.log.warn('WARN: could not find difficulty with id (${targetDifficultyId})'); + return; + } + + trace('target difficulty: ${targetDifficultyId}'); + trace('target variation: ${targetDifficulty?.variation ?? Constants.DEFAULT_VARIATION}'); + + var baseInstrumentalId:String = targetSong.getBaseInstrumentalId(targetDifficultyId, targetDifficulty?.variation ?? Constants.DEFAULT_VARIATION) ?? ''; + var altInstrumentalIds:Array = targetSong.listAltInstrumentalIds(targetDifficultyId, + targetDifficulty?.variation ?? Constants.DEFAULT_VARIATION) ?? []; + + if (altInstrumentalIds.length > 0) + { + var instrumentalIds = [baseInstrumentalId].concat(altInstrumentalIds); + openInstrumentalList(cap, instrumentalIds); + } + else + { + trace('NO ALTS'); + capsuleOnConfirmDefault(cap); + } + } + + public function getControls():Controls + { + return controls; + } + + function openInstrumentalList(cap:SongMenuItem, instrumentalIds:Array):Void + { + busy = true; + + capsuleOptionsMenu = new CapsuleOptionsMenu(this, cap.x + 175, cap.y + 115, instrumentalIds); + capsuleOptionsMenu.cameras = [funnyCam]; + capsuleOptionsMenu.zIndex = 10000; + add(capsuleOptionsMenu); + + capsuleOptionsMenu.onConfirm = function(targetInstId:String) { + capsuleOnConfirmDefault(cap, targetInstId); + }; + } + + var capsuleOptionsMenu:Null = null; + + public function cleanupCapsuleOptionsMenu():Void + { + this.busy = false; + + if (capsuleOptionsMenu != null) + { + remove(capsuleOptionsMenu); + capsuleOptionsMenu = null; + } + } + + /** + * Called when hitting ENTER to play the song. + */ + function capsuleOnConfirmDefault(cap:SongMenuItem, ?targetInstId:String):Void { busy = true; letterSort.inputEnabled = false; @@ -1766,18 +1882,11 @@ class FreeplayState extends MusicBeatSubState return; } - var baseInstrumentalId:String = targetDifficulty?.characters?.instrumental ?? ''; - var altInstrumentalIds:Array = targetDifficulty?.characters?.altInstrumentals ?? []; - - var targetInstId:String = baseInstrumentalId; + var baseInstrumentalId:String = targetSong?.getBaseInstrumentalId(targetDifficultyId, targetDifficulty.variation ?? Constants.DEFAULT_VARIATION) ?? ''; + var altInstrumentalIds:Array = targetSong?.listAltInstrumentalIds(targetDifficultyId, + targetDifficulty.variation ?? Constants.DEFAULT_VARIATION) ?? []; - // TODO: Make this a UI element. - #if FEATURE_DEBUG_FUNCTIONS - if (altInstrumentalIds.length > 0 && FlxG.keys.pressed.CONTROL) - { - targetInstId = altInstrumentalIds[0]; - } - #end + if (targetInstId == null) targetInstId = baseInstrumentalId; // Visual and audio effects. FunkinSound.playOnce(Paths.sound('confirmMenu')); @@ -1891,7 +2000,7 @@ class FreeplayState extends MusicBeatSubState intendedCompletion = 0.0; diffIdsCurrent = diffIdsTotal; rememberedSongId = null; - rememberedDifficulty = null; + rememberedDifficulty = Constants.DEFAULT_DIFFICULTY; albumRoll.albumId = null; } @@ -1934,10 +2043,12 @@ class FreeplayState extends MusicBeatSubState if (previewSongId == null) return; var previewSong:Null = SongRegistry.instance.fetchEntry(previewSongId); - var songDifficulty = previewSong?.getDifficulty(currentDifficulty, - previewSong?.getVariationsByCharacter(currentCharacter) ?? Constants.DEFAULT_VARIATION_LIST); - var baseInstrumentalId:String = songDifficulty?.characters?.instrumental ?? ''; - var altInstrumentalIds:Array = songDifficulty?.characters?.altInstrumentals ?? []; + var currentVariation = previewSong?.getVariationsByCharacter(currentCharacter) ?? Constants.DEFAULT_VARIATION_LIST; + var songDifficulty = previewSong?.getDifficulty(currentDifficulty, currentVariation); + + var baseInstrumentalId:String = previewSong?.getBaseInstrumentalId(currentDifficulty, songDifficulty?.variation ?? Constants.DEFAULT_VARIATION) ?? ''; + var altInstrumentalIds:Array = previewSong?.listAltInstrumentalIds(currentDifficulty, + songDifficulty?.variation ?? Constants.DEFAULT_VARIATION) ?? []; var instSuffix:String = baseInstrumentalId; @@ -2000,10 +2111,14 @@ class DifficultySelector extends FlxSprite var controls:Controls; var whiteShader:PureColor; - public function new(x:Float, y:Float, flipped:Bool, controls:Controls) + var parent:FreeplayState; + + public function new(parent:FreeplayState, x:Float, y:Float, flipped:Bool, controls:Controls) { super(x, y); + this.parent = parent; + this.controls = controls; frames = Paths.getSparrowAtlas('freeplay/freeplaySelector'); @@ -2019,8 +2134,8 @@ class DifficultySelector extends FlxSprite override function update(elapsed:Float):Void { - if (flipX && controls.UI_RIGHT_P) moveShitDown(); - if (!flipX && controls.UI_LEFT_P) moveShitDown(); + if (flipX && controls.UI_RIGHT_P && !parent.busy) moveShitDown(); + if (!flipX && controls.UI_LEFT_P && !parent.busy) moveShitDown(); super.update(elapsed); } diff --git a/source/funkin/ui/freeplay/charselect/PlayableCharacter.hx b/source/funkin/ui/freeplay/charselect/PlayableCharacter.hx index c46b4b9300..68eebf06f6 100644 --- a/source/funkin/ui/freeplay/charselect/PlayableCharacter.hx +++ b/source/funkin/ui/freeplay/charselect/PlayableCharacter.hx @@ -83,6 +83,11 @@ class PlayableCharacter implements IRegistryEntry return _data.freeplayDJ; } + public function getCharSelectData():PlayerCharSelectData + { + return _data.charSelect; + } + public function getFreeplayDJText(index:Int):String { return _data.freeplayDJ.getFreeplayDJText(index); diff --git a/source/funkin/ui/mainmenu/MainMenuState.hx b/source/funkin/ui/mainmenu/MainMenuState.hx index 2d809354a4..ac59947b7f 100644 --- a/source/funkin/ui/mainmenu/MainMenuState.hx +++ b/source/funkin/ui/mainmenu/MainMenuState.hx @@ -90,22 +90,13 @@ class MainMenuState extends MusicBeatState magenta.y = bg.y; magenta.visible = false; - // TODO: Why doesn't this line compile I'm going fucking feral - if (Preferences.flashingLights) add(magenta); menuItems = new MenuTypedList(); add(menuItems); menuItems.onChange.add(onMenuItemChange); menuItems.onAcceptPress.add(function(_) { - if (_.name == 'freeplay') - { - magenta.visible = true; - } - else - { - FlxFlicker.flicker(magenta, 1.1, 0.15, false, true); - } + FlxFlicker.flicker(magenta, 1.1, 0.15, false, true); }); menuItems.enabled = true; // can move on intro @@ -117,10 +108,7 @@ class MainMenuState extends MusicBeatState FlxTransitionableState.skipNextTransIn = true; FlxTransitionableState.skipNextTransOut = true; - openSubState(new FreeplayState( - { - character: FlxG.keys.pressed.SHIFT ? 'pico' : 'bf', - })); + openSubState(new FreeplayState()); }); #if CAN_OPEN_LINKS @@ -375,6 +363,28 @@ class MainMenuState extends MusicBeatState }); } + if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.ALT && FlxG.keys.pressed.SHIFT && FlxG.keys.justPressed.L) + { + // Give the user a score of 0 points on Weekend 1 story mode. + // This makes the level count as uncleared and no longer displays the songs in Freeplay. + funkin.save.Save.instance.setLevelScore('weekend1', 'easy', + { + score: 1, + tallies: + { + sick: 0, + good: 0, + bad: 0, + shit: 0, + missed: 0, + combo: 0, + maxCombo: 0, + totalNotesHit: 0, + totalNotes: 0, + } + }); + } + if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.ALT && FlxG.keys.pressed.SHIFT && FlxG.keys.justPressed.R) { // Give the user a hypothetical overridden score, diff --git a/source/funkin/ui/options/PreferencesMenu.hx b/source/funkin/ui/options/PreferencesMenu.hx index 5fbefceed6..855c8b4725 100644 --- a/source/funkin/ui/options/PreferencesMenu.hx +++ b/source/funkin/ui/options/PreferencesMenu.hx @@ -60,6 +60,9 @@ class PreferencesMenu extends Page createPrefItemCheckbox('Downscroll', 'Enable to make notes move downwards', function(value:Bool):Void { Preferences.downscroll = value; }, Preferences.downscroll); + createPrefItemPercentage('Strumline Background', 'Give the strumline a semi-transparent background', function(value:Int):Void { + Preferences.strumlineBackgroundOpacity = value; + }, Preferences.strumlineBackgroundOpacity); createPrefItemCheckbox('Flashing Lights', 'Disable to dampen flashing effects', function(value:Bool):Void { Preferences.flashingLights = value; }, Preferences.flashingLights); diff --git a/source/funkin/ui/story/LevelProp.hx b/source/funkin/ui/story/LevelProp.hx index 4e78415e36..dfb11dd204 100644 --- a/source/funkin/ui/story/LevelProp.hx +++ b/source/funkin/ui/story/LevelProp.hx @@ -16,7 +16,7 @@ class LevelProp extends Bopper this.propData = value; this.visible = this.propData != null; - danceEvery = this.propData?.danceEvery ?? 0.0; + danceEvery = this.propData?.danceEvery ?? 1.0; applyData(); } @@ -32,7 +32,7 @@ class LevelProp extends Bopper public function playConfirm():Void { - playAnimation('confirm', true, true); + if (hasAnimation('confirm')) playAnimation('confirm', true, true); } function applyData():Void diff --git a/source/funkin/ui/transition/LoadingState.hx b/source/funkin/ui/transition/LoadingState.hx index 067de0e7fc..61fc960c66 100644 --- a/source/funkin/ui/transition/LoadingState.hx +++ b/source/funkin/ui/transition/LoadingState.hx @@ -115,14 +115,15 @@ class LoadingState extends MusicBeatSubState function checkLibrary(library:String):Void { - trace(Assets.hasLibrary(library)); + trace('Has library: ' + library + ' : ' + Assets.hasLibrary(library)); if (Assets.getLibrary(library) == null) { @:privateAccess if (!LimeAssets.libraryPaths.exists(library)) throw 'Missing library: ' + library; var callback = callbacks.add('library:' + library); - Assets.loadLibrary(library).onComplete(function(_) { + trace('Loading library: ' + library); + Assets.loadLibrary(library).onComplete(function(assetLibrary:lime.utils.AssetLibrary) { callback(); }); } @@ -314,25 +315,29 @@ class LoadingState extends MusicBeatSubState FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/num7')); FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/num8')); FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/num9')); + FunkinSprite.cacheTexture(Paths.image('notes', 'shared')); FunkinSprite.cacheTexture(Paths.image('noteSplashes', 'shared')); FunkinSprite.cacheTexture(Paths.image('noteStrumline', 'shared')); FunkinSprite.cacheTexture(Paths.image('NOTE_hold_assets')); + FunkinSprite.cacheTexture(Paths.image('ui/countdown/funkin/ready', 'shared')); FunkinSprite.cacheTexture(Paths.image('ui/countdown/funkin/set', 'shared')); FunkinSprite.cacheTexture(Paths.image('ui/countdown/funkin/go', 'shared')); + FunkinSprite.cacheTexture(Paths.image('ui/countdown/pixel/ready', 'shared')); FunkinSprite.cacheTexture(Paths.image('ui/countdown/pixel/set', 'shared')); FunkinSprite.cacheTexture(Paths.image('ui/countdown/pixel/go', 'shared')); - FunkinSprite.cacheTexture(Paths.image('ui/popup/normal/sick')); - FunkinSprite.cacheTexture(Paths.image('ui/popup/normal/good')); - FunkinSprite.cacheTexture(Paths.image('ui/popup/normal/bad')); - FunkinSprite.cacheTexture(Paths.image('ui/popup/normal/shit')); + + FunkinSprite.cacheTexture(Paths.image('ui/popup/funkin/sick')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/funkin/good')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/funkin/bad')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/funkin/shit')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/sick')); FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/good')); FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/bad')); FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/shit')); - FunkinSprite.cacheTexture(Paths.image('miss', 'shared')); // TODO: remove this // List all image assets in the level's library. // This is crude and I want to remove it when we have a proper asset caching system. diff --git a/source/funkin/util/Constants.hx b/source/funkin/util/Constants.hx index 80318f1a42..29ddc78ff0 100644 --- a/source/funkin/util/Constants.hx +++ b/source/funkin/util/Constants.hx @@ -525,11 +525,12 @@ class Constants */ // ============================== + #if FEATURE_GHOST_TAPPING /** - * If true, the player will not receive the ghost miss penalty if there are no notes within the hit window. - * This is the thing people have been begging for forever lolol. + * Duration, in seconds, after the player's section ends before the player can spam without penalty. */ - public static final GHOST_TAPPING:Bool = false; + public static final GHOST_TAP_DELAY:Float = 3 / 8; + #end /** * The maximum number of previous file paths for the Chart Editor to remember.