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/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..45a14243cd 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..5d920352b3 100644 --- a/source/funkin/backend/system/MainState.hx +++ b/source/funkin/backend/system/MainState.hx @@ -10,8 +10,10 @@ import funkin.backend.assets.ModsFolderLibrary; import funkin.backend.assets.ZipFolderLibrary; import funkin.backend.chart.EventsData; import funkin.backend.system.framerate.Framerate; +import funkin.backend.utils.GJUtil; import funkin.editors.ModConfigWarning; import funkin.menus.TitleState; +import funkin.menus.gamejolt.GameJoltCompleteScreen; import haxe.io.Path; @@ -162,7 +164,11 @@ class MainState extends FlxState { if (cast(lib, ZipFolderLibrary).PRELOAD_VIDEOS) cast(lib, ZipFolderLibrary).precacheVideos(); } - var startState:Class = Flags.DISABLE_WARNING_SCREEN ? TitleState : funkin.menus.WarningState; + #if GAMEJOLT_API + GJUtil.init(); + #end + + var startState:Class = /**GameJoltData.freshStart ?**/ GameJoltCompleteScreen /**: (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. if (Options.devMode && Options.allowConfigWarning && !isZipMod) { diff --git a/source/funkin/backend/system/gamejolt/GameJoltData.hx b/source/funkin/backend/system/gamejolt/GameJoltData.hx new file mode 100644 index 0000000000..7c87d8a3db --- /dev/null +++ b/source/funkin/backend/system/gamejolt/GameJoltData.hx @@ -0,0 +1,386 @@ +package funkin.backend.system.gamejolt; + +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 sys.FileSystem; +import flixel.input.keyboard.FlxKey; +import funkin.backend.utils.GJUtil; +import funkin.backend.utils.GJUtil.ScoreTable; +import funkin.backend.system.Controls.Control; +import funkin.backend.system.gamejolt.GameJoltSecurity; +import funkin.backend.assets.AssetSource; + + +typedef CNEGameJoltData = { + ownerU:String, + ownerI:Int, + defTrophies:Map, + cusTrophies:Map, + leaderboards:Map, + addlData:Map>, +} + +typedef GameJoltUserData = { + controls:Map>, + options:Map, + scores:Dynamic, + misc:Map +} + +typedef GJTrophyData = { + id:Int, + ?require:Int, + ?except:Array, + ?hidden:Bool, + ?weekName:String, +} + +class GameJoltData +{ + public static var freshStart(default, null):Bool = false; + + public static var ownerUsername:Null = null; + + public static var ownerUserId:Null = null; + + public static var leaderboards:Map = []; + + public static var definedTrophies:Map = []; + + public static var definitions:Array = ['open-first', 'friday-night', 'week', 'complete-all', 'fc-first', 'fc-all']; + + /** + * 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 = []; + + public static var earnedTrophies:Map = []; + + public static var dataToInclude:Map> = []; + + static var xmlPath:String = Paths.xml('config/gamejolt'); + + static var helpText:String = ''; + + #if GAMEJOLT_API + public static function loadAdminData() + { + Logs.trace(Paths.assetsTree.getSpecificAsset(xmlPath, "TEXT", AssetSource.MODS)); + // get xml + var access = getGJX(); + + if (access == null) + return; + + // return if owner and token nodes are absent + if (!access.hasNode.owner || !access.hasNode.token) { + Logs.trace('Missing owner or token 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 token + encryptToken(access.node.token.innerData); + + // + if (!GJUtil.attemptLogin(access.node.owner.att.username, access.node.owner.att.token, true, true)) { + 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); + return; + } + + ownerUsername = access.node.owner.att.username; + GameJoltSecurity.sendTrusted(USER_FETCH(ownerUsername), false, function(err) { + Logs.traceColored([ + Logs.getPrefix("GameJolt"), + Logs.logText("Unable to obtain mandatory user ID data from user "), + Logs.logText(ownerUsername, GREEN), + Logs.logText(' for data upload: ${err}') + ], ERROR); + ownerUsername = null; + GJUtil.logout(false, true); + }, function(resp) { + ownerUserId = resp.users[0].id; + }); + + if (ownerUserId == null) + return; + + setGlobalData(true, () -> { + GJUtil.logout(false, true); + var overwriteXml:Xml = access.x; + for (own in access.x.elements()) { + if (own.nodeName == "owner" || own.nodeName == "token") + overwriteXml.removeChild(own); + } + + try { + // FileSystem.deleteFile(Paths.assetsTree.getSpecificPath(xmlPath, AssetSource.MODS)); + File.saveContent(Paths.assetsTree.getSpecificPath(xmlPath, AssetSource.MODS), helpText + '\n' + overwriteXml.toString()); + } catch(e) { + Logs.trace('Error creating new XML file: ${e}', ERROR, LIGHTGRAY, "GameJolt"); + } + freshStart = true; + }, access); + } + + public static function loadGlobalData():Bool + { + var ret:Bool = false; + GameJoltSecurity.sendTrusted(DATA_FETCH('CNE_DATASTORE', false), true, function(err) { + Logs.trace('Unable to obtain global mod data. GameJolt API turning off automatically.', ERROR, LIGHTGRAY, 'GameJolt'); + GJUtil.logout(false, true); + }, function(resp) { + var daDat:CNEGameJoltData = cast Json.parse(resp.data); + ownerUsername = daDat.ownerU; + ownerUserId = daDat.ownerI; + definedTrophies = daDat.defTrophies; + customTrophies = daDat.cusTrophies; + leaderboards = daDat.leaderboards; + dataToInclude = daDat.addlData; + ret = true; + }); + return ret; + } + + //region Set CNE Glob'ls + public static function setGlobalData(cleanSet:Bool = false, ?callback:Void->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; + } + } + + // 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. + + 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 != '') ? Std.parseInt(trophyDef.att.require) : null, + except: (trophyDef.has.except && trophyDef.att.except != '') ? trophyDef.att.except.split('//') : 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; + + definedTrophies.set(trophyDef.att.def + (trophyDef.att.def == 'week' ? '-${trophyDef.att.weekName}': ''), { + id: Std.parseInt(trophyDef.att.id), + require: (trophyDef.has.require && trophyDef.att.require != '') ? Std.parseInt(trophyDef.att.require) : null, + except: (trophyDef.has.except && trophyDef.att.except != '') ? trophyDef.att.except.split('//') : 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, + }); + } + + 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.def), + require: (trophyDef.has.require && trophyDef.att.require != '') ? Std.parseInt(trophyDef.att.require) : null, + except: (trophyDef.has.except && trophyDef.att.except != '') ? trophyDef.att.except.split('//') : null, + hidden: (trophyDef.has.hidden && trophyDef.att.hidden != '') ? (trophyDef.att.hidden == 'true') : null, + weekName: null, + }); + } + + for (leaderboard in data.node.leaderboards.nodes.song) { + if (!leaderboard.has.id || !leaderboard.has.name || leaderboard.att.name == '' || leaderboard.att.id == '') + continue; + + leaderboards.set(leaderboard.att.name + (leaderboard.has.diff ? '-${leaderboard.att.diff}' : '') + ' (${leaderboard.has.vari ? leaderboard.att.vari : 'Default'})', Std.parseInt(leaderboard.att.id)); + } + + 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); + } + + var sendOut:CNEGameJoltData = { + ownerU: ownerUsername, + ownerI: ownerUserId, + defTrophies: definedTrophies, + cusTrophies: customTrophies, + leaderboards: leaderboards, + addlData: dataToInclude, + }; + + Logs.trace('Sending global data...', INFO, LIGHTGRAY, "GameJolt"); + + var success:Bool = true; + GameJoltSecurity.sendTrusted(DATA_SET('CNE_DATASTORE', Json.stringify(sendOut), false), false, function(err) { + Logs.trace('Unable to upload global GameJolt data: ${err}', ERROR, LIGHTGRAY, 'GameJolt'); + success = false; + if (cleanSet) { + reset(); + GJUtil.logout(false, true); + } + }); + + if (!success) + return; + + Logs.trace('Global data set successfully!', SUCCESS, LIGHTGRAY, "GameJolt"); + + if (callback != null) callback(); + } + //endregion + + public static function setUserData() + { + + } + + public static function loadUserData() + { + + } + + 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; + } + + 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(); + } + + public static function reset(fullWipe:Bool = false) + { + earnedTrophies = []; + if (fullWipe) { + ownerUsername = null; + ownerUserId = null; + leaderboards = []; + definedTrophies = []; + customTrophies = []; + dataToInclude = []; + freshStart = false; + } + } + #else + public static function loadAdminData() + { + Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + return; + } + + public static function loadGlobalData() + { + Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + return; + } + + public static function setGlobalData(cleanSet:Bool = false, ?callback:Void->Void, ?data:Access) + { + Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + return; + } + + public static function setUserData() + { + Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + return; + } + + public static function loadUserData() + { + Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + return; + } + + public static function reset(fullWipe:Bool = false) + { + Logs.trace('GameJolt API not set in Project.xml!', ERROR, LIGHTGRAY, 'GameJolt'); + return; + } + #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..217e05bccd --- /dev/null +++ b/source/funkin/backend/system/gamejolt/GameJoltSecurity.hx @@ -0,0 +1,393 @@ +package funkin.backend.system.gamejolt; + +import hscript.IHScriptCustomAccessBehaviour; +import funkin.backend.utils.GJUtil; +import funkin.backend.utils.GJUtil.RequestType; +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.*; + +/** + * # 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 = ''; + + /** + * Map of all trophies defined using specific names. + * Specifically stored here so that it's harder to cheese song, + * FC or other specific trophies. + */ + public static var definedTrophies(default, null):Map = []; + + /** + * + */ + public static var definedLeaderboards(default, null):Map = []; + /** + * 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 response created by the API client. Usually for just storage purposes. + */ + private static var lastResponse:Response = {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; + + // 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 + + static function get_url():String + { + return sign('https://api.gamejolt.com/api/game/v1_2${parseType(curCall)}'); + } + + /** + * 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 + * @param async + * @param onError + * @param onComplete + * @param onProgress + */ + public static function sendTrusted(call:RequestType, async:Bool = false, ?onError:String->Void, ?onComplete:Response->Void, ?onProgress:Array->Void) + { + @:privateAccess { + if (GJUtil.executing || !GJUtil.active) + return; + GJUtil.executing = true; + } + + var resp:Response = handleRequest(async, call, onProgress); + @:privateAccess + GJUtil.executing = false; + if (resp.message != null && onError != null) + onError(resp.message); + else if (resp.message == null && onComplete != null) { + @:privateAccess + onComplete(GJUtil.formatImages(resp)); + } + } + + static function handleRequest(async:Bool = false, data:RequestType, ?onProgress:Array->Void):Response + { + if (encryptedGameToken == null || gameId == null) { + lastResponse = {success: false, message: 'Missing game token and/or game ID.'}; + curCall = null; + return lastResponse; + } + + curCall = data; + + if (async) { + 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); + } + + }); + 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}'}; + }); + loader.addEventListener(SecurityErrorEvent.SECURITY_ERROR, (securityError) -> { + lastResponse = {success: false, message: 'Security Error: ${securityError.text}'}; + }); + loader.load(new openfl.net.URLRequest(url)); + return {success: false, message: "No response yet."}; + } else { + var loader:Http = new Http(url); + loader.onData = function(data) { + lastResponse = Json.parse(data).response; + if (lastResponse.message != null) + Logs.traceColored([ + Logs.getPrefix("GameJolt"), + Logs.logText('Response Error: ${lastResponse.message}') + ], ERROR); + }; + loader.onError = function(error) { + lastResponse = {success: false, message: 'Request Error: ${error}'}; + }; + loader.request(false); + } + curCall = null; + return lastResponse; + } + + 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; + } + + /** + * Setter function for encrypted game token. Also sets revealed game token. + * @param tok + */ + 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; + } + + /** + * 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)}'; + } +} \ 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..e05569ed9a --- /dev/null +++ b/source/funkin/backend/utils/GJUtil.hx @@ -0,0 +1,413 @@ +package funkin.backend.utils; + +import funkin.backend.system.gamejolt.GameJoltData; +import funkin.backend.system.gamejolt.GameJoltSecurity; + +/** + * This is how GameJolt API responses are formatted like. + */ +typedef Response = { + // 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. + * Will not do anything if there is no provided GameJolt token. + * + * # IMPORTANT + * If you wish to use this utility, please run your GameJolt game's security code through the Codename Engine + * encryption tool on Codename's website. + * Place the output of that into your modpack.ini under the flag `MOD_GAMEJOLT_TOKEN`. + * + * ## 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; + + /** + * 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:Null->Void> = null; + + /** + * Whether or not the utility is executing a call. + */ + static var executing:Bool = false; + + #if GAMEJOLT_API + 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. + * @return Bool Whether the attempt was successfull or not. + */ + public static function attemptLogin(name:String, token:String, checkCreds:Bool = false, tempLogin:Bool = false):Bool + { + if(Flags.MOD_GAMEJOLT_GAME_ID != '' && Flags.MOD_GAMEJOLT_ENCRYPTED_TOKEN != '') + active = true + else + return false; + + var ret:Bool = false; + userName = name; + GameJoltSecurity.user_token = token; + var batchCalls:Array = [RequestType.SESSION_OPEN]; + if (checkCreds) batchCalls.unshift(RequestType.USER_AUTH); + send(RequestType.BATCH(true, false, batchCalls), false, function(err) { + userName = null; + GameJoltSecurity.user_token = null; + }, function(resp) { + ret = true; + if (!tempLogin) { + Logs.traceColored([ + Logs.getPrefix("GameJolt"), + Logs.logText("Successfully logged in user "), + Logs.logText(userName, GREEN), + Logs.logText('!') + ], SUCCESS); + openfl.Lib.application.onExit.add(onExitApp); + FlxG.signals.postUpdate.add(pingTimer); + if (checkCreds) { + FlxG.save.data.gameJoltArray = [userName, token]; + FlxG.save.flush(); + } + } + }); + return ret; + } + + public static function logout(wipeSave:Bool = false, tempLogin:Bool = false) + { + if (!active) + return; + if (!tempLogin) + shutdownFunctions(); + send(RequestType.SESSION_CLOSE, true, 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; + GameJoltSecurity.user_token = null; + if (wipeSave) { + FlxG.save.data.gameJoltArray = null; + FlxG.save.flush(); + } + }); + } + + public static function makeCall(call:RequestType, async:Bool = false, ?onError:String->Void, ?onComplete:Response->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 SCORES_ADD(score, sort, extra_data, table_id): + return; + + case TROPHIES_ADD(trophy_id): + return; + + case _: + send(call, async, onError, onComplete, onProgress); + } + } + + static var pingTime:Int = 0; + static function pingTimer() + { + pingTime += 1; + if (pingTime < 10000) return; + pingTime -= 10000; + pingSession(); + } + + static function pingSession() + { + send(RequestType.SESSION_PING(true), true, (str) -> { + if (onLostSession != null) onLostSession(); + shutdownFunctions(); + Logs.trace("Session lost; GJUtil shut down successfully.", WARNING, LIGHTGRAY, 'GameJolt'); + active = false; + }); + } + + static function onExitApp(i:Int) + { + logout(); + } + + static function shutdownFunctions() + { + Logs.traceColored([ + Logs.getPrefix("GameJolt"), + Logs.logText("Logging out user "), + Logs.logText(userName, GREEN), + Logs.logText('...') + ], INFO); + FlxG.signals.postUpdate.remove(pingTimer); + openfl.Lib.application.onExit.remove(onExitApp); + onLostSession = null; + } + + @:noPrivateAccess static function send(call:RequestType, async:Bool = false, ?onError:String->Void, ?onComplete:Response->Void, ?onProgress:Array->Void) + { + if (executing || !active) + return; + executing = true; + + @:privateAccess + var resp:Response = GameJoltSecurity.handleRequest(async, call, onProgress); + executing = false; + if (resp.message != null && onError != null) + onError(resp.message); + else if (resp.message == null && onComplete != null) + onComplete(formatImages(resp)); + } + + static function formatImages(res:Response):Response { + 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) 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/"; + newUrl += switch (t.image_url.substring(24).replace(".jpg", "").replace(".webp", "")) { + case "trophy-bronze-1": "9c2c91d0"; + case "trophy-silver-1": "b46e352e"; + case "trophy-gold-1": "363ce2dc"; + case "trophy-platinum-1": "92e5330d"; + default: ""; + }; + newUrl += ".png"; + } + 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 != ''); + return userName = name; + } + #else + + #end +} \ No newline at end of file diff --git a/source/funkin/menus/MainMenuState.hx b/source/funkin/menus/MainMenuState.hx index 43f33ebb53..ae44f4b5ce 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,6 +63,8 @@ class MainMenuState extends MusicBeatState for (i=>option in optionShit) { + if (option == 'gamejolt') + continue; var menuItem:FlxSprite = new FlxSprite(0, 60 + (i * 160)); menuItem.frames = Paths.getFrames('menus/mainmenu/${option}'); menuItem.animation.addByPrefix('idle', option + " basic", 24); @@ -74,6 +77,17 @@ class MainMenuState extends MusicBeatState menuItem.antialiasing = true; } + 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 = menuItems.length; + menuItems.add(menuItem); + menuItem.scrollFactor.set(); + menuItem.antialiasing = true; + FlxG.camera.follow(camFollow, null, 0.06); versionText = new FunkinText(5, FlxG.height - 2, 0, [ @@ -190,6 +204,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..554ec816ac --- /dev/null +++ b/source/funkin/menus/gamejolt/GameJoltCompleteScreen.hx @@ -0,0 +1,57 @@ +package funkin.menus.gamejolt; + +import funkin.editors.ui.UIState; +import funkin.editors.ui.UIButton; +import funkin.editors.ui.UIText; +import openfl.desktop.Clipboard; +import flixel.addons.display.FlxBackdrop; +import funkin.backend.system.gamejolt.GameJoltData; +import funkin.menus.TitleState; +import funkin.backend.system.Controls; + +class GameJoltCompleteScreen extends UIState +{ + var mainText:UIText; + var copyText:UIText; + var copyButton:UIButton; + var continueText:UIText; + + override public function create() + { + super.create(); + + add(mainText = new UIText(0, 50, 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 + 60, 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 + 10, "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); + FlxG.switchState(new TitleState()); + } + } +} \ No newline at end of file diff --git a/source/funkin/menus/gamejolt/GameJoltLoginSuccess.hx b/source/funkin/menus/gamejolt/GameJoltLoginSuccess.hx new file mode 100644 index 0000000000..5e27a0c4c6 --- /dev/null +++ b/source/funkin/menus/gamejolt/GameJoltLoginSuccess.hx @@ -0,0 +1,23 @@ +package funkin.menus.gamejolt; + +import funkin.editors.ui.UIButton; +import funkin.editors.ui.UISubstateWindow; + +class GameJoltLoginSuccess extends UISubstateWindow +{ + var confirmButton:UIButton; + + public override function create() + { + winTitle = "Login Success!"; + + messageSpr.text = 'You have successfully logged in as ${GJUtil.userName}!'; + + super.create(); + + add(confirmButton = new UIButton(windowSpr.x + (windowSpr.bWidth / 2) - 62, windowSpr.y + windowSpr.bHeight - 48, "Sweet!", function() { + close(); + }, 125)); + confirmButton.color = 0xFF00FF00; + } +} \ 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..84a127cd7d --- /dev/null +++ b/source/funkin/menus/gamejolt/GameJoltLoginWindow.hx @@ -0,0 +1,52 @@ +package funkin.menus.gamejolt; + +import funkin.editors.ui.UISubstateWindow; +import funkin.editors.ui.UIButton; +import funkin.editors.ui.UITextBox; +import funkin.editors.ui.UIText; +import funkin.menus.gamejolt.GameJoltTokenInfoWindow; + +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 = 350; + winHeight = 350; + + 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, "?", () -> { FlxG.state.openSubState(new GameJoltTokenInfoWindow()); }, 24, 24)); + userTokenInfo.color = 0xFF3737FF; + userTokenBox.label.textField.displayAsPassword = true; + + add(loginButton = new UIButton(windowSpr.x + windowSpr.bWidth - 20 - 125, windowSpr.y + windowSpr.bHeight - 16 - 32, "Login", function() { + if (GJUtil.attemptLogin(usernameBox.label.text, userTokenBox.label.text, true)) { + FlxG.state.openSubState(new GameJoltLoginSuccess()); + close(); + } else { + + } + }, 125)); + + add(closeButton = new UIButton(loginButton.x - 20 - loginButton.bWidth, loginButton.y, TU.translate("editor.cancel"), 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..0969b2dc08 --- /dev/null +++ b/source/funkin/menus/gamejolt/GameJoltMenu.hx @@ -0,0 +1,47 @@ +package funkin.menus.gamejolt; + +import funkin.editors.ui.UIState; +import funkin.editors.ui.UIButton; +import funkin.editors.ui.UIText; +import funkin.menus.gamejolt.GameJoltLoginWindow; + +class GameJoltMenu extends UIState +{ + var mainGroup:FlxTypedSpriteGroup = new FlxTypedSpriteGroup(); + + override function create() + { + super.create(); + + add(new FlxSprite().loadAnimatedGraphic(Paths.image('menus/menuDesat'))); + + createPages(); + } + + function createPages() + { + var lgIn:Bool = GJUtil.loggedIn; + + mainGroup.add(new UIButton((FlxG.width / 2) - 60, FlxG.height - 200, "Login", () -> { + openSubState(new GameJoltLoginWindow()); + })); + + add(mainGroup); + + var daGroup:FlxTypedSpriteGroup = new FlxTypedSpriteGroup(); + + daGroup.add(new UIText(0, 0, FlxG.width, "NOT LOGGED IN")); + + add(daGroup); + } + + override function update(elapsed:Float) + { + super.update(elapsed); + + if (controls.BACK) { + CoolUtil.playMenuSFX(CANCEL, 0.7); + FlxG.switchState(new MainMenuState()); + } + } +} \ No newline at end of file diff --git a/source/funkin/menus/gamejolt/GameJoltTokenInfoWindow.hx b/source/funkin/menus/gamejolt/GameJoltTokenInfoWindow.hx new file mode 100644 index 0000000000..4751dc45d6 --- /dev/null +++ b/source/funkin/menus/gamejolt/GameJoltTokenInfoWindow.hx @@ -0,0 +1,32 @@ +package funkin.menus.gamejolt; + +import funkin.editors.ui.UISprite; +import funkin.editors.ui.UIButton; +import funkin.editors.ui.UIText; +import funkin.editors.ui.UISubstateWindow; + +class GameJoltTokenInfoWindow extends UISubstateWindow { + public var text:UIText; + public var closeButton:UIButton; + + public var daX:Float; + + public override function create() { + //TODO: get translations for text + winTitle = "GameJolt Token Info"; + + winWidth = 756; + winHeight = 220; + + super.create(); + + daX = windowSpr.x + 20; + + add(text = new UIText(daX, windowSpr.y + 46, windowSpr.width - ((daX - windowSpr.x) * 2), "!! THIS IS NOT YOUR GAMEJOLT ACCOUNT PASSWORD !!\n\nTo access your game token, click on your profile icon, then click on \"Game Token\".")); + + //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.saveClose"), function() { + close(); + }, 125)); + } +} \ No newline at end of file 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 ]; }