package funkin.save; import flixel.util.FlxSave; import funkin.save.migrator.SaveDataMigrator; import thx.semver.Version; import funkin.input.Controls.Device; 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 thx.semver.Version; import funkin.util.SerializerUtil; @:nullSafety class Save { public static final SAVE_DATA_VERSION:thx.semver.Version = "2.0.4"; public static final SAVE_DATA_VERSION_RULE:thx.semver.VersionRule = "2.0.x"; // We load this version's saves from a new save path, to maintain SOME level of backwards compatibility. static final SAVE_PATH:String = 'FunkinCrew'; static final SAVE_NAME:String = 'Funkin'; static final SAVE_PATH_LEGACY:String = 'ninjamuffin99'; static final SAVE_NAME_LEGACY:String = 'funkin'; public static var instance(get, never):Save; static var _instance:Null = null; static function get_instance():Save { if (_instance == null) { _instance = new Save(FlxG.save.data); } return _instance; } var data:RawSaveData; public static function load():Void { trace("[SAVE] Loading save..."); // Bind save data. loadFromSlot(1); } /** * Constructing a new Save will load the default values. */ public function new(?data:RawSaveData) { if (data == null) this.data = Save.getDefault(); else this.data = data; } public static function getDefault():RawSaveData { return { version: Save.SAVE_DATA_VERSION, volume: 1.0, mute: false, api: { newgrounds: { sessionId: null, } }, scores: { // No saved scores. levels: [], songs: [], }, favoriteSongs: [], options: { // Reasonable defaults. naughtyness: true, downscroll: false, flashingLights: true, zoomCamera: true, debugDisplay: false, autoPause: true, inputOffset: 0, audioVisualOffset: 0, controls: { // Leave controls blank so defaults are loaded. p1: { keyboard: {}, gamepad: {}, }, p2: { keyboard: {}, gamepad: {}, }, }, }, mods: { // No mods enabled. enabledMods: [], modOptions: [], }, optionsChartEditor: { // Reasonable defaults. previousFiles: [], noteQuant: 3, chartEditorLiveInputStyle: ChartEditorLiveInputStyle.None, theme: ChartEditorTheme.Light, playtestStartTime: false, downscroll: false, metronomeVolume: 1.0, hitsoundVolumePlayer: 1.0, hitsoundVolumeOpponent: 1.0, themeMusic: true }, }; } /** * NOTE: Modifications will not be saved without calling `Save.flush()`! */ public var options(get, never):SaveDataOptions; function get_options():SaveDataOptions { return data.options; } /** * 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 current session ID for the logged-in Newgrounds user, or null if the user is cringe. */ public var ngSessionId(get, set):Null; function get_ngSessionId():Null { return data.api.newgrounds.sessionId; } function set_ngSessionId(value:Null):Null { data.api.newgrounds.sessionId = value; flush(); return data.api.newgrounds.sessionId; } public var enabledModIds(get, set):Array; function get_enabledModIds():Array { return data.mods.enabledMods; } function set_enabledModIds(value:Array):Array { data.mods.enabledMods = value; flush(); return data.mods.enabledMods; } public var chartEditorPreviousFiles(get, set):Array; function get_chartEditorPreviousFiles():Array { if (data.optionsChartEditor.previousFiles == null) data.optionsChartEditor.previousFiles = []; return data.optionsChartEditor.previousFiles; } function set_chartEditorPreviousFiles(value:Array):Array { // Set and apply. data.optionsChartEditor.previousFiles = value; flush(); return data.optionsChartEditor.previousFiles; } public var chartEditorHasBackup(get, set):Bool; function get_chartEditorHasBackup():Bool { if (data.optionsChartEditor.hasBackup == null) data.optionsChartEditor.hasBackup = false; return data.optionsChartEditor.hasBackup; } function set_chartEditorHasBackup(value:Bool):Bool { // Set and apply. data.optionsChartEditor.hasBackup = value; flush(); return data.optionsChartEditor.hasBackup; } public var chartEditorNoteQuant(get, set):Int; function get_chartEditorNoteQuant():Int { if (data.optionsChartEditor.noteQuant == null) data.optionsChartEditor.noteQuant = 3; return data.optionsChartEditor.noteQuant; } function set_chartEditorNoteQuant(value:Int):Int { // Set and apply. data.optionsChartEditor.noteQuant = value; flush(); return data.optionsChartEditor.noteQuant; } public var chartEditorLiveInputStyle(get, set):ChartEditorLiveInputStyle; function get_chartEditorLiveInputStyle():ChartEditorLiveInputStyle { if (data.optionsChartEditor.chartEditorLiveInputStyle == null) data.optionsChartEditor.chartEditorLiveInputStyle = ChartEditorLiveInputStyle.None; return data.optionsChartEditor.chartEditorLiveInputStyle; } function set_chartEditorLiveInputStyle(value:ChartEditorLiveInputStyle):ChartEditorLiveInputStyle { // Set and apply. data.optionsChartEditor.chartEditorLiveInputStyle = value; flush(); return data.optionsChartEditor.chartEditorLiveInputStyle; } public var chartEditorDownscroll(get, set):Bool; function get_chartEditorDownscroll():Bool { if (data.optionsChartEditor.downscroll == null) data.optionsChartEditor.downscroll = false; return data.optionsChartEditor.downscroll; } function set_chartEditorDownscroll(value:Bool):Bool { // Set and apply. data.optionsChartEditor.downscroll = value; flush(); return data.optionsChartEditor.downscroll; } public var chartEditorPlaytestStartTime(get, set):Bool; function get_chartEditorPlaytestStartTime():Bool { if (data.optionsChartEditor.playtestStartTime == null) data.optionsChartEditor.playtestStartTime = false; return data.optionsChartEditor.playtestStartTime; } function set_chartEditorPlaytestStartTime(value:Bool):Bool { // Set and apply. data.optionsChartEditor.playtestStartTime = value; flush(); return data.optionsChartEditor.playtestStartTime; } public var chartEditorTheme(get, set):ChartEditorTheme; function get_chartEditorTheme():ChartEditorTheme { if (data.optionsChartEditor.theme == null) data.optionsChartEditor.theme = ChartEditorTheme.Light; return data.optionsChartEditor.theme; } function set_chartEditorTheme(value:ChartEditorTheme):ChartEditorTheme { // Set and apply. data.optionsChartEditor.theme = value; flush(); return data.optionsChartEditor.theme; } public var chartEditorMetronomeVolume(get, set):Float; function get_chartEditorMetronomeVolume():Float { if (data.optionsChartEditor.metronomeVolume == null) data.optionsChartEditor.metronomeVolume = 1.0; return data.optionsChartEditor.metronomeVolume; } function set_chartEditorMetronomeVolume(value:Float):Float { // Set and apply. data.optionsChartEditor.metronomeVolume = value; flush(); return data.optionsChartEditor.metronomeVolume; } public var chartEditorHitsoundVolumePlayer(get, set):Float; function get_chartEditorHitsoundVolumePlayer():Float { if (data.optionsChartEditor.hitsoundVolumePlayer == null) data.optionsChartEditor.hitsoundVolumePlayer = 1.0; return data.optionsChartEditor.hitsoundVolumePlayer; } function set_chartEditorHitsoundVolumePlayer(value:Float):Float { // Set and apply. data.optionsChartEditor.hitsoundVolumePlayer = value; flush(); return data.optionsChartEditor.hitsoundVolumePlayer; } public var chartEditorHitsoundVolumeOpponent(get, set):Float; function get_chartEditorHitsoundVolumeOpponent():Float { if (data.optionsChartEditor.hitsoundVolumeOpponent == null) data.optionsChartEditor.hitsoundVolumeOpponent = 1.0; return data.optionsChartEditor.hitsoundVolumeOpponent; } function set_chartEditorHitsoundVolumeOpponent(value:Float):Float { // Set and apply. data.optionsChartEditor.hitsoundVolumeOpponent = value; flush(); return data.optionsChartEditor.hitsoundVolumeOpponent; } public var chartEditorThemeMusic(get, set):Bool; function get_chartEditorThemeMusic():Bool { if (data.optionsChartEditor.themeMusic == null) data.optionsChartEditor.themeMusic = true; return data.optionsChartEditor.themeMusic; } function set_chartEditorThemeMusic(value:Bool):Bool { // Set and apply. data.optionsChartEditor.themeMusic = value; flush(); return data.optionsChartEditor.themeMusic; } public var chartEditorPlaybackSpeed(get, set):Float; function get_chartEditorPlaybackSpeed():Float { if (data.optionsChartEditor.playbackSpeed == null) data.optionsChartEditor.playbackSpeed = 1.0; return data.optionsChartEditor.playbackSpeed; } function set_chartEditorPlaybackSpeed(value:Float):Float { // Set and apply. data.optionsChartEditor.playbackSpeed = value; flush(); return data.optionsChartEditor.playbackSpeed; } /** * 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); 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 (difficultyList == null) { difficultyList = ['easy', 'normal', 'hard']; } for (difficulty in difficultyList) { var score:Null = getLevelScore(levelId, difficulty); // TODO: Do we need to check accuracy/score here? if (score != null) { return true; } } 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. * @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'):Null { var song = data.scores.songs.get(songId); if (song == null) { song = []; data.scores.songs.set(songId, song); } return song.get(difficultyId); } /** * Apply 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); 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; } /** * Has the provided song been beaten on one of the listed difficulties? * @param songId The song ID to check. * @param difficultyList The difficulties to check. Defaults to `easy`, `normal`, and `hard`. * @return Whether the song has been beaten on any of the listed difficulties. */ public function hasBeatenSong(songId:String, ?difficultyList:Array):Bool { if (difficultyList == null) { difficultyList = ['easy', 'normal', 'hard']; } for (difficulty in difficultyList) { var score:Null = getSongScore(songId, difficulty); // TODO: Do we need to check accuracy/score here? if (score != null) { return true; } } return false; } public function isSongFavorited(id:String):Bool { if (data.favoriteSongs == null) { data.favoriteSongs = []; flush(); }; return data.favoriteSongs.contains(id); } public function favoriteSong(id:String):Void { if (!isSongFavorited(id)) { data.favoriteSongs.push(id); flush(); } } public function unfavoriteSong(id:String):Void { if (isSongFavorited(id)) { data.favoriteSongs.remove(id); 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 { switch (inputType) { case Keys: if (playerId == 0) { data.options.controls.p1.keyboard = controls; } else { data.options.controls.p2.keyboard = controls; } case Gamepad(_): if (playerId == 0) { data.options.controls.p1.gamepad = controls; } else { data.options.controls.p2.gamepad = controls; } } flush(); } 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; } } /** * The user's current volume setting. */ public var volume(get, set):Float; function get_volume():Float { return data.volume; } function set_volume(value:Float):Float { return data.volume = value; } /** * Whether the user's volume is currently muted. */ public var mute(get, set):Bool; function get_mute():Bool { return data.mute; } function set_mute(value:Bool):Bool { return data.mute = value; } /** * Call this to make sure the save data is written to disk. */ public function flush():Void { FlxG.save.flush(); } /** * If you set slot to `2`, it will load an independe * @param slot */ static function loadFromSlot(slot:Int):Void { trace("[SAVE] Loading save from slot " + slot + "..."); // Prevent crashes if the save data is corrupted. SerializerUtil.initSerializer(); FlxG.save.bind('$SAVE_NAME${slot}', SAVE_PATH); if (FlxG.save.isEmpty()) { trace('[SAVE] Save data is empty, checking for legacy save data...'); var legacySaveData = fetchLegacySaveData(); if (legacySaveData != null) { trace('[SAVE] Found legacy save data, converting...'); var gameSave = SaveDataMigrator.migrateFromLegacy(legacySaveData); @:privateAccess FlxG.save.mergeData(gameSave.data, true); } else { trace('[SAVE] No legacy save data found.'); var gameSave = new Save(); FlxG.save.mergeData(gameSave.data, true); } } else { trace('[SAVE] Loaded save data.'); @:privateAccess var gameSave = SaveDataMigrator.migrate(FlxG.save.data); FlxG.save.mergeData(gameSave.data, true); } } static function fetchLegacySaveData():Null { trace("[SAVE] Checking for legacy save data..."); var legacySave:FlxSave = new FlxSave(); legacySave.bind(SAVE_NAME_LEGACY, SAVE_PATH_LEGACY); if (legacySave.isEmpty()) { trace("[SAVE] No legacy save data found."); return null; } else { trace("[SAVE] Legacy save data found."); trace(legacySave.data); return cast legacySave.data; } } } /** * An anonymous structure containingg all the user's save data. */ typedef RawSaveData = { // Flixel save data. var volume:Float; var mute:Bool; /** * A semantic versioning string for the save data format. */ @:jcustomparse(funkin.data.DataParse.semverVersion) @:jcustomwrite(funkin.data.DataWrite.semverVersion) var version:Version; var api:SaveApiData; /** * The user's saved scores. */ var scores:SaveHighScoresData; /** * The user's preferences. */ var options:SaveDataOptions; /** * 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; }; typedef SaveApiData = { var newgrounds:SaveApiNewgroundsData; } typedef SaveApiNewgroundsData = { var sessionId:Null; } /** * 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; 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; /** * The accuracy percentage. */ var accuracy:Float; } 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 = { /** * Whether some particularly fowl 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 `false` */ var debugDisplay:Bool; /** * If enabled, the game will automatically pause when tabbing out. * @default `true` */ var autoPause:Bool; /** * Offset the users inputs by this many ms. * @default `0` */ var inputOffset:Int; /** * Affects the delay between the audio and the visuals during gameplay * @default `0` */ var audioVisualOffset:Int; var controls: { var p1: { var keyboard:SaveControlsData; var gamepad:SaveControlsData; }; var p2: { var keyboard:SaveControlsData; var gamepad:SaveControlsData; }; }; }; /** * 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; /** * 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; /** * Theme music in the Chart Editor. * @default `true` */ var ?themeMusic:Bool; /** * Instrumental volume in the Chart Editor. * @default `1.0` */ var ?instVolume:Float; /** * Voices volume in the Chart Editor. * @default `1.0` */ var ?voicesVolume:Float; /** * Playback speed in the Chart Editor. * @default `1.0` */ var ?playbackSpeed:Float; };