package funkin.save; import flixel.util.FlxSave; import funkin.input.Controls.Device; import funkin.data.character.CharacterData.CharacterDataParser; import funkin.play.scoring.Scoring; import funkin.play.scoring.Scoring.ScoringRank; import funkin.save.migrator.RawSaveData_v1_0_0; import funkin.save.migrator.SaveDataMigrator; import funkin.ui.debug.charting.ChartEditorState.ChartEditorLiveInputStyle; import funkin.ui.debug.charting.ChartEditorState.ChartEditorTheme; import funkin.ui.debug.stageeditor.StageEditorState.StageEditorTheme; import funkin.util.FileUtil; import funkin.util.macro.ConsoleMacro; import funkin.util.macro.SaveMacro; import funkin.util.SerializerUtil; import funkin.mobile.ui.FunkinHitbox; import thx.semver.Version; #if FEATURE_NEWGROUNDS import funkin.api.newgrounds.Medals; import funkin.api.newgrounds.Leaderboards; #end @:nullSafety @:build(funkin.util.macro.SaveMacro.buildSaveProperties()) class Save implements ConsoleClass { public static final SAVE_DATA_VERSION:thx.semver.Version = "2.1.1"; public static final SAVE_DATA_VERSION_RULE:thx.semver.VersionRule = ">=2.1.0 <2.2.0"; public static var system:SaveSystem = new SaveSystem(); /** * Singleton for our Save class */ public static var instance(get, never):Save; static var _instance:Null = null; static function get_instance():Save { if (_instance == null) return load(); return _instance; } var data:RawSaveData; public static function load():Save { trace("[SAVE] Loading save..."); // Bind save data. final loadedSave:Save = loadFromSlot(Constants.BASE_SAVE_SLOT); _instance ??= loadedSave; return loadedSave; } public static function clearData():Void { _instance = Save.system.clearSlot(Constants.BASE_SAVE_SLOT); } /** * Constructing a new Save will load the default values. */ @:nullSafety(Off) public function new(?data:RawSaveData) { this.data = data ??= Save.getDefaultData(); // Build macro will inject SaveProperty initialization here automatically // Make sure the verison number is up to date before we flush. updateVersionToLatest(); } public static function getDefaultData():RawSaveData { #if mobile var refreshRate:Int = FlxG.stage.window.displayMode.refreshRate; if (refreshRate < 60) refreshRate = 60; #end return { // Version number is an abstract(Array) internally. // This means it copies by reference, so merging save data overides the version number lol. version: thx.Dynamics.clone(Save.SAVE_DATA_VERSION), volume: 1.0, mute: false, api: { newgrounds: { sessionId: null, } }, scores: { // No saved scores. levels: [], songs: [], }, favoriteSongs: [], options: { // Reasonable defaults. framerate: #if mobile refreshRate #else 60 #end, naughtyness: true, downscroll: false, flashingLights: true, zoomCamera: true, debugDisplay: 'Off', debugDisplayBGOpacity: 50, subtitles: true, hapticsMode: 'All', hapticsIntensityMultiplier: 1, autoPause: true, vsyncMode: 'Off', strumlineBackgroundOpacity: 0, autoFullscreen: false, globalOffset: 0, audioVisualOffset: 0, unlockedFramerate: false, screenshot: { shouldHideMouse: true, fancyPreview: true, previewOnSave: true, }, controls: { // Leave controls blank so defaults are loaded. p1: { keyboard: {}, gamepad: {}, }, p2: { keyboard: {}, gamepad: {}, }, }, }, #if mobile mobileOptions: { // Reasonable defaults. screenTimeout: false, controlsScheme: FunkinHitboxControlSchemes.Arrows, noAds: false }, #end mods: { // No mods enabled. enabledMods: [], modOptions: [], }, unlocks: { // Default to having seen the default character. charactersSeen: ["bf"], oldChar: false }, optionsChartEditor: { // Reasonable defaults. previousFiles: [], noteQuant: 3, chartEditorLiveInputStyle: ChartEditorLiveInputStyle.None, theme: ChartEditorTheme.Light, playtestStartTime: false, playtestAudioSettings: false, downscroll: false, showNoteKinds: true, metronomeVolume: 1.0, hitsoundVolumePlayer: 1.0, hitsoundVolumeOpponent: 1.0, instVolume: 1.0, playerVoiceVolume: 1.0, opponentVoiceVolume: 1.0, playbackSpeed: 0.5, themeMusic: true }, optionsStageEditor: { previousFiles: [], moveStep: "1px", angleStep: 5, theme: StageEditorTheme.Light, bfChar: "bf", gfChar: "gf", dadChar: "dad" } }; } /** * NOTE: Modifications will not be saved without calling `Save.flush()`! */ public var options(get, never):SaveDataOptions; function get_options():SaveDataOptions { return data.options; } #if mobile /** * NOTE: Modifications will not be saved without calling `Save.flush()`! */ public var mobileOptions(get, never):SaveDataMobileOptions; function get_mobileOptions():SaveDataMobileOptions { return data.mobileOptions; } #end /** * NOTE: Modifications will not be saved without calling `Save.flush()`! */ public var modOptions(get, never):Map; function get_modOptions():Map { return data.mods.modOptions; } /** * The user's current volume setting. */ @:saveProperty(data.volume) public var volume:SaveProperty; /** * Whether the user's volume is currently muted. */ @:saveProperty(data.mute) public var mute:SaveProperty; /// /// API /// /** * The current session ID for the logged-in Newgrounds user, or null if the user is cringe. */ @:saveProperty(data.api.newgrounds.sessionId) public var ngSessionId:SaveProperty>; /// /// MODS /// @:saveProperty(data.mods.enabledMods) public var enabledModIds:SaveProperty>; /// /// CHART EDITOR OPTIONS /// @:saveProperty(data.optionsChartEditor.previousFiles, []) public var chartEditorPreviousFiles:SaveProperty>; @:saveProperty(data.optionsChartEditor.hasBackup, false) public var chartEditorHasBackup:SaveProperty; @:saveProperty(data.optionsChartEditor.noteQuant, 3) public var chartEditorNoteQuant:SaveProperty; @:saveProperty(data.optionsChartEditor.chartEditorLiveInputStyle, ChartEditorLiveInputStyle.None) public var chartEditorLiveInputStyle:SaveProperty; @:saveProperty(data.optionsChartEditor.downscroll, false) public var chartEditorDownscroll:SaveProperty; @:saveProperty(data.optionsChartEditor.showNoteKinds, true) public var chartEditorShowNoteKinds:SaveProperty; @:saveProperty(data.optionsChartEditor.showSubtitles, true) public var chartEditorShowSubtitles:SaveProperty; @:saveProperty(data.optionsChartEditor.playtestStartTime, false) public var chartEditorPlaytestStartTime:SaveProperty; @:saveProperty(data.optionsChartEditor.playtestAudioSettings, false) public var chartEditorPlaytestAudioSettings:SaveProperty; @:saveProperty(data.optionsChartEditor.theme, ChartEditorTheme.Light) public var chartEditorTheme:SaveProperty; @:saveProperty(data.optionsChartEditor.metronomeVolume, 1.0) public var chartEditorMetronomeVolume:SaveProperty; @:saveProperty(data.optionsChartEditor.hitsoundVolumePlayer, 1.0) public var chartEditorHitsoundVolumePlayer:SaveProperty; @:saveProperty(data.optionsChartEditor.hitsoundVolumeOpponent, 1.0) public var chartEditorHitsoundVolumeOpponent:SaveProperty; @:saveProperty(data.optionsChartEditor.instVolume, 1.0) public var chartEditorInstVolume:SaveProperty; @:saveProperty(data.optionsChartEditor.playerVoiceVolume, 1.0) public var chartEditorPlayerVoiceVolume:SaveProperty; @:saveProperty(data.optionsChartEditor.opponentVoiceVolume, 1.0) public var chartEditorOpponentVoiceVolume:SaveProperty; @:saveProperty(data.optionsChartEditor.themeMusic, true) public var chartEditorThemeMusic:SaveProperty; @:saveProperty(data.optionsChartEditor.playbackSpeed, 0.5) public var chartEditorPlaybackSpeed:SaveProperty; @:saveProperty(data.unlocks.charactersSeen, ["bf"]) public var charactersSeen:SaveProperty>; /** * Marks whether the player has seen the spotlight animation, which should only display once per save file ever. */ @:saveProperty(data.unlocks.oldChar) public var oldChar:SaveProperty; /// /// STAGE EDITOR /// @:saveProperty(data.optionsStageEditor.previousFiles, []) public var stageEditorPreviousFiles:SaveProperty>; @:saveProperty(data.optionsStageEditor.hasBackup, false) public var stageEditorHasBackup:SaveProperty; @:saveProperty(data.optionsStageEditor.moveStep, "1px") public var stageEditorMoveStep:SaveProperty; @:saveProperty(data.optionsStageEditor.angleStep, 5.0) public var stageEditorAngleStep:SaveProperty; @:saveProperty(data.optionsStageEditor.theme, StageEditorTheme.Light) public var stageEditorTheme:SaveProperty; public var stageBoyfriendChar(get, set):String; function get_stageBoyfriendChar():String { if (data.optionsStageEditor.bfChar == null || CharacterDataParser.fetchCharacterData(data.optionsStageEditor.bfChar) == null) data.optionsStageEditor.bfChar = "bf"; return data.optionsStageEditor.bfChar; } function set_stageBoyfriendChar(value:String):String { // Set and apply. data.optionsStageEditor.bfChar = value; Save.system.flush(); return data.optionsStageEditor.bfChar; } public var stageGirlfriendChar(get, set):String; function get_stageGirlfriendChar():String { if (data.optionsStageEditor.gfChar == null || CharacterDataParser.fetchCharacterData(data.optionsStageEditor.gfChar ?? "") == null) data.optionsStageEditor.gfChar = "gf"; return data.optionsStageEditor.gfChar; } function set_stageGirlfriendChar(value:String):String { // Set and apply. data.optionsStageEditor.gfChar = value; Save.system.flush(); return data.optionsStageEditor.gfChar; } public var stageDadChar(get, set):String; function get_stageDadChar():String { if (data.optionsStageEditor.dadChar == null || CharacterDataParser.fetchCharacterData(data.optionsStageEditor.dadChar ?? "") == null) data.optionsStageEditor.dadChar = "dad"; return data.optionsStageEditor.dadChar; } function set_stageDadChar(value:String):String { // Set and apply. data.optionsStageEditor.dadChar = value; Save.system.flush(); return data.optionsStageEditor.dadChar; } /// UTIL FUNCITONS /** * Call this to make sure the save data is written to disk. */ public function flush():Void { Save.system.flush(); } /** * When we've seen a character unlock, add it to the list of characters seen. * @param character */ public function addCharacterSeen(character:String):Void { if (!data.unlocks.charactersSeen.contains(character)) { trace('Character seen: ' + character); data.unlocks.charactersSeen.push(character); trace('New characters seen list: ' + data.unlocks.charactersSeen); Save.system.flush(); } } /** * Return the score the user achieved for a given level on a given difficulty. * * @param levelId The ID of the level/week. * @param difficultyId The difficulty to check. * @return A data structure containing score, judgement counts, and accuracy. Returns `null` if no score is saved. */ public function getLevelScore(levelId:String, difficultyId:String = 'normal'):Null { if (data.scores?.levels == null) { if (data.scores == null) { data.scores = { songs: [], levels: [] }; } else { data.scores.levels = []; } } var level = data.scores.levels.get(levelId); if (level == null) { level = []; data.scores.levels.set(levelId, level); } return level.get(difficultyId); } /** * Apply the score the user achieved for a given level on a given difficulty. */ public function setLevelScore(levelId:String, difficultyId:String, score:SaveScoreData):Void { var level = data.scores.levels.get(levelId); if (level == null) { level = []; data.scores.levels.set(levelId, level); } level.set(difficultyId, score); Save.system.flush(); } public function isLevelHighScore(levelId:String, difficultyId:String = 'normal', score:SaveScoreData):Bool { var level = data.scores.levels.get(levelId); if (level == null) { level = []; data.scores.levels.set(levelId, level); } var currentScore = level.get(difficultyId); if (currentScore == null) { return true; } return score.score > currentScore.score; } public function hasBeatenLevel(levelId:String, ?difficultyList:Array):Bool { #if UNLOCK_EVERYTHING return true; #end if (difficultyList == null) { difficultyList = ['easy', 'normal', 'hard']; } for (difficulty in difficultyList) { var score:Null = getLevelScore(levelId, difficulty); if (score != null) { 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. continue; } } } return false; } /** * Return the score the user achieved for a given song on a given difficulty. * * @param songId The ID of the song. * @param difficultyId The difficulty to check. * @param variation The variation to check. Defaults to empty string. Appended to difficulty with `-`, e.g. `easy-pico`. * @return A data structure containing score, judgement counts, and accuracy. Returns `null` if no score is saved. */ public function getSongScore(songId:String, difficultyId:String = 'normal', ?variation:String):Null { var song = data.scores.songs.get(songId); if (song == null) { trace('Could not find song data for $songId $difficultyId $variation'); song = []; data.scores.songs.set(songId, song); } // 'default' variations are left with no suffix ('easy', 'normal', 'hard'), // along with 'erect' variations ('erect', 'nightmare') // otherwise, we want to add a suffix of our current variation to get the save data. if (variation != null && variation != '' && variation != 'default' && variation != 'erect') { difficultyId = '${difficultyId}-${variation}'; } return song.get(difficultyId); } public function getSongRank(songId:String, difficultyId:String = 'normal', ?variation:String):Null { return Scoring.calculateRank(getSongScore(songId, difficultyId, variation)); } /** * Directly set the score the user achieved for a given song on a given difficulty. */ public function setSongScore(songId:String, difficultyId:String, score:SaveScoreData):Void { var song = data.scores.songs.get(songId); if (song == null) { song = []; data.scores.songs.set(songId, song); } song.set(difficultyId, score); Save.system.flush(); } /** * Only replace the ranking data for the song, because the old score is still better. */ public function applySongRank(songId:String, difficultyId:String, newScoreData:SaveScoreData):Void { var newRank = Scoring.calculateRank(newScoreData); if (newScoreData == null || newRank == null) return; var song = data.scores.songs.get(songId); if (song == null) { song = []; data.scores.songs.set(songId, song); } var previousScoreData = song.get(difficultyId); var previousRank = Scoring.calculateRank(previousScoreData); if (previousScoreData == null || previousRank == null) { // Directly set the highscore. setSongScore(songId, difficultyId, newScoreData); return; } // Set the high score and the high rank separately. var newScore:SaveScoreData = { score: (previousScoreData.score > newScoreData.score) ? previousScoreData.score : newScoreData.score, tallies: (previousRank > newRank || Scoring.tallyCompletion(previousScoreData.tallies) > Scoring.tallyCompletion(newScoreData.tallies)) ? previousScoreData.tallies : newScoreData.tallies }; song.set(difficultyId, newScore); Save.system.flush(); } /** * Is the provided score data better than the current high score for the given song? * @param songId The song ID to check. * @param difficultyId The difficulty to check. * @param score The score to check. * @return Whether the score is better than the current high score. */ public function isSongHighScore(songId:String, difficultyId:String = 'normal', score:SaveScoreData):Bool { var song = data.scores.songs.get(songId); if (song == null) { song = []; data.scores.songs.set(songId, song); } var currentScore = song.get(difficultyId); if (currentScore == null) { return true; } return score.score > currentScore.score; } /** * Is the provided score data better than the current rank for the given song? * @param songId The song ID to check. * @param difficultyId The difficulty to check. * @param score The score to check the rank for. * @return Whether the score's rank is better than the current rank. */ public function isSongHighRank(songId:String, difficultyId:String = 'normal', score:SaveScoreData):Bool { var newScoreRank = Scoring.calculateRank(score); if (newScoreRank == null) { // The provided score is invalid. return false; } var song = data.scores.songs.get(songId); if (song == null) { song = []; data.scores.songs.set(songId, song); } var currentScore = song.get(difficultyId); var currentScoreRank = Scoring.calculateRank(currentScore); if (currentScoreRank == null) { // There is no primary highscore for this song. return true; } return newScoreRank > currentScoreRank; } /** * Has the provided song been beaten on one of the listed difficulties? * Note: This function can still take in the 'difficulty-variation' format for the difficultyList parameter * as it is used in the old save data format. However inputting a variation will append it to the difficulty * so you can do `hasBeatenSong('dadbattle', ['easy-pico'])` to check if you've beaten the Pico mix on easy. * or you can do `hasBeatenSong('dadbattle', ['easy'], 'pico')` to check if you've beaten the Pico mix on easy. * however you should not mix the two as it will append '-pico' to the 'easy-pico' if it's inputted into the array. * @param songId The song ID to check. * @param difficultyList The difficulties to check. Defaults to `easy`, `normal`, and `hard`. * @param variation The variation to check. Defaults to empty string. Appended to difficulty list with `-`, e.g. `easy-pico`. * This is our old format for getting difficulty/variation information, however we don't want to mess around with * save migration just yet. * @return Whether the song has been beaten on any of the listed difficulties. */ public function hasBeatenSong(songId:String, ?difficultyList:Array, ?variation:String):Bool { if (difficultyList == null) { difficultyList = ['easy', 'normal', 'hard']; } if (variation == null) variation = ''; for (difficulty in difficultyList) { if (variation != '') difficulty = '${difficulty}-${variation}'; var score:Null = getSongScore(songId, difficulty); if (score != null) { #if NO_UNLOCK_EVERYTHING 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. continue; } #else return true; #end } } return false; } public function isSongFavorited(id:String):Bool { if (data.favoriteSongs == null) { data.favoriteSongs = []; Save.system.flush(); }; return data.favoriteSongs.contains(id); } public function favoriteSong(id:String):Void { if (!isSongFavorited(id)) { data.favoriteSongs.push(id); Save.system.flush(); } } public function unfavoriteSong(id:String):Void { if (isSongFavorited(id)) { data.favoriteSongs.remove(id); Save.system.flush(); } } public function getControls(playerId:Int, inputType:Device):Null { switch (inputType) { case Keys: return (playerId == 0) ? data?.options?.controls?.p1.keyboard : data?.options?.controls?.p2.keyboard; case Gamepad(_): return (playerId == 0) ? data?.options?.controls?.p1.gamepad : data?.options?.controls?.p2.gamepad; } } public function hasControls(playerId:Int, inputType:Device):Bool { var controls = getControls(playerId, inputType); if (controls == null) return false; var controlsFields = Reflect.fields(controls); return controlsFields.length > 0; } public function setControls(playerId:Int, inputType:Device, controls:SaveControlsData):Void { final getPlayer:Int->PlayerControlData = function(id) return id == 0 ? data.options.controls.p1 : data.options.controls.p2; switch (inputType) { case Keys: getPlayer(playerId).keyboard = controls; case Gamepad(_): getPlayer(playerId).gamepad = controls; } } public function isCharacterUnlocked(characterId:String):Bool { switch (characterId) { case 'bf': return true; case 'pico': return hasBeatenLevel('weekend1'); default: trace('Unknown character ID: ' + characterId); return true; } } /** * If you set slot to `2`, it will load an independent save file from slot 2. * @param slot */ @:haxe.warning("-WDeprecated") static function loadFromSlot(slot:Int):Save { trace('[SAVE] Loading save from slot $slot...'); FlxG.save.bind(Constants.SAVE_NAME + slot, Constants.SAVE_PATH); switch (FlxG.save.status) { case EMPTY: trace('[SAVE] Save data in slot ${slot} is empty, checking for legacy save data...'); switch (Save.system.fetchLegacySaveData()) { case None: trace('[SAVE] No legacy save data found.'); var gameSave:Save = new Save(); FlxG.save.mergeData(gameSave.data, true); return gameSave; case Some(legacySaveData): trace('[SAVE] Found legacy save data, converting...'); var gameSave = SaveDataMigrator.migrateFromLegacy(legacySaveData); FlxG.save.mergeData(gameSave.data, true); return gameSave; } case ERROR(_): // DEPRECATED: Unused return handleSaveDataError(slot); case SAVE_ERROR(_): return handleSaveDataError(slot); case LOAD_ERROR(_): return handleSaveDataError(slot); case BOUND(_, _): trace('[SAVE] Loaded existing save data in slot ${slot}.'); var gameSave = SaveDataMigrator.migrate(FlxG.save.data); FlxG.save.mergeData(gameSave.data, true); return gameSave; } } /** * Call this when there is an error loading the save data in slot X. */ static function handleSaveDataError(slot:Int):Save { var msg = 'There was an error loading your save data in slot ${slot}.'; msg += '\nPlease report this issue to the developers.'; funkin.util.WindowUtil.showError("Save Data Failure", msg); // Don't touch that slot anymore. // Instead, load the next available slot. var nextSlot:Int = slot + 1; if (nextSlot > 1000) throw "End of save data slots. Can't load any more."; return loadFromSlot(nextSlot); } public static function debug_queryBadSaveData():Void { final RECOVERY_SLOT_START = 1000; final RECOVERY_SLOT_END = 1100; var firstBadSaveData = querySlotRange(RECOVERY_SLOT_START, RECOVERY_SLOT_END); if (firstBadSaveData > 0) { trace('[SAVE] Found bad save data in slot ${firstBadSaveData}!'); trace('We should look into recovery...'); trace(haxe.Json.stringify(fetchFromSlotRaw(firstBadSaveData))); } } static function fetchFromSlotRaw(slot:Int):Null { var targetSaveData = new FlxSave(); targetSaveData.bind(Constants.SAVE_NAME + slot, Constants.SAVE_PATH); if (targetSaveData.isEmpty()) return null; return targetSaveData.data; } /** * Return true if the given save slot is not empty. * @param slot The slot number to check. * @return Whether the slot is not empty. */ @:haxe.warning("-WDeprecated") static function querySlot(slot:Int):Bool { var targetSaveData:FlxSave = new FlxSave(); targetSaveData.bind(Constants.SAVE_NAME + slot, Constants.SAVE_PATH); switch (targetSaveData.status) { case EMPTY: return false; case ERROR(_): // DEPRECATED: Unused return false; case LOAD_ERROR(_): return false; case SAVE_ERROR(_): return false; case BOUND(_, _): return true; } } /** * Return true if any of the slots in the given range is not empty. * @param start The starting slot number to check. * @param end The ending slot number to check. * @return The first slot in the range that is not empty, or `-1` if none are. */ static function querySlotRange(start:Int, end:Int):Int { for (i in start...end) { if (querySlot(i)) return i; } return -1; } /** * Serialize this Save into a JSON string. * @param pretty Whether the JSON should be big ol string (false), * or pretty printed formatted with tabs (true) * @return The JSON string. */ public function serializeJson(pretty:Bool = true):String { var ignoreNullOptionals:Bool = true; var writer = new json2object.JsonWriter(ignoreNullOptionals); return writer.write(data, pretty ? ' ' : null); } public function updateVersionToLatest():Void { this.data.version = Save.SAVE_DATA_VERSION; } public function debug_dumpSaveJsonSave():Void { FileUtil.saveFile(haxe.io.Bytes.ofString(this.serializeJson()), [FileUtil.FILE_FILTER_JSON], null, null, './save.json', 'Write save data as JSON...'); } public function debug_dumpSaveJsonPrint():Void { trace(this.serializeJson()); } #if FEATURE_NEWGROUNDS public static function saveToNewgrounds():Void { if (_instance == null) return; trace('[SAVE] Saving Save Data to Newgrounds...'); funkin.api.newgrounds.NGSaveSlot.instance.save(_instance.data); } public static function loadFromNewgrounds(onFinish:Void->Void):Void { trace('[SAVE] Loading Save Data from Newgrounds...'); funkin.api.newgrounds.NGSaveSlot.instance.load((data:Dynamic) -> { FlxG.save.bind(Constants.SAVE_NAME + Constants.BASE_SAVE_SLOT, Constants.SAVE_PATH); if (FlxG.save.status != EMPTY) { // best i can do in case the NG file is corrupted or something along those lines var backupSlot:Int = Save.system.archiveBadSaveData(FlxG.save.data); trace('[SAVE] Backed up current save data in case of emergency to $backupSlot!'); } FlxG.save.erase(); FlxG.save.bind(Constants.SAVE_NAME + Constants.BASE_SAVE_SLOT, Constants.SAVE_PATH); // forces regeneration of the file as erase deletes it var gameSave = SaveDataMigrator.migrate(data); FlxG.save.mergeData(gameSave.data, true); _instance = gameSave; onFinish(); }, (error:io.newgrounds.Call.CallError) -> { var errorMsg:String = io.newgrounds.Call.CallErrorTools.toString(error); var msg = 'There was an error loading your save data from Newgrounds.'; msg += '\n${errorMsg}'; msg += '\nAre you sure you are connected to the internet?'; funkin.util.WindowUtil.showError("Newgrounds Save Slot Failure", msg); }); } #end } /** * An anonymous structure containingg all the user's save data. * Isn't stored with JSON, stored with some sort of Haxe built-in serialization? */ typedef RawSaveData = { // Flixel save data. var volume:Float; var mute:Bool; /** * A semantic versioning string for the save data format. */ var version:Version; var api:SaveApiData; /** * The user's saved scores. */ var scores:SaveHighScoresData; /** * The user's preferences. */ var options:SaveDataOptions; var unlocks:SaveDataUnlocks; #if mobile /** * The user's preferences for mobile. */ var mobileOptions:SaveDataMobileOptions; #end /** * The user's favorited songs in the Freeplay menu, * as a list of song IDs. */ var favoriteSongs:Array; var mods:SaveDataMods; /** * The user's preferences specific to the Chart Editor. */ var optionsChartEditor:SaveDataChartEditorOptions; /** * The user's preferences specific to the Stage Editor. */ var optionsStageEditor:SaveDataStageEditorOptions; }; typedef SaveApiData = { var newgrounds:SaveApiNewgroundsData; } 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; /** * This is a conditional when the player enters the character state * For the first time ever */ var oldChar:Bool; } /** * An anoymous structure containing options about the user's high scores. */ typedef SaveHighScoresData = { /** * Scores for each level (or week). */ var levels:SaveScoreLevelsData; /** * Scores for individual songs. */ var songs:SaveScoreSongsData; }; typedef SaveDataMods = { var enabledMods:Array; // TODO: Make this not trip up the serializer when debugging. @:jignored var modOptions:Map; } /** * Key is the level ID, value is the SaveScoreLevelData. */ typedef SaveScoreLevelsData = Map; /** * Key is the song ID, value is the data for each difficulty. */ typedef SaveScoreSongsData = Map; /** * Key is the difficulty ID, value is the score. */ typedef SaveScoreDifficultiesData = Map; /** * An individual score. Contains the score, accuracy, and count of each judgement hit. */ typedef SaveScoreData = { /** * The score achieved. */ var score:Int; /** * The count of each judgement hit. */ var tallies:SaveScoreTallyData; } typedef SaveScoreTallyData = { var sick:Int; var good:Int; var bad:Int; var shit:Int; var missed:Int; var combo:Int; var maxCombo:Int; var totalNotesHit:Int; var totalNotes:Int; } /** * An anonymous structure containing all the user's options and preferences for the main game. * Every time you add a new option, it needs to be added here. */ typedef SaveDataOptions = { /** * FPS * @default `60` */ var framerate:Int; /** * Whether some particularly foul language is displayed. * @default `true` */ var naughtyness:Bool; /** * If enabled, the strumline is at the bottom of the screen rather than the top. * @default `false` */ var downscroll:Bool; /** * If disabled, flashing lights in the main menu and other areas will be less intense. * @default `true` */ var flashingLights:Bool; /** * If disabled, the camera bump synchronized to the beat. * @default `false` */ var zoomCamera:Bool; /** * If enabled, an FPS and memory counter will be displayed even if this is not a debug build. * @default `Off` */ var debugDisplay:String; /** * Opacity of the debug display's background. * @default `50` */ var debugDisplayBGOpacity:Int; /** * If enabled, subtitles will appear. * @default `true` */ var subtitles:Bool; /** * If enabled, haptic feedback will be enabled. * @default `All` */ var hapticsMode:String; /** * Multiplier of intensity for all the haptic feedback effects. * @default `1` */ var hapticsIntensityMultiplier:Float; /** * If enabled, the game will automatically pause when tabbing out. * @default `true` */ var autoPause:Bool; /** * If enabled, the game will utilize VSync (or adaptive VSync) on startup. * @default `Off` */ var vsyncMode:String; /** * 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; /** * If enabled, the game will automatically launch in fullscreen on startup. * @default `true` */ var autoFullscreen:Bool; /** * Offset the user's inputs by this many ms. * @default `0` */ var globalOffset:Int; /** * Unused !! * Affects the delay between the audio and the visuals during gameplay. * @default `0` */ var audioVisualOffset:Int; /** * If we want the framerate to be unlocked on HTML5. * @default `false` */ var unlockedFramerate:Bool; /** * Screenshot options * @param shouldHideMouse Should the mouse be hidden when taking a screenshot? Default: `true` * @param fancyPreview Show a fancy preview? Default: `true` * @param previewOnSave Only show the fancy preview after a screenshot is saved? Default: `true` */ var screenshot: { var shouldHideMouse:Bool; var fancyPreview:Bool; var previewOnSave:Bool; }; var controls: { var p1:PlayerControlData; var p2:PlayerControlData; }; } typedef PlayerControlData = { var keyboard:SaveControlsData; var gamepad:SaveControlsData; } #if mobile typedef SaveDataMobileOptions = { /** * If enabled, device will be able to sleep on its own. * @default `false` */ var screenTimeout:Bool; /** * Controls scheme for the hitbox. * @default `Arrows` */ var controlsScheme:String; /** * If bought, the game will not show any ads. * @default `false` */ var noAds:Bool; } #end /** * An anonymous structure containing a specific player's bound keys. * Each key is an action name and each value is an array of keycodes. * * If a keybind is `null`, it needs to be reinitialized to the default. * If a keybind is `[]`, it is UNBOUND by the user and should not be rebound. */ typedef SaveControlsData = { /** * Keybind for navigating in the menu. * @default `Up Arrow` */ var ?UI_UP:Array; /** * Keybind for navigating in the menu. * @default `Left Arrow` */ var ?UI_LEFT:Array; /** * Keybind for navigating in the menu. * @default `Right Arrow` */ var ?UI_RIGHT:Array; /** * Keybind for navigating in the menu. * @default `Down Arrow` */ var ?UI_DOWN:Array; /** * Keybind for hitting notes. * @default `A` and `Left Arrow` */ var ?NOTE_LEFT:Array; /** * Keybind for hitting notes. * @default `W` and `Up Arrow` */ var ?NOTE_UP:Array; /** * Keybind for hitting notes. * @default `S` and `Down Arrow` */ var ?NOTE_DOWN:Array; /** * Keybind for hitting notes. * @default `D` and `Right Arrow` */ var ?NOTE_RIGHT:Array; /** * Keybind for continue/OK in menus. * @default `Enter` and `Space` */ var ?ACCEPT:Array; /** * Keybind for back/cancel in menus. * @default `Escape` */ var ?BACK:Array; /** * Keybind for pausing the game. * @default `Escape` */ var ?PAUSE:Array; /** * Keybind for advancing cutscenes. * @default `Z` and `Space` and `Enter` */ var ?CUTSCENE_ADVANCE:Array; /** * Keybind for increasing volume. * @default `Plus` */ var ?VOLUME_UP:Array; /** * Keybind for decreasing volume. * @default `Minus` */ var ?VOLUME_DOWN:Array; /** * Keybind for muting/unmuting volume. * @default `Zero` */ var ?VOLUME_MUTE:Array; /** * Keybind for restarting a song. * @default `R` */ var ?RESET:Array; } /** * An anonymous structure containing all the user's options and preferences, specific to the Chart Editor. */ typedef SaveDataChartEditorOptions = { /** * Whether the Chart Editor created a backup the last time it closed. * Prompt the user to load it, then set this back to `false`. * @default `false` */ var ?hasBackup:Bool; /** * Previous files opened in the Chart Editor. * @default `[]` */ var ?previousFiles:Array; /** * Note snapping level in the Chart Editor. * @default `3` */ var ?noteQuant:Int; /** * Live input style in the Chart Editor. * @default `ChartEditorLiveInputStyle.None` */ var ?chartEditorLiveInputStyle:ChartEditorLiveInputStyle; /** * Theme in the Chart Editor. * @default `ChartEditorTheme.Light` */ var ?theme:ChartEditorTheme; /** * Downscroll in the Chart Editor. * @default `false` */ var ?downscroll:Bool; /** * Show Note Kind Indicator in the Chart Editor. * @default `true` */ var ?showNoteKinds:Bool; /** * Show Subtitles in the Chart Editor. * @default `true` */ var ?showSubtitles:Bool; /** * Metronome volume in the Chart Editor. * @default `1.0` */ var ?metronomeVolume:Float; /** * Hitsound volume (player) in the Chart Editor. * @default `1.0` */ var ?hitsoundVolumePlayer:Float; /** * Hitsound volume (opponent) in the Chart Editor. * @default `1.0` */ var ?hitsoundVolumeOpponent:Float; /** * If true, playtest songs from the current position in the Chart Editor. * @default `false` */ var ?playtestStartTime:Bool; /** * If true, playtest songs with the current audio settings in the Chart Editor. * @default `false` */ var ?playtestAudioSettings:Bool; /** * Theme music in the Chart Editor. * @default `true` */ var ?themeMusic:Bool; /** * Instrumental volume in the Chart Editor. * @default `1.0` */ var ?instVolume:Float; /** * Player voice volume in the Chart Editor. * @default `1.0` */ var ?playerVoiceVolume:Float; /** * Opponent voice volume in the Chart Editor. * @default `1.0` */ var ?opponentVoiceVolume:Float; /** * Playback speed in the Chart Editor. * @default `1.0` */ var ?playbackSpeed:Float; } typedef SaveDataStageEditorOptions = { // a lot of these things were copied from savedatacharteditoroptions /** * Whether the Stage Editor created a backup the last time it closed. * Prompt the user to load it, then set this back to `false`. * @default `false` */ var ?hasBackup:Bool; /** * Previous files opened in the Stage Editor. * @default `[]` */ var ?previousFiles:Array; /** * The Step at which an Object or Character is moved. * @default `1px` */ var ?moveStep:String; /** * The Step at which an Object is rotated. * @default `5` */ var ?angleStep:Float; /** * Theme in the Stage Editor. * @default `StageEditorTheme.Light` */ var ?theme:StageEditorTheme; /** * The BF character ID used in testing stages. * @default bf */ var ?bfChar:String; /** * The GF character ID used in testing stages. * @default gf */ var ?gfChar:String; /** * The Dad character ID used in testing stages. * @default dad */ var ?dadChar:String; }