diff --git a/source/funkin/backend/assets/AssetsLibraryList.hx b/source/funkin/backend/assets/AssetsLibraryList.hx index 4b1b4970..f12c35db 100644 --- a/source/funkin/backend/assets/AssetsLibraryList.hx +++ b/source/funkin/backend/assets/AssetsLibraryList.hx @@ -83,7 +83,8 @@ class AssetsLibraryList extends AssetLibrary { if (l is IModsAssetLibrary) { var lib = cast(l, IModsAssetLibrary); for(e in lib.getFolders(folder)) - content.push(e); + if(!content.contains(e)) + content.push(e); } #end } diff --git a/source/funkin/backend/assets/ModsFolder.hx b/source/funkin/backend/assets/ModsFolder.hx index 1cf813e1..bf7365c6 100644 --- a/source/funkin/backend/assets/ModsFolder.hx +++ b/source/funkin/backend/assets/ModsFolder.hx @@ -62,7 +62,7 @@ class ModsFolder { Options.lastLoadedMod = currentModFolder = mod; reloadMods(); Logs.traceColored([ - Logs.logText('Switched to mod ', GRAY), + Logs.logText('Switched to mod: '), Logs.logText(mod, GREEN) ], VERBOSE); } diff --git a/source/funkin/backend/assets/Paths.hx b/source/funkin/backend/assets/Paths.hx index a0cb0138..b2d520c2 100644 --- a/source/funkin/backend/assets/Paths.hx +++ b/source/funkin/backend/assets/Paths.hx @@ -23,8 +23,6 @@ class Paths */ public static inline final SOUND_EXT = #if web "mp3" #else "ogg" #end; - public static inline var translFolderName:String = "translations"; - public static var assetsTree:AssetsLibraryList; public static var tempFramesCache:Map = []; @@ -38,12 +36,6 @@ class Paths public static inline function getPath(file:String, ?library:String) return library != null ? '$library:assets/$library/$file' : 'assets/$file'; - public static inline function translationsMain(key:String) - return getPath('$translFolderName/$key'); - - public static inline function translations(key:String) - return translationsMain('${TranslationUtil.curLanguage}/$key'); - public static inline function video(key:String, ?ext:String = "mp4") return getPath('videos/$key.$ext'); diff --git a/source/funkin/backend/assets/TranslatedAssetLibrary.hx b/source/funkin/backend/assets/TranslatedAssetLibrary.hx index 9b19c4b1..dfaa2e0c 100644 --- a/source/funkin/backend/assets/TranslatedAssetLibrary.hx +++ b/source/funkin/backend/assets/TranslatedAssetLibrary.hx @@ -10,14 +10,14 @@ class TranslatedAssetLibrary extends AssetLibrary implements IModsAssetLibrary { public var libName:String; public var modName:String; public var basePath:String; - public var prefix:String = Paths.translFolderName + "/"; + public var prefix:String = TranslationUtil.LANG_FOLDER + "/"; public var langFolder(get, set):String; @:noCompletion private inline function get_langFolder():String { return libName; } @:noCompletion private inline function set_langFolder(value:String):String { - basePath = prefix + (libName = modName = (value != null ? value : TranslationUtil.DEFAULT_LANGUAGE.split("/")[0])) + "/"; + basePath = prefix + (libName = modName = (value != null ? value : TranslationUtil.DEFAULT_LANGUAGE)) + "/"; return libName; } diff --git a/source/funkin/backend/assets/ZipFolderLibrary.hx b/source/funkin/backend/assets/ZipFolderLibrary.hx index a3bde303..3a4229a3 100644 --- a/source/funkin/backend/assets/ZipFolderLibrary.hx +++ b/source/funkin/backend/assets/ZipFolderLibrary.hx @@ -1,27 +1,15 @@ package funkin.backend.assets; -import lime.utils.Log; -import lime.utils.AssetManifest; import haxe.io.Path; -import lime.app.Event; -import lime.app.Future; -import lime.app.Promise; import lime.media.AudioBuffer; import lime.graphics.Image; import lime.text.Font; -import lime.utils.AssetType; import lime.utils.Bytes; -import lime.utils.Assets as LimeAssets; -import openfl.text.Font as OpenFLFont; import openfl.utils.AssetLibrary; #if MOD_SUPPORT -import sys.FileStat; -import sys.FileSystem; -import sys.io.File; -import haxe.zip.Reader; import funkin.backend.utils.SysZip; import funkin.backend.utils.SysZip.SysZipEntry; diff --git a/source/funkin/backend/system/Logs.hx b/source/funkin/backend/system/Logs.hx index 88add011..b82885d2 100644 --- a/source/funkin/backend/system/Logs.hx +++ b/source/funkin/backend/system/Logs.hx @@ -98,23 +98,26 @@ class Logs { public static function traceColored(text:Array, level:Level = INFO) { __showInConsole(prepareColoredTrace(text, level)); } - public static function trace(text:String, level:Level = INFO, color:ConsoleColor = LIGHTGRAY) { - traceColored([{ - text: text, - color: color - }], level); + public static function trace(text:String, level:Level = INFO, color:ConsoleColor = LIGHTGRAY, ?prefix:String) { + var text = [logText(text, color)]; + if(prefix != null) text.insert(0, getPrefix(prefix)); + traceColored(text, level); } - public inline static function verbose(text:String, level:Level = VERBOSE, color:ConsoleColor = LIGHTGRAY) { - Logs.trace(text, level, color); + public inline static function getPrefix(prefix:String) { + return logText('[${prefix}] ', BLUE); } - public inline static function warn(text:String, level:Level = WARNING, color:ConsoleColor = YELLOW) { - Logs.trace(text, level, color); + public inline static function verbose(text:String, level:Level = VERBOSE, color:ConsoleColor = LIGHTGRAY, ?prefix:String) { + Logs.trace(text, level, color, prefix); } - public inline static function error(text:String, level:Level = ERROR, color:ConsoleColor = RED) { - Logs.trace(text, level, color); + public inline static function warn(text:String, level:Level = WARNING, color:ConsoleColor = YELLOW, ?prefix:String) { + Logs.trace(text, level, color, prefix); + } + + public inline static function error(text:String, level:Level = ERROR, color:ConsoleColor = RED, ?prefix:String) { + Logs.trace(text, level, color, prefix); } } diff --git a/source/funkin/backend/system/MainState.hx b/source/funkin/backend/system/MainState.hx index 865233ac..bb265859 100644 --- a/source/funkin/backend/system/MainState.hx +++ b/source/funkin/backend/system/MainState.hx @@ -55,6 +55,7 @@ class MainState extends FlxState { Main.refreshAssets(); ModsFolder.onModSwitch.dispatch(ModsFolder.currentModFolder); + TranslationUtil.findAllLanguages(); TranslationUtil.setLanguage(); DiscordUtil.init(); EventsData.reloadEvents(); diff --git a/source/funkin/backend/utils/CoolUtil.hx b/source/funkin/backend/utils/CoolUtil.hx index e4235ad7..a47df229 100644 --- a/source/funkin/backend/utils/CoolUtil.hx +++ b/source/funkin/backend/utils/CoolUtil.hx @@ -140,7 +140,16 @@ class CoolUtil } /** - * Returns the last of an Array + * Returns the first element of an Array + * @param array Array + * @return T Last element + */ + public static inline function first(array:Array):T { + return array[0]; + } + + /** + * Returns the last element of an Array * @param array Array * @return T Last element */ @@ -703,6 +712,19 @@ class CoolUtil @:noUsing public static inline function flxeaseFromString(mainEase:String, suffix:String) return Reflect.field(FlxEase, mainEase + (mainEase == "linear" ? "" : suffix)); + + public static function sortAlphabetically(array:Array, ?lowercase:Bool=false) { + array.sort(function(a1, a2):Int { + if(lowercase) { + a1 = a1.toLowerCase(); + a2 = a2.toLowerCase(); + } + if (a1 < a2) return -1; + if (a1 > a2) return 1; + return 0; + }); + return array; + } } /** diff --git a/source/funkin/backend/utils/DiscordUtil.hx b/source/funkin/backend/utils/DiscordUtil.hx index 30fc685a..611a9ec1 100644 --- a/source/funkin/backend/utils/DiscordUtil.hx +++ b/source/funkin/backend/utils/DiscordUtil.hx @@ -285,7 +285,7 @@ class DiscordUtil user = DUser.initRaw(request); Logs.traceColored([ - Logs.logText("[Discord] ", BLUE), + Logs.getPrefix("Discord"), Logs.logText("Connected to User " + user.globalName + " ("), Logs.logText(user.handle, GRAY), Logs.logText(")") @@ -301,7 +301,7 @@ class DiscordUtil var finalMsg:String = cast(message, String); Logs.traceColored([ - Logs.logText("[Discord] ", BLUE), + Logs.getPrefix("Discord"), Logs.logText("Disconnected ("), Logs.logText('$errorCode: $finalMsg', RED), Logs.logText(")") @@ -315,7 +315,7 @@ class DiscordUtil var finalMsg:String = cast(message, String); Logs.traceColored([ - Logs.logText("[Discord] ", BLUE), + Logs.getPrefix("Discord"), Logs.logText('Error ($errorCode: $finalMsg)', RED) ], ERROR); @@ -324,7 +324,7 @@ class DiscordUtil private static function onJoin(joinSecret:cpp.ConstCharStar):Void { - Logs.traceColored([Logs.logText("[Discord] ", BLUE), Logs.logText("Someone has just joined", GREEN)], INFO); + Logs.traceColored([Logs.getPrefix("Discord"), Logs.logText("Someone has just joined", GREEN)], INFO); call("onJoinGame", [cast(joinSecret, String)]); } @@ -332,7 +332,7 @@ class DiscordUtil private static function onSpectate(spectateSecret:cpp.ConstCharStar):Void { Logs.traceColored([ - Logs.logText("[Discord] ", BLUE), + Logs.getPrefix("Discord"), Logs.logText("Someone started spectating your game", YELLOW) ], INFO); @@ -342,7 +342,7 @@ class DiscordUtil private static function onJoinReq(request:cpp.RawConstPointer):Void { Logs.traceColored([ - Logs.logText("[Discord] ", BLUE), + Logs.getPrefix("Discord"), Logs.logText("Someone has just requested to join", YELLOW) ], INFO); diff --git a/source/funkin/backend/utils/IniUtil.hx b/source/funkin/backend/utils/IniUtil.hx index 7f090554..1777ffb8 100644 --- a/source/funkin/backend/utils/IniUtil.hx +++ b/source/funkin/backend/utils/IniUtil.hx @@ -1,18 +1,20 @@ package funkin.backend.utils; +typedef IniMap = Map; /** - * DOESNT SUPPORT CATEGORIES YET!! + * Parses an ini file and returns a map of its contents. + * WARNING: This is not a full ini parser, it only supports the basics. So no sections */ class IniUtil { - public static inline function parseAsset(assetPath:String, ?defaultVariables:Map) + public static inline function parseAsset(assetPath:String, ?defaultVariables:IniMap):IniMap return parseString(Assets.getText(assetPath), defaultVariables); - public static function parseString(data:String, ?defaultVariables:Map) { + public static function parseString(data:String, ?defaultVariables:IniMap):IniMap { var trimmed:String; var splitContent = [for(e in data.split("\n")) if ((trimmed = e.trim()) != "") trimmed]; - var finalMap:Map = []; + var finalMap:IniMap = []; if (defaultVariables != null) for(k=>e in defaultVariables) finalMap[k] = e; @@ -20,7 +22,7 @@ class IniUtil { for(line in splitContent) { // comment if (line.startsWith(";")) continue; - // categories; not supported yet + // sections; not supported yet if (line.startsWith("[") && line.endsWith("]")) continue; var index = line.indexOf("="); diff --git a/source/funkin/backend/utils/SortedArrayUtil.hx b/source/funkin/backend/utils/SortedArrayUtil.hx index ac317811..10422198 100644 --- a/source/funkin/backend/utils/SortedArrayUtil.hx +++ b/source/funkin/backend/utils/SortedArrayUtil.hx @@ -10,7 +10,7 @@ class SortedArrayUtil { * @param getVal Function that returns the position value of T * @return Index */ - public static inline function binarySearch(array:Array, val:Float, getVal:T->Float):Int { + public static function binarySearch(array:Array, val:Float, getVal:T->Float):Int { if (array.length <= 0) return 0; // if the array is empty, it should be equal to zero (the beginning) if (getVal(array[0]) > val) return 0; // in case its the minimum if (getVal(array[array.length-1]) < val) return array.length; // in case its the maximum @@ -46,4 +46,10 @@ class SortedArrayUtil { if (val != null) array.insert(binarySearch(array, getVal(val), getVal), val); } + + public static function removeSorted(array:Array, val:T, getVal:T->Float) { + var index = binarySearch(array, getVal(val), getVal); + if (index != -1) + array.splice(index, 1); + } } \ No newline at end of file diff --git a/source/funkin/backend/utils/TranslationUtil.hx b/source/funkin/backend/utils/TranslationUtil.hx index 5dd1b54e..208f77ff 100644 --- a/source/funkin/backend/utils/TranslationUtil.hx +++ b/source/funkin/backend/utils/TranslationUtil.hx @@ -1,10 +1,8 @@ package funkin.backend.utils; +import funkin.backend.utils.IniUtil; import funkin.backend.assets.TranslatedAssetLibrary; -import funkin.backend.assets.ModsFolderLibrary; import funkin.backend.assets.ModsFolder; -import funkin.backend.assets.IModsAssetLibrary; -import funkin.backend.assets.ZipFolderLibrary; import openfl.utils.Assets; import haxe.io.Path; import haxe.xml.Access; @@ -15,6 +13,7 @@ import haxe.Exception; * * Made by @NexIsDumb originally for the Poldhub mod. */ +@:allow(funkin.backend.assets.TranslatedAssetLibrary) class TranslationUtil { /** @@ -22,42 +21,51 @@ class TranslationUtil * * It'll never be `null`. */ - public static var transMap(default, set):Map = []; - - @:noCompletion private static function set_transMap(value:Map):Map { - if (value == null) value = []; - return transMap = value; - } + public static var stringMap(default, set):Map = []; /** * The default language used inside of the source code. */ - public static var DEFAULT_LANGUAGE:String = 'en/English'; // no inline so psycopathic mods can edit it - Nex + public static var DEFAULT_LANGUAGE:String = 'en'; // no inline so psychopathic mods can edit it - Nex + /** + * Returns the current language config. + */ + public static var config:IniMap = []; /** * Returns the current language. */ public static var curLanguage(get, set):String; - @:noCompletion private static function get_curLanguage():String { - return Options.language; - } - @:noCompletion private static function set_curLanguage(value:String):String { - return Options.language = value; - } + /** + * Returns the current language name. + */ + public static var curLanguageName(get, set):String; /** * Returns if the current language is the default one (`DEFAULT_LANGUAGE`). */ public static var isDefaultLanguage(get, never):Bool; - @:noCompletion private static function get_isDefaultLanguage():Bool - return Options.language == DEFAULT_LANGUAGE; /** * Returns if any translation are loaded. */ - public static var isAnyTransLoaded(get, never):Bool; - @:noCompletion private static function get_isAnyTransLoaded():Bool - return Lambda.count(transMap) > 0; + public static var isLanguageLoaded(get, never):Bool; + + /** + * Returns an array has a list of the languages that were found. + */ + public static var foundLanguages:Array = []; + + // Private + private static inline var LANG_FOLDER:String = "languages"; + private static var langConfigs:Map = []; + private static var nameMap:Map = []; + private static inline function getDefaultNameMap():Map { + return ['en' => 'English']; + } + private static inline function getDefaultConfig(name:String):IniMap { + return ["name" => getLanguageName(name), "credits" => "", "version" => "1.0.0"]; + } /** * Updates the language. @@ -69,11 +77,19 @@ class TranslationUtil public static function setLanguage(?name:String) { #if TRANSLATIONS_SUPPORT if(name == null) name = curLanguage; - name = name.split("/")[0]; - transMap = loadLanguage(name); - for(mod in ModsFolder.getLoadedModsLibs()) if(mod is TranslatedAssetLibrary) - cast(mod, TranslatedAssetLibrary).langFolder = name.split("/")[0]; + config = getConfig(name); + stringMap = loadLanguage(name); + + Logs.traceColored([ + Logs.getPrefix("Language"), + Logs.logText("Set Language To: "), + Logs.logText('${getLanguageName(name)} ($name)', GREEN) + ], VERBOSE); + + for(mod in ModsFolder.getLoadedModsLibs(false)) + if(mod is TranslatedAssetLibrary) + cast(mod, TranslatedAssetLibrary).langFolder = name; #end } @@ -86,7 +102,7 @@ class TranslationUtil { #if TRANSLATIONS_SUPPORT if (id == null) id = defString; - if (transMap.exists(id)) return transMap.get(id).format(params); + if (stringMap.exists(id)) return stringMap.get(id).format(params); #end return FormatUtil.get(defString).format(params); } @@ -94,64 +110,149 @@ class TranslationUtil /** * Returns an array that specifies which languages were found. */ - public static function getLanguages():Array + public static function findAllLanguages() { - var languages:Array = []; #if TRANSLATIONS_SUPPORT - var main:String = Paths.translationsMain(''); - for (l in Paths.assetsTree.getFolders(main)) { - var path:String = main + l + "/config.ini"; - if(Assets.exists(path)) languages.push('$l/' + IniUtil.parseAsset(path, ["name" => l])["name"]); - else for(f in Paths.assetsTree.getFiles(main + l)) if(Path.extension(path = '$l/$f') == "xml") { - languages.push(Path.withoutExtension(path)); - break; // only gets the first xml's name if theres no config.ini! - Nex + foundLanguages = []; + nameMap = getDefaultNameMap(); + var mainPath:String = translationsMain(""); + var langName:String = null; + for (lang in Paths.assetsTree.getFolders("assets/" + mainPath)) { + var path:String = Path.join([mainPath, lang, "config.ini"]); + + var config = getDefaultConfig(lang); + + if(Assets.exists(path)) { + config = IniUtil.parseAsset(path, config); + langName = config["name"]; + } else { // if there was no config.ini, use the file name as the language name + for(file in Paths.getFolderContent(mainPath + lang).sortAlphabetically()) { + if(Path.extension(path = '$lang/$file') == "xml") { + config["name"] = (langName = Path.withoutExtension(file)); + break; + } + } } + nameMap.set(lang, langName); + langConfigs.set(lang, config); + foundLanguages.push('$lang/$langName'); } + + // Ensure that the default language is always first + var englishName = TranslationUtil.DEFAULT_LANGUAGE + "/" + getLanguageName(TranslationUtil.DEFAULT_LANGUAGE); + if (foundLanguages.contains(englishName)) + foundLanguages.remove(englishName); + foundLanguages.insert(0, englishName); #end - return languages; } /** * Returns a map of translations based on its XML. */ - public static function loadLanguage(folderName:String):Map + public static function loadLanguage(lang:String):Map { #if TRANSLATIONS_SUPPORT - var mainPath:String = Paths.translationsMain(folderName); + FormatUtil.clear(); // Clean up the format cache + var mainPath:String = translationsMain(lang); var leMap:Map = []; - for(file in Paths.getFolderContent(mainPath)) if(Path.extension(file) == "xml") { - var xml:Access = null; - try xml = new Access(Xml.parse(Assets.getText('$mainPath/$file'))) - catch(e) { - Logs.error('Error while parsing $file: ${Std.string(e)}'); - return []; - } - if (xml == null) return []; - if (!xml.hasNode.translations) { - Logs.warn("A translation xml file requires a translations root element."); - return []; - } - - FormatUtil.clear(); // Clean up the format cache - for(node in xml.node.translations.elements) { - switch(node.name) { - case "trans": - if (!node.has.id) { - Logs.warn("A translation node requires an ID attribute."); - continue; - } - - var string:String = node.has.string ? node.att.string : node.x.nodeValue; - string = string.replace("\\n", "\n").replace("\r", ""); // remove stupid windows line breaks and convert newline literals to newlines - leMap.set(node.att.id, FormatUtil.get(string)); - } + var translations = []; + function parseXml(xml:Access) { + for(node in xml.elements) { + if (node.name == "group") // Cosmetic name + parseXml(node); + else if(node.name == "text" || node.name == "trans" || node.name == "lang" || node.name == "string") + translations.push(node); } } + + for(file in Paths.getFolderContent(mainPath, true).sortAlphabetically()) { + if(Path.extension(file) != "xml") continue; + + // Parse the XML + var xml:Access = null; + try xml = new Access(Xml.parse(Assets.getText(file))) + catch(e) Logs.error('Error while parsing $file: ${Std.string(e)}', "Language"); + + if (xml == null) continue; + if (!xml.hasNode.language) { + Logs.warn('File $file requires a root element.', "Language"); + continue; + } + + var langNode = xml.node.language; + + //if (langNode.has.name) { + // getConfig(lang).set("name", langNode.att.name); + //} + + parseXml(langNode); + } + + for(node in translations) { + if (!node.has.id) { + Logs.warn('A <${node.name}> node requires an ID attribute.', "Language"); + continue; + } + + var value:String = node.has.string ? node.att.string : node.innerData; + value = value.replace("\\n", "\n").replace("\r", ""); // remove stupid windows line breaks and convert newline literals to newlines + leMap.set(node.att.id, FormatUtil.get(value)); + } + return leMap; #else return []; #end } + + // Utils + + public static function getLanguageName(lang:String) { + return nameMap.exists(lang) ? nameMap.get(lang) : lang; + } + + public static function getLanguageFromName(name:String) { + var reverseMap = new Map(); + for(key => val in nameMap) reverseMap.set(val, key); + return reverseMap.exists(name) ? reverseMap.get(name) : name; + } + + public static function getConfig(lang:String):IniMap { + return langConfigs.exists(lang) ? langConfigs.get(lang) : getDefaultConfig(lang); + } + + public static inline function translationsMain(key:String) + return '$LANG_FOLDER/$key'; + + public static inline function translations(key:String) + return translationsMain('$curLanguage/$key'); + + // getters & setters + + @:noCompletion private static function set_stringMap(value:Map):Map { + if (value == null) value = []; + return stringMap = value; + } + + @:noCompletion private static function get_curLanguage():String { + return Options.language; + } + @:noCompletion private static function set_curLanguage(value:String):String { + return Options.language = value; + } + + @:noCompletion private static function get_curLanguageName():String { + return getLanguageName(Options.language); + } + @:noCompletion private static function set_curLanguageName(value:String):String { + return Options.language = getLanguageFromName(value); + } + + @:noCompletion private static function get_isDefaultLanguage():Bool + return Options.language == DEFAULT_LANGUAGE; + + @:noCompletion private static function get_isLanguageLoaded():Bool + return Lambda.count(stringMap) > 0; } /** @@ -187,6 +288,10 @@ class StrFormatInfo implements IFormatInfo { public function format(params:Array):String { return string; } + + public function toString():String { + return "StrFormatInfo(" + string + ")"; + } } // TODO: add support for @:({0}==1?(Hello):(World)) @@ -235,6 +340,10 @@ class ParamFormatInfo implements IFormatInfo { return str; } + + public function toString():String { + return 'ParamFormatInfo([${strings.join(", ")}] [${indexes.join(", ")}])'; + } } interface IFormatInfo { diff --git a/source/funkin/options/categories/MiscOptions.hx b/source/funkin/options/categories/MiscOptions.hx index de543cb4..37078070 100644 --- a/source/funkin/options/categories/MiscOptions.hx +++ b/source/funkin/options/categories/MiscOptions.hx @@ -6,15 +6,14 @@ class MiscOptions extends OptionsScreen { super("Miscellaneous", "Use this menu to reset save data or engine settings."); { // Language Option - var lanArray:Array = TranslationUtil.getLanguages(); - if (lanArray.contains(TranslationUtil.DEFAULT_LANGUAGE)) - lanArray.remove(TranslationUtil.DEFAULT_LANGUAGE); - lanArray.insert(0, TranslationUtil.DEFAULT_LANGUAGE); + var lanArray:Array = TranslationUtil.foundLanguages; + // TODO: add credits based on the config file add(new ArrayOption( "Language", 'The language that the engine currently uses (the default one is ${TranslationUtil.DEFAULT_LANGUAGE}).', - lanArray, [for(lan in lanArray) lan.split("/").last()], "language", + [for(lan in lanArray) lan.split("/").first()], + [for(lan in lanArray) lan.split("/").last()], "language", function(path:String) { TranslationUtil.setLanguage(path); parent.remove(this);