diff --git a/source/flixel/tweens/misc/BackgroundColorTween.hx b/source/flixel/tweens/misc/BackgroundColorTween.hx new file mode 100644 index 000000000..b8b2d993f --- /dev/null +++ b/source/flixel/tweens/misc/BackgroundColorTween.hx @@ -0,0 +1,66 @@ +package flixel.tweens.misc; + +/** + * Tweens the background color of a state. + * Tweens the red, green, blue, and/or alpha values of the color. + * + * @see `flixel.tweens.misc.ColorTween` for something that operates on sprites! + */ +class BackgroundColorTween extends FlxTween +{ + public var color(default, null):FlxColor; + + var startColor:FlxColor; + var endColor:FlxColor; + + /** + * State object whose color to tween + */ + public var targetState(default, null):FlxState; + + /** + * Clean up references + */ + override public function destroy() + { + super.destroy(); + targetState = null; + } + + /** + * Tweens the color to a new color and an alpha to a new alpha. + * + * @param duration Duration of the tween. + * @param fromColor Start color. + * @param toColor End color. + * @param targetState Optional sprite object whose color to tween. + * @return The tween for chaining. + */ + public function tween(duration:Float, fromColor:FlxColor, toColor:FlxColor, ?targetState:FlxSprite):ColorTween + { + this.color = startColor = fromColor; + this.endColor = toColor; + this.duration = duration; + this.targetState = targetState; + this.start(); + return this; + } + + override function update(elapsed:Float):Void + { + super.update(elapsed); + color = FlxColor.interpolate(startColor, endColor, scale); + + if (targetState != null) + { + targetState.bgColor = color; + // Alpha should apply inherently. + // targetState.alpha = color.alphaFloat; + } + } + + override function isTweenOf(object:Dynamic, ?field:String):Bool + { + return targetState == object && (field == null || field == "color"); + } +} diff --git a/source/funkin/import.hx b/source/funkin/import.hx index 02055d4ed..23b588044 100644 --- a/source/funkin/import.hx +++ b/source/funkin/import.hx @@ -15,6 +15,7 @@ using funkin.util.tools.ArraySortTools; using funkin.util.tools.ArrayTools; using funkin.util.tools.DynamicTools; using funkin.util.tools.FloatTools; +using funkin.util.tools.FlxTweenTools; using funkin.util.tools.Int64Tools; using funkin.util.tools.IntTools; using funkin.util.tools.IteratorTools; diff --git a/source/funkin/play/PauseSubState.hx b/source/funkin/play/PauseSubState.hx index 023b8d5be..8300ac936 100644 --- a/source/funkin/play/PauseSubState.hx +++ b/source/funkin/play/PauseSubState.hx @@ -1,312 +1,370 @@ package funkin.play; -import funkin.play.PlayStatePlaylist; -import flixel.FlxSprite; -import flixel.addons.transition.FlxTransitionableState; -import flixel.group.FlxGroup.FlxTypedGroup; -import funkin.ui.MusicBeatSubState; -import flixel.sound.FlxSound; -import flixel.text.FlxText; -import flixel.tweens.FlxEase; -import flixel.tweens.FlxTween; -import flixel.util.FlxColor; -import funkin.play.PlayState; -import funkin.data.song.SongRegistry; -import funkin.ui.Alphabet; +typedef PauseSubStateParams = +{ + ?mode:PauseMode, +}; +/** + * The menu displayed when the Play State is paused. + */ class PauseSubState extends MusicBeatSubState { - var grpMenuShit:FlxTypedGroup<Alphabet>; - - final pauseOptionsBase:Array<String> = [ - 'Resume', - 'Restart Song', - 'Change Difficulty', - 'Toggle Practice Mode', - 'Exit to Menu' + static final PAUSE_MENU_ENTRIES_STANDARD = [ + {text: 'Resume', callback: resume}, + {text: 'Restart Song', callback: restartPlayState}, + {text: 'Change Difficulty', callback: switchMode.bind(_, Difficulty)}, + {text: 'Enable Practice Mode', callback: enablePracticeMode, filter: () -> (PlayState.instance?.isPracticeMode ?? true)}, + {text: 'Exit to Menu', callback: quitToMenu}, ]; - final pauseOptionsCharting:Array<String> = ['Resume', 'Restart Song', 'Exit to Chart Editor']; - final pauseOptionsDifficultyBase:Array<String> = ['BACK']; + static final PAUSE_MENU_ENTRIES_CHARTING = [ + {text: 'Resume', callback: resume}, + {text: 'Restart Song', callback: restartPlayState}, + {text: 'Return to Chart Editor', callback: quitToChartEditor}, + ]; - var pauseOptionsDifficulty:Array<String> = []; // AUTO-POPULATED + static final PAUSE_MENU_ENTRIES_DIFFICULTY = [ + {text: 'Back', callback: switchMode.bind(_, Standard)} + // Other entries are added dynamically. + ]; - var menuItems:Array<String> = []; - var curSelected:Int = 0; + static final PAUSE_MENU_ENTRIES_CUTSCENE = [ + {text: 'Resume', callback: resume}, + {text: 'Restart Cutscene', callback: restartCutscene}, + {text: 'Skip Cutscene', callback: skipCutscene}, + {text: 'Exit to Menu', callback: quitToMenu}, + ]; - var pauseMusic:FlxSound; + static final MUSIC_FADE_IN_TIME:Float = 50; + static final MUSIC_FINAL_VOLUME:Float = 0.5; - var practiceText:FlxText; + public static var musicSuffix:String = ''; - public var exitingToMenu:Bool = false; + // Status + var menuEntries:Array<PauseMenuEntry>; + var currentEntry:Int = 0; + var currentMode:PauseMode; + var allowInput:Bool = true; - var bg:FlxSprite; - var metaDataGrp:FlxTypedGroup<FlxSprite>; + // Graphics + var metadata:FlxTypedGroup<FlxText>; + var metadataPractice:FlxText; + var menuEntryText:FlxTypedGroup<AtlasText>; - var isChartingMode:Bool; + // Audio + var pauseMusic:FunkinSound; - public function new(isChartingMode:Bool = false) + public function new(?params:PauseSubStateParams) { super(); + this.currentMode = params?.mode ?? PauseMode.Standard; - this.isChartingMode = isChartingMode; - - menuItems = this.isChartingMode ? pauseOptionsCharting : pauseOptionsBase; - var difficultiesInVariation = PlayState.instance.currentSong.listDifficulties(PlayState.instance.currentChart.variation); - trace('DIFFICULTIES: ${difficultiesInVariation}'); - - pauseOptionsDifficulty = difficultiesInVariation.map(function(item:String):String { - return item.toUpperCase(); - }).concat(pauseOptionsDifficultyBase); - - if (PlayStatePlaylist.campaignId == 'week6') - { - pauseMusic = new FlxSound().loadEmbedded(Paths.music('breakfast-pixel'), true, true); - } - else - { - pauseMusic = new FlxSound().loadEmbedded(Paths.music('breakfast'), true, true); - } - pauseMusic.volume = 0; - pauseMusic.play(false, FlxG.random.int(0, Std.int(pauseMusic.length / 2))); - - FlxG.sound.list.add(pauseMusic); - - bg = new FlxSprite().makeGraphic(FlxG.width, FlxG.height, FlxColor.BLACK); - bg.alpha = 0; - bg.scrollFactor.set(); - add(bg); - - metaDataGrp = new FlxTypedGroup<FlxSprite>(); - add(metaDataGrp); - - var levelInfo:FlxText = new FlxText(20, 15, 0, '', 32); - if (PlayState.instance.currentChart != null) - { - levelInfo.text += '${PlayState.instance.currentChart.songName} - ${PlayState.instance.currentChart.songArtist}'; - } - levelInfo.scrollFactor.set(); - levelInfo.setFormat(Paths.font('vcr.ttf'), 32); - levelInfo.updateHitbox(); - metaDataGrp.add(levelInfo); - - var levelDifficulty:FlxText = new FlxText(20, 15 + 32, 0, '', 32); - levelDifficulty.text += PlayState.instance.currentDifficulty.toTitleCase(); - levelDifficulty.scrollFactor.set(); - levelDifficulty.setFormat(Paths.font('vcr.ttf'), 32); - levelDifficulty.updateHitbox(); - metaDataGrp.add(levelDifficulty); - - var deathCounter:FlxText = new FlxText(20, 15 + 64, 0, '', 32); - deathCounter.text = 'Blue balled: ${PlayState.instance.deathCounter}'; - FlxG.watch.addQuick('totalNotesHit', Highscore.tallies.totalNotesHit); - FlxG.watch.addQuick('totalNotes', Highscore.tallies.totalNotes); - deathCounter.scrollFactor.set(); - deathCounter.setFormat(Paths.font('vcr.ttf'), 32); - deathCounter.updateHitbox(); - metaDataGrp.add(deathCounter); - - practiceText = new FlxText(20, 15 + 64 + 32, 0, 'PRACTICE MODE', 32); - practiceText.scrollFactor.set(); - practiceText.setFormat(Paths.font('vcr.ttf'), 32); - practiceText.updateHitbox(); - practiceText.x = FlxG.width - (practiceText.width + 20); - practiceText.visible = PlayState.instance.isPracticeMode; - metaDataGrp.add(practiceText); - - levelDifficulty.alpha = 0; - levelInfo.alpha = 0; - deathCounter.alpha = 0; - - levelInfo.x = FlxG.width - (levelInfo.width + 20); - levelDifficulty.x = FlxG.width - (levelDifficulty.width + 20); - deathCounter.x = FlxG.width - (deathCounter.width + 20); - - FlxTween.tween(bg, {alpha: 0.6}, 0.4, {ease: FlxEase.quartInOut}); - FlxTween.tween(levelInfo, {alpha: 1, y: 20}, 0.4, {ease: FlxEase.quartInOut, startDelay: 0.3}); - FlxTween.tween(levelDifficulty, {alpha: 1, y: levelDifficulty.y + 5}, 0.4, {ease: FlxEase.quartInOut, startDelay: 0.5}); - FlxTween.tween(deathCounter, {alpha: 1, y: deathCounter.y + 5}, 0.4, {ease: FlxEase.quartInOut, startDelay: 0.7}); - - grpMenuShit = new FlxTypedGroup<Alphabet>(); - add(grpMenuShit); - - regenMenu(); - - // cameras = [FlxG.cameras.list[FlxG.cameras.list.length - 1]]; + this.bgColor = FlxColor.TRANSPARENT; // Transparent, fades into black later. } - function regenMenu():Void + public override function create():Void { - while (grpMenuShit.members.length > 0) - { - grpMenuShit.remove(grpMenuShit.members[0], true); - } + super.create(); - for (i in 0...menuItems.length) - { - var songText:Alphabet = new Alphabet(0, (70 * i) + 30, menuItems[i], true, false); - songText.isMenuItem = true; - songText.targetY = i; - grpMenuShit.add(songText); - } + startPauseMusic(); - curSelected = 0; - changeSelection(); + buildMetadata(); + + transitionIn(); } - override function update(elapsed:Float):Void + public override function update(elapsed:Float):Void { - if (pauseMusic.volume < 0.5) pauseMusic.volume += 0.01 * elapsed; - super.update(elapsed); handleInputs(); } + function startPauseMusic():Void + { + pauseMusic = FunkinSound.load(Paths.music('breakfast-pixel'), true, true); + + // Start playing at a random point in the song. + pauseMusic.play(false, FlxG.random.int(0, Std.int(pauseMusic.length / 2))); + pauseMusic.fadeIn(MUSIC_FADE_IN_TIME, 0, MUSIC_FINAL_VOLUME); + } + + /** + * Render the metadata in the top right. + */ + function buildMetadata():Void + { + metadata = new FlxTypedGroup<FlxSprite>(); + add(metadata); + + var metadataSong:FlxText = new FlxText(20, 15, 0, 'Song Name - Artist'); + metadataSong.setFormat(Paths.font('vcr.ttf'), 32, FlxColor.WHITE, FlxTextAlign.RIGHT); + if (PlayState.instance?.currentChart != null) + { + metadataSong.text += '${PlayState.instance.currentChart.songName} - ${PlayState.instance.currentChart.songArtist}'; + } + metadata.add(metadataSong); + + var metadataDifficulty:FlxText = new FlxText(20, 15 + 32, 0, 'Difficulty'); + metadataDifficulty.setFormat(Paths.font('vcr.ttf'), 32, FlxColor.WHITE, FlxTextAlign.RIGHT); + if (PlayState.instance?.currentDifficulty != null) + { + metadataDifficulty.text += PlayState.instance.currentDifficulty.toTitleCase(); + } + metadata.add(metadataDifficulty); + + var metadataDeaths:FlxText = new FlxText(20, 15 + 64, 0, '0 Blue Balls'); + metadataDeaths.setFormat(Paths.font('vcr.ttf'), 32, FlxColor.WHITE, FlxTextAlign.RIGHT); + metadataDeaths.text = '${PlayState.instance?.deathCounter} Blue Balls'; + metadata.add(metadataDeaths); + + metadataPractice = new FlxText(20, 15 + 96, 0, 'PRACTICE MODE'); + metadataPractice.setFormat(Paths.font('vcr.ttf'), 32, FlxColor.WHITE, FlxTextAlign.RIGHT); + metadataPractice.visible = PlayState.instance?.isPracticeMode ?? false; + metadata.add(metadataPractice); + } + + function regenerateMenu(?targetMode:PauseMode):Void + { + var previousMode:PauseMode = this.currentMode; + this.currentMode = targetMode ?? this.currentMode; + this.currentEntry = 0; + + menuEntryText.clear(); + + // Choose the correct menu entries. + switch (this.currentMode) + { + case PauseMode.Standard: + currentMenuEntries = PAUSE_MENU_ENTRIES_STANDARD.clone(); + case PauseMode.Charting: + currentMenuEntries = PAUSE_MENU_ENTRIES_CHARTING.clone(); + case PauseMode.Difficulty: + // Prepend the difficulties. + var entries:Array<PauseMenuEntry> = []; + if (PlayState.instance.currentChart != null) + { + var difficultiesInVariation = PlayState.instance.currentSong.listDifficulties(PlayState.instance.currentChart.variation); + trace('DIFFICULTIES: ${difficultiesInVariation}'); + for (difficulty in difficultiesInVariation) + { + difficulties.push({text: difficulty.toTitleCase(), callback: () -> changeDifficulty(this, difficulty)}); + } + } + + // Add the back button. + currentMenuEntries = entries.concat(PAUSE_MENU_ENTRIES_DIFFICULTY.clone()); + case PauseMode.Cutscene: + currentMenuEntries = PAUSE_MENU_ENTRIES_CUTSCENE.clone(); + } + + // Render out the entries depending on the mode. + for (entryIndex in 0...entries) + { + var entry:PauseMenuEntry = entries[entryIndex]; + + // Remove entries that should be hidden. + if (entry.filter != null && !entry.filter()) currentMenuEntries.remove(entry); + + var yPos:Float = 70 * entryIndex + 30; + var text:AtlasText = new AtlasText(0, yPos, entry.text, AtlasFont.BOLD); + text.alpha = 0; + menuEntryText.add(text); + + entry.sprite = text; + } + + metadataPractice.visible = PlayState.instance?.isPracticeMode ?? false; + + changeSelection(); + } + + function transitionIn():Void + { + FlxTween.globalManager.bgColor(this, 0.4, FlxColor.fromRGB(0, 0, 0, 0.0), FlxColor.fromRGB(0, 0, 0, 0.6), {ease: FlxEase.quartInOut}); + + // Animate each element a little bit downwards. + var delay:Float = 0.3; + for (child in metadata.members) + { + FlxTween.tween(child, {alpha: 1, y: child.y + 5}, 0.4, {ease: FlxEase.quartInOut, startDelay: delay}); + delay += 0.2; + } + } + function handleInputs():Void { - var upP = controls.UI_UP_P; - var downP = controls.UI_DOWN_P; - var accepted = controls.ACCEPT; + if (!allowInput) return; + + if (controls.UI_UP_P) + { + changeSelection(-1); + } + if (controls.UI_DOWN_P) + { + changeSelection(1); + } + if (controls.PAUSE) + { + resume(this); + } + if (controls.ACCEPT) + { + menuEntries[currentEntry].callback(this); + } #if (debug || FORCE_DEBUG_VERSION) // to pause the game and get screenshots easy, press H on pause menu! if (FlxG.keys.justPressed.H) { - bg.visible = !bg.visible; - grpMenuShit.visible = !grpMenuShit.visible; - metaDataGrp.visible = !metaDataGrp.visible; + var visible = !metaDataGrp.visible; + metadata = visible; + menuEntryText = visible; + this.bgColor = visible ? 0x99000000 : 0x00000000; // 60% or fully transparent black } #end - - if (!exitingToMenu) - { - if (upP) - { - changeSelection(-1); - } - if (downP) - { - changeSelection(1); - } - - var androidPause:Bool = false; - - #if android - androidPause = FlxG.android.justPressed.BACK; - #end - - if (androidPause) close(); - - if (accepted) - { - var daSelected:String = menuItems[curSelected]; - - switch (daSelected) - { - case 'Resume': - close(); - - case 'Change Difficulty': - menuItems = pauseOptionsDifficulty; - regenMenu(); - - case 'Toggle Practice Mode': - PlayState.instance.isPracticeMode = true; - practiceText.visible = PlayState.instance.isPracticeMode; - - case 'Restart Song': - PlayState.instance.needsReset = true; - close(); - - case 'Exit to Menu': - exitingToMenu = true; - PlayState.instance.deathCounter = 0; - - for (item in grpMenuShit.members) - { - item.targetY = -3; - item.alpha = 0.6; - } - - FlxTransitionableState.skipNextTransIn = true; - FlxTransitionableState.skipNextTransOut = true; - - if (PlayStatePlaylist.isStoryMode) - { - PlayStatePlaylist.reset(); - openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> new funkin.ui.story.StoryMenuState(sticker))); - } - else - { - openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> new funkin.ui.freeplay.FreeplayState(null, sticker))); - } - - case 'Exit to Chart Editor': - this.close(); - if (FlxG.sound.music != null) FlxG.sound.music.pause(); // Don't reset song position! - PlayState.instance.close(); // This only works because PlayState is a substate! - - case 'BACK': - menuItems = this.isChartingMode ? pauseOptionsCharting : pauseOptionsBase; - regenMenu(); - - default: - if (pauseOptionsDifficulty.contains(daSelected)) - { - PlayState.instance.currentSong = SongRegistry.instance.fetchEntry(PlayState.instance.currentSong.id.toLowerCase()); - - // Reset campaign score when changing difficulty - // So if you switch difficulty on the last song of a week you get a really low overall score. - PlayStatePlaylist.campaignScore = 0; - PlayStatePlaylist.campaignDifficulty = daSelected.toLowerCase(); - PlayState.instance.currentDifficulty = PlayStatePlaylist.campaignDifficulty; - - PlayState.instance.needsReset = true; - - close(); - } - else - { - trace('[WARN] Unhandled pause menu option: ${daSelected}'); - } - } - } - - if (FlxG.keys.justPressed.J) - { - // for reference later! - // PlayerSettings.player1.controls.replaceBinding(Control.LEFT, Keys, FlxKey.J, null); - } - } - } - - override function destroy():Void - { - pauseMusic.destroy(); - - super.destroy(); } function changeSelection(change:Int = 0):Void { FlxG.sound.play(Paths.sound('scrollMenu'), 0.4); - curSelected += change; + currentEntry += change; - if (curSelected < 0) curSelected = menuItems.length - 1; - if (curSelected >= menuItems.length) curSelected = 0; + if (currentEntry < 0) currentEntry = menuEntries.length - 1; + if (currentEntry >= menuEntries.length) currentEntry = 0; - for (index => item in grpMenuShit.members) + for (entryIndex in 0...menuEntries.length) { - item.targetY = index - curSelected; + var isCurrent:Bool = entryIndex == currentEntry; - item.alpha = 0.6; + var entry:PauseMenuEntry = menuEntries[entryIndex]; + var text:AtlasText = entry.sprite; - if (item.targetY == 0) - { - item.alpha = 1; - } + // Set the transparency. + text.alpha = isCurrent ? 1.0 : 0.6; + + // Set the position. + var targetX = FlxMath.remapToRange((entryIndex - currentEntry), 0, 1, 0, 1.3) * 20 + 90; + var targetY = FlxMath.remapToRange((entryIndex - currentEntry), 0, 1, 0, 1.3) * 120 + (FlxG.height * 0.48); + FlxTween.tween(text, {x: targetX, y: targetY}, 0.16, {ease: FlxEase.linear}); } } + + // =============== + // Menu Callbacks + // =============== + static function resume(state:PauseSubState):Void + { + state.close(); + } + + static function switchMode(state:PauseSubState, targetMode:PauseMode):Void + { + state.regenerateMenu(targetMode); + } + + static function changeDifficulty(state:PauseSubState, difficulty:String):Void + { + PlayState.instance.currentSong = SongRegistry.instance.fetchEntry(PlayState.instance.currentSong.id.toLowerCase()); + + // Reset campaign score when changing difficulty + // So if you switch difficulty on the last song of a week you get a really low overall score. + PlayStatePlaylist.campaignScore = 0; + PlayStatePlaylist.campaignDifficulty = difficulty; + PlayState.instance.currentDifficulty = PlayStatePlaylist.campaignDifficulty; + + PlayState.instance.needsReset = true; + + state.close(); + } + + static function restartPlayState(state:PauseSubState):Void + { + PlayState.instance.needsReset = true; + state.close(); + } + + static function enablePracticeMode(state:PauseSubState):Void + { + if (PlayState.instance == null) return; + + PlayState.instance.isPracticeMode = true; + regenerateMenu(); + } + + static function quitToMenu(state:PauseSubState):Void + { + state.allowInput = false; + + PlayState.instance.deathCounter = 0; + + FlxTransitionableState.skipNextTransIn = true; + FlxTransitionableState.skipNextTransOut = true; + + if (PlayStatePlaylist.isStoryMode) + { + PlayStatePlaylist.reset(); + openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> new funkin.ui.story.StoryMenuState(sticker))); + } + else + { + openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> new funkin.ui.freeplay.FreeplayState(null, sticker))); + } + } + + static function quitToChartEditor(state:PauseSubState):Void + { + state.close(); + if (FlxG.sound.music != null) FlxG.sound.music.pause(); // Don't reset song position! + PlayState.instance.close(); // This only works because PlayState is a substate! + } + + /** + * Reset the pause configuration to the default. + */ + public static function reset():Void + { + musicSuffix = ''; + } } + +/** + * Which set of options the pause menu should display. + */ +enum PauseMode +{ + /** + * The menu displayed when the player pauses the game during a song. + */ + Standard; + + /** + * The menu displayed when the player pauses the game during a song while in charting mode. + */ + Charting; + + /** + * The menu displayed when the player moves to change the game's difficulty. + */ + Difficulty; + + /** + * The menu displayed when the player pauses the game during a cutscene. + */ + Cutscene; +} + +typedef PauseMenuEntry = +{ + var text:String; + var callback:PauseSubState->Void; + + var ?sprite:AtlasText; + + /** + * If this returns true, the entry will be displayed. If it returns false, the entry will be hidden. + */ + var ?filter:Void->Bool; +}; diff --git a/source/funkin/util/tools/FlxTweenTools.hx b/source/funkin/util/tools/FlxTweenTools.hx new file mode 100644 index 000000000..0860af64b --- /dev/null +++ b/source/funkin/util/tools/FlxTweenTools.hx @@ -0,0 +1,25 @@ +package funkin.util.tools; + +import flixel.tweens.FlxTween; +import flixel.tweens.FlxTween.FlxTweenManager; + +class FlxTweenTools +{ + /** + * Tween the background color of a FlxState. + * @param globalManager `flixel.tweens.FlxTween.globalManager` + * @param targetState The FlxState to tween the background color of. + * @param duration The duration of the tween. + * @param fromColor The starting color. + * @param toColor The ending color. + * @param options The options for the tween. + * @return The tween. + */ + public static function bgColor(globalManager:FlxTweenManager, targetState:FlxState, duration:Float = 1.0, fromColor:FlxColor, toColor:FlxColor, + ?options:TweenOptions):BackgroundColorTween + { + var tween = new BackgroundColorTween(options, this); + tween.tween(duration, fromColor, toColor, targetState); + globalManager.add(tween); + } +}