支持颠倒的英语 (#4527)

This commit is contained in:
Glavo
2025-09-22 22:07:06 +08:00
committed by GitHub
parent cbe6554390
commit 36d71bd14e
28 changed files with 1488 additions and 332 deletions

View File

@@ -1071,6 +1071,7 @@ ij_jsp_keep_indents_on_empty_lines = false
ij_jspx_keep_indents_on_empty_lines = false
[{*.markdown,*.md}]
max_line_length = 200
ij_markdown_force_one_space_after_blockquote_symbol = true
ij_markdown_force_one_space_after_header_symbol = true
ij_markdown_force_one_space_after_list_bullet = true

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.CreateLocaleNamesResourceBundle
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(createLocaleNamesResourceBundle)
dependsOn(createLanguageList)
into("assets/") {
from(hmclPropertiesFile)
from(embedResources)
}
into("assets/lang") {
from(createLanguageList.map { it.outputFile })
from(upsideDownTranslate.map { it.outputFile })
from(createLocaleNamesResourceBundle.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 createLocaleNamesResourceBundle by tasks.registering(CreateLocaleNamesResourceBundle::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
@@ -112,7 +110,7 @@ public final class HMCLGameLauncher extends DefaultLauncher {
private static String normalizedLanguageTag(Locale locale, GameVersionNumber gameVersion) {
String region = locale.getCountry();
return switch (LocaleUtils.getISO1Language(locale)) {
return switch (LocaleUtils.getISO2Language(locale)) {
case "es" -> "es_ES";
case "ja" -> "ja_JP";
case "ru" -> "ru_RU";
@@ -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.isBlank() || "def".equals(name) || "default".equals(name))
return DEFAULT;
return getLocale(Locale.forLanguageTag(name.trim().replace('_', '-')));
}
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.getISO2Language(this.getLocale())
.equals(LocaleUtils.getISO2Language(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

@@ -30,7 +30,7 @@ import java.util.ResourceBundle;
/// - For all Chinese locales, `zh-CN` is always added to the candidate list. If `zh-Hans` already exists in the candidate list,
/// `zh-CN` is inserted before `zh`; otherwise, it is inserted after `zh`.
/// - For all Traditional Chinese locales, `zh-TW` is always added to the candidate list (before `zh`).
/// - For all [supported][LocaleUtils#mapToISO1Language(String)] ISO 639-3 language code (such as `eng`, `zho`, `lzh`, etc.),
/// - For all [supported][LocaleUtils#mapToISO2Language(String)] ISO 639-3 language code (such as `eng`, `zho`, `lzh`, etc.),
/// a candidate list with the language code replaced by the ISO 639-1 (Macro)language code is added to the end of the candidate list.
///
/// @author Glavo
@@ -45,4 +45,11 @@ public class DefaultResourceBundleControl extends ResourceBundle.Control {
public List<Locale> getCandidateLocales(String baseName, Locale locale) {
return LocaleUtils.getCandidateLocales(locale);
}
@Override
public Locale getFallbackLocale(String baseName, Locale locale) {
// By default, when only the base bundle is found, it will attempt to fall back to Locale.getDefault() for further lookup.
// Since we always use the base bundle as the English resource file, we want to suppress this behavior.
return null;
}
}

View File

@@ -23,6 +23,8 @@ import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
@@ -44,6 +46,56 @@ public final class LocaleUtils {
public static final String DEFAULT_LANGUAGE_KEY = "default";
private static final Map<String, String> subLanguageToParent = new HashMap<>();
private static final Map<String, String> iso3To2 = new HashMap<>();
static {
try (InputStream input = LocaleUtils.class.getResourceAsStream("/assets/lang/sublanguages.csv")) {
if (input != null) {
new String(input.readAllBytes()).lines()
.filter(line -> !line.startsWith("#") && !line.isBlank())
.forEach(line -> {
String[] languages = line.split(",");
if (languages.length < 2)
LOG.warning("Invalid line in sublanguages.csv: " + line);
String parent = languages[0];
for (int i = 1; i < languages.length; i++) {
subLanguageToParent.put(languages[i], parent);
}
});
}
} catch (Throwable e) {
LOG.warning("Failed to load sublanguages.csv", e);
}
// Line Format:
// (?<iso2>[a-z]{2}),(?<iso3>[a-z]{3})
try (InputStream input = LocaleUtils.class.getResourceAsStream("/assets/lang/iso_languages.csv")) {
if (input != null) {
int lineLength = 2 + 1 + 3;
byte[] bytes = input.readAllBytes();
for (int offset = 0; offset < bytes.length; ) {
if (offset > bytes.length - lineLength)
break;
if (bytes[offset + 2] != ',')
throw new IOException("iso_languages.csv format invalid");
String iso2 = new String(bytes, offset, 2, StandardCharsets.US_ASCII);
String iso3 = new String(bytes, offset + 3, 3, StandardCharsets.US_ASCII);
iso3To2.put(iso3, iso2);
offset += (lineLength + 1);
}
}
} catch (Throwable e) {
LOG.warning("Failed to load iso_languages.csv", e);
}
}
private static Locale getInstance(String language, String script, String region,
String variant) {
Locale.Builder builder = new Locale.Builder();
@@ -64,7 +116,7 @@ public final class LocaleUtils {
: locale.stripExtensions().toLanguageTag();
}
public static @NotNull String getISO1Language(Locale locale) {
public static @NotNull String getISO2Language(Locale locale) {
String language = locale.getLanguage();
if (language.isEmpty()) return "en";
if (language.length() <= 2)
@@ -75,7 +127,7 @@ public final class LocaleUtils {
if (lang.length() <= 2)
return lang;
else {
String iso1 = mapToISO1Language(lang);
String iso1 = mapToISO2Language(lang);
if (iso1 != null)
return iso1;
}
@@ -88,6 +140,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";
@@ -130,7 +188,7 @@ public final class LocaleUtils {
} else if (language.length() <= 2) {
languages = List.of(language);
} else {
String iso1Language = mapToISO1Language(language);
String iso1Language = mapToISO2Language(language);
languages = iso1Language != null
? List.of(language, iso1Language)
: List.of(language);
@@ -294,37 +352,26 @@ public final class LocaleUtils {
// ---
/// Map ISO 639-3 language codes to ISO 639-1 language codes.
public static @Nullable String mapToISO1Language(String iso3Language) {
return switch (iso3Language) {
case "eng" -> "en";
case "spa" -> "es";
case "jpa" -> "ja";
case "rus" -> "ru";
case "ukr" -> "uk";
case "zho" -> "zh";
default -> null;
};
/// Map ISO 639 alpha-3 language codes to ISO 639 alpha-2 language codes.
public static @Nullable String mapToISO2Language(String iso3Language) {
return iso3To2.get(iso3Language);
}
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) {
return "en".equals(getISO1Language(locale));
return "en".equals(getISO2Language(locale));
}
public static final Set<String> CHINESE_TRADITIONAL_REGIONS = Set.of("TW", "HK", "MO");
public static final Set<String> CHINESE_LATN_VARIANTS = Set.of("pinyin", "wadegile", "tongyong");
public static boolean isChinese(Locale locale) {
return "zh".equals(getISO1Language(locale));
return "zh".equals(getISO2Language(locale));
}
private LocaleUtils() {

View File

@@ -0,0 +1,187 @@
aa,aar
ab,abk
ae,ave
af,afr
ak,aka
am,amh
an,arg
ar,ara
as,asm
av,ava
ay,aym
az,aze
ba,bak
be,bel
bg,bul
bh,bih
bi,bis
bm,bam
bn,ben
bo,bod
br,bre
bs,bos
ca,cat
ce,che
ch,cha
co,cos
cr,cre
cs,ces
cu,chu
cv,chv
cy,cym
da,dan
de,deu
dv,div
dz,dzo
ee,ewe
el,ell
en,eng
eo,epo
es,spa
et,est
eu,eus
fa,fas
ff,ful
fi,fin
fj,fij
fo,fao
fr,fra
fy,fry
ga,gle
gd,gla
gl,glg
gn,grn
gu,guj
gv,glv
ha,hau
he,heb
hi,hin
ho,hmo
hr,hrv
ht,hat
hu,hun
hy,hye
hz,her
ia,ina
id,ind
ie,ile
ig,ibo
ii,iii
ik,ipk
in,ind
io,ido
is,isl
it,ita
iu,iku
iw,heb
ja,jpn
ji,yid
jv,jav
ka,kat
kg,kon
ki,kik
kj,kua
kk,kaz
kl,kal
km,khm
kn,kan
ko,kor
kr,kau
ks,kas
ku,kur
kv,kom
kw,cor
ky,kir
la,lat
lb,ltz
lg,lug
li,lim
ln,lin
lo,lao
lt,lit
lu,lub
lv,lav
mg,mlg
mh,mah
mi,mri
mk,mkd
ml,mal
mn,mon
mo,mol
mr,mar
ms,msa
mt,mlt
my,mya
na,nau
nb,nob
nd,nde
ne,nep
ng,ndo
nl,nld
nn,nno
no,nor
nr,nbl
nv,nav
ny,nya
oc,oci
oj,oji
om,orm
or,ori
os,oss
pa,pan
pi,pli
pl,pol
ps,pus
pt,por
qu,que
rm,roh
rn,run
ro,ron
ru,rus
rw,kin
sa,san
sc,srd
sd,snd
se,sme
sg,sag
si,sin
sk,slk
sl,slv
sm,smo
sn,sna
so,som
sq,sqi
sr,srp
ss,ssw
st,sot
su,sun
sv,swe
sw,swa
ta,tam
te,tel
tg,tgk
th,tha
ti,tir
tk,tuk
tl,tgl
tn,tsn
to,ton
tr,tur
ts,tso
tt,tat
tw,twi
ty,tah
ug,uig
uk,ukr
ur,urd
uz,uzb
ve,ven
vi,vie
vo,vol
wa,wln
wo,wol
xh,xho
yi,yid
za,zha
zh,zho
zu,zul
1 aa aar
2 ab abk
3 ae ave
4 af afr
5 ak aka
6 am amh
7 an arg
8 ar ara
9 as asm
10 av ava
11 ay aym
12 az aze
13 ba bak
14 be bel
15 bg bul
16 bh bih
17 bi bis
18 bm bam
19 bn ben
20 bo bod
21 br bre
22 bs bos
23 ca cat
24 ce che
25 ch cha
26 co cos
27 cr cre
28 cs ces
29 cu chu
30 cv chv
31 cy cym
32 da dan
33 de deu
34 dv div
35 dz dzo
36 ee ewe
37 el ell
38 en eng
39 eo epo
40 es spa
41 et est
42 eu eus
43 fa fas
44 ff ful
45 fi fin
46 fj fij
47 fo fao
48 fr fra
49 fy fry
50 ga gle
51 gd gla
52 gl glg
53 gn grn
54 gu guj
55 gv glv
56 ha hau
57 he heb
58 hi hin
59 ho hmo
60 hr hrv
61 ht hat
62 hu hun
63 hy hye
64 hz her
65 ia ina
66 id ind
67 ie ile
68 ig ibo
69 ii iii
70 ik ipk
71 in ind
72 io ido
73 is isl
74 it ita
75 iu iku
76 iw heb
77 ja jpn
78 ji yid
79 jv jav
80 ka kat
81 kg kon
82 ki kik
83 kj kua
84 kk kaz
85 kl kal
86 km khm
87 kn kan
88 ko kor
89 kr kau
90 ks kas
91 ku kur
92 kv kom
93 kw cor
94 ky kir
95 la lat
96 lb ltz
97 lg lug
98 li lim
99 ln lin
100 lo lao
101 lt lit
102 lu lub
103 lv lav
104 mg mlg
105 mh mah
106 mi mri
107 mk mkd
108 ml mal
109 mn mon
110 mo mol
111 mr mar
112 ms msa
113 mt mlt
114 my mya
115 na nau
116 nb nob
117 nd nde
118 ne nep
119 ng ndo
120 nl nld
121 nn nno
122 no nor
123 nr nbl
124 nv nav
125 ny nya
126 oc oci
127 oj oji
128 om orm
129 or ori
130 os oss
131 pa pan
132 pi pli
133 pl pol
134 ps pus
135 pt por
136 qu que
137 rm roh
138 rn run
139 ro ron
140 ru rus
141 rw kin
142 sa san
143 sc srd
144 sd snd
145 se sme
146 sg sag
147 si sin
148 sk slk
149 sl slv
150 sm smo
151 sn sna
152 so som
153 sq sqi
154 sr srp
155 ss ssw
156 st sot
157 su sun
158 sv swe
159 sw swa
160 ta tam
161 te tel
162 tg tgk
163 th tha
164 ti tir
165 tk tuk
166 tl tgl
167 tn tsn
168 to ton
169 tr tur
170 ts tso
171 tt tat
172 tw twi
173 ty tah
174 ug uig
175 uk ukr
176 ur urd
177 uz uzb
178 ve ven
179 vi vie
180 vo vol
181 wa wln
182 wo wol
183 xh xho
184 yi yid
185 za zha
186 zh zho
187 zu zul

View File

@@ -0,0 +1 @@
zh,cmn,lzh,cdo,cjy,cpx,czh,gan,hak,hsn,mnp,nan,wuu,yue
1 zh cmn lzh cdo cjy cpx czh gan hak hsn mnp nan wuu yue

View File

@@ -72,8 +72,8 @@ public final class LocaleUtilsTest {
assertCandidateLocales("ja", List.of("ja", "und"));
assertCandidateLocales("ja-JP", List.of("ja-JP", "ja", "und"));
assertCandidateLocales("jpa", List.of("jpa", "ja", "und"));
assertCandidateLocales("jpa-JP", List.of("jpa-JP", "ja-JP", "jpa", "ja", "und"));
assertCandidateLocales("jpn", List.of("jpn", "ja", "und"));
assertCandidateLocales("jpn-JP", List.of("jpn-JP", "ja-JP", "jpn", "ja", "und"));
assertCandidateLocales("en", List.of("en", "und"));
assertCandidateLocales("en-US", List.of("en-US", "en", "und"));
@@ -190,4 +190,21 @@ public final class LocaleUtilsTest {
LocaleUtils.findAllLocalizedFiles(testDir, "meow", Set.of("json", "toml")));
}
}
@Test
public void testMapToISO2Language() throws IOException {
assertEquals("en", LocaleUtils.mapToISO2Language("eng"));
assertEquals("es", LocaleUtils.mapToISO2Language("spa"));
assertEquals("ja", LocaleUtils.mapToISO2Language("jpn"));
assertEquals("ru", LocaleUtils.mapToISO2Language("rus"));
assertEquals("uk", LocaleUtils.mapToISO2Language("ukr"));
assertEquals("zh", LocaleUtils.mapToISO2Language("zho"));
assertEquals("zu", LocaleUtils.mapToISO2Language("zul"));
assertNull(LocaleUtils.mapToISO2Language(null));
assertNull(LocaleUtils.mapToISO2Language(""));
assertNull(LocaleUtils.mapToISO2Language("cmn"));
assertNull(LocaleUtils.mapToISO2Language("lzh"));
assertNull(LocaleUtils.mapToISO2Language("tlh"));
}
}

View File

@@ -10,4 +10,10 @@ dependencies {
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
tasks.processResources {
into("org/jackhuang/hmcl/gradle/l10n") {
from(projectDir.resolve("../HMCLCore/src/main/resources/assets/lang/"))
}
}

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,102 @@
/*
* 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.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<>(LocalizationUtils::compareLocale);
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(", ", "[", "]")));
}
}

View File

@@ -0,0 +1,298 @@
/*
* 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.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.*;
import java.util.stream.Collectors;
/// @author Glavo
public abstract class CreateLocaleNamesResourceBundle extends DefaultTask {
@InputFile
public abstract RegularFileProperty getLanguagesFile();
@OutputDirectory
public abstract DirectoryProperty getOutputDirectory();
private static String mapToFileName(String base, String ext, Locale locale) {
if (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;
}
private static final ResourceBundle.Control CONTROL = new ResourceBundle.Control() {
};
@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)
.sorted(LocalizationUtils::compareLocale)
.toList();
}
if (!supportedLanguages.contains(Locale.ENGLISH))
throw new GradleException("Missing english in supported languages: " + supportedLanguages);
// Ensure English is at the first position, this assumption will be used later
if (!supportedLanguages.get(0).equals(Locale.ENGLISH)) {
supportedLanguages = new ArrayList<>(supportedLanguages);
supportedLanguages.remove(Locale.ENGLISH);
supportedLanguages.add(0, Locale.ENGLISH);
}
EnumMap<LocaleField, SortedSet<String>> names = new EnumMap<>(LocaleField.class);
for (LocaleField field : LocaleField.values()) {
names.put(field, supportedLanguages.stream()
.map(field::get)
.filter(it -> !it.isBlank())
.collect(Collectors.toCollection(() -> new TreeSet<>(field))));
}
Map<Locale, Properties> overrides = new HashMap<>();
for (Locale currentLanguage : supportedLanguages) {
InputStream overrideFile = CreateLocaleNamesResourceBundle.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);
}
}
overrides.put(currentLanguage, overrideProperties);
}
Map<Locale, LocaleNames> allLocaleNames = new HashMap<>();
// For Upside Down English
UpsideDownTranslate.Translator upsideDownTranslator = new UpsideDownTranslate.Translator();
for (Locale currentLocale : supportedLanguages) {
Properties currentOverrides = overrides.get(currentLocale);
if (currentLocale.getLanguage().length() > 2 && currentOverrides.isEmpty()) {
// The JDK does not provide localized texts for these languages
continue;
}
LocaleNames currentDisplayNames = new LocaleNames();
for (LocaleField field : LocaleField.values()) {
SortedMap<String, String> nameToDisplayName = currentDisplayNames.getNameToDisplayName(field);
loop:
for (String name : names.get(field)) {
String displayName = currentOverrides.getProperty(name);
getDisplayName:
if (displayName == null) {
if (currentLocale.equals(UpsideDownTranslate.EN_QABS)) {
String englishDisplayName = allLocaleNames.get(Locale.ENGLISH).getNameToDisplayName(field).get(name);
if (englishDisplayName != null) {
displayName = upsideDownTranslator.translate(englishDisplayName);
break getDisplayName;
}
}
// Although it cannot correctly handle the inheritance relationship between languages,
// we will not apply this function to sublanguages.
List<Locale> candidateLocales = CONTROL.getCandidateLocales("", currentLocale);
for (Locale candidateLocale : candidateLocales) {
Properties candidateOverride = overrides.get(candidateLocale);
if (candidateOverride != null && candidateOverride.containsKey(name)) {
continue loop;
}
}
displayName = field.getDisplayName(name, currentLocale);
// JDK does not have a built-in translation
if (displayName.isBlank() || displayName.equals(name)) {
continue loop;
}
// If it is just a duplicate of the parent content, ignored it
for (Locale candidateLocale : candidateLocales) {
LocaleNames candidateLocaleNames = allLocaleNames.get(candidateLocale);
if (candidateLocaleNames != null) {
String candidateDisplayName = candidateLocaleNames.getNameToDisplayName(field).get(name);
if (displayName.equals(candidateDisplayName)) {
continue loop;
}
break;
}
}
// Ignore it if the JDK falls back to English when querying the display name
if (!currentLocale.equals(Locale.ENGLISH)
&& displayName.equals(allLocaleNames.get(Locale.ENGLISH).getNameToDisplayName(field).get(name))) {
continue loop;
}
}
nameToDisplayName.put(name, displayName);
}
}
allLocaleNames.put(currentLocale, currentDisplayNames);
}
for (Map.Entry<Locale, LocaleNames> entry : allLocaleNames.entrySet()) {
if (!entry.getValue().isEmpty()) {
Path targetFile = outputDir.resolve(mapToFileName("LocaleNames", "properties", entry.getKey()));
entry.getValue().writeTo(targetFile);
}
}
}
private static final class LocaleNames {
private final EnumMap<LocaleField, SortedMap<String, String>> displayNames = new EnumMap<>(LocaleField.class);
LocaleNames() {
for (LocaleField field : LocaleField.values()) {
displayNames.put(field, new TreeMap<>(field));
}
}
boolean isEmpty() {
return displayNames.values().stream().allMatch(Map::isEmpty);
}
SortedMap<String, String> getNameToDisplayName(LocaleField field) {
return displayNames.get(field);
}
void writeTo(Path file) throws IOException {
try (var writer = Files.newBufferedWriter(file, StandardOpenOption.CREATE_NEW)) {
boolean firstBlock = true;
for (var entry : displayNames.entrySet()) {
LocaleField field = entry.getKey();
SortedMap<String, String> values = entry.getValue();
if (!values.isEmpty()) {
if (firstBlock)
firstBlock = false;
else
writer.newLine();
writer.write("# " + field.blockHeader + "\n");
for (var nameToDisplay : values.entrySet()) {
writer.write(nameToDisplay.getKey() + "=" + nameToDisplay.getValue() + "\n");
}
}
}
}
}
}
private enum LocaleField implements Comparator<String> {
LANGUAGE("Languages") {
@Override
public String get(Locale locale) {
return locale.getLanguage();
}
@Override
public String getDisplayName(String fieldValue, Locale inLocale) {
return new Locale.Builder()
.setLanguage(fieldValue)
.build()
.getDisplayLanguage(inLocale);
}
@Override
public int compare(String l1, String l2) {
return LocalizationUtils.compareLanguage(l1, l2);
}
},
SCRIPT("Scripts") {
@Override
public String get(Locale locale) {
return locale.getScript();
}
@Override
public String getDisplayName(String fieldValue, Locale inLocale) {
return new Locale.Builder()
.setScript(fieldValue)
.build()
.getDisplayScript(inLocale);
}
@Override
public int compare(String s1, String s2) {
return LocalizationUtils.compareScript(s1, s2);
}
};
final String blockHeader;
LocaleField(String blockHeader) {
this.blockHeader = blockHeader;
}
public abstract String get(Locale locale);
public abstract String getDisplayName(String fieldValue, Locale inLocale);
}
}

View File

@@ -0,0 +1,110 @@
/*
* 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.GradleException;
import java.io.IOException;
import java.io.InputStream;
import java.util.*;
final class LocalizationUtils {
public static final Map<String, String> subLanguageToParent;
static {
InputStream input = LocalizationUtils.class.getResourceAsStream("sublanguages.csv");
if (input == null)
throw new GradleException("Missing sublanguages.csv file");
Map<String, String> map = new HashMap<>();
try (input) {
new String(input.readAllBytes()).lines()
.filter(line -> !line.startsWith("#") && !line.isBlank())
.forEach(line -> {
String[] languages = line.split(",");
if (languages.length < 2)
throw new GradleException("Invalid line in sublanguages.csv: " + line);
String parent = languages[0];
for (int i = 1; i < languages.length; i++) {
map.put(languages[i], parent);
}
});
} catch (IOException e) {
throw new GradleException("Failed to read sublanguages.csv", e);
}
subLanguageToParent = Collections.unmodifiableMap(map);
}
private static 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;
}
}
}
public static 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());
}
public static int compareScript(String s1, String s2) {
return s1.compareTo(s2);
}
public static int compareLocale(Locale l1, Locale l2) {
int c = compareLanguage(l1.getLanguage(), l2.getLanguage());
if (c != 0)
return c;
c = compareScript(l1.getScript(), 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());
}
private LocalizationUtils() {
}
}

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

@@ -0,0 +1,20 @@
#
# 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=文言

View File

@@ -10,16 +10,63 @@ HMCL 为多种语言提供本地化支持。
目前HMCL 为这些语言提供支持:
| 语言 | 语言标签 | 首选本地化文件后缀 | 首选本地化 | [游戏语言文件](https://minecraft.wiki/w/Language) | 支持状态 | 志愿者 |
|---------|-----------|------------|-----------|---------------------------------------------|--------|-------------------------------------------|
| 英语 | `en` | (空) | `default` | `en_us` | **主要** | [Glavo](https://github.com/Glavo) |
| 中文 (简体) | `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` | 次要 | |
| 日语 | `ja` | `_ja` | `ja` | `ja_jp` | 次要 | |
| 西班牙语 | `es` | `_es` | `es` | `es_es` | 次要 | [3gf8jv4dv](https://github.com/3gf8jv4dv) |
| 俄语 | `ru` | `_ru` | `ru` | `ru_ru` | 次要 | [3gf8jv4dv](https://github.com/3gf8jv4dv) |
| 乌克兰语 | `uk` | `_uk` | `uk` | `uk_ua` | 次要 | |
| 语言 | 语言标签 | 首选本地化 | 首选本地化文件后缀 | [游戏语言文件](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` | 次要 | |
| 日语 | `ja` | `ja` | `_ja` | `ja_jp` | 次要 | |
| 西班牙语 | `es` | `es` | `_es` | `es_es` | 次要 | [3gf8jv4dv](https://github.com/3gf8jv4dv) |
| 俄语 | `ru` | `ru` | `_ru` | `ru_ru` | 次要 | [3gf8jv4dv](https://github.com/3gf8jv4dv) |
| 乌克兰语 | `uk` | `uk` | `_uk` | `uk_ua` | 次要 | |
<details>
<summary>关于语言标签</summary>
HMCL 使用符合 IETF BCP 47 规范的语言标签。
对于 ISO 639 标准中定义的语言,如果同时存在两字母语言代码和三字母语言代码,那么应当优先选择两字母语言代码。
例如,对于英语,我们使用 `en` 而不是 `eng` 作为语言代码。
对于 Minecraft 所定义的非标准语言,应当优先使用语言文件的 `language.code` 中定义的代码,而非游戏语言文件的名称
(但对于存在两字母代码的语言,应当将三字母语言代码替换为对应的两字母语言代码)。
这是因为 Minecraft 有时候会用现实中实际存在的国家/地区代码来表示虚构语言 (比如说海盗英语的语言文件为 `en_pt`,但 `PT` 其实是葡萄牙的国家代码)。
例如,对于颠倒的英语,我们使用 `en-Qabs` 作为语言代码,而不是 `en-UD`
此外,语言代码中应当尽可能选择地区中立的语言标签。
例如,对于简体中文和繁体中文,我们使用 `zh-Hans``zh-Hant` 作为语言代码,而不是 `zh-CN``zh-TW`
</details>
<details>
<summary>关于本地化键和本地化文件后缀</summary>
本地化文件后缀和本地化键用于为[本地化资源](#本地化资源)命名。
通常来说,本地化键就是这份本地化资源对应的语言代码,而本地化文件后缀是将语言代码中的 `-` 替换为 `_`,并加上一个前缀下划线得到的。
作为特例,对于默认的资源,本地化键为 `default`,本地化文件后缀为空。
由于[资源回退机制](#资源回退机制)的存在。
如果没有完全匹配当前语言环境的资源HMCL 会根据当前环境的语言标签推导出一个搜索列表,根据该列表依次搜索资源。
我们建议在提供本地化资源时,总是提供默认资源 (对应 `default` 本地化键和空的本地化文件后缀)
以确保所有用户都能正常加载资源。
并且我们建议尽可能为本地化资源使用更宽泛的语言标签,使用户更不容易回退到默认资源上。
例如,如果你提供了一份简体中文的本地化资源,那么我们推荐使用 `zh` 作为本地化键,而不是更具体的 `zh-Hans`
这样它会对于所有使用中文的用户生效,避免对于这些用户回退到默认资源上。
如果你想同时提供简体中文和繁体中文的资源,那么推荐对用户占比更高的资源使用更宽泛的 `zh` 作为本地化键,使其作为默认的中文资源,
而对用户占比更低的资源使用更具体的 `zh-Hans`/`zh-Hant` 作为本地化键。
</details>
HMCL 会要求所有 Pull Request 在更新文档和本地化资源时同步更新所有**主要**支持的语言对应的资源。
如果 PR 作者对相关语言并不了解,那么可以直接在评论中提出翻译请求,
@@ -56,7 +103,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`