Merge 5bb062bd86cbc0bdedd1c7d11b693fafba83b02f into bd9ae189f83e33a6977bbe056774c851e96fe0a7

This commit is contained in:
Glavo 2025-09-21 15:16:33 +00:00 committed by GitHub
commit c285b968e3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1028 additions and 301 deletions

1
.gitignore vendored
View File

@ -2,6 +2,7 @@
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
*.hprof
.gradle

View File

@ -1,4 +1,7 @@
import org.jackhuang.hmcl.gradle.CheckTranslations
import org.jackhuang.hmcl.gradle.l10n.CheckTranslations
import org.jackhuang.hmcl.gradle.l10n.CreateLanguageList
import org.jackhuang.hmcl.gradle.l10n.CreateLocaleNames
import org.jackhuang.hmcl.gradle.l10n.UpsideDownTranslate
import org.jackhuang.hmcl.gradle.mod.ParseModDataTask
import java.net.URI
import java.nio.file.FileSystems
@ -206,11 +209,20 @@ tasks.shadowJar {
tasks.processResources {
dependsOn(createPropertiesFile)
dependsOn(upsideDownTranslate)
dependsOn(createLocaleNames)
dependsOn(createLanguageList)
into("assets/") {
from(hmclPropertiesFile)
from(embedResources)
}
into("assets/lang") {
from(createLanguageList.map { it.outputFile })
from(upsideDownTranslate.map { it.outputFile })
from(createLocaleNames.map { it.outputDirectory })
}
}
val makeExecutables by tasks.registering {
@ -344,6 +356,29 @@ tasks.register<CheckTranslations>("checkTranslations") {
classicalChineseFile.set(dir.file("I18N_lzh.properties"))
}
// l10n
val generatedDir = layout.buildDirectory.dir("generated")
val upsideDownTranslate by tasks.registering(UpsideDownTranslate::class) {
inputFile.set(layout.projectDirectory.file("src/main/resources/assets/lang/I18N.properties"))
outputFile.set(generatedDir.map { it.file("generated/i18n/I18N_en_Qabs.properties") })
}
val createLanguageList by tasks.registering(CreateLanguageList::class) {
resourceBundleDir.set(layout.projectDirectory.dir("src/main/resources/assets/lang"))
resourceBundleBaseName.set("I18N")
additionalLanguages.set(listOf("en-Qabs"))
outputFile.set(generatedDir.map { it.file("languages.json") })
}
val createLocaleNames by tasks.registering(CreateLocaleNames::class) {
dependsOn(createLanguageList)
languagesFile.set(createLanguageList.flatMap { it.outputFile })
outputDirectory.set(generatedDir.map { it.dir("generated/LocaleNames") })
}
// mcmod data
tasks.register<ParseModDataTask>("parseModData") {

View File

@ -80,8 +80,6 @@ public final class HMCLGameLauncher extends DefaultLauncher {
}
Locale locale = Locale.getDefault();
if (LocaleUtils.isEnglish(locale))
return;
/*
* 1.0 : No language option, do not set for these versions
@ -129,6 +127,13 @@ public final class HMCLGameLauncher extends DefaultLauncher {
}
yield "zh_CN";
}
case "en" -> {
if ("Qabs".equals(LocaleUtils.getScript(locale)) && gameVersion.compareTo("1.16") >= 0) {
yield "en_UD";
}
yield "";
}
default -> "";
};
}

View File

@ -37,8 +37,7 @@ import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
import org.jackhuang.hmcl.java.JavaRuntime;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.gson.*;
import org.jackhuang.hmcl.util.i18n.Locales;
import org.jackhuang.hmcl.util.i18n.Locales.SupportedLocale;
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
import org.jackhuang.hmcl.util.javafx.DirtyTracker;
import org.jackhuang.hmcl.util.javafx.ObservableHelper;
import org.jetbrains.annotations.Nullable;
@ -222,7 +221,7 @@ public final class Config implements Observable {
}
@SerializedName("localization")
private final ObjectProperty<SupportedLocale> localization = new SimpleObjectProperty<>(Locales.DEFAULT);
private final ObjectProperty<SupportedLocale> localization = new SimpleObjectProperty<>(SupportedLocale.DEFAULT);
public ObjectProperty<SupportedLocale> localizationProperty() {
return localization;

View File

@ -33,7 +33,7 @@ import org.jackhuang.hmcl.upgrade.UpdateChannel;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
import org.jackhuang.hmcl.upgrade.UpdateHandler;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.Locales;
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.tukaani.xz.XZInputStream;
@ -65,7 +65,7 @@ public final class SettingsPage extends SettingsView {
FXUtils.smoothScrolling(scroll);
// ==== Languages ====
cboLanguage.getItems().setAll(Locales.LOCALES);
cboLanguage.getItems().setAll(SupportedLocale.getSupportedLocales());
selectedItemPropertyFor(cboLanguage).bindBidirectional(config().localizationProperty());
disableAutoGameOptionsPane.selectedProperty().bindBidirectional(config().disableAutoGameOptionsProperty());

View File

@ -39,7 +39,7 @@ import org.jackhuang.hmcl.ui.construct.ComponentSublist;
import org.jackhuang.hmcl.ui.construct.MultiFileItem;
import org.jackhuang.hmcl.ui.construct.OptionToggleButton;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.Locales.SupportedLocale;
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
import java.util.Arrays;

View File

@ -19,7 +19,6 @@ package org.jackhuang.hmcl.util.i18n;
import org.jackhuang.hmcl.download.RemoteVersion;
import org.jackhuang.hmcl.download.game.GameRemoteVersion;
import org.jackhuang.hmcl.util.i18n.Locales.SupportedLocale;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.Nullable;
@ -34,7 +33,7 @@ public final class I18n {
private I18n() {
}
private static volatile SupportedLocale locale = Locales.DEFAULT;
private static volatile SupportedLocale locale = SupportedLocale.DEFAULT;
public static void setLocale(SupportedLocale locale) {
I18n.locale = locale;
@ -71,6 +70,11 @@ public final class I18n {
else
return WenyanUtils.translateGenericVersion(version.getSelfVersion());
}
if (LocaleUtils.isEnglish(locale.getLocale()) && "Qabs".equals(LocaleUtils.getScript(locale.getLocale()))) {
return UpsideDownUtils.translate(version.getSelfVersion());
}
return version.getSelfVersion();
}

View File

@ -1,273 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.util.i18n;
import com.google.gson.annotations.JsonAdapter;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;
import org.jackhuang.hmcl.util.StringUtils;
import java.io.IOException;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAccessor;
import java.util.*;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class Locales {
private Locales() {
}
public static final SupportedLocale DEFAULT;
static {
String language = System.getenv("HMCL_LANGUAGE");
DEFAULT = new SupportedLocale(true, "def",
StringUtils.isBlank(language) ? LocaleUtils.SYSTEM_DEFAULT : Locale.forLanguageTag(language));
}
/**
* English
*/
public static final SupportedLocale EN = new SupportedLocale("en");
/**
* Spanish
*/
public static final SupportedLocale ES = new SupportedLocale("es");
/**
* Russian
*/
public static final SupportedLocale RU = new SupportedLocale("ru");
/**
* Ukrainian
*/
public static final SupportedLocale UK = new SupportedLocale("uk");
/**
* Japanese
*/
public static final SupportedLocale JA = new SupportedLocale("ja");
/**
* Chinese (Simplified)
*/
public static final SupportedLocale ZH_HANS = new SupportedLocale("zh_CN", LocaleUtils.LOCALE_ZH_HANS);
/**
* Chinese (Traditional)
*/
public static final SupportedLocale ZH_HANT = new SupportedLocale("zh", LocaleUtils.LOCALE_ZH_HANT);
/**
* Wenyan (Classical Chinese)
*/
public static final SupportedLocale WENYAN = new SupportedLocale("lzh");
public static final List<SupportedLocale> LOCALES = List.of(DEFAULT, EN, ES, JA, RU, UK, ZH_HANS, ZH_HANT, WENYAN);
public static SupportedLocale getLocaleByName(String name) {
if (name == null) return DEFAULT;
for (SupportedLocale locale : LOCALES) {
if (locale.getName().equalsIgnoreCase(name))
return locale;
}
return DEFAULT;
}
@JsonAdapter(SupportedLocale.TypeAdapter.class)
public static final class SupportedLocale {
private final boolean isDefault;
private final String name;
private final Locale locale;
private ResourceBundle resourceBundle;
private DateTimeFormatter dateTimeFormatter;
private List<Locale> candidateLocales;
SupportedLocale(boolean isDefault, String name, Locale locale) {
this.isDefault = isDefault;
this.name = name;
this.locale = locale;
}
SupportedLocale(String name) {
this(false, name, Locale.forLanguageTag(name));
}
SupportedLocale(String name, Locale locale) {
this(false, name, locale);
}
public boolean isDefault() {
return isDefault;
}
public String getName() {
return name;
}
public Locale getLocale() {
return locale;
}
public String getDisplayName(SupportedLocale inLocale) {
if (isDefault()) {
try {
return inLocale.getResourceBundle().getString("lang.default");
} catch (Throwable e) {
LOG.warning("Failed to get localized name for default locale", e);
return "Default";
}
}
Locale inJavaLocale = inLocale.getLocale();
if (inJavaLocale.getLanguage().length() > 2) {
String iso1 = LocaleUtils.getISO1Language(inJavaLocale);
if (iso1.length() <= 2) {
Locale.Builder builder = new Locale.Builder()
.setLocale(inJavaLocale)
.setLanguage(iso1);
if (inJavaLocale.getScript().isEmpty())
builder.setScript(LocaleUtils.getScript(inJavaLocale));
inJavaLocale = builder.build();
}
}
if (this.locale.getLanguage().equals("lzh")) {
if (inJavaLocale.getLanguage().equals("zh"))
return "文言";
String name = locale.getDisplayName(inJavaLocale);
return name.equals("lzh") || name.equals("Literary Chinese")
? "Chinese (Classical)"
: name;
}
return locale.getDisplayName(inJavaLocale);
}
public ResourceBundle getResourceBundle() {
ResourceBundle bundle = resourceBundle;
if (resourceBundle == null)
resourceBundle = bundle = ResourceBundle.getBundle("assets.lang.I18N", locale,
DefaultResourceBundleControl.INSTANCE);
return bundle;
}
public List<Locale> getCandidateLocales() {
if (candidateLocales == null)
candidateLocales = List.copyOf(LocaleUtils.getCandidateLocales(locale));
return candidateLocales;
}
public String i18n(String key, Object... formatArgs) {
try {
return String.format(getResourceBundle().getString(key), formatArgs);
} catch (MissingResourceException e) {
LOG.error("Cannot find key " + key + " in resource bundle", e);
} catch (IllegalFormatException e) {
LOG.error("Illegal format string, key=" + key + ", args=" + Arrays.toString(formatArgs), e);
}
return key + Arrays.toString(formatArgs);
}
public String i18n(String key) {
try {
return getResourceBundle().getString(key);
} catch (MissingResourceException e) {
LOG.error("Cannot find key " + key + " in resource bundle", e);
return key;
}
}
public String formatDateTime(TemporalAccessor time) {
DateTimeFormatter formatter = dateTimeFormatter;
if (formatter == null) {
if (locale.getLanguage().equals("lzh"))
return WenyanUtils.formatDateTime(time);
formatter = dateTimeFormatter = DateTimeFormatter.ofPattern(getResourceBundle().getString("datetime.format"))
.withZone(ZoneId.systemDefault());
}
return formatter.format(time);
}
public String getFcMatchPattern() {
String language = locale.getLanguage();
String region = locale.getCountry();
if (LocaleUtils.isEnglish(locale))
return "";
if (LocaleUtils.isChinese(locale)) {
String lang;
String charset;
String script = LocaleUtils.getScript(locale);
switch (script) {
case "Hans":
lang = region.equals("SG") || region.equals("MY")
? "zh-" + region
: "zh-CN";
charset = "0x6e38,0x620f";
break;
case "Hant":
lang = region.equals("HK") || region.equals("MO")
? "zh-" + region
: "zh-TW";
charset = "0x904a,0x6232";
break;
default:
return "";
}
return ":lang=" + lang + ":charset=" + charset;
}
return region.isEmpty() ? language : language + "-" + region;
}
public boolean isSameLanguage(SupportedLocale other) {
return LocaleUtils.getISO1Language(this.getLocale())
.equals(LocaleUtils.getISO1Language(other.getLocale()));
}
public static final class TypeAdapter extends com.google.gson.TypeAdapter<SupportedLocale> {
@Override
public void write(JsonWriter out, SupportedLocale value) throws IOException {
out.value(value.getName());
}
@Override
public SupportedLocale read(JsonReader in) throws IOException {
return getLocaleByName(in.nextString());
}
}
}
}

View File

@ -27,7 +27,7 @@ public final class MinecraftWiki {
private static final Pattern SNAPSHOT_PATTERN = Pattern.compile("^[0-9]{2}w[0-9]{2}.+$");
public static String getWikiLink(Locales.SupportedLocale locale, GameRemoteVersion version) {
public static String getWikiLink(SupportedLocale locale, GameRemoteVersion version) {
String wikiVersion = version.getSelfVersion();
var gameVersion = GameVersionNumber.asGameVersion(wikiVersion);
@ -41,7 +41,7 @@ public final class MinecraftWiki {
translatedVersion = WenyanUtils.translateGameVersion(gameVersion);
if (translatedVersion.equals(gameVersion.toString()) || gameVersion instanceof GameVersionNumber.Old) {
return getWikiLink(Locales.ZH_HANT, version);
return getWikiLink(SupportedLocale.getLocale(LocaleUtils.LOCALE_ZH_HANT), version);
} else if (SNAPSHOT_PATTERN.matcher(wikiVersion).matches()) {
return locale.i18n("wiki.version.game.snapshot", translatedVersion);
} else {

View File

@ -0,0 +1,272 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.util.i18n;
import com.google.gson.annotations.JsonAdapter;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonToken;
import com.google.gson.stream.JsonWriter;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import java.io.IOException;
import java.io.InputStream;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAccessor;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@JsonAdapter(SupportedLocale.TypeAdapter.class)
public final class SupportedLocale {
public static final SupportedLocale DEFAULT = new SupportedLocale();
private static final ConcurrentMap<Locale, SupportedLocale> LOCALES = new ConcurrentHashMap<>();
public static List<SupportedLocale> getSupportedLocales() {
List<SupportedLocale> list = new ArrayList<>();
list.add(DEFAULT);
InputStream locales = SupportedLocale.class.getResourceAsStream("/assets/lang/languages.json");
if (locales != null) {
try (locales) {
list.addAll(JsonUtils.fromNonNullJsonFully(locales, JsonUtils.listTypeOf(SupportedLocale.class)));
} catch (Throwable e) {
LOG.warning("Failed to load languages.json", e);
}
}
return List.copyOf(list);
}
public static SupportedLocale getLocale(Locale locale) {
return LOCALES.computeIfAbsent(locale, SupportedLocale::new);
}
public static SupportedLocale getLocaleByName(String name) {
if (name == null || name.isEmpty() || "def".equals(name) || "default".equals(name))
return DEFAULT;
return getLocale(Locale.forLanguageTag(name.trim()));
}
private final boolean isDefault;
private final String name;
private final Locale locale;
private ResourceBundle resourceBundle;
private ResourceBundle localeNamesBundle;
private DateTimeFormatter dateTimeFormatter;
private List<Locale> candidateLocales;
SupportedLocale() {
this.isDefault = true;
this.name = "def"; // TODO: Change to "default" after updating the Config format
String language = System.getenv("HMCL_LANGUAGE");
this.locale = StringUtils.isBlank(language)
? LocaleUtils.SYSTEM_DEFAULT
: Locale.forLanguageTag(language);
}
SupportedLocale(Locale locale) {
this.isDefault = false;
this.name = locale.toLanguageTag();
this.locale = locale;
}
public boolean isDefault() {
return isDefault;
}
public String getName() {
return name;
}
public Locale getLocale() {
return locale;
}
public String getDisplayName(SupportedLocale inLocale) {
if (isDefault()) {
try {
return inLocale.getResourceBundle().getString("lang.default");
} catch (Throwable e) {
LOG.warning("Failed to get localized name for default locale", e);
return "Default";
}
}
Locale currentLocale = this.getLocale();
String language = currentLocale.getLanguage();
String script = currentLocale.getScript();
// Currently, HMCL does not support any locales with regions or variants, so they are not handled for now
// String region = currentLocale.getCountry();
// String variant = currentLocale.getDisplayVariant();
ResourceBundle localeNames = inLocale.getLocaleNamesBundle();
String languageDisplayName = language;
try {
languageDisplayName = localeNames.getString(language);
} catch (Throwable e) {
LOG.warning("Failed to get localized name for language " + language, e);
}
if (script.isEmpty()) {
return languageDisplayName;
}
String scriptDisplayName = script;
try {
scriptDisplayName = localeNames.getString(script);
} catch (Throwable e) {
LOG.warning("Failed to get localized name for script " + script, e);
}
return languageDisplayName + " (" + scriptDisplayName + ")";
}
public ResourceBundle getResourceBundle() {
ResourceBundle bundle = resourceBundle;
if (resourceBundle == null)
resourceBundle = bundle = ResourceBundle.getBundle("assets.lang.I18N", locale,
DefaultResourceBundleControl.INSTANCE);
return bundle;
}
public ResourceBundle getLocaleNamesBundle() {
ResourceBundle bundle = localeNamesBundle;
if (localeNamesBundle == null)
localeNamesBundle = bundle = ResourceBundle.getBundle("assets.lang.LocaleNames", locale,
DefaultResourceBundleControl.INSTANCE);
return bundle;
}
public List<Locale> getCandidateLocales() {
if (candidateLocales == null)
candidateLocales = List.copyOf(LocaleUtils.getCandidateLocales(locale));
return candidateLocales;
}
public String i18n(String key, Object... formatArgs) {
try {
return String.format(getResourceBundle().getString(key), formatArgs);
} catch (MissingResourceException e) {
LOG.error("Cannot find key " + key + " in resource bundle", e);
} catch (IllegalFormatException e) {
LOG.error("Illegal format string, key=" + key + ", args=" + Arrays.toString(formatArgs), e);
}
return key + Arrays.toString(formatArgs);
}
public String i18n(String key) {
try {
return getResourceBundle().getString(key);
} catch (MissingResourceException e) {
LOG.error("Cannot find key " + key + " in resource bundle", e);
return key;
}
}
public String formatDateTime(TemporalAccessor time) {
DateTimeFormatter formatter = dateTimeFormatter;
if (formatter == null) {
if (LocaleUtils.isEnglish(locale) && "Qabs".equals(locale.getScript())) {
return UpsideDownUtils.formatDateTime(time);
}
if (locale.getLanguage().equals("lzh")) {
return WenyanUtils.formatDateTime(time);
}
formatter = dateTimeFormatter = DateTimeFormatter.ofPattern(getResourceBundle().getString("datetime.format"))
.withZone(ZoneId.systemDefault());
}
return formatter.format(time);
}
public String getFcMatchPattern() {
String language = locale.getLanguage();
String region = locale.getCountry();
if (LocaleUtils.isEnglish(locale))
return "";
if (LocaleUtils.isChinese(locale)) {
String lang;
String charset;
String script = LocaleUtils.getScript(locale);
switch (script) {
case "Hans":
lang = region.equals("SG") || region.equals("MY")
? "zh-" + region
: "zh-CN";
charset = "0x6e38,0x620f";
break;
case "Hant":
lang = region.equals("HK") || region.equals("MO")
? "zh-" + region
: "zh-TW";
charset = "0x904a,0x6232";
break;
default:
return "";
}
return ":lang=" + lang + ":charset=" + charset;
}
return region.isEmpty() ? language : language + "-" + region;
}
public boolean isSameLanguage(SupportedLocale other) {
return LocaleUtils.getISO1Language(this.getLocale())
.equals(LocaleUtils.getISO1Language(other.getLocale()));
}
public static final class TypeAdapter extends com.google.gson.TypeAdapter<SupportedLocale> {
@Override
public void write(JsonWriter out, SupportedLocale value) throws IOException {
out.value(value.getName());
}
@Override
public SupportedLocale read(JsonReader in) throws IOException {
if (in.peek() == JsonToken.NULL)
return DEFAULT;
String language = in.nextString();
return getLocaleByName(switch (language) {
// TODO: Remove these compatibility codes after updating the Config format
case "zh_CN" -> "zh-Hans"; // For compatibility
case "zh" -> "zh-Hant"; // For compatibility
default -> language;
});
}
}
}

View File

@ -0,0 +1,68 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.util.i18n;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAccessor;
import java.util.LinkedHashMap;
import java.util.Map;
/// @author Glavo
public final class UpsideDownUtils {
private static final Map<Integer, Integer> MAPPER = new LinkedHashMap<>();
private static void putChars(char baseChar, String upsideDownChars) {
for (int i = 0; i < upsideDownChars.length(); i++) {
MAPPER.put(baseChar + i, (int) upsideDownChars.charAt(i));
}
}
private static void putChars(String baseChars, String upsideDownChars) {
if (baseChars.length() != upsideDownChars.length()) {
throw new IllegalArgumentException("baseChars and upsideDownChars must have same length");
}
for (int i = 0; i < baseChars.length(); i++) {
MAPPER.put((int) baseChars.charAt(i), (int) upsideDownChars.charAt(i));
}
}
static {
putChars('a', "ɐqɔpǝɟbɥıظʞןɯuodbɹsʇnʌʍxʎz");
putChars('A', "ⱯᗺƆᗡƎℲ⅁HIſʞꞀWNOԀὉᴚS⟘∩ΛMXʎZ");
putChars('0', "0ƖᘔƐㄣϛ9ㄥ86");
putChars("_,;.?!/\\'", "‾'⸵˙¿¡/\\,");
}
public static String translate(String str) {
StringBuilder builder = new StringBuilder(str.length());
str.codePoints().forEach(ch -> builder.appendCodePoint(MAPPER.getOrDefault(ch, ch)));
return builder.reverse().toString();
}
private static DateTimeFormatter BASE_FORMATTER = DateTimeFormatter.ofPattern("MMM d, yyyy, h:mm:ss a")
.withZone(ZoneId.systemDefault());
public static String formatDateTime(TemporalAccessor time) {
return translate(BASE_FORMATTER.format(time));
}
private UpsideDownUtils() {
}
}

View File

@ -692,7 +692,7 @@ input.url=The input must be a valid URL.
install=New Instance
install.change_version=Change Version
install.change_version.confirm=Are you sure you want to switch %s from version %s to %s?
install.change_version.confirm=Are you sure you want to switch %1$s from version %2$s to %3$s?
install.change_version.process=Change Version Process
install.failed=Failed to install
install.failed.downloading=Failed to download some required files.
@ -702,7 +702,7 @@ install.failed.install_online=Failed to identify the provided file. If you are i
install.failed.malformed=The downloaded files are corrupted. You can try resolving this problem by switching to another download source in "Settings → Download → Download Source".
install.failed.optifine_conflict=Cannot install both OptiFine and Fabric on Minecraft 1.13 or later.
install.failed.optifine_forge_1.17=For Minecraft 1.17.1, Forge is only compatible with OptiFine H1 pre2 or later. You can install them by checking "Snapshots" when choosing an OptiFine version in HMCL.
install.failed.version_mismatch=This loader requires the game version %s, but the installed one is %s.
install.failed.version_mismatch=This loader requires the game version %1$s, but the installed one is %2$s.
install.installer.change_version=%s Incompatible
install.installer.choose=Choose Your %s Version
install.installer.cleanroom=Cleanroom
@ -787,7 +787,7 @@ launch.advice.forge37_0_60=Forge versions prior to 37.0.60 are not compatible wi
launch.advice.java8_1_13=Minecraft 1.13 and later can only be run on Java 8 or later. Please use Java 8 or later versions.
launch.advice.java8_51_1_13=Minecraft 1.13 may crash on Java 8 versions prior to 1.8.0_51. Please install the latest Java 8 version.
launch.advice.java9=You cannot launch Minecraft 1.12 or earlier with Java 9 or later. Please use Java 8 instead.
launch.advice.modded_java=Some mods may not be compatible with newer Java versions. It is recommended to use Java %s to launch Minecraft %s.
launch.advice.modded_java=Some mods may not be compatible with newer Java versions. It is recommended to use Java %1$s to launch Minecraft %2$s.
launch.advice.modlauncher8=The Forge version you are using is not compatible with the current Java version. Please try updating Forge.
launch.advice.newer_java=You are using an older Java version to launch the game. It is recommended to update to Java 8, otherwise some mods may cause the game to crash.
launch.advice.not_enough_space=You have allocated a memory size larger than the actual %d MiB of memory installed on your computer. You may experience degraded performance or even be unable to launch the game.
@ -906,7 +906,7 @@ modpack.installing=Installing modpack
modpack.installing.given=Installing %s modpack
modpack.introduction=Curse, Modrinth, MultiMC, and MCBBS modpacks are currently supported.
modpack.invalid=Invalid modpack, you can try downloading it again.
modpack.mismatched_type=Modpack type mismatched, the current instance is a(n) %s type, but the provided one is %s type.
modpack.mismatched_type=Modpack type mismatched, the current instance is a(n) %1$s type, but the provided one is %2$s type.
modpack.name=Modpack Name
modpack.not_a_valid_name=Invalid modpack name.
modpack.origin=Source
@ -1236,7 +1236,7 @@ search.first_page=First
search.previous_page=Previous
search.next_page=Next
search.last_page=Last
search.page_n=%d / %s
search.page_n=%1$d / %2$s
selector.choose=Choose
selector.choose_file=Choose file
@ -1333,7 +1333,7 @@ settings.game.java_directory.bit=%s bit
settings.game.java_directory.choose=Choose Java
settings.game.java_directory.invalid=Incorrect Java path
settings.game.java_directory.version=Specify Java Version
settings.game.java_directory.template=%s (%s)
settings.game.java_directory.template=%1$s (%2$s)
settings.game.management=Manage
settings.game.working_directory=Working Directory
settings.game.working_directory.choose=Choose the working directory
@ -1479,7 +1479,7 @@ version.manage.manage=Edit Instance
version.manage.manage.title=Edit Instance - %1s
version.manage.redownload_assets_index=Update Game Assets
version.manage.remove=Delete Instance
version.manage.remove.confirm.trash=Are you sure you want to remove the instance "%s"? You can still find its files in your recycle bin by the name of "%s".
version.manage.remove.confirm.trash=Are you sure you want to remove the instance "%1$s"? You can still find its files in your recycle bin by the name of "%2$s".
version.manage.remove.confirm.independent=Since this instance is stored in an isolated directory, deleting it will also delete its saves and other data. Do you still want to delete the instance "%s"?
version.manage.remove_assets=Delete All Assets
version.manage.remove_libraries=Delete All Libraries

View File

@ -18,11 +18,13 @@
package org.jackhuang.hmcl.util.i18n;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
@ -44,6 +46,23 @@ public final class LocaleUtils {
public static final String DEFAULT_LANGUAGE_KEY = "default";
private static final Map<String, String> subLanguageToParent = new HashMap<>();
static {
try (InputStream input = LocaleUtils.class.getResourceAsStream("/assets/lang/sublanguages.json")) {
if (input != null) {
JsonUtils.fromJsonFully(input, JsonUtils.mapTypeOf(String.class, JsonUtils.listTypeOf(String.class)))
.forEach((parent, subList) -> {
for (String subLanguage : subList) {
subLanguageToParent.put(subLanguage, parent);
}
});
}
} catch (IOException e) {
LOG.warning("Failed to load sublanguages.json file", e);
}
}
private static Locale getInstance(String language, String script, String region,
String variant) {
Locale.Builder builder = new Locale.Builder();
@ -88,6 +107,12 @@ public final class LocaleUtils {
/// the script will be inferred based on the language, the region and the variant.
public static @NotNull String getScript(Locale locale) {
if (locale.getScript().isEmpty()) {
if (isEnglish(locale)) {
if ("UD".equals(locale.getCountry())) {
return "Qabs";
}
}
if (isChinese(locale)) {
if (CHINESE_LATN_VARIANTS.contains(locale.getVariant()))
return "Latn";
@ -308,12 +333,9 @@ public final class LocaleUtils {
}
public static @Nullable String getParentLanguage(String language) {
return switch (language) {
case "cmn", "lzh", "cdo", "cjy", "cpx", "czh",
"gan", "hak", "hsn", "mnp", "nan", "wuu", "yue" -> "zh";
case "" -> null;
default -> "";
};
return !language.isEmpty()
? subLanguageToParent.getOrDefault(language, "")
: null;
}
public static boolean isEnglish(Locale locale) {

View File

@ -0,0 +1,17 @@
{
"zh" : [
"cmn",
"lzh",
"cdo",
"cjy",
"cpx",
"czh",
"gan",
"hak",
"hsn",
"mnp",
"nan",
"wuu",
"yue"
]
}

View File

@ -15,7 +15,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.gradle;
package org.jackhuang.hmcl.gradle.l10n;
import org.gradle.api.DefaultTask;
import org.gradle.api.GradleException;

View File

@ -0,0 +1,175 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.gradle.l10n;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import org.gradle.api.DefaultTask;
import org.gradle.api.GradleException;
import org.gradle.api.file.DirectoryProperty;
import org.gradle.api.file.RegularFileProperty;
import org.gradle.api.provider.ListProperty;
import org.gradle.api.provider.Property;
import org.gradle.api.tasks.Input;
import org.gradle.api.tasks.InputDirectory;
import org.gradle.api.tasks.OutputFile;
import org.gradle.api.tasks.TaskAction;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.stream.Collectors;
/// @author Glavo
public abstract class CreateLanguageList extends DefaultTask {
@InputDirectory
public abstract DirectoryProperty getResourceBundleDir();
@Input
public abstract Property<@NotNull String> getResourceBundleBaseName();
@Input
public abstract ListProperty<@NotNull String> getAdditionalLanguages();
@OutputFile
public abstract RegularFileProperty getOutputFile();
@TaskAction
public void run() throws IOException {
Path inputDir = getResourceBundleDir().get().getAsFile().toPath();
if (!Files.isDirectory(inputDir))
throw new GradleException("Input directory not exists: " + inputDir);
SortedSet<Locale> locales = new TreeSet<>(new LocaleComparator());
locales.addAll(getAdditionalLanguages().getOrElse(List.of()).stream()
.map(Locale::forLanguageTag)
.toList());
String baseName = getResourceBundleBaseName().get();
String suffix = ".properties";
try (var stream = Files.newDirectoryStream(inputDir, file -> {
String fileName = file.getFileName().toString();
return fileName.startsWith(baseName) && fileName.endsWith(suffix);
})) {
for (Path file : stream) {
String fileName = file.getFileName().toString();
if (fileName.length() == baseName.length() + suffix.length())
locales.add(Locale.ENGLISH);
else if (fileName.charAt(baseName.length()) == '_') {
String localeName = fileName.substring(baseName.length() + 1, fileName.length() - suffix.length());
// TODO: Delete this if the I18N file naming is changed
if (baseName.equals("I18N")) {
if (localeName.equals("zh"))
locales.add(Locale.forLanguageTag("zh-Hant"));
else if (localeName.equals("zh_CN"))
locales.add(Locale.forLanguageTag("zh-Hans"));
else
locales.add(Locale.forLanguageTag(localeName.replace('_', '-')));
} else {
if (localeName.equals("zh"))
locales.add(Locale.forLanguageTag("zh-Hans"));
else
locales.add(Locale.forLanguageTag(localeName.replace('_', '-')));
}
}
}
}
Path outputFile = getOutputFile().get().getAsFile().toPath();
Files.createDirectories(outputFile.getParent());
Files.writeString(outputFile, locales.stream().map(locale -> '"' + locale.toLanguageTag() + '"')
.collect(Collectors.joining(", ", "[", "]")));
}
private final class LocaleComparator implements Comparator<Locale> {
Map<String, String> subLanguageToParent = new HashMap<>();
{
Path file = getProject().getRootProject().getLayout().getProjectDirectory()
.file("HMCLCore/src/main/resources/assets/lang/sublanguages.json").getAsFile().toPath();
try (var reader = Files.newBufferedReader(file)) {
new Gson().fromJson(reader, new TypeToken<Map<String, List<String>>>() {
}).forEach((parent, subList) -> {
for (String subLanguage : subList) {
subLanguageToParent.put(subLanguage, parent);
}
});
} catch (IOException e) {
throw new GradleException(e.getMessage(), e);
}
}
private List<String> resolveLanguage(String language) {
List<String> langList = new ArrayList<>();
String lang = language;
while (true) {
langList.add(0, lang);
String parent = subLanguageToParent.get(lang);
if (parent != null) {
lang = parent;
} else {
return langList;
}
}
}
private int compareLanguage(String l1, String l2) {
var list1 = resolveLanguage(l1);
var list2 = resolveLanguage(l2);
int n = Math.min(list1.size(), list2.size());
for (int i = 0; i < n; i++) {
int c = list1.get(i).compareTo(list2.get(i));
if (c != 0)
return c;
}
return Integer.compare(list1.size(), list2.size());
}
@Override
public int compare(Locale l1, Locale l2) {
int c = compareLanguage(l1.getLanguage(), l2.getLanguage());
if (c != 0)
return c;
c = l1.getScript().compareTo(l2.getScript());
if (c != 0)
return c;
c = l1.getCountry().compareTo(l2.getCountry());
if (c != 0)
return c;
c = l1.getVariant().compareTo(l2.getVariant());
if (c != 0)
return c;
return l1.toString().compareTo(l2.toLanguageTag());
}
}
}

View File

@ -0,0 +1,199 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.gradle.l10n;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import org.gradle.api.DefaultTask;
import org.gradle.api.GradleException;
import org.gradle.api.file.DirectoryProperty;
import org.gradle.api.file.RegularFileProperty;
import org.gradle.api.tasks.InputFile;
import org.gradle.api.tasks.OutputDirectory;
import org.gradle.api.tasks.TaskAction;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.*;
import java.util.stream.Collectors;
/// @author Glavo
public abstract class CreateLocaleNames extends DefaultTask {
@InputFile
public abstract RegularFileProperty getLanguagesFile();
@OutputDirectory
public abstract DirectoryProperty getOutputDirectory();
private static String mapToFileName(String base, String ext, Locale locale) {
if (locale.getLanguage().isEmpty() || locale.equals(Locale.ENGLISH))
return base + "." + ext;
else if (locale.toLanguageTag().equals("zh-Hans"))
return base + "_zh." + ext;
else
return base + "_" + locale.toLanguageTag().replace('-', '_') + "." + ext;
}
@TaskAction
public void run() throws IOException {
Path languagesFile = getLanguagesFile().get().getAsFile().toPath();
Path outputDir = getOutputDirectory().get().getAsFile().toPath();
if (Files.isDirectory(outputDir)) {
Files.walkFileTree(outputDir, new SimpleFileVisitor<>() {
@Override
public @NotNull FileVisitResult visitFile(@NotNull Path file, @NotNull BasicFileAttributes attrs) throws IOException {
Files.deleteIfExists(file);
return FileVisitResult.CONTINUE;
}
@Override
public @NotNull FileVisitResult postVisitDirectory(@NotNull Path dir, @Nullable IOException exc) throws IOException {
if (!dir.equals(outputDir))
Files.deleteIfExists(dir);
return FileVisitResult.CONTINUE;
}
});
}
Files.deleteIfExists(outputDir);
Files.createDirectories(outputDir);
List<Locale> supportedLanguages;
try (var reader = Files.newBufferedReader(languagesFile)) {
supportedLanguages = new Gson().fromJson(reader, new TypeToken<List<String>>() {
}).stream()
.map(Locale::forLanguageTag)
.toList();
}
if (!supportedLanguages.get(0).equals(Locale.ENGLISH))
throw new GradleException("The first language must be english.");
// For Upside Down English
UpsideDownTranslate.Translator upsideDownTranslator = new UpsideDownTranslate.Translator();
Map<String, String> englishDisplayNames = new HashMap<>();
SortedSet<String> languages = supportedLanguages.stream()
.map(Locale::getLanguage)
.filter(it -> !it.isBlank())
.collect(Collectors.toCollection(TreeSet::new));
SortedSet<String> scripts = supportedLanguages.stream()
.map(Locale::getScript)
.filter(it -> !it.isBlank())
.collect(Collectors.toCollection(TreeSet::new));
for (Locale currentLanguage : supportedLanguages) {
InputStream overrideFile = CreateLocaleNames.class.getResourceAsStream(
mapToFileName("LocaleNamesOverride", "properties", currentLanguage));
Properties overrideProperties = new Properties();
if (overrideFile != null) {
try (var reader = new InputStreamReader(overrideFile, StandardCharsets.UTF_8)) {
overrideProperties.load(reader);
}
}
Path targetFile = outputDir.resolve(mapToFileName("LocaleNames", "properties", currentLanguage));
if (Files.exists(targetFile))
throw new GradleException(String.format("File %s already exists", targetFile));
try (var writer = Files.newBufferedWriter(targetFile)) {
writer.write("""
#
# Hello Minecraft! Launcher
# Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
#
""");
writer.write("# Languages\n");
for (String language : languages) {
String displayName = overrideProperties.getProperty(language);
if (displayName == null) {
if (currentLanguage.equals(UpsideDownTranslate.EN_QABS) && englishDisplayNames.containsKey(language)) {
displayName = upsideDownTranslator.translate(englishDisplayNames.get(language));
} else {
displayName = new Locale.Builder()
.setLanguage(language)
.build()
.getDisplayLanguage(currentLanguage);
if (displayName.equals(language)
|| (!currentLanguage.equals(Locale.ENGLISH) && displayName.equals(englishDisplayNames.get(language))))
continue; // Skip
}
}
if (currentLanguage.equals(Locale.ENGLISH))
englishDisplayNames.put(language, displayName);
writer.write(language + "=" + displayName + "\n");
}
writer.write('\n');
writer.write("# Scripts\n");
for (String script : scripts) {
String displayName = overrideProperties.getProperty(script);
if (displayName == null) {
if (currentLanguage.equals(UpsideDownTranslate.EN_QABS) && englishDisplayNames.containsKey(script)) {
displayName = upsideDownTranslator.translate(englishDisplayNames.get(script));
} else {
displayName = new Locale.Builder()
.setScript(script)
.build()
.getDisplayScript(currentLanguage);
if (displayName.equals(script)
|| (!currentLanguage.equals(Locale.ENGLISH) && displayName.equals(englishDisplayNames.get(script))))
continue; // Skip
}
}
if (currentLanguage.equals(Locale.ENGLISH))
englishDisplayNames.put(script, displayName);
writer.write(script + "=" + displayName + "\n");
}
}
}
}
}

View File

@ -0,0 +1,155 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.gradle.l10n;
import org.gradle.api.DefaultTask;
import org.gradle.api.file.RegularFileProperty;
import org.gradle.api.tasks.InputFile;
import org.gradle.api.tasks.OutputFile;
import org.gradle.api.tasks.TaskAction;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Properties;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/// @author Glavo
public abstract class UpsideDownTranslate extends DefaultTask {
static final Locale EN_QABS = Locale.forLanguageTag("en-Qabs");
private static final Map<String, String> PROPERTIES = Map.of(
"datetime.format", "MMM d, yyyy, h:mm:ss a"
);
@InputFile
public abstract RegularFileProperty getInputFile();
@OutputFile
public abstract RegularFileProperty getOutputFile();
@TaskAction
public void run() throws IOException {
Path inputFile = getInputFile().get().getAsFile().toPath();
Path outputFile = getOutputFile().get().getAsFile().toPath();
Properties english = new Properties();
try (var reader = Files.newBufferedReader(inputFile)) {
english.load(reader);
}
Properties output = new Properties();
Translator translator = new Translator();
english.forEach((k, v) -> {
if (PROPERTIES.containsKey(k.toString())) {
output.setProperty(k.toString(), PROPERTIES.get(k.toString()));
} else {
output.put(k, translator.translate(v.toString()));
}
});
Files.createDirectories(outputFile.getParent());
try (var writer = Files.newBufferedWriter(outputFile)) {
output.store(writer, "This file is automatically generated, please do not modify it manually");
}
}
static final class Translator {
private static final Map<Integer, Integer> MAPPER = new LinkedHashMap<>();
private static void putChars(char baseChar, String upsideDownChars) {
for (int i = 0; i < upsideDownChars.length(); i++) {
MAPPER.put(baseChar + i, (int) upsideDownChars.charAt(i));
}
}
private static void putChars(String baseChars, String upsideDownChars) {
if (baseChars.length() != upsideDownChars.length()) {
throw new IllegalArgumentException("baseChars and upsideDownChars must have same length");
}
for (int i = 0; i < baseChars.length(); i++) {
MAPPER.put((int) baseChars.charAt(i), (int) upsideDownChars.charAt(i));
}
}
static {
putChars('a', "ɐqɔpǝɟbɥıظʞןɯuodbɹsʇnʌʍxʎz");
putChars('A', "ⱯᗺƆᗡƎℲ⅁HIſʞꞀWNOԀὉᴚS⟘∩ΛMXʎZ");
putChars('0', "0ƖᘔƐㄣϛ9ㄥ86");
putChars("_,;.?!/\\'", "‾'⸵˙¿¡/\\,");
}
private static final Pattern FORMAT_PATTERN = Pattern.compile("^%(\\d\\$)?(\\d+)?(\\.\\d+)?([sdf])");
private static final Pattern XML_TAG_PATTERN = Pattern.compile("^<(?<tag>[a-zA-Z]+)( href=\"[^\"]*\")?>");
private final StringBuilder resultBuilder = new StringBuilder();
private void appendToLineBuilder(String input) {
for (int i = 0; i < input.length(); ) {
int ch = input.codePointAt(i);
if (ch == '%') {
Matcher matcher = FORMAT_PATTERN.matcher(input).region(i, input.length());
if (matcher.find()) {
String formatString = matcher.group();
resultBuilder.insert(0, formatString);
i += formatString.length();
continue;
}
} else if (ch == '<') {
Matcher matcher = XML_TAG_PATTERN.matcher(input).region(i, input.length());
if (matcher.find()) {
String beginTag = matcher.group();
String endTag = "</" + matcher.group(1) + ">";
int endTagOffset = input.indexOf(endTag, i + beginTag.length());
if (endTagOffset > 0) {
resultBuilder.insert(0, endTag);
appendToLineBuilder(input.substring(i + beginTag.length(), endTagOffset));
resultBuilder.insert(0, beginTag);
i = endTagOffset + endTag.length();
continue;
}
}
}
int udCh = MAPPER.getOrDefault(ch, ch);
if (Character.isBmpCodePoint(udCh)) {
resultBuilder.insert(0, (char) udCh);
} else {
resultBuilder.insert(0, Character.toChars(udCh));
}
i += Character.charCount(ch);
}
}
String translate(String input) {
resultBuilder.setLength(0);
appendToLineBuilder(input);
return resultBuilder.toString();
}
}
}

View File

@ -0,0 +1,23 @@
#
# Hello Minecraft! Launcher
# Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
#
# Languages
lzh=Classical Chinese
# Scripts
Qabs=Upside down

View File

@ -0,0 +1,23 @@
#
# Hello Minecraft! Launcher
# Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
#
# Languages
lzh=文言
# Scripts
Qabs=颠倒

View File

@ -13,6 +13,7 @@ HMCL 为多种语言提供本地化支持。
| 语言 | 语言标签 | 首选本地化文件后缀 | 首选本地化键 | [游戏语言文件](https://minecraft.wiki/w/Language) | 支持状态 | 志愿者 |
|---------|-----------|------------|-----------|---------------------------------------------|--------|-------------------------------------------|
| 英语 | `en` | (空) | `default` | `en_us` | **主要** | [Glavo](https://github.com/Glavo) |
| 英语 (颠倒) | `en-Qabs` | `en_Qabs` | `en-Qabs` | `en_ud` | 自动 | |
| 中文 (简体) | `zh-Hans` | `_zh` | `zh` | `zh_cn` | **主要** | [Glavo](https://github.com/Glavo) |
| 中文 (繁体) | `zh-Hant` | `_zh_Hant` | `zh-Hant` | `zh_tw` <br/> `zh_hk` | **主要** | [Glavo](https://github.com/Glavo) |
| 中文 (文言) | `lzh` | `_lzh` | `lzh` | `lzh` | 次要 | |
@ -56,7 +57,8 @@ HMCL 欢迎任何人参与翻译和贡献。但是维护更多语言的翻译需
如果你想为 HMCL 添加新的语言支持,请从翻译 [`I18N.properties`](../HMCL/src/main/resources/assets/lang/I18N.properties) 开始。
HMCL 的绝大多数文本都位于这个文件中,翻译此文件就能翻译整个界面。
这是一个 Java Properties 文件,格式非常简单。在翻译前请先阅读该格式的介绍: [Properties 文件](https://en.wikipedia.org/wiki/.properties)。
这是一个 Java Properties 文件,格式非常简单。
在翻译前请先阅读该格式的介绍: [Properties 文件](https://en.wikipedia.org/wiki/.properties)。
作为翻译的第一步,请从[这张表格](https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes)中查询这个语言对应的两字母或三字母语言标签。
例如,英语的语言标签为 `en`