diff --git a/.gitignore b/.gitignore index 8b905e1643..3138e6d0ed 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ addons/* dev-libs/ *.sum .vs/ +.env \ No newline at end of file diff --git a/assets/data/config/menuItems.txt b/assets/data/config/menuItems.txt index a8ec55b16e..677cc54593 100644 --- a/assets/data/config/menuItems.txt +++ b/assets/data/config/menuItems.txt @@ -1,4 +1,5 @@ story mode freeplay options -credits \ No newline at end of file +credits +gamejolt \ No newline at end of file diff --git a/assets/images/menus/gamejolt-icon.png b/assets/images/menus/gamejolt-icon.png new file mode 100644 index 0000000000..70b5055600 Binary files /dev/null and b/assets/images/menus/gamejolt-icon.png differ diff --git a/assets/images/menus/gamejolt/bronze-secret.png b/assets/images/menus/gamejolt/bronze-secret.png new file mode 100644 index 0000000000..4f7ba8b542 Binary files /dev/null and b/assets/images/menus/gamejolt/bronze-secret.png differ diff --git a/assets/images/menus/gamejolt/bronze.png b/assets/images/menus/gamejolt/bronze.png new file mode 100644 index 0000000000..4404e2533c Binary files /dev/null and b/assets/images/menus/gamejolt/bronze.png differ diff --git a/assets/images/menus/gamejolt/gold-secret.png b/assets/images/menus/gamejolt/gold-secret.png new file mode 100644 index 0000000000..e72d9adcbc Binary files /dev/null and b/assets/images/menus/gamejolt/gold-secret.png differ diff --git a/assets/images/menus/gamejolt/gold.png b/assets/images/menus/gamejolt/gold.png new file mode 100644 index 0000000000..1b94e02a50 Binary files /dev/null and b/assets/images/menus/gamejolt/gold.png differ diff --git a/assets/images/menus/gamejolt/platinum-secret.png b/assets/images/menus/gamejolt/platinum-secret.png new file mode 100644 index 0000000000..f9fbf53e25 Binary files /dev/null and b/assets/images/menus/gamejolt/platinum-secret.png differ diff --git a/assets/images/menus/gamejolt/platinum.png b/assets/images/menus/gamejolt/platinum.png new file mode 100644 index 0000000000..2d4763efb3 Binary files /dev/null and b/assets/images/menus/gamejolt/platinum.png differ diff --git a/assets/images/menus/gamejolt/silver-secret.png b/assets/images/menus/gamejolt/silver-secret.png new file mode 100644 index 0000000000..e5915817e9 Binary files /dev/null and b/assets/images/menus/gamejolt/silver-secret.png differ diff --git a/assets/images/menus/gamejolt/silver.png b/assets/images/menus/gamejolt/silver.png new file mode 100644 index 0000000000..ece17d4db6 Binary files /dev/null and b/assets/images/menus/gamejolt/silver.png differ diff --git a/building/libs.xml b/building/libs.xml index f78849581d..9cc20032b4 100644 --- a/building/libs.xml +++ b/building/libs.xml @@ -17,6 +17,7 @@ + diff --git a/project.xml b/project.xml index 3bcdd92c26..8c8cf40a62 100644 --- a/project.xml +++ b/project.xml @@ -66,6 +66,9 @@ + + + @@ -131,6 +134,7 @@ + diff --git a/source/funkin/backend/assets/ModsFolder.hx b/source/funkin/backend/assets/ModsFolder.hx index 0e33f999ed..e1db271b7b 100644 --- a/source/funkin/backend/assets/ModsFolder.hx +++ b/source/funkin/backend/assets/ModsFolder.hx @@ -3,6 +3,7 @@ package funkin.backend.assets; import flixel.util.FlxSignal.FlxTypedSignal; import funkin.backend.system.MainState; import funkin.backend.utils.CoolUtil; +import funkin.backend.utils.GJUtil; import haxe.ds.StringMap; import haxe.io.Path; import lime.text.Font; @@ -75,9 +76,17 @@ class ModsFolder { } public static function reloadMods() { - if (!__firstTime) + if (!__firstTime) { + #if GAMEJOLT_API + if (GJUtil.active) + GJUtil.logout(); + #end + FlxG.switchState(new MainState()); + } __firstTime = false; + + } /** diff --git a/source/funkin/backend/system/Flags.hx b/source/funkin/backend/system/Flags.hx index 2e61eec20c..4cb28ca8f4 100644 --- a/source/funkin/backend/system/Flags.hx +++ b/source/funkin/backend/system/Flags.hx @@ -28,6 +28,7 @@ class Flags { public static var MOD_NAME:String = ""; public static var MOD_DESCRIPTION:String = ""; public static var MOD_AUTHOR:String = ""; + public static var MOD_VERSION:String = ""; @:lazy public static var MOD_API_VERSION:Null = null; public static var MOD_DOWNLOAD_LINK:String = ""; public static var MOD_DEPENDENCIES:Array = []; @@ -43,6 +44,11 @@ class Flags { public static var MOD_REDIRECT_STATES:Map = []; + @:also(funkin.backend.system.gamejolt.GameJoltSecurity.gameId) + public static var MOD_GAMEJOLT_GAME_ID:String = ''; + @:also(funkin.backend.system.gamejolt.GameJoltSecurity.encryptedGameToken) + public static var MOD_GAMEJOLT_ENCRYPTED_TOKEN:String = ''; + // -- Codename's Default Flags -- @:lazy public static var SAVE_PATH:String = haxe.macro.Compiler.getDefine("SAVE_PATH"); @:lazy public static var SAVE_NAME:String = haxe.macro.Compiler.getDefine("SAVE_NAME"); diff --git a/source/funkin/backend/system/MainState.hx b/source/funkin/backend/system/MainState.hx index 14988dddc4..9b50f74e9f 100644 --- a/source/funkin/backend/system/MainState.hx +++ b/source/funkin/backend/system/MainState.hx @@ -10,8 +10,11 @@ import funkin.backend.assets.ModsFolderLibrary; import funkin.backend.assets.ZipFolderLibrary; import funkin.backend.chart.EventsData; import funkin.backend.system.framerate.Framerate; +import funkin.backend.system.gamejolt.GameJoltData; +import funkin.backend.utils.GJUtil; import funkin.editors.ModConfigWarning; import funkin.menus.TitleState; +import funkin.menus.gamejolt.GameJoltCompleteScreen; import haxe.io.Path; @@ -162,6 +165,10 @@ class MainState extends FlxState { if (cast(lib, ZipFolderLibrary).PRELOAD_VIDEOS) cast(lib, ZipFolderLibrary).precacheVideos(); } + #if GAMEJOLT_API + GJUtil.init(); + #end + var startState:Class = Flags.DISABLE_WARNING_SCREEN ? TitleState : funkin.menus.WarningState; // In this case if the mod we just loaded a compressed modpack, we can't edit or modify files without decompressing it. @@ -177,6 +184,6 @@ class MainState extends FlxState { } } - FlxG.switchState(cast Type.createInstance(startState, [])); + if (!GameJoltData.freshStart) FlxG.switchState(cast Type.createInstance(startState, [])); } } diff --git a/source/funkin/backend/system/gamejolt/GameJoltData.hx b/source/funkin/backend/system/gamejolt/GameJoltData.hx new file mode 100644 index 0000000000..3e58bdab0b --- /dev/null +++ b/source/funkin/backend/system/gamejolt/GameJoltData.hx @@ -0,0 +1,762 @@ +package funkin.backend.system.gamejolt; + +import flixel.util.FlxSave; +import haxe.xml.Access; +import haxe.io.Bytes; +import haxe.crypto.Aes; +import haxe.crypto.mode.Mode; +import haxe.crypto.padding.Padding; +import haxe.Json; +import sys.io.File; +import funkin.backend.utils.GJUtil; +import funkin.backend.system.gamejolt.GameJoltSecurity; +import funkin.backend.assets.AssetSource; +import funkin.menus.gamejolt.GameJoltCompleteScreen; +import funkin.savedata.FunkinSave; + +//region Typedefs +/** + * Global data store format. Used to provide a unanimous + * structure to data loading. + */ +typedef CNEGameJoltData = { + ownerU:String, + ownerI:Int, + defTrophies:String, + cusTrophies:String, + leaderboards:String, + addlData:String, +} + +/** + * User data store format. Used to provide a unanimous + * structure to data loading. + */ +typedef GameJoltUserData = { + options:Dynamic, + scores:Dynamic, + miscItems:Dynamic, +} + +/** + * Trophy data. This is not the data pulled from the GameJolt API - + * instead, it allows for things like trophy pre-requisites + * or anything that should be excluded from requirements. + */ +typedef GJTrophyData = { + id:Int, + ?require:Array, + ?except:Array, + ?hidden:Bool, + ?weekName:String, + ?songName:String, +} +//endregion +/** + * Data class for GameJolt. + * + * Holds, sets, and modifies variables from the global data store (which can + * only be set by the owner of the GameJolt game) or the user-specific + * data store. + */ +class GameJoltData +{ + //region Variables + /** + * Whether to go to the page displaying the key and successful data transfer. + */ + public static var freshStart(default, null):Bool = false; + + /** + * The username of the owner of the GameJolt page. + * Set using the global data store. + */ + public static var ownerUsername:Null = null; + + /** + * The ID of the owner of the GameJolt page. + * Cross-checks this alongside the current user to + * determine access to global variables. + */ + public static var ownerUserId:Null = null; + + /** + * Leaderboards from the global data store. + */ + public static var leaderboards(default, null):Map = new Map(); + + /** + * Any trophies defined specifically for use in hardcoding. + * Set using the global data store. + * To set trophies in hardcode, use the `definitions` var as the `def` + * attribute in the node, then set the parameters and trophy ID. + */ + public static var definedTrophies(default, null):Map = new Map(); + + /** + * Any valid definitions that can be used in hardcoding. + */ + public static var definitions(default, null):Array = ['open-first', 'friday-night', 'week', 'song', 'complete-all', 'fc-first', 'fc-all', 'death-first']; + + /** + * Custom trophies that mods may want to implement outside of the usual suspects. + * Unfortunately, these could be easy to cheese. + */ + public static var customTrophies(default, null):Map = new Map(); + + /** + * Any trophies that the user already earned. + * Exists to not make an insane amount of calls per game, even if + * they are async. + */ + public static var earnedTrophies:Map = new Map(); + + /** + * Any pieces of the save data that the game should save in user data. + * Set using the global data store. + */ + public static var dataToInclude:Map> = new Map>(); + + /** + * Path of the gamejolt.xml. + * Mainly here to prevent ghost variables where possible. + */ + static var xmlPath:String = Paths.xml('config/gamejolt'); + + /** + * Help text to be printed in the XML file. + * Mainly here to prevent ghost varialbes where possible. + */ + static var helpText:String = 'XML for gamejolt setup. +This stores it to the global database of the game, under the +key CNE_DATASTORE. +By storing it there, only the owner of the game can modify +that data or delete it if necessary. + +TO SETUP GAMEJOLT FOR YOUR GAME: +- Ensure that the flag `GAMEJOLT_GAME_ID` in the General section +is set to your game ID. +- Input the following nodes into this xml: + + + game-private-key-here +- Run the mod. It will start a session with the owner\'s username, encrypt +your game key, and inject the data into the global data store.'; + //endregion + + #if GAMEJOLT_API + /** + * Used for initialization of the global data store and + * GameJolt integrations. + * Only called if the system doesn't recognize a game + * security key. + */ + public static function loadAdminData() + { + // get xml + var access = getGJX(); + + if (access == null) { + Logs.traceColored([ + Logs.getPrefix("GameJolt"), + Logs.logText("Unable to locate "), + Logs.logText("gamejolt.xml", GREEN), + Logs.logText(' in '), + Logs.logText("data/config", GREEN), + Logs.logText(' folder!'), + ], ERROR); + return; + } + + // return if owner and gamekey nodes are absent + if (!access.hasNode.owner || !access.hasNode.gamekey) { + Logs.trace('Missing owner or gamekey node from gamejolt.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + return; + } + + if (!access.node.owner.has.username || !access.node.owner.has.token) { + Logs.trace('Missing username or user token in gamejolt.xml owner node!', ERROR, LIGHTGRAY, 'GameJolt'); + return; + } + + // encrypt gamekey + encryptToken(access.node.gamekey.innerData); + + // + GJUtil.attemptLogin(access.node.owner.att.username, access.node.owner.att.token, (bl) -> { + if (!bl) { + Logs.traceColored([ + Logs.getPrefix("GameJolt"), + Logs.logText("Unable to log in user "), + Logs.logText(access.node.owner.att.username, GREEN), + Logs.logText(' for data upload.') + ], ERROR); + } else { + ownerUsername = access.node.owner.att.username; + ownerUserId = GameJoltSecurity.userId; + + setGlobalData(true, (bl) -> { + GJUtil.logout(false, true); + if (bl) { + if (buildGamejoltXml()) { + freshStart = true; + FlxG.switchState(new GameJoltCompleteScreen()); + } + } + }, access); + } + }, true, true); + } + + //region CNE Globals + /** + * Loads the variables from the global data store. + * @param callback Function to run on failure (false) or success + * (true) to load global data. + */ + public static function loadGlobalData(?callback:Bool->Void) + { + GameJoltSecurity.sendTrusted(DATA_FETCH('CNE_DATASTORE', false), true, function(err) { + Logs.trace('Unable to obtain global mod data: $err GameJolt API turning off automatically.', ERROR, LIGHTGRAY, 'GameJolt'); + GJUtil.logout(false, true); + if (callback != null) callback(false); + }, function(resp) { + var daDat:CNEGameJoltData = cast Json.parse(resp.data); + + ownerUsername = daDat.ownerU; + ownerUserId = daDat.ownerI; + if (daDat.defTrophies != 'null') { + var itms:Array = daDat.defTrophies.split('---'); + for (indiv in itms) { + var details:Array = indiv.split('-V:'); + definedTrophies.set(details[0], cast Json.parse(details[1])); + } + } + if (daDat.cusTrophies != 'null') { + var itms:Array = daDat.cusTrophies.split('---'); + for (indiv in itms) { + var details:Array = indiv.split('-V:'); + customTrophies.set(details[0], cast Json.parse(details[1])); + } + } + if (daDat.leaderboards != 'null') { + var itms:Array = daDat.leaderboards.split('---'); + for (indiv in itms) { + var details:Array = indiv.split('-V:'); + leaderboards.set(details[0], Std.parseInt(details[1])); + } + } + if (daDat.addlData != 'null') { + var itms:Array = daDat.addlData.split('---'); + for (indiv in itms) { + var details:Array = indiv.split('-V:'); + dataToInclude.set(details[0], details[1].split('-vVv-')); + } + } + + if (callback != null) callback(true); + }); + } + + /** + * Sets the variables located in the global data store. + * @param cleanSet Whether or not this is the first time we're setting + * these variables in the global data store. If we're replacing a data + * store that already exists, this should be `false`. + * @param callback Function to run on failure (false) or success + * (true) to set global data. + * @param data Access data to set global data store to. Loads gamejolt.xml + * by default. + */ + public static function setGlobalData(cleanSet:Bool = false, ?callback:Bool->Void, ?data:Access) + { + if (data == null) { + data = getGJX(); + if (data == null) { + Logs.traceColored([ + Logs.getPrefix("GameJolt"), + Logs.logText("Unable to locate "), + Logs.logText("gamejolt.xml", GREEN), + Logs.logText(' in '), + Logs.logText("data/config", GREEN), + Logs.logText(' folder!'), + ], ERROR); + return; + } + } + + var trophyDefArray:Array = []; + var trophyCusArray:Array = []; + var leaderArray:Array = []; + var dataArray:Array = []; + + // With all of these nodes, it's important to make sure that + // what we're importing a) exists, and b) isn't just a blank string. + + if (data.hasNode.trophies) { + if (data.node.trophies.hasNode.defined) for (trophyDef in data.node.trophies.nodes.defined) { + if (!trophyDef.has.def || !trophyDef.has.id || trophyDef.att.def == '' || trophyDef.att.id == '') + continue; + + if (!definitions.contains(trophyDef.att.def)) + customTrophies.set(trophyDef.att.def, { + id: Std.parseInt(trophyDef.att.id), + require: (trophyDef.has.require && trophyDef.att.require != '') ? [for (trop in trophyDef.att.require.split('//')) Std.parseInt(trop.trim())] : null, + except: (trophyDef.has.except && trophyDef.att.except != '') ? [for (trop in trophyDef.att.except.split('//')) trop.trim()] : null, + hidden: (trophyDef.has.hidden && trophyDef.att.hidden != '') ? (trophyDef.att.hidden == 'true') : null, + weekName: null, + }); + + if (trophyDef.att.def == 'week' && (!trophyDef.has.weekName || trophyDef.att.weekName == '')) + continue; + + if (trophyDef.att.def == 'song' && (!trophyDef.has.songName || trophyDef.att.songName == '')) + continue; + + definedTrophies.set(trophyDef.att.def + (trophyDef.att.def == 'week' ? '-${trophyDef.att.weekName}' : (trophyDef.att.def == 'song' ? '-${trophyDef.att.songName}': '')), { + id: Std.parseInt(trophyDef.att.id), + require: (trophyDef.has.require && trophyDef.att.require != '') ? [for (trop in trophyDef.att.require.split(',')) Std.parseInt(trop.trim())] : null, + except: (trophyDef.has.except && trophyDef.att.except != '') ? [for (trop in trophyDef.att.except.split('//')) trop.trim()] : null, + hidden: (trophyDef.has.hidden && trophyDef.att.hidden != '') ? (trophyDef.att.hidden == 'true') : null, + weekName: (trophyDef.att.def == 'week' && trophyDef.has.weekName && trophyDef.att.weekName != '') ? trophyDef.att.weekName : null, + songName: (trophyDef.att.def == 'song' && trophyDef.has.songName && trophyDef.att.songName != '') ? trophyDef.att.songName : null, + }); + } + + if (data.node.trophies.hasNode.custom) for (trophyDef in data.node.trophies.nodes.custom) { + if (!trophyDef.has.def || !trophyDef.has.id) + continue; + + customTrophies.set(trophyDef.att.def, { + id: Std.parseInt(trophyDef.att.id), + require: (trophyDef.has.require && trophyDef.att.require != '') ? [for (trop in trophyDef.att.require.split(',')) Std.parseInt(trop.trim())] : null, + except: (trophyDef.has.except && trophyDef.att.except != '') ? [for (trop in trophyDef.att.except.split('//')) trop.trim()] : null, + hidden: (trophyDef.has.hidden && trophyDef.att.hidden != '') ? (trophyDef.att.hidden == 'true') : null, + weekName: null, + }); + } + } + + if (data.hasNode.leaderboards) { + if (data.node.leaderboards.hasNode.song) for (leaderboard in data.node.leaderboards.nodes.song) { + if (!leaderboard.has.id || !leaderboard.has.name || leaderboard.att.name == '' || leaderboard.att.id == '') + continue; + + leaderboards.set('song-' + leaderboard.att.name + (leaderboard.has.diff ? '--D:${leaderboard.att.diff}' : '') + ' (V:${leaderboard.has.vari ? leaderboard.att.vari : 'Default'})', Std.parseInt(leaderboard.att.id)); + } + + if (data.node.leaderboards.hasNode.week) for (leaderboard in data.node.leaderboards.nodes.week) { + if (!leaderboard.has.id || !leaderboard.has.name || leaderboard.att.name == '' || leaderboard.att.id == '') + continue; + + leaderboards.set('week-' + leaderboard.att.name + (leaderboard.has.diff ? '--D:${leaderboard.att.diff}' : ''), Std.parseInt(leaderboard.att.id)); + } + } + + if (data.hasNode.data) for (dat in data.node.data.nodes.value) { + if (!dat.has.name || dat.att.name == '') + continue; + + var location:String = (dat.has.inSave && dat.att.inSave != '') ? dat.att.inSave : "FlxG"; + var curVars:Array = dataToInclude.exists(location) ? dataToInclude.get(location) : []; + curVars.push(dat.att.name); + dataToInclude.set(location, curVars); + } + + for (key => value in definedTrophies) + trophyDefArray.push('$key-V:${Json.stringify(value)}'); + + for (key => value in customTrophies) + trophyCusArray.push('$key-V:${Json.stringify(value)}'); + + for (key => value in leaderboards) + leaderArray.push('$key-V:$value'); + + for (key => value in dataToInclude) + dataArray.push('$key-V:${value.join('-vVv-')}'); + + var sendOut:CNEGameJoltData = { + ownerU: ownerUsername, + ownerI: ownerUserId, + defTrophies: (trophyDefArray.length > 0 ? trophyDefArray.join('---') : "null"), + cusTrophies: (trophyCusArray.length > 0 ? trophyCusArray.join('---') : "null"), + leaderboards: (leaderArray.length > 0 ? leaderArray.join('---') : "null"), + addlData: (dataArray.length > 0 ? dataArray.join('---') : "null"), + }; + + Logs.trace('Sending global data...', INFO, LIGHTGRAY, "GameJolt"); + + GameJoltSecurity.sendTrusted(DATA_SET('CNE_DATASTORE', Json.stringify(sendOut), false), !cleanSet, function(err) { + Logs.trace('Unable to upload global GameJolt data: ${err}', ERROR, LIGHTGRAY, 'GameJolt'); + if (cleanSet) { + reset(); + GJUtil.logout(false, true); + } else if (callback != null) + callback(false); + }, function(resp) { + Logs.trace('Global data set successfully!', SUCCESS, LIGHTGRAY, "GameJolt"); + if (callback != null) callback(true); + }); + } + + /** + * Wipes global data store from the GameJolt cloud data. + * @param callback Function to run on failure (false) or success + * (true) to wipe global data. + */ + public static function wipeGlobalData(?callback:Bool->Void) + { + GameJoltSecurity.sendTrusted(DATA_REMOVE('CNE_DATASTORE', false), true, function(err) { + Logs.trace('Unable to wipe global mod data: $err', ERROR, LIGHTGRAY, 'GameJolt'); + if (callback != null) callback(false); + }, function(resp) { + if (callback != null) callback(true); + }); + } + //endregion + + //region User Data + /** + * Saves user-specific data in the game's user-specific data store. + * @param callback Function to run on failure (false) or success + * (true) to save user data. + */ + public static function setUserData(?callback:Bool->Void) + { + FunkinSave.flush(); + + var addlStuff = {}; + + if (dataToInclude != null) for (elem in dataToInclude.keys()) { + var daSave:FlxSave = Reflect.field(elem, "save"); + if (daSave == null) + continue; + var addlSaveItems = {}; + for (itm in dataToInclude.get(elem)) { + var itmVal = Reflect.field(daSave.data, itm); + if (itmVal == null) + continue; + Reflect.setField(addlSaveItems, itm, itmVal); + } + Reflect.setField(addlStuff, elem, addlSaveItems); + } + + var dataToSend:GameJoltUserData = { + options: Options.__save.data, + scores: FunkinSave.save.data.highscores, + miscItems: addlStuff, + }; + + + var daId:Null = GameJoltSecurity.userId; + if (daId == null) { + if (callback != null) callback(false); + } else { + Logs.trace('Sending user data...', INFO, LIGHTGRAY, "GameJolt"); + + GameJoltSecurity.sendTrusted(DATA_SET('USER_$daId', Json.stringify(dataToSend), true), true, function(err) { + Logs.traceColored([ + Logs.getPrefix("GameJolt"), + Logs.logText("Unable to set data for user "), + Logs.logText(GJUtil.userName, GREEN), + Logs.logText(': ${err}') + ], ERROR); + if (callback != null) + callback(false); + }, function(resp) { + Logs.trace('User data sent successfully!', SUCCESS, LIGHTGRAY, 'GameJolt'); + if (callback != null) callback(true); + }); + } + } + + /** + * Loads user-specific data in game's user-specific data store. + * @param callback Function to run on failure (false) or success + * (true) to load user data. + */ + public static function loadUserData(?callback:Bool->Void) + { + var daId:Null = GameJoltSecurity.userId; + if (daId == null) { + if (callback != null) callback(false); + } else { + Logs.trace('Fetching user data...', INFO, LIGHTGRAY, "GameJolt"); + + GameJoltSecurity.sendTrusted(DATA_FETCH('USER_$daId', true), true, function(err) { + Logs.traceColored([ + Logs.getPrefix("GameJolt"), + Logs.logText("Unable to set data for user "), + Logs.logText(GJUtil.userName, GREEN), + Logs.logText(': ${err}') + ], ERROR); + if (callback != null) callback(false); + }, function(resp) { + Logs.trace('User data collected successfully; now setting save data to obtained user data...', INFO, LIGHTGRAY, "GameJolt"); + var daData:GameJoltUserData = cast Json.parse(resp.data); + Options.__save.mergeData(daData.options, true); + FunkinSave.save.data.highscores = daData.scores; + if (daData.miscItems != {}) { + // I hate how much reflecting is in this code. + var locs:Array = Reflect.fields(daData.miscItems); + for (location in locs) { + if (location == null || location == '') + continue; + var daSve:FlxSave = Reflect.field(location, "save"); + if (daSve == null) + continue; + for (daItm in Reflect.fields(location)) { + Reflect.setField(daSve.data, daItm, Reflect.field(location, daItm)); + } + + daSve.flush(); + } + } + if (callback != null) callback(true); + }); + } + } + + /** + * Wipes user-specific data in game's user-specific data store. + * @param callback Function to run on failure (false) or success + * (true) to wipe user data. + */ + public static function wipeUserData(?callback:Bool->Void) + { + var daId:Null = GameJoltSecurity.userId; + if (daId == null) { + if (callback != null) callback(false); + } else { + GameJoltSecurity.sendTrusted(DATA_REMOVE('USER_$daId', true), true, function(err) { + Logs.trace('Unable to wipe user mod data: $err', ERROR, LIGHTGRAY, 'GameJolt'); + if (callback != null) callback(false); + }, function(resp) { + if (callback != null) callback(true); + }); + } + } + //endregion + + /** + * Resets the data in this class. + * @param fullWipe Whether or not to wipe earnedTrophies only + * (`false`) or all data in this class (`true`). + */ + public static function reset(fullWipe:Bool = false) + { + earnedTrophies.clear(); + if (fullWipe) { + ownerUsername = null; + ownerUserId = null; + leaderboards.clear(); + definedTrophies.clear(); + customTrophies.clear(); + dataToInclude.clear(); + freshStart = false; + } + } + + /** + * Creates an XML file from the data provided in this class. + * @return Bool Whether or not the creation was successful. + */ + static function buildGamejoltXml():Bool + { + final bodyNode:Xml = Xml.createElement('gamejolt'); + final trophyNodes:Xml = Xml.createElement('trophies'); + final leaderNodes:Xml = Xml.createElement('leaderboards'); + final dataNodes:Xml = Xml.createElement('data'); + final ret:Xml = Xml.createDocument(); + + for (key => trop in definedTrophies) { + var nodeToTrophy:Xml = Xml.createElement('defined'); + nodeToTrophy.set('def', key.substring(0, trop.weekName != null ? 4 : null)); + if (trop.require != null) + nodeToTrophy.set('require', trop.require.join(',')); + if (trop.except != null) + nodeToTrophy.set('except', trop.except.join(',')); + if (trop.hidden != null) + nodeToTrophy.set('hidden', '${trop.hidden}'); + if (trop.weekName != null) + nodeToTrophy.set('week', trop.weekName); + nodeToTrophy.set('id', '${trop.id}'); + trophyNodes.addChild(nodeToTrophy); + } + + for (key => trop in customTrophies) { + var nodeToTrophy:Xml = Xml.createElement('custom'); + nodeToTrophy.set('def', key); + if (trop.require != null) + nodeToTrophy.set('require', trop.require.join(',')); + if (trop.except != null) + nodeToTrophy.set('except', trop.except.join(',')); + if (trop.hidden != null) + nodeToTrophy.set('hidden', '${trop.hidden}'); + nodeToTrophy.set('id', '${trop.id}'); + trophyNodes.addChild(nodeToTrophy); + } + + for (key => board in leaderboards) { + var nodeToLeader:Xml = Xml.createElement('song'); + var diffInd:Int = key.indexOf('--D:'); + var variInd:Int = key.indexOf('(V:'); + var nameSplice = key.substring(0, diffInd != -1 ? diffInd : variInd).trim(); + nodeToLeader.set('name', nameSplice); + if (diffInd != -1) { + var diffSplice:String = key.substring(diffInd + 4, variInd).trim(); + nodeToLeader.set('diff', diffSplice); + } + var variSplice:String = key.substring(variInd + 3, key.lastIndexOf(')')).trim(); + nodeToLeader.set('vari', variSplice); + nodeToLeader.set('id', '$board'); + leaderNodes.addChild(nodeToLeader); + } + + for (key => loc in dataToInclude) { + for (itm in loc) { + var nodeToData:Xml = Xml.createElement('value'); + nodeToData.set('name', itm); + nodeToData.set('inSave', key); + dataNodes.addChild(nodeToData); + } + } + + bodyNode.addChild(trophyNodes); + bodyNode.addChild(leaderNodes); + bodyNode.addChild(dataNodes); + ret.addChild(bodyNode); + + var finalBool:Bool = true; + try { + File.saveContent(Paths.assetsTree.getSpecificPath(xmlPath, AssetSource.MODS), '\n' + XMLUtil.fixXMLText(ret.toString())); + } catch(e) { + finalBool = false; + Logs.trace('Error creating new XML file: ${e}', ERROR, LIGHTGRAY, "GameJolt"); + } + + return finalBool; + } + + /** + * Gets the gamejolt.xml file from the specified static location. + * @return Null If the data load was successful, returns an + * Access of the XML data; if unsuccessful, returns `null`. + */ + static function getGJX():Null + { + if (!Paths.assetsTree.existsSpecific(xmlPath, "TEXT", AssetSource.MODS)) + return null; + + var access:Access = null; + try { + access = new Access(Xml.parse(Paths.assetsTree.getSpecificAsset(xmlPath, "TEXT", AssetSource.MODS)).firstElement()); + } catch(e) { + Logs.trace('Error while parsing gamejolt.xml: ${Std.string(e)}', ERROR, LIGHTGRAY, 'GameJolt'); + } + return access; + } + + /** + * Encrypts a GameJolt game security key using AES encryption. It then + * sets the `GAMEJOLT_ENCRYPTED_TOKEN` flag to this newly-encrypted token. + * + * ## A note about the key and IV for AES + * The AES key should be kept hidden, and is done so using classes + * inaccessible to Hscript and a .env file that is hidden to open source. + * This .env file generates a random key for a build if the AES key is + * missing - meaning if it gets lost, mods have to re-encrypt their + * game security keys. + * It is common real-world practice however to include the IV alongside the + * encrypted text, as done here. Both the key and IV are required to unlock an + * AES-encrypted text. The key though should be kept as secret as possible, + * as it does most of the heavy lifting in encryption; the IV mainly + * obscures the key and first block of encrypted text. Think of the IV as + * icing on the cake. + * + * And yes - I did academic research for an FNF engine, why do you ask. + * @param token Token to encrypt. + */ + static function encryptToken(token:String) + { + var validHex:String = "0123456789abcdef"; + + var dateString:String = Date.now().toString(); + + var hexString:String = ''; + + for (i in 0...dateString.length - 1) { + if (dateString.charAt(i) == ' ') continue; + if (hexString.length == 32) break; + var charCode:Int = StringTools.fastCodeAt(dateString, i); + var charString:String = StringTools.hex(charCode); + hexString += charString.substr(0, Std.int(Math.min(charString.length, 32 - hexString.length))); + } + + for (i in 0...(32 - hexString.length)) { + if (FlxG.random.bool()) { + hexString += validHex.charAt(FlxG.random.int(0, validHex.length - 1)); + } else { + hexString = validHex.charAt(FlxG.random.int(0, validHex.length - 1)) + hexString; + } + } + var iv:Bytes = Bytes.ofHex(hexString); + + @:privateAccess + var aes:Aes = new Aes(Bytes.ofHex(GameJoltSecurity.CODENAME_AES_KEY), iv); + var encryp:Bytes = aes.encrypt(Mode.OFB, Bytes.ofString(token), Padding.NoPadding); + + GameJoltSecurity.encryptedGameToken = Flags.MOD_GAMEJOLT_ENCRYPTED_TOKEN = hexString.toLowerCase() + encryp.toHex(); + } + #else + //region GJ API Inaccessible + public static function loadAdminData() + { + Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + return; + } + + public static function loadGlobalData(?callback:Bool->Void) + { + Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + if (callback != null) callback(false); + } + + public static function setGlobalData(cleanSet:Bool = false, ?callback:Bool->Void, ?data:Access) + { + Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + if (callback != null) callback(false); + } + + public static function wipeGlobalData(?callback:Bool->Void) + { + Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + if (callback != null) callback(false); + } + + public static function setUserData(?callback:Bool->Void) + { + Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + if (callback != null) callback(false); + } + + public static function loadUserData(?callback:Bool->Void) + { + Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + if (callback != null) callback(false); + } + + public static function wipeUserData(?callback:Bool->Void) + { + Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + if (callback != null) callback(false); + } + + public static function reset(fullWipe:Bool = false) + { + Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + return; + } + //endregion + #end +} \ No newline at end of file diff --git a/source/funkin/backend/system/gamejolt/GameJoltSecurity.hx b/source/funkin/backend/system/gamejolt/GameJoltSecurity.hx new file mode 100644 index 0000000000..d3c2f7164b --- /dev/null +++ b/source/funkin/backend/system/gamejolt/GameJoltSecurity.hx @@ -0,0 +1,508 @@ +package funkin.backend.system.gamejolt; + +import hscript.IHScriptCustomAccessBehaviour; +import funkin.backend.utils.GJUtil; +import funkin.backend.utils.GJUtil.RequestType; +import funkin.backend.system.gamejolt.GameJoltData; +import haxe.crypto.*; +import haxe.crypto.mode.Mode; +import haxe.crypto.padding.Padding; +import haxe.io.Bytes; +import funkin.backend.utils.GJUtil.*; +import haxe.Http; +import haxe.Json; +import openfl.events.*; +#if ALLOW_MULTITHREADING +import funkin.backend.utils.ThreadUtil; +#end +#if (target.threaded) +import sys.thread.Thread; +#end + + + +/** + * # A BIG MOTHERFUCKING WARNING + * + * This class handles the raw game keys for GameJolt keys. + * If the raw game keys are made public, people can mess with leaderboards, data, achievements, + * or whatever else is on the game page. + * + * As such, Codename Engine requires players to encrypt keys using the AES protocol, and + * the key to decrypt such keys is non-disclosable (hence why it's in a .env file). + * + * Also for security purposes, this class is unattainable via HScript. + * + * ~ SplatterDash + */ + +@:noCustomClass @:build(funkin.backend.system.macros.SecretMacro.build()) +class GameJoltSecurity implements IHScriptCustomAccessBehaviour +{ + /** + * Token for the user if they're logged in. + */ + public static var user_token:String = ''; + + /** + * ID number for the current mod. + */ + public static var gameId:String = ''; + + /** + * ID number for the currently logged in user. + */ + public static var userId:Null = null; + /** + * The encrypted game token. Set using GAMEJOLT_ENCRYPTED_TOKEN in ini file. + */ + public static var encryptedGameToken(default, set):String; + + /** + * The unencrypted game token. It's insanely hard to get this variable. + */ + @:noPrivateAccess private static var revealedGameToken:String; + + /** + * URL sent to GameJolt per request. + */ + @:noPrivateAccess private static var url(get, never):String; + + /** + * The previous GJResponse created by the API client. Usually for just storage purposes. + */ + private static var lastResponse:GJResponse = {success: false, message: "No response yet."}; + + /** + * The current call being processed. + */ + private static var curCall:Null = null; + + /** + * The secret key to decrypt GameJolt keys using AES. This cannot be traced or located in any way. + */ + @:envField + private static final CODENAME_AES_KEY:Null; + + #if target.threaded + static final mutex = new sys.thread.Mutex(); + #end + + // hscript - thanks LJ :D + + //region IHScriptCustomAccessBehaviour implementation + public var __allowSetGet:Bool = false; + + public function hget(name:String):Dynamic + return null; + + public function hset(name:String, val:Dynamic):Dynamic + return null; + + public function __callGetter(name:String):Dynamic + return null; + + public function __callSetter(name:String, val:Dynamic):Dynamic + return null; + //endregion + + #if GAMEJOLT_API + /** + * This is a copy of GJUtil's "send" request, but kept here since Hscript can't get here. + * Any calls here are guaranteed to be from hardcoding. + * @param call The RequestType call to make. Can make any type of call. + * @param async Whether or not the call should be asyncronous. + * @param onError Callback function if an error occurs. Gives error string. + * @param onComplete Callback function on successful completion of the call. Gives response data. + * @param onProgress Callback function for progress on async calls. Gives a progress float array. + */ + public static function sendTrusted(call:RequestType, async:Bool = false, ?onError:String->Void, ?onComplete:GJResponse->Void, ?onProgress:Array->Void) + { + @:privateAccess { + if (GJUtil.executing || !GJUtil.active) + return; + GJUtil.executing = true; + } + + handleRequest(async, call, function(errstr) { + @:privateAccess + GJUtil.executing = false; + if (onError != null) onError(errstr); + }, function(resp) { + @:privateAccess { + GJUtil.executing = false; + if (onComplete != null) onComplete(GJUtil.formatImages(resp)); + } + }, onProgress); + } + + /** + * Unlocks a trophy from the `definedTrophies` map - in other words, + * any trophy that is specifically defined in hardcode. + * @param def Key/def of trophy in `definedTrophies` map. + * @param callback Function running on completion of unlock attempt. Returns + * trophy data if successful, `null` if unsuccessful. + */ + public static function unlockDefinedTrophy(def:String, ?callback:Null->Void) + { + if (GameJoltData.definedTrophies == null || !GameJoltData.definedTrophies.exists(def)) { + Logs.error('No defined trophy exists with the key "$def".', RED, 'GameJolt'); + if (callback != null) callback(null); + } else { + var daTrophy:GJTrophyData = GameJoltData.definedTrophies.get(def); + + if (GameJoltData.earnedTrophies.exists(def)) { + Logs.error('User already earned trophy with key "$def"!', RED, 'GameJolt'); + if (callback != null) callback(null); + } else { + var meetsReqs:Bool = true; + if (daTrophy.require != null) { + var reqsMet:Array = []; + for (earned in GameJoltData.earnedTrophies) { + if (daTrophy.require.contains(earned.id)) reqsMet.push(earned.id); + } + + if (reqsMet.length != daTrophy.require.length) + meetsReqs = false; + } + + if (!meetsReqs) { + Logs.error('User does not meet requirements for trophy with the key "$def".', RED, 'GameJolt'); + if(callback != null) callback(null); + } else { + sendTrusted(BATCH(false, true, [TROPHIES_FETCH(false, daTrophy.id), TROPHIES_ADD(daTrophy.id)]), true, function(err) { + Logs.trace('Trophy unlock error: ${err}', ERROR, LIGHTGRAY, 'GameJolt'); + if (callback != null) callback(null); + }, function(resp) { + GameJoltData.earnedTrophies.set(def, daTrophy); + if(resp.responses[0].trophies[0] == null) { + Logs.trace('Trophy with key $def already unlocked!', WARNING, LIGHTGRAY, 'GameJolt'); + if (callback != null) callback(null); + } else { + Logs.trace('Trophy unlock: ${resp.responses[0].trophies[0].title}!', SUCCESS, LIGHTGRAY, 'GameJolt'); + if (GJUtil.onTrophyUnlock != null) GJUtil.onTrophyUnlock(resp.responses[0].trophies[0]); + if (callback != null) callback(resp.responses[0].trophies[0]); + } + }); + } + } + } + } + + /** + * Handled the main request without giving out any compromisable data. + * @param async Whether or not the call should be asyncronous. + * @param data The RequestType call to make. Can make any type of call. + * @param onError Callback function if an error occurs. Gives error string. + * @param onComplete Callback function on successful completion of the call. Gives response data. + * @param onProgress Callback function for progress on async calls. Gives a progress float array. + */ + static function handleRequest(async:Bool = false, data:RequestType, ?onError:String->Void, ?onComplete:GJResponse->Void, ?onProgress:Array->Void) + { + if (encryptedGameToken == null || gameId == null) { + lastResponse = {success: false, message: 'Missing game token and/or game ID.'}; + curCall = null; + if (onError != null) onError(lastResponse.message); + } + + curCall = data; + + if (async) { + #if ALLOW_MULTITHREADING ThreadUtil.execAsync#elseif (target.threaded) Thread.create#end (() -> { + var loader = new openfl.net.URLLoader(); + loader.addEventListener(Event.COMPLETE, function(complete) { + lastResponse = Json.parse(cast(loader.data, String)).response; + if (lastResponse.message != null) { + Logs.traceColored([ + Logs.getPrefix("GameJolt"), + Logs.logText('Response Error: ${lastResponse.message}') + ], ERROR); + curCall = null; + if (onError != null) onError(lastResponse.message); + } else { + curCall = null; + if (onComplete != null) onComplete(lastResponse); + } + }); + loader.addEventListener(ProgressEvent.PROGRESS, progress -> { if (onProgress != null) onProgress([progress.bytesLoaded, progress.bytesTotal]);}); + loader.addEventListener(IOErrorEvent.IO_ERROR, function(ioError) { + lastResponse = {success: false, message: 'IO Error: ${ioError.text}'}; + curCall = null; + if (onError != null) onError(lastResponse.message); + }); + loader.addEventListener(SecurityErrorEvent.SECURITY_ERROR, (securityError) -> { + lastResponse = {success: false, message: 'Security Error: ${securityError.text}'}; + curCall = null; + if (onError != null) onError(lastResponse.message); + }); + loader.load(new openfl.net.URLRequest(url)); + }); + } else { + var loader:Http = new Http(url); + loader.onData = function(data) { + lastResponse = cast Json.parse(data).response; + if (lastResponse.message != null) { + Logs.traceColored([ + Logs.getPrefix("GameJolt"), + Logs.logText('Response Error: ${lastResponse.message}') + ], ERROR); + curCall = null; + if (onError != null) onError(lastResponse.message); + } else { + curCall = null; + if (onComplete != null) onComplete(lastResponse); + } + }; + loader.onError = function(error) { + lastResponse = {success: false, message: 'Request Error: ${error}'}; + curCall = null; + if (onError != null) onError(lastResponse.message); + }; + loader.request(false); + } + } + + /** + * Parses data from a request to be sent via OpenFL or HTTP request. + * @param request RequestType to be formatted. + * @param signed Whether or not the request should be "signed" using the game token. + * @return String Request link. + */ + static function parseType(request:RequestType, signed:Bool = false):String { + var command:String = ""; + var action:String = ""; + var params:Array<{name:String, value:String}> = []; + + switch (request) { + case BATCH(parallel, breakOnError, requests): + command = "batch"; + params.push({name: "parallel", value: '$parallel'}); + params.push({name: "break_on_error", value: '$breakOnError'}); + for (req in requests) params.push({name: "requests[]", value: parseType(req, true)}); + case DATA_FETCH(key, fromUser): + command = "data-store"; + params.push({name: "key", value: key.urlEncode()}); + if (fromUser) { + params.push({name: "username", value: GJUtil.userName}); + params.push({name: "user_token", value: user_token}); + } + case DATA_GETKEYS(fromUser, pattern): + command = "data-store"; + action = "get-keys"; + if (pattern != null && pattern != "") + params.push({name: "pattern", value: pattern.urlEncode()}); + if (fromUser) { + params.push({name: "username", value: GJUtil.userName}); + params.push({name: "user_token", value: user_token}); + } + case DATA_REMOVE(key, fromUser): + command = "data-store"; + action = "remove"; + params.push({name: "key", value: key.urlEncode()}); + if (fromUser) { + params.push({name: "username", value: GJUtil.userName}); + params.push({name: "user_token", value: user_token}); + } + case DATA_SET(key, data, toUser): + command = "data-store"; + action = "set"; + params.push({name: "key", value: key.urlEncode()}); + params.push({name: "data", value: data.urlEncode()}); + if (toUser) { + params.push({name: "username", value: GJUtil.userName}); + params.push({name: "user_token", value: user_token}); + } + case DATA_UPDATE(key, operation, toUser): + command = "data-store"; + action = "update"; + params.push({name: "key", value: key.urlEncode()}); + if (toUser) { + params.push({name: "username", value: GJUtil.userName}); + params.push({name: "user_token", value: user_token}); + } + switch (operation) { + case Add(n): + params.push({name: 'operation', value: 'add'}); + params.push({name: 'value', value: '$n'}); + case Substract(n): + params.push({name: 'operation', value: 'substract'}); + params.push({name: 'value', value: '$n'}); + case Multiply(n): + params.push({name: 'operation', value: 'multiply'}); + params.push({name: 'value', value: '$n'}); + case Divide(n): + params.push({name: 'operation', value: 'divide'}); + params.push({name: 'value', value: '$n'}); + case Append(t): + params.push({name: 'operation', value: 'append'}); + params.push({name: 'value', value: t.urlEncode()}); + case Prepend(t): + params.push({name: 'operation', value: 'prepend'}); + params.push({name: 'value', value: t.urlEncode()}); + } + case FRIENDS: + command = "friends"; + params.push({name: "username", value: GJUtil.userName}); + params.push({name: "user_token", value: user_token}); + case TIME: + command = "time"; + case USER_AUTH: + command = "users"; + action = "auth"; + params.push({name: "username", value: GJUtil.userName}); + params.push({name: "user_token", value: user_token}); + case USER_FETCH(userOrID): + command = "users"; + var letters:Array = "ABCDEFGHIJKLMNÑOPQRSTUVWXYZ_-".split(""); + if (letters.filter(l -> userOrID.contains(l.toUpperCase()) || userOrID.contains(l.toLowerCase())).length > 0) + params.push({name: "username", value: userOrID}); + else + params.push({name: "user_id", value: userOrID.replace(",", "%2C")}); + case SESSION_OPEN: + command = "sessions"; + action = "open"; + params.push({name: "username", value: GJUtil.userName}); + params.push({name: "user_token", value: user_token}); + case SESSION_PING(active): + command = "sessions"; + action = "ping"; + params.push({name: "status", value: active ? "active" : "idle"}); + params.push({name: "username", value: GJUtil.userName}); + params.push({name: "user_token", value: user_token}); + case SESSION_CHECK: + command = "sessions"; + action = "check"; + params.push({name: "username", value: GJUtil.userName}); + params.push({name: "user_token", value: user_token}); + case SESSION_CLOSE: + command = "sessions"; + action = "close"; + params.push({name: "username", value: GJUtil.userName}); + params.push({name: "user_token", value: user_token}); + case SCORES_ADD(score, sort, extra_data, table_id): + command = "scores"; + action = "add"; + params.push({name: "score", value: score}); + params.push({name: "sort", value: '$sort'}); + if (extra_data != null && extra_data != "") + params.push({name: "extra_data", value: extra_data.urlEncode()}); + if (table_id != null) + params.push({name: "table_id", value: '$table_id'}); + if (user_token != "") { + params.push({name: "username", value: GJUtil.userName}); + params.push({name: "user_token", value: user_token}); + } else + params.push({name: "guest", value: GJUtil.userName}); + case SCORES_GETRANK(sort, table_id): + command = "scores"; + action = "get-rank"; + params.push({name: "sort", value: '$sort'}); + if (table_id != null) + params.push({name: "table_id", value: '$table_id'}); + case SCORES_FETCH(fromUser, table_id, limit, betterThan): + command = "scores"; + if (table_id != null) + params.push({name: "table_id", value: '$table_id'}); + if (limit != null) + params.push({name: "limit", value: '$limit'}); + if (betterThan != null) + params.push({name: betterThan < 0 ? "worse_than" : "better_than", value: '${Math.abs(betterThan)}'}); + if (fromUser) { + if (user_token != "") { + params.push({name: "username", value: GJUtil.userName}); + params.push({name: "user_token", value: user_token}); + } else + params.push({name: "guest", value: GJUtil.userName}); + } + case SCORES_TABLES: + command = "scores"; + action = "tables"; + case TROPHIES_FETCH(achieved, trophy_id): + command = "trophies"; + if (achieved != null) + params.push({name: "achieved", value: '$achieved'}); + if (trophy_id != null) + params.push({name: "trophy_id", value: '$trophy_id'}); + params.push({name: "username", value: GJUtil.userName}); + params.push({name: "user_token", value: user_token}); + case TROPHIES_ADD(trophy_id): + command = "trophies"; + action = "add-achieved"; + params.push({name: "trophy_id", value: '$trophy_id'}); + params.push({name: "username", value: GJUtil.userName}); + params.push({name: "user_token", value: user_token}); + case TROPHIES_REMOVE(trophy_id): + command = "trophies"; + action = "remove-achieved"; + params.push({name: "trophy_id", value: '$trophy_id'}); + params.push({name: "username", value: GJUtil.userName}); + params.push({name: "user_token", value: user_token}); + } + + var urlSection:String = '/$command${action != "" ? '/$action' : ""}?game_id=${gameId}${[for (p in params) '&${p.name}=${p.value}'].join("")}'; + if (signed) + urlSection = sign(urlSection).urlEncode(); + return urlSection; + } + + /** + * Signs a piece of URL with Md5. + * @param daUrl The old URL piece. + * @return The new URL piece. + */ + static function sign(daUrl:String):String { + var urlToEncode:String = daUrl + revealedGameToken; + return '$daUrl&signature=${Md5.encode(urlToEncode)}'; + } + + /** + * Setter function for encrypted game token. Also sets revealed game token. + * @param tok New string to set for `encryptedGameToken`. + */ + static function set_encryptedGameToken(tok:String) + { + if (tok != null && CODENAME_AES_KEY != null) { + var iv:String = tok.substr(0, 32); + var theK:String = tok.substr(32); + + var aes:Aes = new Aes(Bytes.ofHex(CODENAME_AES_KEY), Bytes.ofHex(iv.toUpperCase())); + + var dat:String = aes.decrypt(Mode.OFB, Bytes.ofHex(theK), Padding.NoPadding).toString(); + + revealedGameToken = dat; + } else { + revealedGameToken = null; + } + return encryptedGameToken = tok; + } + + static function get_url():String + { + return sign('https://api.gamejolt.com/api/game/v1_2${parseType(curCall)}'); + } + #else + //region No API Integrations + public static function sendTrusted(call:RequestType, async:Bool = false, ?onError:String->Void, ?onComplete:GJResponse->Void, ?onProgress:Array->Void) + { + Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + if (onError != null) onError('GameJolt API not set in Project.xml!'); + } + + public static function unlockDefinedTrophy(def:String, ?callback:Null->Void) + { + Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + if (callback != null) callback(null); + } + + static function set_encryptedGameToken(tok:String) + { + return encryptedGameToken = tok; + } + + static function get_url():String + { + return null; + } + //endregion + #end +} \ No newline at end of file diff --git a/source/funkin/backend/system/macros/SecretMacro.hx b/source/funkin/backend/system/macros/SecretMacro.hx new file mode 100644 index 0000000000..3385db3735 --- /dev/null +++ b/source/funkin/backend/system/macros/SecretMacro.hx @@ -0,0 +1,104 @@ +package funkin.backend.system.macros; + +#if macro +import sys.io.File; +import sys.FileSystem; +import Sys; +import haxe.macro.Expr; +import haxe.macro.Expr.Field; +import haxe.macro.Context; +#end + +@:dox(hide) class SecretMacro { + static var envPath:String = '.env'; + // A little bit taken from Funkin's base code. Not too much though. + public static macro function build():Array + { + var contents:Null = !FileSystem.exists(envPath) ? buildEnv() : File.getContent(envPath); + + if (contents == null) + contents = buildEnv(); + + final indivVars:Array = contents.split('\n'); + + final envMap:Map = []; + + for (varE in indivVars) { + // If there's no equal sign, or multiple equal signs, we don't process this variable due to errors. + if (varE.indexOf('=') == -1 || varE.indexOf('=') != varE.lastIndexOf('=')) + continue; + + var titAndVal:Array = varE.split('='); + envMap.set(titAndVal[0], titAndVal[1]); + } + + // Now we look at the actual fields to try and match things up. + final buildFields:Array = Context.getBuildFields(); + + for (fld in buildFields) { + if (fld.access.contains(AStatic)) switch(fld.kind) { + case FVar(t, e): + for (meta in fld.meta) { + if (meta.name != ':envField') + continue; + + // Not gonna do the 'mandatoryIfDefined' stuff because that's not necessary! + // But because we're not fixing what isn't broken, time for null string checks. + var isNullString:Bool = false; + switch (t) { + case TPath(tp): + if (tp.name == 'Null' && tp.params != null && tp.params.length == 1) + switch (tp.params[0]) { + case TPType(TPath(tptp)): + if (tptp.name == 'String') + { + isNullString = true; + } + case _: + } + case _: + } + + if (!isNullString) + Context.fatalError('Field ${fld.name} must be of type Null to use :envField', fld.pos) + else { + if (envMap.exists(fld.name)) + buildFields[buildFields.indexOf(fld)].kind = FVar(t, macro $v{envMap.get(fld.name)}); + else + Sys.println('WARNING: Value for environment variable "${fld.name}" not found.'); + } + } + case _: + // nothing lol + } + } + return buildFields; + } + + private static function buildEnv():Null + { + if (sys.FileSystem.exists(envPath)) + return sys.io.File.getContent(envPath); + + // For future peoples: to append an item to .env, just add it as a new item in this array. + // Use the format 'NAME_ALL_CAPS=value' - like an ini file! + var items:Array = ['CODENAME_AES_KEY=${generateKey()}']; + var finalProd:String = items.join('\n'); + + sys.io.File.saveContent(Sys.getCwd() + '\\.env', finalProd); + + return finalProd; + } + + private static function generateKey():String + { + var validChars:String = "0123456789ABCDEF"; + var outputStr:String = ''; + + for (i in 0...64) + { + outputStr += validChars.charAt(Math.round(Math.random() * 15)); + } + return outputStr; + } +} \ No newline at end of file diff --git a/source/funkin/backend/utils/GJUtil.hx b/source/funkin/backend/utils/GJUtil.hx new file mode 100644 index 0000000000..8911b7124d --- /dev/null +++ b/source/funkin/backend/utils/GJUtil.hx @@ -0,0 +1,763 @@ +package funkin.backend.utils; + +import lime.graphics.Image; +import haxe.Timer; +import flixel.graphics.FlxGraphic; +import openfl.display.BitmapData; +import funkin.backend.system.gamejolt.GameJoltData; +import funkin.backend.system.gamejolt.GameJoltSecurity; + +#if ALLOW_MULTITHREADING +import funkin.backend.utils.ThreadUtil; +#end +#if (target.threaded) +import sys.thread.Thread; +import sys.thread.Mutex; +#end + +/** + * This is how GameJolt API responses are formatted. + */ +typedef GJResponse = { + // General + success:Bool, + ?message:String, + // User Fetching + ?users:Array, + // Trophies Fetching + ?trophies:Array, + // Scores Fetching + ?scores:Array, + ?tables:Array, + ?rank:Int, + // Friends Fetching + ?friends:Array<{friend_id:Int}>, + // Data Store Fetching + ?keys:Array<{key:String}>, + ?data:String, + // Time Fetching + ?timestamp:Int, + ?timezone:String, + ?year:Int, + ?month:Int, + ?day:Int, + ?hour:Int, + ?minute:Int, + ?second:Int, + // Batch Reception + ?responses:Array +} + +/** + * The way the scores are fetched from your game API. + * + * @param score The display text of the Score. + * @param sort The Score value. + * @param extra_data If some extra data is attached to this Score, it'll be shown here. + * @param user The username of the User who achieved this Score, if it's a registered User. + * @param user_id The user ID of the User who achieved this Score, if it's a registered User. + * @param guest The name of the user who achieved this Score, if it's a guest user. + * @param stored A short description about when the Score was achieved by the User or Guest. + * @param stored_timestamp A long time stamp (in seconds) of when the Score was achieved by the User or Guest. + */ +typedef Score = { + score:String, + sort:Int, + extra_data:String, + user:String, + user_id:Int, + guest:String, + stored:String, + stored_timestamp:Int +} + +/** + * The way the score tables are fetched from your game API. + * + * @param id The ID of the Score Table. + * @param name The name of the Score Table. + * @param description The description of the Score Table. + * @param primary Whether if this is the Primary Score Table in your game (1) or not (0). + */ +typedef ScoreTable = { + id:Int, + name:String, + description:String, + primary:Bool +} + +/** + * The way the trophies are fetched from your game API. + * + * @param id The ID of the Trophy. + * @param title The title of the Trophy. + * @param description The description of the Trophy. + * @param difficulty The difficulty rank of the Trophy. + * @param image_url The link of the image that represents the Trophy. + * @param achieved Whether this Trophy was achieved or not, it can be a string if it was (with info about how much time ago it was achieved) or bool if not (false). + */ +typedef Trophy = { + id:Int, + title:String, + description:String, + difficulty:String, + image_url:String, + achieved:String +} + +/** + * The way the user data is fetched from the GameJolt API. + * + * @param id The ID of the User. + * @param type The category the User is cataloged like in GameJolt. + * @param username The username of the User. (Also available for guests). + * @param avatar_url The link of the avatar of the User. + * @param signed_up A short description about how long the User have been in GameJolt. + * @param signed_up_timestamp A long time stamp (in seconds) of when the User signed up. + * @param last_logged_in A short description about the last time the User was found active in GameJolt. + * @param last_logged_in_timestamp A long time stamp (in seconds) of the last time the User logged in GameJolt. + * @param status The actual status of the User. + * @param developer_name The display name of the User. (Also available for guests). + * @param developer_website The website of the User. + * @param developer_description The description of the User. + */ +typedef User = { + id:Int, + type:String, + username:String, + avatar_url:String, + signed_up:String, + signed_up_timestamp:Int, + last_logged_in:String, + last_logged_in_timestamp:Int, + status:String, + developer_name:String, + developer_website:String, + developer_description:String +} + +/** + * An enum class to clasify Data Store update functions. + */ +enum DataUpdateType { + Add(n:Int); + Substract(n:Int); + Multiply(n:Int); + Divide(n:Int); + Append(t:String); + Prepend(t:String); +} + +/** + * An enum of every single command currently available to request to GameJolt API. + */ +enum RequestType { + BATCH(parallel:Bool, breakOnError:Bool, requests:Array); + DATA_FETCH(key:String, fromUser:Bool); + DATA_GETKEYS(fromUser:Bool, ?pattern:String); + DATA_REMOVE(key:String, fromUser:Bool); + DATA_SET(key:String, data:String, toUser:Bool); + DATA_UPDATE(key:String, operation:DataUpdateType, toUser:Bool); + FRIENDS; + TIME; + USER_AUTH; + USER_FETCH(userOrID:String); + SESSION_OPEN; + SESSION_PING(active:Bool); + SESSION_CHECK; + SESSION_CLOSE; + SCORES_ADD(score:String, sort:Int, ?extra_data:String, ?table_id:Int); + SCORES_GETRANK(sort:Int, ?table_id:Int); + SCORES_FETCH(fromUser:Bool, ?table_id:Int, ?limit:Int, ?betterThan:Int); + SCORES_TABLES; + TROPHIES_FETCH(?achieved:Bool, ?trophy_id:Int); + TROPHIES_ADD(trophy_id:Int); + TROPHIES_REMOVE(trophy_id:Int); +} + +/** + * GameJolt utility to help with GameJolt functionality. Use this class to determine if your player is logged into GameJolt, + * unlock custom trophies, or do anything that is safe to accomplish with softcoding. Will not do anything if there is no + * provided GameJolt token or game ID. + * + * # IMPORTANT + * If you wish to setup GameJolt API integrations, please follow these steps: + * 1. Place your game's game ID (NOT your game's security key) into your `modpack.ini` + * for the flag `GAMEJOLT_GAME_ID`. + * 2. Create a file in `data/config` named `gamejolt.xml`. + * 3. Create nodes for the following: + * ```xml + * + * your-game-security-key-here + * ``` + * 4. Create nodes for trophies and leaderboards. + * 5. Create nodes for data - any specific items in save files you want to be saved in a user's data store. + * 6. Run the mod in Codename Engine (preferred: run the EXE file from a Command Prompt). + * If successful, you will see a screen with more information; if unsuccessful, you'll see + * an error in the console. + * 7. If successful, press the "Copy" button and place the copied output into your modpack.ini, + * preferrably right under the `GAMEJOLT_GAME_ID` flag. + * 8. Discard the overwritten `gamejolt.xml` file or keep it for any updates you want to make to + * the global data store. + * + * ## DO NOT PLACE YOUR SECURITY KEY RIGHT INTO THE MODPACK.INI!!!! THAT IS A SECURITY ISSUE!!!! + */ +class GJUtil +{ + /** + * Boolean to determine if our player logged in. + */ + public static var loggedIn:Bool = false; + + /** + * The username of the logged in user. + */ + public static var userName(default, set):String; + + /** + * The avatar of the logged in user. + */ + public static var userAvatarUrl(default, null):String; + + /** + * The profile markdown description of the logged in user. + */ + public static var userDescription(default, null):String; + + /** + * Whether or not the GameJolt utility is operational. + * This cannot be set other than load operations. + */ + public static var active(default, null):Bool = false; + + /** + * Helper function in case the session is lost in the middle of the game. + */ + public static var onLostSession:NullVoid> = null; + + /** + * Helper function that runs when an achievement is unlocked. + * Can be useful for notifications. + */ + public static var onTrophyUnlock:NullVoid> = null; + + /** + * Whether or not the utility is executing a call. + */ + static var executing:Bool = false; + + /** + * The timer for calling the session ping. Runs every 10 seconds. + */ + static var daTimer:Null = null; + + #if (target.threaded) + static final mutex = new Mutex(); + #end + + #if GAMEJOLT_API + /** + * Initializes the GJUtil class and attempts to log in. + * If needed, also attempts to initialize global data. + */ + public static function init() + { + if (Flags.MOD_GAMEJOLT_GAME_ID == '') + return; + + if (Flags.MOD_GAMEJOLT_ENCRYPTED_TOKEN == '') { + GameJoltData.loadAdminData(); + if (Flags.MOD_GAMEJOLT_ENCRYPTED_TOKEN == '') + return; + } else if (FlxG.save.data.gameJoltArray != null) { + var gjDat:Array = FlxG.save.data.gameJoltArray; + GJUtil.attemptLogin(gjDat[0], gjDat[1]); + } + } + + /** + * Helper function to simplify the login process. + * @param name Username of user attempting to login. + * @param token User token of user attempting to login. + * @param callback Function to run when attempt is complete. Return bool + * determines if attempt was successful or unsuccessful. + * @param checkCreds Whether or not to check and make sure the user actually + * exists. Helpful for new login attempts (instead of confirmed attempts, like + * those coming from the save file). + * @param tempLogin Whether or not the login is temporary (i.e. first-time global + * data upload) or permanent. + */ + public static function attemptLogin(name:String, token:String, ?callback:Bool->Void, checkCreds:Bool = false, tempLogin:Bool = false) + { + if(Flags.MOD_GAMEJOLT_GAME_ID != '' && Flags.MOD_GAMEJOLT_ENCRYPTED_TOKEN != '') { + active = true; + + userName = name; + GameJoltSecurity.user_token = token; + var batchCalls:Array = [SESSION_OPEN, USER_FETCH(name)]; + if (checkCreds) + batchCalls.unshift(USER_AUTH); + if (!tempLogin) { + batchCalls.push(TROPHIES_FETCH()); + } + + send(RequestType.BATCH(true, false, batchCalls), !tempLogin, function(err) { + userName = null; + if (callback != null) callback(false); + }, function(resp) { + GameJoltSecurity.userId = resp.responses[checkCreds ? 2 : 1].users[0].id; + if (!tempLogin) { + userAvatarUrl = resp.responses[checkCreds ? 2 : 1].users[0].avatar_url; + userDescription = resp.responses[checkCreds ? 2 : 1].users[0].developer_description; + GameJoltData.loadGlobalData((bl) -> { + if (bl) { + for (trop in resp.responses[checkCreds ? 3 : 2].trophies) { + for (key => value in GameJoltData.definedTrophies) { + if (value.id == trop.id && trop.achieved != "false") + GameJoltData.earnedTrophies.set(key, value); + } + for (key => value in GameJoltData.customTrophies) { + if (value.id == trop.id && trop.achieved != "false") + GameJoltData.earnedTrophies.set(key, value); + } + } + + Logs.traceColored([ + Logs.getPrefix("GameJolt"), + Logs.logText("Successfully logged in user "), + Logs.logText(userName, GREEN), + Logs.logText('!') + ], SUCCESS); + openfl.Lib.application.onExit.add(onExitApp); + daTimer = new Timer(10000); + daTimer.run = pingSession; + if (checkCreds) { + FlxG.save.data.gameJoltArray = [userName, token]; + FlxG.save.flush(); + } + GameJoltSecurity.unlockDefinedTrophy('open-first'); + if (Date.now().getDay() == 5 && Date.now().getHours() >= 18) + GameJoltSecurity.unlockDefinedTrophy('friday-night'); + if (callback != null) callback(true); + } else { + Logs.trace('Unable to obtain global data. Logging out of GameJolt.', ERROR, LIGHTGRAY, 'GameJolt'); + logout(false, true); // so that it doesn't remove functions that don't exist + if (callback != null) callback(false); + } + }); + } else if (callback != null) + callback(true); + }); + } + else + if (callback != null) callback(false); + } + + /** + * Logs out a user from GameJolt. + * @param wipeSave Whether or not to wipe the user credentials from + * the save file. + * @param tempLogin Whether or not the login was temporary (i.e. first-time global + * data upload) or permanent. + */ + public static function logout(wipeSave:Bool = false, tempLogin:Bool = false) + { + if (!active) + return; + if (!tempLogin) + shutdownFunctions(); + send(RequestType.SESSION_CLOSE, !tempLogin, null, function(resp) { + if (!tempLogin) + Logs.traceColored([ + Logs.getPrefix("GameJolt"), + Logs.logText("User "), + Logs.logText(userName, GREEN), + Logs.logText(' logged out successfully.') + ], VERBOSE); + userName = null; + if (wipeSave) { + FlxG.save.data.gameJoltArray = null; + FlxG.save.flush(); + } + }); + } + + /** + * Make a GameJolt API call that is safe to make via softcoding. + * @param call The RequestType call to make. Currently only supports: + * `FRIENDS`, `TIME`, `USER_FETCH`, `SCORES_GETRANK`, and `TROPHIES_FETCH`. + * @param async Whether or not the call should be asyncronous. + * @param onError Callback function if an error occurs. Gives error string. + * @param onComplete Callback function on successful completion of the call. Gives response data. + * @param onProgress Callback function for progress on async calls. Gives a progress float array. + */ + public static function makeCall(call:RequestType, async:Bool = false, ?onError:String->Void, ?onComplete:GJResponse->Void, ?onProgress:Array->Void) + { + switch(call) { + case BATCH(parallel, breakOnError, requests): + return; + + case DATA_GETKEYS(fromUser, pattern): + return; + + case DATA_REMOVE(key, fromUser): + return; + + case DATA_SET(key, data, toUser): + return; + + case DATA_UPDATE(key, operation, toUser): + return; + + case USER_AUTH: + return; + + case SESSION_OPEN: + return; + + case SESSION_PING(active): + return; + + case SESSION_CHECK: + return; + + case SESSION_CLOSE: + return; + + case SCORES_ADD(score, sort, extra_data, table_id): + return; + + case TROPHIES_ADD(trophy_id): + return; + + case TROPHIES_REMOVE(trophy_id): + return; + + case _: + send(call, async, onError, onComplete, onProgress); + } + } + + /** + * Unlocks a trophy from the `customTrophies` map - in other words, + * any trophy that isn't specifically defined in hardcode. + * @param custom Key/def of trophy in `customTrophies` map. + * @param callback Function running on completion of unlock attempt. Returns + * trophy data if successful, `null` if unsuccessful. + */ + public static function unlockCustomTrophy(custom:String, ?callback:Null->Void) + { + if (!GameJoltData.customTrophies.exists(custom)) + if (callback != null) callback(null) + else { + var daTrophy:GJTrophyData = GameJoltData.customTrophies.get(custom); + + if (GameJoltData.earnedTrophies.exists(custom)) + if (callback != null) callback(null) + else { + var meetsReqs:Bool = true; + if (daTrophy.require != null) { + var reqsMet:Array = []; + for (earned in GameJoltData.earnedTrophies) + if (daTrophy.require.contains(earned.id)) reqsMet.push(earned.id); + + if (reqsMet.length != daTrophy.require.length) + meetsReqs = false; + } + + if (!meetsReqs) + if(callback != null) callback(null) + else { + send(TROPHIES_ADD(daTrophy.id), true, function(err) { + Logs.trace('Trophy unlock error: ${err}', ERROR, LIGHTGRAY, 'GameJolt'); + if (callback != null) callback(null); + }, function(resp) { + GameJoltData.earnedTrophies.set(custom, daTrophy); + Logs.trace('Trophy unlock: ${resp.responses[0].trophies[0].title}!'); + if (onTrophyUnlock != null) onTrophyUnlock(resp.responses[0].trophies[0]); + if (callback != null) callback(resp.trophies[0]); + }); + } + } + } + } + + /** + * Fetches image of user avatar (using same method as fetching GitHub profile images). + * @param image Sprite to apply the bitmap to. + * @param link Link to get image from. Defaults to logged in user's link if null. + * @param addlCallback Callback function to run on successful completion of attempt. + */ + public static function getAvatarImage(image:FlxSprite, ?link:String, ?addlCallback:Void->Void) + { + #if ALLOW_MULTITHREADING ThreadUtil.execAsync#elseif (target.threaded) Thread.create#end(function() { + var key:String = 'GAMEJOLT-LINK:${link != null ? link : userName}'; + var bmap:Dynamic = FlxG.bitmap.get(key); + + if(bmap == null) { + Logs.trace('Downloading avatar: ${link != null ? link : userName}', INFO, LIGHTGRAY, 'GameJolt'); + var unfLink:Bool = StringTools.endsWith(link != null ? link : userAvatarUrl, '.png'); + + var bytes = null; + if(unfLink) { + try bytes = HttpUtil.requestBytes(link != null ? link : userAvatarUrl) + catch(e) Logs.error('Failed to download GameJolt pfp for ${link != null ? link : userName}: ${e.message} - (Retrying using the api..)', RED, 'GameJolt'); + + if(bytes != null) { + bmap = BitmapData.fromBytes(bytes); + } + } + + var leGraphic:FlxGraphic = null; + if(bmap != null) try { + #if (target.threaded) + mutex.acquire(); + #end + leGraphic = FlxG.bitmap.add(bmap, false, key); + leGraphic.persist = true; + bmap = null; + image.loadGraphic(leGraphic); + if (addlCallback != null) addlCallback(); + #if (target.threaded) + mutex.release(); + #end + } catch(e) { + Logs.error('Failed to update the pfp for ${userName}: ${e.message}', RED, 'GameJolt'); + } + } else { + image.loadGraphic(bmap); + if (addlCallback != null) addlCallback(); + } + }); + } + + /** + * Fetches image of a trophy (using same method as fetching GitHub profile images). + * If null, it will set the graphic to one of the default graphics stored in the + * Assets file. + * + * You can also create your own default trophy images by just making "bronze", + * "silver", "gold", and "platnum" trophy images, then placing those in + * "images/menus/gamejolt". + * @param image Sprite to apply the bitmap to. + * @param link Link to get image from. Defaults to logged in user's link if null. + * @param addlCallback Callback function to run on successful completion of attempt. + */ + public static function getTrophyImage(image:FlxSprite, trophy:Trophy, ?addlCallback:Void->Void) + { + #if ALLOW_MULTITHREADING ThreadUtil.execAsync#elseif (target.threaded) Thread.create#end(function() { + var key:String = 'GAMEJOLT-TROP:${trophy.image_url}'; + var bmap:Dynamic = FlxG.bitmap.get(key); + + if(bmap == null) { + Logs.trace('Downloading trophy image for trophy: ${trophy.title}', INFO, LIGHTGRAY, 'GameJolt'); + var unfLink:Bool = StringTools.endsWith(trophy.image_url, '.png'); + + if(unfLink) { + Image.loadFromFile(trophy.image_url).onComplete((img:Image) -> { + try { + #if (target.threaded) + mutex.acquire(); + #end + var leGraphic:FlxGraphic = FlxG.bitmap.add(BitmapData.fromImage(img), false, key); + leGraphic.persist = true; + image.loadGraphic(leGraphic); + if (addlCallback != null) addlCallback(); + #if (target.threaded) + mutex.release(); + #end + } catch(e) { + Logs.error('Failed to update the image for ${trophy.title}: ${e.message}', RED, 'GameJolt'); + } + }).onError((e) -> { + Logs.error('Failed to download trophy image for ${trophy.title}: ${e.message} - (Obtaining default image from files instead...)', RED, 'GameJolt'); + try { + #if (target.threaded) + mutex.acquire(); + #end + var secret:Bool = false; + for (t in GameJoltData.definedTrophies) { + if (t.id == trophy.id && t.hidden != null && !t.hidden) + secret = true; + } + + for (t in GameJoltData.customTrophies) { + if (t.id == trophy.id && t.hidden != null && !t.hidden) + secret = true; + } + var leGraphic:FlxGraphic = FlxG.bitmap.add(BitmapData.fromFile(Paths.image('menus/gamejolt/' + trophy.difficulty.toLowerCase() + (secret ? "-secret" : ""))), false, key); + leGraphic.persist = true; + image.loadGraphic(leGraphic); + if (addlCallback != null) addlCallback(); + #if (target.threaded) + mutex.release(); + #end + } catch(e) { + Logs.error('Failed to update the image for ${trophy.title}: ${e.message}', RED, 'GameJolt'); + } + }); + } else { + Logs.error('Image for ${trophy.title} is not a .png image; obtaining default image from files instead...', RED, 'GameJolt'); + try { + #if (target.threaded) + mutex.acquire(); + #end + var leGraphic:FlxGraphic = FlxG.bitmap.add(BitmapData.fromFile(Paths.image('menus/gamejolt/' + trophy.difficulty.toLowerCase())), false, key); + leGraphic.persist = true; + image.loadGraphic(leGraphic); + if (addlCallback != null) addlCallback(); + #if (target.threaded) + mutex.release(); + #end + } catch(e) { + Logs.error('Failed to update the image for ${trophy.title}: ${e.message}', RED, 'GameJolt'); + } + } + } else { + image.loadGraphic(bmap); + if (addlCallback != null) addlCallback(); + } + }); + } + + /** + * Function to ping session on regular basis. + */ + static function pingSession() + { + send(SESSION_PING(true), true, (str) -> { + if (onLostSession != null) onLostSession(); + shutdownFunctions(); + Logs.trace('Session lost ($str); GJUtil shut down successfully.', WARNING, LIGHTGRAY, 'GameJolt'); + active = false; + }); + } + + /** + * Function to log out of GameJolt on application exit. + * @param i idk man. + */ + static function onExitApp(i:Int) + { + logout(); + } + + /** + * Functions to run when safely logging out user from GameJolt. + */ + static function shutdownFunctions() + { + Logs.traceColored([ + Logs.getPrefix("GameJolt"), + Logs.logText("Logging out user "), + Logs.logText(userName, GREEN), + Logs.logText('...') + ], INFO); + daTimer.stop(); + daTimer = null; + openfl.Lib.application.onExit.remove(onExitApp); + onLostSession = null; + GameJoltData.reset(); + } + + /** + * Main send function for calls and requests using GameJolt API. + * @param call Type of request to send. + * @param async Whether or not the request is asynchronous. + * @param onError Function to run on error. Returns error message as string. + * @param onComplete Function to run on success. Returns response data as typedef `Response`. + * @param onProgress Function to run on async progress. Returns array of floats. + */ + @:noPrivateAccess static function send(call:RequestType, async:Bool = false, ?onError:String->Void, ?onComplete:GJResponse->Void, ?onProgress:Array->Void) + { + if (executing || !active) + return; + executing = true; + + @:privateAccess + GameJoltSecurity.handleRequest(async, call, function(errmsg) { + executing = false; + if (onError != null) onError(errmsg); + }, function(resp) { + executing = false; + if (onComplete != null) onComplete(formatImages(resp)); + }, onProgress); + } + + /** + * Formats any images received to proper file types + * @param res Response to format. + * @return GJResponse Response with formatted images. + */ + static function formatImages(res:GJResponse):GJResponse { + if (res.users != null) { + for (u in res.users) { + u.avatar_url = '${u.avatar_url.substring(0, 32)}1000${u.avatar_url.substr(34)}'.replace(".jpg", ".png").replace(".webp", ".png"); + } + } + if (res.trophies != null && res.trophies[0] != null) { + for (t in res.trophies) { + var newUrl:String = ""; + if (t.image_url.startsWith('https://m.')) + newUrl = '${t.image_url.substring(0, 37)}1000${t.image_url.substr(40)}'.replace(".jpg", ".png").replace(".webp", ".png"); + else { + newUrl = 'https://s.gjcdn.net/assets/${t.image_url.substr(24)}'; + } + t.image_url = newUrl; + }; + } + if (res.responses != null) for (res2 in res.responses) res2 = formatImages(res2); + return res; + } + + static function set_userName(name:String):String + { + loggedIn = (name != null && name != ''); + if (name == null) { + userAvatarUrl = null; + userDescription = null; + GameJoltSecurity.userId = null; + GameJoltSecurity.user_token = null; + + } + return userName = name; + } + #else + public static function init() + { + Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + } + + public static function attemptLogin(name:String, token:String, ?callback:Bool->Void, checkCreds:Bool = false, tempLogin:Bool = false) + { + Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + if (callback != null) callback(false); + } + + public static function logout(wipeSave:Bool = false, tempLogin:Bool = false) + { + Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + } + + public static function makeCall(call:RequestType, async:Bool = false, ?onError:String->Void, ?onComplete:GJResponse->Void, ?onProgress:Array->Void) + { + Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + } + + public static function unlockCustomTrophy(custom:String, ?callback:Null->Void) + { + Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + if (callback != null) callback(null); + } + + public static function getAvatarImage(image:FlxSprite, ?addlCallback:Void->Void) + { + Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + } + + static function set_userName(name:String):String + { + return userName = name; + } + #end +} \ No newline at end of file diff --git a/source/funkin/game/PlayState.hx b/source/funkin/game/PlayState.hx index 1b937ca6c8..69e3d7899e 100644 --- a/source/funkin/game/PlayState.hx +++ b/source/funkin/game/PlayState.hx @@ -24,6 +24,7 @@ import funkin.backend.scripting.events.gameplay.*; import funkin.backend.scripting.events.note.*; import funkin.backend.system.Conductor; import funkin.backend.system.RotatingSpriteGroup; +import funkin.backend.system.gamejolt.*; import funkin.editors.SaveWarning; import funkin.editors.charter.Charter; import funkin.editors.charter.CharterSelection; @@ -1764,6 +1765,8 @@ class PlayState extends MusicBeatState deathCounter++; + GameJoltSecurity.unlockDefinedTrophy('death-first'); + openSubState(new GameOverSubstate(event.x, event.y, event.deathCharID, event.isPlayer, event.gameOverSong, event.lossSFX, event.retrySFX)); gameAndCharsEvent("onPostGameOver", event); diff --git a/source/funkin/menus/MainMenuState.hx b/source/funkin/menus/MainMenuState.hx index 43f33ebb53..0690c039d7 100644 --- a/source/funkin/menus/MainMenuState.hx +++ b/source/funkin/menus/MainMenuState.hx @@ -7,6 +7,7 @@ import funkin.backend.FunkinText; import funkin.backend.scripting.events.menu.MenuChangeEvent; import funkin.backend.scripting.events.NameEvent; import funkin.menus.credits.CreditsMain; +import funkin.menus.gamejolt.GameJoltMenu; import funkin.options.OptionsMenu; import lime.app.Application; @@ -62,16 +63,33 @@ class MainMenuState extends MusicBeatState for (i=>option in optionShit) { - var menuItem:FlxSprite = new FlxSprite(0, 60 + (i * 160)); - menuItem.frames = Paths.getFrames('menus/mainmenu/${option}'); - menuItem.animation.addByPrefix('idle', option + " basic", 24); - menuItem.animation.addByPrefix('selected', option + " white", 24); - menuItem.animation.play('idle'); - menuItem.ID = i; - menuItem.screenCenter(X); - menuItems.add(menuItem); - menuItem.scrollFactor.set(); - menuItem.antialiasing = true; + if (option == 'gamejolt') { + #if GAMEJOLT_API + var menuItem:FlxSprite = new FlxSprite(FlxG.width - 138, FlxG.height - 138).loadGraphic(Paths.image('menus/gamejolt-icon')); + menuItem.setGraphicSize(128); + menuItem.updateHitbox(); + menuItem.animation.add('idle', [0], 1, false); + menuItem.animation.add('selected', [0], 1, false); + menuItem.animation.play('idle'); + menuItem.ID = i; + menuItems.add(menuItem); + menuItem.scrollFactor.set(); + menuItem.antialiasing = true; + #else + continue; + #end + } else { + var menuItem:FlxSprite = new FlxSprite(0, 60 + (i * 160)); + menuItem.frames = Paths.getFrames('menus/mainmenu/${option}'); + menuItem.animation.addByPrefix('idle', option + " basic", 24); + menuItem.animation.addByPrefix('selected', option + " white", 24); + menuItem.animation.play('idle'); + menuItem.ID = i; + menuItem.screenCenter(X); + menuItems.add(menuItem); + menuItem.scrollFactor.set(); + menuItem.antialiasing = true; + } } FlxG.camera.follow(camFollow, null, 0.06); @@ -190,6 +208,7 @@ class MainMenuState extends MusicBeatState case 'freeplay': FlxG.switchState(new FreeplayState()); case 'donate', 'credits': FlxG.switchState(new CreditsMain()); // kept donate for not breaking scripts, if you don't want donate to bring you to the credits menu, thats easy softcodable - Nex case 'options': FlxG.switchState(new OptionsMenu()); + case 'gamejolt': FlxG.switchState(new GameJoltMenu()); } }); } diff --git a/source/funkin/menus/gamejolt/GameJoltCompleteScreen.hx b/source/funkin/menus/gamejolt/GameJoltCompleteScreen.hx new file mode 100644 index 0000000000..05a623952a --- /dev/null +++ b/source/funkin/menus/gamejolt/GameJoltCompleteScreen.hx @@ -0,0 +1,66 @@ +package funkin.menus.gamejolt; + +import funkin.editors.ui.*; +import openfl.desktop.Clipboard; +import flixel.addons.display.FlxBackdrop; +import funkin.backend.system.gamejolt.*; +import funkin.backend.utils.GJUtil; +import funkin.menus.TitleState; + +class GameJoltCompleteScreen extends UIState +{ + var bg:FlxBackdrop; + var mainText:UIText; + var copyText:UIText; + var copyButton:UIButton; + var continueText:UIText; + + override public function create() + { + super.create(); + + add(bg = new FlxBackdrop()); + bg.loadGraphic(Paths.image('editors/bgs/default')); + bg.antialiasing = true; + bg.rotation = -5; + bg.velocity.set(85, 0).degrees = bg.rotation; + + add(mainText = new UIText(0, 80, FlxG.width, + 'Hey there funkhead! + Your GameJolt data was recognized and registered to your game\'s data store successfully. + From here on, only ${GameJoltData.ownerUsername} will be able to change the global data. As for the XML - for security purposes, we took out the login info and game token. Feel free to discard the XML entirely, or keep it as a souvenir - your globals load from your game\'s data store now! + Before you go: it\'s important to press the "Copy" button below and copy the following text into your mod\'s modpack.ini file. This is your game\'s security key encrypted uniquely for this build. Paste it in the "Common" section.', + 30)); + + mainText.alignment = CENTER; + mainText.antialiasing = true; + + add(copyText = new UIText(0, mainText.height + 120, FlxG.width, 'GAMEJOLT_ENCRYPTED_TOKEN=\'${Flags.MOD_GAMEJOLT_ENCRYPTED_TOKEN}\'', 16)); + copyText.alignment = CENTER; + copyText.antialiasing = true; + + add(copyButton = new UIButton(0, copyText.y + copyText.height + 20, "Copy", () -> { + Clipboard.generalClipboard.setData(TEXT_FORMAT, copyText.text); + })); + copyButton.color = 0xFF3F3FFF; + copyButton.x = (FlxG.width / 2) - (copyButton.bWidth / 2); + + add(continueText = new UIText(0, copyButton.y + copyButton.bHeight + 30, FlxG.width, '~ Press ${controls.getKeyName(ACCEPT)} to continue ~')); + continueText.alignment = CENTER; + + CoolUtil.playMusic(Paths.music('breakfast')); + } + + override public function update(elapsed:Float) + { + super.update(elapsed); + if (controls.ACCEPT) { + CoolUtil.playMenuSFX(CONFIRM); + if (FlxG.save.data.gameJoltArray != null) { + var gjDat:Array = FlxG.save.data.gameJoltArray; + GJUtil.attemptLogin(gjDat[0], gjDat[1]); + } + FlxG.switchState(new TitleState()); + } + } +} \ No newline at end of file diff --git a/source/funkin/menus/gamejolt/GameJoltConfirmationWindow.hx b/source/funkin/menus/gamejolt/GameJoltConfirmationWindow.hx new file mode 100644 index 0000000000..3fbacf8810 --- /dev/null +++ b/source/funkin/menus/gamejolt/GameJoltConfirmationWindow.hx @@ -0,0 +1,53 @@ +package funkin.menus.gamejolt; + +import funkin.editors.ui.*; + +class GameJoltConfirmationWindow extends UISubstateWindow +{ + public var actionType:String; + public var description:String; + public var callback:NullVoid>; + public var closeOnConfirm:Bool; + public var yesText:String; + public var noText:String; + + public var yesButton:UIButton; + public var noButton:UIButton; + + override public function new(actionType:String = 'Logout', description:String = 'No description provided.', ?callback:Void->Void, closeOnConfirm:Bool = true, ?yesText:String, ?noText:String) + { + super(); + this.actionType = actionType; + this.description = description; + this.callback = callback; + this.closeOnConfirm = closeOnConfirm; + this.yesText = yesText != null ? yesText : TU.translate('editor.yes'); + this.noText = noText != null ? noText : TU.translate('editor.cancel'); + } + + override public function create() + { + winWidth = 360; + + winTitle = 'Confirm $actionType'; + + super.create(); + + add(messageSpr = new UIText(windowSpr.x + 20, windowSpr.y + 46, windowSpr.bWidth - 40, description)); + messageSpr.alignment = CENTER; + + windowSpr.resize(winWidth, Std.int(messageSpr.y + messageSpr.height + 68)); + + add(yesButton = new UIButton(windowSpr.x + (windowSpr.bWidth / 2) - 130, windowSpr.y + windowSpr.bHeight - 48, yesText, confirmAndClose, 125)); + add(noButton = new UIButton(windowSpr.x + (windowSpr.bWidth / 2) + 5, windowSpr.y + windowSpr.bHeight - 48, noText, close, 125)); + noButton.color = 0xFFFF0000; + } + + function confirmAndClose() + { + if (callback != null) + callback(); + if (closeOnConfirm) + close(); + } +} \ No newline at end of file diff --git a/source/funkin/menus/gamejolt/GameJoltDataWindow.hx b/source/funkin/menus/gamejolt/GameJoltDataWindow.hx new file mode 100644 index 0000000000..78262feb75 --- /dev/null +++ b/source/funkin/menus/gamejolt/GameJoltDataWindow.hx @@ -0,0 +1,119 @@ +package funkin.menus.gamejolt; + +import funkin.backend.system.gamejolt.*; +import funkin.editors.ui.*; +import funkin.menus.gamejolt.*; +import funkin.menus.MainMenuState; + +enum abstract DataWindowType(String) { + var USER = 'User'; + var GLOBAL = 'Global'; +} + +class GameJoltDataWindow extends UISubstateWindow { + var loadButton:UIButton; + var saveButton:UIButton; + var closeButton:UIButton; + var resetButton:UIButton; + + var type:DataWindowType; + + var daX:Float; + + override public function new(type:DataWindowType) + { + super(); + // Prevent people from manipulating global data + this.type = ((GameJoltSecurity.userId == GameJoltData.ownerUserId && GameJoltSecurity.userId != null) ? type : USER); + } + + override public function create() + { + winTitle = '${type} Data Actions'; + + winWidth = 756; + + super.create(); + + daX = windowSpr.x + 20; + + // Resize window height automatically with height of unused messageSpr. + add(messageSpr = new UIText(daX, windowSpr.y + 46, windowSpr.bWidth - ((daX - windowSpr.x) * 2), type == USER ? "User data includes base Codename Engine options (including base controls), current scores, and any other items defined by this mod's developers." : "Remember that gamejolt.xml file in data/global you used to set up the global items like leaderboards and trophy info? You can retrieve that here or overwrite the current globals here.")); + messageSpr.alignment = CENTER; + windowSpr.resize(winWidth, Std.int(messageSpr.y + messageSpr.height + 68)); + + // Load button for loading user/global data. + add(loadButton = new UIButton(windowSpr.x + (windowSpr.bWidth / 2) - 265, windowSpr.y + windowSpr.bHeight - 48, "Load", () -> { + if (type == USER) + openSubState(new GameJoltConfirmationWindow('User Data Load', 'This will load, and overwrite, any user-specific data saved in this mod\'s data store to the local save.\nAre you sure you want to do this?', () -> { + GameJoltData.loadUserData((bl) -> { + if (bl) + openSubState(new GameJoltInfoWindow('User Data Load Success', 'User data loaded successfully.', close)); + else + openSubState(new GameJoltInfoWindow('User Data Load Error', 'Unable to load user data. Please check console for more information.', close)); + }); + }, false)); + else + openSubState(new GameJoltConfirmationWindow('Global Data Load', 'This will load all global data saved in this mod\'s data store. It will then write it to this mod\'s data/config/gamejolt.xml\nAre you sure you want to do this?', () -> { + GameJoltData.loadGlobalData((bl) -> { + if (bl) + openSubState(new GameJoltInfoWindow('Global Data Load Success', 'Global data loaded successfully into data/config/gamejolt.xml.', close)); + else + openSubState(new GameJoltInfoWindow('Global Data Load Error', 'Unable to load global data. Please check console for more information.', close)); + }); + }, false)); + }, 125)); + + // Save button for writing user/global data. + add(saveButton = new UIButton(windowSpr.x + (windowSpr.bWidth / 2) - 130, windowSpr.y + windowSpr.bHeight - 48, TU.translate("editor.save"), () -> { + if (type == USER) + openSubState(new GameJoltConfirmationWindow('User Data Save', 'This will save any user-specific data saved in this mod\'s data store. If a save exists for this user in the data store, it will overwrite that save.\nAre you sure you want to do this?', () -> { + GameJoltData.setUserData((bl) -> { + if (bl) + openSubState(new GameJoltInfoWindow('User Data Save Success', 'User data saved successfully.', close)); + else + openSubState(new GameJoltInfoWindow('User Data Save Error', 'Unable to save user data. Please check console for more information.', close)); + }); + }, false)); + else + openSubState(new GameJoltConfirmationWindow('Global Data Save', 'This will transfer all global data in data/config/gamejolt.xml to this mod\'s data store. It will overwrite any previous values.\nAre you sure you want to do this?', () -> { + GameJoltData.setGlobalData(false, (bl) -> { + if (bl) + openSubState(new GameJoltInfoWindow('Global Data Save Success', 'Global data saved successfully.', close)); + else + openSubState(new GameJoltInfoWindow('Global Data Save Error', 'Unable to save global data. Please check console for more information.', close)); + }); + }, false)); + }, 125)); + + // Reset button for removing user/global data. + add(resetButton = new UIButton(windowSpr.x + (windowSpr.bWidth / 2) + 5, saveButton.y, "Remove " + (type == USER ? "User Data" : "GJ Integration"), () -> { + if (type == USER) + openSubState(new GameJoltConfirmationWindow('Remove User Data', '!! WARNING !!\nThis will remove ALL user data from this mod\'s data store. This is an irreversible action.\nARE YOU SURE YOU WANT TO DO THIS?', () -> { + GameJoltData.wipeUserData((bl) -> { + if (bl) + openSubState(new GameJoltInfoWindow('User Data Wipe Success', 'User data wiped successfully.', close)); + else + openSubState(new GameJoltInfoWindow('User Data Wipe Error', 'Error wiping user data.', close)); + }); + }, false)); + else + openSubState(new GameJoltConfirmationWindow('Remove GameJolt Integration', '!! WARNING !!\nThis will remove ALL global data from your game\'s data store. You will not be able to use GameJolt integrations for this mod until you open the mod with a properly configured gamejolt.xml file in data/config.\nARE YOU SURE YOU WANT TO DO THIS?', () -> { + GameJoltData.wipeGlobalData((bl) -> { + if (bl) + openSubState(new GameJoltInfoWindow('Global Data Wipe Success', 'Global data wiped successfully. This mod no longer has GameJolt integrations. Now redirecting to the Main Menu.', () -> { + FlxG.state.closeSubState(); + FlxG.switchState(new MainMenuState()); + })); + else + openSubState(new GameJoltInfoWindow('Global Data Wipe Error', 'Error wiping global data.', close)); + }); + }, false)); + }, 125)); + resetButton.color = 0xFFFF0000; + + // The humble "close window" button. + add(closeButton = new UIButton(windowSpr.x + (windowSpr.bWidth / 2) + 140, windowSpr.y + windowSpr.bHeight - 48, TU.translate("editor.close"), close, 125)); + closeButton.color = 0xFFFF0000; + } +} \ No newline at end of file diff --git a/source/funkin/menus/gamejolt/GameJoltInfoWindow.hx b/source/funkin/menus/gamejolt/GameJoltInfoWindow.hx new file mode 100644 index 0000000000..38eef3f43c --- /dev/null +++ b/source/funkin/menus/gamejolt/GameJoltInfoWindow.hx @@ -0,0 +1,48 @@ +package funkin.menus.gamejolt; + +import funkin.editors.ui.*; + +class GameJoltInfoWindow extends UISubstateWindow { + public var infoTitle:String; + public var description:String; + public var closeText:String; + public var callback:NullVoid>; + + public var closeButton:UIButton; + + public var daX:Float; + + override public function new(infoTitle:String = 'General', description:String = "No description provided.", ?callback:Void->Void, ?closeText:String) + { + super(); + this.infoTitle = infoTitle; + this.description = description; + this.closeText = closeText != null ? closeText : TU.translate('editor.ok'); + this.callback = callback; + } + + public override function create() { + //TODO: get translations for text + winTitle = 'Info - $infoTitle'; + + winWidth = 756; + + super.create(); + + daX = windowSpr.x + 20; + + add(messageSpr = new UIText(daX, windowSpr.y + 46, windowSpr.bWidth - ((daX - windowSpr.x) * 2), description)); + messageSpr.alignment = CENTER; + + windowSpr.resize(winWidth, Std.int(messageSpr.y + messageSpr.height + 68)); + + //add(new UISprite(daX, text.y + text.height + 10).loadGraphic(Paths.image())) + add(closeButton = new UIButton(windowSpr.x + (windowSpr.bWidth / 2) - 62, windowSpr.y + windowSpr.bHeight - 48, TU.translate("editor.close"), close, 125)); + } + + public override function close() + { + if (callback != null) callback(); + super.close(); + } +} \ No newline at end of file diff --git a/source/funkin/menus/gamejolt/GameJoltLoginWindow.hx b/source/funkin/menus/gamejolt/GameJoltLoginWindow.hx new file mode 100644 index 0000000000..f166c4103c --- /dev/null +++ b/source/funkin/menus/gamejolt/GameJoltLoginWindow.hx @@ -0,0 +1,55 @@ +package funkin.menus.gamejolt; + +import funkin.editors.ui.*; +import funkin.menus.gamejolt.*; + +class GameJoltLoginWindow extends UISubstateWindow { + public var usernameBox:UITextBox; + public var userTokenLabel:UIText; + public var userTokenInfo:UIButton; + public var userTokenBox:UITextBox; + + public var closeButton:UIButton; + public var loginButton:UIButton; + + public var daX:Float; + + public override function create() { + //TODO: get translations for text + winTitle = "Login with GameJolt..."; + + winWidth = 360; + winHeight = 250; + + super.create(); + + daX = windowSpr.x + 20; + + add(usernameBox = new UITextBox(daX, windowSpr.y + 60, "")); + usernameBox.members.push(new UIText(daX, usernameBox.y - 24, 0, "Username")); + + add(userTokenBox = new UITextBox(daX, usernameBox.y + usernameBox.height + 60, "")); + userTokenBox.members.push(userTokenLabel = new UIText(daX, userTokenBox.y - 24, 0, "User Token")); + userTokenBox.members.push(userTokenInfo = new UIButton(daX + userTokenLabel.width, userTokenLabel.y - 4, "?", () -> { + openSubState(new GameJoltInfoWindow('GameJolt User Token', "!! THIS IS NOT YOUR GAMEJOLT ACCOUNT PASSWORD !!\n\nTo access your game token, click on your profile icon, then click on \"Game Token\".")); + }, 24, 24)); + userTokenInfo.color = 0xFF3737FF; + userTokenBox.label.textField.displayAsPassword = true; + + add(loginButton = new UIButton(windowSpr.x + (windowSpr.bWidth / 2) + 20, windowSpr.y + windowSpr.bHeight - 48, "Login", function() { + GJUtil.attemptLogin(usernameBox.label.text, userTokenBox.label.text, (bl) -> { + openSubState(new GameJoltInfoWindow(bl ? "Login Success!" : "Login Error", bl ? 'You have successfully logged in as ${GJUtil.userName}!' : 'Could not log into GameJolt.', () -> { + if (bl) { + close(); + FlxG.resetState(); + } + }, bl ? 'Sweet!' : TU.translate('editor.ok'))); + }, true); + }, 125)); + + add(closeButton = new UIButton(loginButton.x - loginButton.bWidth - 20, loginButton.y, TU.translate("editor.cancel"), function() { + close(); + }, 125)); + closeButton.color = 0xFFFF0000; + } +} \ No newline at end of file diff --git a/source/funkin/menus/gamejolt/GameJoltMenu.hx b/source/funkin/menus/gamejolt/GameJoltMenu.hx new file mode 100644 index 0000000000..7c844a4a1d --- /dev/null +++ b/source/funkin/menus/gamejolt/GameJoltMenu.hx @@ -0,0 +1,597 @@ +package funkin.menus.gamejolt; + +import funkin.backend.utils.GJUtil.GJResponse; +import funkin.backend.utils.GJUtil.RequestType; +import openfl.display.BitmapData; +import lime.graphics.Image; +import flixel.graphics.FlxGraphic; +import flixel.addons.display.FlxBackdrop; +import flixel.math.FlxRect; +import flixel.tweens.FlxTween; +import flixel.util.FlxColor; +import funkin.backend.system.gamejolt.*; +import funkin.editors.ui.*; +import funkin.menus.gamejolt.*; + +class GameJoltMenu extends UIState +{ + public static var leaderboardLimit:Int = 50; + public static var achRowSpacing:Int = 10; + public static var achRowAmount:Int = 5; + public static var boxContentBorderSize:Int = 5; + public static var achievementSelectScale:Float = 1.2; + public static var achEase:Float->Float = FlxEase.elasticOut; + public static var achScaleTime:Float = 1.5; + public static var displayHiddenTrophies:Bool = false; + + // Main page variables + var bg:FlxBackdrop; + var userBorder:UISprite; + var userBackground:UISprite; + var mainTitleText:UIText; + var userPhoto:UISprite; + var subTitleText:UIText; + var trophyOrLoginButton:UIButton; + var leaderOrRegisterButton:UIButton; + var userDataButton:UIButton; + var ownerDataButton(default, null):UIButton; + var logoutButton:UIButton; + + // Secondary page variables + var secondaryTitleText:UIText; + var subBoxBacking:UISprite; + var mainBoxBacking:UISprite; + var mainBoxScroll:UIScrollBar; + var subBoxAch:FlxTypedSpriteGroup; + var mainBoxAch:FlxTypedSpriteGroup; + var missingTextAch:Null = null; + var subBoxLead:FlxTypedSpriteGroup; + var mainBoxLead:FlxTypedSpriteGroup; + var missingTextLead:Null = null; + var trophyTitle:UIText; + var trophyDesc:UIText; + var noScoresLead:UIText; + var rankLead:UIText; + + var onHome(default, set):Bool; + @:bypassAccessor var daInd(default, set):Int = 0; + var daMax:Int = 0; + var secondaryPage(default, set):String; + var isDaOwner(default, null):Bool = (GameJoltSecurity.userId == GameJoltData.ownerUserId && GameJoltSecurity.userId != null); + var scoresMap:Map = new Map(); + var trophiesMap:Map = new Map(); + var daSprites:FlxTypedSpriteGroup = new FlxTypedSpriteGroup(); + var noScaleTween:Bool = false; + var inTween:FlxTween = null; + var outTween:FlxTween = null; + + override function create() + { + super.create(); + + add(bg = new FlxBackdrop()); + bg.loadGraphic(Paths.image('editors/bgs/default')); + bg.antialiasing = true; + bg.rotation = -5; + bg.velocity.set(85, 0).degrees = bg.rotation; + + add(daSprites); + + userBorder = new UISprite(); + userBorder.makeGraphic(264, 264, FlxColor.BLACK); + userBorder.screenCenter(X); + daSprites.add(userBorder); + + userBackground = new UISprite(); + userBackground.makeGraphic(256, 256, FlxColor.GRAY); + userBackground.screenCenter(X); + daSprites.add(userBackground); + + mainTitleText = new UIText(0, 80, FlxG.width, (GJUtil.loggedIn ? "GAMEJOLT: " + GJUtil.userName : "NOT LOGGED IN"), 60); + mainTitleText.alignment = CENTER; + userBorder.y = mainTitleText.y + mainTitleText.height + 20; + userBackground.y = userBorder.y + 4; + daSprites.add(mainTitleText); + + userPhoto = new UISprite(userBackground.x, userBackground.y); + + if (GJUtil.loggedIn) { + //region Main Page + GJUtil.getAvatarImage(userPhoto, null, () -> { + userPhoto.setGraphicSize(256, 256); + userPhoto.updateHitbox(); + userPhoto.screenCenter(X); + userPhoto.visible = true; + }); + userPhoto.screenCenter(X); + daSprites.add(userPhoto); + userPhoto.visible = false; + + subTitleText = new UIText(20, userBorder.y + userBorder.height + 20, FlxG.width - 40, '"${GJUtil.userDescription}"', 16); + subTitleText.alignment = CENTER; + subTitleText.italic = true; + daSprites.add(subTitleText); + + trophyOrLoginButton = new UIButton((FlxG.width / 2) - (isDaOwner ? 470 : 390), FlxG.height - 128, "Achievements", () -> { + secondaryPage = 'achievements'; + onHome = false; + }, 180, 48); + // trophyOrLoginButton.color = 0xFF31FF31; + trophyOrLoginButton.field.size = 23; + daSprites.add(trophyOrLoginButton); + + leaderOrRegisterButton = new UIButton((FlxG.width / 2) - (isDaOwner ? 280 : 190), FlxG.height - 128, "Leaderboards", () -> { + secondaryPage = 'leaderboards'; + onHome = false; + }, 180, 48); + // leaderOrRegisterButton.color = 0xFF31FF31; + leaderOrRegisterButton.field.size = 23; + daSprites.add(leaderOrRegisterButton); + + userDataButton = new UIButton((FlxG.width / 2) - (isDaOwner ? 90 : -10), FlxG.height - 128, "User Data", () -> { + // UI Window - WIP + openSubState(new GameJoltDataWindow(USER)); + }, 180, 48); + // userDataButton.color = 0xFF31FF31; + userDataButton.field.size = 23; + daSprites.add(userDataButton); + + if(isDaOwner) { + ownerDataButton = new UIButton((FlxG.width / 2) + 100, FlxG.height - 128, "Global Data", () -> { + // UI Window - WIP + openSubState(new GameJoltDataWindow(GLOBAL)); + }, 180, 48); + // ownerDataButton.color = 0xFF31FF31; + ownerDataButton.field.size = 23; + daSprites.add(ownerDataButton); + } + + logoutButton = new UIButton((FlxG.width / 2) + (isDaOwner ? 290 : 210), FlxG.height - 128, "Logout", () -> { + openSubState(new GameJoltConfirmationWindow('Logout', 'Are you sure you want to log out of GameJolt? This will log you out of ALL mods on this build of CNE!', () -> { + closeSubState(); + GJUtil.logout(true); + FlxG.resetState(); + })); + }, 180, 48); + logoutButton.color = 0xFFFF0000; + logoutButton.field.size = 23; + daSprites.add(logoutButton); + //endregion + + //region Secondary Page + secondaryTitleText = new UIText(FlxG.width, 80, FlxG.width, "", 60); + secondaryTitleText.alignment = CENTER; + daSprites.add(secondaryTitleText); + + subBoxBacking = new UISprite(FlxG.width + 100, secondaryTitleText.height + 100); + subBoxBacking.makeGraphic(Std.int(((FlxG.width - 200) / 4) - 25), Std.int(FlxG.height - subBoxBacking.y - 100), FlxColor.GRAY); + daSprites.add(subBoxBacking); + + mainBoxBacking = new UISprite(subBoxBacking.width + subBoxBacking.x + 50, subBoxBacking.y); + mainBoxBacking.makeGraphic(Std.int((subBoxBacking.width * 3) + 25), Std.int(subBoxBacking.height), FlxColor.GRAY); + daSprites.add(mainBoxBacking); + + // for achievements, put the main box on the left and the sub box on the right + mainBoxAch = new FlxTypedSpriteGroup(subBoxBacking.x + boxContentBorderSize, subBoxBacking.y + boxContentBorderSize); + mainBoxAch.clipRect = new FlxRect(0, 0, mainBoxBacking.width - (boxContentBorderSize * 4), mainBoxBacking.height - (boxContentBorderSize * 4)); + daSprites.add(mainBoxAch); + + subBoxAch = new FlxTypedSpriteGroup(subBoxBacking.x + mainBoxBacking.width + boxContentBorderSize + 50, subBoxBacking.y + boxContentBorderSize); + subBoxAch.clipRect = new FlxRect(0, 0, subBoxBacking.width - (boxContentBorderSize * 3), subBoxBacking.height - (boxContentBorderSize * 4)); + daSprites.add(subBoxAch); + + subBoxLead = new FlxTypedSpriteGroup(subBoxBacking.x + boxContentBorderSize, subBoxBacking.y + boxContentBorderSize); + subBoxLead.clipRect = new FlxRect(0, 0, subBoxBacking.width - (boxContentBorderSize * 3), subBoxBacking.height - (boxContentBorderSize * 4)); + daSprites.add(subBoxLead); + + mainBoxLead = new FlxTypedSpriteGroup(mainBoxBacking.x + boxContentBorderSize, mainBoxBacking.y + boxContentBorderSize); + mainBoxLead.clipRect = new FlxRect(0, 0, mainBoxBacking.width - (boxContentBorderSize * 4), mainBoxBacking.height - (boxContentBorderSize * 4)); + daSprites.add(mainBoxLead); + + var reqsToMake:Array = []; + if (GameJoltData.definedTrophies.toString() == '[]' && GameJoltData.customTrophies.toString() == '[]') + daSprites.add(missingTextAch = new UIText(mainBoxAch.x, mainBoxAch.y, mainBoxAch.clipRect.width, "NO TROPHIES FOUND FOR THIS MOD!", 48)); + else + reqsToMake.push(TROPHIES_FETCH()); + + if (GameJoltData.leaderboards.toString() == '[]') + daSprites.add(missingTextLead = new UIText(mainBoxLead.x, mainBoxLead.y, mainBoxLead.clipRect.width, "NO LEADERBOARDS AVAILABLE FOR THIS MOD!", 48)); + else + reqsToMake.push(SCORES_TABLES); + + // do this in a batch to make sure we get everything + GameJoltSecurity.sendTrusted(BATCH(true, false, reqsToMake), true, function(err) { + daSprites.add(missingTextAch = new UIText(mainBoxAch.x, mainBoxAch.y, mainBoxAch.clipRect.width, "UNABLE TO FETCH TROPHIES: " + err, 48)); + daSprites.add(missingTextLead = new UIText(mainBoxLead.x, mainBoxLead.y, mainBoxLead.clipRect.width, "UNABLE TO FETCH LEADERBOARDS: " + err, 48)); + }, function(resp) { + // seeing if we have a response for trophies or scoreboards + var trophyResp:Null = null; + var scoresResp:Null = null; + if (resp.responses.length > 1) { + trophyResp = resp.responses[0]; + scoresResp = resp.responses[1]; + } else { + if (resp.responses[0].trophies == null || resp.responses[0].trophies.length <= 0) + trophyResp = resp.responses[0] + else if (resp.responses[0].tables == null || resp.responses[0].tables.length <= 0) + scoresResp = resp.responses[0]; + } + + // achievement trophy image loading + if (trophyResp == null) { + daSprites.add(missingTextAch = new UIText(mainBoxAch.x, mainBoxAch.y, mainBoxAch.clipRect.width, "NO TROPHIES FOUND FOR THIS MOD!", 48)); + } else { + var placeInd:Int = 0; + var photoSize:Int = Std.int((mainBoxAch.clipRect.width - (achRowSpacing * Math.max(0, achRowAmount - 1))) / achRowAmount); + for (trop in trophyResp.trophies) { + var canDisplay:Bool = false; + var hiddenTrop:Bool = false; + for (t in GameJoltData.definedTrophies) { + if ('${t.id}' == '${trop.id}') { + canDisplay = true; + if (t.hidden != null && t.hidden) + hiddenTrop = true; + } + } + + for (t in GameJoltData.customTrophies) { + if ('${t.id}' == '${trop.id}') { + canDisplay = true; + if (t.hidden != null && t.hidden) + hiddenTrop = true; + } + } + + if (!canDisplay || (hiddenTrop && !displayHiddenTrophies)) + continue; + + var tropSprite:UISprite = new UISprite(((achRowSpacing + photoSize) * (placeInd % achRowAmount)) + (achRowSpacing / 2), ((photoSize + achRowSpacing) * Math.floor(placeInd / achRowAmount)) + (achRowSpacing / 2)); + GJUtil.getTrophyImage(tropSprite, trop, () -> { + tropSprite.setGraphicSize(photoSize, photoSize); + tropSprite.updateHitbox(); + }); + tropSprite.ID = placeInd; + if (trop.achieved == "false") + tropSprite.color = FlxColor.GRAY; + trophiesMap.set(placeInd, trop); + mainBoxAch.add(tropSprite); + placeInd++; + } + + if (placeInd == 0) + daSprites.add(missingTextAch = new UIText(mainBoxAch.x, mainBoxAch.y, mainBoxAch.clipRect.width, "NO TROPHIES FOUND FOR THIS MOD!", 48)); + else { + subBoxAch.add(trophyTitle = new UIText(achRowSpacing / 2, achRowSpacing / 2, subBoxAch.clipRect.width - (boxContentBorderSize * 2), "", 38)); + trophyTitle.alignment = CENTER; + subBoxAch.add(trophyDesc = new UIText(achRowSpacing / 2, achRowSpacing / 2, subBoxAch.clipRect.width - (boxContentBorderSize * 2), "", 20)); + trophyDesc.alignment = CENTER; + } + } + + // leaderboard loading + if (scoresResp == null) + daSprites.add(missingTextLead = new UIText(mainBoxLead.x, mainBoxLead.y, mainBoxLead.clipRect.width, "NO LEADERBOARDS AVAILABLE FOR THIS MOD!", 48)); + else { + var amount:Int = 0; + for (board in scoresResp.tables) { + for (b in GameJoltData.leaderboards) { + if ('$b' == '${board.id}') { + scoresMap.set(board.name, board.id); + amount++; + } + } + } + + if (amount == 0) { + daSprites.add(missingTextLead = new UIText(mainBoxLead.x, mainBoxLead.y, mainBoxLead.clipRect.width, "NO LEADERBOARDS AVAILABLE FOR THIS MOD!", 48)); + } else { + var daY:Float = 0; + for (name in scoresMap.keys()) { + var daText:UIText = new UIText(0, daY, subBoxLead.clipRect.width, name, 24); + daText.textField.background = true; + daText.textField.backgroundColor = 0x0011AA00; + daText.wordWrap = false; + daText.autoSize = false; + subBoxLead.add(daText); + daY += daText.height; + } + + for (i in 0...leaderboardLimit) { + var anEntry:GameJoltLeaderboardItem = new GameJoltLeaderboardItem(); + mainBoxLead.add(anEntry); + } + + daSprites.add(noScoresLead = new UIText(mainBoxLead.x, mainBoxLead.y, mainBoxLead.clipRect.width, "NO SCORES AVAILABLE FOR THIS LEADERBOARD!", 48)); + noScoresLead.alignment = CENTER; + daSprites.add(rankLead = new UIText(mainBoxLead.x, mainBoxLead.y, mainBoxLead.clipRect.width, "YOUR RANK: --- (NOT SCORED YET!)", 32)); + rankLead.alignment = CENTER; + } + } + }); + + if (missingTextAch != null) + missingTextAch.alignment = CENTER; + + if (missingTextLead != null) + missingTextLead.alignment = CENTER; + //endregion + } else { + //region Login Page + userPhoto.loadGraphic(Paths.image('menus/gamejolt-icon')); + userPhoto.setGraphicSize(248, 248); + userPhoto.updateHitbox(); + userPhoto.screenCenter(X); + userPhoto.y += 4; + daSprites.add(userPhoto); + + subTitleText = new UIText(0, userPhoto.y + userPhoto.height + 30, FlxG.width, "Login with your GameJolt account to see leaderboards, get achievements, save your data, and more!", 35); + subTitleText.alignment = CENTER; + daSprites.add(subTitleText); + + trophyOrLoginButton = new UIButton((FlxG.width / 2) + 10, FlxG.height - 128, "Login", () -> { + openSubState(new GameJoltLoginWindow()); + }, 180, 48); + trophyOrLoginButton.color = 0xFF31FF31; + trophyOrLoginButton.field.size = 23; + daSprites.add(trophyOrLoginButton); + + leaderOrRegisterButton = new UIButton((FlxG.width / 2) - 190, FlxG.height - 128, "Register", () -> { + CoolUtil.openURL('https://gamejolt.com/join'); + }, 180, 48); + leaderOrRegisterButton.color = 0xFF31FF31; + leaderOrRegisterButton.field.size = 23; + daSprites.add(leaderOrRegisterButton); + //endregion + } + + @:bypassAccessor onHome = true; + secondaryPage = 'achievements'; + } + + override function update(elapsed:Float) + { + super.update(elapsed); + + var scroll = FlxG.mouse.wheel; + var upP = controls.UP_P; + var downP = controls.DOWN_P; + var leftP = controls.LEFT_P; + var rightP = controls.RIGHT_P; + + if (!onHome) switch (secondaryPage) { + case "leaderboards": + if (subBoxLead.members.length > 1 && (upP || downP || scroll != 0)) + daInd = FlxMath.wrap(daInd + (upP ? -1 : 0) + (downP ? 1 : 0) - scroll, 0, mainBoxLead.members.length - 1); + case "achievements": + if (mainBoxAch.members.length > 1 && (leftP || rightP || scroll != 0)) + daInd = FlxMath.wrap(daInd + (leftP ? -1 : 0) + (rightP ? 1 : 0) - scroll, 0, mainBoxAch.members.length - 1); + case _: + + } + if (controls.BACK) { + CoolUtil.playMenuSFX(CANCEL, 0.7); + if (onHome) + FlxG.switchState(new MainMenuState()); + else + onHome = true; + } + } + + function set_onHome(onH:Bool):Bool + { + FlxTween.num(0, FlxG.width, 1, { }, function(num:Float) { + var prcnt:Float = num / FlxG.width; + bg.velocity.set(85 * (1 + (Math.sin(Math.PI * prcnt) * (onH ? -1 : 1)))); + daSprites.x = FlxEase.elasticInOut(onH ? (1 - prcnt) : prcnt) * -FlxG.width; + }); + return onHome = onH; + } + + function set_daInd(newInd:Int):Int + { + if (secondaryTitleText != null) switch(secondaryTitleText.text) { + case "LEADERBOARDS": + if (mainBoxLead.members.length > 0) { + for (member in mainBoxLead.members) + member.clearData(); + + if (daInd != newInd && subBoxLead.members[daInd] != null) + subBoxLead.members[daInd].textField.backgroundColor = 0x0011AA00; + subBoxLead.members[newInd].textField.backgroundColor = 0xff11AA00; + var daId:Int = scoresMap.get(subBoxLead.members[newInd].text); + GameJoltSecurity.sendTrusted(BATCH(true, false, [SCORES_FETCH(false, daId, leaderboardLimit), SCORES_FETCH(true, daId)]), true, function(err) { + Logs.error("Leaderboard fetch error: " + err, RED, 'GameJolt'); + }, function(resp) { + if (resp.responses[0].scores == null || resp.responses[0].scores.length <= 0) { + noScoresLead.visible = true; + rankLead.visible = false; + } else { + rankLead.visible = true; + noScoresLead.visible = false; + for (i in 0...resp.responses[0].scores.length) { + var usr = resp.responses[0].scores[i]; + mainBoxLead.members[i].setData(Std.int(mainBoxLead.clipRect.width), Std.int(mainBoxLead.clipRect.height / 8.5), usr.user, usr.score, i); + // fun fact: wanted to try and include pfp's, but the calls would've been a bit much, at least imo. ~SPD + } + + if(resp.responses[1].scores[0] != null) + GameJoltSecurity.sendTrusted(SCORES_GETRANK(resp.responses[1].scores[0].sort, daId), true, function(err) { + Logs.error("Error fetching user rank: " + err, RED, 'GameJolt'); + rankLead.text = 'YOUR RANK: --- (??????)'; + }, function(respSec) { + rankLead.text = 'YOUR RANK: ${respSec.rank != null ? '${respSec.rank}' : '---'} (${resp.responses[1].scores[0].score})'; + }) + else + rankLead.text = 'YOUR RANK: --- (NOT SCORED YET!)'; + } + }); + } + + case "ACHIEVEMENTS": + if (mainBoxAch.members.length > 0) { + if (noScaleTween) { + mainBoxAch.members[daInd].scale.set(1, 1); + mainBoxAch.members[daInd].scale.set(achievementSelectScale, achievementSelectScale); + } else { + FlxTween.cancelTweensOf(mainBoxAch.members[daInd].scale); + FlxTween.cancelTweensOf(mainBoxAch.members[newInd].scale); + FlxTween.tween(mainBoxAch.members[daInd].scale, { x: 1, y: 1 }, achScaleTime, { ease: achEase }); + FlxTween.tween(mainBoxAch.members[newInd].scale, { x: achievementSelectScale, y: achievementSelectScale }, achScaleTime, { ease: achEase }); + } + + trophyTitle.text = trophiesMap.get(mainBoxAch.members[newInd].ID).title.toUpperCase(); + trophyDesc.y = trophyTitle.y + trophyTitle.height + achRowSpacing; + trophyDesc.text = trophiesMap.get(mainBoxAch.members[newInd].ID).description; + } + } + return daInd = newInd; + } + + function set_secondaryPage(type:String):String + { + if (secondaryTitleText != null) switch(type) { + case "leaderboards": + secondaryTitleText.text = type.toUpperCase(); + subBoxAch.active = subBoxAch.visible = mainBoxAch.active = mainBoxAch.visible = false; + if (missingTextAch != null) + missingTextAch.visible = false; + subBoxLead.active = subBoxLead.visible = mainBoxLead.active = mainBoxLead.visible = true; + if (missingTextLead != null) + missingTextLead.visible = true; + subBoxBacking.x = FlxG.width + 100; + mainBoxBacking.x = subBoxBacking.width + subBoxBacking.x + 50; + noScaleTween = true; + daInd = 0; + noScaleTween = false; + + case "achievements": + secondaryTitleText.text = type.toUpperCase(); + subBoxAch.active = subBoxAch.visible = mainBoxAch.active = mainBoxAch.visible = true; + if (missingTextAch != null) + missingTextAch.visible = true; + subBoxLead.active = subBoxLead.visible = mainBoxLead.active = mainBoxLead.visible = false; + if (missingTextLead != null) + missingTextLead.visible = false; + if (noScoresLead != null) + noScoresLead.visible = false; + if (rankLead != null) + rankLead.visible = false; + mainBoxBacking.x = FlxG.width + 100; + subBoxBacking.x = mainBoxBacking.width + mainBoxBacking.x + 50; + noScaleTween = true; + daInd = 0; + noScaleTween = false; + } + + return secondaryPage = type; + } +} + +//region Leaderboard Items +class GameJoltLeaderboardItem extends FlxTypedSpriteGroup +{ + public var selected(default, set):Bool; + + var name(default, set):String; + var score(default, set):String; + var rank(default, set):Int; + + var daWidth(default, set):Int; + var daHeight(default, set):Int; + + var nameText:UIText; + var scoreText:UIText; + var rankText:UIText; + var profilePic:UISprite; + var background:UISprite; + + override public function new() + { + super(); + + add(background = new UISprite()); + background.alpha = 0.6; + background.visible = false; + + add(nameText = new UIText(0, 0, width, "")); + add(scoreText = new UIText(0, 0, width, "")); + add(rankText = new UIText(0, 0, width, "")); + add(profilePic = new UISprite()); + + @:bypassAccessor selected = false; + } + + public function setData(width:Int = 200, height:Int = 200, name:String, score:String, rank:Int, ?image:FlxGraphic) + { + this.name = name; + this.score = score; + this.rank = rank; + + if (image != null) + profilePic.loadGraphic(image); + + this.height = height; + this.width = width; + + active = visible = true; + } + + public function clearData() + { + name = ""; + score = ""; + rank = 0; + height = 0; + width = 0; + active = visible = false; + } + + inline function set_selected(sel:Bool):Bool + { + background.visible = sel; + return selected = sel; + } + + inline function set_name(n:String):String + { + nameText.text = n; + return name = n; + } + + inline function set_score(s:String):String + { + scoreText.text = s; + return score = s; + } + + inline function set_rank(r:Int):Int + { + rankText.text = '$r'; + return rank = r; + } + + inline function set_daWidth(w:Int):Int + { + if (w != 0) { + background.makeGraphic(w, daHeight, FlxColor.YELLOW); + profilePic.x = Std.int(w * 0.01); + rankText.x = Std.int(profilePic.x + profilePic.width + (w * 0.02)); + nameText.x = Std.int(w / 2); + scoreText.fieldWidth = Std.int(w * 0.99); + } + return daWidth = w; + } + + inline function set_daHeight(h:Int):Int + { + if (h != 0) { + if (profilePic.graphic != null) { + background.makeGraphic(daWidth, h, FlxColor.YELLOW); + profilePic.setGraphicSize(Std.int(h * 0.9), Std.int(h * 0.9)); + profilePic.updateHitbox(); + } + } + return daHeight = h; + } +} +//endregion \ No newline at end of file diff --git a/source/funkin/savedata/FunkinSave.hx b/source/funkin/savedata/FunkinSave.hx index b72184ed74..7c58fbea9d 100644 --- a/source/funkin/savedata/FunkinSave.hx +++ b/source/funkin/savedata/FunkinSave.hx @@ -1,7 +1,10 @@ package funkin.savedata; +import funkin.backend.chart.ChartData.ChartMetaData; +import funkin.menus.FreeplayState.FreeplaySonglist; +import funkin.backend.system.gamejolt.GameJoltData.GJTrophyData; import flixel.util.FlxSave; -import lime.app.Application; +import funkin.backend.system.gamejolt.*; import openfl.Lib; import haxe.Serializer; import haxe.Unserializer; @@ -144,6 +147,137 @@ class FunkinSave { var oldHigh = safeGetHighscore(entry); if (force || oldHigh.date == null || oldHigh.score < highscore.score) { highscores.set(entry, highscore); + + // GameJolt is only logged in if GAMEJOLT_API is on, otherwise it's always false. + if (GJUtil.loggedIn) { + switch(entry) { + case HSongEntry(songName, difficulty, variation, changes): + if (changes.length == 0) { + if (GameJoltData.leaderboards.exists('song-$songName--D:$difficulty (V:${variation != null ? variation : 'Default'})')) + GameJoltSecurity.sendTrusted(SCORES_ADD('${highscore.score}', highscore.score, 'accuracy=${highscore.accuracy};date=${highscore.date}', GameJoltData.leaderboards.get('song-$songName--D:$difficulty (V:${variation != null ? variation : 'Default'})')), true, function(err) { + Logs.error('Could not post score to leaderboard: $err', RED, 'GameJolt'); + }, function(resp) { + Logs.trace('Successfully posted score to leaderboard associated with song $songName.', SUCCESS, LIGHTGRAY, 'GameJolt'); + }) + else if (GameJoltData.leaderboards.exists('song-$songName (V:${variation != null ? variation : 'Default'})')) + GameJoltSecurity.sendTrusted(SCORES_ADD('${highscore.score}', highscore.score, 'accuracy=${highscore.accuracy};date=${highscore.date}', GameJoltData.leaderboards.get('song-$songName (V:${variation != null ? variation : 'Default'})')), true, function(err) { + Logs.error('Could not post score to leaderboard: $err', RED, 'GameJolt'); + }, function(resp) { + Logs.trace('Successfully posted score to leaderboard associated with song $songName.', SUCCESS, LIGHTGRAY, 'GameJolt'); + }); + + // song trophy and exception check + if (GameJoltData.definedTrophies.exists('song-$songName')) { + var tropData:GJTrophyData = GameJoltData.definedTrophies.get('song-$songName'); + var hasException:Bool = false; + if (tropData.except != null) { + for (itms in tropData.except) { + var indic:String = itms.substr(0, 3); + var dat:Array = itms.substr(3).split(','); + for (d in dat) d.trim(); + switch (indic) { + case "=D:": //difficulty exception + if (dat.contains(difficulty)) hasException = true; + + case "=V:": //variation exception + if (dat.contains(variation)) hasException = true; + + case _: + // nothing lol + } + } + } + if (!hasException) GameJoltSecurity.unlockDefinedTrophy('song-$songName'); + } + + // first fc check + if (highscore.misses == 0 && GameJoltData.definedTrophies.exists('fc-first')) { + if (!GameJoltData.definedTrophies.get('fc-first').except.contains(songName)) + GameJoltSecurity.unlockDefinedTrophy('fc-first'); + } + + // complete all & fc all achievement check + if (GameJoltData.definedTrophies.exists('complete-all') || GameJoltData.definedTrophies.exists('fc-all')) { + var songList:Array = FreeplaySonglist.get().songs; + var completedSongs:Array = []; + var fcData:Array = []; + for (s in highscores.keys()) { + if (highscores.get(s).score == 0) + continue + else switch (s) { + case HSongEntry(songName, difficulty, variation, changes): + completedSongs.push(songName); + fcData.push(highscores.get(s)); + default: + // oop + } + } + + if (GameJoltData.definedTrophies.exists('complete-all')) + { + var daTrophy:GJTrophyData = GameJoltData.definedTrophies.get('complete-all'); + var songAmount:Int = completedSongs.length; + for (sng in completedSongs) { + if (daTrophy.except.contains(sng)) + songAmount--; + } + if (songAmount == (completedSongs.length - daTrophy.except.length)) + GameJoltSecurity.unlockDefinedTrophy('complete-all'); + } + + if (GameJoltData.definedTrophies.exists('fc-all')) + { + var daTrophy:GJTrophyData = GameJoltData.definedTrophies.get('fc-all'); + var songAmount:Int = 0; + for (sng in completedSongs) { + if (daTrophy.except.contains(sng)) + continue; + + if (fcData[completedSongs.indexOf(sng)].misses == 0) + songAmount++; + } + if (songAmount == (completedSongs.length - daTrophy.except.length)) + GameJoltSecurity.unlockDefinedTrophy('fc-all'); + } + } + + } + case HWeekEntry(weekName, difficulty): + if (GameJoltData.leaderboards.exists('week-$weekName--D:$difficulty')) + GameJoltSecurity.sendTrusted(SCORES_ADD('${highscore.score}', highscore.score, 'accuracy=${highscore.accuracy};date=${highscore.date}', GameJoltData.leaderboards.get('week-$weekName--D:$difficulty')), true, function(err) { + Logs.error('Could not post score to leaderboard: $err', RED, 'GameJolt'); + }, function(resp) { + Logs.trace('Successfully posted score to leaderboard associated with week $weekName.', SUCCESS, LIGHTGRAY, 'GameJolt'); + }) + else if (GameJoltData.leaderboards.exists('week-$weekName')) + GameJoltSecurity.sendTrusted(SCORES_ADD('${highscore.score}', highscore.score, 'accuracy=${highscore.accuracy};date=${highscore.date}', GameJoltData.leaderboards.get('week-$weekName')), true, function(err) { + Logs.error('Could not post score to leaderboard: $err', RED, 'GameJolt'); + }, function(resp) { + Logs.trace('Successfully posted score to leaderboard associated with week $weekName.', SUCCESS, LIGHTGRAY, 'GameJolt'); + }); + + // week trophy and exception check + if (GameJoltData.definedTrophies.exists('week-$weekName')) { + var tropData:GJTrophyData = GameJoltData.definedTrophies.get('week-$weekName'); + var hasException:Bool = false; + if (tropData.except != null) { + for (itms in tropData.except) { + var indic:String = itms.substr(0, 3); + var dat:Array = itms.substr(3).split(','); + for (d in dat) d.trim(); + switch (indic) { + case "=D:": //difficulty exception + if (dat.contains(difficulty)) hasException = true; + + case _: + // nothing lol + } + } + } + if (!hasException) GameJoltSecurity.unlockDefinedTrophy('week-$weekName'); + } + } + } return true; } return false; diff --git a/source/hscript/Config.hx b/source/hscript/Config.hx index 3acd554b16..b1e53488a0 100644 --- a/source/hscript/Config.hx +++ b/source/hscript/Config.hx @@ -29,7 +29,7 @@ class Config { // Incase any of your files fail // These are the module names public static final DISALLOW_CUSTOM_CLASSES = [ - + ]; public static final DISALLOW_ABSTRACT_AND_ENUM = [ @@ -38,6 +38,7 @@ class Config { @:unreflective public static final IMPORT_BLACKLIST:Array = [ - + "funkin.backend.system.gamejolt.GameJoltSecurity", // don't want people getting those gamejolt keys! + "funkin.backend.system.gamejolt.GameJoltData", // Global data items and really bad things to modify ]; }