package funkin.menus; import flixel.text.FlxText; import flixel.util.FlxColor; import funkin.backend.chart.Chart; import funkin.backend.chart.ChartData.ChartMetaData; import funkin.backend.scripting.events.menu.MenuChangeEvent; import funkin.backend.scripting.events.menu.freeplay.*; import funkin.backend.system.Conductor; import funkin.game.HealthIcon; import funkin.savedata.FunkinSave; import haxe.io.Path; using StringTools; class FreeplayState extends MusicBeatState { /** * Array containing all of the songs' metadata. */ public var songs:Array = []; /** * Current song metadata */ public var curSong:Null; /** * Current song difficulties */ public var curDifficulties:Array; /** * songs[curSelected].metas.get(curDiffMetaIndices[curDifficulty]) */ public var curDiffMetaKeys:Array = []; /* * Array containing all labels for game modes. */ public var gameModeLabels:Array = []; /** * Currently selected song */ public var curSelected:Int = 0; /** * Currently selected difficulty */ public var curDifficulty:Int = 1; /** * Currently selected game mode */ public var curGameMode:Int = 0; /** * Text containing the score info (PERSONAL BEST: 0) */ public var scoreText:FlxText; /** * Text containing the current difficulty (< HARD >) */ public var diffText:FlxText; /** * Text containing the current game mode, for example: ([KEYBINDS] Co-Op mode) */ public var gameModeText:FlxText; /** * Currently lerped score. Is updated to go towards `intendedScore`. */ public var lerpScore:Float = 0; /** * Destination for the currently lerped score. */ public var intendedScore:Int = 0; /** * Assigned FreeplaySonglist item. */ public var songList:FreeplaySonglist; /** * Black background around the score, the difficulty text and the co-op text. */ public var scoreBG:FlxSprite; /** * Background. */ public var bg:FlxSprite; /** * Whenever the player can navigate and select */ public var canSelect:Bool = true; /** * Group containing all of the alphabets */ public var grpSongs:FlxTypedGroup; /** * Whenever the currently selected song is playing. */ public var curPlaying:Bool = false; /** * Array containing all of the icons. */ public var iconArray:Array = []; /** * FlxInterpolateColor object for smooth transition between Freeplay colors. */ public var interpColor:FlxInterpolateColor; override function create() { CoolUtil.playMenuSong(); songList = FreeplaySonglist.get(); songs = songList.songs; for(k=>s in songs) { if (s.name == Options.freeplayLastSong) { curSelected = k; } } updateCurDifficulties(); for(i=>diff in curDifficulties) { if (curDiffMetaKeys[i] == Options.freeplayLastVariation && diff == Options.freeplayLastDifficulty) curDifficulty = i; } updateCurSong(); gameModeLabels = FreeplayGameMode.get(); DiscordUtil.call("onMenuLoaded", ["Freeplay"]); super.create(); // LOAD CHARACTERS bg = new FlxSprite(0, 0).loadAnimatedGraphic(Paths.image('menus/menuDesat')); if (songs.length > 0) bg.color = songs[0].color; bg.antialiasing = true; add(bg); grpSongs = new FlxTypedGroup(); add(grpSongs); for (i in 0...songs.length) { var songText:Alphabet = new Alphabet(0, (70 * i) + 30, songs[i].displayName, "bold"); songText.isMenuItem = true; songText.targetY = i; grpSongs.add(songText); var icon:HealthIcon = new HealthIcon(songs[i].icon); icon.sprTracker = songText; if (Math.max(icon.width, icon.height) > 150) icon.setUnstretchedGraphicSize(150, 150); // using a FlxGroup is too much fuss! iconArray.push(icon); add(icon); // songText.x += 40; // DON'T PUT X IN THE FIRST PARAMETER OF new ALPHABET() !! // songText.screenCenter(X); } scoreText = new FlxText(FlxG.width * 0.7, 5, 0, "", 32); scoreText.setFormat(Paths.font("vcr.ttf"), 32, FlxColor.WHITE, RIGHT); scoreBG = new FlxSprite(scoreText.x - 6, 0).makeGraphic(1, 1, 0xFF000000); scoreBG.alpha = 0.6; add(scoreBG); diffText = new FlxText(scoreText.x, scoreText.y + 36, 0, "", 24); diffText.font = scoreText.font; add(diffText); gameModeText = new FlxText(diffText.x, diffText.y + diffText.height + 2, 0, "", 24); gameModeText.font = scoreText.font; add(gameModeText); add(scoreText); changeSelection(0, true); changeDiff(0, true); changeGameMode(0, true); interpColor = new FlxInterpolateColor(bg.color); } #if PRELOAD_ALL /** * How much time a song stays selected until it autoplays. */ public var timeUntilAutoplay:Float = 1; /** * Whenever the song autoplays when hovered over. */ public var disableAutoPlay:Bool = false; /** * Whenever the autoplayed song gets async loaded. */ public var disableAsyncLoading:Bool = #if desktop false #else true #end; /** * Time elapsed since last autoplay. If this time exceeds `timeUntilAutoplay`, the currently selected song will play. */ public var autoplayElapsed:Float = 0; /** * Whenever the currently selected song instrumental is playing. */ public var songInstPlaying:Bool = true; /** * Path to the currently playing song instrumental. */ public var curPlayingInst:String = null; /** * If it should play the song automatically. */ public var autoplayShouldPlay:Bool = true; #end private var TEXT_FREEPLAY_SCORE = TU.getRaw("freeplay.score"); override function update(elapsed:Float) { super.update(elapsed); if (FlxG.sound.music != null && FlxG.sound.music.volume < 0.7) { FlxG.sound.music.volume += 0.5 * elapsed; } lerpScore = lerp(lerpScore, intendedScore, 0.4); if (Math.abs(lerpScore - intendedScore) <= 10) lerpScore = intendedScore; if (canSelect) { changeSelection((controls.UP_P ? -1 : 0) + (controls.DOWN_P ? 1 : 0) - FlxG.mouse.wheel); changeDiff((controls.LEFT_P ? -1 : 0) + (controls.RIGHT_P ? 1 : 0)); changeGameMode((controls.CHANGE_MODE ? 1 : 0)); // putting it before so that its actually smooth updateOptionsAlpha(); } scoreText.text = TEXT_FREEPLAY_SCORE.format([Math.round(lerpScore)]); scoreBG.scale.set(MathUtil.maxSmart(diffText.width, scoreText.width, gameModeText.width) + 8, (gameModeText.visible ? gameModeText.y + gameModeText.height : 66)); scoreBG.updateHitbox(); scoreBG.x = FlxG.width - scoreBG.width; scoreText.x = gameModeText.x = scoreBG.x + 4; diffText.x = Std.int(scoreBG.x + ((scoreBG.width - diffText.width) / 2)); interpColor.fpsLerpTo(curSong.color, 0.0625); bg.color = interpColor.color; #if PRELOAD_ALL var dontPlaySongThisFrame = false; autoplayElapsed += elapsed; if (!disableAutoPlay && !songInstPlaying && (autoplayElapsed > timeUntilAutoplay)) { if (curPlayingInst != (curPlayingInst = Paths.inst(curSong.name, curDifficulties[curDifficulty], curSong.instSuffix))) { var streamed = false; if (Options.streamedMusic) { var sound = Assets.getMusic(curPlayingInst, true, false); streamed = sound != null; if (streamed && autoplayShouldPlay) { FlxG.sound.playMusic(sound, 0); Conductor.changeBPM(curSong.bpm, curSong.beatsPerMeasure, curSong.stepsPerBeat); } } if (!streamed) { var huh:Void->Void = function() { var soundPath = curPlayingInst; var sound = null; if (Assets.exists(soundPath, SOUND) || Assets.exists(soundPath, MUSIC)) sound = Assets.getSound(soundPath); else FlxG.log.error('Could not find a Sound asset with an ID of \'$soundPath\'.'); if (sound != null && autoplayShouldPlay) { FlxG.sound.playMusic(sound, 0); Conductor.changeBPM(curSong.bpm, curSong.beatsPerMeasure, curSong.stepsPerBeat); } } if (!disableAsyncLoading) Main.execAsync(huh); else huh(); } } songInstPlaying = true; if (disableAsyncLoading && !Options.streamedMusic) dontPlaySongThisFrame = true; } #end if (controls.BACK) { CoolUtil.playMenuSFX(CANCEL, 0.7); FlxG.switchState(new MainMenuState()); } #if sys if (FlxG.keys.justPressed.EIGHT && Sys.args().contains("-livereload")) convertChart(); #end if (controls.ACCEPT #if PRELOAD_ALL && !dontPlaySongThisFrame #end) select(); } /** * Selects the current song. */ public function select() { if (curDifficulties.length == 0) return; var event = event("onSelect", EventManager.get(FreeplaySongSelectEvent).recycle(curSong.name, curDifficulties[curDifficulty], curSong.variant, gameModeLabels[curGameMode])); if (event.cancelled) return; #if PRELOAD_ALL autoplayShouldPlay = false; #end Options.freeplayLastSong = curSong.name; Options.freeplayLastDifficulty = curDifficulties[curDifficulty]; Options.freeplayLastVariation = curSong.variant; PlayState.loadSong(event.song, event.difficulty, event.variant, event.opponentMode, event.coopMode); FlxG.switchState(new PlayState()); } public function convertChart() { trace('Converting ${curSong.name} ${curDifficulties[curDifficulty]} ${curSong.variant} to Codename format...'); var chart = Chart.parse(curSong.name, curDifficulties[curDifficulty], curSong.variant); Chart.save(chart, curDifficulties[curDifficulty], curSong.variant); } /** * Changes the current difficulty * @param change How much to change. * @param force Force the change if `change` is equal to 0 */ public function changeDiff(change:Int = 0, force:Bool = false) { if (change == 0 && !force) return; var validDifficulties = curDifficulties.length > 0; var event = event("onChangeDiff", EventManager.get(MenuChangeEvent).recycle(curDifficulty, validDifficulties ? FlxMath.wrap(curDifficulty + change, 0, curDifficulties.length-1) : 0, change)); if (event.cancelled) { if (force) updateCurSong(); return; } var prevSong = curSong; curDifficulty = event.value; updateCurSong(); updateScore(); #if PRELOAD_ALL if (curSong != prevSong) { autoplayElapsed = 0; songInstPlaying = false; } #end var text = validDifficulties ? curDifficulties[curDifficulty].toUpperCase() + (curSong != songs[curSelected] ? ' (${curSong.variant.toUpperCase()})' : '') : '-'; diffText.text = curDifficulties.length > 1 ? '< $text >' : text; } function updateScore() { if (curDifficulties.length == 0) { intendedScore = 0; return; } var changes:Array = []; if (__coopMode) changes.push(CCoopMode); if (__opponentMode) changes.push(COpponentMode); var saveData = FunkinSave.getSongHighscore(curSong.name, curDifficulties[curDifficulty], curSong.variant, changes); intendedScore = saveData.score; } /** * Change the current coop mode context. * @param change How much to change * @param force Force the change, even if `change` is equal to 0. */ public function changeGameMode(change:Int = 0, force:Bool = false) { if (change == 0 && !force) return; var i = 0; var allowed = getAllowedGameModesID(); var wrapped = FlxMath.wrap(curGameMode + change, 0, gameModeLabels.length - 1); while (!allowed.contains(gameModeLabels[wrapped].modeID)) { // Skipping the blacklisted ones - Nex wrapped = FlxMath.wrap(wrapped + change, 0, gameModeLabels.length - 1); if (i++ > allowed.length) { curGameMode = 0; // Idk honestly if I should modify the other event's variables - Nex break; } } var e = EventManager.get(MenuChangeEvent).recycle(curGameMode, wrapped, change); event("onChangeCoopMode", e); // Backwards compat - Nex if (event("onChangeGameMode", e).cancelled) return; // Getting from scratch the allowed game modes just in case they changed when the event got called - Nex if (gameModeText.visible = getAllowedGameModesID().length > 0) gameModeText.text = "[TAB] " + gameModeLabels[curGameMode = e.value].modeName; updateScore(); } public inline function getAllowedGameModesID() { var excluded = songs[curSelected].excludedGameModes; return [for (mode in gameModeLabels) if (!excluded.contains(mode.modeID)) mode.modeID]; } /** * Change the current selection. * @param change How much to change * @param force Force the change, even if `change` is equal to 0. */ public function changeSelection(change:Int = 0, force:Bool = false) { if (change == 0 && !force) return; var event = event("onChangeSelection", EventManager.get(MenuChangeEvent).recycle(curSelected, FlxMath.wrap(curSelected + change, 0, songs.length-1), change)); if (event.cancelled) return; curSelected = event.value; if (event.playMenuSFX) CoolUtil.playMenuSFX(SCROLL, 0.7); var prevDiff = curDifficulties[curDifficulty], prevVariant = curDiffMetaKeys[curDifficulty]; updateCurDifficulties(); for (i => diff in curDifficulties) if (diff == prevDiff && curDiffMetaKeys[i] == prevVariant) { curDifficulty = i; break; } changeDiff(0, true); changeGameMode(0, true); #if PRELOAD_ALL autoplayElapsed = 0; songInstPlaying = false; #end } function updateOptionsAlpha() { var event = event("onUpdateOptionsAlpha", EventManager.get(FreeplayAlphaUpdateEvent).recycle(0.6, 0.45, 1, 1, 0.25)); if (event.cancelled) return; final idleAlpha = #if PRELOAD_ALL songInstPlaying ? event.idlePlayingAlpha : #end event.idleAlpha; final selectedAlpha = #if PRELOAD_ALL songInstPlaying ? event.selectedPlayingAlpha : #end event.selectedAlpha; for (i in 0...iconArray.length) iconArray[i].alpha = lerp(iconArray[i].alpha, idleAlpha, event.lerp); iconArray[curSelected].alpha = selectedAlpha; for (i => item in grpSongs.members) { item.targetY = i - curSelected; item.alpha = lerp(item.alpha, idleAlpha, event.lerp); if (item.targetY == 0) item.alpha = selectedAlpha; } } function updateCurDifficulties() { curDiffMetaKeys.resize(0); curDifficulties = songs[curSelected].difficulties.copy(); for (i in 0...curDifficulties.length) curDiffMetaKeys.push(null); if (songs[curSelected].variants != null) { var meta:ChartMetaData; for (variant in songs[curSelected].variants) if ((meta = songs[curSelected].metas.get(variant)) != null) { curDifficulties = curDifficulties.concat(meta.difficulties); for (i in 0...meta.difficulties.length) curDiffMetaKeys.push(variant); } } } function updateCurSong() { var song = songs[curSelected]; if (song == null) curSong = null; else if ((curSong = song.metas.get(curDiffMetaKeys[curDifficulty])) == null) curSong = song; } // Backwards compat @:noCompletion public function changeCoopMode(change:Int = 0, force:Bool = false) return changeGameMode(change, force); @:noCompletion public var coopText(get, set):FlxText; @:noCompletion private function get_coopText() return gameModeText; @:noCompletion private function set_coopText(val:FlxText) return gameModeText = val; @:noCompletion function updateCoopModes() {} @:noCompletion var __opponentMode(get, default):Bool = false; // God this is so cursed - Nex @:noCompletion private function get___opponentMode() return getAllowedGameModesID().contains("codename.opponent"); @:noCompletion var __coopMode(get, default):Bool = false; @:noCompletion private function get___coopMode() return getAllowedGameModesID().contains("codename.coop"); @:noCompletion var curCoopMode(get, set):Int; @:noCompletion private function get_curCoopMode() return curGameMode; @:noCompletion private function set_curCoopMode(val:Int) return curGameMode = val; @:noCompletion var coopLabels(get, set):Array; @:noCompletion private function get_coopLabels() return [for(mode in gameModeLabels) mode.modeName]; @:noCompletion private function set_coopLabels(names:Array) { gameModeLabels = [for(name in names) { if (name.startsWith("[TAB] ")) name = name.substr("[TAB] ".length); new FreeplayGameMode(name, switch(name) { case "Solo": "codename.solo"; case "Opponent Mode": "codename.opponent"; case "Co-Op Mode": "codename.coop"; case "Co-Op Mode (Switched)": "codename.coop-opponent"; default: name.toLowerCase().replace(" ", "-"); }, switch(name) { case "Solo": ["solo"]; case "Opponent Mode": ["opponent"]; case "Co-Op Mode": ["coop"]; case "Co-Op Mode (Switched)": ["coop-switched", "opponent", "coop"]; default: null; }); }]; return names; } } /** * Class used for the Freeplay menu game modes. * * **NOTE**: `fields` is useable not just for boolean values!! */ class FreeplayGameMode { public var modeName:String; public var modeID:String; public var scripts(default, set):Array; public var fields(default, set):Dynamic; @:noCompletion function set_scripts(val:Array) return scripts = (val == null ? [] : val); @:noCompletion function set_fields(val:Dynamic) return fields = (val == null ? {} : val); public function new(modeName:String, modeID:String, ?scripts:Array, ?fields:Dynamic) { this.modeName = modeName; this.modeID = modeID; this.scripts = scripts; this.fields = fields; } public function toModifiers():Array { var fields = this.fields; var modifiers:Array = []; for (field in {var _ = Reflect.fields(fields); _.sort(Reflect.compare); _;}) { var val:Dynamic = Reflect.field(fields, field); if (val != null && val != false) { // allow true, and floats/ints modifiers.push(field); modifiers.push(val); } } return modifiers; } public static inline function generateDefault() return new FreeplayGameMode("Solo Mode", "solo"); public static inline function getGameModesFromSource(source:AssetSource = BOTH, useTxt:Bool = true):Array { var path = 'data/gamemodes'; var txt = Paths.txt('gamemodes/list'); var list = useTxt && Paths.assetsTree.existsSpecific(txt, "TEXT", source) ? CoolUtil.coolTextFile(txt) : Paths.getFolderContent(path, false, source); return [for (file in list) { if (useTxt) file += ".json"; else if (Path.extension(file) != "json") continue; var meta:FreeplayGameMode = null; try { var data = CoolUtil.parseJson(Paths.file(path + "/" + file)); var id = data.modeID; meta = new FreeplayGameMode(CoolUtil.getDefault(data.displayName, id), id, [Path.withoutExtension(file)].concat(CoolUtil.getDefault(data.scripts, [])), data.fields); } catch(e) Logs.trace('Failed to load game mode metadata for $file ($path): ${Std.string(e)}', ERROR); if (meta != null) meta; }]; } public static function get(useTxt:Bool = true):Array { var list:Array; switch(Flags.GAME_MODES_LIST_MOD_MODE) { case 'prepend': list = getGameModesFromSource(MODS, useTxt).concat(getGameModesFromSource(SOURCE, useTxt)); case 'append': list = getGameModesFromSource(SOURCE, useTxt).concat(getGameModesFromSource(MODS, useTxt)); case 'override': list = getGameModesFromSource(BOTH, useTxt); default /*case 'oneOFtwo'*/: if ((list = getGameModesFromSource(MODS, useTxt)).length == 0) list = getGameModesFromSource(SOURCE, useTxt); } return list; } } class FreeplaySonglist { public var songs:Array = []; public function new() {} public function getSongsFromSource(source:funkin.backend.assets.AssetSource, useTxt:Bool = true) { var songsFound:Array = null; if (useTxt) { var oldPath = Paths.txt('freeplaySonglist'); var newPath = Paths.txt('config/freeplaySonglist'); if (Paths.assetsTree.existsSpecific(newPath, "TEXT", source)) songsFound = CoolUtil.coolTextFile(newPath); else if (Paths.assetsTree.existsSpecific(oldPath, "TEXT", source)) { Logs.warn("data/freeplaySonglist.txt is deprecated and will be removed in the future. Please move the file to data/config/", DARKYELLOW, "FreeplaySonglist"); songsFound = CoolUtil.coolTextFile(oldPath); } } if (songsFound == null) songsFound = Paths.getFolderDirectories("songs", false, source); if (songsFound.length > 0) { for (s in songsFound) songs.push(Chart.loadChartMeta(s, source == MODS)); return false; } return true; } public static function get(useTxt:Bool = true) { var songList = new FreeplaySonglist(); switch(Flags.SONGS_LIST_MOD_MODE) { case 'prepend': songList.getSongsFromSource(MODS, useTxt); songList.getSongsFromSource(SOURCE, useTxt); case 'append': songList.getSongsFromSource(SOURCE, useTxt); songList.getSongsFromSource(MODS, useTxt); case 'override': songList.getSongsFromSource(BOTH, useTxt); default /*case 'oneOFtwo'*/: if (songList.getSongsFromSource(MODS, useTxt)) songList.getSongsFromSource(SOURCE, useTxt); } return songList; } }